Merge main and harden managed Codex account flow

This commit is contained in:
SaladDay
2026-07-18 10:06:58 +00:00
276 changed files with 32101 additions and 3292 deletions
+11 -1
View File
@@ -758,7 +758,7 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.16.4"
version = "3.17.0"
dependencies = [
"anyhow",
"arboard",
@@ -799,6 +799,7 @@ dependencies = [
"serde_yaml",
"serial_test",
"sha2",
"sys-locale",
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
@@ -5438,6 +5439,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "sys-locale"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"libc",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.16.4"
version = "3.17.0"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"
@@ -81,6 +81,7 @@ sha2 = "0.10"
hmac = "0.12"
json5 = "0.4"
json-five = "0.3.1"
sys-locale = "0.3"
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
tauri-plugin-single-instance = "2"
+71 -4
View File
@@ -14,6 +14,8 @@ pub struct McpApps {
#[serde(default)]
pub gemini: bool,
#[serde(default)]
pub grokbuild: bool,
#[serde(default)]
pub opencode: bool,
#[serde(default)]
pub hermes: bool,
@@ -26,6 +28,7 @@ impl McpApps {
AppType::Claude => self.claude,
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
AppType::GrokBuild => self.grokbuild,
AppType::OpenCode => self.opencode,
AppType::OpenClaw => false, // OpenClaw doesn't support MCP
AppType::Hermes => self.hermes,
@@ -39,6 +42,7 @@ impl McpApps {
AppType::Claude => self.claude = enabled,
AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled,
AppType::GrokBuild => self.grokbuild = enabled,
AppType::OpenCode => self.opencode = enabled,
AppType::OpenClaw => {} // OpenClaw doesn't support MCP, ignore
AppType::Hermes => self.hermes = enabled,
@@ -58,6 +62,9 @@ impl McpApps {
if self.gemini {
apps.push(AppType::Gemini);
}
if self.grokbuild {
apps.push(AppType::GrokBuild);
}
if self.opencode {
apps.push(AppType::OpenCode);
}
@@ -69,7 +76,12 @@ impl McpApps {
/// 检查是否所有应用都未启用
pub fn is_empty(&self) -> bool {
!self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes
!self.claude
&& !self.codex
&& !self.gemini
&& !self.grokbuild
&& !self.opencode
&& !self.hermes
}
}
@@ -83,6 +95,8 @@ pub struct SkillApps {
#[serde(default)]
pub gemini: bool,
#[serde(default)]
pub grokbuild: bool,
#[serde(default)]
pub opencode: bool,
#[serde(default)]
pub hermes: bool,
@@ -95,6 +109,7 @@ impl SkillApps {
AppType::Claude => self.claude,
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
AppType::GrokBuild => self.grokbuild,
AppType::OpenCode => self.opencode,
AppType::Hermes => self.hermes,
AppType::OpenClaw => false, // OpenClaw doesn't support Skills
@@ -108,6 +123,7 @@ impl SkillApps {
AppType::Claude => self.claude = enabled,
AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled,
AppType::GrokBuild => self.grokbuild = enabled,
AppType::OpenCode => self.opencode = enabled,
AppType::Hermes => self.hermes = enabled,
AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore
@@ -127,6 +143,9 @@ impl SkillApps {
if self.gemini {
apps.push(AppType::Gemini);
}
if self.grokbuild {
apps.push(AppType::GrokBuild);
}
if self.opencode {
apps.push(AppType::OpenCode);
}
@@ -138,7 +157,12 @@ impl SkillApps {
/// 检查是否所有应用都未启用
pub fn is_empty(&self) -> bool {
!self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes
!self.claude
&& !self.codex
&& !self.gemini
&& !self.grokbuild
&& !self.opencode
&& !self.hermes
}
/// 仅启用指定应用(其他应用设为禁用)
@@ -271,6 +295,8 @@ pub struct McpRoot {
pub codex: McpConfig,
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub gemini: McpConfig,
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub grokbuild: McpConfig,
/// OpenCode MCP 配置(v4.0.0+,实际使用 opencode.json
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub opencode: McpConfig,
@@ -292,6 +318,7 @@ impl Default for McpRoot {
claude_desktop: McpConfig::default(),
codex: McpConfig::default(),
gemini: McpConfig::default(),
grokbuild: McpConfig::default(),
opencode: McpConfig::default(),
openclaw: McpConfig::default(),
hermes: McpConfig::default(),
@@ -323,6 +350,8 @@ pub struct PromptRoot {
#[serde(default)]
pub gemini: PromptConfig,
#[serde(default)]
pub grokbuild: PromptConfig,
#[serde(default)]
pub opencode: PromptConfig,
#[serde(default)]
pub openclaw: PromptConfig,
@@ -348,6 +377,7 @@ pub enum AppType {
ClaudeDesktop,
Codex,
Gemini,
GrokBuild,
OpenCode,
OpenClaw,
Hermes,
@@ -360,6 +390,7 @@ impl AppType {
AppType::ClaudeDesktop => "claude-desktop",
AppType::Codex => "codex",
AppType::Gemini => "gemini",
AppType::GrokBuild => "grokbuild",
AppType::OpenCode => "opencode",
AppType::OpenClaw => "openclaw",
AppType::Hermes => "hermes",
@@ -384,6 +415,7 @@ impl AppType {
AppType::ClaudeDesktop,
AppType::Codex,
AppType::Gemini,
AppType::GrokBuild,
AppType::OpenCode,
AppType::OpenClaw,
AppType::Hermes,
@@ -402,13 +434,14 @@ impl FromStr for AppType {
"claude-desktop" | "claude_desktop" | "claudedesktop" => Ok(AppType::ClaudeDesktop),
"codex" => Ok(AppType::Codex),
"gemini" => Ok(AppType::Gemini),
"grokbuild" | "grok-build" | "grok_build" | "grok" => Ok(AppType::GrokBuild),
"opencode" => Ok(AppType::OpenCode),
"openclaw" => Ok(AppType::OpenClaw),
"hermes" => Ok(AppType::Hermes),
other => Err(AppError::localized(
"unsupported_app",
format!("不支持的应用标识: '{other}'。可选值: claude, claude-desktop, codex, gemini, opencode, openclaw, hermes。"),
format!("Unsupported app id: '{other}'. Allowed: claude, claude-desktop, codex, gemini, opencode, openclaw, hermes."),
format!("不支持的应用标识: '{other}'。可选值: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes。"),
format!("Unsupported app id: '{other}'. Allowed: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes."),
)),
}
}
@@ -444,6 +477,7 @@ impl CommonConfigSnippets {
AppType::ClaudeDesktop => None,
AppType::Codex => self.codex.as_ref(),
AppType::Gemini => self.gemini.as_ref(),
AppType::GrokBuild => None,
AppType::OpenCode => self.opencode.as_ref(),
AppType::OpenClaw => self.openclaw.as_ref(),
AppType::Hermes => self.hermes.as_ref(),
@@ -457,6 +491,7 @@ impl CommonConfigSnippets {
AppType::ClaudeDesktop => {}
AppType::Codex => self.codex = snippet,
AppType::Gemini => self.gemini = snippet,
AppType::GrokBuild => {}
AppType::OpenCode => self.opencode = snippet,
AppType::OpenClaw => self.openclaw = snippet,
AppType::Hermes => self.hermes = snippet,
@@ -500,6 +535,7 @@ impl Default for MultiAppConfig {
apps.insert("claude-desktop".to_string(), ProviderManager::default());
apps.insert("codex".to_string(), ProviderManager::default());
apps.insert("gemini".to_string(), ProviderManager::default());
apps.insert("grokbuild".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());
@@ -662,6 +698,7 @@ impl MultiAppConfig {
AppType::ClaudeDesktop => &self.mcp.claude_desktop,
AppType::Codex => &self.mcp.codex,
AppType::Gemini => &self.mcp.gemini,
AppType::GrokBuild => &self.mcp.grokbuild,
AppType::OpenCode => &self.mcp.opencode,
AppType::OpenClaw => &self.mcp.openclaw,
AppType::Hermes => &self.mcp.hermes,
@@ -675,6 +712,7 @@ impl MultiAppConfig {
AppType::ClaudeDesktop => &mut self.mcp.claude_desktop,
AppType::Codex => &mut self.mcp.codex,
AppType::Gemini => &mut self.mcp.gemini,
AppType::GrokBuild => &mut self.mcp.grokbuild,
AppType::OpenCode => &mut self.mcp.opencode,
AppType::OpenClaw => &mut self.mcp.openclaw,
AppType::Hermes => &mut self.mcp.hermes,
@@ -691,6 +729,7 @@ impl MultiAppConfig {
Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::GrokBuild)?;
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)?;
@@ -714,6 +753,7 @@ impl MultiAppConfig {
|| !self.prompts.claude_desktop.prompts.is_empty()
|| !self.prompts.codex.prompts.is_empty()
|| !self.prompts.gemini.prompts.is_empty()
|| !self.prompts.grokbuild.prompts.is_empty()
|| !self.prompts.opencode.prompts.is_empty()
|| !self.prompts.openclaw.prompts.is_empty()
|| !self.prompts.hermes.prompts.is_empty()
@@ -728,6 +768,7 @@ impl MultiAppConfig {
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::GrokBuild,
AppType::OpenCode,
AppType::OpenClaw,
AppType::Hermes,
@@ -801,6 +842,7 @@ impl MultiAppConfig {
AppType::ClaudeDesktop => &mut config.prompts.claude_desktop.prompts,
AppType::Codex => &mut config.prompts.codex.prompts,
AppType::Gemini => &mut config.prompts.gemini.prompts,
AppType::GrokBuild => &mut config.prompts.grokbuild.prompts,
AppType::OpenCode => &mut config.prompts.opencode.prompts,
AppType::OpenClaw => &mut config.prompts.openclaw.prompts,
AppType::Hermes => &mut config.prompts.hermes.prompts,
@@ -843,6 +885,7 @@ impl MultiAppConfig {
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::GrokBuild => continue,
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
@@ -1133,6 +1176,30 @@ mod tests {
);
}
#[test]
#[serial]
fn auto_imports_grokbuild_prompt_on_first_launch() {
let _home = TempHome::new();
write_prompt_file(AppType::GrokBuild, "# Grok Build Prompt\n\nTest content");
let config = MultiAppConfig::load().expect("load config");
assert_eq!(config.prompts.grokbuild.prompts.len(), 1);
let prompt = config
.prompts
.grokbuild
.prompts
.values()
.next()
.expect("grokbuild prompt exists");
assert!(prompt.enabled, "grokbuild prompt should be enabled");
assert_eq!(prompt.content, "# Grok Build Prompt\n\nTest content");
assert_eq!(
prompt.description,
Some("Automatically imported on first launch".to_string())
);
}
#[test]
#[serial]
fn auto_imports_all_three_apps_prompts() {
+3 -3
View File
@@ -46,7 +46,7 @@ pub struct ClaudeDesktopDefaultRoute {
pub const DEFAULT_PROXY_ROUTES: &[ClaudeDesktopDefaultRoute] = &[
ClaudeDesktopDefaultRoute {
route_id: "claude-sonnet-4-6",
route_id: "claude-sonnet-5",
env_key: "ANTHROPIC_DEFAULT_SONNET_MODEL",
supports_1m: true,
},
@@ -1966,9 +1966,9 @@ mod tests {
},
),
(
"claude-sonnet-4-6".to_string(),
"claude-sonnet-5".to_string(),
ClaudeDesktopModelRoute {
model: "claude-sonnet-4-6".to_string(),
model: "claude-sonnet-5".to_string(),
label_override: None,
supports_1m: Some(false),
},
File diff suppressed because it is too large Load Diff
+4 -51
View File
@@ -6,6 +6,7 @@
use crate::codex_config::{
get_codex_config_dir, read_codex_config_text, CC_SWITCH_CODEX_MODEL_PROVIDER_ID,
};
use crate::codex_state_db::codex_state_db_paths;
use crate::config::{atomic_write, copy_file, get_app_config_dir};
use crate::database::{is_official_seed_id, Database};
use crate::error::AppError;
@@ -28,7 +29,6 @@ const MIGRATION_NAME: &str = "codex-history-provider-migration-v1";
const OFFICIAL_UNIFY_MIGRATION_NAME: &str = "codex-official-history-unify-v1";
/// 还原操作自身的备份目录(与迁移备份分开,保持迁移账本目录纯净)。
const OFFICIAL_UNIFY_RESTORE_BACKUP_NAME: &str = "codex-official-history-unify-restore-v1";
const CODEX_STATE_DB_FILENAME: &str = "state_5.sqlite";
/// SQLite 变量上限保守值,IN 列表按此分块。
const STATE_DB_ID_CHUNK: usize = 500;
@@ -1116,55 +1116,6 @@ fn migrate_codex_state_dbs(
Ok(migrated)
}
fn codex_state_db_paths(codex_dir: &Path, config_text: &str) -> Vec<PathBuf> {
let mut paths = Vec::new();
push_unique_path(&mut paths, codex_dir.join(CODEX_STATE_DB_FILENAME));
// Codex lets SQLite state move away from CODEX_HOME; config takes precedence.
if let Some(sqlite_home) = sqlite_home_from_codex_config(config_text) {
push_unique_path(&mut paths, sqlite_home.join(CODEX_STATE_DB_FILENAME));
} else if let Some(sqlite_home) = sqlite_home_from_env() {
push_unique_path(&mut paths, sqlite_home.join(CODEX_STATE_DB_FILENAME));
}
paths
}
fn push_unique_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
if !paths.contains(&path) {
paths.push(path);
}
}
fn sqlite_home_from_codex_config(config_text: &str) -> Option<PathBuf> {
let doc = config_text.parse::<DocumentMut>().ok()?;
let raw = doc.get("sqlite_home")?.as_str()?.trim();
if raw.is_empty() {
return None;
}
Some(resolve_user_path(raw))
}
fn sqlite_home_from_env() -> Option<PathBuf> {
let raw = std::env::var("CODEX_SQLITE_HOME").ok()?;
let raw = raw.trim();
if raw.is_empty() {
return None;
}
Some(resolve_user_path(raw))
}
fn resolve_user_path(raw: &str) -> PathBuf {
if raw == "~" {
return crate::config::get_home_dir();
}
if let Some(rest) = raw.strip_prefix("~/") {
return crate::config::get_home_dir().join(rest);
}
if let Some(rest) = raw.strip_prefix("~\\") {
return crate::config::get_home_dir().join(rest);
}
PathBuf::from(raw)
}
fn migrate_codex_state_db_provider_bucket(
db_path: &Path,
codex_dir: &Path,
@@ -1329,6 +1280,7 @@ fn relative_backup_path(path: &Path, root: &Path) -> PathBuf {
#[cfg(test)]
mod tests {
use super::*;
use crate::codex_state_db::CODEX_STATE_DB_FILENAME;
use crate::provider::Provider;
use serial_test::serial;
use std::ffi::OsString;
@@ -2185,7 +2137,8 @@ base_url = "https://proxy.example/v1"
let env_sqlite_home = dir.path().join("env-sqlite-home");
let config_sqlite_home = dir.path().join("config-sqlite-home");
let _guard = EnvVarGuard::set("CODEX_SQLITE_HOME", &env_sqlite_home);
let config_text = format!("sqlite_home = \"{}\"\n", config_sqlite_home.display());
// TOML 字面量字符串(单引号)Windows 路径含反斜杠,basic string 会解析失败。
let config_text = format!("sqlite_home = '{}'\n", config_sqlite_home.display());
let paths = codex_state_db_paths(&codex_dir, &config_text);
+100
View File
@@ -0,0 +1,100 @@
//! Locating Codex's per-thread state SQLite databases.
//!
//! Codex stores thread metadata in `state_5.sqlite`, normally inside the Codex
//! config dir (`CODEX_HOME` / `~/.codex`). The SQLite location can be moved with
//! the `sqlite_home` key in `config.toml` or the `CODEX_SQLITE_HOME` env var;
//! when set, a second DB lives there. Both history migration and the session
//! list's title lookup need the same resolution, so it lives here once.
use std::path::{Path, PathBuf};
use toml_edit::DocumentMut;
use crate::config::get_home_dir;
/// Filename of Codex's per-thread state database. Codex bumps the version
/// number across releases; update this single source of truth when a new state
/// DB version ships.
pub(crate) const CODEX_STATE_DB_FILENAME: &str = "state_5.sqlite";
/// Env var that overrides the Codex SQLite state directory.
const CODEX_SQLITE_HOME_ENV: &str = "CODEX_SQLITE_HOME";
/// Resolve every candidate `state_5.sqlite` path: the config-dir DB plus, when
/// Codex is configured to keep its SQLite state elsewhere, that DB too.
///
/// `config_dir` is the Codex config dir (`~/.codex`); `config_text` is the raw
/// `config.toml` contents, used to detect a `sqlite_home` override.
pub(crate) fn codex_state_db_paths(config_dir: &Path, config_text: &str) -> Vec<PathBuf> {
let mut paths = Vec::new();
push_unique_path(&mut paths, config_dir.join(CODEX_STATE_DB_FILENAME));
// Codex lets SQLite state move away from CODEX_HOME; config takes precedence.
if let Some(sqlite_home) = sqlite_home_from_codex_config(config_text) {
push_unique_path(&mut paths, sqlite_home.join(CODEX_STATE_DB_FILENAME));
} else if let Some(sqlite_home) = sqlite_home_from_env() {
push_unique_path(&mut paths, sqlite_home.join(CODEX_STATE_DB_FILENAME));
}
paths
}
fn push_unique_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
if !paths.contains(&path) {
paths.push(path);
}
}
fn sqlite_home_from_codex_config(config_text: &str) -> Option<PathBuf> {
let doc = config_text.parse::<DocumentMut>().ok()?;
let raw = doc.get("sqlite_home")?.as_str()?.trim();
if raw.is_empty() {
return None;
}
Some(resolve_user_path(raw))
}
fn sqlite_home_from_env() -> Option<PathBuf> {
let raw = std::env::var(CODEX_SQLITE_HOME_ENV).ok()?;
let raw = raw.trim();
if raw.is_empty() {
return None;
}
Some(resolve_user_path(raw))
}
fn resolve_user_path(raw: &str) -> PathBuf {
if raw == "~" {
return get_home_dir();
}
if let Some(rest) = raw.strip_prefix("~/") {
return get_home_dir().join(rest);
}
if let Some(rest) = raw.strip_prefix("~\\") {
return get_home_dir().join(rest);
}
PathBuf::from(raw)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn includes_config_sqlite_home() {
let temp = tempdir().expect("tempdir");
let sqlite_home = temp.path().join("sqlite-home");
// 用 TOML 字面量字符串(单引号)承载路径:Windows 路径含反斜杠,basic string(双引号)
// 会把 `\U`/`\s` 等当作非法转义导致解析失败。
let config_text = format!("sqlite_home = '{}'\n", sqlite_home.display());
let paths = codex_state_db_paths(temp.path(), &config_text);
assert_eq!(
paths,
vec![
temp.path().join(CODEX_STATE_DB_FILENAME),
sqlite_home.join(CODEX_STATE_DB_FILENAME),
]
);
}
}
+3 -2
View File
@@ -52,13 +52,14 @@ pub async fn get_codex_oauth_quota(
}
};
Ok(query_codex_quota(
// 瞬时传输失败以 Err 传播(前端 reject → retry + 保留上次成功值)。
query_codex_quota(
&token,
Some(&id),
"codex_oauth",
"Codex OAuth access token expired or rejected. Please re-login via cc-switch.",
)
.await)
.await
}
/// 获取 Codex OAuth (ChatGPT Plus/Pro) 可用模型列表
+7
View File
@@ -7,12 +7,19 @@ pub async fn get_coding_plan_quota(
// 火山方舟用控制面 AK/SK 签名查询用量;其他供应商不传,沿用 api_key。
access_key_id: Option<String>,
secret_access_key: Option<String>,
// 智谱团队版(zhipu_team)靠显式标识路由(base_url 与个人版相同无法区分)。
coding_plan_provider: Option<String>,
team_organization_id: Option<String>,
team_project_id: Option<String>,
) -> Result<SubscriptionQuota, String> {
crate::services::coding_plan::get_coding_plan_quota(
&base_url,
&api_key,
access_key_id.as_deref(),
secret_access_key.as_deref(),
coding_plan_provider.as_deref(),
team_organization_id.as_deref(),
team_project_id.as_deref(),
)
.await
}
+28
View File
@@ -99,6 +99,15 @@ pub async fn get_config_status(
Ok(ConfigStatus { exists, path })
}
AppType::GrokBuild => {
let config_path = crate::grok_config::get_grok_config_path();
let exists = config_path.exists();
let path = crate::grok_config::get_grok_config_dir()
.to_string_lossy()
.to_string();
Ok(ConfigStatus { exists, path })
}
AppType::OpenCode => {
let config_path = crate::opencode_config::get_opencode_config_path();
let exists = config_path.exists();
@@ -143,6 +152,7 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
}
AppType::Codex => codex_config::get_codex_config_dir(),
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
AppType::GrokBuild => crate::grok_config::get_grok_config_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(),
@@ -160,6 +170,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
}
AppType::Codex => codex_config::get_codex_config_dir(),
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
AppType::GrokBuild => crate::grok_config::get_grok_config_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(),
@@ -275,6 +286,23 @@ pub async fn get_common_config_snippet(
.map_err(|e| e.to_string())
}
/// 对前端编辑器里的 config.toml 文本做通用配置片段的合并/剥离。
/// 放后端是为了走 toml_edit(保注释、保键序);前端 smol-toml 的
/// 整文档重序列化会破坏用户手写格式。
#[tauri::command]
pub async fn update_toml_common_config_snippet(
config_toml: String,
snippet_toml: String,
enabled: bool,
) -> Result<String, String> {
crate::services::provider::update_toml_common_config_snippet(
&config_toml,
&snippet_toml,
enabled,
)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn set_common_config_snippet(
app_type: String,
+1 -7
View File
@@ -197,11 +197,5 @@ pub async fn toggle_mcp_app(
/// 从所有应用导入 MCP 服务器(复用已有的导入逻辑)
#[tauri::command]
pub async fn import_mcp_from_apps(state: State<'_, AppState>) -> Result<usize, String> {
let mut total = 0;
total += McpService::import_from_claude(&state).unwrap_or(0);
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)
McpService::import_from_all_apps(&state).map_err(|e| e.to_string())
}
+107 -22
View File
@@ -111,8 +111,8 @@ pub struct ToolVersion {
wsl_distro: Option<String>,
}
const VALID_TOOLS: [&str; 6] = [
"claude", "codex", "gemini", "opencode", "openclaw", "hermes",
const VALID_TOOLS: [&str; 7] = [
"claude", "codex", "gemini", "grok", "opencode", "openclaw", "hermes",
];
#[derive(Debug, Clone, serde::Deserialize)]
@@ -424,6 +424,7 @@ fn tool_display_name(tool: &str) -> &'static str {
"claude" => "Claude Code",
"codex" => "Codex",
"gemini" => "Gemini CLI",
"grok" => "Grok Build",
"opencode" => "OpenCode",
"openclaw" => "OpenClaw",
"hermes" => "Hermes",
@@ -492,6 +493,7 @@ fn npm_install_command_for(tool: &str) -> Option<&'static str> {
"claude" => Some("npm i -g @anthropic-ai/claude-code@latest"),
"codex" => Some("npm i -g @openai/codex@latest"),
"gemini" => Some("npm i -g @google/gemini-cli@latest"),
"grok" => Some("npm i -g @xai-official/grok@latest"),
"opencode" => Some("npm i -g opencode-ai@latest"),
"openclaw" => Some("npm i -g openclaw@latest"),
_ => None,
@@ -638,7 +640,7 @@ fn build_tool_action_line(
// (npm/pnpm)或 .exe(volta),静态命令头部是 `npm`(也是 .cmd)、`py` 等——
// 全部加 `call ` 前缀,风格统一且语义正确。含空格的头部已被 `win_quote_path_for_batch`
// 加上双引号,call 对带引号的路径解析正常。
return Ok(format!("call {command}"));
Ok(format!("call {command}"))
}
#[cfg(not(target_os = "windows"))]
@@ -762,6 +764,7 @@ async fn get_single_tool_version_impl(
}
"codex" => fetch_npm_latest_for_tool(&client, "@openai/codex", tool, local).await,
"gemini" => fetch_npm_latest_for_tool(&client, "@google/gemini-cli", tool, local).await,
"grok" => fetch_npm_latest_for_tool(&client, "@xai-official/grok", tool, local).await,
"opencode" => {
if let Some(version) =
fetch_npm_latest_for_tool(&client, "opencode-ai", tool, local).await
@@ -1069,6 +1072,8 @@ fn default_flag_for_shell(shell: &str) -> &'static str {
}
}
// 以下 shell 解析辅助函数仅被 macOS/Linux 的终端启动逻辑使用;Windows 非 test 编译下为死代码。
#[cfg_attr(windows, allow(dead_code))]
fn fallback_user_shell() -> &'static str {
if cfg!(target_os = "macos") {
"/bin/zsh"
@@ -1077,6 +1082,7 @@ fn fallback_user_shell() -> &'static str {
}
}
#[cfg_attr(windows, allow(dead_code))]
fn valid_user_shell_path(shell: &str) -> bool {
if shell.is_empty()
|| !shell.starts_with('/')
@@ -1100,11 +1106,13 @@ fn is_executable_file(path: &std::path::Path) -> bool {
}
#[cfg(not(unix))]
#[cfg_attr(windows, allow(dead_code))]
fn is_executable_file(path: &std::path::Path) -> bool {
path.is_file()
}
/// 获取用户默认 shell 的完整路径;异常或被污染的 SHELL 回退到平台默认值。
#[cfg_attr(windows, allow(dead_code))]
fn get_user_shell() -> String {
std::env::var("SHELL")
.ok()
@@ -1113,6 +1121,7 @@ fn get_user_shell() -> String {
}
/// 构建 exec 行:引号保护 shell 路径,交还用户 shell 让其按默认规则加载 rc 配置。
#[cfg_attr(windows, allow(dead_code))]
fn build_exec_line(shell: &str, cwd: Option<&Path>) -> String {
let quoted_shell = shell_single_quote(shell);
@@ -1132,6 +1141,7 @@ fn build_exec_line(shell: &str, cwd: Option<&Path>) -> String {
}
/// 构建 provider 命令行:通过用户 shell 的交互模式执行,确保 GUI 启动的终端也加载用户 PATH。
#[cfg_attr(windows, allow(dead_code))]
fn build_provider_command_line(shell: &str, config_path: &str, cwd: Option<&Path>) -> String {
let claude_command = format!("claude --settings {}", shell_single_quote(config_path));
let command = cwd
@@ -1152,6 +1162,7 @@ fn build_provider_command_line(shell: &str, config_path: &str, cwd: Option<&Path
)
}
#[cfg_attr(windows, allow(dead_code))]
fn provider_command_flag_for_shell(shell: &str) -> &'static str {
match shell.rsplit('/').next().unwrap_or(shell) {
"dash" | "sh" => "-c",
@@ -1160,6 +1171,7 @@ fn provider_command_flag_for_shell(shell: &str) -> &'static str {
}
}
#[cfg_attr(windows, allow(dead_code))]
fn build_final_shell_cd_command(shell: &str, cwd: Option<&Path>) -> String {
if matches!(shell.rsplit('/').next().unwrap_or(shell), "zsh") {
return String::new();
@@ -1936,6 +1948,7 @@ fn npm_package_for(tool: &str) -> Option<&'static str> {
"claude" => Some("@anthropic-ai/claude-code"),
"codex" => Some("@openai/codex"),
"gemini" => Some("@google/gemini-cli"),
"grok" => Some("@xai-official/grok"),
"opencode" => Some("opencode-ai"),
"openclaw" => Some("openclaw"),
_ => None,
@@ -2248,7 +2261,8 @@ fn package_manager_anchored_command_from_paths(
/// formula 由 Homebrew 拥有,避免 self-update 尝试改动包管理器管理的安装。
/// ④ 其余支持官方自升级的工具 → `<bin_path 绝对> update/upgrade || <原锚定包管理器命令>`
/// Codex 的 self-update 只在部分 release 可用,所以保留 npm/brew/bun/volta fallback。
/// ⑤ 不支持官方自升级的 npm 全局包(例如 Gemini CLI) → 锚定到"那处 bin 目录的 npm"。
/// ⑤ 不支持官方自升级的 npm 全局包(例如 Gemini CLI / Grok Build) → 锚定到
/// "那处 bin 目录的 npm"。
#[cfg(not(target_os = "windows"))]
fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) -> Option<String> {
let real_lower = real_target.to_ascii_lowercase();
@@ -2544,6 +2558,7 @@ fn wsl_distro_for_tool(tool: &str) -> Option<String> {
"claude" => crate::settings::get_claude_override_dir(),
"codex" => crate::settings::get_codex_override_dir(),
"gemini" => crate::settings::get_gemini_override_dir(),
"grok" => crate::settings::get_grok_override_dir(),
"opencode" => crate::settings::get_opencode_override_dir(),
"openclaw" => crate::settings::get_openclaw_override_dir(),
"hermes" => crate::settings::get_hermes_override_dir(),
@@ -2731,7 +2746,7 @@ fn launch_terminal_with_env(
#[cfg(target_os = "windows")]
{
launch_windows_terminal(&temp_dir, &config_file, cwd)?;
return Ok(());
Ok(())
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
@@ -3252,6 +3267,7 @@ del \"%~f0\" >nul 2>&1
result
}
#[cfg_attr(windows, allow(dead_code))]
fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
@@ -3650,6 +3666,35 @@ mod tests {
assert_eq!(extract_version("no version here"), "no version here");
}
#[test]
fn grok_lifecycle_metadata_is_consistent() {
let requested = vec!["unsupported".to_string(), "grok".to_string()];
assert_eq!(normalize_requested_tools(&requested), vec!["grok"]);
assert_eq!(tool_display_name("grok"), "Grok Build");
assert_eq!(npm_package_for("grok"), Some("@xai-official/grok"));
assert_eq!(
npm_install_command_for("grok"),
Some("npm i -g @xai-official/grok@latest")
);
assert_eq!(official_update_args("grok"), None);
for shell in [
LifecycleCommandShell::Posix,
LifecycleCommandShell::WindowsBatch,
] {
assert_eq!(
tool_action_shell_command_for_shell("grok", ToolLifecycleAction::Install, shell,)
.as_deref(),
Some("npm i -g @xai-official/grok@latest")
);
assert_eq!(
tool_action_shell_command_for_shell("grok", ToolLifecycleAction::Update, shell,)
.as_deref(),
Some("npm i -g @xai-official/grok@latest")
);
}
}
#[test]
fn test_compare_semver() {
use std::cmp::Ordering;
@@ -3838,11 +3883,8 @@ mod tests {
let (_dir, sub, bin_path) = setup_sibling("Volta", "codex.cmd", &["volta.exe"]);
let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path);
let volta_full = format!("{}\\volta.exe", sub.to_string_lossy());
let expected = format!(
"{} update || call {} install @openai/codex",
expect_quoted_path(&bin_path),
expect_quoted_path(&volta_full)
);
// codex 自 5092fe51 起不在 prefers_official_update:直接锚定包管理器,无 `codex update ||` 前缀。
let expected = format!("{} install @openai/codex", expect_quoted_path(&volta_full));
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
}
@@ -3854,9 +3896,9 @@ mod tests {
let (_dir, sub, bin_path) = setup_sibling("pnpm", "codex.cmd", &["pnpm.cmd"]);
let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path);
let pnpm_full = format!("{}\\pnpm.cmd", sub.to_string_lossy());
// codex 自 5092fe51 起不在 prefers_official_update:直接锚定包管理器,无 `codex update ||` 前缀。
let expected = format!(
"{} update || call {} add -g @openai/codex@latest",
expect_quoted_path(&bin_path),
"{} add -g @openai/codex@latest",
expect_quoted_path(&pnpm_full)
);
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
@@ -3888,9 +3930,21 @@ mod tests {
let (_dir, sub, bin_path) = setup_sibling("v22.0.0", "codex.cmd", &["npm.cmd"]);
let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path);
let npm_full = format!("{}\\npm.cmd", sub.to_string_lossy());
// codex 自 5092fe51 起不在 prefers_official_update:直接锚定包管理器,无 `codex update ||` 前缀。
let expected = format!(
"{} update || call {} i -g @openai/codex@latest",
expect_quoted_path(&bin_path),
"{} i -g @openai/codex@latest",
expect_quoted_path(&npm_full)
);
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
}
#[test]
fn grok_windows_anchors_to_sibling_npm() {
let (_dir, sub, bin_path) = setup_sibling("v22.0.0", "grok.cmd", &["npm.cmd"]);
let cmd = anchored_command_from_paths("grok", &bin_path, &bin_path);
let npm_full = format!("{}\\npm.cmd", sub.to_string_lossy());
let expected = format!(
"{} i -g @xai-official/grok@latest",
expect_quoted_path(&npm_full)
);
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
@@ -3898,10 +3952,11 @@ mod tests {
#[test]
fn windows_no_sibling_uses_cli_update_without_package_fallback() {
// sibling npm.cmd 不存在(纯独立二进制)时,仍可锚定到 CLI 自身跑官方 update。
// 只是没有包管理器 fallback。
let (_dir, _sub, bin_path) = setup_sibling("", "codex.cmd", &[]);
let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path);
// sibling 包管理器不存在(纯独立二进制)时,仍可锚定到 CLI 自身跑官方 update。
// 只是没有包管理器 fallback。用 claude —— codex 自 5092fe51 起一律走 npm 锚定,
// 已不再有官方 self-update 分支,故改用仍在 prefers_official_update 的 claude 覆盖此路径。
let (_dir, _sub, bin_path) = setup_sibling("", "claude.cmd", &[]);
let cmd = anchored_command_from_paths("claude", &bin_path, &bin_path);
let expected = format!("{} update", expect_quoted_path(&bin_path));
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
}
@@ -3977,9 +4032,9 @@ mod tests {
let (_dir, sub, bin_path) = setup_sibling("Program Files", "codex.cmd", &["npm.cmd"]);
let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path);
let npm_full = format!("{}\\npm.cmd", sub.to_string_lossy());
// codex 走 npm 锚定(5092fe51),含空格的 npm 全路径仍必须被双引号包裹。
let expected = format!(
"{} update || call {} i -g @openai/codex@latest",
expect_quoted_path(&bin_path),
"{} i -g @openai/codex@latest",
expect_quoted_path(&npm_full)
);
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
@@ -4001,9 +4056,10 @@ mod tests {
// 含空格的环境**(否则 sub 本身含空格 + 子目录 `path%foo%` 触发 4 倍 `%` 转义
// 会让 expected 漏引号、假失败)。
let npm_full = format!("{}\\npm.cmd", sub.to_string_lossy());
// codex 走 npm 锚定(5092fe51)batch 行 = `call <npm 全路径> i -g ...`
// 含字面 `%` 的 npm 路径仍须 4 倍转义。
let expected = format!(
"call {} update || call {} i -g @openai/codex@latest",
expect_quoted_path(&bin_path),
"call {} i -g @openai/codex@latest",
expect_quoted_path(&npm_full)
);
assert_eq!(batch_line, expected);
@@ -4214,6 +4270,9 @@ mod tests {
let codex =
wsl_tool_action_shell_command("codex", ToolLifecycleAction::Install).unwrap();
assert_eq!(codex, "npm i -g @openai/codex@latest");
let grok = wsl_tool_action_shell_command("grok", ToolLifecycleAction::Install).unwrap();
assert_eq!(grok, "npm i -g @xai-official/grok@latest");
}
#[test]
@@ -4374,6 +4433,21 @@ mod tests {
);
}
#[test]
fn grok_nvm_anchors_to_npm_without_cli_update() {
let cmd = anchored_command_from_paths(
"grok",
"/Users/me/.nvm/versions/node/v22.14.0/bin/grok",
"/Users/me/.nvm/versions/node/v22.14.0/lib/node_modules/@xai-official/grok/bin/grok",
);
assert_eq!(
cmd.as_deref(),
Some(
"/Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @xai-official/grok@latest"
)
);
}
#[test]
fn codex_nvm_anchors_to_that_npm() {
// Codex 不走 self-update`codex update` 在 npm 安装上只是裸 `npm install -g`
@@ -4824,6 +4898,12 @@ mod tests {
assert_eq!(cmd, "npm i -g @google/gemini-cli@latest");
}
#[test]
fn grok_install_keeps_static_npm() {
let cmd = install_command_for("grok");
assert_eq!(cmd, "npm i -g @xai-official/grok@latest");
}
#[test]
fn openclaw_install_keeps_static_npm() {
let cmd = install_command_for("openclaw");
@@ -4846,6 +4926,11 @@ mod tests {
"npm i -g @google/gemini-cli@latest"
);
assert!(!static_fallback_command("gemini").contains("gemini update"));
assert_eq!(
static_fallback_command("grok"),
"npm i -g @xai-official/grok@latest"
);
assert!(!static_fallback_command("grok").contains("grok update"));
assert_eq!(
static_fallback_command("opencode"),
"opencode upgrade || npm i -g opencode-ai@latest"
+2
View File
@@ -18,6 +18,7 @@ mod model_fetch;
mod omo;
mod openclaw;
mod plugin;
mod profile;
mod prompt;
mod provider;
mod proxy;
@@ -52,6 +53,7 @@ pub use model_fetch::*;
pub use omo::*;
pub use openclaw::*;
pub use plugin::*;
pub use profile::*;
pub use prompt::*;
pub use provider::*;
pub use proxy::*;
+195
View File
@@ -0,0 +1,195 @@
//! 项目 Profile 管理命令
use serde::Serialize;
use tauri::{Emitter, Manager, State};
use crate::database::Profile;
use crate::services::profile::{ProfilePayload, ProfileScope, ProfileService};
use crate::store::AppState;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileDto {
pub id: String,
pub name: String,
pub payload: ProfilePayload,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<i64>,
}
impl From<Profile> for ProfileDto {
fn from(profile: Profile) -> Self {
// 单条 payload 损坏不应拖垮整个列表:降级为默认值并记日志
let payload = serde_json::from_str(&profile.payload).unwrap_or_else(|e| {
log::warn!(
"解析 profile '{}' payload 失败,使用默认值: {e}",
profile.id
);
ProfilePayload::default()
});
Self {
id: profile.id,
name: profile.name,
payload,
created_at: profile.created_at,
updated_at: profile.updated_at,
}
}
}
/// 每个分组当前激活的项目 id(未使用项目时为 null)
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CurrentProfileIds {
pub claude: Option<String>,
pub claude_desktop: Option<String>,
pub codex: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfilesResponse {
pub profiles: Vec<ProfileDto>,
pub current_ids: CurrentProfileIds,
}
/// Profile 应用完成后的统一收尾:发事件 + 重建托盘菜单
///
/// 只对项目所属分组内的应用发 provider-switched。UI 与托盘两个入口必须
/// 共用此函数,保证事件 payload 形状一致(前端 App.tsx 的
/// provider-switched 监听依赖该形状)。
pub fn emit_profile_apply_events(
app: &tauri::AppHandle,
state: &AppState,
profile_id: &str,
scope: ProfileScope,
) {
for app_type in scope.apps().iter() {
let app_str = app_type.as_str();
let (proxy_enabled, auto_failover_enabled) = state.db.get_proxy_flags_sync(app_str);
let provider_id = crate::settings::get_effective_current_provider(&state.db, app_type)
.ok()
.flatten()
.unwrap_or_default();
let event_data = serde_json::json!({
"appType": app_str,
"proxyEnabled": proxy_enabled,
"autoFailoverEnabled": auto_failover_enabled,
"providerId": provider_id,
});
if let Err(e) = app.emit("provider-switched", event_data) {
log::error!("发射 provider-switched 事件失败: {e}");
}
}
if let Err(e) = app.emit(
"profile-applied",
serde_json::json!({ "profileId": profile_id, "scope": scope.as_str() }),
) {
log::error!("发射 profile-applied 事件失败: {e}");
}
crate::tray::refresh_tray_menu(app);
}
#[tauri::command]
pub fn list_profiles(state: State<'_, AppState>) -> Result<ProfilesResponse, String> {
let profiles = ProfileService::list(&state).map_err(|e| e.to_string())?;
let current_ids = CurrentProfileIds {
claude: state
.db
.get_current_profile_id(ProfileScope::Claude.as_str())
.map_err(|e| e.to_string())?,
claude_desktop: state
.db
.get_current_profile_id(ProfileScope::ClaudeDesktop.as_str())
.map_err(|e| e.to_string())?,
codex: state
.db
.get_current_profile_id(ProfileScope::Codex.as_str())
.map_err(|e| e.to_string())?,
};
Ok(ProfilesResponse {
profiles: profiles.into_iter().map(ProfileDto::from).collect(),
current_ids,
})
}
#[tauri::command]
pub fn create_profile(
state: State<'_, AppState>,
name: String,
scope: String,
) -> Result<ProfileDto, String> {
let scope = ProfileScope::parse(&scope).map_err(|e| e.to_string())?;
ProfileService::create(&state, &name, scope)
.map(ProfileDto::from)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn update_profile(
state: State<'_, AppState>,
id: String,
name: Option<String>,
resnapshot: Option<bool>,
scope: Option<String>,
) -> Result<ProfileDto, String> {
let scope = scope
.map(|s| ProfileScope::parse(&s))
.transpose()
.map_err(|e| e.to_string())?;
ProfileService::update(&state, &id, name, resnapshot.unwrap_or(false), scope)
.map(ProfileDto::from)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn delete_profile(state: State<'_, AppState>, id: String) -> Result<(), String> {
ProfileService::delete(&state, &id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn clear_current_profile(state: State<'_, AppState>, scope: String) -> Result<(), String> {
let scope = ProfileScope::parse(&scope).map_err(|e| e.to_string())?;
state
.db
.set_current_profile_id(scope.as_str(), None)
.map_err(|e| e.to_string())
}
/// 应用项目快照(只作用于发起页所属分组内的应用)。
///
/// 注意:必须保持同步命令(跑在 Tauri 线程池)——`ProviderService::switch`
/// 内部使用 block_on 获取切换锁,放进 async 命令会在运行时线程上 panic。
#[tauri::command]
pub fn apply_profile(
app: tauri::AppHandle,
state: State<'_, AppState>,
id: String,
scope: String,
) -> Result<Vec<String>, String> {
let scope = ProfileScope::parse(&scope).map_err(|e| e.to_string())?;
let (warnings, should_stop_proxy) =
ProfileService::apply(&state, &id, scope).map_err(|e| e.to_string())?;
if should_stop_proxy {
// sync 命令线程没有 Tokio runtime,无法直接 await stop()
// 把停止服务放到 Tauri async runtime,停止后再补发事件刷新 UI。
let app_handle = app.clone();
let profile_id = id.clone();
let proxy_service = state.proxy_service.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = proxy_service.stop().await {
log::warn!("切换项目后停止代理服务失败: {e}");
}
if let Some(app_state) = app_handle.try_state::<AppState>() {
emit_profile_apply_events(&app_handle, app_state.inner(), &profile_id, scope);
}
});
} else {
emit_profile_apply_events(&app, &state, &id, scope);
}
Ok(warnings)
}
+51 -52
View File
@@ -231,6 +231,14 @@ pub fn ensure_claude_desktop_official_provider(state: State<'_, AppState>) -> Re
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn ensure_codex_official_provider(state: State<'_, AppState>) -> Result<bool, String> {
state
.db
.ensure_official_seed_by_id(crate::database::CODEX_OFFICIAL_PROVIDER_ID, AppType::Codex)
.map_err(|e| e.to_string())
}
fn claude_provider_models_are_claude_safe(provider: &Provider) -> bool {
let Some(env) = provider
.settings_config
@@ -381,32 +389,30 @@ pub async fn queryProviderUsage(
) -> 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 回调,不吞错误。
// 1) 返回 Ok(UsageResult { success: false, .. }) —— 确定性失败(401、脚本
// 报错、未知供应商等)。写进 UsageCache 并刷新托盘,让
// format_script_summary 的 success 守卫生效、suffix 自然消失。
// 2) 返回 Err(String) —— 瞬时传输失败(网络/超时)及 DB/Copilot fetch 等
// 不写失败快照、不 emit:保留上一份托盘快照,与前端 react-query reject
// 保留上次 data 的语义一致;否则失败快照会经 useUsageCacheBridge 盲写
// 回 query 缓存,抹掉 reject 本该保留的旧值。
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}");
if let Ok(snapshot) = &inner {
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.clone());
crate::tray::schedule_tray_refresh(&app_handle);
}
state.usage_cache.put_script(app_type, providerId, snapshot);
crate::tray::schedule_tray_refresh(&app_handle);
inner
}
@@ -518,12 +524,20 @@ async fn query_provider_usage_inner(
// 其他供应商为 Noneservice 层沿用 api_key。
let access_key_id = usage_script.and_then(|s| s.access_key_id.clone());
let secret_access_key = usage_script.and_then(|s| s.secret_access_key.clone());
// 智谱团队版:显式 provider 标识 + 组织/项目 ID(与个人版智谱 base_url 相同,
// 靠 coding_plan_provider == "zhipu_team" 在 service 层路由)。
let coding_plan_provider = usage_script.and_then(|s| s.coding_plan_provider.clone());
let team_organization_id = usage_script.and_then(|s| s.team_organization_id.clone());
let team_project_id = usage_script.and_then(|s| s.team_project_id.clone());
let quota = crate::services::coding_plan::get_coding_plan_quota(
&base_url,
&api_key,
access_key_id.as_deref(),
secret_access_key.as_deref(),
coding_plan_provider.as_deref(),
team_organization_id.as_deref(),
team_project_id.as_deref(),
)
.await
.map_err(|e| format!("Failed to query coding plan: {e}"))?;
@@ -888,9 +902,7 @@ mod import_claude_desktop_tests {
None,
);
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
let r = routes
.get("claude-sonnet-4-6")
.expect("sonnet route present");
let r = routes.get("claude-sonnet-5").expect("sonnet route present");
assert_eq!(r.model, "claude-sonnet-4-5-20250929");
assert!(
!r.model.to_ascii_lowercase().contains("[1m]"),
@@ -909,9 +921,7 @@ mod import_claude_desktop_tests {
None,
);
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
let r = routes
.get("claude-sonnet-4-6")
.expect("sonnet route present");
let r = routes.get("claude-sonnet-5").expect("sonnet route present");
assert_eq!(r.model, "kimi-k2");
assert_eq!(r.label_override.as_deref(), Some("kimi-k2"));
// 默认 provider_type 缺省 → supports_1m_default = true
@@ -928,9 +938,7 @@ mod import_claude_desktop_tests {
None,
);
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
let r = routes
.get("claude-sonnet-4-6")
.expect("sonnet route present");
let r = routes.get("claude-sonnet-5").expect("sonnet route present");
assert_eq!(r.model, "kimi-k2");
assert_eq!(r.label_override.as_deref(), Some("Kimi K2"));
}
@@ -945,9 +953,7 @@ mod import_claude_desktop_tests {
Some("github_copilot"),
);
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
let r = routes
.get("claude-sonnet-4-6")
.expect("sonnet route present");
let r = routes.get("claude-sonnet-5").expect("sonnet route present");
assert_eq!(r.model, "gpt-5-codex");
assert_eq!(r.label_override.as_deref(), Some("gpt-5-codex"));
assert_eq!(r.supports_1m, Some(true));
@@ -962,9 +968,7 @@ mod import_claude_desktop_tests {
Some("github_copilot"),
);
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
let r = routes
.get("claude-sonnet-4-6")
.expect("sonnet route present");
let r = routes.get("claude-sonnet-5").expect("sonnet route present");
assert_eq!(r.model, "gpt-5-codex");
assert_eq!(r.label_override.as_deref(), Some("gpt-5-codex"));
assert_eq!(r.supports_1m, Some(false));
@@ -982,9 +986,7 @@ mod import_claude_desktop_tests {
);
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
assert_eq!(routes.len(), 1, "three aliases → one merged route");
let r = routes
.get("claude-sonnet-4-6")
.expect("merged route present");
let r = routes.get("claude-sonnet-5").expect("merged route present");
assert_eq!(r.model, "MiniMax-M2");
assert_eq!(r.label_override.as_deref(), Some("MiniMax-M2"));
}
@@ -1002,9 +1004,7 @@ mod import_claude_desktop_tests {
);
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
assert_eq!(routes.len(), 1);
let r = routes
.get("claude-sonnet-4-6")
.expect("merged route present");
let r = routes.get("claude-sonnet-5").expect("merged route present");
assert_eq!(r.supports_1m, Some(true));
}
@@ -1020,12 +1020,12 @@ mod import_claude_desktop_tests {
);
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
assert_eq!(routes.len(), 3);
assert_eq!(routes.get("claude-sonnet-4-6").unwrap().model, "GLM-4.6");
assert_eq!(routes.get("claude-sonnet-5").unwrap().model, "GLM-4.6");
assert_eq!(routes.get("claude-opus-4-8").unwrap().model, "GLM-4-Air");
assert_eq!(routes.get("claude-haiku-4-5").unwrap().model, "GLM-4-Flash");
assert_eq!(
routes
.get("claude-sonnet-4-6")
.get("claude-sonnet-5")
.unwrap()
.label_override
.as_deref(),
@@ -1045,7 +1045,7 @@ mod import_claude_desktop_tests {
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
assert_eq!(routes.len(), 1);
let r = routes
.get("claude-sonnet-4-6")
.get("claude-sonnet-5")
.expect("fallback route present");
assert_eq!(r.model, "kimi-k2");
assert_eq!(r.label_override.as_deref(), Some("kimi-k2"));
@@ -1060,13 +1060,10 @@ mod import_claude_desktop_tests {
None,
);
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
assert!(routes.contains_key("claude-sonnet-4-6"));
assert!(routes.contains_key("claude-sonnet-5"));
assert!(!routes.contains_key("claude-claude-sonnet-4-5-20250929"));
assert_eq!(
routes
.get("claude-sonnet-4-6")
.expect("route")
.label_override,
routes.get("claude-sonnet-5").expect("route").label_override,
None
);
}
@@ -1098,6 +1095,8 @@ mod native_query_credentials_tests {
coding_plan_provider: coding_plan_provider.map(str::to_string),
access_key_id: None,
secret_access_key: None,
team_organization_id: None,
team_project_id: None,
}
}
+9 -2
View File
@@ -6,6 +6,7 @@ use crate::error::AppError;
use crate::proxy::types::*;
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
use crate::store::AppState;
use std::str::FromStr;
/// 启动代理服务器(仅启动服务,不接管 Live 配置)
#[tauri::command]
@@ -22,6 +23,7 @@ pub async fn stop_proxy_server(state: tauri::State<'_, AppState>) -> Result<(),
if takeover.claude
|| takeover.codex
|| takeover.gemini
|| takeover.grokbuild
|| takeover.opencode
|| takeover.openclaw
{
@@ -279,13 +281,18 @@ pub async fn switch_proxy_provider(
app_type: String,
provider_id: String,
) -> Result<(), String> {
// Block official providers during proxy takeover
// Codex's built-in official provider can use the client's native OpenAI
// login through takeover. Other official providers remain blocked.
let provider = state
.db
.get_provider_by_id(&provider_id, &app_type)
.map_err(|e| format!("读取供应商失败: {e}"))?
.ok_or_else(|| format!("供应商不存在: {provider_id}"))?;
if provider.category.as_deref() == Some("official") {
let app = crate::app_config::AppType::from_str(&app_type)
.map_err(|e| format!("无效的应用类型: {e}"))?;
if provider.category.as_deref() == Some("official")
&& !crate::services::provider::official_provider_supports_proxy_takeover(&app, &provider)
{
return Err(
"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)"
.to_string(),
+1 -10
View File
@@ -247,7 +247,7 @@ pub async fn install_update_and_restart(app: AppHandle) -> Result<bool, String>
"Windows 更新安装失败: {e}。已执行退出前清理,代理或 Live 接管可能已暂停;请重启应用或重新开启代理后再试。"
)
})?;
return Ok(true);
Ok(true)
}
#[cfg(not(target_os = "windows"))]
@@ -661,15 +661,6 @@ pub async fn set_optimizer_config(
state: tauri::State<'_, crate::AppState>,
config: crate::proxy::types::OptimizerConfig,
) -> Result<bool, String> {
// Validate cache_ttl: only allow known values
match config.cache_ttl.as_str() {
"5m" | "1h" => {}
other => {
return Err(format!(
"Invalid cache_ttl value: '{other}'. Allowed values: '5m', '1h'"
))
}
}
state
.db
.set_optimizer_config(&config)
+6
View File
@@ -72,6 +72,12 @@ pub async fn stream_check_all_providers(
let mut results = Vec::new();
for (id, provider) in providers {
// Official OAuth providers intentionally have no user-configured probe
// target. Never turn their runtime adapter defaults into unauthenticated
// network probes against first-party endpoints.
if provider.category.as_deref() == Some("official") {
continue;
}
if let Some(ids) = &allowed_ids {
if !ids.contains(&id) {
continue;
+22 -21
View File
@@ -2,17 +2,19 @@ use std::str::FromStr;
use tauri::{Emitter, State};
use crate::app_config::AppType;
use crate::services::subscription::{CredentialStatus, SubscriptionQuota};
use crate::services::subscription::SubscriptionQuota;
use crate::store::AppState;
/// 查询官方订阅额度
///
/// 读取 CLI 工具已有的 OAuth 凭据并调用官方 API 获取使用额度。
/// 结果(无论业务失败还是 transport 层 Err)都会写入 `UsageCache`、通知托盘
/// 刷新,并 emit `usage-cache-updated`,让前端 React Query 与托盘共享同一份
/// 最新数据。失败快照写入后 `format_subscription_summary` 会通过 `success=false`
/// 守卫返回 `None`,托盘 suffix 自然消失,避免长期滞留旧配额数字。
/// Err 原样向前端返回,React Query 的 onError 不会被吞掉。
/// `Ok`(成功或确定性失败)写入 `UsageCache`、通知托盘刷新并 emit
/// `usage-cache-updated`,让前端 React Query 与托盘共享同一份最新数据;失败
/// 快照写入后 `format_subscription_summary` 会通过 `success=false` 守卫返回
/// `None`,托盘 suffix 自然消失,避免长期滞留旧配额数字。
/// `Err`(瞬时传输失败)不写快照、不 emit:保留上一份托盘快照,与前端
/// react-query reject 保留上次 data 的语义一致(emit 失败快照会经
/// `useUsageCacheBridge` 盲写回 query 缓存,抹掉本该保留的旧值)。
#[tauri::command]
pub async fn get_subscription_quota(
app: tauri::AppHandle,
@@ -20,22 +22,21 @@ pub async fn get_subscription_quota(
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}");
if let Ok(snapshot) = &inner {
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.clone());
crate::tray::schedule_tray_refresh(&app);
}
state.usage_cache.put_subscription(app_type, snapshot);
crate::tray::schedule_tray_refresh(&app);
}
inner
}
+8 -5
View File
@@ -13,7 +13,7 @@ impl Database {
pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn.prepare(
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes
FROM mcp_servers
ORDER BY name ASC, id ASC"
).map_err(|e| AppError::Database(e.to_string()))?;
@@ -30,8 +30,9 @@ impl Database {
let enabled_claude: bool = row.get(7)?;
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 enabled_grokbuild: bool = row.get(10)?;
let enabled_opencode: bool = row.get(11)?;
let enabled_hermes: bool = row.get(12)?;
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 {
claude: enabled_claude,
codex: enabled_codex,
gemini: enabled_gemini,
grokbuild: enabled_grokbuild,
opencode: enabled_opencode,
hermes: enabled_hermes,
},
@@ -72,8 +74,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, enabled_hermes
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
params![
server.id,
server.name,
@@ -88,6 +90,7 @@ impl Database {
server.apps.claude,
server.apps.codex,
server.apps.gemini,
server.apps.grokbuild,
server.apps.opencode,
server.apps.hermes,
],
+3 -1
View File
@@ -4,6 +4,7 @@
pub mod failover;
pub mod mcp;
pub mod profiles;
pub mod prompts;
pub mod providers;
pub mod providers_seed;
@@ -15,5 +16,6 @@ pub mod universal_providers;
pub mod usage_rollup;
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
// 导出 FailoverQueueItem 供外部使用
// 导出 FailoverQueueItem / Profile 供外部使用
pub use failover::FailoverQueueItem;
pub use profiles::Profile;
+207
View File
@@ -0,0 +1,207 @@
//! 项目 Profile 数据访问对象
//!
//! profiles 表存放全应用共享的项目实体(供应商/MCP/Skills/Prompt 快照),
//! payload 为原始 JSON 文本(按 app 分槽),解析在 service 层进行。
//! 各应用分组(scope)独立的 current 标记存放于 settings 表(key-value)。
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use rusqlite::params;
/// 每个 scope 的 current 标记 key = 前缀 + scope(如 current_profile_id_claude
const CURRENT_PROFILE_ID_KEY_PREFIX: &str = "current_profile_id_";
fn current_profile_key(scope: &str) -> String {
format!("{CURRENT_PROFILE_ID_KEY_PREFIX}{scope}")
}
/// 项目 Profile 记录(全应用共享,无所属分组)
#[derive(Debug, Clone)]
pub struct Profile {
pub id: String,
pub name: String,
/// 原始 JSON 快照文本(ProfilePayload),解析在 service 层
pub payload: String,
pub sort_order: Option<i64>,
pub created_at: Option<i64>,
pub updated_at: Option<i64>,
}
impl Database {
/// 获取所有项目(按 sort_order 优先、created_at 兜底排序)
pub fn get_all_profiles(&self) -> Result<Vec<Profile>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare(
"SELECT id, name, payload, sort_order, created_at, updated_at
FROM profiles
ORDER BY sort_order IS NULL, sort_order, created_at, id",
)
.map_err(|e| AppError::Database(e.to_string()))?;
let rows = stmt
.query_map([], |row| {
Ok(Profile {
id: row.get(0)?,
name: row.get(1)?,
payload: row.get(2)?,
sort_order: row.get(3)?,
created_at: row.get(4)?,
updated_at: row.get(5)?,
})
})
.map_err(|e| AppError::Database(e.to_string()))?;
let mut profiles = Vec::new();
for row in rows {
profiles.push(row.map_err(|e| AppError::Database(e.to_string()))?);
}
Ok(profiles)
}
/// 获取单个项目
pub fn get_profile(&self, id: &str) -> Result<Option<Profile>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare(
"SELECT id, name, payload, sort_order, created_at, updated_at
FROM profiles WHERE id = ?1",
)
.map_err(|e| AppError::Database(e.to_string()))?;
match stmt.query_row(params![id], |row| {
Ok(Profile {
id: row.get(0)?,
name: row.get(1)?,
payload: row.get(2)?,
sort_order: row.get(3)?,
created_at: row.get(4)?,
updated_at: row.get(5)?,
})
}) {
Ok(profile) => Ok(Some(profile)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 保存项目(插入或整行替换)
pub fn save_profile(&self, profile: &Profile) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"INSERT OR REPLACE INTO profiles
(id, name, payload, sort_order, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
profile.id,
profile.name,
profile.payload,
profile.sort_order,
profile.created_at,
profile.updated_at,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 删除项目,返回是否实际删除了记录
pub fn delete_profile(&self, id: &str) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let affected = conn
.execute("DELETE FROM profiles WHERE id = ?1", params![id])
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(affected > 0)
}
/// 读取某分组当前激活的项目 id(未使用项目时为 None)
pub fn get_current_profile_id(&self, scope: &str) -> Result<Option<String>, AppError> {
self.get_setting(&current_profile_key(scope))
}
/// 设置某分组当前激活的项目 id;None 表示"不使用项目"(删除 key
pub fn set_current_profile_id(&self, scope: &str, id: Option<&str>) -> Result<(), AppError> {
let key = current_profile_key(scope);
match id {
Some(id) => self.set_setting(&key, id),
None => {
let conn = lock_conn!(self.conn);
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(id: &str, name: &str, sort_order: Option<i64>) -> Profile {
Profile {
id: id.to_string(),
name: name.to_string(),
payload: r#"{"providers":{"claude":null,"codex":null}}"#.to_string(),
sort_order,
created_at: Some(1_000),
updated_at: Some(1_000),
}
}
#[test]
fn test_profile_crud_roundtrip() -> Result<(), AppError> {
let db = Database::memory()?;
db.save_profile(&sample("a", "Dev", Some(2)))?;
db.save_profile(&sample("b", "Draw", Some(1)))?;
db.save_profile(&sample("c", "Misc", None))?;
// sort_order 优先,NULL 排最后
let all = db.get_all_profiles()?;
assert_eq!(
all.iter().map(|p| p.id.as_str()).collect::<Vec<_>>(),
vec!["b", "a", "c"]
);
let got = db.get_profile("a")?.expect("profile a exists");
assert_eq!(got.name, "Dev");
assert!(got.payload.contains("providers"));
// 整行替换更新
let mut updated = sample("a", "Dev Renamed", Some(2));
updated.updated_at = Some(2_000);
db.save_profile(&updated)?;
let got = db.get_profile("a")?.expect("profile a exists");
assert_eq!(got.name, "Dev Renamed");
assert_eq!(got.updated_at, Some(2_000));
assert!(db.delete_profile("a")?);
assert!(!db.delete_profile("a")?);
assert!(db.get_profile("a")?.is_none());
Ok(())
}
#[test]
fn test_current_profile_id_is_scoped() -> Result<(), AppError> {
let db = Database::memory()?;
assert_eq!(db.get_current_profile_id("claude")?, None);
assert_eq!(db.get_current_profile_id("codex")?, None);
// 两个分组的标记互不影响
db.set_current_profile_id("claude", Some("a"))?;
db.set_current_profile_id("codex", Some("b"))?;
assert_eq!(db.get_current_profile_id("claude")?, Some("a".to_string()));
assert_eq!(db.get_current_profile_id("codex")?, Some("b".to_string()));
db.set_current_profile_id("claude", None)?;
assert_eq!(db.get_current_profile_id("claude")?, None);
assert_eq!(db.get_current_profile_id("codex")?, Some("b".to_string()));
// 重复清除应幂等
db.set_current_profile_id("claude", None)?;
assert_eq!(db.get_current_profile_id("claude")?, None);
Ok(())
}
}
+22 -1
View File
@@ -710,7 +710,9 @@ impl Database {
#[cfg(test)]
mod ensure_official_seed_tests {
use crate::app_config::AppType;
use crate::database::{Database, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID};
use crate::database::{
Database, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, CODEX_OFFICIAL_PROVIDER_ID,
};
#[test]
fn ensure_inserts_when_missing() {
@@ -769,6 +771,25 @@ mod ensure_official_seed_tests {
);
}
#[test]
fn ensure_recreates_codex_official_seed_after_deletion() {
let db = Database::memory().expect("memory db");
db.init_default_official_providers().expect("seed");
db.delete_provider(AppType::Codex.as_str(), CODEX_OFFICIAL_PROVIDER_ID)
.expect("delete Codex official");
let inserted = db
.ensure_official_seed_by_id(CODEX_OFFICIAL_PROVIDER_ID, AppType::Codex)
.expect("ensure Codex official");
assert!(inserted);
let provider = db
.get_provider_by_id(CODEX_OFFICIAL_PROVIDER_ID, AppType::Codex.as_str())
.expect("query")
.expect("Codex official restored");
assert_eq!(provider.category.as_deref(), Some("official"));
assert_eq!(provider.settings_config["auth"], serde_json::json!({}));
}
#[test]
fn ensure_rejects_unknown_seed() {
let db = Database::memory().expect("memory db");
+2 -1
View File
@@ -11,6 +11,7 @@
use crate::app_config::AppType;
pub(crate) const CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID: &str = "claude-desktop-official";
pub(crate) const CODEX_OFFICIAL_PROVIDER_ID: &str = "codex-official";
/// 单条官方供应商种子定义。
pub(crate) struct OfficialProviderSeed {
@@ -49,7 +50,7 @@ pub(crate) const OFFICIAL_SEEDS: &[OfficialProviderSeed] = &[
settings_config_json: r#"{"env":{}}"#,
},
OfficialProviderSeed {
id: "codex-official",
id: CODEX_OFFICIAL_PROVIDER_ID,
app_type: AppType::Codex,
name: "OpenAI Official",
website_url: "https://chatgpt.com/codex",
+30
View File
@@ -328,6 +328,7 @@ impl Database {
"claude" => (6, 90, 180, 8, 3, 90, 0.7, 15),
"codex" => (3, 60, 120, 4, 2, 60, 0.6, 10),
"gemini" => (5, 60, 120, 4, 2, 60, 0.6, 10),
"grokbuild" => (3, 60, 120, 4, 2, 60, 0.6, 10),
_ => (3, 60, 120, 4, 2, 60, 0.6, 10), // 默认值
};
@@ -398,6 +399,18 @@ impl Database {
)
.map_err(|e| AppError::Database(e.to_string()))?;
// grokbuild: Responses protocol, same timeout defaults as Codex.
conn.execute(
"INSERT OR IGNORE INTO proxy_config (
app_type, max_retries,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests
) VALUES ('grokbuild', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
@@ -494,6 +507,23 @@ impl Database {
Ok(count > 0)
}
/// 同步版本:检查是否有任一 app 的 enabled = true
///
/// 用于 `ProfileService::apply` 等 sync 路径判断是否需要停止代理服务。
pub fn is_live_takeover_active_sync(&self) -> bool {
let conn = match self.conn.lock() {
Ok(c) => c,
Err(_) => return false,
};
conn.query_row(
"SELECT COUNT(*) FROM proxy_config WHERE enabled = 1",
[],
|row| row.get::<_, i64>(0),
)
.unwrap_or(0)
> 0
}
// ==================== Provider Health ====================
/// 获取Provider健康状态
+37 -36
View File
@@ -22,8 +22,8 @@ impl Database {
let mut stmt = conn
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
enabled_hermes, installed_at, content_hash, updated_at
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild,
enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at
FROM skills ORDER BY name ASC",
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -43,12 +43,13 @@ impl Database {
claude: row.get(8)?,
codex: row.get(9)?,
gemini: row.get(10)?,
opencode: row.get(11)?,
hermes: row.get(12)?,
grokbuild: row.get(11)?,
opencode: row.get(12)?,
hermes: row.get(13)?,
},
installed_at: row.get(13)?,
content_hash: row.get(14)?,
updated_at: row.get::<_, i64>(15).unwrap_or(0),
installed_at: row.get(14)?,
content_hash: row.get(15)?,
updated_at: row.get::<_, i64>(16).unwrap_or(0),
})
})
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -67,8 +68,8 @@ impl Database {
let mut stmt = conn
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
enabled_hermes, installed_at, content_hash, updated_at
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild,
enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at
FROM skills WHERE id = ?1",
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -87,12 +88,13 @@ impl Database {
claude: row.get(8)?,
codex: row.get(9)?,
gemini: row.get(10)?,
opencode: row.get(11)?,
hermes: row.get(12)?,
grokbuild: row.get(11)?,
opencode: row.get(12)?,
hermes: row.get(13)?,
},
installed_at: row.get(13)?,
content_hash: row.get(14)?,
updated_at: row.get::<_, i64>(15).unwrap_or(0),
installed_at: row.get(14)?,
content_hash: row.get(15)?,
updated_at: row.get::<_, i64>(16).unwrap_or(0),
})
});
@@ -109,9 +111,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, enabled_hermes,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, 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, ?16)",
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
params![
skill.id,
skill.name,
@@ -124,6 +126,7 @@ impl Database {
skill.apps.claude,
skill.apps.codex,
skill.apps.gemini,
skill.apps.grokbuild,
skill.apps.opencode,
skill.apps.hermes,
skill.installed_at,
@@ -157,8 +160,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, enabled_hermes = ?5 WHERE id = ?6",
params![apps.claude, apps.codex, apps.gemini, apps.opencode, apps.hermes, id],
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_grokbuild = ?4, enabled_opencode = ?5, enabled_hermes = ?6 WHERE id = ?7",
params![apps.claude, apps.codex, apps.gemini, apps.grokbuild, apps.opencode, apps.hermes, id],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(affected > 0)
@@ -232,32 +235,30 @@ impl Database {
Ok(())
}
/// 初始化默认的 Skill 仓库(启动时调用,补充缺失的默认仓库
/// 初始化默认的 Skill 仓库(启动时调用,每个数据库仅执行一次
pub fn init_default_skill_repos(&self) -> Result<usize, AppError> {
// 获取已有仓库列表
let existing = self.get_skill_repos()?;
let existing_keys: std::collections::HashSet<(String, String)> = existing
.iter()
.map(|r| (r.owner.clone(), r.name.clone()))
.collect();
const INITIALIZED_KEY: &str = "default_skill_repos_initialized";
if self.get_bool_flag(INITIALIZED_KEY)? {
return Ok(0);
}
// 兼容升级前已经存在的用户选择,并记录初始化状态,避免以后删空后恢复默认值。
if !self.get_skill_repos()?.is_empty() {
self.set_setting(INITIALIZED_KEY, "true")?;
return Ok(0);
}
// 获取默认仓库列表
let default_store = crate::services::skill::SkillStore::default();
let mut count = 0;
// 仅插入缺失的默认仓库
for repo in &default_store.repos {
let key = (repo.owner.clone(), repo.name.clone());
if !existing_keys.contains(&key) {
self.save_skill_repo(repo)?;
count += 1;
log::info!("补充默认 Skill 仓库: {}/{}", repo.owner, repo.name);
}
self.save_skill_repo(repo)?;
count += 1;
log::info!("初始化默认 Skill 仓库: {}/{}", repo.owner, repo.name);
}
if count > 0 {
log::info!("补充默认 Skill 仓库完成,新增 {count} 个");
}
self.set_setting(INITIALIZED_KEY, "true")?;
Ok(count)
}
}
+42 -4
View File
@@ -4,6 +4,7 @@
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::services::sql_helpers::{fresh_input_sql, INPUT_TOKEN_SEMANTICS_FRESH};
use crate::services::usage_stats::effective_usage_log_filter;
use chrono::{Duration, Local, TimeZone};
@@ -115,6 +116,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.
let effective_filter = effective_usage_log_filter("l");
let fresh_detail_input = fresh_input_sql("l");
let fresh_old_input = fresh_input_sql("old");
// request_model 维度保留路由接管的「客户端别名 → 真实模型」映射,
// pricing_model 维度保留写入时的计价基准(request 计价模式下与 model 分叉);
// 明细行的这两列可能为 NULL(历史/手工数据),归一为 ''。
@@ -124,15 +127,16 @@ impl Database {
request_count, success_count,
input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens,
total_cost_usd, avg_latency_ms)
input_token_semantics, total_cost_usd, avg_latency_ms)
SELECT
d, a, p, m, rm, pm,
COALESCE(old.request_count, 0) + new_req,
COALESCE(old.success_count, 0) + new_succ,
COALESCE(old.input_tokens, 0) + new_in,
COALESCE({fresh_old_input}, 0) + new_in,
COALESCE(old.output_tokens, 0) + new_out,
COALESCE(old.cache_read_tokens, 0) + new_cr,
COALESCE(old.cache_creation_tokens, 0) + new_cc,
{INPUT_TOKEN_SEMANTICS_FRESH},
CAST(COALESCE(CAST(old.total_cost_usd AS REAL), 0) + new_cost AS TEXT),
CASE WHEN COALESCE(old.request_count, 0) + new_req > 0
THEN (COALESCE(old.avg_latency_ms, 0) * COALESCE(old.request_count, 0)
@@ -147,7 +151,7 @@ impl Database {
COALESCE(l.pricing_model, '') as pm,
COUNT(*) as new_req,
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({fresh_detail_input}), 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,
@@ -327,7 +331,7 @@ mod tests {
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!(*input_tokens, 90, "rollup stores normalized fresh input");
assert_eq!(*output_tokens, 20);
assert_eq!(*cache_read_tokens, 10);
@@ -340,6 +344,40 @@ mod tests {
Ok(())
}
#[test]
fn test_rollup_normalizes_total_cache_semantics_to_fresh() -> Result<(), AppError> {
let db = Database::memory()?;
let old_ts = chrono::Utc::now().timestamp() - 40 * 86400;
{
let conn = crate::database::lock_conn!(db.conn);
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
input_token_semantics, total_cost_usd,
latency_ms, status_code, created_at
) VALUES ('total-semantics-rollup', 'p1', 'codex', 'gpt-5.5',
100, 5, 10, 20, 1, '0.10', 100, 200, ?1)",
[old_ts],
)?;
}
assert_eq!(db.rollup_and_prune(30)?, 1);
let conn = crate::database::lock_conn!(db.conn);
let row: (i64, i64, i64, i64) = conn.query_row(
"SELECT input_tokens, cache_read_tokens, cache_creation_tokens,
input_token_semantics
FROM usage_daily_rollups WHERE model = 'gpt-5.5'",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)?;
assert_eq!(row, (70, 10, 20, 2));
Ok(())
}
#[test]
fn test_rollup_preserves_request_model_dimension() -> Result<(), AppError> {
let db = Database::memory()?;
+5 -2
View File
@@ -32,12 +32,15 @@ mod schema;
mod tests;
// DAO 类型导出供外部使用
pub(crate) use dao::providers_seed::{is_official_seed_id, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID};
pub(crate) use dao::providers_seed::{
is_official_seed_id, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, CODEX_OFFICIAL_PROVIDER_ID,
};
pub(crate) use dao::proxy::{
validate_cost_multiplier, validate_pricing_source, PRICING_SOURCE_REQUEST,
PRICING_SOURCE_RESPONSE,
};
pub use dao::FailoverQueueItem;
pub use dao::Profile;
use crate::config::get_app_config_dir;
use crate::error::AppError;
@@ -49,7 +52,7 @@ use std::sync::Mutex;
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 11;
pub(crate) const SCHEMA_VERSION: i32 = 15;
/// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
+432 -3
View File
@@ -65,7 +65,8 @@ impl Database {
id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',
enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_grokbuild 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_claude BOOLEAN NOT NULL DEFAULT 0,
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
enabled_grokbuild 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,
@@ -122,7 +124,7 @@ impl Database {
// 8. Proxy Config 表(三行结构,app_type 主键)
conn.execute("CREATE TABLE IF NOT EXISTS proxy_config (
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini','grokbuild')),
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
listen_port INTEGER NOT NULL DEFAULT 15721, enable_logging INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
@@ -169,6 +171,15 @@ impl Database {
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests)
VALUES ('grokbuild', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
// 9. Provider Health 表
@@ -189,6 +200,7 @@ impl Database {
pricing_model TEXT,
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
input_token_semantics INTEGER NOT NULL DEFAULT 0,
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
cache_read_cost_usd TEXT NOT NULL DEFAULT '0', cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
total_cost_usd TEXT NOT NULL DEFAULT '0', latency_ms INTEGER NOT NULL, first_token_ms INTEGER,
@@ -275,6 +287,7 @@ impl Database {
output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
input_token_semantics INTEGER NOT NULL DEFAULT 0,
total_cost_usd TEXT NOT NULL DEFAULT '0',
avg_latency_ms INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (date, app_type, provider_id, model, request_model, pricing_model)
@@ -295,6 +308,35 @@ impl Database {
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 19. Profiles 表(全应用共享的项目实体,payload 按 app 分槽快照
// 供应商/MCP/Skills/Prompt;各应用分组的 current 标记在 settings 表)
conn.execute(
"CREATE TABLE IF NOT EXISTS profiles (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
payload TEXT NOT NULL,
sort_order INTEGER,
created_at INTEGER,
updated_at INTEGER
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 修复跑过未发布开发版的库:current 标记曾是全局 key,现按应用分组
// (随 v12 定稿为 current_profile_id_<scope>,不单独 bump 版本)
if conn
.execute(
"INSERT OR REPLACE INTO settings (key, value)
SELECT 'current_profile_id_claude', value FROM settings
WHERE key = 'current_profile_id'",
[],
)
.is_ok()
{
let _ = conn.execute("DELETE FROM settings WHERE key = 'current_profile_id'", []);
}
// 尝试添加 live_takeover_active 列到 proxy_config 表
let _ = conn.execute(
"ALTER TABLE proxy_config ADD COLUMN live_takeover_active INTEGER NOT NULL DEFAULT 0",
@@ -444,6 +486,26 @@ impl Database {
Self::migrate_v10_to_v11(conn)?;
Self::set_user_version(conn, 11)?;
}
11 => {
log::info!("迁移数据库从 v11 到 v12(添加项目 Profiles 表)");
Self::migrate_v11_to_v12(conn)?;
Self::set_user_version(conn, 12)?;
}
12 => {
log::info!("迁移数据库从 v12 到 v13(记录输入 token 缓存语义)");
Self::migrate_v12_to_v13(conn)?;
Self::set_user_version(conn, 13)?;
}
13 => {
log::info!("迁移数据库从 v13 到 v14(添加 Grok Build 代理配置)");
Self::migrate_v13_to_v14(conn)?;
Self::set_user_version(conn, 14)?;
}
14 => {
log::info!("迁移数据库从 v14 到 v15Skills/MCP 添加 Grok Build 支持)");
Self::migrate_v14_to_v15(conn)?;
Self::set_user_version(conn, 15)?;
}
_ => {
return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
@@ -616,6 +678,7 @@ impl Database {
request_model TEXT,
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
input_token_semantics INTEGER NOT NULL DEFAULT 0,
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
cache_read_cost_usd TEXT NOT NULL DEFAULT '0', cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
total_cost_usd TEXT NOT NULL DEFAULT '0', latency_ms INTEGER NOT NULL, first_token_ms INTEGER,
@@ -756,12 +819,25 @@ impl Database {
old_cb.3,
old_cb.4,
),
(
"grokbuild",
false,
false,
3,
old_config.4,
old_config.5,
old_cb.0,
old_cb.1,
old_cb.2,
old_cb.3,
old_cb.4,
),
];
// 创建新表
conn.execute("DROP TABLE IF EXISTS proxy_config_new", [])?;
conn.execute("CREATE TABLE proxy_config_new (
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini','grokbuild')),
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
listen_port INTEGER NOT NULL DEFAULT 15721, enable_logging INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
@@ -772,6 +848,7 @@ impl Database {
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
default_cost_multiplier TEXT NOT NULL DEFAULT '1',
pricing_model_source TEXT NOT NULL DEFAULT 'response',
live_takeover_active INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)", [])?;
@@ -1270,6 +1347,169 @@ impl Database {
Ok(())
}
/// v11 -> v12 迁移:添加项目 Profiles 表
/// 与 create_tables_on_conn 中的建表语句保持一致(IF NOT EXISTS 保证幂等)
fn migrate_v11_to_v12(conn: &Connection) -> Result<(), AppError> {
conn.execute(
"CREATE TABLE IF NOT EXISTS profiles (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
payload TEXT NOT NULL,
sort_order INTEGER,
created_at INTEGER,
updated_at INTEGER
)",
[],
)
.map_err(|e| AppError::Database(format!("v11 -> v12 创建 profiles 表失败: {e}")))?;
Ok(())
}
/// v12 -> v13:记录 input_tokens 是否包含缓存写入。
///
/// 默认 0 表示旧版/未知语义;旧 Codex 行只包含 cache read,不包含
/// cache creation。新代理行会显式写入 1(total-inclusive) 或 2(fresh)。
fn migrate_v12_to_v13(conn: &Connection) -> Result<(), AppError> {
if Self::table_exists(conn, "proxy_request_logs")? {
Self::add_column_if_missing(
conn,
"proxy_request_logs",
"input_token_semantics",
"INTEGER NOT NULL DEFAULT 0",
)?;
}
if Self::table_exists(conn, "usage_daily_rollups")? {
Self::add_column_if_missing(
conn,
"usage_daily_rollups",
"input_token_semantics",
"INTEGER NOT NULL DEFAULT 0",
)?;
}
Ok(())
}
/// v13 -> v14: allow Grok Build to own an independent proxy configuration row.
fn migrate_v13_to_v14(conn: &Connection) -> Result<(), AppError> {
if !Self::table_exists(conn, "proxy_config")? {
return Ok(());
}
conn.execute("DROP TABLE IF EXISTS proxy_config_v14", [])
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE TABLE proxy_config_v14 (
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini','grokbuild')),
proxy_enabled INTEGER NOT NULL DEFAULT 0,
listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
listen_port INTEGER NOT NULL DEFAULT 15721,
enable_logging INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 0,
auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3,
streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,
streaming_idle_timeout INTEGER NOT NULL DEFAULT 120,
non_streaming_timeout INTEGER NOT NULL DEFAULT 600,
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4,
circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60,
circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
default_cost_multiplier TEXT NOT NULL DEFAULT '1',
pricing_model_source TEXT NOT NULL DEFAULT 'response',
live_takeover_active INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
let copied_columns = [
("app_type", "'claude'"),
("proxy_enabled", "0"),
("listen_address", "'127.0.0.1'"),
("listen_port", "15721"),
("enable_logging", "1"),
("enabled", "0"),
("auto_failover_enabled", "0"),
("max_retries", "3"),
("streaming_first_byte_timeout", "60"),
("streaming_idle_timeout", "120"),
("non_streaming_timeout", "600"),
("circuit_failure_threshold", "4"),
("circuit_success_threshold", "2"),
("circuit_timeout_seconds", "60"),
("circuit_error_rate_threshold", "0.6"),
("circuit_min_requests", "10"),
("default_cost_multiplier", "'1'"),
("pricing_model_source", "'response'"),
("live_takeover_active", "0"),
("created_at", "datetime('now')"),
("updated_at", "datetime('now')"),
]
.into_iter()
.map(|(column, fallback)| {
Self::has_column(conn, "proxy_config", column).map(|exists| {
if exists {
format!("\"{column}\"")
} else {
fallback.into()
}
})
})
.collect::<Result<Vec<_>, AppError>>()?
.join(", ");
let copy_sql = format!(
"INSERT INTO proxy_config_v14 (
app_type, proxy_enabled, listen_address, listen_port, enable_logging,
enabled, auto_failover_enabled, max_retries,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests,
default_cost_multiplier, pricing_model_source, live_takeover_active,
created_at, updated_at
)
SELECT {copied_columns} FROM proxy_config"
);
conn.execute(&copy_sql, [])
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute("DROP TABLE proxy_config", [])
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute("ALTER TABLE proxy_config_v14 RENAME TO proxy_config", [])
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"INSERT OR IGNORE INTO proxy_config (app_type) VALUES ('grokbuild')",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// v14 -> v15: persist Grok Build enablement for unified Skills and MCP.
fn migrate_v14_to_v15(conn: &Connection) -> Result<(), AppError> {
if Self::table_exists(conn, "mcp_servers")? {
Self::add_column_if_missing(
conn,
"mcp_servers",
"enabled_grokbuild",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
}
if Self::table_exists(conn, "skills")? {
Self::add_column_if_missing(
conn,
"skills",
"enabled_grokbuild",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
}
Ok(())
}
/// 插入默认模型定价数据
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
@@ -1301,6 +1541,15 @@ impl Database {
"0.50",
"6.25",
),
// Claude Sonnet 5list 价,与 Sonnet 4.6 一致;促销 $2/$10 至 2026-08-31 不入表)
(
"claude-sonnet-5",
"Claude Sonnet 5",
"3",
"15",
"0.30",
"3.75",
),
// Claude 4.7 系列
(
"claude-opus-4-7",
@@ -1394,6 +1643,25 @@ impl Database {
"0.30",
"3.75",
),
// GPT-5.6 系列(Sol / Terra / Luna2026-06 发布)
// 5.6 家族起 cache write 收 1.25× 输入价(此前 GPT 模型写缓存免费,勿回填旧系列)
("gpt-5.6-sol", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"),
(
"gpt-5.6-terra",
"GPT-5.6 Terra",
"2.50",
"15",
"0.25",
"3.125",
),
("gpt-5.6-luna", "GPT-5.6 Luna", "1", "6", "0.10", "1.25"),
// 裸名 gpt-5.6 是 sol 的官方别名;effort 后缀对齐 gpt-5.5 系列的记账形态
("gpt-5.6", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"),
("gpt-5.6-low", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"),
("gpt-5.6-medium", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"),
("gpt-5.6-high", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"),
("gpt-5.6-xhigh", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"),
("gpt-5.6-minimal", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"),
// 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"),
@@ -1831,6 +2099,9 @@ impl Database {
"0.19",
"0",
),
// 腾讯混元 (Tencent Hunyuan)(官方 CNY 1/4/0.25 按 1 USD ≈ 7.14 折算;Hy3 阶梯计价取最低档)
("hunyuan-hy3", "Hunyuan Hy3", "0.14", "0.56", "0.035", "0"),
("hy3", "Hunyuan Hy3", "0.14", "0.56", "0.035", "0"),
// MiniMax 系列
("minimax-m2.1", "MiniMax M2.1", "0.27", "0.95", "0.03", "0"),
(
@@ -2126,6 +2397,44 @@ impl Database {
fn repair_current_model_pricing(conn: &Connection) -> Result<(), AppError> {
let pricing_fixes = [
// 2026-07-12 GPT-5.6 家族 cache write=1.25× 输入价(OpenAI 5.6 起的新规),
// 修正早期 seed 的 0 值;只匹配未被用户改过的行
(
"gpt-5.6-sol",
"GPT-5.6 Sol",
"5",
"30",
"0.50",
"6.25",
"5",
"30",
"0.50",
"0",
),
(
"gpt-5.6-terra",
"GPT-5.6 Terra",
"2.50",
"15",
"0.25",
"3.125",
"2.50",
"15",
"0.25",
"0",
),
(
"gpt-5.6-luna",
"GPT-5.6 Luna",
"1",
"6",
"0.10",
"1.25",
"1",
"6",
"0.10",
"0",
),
// 2026-06-10 全量核价(厂商官方 list 价;CNY 按 ~7.14 折算)
// GLM 4.6/4.7:旧值是中转/OpenRouter 折扣价,统一到 Z.ai 官方(与 glm-5/5.1 一致)
(
@@ -2593,3 +2902,123 @@ impl Database {
Ok(true)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn migrate_v12_to_v13_adds_input_token_semantics_columns() -> Result<(), AppError> {
let conn = Connection::open_in_memory()?;
conn.execute(
"CREATE TABLE proxy_request_logs (request_id TEXT PRIMARY KEY)",
[],
)?;
conn.execute(
"CREATE TABLE usage_daily_rollups (date TEXT PRIMARY KEY)",
[],
)?;
Database::set_user_version(&conn, 12)?;
Database::apply_schema_migrations_on_conn(&conn)?;
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
assert!(Database::has_column(
&conn,
"proxy_request_logs",
"input_token_semantics"
)?);
assert!(Database::has_column(
&conn,
"usage_daily_rollups",
"input_token_semantics"
)?);
let log_default: i64 = conn.query_row(
"SELECT dflt_value = '0' FROM pragma_table_info('proxy_request_logs')
WHERE name = 'input_token_semantics'",
[],
|row| row.get(0),
)?;
assert_eq!(log_default, 1);
Ok(())
}
#[test]
fn migrate_v13_to_v14_adds_grokbuild_proxy_row_and_preserves_values() -> Result<(), AppError> {
let conn = Connection::open_in_memory()?;
Database::create_tables_on_conn(&conn)?;
conn.execute("DELETE FROM proxy_config WHERE app_type = 'grokbuild'", [])?;
conn.execute(
"UPDATE proxy_config SET enabled = 1, max_retries = 9 WHERE app_type = 'codex'",
[],
)?;
Database::set_user_version(&conn, 13)?;
Database::apply_schema_migrations_on_conn(&conn)?;
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
let grok_rows: i64 = conn.query_row(
"SELECT COUNT(*) FROM proxy_config WHERE app_type = 'grokbuild'",
[],
|row| row.get(0),
)?;
assert_eq!(grok_rows, 1);
let codex_values: (i64, i64) = conn.query_row(
"SELECT enabled, max_retries FROM proxy_config WHERE app_type = 'codex'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
assert_eq!(codex_values, (1, 9));
Ok(())
}
#[test]
fn migrate_v14_to_v15_adds_grokbuild_skill_and_mcp_flags() -> Result<(), AppError> {
let conn = Connection::open_in_memory()?;
conn.execute_batch(
"CREATE TABLE mcp_servers (
id TEXT PRIMARY KEY,
enabled_codex BOOLEAN NOT NULL DEFAULT 0
);
CREATE TABLE skills (
id TEXT PRIMARY KEY,
enabled_codex BOOLEAN NOT NULL DEFAULT 0
);",
)?;
conn.execute(
"INSERT INTO mcp_servers (id, enabled_codex) VALUES ('mcp-1', 1)",
[],
)?;
conn.execute(
"INSERT INTO skills (id, enabled_codex) VALUES ('skill-1', 1)",
[],
)?;
Database::set_user_version(&conn, 14)?;
Database::apply_schema_migrations_on_conn(&conn)?;
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
assert!(Database::has_column(
&conn,
"mcp_servers",
"enabled_grokbuild"
)?);
assert!(Database::has_column(&conn, "skills", "enabled_grokbuild")?);
let mcp_values: (i64, i64) = conn.query_row(
"SELECT enabled_codex, enabled_grokbuild FROM mcp_servers WHERE id = 'mcp-1'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
let skill_values: (i64, i64) = conn.query_row(
"SELECT enabled_codex, enabled_grokbuild FROM skills WHERE id = 'skill-1'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
assert_eq!(mcp_values, (1, 0));
assert_eq!(skill_values, (1, 0));
Ok(())
}
}
+90 -2
View File
@@ -151,6 +151,38 @@ fn normalize_default(default: &Option<String>) -> Option<String> {
.map(|s| s.trim_matches('\'').trim_matches('"').to_string())
}
#[test]
fn deleted_default_skill_repo_is_not_restored() {
let db = Database::memory().expect("create memory db");
assert_eq!(db.init_default_skill_repos().expect("initialize repos"), 4);
for repo in db.get_skill_repos().expect("get initialized repos") {
db.delete_skill_repo(&repo.owner, &repo.name)
.expect("delete repo");
}
assert!(db.get_skill_repos().expect("get deleted repos").is_empty());
assert_eq!(
db.init_default_skill_repos().expect("reinitialize repos"),
0
);
assert!(db.get_skill_repos().expect("get repos").is_empty());
}
#[test]
fn existing_skill_repo_selection_is_not_supplemented() {
let db = Database::memory().expect("create memory db");
let default_store = crate::services::skill::SkillStore::default();
db.save_skill_repo(&default_store.repos[0])
.expect("save existing repo");
assert_eq!(db.init_default_skill_repos().expect("initialize repos"), 0);
assert_eq!(db.get_skill_repos().expect("get repos").len(), 1);
assert!(db
.get_bool_flag("default_skill_repos_initialized")
.expect("get initialized flag"));
}
#[test]
fn schema_migration_sets_user_version_when_missing() {
let conn = Connection::open_in_memory().expect("open memory db");
@@ -426,6 +458,62 @@ fn migration_v10_to_v11_rebuilds_rollups_with_request_model_dimension() {
);
}
#[test]
fn schema_create_tables_repairs_dev_global_profile_marker() {
let conn = Connection::open_in_memory().expect("open memory db");
// 模拟跑过未发布开发版的库:user_version 已是 12(迁移不会再跑),
// 但 current 标记还是全局 key(现按应用分组)
conn.execute_batch(
r#"
CREATE TABLE profiles (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
payload TEXT NOT NULL,
sort_order INTEGER,
created_at INTEGER,
updated_at INTEGER
);
INSERT INTO profiles (id, name, payload) VALUES ('p1', 'Project A', '{}');
CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT);
INSERT INTO settings (key, value) VALUES ('current_profile_id', 'p1');
"#,
)
.expect("seed dev v12 shape");
Database::set_user_version(&conn, 12).expect("set user_version=12");
Database::create_tables_on_conn(&conn).expect("create tables should repair marker");
// 全局 current 标记改名为 claude 组标记,旧 key 删除
let claude_marker: String = conn
.query_row(
"SELECT value FROM settings WHERE key = 'current_profile_id_claude'",
[],
|row| row.get(0),
)
.expect("scoped current marker");
assert_eq!(claude_marker, "p1");
let old_marker: i64 = conn
.query_row(
"SELECT COUNT(*) FROM settings WHERE key = 'current_profile_id'",
[],
|row| row.get(0),
)
.expect("count old marker");
assert_eq!(old_marker, 0);
// 修复必须幂等:再跑一遍不应破坏已迁移的标记
Database::create_tables_on_conn(&conn).expect("repair is idempotent");
let claude_marker: String = conn
.query_row(
"SELECT value FROM settings WHERE key = 'current_profile_id_claude'",
[],
|row| row.get(0),
)
.expect("scoped current marker survives");
assert_eq!(claude_marker, "p1");
}
#[test]
fn schema_create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
let conn = Connection::open_in_memory().expect("open memory db");
@@ -461,7 +549,7 @@ fn schema_create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
let count: i32 = conn
.query_row("SELECT COUNT(*) FROM proxy_config", [], |r| r.get(0))
.expect("count rows");
assert_eq!(count, 3, "per-app proxy_config should have 3 rows");
assert_eq!(count, 4, "per-app proxy_config should have 4 rows");
// 新结构下应能按 app_type 查询
let _: i32 = conn
@@ -601,7 +689,7 @@ fn migration_from_v3_8_schema_v1_to_current_schema_v3() {
let proxy_rows: i64 = conn
.query_row("SELECT COUNT(*) FROM proxy_config", [], |r| r.get(0))
.expect("count proxy_config rows");
assert_eq!(proxy_rows, 3);
assert_eq!(proxy_rows, 4);
// model_pricing 应具备默认数据(迁移时会 seed)
let pricing_rows: i64 = conn
+40 -11
View File
@@ -101,17 +101,7 @@ pub fn import_mcp_from_deeplink(
// Server exists - merge apps only, keep other fields unchanged
log::info!("MCP server '{id}' already exists, merging apps only");
let mut merged_apps = existing.apps.clone();
// Merge new apps into existing apps
if target_apps.claude {
merged_apps.claude = true;
}
if target_apps.codex {
merged_apps.codex = true;
}
if target_apps.gemini {
merged_apps.gemini = true;
}
let merged_apps = merge_mcp_apps(&existing.apps, &target_apps);
McpServer {
id: existing.id.clone(),
@@ -166,6 +156,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
claude: false,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
};
@@ -175,6 +166,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
"claude" => apps.claude = true,
"codex" => apps.codex = true,
"gemini" => apps.gemini = true,
"grokbuild" | "grok" => apps.grokbuild = true,
"opencode" => apps.opencode = true,
"openclaw" => {
// OpenClaw doesn't support MCP, ignore silently
@@ -197,3 +189,40 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
Ok(apps)
}
fn merge_mcp_apps(existing: &McpApps, target: &McpApps) -> McpApps {
let mut merged = existing.clone();
for app in target.enabled_apps() {
merged.set_enabled_for(&app, true);
}
merged
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn enabled_apps_merge_covers_every_supported_mcp_client() {
let existing = McpApps {
claude: true,
..McpApps::default()
};
let target = McpApps {
codex: true,
gemini: true,
grokbuild: true,
opencode: true,
hermes: true,
..McpApps::default()
};
let merged = merge_mcp_apps(&existing, &target);
assert!(merged.claude);
assert!(merged.codex);
assert!(merged.gemini);
assert!(merged.grokbuild);
assert!(merged.opencode);
assert!(merged.hermes);
}
}
+13 -6
View File
@@ -81,10 +81,10 @@ fn parse_provider_deeplink(
// Validate app type
if !matches!(
app.as_str(),
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
"claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes"
) {
return Err(AppError::InvalidInput(format!(
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{app}'"
"Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', '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" | "hermes"
"claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes"
) {
return Err(AppError::InvalidInput(format!(
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{app}'"
"Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', or 'hermes', got '{app}'"
)));
}
@@ -262,10 +262,17 @@ fn parse_mcp_deeplink(
let trimmed = app.trim();
if !matches!(
trimmed,
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
"claude"
| "codex"
| "gemini"
| "grokbuild"
| "grok"
| "opencode"
| "openclaw"
| "hermes"
) {
return Err(AppError::InvalidInput(format!(
"Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{trimmed}'"
"Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', or 'hermes', got '{trimmed}'"
)));
}
}
+84
View File
@@ -145,6 +145,7 @@ pub(crate) fn build_provider_from_request(
AppType::Claude | AppType::ClaudeDesktop => build_claude_settings(request),
AppType::Codex => build_codex_settings(request),
AppType::Gemini => build_gemini_settings(request),
AppType::GrokBuild => build_grokbuild_settings(request),
AppType::OpenCode => build_opencode_settings(request),
AppType::OpenClaw => build_additive_app_settings(request),
AppType::Hermes => build_hermes_settings(request),
@@ -267,6 +268,8 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
coding_plan_provider: None,
access_key_id: None,
secret_access_key: None,
team_organization_id: None,
team_project_id: None,
};
Ok(Some(ProviderMeta {
@@ -442,6 +445,36 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
json!({ "env": env })
}
fn build_grokbuild_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
let model = request
.model
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or(crate::grok_config::DEFAULT_MODEL)
.trim();
let name = request
.name
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or("custom")
.trim();
let endpoint = get_primary_endpoint(request).trim().to_string();
let api_key = request.api_key.as_deref().unwrap_or("").trim();
let model_value = toml_edit::Value::from(model).to_string();
let name_value = toml_edit::Value::from(name).to_string();
let endpoint_value = toml_edit::Value::from(endpoint.as_str()).to_string();
let api_key_value = toml_edit::Value::from(api_key).to_string();
json!({
"config": format!(
"[models]\ndefault = {model_value}\n\n[model.{model_value}]\nmodel = {model_value}\nbase_url = {endpoint_value}\nname = {name_value}\napi_key = {api_key_value}\napi_backend = \"{}\"\ncontext_window = {}\n",
crate::grok_config::DEFAULT_API_BACKEND,
crate::grok_config::DEFAULT_CONTEXT_WINDOW,
)
})
}
/// Build OpenCode settings configuration
fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
let endpoint = get_primary_endpoint(request);
@@ -598,6 +631,7 @@ pub fn parse_and_merge_config(
"claude" => merge_claude_config(&mut merged, &config_value)?,
"codex" => merge_codex_config(&mut merged, &config_value)?,
"gemini" => merge_gemini_config(&mut merged, &config_value)?,
"grokbuild" => merge_grokbuild_config(&mut merged, &config_value)?,
// Additive mode apps use JSON config directly; pass through as-is
"openclaw" | "opencode" | "hermes" => {
merge_additive_config(&mut merged, &config_value)?;
@@ -772,6 +806,56 @@ fn merge_gemini_config(
Ok(())
}
fn merge_grokbuild_config(
request: &mut DeepLinkImportRequest,
config: &serde_json::Value,
) -> Result<(), AppError> {
let config_toml = if let Some(config_toml) = config.get("config").and_then(|v| v.as_str()) {
config_toml.to_string()
} else {
let toml_value: toml::Value = serde_json::from_value(config.clone()).map_err(|error| {
AppError::InvalidInput(format!("Invalid Grok Build config: {error}"))
})?;
toml::to_string(&toml_value).map_err(|error| {
AppError::InvalidInput(format!("Invalid Grok Build config: {error}"))
})?
};
let model = crate::grok_config::extract_model_config(&config_toml).ok_or_else(|| {
AppError::InvalidInput("Invalid Grok Build config.toml model profile".to_string())
})?;
if request
.api_key
.as_ref()
.is_none_or(|value| value.is_empty())
{
request.api_key = model.api_key.or_else(|| {
crate::grok_config::extract_credentials(&config_toml).map(|(_, api_key)| api_key)
});
}
if request
.endpoint
.as_ref()
.is_none_or(|value| value.is_empty())
{
request.endpoint = Some(model.base_url);
}
if request.model.is_none() {
request.model = Some(model.model);
}
if request
.homepage
.as_ref()
.is_none_or(|value| value.is_empty())
{
if let Some(endpoint) = request.endpoint.as_deref() {
request.homepage = infer_homepage_from_endpoint(endpoint);
}
}
Ok(())
}
/// Merge configuration for additive mode apps (OpenClaw, OpenCode)
///
/// These apps use JSON config directly, so we only extract common fields
+95
View File
@@ -87,6 +87,39 @@ fn test_parse_deeplink_with_notes() {
assert_eq!(request.notes, Some("Test notes".to_string()));
}
#[test]
fn test_parse_grokbuild_provider() {
use super::provider::build_provider_from_request;
let url = "ccswitch://v1/import?resource=provider&app=grokbuild&name=Grok%20Relay&endpoint=https%3A%2F%2Fapi.example.com%2Fv1&apiKey=secret&model=grok-4.5";
let request = parse_deeplink_url(url).unwrap();
assert_eq!(request.app.as_deref(), Some("grokbuild"));
assert_eq!(request.name.as_deref(), Some("Grok Relay"));
assert_eq!(
request.endpoint.as_deref(),
Some("https://api.example.com/v1")
);
assert_eq!(request.api_key.as_deref(), Some("secret"));
assert_eq!(request.model.as_deref(), Some("grok-4.5"));
let provider = build_provider_from_request(&AppType::GrokBuild, &request).unwrap();
let config = provider.settings_config["config"].as_str().unwrap();
let document = config.parse::<toml::Value>().unwrap();
let model = &document["model"]["grok-4.5"];
assert_eq!(document["models"]["default"].as_str(), Some("grok-4.5"));
assert_eq!(
model["base_url"].as_str(),
Some("https://api.example.com/v1")
);
assert_eq!(model["name"].as_str(), Some("Grok Relay"));
assert_eq!(model["api_key"].as_str(), Some("secret"));
assert_eq!(model["api_backend"].as_str(), Some("responses"));
assert_eq!(model["context_window"].as_integer(), Some(500_000));
}
#[test]
fn test_parse_invalid_scheme() {
let url = "https://v1/import?resource=provider&app=claude&name=Test";
@@ -500,6 +533,38 @@ experimental_bearer_token = "sk-rightcode"
assert_eq!(merged.model, Some("gpt-5-codex".to_string()));
}
#[test]
fn test_parse_and_merge_config_grokbuild() {
let config_toml = r#"[models]
default = "grok-profile"
[model."grok-profile"]
model = "grok-upstream"
base_url = "https://grok.example/v1"
name = "Grok Relay"
api_key = "sk-grok"
api_backend = "responses"
context_window = 500000
"#;
let config_json = serde_json::json!({ "config": config_toml }).to_string();
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: Some("grokbuild".to_string()),
name: Some("Grok Relay".to_string()),
config: Some(BASE64_STANDARD.encode(config_json.as_bytes())),
config_format: Some("json".to_string()),
..Default::default()
};
let merged = parse_and_merge_config(&request).expect("merge Grok Build config");
assert_eq!(merged.api_key.as_deref(), Some("sk-grok"));
assert_eq!(merged.endpoint.as_deref(), Some("https://grok.example/v1"));
assert_eq!(merged.model.as_deref(), Some("grok-upstream"));
assert_eq!(merged.homepage.as_deref(), Some("https://grok.example"));
}
#[test]
fn test_parse_and_merge_config_url_override() {
let config_json = r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-old","ANTHROPIC_BASE_URL":"https://api.anthropic.com/v1"}}"#;
@@ -709,6 +774,11 @@ fn test_parse_mcp_apps() {
assert!(!apps.codex);
assert!(apps.gemini);
let apps = parse_mcp_apps("grokbuild,opencode,hermes").unwrap();
assert!(apps.grokbuild);
assert!(apps.opencode);
assert!(apps.hermes);
let err = parse_mcp_apps("invalid").unwrap_err();
assert!(err.to_string().contains("Invalid app"));
}
@@ -731,6 +801,18 @@ fn test_parse_prompt_deeplink() {
assert!(request.enabled.unwrap());
}
#[test]
fn test_parse_grokbuild_prompt_deeplink() {
let content_b64 = BASE64_STANDARD.encode("Grok instructions");
let url = format!(
"ccswitch://v1/import?resource=prompt&app=grokbuild&name=test&content={content_b64}"
);
let request = parse_deeplink_url(&url).expect("parse Grok Build prompt deeplink");
assert_eq!(request.app.as_deref(), Some("grokbuild"));
}
#[test]
fn test_parse_mcp_deeplink() {
let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#;
@@ -747,6 +829,19 @@ fn test_parse_mcp_deeplink() {
assert!(request.enabled.unwrap());
}
#[test]
fn test_parse_grokbuild_mcp_deeplink() {
let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#;
let config_b64 = BASE64_STANDARD.encode(config);
let url = format!(
"ccswitch://v1/import?resource=mcp&apps=grokbuild&config={config_b64}&enabled=true"
);
let request = parse_deeplink_url(&url).expect("parse Grok Build MCP deeplink");
assert_eq!(request.apps.as_deref(), Some("grokbuild"));
}
#[test]
fn test_parse_skill_deeplink() {
let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev";
+516
View File
@@ -0,0 +1,516 @@
use serde_json::{json, Value};
use std::fs;
use std::path::PathBuf;
use crate::config::{get_home_dir, write_text_file};
use crate::error::AppError;
use crate::provider::Provider;
pub const DEFAULT_MODEL: &str = "grok-4.5";
pub const DEFAULT_API_BACKEND: &str = "responses";
pub const DEFAULT_CONTEXT_WINDOW: i64 = 500_000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GrokModelConfig {
pub profile: String,
pub model: String,
pub base_url: String,
pub name: String,
pub api_key: Option<String>,
pub env_key: Option<String>,
pub api_backend: String,
pub context_window: i64,
}
/// Grok Build configuration directory (`~/.grok`).
pub fn get_grok_config_dir() -> PathBuf {
crate::settings::get_grok_override_dir().unwrap_or_else(|| get_home_dir().join(".grok"))
}
/// Grok Build live configuration path (`~/.grok/config.toml`).
pub fn get_grok_config_path() -> PathBuf {
get_grok_config_dir().join("config.toml")
}
fn required_non_empty_string<'a>(
table: &'a toml::value::Table,
key: &str,
) -> Result<&'a str, AppError> {
table
.get(key)
.and_then(toml::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::localized(
"provider.grokbuild.field.missing",
format!("Grok Build 配置缺少有效的 {key} 字段"),
format!("Grok Build configuration is missing a valid {key} field"),
)
})
}
fn optional_non_empty_string(table: &toml::value::Table, key: &str) -> Option<String> {
table
.get(key)
.and_then(toml::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
/// Validate the provider-owned Grok Build TOML document.
pub fn validate_config_toml(config_toml: &str) -> Result<(), AppError> {
let document = config_toml.parse::<toml::Value>().map_err(|error| {
AppError::localized(
"provider.grokbuild.config.invalid_toml",
format!("Grok Build config.toml 格式错误: {error}"),
format!("Invalid Grok Build config.toml: {error}"),
)
})?;
let root = document.as_table().ok_or_else(|| {
AppError::localized(
"provider.grokbuild.config.not_table",
"Grok Build 配置必须是 TOML 表结构",
"Grok Build configuration must be a TOML table",
)
})?;
let models = root
.get("models")
.and_then(toml::Value::as_table)
.ok_or_else(|| {
AppError::localized(
"provider.grokbuild.models.missing",
"Grok Build 配置缺少 [models]",
"Grok Build configuration is missing [models]",
)
})?;
let default_model = required_non_empty_string(models, "default")?;
let model_entries = root
.get("model")
.and_then(toml::Value::as_table)
.ok_or_else(|| {
AppError::localized(
"provider.grokbuild.model.missing",
"Grok Build 配置缺少 [model.<name>]",
"Grok Build configuration is missing [model.<name>]",
)
})?;
let selected_model = model_entries
.get(default_model)
.and_then(toml::Value::as_table)
.ok_or_else(|| {
AppError::localized(
"provider.grokbuild.default_model.missing",
format!("Grok Build 配置缺少 [model.\"{default_model}\"]"),
format!("Grok Build configuration is missing [model.\"{default_model}\"]"),
)
})?;
required_non_empty_string(selected_model, "model")?;
required_non_empty_string(selected_model, "base_url")?;
required_non_empty_string(selected_model, "name")?;
if optional_non_empty_string(selected_model, "api_key").is_none()
&& optional_non_empty_string(selected_model, "env_key").is_none()
{
return Err(AppError::localized(
"provider.grokbuild.credentials.missing",
"Grok Build 配置缺少有效的 api_key 或 env_key 字段",
"Grok Build configuration is missing a valid api_key or env_key field",
));
}
required_non_empty_string(selected_model, "api_backend")?;
selected_model
.get("context_window")
.and_then(toml::Value::as_integer)
.filter(|value| *value > 0)
.ok_or_else(|| {
AppError::localized(
"provider.grokbuild.context_window.invalid",
"Grok Build context_window 必须是正整数",
"Grok Build context_window must be a positive integer",
)
})?;
Ok(())
}
pub fn extract_model_config(config_toml: &str) -> Option<GrokModelConfig> {
let document = config_toml.parse::<toml::Value>().ok()?;
let root = document.as_table()?;
let default_model = root
.get("models")?
.as_table()?
.get("default")?
.as_str()?
.trim();
let selected_model = root
.get("model")?
.as_table()?
.get(default_model)?
.as_table()?;
Some(GrokModelConfig {
profile: default_model.to_string(),
model: selected_model.get("model")?.as_str()?.trim().to_string(),
base_url: selected_model
.get("base_url")?
.as_str()?
.trim_end_matches('/')
.to_string(),
name: selected_model.get("name")?.as_str()?.trim().to_string(),
api_key: optional_non_empty_string(selected_model, "api_key"),
env_key: optional_non_empty_string(selected_model, "env_key"),
api_backend: selected_model
.get("api_backend")?
.as_str()?
.trim()
.to_string(),
context_window: selected_model.get("context_window")?.as_integer()?,
})
}
pub fn extract_credentials(config_toml: &str) -> Option<(String, String)> {
let config = extract_model_config(config_toml)?;
let api_key = config
.api_key
.or_else(|| {
config
.env_key
.as_deref()
.and_then(|key| std::env::var(key).ok())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
})
.or_else(|| {
std::env::var("XAI_API_KEY")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
})?;
Some((config.base_url, api_key))
}
pub fn extract_inline_api_key(config_toml: &str) -> Option<String> {
extract_model_config(config_toml)?.api_key
}
pub fn extract_base_url(config_toml: &str) -> Option<String> {
Some(extract_model_config(config_toml)?.base_url)
}
fn update_selected_model_string(
config_toml: &str,
field: &str,
value: &str,
) -> Result<String, AppError> {
let mut document = config_toml
.parse::<toml_edit::DocumentMut>()
.map_err(|error| {
AppError::localized(
"provider.grokbuild.config.invalid_toml",
format!("Grok Build config.toml 格式错误: {error}"),
format!("Invalid Grok Build config.toml: {error}"),
)
})?;
let default_model = document
.get("models")
.and_then(|item| item.get("default"))
.and_then(toml_edit::Item::as_str)
.map(str::trim)
.filter(|model| !model.is_empty())
.ok_or_else(|| {
AppError::localized(
"provider.grokbuild.default_model.missing",
"Grok Build 配置缺少 models.default",
"Grok Build configuration is missing models.default",
)
})?
.to_string();
let selected_model = document
.get_mut("model")
.and_then(|item| item.get_mut(&default_model))
.and_then(toml_edit::Item::as_table_like_mut)
.ok_or_else(|| {
AppError::localized(
"provider.grokbuild.default_model.missing",
format!("Grok Build 配置缺少 [model.\"{default_model}\"]"),
format!("Grok Build configuration is missing [model.\"{default_model}\"]"),
)
})?;
selected_model.insert(field, toml_edit::value(value));
Ok(document.to_string())
}
pub fn apply_proxy_takeover(
config_toml: &str,
proxy_base_url: &str,
token_placeholder: &str,
) -> Result<String, AppError> {
let updated = update_selected_model_string(config_toml, "base_url", proxy_base_url)?;
update_selected_model_string(&updated, "api_key", token_placeholder)
}
pub fn update_api_key(config_toml: &str, api_key: &str) -> Result<String, AppError> {
update_selected_model_string(config_toml, "api_key", api_key)
}
pub fn has_proxy_placeholder(config_toml: &str, token_placeholder: &str) -> bool {
extract_model_config(config_toml)
.and_then(|config| config.api_key)
.is_some_and(|api_key| api_key == token_placeholder)
}
pub fn base_url_matches(config_toml: &str, predicate: impl FnOnce(&str) -> bool) -> bool {
extract_model_config(config_toml).is_some_and(|config| predicate(&config.base_url))
}
/// Remove MCP projections from a provider-owned Grok Build settings snapshot.
/// MCP servers are owned by the database and projected into live config.toml.
pub fn strip_grok_mcp_servers_from_settings(settings: &mut Value) -> Result<(), AppError> {
let Some(config_text) = settings
.get("config")
.and_then(Value::as_str)
.map(str::to_string)
else {
return Ok(());
};
if !config_text.contains("mcp") {
return Ok(());
}
let mut document = config_text
.parse::<toml_edit::DocumentMut>()
.map_err(|error| AppError::Message(format!("Invalid Grok Build config.toml: {error}")))?;
let mut changed = document.as_table_mut().remove("mcp_servers").is_some();
if let Some(mcp_table) = document
.get_mut("mcp")
.and_then(toml_edit::Item::as_table_like_mut)
{
if mcp_table.remove("servers").is_some() {
changed = true;
}
if mcp_table.is_empty() {
document.as_table_mut().remove("mcp");
}
}
if changed {
if let Some(object) = settings.as_object_mut() {
object.insert("config".to_string(), Value::String(document.to_string()));
}
}
Ok(())
}
pub fn read_grok_live_settings() -> Result<Value, AppError> {
let path = get_grok_config_path();
if !path.exists() {
return Err(AppError::localized(
"grokbuild.config.missing",
"Grok Build 配置文件不存在",
"Grok Build configuration file not found",
));
}
let config = fs::read_to_string(&path).map_err(|error| AppError::io(&path, error))?;
validate_config_toml(&config)?;
Ok(json!({ "config": config }))
}
pub fn write_grok_provider_live(provider: &Provider) -> Result<(), AppError> {
let settings = provider.settings_config.as_object().ok_or_else(|| {
AppError::localized(
"provider.grokbuild.settings.not_object",
"Grok Build 配置必须是 JSON 对象",
"Grok Build configuration must be a JSON object",
)
})?;
let config = settings
.get("config")
.and_then(Value::as_str)
.ok_or_else(|| {
AppError::localized(
"provider.grokbuild.config.missing",
"Grok Build 配置缺少 config 字段",
"Grok Build configuration is missing the config field",
)
})?;
write_grok_live_settings(&json!({ "config": config }))
}
pub fn write_grok_live_settings(settings: &Value) -> Result<(), AppError> {
let config = settings
.get("config")
.and_then(Value::as_str)
.ok_or_else(|| {
AppError::localized(
"provider.grokbuild.config.missing",
"Grok Build 配置缺少 config 字段",
"Grok Build configuration is missing the config field",
)
})?;
validate_config_toml(config)?;
write_text_file(&get_grok_config_path(), config)
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use tempfile::TempDir;
fn valid_config() -> &'static str {
r#"[models]
default = "grok-4.5"
[model."grok-4.5"]
model = "grok-4.5"
base_url = "https://example.com/v1"
name = "Example"
api_key = "secret"
api_backend = "responses"
context_window = 500000
"#
}
fn valid_env_key_config() -> &'static str {
r#"[models]
default = "grok-env"
[model."grok-env"]
model = "grok-4.5"
base_url = "https://example.com/v1"
name = "Example Env"
env_key = "GROK_TEST_API_KEY"
api_backend = "responses"
context_window = 500000
"#
}
#[test]
fn validates_expected_config_shape() {
validate_config_toml(valid_config()).expect("valid Grok Build config");
validate_config_toml(valid_env_key_config()).expect("valid env_key configuration");
}
#[test]
fn rejects_missing_selected_model_table() {
let error = validate_config_toml("[models]\ndefault = \"grok-4.5\"\n")
.expect_err("missing model table should fail");
assert!(error.to_string().contains("model"));
}
#[test]
fn rejects_config_without_api_key_or_env_key() {
let config = valid_config().replace("api_key = \"secret\"\n", "");
let error = validate_config_toml(&config).expect_err("credentials should be required");
assert!(error.to_string().contains("api_key"));
assert!(error.to_string().contains("env_key"));
}
#[test]
fn extracts_selected_model_and_updates_takeover_fields() {
let selected = extract_model_config(valid_config()).expect("selected model");
assert_eq!(selected.profile, "grok-4.5");
assert_eq!(selected.model, "grok-4.5");
assert_eq!(selected.base_url, "https://example.com/v1");
let updated = apply_proxy_takeover(
valid_config(),
"http://127.0.0.1:15721/grokbuild/v1",
"PROXY_MANAGED",
)
.expect("takeover config");
let selected = extract_model_config(&updated).expect("updated selected model");
assert_eq!(selected.base_url, "http://127.0.0.1:15721/grokbuild/v1");
assert_eq!(selected.api_key.as_deref(), Some("PROXY_MANAGED"));
assert!(has_proxy_placeholder(&updated, "PROXY_MANAGED"));
}
#[test]
fn takeover_preserves_env_key_profile_and_injects_inline_placeholder() {
let updated = apply_proxy_takeover(
valid_env_key_config(),
"http://127.0.0.1:15721/grokbuild/v1",
"PROXY_MANAGED",
)
.expect("takeover config");
let selected = extract_model_config(&updated).expect("updated selected model");
assert_eq!(selected.profile, "grok-env");
assert_eq!(selected.env_key.as_deref(), Some("GROK_TEST_API_KEY"));
assert_eq!(selected.api_key.as_deref(), Some("PROXY_MANAGED"));
}
#[test]
#[serial]
fn resolves_api_key_from_configured_environment_variable() {
let original = std::env::var_os("GROK_TEST_API_KEY");
std::env::set_var("GROK_TEST_API_KEY", "env-secret");
let credentials = extract_credentials(valid_env_key_config()).expect("credentials");
assert_eq!(credentials.0, "https://example.com/v1");
assert_eq!(credentials.1, "env-secret");
match original {
Some(value) => std::env::set_var("GROK_TEST_API_KEY", value),
None => std::env::remove_var("GROK_TEST_API_KEY"),
}
}
#[test]
fn strips_projected_mcp_servers_without_touching_model_config() {
let mut settings = json!({
"config": format!(
"{}\n[mcp_servers.echo]\ncommand = \"echo\"\n",
valid_config()
)
});
strip_grok_mcp_servers_from_settings(&mut settings).expect("strip MCP servers");
let config = settings.get("config").and_then(Value::as_str).unwrap();
assert!(!config.contains("mcp_servers"));
assert!(config.contains("model = \"grok-4.5\""));
validate_config_toml(config).expect("stripped config remains valid");
}
#[test]
#[serial]
fn writes_and_reads_live_config() {
let temp = TempDir::new().expect("temp dir");
let original_test_home = std::env::var_os("CC_SWITCH_TEST_HOME");
std::env::set_var("CC_SWITCH_TEST_HOME", temp.path());
let provider = Provider::with_id(
"grok".to_string(),
"Example".to_string(),
json!({ "config": valid_config() }),
None,
);
write_grok_provider_live(&provider).expect("write live config");
let path = get_grok_config_path();
assert_eq!(path, temp.path().join(".grok").join("config.toml"));
assert_eq!(
fs::read_to_string(path).expect("read config"),
valid_config()
);
assert_eq!(
read_grok_live_settings()
.expect("read live settings")
.get("config")
.and_then(Value::as_str),
Some(valid_config())
);
match original_test_home {
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
}
}
}
+77 -24
View File
@@ -6,6 +6,7 @@ mod claude_mcp;
mod claude_plugin;
mod codex_config;
mod codex_history_migration;
mod codex_state_db;
mod commands;
mod config;
mod database;
@@ -13,12 +14,14 @@ mod deeplink;
mod error;
mod gemini_config;
mod gemini_mcp;
mod grok_config;
pub mod hermes_config;
mod init_status;
mod lightweight;
#[cfg(target_os = "linux")]
mod linux_fix;
mod mcp;
mod model_capabilities;
mod openclaw_config;
mod opencode_config;
mod panic_hook;
@@ -41,17 +44,21 @@ pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_l
pub use commands::open_provider_terminal;
pub use commands::*;
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
pub use database::Database;
pub use database::{Database, Profile};
pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest};
pub use error::AppError;
pub use mcp::{
import_from_claude, import_from_codex, import_from_gemini, remove_server_from_claude,
remove_server_from_codex, remove_server_from_gemini, sync_enabled_to_claude,
sync_enabled_to_codex, sync_enabled_to_gemini, sync_single_server_to_claude,
sync_single_server_to_codex, sync_single_server_to_gemini,
import_from_claude, import_from_codex, import_from_gemini, import_from_grokbuild,
remove_server_from_claude, remove_server_from_codex, remove_server_from_gemini,
remove_server_from_grokbuild, sync_enabled_to_claude, sync_enabled_to_codex,
sync_enabled_to_gemini, sync_single_server_to_claude, sync_single_server_to_codex,
sync_single_server_to_gemini, sync_single_server_to_grokbuild,
};
pub use prompt::Prompt;
pub use provider::{Provider, ProviderMeta};
pub use services::{
profile::{ProfilePayload, ProfileScope, ProfileService},
provider::reapply_current_codex_official_live,
skill::{migrate_skills_to_ssot, ImportSkillSelection},
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, ProxyService,
SkillService, SpeedtestService,
@@ -669,32 +676,33 @@ pub fn run() {
// 1.6. 自动同步 OpenCode / OpenClaw 的 live providers 到数据库
//
// additive 模式(OpenCode / OpenClaw)的 import 函数本身按 id 幂等
// 已有的 provider 会被跳过,所以每次启动都跑是安全的——既保证新装
// 用户开箱可见 live 中的供应商,也让外部修改的 live 文件能在重启
// 后同步到数据库(与之前依赖前端"导入当前配置"按钮手动触发不同)。
// additive 模式(OpenCode / OpenClaw)的 import 函数按 id 幂等——
// 新 id 执行导入,已有 id 则更新 settings 和 display name,所以每次
// 启动都跑是安全的:既保证新装用户开箱可见 live 中的供应商,也让外部
// 修改的 live 文件能在重启后同步到数据库(与之前依赖前端"导入当前配置"
// 按钮手动触发不同)。
//
// 底层 read_*_config 在文件不存在时返回默认空配置,因此新装且无
// live 文件的用户走 Ok(0) 路径,不会产生错误日志噪音。
match crate::services::provider::import_opencode_providers_from_live(&app_state) {
Ok(count) if count > 0 => {
log::info!("Imported {count} OpenCode provider(s) from live config");
log::info!("Synced {count} OpenCode provider(s) from live config");
}
Ok(_) => log::debug!("○ No new OpenCode providers to import"),
Ok(_) => log::debug!("○ No OpenCode provider changes from live config"),
Err(e) => log::warn!("✗ Failed to import OpenCode providers: {e}"),
}
match crate::services::provider::import_openclaw_providers_from_live(&app_state) {
Ok(count) if count > 0 => {
log::info!("Imported {count} OpenClaw provider(s) from live config");
log::info!("Synced {count} OpenClaw provider(s) from live config");
}
Ok(_) => log::debug!("○ No new OpenClaw providers to import"),
Ok(_) => log::debug!("○ No OpenClaw provider changes from live config"),
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");
log::info!("Synced {count} Hermes provider(s) from live config");
}
Ok(_) => log::debug!("○ No new Hermes providers to import"),
Ok(_) => log::debug!("○ No Hermes provider changes from live config"),
Err(e) => log::warn!("✗ Failed to import Hermes providers: {e}"),
}
@@ -777,6 +785,14 @@ pub fn run() {
Err(e) => log::warn!("✗ Failed to import Gemini MCP: {e}"),
}
match crate::services::mcp::McpService::import_from_grokbuild(&app_state) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} MCP server(s) from Grok Build");
}
Ok(_) => log::debug!("○ No Grok Build MCP servers found to import"),
Err(e) => log::warn!("✗ Failed to import Grok Build MCP: {e}"),
}
match crate::services::mcp::McpService::import_from_opencode(&app_state) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} MCP server(s) from OpenCode");
@@ -802,6 +818,7 @@ pub fn run() {
crate::app_config::AppType::Claude,
crate::app_config::AppType::Codex,
crate::app_config::AppType::Gemini,
crate::app_config::AppType::GrokBuild,
crate::app_config::AppType::OpenCode,
crate::app_config::AppType::OpenClaw,
crate::app_config::AppType::Hermes,
@@ -1192,6 +1209,7 @@ pub fn run() {
commands::get_claude_desktop_default_routes,
commands::import_claude_desktop_providers_from_claude,
commands::ensure_claude_desktop_official_provider,
commands::ensure_codex_official_provider,
commands::get_claude_config_status,
commands::get_config_status,
commands::get_claude_code_config_path,
@@ -1208,6 +1226,7 @@ pub fn run() {
commands::set_claude_common_config_snippet,
commands::get_common_config_snippet,
commands::set_common_config_snippet,
commands::update_toml_common_config_snippet,
commands::extract_common_config_snippet,
commands::read_live_provider_settings,
commands::get_settings,
@@ -1267,6 +1286,13 @@ pub fn run() {
commands::enable_prompt,
commands::import_prompt_from_file,
commands::get_current_prompt_file_content,
// Profile management (项目配置方案)
commands::list_profiles,
commands::create_profile,
commands::update_profile,
commands::delete_profile,
commands::clear_current_profile,
commands::apply_profile,
// model list fetch (OpenAI-compatible /v1/models)
commands::fetch_models_for_config,
// ours: endpoint speed test + custom endpoint management
@@ -1723,16 +1749,25 @@ pub(crate) fn remove_tray_icon_before_exit(app_handle: &tauri::AppHandle) {
///
/// 检查 `proxy_config.enabled` 字段,如果有任一应用的状态为 `true`,
/// 则自动启动代理服务并接管对应应用的 Live 配置。
async fn restore_proxy_state_on_startup(state: &store::AppState) {
// 收集需要恢复接管的应用列表(从 proxy_config.enabled 读取)
let mut apps_to_restore = Vec::new();
for app_type in ["claude", "codex", "gemini"] {
if let Ok(config) = state.db.get_proxy_config_for_app(app_type).await {
if config.enabled {
apps_to_restore.push(app_type);
}
const PROXY_STARTUP_APP_TYPES: [&str; 4] = ["claude", "codex", "gemini", "grokbuild"];
async fn enabled_proxy_apps_on_startup(db: &database::Database) -> Vec<&'static str> {
let mut apps = Vec::new();
for app_type in PROXY_STARTUP_APP_TYPES {
if db
.get_proxy_config_for_app(app_type)
.await
.is_ok_and(|config| config.enabled)
{
apps.push(app_type);
}
}
apps
}
async fn restore_proxy_state_on_startup(state: &store::AppState) {
// 收集需要恢复接管的应用列表(从 proxy_config.enabled 读取)
let apps_to_restore = enabled_proxy_apps_on_startup(&state.db).await;
if apps_to_restore.is_empty() {
log::debug!("启动时无需恢复代理状态");
@@ -2050,7 +2085,8 @@ pub fn restart_process(app_handle: &tauri::AppHandle) -> ! {
#[cfg(test)]
mod tests {
use super::{classify_exit_request, ExitRequestAction};
use super::{classify_exit_request, enabled_proxy_apps_on_startup, ExitRequestAction};
use crate::database::Database;
#[test]
fn no_code_keeps_app_alive_in_tray() {
@@ -2076,4 +2112,21 @@ mod tests {
ExitRequestAction::CleanupAndExit
);
}
#[tokio::test]
async fn startup_restore_includes_enabled_grokbuild_route() {
let db = Database::memory().expect("initialize database");
let mut config = db
.get_proxy_config_for_app("grokbuild")
.await
.expect("read Grok Build proxy config");
config.enabled = true;
db.update_proxy_config_for_app(config)
.await
.expect("enable Grok Build proxy config");
let apps = enabled_proxy_apps_on_startup(&db).await;
assert_eq!(apps, vec!["grokbuild"]);
}
}
+1
View File
@@ -91,6 +91,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
claude: true,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
+7 -9
View File
@@ -235,6 +235,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
claude: false,
codex: true,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -361,14 +362,11 @@ pub fn sync_single_server_to_codex(
let mut doc = if config_path.exists() {
let content =
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
// 尝试解析现有配置,如果失败则创建新文档(容错处理)
match content.parse::<toml_edit::DocumentMut>() {
Ok(doc) => doc,
Err(e) => {
log::warn!("解析 Codex config.toml 失败: {e},将创建新配置");
toml_edit::DocumentMut::new()
}
}
// 解析失败必须报错而不是用空文档顶替:写回空文档会把用户
// config.toml 里的其它段落(model/model_providers/注释等)整体清空
content
.parse::<toml_edit::DocumentMut>()
.map_err(|e| AppError::McpValidation(format!("解析 config.toml 失败: {e}")))?
} else {
toml_edit::DocumentMut::new()
};
@@ -559,7 +557,7 @@ fn json_value_to_toml_item(value: &Value, field_name: &str) -> Option<toml_edit:
/// 1. 核心字段(type, command, args, url, headers, env, cwd)使用强类型处理
/// 2. 扩展字段(timeout、retry 等)通过白名单列表自动转换
/// 3. 其他未知字段使用通用转换器尝试转换
fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError> {
pub(super) fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError> {
use toml_edit::{Array, Item, Table};
let mut t = Table::new();
+1
View File
@@ -87,6 +87,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError
claude: false,
codex: false,
gemini: true,
grokbuild: false,
opencode: false,
hermes: false,
},
+221
View File
@@ -0,0 +1,221 @@
//! Grok Build MCP synchronization and import.
//!
//! Grok Build uses the same top-level `[mcp_servers]` TOML layout as Codex,
//! stored alongside its model configuration in `~/.grok/config.toml`.
use serde_json::{json, Value};
use crate::app_config::{McpApps, McpServer, MultiAppConfig};
use crate::error::AppError;
use super::codex::json_server_to_toml_table;
use super::validation::validate_server_spec;
fn should_sync_grokbuild_mcp() -> bool {
crate::grok_config::get_grok_config_dir().exists()
}
fn read_config_text() -> Result<String, AppError> {
let path = crate::grok_config::get_grok_config_path();
if !path.exists() {
return Ok(String::new());
}
std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))
}
fn json_server_to_grokbuild_toml_table(server_spec: &Value) -> Result<toml_edit::Table, AppError> {
let mut table = json_server_to_toml_table(server_spec)?;
// Grok infers transport from `command` or `url` and uses `headers`, while
// Codex writes an explicit `type` plus `http_headers`.
table.remove("type");
if let Some(headers) = table.remove("http_headers") {
table.insert("headers", headers);
}
Ok(table)
}
fn toml_server_to_json(entry: &toml::value::Table) -> Value {
fn convert(value: &toml::Value) -> Option<Value> {
match value {
toml::Value::String(value) => Some(json!(value)),
toml::Value::Integer(value) => Some(json!(value)),
toml::Value::Float(value) => Some(json!(value)),
toml::Value::Boolean(value) => Some(json!(value)),
toml::Value::Datetime(value) => Some(json!(value.to_string())),
toml::Value::Array(values) => Some(Value::Array(
values.iter().filter_map(convert).collect::<Vec<_>>(),
)),
toml::Value::Table(values) => Some(Value::Object(
values
.iter()
.filter_map(|(key, value)| convert(value).map(|value| (key.clone(), value)))
.collect(),
)),
}
}
let mut spec = serde_json::Map::new();
for (key, value) in entry {
let output_key = if key == "http_headers" {
"headers"
} else {
key
};
if let Some(value) = convert(value) {
spec.insert(output_key.to_string(), value);
}
}
let default_type = if spec.contains_key("url") {
"http"
} else {
"stdio"
};
spec.entry("type".to_string())
.or_insert_with(|| json!(default_type));
Value::Object(spec)
}
pub fn import_from_grokbuild(config: &mut MultiAppConfig) -> Result<usize, AppError> {
let text = read_config_text()?;
if text.trim().is_empty() {
return Ok(0);
}
let root: toml::Table = toml::from_str(&text)
.map_err(|e| AppError::McpValidation(format!("解析 ~/.grok/config.toml 失败: {e}")))?;
let Some(entries) = root.get("mcp_servers").and_then(toml::Value::as_table) else {
return Ok(0);
};
let servers = config
.mcp
.servers
.get_or_insert_with(std::collections::HashMap::new);
let mut changed = 0;
for (id, entry) in entries {
let Some(entry) = entry.as_table() else {
continue;
};
let spec = toml_server_to_json(entry);
if let Err(error) = validate_server_spec(&spec) {
log::warn!("跳过无效 Grok Build MCP 项 '{id}': {error}");
continue;
}
if let Some(existing) = servers.get_mut(id) {
if !existing.apps.grokbuild {
existing.apps.grokbuild = true;
changed += 1;
}
} else {
servers.insert(
id.clone(),
McpServer {
id: id.clone(),
name: id.clone(),
server: spec,
apps: McpApps {
grokbuild: true,
..Default::default()
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
);
changed += 1;
}
}
Ok(changed)
}
pub fn sync_single_server_to_grokbuild(
_config: &MultiAppConfig,
id: &str,
server_spec: &Value,
) -> Result<(), AppError> {
if !should_sync_grokbuild_mcp() {
return Ok(());
}
use toml_edit::Item;
let path = crate::grok_config::get_grok_config_path();
let text = read_config_text()?;
let mut doc = if text.trim().is_empty() {
toml_edit::DocumentMut::new()
} else {
text.parse::<toml_edit::DocumentMut>().map_err(|e| {
AppError::McpValidation(format!("解析 Grok Build config.toml 失败: {e}"))
})?
};
if !doc.contains_key("mcp_servers") {
doc["mcp_servers"] = toml_edit::table();
}
doc["mcp_servers"][id] = Item::Table(json_server_to_grokbuild_toml_table(server_spec)?);
crate::config::write_text_file(&path, &doc.to_string())
}
pub fn remove_server_from_grokbuild(id: &str) -> Result<(), AppError> {
if !should_sync_grokbuild_mcp() {
return Ok(());
}
let path = crate::grok_config::get_grok_config_path();
if !path.exists() {
return Ok(());
}
let text = read_config_text()?;
let mut doc = match text.parse::<toml_edit::DocumentMut>() {
Ok(doc) => doc,
Err(error) => {
log::warn!("解析 Grok Build config.toml 失败: {error},跳过删除操作");
return Ok(());
}
};
if let Some(servers) = doc
.get_mut("mcp_servers")
.and_then(toml_edit::Item::as_table_mut)
{
servers.remove(id);
}
crate::config::write_text_file(&path, &doc.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn converts_http_headers_to_unified_headers() {
let entry: toml::value::Table = toml::from_str(
r#"type = "http"
url = "https://example.com/mcp"
http_headers = { Authorization = "Bearer token" }
"#,
)
.expect("parse table");
let spec = toml_server_to_json(&entry);
assert_eq!(spec["type"], "http");
assert_eq!(spec["headers"]["Authorization"], "Bearer token");
}
#[test]
fn writes_grokbuild_remote_server_without_codex_only_fields() {
let table = json_server_to_grokbuild_toml_table(&json!({
"type": "http",
"url": "https://example.com/mcp",
"headers": { "Authorization": "Bearer token" }
}))
.expect("convert server");
assert!(!table.contains_key("type"));
assert!(!table.contains_key("http_headers"));
assert_eq!(
table
.get("headers")
.and_then(toml_edit::Item::as_table)
.and_then(|headers| headers.get("Authorization"))
.and_then(toml_edit::Item::as_str),
Some("Bearer token")
);
}
}
+1
View File
@@ -315,6 +315,7 @@ pub fn import_from_hermes(config: &mut MultiAppConfig) -> Result<usize, AppError
claude: false,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: true,
},
+4
View File
@@ -14,6 +14,7 @@
mod claude;
mod codex;
mod gemini;
mod grokbuild;
mod hermes;
mod opencode;
mod validation;
@@ -30,6 +31,9 @@ pub use gemini::{
import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini,
sync_single_server_to_gemini,
};
pub use grokbuild::{
import_from_grokbuild, remove_server_from_grokbuild, sync_single_server_to_grokbuild,
};
pub use hermes::{import_from_hermes, remove_server_from_hermes, sync_single_server_to_hermes};
pub use opencode::{
import_from_opencode, remove_server_from_opencode, sync_single_server_to_opencode,
+1
View File
@@ -258,6 +258,7 @@ pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result<usize, AppErr
claude: false,
codex: false,
gemini: false,
grokbuild: false,
opencode: true,
hermes: false,
},
+276
View File
@@ -0,0 +1,276 @@
use serde_json::Value;
/// Image-input capability shared by Codex catalog generation and proxy request
/// rectification.
///
/// `Unknown` is intentionally distinct from `Supported`: callers may choose
/// different execution policies without duplicating the model-name registry.
/// The Codex catalog treats unknown models as image-capable (fail open), while
/// the media rectifier leaves their request bodies untouched.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ImageInputCapability {
Supported,
Unsupported,
Unknown,
}
/// Resolve image-input capability from an explicit declaration first, then the
/// confirmed text-only model registry when the caller enables registry lookup.
pub(crate) fn resolve_image_input_capability(
model: &str,
declared_support: Option<bool>,
use_confirmed_registry: bool,
) -> ImageInputCapability {
match declared_support {
Some(true) => ImageInputCapability::Supported,
Some(false) => ImageInputCapability::Unsupported,
None if use_confirmed_registry && is_confirmed_text_only_model(model) => {
ImageInputCapability::Unsupported
}
None => ImageInputCapability::Unknown,
}
}
/// Resolve a model's image-input capability from the provider settings shapes
/// accepted by the proxy (`modelCatalog.models`, `modelCatalog`, or `models`).
pub(crate) fn image_input_capability_from_settings(
settings: &Value,
model: &str,
use_confirmed_registry: bool,
) -> ImageInputCapability {
resolve_image_input_capability(
model,
declared_model_image_support(settings, model),
use_confirmed_registry,
)
}
/// Convert a catalog row's explicit modality list into the shared capability
/// representation, falling back to the text-only registry when omitted.
pub(crate) fn image_input_capability_from_modalities(
model: &str,
modalities: Option<&[String]>,
) -> ImageInputCapability {
let declared_support = modalities.map(|items| {
items
.iter()
.any(|item| item.trim().eq_ignore_ascii_case("image"))
});
resolve_image_input_capability(model, declared_support, true)
}
/// Models that CC Switch is willing to advertise to clients as text-only.
///
/// This registry is deliberately exact and fail-open. A new suffix is not
/// inherited automatically: it remains image-capable until its capability is
/// confirmed, preventing a future `-vision`/`-vl` variant from being blocked by
/// the Codex client before a request can reach the proxy.
pub(crate) fn is_confirmed_text_only_model(model: &str) -> bool {
let normalized = normalize_model_id(model);
let tail = normalized.rsplit('/').next().unwrap_or(normalized.as_str());
const CONFIRMED_TAILS: &[&str] = &[
"ark-code-latest",
"deepseek-chat",
"deepseek-reasoner",
"deepseek-v4-flash",
"deepseek-v4-pro",
"glm-5.1",
// Exact rather than prefix matching: GLM visual models use a `v`
// suffix (for example glm-5.2v), which must remain image-capable.
"glm-5.2",
"kat-coder",
"kat-coder-pro",
"kat-coder-pro v1",
"kat-coder-pro v2",
"kat-coder-pro-v1",
"kat-coder-pro-v2",
"ling-2.5-1t",
"longcat-2.0",
"longcat-flash-chat",
"minimax-m2.7",
"minimax-m2.7-highspeed",
"mimo-v2.5-pro",
"qwen3-coder-480b",
"qwen3-coder-480b-a35b-instruct",
"qwen3-coder-flash",
"qwen3-coder-next",
"qwen3-coder-plus",
"step-3.5-flash",
"step-3.5-flash-2603",
"us.deepseek.r1-v1",
];
CONFIRMED_TAILS.contains(&tail)
}
fn declared_model_image_support(settings: &Value, model: &str) -> Option<bool> {
[
settings
.get("modelCatalog")
.and_then(|catalog| catalog.get("models")),
settings.get("modelCatalog"),
settings.get("models"),
]
.into_iter()
.flatten()
.find_map(|value| declared_model_image_support_in_value(value, model))
}
fn declared_model_image_support_in_value(value: &Value, model: &str) -> Option<bool> {
if let Some(models) = value.as_array() {
return models.iter().find_map(|entry| {
model_entry_matches(entry, None, model).then(|| explicit_image_support(entry))?
});
}
let object = value.as_object()?;
object.iter().find_map(|(key, entry)| {
model_entry_matches(entry, Some(key), model).then(|| explicit_image_support(entry))?
})
}
fn explicit_image_support(entry: &Value) -> Option<bool> {
if let Some(value) = entry
.get("supportsImage")
.or_else(|| entry.get("supports_image"))
.or_else(|| entry.get("vision"))
.and_then(Value::as_bool)
{
return Some(value);
}
[
entry.get("input"),
entry.pointer("/modalities/input"),
entry.get("input_modalities"),
entry.get("inputModalities"),
]
.into_iter()
.flatten()
.find_map(input_modalities_support_image)
}
fn input_modalities_support_image(value: &Value) -> Option<bool> {
let modalities = value.as_array()?;
Some(modalities.iter().any(|item| {
item.as_str()
.map(str::trim)
.is_some_and(|item| item.eq_ignore_ascii_case("image"))
}))
}
fn model_entry_matches(entry: &Value, key: Option<&str>, model: &str) -> bool {
key.is_some_and(|key| model_ids_match(key, model))
|| ["model", "id", "name"]
.into_iter()
.filter_map(|field| entry.get(field).and_then(Value::as_str))
.any(|candidate| model_ids_match(candidate, model))
}
fn model_ids_match(candidate: &str, model: &str) -> bool {
let candidate = normalize_model_id(candidate);
let model = normalize_model_id(model);
if candidate.is_empty() || model.is_empty() {
return false;
}
if candidate == model {
return true;
}
let candidate_tail = candidate.rsplit('/').next().unwrap_or(candidate.as_str());
let model_tail = model.rsplit('/').next().unwrap_or(model.as_str());
candidate_tail == model_tail || candidate == model_tail || candidate_tail == model
}
fn normalize_model_id(value: &str) -> String {
let mut normalized = value
.trim()
.trim_start_matches("models/")
.trim()
.to_ascii_lowercase();
if let Some(stripped) =
normalized.strip_suffix(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)
{
normalized = stripped.trim().to_string();
}
normalized
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn gpt_and_unknown_models_remain_unknown_without_declarations() {
for model in ["gpt-5.4", "gpt-5.5", "gpt-5.6-sol", "custom-alias"] {
assert_eq!(
resolve_image_input_capability(model, None, true),
ImageInputCapability::Unknown,
"{model} must fail open"
);
}
}
#[test]
fn confirmed_text_only_registry_normalizes_namespaces_and_context_markers() {
assert!(is_confirmed_text_only_model("deepseek/deepseek-v4-pro"));
assert!(is_confirmed_text_only_model("GLM-5.2[1M]"));
assert!(is_confirmed_text_only_model("qwen/qwen3-coder-plus"));
assert!(is_confirmed_text_only_model(
"Qwen/Qwen3-Coder-480B-A35B-Instruct"
));
assert!(is_confirmed_text_only_model("MiniMax-M2.7-Highspeed"));
assert!(is_confirmed_text_only_model("step-3.5-flash-2603"));
assert!(!is_confirmed_text_only_model("glm-5.2v"));
}
#[test]
fn unconfirmed_family_suffixes_fail_open() {
for model in [
"minimax-m2.7-vision",
"qwen3-coder-ultra",
"qwen3-coder-vl",
"step-3.5-flash-vision",
] {
assert!(
!is_confirmed_text_only_model(model),
"unconfirmed variant {model} must not be hard-gated"
);
}
}
#[test]
fn explicit_capability_overrides_the_registry() {
assert_eq!(
resolve_image_input_capability("deepseek-v4-pro", Some(true), true),
ImageInputCapability::Supported
);
assert_eq!(
resolve_image_input_capability("gpt-5.4", Some(false), true),
ImageInputCapability::Unsupported
);
}
#[test]
fn provider_settings_support_multiple_capability_shapes() {
let settings = json!({
"modelCatalog": {
"models": [
{ "model": "vision", "modalities": { "input": ["text", "image"] } },
{ "model": "text", "inputModalities": ["text"] }
]
}
});
assert_eq!(
image_input_capability_from_settings(&settings, "vision", true),
ImageInputCapability::Supported
);
assert_eq!(
image_input_capability_from_settings(&settings, "text", true),
ImageInputCapability::Unsupported
);
}
}
+5 -4
View File
@@ -12,9 +12,9 @@ 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",
"app.prompts_unsupported",
"当前应用暂不支持 Prompts",
"This app does not support Prompts",
));
}
@@ -22,6 +22,7 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
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::GrokBuild => crate::grok_config::get_grok_config_dir(),
AppType::OpenCode => get_opencode_dir(),
AppType::OpenClaw => get_openclaw_dir(),
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
@@ -32,7 +33,7 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
AppType::Claude => "CLAUDE.md",
AppType::Codex => "AGENTS.md",
AppType::Gemini => "GEMINI.md",
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.md",
AppType::GrokBuild | AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.md",
AppType::ClaudeDesktop => unreachable!("handled above"),
};
+61 -23
View File
@@ -164,6 +164,11 @@ impl Provider {
let api_key = first_non_empty(env, &["GEMINI_API_KEY", "GOOGLE_API_KEY"]);
(base_url, api_key)
}
AppType::GrokBuild => settings
.get("config")
.and_then(Value::as_str)
.and_then(crate::grok_config::extract_credentials)
.unwrap_or_default(),
// Hermes (config.yaml) flattens credentials at the top level, snake_case.
AppType::Hermes => (
str_at(settings.get("base_url")),
@@ -259,6 +264,14 @@ pub struct UsageScript {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "secretAccessKey")]
pub secret_access_key: Option<String>,
/// 智谱团队套餐(Team Plan)的组织 ID(用量查询请求头 bigmodel-organization
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "teamOrganizationId")]
pub team_organization_id: Option<String>,
/// 智谱团队套餐(Team Plan)的项目 ID(用量查询请求头 bigmodel-project
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "teamProjectId")]
pub team_project_id: Option<String>,
}
/// 用量数据
@@ -295,26 +308,6 @@ pub struct UsageResult {
pub error: Option<String>,
}
/// 供应商单独的连通检测配置
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderTestConfig {
/// 是否启用单独配置(false 时使用全局配置)
#[serde(default)]
pub enabled: bool,
/// 超时时间(秒)
#[serde(rename = "timeoutSecs", skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,
/// 降级阈值(毫秒)
#[serde(
rename = "degradedThresholdMs",
skip_serializing_if = "Option::is_none"
)]
pub degraded_threshold_ms: Option<u64>,
/// 最大重试次数
#[serde(rename = "maxRetries", skip_serializing_if = "Option::is_none")]
pub max_retries: Option<u32>,
}
/// 认证绑定来源
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
@@ -446,9 +439,6 @@ pub struct ProviderMeta {
/// 每月消费限额(USD
#[serde(rename = "limitMonthlyUsd", skip_serializing_if = "Option::is_none")]
pub limit_monthly_usd: Option<String>,
/// 供应商单独的模型测试配置
#[serde(rename = "testConfig", skip_serializing_if = "Option::is_none")]
pub test_config: Option<ProviderTestConfig>,
/// Claude API 格式(仅 Claude 供应商使用)
/// - "anthropic": 原生 Anthropic Messages API,直接透传
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
@@ -472,12 +462,36 @@ pub struct ProviderMeta {
/// 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>,
/// Session-based prompt-cache routing for Codex Responses -> Chat conversions.
/// "auto" enables known-compatible upstreams; "enabled" / "disabled" are overrides.
#[serde(rename = "promptCacheRouting", skip_serializing_if = "Option::is_none")]
pub prompt_cache_routing: 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>,
/// Codex Responses -> Chat Completions reasoning capability metadata.
#[serde(rename = "codexChatReasoning", skip_serializing_if = "Option::is_none")]
pub codex_chat_reasoning: Option<CodexChatReasoningConfig>,
/// Codex → Anthropic path: whether to emulate the Claude Code client
/// (User-Agent / anthropic-beta / x-app + injecting the Claude Code system
/// prompt first line). Disabled by default; only an explicit `true` enables it.
#[serde(
rename = "impersonateClaudeCode",
skip_serializing_if = "Option::is_none"
)]
pub impersonate_claude_code: Option<bool>,
/// Codex → Anthropic path: override the Anthropic `max_tokens` (output ceiling).
///
/// Codex does not forward its `model_max_output_tokens` in the Responses
/// request body, so without this the path falls back to a conservative
/// default (8192), which truncates long or thinking-heavy responses
/// (`stop_reason=max_tokens`). When set (>0), this value is injected as the
/// request's `max_output_tokens` before conversion, taking precedence over
/// both any request-supplied value and the default. Kept per-provider on
/// purpose: a global large default would hard-400 on low-output-ceiling
/// models/gateways (and that error is non-retryable).
#[serde(rename = "maxOutputTokens", skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<u64>,
/// Custom User-Agent for local proxy routing.
#[serde(rename = "customUserAgent", skip_serializing_if = "Option::is_none")]
pub custom_user_agent: Option<String>,
@@ -985,6 +999,30 @@ mod tests {
assert!(value.get("pricingModelSource").is_none());
}
#[test]
fn provider_meta_roundtrips_max_output_tokens() {
let meta = ProviderMeta {
max_output_tokens: Some(64000),
..ProviderMeta::default()
};
let value = serde_json::to_value(&meta).expect("serialize ProviderMeta");
assert_eq!(
value.get("maxOutputTokens").and_then(|v| v.as_u64()),
Some(64000)
);
assert!(value.get("max_output_tokens").is_none());
let parsed: ProviderMeta = serde_json::from_value(value).expect("deserialize ProviderMeta");
assert_eq!(parsed.max_output_tokens, Some(64000));
}
#[test]
fn provider_meta_omits_max_output_tokens_when_none() {
let value = serde_json::to_value(ProviderMeta::default()).expect("serialize ProviderMeta");
assert!(value.get("maxOutputTokens").is_none());
}
#[test]
fn provider_meta_roundtrips_local_proxy_request_overrides() {
let meta = ProviderMeta {
+159 -99
View File
@@ -7,25 +7,24 @@ use serde_json::{json, Value};
/// 在请求体关键位置注入 cache_control 断点
pub fn inject(body: &mut Value, config: &OptimizerConfig) {
if !config.cache_injection {
if !config.enabled || !config.cache_injection {
return;
}
let existing = count_existing(body);
// 升级已有断点的 TTL
upgrade_existing_ttl(body, &config.cache_ttl);
if existing > 4 {
// Existing markers are caller-owned. Do not silently delete or reorder
// them; surface the invalid/unsupported total and leave validation to
// the upstream provider.
log::warn!(
"[OPT] cache: existing breakpoint count {existing} exceeds the supported total of 4; preserving caller input"
);
}
let mut budget = 4_usize.saturating_sub(existing);
if budget == 0 {
if existing > 0 {
log::info!(
"[OPT] cache: ttl-upgrade({existing}->{},existing={existing})",
config.cache_ttl
);
} else {
log::info!("[OPT] cache: no-op(existing={existing})");
}
log::info!("[OPT] cache: no-op(existing={existing})");
return;
}
@@ -37,10 +36,7 @@ pub fn inject(body: &mut Value, config: &OptimizerConfig) {
if let Some(last) = tools.last_mut() {
if last.get("cache_control").is_none() {
if let Some(o) = last.as_object_mut() {
o.insert(
"cache_control".to_string(),
make_cache_control(&config.cache_ttl),
);
o.insert("cache_control".to_string(), make_cache_control());
}
budget -= 1;
injected.push("tools");
@@ -64,10 +60,7 @@ pub fn inject(body: &mut Value, config: &OptimizerConfig) {
if let Some(last) = system.last_mut() {
if last.get("cache_control").is_none() {
if let Some(o) = last.as_object_mut() {
o.insert(
"cache_control".to_string(),
make_cache_control(&config.cache_ttl),
);
o.insert("cache_control".to_string(), make_cache_control());
}
budget -= 1;
injected.push("system");
@@ -76,32 +69,33 @@ pub fn inject(body: &mut Value, config: &OptimizerConfig) {
}
}
// (c) 最后一条 assistant 消息的最后一个非 thinking block
// (c) 最后一条可缓存消息的最后一个非 thinking block。工具循环通常以
// user/tool_result 结束;只标 assistant 会让最新稳定前缀无法命中缓存。
if budget > 0 {
if let Some(messages) = body.get_mut("messages").and_then(|m| m.as_array_mut()) {
if let Some(assistant_msg) = messages
.iter_mut()
.rev()
.find(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant"))
{
if let Some(content) = assistant_msg
.get_mut("content")
.and_then(|c| c.as_array_mut())
{
// 逆序找最后一个非 thinking/redacted_thinking block
if let Some(block) = content.iter_mut().rev().find(|b| {
let bt = b.get("type").and_then(|t| t.as_str()).unwrap_or("");
bt != "thinking" && bt != "redacted_thinking"
}) {
if block.get("cache_control").is_none() {
if let Some(o) = block.as_object_mut() {
o.insert(
"cache_control".to_string(),
make_cache_control(&config.cache_ttl),
);
}
injected.push("msgs");
for message in messages.iter_mut().rev() {
if inject_message_breakpoint(message) {
budget -= 1;
injected.push("msgs-latest");
break;
}
}
// (d) A second, older user anchor helps long tool-result turns where
// the stable prefix falls outside Anthropic's 20-block lookback from
// the newest breakpoint. Keep this best-effort and inside the 4-BP cap.
if budget > 0 && messages.len() >= 4 {
let mut user_count = 0;
for message in messages.iter_mut().rev() {
if message.get("role").and_then(Value::as_str) != Some("user") {
continue;
}
user_count += 1;
if user_count == 2 {
if inject_message_breakpoint(message) {
injected.push("msgs-prior-user");
}
break;
}
}
}
@@ -112,16 +106,34 @@ pub fn inject(body: &mut Value, config: &OptimizerConfig) {
"[OPT] cache: {}bp({},{},pre={existing})",
injected.len(),
injected.join("+"),
config.cache_ttl,
"5m",
);
}
fn make_cache_control(ttl: &str) -> Value {
if ttl == "5m" {
json!({"type": "ephemeral"})
} else {
json!({"type": "ephemeral", "ttl": ttl})
fn inject_message_breakpoint(message: &mut Value) -> bool {
let Some(content) = message.get_mut("content").and_then(Value::as_array_mut) else {
return false;
};
let Some(block) = content.iter_mut().rev().find(|block| {
!matches!(
block.get("type").and_then(Value::as_str),
Some("thinking" | "redacted_thinking")
)
}) else {
return false;
};
if block.get("cache_control").is_some() {
return false;
}
let Some(object) = block.as_object_mut() else {
return false;
};
object.insert("cache_control".to_string(), make_cache_control());
true
}
fn make_cache_control() -> Value {
json!({"type": "ephemeral"})
}
fn count_existing(body: &Value) -> usize {
@@ -155,40 +167,6 @@ fn count_existing(body: &Value) -> usize {
count
}
fn upgrade_existing_ttl(body: &mut Value, ttl: &str) {
let upgrade = |val: &mut Value| {
if let Some(cc) = val.get_mut("cache_control").and_then(|c| c.as_object_mut()) {
if ttl == "5m" {
cc.remove("ttl");
} else {
cc.insert("ttl".to_string(), json!(ttl));
}
}
};
if let Some(tools) = body.get_mut("tools").and_then(|t| t.as_array_mut()) {
for tool in tools.iter_mut() {
upgrade(tool);
}
}
if let Some(system) = body.get_mut("system").and_then(|s| s.as_array_mut()) {
for block in system.iter_mut() {
upgrade(block);
}
}
if let Some(messages) = body.get_mut("messages").and_then(|m| m.as_array_mut()) {
for msg in messages.iter_mut() {
if let Some(content) = msg.get_mut("content").and_then(|c| c.as_array_mut()) {
for block in content.iter_mut() {
upgrade(block);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -199,17 +177,16 @@ mod tests {
enabled: true,
thinking_optimizer: true,
cache_injection: true,
cache_ttl: "1h".to_string(),
}
}
#[test]
fn test_empty_body_no_injection() {
let mut body = json!({"model": "test", "messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]});
let original = body.clone();
inject(&mut body, &default_config());
// No tools, no system, no assistant → no injection
assert_eq!(body, original);
assert!(body["messages"][0]["content"][0]
.get("cache_control")
.is_some());
}
#[test]
@@ -230,7 +207,7 @@ mod tests {
// tools last element
assert!(body["tools"][1].get("cache_control").is_some());
assert_eq!(body["tools"][1]["cache_control"]["ttl"], "1h");
assert!(body["tools"][1]["cache_control"].get("ttl").is_none());
// system last element
assert!(body["system"][0].get("cache_control").is_some());
// assistant last non-thinking block
@@ -240,26 +217,50 @@ mod tests {
}
#[test]
fn test_existing_four_breakpoints_only_upgrades_ttl() {
fn test_long_history_uses_fourth_prior_user_breakpoint() {
let mut body = json!({
"model":"test",
"tools":[{"name":"tool1"}],
"system":[{"type":"text","text":"sys"}],
"messages":[
{"role":"user","content":[{"type":"text","text":"first"}]},
{"role":"assistant","content":[{"type":"text","text":"answer"}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"c1","content":"result"}]},
{"role":"assistant","content":[{"type":"text","text":"latest"}]}
]
});
inject(&mut body, &default_config());
assert_eq!(count_existing(&body), 4);
assert!(body["messages"][0]["content"][0]
.get("cache_control")
.is_some());
assert!(body["messages"][3]["content"][0]
.get("cache_control")
.is_some());
}
#[test]
fn test_existing_four_breakpoints_preserve_caller_ttl() {
let mut body = json!({
"model": "test",
"tools": [
{"name": "t1", "cache_control": {"type": "ephemeral", "ttl": "5m"}},
{"name": "t2", "cache_control": {"type": "ephemeral", "ttl": "5m"}}
{"name": "t1", "cache_control": {"type": "ephemeral", "ttl": "1h"}},
{"name": "t2", "cache_control": {"type": "ephemeral", "ttl": "1h"}}
],
"system": [
{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral", "ttl": "5m"}}
{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral", "ttl": "1h"}}
],
"messages": [
{"role": "assistant", "content": [
{"type": "text", "text": "ok", "cache_control": {"type": "ephemeral", "ttl": "5m"}}
{"type": "text", "text": "ok", "cache_control": {"type": "ephemeral", "ttl": "1h"}}
]}
]
});
inject(&mut body, &default_config());
// All TTLs upgraded to 1h, no new breakpoints
// Existing markers are caller-owned; only newly injected markers are fixed to 5m.
assert_eq!(body["tools"][0]["cache_control"]["ttl"], "1h");
assert_eq!(body["tools"][1]["cache_control"]["ttl"], "1h");
assert_eq!(body["system"][0]["cache_control"]["ttl"], "1h");
@@ -292,6 +293,32 @@ mod tests {
.is_some());
}
#[test]
fn test_more_than_four_existing_breakpoints_are_preserved() {
let mut body = json!({
"model": "test",
"tools": [
{"name": "t1", "cache_control": {"type": "ephemeral"}},
{"name": "t2", "cache_control": {"type": "ephemeral"}}
],
"system": [
{"type": "text", "text": "s1", "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": "s2", "cache_control": {"type": "ephemeral"}}
],
"messages": [{"role": "user", "content": [
{"type": "text", "text": "m1", "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": "m2"}
]}]
});
inject(&mut body, &default_config());
assert_eq!(count_existing(&body), 5);
assert!(body["messages"][0]["content"][1]
.get("cache_control")
.is_none());
}
#[test]
fn test_system_string_converted_to_array() {
let mut body = json!({
@@ -311,18 +338,14 @@ mod tests {
}
#[test]
fn test_ttl_5m_no_ttl_field() {
let config = OptimizerConfig {
cache_ttl: "5m".to_string(),
..default_config()
};
fn test_standard_five_minute_cache_control_omits_ttl() {
let mut body = json!({
"model": "test",
"tools": [{"name": "tool1"}],
"messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]
});
inject(&mut body, &config);
inject(&mut body, &default_config());
let cc = &body["tools"][0]["cache_control"];
assert_eq!(cc["type"], "ephemeral");
@@ -348,6 +371,24 @@ mod tests {
assert_eq!(body, original);
}
#[test]
fn test_optimizer_disabled_no_change() {
let config = OptimizerConfig {
enabled: false,
cache_injection: true,
..default_config()
};
let mut body = json!({
"model":"test",
"tools":[{"name":"tool1"}],
"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]
});
let original = body.clone();
inject(&mut body, &config);
assert_eq!(body, original);
}
#[test]
fn test_skip_thinking_blocks_in_assistant() {
let mut body = json!({
@@ -374,4 +415,23 @@ mod tests {
.get("cache_control")
.is_none());
}
#[test]
fn test_injects_latest_tool_result_instead_of_older_assistant() {
let mut body = json!({
"messages": [
{"role": "assistant", "content": [{"type": "tool_use", "id": "call_1", "name": "Read", "input": {}}]},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "call_1", "content": "done"}]}
]
});
inject(&mut body, &default_config());
assert!(body["messages"][0]["content"][0]
.get("cache_control")
.is_none());
assert!(body["messages"][1]["content"][0]
.get("cache_control")
.is_some());
}
}
File diff suppressed because it is too large Load Diff
+449 -56
View File
@@ -18,12 +18,18 @@ use super::{
handler_context::RequestContext,
providers::{
codex_chat_common::extract_reasoning_field_text,
codex_chat_history::record_responses_sse_stream, get_adapter, get_claude_api_format,
codex_chat_history::record_responses_sse_stream,
get_adapter, get_claude_api_format,
streaming::create_anthropic_sse_stream,
streaming_codex_anthropic::{
create_responses_sse_stream_from_anthropic_with_context,
responses_sse_events_from_anthropic_message,
},
streaming_codex_chat::create_responses_sse_stream_from_chat_with_context,
streaming_gemini::create_anthropic_sse_stream_from_gemini,
streaming_responses::create_anthropic_sse_stream_from_responses, transform,
transform_codex_chat, transform_gemini, transform_responses,
streaming_responses::create_anthropic_sse_stream_from_responses,
transform, transform_codex_anthropic, transform_codex_chat, transform_gemini,
transform_responses,
},
response_processor::{
create_logged_passthrough_stream, process_response, read_decoded_body,
@@ -277,6 +283,90 @@ fn validate_claude_desktop_gateway_auth(
/// Claude 格式转换处理(独有逻辑)
///
/// 支持 OpenAI Chat Completions 和 Responses API 两种格式的转换
struct ClaudeUsageLog {
model: String,
request_model: String,
outbound_model: String,
app_type: &'static str,
provider_id: String,
session_id: String,
usage: TokenUsage,
latency_ms: u64,
status_code: u16,
is_streaming: bool,
}
fn prepare_claude_usage_log(
ctx: &RequestContext,
response: &Value,
status_code: u16,
is_streaming: bool,
) -> Option<ClaudeUsageLog> {
let usage =
TokenUsage::from_claude_response(response).filter(TokenUsage::has_billable_tokens)?;
let model = response
.get("model")
.and_then(Value::as_str)
.filter(|model| !model.is_empty())
.map(str::to_string)
.or_else(|| ctx.outbound_model.clone())
.unwrap_or_else(|| ctx.request_model.clone());
Some(ClaudeUsageLog {
model,
request_model: ctx.request_model.clone(),
outbound_model: ctx
.outbound_model
.clone()
.unwrap_or_else(|| ctx.request_model.clone()),
app_type: ctx.app_type_str,
provider_id: ctx.provider.id.clone(),
session_id: ctx.session_id.clone(),
usage,
latency_ms: ctx.latency_ms(),
status_code,
is_streaming,
})
}
async fn write_claude_usage_log(state: &ProxyState, log: ClaudeUsageLog) {
log_usage(
state,
&log.provider_id,
log.app_type,
&log.model,
&log.request_model,
&log.outbound_model,
log.usage,
log.latency_ms,
None,
log.is_streaming,
log.status_code,
Some(log.session_id),
)
.await;
}
fn spawn_claude_usage_log(
state: &ProxyState,
ctx: &RequestContext,
response: &Value,
status_code: u16,
is_streaming: bool,
) {
if !usage_logging_enabled(state) {
return;
}
let Some(log) = prepare_claude_usage_log(ctx, response, status_code, is_streaming) else {
return;
};
let state = state.clone();
tokio::spawn(async move {
write_claude_usage_log(&state, log).await;
});
}
async fn handle_claude_transform(
response: super::hyper_client::ProxyResponse,
ctx: &RequestContext,
@@ -468,8 +558,20 @@ async fn handle_claude_transform(
}
};
// Preserve raw Responses usage so a post-upstream conversion failure still
// records the tokens already consumed by the successful upstream request.
let raw_usage_response = (api_format == "openai_responses").then(|| {
json!({
"id": upstream_response.get("id").cloned().unwrap_or(Value::Null),
"model": upstream_response.get("model").cloned().unwrap_or(Value::Null),
"usage": transform_responses::build_anthropic_usage_from_responses(
upstream_response.get("usage")
)
})
});
// 根据 api_format 选择非流式转换器
let anthropic_response = if api_format == "openai_responses" {
let transform_result = if api_format == "openai_responses" {
transform_responses::responses_to_anthropic(upstream_response)
} else if api_format == "gemini_native" {
transform_gemini::gemini_to_anthropic_with_shadow_and_hints(
@@ -481,58 +583,28 @@ async fn handle_claude_transform(
)
} else {
transform::openai_to_anthropic(upstream_response)
}
.map_err(|e| {
log::error!("[Claude] 转换响应失败: {e}");
e
})?;
};
let anthropic_response = match transform_result {
Ok(response) => response,
Err(error) => {
log::error!("[Claude] 转换响应失败: {error}");
if usage_logging_enabled(state) {
if let Some(log) = raw_usage_response.as_ref().and_then(|response| {
prepare_claude_usage_log(ctx, response, status.as_u16(), false)
}) {
// The upstream request already succeeded and consumed tokens. Persist
// usage before returning the terminal transform error to the client.
write_claude_usage_log(state, log).await;
}
}
return Err(error);
}
};
// 记录使用量
// 全 0 usage 不落账(对齐 Codex 流式收集器的 skip):SSE 聚合兜底救回的流
// 在上游缺 stream_options.include_usage 时没有 usage,写入只会产生无意义空行
if let Some(usage) =
TokenUsage::from_claude_response(&anthropic_response).filter(|u| u.has_billable_tokens())
{
// 转换后的响应缺失/合成空 model 时,回退到映射后的出站模型(接管真值),
// 再回退到客户端请求别名
let model = anthropic_response
.get("model")
.and_then(|m| m.as_str())
.filter(|m| !m.is_empty())
.map(str::to_string)
.or_else(|| ctx.outbound_model.clone())
.unwrap_or_else(|| ctx.request_model.clone());
let latency_ms = ctx.latency_ms();
let request_model = ctx.request_model.clone();
let outbound_model = ctx
.outbound_model
.clone()
.unwrap_or_else(|| ctx.request_model.clone());
let app_type_str = ctx.app_type_str;
tokio::spawn({
let state = state.clone();
let provider_id = ctx.provider.id.clone();
let session_id = ctx.session_id.clone();
async move {
log_usage(
&state,
&provider_id,
app_type_str,
&model,
&request_model,
&outbound_model,
usage,
latency_ms,
None,
false,
status.as_u16(),
Some(session_id),
)
.await;
}
});
}
spawn_claude_usage_log(state, ctx, &anthropic_response, status.as_u16(), false);
// 构建响应
let mut builder = axum::response::Response::builder().status(status);
@@ -686,6 +758,30 @@ pub async fn handle_chat_completions(
pub async fn handle_responses(
State(state): State<ProxyState>,
request: axum::extract::Request,
) -> Result<axum::response::Response, ProxyError> {
handle_responses_for_app(state, request, AppType::Codex, "Codex", "codex").await
}
pub async fn handle_grokbuild_responses(
State(state): State<ProxyState>,
request: axum::extract::Request,
) -> Result<axum::response::Response, ProxyError> {
handle_responses_for_app(
state,
request,
AppType::GrokBuild,
"Grok Build",
"grokbuild",
)
.await
}
async fn handle_responses_for_app(
state: ProxyState,
request: axum::extract::Request,
app_type: AppType,
tag: &'static str,
app_type_str: &'static str,
) -> Result<axum::response::Response, ProxyError> {
let (parts, req_body) = request.into_parts();
let method = parts.method.clone();
@@ -702,7 +798,7 @@ pub async fn handle_responses(
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
RequestContext::new(&state, &body, &headers, app_type.clone(), tag, app_type_str).await?;
let endpoint = endpoint_with_query(&uri, "/responses");
let is_stream = body
@@ -714,7 +810,7 @@ pub async fn handle_responses(
let forwarder = ctx.create_forwarder(&state);
let mut result = match forwarder
.forward_with_retry(
&AppType::Codex,
&app_type,
method,
&endpoint,
body,
@@ -739,6 +835,18 @@ pub async fn handle_responses(
ctx.provider = result.provider;
let response = result.response;
if super::providers::should_convert_codex_responses_to_anthropic(&ctx.provider, &endpoint) {
return handle_codex_anthropic_to_responses_transform(
response,
&ctx,
&state,
is_stream,
connection_guard,
codex_tool_context,
)
.await;
}
if super::providers::should_convert_codex_responses_to_chat(&ctx.provider, &endpoint) {
return handle_codex_chat_to_responses_transform(
response,
@@ -765,6 +873,30 @@ pub async fn handle_responses(
pub async fn handle_responses_compact(
State(state): State<ProxyState>,
request: axum::extract::Request,
) -> Result<axum::response::Response, ProxyError> {
handle_responses_compact_for_app(state, request, AppType::Codex, "Codex", "codex").await
}
pub async fn handle_grokbuild_responses_compact(
State(state): State<ProxyState>,
request: axum::extract::Request,
) -> Result<axum::response::Response, ProxyError> {
handle_responses_compact_for_app(
state,
request,
AppType::GrokBuild,
"Grok Build",
"grokbuild",
)
.await
}
async fn handle_responses_compact_for_app(
state: ProxyState,
request: axum::extract::Request,
app_type: AppType,
tag: &'static str,
app_type_str: &'static str,
) -> Result<axum::response::Response, ProxyError> {
let (parts, req_body) = request.into_parts();
let method = parts.method.clone();
@@ -781,7 +913,7 @@ pub async fn handle_responses_compact(
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
RequestContext::new(&state, &body, &headers, app_type.clone(), tag, app_type_str).await?;
let endpoint = endpoint_with_query(&uri, "/responses/compact");
let is_stream = body
@@ -793,7 +925,7 @@ pub async fn handle_responses_compact(
let forwarder = ctx.create_forwarder(&state);
let mut result = match forwarder
.forward_with_retry(
&AppType::Codex,
&app_type,
method,
&endpoint,
body,
@@ -818,6 +950,18 @@ pub async fn handle_responses_compact(
ctx.provider = result.provider;
let response = result.response;
if super::providers::should_convert_codex_responses_to_anthropic(&ctx.provider, &endpoint) {
return handle_codex_anthropic_to_responses_transform(
response,
&ctx,
&state,
is_stream,
connection_guard,
codex_tool_context,
)
.await;
}
if super::providers::should_convert_codex_responses_to_chat(&ctx.provider, &endpoint) {
return handle_codex_chat_to_responses_transform(
response,
@@ -1065,6 +1209,255 @@ async fn handle_codex_chat_to_responses_transform(
})
}
/// Response-transform handler for the Codex (Responses) ↔ Anthropic Messages gateway.
///
/// Parallel to `handle_codex_chat_to_responses_transform`: the upstream speaks
/// Anthropic Messages, and this converts the response back into the Responses form
/// Codex expects (both streaming and non-streaming). Error bodies reuse
/// `handle_codex_chat_error_response` (whose extraction logic also works for
/// Anthropic's `{"error":{type,message}}`). It does not involve codex_chat_history
/// (tool ids round-trip natively through Anthropic).
async fn handle_codex_anthropic_to_responses_transform(
response: super::hyper_client::ProxyResponse,
ctx: &RequestContext,
state: &ProxyState,
is_stream: bool,
connection_guard: Option<ActiveConnectionGuard>,
codex_tool_context: transform_codex_chat::CodexToolContext,
) -> Result<axum::response::Response, ProxyError> {
let status = response.status();
if !status.is_success() {
return handle_codex_chat_error_response(response, ctx, status).await;
}
// Preserve live streaming when the gateway marks SSE correctly or omits an
// explicit JSON media type. Explicit JSON is buffered below so 2xx error
// envelopes and gateways that ignore stream:true can be converted faithfully.
if response.is_sse() || (is_stream && !response.is_json()) {
let stream = response.bytes_stream();
let sse_stream =
create_responses_sse_stream_from_anthropic_with_context(stream, codex_tool_context);
return build_codex_anthropic_sse_response(
sse_stream,
ctx,
state,
status,
connection_guard,
);
}
let body_timeout =
if ctx.app_config.auto_failover_enabled && ctx.app_config.non_streaming_timeout > 0 {
std::time::Duration::from_secs(ctx.app_config.non_streaming_timeout as u64)
} else {
std::time::Duration::ZERO
};
let (mut response_headers, status, body_bytes) =
read_decoded_body(response, ctx.tag, body_timeout).await?;
let body_str = String::from_utf8_lossy(&body_bytes);
let anthropic_response: Value = match serde_json::from_slice(&body_bytes) {
Ok(value) => value,
// Fallback sniffing symmetric to the chat / claude side (#2234): when the
// upstream returns an Anthropic SSE body with an unmarked Content-Type,
// aggregate it back into a message before continuing the conversion.
Err(_) if body_looks_like_sse(&body_str) => {
log::warn!("[Codex] Upstream returned an unmarked Anthropic SSE body, falling back to aggregation");
transform_codex_anthropic::anthropic_sse_to_message_value(&body_str).map_err(|e| {
log::error!("[Codex] Failed to aggregate Anthropic SSE body: {e}");
e
})?
}
Err(e) => {
log::error!(
"[Codex] Failed to parse Anthropic upstream response: {e}, body: {body_str}"
);
return Err(upstream_body_parse_error(
"Failed to parse upstream anthropic response",
&e,
&response_headers,
&body_str,
));
}
};
if is_stream {
let events =
responses_sse_events_from_anthropic_message(&anthropic_response, codex_tool_context);
let sse_stream = futures::stream::iter(events.into_iter().map(Ok::<Bytes, std::io::Error>));
return build_codex_anthropic_sse_response(
sse_stream,
ctx,
state,
status,
connection_guard,
);
}
let _connection_guard = connection_guard;
let responses_response =
transform_codex_anthropic::anthropic_response_to_responses_with_context(
anthropic_response,
&codex_tool_context,
)
.map_err(|e| {
log::error!("[Codex] Failed to convert Anthropic response to Responses: {e}");
e
})?;
if let Some(usage) = TokenUsage::from_codex_response_auto(&responses_response)
.filter(TokenUsage::has_billable_tokens)
{
let model = responses_response
.get("model")
.and_then(|m| m.as_str())
.filter(|m| !m.is_empty())
.map(str::to_string)
.or_else(|| ctx.outbound_model.clone())
.unwrap_or_else(|| ctx.request_model.clone());
let request_model = ctx.request_model.clone();
let outbound_model = ctx
.outbound_model
.clone()
.unwrap_or_else(|| ctx.request_model.clone());
let app_type_str = ctx.app_type_str;
tokio::spawn({
let state = state.clone();
let provider_id = ctx.provider.id.clone();
let session_id = ctx.session_id.clone();
let latency_ms = ctx.latency_ms();
async move {
log_usage(
&state,
&provider_id,
app_type_str,
&model,
&request_model,
&outbound_model,
usage,
latency_ms,
None,
false,
status.as_u16(),
Some(session_id),
)
.await;
}
});
}
strip_entity_headers_for_rebuilt_body(&mut response_headers);
strip_hop_by_hop_response_headers(&mut response_headers);
response_headers.remove(axum::http::header::CONTENT_TYPE);
let mut builder = axum::response::Response::builder().status(status);
for (key, value) in response_headers.iter() {
builder = builder.header(key, value);
}
builder = builder.header(
axum::http::header::CONTENT_TYPE,
axum::http::HeaderValue::from_static("application/json"),
);
let response_body = serde_json::to_vec(&responses_response).map_err(|e| {
log::error!("[Codex] Failed to serialize Responses response: {e}");
ProxyError::TransformError(format!("Failed to serialize responses response: {e}"))
})?;
builder
.body(axum::body::Body::from(response_body))
.map_err(|e| {
log::error!("[Codex] Failed to build Responses response: {e}");
ProxyError::Internal(format!("Failed to build response: {e}"))
})
}
fn build_codex_anthropic_sse_response(
sse_stream: impl futures::Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static,
ctx: &RequestContext,
state: &ProxyState,
status: StatusCode,
connection_guard: Option<ActiveConnectionGuard>,
) -> Result<axum::response::Response, ProxyError> {
let usage_collector = if usage_logging_enabled(state) {
let state = state.clone();
let provider_id = ctx.provider.id.clone();
let request_model = ctx.request_model.clone();
let fallback_model = ctx
.outbound_model
.clone()
.unwrap_or_else(|| ctx.request_model.clone());
let app_type_str = ctx.app_type_str;
let start_time = ctx.start_time;
let session_id = ctx.session_id.clone();
Some(SseUsageCollector::new(
start_time,
Some(codex_stream_usage_event_filter),
move |events, first_token_ms| {
let usage = TokenUsage::from_codex_stream_events_auto(&events).unwrap_or_default();
if !usage.has_billable_tokens() {
log::debug!("[Codex] Anthropic streaming response usage is all-zero or missing, skipping usage recording");
return;
}
let model = usage
.model
.clone()
.filter(|m| !m.is_empty())
.unwrap_or_else(|| fallback_model.clone());
let latency_ms = start_time.elapsed().as_millis() as u64;
let state = state.clone();
let provider_id = provider_id.clone();
let request_model = request_model.clone();
let outbound_model = fallback_model.clone();
let session_id = session_id.clone();
tokio::spawn(async move {
log_usage(
&state,
&provider_id,
app_type_str,
&model,
&request_model,
&outbound_model,
usage,
latency_ms,
first_token_ms,
true,
status.as_u16(),
Some(session_id),
)
.await;
});
},
))
} else {
None
};
let logged_stream = create_logged_passthrough_stream(
sse_stream,
ctx.tag,
usage_collector,
ctx.streaming_timeout_config(),
connection_guard,
);
let mut headers = axum::http::HeaderMap::new();
headers.insert(
"Content-Type",
axum::http::HeaderValue::from_static("text/event-stream"),
);
headers.insert(
"Cache-Control",
axum::http::HeaderValue::from_static("no-cache"),
);
let body = axum::body::Body::from_stream(logged_stream);
Ok((headers, body).into_response())
}
/// 把上游 Chat Completions 的错误响应转换为 Responses API 错误形状。
///
/// 与正常响应分支配套:正常响应已经被改写成 Responses 形式,错误响应若仍保留
+39
View File
@@ -142,6 +142,21 @@ impl ProxyResponse {
.unwrap_or(false)
}
/// Check whether the response explicitly declares a JSON media type.
pub fn is_json(&self) -> bool {
self.content_type()
.map(|content_type| {
let media_type = content_type
.split(';')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
media_type == "application/json" || media_type.ends_with("+json")
})
.unwrap_or(false)
}
/// Consume the response and collect the full body into `Bytes`.
pub async fn bytes(self) -> Result<Bytes, ProxyError> {
match self {
@@ -737,3 +752,27 @@ impl<S: Unpin> tokio::io::AsyncWrite for WriteFilter<S> {
std::task::Poll::Ready(Ok(()))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn buffered_with_content_type(content_type: Option<&str>) -> ProxyResponse {
let mut headers = http::HeaderMap::new();
if let Some(content_type) = content_type {
headers.insert(
http::header::CONTENT_TYPE,
http::HeaderValue::from_str(content_type).unwrap(),
);
}
ProxyResponse::buffered(http::StatusCode::OK, headers, Bytes::new())
}
#[test]
fn json_content_type_detection_accepts_json_suffixes() {
assert!(buffered_with_content_type(Some("application/json; charset=utf-8")).is_json());
assert!(buffered_with_content_type(Some("application/problem+json")).is_json());
assert!(!buffered_with_content_type(Some("text/event-stream")).is_json());
assert!(!buffered_with_content_type(None).is_json());
}
}
+61 -137
View File
@@ -1,3 +1,6 @@
#[cfg(test)]
use crate::model_capabilities::is_confirmed_text_only_model as confirmed_text_only_model;
use crate::model_capabilities::{image_input_capability_from_settings, ImageInputCapability};
use crate::provider::Provider;
use crate::proxy::error::ProxyError;
use serde_json::{json, Value};
@@ -9,9 +12,9 @@ pub const UNSUPPORTED_IMAGE_MARKER: &str = "[Unsupported Image]";
/// Two paths, both reached only when the caller's media-fallback switch is on:
/// - explicit capability from the provider config (modelCatalog / modalities) is
/// always trusted — it is declaration-driven, never a guess;
/// - the curated `known_text_only_model` list is a heuristic *prediction* and only
/// runs when `allow_heuristic` is true, so a mislabeled multimodal model cannot
/// have its images silently stripped when the user opts out.
/// - the confirmed text-only registry is used for proactive replacement only
/// when `allow_heuristic` is true. This switch controls silent request-body
/// mutation, not the capability truth advertised by the Codex model catalog.
pub fn replace_images_for_text_only_model(
body: &mut Value,
provider: &Provider,
@@ -27,13 +30,9 @@ pub fn replace_images_for_text_only_model(
.map(str::trim)
.unwrap_or("");
match explicit_model_image_support(provider, model) {
Some(true) => return 0,
Some(false) => return replace_images_in_body(body),
None => {}
}
if !allow_heuristic || !known_text_only_model(model) {
if image_input_capability_from_settings(&provider.settings_config, model, allow_heuristic)
!= ImageInputCapability::Unsupported
{
return 0;
}
@@ -63,6 +62,19 @@ pub fn is_unsupported_image_error(error: &ProxyError) -> bool {
let message = extract_error_text(body);
let message = message.to_ascii_lowercase();
// 自证性表述:这类短语本身就断言了"仅接受文本",属于模态拒绝,无需再要求
// 错误提到 image/media 等字样——火山方舟等网关的报错是
// "Model only support text input",全程不出现 imageissue #5025)。
// 国产网关的英文常缺三单 s,因此带 s / 不带 s 两种形式都要列。
const TEXT_ONLY_SELF_EVIDENT_HINTS: &[&str] = &["only support text", "only supports text"];
if TEXT_ONLY_SELF_EVIDENT_HINTS
.iter()
.any(|hint| message.contains(hint))
{
return true;
}
let mentions_image = message.contains("image")
|| message.contains("vision")
|| message.contains("multimodal")
@@ -83,7 +95,6 @@ pub fn is_unsupported_image_error(error: &ProxyError) -> bool {
"doesn't support",
"do not support",
"don't support",
"only supports text",
"text only",
"text-only",
"invalid content type",
@@ -225,91 +236,6 @@ fn replace_image_block_with_text_marker(block: &mut Value, text_type: &str) {
}
}
fn explicit_model_image_support(provider: &Provider, model: &str) -> Option<bool> {
let settings = &provider.settings_config;
[
settings
.get("modelCatalog")
.and_then(|catalog| catalog.get("models")),
settings.get("modelCatalog"),
settings.get("models"),
]
.into_iter()
.flatten()
.find_map(|value| explicit_model_image_support_in_value(value, model))
}
fn known_text_only_model(model: &str) -> bool {
let normalized = normalize_model_id(model);
let tail = normalized.rsplit('/').next().unwrap_or(normalized.as_str());
const EXACT_TAILS: &[&str] = &[
"ark-code-latest",
"deepseek-chat",
"deepseek-reasoner",
"deepseek-v4-flash",
"deepseek-v4-pro",
"glm-5.1",
"kat-coder",
"kat-coder-pro",
"kat-coder-pro v1",
"kat-coder-pro v2",
"kat-coder-pro-v1",
"kat-coder-pro-v2",
"ling-2.5-1t",
"longcat-flash-chat",
"mimo-v2.5-pro",
"us.deepseek.r1-v1",
];
const TAIL_PREFIXES: &[&str] = &["minimax-m2.7", "qwen3-coder", "step-3.5-flash"];
EXACT_TAILS.contains(&tail) || TAIL_PREFIXES.iter().any(|prefix| tail.starts_with(prefix))
}
fn explicit_model_image_support_in_value(value: &Value, model: &str) -> Option<bool> {
if let Some(models) = value.as_array() {
return models.iter().find_map(|entry| {
model_entry_matches(entry, None, model).then(|| explicit_image_support(entry))?
});
}
let object = value.as_object()?;
object.iter().find_map(|(key, entry)| {
model_entry_matches(entry, Some(key), model).then(|| explicit_image_support(entry))?
})
}
fn explicit_image_support(entry: &Value) -> Option<bool> {
if let Some(value) = entry
.get("supportsImage")
.or_else(|| entry.get("supports_image"))
.or_else(|| entry.get("vision"))
.and_then(Value::as_bool)
{
return Some(value);
}
[
entry.get("input"),
entry.pointer("/modalities/input"),
entry.get("input_modalities"),
entry.get("inputModalities"),
]
.into_iter()
.flatten()
.find_map(input_modalities_support_image)
}
fn input_modalities_support_image(value: &Value) -> Option<bool> {
let modalities = value.as_array()?;
Some(modalities.iter().any(|item| {
item.as_str()
.map(str::trim)
.is_some_and(|item| item.eq_ignore_ascii_case("image"))
}))
}
fn extract_error_text(body: &str) -> String {
if let Ok(value) = serde_json::from_str::<Value>(body) {
let candidates = [
@@ -334,43 +260,6 @@ fn extract_error_text(body: &str) -> String {
body.to_string()
}
fn model_entry_matches(entry: &Value, key: Option<&str>, model: &str) -> bool {
key.is_some_and(|key| model_ids_match(key, model))
|| ["model", "id", "name"]
.into_iter()
.filter_map(|field| entry.get(field).and_then(Value::as_str))
.any(|candidate| model_ids_match(candidate, model))
}
fn model_ids_match(candidate: &str, model: &str) -> bool {
let candidate = normalize_model_id(candidate);
let model = normalize_model_id(model);
if candidate.is_empty() || model.is_empty() {
return false;
}
if candidate == model {
return true;
}
let candidate_tail = candidate.rsplit('/').next().unwrap_or(candidate.as_str());
let model_tail = model.rsplit('/').next().unwrap_or(model.as_str());
candidate_tail == model_tail || candidate == model_tail || candidate_tail == model
}
fn normalize_model_id(value: &str) -> String {
let mut normalized = value
.trim()
.trim_start_matches("models/")
.trim()
.to_ascii_lowercase();
if let Some(stripped) =
normalized.strip_suffix(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)
{
normalized = stripped.trim().to_string();
}
normalized
}
#[cfg(test)]
mod tests {
use super::*;
@@ -415,7 +304,7 @@ mod tests {
}
#[test]
fn known_text_only_models_replace_images_before_send() {
fn confirmed_text_only_models_replace_images_before_send() {
let provider = provider(json!({}));
let mut body = json!({
"model": "deepseek/deepseek-v4-pro",
@@ -437,7 +326,7 @@ mod tests {
}
#[test]
fn known_text_only_models_replace_chat_image_url_before_send() {
fn confirmed_text_only_models_replace_chat_image_url_before_send() {
let provider = provider(json!({}));
let mut body = json!({
"model": "deepseek-v4-flash",
@@ -461,7 +350,7 @@ mod tests {
}
#[test]
fn known_text_only_models_replace_codex_input_image_before_send() {
fn confirmed_text_only_models_replace_codex_input_image_before_send() {
let provider = provider(json!({}));
let mut body = json!({
"model": "deepseek-v4-flash",
@@ -484,6 +373,15 @@ mod tests {
);
}
#[test]
fn longcat_models_are_classified_text_only() {
// LongCat-2.0 (like the retired Flash Chat) is a text-only model; the
// preset ships it in mixed case, so the classifier must normalize first.
assert!(confirmed_text_only_model("LongCat-2.0"));
assert!(confirmed_text_only_model("longcat/LongCat-2.0"));
assert!(confirmed_text_only_model("LongCat-Flash-Chat"));
}
#[test]
fn explicit_text_modalities_replace_images_before_send() {
let provider = provider(json!({
@@ -637,7 +535,7 @@ mod tests {
}
#[test]
fn known_text_only_prefixes_replace_images_before_send() {
fn confirmed_text_only_variant_replaces_images_before_send() {
let provider = provider(json!({}));
let mut body = json!({
"model": "therouter/qwen/qwen3-coder-480b",
@@ -717,6 +615,32 @@ mod tests {
assert!(is_unsupported_image_error(&error));
}
#[test]
fn detects_text_only_errors_without_image_mention() {
// 火山方舟真实报错(issue #5025):不含 image/media 等字样,且英文缺
// 三单 s——旧逻辑的 mentions_image 门与 "only supports text" 提示都拦不住。
let error = ProxyError::UpstreamError {
status: 400,
body: Some(
r#"{"error":{"message":"Model only support text input Request id: 021783"}}"#
.to_string(),
),
};
assert!(is_unsupported_image_error(&error));
}
#[test]
fn glm_52_is_classified_text_only() {
// issue #5025:火山 Coding Plan 的 GLM 5.2 是纯文本端点,
// 映射链 glm-5.2[1M] 归一化后尾部为 glm-5.2。
assert!(confirmed_text_only_model("glm-5.2"));
assert!(confirmed_text_only_model("GLM-5.2[1M]"));
assert!(confirmed_text_only_model("zai-org/GLM-5.2"));
// 未来视觉版(智谱 4v/5v 命名惯例)不能被误判为纯文本。
assert!(!confirmed_text_only_model("glm-5.2v"));
}
#[test]
fn ignores_non_image_errors() {
let error = ProxyError::UpstreamError {
+50
View File
@@ -12,6 +12,7 @@ pub struct ModelMapping {
pub sonnet_model: Option<String>,
pub opus_model: Option<String>,
pub fable_model: Option<String>,
pub subagent_model: Option<String>,
pub default_model: Option<String>,
}
@@ -41,6 +42,11 @@ impl ModelMapping {
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
subagent_model: env
.and_then(|e| e.get("CLAUDE_CODE_SUBAGENT_MODEL"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
default_model: env
.and_then(|e| e.get("ANTHROPIC_MODEL"))
.and_then(|v| v.as_str())
@@ -55,6 +61,7 @@ impl ModelMapping {
|| self.sonnet_model.is_some()
|| self.opus_model.is_some()
|| self.fable_model.is_some()
|| self.subagent_model.is_some()
|| self.default_model.is_some()
}
@@ -89,6 +96,13 @@ impl ModelMapping {
}
}
if let Some(ref m) = self.subagent_model {
if strip_one_m_suffix_for_upstream(original_model) == strip_one_m_suffix_for_upstream(m)
{
return original_model.to_string();
}
}
// 2. 默认模型
if let Some(ref m) = self.default_model {
return m.clone();
@@ -327,6 +341,42 @@ mod tests {
assert_eq!(mapped, Some("default-model".to_string()));
}
#[test]
fn test_subagent_model_preserved_before_default_fallback() {
let mut provider = create_provider_with_mapping();
provider.settings_config = json!({
"env": {
"ANTHROPIC_MODEL": "default-model",
"CLAUDE_CODE_SUBAGENT_MODEL": "gpt-5.4-mini"
}
});
let body = json!({"model": "gpt-5.4-mini"});
let (result, original, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "gpt-5.4-mini");
assert_eq!(original, Some("gpt-5.4-mini".to_string()));
assert!(mapped.is_none());
}
#[test]
fn test_subagent_model_preserved_with_one_m_suffix_before_default_fallback() {
let mut provider = create_provider_with_mapping();
provider.settings_config = json!({
"env": {
"ANTHROPIC_MODEL": "default-model",
"CLAUDE_CODE_SUBAGENT_MODEL": "gpt-5.4-mini"
}
});
let body = json!({"model": "gpt-5.4-mini[1M]"});
let (result, original, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "gpt-5.4-mini[1M]");
assert_eq!(original, Some("gpt-5.4-mini[1M]".to_string()));
assert!(mapped.is_none());
}
#[test]
fn test_no_mapping_configured() {
let provider = create_provider_without_mapping();
+1 -1
View File
@@ -124,7 +124,7 @@ pub enum AuthStrategy {
///
/// - Header: `Authorization: Bearer <access_token>`
/// - Header: `ChatGPT-Account-Id: <account_id>` (来自 forwarder 注入)
/// - Header: `originator: cc-switch`
/// - Header: `originator: codex_cli_rs` + `version: <codex 版本>`(成对,后端按此做模型 cohort 路由)
///
/// 使用动态获取的 OpenAI access_token(通过 Device Code 流程获取)
CodexOAuth,
+15 -4
View File
@@ -24,6 +24,13 @@ const ANTHROPIC_REDACTED_THINKING_PLACEHOLDER: &str = "[redacted thinking]";
// Keep hints lowercase; matching lowercases only the input value.
const REASONING_VENDOR_HINTS: &[&str] = &["moonshot", "kimi", "deepseek", "mimo", "xiaomimimo"];
// ChatGPT Codex 后端按 originator+version 组合做模型 cohort 路由:非官方身份会把
// gpt-5.6-luna 解析到未部署的内部引擎(HTTP 404 Model not foundopenai/codex#31967
// 本机 A/B 实测确认)。两个头必须成对发送,缺一即 404;version 需 ≥ 目标模型
// catalog 的 minimal_client_versionluna=0.144.0),新模型抬门槛时同步 bump。
const CODEX_OAUTH_ORIGINATOR: &str = "codex_cli_rs";
const CODEX_OAUTH_CLIENT_VERSION: &str = "0.144.1";
/// 获取 Claude 供应商的 API 格式
///
/// 供 handler/forwarder 外部使用的公开函数。
@@ -666,7 +673,7 @@ impl ProviderAdapter for ClaudeAdapter {
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
// Codex OAuth: 强制使用 ChatGPT 后端 API 端点(忽略用户配置的 base_url)
if self.is_codex_oauth(provider) {
return Ok("https://chatgpt.com/backend-api/codex".to_string());
return Ok(super::CHATGPT_CODEX_BASE_URL.to_string());
}
// 1. 从 env 中获取
@@ -778,9 +785,9 @@ impl ProviderAdapter for ClaudeAdapter {
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
// Codex OAuth: 所有请求统一走 /responses 端点
if base_url == "https://chatgpt.com/backend-api/codex" {
if base_url == super::CHATGPT_CODEX_BASE_URL {
let _ = endpoint; // 忽略原始 endpoint
return "https://chatgpt.com/backend-api/codex/responses".to_string();
return format!("{}/responses", super::CHATGPT_CODEX_BASE_URL);
}
// NOTE:
@@ -843,7 +850,11 @@ impl ProviderAdapter for ClaudeAdapter {
(HeaderName::from_static("authorization"), hv(&bearer)?),
(
HeaderName::from_static("originator"),
HeaderValue::from_static("cc-switch"),
HeaderValue::from_static(CODEX_OAUTH_ORIGINATOR),
),
(
HeaderName::from_static("version"),
HeaderValue::from_static(CODEX_OAUTH_CLIENT_VERSION),
),
]
}
+550 -2
View File
@@ -84,6 +84,158 @@ pub fn should_convert_codex_responses_to_chat(provider: &Provider, endpoint: &st
) && codex_provider_uses_chat_completions(provider)
}
/// Whether a converted Codex Responses request may send `prompt_cache_key` to
/// its Chat Completions upstream. Unknown OpenAI-compatible gateways default to
/// false because many reject unsupported request fields with HTTP 400.
pub fn should_send_codex_chat_prompt_cache_key(provider: &Provider) -> bool {
match provider
.meta
.as_ref()
.and_then(|meta| meta.prompt_cache_routing.as_deref())
.unwrap_or("auto")
{
"enabled" => return true,
"disabled" => return false,
_ => {}
}
let base_url = provider
.settings_config
.get("base_url")
.or_else(|| provider.settings_config.get("baseURL"))
.and_then(|value| value.as_str())
.map(ToString::to_string)
.or_else(|| {
provider
.settings_config
.get("config")
.and_then(|value| value.as_str())
.and_then(extract_codex_base_url_from_toml)
});
let Some(base_url) = base_url else {
return false;
};
let Ok(url) = url::Url::parse(&base_url) else {
return false;
};
match url.host_str() {
Some("api.openai.com") => true,
Some("api.kimi.com") => {
let path = url.path().trim_end_matches('/');
path == "/coding" || path.starts_with("/coding/")
}
_ => false,
}
}
/// Add a stable cache-routing key after Responses -> Chat conversion. An
/// explicit client key wins; otherwise only a real client-provided session ID
/// is eligible. Generated per-request UUIDs must never be used here.
pub fn inject_codex_chat_prompt_cache_key(
provider: &Provider,
chat_body: &mut JsonValue,
explicit_key: Option<&str>,
client_session_id: Option<&str>,
) -> bool {
if !should_send_codex_chat_prompt_cache_key(provider) {
return false;
}
let key = explicit_key
.map(str::trim)
.filter(|key| !key.is_empty())
.or_else(|| {
client_session_id
.map(str::trim)
.filter(|session_id| !session_id.is_empty())
});
let Some(key) = key else {
return false;
};
chat_body["prompt_cache_key"] = JsonValue::String(key.to_string());
true
}
/// Whether this Codex provider's real upstream speaks the native Anthropic
/// Messages protocol (`/v1/messages`). The local Codex client always talks to CC
/// Switch through the Responses API, so CC Switch bridges Responses ⇄ Anthropic.
///
/// Determined solely from explicit config (apiFormat / wire_api); no base_url
/// guessing — Anthropic gateway addresses vary widely and guessing easily misfires.
pub fn codex_provider_uses_anthropic(provider: &Provider) -> bool {
if let Some(api_format) = provider
.meta
.as_ref()
.and_then(|meta| meta.api_format.as_deref())
.or_else(|| {
provider
.settings_config
.get("api_format")
.and_then(|v| v.as_str())
})
.or_else(|| {
provider
.settings_config
.get("apiFormat")
.and_then(|v| v.as_str())
})
{
return is_anthropic_wire_api(api_format);
}
provider
.settings_config
.get("config")
.and_then(|v| v.as_str())
.and_then(extract_codex_wire_api_from_toml)
.map(|wire_api| is_anthropic_wire_api(&wire_api))
.unwrap_or(false)
}
pub fn should_convert_codex_responses_to_anthropic(provider: &Provider, endpoint: &str) -> bool {
let path = endpoint
.split_once('?')
.map_or(endpoint, |(path, _query)| path);
matches!(
path,
"/responses" | "/v1/responses" | "/responses/compact" | "/v1/responses/compact"
) && codex_provider_uses_anthropic(provider)
}
/// The single built-in official Codex provider. Unlike managed Codex OAuth
/// providers used by Claude, this route receives authentication from the
/// calling Codex client (`requires_openai_auth = true`).
pub fn is_codex_official_provider(provider: &Provider) -> bool {
provider.id == crate::database::CODEX_OFFICIAL_PROVIDER_ID
&& provider.category.as_deref() == Some("official")
}
/// Resolve the model-catalog tool profile for a Codex provider using the SAME
/// Anthropic detection as the proxy router ([`codex_provider_uses_anthropic`]), so the
/// generated catalog never disagrees with the routed transform. A provider whose
/// Anthropic upstream is declared only via settings `apiFormat` or TOML `wire_api`
/// (not `meta.api_format`) would otherwise get a `ProxyChat` catalog and emit the
/// freeform `apply_patch` tool that the Anthropic transform then silently drops.
/// Non-Anthropic providers keep the existing `meta.api_format` classification.
pub fn resolve_codex_catalog_tool_profile(
provider: &Provider,
) -> crate::codex_config::CodexCatalogToolProfile {
use crate::codex_config::CodexCatalogToolProfile;
if is_codex_official_provider(provider) {
return CodexCatalogToolProfile::NativeResponses;
}
if codex_provider_uses_anthropic(provider) {
return CodexCatalogToolProfile::Anthropic;
}
CodexCatalogToolProfile::from_api_format(
provider.meta.as_ref().and_then(|m| m.api_format.as_deref()),
)
}
/// Extract the real upstream model configured for a Codex provider.
pub fn codex_provider_upstream_model(provider: &Provider) -> Option<String> {
provider
@@ -98,7 +250,11 @@ pub fn codex_provider_upstream_model(provider: &Provider) -> Option<String> {
.settings_config
.get("config")
.and_then(|v| v.as_str())
.and_then(extract_codex_model_from_toml)
.and_then(|config| {
crate::grok_config::extract_model_config(config)
.map(|model| model.model)
.or_else(|| extract_codex_model_from_toml(config))
})
})
}
@@ -129,7 +285,13 @@ pub fn apply_codex_chat_upstream_model(
if !codex_provider_uses_chat_completions(provider) {
return None;
}
apply_codex_upstream_model(provider, body)
}
/// Same model-substitution logic as `apply_codex_chat_upstream_model`, but without
/// the chat gating check. Reused by the anthropic conversion path (the forwarder has
/// already confirmed this provider uses anthropic).
pub fn apply_codex_upstream_model(provider: &Provider, body: &mut JsonValue) -> Option<String> {
let catalog_model_ids = codex_provider_catalog_model_ids(provider);
if let Some(request_model) = body
.get("model")
@@ -345,6 +507,13 @@ fn is_chat_wire_api(value: &str) -> bool {
)
}
fn is_anthropic_wire_api(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"anthropic" | "anthropic_messages" | "anthropic-messages" | "claude" | "messages"
)
}
fn is_chat_completions_url(value: &str) -> bool {
value
.trim_end_matches('/')
@@ -456,6 +625,9 @@ impl CodexAdapter {
}
if let Some(config_str) = config.as_str() {
if let Some((_, key)) = crate::grok_config::extract_credentials(config_str) {
return Some(key);
}
if let Some(key) =
crate::codex_config::extract_codex_experimental_bearer_token(config_str)
{
@@ -480,6 +652,10 @@ impl ProviderAdapter for CodexAdapter {
}
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
if is_codex_official_provider(provider) {
return Ok(super::CHATGPT_CODEX_BASE_URL.to_string());
}
// 1. 尝试直接获取 base_url 字段
if let Some(url) = provider
.settings_config
@@ -506,6 +682,9 @@ impl ProviderAdapter for CodexAdapter {
// 尝试解析 TOML 字符串格式
if let Some(config_str) = config.as_str() {
if let Some(url) = crate::grok_config::extract_base_url(config_str) {
return Ok(url.trim_end_matches('/').to_string());
}
if let Some(start) = config_str.find("base_url = \"") {
let rest = &config_str[start + 12..];
if let Some(end) = rest.find('"') {
@@ -527,8 +706,28 @@ impl ProviderAdapter for CodexAdapter {
}
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
// Anthropic upstream: the auth field is chosen by the user in the UI (meta.apiKeyField).
// ANTHROPIC_API_KEY → x-api-key (AuthStrategy::Anthropic)
// ANTHROPIC_AUTH_TOKEN → Authorization: Bearer (default, AuthStrategy::Bearer)
// The two are mutually exclusive to avoid a 401 from the gateway receiving
// both auth headers at once. All other Codex upstreams stay pure Bearer.
let strategy = if codex_provider_uses_anthropic(provider) {
let uses_x_api_key = provider
.meta
.as_ref()
.and_then(|meta| meta.api_key_field.as_deref())
.map(|field| field.eq_ignore_ascii_case("ANTHROPIC_API_KEY"))
.unwrap_or(false);
if uses_x_api_key {
AuthStrategy::Anthropic
} else {
AuthStrategy::Bearer
}
} else {
AuthStrategy::Bearer
};
self.extract_key(provider)
.map(|key| AuthInfo::new(key, AuthStrategy::Bearer))
.map(|key| AuthInfo::new(key, strategy))
}
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
@@ -569,6 +768,15 @@ impl ProviderAdapter for CodexAdapter {
) -> Result<Vec<(http::HeaderName, http::HeaderValue)>, ProxyError> {
use super::adapter::auth_header_value;
let bearer = format!("Bearer {}", auth.api_key);
// Anthropic gateway: send only x-api-key (anthropic-version is filled in by
// the forwarder). Mutually exclusive with Bearer to avoid a 401 from the
// gateway receiving both auth headers at once.
if auth.strategy == AuthStrategy::Anthropic {
return Ok(vec![(
http::HeaderName::from_static("x-api-key"),
auth_header_value(&auth.api_key)?,
)]);
}
Ok(vec![(
http::HeaderName::from_static("authorization"),
auth_header_value(&bearer)?,
@@ -598,6 +806,155 @@ mod tests {
}
}
#[test]
fn grok_build_toml_exposes_upstream_credentials_and_model() {
let adapter = CodexAdapter::new();
let provider = create_provider(json!({
"config": r#"
[models]
default = "grok-4.5"
[model."grok-4.5"]
model = "upstream-grok-model"
base_url = "https://relay.example.com/v1/"
name = "Example Relay"
api_key = "grok-secret"
api_backend = "responses"
context_window = 500000
"#
}));
assert_eq!(
adapter.extract_base_url(&provider).unwrap(),
"https://relay.example.com/v1"
);
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "grok-secret");
assert_eq!(auth.strategy, AuthStrategy::Bearer);
assert_eq!(
codex_provider_upstream_model(&provider).as_deref(),
Some("upstream-grok-model")
);
}
#[test]
fn official_provider_uses_fixed_chatgpt_backend_without_stored_key() {
let mut provider = create_provider(json!({ "auth": {}, "config": "" }));
provider.id = "codex-official".to_string();
provider.category = Some("official".to_string());
let adapter = CodexAdapter::new();
assert!(is_codex_official_provider(&provider));
assert_eq!(
adapter
.extract_base_url(&provider)
.expect("official base url"),
"https://chatgpt.com/backend-api/codex"
);
assert!(adapter.extract_auth(&provider).is_none());
assert_eq!(
adapter.build_url(
"https://chatgpt.com/backend-api/codex",
"/responses/compact"
),
"https://chatgpt.com/backend-api/codex/responses/compact"
);
}
#[test]
fn prompt_cache_routing_auto_enables_known_upstreams_only() {
let kimi = create_provider(json!({
"config": r#"
model_provider = "custom"
[model_providers.custom]
base_url = "https://api.kimi.com/coding/v1"
wire_api = "responses"
"#
}));
let openai = create_provider(json!({
"base_url": "https://api.openai.com/v1"
}));
let unknown = create_provider(json!({
"base_url": "https://strict.example.com/v1"
}));
assert!(should_send_codex_chat_prompt_cache_key(&kimi));
assert!(should_send_codex_chat_prompt_cache_key(&openai));
assert!(!should_send_codex_chat_prompt_cache_key(&unknown));
}
#[test]
fn prompt_cache_routing_user_override_wins_over_auto_detection() {
let mut kimi = create_provider(json!({
"base_url": "https://api.kimi.com/coding/v1"
}));
kimi.meta = Some(crate::provider::ProviderMeta {
prompt_cache_routing: Some("disabled".to_string()),
..Default::default()
});
assert!(!should_send_codex_chat_prompt_cache_key(&kimi));
let mut unknown = create_provider(json!({
"base_url": "https://strict.example.com/v1"
}));
unknown.meta = Some(crate::provider::ProviderMeta {
prompt_cache_routing: Some("enabled".to_string()),
..Default::default()
});
assert!(should_send_codex_chat_prompt_cache_key(&unknown));
}
#[test]
fn prompt_cache_key_prefers_explicit_key_then_real_session() {
let provider = create_provider(json!({
"base_url": "https://api.kimi.com/coding/v1"
}));
let mut explicit_body = json!({ "model": "kimi-for-coding" });
assert!(inject_codex_chat_prompt_cache_key(
&provider,
&mut explicit_body,
Some("request-key"),
Some("session-key"),
));
assert_eq!(explicit_body["prompt_cache_key"], "request-key");
let mut session_body = json!({ "model": "kimi-for-coding" });
assert!(inject_codex_chat_prompt_cache_key(
&provider,
&mut session_body,
None,
Some("session-key"),
));
assert_eq!(session_body["prompt_cache_key"], "session-key");
}
#[test]
fn prompt_cache_key_is_not_injected_without_real_session_or_support() {
let kimi = create_provider(json!({
"base_url": "https://api.kimi.com/coding/v1"
}));
let mut no_session_body = json!({ "model": "kimi-for-coding" });
assert!(!inject_codex_chat_prompt_cache_key(
&kimi,
&mut no_session_body,
None,
None,
));
assert!(no_session_body.get("prompt_cache_key").is_none());
let unknown = create_provider(json!({
"base_url": "https://strict.example.com/v1"
}));
let mut unsupported_body = json!({ "model": "other" });
assert!(!inject_codex_chat_prompt_cache_key(
&unknown,
&mut unsupported_body,
Some("request-key"),
Some("session-key"),
));
assert!(unsupported_body.get("prompt_cache_key").is_none());
}
#[test]
fn test_extract_base_url_direct() {
let adapter = CodexAdapter::new();
@@ -662,6 +1019,197 @@ experimental_bearer_token = "sk-config-key"
assert_eq!(url, "https://api.openai.com/v1/responses");
}
// ==================== anthropic upstream detection ====================
#[test]
fn test_uses_anthropic_from_settings_api_format() {
let provider = create_provider(json!({ "apiFormat": "anthropic" }));
assert!(codex_provider_uses_anthropic(&provider));
let provider = create_provider(json!({ "api_format": "anthropic_messages" }));
assert!(codex_provider_uses_anthropic(&provider));
}
#[test]
fn test_uses_anthropic_from_meta_api_format() {
let mut provider = create_provider(json!({}));
provider.meta = Some(crate::provider::ProviderMeta {
api_format: Some("anthropic".to_string()),
..Default::default()
});
assert!(codex_provider_uses_anthropic(&provider));
}
#[test]
fn test_uses_anthropic_from_toml_wire_api() {
let provider = create_provider(json!({
"config": r#"model_provider = "custom"
[model_providers.custom]
wire_api = "anthropic"
"#
}));
assert!(codex_provider_uses_anthropic(&provider));
}
#[test]
fn test_anthropic_false_for_chat_and_responses() {
let chat = create_provider(json!({ "apiFormat": "openai_chat" }));
assert!(!codex_provider_uses_anthropic(&chat));
let responses = create_provider(json!({ "apiFormat": "openai_responses" }));
assert!(!codex_provider_uses_anthropic(&responses));
}
#[test]
fn test_anthropic_and_chat_are_mutually_exclusive() {
let anth = create_provider(json!({ "apiFormat": "anthropic" }));
assert!(codex_provider_uses_anthropic(&anth));
assert!(!codex_provider_uses_chat_completions(&anth));
let chat = create_provider(json!({ "apiFormat": "openai_chat" }));
assert!(codex_provider_uses_chat_completions(&chat));
assert!(!codex_provider_uses_anthropic(&chat));
}
#[test]
fn test_should_convert_responses_to_anthropic_path_guard() {
let provider = create_provider(json!({ "apiFormat": "anthropic" }));
assert!(should_convert_codex_responses_to_anthropic(
&provider,
"/responses"
));
assert!(should_convert_codex_responses_to_anthropic(
&provider,
"/v1/responses/compact"
));
assert!(should_convert_codex_responses_to_anthropic(
&provider,
"/responses?x=1"
));
assert!(!should_convert_codex_responses_to_anthropic(
&provider,
"/chat/completions"
));
}
#[test]
fn test_resolve_catalog_profile_matches_router() {
use crate::codex_config::CodexCatalogToolProfile;
// Anthropic declared only via TOML wire_api (no meta.api_format) must still
// resolve to the Anthropic catalog profile — this is the routing/catalog
// divergence that let apply_patch leak through.
let toml_anthropic = create_provider(json!({
"config": r#"model_provider = "custom"
[model_providers.custom]
wire_api = "anthropic"
"#
}));
assert_eq!(
resolve_codex_catalog_tool_profile(&toml_anthropic),
CodexCatalogToolProfile::Anthropic
);
// Anthropic via settings apiFormat.
let settings_anthropic = create_provider(json!({ "apiFormat": "anthropic" }));
assert_eq!(
resolve_codex_catalog_tool_profile(&settings_anthropic),
CodexCatalogToolProfile::Anthropic
);
// Native openai_responses (meta) → NativeResponses; chat → ProxyChat.
let mut native = create_provider(json!({}));
native.meta = Some(crate::provider::ProviderMeta {
api_format: Some("openai_responses".to_string()),
..Default::default()
});
assert_eq!(
resolve_codex_catalog_tool_profile(&native),
CodexCatalogToolProfile::NativeResponses
);
let chat = create_provider(json!({ "apiFormat": "openai_chat" }));
assert_eq!(
resolve_codex_catalog_tool_profile(&chat),
CodexCatalogToolProfile::ProxyChat
);
}
#[test]
fn test_apply_codex_upstream_model_preserves_one_m_catalog_model() {
// Regression for the [1m] path: a request model carrying the [1m] marker must
// match its catalog entry and be preserved (not overridden by the provider
// default) so the transform can later strip [1m] and emit the context-1m beta.
// This only works because the forwarder no longer strips [1m] before this call
// on the Anthropic path.
let provider = create_provider(json!({
"config": r#"model_provider = "custom"
model = "claude-opus-4-1"
[model_providers.custom]
wire_api = "anthropic"
"#,
"modelCatalog": {
"models": [
{ "model": "claude-opus-4-1[1m]" }
]
}
}));
let mut body = json!({ "model": "claude-opus-4-1[1m]", "input": "hi" });
let result = apply_codex_upstream_model(&provider, &mut body);
assert_eq!(result.as_deref(), Some("claude-opus-4-1[1m]"));
assert_eq!(
body.get("model").and_then(|v| v.as_str()),
Some("claude-opus-4-1[1m]")
);
}
#[test]
fn test_anthropic_auth_defaults_to_bearer() {
// No meta.apiKeyField (defaults to ANTHROPIC_AUTH_TOKEN) → Authorization: Bearer only
let adapter = CodexAdapter::new();
let provider = create_provider(json!({
"apiFormat": "anthropic",
"auth": { "OPENAI_API_KEY": "sk-anthropic-key-123" }
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.strategy, AuthStrategy::Bearer);
let headers = adapter.get_auth_headers(&auth).unwrap();
let names: Vec<String> = headers
.iter()
.map(|(name, _)| name.as_str().to_string())
.collect();
assert_eq!(names, vec!["authorization".to_string()]);
}
#[test]
fn test_anthropic_auth_x_api_key_when_selected() {
// meta.apiKeyField = ANTHROPIC_API_KEY → x-api-key only
let adapter = CodexAdapter::new();
let mut provider = create_provider(json!({
"apiFormat": "anthropic",
"auth": { "OPENAI_API_KEY": "sk-anthropic-key-123" }
}));
provider.meta = Some(crate::provider::ProviderMeta {
api_format: Some("anthropic".to_string()),
api_key_field: Some("ANTHROPIC_API_KEY".to_string()),
..Default::default()
});
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
let headers = adapter.get_auth_headers(&auth).unwrap();
let names: Vec<String> = headers
.iter()
.map(|(name, _)| name.as_str().to_string())
.collect();
assert_eq!(names, vec!["x-api-key".to_string()]);
}
#[test]
fn test_build_url_origin_adds_v1() {
let adapter = CodexAdapter::new();
+484 -83
View File
@@ -202,6 +202,10 @@ struct CodexAccountData {
/// 与原生浏览器登录保持一致的 tokens 字段形状;刷新时若返回新值则更新。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id_token: Option<String>,
/// 最近一次取得或采纳这组 OAuth token 的时间。用于在 Codex CLI 与
/// cc-switch 都可能轮换 refresh_token 时拒绝从 live 采纳更旧的一代。
#[serde(default)]
pub token_updated_at_ms: i64,
}
/// 公开的账号信息(返回给前端,复用 GitHubAccount 结构)
@@ -253,6 +257,9 @@ pub struct CodexOAuthManager {
access_tokens: Arc<RwLock<HashMap<String, CachedAccessToken>>>,
/// 每个账号的刷新锁
refresh_locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
/// 普通 token 解析/采纳持读锁,账号删除/清空持写锁。删除因此会等待
/// 已在飞 refresh 完成,也不会因过早清理 refresh_locks 产生第二把账号锁。
lifecycle_lock: Arc<RwLock<()>>,
/// 进行中的 Device Code 流程:device_auth_id -> {user_code, expires_at_ms}
/// 过期条目会在 start_device_flow 时被清理,防止放弃的登录流程导致无界增长
pending_device_codes: Arc<RwLock<HashMap<String, PendingDeviceCode>>>,
@@ -272,6 +279,7 @@ impl CodexOAuthManager {
default_account_id: Arc::new(RwLock::new(None)),
access_tokens: Arc::new(RwLock::new(HashMap::new())),
refresh_locks: Arc::new(RwLock::new(HashMap::new())),
lifecycle_lock: Arc::new(RwLock::new(())),
pending_device_codes: Arc::new(RwLock::new(HashMap::new())),
storage_path,
storage_lock: Arc::new(Mutex::new(())),
@@ -420,12 +428,6 @@ impl CodexOAuthManager {
.exchange_code_for_tokens(&success.authorization_code, &success.code_verifier)
.await?;
// 清理 pending device code
{
let mut pending = self.pending_device_codes.write().await;
pending.remove(device_code);
}
let refresh_token = tokens.refresh_token.clone().ok_or_else(|| {
CodexOAuthError::TokenFetchFailed("响应缺少 refresh_token".to_string())
})?;
@@ -435,8 +437,9 @@ impl CodexOAuthManager {
CodexOAuthError::ParseError("无法从 token 中提取 account_id".to_string())
})?;
// 先落账号(写 accounts + 持久化),再按全局 accounts -> access_tokens 顺序、
// 在存在性确认下写 token 缓存,遵守「缓存条目只对应存在的账号」。
let obtained_at_ms = chrono::Utc::now().timestamp_millis();
// 登录提交与该账号的 refresh/adopt 共用一把 generation 锁;账号和
// access cache 一次写入,旧刷新响应因此不能覆盖新登录链。
let account = self
.add_account_internal(
account_id.clone(),
@@ -444,24 +447,15 @@ impl CodexOAuthManager {
email,
// 空字符串视为缺失,避免写出空的 id_token
tokens.id_token.clone().filter(|t| !t.trim().is_empty()),
Some(CachedAccessToken {
token: tokens.access_token.clone(),
expires_at_ms: compute_expires_at_ms(tokens.expires_in),
obtained_at_ms,
}),
Some(device_code),
)
.await?;
{
let accounts = self.accounts.read().await;
if accounts.contains_key(&account_id) {
let mut tokens_cache = self.access_tokens.write().await;
tokens_cache.insert(
account_id.clone(),
CachedAccessToken {
token: tokens.access_token.clone(),
expires_at_ms: compute_expires_at_ms(tokens.expires_in),
obtained_at_ms: chrono::Utc::now().timestamp_millis(),
},
);
}
}
Ok(Some(account))
}
@@ -520,12 +514,22 @@ impl CodexOAuthManager {
.await?;
let status = response.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return Err(CodexOAuthError::RefreshTokenInvalid);
}
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
let refresh_error_code = extract_refresh_error_code(&text);
if status == reqwest::StatusCode::UNAUTHORIZED
|| status == reqwest::StatusCode::FORBIDDEN
|| matches!(
refresh_error_code.as_deref(),
Some(
"refresh_token_expired"
| "refresh_token_reused"
| "refresh_token_invalidated"
)
)
{
return Err(CodexOAuthError::RefreshTokenInvalid);
}
return Err(CodexOAuthError::TokenFetchFailed(format!(
"Refresh 失败: {status} - {text}"
)));
@@ -544,6 +548,7 @@ impl CodexOAuthManager {
&self,
account_id: &str,
) -> Result<String, CodexOAuthError> {
let _lifecycle = self.lifecycle_lock.read().await;
Ok(self.resolve_valid_cached_token(account_id).await?.token)
}
@@ -552,14 +557,9 @@ impl CodexOAuthManager {
/// 返回完整 `CachedAccessToken`,使 token 与其 `obtained_at_ms` 天然配套(写托管
/// auth.json 的 `last_refresh` 直接取用),避免分两次读缓存造成的错配。
///
/// 并发正确性:统一`accounts -> access_tokens` 顺序加锁——读/写 token 缓存前都
/// 在 `accounts` 读锁下确认账号仍存在。配合 `remove_account`/`clear_auth` 在
/// `accounts` 写锁内原子清缓存,杜绝「已删账号的 token 被写回或被继续返回」
///
/// 已知未覆盖边界(ABA,极窄且可恢复):若一次刷新已用旧 refresh_token 在飞(≤30s
/// 超时),期间同一 `account_id` 被 remove 后又重新登录,则旧刷新返回时可能把旧
/// generation 的 token 写进新账号。需要 generation/version 校验才能彻底关闭;因触发
/// 需要「刷新在飞窗口内 remove+重加同一账号」且结果可通过重新登录恢复,暂不引入。
/// 并发正确性:调用方持 lifecycle 读锁;刷新再按 account refresh mutex →
/// accounts → access_tokens → storage 的顺序提交。remove/clear 持 lifecycle 写锁,
/// 因而会等待在飞刷新并阻断同 account_id 的 ABA 重建
async fn resolve_valid_cached_token(
&self,
account_id: &str,
@@ -582,6 +582,30 @@ impl CodexOAuthManager {
let refresh_lock = self.get_refresh_lock(account_id).await;
let _guard = refresh_lock.lock().await;
self.resolve_valid_cached_token_under_lock(account_id).await
}
/// Resolve a token while the caller owns this account's refresh mutex.
/// Keeping this separate lets the full auth-bundle path hold one generation
/// lock across access/id/refresh reads without recursively locking the mutex.
async fn resolve_valid_cached_token_under_lock(
&self,
account_id: &str,
) -> Result<CachedAccessToken, CodexOAuthError> {
// Codex CLI may have advanced the shared refresh-token generation since
// this manager last used the account. Reload it under the same per-account
// lock before deciding whether a network refresh is necessary.
if let Some((live_refresh, live_id_token, live_last_refresh_ms)) =
crate::codex_config::read_codex_live_auth_refresh_for_account(account_id)
{
self.adopt_account_refresh_token_under_lock(
account_id,
live_refresh,
live_id_token,
live_last_refresh_ms,
)
.await?;
}
// double-check(同样在 accounts 读锁下)
{
@@ -597,7 +621,7 @@ impl CodexOAuthManager {
}
}
let refresh_token = {
let mut refresh_token = {
let accounts = self.accounts.read().await;
accounts
.get(account_id)
@@ -605,33 +629,79 @@ impl CodexOAuthManager {
.ok_or_else(|| CodexOAuthError::AccountNotFound(account_id.to_string()))?
};
let new_tokens = self.refresh_with_token(&refresh_token).await?;
let new_tokens = match self.refresh_with_token(&refresh_token).await {
Err(CodexOAuthError::RefreshTokenInvalid) => {
// If Codex CLI refreshed between our pre-read and request, reload
// its newer generation and retry exactly once. Error-code handling
// includes OpenAI's `refresh_token_reused` response.
let Some((live_refresh, live_id_token, live_last_refresh_ms)) =
crate::codex_config::read_codex_live_auth_refresh_for_account(account_id)
.filter(|(token, _, _)| token.trim() != refresh_token.as_str())
else {
return Err(CodexOAuthError::RefreshTokenInvalid);
};
let adopted = self
.adopt_account_refresh_token_under_lock(
account_id,
live_refresh.clone(),
live_id_token,
live_last_refresh_ms,
)
.await?;
if !adopted {
return Err(CodexOAuthError::RefreshTokenInvalid);
}
refresh_token = live_refresh;
self.refresh_with_token(&refresh_token).await?
}
result => result?,
};
let obtained_at_ms = chrono::Utc::now().timestamp_millis();
// 如果服务端返回了新的 refresh_token 或 id_token,更新存储
let mut needs_save = false;
{
let (stored_refresh_token, stored_id_token) = {
let mut accounts = self.accounts.write().await;
if let Some(account) = accounts.get_mut(account_id) {
if let Some(new_refresh) = new_tokens.refresh_token.clone() {
if new_refresh != account.refresh_token {
account.refresh_token = new_refresh;
needs_save = true;
}
}
// 刷新使用 openid scope,正常会返回新 id_token;为空则视为缺失,
// 保留旧值而非覆盖(旧值的 claims 仍可用于账号/套餐显示)。
if let Some(new_id_token) = new_tokens
.id_token
.clone()
.filter(|token| !token.trim().is_empty())
{
if account.id_token.as_deref() != Some(new_id_token.as_str()) {
account.id_token = Some(new_id_token);
needs_save = true;
}
let account = accounts
.get_mut(account_id)
.ok_or_else(|| CodexOAuthError::AccountNotFound(account_id.to_string()))?;
// Device re-login and CLI-token adoption use the same account lock,
// but keep a generation CAS here as defense in depth: a response for
// R0 must never overwrite a newly committed R1/N0 chain.
if account.refresh_token != refresh_token {
return Err(CodexOAuthError::TokenFetchFailed(
"账号凭据已更新,已丢弃旧刷新响应".to_string(),
));
}
if let Some(new_refresh) = new_tokens
.refresh_token
.clone()
.filter(|token| !token.trim().is_empty())
{
if new_refresh != account.refresh_token {
account.refresh_token = new_refresh;
needs_save = true;
}
}
}
// 刷新使用 openid scope,正常会返回新 id_token;为空则视为缺失,
// 保留旧值而非覆盖(旧值的 claims 仍可用于账号/套餐显示)。
if let Some(new_id_token) = new_tokens
.id_token
.clone()
.filter(|token| !token.trim().is_empty())
{
if account.id_token.as_deref() != Some(new_id_token.as_str()) {
account.id_token = Some(new_id_token);
needs_save = true;
}
}
if account.token_updated_at_ms != obtained_at_ms {
account.token_updated_at_ms = obtained_at_ms;
needs_save = true;
}
(account.refresh_token.clone(), account.id_token.clone())
};
if needs_save {
self.save_to_disk().await?;
}
@@ -639,9 +709,31 @@ impl CodexOAuthManager {
let cached = CachedAccessToken {
token: new_tokens.access_token.clone(),
expires_at_ms: compute_expires_at_ms(new_tokens.expires_in),
obtained_at_ms: chrono::Utc::now().timestamp_millis(),
obtained_at_ms,
};
let last_refresh = chrono::DateTime::<chrono::Utc>::from_timestamp_millis(obtained_at_ms)
.unwrap_or_else(chrono::Utc::now)
.to_rfc3339_opts(chrono::SecondsFormat::Nanos, true);
let refreshed_auth = crate::codex_config::codex_managed_oauth_auth_value(
account_id,
&cached.token,
stored_id_token.as_deref(),
&stored_refresh_token,
&last_refresh,
);
if let Err(err) = crate::codex_config::sync_codex_managed_oauth_live_auth_after_refresh(
account_id,
&refresh_token,
&refreshed_auth,
) {
// The manager token remains valid; a later provider write will
// retry the live synchronization without rolling it back.
log::warn!(
"[CodexOAuth] 同步刷新后的 Codex live auth 失败(account={account_id}: {err}"
);
}
// 在 accounts 读锁下确认账号仍存在,再写缓存:与 remove/clear(持 accounts
// 写锁并原子清缓存)互斥,杜绝把已删账号的 token 写回缓存。
{
@@ -665,13 +757,8 @@ impl CodexOAuthManager {
&self,
account_id: &str,
) -> Result<(String, Option<String>), CodexOAuthError> {
// 先确保 access_token 有效;刷新过程会顺带更新持久化的 id_token
let access_token = self.get_valid_token_for_account(account_id).await?;
let id_token = {
let accounts = self.accounts.read().await;
accounts.get(account_id).and_then(|a| a.id_token.clone())
};
Ok((access_token, id_token))
let bundle = self.get_valid_token_bundle_for_account(account_id).await?;
Ok((bundle.access_token, bundle.id_token))
}
/// 获取写入托管 Codex `auth.json` 所需的完整可刷新 token 束
@@ -684,9 +771,16 @@ impl CodexOAuthManager {
&self,
account_id: &str,
) -> Result<ManagedTokenBundle, CodexOAuthError> {
// access_token 与其获取时间来自同一次解析(缓存命中即同一条目、刷新即新铸),
// 天然配套,杜绝「旧 token + 新时间戳」的错配。
let cached = self.resolve_valid_cached_token(account_id).await?;
let _lifecycle = self.lifecycle_lock.read().await;
let refresh_lock = self.get_refresh_lock(account_id).await;
let _refresh_guard = refresh_lock.lock().await;
// Resolve and read every persistent token field while holding the same
// account generation lock. Otherwise an adoption between these reads
// can create an invalid A0 + R1/ID1 mixed bundle.
let cached = self
.resolve_valid_cached_token_under_lock(account_id)
.await?;
let last_refresh =
chrono::DateTime::<chrono::Utc>::from_timestamp_millis(cached.obtained_at_ms)
.unwrap_or_else(chrono::Utc::now)
@@ -718,7 +812,9 @@ impl CodexOAuthManager {
account_id: &str,
refresh_token: String,
id_token: Option<String>,
last_refresh_ms: Option<i64>,
) -> Result<bool, CodexOAuthError> {
let _lifecycle = self.lifecycle_lock.read().await;
let refresh_token = refresh_token.trim().to_string();
if refresh_token.is_empty() {
return Ok(false);
@@ -727,6 +823,25 @@ impl CodexOAuthManager {
// 覆盖我们刚采纳的 CLI 轮换值。
let refresh_lock = self.get_refresh_lock(account_id).await;
let _guard = refresh_lock.lock().await;
self.adopt_account_refresh_token_under_lock(
account_id,
refresh_token,
id_token,
last_refresh_ms,
)
.await
}
/// Same as `adopt_account_refresh_token`, for callers already holding the
/// per-account refresh lock.
async fn adopt_account_refresh_token_under_lock(
&self,
account_id: &str,
refresh_token: String,
id_token: Option<String>,
last_refresh_ms: Option<i64>,
) -> Result<bool, CodexOAuthError> {
let incoming_id_token = id_token.filter(|token| !token.trim().is_empty());
let mut changed = false;
{
let mut accounts = self.accounts.write().await;
@@ -734,16 +849,46 @@ impl CodexOAuthManager {
// 不是本 manager 托管的账号:不接管、不落盘。
return Ok(false);
};
if account.refresh_token != refresh_token {
// A manager refresh may already have advanced the token generation
// while auth.json still contains the older one. Never roll that
// state back during the preflight/write double-build sequence.
let refresh_changed = account.refresh_token != refresh_token;
let id_token_changed = incoming_id_token
.as_ref()
.is_some_and(|token| account.id_token.as_deref() != Some(token.as_str()));
let material_changed = refresh_changed || id_token_changed;
// Once the manager has a dated generation, any different token
// material must carry a *strictly newer* live timestamp. Equality is
// ambiguous at millisecond precision and therefore cannot authorize
// replacing the manager generation either.
let observed_is_newer = account.token_updated_at_ms <= 0
|| last_refresh_ms.is_some_and(|observed| observed > account.token_updated_at_ms);
if material_changed && !observed_is_newer {
return Ok(false);
}
if refresh_changed {
account.refresh_token = refresh_token;
changed = true;
}
if let Some(id_token) = id_token.filter(|token| !token.trim().is_empty()) {
if let Some(id_token) = incoming_id_token {
if account.id_token.as_deref() != Some(id_token.as_str()) {
account.id_token = Some(id_token);
changed = true;
}
}
if let Some(observed) = last_refresh_ms {
if observed > account.token_updated_at_ms {
account.token_updated_at_ms = observed;
changed = true;
}
} else if material_changed && account.token_updated_at_ms <= 0 {
// One-time migration for stores written before generation
// timestamps existed. Dating the adopted value prevents another
// undated live file from rolling it back later.
account.token_updated_at_ms = chrono::Utc::now().timestamp_millis();
changed = true;
}
// 采纳了 CLI 轮换后的 refresh_token:与之配套的旧 access_token 可能已被
// 服务端提前失效。在同一 accounts 写锁内(accounts -> access_tokens 顺序)
// 清缓存,避免释放锁后被快路径读到旧 token;下次按新 refresh_token 重取。
@@ -782,6 +927,10 @@ impl CodexOAuthManager {
pub async fn remove_account(&self, account_id: &str) -> Result<(), CodexOAuthError> {
log::info!("[CodexOAuth] 移除账号: {account_id}");
// Wait for all in-flight refresh/adopt operations before deleting. New
// token work is blocked until the account, cache, lock and disk state
// have been removed as one lifecycle transition.
let _lifecycle = self.lifecycle_lock.write().await;
{
// 在 accounts 写锁内原子清除该账号的 token 缓存(accounts -> access_tokens
@@ -810,6 +959,7 @@ impl CodexOAuthManager {
}
pub async fn set_default_account(&self, account_id: &str) -> Result<(), CodexOAuthError> {
let _lifecycle = self.lifecycle_lock.read().await;
{
let accounts = self.accounts.read().await;
if !accounts.contains_key(account_id) {
@@ -829,6 +979,12 @@ impl CodexOAuthManager {
pub async fn clear_auth(&self) -> Result<(), CodexOAuthError> {
log::info!("[CodexOAuth] 清除所有认证");
// Acquire lifecycle before storage. Refresh follows lifecycle(read) ->
// account mutex -> storage, so this fixed order cannot deadlock and the
// write guard guarantees no refresh can recreate live/disk state after
// the clear has committed.
let _lifecycle = self.lifecycle_lock.write().await;
// 与 save_to_disk 共用持久化锁:确保「清内存 + 删文件」相对于并发保存原子,
// 不会被一个持有旧快照的 save 复活已清除的账号。
let _persist = self.storage_lock.lock().await;
@@ -892,24 +1048,21 @@ impl CodexOAuthManager {
access_token: &str,
id_token: Option<&str>,
) -> Result<(), CodexOAuthError> {
let obtained_at_ms = chrono::Utc::now().timestamp_millis();
self.add_account_internal(
account_id.to_string(),
"test-refresh-token".to_string(),
Some(format!("{account_id}@example.test")),
id_token.map(|token| token.to_string()),
Some(CachedAccessToken {
token: access_token.to_string(),
expires_at_ms: obtained_at_ms + 3_600_000,
obtained_at_ms,
}),
None,
)
.await?;
let mut tokens = self.access_tokens.write().await;
tokens.insert(
account_id.to_string(),
CachedAccessToken {
token: access_token.to_string(),
expires_at_ms: chrono::Utc::now().timestamp_millis() + 3_600_000,
obtained_at_ms: chrono::Utc::now().timestamp_millis(),
},
);
Ok(())
}
@@ -921,8 +1074,28 @@ impl CodexOAuthManager {
refresh_token: String,
email: Option<String>,
id_token: Option<String>,
initial_access_token: Option<CachedAccessToken>,
pending_device_code: Option<&str>,
) -> Result<GitHubAccount, CodexOAuthError> {
let _lifecycle = self.lifecycle_lock.read().await;
if let Some(device_code) = pending_device_code {
// `clear_auth` owns lifecycle(write) while clearing pending flows.
// Re-check under lifecycle(read) at commit time so a poll that was
// already on the network cannot recreate an account after clear.
if self
.pending_device_codes
.write()
.await
.remove(device_code)
.is_none()
{
return Err(CodexOAuthError::ExpiredToken);
}
}
let refresh_lock = self.get_refresh_lock(&account_id).await;
let _refresh_guard = refresh_lock.lock().await;
let now = chrono::Utc::now().timestamp();
let now_ms = chrono::Utc::now().timestamp_millis();
let data = CodexAccountData {
account_id: account_id.clone(),
@@ -930,6 +1103,7 @@ impl CodexOAuthManager {
refresh_token,
authenticated_at: now,
id_token,
token_updated_at_ms: now_ms,
};
let account = GitHubAccount::from(&data);
@@ -937,6 +1111,12 @@ impl CodexOAuthManager {
{
let mut accounts = self.accounts.write().await;
accounts.insert(account_id.clone(), data);
let mut access_tokens = self.access_tokens.write().await;
if let Some(cached) = initial_access_token {
access_tokens.insert(account_id.clone(), cached);
} else {
access_tokens.remove(&account_id);
}
}
{
@@ -1143,6 +1323,19 @@ fn compute_expires_at_ms(expires_in: Option<i64>) -> i64 {
now_ms + secs * 1000
}
fn extract_refresh_error_code(body: &str) -> Option<String> {
let value: serde_json::Value = serde_json::from_str(body).ok()?;
value
.get("error")
.and_then(|error| match error {
serde_json::Value::Object(object) => object.get("code").and_then(|code| code.as_str()),
serde_json::Value::String(code) => Some(code.as_str()),
_ => None,
})
.or_else(|| value.get("code").and_then(|code| code.as_str()))
.map(|code| code.to_ascii_lowercase())
}
/// 解析 JWT 中的 claims
fn parse_jwt_claims(token: &str) -> Option<IdTokenClaims> {
let parts: Vec<&str> = token.split('.').collect();
@@ -1317,6 +1510,8 @@ mod tests {
"rt-secret".to_string(),
Some("user@example.com".to_string()),
None,
None,
None,
)
.await
.unwrap();
@@ -1340,6 +1535,8 @@ mod tests {
"rt".to_string(),
Some("a@example.com".to_string()),
None,
None,
None,
)
.await
.unwrap();
@@ -1349,6 +1546,8 @@ mod tests {
"rt2".to_string(),
Some("b@example.com".to_string()),
None,
None,
None,
)
.await
.unwrap();
@@ -1368,12 +1567,20 @@ mod tests {
.await
.unwrap();
// 采纳 Codex CLI 轮换后的 refresh_token / id_token。
// 采纳带有更新 last_refresh 的 Codex CLI 轮换 refresh_token / id_token。
let manager_updated_at = manager
.accounts
.read()
.await
.get("acc-1")
.expect("account present")
.token_updated_at_ms;
let changed = manager
.adopt_account_refresh_token(
"acc-1",
"rotated-rt".to_string(),
Some("id-2".to_string()),
Some(manager_updated_at.saturating_add(1)),
)
.await
.unwrap();
@@ -1395,14 +1602,208 @@ mod tests {
// 未知账号不接管。
assert!(!manager
.adopt_account_refresh_token("acc-unknown", "x".to_string(), None)
.adopt_account_refresh_token("acc-unknown", "x".to_string(), None, None)
.await
.unwrap());
// 相同值不算变化。
assert!(!manager
.adopt_account_refresh_token("acc-1", "rotated-rt".to_string(), None)
.adopt_account_refresh_token("acc-1", "rotated-rt".to_string(), None, None)
.await
.unwrap());
}
#[tokio::test]
async fn adopt_account_refresh_token_rejects_older_live_generation() {
let temp = tempfile::tempdir().unwrap();
let manager = CodexOAuthManager::new(temp.path().to_path_buf());
manager
.add_test_account_with_access_token("acc-1", "access-cached", Some("id-1"))
.await
.unwrap();
let manager_updated_at = manager
.accounts
.read()
.await
.get("acc-1")
.expect("account present")
.token_updated_at_ms;
let changed = manager
.adopt_account_refresh_token(
"acc-1",
"stale-live-refresh".to_string(),
None,
Some(manager_updated_at.saturating_sub(1)),
)
.await
.unwrap();
assert!(!changed, "older live state must not roll the manager back");
assert_eq!(
manager
.accounts
.read()
.await
.get("acc-1")
.expect("account present")
.refresh_token,
"test-refresh-token"
);
}
#[tokio::test]
async fn adopt_account_refresh_token_rejects_undated_live_generation() {
let temp = tempfile::tempdir().unwrap();
let manager = CodexOAuthManager::new(temp.path().to_path_buf());
manager
.add_test_account_with_access_token("acc-1", "access-cached", Some("id-1"))
.await
.unwrap();
let changed = manager
.adopt_account_refresh_token("acc-1", "ambiguous-live-refresh".to_string(), None, None)
.await
.unwrap();
assert!(
!changed,
"an undated live token must not roll back a timestamped manager generation"
);
assert_eq!(
manager
.accounts
.read()
.await
.get("acc-1")
.expect("account present")
.refresh_token,
"test-refresh-token"
);
}
#[tokio::test]
async fn adopt_account_refresh_token_rejects_stale_id_token_with_same_refresh() {
let temp = tempfile::tempdir().unwrap();
let manager = CodexOAuthManager::new(temp.path().to_path_buf());
manager
.add_test_account_with_access_token("acc-1", "access-cached", Some("id-new"))
.await
.unwrap();
let manager_updated_at = manager
.accounts
.read()
.await
.get("acc-1")
.expect("account present")
.token_updated_at_ms;
let changed = manager
.adopt_account_refresh_token(
"acc-1",
"test-refresh-token".to_string(),
Some("id-stale".to_string()),
Some(manager_updated_at.saturating_sub(1)),
)
.await
.unwrap();
assert!(!changed);
assert_eq!(
manager
.accounts
.read()
.await
.get("acc-1")
.expect("account present")
.id_token
.as_deref(),
Some("id-new")
);
}
#[tokio::test]
async fn adopt_account_refresh_token_rejects_equal_timestamp_generation() {
let temp = tempfile::tempdir().unwrap();
let manager = CodexOAuthManager::new(temp.path().to_path_buf());
manager
.add_test_account_with_access_token("acc-1", "access-cached", Some("id-1"))
.await
.unwrap();
let manager_updated_at = manager
.accounts
.read()
.await
.get("acc-1")
.expect("account present")
.token_updated_at_ms;
let changed = manager
.adopt_account_refresh_token(
"acc-1",
"same-millisecond-refresh".to_string(),
None,
Some(manager_updated_at),
)
.await
.unwrap();
assert!(!changed);
assert_eq!(
manager
.accounts
.read()
.await
.get("acc-1")
.expect("account present")
.refresh_token,
"test-refresh-token"
);
}
#[tokio::test]
async fn device_commit_rejects_flow_cleared_during_network_poll() {
let temp = tempfile::tempdir().unwrap();
let manager = CodexOAuthManager::new(temp.path().to_path_buf());
manager.pending_device_codes.write().await.insert(
"device-auth-id".to_string(),
PendingDeviceCode {
user_code: "ABCD-EFGH".to_string(),
expires_at_ms: chrono::Utc::now().timestamp_millis() + 60_000,
},
);
manager.clear_auth().await.unwrap();
let result = manager
.add_account_internal(
"acc-after-clear".to_string(),
"refresh-after-clear".to_string(),
None,
None,
None,
Some("device-auth-id"),
)
.await;
assert!(matches!(result, Err(CodexOAuthError::ExpiredToken)));
assert!(manager.list_accounts().await.is_empty());
assert!(!manager.storage_path.exists());
}
#[test]
fn refresh_error_code_accepts_openai_error_shapes() {
assert_eq!(
extract_refresh_error_code(r#"{"error":{"code":"refresh_token_reused"}}"#).as_deref(),
Some("refresh_token_reused")
);
assert_eq!(
extract_refresh_error_code(r#"{"error":"refresh_token_expired"}"#).as_deref(),
Some("refresh_token_expired")
);
assert_eq!(
extract_refresh_error_code(r#"{"code":"REFRESH_TOKEN_INVALIDATED"}"#).as_deref(),
Some("refresh_token_invalidated")
);
assert_eq!(extract_refresh_error_code("not json"), None);
}
}
@@ -0,0 +1,399 @@
//! Shared builders for the OpenAI Responses SSE envelope.
//!
//! The two Codex streaming converters — `streaming_codex_chat` (Chat Completions SSE →
//! Responses SSE) and `streaming_codex_anthropic` (Anthropic Messages SSE → Responses
//! SSE) — have completely different *input* state machines but must emit the identical
//! Responses event stream the Codex client understands. This module owns that output
//! envelope so the two converters cannot drift when an event's shape changes: a wire fix
//! lands here once instead of being mirrored in both files.
//!
//! Each function is pure — it takes primitives or a caller-built `item` `Value` and
//! returns the exact bytes the converters previously constructed inline. Item shapes that
//! vary per converter (including function, namespace, custom, and tool-search calls)
//! are supplied by the caller via the generic
//! `output_item_added` / `output_item_done` helpers.
use bytes::Bytes;
use serde_json::{json, Value};
/// Serialize one Responses SSE event with the standard `event:`/`data:` framing.
pub(crate) fn sse_event(event: &str, data: Value) -> Bytes {
Bytes::from(format!(
"event: {event}\ndata: {}\n\n",
serde_json::to_string(&data).unwrap_or_default()
))
}
// ---------------------------------------------------------------------------
// Response lifecycle (created / in_progress / completed / failed)
// ---------------------------------------------------------------------------
/// `response.created`, wrapping a caller-built `response` object (usage/created_at differ
/// per converter, so the caller supplies the whole object).
pub(crate) fn response_created(response: &Value) -> Bytes {
sse_event(
"response.created",
json!({ "type": "response.created", "response": response }),
)
}
/// `response.in_progress`.
pub(crate) fn response_in_progress(response: &Value) -> Bytes {
sse_event(
"response.in_progress",
json!({ "type": "response.in_progress", "response": response }),
)
}
/// `response.completed`.
pub(crate) fn response_completed(response: &Value) -> Bytes {
sse_event(
"response.completed",
json!({ "type": "response.completed", "response": response }),
)
}
/// `response.failed`.
pub(crate) fn response_failed(response: &Value) -> Bytes {
sse_event(
"response.failed",
json!({ "type": "response.failed", "response": response }),
)
}
// ---------------------------------------------------------------------------
// Generic output-item add/done (item value supplied by the caller)
// ---------------------------------------------------------------------------
/// `response.output_item.added` with a caller-built item (message / reasoning /
/// function_call / custom_tool_call).
pub(crate) fn output_item_added(output_index: u32, item: &Value) -> Bytes {
sse_event(
"response.output_item.added",
json!({
"type": "response.output_item.added",
"output_index": output_index,
"item": item
}),
)
}
/// `response.output_item.done` with a caller-built item.
pub(crate) fn output_item_done(output_index: u32, item: &Value) -> Bytes {
sse_event(
"response.output_item.done",
json!({
"type": "response.output_item.done",
"output_index": output_index,
"item": item
}),
)
}
// ---------------------------------------------------------------------------
// Assistant message (text) lifecycle
// ---------------------------------------------------------------------------
/// `response.output_item.added` for an in-progress assistant message.
pub(crate) fn message_item_added(output_index: u32, item_id: &str) -> Bytes {
output_item_added(
output_index,
&json!({
"id": item_id,
"type": "message",
"status": "in_progress",
"role": "assistant",
"content": []
}),
)
}
/// `response.content_part.added` for the (empty) output_text part of a message.
pub(crate) fn message_content_part_added(output_index: u32, item_id: &str) -> Bytes {
sse_event(
"response.content_part.added",
json!({
"type": "response.content_part.added",
"item_id": item_id,
"output_index": output_index,
"content_index": 0,
"part": { "type": "output_text", "text": "", "annotations": [] }
}),
)
}
/// `response.output_text.delta`.
pub(crate) fn output_text_delta(output_index: u32, item_id: &str, delta: &str) -> Bytes {
sse_event(
"response.output_text.delta",
json!({
"type": "response.output_text.delta",
"item_id": item_id,
"output_index": output_index,
"content_index": 0,
"delta": delta
}),
)
}
/// The completed assistant-message item value.
pub(crate) fn message_item(item_id: &str, text: &str) -> Value {
json!({
"id": item_id,
"type": "message",
"status": "completed",
"role": "assistant",
"content": [{ "type": "output_text", "text": text, "annotations": [] }]
})
}
/// Close an assistant message: emits `output_text.done` → `content_part.done` →
/// `output_item.done`, and returns the completed item so the caller can record it.
pub(crate) fn message_close(output_index: u32, item_id: &str, text: &str) -> (Vec<Bytes>, Value) {
let item = message_item(item_id, text);
let events = vec![
sse_event(
"response.output_text.done",
json!({
"type": "response.output_text.done",
"item_id": item_id,
"output_index": output_index,
"content_index": 0,
"text": text
}),
),
sse_event(
"response.content_part.done",
json!({
"type": "response.content_part.done",
"item_id": item_id,
"output_index": output_index,
"content_index": 0,
"part": { "type": "output_text", "text": text, "annotations": [] }
}),
),
output_item_done(output_index, &item),
];
(events, item)
}
// ---------------------------------------------------------------------------
// Reasoning (summary) lifecycle
// ---------------------------------------------------------------------------
/// `response.output_item.added` for an in-progress reasoning item.
pub(crate) fn reasoning_item_added(output_index: u32, item_id: &str) -> Bytes {
output_item_added(
output_index,
&json!({
"id": item_id,
"type": "reasoning",
"status": "in_progress",
"summary": []
}),
)
}
/// `response.reasoning_summary_part.added` for the (empty) summary part.
pub(crate) fn reasoning_summary_part_added(output_index: u32, item_id: &str) -> Bytes {
sse_event(
"response.reasoning_summary_part.added",
json!({
"type": "response.reasoning_summary_part.added",
"item_id": item_id,
"output_index": output_index,
"summary_index": 0,
"part": { "type": "summary_text", "text": "" }
}),
)
}
/// `response.reasoning_summary_text.delta`.
pub(crate) fn reasoning_summary_text_delta(output_index: u32, item_id: &str, delta: &str) -> Bytes {
sse_event(
"response.reasoning_summary_text.delta",
json!({
"type": "response.reasoning_summary_text.delta",
"item_id": item_id,
"output_index": output_index,
"summary_index": 0,
"delta": delta
}),
)
}
/// The completed reasoning item value (note: no `status` field, matching both converters).
pub(crate) fn reasoning_item(item_id: &str, text: &str) -> Value {
json!({
"id": item_id,
"type": "reasoning",
"summary": [{ "type": "summary_text", "text": text }]
})
}
/// Close a reasoning item: emits `reasoning_summary_text.done` →
/// `reasoning_summary_part.done` → `output_item.done`, and returns the completed item.
pub(crate) fn reasoning_close(output_index: u32, item_id: &str, text: &str) -> (Vec<Bytes>, Value) {
let item = reasoning_item(item_id, text);
let events = reasoning_close_with_item(output_index, item_id, text, &item, true);
(events, item)
}
/// Close a reasoning item whose completed shape is supplied by the converter.
/// Anthropic uses this to attach opaque signed/redacted thinking in
/// `encrypted_content` while keeping the standard Responses event lifecycle.
pub(crate) fn reasoning_close_with_item(
output_index: u32,
item_id: &str,
text: &str,
item: &Value,
has_visible_summary: bool,
) -> Vec<Bytes> {
let mut events = Vec::new();
if has_visible_summary {
events.extend([
sse_event(
"response.reasoning_summary_text.done",
json!({
"type": "response.reasoning_summary_text.done",
"item_id": item_id,
"output_index": output_index,
"summary_index": 0,
"text": text
}),
),
sse_event(
"response.reasoning_summary_part.done",
json!({
"type": "response.reasoning_summary_part.done",
"item_id": item_id,
"output_index": output_index,
"summary_index": 0,
"part": { "type": "summary_text", "text": text }
}),
),
]);
}
events.push(output_item_done(output_index, item));
events
}
// ---------------------------------------------------------------------------
// Tool-call argument streaming (item value supplied by the caller)
// ---------------------------------------------------------------------------
/// `response.function_call_arguments.delta`.
pub(crate) fn function_call_arguments_delta(
output_index: u32,
item_id: &str,
delta: &str,
) -> Bytes {
sse_event(
"response.function_call_arguments.delta",
json!({
"type": "response.function_call_arguments.delta",
"item_id": item_id,
"output_index": output_index,
"delta": delta
}),
)
}
/// `response.function_call_arguments.done`.
pub(crate) fn function_call_arguments_done(
output_index: u32,
item_id: &str,
arguments: &str,
) -> Bytes {
sse_event(
"response.function_call_arguments.done",
json!({
"type": "response.function_call_arguments.done",
"item_id": item_id,
"output_index": output_index,
"arguments": arguments
}),
)
}
/// `response.custom_tool_call_input.delta` (Chat freeform tools only).
pub(crate) fn custom_tool_call_input_delta(output_index: u32, item_id: &str, delta: &str) -> Bytes {
sse_event(
"response.custom_tool_call_input.delta",
json!({
"type": "response.custom_tool_call_input.delta",
"item_id": item_id,
"output_index": output_index,
"delta": delta
}),
)
}
/// `response.custom_tool_call_input.done` (Chat freeform tools only).
pub(crate) fn custom_tool_call_input_done(output_index: u32, item_id: &str, input: &str) -> Bytes {
sse_event(
"response.custom_tool_call_input.done",
json!({
"type": "response.custom_tool_call_input.done",
"item_id": item_id,
"output_index": output_index,
"input": input
}),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn body(bytes: &Bytes) -> String {
String::from_utf8(bytes.to_vec()).unwrap()
}
#[test]
fn sse_event_framing() {
let ev = sse_event("response.created", json!({ "a": 1 }));
assert_eq!(body(&ev), "event: response.created\ndata: {\"a\":1}\n\n");
}
#[test]
fn message_close_shapes_match_legacy() {
let (events, item) = message_close(2, "resp_1_msg", "hi");
assert_eq!(events.len(), 3);
assert!(body(&events[0]).contains("\"type\":\"response.output_text.done\""));
assert!(body(&events[0]).contains("\"text\":\"hi\""));
assert!(body(&events[1]).contains("\"type\":\"response.content_part.done\""));
assert!(body(&events[2]).contains("\"type\":\"response.output_item.done\""));
assert_eq!(item["type"], "message");
assert_eq!(item["status"], "completed");
assert_eq!(item["content"][0]["text"], "hi");
}
#[test]
fn reasoning_close_item_has_no_status() {
let (events, item) = reasoning_close(0, "rs_1", "because");
assert_eq!(events.len(), 3);
assert!(body(&events[0]).contains("\"type\":\"response.reasoning_summary_text.done\""));
assert!(body(&events[1]).contains("\"type\":\"response.reasoning_summary_part.done\""));
// The completed reasoning item intentionally carries no `status` field.
assert!(item.get("status").is_none());
assert_eq!(item["summary"][0]["text"], "because");
}
#[test]
fn message_item_added_is_in_progress() {
let ev = message_item_added(0, "m1");
let s = body(&ev);
assert!(s.contains("\"type\":\"response.output_item.added\""));
assert!(s.contains("\"status\":\"in_progress\""));
assert!(s.contains("\"role\":\"assistant\""));
}
#[test]
fn function_call_argument_events() {
assert!(body(&function_call_arguments_delta(1, "fc_x", "{\"a\":"))
.contains("\"type\":\"response.function_call_arguments.delta\""));
assert!(body(&function_call_arguments_done(1, "fc_x", "{\"a\":1}"))
.contains("\"arguments\":\"{\\\"a\\\":1}\""));
}
}
+15 -11
View File
@@ -18,17 +18,21 @@ mod codex;
pub(crate) mod codex_chat_common;
pub mod codex_chat_history;
pub mod codex_oauth_auth;
pub(crate) mod codex_responses_sse;
pub mod copilot_auth;
pub mod copilot_model_map;
mod gemini;
pub(crate) mod gemini_schema;
pub mod gemini_shadow;
pub mod models;
pub(crate) mod reasoning_bridge;
pub mod streaming;
pub mod streaming_codex_anthropic;
pub mod streaming_codex_chat;
pub mod streaming_gemini;
pub mod streaming_responses;
pub mod transform;
pub mod transform_codex_anthropic;
pub mod transform_codex_chat;
pub mod transform_gemini;
pub mod transform_responses;
@@ -37,6 +41,8 @@ use crate::app_config::AppType;
use crate::provider::Provider;
use serde::{Deserialize, Serialize};
pub const CHATGPT_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api/codex";
// 公开导出
pub use adapter::ProviderAdapter;
pub use auth::{AuthInfo, AuthStrategy};
@@ -47,8 +53,10 @@ pub use claude::{
};
pub use codex::CodexAdapter;
pub use codex::{
apply_codex_chat_upstream_model, codex_provider_upstream_model,
resolve_codex_chat_reasoning_config, should_convert_codex_responses_to_chat,
apply_codex_chat_upstream_model, apply_codex_upstream_model, codex_provider_upstream_model,
inject_codex_chat_prompt_cache_key, is_codex_official_provider,
resolve_codex_catalog_tool_profile, resolve_codex_chat_reasoning_config,
should_convert_codex_responses_to_anthropic, should_convert_codex_responses_to_chat,
};
pub use gemini::GeminiAdapter;
@@ -104,7 +112,7 @@ impl ProviderType {
}
ProviderType::OpenRouter => "https://openrouter.ai/api",
ProviderType::GitHubCopilot => "https://api.githubcopilot.com",
ProviderType::CodexOAuth => "https://chatgpt.com/backend-api/codex",
ProviderType::CodexOAuth => CHATGPT_CODEX_BASE_URL,
}
}
@@ -184,10 +192,8 @@ impl ProviderType {
}
ProviderType::Gemini
}
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
// These apps don't support proxy, fallback to Codex-like type
ProviderType::Codex
}
AppType::GrokBuild => ProviderType::Codex,
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => ProviderType::Codex,
}
}
@@ -238,10 +244,8 @@ pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
AppType::Claude | AppType::ClaudeDesktop => Box::new(ClaudeAdapter::new()),
AppType::Codex => Box::new(CodexAdapter::new()),
AppType::Gemini => Box::new(GeminiAdapter::new()),
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
// These apps don't support proxy, fallback to Codex adapter
Box::new(CodexAdapter::new())
}
AppType::GrokBuild => Box::new(CodexAdapter::new()),
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => Box::new(CodexAdapter::new()),
}
}
@@ -0,0 +1,131 @@
//! Opaque reasoning transport helpers shared by the Messages ↔ Responses bridge.
//!
//! The Anthropic Messages protocol has no field for an OpenAI Responses
//! `reasoning` item. To keep stateless tool loops lossless, the complete item is
//! carried in a versioned thinking signature/redacted-thinking payload and
//! restored when the client replays the assistant message.
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use serde_json::{json, Value};
pub(crate) const OPENAI_REASONING_ITEM_PREFIX: &str = "ccswitch-openai-reasoning-v1:";
pub(crate) fn reasoning_summary_text(item: &Value) -> String {
item.get("summary")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|part| {
matches!(
part.get("type").and_then(Value::as_str),
Some("summary_text" | "reasoning_text")
)
.then(|| part.get("text").and_then(Value::as_str))
.flatten()
})
.collect::<Vec<_>>()
.join("")
}
pub(crate) fn encode_openai_reasoning_item(item: &Value) -> Option<String> {
if item.get("type").and_then(Value::as_str) != Some("reasoning") {
return None;
}
let bytes = serde_json::to_vec(item).ok()?;
Some(format!(
"{OPENAI_REASONING_ITEM_PREFIX}{}",
URL_SAFE_NO_PAD.encode(bytes)
))
}
pub(crate) fn decode_openai_reasoning_item(encoded: &str) -> Option<Value> {
let payload = encoded.strip_prefix(OPENAI_REASONING_ITEM_PREFIX)?;
let bytes = URL_SAFE_NO_PAD.decode(payload).ok()?;
let item: Value = serde_json::from_slice(&bytes).ok()?;
(item.get("type").and_then(Value::as_str) == Some("reasoning")).then_some(item)
}
pub(crate) fn anthropic_block_from_openai_reasoning_item(item: &Value) -> Option<Value> {
if item.get("type").and_then(Value::as_str) != Some("reasoning") {
return None;
}
let text = reasoning_summary_text(item);
let has_encrypted_content = item
.get("encrypted_content")
.and_then(Value::as_str)
.is_some_and(|value| !value.is_empty());
if has_encrypted_content {
let envelope = encode_openai_reasoning_item(item)?;
if text.is_empty() {
return Some(json!({
"type": "redacted_thinking",
"data": envelope
}));
}
return Some(json!({
"type": "thinking",
"thinking": text,
"signature": envelope
}));
}
(!text.is_empty()).then(|| {
json!({
"type": "thinking",
"thinking": text
})
})
}
pub(crate) fn openai_reasoning_item_from_anthropic_block(block: &Value) -> Option<Value> {
match block.get("type").and_then(Value::as_str) {
Some("thinking") => block
.get("signature")
.and_then(Value::as_str)
.and_then(decode_openai_reasoning_item),
Some("redacted_thinking") => block
.get("data")
.and_then(Value::as_str)
.and_then(decode_openai_reasoning_item),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn openai_reasoning_item_round_trips_through_thinking_signature() {
let item = json!({
"id": "rs_1",
"type": "reasoning",
"summary": [{"type": "summary_text", "text": "Need a tool."}],
"encrypted_content": "opaque"
});
let block = anthropic_block_from_openai_reasoning_item(&item).unwrap();
assert_eq!(block["type"], "thinking");
assert_eq!(
openai_reasoning_item_from_anthropic_block(&block),
Some(item)
);
}
#[test]
fn encrypted_item_without_summary_uses_redacted_thinking() {
let item = json!({
"id": "rs_2",
"type": "reasoning",
"summary": [],
"encrypted_content": "opaque"
});
let block = anthropic_block_from_openai_reasoning_item(&item).unwrap();
assert_eq!(block["type"], "redacted_thinking");
assert_eq!(
openai_reasoning_item_from_anthropic_block(&block),
Some(item)
);
}
}
+17 -3
View File
@@ -80,6 +80,8 @@ struct Usage {
struct PromptTokensDetails {
#[serde(default)]
cached_tokens: u32,
#[serde(default)]
cache_write_tokens: u32,
}
#[derive(Debug, Clone)]
@@ -103,7 +105,7 @@ fn build_anthropic_usage_json(usage: &Usage) -> Value {
// OpenAI prompt_tokens 含缓存,Anthropic input_tokens 不含,需减去 cache_read 与 cache_creation
// (三桶互斥,恒等 input + cache_read + cache_creation == prompt_tokens)。
let cached = extract_cache_read_tokens(usage).unwrap_or(0);
let cache_creation = usage.cache_creation_input_tokens.unwrap_or(0);
let cache_creation = extract_cache_write_tokens(usage).unwrap_or(0);
let input_tokens = usage
.prompt_tokens
.saturating_sub(cached)
@@ -233,7 +235,7 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
if let Some(u) = &chunk.usage {
let cached = extract_cache_read_tokens(u).unwrap_or(0);
let cache_creation =
u.cache_creation_input_tokens.unwrap_or(0);
extract_cache_write_tokens(u).unwrap_or(0);
let input = u
.prompt_tokens
.saturating_sub(cached)
@@ -683,6 +685,18 @@ fn extract_cache_read_tokens(usage: &Usage) -> Option<u32> {
.filter(|&v| v > 0)
}
/// Extract cache-write tokens from direct compatibility fields or OpenAI details.
fn extract_cache_write_tokens(usage: &Usage) -> Option<u32> {
if let Some(value) = usage.cache_creation_input_tokens {
return Some(value);
}
usage
.prompt_tokens_details
.as_ref()
.map(|details| details.cache_write_tokens)
.filter(|value| *value > 0)
}
/// 映射停止原因
fn map_stop_reason(finish_reason: Option<&str>) -> Option<String> {
finish_reason.map(|r| {
@@ -1061,7 +1075,7 @@ mod tests {
let input = concat!(
"data: {\"id\":\"chatcmpl_cc\",\"model\":\"glm-5.1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"tool-1\",\"type\":\"function\",\"function\":{\"name\":\"Bash\",\"arguments\":\"{\\\"command\\\":\\\"pwd\\\"}\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_cc\",\"model\":\"glm-5.1\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":1000,\"completion_tokens\":50,\"prompt_tokens_details\":{\"cached_tokens\":600},\"cache_creation_input_tokens\":300}}\n\n",
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":1000,\"completion_tokens\":50,\"prompt_tokens_details\":{\"cached_tokens\":600,\"cache_write_tokens\":300}}}\n\n",
"data: [DONE]\n\n"
);
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,6 @@
//! OpenAI Chat Completions SSE → OpenAI Responses SSE conversion.
use super::codex_responses_sse as sse;
use super::{
codex_chat_common::{
extract_reasoning_field_text, split_leading_think_block, strip_leading_think_open_tag,
@@ -74,6 +75,7 @@ struct ChatToResponsesState {
reasoning: ReasoningItemState,
inline_think: InlineThinkState,
tools: BTreeMap<usize, ToolCallState>,
next_tool_index_to_add: usize,
output_items: Vec<(u32, Value)>,
latest_usage: Option<Value>,
finish_reason: Option<String>,
@@ -93,6 +95,7 @@ impl Default for ChatToResponsesState {
reasoning: ReasoningItemState::default(),
inline_think: InlineThinkState::default(),
tools: BTreeMap::new(),
next_tool_index_to_add: 0,
output_items: Vec::new(),
latest_usage: None,
finish_reason: None,
@@ -270,20 +273,8 @@ impl ChatToResponsesState {
let response = self.base_response("in_progress", Vec::new());
vec![
sse_event(
"response.created",
json!({
"type": "response.created",
"response": response
}),
),
sse_event(
"response.in_progress",
json!({
"type": "response.in_progress",
"response": self.base_response("in_progress", Vec::new())
}),
),
sse::response_created(&response),
sse::response_in_progress(&response),
]
}
@@ -297,45 +288,16 @@ impl ChatToResponsesState {
self.reasoning.item_id = item_id.clone();
self.reasoning.added = true;
events.push(sse_event(
"response.output_item.added",
json!({
"type": "response.output_item.added",
"output_index": output_index,
"item": {
"id": item_id,
"type": "reasoning",
"status": "in_progress",
"summary": []
}
}),
));
events.push(sse_event(
"response.reasoning_summary_part.added",
json!({
"type": "response.reasoning_summary_part.added",
"item_id": self.reasoning.item_id,
"output_index": output_index,
"summary_index": 0,
"part": {
"type": "summary_text",
"text": ""
}
}),
));
events.push(sse::reasoning_item_added(output_index, &item_id));
events.push(sse::reasoning_summary_part_added(output_index, &item_id));
}
self.reasoning.text.push_str(delta);
let output_index = self.reasoning.output_index.unwrap_or(0);
events.push(sse_event(
"response.reasoning_summary_text.delta",
json!({
"type": "response.reasoning_summary_text.delta",
"item_id": self.reasoning.item_id,
"output_index": output_index,
"summary_index": 0,
"delta": delta
}),
events.push(sse::reasoning_summary_text_delta(
output_index,
&self.reasoning.item_id,
delta,
));
events
@@ -351,47 +313,16 @@ impl ChatToResponsesState {
self.text.item_id = item_id.clone();
self.text.added = true;
events.push(sse_event(
"response.output_item.added",
json!({
"type": "response.output_item.added",
"output_index": output_index,
"item": {
"id": item_id,
"type": "message",
"status": "in_progress",
"role": "assistant",
"content": []
}
}),
));
events.push(sse_event(
"response.content_part.added",
json!({
"type": "response.content_part.added",
"item_id": self.text.item_id,
"output_index": output_index,
"content_index": 0,
"part": {
"type": "output_text",
"text": "",
"annotations": []
}
}),
));
events.push(sse::message_item_added(output_index, &item_id));
events.push(sse::message_content_part_added(output_index, &item_id));
}
self.text.text.push_str(delta);
let output_index = self.text.output_index.unwrap_or(0);
events.push(sse_event(
"response.output_text.delta",
json!({
"type": "response.output_text.delta",
"item_id": self.text.item_id,
"output_index": output_index,
"content_index": 0,
"delta": delta
}),
events.push(sse::output_text_delta(
output_index,
&self.text.item_id,
delta,
));
events
@@ -418,16 +349,16 @@ impl ChatToResponsesState {
.unwrap_or("")
.to_string();
let mut should_add = false;
let mut output_index = None;
let mut item_id = String::new();
let mut pending_arguments = String::new();
let current_name: String;
{
let state = self.tools.entry(chat_index).or_default();
if let Some(id) = id_delta {
state.call_id = id;
if let Some(ref id) = id_delta {
if !id.is_empty() {
state.call_id.clone_from(id);
}
}
if let Some(ref name) = name_delta {
if !name.is_empty() {
@@ -444,10 +375,7 @@ impl ChatToResponsesState {
}
}
if !state.added && !state.call_id.is_empty() && !state.name.is_empty() {
should_add = true;
pending_arguments = state.arguments.clone();
} else if state.added {
if state.added {
output_index = state.output_index;
item_id = state.item_id.clone();
}
@@ -457,26 +385,51 @@ impl ChatToResponsesState {
let is_custom_tool = self.tool_context.is_custom_tool_chat_name(&current_name);
let mut events = Vec::new();
if should_add {
if !args_delta.is_empty() && !is_custom_tool {
if let Some(output_index) = output_index {
events.push(sse::function_call_arguments_delta(
output_index,
&item_id,
&args_delta,
));
}
}
events.extend(self.flush_ready_tool_calls());
events
}
fn flush_ready_tool_calls(&mut self) -> Vec<Bytes> {
// Release consecutive Chat indexes so late identity fragments cannot reorder calls.
let mut events = Vec::new();
loop {
let key = self.next_tool_index_to_add;
let Some(state) = self.tools.get(&key) else {
break;
};
if state.added || state.done {
self.next_tool_index_to_add += 1;
continue;
}
if state.call_id.is_empty() || state.name.is_empty() {
break;
}
let assigned = self.next_output_index();
let Some(state) = self.tools.get_mut(&chat_index) else {
return events;
let Some(state) = self.tools.get_mut(&key) else {
continue;
};
state.added = true;
if state.call_id.is_empty() {
state.call_id = format!("call_{chat_index}");
}
state.output_index = Some(assigned);
let is_custom_tool = self.tool_context.is_custom_tool_chat_name(&state.name);
state.item_id = response_tool_call_item_id_from_chat_name(
&state.call_id,
&state.name,
&self.tool_context,
);
item_id = state.item_id.clone();
let item = response_tool_call_item_from_chat_name(
&item_id,
&state.item_id,
"in_progress",
&state.call_id,
&state.name,
@@ -485,38 +438,18 @@ impl ChatToResponsesState {
&self.tool_context,
);
events.push(sse_event(
"response.output_item.added",
json!({
"type": "response.output_item.added",
"output_index": assigned,
"item": item
}),
));
events.push(sse::output_item_added(assigned, &item));
if !pending_arguments.is_empty() && !is_custom_tool {
events.push(sse_event(
"response.function_call_arguments.delta",
json!({
"type": "response.function_call_arguments.delta",
"item_id": state.item_id,
"output_index": assigned,
"delta": pending_arguments
}),
));
}
} else if !args_delta.is_empty() && !is_custom_tool {
if let Some(output_index) = output_index {
events.push(sse_event(
"response.function_call_arguments.delta",
json!({
"type": "response.function_call_arguments.delta",
"item_id": item_id,
"output_index": output_index,
"delta": args_delta
}),
if !state.arguments.is_empty()
&& !self.tool_context.is_custom_tool_chat_name(&state.name)
{
events.push(sse::function_call_arguments_delta(
assigned,
&state.item_id,
&state.arguments,
));
}
self.next_tool_index_to_add += 1;
}
events
@@ -567,13 +500,7 @@ impl ChatToResponsesState {
response["incomplete_details"] = json!({ "reason": "max_output_tokens" });
}
events.push(sse_event(
"response.completed",
json!({
"type": "response.completed",
"response": response
}),
));
events.push(sse::response_completed(&response));
self.completed = true;
events
}
@@ -586,50 +513,10 @@ impl ChatToResponsesState {
let output_index = self.reasoning.output_index.unwrap_or(0);
let item_id = self.reasoning.item_id.clone();
let text = self.reasoning.text.clone();
let item = json!({
"id": item_id,
"type": "reasoning",
"summary": [{
"type": "summary_text",
"text": text
}]
});
self.output_items.push((output_index, item.clone()));
let (events, item) = sse::reasoning_close(output_index, &item_id, &text);
self.output_items.push((output_index, item));
self.reasoning.done = true;
vec![
sse_event(
"response.reasoning_summary_text.done",
json!({
"type": "response.reasoning_summary_text.done",
"item_id": self.reasoning.item_id,
"output_index": output_index,
"summary_index": 0,
"text": self.reasoning.text
}),
),
sse_event(
"response.reasoning_summary_part.done",
json!({
"type": "response.reasoning_summary_part.done",
"item_id": self.reasoning.item_id,
"output_index": output_index,
"summary_index": 0,
"part": {
"type": "summary_text",
"text": self.reasoning.text
}
}),
),
sse_event(
"response.output_item.done",
json!({
"type": "response.output_item.done",
"output_index": output_index,
"item": item
}),
),
]
events
}
fn finalize_text(&mut self) -> Vec<Bytes> {
@@ -638,54 +525,12 @@ impl ChatToResponsesState {
}
let output_index = self.text.output_index.unwrap_or(0);
let item = json!({
"id": self.text.item_id,
"type": "message",
"status": "completed",
"role": "assistant",
"content": [{
"type": "output_text",
"text": self.text.text,
"annotations": []
}]
});
self.output_items.push((output_index, item.clone()));
let item_id = self.text.item_id.clone();
let text = self.text.text.clone();
let (events, item) = sse::message_close(output_index, &item_id, &text);
self.output_items.push((output_index, item));
self.text.done = true;
vec![
sse_event(
"response.output_text.done",
json!({
"type": "response.output_text.done",
"item_id": self.text.item_id,
"output_index": output_index,
"content_index": 0,
"text": self.text.text
}),
),
sse_event(
"response.content_part.done",
json!({
"type": "response.content_part.done",
"item_id": self.text.item_id,
"output_index": output_index,
"content_index": 0,
"part": {
"type": "output_text",
"text": self.text.text,
"annotations": []
}
}),
),
sse_event(
"response.output_item.done",
json!({
"type": "response.output_item.done",
"output_index": output_index,
"item": item
}),
),
]
events
}
fn finalize_tools(&mut self) -> Vec<Bytes> {
@@ -742,14 +587,7 @@ impl ChatToResponsesState {
Some(&state.reasoning_content),
&self.tool_context,
);
add_event = Some(sse_event(
"response.output_item.added",
json!({
"type": "response.output_item.added",
"output_index": assigned,
"item": item
}),
));
add_event = Some(sse::output_item_added(assigned, &item));
}
if let Some(event) = add_event {
@@ -777,44 +615,25 @@ impl ChatToResponsesState {
if is_custom_tool {
let input = custom_tool_input_from_chat_arguments(&arguments);
if !input.is_empty() {
events.push(sse_event(
"response.custom_tool_call_input.delta",
json!({
"type": "response.custom_tool_call_input.delta",
"item_id": state.item_id,
"output_index": output_index,
"delta": input.clone()
}),
events.push(sse::custom_tool_call_input_delta(
output_index,
&state.item_id,
&input,
));
}
events.push(sse_event(
"response.custom_tool_call_input.done",
json!({
"type": "response.custom_tool_call_input.done",
"item_id": state.item_id,
"output_index": output_index,
"input": input
}),
events.push(sse::custom_tool_call_input_done(
output_index,
&state.item_id,
&input,
));
} else {
events.push(sse_event(
"response.function_call_arguments.done",
json!({
"type": "response.function_call_arguments.done",
"item_id": state.item_id,
"output_index": output_index,
"arguments": arguments
}),
events.push(sse::function_call_arguments_done(
output_index,
&state.item_id,
&arguments,
));
}
events.push(sse_event(
"response.output_item.done",
json!({
"type": "response.output_item.done",
"output_index": output_index,
"item": item
}),
));
events.push(sse::output_item_done(output_index, &item));
}
events
@@ -864,13 +683,7 @@ impl ChatToResponsesState {
let mut response = self.base_response("failed", self.completed_output_items());
response["error"] = error;
sse_event(
"response.failed",
json!({
"type": "response.failed",
"response": response
}),
)
sse::response_failed(&response)
}
}
@@ -1030,13 +843,6 @@ fn extract_chat_sse_error(value: &Value) -> (String, Option<String>) {
(message, error_type)
}
fn sse_event(event: &str, data: Value) -> Bytes {
Bytes::from(format!(
"event: {event}\ndata: {}\n\n",
serde_json::to_string(&data).unwrap_or_default()
))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1057,6 +863,16 @@ mod tests {
String::from_utf8(bytes.concat()).unwrap()
}
fn parse_sse_events(output: &str) -> Vec<Value> {
output
.split("\n\n")
.filter_map(|block| {
let data = block.lines().find_map(|line| line.strip_prefix("data: "))?;
serde_json::from_str(data).ok()
})
.collect()
}
#[tokio::test]
async fn converts_text_chat_sse_to_responses_sse() {
let output = collect(vec![
@@ -1129,6 +945,114 @@ mod tests {
assert!(output.contains("\"call_id\":\"call_1\""));
}
#[tokio::test]
async fn preserves_tool_identity_across_empty_continuation_deltas() {
let output = collect(vec![
"data: {\"id\":\"chatcmpl_dashscope\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_dashscope\",\"type\":\"function\",\"function\":{\"name\":\"exec_command\",\"arguments\":\"{\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_dashscope\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"\",\"type\":\"function\",\"function\":{\"name\":\"\",\"arguments\":\"\\\"cmd\\\":\\\"date\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n",
])
.await;
let events = parse_sse_events(&output);
let added = events
.iter()
.filter(|event| event["type"] == "response.output_item.added")
.collect::<Vec<_>>();
let done = events
.iter()
.find(|event| event["type"] == "response.output_item.done")
.unwrap();
let completed = events
.iter()
.find(|event| event["type"] == "response.completed")
.unwrap();
assert_eq!(added.len(), 1);
for item in [&done["item"], &completed["response"]["output"][0]] {
assert_eq!(item["type"], "function_call");
assert_eq!(item["name"], "exec_command");
assert_eq!(item["call_id"], "call_dashscope");
assert_eq!(item["arguments"], r#"{"cmd":"date"}"#);
}
assert!(!output.contains(r#""name":"""#));
assert!(!output.contains(r#""call_id":"""#));
}
#[tokio::test]
async fn preserves_parallel_tool_order_when_earlier_name_arrives_late() {
let output = collect(vec![
"data: {\"id\":\"chatcmpl_parallel\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_first\",\"type\":\"function\",\"function\":{\"name\":\"\",\"arguments\":\"{\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_parallel\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"id\":\"call_second\",\"type\":\"function\",\"function\":{\"name\":\"second_tool\",\"arguments\":\"{\\\"value\\\":2}\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_parallel\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"first_tool\",\"arguments\":\"\\\"value\\\":1}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n",
])
.await;
let events = parse_sse_events(&output);
let added = events
.iter()
.filter(|event| event["type"] == "response.output_item.added")
.collect::<Vec<_>>();
let completed = events
.iter()
.find(|event| event["type"] == "response.completed")
.unwrap();
let items = completed["response"]["output"].as_array().unwrap();
assert_eq!(added.len(), 2);
assert_eq!(added[0]["output_index"], 0);
assert_eq!(added[0]["item"]["name"], "first_tool");
assert_eq!(added[1]["output_index"], 1);
assert_eq!(added[1]["item"]["name"], "second_tool");
assert_eq!(items[0]["name"], "first_tool");
assert_eq!(items[0]["call_id"], "call_first");
assert_eq!(items[0]["arguments"], r#"{"value":1}"#);
assert_eq!(items[1]["name"], "second_tool");
assert_eq!(items[1]["call_id"], "call_second");
assert_eq!(items[1]["arguments"], r#"{"value":2}"#);
}
#[tokio::test]
async fn finalization_keeps_valid_call_after_unnamed_earlier_call() {
let output = collect(vec![
"data: {\"id\":\"chatcmpl_parallel_missing\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_missing\",\"type\":\"function\",\"function\":{\"arguments\":\"{}\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_parallel_missing\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"id\":\"call_valid\",\"type\":\"function\",\"function\":{\"name\":\"exec_command\",\"arguments\":\"{\\\"cmd\\\":\\\"date\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n",
])
.await;
let events = parse_sse_events(&output);
let completed = events
.iter()
.find(|event| event["type"] == "response.completed")
.unwrap();
let items = completed["response"]["output"].as_array().unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0]["name"], "exec_command");
assert_eq!(items[0]["call_id"], "call_valid");
assert_eq!(items[0]["arguments"], r#"{"cmd":"date"}"#);
assert!(!output.contains("call_missing"));
}
#[tokio::test]
async fn finalization_keeps_non_contiguous_tool_index() {
let output = collect(vec![
"data: {\"id\":\"chatcmpl_sparse\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":2,\"id\":\"call_sparse\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"README.md\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n",
])
.await;
let events = parse_sse_events(&output);
let completed = events
.iter()
.find(|event| event["type"] == "response.completed")
.unwrap();
let items = completed["response"]["output"].as_array().unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0]["name"], "read_file");
assert_eq!(items[0]["call_id"], "call_sparse");
assert_eq!(items[0]["arguments"], r#"{"path":"README.md"}"#);
}
#[tokio::test]
async fn restores_custom_tool_input_stream_events() {
let request = json!({
File diff suppressed because it is too large Load Diff
+92 -5
View File
@@ -490,9 +490,21 @@ fn convert_message_to_openai(
Ok(result)
}
/// 清理 JSON schema(移除不支持的 format
pub fn clean_schema(mut schema: Value) -> Value {
/// 清理工具参数的 JSON schema,并为根 schema 补齐 OpenAI 要求的 object 类型。
pub fn clean_schema(schema: Value) -> Value {
clean_schema_inner(schema, true)
}
fn clean_schema_inner(mut schema: Value, is_root: bool) -> Value {
if let Some(obj) = schema.as_object_mut() {
let missing_type = is_root && !obj.contains_key("type");
if missing_type {
obj.insert("type".to_string(), json!("object"));
}
if missing_type && !obj.contains_key("properties") {
obj.insert("properties".to_string(), json!({}));
}
// 移除 "format": "uri"
if obj.get("format").and_then(|v| v.as_str()) == Some("uri") {
obj.remove("format");
@@ -501,12 +513,12 @@ pub fn clean_schema(mut schema: Value) -> Value {
// 递归清理嵌套 schema
if let Some(properties) = obj.get_mut("properties").and_then(|v| v.as_object_mut()) {
for (_, value) in properties.iter_mut() {
*value = clean_schema(value.clone());
*value = clean_schema_inner(value.clone(), false);
}
}
if let Some(items) = obj.get_mut("items") {
*items = clean_schema(items.clone());
*items = clean_schema_inner(items.clone(), false);
}
}
schema
@@ -653,7 +665,7 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
// 不再扣减),若不减则缓存会被计入 input 与各 cache 桶两次。三桶互斥,恒等:
// input + cache_read + cache_creation == prompt_tokensinclusive 上游)。
// 与流式 build_anthropic_usage_json (#2774) 及 transform_gemini 的 saturating_sub 对称。
// 最终 cache_read:直传字段优先于 nestedcache_creation 仅来自直传字段(OpenAI 无此概念)
// 最终 cache_read/cache_creation:直传字段优先于 OpenAI nested details
let cached = usage
.get("cache_read_input_tokens")
.and_then(|v| v.as_u64())
@@ -666,6 +678,12 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
let cache_creation = usage
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
.or_else(|| {
usage
.pointer("/prompt_tokens_details/cache_write_tokens")
.or_else(|| usage.pointer("/input_tokens_details/cache_write_tokens"))
.and_then(|v| v.as_u64())
})
.unwrap_or(0);
let input_tokens = usage
.get("prompt_tokens")
@@ -831,6 +849,75 @@ mod tests {
let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["tools"][0]["type"], "function");
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
assert_eq!(
result["tools"][0]["function"]["parameters"]["type"],
json!("object")
);
assert_eq!(
result["tools"][0]["function"]["parameters"]["properties"]["location"]["type"],
json!("string")
);
}
#[test]
fn test_anthropic_to_openai_defaults_missing_tool_schema_type() {
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "What's the weather?"}],
"tools": [{
"name": "get_weather",
"description": "Get weather info",
"input_schema": {"properties": {"location": {"type": "string"}}}
}]
});
let result = anthropic_to_openai(input).unwrap();
let parameters = &result["tools"][0]["function"]["parameters"];
assert_eq!(parameters["type"], json!("object"));
assert_eq!(
parameters["properties"]["location"]["type"],
json!("string")
);
}
#[test]
fn test_anthropic_to_openai_defaults_empty_tool_schema() {
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Do work"}],
"tools": [{"name": "do_work", "input_schema": {}}]
});
let result = anthropic_to_openai(input).unwrap();
let parameters = &result["tools"][0]["function"]["parameters"];
assert_eq!(parameters, &json!({"type": "object", "properties": {}}));
}
#[test]
fn test_clean_schema_only_defaults_root_to_object() {
let schema = json!({
"properties": {
"nullable_value": {
"anyOf": [{"type": "string"}, {"type": "null"}]
},
"list": {
"items": {"type": "string"}
}
}
});
let result = clean_schema(schema);
assert_eq!(result["type"], json!("object"));
assert_eq!(
result["properties"]["nullable_value"],
json!({"anyOf": [{"type": "string"}, {"type": "null"}]})
);
assert_eq!(
result["properties"]["list"],
json!({"items": {"type": "string"}})
);
}
#[test]
File diff suppressed because it is too large Load Diff
@@ -80,7 +80,11 @@ impl CodexToolContext {
.is_some_and(|spec| matches!(&spec.kind, CodexToolKind::Custom))
}
fn chat_name_for_response_function(&self, name: &str, namespace: Option<&str>) -> String {
pub(crate) fn chat_name_for_response_function(
&self,
name: &str,
namespace: Option<&str>,
) -> String {
if let Some(namespace) = namespace.filter(|value| !value.is_empty()) {
if let Some(chat_name) = self
.namespace_name_to_chat_name
@@ -1092,6 +1096,26 @@ fn serialize_tool_definition_for_description(tool: &Value) -> String {
canonical_json_string(tool)
}
/// Normalize a function's `parameters` JSON Schema so `type` is always `"object"`.
///
/// Some Responses tools carry `parameters: null` or `parameters: {"type": null}`,
/// but OpenAI Chat Completions strictly requires `{"type": "object", "properties": {...}}`.
fn normalize_function_parameters(params: Option<&Value>) -> Value {
let mut params = match params {
Some(Value::Object(obj)) => Value::Object(obj.clone()),
_ => json!({"type": "object", "properties": {}}),
};
if let Some(obj) = params.as_object_mut() {
match obj.get("type").and_then(|v| v.as_str()) {
Some("object") => {}
_ => {
obj.insert("type".to_string(), json!("object"));
}
}
}
params
}
fn responses_function_tool_to_chat_tool(tool: &Value, chat_name: &str) -> Option<Value> {
if tool.get("type").and_then(|v| v.as_str()) != Some("function") {
return None;
@@ -1106,6 +1130,14 @@ fn responses_function_tool_to_chat_tool(tool: &Value, chat_name: &str) -> Option
.get_mut("function")
.and_then(|value| value.as_object_mut())
{
// Ensure parameters.type is "object" for strict OpenAI-compatible providers
if let Some(params) = obj.get("parameters") {
let normalized = normalize_function_parameters(Some(params));
if normalized != *params {
obj.insert("parameters".to_string(), normalized);
}
}
obj.insert("name".to_string(), json!(chat_name));
if let Some(strict) = tool.get("strict").cloned() {
obj.entry("strict".to_string()).or_insert(strict);
@@ -1117,7 +1149,7 @@ fn responses_function_tool_to_chat_tool(tool: &Value, chat_name: &str) -> Option
let mut function = json!({
"name": chat_name,
"description": tool.get("description").cloned().unwrap_or(Value::Null),
"parameters": tool.get("parameters").cloned().unwrap_or_else(|| json!({}))
"parameters": normalize_function_parameters(tool.get("parameters"))
});
if let Some(strict) = tool.get("strict") {
function["strict"] = strict.clone();
@@ -1625,12 +1657,26 @@ pub(crate) fn chat_usage_to_responses_usage(usage: Option<&Value>) -> Value {
"total_tokens": total_tokens
});
if let Some(cached) = usage
let cached = usage
.pointer("/prompt_tokens_details/cached_tokens")
.or_else(|| usage.pointer("/input_tokens_details/cached_tokens"))
.and_then(|v| v.as_u64())
{
result["input_tokens_details"] = json!({ "cached_tokens": cached });
.unwrap_or(0);
let cache_write = usage
.pointer("/prompt_tokens_details/cache_write_tokens")
.or_else(|| usage.pointer("/input_tokens_details/cache_write_tokens"))
.and_then(|v| v.as_u64())
.or_else(|| {
usage
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
})
.unwrap_or(0);
if cached > 0 || cache_write > 0 {
result["input_tokens_details"] = json!({
"cached_tokens": cached,
"cache_write_tokens": cache_write
});
}
if let Some(details) = usage
@@ -1649,8 +1695,8 @@ pub(crate) fn chat_usage_to_responses_usage(usage: Option<&Value>) -> Value {
if let Some(cache_read) = usage.get("cache_read_input_tokens") {
result["cache_read_input_tokens"] = cache_read.clone();
}
if let Some(cache_creation) = usage.get("cache_creation_input_tokens") {
result["cache_creation_input_tokens"] = cache_creation.clone();
if cache_write > 0 {
result["cache_creation_input_tokens"] = json!(cache_write);
}
result
@@ -2731,7 +2777,7 @@ mod tests {
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15,
"prompt_tokens_details": {"cached_tokens": 3}
"prompt_tokens_details": {"cached_tokens": 3, "cache_write_tokens": 2}
}
});
@@ -2755,6 +2801,10 @@ mod tests {
assert_eq!(result["usage"]["input_tokens"], 10);
assert_eq!(result["usage"]["output_tokens"], 5);
assert_eq!(result["usage"]["input_tokens_details"]["cached_tokens"], 3);
assert_eq!(
result["usage"]["input_tokens_details"]["cache_write_tokens"],
2
);
}
#[test]
@@ -11,6 +11,134 @@
use crate::proxy::{error::ProxyError, json_canonical::canonical_json_string};
use serde_json::{json, Value};
use super::reasoning_bridge::{
anthropic_block_from_openai_reasoning_item, openai_reasoning_item_from_anthropic_block,
};
pub(crate) const TOOL_RESULT_ERROR_MARKER: &str = "[cc-switch:tool-result-error]";
fn anthropic_image_to_responses_part(block: &Value) -> Option<Value> {
let source = block.get("source")?;
match source.get("type").and_then(Value::as_str) {
Some("url") => source
.get("url")
.and_then(Value::as_str)
.filter(|url| url.starts_with("http://") || url.starts_with("https://"))
.map(|url| json!({"type":"input_image","image_url":url})),
Some("base64") | None => {
let data = source.get("data").and_then(Value::as_str)?;
if data.is_empty() {
return None;
}
let media_type = source
.get("media_type")
.and_then(Value::as_str)
.unwrap_or("image/png");
Some(json!({
"type":"input_image",
"image_url":format!("data:{media_type};base64,{data}")
}))
}
_ => None,
}
}
fn anthropic_document_to_responses_part(block: &Value) -> Option<Value> {
let source = block.get("source")?;
let filename = block
.get("title")
.or_else(|| block.get("filename"))
.and_then(Value::as_str)
.unwrap_or("document.pdf");
match source.get("type").and_then(Value::as_str) {
Some("url") => source
.get("url")
.and_then(Value::as_str)
.filter(|url| url.starts_with("http://") || url.starts_with("https://"))
.map(|url| json!({"type":"input_file","file_url":url,"filename":filename})),
Some("base64") => {
let data = source.get("data").and_then(Value::as_str)?;
if data.is_empty() {
return None;
}
let media_type = source
.get("media_type")
.and_then(Value::as_str)
.unwrap_or("application/pdf");
Some(json!({
"type":"input_file",
"file_data":format!("data:{media_type};base64,{data}"),
"filename":filename
}))
}
_ => None,
}
}
fn anthropic_tool_result_to_responses_output(block: &Value) -> Value {
let is_error = block.get("is_error").and_then(Value::as_bool) == Some(true);
let content = block.get("content");
if !is_error {
if let Some(Value::String(text)) = content {
return json!(text);
}
}
let mut output = Vec::new();
if is_error {
output.push(json!({"type":"input_text","text":TOOL_RESULT_ERROR_MARKER}));
}
match content {
Some(Value::String(text)) => {
output.push(json!({"type":"input_text","text":text}));
}
Some(Value::Array(blocks)) => {
for part in blocks {
match part.get("type").and_then(Value::as_str) {
Some("text") => {
if let Some(text) = part.get("text").and_then(Value::as_str) {
output.push(json!({"type":"input_text","text":text}));
}
}
Some("image") => {
if let Some(image) = anthropic_image_to_responses_part(part) {
output.push(image);
} else {
output.push(json!({
"type":"input_text",
"text":canonical_json_string(part)
}));
}
}
Some("document") => {
if let Some(file) = anthropic_document_to_responses_part(part) {
output.push(file);
} else {
output.push(json!({
"type":"input_text",
"text":canonical_json_string(part)
}));
}
}
_ => output.push(json!({
"type":"input_text",
"text":canonical_json_string(part)
})),
}
}
}
Some(value) => output.push(json!({
"type":"input_text",
"text":canonical_json_string(value)
})),
None => {}
}
Value::Array(output)
}
pub(crate) fn sanitize_anthropic_tool_use_input(name: &str, input: Value) -> Value {
if name != "Read" {
return input;
@@ -247,6 +375,37 @@ pub(crate) fn map_responses_stop_reason(
})
}
fn responses_error_message(body: &Value, fallback: &str) -> String {
body.pointer("/error/message")
.and_then(Value::as_str)
.or_else(|| body.get("message").and_then(Value::as_str))
.or_else(|| body.get("error").and_then(Value::as_str))
.filter(|message| !message.trim().is_empty())
.unwrap_or(fallback)
.to_string()
}
fn validate_responses_terminal_status(body: &Value) -> Result<(), ProxyError> {
let status = body.get("status").and_then(Value::as_str);
let has_error = body.get("error").is_some_and(|error| !error.is_null());
match status {
Some("failed") => Err(ProxyError::TransformError(format!(
"Responses upstream failed: {}",
responses_error_message(body, "response generation failed")
))),
Some("cancelled") => Err(ProxyError::TransformError(format!(
"Responses upstream cancelled the response: {}",
responses_error_message(body, "response generation was cancelled")
))),
_ if has_error => Err(ProxyError::TransformError(format!(
"Responses upstream returned an error envelope: {}",
responses_error_message(body, "unknown upstream error")
))),
_ => Ok(()),
}
}
/// Build Anthropic-style usage JSON from Responses API usage, including cache tokens.
///
/// **Robustness Features**:
@@ -259,10 +418,11 @@ pub(crate) fn map_responses_stop_reason(
/// 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
/// 4. cache_creation_input_tokens: Direct field → nested
/// input_tokens_details.cache_write_tokens → prompt_tokens_details.cache_write_tokens
///
/// **Cache Token Priority Order**:
/// 1. OpenAI nested details (`input_tokens_details.cached_tokens`, `prompt_tokens_details.cached_tokens`) as initial value
/// 1. OpenAI nested details (`cached_tokens`, `cache_write_tokens`) as initial values
/// 2. Direct Anthropic-style fields (`cache_read_input_tokens`, `cache_creation_input_tokens`) override if present
///
/// **Logging**:
@@ -345,6 +505,19 @@ pub(crate) fn build_anthropic_usage_from_responses(usage: Option<&Value>) -> Val
result["cache_read_input_tokens"] = json!(cached);
}
}
// GPT-5.6+ reports cache writes in the nested OpenAI token-details object.
// Treat writes as Anthropic cache creation so the downstream client and
// billing layer can distinguish them from fresh input.
let nested_cache_write = u
.pointer("/input_tokens_details/cache_write_tokens")
.and_then(|v| v.as_u64())
.or_else(|| {
u.pointer("/prompt_tokens_details/cache_write_tokens")
.and_then(|v| v.as_u64())
});
if let Some(cache_write) = nested_cache_write {
result["cache_creation_input_tokens"] = json!(cache_write);
}
// Step 2: Direct Anthropic-style fields override (authoritative if present)
// These preserve cache tokens even if input/output_tokens are missing
@@ -354,6 +527,9 @@ pub(crate) fn build_anthropic_usage_from_responses(usage: Option<&Value>) -> Val
if let Some(v) = u.get("cache_creation_input_tokens") {
result["cache_creation_input_tokens"] = v.clone();
}
if let Some(v) = u.get("cache_creation") {
result["cache_creation"] = v.clone();
}
// OpenAI/Responses 的 input(prompt_tokens/input_tokens)含缓存命中,Anthropic input_tokens 不含
// → 减去 cache_read 与 cache_creation,使其成为 fresh input。本函数在计量意义上是 claude 专属
@@ -381,13 +557,15 @@ pub(crate) fn build_anthropic_usage_from_responses(usage: Option<&Value>) -> Val
/// - user/assistant 的 text 内容 → 对应 role 的 message item
/// - tool_use 从 assistant message 中"提升"为独立的 function_call item
/// - tool_result 从 user message 中"提升"为独立的 function_call_output item
/// - thinking blocks → 丢弃
/// - bridge-owned thinking blocks → restore the original Responses reasoning item
/// - unrelated native thinking blocks → 丢弃
fn convert_messages_to_input(messages: &[Value]) -> Result<Vec<Value>, ProxyError> {
let mut input = Vec::new();
for msg in messages {
let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("user");
let content = msg.get("content");
let message_input_start = input.len();
match content {
// 字符串内容
@@ -425,17 +603,22 @@ fn convert_messages_to_input(messages: &[Value]) -> Result<Vec<Value>, ProxyErro
}
"image" => {
if let Some(source) = block.get("source") {
let media_type = source
.get("media_type")
.and_then(|m| m.as_str())
.unwrap_or("image/png");
let data =
source.get("data").and_then(|d| d.as_str()).unwrap_or("");
message_content.push(json!({
"type": "input_image",
"image_url": format!("data:{media_type};base64,{data}")
}));
if let Some(image) = anthropic_image_to_responses_part(block) {
message_content.push(image);
} else {
log::warn!(
"[Responses] Unsupported or invalid Anthropic image block"
);
}
}
"document" => {
if let Some(file) = anthropic_document_to_responses_part(block) {
message_content.push(file);
} else {
log::warn!(
"[Responses] Unsupported or invalid Anthropic document block"
);
}
}
@@ -477,11 +660,7 @@ fn convert_messages_to_input(messages: &[Value]) -> Result<Vec<Value>, ProxyErro
.get("tool_use_id")
.and_then(|i| i.as_str())
.unwrap_or("");
let output = match block.get("content") {
Some(Value::String(s)) => s.clone(),
Some(v) => canonical_json_string(v),
None => String::new(),
};
let output = anthropic_tool_result_to_responses_output(block);
input.push(json!({
"type": "function_call_output",
@@ -490,8 +669,19 @@ fn convert_messages_to_input(messages: &[Value]) -> Result<Vec<Value>, ProxyErro
}));
}
"thinking" => {
// 丢弃 thinking blocks(与 openai_chat 一致)
"thinking" | "redacted_thinking" => {
if let Some(reasoning_item) =
openai_reasoning_item_from_anthropic_block(block)
{
if !message_content.is_empty() {
input.push(json!({
"role": role,
"content": message_content.clone()
}));
message_content.clear();
}
input.push(reasoning_item);
}
}
_ => {}
@@ -512,6 +702,26 @@ fn convert_messages_to_input(messages: &[Value]) -> Result<Vec<Value>, ProxyErro
input.push(json!({ "role": role }));
}
}
// A replayed reasoning item is only valid when the same assistant
// generation also contains a following message/function call item.
// Reasoning-only incomplete turns otherwise brick the next request with
// "reasoning item ... without its required following item".
if role == "assistant" {
let mut has_generated_follower = false;
for index in (message_input_start..input.len()).rev() {
let item_type = input[index].get("type").and_then(Value::as_str);
let is_assistant_message =
input[index].get("role").and_then(Value::as_str) == Some("assistant");
if item_type == Some("reasoning") {
if !has_generated_follower {
input.remove(index);
}
} else if item_type == Some("function_call") || is_assistant_message {
has_generated_follower = true;
}
}
}
}
Ok(input)
@@ -519,12 +729,18 @@ fn convert_messages_to_input(messages: &[Value]) -> Result<Vec<Value>, ProxyErro
/// OpenAI Responses 响应 → Anthropic 响应
pub fn responses_to_anthropic(body: Value) -> Result<Value, ProxyError> {
// A Responses failure can arrive inside an HTTP 2xx response object. Reject it
// before looking at `output`; otherwise `{status:"failed", output:[]}` becomes
// a successful empty Anthropic `end_turn` and hides the upstream error.
validate_responses_terminal_status(&body)?;
let output = body
.get("output")
.and_then(|o| o.as_array())
.ok_or_else(|| ProxyError::TransformError("No output in response".to_string()))?;
let mut content = Vec::new();
let response_completed = body.get("status").and_then(Value::as_str) == Some("completed");
let mut has_tool_use = false;
for item in output {
@@ -559,7 +775,42 @@ pub fn responses_to_anthropic(body: Value) -> Result<Value, ProxyError> {
.get("arguments")
.and_then(|a| a.as_str())
.unwrap_or("{}");
let input: Value = serde_json::from_str(args_str).unwrap_or(json!({}));
let input: Value = if args_str.trim().is_empty() {
json!({})
} else {
match serde_json::from_str(args_str) {
Ok(value) => value,
Err(error) if !response_completed => {
log::warn!(
"[Responses] Replacing incomplete function_call '{name}' arguments with an empty object: {error}"
);
json!({})
}
Err(error) => {
return Err(ProxyError::TransformError(format!(
"Invalid function_call arguments for '{name}': {error}"
)))
}
}
};
if !input.is_object() {
if !response_completed {
log::warn!(
"[Responses] Replacing incomplete function_call '{name}' non-object arguments with an empty object"
);
content.push(json!({
"type": "tool_use",
"id": call_id,
"name": name,
"input": {}
}));
has_tool_use = true;
continue;
}
return Err(ProxyError::TransformError(format!(
"Function call arguments for '{name}' must be a JSON object"
)));
}
let input = sanitize_anthropic_tool_use_input(name, input);
content.push(json!({
@@ -572,26 +823,8 @@ pub fn responses_to_anthropic(body: Value) -> Result<Value, ProxyError> {
}
"reasoning" => {
// 映射 reasoning summary → thinking block
if let Some(summary) = item.get("summary").and_then(|s| s.as_array()) {
let thinking_text: String = summary
.iter()
.filter_map(|s| {
if s.get("type").and_then(|t| t.as_str()) == Some("summary_text") {
s.get("text").and_then(|t| t.as_str())
} else {
None
}
})
.collect::<Vec<_>>()
.join("");
if !thinking_text.is_empty() {
content.push(json!({
"type": "thinking",
"thinking": thinking_text
}));
}
if let Some(block) = anthropic_block_from_openai_reasoning_item(item) {
content.push(block);
}
}
@@ -770,10 +1003,51 @@ mod tests {
assert_eq!(result["tools"][0]["type"], "function");
assert_eq!(result["tools"][0]["name"], "get_weather");
assert!(result["tools"][0].get("parameters").is_some());
assert_eq!(result["tools"][0]["parameters"]["type"], json!("object"));
assert_eq!(
result["tools"][0]["parameters"]["properties"]["location"]["type"],
json!("string")
);
// input_schema should not appear
assert!(result["tools"][0].get("input_schema").is_none());
}
#[test]
fn test_anthropic_to_responses_defaults_missing_tool_schema_type() {
let input = json!({
"model": "gpt-4o",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Weather?"}],
"tools": [{
"name": "get_weather",
"description": "Get weather info",
"input_schema": {"properties": {"location": {"type": "string"}}}
}]
});
let result = anthropic_to_responses(input, None, false, false).unwrap();
let parameters = &result["tools"][0]["parameters"];
assert_eq!(parameters["type"], json!("object"));
assert_eq!(
parameters["properties"]["location"]["type"],
json!("string")
);
}
#[test]
fn test_anthropic_to_responses_defaults_empty_tool_schema() {
let input = json!({
"model": "gpt-4o",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Do work"}],
"tools": [{"name": "do_work", "input_schema": {}}]
});
let result = anthropic_to_responses(input, None, false, false).unwrap();
let parameters = &result["tools"][0]["parameters"];
assert_eq!(parameters, &json!({"type": "object", "properties": {}}));
}
#[test]
fn test_anthropic_to_responses_tool_choice_any_to_required() {
let input = json!({
@@ -855,6 +1129,34 @@ mod tests {
assert_eq!(input_arr[0]["output"], "Sunny, 25°C");
}
#[test]
fn test_anthropic_to_responses_tool_result_preserves_blocks_and_error() {
let input = json!({
"model":"gpt-5",
"messages":[{"role":"user","content":[{
"type":"tool_result",
"tool_use_id":"call_1",
"is_error":true,
"content":[
{"type":"text","text":"command failed"},
{"type":"image","source":{"type":"url","url":"https://example.com/error.png"}},
{"type":"document","title":"trace.pdf","source":{"type":"base64","media_type":"application/pdf","data":"JVBERi0="}}
]
}]}]
});
let result = anthropic_to_responses(input, None, false, false).unwrap();
let output = result["input"][0]["output"].as_array().unwrap();
assert_eq!(output[0]["text"], TOOL_RESULT_ERROR_MARKER);
assert_eq!(
output[1],
json!({"type":"input_text","text":"command failed"})
);
assert_eq!(output[2]["image_url"], "https://example.com/error.png");
assert_eq!(output[3]["type"], "input_file");
assert_eq!(output[3]["filename"], "trace.pdf");
}
#[test]
fn test_anthropic_to_responses_thinking_discarded() {
let input = json!({
@@ -900,6 +1202,25 @@ mod tests {
assert_eq!(content[1]["image_url"], "data:image/png;base64,abc123");
}
#[test]
fn test_anthropic_to_responses_url_image_and_document() {
let input = json!({
"model":"gpt-5",
"messages":[{"role":"user","content":[
{"type":"image","source":{"type":"url","url":"https://example.com/a.png"}},
{"type":"document","title":"manual.pdf","source":{"type":"url","url":"https://example.com/manual.pdf"}}
]}]
});
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_image");
assert_eq!(content[0]["image_url"], "https://example.com/a.png");
assert_eq!(content[1]["type"], "input_file");
assert_eq!(content[1]["file_url"], "https://example.com/manual.pdf");
assert_eq!(content[1]["filename"], "manual.pdf");
}
#[test]
fn test_responses_to_anthropic_simple() {
let input = json!({
@@ -952,6 +1273,65 @@ mod tests {
assert_eq!(result["stop_reason"], "tool_use");
}
#[test]
fn test_completed_function_call_empty_arguments_normalizes_to_object() {
let input = json!({
"id": "resp_empty_args",
"status": "completed",
"model": "gpt-5.6",
"output": [{
"type": "function_call",
"call_id": "call_1",
"name": "ping",
"arguments": ""
}],
"usage": {"input_tokens": 10, "output_tokens": 2}
});
let result = responses_to_anthropic(input).unwrap();
assert_eq!(result["content"][0]["input"], json!({}));
}
#[test]
fn test_incomplete_function_call_invalid_arguments_uses_empty_object() {
let input = json!({
"id": "resp_partial_args",
"status": "incomplete",
"incomplete_details": {"reason": "max_output_tokens"},
"model": "gpt-5.6",
"output": [{
"type": "function_call",
"call_id": "call_1",
"name": "dangerous_tool",
"arguments": "{\"path\":"
}],
"usage": {"input_tokens": 10, "output_tokens": 2}
});
let result = responses_to_anthropic(input).unwrap();
assert_eq!(result["content"][0]["type"], "tool_use");
assert_eq!(result["content"][0]["input"], json!({}));
assert_eq!(result["stop_reason"], "max_tokens");
}
#[test]
fn test_completed_function_call_invalid_arguments_is_error() {
let input = json!({
"id": "resp_bad_args",
"status": "completed",
"model": "gpt-5.6",
"output": [{
"type": "function_call",
"call_id": "call_1",
"name": "broken_tool",
"arguments": "{\"path\":"
}],
"usage": {"input_tokens": 10, "output_tokens": 2}
});
assert!(matches!(
responses_to_anthropic(input),
Err(ProxyError::TransformError(_))
));
}
#[test]
fn test_responses_to_anthropic_read_drops_empty_pages() {
let input = json!({
@@ -1057,6 +1437,115 @@ mod tests {
assert_eq!(result["content"][1]["text"], "The answer is 42");
}
#[test]
fn test_encrypted_reasoning_round_trips_through_anthropic_history() {
let original = json!({
"type": "reasoning",
"id": "rs_123",
"summary": [{"type": "summary_text", "text": "Need a tool."}],
"encrypted_content": "opaque-ciphertext"
});
let response = json!({
"id": "resp_123",
"status": "completed",
"model": "gpt-5.6",
"output": [original.clone()],
"usage": {"input_tokens": 10, "output_tokens": 2}
});
let anthropic = responses_to_anthropic(response).unwrap();
let thinking = anthropic["content"][0].clone();
assert_eq!(thinking["type"], "thinking");
assert!(thinking["signature"]
.as_str()
.is_some_and(|value| value.starts_with("ccswitch-openai-reasoning-v1:")));
let replay = anthropic_to_responses(
json!({
"model": "gpt-5.6",
"messages": [{"role": "assistant", "content": [
thinking,
{"type": "tool_use", "id": "call_1", "name": "lookup", "input": {}}
]}]
}),
None,
true,
false,
)
.unwrap();
assert_eq!(replay["input"][0], original);
assert_eq!(replay["input"][1]["type"], "function_call");
}
#[test]
fn test_reasoning_only_assistant_turn_is_not_replayed() {
let item = json!({
"type": "reasoning",
"id": "rs_orphan",
"summary": [],
"encrypted_content": "opaque"
});
let block = anthropic_block_from_openai_reasoning_item(&item).unwrap();
let replay = anthropic_to_responses(
json!({
"model": "gpt-5.6",
"messages": [
{"role": "assistant", "content": [block]},
{"role": "user", "content": "continue"}
]
}),
None,
true,
false,
)
.unwrap();
assert_eq!(replay["input"].as_array().unwrap().len(), 1);
assert_eq!(replay["input"][0]["role"], "user");
}
#[test]
fn test_responses_failed_status_is_not_silent_empty_success() {
let input = json!({
"id": "resp_failed",
"status": "failed",
"error": {"type": "server_error", "message": "backend exploded"},
"output": [],
"usage": {"input_tokens": 10, "output_tokens": 0}
});
let error = responses_to_anthropic(input).unwrap_err();
assert!(
matches!(error, ProxyError::TransformError(message) if message.contains("backend exploded"))
);
}
#[test]
fn test_responses_error_envelope_preserves_upstream_message() {
let input = json!({
"error": {"type": "rate_limit_error", "message": "too many requests"}
});
let error = responses_to_anthropic(input).unwrap_err();
assert!(
matches!(error, ProxyError::TransformError(message) if message.contains("too many requests"))
);
}
#[test]
fn test_responses_cancelled_status_is_not_end_turn() {
let input = json!({
"id": "resp_cancelled",
"status": "cancelled",
"output": []
});
let error = responses_to_anthropic(input).unwrap_err();
assert!(
matches!(error, ProxyError::TransformError(message) if message.contains("cancelled"))
);
}
#[test]
fn test_responses_to_anthropic_incomplete_status() {
let input = json!({
@@ -1669,6 +2158,21 @@ mod tests {
assert_eq!(result["cache_read_input_tokens"], json!(80));
}
#[test]
fn test_build_usage_cache_write_tokens_from_nested_details() {
let result = build_anthropic_usage_from_responses(Some(&json!({
"input_tokens": 100,
"output_tokens": 10,
"input_tokens_details": {
"cached_tokens": 30,
"cache_write_tokens": 20
}
})));
assert_eq!(result["input_tokens"], json!(50));
assert_eq!(result["cache_read_input_tokens"], json!(30));
assert_eq!(result["cache_creation_input_tokens"], json!(20));
}
#[test]
fn test_build_usage_cache_tokens_direct_override() {
let result = build_anthropic_usage_from_responses(Some(&json!({
+10
View File
@@ -327,6 +327,12 @@ impl ProxyServer {
.route("/v1/responses", post(handlers::handle_responses))
.route("/v1/v1/responses", post(handlers::handle_responses))
.route("/codex/v1/responses", post(handlers::handle_responses))
// Grok Build uses the Responses protocol but has an independent
// provider namespace and failover queue.
.route(
"/grokbuild/v1/responses",
post(handlers::handle_grokbuild_responses),
)
// OpenAI Responses Compact API (Codex CLI 远程压缩,透传)
.route(
"/responses/compact",
@@ -344,6 +350,10 @@ impl ProxyServer {
"/codex/v1/responses/compact",
post(handlers::handle_responses_compact),
)
.route(
"/grokbuild/v1/responses/compact",
post(handlers::handle_grokbuild_responses_compact),
)
// Gemini API (支持带前缀和不带前缀)
//
// 用 `any(..)` 覆盖所有 HTTP 方法:除了 POST `:generateContent` /
+48 -6
View File
@@ -7,7 +7,7 @@ use serde_json::{json, Value};
///
/// 三路径分发:
/// - skip: haiku 模型直接跳过
/// - adaptive: opus-4-8 / opus-4-7 / opus-4-6 / sonnet-4-6 使用 adaptive thinking
/// - adaptive: current adaptive-thinking Claude models use adaptive thinking
/// - legacy: 其他模型注入 enabled thinking + budget_tokens
pub fn optimize(body: &mut Value, config: &OptimizerConfig) {
if !config.thinking_optimizer {
@@ -73,13 +73,42 @@ pub fn optimize(body: &mut Value, config: &OptimizerConfig) {
}
}
fn uses_adaptive_thinking(model: &str) -> bool {
let normalized = model.replace('.', "-");
["opus-4-8", "opus-4-7", "opus-4-6", "sonnet-4-6"]
pub(crate) fn uses_adaptive_thinking(model: &str) -> bool {
let normalized = normalize_model_name(model);
[
"fable-5",
"mythos-5",
"mythos-preview",
"sonnet-5",
"opus-4-8",
"opus-4-7",
"opus-4-6",
"sonnet-4-6",
]
.iter()
.any(|needle| normalized.contains(needle))
}
/// Models where omitting `thinking` still leaves adaptive thinking enabled.
pub(crate) fn adaptive_thinking_is_default(model: &str) -> bool {
let normalized = normalize_model_name(model);
["fable-5", "mythos-5", "mythos-preview", "sonnet-5"]
.iter()
.any(|needle| normalized.contains(needle))
}
/// Models that reject `thinking: {"type":"disabled"}`.
pub(crate) fn thinking_cannot_be_disabled(model: &str) -> bool {
let normalized = normalize_model_name(model);
["fable-5", "mythos-5"]
.iter()
.any(|needle| normalized.contains(needle))
}
fn normalize_model_name(model: &str) -> String {
model.trim().to_ascii_lowercase().replace(['.', '_'], "-")
}
/// 追加 beta 标识到 anthropic_beta 数组(去重)
fn append_beta(body: &mut Value, beta: &str) {
match body.get_mut("anthropic_beta") {
@@ -108,7 +137,6 @@ mod tests {
enabled: true,
thinking_optimizer: true,
cache_injection: true,
cache_ttl: "1h".to_string(),
}
}
@@ -117,7 +145,6 @@ mod tests {
enabled: true,
thinking_optimizer: false,
cache_injection: true,
cache_ttl: "1h".to_string(),
}
}
@@ -139,6 +166,21 @@ mod tests {
assert!(betas.iter().any(|v| v == "context-1m-2025-08-07"));
}
#[test]
fn current_generation_models_use_adaptive_thinking() {
for model in [
"claude-sonnet-5",
"anthropic/claude-fable-5",
"claude-mythos-5",
"claude-opus-4.8",
] {
assert!(uses_adaptive_thinking(model), "model={model}");
}
assert!(adaptive_thinking_is_default("claude-sonnet-5"));
assert!(thinking_cannot_be_disabled("claude-fable-5"));
assert!(!thinking_cannot_be_disabled("claude-sonnet-5"));
}
#[test]
fn test_adaptive_opus_4_6() {
let mut body = json!({
+5 -12
View File
@@ -113,6 +113,7 @@ pub struct ProxyTakeoverStatus {
pub claude: bool,
pub codex: bool,
pub gemini: bool,
pub grokbuild: bool,
pub opencode: bool,
pub openclaw: bool,
}
@@ -219,11 +220,11 @@ pub struct RectifierConfig {
/// 让对话不中断。总开关,管辖「显式声明 text-only」与「上游报错后兜底」两条事实驱动路径。
#[serde(default = "default_true")]
pub request_media_fallback: bool,
/// 请求整流:启发式 text-only 模型名匹配(默认开启)
/// 请求整流:确认纯文本注册表的发送前降级(默认开启)
///
/// 在模型未声明能力时,按内置模型名列表预测性地剥离图片(发送前)
/// 受 request_media_fallback 管辖;单独关闭后仅保留「显式声明」与「上游兜底」
/// 避免内置列表把多模态模型误判成 text-only 而静默剥图
/// 在模型未声明能力时,按内置的确认纯文本注册表预先剥离图片
/// 受 request_media_fallback 管辖;单独关闭只停用代理的注册表预判
/// 仍保留「显式声明」与「上游兜底」,且不改变 Codex 模型目录声明
#[serde(default = "default_true")]
pub request_media_heuristic: bool,
}
@@ -264,13 +265,6 @@ pub struct OptimizerConfig {
/// Cache 注入子开关(总开关开启后默认生效)
#[serde(default = "default_true")]
pub cache_injection: bool,
/// Cache TTL: "5m" | "1h"(默认 "1h"
#[serde(default = "default_cache_ttl")]
pub cache_ttl: String,
}
fn default_cache_ttl() -> String {
"1h".to_string()
}
impl Default for OptimizerConfig {
@@ -279,7 +273,6 @@ impl Default for OptimizerConfig {
enabled: false,
thinking_optimizer: true,
cache_injection: true,
cache_ttl: "1h".to_string(),
}
}
}
+28 -6
View File
@@ -59,7 +59,7 @@ impl CostCalculator {
pricing: &ModelPricing,
cost_multiplier: Decimal,
) -> CostBreakdown {
let input_includes_cache_read = matches!(app_type, "codex" | "gemini");
let input_includes_cache_read = matches!(app_type, "codex" | "gemini" | "grokbuild");
Self::calculate_with_cache_semantics(
usage,
pricing,
@@ -76,10 +76,13 @@ impl CostCalculator {
) -> CostBreakdown {
let million = Decimal::from(1_000_000);
// OpenAI/Gemini 风格的 input_tokens 包含缓存命中,需要扣除后再按输入价计费;
// OpenAI/Gemini 风格的 input_tokens 包含缓存读取和写入,需要扣除后再按输入价计费;
// Claude/Anthropic 风格的 input_tokens 已经是 fresh input,不能再次扣减。
let billable_input_tokens = if input_includes_cache_read {
usage.input_tokens.saturating_sub(usage.cache_read_tokens)
usage
.input_tokens
.saturating_sub(usage.cache_read_tokens)
.saturating_sub(usage.cache_creation_tokens)
} else {
usage.input_tokens
};
@@ -197,15 +200,34 @@ mod tests {
let cost = CostCalculator::calculate_for_app("codex", &usage, &pricing, multiplier);
// Codex/OpenAI 语义:input_tokens 包含 cached_tokens,需要扣除 cache_read_tokens
assert_eq!(cost.input_cost, Decimal::from_str("0.0024").unwrap());
// Codex/OpenAI 语义:input_tokens 包含 cache read/write,两桶都需扣除。
assert_eq!(cost.input_cost, Decimal::from_str("0.0021").unwrap());
assert_eq!(cost.output_cost, Decimal::from_str("0.0075").unwrap());
assert_eq!(cost.cache_read_cost, Decimal::from_str("0.00006").unwrap());
assert_eq!(
cost.cache_creation_cost,
Decimal::from_str("0.000375").unwrap()
);
assert_eq!(cost.total_cost, Decimal::from_str("0.010335").unwrap());
assert_eq!(cost.total_cost, Decimal::from_str("0.010035").unwrap());
}
#[test]
fn grokbuild_does_not_double_bill_cached_input() {
let usage = TokenUsage {
input_tokens: 1000,
output_tokens: 0,
cache_read_tokens: 600,
cache_creation_tokens: 0,
model: None,
message_id: None,
};
let pricing = ModelPricing::from_strings("10", "0", "1", "0").unwrap();
let cost = CostCalculator::calculate_for_app("grokbuild", &usage, &pricing, Decimal::ONE);
assert_eq!(cost.input_cost, Decimal::from_str("0.004").unwrap());
assert_eq!(cost.cache_read_cost, Decimal::from_str("0.0006").unwrap());
assert_eq!(cost.total_cost, Decimal::from_str("0.0046").unwrap());
}
#[test]
+45 -1
View File
@@ -4,6 +4,7 @@ use super::calculator::{CostBreakdown, CostCalculator, ModelPricing};
use super::parser::TokenUsage;
use crate::database::{Database, PRICING_SOURCE_REQUEST, PRICING_SOURCE_RESPONSE};
use crate::error::AppError;
use crate::services::sql_helpers::{INPUT_TOKEN_SEMANTICS_FRESH, INPUT_TOKEN_SEMANTICS_TOTAL};
use crate::services::usage_stats::{find_model_pricing_row, is_placeholder_pricing_model};
use rust_decimal::Decimal;
use std::str::FromStr;
@@ -70,15 +71,22 @@ impl<'a> UsageLogger<'a> {
};
let created_at = chrono::Utc::now().timestamp();
let input_token_semantics =
if matches!(log.app_type.as_str(), "codex" | "gemini" | "grokbuild") {
INPUT_TOKEN_SEMANTICS_TOTAL
} else {
INPUT_TOKEN_SEMANTICS_FRESH
};
conn.execute(
"INSERT OR REPLACE INTO proxy_request_logs (
request_id, provider_id, app_type, model, request_model, pricing_model,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
input_token_semantics,
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
latency_ms, first_token_ms, status_code, error_message, session_id,
provider_type, is_streaming, cost_multiplier, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)",
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25)",
rusqlite::params![
log.request_id,
log.provider_id,
@@ -90,6 +98,7 @@ impl<'a> UsageLogger<'a> {
log.usage.output_tokens,
log.usage.cache_read_tokens,
log.usage.cache_creation_tokens,
input_token_semantics,
input_cost,
output_cost,
cache_read_cost,
@@ -451,4 +460,39 @@ mod tests {
assert_eq!(error, Some("Internal Server Error".to_string()));
Ok(())
}
#[test]
fn grokbuild_logs_total_input_token_semantics() -> Result<(), AppError> {
let db = Database::memory()?;
let logger = UsageLogger::new(&db);
let log = RequestLog {
request_id: "grok-semantics".to_string(),
provider_id: "grok-provider".to_string(),
app_type: "grokbuild".to_string(),
model: "grok-4.5".to_string(),
request_model: "grok-4.5".to_string(),
pricing_model: String::new(),
usage: TokenUsage::default(),
cost: None,
latency_ms: 1,
first_token_ms: None,
status_code: 200,
error_message: None,
session_id: None,
provider_type: Some("grokbuild".to_string()),
is_streaming: false,
cost_multiplier: "1".to_string(),
};
logger.log_request(&log)?;
let conn = crate::database::lock_conn!(db.conn);
let semantics: i64 = conn.query_row(
"SELECT input_token_semantics FROM proxy_request_logs WHERE request_id = 'grok-semantics'",
[],
|row| row.get(0),
)?;
assert_eq!(semantics, INPUT_TOKEN_SEMANTICS_TOTAL);
Ok(())
}
}
+55 -36
View File
@@ -9,6 +9,24 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
fn openai_cache_read_tokens(usage: &Value) -> u32 {
usage
.get("cache_read_input_tokens")
.or_else(|| usage.pointer("/input_tokens_details/cached_tokens"))
.or_else(|| usage.pointer("/prompt_tokens_details/cached_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0) as u32
}
fn openai_cache_write_tokens(usage: &Value) -> u32 {
usage
.get("cache_creation_input_tokens")
.or_else(|| usage.pointer("/input_tokens_details/cache_write_tokens"))
.or_else(|| usage.pointer("/prompt_tokens_details/cache_write_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0) as u32
}
/// Session 日志 request_id 前缀,与 `session_usage.rs` 中的格式保持一致
pub const SESSION_REQUEST_ID_PREFIX: &str = "session:";
@@ -250,25 +268,14 @@ impl TokenUsage {
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let cached_tokens = usage
.get("cache_read_input_tokens")
.and_then(|v| v.as_u64())
.or_else(|| {
usage
.get("input_tokens_details")
.and_then(|d| d.get("cached_tokens"))
.and_then(|v| v.as_u64())
})
.unwrap_or(0) as u32;
let cached_tokens = openai_cache_read_tokens(usage);
let cache_write_tokens = openai_cache_write_tokens(usage);
Some(Self {
input_tokens: input_tokens? as u32,
output_tokens: output_tokens? as u32,
cache_read_tokens: cached_tokens,
cache_creation_tokens: usage
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
cache_creation_tokens: cache_write_tokens,
model,
message_id: None,
})
@@ -285,19 +292,13 @@ impl TokenUsage {
let output_tokens = usage.get("output_tokens")?.as_u64()? as u32;
// 获取 cached_tokens (可能在 cache_read_input_tokens 或 input_tokens_details 中)
let cached_tokens = usage
.get("cache_read_input_tokens")
.and_then(|v| v.as_u64())
.or_else(|| {
usage
.get("input_tokens_details")
.and_then(|d| d.get("cached_tokens"))
.and_then(|v| v.as_u64())
})
.unwrap_or(0) as u32;
let cached_tokens = openai_cache_read_tokens(usage);
let cache_write_tokens = openai_cache_write_tokens(usage);
// 调整 input_tokens: 减去 cached_tokens
let adjusted_input = input_tokens.saturating_sub(cached_tokens);
// 调整 input_tokens: OpenAI total input 同时包含 cache read/write 两桶。
let adjusted_input = input_tokens
.saturating_sub(cached_tokens)
.saturating_sub(cache_write_tokens);
// 提取响应中的模型名称
let model = body
@@ -309,10 +310,7 @@ impl TokenUsage {
input_tokens: adjusted_input,
output_tokens,
cache_read_tokens: cached_tokens,
cache_creation_tokens: usage
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
cache_creation_tokens: cache_write_tokens,
model,
message_id: None,
})
@@ -391,11 +389,8 @@ impl TokenUsage {
let completion_tokens = usage.get("completion_tokens").and_then(|v| v.as_u64())?;
// 获取 cached_tokens (可能在 prompt_tokens_details 中)
let cached_tokens = usage
.get("prompt_tokens_details")
.and_then(|d| d.get("cached_tokens"))
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let cached_tokens = openai_cache_read_tokens(usage);
let cache_write_tokens = openai_cache_write_tokens(usage);
// 提取响应中的模型名称
let model = body
@@ -407,7 +402,7 @@ impl TokenUsage {
input_tokens: prompt_tokens as u32,
output_tokens: completion_tokens as u32,
cache_read_tokens: cached_tokens,
cache_creation_tokens: 0,
cache_creation_tokens: cache_write_tokens,
model,
message_id: None,
})
@@ -797,6 +792,30 @@ mod tests {
assert_eq!(usage.cache_read_tokens, 300);
}
#[test]
fn test_codex_response_parsing_cache_write_tokens_in_details() {
let response = json!({
"usage": {
"input_tokens": 1000,
"output_tokens": 500,
"input_tokens_details": {
"cached_tokens": 300,
"cache_write_tokens": 200
}
}
});
let usage = TokenUsage::from_codex_response(&response).unwrap();
assert_eq!(usage.input_tokens, 1000);
assert_eq!(usage.cache_read_tokens, 300);
assert_eq!(usage.cache_creation_tokens, 200);
let adjusted = TokenUsage::from_codex_response_adjusted(&response).unwrap();
assert_eq!(adjusted.input_tokens, 500);
assert_eq!(adjusted.cache_read_tokens, 300);
assert_eq!(adjusted.cache_creation_tokens, 200);
}
#[test]
fn test_codex_response_adjusted() {
let response = json!({
@@ -32,7 +32,8 @@
"effective_context_window_percent": 95,
"experimental_supported_tools": [],
"input_modalities": [
"text"
"text",
"image"
],
"supports_search_tool": false
}
+81 -45
View File
@@ -2,6 +2,12 @@
//!
//! 支持 DeepSeek、StepFun、SiliconFlow、OpenRouter、Novita AI 的账户余额查询。
//! 返回 UsageResult 格式,与现有用量系统无缝对接。
//!
//! 错误通道语义(与 coding_plan / subscription 两个服务保持一致):
//! - `Err(String)` = 瞬时传输失败(网络不可达/超时/读体中断)。前端 invoke reject
//! react-query 触发 retry 并保留上一次成功的 data(天然 keep-last-good)。
//! - `Ok(success:false)` = 确定性失败(空 key/未知供应商/鉴权/非 2xx/响应体非法 JSON),
//! 立即透出错误文案。判定按 reqwest 错误种类在折叠点完成,不依赖错误文案匹配。
use crate::provider::{UsageData, UsageResult};
use std::time::Duration;
@@ -65,7 +71,7 @@ fn make_auth_error(status: reqwest::StatusCode) -> UsageResult {
// GET https://api.deepseek.com/user/balance
// Response: { balance_infos: [{ currency, total_balance, granted_balance, topped_up_balance }], is_available }
async fn query_deepseek(api_key: &str) -> UsageResult {
async fn query_deepseek(api_key: &str) -> Result<UsageResult, String> {
let client = crate::proxy::http_client::get();
let resp = client
@@ -78,21 +84,27 @@ async fn query_deepseek(api_key: &str) -> UsageResult {
let resp = match resp {
Ok(r) => r,
Err(e) => return make_error(format!("Network error: {e}")),
Err(e) => return Err(format!("Network error: {e}")),
};
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return make_auth_error(status);
return Ok(make_auth_error(status));
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return make_error(format!("API error (HTTP {status}): {body}"));
return Ok(make_error(format!("API error (HTTP {status}): {body}")));
}
let body: serde_json::Value = match resp.json().await {
// 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体
// 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。
let raw = match resp.bytes().await {
Ok(b) => b,
Err(e) => return Err(format!("Failed to read response: {e}")),
};
let body: serde_json::Value = match serde_json::from_slice(&raw) {
Ok(v) => v,
Err(e) => return make_error(format!("Failed to parse response: {e}")),
Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))),
};
let is_available = body
@@ -126,18 +138,18 @@ async fn query_deepseek(api_key: &str) -> UsageResult {
}
}
UsageResult {
Ok(UsageResult {
success: true,
data: if data.is_empty() { None } else { Some(data) },
error: None,
}
})
}
// ── StepFun ─────────────────────────────────────────────────
// GET https://api.stepfun.com/v1/accounts
// Response: { object, type, balance, total_cash_balance, total_voucher_balance }
async fn query_stepfun(api_key: &str) -> UsageResult {
async fn query_stepfun(api_key: &str) -> Result<UsageResult, String> {
let client = crate::proxy::http_client::get();
let resp = client
@@ -150,26 +162,32 @@ async fn query_stepfun(api_key: &str) -> UsageResult {
let resp = match resp {
Ok(r) => r,
Err(e) => return make_error(format!("Network error: {e}")),
Err(e) => return Err(format!("Network error: {e}")),
};
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return make_auth_error(status);
return Ok(make_auth_error(status));
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return make_error(format!("API error (HTTP {status}): {body}"));
return Ok(make_error(format!("API error (HTTP {status}): {body}")));
}
let body: serde_json::Value = match resp.json().await {
// 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体
// 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。
let raw = match resp.bytes().await {
Ok(b) => b,
Err(e) => return Err(format!("Failed to read response: {e}")),
};
let body: serde_json::Value = match serde_json::from_slice(&raw) {
Ok(v) => v,
Err(e) => return make_error(format!("Failed to parse response: {e}")),
Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))),
};
let balance = parse_f64_field(&body, "balance").unwrap_or(0.0);
UsageResult {
Ok(UsageResult {
success: true,
data: Some(vec![UsageData {
plan_name: Some("StepFun".to_string()),
@@ -182,14 +200,14 @@ async fn query_stepfun(api_key: &str) -> UsageResult {
extra: None,
}]),
error: None,
}
})
}
// ── SiliconFlow ─────────────────────────────────────────────
// GET https://api.siliconflow.cn/v1/user/info (or .com for EN)
// Response: { code, data: { balance, chargeBalance, totalBalance, status } }
async fn query_siliconflow(api_key: &str, is_cn: bool) -> UsageResult {
async fn query_siliconflow(api_key: &str, is_cn: bool) -> Result<UsageResult, String> {
let client = crate::proxy::http_client::get();
let domain = if is_cn {
@@ -209,26 +227,32 @@ async fn query_siliconflow(api_key: &str, is_cn: bool) -> UsageResult {
let resp = match resp {
Ok(r) => r,
Err(e) => return make_error(format!("Network error: {e}")),
Err(e) => return Err(format!("Network error: {e}")),
};
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return make_auth_error(status);
return Ok(make_auth_error(status));
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return make_error(format!("API error (HTTP {status}): {body}"));
return Ok(make_error(format!("API error (HTTP {status}): {body}")));
}
let body: serde_json::Value = match resp.json().await {
// 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体
// 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。
let raw = match resp.bytes().await {
Ok(b) => b,
Err(e) => return Err(format!("Failed to read response: {e}")),
};
let body: serde_json::Value = match serde_json::from_slice(&raw) {
Ok(v) => v,
Err(e) => return make_error(format!("Failed to parse response: {e}")),
Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))),
};
let data = match body.get("data") {
Some(d) => d,
None => return make_error("Missing 'data' field in response".to_string()),
None => return Ok(make_error("Missing 'data' field in response".to_string())),
};
let total_balance = parse_f64_field(data, "totalBalance").unwrap_or(0.0);
@@ -240,7 +264,7 @@ async fn query_siliconflow(api_key: &str, is_cn: bool) -> UsageResult {
"SiliconFlow (EN)"
};
UsageResult {
Ok(UsageResult {
success: true,
data: Some(vec![UsageData {
plan_name: Some(plan_name.to_string()),
@@ -253,14 +277,14 @@ async fn query_siliconflow(api_key: &str, is_cn: bool) -> UsageResult {
extra: None,
}]),
error: None,
}
})
}
// ── OpenRouter ──────────────────────────────────────────────
// GET https://openrouter.ai/api/v1/credits
// Response: { data: { total_credits, total_usage } }
async fn query_openrouter(api_key: &str) -> UsageResult {
async fn query_openrouter(api_key: &str) -> Result<UsageResult, String> {
let client = crate::proxy::http_client::get();
let resp = client
@@ -273,21 +297,27 @@ async fn query_openrouter(api_key: &str) -> UsageResult {
let resp = match resp {
Ok(r) => r,
Err(e) => return make_error(format!("Network error: {e}")),
Err(e) => return Err(format!("Network error: {e}")),
};
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return make_auth_error(status);
return Ok(make_auth_error(status));
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return make_error(format!("API error (HTTP {status}): {body}"));
return Ok(make_error(format!("API error (HTTP {status}): {body}")));
}
let body: serde_json::Value = match resp.json().await {
// 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体
// 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。
let raw = match resp.bytes().await {
Ok(b) => b,
Err(e) => return Err(format!("Failed to read response: {e}")),
};
let body: serde_json::Value = match serde_json::from_slice(&raw) {
Ok(v) => v,
Err(e) => return make_error(format!("Failed to parse response: {e}")),
Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))),
};
let data = body.get("data").unwrap_or(&body);
@@ -295,7 +325,7 @@ async fn query_openrouter(api_key: &str) -> UsageResult {
let total_usage = parse_f64_field(data, "total_usage").unwrap_or(0.0);
let remaining = total_credits - total_usage;
UsageResult {
Ok(UsageResult {
success: true,
data: Some(vec![UsageData {
plan_name: Some("OpenRouter".to_string()),
@@ -312,7 +342,7 @@ async fn query_openrouter(api_key: &str) -> UsageResult {
extra: None,
}]),
error: None,
}
})
}
// ── Novita AI ───────────────────────────────────────────────
@@ -320,7 +350,7 @@ async fn query_openrouter(api_key: &str) -> UsageResult {
// Response: { availableBalance, cashBalance, creditLimit, outstandingInvoices }
// 金额单位:0.0001 USD
async fn query_novita(api_key: &str) -> UsageResult {
async fn query_novita(api_key: &str) -> Result<UsageResult, String> {
let client = crate::proxy::http_client::get();
let resp = client
@@ -333,27 +363,33 @@ async fn query_novita(api_key: &str) -> UsageResult {
let resp = match resp {
Ok(r) => r,
Err(e) => return make_error(format!("Network error: {e}")),
Err(e) => return Err(format!("Network error: {e}")),
};
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return make_auth_error(status);
return Ok(make_auth_error(status));
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return make_error(format!("API error (HTTP {status}): {body}"));
return Ok(make_error(format!("API error (HTTP {status}): {body}")));
}
let body: serde_json::Value = match resp.json().await {
// 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体
// 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。
let raw = match resp.bytes().await {
Ok(b) => b,
Err(e) => return Err(format!("Failed to read response: {e}")),
};
let body: serde_json::Value = match serde_json::from_slice(&raw) {
Ok(v) => v,
Err(e) => return make_error(format!("Failed to parse response: {e}")),
Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))),
};
// Novita 金额单位为 0.0001 USD,需除以 10000 转为 USD
let available = parse_f64_field(&body, "availableBalance").unwrap_or(0.0) / 10000.0;
UsageResult {
Ok(UsageResult {
success: true,
data: Some(vec![UsageData {
plan_name: Some("Novita AI".to_string()),
@@ -370,7 +406,7 @@ async fn query_novita(api_key: &str) -> UsageResult {
extra: None,
}]),
error: None,
}
})
}
// ── 工具函数 ────────────────────────────────────────────────
@@ -385,6 +421,8 @@ fn parse_f64_field(obj: &serde_json::Value, field: &str) -> Option<f64> {
// ── 公开入口 ────────────────────────────────────────────────
/// 查询余额。瞬时传输失败返回 `Err`(前端 reject → retry + 保留上次成功值),
/// 确定性失败返回 `Ok(success:false)`(见模块级文档)。
pub async fn get_balance(base_url: &str, api_key: &str) -> Result<UsageResult, String> {
if api_key.trim().is_empty() {
return Ok(UsageResult {
@@ -405,14 +443,12 @@ pub async fn get_balance(base_url: &str, api_key: &str) -> Result<UsageResult, S
}
};
let result = match provider {
match provider {
BalanceProvider::DeepSeek => query_deepseek(api_key).await,
BalanceProvider::StepFun => query_stepfun(api_key).await,
BalanceProvider::SiliconFlow => query_siliconflow(api_key, true).await,
BalanceProvider::SiliconFlowEn => query_siliconflow(api_key, false).await,
BalanceProvider::OpenRouter => query_openrouter(api_key).await,
BalanceProvider::NovitaAI => query_novita(api_key).await,
};
Ok(result)
}
}
+486 -58
View File
@@ -99,7 +99,7 @@ fn make_error(msg: String) -> SubscriptionQuota {
// ── Kimi For Coding ─────────────────────────────────────────
async fn query_kimi(api_key: &str) -> SubscriptionQuota {
async fn query_kimi(api_key: &str) -> Result<SubscriptionQuota, String> {
let client = crate::proxy::http_client::get();
let resp = client
@@ -112,12 +112,12 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota {
let resp = match resp {
Ok(r) => r,
Err(e) => return make_error(format!("Network error: {e}")),
Err(e) => return Err(format!("Network error: {e}")),
};
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return SubscriptionQuota {
return Ok(SubscriptionQuota {
tool: "coding_plan".to_string(),
credential_status: CredentialStatus::Expired,
credential_message: Some("Invalid API key".to_string()),
@@ -126,17 +126,23 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota {
extra_usage: None,
error: Some(format!("Authentication failed (HTTP {status})")),
queried_at: Some(now_millis()),
};
});
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return make_error(format!("API error (HTTP {status}): {body}"));
return Ok(make_error(format!("API error (HTTP {status}): {body}")));
}
let body: serde_json::Value = match resp.json().await {
// 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体
// 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。
let raw = match resp.bytes().await {
Ok(b) => b,
Err(e) => return Err(format!("Failed to read response: {e}")),
};
let body: serde_json::Value = match serde_json::from_slice(&raw) {
Ok(v) => v,
Err(e) => return make_error(format!("Failed to parse response: {e}")),
Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))),
};
let mut tiers = Vec::new();
@@ -187,7 +193,7 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota {
});
}
SubscriptionQuota {
Ok(SubscriptionQuota {
tool: "coding_plan".to_string(),
credential_status: CredentialStatus::Valid,
credential_message: None,
@@ -196,7 +202,7 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota {
extra_usage: None,
error: None,
queried_at: Some(now_millis()),
}
})
}
// ── 智谱 GLM ────────────────────────────────────────────────
@@ -307,7 +313,7 @@ fn zhipu_quota_base(base_url: &str) -> &'static str {
}
}
async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota {
async fn query_zhipu(base_url: &str, api_key: &str) -> Result<SubscriptionQuota, String> {
let client = crate::proxy::http_client::get();
let url = format!(
"{}/api/monitor/usage/quota/limit",
@@ -325,12 +331,12 @@ async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota {
let resp = match resp {
Ok(r) => r,
Err(e) => return make_error(format!("Network error: {e}")),
Err(e) => return Err(format!("Network error: {e}")),
};
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return SubscriptionQuota {
return Ok(SubscriptionQuota {
tool: "coding_plan".to_string(),
credential_status: CredentialStatus::Expired,
credential_message: Some("Invalid API key".to_string()),
@@ -339,19 +345,32 @@ async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota {
extra_usage: None,
error: Some(format!("Authentication failed (HTTP {status})")),
queried_at: Some(now_millis()),
};
});
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return make_error(format!("API error (HTTP {status}): {body}"));
return Ok(make_error(format!("API error (HTTP {status}): {body}")));
}
let body: serde_json::Value = match resp.json().await {
// 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体
// 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。
let raw = match resp.bytes().await {
Ok(b) => b,
Err(e) => return Err(format!("Failed to read response: {e}")),
};
let body: serde_json::Value = match serde_json::from_slice(&raw) {
Ok(v) => v,
Err(e) => return make_error(format!("Failed to parse response: {e}")),
Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))),
};
Ok(zhipu_quota_from_body(&body))
}
/// 解析智谱额度响应体(个人版与团队版共用同一 shape)。
/// 仅在 HTTP 成功、body 已完整读取并解析为 JSON 后调用——本函数不做任何网络 IO,
/// 故无瞬时失败通道,确定性失败直接落进 `Ok(success:false)`。
fn zhipu_quota_from_body(body: &serde_json::Value) -> SubscriptionQuota {
// 检查业务级别错误
if body.get("success").and_then(|v| v.as_bool()) == Some(false) {
let msg = body
@@ -388,7 +407,7 @@ async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota {
// ── MiniMax ─────────────────────────────────────────────────
async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota {
async fn query_minimax(api_key: &str, is_cn: bool) -> Result<SubscriptionQuota, String> {
let client = crate::proxy::http_client::get();
let api_domain = if is_cn {
@@ -408,12 +427,12 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota {
let resp = match resp {
Ok(r) => r,
Err(e) => return make_error(format!("Network error: {e}")),
Err(e) => return Err(format!("Network error: {e}")),
};
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return SubscriptionQuota {
return Ok(SubscriptionQuota {
tool: "coding_plan".to_string(),
credential_status: CredentialStatus::Expired,
credential_message: Some("Invalid API key".to_string()),
@@ -422,17 +441,23 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota {
extra_usage: None,
error: Some(format!("Authentication failed (HTTP {status})")),
queried_at: Some(now_millis()),
};
});
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return make_error(format!("API error (HTTP {status}): {body}"));
return Ok(make_error(format!("API error (HTTP {status}): {body}")));
}
let body: serde_json::Value = match resp.json().await {
// 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体
// 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。
let raw = match resp.bytes().await {
Ok(b) => b,
Err(e) => return Err(format!("Failed to read response: {e}")),
};
let body: serde_json::Value = match serde_json::from_slice(&raw) {
Ok(v) => v,
Err(e) => return make_error(format!("Failed to parse response: {e}")),
Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))),
};
// 检查业务级别错误
@@ -446,14 +471,14 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota {
.get("status_msg")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return make_error(format!("API error (code {status_code}): {msg}"));
return Ok(make_error(format!("API error (code {status_code}): {msg}")));
}
}
// 提取纯函数便于无 mock 单元测试;新接口直接给"剩余百分比",反转为已用百分比
let tiers = parse_minimax_tiers(&body);
SubscriptionQuota {
Ok(SubscriptionQuota {
tool: "coding_plan".to_string(),
credential_status: CredentialStatus::Valid,
credential_message: None,
@@ -462,12 +487,12 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota {
extra_usage: None,
error: None,
queried_at: Some(now_millis()),
}
})
}
// ── ZenMux ──────────────────────────────────────────────────
async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota {
async fn query_zenmux(base_url: &str, api_key: &str) -> Result<SubscriptionQuota, String> {
let client = crate::proxy::http_client::get();
let resp = client
@@ -480,12 +505,12 @@ async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota {
let resp = match resp {
Ok(r) => r,
Err(e) => return make_error(format!("Network error: {e}")),
Err(e) => return Err(format!("Network error: {e}")),
};
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return SubscriptionQuota {
return Ok(SubscriptionQuota {
tool: "coding_plan".to_string(),
credential_status: CredentialStatus::Expired,
credential_message: Some("Invalid API key".to_string()),
@@ -494,17 +519,23 @@ async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota {
extra_usage: None,
error: Some(format!("Authentication failed (HTTP {status})")),
queried_at: Some(now_millis()),
};
});
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return make_error(format!("API error (HTTP {status}): {body}"));
return Ok(make_error(format!("API error (HTTP {status}): {body}")));
}
let body: serde_json::Value = match resp.json().await {
// 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体
// 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。
let raw = match resp.bytes().await {
Ok(b) => b,
Err(e) => return Err(format!("Failed to read response: {e}")),
};
let body: serde_json::Value = match serde_json::from_slice(&raw) {
Ok(v) => v,
Err(e) => return make_error(format!("Failed to parse response: {e}")),
Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))),
};
// 检查业务级别错误
@@ -513,12 +544,12 @@ async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota {
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return make_error(format!("API error: {msg}"));
return Ok(make_error(format!("API error: {msg}")));
}
let data = match body.get("data") {
Some(d) => d,
None => return make_error("Missing 'data' field in response".to_string()),
None => return Ok(make_error("Missing 'data' field in response".to_string())),
};
let mut tiers = Vec::new();
@@ -581,7 +612,7 @@ async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota {
String::new()
};
SubscriptionQuota {
Ok(SubscriptionQuota {
tool: "coding_plan".to_string(),
credential_status: CredentialStatus::Valid,
credential_message: if plan_info.is_empty() {
@@ -594,7 +625,7 @@ async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota {
extra_usage: None,
error: None,
queried_at: Some(now_millis()),
}
})
}
/// 从 `/coding_plan/remains` 响应中解析 MiniMax 编程套餐的额度 tier。
@@ -690,8 +721,11 @@ enum VolcCall {
/// 硬鉴权失败(HTTP 401/403 或 AccessDenied/Signature 等错误码)——两个 plan
/// 共用凭据,命中即停。
Auth(String),
/// 网络 / 非鉴权 HTTP 错误 / 解析失败——记录后可继续尝试另一个 plan。
/// 非鉴权 HTTP 错误 / 响应体非法 JSON——记录后可继续尝试另一个 plan。
Soft(String),
/// 瞬时传输失败(网络/超时/读体中断)——同 host 的另一个 plan 大概率同样
/// 失败,调用方应立即以 `Err` 传播(前端 reject → retry + 保留上次成功值)。
Transient(String),
}
/// 从数据面 base_url 提取控制面 OpenAPI 所需的 Region(如
@@ -889,7 +923,7 @@ async fn volcengine_openapi_call(
let resp = match resp {
Ok(r) => r,
Err(e) => return VolcCall::Soft(format!("Network error: {e}")),
Err(e) => return VolcCall::Transient(format!("Network error: {e}")),
};
let status = resp.status();
@@ -916,7 +950,13 @@ async fn volcengine_openapi_call(
return VolcCall::Soft(format!("API error (HTTP {status}): {raw}"));
}
let body: serde_json::Value = match resp.json().await {
// 同 Bearer 路径:先 bytes() 再解析——读体失败是瞬时(Transient),解析失败
// 是确定性(Soft)。reqwest 的 json() 把读体错误也包成 decode,无法区分。
let raw = match resp.bytes().await {
Ok(b) => b,
Err(e) => return VolcCall::Transient(format!("Failed to read response: {e}")),
};
let body: serde_json::Value = match serde_json::from_slice(&raw) {
Ok(v) => v,
Err(e) => return VolcCall::Soft(format!("Failed to parse response: {e}")),
};
@@ -1058,7 +1098,7 @@ async fn query_volcengine(
base_url: &str,
access_key_id: &str,
secret_access_key: &str,
) -> SubscriptionQuota {
) -> Result<SubscriptionQuota, String> {
let region = volcengine_region(base_url);
let mut soft_errors: Vec<String> = Vec::new();
// 2xx + 无 Error 信封但解析不出额度时,截断原始响应用于诊断(区分"真没订阅"
@@ -1071,7 +1111,8 @@ async fn query_volcengine(
// 1) Agent PlanGetAFPUsage
match volcengine_openapi_call(&region, access_key_id, secret_access_key, "GetAFPUsage").await {
VolcCall::Auth(detail) => return volcengine_auth_error(detail),
VolcCall::Auth(detail) => return Ok(volcengine_auth_error(detail)),
VolcCall::Transient(detail) => return Err(format!("GetAFPUsage: {detail}")),
VolcCall::Soft(detail) => soft_errors.push(format!("GetAFPUsage: {detail}")),
VolcCall::Body(body) => {
let result = body.get("Result").unwrap_or(&body);
@@ -1083,7 +1124,7 @@ async fn query_volcengine(
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| format!("Agent Plan {s}"));
return volcengine_success(tiers, plan);
return Ok(volcengine_success(tiers, plan));
}
empty_responses.push(summarize("GetAFPUsage", &body));
}
@@ -1098,32 +1139,33 @@ async fn query_volcengine(
)
.await
{
VolcCall::Auth(detail) => return volcengine_auth_error(detail),
VolcCall::Auth(detail) => return Ok(volcengine_auth_error(detail)),
VolcCall::Transient(detail) => return Err(format!("GetCodingPlanUsage: {detail}")),
VolcCall::Soft(detail) => soft_errors.push(format!("GetCodingPlanUsage: {detail}")),
VolcCall::Body(body) => {
let result = body.get("Result").unwrap_or(&body);
let tiers = parse_coding_plan_tiers(result);
if !tiers.is_empty() {
return volcengine_success(tiers, Some("Coding Plan".to_string()));
return Ok(volcengine_success(tiers, Some("Coding Plan".to_string())));
}
empty_responses.push(summarize("GetCodingPlanUsage", &body));
}
}
if !soft_errors.is_empty() {
make_error(soft_errors.join("; "))
Ok(make_error(soft_errors.join("; ")))
} else if !empty_responses.is_empty() {
// 签名已通过、请求到达业务层,但响应里没有可解析的额度。带上原始响应,
// 便于核对真实字段名/包裹层,或确认确实未订阅。
make_error(format!(
Ok(make_error(format!(
"No active subscription found (signature OK). Raw: {}",
empty_responses.join(" || ")
))
)))
} else {
make_error(
Ok(make_error(
"No active Agent Plan or Coding Plan subscription found for this credential"
.to_string(),
)
))
}
}
@@ -1143,12 +1185,115 @@ fn coding_plan_not_found(error: &str) -> SubscriptionQuota {
}
}
// ── 智谱团队套餐(Team Plan)──────────────────────────────────
//
// 与个人版的差异仅在请求构造(参考 token-monitor/src/shared/zaiTeamLimits.js):
// - 固定走国内站 open.bigmodel.cn(团队版仅存在于国内站,z.ai 国际站无 team 档)
// - 同一 quota 路径加 `?type=2`
// - 额外请求头 bigmodel-organization / bigmodel-project(两者 + api_key 缺一不可)
// 响应 shape 与个人版完全一致 → 复用 zhipu_quota_from_body / parse_zhipu_token_tiers。
const ZHIPU_TEAM_QUOTA_URL: &str = "https://open.bigmodel.cn/api/monitor/usage/quota/limit";
async fn query_zhipu_team(
api_key: &str,
organization_id: &str,
project_id: &str,
) -> Result<SubscriptionQuota, String> {
query_zhipu_team_at(ZHIPU_TEAM_QUOTA_URL, api_key, organization_id, project_id).await
}
/// 团队版额度查询。`quota_url_base` 为不含 query 的 quota 端点;团队版与个人版同路径,
/// 靠 `?type=2` 区分(在此拼上)。拆出 url 参数便于用本地 server 测试请求形状。
async fn query_zhipu_team_at(
quota_url_base: &str,
api_key: &str,
organization_id: &str,
project_id: &str,
) -> Result<SubscriptionQuota, String> {
let client = crate::proxy::http_client::get();
let url = format!("{quota_url_base}?type=2");
let resp = client
.get(&url)
.header("Authorization", api_key) // 与个人版一致:智谱不加 Bearer 前缀
.header("bigmodel-organization", organization_id)
.header("bigmodel-project", project_id)
.header("Content-Type", "application/json")
.header("Accept-Language", "en-US,en")
.timeout(std::time::Duration::from_secs(15))
.send()
.await;
let resp = match resp {
Ok(r) => r,
Err(e) => return Err(format!("Network error: {e}")),
};
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return Ok(SubscriptionQuota {
tool: "coding_plan".to_string(),
credential_status: CredentialStatus::Expired,
credential_message: Some("Invalid API key".to_string()),
success: false,
tiers: vec![],
extra_usage: None,
error: Some(format!("Authentication failed (HTTP {status})")),
queried_at: Some(now_millis()),
});
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Ok(make_error(format!("API error (HTTP {status}): {body}")));
}
// 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体
// 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。
let raw = match resp.bytes().await {
Ok(b) => b,
Err(e) => return Err(format!("Failed to read response: {e}")),
};
let body: serde_json::Value = match serde_json::from_slice(&raw) {
Ok(v) => v,
Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))),
};
Ok(zhipu_quota_from_body(&body))
}
/// 查询编程套餐额度。瞬时传输失败(网络/超时/读体中断)返回 `Err`(前端 reject →
/// retry + 保留上次成功值);确定性失败(凭据缺失/未知域名/鉴权/非 2xx/业务错误)
/// 返回 `Ok(success:false)` 立即透出文案。判定按 reqwest 错误种类在折叠点完成。
///
/// `coding_plan_provider` 显式标识用于无法靠 base_url 区分的供应商(当前为智谱团队版
/// `zhipu_team`——其 base_url 与个人版智谱相同);其余情况走 `detect_provider`。
pub async fn get_coding_plan_quota(
base_url: &str,
api_key: &str,
access_key_id: Option<&str>,
secret_access_key: Option<&str>,
coding_plan_provider: Option<&str>,
team_organization_id: Option<&str>,
team_project_id: Option<&str>,
) -> Result<SubscriptionQuota, String> {
// 智谱团队版:base_url 与个人版智谱(open.bigmodel.cn)相同,detect_provider 无法
// 区分,必须靠显式 coding_plan_provider == "zhipu_team" 路由。需 api_key + 组织 ID
// + 项目 ID 三者齐全,缺任一返回 NotFound 引导补全。
if coding_plan_provider
.map(|p| p.eq_ignore_ascii_case("zhipu_team"))
.unwrap_or(false)
{
let organization_id = team_organization_id.unwrap_or("").trim();
let project_id = team_project_id.unwrap_or("").trim();
if api_key.trim().is_empty() || organization_id.is_empty() || project_id.is_empty() {
return Ok(coding_plan_not_found(
"Zhipu team plan needs the API key + organization ID + project ID",
));
}
return query_zhipu_team(api_key, organization_id, project_id).await;
}
let provider = match detect_provider(base_url) {
Some(p) => p,
// 域名未命中已知套餐供应商(如第三方中转站):给出明确错误而非静默失败
@@ -1165,7 +1310,7 @@ pub async fn get_coding_plan_quota(
"Volcengine usage query needs the account AccessKey ID + Secret (not the inference API key)",
));
}
return Ok(query_volcengine(base_url, ak, sk).await);
return query_volcengine(base_url, ak, sk).await;
}
// 其余供应商:数据面 Bearer api_key。
@@ -1174,7 +1319,7 @@ pub async fn get_coding_plan_quota(
return Ok(coding_plan_not_found("API key is empty"));
}
let quota = match provider {
match provider {
CodingPlanProvider::Kimi => query_kimi(api_key).await,
CodingPlanProvider::ZhipuCn | CodingPlanProvider::ZhipuEn => {
query_zhipu(base_url, api_key).await
@@ -1186,18 +1331,16 @@ pub async fn get_coding_plan_quota(
CodingPlanProvider::Volcengine => {
unreachable!("volcengine handled via AK/SK branch above")
}
};
Ok(quota)
}
}
#[cfg(test)]
mod tests {
use super::{
parse_afp_tiers, parse_coding_plan_tiers, parse_minimax_tiers, parse_zhipu_token_tiers,
volcengine_canonical_query, volcengine_is_auth_error_code, volcengine_region,
volcengine_response_error, volcengine_sign, zhipu_quota_base, TIER_FIVE_HOUR, TIER_MONTHLY,
TIER_WEEKLY_LIMIT,
query_zhipu_team_at, volcengine_canonical_query, volcengine_is_auth_error_code,
volcengine_region, volcengine_response_error, volcengine_sign, zhipu_quota_base,
TIER_FIVE_HOUR, TIER_MONTHLY, TIER_WEEKLY_LIMIT,
};
use serde_json::json;
@@ -1806,4 +1949,289 @@ mod tests {
let ok_body = json!({ "ResponseMetadata": { "RequestId": "x" }, "Result": {} });
assert!(volcengine_response_error(&ok_body).is_none());
}
// ── 传输层错误通道语义:瞬时 → Err(前端 reject/retry),确定性 → Ok(success:false) ──
//
// 借 ZenMux 分支可指向任意 base_url 的特性,用本地 listener 驱动真实 HTTP
// 路径,锁定 send 失败 / 读体中断 / 4xx / 非法 JSON 各自落在哪条通道。
// balance / subscription 服务与本文件共用同一折叠模式,这里的用例同时充当
// 三个服务的语义回归锚。
use super::get_coding_plan_quota;
use crate::services::subscription::CredentialStatus;
use std::io::{Read, Write};
/// 测试进程内可能有其他用例临时 set_var HTTP_PROXYhttp_client 的
/// loopback 检测测试),NO_PROXY 保证本地回环请求始终直连。
fn ensure_no_proxy_for_loopback() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
std::env::set_var("NO_PROXY", "127.0.0.1,localhost");
std::env::set_var("no_proxy", "127.0.0.1,localhost");
});
}
/// 起一个只服务一次连接的本地 HTTP server。`response=None` 表示读完请求
/// 直接断开(模拟响应前连接中断)。返回可命中 ZenMux 分支的 base_url。
fn spawn_once_server(response: Option<String>) -> (String, std::thread::JoinHandle<()>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind local listener");
let port = listener.local_addr().expect("local addr").port();
let handle = std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut buf = [0u8; 2048];
let _ = stream.read(&mut buf);
if let Some(resp) = response {
let _ = stream.write_all(resp.as_bytes());
let _ = stream.flush();
}
}
});
(format!("http://127.0.0.1:{port}/zenmux"), handle)
}
fn http_response(status_line: &str, body: &str) -> String {
format!(
"HTTP/1.1 {status_line}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
)
}
#[tokio::test]
async fn transient_connection_refused_returns_err() {
ensure_no_proxy_for_loopback();
// 绑定后立刻释放端口 → 连接被拒(send 失败)
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().expect("local addr").port();
drop(listener);
let result = get_coding_plan_quota(
&format!("http://127.0.0.1:{port}/zenmux"),
"k",
None,
None,
None,
None,
None,
)
.await;
let err = result.expect_err("send 失败必须走 Err 通道(瞬时,前端 reject 后重试)");
assert!(err.contains("Network error"), "err={err}");
}
#[tokio::test]
async fn transient_connection_closed_before_response_returns_err() {
ensure_no_proxy_for_loopback();
let (base_url, handle) = spawn_once_server(None);
let result = get_coding_plan_quota(&base_url, "k", None, None, None, None, None).await;
let err = result.expect_err("响应前连接中断必须走 Err 通道(瞬时)");
assert!(err.contains("Network error"), "err={err}");
handle.join().expect("server thread");
}
#[tokio::test]
async fn transient_truncated_body_returns_err() {
ensure_no_proxy_for_loopback();
// 声明 content-length: 100 但只写一小段就断开 → 读体中断。
// 锁定 bytes() 先于解析:这类失败必须走 Err(瞬时),不能因 reqwest 把
// 读体错误包成 decode 而被误判成确定性的 "Failed to parse response"。
let (base_url, handle) = spawn_once_server(Some(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 100\r\n\r\npartial"
.to_string(),
));
let result = get_coding_plan_quota(&base_url, "k", None, None, None, None, None).await;
let err = result.expect_err("读体中断必须走 Err 通道(瞬时,前端 reject 后重试)");
assert!(err.contains("Failed to read response"), "err={err}");
handle.join().expect("server thread");
}
#[tokio::test]
async fn deterministic_http_401_stays_ok_with_auth_error() {
ensure_no_proxy_for_loopback();
let (base_url, handle) = spawn_once_server(Some(http_response("401 Unauthorized", "{}")));
let quota = get_coding_plan_quota(&base_url, "k", None, None, None, None, None)
.await
.expect("鉴权失败是确定性失败,必须保持 Ok(success:false) 展示文案");
assert!(!quota.success);
assert!(matches!(quota.credential_status, CredentialStatus::Expired));
let err = quota.error.expect("应有错误文案");
assert!(err.contains("Authentication failed (HTTP 401"), "err={err}");
handle.join().expect("server thread");
}
#[tokio::test]
async fn deterministic_http_429_stays_ok_with_status_in_error() {
ensure_no_proxy_for_loopback();
let (base_url, handle) =
spawn_once_server(Some(http_response("429 Too Many Requests", "slow down")));
let quota = get_coding_plan_quota(&base_url, "k", None, None, None, None, None)
.await
.expect("非 2xx 保持 Ok(success:false),状态码留在文案里交前端分类");
assert!(!quota.success);
// 前端 isTransientUsageError 靠 /http\s+(\d{3})/ 提取状态码把 429 归瞬时,
// 文案格式是跨层契约,勿改。
let err = quota.error.expect("应有错误文案");
assert!(err.contains("HTTP 429"), "err={err}");
handle.join().expect("server thread");
}
#[tokio::test]
async fn deterministic_invalid_json_body_stays_ok_with_parse_error() {
ensure_no_proxy_for_loopback();
// 完整读到响应体但不是 JSON → is_decode → 确定性解析失败
let (base_url, handle) = spawn_once_server(Some(http_response("200 OK", "not-json")));
let quota = get_coding_plan_quota(&base_url, "k", None, None, None, None, None)
.await
.expect("完整但非法的响应体是确定性失败,必须保持 Ok(success:false)");
assert!(!quota.success);
let err = quota.error.expect("应有错误文案");
assert!(err.contains("Failed to parse response"), "err={err}");
handle.join().expect("server thread");
}
// ── 智谱团队套餐(Team Plan)──
/// 起一个只服务一次连接的本地 HTTP server,捕获原始请求文本(请求行 + 头),
/// 用于断言 team 查询发出的 URL query 与组织/项目请求头。`response=None` 时只捕获不回包。
fn spawn_request_capturing_server(
response: Option<String>,
) -> (
String,
std::sync::Arc<std::sync::Mutex<Option<String>>>,
std::thread::JoinHandle<()>,
) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind local listener");
let port = listener.local_addr().expect("local addr").port();
let captured = std::sync::Arc::new(std::sync::Mutex::new(None::<String>));
let captured_clone = captured.clone();
let handle = std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut buf: Vec<u8> = Vec::new();
let mut tmp = [0u8; 4096];
// GET 无 body,读到 header 末尾(\r\n\r\n)即可
while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
match stream.read(&mut tmp) {
Ok(0) | Err(_) => break,
Ok(n) => {
buf.extend_from_slice(&tmp[..n]);
if buf.len() > 16 * 1024 {
break;
}
}
}
}
*captured_clone.lock().unwrap() = Some(String::from_utf8_lossy(&buf).into_owned());
if let Some(resp) = response {
let _ = stream.write_all(resp.as_bytes());
let _ = stream.flush();
}
}
});
(format!("http://127.0.0.1:{port}/team"), captured, handle)
}
#[tokio::test]
async fn zhipu_team_missing_creds_returns_not_found() {
// 团队版必需 api_key + 组织 ID + 项目 ID,缺任一在触网前返回 NotFound(不联网)。
let cases: [(&str, Option<&str>, Option<&str>); 3] = [
("key", Some("org"), None), // 缺 project
("key", None, Some("proj")), // 缺 organization
(" ", Some("org"), Some("proj")), // 空 api_key
];
for (api_key, org, project) in cases {
let q = get_coding_plan_quota(
"https://open.bigmodel.cn/api/coding",
api_key,
None,
None,
Some("zhipu_team"),
org,
project,
)
.await
.expect("凭据缺失是确定性失败,保持 Ok(success:false)");
assert!(!q.success, "应失败: api_key={api_key:?}");
assert!(
matches!(q.credential_status, CredentialStatus::NotFound),
"应为 NotFound: api_key={api_key:?}"
);
}
// 标识大小写不敏感(eq_ignore_ascii_case),大写仍命中 team 分支并返回引导文案。
let msg = get_coding_plan_quota(
"https://open.bigmodel.cn/api/coding",
"key",
None,
None,
Some("Zhipu_Team"),
None,
None,
)
.await
.expect("ok")
.error
.expect("应有错误文案");
assert!(
msg.contains("API key + organization ID + project ID"),
"err={msg}"
);
}
#[tokio::test]
async fn zhipu_team_request_carries_type2_and_org_project_headers() {
ensure_no_proxy_for_loopback();
// 响应 shape 与个人版一致:两条 TOKENS_LIMITunit 3/6)→ five_hour + weekly。
let body = serde_json::json!({
"success": true,
"data": {
"level": "max",
"limits": [
{ "type": "TOKENS_LIMIT", "unit": 3, "number": 5, "percentage": 26.0 },
{ "type": "TOKENS_LIMIT", "unit": 6, "number": 1, "percentage": 5.0 }
]
}
});
let body_str = body.to_string();
let resp = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body_str}",
body_str.len()
);
let (base, captured, handle) = spawn_request_capturing_server(Some(resp));
let quota = query_zhipu_team_at(&base, "team-key", "org-xxx", "proj_xxx")
.await
.expect("2xx + 合法 body 应成功");
handle.join().expect("server thread");
// 请求形状契约(与 token-monitor zaiTeamLimits 对齐):
// 同路径加 ?type=2 + bigmodel-organization / bigmodel-project 头 + 鉴权头。
// reqwest/hyper 发头会小写化,故整体转小写做包含匹配。
let raw = captured.lock().unwrap().clone().expect("应捕获到请求");
let raw_lc = raw.to_lowercase();
assert!(raw_lc.contains("/team?type=2"), "缺 ?type=2: {raw}");
assert!(
raw_lc.contains("bigmodel-organization: org-xxx"),
"缺组织头: {raw}"
);
assert!(
raw_lc.contains("bigmodel-project: proj_xxx"),
"缺项目头: {raw}"
);
assert!(
raw_lc.contains("authorization: team-key"),
"缺鉴权头: {raw}"
);
// 解析复用个人版 zhipu_quota_from_body / parse_zhipu_token_tiers。
assert!(quota.success);
assert_eq!(quota.tiers.len(), 2);
assert_eq!(quota.tiers[0].name, TIER_FIVE_HOUR);
assert_eq!(quota.tiers[0].utilization, 26.0);
assert_eq!(quota.tiers[1].name, TIER_WEEKLY_LIMIT);
assert_eq!(quota.tiers[1].utilization, 5.0);
}
}
+3 -3
View File
@@ -88,6 +88,7 @@ impl ConfigService {
Self::sync_current_provider_for_app(config, &AppType::Claude)?;
Self::sync_current_provider_for_app(config, &AppType::Codex)?;
Self::sync_current_provider_for_app(config, &AppType::Gemini)?;
Self::sync_current_provider_for_app(config, &AppType::GrokBuild)?;
Ok(())
}
@@ -125,6 +126,7 @@ impl ConfigService {
// Claude Desktop 3P profiles are managed by claude_desktop_config.
}
AppType::Gemini => Self::sync_gemini_live(config, &current_id, &provider)?,
AppType::GrokBuild => crate::grok_config::write_grok_provider_live(&provider)?,
AppType::OpenCode => {
// OpenCode uses additive mode, no live sync needed
// OpenCode providers are managed directly in the config file
@@ -159,9 +161,7 @@ impl ConfigService {
}
let cfg_text = settings.get("config").and_then(Value::as_str);
let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format(
provider.meta.as_ref().and_then(|m| m.api_format.as_deref()),
);
let profile = crate::proxy::providers::resolve_codex_catalog_tool_profile(provider);
crate::codex_config::write_codex_provider_live_with_catalog(
&provider.settings_config,
+118 -9
View File
@@ -37,6 +37,9 @@ impl McpService {
if prev_apps.gemini && !server.apps.gemini {
Self::remove_server_from_app(state, &server.id, &AppType::Gemini)?;
}
if prev_apps.grokbuild && !server.apps.grokbuild {
Self::remove_server_from_app(state, &server.id, &AppType::GrokBuild)?;
}
if prev_apps.opencode && !server.apps.opencode {
Self::remove_server_from_app(state, &server.id, &AppType::OpenCode)?;
}
@@ -122,6 +125,13 @@ impl McpService {
AppType::Gemini => {
mcp::sync_single_server_to_gemini(&Default::default(), &server.id, &server.server)?;
}
AppType::GrokBuild => {
mcp::sync_single_server_to_grokbuild(
&Default::default(),
&server.id,
&server.server,
)?;
}
AppType::OpenCode => {
mcp::sync_single_server_to_opencode(
&Default::default(),
@@ -162,6 +172,7 @@ impl McpService {
}
AppType::Codex => mcp::remove_server_from_codex(id)?,
AppType::Gemini => mcp::remove_server_from_gemini(id)?,
AppType::GrokBuild => mcp::remove_server_from_grokbuild(id)?,
AppType::OpenCode => {
mcp::remove_server_from_opencode(id)?;
}
@@ -176,21 +187,55 @@ impl McpService {
Ok(())
}
/// 手动同步所有启用的 MCP 服务器到对应的应用
/// 手动同步所有启用的 MCP 服务器到对应的应用
///
/// Best-effort:单个应用投影失败(如 ~/.claude.json 坏 JSON)不阻断
/// 其余应用——各应用的 live 文件互相独立,一处损坏没有理由让其他
/// 应用的 MCP 状态陈旧。全部跑完后若有失败,聚合成一个错误上报,
/// 保留调用方的可见性。
pub fn sync_all_enabled(state: &AppState) -> Result<(), AppError> {
let servers = Self::get_all_servers(state)?;
let mut failures: Vec<String> = Vec::new();
for app in AppType::all() {
if matches!(app, AppType::OpenClaw | AppType::ClaudeDesktop) {
continue;
if let Err(err) = Self::project_servers_to_app(state, &servers, &app) {
log::warn!("同步 MCP 到 {app:?} 失败: {err}");
failures.push(format!("{}: {err}", app.as_str()));
}
}
for server in servers.values() {
if server.apps.is_enabled_for(&app) {
Self::sync_server_to_app(state, server, &app)?;
} else {
Self::remove_server_from_app(state, &server.id, &app)?;
}
if failures.is_empty() {
Ok(())
} else {
Err(AppError::Message(format!(
"部分应用 MCP 同步失败: {}",
failures.join("; ")
)))
}
}
/// 只把启用状态投影到单个应用。某个应用的 live 被整体重写后用它做
/// 定向重投影,避免把无关应用的失败面(如 ~/.claude.json 坏 JSON
/// 牵连进目标应用的关键路径。
pub fn sync_enabled_for_app(state: &AppState, app: &AppType) -> Result<(), AppError> {
let servers = Self::get_all_servers(state)?;
Self::project_servers_to_app(state, &servers, app)
}
fn project_servers_to_app(
state: &AppState,
servers: &IndexMap<String, McpServer>,
app: &AppType,
) -> Result<(), AppError> {
if matches!(app, AppType::OpenClaw | AppType::ClaudeDesktop) {
return Ok(());
}
for server in servers.values() {
if server.apps.is_enabled_for(app) {
Self::sync_server_to_app(state, server, app)?;
} else {
Self::remove_server_from_app(state, &server.id, app)?;
}
}
@@ -359,6 +404,32 @@ impl McpService {
Ok(new_count)
}
/// 从 Grok Build 的 `[mcp_servers]` 导入 MCP。
pub fn import_from_grokbuild(state: &AppState) -> Result<usize, AppError> {
let mut temp_config = crate::app_config::MultiAppConfig::default();
let count = crate::mcp::import_from_grokbuild(&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() {
let to_save = if let Some(existing_server) = existing.get(&server.id) {
let mut merged = existing_server.clone();
merged.apps.grokbuild = true;
merged
} else {
new_count += 1;
server.clone()
};
state.db.save_mcp_server(&to_save)?;
existing.insert(to_save.id.clone(), to_save);
}
}
}
Ok(new_count)
}
/// 从 OpenCode 导入 MCPv3.9.2+ 新增)
pub fn import_from_opencode(state: &AppState) -> Result<usize, AppError> {
// 创建临时 MultiAppConfig 用于导入
@@ -434,4 +505,42 @@ impl McpService {
Ok(new_count)
}
/// 从所有支持 MCP 的应用导入服务器,返回新导入的数量。
///
/// Best-effort:单个应用导入失败(如坏 config.toml)不阻断其余应用;
/// 全部跑完后若有失败,聚合成一个错误上报——历史实现逐应用
/// `unwrap_or(0)` 吞错,坏文件只会表现为"导入成功 0 个",用户
/// 无从得知哪个应用出了问题。
pub fn import_from_all_apps(state: &AppState) -> Result<usize, AppError> {
let mut total = 0;
let mut failures: Vec<String> = Vec::new();
let results: [(&str, Result<usize, AppError>); 6] = [
("claude", Self::import_from_claude(state)),
("codex", Self::import_from_codex(state)),
("gemini", Self::import_from_gemini(state)),
("grokbuild", Self::import_from_grokbuild(state)),
("opencode", Self::import_from_opencode(state)),
("hermes", Self::import_from_hermes(state)),
];
for (app, result) in results {
match result {
Ok(count) => total += count,
Err(err) => {
log::warn!("从 {app} 导入 MCP 失败: {err}");
failures.push(format!("{app}: {err}"));
}
}
}
if failures.is_empty() {
Ok(total)
} else {
Err(AppError::Message(format!(
"已导入 {total} 个,部分应用导入失败: {}",
failures.join("; ")
)))
}
}
}
+1
View File
@@ -7,6 +7,7 @@ pub mod env_manager;
pub mod mcp;
pub mod model_fetch;
pub mod omo;
pub mod profile;
pub mod prompt;
pub mod provider;
pub mod proxy;
+656
View File
@@ -0,0 +1,656 @@
//! 项目 Profile 编排服务
//!
//! Profile 是**全应用共享的项目实体**(用户拥有的项目就那几个),payload
//! 按 app 分槽存配置快照(供应商 / MCP / Skills / Prompt)。快照与应用
//! 均**按分组(scope)操作**Claude Code 与 Codex 的工作目录往往不同
//! (各在各的项目里),因此各组独立指向自己的当前项目、只拍/只应用组内
//! 槽位,互不牵连;重命名/删除作用于共享实体本身。
//! 应用(apply)时复用现有切换原语批量落地:
//! - 供应商:`ProviderService::switch`(内建代理接管热切换与接管下禁切官方)
//! - MCP`McpService::toggle_app`(改标志 + 单 server 物化)
//! - Skills`SkillService::toggle_app`(改标志 + 单 skill 物化)
//! - Prompt`PromptService::enable_prompt`(互斥激活 + 原子写 live
//!
//! apply 为 best-effort:单项失败收集为 warning 继续,不整体回滚。
use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use crate::app_config::AppType;
use crate::database::Profile;
use crate::error::AppError;
use crate::services::{McpService, PromptService, ProviderService, SkillService};
use crate::store::AppState;
/// Profile 操作的应用分组:项目实体全应用共享,但快照/应用/当前指针按组进行。
///
/// Claude Code 与 Claude Desktop 的供应商在 cc-switch 中是独立切换的,
/// 因此各自拥有独立的项目分组。两者 live 文件零交集
///`~/.claude` / `Application Support/Claude-3p`),分组切换互不干扰。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProfileScope {
Claude,
#[serde(rename = "claude-desktop")]
ClaudeDesktop,
Codex,
}
impl ProfileScope {
/// 全部分组(扩展新分组时同步扩展 apps/for_app 与前端 scope.ts 镜像)
pub const ALL: [ProfileScope; 3] = [
ProfileScope::Claude,
ProfileScope::ClaudeDesktop,
ProfileScope::Codex,
];
pub fn as_str(&self) -> &'static str {
match self {
ProfileScope::Claude => "claude",
ProfileScope::ClaudeDesktop => "claude-desktop",
ProfileScope::Codex => "codex",
}
}
pub fn parse(value: &str) -> Result<Self, AppError> {
match value {
"claude" => Ok(ProfileScope::Claude),
"claude-desktop" => Ok(ProfileScope::ClaudeDesktop),
"codex" => Ok(ProfileScope::Codex),
other => Err(AppError::InvalidInput(format!(
"Unknown profile scope: {other}"
))),
}
}
/// 组内受管应用(快照与 apply 只作用于这些 app 的槽位)
pub fn apps(&self) -> &'static [AppType] {
match self {
ProfileScope::Claude => &[AppType::Claude],
ProfileScope::ClaudeDesktop => &[AppType::ClaudeDesktop],
ProfileScope::Codex => &[AppType::Codex],
}
}
/// 应用页 → 所属分组(Profile 不支持的应用返回 None
pub fn for_app(app: &AppType) -> Option<Self> {
match app {
AppType::Claude => Some(ProfileScope::Claude),
AppType::ClaudeDesktop => Some(ProfileScope::ClaudeDesktop),
AppType::Codex => Some(ProfileScope::Codex),
_ => None,
}
}
}
/// 按 app 分槽的载荷容器;字段名与 AppType 的 serde 形式一致
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct PerApp<T> {
pub claude: T,
#[serde(rename = "claude-desktop")]
pub claude_desktop: T,
pub codex: T,
}
impl<T> PerApp<T> {
pub fn get(&self, app: &AppType) -> Option<&T> {
match app {
AppType::Claude => Some(&self.claude),
AppType::ClaudeDesktop => Some(&self.claude_desktop),
AppType::Codex => Some(&self.codex),
_ => None,
}
}
pub fn get_mut(&mut self, app: &AppType) -> Option<&mut T> {
match app {
AppType::Claude => Some(&mut self.claude),
AppType::ClaudeDesktop => Some(&mut self.claude_desktop),
AppType::Codex => Some(&mut self.codex),
_ => None,
}
}
}
/// Profile 的 JSON 快照结构(与前端 TS 类型严格对应)
///
/// 所有槽位都是 Option:None = 该侧从未拍过快照(应用时不动),
/// 与"拍到的就是空集/无激活项"(Some(空),应用时清空启用)严格区分——
/// 在 Codex 页选中一个只在 Claude 页建过的项目不能误清 Codex 的启用状态。
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ProfilePayload {
/// 每 app 的当前供应商 id
pub providers: PerApp<Option<String>>,
/// 每 app 启用的 MCP server id 集合
pub mcp: PerApp<Option<Vec<String>>>,
/// 每 app 启用的 Skill id 集合
pub skills: PerApp<Option<Vec<String>>>,
/// 每 app 激活的 prompt id
pub prompts: PerApp<Option<String>>,
}
impl ProfilePayload {
/// 用另一份快照覆盖本载荷中某分组的槽位,其余分组原样保留
/// "以当前状态更新"只更新发起页所属分组,避免把别的应用
/// 正处于其他项目的状态串进来)
pub fn merge_scope_from(&mut self, other: &ProfilePayload, scope: ProfileScope) {
for app in scope.apps() {
if let (Some(dst), Some(src)) = (self.providers.get_mut(app), other.providers.get(app))
{
*dst = src.clone();
}
if let (Some(dst), Some(src)) = (self.mcp.get_mut(app), other.mcp.get(app)) {
*dst = src.clone();
}
if let (Some(dst), Some(src)) = (self.skills.get_mut(app), other.skills.get(app)) {
*dst = src.clone();
}
if let (Some(dst), Some(src)) = (self.prompts.get_mut(app), other.prompts.get(app)) {
*dst = src.clone();
}
}
}
/// 某分组是否拍过快照(任一槽位非 None 即视为拍过)
pub fn scope_captured(&self, scope: ProfileScope) -> bool {
scope.apps().iter().any(|app| {
self.providers.get(app).is_some_and(|s| s.is_some())
|| self.mcp.get(app).is_some_and(|s| s.is_some())
|| self.skills.get(app).is_some_and(|s| s.is_some())
|| self.prompts.get(app).is_some_and(|s| s.is_some())
})
}
}
/// 计算从当前启用状态到目标集合的最小 toggle 集
///
/// 返回 (需要执行的 (id, enabled) 列表, payload 中已不存在于 DB 的悬空 id 列表)
fn plan_toggles(
current: &[(String, bool)],
target_ids: &[String],
) -> (Vec<(String, bool)>, Vec<String>) {
let existing: HashSet<&str> = current.iter().map(|(id, _)| id.as_str()).collect();
let target: HashSet<&str> = target_ids.iter().map(|s| s.as_str()).collect();
let toggles = current
.iter()
.filter(|(id, enabled)| target.contains(id.as_str()) != *enabled)
.map(|(id, enabled)| (id.clone(), !enabled))
.collect();
let dangling = target_ids
.iter()
.filter(|id| !existing.contains(id.as_str()))
.cloned()
.collect();
(toggles, dangling)
}
pub struct ProfileService;
impl ProfileService {
/// 抓取分组内应用的当前配置状态生成快照(组外槽位保持默认值)
pub fn snapshot_current(
state: &AppState,
scope: ProfileScope,
) -> Result<ProfilePayload, AppError> {
let mut payload = ProfilePayload::default();
let mcp_servers = state.db.get_all_mcp_servers()?;
let skills = state.db.get_all_installed_skills()?;
for app in scope.apps().iter() {
if let Some(slot) = payload.providers.get_mut(app) {
*slot = crate::settings::get_effective_current_provider(&state.db, app)?;
}
if let Some(slot) = payload.mcp.get_mut(app) {
*slot = Some(
mcp_servers
.values()
.filter(|s| s.apps.is_enabled_for(app))
.map(|s| s.id.clone())
.collect(),
);
}
if let Some(slot) = payload.skills.get_mut(app) {
*slot = Some(
skills
.values()
.filter(|s| s.apps.is_enabled_for(app))
.map(|s| s.id.clone())
.collect(),
);
}
if let Some(slot) = payload.prompts.get_mut(app) {
*slot = state
.db
.get_prompts(app.as_str())?
.values()
.find(|p| p.enabled)
.map(|p| p.id.clone());
}
}
Ok(payload)
}
/// 列出所有项目(项目实体全应用共享,current 标记按分组单独读取)
pub fn list(state: &AppState) -> Result<Vec<Profile>, AppError> {
state.db.get_all_profiles()
}
/// 创建新项目:只拍发起页所属分组的当前状态,其余分组槽位留 None
/// (其他应用可能正处于别的项目,不能替用户拍进来)
pub fn create(state: &AppState, name: &str, scope: ProfileScope) -> Result<Profile, AppError> {
let name = name.trim();
if name.is_empty() {
return Err(AppError::InvalidInput("Profile name is empty".to_string()));
}
let payload = Self::snapshot_current(state, scope)?;
let now = chrono::Utc::now().timestamp();
let profile = Profile {
id: uuid::Uuid::new_v4().to_string(),
name: name.to_string(),
payload: serde_json::to_string(&payload)
.map_err(|e| AppError::Config(format!("序列化 profile payload 失败: {e}")))?,
sort_order: None,
created_at: Some(now),
updated_at: Some(now),
};
state.db.save_profile(&profile)?;
Ok(profile)
}
/// 更新项目:重命名(作用于共享实体)和/或以当前状态重拍快照
/// resnapshot 只覆盖 scope 分组的槽位,其余分组原样保留;
/// 快照重拍仅由 [`Self::apply`] 切换前的自动保存触发,UI 不再暴露手动入口)
pub fn update(
state: &AppState,
id: &str,
name: Option<String>,
resnapshot: bool,
scope: Option<ProfileScope>,
) -> Result<Profile, AppError> {
let mut profile = state
.db
.get_profile(id)?
.ok_or_else(|| AppError::InvalidInput(format!("Profile not found: {id}")))?;
if let Some(name) = name {
let name = name.trim().to_string();
if name.is_empty() {
return Err(AppError::InvalidInput("Profile name is empty".to_string()));
}
profile.name = name;
}
if resnapshot {
let scope = scope.ok_or_else(|| {
AppError::InvalidInput("Resnapshot requires a profile scope".to_string())
})?;
let mut payload: ProfilePayload = serde_json::from_str(&profile.payload)
.map_err(|e| AppError::Config(format!("解析 profile payload 失败: {e}")))?;
payload.merge_scope_from(&Self::snapshot_current(state, scope)?, scope);
profile.payload = serde_json::to_string(&payload)
.map_err(|e| AppError::Config(format!("序列化 profile payload 失败: {e}")))?;
}
profile.updated_at = Some(chrono::Utc::now().timestamp());
state.db.save_profile(&profile)?;
Ok(profile)
}
/// 删除项目;若删除的是某分组当前激活项目,一并清除该分组的激活标记
pub fn delete(state: &AppState, id: &str) -> Result<(), AppError> {
state.db.delete_profile(id)?;
for scope in ProfileScope::ALL {
if state.db.get_current_profile_id(scope.as_str())?.as_deref() == Some(id) {
state.db.set_current_profile_id(scope.as_str(), None)?;
}
}
Ok(())
}
/// 应用项目快照(best-effort,返回 warnings
///
/// 只作用于发起页所属分组内的应用,不碰其他分组的配置与 current 标记。
/// 该分组从未拍过快照时不改动任何配置,仅标记 current 并返回提示
/// (下次从该项目切走时,自动保存会补拍该侧快照)。
///
/// **切换前会自动保存旧项目**:若当前分组已绑定到另一个项目,先把当前
/// 状态写入那个旧项目(仅当前分组槽位),再加载目标项目。这样切走后
/// 旧项目仍保留离开时的配置,回来时状态一致。自动保存失败时作为 warning
/// 继续,不阻塞切换。
///
/// 应用指定项目的快照到当前分组内的所有应用。
///
/// 返回 `(warnings, should_stop_proxy)`:当当前分组内所有接管都被关闭、且
/// 其它应用也没有接管时,建议调用者停止代理服务,以便 Claude Desktop 的
/// "本地路由"总开关同步显示为关闭。
pub fn apply(
state: &AppState,
profile_id: &str,
scope: ProfileScope,
) -> Result<(Vec<String>, bool), AppError> {
let mut warnings = Vec::new();
// 自动保存旧项目当前状态(仅当前分组),失败不阻塞切换
if let Some(current_id) = state.db.get_current_profile_id(scope.as_str())? {
if current_id != profile_id {
if let Err(e) = Self::update(state, &current_id, None, true, Some(scope)) {
warnings.push(format!(
"autosave profile '{current_id}' before switch failed: {e}"
));
}
}
}
let profile = state
.db
.get_profile(profile_id)?
.ok_or_else(|| AppError::InvalidInput(format!("Profile not found: {profile_id}")))?;
let payload: ProfilePayload = serde_json::from_str(&profile.payload)
.map_err(|e| AppError::Config(format!("解析 profile payload 失败: {e}")))?;
if !payload.scope_captured(scope) {
warnings.push(format!(
"no {} configuration captured in this project yet; marked as current without changes (it will be saved automatically when you switch away)",
scope.as_str()
));
}
for app in scope.apps().iter() {
let app_str = app.as_str();
// 1. 切换项目前无条件关闭当前应用的代理接管。
// 接管态下 live 文件属于代理;用户希望切换工作目录时总是退出当前
// 代理环境,再按快照写入真实供应商配置。
if let Err(e) = state.proxy_service.disable_takeover_for_app_sync(app) {
warnings.push(format!(
"[{app_str}] auto-disable proxy takeover before profile switch failed: {e}"
));
}
// 2. 供应商
if let Some(Some(target_pid)) = payload.providers.get(app) {
let providers = state.db.get_all_providers(app_str)?;
if !providers.contains_key(target_pid) {
warnings.push(format!(
"[{app_str}] provider '{target_pid}' no longer exists, skipped"
));
} else {
let current = crate::settings::get_effective_current_provider(&state.db, app)?;
if current.as_deref() != Some(target_pid.as_str()) {
match ProviderService::switch(state, app.clone(), target_pid) {
Ok(result) => warnings.extend(result.warnings),
Err(e) => warnings.push(format!(
"[{app_str}] switch provider '{target_pid}' failed: {e}"
)),
}
}
}
}
// 3. MCP diff(最小 toggle:仅动目标态≠当前态的条目;None = 该侧未拍过,不动)
if let Some(Some(target_ids)) = payload.mcp.get(app) {
let servers = state.db.get_all_mcp_servers()?;
let current: Vec<(String, bool)> = servers
.values()
.map(|s| (s.id.clone(), s.apps.is_enabled_for(app)))
.collect();
let (toggles, dangling) = plan_toggles(&current, target_ids);
for id in dangling {
warnings.push(format!("[{app_str}] MCP '{id}' no longer exists, skipped"));
}
for (id, enabled) in toggles {
if let Err(e) = McpService::toggle_app(state, &id, app.clone(), enabled) {
warnings.push(format!(
"[{app_str}] toggle MCP '{id}' -> {enabled} failed: {e}"
));
}
}
}
// 4. Skills diffSkillService 返回 anyhow::Result,收进 warning
if let Some(Some(target_ids)) = payload.skills.get(app) {
let skills = state.db.get_all_installed_skills()?;
let current: Vec<(String, bool)> = skills
.values()
.map(|s| (s.id.clone(), s.apps.is_enabled_for(app)))
.collect();
let (toggles, dangling) = plan_toggles(&current, target_ids);
for id in dangling {
warnings.push(format!(
"[{app_str}] skill '{id}' no longer exists, skipped"
));
}
for (id, enabled) in toggles {
if let Err(e) = SkillService::toggle_app(&state.db, &id, app, enabled) {
warnings.push(format!(
"[{app_str}] toggle skill '{id}' -> {enabled} failed: {e}"
));
}
}
}
// 5. PromptNone = 不动;已激活则幂等跳过,避免无谓的文件写与备份)
if let Some(Some(target_prompt)) = payload.prompts.get(app) {
let prompts = state.db.get_prompts(app_str)?;
match prompts.get(target_prompt) {
None => warnings.push(format!(
"[{app_str}] prompt '{target_prompt}' no longer exists, skipped"
)),
Some(p) if p.enabled => {}
Some(_) => {
if let Err(e) =
PromptService::enable_prompt(state, app.clone(), target_prompt)
{
warnings.push(format!(
"[{app_str}] enable prompt '{target_prompt}' failed: {e}"
));
}
}
}
}
}
state
.db
.set_current_profile_id(scope.as_str(), Some(profile_id))?;
// 当前分组内所有接管已关闭;若其它应用也无接管,可停止代理服务。
let should_stop_proxy = !state.db.is_live_takeover_active_sync();
Ok((warnings, should_stop_proxy))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ids(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
#[test]
fn test_payload_serde_roundtrip() {
let payload = ProfilePayload {
providers: PerApp {
claude: Some("p1".into()),
claude_desktop: Some("d1".into()),
codex: None,
},
mcp: PerApp {
claude: Some(ids(&["m1", "m2"])),
claude_desktop: Some(vec![]),
codex: None,
},
skills: PerApp {
claude: Some(vec![]),
claude_desktop: Some(vec![]),
codex: Some(ids(&["s1"])),
},
prompts: PerApp {
claude: None,
claude_desktop: None,
codex: Some("pr1".into()),
},
};
let json = serde_json::to_string(&payload).unwrap();
// per-app key 必须与 AppType 的 serde 形式一致(claude-desktop 是连字符)
assert!(json.contains("\"claude\""));
assert!(json.contains("\"claude-desktop\""));
assert!(json.contains("\"codex\""));
let back: ProfilePayload = serde_json::from_str(&json).unwrap();
assert_eq!(back, payload);
}
#[test]
fn test_payload_tolerates_missing_fields() {
// 前向兼容:旧版/部分字段缺失时应落到 None("该侧未拍过")而不是报错,
// 应用时对缺失槽位不做任何改动
let back: ProfilePayload =
serde_json::from_str(r#"{"providers":{"claude":"p1"},"mcp":{"claude":["m1"]}}"#)
.unwrap();
assert_eq!(back.providers.claude, Some("p1".to_string()));
assert_eq!(back.providers.claude_desktop, None);
assert_eq!(back.providers.codex, None);
assert_eq!(back.mcp.claude, Some(ids(&["m1"])));
assert_eq!(back.mcp.claude_desktop, None);
assert_eq!(back.mcp.codex, None, "missing slot means untouched");
assert_eq!(back.prompts.codex, None);
let empty: ProfilePayload = serde_json::from_str("{}").unwrap();
assert_eq!(empty, ProfilePayload::default());
}
#[test]
fn test_merge_scope_from_only_touches_scope_slots() {
// 项目 A:两侧都已拍过快照
let mut payload = ProfilePayload {
providers: PerApp {
claude: Some("p1".into()),
claude_desktop: Some("d1".into()),
codex: Some("c1".into()),
},
mcp: PerApp {
claude: Some(ids(&["m1"])),
claude_desktop: Some(vec![]),
codex: Some(ids(&["m9"])),
},
..Default::default()
};
// 在 Claude 页"以当前状态更新":只覆盖 claude 组槽位
let fresh = ProfilePayload {
providers: PerApp {
claude: Some("p2".into()),
claude_desktop: None,
codex: Some("SHOULD-NOT-LEAK".into()),
},
mcp: PerApp {
claude: Some(ids(&["m2"])),
claude_desktop: Some(vec![]),
codex: None,
},
..Default::default()
};
payload.merge_scope_from(&fresh, ProfileScope::Claude);
assert_eq!(payload.providers.claude, Some("p2".to_string()));
assert_eq!(
payload.providers.claude_desktop,
Some("d1".to_string()),
"claude-desktop slot is in its own scope, untouched by claude merge"
);
assert_eq!(payload.mcp.claude, Some(ids(&["m2"])));
// codex 侧完好:既没被覆盖也没被 fresh 的值污染
assert_eq!(payload.providers.codex, Some("c1".to_string()));
assert_eq!(payload.mcp.codex, Some(ids(&["m9"])));
}
#[test]
fn test_scope_captured_detects_per_scope_snapshot() {
let mut payload = ProfilePayload::default();
assert!(!payload.scope_captured(ProfileScope::Claude));
assert!(!payload.scope_captured(ProfileScope::ClaudeDesktop));
assert!(!payload.scope_captured(ProfileScope::Codex));
// 只拍过 claude 组(哪怕拍到的是空集)
payload.mcp.claude = Some(vec![]);
assert!(payload.scope_captured(ProfileScope::Claude));
assert!(!payload.scope_captured(ProfileScope::ClaudeDesktop));
assert!(!payload.scope_captured(ProfileScope::Codex));
// Desktop 槽位属于独立的 claude-desktop 组
let mut desktop_only = ProfilePayload::default();
desktop_only.providers.claude_desktop = Some("d1".into());
assert!(desktop_only.scope_captured(ProfileScope::ClaudeDesktop));
assert!(!desktop_only.scope_captured(ProfileScope::Claude));
}
#[test]
fn test_per_app_get_only_supports_profile_apps() {
let per: PerApp<Option<String>> = PerApp::default();
assert!(per.get(&AppType::Claude).is_some());
assert!(per.get(&AppType::ClaudeDesktop).is_some());
assert!(per.get(&AppType::Codex).is_some());
assert!(per.get(&AppType::Gemini).is_none());
}
#[test]
fn test_scope_serde_and_parse_roundtrip() {
for scope in ProfileScope::ALL {
// DB 存储字符串(as_str/parse)与 JSON 序列化必须是同一形式
assert_eq!(
serde_json::to_string(&scope).unwrap(),
format!("\"{}\"", scope.as_str())
);
assert_eq!(ProfileScope::parse(scope.as_str()).unwrap(), scope);
}
assert!(ProfileScope::parse("gemini").is_err());
assert!(ProfileScope::parse("").is_err());
}
#[test]
fn test_scope_app_grouping() {
// Claude Code 与 Claude Desktop 各自独立成组;
// 组内应用与 for_app 反向映射必须一致
assert_eq!(ProfileScope::Claude.apps(), &[AppType::Claude]);
assert_eq!(
ProfileScope::ClaudeDesktop.apps(),
&[AppType::ClaudeDesktop]
);
assert_eq!(ProfileScope::Codex.apps(), &[AppType::Codex]);
for scope in ProfileScope::ALL {
for app in scope.apps() {
assert_eq!(ProfileScope::for_app(app), Some(scope));
}
}
assert_eq!(ProfileScope::for_app(&AppType::Gemini), None);
}
#[test]
fn test_plan_toggles_minimal_diff() {
let current = vec![
("a".to_string(), true), // 目标含 a:不动
("b".to_string(), false), // 目标含 b:开
("c".to_string(), true), // 目标不含 c:关
("d".to_string(), false), // 目标不含 d:不动
];
let (toggles, dangling) = plan_toggles(&current, &ids(&["a", "b", "ghost"]));
assert_eq!(
toggles,
vec![("b".to_string(), true), ("c".to_string(), false)]
);
assert_eq!(dangling, ids(&["ghost"]));
}
#[test]
fn test_plan_toggles_empty_target_disables_all_enabled() {
let current = vec![("a".to_string(), true), ("b".to_string(), false)];
let (toggles, dangling) = plan_toggles(&current, &[]);
assert_eq!(toggles, vec![("a".to_string(), false)]);
assert!(dangling.is_empty());
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+12
View File
@@ -57,6 +57,18 @@ pub(crate) async fn execute_and_format_usage_result(
})
}
Err(err) => {
// 瞬时传输失败(send 失败/超时、读体中断)以 Err 传播,让前端 invoke
// reject → react-query retry 并保留上次成功值;按错误 key 判定而非
// 文案匹配。其余脚本/配置/HTTP 业务错误折叠成 success:false 展示文案。
if let AppError::Localized { key, .. } = &err {
if matches!(
*key,
"usage_script.request_failed" | "usage_script.read_response_failed"
) {
return Err(err);
}
}
let lang = settings::get_settings()
.language
.unwrap_or_else(|| "zh".to_string());
File diff suppressed because it is too large Load Diff
+385 -17
View File
@@ -9,7 +9,7 @@
//! ```
//!
//! ## 解析的事件类型
//! - `session_meta` → 提取 session_id
//! - `session_meta` → 提取唯一 thread_id(子代理的 session_id 指向父线程)
//! - `turn_context` → 提取当前 model
//! - `event_msg` (type=token_count) → 提取累计 token 用量,计算 delta
@@ -28,6 +28,8 @@ use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
const CODEX_THREAD_REQUEST_ID_PREFIX: &str = "codex_session:thread-v1";
/// 累计 token 用量(跟踪 total_token_usage 字段)
#[derive(Debug, Clone, Default)]
struct CumulativeTokens {
@@ -52,10 +54,162 @@ impl DeltaTokens {
/// 单文件解析时的运行状态
struct FileParseState {
session_id: Option<String>,
thread_id: Option<String>,
current_model: String,
prev_total: Option<CumulativeTokens>,
event_index: u32,
history_replay_boundary: Option<i64>,
}
/// Codex 子代理日志中的 `id` 是当前线程的唯一 ID,`session_id` 则指向父线程。
#[derive(Debug, Clone, PartialEq, Eq)]
struct CodexSessionIdentity {
thread_id: String,
carries_history_snapshot: bool,
}
fn parse_codex_session_identity(payload: &serde_json::Value) -> Option<CodexSessionIdentity> {
let thread_id = payload
.get("id")
.or_else(|| payload.get("thread_id"))
.or_else(|| payload.get("threadId"))
.or_else(|| payload.get("session_id"))
.or_else(|| payload.get("sessionId"))
.and_then(|value| value.as_str())?
.to_string();
let session_id = payload
.get("session_id")
.or_else(|| payload.get("sessionId"))
.and_then(|value| value.as_str());
let carries_history_snapshot = payload
.get("forked_from_id")
.and_then(|value| value.as_str())
.is_some_and(|value| !value.is_empty())
|| payload
.get("source")
.and_then(|source| source.get("subagent"))
.is_some()
|| session_id.is_some_and(|session_id| session_id != thread_id);
Some(CodexSessionIdentity {
thread_id,
carries_history_snapshot,
})
}
fn read_codex_session_identity(file_path: &Path) -> Option<CodexSessionIdentity> {
let file = fs::File::open(file_path).ok()?;
for line in BufReader::new(file).lines() {
let Ok(line) = line else {
continue;
};
if !line.contains("\"session_meta\"") {
continue;
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
continue;
};
if value.get("type").and_then(|value| value.as_str()) != Some("session_meta") {
continue;
}
if let Some(identity) = value.get("payload").and_then(parse_codex_session_identity) {
return Some(identity);
}
}
None
}
/// fork/子代理日志会先重放父线程历史,再以接管事件开始当前线程。
/// 返回接管事件所在行;此前的 token_count 只用于恢复累计值基线。
fn codex_history_replay_boundary(
file_path: &Path,
identity: Option<&CodexSessionIdentity>,
) -> Option<i64> {
if !identity.is_some_and(|identity| identity.carries_history_snapshot) {
return None;
}
let file = fs::File::open(file_path).ok()?;
for (index, line) in BufReader::new(file).lines().enumerate() {
let Ok(line) = line else {
continue;
};
if !line.contains("\"thread_settings_applied\"")
&& !line.contains("\"inter_agent_communication")
{
continue;
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
continue;
};
let Some(event_type) = value.get("type").and_then(|value| value.as_str()) else {
continue;
};
let is_replay_boundary = event_type.starts_with("inter_agent_communication")
|| (event_type == "event_msg"
&& value
.get("payload")
.and_then(|payload| payload.get("type"))
.and_then(|value| value.as_str())
== Some("thread_settings_applied"));
if is_replay_boundary {
return Some(index as i64 + 1);
}
}
None
}
fn is_history_snapshot_event(state: &FileParseState, line_offset: i64) -> bool {
state
.history_replay_boundary
.is_some_and(|boundary| line_offset < boundary)
}
fn get_codex_sync_state(db: &Database, file_path: &Path) -> Result<(i64, i64), AppError> {
let file_path_str = file_path.to_string_lossy().to_string();
let state = get_sync_state(db, &file_path_str)?;
if state != (0, 0)
|| file_path
.parent()
.and_then(Path::file_name)
.and_then(|name| name.to_str())
!= Some("archived_sessions")
{
return Ok(state);
}
let Some(file_name) = file_path.file_name().and_then(|name| name.to_str()) else {
return Ok(state);
};
let slash_suffix = format!("/{file_name}");
let backslash_suffix = format!("\\{file_name}");
let conn = lock_conn!(db.conn);
let inherited = conn.query_row(
"SELECT last_modified, last_line_offset
FROM session_log_sync
WHERE file_path <> ?1
AND (substr(file_path, -length(?2)) = ?2
OR substr(file_path, -length(?3)) = ?3)
ORDER BY last_line_offset DESC, last_modified DESC
LIMIT 1",
rusqlite::params![file_path_str, slash_suffix, backslash_suffix],
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
);
drop(conn);
match inherited {
Ok(inherited) => {
update_sync_state(db, &file_path_str, inherited.0, inherited.1)?;
Ok(inherited)
}
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(state),
Err(error) => Err(AppError::Database(format!(
"查询 Codex 归档文件同步状态失败: {error}"
))),
}
}
/// 归一化 Codex 模型名
@@ -238,23 +392,27 @@ fn sync_single_codex_file(db: &Database, file_path: &Path) -> Result<(u32, u32),
let file_modified = metadata_modified_nanos(&metadata);
// 检查同步状态
let (last_modified, last_offset) = get_sync_state(db, &file_path_str)?;
let (last_modified, last_offset) = get_codex_sync_state(db, file_path)?;
// 文件未变化则跳过
if file_modified <= last_modified {
return Ok((0, 0));
}
let identity = read_codex_session_identity(file_path);
// 打开文件逐行解析
let file =
fs::File::open(file_path).map_err(|e| AppError::Config(format!("无法打开文件: {e}")))?;
let reader = BufReader::new(file);
let history_replay_boundary = codex_history_replay_boundary(file_path, identity.as_ref());
let mut state = FileParseState {
session_id: None,
thread_id: identity.map(|identity| identity.thread_id),
current_model: "unknown".to_string(),
prev_total: None,
event_index: 0,
history_replay_boundary,
};
let mut line_offset: i64 = 0;
@@ -296,16 +454,11 @@ fn sync_single_codex_file(db: &Database, file_path: &Path) -> Result<(u32, u32),
};
match event_type {
"session_meta" if state.session_id.is_none() => {
let payload = value.get("payload");
state.session_id = payload
.and_then(|p| {
p.get("session_id")
.or_else(|| p.get("sessionId"))
.or_else(|| p.get("id"))
})
.and_then(|v| v.as_str())
.map(|s| s.to_string());
"session_meta" if state.thread_id.is_none() => {
state.thread_id = value
.get("payload")
.and_then(parse_codex_session_identity)
.map(|identity| identity.thread_id);
}
"turn_context" => {
if let Some(payload) = value.get("payload") {
@@ -383,16 +536,28 @@ fn sync_single_codex_file(db: &Database, file_path: &Path) -> Result<(u32, u32),
continue; // 跳过 task 边界的零 delta 事件
}
// 所有非零事件都占据稳定序号,包括已同步事件与 replay 快照。
state.event_index += 1;
// replay 快照更新了 prev_total,但不是当前线程的新用量。
if is_history_snapshot_event(&state, line_offset) {
if line_offset > last_offset {
skipped += 1;
}
continue;
}
// 跳过已处理的行(但仍需解析以恢复状态)
if line_offset <= last_offset {
continue;
}
// 生成唯一 request_id
let session_id_str = state.session_id.as_deref().unwrap_or("unknown");
let request_id = format!("codex_session:{}:{}", session_id_str, state.event_index);
let thread_id = state.thread_id.as_deref().unwrap_or("unknown");
let request_id = format!(
"{CODEX_THREAD_REQUEST_ID_PREFIX}:{thread_id}:{}",
state.event_index
);
// 提取时间戳
let timestamp = value
@@ -405,7 +570,7 @@ fn sync_single_codex_file(db: &Database, file_path: &Path) -> Result<(u32, u32),
&request_id,
&delta,
&state.current_model,
state.session_id.as_deref(),
state.thread_id.as_deref(),
timestamp.as_deref(),
) {
Ok(true) => imported += 1,
@@ -549,6 +714,56 @@ fn find_codex_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option<Mod
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn write_jsonl(path: &Path, values: &[serde_json::Value]) {
let contents = values
.iter()
.map(serde_json::Value::to_string)
.collect::<Vec<_>>()
.join("\n")
+ "\n";
fs::write(path, contents).unwrap();
}
fn session_meta(thread_id: &str, session_id: &str) -> serde_json::Value {
serde_json::json!({
"timestamp": "2026-07-10T03:00:00Z",
"type": "session_meta",
"payload": {
"id": thread_id,
"session_id": session_id,
"source": if thread_id == session_id {
serde_json::Value::String("cli".to_string())
} else {
serde_json::json!({ "subagent": {} })
}
}
})
}
fn turn_context() -> serde_json::Value {
serde_json::json!({
"timestamp": "2026-07-10T03:00:01Z",
"type": "turn_context",
"payload": { "model": "gpt-5.6-sol" }
})
}
fn token_count(input: u64, cached: u64, output: u64) -> serde_json::Value {
serde_json::json!({
"timestamp": "2026-07-10T03:00:02Z",
"type": "event_msg",
"payload": {
"type": "token_count",
"info": { "total_token_usage": {
"input_tokens": input,
"cached_input_tokens": cached,
"output_tokens": output
}}
}
})
}
#[test]
fn test_delta_first_event() {
@@ -659,6 +874,159 @@ mod tests {
assert!(files.is_empty());
}
#[test]
fn test_subagent_identity_prefers_unique_thread_id() {
let identity =
parse_codex_session_identity(session_meta("child", "parent").get("payload").unwrap())
.unwrap();
assert_eq!(identity.thread_id, "child");
assert!(identity.carries_history_snapshot);
}
#[test]
fn test_subagent_replay_only_establishes_token_baseline() -> Result<(), AppError> {
let db = Database::memory()?;
let temp = tempdir().unwrap();
let child = temp.path().join("child.jsonl");
write_jsonl(
&child,
&[
session_meta("child", "parent"),
turn_context(),
token_count(1_000, 900, 100),
token_count(1_200, 1_000, 120),
serde_json::json!({
"timestamp": "2026-07-10T03:00:03Z",
"type": "event_msg",
"payload": { "type": "thread_settings_applied" }
}),
token_count(1_300, 1_050, 150),
],
);
assert_eq!(sync_single_codex_file(&db, &child)?, (1, 2));
let conn = lock_conn!(db.conn);
let usage: (i64, i64, i64) = conn.query_row(
"SELECT input_tokens, cache_read_tokens, output_tokens
FROM proxy_request_logs
WHERE request_id = 'codex_session:thread-v1:child:3'",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)?;
assert_eq!(usage, (100, 50, 30));
Ok(())
}
#[test]
fn test_subagents_under_same_parent_use_distinct_request_ids() -> Result<(), AppError> {
let db = Database::memory()?;
let temp = tempdir().unwrap();
let child_a = temp.path().join("child-a.jsonl");
let child_b = temp.path().join("child-b.jsonl");
write_jsonl(
&child_a,
&[
session_meta("child-a", "parent"),
turn_context(),
token_count(100, 50, 10),
],
);
write_jsonl(
&child_b,
&[
session_meta("child-b", "parent"),
turn_context(),
token_count(200, 100, 20),
],
);
assert_eq!(sync_single_codex_file(&db, &child_a)?, (1, 0));
assert_eq!(sync_single_codex_file(&db, &child_b)?, (1, 0));
let conn = lock_conn!(db.conn);
let request_ids = conn
.prepare(
"SELECT request_id FROM proxy_request_logs
WHERE data_source = 'codex_session' ORDER BY request_id",
)?
.query_map([], |row| row.get::<_, String>(0))?
.collect::<Result<Vec<_>, _>>()?;
assert_eq!(
request_ids,
vec![
"codex_session:thread-v1:child-a:1",
"codex_session:thread-v1:child-b:1"
]
);
Ok(())
}
#[test]
fn test_archived_log_inherits_cursor_and_only_imports_appended_usage() -> Result<(), AppError> {
let db = Database::memory()?;
let temp = tempdir().unwrap();
let sessions = temp.path().join("sessions");
let archived = temp.path().join("archived_sessions");
fs::create_dir_all(&sessions).unwrap();
fs::create_dir_all(&archived).unwrap();
let source = sessions.join("rollout-parent.jsonl");
let archived_file = archived.join("rollout-parent.jsonl");
write_jsonl(
&archived_file,
&[
session_meta("parent", "parent"),
turn_context(),
token_count(100, 50, 10),
token_count(200, 100, 20),
],
);
{
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,
total_cost_usd, latency_ms, status_code, session_id,
created_at, data_source
) VALUES ('codex_session:parent:2', '_codex_session', 'codex',
'gpt-5.6-sol', 'gpt-5.6-sol', 999, 99, 0, '0', 0,
200, 'parent', 1, 'codex_session')",
[],
)?;
}
let source_path = source.to_string_lossy().to_string();
update_sync_state(&db, &source_path, 1, 3)?;
assert_eq!(sync_single_codex_file(&db, &archived_file)?, (1, 0));
assert_eq!(sync_single_codex_file(&db, &archived_file)?, (0, 0));
let conn = lock_conn!(db.conn);
let old_row_count: i64 = conn.query_row(
"SELECT COUNT(*) FROM proxy_request_logs
WHERE request_id = 'codex_session:parent:2'",
[],
|row| row.get(0),
)?;
assert_eq!(old_row_count, 1);
let usage: (i64, i64, i64) = conn.query_row(
"SELECT input_tokens, cache_read_tokens, output_tokens
FROM proxy_request_logs
WHERE request_id = 'codex_session:thread-v1:parent:2'",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)?;
assert_eq!(usage, (100, 50, 10));
drop(conn);
assert_eq!(get_sync_state(&db, &archived_file.to_string_lossy())?.1, 4);
Ok(())
}
#[test]
fn test_insert_codex_session_skips_matching_proxy_log() -> Result<(), AppError> {
let db = Database::memory()?;
+47 -24
View File
@@ -374,20 +374,15 @@ fn parse_branch_from_source_url(source_url: Option<&str>) -> Option<String> {
/// 获取 `~/.agents/skills/` 目录(存在时返回)
fn get_agents_skills_dir() -> Option<PathBuf> {
dirs::home_dir()
.map(|h| h.join(".agents").join("skills"))
.filter(|p| p.exists())
let dir = crate::config::get_home_dir().join(".agents").join("skills");
dir.exists().then_some(dir)
}
/// 解析 `~/.agents/.skill-lock.json`,返回 skill_name -> 仓库信息
fn parse_agents_lock() -> HashMap<String, LockRepoInfo> {
let path = match dirs::home_dir() {
Some(h) => h.join(".agents").join(".skill-lock.json"),
None => {
log::warn!("无法获取 HOME 目录,跳过解析 agents lock 文件");
return HashMap::new();
}
};
let path = crate::config::get_home_dir()
.join(".agents")
.join(".skill-lock.json");
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
@@ -482,12 +477,7 @@ impl SkillService {
let dir = match location {
SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"),
SkillStorageLocation::Unified => {
let home = dirs::home_dir().context(format_skill_error(
"GET_HOME_DIR_FAILED",
&[],
Some("checkPermission"),
))?;
home.join(".agents").join("skills")
crate::config::get_home_dir().join(".agents").join("skills")
}
};
fs::create_dir_all(&dir)?;
@@ -521,6 +511,11 @@ impl SkillService {
return Ok(custom.join("skills"));
}
}
AppType::GrokBuild => {
if let Some(custom) = crate::settings::get_grok_override_dir() {
return Ok(custom.join("skills"));
}
}
AppType::OpenCode => {
if let Some(custom) = crate::settings::get_opencode_override_dir() {
return Ok(custom.join("skills"));
@@ -538,18 +533,17 @@ impl SkillService {
}
}
// 默认路径:回退到用户主目录下的标准位置
let home = dirs::home_dir().context(format_skill_error(
"GET_HOME_DIR_FAILED",
&[],
Some("checkPermission"),
))?;
// 默认路径:回退到用户主目录下的标准位置
// 必须走 get_home_dir()(可被 CC_SWITCH_TEST_HOME 覆盖):Windows 上 dirs::home_dir()
// 走 Known Folder API,测试无法隔离真实用户目录。
let home = crate::config::get_home_dir();
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::GrokBuild => home.join(".grok").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"),
@@ -1167,8 +1161,7 @@ impl SkillService {
let new_dir = match target {
SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"),
SkillStorageLocation::Unified => {
let home = dirs::home_dir().context("Cannot determine home directory")?;
home.join(".agents").join("skills")
crate::config::get_home_dir().join(".agents").join("skills")
}
};
fs::create_dir_all(&new_dir)?;
@@ -3069,6 +3062,36 @@ mod tests {
.expect("write SKILL.md");
}
#[test]
// serial:与 backup/s3_sync/deeplink 等同样读写进程级 CC_SWITCH_TEST_HOME 的测试互斥,
// EnvGuard 只负责恢复不提供互斥。
#[serial_test::serial]
fn get_app_skills_dir_honors_test_home_override() {
// 回归:曾直呼 dirs::home_dir() 绕过 CC_SWITCH_TEST_HOME——Unix 上碰巧跟 $HOME
// 一致所以测试能过,Windows 上 dirs 走 Known Folder API,测试隔离整体失效
// tests/skill_sync.rs 扫到 runner 真实用户目录)。
struct EnvGuard(Option<std::ffi::OsString>);
impl Drop for EnvGuard {
fn drop(&mut self) {
match self.0.take() {
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
}
}
}
let temp = tempdir().expect("tempdir");
let _guard = EnvGuard(std::env::var_os("CC_SWITCH_TEST_HOME"));
std::env::set_var("CC_SWITCH_TEST_HOME", temp.path());
let dir =
SkillService::get_app_skills_dir(&AppType::Claude).expect("resolve claude skills dir");
assert!(
dir.starts_with(temp.path()),
"skills dir must live under the overridden test home, got {}",
dir.display()
);
}
#[test]
fn resolve_skill_source_dir_returns_repo_root_for_root_level_skill() {
let temp = tempdir().expect("tempdir");
+63 -9
View File
@@ -16,15 +16,18 @@
/// style provider not added here) shows up loudly as a too-low cache hit
/// rate, which is easier to catch than the silent over-deduction that
/// would happen with the opposite default.
const CACHE_INCLUSIVE_APP_TYPES: &[&str] = &["codex", "gemini"];
const CACHE_INCLUSIVE_APP_TYPES: &[&str] = &["codex", "gemini", "grokbuild"];
pub(crate) const INPUT_TOKEN_SEMANTICS_LEGACY: i64 = 0;
pub(crate) const INPUT_TOKEN_SEMANTICS_TOTAL: i64 = 1;
pub(crate) const INPUT_TOKEN_SEMANTICS_FRESH: i64 = 2;
/// Build an SQL expression that returns the cache-normalized `input_tokens`
/// for a single row in `proxy_request_logs` or `usage_daily_rollups`.
///
/// For rows whose `app_type` is in [`CACHE_INCLUSIVE_APP_TYPES`] and
/// `input_tokens >= cache_read_tokens`, returns
/// `input_tokens - cache_read_tokens`. For all other rows the original
/// `input_tokens` is returned unchanged.
/// Legacy rows subtract cache reads only. New total-inclusive rows subtract
/// both cache reads and writes. Rollups normalized to fresh input are returned
/// unchanged.
///
/// Pass an empty string to reference the columns directly (no alias),
/// or a table alias such as `"l"` to emit `l.input_tokens` style references.
@@ -40,7 +43,15 @@ pub fn fresh_input_sql(alias: &str) -> String {
.collect::<Vec<_>>()
.join(", ");
format!(
"CASE WHEN {prefix}app_type IN ({app_type_list}) AND {prefix}input_tokens >= {prefix}cache_read_tokens \
"CASE \
WHEN {prefix}input_token_semantics = {INPUT_TOKEN_SEMANTICS_FRESH} THEN {prefix}input_tokens \
WHEN {prefix}app_type IN ({app_type_list}) \
AND {prefix}input_token_semantics = {INPUT_TOKEN_SEMANTICS_TOTAL} \
AND {prefix}input_tokens >= ({prefix}cache_read_tokens + {prefix}cache_creation_tokens) \
THEN ({prefix}input_tokens - {prefix}cache_read_tokens - {prefix}cache_creation_tokens) \
WHEN {prefix}app_type IN ({app_type_list}) \
AND {prefix}input_token_semantics = {INPUT_TOKEN_SEMANTICS_LEGACY} \
AND {prefix}input_tokens >= {prefix}cache_read_tokens \
THEN ({prefix}input_tokens - {prefix}cache_read_tokens) \
ELSE {prefix}input_tokens END"
)
@@ -60,7 +71,8 @@ mod tests {
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
cache_creation_tokens INTEGER NOT NULL DEFAULT 0
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
input_token_semantics INTEGER NOT NULL DEFAULT 0
);",
)
.unwrap();
@@ -81,6 +93,7 @@ mod tests {
assert!(!sql.contains("."));
assert!(sql.contains("'codex'"));
assert!(sql.contains("'gemini'"));
assert!(sql.contains("'grokbuild'"));
}
#[test]
@@ -100,6 +113,13 @@ mod tests {
[],
)
.unwrap();
// Grok Build uses OpenAI Responses semantics too.
conn.execute(
"INSERT INTO proxy_request_logs (request_id, app_type, input_tokens, cache_read_tokens)
VALUES ('grok-1', 'grokbuild', 700, 250)",
[],
)
.unwrap();
// Claude row: Anthropic semantics — input_tokens already excludes cache.
conn.execute(
"INSERT INTO proxy_request_logs (request_id, app_type, input_tokens, cache_read_tokens)
@@ -111,8 +131,8 @@ mod tests {
let expr = fresh_input_sql("l");
let sql = format!("SELECT COALESCE(SUM({expr}), 0) FROM proxy_request_logs l");
let total: i64 = conn.query_row(&sql, [], |r| r.get(0)).unwrap();
// Codex: 1000-600=400; Gemini: 800-300=500; Claude: 200 unchanged.
assert_eq!(total, 400 + 500 + 200);
// Codex: 400; Gemini: 500; Grok Build: 450; Claude: 200 unchanged.
assert_eq!(total, 400 + 500 + 450 + 200);
}
#[test]
@@ -131,4 +151,38 @@ mod tests {
let value: i64 = conn.query_row(&sql, [], |r| r.get(0)).unwrap();
assert_eq!(value, 100);
}
#[test]
fn fresh_input_subtracts_cache_write_for_total_semantics() {
let conn = setup_conn();
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, app_type, input_tokens, cache_read_tokens,
cache_creation_tokens, input_token_semantics
) VALUES ('codex-total', 'codex', 1000, 300, 200, ?1)",
[INPUT_TOKEN_SEMANTICS_TOTAL],
)
.unwrap();
let expr = fresh_input_sql("l");
let sql = format!("SELECT {expr} FROM proxy_request_logs l");
let value: i64 = conn.query_row(&sql, [], |row| row.get(0)).unwrap();
assert_eq!(value, 500);
}
#[test]
fn fresh_input_keeps_normalized_rollup_value() {
let conn = setup_conn();
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, app_type, input_tokens, cache_read_tokens,
cache_creation_tokens, input_token_semantics
) VALUES ('codex-fresh', 'codex', 500, 300, 200, ?1)",
[INPUT_TOKEN_SEMANTICS_FRESH],
)
.unwrap();
let expr = fresh_input_sql("l");
let sql = format!("SELECT {expr} FROM proxy_request_logs l");
let value: i64 = conn.query_row(&sql, [], |row| row.get(0)).unwrap();
assert_eq!(value, 500);
}
}
+17 -75
View File
@@ -92,19 +92,12 @@ impl StreamCheckService {
config: &StreamCheckConfig,
base_url_override: Option<String>,
) -> Result<StreamCheckResult, AppError> {
let effective = Self::merge_provider_config(provider, config);
let mut last_result: Option<StreamCheckResult> = None;
for attempt in 0..=effective.max_retries {
for attempt in 0..=config.max_retries {
let start = Instant::now();
let result = Self::check_once(
app_type,
provider,
&effective,
base_url_override.clone(),
start,
)
.await?;
let result =
Self::check_once(app_type, provider, config, base_url_override.clone(), start)
.await?;
if result.success {
return Ok(StreamCheckResult {
@@ -114,7 +107,7 @@ impl StreamCheckService {
}
// 仅超时 / abort 类网络抖动值得重试;连接被拒、DNS 失败等立即返回。
if Self::should_retry(&result.message) && attempt < effective.max_retries {
if Self::should_retry(&result.message) && attempt < config.max_retries {
last_result = Some(result);
continue;
}
@@ -132,31 +125,11 @@ impl StreamCheckService {
http_status: None,
model_used: String::new(),
tested_at: chrono::Utc::now().timestamp(),
retry_count: effective.max_retries,
retry_count: config.max_retries,
error_category: None,
}))
}
/// 合并供应商单独配置(`meta.testConfig`,仅当 `enabled`)与全局配置。
fn merge_provider_config(provider: &Provider, global: &StreamCheckConfig) -> StreamCheckConfig {
let tc = provider
.meta
.as_ref()
.and_then(|m| m.test_config.as_ref())
.filter(|tc| tc.enabled);
match tc {
Some(tc) => StreamCheckConfig {
timeout_secs: tc.timeout_secs.unwrap_or(global.timeout_secs),
max_retries: tc.max_retries.unwrap_or(global.max_retries),
degraded_threshold_ms: tc
.degraded_threshold_ms
.unwrap_or(global.degraded_threshold_ms),
},
None => global.clone(),
}
}
/// 单次连通性探测。
async fn check_once(
app_type: &AppType,
@@ -193,6 +166,12 @@ impl StreamCheckService {
/// 没有 cc-switch 能可靠探测的目标——这类供应商的连通检测按钮在前端已隐藏
/// (见 `ProviderCard.tsx`),故此处对其提取失败直接报错即可,不做官方端点回退。
fn resolve_base_url(app_type: &AppType, provider: &Provider) -> Result<String, AppError> {
if provider.category.as_deref() == Some("official") {
return Err(AppError::Message(
"Official providers do not expose a reachability-check target".to_string(),
));
}
match app_type {
// 累加模式应用的 settings_config 结构与 Claude/Codex/Gemini 不同,
// 不走 adapter,直接按各自约定提取 base_url。
@@ -474,48 +453,6 @@ mod tests {
assert_eq!(r.status, HealthStatus::Degraded);
}
#[test]
fn test_merge_provider_config_override_and_default() {
use crate::provider::{ProviderMeta, ProviderTestConfig};
let global = StreamCheckConfig::default();
// 无 testConfig → 用全局
let p = make_provider(serde_json::json!({}));
let merged = StreamCheckService::merge_provider_config(&p, &global);
assert_eq!(merged.timeout_secs, global.timeout_secs);
// testConfig 启用并覆盖部分字段
let mut p2 = make_provider(serde_json::json!({}));
p2.meta = Some(ProviderMeta {
test_config: Some(ProviderTestConfig {
enabled: true,
timeout_secs: Some(20),
degraded_threshold_ms: Some(3000),
max_retries: None,
}),
..Default::default()
});
let merged2 = StreamCheckService::merge_provider_config(&p2, &global);
assert_eq!(merged2.timeout_secs, 20);
assert_eq!(merged2.degraded_threshold_ms, 3000);
assert_eq!(merged2.max_retries, global.max_retries); // 未覆盖 → 全局
// testConfig 存在但未启用 → 忽略,用全局
let mut p3 = make_provider(serde_json::json!({}));
p3.meta = Some(ProviderMeta {
test_config: Some(ProviderTestConfig {
enabled: false,
timeout_secs: Some(99),
degraded_threshold_ms: None,
max_retries: None,
}),
..Default::default()
});
let merged3 = StreamCheckService::merge_provider_config(&p3, &global);
assert_eq!(merged3.timeout_secs, global.timeout_secs);
}
#[test]
fn test_resolve_opencode_base_url_explicit_wins() {
let p = make_provider(serde_json::json!({
@@ -579,5 +516,10 @@ mod tests {
// 不会走到这里;不做官方端点回退(避免给忘填地址的第三方误显绿灯)。
let empty = make_provider(serde_json::json!({ "env": {} }));
assert!(StreamCheckService::resolve_base_url(&AppType::Claude, &empty).is_err());
let mut official = make_provider(serde_json::json!({ "auth": {}, "config": "" }));
official.id = crate::database::CODEX_OFFICIAL_PROVIDER_ID.to_string();
official.category = Some("official".to_string());
assert!(StreamCheckService::resolve_base_url(&AppType::Codex, &official).is_err());
}
}
+105 -75
View File
@@ -312,6 +312,12 @@ pub const TIER_WEEKLY_LIMIT: &str = "weekly_limit";
/// 映射到 `subscription.monthly`。
pub const TIER_MONTHLY: &str = "monthly";
/// Codex 免费方案的 30 天(月)滚动窗口 tier 名。付费方案的次要窗口是 7 天
/// (`seven_day`),免费方案则是 30 天。由 `window_seconds_to_tier_name` 产出、
/// tray 的月分组渲染、前端 `TIER_I18N_KEYS` 映射到 `subscription.thirtyDay`
/// 三处共用同一标识。见 #3651。
pub const TIER_THIRTY_DAY: &str = "30_day";
/// Gemini 用量分组名称(按模型而非时间窗口)。`classify_gemini_model` 输出。
pub const TIER_GEMINI_PRO: &str = "gemini_pro";
pub const TIER_GEMINI_FLASH: &str = "gemini_flash";
@@ -325,7 +331,11 @@ const KNOWN_TIERS: &[&str] = &[
];
/// 查询 Claude 官方订阅额度
async fn query_claude_quota(access_token: &str) -> SubscriptionQuota {
///
/// 瞬时传输失败(网络/超时/读体中断)返回 `Err`(前端 reject → retry + 保留上次
/// 成功值);确定性失败(鉴权/非 2xx/响应体非法 JSON)返回 `Ok(success:false)`。
/// codex/gemini 两个查询函数遵守同一约定。
async fn query_claude_quota(access_token: &str) -> Result<SubscriptionQuota, String> {
let client = crate::proxy::http_client::get();
let resp = client
@@ -339,42 +349,42 @@ async fn query_claude_quota(access_token: &str) -> SubscriptionQuota {
let resp = match resp {
Ok(r) => r,
Err(e) => {
return SubscriptionQuota::error(
"claude",
CredentialStatus::Valid,
format!("Network error: {e}"),
);
}
Err(e) => return Err(format!("Network error: {e}")),
};
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return SubscriptionQuota::error(
return Ok(SubscriptionQuota::error(
"claude",
CredentialStatus::Expired,
format!("Authentication failed (HTTP {status}). Please re-login with Claude CLI."),
);
));
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return SubscriptionQuota::error(
return Ok(SubscriptionQuota::error(
"claude",
CredentialStatus::Valid,
format!("API error (HTTP {status}): {body}"),
);
));
}
let body: serde_json::Value = match resp.json().await {
// 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体
// 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。
let raw = match resp.bytes().await {
Ok(b) => b,
Err(e) => return Err(format!("Failed to read API response: {e}")),
};
let body: serde_json::Value = match serde_json::from_slice(&raw) {
Ok(v) => v,
Err(e) => {
return SubscriptionQuota::error(
return Ok(SubscriptionQuota::error(
"claude",
CredentialStatus::Valid,
format!("Failed to parse API response: {e}"),
);
));
}
};
@@ -429,7 +439,7 @@ async fn query_claude_quota(access_token: &str) -> SubscriptionQuota {
})
});
SubscriptionQuota {
Ok(SubscriptionQuota {
tool: "claude".to_string(),
credential_status: CredentialStatus::Valid,
credential_message: None,
@@ -438,7 +448,7 @@ async fn query_claude_quota(access_token: &str) -> SubscriptionQuota {
extra_usage,
error: None,
queried_at: Some(now_millis()),
}
})
}
// ── Codex 凭据读取 ──────────────────────────────────────
@@ -632,8 +642,12 @@ struct CodexUsageResponse {
/// 根据窗口秒数映射到 tier 名称(与 Claude 的命名兼容以复用前端 i18n)
fn window_seconds_to_tier_name(secs: i64) -> String {
match secs {
18000 => "five_hour".to_string(),
604800 => "seven_day".to_string(),
18000 => TIER_FIVE_HOUR.to_string(),
604800 => TIER_SEVEN_DAY.to_string(),
// Codex 免费方案的 30 天窗口。显式映射到常量,与 tray 月分组、前端
// TIER_I18N_KEYS 保持同一标识(否则动态回退虽也得到 "30_day",但字符串
// 分散在多处、易和托盘/前端白名单脱节)。见 #3651。
2_592_000 => TIER_THIRTY_DAY.to_string(),
s => {
let hours = s / 3600;
if hours >= 24 {
@@ -660,7 +674,7 @@ pub(crate) async fn query_codex_quota(
account_id: Option<&str>,
tool_label: &str,
expired_message: &str,
) -> SubscriptionQuota {
) -> Result<SubscriptionQuota, String> {
let client = crate::proxy::http_client::get();
let mut req = client
@@ -675,42 +689,40 @@ pub(crate) async fn query_codex_quota(
let resp = match req.timeout(std::time::Duration::from_secs(15)).send().await {
Ok(r) => r,
Err(e) => {
return SubscriptionQuota::error(
tool_label,
CredentialStatus::Valid,
format!("Network error: {e}"),
);
}
Err(e) => return Err(format!("Network error: {e}")),
};
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return SubscriptionQuota::error(
return Ok(SubscriptionQuota::error(
tool_label,
CredentialStatus::Expired,
format!("{expired_message} (HTTP {status})"),
);
));
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return SubscriptionQuota::error(
return Ok(SubscriptionQuota::error(
tool_label,
CredentialStatus::Valid,
format!("API error (HTTP {status}): {body}"),
);
));
}
let body: CodexUsageResponse = match resp.json().await {
let raw = match resp.bytes().await {
Ok(b) => b,
Err(e) => return Err(format!("Failed to read API response: {e}")),
};
let body: CodexUsageResponse = match serde_json::from_slice(&raw) {
Ok(v) => v,
Err(e) => {
return SubscriptionQuota::error(
return Ok(SubscriptionQuota::error(
tool_label,
CredentialStatus::Valid,
format!("Failed to parse API response: {e}"),
);
));
}
};
@@ -736,7 +748,7 @@ pub(crate) async fn query_codex_quota(
}
}
SubscriptionQuota {
Ok(SubscriptionQuota {
tool: tool_label.to_string(),
credential_status: CredentialStatus::Valid,
credential_message: None,
@@ -745,7 +757,7 @@ pub(crate) async fn query_codex_quota(
extra_usage: None,
error: None,
queried_at: Some(now_millis()),
}
})
}
// ── Gemini 凭据读取 ──────────────────────────────────────
@@ -1039,7 +1051,7 @@ fn classify_gemini_model(model_id: &str) -> &str {
/// 两步 API 调用:
/// 1. loadCodeAssist → 获取 cloudaicompanionProject
/// 2. retrieveUserQuota → 获取按模型分桶的配额数据
async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota {
async fn query_gemini_quota(access_token: &str) -> Result<SubscriptionQuota, String> {
let client = crate::proxy::http_client::get();
// ── Step 1: loadCodeAssist 获取项目 ID ──
@@ -1059,42 +1071,40 @@ async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota {
let load_resp = match load_resp {
Ok(r) => r,
Err(e) => {
return SubscriptionQuota::error(
"gemini",
CredentialStatus::Valid,
format!("Network error (loadCodeAssist): {e}"),
);
}
Err(e) => return Err(format!("Network error (loadCodeAssist): {e}")),
};
let load_status = load_resp.status();
if load_status == reqwest::StatusCode::UNAUTHORIZED
|| load_status == reqwest::StatusCode::FORBIDDEN
{
return SubscriptionQuota::error(
return Ok(SubscriptionQuota::error(
"gemini",
CredentialStatus::Expired,
format!("Authentication failed (HTTP {load_status}). Please re-login with Gemini CLI."),
);
));
}
if !load_status.is_success() {
let body = load_resp.text().await.unwrap_or_default();
return SubscriptionQuota::error(
return Ok(SubscriptionQuota::error(
"gemini",
CredentialStatus::Valid,
format!("loadCodeAssist failed (HTTP {load_status}): {body}"),
);
));
}
let load_body: GeminiLoadCodeAssistResponse = match load_resp.json().await {
let load_raw = match load_resp.bytes().await {
Ok(b) => b,
Err(e) => return Err(format!("Failed to read loadCodeAssist response: {e}")),
};
let load_body: GeminiLoadCodeAssistResponse = match serde_json::from_slice(&load_raw) {
Ok(v) => v,
Err(e) => {
return SubscriptionQuota::error(
return Ok(SubscriptionQuota::error(
"gemini",
CredentialStatus::Valid,
format!("Failed to parse loadCodeAssist response: {e}"),
);
));
}
};
@@ -1120,42 +1130,40 @@ async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota {
let quota_resp = match quota_resp {
Ok(r) => r,
Err(e) => {
return SubscriptionQuota::error(
"gemini",
CredentialStatus::Valid,
format!("Network error (retrieveUserQuota): {e}"),
);
}
Err(e) => return Err(format!("Network error (retrieveUserQuota): {e}")),
};
let quota_status = quota_resp.status();
if quota_status == reqwest::StatusCode::UNAUTHORIZED
|| quota_status == reqwest::StatusCode::FORBIDDEN
{
return SubscriptionQuota::error(
return Ok(SubscriptionQuota::error(
"gemini",
CredentialStatus::Expired,
format!("Authentication failed (HTTP {quota_status})."),
);
));
}
if !quota_status.is_success() {
let body = quota_resp.text().await.unwrap_or_default();
return SubscriptionQuota::error(
return Ok(SubscriptionQuota::error(
"gemini",
CredentialStatus::Valid,
format!("retrieveUserQuota failed (HTTP {quota_status}): {body}"),
);
));
}
let quota_data: GeminiQuotaResponse = match quota_resp.json().await {
let quota_raw = match quota_resp.bytes().await {
Ok(b) => b,
Err(e) => return Err(format!("Failed to read quota response: {e}")),
};
let quota_data: GeminiQuotaResponse = match serde_json::from_slice(&quota_raw) {
Ok(v) => v,
Err(e) => {
return SubscriptionQuota::error(
return Ok(SubscriptionQuota::error(
"gemini",
CredentialStatus::Valid,
format!("Failed to parse quota response: {e}"),
);
));
}
};
@@ -1203,7 +1211,7 @@ async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota {
tiers.sort_by_key(|t| sort_order(&t.name));
SubscriptionQuota {
Ok(SubscriptionQuota {
tool: "gemini".to_string(),
credential_status: CredentialStatus::Valid,
credential_message: None,
@@ -1212,12 +1220,16 @@ async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota {
extra_usage: None,
error: None,
queried_at: Some(now_millis()),
}
})
}
// ── 入口函数 ──────────────────────────────────────────────
/// 查询指定 CLI 工具的官方订阅额度
///
/// 瞬时传输失败以 `Err` 传播(前端 reject → retry + 保留上次成功值)。Expired
/// 分支的"过期也试一把"重试同样用 `?` 传播瞬时错误——不能折叠成"已过期",
/// 否则一次网络抖动会被误报成确定性的凭据过期。
pub async fn get_subscription_quota(tool: &str) -> Result<SubscriptionQuota, String> {
match tool {
"claude" => {
@@ -1233,7 +1245,7 @@ pub async fn get_subscription_quota(tool: &str) -> Result<SubscriptionQuota, Str
CredentialStatus::Expired => {
// 即使过期也尝试调用 API(token 可能实际上仍有效)
if let Some(token) = token {
let result = query_claude_quota(&token).await;
let result = query_claude_quota(&token).await?;
if result.success {
return Ok(result);
}
@@ -1246,7 +1258,7 @@ pub async fn get_subscription_quota(tool: &str) -> Result<SubscriptionQuota, Str
}
CredentialStatus::Valid => {
let token = token.expect("token must be Some when status is Valid");
Ok(query_claude_quota(&token).await)
query_claude_quota(&token).await
}
}
}
@@ -1269,7 +1281,7 @@ pub async fn get_subscription_quota(tool: &str) -> Result<SubscriptionQuota, Str
"codex",
"Authentication failed. Please re-login with Codex CLI.",
)
.await;
.await?;
if result.success {
return Ok(result);
}
@@ -1282,13 +1294,13 @@ pub async fn get_subscription_quota(tool: &str) -> Result<SubscriptionQuota, Str
}
CredentialStatus::Valid => {
let token = token.expect("token must be Some when status is Valid");
Ok(query_codex_quota(
query_codex_quota(
&token,
account_id.as_deref(),
"codex",
"Authentication failed. Please re-login with Codex CLI.",
)
.await)
.await
}
}
}
@@ -1306,12 +1318,12 @@ pub async fn get_subscription_quota(tool: &str) -> Result<SubscriptionQuota, Str
// Gemini access_token 仅 ~1h 有效,尝试用 refresh_token 刷新
if let Some(ref rt) = refresh_token {
if let Some(new_token) = refresh_gemini_token(rt).await {
return Ok(query_gemini_quota(&new_token).await);
return query_gemini_quota(&new_token).await;
}
}
// 刷新失败,尝试用旧 token
if let Some(ref token) = token {
let result = query_gemini_quota(token).await;
let result = query_gemini_quota(token).await?;
if result.success {
return Ok(result);
}
@@ -1324,7 +1336,7 @@ pub async fn get_subscription_quota(tool: &str) -> Result<SubscriptionQuota, Str
}
CredentialStatus::Valid => {
let token = token.expect("token must be Some when status is Valid");
Ok(query_gemini_quota(&token).await)
query_gemini_quota(&token).await
}
}
}
@@ -1340,3 +1352,21 @@ fn now_millis() -> i64 {
.unwrap_or_default()
.as_millis() as i64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn window_seconds_map_to_expected_tier_names() {
// 官方特例窗口
assert_eq!(window_seconds_to_tier_name(18000), TIER_FIVE_HOUR);
assert_eq!(window_seconds_to_tier_name(604800), TIER_SEVEN_DAY);
// Codex 免费方案的次要窗口是 30 天(30 * 24 * 3600 = 2_592_000 秒)。
// 前端 TIER_I18N_KEYS 与 tray 月分组都需要认得 "30_day",见 #3651。
assert_eq!(window_seconds_to_tier_name(2_592_000), TIER_THIRTY_DAY);
// 其他窗口按小时/天回退命名
assert_eq!(window_seconds_to_tier_name(3600), "1_hour");
assert_eq!(window_seconds_to_tier_name(86400), "1_day");
}
}
+99 -14
View File
@@ -5,7 +5,9 @@
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::proxy::usage::calculator::ModelPricing;
use crate::services::sql_helpers::fresh_input_sql;
use crate::services::sql_helpers::{
fresh_input_sql, INPUT_TOKEN_SEMANTICS_FRESH, INPUT_TOKEN_SEMANTICS_TOTAL,
};
use chrono::{Local, NaiveDate, TimeZone, Timelike};
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
@@ -135,6 +137,9 @@ pub struct RequestLogDetail {
pub output_tokens: u32,
pub cache_read_tokens: u32,
pub cache_creation_tokens: u32,
/// Internal storage semantics; omitted from the UI/API payload.
#[serde(skip)]
pub input_token_semantics: i64,
pub input_cost_usd: String,
pub output_cost_usd: String,
pub cache_read_cost_usd: String,
@@ -154,15 +159,15 @@ pub struct RequestLogDetail {
pub pricing_model: Option<String>,
}
/// 把 25 列的查询结果映射为 `RequestLogDetail`。
/// 把 26 列的查询结果映射为 `RequestLogDetail`。
///
/// 调用方的 SELECT **必须**按以下顺序返回 25 列:
/// 调用方的 SELECT **必须**按以下顺序返回 26 列:
/// `request_id, provider_id, provider_name, app_type, model, request_model,
/// cost_multiplier, input_tokens, output_tokens, cache_read_tokens,
/// cache_creation_tokens, input_cost_usd, output_cost_usd, cache_read_cost_usd,
/// cache_creation_cost_usd, total_cost_usd, is_streaming, latency_ms,
/// first_token_ms, duration_ms, status_code, error_message, created_at,
/// data_source, pricing_model`
/// data_source, pricing_model, input_token_semantics`
///
/// 不需要 provider_name 时(如 backfillSELECT `NULL AS provider_name` 占位即可。
fn row_to_request_log_detail(row: &rusqlite::Row<'_>) -> rusqlite::Result<RequestLogDetail> {
@@ -194,6 +199,7 @@ fn row_to_request_log_detail(row: &rusqlite::Row<'_>) -> rusqlite::Result<Reques
created_at: row.get(22)?,
data_source: row.get(23)?,
pricing_model: row.get(24)?,
input_token_semantics: row.get::<_, i64>(25)?,
})
}
@@ -1526,7 +1532,8 @@ impl Database {
l.input_tokens, l.output_tokens, l.cache_read_tokens, l.cache_creation_tokens,
l.input_cost_usd, l.output_cost_usd, l.cache_read_cost_usd, l.cache_creation_cost_usd, l.total_cost_usd,
l.is_streaming, l.latency_ms, l.first_token_ms, l.duration_ms,
l.status_code, l.error_message, l.created_at, l.data_source, l.pricing_model
l.status_code, l.error_message, l.created_at, l.data_source, l.pricing_model,
l.input_token_semantics
FROM proxy_request_logs l
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
{where_clause}
@@ -1569,7 +1576,8 @@ impl Database {
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
is_streaming, latency_ms, first_token_ms, duration_ms,
status_code, error_message, created_at, l.data_source, l.pricing_model
status_code, error_message, created_at, l.data_source, l.pricing_model,
l.input_token_semantics
FROM proxy_request_logs l
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
WHERE l.request_id = ?"
@@ -1725,7 +1733,7 @@ impl Database {
input_cost_usd, output_cost_usd, cache_read_cost_usd,
cache_creation_cost_usd, total_cost_usd, is_streaming, latency_ms,
first_token_ms, duration_ms, status_code, error_message, created_at,
data_source, pricing_model
data_source, pricing_model, input_token_semantics
FROM proxy_request_logs
WHERE CAST(total_cost_usd AS REAL) <= 0
AND (input_tokens > 0 OR output_tokens > 0
@@ -1806,15 +1814,21 @@ impl Database {
let million = rust_decimal::Decimal::from(1_000_000u64);
// 与 CostCalculator::calculate_for_app 保持一致的计算逻辑:
// 1. Codex/Gemini 的 input_tokens 包含 cache_read_tokens,需要扣除后按输入价计费
// 1. 历史 Codex/Gemini 行只包含 cache read;新 total 行还包含 cache write。
// 2. Claude/Anthropic 的 input_tokens 已经是 fresh input,不能再次扣减
// 3. 各项成本是基础成本(不含倍率),倍率只作用于最终总价
let input_includes_cache_read = matches!(log.app_type.as_str(), "codex" | "gemini");
let billable_input_tokens = if input_includes_cache_read {
(log.input_tokens as u64).saturating_sub(log.cache_read_tokens as u64)
} else {
log.input_tokens as u64
};
let cache_inclusive_app = matches!(log.app_type.as_str(), "codex" | "gemini");
let billable_input_tokens =
if !cache_inclusive_app || log.input_token_semantics == INPUT_TOKEN_SEMANTICS_FRESH {
log.input_tokens as u64
} else if log.input_token_semantics == INPUT_TOKEN_SEMANTICS_TOTAL {
(log.input_tokens as u64)
.saturating_sub(log.cache_read_tokens as u64)
.saturating_sub(log.cache_creation_tokens as u64)
} else {
// v12 and earlier: input included cache reads but excluded cache writes.
(log.input_tokens as u64).saturating_sub(log.cache_read_tokens as u64)
};
let input_cost =
rust_decimal::Decimal::from(billable_input_tokens) * pricing.input / million;
let output_cost =
@@ -2492,6 +2506,77 @@ mod tests {
Ok(())
}
#[test]
fn test_backfill_distinguishes_legacy_and_total_cache_semantics() -> Result<(), AppError> {
let db = Database::memory()?;
{
let conn = lock_conn!(db.conn);
// v12 mirror row: input = fresh + read; creation was reported separately.
insert_usage_log(
&conn,
"legacy-cache-semantics",
"codex",
"p1",
"gpt-5.5",
"proxy",
1000,
800_000,
0,
600_000,
200_000,
200,
"0",
)?;
// v13 proxy row: input = fresh + read + creation.
insert_usage_log(
&conn,
"total-cache-semantics",
"codex",
"p1",
"gpt-5.5",
"proxy",
1001,
1_000_000,
0,
600_000,
200_000,
200,
"0",
)?;
conn.execute(
"UPDATE proxy_request_logs
SET input_token_semantics = ?1
WHERE request_id = 'total-cache-semantics'",
[INPUT_TOKEN_SEMANTICS_TOTAL],
)?;
}
assert_eq!(db.backfill_missing_usage_costs()?, 2);
let conn = lock_conn!(db.conn);
let mut stmt = conn.prepare(
"SELECT request_id, input_cost_usd
FROM proxy_request_logs
WHERE request_id IN ('legacy-cache-semantics', 'total-cache-semantics')
ORDER BY request_id",
)?;
let rows = stmt
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?
.collect::<Result<Vec<_>, _>>()?;
assert_eq!(
rows,
vec![
("legacy-cache-semantics".to_string(), "1.000000".to_string()),
("total-cache-semantics".to_string(), "1.000000".to_string()),
]
);
Ok(())
}
#[test]
fn test_backfill_missing_usage_costs_uses_stored_multiplier() -> Result<(), AppError> {
let db = Database::memory()?;
+10 -2
View File
@@ -4,7 +4,7 @@ pub mod terminal;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use providers::{claude, codex, gemini, hermes, openclaw, opencode};
use providers::{claude, codex, gemini, grokbuild, hermes, openclaw, opencode};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -56,13 +56,14 @@ pub struct DeleteSessionOutcome {
}
pub fn scan_sessions() -> Vec<SessionMeta> {
let (r1, r2, r3, r4, r5, r6) = std::thread::scope(|s| {
let (r1, r2, r3, r4, r5, r6, r7) = 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);
let h7 = s.spawn(grokbuild::scan_sessions);
(
h1.join().unwrap_or_default(),
h2.join().unwrap_or_default(),
@@ -70,6 +71,7 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
h4.join().unwrap_or_default(),
h5.join().unwrap_or_default(),
h6.join().unwrap_or_default(),
h7.join().unwrap_or_default(),
)
});
@@ -80,6 +82,7 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
sessions.extend(r4);
sessions.extend(r5);
sessions.extend(r6);
sessions.extend(r7);
sessions.sort_by(|a, b| {
let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0);
@@ -106,6 +109,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),
"grokbuild" => grokbuild::load_messages(path),
"hermes" => hermes::load_messages(path),
_ => Err(format!("Unsupported provider: {provider_id}")),
}
@@ -165,6 +169,9 @@ fn delete_session_with_roots(
openclaw::delete_session(&validated_root, &validated_source, session_id)
}
"gemini" => gemini::delete_session(&validated_root, &validated_source, session_id),
"grokbuild" => {
grokbuild::delete_session(&validated_root, &validated_source, session_id)
}
"hermes" => hermes::delete_session(&validated_root, &validated_source, session_id),
_ => Err(format!("Unsupported provider: {provider_id}")),
};
@@ -194,6 +201,7 @@ fn provider_roots(provider_id: &str) -> Result<Vec<PathBuf>, String> {
"opencode" => vec![opencode::get_opencode_data_dir()],
"openclaw" => vec![crate::openclaw_config::get_openclaw_dir().join("agents")],
"gemini" => vec![crate::gemini_config::get_gemini_dir().join("tmp")],
"grokbuild" => grokbuild::session_roots(),
"hermes" => vec![crate::hermes_config::get_hermes_dir().join("sessions")],
_ => return Err(format!("Unsupported provider: {provider_id}")),
};
@@ -1,12 +1,17 @@
use std::collections::HashMap;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use std::time::Duration;
use regex::Regex;
use rusqlite::Connection;
use serde::Deserialize;
use serde_json::Value;
use crate::codex_config::get_codex_config_dir;
use crate::codex_config::{get_codex_config_dir, read_codex_config_text};
use crate::codex_state_db::codex_state_db_paths;
use crate::session_manager::{SessionMessage, SessionMeta};
use super::utils::{
@@ -15,6 +20,7 @@ use super::utils::{
};
const PROVIDER_ID: &str = "codex";
const CODEX_SESSION_INDEX_FILENAME: &str = "session_index.jsonl";
const VSCODE_CONTEXT_PREFIX: &str = "# Context from my IDE setup:";
const CODEX_REQUEST_MARKER: &str = "my request for codex";
@@ -23,6 +29,12 @@ static UUID_RE: LazyLock<Regex> = LazyLock::new(|| {
.unwrap()
});
#[derive(Deserialize)]
struct SessionIndexEntry {
id: String,
thread_name: String,
}
pub fn scan_sessions() -> Vec<SessionMeta> {
let roots = session_roots();
scan_sessions_in_roots(&roots)
@@ -37,6 +49,14 @@ pub fn session_roots() -> Vec<PathBuf> {
}
fn scan_sessions_in_roots(roots: &[PathBuf]) -> Vec<SessionMeta> {
let thread_titles = load_thread_titles();
scan_sessions_in_roots_with_titles(roots, &thread_titles)
}
fn scan_sessions_in_roots_with_titles(
roots: &[PathBuf],
thread_titles: &HashMap<String, String>,
) -> Vec<SessionMeta> {
let mut files = Vec::new();
for root in roots {
collect_jsonl_files(root, &mut files);
@@ -44,7 +64,7 @@ fn scan_sessions_in_roots(roots: &[PathBuf]) -> Vec<SessionMeta> {
let mut sessions = Vec::new();
for path in files {
if let Some(meta) = parse_session(&path) {
if let Some(meta) = parse_session_with_titles(&path, thread_titles) {
sessions.push(meta);
}
}
@@ -52,6 +72,135 @@ fn scan_sessions_in_roots(roots: &[PathBuf]) -> Vec<SessionMeta> {
sessions
}
fn load_thread_titles() -> HashMap<String, String> {
let config_dir = get_codex_config_dir();
let config_text = read_codex_config_text().unwrap_or_default();
let db_paths = codex_state_db_paths(&config_dir, &config_text);
load_thread_titles_from_paths(&config_dir.join(CODEX_SESSION_INDEX_FILENAME), &db_paths)
}
fn load_thread_titles_from_paths(
session_index_path: &Path,
db_paths: &[PathBuf],
) -> HashMap<String, String> {
let mut titles = load_thread_titles_from_session_index(session_index_path);
for db_path in db_paths {
titles.extend(load_thread_titles_from_db(db_path));
}
titles
}
fn load_thread_titles_from_session_index(index_path: &Path) -> HashMap<String, String> {
if !index_path.exists() {
return HashMap::new();
}
let file = match File::open(index_path) {
Ok(file) => file,
Err(err) => {
log::warn!(
"Failed to open Codex session index {}: {err}",
index_path.display()
);
return HashMap::new();
}
};
let reader = BufReader::new(file);
let mut titles = HashMap::new();
for line in reader.lines() {
let line = match line {
Ok(line) => line,
Err(_) => continue,
};
let Ok(entry) = serde_json::from_str::<SessionIndexEntry>(line.trim()) else {
continue;
};
let id = entry.id.trim();
let title = entry.thread_name.trim();
if !id.is_empty() && !title.is_empty() {
titles.insert(id.to_string(), title.to_string());
}
}
titles
}
fn load_thread_titles_from_db(db_path: &Path) -> HashMap<String, String> {
if !db_path.exists() {
return HashMap::new();
}
let conn = match Connection::open_with_flags(
db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
) {
Ok(conn) => conn,
Err(err) => {
log::warn!(
"Failed to open Codex state database {}: {err}",
db_path.display()
);
return HashMap::new();
}
};
// Codex keeps this DB open and write-locked while running; without a busy
// timeout a read during a write fails immediately and titles silently drop.
if let Err(err) = conn.busy_timeout(Duration::from_secs(2)) {
log::warn!(
"Failed to set Codex state database busy timeout for {}: {err}",
db_path.display()
);
return HashMap::new();
}
// Mirror Codex's own `distinct_thread_metadata_title`: keep a title only
// when it differs from the first user message. Push the comparison into SQL
// (NULL-safe) so we never SELECT the unbounded `first_user_message` blob —
// it can grow large enough to OOM (openai/codex#29007).
let mut stmt = match conn.prepare(
"SELECT id, title FROM threads \
WHERE title <> '' \
AND (first_user_message IS NULL OR TRIM(title) <> TRIM(first_user_message))",
) {
Ok(stmt) => stmt,
Err(err) => {
log::warn!(
"Failed to prepare Codex thread title query for {}: {err}",
db_path.display()
);
return HashMap::new();
}
};
let rows = match stmt.query_map([], |row| {
let id: String = row.get(0)?;
let title: String = row.get(1)?;
Ok((id, title))
}) {
Ok(rows) => rows,
Err(err) => {
log::warn!(
"Failed to query Codex thread titles from {}: {err}",
db_path.display()
);
return HashMap::new();
}
};
rows.flatten()
.filter_map(|(id, title)| {
let id = id.trim();
let title = title.trim();
if id.is_empty() || title.is_empty() {
None
} else {
Some((id.to_string(), title.to_string()))
}
})
.collect()
}
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);
@@ -141,6 +290,13 @@ pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result<boo
}
fn parse_session(path: &Path) -> Option<SessionMeta> {
parse_session_with_titles(path, &HashMap::new())
}
fn parse_session_with_titles(
path: &Path,
thread_titles: &HashMap<String, String>,
) -> Option<SessionMeta> {
let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;
let mut session_id: Option<String> = None;
@@ -233,8 +389,10 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
let session_id = session_id.or_else(|| infer_session_id_from_filename(path));
let session_id = session_id?;
let title = first_user_message
.map(|t| truncate_summary(&t, TITLE_MAX_CHARS))
let title = thread_titles
.get(&session_id)
.map(|t| truncate_summary(t, TITLE_MAX_CHARS))
.or_else(|| first_user_message.map(|t| truncate_summary(&t, TITLE_MAX_CHARS)))
.or_else(|| {
project_dir
.as_deref()
@@ -366,6 +524,7 @@ fn collect_jsonl_files(root: &Path, files: &mut Vec<PathBuf>) {
#[cfg(test)]
mod tests {
use super::*;
use crate::codex_state_db::CODEX_STATE_DB_FILENAME;
use tempfile::tempdir;
fn write_codex_session(path: &Path, session_id: &str, message: &str) {
@@ -443,6 +602,166 @@ mod tests {
assert_eq!(meta.title.as_deref(), Some("How do I deploy?"));
}
#[test]
fn parse_session_prefers_thread_title() {
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\":\"How do I deploy?\"}}\n"
),
)
.expect("write");
let mut thread_titles = HashMap::new();
thread_titles.insert(
"test-id".to_string(),
"Renamed deployment thread".to_string(),
);
let meta = parse_session_with_titles(&path, &thread_titles).unwrap();
assert_eq!(meta.title.as_deref(), Some("Renamed deployment thread"));
}
#[test]
fn load_thread_titles_from_state_db_trims_and_filters_titles() {
let temp = tempdir().expect("tempdir");
let db_path = temp.path().join(CODEX_STATE_DB_FILENAME);
let conn = Connection::open(&db_path).expect("open sqlite db");
conn.execute(
"CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT NOT NULL, first_user_message TEXT NOT NULL)",
[],
)
.expect("create threads table");
conn.execute(
"INSERT INTO threads (id, title, first_user_message) VALUES (?1, ?2, ?3)",
("thread-1", " Renamed Codex thread ", "First prompt"),
)
.expect("insert renamed thread");
conn.execute(
"INSERT INTO threads (id, title, first_user_message) VALUES (?1, ?2, ?3)",
("thread-2", " ", "First prompt"),
)
.expect("insert blank thread");
conn.execute(
"INSERT INTO threads (id, title, first_user_message) VALUES (?1, ?2, ?3)",
("thread-3", " First prompt ", "First prompt"),
)
.expect("insert first-message title");
drop(conn);
let titles = load_thread_titles_from_db(&db_path);
assert_eq!(
titles.get("thread-1").map(String::as_str),
Some("Renamed Codex thread")
);
assert!(!titles.contains_key("thread-2"));
assert!(!titles.contains_key("thread-3"));
}
#[test]
fn load_thread_titles_from_state_db_keeps_title_when_first_user_message_null() {
let temp = tempdir().expect("tempdir");
let db_path = temp.path().join(CODEX_STATE_DB_FILENAME);
let conn = Connection::open(&db_path).expect("open sqlite db");
// Codex stores first_user_message as a nullable column (Option<String>);
// a renamed thread can have a title before any first message is synced.
conn.execute(
"CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT NOT NULL, first_user_message TEXT)",
[],
)
.expect("create threads table");
conn.execute(
"INSERT INTO threads (id, title, first_user_message) VALUES (?1, ?2, NULL)",
("thread-1", "Renamed thread"),
)
.expect("insert renamed thread without first message");
conn.execute(
"INSERT INTO threads (id, title, first_user_message) VALUES (?1, ?2, ?3)",
("thread-2", "First prompt", "First prompt"),
)
.expect("insert first-message title");
drop(conn);
let titles = load_thread_titles_from_db(&db_path);
// Kept: title present and no first message to compare against.
assert_eq!(
titles.get("thread-1").map(String::as_str),
Some("Renamed thread")
);
// Filtered: title equals the first user message.
assert!(!titles.contains_key("thread-2"));
}
#[test]
fn load_thread_titles_from_session_index_uses_latest_name() {
let temp = tempdir().expect("tempdir");
let index_path = temp.path().join(CODEX_SESSION_INDEX_FILENAME);
std::fs::write(
&index_path,
concat!(
"{\"id\":\"thread-1\",\"thread_name\":\"Old name\",\"updated_at\":\"2026-07-01T00:00:00Z\"}\n",
"{\"id\":\"thread-2\",\"thread_name\":\" \",\"updated_at\":\"2026-07-01T00:00:00Z\"}\n",
"not json\n",
"{\"id\":\"thread-1\",\"thread_name\":\" New name \",\"updated_at\":\"2026-07-02T00:00:00Z\"}\n"
),
)
.expect("write session index");
let titles = load_thread_titles_from_session_index(&index_path);
assert_eq!(titles.get("thread-1").map(String::as_str), Some("New name"));
assert!(!titles.contains_key("thread-2"));
}
#[test]
fn load_thread_titles_prefers_state_db_explicit_title_over_session_index() {
let temp = tempdir().expect("tempdir");
let index_path = temp.path().join(CODEX_SESSION_INDEX_FILENAME);
std::fs::write(
&index_path,
concat!(
"{\"id\":\"thread-1\",\"thread_name\":\"Legacy name\",\"updated_at\":\"2026-07-01T00:00:00Z\"}\n",
"{\"id\":\"thread-2\",\"thread_name\":\"Legacy fallback\",\"updated_at\":\"2026-07-01T00:00:00Z\"}\n"
),
)
.expect("write session index");
let db_path = temp.path().join(CODEX_STATE_DB_FILENAME);
let conn = Connection::open(&db_path).expect("open sqlite db");
conn.execute(
"CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT NOT NULL, first_user_message TEXT NOT NULL)",
[],
)
.expect("create threads table");
conn.execute(
"INSERT INTO threads (id, title, first_user_message) VALUES (?1, ?2, ?3)",
("thread-1", "SQLite name", "First prompt"),
)
.expect("insert sqlite title");
conn.execute(
"INSERT INTO threads (id, title, first_user_message) VALUES (?1, ?2, ?3)",
("thread-2", "First prompt", "First prompt"),
)
.expect("insert first-message sqlite title");
drop(conn);
let titles = load_thread_titles_from_paths(&index_path, &[db_path]);
assert_eq!(
titles.get("thread-1").map(String::as_str),
Some("SQLite name")
);
assert_eq!(
titles.get("thread-2").map(String::as_str),
Some("Legacy fallback")
);
}
#[test]
fn parse_session_skips_agents_md_injection() {
let temp = tempdir().expect("tempdir");
@@ -0,0 +1,296 @@
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use serde::Deserialize;
use serde_json::Value;
use crate::session_manager::{SessionMessage, SessionMeta};
use super::utils::{extract_text, parse_timestamp_to_ms, truncate_summary, TITLE_MAX_CHARS};
#[derive(Debug, Deserialize)]
struct GrokSessionInfo {
id: String,
#[serde(default)]
cwd: Option<String>,
}
#[derive(Debug, Deserialize)]
struct GrokSessionSummary {
info: GrokSessionInfo,
#[serde(default)]
session_summary: Option<String>,
#[serde(default)]
generated_title: Option<String>,
#[serde(default)]
created_at: Option<Value>,
#[serde(default)]
updated_at: Option<Value>,
#[serde(default)]
last_active_at: Option<Value>,
}
pub fn session_roots() -> Vec<PathBuf> {
let config_dir = crate::grok_config::get_grok_config_dir();
vec![
config_dir.join("sessions"),
config_dir.join("archived_sessions"),
]
}
pub fn scan_sessions() -> Vec<SessionMeta> {
let mut summaries = Vec::new();
for root in session_roots() {
collect_summary_files(&root, &mut summaries);
}
summaries
.into_iter()
.filter_map(|path| parse_summary(&path))
.collect()
}
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
let session_dir = path
.parent()
.ok_or_else(|| format!("Invalid Grok Build session path: {}", path.display()))?;
let chat_path = session_dir.join("chat_history.jsonl");
let file = File::open(&chat_path)
.map_err(|e| format!("Failed to open Grok Build chat history: {e}"))?;
let reader = BufReader::new(file);
let mut messages = Vec::new();
for line in reader.lines().map_while(Result::ok) {
let Ok(value) = serde_json::from_str::<Value>(&line) else {
continue;
};
let kind = value.get("type").and_then(Value::as_str).unwrap_or("");
let role = match kind {
"system" | "user" | "assistant" | "tool" => kind,
// Reasoning records can contain encrypted/internal state and are not
// conversation messages shown by Grok's own history view.
_ => continue,
};
let content = value.get("content").map(extract_text).unwrap_or_default();
if content.trim().is_empty() {
continue;
}
let ts = value
.get("timestamp")
.or_else(|| value.get("ts"))
.and_then(parse_timestamp_to_ms);
messages.push(SessionMessage {
role: role.to_string(),
content,
ts,
});
}
Ok(messages)
}
pub fn delete_session(root: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
if !path.starts_with(root) {
return Err(format!(
"Grok Build session source is outside the session root: {}",
path.display()
));
}
if path.file_name().and_then(|name| name.to_str()) != Some("summary.json") {
return Err(format!(
"Unexpected Grok Build session source: {}",
path.display()
));
}
let summary = read_summary(path)?;
if summary.info.id != session_id {
return Err(format!(
"Grok Build session ID mismatch: expected {session_id}, found {}",
summary.info.id
));
}
let session_dir = path
.parent()
.ok_or_else(|| format!("Invalid Grok Build session path: {}", path.display()))?;
if session_dir == root || !session_dir.starts_with(root) {
return Err(format!(
"Refusing to delete Grok Build session directory outside its root: {}",
session_dir.display()
));
}
if session_dir.file_name().and_then(|name| name.to_str()) != Some(session_id) {
return Err(format!(
"Grok Build session directory does not match session ID: {}",
session_dir.display()
));
}
std::fs::remove_dir_all(session_dir).map_err(|e| {
format!(
"Failed to delete Grok Build session directory {}: {e}",
session_dir.display()
)
})?;
Ok(true)
}
fn collect_summary_files(root: &Path, files: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(root) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_summary_files(&path, files);
} else if path.file_name().and_then(|name| name.to_str()) == Some("summary.json") {
files.push(path);
}
}
}
fn read_summary(path: &Path) -> Result<GrokSessionSummary, String> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read Grok Build session summary: {e}"))?;
serde_json::from_str(&text)
.map_err(|e| format!("Failed to parse Grok Build session summary: {e}"))
}
fn parse_summary(path: &Path) -> Option<SessionMeta> {
let summary = read_summary(path).ok()?;
let session_id = summary.info.id;
let title = summary
.generated_title
.as_deref()
.filter(|value| !value.trim().is_empty())
.or_else(|| {
summary
.session_summary
.as_deref()
.filter(|value| !value.trim().is_empty())
})
.map(|value| truncate_summary(value, TITLE_MAX_CHARS));
let session_summary = summary
.session_summary
.as_deref()
.filter(|value| !value.trim().is_empty())
.map(|value| truncate_summary(value, 160));
let created_at = summary.created_at.as_ref().and_then(parse_timestamp_to_ms);
let last_active_at = summary
.last_active_at
.as_ref()
.or(summary.updated_at.as_ref())
.and_then(parse_timestamp_to_ms);
Some(SessionMeta {
provider_id: "grokbuild".to_string(),
session_id: session_id.clone(),
title,
summary: session_summary,
project_dir: summary.info.cwd,
created_at,
last_active_at,
source_path: Some(path.to_string_lossy().to_string()),
resume_command: Some(format!("grok --resume {session_id}")),
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn scans_native_grokbuild_session_layout() {
let temp = tempdir().expect("tempdir");
let sessions_dir = temp.path().join("sessions");
let session_id = "019f6af2-18b0-7673-958e-d25be650e172";
let session_dir = sessions_dir.join("encoded-project").join(session_id);
std::fs::create_dir_all(&session_dir).expect("create session dir");
std::fs::write(
session_dir.join("summary.json"),
format!(
r#"{{"info":{{"id":"{session_id}","cwd":"C:/work"}},"session_summary":"hello grok","generated_title":"Grok session","created_at":"2026-07-16T12:00:00Z","last_active_at":"2026-07-16T12:00:01Z"}}"#
),
)
.expect("write summary");
let mut files = Vec::new();
collect_summary_files(&sessions_dir, &mut files);
let sessions = files
.iter()
.filter_map(|path| parse_summary(path))
.collect::<Vec<_>>();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].provider_id, "grokbuild");
assert_eq!(sessions[0].session_id, session_id);
assert_eq!(sessions[0].title.as_deref(), Some("Grok session"));
let expected_resume = format!("grok --resume {session_id}");
assert_eq!(
sessions[0].resume_command.as_deref(),
Some(expected_resume.as_str())
);
}
#[test]
fn loads_native_grokbuild_chat_history() {
let temp = tempdir().expect("tempdir");
let summary_path = temp.path().join("summary.json");
std::fs::write(&summary_path, "{}").expect("write summary placeholder");
std::fs::write(
temp.path().join("chat_history.jsonl"),
concat!(
"{\"type\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"hello\"}]}\n",
"{\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"private\"}]}\n",
"{\"type\":\"assistant\",\"content\":\"Hi there\"}\n"
),
)
.expect("write chat history");
let messages = load_messages(&summary_path).expect("load messages");
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].role, "user");
assert_eq!(messages[0].content, "hello");
assert_eq!(messages[1].content, "Hi there");
}
#[test]
fn delete_session_removes_only_the_matching_session_directory() {
let temp = tempdir().expect("tempdir");
let root = temp.path().join("sessions");
let session_id = "session-to-delete";
let session_dir = root.join("project").join(session_id);
let sibling_dir = root.join("project").join("session-to-keep");
std::fs::create_dir_all(&session_dir).expect("create session directory");
std::fs::create_dir_all(&sibling_dir).expect("create sibling directory");
let summary_path = session_dir.join("summary.json");
std::fs::write(
&summary_path,
format!(r#"{{"info":{{"id":"{session_id}"}}}}"#),
)
.expect("write summary");
std::fs::write(sibling_dir.join("keep.txt"), "keep").expect("write sibling file");
let deleted = delete_session(&root, &summary_path, session_id).expect("delete session");
assert!(deleted);
assert!(!session_dir.exists());
assert!(sibling_dir.exists());
}
#[test]
fn delete_session_rejects_remove_dir_all_target_outside_root() {
let temp = tempdir().expect("tempdir");
let root = temp.path().join("sessions");
let outside_dir = temp.path().join("outside").join("session-outside");
std::fs::create_dir_all(&root).expect("create root");
std::fs::create_dir_all(&outside_dir).expect("create outside directory");
let summary_path = outside_dir.join("summary.json");
std::fs::write(&summary_path, r#"{"info":{"id":"session-outside"}}"#)
.expect("write summary");
let error = delete_session(&root, &summary_path, "session-outside")
.expect_err("outside path must be rejected");
assert!(error.contains("outside the session root"));
assert!(outside_dir.exists());
}
}
@@ -1,6 +1,7 @@
pub mod claude;
pub mod codex;
pub mod gemini;
pub mod grokbuild;
pub mod hermes;
pub mod openclaw;
pub mod opencode;
@@ -149,7 +149,7 @@ fn scan_sessions_sqlite() -> Vec<SessionMeta> {
created_at: Some(created),
last_active_at: Some(updated),
source_path: Some(format!("sqlite:{db_display}:{session_id}")),
resume_command: Some(format!("opencode session resume {session_id}")),
resume_command: Some(format!("opencode -s {session_id}")),
});
}
sessions
@@ -473,7 +473,7 @@ fn parse_session(storage: &Path, path: &Path) -> Option<SessionMeta> {
created_at,
last_active_at: updated_at.or(created_at),
source_path: Some(source_path),
resume_command: Some(format!("opencode session resume {session_id}")),
resume_command: Some(format!("opencode -s {session_id}")),
})
}
@@ -825,6 +825,10 @@ mod tests {
sessions[1].source_path.as_deref(),
Some(expected_source.as_str())
);
assert_eq!(
sessions[1].resume_command.as_deref(),
Some("opencode -s ses_1")
);
}
#[test]
+39 -4
View File
@@ -1,6 +1,5 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::sync::{OnceLock, RwLock};
@@ -40,6 +39,8 @@ pub struct VisibleApps {
#[serde(default = "default_true")]
pub gemini: bool,
#[serde(default = "default_true")]
pub grokbuild: bool,
#[serde(default = "default_true")]
pub opencode: bool,
#[serde(default = "default_true")]
pub openclaw: bool,
@@ -54,6 +55,7 @@ impl Default for VisibleApps {
claude_desktop: true,
codex: true,
gemini: true,
grokbuild: true,
opencode: true,
openclaw: true,
hermes: false, // 默认不显示,需用户手动启用
@@ -69,6 +71,7 @@ impl VisibleApps {
AppType::ClaudeDesktop => self.claude_desktop,
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
AppType::GrokBuild => self.grokbuild,
AppType::OpenCode => self.opencode,
AppType::OpenClaw => self.openclaw,
AppType::Hermes => self.hermes,
@@ -366,12 +369,14 @@ pub struct AppSettings {
/// User has confirmed the usage query first-run notice
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage_confirmed: Option<bool>,
/// User has confirmed the stream check first-run notice
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stream_check_confirmed: Option<bool>,
pub usage_dashboard_refresh_interval_ms: Option<u32>,
/// Whether to show the failover toggle independently on the main page
#[serde(default)]
pub enable_failover_toggle: bool,
/// Whether to show the project profile switcher on the main page header
#[serde(default = "default_show_profile_switcher")]
pub show_profile_switcher: bool,
/// Keep Codex ChatGPT login material in auth.json when switching to third-party providers.
/// Opt-in: defaults to false so third-party switches cleanly overwrite auth.json.
#[serde(default)]
@@ -410,6 +415,8 @@ pub struct AppSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gemini_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub grok_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opencode_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub openclaw_config_dir: Option<String>,
@@ -429,6 +436,9 @@ pub struct AppSettings {
/// 当前 Gemini 供应商 ID(本地存储,优先于数据库 is_current
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_gemini: Option<String>,
/// 当前 Grok Build 供应商 ID(本地存储,优先于数据库 is_current
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_grokbuild: Option<String>,
/// 当前 OpenCode 供应商 ID(本地存储,对 OpenCode 可能无意义,但保持结构一致)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_opencode: Option<String>,
@@ -488,6 +498,10 @@ fn default_minimize_to_tray_on_close() -> bool {
true
}
fn default_show_profile_switcher() -> bool {
true
}
impl Default for AppSettings {
fn default() -> Self {
Self {
@@ -501,8 +515,9 @@ impl Default for AppSettings {
enable_local_proxy: false,
proxy_confirmed: None,
usage_confirmed: None,
stream_check_confirmed: None,
usage_dashboard_refresh_interval_ms: None,
enable_failover_toggle: false,
show_profile_switcher: true,
preserve_codex_official_auth_on_switch: false,
unify_codex_session_history: false,
unify_codex_migrate_existing: None,
@@ -514,6 +529,7 @@ impl Default for AppSettings {
claude_config_dir: None,
codex_config_dir: None,
gemini_config_dir: None,
grok_config_dir: None,
opencode_config_dir: None,
openclaw_config_dir: None,
hermes_config_dir: None,
@@ -521,6 +537,7 @@ impl Default for AppSettings {
current_provider_claude_desktop: None,
current_provider_codex: None,
current_provider_gemini: None,
current_provider_grokbuild: None,
current_provider_opencode: None,
current_provider_openclaw: None,
current_provider_hermes: None,
@@ -569,6 +586,13 @@ impl AppSettings {
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
self.grok_config_dir = self
.grok_config_dir
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
self.opencode_config_dir = self
.opencode_config_dir
.as_ref()
@@ -653,6 +677,7 @@ fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {
#[cfg(unix)]
{
use std::fs::OpenOptions;
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = OpenOptions::new()
@@ -876,6 +901,14 @@ pub fn get_gemini_override_dir() -> Option<PathBuf> {
.map(|p| resolve_override_path(p))
}
pub fn get_grok_override_dir() -> Option<PathBuf> {
let settings = settings_store().read().ok()?;
settings
.grok_config_dir
.as_ref()
.map(|p| resolve_override_path(p))
}
pub fn get_opencode_override_dir() -> Option<PathBuf> {
let settings = settings_store().read().ok()?;
settings
@@ -933,6 +966,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option<String> {
AppType::ClaudeDesktop => settings.current_provider_claude_desktop.clone(),
AppType::Codex => settings.current_provider_codex.clone(),
AppType::Gemini => settings.current_provider_gemini.clone(),
AppType::GrokBuild => settings.current_provider_grokbuild.clone(),
AppType::OpenCode => settings.current_provider_opencode.clone(),
AppType::OpenClaw => settings.current_provider_openclaw.clone(),
AppType::Hermes => settings.current_provider_hermes.clone(),
@@ -950,6 +984,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(),
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::GrokBuild => settings.current_provider_grokbuild = 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(),
+327 -8
View File
@@ -19,8 +19,13 @@ const W_TIER_NAMES: &[&str] = &[
crate::services::subscription::TIER_SEVEN_DAY_OPUS,
crate::services::subscription::TIER_SEVEN_DAY_SONNET,
];
// 火山方舟 Agent/Coding Plan 的月窗口(5h/周/月 三档)
const M_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_MONTHLY];
// 月窗口分组:火山方舟 Agent/Coding Plan 的月窗口(5h/周/月 三档)
// 以及 Codex 免费方案的 30 天窗口(#3651)——两者都归入 "m" 档,避免免费
// Codex 账号在托盘里空白(前端 footer 能看到、托盘却不显示的不对称)。
const M_TIER_NAMES: &[&str] = &[
crate::services::subscription::TIER_MONTHLY,
crate::services::subscription::TIER_THIRTY_DAY,
];
const GEMINI_PRO_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_GEMINI_PRO];
const GEMINI_FLASH_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_GEMINI_FLASH];
const GEMINI_FLASH_LITE_TIER_NAMES: &[&str] =
@@ -49,6 +54,43 @@ pub struct TrayTexts {
pub lightweight_mode: &'static str,
pub quit: &'static str,
pub _auto_label: &'static str,
pub projects_label: &'static str,
pub no_project_label: &'static str,
}
/// 将系统区域标识映射为托盘支持的语言码。
///
/// 镜像前端 `i18n/getInitialLanguage` 的判定顺序,确保首次安装
/// `settings.language` 尚未写入)时托盘语言与界面语言一致:
/// 繁中系统(zh-TW/HK/MO/Hant)→ `zh-TW`,其余 zh → `zh`
/// 日文 → `ja`,英文 → `en`,未知区域回退到 `zh`(与前端默认一致)。
fn map_locale_to_tray_language(locale: &str) -> &'static str {
let locale = locale.to_lowercase();
if locale == "zh" {
"zh"
} else if locale.starts_with("zh-tw")
|| locale.starts_with("zh-hk")
|| locale.starts_with("zh-mo")
|| locale.starts_with("zh-hant")
{
"zh-TW"
} else if locale.starts_with("zh") {
"zh"
} else if locale.starts_with("ja") {
"ja"
} else if locale.starts_with("en") {
"en"
} else {
"zh"
}
}
/// 读取系统区域并映射为托盘语言码;取不到区域时回退到 `zh`。
fn detect_system_tray_language() -> &'static str {
sys_locale::get_locale()
.as_deref()
.map(map_locale_to_tray_language)
.unwrap_or("zh")
}
impl TrayTexts {
@@ -61,6 +103,8 @@ impl TrayTexts {
lightweight_mode: "Lightweight Mode",
quit: "Quit",
_auto_label: "Auto (Failover)",
projects_label: "Projects",
no_project_label: "No project",
},
"ja" => Self {
show_main: "メインウィンドウを開く",
@@ -69,6 +113,8 @@ impl TrayTexts {
lightweight_mode: "軽量モード",
quit: "終了",
_auto_label: "自動 (フェイルオーバー)",
projects_label: "プロジェクト",
no_project_label: "プロジェクトを使用しない",
},
"zh-TW" => Self {
show_main: "開啟主介面",
@@ -77,6 +123,8 @@ impl TrayTexts {
lightweight_mode: "輕量模式",
quit: "退出",
_auto_label: "自動 (故障轉移)",
projects_label: "專案",
no_project_label: "不使用專案",
},
_ => Self {
show_main: "打开主界面",
@@ -85,6 +133,8 @@ impl TrayTexts {
lightweight_mode: "轻量模式",
quit: "退出",
_auto_label: "自动 (故障转移)",
projects_label: "项目",
no_project_label: "不使用项目",
},
}
}
@@ -103,7 +153,7 @@ pub struct TrayAppSection {
pub const AUTO_SUFFIX: &str = "auto";
pub const TRAY_ID: &str = "cc-switch";
pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
pub const TRAY_SECTIONS: [TrayAppSection; 4] = [
TrayAppSection {
app_type: AppType::Claude,
prefix: "claude_",
@@ -125,6 +175,13 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
header_label: "Gemini",
log_name: "Gemini",
},
TrayAppSection {
app_type: AppType::GrokBuild,
prefix: "grokbuild_",
empty_id: "grokbuild_empty",
header_label: "Grok Build",
log_name: "Grok Build",
},
];
/// 配色阈值(与前端 `utilizationColor` 语义一致)。
@@ -321,6 +378,95 @@ fn sort_providers(
sorted
}
/// 处理项目 Profile 托盘事件,返回是否已处理
///
/// 事件 id 形如 `profile_<scope>_<uuid>`(同一项目在各分组子菜单里各有一项,
/// 应用时只作用于该分组);`profile_none_<scope>` 表示某分组"不使用项目"
/// (只清该分组标记,不动配置)。
pub fn handle_profile_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool {
let Some(suffix) = event_id.strip_prefix("profile_") else {
return false;
};
if let Some(scope_str) = suffix.strip_prefix("none_") {
let Ok(scope) = crate::services::profile::ProfileScope::parse(scope_str) else {
log::error!("未知的项目分组托盘事件: {event_id}");
return true;
};
if let Some(app_state) = app.try_state::<AppState>() {
if let Err(e) = app_state.db.set_current_profile_id(scope.as_str(), None) {
log::error!("清除当前项目失败: {e}");
}
}
// 通知主窗口刷新(profileId=null 表示该分组已清除当前项目)
if let Err(e) = app.emit(
"profile-applied",
serde_json::json!({ "profileId": null, "scope": scope.as_str() }),
) {
log::error!("发射 profile-applied 事件失败: {e}");
}
refresh_tray_menu(app);
return true;
}
// scope 是固定枚举字符串(不含下划线),uuid 只含连字符,首个下划线即分界
let Some((scope_str, profile_id)) = suffix.split_once('_') else {
log::error!("无法解析项目托盘事件: {event_id}");
return true;
};
let Ok(scope) = crate::services::profile::ProfileScope::parse(scope_str) else {
log::error!("未知的项目分组托盘事件: {event_id}");
return true;
};
log::info!("应用项目: {profile_id}{scope_str} 组)");
let app_handle = app.clone();
let profile_id = profile_id.to_string();
tauri::async_runtime::spawn_blocking(move || {
let Some(app_state) = app_handle.try_state::<AppState>() else {
return;
};
match crate::services::profile::ProfileService::apply(app_state.inner(), &profile_id, scope)
{
Ok((warnings, should_stop_proxy)) => {
for warning in &warnings {
log::warn!("[Profile] 应用项目 {profile_id} 警告: {warning}");
}
if should_stop_proxy {
let app_handle2 = app_handle.clone();
let proxy_service = app_state.proxy_service.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = proxy_service.stop().await {
log::warn!("托盘切换项目后停止代理服务失败: {e}");
}
if let Some(state) = app_handle2.try_state::<AppState>() {
crate::commands::emit_profile_apply_events(
&app_handle2,
state.inner(),
&profile_id,
scope,
);
}
});
} else {
crate::commands::emit_profile_apply_events(
&app_handle,
app_state.inner(),
&profile_id,
scope,
);
}
}
Err(e) => {
log::error!("应用项目 {profile_id} 失败: {e}");
refresh_tray_menu(&app_handle);
}
}
});
true
}
/// 处理供应商托盘事件
pub fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool {
for section in TRAY_SECTIONS.iter() {
@@ -493,7 +639,13 @@ pub fn create_tray_menu(
app_state: &AppState,
) -> Result<Menu<tauri::Wry>, AppError> {
let app_settings = crate::settings::get_settings();
let tray_texts = TrayTexts::from_language(app_settings.language.as_deref().unwrap_or("zh"));
// 用户未显式设置语言(首次安装)时,按系统区域回退而非硬编码简体,
// 否则繁中系统的托盘会固定显示简体直到用户手动切换一次。
let language: &str = match app_settings.language.as_deref() {
Some(lang) => lang,
None => detect_system_tray_language(),
};
let tray_texts = TrayTexts::from_language(language);
// Get visible apps setting, default to all visible
let visible_apps = app_settings.visible_apps.unwrap_or_default();
@@ -569,8 +721,12 @@ pub fn create_tray_menu(
for (id, provider) in sort_providers(&providers) {
let is_current = current_id == *id;
let is_official_blocked =
is_app_taken_over && provider.category.as_deref() == Some("official");
let is_official_blocked = is_app_taken_over
&& provider.category.as_deref() == Some("official")
&& !crate::services::provider::official_provider_supports_proxy_takeover(
&section.app_type,
provider,
);
let label = if is_official_blocked {
format!("{} \u{26D4}", &provider.name) // ⛔ emoji
} else {
@@ -600,6 +756,90 @@ pub fn create_tray_menu(
menu_builder = menu_builder.separator();
}
// 项目 Profile 子菜单:项目列表全应用共享,按分组嵌套子菜单各自勾选/应用
// (组内应用可见且存在项目时才显示该组)
{
use crate::services::profile::ProfileScope;
let any_scope_visible = ProfileScope::ALL.iter().any(|scope| {
scope
.apps()
.iter()
.any(|app_type| visible_apps.is_visible(app_type))
});
let profiles = if any_scope_visible {
app_state.db.get_all_profiles()?
} else {
Vec::new()
};
let mut scope_submenus = Vec::new();
for scope in ProfileScope::ALL {
if profiles.is_empty()
|| !scope
.apps()
.iter()
.any(|app_type| visible_apps.is_visible(app_type))
{
continue;
}
let current_profile_id = app_state
.db
.get_current_profile_id(scope.as_str())?
.unwrap_or_default();
// 分组标签用产品名,不进 i18n
let scope_label = match scope {
ProfileScope::Claude => "Claude Code",
ProfileScope::ClaudeDesktop => "Claude Desktop",
ProfileScope::Codex => "Codex",
};
let mut scope_builder = SubmenuBuilder::with_id(
app,
format!("submenu_profiles_{}", scope.as_str()),
scope_label,
);
for profile in &profiles {
let item = CheckMenuItem::with_id(
app,
format!("profile_{}_{}", scope.as_str(), profile.id),
&profile.name,
true,
current_profile_id == profile.id,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建项目菜单项失败: {e}")))?;
scope_builder = scope_builder.item(&item);
}
let none_item = CheckMenuItem::with_id(
app,
format!("profile_none_{}", scope.as_str()),
tray_texts.no_project_label,
true,
current_profile_id.is_empty(),
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建不使用项目菜单项失败: {e}")))?;
let scope_submenu = scope_builder
.separator()
.item(&none_item)
.build()
.map_err(|e| AppError::Message(format!("构建项目分组子菜单失败: {e}")))?;
scope_submenus.push(scope_submenu);
}
if !scope_submenus.is_empty() {
let mut profiles_builder =
SubmenuBuilder::with_id(app, "submenu_profiles", tray_texts.projects_label);
for scope_submenu in &scope_submenus {
profiles_builder = profiles_builder.item(scope_submenu);
}
let profiles_submenu = profiles_builder
.build()
.map_err(|e| AppError::Message(format!("构建项目子菜单失败: {e}")))?;
menu_builder = menu_builder.item(&profiles_submenu).separator();
}
}
let lightweight_item = CheckMenuItem::with_id(
app,
"lightweight_mode",
@@ -745,6 +985,9 @@ pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
app.exit(0);
}
_ => {
if handle_profile_tray_event(app, event_id) {
return;
}
if handle_provider_tray_event(app, event_id) {
return;
}
@@ -877,12 +1120,13 @@ pub(crate) async fn refresh_all_usage_in_tray(app: &tauri::AppHandle) {
#[cfg(test)]
mod tests {
use super::{format_script_summary, format_subscription_summary, TRAY_ID};
use super::{format_script_summary, format_subscription_summary, TRAY_ID, TRAY_SECTIONS};
use crate::app_config::AppType;
use crate::provider::{UsageData, UsageResult};
use crate::services::subscription::{
CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_GEMINI_FLASH,
TIER_GEMINI_FLASH_LITE, TIER_GEMINI_PRO, TIER_MONTHLY, TIER_SEVEN_DAY, TIER_SEVEN_DAY_OPUS,
TIER_SEVEN_DAY_SONNET, TIER_WEEKLY_LIMIT,
TIER_SEVEN_DAY_SONNET, TIER_THIRTY_DAY, TIER_WEEKLY_LIMIT,
};
#[test]
@@ -891,6 +1135,71 @@ mod tests {
assert_ne!(TRAY_ID, "main");
}
#[test]
fn locale_maps_traditional_chinese_variants_to_zh_tw() {
use super::map_locale_to_tray_language;
for locale in [
"zh-TW",
"zh-HK",
"zh-MO",
"zh-Hant",
"zh-Hant-TW",
"zh-hant-hk",
] {
assert_eq!(
map_locale_to_tray_language(locale),
"zh-TW",
"expected {locale} -> zh-TW"
);
}
}
#[test]
fn locale_maps_simplified_chinese_variants_to_zh() {
use super::map_locale_to_tray_language;
for locale in ["zh", "zh-CN", "zh-SG", "zh-Hans", "zh-Hans-CN"] {
assert_eq!(
map_locale_to_tray_language(locale),
"zh",
"expected {locale} -> zh"
);
}
}
#[test]
fn locale_maps_japanese_and_english() {
use super::map_locale_to_tray_language;
assert_eq!(map_locale_to_tray_language("ja-JP"), "ja");
assert_eq!(map_locale_to_tray_language("ja"), "ja");
assert_eq!(map_locale_to_tray_language("en-US"), "en");
assert_eq!(map_locale_to_tray_language("en"), "en");
}
#[test]
fn locale_unknown_falls_back_to_zh() {
use super::map_locale_to_tray_language;
// 与前端 getInitialLanguage 的默认值保持一致。
for locale in ["de-DE", "fr", "ko-KR", ""] {
assert_eq!(
map_locale_to_tray_language(locale),
"zh",
"expected {locale} -> zh (default)"
);
}
}
#[test]
fn tray_sections_include_grokbuild_provider_switching() {
let section = TRAY_SECTIONS
.iter()
.find(|section| section.app_type == AppType::GrokBuild)
.expect("Grok Build tray section should exist");
assert_eq!(section.prefix, "grokbuild_");
assert_eq!(section.empty_id, "grokbuild_empty");
assert_eq!(section.header_label, "Grok Build");
}
fn make_quota(tool: &str, success: bool, tiers: Vec<QuotaTier>) -> SubscriptionQuota {
SubscriptionQuota {
tool: tool.to_string(),
@@ -964,6 +1273,16 @@ mod tests {
assert!(s.contains("l80%"), "expected l80% in {s}");
}
#[test]
fn codex_summary_thirty_day_only_still_renders() {
// Codex 免费方案的唯一 tier 是 30 天窗口。前端 footer 已能显示(TIER_I18N_KEYS
// 有 "30_day"),托盘也必须能显示——否则就是这条不变量要防的非对称:footer
// 能看到、托盘却空白。30_day 归入 "m" 月分组。见 #3651。
let quota = make_quota("codex", true, vec![tier(TIER_THIRTY_DAY, 85.0)]);
let s = format_subscription_summary(&quota).expect("should format");
assert!(s.contains("m85%"), "expected m85% in {s}");
}
#[test]
fn gemini_summary_emoji_reflects_highest_tier_including_lite() {
// lite 是利用率最高的那条 → emoji 必须是红色,不能被 pro/flash 掩盖。
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.16.4",
"version": "3.17.0",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
+8
View File
@@ -6,6 +6,14 @@ use cc_switch_lib::AppType;
fn parse_known_apps_case_insensitive_and_trim() {
assert!(matches!(AppType::from_str("claude"), Ok(AppType::Claude)));
assert!(matches!(AppType::from_str("codex"), Ok(AppType::Codex)));
assert!(matches!(
AppType::from_str("grokbuild"),
Ok(AppType::GrokBuild)
));
assert!(matches!(
AppType::from_str("Grok-Build"),
Ok(AppType::GrokBuild)
));
assert!(matches!(
AppType::from_str(" ClAuDe \n"),
Ok(AppType::Claude)
+38
View File
@@ -497,6 +497,42 @@ fn sync_enabled_to_codex_returns_error_on_invalid_toml() {
}
}
#[test]
fn sync_single_server_to_codex_fails_closed_on_invalid_toml() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let path = cc_switch_lib::get_codex_config_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create codex dir");
}
// 含用户内容 + 语法错误的 config.toml:同步必须报错且不得覆盖文件
let broken = "model = \"gpt-5.5\"\ninvalid = [\n";
fs::write(&path, broken).expect("write invalid config");
let config = MultiAppConfig::default();
let err = cc_switch_lib::sync_single_server_to_codex(
&config,
"srv",
&json!({ "type": "stdio", "command": "echo" }),
)
.expect_err("sync should fail instead of wiping the file");
match err {
cc_switch_lib::AppError::McpValidation(msg) => {
assert!(
msg.contains("config.toml"),
"error message should mention config.toml"
);
}
other => panic!("unexpected error: {other:?}"),
}
let text = fs::read_to_string(&path).expect("read config.toml");
assert_eq!(
text, broken,
"invalid config.toml must be left untouched on sync failure"
);
}
#[test]
fn sync_codex_provider_missing_auth_returns_error() {
let _guard = test_mutex().lock().expect("acquire test mutex");
@@ -712,6 +748,7 @@ command = "echo"
claude: false,
codex: false, // 初始未启用
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},
@@ -841,6 +878,7 @@ fn import_from_claude_merges_into_config() {
claude: false, // 初始未启用
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
hermes: false,
},

Some files were not shown because too many files have changed in this diff Show More