diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 46d700f..68b4a94 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "desktop", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "desktop", - "version": "0.1.0", + "version": "0.2.0", "devDependencies": { "@tauri-apps/cli": "^2" } diff --git a/desktop/src-tauri/src/config.rs b/desktop/src-tauri/src/config.rs index d71f108..b490b18 100644 --- a/desktop/src-tauri/src/config.rs +++ b/desktop/src-tauri/src/config.rs @@ -30,11 +30,14 @@ fn default_mode() -> String { "proxy".to_string() } -/// 单个 provider 的配置。目前只有 key(明文存盘)。 +/// 单个 provider 的配置。key 明文存盘;base_url 仅「中转站」(relay) 用—— +/// 中转站的 Anthropic 兼容端点根地址(如 https://byteswarm.ai/claude)。base_url 不是密钥,可回显。 #[derive(Serialize, Deserialize, Clone, Default, Debug, PartialEq)] pub struct ProviderCfg { #[serde(default)] pub key: String, + #[serde(default)] + pub base_url: String, } /// 顶层配置。字段都有默认值,缺字段的旧文件也能读。 @@ -80,6 +83,14 @@ impl Config { .map(|p| p.key.clone()) .filter(|k| !k.is_empty()) } + + /// 取某 provider 的 base_url(仅 relay 用;空串视为未设)。base_url 不是密钥,可回显。 + pub fn base_url_for(&self, provider: &str) -> Option { + self.providers + .get(provider) + .map(|p| p.base_url.clone()) + .filter(|u| !u.is_empty()) + } } /// 生产环境配置目录:`$HOME/.csswitch`。 @@ -255,6 +266,7 @@ mod tests { "deepseek".into(), ProviderCfg { key: "sk-abcdef1234".into(), + ..Default::default() }, ); save_to(&d, &cfg).unwrap(); @@ -263,6 +275,32 @@ mod tests { assert_eq!(got.key_for("deepseek").as_deref(), Some("sk-abcdef1234")); } + #[test] + fn relay_base_url_roundtrips_and_helper_reads_it() { + // 中转站 base_url 存/读 roundtrip;空串视为未设。 + let d = tmpdir().join(".csswitch"); + let mut cfg = Config { + provider: "relay".into(), + ..Default::default() + }; + cfg.providers.insert( + "relay".into(), + ProviderCfg { + key: "cr_secret".into(), + base_url: "https://byteswarm.ai/claude".into(), + }, + ); + save_to(&d, &cfg).unwrap(); + let got = load_from(&d).unwrap(); + assert_eq!( + got.base_url_for("relay").as_deref(), + Some("https://byteswarm.ai/claude") + ); + assert_eq!(got.key_for("relay").as_deref(), Some("cr_secret")); + // 未设 base_url 的 provider(缺字段的旧文件也走这条)→ None。 + assert_eq!(got.base_url_for("deepseek"), None); + } + #[test] fn save_sets_dir_0700_and_file_0600() { let d = tmpdir().join(".csswitch"); @@ -358,6 +396,7 @@ mod tests { "qwen".into(), ProviderCfg { key: "k-xyz".into(), + ..Default::default() }, ); }) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 02a8480..afa028e 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -49,17 +49,48 @@ fn key_fingerprint(s: &str) -> u64 { fn key_env(provider: &str) -> &'static str { match provider { "qwen" => "DASHSCOPE_API_KEY", + "relay" => "CSSWITCH_RELAY_KEY", _ => "DEEPSEEK_API_KEY", } } -fn upstream_host(provider: &str) -> &'static str { +/// 上游主机名(供 status 上游灯做 TCP 可达性探测)。relay 从其 base_url 解析。 +fn upstream_host(provider: &str, base_url: &str) -> String { match provider { - "qwen" => "dashscope.aliyuncs.com", - _ => "api.deepseek.com", + "qwen" => "dashscope.aliyuncs.com".to_string(), + "relay" => parse_host(base_url).unwrap_or_default(), + _ => "api.deepseek.com".to_string(), } } +/// 从 `http(s)://host[:port]/path` 里抽出 host。解析不出返回 None(不引 url crate)。 +fn parse_host(url: &str) -> Option { + let rest = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://"))?; + let host = rest + .split(['/', ':', '?', '#']) + .next() + .unwrap_or("") + .to_string(); + if host.is_empty() { + None + } else { + Some(host) + } +} + +/// 判断模型 id 是否会平铺进 Science 选择器主列表(claude-{opus|sonnet|haiku}-<数字…>)。 +/// 仅用于「获取模型」结果排序(主列表项排前),非鉴权路径。 +fn is_main_list_model(id: &str) -> bool { + for fam in ["claude-opus-", "claude-sonnet-", "claude-haiku-"] { + if let Some(rest) = id.strip_prefix(fam) { + return rest.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false); + } + } + false +} + // ---------- 路径与日志 ---------- /// 定位 CSSwitch 仓库根(含 proxy/csswitch_proxy.py)。优先 CSSWITCH_REPO, /// 否则从可执行文件与当前目录逐级上溯。找不到返回 None。 @@ -221,7 +252,13 @@ fn ensure_proxy( let key = cfg .key_for(&provider) .ok_or_else(|| format!("缺少 {provider} 的 API key,请先在面板填写并保存。"))?; - let key_fp = key_fingerprint(&key); + // relay(中转站):base_url 必填,作上游根地址。非 relay 为空串。 + let base_url = cfg.base_url_for(&provider).unwrap_or_default(); + if provider == "relay" && base_url.is_empty() { + return Err("中转站模式需要填 base_url(如 https://your-relay/claude),请先在面板填写并保存。".into()); + } + // 指纹并入 base_url:换 key 或换中转站地址都触发代理重启(避免复用旧上游)。 + let key_fp = key_fingerprint(&format!("{key}\n{base_url}")); let port = cfg.proxy_port; let root = asset_root(app) .ok_or("找不到代理脚本 proxy/csswitch_proxy.py(打包资源或仓库根均未命中)。开发态可设 CSSWITCH_REPO。")?; @@ -267,8 +304,8 @@ fn ensure_proxy( let logf = open_log("proxy.log").map_err(|e| format!("建日志失败:{e}"))?; let logf2 = logf.try_clone().map_err(|e| e.to_string())?; - let child = Command::new(&py) - .arg(&script) + let mut cmd = Command::new(&py); + cmd.arg(&script) .arg("--provider") .arg(&provider) .arg("--port") @@ -276,7 +313,12 @@ fn ensure_proxy( .arg("--auth-token") .arg(&secret) // key 经环境变量注入,绝不作为命令行参数(避免 ps 泄露)。 - .env(key_env(&provider), &key) + .env(key_env(&provider), &key); + // relay:中转站 base_url 经环境变量交给代理(非密钥,但与 key 一致走 env)。 + if provider == "relay" { + cmd.env("CSSWITCH_RELAY_BASE_URL", &base_url); + } + let child = cmd .stdout(Stdio::from(logf)) .stderr(Stdio::from(logf2)) .spawn() @@ -363,7 +405,7 @@ fn get_config() -> Result { let dir = config::default_dir(); let cfg = config::load_from(&dir).map_err(|e| e.to_string())?; let mut keys = serde_json::Map::new(); - for p in ["deepseek", "qwen"] { + for p in ["deepseek", "qwen", "relay"] { let masked = cfg.key_for(p).map(|k| config::mask(&k)).unwrap_or_default(); keys.insert(p.to_string(), serde_json::Value::String(masked)); } @@ -373,6 +415,8 @@ fn get_config() -> Result { "sandbox_port": cfg.sandbox_port, "mode": cfg.mode, "keys": keys, + // 中转站 base_url(非密钥,可回显),供面板回填。 + "relay_base_url": cfg.base_url_for("relay").unwrap_or_default(), })) } @@ -448,6 +492,9 @@ struct UiSettings { provider: String, proxy_port: u16, sandbox_port: u16, + /// 仅「中转站」(relay) 用:中转站 base_url。其它 provider 前端不传(None → 不改动已存值)。 + #[serde(default)] + base_url: Option, } #[tauri::command] @@ -457,9 +504,9 @@ fn set_config(cfg: UiSettings) -> Result<(), String> { return Err("端口 8765 是真实 Science 实例保留端口,不能用。".into()); } // 只认已实现的 provider,避免存进未知值后起代理时才失败(修 P2-3)。 - if cfg.provider != "deepseek" && cfg.provider != "qwen" { + if cfg.provider != "deepseek" && cfg.provider != "qwen" && cfg.provider != "relay" { return Err(format!( - "未知 provider:{}(只支持 deepseek / qwen)。", + "未知 provider:{}(只支持 deepseek / qwen / relay)。", cfg.provider )); } @@ -471,11 +518,25 @@ fn set_config(cfg: UiSettings) -> Result<(), String> { if cfg.proxy_port == cfg.sandbox_port { return Err("代理端口与沙箱端口不能相同。".into()); } + // base_url 只做「非空时校验格式」——不在这里强制必填,否则「先选中转站、再填地址」 + // 的流程会在切 provider 时就被拦下。必填校验放在真正要用它的 ensure_proxy / + // fetch_relay_models(那里给的提示也更贴合动作)。 + let base_url = cfg.base_url.clone().unwrap_or_default().trim().to_string(); + if !base_url.is_empty() + && !(base_url.starts_with("http://") || base_url.starts_with("https://")) + { + return Err("中转站 base_url 必须以 http:// 或 https:// 开头。".into()); + } let dir = config::default_dir(); config::update(&dir, move |c| { c.provider = cfg.provider; c.proxy_port = cfg.proxy_port; c.sandbox_port = cfg.sandbox_port; + // 只在前端传了 base_url(即用户在中转站模式)时写入 relay 的 base_url, + // 避免在别的 provider 下改端口时误清空已存的中转站地址。 + if !base_url.is_empty() { + c.providers.entry("relay".into()).or_default().base_url = base_url; + } }) .map(|_| ()) .map_err(|e| e.to_string()) @@ -526,6 +587,77 @@ fn verify_key( } } +#[derive(Deserialize)] +struct RelayModelsReq { + base_url: String, + key: String, +} + +/// 「获取中转站可用模型」:把面板填的 base_url / token 存进 relay 配置(token 为空=沿用已存)、 +/// 切到 relay,起 relay 代理,再经**本地回环代理**回源拉中转站 `/v1/models` +/// (回源的 TLS 由代理的 urllib 完成,Rust 侧只打回环明文,无需引 TLS)。 +/// 返回 `{models:[id,...]}`——会平铺进 Science 选择器主列表的 id 排前。 +#[tauri::command] +fn fetch_relay_models( + app: tauri::AppHandle, + state: State<'_, Mutex>, + req: RelayModelsReq, +) -> Result { + let dir = config::default_dir(); + let base_url = req.base_url.trim().to_string(); + let key = req.key.trim().to_string(); + // 落盘 relay 配置并切到 relay(供 ensure_proxy 起 relay 代理)。token 为空表示沿用已存的。 + let (bu, k) = (base_url.clone(), key.clone()); + config::update(&dir, move |c| { + c.provider = "relay".into(); + let e = c.providers.entry("relay".into()).or_default(); + if !bu.is_empty() { + e.base_url = bu; + } + if !k.is_empty() { + e.key = k; + } + }) + .map_err(|e| e.to_string())?; + // 用落盘后的最终值校验。 + let cfg = config::load_from(&dir).map_err(|e| e.to_string())?; + let bu = cfg.base_url_for("relay").unwrap_or_default(); + if bu.is_empty() || !(bu.starts_with("http://") || bu.starts_with("https://")) { + return Err("请先填写中转站 base_url(http:// 或 https:// 开头)。".into()); + } + if cfg.key_for("relay").is_none() { + return Err("请先填写中转站 API Key / Token。".into()); + } + // 起 relay 代理(内部已校验 key/base_url、探活),经回环代理回源拉 /v1/models。 + let (port, secret, _action) = ensure_proxy(&app, &state)?; + match proc::http_get_body(port, Some(&secret), "/v1/models", 20000) { + Some((200, body)) => { + let v: serde_json::Value = + serde_json::from_str(&body).map_err(|e| format!("解析模型列表失败:{e}"))?; + let mut ids: Vec = v + .get("data") + .and_then(|d| d.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|m| m.get("id").and_then(|i| i.as_str()).map(String::from)) + .collect() + }) + .unwrap_or_default(); + if ids.is_empty() { + return Err("中转站没有返回可用模型(/v1/models 为空)。".into()); + } + // 稳定排序:主列表项(会平铺进选择器)排前,其余保持上游顺序。 + ids.sort_by_key(|id| if is_main_list_model(id) { 0u8 } else { 1u8 }); + Ok(json!({ "models": ids })) + } + Some((code @ (401 | 403), _)) => { + Err(format!("中转站拒绝({code}),key 或权限可能有误。")) + } + Some((code, _)) => Err(format!("中转站返回 {code},无法获取模型列表。")), + None => Err("获取模型无响应(多为网络或中转站不通)。".into()), + } +} + #[tauri::command] fn stop_all(app: tauri::AppHandle, state: State<'_, Mutex>) -> Result<(), String> { let mut st = lock(&state); @@ -733,7 +865,7 @@ fn sandbox_running_ours(port: u16) -> bool { #[tauri::command] fn status(state: State<'_, Mutex>) -> serde_json::Value { // 只在锁内取值,锁外做阻塞探活。 - let (pport, secret, sport, provider) = { + let (pport, secret, sport, provider, base_url) = { let st = lock(&state); let cfg = config::load_from(&config::default_dir()).unwrap_or_default(); let pport = if st.proxy_port != 0 { @@ -746,7 +878,8 @@ fn status(state: State<'_, Mutex>) -> serde_json::Value { } else { cfg.sandbox_port }; - (pport, st.secret.clone(), sport, cfg.provider) + let base_url = cfg.base_url_for(&cfg.provider).unwrap_or_default(); + (pport, st.secret.clone(), sport, cfg.provider, base_url) }; let proxy = if !secret.is_empty() && proc::http_health(pport, Some(&secret), 300) { "green" @@ -760,7 +893,8 @@ fn status(state: State<'_, Mutex>) -> serde_json::Value { } else { "amber" }; - let upstream = if proc::tcp_reachable(upstream_host(&provider), 443, 500) { + let uhost = upstream_host(&provider, &base_url); + let upstream = if !uhost.is_empty() && proc::tcp_reachable(&uhost, 443, 500) { "green" } else { "amber" @@ -855,6 +989,7 @@ pub fn run() { save_provider_key, start_proxy, verify_key, + fetch_relay_models, stop_all, one_click_login, status, @@ -894,7 +1029,38 @@ pub fn run() { #[cfg(test)] mod tests { - use super::{key_fingerprint, redact, sandbox_home}; + use super::{is_main_list_model, key_fingerprint, parse_host, redact, sandbox_home}; + + #[test] + fn parse_host_extracts_host_from_relay_base_url() { + assert_eq!( + parse_host("https://byteswarm.ai/claude").as_deref(), + Some("byteswarm.ai") + ); + assert_eq!( + parse_host("http://127.0.0.1:8080/v1").as_deref(), + Some("127.0.0.1") + ); + assert_eq!( + parse_host("https://relay.example.com:8443").as_deref(), + Some("relay.example.com") + ); + // 无 scheme / 空 → None(status 上游灯据此显黄,不误探)。 + assert_eq!(parse_host("byteswarm.ai/claude"), None); + assert_eq!(parse_host(""), None); + } + + #[test] + fn main_list_model_matches_family_plus_digit() { + // 会平铺进 Science 选择器主列表的。 + assert!(is_main_list_model("claude-opus-4-8")); + assert!(is_main_list_model("claude-sonnet-5")); + assert!(is_main_list_model("claude-haiku-4-5-20251001")); + // 不会平铺(折叠进 More models):老式命名 / family 后非数字。 + assert!(!is_main_list_model("claude-3-5-sonnet-20241022")); + assert!(!is_main_list_model("claude-fable-5")); + assert!(!is_main_list_model("gpt-4o")); + } #[test] fn redact_scrubs_secret_and_is_noop_when_empty() { diff --git a/desktop/src-tauri/src/proc.rs b/desktop/src-tauri/src/proc.rs index 607c879..8838ec4 100644 --- a/desktop/src-tauri/src/proc.rs +++ b/desktop/src-tauri/src/proc.rs @@ -100,6 +100,54 @@ pub fn http_post_status( .and_then(|s| s.parse::().ok()) } +/// 向本地回环代理 GET 一段路径(`GET /`),返回 (状态码, 响应体)。 +/// 连不上 / 无响应返回 None。用于经代理回源拉中转站 `/v1/models`——代理有 TLS(urllib), +/// 这里只打回环明文,无需在 Rust 侧引 TLS。timeout_ms 要给足(代理要转发上游),建议 ~15000。 +pub fn http_get_body( + port: u16, + secret: Option<&str>, + path_suffix: &str, + timeout_ms: u64, +) -> Option<(u16, String)> { + let addr = ("127.0.0.1", port).to_socket_addrs().ok()?.next()?; + let dur = Duration::from_millis(timeout_ms); + let mut stream = TcpStream::connect_timeout(&addr, dur).ok()?; + let _ = stream.set_read_timeout(Some(dur)); + let _ = stream.set_write_timeout(Some(dur)); + let path = match secret { + Some(s) if !s.is_empty() => format!("/{s}{path_suffix}"), + _ => path_suffix.to_string(), + }; + let req = + format!("GET {path} HTTP/1.0\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"); + stream.write_all(req.as_bytes()).ok()?; + // 读完整响应(状态行 + 头 + 体)。上限防呆:模型列表通常 < 数十 KB,给 1 MiB。 + let mut buf = Vec::new(); + let mut chunk = [0u8; 4096]; + loop { + if buf.len() > 1_048_576 { + break; + } + match stream.read(&mut chunk) { + Ok(0) => break, + Ok(n) => buf.extend_from_slice(&chunk[..n]), + Err(_) => break, + } + } + let text = String::from_utf8_lossy(&buf); + let status = text + .lines() + .next() + .and_then(|l| l.split_whitespace().nth(1)) + .and_then(|s| s.parse::().ok())?; + // 分割头与体:首个空行之后即响应体。 + let body = match text.split_once("\r\n\r\n") { + Some((_, b)) => b.to_string(), + None => String::new(), + }; + Some((status, body)) +} + /// 上游主机可达性(仅 TCP 连通,不校验 key)。绿灯=可达,黄灯=不可达。 pub fn tcp_reachable(host: &str, port: u16, timeout_ms: u64) -> bool { let dur = Duration::from_millis(timeout_ms); @@ -275,6 +323,12 @@ mod tests { assert!(!http_health(59999, None, 300)); } + #[test] + fn get_body_none_when_nothing_listening() { + // 没人监听 → 连不上 → None(与 http_health 一致的失败关闭语义)。 + assert!(http_get_body(59998, Some("secret"), "/v1/models", 300).is_none()); + } + #[test] fn which_finds_sh() { let sh = which("sh"); diff --git a/desktop/src/index.html b/desktop/src/index.html index bfbba1f..3f9752d 100644 --- a/desktop/src/index.html +++ b/desktop/src/index.html @@ -30,6 +30,7 @@ @@ -42,6 +43,16 @@
存本地 ~/.csswitch/config.json(0600);面板只回显末 4 位。
+
+ +
+ + +
+
任意 Anthropic 兼容中转站的根地址(自动补 /v1/messages/v1/models)。填好上面的 Key 与此地址后点「获取模型」。
+ +
+
高级设置
diff --git a/desktop/src/main.js b/desktop/src/main.js index e8a14bc..2828aba 100644 --- a/desktop/src/main.js +++ b/desktop/src/main.js @@ -11,7 +11,7 @@ const invoke = PREVIEW function mockInvoke(cmd, args) { switch (cmd) { case "get_config": - return Promise.resolve({ provider: "deepseek", proxy_port: 18991, sandbox_port: 8990, mode: "proxy", keys: { deepseek: "", qwen: "" } }); + return Promise.resolve({ provider: "deepseek", proxy_port: 18991, sandbox_port: 8990, mode: "proxy", keys: { deepseek: "", qwen: "", relay: "" }, relay_base_url: "" }); case "set_mode": case "open_official": return Promise.resolve(null); @@ -23,6 +23,8 @@ function mockInvoke(cmd, args) { return Promise.resolve({ port: 18991 }); case "verify_key": return Promise.resolve({ ok: true, hint: "(预览模式:假装 key 有效)" }); + case "fetch_relay_models": + return Promise.resolve({ models: ["claude-opus-4-8", "claude-sonnet-5", "claude-haiku-4-5-20251001", "claude-3-5-sonnet-20241022"] }); case "one_click_login": return Promise.resolve({ url: "http://127.0.0.1:8990" }); case "run_doctor": @@ -44,7 +46,7 @@ let statusTimer = null; let busy = false; let mode = "proxy"; // "proxy" 第三方 | "official" 官方 -const KEY_LABELS = { deepseek: "DeepSeek API Key", qwen: "DashScope (通义千问) API Key" }; +const KEY_LABELS = { deepseek: "DeepSeek API Key", qwen: "DashScope (通义千问) API Key", relay: "中转站 API Key / Token" }; function setMsg(text, kind) { els.msg.textContent = text; @@ -59,7 +61,9 @@ function setLight(el, state) { function setBusy(on) { busy = on; - [els.oneClickBtn, els.stopBtn, els.saveKeyBtn].forEach((b) => (b.disabled = on)); + [els.oneClickBtn, els.stopBtn, els.saveKeyBtn, els.fetchModelsBtn].forEach( + (b) => b && (b.disabled = on) + ); } async function call(cmd, args) { @@ -72,9 +76,10 @@ async function loadConfig() { els.provider.value = cfg.provider || "deepseek"; els.proxyPort.value = cfg.proxy_port ?? 18991; els.sandboxPort.value = cfg.sandbox_port ?? 8990; + els.relayBase.value = cfg.relay_base_url || ""; window._keys = cfg.keys || {}; - reflectProvider(); applyMode(cfg.mode === "official" ? "official" : "proxy"); + reflectProvider(); } catch (e) { setMsg("读取配置失败:" + e, "err"); } @@ -89,6 +94,14 @@ function applyMode(m) { ); els.oneClickBtn.textContent = mode === "official" ? "打开官方 Claude Science ↗" : "⚡ 一键开始"; + updateRelayUI(); +} + +// 中转站区块可见性:仅「第三方模式 且 provider=relay」时显示。用 JS 计算而非纯 CSS, +// 保证它绝不与官方模式并存(也就不必和 mode-official 抢 CSS 优先级)。 +function updateRelayUI() { + const show = mode !== "official" && els.provider.value === "relay"; + els.panel.classList.toggle("show-relay", show); } // 点分段切换:先落盘(切官方时后端会顺带停第三方链路),成功再翻 UI;失败保持旧模式、如实报错。 @@ -141,15 +154,25 @@ function reflectProvider() { els.keyLabel.textContent = KEY_LABELS[p] || "API Key"; const masked = (window._keys && window._keys[p]) || ""; els.keyInput.value = ""; - els.keyInput.placeholder = masked ? "已存:" + masked : "粘贴第三方 key(只存本地)"; + els.keyInput.placeholder = masked + ? "已存:" + masked + : p === "relay" + ? "粘贴中转站 key / token(只存本地)" + : "粘贴第三方 key(只存本地)"; + updateRelayUI(); } function currentSettings() { - return { + const s = { provider: els.provider.value, proxy_port: parseInt(els.proxyPort.value, 10) || 18991, sandbox_port: parseInt(els.sandboxPort.value, 10) || 8990, }; + // 只在中转站模式带 base_url(后端据「有无该字段」决定是否改动已存值)。 + if (els.provider.value === "relay") { + s.base_url = els.relayBase.value.trim(); + } + return s; } // 保存设置:失败会【抛出】,让调用方(起代理 / 一键登录)中止, @@ -200,6 +223,65 @@ async function saveKey() { } } +// 与后端 is_main_list_model 对齐:会平铺进 Science 选择器主列表的 id。 +function isMainListModel(id) { + for (const fam of ["claude-opus-", "claude-sonnet-", "claude-haiku-"]) { + if (id.startsWith(fam)) { + const c = id.charAt(fam.length); + return c >= "0" && c <= "9"; + } + } + return false; +} + +function renderModels(models) { + const box = els.modelList; + box.innerHTML = ""; + if (!models || !models.length) { + box.hidden = true; + return; + } + for (const id of models) { + const d = document.createElement("div"); + d.className = "m" + (isMainListModel(id) ? " main" : ""); + d.textContent = id; + box.appendChild(d); + } + box.hidden = false; +} + +// 「获取模型」:把 base_url(+可能的新 key)交后端,起 relay 代理并回源拉中转站可用模型。 +async function fetchModels() { + const base = els.relayBase.value.trim(); + if (!base) { + setMsg("请先填写中转站地址 base_url。", "err"); + return; + } + setBusy(true); + setMsg("获取模型中:起代理 → 回源拉 /v1/models…"); + try { + const key = els.keyInput.value.trim(); // 有新 key 就带上;为空则后端沿用已存 + const r = await call("fetch_relay_models", { req: { base_url: base, key } }); + const models = (r && r.models) || []; + renderModels(models); + if (key) { + // 后端已把新 key 落盘;刷新掩码占位、清空输入框。 + window._keys.relay = "•".repeat(Math.max(0, key.length - 4)) + key.slice(-4); + els.keyInput.value = ""; + reflectProvider(); + } + setMsg( + "已获取 " + models.length + " 个模型(加粗的会平铺进选择器,其余在「More models」)。点「一键开始」即可在 Science 里选用。", + "ok" + ); + await refreshStatus(); + } catch (e) { + setMsg("获取模型失败:" + e, "err"); + } finally { + setBusy(false); + } +} + async function stopAll() { setBusy(true); setMsg("停止中…"); @@ -315,6 +397,7 @@ function wire() { "oneClickBtn", "stopBtn", "ltProxy", "ltSandbox", "ltUpstream", "msg", "brandDot", "openBrowserBtn", "doctorBtn", "updateBtn", "verLabel", "reportBtn", "logsBtn", "quitBtn", "modeSeg", + "relayBase", "fetchModelsBtn", "modelList", ].forEach((id) => (els[id] = $(id))); els.panel = document.querySelector(".panel"); @@ -328,6 +411,8 @@ function wire() { }); els.proxyPort.addEventListener("change", persistSettingsSafe); els.sandboxPort.addEventListener("change", persistSettingsSafe); + els.relayBase.addEventListener("change", persistSettingsSafe); + els.fetchModelsBtn.addEventListener("click", fetchModels); els.saveKeyBtn.addEventListener("click", saveKey); els.stopBtn.addEventListener("click", stopAll); els.oneClickBtn.addEventListener("click", heroClick); diff --git a/desktop/src/styles.css b/desktop/src/styles.css index 87c16f9..4143d5c 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -25,6 +25,15 @@ body{ .official-only{display:none} .panel.mode-official .tp-only{display:none} .panel.mode-official .official-only{display:block} +/* 中转站(relay)专属区块:仅在「第三方模式 且 provider=relay」时显示(由 JS 加 show-relay 类, + 已保证不与官方模式并存,故不必与 mode-official 抢 CSS 优先级) */ +.relay-only{display:none} +.panel.show-relay .relay-only{display:block} +.modellist{margin-top:8px;max-height:120px;overflow:auto;border:1px solid var(--line); + border-radius:9px;background:var(--field);padding:6px 9px;font-size:11.5px;line-height:1.75; + -webkit-user-select:text;user-select:text} +.modellist .m{color:var(--sub);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.modellist .m.main{color:var(--ink);font-weight:600} .brand{display:flex;align-items:center;gap:8px;font-weight:650;font-size:14px} .dot{width:8px;height:8px;border-radius:50%;background:var(--green);box-shadow:0 0 0 3px rgba(47,191,113,.16)} .dot.amber{background:var(--amber);box-shadow:0 0 0 3px rgba(230,162,60,.16)} diff --git a/proxy/csswitch_proxy.py b/proxy/csswitch_proxy.py index 60780dd..c63e06f 100644 --- a/proxy/csswitch_proxy.py +++ b/proxy/csswitch_proxy.py @@ -6,6 +6,9 @@ 代理只做「透传 + 改模型名 + 换鉴权头 + max_tokens 夹取 + 连接重试」, thinking/tool_use 全部原生保真(不翻译协议)。 qwen :DashScope compatible-mode —— Anthropic↔OpenAI 双向翻译(流式以 SSE 回放保真 tool_use)。 + relay :任意「中转站」(Anthropic 兼容端点,base_url + token)。原生透传、【不重映射模型】, + /v1/models 回源直拉让 Science 选择器自动铺满中转站真实模型。base_url 经 + CSSWITCH_RELAY_BASE_URL 提供、token 经 CSSWITCH_RELAY_KEY 提供。 安全约束: - 入站 Authorization / x-api-key(Science 带来的 OAuth Bearer)一律剥离,不记录、不转发。 @@ -86,6 +89,23 @@ "default_cap": 8192, "default_model": "qwen-plus", }, + "relay": { + # 「中转站」:任意 Anthropic 兼容端点(base_url + token)。原生透传、【不重映射模型】 + # ——中转站原生认 claude-* 名。上游 url / models_url 在 __main__ 里按 CSSWITCH_RELAY_BASE_URL + # 装配(base + /v1/messages、base + /v1/models)。 + "mode": "anthropic", # 复用原生透传 handler(流式/非流式/重试都现成) + "url": None, # __main__ 装配 + "models_url": None, # __main__ 装配;存在即 /v1/models 回源直拉 + "key_env": "CSSWITCH_RELAY_KEY", + "passthrough": True, # resolve_model 原样透传模型名(不映射) + "auth_style": "both", # 同时带 x-api-key + Authorization: Bearer(最大兼容各家中转站) + "models": [], # 回源拉取,静态为空 + "model_map": {}, + "model_caps": {}, + "default_cap": None, # 不夹 max_tokens:尊重中转站真实(claude 原生)上限 + # 空名兜底:Science 硬编码的默认推理模型 id(中转站基本都提供)。 + "default_model": "claude-opus-4-8", + }, } PROV = None # 当前 provider 配置(dict),运行时设定 @@ -94,6 +114,13 @@ PROV_NAME = None # 运行时设定;模块被 import 做测试时也要有定义,避免 handler NameError AUTH_SECRET = None # 未设则不启用鉴权(保持旧行为) _DATE_SUFFIX = re.compile(r"-\d{8}$") +# relay 模式:最近一次 /v1/models 回源拉到的上游模型 id 列表。resolve_model 用它把 +# Science 发来的裸 id(如标题 agent 的 claude-haiku-4-5)贴合到中转站真实 id +# (如 claude-haiku-4-5-20251001)。首拉前为空 → 纯透传。 +RELAY_MODELS = [] +# 出站 User-Agent:部分中转站的 WAF 把默认的 "Python-urllib/x.y" 判为 bot 直接 403 +# (byteswarm 实测),故所有上游请求统一带一个非 bot 的 UA。 +UPSTREAM_UA = "CSSwitch/0.2 (+https://github.com/SuperJJ007/CSswitch)" # ---------- #3: targeted fast-fail(沙箱「Switching organization」卡死修复) ---------- # 沙箱 Science 启动时会对 claude.ai/api/oauth/profile 发【阻塞式】请求解析组织; @@ -139,11 +166,25 @@ def load_key(prov, args): return None +def _snap_relay_model(name): + """relay 透传:把请求模型贴合到中转站真实 id。精确命中优先;否则找一个以 + 请求名为前缀的上游 id(裸 claude-haiku-4-5 → claude-haiku-4-5-20251001); + 都不中就原样返回(中转站自行处理别名 / 报错)。""" + if not RELAY_MODELS or name in RELAY_MODELS: + return name + for mid in RELAY_MODELS: + if mid.startswith(name + "-") or mid == name: + return mid + return name + + def resolve_model(name): """把 Science 传来的模型名解析成当前 provider 的目标模型。 优先:选择器里选中的 provider 原生名 > 显式映射 > 去日期后缀 > 前缀匹配 > 默认。""" if not name: return PROV["default_model"] + if PROV.get("passthrough"): # relay:中转站原生认 claude-*,透传(仅贴合到真实 id) + return _snap_relay_model(name) mm = PROV["model_map"] if name in mm: # 先查映射(覆盖伪 claude- 前缀的选择器 id 和 Science 硬编码 claude-*) return mm[name] @@ -172,6 +213,7 @@ def clamp_max_tokens(v, model=None): def http_post(url, data, headers, attempts=4, timeout=300): """POST 上游;重试覆盖【连接 + 完整读体】(含 SSL EOF、握手超时、对端断开、IncompleteRead), 对服务端明确响应(HTTPError,如 400)不重试。返回 (body_bytes, content_type)。""" + headers = {"User-Agent": UPSTREAM_UA, **headers} for i in range(attempts): req = urllib.request.Request(url, data=data, headers=headers) try: @@ -190,6 +232,7 @@ def http_post(url, data, headers, attempts=4, timeout=300): def open_stream(url, data, headers, attempts=4, timeout=300): """打开上游流式连接并预读首块(把「200 但立刻空体」这种抖动也纳入重试)。 返回 (resp, first_chunk, content_type);首字节到手后不再重试。""" + headers = {"User-Agent": UPSTREAM_UA, **headers} for i in range(attempts): req = urllib.request.Request(url, data=data, headers=headers) try: @@ -209,6 +252,61 @@ def open_stream(url, data, headers, attempts=4, timeout=300): raise +def http_get_json(url, headers, attempts=3, timeout=30): + """GET 上游并解析 JSON(relay 回源拉 /v1/models 用)。连接抖动重试,服务端明确响应不重试。""" + headers = {"User-Agent": UPSTREAM_UA, **headers} + for i in range(attempts): + req = urllib.request.Request(url, headers=headers, method="GET") + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + return json.loads(r.read()) + except urllib.error.HTTPError: + raise + except Exception as e: + if i < attempts - 1: + log(f" ~ 上游连接抖动,重试 {i + 1}/{attempts - 1}: {e}") + time.sleep(0.6 * (i + 1)) + continue + raise + + +def _upstream_auth_headers(): + """上游鉴权头:按当前 provider 的 auth_style 装 x-api-key / bearer / both。 + deepseek 未设 → 默认 x-api-key(保持原状);relay = both。""" + style = PROV.get("auth_style", "x-api-key") + h = {} + if style in ("x-api-key", "both"): + h["x-api-key"] = KEY + if style in ("bearer", "both"): + h["Authorization"] = f"Bearer {KEY}" + return h + + +def fetch_relay_models(): + """回源拉中转站 /v1/models,归一化成 Science 认的 Anthropic 模型列表,并刷新 + RELAY_MODELS 缓存(供 resolve_model 贴合)。返回归一化后的 list(可空)。""" + global RELAY_MODELS + murl = PROV.get("models_url") + if not murl: + return [] + headers = dict(_upstream_auth_headers()) + headers["anthropic-version"] = "2023-06-01" + raw = http_get_json(murl, headers) + data = raw.get("data") if isinstance(raw, dict) else raw + out, ids = [], [] + for m in data or []: + mid = m.get("id") if isinstance(m, dict) else None + if not mid: + continue + ids.append(mid) + out.append({"type": "model", "id": mid, + "display_name": (m.get("display_name") if isinstance(m, dict) else None) or mid, + "created_at": "2026-01-01T00:00:00Z"}) + if ids: + RELAY_MODELS = ids + return out + + # ---------- Anthropic -> OpenAI 翻译(qwen 路径) ---------- def anthropic_to_openai(req): msgs = [] @@ -363,11 +461,21 @@ def do_GET(self): if not self._auth_ok(): return if self.path.startswith("/v1/models"): - data = [{"type": "model", "id": mid, "display_name": disp, - "created_at": "2026-01-01T00:00:00Z"} for mid, disp in PROV["models"]] - log(f"GET /v1/models -> {PROV_NAME}: {', '.join(m[0] for m in PROV['models'])}") + # relay:回源直拉中转站真实模型(拉不到退回静态 PROV["models"],relay 为空则空列表)。 + data = None + if PROV.get("models_url"): + try: + data = fetch_relay_models() + log(f"GET /v1/models -> {PROV_NAME}(回源): {len(data)} 个模型") + except Exception as e: + log(f"GET /v1/models -> {PROV_NAME} 回源失败,退回静态: {e}") + if data is None: + data = [{"type": "model", "id": mid, "display_name": disp, + "created_at": "2026-01-01T00:00:00Z"} for mid, disp in PROV["models"]] + log(f"GET /v1/models -> {PROV_NAME}: {', '.join(m[0] for m in PROV['models'])}") self._send_json(200, {"data": data, "has_more": False, - "first_id": data[0]["id"], "last_id": data[-1]["id"]}) + "first_id": data[0]["id"] if data else None, + "last_id": data[-1]["id"] if data else None}) elif self.path.startswith("/health"): self._send_json(200, {"status": "ok", "provider": PROV_NAME}) else: @@ -517,7 +625,10 @@ def _handle_anthropic(self, areq): n_tools = len(body.get("tools") or []) log(f"POST /v1/messages {src}->{target} stream={stream} tools={n_tools} " f"msgs={len(body.get('messages') or [])} (入站鉴权已剥离, 直连 {PROV_NAME})") - headers = {"x-api-key": KEY, "content-type": "application/json", "anthropic-version": "2023-06-01"} + # 鉴权头按 provider 的 auth_style:deepseek 默认 x-api-key;relay 用 both(同时带 + # x-api-key + Authorization: Bearer,兼容各家中转站)。KEY 只驻内存、不入日志。 + headers = {"content-type": "application/json", "anthropic-version": "2023-06-01"} + headers.update(_upstream_auth_headers()) data = json.dumps(body).encode() headers_sent = False try: @@ -644,12 +755,24 @@ def _replay_as_sse(self, aresp): ap.add_argument("--env-file", default=None) ap.add_argument("--log", default=None) ap.add_argument("--auth-token", default=None) + ap.add_argument("--relay-base", default=None, + help="relay provider 的中转站 base_url(也可用环境变量 CSSWITCH_RELAY_BASE_URL)") args = ap.parse_args() PROV_NAME = args.provider PROV = PROVIDERS[PROV_NAME] LOG = args.log KEY = load_key(PROV, args) AUTH_SECRET = os.environ.get("CSSWITCH_AUTH_TOKEN") or args.auth_token + # relay:按中转站 base_url 装配上游端点(base + /v1/messages、base + /v1/models)。 + if PROV_NAME == "relay": + base = (os.environ.get("CSSWITCH_RELAY_BASE_URL") or args.relay_base or "").strip().rstrip("/") + if not base or not re.match(r"^https?://", base): + print("relay 需要中转站 base_url(http(s)://…)。用 --relay-base 或环境变量 " + "CSSWITCH_RELAY_BASE_URL 提供。", file=sys.stderr) + sys.exit(1) + PROV = dict(PROV) + PROV["url"] = base + "/v1/messages" + PROV["models_url"] = base + "/v1/models" _up = os.environ.get("CSSWITCH_UPSTREAM_URL") if _up: PROV = dict(PROV) diff --git a/test/test_proxy_units.py b/test/test_proxy_units.py index 45a408d..449a763 100644 --- a/test/test_proxy_units.py +++ b/test/test_proxy_units.py @@ -66,5 +66,69 @@ def test_qwen_per_model(self): self.assertEqual(cs.clamp_max_tokens(100000, "qwen-max"), 8192) +class RelayProvider(unittest.TestCase): + """中转站 provider:透传(不重映射)+ 贴合 + 双鉴权头 + 不夹 max_tokens + /v1/models 归一化。""" + + def setUp(self): + cs.PROV = dict(cs.PROVIDERS["relay"]) + cs.PROV["url"] = "https://relay.test/claude/v1/messages" + cs.PROV["models_url"] = "https://relay.test/claude/v1/models" + cs.KEY = "cr_testkey" + cs.RELAY_MODELS = [] + + def test_passthrough_keeps_model_name(self): + # 中转站原生认 claude-*,模型名原样透传(不走 model_map)。 + self.assertEqual(cs.resolve_model("claude-opus-4-8"), "claude-opus-4-8") + self.assertEqual(cs.resolve_model("claude-sonnet-4-6"), "claude-sonnet-4-6") + self.assertEqual(cs.resolve_model("some-other-model"), "some-other-model") + + def test_empty_model_falls_back_to_default(self): + self.assertEqual(cs.resolve_model(""), "claude-opus-4-8") + + def test_snaps_bare_id_to_upstream_dated_id(self): + cs.RELAY_MODELS = ["claude-haiku-4-5-20251001", "claude-opus-4-8"] + # 裸 id(如标题 agent 的)贴合到中转站带日期的真实 id。 + self.assertEqual(cs.resolve_model("claude-haiku-4-5"), "claude-haiku-4-5-20251001") + # 精确命中不改。 + self.assertEqual(cs.resolve_model("claude-opus-4-8"), "claude-opus-4-8") + # 缓存里没有的原样透传(交中转站处理别名/报错)。 + self.assertEqual(cs.resolve_model("claude-sonnet-5"), "claude-sonnet-5") + + def test_auth_headers_both(self): + h = cs._upstream_auth_headers() + self.assertEqual(h.get("x-api-key"), "cr_testkey") + self.assertEqual(h.get("Authorization"), "Bearer cr_testkey") + + def test_deepseek_auth_headers_default_xapikey(self): + # 回归:deepseek 未设 auth_style → 仍只带 x-api-key(不误引入 Bearer)。 + cs.PROV = cs.PROVIDERS["deepseek"] + cs.KEY = "sk-ds" + self.assertEqual(cs._upstream_auth_headers(), {"x-api-key": "sk-ds"}) + + def test_no_max_tokens_clamp(self): + # relay default_cap=None、model_caps 空 → 尊重中转站真实上限,不夹取。 + self.assertEqual(cs.clamp_max_tokens(1000000, "claude-opus-4-8"), 1000000) + + def test_models_normalized_and_cache_refreshed(self): + # 用假 http_get_json 复刻中转站 OpenAI 风格返回,验证归一化成 Anthropic 格式 + # + RELAY_MODELS 缓存刷新(离线,不触网)。 + orig = cs.http_get_json + cs.http_get_json = lambda url, headers: { + "data": [ + {"id": "claude-opus-4-8", "object": "model"}, + {"id": "claude-3-5-sonnet-20241022", "object": "model"}, + {"no_id": True}, # 缺 id 的条目应被跳过 + ] + } + try: + out = cs.fetch_relay_models() + finally: + cs.http_get_json = orig + self.assertEqual([m["id"] for m in out], ["claude-opus-4-8", "claude-3-5-sonnet-20241022"]) + self.assertTrue(all(m["type"] == "model" for m in out)) + self.assertEqual(out[0]["display_name"], "claude-opus-4-8") + self.assertEqual(cs.RELAY_MODELS, ["claude-opus-4-8", "claude-3-5-sonnet-20241022"]) + + if __name__ == "__main__": unittest.main()