Merge remote-tracking branch 'origin/main' into feature/managed-oauth-account-selector

# Conflicts:
#	src-tauri/src/services/provider/live.rs
#	src-tauri/src/services/proxy.rs
This commit is contained in:
Jason
2026-06-15 15:04:01 +08:00
143 changed files with 14006 additions and 4259 deletions
+1 -1
View File
@@ -735,7 +735,7 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.16.1"
version = "3.16.3"
dependencies = [
"anyhow",
"arboard",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.16.1"
version = "3.16.3"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"
+159 -9
View File
@@ -60,6 +60,16 @@ pub const DEFAULT_PROXY_ROUTES: &[ClaudeDesktopDefaultRoute] = &[
env_key: "ANTHROPIC_DEFAULT_HAIKU_MODEL",
supports_1m: true,
},
// fable 置于末尾:next_catalog_safe_route_id 给非安全品牌 route 借用合法
// 角色名时仍按 sonnet→opus→haiku 顺序分配(向后兼容既有 catalog),不会把
// 无关品牌模型借用成 fable 顶配档名。UI 行序由前端 ROLE_ORDER 独立控制为
// Sonnet/Opus/Fable/Haiku(所有 proxy 路径都经 normalizeProxyRows 重排),
// 与此处物理顺序无关。
ClaudeDesktopDefaultRoute {
route_id: "claude-fable-5",
env_key: "ANTHROPIC_DEFAULT_FABLE_MODEL",
supports_1m: true,
},
];
#[derive(Debug, Clone)]
@@ -238,11 +248,16 @@ pub fn is_claude_safe_model_id(model: &str) -> bool {
// 角色前缀后必须还有实际模型标识,拒绝 claude-sonnet- 这类退化值
// (否则会写入 profile 并触发 Claude Desktop fail-all 拒收整组)。
["sonnet-", "opus-", "haiku-"].iter().any(|prefix| {
route_tail
.strip_prefix(prefix)
.is_some_and(|rest| !rest.is_empty())
})
// Claude Desktop 1.12603.1+ 的 fail-all validator 角色白名单已纳入 fable
// app.asar 内 ["sonnet","opus","haiku","fable","mythos"]),故 claude-fable-*
// 可安全写入 profile。mythos 官方未公开发布,暂不暴露给用户。
["sonnet-", "opus-", "haiku-", "fable-"]
.iter()
.any(|prefix| {
route_tail
.strip_prefix(prefix)
.is_some_and(|rest| !rest.is_empty())
})
}
fn inference_model_json(spec: &InferenceModelSpec) -> Value {
@@ -693,8 +708,8 @@ pub fn map_proxy_request_model(mut body: Value, provider: &Provider) -> Result<V
.or_else(|| {
// 角色关键词回落:Claude Desktop 的部分调用(如子 agent)会请求带发布
// 日期后缀的完整官方名(claude-haiku-4-5-20251001),与 manifest 暴露的
// 简短 route_id(claude-haiku-4-5)不精确相等。按 opus/haiku/sonnet 归类
// 到同档已配置路由,对齐 Claude Code model_mapper 的宽松匹配。
// 简短 route_id(claude-haiku-4-5)不精确相等。按 opus/haiku/fable/sonnet
// 归类到同档已配置路由,对齐 Claude Code model_mapper 的宽松匹配。
// 匹配前已剥离本地 [1m] 标记;这里仍只对 Claude Desktop 认可的
// 安全模型名回落,避免非 Claude route 被误映射。
if !is_claude_safe_model_id(requested) {
@@ -704,6 +719,18 @@ pub fn map_proxy_request_model(mut body: Value, provider: &Provider) -> Result<V
routes
.iter()
.find(|route| claude_role_keyword(&route.route_id) == Some(role))
// 老用户只配了 Sonnet/Opus/Haiku 三档时,fable 请求降级到 opus 档,
// 与官方安全分类器的降级方向一致,避免 route_unknown 硬错误。
// 用户一旦显式配置 fable 档,上面的精确角色匹配会优先命中。
.or_else(|| {
(role == "fable")
.then(|| {
routes
.iter()
.find(|route| claude_role_keyword(&route.route_id) == Some("opus"))
})
.flatten()
})
.map(|route| route.upstream_model.clone())
})
.ok_or_else(|| {
@@ -754,15 +781,17 @@ fn is_compatible_opus_route_alias(route_id: &str, requested: &str) -> bool {
)
}
/// 按角色关键词(opus / haiku / sonnet)归类一个 Claude 模型名/route_id。
/// 按角色关键词(opus / haiku / fable / sonnet)归类一个 Claude 模型名/route_id。
/// 仅在命中明确角色词时返回 Some,未知模型返回 None(不回落,保持精确报错语义)。
/// 与前端 `routeRoleFromId` 同序(opus → haiku → sonnet)。
/// 与前端 `routeRoleFromId` 同序(opus → haiku → fable → sonnet)。
fn claude_role_keyword(model: &str) -> Option<&'static str> {
let normalized = model.to_ascii_lowercase();
if normalized.contains("opus") {
Some("opus")
} else if normalized.contains("haiku") {
Some("haiku")
} else if normalized.contains("fable") {
Some("fable")
} else if normalized.contains("sonnet") {
Some("sonnet")
} else {
@@ -1650,6 +1679,127 @@ mod tests {
assert!(err.to_string().contains("gpt-5"));
}
#[test]
fn claude_desktop_proxy_maps_fable_to_opus_tier() {
// issue #4026/#4049:老用户只配 Sonnet/Opus/Haiku 三档、未显式配置
// fable 档时,fable 请求按官方分类器降级方向回落到 opus 档兜底。
let mut provider = proxy_provider("proxy");
provider
.meta
.as_mut()
.expect("meta")
.claude_desktop_model_routes = std::collections::HashMap::from([
(
"claude-opus-4-8".to_string(),
ClaudeDesktopModelRoute {
model: "upstream-opus".to_string(),
label_override: None,
supports_1m: Some(true),
},
),
(
"claude-sonnet-4-6".to_string(),
ClaudeDesktopModelRoute {
model: "upstream-sonnet".to_string(),
label_override: None,
supports_1m: Some(true),
},
),
]);
let mapped = map_proxy_request_model(
json!({"model": "claude-fable-5", "messages": []}),
&provider,
)
.expect("fable should fall back to the opus tier");
assert_eq!(mapped["model"], json!("upstream-opus"));
// 带 [1m] 标记与日期后缀的形态也应命中同一回落。
let mapped_one_m = map_proxy_request_model(
json!({"model": "claude-fable-5[1m]", "messages": []}),
&provider,
)
.expect("fable with [1m] marker should fall back to the opus tier");
assert_eq!(mapped_one_m["model"], json!("upstream-opus"));
let mapped_dated = map_proxy_request_model(
json!({"model": "claude-fable-5-20260609", "messages": []}),
&provider,
)
.expect("dated fable alias should fall back to the opus tier");
assert_eq!(mapped_dated["model"], json!("upstream-opus"));
}
#[test]
fn claude_desktop_proxy_fable_without_opus_route_still_errors() {
// 没有 opus 档可回落时保持精确报错语义,不静默落到其他档。
let mut provider = proxy_provider("proxy");
provider
.meta
.as_mut()
.expect("meta")
.claude_desktop_model_routes = std::collections::HashMap::from([(
"claude-sonnet-4-6".to_string(),
ClaudeDesktopModelRoute {
model: "upstream-sonnet".to_string(),
label_override: None,
supports_1m: Some(true),
},
)]);
let err = map_proxy_request_model(
json!({"model": "claude-fable-5", "messages": []}),
&provider,
)
.expect_err("fable without an opus route should fail");
assert!(err.to_string().contains("claude-fable-5"));
}
#[test]
fn claude_desktop_proxy_maps_fable_to_dedicated_route() {
// Desktop 1.12603.1+ fail-all 校验已放行 claude-fable-5,用户可显式配置
// 独立 fable 档;此时 fable 请求精确命中 fable 档,不再降级到 opus。
let mut provider = proxy_provider("proxy");
provider
.meta
.as_mut()
.expect("meta")
.claude_desktop_model_routes = std::collections::HashMap::from([
(
"claude-opus-4-8".to_string(),
ClaudeDesktopModelRoute {
model: "upstream-opus".to_string(),
label_override: None,
supports_1m: Some(true),
},
),
(
"claude-fable-5".to_string(),
ClaudeDesktopModelRoute {
model: "upstream-fable".to_string(),
label_override: None,
supports_1m: Some(true),
},
),
]);
// 精确匹配优先命中 fable 档
let mapped = map_proxy_request_model(
json!({"model": "claude-fable-5", "messages": []}),
&provider,
)
.expect("explicit fable route should match");
assert_eq!(mapped["model"], json!("upstream-fable"));
// 带日期后缀经角色关键词回落仍归 fable 档,而非降级 opus
let mapped_dated = map_proxy_request_model(
json!({"model": "claude-fable-5-20260609", "messages": []}),
&provider,
)
.expect("dated fable alias should map via fable role keyword");
assert_eq!(mapped_dated["model"], json!("upstream-fable"));
}
#[test]
fn claude_desktop_proxy_accepts_opus_4_7_4_8_alias_during_rollout() {
let mut provider = proxy_provider("proxy");
+332 -1
View File
@@ -333,7 +333,10 @@ pub fn extract_codex_api_key(auth: Option<&Value>, config_text: Option<&str>) ->
/// Extract the upstream base URL from a Codex `config.toml` string.
///
/// Prefers the active `[model_providers.<model_provider>].base_url`, falling
/// back to a top-level `base_url` when no model provider is selected.
/// back to a top-level `base_url`. Deliberately never reads a non-active
/// `[model_providers.*]` section — the frontend `extractCodexBaseUrl`
/// (`getRecoverableBaseUrlAssignments`) excludes those too, and a leftover
/// section unrelated to the active provider must not leak into `{{baseUrl}}`.
pub fn extract_codex_base_url(config_text: &str) -> Option<String> {
let doc = config_text.parse::<toml::Value>().ok()?;
@@ -1165,16 +1168,197 @@ pub fn read_codex_live_settings() -> Result<Value, AppError> {
Ok(json!({ "auth": auth, "config": cfg_text }))
}
/// `[model_providers.custom]` entry that makes an official (ChatGPT OAuth)
/// provider behave like Codex's built-in `openai` entry while running under
/// the shared custom id: `requires_openai_auth` routes auth to the ChatGPT
/// login in `auth.json` (base_url then defaults to the official Codex
/// backend), `name = "OpenAI"` keeps Codex's `is_openai()` feature gates
/// (web search, remote compaction), and `supports_websockets` restores the
/// built-in default that custom entries otherwise lose.
fn codex_unified_official_provider_table() -> toml_edit::Table {
let mut table = toml_edit::Table::new();
table["name"] = toml_edit::value("OpenAI");
table["requires_openai_auth"] = toml_edit::value(true);
table["supports_websockets"] = toml_edit::value(true);
table["wire_api"] = toml_edit::value("responses");
table
}
fn table_matches_codex_unified_official_provider(table: &toml_edit::Table) -> bool {
table.len() == 4
&& table.get("name").and_then(|item| item.as_str()) == Some("OpenAI")
&& table
.get("requires_openai_auth")
.and_then(|item| item.as_bool())
== Some(true)
&& table
.get("supports_websockets")
.and_then(|item| item.as_bool())
== Some(true)
&& table.get("wire_api").and_then(|item| item.as_str()) == Some("responses")
}
/// 统一 Codex 会话历史:把官方供应商的 live 配置改写为以共享的
/// `custom` model_provider 标识运行(认证仍走 `auth.json` 的 ChatGPT 登录),
/// 使开关开启后创建的官方会话与第三方会话共用同一个 resume 历史桶。
///
/// 两种情况拒绝注入、原样返回:
/// - 配置已有显式 `model_provider`:用户手工指定的路由不被覆盖;
/// - 配置已有形态不同的 `[model_providers.custom]` 表:设置 `model_provider`
/// 会激活这张我们不认识的表(可能带第三方 base_url/token,会把 ChatGPT
/// OAuth 流量路由到错误后端),宁可让开关对该配置不生效。
pub fn inject_codex_unified_session_bucket(config_text: &str) -> Result<String, AppError> {
let mut doc = config_text
.parse::<DocumentMut>()
.map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
if doc.get("model_provider").is_some() {
return Ok(config_text.to_string());
}
let existing_custom_conflicts = doc
.get("model_providers")
.and_then(|item| item.as_table())
.and_then(|providers| providers.get(CC_SWITCH_CODEX_MODEL_PROVIDER_ID))
.and_then(|item| item.as_table())
.is_some_and(|table| !table_matches_codex_unified_official_provider(table));
if existing_custom_conflicts {
log::warn!(
"官方 Codex 配置已存在自定义 [model_providers.custom],跳过统一会话路由注入以避免激活未知路由"
);
return Ok(config_text.to_string());
}
doc["model_provider"] = toml_edit::value(CC_SWITCH_CODEX_MODEL_PROVIDER_ID);
if doc.get("model_providers").is_none() {
let mut parent = toml_edit::Table::new();
parent.set_implicit(true);
doc["model_providers"] = toml_edit::Item::Table(parent);
}
if let Some(providers) = doc["model_providers"].as_table_mut() {
if !providers.contains_key(CC_SWITCH_CODEX_MODEL_PROVIDER_ID) {
providers.insert(
CC_SWITCH_CODEX_MODEL_PROVIDER_ID,
toml_edit::Item::Table(codex_unified_official_provider_table()),
);
}
}
Ok(doc.to_string())
}
/// `inject_codex_unified_session_bucket` 的反向操作:从配置文本里剥掉注入的
/// 统一会话路由,保证切换回填不会把它带进数据库的存储配置(关闭开关后
/// 切换即可完全还原)。仅当形态与注入产物完全一致时才剥离;第三方模板和
/// 用户自定义的 `custom` 条目(带 base_url 等差异字段)原样保留。
pub fn strip_codex_unified_session_bucket(config_text: &str) -> Result<String, AppError> {
if !config_text.contains("model_provider") {
return Ok(config_text.to_string());
}
let mut doc = config_text
.parse::<DocumentMut>()
.map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
if doc.get("model_provider").and_then(|item| item.as_str())
!= Some(CC_SWITCH_CODEX_MODEL_PROVIDER_ID)
{
return Ok(config_text.to_string());
}
let matches_injected = doc
.get("model_providers")
.and_then(|item| item.as_table())
.and_then(|providers| providers.get(CC_SWITCH_CODEX_MODEL_PROVIDER_ID))
.and_then(|item| item.as_table())
.is_some_and(table_matches_codex_unified_official_provider);
if !matches_injected {
return Ok(config_text.to_string());
}
doc.as_table_mut().remove("model_provider");
let providers_empty = doc["model_providers"]
.as_table_mut()
.map(|providers| {
providers.remove(CC_SWITCH_CODEX_MODEL_PROVIDER_ID);
providers.is_empty()
})
.unwrap_or(false);
if providers_empty {
doc.as_table_mut().remove("model_providers");
}
Ok(doc.to_string())
}
/// 统一会话开关开启时,把官方供应商 `{ auth, config }` 设置对象中的
/// config 文本注入共享 custom 路由;开关关闭或非官方供应商时不做改动。
///
/// 普通 live 写入(`write_codex_live_for_provider`)与代理接管备份
/// `update_live_backup_from_provider`)两条落盘路径共用:接管期间
/// live 归代理所有,注入必须进备份,接管释放恢复的 live 才带统一路由。
pub fn apply_codex_unified_session_bucket_to_settings(
category: Option<&str>,
settings: &mut Value,
) -> Result<(), AppError> {
if category != Some("official") || !crate::settings::unify_codex_session_history() {
return Ok(());
}
let config_text = settings
.get("config")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string();
let injected = inject_codex_unified_session_bucket(&config_text)?;
if injected != config_text {
if let Some(obj) = settings.as_object_mut() {
obj.insert("config".to_string(), Value::String(injected));
}
}
Ok(())
}
/// Backfill helper: strip the unified-session injection from a live
/// `{ auth, config }` settings object before it is stored back to the DB.
pub fn strip_codex_unified_session_bucket_from_settings(
settings: &mut Value,
) -> Result<(), AppError> {
let Some(config_text) = settings
.get("config")
.and_then(|value| value.as_str())
.map(str::to_string)
else {
return Ok(());
};
let stripped = strip_codex_unified_session_bucket(&config_text)?;
if stripped != config_text {
if let Some(obj) = settings.as_object_mut() {
obj.insert("config".to_string(), Value::String(stripped));
}
}
Ok(())
}
/// Route a Codex live write between full auth+config or config-only.
///
/// Official providers with usable login material own `auth.json`. Third-party
/// providers only touch `config.toml` when the compatibility setting is enabled
/// so the user's ChatGPT login cache survives provider switches.
///
/// 统一会话开关开启时,官方配置在落盘前注入共享的 `custom` 路由
/// (见 `inject_codex_unified_session_bucket`)。
pub fn write_codex_live_for_provider(
category: Option<&str>,
auth: &Value,
config_text: Option<&str>,
) -> Result<(), AppError> {
let unified_official_config =
if category == Some("official") && crate::settings::unify_codex_session_history() {
Some(inject_codex_unified_session_bucket(
config_text.unwrap_or(""),
)?)
} else {
None
};
let config_text = unified_official_config.as_deref().or(config_text);
let should_write_auth = (category == Some("official") && codex_auth_has_login_material(auth))
|| (category != Some("official")
&& !crate::settings::preserve_codex_official_auth_on_switch());
@@ -1409,6 +1593,153 @@ mod tests {
}
}
#[test]
fn unified_session_bucket_injects_for_empty_official_config() {
let injected = inject_codex_unified_session_bucket("").expect("inject");
let doc: toml::Table = toml::from_str(&injected).expect("parse injected config");
assert_eq!(
doc.get("model_provider").and_then(|v| v.as_str()),
Some(CC_SWITCH_CODEX_MODEL_PROVIDER_ID)
);
let custom = doc["model_providers"][CC_SWITCH_CODEX_MODEL_PROVIDER_ID]
.as_table()
.expect("custom provider table");
assert_eq!(custom.get("name").and_then(|v| v.as_str()), Some("OpenAI"));
assert_eq!(
custom.get("requires_openai_auth").and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
custom.get("supports_websockets").and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
custom.get("wire_api").and_then(|v| v.as_str()),
Some("responses")
);
}
#[test]
fn unified_session_bucket_preserves_other_keys_and_explicit_routing() {
let with_catalog = "model_catalog_json = \"cc-switch-model-catalog.json\"\n";
let injected = inject_codex_unified_session_bucket(with_catalog).expect("inject");
assert!(injected.contains("model_catalog_json"));
assert!(injected.contains("model_provider = \"custom\""));
// 用户显式指定过 model_provider 的官方配置不被覆盖
let explicit = "model_provider = \"openai_https\"\n";
let unchanged = inject_codex_unified_session_bucket(explicit).expect("inject");
assert_eq!(unchanged, explicit);
}
#[test]
fn unified_session_bucket_skips_conflicting_custom_table() {
// 残留的非注入形态 custom 表:设置 model_provider 会把官方流量
// 路由到表里的第三方端点,必须整体拒绝注入。
let stale = r#"[model_providers.custom]
name = "Relay"
base_url = "https://relay.example/v1"
"#;
let unchanged = inject_codex_unified_session_bucket(stale).expect("inject");
assert_eq!(unchanged, stale);
// 已是注入形态的 custom 表(如重复注入)则照常补上 model_provider
let injected_once = inject_codex_unified_session_bucket("").expect("inject");
let reinjected = inject_codex_unified_session_bucket(&injected_once).expect("re-inject");
assert_eq!(reinjected, injected_once);
}
#[test]
fn unified_session_bucket_strip_round_trips_injection() {
let injected = inject_codex_unified_session_bucket("").expect("inject");
let stripped = strip_codex_unified_session_bucket(&injected).expect("strip");
assert_eq!(stripped.trim(), "");
let with_catalog = "model_catalog_json = \"cc-switch-model-catalog.json\"\n";
let injected = inject_codex_unified_session_bucket(with_catalog).expect("inject");
let stripped = strip_codex_unified_session_bucket(&injected).expect("strip");
assert_eq!(stripped, with_catalog);
}
#[test]
fn unified_session_bucket_strip_keeps_third_party_custom_entry() {
// 第三方模板同样用 custom 路由,但条目带 base_url 等差异字段,
// 形态不等于注入产物,必须原样保留。
let third_party = r#"model_provider = "custom"
[model_providers.custom]
name = "Relay"
base_url = "https://relay.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#;
let untouched = strip_codex_unified_session_bucket(third_party).expect("strip");
assert_eq!(untouched, third_party);
}
#[test]
fn unified_session_bucket_strip_from_settings_only_touches_config() {
let injected = inject_codex_unified_session_bucket("").expect("inject");
let mut settings = json!({
"auth": { "tokens": { "access_token": "secret" } },
"config": injected,
});
strip_codex_unified_session_bucket_from_settings(&mut settings).expect("strip settings");
assert_eq!(
settings
.get("config")
.and_then(|v| v.as_str())
.map(str::trim),
Some("")
);
assert!(settings.pointer("/auth/tokens/access_token").is_some());
}
#[test]
fn extract_base_url_prefers_active_provider_section() {
let input = r#"model_provider = "azure"
[model_providers.azure]
base_url = "https://azure.example.com/v1"
[model_providers.other]
base_url = "https://other.example.com/v1"
"#;
assert_eq!(
extract_codex_base_url(input).as_deref(),
Some("https://azure.example.com/v1")
);
}
#[test]
fn extract_base_url_falls_back_to_top_level_only() {
let top_level = r#"base_url = "https://top-level.example.com/v1""#;
assert_eq!(
extract_codex_base_url(top_level).as_deref(),
Some("https://top-level.example.com/v1")
);
}
// Mirrors the frontend extractCodexBaseUrl: a non-active provider section
// is never a credential source, whether the active provider points
// elsewhere (e.g. the built-in "openai") or none is selected at all.
#[test]
fn extract_base_url_ignores_non_active_provider_sections() {
let mismatched = r#"model_provider = "openai"
[model_providers.custom]
base_url = "https://leftover.example.com/v1"
"#;
assert_eq!(extract_codex_base_url(mismatched), None);
let no_active = r#"[model_providers.any]
base_url = "https://single.example.com/v1"
"#;
assert_eq!(extract_codex_base_url(no_active), None);
}
#[test]
fn prepare_provider_live_config_rejects_key_without_config() {
let err = prepare_codex_provider_live_config(&json!({"OPENAI_API_KEY": "sk-test"}), "")
+865 -6
View File
@@ -10,7 +10,8 @@ use crate::config::{atomic_write, copy_file, get_app_config_dir};
use crate::database::{is_official_seed_id, Database};
use crate::error::AppError;
use crate::settings::{
CodexProviderTemplateMigration, CodexThirdPartyHistoryProviderBucketMigration,
CodexOfficialHistoryUnifyMigration, CodexProviderTemplateMigration,
CodexThirdPartyHistoryProviderBucketMigration,
};
use chrono::{Local, Utc};
use rusqlite::{backup::Backup, params_from_iter, Connection};
@@ -24,7 +25,25 @@ use std::time::{Duration, SystemTime};
use toml_edit::DocumentMut;
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;
/// 串行化官方历史的迁移与还原:开启迁移(启动重试 + 设置保存后台任务)和
/// 关闭还原可能在毫秒级先后被触发,对同一批 jsonl / state DB 双向改写。
static CODEX_OFFICIAL_HISTORY_OP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn lock_codex_official_history_op() -> std::sync::MutexGuard<'static, ()> {
CODEX_OFFICIAL_HISTORY_OP_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
/// Codex 内建默认 provider idconfig.toml 没有 `model_provider` 键时会话归入此桶。
/// 官方订阅(ChatGPT OAuth / OpenAI API key)的历史会话都记录这个 id。
const OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID: &str = "openai";
const LEGACY_CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "ccswitch";
// If a Codex preset ever used a temporary routing key, keep that old key here
// so local history can be bucketed under the current custom provider id.
@@ -120,7 +139,7 @@ pub fn maybe_migrate_codex_third_party_history_provider_bucket(
});
}
let backup_root = migration_backup_root();
let backup_root = migration_backup_root(MIGRATION_NAME);
let codex_dir = get_codex_config_dir();
let migrated_jsonl_files =
migrate_codex_jsonl_files(&codex_dir, &source_provider_ids, &backup_root)?;
@@ -157,7 +176,7 @@ pub fn maybe_migrate_codex_provider_template_bucket(
});
}
let backup_root = migration_backup_root();
let backup_root = migration_backup_root(MIGRATION_NAME);
let outcome = migrate_codex_provider_templates_to_custom(db, &backup_root)?;
crate::settings::mark_codex_provider_template_migrated(CodexProviderTemplateMigration {
completed_at: Utc::now().to_rfc3339(),
@@ -167,6 +186,475 @@ pub fn maybe_migrate_codex_provider_template_bucket(
Ok(outcome)
}
/// 统一会话开关的存量迁移:把官方会话(内建 "openai" 桶)迁入共享 "custom" 桶。
///
/// 仅当用户在开启弹窗里勾选了"迁入既有官方会话"`unify_codex_migrate_existing`
/// 且本轮未完成时执行;开关关闭时标记与勾选意愿都会被清除(见 `save_settings`),
/// 重新开启并再次勾选即可补迁关闭期间产生的官方会话。
/// custom 桶里官方与第三方会话无法区分,自动逻辑绝不反向搬回;
/// 用户可在关闭开关时选择按备份账本精确还原(见 `restore_codex_official_history_from_backups`)。
/// 迁移前 jsonl / state DB 均备份到 `~/.cc-switch/backups/codex-official-history-unify-v1/`。
pub fn maybe_migrate_codex_official_history_to_unified_bucket(
) -> Result<CodexHistoryProviderBucketMigrationOutcome, AppError> {
if !crate::settings::unify_codex_session_history() {
return Ok(CodexHistoryProviderBucketMigrationOutcome {
skipped_reason: Some("unify_toggle_off".to_string()),
..Default::default()
});
}
if !crate::settings::unify_codex_migrate_existing_requested() {
return Ok(CodexHistoryProviderBucketMigrationOutcome {
skipped_reason: Some("stock_migration_not_requested".to_string()),
..Default::default()
});
}
let _op_guard = lock_codex_official_history_op();
let codex_dir = get_codex_config_dir();
// marker 绑定迁移时的 Codex 目录:切换 codex_config_dir 后旧 marker 不再
// 挡住新目录的迁移(迁移幂等,重跑无害)。
let codex_dir_key = canonical_dir_string(&codex_dir);
if crate::settings::is_codex_official_history_unify_migrated_for_dir(&codex_dir_key) {
return Ok(CodexHistoryProviderBucketMigrationOutcome {
skipped_reason: Some("already_migrated".to_string()),
..Default::default()
});
}
// live 必须已实际路由到共享 custom 桶才允许迁移:官方配置的注入可能被拒
// (已有显式 model_provider / 形态冲突的 custom 表,见
// `inject_codex_unified_session_bucket`),代理接管期间的 live 也不带统一
// 路由(注入只进备份)。这些状态下新会话仍落 "openai" 桶,迁移只会把
// 历史搬进当前 live 看不见的桶里。开关与迁移意愿保持不动,待 live 真正
// 统一后(下次切换 / 接管释放后的启动重试)再迁。
if !codex_config_text_routes_custom(&read_codex_config_text().unwrap_or_default()) {
return Ok(CodexHistoryProviderBucketMigrationOutcome {
skipped_reason: Some("live_not_unified".to_string()),
..Default::default()
});
}
let source_provider_ids: BTreeSet<String> =
std::iter::once(OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID.to_string()).collect();
let backup_root = migration_backup_root(OFFICIAL_UNIFY_MIGRATION_NAME);
let migrated_jsonl_files =
migrate_codex_jsonl_files(&codex_dir, &source_provider_ids, &backup_root)?;
let migrated_state_rows =
migrate_codex_state_dbs(&codex_dir, &source_provider_ids, &backup_root)?;
// 备份代际记录来源目录,restore 据此只取当前目录的账本。
write_backup_generation_meta(&backup_root, &codex_dir_key)?;
let outcome = CodexHistoryProviderBucketMigrationOutcome {
source_provider_ids: source_provider_ids.into_iter().collect(),
migrated_jsonl_files,
migrated_state_rows,
skipped_reason: None,
};
// 条件写入在 settings 写锁内原子完成:"迁移期间开关被关掉"时不写完成标记,
// 避免下一次开启被标记挡住而漏迁"关闭期间"新产生的 openai 桶会话。
// 与关闭路径(update_settings + 清标记)共用同一把锁,无检查-写入窗口。
let marker_written = crate::settings::mark_codex_official_history_unify_migrated_if_enabled(
CodexOfficialHistoryUnifyMigration {
completed_at: Utc::now().to_rfc3339(),
target_provider_id: CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string(),
migrated_jsonl_files,
migrated_state_rows,
codex_config_dir: Some(codex_dir_key),
},
)?;
if !marker_written {
return Ok(CodexHistoryProviderBucketMigrationOutcome {
skipped_reason: Some("toggle_disabled_during_migration".to_string()),
..outcome
});
}
Ok(outcome)
}
/// live config.toml 是否路由到共享 custom 桶(会话分桶只看这个实态:
/// base_url / 接管与否都不影响 session_meta 记录的 model_provider)。
fn codex_config_text_routes_custom(config_text: &str) -> bool {
config_text
.parse::<DocumentMut>()
.ok()
.and_then(|doc| {
doc.get("model_provider")
.and_then(|item| item.as_str())
.map(|id| id.trim() == CC_SWITCH_CODEX_MODEL_PROVIDER_ID)
})
.unwrap_or(false)
}
/// 目录的规范化字符串形式,用作 marker / 备份代际的目录身份。
/// canonicalize 失败(目录尚不存在等)时退回原始路径字符串。
fn canonical_dir_string(dir: &Path) -> String {
fs::canonicalize(dir)
.unwrap_or_else(|_| dir.to_path_buf())
.to_string_lossy()
.to_string()
}
/// 在备份代际根目录写入 meta.json,记录这批备份来自哪个 Codex 目录。
/// 代际目录不存在(本轮没有任何文件被迁移)时跳过。
fn write_backup_generation_meta(backup_root: &Path, codex_dir_key: &str) -> Result<(), AppError> {
if !backup_root.exists() {
return Ok(());
}
let payload = serde_json::json!({ "codexConfigDir": codex_dir_key });
let bytes =
serde_json::to_vec_pretty(&payload).map_err(|e| AppError::JsonSerialize { source: e })?;
atomic_write(&backup_root.join("meta.json"), &bytes)
}
#[derive(Debug, Clone, Default)]
pub struct CodexOfficialHistoryRestoreOutcome {
pub restored_jsonl_files: usize,
pub restored_state_rows: usize,
pub skipped_reason: Option<String>,
}
/// 统一会话开关迁移备份的父目录(其下每次迁移一个时间戳代际目录)。
fn official_history_unify_backup_parent() -> PathBuf {
get_app_config_dir()
.join("backups")
.join(OFFICIAL_UNIFY_MIGRATION_NAME)
}
/// 是否存在可用于还原的迁移备份(给前端决定要不要显示"恢复备份"勾选)。
/// 与 restore 的账本收集共用同一目录匹配口径:只认属于当前 Codex 目录的
/// 代际,避免切换 codex_config_dir 后弹出注定空跑的勾选。
/// 精确账本内容仍在真正还原时才解析。
pub fn has_codex_official_history_unify_backup() -> bool {
has_official_history_unify_backup_for_dir(
&official_history_unify_backup_parent(),
&canonical_dir_string(&get_codex_config_dir()),
)
}
fn has_official_history_unify_backup_for_dir(ledger_parent: &Path, codex_dir_key: &str) -> bool {
let Ok(entries) = fs::read_dir(ledger_parent) else {
return false;
};
entries.flatten().any(|entry| {
let generation = entry.path();
generation.is_dir() && backup_generation_matches_dir(&generation, codex_dir_key)
})
}
/// 关闭统一会话开关时的可选还原:按迁移备份账本,把当时迁入共享 custom 桶的
/// 官方会话精确翻回 "openai" 桶。
///
/// 备份是唯一可信的归属证据:备份里 model_provider=="openai" 的会话必定源自
/// 官方桶。开启期间新产生的会话不在任何备份里,**永不触碰**——它们可能来自
/// 第三方,方向无法判定(产品决策:宁可留在第三方历史)。
/// 扫描全部备份代际取并集,多次开关循环后仍能还原早期迁入的会话;
/// 还原前改动目标先备份到独立的 restore 目录(保持迁移账本目录纯净),
/// 且只改写当前仍为 custom 的目标,重复执行无害。
pub fn restore_codex_official_history_from_backups(
) -> Result<CodexOfficialHistoryRestoreOutcome, AppError> {
let _op_guard = lock_codex_official_history_op();
// 开关已(重新)开启时拒绝还原:live 正路由 custom,把账本会话翻回
// openai 桶等于亲手制造分裂。覆盖"关闭保存成功后用户立刻重新开启,
// 还原排在重开迁移之后才拿到 op lock"的时序。
if crate::settings::unify_codex_session_history() {
return Ok(CodexOfficialHistoryRestoreOutcome {
skipped_reason: Some("unify_toggle_on".to_string()),
..Default::default()
});
}
let config_text = read_codex_config_text().unwrap_or_default();
restore_codex_official_history_inner(
&get_codex_config_dir(),
&official_history_unify_backup_parent(),
&migration_backup_root(OFFICIAL_UNIFY_RESTORE_BACKUP_NAME),
&config_text,
)
}
fn restore_codex_official_history_inner(
codex_dir: &Path,
ledger_parent: &Path,
restore_backup_root: &Path,
config_text: &str,
) -> Result<CodexOfficialHistoryRestoreOutcome, AppError> {
let codex_dir_key = canonical_dir_string(codex_dir);
let (official_session_ids, official_thread_ids) =
collect_official_ledger(ledger_parent, &codex_dir_key)?;
if official_session_ids.is_empty() && official_thread_ids.is_empty() {
return Ok(CodexOfficialHistoryRestoreOutcome {
skipped_reason: Some("no_backup_ledger".to_string()),
..Default::default()
});
}
let mut files = Vec::new();
collect_jsonl_files(&codex_dir.join("sessions"), &mut files, 0, 8);
collect_jsonl_files(&codex_dir.join("archived_sessions"), &mut files, 0, 4);
let mut restored_jsonl_files = 0;
for file_path in files {
if rewrite_codex_session_file_lines(&file_path, codex_dir, restore_backup_root, |line| {
rewrite_codex_session_meta_line_for_restore(line, &official_session_ids)
})? {
restored_jsonl_files += 1;
}
}
let mut restored_state_rows = 0;
for db_path in codex_state_db_paths(codex_dir, config_text) {
restored_state_rows += restore_codex_state_db_official_threads(
&db_path,
codex_dir,
&official_thread_ids,
restore_backup_root,
)?;
}
if restored_jsonl_files == 0 && restored_state_rows == 0 {
// 账本非空但没有任何"当前仍为 custom"的目标(如重复还原):
// 以 reason 告知前端,避免误报"已还原 0 项"为成功。
return Ok(CodexOfficialHistoryRestoreOutcome {
skipped_reason: Some("nothing_to_restore".to_string()),
..Default::default()
});
}
Ok(CodexOfficialHistoryRestoreOutcome {
restored_jsonl_files,
restored_state_rows,
skipped_reason: None,
})
}
/// 从备份代际收集官方会话账本:jsonl 备份里 session_meta 为 "openai" 的
/// 会话 id + state DB 备份里 model_provider 为 "openai" 的 thread id。
/// 只采纳 meta.json 目录与当前 Codex 目录一致的代际,避免切换
/// codex_config_dir 后拿旧目录的账本作用到新目录。
/// 还原操作自身的备份(restore 目录)天然不会混入:那些副本里的 id 都是
/// custom,解析后贡献为空。
fn collect_official_ledger(
ledger_parent: &Path,
codex_dir_key: &str,
) -> Result<(HashSet<String>, BTreeSet<String>), AppError> {
let mut session_ids = HashSet::new();
let mut thread_ids = BTreeSet::new();
let entries = match fs::read_dir(ledger_parent) {
Ok(entries) => entries,
Err(_) => return Ok((session_ids, thread_ids)),
};
for entry in entries.flatten() {
let generation = entry.path();
if !generation.is_dir() {
continue;
}
if !backup_generation_matches_dir(&generation, codex_dir_key) {
continue;
}
let mut backup_files = Vec::new();
collect_jsonl_files(&generation.join("jsonl"), &mut backup_files, 0, 10);
for backup_file in backup_files {
collect_official_session_ids_from_backup(&backup_file, &mut session_ids);
}
let mut backup_dbs = Vec::new();
collect_files_with_extension(&generation.join("state"), "sqlite", &mut backup_dbs, 0, 4);
for backup_db in backup_dbs {
collect_official_thread_ids_from_backup(&backup_db, &mut thread_ids);
}
}
Ok((session_ids, thread_ids))
}
/// 备份代际是否属于指定 Codex 目录。无 meta.json 或解析失败时宽容接受:
/// 早期版本的备份没有 meta,而那个时期不存在切目录场景;误纳的代价也被
/// "按会话 id 精确匹配 + 仅改写 custom"双重条件兜底。
fn backup_generation_matches_dir(generation: &Path, codex_dir_key: &str) -> bool {
let Ok(text) = fs::read_to_string(generation.join("meta.json")) else {
return true;
};
serde_json::from_str::<Value>(&text)
.ok()
.and_then(|value| {
value
.get("codexConfigDir")
.and_then(Value::as_str)
.map(|dir| dir == codex_dir_key)
})
.unwrap_or(true)
}
fn collect_official_session_ids_from_backup(path: &Path, session_ids: &mut HashSet<String>) {
let Ok(content) = fs::read_to_string(path) else {
log::debug!("Failed to read unify backup file {}", path.display());
return;
};
for line in content.lines() {
if !line.contains("\"session_meta\"") || !line.contains("\"model_provider\"") {
continue;
}
let Ok(value) = serde_json::from_str::<Value>(line) else {
continue;
};
if value.get("type").and_then(Value::as_str) != Some("session_meta") {
continue;
}
let Some(payload) = value.get("payload") else {
continue;
};
if payload.get("model_provider").and_then(Value::as_str)
!= Some(OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID)
{
continue;
}
if let Some(session_id) = payload.get("id").and_then(Value::as_str) {
session_ids.insert(session_id.to_string());
}
}
}
fn collect_official_thread_ids_from_backup(db_path: &Path, thread_ids: &mut BTreeSet<String>) {
let conn =
match Connection::open_with_flags(db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) {
Ok(conn) => conn,
Err(err) => {
log::debug!(
"Failed to open unify backup state DB {}: {err}",
db_path.display()
);
return;
}
};
let has_threads = Database::table_exists(&conn, "threads").unwrap_or(false)
&& Database::has_column(&conn, "threads", "model_provider").unwrap_or(false);
if !has_threads {
return;
}
let Ok(mut stmt) = conn.prepare("SELECT id FROM threads WHERE model_provider = ?1") else {
return;
};
let Ok(rows) = stmt.query_map([OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID], |row| {
row.get::<_, String>(0)
}) else {
return;
};
for thread_id in rows.flatten() {
thread_ids.insert(thread_id);
}
}
fn collect_files_with_extension(
dir: &Path,
extension: &str,
files: &mut Vec<PathBuf>,
depth: u8,
max_depth: u8,
) {
if depth > max_depth || !dir.is_dir() {
return;
}
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_files_with_extension(&path, extension, files, depth + 1, max_depth);
} else if path.extension().and_then(|ext| ext.to_str()) == Some(extension) {
files.push(path);
}
}
}
fn rewrite_codex_session_meta_line_for_restore(
line: &str,
official_session_ids: &HashSet<String>,
) -> Option<String> {
if !line.contains("\"session_meta\"") || !line.contains("\"model_provider\"") {
return None;
}
let mut value: Value = serde_json::from_str(line).ok()?;
if value.get("type").and_then(Value::as_str) != Some("session_meta") {
return None;
}
let payload = value.get_mut("payload")?.as_object_mut()?;
if payload.get("model_provider")?.as_str()? != CC_SWITCH_CODEX_MODEL_PROVIDER_ID {
return None;
}
let session_id = payload.get("id")?.as_str()?;
if !official_session_ids.contains(session_id) {
return None;
}
payload.insert(
"model_provider".to_string(),
Value::String(OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID.to_string()),
);
serde_json::to_string(&value).ok()
}
fn restore_codex_state_db_official_threads(
db_path: &Path,
codex_dir: &Path,
official_thread_ids: &BTreeSet<String>,
backup_root: &Path,
) -> Result<usize, AppError> {
if !db_path.exists() || official_thread_ids.is_empty() {
return Ok(0);
}
let mut conn = Connection::open(db_path)
.map_err(|e| AppError::Database(format!("打开 Codex state DB 失败: {e}")))?;
conn.busy_timeout(Duration::from_secs(5))
.map_err(|e| AppError::Database(format!("设置 Codex state DB busy_timeout 失败: {e}")))?;
if !Database::table_exists(&conn, "threads")?
|| !Database::has_column(&conn, "threads", "model_provider")?
{
return Ok(0);
}
let ids: Vec<&String> = official_thread_ids.iter().collect();
let mut matching_rows: i64 = 0;
for chunk in ids.chunks(STATE_DB_ID_CHUNK) {
let placeholders = placeholders(chunk.len());
let count_sql = format!(
"SELECT COUNT(*) FROM threads WHERE model_provider = ? AND id IN ({placeholders})"
);
let mut values = Vec::with_capacity(chunk.len() + 1);
values.push(CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string());
values.extend(chunk.iter().map(|id| (*id).clone()));
let count: i64 = conn
.query_row(&count_sql, params_from_iter(values.iter()), |row| {
row.get(0)
})
.map_err(|e| AppError::Database(format!("统计 Codex state DB 待还原行失败: {e}")))?;
matching_rows += count;
}
if matching_rows == 0 {
return Ok(0);
}
backup_codex_state_db(db_path, codex_dir, backup_root, &conn)?;
let tx = conn
.transaction()
.map_err(|e| AppError::Database(format!("开启 Codex state DB 还原事务失败: {e}")))?;
let mut changed = 0;
for chunk in ids.chunks(STATE_DB_ID_CHUNK) {
let placeholders = placeholders(chunk.len());
let update_sql = format!(
"UPDATE threads SET model_provider = ? WHERE model_provider = ? AND id IN ({placeholders})"
);
let mut values = Vec::with_capacity(chunk.len() + 2);
values.push(OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID.to_string());
values.push(CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string());
values.extend(chunk.iter().map(|id| (*id).clone()));
changed += tx
.execute(&update_sql, params_from_iter(values.iter()))
.map_err(|e| AppError::Database(format!("还原 Codex state DB provider 失败: {e}")))?;
}
tx.commit()
.map_err(|e| AppError::Database(format!("提交 Codex state DB 还原事务失败: {e}")))?;
Ok(changed)
}
fn migrate_codex_provider_templates_to_custom(
db: &Database,
backup_root: &Path,
@@ -257,10 +745,10 @@ fn insert_known_cc_switch_legacy_source_id(ids: &mut BTreeSet<String>, provider_
}
}
fn migration_backup_root() -> PathBuf {
fn migration_backup_root(migration_name: &str) -> PathBuf {
get_app_config_dir()
.join("backups")
.join(MIGRATION_NAME)
.join(migration_name)
.join(Local::now().format("%Y%m%d_%H%M%S").to_string())
}
@@ -524,6 +1012,17 @@ fn rewrite_codex_session_file_for_provider_bucket(
codex_dir: &Path,
source_provider_ids: &HashSet<String>,
backup_root: &Path,
) -> Result<bool, AppError> {
rewrite_codex_session_file_lines(path, codex_dir, backup_root, |line| {
rewrite_codex_session_meta_line(line, source_provider_ids)
})
}
fn rewrite_codex_session_file_lines(
path: &Path,
codex_dir: &Path,
backup_root: &Path,
rewrite_line: impl Fn(&str) -> Option<String>,
) -> Result<bool, AppError> {
let metadata_before = fs::metadata(path).map_err(|e| AppError::io(path, e))?;
let modified_before = metadata_before.modified().ok();
@@ -537,7 +1036,7 @@ fn rewrite_codex_session_file_for_provider_bucket(
.strip_suffix('\n')
.map(|line| (line, "\n"))
.unwrap_or((segment, ""));
if let Some(next_line) = rewrite_codex_session_meta_line(line, source_provider_ids) {
if let Some(next_line) = rewrite_line(line) {
rewritten.push_str(&next_line);
changed = true;
} else {
@@ -820,6 +1319,39 @@ mod tests {
values.iter().map(|value| value.to_string()).collect()
}
#[test]
fn detects_custom_routed_codex_config_for_unify_gate() {
// 注入产物(官方 + 统一开关)
assert!(codex_config_text_routes_custom(
r#"model_provider = "custom"
[model_providers.custom]
name = "OpenAI"
requires_openai_auth = true
supports_websockets = true
wire_api = "responses"
"#
));
// 第三方供应商的常规 custom 路由(带 base_url)同样算已统一
assert!(codex_config_text_routes_custom(
r#"model_provider = "custom"
[model_providers.custom]
name = "AIHubMix"
base_url = "https://aihubmix.example/v1"
"#
));
// 注入被拒的形态:显式 openai 路由 / 无 model_provider(接管期间、空配置)
assert!(!codex_config_text_routes_custom(
"model_provider = \"openai\"\n"
));
assert!(!codex_config_text_routes_custom(
"base_url = \"http://127.0.0.1:15721/codex\"\n"
));
assert!(!codex_config_text_routes_custom(""));
assert!(!codex_config_text_routes_custom("not toml ["));
}
fn migrate_provider_templates_for_test(
db: &Database,
) -> (
@@ -1092,6 +1624,333 @@ base_url = "https://proxy.example/v1"
);
}
#[test]
fn simulates_official_history_unify_migration_end_to_end() {
let dir = tempdir().expect("tempdir");
let codex_dir = dir.path().join(".codex");
let backup_root = dir.path().join("backup");
fs::create_dir_all(&codex_dir).expect("create codex dir");
let source_provider_ids = source_ids(&[OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID]);
let session_dir = codex_dir.join("sessions/2026/06/12");
fs::create_dir_all(&session_dir).expect("create session dir");
let session_path = session_dir.join("official-sim.jsonl");
fs::write(
&session_path,
concat!(
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"openai\"}}\n",
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s2\",\"model_provider\":\"custom\"}}\n",
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s3\",\"model_provider\":\"my-private-relay\"}}\n",
"{\"type\":\"response_item\",\"payload\":{\"text\":\"openai\"}}\n",
),
)
.expect("write session");
let migrated_jsonl =
migrate_codex_jsonl_files(&codex_dir, &source_provider_ids, &backup_root)
.expect("migrate jsonl");
assert_eq!(migrated_jsonl, 1);
let session_text = fs::read_to_string(&session_path).expect("read session");
assert_eq!(
session_text
.matches("\"model_provider\":\"custom\"")
.count(),
2
);
assert!(!session_text.contains("\"model_provider\":\"openai\""));
assert!(session_text.contains("\"model_provider\":\"my-private-relay\""));
assert!(
session_text.contains("{\"type\":\"response_item\",\"payload\":{\"text\":\"openai\"}}")
);
assert!(backup_root
.join("jsonl/sessions/2026/06/12/official-sim.jsonl")
.exists());
// 第二次执行应当无事可做(幂等)
let rerun = migrate_codex_jsonl_files(&codex_dir, &source_provider_ids, &backup_root)
.expect("rerun migrate jsonl");
assert_eq!(rerun, 0);
let state_db_path = codex_dir.join(CODEX_STATE_DB_FILENAME);
let conn = Connection::open(&state_db_path).expect("open state db");
conn.execute_batch(
"CREATE TABLE threads (
id TEXT PRIMARY KEY,
model_provider TEXT NOT NULL
);
INSERT INTO threads (id, model_provider) VALUES
('openai-thread', 'openai'),
('custom-thread', 'custom'),
('manual-thread', 'my-private-relay');",
)
.expect("seed state db");
drop(conn);
let migrated_state_rows = migrate_codex_state_db_provider_bucket(
&state_db_path,
&codex_dir,
&source_provider_ids,
&backup_root,
)
.expect("migrate state db");
assert_eq!(migrated_state_rows, 1);
let conn = Connection::open(&state_db_path).expect("reopen state db");
let count_provider = |provider_id: &str| -> i64 {
conn.query_row(
"SELECT COUNT(*) FROM threads WHERE model_provider = ?1",
[provider_id],
|row| row.get(0),
)
.expect("count provider")
};
assert_eq!(count_provider("custom"), 2);
assert_eq!(count_provider("openai"), 0);
assert_eq!(count_provider("my-private-relay"), 1);
}
#[test]
fn restores_only_ledgered_official_sessions_from_backups() {
let dir = tempdir().expect("tempdir");
let codex_dir = dir.path().join(".codex");
let ledger_parent = dir.path().join("ledger");
let restore_backup_root = dir.path().join("restore-backup");
// 备份账本:一个代际,jsonl 备份里 s1 是 openaistate 备份里 t1 是 openai
let generation = ledger_parent.join("20260612_010101");
let backup_session_dir = generation.join("jsonl/sessions/2026/06/01");
fs::create_dir_all(&backup_session_dir).expect("create backup session dir");
fs::write(
backup_session_dir.join("official.jsonl"),
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"openai\"}}\n",
)
.expect("write backup session");
let backup_state_dir = generation.join("state");
fs::create_dir_all(&backup_state_dir).expect("create backup state dir");
let backup_db = Connection::open(backup_state_dir.join(CODEX_STATE_DB_FILENAME))
.expect("open backup db");
backup_db
.execute_batch(
"CREATE TABLE threads (id TEXT PRIMARY KEY, model_provider TEXT NOT NULL);
INSERT INTO threads (id, model_provider) VALUES ('t1', 'openai');",
)
.expect("seed backup db");
drop(backup_db);
// 当前数据:s1(账本内,custom)应还原;s2(开启期间新会话,不在账本)
// 与 s3(手工 relay)必须原样保留
let session_dir = codex_dir.join("sessions/2026/06/01");
fs::create_dir_all(&session_dir).expect("create session dir");
let official_path = session_dir.join("official.jsonl");
fs::write(
&official_path,
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"custom\"}}\n",
)
.expect("write official session");
let on_period_dir = codex_dir.join("sessions/2026/06/12");
fs::create_dir_all(&on_period_dir).expect("create on-period dir");
let on_period_path = on_period_dir.join("on-period.jsonl");
fs::write(
&on_period_path,
concat!(
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s2\",\"model_provider\":\"custom\"}}\n",
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s3\",\"model_provider\":\"my-private-relay\"}}\n",
),
)
.expect("write on-period session");
let state_db_path = codex_dir.join(CODEX_STATE_DB_FILENAME);
let conn = Connection::open(&state_db_path).expect("open state db");
conn.execute_batch(
"CREATE TABLE threads (id TEXT PRIMARY KEY, model_provider TEXT NOT NULL);
INSERT INTO threads (id, model_provider) VALUES
('t1', 'custom'),
('t2', 'custom'),
('t3', 'openai');",
)
.expect("seed state db");
drop(conn);
// 代际 meta 指向当前 Codex 目录:精确匹配分支生效(而非无 meta 的宽容分支)
fs::write(
generation.join("meta.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"codexConfigDir": canonical_dir_string(&codex_dir)
}))
.expect("serialize meta"),
)
.expect("write meta");
let outcome = restore_codex_official_history_inner(
&codex_dir,
&ledger_parent,
&restore_backup_root,
"",
)
.expect("restore");
assert_eq!(outcome.restored_jsonl_files, 1);
assert_eq!(outcome.restored_state_rows, 1);
assert!(outcome.skipped_reason.is_none());
let official_text = fs::read_to_string(&official_path).expect("read official");
assert!(official_text.contains("\"model_provider\":\"openai\""));
let on_period_text = fs::read_to_string(&on_period_path).expect("read on-period");
assert!(on_period_text.contains("\"id\":\"s2\",\"model_provider\":\"custom\""));
assert!(on_period_text.contains("\"model_provider\":\"my-private-relay\""));
let conn = Connection::open(&state_db_path).expect("reopen state db");
let provider_of = |thread_id: &str| -> String {
conn.query_row(
"SELECT model_provider FROM threads WHERE id = ?1",
[thread_id],
|row| row.get(0),
)
.expect("thread provider")
};
assert_eq!(provider_of("t1"), "openai");
assert_eq!(provider_of("t2"), "custom");
assert_eq!(provider_of("t3"), "openai");
drop(conn);
// 还原前的现场已备份到独立目录
assert!(restore_backup_root
.join("jsonl/sessions/2026/06/01/official.jsonl")
.exists());
assert!(restore_backup_root
.join("state")
.join(CODEX_STATE_DB_FILENAME)
.exists());
// 幂等:第二次还原无事可做
let rerun = restore_codex_official_history_inner(
&codex_dir,
&ledger_parent,
&dir.path().join("restore-backup-2"),
"",
)
.expect("rerun restore");
assert_eq!(rerun.restored_jsonl_files, 0);
assert_eq!(rerun.restored_state_rows, 0);
assert_eq!(rerun.skipped_reason.as_deref(), Some("nothing_to_restore"));
}
#[test]
fn restore_ignores_backup_generations_from_other_codex_dirs() {
let dir = tempdir().expect("tempdir");
let codex_dir = dir.path().join(".codex");
let ledger_parent = dir.path().join("ledger");
// 账本代际属于另一个 Codex 目录
let generation = ledger_parent.join("20260612_010101");
let backup_session_dir = generation.join("jsonl/sessions/2026/06/01");
fs::create_dir_all(&backup_session_dir).expect("create backup session dir");
fs::write(
backup_session_dir.join("official.jsonl"),
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"openai\"}}\n",
)
.expect("write backup session");
fs::write(
generation.join("meta.json"),
"{\n \"codexConfigDir\": \"/some/other/codex-dir\"\n}",
)
.expect("write meta");
let session_dir = codex_dir.join("sessions/2026/06/01");
fs::create_dir_all(&session_dir).expect("create session dir");
let session_path = session_dir.join("official.jsonl");
fs::write(
&session_path,
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"custom\"}}\n",
)
.expect("write session");
let outcome = restore_codex_official_history_inner(
&codex_dir,
&ledger_parent,
&dir.path().join("restore-backup"),
"",
)
.expect("restore");
assert_eq!(outcome.skipped_reason.as_deref(), Some("no_backup_ledger"));
let text = fs::read_to_string(&session_path).expect("read session");
assert!(text.contains("\"model_provider\":\"custom\""));
}
#[test]
fn backup_probe_only_counts_generations_for_current_dir() {
let dir = tempdir().expect("tempdir");
let ledger_parent = dir.path().join("ledger");
let codex_dir_key = "/current/codex-dir";
// 空父目录 / 父目录不存在:无备份
assert!(!has_official_history_unify_backup_for_dir(
&ledger_parent,
codex_dir_key
));
// 只有其他目录的代际:不算有备份
let other = ledger_parent.join("20260612_010101");
fs::create_dir_all(&other).expect("create generation");
fs::write(
other.join("meta.json"),
"{\n \"codexConfigDir\": \"/some/other/codex-dir\"\n}",
)
.expect("write meta");
assert!(!has_official_history_unify_backup_for_dir(
&ledger_parent,
codex_dir_key
));
// 无 meta 的早期代际:宽容接受(与 restore 的账本口径一致)
fs::create_dir_all(ledger_parent.join("20260612_020202")).expect("create legacy gen");
assert!(has_official_history_unify_backup_for_dir(
&ledger_parent,
codex_dir_key
));
// 精确匹配当前目录的代际
fs::remove_dir_all(ledger_parent.join("20260612_020202")).expect("remove legacy gen");
let matched = ledger_parent.join("20260612_030303");
fs::create_dir_all(&matched).expect("create matched gen");
fs::write(
matched.join("meta.json"),
format!("{{\n \"codexConfigDir\": \"{codex_dir_key}\"\n}}"),
)
.expect("write matched meta");
assert!(has_official_history_unify_backup_for_dir(
&ledger_parent,
codex_dir_key
));
}
#[test]
fn restore_skips_when_no_backup_ledger_exists() {
let dir = tempdir().expect("tempdir");
let codex_dir = dir.path().join(".codex");
let session_dir = codex_dir.join("sessions/2026/06/01");
fs::create_dir_all(&session_dir).expect("create session dir");
fs::write(
session_dir.join("session.jsonl"),
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"custom\"}}\n",
)
.expect("write session");
let outcome = restore_codex_official_history_inner(
&codex_dir,
&dir.path().join("missing-ledger"),
&dir.path().join("restore-backup"),
"",
)
.expect("restore");
assert_eq!(outcome.skipped_reason.as_deref(), Some("no_backup_ledger"));
assert_eq!(outcome.restored_jsonl_files, 0);
assert_eq!(outcome.restored_state_rows, 0);
let text = fs::read_to_string(session_dir.join("session.jsonl")).expect("read session");
assert!(text.contains("\"model_provider\":\"custom\""));
}
#[test]
fn rewrites_only_codex_session_meta_provider_ids() {
let dir = tempdir().expect("tempdir");
+389 -70
View File
@@ -1985,10 +1985,19 @@ fn anchored_official_update_command(tool: &str, bin_path: &str) -> Option<String
official_update_args(tool).map(|args| format!("{} {args}", win_quote_path_for_batch(bin_path)))
}
/// 哪些工具的"官方 self-update"优先于包管理器升级(生成 `<tool> update || <pkg-mgr>`)。
///
/// **codex 刻意不在此列**`codex update` 在 npm 安装上只是裸 `npm install -g
/// @openai/codex`(无 `@latest` / `--include=optional` / 不先卸载),却只检查 exit code、
/// 无条件打印 “Update ran successfully”。当 npm 把平台二进制 optional 依赖
/// `@openai/codex-<triple>` 漏装时它仍 **exit 0 假成功**,使外层 `||` 兜底被短路、损坏被
/// 成功 toast 掩盖(用户报告的 “Missing optional dependency” 即源于此)。因此 codex 一律走
/// npm 锚定升级;真正损坏(`runnable=false`)时由 `installs_anchored_command` 的门控改用
/// `codex_repair_command` 的 uninstall+install 自愈,而非交给 codex 自身的 self-update。
fn prefers_official_update(tool: &str, shell: LifecycleCommandShell) -> bool {
match shell {
LifecycleCommandShell::Posix => {
matches!(tool, "claude" | "codex" | "opencode" | "openclaw")
matches!(tool, "claude" | "opencode" | "openclaw")
}
LifecycleCommandShell::WindowsBatch => {
matches!(
@@ -1997,12 +2006,66 @@ fn prefers_official_update(tool: &str, shell: LifecycleCommandShell) -> bool {
// 安装方式探测失败弹交互 promptspawn npm.cmd 没传 shell:true);静默
// lifecycle 没有 stdin 会挂死,Windows 先锚到包管理器路径,等上游修了
// 再把 opencode 加回这里。
"claude" | "codex" | "openclaw"
"claude" | "openclaw"
)
}
}
}
/// Codex 平台分发包损坏的自愈命令。Codex 的 npm 包是「主包 `@openai/codex`(纯 JS
/// launcher+ 平台二进制 optional 依赖 `@openai/codex-<triple>`」的分发模式(同 esbuild/swc)。
/// 当平台二进制缺失时 codex 跑不起来——`enumerate_tool_installations` 跑 `--version` 会拿到
/// “Missing optional dependency” 的非 0 退出,标记 `runnable=false`。此状态下普通
/// `npm i -g @pkg@latest` 是 **no-op**npm 视 optional 依赖缺失为非致命,reify 又认为主包已是
/// 最新(外加半损坏留下的空 nested `node_modules` 残骸强化「tree 已满足」判断),不会补回平台
/// 二进制。唯一实测可靠的修复是先 `uninstall` 清掉残骸、再 `install` 装回完整的主包 + 平台二进制
/// (实测输出 `added 2 packages`)。
///
/// 锚定到与 codex 入口同目录的 npm(与升级路径一致,不依赖 GUI 非登录进程的 PATH)。`|| true`
/// 让 uninstall 失败(如 nvm 上对半损坏包静默返回非 0)不触发外层 `set -e` 中止,但随后的
/// install 若失败仍会被 `set -e` 捕获并上报给前端 toast。
///
/// **仅对会锚定到 sibling npm 的 node 管理器来源(nvm/fnm/mise/homebrew npm)生效**
/// `runnable=false` 是宽信号(权限 / node 版本 / 任意 `--version` 失败皆可触发),非 npm
/// 全局安装各有自己的二进制分发与修复方式,无脑套 npm uninstall+install 会出错——Homebrew
/// formulareal 在 `Cellar/`)本应 `brew upgrade codex`npm 够不到它反而旁路装第二份 npm
/// 全局 codexVolta/Bun 本应 `volta install`/`bun add`,且 `~/.bun/bin` 下没有 npm、
/// `sibling_bin` 会拼出不存在的路径;system/未知来源无可靠 sibling npm。这些来源一律返回
/// None,让上游继续走 source-specific 的 `anchored_command_from_paths`。白名单与
/// `package_manager_anchored_command_from_paths` 的 sibling-npm 分支对齐。
/// 刻意**不**额外用 `inst.error` 文本确认「确系缺二进制」:enumerate 只保留 stderr 末尾 4 行,
/// 而 codex.js 抛错的 "Missing optional dependency" 行会被尾部 node stack `at ...` 行挤出窗口
/// (实测用户原始错误即如此),强加该条件反而漏修真实缺包;对 npm 全局安装,uninstall+install
/// 对各类损坏都是合理且不会更糟的修复。
#[cfg(not(target_os = "windows"))]
fn codex_repair_command(bin_path: &str, real: &str) -> Option<String> {
// brew formulareal 在 Cellar)→ 不归 npm 管,交回 anchored 走 brew upgrade。
if brew_formula_from_path(real).is_some() {
return None;
}
// 只认会落到 sibling npm 的 node 管理器来源;volta/bun/system/未知交回 anchored。
if !matches!(
infer_install_source(Path::new(bin_path)),
"nvm" | "fnm" | "mise" | "homebrew"
) {
return None;
}
let npm = sibling_bin(bin_path, "npm")?;
let npm = quote_path_if_spaced(&npm);
let pkg = "@openai/codex";
Some(format!(
"{npm} uninstall -g {pkg} || true; {npm} i -g {pkg}@latest"
))
}
/// Windows 暂不做平台分发自愈:Windows 上 codex 的破坏模式不同(EPERM 文件锁 / 版本 bump
/// 残留,见 openai/codex#21872、#19824),且 `.bat` 链的错误处理与 POSIX `set -e` 语义不同,
/// 需要单独设计;先在本问题实际发生的 POSIX 平台落地。返回 None → 上游走正常锚定命令。
#[cfg(target_os = "windows")]
fn codex_repair_command(_bin_path: &str, _real: &str) -> Option<String> {
None
}
#[cfg(not(target_os = "windows"))]
fn package_manager_anchored_command_from_paths(
tool: &str,
@@ -2190,6 +2253,17 @@ fn default_install(installs: &[ToolInstallation]) -> Option<&ToolInstallation> {
fn installs_anchored_command(tool: &str, installs: &[ToolInstallation]) -> Option<String> {
let inst = default_install(installs)?;
let real = inst.real.to_string_lossy();
// Codex 平台分发包损坏自愈:主包在但平台二进制缺失时 codex 跑不起来
// runnable=false),此时正常锚定的 `npm i -g @latest` 是 no-op 修不好——改用
// uninstall+install 重装补回平台二进制。**但仅限会锚定到 sibling npm 的 node 管理器
// 来源**codex_repair_command 内按 source/real 收窄,brew/volta/bun/system 交回下方
// source-specific 锚定,避免误用 npm 重装)。runnable=true 的正常升级也走下方普通锚定
// 路径(且因 codex 不在 prefers_official_update,不会再跑会假成功掩盖损坏的 `codex update`)。
if tool == "codex" && !inst.runnable {
if let Some(cmd) = codex_repair_command(&inst.path, &real) {
return Some(cmd);
}
}
anchored_command_from_paths(tool, &inst.path, &real)
}
@@ -2620,29 +2694,58 @@ exec bash --norc --noprofile
result
}
/// macOS: Terminal.app
/// Escape a value as an AppleScript string literal.
#[cfg(target_os = "macos")]
fn launch_macos_terminal_app(script_file: &std::path::Path) -> Result<(), String> {
use std::process::Command;
fn applescript_string_literal(value: &str) -> String {
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
}
let applescript = format!(
r#"tell application "Terminal"
activate
do script "bash '{}'"
/// Build the launcher command literal used by AppleScript.
#[cfg(target_os = "macos")]
fn applescript_launcher_command(script_file: &std::path::Path) -> String {
applescript_string_literal(&format!(
"bash {}",
shell_single_quote(&script_file.to_string_lossy())
))
}
/// macOS: Terminal.app AppleScript.
/// A cold `activate` creates a default empty window before `do script` opens the command session.
/// Use `launch` for cold starts so `do script` can create the only new session without reusing restored windows.
#[cfg(target_os = "macos")]
fn build_macos_terminal_applescript(script_file: &std::path::Path) -> String {
format!(
r#"set launcher_script to {launcher}
set was_running to application "Terminal" is running
tell application "Terminal"
if was_running then
activate
do script launcher_script
else
launch
do script launcher_script
activate
end if
end tell"#,
script_file.display()
);
launcher = applescript_launcher_command(script_file)
)
}
/// Run AppleScript through `osascript -e` with shared error handling.
#[cfg(target_os = "macos")]
fn run_terminal_osascript(applescript: &str, terminal_label: &str) -> Result<(), String> {
use std::process::Command;
let output = Command::new("osascript")
.arg("-e")
.arg(&applescript)
.arg(applescript)
.output()
.map_err(|e| format!("执行 osascript 失败: {e}"))?;
if !output.status.success() {
let stderr = decode_command_output(&output.stderr);
return Err(format!(
"Terminal.app 执行失败 (exit code: {:?}): {}",
"{terminal_label} 执行失败 (exit code: {:?}): {}",
output.status.code(),
stderr
));
@@ -2651,11 +2754,20 @@ end tell"#,
Ok(())
}
/// macOS: Terminal.app
#[cfg(target_os = "macos")]
fn launch_macos_terminal_app(script_file: &std::path::Path) -> Result<(), String> {
run_terminal_osascript(
&build_macos_terminal_applescript(script_file),
"Terminal.app",
)
}
/// macOS: iTerm2
#[cfg(target_os = "macos")]
fn build_macos_iterm2_applescript(script_file: &std::path::Path) -> String {
format!(
r#"set launcher_script to "bash '{}'"
r#"set launcher_script to {launcher}
set was_running to application "iTerm" is running
tell application "iTerm"
if was_running then
@@ -2683,63 +2795,59 @@ tell application "iTerm"
write text launcher_script
end tell
end tell"#,
script_file.display()
launcher = applescript_launcher_command(script_file)
)
}
/// macOS: iTerm2
#[cfg(target_os = "macos")]
fn launch_macos_iterm2(script_file: &std::path::Path) -> Result<(), String> {
use std::process::Command;
let applescript = build_macos_iterm2_applescript(script_file);
let output = Command::new("osascript")
.arg("-e")
.arg(&applescript)
.output()
.map_err(|e| format!("执行 osascript 失败: {e}"))?;
if !output.status.success() {
let stderr = decode_command_output(&output.stderr);
return Err(format!(
"iTerm2 执行失败 (exit code: {:?}): {}",
output.status.code(),
stderr
));
}
Ok(())
run_terminal_osascript(&build_macos_iterm2_applescript(script_file), "iTerm2")
}
/// macOS: Ghostty — use --quit-after-last-window-closed to avoid cloning existing tabs
/// Keep the launcher path inside a `bash -c` string.
/// A bare `.sh` passed through `open --args` may also be opened as a document.
#[cfg(target_os = "macos")]
fn build_macos_dash_c_command(script_file: &std::path::Path) -> String {
format!(
"exec bash {}",
shell_single_quote(&script_file.to_string_lossy())
)
}
/// macOS: Ghostty.
/// Warm starts use AppleScript to create one command window.
/// Cold starts use `initial-command` so the first default surface runs the launcher.
/// Do not use `initial-window=false` plus `new window`: cold launch can still create the default window first.
#[cfg(target_os = "macos")]
fn build_macos_ghostty_applescript(script_file: &std::path::Path) -> String {
format!(
r#"set launcher_command to {launcher}
set was_running to application "Ghostty" is running
if was_running then
tell application "Ghostty"
new window with configuration {{command:launcher_command}}
end tell
else
do shell script "open -na Ghostty --args --quit-after-last-window-closed=true " & quoted form of ("--initial-command=" & launcher_command)
end if
"#,
launcher = applescript_launcher_command(script_file)
)
}
/// macOS: Ghostty
#[cfg(target_os = "macos")]
fn launch_macos_ghostty(script_file: &std::path::Path) -> Result<(), String> {
use std::process::Command;
let output = Command::new("open")
.args([
"-na",
"Ghostty",
"--args",
"--quit-after-last-window-closed=true",
"-e",
"bash",
])
.arg(script_file)
.output()
.map_err(|e| format!("启动 Ghostty 失败: {e}"))?;
if !output.status.success() {
let stderr = decode_command_output(&output.stderr);
return Err(format!(
"Ghostty 启动失败 (exit code: {:?}): {}",
output.status.code(),
stderr
));
match run_terminal_osascript(&build_macos_ghostty_applescript(script_file), "Ghostty") {
Ok(()) => Ok(()),
Err(applescript_error) => {
log::warn!(
"Ghostty AppleScript launch failed, falling back to open -na: {applescript_error}"
);
launch_macos_open_app("Ghostty", script_file, true)
}
}
Ok(())
}
/// macOS: 使用 open -na 启动支持 --args 参数的终端(Alacritty/Kitty/WezTerm/Kaku
@@ -2757,7 +2865,10 @@ fn launch_macos_open_app(
if use_e_flag {
cmd.arg("-e");
}
cmd.arg("bash").arg(script_file);
// Keep the script path inside `bash -c`; a trailing bare `.sh` can be opened as a document.
cmd.arg("bash")
.arg("-c")
.arg(build_macos_dash_c_command(script_file));
let output = cmd
.output()
@@ -4009,8 +4120,9 @@ mod tests {
#[test]
fn codex_nvm_anchors_to_that_npm() {
// Codex 官方 self-update 只在支持的 release 上生效;失败时仍写回同一个
// node 的 npm,而非 PATH 第一个 npm。
// Codex 不走 self-update`codex update` 在 npm 安装上只是裸 `npm install -g`
// 却会假成功掩盖平台二进制漏装)——直接锚定到同一个 node 的 npm,而非 PATH
// 第一个 npm。损坏时的 uninstall+install 自愈见 codex_missing_platform_binary_*。
let cmd = anchored_command_from_paths(
"codex",
"/Users/me/.nvm/versions/node/v22.14.0/bin/codex",
@@ -4018,7 +4130,7 @@ mod tests {
);
assert_eq!(
cmd.as_deref(),
Some("/Users/me/.nvm/versions/node/v22.14.0/bin/codex update || /Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest")
Some("/Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest")
);
}
@@ -4038,9 +4150,25 @@ mod tests {
}
#[test]
fn volta_uses_volta_install() {
fn volta_self_update_chain_anchors_to_volta() {
// `~/.volta/bin` 通常不在 GUI 非登录 `bash -c` 的 PATH 里,且用户可能
// PATH 上还有另一份 volta → 必须绝对路径锚定到命令行命中的这一份。
// 用 openclaw(仍在 prefers_official_update)覆盖 volta 分支的 self-update 链;
// codex 已改为不 self-update(见 codex_volta_anchors_to_volta_install)。
let cmd = anchored_command_from_paths(
"openclaw",
"/Users/me/.volta/bin/openclaw",
"/Users/me/.volta/tools/image/packages/openclaw/lib/node_modules/openclaw",
);
assert_eq!(
cmd.as_deref(),
Some("/Users/me/.volta/bin/openclaw update --yes || /Users/me/.volta/bin/volta install openclaw")
);
}
#[test]
fn codex_volta_anchors_to_volta_install() {
// codex 锚定到命令行命中的那份 volta,但不 self-update:纯 `volta install`。
let cmd = anchored_command_from_paths(
"codex",
"/Users/me/.volta/bin/codex",
@@ -4048,7 +4176,7 @@ mod tests {
);
assert_eq!(
cmd.as_deref(),
Some("/Users/me/.volta/bin/codex update || /Users/me/.volta/bin/volta install @openai/codex")
Some("/Users/me/.volta/bin/volta install @openai/codex")
);
}
@@ -4076,7 +4204,7 @@ mod tests {
);
assert_eq!(
cmd.as_deref(),
Some("'/Users/my name/.volta/bin/codex' update || '/Users/my name/.volta/bin/volta' install @openai/codex")
Some("'/Users/my name/.volta/bin/volta' install @openai/codex")
);
}
@@ -4144,7 +4272,7 @@ mod tests {
assert_eq!(
cmd.as_deref(),
Some(
"/Users/me/.local/share/fnm_multishells/12345_abc/bin/codex update || /Users/me/.local/share/fnm_multishells/12345_abc/bin/npm i -g @openai/codex@latest"
"/Users/me/.local/share/fnm_multishells/12345_abc/bin/npm i -g @openai/codex@latest"
)
);
}
@@ -4158,7 +4286,7 @@ mod tests {
);
assert_eq!(
cmd.as_deref(),
Some("'/Users/my name/.nvm/versions/node/v22/bin/codex' update || '/Users/my name/.nvm/versions/node/v22/bin/npm' i -g @openai/codex@latest")
Some("'/Users/my name/.nvm/versions/node/v22/bin/npm' i -g @openai/codex@latest")
);
}
@@ -4255,6 +4383,78 @@ mod tests {
assert!(default_install(&installs).is_none());
}
#[test]
fn codex_missing_platform_binary_self_heals_via_uninstall_install() {
// 平台二进制缺失 → `codex --version` 报 "Missing optional dependency" 退出非 0
// → enumerate 标记 runnable=false。此状态下普通 `npm i -g @latest` 是 no-op 修不好,
// 升级路径改用 uninstall+install 重装补回平台二进制(`|| true` 让 uninstall 在
// set -e 下对半损坏包返回非 0 时仍继续 install)。
let mut broken = inst("/Users/me/.nvm/versions/node/v22.14.0/bin/codex", true);
broken.runnable = false;
assert_eq!(
installs_anchored_command("codex", &[broken]).as_deref(),
Some("/Users/me/.nvm/versions/node/v22.14.0/bin/npm uninstall -g @openai/codex || true; /Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest")
);
}
#[test]
fn codex_runnable_uses_plain_npm_not_self_heal() {
// 正常(runnable=true)的 codex 升级:锚定 npm,既不重装、也不跑会假成功
// 掩盖损坏的 `codex update`。
let healthy = inst("/Users/me/.nvm/versions/node/v22.14.0/bin/codex", true);
let cmd = installs_anchored_command("codex", &[healthy]);
assert_eq!(
cmd.as_deref(),
Some("/Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest")
);
assert!(!cmd.unwrap().contains("uninstall"));
}
#[test]
fn codex_broken_homebrew_formula_uses_brew_not_npm_repair() {
// brew formula 装的坏 codexreal 在 Cellar):自愈门控必须收窄放行,回落到
// `brew upgrade codex`——若误走 npm 重装,npm 够不到 Cellar 那份、反而旁路
// 装第二份 npm 全局 codex 制造双安装。
let broken = ToolInstallation {
path: "/opt/homebrew/bin/codex".to_string(),
version: None,
runnable: false,
error: None,
source: "homebrew".to_string(),
is_path_default: true,
real: std::path::PathBuf::from("/opt/homebrew/Cellar/codex/1.2.3/bin/codex"),
};
assert_eq!(
installs_anchored_command("codex", &[broken]).as_deref(),
Some("/opt/homebrew/bin/brew upgrade codex")
);
}
#[test]
fn codex_broken_volta_uses_volta_install_not_npm_repair() {
// volta 装的坏 codex:回落到 `volta install`,不走 npm 重装。
let mut broken = inst("/Users/me/.volta/bin/codex", true);
broken.runnable = false;
assert_eq!(
installs_anchored_command("codex", &[broken]).as_deref(),
Some("/Users/me/.volta/bin/volta install @openai/codex")
);
}
#[test]
fn codex_broken_bun_uses_bun_add_not_phantom_npm() {
// bun 装的坏 codex:回落到 `bun add`,且**绝不**拼出 `~/.bun/bin/npm`
// (bun 目录下没有 npm,那条路径不存在、执行会直接失败)。
let mut broken = inst("/Users/me/.bun/bin/codex", true);
broken.runnable = false;
let cmd = installs_anchored_command("codex", &[broken]);
assert_eq!(
cmd.as_deref(),
Some("/Users/me/.bun/bin/bun add -g @openai/codex@latest")
);
assert!(!cmd.unwrap().contains("npm"));
}
#[test]
fn first_abs_path_line_skips_shell_noise() {
// 交互式 .zshrc 先打印欢迎语(如 powerlevel10k / 自定义提示),
@@ -4382,8 +4582,9 @@ mod tests {
);
assert_eq!(
static_fallback_command("codex"),
"codex update || npm i -g @openai/codex@latest"
"npm i -g @openai/codex@latest"
);
assert!(!static_fallback_command("codex").contains("codex update"));
assert_eq!(
static_fallback_command("gemini"),
"npm i -g @google/gemini-cli@latest"
@@ -4693,6 +4894,124 @@ mod tests {
assert!(running_branch.contains("create tab with default profile"));
}
/// Terminal `activate` creates a default empty window on cold start; `launch` does not.
#[cfg(target_os = "macos")]
#[test]
fn terminal_applescript_cold_start_uses_launch_before_do_script() {
let script = build_macos_terminal_applescript(Path::new("/tmp/cc_switch_launcher.sh"));
assert!(
script.contains(r#"set was_running to application "Terminal" is running"#),
"missing was_running detection:\n{script}"
);
// Cold launches avoid `activate` until after `do script`, so no default empty window is created first.
assert!(
script.contains(
"else\n launch\n do script launcher_script\n activate"
),
"cold start should launch before activating:\n{script}"
);
// Already-running launches should create a fresh session.
assert!(
script.contains(
"if was_running then\n activate\n do script launcher_script\n"
),
"already-running branch should use bare do script:\n{script}"
);
}
/// Restored windows should not receive the launcher command.
#[cfg(target_os = "macos")]
#[test]
fn terminal_applescript_does_not_hijack_restored_windows() {
let script = build_macos_terminal_applescript(Path::new("/tmp/cc_switch_launcher.sh"));
assert!(
!script.contains(" in window 1"),
"should not inject into an existing/restored Terminal window:\n{script}"
);
assert!(
!script.contains("count of windows"),
"should not infer restored-window safety from window count:\n{script}"
);
}
/// Ghostty cold starts use `initial-command`; warm starts use the scripting dictionary.
#[cfg(target_os = "macos")]
#[test]
fn ghostty_applescript_cold_start_uses_initial_command() {
let script = build_macos_ghostty_applescript(Path::new("/tmp/cc_switch_launcher.sh"));
// Warm launches execute through the AppleScript command property, not `open -na ... -e`.
assert!(
script.contains(r#"set launcher_command to "bash '/tmp/cc_switch_launcher.sh'""#),
"missing launcher_command:\n{script}"
);
assert!(script.contains("if was_running then"));
assert!(script.contains("new window with configuration {command:launcher_command}"));
assert!(
!script.contains(" --args -e"),
"should not execute through open -na -e:\n{script}"
);
// Cold launches make Ghostty's first default surface execute the launcher.
assert!(script.contains(r#"set was_running to application "Ghostty" is running"#));
assert!(
script.contains(
r#"do shell script "open -na Ghostty --args --quit-after-last-window-closed=true " & quoted form of ("--initial-command=" & launcher_command)"#
),
"cold start should use initial-command:\n{script}"
);
assert!(
!script.contains("--initial-window=false"),
"should not rely on initial-window=false:\n{script}"
);
assert!(
!script.contains("delay 0.5"),
"should not rely on a fixed delay:\n{script}"
);
assert!(
!script.contains("old_ids"),
"should not track default windows for closing:\n{script}"
);
assert!(
!script.contains("close window"),
"should not close a default window:\n{script}"
);
}
#[cfg(target_os = "macos")]
#[test]
fn dash_c_command_wraps_script_path_inside_quoted_arg() {
// The script path must stay inside the `-c` string, not as a bare argv.
let s = build_macos_dash_c_command(Path::new("/tmp/cc_switch_launcher_1.sh"));
assert_eq!(s, "exec bash '/tmp/cc_switch_launcher_1.sh'");
// Spaces and single quotes must stay shell-safe too.
let s2 = build_macos_dash_c_command(Path::new("/Users/me/it's dir/x.sh"));
assert_eq!(s2, r#"exec bash '/Users/me/it'"'"'s dir/x.sh'"#);
}
/// AppleScript launchers need both shell-path quoting and AppleScript string quoting.
#[cfg(target_os = "macos")]
#[test]
fn applescript_builders_safely_quote_special_paths() {
// First shell-quote the path, then wrap the whole command as an AppleScript string.
let expected = r#""bash '/Users/me/it'\"'\"'s dir/x.sh'""#;
let p = Path::new("/Users/me/it's dir/x.sh");
assert_eq!(applescript_launcher_command(p), expected);
assert!(
build_macos_terminal_applescript(p).contains(expected),
"Terminal did not quote safely"
);
assert!(
build_macos_iterm2_applescript(p).contains(expected),
"iTerm2 did not quote safely"
);
assert!(
build_macos_ghostty_applescript(p).contains(expected),
"Ghostty did not quote safely"
);
}
#[test]
fn build_windows_cwd_command_str_uses_cd_for_drive_paths() {
let command = build_windows_cwd_command_str(r"C:\work\repo");
+6
View File
@@ -14,12 +14,18 @@ pub async fn fetch_models_for_config(
api_key: String,
is_full_url: Option<bool>,
models_url: Option<String>,
custom_user_agent: Option<String>,
) -> Result<Vec<FetchedModel>, String> {
// 与转发 / 检测路径共用 parse_custom_user_agent:非法 UA 静默忽略(不阻断取模型)。
let user_agent = crate::provider::parse_custom_user_agent(custom_user_agent.as_deref())
.ok()
.flatten();
model_fetch::fetch_models(
&base_url,
&api_key,
is_full_url.unwrap_or(false),
models_url.as_deref(),
user_agent,
)
.await
}
+254 -21
View File
@@ -1,6 +1,7 @@
#![allow(non_snake_case)]
use tauri::AppHandle;
use tauri_plugin_updater::UpdaterExt;
fn merge_settings_for_save(
mut incoming: crate::settings::AppSettings,
@@ -35,24 +36,11 @@ fn merge_settings_for_save(
}
_ => {}
}
if incoming.local_migrations.is_none() {
incoming.local_migrations = existing.local_migrations.clone();
} else if let (Some(incoming_migrations), Some(existing_migrations)) =
(&mut incoming.local_migrations, &existing.local_migrations)
{
if incoming_migrations
.codex_third_party_history_provider_bucket_v1
.is_none()
{
incoming_migrations.codex_third_party_history_provider_bucket_v1 = existing_migrations
.codex_third_party_history_provider_bucket_v1
.clone();
}
if incoming_migrations.codex_provider_template_v1.is_none() {
incoming_migrations.codex_provider_template_v1 =
existing_migrations.codex_provider_template_v1.clone();
}
}
// local_migrations 是纯后端状态(迁移完成标记),前端没有合法的修改场景,
// 无条件取现有值。若按 incoming 透传:后端清掉 marker(如关闭统一会话
// 开关)后、前端 query 缓存刷新前的一次全量保存会把旧 marker 重放回来,
// 重新开启时被"复活"的标记挡住而漏迁。
incoming.local_migrations = existing.local_migrations.clone();
incoming
}
@@ -64,13 +52,117 @@ pub async fn get_settings() -> Result<crate::settings::AppSettings, String> {
/// 保存设置
#[tauri::command]
pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<bool, String> {
pub async fn save_settings(
state: tauri::State<'_, crate::store::AppState>,
settings: crate::settings::AppSettings,
) -> Result<bool, String> {
let existing = crate::settings::get_settings();
let merged = merge_settings_for_save(settings, &existing);
let unify_codex_changed =
merged.unify_codex_session_history != existing.unify_codex_session_history;
let unify_codex_enabled = merged.unify_codex_session_history;
crate::settings::update_settings(merged).map_err(|e| e.to_string())?;
// 统一会话开关变更时立即重写当前官方 Codex 供应商的 live 配置,
// 不必等下一次切换才生效。
if unify_codex_changed {
// live 重写失败时回滚设置并把保存整体报失败:若设置保持已切换状态,
// live 仍跑旧桶,后续的历史迁移/还原会让会话再次分裂(开启=历史
// 迁走而新会话仍写 openai 桶;关闭=会话还原而 live 仍写 custom)。
// 报错让前端 saved=false 短路还原;回滚是整次保存的事务语义
// (本开关的保存只携带开关相关字段)。
if let Err(err) =
crate::services::provider::reapply_current_codex_official_live(state.inner())
{
log::warn!("统一 Codex 会话历史开关变更后重写 live 配置失败,回滚设置: {err}");
if let Err(rollback_err) = crate::settings::update_settings(existing) {
log::error!("回滚统一会话开关设置失败: {rollback_err}");
}
return Err(format!(
"统一 Codex 会话历史开关未生效(live 配置重写失败): {err}"
));
}
if unify_codex_enabled {
// 后台执行存量迁移(openai 桶 → custom 桶;仅当用户勾选了迁入既有
// 会话,函数内部自门控)。大会话目录可能要读数秒,不能阻塞设置保存;
// 失败时不写完成标记,下次启动自动重试。
tauri::async_runtime::spawn_blocking(|| {
match crate::codex_history_migration::maybe_migrate_codex_official_history_to_unified_bucket() {
Ok(outcome) => {
if let Some(reason) = outcome.skipped_reason {
log::debug!("○ Codex official history unify migration skipped: {reason}");
} else {
log::info!(
"✓ Codex official history unify migration completed: jsonl_files={}, state_rows={}",
outcome.migrated_jsonl_files,
outcome.migrated_state_rows
);
}
}
Err(e) => {
log::warn!("✗ Codex official history unify migration failed: {e}");
}
}
});
} else {
// 清除标记与迁移意愿,让重新开启并再次勾选时能补迁
// 关闭期间落入 openai 桶的官方会话。
if let Err(err) = crate::settings::clear_codex_official_history_unify_migration() {
log::warn!("清除统一会话迁移标记失败: {err}");
}
if let Err(err) = crate::settings::clear_codex_unify_migrate_existing() {
log::warn!("清除统一会话迁移意愿失败: {err}");
}
}
}
Ok(true)
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CodexUnifyHistoryRestoreResult {
pub restored_jsonl_files: usize,
pub restored_state_rows: usize,
/// 还原被跳过的原因(如当前目录没有账本),前端据此提示而非报"成功 0 项"。
#[serde(skip_serializing_if = "Option::is_none")]
pub skipped_reason: Option<String>,
}
/// 是否存在统一会话开关的迁移备份(决定关闭弹窗里是否显示"恢复备份"勾选)。
#[tauri::command]
pub async fn has_codex_unify_history_backup() -> Result<bool, String> {
Ok(crate::codex_history_migration::has_codex_official_history_unify_backup())
}
/// 按迁移备份账本把当时迁入共享桶的官方会话还原回 "openai" 桶。
/// 由关闭统一会话开关的确认弹窗触发;幂等,可安全重试。
#[tauri::command]
pub async fn restore_codex_unified_history() -> Result<CodexUnifyHistoryRestoreResult, String> {
let outcome = tauri::async_runtime::spawn_blocking(|| {
crate::codex_history_migration::restore_codex_official_history_from_backups()
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
if let Some(reason) = &outcome.skipped_reason {
log::debug!("○ Codex official history restore skipped: {reason}");
} else {
log::info!(
"✓ Codex official history restored from backups: jsonl_files={}, state_rows={}",
outcome.restored_jsonl_files,
outcome.restored_state_rows
);
}
Ok(CodexUnifyHistoryRestoreResult {
restored_jsonl_files: outcome.restored_jsonl_files,
restored_state_rows: outcome.restored_state_rows,
skipped_reason: outcome.skipped_reason,
})
}
/// 重启应用程序(当 app_config_dir 变更后使用)
#[tauri::command]
pub async fn restart_app(app: AppHandle) -> Result<bool, String> {
@@ -79,11 +171,80 @@ pub async fn restart_app(app: AppHandle) -> Result<bool, String> {
// 在后台延迟重启,让函数有时间返回响应
tauri::async_runtime::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
// app.restart() 走 RESTART_EXIT_CODE 路径,ExitRequested 处理器会直接
// 放行给 Tauri 默认 re-exec,不执行代理/Live 清理。但本命令用于
// app_config_dir 变更后的重启:新实例会切到新数据库,拿不到旧库里的
// Live 备份,无法恢复被接管的 Live 配置。因此必须趁旧实例的事件循环
// 仍存活,在这里同步完成恢复(保留代理状态,新实例启动时自动重新接管)。
crate::cleanup_before_exit(&app).await;
app.restart();
});
Ok(true)
}
/// 下载并安装应用更新,然后由后端直接重启应用。
///
/// macOS 更新会原地替换 `.app` bundle。如果先返回前端、再让旧 WebView 调
/// `process.relaunch()`,旧进程可能已经处在 bundle 被替换后的不稳定窗口期。
/// 这里把退出清理、安装和重启串在同一个后端流程中,避免依赖旧前端继续执行。
#[tauri::command]
pub async fn install_update_and_restart(app: AppHandle) -> Result<bool, String> {
let updater = app
.updater_builder()
.build()
.map_err(|e| format!("初始化更新器失败: {e}"))?;
let Some(update) = updater
.check()
.await
.map_err(|e| format!("检查更新失败: {e}"))?
else {
return Ok(false);
};
log::info!("开始下载应用更新: {}", update.version);
let bytes = update
.download(|_, _| {}, || {})
.await
.map_err(|e| format!("下载更新失败: {e}"))?;
log::info!("开始安装应用更新: {}", update.version);
#[cfg(target_os = "windows")]
{
// Windows updater 会在 install() 内启动安装器并直接退出当前进程
// (插件内部 std::process::exit(0),绕过 TrayIcon::drop、不发
// NIM_DELETE,会残留死图标——与托盘"退出"路径相同的问题)。
// 因此清理只能放在 install 前执行,且必须显式移除托盘图标。
crate::save_window_state_before_exit(&app);
crate::cleanup_before_exit(&app).await;
crate::remove_tray_icon_before_exit(&app);
crate::destroy_single_instance_lock(&app);
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
update.install(bytes).map_err(|e| {
format!(
"Windows 更新安装失败: {e}。已执行退出前清理,代理或 Live 接管可能已暂停;请重启应用或重新开启代理后再试。"
)
})?;
return Ok(true);
}
#[cfg(not(target_os = "windows"))]
{
// macOS/Linux install() 会返回;先安装,避免安装失败时误停代理/撤回接管。
update
.install(bytes)
.map_err(|e| format!("安装更新失败: {e}"))?;
crate::save_window_state_before_exit(&app);
crate::cleanup_before_exit(&app).await;
log::info!("应用更新安装完成,正在重启应用");
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
crate::restart_process(&app);
}
}
/// 获取 app_config_dir 覆盖配置 (从 Store)
#[tauri::command]
pub async fn get_app_config_dir_override(app: AppHandle) -> Result<Option<String>, String> {
@@ -116,8 +277,9 @@ pub async fn set_auto_launch(enabled: bool) -> Result<bool, String> {
mod tests {
use super::merge_settings_for_save;
use crate::settings::{
AppSettings, CodexProviderTemplateMigration, CodexThirdPartyHistoryProviderBucketMigration,
LocalMigrations, S3SyncSettings, WebDavSyncSettings,
AppSettings, CodexOfficialHistoryUnifyMigration, CodexProviderTemplateMigration,
CodexThirdPartyHistoryProviderBucketMigration, LocalMigrations, S3SyncSettings,
WebDavSyncSettings,
};
#[test]
@@ -316,6 +478,13 @@ mod tests {
completed_at: "2026-05-20T00:01:00Z".to_string(),
migrated_provider_ids: vec!["legacy".to_string()],
}),
codex_official_history_unify_v1: Some(CodexOfficialHistoryUnifyMigration {
completed_at: "2026-06-12T00:00:00Z".to_string(),
target_provider_id: "custom".to_string(),
migrated_jsonl_files: 5,
migrated_state_rows: 7,
codex_config_dir: None,
}),
}),
..AppSettings::default()
};
@@ -345,6 +514,70 @@ mod tests {
template_migration.migrated_provider_ids,
vec!["legacy".to_string()]
);
let unify_migration = merged
.local_migrations
.as_ref()
.and_then(|migrations| migrations.codex_official_history_unify_v1.as_ref())
.expect("official unify migration marker should be preserved");
assert_eq!(unify_migration.migrated_jsonl_files, 5);
assert_eq!(unify_migration.migrated_state_rows, 7);
}
/// incoming 带有 local_migrations(哪怕是空的)也不能覆盖后端维护的标记。
#[test]
fn save_settings_should_keep_backend_migration_markers_over_incoming() {
let existing = AppSettings {
local_migrations: Some(LocalMigrations {
codex_third_party_history_provider_bucket_v1: None,
codex_provider_template_v1: None,
codex_official_history_unify_v1: Some(CodexOfficialHistoryUnifyMigration {
completed_at: "2026-06-12T00:00:00Z".to_string(),
target_provider_id: "custom".to_string(),
migrated_jsonl_files: 1,
migrated_state_rows: 2,
codex_config_dir: None,
}),
}),
..AppSettings::default()
};
let incoming = AppSettings {
local_migrations: Some(LocalMigrations::default()),
..AppSettings::default()
};
let merged = merge_settings_for_save(incoming, &existing);
assert!(merged
.local_migrations
.as_ref()
.and_then(|migrations| migrations.codex_official_history_unify_v1.as_ref())
.is_some());
}
/// 后端清掉 marker 后(如关闭统一会话开关)、前端缓存刷新前的全量保存
/// 会携带旧 markermerge 必须忽略它,否则被"复活"的标记会让重新开启
/// 时误判已迁移而漏迁。
#[test]
fn save_settings_should_ignore_stale_incoming_migration_markers() {
let existing = AppSettings::default();
let incoming = AppSettings {
local_migrations: Some(LocalMigrations {
codex_official_history_unify_v1: Some(CodexOfficialHistoryUnifyMigration {
completed_at: "2026-06-12T00:00:00Z".to_string(),
target_provider_id: "custom".to_string(),
migrated_jsonl_files: 1,
migrated_state_rows: 2,
codex_config_dir: None,
}),
..LocalMigrations::default()
}),
..AppSettings::default()
};
let merged = merge_settings_for_save(incoming, &existing);
assert!(merged.local_migrations.is_none());
}
}
+30 -151
View File
@@ -1,4 +1,7 @@
//! 流式健康检查命令
//! 供应商连通性检查命令
//!
//! 注意:本检查只探测 base_url 是否可达,不发真实大模型请求,也不触碰故障转移
//! 熔断器(熔断器由真实转发流量驱动)。详见 `services::stream_check`。
use crate::app_config::AppType;
use crate::commands::copilot::CopilotAuthState;
@@ -10,7 +13,7 @@ use crate::store::AppState;
use std::collections::HashSet;
use tauri::State;
/// 流式健康检查(单个供应商)
/// 连通性检查(单个供应商)
#[tauri::command]
pub async fn stream_check_provider(
state: State<'_, AppState>,
@@ -25,25 +28,12 @@ pub async fn stream_check_provider(
.get(&provider_id)
.ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?;
let auth_override = resolve_copilot_auth_override(provider, &copilot_state).await?;
// Copilot 端点是动态的(随 OAuth token 解析),需预先取出 host 再探测;
// 其余供应商传 None,由服务层从 settings_config 提取 base_url。无需鉴权。
let base_url_override = resolve_copilot_base_url_override(provider, &copilot_state).await?;
let claude_api_format_override = resolve_claude_api_format_override(
&app_type,
provider,
&config,
&copilot_state,
auth_override.as_ref(),
)
.await?;
let result = StreamCheckService::check_with_retry(
&app_type,
provider,
&config,
auth_override,
base_url_override,
claude_api_format_override,
)
.await?;
let result =
StreamCheckService::check_with_retry(&app_type, provider, &config, base_url_override)
.await?;
// 记录日志
let _ =
@@ -54,7 +44,7 @@ pub async fn stream_check_provider(
Ok(result)
}
/// 批量流式健康检查
/// 批量连通性检查
#[tauri::command]
pub async fn stream_check_all_providers(
state: State<'_, AppState>,
@@ -65,7 +55,6 @@ pub async fn stream_check_all_providers(
let config = state.db.get_stream_check_config()?;
let providers = state.db.get_all_providers(app_type.as_str())?;
let mut results = Vec::new();
let allowed_ids: Option<HashSet<String>> = if proxy_targets_only {
let mut ids = HashSet::new();
if let Ok(Some(current_id)) = state.db.get_current_provider(app_type.as_str()) {
@@ -81,6 +70,7 @@ pub async fn stream_check_all_providers(
None
};
let mut results = Vec::new();
for (id, provider) in providers {
if let Some(ids) = &allowed_ids {
if !ids.contains(&id) {
@@ -88,54 +78,22 @@ pub async fn stream_check_all_providers(
}
}
let auth_override = resolve_copilot_auth_override(&provider, &copilot_state).await?;
let base_url_override =
resolve_copilot_base_url_override(&provider, &copilot_state).await?;
let claude_api_format_override = resolve_claude_api_format_override(
&app_type,
&provider,
&config,
&copilot_state,
auth_override.as_ref(),
)
.await
.unwrap_or_else(|e| {
log::warn!(
"[StreamCheck] Failed to resolve Claude API format override for {}: {}",
provider.id,
e
);
None
});
let result = StreamCheckService::check_with_retry(
&app_type,
&provider,
&config,
auth_override,
base_url_override,
claude_api_format_override,
)
.await
.unwrap_or_else(|e| {
let (http_status, message) = match &e {
crate::error::AppError::HttpStatus { status, .. } => (
Some(*status),
StreamCheckService::classify_http_status(*status).to_string(),
),
_ => (None, e.to_string()),
};
StreamCheckResult {
status: HealthStatus::Failed,
success: false,
message,
response_time_ms: None,
http_status,
model_used: String::new(),
tested_at: chrono::Utc::now().timestamp(),
retry_count: 0,
error_category: None,
}
});
let result =
StreamCheckService::check_with_retry(&app_type, &provider, &config, base_url_override)
.await
.unwrap_or_else(|e| StreamCheckResult {
status: HealthStatus::Failed,
success: false,
message: e.to_string(),
response_time_ms: None,
http_status: None,
model_used: String::new(),
tested_at: chrono::Utc::now().timestamp(),
retry_count: 0,
error_category: None,
});
let _ = state
.db
@@ -147,13 +105,13 @@ pub async fn stream_check_all_providers(
Ok(results)
}
/// 获取流式检查配置
/// 获取连通性检查配置
#[tauri::command]
pub fn get_stream_check_config(state: State<'_, AppState>) -> Result<StreamCheckConfig, AppError> {
state.db.get_stream_check_config()
}
/// 保存流式检查配置
/// 保存连通性检查配置
#[tauri::command]
pub fn save_stream_check_config(
state: State<'_, AppState>,
@@ -162,39 +120,8 @@ pub fn save_stream_check_config(
state.db.save_stream_check_config(&config)
}
async fn resolve_copilot_auth_override(
provider: &crate::provider::Provider,
copilot_state: &State<'_, CopilotAuthState>,
) -> Result<Option<crate::proxy::providers::AuthInfo>, AppError> {
let is_copilot = is_copilot_provider(provider);
if !is_copilot {
return Ok(None);
}
let auth_manager = copilot_state.0.read().await;
let account_id = provider
.meta
.as_ref()
.and_then(|meta| meta.managed_account_id_for("github_copilot"));
let token = match account_id.as_deref() {
Some(id) => auth_manager
.get_valid_token_for_account(id)
.await
.map_err(|e| AppError::Message(format!("GitHub Copilot 认证失败: {e}")))?,
None => auth_manager
.get_valid_token()
.await
.map_err(|e| AppError::Message(format!("GitHub Copilot 认证失败: {e}")))?,
};
Ok(Some(crate::proxy::providers::AuthInfo::new(
token,
crate::proxy::providers::AuthStrategy::GitHubCopilot,
)))
}
/// Copilot 供应商的 base_url 需要从 OAuth 管理器动态解析(按账号或默认端点)。
/// `is_full_url` 的供应商已是完整地址,无需解析。
async fn resolve_copilot_base_url_override(
provider: &crate::provider::Provider,
copilot_state: &State<'_, CopilotAuthState>,
@@ -238,54 +165,6 @@ fn is_copilot_provider(provider: &crate::provider::Provider) -> bool {
.unwrap_or(false)
}
async fn resolve_claude_api_format_override(
app_type: &AppType,
provider: &crate::provider::Provider,
config: &StreamCheckConfig,
copilot_state: &State<'_, CopilotAuthState>,
auth_override: Option<&crate::proxy::providers::AuthInfo>,
) -> Result<Option<String>, AppError> {
if *app_type != AppType::Claude {
return Ok(None);
}
let is_copilot = auth_override
.map(|auth| auth.strategy == crate::proxy::providers::AuthStrategy::GitHubCopilot)
.unwrap_or(false);
if !is_copilot {
return Ok(None);
}
let model_id = StreamCheckService::resolve_effective_test_model(app_type, provider, config);
let auth_manager = copilot_state.0.read().await;
let account_id = provider
.meta
.as_ref()
.and_then(|meta| meta.managed_account_id_for("github_copilot"));
let vendor_result = match account_id.as_deref() {
Some(id) => {
auth_manager
.get_model_vendor_for_account(id, &model_id)
.await
}
None => auth_manager.get_model_vendor(&model_id).await,
};
let api_format = match vendor_result {
Ok(Some(vendor)) if vendor.eq_ignore_ascii_case("openai") => "openai_responses",
Ok(Some(_)) | Ok(None) => "openai_chat",
Err(err) => {
log::warn!(
"[StreamCheck] Failed to resolve Copilot model vendor for {model_id}: {err}. Falling back to chat/completions"
);
"openai_chat"
}
};
Ok(Some(api_format.to_string()))
}
#[cfg(test)]
mod tests {
use super::is_copilot_provider;
+44 -13
View File
@@ -14,10 +14,16 @@ pub fn get_usage_summary(
start_date: Option<i64>,
end_date: Option<i64>,
app_type: Option<String>,
provider_name: Option<String>,
model: Option<String>,
) -> Result<UsageSummary, AppError> {
state
.db
.get_usage_summary(start_date, end_date, app_type.as_deref())
state.db.get_usage_summary(
start_date,
end_date,
app_type.as_deref(),
provider_name.as_deref(),
model.as_deref(),
)
}
/// 获取按 app_type 拆分的使用量汇总
@@ -26,8 +32,15 @@ pub fn get_usage_summary_by_app(
state: State<'_, AppState>,
start_date: Option<i64>,
end_date: Option<i64>,
provider_name: Option<String>,
model: Option<String>,
) -> Result<Vec<UsageSummaryByApp>, AppError> {
state.db.get_usage_summary_by_app(start_date, end_date)
state.db.get_usage_summary_by_app(
start_date,
end_date,
provider_name.as_deref(),
model.as_deref(),
)
}
/// 获取每日趋势
@@ -37,10 +50,16 @@ pub fn get_usage_trends(
start_date: Option<i64>,
end_date: Option<i64>,
app_type: Option<String>,
provider_name: Option<String>,
model: Option<String>,
) -> Result<Vec<DailyStats>, AppError> {
state
.db
.get_daily_trends(start_date, end_date, app_type.as_deref())
state.db.get_daily_trends(
start_date,
end_date,
app_type.as_deref(),
provider_name.as_deref(),
model.as_deref(),
)
}
/// 获取 Provider 统计
@@ -50,10 +69,16 @@ pub fn get_provider_stats(
start_date: Option<i64>,
end_date: Option<i64>,
app_type: Option<String>,
provider_name: Option<String>,
model: Option<String>,
) -> Result<Vec<ProviderStats>, AppError> {
state
.db
.get_provider_stats(start_date, end_date, app_type.as_deref())
state.db.get_provider_stats(
start_date,
end_date,
app_type.as_deref(),
provider_name.as_deref(),
model.as_deref(),
)
}
/// 获取模型统计
@@ -63,10 +88,16 @@ pub fn get_model_stats(
start_date: Option<i64>,
end_date: Option<i64>,
app_type: Option<String>,
provider_name: Option<String>,
model: Option<String>,
) -> Result<Vec<ModelStats>, AppError> {
state
.db
.get_model_stats(start_date, end_date, app_type.as_deref())
state.db.get_model_stats(
start_date,
end_date,
app_type.as_deref(),
provider_name.as_deref(),
model.as_deref(),
)
}
/// 获取请求日志列表
+157 -4
View File
@@ -75,6 +75,15 @@ impl Database {
return Ok(0);
}
// 剪枝是不可逆的:明细一旦汇总删除,0 成本行就永远失去按 pricing_model
// 补价重算的机会(启动序列里 seed 定价先于 rollup、但启动回填在 rollup
// 之后;周期任务同理)。所以剪枝前先尽力回填一次。失败仅告警不阻断——
// 否则一行损坏的定价数据会永久卡死日志清理。
// 注意必须在 SAVEPOINT 之外调用:回填内部自己开顶层事务。
if let Err(e) = Self::backfill_missing_usage_costs_on_conn(&conn, None) {
log::warn!("Pre-prune cost backfill failed, pruning anyway: {e}");
}
// Use a savepoint for atomicity
conn.execute("SAVEPOINT rollup_prune;", [])
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -106,15 +115,18 @@ 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");
// request_model 维度保留路由接管的「客户端别名 → 真实模型」映射,
// pricing_model 维度保留写入时的计价基准(request 计价模式下与 model 分叉);
// 明细行的这两列可能为 NULL(历史/手工数据),归一为 ''。
let aggregation_sql = format!(
"INSERT OR REPLACE INTO usage_daily_rollups
(date, app_type, provider_id, model,
(date, app_type, provider_id, model, request_model, pricing_model,
request_count, success_count,
input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens,
total_cost_usd, avg_latency_ms)
SELECT
d, a, p, m,
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,
@@ -131,6 +143,8 @@ impl Database {
SELECT
date(l.created_at, 'unixepoch', 'localtime') as d,
l.app_type as a, l.provider_id as p, l.model as m,
COALESCE(l.request_model, '') as rm,
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,
@@ -141,11 +155,12 @@ impl Database {
COALESCE(AVG(l.latency_ms), 0) as new_lat
FROM proxy_request_logs l
WHERE l.created_at < ?1 AND {effective_filter}
GROUP BY d, a, p, m
GROUP BY d, a, p, m, rm, pm
) agg
LEFT JOIN usage_daily_rollups old
ON old.date = agg.d AND old.app_type = agg.a
AND old.provider_id = agg.p AND old.model = agg.m"
AND old.provider_id = agg.p AND old.model = agg.m
AND old.request_model = agg.rm AND old.pricing_model = agg.pm"
);
conn.execute(&aggregation_sql, [cutoff])
@@ -325,6 +340,144 @@ mod tests {
Ok(())
}
#[test]
fn test_rollup_preserves_request_model_dimension() -> Result<(), AppError> {
let db = Database::memory()?;
let now = chrono::Utc::now().timestamp();
let old_ts = now - 40 * 86400;
{
let conn = crate::database::lock_conn!(db.conn);
// 路由接管行:model 是真实上游模型,request_model 是客户端别名。
// 同 model 下两个不同别名必须各自成行,prune 后映射关系仍可审计。
for (i, request_model) in [
("a", "claude-sonnet-4-6"),
("b", "claude-sonnet-4-6"),
("c", "claude-haiku-4-5"),
] {
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model, request_model,
input_tokens, output_tokens, total_cost_usd,
latency_ms, status_code, created_at
) VALUES (?1, 'p1', 'claude', 'kimi-k2', ?2, 100, 50, '0.01', 100, 200, ?3)",
rusqlite::params![format!("takeover-{i}"), request_model, old_ts],
)?;
}
}
let deleted = db.rollup_and_prune(30)?;
assert_eq!(deleted, 3);
let conn = crate::database::lock_conn!(db.conn);
let mut stmt = conn.prepare(
"SELECT request_model, request_count FROM usage_daily_rollups
WHERE model = 'kimi-k2' ORDER BY request_model",
)?;
let rows = stmt
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?
.collect::<Result<Vec<_>, _>>()?;
assert_eq!(
rows,
vec![
("claude-haiku-4-5".to_string(), 1),
("claude-sonnet-4-6".to_string(), 2),
]
);
Ok(())
}
#[test]
fn test_rollup_preserves_pricing_model_dimension() -> Result<(), AppError> {
let db = Database::memory()?;
let now = chrono::Utc::now().timestamp();
let old_ts = now - 40 * 86400;
{
let conn = crate::database::lock_conn!(db.conn);
// request 计价模式下 pricing_model 与 model 分叉,必须各自成行
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model, request_model, pricing_model,
input_tokens, output_tokens, total_cost_usd,
latency_ms, status_code, created_at
) VALUES ('pm-a', 'p1', 'claude', 'kimi-k2', 'claude-sonnet-4-6', 'kimi-k2',
100, 50, '0.01', 100, 200, ?1)",
rusqlite::params![old_ts],
)?;
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model, request_model, pricing_model,
input_tokens, output_tokens, total_cost_usd,
latency_ms, status_code, created_at
) VALUES ('pm-b', 'p1', 'claude', 'kimi-k2', 'claude-sonnet-4-6', 'claude-sonnet-4-6',
100, 50, '0.30', 100, 200, ?1)",
rusqlite::params![old_ts],
)?;
}
let deleted = db.rollup_and_prune(30)?;
assert_eq!(deleted, 2);
let conn = crate::database::lock_conn!(db.conn);
let mut stmt = conn.prepare(
"SELECT pricing_model, total_cost_usd FROM usage_daily_rollups
WHERE model = 'kimi-k2' ORDER BY pricing_model",
)?;
let rows = stmt
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?
.collect::<Result<Vec<_>, _>>()?;
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].0, "claude-sonnet-4-6");
assert_eq!(rows[1].0, "kimi-k2");
Ok(())
}
#[test]
fn test_rollup_backfills_costs_before_pruning() -> Result<(), AppError> {
let db = Database::memory()?;
let now = chrono::Utc::now().timestamp();
let old_ts = now - 40 * 86400;
{
let conn = crate::database::lock_conn!(db.conn);
// >30 天的 0 成本行:pricing_modelgpt-5.5)在 seed 定价表中有价。
// 剪枝是不可逆的,rollup 必须先回填再汇总,否则按 0 永久入账。
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model, request_model, pricing_model,
input_tokens, output_tokens, total_cost_usd,
latency_ms, status_code, created_at
) VALUES ('prune-backfill', 'p1', 'codex', 'gpt-5.5', 'gpt-5.5', 'gpt-5.5',
1000000, 0, '0', 100, 200, ?1)",
rusqlite::params![old_ts],
)?;
}
let deleted = db.rollup_and_prune(30)?;
assert_eq!(deleted, 1);
let conn = crate::database::lock_conn!(db.conn);
let total_cost: f64 = conn.query_row(
"SELECT CAST(total_cost_usd AS REAL) FROM usage_daily_rollups
WHERE model = 'gpt-5.5'",
[],
|row| row.get(0),
)?;
// gpt-5.5 input $5/M × 1M tokens,回填后再汇总
assert!(
(total_cost - 5.0).abs() < 1e-6,
"expected backfilled cost 5.0, got {total_cost}"
);
Ok(())
}
#[test]
fn test_rollup_noop_when_no_old_data() -> Result<(), AppError> {
let db = Database::memory()?;
+1 -1
View File
@@ -49,7 +49,7 @@ use std::sync::Mutex;
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 10;
pub(crate) const SCHEMA_VERSION: i32 = 11;
/// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
+395 -24
View File
@@ -181,9 +181,12 @@ impl Database {
)", []).map_err(|e| AppError::Database(e.to_string()))?;
// 10. Proxy Request Logs 表
// pricing_model = 写入时实际用于计价的模型名(pricing_model_source 解析结果),
// 回填按它重算;NULL 表示 v11 之前的历史行,'' 表示未计价的错误行。
conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
request_model TEXT,
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_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
@@ -255,12 +258,17 @@ impl Database {
.map_err(|e| AppError::Database(e.to_string()))?;
// 17. Usage Daily Rollups 表 (日聚合统计)
// request_model 保留路由接管的「客户端别名 → 真实模型」映射维度,
// pricing_model 保留写入时的计价基准(request 计价模式下与 model 分叉),
// 否则明细被 prune 后接管计费不可审计;历史行迁移时填 ''(未知)。
conn.execute(
"CREATE TABLE IF NOT EXISTS usage_daily_rollups (
date TEXT NOT NULL,
app_type TEXT NOT NULL,
provider_id TEXT NOT NULL,
model TEXT NOT NULL,
request_model TEXT NOT NULL DEFAULT '',
pricing_model TEXT NOT NULL DEFAULT '',
request_count INTEGER NOT NULL DEFAULT 0,
success_count INTEGER NOT NULL DEFAULT 0,
input_tokens INTEGER NOT NULL DEFAULT 0,
@@ -269,7 +277,7 @@ impl Database {
cache_creation_tokens 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)
PRIMARY KEY (date, app_type, provider_id, model, request_model, pricing_model)
)",
[],
)
@@ -431,6 +439,11 @@ impl Database {
Self::migrate_v9_to_v10(conn)?;
Self::set_user_version(conn, 10)?;
}
10 => {
log::info!("迁移数据库从 v10 到 v11usage_daily_rollups 保留 request_model 维度)");
Self::migrate_v10_to_v11(conn)?;
Self::set_user_version(conn, 11)?;
}
_ => {
return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
@@ -1200,11 +1213,85 @@ impl Database {
Ok(())
}
/// v10 -> v11usage_daily_rollups 增加 request_model 维度(进入主键),
/// proxy_request_logs 增加 pricing_model 列(写入时的计价基准,回填依据)。
///
/// 路由接管下 model(真实上游模型)≠ request_model(客户端别名),
/// 旧 rollup 只按 model 聚合,明细 prune 后映射关系永久丢失、计费不可审计。
/// SQLite 改主键必须重建表;历史行的 request_model 已不可知,填 ''。
fn migrate_v10_to_v11(conn: &Connection) -> Result<(), AppError> {
// proxy_request_logs.pricing_modelNULL = v11 前的历史行(回填走
// model → 占位符回退 request_model 的旧逻辑),'' = 未计价的错误行
if Self::table_exists(conn, "proxy_request_logs")? {
Self::add_column_if_missing(conn, "proxy_request_logs", "pricing_model", "TEXT")?;
}
if !Self::table_exists(conn, "usage_daily_rollups")? {
log::info!("v10 -> v11usage_daily_rollups 不存在,跳过重建");
return Ok(());
}
conn.execute_batch(
"ALTER TABLE usage_daily_rollups RENAME TO usage_daily_rollups_v10;
CREATE TABLE usage_daily_rollups (
date TEXT NOT NULL,
app_type TEXT NOT NULL,
provider_id TEXT NOT NULL,
model TEXT NOT NULL,
request_model TEXT NOT NULL DEFAULT '',
pricing_model TEXT NOT NULL DEFAULT '',
request_count INTEGER NOT NULL DEFAULT 0,
success_count INTEGER NOT NULL DEFAULT 0,
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,
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)
);
INSERT INTO usage_daily_rollups
(date, app_type, provider_id, model, request_model, pricing_model,
request_count, success_count, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms)
SELECT date, app_type, provider_id, model, '', '',
request_count, success_count, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms
FROM usage_daily_rollups_v10;
DROP TABLE usage_daily_rollups_v10;",
)
.map_err(|e| {
AppError::Database(format!("v10 -> v11 重建 usage_daily_rollups 失败: {e}"))
})?;
log::info!(
"v10 -> v11 迁移完成:usage_daily_rollups 已保留 request_model/pricing_model 维度"
);
Ok(())
}
/// 插入默认模型定价数据
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {
let pricing_data = [
// Claude Fable 5Opus 之上的新档)
(
"claude-fable-5",
"Claude Fable 5",
"10",
"50",
"1.00",
"12.50",
),
(
"claude-mythos-5",
"Claude Mythos 5",
"10",
"50",
"1.00",
"12.50",
),
// Claude 4.8 系列
(
"claude-opus-4-8",
@@ -1571,6 +1658,14 @@ impl Database {
"0",
),
// StepFun 系列
(
"step-3.7-flash",
"Step 3.7 Flash",
"0.19",
"1.13",
"0.04",
"0",
),
(
"step-3.5-flash",
"Step 3.5 Flash",
@@ -1602,7 +1697,7 @@ impl Database {
"Doubao Seed 2.0 Pro",
"0.47",
"2.37",
"0",
"0.09",
"0",
),
(
@@ -1610,7 +1705,7 @@ impl Database {
"Doubao Seed 2.0 Code",
"0.47",
"2.37",
"0",
"0.09",
"0",
),
(
@@ -1618,15 +1713,15 @@ impl Database {
"Doubao Seed 2.0 Code Preview",
"0.47",
"2.37",
"0",
"0.09",
"0",
),
(
"doubao-seed-2-0-lite",
"Doubao Seed 2.0 Lite",
"0.25",
"2",
"0",
"0.08",
"0.50",
"0.017",
"0",
),
(
@@ -1634,7 +1729,7 @@ impl Database {
"Doubao Seed 2.0 Mini",
"0.03",
"0.31",
"0",
"0.0056",
"0",
),
// DeepSeek 系列
@@ -1706,8 +1801,16 @@ impl Database {
"0.14",
"0",
),
("kimi-k2.5", "Kimi K2.5", "0.60", "2.50", "0.10", "0"),
("kimi-k2.5", "Kimi K2.5", "0.60", "3.00", "0.10", "0"),
("kimi-k2.6", "Kimi K2.6", "0.95", "4.00", "0.16", "0"),
(
"kimi-k2.7-code",
"Kimi K2.7 Code",
"0.95",
"4.00",
"0.19",
"0",
),
// MiniMax 系列
("minimax-m2.1", "MiniMax M2.1", "0.27", "0.95", "0.03", "0"),
(
@@ -1719,7 +1822,7 @@ impl Database {
"0",
),
("minimax-m2", "MiniMax M2", "0.27", "0.95", "0.03", "0"),
("minimax-m2.5", "MiniMax M2.5", "0.12", "0.95", "0.03", "0"),
("minimax-m2.5", "MiniMax M2.5", "0.15", "0.95", "0.03", "0"),
(
"minimax-m2.5-lightning",
"MiniMax M2.5 Lightning",
@@ -1746,8 +1849,8 @@ impl Database {
),
("minimax-m3", "MiniMax M3", "0.60", "2.40", "0.12", "0"),
// GLM (智谱)
("glm-4.7", "GLM-4.7", "0.39", "1.75", "0.04", "0"),
("glm-4.6", "GLM-4.6", "0.28", "1.11", "0.03", "0"),
("glm-4.7", "GLM-4.7", "0.6", "2.2", "0.11", "0"),
("glm-4.6", "GLM-4.6", "0.6", "2.2", "0.11", "0"),
("glm-5", "GLM-5", "1", "3.2", "0.2", "0"),
("glm-5.1", "GLM-5.1", "1.4", "4.4", "0.26", "0"),
// MiMo (小米)
@@ -1759,12 +1862,28 @@ impl Database {
"0.009",
"0",
),
("mimo-v2-pro", "MiMo V2 Pro", "1", "3", "0", "0"),
("mimo-v2.5", "MiMo V2.5", "0.09", "0.29", "0.009", "0"),
("mimo-v2.5-pro", "MiMo V2.5 Pro", "1", "3", "0", "0"),
("mimo-v2-pro", "MiMo V2 Pro", "0.435", "0.87", "0.0036", "0"),
("mimo-v2.5", "MiMo V2.5", "0.14", "0.29", "0.0028", "0"),
(
"mimo-v2.5-pro",
"MiMo V2.5 Pro",
"0.435",
"0.87",
"0.0036",
"0",
),
// Qwen 系列 (阿里巴巴)
("qwen3.6-plus", "Qwen3.6 Plus", "0.325", "1.95", "0", "0"),
("qwen3.5-plus", "Qwen3.5 Plus", "0.26", "1.56", "0", "0"),
("qwen3.7-max", "Qwen3.7 Max", "2.50", "7.50", "0.25", "0"),
("qwen3.7-plus", "Qwen3.7 Plus", "0.40", "1.60", "0.08", "0"),
(
"qwen3.6-plus",
"Qwen3.6 Plus",
"0.325",
"1.95",
"0.065",
"0",
),
("qwen3.5-plus", "Qwen3.5 Plus", "0.26", "1.56", "0.052", "0"),
("qwen3-max", "Qwen3 Max", "0.78", "3.90", "0", "0"),
(
"qwen3-235b-a22b",
@@ -1779,7 +1898,7 @@ impl Database {
"Qwen3 Coder Plus",
"0.65",
"3.25",
"0",
"0.13",
"0",
),
(
@@ -1803,7 +1922,7 @@ impl Database {
"Qwen3 Coder Flash",
"0.195",
"0.975",
"0",
"0.039",
"0",
),
(
@@ -1818,19 +1937,20 @@ impl Database {
("qwq-32b", "QwQ 32B", "0.20", "0.60", "0", "0"),
("qwen3-32b", "Qwen3 32B", "0.16", "0.64", "0", "0"),
// Grok 系列 (xAI)
("grok-4.3", "Grok 4.3", "1.25", "2.50", "0.20", "0"),
(
"grok-4.20-0309-reasoning",
"Grok 4.20 Reasoning",
"2",
"6",
"1.25",
"2.50",
"0.20",
"0",
),
(
"grok-4.20-0309-non-reasoning",
"Grok 4.20",
"2",
"6",
"1.25",
"2.50",
"0.20",
"0",
),
@@ -1863,6 +1983,38 @@ impl Database {
("grok-3", "Grok 3", "3", "15", "0.75", "0"),
("grok-3-mini", "Grok 3 Mini", "0.25", "0.50", "0.075", "0"),
// Mistral 系列
(
"mistral-medium-3.5",
"Mistral Medium 3.5",
"1.50",
"7.50",
"0",
"0",
),
(
"mistral-small-4",
"Mistral Small 4",
"0.10",
"0.30",
"0.01",
"0",
),
(
"devstral-small-2-2512",
"Devstral Small 2",
"0.10",
"0.30",
"0.01",
"0",
),
(
"magistral-small",
"Magistral Small",
"0.50",
"1.50",
"0",
"0",
),
("codestral-2508", "Codestral", "0.30", "0.90", "0.03", "0"),
(
"devstral-small-1.1",
@@ -1872,7 +2024,7 @@ impl Database {
"0.01",
"0",
),
("devstral-2-2512", "Devstral 2", "0.40", "0.90", "0.04", "0"),
("devstral-2-2512", "Devstral 2", "0.40", "2", "0.04", "0"),
(
"devstral-medium",
"Devstral Medium",
@@ -1953,6 +2105,225 @@ impl Database {
fn repair_current_model_pricing(conn: &Connection) -> Result<(), AppError> {
let pricing_fixes = [
// 2026-06-10 全量核价(厂商官方 list 价;CNY 按 ~7.14 折算)
// GLM 4.6/4.7:旧值是中转/OpenRouter 折扣价,统一到 Z.ai 官方(与 glm-5/5.1 一致)
(
"glm-4.7", "GLM-4.7", "0.6", "2.2", "0.11", "0", "0.39", "1.75", "0.04", "0",
),
(
"glm-4.6", "GLM-4.6", "0.6", "2.2", "0.11", "0", "0.28", "1.11", "0.03", "0",
),
// Grok 4.20xAI 已降价 2/6 → 1.25/2.50
(
"grok-4.20-0309-reasoning",
"Grok 4.20 Reasoning",
"1.25",
"2.50",
"0.20",
"0",
"2",
"6",
"0.20",
"0",
),
(
"grok-4.20-0309-non-reasoning",
"Grok 4.20",
"1.25",
"2.50",
"0.20",
"0",
"2",
"6",
"0.20",
"0",
),
// Kimi K2.5 官方 output 3.00
(
"kimi-k2.5",
"Kimi K2.5",
"0.60",
"3.00",
"0.10",
"0",
"0.60",
"2.50",
"0.10",
"0",
),
// MiniMax M2.5 input 0.15
(
"minimax-m2.5",
"MiniMax M2.5",
"0.15",
"0.95",
"0.03",
"0",
"0.12",
"0.95",
"0.03",
"0",
),
// Mistral Devstral 2 output 0.90 → 2(与同表 devstral-medium 一致)
(
"devstral-2-2512",
"Devstral 2",
"0.40",
"2",
"0.04",
"0",
"0.40",
"0.90",
"0.04",
"0",
),
// Doubao Seed 2.0lite 旧价贵 3-4 倍 + 全系补 cache 命中价
(
"doubao-seed-2-0-lite",
"Doubao Seed 2.0 Lite",
"0.08",
"0.50",
"0.017",
"0",
"0.25",
"2",
"0",
"0",
),
(
"doubao-seed-2-0-pro",
"Doubao Seed 2.0 Pro",
"0.47",
"2.37",
"0.09",
"0",
"0.47",
"2.37",
"0",
"0",
),
(
"doubao-seed-2-0-code",
"Doubao Seed 2.0 Code",
"0.47",
"2.37",
"0.09",
"0",
"0.47",
"2.37",
"0",
"0",
),
(
"doubao-seed-2-0-code-preview-latest",
"Doubao Seed 2.0 Code Preview",
"0.47",
"2.37",
"0.09",
"0",
"0.47",
"2.37",
"0",
"0",
),
(
"doubao-seed-2-0-mini",
"Doubao Seed 2.0 Mini",
"0.03",
"0.31",
"0.0056",
"0",
"0.03",
"0.31",
"0",
"0",
),
// MiMo:5/27 永久降价,旧值是旧价
(
"mimo-v2-pro",
"MiMo V2 Pro",
"0.435",
"0.87",
"0.0036",
"0",
"1",
"3",
"0",
"0",
),
(
"mimo-v2.5",
"MiMo V2.5",
"0.14",
"0.29",
"0.0028",
"0",
"0.09",
"0.29",
"0.009",
"0",
),
(
"mimo-v2.5-pro",
"MiMo V2.5 Pro",
"0.435",
"0.87",
"0.0036",
"0",
"1",
"3",
"0",
"0",
),
// Qwen:官方"隐式缓存 = 输入 20%"补 cache 命中价
(
"qwen3.6-plus",
"Qwen3.6 Plus",
"0.325",
"1.95",
"0.065",
"0",
"0.325",
"1.95",
"0",
"0",
),
(
"qwen3.5-plus",
"Qwen3.5 Plus",
"0.26",
"1.56",
"0.052",
"0",
"0.26",
"1.56",
"0",
"0",
),
(
"qwen3-coder-plus",
"Qwen3 Coder Plus",
"0.65",
"3.25",
"0.13",
"0",
"0.65",
"3.25",
"0",
"0",
),
(
"qwen3-coder-flash",
"Qwen3 Coder Flash",
"0.195",
"0.975",
"0.039",
"0",
"0.195",
"0.975",
"0",
"0",
),
(
"deepseek-v4-flash",
"DeepSeek V4 Flash",
+81
View File
@@ -345,6 +345,87 @@ fn schema_migration_v4_adds_pricing_model_columns() {
);
}
#[test]
fn migration_v10_to_v11_rebuilds_rollups_with_request_model_dimension() {
let conn = Connection::open_in_memory().expect("open memory db");
// 模拟 v10 形状的 rollup 表(主键不含 request_model)+ 一行历史聚合数据,
// 以及 v10 形状的明细表(无 pricing_model 列)
conn.execute_batch(
r#"
CREATE TABLE proxy_request_logs (
request_id TEXT PRIMARY KEY,
model TEXT NOT NULL,
request_model TEXT
);
CREATE TABLE usage_daily_rollups (
date TEXT NOT NULL,
app_type TEXT NOT NULL,
provider_id TEXT NOT NULL,
model TEXT NOT NULL,
request_count INTEGER NOT NULL DEFAULT 0,
success_count INTEGER NOT NULL DEFAULT 0,
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,
total_cost_usd TEXT NOT NULL DEFAULT '0',
avg_latency_ms INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (date, app_type, provider_id, model)
);
INSERT INTO usage_daily_rollups
(date, app_type, provider_id, model, request_count, success_count,
input_tokens, output_tokens, total_cost_usd, avg_latency_ms)
VALUES ('2026-05-01', 'claude', 'p1', 'kimi-k2', 7, 7, 1000, 500, '0.07', 120);
"#,
)
.expect("seed v10 rollup table");
Database::set_user_version(&conn, 10).expect("set user_version=10");
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
// 新列存在且 NOT NULL DEFAULT ''
let request_model = get_column_info(&conn, "usage_daily_rollups", "request_model");
assert_eq!(request_model.r#type, "TEXT");
assert_eq!(request_model.notnull, 1);
let rollup_pricing_model = get_column_info(&conn, "usage_daily_rollups", "pricing_model");
assert_eq!(rollup_pricing_model.r#type, "TEXT");
assert_eq!(rollup_pricing_model.notnull, 1);
// 明细表补上 pricing_model 列(可空,历史行 NULL
let pricing_model = get_column_info(&conn, "proxy_request_logs", "pricing_model");
assert_eq!(pricing_model.r#type, "TEXT");
assert_eq!(pricing_model.notnull, 0);
// 历史行保留,request_model 填 ''(未知)
let (rm, count, input, cost): (String, i64, i64, String) = conn
.query_row(
"SELECT request_model, request_count, input_tokens, total_cost_usd
FROM usage_daily_rollups WHERE model = 'kimi-k2'",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)
.expect("migrated row");
assert_eq!(rm, "");
assert_eq!(count, 7);
assert_eq!(input, 1000);
assert_eq!(cost, "0.07");
// 主键包含 request_model:同 model 不同别名可共存
conn.execute(
"INSERT INTO usage_daily_rollups
(date, app_type, provider_id, model, request_model, request_count)
VALUES ('2026-05-01', 'claude', 'p1', 'kimi-k2', 'claude-sonnet-4-6', 1)",
[],
)
.expect("insert row with same model but different request_model");
assert_eq!(
Database::get_user_version(&conn).expect("version after migration"),
SCHEMA_VERSION
);
}
#[test]
fn schema_create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
let conn = Connection::open_in_memory().expect("open memory db");
+253 -4
View File
@@ -116,10 +116,76 @@ pub fn read_hermes_config() -> Result<serde_yaml::Value, AppError> {
return Ok(serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
}
serde_yaml::from_str(&content)
// Heal duplicate top-level keys left behind by the pre-CRLF-fix append
// bug (#3633); serde_yaml rejects them outright, which bricked the panel.
let deduped = deduplicate_top_level_keys(&content);
serde_yaml::from_str(&deduped)
.map_err(|e| AppError::Config(format!("Failed to parse Hermes config as YAML: {e}")))
}
/// Remove duplicate top-level YAML sections, keeping the LAST occurrence of
/// each key.
///
/// Keep-last is deliberate, not arbitrary: the duplicates come from section
/// replacement degrading into appends (#3633), so the last block is the
/// newest data — and Hermes itself reads the file with PyYAML, whose
/// duplicate-key semantics are last-wins. Keeping the first occurrence would
/// silently roll the user back to stale config and diverge from what Hermes
/// actually runs with.
fn deduplicate_top_level_keys(raw: &str) -> String {
use std::collections::HashMap;
// Pass 1: locate every top-level key line as (key, byte offset).
let mut sections: Vec<(&str, usize)> = Vec::new();
let mut offset = 0;
for line in raw.split('\n') {
if is_top_level_key_line(line) {
if let Some(colon_pos) = line.find(':') {
sections.push((&line[..colon_pos], offset));
}
}
offset += line.len() + 1;
}
let mut remaining: HashMap<&str, usize> = HashMap::new();
for (key, _) in &sections {
*remaining.entry(key).or_insert(0) += 1;
}
if remaining.values().all(|&count| count <= 1) {
return raw.to_string();
}
// Pass 2: re-emit, dropping every section that has a later occurrence of
// the same key. A section spans from its key line to the next top-level
// key line (or EOF), matching find_yaml_section_range. Content before the
// first section (comments, document markers) is always kept.
let mut result = String::with_capacity(raw.len());
let head_end = sections
.first()
.map(|&(_, start)| start)
.unwrap_or(raw.len());
result.push_str(&raw[..head_end]);
for (i, &(key, start)) in sections.iter().enumerate() {
let end = sections
.get(i + 1)
.map(|&(_, next_start)| next_start)
.unwrap_or(raw.len());
let count = remaining.get_mut(key).expect("key collected in pass 1");
*count -= 1;
if *count > 0 {
log::warn!(
"Hermes config: dropped duplicate top-level section '{key}' (keeping the last occurrence)"
);
continue;
}
result.push_str(&raw[start..end]);
}
result
}
// ============================================================================
// YAML Section-Level Replacement
// ============================================================================
@@ -132,6 +198,11 @@ pub fn read_hermes_config() -> Result<serde_yaml::Value, AppError> {
/// - Not be a comment (starting with `#`)
/// - Not be a sequence item (starting with `-`)
/// - Contain `:` followed by space, tab, newline, or end-of-line
///
/// Lines may carry a trailing `\r` (CRLF files split on `\n`) or `\n`
/// (callers using `split_inclusive`); both count as end-of-line after the
/// colon. Rejecting `\r` here used to make every section lookup miss on
/// CRLF configs, turning section replacement into endless appends (#3633).
fn is_top_level_key_line(line: &str) -> bool {
if line.is_empty() {
return false;
@@ -142,7 +213,7 @@ fn is_top_level_key_line(line: &str) -> bool {
}
if let Some(colon_pos) = line.find(':') {
let after_colon = &line[colon_pos + 1..];
after_colon.is_empty() || after_colon.starts_with(' ') || after_colon.starts_with('\t')
after_colon.is_empty() || after_colon.starts_with([' ', '\t', '\r', '\n'])
} else {
false
}
@@ -196,6 +267,21 @@ fn serialize_yaml_section(key: &str, value: &serde_yaml::Value) -> Result<String
Ok(yaml_str)
}
/// Remove every top-level section with the given key from raw YAML text.
/// Used to clean residual duplicates of a key after replacing its first
/// occurrence; safe values come from the keep-last healed read, so dropping
/// all on-disk copies here loses nothing.
fn remove_all_sections(raw: &str, section_key: &str) -> String {
let mut result = String::with_capacity(raw.len());
let mut rest = raw;
while let Some((start, end)) = find_yaml_section_range(rest, section_key) {
result.push_str(&rest[..start]);
rest = &rest[end..];
}
result.push_str(rest);
result
}
/// Replace a YAML section in raw text, or append it if not found.
fn replace_yaml_section(
raw: &str,
@@ -208,12 +294,14 @@ fn replace_yaml_section(
let mut result = String::with_capacity(raw.len());
result.push_str(&raw[..start]);
result.push_str(&serialized);
// Drop duplicate sections of this key from the remainder — configs
// written before the CRLF fix may carry several appended copies.
let remainder = remove_all_sections(&raw[end..], section_key);
// Ensure proper separation between sections
let remainder = &raw[end..];
if !serialized.ends_with('\n') && !remainder.is_empty() && !remainder.starts_with('\n') {
result.push('\n');
}
result.push_str(remainder);
result.push_str(&remainder);
Ok(result)
} else {
// Section not found — append at end
@@ -1204,6 +1292,111 @@ model:
assert!(!section.starts_with("model_extra:"));
}
#[test]
fn find_section_handles_crlf() {
// Regression for #3633: CRLF line endings must not hide sections.
let yaml = "model:\r\n default: gpt-4\r\nagent:\r\n max_turns: 10\r\n";
let (start, end) = find_yaml_section_range(yaml, "model").unwrap();
let section = &yaml[start..end];
assert!(section.starts_with("model:"));
assert!(section.contains("default: gpt-4"));
assert!(!section.contains("agent:"));
}
// ---- deduplicate_top_level_keys tests ----
#[test]
fn dedup_keeps_last_occurrence() {
// Duplicates come from replace-degraded-to-append, so the last block
// is the newest data and must win (PyYAML last-wins, like Hermes).
let yaml = "\
model:
default: gpt-4
agent:
max_turns: 10
model:
default: claude-opus-4-8
";
let result = deduplicate_top_level_keys(yaml);
assert_eq!(
result.lines().filter(|l| *l == "model:").count(),
1,
"duplicate model: section was not removed"
);
assert!(result.contains("claude-opus-4-8"));
assert!(!result.contains("gpt-4"));
assert!(result.contains("max_turns"));
}
#[test]
fn dedup_handles_crlf() {
let yaml = "model:\r\n default: gpt-4\r\nagent:\r\n max_turns: 10\r\nmodel:\r\n default: claude\r\n";
let result = deduplicate_top_level_keys(yaml);
assert_eq!(result.lines().filter(|l| l.trim() == "model:").count(), 1);
assert!(result.contains("default: claude"));
assert!(!result.contains("gpt-4"));
}
#[test]
fn dedup_is_identity_without_duplicates() {
let yaml = "\
# Hermes config
model:
default: gpt-4
agent:
max_turns: 10
";
assert_eq!(deduplicate_top_level_keys(yaml), yaml);
}
#[test]
fn dedup_result_parses_with_last_value() {
// End-to-end: a config that serde_yaml rejects today must parse after
// healing, and expose the newest (last) value.
let yaml = "\
custom_providers:
- name: old-provider
model:
default: gpt-4
custom_providers:
- name: old-provider
- name: new-provider
";
let healed = deduplicate_top_level_keys(yaml);
let value: serde_yaml::Value = serde_yaml::from_str(&healed).unwrap();
let providers = value
.get("custom_providers")
.unwrap()
.as_sequence()
.unwrap();
assert_eq!(providers.len(), 2);
assert_eq!(
providers[1].get("name").unwrap().as_str().unwrap(),
"new-provider"
);
}
// ---- remove_all_sections tests ----
#[test]
fn remove_all_sections_strips_every_occurrence() {
let yaml = "\
model:
default: gpt-4
agent:
max_turns: 10
model:
default: claude
model:
default: gemini
";
let result = remove_all_sections(yaml, "model");
assert!(!result.contains("model:"));
assert!(result.contains("agent:"));
assert!(result.contains("max_turns"));
}
// ---- replace_yaml_section tests ----
#[test]
@@ -1239,6 +1432,62 @@ agent:
assert!(!result.contains("openai"));
}
#[test]
fn replace_section_in_crlf_config_replaces_in_place() {
// Regression for #3633: on CRLF configs every "replace" used to
// degrade into an append, piling up duplicate sections.
let yaml = "model:\r\n default: gpt-4\r\nagent:\r\n max_turns: 10\r\n";
let new_model = serde_yaml::Value::Mapping({
let mut m = serde_yaml::Mapping::new();
m.insert(
serde_yaml::Value::String("default".to_string()),
serde_yaml::Value::String("claude-opus-4-8".to_string()),
);
m
});
let result = replace_yaml_section(yaml, "model", &new_model).unwrap();
assert_eq!(
result.lines().filter(|l| l.trim() == "model:").count(),
1,
"model: must be replaced in place, not appended"
);
assert!(result.contains("claude-opus-4-8"));
assert!(!result.contains("gpt-4"));
assert!(result.contains("max_turns"));
}
#[test]
fn replace_section_removes_residual_duplicates() {
// A config already broken by the append bug: replacing the section
// must also clean the stale duplicate copies after it.
let yaml = "\
model:
default: gpt-4
agent:
max_turns: 10
model:
default: stale-copy
";
let new_model = serde_yaml::Value::Mapping({
let mut m = serde_yaml::Mapping::new();
m.insert(
serde_yaml::Value::String("default".to_string()),
serde_yaml::Value::String("claude-opus-4-8".to_string()),
);
m
});
let result = replace_yaml_section(yaml, "model", &new_model).unwrap();
assert_eq!(result.lines().filter(|l| *l == "model:").count(), 1);
assert!(result.contains("claude-opus-4-8"));
assert!(!result.contains("stale-copy"));
assert!(result.contains("agent:"));
// The healed output must be valid YAML again
let parsed: Result<serde_yaml::Value, _> = serde_yaml::from_str(&result);
assert!(parsed.is_ok());
}
#[test]
fn append_new_section() {
let yaml = "\
+140 -9
View File
@@ -600,6 +600,25 @@ pub fn run() {
log::warn!("✗ Codex provider template bucket migration failed: {e}");
}
}
// 统一会话开关的官方历史迁移:开关开启但上次未完成(如文件被占用
// 中途失败)时在启动期重试;函数内部自门控,开关关闭时直接跳过。
match crate::codex_history_migration::maybe_migrate_codex_official_history_to_unified_bucket() {
Ok(outcome) => {
if let Some(reason) = outcome.skipped_reason {
log::debug!("○ Codex official history unify migration skipped: {reason}");
} else {
log::info!(
"✓ Codex official history unify migration completed: jsonl_files={}, state_rows={}",
outcome.migrated_jsonl_files,
outcome.migrated_state_rows
);
}
}
Err(e) => {
log::warn!("✗ Codex official history unify migration failed: {e}");
}
}
});
}
@@ -1154,6 +1173,8 @@ pub fn run() {
commands::read_live_provider_settings,
commands::get_settings,
commands::save_settings,
commands::has_codex_unify_history_backup,
commands::restore_codex_unified_history,
commands::get_rectifier_config,
commands::set_rectifier_config,
commands::get_optimizer_config,
@@ -1163,6 +1184,7 @@ pub fn run() {
commands::get_log_config,
commands::set_log_config,
commands::restart_app,
commands::install_update_and_restart,
commands::check_for_updates,
commands::is_portable_mode,
commands::copy_text_to_clipboard,
@@ -1441,14 +1463,37 @@ pub fn run() {
app.run(|app_handle, event| {
// 处理退出请求(所有平台)
if let RunEvent::ExitRequested { api, code, .. } = &event {
// code 为 None 表示运行时自动触发(如隐藏窗口的 WebView 被回收导致无存活窗口),
// 此时应仅阻止退出、保持托盘后台运行;
// code 为 Some(_) 表示用户主动调用 app.exit() 退出(如托盘菜单"退出"),
// 此时执行清理后退出。
if code.is_none() {
log::info!("运行时触发退出请求(无存活窗口),阻止退出以保持托盘后台运行");
api.prevent_exit();
return;
match classify_exit_request(*code) {
// code 为 None 表示运行时自动触发(如隐藏窗口的 WebView 被回收导致无存活窗口),
// 此时应仅阻止退出、保持托盘后台运行。
ExitRequestAction::StayInTray => {
log::info!("运行时触发退出请求(无存活窗口),阻止退出以保持托盘后台运行");
api.prevent_exit();
return;
}
// code 为 RESTART_EXIT_CODEapp.restart() / 自更新 relaunch 发起的重启。
// 这条路径上 prevent_exit() 会被 Tauri 忽略,事件循环必定退出,随后由
// Tauri 在 RunEvent::Exit 后用新二进制 re-execmacOS 会按更新后的
// Info.plist 解析可执行名)。
//
// 绝不能复用下面的异步清理任务:该任务在 tokio 线程调 save_window_state
// 持有 window-state 插件锁的同时向主线程查询窗口几何;而主线程此刻正在
// 退出事件循环,并在插件自带的 RunEvent::Exit 钩子里等待同一把锁——双方
// 互等造成进程永久卡死(更新已安装但应用冻结、不再重启,见 #3998)。
//
// 重启路径交还 Tauri 默认流程即可:
// - 窗口状态:插件 Exit 钩子在主线程保存(同线程读取窗口几何,无死锁)
// - 托盘图标:Tauri 内部 cleanup_before_exit 清理,正常走 Drop
// - 代理/Live 配置:无需恢复,重启后新实例立即接管并恢复代理状态
// - 100ms 落盘等待:重启前的 DB 写入均为命令驱动、此刻已完成,
// 与所有 Tauri 应用默认重启路径的行为一致,无需额外等待
ExitRequestAction::DeferToTauriRestart => {
log::info!("收到重启请求 (code={code:?}),交由 Tauri 默认重启流程 re-exec");
return;
}
// 其它 Some(_):用户主动调用 app.exit() 退出(如托盘菜单"退出"),
// 此时执行清理后退出。
ExitRequestAction::CleanupAndExit => {}
}
log::info!("收到用户主动退出请求 (code={code:?}),开始清理...");
@@ -1620,7 +1665,7 @@ pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) {
/// 触发 tray-icon 内部的 `remove_tray_icon` → `Shell_NotifyIconW(NIM_DELETE)`
/// 在进程结束前干净地把图标摘掉。其它平台 `set_visible(false)` 也是
/// 正常的隐藏/移除语义,作为跨平台兜底也安全。
fn remove_tray_icon_before_exit(app_handle: &tauri::AppHandle) {
pub(crate) fn remove_tray_icon_before_exit(app_handle: &tauri::AppHandle) {
if let Some(tray) = app_handle.tray_by_id(tray::TRAY_ID) {
if let Err(e) = tray.set_visible(false) {
log::warn!("退出时移除托盘图标失败: {e}");
@@ -1889,6 +1934,36 @@ fn show_database_init_error_dialog(
.blocking_show()
}
// ============================================================
// 退出请求分类
// ============================================================
/// `RunEvent::ExitRequested` 的三类来源,处理方式必须区分。
///
/// 关键约束:重启请求(`code == RESTART_EXIT_CODE`)上 `prevent_exit()` 会被
/// Tauri 静默忽略(见 `ExitRequestApi::prevent_exit` 文档),事件循环必定继续
/// 退出并触发各插件的 `RunEvent::Exit` 钩子;任何与之并发的自定义清理任务都
/// 可能与插件退出钩子争用同一状态而死锁。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ExitRequestAction {
/// `code` 为 `None`:运行时自动触发(如隐藏窗口的 WebView 被回收导致无存活
/// 窗口),阻止退出、保持托盘后台运行。
StayInTray,
/// `code` 为 `RESTART_EXIT_CODE``app.restart()` / 自更新 relaunch 发起的
/// 重启,不拦截、不做自定义清理,交还 Tauri 默认 re-exec 流程。
DeferToTauriRestart,
/// 其它 `Some(_)`:用户主动退出(托盘「退出」等),执行完整异步清理后结束进程。
CleanupAndExit,
}
fn classify_exit_request(code: Option<i32>) -> ExitRequestAction {
match code {
None => ExitRequestAction::StayInTray,
Some(tauri::RESTART_EXIT_CODE) => ExitRequestAction::DeferToTauriRestart,
Some(_) => ExitRequestAction::CleanupAndExit,
}
}
// ============================================================
// 在应用主动退出前显式持久化窗口状态
// ============================================================
@@ -1906,3 +1981,59 @@ pub fn save_window_state_before_exit(app_handle: &tauri::AppHandle) {
log::info!("已在退出前保存窗口状态");
}
}
/// 主动释放 single-instance 锁。
///
/// macOS single-instance 使用 `/tmp/{identifier}.sock`。我们有若干路径会直接
/// `std::process::exit(0)`,不会触发插件挂在 `RunEvent::Exit` 上的清理钩子。
/// 重启前主动 destroy 可以避免新进程误连旧 listener 后自行退出。
pub fn destroy_single_instance_lock(app_handle: &tauri::AppHandle) {
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
tauri_plugin_single_instance::destroy(app_handle);
}
/// 清理托盘图标、释放 single-instance 锁后重启当前应用。
///
/// 直接走 `tauri::process::restart`spawn 新进程 + `exit(0)`),不经过事件
/// 循环退出,因此 Tauri 内部的 `cleanup_before_exit` 和各插件的
/// `RunEvent::Exit` 钩子都不会执行。需要的清理由调用方与本函数显式补偿:
/// 窗口状态、代理/Live 恢复(调用方);托盘图标、single-instance 锁(本函数)。
///
/// 有意不调 `AppHandle::cleanup_before_exit()`:它会在调用线程上 Drop 托盘
/// 图标,而 macOS 的 NSStatusItem 操作要求主线程;`set_visible(false)` 走
/// `run_item_main_thread` 代理,跨线程安全(见 `remove_tray_icon_before_exit`)。
pub fn restart_process(app_handle: &tauri::AppHandle) -> ! {
remove_tray_icon_before_exit(app_handle);
destroy_single_instance_lock(app_handle);
tauri::process::restart(&app_handle.env());
}
#[cfg(test)]
mod tests {
use super::{classify_exit_request, ExitRequestAction};
#[test]
fn no_code_keeps_app_alive_in_tray() {
assert_eq!(classify_exit_request(None), ExitRequestAction::StayInTray);
}
#[test]
fn restart_exit_code_defers_to_tauri_default_restart() {
assert_eq!(
classify_exit_request(Some(tauri::RESTART_EXIT_CODE)),
ExitRequestAction::DeferToTauriRestart
);
}
#[test]
fn user_exit_codes_run_cleanup_then_exit() {
assert_eq!(
classify_exit_request(Some(0)),
ExitRequestAction::CleanupAndExit
);
assert_eq!(
classify_exit_request(Some(1)),
ExitRequestAction::CleanupAndExit
);
}
}
+37 -14
View File
@@ -1,3 +1,4 @@
use http::header::{HeaderValue, InvalidHeaderValue};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -108,17 +109,13 @@ impl Provider {
.unwrap_or(false)
}
/// Resolve `(base_url, api_key)` for native usage queries (balance /
/// coding-plan) from the stored provider config.
/// Resolve `(base_url, api_key)` for usage queries (native balance /
/// coding-plan and the JS-script `{{apiKey}}`/`{{baseUrl}}` fallback)
/// from the stored provider config.
///
/// Each app persists credentials in a different shape, so callers must pass
/// the owning app type. This mirrors the frontend `getProviderCredentials`
/// in `UsageScriptModal.tsx`.
///
/// TODO: the env-only helpers in `services/provider/usage.rs`
/// (`extract_api_key_from_provider` / `extract_base_url_from_provider`)
/// duplicate this per-app logic on the JS-script path and could delegate
/// here in a follow-up to remove the remaining copy.
pub fn resolve_usage_credentials(
&self,
app_type: &crate::app_config::AppType,
@@ -290,21 +287,15 @@ pub struct UsageResult {
pub error: Option<String>,
}
/// 供应商单独的模型测试配置
/// 供应商单独的连通检测配置
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderTestConfig {
/// 是否启用单独配置(false 时使用全局配置)
#[serde(default)]
pub enabled: bool,
/// 测试用的模型名称(覆盖全局配置)
#[serde(rename = "testModel", skip_serializing_if = "Option::is_none")]
pub test_model: Option<String>,
/// 超时时间(秒)
#[serde(rename = "timeoutSecs", skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,
/// 测试提示词
#[serde(rename = "testPrompt", skip_serializing_if = "Option::is_none")]
pub test_prompt: Option<String>,
/// 降级阈值(毫秒)
#[serde(
rename = "degradedThresholdMs",
@@ -464,6 +455,9 @@ pub struct ProviderMeta {
/// Codex Responses -> Chat Completions reasoning capability metadata.
#[serde(rename = "codexChatReasoning", skip_serializing_if = "Option::is_none")]
pub codex_chat_reasoning: Option<CodexChatReasoningConfig>,
/// Custom User-Agent for local proxy routing.
#[serde(rename = "customUserAgent", skip_serializing_if = "Option::is_none")]
pub custom_user_agent: Option<String>,
/// 累加模式应用中,该 provider 是否已写入 live config。
/// `None` 表示旧数据/未知状态,`Some(false)` 表示明确仅存在于数据库中。
#[serde(rename = "liveConfigManaged", skip_serializing_if = "Option::is_none")]
@@ -478,6 +472,30 @@ pub struct ProviderMeta {
pub github_account_id: Option<String>,
}
/// 解析 Provider 级自定义 User-Agent 字符串(单一真理来源)。
///
/// 转发(forwarder)、流式检测(stream_check)、获取模型列表(model_fetch)三条路径
/// 共用同一口径,避免出现"某条路径用了 UA、另一条没用 / 报错"的不一致。
///
/// 合法性由 `http::HeaderValue::from_str` 按**字节**判定(`b >= 32 && b != 127 || b == '\t'`),
/// 与前端 `src/lib/userAgent.ts::isValidUserAgentHeader` 严格一致:
/// - `Ok(None)`:未设置或纯空白(trim 后为空)。
/// - `Ok(Some(hv))`:合法。制表符、可见 ASCII(0x20–0x7E)、以及任意非 ASCII 字符
/// UTF-8 字节均 ≥ 0x80)都合法。
/// - `Err(_)`:仅含控制字符时——除 `\t` 外的 0x000x1F(含换行)与 0x7FDEL)。
///
/// 非法值的处理:三条运行时路径**均静默忽略**`.ok().flatten()`,绝不让某条路径报错而
/// 另一条放行);前端在输入框处给出非阻断提示。当前**不在保存时阻断**——deeplink 导入等
/// 非表单路径应宽容,运行时静默忽略即为安全网。
pub fn parse_custom_user_agent(
raw: Option<&str>,
) -> Result<Option<HeaderValue>, InvalidHeaderValue> {
match raw.map(str::trim).filter(|s| !s.is_empty()) {
Some(ua) => HeaderValue::from_str(ua).map(Some),
None => Ok(None),
}
}
impl ProviderMeta {
/// Codex OAuth FAST mode 是否启用。默认关闭,因为 `service_tier="priority"`
/// 会按更高速率消耗 ChatGPT 订阅配额,用户需显式开启以换取更低延迟。
@@ -485,6 +503,11 @@ impl ProviderMeta {
self.codex_fast_mode.unwrap_or(false)
}
/// 经校验的 Provider 级自定义 User-Agent。见 [`parse_custom_user_agent`]。
pub fn custom_user_agent_header(&self) -> Result<Option<HeaderValue>, InvalidHeaderValue> {
parse_custom_user_agent(self.custom_user_agent.as_deref())
}
/// 解析指定托管认证供应商绑定的账号 ID。
///
/// 新版优先读取 authBinding,旧版继续兼容 githubAccountId。
+100 -8
View File
@@ -38,6 +38,11 @@ pub struct ForwardResult {
pub response: ProxyResponse,
pub provider: Provider,
pub claude_api_format: Option<String>,
/// 实际发往上游的模型名(路由接管/模型映射后的真值)。
///
/// usage 归因不能依赖 ctx.request_model(映射前的客户端别名):上游响应
/// 缺失 model 或回显别名时,接管流量会被记成 claude-* 并按其定价计费。
pub outbound_model: Option<String>,
/// 活跃连接 RAII guard:随响应一起流转到 response_processor / handle_claude_transform
/// 最终被 move 进流式 body future(或非流式响应作用域),覆盖整个响应生命周期。
pub(crate) connection_guard: Option<ActiveConnectionGuard>,
@@ -159,7 +164,7 @@ impl RequestForwarder {
provider_body: &Value,
error: &ProxyError,
) -> bool {
adapter_name == "Claude"
matches!(adapter_name, "Claude" | "Codex")
&& self.rectifier_config.enabled
&& self.rectifier_config.request_media_fallback
&& !already_retried
@@ -463,7 +468,7 @@ impl RequestForwarder {
)
.await
{
Ok((response, claude_api_format)) => {
Ok((response, claude_api_format, outbound_model)) => {
// 成功:普通闭合熔断状态异步记录,避免阻塞流式首包返回;
// HalfOpen 探测仍同步等待,保证 permit 与熔断状态及时释放。
self.record_success_result(&provider.id, app_type_str, used_half_open_permit)
@@ -511,6 +516,7 @@ impl RequestForwarder {
response,
provider: provider.clone(),
claude_api_format,
outbound_model,
connection_guard: None,
});
}
@@ -561,7 +567,7 @@ impl RequestForwarder {
)
.await
{
Ok((response, claude_api_format)) => {
Ok((response, claude_api_format, outbound_model)) => {
log::info!(
"[{app_type_str}] [Media] Unsupported-image retry succeeded"
);
@@ -613,6 +619,7 @@ impl RequestForwarder {
response,
provider: provider.clone(),
claude_api_format,
outbound_model,
connection_guard: None,
});
}
@@ -706,7 +713,7 @@ impl RequestForwarder {
)
.await
{
Ok((response, claude_api_format)) => {
Ok((response, claude_api_format, outbound_model)) => {
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
self.record_success_result(
&provider.id,
@@ -761,6 +768,7 @@ impl RequestForwarder {
response,
provider: provider.clone(),
claude_api_format,
outbound_model,
connection_guard: None,
});
}
@@ -871,7 +879,7 @@ impl RequestForwarder {
)
.await
{
Ok((response, claude_api_format)) => {
Ok((response, claude_api_format, outbound_model)) => {
log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功");
self.record_success_result(
&provider.id,
@@ -920,6 +928,7 @@ impl RequestForwarder {
response,
provider: provider.clone(),
claude_api_format,
outbound_model,
connection_guard: None,
});
}
@@ -1077,6 +1086,9 @@ impl RequestForwarder {
}
/// 转发单个请求(使用适配器)
///
/// 成功时返回 `(response, claude_api_format, outbound_model)`,其中
/// `outbound_model` 是最终发往上游的模型名(所有映射/改写之后)。
#[allow(clippy::too_many_arguments)]
async fn forward(
&self,
@@ -1088,7 +1100,7 @@ impl RequestForwarder {
headers: &axum::http::HeaderMap,
extensions: &Extensions,
adapter: &dyn ProviderAdapter,
) -> Result<(ProxyResponse, Option<String>), ProxyError> {
) -> Result<(ProxyResponse, Option<String>, Option<String>), ProxyError> {
// 使用适配器提取 base_url
let mut base_url = adapter.extract_base_url(provider)?;
@@ -1320,8 +1332,17 @@ impl RequestForwarder {
adapter.build_url(&base_url, &effective_endpoint)
};
// 记录映射后的出站模型名(此时 mapped_body 已完成接管映射 / [1m] 剥离 /
// Copilot 归一化)。格式转换后若 body 仍带 model 字段会在下方刷新覆盖;
// gemini_native 等模型在 URL 中的格式则保留此处的转换前真值。
let mut outbound_model = mapped_body
.get("model")
.and_then(|m| m.as_str())
.filter(|m| !m.is_empty())
.map(str::to_string);
// 转换请求体(如果需要)
let request_body = if codex_responses_to_chat {
let mut request_body = if codex_responses_to_chat {
let mut mapped_body = mapped_body;
let restored = self
.codex_chat_history
@@ -1359,9 +1380,21 @@ impl RequestForwarder {
mapped_body
};
if matches!(app_type, AppType::Codex) {
self.apply_media_prevention(&mut request_body, provider);
}
// 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游
// 默认使用空白名单,过滤所有 _ 前缀字段
let filtered_body = prepare_upstream_request_body(request_body);
// 出站 body 定稿后刷新真值(覆盖 Codex chat 上游模型覆写、转换层模型改写)
if let Some(m) = filtered_body
.get("model")
.and_then(|m| m.as_str())
.filter(|m| !m.is_empty())
{
outbound_model = Some(m.to_string());
}
log_prompt_cache_trace(
app_type,
provider,
@@ -1504,6 +1537,18 @@ impl RequestForwarder {
Vec::new()
};
// 自定义 User-Agent:与 stream_check / model_fetch 共用 parse_custom_user_agent
// 运行时静默忽略非法值(前端在输入处给非阻断提示,不在保存时阻断)。
// Copilot 指纹 UA 不可覆盖。
let custom_user_agent = if is_copilot {
None
} else {
provider
.meta
.as_ref()
.and_then(|meta| meta.custom_user_agent_header().ok().flatten())
};
// --- Copilot 优化器:动态 header 注入 ---
if let Some((ref classification, ref det_request_id, ref interaction_id)) =
copilot_optimization
@@ -1598,6 +1643,7 @@ impl RequestForwarder {
let mut ordered_headers = http::HeaderMap::new();
let mut saw_auth = false;
let mut saw_accept_encoding = false;
let mut saw_user_agent = false;
let mut saw_anthropic_beta = false;
let mut saw_anthropic_version = false;
@@ -1678,6 +1724,19 @@ impl RequestForwarder {
continue;
}
// --- user-agent: provider-level override for local proxy routing ---
if !is_copilot && key_str.eq_ignore_ascii_case("user-agent") {
if !saw_user_agent {
saw_user_agent = true;
if let Some(ref ua) = custom_user_agent {
ordered_headers.append(http::header::USER_AGENT, ua.clone());
} else {
ordered_headers.append(key.clone(), value.clone());
}
}
continue;
}
// --- anthropic-beta — 用重建值替换(确保含 claude-code 标记) ---
if key_str.eq_ignore_ascii_case("anthropic-beta") {
if !saw_anthropic_beta {
@@ -1727,6 +1786,12 @@ impl RequestForwarder {
);
}
if !saw_user_agent {
if let Some(ref ua) = custom_user_agent {
ordered_headers.append(http::header::USER_AGENT, ua.clone());
}
}
// 如果原始请求中没有 anthropic-beta 且有值需要添加,追加
if !saw_anthropic_beta {
if let Some(ref beta_val) = anthropic_beta_value {
@@ -1874,7 +1939,7 @@ impl RequestForwarder {
let response = self
.prepare_success_response_for_failover(response, request_is_streaming)
.await?;
Ok((response, resolved_claude_api_format))
Ok((response, resolved_claude_api_format, outbound_model))
} else {
let status_code = status.as_u16();
let body_text = String::from_utf8(response.bytes().await?.to_vec()).ok();
@@ -3298,6 +3363,18 @@ mod tests {
})
}
fn body_with_codex_input_image(model: &str) -> Value {
json!({
"model": model,
"input": [{
"role": "user",
"content": [
{ "type": "input_image", "image_url": "data:image/png;base64,abc" }
]
}]
})
}
fn image_unsupported_error() -> ProxyError {
ProxyError::UpstreamError {
status: 400,
@@ -3385,6 +3462,21 @@ mod tests {
assert!(fwd.media_retry_should_trigger("Claude", false, &body, &image_unsupported_error()));
}
#[test]
fn reactive_triggers_for_codex_image_url_deserialize_errors() {
let fwd = forwarder_with_rectifier(RectifierConfig::default());
let body = body_with_codex_input_image("deepseek-v4-flash");
let error = ProxyError::UpstreamError {
status: 400,
body: Some(
r#"{"error":{"message":"Failed to deserialize the JSON body into the target type: messages[11]: unknown variant image_url, expected text"}}"#
.to_string(),
),
};
assert!(fwd.media_retry_should_trigger("Codex", false, &body, &error));
}
#[test]
fn reactive_skipped_when_media_fallback_off() {
// 关闭 request_media_fallback:上游报图片错误也不触发兜底重试。
+23 -15
View File
@@ -60,37 +60,40 @@ fn gemini_stream_usage_event_filter(data: &str) -> bool {
// ============================================================================
/// Claude 流式响应模型提取(优先使用 usage.model
fn claude_model_extractor(events: &[Value], request_model: &str) -> String {
///
/// 空字符串模型名视为缺失(转换层对无回显上游会合成 model:""),
/// 落到 fallback_model(映射后的出站模型或客户端请求模型)。
fn claude_model_extractor(events: &[Value], fallback_model: &str) -> String {
// 首先尝试从解析的 usage 中获取模型
if let Some(usage) = TokenUsage::from_claude_stream_events(events) {
if let Some(model) = usage.model {
if let Some(model) = usage.model.filter(|m| !m.is_empty()) {
return model;
}
}
request_model.to_string()
fallback_model.to_string()
}
/// OpenAI Chat Completions 流式响应模型提取(优先使用 usage.model
fn openai_model_extractor(events: &[Value], request_model: &str) -> String {
fn openai_model_extractor(events: &[Value], fallback_model: &str) -> String {
// 首先尝试从解析的 usage 中获取模型
if let Some(usage) = TokenUsage::from_openai_stream_events(events) {
if let Some(model) = usage.model {
if let Some(model) = usage.model.filter(|m| !m.is_empty()) {
return model;
}
}
// 回退:从事件中直接提取
events
.iter()
.find_map(|e| e.get("model")?.as_str())
.unwrap_or(request_model)
.find_map(|e| e.get("model")?.as_str().filter(|m| !m.is_empty()))
.unwrap_or(fallback_model)
.to_string()
}
/// Codex 智能流式响应模型提取(自动检测格式)
fn codex_auto_model_extractor(events: &[Value], request_model: &str) -> String {
fn codex_auto_model_extractor(events: &[Value], fallback_model: &str) -> String {
// 首先尝试从解析的 usage 中获取模型
if let Some(usage) = TokenUsage::from_codex_stream_events_auto(events) {
if let Some(model) = usage.model {
if let Some(model) = usage.model.filter(|m| !m.is_empty()) {
return model;
}
}
@@ -99,28 +102,33 @@ fn codex_auto_model_extractor(events: &[Value], request_model: &str) -> String {
.iter()
.find_map(|e| {
if e.get("type")?.as_str()? == "response.completed" {
e.get("response")?.get("model")?.as_str()
e.get("response")?
.get("model")?
.as_str()
.filter(|m| !m.is_empty())
} else {
None
}
})
.or_else(|| {
// 再回退:从 OpenAI 格式事件中提取
events.iter().find_map(|e| e.get("model")?.as_str())
events
.iter()
.find_map(|e| e.get("model")?.as_str().filter(|m| !m.is_empty()))
})
.unwrap_or(request_model)
.unwrap_or(fallback_model)
.to_string()
}
/// Gemini 流式响应模型提取(优先使用 usage.model
fn gemini_model_extractor(events: &[Value], request_model: &str) -> String {
fn gemini_model_extractor(events: &[Value], fallback_model: &str) -> String {
// 首先尝试从解析的 usage 中获取模型
if let Some(usage) = TokenUsage::from_gemini_stream_chunks(events) {
if let Some(model) = usage.model {
if let Some(model) = usage.model.filter(|m| !m.is_empty()) {
return model;
}
}
request_model.to_string()
fallback_model.to_string()
}
// ============================================================================
+6
View File
@@ -48,6 +48,11 @@ pub struct RequestContext {
pub current_provider_id: String,
/// 请求中的模型名称
pub request_model: String,
/// 实际发往上游的模型名(路由接管/模型映射后的真值,forward 成功后回填)。
///
/// usage 归因的兜底顺序:上游响应回显 → outbound_model → request_model。
/// 不能直接用 request_model 兜底:接管场景下它是映射前的客户端别名。
pub outbound_model: Option<String>,
/// 日志标签(如 "Claude"、"Codex"、"Gemini"
pub tag: &'static str,
/// 应用类型字符串(如 "claude"、"codex"、"gemini"
@@ -159,6 +164,7 @@ impl RequestContext {
providers,
current_provider_id,
request_model,
outbound_model: None,
tag,
app_type_str,
app_type,
File diff suppressed because it is too large Load Diff
+155 -27
View File
@@ -41,14 +41,7 @@ pub fn replace_images_for_text_only_model(
}
pub fn contains_image_blocks(body: &Value) -> bool {
body.get("messages")
.and_then(Value::as_array)
.is_some_and(|messages| {
messages
.iter()
.filter_map(|message| message.get("content"))
.any(content_has_image_blocks)
})
messages_have_image_blocks(body) || responses_input_has_image_blocks(body.get("input"))
}
pub fn replace_image_blocks_with_marker(body: &mut Value) -> usize {
@@ -95,6 +88,7 @@ pub fn is_unsupported_image_error(error: &ProxyError) -> bool {
"text-only",
"invalid content type",
"invalid message content",
"unknown variant",
"unknown content type",
"unrecognized content type",
"cannot process",
@@ -113,51 +107,124 @@ fn content_has_image_blocks(content: &Value) -> bool {
};
blocks.iter().any(|block| {
block.get("type").and_then(Value::as_str) == Some("image")
is_image_block_type(block.get("type").and_then(Value::as_str))
|| block.get("content").is_some_and(content_has_image_blocks)
})
}
fn replace_images_in_body(body: &mut Value) -> usize {
let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else {
return 0;
};
let message_replacements = body
.get_mut("messages")
.and_then(Value::as_array_mut)
.map(|messages| {
messages
.iter_mut()
.filter_map(|message| message.get_mut("content"))
.map(replace_images_in_content)
.sum()
})
.unwrap_or(0);
messages
.iter_mut()
.filter_map(|message| message.get_mut("content"))
.map(replace_images_in_content)
.sum()
message_replacements
+ body
.get_mut("input")
.map(replace_images_in_responses_input)
.unwrap_or(0)
}
fn replace_images_in_content(content: &mut Value) -> usize {
replace_images_in_content_with_text_type(content, "text")
}
fn replace_images_in_content_with_text_type(content: &mut Value, text_type: &str) -> usize {
let Some(blocks) = content.as_array_mut() else {
return 0;
};
let mut replaced = 0usize;
for block in blocks {
if block.get("type").and_then(Value::as_str) == Some("image") {
let cache_control = block.get("cache_control").cloned();
*block = json!({
"type": "text",
"text": UNSUPPORTED_IMAGE_MARKER
});
if let (Some(cache_control), Some(object)) = (cache_control, block.as_object_mut()) {
object.insert("cache_control".to_string(), cache_control);
}
if is_image_block_type(block.get("type").and_then(Value::as_str)) {
replace_image_block_with_text_marker(block, text_type);
replaced += 1;
continue;
}
if let Some(nested_content) = block.get_mut("content") {
replaced += replace_images_in_content(nested_content);
replaced += replace_images_in_content_with_text_type(nested_content, text_type);
}
}
replaced
}
fn messages_have_image_blocks(body: &Value) -> bool {
body.get("messages")
.and_then(Value::as_array)
.is_some_and(|messages| {
messages
.iter()
.filter_map(|message| message.get("content"))
.any(content_has_image_blocks)
})
}
fn responses_input_has_image_blocks(input: Option<&Value>) -> bool {
match input {
Some(Value::Array(items)) => items.iter().any(responses_input_item_has_image_blocks),
Some(item @ Value::Object(_)) => responses_input_item_has_image_blocks(item),
_ => false,
}
}
fn responses_input_item_has_image_blocks(item: &Value) -> bool {
if item.get("type").and_then(Value::as_str) == Some("input_image") {
return true;
}
item.get("content").is_some_and(content_has_image_blocks)
}
fn replace_images_in_responses_input(input: &mut Value) -> usize {
match input {
Value::Array(items) => items
.iter_mut()
.map(replace_images_in_responses_input_item)
.sum(),
Value::Object(_) => replace_images_in_responses_input_item(input),
_ => 0,
}
}
fn replace_images_in_responses_input_item(item: &mut Value) -> usize {
let mut replaced = 0usize;
if item.get("type").and_then(Value::as_str) == Some("input_image") {
replace_image_block_with_text_marker(item, "input_text");
replaced += 1;
}
if let Some(content) = item.get_mut("content") {
replaced += replace_images_in_content_with_text_type(content, "input_text");
}
replaced
}
fn is_image_block_type(block_type: Option<&str>) -> bool {
matches!(block_type, Some("image" | "image_url" | "input_image"))
}
fn replace_image_block_with_text_marker(block: &mut Value, text_type: &str) {
let cache_control = block.get("cache_control").cloned();
*block = json!({
"type": text_type,
"text": UNSUPPORTED_IMAGE_MARKER
});
if let (Some(cache_control), Some(object)) = (cache_control, block.as_object_mut()) {
object.insert("cache_control".to_string(), cache_control);
}
}
fn explicit_model_image_support(provider: &Provider, model: &str) -> Option<bool> {
let settings = &provider.settings_config;
[
@@ -369,6 +436,54 @@ mod tests {
);
}
#[test]
fn known_text_only_models_replace_chat_image_url_before_send() {
let provider = provider(json!({}));
let mut body = json!({
"model": "deepseek-v4-flash",
"messages": [{
"role": "user",
"content": [
{ "type": "text", "text": "look" },
{ "type": "image_url", "image_url": { "url": "data:image/png;base64,abc" } }
]
}]
});
let count = replace_images_for_text_only_model(&mut body, &provider, true);
assert_eq!(count, 1);
assert_eq!(body["messages"][0]["content"][1]["type"], "text");
assert_eq!(
body["messages"][0]["content"][1]["text"],
UNSUPPORTED_IMAGE_MARKER
);
}
#[test]
fn known_text_only_models_replace_codex_input_image_before_send() {
let provider = provider(json!({}));
let mut body = json!({
"model": "deepseek-v4-flash",
"input": [{
"role": "user",
"content": [
{ "type": "input_text", "text": "look" },
{ "type": "input_image", "image_url": "data:image/png;base64,abc" }
]
}]
});
let count = replace_images_for_text_only_model(&mut body, &provider, true);
assert_eq!(count, 1);
assert_eq!(body["input"][0]["content"][1]["type"], "input_text");
assert_eq!(
body["input"][0]["content"][1]["text"],
UNSUPPORTED_IMAGE_MARKER
);
}
#[test]
fn explicit_text_modalities_replace_images_before_send() {
let provider = provider(json!({
@@ -654,6 +769,19 @@ mod tests {
assert!(is_unsupported_image_error(&attachment_error));
}
#[test]
fn detects_chat_content_unknown_variant_image_url_errors() {
let error = ProxyError::UpstreamError {
status: 400,
body: Some(
r#"{"error":{"message":"Failed to deserialize the JSON body into the target type: messages[11]: unknown variant image_url, expected text"}}"#
.to_string(),
),
};
assert!(is_unsupported_image_error(&error));
}
#[test]
fn heuristic_disabled_keeps_images_for_listed_text_only_models() {
// allow_heuristic = false:内置列表不再预测性剥图,避免误判多模态模型时静默丢图。
+67 -1
View File
@@ -11,6 +11,7 @@ pub struct ModelMapping {
pub haiku_model: Option<String>,
pub sonnet_model: Option<String>,
pub opus_model: Option<String>,
pub fable_model: Option<String>,
pub default_model: Option<String>,
}
@@ -35,6 +36,11 @@ impl ModelMapping {
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
fable_model: env
.and_then(|e| e.get("ANTHROPIC_DEFAULT_FABLE_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())
@@ -48,6 +54,7 @@ impl ModelMapping {
self.haiku_model.is_some()
|| self.sonnet_model.is_some()
|| self.opus_model.is_some()
|| self.fable_model.is_some()
|| self.default_model.is_some()
}
@@ -56,6 +63,16 @@ impl ModelMapping {
let model_lower = original_model.to_lowercase();
// 1. 按模型类型匹配
if model_lower.contains("fable") {
if let Some(ref m) = self.fable_model {
return m.clone();
}
// 未单独配置 fable 档时归入 opus 档,与 Claude Code 官方
// 分类器降级方向一致(fable→opus),避免落到 default 失去层级。
if let Some(ref m) = self.opus_model {
return m.clone();
}
}
if model_lower.contains("haiku") {
if let Some(ref m) = self.haiku_model {
return m.clone();
@@ -154,7 +171,8 @@ mod tests {
"ANTHROPIC_MODEL": "default-model",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-mapped",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "sonnet-mapped",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped"
"ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped",
"ANTHROPIC_DEFAULT_FABLE_MODEL": "fable-mapped"
}
}),
website_url: None,
@@ -214,6 +232,54 @@ mod tests {
assert_eq!(mapped, Some("opus-mapped".to_string()));
}
#[test]
fn test_fable_mapping() {
let provider = create_provider_with_mapping();
let body = json!({"model": "claude-fable-5"});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "fable-mapped");
assert_eq!(mapped, Some("fable-mapped".to_string()));
}
#[test]
fn test_fable_with_one_m_suffix_mapping() {
// Claude Code 实际会发 claude-fable-5[1m] 形态(issue #3980
let provider = create_provider_with_mapping();
let body = json!({"model": "claude-fable-5[1m]"});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "fable-mapped");
assert_eq!(mapped, Some("fable-mapped".to_string()));
}
#[test]
fn test_fable_falls_back_to_opus_when_unset() {
let mut provider = create_provider_with_mapping();
provider.settings_config = json!({
"env": {
"ANTHROPIC_MODEL": "default-model",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped"
}
});
let body = json!({"model": "claude-fable-5"});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "opus-mapped");
assert_eq!(mapped, Some("opus-mapped".to_string()));
}
#[test]
fn test_fable_falls_back_to_default_without_opus() {
let mut provider = create_provider_with_mapping();
provider.settings_config = json!({
"env": {
"ANTHROPIC_MODEL": "default-model"
}
});
let body = json!({"model": "claude-fable-5"});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "default-model");
assert_eq!(mapped, Some("default-model".to_string()));
}
#[test]
fn test_thinking_does_not_affect_model_mapping() {
// Issue #2081: thinking 参数不应影响模型映射
+41
View File
@@ -409,6 +409,10 @@ pub fn transform_claude_request_for_api_format(
{
result["prompt_cache_key"] = serde_json::json!(key);
}
// 流式请求必须注入 stream_options.include_usage,否则 OpenAI 兼容上游
// 不在 SSE 末尾吐 usage → 转换出的 Anthropic message_delta 全 0 →
// 整笔 input/output/cache 漏记(与 Codex Responses→Chat 路径同源)。
super::transform::inject_openai_stream_include_usage(&mut result);
Ok(result)
}
"gemini_native" => super::transform_gemini::anthropic_to_gemini_with_shadow(
@@ -1617,6 +1621,43 @@ mod tests {
assert!(transformed.get("max_output_tokens").is_some());
}
#[test]
fn test_transform_claude_request_openai_chat_streaming_injects_include_usage() {
let provider = create_provider(json!({
"env": { "ANTHROPIC_BASE_URL": "https://openrouter.ai/api/v1" }
}));
// 流式请求必须注入 stream_options.include_usage,否则 OpenAI 兼容上游不在
// SSE 末尾吐 usage → 转换出的 Anthropic message_delta 全 0 → 整笔 usage 漏记。
let body = json!({
"model": "moonshotai/kimi-k2",
"messages": [{ "role": "user", "content": "hello" }],
"max_tokens": 128,
"stream": true
});
let transformed =
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
.unwrap();
assert_eq!(transformed["stream"], true);
assert_eq!(transformed["stream_options"]["include_usage"], true);
}
#[test]
fn test_transform_claude_request_openai_chat_non_streaming_omits_stream_options() {
let provider = create_provider(json!({
"env": { "ANTHROPIC_BASE_URL": "https://openrouter.ai/api/v1" }
}));
// 非流式请求不应注入 stream_optionsusage 在非流式响应体里恒有)。
let body = json!({
"model": "moonshotai/kimi-k2",
"messages": [{ "role": "user", "content": "hello" }],
"max_tokens": 128
});
let transformed =
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
.unwrap();
assert!(transformed.get("stream_options").is_none());
}
#[test]
fn test_transform_claude_request_for_codex_oauth_uses_session_cache_key() {
let provider = create_provider_with_meta(
+1 -2
View File
@@ -48,8 +48,7 @@ pub use claude::{
pub use codex::CodexAdapter;
pub use codex::{
apply_codex_chat_upstream_model, codex_provider_upstream_model,
codex_provider_uses_chat_completions, is_origin_only_url, resolve_codex_chat_reasoning_config,
should_convert_codex_responses_to_chat,
resolve_codex_chat_reasoning_config, should_convert_codex_responses_to_chat,
};
pub use gemini::GeminiAdapter;
+100 -9
View File
@@ -100,15 +100,23 @@ struct ToolBlockState {
const INFINITE_WHITESPACE_THRESHOLD: usize = 500;
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 input_tokens = usage
.prompt_tokens
.saturating_sub(cached)
.saturating_sub(cache_creation);
let mut usage_json = json!({
"input_tokens": usage.prompt_tokens,
"input_tokens": input_tokens,
"output_tokens": usage.completion_tokens
});
if let Some(cached) = extract_cache_read_tokens(usage) {
if cached > 0 {
usage_json["cache_read_input_tokens"] = json!(cached);
}
if let Some(created) = usage.cache_creation_input_tokens {
usage_json["cache_creation_input_tokens"] = json!(created);
if cache_creation > 0 {
usage_json["cache_creation_input_tokens"] = json!(cache_creation);
}
usage_json
}
@@ -223,12 +231,20 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
"output_tokens": 0
});
if let Some(u) = &chunk.usage {
start_usage["input_tokens"] = json!(u.prompt_tokens);
if let Some(cached) = extract_cache_read_tokens(u) {
let cached = extract_cache_read_tokens(u).unwrap_or(0);
let cache_creation =
u.cache_creation_input_tokens.unwrap_or(0);
let input = u
.prompt_tokens
.saturating_sub(cached)
.saturating_sub(cache_creation);
start_usage["input_tokens"] = json!(input);
if cached > 0 {
start_usage["cache_read_input_tokens"] = json!(cached);
}
if let Some(created) = u.cache_creation_input_tokens {
start_usage["cache_creation_input_tokens"] = json!(created);
if cache_creation > 0 {
start_usage["cache_creation_input_tokens"] =
json!(cache_creation);
}
}
@@ -1022,7 +1038,7 @@ mod tests {
message_delta
.pointer("/usage/input_tokens")
.and_then(|v| v.as_u64()),
Some(13312)
Some(13212)
);
assert_eq!(
message_delta
@@ -1038,6 +1054,81 @@ mod tests {
);
}
#[tokio::test]
async fn test_usage_chunk_subtracts_cache_read_and_creation_from_input() {
// prompt_tokens(1000) 含 cache_read(600) 与 cache_creation(300);转 Anthropic 后
// input 应为 fresh,守恒:input(100) + cache_read(600) + cache_creation(300) == prompt(1000)。
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: [DONE]\n\n"
);
let events = collect_anthropic_events(input).await;
let message_delta = events
.iter()
.find(|event| event_type(event) == Some("message_delta"))
.expect("should emit message_delta with usage");
// fresh input = 1000 - 600 - 300 = 100
assert_eq!(
message_delta
.pointer("/usage/input_tokens")
.and_then(|v| v.as_u64()),
Some(100)
);
assert_eq!(
message_delta
.pointer("/usage/cache_read_input_tokens")
.and_then(|v| v.as_u64()),
Some(600)
);
assert_eq!(
message_delta
.pointer("/usage/cache_creation_input_tokens")
.and_then(|v| v.as_u64()),
Some(300)
);
}
#[tokio::test]
async fn test_usage_chunk_clamps_input_to_zero_when_cache_exceeds_prompt() {
// prompt(100) < cache_read(80)+cache_creation(50)=130saturating 钳到 0,防下溢。
// 钉桩:阻止未来把 saturating_sub 误改成普通减法(debug panic / release wrap)。
let input = concat!(
"data: {\"id\":\"chatcmpl_uf\",\"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_uf\",\"model\":\"glm-5.1\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":100,\"completion_tokens\":50,\"prompt_tokens_details\":{\"cached_tokens\":80},\"cache_creation_input_tokens\":50}}\n\n",
"data: [DONE]\n\n"
);
let events = collect_anthropic_events(input).await;
let message_delta = events
.iter()
.find(|event| event_type(event) == Some("message_delta"))
.expect("should emit message_delta with usage");
assert_eq!(
message_delta
.pointer("/usage/input_tokens")
.and_then(|v| v.as_u64()),
Some(0)
);
assert_eq!(
message_delta
.pointer("/usage/cache_read_input_tokens")
.and_then(|v| v.as_u64()),
Some(80)
);
assert_eq!(
message_delta
.pointer("/usage/cache_creation_input_tokens")
.and_then(|v| v.as_u64()),
Some(50)
);
}
#[tokio::test]
async fn test_message_delta_includes_zero_usage_when_stream_has_no_usage() {
let input = concat!(
+180 -76
View File
@@ -112,6 +112,10 @@ pub fn resolve_reasoning_effort(body: &Value) -> Option<&'static str> {
}
/// Anthropic 请求 → OpenAI Chat Completions 请求
///
/// 转换工具库 API:当前无生产调用方(连通性检查不再发真实请求,曾是其唯一 crate 内
/// 消费者),但保留其转换逻辑与下方测试套件,供代理转换路径复用 / 未来接线。
#[allow(dead_code)]
pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
anthropic_to_openai_with_reasoning_content(body, false)
}
@@ -142,18 +146,13 @@ pub fn anthropic_to_openai_with_reasoning_content(
messages.push(json!({"role": "system", "content": text}));
}
} else if let Some(arr) = system.as_array() {
// 多个 system message — preserve cache_control for compatible proxies
for msg in arr {
if let Some(text) = msg.get("text").and_then(|t| t.as_str()) {
let text = strip_leading_anthropic_billing_header(text);
if text.is_empty() {
continue;
}
let mut sys_msg = json!({"role": "system", "content": text});
if let Some(cc) = msg.get("cache_control") {
sys_msg["cache_control"] = cc.clone();
}
messages.push(sys_msg);
messages.push(json!({"role": "system", "content": text}));
}
}
}
@@ -207,18 +206,14 @@ pub fn anthropic_to_openai_with_reasoning_content(
.iter()
.filter(|t| t.get("type").and_then(|v| v.as_str()) != Some("BatchTool"))
.map(|t| {
let mut tool = json!({
json!({
"type": "function",
"function": {
"name": t.get("name").and_then(|n| n.as_str()).unwrap_or(""),
"description": t.get("description"),
"parameters": clean_schema(t.get("input_schema").cloned().unwrap_or(json!({})))
}
});
if let Some(cc) = t.get("cache_control") {
tool["cache_control"] = cc.clone();
}
tool
})
})
.collect();
@@ -234,6 +229,33 @@ pub fn anthropic_to_openai_with_reasoning_content(
Ok(result)
}
/// 为 OpenAI Chat Completions 流式请求注入 `stream_options.include_usage`。
///
/// OpenAI 兼容上游在流式下默认不在 SSE 里返回 usage,必须显式声明 include_usage
/// 才会在末尾吐 usage chunk。缺这一注入会导致流式请求的 token/成本/缓存全部漏记
/// input/output/cache 全为 0)。保留客户端可能透传的其它 stream_options 字段,
/// 仅补 include_usage;非流式请求不动。
///
/// 由 Claude→openai_chatclaude.rs)与 Codex Responses→Chattransform_codex_chat.rs
/// 两条转换路径共用,确保两个客户端方向行为一致。
pub(crate) fn inject_openai_stream_include_usage(result: &mut Value) {
let is_stream = result
.get("stream")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !is_stream {
return;
}
match result.get_mut("stream_options") {
Some(Value::Object(opts)) => {
opts.insert("include_usage".to_string(), json!(true));
}
_ => {
result["stream_options"] = json!({ "include_usage": true });
}
}
}
/// Translate an Anthropic `tool_choice` into the OpenAI Chat Completions form.
///
/// Anthropic forms:
@@ -294,10 +316,6 @@ fn normalize_openai_system_messages(messages: &mut Vec<Value>) {
}
let mut parts = Vec::new();
let mut inherited_cache_control: Option<Value> = None;
let mut cache_control_conflict = false;
let mut saw_cache_control = false;
let mut saw_missing_cache_control = false;
messages.retain(|message| {
if message.get("role").and_then(|value| value.as_str()) != Some("system") {
return true;
@@ -318,28 +336,11 @@ fn normalize_openai_system_messages(messages: &mut Vec<Value>) {
_ => {}
}
if let Some(cache_control) = message.get("cache_control") {
saw_cache_control = true;
match &inherited_cache_control {
None => inherited_cache_control = Some(cache_control.clone()),
Some(existing) if existing == cache_control => {}
Some(_) => cache_control_conflict = true,
}
} else {
saw_missing_cache_control = true;
}
false
});
if !parts.is_empty() {
let mut merged = json!({"role": "system", "content": parts.join("\n")});
if !(cache_control_conflict || (saw_cache_control && saw_missing_cache_control)) {
if let Some(cache_control) = inherited_cache_control {
merged["cache_control"] = cache_control;
}
}
messages.insert(0, merged);
messages.insert(0, json!({"role": "system", "content": parts.join("\n")}));
}
}
@@ -379,11 +380,7 @@ fn convert_message_to_openai(
match block_type {
"text" => {
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
let mut part = json!({"type": "text", "text": text});
if let Some(cc) = block.get("cache_control") {
part["cache_control"] = cc.clone();
}
content_parts.push(part);
content_parts.push(json!({"type": "text", "text": text}));
}
}
"image" => {
@@ -458,14 +455,9 @@ fn convert_message_to_openai(
if content_parts.is_empty() {
msg["content"] = Value::Null;
} else if content_parts.len() == 1 {
// When cache_control is present, keep array format to preserve it
let has_cache_control = content_parts[0].get("cache_control").is_some();
if !has_cache_control {
if let Some(text) = content_parts[0].get("text") {
msg["content"] = text.clone();
} else {
msg["content"] = json!(content_parts);
}
// 单 text block 简化为纯字符串
if let Some(text) = content_parts[0].get("text") {
msg["content"] = text.clone();
} else {
msg["content"] = json!(content_parts);
}
@@ -656,10 +648,31 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
// usage — map cache tokens from OpenAI format to Anthropic format
let usage = body.get("usage").cloned().unwrap_or(json!({}));
// OpenAI prompt_tokens 含缓存命中,Anthropic input_tokens 不含 → 减去 cache_read 与
// cache_creation,使 input 成为 fresh input。本路径以 app_type="claude" 记账(calculator
// 不再扣减),若不减则缓存会被计入 input 与各 cache 桶两次。三桶互斥,恒等:
// input + cache_read + cache_creation == prompt_tokensinclusive 上游)。
// 与流式 build_anthropic_usage_json (#2774) 及 transform_gemini 的 saturating_sub 对称。
// 最终 cache_read:直传字段优先于 nestedcache_creation 仅来自直传字段(OpenAI 无此概念)。
let cached = usage
.get("cache_read_input_tokens")
.and_then(|v| v.as_u64())
.or_else(|| {
usage
.pointer("/prompt_tokens_details/cached_tokens")
.and_then(|v| v.as_u64())
})
.unwrap_or(0);
let cache_creation = usage
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let input_tokens = usage
.get("prompt_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
.unwrap_or(0)
.saturating_sub(cached)
.saturating_sub(cache_creation) as u32;
let output_tokens = usage
.get("completion_tokens")
.and_then(|v| v.as_u64())
@@ -670,19 +683,11 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
"output_tokens": output_tokens
});
// OpenAI standard: prompt_tokens_details.cached_tokens
if let Some(cached) = usage
.pointer("/prompt_tokens_details/cached_tokens")
.and_then(|v| v.as_u64())
{
if cached > 0 {
usage_json["cache_read_input_tokens"] = json!(cached);
}
// Some compatible servers return these fields directly
if let Some(v) = usage.get("cache_read_input_tokens") {
usage_json["cache_read_input_tokens"] = v.clone();
}
if let Some(v) = usage.get("cache_creation_input_tokens") {
usage_json["cache_creation_input_tokens"] = v.clone();
if cache_creation > 0 {
usage_json["cache_creation_input_tokens"] = json!(cache_creation);
}
let result = json!({
@@ -829,7 +834,7 @@ mod tests {
}
#[test]
fn test_anthropic_to_openai_preserves_matching_system_cache_control_when_merging() {
fn test_anthropic_to_openai_strips_cache_control_from_merged_system() {
let input = json!({
"model": "claude-3-sonnet",
"max_tokens": 1024,
@@ -847,12 +852,12 @@ mod tests {
result["messages"][0]["content"],
"You are Claude Code.\nBe concise."
);
assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral");
assert!(result["messages"][0].get("cache_control").is_none());
assert_eq!(result["messages"][1]["role"], "user");
}
#[test]
fn test_anthropic_to_openai_drops_mixed_present_absent_system_cache_control_when_merging() {
fn test_anthropic_to_openai_strips_cache_control_from_mixed_system() {
let input = json!({
"model": "claude-3-sonnet",
"max_tokens": 1024,
@@ -873,7 +878,7 @@ mod tests {
}
#[test]
fn test_anthropic_to_openai_drops_conflicting_system_cache_control_when_merging() {
fn test_anthropic_to_openai_strips_cache_control_from_conflicting_system() {
let input = json!({
"model": "claude-3-sonnet",
"max_tokens": 1024,
@@ -1199,7 +1204,7 @@ mod tests {
}
#[test]
fn test_anthropic_to_openai_cache_control_preserved() {
fn test_anthropic_to_openai_strips_all_cache_control() {
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
@@ -1221,19 +1226,89 @@ mod tests {
});
let result = anthropic_to_openai(input).unwrap();
// System message cache_control preserved
assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral");
// Text block cache_control preserved
assert_eq!(
result["messages"][1]["content"][0]["cache_control"]["type"],
"ephemeral"
// System message: no cache_control
assert!(result["messages"][0].get("cache_control").is_none());
// User message: content simplified to string (no cache_control → flat string)
assert_eq!(result["messages"][1]["content"], "Hello");
// Tool: no cache_control
assert!(result["tools"][0].get("cache_control").is_none());
}
/// 精确复现 Issue #3805 报告的 400 错误场景:
/// GLM/Qwen 等严格校验模型拒绝 cache_control 和 content 数组格式
#[test]
fn test_regression_gh3805_no_cache_control_leak_to_openai() {
let input = json!({
"model": "glm-5.1",
"max_tokens": 1024,
"system": [
{"type": "text", "text": "You are helpful.", "cache_control": {"type": "ephemeral"}}
],
"messages": [
{"role": "user", "content": [
{"type": "text", "text": "Hello", "cache_control": {"type": "ephemeral"}}
]}
],
"tools": [{
"name": "search",
"description": "Search the web",
"input_schema": {"type": "object"},
"cache_control": {"type": "ephemeral"}
}]
});
let result = anthropic_to_openai(input).unwrap();
// 验证: messages 中不存在 cache_control
for (i, msg) in result["messages"].as_array().unwrap().iter().enumerate() {
assert!(
msg.get("cache_control").is_none(),
"messages[{i}] must not have cache_control"
);
}
// 验证: content 中没有 cache_control
for (i, msg) in result["messages"].as_array().unwrap().iter().enumerate() {
if let Some(content) = msg.get("content") {
assert!(
!content.is_array()
|| content
.as_array()
.unwrap()
.iter()
.all(|part| part.get("cache_control").is_none()),
"messages[{i}] content parts must not have cache_control"
);
}
}
// 验证: system content 为纯字符串格式(不是数组)
let sys_msg = &result["messages"][0];
assert_eq!(sys_msg["role"], "system");
assert!(
sys_msg["content"].is_string(),
"system content must be string, got: {}",
sys_msg["content"]
);
assert_eq!(
result["messages"][1]["content"][0]["cache_control"]["ttl"],
"5m"
// 验证: user content 为纯字符串格式(不是数组)
let user_msg = &result["messages"][1];
assert_eq!(user_msg["role"], "user");
assert!(
user_msg["content"].is_string(),
"user content must be string, got: {}",
user_msg["content"]
);
// Tool cache_control preserved
assert_eq!(result["tools"][0]["cache_control"]["type"], "ephemeral");
// 验证: tools 中不存在 cache_control
if let Some(tools) = result["tools"].as_array() {
for (i, tool) in tools.iter().enumerate() {
assert!(
tool.get("cache_control").is_none(),
"tools[{i}] must not have cache_control"
);
}
}
}
#[test]
@@ -1256,7 +1331,8 @@ mod tests {
});
let result = openai_to_anthropic(input).unwrap();
assert_eq!(result["usage"]["input_tokens"], 100);
// prompt_tokens(100) 含 cached(80),转换后 input 应为 fresh = 100 - 80 = 20
assert_eq!(result["usage"]["input_tokens"], 20);
assert_eq!(result["usage"]["output_tokens"], 50);
assert_eq!(result["usage"]["cache_read_input_tokens"], 80);
}
@@ -1280,10 +1356,38 @@ mod tests {
});
let result = openai_to_anthropic(input).unwrap();
// cache_read(60)+cache_creation(20) 均从 prompt(100) 扣除,fresh = 100 - 60 - 20 = 20
// 守恒:input(20) + cache_read(60) + cache_creation(20) == prompt(100)
assert_eq!(result["usage"]["input_tokens"], 20);
assert_eq!(result["usage"]["cache_read_input_tokens"], 60);
assert_eq!(result["usage"]["cache_creation_input_tokens"], 20);
}
#[test]
fn test_openai_to_anthropic_clamps_input_when_cache_exceeds_prompt() {
// prompt(100) < cache_read(60)+cache_creation(50)=110saturating 钳到 0,防下溢。
// 钉桩:阻止未来把 saturating_sub 误改成普通减法(debug panic / release wrap)。
let input = json!({
"id": "chatcmpl-uf",
"model": "gpt-4",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "x"},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 100,
"completion_tokens": 10,
"cache_read_input_tokens": 60,
"cache_creation_input_tokens": 50
}
});
let result = openai_to_anthropic(input).unwrap();
assert_eq!(result["usage"]["input_tokens"], 0);
assert_eq!(result["usage"]["cache_read_input_tokens"], 60);
assert_eq!(result["usage"]["cache_creation_input_tokens"], 50);
}
#[test]
fn test_openai_to_anthropic_finish_reason_content_filter_maps_end_turn() {
let input = json!({
@@ -336,21 +336,8 @@ pub fn responses_to_chat_completions_with_reasoning(
// include_usage 才会在末尾吐 usage chunk。Codex CLI 用 Responses 协议、
// 自身不带 stream_options,缺这一注入会导致 kimi/MiniMax 等第三方流式请求的
// token/成本/缓存命中率全部漏记(input/output/cache 全为 0)。
let is_stream = result
.get("stream")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if is_stream {
match result.get_mut("stream_options") {
// 保留客户端可能透传的其它 stream_options 字段,仅补 include_usage。
Some(Value::Object(opts)) => {
opts.insert("include_usage".to_string(), json!(true));
}
_ => {
result["stream_options"] = json!({ "include_usage": true });
}
}
}
// 与 Claude→openai_chat 路径共用同一 helper,保证两个客户端方向一致。
super::transform::inject_openai_stream_include_usage(&mut result);
Ok(result)
}
@@ -39,6 +39,11 @@ pub(crate) fn is_synthesized_tool_call_id(id: &str) -> bool {
id.starts_with(SYNTHESIZED_ID_PREFIX)
}
/// Anthropic 请求 → Gemini 原生请求。
///
/// 转换工具库 API:当前无生产调用方(连通性检查不再发真实请求,曾是其唯一 crate 内
/// 消费者),但保留其转换逻辑与下方测试套件,供代理转换路径复用 / 未来接线。
#[allow(dead_code)]
pub fn anthropic_to_gemini(body: Value) -> Result<Value, ProxyError> {
anthropic_to_gemini_with_shadow(body, None, None, None)
}
@@ -1101,7 +1106,7 @@ pub(crate) fn build_anthropic_usage(usage: Option<&Value>) -> Value {
});
};
let input_tokens = usage
let prompt_tokens = usage
.get("promptTokenCount")
.and_then(|value| value.as_u64())
.unwrap_or(0);
@@ -1109,18 +1114,26 @@ pub(crate) fn build_anthropic_usage(usage: Option<&Value>) -> Value {
.get("totalTokenCount")
.and_then(|value| value.as_u64())
.unwrap_or(0);
let output_tokens = total_tokens.saturating_sub(input_tokens);
let cached_tokens = usage
.get("cachedContentTokenCount")
.and_then(|value| value.as_u64())
.unwrap_or(0);
// Gemini 的 promptTokenCount 含缓存命中(cachedContentTokenCount);而 Anthropic
// 语义下 input_tokens 必须是不含 cache 的 fresh input、cache_read 单列。本路径转成
// Anthropic 后以 app_type=claude 记账,calculator 对 claude 设 input_includes_cache_read
// =false 不再从 input 扣 cache,因此这里必须先扣减,否则缓存 token 会被双重计费
// (一次按完整 input 价、一次按 cache_read 价)。output 仍按 total-prompt 计算
// (prompt 是总输入,扣减只作用于 input/cache 的拆分,不影响 output)。
let input_tokens = prompt_tokens.saturating_sub(cached_tokens);
let output_tokens = total_tokens.saturating_sub(prompt_tokens);
let mut result = json!({
"input_tokens": input_tokens,
"output_tokens": output_tokens
});
if let Some(cached) = usage
.get("cachedContentTokenCount")
.and_then(|value| value.as_u64())
{
result["cache_read_input_tokens"] = json!(cached);
if cached_tokens > 0 {
result["cache_read_input_tokens"] = json!(cached_tokens);
}
result
@@ -1370,7 +1383,11 @@ mod tests {
assert_eq!(result["content"][0]["type"], "text");
assert_eq!(result["content"][0]["text"], "Hello from Gemini");
assert_eq!(result["stop_reason"], "end_turn");
assert_eq!(result["usage"]["input_tokens"], 12);
// input_tokens = promptTokenCount(12) - cachedContentTokenCount(3) = 9fresh input)。
// Gemini 的 promptTokenCount 含缓存命中,但 Anthropic 语义要求 input 不含 cache、
// cache_read 单列;二者相加(9+3)=总输入 12。扣减避免本路径以 app_type=claude
// 记账时把缓存 token 双重计费。
assert_eq!(result["usage"]["input_tokens"], 9);
assert_eq!(result["usage"]["output_tokens"], 8);
assert_eq!(result["usage"]["cache_read_input_tokens"], 3);
}
@@ -355,6 +355,23 @@ pub(crate) fn build_anthropic_usage_from_responses(usage: Option<&Value>) -> Val
result["cache_creation_input_tokens"] = v.clone();
}
// OpenAI/Responses 的 input(prompt_tokens/input_tokens)含缓存命中,Anthropic input_tokens 不含
// → 减去 cache_read 与 cache_creation,使其成为 fresh input。本函数在计量意义上是 claude 专属
// Codex Responses 透传走 from_codex_response_*,不调用本函数),故可安全在此扣减。三桶互斥,
// 恒等:input + cache_read + cache_creation == 上游 input(inclusive)。与 build_anthropic_usage_json
// (#2774) 及 transform_gemini 的 saturating_sub 对称;一处同时覆盖非流式与流式(streaming_responses)。
let cached = result
.get("cache_read_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let cache_creation = result
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0);
if cached > 0 || cache_creation > 0 {
result["input_tokens"] = json!(input.saturating_sub(cached).saturating_sub(cache_creation));
}
result
}
@@ -1156,7 +1173,8 @@ mod tests {
});
let result = responses_to_anthropic(input).unwrap();
assert_eq!(result["usage"]["input_tokens"], 100);
// input_tokens(100) 含 cached(80),转换后 input 应为 fresh = 100 - 80 = 20
assert_eq!(result["usage"]["input_tokens"], 20);
assert_eq!(result["usage"]["output_tokens"], 50);
assert_eq!(result["usage"]["cache_read_input_tokens"], 80);
}
@@ -1180,6 +1198,9 @@ mod tests {
});
let result = responses_to_anthropic(input).unwrap();
// cache_read(60)+cache_creation(20) 均从 input(100) 扣除,fresh = 100 - 60 - 20 = 20
// 守恒:input(20) + cache_read(60) + cache_creation(20) == 上游 input(100)
assert_eq!(result["usage"]["input_tokens"], 20);
assert_eq!(result["usage"]["cache_read_input_tokens"], 60);
assert_eq!(result["usage"]["cache_creation_input_tokens"], 20);
}
@@ -1642,7 +1663,8 @@ mod tests {
"cached_tokens": 80
}
})));
assert_eq!(result["input_tokens"], json!(100));
// input_tokens(100) 含 nested cached(80),转换后 input 应为 fresh = 100 - 80 = 20
assert_eq!(result["input_tokens"], json!(20));
assert_eq!(result["output_tokens"], json!(50));
assert_eq!(result["cache_read_input_tokens"], json!(80));
}
@@ -1657,9 +1679,26 @@ mod tests {
},
"cache_read_input_tokens": 100
})));
// 直传 cache_read(100) 优先于 nested(80)input(100) - 100 = 0fresh
assert_eq!(result["input_tokens"], json!(0));
assert_eq!(result["cache_read_input_tokens"], json!(100)); // Direct field overrides nested
}
#[test]
fn test_build_usage_clamps_input_when_cache_exceeds_input() {
// input(100) < cache_read(60)+cache_creation(50)=110saturating 钳到 0,防下溢。
// 钉桩:阻止未来把 saturating_sub 误改成普通减法(debug panic / release wrap)。
let result = build_anthropic_usage_from_responses(Some(&json!({
"input_tokens": 100,
"output_tokens": 10,
"cache_read_input_tokens": 60,
"cache_creation_input_tokens": 50
})));
assert_eq!(result["input_tokens"], json!(0));
assert_eq!(result["cache_read_input_tokens"], json!(60));
assert_eq!(result["cache_creation_input_tokens"], json!(50));
}
#[test]
fn test_build_usage_cache_tokens_without_input_output() {
let result = build_anthropic_usage_from_responses(Some(&json!({
+198 -23
View File
@@ -35,28 +35,41 @@ use tokio::sync::Mutex;
/// 根据 content-encoding 解压响应体字节
///
/// reqwest 自动解压已禁用(为了透传 accept-encoding),需要手动解压。
fn decompress_body(content_encoding: &str, body: &[u8]) -> Result<Vec<u8>, std::io::Error> {
/// 返回 `Ok(None)` 表示编码不受支持、原样透传——此时调用方必须保留
/// content-encoding 头,否则下游(诊断/客户端)会把压缩字节误当明文。
fn decompress_body(content_encoding: &str, body: &[u8]) -> Result<Option<Vec<u8>>, std::io::Error> {
match content_encoding {
"gzip" | "x-gzip" => {
let mut decoder = flate2::read::GzDecoder::new(body);
let mut decompressed = Vec::new();
decoder.read_to_end(&mut decompressed)?;
Ok(decompressed)
Ok(Some(decompressed))
}
"deflate" => {
let mut decoder = flate2::read::DeflateDecoder::new(body);
// RFC 9110: deflate 指 zlib 包裹格式;但部分上游发 raw deflate 流。
// 先按规范尝试 zlib,失败再回退 raw —— 否则合规上游必然解压失败,
// 原始压缩字节会被 fail-open 透传给 JSON 解析(#2234 形态 C 之一)。
let mut decompressed = Vec::new();
decoder.read_to_end(&mut decompressed)?;
Ok(decompressed)
let mut zlib = flate2::read::ZlibDecoder::new(body);
match zlib.read_to_end(&mut decompressed) {
Ok(_) => Ok(Some(decompressed)),
Err(zlib_err) => {
log::debug!("deflate 按 zlib 解压失败({zlib_err}),回退 raw deflate");
let mut decompressed = Vec::new();
let mut raw = flate2::read::DeflateDecoder::new(body);
raw.read_to_end(&mut decompressed)?;
Ok(Some(decompressed))
}
}
}
"br" => {
let mut decompressed = Vec::new();
brotli::BrotliDecompress(&mut std::io::Cursor::new(body), &mut decompressed)?;
Ok(decompressed)
Ok(Some(decompressed))
}
_ => {
log::warn!("未知的 content-encoding: {content_encoding},跳过解压");
Ok(body.to_vec())
Ok(None)
}
}
}
@@ -150,10 +163,13 @@ pub(crate) async fn read_decoded_body(
if let Some(encoding) = get_content_encoding(&headers) {
log::debug!("[{tag}] 解压非流式响应: content-encoding={encoding}");
match decompress_body(&encoding, &raw_bytes) {
Ok(decompressed) => {
Ok(Some(decompressed)) => {
body_bytes = Bytes::from(decompressed);
decoded = true;
}
// 不支持的编码:原样透传且保留 content-encoding 头,
// 让下游诊断/客户端知道这仍是压缩字节
Ok(None) => {}
Err(e) => {
log::warn!("[{tag}] 解压失败 ({encoding}): {e},使用原始数据");
}
@@ -270,14 +286,21 @@ pub async fn handle_non_streaming(
if let Ok(json_value) = serde_json::from_slice::<Value>(&body_bytes) {
// 解析使用量
if let Some(usage) = (parser_config.response_parser)(&json_value) {
// 优先使用 usage 解析出的模型名称,其次使用响应中的 model 字段,最后回退到请求模型
let model = if let Some(ref m) = usage.model {
m.clone()
} else if let Some(m) = json_value.get("model").and_then(|m| m.as_str()) {
m.to_string()
} else {
ctx.request_model.clone()
};
// 归因优先级:usage 解析出的模型 → 响应 model 字段 → 映射后的出站
// 模型(路由接管真值)→ 客户端请求模型。空字符串视为缺失。
let model = usage
.model
.clone()
.filter(|m| !m.is_empty())
.or_else(|| {
json_value
.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());
spawn_log_usage(
state,
@@ -292,8 +315,10 @@ pub async fn handle_non_streaming(
let model = json_value
.get("model")
.and_then(|m| m.as_str())
.unwrap_or(&ctx.request_model)
.to_string();
.filter(|m| !m.is_empty())
.map(str::to_string)
.or_else(|| ctx.outbound_model.clone())
.unwrap_or_else(|| ctx.request_model.clone());
spawn_log_usage(
state,
ctx,
@@ -318,7 +343,7 @@ pub async fn handle_non_streaming(
state,
ctx,
TokenUsage::default(),
&ctx.request_model,
ctx.outbound_model.as_deref().unwrap_or(&ctx.request_model),
&ctx.request_model,
status.as_u16(),
false,
@@ -500,7 +525,16 @@ fn create_usage_collector(
let state = state.clone();
let provider_id = ctx.provider.id.clone();
let request_model = ctx.request_model.clone();
let app_type_str = parser_config.app_type_str;
// 流式事件缺失模型名时的归因兜底:映射后的出站模型(路由接管真值)优先,
// 其次才是客户端请求别名
let fallback_model = ctx
.outbound_model
.clone()
.unwrap_or_else(|| ctx.request_model.clone());
// 用 ctx 的 app_type 而不是 parser_config 的:Claude Desktop 流式透传复用
// CLAUDE_PARSER_CONFIGapp_type_str="claude"),按 parser_config 记账会把
// claude-desktop 的行错记到 claude 名下,导致供应商计价覆盖解析不到。
let app_type_str = ctx.app_type_str;
let tag = ctx.tag;
let start_time = ctx.start_time;
let stream_parser = parser_config.stream_parser;
@@ -512,13 +546,14 @@ fn create_usage_collector(
parser_config.stream_event_filter,
move |events, first_token_ms| {
if let Some(usage) = stream_parser(&events) {
let model = model_extractor(&events, &request_model);
let model = model_extractor(&events, &fallback_model);
let latency_ms = start_time.elapsed().as_millis() as u64;
let state = state.clone();
let provider_id = provider_id.clone();
let session_id = session_id.clone();
let request_model = request_model.clone();
let outbound_model = fallback_model.clone();
tokio::spawn(async move {
log_usage_internal(
@@ -527,6 +562,7 @@ fn create_usage_collector(
app_type_str,
&model,
&request_model,
&outbound_model,
usage,
latency_ms,
first_token_ms,
@@ -537,12 +573,13 @@ fn create_usage_collector(
.await;
});
} else {
let model = model_extractor(&events, &request_model);
let model = model_extractor(&events, &fallback_model);
let latency_ms = start_time.elapsed().as_millis() as u64;
let state = state.clone();
let provider_id = provider_id.clone();
let session_id = session_id.clone();
let request_model = request_model.clone();
let outbound_model = fallback_model.clone();
tokio::spawn(async move {
log_usage_internal(
@@ -551,6 +588,7 @@ fn create_usage_collector(
app_type_str,
&model,
&request_model,
&outbound_model,
TokenUsage::default(),
latency_ms,
first_token_ms,
@@ -588,6 +626,11 @@ fn spawn_log_usage(
let app_type_str = ctx.app_type_str.to_string();
let model = model.to_string();
let request_model = request_model.to_string();
// 「按请求计价」模式的锚点:映射后的出站模型,无映射时等于 request_model
let outbound_model = ctx
.outbound_model
.clone()
.unwrap_or_else(|| ctx.request_model.clone());
let latency_ms = ctx.latency_ms();
let session_id = ctx.session_id.clone();
@@ -598,6 +641,7 @@ fn spawn_log_usage(
&app_type_str,
&model,
&request_model,
&outbound_model,
usage,
latency_ms,
None,
@@ -618,6 +662,11 @@ pub(crate) fn usage_logging_enabled(state: &ProxyState) -> bool {
}
/// 内部使用量记录函数
///
/// `outbound_model` 是「按请求计价」模式的锚点:实际发往上游的模型
/// (路由接管映射后的真值,无映射时等于 request_model)。该模式的语义是
/// 「按代理发出的请求计价、不信任上游回显」,接管场景下发出的请求模型是
/// 映射后的 Y 而非客户端别名 X,按 X 计价会用错定价表行。
#[allow(clippy::too_many_arguments)]
async fn log_usage_internal(
state: &ProxyState,
@@ -625,6 +674,7 @@ async fn log_usage_internal(
app_type: &str,
model: &str,
request_model: &str,
outbound_model: &str,
usage: TokenUsage,
latency_ms: u64,
first_token_ms: Option<u64>,
@@ -638,7 +688,7 @@ async fn log_usage_internal(
let (multiplier, pricing_model_source) =
logger.resolve_pricing_config(provider_id, app_type).await;
let pricing_model = if pricing_model_source == PRICING_SOURCE_REQUEST {
request_model
outbound_model
} else {
model
};
@@ -828,6 +878,40 @@ mod tests {
use std::sync::Arc;
use tokio::sync::RwLock;
#[test]
fn decompress_body_deflate_handles_zlib_wrapped_per_rfc9110() {
// RFC 9110 规范的 deflate = zlib 包裹格式(合规上游发的就是这个)
let payload = br#"{"ok":true}"#;
let mut encoder =
flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
std::io::Write::write_all(&mut encoder, payload).unwrap();
let compressed = encoder.finish().unwrap();
let decompressed = decompress_body("deflate", &compressed).unwrap().unwrap();
assert_eq!(decompressed, payload);
}
#[test]
fn decompress_body_deflate_falls_back_to_raw_stream() {
// 部分上游违规发 raw deflate 流,保持兼容
let payload = br#"{"ok":true}"#;
let mut encoder =
flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
std::io::Write::write_all(&mut encoder, payload).unwrap();
let compressed = encoder.finish().unwrap();
let decompressed = decompress_body("deflate", &compressed).unwrap().unwrap();
assert_eq!(decompressed, payload);
}
#[test]
fn decompress_body_unknown_encoding_returns_none_to_keep_headers() {
// 未知编码必须返回 None(而非伪装成"已解码"),否则 content-encoding
// 头被剥掉,下游诊断会把压缩字节误报成明文
let result = decompress_body("zstd", b"\x28\xb5\x2f\xfd").unwrap();
assert!(result.is_none());
}
#[test]
fn test_strip_sse_field_accepts_optional_space() {
assert_eq!(
@@ -1015,6 +1099,7 @@ mod tests {
app_type,
"resp-model",
"req-model",
"req-model",
usage,
10,
None,
@@ -1047,6 +1132,95 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn test_request_pricing_mode_anchors_to_outbound_model() -> Result<(), AppError> {
let db = Arc::new(Database::memory()?);
let app_type = "claude";
db.set_pricing_model_source(app_type, "request").await?;
seed_pricing(&db)?;
{
let conn = crate::database::lock_conn!(db.conn);
conn.execute(
"INSERT OR REPLACE INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)
VALUES ('outbound-model', 'Outbound Model', '4.0', '0')",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
insert_provider(&db, "provider-3", app_type, ProviderMeta::default())?;
let state = build_state(db.clone());
let usage = TokenUsage {
input_tokens: 1_000_000,
output_tokens: 0,
cache_read_tokens: 0,
cache_creation_tokens: 0,
model: None,
message_id: None,
};
// 路由接管场景:客户端请求 req-model($2/M),代理实际发出 outbound-model
// $4/M),上游回显 resp-model。「按请求计价」必须锚定实际发出的模型。
log_usage_internal(
&state,
"provider-3",
app_type,
"resp-model",
"req-model",
"outbound-model",
usage,
10,
None,
false,
200,
None,
)
.await;
let conn = crate::database::lock_conn!(db.conn);
let (model, request_model, total_cost): (String, String, String) = conn
.query_row(
"SELECT model, request_model, total_cost_usd
FROM proxy_request_logs WHERE provider_id = ?1",
["provider-3"],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.map_err(|e| AppError::Database(e.to_string()))?;
// model / request_model 列不受计价锚点影响
assert_eq!(model, "resp-model");
assert_eq!(request_model, "req-model");
// 按 outbound-model$4/M)计价,而不是 req-model$2/M)或 resp-model$1/M
assert_eq!(
Decimal::from_str(&total_cost).unwrap(),
Decimal::from_str("4").unwrap()
);
Ok(())
}
#[tokio::test]
async fn test_claude_desktop_inherits_claude_global_defaults() -> Result<(), AppError> {
use crate::proxy::usage::logger::UsageLogger;
let db = Arc::new(Database::memory()?);
// 全局计费配置只有 claude/codex/gemini 三行;claude-desktop 的
// 全局默认必须继承 claude,而不是静默落回工厂默认(1 / response
db.set_default_cost_multiplier("claude", "1.5").await?;
db.set_pricing_model_source("claude", "request").await?;
let logger = UsageLogger::new(&db);
let (multiplier, source) = logger
.resolve_pricing_config("nonexistent-provider", "claude-desktop")
.await;
assert_eq!(multiplier, Decimal::from_str("1.5").unwrap());
assert_eq!(source, "request");
Ok(())
}
#[tokio::test]
async fn test_log_usage_falls_back_to_global_defaults() -> Result<(), AppError> {
let db = Arc::new(Database::memory()?);
@@ -1075,6 +1249,7 @@ mod tests {
app_type,
"resp-model",
"req-model",
"req-model",
usage,
10,
None,
+36 -15
View File
@@ -16,6 +16,11 @@ pub struct RequestLog {
pub app_type: String,
pub model: String,
pub request_model: String,
/// 写入时实际用于计价的模型名(pricing_model_source 解析后的结果)。
/// 落库供回填使用:缺价行补价后必须按写入时的基准重算,而不是
/// 用 model/request_model 猜——路由接管下三者可能各不相同。
/// 错误行(未计价)为空字符串。
pub pricing_model: String,
pub usage: TokenUsage,
pub cost: Option<CostBreakdown>,
pub latency_ms: u64,
@@ -68,18 +73,19 @@ impl<'a> UsageLogger<'a> {
conn.execute(
"INSERT OR REPLACE INTO proxy_request_logs (
request_id, provider_id, app_type, model, request_model,
request_id, provider_id, app_type, model, request_model, pricing_model,
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,
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)",
) 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)",
rusqlite::params![
log.request_id,
log.provider_id,
log.app_type,
log.model,
log.request_model,
log.pricing_model,
log.usage.input_tokens,
log.usage.output_tokens,
log.usage.cache_read_tokens,
@@ -129,6 +135,8 @@ impl<'a> UsageLogger<'a> {
app_type,
model,
request_model,
// 错误行未经过计价,留空(回填的 has_usage 闸门也不会碰全 0 行)
pricing_model: String::new(),
usage: TokenUsage::default(),
cost: None,
latency_ms,
@@ -168,6 +176,8 @@ impl<'a> UsageLogger<'a> {
app_type,
model,
request_model,
// 错误行未经过计价,留空(回填的 has_usage 闸门也不会碰全 0 行)
pricing_model: String::new(),
usage: TokenUsage::default(),
cost: None,
latency_ms,
@@ -203,13 +213,22 @@ impl<'a> UsageLogger<'a> {
provider_id: &str,
app_type: &str,
) -> (Decimal, String) {
let default_multiplier_raw = match self.db.get_default_cost_multiplier(app_type).await {
Ok(value) => value,
Err(e) => {
log::warn!("[USG-003] 获取默认倍率失败 (app_type={app_type}): {e}");
"1".to_string()
}
// Claude Desktop 网关没有独立的全局计费配置(proxy_config 的 CHECK 仅
// 允许 claude/codex/gemini,前端也只暴露三项),全局默认继承 claude;
// 供应商级 meta 覆盖仍按 claude-desktop 查找(providers 表按该 app_type 存)。
let default_app_type = if app_type == "claude-desktop" {
"claude"
} else {
app_type
};
let default_multiplier_raw =
match self.db.get_default_cost_multiplier(default_app_type).await {
Ok(value) => value,
Err(e) => {
log::warn!("[USG-003] 获取默认倍率失败 (app_type={app_type}): {e}");
"1".to_string()
}
};
let default_multiplier = match Decimal::from_str(&default_multiplier_raw) {
Ok(value) => value,
Err(e) => {
@@ -220,13 +239,14 @@ impl<'a> UsageLogger<'a> {
}
};
let default_pricing_source_raw = match self.db.get_pricing_model_source(app_type).await {
Ok(value) => value,
Err(e) => {
log::warn!("[USG-003] 获取默认计费模式失败 (app_type={app_type}): {e}");
PRICING_SOURCE_RESPONSE.to_string()
}
};
let default_pricing_source_raw =
match self.db.get_pricing_model_source(default_app_type).await {
Ok(value) => value,
Err(e) => {
log::warn!("[USG-003] 获取默认计费模式失败 (app_type={app_type}): {e}");
PRICING_SOURCE_RESPONSE.to_string()
}
};
let default_pricing_source = if default_pricing_source_raw == PRICING_SOURCE_RESPONSE
|| default_pricing_source_raw == PRICING_SOURCE_REQUEST
{
@@ -325,6 +345,7 @@ impl<'a> UsageLogger<'a> {
app_type,
model,
request_model,
pricing_model,
usage,
cost,
latency_ms,
+82 -1
View File
@@ -37,6 +37,18 @@ impl TokenUsage {
.map(|mid| format!("{SESSION_REQUEST_ID_PREFIX}{mid}"))
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
}
/// 是否产生了任一计费维度的 token。
///
/// 用于在写入前过滤全 0 的空 usage:当 OpenAI 兼容上游在流式下省略 usage 时,
/// 转换器会合成一个全 0 的终止事件,若无 message_id 则 `dedup_request_id`
/// 退化为随机 UUID,导致每笔请求插入一条无意义的空行、虚增请求数。
pub fn has_billable_tokens(&self) -> bool {
self.input_tokens > 0
|| self.output_tokens > 0
|| self.cache_read_tokens > 0
|| self.cache_creation_tokens > 0
}
}
/// API 类型
@@ -185,7 +197,11 @@ impl TokenUsage {
}
}
if usage.input_tokens > 0 || usage.output_tokens > 0 {
// 用 has_billable_tokens 而非仅看 input/output:完全缓存命中、无输出的流式请求
// input==0 && output==0 但 cache_read>0)是真实的 cache-read 计费,必须保留。
// Gemini→Anthropic 路径在 input 改为 fresh(promptTokenCount - cachedContentTokenCount)
// 后尤其会出现这种全缓存场景;旧 gate 会把它当成"无 usage"丢弃。
if usage.has_billable_tokens() {
usage.model = model;
usage.message_id = message_id;
Some(usage)
@@ -522,6 +538,71 @@ mod tests {
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
}
#[test]
fn test_has_billable_tokens_gates_empty_usage() {
// 全 0 usage(如上游省略 usage 时合成的全 0 终止事件)不应计费——
// 这是 Codex 流式空行多记修复(D)的闸门依据。
assert!(!TokenUsage::default().has_billable_tokens());
// 仅有 cache_read 也属于真实计费 token,必须计入。
let only_cache = TokenUsage {
cache_read_tokens: 100,
..Default::default()
};
assert!(only_cache.has_billable_tokens());
let normal = TokenUsage {
input_tokens: 10,
output_tokens: 5,
..Default::default()
};
assert!(normal.has_billable_tokens());
}
#[test]
fn test_claude_stream_cache_only_request_is_recorded() {
// P2 回归:完全缓存命中、无输出的流式请求(input==0 && output==0 但 cache_read>0
// 是真实计费,必须保留——旧 gate `input>0 || output>0` 会把它丢弃。
let events = vec![
json!({
"type": "message_start",
"message": {
"id": "msg_cacheonly",
"model": "claude-opus-4-8",
"usage": {
"input_tokens": 0,
"cache_read_input_tokens": 50000,
"cache_creation_input_tokens": 0
}
}
}),
json!({
"type": "message_delta",
"usage": { "output_tokens": 0 }
}),
];
let usage = TokenUsage::from_claude_stream_events(&events)
.expect("cache-only 流式请求必须被记录,不能被 input/output gate 丢弃");
assert_eq!(usage.input_tokens, 0);
assert_eq!(usage.output_tokens, 0);
assert_eq!(usage.cache_read_tokens, 50000);
assert_eq!(usage.message_id, Some("msg_cacheonly".to_string()));
}
#[test]
fn test_codex_response_auto_returns_some_for_synthetic_all_zero() {
// P3 回归:上游非流式 Chat 省略 usage 时转换器合成的全 0 usagefrom_codex_response_auto
// 仍返回 Some(字段存在、无 positivity check)——证明 handlers 必须用 has_billable_tokens
// 闸门才能挡住空行,单靠 `if let Some` 不够。
let synthetic = json!({
"usage": { "input_tokens": 0, "output_tokens": 0, "total_tokens": 0 }
});
let usage = TokenUsage::from_codex_response_auto(&synthetic)
.expect("全 0 usage 字段存在时 from_codex_response_auto 返回 Some");
assert!(
!usage.has_billable_tokens(),
"全 0 usage 必须被 has_billable_tokens 判为非计费,由 handlers 闸门跳过"
);
}
#[test]
fn test_claude_response_parsing_no_model() {
let response = json!({
+5 -5
View File
@@ -72,7 +72,7 @@ async fn query_deepseek(api_key: &str) -> UsageResult {
.get("https://api.deepseek.com/user/balance")
.header("Authorization", format!("Bearer {api_key}"))
.header("Accept", "application/json")
.timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(15))
.send()
.await;
@@ -144,7 +144,7 @@ async fn query_stepfun(api_key: &str) -> UsageResult {
.get("https://api.stepfun.com/v1/accounts")
.header("Authorization", format!("Bearer {api_key}"))
.header("Accept", "application/json")
.timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(15))
.send()
.await;
@@ -203,7 +203,7 @@ async fn query_siliconflow(api_key: &str, is_cn: bool) -> UsageResult {
.get(&url)
.header("Authorization", format!("Bearer {api_key}"))
.header("Accept", "application/json")
.timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(15))
.send()
.await;
@@ -267,7 +267,7 @@ async fn query_openrouter(api_key: &str) -> UsageResult {
.get("https://openrouter.ai/api/v1/credits")
.header("Authorization", format!("Bearer {api_key}"))
.header("Accept", "application/json")
.timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(15))
.send()
.await;
@@ -327,7 +327,7 @@ async fn query_novita(api_key: &str) -> UsageResult {
.get("https://api.novita.ai/v3/user/balance")
.header("Authorization", format!("Bearer {api_key}"))
.header("Accept", "application/json")
.timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(15))
.send()
.await;
+158 -25
View File
@@ -95,7 +95,7 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota {
.get("https://api.kimi.com/coding/v1/usages")
.header("Authorization", format!("Bearer {api_key}"))
.header("Accept", "application/json")
.timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(15))
.send()
.await;
@@ -190,14 +190,46 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota {
// ── 智谱 GLM ────────────────────────────────────────────────
/// 智谱 TOKENS_LIMIT 条目按 `unit` 字段的显式窗口分类。
enum ZhipuWindow {
FiveHour,
Weekly,
}
/// 按 `unit` 字段判定 TOKENS_LIMIT 条目所属窗口。
///
/// 实测形态(bigmodel.cn 与 z.ai 共用同一后端,字段一致):
/// - `unit: 3, number: 5` → 5 小时滚动窗口(老/新套餐均有)
/// - `unit: 6, number: 7` 与 `unit: 6, number: 1` → 每周窗口(两种取值都被
/// 实测过,故只锚定 `unit`、不绑 `number`
///
/// `unit` 缺失或值不认识时返回 None,由调用方走重置时间启发式兜底。
fn classify_zhipu_window(item: &serde_json::Value) -> Option<ZhipuWindow> {
match item.get("unit").and_then(|v| v.as_i64()) {
Some(3) => Some(ZhipuWindow::FiveHour),
Some(6) => Some(ZhipuWindow::Weekly),
_ => None,
}
}
/// 把智谱 `data` 里的 `limits[]` 解析成 tier 列表。
///
/// 双桶响应中,5 小时桶在 0% 等状态下可能没有 `nextResetTime`
/// 这类无 reset 条目应优先归为五小时桶。其余条目按 `nextResetTime` 升序。
/// 分类优先级:
/// 1. 显式字段:`unit` 标识窗口类型(见 [`classify_zhipu_window`])。不能按
/// `nextResetTime` 排序代替——周期末尾每周窗口会比 5 小时窗口更早重置
/// issue #3036),时间排序在该场景必然把两桶标反。
/// 2. 兜底启发式(`unit` 缺失或不识别):无 `nextResetTime` 的条目优先归
/// five_hour5 小时桶在 0% 等状态下可能没有 reset),其余按 reset 升序
/// 依次填入仍空缺的槽位。
///
/// 老套餐(2026-02-12 前订阅)只回 1 条
/// `TOKENS_LIMIT`,自然降级为仅展示 `five_hour`;新套餐回 2 条。
fn parse_zhipu_token_tiers(data: &serde_json::Value) -> Vec<QuotaTier> {
let mut token_limits: Vec<(Option<i64>, f64, Option<String>)> = Vec::new();
type Entry = (Option<i64>, f64, Option<String>);
let mut five_hour: Option<Entry> = None;
let mut weekly: Option<Entry> = None;
let mut unclassified: Vec<Entry> = Vec::new();
if let Some(limits) = data.get("limits").and_then(|v| v.as_array()) {
for limit_item in limits {
let limit_type = limit_item
@@ -214,29 +246,38 @@ fn parse_zhipu_token_tiers(data: &serde_json::Value) -> Vec<QuotaTier> {
.unwrap_or(0.0);
let reset_ms = limit_item.get("nextResetTime").and_then(|v| v.as_i64());
let reset_iso = reset_ms.and_then(millis_to_iso8601);
token_limits.push((reset_ms, percentage, reset_iso));
let entry = (reset_ms, percentage, reset_iso);
match classify_zhipu_window(limit_item) {
Some(ZhipuWindow::FiveHour) if five_hour.is_none() => five_hour = Some(entry),
Some(ZhipuWindow::Weekly) if weekly.is_none() => weekly = Some(entry),
_ => unclassified.push(entry),
}
}
}
token_limits.sort_by_key(|(reset, _, _)| (reset.is_some(), reset.unwrap_or(i64::MIN)));
token_limits
.into_iter()
.enumerate()
.filter_map(|(idx, (_, percentage, resets_at))| {
let name = match idx {
0 => TIER_FIVE_HOUR,
1 => TIER_WEEKLY_LIMIT,
_ => return None, // 智谱当前最多两条 TOKENS_LIMIT,多余的忽略
};
Some(QuotaTier {
unclassified.sort_by_key(|(reset, _, _)| (reset.is_some(), reset.unwrap_or(i64::MIN)));
for entry in unclassified {
if five_hour.is_none() {
five_hour = Some(entry);
} else if weekly.is_none() {
weekly = Some(entry);
}
// 智谱当前最多两条 TOKENS_LIMIT,多余的忽略
}
let mut tiers = Vec::new();
for (name, slot) in [(TIER_FIVE_HOUR, five_hour), (TIER_WEEKLY_LIMIT, weekly)] {
if let Some((_, percentage, resets_at)) = slot {
tiers.push(QuotaTier {
name: name.to_string(),
utilization: percentage,
resets_at,
used_value_usd: None,
max_value_usd: None,
})
})
.collect()
});
}
}
tiers
}
/// Resolve the Zhipu quota endpoint from the user's configured `base_url`.
@@ -267,7 +308,7 @@ async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota {
.header("Authorization", api_key) // 注意:智谱不加 Bearer 前缀
.header("Content-Type", "application/json")
.header("Accept-Language", "en-US,en")
.timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(15))
.send()
.await;
@@ -350,7 +391,7 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota {
.get(&url)
.header("Authorization", format!("Bearer {api_key}"))
.header("Content-Type", "application/json")
.timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(15))
.send()
.await;
@@ -422,7 +463,7 @@ async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota {
.get(base_url)
.header("Authorization", format!("Bearer {api_key}"))
.header("Accept", "application/json")
.timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(15))
.send()
.await;
@@ -625,7 +666,8 @@ pub async fn get_coding_plan_quota(
success: false,
tiers: vec![],
extra_usage: None,
error: None,
// 与 balance::get_balance 一致:给出明确错误,避免 footer 显示无信息的失败
error: Some("API key is empty".to_string()),
queried_at: None,
});
}
@@ -640,9 +682,10 @@ pub async fn get_coding_plan_quota(
success: false,
tiers: vec![],
extra_usage: None,
error: None,
// 域名未命中已知套餐供应商(如第三方中转站):给出明确错误而非静默失败
error: Some("Unknown coding plan provider".to_string()),
queried_at: None,
})
});
}
};
@@ -779,6 +822,96 @@ mod tests {
assert_eq!(tiers[1].utilization, 150.0);
}
#[test]
fn zhipu_unit_field_overrides_reset_order_when_weekly_resets_sooner() {
// 真实案例(issue #30362026-06-10 再次复现):每周周期末尾,周桶比
// 5 小时桶更早重置。官网真实值:5h 用 1%(约 5h 后重置)、每周用 42%
// (约 1h 后重置)。旧逻辑按 reset 升序必然标反,unit 字段须优先。
let data = json!({
"limits": [
{ "type": "TOKENS_LIMIT", "unit": 6, "number": 7, "percentage": 42.0, "nextResetTime": 1_000_003_600_000_i64 },
{ "type": "TOKENS_LIMIT", "unit": 3, "number": 5, "percentage": 1.0, "nextResetTime": 1_000_018_000_000_i64 }
]
});
let tiers = parse_zhipu_token_tiers(&data);
assert_eq!(tiers.len(), 2);
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
assert_eq!(tiers[0].utilization, 1.0);
assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT);
assert_eq!(tiers[1].utilization, 42.0);
}
#[test]
fn zhipu_weekly_unit_six_number_one_variant() {
// z.ai 也观测过 (unit:6, number:1) 表示每周窗口(按"1 周"计),
// 分类只看 unitnumber 取值不影响。
let data = json!({
"limits": [
{ "type": "TOKENS_LIMIT", "unit": 6, "number": 1, "percentage": 30.0, "nextResetTime": 1_000_000_000_000_i64 },
{ "type": "TOKENS_LIMIT", "unit": 3, "number": 5, "percentage": 10.0, "nextResetTime": 2_000_000_000_000_i64 }
]
});
let tiers = parse_zhipu_token_tiers(&data);
assert_eq!(tiers.len(), 2);
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
assert_eq!(tiers[0].utilization, 10.0);
assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT);
assert_eq!(tiers[1].utilization, 30.0);
}
#[test]
fn zhipu_partial_unit_fields_fill_remaining_slot() {
// 只有周桶带 unit 时,缺 unit 的另一条应填入剩下的 five_hour 槽位,
// 即便它的 reset 更晚——显式分类结果不受时间排序干扰。
let data = json!({
"limits": [
{ "type": "TOKENS_LIMIT", "unit": 6, "number": 7, "percentage": 42.0, "nextResetTime": 1_000_000_000_000_i64 },
{ "type": "TOKENS_LIMIT", "percentage": 1.0, "nextResetTime": 2_000_000_000_000_i64 }
]
});
let tiers = parse_zhipu_token_tiers(&data);
assert_eq!(tiers.len(), 2);
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
assert_eq!(tiers[0].utilization, 1.0);
assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT);
assert_eq!(tiers[1].utilization, 42.0);
}
#[test]
fn zhipu_unknown_unit_values_fall_back_to_reset_order() {
// 未识别的 unit 枚举值不猜语义,整体回落旧的重置时间启发式。
let data = json!({
"limits": [
{ "type": "TOKENS_LIMIT", "unit": 9, "percentage": 44.0, "nextResetTime": 1_000_000_000_000_i64 },
{ "type": "TOKENS_LIMIT", "unit": 9, "percentage": 53.0, "nextResetTime": 2_000_000_000_000_i64 }
]
});
let tiers = parse_zhipu_token_tiers(&data);
assert_eq!(tiers.len(), 2);
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
assert_eq!(tiers[0].utilization, 44.0);
assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT);
assert_eq!(tiers[1].utilization, 53.0);
}
#[test]
fn zhipu_duplicate_unit_classification_fills_other_slot() {
// 防御性:两条都标成 5 小时窗(上游异常)时,第一条占 five_hour,
// 第二条降级走兜底填入 weekly,保证不丢数据也不 panic。
let data = json!({
"limits": [
{ "type": "TOKENS_LIMIT", "unit": 3, "number": 5, "percentage": 10.0, "nextResetTime": 1_000_000_000_000_i64 },
{ "type": "TOKENS_LIMIT", "unit": 3, "number": 5, "percentage": 20.0, "nextResetTime": 2_000_000_000_000_i64 }
]
});
let tiers = parse_zhipu_token_tiers(&data);
assert_eq!(tiers.len(), 2);
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
assert_eq!(tiers[0].utilization, 10.0);
assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT);
assert_eq!(tiers[1].utilization, 20.0);
}
#[test]
fn zhipu_more_than_two_token_limits_keeps_first_two() {
// 防御性:智谱当前最多两条 TOKENS_LIMIT,若上游意外增加第三条应被丢弃,避免命名空缺。
+10 -5
View File
@@ -4,6 +4,7 @@
//! 主要面向第三方聚合站(硅基流动、OpenRouter 等),以及把 Anthropic
//! 协议挂在兼容子路径上的官方供应商(DeepSeek、Kimi、智谱 GLM 等)。
use reqwest::header::{HeaderValue, USER_AGENT};
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use std::time::Duration;
@@ -55,6 +56,7 @@ pub async fn fetch_models(
api_key: &str,
is_full_url: bool,
models_url_override: Option<&str>,
user_agent: Option<HeaderValue>,
) -> Result<Vec<FetchedModel>, String> {
if api_key.is_empty() {
return Err("API Key is required to fetch models".to_string());
@@ -66,13 +68,16 @@ pub async fn fetch_models(
for url in &candidates {
log::debug!("[ModelFetch] Trying endpoint: {url}");
let response = match client
let mut request = client
.get(url)
.header("Authorization", format!("Bearer {api_key}"))
.timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
.send()
.await
{
.timeout(Duration::from_secs(FETCH_TIMEOUT_SECS));
// 自定义 User-Agent:部分 /models 端点同样有 UA 白名单(如 Kimi Coding Plan),
// 与转发 / 检测路径共用同一 UA,避免"代理可用但取模型失败"。
if let Some(ua) = &user_agent {
request = request.header(USER_AGENT, ua.clone());
}
let response = match request.send().await {
Ok(r) => r,
Err(e) => {
return Err(format!("Request failed: {e}"));
+29
View File
@@ -704,6 +704,19 @@ fn restore_live_settings_for_provider_backfill(
strip_codex_managed_oauth_auth_for_backfill(provider, &mut settings);
// 统一会话开关注入的共享 `custom` 路由只属于 live 配置;切换回填时
// 必须剥掉,否则官方供应商的存储配置被污染,关闭开关后无法还原。
if provider.category.as_deref() == Some("official") {
if let Err(err) =
crate::codex_config::strip_codex_unified_session_bucket_from_settings(&mut settings)
{
log::warn!(
"Failed to strip unified session bucket while backfilling '{}': {err}",
provider.id
);
}
}
// `modelCatalog` is a cc-switchprivate field whose SSOT is the DB. Live's
// `config.toml` only carries a lossy projection (`model_catalog_json` →
// generated catalog file) that proxy takeover/restore cycles and Codex.app
@@ -1284,6 +1297,22 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
return Ok(false);
}
// 拒绝把"被代理接管的 Live"导入为供应商:接管期间 Live 里只有
// PROXY_MANAGED 占位符和本地代理地址,不是用户的真实配置。一旦导入,
// 它会成为 current providerSSOT),后续"无备份恢复"路径会把占位符
// 当真实配置写回 Live,永久卡在已失效的本地代理上。
// 典型触发场景:代理接管开启时切换 app_config_dir 并重启,新数据库首启导入。
if state
.proxy_service
.detect_takeover_in_live_config_for_app(&app_type)
{
return Err(AppError::localized(
"provider.import.live_taken_over",
"Live 配置当前处于代理接管状态(包含占位符),不能导入为供应商。请先关闭代理接管或恢复 Live 配置后重试。",
"The live config is currently taken over by the proxy (contains placeholders) and cannot be imported as a provider. Disable proxy takeover or restore the live config first.",
));
}
let settings_config = match app_type {
AppType::Codex => crate::codex_config::read_codex_live_settings()?,
AppType::Claude => {
+42
View File
@@ -44,6 +44,48 @@ use live::{
};
use usage::validate_usage_script;
/// 统一会话开关变更后,立即按新开关状态重写当前官方 Codex 供应商的
/// live 配置,使开关即时生效(无需等下一次切换)。
/// 当前供应商非官方(或不存在)时为 no-op:注入只作用于官方配置,
/// 第三方 live 配置不受开关影响。
pub fn reapply_current_codex_official_live(state: &AppState) -> Result<bool, AppError> {
let current_id = ProviderService::current(state, AppType::Codex)?;
if current_id.is_empty() {
return Ok(false);
}
let providers = state.db.get_all_providers(AppType::Codex.as_str())?;
let Some(provider) = providers.get(&current_id) else {
return Ok(false);
};
if provider.category.as_deref() != Some("official") {
return Ok(false);
}
// 代理接管期间 live 归代理所有(开启代理时官方供应商只警告不拦截,
// 二者可以共存)。与切换/保存路径一致:以 backup/占位符为所有权信号,
// 只更新备份,注入后的配置由接管释放时的恢复路径落盘。
let has_live_backup =
futures::executor::block_on(state.db.get_live_backup(AppType::Codex.as_str()))
.ok()
.flatten()
.is_some();
let live_taken_over = state
.proxy_service
.detect_takeover_in_live_config_for_app(&AppType::Codex);
if has_live_backup || live_taken_over {
futures::executor::block_on(
state
.proxy_service
.update_live_backup_from_provider(AppType::Codex.as_str(), provider),
)
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
return Ok(true);
}
live::write_live_with_common_config_for_state(state, &AppType::Codex, provider)?;
Ok(true)
}
/// Provider business logic service
pub struct ProviderService;
+123 -44
View File
@@ -81,32 +81,33 @@ pub(crate) async fn execute_and_format_usage_result(
}
}
/// Extract API key from provider configuration
fn extract_api_key_from_provider(provider: &crate::provider::Provider) -> Option<String> {
if let Some(env) = provider.settings_config.get("env") {
// Try multiple possible API key fields
env.get("ANTHROPIC_AUTH_TOKEN")
.or_else(|| env.get("ANTHROPIC_API_KEY"))
.or_else(|| env.get("OPENROUTER_API_KEY"))
.or_else(|| env.get("GOOGLE_API_KEY"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
} else {
None
}
}
/// Resolve `(api_key, base_url)` for the JS-script path: explicit non-empty
/// script values win, otherwise fall back to the provider's stored config via
/// `Provider::resolve_usage_credentials` — the same per-app resolver the
/// native balance/coding-plan path and the frontend `getProviderCredentials`
/// use, so `{{apiKey}}`/`{{baseUrl}}` match what the UI shows for them.
fn resolve_script_credentials(
app_type: &AppType,
provider: &crate::provider::Provider,
api_key: Option<&str>,
base_url: Option<&str>,
) -> (String, String) {
let (provider_base_url, provider_api_key) = provider.resolve_usage_credentials(app_type);
/// Extract base URL from provider configuration
fn extract_base_url_from_provider(provider: &crate::provider::Provider) -> Option<String> {
if let Some(env) = provider.settings_config.get("env") {
// Try multiple possible base URL fields
env.get("ANTHROPIC_BASE_URL")
.or_else(|| env.get("GOOGLE_GEMINI_BASE_URL"))
.and_then(|v| v.as_str())
.map(|s| s.trim_end_matches('/').to_string())
} else {
None
}
let api_key = api_key
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_owned)
.unwrap_or(provider_api_key);
let base_url = base_url
.map(str::trim)
.filter(|value| !value.is_empty())
// Trim like the provider path so `{{baseUrl}}/path` never doubles the slash.
.map(|value| value.trim_end_matches('/').to_owned())
.unwrap_or(provider_base_url);
(api_key, base_url)
}
/// Query provider usage (using saved script configuration)
@@ -145,19 +146,12 @@ pub async fn query_usage(
}
// Get credentials: prioritize UsageScript values, fallback to provider config
let api_key = usage_script
.api_key
.clone()
.filter(|k| !k.is_empty())
.or_else(|| extract_api_key_from_provider(provider))
.unwrap_or_default();
let base_url = usage_script
.base_url
.clone()
.filter(|u| !u.is_empty())
.or_else(|| extract_base_url_from_provider(provider))
.unwrap_or_default();
let (api_key, base_url) = resolve_script_credentials(
&app_type,
provider,
usage_script.api_key.as_deref(),
usage_script.base_url.as_deref(),
);
(
usage_script.code.clone(),
@@ -185,9 +179,9 @@ pub async fn query_usage(
/// Test usage script (using temporary script content, not saved)
#[allow(clippy::too_many_arguments)]
pub async fn test_usage_script(
_state: &AppState,
_app_type: AppType,
_provider_id: &str,
state: &AppState,
app_type: AppType,
provider_id: &str,
script_code: &str,
timeout: u64,
api_key: Option<&str>,
@@ -196,11 +190,23 @@ pub async fn test_usage_script(
user_id: Option<&str>,
template_type: Option<&str>,
) -> Result<UsageResult, AppError> {
// Use provided credential parameters directly for testing
let providers = state.db.get_all_providers(app_type.as_str())?;
let provider = providers.get(provider_id).ok_or_else(|| {
AppError::localized(
"provider.not_found",
format!("供应商不存在: {provider_id}"),
format!("Provider not found: {provider_id}"),
)
})?;
// Resolve like the real query so testing matches what a saved script does:
// explicit values win, empty ones fall back to the provider config.
let (api_key, base_url) = resolve_script_credentials(&app_type, provider, api_key, base_url);
execute_and_format_usage_result(
script_code,
api_key.unwrap_or(""),
base_url.unwrap_or(""),
&api_key,
&base_url,
timeout,
access_token,
user_id,
@@ -226,3 +232,76 @@ pub(crate) fn validate_usage_script(script: &UsageScript) -> Result<(), AppError
Ok(())
}
#[cfg(test)]
mod tests {
use super::resolve_script_credentials;
use crate::app_config::AppType;
use crate::provider::Provider;
use serde_json::json;
fn provider_with_settings(settings_config: serde_json::Value) -> Provider {
Provider::with_id(
"provider-1".to_string(),
"Provider".to_string(),
settings_config,
None,
)
}
#[test]
fn script_values_override_provider_credentials() {
let provider = provider_with_settings(json!({
"env": {
"ANTHROPIC_AUTH_TOKEN": "provider-key",
"ANTHROPIC_BASE_URL": "https://provider.example.com/"
}
}));
let (api_key, base_url) = resolve_script_credentials(
&AppType::Claude,
&provider,
Some(" script-key "),
Some(" https://script.example.com/ "),
);
assert_eq!(api_key, "script-key");
assert_eq!(base_url, "https://script.example.com");
}
#[test]
fn empty_script_values_fall_back_to_provider_credentials() {
let provider = provider_with_settings(json!({
"env": {
"ANTHROPIC_AUTH_TOKEN": "provider-key",
"ANTHROPIC_BASE_URL": "https://provider.example.com/"
}
}));
let (api_key, base_url) =
resolve_script_credentials(&AppType::Claude, &provider, Some(""), None);
assert_eq!(api_key, "provider-key");
assert_eq!(base_url, "https://provider.example.com");
}
#[test]
fn codex_fallback_reads_auth_and_config_toml() {
let provider = provider_with_settings(json!({
"auth": {
"OPENAI_API_KEY": "openai-key"
},
"config": r#"model_provider = "azure"
[model_providers.azure]
base_url = "https://azure.example.com/v1/"
[model_providers.other]
base_url = "https://other.example.com/v1"
"#
}));
let (api_key, base_url) =
resolve_script_credentials(&AppType::Codex, &provider, None, None);
assert_eq!(api_key, "openai-key");
assert_eq!(base_url, "https://azure.example.com/v1");
}
}
+137 -4
View File
@@ -51,7 +51,7 @@ const CLAUDE_ONE_M_MARKER_FOR_CLIENT: &str = "[1M]";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ClaudeTakeoverAuthPolicy {
PreserveExistingOrAuthToken,
ManagedAccount,
ManagedAccount { keep_auth_token: bool },
}
#[derive(Clone)]
@@ -106,7 +106,12 @@ impl ProxyService {
provider: &Provider,
) {
let auth_policy = if provider.uses_managed_account_auth() {
ClaudeTakeoverAuthPolicy::ManagedAccount
// Codex 系(含仅凭 base_url 识别、无 provider_type meta 的)必须保留
// ANTHROPIC_AUTH_TOKEN 占位符:Claude Code 缺该键会弹登录提示(#3784)。
// Copilot 维持仅 API_KEY 占位,避免与 /login 管理的 key 冲突(#1049)。
ClaudeTakeoverAuthPolicy::ManagedAccount {
keep_auth_token: !provider.is_github_copilot(),
}
} else {
ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken
};
@@ -196,7 +201,7 @@ impl ProxyService {
);
}
}
ClaudeTakeoverAuthPolicy::ManagedAccount => {
ClaudeTakeoverAuthPolicy::ManagedAccount { keep_auth_token } => {
for key in token_keys {
env.remove(key);
}
@@ -204,6 +209,14 @@ impl ProxyService {
"ANTHROPIC_API_KEY".to_string(),
json!(PROXY_TOKEN_PLACEHOLDER),
);
if keep_auth_token {
// 无条件注入而非"已存在才保留":热切换路径传入的是 provider
// settings(预设不含该键),且旧版接管已把存量用户 live 中的键删光。
env.insert(
"ANTHROPIC_AUTH_TOKEN".to_string(),
json!(PROXY_TOKEN_PLACEHOLDER),
);
}
}
}
}
@@ -1648,7 +1661,7 @@ impl ProxyService {
///
/// 返回值:
/// - Ok(true):已成功写回
/// - Ok(false):缺少当前供应商/供应商不存在,无法写回
/// - Ok(false):缺少当前供应商/供应商不存在/供应商本身含占位符,无法写回
fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {
let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)
.map_err(|e| format!("获取 {app_type:?} 当前供应商失败: {e}"))?;
@@ -1666,6 +1679,16 @@ impl ProxyService {
return Ok(false);
};
// 供应商配置本身含接管占位符时不可写回(历史异常:接管期间 Live 被
// 误导入成了供应商)。写回只会把占位符固化进 Live;返回 Ok(false)
// 让调用方落到"清理占位符"兜底。
if Self::live_has_proxy_placeholder_for_app(app_type, &provider.settings_config) {
log::warn!(
"{app_type:?} 当前供应商配置含代理接管占位符(疑似接管期间被导入的残留),跳过 SSOT 写回,改走占位符清理"
);
return Ok(false);
}
write_live_with_common_config_for_codex_oauth_manager(
self.db.as_ref(),
app_type,
@@ -2065,6 +2088,14 @@ impl ProxyService {
)?;
}
}
// 统一会话开关:备份是接管释放时恢复 live 的来源,官方配置的
// 共享 custom 路由注入必须落在备份里,否则恢复后开关失效。
crate::codex_config::apply_codex_unified_session_bucket_to_settings(
provider.category.as_deref(),
&mut effective_settings,
)
.map_err(|e| format!("注入统一会话路由失败: {e}"))?;
}
let backup_json = match app_type_enum {
@@ -3037,6 +3068,108 @@ mod tests {
assert_env_str(env, "ANTHROPIC_DEFAULT_OPUS_MODEL", Some("claude-opus-4-8"));
assert_env_str(env, "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", Some("gpt-5.4"));
assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER));
assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", Some(PROXY_TOKEN_PLACEHOLDER));
}
#[test]
fn managed_account_claude_takeover_codex_injects_auth_token_without_preexisting_key() {
let mut provider = Provider::with_id(
"codex".to_string(),
"Codex".to_string(),
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex"
}
}),
None,
);
provider.meta = Some(ProviderMeta {
provider_type: Some("codex_oauth".to_string()),
..Default::default()
});
// 全新安装/热切换形态:传入的 env 没有任何 token 键。
let mut live_config = provider.settings_config.clone();
ProxyService::apply_claude_takeover_fields_for_provider(
&mut live_config,
"http://127.0.0.1:15721",
&provider,
);
let env = live_config
.get("env")
.and_then(|value| value.as_object())
.expect("env should exist");
assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER));
assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", Some(PROXY_TOKEN_PLACEHOLDER));
}
#[test]
fn managed_account_claude_takeover_codex_by_base_url_keeps_auth_token() {
// 无 provider_type meta、仅凭 base_url 识别为受管 codex 的供应商,
// 也必须保留 AUTH_TOKEN 占位符(与策略选择共用同一判定族)。
let provider = Provider::with_id(
"codex-url-only".to_string(),
"Codex (URL only)".to_string(),
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex"
}
}),
None,
);
assert!(provider.uses_managed_account_auth());
assert!(!provider.is_codex_oauth());
let mut live_config = provider.settings_config.clone();
ProxyService::apply_claude_takeover_fields_for_provider(
&mut live_config,
"http://127.0.0.1:15721",
&provider,
);
let env = live_config
.get("env")
.and_then(|value| value.as_object())
.expect("env should exist");
assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER));
assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", Some(PROXY_TOKEN_PLACEHOLDER));
}
#[test]
fn managed_account_claude_takeover_copilot_removes_stale_auth_token() {
let mut provider = Provider::with_id(
"copilot".to_string(),
"GitHub Copilot".to_string(),
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
}
}),
None,
);
provider.meta = Some(ProviderMeta {
provider_type: Some("github_copilot".to_string()),
..Default::default()
});
let mut live_config = json!({
"env": {
"ANTHROPIC_BASE_URL": "https://stale.example.com",
"ANTHROPIC_AUTH_TOKEN": "stale-token"
}
});
ProxyService::apply_claude_takeover_fields_for_provider(
&mut live_config,
"http://127.0.0.1:15721",
&provider,
);
let env = live_config
.get("env")
.and_then(|value| value.as_object())
.expect("env should exist");
assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER));
assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", None);
}
+127 -17
View File
@@ -109,9 +109,15 @@ pub fn sync_claude_session_logs(db: &Database) -> Result<SessionSyncResult, AppE
/// 收集目录下所有 .jsonl 文件(含子 agent 文件)
///
/// 扫描三层固定深度,不使用递归,避免死循环:
/// projects_dir/项目目录/*.jsonl (主会话)
/// projects_dir/项目目录/SESSION_ID/subagents/*.jsonl (子 agent)
/// 扫描固定深度,不使用递归,避免死循环:
/// projects_dir/项目目录/*.jsonl (主会话)
/// projects_dir/项目目录/SESSION_ID/subagents/*.jsonl (Task/Agent 子 agent)
/// projects_dir/项目目录/SESSION_ID/subagents/workflows/wf_*/*.jsonl (Workflow 子 agent)
///
/// 最后一层是 Claude Code Workflow 功能产生的子 agent transcript,比普通子
/// agent 多嵌套一层 `workflows/wf_<ID>/`。漏掉这一层会让 Workflow 的 token
/// 用量完全不计入统计;`journal.jsonl` 不含 `type=="assistant"` 行,解析时
/// 会被 `sync_single_file` 天然跳过,因此这里无需按文件名过滤。
fn collect_jsonl_files(projects_dir: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
@@ -136,12 +142,18 @@ fn collect_jsonl_files(projects_dir: &Path) -> Vec<PathBuf> {
// 扫描子 agent 目录: 项目/SESSION_ID/subagents/*.jsonl
let subagents_dir = sub_path.join("subagents");
if subagents_dir.is_dir() {
if let Ok(agent_entries) = fs::read_dir(&subagents_dir) {
for agent_entry in agent_entries.flatten() {
let agent_path = agent_entry.path();
if agent_path.extension().and_then(|e| e.to_str()) == Some("jsonl")
{
files.push(agent_path);
push_jsonl_children(&subagents_dir, &mut files);
// 额外下探 Workflow 子 agent:
// 项目/SESSION_ID/subagents/workflows/wf_<ID>/*.jsonl
let workflows_dir = subagents_dir.join("workflows");
if workflows_dir.is_dir() {
if let Ok(wf_entries) = fs::read_dir(&workflows_dir) {
for wf_entry in wf_entries.flatten() {
let wf_path = wf_entry.path();
if wf_path.is_dir() {
push_jsonl_children(&wf_path, &mut files);
}
}
}
}
@@ -154,6 +166,18 @@ fn collect_jsonl_files(projects_dir: &Path) -> Vec<PathBuf> {
files
}
/// 将 `dir` 下直接子层的所有 `.jsonl` 文件追加到 `files`(不递归)。
fn push_jsonl_children(dir: &Path, files: &mut Vec<PathBuf>) {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
files.push(path);
}
}
}
}
/// 同步单个 JSONL 文件,返回 (imported, skipped)
fn sync_single_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppError> {
let file_path_str = file_path.to_string_lossy().to_string();
@@ -290,8 +314,23 @@ fn sync_single_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppEr
let mut skipped: u32 = 0;
for msg in messages.values() {
// 只导入有 stop_reason 的最终条目(完整的 API 调用)
if msg.stop_reason.is_none() {
// 只要产生了真实计费 token 就导入,不再强制要求 stop_reason 或 output>0。
//
// Anthropic 在受理请求时即对 input + cache_read + cache_creation 计费
// (这些在请求开始就确定),output 按实际生成量计。Workflow / 子 agent 的
// 并行短命请求经常只写了 message_start 快照(output=1、stop_reason=None
// 却没有写最终块,但其 cache/input 成本已被真实计费。旧逻辑用 stop_reason
// 非空 + output>0 双重过滤,会把这类请求整条丢弃,实测系统性低估约 4.1%,
// 且 92% 集中在 workflow/subagent。这里改为「任一计费维度 > 0 即导入」。
//
// 去重选择逻辑(上方按 message.id 取 stop_reason 优先 / output 最大者)保持
// 不变:它选出的代表行的 input/cache 本就准确;request_id = session:msg_id
// 主键 + INSERT OR IGNORE 保证一个 message 仍只落库一次,放宽 gate 不会双算。
let has_billable_tokens = msg.input_tokens > 0
|| msg.output_tokens > 0
|| msg.cache_read_tokens > 0
|| msg.cache_creation_tokens > 0;
if !has_billable_tokens {
continue;
}
@@ -301,11 +340,6 @@ fn sync_single_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppEr
msg.message_id
);
// 跳过 output_tokens 为 0 的无意义条目
if msg.output_tokens == 0 {
continue;
}
match insert_session_log_entry(db, &request_id, msg) {
Ok(true) => imported += 1,
Ok(false) => skipped += 1,
@@ -467,7 +501,7 @@ fn insert_session_log_entry(
total_cost,
0i64, // latency_ms: 会话日志无此数据
Option::<i64>::None, // first_token_ms
200i64, // status_code: 有 stop_reason 说明请求成功
200i64, // status_code: 会话日志中的请求只要产生计费 token 即视为成功
Option::<String>::None, // error_message
msg.session_id,
Some("session_log"), // provider_type
@@ -685,4 +719,80 @@ mod tests {
fs::remove_dir_all(&tmp).ok();
}
#[test]
fn test_collect_jsonl_files_includes_workflow_subagents() {
// Claude Code Workflow 把子 agent transcript 嵌在
// 项目/SESSION_ID/subagents/workflows/wf_<ID>/ 下,比普通子 agent 深一层。
let tmp = std::env::temp_dir().join(format!("cc-switch-test-{}", uuid::Uuid::new_v4()));
let project = tmp.join("project");
let session_dir = project.join("test-session");
let subagents_dir = session_dir.join("subagents");
let wf_dir = subagents_dir.join("workflows").join("wf_test123");
fs::create_dir_all(&wf_dir).unwrap();
fs::write(project.join("main.jsonl"), "{}").unwrap();
fs::write(subagents_dir.join("agent-plain.jsonl"), "{}").unwrap();
fs::write(wf_dir.join("agent-wf.jsonl"), "{}").unwrap();
// journal.jsonl 也会被收集,但解析时因无 assistant 行而产出 0 条
fs::write(wf_dir.join("journal.jsonl"), "{}").unwrap();
let files = collect_jsonl_files(&tmp);
let paths: Vec<String> = files
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect();
// 主会话 + 普通子 agent + Workflow 子 agent(agent-wf + journal) = 4
assert_eq!(files.len(), 4);
assert!(paths.iter().any(|p| p.contains("main.jsonl")));
assert!(paths.iter().any(|p| p.contains("agent-plain.jsonl")));
assert!(
paths.iter().any(|p| p.contains("agent-wf.jsonl")),
"Workflow 子 agent transcript 必须被收集"
);
fs::remove_dir_all(&tmp).ok();
}
#[test]
fn test_sync_imports_billable_message_without_stop_reason() -> Result<(), AppError> {
// 回归:stop_reason 缺失但有真实 cache/input 成本的 messageWorkflow /
// 子 agent 常见的「只有 message_start 快照、没写最终块」形态)必须被计入,
// 不能因缺 stop_reason 或 output==0 而整条丢弃;全 0 token 的占位行仍应跳过。
let db = Database::memory()?;
let tmp = std::env::temp_dir().join(format!("cc-switch-test-{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&tmp).unwrap();
let file = tmp.join("agent-wf.jsonl");
// 第一行:无 stop_reason、output=1,但 cache_read/cache_creation 很大 → 应导入
// 第二行:全部 token 为 0 → 应跳过(无计费意义)
let billable = r#"{"type":"assistant","message":{"id":"msg_nostop","model":"claude-opus-4-8","usage":{"input_tokens":2,"output_tokens":1,"cache_read_input_tokens":48719,"cache_creation_input_tokens":2061}},"timestamp":"2026-06-07T13:01:23Z","sessionId":"session-wf"}"#;
let empty = r#"{"type":"assistant","message":{"id":"msg_empty","model":"claude-opus-4-8","usage":{"input_tokens":0,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}},"timestamp":"2026-06-07T13:01:24Z","sessionId":"session-wf"}"#;
fs::write(&file, format!("{billable}\n{empty}\n")).unwrap();
let (imported, _skipped) = sync_single_file(&db, &file)?;
assert_eq!(
imported, 1,
"有 cache 成本但无 stop_reason 的 message 必须被导入"
);
let conn = lock_conn!(db.conn);
let cache_read: i64 = conn.query_row(
"SELECT cache_read_tokens FROM proxy_request_logs WHERE request_id = 'session:msg_nostop'",
[],
|row| row.get(0),
)?;
assert_eq!(cache_read, 48719, "cache_read 必须被完整记录");
let empty_exists: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM proxy_request_logs WHERE request_id = 'session:msg_empty')",
[],
|row| row.get(0),
)?;
assert!(!empty_exists, "全 0 token 的 message 应被跳过");
drop(conn);
fs::remove_dir_all(&tmp).ok();
Ok(())
}
}
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -328,7 +328,7 @@ async fn query_claude_quota(access_token: &str) -> SubscriptionQuota {
.header("Authorization", format!("Bearer {access_token}"))
.header("anthropic-beta", "oauth-2025-04-20")
.header("Accept", "application/json")
.timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(15))
.send()
.await;
@@ -668,7 +668,7 @@ pub(crate) async fn query_codex_quota(
req = req.header("ChatGPT-Account-Id", id);
}
let resp = match req.timeout(std::time::Duration::from_secs(10)).send().await {
let resp = match req.timeout(std::time::Duration::from_secs(15)).send().await {
Ok(r) => r,
Err(e) => {
return SubscriptionQuota::error(
@@ -964,7 +964,7 @@ async fn refresh_gemini_token(refresh_token: &str) -> Option<String> {
("refresh_token", refresh_token),
("grant_type", "refresh_token"),
])
.timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.ok()?;
@@ -1048,7 +1048,7 @@ async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota {
"pluginType": "GEMINI"
}
}))
.timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(15))
.send()
.await;
@@ -1109,7 +1109,7 @@ async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota {
.header("Authorization", format!("Bearer {access_token}"))
.header("Content-Type", "application/json")
.json(&quota_body)
.timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(15))
.send()
.await;
File diff suppressed because it is too large Load Diff
+91
View File
@@ -287,6 +287,10 @@ pub struct LocalMigrations {
Option<CodexThirdPartyHistoryProviderBucketMigration>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_provider_template_v1: Option<CodexProviderTemplateMigration>,
/// 统一会话开关的官方历史迁移标记。开关关闭时会被清除,
/// 这样重新开启能把"关闭期间"落入 openai 桶的官方会话补迁进来。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_official_history_unify_v1: Option<CodexOfficialHistoryUnifyMigration>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -312,6 +316,21 @@ pub struct CodexProviderTemplateMigration {
pub migrated_provider_ids: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CodexOfficialHistoryUnifyMigration {
pub completed_at: String,
pub target_provider_id: String,
#[serde(default)]
pub migrated_jsonl_files: usize,
#[serde(default)]
pub migrated_state_rows: usize,
/// 迁移时的规范化 Codex 目录。标记只对同一目录生效:
/// 切换 codex_config_dir 后旧标记不会挡住新目录的迁移。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_config_dir: Option<String>,
}
/// 应用设置结构
///
/// 存储设备级别设置,保存在本地 `~/.cc-switch/settings.json`,不随数据库同步。
@@ -357,6 +376,16 @@ pub struct AppSettings {
/// Opt-in: defaults to false so third-party switches cleanly overwrite auth.json.
#[serde(default)]
pub preserve_codex_official_auth_on_switch: bool,
/// Run official Codex providers under the shared "custom" model_provider id
/// so official sessions share one resume-history bucket with third-party
/// providers. Opt-in: defaults to false.
#[serde(default)]
pub unify_codex_session_history: bool,
/// User opted in (via the enable dialog checkbox) to migrate existing
/// official sessions ("openai" bucket) into the shared bucket. Persisted so
/// a failed migration retries at startup; cleared when the toggle turns off.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unify_codex_migrate_existing: Option<bool>,
/// User has confirmed the failover toggle first-run notice
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failover_confirmed: Option<bool>,
@@ -475,6 +504,8 @@ impl Default for AppSettings {
stream_check_confirmed: None,
enable_failover_toggle: false,
preserve_codex_official_auth_on_switch: false,
unify_codex_session_history: false,
unify_codex_migrate_existing: None,
failover_confirmed: None,
first_run_notice_confirmed: None,
common_config_confirmed: None,
@@ -759,6 +790,56 @@ pub fn mark_codex_provider_template_migrated(
})
}
/// 统一会话迁移标记是否覆盖指定目录。标记里没记目录(不应出现的旧格式)
/// 视为不匹配——重跑迁移是幂等的,宁可重迁也不漏迁。
pub fn is_codex_official_history_unify_migrated_for_dir(codex_dir: &str) -> bool {
get_settings()
.local_migrations
.as_ref()
.and_then(|migrations| migrations.codex_official_history_unify_v1.as_ref())
.is_some_and(|migration| migration.codex_config_dir.as_deref() == Some(codex_dir))
}
/// 条件写入迁移完成标记:仅当此刻开关仍开启且迁移意愿仍在时才写。
/// 检查与写入在 settings 写锁内原子完成,与关闭开关路径
/// `update_settings` / 清标记)串行,消除"迁移线程复查开关后、写标记前
/// 用户恰好关闭开关"的竞态窗口。返回是否实际写入。
pub fn mark_codex_official_history_unify_migrated_if_enabled(
migration: CodexOfficialHistoryUnifyMigration,
) -> Result<bool, AppError> {
let mut written = false;
mutate_settings(|settings| {
if settings.unify_codex_session_history
&& settings.unify_codex_migrate_existing.unwrap_or(false)
{
settings
.local_migrations
.get_or_insert_with(Default::default)
.codex_official_history_unify_v1 = Some(migration);
written = true;
}
})?;
Ok(written)
}
pub fn clear_codex_official_history_unify_migration() -> Result<(), AppError> {
mutate_settings(|settings| {
if let Some(migrations) = settings.local_migrations.as_mut() {
migrations.codex_official_history_unify_v1 = None;
}
})
}
pub fn unify_codex_migrate_existing_requested() -> bool {
get_settings().unify_codex_migrate_existing.unwrap_or(false)
}
pub fn clear_codex_unify_migrate_existing() -> Result<(), AppError> {
mutate_settings(|settings| {
settings.unify_codex_migrate_existing = None;
})
}
/// 从文件重新加载设置到内存缓存
/// 用于导入配置等场景,确保内存缓存与文件同步
pub fn reload_settings() -> Result<(), AppError> {
@@ -829,6 +910,16 @@ pub fn preserve_codex_official_auth_on_switch() -> bool {
.preserve_codex_official_auth_on_switch
}
pub fn unify_codex_session_history() -> bool {
settings_store()
.read()
.unwrap_or_else(|e| {
log::warn!("设置锁已毒化,使用恢复值: {e}");
e.into_inner()
})
.unify_codex_session_history
}
// ===== 当前供应商管理函数 =====
/// 获取指定应用类型的当前供应商 ID(从本地 settings 读取)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.16.1",
"version": "3.16.3",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
+27
View File
@@ -588,3 +588,30 @@ fn switch_provider_codex_missing_auth_returns_error_and_keeps_state() {
"current provider should remain empty or be the attempted id on failure, got: {current_id:?}"
);
}
#[test]
fn import_refuses_live_config_under_proxy_takeover() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
ensure_test_home();
// 接管态 Codex Liveauth 是 PROXY_MANAGED 占位符,不是用户真实配置
let auth = json!({"OPENAI_API_KEY": "PROXY_MANAGED"});
let config = r#"model = "gpt-5"
"#;
write_codex_live_atomic(&auth, Some(config)).expect("seed taken-over codex live");
let state = create_test_state().expect("create test state");
import_default_config_test_hook(&state, AppType::Codex)
.expect_err("importing a taken-over live config must fail");
let providers = state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("get codex providers");
assert!(
providers.is_empty(),
"taken-over live import must not create providers"
);
}
+59
View File
@@ -1911,3 +1911,62 @@ fn provider_service_delete_current_provider_returns_error() {
other => panic!("expected Config/Message error, got {other:?}"),
}
}
#[test]
fn recover_from_crash_without_backup_cleans_placeholder_instead_of_writing_it_back() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
// 接管态 Claude Live,且 DB 中无备份(模拟切换 app_config_dir 后新库首启的场景)
let taken_over_live = json!({
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721",
"ANTHROPIC_AUTH_TOKEN": "PROXY_MANAGED"
}
});
let settings_path = get_claude_settings_path();
std::fs::create_dir_all(settings_path.parent().expect("settings dir")).expect("create dir");
std::fs::write(
&settings_path,
serde_json::to_string_pretty(&taken_over_live).expect("serialize taken over live"),
)
.expect("write taken over live");
let state = create_test_state().expect("create test state");
// 模拟历史异常:接管态 Live 已被导入成 current providerSSOT 被污染)
let provider = Provider::with_id(
"default".to_string(),
"default".to_string(),
taken_over_live.clone(),
None,
);
state
.db
.save_provider(AppType::Claude.as_str(), &provider)
.expect("save placeholder provider");
state
.db
.set_current_provider(AppType::Claude.as_str(), "default")
.expect("set current provider");
futures::executor::block_on(state.proxy_service.recover_from_crash())
.expect("recover from crash");
let live_after: serde_json::Value =
read_json_file(&settings_path).expect("read live settings after recovery");
let env = live_after.get("env").cloned().unwrap_or_else(|| json!({}));
assert_ne!(
env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()),
Some("PROXY_MANAGED"),
"recovery must not write the placeholder back to live"
);
assert!(
env.get("ANTHROPIC_BASE_URL")
.and_then(|v| v.as_str())
.map(|url| !url.starts_with("http://127.0.0.1"))
.unwrap_or(true),
"recovery must drop the local proxy base URL"
);
}