mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-29 09:37:37 +08:00
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:
Generated
+1
-1
@@ -735,7 +735,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.16.1"
|
||||
version = "3.16.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"}), "")
|
||||
|
||||
@@ -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 id:config.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 是 openai;state 备份里 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
@@ -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 {
|
||||
// 安装方式探测失败弹交互 prompt(spawn 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
|
||||
/// formula(real 在 `Cellar/`)本应 `brew upgrade codex`,npm 够不到它反而旁路装第二份 npm
|
||||
/// 全局 codex;Volta/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 formula(real 在 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 装的坏 codex(real 在 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");
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 后(如关闭统一会话开关)、前端缓存刷新前的全量保存
|
||||
/// 会携带旧 marker;merge 必须忽略它,否则被"复活"的标记会让重新开启
|
||||
/// 时误判已迁移而漏迁。
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取请求日志列表
|
||||
|
||||
@@ -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_model(gpt-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()?;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 到 v11(usage_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 -> v11:usage_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_model:NULL = 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 -> v11:usage_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 5(Opus 之上的新档)
|
||||
(
|
||||
"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.20:xAI 已降价 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.0:lite 旧价贵 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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 §ions {
|
||||
*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
@@ -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_CODE:app.restart() / 自更新 relaunch 发起的重启。
|
||||
// 这条路径上 prevent_exit() 会被 Tauri 忽略,事件循环必定退出,随后由
|
||||
// Tauri 在 RunEvent::Exit 后用新二进制 re-exec(macOS 会按更新后的
|
||||
// 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
@@ -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` 外的 0x00–0x1F(含换行)与 0x7F(DEL)。
|
||||
///
|
||||
/// 非法值的处理:三条运行时路径**均静默忽略**(`.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。
|
||||
|
||||
@@ -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:上游报图片错误也不触发兜底重试。
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1144
-32
File diff suppressed because it is too large
Load Diff
@@ -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:内置列表不再预测性剥图,避免误判多模态模型时静默丢图。
|
||||
|
||||
@@ -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 参数不应影响模型映射
|
||||
|
||||
@@ -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_options(usage 在非流式响应体里恒有)。
|
||||
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(
|
||||
|
||||
@@ -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,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)=130:saturating 钳到 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!(
|
||||
|
||||
@@ -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_chat(claude.rs)与 Codex Responses→Chat(transform_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_tokens(inclusive 上游)。
|
||||
// 与流式 build_anthropic_usage_json (#2774) 及 transform_gemini 的 saturating_sub 对称。
|
||||
// 最终 cache_read:直传字段优先于 nested;cache_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)=110:saturating 钳到 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) = 9(fresh 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 = 0(fresh)
|
||||
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)=110:saturating 钳到 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!({
|
||||
|
||||
@@ -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_CONFIG(app_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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 usage,from_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!({
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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_hour(5 小时桶在 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 #3036,2026-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 周"计),
|
||||
// 分类只看 unit,number 取值不影响。
|
||||
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,若上游意外增加第三条应被丢弃,避免命名空缺。
|
||||
|
||||
@@ -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}"));
|
||||
|
||||
@@ -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-switch–private 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 provider(SSOT),后续"无备份恢复"路径会把占位符
|
||||
// 当真实配置写回 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 => {
|
||||
|
||||
@@ -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(¤t_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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 成本的 message(Workflow /
|
||||
// 子 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(())
|
||||
}
|
||||
}
|
||||
|
||||
+307
-1890
File diff suppressed because it is too large
Load Diff
@@ -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("a_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
@@ -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,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",
|
||||
|
||||
@@ -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 Live:auth 是 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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 provider(SSOT 被污染)
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user