mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 05:38:38 +08:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a520b52d8f | |||
| c718dd703b | |||
| 8c8b265ebf | |||
| 8a26a091a9 | |||
| c2120dc8c3 | |||
| 69ab4a8a46 | |||
| cf945de997 | |||
| eeda9adb03 |
@@ -4,10 +4,10 @@
|
||||
|
||||
### The All-in-One Manager for Claude Code, Codex, Gemini CLI, OpenCode & OpenClaw
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
|
||||
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
@@ -100,11 +100,6 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
|
||||
<td>Thanks to CTok.ai for sponsoring this project! CTok.ai is dedicated to building a one-stop AI programming tool service platform. We offer professional Claude Code packages and technical community services, with support for Google Gemini and OpenAI Codex. Through carefully designed plans and a professional tech community, we provide developers with reliable service guarantees and continuous technical support, making AI-assisted programming a true productivity tool. Click <a href="https://ctok.ai">here</a> to register!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://chefshop.ai"><img src="assets/partners/logos/chefshop.png" alt="ChefShop" width="150"></a></td>
|
||||
<td>Thanks to ChefShop AI for sponsoring this project! ChefShop AI is a premium account service provider tailored for heavy AI subscription users. The platform offers official top-up and stable account services for mainstream large models including ChatGPT Plus/Pro, Claude Max, Grok Super/Heavy, and Gemini. Click <a href="https://chefshop.ai">here</a> to purchase!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
</details>
|
||||
|
||||
+2
-7
@@ -4,10 +4,10 @@
|
||||
|
||||
### Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw のオールインワン管理ツール
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
|
||||
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
@@ -100,11 +100,6 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
<td>CTok.ai のご支援に感謝します!CTok.ai はワンストップ AI プログラミングツールサービスプラットフォームの構築に取り組んでいます。Claude Code のプロフェッショナルプランと技術コミュニティサービスを提供し、Google Gemini や OpenAI Codex にも対応しています。丁寧に設計されたプランと専門的な技術コミュニティを通じて、開発者に安定したサービス保証と継続的な技術サポートを提供し、AI アシストプログラミングを真の生産性ツールにします。<a href="https://ctok.ai">こちら</a>から登録してください!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://chefshop.ai"><img src="assets/partners/logos/chefshop.png" alt="ChefShop" width="150"></a></td>
|
||||
<td>ChefShop AI のご支援に感謝します!ChefShop AI は、AI ヘビーユーザー向けにカスタマイズされたプレミアムアカウントサービスプロバイダーです。ChatGPT Plus/Pro、Claude Max、Grok Super/Heavy、Gemini など主流の大規模モデルの公式チャージと安定したアカウントサービスを提供しています。<a href="https://chefshop.ai">こちら</a>から購入してください!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
</details>
|
||||
|
||||
+2
-7
@@ -4,10 +4,10 @@
|
||||
|
||||
### Claude Code、Codex、Gemini CLI、OpenCode 和 OpenClaw 的全方位管理工具
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
|
||||
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
@@ -101,11 +101,6 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
<td>感谢 CTok.ai 赞助了本项目!CTok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击<a href="https://ctok.ai">这里</a>注册!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://chefshop.ai"><img src="assets/partners/logos/chefshop.png" alt="ChefShop" width="150"></a></td>
|
||||
<td>感谢 厨师长AI小铺 赞助了本项目!厨师长AI小铺 是一家专为 AI 重度订阅用户量身定制的优质账号服务商。平台提供涵盖 ChatGPT Plus/Pro、Claude Max、Grok Super/Heavy 以及 Gemini 等主流大模型的官方代充与稳定成品账号服务。点击<a href="https://chefshop.ai">这里</a>购买!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
</details>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 280 KiB |
Generated
+39
-90
@@ -137,6 +137,18 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1"
|
||||
dependencies = [
|
||||
"compression-codecs",
|
||||
"compression-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-executor"
|
||||
version = "1.14.0"
|
||||
@@ -312,28 +324,6 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.15.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e84ce723ab67259cfeb9877c6a639ee9eb7a27b28123abd71db7f0d5d0cc9d86"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43a442ece363113bd4bd4c8b18977a7798dd4d3c3383f34fb61936960e8f4ad8"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.7.9"
|
||||
@@ -506,17 +496,6 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "7.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc97b8f16f944bba54f0433f07e30be199b6dc2bd25937444bbad560bcea29bd"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
"brotli-decompressor 4.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "8.0.2"
|
||||
@@ -525,17 +504,7 @@ checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
"brotli-decompressor 5.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "4.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a334ef7c9e23abf0ce748e8cd309037da93e606ad52eb372e4ce327a0dcfbdfd"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
"brotli-decompressor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -722,19 +691,11 @@ dependencies = [
|
||||
"auto-launch",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"brotli 7.0.0",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"flate2",
|
||||
"futures",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"httparse",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"indexmap 2.13.0",
|
||||
"json-five",
|
||||
"json5",
|
||||
@@ -747,8 +708,6 @@ dependencies = [
|
||||
"rquickjs",
|
||||
"rusqlite",
|
||||
"rust_decimal",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
@@ -767,7 +726,6 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"toml 0.8.23",
|
||||
"toml_edit 0.22.27",
|
||||
"tower 0.4.13",
|
||||
@@ -775,7 +733,6 @@ dependencies = [
|
||||
"url",
|
||||
"uuid",
|
||||
"webkit2gtk",
|
||||
"webpki-roots 0.26.11",
|
||||
"winreg 0.52.0",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
@@ -843,15 +800,6 @@ dependencies = [
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.57"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -862,6 +810,23 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7"
|
||||
dependencies = [
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-core"
|
||||
version = "0.4.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d"
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@@ -1608,12 +1573,6 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "funty"
|
||||
version = "2.0.0"
|
||||
@@ -2247,14 +2206,12 @@ dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"log",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots 1.0.6",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4243,7 +4200,7 @@ dependencies = [
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.4.2",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.6",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4453,8 +4410,6 @@ version = "0.23.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -4518,7 +4473,6 @@ version = "0.103.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
@@ -4761,7 +4715,6 @@ version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
@@ -5372,7 +5325,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"brotli 8.0.2",
|
||||
"brotli",
|
||||
"ico",
|
||||
"json-patch",
|
||||
"plist",
|
||||
@@ -5660,7 +5613,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"brotli 8.0.2",
|
||||
"brotli",
|
||||
"cargo_metadata",
|
||||
"ctor",
|
||||
"dunce",
|
||||
@@ -6064,13 +6017,18 @@ version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"bitflags 2.11.0",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"iri-string",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower 0.5.3",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
@@ -6606,15 +6564,6 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.6"
|
||||
|
||||
+2
-14
@@ -23,7 +23,7 @@ test-hooks = []
|
||||
tauri-build = { version = "2.4.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = { version = "1.0", features = ["preserve_order"] }
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
@@ -38,9 +38,7 @@ tauri-plugin-deep-link = "2"
|
||||
dirs = "5.0"
|
||||
toml = "0.8"
|
||||
toml_edit = "0.22"
|
||||
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks"] }
|
||||
flate2 = "1"
|
||||
brotli = "7"
|
||||
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks", "gzip"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
|
||||
futures = "0.3"
|
||||
async-stream = "0.3"
|
||||
@@ -49,16 +47,6 @@ axum = "0.7"
|
||||
tower = "0.4"
|
||||
tower-http = { version = "0.5", features = ["cors"] }
|
||||
hyper = { version = "1.0", features = ["full"] }
|
||||
hyper-util = { version = "0.1", features = ["tokio", "http1", "client-legacy"] }
|
||||
hyper-rustls = { version = "0.27", features = ["http1", "tls12", "ring", "webpki-tokio"] }
|
||||
http = "1"
|
||||
http-body = "1"
|
||||
http-body-util = "0.1"
|
||||
httparse = "1"
|
||||
tokio-rustls = "0.26"
|
||||
rustls = "0.23"
|
||||
webpki-roots = "0.26"
|
||||
rustls-native-certs = "0.8"
|
||||
regex = "1.10"
|
||||
rquickjs = { version = "0.8", features = ["array-buffer", "classes"] }
|
||||
thiserror = "2.0"
|
||||
|
||||
@@ -141,7 +141,7 @@ pub fn read_and_validate_codex_config_text() -> Result<String, AppError> {
|
||||
///
|
||||
/// Supported fields:
|
||||
/// - `"base_url"`: writes to `[model_providers.<current>].base_url` if `model_provider` exists,
|
||||
/// otherwise falls back to top-level `base_url`.
|
||||
/// otherwise falls back to top-level `base_url`.
|
||||
/// - `"model"`: writes to top-level `model` field.
|
||||
///
|
||||
/// Empty value removes the field.
|
||||
|
||||
@@ -49,7 +49,7 @@ pub async fn copilot_poll_for_auth(
|
||||
Ok(false)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[CopilotAuth] 轮询失败: {e}");
|
||||
log::error!("[CopilotAuth] 轮询失败: {}", e);
|
||||
Err(e.to_string())
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ pub async fn copilot_poll_for_account(
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[CopilotAuth] 轮询失败: {e}");
|
||||
log::error!("[CopilotAuth] 轮询失败: {}", e);
|
||||
Err(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
#[tauri::command]
|
||||
pub fn enter_lightweight_mode(app: tauri::AppHandle) -> Result<(), String> {
|
||||
crate::lightweight::enter_lightweight_mode(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn exit_lightweight_mode(app: tauri::AppHandle) -> Result<(), String> {
|
||||
crate::lightweight::exit_lightweight_mode(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn is_lightweight_mode() -> bool {
|
||||
crate::lightweight::is_lightweight_mode()
|
||||
}
|
||||
+13
-188
@@ -6,7 +6,7 @@ use crate::services::ProviderService;
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
use tauri::AppHandle;
|
||||
use tauri::State;
|
||||
@@ -500,8 +500,7 @@ fn extend_from_path_list(
|
||||
|
||||
/// OpenCode install.sh 路径优先级(见 https://github.com/anomalyco/opencode README):
|
||||
/// $OPENCODE_INSTALL_DIR > $XDG_BIN_DIR > $HOME/bin > $HOME/.opencode/bin
|
||||
/// 额外扫描 Bun 默认全局安装路径(~/.bun/bin)
|
||||
/// 和 Go 安装路径(~/go/bin、$GOPATH/*/bin)。
|
||||
/// 额外扫描 Go 安装路径(~/go/bin、$GOPATH/*/bin)。
|
||||
fn opencode_extra_search_paths(
|
||||
home: &Path,
|
||||
opencode_install_dir: Option<std::ffi::OsString>,
|
||||
@@ -516,7 +515,6 @@ fn opencode_extra_search_paths(
|
||||
if !home.as_os_str().is_empty() {
|
||||
push_unique_path(&mut paths, home.join("bin"));
|
||||
push_unique_path(&mut paths, home.join(".opencode").join("bin"));
|
||||
push_unique_path(&mut paths, home.join(".bun").join("bin"));
|
||||
push_unique_path(&mut paths, home.join("go").join("bin"));
|
||||
}
|
||||
|
||||
@@ -720,10 +718,8 @@ pub async fn open_provider_terminal(
|
||||
state: State<'_, crate::store::AppState>,
|
||||
app: String,
|
||||
#[allow(non_snake_case)] providerId: String,
|
||||
cwd: Option<String>,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
let launch_cwd = resolve_launch_cwd(cwd)?;
|
||||
|
||||
// 获取提供商配置
|
||||
let providers = ProviderService::list(state.inner(), app_type.clone())
|
||||
@@ -738,8 +734,7 @@ pub async fn open_provider_terminal(
|
||||
let env_vars = extract_env_vars_from_config(config, &app_type);
|
||||
|
||||
// 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名
|
||||
launch_terminal_with_env(env_vars, &providerId, launch_cwd.as_deref())
|
||||
.map_err(|e| format!("启动终端失败: {e}"))?;
|
||||
launch_terminal_with_env(env_vars, &providerId).map_err(|e| format!("启动终端失败: {e}"))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
@@ -794,49 +789,11 @@ fn extract_env_vars_from_config(
|
||||
env_vars
|
||||
}
|
||||
|
||||
fn resolve_launch_cwd(cwd: Option<String>) -> Result<Option<PathBuf>, String> {
|
||||
let Some(raw_path) = cwd.filter(|value| !value.trim().is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if raw_path.contains('\n') || raw_path.contains('\r') {
|
||||
return Err("目录路径包含非法换行符".to_string());
|
||||
}
|
||||
|
||||
let path = Path::new(&raw_path);
|
||||
if !path.exists() {
|
||||
return Err(format!("目录不存在: {raw_path}"));
|
||||
}
|
||||
|
||||
let resolved = std::fs::canonicalize(path).map_err(|e| format!("解析目录失败: {e}"))?;
|
||||
if !resolved.is_dir() {
|
||||
return Err(format!("选择的路径不是文件夹: {}", resolved.display()));
|
||||
}
|
||||
|
||||
// Strip Windows extended-length prefix that canonicalize produces,
|
||||
// as it can break batch scripts and other shell commands.
|
||||
// Special-case \\?\UNC\server\share -> \\server\share for network/WSL paths.
|
||||
#[cfg(target_os = "windows")]
|
||||
let resolved = {
|
||||
let s = resolved.to_string_lossy();
|
||||
if let Some(unc) = s.strip_prefix(r"\\?\UNC\") {
|
||||
PathBuf::from(format!(r"\\{unc}"))
|
||||
} else if let Some(stripped) = s.strip_prefix(r"\\?\") {
|
||||
PathBuf::from(stripped)
|
||||
} else {
|
||||
resolved
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(resolved))
|
||||
}
|
||||
|
||||
/// 创建临时配置文件并启动 claude 终端
|
||||
/// 使用 --settings 参数传入提供商特定的 API 配置
|
||||
fn launch_terminal_with_env(
|
||||
env_vars: Vec<(String, String)>,
|
||||
provider_id: &str,
|
||||
cwd: Option<&Path>,
|
||||
) -> Result<(), String> {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let config_file = temp_dir.join(format!(
|
||||
@@ -850,19 +807,19 @@ fn launch_terminal_with_env(
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
launch_macos_terminal(&config_file, cwd)?;
|
||||
launch_macos_terminal(&config_file)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
launch_linux_terminal(&config_file, cwd)?;
|
||||
launch_linux_terminal(&config_file)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
launch_windows_terminal(&temp_dir, &config_file, cwd)?;
|
||||
launch_windows_terminal(&temp_dir, &config_file)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -892,7 +849,7 @@ fn write_claude_config(
|
||||
|
||||
/// macOS: 根据用户首选终端启动
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch_macos_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> {
|
||||
fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let preferred = crate::settings::get_preferred_terminal();
|
||||
@@ -901,21 +858,18 @@ fn launch_macos_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);
|
||||
|
||||
// Write the shell script to a temp file
|
||||
let script_content = format!(
|
||||
r#"#!/bin/bash
|
||||
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
|
||||
"#,
|
||||
config_path = config_path,
|
||||
script_file = script_file.display(),
|
||||
cd_command = cd_command,
|
||||
script_file = script_file.display()
|
||||
);
|
||||
|
||||
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
|
||||
@@ -1051,7 +1005,7 @@ fn launch_macos_open_app(
|
||||
|
||||
/// Linux: 根据用户首选终端启动
|
||||
#[cfg(target_os = "linux")]
|
||||
fn launch_linux_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> {
|
||||
fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::process::Command;
|
||||
|
||||
@@ -1073,20 +1027,17 @@ 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 script_content = format!(
|
||||
r#"#!/bin/bash
|
||||
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
|
||||
"#,
|
||||
config_path = config_path,
|
||||
script_file = script_file.display(),
|
||||
cd_command = cd_command,
|
||||
script_file = script_file.display()
|
||||
);
|
||||
|
||||
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
|
||||
@@ -1165,28 +1116,22 @@ fn which_command(cmd: &str) -> bool {
|
||||
fn launch_windows_terminal(
|
||||
temp_dir: &std::path::Path,
|
||||
config_file: &std::path::Path,
|
||||
cwd: Option<&Path>,
|
||||
) -> Result<(), String> {
|
||||
let preferred = crate::settings::get_preferred_terminal();
|
||||
let terminal = preferred.as_deref().unwrap_or("cmd");
|
||||
|
||||
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id()));
|
||||
let config_path_for_batch = escape_windows_batch_value(&config_file.to_string_lossy());
|
||||
let cwd_command = build_windows_cwd_command(cwd);
|
||||
let config_path_for_batch = config_file.to_string_lossy().replace('&', "^&");
|
||||
|
||||
let content = format!(
|
||||
"@echo off
|
||||
{cwd_command}
|
||||
echo Using provider-specific claude config:
|
||||
echo {}
|
||||
claude --settings \"{}\"
|
||||
del \"{}\" >nul 2>&1
|
||||
del \"%~f0\" >nul 2>&1
|
||||
",
|
||||
config_path_for_batch,
|
||||
config_path_for_batch,
|
||||
config_path_for_batch,
|
||||
cwd_command = cwd_command,
|
||||
config_path_for_batch, config_path_for_batch, config_path_for_batch
|
||||
);
|
||||
|
||||
std::fs::write(&bat_file, &content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
|
||||
@@ -1217,55 +1162,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('\'', "'\"'\"'"))
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn is_windows_unc_path(path: &str) -> bool {
|
||||
path.starts_with(r"\\")
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn build_windows_cwd_command_str(path: &str) -> String {
|
||||
let escaped = escape_windows_batch_value(path);
|
||||
|
||||
if is_windows_unc_path(path) {
|
||||
// `cmd.exe` cannot make a UNC path current via `cd`; `pushd` maps it first.
|
||||
format!("pushd \"{escaped}\" || exit /b 1\r\n")
|
||||
} else {
|
||||
format!("cd /d \"{escaped}\" || exit /b 1\r\n")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn build_windows_cwd_command(cwd: Option<&Path>) -> String {
|
||||
cwd.map(|dir| build_windows_cwd_command_str(&dir.to_string_lossy()))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn escape_windows_batch_value(value: &str) -> String {
|
||||
value
|
||||
.replace('^', "^^")
|
||||
.replace('%', "%%")
|
||||
.replace('&', "^&")
|
||||
.replace('|', "^|")
|
||||
.replace('<', "^<")
|
||||
.replace('>', "^>")
|
||||
.replace('(', "^(")
|
||||
.replace(')', "^)")
|
||||
}
|
||||
/// Windows: Run a start command with common error handling
|
||||
#[cfg(target_os = "windows")]
|
||||
fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), String> {
|
||||
@@ -1384,7 +1280,6 @@ mod tests {
|
||||
assert_eq!(paths[1], PathBuf::from("/xdg/bin"));
|
||||
assert!(paths.contains(&PathBuf::from("/home/tester/bin")));
|
||||
assert!(paths.contains(&PathBuf::from("/home/tester/.opencode/bin")));
|
||||
assert!(paths.contains(&PathBuf::from("/home/tester/.bun/bin")));
|
||||
assert!(paths.contains(&PathBuf::from("/home/tester/go/bin")));
|
||||
assert!(paths.contains(&PathBuf::from("/go/path1/bin")));
|
||||
assert!(paths.contains(&PathBuf::from("/go/path2/bin")));
|
||||
@@ -1395,7 +1290,7 @@ mod tests {
|
||||
let home = PathBuf::from("/home/tester");
|
||||
let same_dir = Some(std::ffi::OsString::from("/same/path"));
|
||||
|
||||
let paths = opencode_extra_search_paths(&home, same_dir.clone(), same_dir, None);
|
||||
let paths = opencode_extra_search_paths(&home, same_dir.clone(), same_dir.clone(), None);
|
||||
|
||||
let count = paths
|
||||
.iter()
|
||||
@@ -1404,18 +1299,6 @@ mod tests {
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_extra_search_paths_deduplicates_bun_default_dir() {
|
||||
let home = PathBuf::from("/home/tester");
|
||||
let paths = opencode_extra_search_paths(&home, None, None, None);
|
||||
|
||||
let count = paths
|
||||
.iter()
|
||||
.filter(|path| **path == PathBuf::from("/home/tester/.bun/bin"))
|
||||
.count();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[test]
|
||||
fn tool_executable_candidates_non_windows_uses_plain_binary_name() {
|
||||
@@ -1440,62 +1323,4 @@ mod tests {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_launch_cwd_accepts_existing_directory() {
|
||||
let resolved =
|
||||
resolve_launch_cwd(Some(std::env::temp_dir().to_string_lossy().into_owned()))
|
||||
.expect("temp dir should resolve")
|
||||
.expect("temp dir should be present");
|
||||
|
||||
assert!(resolved.is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_launch_cwd_rejects_missing_directory() {
|
||||
let unique = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("clock should be after epoch")
|
||||
.as_nanos();
|
||||
let missing = std::env::temp_dir().join(format!("cc-switch-missing-{unique}"));
|
||||
|
||||
let error = resolve_launch_cwd(Some(missing.to_string_lossy().into_owned()))
|
||||
.expect_err("missing directory should fail");
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_windows_cwd_command_str_uses_cd_for_drive_paths() {
|
||||
let command = build_windows_cwd_command_str(r"C:\work\repo");
|
||||
|
||||
assert_eq!(command, "cd /d \"C:\\work\\repo\" || exit /b 1\r\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_windows_cwd_command_str_uses_pushd_for_unc_paths() {
|
||||
let command = build_windows_cwd_command_str(r"\\wsl$\Ubuntu\home\coder\repo");
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
"pushd \"\\\\wsl$\\Ubuntu\\home\\coder\\repo\" || exit /b 1\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_windows_cwd_command_str_escapes_batch_metacharacters() {
|
||||
let command = build_windows_cwd_command_str(r"\\server\share\100%&(test)");
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
"pushd \"\\\\server\\share\\100%%^&^(test^)\" || exit /b 1\r\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ pub mod skill;
|
||||
mod stream_check;
|
||||
mod sync_support;
|
||||
|
||||
mod lightweight;
|
||||
mod usage;
|
||||
mod webdav_sync;
|
||||
mod workspace;
|
||||
@@ -48,7 +47,6 @@ pub use settings::*;
|
||||
pub use skill::*;
|
||||
pub use stream_check::*;
|
||||
|
||||
pub use lightweight::*;
|
||||
pub use usage::*;
|
||||
pub use webdav_sync::*;
|
||||
pub use workspace::*;
|
||||
|
||||
@@ -159,7 +159,7 @@ pub async fn queryProviderUsage(
|
||||
let providers = state
|
||||
.db
|
||||
.get_all_providers(app_type.as_str())
|
||||
.map_err(|e| format!("Failed to get providers: {e}"))?;
|
||||
.map_err(|e| format!("Failed to get providers: {}", e))?;
|
||||
|
||||
let provider = providers.get(&providerId);
|
||||
let is_copilot = provider
|
||||
@@ -182,11 +182,11 @@ pub async fn queryProviderUsage(
|
||||
Some(account_id) => auth_manager
|
||||
.fetch_usage_for_account(account_id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch Copilot usage: {e}"))?,
|
||||
.map_err(|e| format!("Failed to fetch Copilot usage: {}", e))?,
|
||||
None => auth_manager
|
||||
.fetch_usage()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch Copilot usage: {e}"))?,
|
||||
.map_err(|e| format!("Failed to fetch Copilot usage: {}", e))?,
|
||||
};
|
||||
let premium = &usage.quota_snapshots.premium_interactions;
|
||||
let used = premium.entitlement - premium.remaining;
|
||||
|
||||
@@ -74,12 +74,3 @@ pub async fn delete_session(
|
||||
.await
|
||||
.map_err(|e| format!("Failed to delete session: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_sessions(
|
||||
items: Vec<session_manager::DeleteSessionRequest>,
|
||||
) -> Result<Vec<session_manager::DeleteSessionOutcome>, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || session_manager::delete_sessions(&items))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to delete sessions: {e}"))
|
||||
}
|
||||
|
||||
@@ -26,22 +26,8 @@ pub async fn stream_check_provider(
|
||||
.ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?;
|
||||
|
||||
let auth_override = resolve_copilot_auth_override(provider, &copilot_state).await?;
|
||||
let claude_api_format_override = resolve_claude_api_format_override(
|
||||
&app_type,
|
||||
provider,
|
||||
&config,
|
||||
&copilot_state,
|
||||
auth_override.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
let result = StreamCheckService::check_with_retry(
|
||||
&app_type,
|
||||
provider,
|
||||
&config,
|
||||
auth_override,
|
||||
claude_api_format_override,
|
||||
)
|
||||
.await?;
|
||||
let result =
|
||||
StreamCheckService::check_with_retry(&app_type, provider, &config, auth_override).await?;
|
||||
|
||||
// 记录日志
|
||||
let _ =
|
||||
@@ -87,40 +73,19 @@ pub async fn stream_check_all_providers(
|
||||
}
|
||||
|
||||
let auth_override = resolve_copilot_auth_override(&provider, &copilot_state).await?;
|
||||
let claude_api_format_override = resolve_claude_api_format_override(
|
||||
&app_type,
|
||||
&provider,
|
||||
&config,
|
||||
&copilot_state,
|
||||
auth_override.as_ref(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!(
|
||||
"[StreamCheck] Failed to resolve Claude API format override for {}: {}",
|
||||
provider.id,
|
||||
e
|
||||
);
|
||||
None
|
||||
});
|
||||
let result = StreamCheckService::check_with_retry(
|
||||
&app_type,
|
||||
&provider,
|
||||
&config,
|
||||
auth_override,
|
||||
claude_api_format_override,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|e| StreamCheckResult {
|
||||
status: HealthStatus::Failed,
|
||||
success: false,
|
||||
message: e.to_string(),
|
||||
response_time_ms: None,
|
||||
http_status: None,
|
||||
model_used: String::new(),
|
||||
tested_at: chrono::Utc::now().timestamp(),
|
||||
retry_count: 0,
|
||||
});
|
||||
let result =
|
||||
StreamCheckService::check_with_retry(&app_type, &provider, &config, auth_override)
|
||||
.await
|
||||
.unwrap_or_else(|e| StreamCheckResult {
|
||||
status: HealthStatus::Failed,
|
||||
success: false,
|
||||
message: e.to_string(),
|
||||
response_time_ms: None,
|
||||
http_status: None,
|
||||
model_used: String::new(),
|
||||
tested_at: chrono::Utc::now().timestamp(),
|
||||
retry_count: 0,
|
||||
});
|
||||
|
||||
let _ = state
|
||||
.db
|
||||
@@ -189,51 +154,3 @@ async fn resolve_copilot_auth_override(
|
||||
crate::proxy::providers::AuthStrategy::GitHubCopilot,
|
||||
)))
|
||||
}
|
||||
|
||||
async fn resolve_claude_api_format_override(
|
||||
app_type: &AppType,
|
||||
provider: &crate::provider::Provider,
|
||||
config: &StreamCheckConfig,
|
||||
copilot_state: &State<'_, CopilotAuthState>,
|
||||
auth_override: Option<&crate::proxy::providers::AuthInfo>,
|
||||
) -> Result<Option<String>, AppError> {
|
||||
if *app_type != AppType::Claude {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let is_copilot = auth_override
|
||||
.map(|auth| auth.strategy == crate::proxy::providers::AuthStrategy::GitHubCopilot)
|
||||
.unwrap_or(false);
|
||||
if !is_copilot {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let model_id = StreamCheckService::resolve_effective_test_model(app_type, provider, config);
|
||||
let auth_manager = copilot_state.0.read().await;
|
||||
let account_id = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.managed_account_id_for("github_copilot"));
|
||||
|
||||
let vendor_result = match account_id.as_deref() {
|
||||
Some(id) => {
|
||||
auth_manager
|
||||
.get_model_vendor_for_account(id, &model_id)
|
||||
.await
|
||||
}
|
||||
None => auth_manager.get_model_vendor(&model_id).await,
|
||||
};
|
||||
|
||||
let api_format = match vendor_result {
|
||||
Ok(Some(vendor)) if vendor.eq_ignore_ascii_case("openai") => "openai_responses",
|
||||
Ok(Some(_)) | Ok(None) => "openai_chat",
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"[StreamCheck] Failed to resolve Copilot model vendor for {model_id}: {err}. Falling back to chat/completions"
|
||||
);
|
||||
"openai_chat"
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(api_format.to_string()))
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ mod error;
|
||||
mod gemini_config;
|
||||
mod gemini_mcp;
|
||||
mod init_status;
|
||||
mod lightweight;
|
||||
mod mcp;
|
||||
mod openclaw_config;
|
||||
mod opencode_config;
|
||||
@@ -205,12 +204,6 @@ pub fn run() {
|
||||
log::debug!(" arg[{i}]: {}", redact_url_for_log(arg));
|
||||
}
|
||||
|
||||
if crate::lightweight::is_lightweight_mode() {
|
||||
if let Err(e) = crate::lightweight::exit_lightweight_mode(app) {
|
||||
log::error!("退出轻量模式重建窗口失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Check for deep link URL in args (mainly for Windows/Linux command line)
|
||||
let mut found_deeplink = false;
|
||||
for arg in &args {
|
||||
@@ -622,12 +615,6 @@ pub fn run() {
|
||||
let urls = event.urls();
|
||||
log::info!("Received {} URL(s)", urls.len());
|
||||
|
||||
if crate::lightweight::is_lightweight_mode() {
|
||||
if let Err(e) = crate::lightweight::exit_lightweight_mode(&app_handle) {
|
||||
log::error!("退出轻量模式重建窗口失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
for (i, url) in urls.iter().enumerate() {
|
||||
let url_str = url.as_str();
|
||||
log::debug!(" URL[{i}]: {}", redact_url_for_log(url_str));
|
||||
@@ -1021,7 +1008,6 @@ pub fn run() {
|
||||
commands::list_sessions,
|
||||
commands::get_session_messages,
|
||||
commands::delete_session,
|
||||
commands::delete_sessions,
|
||||
commands::launch_session_terminal,
|
||||
commands::get_tool_versions,
|
||||
// Provider terminal
|
||||
@@ -1099,10 +1085,6 @@ pub fn run() {
|
||||
commands::delete_daily_memory_file,
|
||||
commands::search_daily_memory_files,
|
||||
commands::open_workspace_directory,
|
||||
// lightweight mode (for testing or low-resource environments)
|
||||
commands::enter_lightweight_mode,
|
||||
commands::exit_lightweight_mode,
|
||||
commands::is_lightweight_mode,
|
||||
]);
|
||||
|
||||
let app = builder
|
||||
@@ -1153,10 +1135,6 @@ pub fn run() {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
tray::apply_tray_policy(app_handle, true);
|
||||
} else if crate::lightweight::is_lightweight_mode() {
|
||||
if let Err(e) = crate::lightweight::exit_lightweight_mode(app_handle) {
|
||||
log::error!("退出轻量模式重建窗口失败: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://...)
|
||||
@@ -1166,13 +1144,6 @@ pub fn run() {
|
||||
log::info!("RunEvent::Opened with URL: {url_str}");
|
||||
|
||||
if url_str.starts_with("ccswitch://") {
|
||||
if crate::lightweight::is_lightweight_mode() {
|
||||
if let Err(e) = crate::lightweight::exit_lightweight_mode(app_handle)
|
||||
{
|
||||
log::error!("退出轻量模式重建窗口失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// 解析并广播深链接事件,复用与 single_instance 相同的逻辑
|
||||
match crate::deeplink::parse_deeplink_url(&url_str) {
|
||||
Ok(request) => {
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
static LIGHTWEIGHT_MODE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub fn enter_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_skip_taskbar(true);
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
crate::tray::apply_tray_policy(app, false);
|
||||
}
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
window
|
||||
.destroy()
|
||||
.map_err(|e| format!("销毁主窗口失败: {e}"))?;
|
||||
}
|
||||
// else: already in lightweight mode or window not found, just set the flag
|
||||
|
||||
LIGHTWEIGHT_MODE.store(true, Ordering::Release);
|
||||
crate::tray::refresh_tray_menu(app);
|
||||
log::info!("进入轻量模式");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn exit_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
use tauri::WebviewWindowBuilder;
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = window.set_skip_taskbar(false);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
crate::tray::apply_tray_policy(app, true);
|
||||
}
|
||||
LIGHTWEIGHT_MODE.store(false, Ordering::Release);
|
||||
crate::tray::refresh_tray_menu(app);
|
||||
log::info!("退出轻量模式");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let window_config = app
|
||||
.config()
|
||||
.app
|
||||
.windows
|
||||
.iter()
|
||||
.find(|w| w.label == "main")
|
||||
.ok_or("主窗口配置未找到")?;
|
||||
|
||||
WebviewWindowBuilder::from_config(app, window_config)
|
||||
.map_err(|e| format!("加载主窗口配置失败: {e}"))?
|
||||
.visible(true)
|
||||
.build()
|
||||
.map_err(|e| format!("创建主窗口失败: {e}"))?;
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_skip_taskbar(false);
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
crate::tray::apply_tray_policy(app, true);
|
||||
}
|
||||
|
||||
LIGHTWEIGHT_MODE.store(false, Ordering::Release);
|
||||
crate::tray::refresh_tray_menu(app);
|
||||
log::info!("退出轻量模式");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_lightweight_mode() -> bool {
|
||||
LIGHTWEIGHT_MODE.load(Ordering::Acquire)
|
||||
}
|
||||
@@ -6,32 +6,6 @@ use indexmap::IndexMap;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::path::PathBuf;
|
||||
|
||||
const STANDARD_OMO_PLUGIN_PREFIXES: [&str; 2] = ["oh-my-openagent", "oh-my-opencode"];
|
||||
const SLIM_OMO_PLUGIN_PREFIXES: [&str; 1] = ["oh-my-opencode-slim"];
|
||||
|
||||
fn matches_plugin_prefix(plugin_name: &str, prefix: &str) -> bool {
|
||||
plugin_name == prefix
|
||||
|| plugin_name
|
||||
.strip_prefix(prefix)
|
||||
.map(|suffix| suffix.starts_with('@'))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn matches_any_plugin_prefix(plugin_name: &str, prefixes: &[&str]) -> bool {
|
||||
prefixes
|
||||
.iter()
|
||||
.any(|prefix| matches_plugin_prefix(plugin_name, prefix))
|
||||
}
|
||||
|
||||
fn canonicalize_plugin_name(plugin_name: &str) -> String {
|
||||
if let Some(suffix) = plugin_name.strip_prefix("oh-my-opencode") {
|
||||
if suffix.is_empty() || suffix.starts_with('@') {
|
||||
return format!("oh-my-openagent{suffix}");
|
||||
}
|
||||
}
|
||||
plugin_name.to_string()
|
||||
}
|
||||
|
||||
pub fn get_opencode_dir() -> PathBuf {
|
||||
if let Some(override_dir) = get_opencode_override_dir() {
|
||||
return override_dir;
|
||||
@@ -166,56 +140,58 @@ pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
|
||||
|
||||
pub fn add_plugin(plugin_name: &str) -> Result<(), AppError> {
|
||||
let mut config = read_opencode_config()?;
|
||||
let normalized_plugin_name = canonicalize_plugin_name(plugin_name);
|
||||
|
||||
let plugins = config.get_mut("plugin").and_then(|v| v.as_array_mut());
|
||||
|
||||
match plugins {
|
||||
Some(arr) => {
|
||||
// Mutual exclusion: standard OMO and OMO Slim cannot coexist as plugins
|
||||
if matches_any_plugin_prefix(&normalized_plugin_name, &STANDARD_OMO_PLUGIN_PREFIXES) {
|
||||
if plugin_name.starts_with("oh-my-opencode")
|
||||
&& !plugin_name.starts_with("oh-my-opencode-slim")
|
||||
{
|
||||
// Adding standard OMO -> remove all Slim variants
|
||||
arr.retain(|v| {
|
||||
v.as_str()
|
||||
.map(|s| {
|
||||
!matches_any_plugin_prefix(s, &STANDARD_OMO_PLUGIN_PREFIXES)
|
||||
&& !matches_any_plugin_prefix(s, &SLIM_OMO_PLUGIN_PREFIXES)
|
||||
})
|
||||
.map(|s| !s.starts_with("oh-my-opencode-slim"))
|
||||
.unwrap_or(true)
|
||||
});
|
||||
} else if matches_any_plugin_prefix(&normalized_plugin_name, &SLIM_OMO_PLUGIN_PREFIXES)
|
||||
{
|
||||
} else if plugin_name.starts_with("oh-my-opencode-slim") {
|
||||
// Adding Slim -> remove all standard OMO variants (but keep slim)
|
||||
arr.retain(|v| {
|
||||
v.as_str()
|
||||
.map(|s| {
|
||||
!matches_any_plugin_prefix(s, &STANDARD_OMO_PLUGIN_PREFIXES)
|
||||
&& !matches_any_plugin_prefix(s, &SLIM_OMO_PLUGIN_PREFIXES)
|
||||
!s.starts_with("oh-my-opencode") || s.starts_with("oh-my-opencode-slim")
|
||||
})
|
||||
.unwrap_or(true)
|
||||
});
|
||||
}
|
||||
|
||||
let already_exists = arr
|
||||
.iter()
|
||||
.any(|v| v.as_str() == Some(normalized_plugin_name.as_str()));
|
||||
let already_exists = arr.iter().any(|v| v.as_str() == Some(plugin_name));
|
||||
if !already_exists {
|
||||
arr.push(Value::String(normalized_plugin_name));
|
||||
arr.push(Value::String(plugin_name.to_string()));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
config["plugin"] = json!([normalized_plugin_name]);
|
||||
config["plugin"] = json!([plugin_name]);
|
||||
}
|
||||
}
|
||||
|
||||
write_opencode_config(&config)
|
||||
}
|
||||
|
||||
pub fn remove_plugins_by_prefixes(prefixes: &[&str]) -> Result<(), AppError> {
|
||||
pub fn remove_plugin_by_prefix(prefix: &str) -> Result<(), AppError> {
|
||||
let mut config = read_opencode_config()?;
|
||||
|
||||
if let Some(arr) = config.get_mut("plugin").and_then(|v| v.as_array_mut()) {
|
||||
arr.retain(|v| {
|
||||
v.as_str()
|
||||
.map(|s| !matches_any_plugin_prefix(s, prefixes))
|
||||
.map(|s| {
|
||||
if !s.starts_with(prefix) {
|
||||
return true; // Keep: doesn't match prefix at all
|
||||
}
|
||||
let rest = &s[prefix.len()..];
|
||||
rest.starts_with('-')
|
||||
})
|
||||
.unwrap_or(true)
|
||||
});
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ pub enum ProxyError {
|
||||
StreamIdleTimeout(u64),
|
||||
|
||||
/// 认证错误
|
||||
#[allow(dead_code)]
|
||||
#[error("认证失败: {0}")]
|
||||
AuthError(String),
|
||||
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
//!
|
||||
//! 处理故障转移成功后的供应商切换逻辑,包括:
|
||||
//! - 去重控制(避免多个请求同时触发)
|
||||
//! - 数据库更新
|
||||
//! - 托盘菜单更新
|
||||
//! - 前端事件发射
|
||||
//! - Live 备份更新
|
||||
|
||||
use crate::database::Database;
|
||||
use crate::error::AppError;
|
||||
use std::collections::HashSet;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tauri::{Emitter, Manager};
|
||||
use tokio::sync::RwLock;
|
||||
@@ -95,21 +98,30 @@ impl FailoverSwitchManager {
|
||||
|
||||
log::info!("[FO-001] 切换: {app_type} → {provider_name}");
|
||||
|
||||
let mut switched = false;
|
||||
// 1. 更新数据库 is_current
|
||||
self.db.set_current_provider(app_type, provider_id)?;
|
||||
|
||||
// 2. 更新本地 settings(设备级)
|
||||
let app_type_enum = crate::app_config::AppType::from_str(app_type)
|
||||
.map_err(|_| AppError::Message(format!("无效的应用类型: {app_type}")))?;
|
||||
crate::settings::set_current_provider(&app_type_enum, Some(provider_id))?;
|
||||
|
||||
// 3. 更新托盘菜单和发射事件
|
||||
if let Some(app) = app_handle {
|
||||
// 更新托盘菜单
|
||||
if let Some(app_state) = app.try_state::<crate::store::AppState>() {
|
||||
switched = app_state
|
||||
.proxy_service
|
||||
.hot_switch_provider(app_type, provider_id)
|
||||
.await
|
||||
.map_err(AppError::Message)?
|
||||
.logical_target_changed;
|
||||
|
||||
if !switched {
|
||||
return Ok(false);
|
||||
// 更新 Live 备份(确保代理停止时恢复正确配置)
|
||||
if let Ok(Some(provider)) = self.db.get_provider_by_id(provider_id, app_type) {
|
||||
if let Err(e) = app_state
|
||||
.proxy_service
|
||||
.update_live_backup_from_provider(app_type, &provider)
|
||||
.await
|
||||
{
|
||||
log::warn!("[FO-003] Live 备份更新失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// 重建托盘菜单
|
||||
if let Ok(new_menu) = crate::tray::create_tray_menu(app, app_state.inner()) {
|
||||
if let Some(tray) = app.tray_by_id("main") {
|
||||
if let Err(e) = tray.set_menu(Some(new_menu)) {
|
||||
@@ -130,6 +142,6 @@ impl FailoverSwitchManager {
|
||||
}
|
||||
}
|
||||
|
||||
Ok(switched)
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
+172
-396
@@ -2,7 +2,6 @@
|
||||
//!
|
||||
//! 负责将请求转发到上游Provider,支持故障转移
|
||||
|
||||
use super::hyper_client::ProxyResponse;
|
||||
use super::{
|
||||
body_filter::filter_private_params_with_whitelist,
|
||||
error::*,
|
||||
@@ -20,16 +19,68 @@ use super::{
|
||||
use crate::commands::CopilotAuthState;
|
||||
use crate::proxy::providers::copilot_auth::CopilotAuthManager;
|
||||
use crate::{app_config::AppType, provider::Provider};
|
||||
use http::Extensions;
|
||||
use reqwest::Response;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tauri::Manager;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Headers 黑名单 - 不透传到上游的 Headers
|
||||
///
|
||||
/// 精简版黑名单,只过滤必须覆盖或可能导致问题的 header
|
||||
/// 参考成功透传的请求,保留更多原始 header
|
||||
///
|
||||
/// 注意:客户端 IP 类(x-forwarded-for, x-real-ip)默认透传
|
||||
const HEADER_BLACKLIST: &[&str] = &[
|
||||
// 认证类(会被覆盖)
|
||||
"authorization",
|
||||
"x-api-key",
|
||||
"x-goog-api-key",
|
||||
// 连接类(由 HTTP 客户端管理)
|
||||
"host",
|
||||
"content-length",
|
||||
"transfer-encoding",
|
||||
// 编码类(会被覆盖为 identity)
|
||||
"accept-encoding",
|
||||
// 代理转发类(保留 x-forwarded-for 和 x-real-ip)
|
||||
"x-forwarded-host",
|
||||
"x-forwarded-port",
|
||||
"x-forwarded-proto",
|
||||
"forwarded",
|
||||
// CDN/云服务商特定头
|
||||
"cf-connecting-ip",
|
||||
"cf-ipcountry",
|
||||
"cf-ray",
|
||||
"cf-visitor",
|
||||
"true-client-ip",
|
||||
"fastly-client-ip",
|
||||
"x-azure-clientip",
|
||||
"x-azure-fdid",
|
||||
"x-azure-ref",
|
||||
"akamai-origin-hop",
|
||||
"x-akamai-config-log-detail",
|
||||
// 请求追踪类
|
||||
"x-request-id",
|
||||
"x-correlation-id",
|
||||
"x-trace-id",
|
||||
"x-amzn-trace-id",
|
||||
"x-b3-traceid",
|
||||
"x-b3-spanid",
|
||||
"x-b3-parentspanid",
|
||||
"x-b3-sampled",
|
||||
"traceparent",
|
||||
"tracestate",
|
||||
// anthropic 特定头单独处理,避免重复
|
||||
"anthropic-beta",
|
||||
"anthropic-version",
|
||||
// 客户端 IP 单独处理(默认透传)
|
||||
"x-forwarded-for",
|
||||
"x-real-ip",
|
||||
];
|
||||
|
||||
pub struct ForwardResult {
|
||||
pub response: ProxyResponse,
|
||||
pub response: Response,
|
||||
pub provider: Provider,
|
||||
pub claude_api_format: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ForwardError {
|
||||
@@ -98,7 +149,6 @@ impl RequestForwarder {
|
||||
endpoint: &str,
|
||||
body: Value,
|
||||
headers: axum::http::HeaderMap,
|
||||
extensions: Extensions,
|
||||
providers: Vec<Provider>,
|
||||
) -> Result<ForwardResult, ForwardError> {
|
||||
// 获取适配器
|
||||
@@ -175,12 +225,11 @@ impl RequestForwarder {
|
||||
endpoint,
|
||||
&provider_body,
|
||||
&headers,
|
||||
&extensions,
|
||||
adapter.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((response, claude_api_format)) => {
|
||||
Ok(response) => {
|
||||
// 成功:记录成功并更新熔断器
|
||||
let _ = self
|
||||
.router
|
||||
@@ -234,7 +283,6 @@ impl RequestForwarder {
|
||||
return Ok(ForwardResult {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
claude_api_format,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -305,12 +353,11 @@ impl RequestForwarder {
|
||||
endpoint,
|
||||
&provider_body,
|
||||
&headers,
|
||||
&extensions,
|
||||
adapter.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((response, claude_api_format)) => {
|
||||
Ok(response) => {
|
||||
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
|
||||
// 记录成功
|
||||
let _ = self
|
||||
@@ -369,7 +416,6 @@ impl RequestForwarder {
|
||||
return Ok(ForwardResult {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
claude_api_format,
|
||||
});
|
||||
}
|
||||
Err(retry_err) => {
|
||||
@@ -504,12 +550,11 @@ impl RequestForwarder {
|
||||
endpoint,
|
||||
&provider_body,
|
||||
&headers,
|
||||
&extensions,
|
||||
adapter.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((response, claude_api_format)) => {
|
||||
Ok(response) => {
|
||||
log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功");
|
||||
let _ = self
|
||||
.router
|
||||
@@ -561,7 +606,6 @@ impl RequestForwarder {
|
||||
return Ok(ForwardResult {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
claude_api_format,
|
||||
});
|
||||
}
|
||||
Err(retry_err) => {
|
||||
@@ -743,25 +787,20 @@ impl RequestForwarder {
|
||||
endpoint: &str,
|
||||
body: &Value,
|
||||
headers: &axum::http::HeaderMap,
|
||||
extensions: &Extensions,
|
||||
adapter: &dyn ProviderAdapter,
|
||||
) -> Result<(ProxyResponse, Option<String>), ProxyError> {
|
||||
) -> Result<Response, ProxyError> {
|
||||
// 使用适配器提取 base_url
|
||||
let base_url = adapter.extract_base_url(provider)?;
|
||||
|
||||
// 检查是否需要格式转换
|
||||
let needs_transform = adapter.needs_transform(provider);
|
||||
|
||||
let is_full_url = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.is_full_url)
|
||||
.unwrap_or(false);
|
||||
|
||||
// 应用模型映射(独立于格式转换)
|
||||
let (mapped_body, _original_model, _mapped_model) =
|
||||
super::model_mapper::apply_model_mapping(body.clone(), provider);
|
||||
|
||||
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
|
||||
let mapped_body = normalize_thinking_type(mapped_body);
|
||||
|
||||
// 确定有效端点
|
||||
// GitHub Copilot API 使用 /chat/completions(无 /v1 前缀)
|
||||
let is_copilot = provider
|
||||
@@ -770,23 +809,9 @@ impl RequestForwarder {
|
||||
.and_then(|m| m.provider_type.as_deref())
|
||||
== Some("github_copilot")
|
||||
|| base_url.contains("githubcopilot.com");
|
||||
let resolved_claude_api_format = if adapter.name() == "Claude" {
|
||||
Some(
|
||||
self.resolve_claude_api_format(provider, &mapped_body, is_copilot)
|
||||
.await,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let needs_transform = match resolved_claude_api_format.as_deref() {
|
||||
Some(api_format) => super::providers::claude_api_format_needs_transform(api_format),
|
||||
None => adapter.needs_transform(provider),
|
||||
};
|
||||
let (effective_endpoint, passthrough_query) =
|
||||
if needs_transform && adapter.name() == "Claude" {
|
||||
let api_format = resolved_claude_api_format
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| super::providers::get_claude_api_format(provider));
|
||||
let api_format = super::providers::get_claude_api_format(provider);
|
||||
rewrite_claude_transform_endpoint(endpoint, api_format, is_copilot)
|
||||
} else {
|
||||
(
|
||||
@@ -803,20 +828,16 @@ impl RequestForwarder {
|
||||
adapter.build_url(&base_url, &effective_endpoint)
|
||||
};
|
||||
|
||||
// 应用模型映射(独立于格式转换)
|
||||
let (mapped_body, _original_model, _mapped_model) =
|
||||
super::model_mapper::apply_model_mapping(body.clone(), provider);
|
||||
|
||||
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
|
||||
let mapped_body = normalize_thinking_type(mapped_body);
|
||||
|
||||
// 转换请求体(如果需要)
|
||||
let request_body = if needs_transform {
|
||||
if adapter.name() == "Claude" {
|
||||
let api_format = resolved_claude_api_format
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| super::providers::get_claude_api_format(provider));
|
||||
super::providers::transform_claude_request_for_api_format(
|
||||
mapped_body,
|
||||
provider,
|
||||
api_format,
|
||||
)?
|
||||
} else {
|
||||
adapter.transform_request(mapped_body, provider)?
|
||||
}
|
||||
adapter.transform_request(mapped_body, provider)?
|
||||
} else {
|
||||
mapped_body
|
||||
};
|
||||
@@ -824,11 +845,87 @@ impl RequestForwarder {
|
||||
// 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游
|
||||
// 默认使用空白名单,过滤所有 _ 前缀字段
|
||||
let filtered_body = filter_private_params_with_whitelist(request_body, &[]);
|
||||
let force_identity_encoding = needs_transform
|
||||
|| should_force_identity_encoding(&effective_endpoint, &filtered_body, headers);
|
||||
|
||||
// 获取认证头(提前准备,用于内联替换)
|
||||
let auth_headers = if let Some(mut auth) = adapter.extract_auth(provider) {
|
||||
// 获取 HTTP 客户端:优先使用供应商单独代理配置,否则使用全局客户端
|
||||
let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref());
|
||||
let client = super::http_client::get_for_provider(proxy_config);
|
||||
let mut request = client.post(&url);
|
||||
|
||||
// 只有当 timeout > 0 时才设置请求超时
|
||||
// Duration::ZERO 在 reqwest 中表示"立刻超时"而不是"禁用超时"
|
||||
// 故障转移关闭时会传入 0,此时应该使用 client 的默认超时(600秒)
|
||||
if !self.non_streaming_timeout.is_zero() {
|
||||
request = request.timeout(self.non_streaming_timeout);
|
||||
}
|
||||
|
||||
// 过滤黑名单 Headers,保护隐私并避免冲突
|
||||
for (key, value) in headers {
|
||||
let key_str = key.as_str();
|
||||
if HEADER_BLACKLIST
|
||||
.iter()
|
||||
.any(|h| key_str.eq_ignore_ascii_case(h))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Copilot 请求:过滤会由 add_auth_headers 注入的固定指纹头,
|
||||
// 防止客户端原始头与注入头重复(reqwest header() 是追加语义)
|
||||
if is_copilot
|
||||
&& (key_str.eq_ignore_ascii_case("user-agent")
|
||||
|| key_str.eq_ignore_ascii_case("editor-version")
|
||||
|| key_str.eq_ignore_ascii_case("editor-plugin-version")
|
||||
|| key_str.eq_ignore_ascii_case("copilot-integration-id")
|
||||
|| key_str.eq_ignore_ascii_case("x-github-api-version")
|
||||
|| key_str.eq_ignore_ascii_case("openai-intent"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
request = request.header(key, value);
|
||||
}
|
||||
|
||||
// 处理 anthropic-beta Header(仅 Claude)
|
||||
// 关键:确保包含 claude-code-20250219 标记,这是上游服务验证请求来源的依据
|
||||
// 如果客户端发送的 beta 标记中没有包含 claude-code-20250219,需要补充
|
||||
if adapter.name() == "Claude" {
|
||||
const CLAUDE_CODE_BETA: &str = "claude-code-20250219";
|
||||
let beta_value = if let Some(beta) = headers.get("anthropic-beta") {
|
||||
if let Ok(beta_str) = beta.to_str() {
|
||||
// 检查是否已包含 claude-code-20250219
|
||||
if beta_str.contains(CLAUDE_CODE_BETA) {
|
||||
beta_str.to_string()
|
||||
} else {
|
||||
// 补充 claude-code-20250219
|
||||
format!("{CLAUDE_CODE_BETA},{beta_str}")
|
||||
}
|
||||
} else {
|
||||
CLAUDE_CODE_BETA.to_string()
|
||||
}
|
||||
} else {
|
||||
// 如果客户端没有发送,使用默认值
|
||||
CLAUDE_CODE_BETA.to_string()
|
||||
};
|
||||
request = request.header("anthropic-beta", &beta_value);
|
||||
}
|
||||
|
||||
// 客户端 IP 透传(默认开启)
|
||||
if let Some(xff) = headers.get("x-forwarded-for") {
|
||||
if let Ok(xff_str) = xff.to_str() {
|
||||
request = request.header("x-forwarded-for", xff_str);
|
||||
}
|
||||
}
|
||||
if let Some(real_ip) = headers.get("x-real-ip") {
|
||||
if let Ok(real_ip_str) = real_ip.to_str() {
|
||||
request = request.header("x-real-ip", real_ip_str);
|
||||
}
|
||||
}
|
||||
|
||||
// 流式请求保守禁用压缩,避免上游压缩 SSE 在连接中断时触发解压错误。
|
||||
// 非流式请求不显式设置 Accept-Encoding,让 reqwest 自动协商压缩并透明解压。
|
||||
if should_force_identity_encoding(&effective_endpoint, &filtered_body, headers) {
|
||||
request = request.header("accept-encoding", "identity");
|
||||
}
|
||||
|
||||
// 使用适配器添加认证头
|
||||
if let Some(mut auth) = adapter.extract_auth(provider) {
|
||||
// GitHub Copilot 特殊处理:从 CopilotAuthManager 获取真实 token
|
||||
if auth.strategy == AuthStrategy::GitHubCopilot {
|
||||
if let Some(app_handle) = &self.app_handle {
|
||||
@@ -879,211 +976,17 @@ impl RequestForwarder {
|
||||
));
|
||||
}
|
||||
}
|
||||
adapter.get_auth_headers(&auth)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// Copilot 指纹头名(由 get_auth_headers 注入,需在原始头中去重)
|
||||
let copilot_fingerprint_headers: &[&str] = if is_copilot {
|
||||
&[
|
||||
"user-agent",
|
||||
"editor-version",
|
||||
"editor-plugin-version",
|
||||
"copilot-integration-id",
|
||||
"x-github-api-version",
|
||||
"openai-intent",
|
||||
]
|
||||
} else {
|
||||
&[]
|
||||
};
|
||||
|
||||
// 预计算上游 host 值(用于在原位替换 host header)
|
||||
let upstream_host = url
|
||||
.parse::<http::Uri>()
|
||||
.ok()
|
||||
.and_then(|u| u.authority().map(|a| a.to_string()));
|
||||
|
||||
// 预计算 anthropic-beta 值(仅 Claude)
|
||||
let anthropic_beta_value = if adapter.name() == "Claude" {
|
||||
const CLAUDE_CODE_BETA: &str = "claude-code-20250219";
|
||||
Some(if let Some(beta) = headers.get("anthropic-beta") {
|
||||
if let Ok(beta_str) = beta.to_str() {
|
||||
if beta_str.contains(CLAUDE_CODE_BETA) {
|
||||
beta_str.to_string()
|
||||
} else {
|
||||
format!("{CLAUDE_CODE_BETA},{beta_str}")
|
||||
}
|
||||
} else {
|
||||
CLAUDE_CODE_BETA.to_string()
|
||||
}
|
||||
} else {
|
||||
CLAUDE_CODE_BETA.to_string()
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 构建有序 HeaderMap — 内联替换,保持客户端原始顺序
|
||||
// ============================================================
|
||||
let mut ordered_headers = http::HeaderMap::new();
|
||||
let mut saw_auth = false;
|
||||
let mut saw_accept_encoding = false;
|
||||
let mut saw_anthropic_beta = false;
|
||||
let mut saw_anthropic_version = false;
|
||||
|
||||
for (key, value) in headers {
|
||||
let key_str = key.as_str();
|
||||
|
||||
// --- host — 原位替换为上游 host(保持客户端原始位置) ---
|
||||
if key_str.eq_ignore_ascii_case("host") {
|
||||
if let Some(ref host_val) = upstream_host {
|
||||
if let Ok(hv) = http::HeaderValue::from_str(host_val) {
|
||||
ordered_headers.append(key.clone(), hv);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- 连接 / 追踪 / CDN 类 — 无条件跳过 ---
|
||||
if matches!(
|
||||
key_str,
|
||||
"content-length"
|
||||
| "transfer-encoding"
|
||||
| "x-forwarded-host"
|
||||
| "x-forwarded-port"
|
||||
| "x-forwarded-proto"
|
||||
| "forwarded"
|
||||
| "cf-connecting-ip"
|
||||
| "cf-ipcountry"
|
||||
| "cf-ray"
|
||||
| "cf-visitor"
|
||||
| "true-client-ip"
|
||||
| "fastly-client-ip"
|
||||
| "x-azure-clientip"
|
||||
| "x-azure-fdid"
|
||||
| "x-azure-ref"
|
||||
| "akamai-origin-hop"
|
||||
| "x-akamai-config-log-detail"
|
||||
| "x-request-id"
|
||||
| "x-correlation-id"
|
||||
| "x-trace-id"
|
||||
| "x-amzn-trace-id"
|
||||
| "x-b3-traceid"
|
||||
| "x-b3-spanid"
|
||||
| "x-b3-parentspanid"
|
||||
| "x-b3-sampled"
|
||||
| "traceparent"
|
||||
| "tracestate"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- 认证类 — 用 adapter 提供的认证头替换(在原始位置) ---
|
||||
if key_str.eq_ignore_ascii_case("authorization")
|
||||
|| key_str.eq_ignore_ascii_case("x-api-key")
|
||||
|| key_str.eq_ignore_ascii_case("x-goog-api-key")
|
||||
{
|
||||
if !saw_auth {
|
||||
saw_auth = true;
|
||||
for (ah_name, ah_value) in &auth_headers {
|
||||
ordered_headers.append(ah_name.clone(), ah_value.clone());
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- accept-encoding — transform / SSE 路径强制 identity,其余保留原值 ---
|
||||
if key_str.eq_ignore_ascii_case("accept-encoding") {
|
||||
if !saw_accept_encoding {
|
||||
saw_accept_encoding = true;
|
||||
if force_identity_encoding {
|
||||
ordered_headers.append(
|
||||
http::header::ACCEPT_ENCODING,
|
||||
http::HeaderValue::from_static("identity"),
|
||||
);
|
||||
} else {
|
||||
ordered_headers.append(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- anthropic-beta — 用重建值替换(确保含 claude-code 标记) ---
|
||||
if key_str.eq_ignore_ascii_case("anthropic-beta") {
|
||||
if !saw_anthropic_beta {
|
||||
saw_anthropic_beta = true;
|
||||
if let Some(ref beta_val) = anthropic_beta_value {
|
||||
if let Ok(hv) = http::HeaderValue::from_str(beta_val) {
|
||||
ordered_headers.append("anthropic-beta", hv);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- anthropic-version — 透传客户端值 ---
|
||||
if key_str.eq_ignore_ascii_case("anthropic-version") {
|
||||
saw_anthropic_version = true;
|
||||
ordered_headers.append(key.clone(), value.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- Copilot 指纹头 — 跳过(由 auth_headers 提供) ---
|
||||
if copilot_fingerprint_headers
|
||||
.iter()
|
||||
.any(|h| key_str.eq_ignore_ascii_case(h))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- 默认:透传 ---
|
||||
ordered_headers.append(key.clone(), value.clone());
|
||||
request = adapter.add_auth_headers(request, &auth);
|
||||
}
|
||||
|
||||
// 如果原始请求中没有认证头,在末尾追加
|
||||
if !saw_auth && !auth_headers.is_empty() {
|
||||
for (ah_name, ah_value) in &auth_headers {
|
||||
ordered_headers.append(ah_name.clone(), ah_value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// transform / SSE 路径在缺失时补 identity;普通透传不主动补 accept-encoding
|
||||
if !saw_accept_encoding && force_identity_encoding {
|
||||
ordered_headers.append(
|
||||
http::header::ACCEPT_ENCODING,
|
||||
http::HeaderValue::from_static("identity"),
|
||||
);
|
||||
}
|
||||
|
||||
// 如果原始请求中没有 anthropic-beta 且有值需要添加,追加
|
||||
if !saw_anthropic_beta {
|
||||
if let Some(ref beta_val) = anthropic_beta_value {
|
||||
if let Ok(hv) = http::HeaderValue::from_str(beta_val) {
|
||||
ordered_headers.append("anthropic-beta", hv);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// anthropic-version:仅在缺失时补充默认值
|
||||
if adapter.name() == "Claude" && !saw_anthropic_version {
|
||||
ordered_headers.append(
|
||||
"anthropic-version",
|
||||
http::HeaderValue::from_static("2023-06-01"),
|
||||
);
|
||||
}
|
||||
|
||||
// 序列化请求体
|
||||
let body_bytes = serde_json::to_vec(&filtered_body)
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to serialize request body: {e}")))?;
|
||||
|
||||
// 确保 content-type 存在
|
||||
if !ordered_headers.contains_key(http::header::CONTENT_TYPE) {
|
||||
ordered_headers.insert(
|
||||
http::header::CONTENT_TYPE,
|
||||
http::HeaderValue::from_static("application/json"),
|
||||
);
|
||||
// anthropic-version 统一处理(仅 Claude):优先使用客户端的版本号,否则使用默认值
|
||||
// 注意:只设置一次,避免重复
|
||||
if adapter.name() == "Claude" {
|
||||
let version_str = headers
|
||||
.get("anthropic-version")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("2023-06-01");
|
||||
request = request.header("anthropic-version", version_str);
|
||||
}
|
||||
|
||||
// 输出请求信息日志
|
||||
@@ -1101,75 +1004,25 @@ impl RequestForwarder {
|
||||
);
|
||||
}
|
||||
|
||||
// 确定超时
|
||||
let timeout = if self.non_streaming_timeout.is_zero() {
|
||||
std::time::Duration::from_secs(600) // 默认 600 秒
|
||||
} else {
|
||||
self.non_streaming_timeout
|
||||
};
|
||||
|
||||
// 解析上游代理 URL(供应商单独代理 > 全局代理 > 无)
|
||||
let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref());
|
||||
let upstream_proxy_url: Option<String> = proxy_config
|
||||
.filter(|c| c.enabled)
|
||||
.and_then(super::http_client::build_proxy_url_from_config)
|
||||
.or_else(super::http_client::get_current_proxy_url);
|
||||
|
||||
// SOCKS5 代理不支持 CONNECT 隧道,需要用 reqwest
|
||||
let is_socks_proxy = upstream_proxy_url
|
||||
.as_deref()
|
||||
.map(|u| u.starts_with("socks5"))
|
||||
.unwrap_or(false);
|
||||
|
||||
let uri: http::Uri = url
|
||||
.parse()
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Invalid URL '{url}': {e}")))?;
|
||||
|
||||
// 发送请求
|
||||
let response = if is_socks_proxy {
|
||||
// SOCKS5 代理:只能走 reqwest(不支持 header case 保留)
|
||||
log::debug!("[Forwarder] Using reqwest for SOCKS5 proxy");
|
||||
let client = super::http_client::get_for_provider(proxy_config);
|
||||
let mut request = client.post(&url);
|
||||
if !self.non_streaming_timeout.is_zero() {
|
||||
request = request.timeout(self.non_streaming_timeout);
|
||||
let response = request.json(&filtered_body).send().await.map_err(|e| {
|
||||
if e.is_timeout() {
|
||||
ProxyError::Timeout(format!("请求超时: {e}"))
|
||||
} else if e.is_connect() {
|
||||
ProxyError::ForwardFailed(format!("连接失败: {e}"))
|
||||
} else {
|
||||
ProxyError::ForwardFailed(e.to_string())
|
||||
}
|
||||
for (key, value) in &ordered_headers {
|
||||
request = request.header(key, value);
|
||||
}
|
||||
let reqwest_resp = request.body(body_bytes).send().await.map_err(|e| {
|
||||
if e.is_timeout() {
|
||||
ProxyError::Timeout(format!("请求超时: {e}"))
|
||||
} else if e.is_connect() {
|
||||
ProxyError::ForwardFailed(format!("连接失败: {e}"))
|
||||
} else {
|
||||
ProxyError::ForwardFailed(e.to_string())
|
||||
}
|
||||
})?;
|
||||
ProxyResponse::Reqwest(reqwest_resp)
|
||||
} else {
|
||||
// HTTP 代理或直连:走 hyper raw write(保持 header 大小写)
|
||||
// 如果有 HTTP 代理,hyper_client 会用 CONNECT 隧道穿过代理
|
||||
super::hyper_client::send_request(
|
||||
uri,
|
||||
http::Method::POST,
|
||||
ordered_headers,
|
||||
extensions.clone(),
|
||||
body_bytes,
|
||||
timeout,
|
||||
upstream_proxy_url.as_deref(),
|
||||
)
|
||||
.await?
|
||||
};
|
||||
})?;
|
||||
|
||||
// 检查响应状态
|
||||
let status = response.status();
|
||||
|
||||
if status.is_success() {
|
||||
Ok((response, resolved_claude_api_format))
|
||||
Ok(response)
|
||||
} else {
|
||||
let status_code = status.as_u16();
|
||||
let body_text = String::from_utf8(response.bytes().await?.to_vec()).ok();
|
||||
let body_text = response.text().await.ok();
|
||||
|
||||
Err(ProxyError::UpstreamError {
|
||||
status: status_code,
|
||||
@@ -1178,68 +1031,6 @@ impl RequestForwarder {
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_claude_api_format(
|
||||
&self,
|
||||
provider: &Provider,
|
||||
body: &Value,
|
||||
is_copilot: bool,
|
||||
) -> String {
|
||||
if !is_copilot {
|
||||
return super::providers::get_claude_api_format(provider).to_string();
|
||||
}
|
||||
|
||||
let model = body.get("model").and_then(|value| value.as_str());
|
||||
if let Some(model_id) = model {
|
||||
if self
|
||||
.is_copilot_openai_vendor_model(provider, model_id)
|
||||
.await
|
||||
{
|
||||
return "openai_responses".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
"openai_chat".to_string()
|
||||
}
|
||||
|
||||
async fn is_copilot_openai_vendor_model(&self, provider: &Provider, model_id: &str) -> bool {
|
||||
let Some(app_handle) = &self.app_handle else {
|
||||
log::debug!("[Copilot] AppHandle unavailable, fallback to chat/completions");
|
||||
return false;
|
||||
};
|
||||
|
||||
let copilot_state = app_handle.state::<CopilotAuthState>();
|
||||
let copilot_auth = copilot_state.0.read().await;
|
||||
let account_id = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.managed_account_id_for("github_copilot"));
|
||||
|
||||
let vendor_result = match account_id.as_deref() {
|
||||
Some(id) => {
|
||||
copilot_auth
|
||||
.get_model_vendor_for_account(id, model_id)
|
||||
.await
|
||||
}
|
||||
None => copilot_auth.get_model_vendor(model_id).await,
|
||||
};
|
||||
|
||||
match vendor_result {
|
||||
Ok(Some(vendor)) => vendor.eq_ignore_ascii_case("openai"),
|
||||
Ok(None) => {
|
||||
log::debug!(
|
||||
"[Copilot] Model vendor unavailable for {model_id}, fallback to chat/completions"
|
||||
);
|
||||
false
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"[Copilot] Failed to resolve model vendor for {model_id}, fallback to chat/completions: {err}"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory {
|
||||
match error {
|
||||
// 网络和上游错误:都应该尝试下一个供应商
|
||||
@@ -1427,9 +1218,7 @@ fn rewrite_claude_transform_endpoint(
|
||||
return (endpoint.to_string(), passthrough_query);
|
||||
}
|
||||
|
||||
let target_path = if is_copilot && api_format == "openai_responses" {
|
||||
"/v1/responses"
|
||||
} else if is_copilot {
|
||||
let target_path = if is_copilot {
|
||||
"/chat/completions"
|
||||
} else if api_format == "openai_responses" {
|
||||
"/v1/responses"
|
||||
@@ -1498,8 +1287,7 @@ fn summarize_text_for_log(text: &str, max_chars: usize) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::header::{HeaderValue, ACCEPT};
|
||||
use axum::http::HeaderMap;
|
||||
use axum::http::{header::ACCEPT, HeaderMap, HeaderValue};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
@@ -1600,18 +1388,6 @@ mod tests {
|
||||
assert_eq!(passthrough_query.as_deref(), Some("x-id=1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_claude_transform_endpoint_uses_copilot_responses_path() {
|
||||
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
|
||||
"/v1/messages?beta=true&x-id=1",
|
||||
"openai_responses",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(endpoint, "/v1/responses?x-id=1");
|
||||
assert_eq!(passthrough_query.as_deref(), Some("x-id=1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_query_to_full_url_preserves_existing_query_string() {
|
||||
let url = append_query_to_full_url("https://relay.example/api?foo=bar", Some("x-id=1"));
|
||||
|
||||
@@ -18,10 +18,7 @@ use super::{
|
||||
streaming_responses::create_anthropic_sse_stream_from_responses, transform,
|
||||
transform_responses,
|
||||
},
|
||||
response_processor::{
|
||||
create_logged_passthrough_stream, process_response, read_decoded_body,
|
||||
strip_entity_headers_for_rebuilt_body, SseUsageCollector,
|
||||
},
|
||||
response_processor::{create_logged_passthrough_stream, process_response, SseUsageCollector},
|
||||
server::ProxyState,
|
||||
types::*,
|
||||
usage::parser::TokenUsage,
|
||||
@@ -30,7 +27,6 @@ use super::{
|
||||
use crate::app_config::AppType;
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||||
use bytes::Bytes;
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
// ============================================================================
|
||||
@@ -65,20 +61,10 @@ pub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxySta
|
||||
/// - 现在 OpenRouter 已推出 Claude Code 兼容接口,默认不再启用该转换(逻辑保留以备回退)
|
||||
pub async fn handle_messages(
|
||||
State(state): State<ProxyState>,
|
||||
request: axum::extract::Request,
|
||||
uri: axum::http::Uri,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, body) = request.into_parts();
|
||||
let uri = parts.uri;
|
||||
let headers = parts.headers;
|
||||
let extensions = parts.extensions;
|
||||
let body_bytes = body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
|
||||
.to_bytes();
|
||||
let body: Value = serde_json::from_slice(&body_bytes)
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Claude, "Claude", "claude").await?;
|
||||
|
||||
@@ -100,7 +86,6 @@ pub async fn handle_messages(
|
||||
endpoint,
|
||||
body.clone(),
|
||||
headers,
|
||||
extensions,
|
||||
ctx.get_providers(),
|
||||
)
|
||||
.await
|
||||
@@ -116,11 +101,6 @@ pub async fn handle_messages(
|
||||
};
|
||||
|
||||
ctx.provider = result.provider;
|
||||
let api_format = result
|
||||
.claude_api_format
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| get_claude_api_format(&ctx.provider))
|
||||
.to_string();
|
||||
let response = result.response;
|
||||
|
||||
// 检查是否需要格式转换(OpenRouter 等中转服务)
|
||||
@@ -129,8 +109,7 @@ pub async fn handle_messages(
|
||||
|
||||
// Claude 特有:格式转换处理
|
||||
if needs_transform {
|
||||
return handle_claude_transform(response, &ctx, &state, &body, is_stream, &api_format)
|
||||
.await;
|
||||
return handle_claude_transform(response, &ctx, &state, &body, is_stream).await;
|
||||
}
|
||||
|
||||
// 通用响应处理(透传模式)
|
||||
@@ -141,14 +120,14 @@ pub async fn handle_messages(
|
||||
///
|
||||
/// 支持 OpenAI Chat Completions 和 Responses API 两种格式的转换
|
||||
async fn handle_claude_transform(
|
||||
response: super::hyper_client::ProxyResponse,
|
||||
response: reqwest::Response,
|
||||
ctx: &RequestContext,
|
||||
state: &ProxyState,
|
||||
_original_body: &Value,
|
||||
is_stream: bool,
|
||||
api_format: &str,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let status = response.status();
|
||||
let api_format = get_claude_api_format(&ctx.provider);
|
||||
|
||||
if is_stream {
|
||||
// 根据 api_format 选择流式转换器
|
||||
@@ -226,14 +205,12 @@ async fn handle_claude_transform(
|
||||
}
|
||||
|
||||
// 非流式响应转换 (OpenAI/Responses → Anthropic)
|
||||
let body_timeout =
|
||||
if ctx.app_config.auto_failover_enabled && ctx.app_config.non_streaming_timeout > 0 {
|
||||
std::time::Duration::from_secs(ctx.app_config.non_streaming_timeout as u64)
|
||||
} else {
|
||||
std::time::Duration::ZERO
|
||||
};
|
||||
let (mut response_headers, _status, body_bytes) =
|
||||
read_decoded_body(response, ctx.tag, body_timeout).await?;
|
||||
let response_headers = response.headers().clone();
|
||||
|
||||
let body_bytes = response.bytes().await.map_err(|e| {
|
||||
log::error!("[Claude] 读取响应体失败: {e}");
|
||||
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
|
||||
})?;
|
||||
|
||||
let body_str = String::from_utf8_lossy(&body_bytes);
|
||||
|
||||
@@ -286,10 +263,13 @@ async fn handle_claude_transform(
|
||||
|
||||
// 构建响应
|
||||
let mut builder = axum::response::Response::builder().status(status);
|
||||
strip_entity_headers_for_rebuilt_body(&mut response_headers);
|
||||
|
||||
for (key, value) in response_headers.iter() {
|
||||
builder = builder.header(key, value);
|
||||
if key.as_str().to_lowercase() != "content-length"
|
||||
&& key.as_str().to_lowercase() != "transfer-encoding"
|
||||
{
|
||||
builder = builder.header(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
builder = builder.header("content-type", "application/json");
|
||||
@@ -320,20 +300,10 @@ fn endpoint_with_query(uri: &axum::http::Uri, endpoint: &str) -> String {
|
||||
/// 处理 /v1/chat/completions 请求(OpenAI Chat Completions API - Codex CLI)
|
||||
pub async fn handle_chat_completions(
|
||||
State(state): State<ProxyState>,
|
||||
request: axum::extract::Request,
|
||||
uri: axum::http::Uri,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, req_body) = request.into_parts();
|
||||
let uri = parts.uri;
|
||||
let headers = parts.headers;
|
||||
let extensions = parts.extensions;
|
||||
let body_bytes = req_body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
|
||||
.to_bytes();
|
||||
let body: Value = serde_json::from_slice(&body_bytes)
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
let endpoint = endpoint_with_query(&uri, "/chat/completions");
|
||||
@@ -350,7 +320,6 @@ pub async fn handle_chat_completions(
|
||||
&endpoint,
|
||||
body,
|
||||
headers,
|
||||
extensions,
|
||||
ctx.get_providers(),
|
||||
)
|
||||
.await
|
||||
@@ -374,20 +343,10 @@ pub async fn handle_chat_completions(
|
||||
/// 处理 /v1/responses 请求(OpenAI Responses API - Codex CLI 透传)
|
||||
pub async fn handle_responses(
|
||||
State(state): State<ProxyState>,
|
||||
request: axum::extract::Request,
|
||||
uri: axum::http::Uri,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, req_body) = request.into_parts();
|
||||
let uri = parts.uri;
|
||||
let headers = parts.headers;
|
||||
let extensions = parts.extensions;
|
||||
let body_bytes = req_body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
|
||||
.to_bytes();
|
||||
let body: Value = serde_json::from_slice(&body_bytes)
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
let endpoint = endpoint_with_query(&uri, "/responses");
|
||||
@@ -404,7 +363,6 @@ pub async fn handle_responses(
|
||||
&endpoint,
|
||||
body,
|
||||
headers,
|
||||
extensions,
|
||||
ctx.get_providers(),
|
||||
)
|
||||
.await
|
||||
@@ -428,20 +386,10 @@ pub async fn handle_responses(
|
||||
/// 处理 /v1/responses/compact 请求(OpenAI Responses Compact API - Codex CLI 透传)
|
||||
pub async fn handle_responses_compact(
|
||||
State(state): State<ProxyState>,
|
||||
request: axum::extract::Request,
|
||||
uri: axum::http::Uri,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, req_body) = request.into_parts();
|
||||
let uri = parts.uri;
|
||||
let headers = parts.headers;
|
||||
let extensions = parts.extensions;
|
||||
let body_bytes = req_body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
|
||||
.to_bytes();
|
||||
let body: Value = serde_json::from_slice(&body_bytes)
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
let endpoint = endpoint_with_query(&uri, "/responses/compact");
|
||||
@@ -458,7 +406,6 @@ pub async fn handle_responses_compact(
|
||||
&endpoint,
|
||||
body,
|
||||
headers,
|
||||
extensions,
|
||||
ctx.get_providers(),
|
||||
)
|
||||
.await
|
||||
@@ -487,19 +434,9 @@ pub async fn handle_responses_compact(
|
||||
pub async fn handle_gemini(
|
||||
State(state): State<ProxyState>,
|
||||
uri: axum::http::Uri,
|
||||
request: axum::extract::Request,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, req_body) = request.into_parts();
|
||||
let headers = parts.headers;
|
||||
let extensions = parts.extensions;
|
||||
let body_bytes = req_body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
|
||||
.to_bytes();
|
||||
let body: Value = serde_json::from_slice(&body_bytes)
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
// Gemini 的模型名称在 URI 中
|
||||
let mut ctx = RequestContext::new(&state, &body, &headers, AppType::Gemini, "Gemini", "gemini")
|
||||
.await?
|
||||
@@ -523,7 +460,6 @@ pub async fn handle_gemini(
|
||||
endpoint,
|
||||
body,
|
||||
headers,
|
||||
extensions,
|
||||
ctx.get_providers(),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -219,12 +219,7 @@ fn build_client(proxy_url: Option<&str>) -> Result<Client, String> {
|
||||
.timeout(Duration::from_secs(600))
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.pool_max_idle_per_host(10)
|
||||
.tcp_keepalive(Duration::from_secs(60))
|
||||
// 禁用 reqwest 自动解压:防止 reqwest 覆盖客户端原始 accept-encoding header。
|
||||
// 响应解压由 response_processor 根据 content-encoding 手动处理。
|
||||
.no_gzip()
|
||||
.no_brotli()
|
||||
.no_deflate();
|
||||
.tcp_keepalive(Duration::from_secs(60));
|
||||
|
||||
// 有代理地址则使用代理,否则跟随系统代理
|
||||
if let Some(url) = proxy_url {
|
||||
@@ -337,7 +332,7 @@ pub fn mask_url(url: &str) -> String {
|
||||
/// 根据供应商单独代理配置构建代理 URL
|
||||
///
|
||||
/// 将 ProviderProxyConfig 转换为代理 URL 字符串
|
||||
pub fn build_proxy_url_from_config(config: &ProviderProxyConfig) -> Option<String> {
|
||||
fn build_proxy_url_from_config(config: &ProviderProxyConfig) -> Option<String> {
|
||||
let proxy_type = config.proxy_type.as_deref().unwrap_or("http");
|
||||
let host = config.proxy_host.as_deref()?;
|
||||
let port = config.proxy_port?;
|
||||
@@ -392,9 +387,6 @@ pub fn build_client_for_provider(proxy_config: Option<&ProviderProxyConfig>) ->
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.pool_max_idle_per_host(10)
|
||||
.tcp_keepalive(Duration::from_secs(60))
|
||||
.no_gzip()
|
||||
.no_brotli()
|
||||
.no_deflate()
|
||||
.proxy(proxy)
|
||||
.build()
|
||||
{
|
||||
|
||||
@@ -1,685 +0,0 @@
|
||||
//! Hyper-based HTTP client for proxy forwarding
|
||||
//!
|
||||
//! Uses raw TCP/TLS writes to preserve exact original header name casing.
|
||||
//! Supports HTTP CONNECT tunneling through upstream proxies.
|
||||
//! Falls back to hyper-util Client (title-case headers) when raw write is not feasible.
|
||||
|
||||
use super::ProxyError;
|
||||
use bytes::Bytes;
|
||||
use futures::stream::Stream;
|
||||
use http_body_util::BodyExt;
|
||||
use hyper_rustls::HttpsConnectorBuilder;
|
||||
use hyper_util::{client::legacy::Client, rt::TokioExecutor};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Our own header case map: maps lowercase header name → original wire-casing bytes.
|
||||
///
|
||||
/// This is a backup mechanism independent of hyper's internal `HeaderCaseMap` (which is
|
||||
/// `pub(crate)` and cannot be directly inspected or constructed from outside hyper).
|
||||
///
|
||||
/// Populated in `server.rs` by peeking at raw TCP bytes before hyper parses them.
|
||||
/// Used in `send_request` to manually write headers with original casing when hyper's
|
||||
/// own mechanism fails.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct OriginalHeaderCases {
|
||||
/// Ordered list of (lowercase_name, original_wire_bytes) pairs.
|
||||
/// Multiple entries with the same name are allowed (for repeated headers).
|
||||
pub cases: Vec<(String, Vec<u8>)>,
|
||||
}
|
||||
|
||||
impl OriginalHeaderCases {
|
||||
/// Parse raw HTTP request bytes (from TcpStream::peek) to extract original header casings.
|
||||
pub fn from_raw_bytes(buf: &[u8]) -> Self {
|
||||
let mut headers_buf = [httparse::EMPTY_HEADER; 128];
|
||||
let mut req = httparse::Request::new(&mut headers_buf);
|
||||
// We don't care if parsing is partial — we just want the header names we can get
|
||||
let _ = req.parse(buf);
|
||||
|
||||
let mut cases = Vec::new();
|
||||
for header in req.headers.iter() {
|
||||
if header.name.is_empty() {
|
||||
break;
|
||||
}
|
||||
cases.push((
|
||||
header.name.to_ascii_lowercase(),
|
||||
header.name.as_bytes().to_vec(),
|
||||
));
|
||||
}
|
||||
|
||||
Self { cases }
|
||||
}
|
||||
}
|
||||
|
||||
type HyperClient = Client<
|
||||
hyper_rustls::HttpsConnector<hyper_util::client::legacy::connect::HttpConnector>,
|
||||
http_body_util::Full<Bytes>,
|
||||
>;
|
||||
|
||||
/// Lazily-initialized hyper client with header-case preservation enabled.
|
||||
fn global_hyper_client() -> &'static HyperClient {
|
||||
static CLIENT: OnceLock<HyperClient> = OnceLock::new();
|
||||
CLIENT.get_or_init(|| {
|
||||
let connector = HttpsConnectorBuilder::new()
|
||||
.with_webpki_roots()
|
||||
.https_or_http()
|
||||
.enable_http1()
|
||||
.build();
|
||||
|
||||
Client::builder(TokioExecutor::new())
|
||||
.http1_preserve_header_case(true)
|
||||
.http1_title_case_headers(true)
|
||||
.build(connector)
|
||||
})
|
||||
}
|
||||
|
||||
/// Unified response wrapper that can hold either a hyper or reqwest response.
|
||||
///
|
||||
/// The hyper variant is used for the main (direct) path with header-case preservation.
|
||||
/// The reqwest variant is the fallback when an upstream HTTP/SOCKS5 proxy is configured.
|
||||
pub enum ProxyResponse {
|
||||
Hyper(hyper::Response<hyper::body::Incoming>),
|
||||
Reqwest(reqwest::Response),
|
||||
}
|
||||
|
||||
impl ProxyResponse {
|
||||
pub fn status(&self) -> http::StatusCode {
|
||||
match self {
|
||||
Self::Hyper(r) => r.status(),
|
||||
Self::Reqwest(r) => r.status(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn headers(&self) -> &http::HeaderMap {
|
||||
match self {
|
||||
Self::Hyper(r) => r.headers(),
|
||||
Self::Reqwest(r) => r.headers(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Shortcut: extract `content-type` header value as `&str`.
|
||||
pub fn content_type(&self) -> Option<&str> {
|
||||
self.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
}
|
||||
|
||||
/// Check if the response is an SSE stream.
|
||||
pub fn is_sse(&self) -> bool {
|
||||
self.content_type()
|
||||
.map(|ct| ct.contains("text/event-stream"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Consume the response and collect the full body into `Bytes`.
|
||||
pub async fn bytes(self) -> Result<Bytes, ProxyError> {
|
||||
match self {
|
||||
Self::Hyper(r) => {
|
||||
let collected = r.into_body().collect().await.map_err(|e| {
|
||||
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
|
||||
})?;
|
||||
Ok(collected.to_bytes())
|
||||
}
|
||||
Self::Reqwest(r) => r.bytes().await.map_err(|e| {
|
||||
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume the response and return a byte-chunk stream (for SSE pass-through).
|
||||
pub fn bytes_stream(self) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
||||
use futures::StreamExt;
|
||||
|
||||
match self {
|
||||
Self::Hyper(r) => {
|
||||
let body = r.into_body();
|
||||
let stream = futures::stream::unfold(body, |mut body| async {
|
||||
match body.frame().await {
|
||||
Some(Ok(frame)) => {
|
||||
if let Ok(data) = frame.into_data() {
|
||||
if data.is_empty() {
|
||||
Some((Ok(Bytes::new()), body))
|
||||
} else {
|
||||
Some((Ok(data), body))
|
||||
}
|
||||
} else {
|
||||
Some((Ok(Bytes::new()), body))
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => Some((Err(std::io::Error::other(e.to_string())), body)),
|
||||
None => None,
|
||||
}
|
||||
})
|
||||
.filter(|result| {
|
||||
futures::future::ready(!matches!(result, Ok(ref b) if b.is_empty()))
|
||||
});
|
||||
Box::pin(stream)
|
||||
as std::pin::Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>
|
||||
}
|
||||
Self::Reqwest(r) => {
|
||||
let stream = r
|
||||
.bytes_stream()
|
||||
.map(|r| r.map_err(|e| std::io::Error::other(e.to_string())));
|
||||
Box::pin(stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send an HTTP request with header-case preservation.
|
||||
///
|
||||
/// Uses a two-tier strategy:
|
||||
/// 1. Primary: raw HTTP/1.1 write via TLS stream with exact original header casing
|
||||
/// (from `OriginalHeaderCases` captured by peek in server.rs), then hand off to
|
||||
/// hyper for response parsing.
|
||||
/// 2. Fallback: hyper-util Client with `title_case_headers(true)` when raw write
|
||||
/// isn't feasible (e.g., missing original cases).
|
||||
///
|
||||
/// The caller is expected to include `Host` in the supplied `headers` at the
|
||||
/// correct position.
|
||||
///
|
||||
/// `proxy_url`: optional upstream HTTP proxy URL (e.g. `http://127.0.0.1:7890`).
|
||||
/// When set, the raw write path uses HTTP CONNECT tunneling through the proxy,
|
||||
/// so header-case preservation works even when an upstream proxy is configured.
|
||||
pub async fn send_request(
|
||||
uri: http::Uri,
|
||||
method: http::Method,
|
||||
headers: http::HeaderMap,
|
||||
original_extensions: http::Extensions,
|
||||
body: Vec<u8>,
|
||||
timeout: std::time::Duration,
|
||||
proxy_url: Option<&str>,
|
||||
) -> Result<ProxyResponse, ProxyError> {
|
||||
// Extract our own OriginalHeaderCases if available
|
||||
let original_cases = original_extensions.get::<OriginalHeaderCases>().cloned();
|
||||
let has_cases = original_cases
|
||||
.as_ref()
|
||||
.map(|c| !c.cases.is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
log::debug!(
|
||||
"[HyperClient] Sending request: uri={uri}, header_count={}, \
|
||||
has_host={}, has_original_cases={has_cases}, proxy={:?}",
|
||||
headers.len(),
|
||||
headers.contains_key(http::header::HOST),
|
||||
proxy_url,
|
||||
);
|
||||
|
||||
if has_cases {
|
||||
// Primary path: use raw write + hyper handshake for exact header casing
|
||||
let result = tokio::time::timeout(
|
||||
timeout,
|
||||
send_raw_request(
|
||||
&uri,
|
||||
&method,
|
||||
&headers,
|
||||
original_cases.as_ref().unwrap(),
|
||||
&body,
|
||||
proxy_url,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| ProxyError::Timeout(format!("请求超时: {}s", timeout.as_secs())))?;
|
||||
|
||||
match result {
|
||||
Ok(resp) => return Ok(resp),
|
||||
Err(e) => {
|
||||
if proxy_url.is_some() {
|
||||
// Don't bypass configured proxy with direct connect fallback
|
||||
return Err(e);
|
||||
}
|
||||
log::warn!("[HyperClient] Raw write failed, falling back to hyper-util: {e}");
|
||||
// Fall through to hyper-util Client
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: hyper-util Client (title-case headers, no proxy support)
|
||||
let mut req = http::Request::builder()
|
||||
.method(method)
|
||||
.uri(&uri)
|
||||
.body(http_body_util::Full::new(Bytes::from(body)))
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Failed to build request: {e}")))?;
|
||||
|
||||
*req.headers_mut() = headers;
|
||||
*req.extensions_mut() = original_extensions;
|
||||
|
||||
let client = global_hyper_client();
|
||||
let resp = tokio::time::timeout(timeout, client.request(req))
|
||||
.await
|
||||
.map_err(|_| ProxyError::Timeout(format!("请求超时: {}s", timeout.as_secs())))?
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("上游请求失败: {e}")))?;
|
||||
|
||||
Ok(ProxyResponse::Hyper(resp))
|
||||
}
|
||||
|
||||
/// TCP or TLS stream returned by `connect_via_proxy`.
|
||||
///
|
||||
/// When the proxy URL uses `https://`, the connection to the proxy itself is
|
||||
/// TLS-wrapped before sending the CONNECT request. The enum lets
|
||||
/// `send_raw_request` work with either variant generically.
|
||||
enum ProxyStream {
|
||||
Tcp(tokio::net::TcpStream),
|
||||
Tls(Box<tokio_rustls::client::TlsStream<tokio::net::TcpStream>>),
|
||||
}
|
||||
|
||||
impl tokio::io::AsyncRead for ProxyStream {
|
||||
fn poll_read(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &mut tokio::io::ReadBuf<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
match self.get_mut() {
|
||||
ProxyStream::Tcp(s) => std::pin::Pin::new(s).poll_read(cx, buf),
|
||||
ProxyStream::Tls(s) => std::pin::Pin::new(s).poll_read(cx, buf),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl tokio::io::AsyncWrite for ProxyStream {
|
||||
fn poll_write(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> std::task::Poll<std::io::Result<usize>> {
|
||||
match self.get_mut() {
|
||||
ProxyStream::Tcp(s) => std::pin::Pin::new(s).poll_write(cx, buf),
|
||||
ProxyStream::Tls(s) => std::pin::Pin::new(s).poll_write(cx, buf),
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_flush(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
match self.get_mut() {
|
||||
ProxyStream::Tcp(s) => std::pin::Pin::new(s).poll_flush(cx),
|
||||
ProxyStream::Tls(s) => std::pin::Pin::new(s).poll_flush(cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_shutdown(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
match self.get_mut() {
|
||||
ProxyStream::Tcp(s) => std::pin::Pin::new(s).poll_shutdown(cx),
|
||||
ProxyStream::Tls(s) => std::pin::Pin::new(s).poll_shutdown(cx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send request via raw TCP/TLS with exact original header casing.
|
||||
///
|
||||
/// When `proxy_url` is provided, establishes an HTTP CONNECT tunnel through
|
||||
/// the proxy first, then performs TLS + raw write through the tunnel.
|
||||
/// This preserves header casing even when an upstream proxy is configured.
|
||||
async fn send_raw_request(
|
||||
uri: &http::Uri,
|
||||
method: &http::Method,
|
||||
headers: &http::HeaderMap,
|
||||
original_cases: &OriginalHeaderCases,
|
||||
body: &[u8],
|
||||
proxy_url: Option<&str>,
|
||||
) -> Result<ProxyResponse, ProxyError> {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
let scheme = uri.scheme_str().unwrap_or("https");
|
||||
let host = uri
|
||||
.host()
|
||||
.ok_or_else(|| ProxyError::ForwardFailed("URI has no host".into()))?;
|
||||
let port = uri
|
||||
.port_u16()
|
||||
.unwrap_or(if scheme == "https" { 443 } else { 80 });
|
||||
let path_and_query = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or("/");
|
||||
|
||||
// Build raw HTTP request bytes
|
||||
let raw = build_raw_request(method, path_and_query, headers, original_cases, body);
|
||||
|
||||
// Establish TCP connection — either direct or through HTTP CONNECT proxy
|
||||
let stream = if let Some(proxy) = proxy_url {
|
||||
connect_via_proxy(proxy, host, port).await?
|
||||
} else {
|
||||
ProxyStream::Tcp(
|
||||
tokio::net::TcpStream::connect((host, port))
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("TCP connect failed: {e}")))?,
|
||||
)
|
||||
};
|
||||
|
||||
if scheme == "https" {
|
||||
let tls_connector = global_tls_connector();
|
||||
let server_name = rustls::pki_types::ServerName::try_from(host.to_string())
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Invalid server name: {e}")))?;
|
||||
let mut tls_stream = tls_connector
|
||||
.connect(server_name, stream)
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("TLS handshake failed: {e}")))?;
|
||||
|
||||
tls_stream
|
||||
.write_all(&raw)
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Write failed: {e}")))?;
|
||||
|
||||
let filtered = WriteFilter::new(tls_stream);
|
||||
do_hyper_response(filtered, method.clone()).await
|
||||
} else {
|
||||
let mut stream = stream;
|
||||
stream
|
||||
.write_all(&raw)
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Write failed: {e}")))?;
|
||||
|
||||
let filtered = WriteFilter::new(stream);
|
||||
do_hyper_response(filtered, method.clone()).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Establish a connection through an HTTP CONNECT proxy tunnel.
|
||||
///
|
||||
/// 1. Connect TCP to the proxy server (TLS-wrapped when `https://` proxy)
|
||||
/// 2. Send `CONNECT host:port HTTP/1.1` with optional `Proxy-Authorization`
|
||||
/// 3. Read the proxy's 200 response (407 → `AuthError`)
|
||||
/// 4. Return the tunneled stream (ready for target TLS handshake + raw write)
|
||||
async fn connect_via_proxy(
|
||||
proxy_url: &str,
|
||||
target_host: &str,
|
||||
target_port: u16,
|
||||
) -> Result<ProxyStream, ProxyError> {
|
||||
use base64::Engine;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
|
||||
let parsed = url::Url::parse(proxy_url)
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Invalid proxy URL: {e}")))?;
|
||||
|
||||
let proxy_host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| ProxyError::ForwardFailed("Proxy URL has no host".into()))?;
|
||||
let proxy_port = parsed
|
||||
.port()
|
||||
.unwrap_or(if parsed.scheme() == "https" { 443 } else { 80 });
|
||||
|
||||
// Build Proxy-Authorization header if credentials are present
|
||||
let proxy_auth = if !parsed.username().is_empty() {
|
||||
let password = parsed.password().unwrap_or("");
|
||||
let credentials = format!("{}:{}", parsed.username(), password);
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
|
||||
Some(format!("Proxy-Authorization: Basic {encoded}\r\n"))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Connect to the proxy
|
||||
let tcp = tokio::net::TcpStream::connect((proxy_host, proxy_port))
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Proxy TCP connect failed: {e}")))?;
|
||||
|
||||
// Wrap with TLS if the proxy URL uses https://
|
||||
let mut stream: ProxyStream = if parsed.scheme() == "https" {
|
||||
let tls_connector = global_tls_connector();
|
||||
let server_name = rustls::pki_types::ServerName::try_from(proxy_host.to_string())
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Invalid proxy server name: {e}")))?;
|
||||
let tls_stream = tls_connector
|
||||
.connect(server_name, tcp)
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Proxy TLS handshake failed: {e}")))?;
|
||||
ProxyStream::Tls(Box::new(tls_stream))
|
||||
} else {
|
||||
ProxyStream::Tcp(tcp)
|
||||
};
|
||||
|
||||
// Send CONNECT request
|
||||
let mut connect_req = format!(
|
||||
"CONNECT {target_host}:{target_port} HTTP/1.1\r\n\
|
||||
Host: {target_host}:{target_port}\r\n"
|
||||
);
|
||||
if let Some(auth) = &proxy_auth {
|
||||
connect_req.push_str(auth);
|
||||
}
|
||||
connect_req.push_str("\r\n");
|
||||
|
||||
stream
|
||||
.write_all(connect_req.as_bytes())
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("CONNECT write failed: {e}")))?;
|
||||
|
||||
// Read the proxy's response status line
|
||||
let mut reader = BufReader::new(&mut stream);
|
||||
let mut status_line = String::new();
|
||||
reader
|
||||
.read_line(&mut status_line)
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("CONNECT read failed: {e}")))?;
|
||||
|
||||
// Expect "HTTP/1.1 200 ..." or "HTTP/1.0 200 ..."
|
||||
if !status_line.contains(" 200 ") {
|
||||
if status_line.contains(" 407 ") {
|
||||
return Err(ProxyError::AuthError(format!(
|
||||
"Proxy authentication required (407): {}",
|
||||
status_line.trim()
|
||||
)));
|
||||
}
|
||||
return Err(ProxyError::ForwardFailed(format!(
|
||||
"Proxy CONNECT rejected: {}",
|
||||
status_line.trim()
|
||||
)));
|
||||
}
|
||||
|
||||
// Drain remaining response headers (until empty line)
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
reader
|
||||
.read_line(&mut line)
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("CONNECT header read: {e}")))?;
|
||||
if line.trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// BufReader might have buffered data; drop it to get raw stream back.
|
||||
// Since CONNECT response is headers-only (no body), and we read until \r\n\r\n,
|
||||
// the BufReader buffer should be empty at this point.
|
||||
drop(reader);
|
||||
|
||||
log::debug!(
|
||||
"[HyperClient] CONNECT tunnel established via {proxy_host}:{proxy_port} -> {target_host}:{target_port}"
|
||||
);
|
||||
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
/// Lazily-initialized TLS connector for raw connections.
|
||||
///
|
||||
/// Loads both webpki roots AND native system certificates so that
|
||||
/// proxy MITM CAs (e.g. Clash, mitmproxy) installed in the system
|
||||
/// keychain are trusted through the CONNECT tunnel.
|
||||
fn global_tls_connector() -> &'static tokio_rustls::TlsConnector {
|
||||
static CONNECTOR: OnceLock<tokio_rustls::TlsConnector> = OnceLock::new();
|
||||
CONNECTOR.get_or_init(|| {
|
||||
let mut root_store = rustls::RootCertStore::empty();
|
||||
// Baseline: Mozilla/webpki roots
|
||||
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
// Native system certs (includes user-installed proxy CAs)
|
||||
let native = rustls_native_certs::load_native_certs();
|
||||
let (added, _errors) = root_store.add_parsable_certificates(native.certs);
|
||||
log::debug!("[HyperClient] TLS root store: webpki + {added} native certs");
|
||||
let config = rustls::ClientConfig::builder()
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth();
|
||||
tokio_rustls::TlsConnector::from(std::sync::Arc::new(config))
|
||||
})
|
||||
}
|
||||
|
||||
/// Build raw HTTP/1.1 request bytes with original header casing.
|
||||
fn build_raw_request(
|
||||
method: &http::Method,
|
||||
path_and_query: &str,
|
||||
headers: &http::HeaderMap,
|
||||
original_cases: &OriginalHeaderCases,
|
||||
body: &[u8],
|
||||
) -> Vec<u8> {
|
||||
let mut raw = Vec::with_capacity(4096 + body.len());
|
||||
|
||||
// Request line
|
||||
raw.extend_from_slice(method.as_str().as_bytes());
|
||||
raw.extend_from_slice(b" ");
|
||||
raw.extend_from_slice(path_and_query.as_bytes());
|
||||
raw.extend_from_slice(b" HTTP/1.1\r\n");
|
||||
|
||||
// Headers with original casing, emitted in original wire order.
|
||||
//
|
||||
// Strategy:
|
||||
// 1. Walk `original_cases.cases` in order — this preserves the exact
|
||||
// header sequence the client sent. For each entry, emit the stored
|
||||
// original-casing name plus the current value from `headers` (the
|
||||
// proxy may have rewritten the value, e.g. Authorization).
|
||||
// Repeated headers with the same name are handled by tracking a
|
||||
// per-name value cursor so we step through `get_all()` in order.
|
||||
// 2. After the original headers, append any headers that exist in
|
||||
// `headers` but were not present in the original request (i.e. added
|
||||
// by the proxy). These are emitted in lowercase.
|
||||
//
|
||||
// This replaces the old `for name in headers.keys()` loop which iterated
|
||||
// in hash-map order, destroying the original header sequence.
|
||||
let mut emitted: std::collections::HashSet<String> =
|
||||
std::collections::HashSet::with_capacity(original_cases.cases.len());
|
||||
// Per-name cursor: how many values we have already emitted for each name.
|
||||
let mut value_cursor: std::collections::HashMap<String, usize> =
|
||||
std::collections::HashMap::with_capacity(original_cases.cases.len());
|
||||
|
||||
for (lower_name, orig_name_bytes) in &original_cases.cases {
|
||||
if let Ok(header_name) = http::header::HeaderName::from_bytes(lower_name.as_bytes()) {
|
||||
let all_values: Vec<_> = headers.get_all(&header_name).iter().collect();
|
||||
let cursor = value_cursor.entry(lower_name.clone()).or_insert(0);
|
||||
if let Some(value) = all_values.get(*cursor) {
|
||||
raw.extend_from_slice(orig_name_bytes);
|
||||
raw.extend_from_slice(b": ");
|
||||
raw.extend_from_slice(value.as_bytes());
|
||||
raw.extend_from_slice(b"\r\n");
|
||||
*cursor += 1;
|
||||
emitted.insert(lower_name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Append proxy-added headers (not present in the original request).
|
||||
for name in headers.keys() {
|
||||
let lower = name.as_str().to_ascii_lowercase();
|
||||
if !emitted.contains(&lower) {
|
||||
for value in headers.get_all(name) {
|
||||
raw.extend_from_slice(name.as_str().as_bytes());
|
||||
raw.extend_from_slice(b": ");
|
||||
raw.extend_from_slice(value.as_bytes());
|
||||
raw.extend_from_slice(b"\r\n");
|
||||
}
|
||||
emitted.insert(lower);
|
||||
}
|
||||
}
|
||||
|
||||
// Add Content-Length if not already present
|
||||
if !headers.contains_key(http::header::CONTENT_LENGTH) {
|
||||
raw.extend_from_slice(b"Content-Length: ");
|
||||
raw.extend_from_slice(body.len().to_string().as_bytes());
|
||||
raw.extend_from_slice(b"\r\n");
|
||||
}
|
||||
|
||||
// End of headers + body
|
||||
raw.extend_from_slice(b"\r\n");
|
||||
raw.extend_from_slice(body);
|
||||
|
||||
raw
|
||||
}
|
||||
|
||||
/// Use hyper's low-level client to parse the response on a stream where we've
|
||||
/// already written the request.
|
||||
///
|
||||
/// `WriteFilter` discards any writes from hyper (it would try to send its own
|
||||
/// request encoding), while passing reads through transparently.
|
||||
async fn do_hyper_response<S>(
|
||||
stream: WriteFilter<S>,
|
||||
method: http::Method,
|
||||
) -> Result<ProxyResponse, ProxyError>
|
||||
where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
let io = hyper_util::rt::TokioIo::new(stream);
|
||||
|
||||
let (mut sender, conn) = hyper::client::conn::http1::Builder::new()
|
||||
.preserve_header_case(true)
|
||||
.handshake::<_, http_body_util::Full<Bytes>>(io)
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Handshake failed: {e}")))?;
|
||||
|
||||
// Spawn the connection driver (reads responses from the stream)
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = conn.await {
|
||||
log::debug!("[HyperClient] raw conn driver error: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
// Send a dummy request through hyper — hyper will encode this and try to write it,
|
||||
// but WriteFilter discards all writes. Hyper will then read the response from the stream.
|
||||
let dummy_req = http::Request::builder()
|
||||
.method(method)
|
||||
.uri("/")
|
||||
.body(http_body_util::Full::new(Bytes::new()))
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Build dummy request: {e}")))?;
|
||||
|
||||
let resp = sender
|
||||
.send_request(dummy_req)
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Response parse failed: {e}")))?;
|
||||
|
||||
Ok(ProxyResponse::Hyper(resp))
|
||||
}
|
||||
|
||||
/// A stream wrapper that discards all writes but passes reads through.
|
||||
///
|
||||
/// This lets hyper's connection driver think it sent a request (its encoded bytes
|
||||
/// go to /dev/null), while correctly parsing the response that the upstream server
|
||||
/// sends in reply to our raw-written request.
|
||||
struct WriteFilter<S> {
|
||||
inner: S,
|
||||
}
|
||||
|
||||
impl<S> WriteFilter<S> {
|
||||
fn new(inner: S) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: tokio::io::AsyncRead + Unpin> tokio::io::AsyncRead for WriteFilter<S> {
|
||||
fn poll_read(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &mut tokio::io::ReadBuf<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
// Pass reads through to the underlying stream
|
||||
let inner = std::pin::Pin::new(&mut self.get_mut().inner);
|
||||
inner.poll_read(cx, buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Unpin> tokio::io::AsyncWrite for WriteFilter<S> {
|
||||
fn poll_write(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> std::task::Poll<std::io::Result<usize>> {
|
||||
// Discard all writes — pretend they succeeded
|
||||
std::task::Poll::Ready(Ok(buf.len()))
|
||||
}
|
||||
|
||||
fn poll_flush(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn poll_shutdown(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,6 @@ pub mod srv {
|
||||
pub const STOPPED: &str = "SRV-002";
|
||||
pub const STOP_TIMEOUT: &str = "SRV-003";
|
||||
pub const TASK_ERROR: &str = "SRV-004";
|
||||
pub const ACCEPT_ERR: &str = "SRV-005";
|
||||
pub const CONN_ERR: &str = "SRV-006";
|
||||
}
|
||||
|
||||
/// 转发器日志码
|
||||
|
||||
@@ -14,7 +14,6 @@ pub mod handler_context;
|
||||
mod handlers;
|
||||
mod health;
|
||||
pub mod http_client;
|
||||
pub mod hyper_client;
|
||||
pub mod log_codes;
|
||||
pub mod model_mapper;
|
||||
pub mod provider_router;
|
||||
@@ -24,7 +23,6 @@ pub mod response_processor;
|
||||
pub(crate) mod server;
|
||||
pub mod session;
|
||||
pub(crate) mod sse;
|
||||
pub(crate) mod switch_lock;
|
||||
pub mod thinking_budget_rectifier;
|
||||
pub mod thinking_optimizer;
|
||||
pub mod thinking_rectifier;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use super::auth::AuthInfo;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use reqwest::RequestBuilder;
|
||||
use serde_json::Value;
|
||||
|
||||
/// 供应商适配器 Trait
|
||||
@@ -13,36 +14,116 @@ use serde_json::Value;
|
||||
/// - URL 构建
|
||||
/// - 认证信息提取和头部注入
|
||||
/// - 请求/响应格式转换(可选)
|
||||
///
|
||||
/// # 示例
|
||||
///
|
||||
/// ```ignore
|
||||
/// pub struct ClaudeAdapter;
|
||||
///
|
||||
/// impl ProviderAdapter for ClaudeAdapter {
|
||||
/// fn name(&self) -> &'static str { "Claude" }
|
||||
///
|
||||
/// fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
|
||||
/// // 从 provider 配置中提取 base_url
|
||||
/// }
|
||||
///
|
||||
/// fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
|
||||
/// // 从 provider 配置中提取认证信息
|
||||
/// }
|
||||
///
|
||||
/// fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
||||
/// format!("{}{}", base_url.trim_end_matches('/'), endpoint)
|
||||
/// }
|
||||
///
|
||||
/// fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||||
/// // 添加认证头
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub trait ProviderAdapter: Send + Sync {
|
||||
/// 适配器名称(用于日志和调试)
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// 从 Provider 配置中提取 base_url
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `provider` - Provider 配置
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(String)` - 提取到的 base_url(已去除尾部斜杠)
|
||||
/// * `Err(ProxyError)` - 提取失败
|
||||
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError>;
|
||||
|
||||
/// 从 Provider 配置中提取认证信息
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `provider` - Provider 配置
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Some(AuthInfo)` - 提取到的认证信息
|
||||
/// * `None` - 未找到认证信息
|
||||
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo>;
|
||||
|
||||
/// 构建请求 URL
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `base_url` - 基础 URL
|
||||
/// * `endpoint` - 请求端点(如 `/v1/messages`)
|
||||
///
|
||||
/// # Returns
|
||||
/// 完整的请求 URL
|
||||
fn build_url(&self, base_url: &str, endpoint: &str) -> String;
|
||||
|
||||
/// Return auth headers as `(name, value)` pairs.
|
||||
/// 添加认证头到请求
|
||||
///
|
||||
/// The forwarder inserts these at the position of the original auth header
|
||||
/// so that header order is preserved.
|
||||
fn get_auth_headers(&self, auth: &AuthInfo) -> Vec<(http::HeaderName, http::HeaderValue)>;
|
||||
/// # Arguments
|
||||
/// * `request` - reqwest RequestBuilder
|
||||
/// * `auth` - 认证信息
|
||||
///
|
||||
/// # Returns
|
||||
/// 添加了认证头的 RequestBuilder
|
||||
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder;
|
||||
|
||||
/// 是否需要格式转换
|
||||
///
|
||||
/// 默认返回 `false`(透传模式)。
|
||||
/// 仅当供应商需要格式转换时(如 Claude + OpenRouter 旧 OpenAI 兼容接口)才返回 `true`。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `provider` - Provider 配置
|
||||
fn needs_transform(&self, _provider: &Provider) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// 转换请求体
|
||||
///
|
||||
/// 将请求体从一种格式转换为另一种格式(如 Anthropic → OpenAI)。
|
||||
/// 默认实现直接返回原始请求体(透传)。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `body` - 原始请求体
|
||||
/// * `provider` - Provider 配置(用于获取模型映射等)
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(Value)` - 转换后的请求体
|
||||
/// * `Err(ProxyError)` - 转换失败
|
||||
fn transform_request(&self, body: Value, _provider: &Provider) -> Result<Value, ProxyError> {
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// 转换响应体
|
||||
///
|
||||
/// 将响应体从一种格式转换为另一种格式(如 OpenAI → Anthropic)。
|
||||
/// 默认实现直接返回原始响应体(透传)。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `body` - 原始响应体
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(Value)` - 转换后的响应体
|
||||
/// * `Err(ProxyError)` - 转换失败
|
||||
///
|
||||
/// Note: 响应转换将在 handler 层集成,目前预留接口
|
||||
#[allow(dead_code)]
|
||||
fn transform_response(&self, body: Value) -> Result<Value, ProxyError> {
|
||||
Ok(body)
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use reqwest::RequestBuilder;
|
||||
|
||||
/// 获取 Claude 供应商的 API 格式
|
||||
///
|
||||
@@ -65,30 +66,6 @@ pub fn get_claude_api_format(provider: &Provider) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn claude_api_format_needs_transform(api_format: &str) -> bool {
|
||||
matches!(api_format, "openai_chat" | "openai_responses")
|
||||
}
|
||||
|
||||
pub fn transform_claude_request_for_api_format(
|
||||
body: serde_json::Value,
|
||||
provider: &Provider,
|
||||
api_format: &str,
|
||||
) -> Result<serde_json::Value, ProxyError> {
|
||||
let cache_key = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.prompt_cache_key.as_deref())
|
||||
.unwrap_or(&provider.id);
|
||||
|
||||
match api_format {
|
||||
"openai_responses" => {
|
||||
super::transform_responses::anthropic_to_responses(body, Some(cache_key))
|
||||
}
|
||||
"openai_chat" => super::transform::anthropic_to_openai(body, Some(cache_key)),
|
||||
_ => Ok(body),
|
||||
}
|
||||
}
|
||||
|
||||
/// Claude 适配器
|
||||
pub struct ClaudeAdapter;
|
||||
|
||||
@@ -336,50 +313,32 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
base
|
||||
}
|
||||
|
||||
fn get_auth_headers(&self, auth: &AuthInfo) -> Vec<(http::HeaderName, http::HeaderValue)> {
|
||||
use http::{HeaderName, HeaderValue};
|
||||
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||||
// 注意:anthropic-version 由 forwarder.rs 统一处理(透传客户端值或设置默认值)
|
||||
let bearer = format!("Bearer {}", auth.api_key);
|
||||
// 这里不再设置 anthropic-version,避免 header 重复
|
||||
match auth.strategy {
|
||||
AuthStrategy::Anthropic | AuthStrategy::ClaudeAuth | AuthStrategy::Bearer => {
|
||||
vec![(
|
||||
HeaderName::from_static("authorization"),
|
||||
HeaderValue::from_str(&bearer).unwrap(),
|
||||
)]
|
||||
// Anthropic 官方: Authorization Bearer + x-api-key
|
||||
AuthStrategy::Anthropic => request
|
||||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("x-api-key", &auth.api_key),
|
||||
// ClaudeAuth 中转服务: 仅 Bearer,无 x-api-key
|
||||
AuthStrategy::ClaudeAuth => {
|
||||
request.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
}
|
||||
AuthStrategy::GitHubCopilot => {
|
||||
vec![
|
||||
(
|
||||
HeaderName::from_static("authorization"),
|
||||
HeaderValue::from_str(&bearer).unwrap(),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("editor-version"),
|
||||
HeaderValue::from_static(super::copilot_auth::COPILOT_EDITOR_VERSION),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("editor-plugin-version"),
|
||||
HeaderValue::from_static(super::copilot_auth::COPILOT_PLUGIN_VERSION),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("copilot-integration-id"),
|
||||
HeaderValue::from_static(super::copilot_auth::COPILOT_INTEGRATION_ID),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("user-agent"),
|
||||
HeaderValue::from_static(super::copilot_auth::COPILOT_USER_AGENT),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("x-github-api-version"),
|
||||
HeaderValue::from_static(super::copilot_auth::COPILOT_API_VERSION),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("openai-intent"),
|
||||
HeaderValue::from_static("conversation-panel"),
|
||||
),
|
||||
]
|
||||
// OpenRouter: Bearer
|
||||
AuthStrategy::Bearer => {
|
||||
request.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
}
|
||||
_ => vec![],
|
||||
// GitHub Copilot: Bearer + 统一指纹头
|
||||
AuthStrategy::GitHubCopilot => request
|
||||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("editor-version", super::copilot_auth::COPILOT_EDITOR_VERSION)
|
||||
.header("editor-plugin-version", super::copilot_auth::COPILOT_PLUGIN_VERSION)
|
||||
.header("copilot-integration-id", super::copilot_auth::COPILOT_INTEGRATION_ID)
|
||||
.header("user-agent", super::copilot_auth::COPILOT_USER_AGENT)
|
||||
.header("x-github-api-version", super::copilot_auth::COPILOT_API_VERSION)
|
||||
.header("openai-intent", "conversation-panel"),
|
||||
_ => request,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,7 +363,19 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
body: serde_json::Value,
|
||||
provider: &Provider,
|
||||
) -> Result<serde_json::Value, ProxyError> {
|
||||
transform_claude_request_for_api_format(body, provider, self.get_api_format(provider))
|
||||
// Use meta.prompt_cache_key if set by user, otherwise fall back to provider.id
|
||||
let cache_key = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.prompt_cache_key.as_deref())
|
||||
.unwrap_or(&provider.id);
|
||||
|
||||
match self.get_api_format(provider) {
|
||||
"openai_responses" => {
|
||||
super::transform_responses::anthropic_to_responses(body, Some(cache_key))
|
||||
}
|
||||
_ => super::transform::anthropic_to_openai(body, Some(cache_key)),
|
||||
}
|
||||
}
|
||||
|
||||
fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
|
||||
@@ -810,25 +781,4 @@ mod tests {
|
||||
// GitHub Copilot always needs transform
|
||||
assert!(adapter.needs_transform(&copilot));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_claude_request_for_api_format_responses() {
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
|
||||
}
|
||||
}));
|
||||
let body = json!({
|
||||
"model": "gpt-5.4",
|
||||
"messages": [{ "role": "user", "content": "hello" }],
|
||||
"max_tokens": 128
|
||||
});
|
||||
|
||||
let transformed =
|
||||
transform_claude_request_for_api_format(body, &provider, "openai_responses").unwrap();
|
||||
|
||||
assert_eq!(transformed["model"], "gpt-5.4");
|
||||
assert!(transformed.get("input").is_some());
|
||||
assert!(transformed.get("max_output_tokens").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use super::{AuthInfo, AuthStrategy, ProviderAdapter};
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use regex::Regex;
|
||||
use reqwest::RequestBuilder;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
/// 官方 Codex 客户端 User-Agent 正则
|
||||
@@ -173,12 +174,8 @@ impl ProviderAdapter for CodexAdapter {
|
||||
url
|
||||
}
|
||||
|
||||
fn get_auth_headers(&self, auth: &AuthInfo) -> Vec<(http::HeaderName, http::HeaderValue)> {
|
||||
let bearer = format!("Bearer {}", auth.api_key);
|
||||
vec![(
|
||||
http::HeaderName::from_static("authorization"),
|
||||
http::HeaderValue::from_str(&bearer).unwrap(),
|
||||
)]
|
||||
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||||
request.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -310,8 +310,6 @@ pub struct CopilotAuthManager {
|
||||
refresh_locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
|
||||
/// Copilot Token 缓存(key = GitHub user ID,内存缓存,自动刷新)
|
||||
copilot_tokens: Arc<RwLock<HashMap<String, CopilotToken>>>,
|
||||
/// Copilot Models 缓存(key = GitHub user ID,仅进程内复用)
|
||||
copilot_models: Arc<RwLock<HashMap<String, Vec<CopilotModel>>>>,
|
||||
/// HTTP 客户端
|
||||
http_client: Client,
|
||||
/// 存储路径
|
||||
@@ -332,7 +330,6 @@ impl CopilotAuthManager {
|
||||
default_account_id: Arc::new(RwLock::new(None)),
|
||||
refresh_locks: Arc::new(RwLock::new(HashMap::new())),
|
||||
copilot_tokens: Arc::new(RwLock::new(HashMap::new())),
|
||||
copilot_models: Arc::new(RwLock::new(HashMap::new())),
|
||||
http_client: Client::new(),
|
||||
storage_path,
|
||||
pending_migration: Arc::new(RwLock::new(None)),
|
||||
@@ -341,7 +338,7 @@ impl CopilotAuthManager {
|
||||
|
||||
// 尝试从磁盘加载(同步,不发起网络请求)
|
||||
if let Err(e) = manager.load_from_disk_sync() {
|
||||
log::warn!("[CopilotAuth] 加载存储失败: {e}");
|
||||
log::warn!("[CopilotAuth] 加载存储失败: {}", e);
|
||||
}
|
||||
|
||||
manager
|
||||
@@ -364,7 +361,7 @@ impl CopilotAuthManager {
|
||||
|
||||
/// 移除指定账号
|
||||
pub async fn remove_account(&self, account_id: &str) -> Result<(), CopilotAuthError> {
|
||||
log::info!("[CopilotAuth] 移除账号: {account_id}");
|
||||
log::info!("[CopilotAuth] 移除账号: {}", account_id);
|
||||
|
||||
{
|
||||
let mut accounts = self.accounts.write().await;
|
||||
@@ -378,10 +375,6 @@ impl CopilotAuthManager {
|
||||
let mut tokens = self.copilot_tokens.write().await;
|
||||
tokens.remove(account_id);
|
||||
}
|
||||
{
|
||||
let mut models = self.copilot_models.write().await;
|
||||
models.remove(account_id);
|
||||
}
|
||||
{
|
||||
let mut refresh_locks = self.refresh_locks.write().await;
|
||||
refresh_locks.remove(account_id);
|
||||
@@ -482,7 +475,8 @@ impl CopilotAuthManager {
|
||||
let status = response.status();
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
return Err(CopilotAuthError::NetworkError(format!(
|
||||
"GitHub 设备码请求失败: {status} - {text}"
|
||||
"GitHub 设备码请求失败: {} - {}",
|
||||
status, text
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -580,7 +574,10 @@ impl CopilotAuthManager {
|
||||
}
|
||||
|
||||
// 需要刷新
|
||||
log::info!("[CopilotAuth] 账号 {account_id} 的 Copilot Token 需要刷新");
|
||||
log::info!(
|
||||
"[CopilotAuth] 账号 {} 的 Copilot Token 需要刷新",
|
||||
account_id
|
||||
);
|
||||
|
||||
let refresh_lock = self.get_refresh_lock(account_id).await;
|
||||
let _refresh_guard = refresh_lock.lock().await;
|
||||
@@ -632,36 +629,15 @@ impl CopilotAuthManager {
|
||||
pub async fn fetch_models_for_account(
|
||||
&self,
|
||||
account_id: &str,
|
||||
) -> Result<Vec<CopilotModel>, CopilotAuthError> {
|
||||
self.ensure_migration_complete().await?;
|
||||
|
||||
{
|
||||
let models = self.copilot_models.read().await;
|
||||
if let Some(cached) = models.get(account_id) {
|
||||
return Ok(cached.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let models = self.fetch_models_for_account_uncached(account_id).await?;
|
||||
{
|
||||
let mut cache = self.copilot_models.write().await;
|
||||
cache.insert(account_id.to_string(), models.clone());
|
||||
}
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
async fn fetch_models_for_account_uncached(
|
||||
&self,
|
||||
account_id: &str,
|
||||
) -> Result<Vec<CopilotModel>, CopilotAuthError> {
|
||||
let copilot_token = self.get_valid_token_for_account(account_id).await?;
|
||||
|
||||
log::info!("[CopilotAuth] 获取账号 {account_id} 的 Copilot 可用模型");
|
||||
log::info!("[CopilotAuth] 获取账号 {} 的 Copilot 可用模型", account_id);
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.get(COPILOT_MODELS_URL)
|
||||
.header("Authorization", format!("Bearer {copilot_token}"))
|
||||
.header("Authorization", format!("Bearer {}", copilot_token))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("copilot-integration-id", "vscode-chat")
|
||||
.header("editor-version", COPILOT_EDITOR_VERSION)
|
||||
@@ -675,7 +651,8 @@ impl CopilotAuthManager {
|
||||
let status = response.status();
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
return Err(CopilotAuthError::CopilotTokenFetchFailed(format!(
|
||||
"获取模型列表失败: {status} - {text}"
|
||||
"获取模型列表失败: {} - {}",
|
||||
status, text
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -701,18 +678,6 @@ impl CopilotAuthManager {
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
pub async fn get_model_vendor_for_account(
|
||||
&self,
|
||||
account_id: &str,
|
||||
model_id: &str,
|
||||
) -> Result<Option<String>, CopilotAuthError> {
|
||||
let models = self.fetch_models_for_account(account_id).await?;
|
||||
Ok(models
|
||||
.into_iter()
|
||||
.find(|model| model.id == model_id)
|
||||
.map(|model| model.vendor))
|
||||
}
|
||||
|
||||
/// 获取 Copilot 可用模型列表(向后兼容:使用第一个账号)
|
||||
pub async fn fetch_models(&self) -> Result<Vec<CopilotModel>, CopilotAuthError> {
|
||||
match self.resolve_default_account_id().await {
|
||||
@@ -721,16 +686,6 @@ impl CopilotAuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_model_vendor(
|
||||
&self,
|
||||
model_id: &str,
|
||||
) -> Result<Option<String>, CopilotAuthError> {
|
||||
match self.resolve_default_account_id().await {
|
||||
Some(id) => self.get_model_vendor_for_account(&id, model_id).await,
|
||||
None => Err(CopilotAuthError::GitHubTokenInvalid),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取指定账号的 Copilot 使用量信息
|
||||
pub async fn fetch_usage_for_account(
|
||||
&self,
|
||||
@@ -744,12 +699,12 @@ impl CopilotAuthManager {
|
||||
.ok_or_else(|| CopilotAuthError::AccountNotFound(account_id.to_string()))?
|
||||
};
|
||||
|
||||
log::info!("[CopilotAuth] 获取账号 {account_id} 的 Copilot 使用量");
|
||||
log::info!("[CopilotAuth] 获取账号 {} 的 Copilot 使用量", account_id);
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.get(COPILOT_USAGE_URL)
|
||||
.header("Authorization", format!("token {github_token}"))
|
||||
.header("Authorization", format!("token {}", github_token))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("editor-version", COPILOT_EDITOR_VERSION)
|
||||
.header("editor-plugin-version", COPILOT_PLUGIN_VERSION)
|
||||
@@ -766,7 +721,8 @@ impl CopilotAuthManager {
|
||||
let status = response.status();
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
return Err(CopilotAuthError::CopilotTokenFetchFailed(format!(
|
||||
"获取使用量失败: {status} - {text}"
|
||||
"获取使用量失败: {} - {}",
|
||||
status, text
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -994,7 +950,7 @@ impl CopilotAuthManager {
|
||||
let response = self
|
||||
.http_client
|
||||
.get(GITHUB_USER_URL)
|
||||
.header("Authorization", format!("token {github_token}"))
|
||||
.header("Authorization", format!("token {}", github_token))
|
||||
.header("User-Agent", COPILOT_USER_AGENT)
|
||||
.header("Editor-Version", COPILOT_EDITOR_VERSION)
|
||||
.header("Editor-Plugin-Version", COPILOT_PLUGIN_VERSION)
|
||||
@@ -1021,12 +977,12 @@ impl CopilotAuthManager {
|
||||
github_token: &str,
|
||||
account_id: &str,
|
||||
) -> Result<(), CopilotAuthError> {
|
||||
log::debug!("[CopilotAuth] 获取账号 {account_id} 的 Copilot Token");
|
||||
log::debug!("[CopilotAuth] 获取账号 {} 的 Copilot Token", account_id);
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.get(COPILOT_TOKEN_URL)
|
||||
.header("Authorization", format!("token {github_token}"))
|
||||
.header("Authorization", format!("token {}", github_token))
|
||||
.header("User-Agent", COPILOT_USER_AGENT)
|
||||
.header("Editor-Version", COPILOT_EDITOR_VERSION)
|
||||
.header("Editor-Plugin-Version", COPILOT_PLUGIN_VERSION)
|
||||
@@ -1045,7 +1001,8 @@ impl CopilotAuthManager {
|
||||
let status = response.status();
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
return Err(CopilotAuthError::CopilotTokenFetchFailed(format!(
|
||||
"{status}: {text}"
|
||||
"{}: {}",
|
||||
status, text
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -1128,7 +1085,7 @@ impl CopilotAuthManager {
|
||||
.fetch_copilot_token_with_github_token(&legacy_token, &account_id)
|
||||
.await
|
||||
{
|
||||
log::warn!("[CopilotAuth] 迁移时验证 Copilot 订阅失败: {e}");
|
||||
log::warn!("[CopilotAuth] 迁移时验证 Copilot 订阅失败: {}", e);
|
||||
}
|
||||
|
||||
// 添加账号
|
||||
@@ -1142,7 +1099,7 @@ impl CopilotAuthManager {
|
||||
"Legacy Copilot auth migration failed: {e}"
|
||||
)))
|
||||
.await;
|
||||
log::warn!("[CopilotAuth] 迁移失败,旧 token 可能已失效: {e}");
|
||||
log::warn!("[CopilotAuth] 迁移失败,旧 token 可能已失效: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1186,7 +1143,6 @@ impl CopilotAuthManager {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_copilot_token_expiry() {
|
||||
@@ -1359,59 +1315,4 @@ mod tests {
|
||||
Some("67890".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_model_vendor_from_cache() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
{
|
||||
let mut default_account_id = manager.default_account_id.write().await;
|
||||
*default_account_id = Some("12345".to_string());
|
||||
}
|
||||
{
|
||||
let mut accounts = manager.accounts.write().await;
|
||||
accounts.insert(
|
||||
"12345".to_string(),
|
||||
GitHubAccountData {
|
||||
github_token: "gho_test".to_string(),
|
||||
user: GitHubUser {
|
||||
login: "alice".to_string(),
|
||||
id: 12345,
|
||||
avatar_url: None,
|
||||
},
|
||||
authenticated_at: 1700000000,
|
||||
},
|
||||
);
|
||||
}
|
||||
{
|
||||
let mut models = manager.copilot_models.write().await;
|
||||
models.insert(
|
||||
"12345".to_string(),
|
||||
vec![
|
||||
CopilotModel {
|
||||
id: "gpt-5.4".to_string(),
|
||||
name: "GPT-5.4".to_string(),
|
||||
vendor: "OpenAI".to_string(),
|
||||
model_picker_enabled: true,
|
||||
},
|
||||
CopilotModel {
|
||||
id: "claude-sonnet-4".to_string(),
|
||||
name: "Claude Sonnet 4".to_string(),
|
||||
vendor: "Anthropic".to_string(),
|
||||
model_picker_enabled: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
let vendor = manager
|
||||
.get_model_vendor_for_account("12345", "gpt-5.4")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(vendor.as_deref(), Some("OpenAI"));
|
||||
|
||||
let default_vendor = manager.get_model_vendor("claude-sonnet-4").await.unwrap();
|
||||
assert_eq!(default_vendor.as_deref(), Some("Anthropic"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use reqwest::RequestBuilder;
|
||||
|
||||
/// Gemini 适配器
|
||||
pub struct GeminiAdapter;
|
||||
@@ -216,26 +217,17 @@ impl ProviderAdapter for GeminiAdapter {
|
||||
url
|
||||
}
|
||||
|
||||
fn get_auth_headers(&self, auth: &AuthInfo) -> Vec<(http::HeaderName, http::HeaderValue)> {
|
||||
use http::{HeaderName, HeaderValue};
|
||||
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||||
match auth.strategy {
|
||||
// OAuth Bearer 认证
|
||||
AuthStrategy::GoogleOAuth => {
|
||||
let token = auth.access_token.as_ref().unwrap_or(&auth.api_key);
|
||||
vec![
|
||||
(
|
||||
HeaderName::from_static("authorization"),
|
||||
HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("x-goog-api-client"),
|
||||
HeaderValue::from_static("GeminiCLI/1.0"),
|
||||
),
|
||||
]
|
||||
request
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.header("x-goog-api-client", "GeminiCLI/1.0")
|
||||
}
|
||||
_ => vec![(
|
||||
HeaderName::from_static("x-goog-api-key"),
|
||||
HeaderValue::from_str(&auth.api_key).unwrap(),
|
||||
)],
|
||||
// API Key 认证
|
||||
_ => request.header("x-goog-api-key", &auth.api_key),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,10 +30,7 @@ use serde::{Deserialize, Serialize};
|
||||
// 公开导出
|
||||
pub use adapter::ProviderAdapter;
|
||||
pub use auth::{AuthInfo, AuthStrategy};
|
||||
pub use claude::{
|
||||
claude_api_format_needs_transform, get_claude_api_format,
|
||||
transform_claude_request_for_api_format, ClaudeAdapter,
|
||||
};
|
||||
pub use claude::{get_claude_api_format, ClaudeAdapter};
|
||||
pub use codex::CodexAdapter;
|
||||
pub use gemini::GeminiAdapter;
|
||||
|
||||
|
||||
@@ -88,8 +88,8 @@ struct ToolBlockState {
|
||||
}
|
||||
|
||||
/// 创建 Anthropic SSE 流
|
||||
pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
|
||||
stream: impl Stream<Item = Result<Bytes, E>> + Send + 'static,
|
||||
pub fn create_anthropic_sse_stream(
|
||||
stream: impl Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static,
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
||||
async_stream::stream! {
|
||||
let mut buffer = String::new();
|
||||
@@ -598,9 +598,7 @@ mod tests {
|
||||
"data: [DONE]\n\n"
|
||||
);
|
||||
|
||||
let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
|
||||
input.as_bytes().to_vec(),
|
||||
))]);
|
||||
let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);
|
||||
let converted = create_anthropic_sse_stream(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
|
||||
@@ -688,9 +686,7 @@ mod tests {
|
||||
"data: [DONE]\n\n"
|
||||
);
|
||||
|
||||
let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
|
||||
input.as_bytes().to_vec(),
|
||||
))]);
|
||||
let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);
|
||||
let converted = create_anthropic_sse_stream(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
let merged = chunks
|
||||
|
||||
@@ -96,8 +96,8 @@ fn resolve_content_index(
|
||||
///
|
||||
/// 状态机跟踪: message_id, current_model, has_sent_message_start, item/content index map
|
||||
/// SSE 解析支持 named events (event: + data: 行)
|
||||
pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send + 'static>(
|
||||
stream: impl Stream<Item = Result<Bytes, E>> + Send + 'static,
|
||||
pub fn create_anthropic_sse_stream_from_responses(
|
||||
stream: impl Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static,
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
||||
async_stream::stream! {
|
||||
let mut buffer = String::new();
|
||||
@@ -109,7 +109,6 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
||||
let mut index_by_key: HashMap<String, u32> = HashMap::new();
|
||||
let mut open_indices: HashSet<u32> = HashSet::new();
|
||||
let mut fallback_open_index: Option<u32> = None;
|
||||
let mut current_text_index: Option<u32> = None;
|
||||
let mut tool_index_by_item_id: HashMap<String, u32> = HashMap::new();
|
||||
let mut last_tool_index: Option<u32> = None;
|
||||
|
||||
@@ -219,18 +218,12 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
||||
if let Some(part) = data.get("part") {
|
||||
let part_type = part.get("type").and_then(|t| t.as_str());
|
||||
if matches!(part_type, Some("output_text") | Some("refusal")) {
|
||||
let index = if let Some(index) = current_text_index {
|
||||
index
|
||||
} else {
|
||||
let index = resolve_content_index(
|
||||
&data,
|
||||
&mut next_content_index,
|
||||
&mut index_by_key,
|
||||
&mut fallback_open_index,
|
||||
);
|
||||
current_text_index = Some(index);
|
||||
index
|
||||
};
|
||||
let index = resolve_content_index(
|
||||
&data,
|
||||
&mut next_content_index,
|
||||
&mut index_by_key,
|
||||
&mut fallback_open_index,
|
||||
);
|
||||
|
||||
if open_indices.contains(&index) {
|
||||
continue;
|
||||
@@ -257,18 +250,12 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
||||
// ================================================
|
||||
"response.output_text.delta" => {
|
||||
if let Some(delta) = data.get("delta").and_then(|d| d.as_str()) {
|
||||
let index = if let Some(index) = current_text_index {
|
||||
index
|
||||
} else {
|
||||
let index = resolve_content_index(
|
||||
&data,
|
||||
&mut next_content_index,
|
||||
&mut index_by_key,
|
||||
&mut fallback_open_index,
|
||||
);
|
||||
current_text_index = Some(index);
|
||||
index
|
||||
};
|
||||
let index = resolve_content_index(
|
||||
&data,
|
||||
&mut next_content_index,
|
||||
&mut index_by_key,
|
||||
&mut fallback_open_index,
|
||||
);
|
||||
|
||||
if !open_indices.contains(&index) {
|
||||
let start_event = json!({
|
||||
@@ -303,18 +290,12 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
||||
// ================================================
|
||||
"response.refusal.delta" => {
|
||||
if let Some(delta) = data.get("delta").and_then(|d| d.as_str()) {
|
||||
let index = if let Some(index) = current_text_index {
|
||||
index
|
||||
} else {
|
||||
let index = resolve_content_index(
|
||||
&data,
|
||||
&mut next_content_index,
|
||||
&mut index_by_key,
|
||||
&mut fallback_open_index,
|
||||
);
|
||||
current_text_index = Some(index);
|
||||
index
|
||||
};
|
||||
let index = resolve_content_index(
|
||||
&data,
|
||||
&mut next_content_index,
|
||||
&mut index_by_key,
|
||||
&mut fallback_open_index,
|
||||
);
|
||||
|
||||
if !open_indices.contains(&index) {
|
||||
let start_event = json!({
|
||||
@@ -348,7 +329,29 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
||||
// ================================================
|
||||
// response.content_part.done → content_block_stop
|
||||
// ================================================
|
||||
"response.content_part.done" => {}
|
||||
"response.content_part.done" => {
|
||||
let key = content_part_key(&data);
|
||||
let index = if let Some(k) = key {
|
||||
index_by_key.get(&k).copied()
|
||||
} else {
|
||||
fallback_open_index
|
||||
};
|
||||
if let Some(index) = index {
|
||||
if !open_indices.remove(&index) {
|
||||
continue;
|
||||
}
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": index
|
||||
});
|
||||
let sse = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse));
|
||||
if fallback_open_index == Some(index) {
|
||||
fallback_open_index = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// response.output_item.added (function_call) → content_block_start (tool_use)
|
||||
@@ -358,20 +361,6 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
||||
let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
if item_type == "function_call" {
|
||||
has_tool_use = true;
|
||||
if let Some(index) = current_text_index.take() {
|
||||
if open_indices.remove(&index) {
|
||||
let stop_event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": index
|
||||
});
|
||||
let stop_sse = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&stop_event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(stop_sse));
|
||||
}
|
||||
if fallback_open_index == Some(index) {
|
||||
fallback_open_index = None;
|
||||
}
|
||||
}
|
||||
// 确保 message_start 已发送
|
||||
if !has_sent_message_start {
|
||||
let start_event = json!({
|
||||
@@ -532,14 +521,12 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
||||
// response.refusal.done → content_block_stop
|
||||
// ================================================
|
||||
"response.refusal.done" => {
|
||||
let index = current_text_index.take().or_else(|| {
|
||||
let key = content_part_key(&data);
|
||||
if let Some(k) = key {
|
||||
index_by_key.get(&k).copied()
|
||||
} else {
|
||||
fallback_open_index
|
||||
}
|
||||
});
|
||||
let key = content_part_key(&data);
|
||||
let index = if let Some(k) = key {
|
||||
index_by_key.get(&k).copied()
|
||||
} else {
|
||||
fallback_open_index
|
||||
};
|
||||
if let Some(index) = index {
|
||||
if !open_indices.remove(&index) {
|
||||
continue;
|
||||
@@ -566,20 +553,6 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
||||
.or_else(|| data.get("text"))
|
||||
.and_then(|d| d.as_str())
|
||||
{
|
||||
if let Some(index) = current_text_index.take() {
|
||||
if open_indices.remove(&index) {
|
||||
let stop_event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": index
|
||||
});
|
||||
let stop_sse = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&stop_event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(stop_sse));
|
||||
}
|
||||
if fallback_open_index == Some(index) {
|
||||
fallback_open_index = None;
|
||||
}
|
||||
}
|
||||
let index = resolve_content_index(
|
||||
&data,
|
||||
&mut next_content_index,
|
||||
@@ -701,23 +674,8 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
||||
|
||||
// Lifecycle events that don't need Anthropic counterparts.
|
||||
// Listed explicitly so new events trigger a match-completeness review.
|
||||
"response.output_text.done" => {
|
||||
if let Some(index) = current_text_index.take() {
|
||||
if open_indices.remove(&index) {
|
||||
let stop_event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": index
|
||||
});
|
||||
let stop_sse = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&stop_event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(stop_sse));
|
||||
}
|
||||
if fallback_open_index == Some(index) {
|
||||
fallback_open_index = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
"response.output_item.done"
|
||||
"response.output_text.done"
|
||||
| "response.output_item.done"
|
||||
| "response.in_progress" => {}
|
||||
|
||||
// Any other unknown/future events — silently skip.
|
||||
@@ -800,9 +758,7 @@ mod tests {
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":12,\"output_tokens\":3}}}\n\n"
|
||||
);
|
||||
|
||||
let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
|
||||
input.as_bytes().to_vec(),
|
||||
))]);
|
||||
let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);
|
||||
let converted = create_anthropic_sse_stream_from_responses(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
|
||||
@@ -844,9 +800,7 @@ mod tests {
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":8,\"output_tokens\":4}}}\n\n"
|
||||
);
|
||||
|
||||
let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
|
||||
input.as_bytes().to_vec(),
|
||||
))]);
|
||||
let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);
|
||||
let converted = create_anthropic_sse_stream_from_responses(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
let merged = chunks
|
||||
@@ -917,9 +871,7 @@ mod tests {
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":5,\"output_tokens\":10}}}\n\n"
|
||||
);
|
||||
|
||||
let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
|
||||
input.as_bytes().to_vec(),
|
||||
))]);
|
||||
let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);
|
||||
let converted = create_anthropic_sse_stream_from_responses(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
let merged = chunks
|
||||
@@ -950,83 +902,4 @@ mod tests {
|
||||
);
|
||||
assert!(merged.contains("\"stop_reason\":\"end_turn\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_streaming_text_parts_are_merged_into_one_text_block() {
|
||||
let input = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_merge\",\"model\":\"gpt-5.4\",\"usage\":{\"input_tokens\":5,\"output_tokens\":0}}}\n\n",
|
||||
"event: response.content_part.added\n",
|
||||
"data: {\"type\":\"response.content_part.added\",\"part\":{\"type\":\"output_text\",\"text\":\"\"},\"output_index\":0,\"content_index\":0}\n\n",
|
||||
"event: response.output_text.delta\n",
|
||||
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"你\",\"output_index\":0,\"content_index\":0}\n\n",
|
||||
"event: response.content_part.done\n",
|
||||
"data: {\"type\":\"response.content_part.done\",\"output_index\":0,\"content_index\":0}\n\n",
|
||||
"event: response.content_part.added\n",
|
||||
"data: {\"type\":\"response.content_part.added\",\"part\":{\"type\":\"output_text\",\"text\":\"\"},\"output_index\":0,\"content_index\":1}\n\n",
|
||||
"event: response.output_text.delta\n",
|
||||
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"好\",\"output_index\":0,\"content_index\":1}\n\n",
|
||||
"event: response.content_part.done\n",
|
||||
"data: {\"type\":\"response.content_part.done\",\"output_index\":0,\"content_index\":1}\n\n",
|
||||
"event: response.output_text.done\n",
|
||||
"data: {\"type\":\"response.output_text.done\",\"output_index\":0,\"content_index\":1}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":5,\"output_tokens\":2}}}\n\n"
|
||||
);
|
||||
|
||||
let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
|
||||
input.as_bytes().to_vec(),
|
||||
))]);
|
||||
let converted = create_anthropic_sse_stream_from_responses(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
let events: Vec<Value> = chunks
|
||||
.into_iter()
|
||||
.flat_map(|chunk| {
|
||||
let bytes = chunk.unwrap();
|
||||
let text = String::from_utf8_lossy(bytes.as_ref()).to_string();
|
||||
text.split("\n\n")
|
||||
.filter_map(|block| {
|
||||
block.lines().find_map(|line| {
|
||||
strip_sse_field(line, "data")
|
||||
.and_then(|payload| serde_json::from_str::<Value>(payload).ok())
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let text_starts = events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event.get("type").and_then(|v| v.as_str()) == Some("content_block_start")
|
||||
&& event
|
||||
.pointer("/content_block/type")
|
||||
.and_then(|v| v.as_str())
|
||||
== Some("text")
|
||||
})
|
||||
.count();
|
||||
let text_stops = events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event.get("type").and_then(|v| v.as_str()) == Some("content_block_stop")
|
||||
})
|
||||
.count();
|
||||
let text_deltas: Vec<String> = events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event.get("type").and_then(|v| v.as_str()) == Some("content_block_delta")
|
||||
&& event.pointer("/delta/type").and_then(|v| v.as_str()) == Some("text_delta")
|
||||
})
|
||||
.filter_map(|event| {
|
||||
event
|
||||
.pointer("/delta/text")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(ToString::to_string)
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(text_starts, 1);
|
||||
assert_eq!(text_stops, 1);
|
||||
assert_eq!(text_deltas, vec!["你".to_string(), "好".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ pub fn resolve_reasoning_effort(body: &Value) -> Option<&'static str> {
|
||||
"medium" => Some("medium"),
|
||||
"high" => Some("high"),
|
||||
"max" => Some("xhigh"), // OpenAI xhigh = maximum reasoning effort
|
||||
_ => None, // unknown value — do not inject
|
||||
_ => None, // unknown value — do not inject
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,19 +5,17 @@
|
||||
use super::{
|
||||
handler_config::UsageParserConfig,
|
||||
handler_context::{RequestContext, StreamingTimeoutConfig},
|
||||
hyper_client::ProxyResponse,
|
||||
server::ProxyState,
|
||||
sse::strip_sse_field,
|
||||
usage::parser::TokenUsage,
|
||||
ProxyError,
|
||||
};
|
||||
use axum::http::header::HeaderMap;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use reqwest::header::HeaderMap;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
io::Read,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
@@ -26,123 +24,24 @@ use std::{
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
// ============================================================================
|
||||
// 响应解压
|
||||
// ============================================================================
|
||||
|
||||
/// 根据 content-encoding 解压响应体字节
|
||||
///
|
||||
/// reqwest 自动解压已禁用(为了透传 accept-encoding),需要手动解压。
|
||||
fn decompress_body(content_encoding: &str, body: &[u8]) -> Result<Vec<u8>, std::io::Error> {
|
||||
match content_encoding {
|
||||
"gzip" | "x-gzip" => {
|
||||
let mut decoder = flate2::read::GzDecoder::new(body);
|
||||
let mut decompressed = Vec::new();
|
||||
decoder.read_to_end(&mut decompressed)?;
|
||||
Ok(decompressed)
|
||||
}
|
||||
"deflate" => {
|
||||
let mut decoder = flate2::read::DeflateDecoder::new(body);
|
||||
let mut decompressed = Vec::new();
|
||||
decoder.read_to_end(&mut decompressed)?;
|
||||
Ok(decompressed)
|
||||
}
|
||||
"br" => {
|
||||
let mut decompressed = Vec::new();
|
||||
brotli::BrotliDecompress(&mut std::io::Cursor::new(body), &mut decompressed)?;
|
||||
Ok(decompressed)
|
||||
}
|
||||
_ => {
|
||||
log::warn!("未知的 content-encoding: {content_encoding},跳过解压");
|
||||
Ok(body.to_vec())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从响应头提取 content-encoding(忽略 identity 和 chunked)
|
||||
fn get_content_encoding(headers: &HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get("content-encoding")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty() && s != "identity")
|
||||
}
|
||||
|
||||
/// 移除在重建响应体后会失真的实体头。
|
||||
pub(crate) fn strip_entity_headers_for_rebuilt_body(headers: &mut HeaderMap) {
|
||||
headers.remove(axum::http::header::CONTENT_ENCODING);
|
||||
headers.remove(axum::http::header::CONTENT_LENGTH);
|
||||
headers.remove(axum::http::header::TRANSFER_ENCODING);
|
||||
}
|
||||
|
||||
/// 读取响应体并在需要时解压,确保 headers 与返回 body 一致。
|
||||
///
|
||||
/// `body_timeout`: 整包超时。当非零时用 `tokio::time::timeout` 包住 `.bytes()` 调用,
|
||||
/// 防止上游发完响应头后卡住 body 导致请求永远挂住。
|
||||
/// 传入 `Duration::ZERO` 表示不启用超时(故障转移关闭时)。
|
||||
pub(crate) async fn read_decoded_body(
|
||||
response: ProxyResponse,
|
||||
tag: &str,
|
||||
body_timeout: Duration,
|
||||
) -> Result<(HeaderMap, http::StatusCode, Bytes), ProxyError> {
|
||||
let mut headers = response.headers().clone();
|
||||
let status = response.status();
|
||||
let raw_bytes = if body_timeout.is_zero() {
|
||||
response.bytes().await?
|
||||
} else {
|
||||
tokio::time::timeout(body_timeout, response.bytes())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
ProxyError::Timeout(format!(
|
||||
"响应体读取超时: {}s(上游发完响应头后 body 未到达)",
|
||||
body_timeout.as_secs()
|
||||
))
|
||||
})??
|
||||
};
|
||||
|
||||
log::debug!(
|
||||
"[{tag}] 已接收上游响应体: status={}, bytes={}, headers={}",
|
||||
status.as_u16(),
|
||||
raw_bytes.len(),
|
||||
format_headers(&headers)
|
||||
);
|
||||
|
||||
let mut body_bytes = raw_bytes.clone();
|
||||
let mut decoded = false;
|
||||
|
||||
if let Some(encoding) = get_content_encoding(&headers) {
|
||||
log::debug!("[{tag}] 解压非流式响应: content-encoding={encoding}");
|
||||
match decompress_body(&encoding, &raw_bytes) {
|
||||
Ok(decompressed) => {
|
||||
body_bytes = Bytes::from(decompressed);
|
||||
decoded = true;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[{tag}] 解压失败 ({encoding}): {e},使用原始数据");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if decoded {
|
||||
strip_entity_headers_for_rebuilt_body(&mut headers);
|
||||
}
|
||||
|
||||
Ok((headers, status, body_bytes))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 公共接口
|
||||
// ============================================================================
|
||||
|
||||
/// 检测响应是否为 SSE 流式响应
|
||||
#[inline]
|
||||
pub fn is_sse_response(response: &ProxyResponse) -> bool {
|
||||
response.is_sse()
|
||||
pub fn is_sse_response(response: &reqwest::Response) -> bool {
|
||||
response
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|ct| ct.contains("text/event-stream"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 处理流式响应
|
||||
pub async fn handle_streaming(
|
||||
response: ProxyResponse,
|
||||
response: reqwest::Response,
|
||||
ctx: &RequestContext,
|
||||
state: &ProxyState,
|
||||
parser_config: &UsageParserConfig,
|
||||
@@ -154,15 +53,6 @@ pub async fn handle_streaming(
|
||||
status.as_u16(),
|
||||
format_headers(response.headers())
|
||||
);
|
||||
// 检查流式响应是否被压缩(SSE 通常不压缩,如果压缩则 SSE 解析会失败)
|
||||
if let Some(encoding) = get_content_encoding(response.headers()) {
|
||||
log::warn!(
|
||||
"[{}] 流式响应含 content-encoding={encoding},SSE 解析可能失败。\
|
||||
上游在 accept-encoding 透传后压缩了 SSE 流。",
|
||||
ctx.tag
|
||||
);
|
||||
}
|
||||
|
||||
let mut builder = axum::response::Response::builder().status(status);
|
||||
|
||||
// 复制响应头
|
||||
@@ -171,7 +61,9 @@ pub async fn handle_streaming(
|
||||
}
|
||||
|
||||
// 创建字节流
|
||||
let stream = response.bytes_stream();
|
||||
let stream = response
|
||||
.bytes_stream()
|
||||
.map(|chunk| chunk.map_err(|e| std::io::Error::other(e.to_string())));
|
||||
|
||||
// 创建使用量收集器
|
||||
let usage_collector = create_usage_collector(ctx, state, status.as_u16(), parser_config);
|
||||
@@ -195,20 +87,26 @@ pub async fn handle_streaming(
|
||||
|
||||
/// 处理非流式响应
|
||||
pub async fn handle_non_streaming(
|
||||
response: ProxyResponse,
|
||||
response: reqwest::Response,
|
||||
ctx: &RequestContext,
|
||||
state: &ProxyState,
|
||||
parser_config: &UsageParserConfig,
|
||||
) -> Result<Response, ProxyError> {
|
||||
// 整包超时:仅在故障转移开启且配置值非零时生效
|
||||
let body_timeout =
|
||||
if ctx.app_config.auto_failover_enabled && ctx.app_config.non_streaming_timeout > 0 {
|
||||
Duration::from_secs(ctx.app_config.non_streaming_timeout as u64)
|
||||
} else {
|
||||
Duration::ZERO
|
||||
};
|
||||
let (response_headers, status, body_bytes) =
|
||||
read_decoded_body(response, ctx.tag, body_timeout).await?;
|
||||
let response_headers = response.headers().clone();
|
||||
let status = response.status();
|
||||
|
||||
// 读取响应体
|
||||
let body_bytes = response.bytes().await.map_err(|e| {
|
||||
log::error!("[{}] 读取响应失败: {e}", ctx.tag);
|
||||
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
|
||||
})?;
|
||||
log::debug!(
|
||||
"[{}] 已接收上游响应体: status={}, bytes={}, headers={}",
|
||||
ctx.tag,
|
||||
status.as_u16(),
|
||||
body_bytes.len(),
|
||||
format_headers(&response_headers)
|
||||
);
|
||||
|
||||
log::debug!(
|
||||
"[{}] 上游响应体内容: {}",
|
||||
@@ -292,7 +190,7 @@ pub async fn handle_non_streaming(
|
||||
///
|
||||
/// 根据响应类型自动选择流式或非流式处理
|
||||
pub async fn process_response(
|
||||
response: ProxyResponse,
|
||||
response: reqwest::Response,
|
||||
ctx: &RequestContext,
|
||||
state: &ProxyState,
|
||||
parser_config: &UsageParserConfig,
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
//! HTTP代理服务器
|
||||
//!
|
||||
//! 基于Axum的HTTP服务器,处理代理请求
|
||||
//!
|
||||
//! Uses a manual hyper HTTP/1.1 accept loop with `preserve_header_case(true)` so
|
||||
//! that the original header-name casing from the CLI client is captured in a
|
||||
//! `HeaderCaseMap` extension. This map is later forwarded to the upstream via
|
||||
//! the hyper-based HTTP client, producing wire-level header casing identical to
|
||||
//! a direct (non-proxied) CLI request.
|
||||
|
||||
use super::{
|
||||
failover_switch::FailoverSwitchManager, handlers, log_codes::srv as log_srv,
|
||||
@@ -18,7 +12,6 @@ use axum::{
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{oneshot, RwLock};
|
||||
@@ -121,77 +114,15 @@ impl ProxyServer {
|
||||
// 记录启动时间
|
||||
*self.state.start_time.write().await = Some(std::time::Instant::now());
|
||||
|
||||
// 启动服务器 — 使用手动 hyper HTTP/1.1 accept loop
|
||||
// 开启 preserve_header_case 以捕获客户端请求头的原始大小写
|
||||
// 启动服务器
|
||||
let state = self.state.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut shutdown_rx = shutdown_rx;
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = listener.accept() => {
|
||||
let (stream, _remote_addr) = match result {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::error!("[{SRV}] accept 失败: {e}", SRV = log_srv::ACCEPT_ERR);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let app = app.clone();
|
||||
tokio::spawn(async move {
|
||||
// Peek raw TCP bytes to capture original header casing
|
||||
// before hyper parses (and lowercases) the header names.
|
||||
let original_cases = {
|
||||
let mut peek_buf = vec![0u8; 8192];
|
||||
match stream.peek(&mut peek_buf).await {
|
||||
Ok(n) => {
|
||||
let cases = super::hyper_client::OriginalHeaderCases::from_raw_bytes(&peek_buf[..n]);
|
||||
log::debug!(
|
||||
"[ProxyServer] Peeked {} bytes, captured {} header casings",
|
||||
n, cases.cases.len()
|
||||
);
|
||||
cases
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("[ProxyServer] peek failed (non-fatal): {e}");
|
||||
super::hyper_client::OriginalHeaderCases::default()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// service_fn 将 axum Router(tower::Service)桥接到 hyper
|
||||
let service = hyper::service::service_fn(move |req: hyper::Request<hyper::body::Incoming>| {
|
||||
let mut router = app.clone();
|
||||
let cases = original_cases.clone();
|
||||
async move {
|
||||
// 将 hyper::body::Incoming 转为 axum::body::Body,保留 extensions
|
||||
let (mut parts, body) = req.into_parts();
|
||||
|
||||
// Insert our own header case map alongside hyper's internal one
|
||||
parts.extensions.insert(cases);
|
||||
|
||||
let body = axum::body::Body::new(body);
|
||||
let axum_req = http::Request::from_parts(parts, body);
|
||||
<Router as tower::Service<http::Request<axum::body::Body>>>::call(&mut router, axum_req).await
|
||||
}
|
||||
});
|
||||
|
||||
if let Err(e) = hyper::server::conn::http1::Builder::new()
|
||||
.preserve_header_case(true)
|
||||
.serve_connection(TokioIo::new(stream), service)
|
||||
.await
|
||||
{
|
||||
// Connection reset / broken pipe 等在代理场景下很常见,debug 级别
|
||||
log::debug!("[{SRV}] connection error: {e}", SRV = log_srv::CONN_ERR);
|
||||
}
|
||||
});
|
||||
}
|
||||
_ = &mut shutdown_rx => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async {
|
||||
shutdown_rx.await.ok();
|
||||
})
|
||||
.await
|
||||
.ok();
|
||||
|
||||
// 服务器停止后更新状态
|
||||
state.status.write().await.running = false;
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
//! Per-app switch lock
|
||||
//!
|
||||
//! 确保同一应用同时只有一个供应商切换操作在执行,
|
||||
//! 防止并发切换导致 is_current 与 Live 备份不一致。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{Mutex, OwnedMutexGuard, RwLock};
|
||||
|
||||
/// 每个应用类型一把互斥锁,保证同一应用的切换操作串行执行。
|
||||
///
|
||||
/// 不同应用之间(如 Claude 和 Codex)可以并行切换。
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SwitchLockManager {
|
||||
locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
|
||||
}
|
||||
|
||||
impl SwitchLockManager {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 获取指定应用的切换锁。
|
||||
///
|
||||
/// 返回 `OwnedMutexGuard`,持有期间同一 `app_type` 的其他切换会排队等待。
|
||||
pub async fn lock_for_app(&self, app_type: &str) -> OwnedMutexGuard<()> {
|
||||
let lock = {
|
||||
let locks = self.locks.read().await;
|
||||
if let Some(lock) = locks.get(app_type) {
|
||||
lock.clone()
|
||||
} else {
|
||||
drop(locks);
|
||||
let mut locks = self.locks.write().await;
|
||||
locks
|
||||
.entry(app_type.to_string())
|
||||
.or_insert_with(|| Arc::new(Mutex::new(())))
|
||||
.clone()
|
||||
}
|
||||
};
|
||||
lock.lock_owned().await
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ pub fn optimize(body: &mut Value, config: &OptimizerConfig) {
|
||||
}
|
||||
|
||||
if model.contains("opus-4-6") || model.contains("sonnet-4-6") {
|
||||
log::info!("[OPT] thinking: adaptive({model})");
|
||||
log::info!("[OPT] thinking: adaptive({})", model);
|
||||
body["thinking"] = json!({"type": "adaptive"});
|
||||
body["output_config"] = json!({"effort": "max"});
|
||||
append_beta(body, "context-1m-2025-08-07");
|
||||
@@ -33,7 +33,7 @@ pub fn optimize(body: &mut Value, config: &OptimizerConfig) {
|
||||
}
|
||||
|
||||
// legacy path
|
||||
log::info!("[OPT] thinking: legacy({model})");
|
||||
log::info!("[OPT] thinking: legacy({})", model);
|
||||
|
||||
let max_tokens = body
|
||||
.get("max_tokens")
|
||||
|
||||
@@ -21,41 +21,33 @@ type OmoProfileData = (Option<Value>, Option<Value>, Option<Value>);
|
||||
// ── Variant descriptor ─────────────────────────────────────────
|
||||
|
||||
pub struct OmoVariant {
|
||||
pub preferred_filename: &'static str,
|
||||
pub config_candidates: &'static [&'static str],
|
||||
pub filename: &'static str,
|
||||
pub category: &'static str,
|
||||
pub provider_prefix: &'static str,
|
||||
pub plugin_name: &'static str,
|
||||
pub plugin_prefixes: &'static [&'static str],
|
||||
pub plugin_prefix: &'static str,
|
||||
pub has_categories: bool,
|
||||
pub label: &'static str,
|
||||
pub import_label: &'static str,
|
||||
}
|
||||
|
||||
pub const STANDARD: OmoVariant = OmoVariant {
|
||||
preferred_filename: "oh-my-openagent.jsonc",
|
||||
config_candidates: &[
|
||||
"oh-my-openagent.jsonc",
|
||||
"oh-my-openagent.json",
|
||||
"oh-my-opencode.jsonc",
|
||||
"oh-my-opencode.json",
|
||||
],
|
||||
filename: "oh-my-opencode.jsonc",
|
||||
category: "omo",
|
||||
provider_prefix: "omo-",
|
||||
plugin_name: "oh-my-openagent@latest",
|
||||
plugin_prefixes: &["oh-my-openagent", "oh-my-opencode"],
|
||||
plugin_name: "oh-my-opencode@latest",
|
||||
plugin_prefix: "oh-my-opencode",
|
||||
has_categories: true,
|
||||
label: "OMO",
|
||||
import_label: "Imported",
|
||||
};
|
||||
|
||||
pub const SLIM: OmoVariant = OmoVariant {
|
||||
preferred_filename: "oh-my-opencode-slim.jsonc",
|
||||
config_candidates: &["oh-my-opencode-slim.jsonc", "oh-my-opencode-slim.json"],
|
||||
filename: "oh-my-opencode-slim.jsonc",
|
||||
category: "omo-slim",
|
||||
provider_prefix: "omo-slim-",
|
||||
plugin_name: "oh-my-opencode-slim@latest",
|
||||
plugin_prefixes: &["oh-my-opencode-slim"],
|
||||
plugin_prefix: "oh-my-opencode-slim",
|
||||
has_categories: false,
|
||||
label: "OMO Slim",
|
||||
import_label: "Imported Slim",
|
||||
@@ -68,27 +60,22 @@ pub struct OmoService;
|
||||
impl OmoService {
|
||||
// ── Path helpers ────────────────────────────────────────
|
||||
|
||||
fn config_candidates(v: &OmoVariant, base_dir: &Path) -> Vec<PathBuf> {
|
||||
v.config_candidates
|
||||
.iter()
|
||||
.map(|name| base_dir.join(name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn find_existing_config_path(v: &OmoVariant, base_dir: &Path) -> Option<PathBuf> {
|
||||
Self::config_candidates(v, base_dir)
|
||||
.into_iter()
|
||||
.find(|path| path.exists())
|
||||
}
|
||||
|
||||
fn config_path(v: &OmoVariant) -> PathBuf {
|
||||
let base_dir = get_opencode_dir();
|
||||
Self::find_existing_config_path(v, &base_dir)
|
||||
.unwrap_or_else(|| base_dir.join(v.preferred_filename))
|
||||
get_opencode_dir().join(v.filename)
|
||||
}
|
||||
|
||||
fn resolve_local_config_path(v: &OmoVariant) -> Result<PathBuf, AppError> {
|
||||
Self::find_existing_config_path(v, &get_opencode_dir()).ok_or(AppError::OmoConfigNotFound)
|
||||
let config_path = Self::config_path(v);
|
||||
if config_path.exists() {
|
||||
return Ok(config_path);
|
||||
}
|
||||
|
||||
let json_path = config_path.with_extension("json");
|
||||
if json_path.exists() {
|
||||
return Ok(json_path);
|
||||
}
|
||||
|
||||
Err(AppError::OmoConfigNotFound)
|
||||
}
|
||||
|
||||
fn read_jsonc_object(path: &Path) -> Result<Map<String, Value>, AppError> {
|
||||
@@ -136,18 +123,12 @@ impl OmoService {
|
||||
// ── Public API (variant-parameterized) ─────────────────
|
||||
|
||||
pub fn delete_config_file(v: &OmoVariant) -> Result<(), AppError> {
|
||||
let base_dir = get_opencode_dir();
|
||||
let mut deleted_paths = Vec::new();
|
||||
for config_path in Self::config_candidates(v, &base_dir) {
|
||||
if config_path.exists() {
|
||||
std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||
deleted_paths.push(config_path);
|
||||
}
|
||||
let config_path = Self::config_path(v);
|
||||
if config_path.exists() {
|
||||
std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||
log::info!("{} config file deleted: {config_path:?}", v.label);
|
||||
}
|
||||
if !deleted_paths.is_empty() {
|
||||
log::info!("{} config files deleted: {deleted_paths:?}", v.label);
|
||||
}
|
||||
crate::opencode_config::remove_plugins_by_prefixes(v.plugin_prefixes)?;
|
||||
crate::opencode_config::remove_plugin_by_prefix(v.plugin_prefix)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -470,38 +451,4 @@ mod tests {
|
||||
assert!(obj.contains_key("agents"));
|
||||
assert!(obj.contains_key("disabled_agents"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_existing_config_prefers_new_name_over_old() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let old_path = dir.path().join("oh-my-opencode.jsonc");
|
||||
let new_path = dir.path().join("oh-my-openagent.jsonc");
|
||||
|
||||
// Create both old and new files
|
||||
std::fs::write(&old_path, r#"{"agents":{}}"#).unwrap();
|
||||
std::fs::write(&new_path, r#"{"agents":{}}"#).unwrap();
|
||||
|
||||
let found = OmoService::find_existing_config_path(&STANDARD, dir.path());
|
||||
assert_eq!(
|
||||
found.unwrap(),
|
||||
new_path,
|
||||
"When both old and new config files exist, the new name (oh-my-openagent) must be preferred"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_existing_config_falls_back_to_old_name() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let old_path = dir.path().join("oh-my-opencode.jsonc");
|
||||
|
||||
// Only old file exists
|
||||
std::fs::write(&old_path, r#"{"agents":{}}"#).unwrap();
|
||||
|
||||
let found = OmoService::find_existing_config_path(&STANDARD, dir.path());
|
||||
assert_eq!(
|
||||
found.unwrap(),
|
||||
old_path,
|
||||
"When only the old config file exists, it should still be found"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,12 +480,32 @@ impl ProviderService {
|
||||
id
|
||||
);
|
||||
|
||||
// 获取新供应商的完整配置(用于更新备份)
|
||||
let provider = providers
|
||||
.get(id)
|
||||
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
|
||||
|
||||
// Update database is_current
|
||||
state.db.set_current_provider(app_type.as_str(), id)?;
|
||||
|
||||
// Update local settings for consistency
|
||||
crate::settings::set_current_provider(&app_type, Some(id))?;
|
||||
|
||||
// 更新 Live 备份(确保代理关闭时恢复正确的供应商配置)
|
||||
futures::executor::block_on(
|
||||
state
|
||||
.proxy_service
|
||||
.hot_switch_provider(app_type.as_str(), id),
|
||||
.update_live_backup_from_provider(app_type.as_str(), provider),
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("热切换失败: {e}")))?;
|
||||
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
|
||||
|
||||
// 关键修复:接管模式下切换供应商不会写回 Live 配置,
|
||||
// 需要主动清理 Claude Live 中的“模型覆盖”字段,避免仍以旧模型名发起请求。
|
||||
if matches!(app_type, AppType::Claude) {
|
||||
if let Err(e) = state.proxy_service.cleanup_claude_model_overrides_in_live() {
|
||||
log::warn!("清理 Claude Live 模型字段失败(不影响切换结果): {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Note: No Live config write, no MCP sync
|
||||
// The proxy server will route requests to the new provider via is_current
|
||||
|
||||
+39
-280
@@ -7,7 +7,6 @@ use crate::config::{get_claude_settings_path, read_json_file, write_json_file};
|
||||
use crate::database::Database;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::server::ProxyServer;
|
||||
use crate::proxy::switch_lock::SwitchLockManager;
|
||||
use crate::proxy::types::*;
|
||||
use crate::services::provider::{
|
||||
build_effective_settings_with_common_config, write_live_with_common_config,
|
||||
@@ -40,12 +39,6 @@ pub struct ProxyService {
|
||||
server: Arc<RwLock<Option<ProxyServer>>>,
|
||||
/// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新
|
||||
app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,
|
||||
switch_locks: SwitchLockManager,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct HotSwitchOutcome {
|
||||
pub logical_target_changed: bool,
|
||||
}
|
||||
|
||||
impl ProxyService {
|
||||
@@ -54,7 +47,6 @@ impl ProxyService {
|
||||
db,
|
||||
server: Arc::new(RwLock::new(None)),
|
||||
app_handle: Arc::new(RwLock::new(None)),
|
||||
switch_locks: SwitchLockManager::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1108,11 +1100,6 @@ impl ProxyService {
|
||||
|
||||
/// 恢复指定应用的 Live 配置(若无备份则不做任何操作)
|
||||
async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {
|
||||
let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;
|
||||
self.restore_live_config_for_app_inner(app_type).await
|
||||
}
|
||||
|
||||
async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
if let Ok(Some(backup)) = self.db.get_live_backup("claude").await {
|
||||
@@ -1172,15 +1159,6 @@ impl ProxyService {
|
||||
async fn restore_live_config_for_app_with_fallback(
|
||||
&self,
|
||||
app_type: &AppType,
|
||||
) -> Result<(), String> {
|
||||
let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;
|
||||
self.restore_live_config_for_app_with_fallback_inner(app_type)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn restore_live_config_for_app_with_fallback_inner(
|
||||
&self,
|
||||
app_type: &AppType,
|
||||
) -> Result<(), String> {
|
||||
let app_type_str = app_type.as_str();
|
||||
|
||||
@@ -1509,17 +1487,6 @@ impl ProxyService {
|
||||
&self,
|
||||
app_type: &str,
|
||||
provider: &Provider,
|
||||
) -> Result<(), String> {
|
||||
let _guard = self.switch_locks.lock_for_app(app_type).await;
|
||||
self.update_live_backup_from_provider_inner(app_type, provider)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 仅供已持有 per-app 切换锁的调用方使用。
|
||||
async fn update_live_backup_from_provider_inner(
|
||||
&self,
|
||||
app_type: &str,
|
||||
provider: &Provider,
|
||||
) -> Result<(), String> {
|
||||
let app_type_enum =
|
||||
AppType::from_str(app_type).map_err(|_| format!("未知的应用类型: {app_type}"))?;
|
||||
@@ -1573,69 +1540,6 @@ impl ProxyService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn hot_switch_provider(
|
||||
&self,
|
||||
app_type: &str,
|
||||
provider_id: &str,
|
||||
) -> Result<HotSwitchOutcome, String> {
|
||||
let _guard = self.switch_locks.lock_for_app(app_type).await;
|
||||
|
||||
let app_type_enum =
|
||||
AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?;
|
||||
let provider = self
|
||||
.db
|
||||
.get_provider_by_id(provider_id, app_type)
|
||||
.map_err(|e| format!("读取供应商失败: {e}"))?
|
||||
.ok_or_else(|| format!("供应商不存在: {provider_id}"))?;
|
||||
|
||||
let logical_target_changed =
|
||||
crate::settings::get_effective_current_provider(&self.db, &app_type_enum)
|
||||
.map_err(|e| format!("读取当前供应商失败: {e}"))?
|
||||
.as_deref()
|
||||
!= Some(provider_id);
|
||||
|
||||
let has_backup = self
|
||||
.db
|
||||
.get_live_backup(app_type_enum.as_str())
|
||||
.await
|
||||
.map_err(|e| format!("读取 {app_type} 备份失败: {e}"))?
|
||||
.is_some();
|
||||
let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);
|
||||
let should_sync_backup = has_backup || live_taken_over;
|
||||
|
||||
self.db
|
||||
.set_current_provider(app_type_enum.as_str(), provider_id)
|
||||
.map_err(|e| format!("更新当前供应商失败: {e}"))?;
|
||||
crate::settings::set_current_provider(&app_type_enum, Some(provider_id))
|
||||
.map_err(|e| format!("更新本地当前供应商失败: {e}"))?;
|
||||
|
||||
if should_sync_backup {
|
||||
self.update_live_backup_from_provider_inner(app_type, &provider)
|
||||
.await?;
|
||||
|
||||
if matches!(app_type_enum, AppType::Claude) {
|
||||
if let Err(e) = self.cleanup_claude_model_overrides_in_live() {
|
||||
log::warn!("清理 Claude Live 模型字段失败(不影响热切换结果): {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(server) = self.server.read().await.as_ref() {
|
||||
server
|
||||
.set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(HotSwitchOutcome {
|
||||
logical_target_changed,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {
|
||||
self.switch_locks.lock_for_app(app_type).await
|
||||
}
|
||||
|
||||
fn preserve_codex_mcp_servers_in_backup(
|
||||
target_settings: &mut Value,
|
||||
existing_backup: &Value,
|
||||
@@ -1703,13 +1607,47 @@ impl ProxyService {
|
||||
app_type: &str,
|
||||
provider_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let outcome = self.hot_switch_provider(app_type, provider_id).await?;
|
||||
// 代理模式切换供应商(热切换):
|
||||
// - 更新 SSOT(数据库 is_current)
|
||||
// - 同步本地 settings(设备级 current_provider_*)
|
||||
// - 若该应用正处于接管模式,则同步更新 Live 备份(用于停止代理时恢复)
|
||||
let app_type_enum =
|
||||
AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?;
|
||||
|
||||
if outcome.logical_target_changed {
|
||||
log::info!("代理模式:已切换 {app_type} 的目标供应商为 {provider_id}");
|
||||
} else {
|
||||
log::debug!("代理模式:{app_type} 已对齐到目标供应商 {provider_id}");
|
||||
self.db
|
||||
.set_current_provider(app_type_enum.as_str(), provider_id)
|
||||
.map_err(|e| format!("更新当前供应商失败: {e}"))?;
|
||||
|
||||
// 同步本地 settings(设备级优先)
|
||||
crate::settings::set_current_provider(&app_type_enum, Some(provider_id))
|
||||
.map_err(|e| format!("更新本地当前供应商失败: {e}"))?;
|
||||
|
||||
// 仅在确实处于接管状态时才更新 Live 备份,避免无接管时误写覆盖 Live
|
||||
let has_backup = self
|
||||
.db
|
||||
.get_live_backup(app_type_enum.as_str())
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);
|
||||
|
||||
if let Ok(Some(provider)) = self.db.get_provider_by_id(provider_id, app_type) {
|
||||
// 同步更新 Live 备份(用于 stop_with_restore 恢复)
|
||||
if has_backup || live_taken_over {
|
||||
self.update_live_backup_from_provider(app_type, &provider)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// 同步更新 ProxyStatus.active_targets(用于 UI 立即反映切换目标)
|
||||
if let Some(server) = self.server.read().await.as_ref() {
|
||||
server
|
||||
.set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("代理模式:已切换 {app_type} 的目标供应商为 {provider_id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2255,185 +2193,6 @@ model = "gpt-5.1-codex"
|
||||
assert_eq!(backup.original_config, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn hot_switch_provider_serializes_same_app_switches() {
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
let service = ProxyService::new(db.clone());
|
||||
|
||||
let provider_a = Provider::with_id(
|
||||
"a".to_string(),
|
||||
"A".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }),
|
||||
None,
|
||||
);
|
||||
let provider_b = Provider::with_id(
|
||||
"b".to_string(),
|
||||
"B".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }),
|
||||
None,
|
||||
);
|
||||
let provider_c = Provider::with_id(
|
||||
"c".to_string(),
|
||||
"C".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "c-key" } }),
|
||||
None,
|
||||
);
|
||||
|
||||
db.save_provider("claude", &provider_a)
|
||||
.expect("save provider a");
|
||||
db.save_provider("claude", &provider_b)
|
||||
.expect("save provider b");
|
||||
db.save_provider("claude", &provider_c)
|
||||
.expect("save provider c");
|
||||
db.set_current_provider("claude", "a")
|
||||
.expect("set current provider");
|
||||
crate::settings::set_current_provider(&AppType::Claude, Some("a"))
|
||||
.expect("set local current provider");
|
||||
db.save_live_backup("claude", "{\"env\":{}}")
|
||||
.await
|
||||
.expect("seed live backup");
|
||||
|
||||
let guard = service.lock_switch_for_test("claude").await;
|
||||
let service_for_b = service.clone();
|
||||
let service_for_c = service.clone();
|
||||
|
||||
let switch_b = tokio::spawn(async move {
|
||||
service_for_b
|
||||
.hot_switch_provider("claude", "b")
|
||||
.await
|
||||
.expect("switch to b")
|
||||
});
|
||||
sleep(Duration::from_millis(20)).await;
|
||||
let switch_c = tokio::spawn(async move {
|
||||
service_for_c
|
||||
.hot_switch_provider("claude", "c")
|
||||
.await
|
||||
.expect("switch to c")
|
||||
});
|
||||
|
||||
sleep(Duration::from_millis(20)).await;
|
||||
drop(guard);
|
||||
|
||||
let outcome_b = switch_b.await.expect("join switch b");
|
||||
let outcome_c = switch_c.await.expect("join switch c");
|
||||
assert!(outcome_b.logical_target_changed);
|
||||
assert!(outcome_c.logical_target_changed);
|
||||
|
||||
assert_eq!(
|
||||
crate::settings::get_effective_current_provider(&db, &AppType::Claude)
|
||||
.expect("effective current"),
|
||||
Some("c".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
crate::settings::get_current_provider(&AppType::Claude).as_deref(),
|
||||
Some("c")
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_current_provider("claude").expect("db current"),
|
||||
Some("c".to_string())
|
||||
);
|
||||
|
||||
let backup = db
|
||||
.get_live_backup("claude")
|
||||
.await
|
||||
.expect("get live backup")
|
||||
.expect("backup exists");
|
||||
let expected = serde_json::to_string(&provider_c.settings_config).expect("serialize");
|
||||
assert_eq!(backup.original_config, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn restore_waits_for_hot_switch_and_restores_latest_backup() {
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
let service = ProxyService::new(db.clone());
|
||||
|
||||
let provider_a = Provider::with_id(
|
||||
"a".to_string(),
|
||||
"A".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }),
|
||||
None,
|
||||
);
|
||||
let provider_b = Provider::with_id(
|
||||
"b".to_string(),
|
||||
"B".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }),
|
||||
None,
|
||||
);
|
||||
|
||||
db.save_provider("claude", &provider_a)
|
||||
.expect("save provider a");
|
||||
db.save_provider("claude", &provider_b)
|
||||
.expect("save provider b");
|
||||
db.set_current_provider("claude", "a")
|
||||
.expect("set current provider");
|
||||
crate::settings::set_current_provider(&AppType::Claude, Some("a"))
|
||||
.expect("set local current provider");
|
||||
db.save_live_backup(
|
||||
"claude",
|
||||
&serde_json::to_string(&provider_a.settings_config).expect("serialize provider a"),
|
||||
)
|
||||
.await
|
||||
.expect("seed live backup");
|
||||
service
|
||||
.write_claude_live(&json!({ "env": { "ANTHROPIC_API_KEY": "stale" } }))
|
||||
.expect("seed live file");
|
||||
|
||||
let guard = service.lock_switch_for_test("claude").await;
|
||||
let service_for_switch = service.clone();
|
||||
let service_for_restore = service.clone();
|
||||
|
||||
let switch_to_b = tokio::spawn(async move {
|
||||
service_for_switch
|
||||
.hot_switch_provider("claude", "b")
|
||||
.await
|
||||
.expect("switch to b")
|
||||
});
|
||||
sleep(Duration::from_millis(20)).await;
|
||||
let restore = tokio::spawn(async move {
|
||||
service_for_restore
|
||||
.restore_live_config_for_app_with_fallback(&AppType::Claude)
|
||||
.await
|
||||
.expect("restore claude live")
|
||||
});
|
||||
|
||||
sleep(Duration::from_millis(20)).await;
|
||||
drop(guard);
|
||||
|
||||
let outcome = switch_to_b.await.expect("join switch");
|
||||
restore.await.expect("join restore");
|
||||
assert!(outcome.logical_target_changed);
|
||||
|
||||
assert_eq!(
|
||||
crate::settings::get_effective_current_provider(&db, &AppType::Claude)
|
||||
.expect("effective current"),
|
||||
Some("b".to_string())
|
||||
);
|
||||
|
||||
let backup = db
|
||||
.get_live_backup("claude")
|
||||
.await
|
||||
.expect("get live backup")
|
||||
.expect("backup exists");
|
||||
let expected = serde_json::to_string(&provider_b.settings_config).expect("serialize");
|
||||
assert_eq!(backup.original_config, expected);
|
||||
assert_eq!(
|
||||
service.read_claude_live().expect("read live"),
|
||||
provider_b.settings_config
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn update_live_backup_from_provider_applies_claude_common_config() {
|
||||
|
||||
@@ -957,7 +957,7 @@ impl SkillService {
|
||||
if source_path.is_none() {
|
||||
source_path = Some(skill_path);
|
||||
}
|
||||
log::debug!("Skill '{dir_name}' found in source '{label}'");
|
||||
log::debug!("Skill '{}' found in source '{}'", dir_name, label);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ use std::time::Instant;
|
||||
use crate::app_config::AppType;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::providers::copilot_auth;
|
||||
use crate::proxy::providers::transform::anthropic_to_openai;
|
||||
use crate::proxy::providers::copilot_auth;
|
||||
use crate::proxy::providers::transform_responses::anthropic_to_responses;
|
||||
use crate::proxy::providers::{get_adapter, AuthInfo, AuthStrategy};
|
||||
|
||||
@@ -88,21 +88,15 @@ impl StreamCheckService {
|
||||
provider: &Provider,
|
||||
config: &StreamCheckConfig,
|
||||
auth_override: Option<AuthInfo>,
|
||||
claude_api_format_override: Option<String>,
|
||||
) -> Result<StreamCheckResult, AppError> {
|
||||
// 合并供应商单独配置和全局配置
|
||||
let effective_config = Self::merge_provider_config(provider, config);
|
||||
let mut last_result = None;
|
||||
|
||||
for attempt in 0..=effective_config.max_retries {
|
||||
let result = Self::check_once(
|
||||
app_type,
|
||||
provider,
|
||||
&effective_config,
|
||||
auth_override.clone(),
|
||||
claude_api_format_override.clone(),
|
||||
)
|
||||
.await;
|
||||
let result =
|
||||
Self::check_once(app_type, provider, &effective_config, auth_override.clone())
|
||||
.await;
|
||||
|
||||
match &result {
|
||||
Ok(r) if r.success => {
|
||||
@@ -191,7 +185,6 @@ impl StreamCheckService {
|
||||
provider: &Provider,
|
||||
config: &StreamCheckConfig,
|
||||
auth_override: Option<AuthInfo>,
|
||||
claude_api_format_override: Option<String>,
|
||||
) -> Result<StreamCheckResult, AppError> {
|
||||
let start = Instant::now();
|
||||
let adapter = get_adapter(app_type);
|
||||
@@ -222,7 +215,6 @@ impl StreamCheckService {
|
||||
test_prompt,
|
||||
request_timeout,
|
||||
provider,
|
||||
claude_api_format_override.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -303,7 +295,6 @@ impl StreamCheckService {
|
||||
/// 根据供应商的 api_format 选择请求格式:
|
||||
/// - "anthropic" (默认): Anthropic Messages API (/v1/messages)
|
||||
/// - "openai_chat": OpenAI Chat Completions API (/v1/chat/completions)
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn check_claude_stream(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
@@ -312,7 +303,6 @@ impl StreamCheckService {
|
||||
test_prompt: &str,
|
||||
timeout: std::time::Duration,
|
||||
provider: &Provider,
|
||||
claude_api_format_override: Option<&str>,
|
||||
) -> Result<(u16, String), AppError> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let is_github_copilot = auth.strategy == AuthStrategy::GitHubCopilot;
|
||||
@@ -330,24 +320,19 @@ impl StreamCheckService {
|
||||
})
|
||||
.unwrap_or("anthropic");
|
||||
|
||||
let effective_api_format = claude_api_format_override.unwrap_or(api_format);
|
||||
|
||||
let is_full_url = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.is_full_url)
|
||||
.unwrap_or(false);
|
||||
let is_openai_chat = effective_api_format == "openai_chat";
|
||||
let is_openai_responses = effective_api_format == "openai_responses";
|
||||
let url =
|
||||
Self::resolve_claude_stream_url(base, auth.strategy, effective_api_format, is_full_url);
|
||||
|
||||
let max_tokens = if is_openai_responses { 16 } else { 1 };
|
||||
let is_openai_chat = is_github_copilot || api_format == "openai_chat";
|
||||
let is_openai_responses = !is_github_copilot && api_format == "openai_responses";
|
||||
let url = Self::resolve_claude_stream_url(base, auth.strategy, api_format, is_full_url);
|
||||
|
||||
// Build from Anthropic-native shape first, then convert for configured targets.
|
||||
let anthropic_body = json!({
|
||||
"model": model,
|
||||
"max_tokens": max_tokens,
|
||||
"max_tokens": 1,
|
||||
"messages": [{ "role": "user", "content": test_prompt }],
|
||||
"stream": true
|
||||
});
|
||||
@@ -371,14 +356,8 @@ impl StreamCheckService {
|
||||
.header("accept-encoding", "identity")
|
||||
.header("user-agent", copilot_auth::COPILOT_USER_AGENT)
|
||||
.header("editor-version", copilot_auth::COPILOT_EDITOR_VERSION)
|
||||
.header(
|
||||
"editor-plugin-version",
|
||||
copilot_auth::COPILOT_PLUGIN_VERSION,
|
||||
)
|
||||
.header(
|
||||
"copilot-integration-id",
|
||||
copilot_auth::COPILOT_INTEGRATION_ID,
|
||||
)
|
||||
.header("editor-plugin-version", copilot_auth::COPILOT_PLUGIN_VERSION)
|
||||
.header("copilot-integration-id", copilot_auth::COPILOT_INTEGRATION_ID)
|
||||
.header("x-github-api-version", copilot_auth::COPILOT_API_VERSION)
|
||||
.header("openai-intent", "conversation-panel");
|
||||
} else if is_openai_chat || is_openai_responses {
|
||||
@@ -745,9 +724,7 @@ impl StreamCheckService {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let is_github_copilot = auth_strategy == AuthStrategy::GitHubCopilot;
|
||||
|
||||
if is_github_copilot && api_format == "openai_responses" {
|
||||
format!("{base}/v1/responses")
|
||||
} else if is_github_copilot {
|
||||
if is_github_copilot {
|
||||
format!("{base}/chat/completions")
|
||||
} else if api_format == "openai_responses" {
|
||||
if base.ends_with("/v1") {
|
||||
@@ -781,15 +758,6 @@ impl StreamCheckService {
|
||||
vec![format!("{base}/responses"), format!("{base}/v1/responses")]
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_effective_test_model(
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
config: &StreamCheckConfig,
|
||||
) -> String {
|
||||
let effective_config = Self::merge_provider_config(provider, config);
|
||||
Self::resolve_test_model(app_type, provider, &effective_config)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -910,25 +878,13 @@ mod tests {
|
||||
let url = StreamCheckService::resolve_claude_stream_url(
|
||||
"https://api.githubcopilot.com",
|
||||
AuthStrategy::GitHubCopilot,
|
||||
"openai_chat",
|
||||
"anthropic",
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://api.githubcopilot.com/chat/completions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_claude_stream_url_for_github_copilot_responses() {
|
||||
let url = StreamCheckService::resolve_claude_stream_url(
|
||||
"https://api.githubcopilot.com",
|
||||
AuthStrategy::GitHubCopilot,
|
||||
"openai_responses",
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://api.githubcopilot.com/v1/responses");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_claude_stream_url_for_openai_chat() {
|
||||
let url = StreamCheckService::resolve_claude_stream_url(
|
||||
|
||||
@@ -204,11 +204,10 @@ pub async fn ensure_remote_directories(
|
||||
s if s == StatusCode::CREATED || s.is_success() => {
|
||||
log::info!("[WebDAV] MKCOL ok: {}", redact_url(&dir_url));
|
||||
}
|
||||
// 405 commonly means "already exists" on many WebDAV servers
|
||||
StatusCode::METHOD_NOT_ALLOWED => {}
|
||||
// Ambiguous — verify directory actually exists via PROPFIND
|
||||
s if s == StatusCode::METHOD_NOT_ALLOWED
|
||||
|| s == StatusCode::CONFLICT
|
||||
|| s.is_redirection() =>
|
||||
{
|
||||
s if s == StatusCode::CONFLICT || s.is_redirection() => {
|
||||
if !propfind_exists(&client, &dir_url, auth).await? {
|
||||
return Err(webdav_status_error("MKCOL", status, &dir_url));
|
||||
}
|
||||
|
||||
@@ -463,10 +463,12 @@ fn validate_manifest_compat(manifest: &SyncManifest, layout: RemoteLayout) -> Re
|
||||
return Err(localized(
|
||||
"webdav.sync.manifest_db_version_incompatible",
|
||||
format!(
|
||||
"远端数据库快照版本不兼容: db-v{db_compat_version} (本地 db-v{DB_COMPAT_VERSION})"
|
||||
"远端数据库快照版本不兼容: db-v{} (本地 db-v{DB_COMPAT_VERSION})",
|
||||
db_compat_version
|
||||
),
|
||||
format!(
|
||||
"Remote database snapshot version is incompatible: db-v{db_compat_version} (local db-v{DB_COMPAT_VERSION})"
|
||||
"Remote database snapshot version is incompatible: db-v{} (local db-v{DB_COMPAT_VERSION})",
|
||||
db_compat_version
|
||||
),
|
||||
));
|
||||
}
|
||||
@@ -474,10 +476,12 @@ fn validate_manifest_compat(manifest: &SyncManifest, layout: RemoteLayout) -> Re
|
||||
return Err(localized(
|
||||
"webdav.sync.manifest_db_version_incompatible",
|
||||
format!(
|
||||
"远端数据库快照版本不兼容: db-v{db_compat_version} (本地最高支持 db-v{DB_COMPAT_VERSION})"
|
||||
"远端数据库快照版本不兼容: db-v{} (本地最高支持 db-v{DB_COMPAT_VERSION})",
|
||||
db_compat_version
|
||||
),
|
||||
format!(
|
||||
"Remote database snapshot version is incompatible: db-v{db_compat_version} (local supports up to db-v{DB_COMPAT_VERSION})"
|
||||
"Remote database snapshot version is incompatible: db-v{} (local supports up to db-v{DB_COMPAT_VERSION})",
|
||||
db_compat_version
|
||||
),
|
||||
));
|
||||
}
|
||||
@@ -657,8 +661,11 @@ fn validate_artifact_size_limit(artifact_name: &str, size: u64) -> Result<(), Ap
|
||||
let max_mb = MAX_SYNC_ARTIFACT_BYTES / 1024 / 1024;
|
||||
return Err(localized(
|
||||
"webdav.sync.artifact_too_large",
|
||||
format!("artifact {artifact_name} 超过下载上限({max_mb} MB)"),
|
||||
format!("Artifact {artifact_name} exceeds download limit ({max_mb} MB)"),
|
||||
format!("artifact {artifact_name} 超过下载上限({} MB)", max_mb),
|
||||
format!(
|
||||
"Artifact {artifact_name} exceeds download limit ({} MB)",
|
||||
max_mb
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -350,8 +350,8 @@ fn copy_entry_with_total_limit<R: Read, W: Write>(
|
||||
let max_mb = max_total_bytes / 1024 / 1024;
|
||||
return Err(localized(
|
||||
"webdav.sync.skills_zip_too_large",
|
||||
format!("skills.zip 解压后体积超过上限({max_mb} MB)"),
|
||||
format!("skills.zip extracted size exceeds limit ({max_mb} MB)"),
|
||||
format!("skills.zip 解压后体积超过上限({} MB)", max_mb),
|
||||
format!("skills.zip extracted size exceeds limit ({} MB)", max_mb),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
pub mod providers;
|
||||
pub mod terminal;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use providers::{claude, codex, gemini, openclaw, opencode};
|
||||
@@ -36,25 +36,6 @@ pub struct SessionMessage {
|
||||
pub ts: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeleteSessionRequest {
|
||||
pub provider_id: String,
|
||||
pub session_id: String,
|
||||
pub source_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeleteSessionOutcome {
|
||||
pub provider_id: String,
|
||||
pub session_id: String,
|
||||
pub source_path: String,
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
let (r1, r2, r3, r4, r5) = std::thread::scope(|s| {
|
||||
let h1 = s.spawn(codex::scan_sessions);
|
||||
@@ -118,16 +99,6 @@ pub fn delete_session(
|
||||
delete_session_with_root(provider_id, session_id, Path::new(source_path), &root)
|
||||
}
|
||||
|
||||
pub fn delete_sessions(requests: &[DeleteSessionRequest]) -> Vec<DeleteSessionOutcome> {
|
||||
collect_delete_session_outcomes(requests, |request| {
|
||||
delete_session(
|
||||
&request.provider_id,
|
||||
&request.session_id,
|
||||
&request.source_path,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_session_with_root(
|
||||
provider_id: &str,
|
||||
session_id: &str,
|
||||
@@ -176,41 +147,6 @@ fn canonicalize_existing_path(path: &Path, label: &str) -> Result<PathBuf, Strin
|
||||
.map_err(|e| format!("Failed to resolve {label} {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
fn collect_delete_session_outcomes<F>(
|
||||
requests: &[DeleteSessionRequest],
|
||||
mut deleter: F,
|
||||
) -> Vec<DeleteSessionOutcome>
|
||||
where
|
||||
F: FnMut(&DeleteSessionRequest) -> Result<bool, String>,
|
||||
{
|
||||
requests
|
||||
.iter()
|
||||
.map(|request| match deleter(request) {
|
||||
Ok(true) => DeleteSessionOutcome {
|
||||
provider_id: request.provider_id.clone(),
|
||||
session_id: request.session_id.clone(),
|
||||
source_path: request.source_path.clone(),
|
||||
success: true,
|
||||
error: None,
|
||||
},
|
||||
Ok(false) => DeleteSessionOutcome {
|
||||
provider_id: request.provider_id.clone(),
|
||||
session_id: request.session_id.clone(),
|
||||
source_path: request.source_path.clone(),
|
||||
success: false,
|
||||
error: Some("Session was not deleted".to_string()),
|
||||
},
|
||||
Err(error) => DeleteSessionOutcome {
|
||||
provider_id: request.provider_id.clone(),
|
||||
session_id: request.session_id.clone(),
|
||||
source_path: request.source_path.clone(),
|
||||
success: false,
|
||||
error: Some(error),
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -239,44 +175,4 @@ mod tests {
|
||||
|
||||
assert!(err.contains("session source not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_delete_collects_successes_and_failures_in_order() {
|
||||
let requests = vec![
|
||||
DeleteSessionRequest {
|
||||
provider_id: "codex".to_string(),
|
||||
session_id: "s1".to_string(),
|
||||
source_path: "/tmp/s1".to_string(),
|
||||
},
|
||||
DeleteSessionRequest {
|
||||
provider_id: "claude".to_string(),
|
||||
session_id: "s2".to_string(),
|
||||
source_path: "/tmp/s2".to_string(),
|
||||
},
|
||||
DeleteSessionRequest {
|
||||
provider_id: "gemini".to_string(),
|
||||
session_id: "s3".to_string(),
|
||||
source_path: "/tmp/s3".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let outcomes = collect_delete_session_outcomes(&requests, |request| {
|
||||
match request.session_id.as_str() {
|
||||
"s1" => Ok(true),
|
||||
"s2" => Err("boom".to_string()),
|
||||
_ => Ok(false),
|
||||
}
|
||||
});
|
||||
|
||||
assert_eq!(outcomes.len(), 3);
|
||||
assert!(outcomes[0].success);
|
||||
assert_eq!(outcomes[0].error, None);
|
||||
assert!(!outcomes[1].success);
|
||||
assert_eq!(outcomes[1].error.as_deref(), Some("boom"));
|
||||
assert!(!outcomes[2].success);
|
||||
assert_eq!(
|
||||
outcomes[2].error.as_deref(),
|
||||
Some("Session was not deleted")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ fn scan_sessions_sqlite() -> Vec<SessionMeta> {
|
||||
},
|
||||
created_at: Some(created),
|
||||
last_active_at: Some(updated),
|
||||
source_path: Some(format!("sqlite:{db_display}:{session_id}")),
|
||||
source_path: Some(format!("sqlite:{}:{}", db_display, session_id)),
|
||||
resume_command: Some(format!("opencode session resume {session_id}")),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -584,14 +584,17 @@ pub fn get_current_provider(app_type: &AppType) -> Option<String> {
|
||||
/// 这是设备级别的设置,不随数据库同步。
|
||||
/// 传入 `None` 会清除当前供应商设置。
|
||||
pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppError> {
|
||||
let id_owned = id.map(|s| s.to_string());
|
||||
mutate_settings(|settings| match app_type {
|
||||
AppType::Claude => settings.current_provider_claude = id_owned.clone(),
|
||||
AppType::Codex => settings.current_provider_codex = id_owned.clone(),
|
||||
AppType::Gemini => settings.current_provider_gemini = id_owned.clone(),
|
||||
AppType::OpenCode => settings.current_provider_opencode = id_owned.clone(),
|
||||
AppType::OpenClaw => settings.current_provider_openclaw = id_owned.clone(),
|
||||
})
|
||||
let mut settings = get_settings();
|
||||
|
||||
match app_type {
|
||||
AppType::Claude => settings.current_provider_claude = id.map(|s| s.to_string()),
|
||||
AppType::Codex => settings.current_provider_codex = id.map(|s| s.to_string()),
|
||||
AppType::Gemini => settings.current_provider_gemini = id.map(|s| s.to_string()),
|
||||
AppType::OpenCode => settings.current_provider_opencode = id.map(|s| s.to_string()),
|
||||
AppType::OpenClaw => settings.current_provider_openclaw = id.map(|s| s.to_string()),
|
||||
}
|
||||
|
||||
update_settings(settings)
|
||||
}
|
||||
|
||||
/// 获取有效的当前供应商 ID(验证存在性)
|
||||
|
||||
@@ -14,7 +14,6 @@ use crate::store::AppState;
|
||||
pub struct TrayTexts {
|
||||
pub show_main: &'static str,
|
||||
pub no_provider_hint: &'static str,
|
||||
pub lightweight_mode: &'static str,
|
||||
pub quit: &'static str,
|
||||
pub _auto_label: &'static str,
|
||||
}
|
||||
@@ -25,7 +24,6 @@ impl TrayTexts {
|
||||
"en" => Self {
|
||||
show_main: "Open main window",
|
||||
no_provider_hint: " (No providers yet, please add them from the main window)",
|
||||
lightweight_mode: "Lightweight Mode",
|
||||
quit: "Quit",
|
||||
_auto_label: "Auto (Failover)",
|
||||
},
|
||||
@@ -33,14 +31,12 @@ impl TrayTexts {
|
||||
show_main: "メインウィンドウを開く",
|
||||
no_provider_hint:
|
||||
" (プロバイダーがまだありません。メイン画面から追加してください)",
|
||||
lightweight_mode: "軽量モード",
|
||||
quit: "終了",
|
||||
_auto_label: "自動 (フェイルオーバー)",
|
||||
},
|
||||
_ => Self {
|
||||
show_main: "打开主界面",
|
||||
no_provider_hint: " (无供应商,请在主界面添加)",
|
||||
lightweight_mode: "轻量模式",
|
||||
quit: "退出",
|
||||
_auto_label: "自动 (故障转移)",
|
||||
},
|
||||
@@ -386,18 +382,6 @@ pub fn create_tray_menu(
|
||||
menu_builder = menu_builder.separator();
|
||||
}
|
||||
|
||||
let lightweight_item = CheckMenuItem::with_id(
|
||||
app,
|
||||
"lightweight_mode",
|
||||
tray_texts.lightweight_mode,
|
||||
true,
|
||||
crate::lightweight::is_lightweight_mode(),
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("创建轻量模式菜单失败: {e}")))?;
|
||||
|
||||
menu_builder = menu_builder.item(&lightweight_item).separator();
|
||||
|
||||
// 退出菜单(分隔符已在上面的 section 循环中添加)
|
||||
let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>)
|
||||
.map_err(|e| AppError::Message(format!("创建退出菜单失败: {e}")))?;
|
||||
@@ -409,20 +393,6 @@ pub fn create_tray_menu(
|
||||
.map_err(|e| AppError::Message(format!("构建菜单失败: {e}")))
|
||||
}
|
||||
|
||||
pub fn refresh_tray_menu(app: &tauri::AppHandle) {
|
||||
use crate::store::AppState;
|
||||
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
if let Ok(new_menu) = create_tray_menu(app, state.inner()) {
|
||||
if let Some(tray) = app.tray_by_id("main") {
|
||||
if let Err(e) = tray.set_menu(Some(new_menu)) {
|
||||
log::error!("刷新托盘菜单失败: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) {
|
||||
use tauri::ActivationPolicy;
|
||||
@@ -460,19 +430,6 @@ pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
||||
{
|
||||
apply_tray_policy(app, true);
|
||||
}
|
||||
} else if crate::lightweight::is_lightweight_mode() {
|
||||
if let Err(e) = crate::lightweight::exit_lightweight_mode(app) {
|
||||
log::error!("退出轻量模式重建窗口失败: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
"lightweight_mode" => {
|
||||
if crate::lightweight::is_lightweight_mode() {
|
||||
if let Err(e) = crate::lightweight::exit_lightweight_mode(app) {
|
||||
log::error!("退出轻量模式失败: {e}");
|
||||
}
|
||||
} else if let Err(e) = crate::lightweight::enter_lightweight_mode(app) {
|
||||
log::error!("进入轻量模式失败: {e}");
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
|
||||
+1
-8
@@ -648,14 +648,7 @@ function App() {
|
||||
|
||||
const handleOpenTerminal = async (provider: Provider) => {
|
||||
try {
|
||||
const selectedDir = await settingsApi.pickDirectory();
|
||||
if (!selectedDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
await providersApi.openTerminal(provider.id, activeApp, {
|
||||
cwd: selectedDir,
|
||||
});
|
||||
await providersApi.openTerminal(provider.id, activeApp);
|
||||
toast.success(
|
||||
t("provider.terminalOpened", {
|
||||
defaultValue: "终端已打开",
|
||||
|
||||
@@ -42,9 +42,9 @@ export function AddProviderDialog({
|
||||
const { t } = useTranslation();
|
||||
// OpenCode and OpenClaw don't support universal providers
|
||||
const showUniversalTab = appId !== "opencode" && appId !== "openclaw";
|
||||
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
|
||||
"app-specific",
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"app-specific" | "universal"
|
||||
>("app-specific");
|
||||
const [universalFormOpen, setUniversalFormOpen] = useState(false);
|
||||
const [selectedUniversalPreset, setSelectedUniversalPreset] =
|
||||
useState<UniversalProviderPreset | null>(null);
|
||||
@@ -284,7 +284,9 @@ export function AddProviderDialog({
|
||||
{showUniversalTab ? (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as "app-specific" | "universal")}
|
||||
onValueChange={(v) =>
|
||||
setActiveTab(v as "app-specific" | "universal")
|
||||
}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="app-specific">
|
||||
|
||||
@@ -123,7 +123,6 @@ export function ProviderCard({
|
||||
// OMO and OMO Slim share the same card behavior
|
||||
const isAnyOmo = isOmo || isOmoSlim;
|
||||
const handleDisableAnyOmo = isOmoSlim ? onDisableOmoSlim : onDisableOmo;
|
||||
const isAdditiveMode = appId === "opencode" && !isAnyOmo;
|
||||
|
||||
const { data: health } = useProviderHealth(provider.id, appId);
|
||||
|
||||
@@ -210,12 +209,9 @@ export function ProviderCard({
|
||||
: isCurrent;
|
||||
|
||||
const shouldUseGreen = !isAnyOmo && isProxyTakeover && isActiveProvider;
|
||||
const hasPersistentConfigHighlight = isAdditiveMode && isInConfig;
|
||||
const shouldUseBlue =
|
||||
(isAnyOmo && isActiveProvider) ||
|
||||
(!isAnyOmo &&
|
||||
!isProxyTakeover &&
|
||||
(isActiveProvider || hasPersistentConfigHighlight));
|
||||
(!isAnyOmo && !isProxyTakeover && isActiveProvider);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -228,8 +224,7 @@ export function ProviderCard({
|
||||
shouldUseGreen &&
|
||||
"border-emerald-500/60 shadow-sm shadow-emerald-500/10",
|
||||
shouldUseBlue && "border-blue-500/60 shadow-sm shadow-blue-500/10",
|
||||
!(isActiveProvider || hasPersistentConfigHighlight) &&
|
||||
"hover:shadow-sm",
|
||||
!isActiveProvider && "hover:shadow-sm",
|
||||
dragHandleProps?.isDragging &&
|
||||
"cursor-grabbing border-primary shadow-lg scale-105 z-10",
|
||||
)}
|
||||
@@ -239,10 +234,8 @@ export function ProviderCard({
|
||||
"absolute inset-0 bg-gradient-to-r to-transparent transition-opacity duration-500 pointer-events-none",
|
||||
shouldUseGreen && "from-emerald-500/10",
|
||||
shouldUseBlue && "from-blue-500/10",
|
||||
!shouldUseGreen && !shouldUseBlue && "from-primary/10",
|
||||
isActiveProvider || hasPersistentConfigHighlight
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
!isActiveProvider && "from-primary/10",
|
||||
isActiveProvider ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
|
||||
@@ -166,7 +166,8 @@ export function ClaudeFormFields({
|
||||
apiFormat !== "anthropic" ||
|
||||
apiKeyField !== "ANTHROPIC_AUTH_TOKEN"
|
||||
);
|
||||
const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue);
|
||||
const [advancedExpanded, setAdvancedExpanded] =
|
||||
useState(hasAnyAdvancedValue);
|
||||
|
||||
// 预设填充高级值后自动展开(仅从折叠→展开,不会自动折叠)
|
||||
useEffect(() => {
|
||||
@@ -408,7 +409,10 @@ export function ClaudeFormFields({
|
||||
|
||||
{/* 高级选项(API 格式 + 认证字段 + 模型映射) */}
|
||||
{shouldShowModelSelector && (
|
||||
<Collapsible open={advancedExpanded} onOpenChange={setAdvancedExpanded}>
|
||||
<Collapsible
|
||||
open={advancedExpanded}
|
||||
onOpenChange={setAdvancedExpanded}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
import {
|
||||
@@ -181,7 +175,10 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
||||
}
|
||||
} else {
|
||||
toml = removeCodexTopLevelField(toml, "model_context_window");
|
||||
toml = removeCodexTopLevelField(toml, "model_auto_compact_token_limit");
|
||||
toml = removeCodexTopLevelField(
|
||||
toml,
|
||||
"model_auto_compact_token_limit",
|
||||
);
|
||||
}
|
||||
handleLocalChange(toml);
|
||||
},
|
||||
|
||||
@@ -65,7 +65,7 @@ export function ProviderPresetSelector({
|
||||
case "omo":
|
||||
return t("providerForm.omoHint", {
|
||||
defaultValue:
|
||||
"💡 OMO 配置管理 Agent 模型分配,兼容 oh-my-openagent.jsonc / oh-my-opencode.jsonc",
|
||||
"💡 OMO 配置管理 Agent 模型分配,写入 oh-my-opencode.jsonc",
|
||||
});
|
||||
default:
|
||||
return t("providerPreset.hint", {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ChevronRight, Clock } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -20,21 +19,13 @@ import {
|
||||
interface SessionItemProps {
|
||||
session: SessionMeta;
|
||||
isSelected: boolean;
|
||||
selectionMode: boolean;
|
||||
isChecked: boolean;
|
||||
isCheckDisabled?: boolean;
|
||||
onSelect: (key: string) => void;
|
||||
onToggleChecked: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export function SessionItem({
|
||||
session,
|
||||
isSelected,
|
||||
selectionMode,
|
||||
isChecked,
|
||||
isCheckDisabled = false,
|
||||
onSelect,
|
||||
onToggleChecked,
|
||||
}: SessionItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const title = formatSessionTitle(session);
|
||||
@@ -42,64 +33,46 @@ export function SessionItem({
|
||||
const sessionKey = getSessionKey(session);
|
||||
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(sessionKey)}
|
||||
className={cn(
|
||||
"flex items-start gap-2 rounded-lg px-3 py-2.5 transition-all group",
|
||||
"w-full text-left rounded-lg px-3 py-2.5 transition-all group",
|
||||
isSelected
|
||||
? "bg-primary/10 border border-primary/30"
|
||||
: "hover:bg-muted/60 border border-transparent",
|
||||
)}
|
||||
>
|
||||
{selectionMode && (
|
||||
<div className="shrink-0 pt-0.5">
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
disabled={isCheckDisabled}
|
||||
aria-label={t("sessionManager.selectForBatch", {
|
||||
defaultValue: "选择会话",
|
||||
})}
|
||||
onCheckedChange={(checked) => onToggleChecked(Boolean(checked))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(sessionKey)}
|
||||
className="min-w-0 flex-1 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="shrink-0">
|
||||
<ProviderIcon
|
||||
icon={getProviderIconName(session.providerId)}
|
||||
name={session.providerId}
|
||||
size={18}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{getProviderLabel(session.providerId, t)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="text-sm font-medium truncate flex-1">{title}</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-4 text-muted-foreground/50 shrink-0 transition-transform",
|
||||
isSelected && "text-primary rotate-90",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="shrink-0">
|
||||
<ProviderIcon
|
||||
icon={getProviderIconName(session.providerId)}
|
||||
name={session.providerId}
|
||||
size={18}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{getProviderLabel(session.providerId, t)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="text-sm font-medium truncate flex-1">{title}</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-4 text-muted-foreground/50 shrink-0 transition-transform",
|
||||
isSelected && "text-primary rotate-90",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Clock className="size-3" />
|
||||
<span>
|
||||
{lastActive
|
||||
? formatRelativeTime(lastActive, t)
|
||||
: t("common.unknown")}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Clock className="size-3" />
|
||||
<span>
|
||||
{lastActive ? formatRelativeTime(lastActive, t) : t("common.unknown")}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSessionSearch } from "@/hooks/useSessionSearch";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Copy,
|
||||
RefreshCw,
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
Clock,
|
||||
FolderOpen,
|
||||
X,
|
||||
CheckSquare,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useDeleteSessionMutation,
|
||||
@@ -65,7 +63,6 @@ type ProviderFilter =
|
||||
|
||||
export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading, refetch } = useSessionsQuery();
|
||||
const sessions = data ?? [];
|
||||
const detailRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -76,14 +73,7 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
);
|
||||
const [tocDialogOpen, setTocDialogOpen] = useState(false);
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [deleteTargets, setDeleteTargets] = useState<SessionMeta[] | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedSessionKeys, setSelectedSessionKeys] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [isBatchDeleting, setIsBatchDeleting] = useState(false);
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<SessionMeta | null>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -132,25 +122,6 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
selectedSession?.sourcePath,
|
||||
);
|
||||
const deleteSessionMutation = useDeleteSessionMutation();
|
||||
const isDeleting = deleteSessionMutation.isPending || isBatchDeleting;
|
||||
|
||||
useEffect(() => {
|
||||
const validKeys = new Set(
|
||||
sessions.map((session) => getSessionKey(session)),
|
||||
);
|
||||
setSelectedSessionKeys((current) => {
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
current.forEach((key) => {
|
||||
if (validKeys.has(key)) {
|
||||
next.add(key);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : current;
|
||||
});
|
||||
}, [sessions]);
|
||||
|
||||
// 提取用户消息用于目录
|
||||
const userMessagesToc = useMemo(() => {
|
||||
@@ -223,195 +194,16 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTargets || deleteTargets.length === 0 || isDeleting) {
|
||||
if (!deleteTarget?.sourcePath || deleteSessionMutation.isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targets = deleteTargets.filter((session) => session.sourcePath);
|
||||
setDeleteTargets(null);
|
||||
|
||||
if (targets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (targets.length === 1) {
|
||||
const [target] = targets;
|
||||
await deleteSessionMutation.mutateAsync({
|
||||
providerId: target.providerId,
|
||||
sessionId: target.sessionId,
|
||||
sourcePath: target.sourcePath!,
|
||||
});
|
||||
setSelectedSessionKeys((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(getSessionKey(target));
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBatchDeleting(true);
|
||||
try {
|
||||
const results = await sessionsApi.deleteMany(
|
||||
targets.map((session) => ({
|
||||
providerId: session.providerId,
|
||||
sessionId: session.sessionId,
|
||||
sourcePath: session.sourcePath!,
|
||||
})),
|
||||
);
|
||||
|
||||
const deletedKeys = results
|
||||
.filter((result) => result.success)
|
||||
.map(
|
||||
(result) =>
|
||||
`${result.providerId}:${result.sessionId}:${result.sourcePath ?? ""}`,
|
||||
);
|
||||
|
||||
const failedErrors = results
|
||||
.filter((result) => !result.success)
|
||||
.map((result) => result.error || t("common.unknown"));
|
||||
|
||||
if (deletedKeys.length > 0) {
|
||||
const deletedKeySet = new Set(deletedKeys);
|
||||
queryClient.setQueryData<SessionMeta[]>(["sessions"], (current) =>
|
||||
(current ?? []).filter(
|
||||
(session) => !deletedKeySet.has(getSessionKey(session)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
results
|
||||
.filter((result) => result.success)
|
||||
.forEach((result) => {
|
||||
queryClient.removeQueries({
|
||||
queryKey: ["sessionMessages", result.providerId, result.sourcePath],
|
||||
});
|
||||
});
|
||||
|
||||
setSelectedSessionKeys((current) => {
|
||||
const next = new Set(current);
|
||||
deletedKeys.forEach((key) => next.delete(key));
|
||||
return next;
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["sessions"] });
|
||||
|
||||
if (deletedKeys.length > 0) {
|
||||
toast.success(
|
||||
t("sessionManager.batchDeleteSuccess", {
|
||||
defaultValue: "已删除 {{count}} 个会话",
|
||||
count: deletedKeys.length,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (failedErrors.length > 0) {
|
||||
toast.error(
|
||||
t("sessionManager.batchDeleteFailed", {
|
||||
defaultValue: "{{failed}} 个会话删除失败",
|
||||
failed: failedErrors.length,
|
||||
}),
|
||||
{
|
||||
description: failedErrors[0],
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
extractErrorMessage(error) ||
|
||||
t("sessionManager.batchDeleteRequestFailed", {
|
||||
defaultValue: "批量删除失败,请稍后重试",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsBatchDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deletableFilteredSessions = useMemo(
|
||||
() => filteredSessions.filter((session) => Boolean(session.sourcePath)),
|
||||
[filteredSessions],
|
||||
);
|
||||
|
||||
const selectedSessions = useMemo(
|
||||
() =>
|
||||
sessions.filter((session) =>
|
||||
selectedSessionKeys.has(getSessionKey(session)),
|
||||
),
|
||||
[sessions, selectedSessionKeys],
|
||||
);
|
||||
|
||||
const selectedDeletableSessions = useMemo(
|
||||
() => selectedSessions.filter((session) => Boolean(session.sourcePath)),
|
||||
[selectedSessions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectionMode) return;
|
||||
|
||||
const visibleKeys = new Set(
|
||||
deletableFilteredSessions.map((session) => getSessionKey(session)),
|
||||
);
|
||||
|
||||
setSelectedSessionKeys((current) => {
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
|
||||
current.forEach((key) => {
|
||||
if (visibleKeys.has(key)) {
|
||||
next.add(key);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
return changed ? next : current;
|
||||
setDeleteTarget(null);
|
||||
await deleteSessionMutation.mutateAsync({
|
||||
providerId: deleteTarget.providerId,
|
||||
sessionId: deleteTarget.sessionId,
|
||||
sourcePath: deleteTarget.sourcePath,
|
||||
});
|
||||
}, [deletableFilteredSessions, selectionMode]);
|
||||
|
||||
const allFilteredSelected =
|
||||
deletableFilteredSessions.length > 0 &&
|
||||
deletableFilteredSessions.every((session) =>
|
||||
selectedSessionKeys.has(getSessionKey(session)),
|
||||
);
|
||||
|
||||
const toggleSessionChecked = (session: SessionMeta, checked: boolean) => {
|
||||
if (!session.sourcePath) return;
|
||||
const key = getSessionKey(session);
|
||||
setSelectedSessionKeys((current) => {
|
||||
const next = new Set(current);
|
||||
if (checked) {
|
||||
next.add(key);
|
||||
} else {
|
||||
next.delete(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleSelectAll = () => {
|
||||
setSelectedSessionKeys((current) => {
|
||||
const next = new Set(current);
|
||||
if (allFilteredSelected) {
|
||||
deletableFilteredSessions.forEach((session) =>
|
||||
next.delete(getSessionKey(session)),
|
||||
);
|
||||
} else {
|
||||
deletableFilteredSessions.forEach((session) =>
|
||||
next.add(getSessionKey(session)),
|
||||
);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const openBatchDeleteDialog = () => {
|
||||
if (selectedDeletableSessions.length === 0) return;
|
||||
setDeleteTargets(selectedDeletableSessions);
|
||||
};
|
||||
|
||||
const exitSelectionMode = () => {
|
||||
setSelectionMode(false);
|
||||
setSelectedSessionKeys(new Set());
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -427,315 +219,174 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
<Card className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<CardHeader className="py-2 px-3 border-b">
|
||||
{isSearchOpen ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("sessionManager.searchPlaceholder")}
|
||||
className="h-8 pl-8 pr-8 text-sm"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
setIsSearchOpen(false);
|
||||
setSearch("");
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (search.trim() === "") {
|
||||
setIsSearchOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 size-6"
|
||||
onClick={() => {
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("sessionManager.searchPlaceholder")}
|
||||
className="h-8 pl-8 pr-8 text-sm"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
setIsSearchOpen(false);
|
||||
setSearch("");
|
||||
}}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (search.trim() === "") {
|
||||
setIsSearchOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 size-6"
|
||||
onClick={() => {
|
||||
setIsSearchOpen(false);
|
||||
setSearch("");
|
||||
}}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{t("sessionManager.sessionList")}
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{filteredSessions.length}
|
||||
</Badge>
|
||||
</div>
|
||||
{selectionMode && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 bg-blue-50 text-blue-600 hover:bg-blue-100 dark:bg-blue-950/40 dark:text-blue-300 dark:hover:bg-blue-950/60"
|
||||
aria-label={t("sessionManager.exitBatchModeTooltip", {
|
||||
defaultValue: "退出批量管理",
|
||||
})}
|
||||
onClick={exitSelectionMode}
|
||||
className="size-7"
|
||||
onClick={() => {
|
||||
setIsSearchOpen(true);
|
||||
setTimeout(
|
||||
() => searchInputRef.current?.focus(),
|
||||
0,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CheckSquare className="size-3.5" />
|
||||
<Search className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("sessionManager.exitBatchModeTooltip", {
|
||||
defaultValue: "退出批量管理",
|
||||
})}
|
||||
{t("sessionManager.searchSessions")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<CardTitle className="text-sm font-medium whitespace-nowrap">
|
||||
{t("sessionManager.sessionList")}
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{filteredSessions.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{(selectionMode ||
|
||||
deletableFilteredSessions.length > 0) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={selectionMode ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className={
|
||||
selectionMode
|
||||
? "size-7 bg-blue-50 text-blue-600 hover:bg-blue-100 dark:bg-blue-950/40 dark:text-blue-300 dark:hover:bg-blue-950/60"
|
||||
: "size-7"
|
||||
}
|
||||
aria-label={
|
||||
selectionMode
|
||||
? t("sessionManager.exitBatchModeTooltip", {
|
||||
defaultValue: "退出批量管理",
|
||||
})
|
||||
: t("sessionManager.manageBatchTooltip", {
|
||||
defaultValue: "批量管理",
|
||||
})
|
||||
}
|
||||
onClick={() => {
|
||||
if (selectionMode) {
|
||||
exitSelectionMode();
|
||||
} else {
|
||||
setSelectionMode(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CheckSquare className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{selectionMode
|
||||
? t("sessionManager.exitBatchModeTooltip", {
|
||||
defaultValue: "退出批量管理",
|
||||
})
|
||||
: t("sessionManager.manageBatchTooltip", {
|
||||
defaultValue: "批量管理",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Select
|
||||
value={providerFilter}
|
||||
onValueChange={(value) =>
|
||||
setProviderFilter(value as ProviderFilter)
|
||||
}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => {
|
||||
setIsSearchOpen(true);
|
||||
setTimeout(
|
||||
() => searchInputRef.current?.focus(),
|
||||
0,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Search className="size-3.5" />
|
||||
</Button>
|
||||
<SelectTrigger className="size-7 p-0 justify-center border-0 bg-transparent hover:bg-muted">
|
||||
<ProviderIcon
|
||||
icon={
|
||||
providerFilter === "all"
|
||||
? "apps"
|
||||
: getProviderIconName(providerFilter)
|
||||
}
|
||||
name={providerFilter}
|
||||
size={14}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("sessionManager.searchSessions")}
|
||||
{providerFilter === "all"
|
||||
? t("sessionManager.providerFilterAll")
|
||||
: providerFilter}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon icon="apps" name="all" size={14} />
|
||||
<span>
|
||||
{t("sessionManager.providerFilterAll")}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="codex">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="openai"
|
||||
name="codex"
|
||||
size={14}
|
||||
/>
|
||||
<span>Codex</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="claude">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="claude"
|
||||
name="claude"
|
||||
size={14}
|
||||
/>
|
||||
<span>Claude Code</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="opencode">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="opencode"
|
||||
name="opencode"
|
||||
size={14}
|
||||
/>
|
||||
<span>OpenCode</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="openclaw">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="openclaw"
|
||||
name="openclaw"
|
||||
size={14}
|
||||
/>
|
||||
<span>OpenClaw</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="gemini">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="gemini"
|
||||
name="gemini"
|
||||
size={14}
|
||||
/>
|
||||
<span>Gemini CLI</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={providerFilter}
|
||||
onValueChange={(value) =>
|
||||
setProviderFilter(value as ProviderFilter)
|
||||
}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SelectTrigger className="size-7 p-0 justify-center border-0 bg-transparent hover:bg-muted">
|
||||
<ProviderIcon
|
||||
icon={
|
||||
providerFilter === "all"
|
||||
? "apps"
|
||||
: getProviderIconName(providerFilter)
|
||||
}
|
||||
name={providerFilter}
|
||||
size={14}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{providerFilter === "all"
|
||||
? t("sessionManager.providerFilterAll")
|
||||
: providerFilter}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="apps"
|
||||
name="all"
|
||||
size={14}
|
||||
/>
|
||||
<span>
|
||||
{t("sessionManager.providerFilterAll")}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="codex">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="openai"
|
||||
name="codex"
|
||||
size={14}
|
||||
/>
|
||||
<span>Codex</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="claude">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="claude"
|
||||
name="claude"
|
||||
size={14}
|
||||
/>
|
||||
<span>Claude Code</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="opencode">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="opencode"
|
||||
name="opencode"
|
||||
size={14}
|
||||
/>
|
||||
<span>OpenCode</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="openclaw">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="openclaw"
|
||||
name="openclaw"
|
||||
size={14}
|
||||
/>
|
||||
<span>OpenClaw</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="gemini">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="gemini"
|
||||
name="gemini"
|
||||
size={14}
|
||||
/>
|
||||
<span>Gemini CLI</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("common.refresh")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{selectionMode && (
|
||||
<div className="grid gap-3 rounded-md border bg-muted/40 px-3 py-2.5">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("sessionManager.selectedCount", {
|
||||
defaultValue: "已选 {{count}} 项",
|
||||
count: selectedDeletableSessions.length,
|
||||
})}
|
||||
</Badge>
|
||||
<span className="truncate">
|
||||
{t("sessionManager.batchModeHint", {
|
||||
defaultValue: "勾选要删除的会话",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-3 min-[520px]:grid-cols-[minmax(0,1fr)_auto] min-[520px]:items-center">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{deletableFilteredSessions.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs whitespace-nowrap"
|
||||
onClick={handleToggleSelectAll}
|
||||
>
|
||||
{allFilteredSelected
|
||||
? t("sessionManager.clearFilteredSelection", {
|
||||
defaultValue: "取消全选",
|
||||
})
|
||||
: t("sessionManager.selectAllFiltered", {
|
||||
defaultValue: "全选当前",
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs whitespace-nowrap"
|
||||
onClick={() => setSelectedSessionKeys(new Set())}
|
||||
>
|
||||
{t("sessionManager.clearSelection", {
|
||||
defaultValue: "清空已选",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 px-2.5 whitespace-nowrap justify-self-start min-[520px]:justify-self-end"
|
||||
onClick={openBatchDeleteDialog}
|
||||
disabled={
|
||||
isDeleting ||
|
||||
selectedDeletableSessions.length === 0
|
||||
}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
<span className="text-xs">
|
||||
{isBatchDeleting
|
||||
? t("sessionManager.batchDeleting", {
|
||||
defaultValue: "删除中...",
|
||||
})
|
||||
: t("sessionManager.deleteSelected", {
|
||||
defaultValue: "批量删除",
|
||||
})}
|
||||
</span>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("common.refresh")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
@@ -765,15 +416,7 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
key={getSessionKey(session)}
|
||||
session={session}
|
||||
isSelected={isSelected}
|
||||
selectionMode={selectionMode}
|
||||
isChecked={selectedSessionKeys.has(
|
||||
getSessionKey(session),
|
||||
)}
|
||||
isCheckDisabled={!session.sourcePath}
|
||||
onSelect={setSelectedKey}
|
||||
onToggleChecked={(checked) =>
|
||||
toggleSessionChecked(session, checked)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -905,16 +548,15 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="gap-1.5"
|
||||
onClick={() =>
|
||||
setDeleteTargets([selectedSession])
|
||||
}
|
||||
onClick={() => setDeleteTarget(selectedSession)}
|
||||
disabled={
|
||||
!selectedSession.sourcePath || isDeleting
|
||||
!selectedSession.sourcePath ||
|
||||
deleteSessionMutation.isPending
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
<span className="hidden sm:inline">
|
||||
{isDeleting
|
||||
{deleteSessionMutation.isPending
|
||||
? t("sessionManager.deleting", {
|
||||
defaultValue: "删除中...",
|
||||
})
|
||||
@@ -1043,47 +685,29 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
isOpen={Boolean(deleteTargets)}
|
||||
title={
|
||||
deleteTargets && deleteTargets.length > 1
|
||||
? t("sessionManager.batchDeleteConfirmTitle", {
|
||||
defaultValue: "批量删除会话",
|
||||
})
|
||||
: t("sessionManager.deleteConfirmTitle", {
|
||||
defaultValue: "删除会话",
|
||||
})
|
||||
}
|
||||
isOpen={Boolean(deleteTarget)}
|
||||
title={t("sessionManager.deleteConfirmTitle", {
|
||||
defaultValue: "删除会话",
|
||||
})}
|
||||
message={
|
||||
deleteTargets && deleteTargets.length > 1
|
||||
? t("sessionManager.batchDeleteConfirmMessage", {
|
||||
deleteTarget
|
||||
? t("sessionManager.deleteConfirmMessage", {
|
||||
defaultValue:
|
||||
"将永久删除已选中的 {{count}} 个本地会话记录。\n\n此操作不可恢复。",
|
||||
count: deleteTargets.length,
|
||||
})
|
||||
: deleteTargets?.[0]
|
||||
? t("sessionManager.deleteConfirmMessage", {
|
||||
defaultValue:
|
||||
"将永久删除本地会话“{{title}}”\nSession ID: {{sessionId}}\n\n此操作不可恢复。",
|
||||
title: formatSessionTitle(deleteTargets[0]),
|
||||
sessionId: deleteTargets[0].sessionId,
|
||||
})
|
||||
: ""
|
||||
}
|
||||
confirmText={
|
||||
deleteTargets && deleteTargets.length > 1
|
||||
? t("sessionManager.batchDeleteConfirmAction", {
|
||||
defaultValue: "删除所选会话",
|
||||
})
|
||||
: t("sessionManager.deleteConfirmAction", {
|
||||
defaultValue: "删除会话",
|
||||
"将永久删除本地会话“{{title}}”\nSession ID: {{sessionId}}\n\n此操作不可恢复。",
|
||||
title: formatSessionTitle(deleteTarget),
|
||||
sessionId: deleteTarget.sessionId,
|
||||
})
|
||||
: ""
|
||||
}
|
||||
confirmText={t("sessionManager.deleteConfirmAction", {
|
||||
defaultValue: "删除会话",
|
||||
})}
|
||||
cancelText={t("common.cancel", { defaultValue: "取消" })}
|
||||
variant="destructive"
|
||||
onConfirm={() => void handleDeleteConfirm()}
|
||||
onCancel={() => {
|
||||
if (!isDeleting) {
|
||||
setDeleteTargets(null);
|
||||
if (!deleteSessionMutation.isPending) {
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -98,20 +98,6 @@ function formatDbCompatVersion(version?: number | null): string | null {
|
||||
return typeof version === "number" ? `db-v${version}` : null;
|
||||
}
|
||||
|
||||
function buildPasswordPreservationKey(values: {
|
||||
baseUrl?: string | null;
|
||||
username?: string | null;
|
||||
remoteRoot?: string | null;
|
||||
profile?: string | null;
|
||||
}) {
|
||||
return JSON.stringify({
|
||||
baseUrl: values.baseUrl ?? "",
|
||||
username: values.username ?? "",
|
||||
remoteRoot: values.remoteRoot ?? "cc-switch-sync",
|
||||
profile: values.profile ?? "default",
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────
|
||||
|
||||
type ActionState =
|
||||
@@ -181,10 +167,6 @@ export function WebdavSyncSection({
|
||||
const [passwordTouched, setPasswordTouched] = useState(false);
|
||||
const [justSaved, setJustSaved] = useState(false);
|
||||
const justSavedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingPasswordPreservationRef = useRef<{
|
||||
key: string;
|
||||
password: string;
|
||||
} | null>(null);
|
||||
|
||||
// Local form state — credentials are only persisted on explicit "Save".
|
||||
const [form, setForm] = useState(() => ({
|
||||
@@ -223,36 +205,13 @@ export function WebdavSyncSection({
|
||||
// Sync form when config is loaded/updated from backend, but not while user is editing
|
||||
useEffect(() => {
|
||||
if (!config || dirty) return;
|
||||
setForm(() => {
|
||||
const nextBaseUrl = config.baseUrl ?? "";
|
||||
const nextUsername = config.username ?? "";
|
||||
const nextRemoteRoot = config.remoteRoot ?? "cc-switch-sync";
|
||||
const nextProfile = config.profile ?? "default";
|
||||
const nextKey = buildPasswordPreservationKey({
|
||||
baseUrl: nextBaseUrl,
|
||||
username: nextUsername,
|
||||
remoteRoot: nextRemoteRoot,
|
||||
profile: nextProfile,
|
||||
});
|
||||
const shouldPreserveRedactedPassword =
|
||||
!config.password &&
|
||||
pendingPasswordPreservationRef.current?.key === nextKey &&
|
||||
!!pendingPasswordPreservationRef.current.password;
|
||||
|
||||
const nextPassword = shouldPreserveRedactedPassword
|
||||
? pendingPasswordPreservationRef.current!.password
|
||||
: (config.password ?? "");
|
||||
|
||||
pendingPasswordPreservationRef.current = null;
|
||||
|
||||
return {
|
||||
baseUrl: nextBaseUrl,
|
||||
username: nextUsername,
|
||||
password: nextPassword,
|
||||
remoteRoot: nextRemoteRoot,
|
||||
profile: nextProfile,
|
||||
autoSync: config.autoSync ?? false,
|
||||
};
|
||||
setForm({
|
||||
baseUrl: config.baseUrl ?? "",
|
||||
username: config.username ?? "",
|
||||
password: config.password ?? "",
|
||||
remoteRoot: config.remoteRoot ?? "cc-switch-sync",
|
||||
profile: config.profile ?? "default",
|
||||
autoSync: config.autoSync ?? false,
|
||||
});
|
||||
setPasswordTouched(false);
|
||||
setPresetId(detectPreset(config.baseUrl ?? ""));
|
||||
@@ -330,13 +289,12 @@ export function WebdavSyncSection({
|
||||
enabled: true,
|
||||
baseUrl,
|
||||
username: form.username.trim(),
|
||||
// 未重新触碰密码时,提交空值让后端沿用已保存密码,表单里的值仅用于 UI 显示
|
||||
password: passwordTouched ? form.password : "",
|
||||
password: form.password,
|
||||
remoteRoot: form.remoteRoot.trim() || "cc-switch-sync",
|
||||
profile: form.profile.trim() || "default",
|
||||
autoSync: form.autoSync,
|
||||
};
|
||||
}, [form, passwordTouched]);
|
||||
}, [form]);
|
||||
|
||||
// ─── Handlers ───────────────────────────────────────────
|
||||
|
||||
@@ -368,12 +326,6 @@ export function WebdavSyncSection({
|
||||
return;
|
||||
}
|
||||
setActionState("saving");
|
||||
pendingPasswordPreservationRef.current = form.password
|
||||
? {
|
||||
key: buildPasswordPreservationKey(settings),
|
||||
password: form.password,
|
||||
}
|
||||
: null;
|
||||
try {
|
||||
await settingsApi.webdavSyncSaveSettings(settings, passwordTouched);
|
||||
setDirty(false);
|
||||
@@ -387,7 +339,6 @@ export function WebdavSyncSection({
|
||||
}, 2000);
|
||||
await queryClient.invalidateQueries();
|
||||
} catch (error) {
|
||||
pendingPasswordPreservationRef.current = null;
|
||||
toast.error(
|
||||
t("settings.webdavSync.saveFailed", {
|
||||
error: (error as Error)?.message ?? String(error),
|
||||
@@ -411,7 +362,7 @@ export function WebdavSyncSection({
|
||||
} finally {
|
||||
setActionState("idle");
|
||||
}
|
||||
}, [buildSettings, form.password, passwordTouched, queryClient, t]);
|
||||
}, [buildSettings, passwordTouched, queryClient, t]);
|
||||
|
||||
/** Fetch remote info, then open upload confirmation dialog. */
|
||||
const handleUploadClick = useCallback(async () => {
|
||||
|
||||
@@ -192,7 +192,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
apiKeyUrl: "https://platform.stepfun.ai/interface-key",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.stepfun.com/v1",
|
||||
ANTHROPIC_BASE_URL: "https://api.stepfun.ai/v1",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "step-3.5-flash",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "step-3.5-flash",
|
||||
@@ -201,7 +201,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
endpointCandidates: ["https://api.stepfun.com/v1"],
|
||||
endpointCandidates: ["https://api.stepfun.ai/v1"],
|
||||
apiFormat: "openai_chat",
|
||||
icon: "stepfun",
|
||||
iconColor: "#005AFF",
|
||||
|
||||
@@ -11,4 +11,5 @@ export const TEMPLATE_TYPES = {
|
||||
GITHUB_COPILOT: "github_copilot",
|
||||
} as const;
|
||||
|
||||
export type TemplateType = (typeof TEMPLATE_TYPES)[keyof typeof TEMPLATE_TYPES];
|
||||
export type TemplateType =
|
||||
(typeof TEMPLATE_TYPES)[keyof typeof TEMPLATE_TYPES];
|
||||
|
||||
@@ -298,7 +298,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
websiteUrl: "https://platform.stepfun.ai",
|
||||
apiKeyUrl: "https://platform.stepfun.ai/interface-key",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://api.stepfun.com/v1",
|
||||
baseUrl: "https://api.stepfun.ai/v1",
|
||||
apiKey: "",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
@@ -315,8 +315,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
templateValues: {
|
||||
baseUrl: {
|
||||
label: "Base URL",
|
||||
placeholder: "https://api.stepfun.com/v1",
|
||||
defaultValue: "https://api.stepfun.com/v1",
|
||||
placeholder: "https://api.stepfun.ai/v1",
|
||||
defaultValue: "https://api.stepfun.ai/v1",
|
||||
editorValue: "",
|
||||
},
|
||||
apiKey: {
|
||||
|
||||
@@ -453,10 +453,10 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
websiteUrl: "https://www.kimi.com/coding/docs/",
|
||||
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/anthropic",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "Kimi For Coding",
|
||||
options: {
|
||||
baseURL: "https://api.kimi.com/coding/v1",
|
||||
baseURL: "https://api.kimi.com/v1",
|
||||
apiKey: "",
|
||||
setCacheKey: true,
|
||||
},
|
||||
@@ -470,8 +470,8 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
templateValues: {
|
||||
baseURL: {
|
||||
label: "Base URL",
|
||||
placeholder: "https://api.kimi.com/coding/v1",
|
||||
defaultValue: "https://api.kimi.com/coding/v1",
|
||||
placeholder: "https://api.kimi.com/v1",
|
||||
defaultValue: "https://api.kimi.com/v1",
|
||||
editorValue: "",
|
||||
},
|
||||
apiKey: {
|
||||
@@ -489,7 +489,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "StepFun",
|
||||
options: {
|
||||
baseURL: "https://api.stepfun.com/v1",
|
||||
baseURL: "https://api.stepfun.ai/v1",
|
||||
apiKey: "",
|
||||
setCacheKey: true,
|
||||
},
|
||||
@@ -503,8 +503,8 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
templateValues: {
|
||||
baseURL: {
|
||||
label: "Base URL",
|
||||
placeholder: "https://api.stepfun.com/v1",
|
||||
defaultValue: "https://api.stepfun.com/v1",
|
||||
placeholder: "https://api.stepfun.ai/v1",
|
||||
defaultValue: "https://api.stepfun.ai/v1",
|
||||
editorValue: "",
|
||||
},
|
||||
apiKey: {
|
||||
@@ -1343,7 +1343,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
|
||||
{
|
||||
name: "Oh My OpenCode",
|
||||
websiteUrl: "https://github.com/code-yeongyu/oh-my-openagent",
|
||||
websiteUrl: "https://github.com/code-yeongyu/oh-my-opencode",
|
||||
settingsConfig: {
|
||||
npm: "",
|
||||
options: {},
|
||||
|
||||
@@ -139,47 +139,21 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
|
||||
// 切换供应商
|
||||
const switchProvider = useCallback(
|
||||
async (provider: Provider) => {
|
||||
const isCopilotProvider =
|
||||
activeApp === "claude" &&
|
||||
provider.meta?.providerType === "github_copilot";
|
||||
const requiresProxyForSwitch =
|
||||
!isProxyRunning &&
|
||||
provider.category !== "official" &&
|
||||
((activeApp === "claude" &&
|
||||
(provider.meta?.isFullUrl ||
|
||||
provider.meta?.apiFormat === "openai_chat" ||
|
||||
provider.meta?.apiFormat === "openai_responses")) ||
|
||||
(activeApp === "codex" && provider.meta?.isFullUrl));
|
||||
|
||||
// Determine why this provider requires the proxy
|
||||
let proxyRequiredReason: string | null = null;
|
||||
if (!isProxyRunning && provider.category !== "official") {
|
||||
if (isCopilotProvider) {
|
||||
proxyRequiredReason = t("notifications.proxyReasonCopilot", {
|
||||
defaultValue: "使用 GitHub Copilot 作为 Claude 供应商",
|
||||
});
|
||||
} else if (
|
||||
provider.meta?.apiFormat === "openai_chat" &&
|
||||
activeApp === "claude"
|
||||
) {
|
||||
proxyRequiredReason = t("notifications.proxyReasonOpenAIChat", {
|
||||
defaultValue: "使用 OpenAI Chat 接口格式",
|
||||
});
|
||||
} else if (
|
||||
provider.meta?.apiFormat === "openai_responses" &&
|
||||
activeApp === "claude"
|
||||
) {
|
||||
proxyRequiredReason = t("notifications.proxyReasonOpenAIResponses", {
|
||||
defaultValue: "使用 OpenAI Responses 接口格式",
|
||||
});
|
||||
} else if (
|
||||
provider.meta?.isFullUrl &&
|
||||
(activeApp === "claude" || activeApp === "codex")
|
||||
) {
|
||||
proxyRequiredReason = t("notifications.proxyReasonFullUrl", {
|
||||
defaultValue: "开启了完整 URL 连接模式",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (proxyRequiredReason) {
|
||||
if (
|
||||
requiresProxyForSwitch
|
||||
) {
|
||||
toast.warning(
|
||||
t("notifications.proxyRequiredForSwitch", {
|
||||
reason: proxyRequiredReason,
|
||||
defaultValue:
|
||||
"此供应商{{reason}},需要代理服务才能正常使用,请先启动代理",
|
||||
defaultValue: "此供应商需要代理服务,请先启动代理",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
@@ -204,15 +178,15 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
|
||||
if (
|
||||
activeApp === "claude" &&
|
||||
provider.category !== "official" &&
|
||||
(isCopilotProvider ||
|
||||
provider.meta?.apiFormat === "openai_chat" ||
|
||||
(provider.meta?.apiFormat === "openai_chat" ||
|
||||
provider.meta?.apiFormat === "openai_responses")
|
||||
) {
|
||||
// OpenAI format provider: show proxy hint
|
||||
toast.info(
|
||||
isCopilotProvider
|
||||
? t("notifications.copilotProxyHint")
|
||||
: t("notifications.openAIFormatHint"),
|
||||
t("notifications.openAIFormatHint", {
|
||||
defaultValue:
|
||||
"此供应商使用 OpenAI 兼容格式,需要开启代理服务才能正常使用",
|
||||
}),
|
||||
{
|
||||
duration: 5000,
|
||||
closeButton: true,
|
||||
|
||||
+8
-10
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
keepPreviousData,
|
||||
} from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient, keepPreviousData } from "@tanstack/react-query";
|
||||
import {
|
||||
skillsApi,
|
||||
type SkillBackupEntry,
|
||||
@@ -113,10 +108,13 @@ export function useInstallSkill() {
|
||||
export function useUninstallSkill() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, skillKey }: { id: string; skillKey: string }) =>
|
||||
skillsApi
|
||||
.uninstallUnified(id)
|
||||
.then((result) => ({ ...result, skillKey })),
|
||||
mutationFn: ({
|
||||
id,
|
||||
skillKey,
|
||||
}: {
|
||||
id: string;
|
||||
skillKey: string;
|
||||
}) => skillsApi.uninstallUnified(id).then((result) => ({ ...result, skillKey })),
|
||||
onSuccess: ({ skillKey }, _vars) => {
|
||||
// 直接更新 installed 缓存,移除该 skill
|
||||
queryClient.setQueryData<InstalledSkill[]>(
|
||||
|
||||
@@ -174,13 +174,9 @@
|
||||
"deleteFailed": "Failed to delete provider: {{error}}",
|
||||
"settingsSaved": "Settings saved",
|
||||
"settingsSaveFailed": "Failed to save settings: {{error}}",
|
||||
"proxyRequiredForSwitch": "This provider {{reason}}, requires the proxy service to work properly. Start the proxy first.",
|
||||
"proxyReasonCopilot": "uses GitHub Copilot as a Claude provider",
|
||||
"proxyReasonOpenAIChat": "uses OpenAI Chat API format",
|
||||
"proxyReasonOpenAIResponses": "uses OpenAI Responses API format",
|
||||
"proxyReasonFullUrl": "has full URL connection mode enabled",
|
||||
"openAIChatFormatHint": "This provider uses OpenAI Chat format and requires the proxy service to be enabled",
|
||||
"openAIFormatHint": "This provider uses OpenAI-compatible format and requires the proxy service to be enabled",
|
||||
"copilotProxyHint": "GitHub Copilot as a Claude provider always requires the local proxy; the proxy automatically selects Chat Completions or Responses based on the current model.",
|
||||
"proxyRequiredForSwitch": "This provider requires the proxy service. Start the proxy first.",
|
||||
"openLinkFailed": "Failed to open link",
|
||||
"openclawModelsRegistered": "Models have been registered to /model list",
|
||||
"openclawDefaultModelSet": "Set as default model",
|
||||
@@ -613,16 +609,6 @@
|
||||
"searchSessions": "Search sessions",
|
||||
"providerFilterAll": "All",
|
||||
"sessionList": "Sessions",
|
||||
"manageBatchTooltip": "Enter batch management",
|
||||
"exitBatchModeTooltip": "Exit batch management",
|
||||
"batchModeHint": "Select sessions to delete",
|
||||
"selectForBatch": "Select session",
|
||||
"selectedCount": "{{count}} selected",
|
||||
"selectAllFiltered": "Select all",
|
||||
"clearFilteredSelection": "Clear selection",
|
||||
"clearSelection": "Clear",
|
||||
"deleteSelected": "Delete",
|
||||
"batchDeleting": "Deleting...",
|
||||
"loadingSessions": "Loading sessions...",
|
||||
"noSessions": "No sessions found",
|
||||
"selectSession": "Select a session to view details",
|
||||
@@ -651,12 +637,6 @@
|
||||
"deleteConfirmAction": "Delete session",
|
||||
"sessionDeleted": "Session deleted",
|
||||
"deleteFailed": "Failed to delete session: {{error}}",
|
||||
"batchDeleteConfirmTitle": "Delete selected sessions",
|
||||
"batchDeleteConfirmMessage": "This will permanently delete {{count}} selected local sessions.\n\nThis action cannot be undone.",
|
||||
"batchDeleteConfirmAction": "Delete selected",
|
||||
"batchDeleteSuccess": "Deleted {{count}} sessions",
|
||||
"batchDeleteFailed": "{{failed}} sessions could not be deleted",
|
||||
"batchDeleteRequestFailed": "Batch delete failed. Please try again later.",
|
||||
"loadingMessages": "Loading transcript...",
|
||||
"emptySession": "No messages available",
|
||||
"clickToCopyPath": "Click to copy path",
|
||||
@@ -714,7 +694,7 @@
|
||||
"aggregatorApiKeyHint": "💡 Only need to fill in API Key, endpoint is preset",
|
||||
"thirdPartyApiKeyHint": "💡 Only need to fill in API Key, endpoint is preset",
|
||||
"customApiKeyHint": "💡 Custom configuration requires manually filling all necessary fields",
|
||||
"omoHint": "💡 OMO config manages Agent model assignments and supports both oh-my-openagent.jsonc and oh-my-opencode.jsonc",
|
||||
"omoHint": "💡 OMO config manages Agent model assignments and writes to oh-my-opencode.jsonc",
|
||||
"officialHint": "💡 Official provider uses browser login, no API Key needed",
|
||||
"getApiKey": "Get API Key",
|
||||
"partnerPromotion": {
|
||||
|
||||
@@ -174,13 +174,9 @@
|
||||
"deleteFailed": "プロバイダーの削除に失敗しました: {{error}}",
|
||||
"settingsSaved": "設定を保存しました",
|
||||
"settingsSaveFailed": "設定の保存に失敗しました: {{error}}",
|
||||
"proxyRequiredForSwitch": "このプロバイダーは{{reason}}、プロキシサービスが必要です。先にプロキシを起動してください",
|
||||
"proxyReasonCopilot": "GitHub Copilot を Claude プロバイダーとして使用しており",
|
||||
"proxyReasonOpenAIChat": "OpenAI Chat API フォーマットを使用しており",
|
||||
"proxyReasonOpenAIResponses": "OpenAI Responses API フォーマットを使用しており",
|
||||
"proxyReasonFullUrl": "完全 URL 接続モードが有効になっており",
|
||||
"openAIChatFormatHint": "このプロバイダーは OpenAI Chat フォーマットを使用しており、プロキシサービスの有効化が必要です",
|
||||
"openAIFormatHint": "このプロバイダーは OpenAI 互換フォーマットを使用しており、プロキシサービスの有効化が必要です",
|
||||
"copilotProxyHint": "GitHub Copilot を Claude プロバイダーとして使用する場合、ローカルプロキシが常に必要です。プロキシは現在のモデルに応じて Chat Completions または Responses を自動的に選択します。",
|
||||
"proxyRequiredForSwitch": "このプロバイダーにはプロキシサービスが必要です。先にプロキシを起動してください",
|
||||
"openLinkFailed": "リンクを開けませんでした",
|
||||
"openclawModelsRegistered": "モデルが /model リストに登録されました",
|
||||
"openclawDefaultModelSet": "デフォルトモデルに設定しました",
|
||||
@@ -613,16 +609,6 @@
|
||||
"searchSessions": "セッションを検索",
|
||||
"providerFilterAll": "すべて",
|
||||
"sessionList": "セッション一覧",
|
||||
"manageBatchTooltip": "一括管理に入る",
|
||||
"exitBatchModeTooltip": "一括管理を終了",
|
||||
"batchModeHint": "削除するセッションを選択",
|
||||
"selectForBatch": "セッションを選択",
|
||||
"selectedCount": "{{count}} 件を選択中",
|
||||
"selectAllFiltered": "一覧を全選択",
|
||||
"clearFilteredSelection": "全選択を解除",
|
||||
"clearSelection": "クリア",
|
||||
"deleteSelected": "削除",
|
||||
"batchDeleting": "削除中...",
|
||||
"loadingSessions": "セッションを読み込み中...",
|
||||
"noSessions": "セッションが見つかりません",
|
||||
"selectSession": "セッションを選択してください",
|
||||
@@ -651,12 +637,6 @@
|
||||
"deleteConfirmAction": "セッションを削除",
|
||||
"sessionDeleted": "セッションを削除しました",
|
||||
"deleteFailed": "セッションの削除に失敗しました: {{error}}",
|
||||
"batchDeleteConfirmTitle": "選択したセッションを削除",
|
||||
"batchDeleteConfirmMessage": "選択した {{count}} 件のローカルセッションを完全に削除します。\n\nこの操作は元に戻せません。",
|
||||
"batchDeleteConfirmAction": "選択した項目を削除",
|
||||
"batchDeleteSuccess": "{{count}} 件のセッションを削除しました",
|
||||
"batchDeleteFailed": "{{failed}} 件のセッションを削除できませんでした",
|
||||
"batchDeleteRequestFailed": "一括削除に失敗しました。しばらくしてから再試行してください。",
|
||||
"loadingMessages": "内容を読み込み中...",
|
||||
"emptySession": "表示できる内容がありません",
|
||||
"clickToCopyPath": "クリックしてパスをコピー",
|
||||
@@ -714,7 +694,7 @@
|
||||
"aggregatorApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです",
|
||||
"thirdPartyApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです",
|
||||
"customApiKeyHint": "💡 カスタム設定では必要な項目をすべて手動で入力してください",
|
||||
"omoHint": "💡 OMO 設定は Agent のモデル割り当てを管理し、oh-my-openagent.jsonc / oh-my-opencode.jsonc の両方に対応します",
|
||||
"omoHint": "💡 OMO 設定は Agent のモデル割り当てを管理し、oh-my-opencode.jsonc に書き込みます",
|
||||
"officialHint": "💡 公式プロバイダーはブラウザログインで、API Key は不要です",
|
||||
"getApiKey": "API Key を取得",
|
||||
"partnerPromotion": {
|
||||
|
||||
@@ -174,13 +174,9 @@
|
||||
"deleteFailed": "删除供应商失败:{{error}}",
|
||||
"settingsSaved": "设置已保存",
|
||||
"settingsSaveFailed": "保存设置失败:{{error}}",
|
||||
"proxyRequiredForSwitch": "此供应商{{reason}},需要代理服务才能正常使用,请先启动代理",
|
||||
"proxyReasonCopilot": "使用 GitHub Copilot 作为 Claude 供应商",
|
||||
"proxyReasonOpenAIChat": "使用 OpenAI Chat 接口格式",
|
||||
"proxyReasonOpenAIResponses": "使用 OpenAI Responses 接口格式",
|
||||
"proxyReasonFullUrl": "开启了完整 URL 连接模式",
|
||||
"openAIChatFormatHint": "此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用",
|
||||
"openAIFormatHint": "此供应商使用 OpenAI 兼容格式,需要开启代理服务才能正常使用",
|
||||
"copilotProxyHint": "GitHub Copilot 作为 Claude 供应商时始终需要本地代理;代理会根据当前模型自动选择 Chat Completions 或 Responses。",
|
||||
"proxyRequiredForSwitch": "此供应商需要代理服务,请先启动代理",
|
||||
"openLinkFailed": "链接打开失败",
|
||||
"openclawModelsRegistered": "模型已注册到 /model 列表",
|
||||
"openclawDefaultModelSet": "已设为默认模型",
|
||||
@@ -613,16 +609,6 @@
|
||||
"searchSessions": "搜索会话",
|
||||
"providerFilterAll": "全部",
|
||||
"sessionList": "会话列表",
|
||||
"manageBatchTooltip": "进入批量管理",
|
||||
"exitBatchModeTooltip": "退出批量管理",
|
||||
"batchModeHint": "勾选要删除的会话",
|
||||
"selectForBatch": "选择会话",
|
||||
"selectedCount": "已选 {{count}} 项",
|
||||
"selectAllFiltered": "全选当前",
|
||||
"clearFilteredSelection": "取消全选",
|
||||
"clearSelection": "清空已选",
|
||||
"deleteSelected": "批量删除",
|
||||
"batchDeleting": "删除中...",
|
||||
"loadingSessions": "加载会话中...",
|
||||
"noSessions": "未发现会话",
|
||||
"selectSession": "请选择会话查看详情",
|
||||
@@ -651,12 +637,6 @@
|
||||
"deleteConfirmAction": "删除会话",
|
||||
"sessionDeleted": "会话已删除",
|
||||
"deleteFailed": "删除会话失败: {{error}}",
|
||||
"batchDeleteConfirmTitle": "批量删除会话",
|
||||
"batchDeleteConfirmMessage": "将永久删除已选中的 {{count}} 个本地会话记录。\n\n此操作不可恢复。",
|
||||
"batchDeleteConfirmAction": "删除所选会话",
|
||||
"batchDeleteSuccess": "已删除 {{count}} 个会话",
|
||||
"batchDeleteFailed": "{{failed}} 个会话删除失败",
|
||||
"batchDeleteRequestFailed": "批量删除失败,请稍后重试",
|
||||
"loadingMessages": "加载会话内容中...",
|
||||
"emptySession": "该会话暂无可展示内容",
|
||||
"clickToCopyPath": "点击复制路径",
|
||||
@@ -714,7 +694,7 @@
|
||||
"aggregatorApiKeyHint": "💡 只需填写 API Key,请求地址已预设",
|
||||
"thirdPartyApiKeyHint": "💡 只需填写 API Key,请求地址已预设",
|
||||
"customApiKeyHint": "💡 自定义配置需手动填写所有必要字段",
|
||||
"omoHint": "💡 OMO 配置管理 Agent 模型分配,兼容 oh-my-openagent.jsonc / oh-my-opencode.jsonc",
|
||||
"omoHint": "💡 OMO 配置管理 Agent 模型分配,写入 oh-my-opencode.jsonc",
|
||||
"officialHint": "💡 官方供应商使用浏览器登录,无需配置 API Key",
|
||||
"getApiKey": "获取 API Key",
|
||||
"partnerPromotion": {
|
||||
|
||||
@@ -21,10 +21,6 @@ export interface SwitchResult {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface OpenTerminalOptions {
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export const providersApi = {
|
||||
async getAll(appId: AppId): Promise<Record<string, Provider>> {
|
||||
return await invoke("get_providers", { app: appId });
|
||||
@@ -87,17 +83,8 @@ export const providersApi = {
|
||||
* 任何提供商都可以打开终端,不受是否为当前激活提供商的限制
|
||||
* 终端会使用该提供商特定的 API 配置,不影响全局设置
|
||||
*/
|
||||
async openTerminal(
|
||||
providerId: string,
|
||||
appId: AppId,
|
||||
options?: OpenTerminalOptions,
|
||||
): Promise<boolean> {
|
||||
const { cwd } = options ?? {};
|
||||
return await invoke("open_provider_terminal", {
|
||||
providerId,
|
||||
app: appId,
|
||||
cwd,
|
||||
});
|
||||
async openTerminal(providerId: string, appId: AppId): Promise<boolean> {
|
||||
return await invoke("open_provider_terminal", { providerId, app: appId });
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,11 +7,6 @@ export interface DeleteSessionOptions {
|
||||
sourcePath: string;
|
||||
}
|
||||
|
||||
export interface DeleteSessionResult extends DeleteSessionOptions {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const sessionsApi = {
|
||||
async list(): Promise<SessionMeta[]> {
|
||||
return await invoke("list_sessions");
|
||||
@@ -33,12 +28,6 @@ export const sessionsApi = {
|
||||
});
|
||||
},
|
||||
|
||||
async deleteMany(
|
||||
items: DeleteSessionOptions[],
|
||||
): Promise<DeleteSessionResult[]> {
|
||||
return await invoke("delete_sessions", { items });
|
||||
},
|
||||
|
||||
async launchTerminal(options: {
|
||||
command: string;
|
||||
cwd?: string | null;
|
||||
|
||||
@@ -47,10 +47,6 @@ export const settingsApi = {
|
||||
await invoke("open_config_folder", { app: appId });
|
||||
},
|
||||
|
||||
async pickDirectory(defaultPath?: string): Promise<string | null> {
|
||||
return await invoke("pick_directory", { defaultPath });
|
||||
},
|
||||
|
||||
async selectConfigDirectory(defaultPath?: string): Promise<string | null> {
|
||||
return await invoke("pick_directory", { defaultPath });
|
||||
},
|
||||
|
||||
+1
-1
@@ -246,7 +246,7 @@ export const OMO_DISABLEABLE_SKILLS = [
|
||||
] as const;
|
||||
|
||||
export const OMO_DEFAULT_SCHEMA_URL =
|
||||
"https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json";
|
||||
"https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json";
|
||||
|
||||
export const OMO_SISYPHUS_AGENT_PLACEHOLDER = `{
|
||||
"disabled": false,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
@@ -9,7 +8,6 @@ import {
|
||||
} from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
|
||||
import { sessionsApi } from "@/lib/api/sessions";
|
||||
import type { SessionMessage, SessionMeta } from "@/types";
|
||||
import { setSessionFixtures } from "../msw/state";
|
||||
|
||||
@@ -64,19 +62,16 @@ const renderPage = () => {
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
client,
|
||||
...render(
|
||||
<QueryClientProvider client={client}>
|
||||
<SessionManagerPage appId="codex" />
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
};
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<SessionManagerPage appId="codex" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
const openSearch = () => {
|
||||
const searchButton = Array.from(screen.getAllByRole("button")).find(
|
||||
(button) => button.querySelector(".lucide-search"),
|
||||
const searchButton = Array.from(screen.getAllByRole("button")).find((button) =>
|
||||
button.querySelector(".lucide-search"),
|
||||
);
|
||||
|
||||
if (!searchButton) {
|
||||
@@ -86,23 +81,10 @@ const openSearch = () => {
|
||||
fireEvent.click(searchButton);
|
||||
};
|
||||
|
||||
const closeSearch = () => {
|
||||
const closeButton = Array.from(screen.getAllByRole("button")).find(
|
||||
(button) => button.querySelector(".lucide-x"),
|
||||
);
|
||||
|
||||
if (!closeButton) {
|
||||
throw new Error("Search close button not found");
|
||||
}
|
||||
|
||||
fireEvent.click(closeButton);
|
||||
};
|
||||
|
||||
describe("SessionManagerPage", () => {
|
||||
beforeEach(() => {
|
||||
toastSuccessMock.mockReset();
|
||||
toastErrorMock.mockReset();
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
|
||||
const sessions: SessionMeta[] = [
|
||||
{
|
||||
@@ -196,136 +178,11 @@ describe("SessionManagerPage", () => {
|
||||
expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("sessionManager.selectSession"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("sessionManager.selectSession")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("sessionManager.emptySession"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(toastErrorMock).not.toHaveBeenCalled();
|
||||
expect(toastSuccessMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores batch delete controls when deleteMany rejects", async () => {
|
||||
const deleteManySpy = vi
|
||||
.spyOn(sessionsApi, "deleteMany")
|
||||
.mockRejectedValueOnce(new Error("network error"));
|
||||
|
||||
renderPage();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Alpha Session" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /全选当前/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /批量删除/i }));
|
||||
|
||||
const dialog = screen.getByTestId("confirm-dialog");
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole("button", { name: /删除所选会话/i }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toastErrorMock).toHaveBeenCalledWith("network error"),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: /批量删除/i }),
|
||||
).not.toBeDisabled(),
|
||||
);
|
||||
|
||||
deleteManySpy.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps the exit batch mode button visible when search hides all sessions", async () => {
|
||||
renderPage();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Alpha Session" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
|
||||
openSearch();
|
||||
fireEvent.change(screen.getByRole("textbox"), {
|
||||
target: { value: "NoSuchSession" },
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.queryByText("Alpha Session")).toBeNull());
|
||||
|
||||
expect(screen.getByRole("button", { name: /退出批量管理/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it("drops hidden selections when search narrows the result set", async () => {
|
||||
renderPage();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Alpha Session" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /全选当前/i }));
|
||||
|
||||
expect(screen.getByText("已选 2 项")).toBeInTheDocument();
|
||||
|
||||
openSearch();
|
||||
fireEvent.change(screen.getByRole("textbox"), {
|
||||
target: { value: "Alpha" },
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("Beta Session")).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
closeSearch();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("已选 1 项")).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes successfully deleted sessions from the UI before refetch completes", async () => {
|
||||
const view = renderPage();
|
||||
let resolveInvalidate!: () => void;
|
||||
const invalidateSpy = vi
|
||||
.spyOn(view.client, "invalidateQueries")
|
||||
.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveInvalidate = () => resolve(undefined);
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Alpha Session" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /全选当前/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /批量删除/i }));
|
||||
|
||||
const dialog = screen.getByTestId("confirm-dialog");
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole("button", { name: /删除所选会话/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Beta Session")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveInvalidate();
|
||||
});
|
||||
invalidateSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,12 +104,11 @@ function renderSection(config?: WebDavSyncSettings) {
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
const view = render(
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<WebdavSyncSection config={config} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { ...view, client };
|
||||
}
|
||||
|
||||
describe("WebdavSyncSection", () => {
|
||||
@@ -205,7 +204,7 @@ describe("WebdavSyncSection", () => {
|
||||
expect.objectContaining({
|
||||
baseUrl: "https://dav.example.com/dav/",
|
||||
username: "alice",
|
||||
password: "",
|
||||
password: "secret",
|
||||
autoSync: false,
|
||||
}),
|
||||
false,
|
||||
@@ -223,111 +222,6 @@ describe("WebdavSyncSection", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves password only for the single post-save refresh", async () => {
|
||||
const view = renderSection(baseConfig);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
view.rerender(
|
||||
<QueryClientProvider client={view.client}>
|
||||
<WebdavSyncSection config={{ ...baseConfig, password: "" }} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
(
|
||||
screen.getByPlaceholderText(
|
||||
"settings.webdavSync.passwordPlaceholder",
|
||||
) as HTMLInputElement
|
||||
).value,
|
||||
).toBe("secret");
|
||||
|
||||
view.rerender(
|
||||
<QueryClientProvider client={view.client}>
|
||||
<WebdavSyncSection config={{ ...baseConfig, password: "" }} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
(
|
||||
screen.getByPlaceholderText(
|
||||
"settings.webdavSync.passwordPlaceholder",
|
||||
) as HTMLInputElement
|
||||
).value,
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("does not preserve password after a later external config refresh", async () => {
|
||||
const view = renderSection(baseConfig);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
view.rerender(
|
||||
<QueryClientProvider client={view.client}>
|
||||
<WebdavSyncSection config={{ ...baseConfig, password: "" }} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
(
|
||||
screen.getByPlaceholderText(
|
||||
"settings.webdavSync.passwordPlaceholder",
|
||||
) as HTMLInputElement
|
||||
).value,
|
||||
).toBe("secret");
|
||||
|
||||
view.rerender(
|
||||
<QueryClientProvider client={view.client}>
|
||||
<WebdavSyncSection
|
||||
config={{ ...baseConfig, username: "bob", password: "" }}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
(
|
||||
screen.getByPlaceholderText(
|
||||
"settings.webdavSync.passwordPlaceholder",
|
||||
) as HTMLInputElement
|
||||
).value,
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("does not submit a preserved password again when testing without touching it", async () => {
|
||||
const view = renderSection(baseConfig);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
view.rerender(
|
||||
<QueryClientProvider client={view.client}>
|
||||
<WebdavSyncSection config={{ ...baseConfig, password: "" }} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.test" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApiMock.webdavTestConnection).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
password: "",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("saves auto sync as true after toggle", async () => {
|
||||
renderSection(baseConfig);
|
||||
|
||||
|
||||
@@ -65,19 +65,4 @@ describe("AWS Bedrock OpenCode Provider Presets", () => {
|
||||
modelIds.some((id) => id.includes("anthropic.claude")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("Kimi For Coding preset should use Anthropic with the coding endpoint", () => {
|
||||
const kimiForCodingPreset = opencodeProviderPresets.find(
|
||||
(p) => p.name === "Kimi For Coding",
|
||||
);
|
||||
|
||||
expect(kimiForCodingPreset).toBeDefined();
|
||||
expect(kimiForCodingPreset!.settingsConfig.npm).toBe("@ai-sdk/anthropic");
|
||||
expect(kimiForCodingPreset!.settingsConfig.options?.baseURL).toBe(
|
||||
"https://api.kimi.com/coding/v1",
|
||||
);
|
||||
expect(kimiForCodingPreset!.templateValues?.baseURL.defaultValue).toBe(
|
||||
"https://api.kimi.com/coding/v1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,29 +129,6 @@ export const handlers = [
|
||||
return success(deleteSession(providerId, sessionId, sourcePath));
|
||||
}),
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/delete_sessions`, async ({ request }) => {
|
||||
const { items = [] } = await withJson<{
|
||||
items?: {
|
||||
providerId: string;
|
||||
sessionId: string;
|
||||
sourcePath: string;
|
||||
}[];
|
||||
}>(request);
|
||||
|
||||
return success(
|
||||
items.map((item) => ({
|
||||
providerId: item.providerId,
|
||||
sessionId: item.sessionId,
|
||||
sourcePath: item.sourcePath,
|
||||
success: deleteSession(
|
||||
item.providerId,
|
||||
item.sessionId,
|
||||
item.sourcePath,
|
||||
),
|
||||
})),
|
||||
);
|
||||
}),
|
||||
|
||||
// MCP APIs
|
||||
http.post(`${TAURI_ENDPOINT}/get_mcp_config`, async ({ request }) => {
|
||||
const { app } = await withJson<{ app: AppId }>(request);
|
||||
|
||||
Reference in New Issue
Block a user