mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-01 12:22:09 +08:00
Merge remote-tracking branch 'origin/main' into feature/managed-oauth-account-selector
# Conflicts: # src/components/providers/forms/ProviderForm.tsx
This commit is contained in:
Generated
+192
-15
@@ -184,7 +184,7 @@ dependencies = [
|
||||
"futures-lite",
|
||||
"parking",
|
||||
"polling",
|
||||
"rustix",
|
||||
"rustix 1.1.4",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -215,7 +215,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"event-listener",
|
||||
"futures-lite",
|
||||
"rustix",
|
||||
"rustix 1.1.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -241,7 +241,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"rustix",
|
||||
"rustix 1.1.4",
|
||||
"signal-hook-registry",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -421,6 +421,29 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.69.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools",
|
||||
"lazy_static",
|
||||
"lazycell",
|
||||
"log",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"rustc-hash 1.1.0",
|
||||
"shlex",
|
||||
"syn 2.0.117",
|
||||
"which",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
@@ -810,6 +833,15 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||
|
||||
[[package]]
|
||||
name = "cexpr"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
|
||||
dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfb"
|
||||
version = "0.7.3"
|
||||
@@ -867,6 +899,17 @@ dependencies = [
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clang-sys"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
|
||||
dependencies = [
|
||||
"glob",
|
||||
"libc",
|
||||
"libloading 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
@@ -936,6 +979,15 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
@@ -1178,7 +1230,7 @@ version = "0.99.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"convert_case 0.4.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
@@ -1385,6 +1437,12 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.6"
|
||||
@@ -1914,7 +1972,7 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"rustix 1.1.4",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
@@ -2222,6 +2280,15 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "home"
|
||||
version = "0.5.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.29.1"
|
||||
@@ -2616,6 +2683,15 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
@@ -2753,6 +2829,18 @@ dependencies = [
|
||||
"selectors 0.24.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "lazycell"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
@@ -2779,7 +2867,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf"
|
||||
dependencies = [
|
||||
"gtk-sys",
|
||||
"libloading",
|
||||
"libloading 0.7.4",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
@@ -2799,6 +2887,16 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.14"
|
||||
@@ -2822,6 +2920,12 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -2954,6 +3058,12 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.5"
|
||||
@@ -3071,6 +3181,16 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.0"
|
||||
@@ -3839,7 +3959,7 @@ dependencies = [
|
||||
"concurrent-queue",
|
||||
"hermit-abi",
|
||||
"pin-project-lite",
|
||||
"rustix",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -4002,7 +4122,7 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustc-hash 2.1.1",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
@@ -4022,7 +4142,7 @@ dependencies = [
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustc-hash 2.1.1",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
@@ -4278,6 +4398,12 @@ version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "relative-path"
|
||||
version = "1.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
|
||||
|
||||
[[package]]
|
||||
name = "rend"
|
||||
version = "0.4.2"
|
||||
@@ -4447,6 +4573,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d16661bff09e9ed8e01094a188b463de45ec0693ade55b92ed54027d7ba7c40c"
|
||||
dependencies = [
|
||||
"rquickjs-core",
|
||||
"rquickjs-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4455,15 +4582,34 @@ version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c8db6379e204ef84c0811e90e7cc3e3e4d7688701db68a00d14a6db6849087b"
|
||||
dependencies = [
|
||||
"relative-path",
|
||||
"rquickjs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rquickjs-macro"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6041104330c019fcd936026ae05e2446f5e8a2abef329d924f25424b7052a2f3"
|
||||
dependencies = [
|
||||
"convert_case 0.6.0",
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"indexmap 2.13.0",
|
||||
"proc-macro-crate 1.3.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rquickjs-core",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rquickjs-sys"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4bc352c6b663604c3c186c000cfcc6c271f4b50bc135a285dd6d4f2a42f9790a"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cc",
|
||||
]
|
||||
|
||||
@@ -4507,6 +4653,12 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
@@ -4522,6 +4674,19 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "0.38.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.15",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
@@ -4531,7 +4696,7 @@ dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -4775,7 +4940,7 @@ dependencies = [
|
||||
"phf 0.13.1",
|
||||
"phf_codegen 0.13.1",
|
||||
"precomputed-hash",
|
||||
"rustc-hash",
|
||||
"rustc-hash 2.1.1",
|
||||
"servo_arc 0.4.3",
|
||||
"smallvec",
|
||||
]
|
||||
@@ -5814,7 +5979,7 @@ dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -6783,6 +6948,18 @@ version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "which"
|
||||
version = "4.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7"
|
||||
dependencies = [
|
||||
"either",
|
||||
"home",
|
||||
"once_cell",
|
||||
"rustix 0.38.44",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
@@ -7527,7 +7704,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
|
||||
dependencies = [
|
||||
"gethostname",
|
||||
"rustix",
|
||||
"rustix 1.1.4",
|
||||
"x11rb-protocol",
|
||||
]
|
||||
|
||||
@@ -7544,7 +7721,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
"rustix 1.1.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7601,7 +7778,7 @@ dependencies = [
|
||||
"hex",
|
||||
"libc",
|
||||
"ordered-stream",
|
||||
"rustix",
|
||||
"rustix 1.1.4",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tracing",
|
||||
|
||||
@@ -91,6 +91,9 @@ webkit2gtk = { version = "2.0.1", features = ["v2_16"] }
|
||||
winreg = "0.52"
|
||||
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_UI_Shell"] }
|
||||
|
||||
[target.'cfg(all(target_os = "windows", target_arch = "aarch64"))'.dependencies]
|
||||
rquickjs = { version = "0.8", features = ["bindgen"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.5"
|
||||
objc2-app-kit = { version = "0.2", features = ["NSColor"] }
|
||||
|
||||
@@ -1339,8 +1339,10 @@ mod tests {
|
||||
}
|
||||
|
||||
fn set_proxy_port(db: &Database, port: u16) {
|
||||
let mut config = crate::proxy::types::ProxyConfig::default();
|
||||
config.listen_port = port;
|
||||
let config = crate::proxy::types::ProxyConfig {
|
||||
listen_port: port,
|
||||
..Default::default()
|
||||
};
|
||||
futures::executor::block_on(db.update_proxy_config(config)).expect("update proxy config");
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::config::{atomic_write, get_claude_mcp_path, get_default_claude_mcp_path};
|
||||
use crate::config::{atomic_write, get_claude_mcp_path};
|
||||
use crate::error::AppError;
|
||||
|
||||
/// 需要在 Windows 上用 cmd /c 包装的命令
|
||||
@@ -98,51 +98,9 @@ pub struct McpStatus {
|
||||
}
|
||||
|
||||
fn user_config_path() -> PathBuf {
|
||||
ensure_mcp_override_migrated();
|
||||
get_claude_mcp_path()
|
||||
}
|
||||
|
||||
fn ensure_mcp_override_migrated() {
|
||||
if crate::settings::get_claude_override_dir().is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
let new_path = get_claude_mcp_path();
|
||||
if new_path.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let legacy_path = get_default_claude_mcp_path();
|
||||
if !legacy_path.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(parent) = new_path.parent() {
|
||||
if let Err(err) = fs::create_dir_all(parent) {
|
||||
log::warn!("创建 MCP 目录失败: {err}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
match fs::copy(&legacy_path, &new_path) {
|
||||
Ok(_) => {
|
||||
log::info!(
|
||||
"已根据覆盖目录复制 MCP 配置: {} -> {}",
|
||||
legacy_path.display(),
|
||||
new_path.display()
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"复制 MCP 配置失败: {} -> {}: {}",
|
||||
legacy_path.display(),
|
||||
new_path.display(),
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_json_value(path: &Path) -> Result<Value, AppError> {
|
||||
if !path.exists() {
|
||||
return Ok(serde_json::json!({}));
|
||||
|
||||
@@ -67,6 +67,7 @@ const CC_SWITCH_LEGACY_CODEX_MODEL_PROVIDER_IDS: &[&str] = &[
|
||||
"dmxapi",
|
||||
"doubaoseed",
|
||||
"eflowcode",
|
||||
"etok",
|
||||
"kimi",
|
||||
"lemondata",
|
||||
"longcat",
|
||||
@@ -1116,16 +1117,23 @@ fn migrate_codex_state_dbs(
|
||||
}
|
||||
|
||||
fn codex_state_db_paths(codex_dir: &Path, config_text: &str) -> Vec<PathBuf> {
|
||||
let mut paths = vec![codex_dir.join(CODEX_STATE_DB_FILENAME)];
|
||||
let mut paths = Vec::new();
|
||||
push_unique_path(&mut paths, codex_dir.join(CODEX_STATE_DB_FILENAME));
|
||||
// Codex lets SQLite state move away from CODEX_HOME; config takes precedence.
|
||||
if let Some(sqlite_home) = sqlite_home_from_codex_config(config_text) {
|
||||
let db_path = sqlite_home.join(CODEX_STATE_DB_FILENAME);
|
||||
if !paths.contains(&db_path) {
|
||||
paths.push(db_path);
|
||||
}
|
||||
push_unique_path(&mut paths, sqlite_home.join(CODEX_STATE_DB_FILENAME));
|
||||
} else if let Some(sqlite_home) = sqlite_home_from_env() {
|
||||
push_unique_path(&mut paths, sqlite_home.join(CODEX_STATE_DB_FILENAME));
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
fn push_unique_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
|
||||
if !paths.contains(&path) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
fn sqlite_home_from_codex_config(config_text: &str) -> Option<PathBuf> {
|
||||
let doc = config_text.parse::<DocumentMut>().ok()?;
|
||||
let raw = doc.get("sqlite_home")?.as_str()?.trim();
|
||||
@@ -1135,6 +1143,15 @@ fn sqlite_home_from_codex_config(config_text: &str) -> Option<PathBuf> {
|
||||
Some(resolve_user_path(raw))
|
||||
}
|
||||
|
||||
fn sqlite_home_from_env() -> Option<PathBuf> {
|
||||
let raw = std::env::var("CODEX_SQLITE_HOME").ok()?;
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(resolve_user_path(raw))
|
||||
}
|
||||
|
||||
fn resolve_user_path(raw: &str) -> PathBuf {
|
||||
if raw == "~" {
|
||||
return crate::config::get_home_dir();
|
||||
@@ -1313,8 +1330,33 @@ fn relative_backup_path(path: &Path, root: &Path) -> PathBuf {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::provider::Provider;
|
||||
use serial_test::serial;
|
||||
use std::ffi::OsString;
|
||||
use tempfile::tempdir;
|
||||
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
previous: Option<OsString>,
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
fn set(key: &'static str, value: &Path) -> Self {
|
||||
let previous = std::env::var_os(key);
|
||||
std::env::set_var(key, value);
|
||||
Self { key, previous }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(previous) = &self.previous {
|
||||
std::env::set_var(self.key, previous);
|
||||
} else {
|
||||
std::env::remove_var(self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn source_ids(values: &[&str]) -> BTreeSet<String> {
|
||||
values.iter().map(|value| value.to_string()).collect()
|
||||
}
|
||||
@@ -2116,6 +2158,46 @@ base_url = "https://proxy.example/v1"
|
||||
assert_eq!(backed_up_source_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn state_db_paths_include_codex_sqlite_home_env() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let codex_dir = dir.path().join(".codex");
|
||||
let sqlite_home = dir.path().join("sqlite-home");
|
||||
let _guard = EnvVarGuard::set("CODEX_SQLITE_HOME", &sqlite_home);
|
||||
|
||||
let paths = codex_state_db_paths(&codex_dir, "");
|
||||
|
||||
assert_eq!(
|
||||
paths,
|
||||
vec![
|
||||
codex_dir.join(CODEX_STATE_DB_FILENAME),
|
||||
sqlite_home.join(CODEX_STATE_DB_FILENAME),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn config_sqlite_home_takes_precedence_over_codex_sqlite_home_env() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let codex_dir = dir.path().join(".codex");
|
||||
let env_sqlite_home = dir.path().join("env-sqlite-home");
|
||||
let config_sqlite_home = dir.path().join("config-sqlite-home");
|
||||
let _guard = EnvVarGuard::set("CODEX_SQLITE_HOME", &env_sqlite_home);
|
||||
let config_text = format!("sqlite_home = \"{}\"\n", config_sqlite_home.display());
|
||||
|
||||
let paths = codex_state_db_paths(&codex_dir, &config_text);
|
||||
|
||||
assert_eq!(
|
||||
paths,
|
||||
vec![
|
||||
codex_dir.join(CODEX_STATE_DB_FILENAME),
|
||||
config_sqlite_home.join(CODEX_STATE_DB_FILENAME),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_third_party_provider_ids_from_codex_providers() {
|
||||
let db = Database::memory().expect("memory db");
|
||||
|
||||
@@ -4,6 +4,15 @@ use crate::services::subscription::SubscriptionQuota;
|
||||
pub async fn get_coding_plan_quota(
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
// 火山方舟用控制面 AK/SK 签名查询用量;其他供应商不传,沿用 api_key。
|
||||
access_key_id: Option<String>,
|
||||
secret_access_key: Option<String>,
|
||||
) -> Result<SubscriptionQuota, String> {
|
||||
crate::services::coding_plan::get_coding_plan_quota(&base_url, &api_key).await
|
||||
crate::services::coding_plan::get_coding_plan_quota(
|
||||
&base_url,
|
||||
&api_key,
|
||||
access_key_id.as_deref(),
|
||||
secret_access_key.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
+292
-52
@@ -1069,6 +1069,111 @@ fn default_flag_for_shell(shell: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn fallback_user_shell() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"/bin/zsh"
|
||||
} else {
|
||||
"/bin/bash"
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_user_shell_path(shell: &str) -> bool {
|
||||
if shell.is_empty()
|
||||
|| !shell.starts_with('/')
|
||||
|| !is_valid_shell(shell)
|
||||
|| shell.chars().any(char::is_control)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let path = std::path::Path::new(shell);
|
||||
path.is_file() && is_executable_file(path)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn is_executable_file(path: &std::path::Path) -> bool {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
path.metadata()
|
||||
.map(|metadata| metadata.permissions().mode() & 0o111 != 0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn is_executable_file(path: &std::path::Path) -> bool {
|
||||
path.is_file()
|
||||
}
|
||||
|
||||
/// 获取用户默认 shell 的完整路径;异常或被污染的 SHELL 回退到平台默认值。
|
||||
fn get_user_shell() -> String {
|
||||
std::env::var("SHELL")
|
||||
.ok()
|
||||
.filter(|shell| valid_user_shell_path(shell))
|
||||
.unwrap_or_else(|| fallback_user_shell().to_string())
|
||||
}
|
||||
|
||||
/// 构建 exec 行:引号保护 shell 路径,交还用户 shell 让其按默认规则加载 rc 配置。
|
||||
fn build_exec_line(shell: &str, cwd: Option<&Path>) -> String {
|
||||
let quoted_shell = shell_single_quote(shell);
|
||||
|
||||
match shell.rsplit('/').next().unwrap_or(shell) {
|
||||
"zsh" => cwd
|
||||
.map(|dir| {
|
||||
let command = format!(
|
||||
"cd {} || exit 1; exec {} -i",
|
||||
shell_single_quote(&dir.to_string_lossy()),
|
||||
quoted_shell
|
||||
);
|
||||
format!("exec {} -lc {}", quoted_shell, shell_single_quote(&command))
|
||||
})
|
||||
.unwrap_or_else(|| format!("exec {quoted_shell} -l")),
|
||||
_ => format!("exec {quoted_shell}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建 provider 命令行:通过用户 shell 的交互模式执行,确保 GUI 启动的终端也加载用户 PATH。
|
||||
fn build_provider_command_line(shell: &str, config_path: &str, cwd: Option<&Path>) -> String {
|
||||
let claude_command = format!("claude --settings {}", shell_single_quote(config_path));
|
||||
let command = cwd
|
||||
.map(|dir| {
|
||||
format!(
|
||||
"cd {} && {}",
|
||||
shell_single_quote(&dir.to_string_lossy()),
|
||||
claude_command
|
||||
)
|
||||
})
|
||||
.unwrap_or(claude_command);
|
||||
|
||||
format!(
|
||||
"{} {} {}",
|
||||
shell_single_quote(shell),
|
||||
provider_command_flag_for_shell(shell),
|
||||
shell_single_quote(&command)
|
||||
)
|
||||
}
|
||||
|
||||
fn provider_command_flag_for_shell(shell: &str) -> &'static str {
|
||||
match shell.rsplit('/').next().unwrap_or(shell) {
|
||||
"dash" | "sh" => "-c",
|
||||
"zsh" => "-lic",
|
||||
_ => "-ic",
|
||||
}
|
||||
}
|
||||
|
||||
fn build_final_shell_cd_command(shell: &str, cwd: Option<&Path>) -> String {
|
||||
if matches!(shell.rsplit('/').next().unwrap_or(shell), "zsh") {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
cwd.map(|dir| {
|
||||
format!(
|
||||
"cd {} || exit 1\n",
|
||||
shell_single_quote(&dir.to_string_lossy())
|
||||
)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn try_get_version_wsl(
|
||||
tool: &str,
|
||||
@@ -2642,24 +2747,31 @@ fn launch_macos_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> R
|
||||
let preferred = crate::settings::get_preferred_terminal();
|
||||
let terminal = preferred.as_deref().unwrap_or("terminal");
|
||||
|
||||
let shell = get_user_shell();
|
||||
let exec_line = build_exec_line(&shell, cwd);
|
||||
let final_cd_command = build_final_shell_cd_command(&shell, cwd);
|
||||
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
|
||||
let config_path = config_file.to_string_lossy();
|
||||
let cd_command = build_shell_cd_command(cwd);
|
||||
let provider_command = build_provider_command_line(&shell, &config_path, cwd);
|
||||
|
||||
// Write the shell script to a temp file
|
||||
// 脚本使用 POSIX sh 语法确保可移植性,exec 行切换到用户交互式 shell
|
||||
let script_content = format!(
|
||||
r#"#!/bin/bash
|
||||
r#"#!/usr/bin/env sh
|
||||
trap 'rm -f "{config_path}" "{script_file}"' EXIT
|
||||
{cd_command}
|
||||
echo "Using provider-specific claude config:"
|
||||
echo "{config_path}"
|
||||
claude --settings "{config_path}"
|
||||
exec bash --norc --noprofile
|
||||
{provider_command}
|
||||
{final_cd_command}
|
||||
{exec_line}
|
||||
"#,
|
||||
config_path = config_path,
|
||||
script_file = script_file.display(),
|
||||
cd_command = cd_command,
|
||||
provider_command = provider_command,
|
||||
final_cd_command = final_cd_command,
|
||||
exec_line = exec_line,
|
||||
);
|
||||
|
||||
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
|
||||
@@ -2678,7 +2790,7 @@ exec bash --norc --noprofile
|
||||
"ghostty" => launch_macos_ghostty(&script_file),
|
||||
"wezterm" => launch_macos_open_app("WezTerm", &script_file, true),
|
||||
"kaku" => launch_macos_open_app("Kaku", &script_file, true),
|
||||
_ => launch_macos_terminal_app(&script_file), // "terminal" or default
|
||||
_ => launch_macos_terminal_app(&script_file),
|
||||
};
|
||||
|
||||
// If preferred terminal fails and it's not the default, try Terminal.app as fallback
|
||||
@@ -2704,7 +2816,16 @@ fn applescript_string_literal(value: &str) -> String {
|
||||
#[cfg(target_os = "macos")]
|
||||
fn applescript_launcher_command(script_file: &std::path::Path) -> String {
|
||||
applescript_string_literal(&format!(
|
||||
"bash {}",
|
||||
"sh {}",
|
||||
shell_single_quote(&script_file.to_string_lossy())
|
||||
))
|
||||
}
|
||||
|
||||
/// Build a launcher command that replaces the terminal-created shell session.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn applescript_exec_launcher_command(script_file: &std::path::Path) -> String {
|
||||
applescript_string_literal(&format!(
|
||||
"exec sh {}",
|
||||
shell_single_quote(&script_file.to_string_lossy())
|
||||
))
|
||||
}
|
||||
@@ -2727,7 +2848,7 @@ tell application "Terminal"
|
||||
activate
|
||||
end if
|
||||
end tell"#,
|
||||
launcher = applescript_launcher_command(script_file)
|
||||
launcher = applescript_exec_launcher_command(script_file)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2795,7 +2916,7 @@ tell application "iTerm"
|
||||
write text launcher_script
|
||||
end tell
|
||||
end tell"#,
|
||||
launcher = applescript_launcher_command(script_file)
|
||||
launcher = applescript_exec_launcher_command(script_file)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2805,12 +2926,12 @@ fn launch_macos_iterm2(script_file: &std::path::Path) -> Result<(), String> {
|
||||
run_terminal_osascript(&build_macos_iterm2_applescript(script_file), "iTerm2")
|
||||
}
|
||||
|
||||
/// Keep the launcher path inside a `bash -c` string.
|
||||
/// Keep the launcher path inside a `sh -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 {}",
|
||||
"exec sh {}",
|
||||
shell_single_quote(&script_file.to_string_lossy())
|
||||
)
|
||||
}
|
||||
@@ -2865,8 +2986,8 @@ fn launch_macos_open_app(
|
||||
if use_e_flag {
|
||||
cmd.arg("-e");
|
||||
}
|
||||
// Keep the script path inside `bash -c`; a trailing bare `.sh` can be opened as a document.
|
||||
cmd.arg("bash")
|
||||
// Keep the script path inside `sh -c`; a trailing bare `.sh` can be opened as a document.
|
||||
cmd.arg("sh")
|
||||
.arg("-c")
|
||||
.arg(build_macos_dash_c_command(script_file));
|
||||
|
||||
@@ -2912,9 +3033,9 @@ fn launch_macos_warp(script_file: &std::path::Path) -> Result<(), String> {
|
||||
|
||||
rm -- "$0"
|
||||
|
||||
exec bash {}
|
||||
exec sh {quoted_script}
|
||||
"#,
|
||||
script_file.display(),
|
||||
quoted_script = shell_single_quote(&script_file.to_string_lossy()),
|
||||
)
|
||||
.map_err(|e| format!("Failed to write to temporary script file for Warp: {e}"))?;
|
||||
|
||||
@@ -2946,6 +3067,10 @@ fn launch_linux_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> R
|
||||
|
||||
let preferred = crate::settings::get_preferred_terminal();
|
||||
|
||||
let shell = get_user_shell();
|
||||
let exec_line = build_exec_line(&shell, cwd);
|
||||
let final_cd_command = build_final_shell_cd_command(&shell, cwd);
|
||||
|
||||
// Default terminal list with their arguments
|
||||
let default_terminals = [
|
||||
("gnome-terminal", vec!["--"]),
|
||||
@@ -2962,20 +3087,22 @@ fn launch_linux_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> R
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
|
||||
let config_path = config_file.to_string_lossy();
|
||||
let cd_command = build_shell_cd_command(cwd);
|
||||
let provider_command = build_provider_command_line(&shell, &config_path, cwd);
|
||||
|
||||
let script_content = format!(
|
||||
r#"#!/bin/bash
|
||||
r#"#!/usr/bin/env sh
|
||||
trap 'rm -f "{config_path}" "{script_file}"' EXIT
|
||||
{cd_command}
|
||||
echo "Using provider-specific claude config:"
|
||||
echo "{config_path}"
|
||||
claude --settings "{config_path}"
|
||||
exec bash --norc --noprofile
|
||||
{provider_command}
|
||||
{final_cd_command}
|
||||
{exec_line}
|
||||
"#,
|
||||
config_path = config_path,
|
||||
script_file = script_file.display(),
|
||||
cd_command = cd_command,
|
||||
provider_command = provider_command,
|
||||
final_cd_command = final_cd_command,
|
||||
exec_line = exec_line,
|
||||
);
|
||||
|
||||
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
|
||||
@@ -3019,7 +3146,7 @@ exec bash --norc --noprofile
|
||||
if terminal_exists {
|
||||
let result = Command::new(terminal)
|
||||
.args(&args)
|
||||
.arg("bash")
|
||||
.arg("sh")
|
||||
.arg(script_file.to_string_lossy().as_ref())
|
||||
.spawn();
|
||||
|
||||
@@ -3106,16 +3233,6 @@ del \"%~f0\" >nul 2>&1
|
||||
result
|
||||
}
|
||||
|
||||
fn build_shell_cd_command(cwd: Option<&Path>) -> String {
|
||||
cwd.map(|dir| {
|
||||
format!(
|
||||
"cd {} || exit 1\n",
|
||||
shell_single_quote(&dir.to_string_lossy())
|
||||
)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn shell_single_quote(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "'\"'\"'"))
|
||||
}
|
||||
@@ -3182,7 +3299,7 @@ fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), S
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 打开用户首选终端并在其中执行一段可信命令脚本。脚本尾部 `read -n 1` / `pause`
|
||||
/// 打开用户首选终端并在其中执行一段可信命令脚本。脚本尾部 `read -r` / `pause`
|
||||
/// 是刻意设计的——让命令退出后窗口不要瞬间关闭,用户才看得到 `command
|
||||
/// not found` / `ModuleNotFoundError` 这类诊断信息。
|
||||
///
|
||||
@@ -3196,14 +3313,14 @@ pub(crate) fn launch_terminal_running(command_line: &str, label: &str) -> Result
|
||||
let (script_file, script_content) = {
|
||||
let file = temp_dir.join(format!("cc_switch_{}_{}.sh", label, pid));
|
||||
let content = format!(
|
||||
r#"#!/bin/bash
|
||||
r#"#!/usr/bin/env sh
|
||||
trap 'rm -f "{script_path}"' EXIT
|
||||
echo "[cc-switch] Starting: {label}"
|
||||
echo ""
|
||||
{cmd}
|
||||
echo ""
|
||||
echo "[cc-switch] Command exited. Press any key to close."
|
||||
read -n 1 -s
|
||||
echo "[cc-switch] Command exited. Press Enter to close."
|
||||
read -r _
|
||||
"#,
|
||||
script_path = file.display(),
|
||||
label = label,
|
||||
@@ -3299,7 +3416,7 @@ read -n 1 -s
|
||||
if terminal_exists {
|
||||
let spawn_result = Command::new(terminal)
|
||||
.args(&args)
|
||||
.arg("bash")
|
||||
.arg("sh")
|
||||
.arg(script_file.to_string_lossy().as_ref())
|
||||
.spawn();
|
||||
match spawn_result {
|
||||
@@ -3387,6 +3504,126 @@ mod tests {
|
||||
use super::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_test_executable(path: &Path, executable: bool) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let mode = if executable { 0o755 } else { 0o644 };
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
|
||||
.expect("fixture permissions should be set");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_exec_line() {
|
||||
assert_eq!(build_exec_line("/bin/zsh", None), "exec '/bin/zsh' -l");
|
||||
assert_eq!(build_exec_line("/bin/bash", None), "exec '/bin/bash'");
|
||||
assert_eq!(
|
||||
build_exec_line("/opt/homebrew dir/bin/fish", None),
|
||||
"exec '/opt/homebrew dir/bin/fish'"
|
||||
);
|
||||
assert_eq!(build_exec_line("/bin/sh", None), "exec '/bin/sh'");
|
||||
assert_eq!(
|
||||
build_exec_line("/tmp/shell'quote/zsh", None),
|
||||
"exec '/tmp/shell'\"'\"'quote/zsh' -l"
|
||||
);
|
||||
assert_eq!(
|
||||
build_exec_line("/bin/zsh", Some(Path::new("/tmp/project"))),
|
||||
r#"exec '/bin/zsh' -lc 'cd '"'"'/tmp/project'"'"' || exit 1; exec '"'"'/bin/zsh'"'"' -i'"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_provider_command_line_uses_user_shell_environment() {
|
||||
assert_eq!(
|
||||
build_provider_command_line("/bin/zsh", "/tmp/claude config.json", None),
|
||||
"'/bin/zsh' -lic 'claude --settings '\"'\"'/tmp/claude config.json'\"'\"''"
|
||||
);
|
||||
assert_eq!(
|
||||
build_provider_command_line(
|
||||
"/bin/bash",
|
||||
"/tmp/claude config.json",
|
||||
Some(Path::new("/tmp/project"))
|
||||
),
|
||||
r#"'/bin/bash' -ic 'cd '"'"'/tmp/project'"'"' && claude --settings '"'"'/tmp/claude config.json'"'"''"#
|
||||
);
|
||||
assert_eq!(
|
||||
build_provider_command_line(
|
||||
"/bin/sh",
|
||||
"/tmp/claude config.json",
|
||||
Some(Path::new("/tmp/project O'Brien"))
|
||||
),
|
||||
r#"'/bin/sh' -c 'cd '"'"'/tmp/project O'"'"'"'"'"'"'"'"'Brien'"'"' && claude --settings '"'"'/tmp/claude config.json'"'"''"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_final_shell_cd_command() {
|
||||
assert_eq!(build_final_shell_cd_command("/bin/zsh", None), "");
|
||||
assert_eq!(
|
||||
build_final_shell_cd_command("/bin/zsh", Some(Path::new("/tmp/project"))),
|
||||
""
|
||||
);
|
||||
assert_eq!(
|
||||
build_final_shell_cd_command("/bin/bash", Some(Path::new("/tmp/project O'Brien"))),
|
||||
"cd '/tmp/project O'\"'\"'Brien' || exit 1\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_get_user_shell_fallback() {
|
||||
// $SHELL 未设置时应按平台 fallback
|
||||
// 此测试验证 fallback 逻辑,但不验证环境变量值(取决于运行环境)
|
||||
let shell = get_user_shell();
|
||||
// 至少应返回一个合法的绝对路径
|
||||
assert!(valid_user_shell_path(&shell));
|
||||
// basename 应为合法 shell 名
|
||||
let basename = shell.rsplit('/').next().unwrap_or("sh");
|
||||
assert!(["sh", "bash", "zsh", "fish", "dash"].contains(&basename));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_valid_user_shell_path() {
|
||||
let temp = tempfile::tempdir().expect("temp dir should be created");
|
||||
let executable_zsh = temp.path().join("zsh");
|
||||
std::fs::write(&executable_zsh, "#!/usr/bin/env sh\n")
|
||||
.expect("shell fixture should be written");
|
||||
set_test_executable(&executable_zsh, true);
|
||||
|
||||
let executable_fish_dir = temp.path().join("homebrew dir/bin");
|
||||
std::fs::create_dir_all(&executable_fish_dir)
|
||||
.expect("shell fixture directory should be created");
|
||||
let executable_fish = executable_fish_dir.join("fish");
|
||||
std::fs::write(&executable_fish, "#!/usr/bin/env sh\n")
|
||||
.expect("shell fixture should be written");
|
||||
set_test_executable(&executable_fish, true);
|
||||
|
||||
let non_executable_bash = temp.path().join("bash");
|
||||
std::fs::write(&non_executable_bash, "#!/usr/bin/env sh\n")
|
||||
.expect("shell fixture should be written");
|
||||
set_test_executable(&non_executable_bash, false);
|
||||
|
||||
assert!(valid_user_shell_path(&executable_zsh.to_string_lossy()));
|
||||
assert!(valid_user_shell_path(&executable_fish.to_string_lossy()));
|
||||
assert!(!valid_user_shell_path(""));
|
||||
assert!(!valid_user_shell_path("zsh"));
|
||||
assert!(!valid_user_shell_path(
|
||||
&temp.path().join("missing/zsh").to_string_lossy()
|
||||
));
|
||||
assert!(!valid_user_shell_path(
|
||||
&non_executable_bash.to_string_lossy()
|
||||
));
|
||||
assert!(!valid_user_shell_path(
|
||||
&temp.path().join("zsh; rm -rf /").to_string_lossy()
|
||||
));
|
||||
assert!(!valid_user_shell_path(&format!(
|
||||
"{}\n/bin/bash",
|
||||
executable_zsh.to_string_lossy()
|
||||
)));
|
||||
assert!(!valid_user_shell_path("/usr/bin/powershell"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_version() {
|
||||
assert_eq!(extract_version("claude 1.0.20"), "1.0.20");
|
||||
@@ -4850,13 +5087,6 @@ mod tests {
|
||||
assert!(error.contains("目录不存在"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_shell_cd_command_quotes_spaces_and_single_quotes() {
|
||||
let command = build_shell_cd_command(Some(Path::new("/tmp/project O'Brien")));
|
||||
|
||||
assert_eq!(command, "cd '/tmp/project O'\"'\"'Brien' || exit 1\n");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn iterm2_applescript_cold_start_avoids_current_window_before_one_exists() {
|
||||
@@ -4918,6 +5148,10 @@ mod tests {
|
||||
),
|
||||
"already-running branch should use bare do script:\n{script}"
|
||||
);
|
||||
assert!(
|
||||
script.contains(r#"set launcher_script to "exec sh '/tmp/cc_switch_launcher.sh'""#),
|
||||
"Terminal should replace the auto-created shell:\n{script}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Restored windows should not receive the launcher command.
|
||||
@@ -4943,7 +5177,7 @@ mod tests {
|
||||
|
||||
// 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'""#),
|
||||
script.contains(r#"set launcher_command to "sh '/tmp/cc_switch_launcher.sh'""#),
|
||||
"missing launcher_command:\n{script}"
|
||||
);
|
||||
assert!(script.contains("if was_running then"));
|
||||
@@ -4983,11 +5217,11 @@ mod tests {
|
||||
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'");
|
||||
assert_eq!(s, "exec sh '/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'"#);
|
||||
assert_eq!(s2, r#"exec sh '/Users/me/it'"'"'s dir/x.sh'"#);
|
||||
}
|
||||
|
||||
/// AppleScript launchers need both shell-path quoting and AppleScript string quoting.
|
||||
@@ -4995,20 +5229,26 @@ mod tests {
|
||||
#[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 expected = r#""sh '/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_eq!(
|
||||
applescript_exec_launcher_command(p),
|
||||
r#""exec sh '/Users/me/it'\"'\"'s dir/x.sh'""#
|
||||
);
|
||||
assert!(
|
||||
build_macos_terminal_applescript(p).contains(expected),
|
||||
build_macos_terminal_applescript(p)
|
||||
.contains(r#""exec sh '/Users/me/it'\"'\"'s dir/x.sh'""#),
|
||||
"Terminal did not quote safely"
|
||||
);
|
||||
assert!(
|
||||
build_macos_iterm2_applescript(p).contains(expected),
|
||||
build_macos_iterm2_applescript(p)
|
||||
.contains(r#""exec sh '/Users/me/it'\"'\"'s dir/x.sh'""#),
|
||||
"iTerm2 did not quote safely"
|
||||
);
|
||||
assert!(
|
||||
build_macos_ghostty_applescript(p).contains(expected),
|
||||
"Ghostty did not quote safely"
|
||||
"Ghostty did not keep the non-exec launcher"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -514,9 +514,19 @@ async fn query_provider_usage_inner(
|
||||
let (base_url, api_key) =
|
||||
resolve_coding_plan_credentials(&app_type, provider, usage_script);
|
||||
|
||||
let quota = crate::services::coding_plan::get_coding_plan_quota(&base_url, &api_key)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to query coding plan: {e}"))?;
|
||||
// 火山方舟用账号 AK/SK 签名查询用量(存于 usage_script,与推理 api_key 分离);
|
||||
// 其他供应商为 None,service 层沿用 api_key。
|
||||
let access_key_id = usage_script.and_then(|s| s.access_key_id.clone());
|
||||
let secret_access_key = usage_script.and_then(|s| s.secret_access_key.clone());
|
||||
|
||||
let quota = crate::services::coding_plan::get_coding_plan_quota(
|
||||
&base_url,
|
||||
&api_key,
|
||||
access_key_id.as_deref(),
|
||||
secret_access_key.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to query coding plan: {e}"))?;
|
||||
|
||||
// 将 SubscriptionQuota 转换为 UsageResult
|
||||
if !quota.success {
|
||||
@@ -1086,6 +1096,8 @@ mod native_query_credentials_tests {
|
||||
template_type: Some("token_plan".to_string()),
|
||||
auto_query_interval: None,
|
||||
coding_plan_provider: coding_plan_provider.map(str::to_string),
|
||||
access_key_id: None,
|
||||
secret_access_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+159
-26
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
@@ -47,25 +47,118 @@ pub fn get_default_claude_mcp_path() -> PathBuf {
|
||||
get_home_dir().join(".claude.json")
|
||||
}
|
||||
|
||||
fn derive_mcp_path_from_override(dir: &Path) -> Option<PathBuf> {
|
||||
let file_name = dir
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().to_string())?
|
||||
.trim()
|
||||
.to_string();
|
||||
if file_name.is_empty() {
|
||||
return None;
|
||||
fn normalize_path_lexically(path: &Path) -> PathBuf {
|
||||
let mut normalized = PathBuf::new();
|
||||
|
||||
for component in path.components() {
|
||||
match component {
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
if !normalized.pop() {
|
||||
normalized.push(component.as_os_str());
|
||||
}
|
||||
}
|
||||
Component::Normal(part) => normalized.push(part),
|
||||
Component::RootDir | Component::Prefix(_) => normalized.push(component.as_os_str()),
|
||||
}
|
||||
}
|
||||
let parent = dir.parent().unwrap_or_else(|| Path::new(""));
|
||||
Some(parent.join(format!("{file_name}.json")))
|
||||
|
||||
normalized
|
||||
}
|
||||
|
||||
/// 获取 Claude MCP 配置文件路径,若设置了目录覆盖则与覆盖目录同级
|
||||
fn comparable_path_key(path: &Path) -> String {
|
||||
let mut key = normalize_path_lexically(path).to_string_lossy().to_string();
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
key = key.replace('\\', "/");
|
||||
}
|
||||
|
||||
while key.len() > 1 && key.ends_with('/') {
|
||||
key.pop();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
key.make_ascii_lowercase();
|
||||
}
|
||||
|
||||
key
|
||||
}
|
||||
|
||||
fn path_eq_lexical(left: &Path, right: &Path) -> bool {
|
||||
comparable_path_key(left) == comparable_path_key(right)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn derive_wsl_default_mcp_path(dir: &Path) -> Option<PathBuf> {
|
||||
use std::path::Prefix;
|
||||
|
||||
let normalized = normalize_path_lexically(dir);
|
||||
let mut components = normalized.components();
|
||||
let prefix = match components.next()? {
|
||||
Component::Prefix(prefix) => prefix,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let server = match prefix.kind() {
|
||||
Prefix::UNC(server, _) | Prefix::VerbatimUNC(server, _) => server.to_string_lossy(),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
if !server.eq_ignore_ascii_case("wsl$") && !server.eq_ignore_ascii_case("wsl.localhost") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
for component in components {
|
||||
match component {
|
||||
Component::RootDir | Component::CurDir => {}
|
||||
Component::Normal(part) => parts.push(part.to_string_lossy().to_string()),
|
||||
Component::ParentDir | Component::Prefix(_) => return None,
|
||||
}
|
||||
}
|
||||
|
||||
let is_wsl_home_default =
|
||||
parts.len() == 3 && parts[0] == "home" && !parts[1].is_empty() && parts[2] == ".claude";
|
||||
let is_wsl_root_default = parts.len() == 2 && parts[0] == "root" && parts[1] == ".claude";
|
||||
|
||||
if is_wsl_home_default || is_wsl_root_default {
|
||||
return normalized
|
||||
.parent()
|
||||
.map(|parent| parent.join(".claude.json"));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn default_mcp_path_for_config_dir(dir: &Path) -> Option<PathBuf> {
|
||||
let default_config_dir = get_home_dir().join(".claude");
|
||||
if path_eq_lexical(dir, &default_config_dir) {
|
||||
return Some(get_default_claude_mcp_path());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(path) = derive_wsl_default_mcp_path(dir) {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn derive_mcp_path_from_override(dir: &Path) -> PathBuf {
|
||||
dir.join(".claude.json")
|
||||
}
|
||||
|
||||
/// 获取 Claude MCP 配置文件路径
|
||||
pub fn get_claude_mcp_path() -> PathBuf {
|
||||
if let Some(custom_dir) = crate::settings::get_claude_override_dir() {
|
||||
if let Some(path) = derive_mcp_path_from_override(&custom_dir) {
|
||||
if let Some(path) = default_mcp_path_for_config_dir(&custom_dir) {
|
||||
return path;
|
||||
}
|
||||
return derive_mcp_path_from_override(&custom_dir);
|
||||
}
|
||||
get_default_claude_mcp_path()
|
||||
}
|
||||
@@ -263,33 +356,73 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn derive_mcp_path_from_override_preserves_folder_name() {
|
||||
fn derive_mcp_path_from_override_uses_config_dir_for_custom_path() {
|
||||
let override_dir = PathBuf::from("/tmp/profile/.claude");
|
||||
let derived = derive_mcp_path_from_override(&override_dir)
|
||||
.expect("should derive path for nested dir");
|
||||
assert_eq!(derived, PathBuf::from("/tmp/profile/.claude.json"));
|
||||
let derived = derive_mcp_path_from_override(&override_dir);
|
||||
assert_eq!(derived, PathBuf::from("/tmp/profile/.claude/.claude.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_mcp_path_from_override_handles_non_hidden_folder() {
|
||||
fn derive_mcp_path_from_override_uses_config_dir_for_non_hidden_folder() {
|
||||
let override_dir = PathBuf::from("/data/claude-config");
|
||||
let derived = derive_mcp_path_from_override(&override_dir)
|
||||
.expect("should derive path for standard dir");
|
||||
assert_eq!(derived, PathBuf::from("/data/claude-config.json"));
|
||||
let derived = derive_mcp_path_from_override(&override_dir);
|
||||
assert_eq!(derived, PathBuf::from("/data/claude-config/.claude.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_mcp_path_from_override_supports_relative_rootless_dir() {
|
||||
let override_dir = PathBuf::from("claude");
|
||||
let derived = derive_mcp_path_from_override(&override_dir)
|
||||
.expect("should derive path for single segment");
|
||||
assert_eq!(derived, PathBuf::from("claude.json"));
|
||||
let derived = derive_mcp_path_from_override(&override_dir);
|
||||
assert_eq!(derived, PathBuf::from("claude/.claude.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_mcp_path_from_root_like_dir_returns_none() {
|
||||
fn derive_mcp_path_from_root_like_dir_uses_root_file() {
|
||||
let override_dir = PathBuf::from("/");
|
||||
assert!(derive_mcp_path_from_override(&override_dir).is_none());
|
||||
let derived = derive_mcp_path_from_override(&override_dir);
|
||||
assert_eq!(derived, PathBuf::from("/.claude.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_mcp_path_from_override_preserves_leading_parent_dirs() {
|
||||
let override_dir = PathBuf::from("../../profiles/work/.claude");
|
||||
let derived = derive_mcp_path_from_override(&override_dir);
|
||||
assert_eq!(derived, override_dir.join(".claude.json"));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn wsl_unc_home_default_uses_split_mcp_path() {
|
||||
let override_dir = PathBuf::from(r"\\wsl$\Ubuntu\home\travis\.claude");
|
||||
let derived = default_mcp_path_for_config_dir(&override_dir)
|
||||
.expect("WSL home default should use split MCP path");
|
||||
assert_eq!(
|
||||
derived,
|
||||
PathBuf::from(r"\\wsl$\Ubuntu\home\travis\.claude.json")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn wsl_unc_root_default_uses_split_mcp_path() {
|
||||
let override_dir = PathBuf::from(r"\\wsl.localhost\Ubuntu\root\.claude");
|
||||
let derived = default_mcp_path_for_config_dir(&override_dir)
|
||||
.expect("WSL root default should use split MCP path");
|
||||
assert_eq!(
|
||||
derived,
|
||||
PathBuf::from(r"\\wsl.localhost\Ubuntu\root\.claude.json")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn wsl_unc_custom_dir_uses_nested_mcp_path() {
|
||||
let override_dir = PathBuf::from(r"\\wsl$\Ubuntu\opt\claude\.claude");
|
||||
assert!(default_mcp_path_for_config_dir(&override_dir).is_none());
|
||||
assert_eq!(
|
||||
derive_mcp_path_from_override(&override_dir),
|
||||
PathBuf::from(r"\\wsl$\Ubuntu\opt\claude\.claude\.claude.json")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -493,8 +493,10 @@ mod tests {
|
||||
|
||||
{
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let date_str = chrono::DateTime::from_timestamp(old_ts, 0)
|
||||
.unwrap()
|
||||
let date_str = Local
|
||||
.timestamp_opt(old_ts, 0)
|
||||
.single()
|
||||
.expect("old timestamp should be a valid local datetime")
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
conn.execute(
|
||||
|
||||
@@ -1853,6 +1853,7 @@ impl Database {
|
||||
("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"),
|
||||
("glm-5.2", "GLM-5.2", "1.4", "4.4", "0.26", "0"),
|
||||
// MiMo (小米)
|
||||
(
|
||||
"mimo-v2-flash",
|
||||
|
||||
@@ -235,6 +235,8 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
|
||||
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
|
||||
auto_query_interval: request.usage_auto_interval,
|
||||
coding_plan_provider: None,
|
||||
access_key_id: None,
|
||||
secret_access_key: None,
|
||||
};
|
||||
|
||||
Ok(Some(ProviderMeta {
|
||||
|
||||
@@ -251,6 +251,14 @@ pub struct UsageScript {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "codingPlanProvider")]
|
||||
pub coding_plan_provider: Option<String>,
|
||||
/// 火山方舟控制面 OpenAPI 的 AccessKey ID(用量查询签名用,与推理 Key 是两套凭据)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "accessKeyId")]
|
||||
pub access_key_id: Option<String>,
|
||||
/// 火山方舟控制面 OpenAPI 的 SecretAccessKey(同上)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "secretAccessKey")]
|
||||
pub secret_access_key: Option<String>,
|
||||
}
|
||||
|
||||
/// 用量数据
|
||||
|
||||
@@ -1350,7 +1350,7 @@ impl RequestForwarder {
|
||||
.await;
|
||||
if restored > 0 {
|
||||
log::debug!(
|
||||
"[Codex] Restored {restored} cached function call(s) for Chat upstream"
|
||||
"[Codex] Restored or enriched {restored} cached function call item(s) for Chat upstream"
|
||||
);
|
||||
}
|
||||
super::providers::apply_codex_chat_upstream_model(provider, &mut mapped_body);
|
||||
|
||||
@@ -145,6 +145,77 @@ pub fn normalize_anthropic_tool_thinking_history_for_provider(
|
||||
normalize_anthropic_tool_thinking_history(body)
|
||||
}
|
||||
|
||||
/// DeepSeek official Anthropic-compatible endpoint URL
|
||||
const DEEPSEEK_OFFICIAL_ANTHROPIC_URL: &str = "https://api.deepseek.com/anthropic";
|
||||
|
||||
/// Check whether the provider is configured to use DeepSeek's official
|
||||
/// Anthropic-compatible endpoint.
|
||||
fn is_deepseek_official_anthropic_endpoint(provider: &Provider) -> bool {
|
||||
let settings = &provider.settings_config;
|
||||
let base_url = settings
|
||||
.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_BASE_URL"))
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| settings.get("base_url").and_then(|v| v.as_str()))
|
||||
.or_else(|| settings.get("baseURL").and_then(|v| v.as_str()))
|
||||
.or_else(|| settings.get("apiEndpoint").and_then(|v| v.as_str()));
|
||||
|
||||
base_url.map(|u| u.trim_end_matches('/')) == Some(DEEPSEEK_OFFICIAL_ANTHROPIC_URL)
|
||||
}
|
||||
|
||||
/// DeepSeek's official Anthropic-compatible endpoint treats
|
||||
/// `thinking: { type: "disabled" }` and effort parameters (`output_config.effort`
|
||||
/// or `reasoning_effort`) as mutually exclusive, returning HTTP 400:
|
||||
/// "thinking options type cannot be disabled when reasoning_effort is set".
|
||||
/// This breaks Claude Code 2.1.166+ Workflow/Dynamic Workflow features.
|
||||
///
|
||||
/// Rather than overriding Claude Code's intentional `thinking: disabled` for
|
||||
/// sub-agents, we respect that decision and remove the conflicting effort
|
||||
/// parameters instead. `thinking: disabled` means "don't output thinking
|
||||
/// blocks", which is the correct behavior for sub-agents that don't need
|
||||
/// to display reasoning to the user.
|
||||
///
|
||||
/// <https://github.com/deepseek-ai/DeepSeek-V3/issues/1397>
|
||||
pub fn normalize_deepseek_thinking_disabled_strip_effort(
|
||||
body: &mut Value,
|
||||
provider: &Provider,
|
||||
) -> bool {
|
||||
if !is_deepseek_official_anthropic_endpoint(provider) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let thinking_type = body
|
||||
.get("thinking")
|
||||
.and_then(|t| t.get("type"))
|
||||
.and_then(|t| t.as_str());
|
||||
|
||||
if thinking_type != Some("disabled") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut changed = false;
|
||||
|
||||
// Remove output_config.effort (Anthropic format)
|
||||
if let Some(oc) = body
|
||||
.get_mut("output_config")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
changed |= oc.remove("effort").is_some();
|
||||
// Clean up empty output_config
|
||||
if oc.is_empty() {
|
||||
body.as_object_mut().unwrap().remove("output_config");
|
||||
}
|
||||
}
|
||||
|
||||
// Remove reasoning_effort (OpenAI format, may be present in passthrough)
|
||||
if body.get("reasoning_effort").is_some() {
|
||||
body.as_object_mut().unwrap().remove("reasoning_effort");
|
||||
changed = true;
|
||||
}
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
pub fn normalize_anthropic_messages_for_provider(
|
||||
body: &mut Value,
|
||||
provider: &Provider,
|
||||
@@ -154,77 +225,12 @@ pub fn normalize_anthropic_messages_for_provider(
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut changed = normalize_anthropic_system_role_messages(body);
|
||||
changed |= normalize_anthropic_tool_thinking_history_for_provider(body, provider, api_format);
|
||||
let mut changed =
|
||||
normalize_anthropic_tool_thinking_history_for_provider(body, provider, api_format);
|
||||
changed |= normalize_deepseek_thinking_disabled_strip_effort(body, provider);
|
||||
changed
|
||||
}
|
||||
|
||||
fn normalize_anthropic_system_role_messages(body: &mut Value) -> bool {
|
||||
let mut system_parts = Vec::new();
|
||||
let changed = {
|
||||
let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let original_len = messages.len();
|
||||
let mut kept_messages = Vec::with_capacity(messages.len());
|
||||
for message in std::mem::take(messages) {
|
||||
if message.get("role").and_then(Value::as_str) == Some("system") {
|
||||
if let Some(content) = message.get("content") {
|
||||
append_anthropic_system_parts(content, &mut system_parts);
|
||||
}
|
||||
} else {
|
||||
kept_messages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
let changed = kept_messages.len() != original_len;
|
||||
*messages = kept_messages;
|
||||
changed
|
||||
};
|
||||
|
||||
if !changed || system_parts.is_empty() {
|
||||
return changed;
|
||||
}
|
||||
|
||||
let mut merged_parts = Vec::new();
|
||||
if let Some(existing) = body.get("system") {
|
||||
append_anthropic_system_parts(existing, &mut merged_parts);
|
||||
}
|
||||
merged_parts.extend(system_parts);
|
||||
|
||||
if !merged_parts.is_empty() {
|
||||
body["system"] = Value::Array(merged_parts);
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn append_anthropic_system_parts(content: &Value, parts: &mut Vec<Value>) {
|
||||
match content {
|
||||
Value::String(text) if !text.trim().is_empty() => {
|
||||
parts.push(json!({
|
||||
"type": "text",
|
||||
"text": text
|
||||
}));
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
append_anthropic_system_parts(item, parts);
|
||||
}
|
||||
}
|
||||
Value::Object(obj)
|
||||
if obj
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|text| !text.trim().is_empty()) =>
|
||||
{
|
||||
parts.push(Value::Object(obj.clone()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_anthropic_tool_thinking_history(body: &mut Value) -> bool {
|
||||
let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else {
|
||||
return false;
|
||||
@@ -2117,7 +2123,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_system_role_messages_move_to_top_level_system() {
|
||||
fn test_anthropic_messages_no_longer_hoists_system_role_messages() {
|
||||
// After reverting #3775, role=system messages are left in `messages[]`
|
||||
// (DeepSeek's endpoint accepts them natively) and the top-level `system`
|
||||
// field is untouched, preserving the request prefix.
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
|
||||
@@ -2139,15 +2148,13 @@ mod tests {
|
||||
|
||||
let changed = normalize_anthropic_messages_for_provider(&mut body, &provider, "anthropic");
|
||||
|
||||
assert!(changed);
|
||||
assert!(!changed);
|
||||
let messages = body["messages"].as_array().unwrap();
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0]["role"], "user");
|
||||
|
||||
let system = body["system"].as_array().unwrap();
|
||||
assert_eq!(system[0]["text"], "Existing top-level system.");
|
||||
assert_eq!(system[1]["text"], "Message system one.");
|
||||
assert_eq!(system[2]["text"], "Message system two.");
|
||||
assert_eq!(messages.len(), 3);
|
||||
assert_eq!(messages[0]["role"], "system");
|
||||
assert_eq!(messages[1]["role"], "user");
|
||||
assert_eq!(messages[2]["role"], "system");
|
||||
assert_eq!(body["system"], "Existing top-level system.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2300,4 +2307,245 @@ mod tests {
|
||||
assert!(!changed);
|
||||
assert_eq!(body, original);
|
||||
}
|
||||
|
||||
// ==================== normalize_deepseek_thinking_disabled_strip_effort 测试 ====================
|
||||
|
||||
fn deepseek_official_provider() -> Provider {
|
||||
create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
|
||||
"ANTHROPIC_API_KEY": "test-key"
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deepseek_official_strips_output_config_effort() {
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"thinking": { "type": "disabled" },
|
||||
"output_config": { "effort": "max" },
|
||||
"max_tokens": 100000
|
||||
});
|
||||
|
||||
let changed = normalize_deepseek_thinking_disabled_strip_effort(
|
||||
&mut body,
|
||||
&deepseek_official_provider(),
|
||||
);
|
||||
|
||||
assert!(changed);
|
||||
assert_eq!(body["thinking"]["type"], "disabled");
|
||||
assert!(body.get("output_config").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deepseek_official_strips_reasoning_effort() {
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"thinking": { "type": "disabled" },
|
||||
"reasoning_effort": "high",
|
||||
"max_tokens": 100000
|
||||
});
|
||||
|
||||
let changed = normalize_deepseek_thinking_disabled_strip_effort(
|
||||
&mut body,
|
||||
&deepseek_official_provider(),
|
||||
);
|
||||
|
||||
assert!(changed);
|
||||
assert_eq!(body["thinking"]["type"], "disabled");
|
||||
assert!(body.get("reasoning_effort").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deepseek_official_strips_both_effort_fields() {
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"thinking": { "type": "disabled" },
|
||||
"output_config": { "effort": "max" },
|
||||
"reasoning_effort": "high",
|
||||
"max_tokens": 100000
|
||||
});
|
||||
|
||||
let changed = normalize_deepseek_thinking_disabled_strip_effort(
|
||||
&mut body,
|
||||
&deepseek_official_provider(),
|
||||
);
|
||||
|
||||
assert!(changed);
|
||||
assert_eq!(body["thinking"]["type"], "disabled");
|
||||
assert!(body.get("output_config").is_none());
|
||||
assert!(body.get("reasoning_effort").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deepseek_official_no_effort_no_change() {
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"thinking": { "type": "disabled" },
|
||||
"max_tokens": 100000
|
||||
});
|
||||
let original = body.clone();
|
||||
|
||||
let changed = normalize_deepseek_thinking_disabled_strip_effort(
|
||||
&mut body,
|
||||
&deepseek_official_provider(),
|
||||
);
|
||||
|
||||
assert!(!changed);
|
||||
assert_eq!(body, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deepseek_official_preserves_output_config_other_fields() {
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"thinking": { "type": "disabled" },
|
||||
"output_config": { "effort": "max", "temperature": 0.5 },
|
||||
"max_tokens": 100000
|
||||
});
|
||||
|
||||
let changed = normalize_deepseek_thinking_disabled_strip_effort(
|
||||
&mut body,
|
||||
&deepseek_official_provider(),
|
||||
);
|
||||
|
||||
assert!(changed);
|
||||
assert_eq!(body["output_config"]["temperature"], 0.5);
|
||||
assert!(body["output_config"].get("effort").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deepseek_official_non_disabled_not_modified() {
|
||||
let cases = vec![
|
||||
(
|
||||
"enabled",
|
||||
json!({ "type": "enabled", "budget_tokens": 16000 }),
|
||||
),
|
||||
("adaptive", json!({ "type": "adaptive" })),
|
||||
];
|
||||
|
||||
for (label, thinking_value) in cases {
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"thinking": thinking_value,
|
||||
"output_config": { "effort": "max" },
|
||||
"max_tokens": 100000
|
||||
});
|
||||
let original = body.clone();
|
||||
|
||||
let changed = normalize_deepseek_thinking_disabled_strip_effort(
|
||||
&mut body,
|
||||
&deepseek_official_provider(),
|
||||
);
|
||||
|
||||
assert!(!changed, "should not modify thinking.type={label}");
|
||||
assert_eq!(body, original);
|
||||
}
|
||||
|
||||
// missing thinking field entirely
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"output_config": { "effort": "max" },
|
||||
"max_tokens": 100000
|
||||
});
|
||||
let original = body.clone();
|
||||
assert!(!normalize_deepseek_thinking_disabled_strip_effort(
|
||||
&mut body,
|
||||
&deepseek_official_provider()
|
||||
));
|
||||
assert_eq!(body, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deepseek_official_url_with_trailing_slash() {
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic/",
|
||||
"ANTHROPIC_API_KEY": "test-key"
|
||||
}
|
||||
}));
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"thinking": { "type": "disabled" },
|
||||
"output_config": { "effort": "max" },
|
||||
"max_tokens": 100000
|
||||
});
|
||||
|
||||
let changed = normalize_deepseek_thinking_disabled_strip_effort(&mut body, &provider);
|
||||
|
||||
assert!(changed);
|
||||
assert!(body.get("output_config").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deepseek_official_detected_via_base_url_fallback() {
|
||||
let provider = create_provider(json!({
|
||||
"base_url": "https://api.deepseek.com/anthropic",
|
||||
"ANTHROPIC_API_KEY": "test-key"
|
||||
}));
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"thinking": { "type": "disabled" },
|
||||
"reasoning_effort": "high",
|
||||
"max_tokens": 100000
|
||||
});
|
||||
|
||||
let changed = normalize_deepseek_thinking_disabled_strip_effort(&mut body, &provider);
|
||||
|
||||
assert!(changed);
|
||||
assert!(body.get("reasoning_effort").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_deepseek_endpoint_not_modified() {
|
||||
let providers = vec![
|
||||
create_provider(json!({
|
||||
"env": { "ANTHROPIC_BASE_URL": "https://other-api.com/anthropic", "ANTHROPIC_API_KEY": "test-key" }
|
||||
})),
|
||||
create_provider(json!({
|
||||
"env": { "ANTHROPIC_BASE_URL": "https://api.anthropic.com", "ANTHROPIC_API_KEY": "test-key" }
|
||||
})),
|
||||
];
|
||||
|
||||
for provider in providers {
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"thinking": { "type": "disabled" },
|
||||
"output_config": { "effort": "max" },
|
||||
"max_tokens": 100000
|
||||
});
|
||||
let original = body.clone();
|
||||
|
||||
let changed = normalize_deepseek_thinking_disabled_strip_effort(&mut body, &provider);
|
||||
|
||||
assert!(
|
||||
!changed,
|
||||
"should not modify for {}",
|
||||
provider.settings_config["env"]["ANTHROPIC_BASE_URL"]
|
||||
);
|
||||
assert_eq!(body, original);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_messages_pipeline_strips_effort_for_deepseek() {
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"thinking": { "type": "disabled" },
|
||||
"output_config": { "effort": "max" },
|
||||
"max_tokens": 100000,
|
||||
"messages": [{ "role": "user", "content": "hello" }]
|
||||
});
|
||||
|
||||
let changed = normalize_anthropic_messages_for_provider(
|
||||
&mut body,
|
||||
&deepseek_official_provider(),
|
||||
"anthropic",
|
||||
);
|
||||
|
||||
assert!(changed);
|
||||
assert_eq!(body["thinking"]["type"], "disabled");
|
||||
assert!(body.get("output_config").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ impl CodexChatHistoryStore {
|
||||
Some(item_type) if is_call_item_type(item_type) => {
|
||||
if let Some(call_id) = response_item_call_id(&item) {
|
||||
if let Some(cached) = lookup.call(&call_id) {
|
||||
if enrich_call_item_reasoning(&mut item, cached) {
|
||||
if enrich_call_item_from_cache(&mut item, cached) {
|
||||
enriched += 1;
|
||||
}
|
||||
}
|
||||
@@ -466,17 +466,26 @@ fn is_call_output_item_type(item_type: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn enrich_call_item_reasoning(item: &mut Value, cached: &Value) -> bool {
|
||||
fn enrich_call_item_from_cache(item: &mut Value, cached: &Value) -> bool {
|
||||
let mut changed = false;
|
||||
for key in ["reasoning_content", "reasoning"] {
|
||||
for key in [
|
||||
"name",
|
||||
"namespace",
|
||||
"arguments",
|
||||
"input",
|
||||
"status",
|
||||
"execution",
|
||||
"reasoning_content",
|
||||
"reasoning",
|
||||
] {
|
||||
if item.get(key).is_some_and(|value| !is_empty_value(value)) {
|
||||
continue;
|
||||
}
|
||||
let Some(reasoning) = cached.get(key).filter(|value| !is_empty_value(value)) else {
|
||||
let Some(value) = cached.get(key).filter(|value| !is_empty_value(value)) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(object) = item.as_object_mut() {
|
||||
object.insert(key.to_string(), reasoning.clone());
|
||||
object.insert(key.to_string(), value.clone());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
@@ -675,6 +684,48 @@ mod tests {
|
||||
assert_eq!(input.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enriches_existing_function_call_missing_name_and_arguments() {
|
||||
let history = CodexChatHistoryStore::default();
|
||||
history
|
||||
.record_response(&json!({
|
||||
"id": "resp_1",
|
||||
"output": [
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "call_1",
|
||||
"name": "read_file",
|
||||
"arguments": "{\"path\":\"README.md\"}",
|
||||
"reasoning_content": "Need to inspect the file."
|
||||
}
|
||||
]
|
||||
}))
|
||||
.await;
|
||||
|
||||
let mut request = json!({
|
||||
"previous_response_id": "resp_1",
|
||||
"input": [
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "call_1"
|
||||
},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_1",
|
||||
"output": "ok"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
assert_eq!(history.enrich_request(&mut request).await, 1);
|
||||
let input = request["input"].as_array().unwrap();
|
||||
assert_eq!(input[0]["type"], "function_call");
|
||||
assert_eq!(input[0]["name"], "read_file");
|
||||
assert_eq!(input[0]["arguments"], "{\"path\":\"README.md\"}");
|
||||
assert_eq!(input[0]["reasoning_content"], "Need to inspect the file.");
|
||||
assert_eq!(input[1]["type"], "function_call_output");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restores_parallel_tool_calls_as_one_assistant_group() {
|
||||
let history = CodexChatHistoryStore::default();
|
||||
|
||||
@@ -429,8 +429,10 @@ impl ChatToResponsesState {
|
||||
if let Some(id) = id_delta {
|
||||
state.call_id = id;
|
||||
}
|
||||
if let Some(name) = name_delta {
|
||||
state.name = name;
|
||||
if let Some(ref name) = name_delta {
|
||||
if !name.is_empty() {
|
||||
state.name.clone_from(name);
|
||||
}
|
||||
}
|
||||
if !args_delta.is_empty() {
|
||||
state.arguments.push_str(&args_delta);
|
||||
@@ -442,7 +444,7 @@ impl ChatToResponsesState {
|
||||
}
|
||||
}
|
||||
|
||||
if !state.added && (!state.call_id.is_empty() || !state.name.is_empty()) {
|
||||
if !state.added && !state.call_id.is_empty() && !state.name.is_empty() {
|
||||
should_add = true;
|
||||
pending_arguments = state.arguments.clone();
|
||||
} else if state.added {
|
||||
@@ -464,9 +466,6 @@ impl ChatToResponsesState {
|
||||
if state.call_id.is_empty() {
|
||||
state.call_id = format!("call_{chat_index}");
|
||||
}
|
||||
if state.name.is_empty() {
|
||||
state.name = "unknown_tool".to_string();
|
||||
}
|
||||
state.output_index = Some(assigned);
|
||||
let is_custom_tool = self.tool_context.is_custom_tool_chat_name(&state.name);
|
||||
state.item_id = response_tool_call_item_id_from_chat_name(
|
||||
@@ -699,6 +698,21 @@ impl ChatToResponsesState {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip tool calls with missing names (defensive: some models generate
|
||||
// tool call deltas without providing a valid function name)
|
||||
let has_bad_name = self
|
||||
.tools
|
||||
.get(&key)
|
||||
.map(|state| state.name.is_empty())
|
||||
.unwrap_or(true);
|
||||
if has_bad_name {
|
||||
if let Some(state) = self.tools.get_mut(&key) {
|
||||
state.done = true;
|
||||
}
|
||||
log::warn!("[Codex] Skipping streaming tool call with missing name");
|
||||
continue;
|
||||
}
|
||||
|
||||
if self
|
||||
.tools
|
||||
.get(&key)
|
||||
@@ -713,9 +727,6 @@ impl ChatToResponsesState {
|
||||
if state.call_id.is_empty() {
|
||||
state.call_id = format!("call_{key}");
|
||||
}
|
||||
if state.name.is_empty() {
|
||||
state.name = "unknown_tool".to_string();
|
||||
}
|
||||
state.output_index = Some(assigned);
|
||||
state.item_id = response_tool_call_item_id_from_chat_name(
|
||||
&state.call_id,
|
||||
|
||||
@@ -1398,6 +1398,14 @@ fn chat_tool_calls_to_response_output_items(
|
||||
|
||||
if let Some(tool_calls) = message.get("tool_calls").and_then(|v| v.as_array()) {
|
||||
for (index, tool_call) in tool_calls.iter().enumerate() {
|
||||
// Skip tool calls with missing function names (defensive: some models
|
||||
// may generate tool calls without providing a valid name)
|
||||
let function = tool_call.get("function").unwrap_or(&Value::Null);
|
||||
let name = function.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if name.is_empty() {
|
||||
log::warn!("[Codex] Skipping tool call with missing name");
|
||||
continue;
|
||||
}
|
||||
output.push(chat_tool_call_to_response_item(
|
||||
tool_call,
|
||||
index,
|
||||
@@ -1406,11 +1414,11 @@ fn chat_tool_calls_to_response_output_items(
|
||||
));
|
||||
}
|
||||
} else if let Some(function_call) = message.get("function_call") {
|
||||
output.push(chat_legacy_function_call_to_response_item(
|
||||
function_call,
|
||||
reasoning,
|
||||
tool_context,
|
||||
));
|
||||
if let Some(item) =
|
||||
chat_legacy_function_call_to_response_item(function_call, reasoning, tool_context)
|
||||
{
|
||||
output.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
@@ -1448,7 +1456,7 @@ fn chat_legacy_function_call_to_response_item(
|
||||
function_call: &Value,
|
||||
reasoning: Option<&str>,
|
||||
tool_context: &CodexToolContext,
|
||||
) -> Value {
|
||||
) -> Option<Value> {
|
||||
let call_id = function_call
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -1458,10 +1466,18 @@ fn chat_legacy_function_call_to_response_item(
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// Skip legacy function calls with missing names (defensive: some models
|
||||
// may generate function_call without providing a valid name)
|
||||
if name.is_empty() {
|
||||
log::warn!("[Codex] Skipping legacy function_call with missing name");
|
||||
return None;
|
||||
}
|
||||
|
||||
let arguments = canonicalize_tool_arguments(function_call.get("arguments"));
|
||||
|
||||
let item_id = response_tool_call_item_id_from_chat_name(call_id, name, tool_context);
|
||||
response_tool_call_item_from_chat_name(
|
||||
Some(response_tool_call_item_from_chat_name(
|
||||
&item_id,
|
||||
"completed",
|
||||
call_id,
|
||||
@@ -1469,7 +1485,7 @@ fn chat_legacy_function_call_to_response_item(
|
||||
&arguments,
|
||||
reasoning,
|
||||
tool_context,
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn response_tool_call_item_id_from_chat_name(
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! 复用 subscription 模块的 SubscriptionQuota / QuotaTier 类型。
|
||||
|
||||
use super::subscription::{
|
||||
CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_WEEKLY_LIMIT,
|
||||
CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_MONTHLY, TIER_WEEKLY_LIMIT,
|
||||
};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -17,6 +17,9 @@ enum CodingPlanProvider {
|
||||
MiniMaxCn,
|
||||
MiniMaxEn,
|
||||
ZenMux,
|
||||
/// 火山方舟 Agent Plan / Coding Plan(base_url 形如
|
||||
/// `https://ark.cn-beijing.volces.com/api/coding[/v3]`)。
|
||||
Volcengine,
|
||||
}
|
||||
|
||||
fn detect_provider(base_url: &str) -> Option<CodingPlanProvider> {
|
||||
@@ -33,6 +36,10 @@ fn detect_provider(base_url: &str) -> Option<CodingPlanProvider> {
|
||||
Some(CodingPlanProvider::MiniMaxEn)
|
||||
} else if url.contains("zenmux") {
|
||||
Some(CodingPlanProvider::ZenMux)
|
||||
} else if url.contains("volces.com/api/coding") {
|
||||
// 仅匹配 Coding/Agent Plan 入口;DouBaoSeed 按量付费走 /api/v3 与
|
||||
// /api/compatible,没有套餐额度,不在此命中。
|
||||
Some(CodingPlanProvider::Volcengine)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -59,6 +66,10 @@ fn extract_reset_time(value: &serde_json::Value) -> Option<String> {
|
||||
return Some(s.to_string());
|
||||
}
|
||||
if let Some(n) = value.as_i64() {
|
||||
// 0/负时间戳(如火山 session 无活跃窗口回 -1)视为无重置时间
|
||||
if n <= 0 {
|
||||
return None;
|
||||
}
|
||||
// 区分秒和毫秒:秒级时间戳 < 1e12,毫秒 >= 1e12
|
||||
let ms = if n < 1_000_000_000_000 { n * 1000 } else { n };
|
||||
return millis_to_iso8601(ms);
|
||||
@@ -652,43 +663,517 @@ fn parse_minimax_tiers(body: &serde_json::Value) -> Vec<QuotaTier> {
|
||||
tiers
|
||||
}
|
||||
|
||||
// ── 火山方舟 Agent Plan / Coding Plan ───────────────────────
|
||||
//
|
||||
// 与 Kimi/MiniMax(数据面 Bearer 余额接口)不同,火山用量接口是**控制面
|
||||
// OpenAPI**:统一网关 `open.volcengineapi.com`(**不是**数据面推理域名
|
||||
// `ark.cn-beijing.volces.com`),形如
|
||||
// `POST https://open.volcengineapi.com/?Action=...&Version=2024-01-01&Region=cn-beijing`,
|
||||
// **强制火山引擎签名 V4(AK/SK)**——实测复用推理 Bearer Key 会被网关以
|
||||
// `400 InvalidAuthorization` 拒绝(格式层拒绝,非权限问题)。因此用户需在用量查询
|
||||
// 里另填火山账号的 AccessKey ID + Secret(与推理 Key 是两套凭据)。两个 plan 用
|
||||
// 同一份 AK/SK,故鉴权类错误直接停、不再试另一个 plan。
|
||||
//
|
||||
// 自动探测:先调 `GetAFPUsage`(Agent Plan,回绝对额度 Quota/Used),未订阅再调
|
||||
// `GetCodingPlanUsage`(Coding Plan,回百分比)。
|
||||
|
||||
/// 控制面 OpenAPI 统一网关(区别于数据面推理域名 ark.cn-beijing.volces.com)。
|
||||
const VOLCENGINE_OPENAPI_HOST: &str = "open.volcengineapi.com";
|
||||
const VOLCENGINE_API_VERSION: &str = "2024-01-01";
|
||||
/// ark 控制面 OpenAPI 的默认 Region(Agent/Coding Plan 目前在 cn-beijing)。
|
||||
const VOLCENGINE_DEFAULT_REGION: &str = "cn-beijing";
|
||||
|
||||
/// 单次 OpenAPI 调用的归类结果。
|
||||
enum VolcCall {
|
||||
/// 2xx 且 JSON 可解析、无 OpenAPI 级错误(业务 Result 仍可能为空=未订阅)。
|
||||
Body(serde_json::Value),
|
||||
/// 硬鉴权失败(HTTP 401/403 或 AccessDenied/Signature 等错误码)——两个 plan
|
||||
/// 共用凭据,命中即停。
|
||||
Auth(String),
|
||||
/// 网络 / 非鉴权 HTTP 错误 / 解析失败——记录后可继续尝试另一个 plan。
|
||||
Soft(String),
|
||||
}
|
||||
|
||||
/// 从数据面 base_url 提取控制面 OpenAPI 所需的 Region(如
|
||||
/// `ark.cn-beijing.volces.com` → `cn-beijing`);无法识别时回落 cn-beijing。
|
||||
/// 控制面 Host 是固定网关(`VOLCENGINE_OPENAPI_HOST`),不随 base_url 变化。
|
||||
fn volcengine_region(base_url: &str) -> String {
|
||||
let host = base_url
|
||||
.split_once("://")
|
||||
.map(|(_, rest)| rest)
|
||||
.unwrap_or(base_url)
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
host.split('.')
|
||||
.find(|p| p.starts_with("cn-") || p.starts_with("ap-"))
|
||||
.map(|p| p.to_string())
|
||||
.unwrap_or_else(|| VOLCENGINE_DEFAULT_REGION.to_string())
|
||||
}
|
||||
|
||||
/// 判断 OpenAPI 错误码是否属于鉴权类(需要硬停并提示换 AK/SK)。
|
||||
fn volcengine_is_auth_error_code(code: &str) -> bool {
|
||||
let c = code.to_lowercase();
|
||||
c.contains("auth")
|
||||
|| c.contains("signature")
|
||||
|| c.contains("accessdenied")
|
||||
|| c.contains("denied")
|
||||
|| c.contains("unauthorized")
|
||||
|| c.contains("forbidden")
|
||||
|| c.contains("credential")
|
||||
|| c.contains("token")
|
||||
}
|
||||
|
||||
/// 提取火山 OpenAPI 响应里的 `ResponseMetadata.Error`(或顶层 `Error`)。
|
||||
fn volcengine_response_error(body: &serde_json::Value) -> Option<(String, String)> {
|
||||
let err = body
|
||||
.get("ResponseMetadata")
|
||||
.and_then(|m| m.get("Error"))
|
||||
.or_else(|| body.get("Error"))?;
|
||||
let code = err
|
||||
.get("Code")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let msg = err
|
||||
.get("Message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if code.is_empty() && msg.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((code, msg))
|
||||
}
|
||||
}
|
||||
|
||||
/// 鉴权失败时的引导文案,附加在错误后。
|
||||
const VOLCENGINE_AKSK_HINT: &str =
|
||||
"Check the AccessKey ID / Secret are correct and the account has Ark usage-query (OpenAPI) permission.";
|
||||
|
||||
// ── 火山引擎签名 V4(AK/SK)─────────────────────────────────
|
||||
//
|
||||
// 算法是 AWS SigV4 的火山变体(对照官方 volc-openapi-demos/signature/java/Sign.java)。
|
||||
// **两处致命差异,照搬 s3.rs 的标准 SigV4 会签名失败**:
|
||||
// 1. canonical headers 与 SignedHeaders 用**固定顺序**
|
||||
// `host;x-date;x-content-sha256;content-type`(**不按字母序**,s3.rs 是字母序);
|
||||
// 2. algorithm 串 `HMAC-SHA256`(无 `AWS4` 前缀)、credential scope 结尾 `request`
|
||||
// (非 `aws4_request`)、签名密钥 `kDate=HMAC(SK, date)`(SK 不加 `AWS4` 前缀)。
|
||||
// canonical query 仍按 key 字母序(与标准 SigV4 一致);service=`ark`、POST、空 body。
|
||||
|
||||
const VOLCENGINE_SERVICE: &str = "ark";
|
||||
const VOLCENGINE_CONTENT_TYPE: &str = "application/json; charset=utf-8";
|
||||
const VOLCENGINE_SIGNED_HEADERS: &str = "host;x-date;x-content-sha256;content-type";
|
||||
|
||||
fn volc_hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
|
||||
use hmac::{Hmac, Mac};
|
||||
type HmacSha256 = Hmac<sha2::Sha256>;
|
||||
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
|
||||
mac.update(data);
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
|
||||
fn volc_sha256_hex(data: &[u8]) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
format!("{:x}", Sha256::digest(data))
|
||||
}
|
||||
|
||||
/// RFC3986 unreserved 之外全部按 `%XX` 编码(用于 canonical query string)。
|
||||
fn volc_uri_encode(input: &str) -> String {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
for byte in input.bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(byte as char)
|
||||
}
|
||||
_ => {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(out, "%{byte:02X}");
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 构造按 key 字母序排序、逐段 URL 编码的 canonical query string。
|
||||
/// 同一份字符串既用于签名也用于实际请求 URL,保证两者完全一致。
|
||||
fn volcengine_canonical_query(action: &str, region: &str) -> String {
|
||||
let mut pairs = [
|
||||
("Action", action),
|
||||
("Region", region),
|
||||
("Version", VOLCENGINE_API_VERSION),
|
||||
];
|
||||
pairs.sort_by(|a, b| a.0.cmp(b.0));
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", volc_uri_encode(k), volc_uri_encode(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&")
|
||||
}
|
||||
|
||||
/// 生成火山引擎签名 V4 的鉴权头,返回 `(Authorization, X-Date, X-Content-Sha256)`,
|
||||
/// 三者都要塞进请求头;`canonical_query` 必须与实际请求 URL 的 query 完全一致。
|
||||
/// `now` 作参数传入便于写确定性单测。
|
||||
fn volcengine_sign(
|
||||
access_key_id: &str,
|
||||
secret_access_key: &str,
|
||||
region: &str,
|
||||
canonical_query: &str,
|
||||
body: &[u8],
|
||||
now: chrono::DateTime<chrono::Utc>,
|
||||
) -> (String, String, String) {
|
||||
let x_date = now.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
let short_date = now.format("%Y%m%d").to_string();
|
||||
let x_content_sha256 = volc_sha256_hex(body);
|
||||
|
||||
// 固定顺序 canonical headers(火山特有,**不排序**)。
|
||||
let canonical_headers = format!(
|
||||
"host:{VOLCENGINE_OPENAPI_HOST}\nx-date:{x_date}\nx-content-sha256:{x_content_sha256}\ncontent-type:{VOLCENGINE_CONTENT_TYPE}\n"
|
||||
);
|
||||
let canonical_request = format!(
|
||||
"POST\n/\n{canonical_query}\n{canonical_headers}\n{VOLCENGINE_SIGNED_HEADERS}\n{x_content_sha256}"
|
||||
);
|
||||
|
||||
let credential_scope = format!("{short_date}/{region}/{VOLCENGINE_SERVICE}/request");
|
||||
let string_to_sign = format!(
|
||||
"HMAC-SHA256\n{x_date}\n{credential_scope}\n{}",
|
||||
volc_sha256_hex(canonical_request.as_bytes())
|
||||
);
|
||||
|
||||
// 签名密钥派生:kDate=HMAC(SK, date)(SK **不加** AWS4 前缀),终止串 `request`。
|
||||
let k_date = volc_hmac_sha256(secret_access_key.as_bytes(), short_date.as_bytes());
|
||||
let k_region = volc_hmac_sha256(&k_date, region.as_bytes());
|
||||
let k_service = volc_hmac_sha256(&k_region, VOLCENGINE_SERVICE.as_bytes());
|
||||
let k_signing = volc_hmac_sha256(&k_service, b"request");
|
||||
let signature: String = volc_hmac_sha256(&k_signing, string_to_sign.as_bytes())
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect();
|
||||
|
||||
let authorization = format!(
|
||||
"HMAC-SHA256 Credential={access_key_id}/{credential_scope}, SignedHeaders={VOLCENGINE_SIGNED_HEADERS}, Signature={signature}"
|
||||
);
|
||||
(authorization, x_date, x_content_sha256)
|
||||
}
|
||||
|
||||
async fn volcengine_openapi_call(
|
||||
region: &str,
|
||||
access_key_id: &str,
|
||||
secret_access_key: &str,
|
||||
action: &str,
|
||||
) -> VolcCall {
|
||||
let client = crate::proxy::http_client::get();
|
||||
// canonical query 同时用于签名与实际 URL,确保两者逐字一致(否则签名不匹配)。
|
||||
let canonical_query = volcengine_canonical_query(action, region);
|
||||
let url = format!("https://{VOLCENGINE_OPENAPI_HOST}/?{canonical_query}");
|
||||
let body: &[u8] = b"";
|
||||
let (authorization, x_date, x_content_sha256) = volcengine_sign(
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
region,
|
||||
&canonical_query,
|
||||
body,
|
||||
chrono::Utc::now(),
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("X-Date", x_date)
|
||||
.header("X-Content-Sha256", x_content_sha256)
|
||||
.header("Content-Type", VOLCENGINE_CONTENT_TYPE)
|
||||
.header("Authorization", authorization)
|
||||
.body(body.to_vec())
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(e) => return VolcCall::Soft(format!("Network error: {e}")),
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||
return VolcCall::Auth(format!(
|
||||
"Authentication failed (HTTP {status}). {VOLCENGINE_AKSK_HINT}"
|
||||
));
|
||||
}
|
||||
if !status.is_success() {
|
||||
// 火山 OpenAPI 网关对签名/凭据类错误常返 4xx(多为 HTTP 400)并携带与 200
|
||||
// 路径相同的 ResponseMetadata.Error 信封,而非 401/403。这里也解析信封,让
|
||||
// Bearer 被拒时仍能给出 AK/SK 引导并标记凭据失效,而不是当成普通 API 错误。
|
||||
let raw = resp.text().await.unwrap_or_default();
|
||||
if let Ok(body) = serde_json::from_str::<serde_json::Value>(&raw) {
|
||||
if let Some((code, msg)) = volcengine_response_error(&body) {
|
||||
if volcengine_is_auth_error_code(&code) {
|
||||
return VolcCall::Auth(format!(
|
||||
"Authentication failed (HTTP {status}, {code}): {msg}. {VOLCENGINE_AKSK_HINT}"
|
||||
));
|
||||
}
|
||||
return VolcCall::Soft(format!("API error (HTTP {status}, {code}): {msg}"));
|
||||
}
|
||||
}
|
||||
return VolcCall::Soft(format!("API error (HTTP {status}): {raw}"));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => return VolcCall::Soft(format!("Failed to parse response: {e}")),
|
||||
};
|
||||
|
||||
// 火山 OpenAPI 业务错误常以 200 + ResponseMetadata.Error 返回。
|
||||
if let Some((code, msg)) = volcengine_response_error(&body) {
|
||||
if volcengine_is_auth_error_code(&code) {
|
||||
return VolcCall::Auth(format!(
|
||||
"Authentication failed ({code}): {msg}. {VOLCENGINE_AKSK_HINT}"
|
||||
));
|
||||
}
|
||||
return VolcCall::Soft(format!("API error ({code}): {msg}"));
|
||||
}
|
||||
|
||||
VolcCall::Body(body)
|
||||
}
|
||||
|
||||
/// 解析 `GetAFPUsage` 的 `Result` 为 tier 列表。
|
||||
///
|
||||
/// 展示 5h / 周 / 月三个窗口(与控制台一致);`AFPDaily` 被官方控制台隐藏
|
||||
/// (其 Quota 常高于周上限,属历史默认值而非强制限额),故跳过。
|
||||
/// `Quota`/`Used` 是绝对 AFP 值,已用百分比 = Used/Quota×100;`Quota<=0` 视为
|
||||
/// 该窗口未订阅/未启用,跳过——也用于把"已鉴权但无 Agent Plan"识别为空结果,
|
||||
/// 从而回落到 Coding Plan 探测。
|
||||
fn parse_afp_tiers(result: &serde_json::Value) -> Vec<QuotaTier> {
|
||||
let mut tiers = Vec::new();
|
||||
for (key, name) in [
|
||||
("AFPFiveHour", TIER_FIVE_HOUR),
|
||||
("AFPWeekly", TIER_WEEKLY_LIMIT),
|
||||
("AFPMonthly", TIER_MONTHLY),
|
||||
] {
|
||||
let Some(win) = result.get(key) else { continue };
|
||||
let quota = win.get("Quota").and_then(parse_f64).unwrap_or(0.0);
|
||||
if quota <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let used = win.get("Used").and_then(parse_f64).unwrap_or(0.0);
|
||||
// 已用百分比;不做范围裁剪,与 parse_zhipu_token_tiers/parse_minimax_tiers
|
||||
// 的约定一致(下游渲染层负责显示策略)。
|
||||
let utilization = used / quota * 100.0;
|
||||
let resets_at = win.get("ResetTime").and_then(extract_reset_time);
|
||||
tiers.push(QuotaTier {
|
||||
name: name.to_string(),
|
||||
utilization,
|
||||
resets_at,
|
||||
used_value_usd: None,
|
||||
max_value_usd: None,
|
||||
});
|
||||
}
|
||||
tiers
|
||||
}
|
||||
|
||||
/// 把 `GetCodingPlanUsage` 的 window 标签归一到 tier 名。
|
||||
fn volcengine_coding_window(label: &str) -> Option<&'static str> {
|
||||
match label.to_lowercase().as_str() {
|
||||
"session" | "5h" | "fivehour" | "five_hour" | "rolling_5h" => Some(TIER_FIVE_HOUR),
|
||||
"weekly" | "week" | "7d" => Some(TIER_WEEKLY_LIMIT),
|
||||
"monthly" | "month" => Some(TIER_MONTHLY),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 `GetCodingPlanUsage` 的 `Result` 为 tier 列表(防御式)。
|
||||
///
|
||||
/// 该接口官方文档未给出逐字段规格,依据官方 ark-cli 描述:回 session/weekly/
|
||||
/// monthly 窗口、**只给百分比**(已用)、重置时间是秒级。这里宽松匹配
|
||||
/// `QuotaUsage`/`Usages`/`Details` 数组及多种字段名,命中即用、未命中跳过。
|
||||
fn parse_coding_plan_tiers(result: &serde_json::Value) -> Vec<QuotaTier> {
|
||||
let mut tiers = Vec::new();
|
||||
let arr = result
|
||||
.get("QuotaUsage")
|
||||
.and_then(|v| v.as_array())
|
||||
.or_else(|| result.get("Usages").and_then(|v| v.as_array()))
|
||||
.or_else(|| result.get("Details").and_then(|v| v.as_array()));
|
||||
let Some(arr) = arr else { return tiers };
|
||||
|
||||
for item in arr {
|
||||
// 真实字段是 `Level`(实测 2026-06-21:session/weekly/monthly);其余作防御式 fallback。
|
||||
let label = item
|
||||
.get("Level")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| item.get("Type").and_then(|v| v.as_str()))
|
||||
.or_else(|| item.get("Period").and_then(|v| v.as_str()))
|
||||
.or_else(|| item.get("Label").and_then(|v| v.as_str()))
|
||||
.or_else(|| item.get("Window").and_then(|v| v.as_str()))
|
||||
.unwrap_or("");
|
||||
let Some(name) = volcengine_coding_window(label) else {
|
||||
continue;
|
||||
};
|
||||
let utilization = item
|
||||
.get("Percent")
|
||||
.and_then(parse_f64)
|
||||
.or_else(|| item.get("UsedPercent").and_then(parse_f64))
|
||||
.or_else(|| item.get("UsagePercent").and_then(parse_f64))
|
||||
.unwrap_or(0.0);
|
||||
// 兼容秒/毫秒/字符串(extract_reset_time 内部已区分秒与毫秒)。
|
||||
let resets_at = item
|
||||
.get("ResetTime")
|
||||
.or_else(|| item.get("ResetTimestamp"))
|
||||
.and_then(extract_reset_time);
|
||||
tiers.push(QuotaTier {
|
||||
name: name.to_string(),
|
||||
utilization,
|
||||
resets_at,
|
||||
used_value_usd: None,
|
||||
max_value_usd: None,
|
||||
});
|
||||
}
|
||||
tiers
|
||||
}
|
||||
|
||||
fn volcengine_success(tiers: Vec<QuotaTier>, plan: Option<String>) -> SubscriptionQuota {
|
||||
SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Valid,
|
||||
credential_message: plan,
|
||||
success: true,
|
||||
tiers,
|
||||
extra_usage: None,
|
||||
error: None,
|
||||
queried_at: Some(now_millis()),
|
||||
}
|
||||
}
|
||||
|
||||
fn volcengine_auth_error(detail: String) -> SubscriptionQuota {
|
||||
SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Expired,
|
||||
credential_message: Some("Invalid API key".to_string()),
|
||||
success: false,
|
||||
tiers: vec![],
|
||||
extra_usage: None,
|
||||
error: Some(detail),
|
||||
queried_at: Some(now_millis()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn query_volcengine(
|
||||
base_url: &str,
|
||||
access_key_id: &str,
|
||||
secret_access_key: &str,
|
||||
) -> SubscriptionQuota {
|
||||
let region = volcengine_region(base_url);
|
||||
let mut soft_errors: Vec<String> = Vec::new();
|
||||
// 2xx + 无 Error 信封但解析不出额度时,截断原始响应用于诊断(区分"真没订阅"
|
||||
// 与"字段名/包裹层猜错")。签名若不通会走 Auth/Soft 分支,到不了这里。
|
||||
let mut empty_responses: Vec<String> = Vec::new();
|
||||
let summarize = |action: &str, body: &serde_json::Value| -> String {
|
||||
let raw: String = body.to_string().chars().take(700).collect();
|
||||
format!("{action}={raw}")
|
||||
};
|
||||
|
||||
// 1) Agent Plan:GetAFPUsage
|
||||
match volcengine_openapi_call(®ion, access_key_id, secret_access_key, "GetAFPUsage").await {
|
||||
VolcCall::Auth(detail) => return volcengine_auth_error(detail),
|
||||
VolcCall::Soft(detail) => soft_errors.push(format!("GetAFPUsage: {detail}")),
|
||||
VolcCall::Body(body) => {
|
||||
let result = body.get("Result").unwrap_or(&body);
|
||||
let tiers = parse_afp_tiers(result);
|
||||
if !tiers.is_empty() {
|
||||
let plan = result
|
||||
.get("PlanType")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| format!("Agent Plan {s}"));
|
||||
return volcengine_success(tiers, plan);
|
||||
}
|
||||
empty_responses.push(summarize("GetAFPUsage", &body));
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Coding Plan:GetCodingPlanUsage
|
||||
match volcengine_openapi_call(
|
||||
®ion,
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
"GetCodingPlanUsage",
|
||||
)
|
||||
.await
|
||||
{
|
||||
VolcCall::Auth(detail) => return volcengine_auth_error(detail),
|
||||
VolcCall::Soft(detail) => soft_errors.push(format!("GetCodingPlanUsage: {detail}")),
|
||||
VolcCall::Body(body) => {
|
||||
let result = body.get("Result").unwrap_or(&body);
|
||||
let tiers = parse_coding_plan_tiers(result);
|
||||
if !tiers.is_empty() {
|
||||
return volcengine_success(tiers, Some("Coding Plan".to_string()));
|
||||
}
|
||||
empty_responses.push(summarize("GetCodingPlanUsage", &body));
|
||||
}
|
||||
}
|
||||
|
||||
if !soft_errors.is_empty() {
|
||||
make_error(soft_errors.join("; "))
|
||||
} else if !empty_responses.is_empty() {
|
||||
// 签名已通过、请求到达业务层,但响应里没有可解析的额度。带上原始响应,
|
||||
// 便于核对真实字段名/包裹层,或确认确实未订阅。
|
||||
make_error(format!(
|
||||
"No active subscription found (signature OK). Raw: {}",
|
||||
empty_responses.join(" || ")
|
||||
))
|
||||
} else {
|
||||
make_error(
|
||||
"No active Agent Plan or Coding Plan subscription found for this credential"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 公开入口 ────────────────────────────────────────────────
|
||||
|
||||
/// 构造"凭据缺失 / 域名未命中"的失败结果(NotFound 状态 + 明确错误文案)。
|
||||
fn coding_plan_not_found(error: &str) -> SubscriptionQuota {
|
||||
SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::NotFound,
|
||||
credential_message: None,
|
||||
success: false,
|
||||
tiers: vec![],
|
||||
extra_usage: None,
|
||||
error: Some(error.to_string()),
|
||||
queried_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_coding_plan_quota(
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
access_key_id: Option<&str>,
|
||||
secret_access_key: Option<&str>,
|
||||
) -> Result<SubscriptionQuota, String> {
|
||||
if api_key.trim().is_empty() {
|
||||
return Ok(SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::NotFound,
|
||||
credential_message: None,
|
||||
success: false,
|
||||
tiers: vec![],
|
||||
extra_usage: None,
|
||||
// 与 balance::get_balance 一致:给出明确错误,避免 footer 显示无信息的失败
|
||||
error: Some("API key is empty".to_string()),
|
||||
queried_at: None,
|
||||
});
|
||||
}
|
||||
|
||||
let provider = match detect_provider(base_url) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return Ok(SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::NotFound,
|
||||
credential_message: None,
|
||||
success: false,
|
||||
tiers: vec![],
|
||||
extra_usage: None,
|
||||
// 域名未命中已知套餐供应商(如第三方中转站):给出明确错误而非静默失败
|
||||
error: Some("Unknown coding plan provider".to_string()),
|
||||
queried_at: None,
|
||||
});
|
||||
}
|
||||
// 域名未命中已知套餐供应商(如第三方中转站):给出明确错误而非静默失败
|
||||
None => return Ok(coding_plan_not_found("Unknown coding plan provider")),
|
||||
};
|
||||
|
||||
// 火山方舟走控制面 AK/SK 签名(区别于其他供应商的数据面 Bearer api_key),凭据
|
||||
// 校验与查询路径都不同,单独分支提前处理。
|
||||
if let CodingPlanProvider::Volcengine = provider {
|
||||
let ak = access_key_id.unwrap_or("").trim();
|
||||
let sk = secret_access_key.unwrap_or("").trim();
|
||||
if ak.is_empty() || sk.is_empty() {
|
||||
return Ok(coding_plan_not_found(
|
||||
"Volcengine usage query needs the account AccessKey ID + Secret (not the inference API key)",
|
||||
));
|
||||
}
|
||||
return Ok(query_volcengine(base_url, ak, sk).await);
|
||||
}
|
||||
|
||||
// 其余供应商:数据面 Bearer api_key。
|
||||
// 与 balance::get_balance 一致:给出明确错误,避免 footer 显示无信息的失败
|
||||
if api_key.trim().is_empty() {
|
||||
return Ok(coding_plan_not_found("API key is empty"));
|
||||
}
|
||||
|
||||
let quota = match provider {
|
||||
CodingPlanProvider::Kimi => query_kimi(api_key).await,
|
||||
CodingPlanProvider::ZhipuCn | CodingPlanProvider::ZhipuEn => {
|
||||
@@ -697,6 +1182,10 @@ pub async fn get_coding_plan_quota(
|
||||
CodingPlanProvider::MiniMaxCn => query_minimax(api_key, true).await,
|
||||
CodingPlanProvider::MiniMaxEn => query_minimax(api_key, false).await,
|
||||
CodingPlanProvider::ZenMux => query_zenmux(base_url, api_key).await,
|
||||
// 火山已在上面的 AK/SK 分支提前返回,此处不可达。
|
||||
CodingPlanProvider::Volcengine => {
|
||||
unreachable!("volcengine handled via AK/SK branch above")
|
||||
}
|
||||
};
|
||||
|
||||
Ok(quota)
|
||||
@@ -705,7 +1194,9 @@ pub async fn get_coding_plan_quota(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
parse_minimax_tiers, parse_zhipu_token_tiers, zhipu_quota_base, TIER_FIVE_HOUR,
|
||||
parse_afp_tiers, parse_coding_plan_tiers, parse_minimax_tiers, parse_zhipu_token_tiers,
|
||||
volcengine_canonical_query, volcengine_is_auth_error_code, volcengine_region,
|
||||
volcengine_response_error, volcengine_sign, zhipu_quota_base, TIER_FIVE_HOUR, TIER_MONTHLY,
|
||||
TIER_WEEKLY_LIMIT,
|
||||
};
|
||||
use serde_json::json;
|
||||
@@ -1135,4 +1626,184 @@ mod tests {
|
||||
"https://open.bigmodel.cn"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 火山方舟 Agent Plan / Coding Plan ──
|
||||
|
||||
#[test]
|
||||
fn volcengine_afp_three_windows_from_official_example() {
|
||||
// 官方文档 GetAFPUsage 返回示例(逐字):5h 25% / weekly 30% / monthly
|
||||
// 42.525%;AFPDaily 被控制台隐藏,应跳过。
|
||||
let result = json!({
|
||||
"PlanType": "Large",
|
||||
"AFPFiveHour": { "Quota": 50.0, "Used": 12.5, "SubscribeTime": 1778788800000_i64, "ResetTime": 1778806800000_i64 },
|
||||
"AFPDaily": { "Quota": 100.0, "Used": 22.5, "SubscribeTime": 1778716800000_i64, "ResetTime": 1778803200000_i64 },
|
||||
"AFPWeekly": { "Quota": 500.0, "Used": 150.0, "SubscribeTime": 1778457600000_i64, "ResetTime": 1779062400000_i64 },
|
||||
"AFPMonthly": { "Quota": 2000.0, "Used": 850.5, "SubscribeTime": 1777939200000_i64, "ResetTime": 1780531200000_i64 }
|
||||
});
|
||||
let tiers = parse_afp_tiers(&result);
|
||||
assert_eq!(tiers.len(), 3, "daily 应被跳过,只剩 5h/周/月");
|
||||
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
|
||||
assert!((tiers[0].utilization - 25.0).abs() < 1e-9);
|
||||
assert!(tiers[0].resets_at.is_some());
|
||||
assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT);
|
||||
assert!((tiers[1].utilization - 30.0).abs() < 1e-9);
|
||||
assert_eq!(tiers[2].name, TIER_MONTHLY);
|
||||
assert!((tiers[2].utilization - 42.525).abs() < 1e-9);
|
||||
assert!(tiers[2].resets_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volcengine_afp_zero_quota_windows_treated_as_unbound() {
|
||||
// 已鉴权但无 Agent Plan:窗口 Quota=0 → 空结果,调用方据此回落 Coding Plan。
|
||||
let result = json!({
|
||||
"PlanType": "",
|
||||
"AFPFiveHour": { "Quota": 0.0, "Used": 0.0 },
|
||||
"AFPWeekly": { "Quota": 0.0, "Used": 0.0 },
|
||||
"AFPMonthly": { "Quota": 0.0, "Used": 0.0 }
|
||||
});
|
||||
assert!(parse_afp_tiers(&result).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volcengine_afp_partial_windows_only_subscribed_ones() {
|
||||
// 仅 5h 窗口有额度(缺周/月)→ 只产出一个 tier。
|
||||
let result = json!({
|
||||
"AFPFiveHour": { "Quota": 40.0, "Used": 10.0, "ResetTime": 1778806800000_i64 },
|
||||
"AFPWeekly": { "Quota": 0.0, "Used": 0.0 }
|
||||
});
|
||||
let tiers = parse_afp_tiers(&result);
|
||||
assert_eq!(tiers.len(), 1);
|
||||
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
|
||||
assert!((tiers[0].utilization - 25.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volcengine_coding_plan_real_response_levels() {
|
||||
// 真实 GetCodingPlanUsage 响应(用户实测 2026-06-21):字段名是 `Level`(非 `Type`),
|
||||
// 仅百分比,秒级 ResetTimestamp;session 无活跃窗口回 -1 → 无重置时间。
|
||||
let result = json!({
|
||||
"Status": "Running",
|
||||
"UpdateTimestamp": 1782053286_i64,
|
||||
"QuotaUsage": [
|
||||
{ "Level": "session", "Percent": 0.0, "ResetTimestamp": -1_i64 },
|
||||
{ "Level": "weekly", "Percent": 1.672568, "ResetTimestamp": 1782057600_i64 },
|
||||
{ "Level": "monthly", "Percent": 0.836284, "ResetTimestamp": 1784303999_i64 }
|
||||
]
|
||||
});
|
||||
let tiers = parse_coding_plan_tiers(&result);
|
||||
assert_eq!(tiers.len(), 3);
|
||||
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
|
||||
assert!((tiers[0].utilization - 0.0).abs() < 1e-9);
|
||||
assert!(
|
||||
tiers[0].resets_at.is_none(),
|
||||
"session ResetTimestamp=-1 应无重置时间"
|
||||
);
|
||||
assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT);
|
||||
assert!((tiers[1].utilization - 1.672568).abs() < 1e-6);
|
||||
assert!(tiers[1].resets_at.is_some());
|
||||
assert_eq!(tiers[2].name, TIER_MONTHLY);
|
||||
assert!((tiers[2].utilization - 0.836284).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volcengine_coding_plan_unknown_window_skipped_and_missing_array_empty() {
|
||||
let result = json!({
|
||||
"QuotaUsage": [
|
||||
{ "Level": "daily", "Percent": 9.0 },
|
||||
{ "Level": "weekly", "Percent": 20.0 }
|
||||
]
|
||||
});
|
||||
let tiers = parse_coding_plan_tiers(&result);
|
||||
assert_eq!(tiers.len(), 1, "未知 daily 窗口跳过");
|
||||
assert_eq!(tiers[0].name, TIER_WEEKLY_LIMIT);
|
||||
|
||||
assert!(parse_coding_plan_tiers(&json!({})).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volcengine_region_derivation() {
|
||||
assert_eq!(
|
||||
volcengine_region("https://ark.cn-beijing.volces.com/api/coding"),
|
||||
"cn-beijing"
|
||||
);
|
||||
// 其他 region 的数据面域名按段提取。
|
||||
assert_eq!(
|
||||
volcengine_region("https://ark.cn-shanghai.volces.com/api/coding/v3"),
|
||||
"cn-shanghai"
|
||||
);
|
||||
// 无可识别 region 段时回落默认 cn-beijing。
|
||||
assert_eq!(
|
||||
volcengine_region("https://example.com/api/coding"),
|
||||
"cn-beijing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volcengine_canonical_query_is_sorted_and_encoded() {
|
||||
// 按 key 字母序:Action < Region < Version;值含 `-` 属 unreserved,不编码。
|
||||
assert_eq!(
|
||||
volcengine_canonical_query("GetAFPUsage", "cn-beijing"),
|
||||
"Action=GetAFPUsage&Region=cn-beijing&Version=2024-01-01"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volcengine_sign_structure_and_determinism() {
|
||||
// 没有服务端金标准向量时,锁定签名的结构契约 + 确定性(足以抓住 header 顺序、
|
||||
// scope 后缀、algorithm 前缀、空 body hash 等实现错误)。真实正确性靠用户实测。
|
||||
let now = chrono::DateTime::parse_from_rfc3339("2024-06-21T00:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&chrono::Utc);
|
||||
let region = "cn-beijing";
|
||||
let query = volcengine_canonical_query("GetAFPUsage", region);
|
||||
let (auth, x_date, x_content) =
|
||||
volcengine_sign("AKLTtest", "secretkey", region, &query, b"", now);
|
||||
|
||||
// 空 body 的 SHA-256(固定值),证明走的是空 body。
|
||||
assert_eq!(
|
||||
x_content,
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
);
|
||||
// X-Date 形如 20240621T000000Z。
|
||||
assert_eq!(x_date, "20240621T000000Z");
|
||||
// Authorization 结构:算法无 AWS4 前缀、scope 结尾 ark/request、固定 SignedHeaders。
|
||||
assert!(
|
||||
auth.starts_with("HMAC-SHA256 Credential=AKLTtest/20240621/cn-beijing/ark/request,"),
|
||||
"unexpected credential/scope: {auth}"
|
||||
);
|
||||
assert!(
|
||||
auth.contains("SignedHeaders=host;x-date;x-content-sha256;content-type,"),
|
||||
"unexpected signed headers: {auth}"
|
||||
);
|
||||
// Signature 是 64 位十六进制。
|
||||
let sig = auth.rsplit("Signature=").next().unwrap();
|
||||
assert_eq!(sig.len(), 64);
|
||||
assert!(sig.bytes().all(|b| b.is_ascii_hexdigit()));
|
||||
|
||||
// 确定性:同输入同输出。
|
||||
let (auth2, _, _) = volcengine_sign("AKLTtest", "secretkey", region, &query, b"", now);
|
||||
assert_eq!(auth, auth2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volcengine_auth_error_code_detection_and_extraction() {
|
||||
assert!(volcengine_is_auth_error_code("AccessDenied"));
|
||||
assert!(volcengine_is_auth_error_code("SignatureDoesNotMatch"));
|
||||
assert!(volcengine_is_auth_error_code("InvalidAuthorization"));
|
||||
assert!(volcengine_is_auth_error_code("Unauthorized"));
|
||||
assert!(!volcengine_is_auth_error_code("InvalidParameter.Action"));
|
||||
assert!(!volcengine_is_auth_error_code("InternalError"));
|
||||
|
||||
// ResponseMetadata.Error 抽取
|
||||
let body = json!({
|
||||
"ResponseMetadata": { "RequestId": "x", "Error": { "Code": "AccessDenied", "Message": "no permission" } }
|
||||
});
|
||||
let (code, msg) = volcengine_response_error(&body).expect("应抽到 Error");
|
||||
assert_eq!(code, "AccessDenied");
|
||||
assert_eq!(msg, "no permission");
|
||||
|
||||
// 无 Error 时返回 None
|
||||
let ok_body = json!({ "ResponseMetadata": { "RequestId": "x" }, "Result": {} });
|
||||
assert!(volcengine_response_error(&ok_body).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,6 +465,7 @@ base_url = "http://localhost:8080"
|
||||
|
||||
db.update_proxy_config(ProxyConfig {
|
||||
live_takeover_active: true,
|
||||
listen_port: 0,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
@@ -493,7 +494,7 @@ base_url = "http://localhost:8080"
|
||||
)
|
||||
.expect("seed taken-over live file");
|
||||
|
||||
state
|
||||
let proxy_info = state
|
||||
.proxy_service
|
||||
.start()
|
||||
.await
|
||||
@@ -546,7 +547,7 @@ base_url = "http://localhost:8080"
|
||||
live.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_BASE_URL"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("http://127.0.0.1:15721"),
|
||||
Some(format!("http://127.0.0.1:{}", proxy_info.port).as_str()),
|
||||
"proxy base URL should stay intact"
|
||||
);
|
||||
assert!(
|
||||
|
||||
@@ -5701,8 +5701,10 @@ command = "shared-command"
|
||||
)
|
||||
.expect("set common config snippet");
|
||||
|
||||
let mut proxy_config = ProxyConfig::default();
|
||||
proxy_config.listen_port = 0;
|
||||
let proxy_config = ProxyConfig {
|
||||
listen_port: 0,
|
||||
..Default::default()
|
||||
};
|
||||
db.update_proxy_config(proxy_config)
|
||||
.await
|
||||
.expect("set test proxy config");
|
||||
@@ -5839,8 +5841,10 @@ requires_openai_auth = true
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
let state = crate::store::AppState::new(db.clone());
|
||||
|
||||
let mut proxy_config = ProxyConfig::default();
|
||||
proxy_config.listen_port = 0;
|
||||
let proxy_config = ProxyConfig {
|
||||
listen_port: 0,
|
||||
..Default::default()
|
||||
};
|
||||
db.update_proxy_config(proxy_config)
|
||||
.await
|
||||
.expect("set test proxy config");
|
||||
|
||||
@@ -307,6 +307,11 @@ pub const TIER_SEVEN_DAY_SONNET: &str = "seven_day_sonnet";
|
||||
/// 写入、tray 渲染、commands::provider 扁平化三处共用同一标识。
|
||||
pub const TIER_WEEKLY_LIMIT: &str = "weekly_limit";
|
||||
|
||||
/// 月窗口 tier 名。火山方舟 Agent Plan / Coding Plan 有 5h / 周 / 月 三个展示
|
||||
/// 窗口(Kimi / MiniMax 只有 5h + 周),月窗口共用此标识;前端 `TIER_I18N_KEYS`
|
||||
/// 映射到 `subscription.monthly`。
|
||||
pub const TIER_MONTHLY: &str = "monthly";
|
||||
|
||||
/// Gemini 用量分组名称(按模型而非时间窗口)。`classify_gemini_model` 输出。
|
||||
pub const TIER_GEMINI_PRO: &str = "gemini_pro";
|
||||
pub const TIER_GEMINI_FLASH: &str = "gemini_flash";
|
||||
|
||||
@@ -1732,23 +1732,20 @@ impl Database {
|
||||
OR cache_read_tokens > 0 OR cache_creation_tokens > 0)";
|
||||
|
||||
let mut logs = {
|
||||
match only_model_id {
|
||||
Some(model) => {
|
||||
let sql = format!(
|
||||
"{BASE_SQL} AND (model = ?1 OR request_model = ?1 OR pricing_model = ?1)"
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map([model], row_to_request_log_detail)?;
|
||||
rows.collect::<Result<Vec<_>, _>>()?
|
||||
}
|
||||
None => {
|
||||
let mut stmt = conn.prepare(BASE_SQL)?;
|
||||
let rows = stmt.query_map([], row_to_request_log_detail)?;
|
||||
rows.collect::<Result<Vec<_>, _>>()?
|
||||
}
|
||||
}
|
||||
let mut stmt = conn.prepare(BASE_SQL)?;
|
||||
let rows = stmt.query_map([], row_to_request_log_detail)?;
|
||||
rows.collect::<Result<Vec<_>, _>>()?
|
||||
};
|
||||
|
||||
// 精准回填的行筛选必须与查价层共用 candidates 归一化:SQL 精确匹配会漏掉
|
||||
// 以原始别名落库的行(如 openrouter/anthropic/claude-sonnet-4.5:free),
|
||||
// 这些行查价时能归一化命中新定价,却在筛选层被挡掉,导致导入定价后
|
||||
// 历史成本要等下次全量回填才更新。误纳无害——查不到价的行会被跳过。
|
||||
if let Some(model_id) = only_model_id {
|
||||
let target = model_pricing_candidates(model_id);
|
||||
logs.retain(|log| log_pricing_scope_matches(log, &target));
|
||||
}
|
||||
|
||||
if logs.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
@@ -1966,6 +1963,30 @@ pub(crate) fn find_model_pricing_row(
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// 精准回填的行筛选:log 的任一模型字段归一化后与目标模型的 candidates 相交,
|
||||
/// 或可按查价层的前缀规则命中目标,即视为相关。镜像 find_model_pricing_row 的
|
||||
/// 匹配语义,宁可误纳(后续查价会兜底)不可漏筛。
|
||||
fn log_pricing_scope_matches(log: &RequestLogDetail, target_candidates: &[String]) -> bool {
|
||||
[
|
||||
Some(log.model.as_str()),
|
||||
log.request_model.as_deref(),
|
||||
log.pricing_model.as_deref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|field| {
|
||||
model_pricing_candidates(field).iter().any(|candidate| {
|
||||
target_candidates.iter().any(|target| {
|
||||
target == candidate
|
||||
|| (should_try_pricing_prefix_match(candidate)
|
||||
&& target
|
||||
.strip_prefix(candidate.as_str())
|
||||
.is_some_and(|rest| rest.starts_with('-')))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn is_placeholder_pricing_model(model_id: &str) -> bool {
|
||||
let normalized = model_id.trim().to_ascii_lowercase();
|
||||
normalized.is_empty() || matches!(normalized.as_str(), "unknown" | "null" | "none")
|
||||
@@ -2652,6 +2673,61 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scoped_backfill_matches_raw_alias_rows() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
{
|
||||
let conn = lock_conn!(db.conn);
|
||||
// 代理日志按上游原文落库:带路由前缀和 :free 后缀的别名形式。
|
||||
// 精准回填的筛选必须归一化后匹配,否则这类行要等全量回填才更新。
|
||||
insert_usage_log(
|
||||
&conn,
|
||||
"openrouter-alias-zero-cost",
|
||||
"claude",
|
||||
"provider-1",
|
||||
"openrouter/moonshot/kimi-k2-novel:free",
|
||||
"proxy",
|
||||
1000,
|
||||
1_000_000,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
200,
|
||||
"0",
|
||||
)?;
|
||||
}
|
||||
|
||||
// 定价缺失时不应回填
|
||||
assert_eq!(db.backfill_missing_usage_costs()?, 0);
|
||||
|
||||
{
|
||||
let conn = lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"INSERT INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)
|
||||
VALUES ('kimi-k2-novel', 'Kimi K2 Novel', '0.6', '2.5')",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
|
||||
// 按归一化 ID 精准回填,应命中以原始别名落库的行
|
||||
assert_eq!(
|
||||
db.backfill_missing_usage_costs_for_model("kimi-k2-novel")?,
|
||||
1
|
||||
);
|
||||
|
||||
let conn = lock_conn!(db.conn);
|
||||
let total_cost: String = conn.query_row(
|
||||
"SELECT total_cost_usd
|
||||
FROM proxy_request_logs WHERE request_id = 'openrouter-alias-zero-cost'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(total_cost, "0.600000");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backfill_missing_usage_costs_keeps_claude_fresh_input() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
+34
-1
@@ -19,6 +19,8 @@ const W_TIER_NAMES: &[&str] = &[
|
||||
crate::services::subscription::TIER_SEVEN_DAY_OPUS,
|
||||
crate::services::subscription::TIER_SEVEN_DAY_SONNET,
|
||||
];
|
||||
// 火山方舟 Agent/Coding Plan 的月窗口(5h/周/月 三档)。
|
||||
const M_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_MONTHLY];
|
||||
const GEMINI_PRO_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_GEMINI_PRO];
|
||||
const GEMINI_FLASH_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_GEMINI_FLASH];
|
||||
const GEMINI_FLASH_LITE_TIER_NAMES: &[&str] =
|
||||
@@ -26,6 +28,7 @@ const GEMINI_FLASH_LITE_TIER_NAMES: &[&str] =
|
||||
const TIER_LABEL_GROUPS: &[(&str, &[&str])] = &[
|
||||
("h", H_TIER_NAMES),
|
||||
("w", W_TIER_NAMES),
|
||||
("m", M_TIER_NAMES),
|
||||
("p", GEMINI_PRO_TIER_NAMES),
|
||||
("f", GEMINI_FLASH_TIER_NAMES),
|
||||
("l", GEMINI_FLASH_LITE_TIER_NAMES),
|
||||
@@ -878,7 +881,7 @@ mod tests {
|
||||
use crate::provider::{UsageData, UsageResult};
|
||||
use crate::services::subscription::{
|
||||
CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_GEMINI_FLASH,
|
||||
TIER_GEMINI_FLASH_LITE, TIER_GEMINI_PRO, TIER_SEVEN_DAY, TIER_SEVEN_DAY_OPUS,
|
||||
TIER_GEMINI_FLASH_LITE, TIER_GEMINI_PRO, TIER_MONTHLY, TIER_SEVEN_DAY, TIER_SEVEN_DAY_OPUS,
|
||||
TIER_SEVEN_DAY_SONNET, TIER_WEEKLY_LIMIT,
|
||||
};
|
||||
|
||||
@@ -1094,6 +1097,36 @@ mod tests {
|
||||
assert!(s.contains("w50%"), "expected w50% in {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_summary_token_plan_volcengine_three_tiers_with_monthly() {
|
||||
// 火山方舟 Agent Plan 回 5h/周/月三档,托盘应包含 m(月)窗口,
|
||||
// 不再静默丢弃。
|
||||
let r = usage_result(
|
||||
true,
|
||||
vec![
|
||||
usage_data(Some(TIER_FIVE_HOUR), 25.0),
|
||||
usage_data(Some(TIER_WEEKLY_LIMIT), 30.0),
|
||||
usage_data(Some(TIER_MONTHLY), 42.0),
|
||||
],
|
||||
);
|
||||
let s = format_script_summary(&r).expect("should format");
|
||||
assert!(s.contains("h25%"), "expected h25% in {s}");
|
||||
assert!(s.contains("w30%"), "expected w30% in {s}");
|
||||
assert!(s.contains("m42%"), "expected m42% in {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_summary_token_plan_monthly_only_renders_label_not_raw_name() {
|
||||
// 仅月窗口激活时不应回落到原始 "monthly" 机器名,而是走 m 标签。
|
||||
let r = usage_result(true, vec![usage_data(Some(TIER_MONTHLY), 60.0)]);
|
||||
let s = format_script_summary(&r).expect("should format");
|
||||
assert!(s.contains("m60%"), "expected m60% in {s}");
|
||||
assert!(
|
||||
!s.contains("monthly"),
|
||||
"raw tier name should not leak into label: {s}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_summary_official_subscription_claude_uses_h_and_w_labels() {
|
||||
let r = usage_result(
|
||||
|
||||
@@ -4,8 +4,9 @@ use std::fs;
|
||||
use serde_json::json;
|
||||
|
||||
use cc_switch_lib::{
|
||||
get_claude_mcp_path, get_claude_settings_path, import_default_config_test_hook, AppError,
|
||||
AppType, McpApps, McpServer, McpService, MultiAppConfig,
|
||||
get_claude_mcp_path, get_claude_mcp_status, get_claude_settings_path,
|
||||
import_default_config_test_hook, read_claude_mcp_config, update_settings, AppError,
|
||||
AppSettings, AppType, McpApps, McpServer, McpService, MultiAppConfig,
|
||||
};
|
||||
|
||||
#[path = "support.rs"]
|
||||
@@ -673,6 +674,274 @@ fn enabling_claude_mcp_skips_when_claude_config_absent() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_default_claude_dir_keeps_default_split_mcp_path() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
let claude_dir = home.join(".claude");
|
||||
fs::create_dir_all(&claude_dir).expect("create explicit default claude dir");
|
||||
|
||||
update_settings(AppSettings {
|
||||
claude_config_dir: Some(claude_dir.to_string_lossy().to_string()),
|
||||
..AppSettings::default()
|
||||
})
|
||||
.expect("set explicit default claude config dir");
|
||||
|
||||
assert_eq!(
|
||||
get_claude_mcp_path(),
|
||||
home.join(".claude.json"),
|
||||
"explicit default Claude dir should keep Claude Code's split MCP path"
|
||||
);
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
McpService::upsert_server(
|
||||
&state,
|
||||
McpServer {
|
||||
id: "claude-default".to_string(),
|
||||
name: "Claude Default".to_string(),
|
||||
server: json!({
|
||||
"type": "stdio",
|
||||
"command": "echo"
|
||||
}),
|
||||
apps: McpApps {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
docs: None,
|
||||
tags: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("sync default Claude MCP");
|
||||
|
||||
assert!(
|
||||
home.join(".claude.json").exists(),
|
||||
"default split MCP file should be written at home/.claude.json"
|
||||
);
|
||||
assert!(
|
||||
!claude_dir.join(".claude.json").exists(),
|
||||
"explicit default dir should not use nested .claude/.claude.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_claude_dir_writes_mcp_inside_config_dir() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
let custom_dir = home.join("profiles").join(".claude");
|
||||
fs::create_dir_all(&custom_dir).expect("create custom claude dir");
|
||||
|
||||
update_settings(AppSettings {
|
||||
claude_config_dir: Some(custom_dir.to_string_lossy().to_string()),
|
||||
..AppSettings::default()
|
||||
})
|
||||
.expect("set custom claude config dir");
|
||||
|
||||
let expected_mcp_path = custom_dir.join(".claude.json");
|
||||
assert_eq!(
|
||||
get_claude_mcp_path(),
|
||||
expected_mcp_path,
|
||||
"custom Claude dir should keep MCP state inside the config dir"
|
||||
);
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
McpService::upsert_server(
|
||||
&state,
|
||||
McpServer {
|
||||
id: "claude-custom".to_string(),
|
||||
name: "Claude Custom".to_string(),
|
||||
server: json!({
|
||||
"type": "stdio",
|
||||
"command": "echo"
|
||||
}),
|
||||
apps: McpApps {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
docs: None,
|
||||
tags: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("sync custom Claude MCP");
|
||||
|
||||
assert!(
|
||||
expected_mcp_path.exists(),
|
||||
"custom Claude MCP file should be written inside custom dir"
|
||||
);
|
||||
assert!(
|
||||
!home.join("profiles").join(".claude.json").exists(),
|
||||
"custom Claude dir should not write sibling .claude.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_claude_dir_sync_does_not_copy_default_profile() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
let home_mcp_path = home.join(".claude.json");
|
||||
let default_profile = json!({
|
||||
"hasCompletedOnboarding": true,
|
||||
"projects": {
|
||||
"/home-project": {
|
||||
"hasTrustDialogAccepted": true
|
||||
}
|
||||
},
|
||||
"mcpServers": {
|
||||
"home-only": {
|
||||
"type": "stdio",
|
||||
"command": "home-command"
|
||||
}
|
||||
},
|
||||
"profileSentinel": "home-profile"
|
||||
});
|
||||
let default_profile_text =
|
||||
serde_json::to_string_pretty(&default_profile).expect("serialize default profile");
|
||||
fs::write(&home_mcp_path, &default_profile_text).expect("seed default Claude profile");
|
||||
|
||||
let custom_dir = home.join("profiles").join("work").join(".claude");
|
||||
fs::create_dir_all(&custom_dir).expect("create custom claude dir");
|
||||
update_settings(AppSettings {
|
||||
claude_config_dir: Some(custom_dir.to_string_lossy().to_string()),
|
||||
..AppSettings::default()
|
||||
})
|
||||
.expect("set custom claude config dir");
|
||||
|
||||
let expected_mcp_path = custom_dir.join(".claude.json");
|
||||
assert_eq!(
|
||||
get_claude_mcp_path(),
|
||||
expected_mcp_path,
|
||||
"custom Claude dir should use nested .claude.json"
|
||||
);
|
||||
assert!(
|
||||
!expected_mcp_path.exists(),
|
||||
"custom profile should start without a live MCP file"
|
||||
);
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
McpService::upsert_server(
|
||||
&state,
|
||||
McpServer {
|
||||
id: "custom-only".to_string(),
|
||||
name: "Custom Only".to_string(),
|
||||
server: json!({
|
||||
"type": "stdio",
|
||||
"command": "custom-command"
|
||||
}),
|
||||
apps: McpApps {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
docs: None,
|
||||
tags: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("sync custom Claude MCP");
|
||||
|
||||
let text = fs::read_to_string(&expected_mcp_path).expect("read custom Claude MCP");
|
||||
let value: serde_json::Value = serde_json::from_str(&text).expect("parse custom Claude MCP");
|
||||
let servers = value
|
||||
.get("mcpServers")
|
||||
.and_then(|v| v.as_object())
|
||||
.expect("custom profile should contain mcpServers");
|
||||
assert!(
|
||||
servers.contains_key("custom-only"),
|
||||
"custom profile should contain DB-managed Claude server"
|
||||
);
|
||||
assert!(
|
||||
!servers.contains_key("home-only"),
|
||||
"custom profile should not inherit default profile MCP servers"
|
||||
);
|
||||
assert!(
|
||||
value.get("hasCompletedOnboarding").is_none(),
|
||||
"custom profile should not inherit onboarding state"
|
||||
);
|
||||
assert!(
|
||||
value.get("projects").is_none(),
|
||||
"custom profile should not inherit project trust state"
|
||||
);
|
||||
assert!(
|
||||
value.get("profileSentinel").is_none(),
|
||||
"custom profile should not inherit unrelated default profile fields"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(&home_mcp_path).expect("reread default Claude profile"),
|
||||
default_profile_text,
|
||||
"default Claude profile should remain unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_claude_dir_read_only_mcp_queries_do_not_create_profile() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
let home_mcp_path = home.join(".claude.json");
|
||||
fs::write(
|
||||
&home_mcp_path,
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"mcpServers": {
|
||||
"home-only": {
|
||||
"type": "stdio",
|
||||
"command": "home-command"
|
||||
}
|
||||
},
|
||||
"profileSentinel": "home-profile"
|
||||
}))
|
||||
.expect("serialize default profile"),
|
||||
)
|
||||
.expect("seed default Claude profile");
|
||||
|
||||
let custom_dir = home.join("profiles").join("work").join(".claude");
|
||||
fs::create_dir_all(&custom_dir).expect("create custom claude dir");
|
||||
update_settings(AppSettings {
|
||||
claude_config_dir: Some(custom_dir.to_string_lossy().to_string()),
|
||||
..AppSettings::default()
|
||||
})
|
||||
.expect("set custom claude config dir");
|
||||
|
||||
let expected_mcp_path = custom_dir.join(".claude.json");
|
||||
assert!(
|
||||
!expected_mcp_path.exists(),
|
||||
"custom profile should start without a live MCP file"
|
||||
);
|
||||
|
||||
let status =
|
||||
futures::executor::block_on(get_claude_mcp_status()).expect("get Claude MCP status");
|
||||
assert_eq!(
|
||||
status.user_config_path,
|
||||
expected_mcp_path.to_string_lossy(),
|
||||
"status should report the custom profile MCP path"
|
||||
);
|
||||
assert!(
|
||||
!status.user_config_exists,
|
||||
"status should report missing custom profile MCP file"
|
||||
);
|
||||
let text =
|
||||
futures::executor::block_on(read_claude_mcp_config()).expect("read Claude MCP config");
|
||||
assert_eq!(text, None, "missing custom profile should read as None");
|
||||
assert!(
|
||||
!expected_mcp_path.exists(),
|
||||
"read-only MCP queries should not copy or create the custom profile"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
|
||||
@@ -597,6 +597,14 @@ wire_api = "responses"
|
||||
|
||||
let state = create_test_state_with_config(&initial_config).expect("create test state");
|
||||
|
||||
let mut proxy_config = state.db.get_proxy_config().await.expect("get proxy config");
|
||||
proxy_config.listen_port = 0;
|
||||
state
|
||||
.db
|
||||
.update_proxy_config(proxy_config)
|
||||
.await
|
||||
.expect("use ephemeral proxy port");
|
||||
|
||||
ProviderService::switch(&state, AppType::Codex, "deepseek-provider")
|
||||
.expect("switch from official subscription to DeepSeek");
|
||||
|
||||
@@ -623,6 +631,12 @@ wire_api = "responses"
|
||||
.set_takeover_for_app("codex", true)
|
||||
.await
|
||||
.expect("enable Codex takeover");
|
||||
let proxy_status = state
|
||||
.proxy_service
|
||||
.get_status()
|
||||
.await
|
||||
.expect("read proxy status after takeover");
|
||||
let codex_proxy_base_url = format!("http://127.0.0.1:{}/v1", proxy_status.port);
|
||||
|
||||
let auth_after_takeover: serde_json::Value =
|
||||
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth after takeover");
|
||||
@@ -634,7 +648,7 @@ wire_api = "responses"
|
||||
let config_after_takeover =
|
||||
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config");
|
||||
assert!(
|
||||
config_after_takeover.contains("http://127.0.0.1:15721/v1"),
|
||||
config_after_takeover.contains(&codex_proxy_base_url),
|
||||
"enabling takeover should point Codex config.toml at the local proxy"
|
||||
);
|
||||
assert!(
|
||||
|
||||
@@ -33,6 +33,7 @@ pub fn reset_test_fs() {
|
||||
".gemini",
|
||||
".config",
|
||||
".openclaw",
|
||||
"profiles",
|
||||
] {
|
||||
let path = home.join(sub);
|
||||
if path.exists() {
|
||||
|
||||
Reference in New Issue
Block a user