mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-01 04:02:02 +08:00
Merge main and harden managed Codex account flow
This commit is contained in:
Generated
+11
-1
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
+792
-36
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(
|
||||
// 其他供应商为 None,service 层沿用 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(¤t_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(())
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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健康状态
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 到 v15(Skills/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(©_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 5(list 价,与 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 / Luna,2026-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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}'"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 形式,错误响应若仍保留
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",全程不出现 image(issue #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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 found,openai/codex#31967,
|
||||
// 本机 A/B 实测确认)。两个头必须成对发送,缺一即 404;version 需 ≥ 目标模型
|
||||
// catalog 的 minimal_client_version(luna=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),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}\""));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(¤t_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
@@ -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_tokens(inclusive 上游)。
|
||||
// 与流式 build_anthropic_usage_json (#2774) 及 transform_gemini 的 saturating_sub 对称。
|
||||
// 最终 cache_read:直传字段优先于 nested;cache_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!({
|
||||
|
||||
@@ -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` /
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 Plan:GetAFPUsage
|
||||
match volcengine_openapi_call(®ion, 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_PROXY(http_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_LIMIT(unit 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ¤t_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,
|
||||
|
||||
@@ -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 导入 MCP(v3.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("; ")
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, ¤t_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(¤t, 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 diff(SkillService 返回 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(¤t, 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. Prompt(None = 不动;已激活则幂等跳过,避免无谓的文件写与备份)
|
||||
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(¤t, &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(¤t, &[]);
|
||||
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
@@ -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());
|
||||
|
||||
+2002
-313
File diff suppressed because it is too large
Load Diff
@@ -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()?;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("a_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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 时(如 backfill)SELECT `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()?;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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(
|
||||
§ion.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("a).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,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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user