diff --git a/package.json b/package.json index 4490166f..fc57eb2e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "thinkwatch-lite", "private": true, "description": "Desktop app for a local AI API gateway on macOS, Windows and Linux", - "version": "2026.10.4", + "version": "2026.10.5", "type": "module", "packageManager": "pnpm@11.13.0", "scripts": { diff --git a/release-notes/2026.10.5.md b/release-notes/2026.10.5.md new file mode 100644 index 00000000..80156305 --- /dev/null +++ b/release-notes/2026.10.5.md @@ -0,0 +1,26 @@ +**Upgrade notes:** +- The bundled core is now 0.62.0, which speaks control-plane protocol 38. A remote server has to run core 0.62.0 too: this version does not connect to 0.61.0, and 2026.10.4 and earlier do not connect to 0.62.0. +- Request history is kept. +- A key's model scope now also applies when no upstream reports a model list, on WebSocket connections such as Codex's, and to every backup in failover. A client that used a model outside its key's scope through one of these gaps is now refused. + +**Model aliases:** +- Upstreams has a new Aliases tab. An alias is another name for the same model: list the names the model has on each upstream, and every upstream that offers one of them can serve it under its own name, as backups for one another. +- When the same Claude model has different names across upstreams (official, Bedrock, Vertex, OpenRouter), the tab and the new-alias dialog suggest grouping them, and a dot on the tab shows there is a suggestion. +- An alias can also be created from an upstream's model list. The dialog shows which upstream receives which name, and warns when the name takes over another upstream's model or when a connected client treats the name specially. +- Clients see aliases in their model lists, and answers carry the name the client asked for. +- In a key's model scope, allowing an upstream model also allows the aliases that list it. + +**Routing:** +- A rule can forward to specified models: an upstream and one of its models, with backups tried in order. The model is sent as written. +- A model condition written for an upstream model also matches the aliases that list it. +- Traffic marks requests sent under an alias or to a specified model, and shows the model each attempt sent. + +**Claude Desktop:** +- Takeover follows the model-name rule of the current Claude Desktop, including 1M spellings. +- When no upstream offers a Claude model, takeover asks which upstream model Claude Desktop should use and adds a routing rule for its own key. Cancelling the takeover removes the rule. + +**Also:** +- Tool-call inspection has a new built-in rule, "Read or change ThinkWatch's own data", for tool calls whose path or command points into ThinkWatch's data directory, where every upstream key is kept. A document that only mentions the directory does not count. +- Model lists sent to clients carry each model's context window, which Claude Desktop, Hermes and others use to size conversations. +- Upstream addresses that end in their own API version, such as Zhipu's `…/api/paas/v4` and Ark's `…/api/v3`, work without a second `/v1`. +- System notifications name every built-in tool-call rule; two used to show their internal id. diff --git a/scripts/shots/core/en/dryrun.json b/scripts/shots/core/en/dryrun.json index 99e5ed5b..16d58952 100644 --- a/scripts/shots/core/en/dryrun.json +++ b/scripts/shots/core/en/dryrun.json @@ -1,5 +1,15 @@ [ { + "candidate_models": [ + { + "provider": "relay", + "sent_model": "claude-sonnet-5" + }, + { + "provider": "anthropic", + "sent_model": "claude-sonnet-5" + } + ], "candidates": [ "relay", "anthropic" diff --git a/scripts/shots/core/en/security.json b/scripts/shots/core/en/security.json index c4db3202..0680efb5 100644 --- a/scripts/shots/core/en/security.json +++ b/scripts/shots/core/en/security.json @@ -493,6 +493,21 @@ "on_by_default": true, "why": "Sends a credential to a host that is neither local nor the credential's own provider" }, + { + "action": "cut", + "custom": false, + "default_action": "cut", + "enabled": true, + "id": "thinkwatch-data", + "kind": "command", + "matcher": { + "check": "thinkwatch-data", + "kind": "builtin" + }, + "name": "Read or change ThinkWatch's own data", + "on_by_default": true, + "why": "Reads or changes ThinkWatch's data directory, which holds every upstream key in plain text and the settings of these protections" + }, { "action": "cut", "custom": false, diff --git a/scripts/shots/core/en/status.json b/scripts/shots/core/en/status.json index 233a6d66..6d6548f8 100644 --- a/scripts/shots/core/en/status.json +++ b/scripts/shots/core/en/status.json @@ -1,5 +1,5 @@ { - "api_version": 37, + "api_version": 38, "clients": 4, "config_path": "", "gateway_addr": null, diff --git a/scripts/shots/core/zh/dryrun.json b/scripts/shots/core/zh/dryrun.json index dd2c1c71..fd9f8417 100644 --- a/scripts/shots/core/zh/dryrun.json +++ b/scripts/shots/core/zh/dryrun.json @@ -1,5 +1,15 @@ [ { + "candidate_models": [ + { + "provider": "relay", + "sent_model": "claude-sonnet-5" + }, + { + "provider": "anthropic", + "sent_model": "claude-sonnet-5" + } + ], "candidates": [ "relay", "anthropic" diff --git a/scripts/shots/core/zh/security.json b/scripts/shots/core/zh/security.json index 04962fcd..4fa0dceb 100644 --- a/scripts/shots/core/zh/security.json +++ b/scripts/shots/core/zh/security.json @@ -493,6 +493,21 @@ "on_by_default": true, "why": "Sends a credential to a host that is neither local nor the credential's own provider" }, + { + "action": "cut", + "custom": false, + "default_action": "cut", + "enabled": true, + "id": "thinkwatch-data", + "kind": "command", + "matcher": { + "check": "thinkwatch-data", + "kind": "builtin" + }, + "name": "Read or change ThinkWatch's own data", + "on_by_default": true, + "why": "Reads or changes ThinkWatch's data directory, which holds every upstream key in plain text and the settings of these protections" + }, { "action": "cut", "custom": false, diff --git a/scripts/shots/core/zh/status.json b/scripts/shots/core/zh/status.json index 233a6d66..6d6548f8 100644 --- a/scripts/shots/core/zh/status.json +++ b/scripts/shots/core/zh/status.json @@ -1,5 +1,5 @@ { - "api_version": 37, + "api_version": 38, "clients": 4, "config_path": "", "gateway_addr": null, diff --git a/scripts/shots/mock/config.ts b/scripts/shots/mock/config.ts index 98619c1a..3f9468fe 100644 --- a/scripts/shots/mock/config.ts +++ b/scripts/shots/mock/config.ts @@ -193,6 +193,7 @@ export function providerModels(name: string): ProviderModelsView | null { price, price_source: price ? priceSource(name) : null, estimated: false, + aliases: [], }; }); return { @@ -209,7 +210,10 @@ export function providerModels(name: string): ProviderModelsView | null { export function knownModels() { const by = new Map(); for (const p of providers()) for (const m of catalogOf(p)) by.set(m, [...(by.get(m) ?? []), p.name]); - return [...by.entries()].map(([id, ps]) => ({ id, providers: ps })).sort((a, b) => a.id.localeCompare(b.id)); + // 示例配置里没有别名 + return [...by.entries()] + .map(([id, ps]) => ({ id, providers: ps, aliases: [] })) + .sort((a, b) => a.id.localeCompare(b.id)); } // ───────────────────────────────────────── 概览、密钥、安全、价格 diff --git a/scripts/shots/mock/core.ts b/scripts/shots/mock/core.ts index 3c119808..31105ea0 100644 --- a/scripts/shots/mock/core.ts +++ b/scripts/shots/mock/core.ts @@ -137,6 +137,13 @@ export const CORE: { [N in WebviewEndpoint]: Handler } = { UpdateGroup: refuse, DeleteGroup: refuse, KnownModels: () => knownModels(), + // 示例配置里没有别名 + Aliases: () => ({ aliases: [], suggestions: [] }), + CreateAlias: refuse, + UpdateAlias: refuse, + DeleteAlias: refuse, + PreviewAlias: () => notFound("The alias preview"), + AliasUsage: (_req, [name]) => notFound(`The alias ${name}`), // 没给时间窗就是今天:本地零点到现在(`Window` 的默认) // 没给时间窗就是最近 7 天(`UpstreamHealth` 的默认) UpstreamHealth: (req) => upstreamHealth(req.from_ms ?? NOW - 7 * DAY, req.to_ms ?? Date.now()), diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6b53bc71..dffa0c2c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -82,7 +82,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.52.0", "wl-clipboard-rs", "x11rb", ] @@ -985,7 +985,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.3", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2566,7 +2566,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.21", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2661,7 +2661,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4095,7 +4095,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4740,7 +4740,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.52.0", @@ -4766,7 +4766,7 @@ dependencies = [ [[package]] name = "thinkwatch-lite" -version = "2026.10.4" +version = "2026.10.5" dependencies = [ "anyhow", "block2", @@ -5208,7 +5208,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.21", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5268,8 +5268,8 @@ dependencies = [ [[package]] name = "tw-api" -version = "0.61.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.61.0#57f0ac21afdced765c8cf4f3d841196e285507c8" +version = "0.62.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.62.0#51ef803bee644e72668cb8725d7bc27f6bb77a3f" dependencies = [ "serde", "serde_json", @@ -5280,8 +5280,8 @@ dependencies = [ [[package]] name = "tw-dialect" -version = "0.61.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.61.0#57f0ac21afdced765c8cf4f3d841196e285507c8" +version = "0.62.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.62.0#51ef803bee644e72668cb8725d7bc27f6bb77a3f" dependencies = [ "serde", "serde_json", @@ -5289,8 +5289,8 @@ dependencies = [ [[package]] name = "tw-guard" -version = "0.61.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.61.0#57f0ac21afdced765c8cf4f3d841196e285507c8" +version = "0.62.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.62.0#51ef803bee644e72668cb8725d7bc27f6bb77a3f" dependencies = [ "base64 0.22.1", "bytes", @@ -5305,8 +5305,8 @@ dependencies = [ [[package]] name = "tw-link" -version = "0.61.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.61.0#57f0ac21afdced765c8cf4f3d841196e285507c8" +version = "0.62.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.62.0#51ef803bee644e72668cb8725d7bc27f6bb77a3f" dependencies = [ "serde", "serde_json", @@ -5332,8 +5332,8 @@ dependencies = [ [[package]] name = "tw-types" -version = "0.61.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.61.0#57f0ac21afdced765c8cf4f3d841196e285507c8" +version = "0.62.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.62.0#51ef803bee644e72668cb8725d7bc27f6bb77a3f" dependencies = [ "serde", "ts-rs", @@ -5341,8 +5341,8 @@ dependencies = [ [[package]] name = "tw-watch" -version = "0.61.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.61.0#57f0ac21afdced765c8cf4f3d841196e285507c8" +version = "0.62.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.62.0#51ef803bee644e72668cb8725d7bc27f6bb77a3f" dependencies = [ "notify", "thiserror 2.0.21", @@ -5351,8 +5351,8 @@ dependencies = [ [[package]] name = "tw-yaml" -version = "0.61.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.61.0#57f0ac21afdced765c8cf4f3d841196e285507c8" +version = "0.62.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.62.0#51ef803bee644e72668cb8725d7bc27f6bb77a3f" dependencies = [ "saphyr-parser", "thiserror 2.0.21", @@ -5379,7 +5379,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5837,7 +5837,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "windows-sys 0.60.2", + "windows-sys 0.61.2", "windows-version", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 581a0eef..05be5b2e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "thinkwatch-lite" -version = "2026.10.4" +version = "2026.10.5" edition = "2024" # **这个数决定依赖能升到哪一版。**edition 2024 的解析器只挑声明的 Rust # 版本编得动的依赖:写 1.85 的时候,`cargo update` 一直停在旧的 time 和 @@ -26,12 +26,12 @@ license = "MIT" # twcore 二进制必须和这里编译进去的协议镜像来自同一个 core 版本 —— 打包脚本正是 # 从 tw-api 锁到的 tag 去取二进制的(见 scripts/fetch-core.sh)。 [workspace.dependencies] -tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.61.0" } -tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.61.0" } -tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.61.0" } -tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.61.0" } -tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.61.0" } -tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.61.0" } +tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.62.0" } +tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.62.0" } +tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.62.0" } +tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.62.0" } +tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.62.0" } +tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.62.0" } [lib] name = "thinkwatch_lite_lib" diff --git a/src-tauri/crates/tw-adopt/src/cloud.rs b/src-tauri/crates/tw-adopt/src/cloud.rs index 00db6abc..5ac3adb7 100644 --- a/src-tauri/crates/tw-adopt/src/cloud.rs +++ b/src-tauri/crates/tw-adopt/src/cloud.rs @@ -304,8 +304,9 @@ pub fn unpinned_models(home: &Path, settings: &str, around: &Around) -> Vec<&'st out } -/// Claude Code 的模型别名(model-config 一页的表)。带 `[1m]` 的也是 -fn is_alias(m: &str) -> bool { +/// Claude Code 的模型别名(model-config 一页的表)。带 `[1m]` 的也是。它请求之前先换成完整的 +/// 模型名 —— 网关的模型别名起成这些名称,Claude Code 的请求用不到(Lite 的 `aliases` 据此提示) +pub fn is_alias(m: &str) -> bool { let m = m.trim().to_ascii_lowercase(); let m = m.strip_suffix("[1m]").unwrap_or(&m); matches!( diff --git a/src-tauri/crates/tw-adopt/src/desktop.rs b/src-tauri/crates/tw-adopt/src/desktop.rs index 55293191..48be6c5e 100644 --- a/src-tauri/crates/tw-adopt/src/desktop.rs +++ b/src-tauri/crates/tw-adopt/src/desktop.rs @@ -242,9 +242,36 @@ pub fn looks_like_claude(model: &str) -> bool { } /// 网关列出的模型它一个都不收时写进去的那一个(一条都不剩,它就没有模型可选)。 -/// 路由里要有一条规则把它改写到上游真正在用的模型 —— 接管说明里给出那条规则。 +/// +/// 它发来的请求由它密钥上的一条「指定模型」规则接住,发给接管时选的那个上游模型 +/// (见 [`ModelPick`])—— 它发什么 Claude 名称都接得住,别的客户端用这个名称不受影响。 pub const FALLBACK_MODEL: &str = "claude-sonnet-5"; +/// 网关列出的模型 Claude Desktop 一个都不收:写进它配置的是 [`FALLBACK_MODEL`], +/// 要选一个上游模型给它用。 +/// +/// **这里只说要选、从哪些里选**:规则加在网关上(它的密钥、它所用的路由),那是 +/// 应用那一侧的事,这个 crate 只管文件。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelPick { + /// 写进 `inferenceModels` 的那一个 + pub written: String, + /// 网关为这把密钥列出的模型,按列出的顺序:选的那个从这里来 + pub listed: Vec, +} + +/// 网关为这把密钥列出的模型里,Claude Desktop 一个都不收时,要选一个给它用。 +/// 有它收的就不用选(`None`)。一个都没列出时也要选,只是没得选 +pub fn model_pick(listed: &[String]) -> Option { + if listed.iter().any(|m| looks_like_claude(m)) { + return None; + } + Some(ModelPick { + written: FALLBACK_MODEL.to_string(), + listed: listed.to_vec(), + }) +} + // ---------------------------------------------------------------- 接管 /// 算一份接管改动。**不写任何东西。** @@ -281,20 +308,19 @@ pub fn plan_adopt_in( let profile = profile_path(home); let mut edits = crate::clients::edits(c, gw); let mut notes: Vec = plan::cost_notes(c).collect(); + let mut pick = None; match models { Some(all) => { // 它不收的名字它自己会去掉,但每一条都报一个配置错误:不写进去。带 `[1m]` 的 // 照写 —— 网关同时列出 `X` 和 `X[1m]` 时,它在选择器里合成一个带 1M 版本的模型 - let claude: Vec = all - .iter() - .filter(|m| looks_like_claude(m)) - .cloned() - .collect(); - let names = if claude.is_empty() { - notes.push(no_claude_model(all.iter().find(|m| !looks_like_claude(m)))); - vec![FALLBACK_MODEL.to_string()] - } else { - claude + pick = model_pick(all); + let names = match &pick { + Some(p) => vec![p.written.clone()], + None => all + .iter() + .filter(|m| looks_like_claude(m)) + .cloned() + .collect(), }; edits.push(Edit { path: vec!["inferenceModels".into()], @@ -331,6 +357,7 @@ pub fn plan_adopt_in( let mut main = plan::adopt_file(c.id, profile.clone(), Format::Json, &edits)?; main.notes = notes; main.bedrock = bedrock; + main.pick_model = pick; main.also = vec![ adopt_meta(c.id, home)?, plan::adopt_file( @@ -444,21 +471,6 @@ fn deployment_mode() -> Edit { } } -/// 网关列出的模型它一个都不收:要在路由里加一条规则,把 [`FALLBACK_MODEL`] 改写到 -/// 上游真正在用的那个模型。`upstream` 是网关列出的第一个模型,拿来填示例。 -fn no_claude_model(upstream: Option<&String>) -> Msg { - let target = upstream.map_or("", |s| s.as_str()); - let rule = format!( - "{{ name: Claude Desktop, when: {{ model: {FALLBACK_MODEL} }}, set: {{ model: {target} }} }}" - ); - msg!( - "adopt.plan.claude_desktop.no_claude_model", - model = FALLBACK_MODEL, - rule = rule - => "The gateway lists no model whose name looks like Claude, and Claude Desktop accepts no other names. {model} is written as its model; a route needs a rule that rewrites {model} to the model actually in use upstream, for example: {rule}" - ) -} - /// 我们那一份在 `entries` 里长什么样 fn entry() -> Val { Val::Obj(vec![ @@ -623,6 +635,7 @@ fn restore_meta(client: &str, home: &Path) -> Result, PlanError> { also: Vec::new(), prior: None, bedrock: None, + pick_model: None, })); }; @@ -695,6 +708,7 @@ fn restore_meta(client: &str, home: &Path) -> Result, PlanError> { also: Vec::new(), prior: None, bedrock: None, + pick_model: None, })) } @@ -836,6 +850,29 @@ mod tests { assert!(looks_like_claude(FALLBACK_MODEL)); } + fn names(xs: &[&str]) -> Vec { + xs.iter().map(|s| s.to_string()).collect() + } + + /// 网关列出的有一个它收的,就不用选;一个都不收(或者一个都没列出)才要选, + /// 候选是列出的全部,照列出的顺序 + #[test] + fn a_model_is_picked_only_when_the_gateway_lists_nothing_it_accepts() { + assert_eq!( + model_pick(&names(&["glm-4.6", "claude-sonnet-5", "gpt-5"])), + None + ); + assert_eq!(model_pick(&names(&["opus-proxy"])), None); + let p = model_pick(&names(&["glm-4.6", "claude-deepseek-v3", "kimi-k2"])).unwrap(); + assert_eq!(p.written, FALLBACK_MODEL); + assert_eq!( + p.listed, + names(&["glm-4.6", "claude-deepseek-v3", "kimi-k2"]) + ); + let none = model_pick(&[]).unwrap(); + assert!(none.listed.is_empty()); + } + #[cfg(not(windows))] #[test] fn managed_configuration_is_looked_for_where_the_docs_put_it() { diff --git a/src-tauri/crates/tw-adopt/src/plan.rs b/src-tauri/crates/tw-adopt/src/plan.rs index fb89bfe0..a47cbf03 100644 --- a/src-tauri/crates/tw-adopt/src/plan.rs +++ b/src-tauri/crates/tw-adopt/src/plan.rs @@ -170,6 +170,9 @@ pub struct Plan { /// 客户端原来直连 Bedrock:按它原来的设置新建 Bedrock 上游要填的([`crate::cloud`])。 /// 界面据此给一个「新建上游」的入口 pub bedrock: Option, + /// 接管 Claude Desktop 而网关列出的模型它一个都不收:要选一个上游模型给它用 + /// ([`crate::desktop::model_pick`])。别的客户端、还原时没有 + pub pick_model: Option, } impl Plan { @@ -650,6 +653,7 @@ pub(crate) fn adopt_file( also: Vec::new(), prior: prior_rec.map(|r| (r.backup, r.created_file)), bedrock: None, + pick_model: None, }) } @@ -1038,6 +1042,7 @@ pub(crate) fn restore_file_plan( also: Vec::new(), prior: None, bedrock: None, + pick_model: None, }); }; @@ -1186,6 +1191,7 @@ pub(crate) fn restore_file_plan( also: Vec::new(), prior: None, bedrock: None, + pick_model: None, }) } diff --git a/src-tauri/crates/tw-adopt/tests/claude_desktop.rs b/src-tauri/crates/tw-adopt/tests/claude_desktop.rs index 590ed3c4..10bde840 100644 --- a/src-tauri/crates/tw-adopt/tests/claude_desktop.rs +++ b/src-tauri/crates/tw-adopt/tests/claude_desktop.rs @@ -390,34 +390,72 @@ fn a_configuration_switched_in_the_app_is_left_as_the_user_chose() { assert!(!read(&meta).contains(PROFILE_ID)); } +/// 网关列出的它一个都不收:写进去的是兜底的那一个,计划里说要选一个上游模型给它用, +/// 候选是网关列出的那些。**说明里不再教人手写规则** —— 规则在接管时加在它的密钥上 #[test] -fn without_a_claude_model_the_fallback_is_written_and_the_rule_is_spelled_out() { +fn without_a_claude_model_the_fallback_is_written_and_a_model_is_asked_for() { let b = bed(); let c = client(); - let p = desktop::plan_adopt_in( - &c, - &b.home, - &gw(), - Some(&names(&["deepseek-chat", "claude-deepseek-v3", "gpt-5"])), - None, - &Around::default(), - ) - .unwrap(); - let note = p - .notes - .iter() - .find(|n| n.code == "adopt.plan.claude_desktop.no_claude_model") - .expect("没有说要加一条规则"); + let listed = names(&["deepseek-chat", "claude-deepseek-v3", "gpt-5"]); + let p = desktop::plan_adopt_in(&c, &b.home, &gw(), Some(&listed), None, &Around::default()) + .unwrap(); + assert_eq!( + p.pick_model, + Some(desktop::ModelPick { + written: desktop::FALLBACK_MODEL.into(), + listed, + }) + ); assert!( - note.text.contains("set: { model: deepseek-chat }"), - "{note}" + p.notes.iter().all(|n| !n.code.contains("no_claude_model")), + "{:?}", + p.notes ); - assert!(note.text.contains(desktop::FALLBACK_MODEL), "{note}"); + assert!(p.also.iter().all(|a| a.pick_model.is_none())); apply(&c, &p, &b.backups).unwrap(); assert_eq!( json(&desktop::profile_path(&b.home))["inferenceModels"], serde_json::json!([desktop::FALLBACK_MODEL]) ); + // 选不选、选了哪个,文件都是这样:再算一次是空操作 + let again = desktop::plan_adopt_in( + &c, + &b.home, + &gw(), + Some(&names(&["deepseek-chat"])), + None, + &Around::default(), + ) + .unwrap(); + assert!(again.is_noop()); + assert!(again.pick_model.is_some()); +} + +/// 有它收的模型就不用选;不问模型(`None`)时也不问 +#[test] +fn with_a_claude_model_or_without_a_listing_nothing_is_asked_for() { + let b = bed(); + let c = client(); + let with = desktop::plan_adopt_in( + &c, + &b.home, + &gw(), + Some(&names(&["glm-4.6", "claude-sonnet-5"])), + None, + &Around::default(), + ) + .unwrap(); + assert_eq!(with.pick_model, None); + let unasked = + desktop::plan_adopt_in(&c, &b.home, &gw(), None, None, &Around::default()).unwrap(); + assert_eq!(unasked.pick_model, None); + // 一个都没列出:要选,但没得选 + let empty = + desktop::plan_adopt_in(&c, &b.home, &gw(), Some(&[]), None, &Around::default()).unwrap(); + assert_eq!(empty.pick_model.map(|p| p.listed), Some(Vec::new())); + // 还原的计划里没有 + adopt(&b, Some(&names(&["glm-4.6"]))); + assert_eq!(plan_restore(&c, &b.home).unwrap().pick_model, None); } #[test] diff --git a/src-tauri/msg-codes.txt b/src-tauri/msg-codes.txt index a9febbda..e6e17bb2 100644 --- a/src-tauri/msg-codes.txt +++ b/src-tauri/msg-codes.txt @@ -3,6 +3,9 @@ # A second column marks codes a UI does not translate: # passthrough the sentence is only placeholders (system or upstream text); show `text` # test only produced by tests; never reaches a UI +adopt.claude_desktop.rule_left +adopt.claude_desktop.rule_not_reverted +adopt.claude_desktop.rule_unchecked adopt.cost.aider.lookup_order adopt.cost.aider.restart adopt.cost.claude_code.mcp_tool_search @@ -157,7 +160,7 @@ adopt.plan.bedrock_unseen adopt.plan.bedrock_unusable adopt.plan.claude_desktop.aws_dir adopt.plan.claude_desktop.bedrock_key -adopt.plan.claude_desktop.no_claude_model +adopt.plan.claude_desktop.rule_name_taken adopt.plan.claude_desktop.was_cloud adopt.plan.cloud_above adopt.plan.cloud_managed @@ -167,6 +170,7 @@ adopt.plan.cloud_off.settings adopt.plan.cloud_off.shell adopt.plan.fields_only adopt.plan.foreign_record +adopt.plan.gateway_changed adopt.plan.hermes_agent.active_profile adopt.plan.hermes_agent.other_profiles adopt.plan.managed diff --git a/src-tauri/src/aliases.rs b/src-tauri/src/aliases.rs new file mode 100644 index 00000000..912ca4f7 --- /dev/null +++ b/src-tauri/src/aliases.rs @@ -0,0 +1,519 @@ +//! 新建、编辑模型别名时,名称下面那几条和客户端有关的提示(`alias_hints`)。 +//! +//! 别名怎么解析、挡不挡保存,是 core 的事(`/aliases/preview`)。这几条说的是**这台机器上的 +//! 客户端**会怎么对待这个名称:只有这一侧知道装了哪些、接管着哪些,所以在这里判断。 +//! +//! **只说检测到的客户端。**没装的客户端会怎样,用户不关心;Claude Desktop、按名称挑请求格式 +//! 的那几个只在这一份接管着它们的时候说 —— 只有接管着,它们才用得上网关的模型。 +//! +//! 给界面的是码和参数([`AliasHint`]),句子在界面那边(`src/aliases/AliasDialog.i18n.ts`)。 + +use std::path::Path; + +use tw_adopt::clients::Client; +use tw_adopt::{cloud, desktop, detect}; + +use crate::clients::{blocking, home_dir, ops}; +use crate::error::Out; +use crate::wire::AliasHint; + +/// 名称写成什么样会出提示。`models` 是对话框里此刻列出的上游模型。 +#[tauri::command] +pub async fn alias_hints(name: String, models: Vec) -> Out> { + blocking(move || { + let seen = Seen::scan(&home_dir(), &tw_adopt::foreign::backup_root()); + hints(&name, &models, &seen) + }) + .await +} + +/// 这台机器上和这几条提示有关的客户端。 +#[derive(Debug, Default, Clone)] +pub struct Seen { + /// 检测到的(装着,或者配置文件在) + pub installed: Vec, + /// 这一份接管着的 + pub adopted: Vec, +} + +impl Seen { + /// 扫一遍本机。**只读**,读的是几个文件在不在、接管的旁文件,几毫秒 + pub fn scan(home: &Path, backups: &Path) -> Seen { + let mut seen = Seen::default(); + for c in ops::all(home) { + let d = detect::detect_one(&c, home); + if ops::ours(&d, backups) { + seen.adopted.push(c.clone()); + } + if d.installed { + seen.installed.push(c); + } + } + seen + } + + fn has(&self, id: &str) -> bool { + self.installed.iter().any(|c| c.id == id) + } +} + +/// 这几条提示,按界面上从上往下的顺序。 +pub fn hints(name: &str, models: &[String], seen: &Seen) -> Vec { + let name = name.trim(); + let mut out = Vec::new(); + if !name.is_empty() { + if seen.has("claude-code") && cloud::is_alias(name) { + out.push(AliasHint::ClaudeCodeReserved { name: name.into() }); + } + if let Some((family, model)) = mismatch(name, models) { + let clients: Vec = seen + .adopted + .iter() + .filter(|c| format_differs(c.id, name, model)) + .map(|c| c.name.to_string()) + .collect(); + if !clients.is_empty() { + out.push(AliasHint::FamilyMismatch { + family: family.into(), + model: model.into(), + clients, + }); + } + } + if seen.adopted.iter().any(|c| c.id == desktop::ID) { + out.push(if desktop::looks_like_claude(name) { + AliasHint::ClaudeDesktopShown { name: name.into() } + } else { + AliasHint::ClaudeDesktopHidden { name: name.into() } + }); + } + } + let listing: Vec = seen + .adopted + .iter() + .filter(|c| c.writes_models) + .map(|c| c.name.to_string()) + .collect(); + if !listing.is_empty() { + out.push(AliasHint::ModelListsUpdate { clients: listing }); + } + out +} + +/// 认得出的几家模型,按名字里的片段认:模型名按非字母数字切成几段,**某一段以这个片段开头** +/// 就算。路径前缀(`anthropic/`、`moonshotai/`)、Bedrock 的 `us.anthropic.` 也是几段,一起认。 +const FAMILIES: &[(&str, &[&str])] = &[ + ( + "Claude", + &[ + "claude", + "anthropic", + "sonnet", + "opus", + "haiku", + "fable", + "mythos", + ], + ), + ("GPT", &["gpt", "chatgpt", "codex", "openai"]), + ("Gemini", &["gemini"]), + ("DeepSeek", &["deepseek"]), + ("Qwen", &["qwen", "qwq"]), + ("GLM", &["glm", "chatglm"]), + ("Kimi", &["kimi", "moonshot"]), + ("MiniMax", &["minimax"]), + ("Grok", &["grok"]), + ("Llama", &["llama"]), + ( + "Mistral", + &[ + "mistral", + "mixtral", + "devstral", + "codestral", + "magistral", + "ministral", + ], + ), +]; + +/// 这个模型名是哪一家的。**认出不止一家(`claude-codex`)或一家都认不出时是 `None`**: +/// 拿不准就不提示,免得说错。OpenAI 的 `o3`、`o4-mini` 这类只看第一段 +pub fn family(model: &str) -> Option<&'static str> { + let lower = model.trim().to_ascii_lowercase(); + let parts: Vec<&str> = lower + .split(|c: char| !c.is_ascii_alphanumeric()) + .filter(|p| !p.is_empty()) + .collect(); + let o_series = parts.first().is_some_and(|p| { + p.len() >= 2 && p.starts_with('o') && p[1..].bytes().all(|b| b.is_ascii_digit()) + }); + let mut found: Vec<&'static str> = FAMILIES + .iter() + .filter(|(_, frags)| parts.iter().any(|p| frags.iter().any(|f| p.starts_with(f)))) + .map(|(f, _)| *f) + .collect(); + if o_series && !found.contains(&"GPT") { + found.push("GPT"); + } + match found.as_slice() { + [one] => Some(one), + _ => None, + } +} + +/// 名称像一家的模型、列出的上游模型却没有一个是这一家的,而至少有一个认得出是别家: +/// 返回名称的那一家和第一个别家的模型。认不出的模型名不算别家 +fn mismatch<'a>(name: &str, models: &'a [String]) -> Option<(&'static str, &'a str)> { + let want = family(name)?; + let mut other = None; + for m in models { + match family(m) { + Some(f) if f == want => return None, + Some(_) if other.is_none() => other = Some(m.as_str()), + _ => {} + } + } + other.map(|m| (want, m)) +} + +/// 这个客户端发这个名称的请求和发 `model` 用的格式不同。 +/// +/// 网关的模型清单不说一个模型背后是哪种上游,接管时写进客户端配置的请求格式**只看模型名**: +/// Pi、oh-my-pi 按 [`tw_adopt::pi::api_for`],Grok Build 按 [`tw_adopt::grok::backend_for`]。 +/// 别名的名称像别家的模型,它就照那一家的格式发出、由网关转换。Hermes Agent 也按名字挑 +/// ([`tw_adopt::hermes::api_mode_for`]),但只给接管时选定的默认模型挑,不算;别的客户端 +/// 整个 provider 一种格式,和名称无关 +fn format_differs(client: &str, name: &str, model: &str) -> bool { + use tw_adopt::{grok, pi}; + match client { + "pi" | "omp" => pi::api_for(name) != pi::api_for(model), + "grok-build" => grok::backend_for(name) != grok::backend_for(model), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn client(id: &str) -> Client { + tw_adopt::clients::adoptable() + .into_iter() + .find(|c| c.id == id) + .unwrap_or_else(|| panic!("{id}")) + } + + fn seen(installed: &[&str], adopted: &[&str]) -> Seen { + Seen { + installed: installed.iter().map(|id| client(id)).collect(), + adopted: adopted.iter().map(|id| client(id)).collect(), + } + } + + fn models(ms: &[&str]) -> Vec { + ms.iter().map(|m| m.to_string()).collect() + } + + #[test] + fn families_are_recognised_by_name_parts() { + for (m, f) in [ + ("claude-sonnet-5", "Claude"), + ("anthropic/claude-opus-5", "Claude"), + ("us.anthropic.claude-sonnet-5-v1:0", "Claude"), + ("claude-opus-5@20260101", "Claude"), + ("sonnet", "Claude"), + ("opusplan", "Claude"), + ("gpt-5", "GPT"), + ("openai/gpt-oss-120b", "GPT"), + ("chatgpt-4o-latest", "GPT"), + ("gpt-5-codex", "GPT"), + ("o3", "GPT"), + ("o4-mini", "GPT"), + ("gemini-2.5-pro", "Gemini"), + ("google/gemini-2.5-flash", "Gemini"), + ("DeepSeek-v4.1-flash", "DeepSeek"), + ("deepseek/deepseek-chat", "DeepSeek"), + ("qwen3-coder-plus", "Qwen"), + ("qwen/qwen3-coder", "Qwen"), + ("glm-4.6", "GLM"), + ("z-ai/glm-4.6", "GLM"), + ("kimi-k2", "Kimi"), + ("moonshotai/kimi-k2", "Kimi"), + ("MiniMax-M2", "MiniMax"), + ("grok-4", "Grok"), + ("meta-llama/llama-4-maverick", "Llama"), + ("devstral-medium", "Mistral"), + ] { + assert_eq!(family(m), Some(f), "{m}"); + } + // 认不出,或者认出不止一家:不说 + for m in [ + "", + "my-coder", + "flash", + "claude-codex", + "anthropic/kimi-k2", + "claude-deepseek-v3", + // `o` 系列只看第一段,`o` 后面要有数字 + "turbo-o3", + "omni", + ] { + assert_eq!(family(m), None, "{m}"); + } + } + + /// 接管着的、按名称挑请求格式的客户端:Pi、oh-my-pi、Grok Build + fn by_name() -> Seen { + let ids = ["claude-code", "codex", "pi", "omp", "grok-build"]; + seen(&ids, &ids) + } + + /// 和名称有关的那几条:接管着的这几个都写模型清单,「更新清单」那条另有测试 + fn about_name(name: &str, models: &[String], seen: &Seen) -> Vec { + hints(name, models, seen) + .into_iter() + .filter(|h| !matches!(h, AliasHint::ModelListsUpdate { .. })) + .collect() + } + + #[test] + fn a_claude_name_for_another_model_names_the_clients_that_pick_formats_by_name() { + assert_eq!( + about_name("claude-sonnet-5", &models(&["glm-4.6"]), &by_name()), + vec![AliasHint::FamilyMismatch { + family: "Claude".into(), + model: "glm-4.6".into(), + clients: vec!["Pi".into(), "oh-my-pi".into(), "Grok Build".into()], + }] + ); + // 只是装着、没接管:它们的配置里没有网关的模型,不说 + let installed = ["claude-code", "pi", "omp", "grok-build"]; + assert!( + about_name( + "claude-sonnet-5", + &models(&["glm-4.6"]), + &seen(&installed, &[]) + ) + .is_empty() + ); + // 接管着的都不按名称挑格式:不说 + assert!( + about_name( + "claude-sonnet-5", + &models(&["glm-4.6"]), + &seen(&["claude-code", "codex"], &["claude-code", "codex"]) + ) + .is_empty() + ); + // GPT 的名字给 DeepSeek:三个都把它当 Responses 发 + assert_eq!( + about_name("gpt-5", &models(&["deepseek-chat"]), &by_name()), + vec![AliasHint::FamilyMismatch { + family: "GPT".into(), + model: "deepseek-chat".into(), + clients: vec!["Pi".into(), "oh-my-pi".into(), "Grok Build".into()], + }] + ); + // Grok 的名字给 GPT:Grok Build 两个都发 Responses,只有 Pi 那两个格式不同 + assert_eq!( + about_name("grok-4", &models(&["gpt-5"]), &by_name()), + vec![AliasHint::FamilyMismatch { + family: "Grok".into(), + model: "gpt-5".into(), + clients: vec!["Pi".into(), "oh-my-pi".into()], + }] + ); + } + + #[test] + fn the_same_family_the_same_format_or_an_unknown_model_is_not_a_mismatch() { + let all = by_name(); + // 几家上游的同一个 Claude + assert!( + about_name( + "claude-sonnet-5", + &models(&[ + "claude-sonnet-5", + "us.anthropic.claude-sonnet-5-v1:0", + "anthropic/claude-sonnet-5" + ]), + &all + ) + .is_empty() + ); + // 列出的里有一个是这一家的就不说,哪怕还列了别家的 + assert!( + about_name( + "claude-sonnet-5", + &models(&["glm-4.6", "claude-sonnet-5"]), + &all + ) + .is_empty() + ); + // 名字认不出、模型认不出:不说 + assert!(about_name("my-coder", &models(&["glm-4.6"]), &all).is_empty()); + assert!(about_name("claude-sonnet-5", &models(&["local-model"]), &all).is_empty()); + assert!(about_name("deepseek-v4.1", &models(&["DeepSeek-v4.1-flash"]), &all).is_empty()); + // 两家都走 Chat Completions:格式一样,不说 + assert!(about_name("qwen-max", &models(&["glm-4.6"]), &all).is_empty()); + // 还没列模型 + assert!(about_name("claude-sonnet-5", &[], &all).is_empty()); + // 第一个认得出的别家模型 + assert_eq!( + about_name( + "gemini-2.5-pro", + &models(&["local-model", "glm-4.6", "kimi-k2"]), + &all + ), + vec![AliasHint::FamilyMismatch { + family: "Gemini".into(), + model: "glm-4.6".into(), + clients: vec!["Pi".into(), "oh-my-pi".into()], + }] + ); + } + + #[test] + fn claude_codes_own_tier_names_are_flagged_when_it_is_installed() { + for name in [ + "sonnet", + "Opus", + "opusplan", + "haiku", + "default", + "best", + "sonnet[1m]", + ] { + assert_eq!( + hints( + name, + &models(&["claude-sonnet-5"]), + &seen(&["claude-code"], &[]) + ), + vec![AliasHint::ClaudeCodeReserved { name: name.into() }], + "{name}" + ); + } + // 没装 Claude Code:不说 + assert!( + hints( + "sonnet", + &models(&["claude-sonnet-5"]), + &seen(&["codex"], &[]) + ) + .is_empty() + ); + // 带版本的完整名字不是它的档位名 + assert!( + hints( + "claude-sonnet-5", + &models(&["claude-sonnet-5"]), + &seen(&["claude-code"], &[]) + ) + .is_empty() + ); + // 档位名指向别家的模型:Pi 和 Grok Build 不把 `opus` 当 Claude 发,只说档位名 + assert_eq!( + about_name("opus", &models(&["glm-4.6"]), &by_name()), + vec![AliasHint::ClaudeCodeReserved { + name: "opus".into() + }] + ); + } + + #[test] + fn claude_desktop_is_told_only_when_this_copy_has_taken_it_over() { + let desktop = desktop::ID; + // 接管着:说它显示不显示 + assert_eq!( + hints( + "glm-4.6-fast", + &models(&["glm-4.6"]), + &seen(&[desktop], &[desktop]) + ), + vec![AliasHint::ClaudeDesktopHidden { + name: "glm-4.6-fast".into() + }] + ); + assert_eq!( + hints( + "my-sonnet", + &models(&["glm-4.6"]), + &seen(&[desktop], &[desktop]) + ), + vec![AliasHint::ClaudeDesktopShown { + name: "my-sonnet".into() + }] + ); + // 只是装着:它不从网关取模型列表,不说 + assert!( + hints( + "glm-4.6-fast", + &models(&["glm-4.6"]), + &seen(&[desktop], &[]) + ) + .is_empty() + ); + } + + #[test] + fn adopted_clients_that_write_the_model_list_are_named() { + let s = seen( + &["opencode", "pi", "claude-code"], + &["opencode", "pi", "claude-code"], + ); + assert_eq!( + hints("deepseek-v4.1", &models(&["DeepSeek-v4.1-flash"]), &s), + vec![AliasHint::ModelListsUpdate { + clients: vec!["opencode".into(), "Pi".into()], + }] + ); + // 名称还空着也说:它和名称无关 + assert_eq!( + hints("", &[], &s), + vec![AliasHint::ModelListsUpdate { + clients: vec!["opencode".into(), "Pi".into()], + }] + ); + // 只是装着、没接管:不说 + assert!(hints("deepseek-v4.1", &[], &seen(&["opencode"], &[])).is_empty()); + } + + #[test] + fn hints_are_tagged_by_code_for_the_interface() { + let v = serde_json::to_value(AliasHint::FamilyMismatch { + family: "Claude".into(), + model: "glm-4.6".into(), + clients: vec!["Pi".into()], + }) + .unwrap(); + assert_eq!( + v, + serde_json::json!({ + "code": "family_mismatch", + "family": "Claude", + "model": "glm-4.6", + "clients": ["Pi"], + }) + ); + } + + #[test] + fn scanning_finds_claude_code_by_its_directory() { + let home = tempfile::tempdir().unwrap(); + let backups = home.path().join("backups"); + let has = |s: &Seen| s.installed.iter().any(|c| c.id == "claude-code"); + assert!(!has(&Seen::scan(home.path(), &backups))); + std::fs::create_dir(home.path().join(".claude")).unwrap(); + let s = Seen::scan(home.path(), &backups); + assert!(has(&s)); + // 装着、没接管 + assert!(!s.adopted.iter().any(|c| c.id == "claude-code")); + assert!( + hints("opusplan", &[], &s).contains(&AliasHint::ClaudeCodeReserved { + name: "opusplan".into() + }) + ); + } +} diff --git a/src-tauri/src/call.rs b/src-tauri/src/call.rs index c1692722..afa29d7f 100644 --- a/src-tauri/src/call.rs +++ b/src-tauri/src/call.rs @@ -112,6 +112,13 @@ webview_endpoints![ UpdateGroup, DeleteGroup, KnownModels, + // 模型别名:同一个模型在各个上游的不同名称 + Aliases, + CreateAlias, + UpdateAlias, + DeleteAlias, + PreviewAlias, + AliasUsage, // 各条路由、各条规则命中了多少:路由图按它给线加权、标出从没命中过的规则 RouteStats, // 上游体检:各家上游的事实和样本数,界面按它写成自己的话 @@ -206,7 +213,7 @@ mod tests { let ts = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../src/control.ts")) .unwrap(); for (name, path) in [ - ("CreatePluginConfirmed", "/plugins/confirmed"), + ("CreatePluginConfirmed", "/plugin-confirmed"), ("SavePluginConfirmed", "/plugins/{id}/confirmed"), ( "ApprovePluginFileConfirmed", diff --git a/src-tauri/src/clients/desktop_rule.rs b/src-tauri/src/clients/desktop_rule.rs new file mode 100644 index 00000000..a781a23e --- /dev/null +++ b/src-tauri/src/clients/desktop_rule.rs @@ -0,0 +1,1098 @@ +//! 接管 Claude Desktop 时网关上的那条路由规则。 +//! +//! 网关模式下 Claude Desktop 只认名称像 Claude 的模型(`tw_adopt::desktop::looks_like_claude`); +//! 网关一个都不列时它开不了会话。这时接管写进它配置的是 `tw_adopt::desktop::FALLBACK_MODEL`, +//! 确认框里选一个上游模型,接管时在**它的密钥**上加一条「指定模型」规则: +//! +//! ```yaml +//! - name: Claude Desktop +//! when: { client: claude-desktop } # 它的那把密钥 +//! to: # 提供所选模型的每一家,按顺序备用 +//! - { provider: zai, model: glm-4.6 } +//! - { provider: bigmodel, model: glm-4.6 } +//! ``` +//! +//! 插在这把密钥所用路由的**最前面**:它发来什么 Claude 名称都接得住,以后它换默认模型名 +//! 也不受影响;别的客户端用同一个名称不受影响。**不建全局别名** —— 别名会改变这个名称对 +//! 所有客户端的意思。取消接管时删掉这条规则;网关后来有了它认的模型、再接管一次时也删掉 +//! (它的配置里换成了真的 Claude 模型,规则留着会把它们都改发走)。 +//! +//! **认这条规则看名字和条件**:名字是 [`RULE_NAME`]、条件恰好只有「密钥 = 它的那把」, +//! 而且仍是我们加的样子(去向是指定模型、没有拒绝和改写)。用户改过名字或条件,或者改成了 +//! 转发到上游、策略组,加了拒绝、改写,就不再是这里管的那条:还原不删它,接管也不覆盖它 +//! (同一条路由里同名时报错,请用户先处理)。用户只换了指定的模型的,仍是这条。 +//! +//! 先有规则再写它的配置(和「先有钥匙」同一个道理);配置没写成就把规则改回去。还原时先 +//! 还原文件、再删规则 —— 退路不依赖网关,删不成就提醒去路由页删。 + +use tw_api::{ + ConditionField, ConditionView, KnownModel, Overview, PinnedModel, RouteInput, RouteSave, + RouteView, RuleInput, RuleTarget, RuleView, ep, +}; +use tw_types::{Msg, msg}; + +use crate::control::ControlClient; +use crate::wire::{DesktopPick, DesktopRule, ModelChoice}; + +/// 那条规则的名字 +pub const RULE_NAME: &str = "Claude Desktop"; + +// ---------------------------------------------------------------- 网关此刻的样子 + +/// 算这条规则要知道的:路由、密钥(`/overview`)和各家上游提供的模型(`/models`) +#[derive(Debug, Clone)] +pub struct Snapshot { + pub overview: Overview, + pub known: Vec, +} + +/// 一起问 +pub async fn snapshot(control: &ControlClient) -> Result { + let (overview, known) = tokio::join!( + control.call::(&[], &()), + control.call::(&[], &()) + ); + Ok(Snapshot { + overview: overview.map_err(failed)?, + known: known.map_err(failed)?, + }) +} + +fn failed(e: anyhow::Error) -> Msg { + crate::error::CmdError::from(e).into_msg() +} + +impl Snapshot { + /// 这把密钥走的路由:绑了的那条,没绑的是默认路由 + fn route_of(&self, key: &str) -> &str { + self.overview + .clients + .iter() + .find(|k| k.name == key) + .and_then(|k| k.route.as_deref()) + .unwrap_or(&self.overview.default_route) + } + + fn route(&self, name: &str) -> Option<&RouteView> { + self.overview.routes.iter().find(|r| r.name == name) + } + + /// 网关列出的这些里,哪家上游的哪个模型可以选:按列出的顺序,同名只算一次; + /// 别名、没有上游提供的不算(指定模型要的是上游自己的名称) + pub fn choices(&self, listed: &[String]) -> Vec { + let mut out: Vec = Vec::new(); + for m in listed { + if out.iter().any(|c| &c.model == m) { + continue; + } + if let Some(k) = self + .known + .iter() + .find(|k| &k.id == m && k.alias.is_none() && !k.providers.is_empty()) + { + out.push(ModelChoice { + model: k.id.clone(), + providers: k.providers.clone(), + }); + } + } + out + } +} + +/// 读到的一条规则写回去的样子。**读 `RuleView`、写 `RuleInput` 是同一套字段**(路由页也是 +/// 这样往返的),只读视图里的那几项(`catch_all`、`phase_two`、`shadowed`)不要。改写原样往返 +fn input(r: &RuleView) -> RuleInput { + RuleInput { + name: r.name.clone(), + conditions: r.conditions.clone(), + to: r.to.clone(), + deny: r.deny.clone(), + set: r.set.clone(), + } +} + +/// 一条路由此刻的规则,按写回去的样子 +fn rules_of(route: &RouteView) -> Vec { + route.rules.iter().map(input).collect() +} + +/// 这把密钥的那条规则:只有它的密钥这一个条件 +fn key_only(rule: &RuleInput, key: &str) -> bool { + matches!(rule.conditions.as_slice(), [c] if c.field == ConditionField::Client && c.values == [key]) +} + +/// 是接管时加的那一条(见文件头):名字、条件对得上,仍是指定模型、没有拒绝和改写 +pub fn is_ours(rule: &RuleInput, key: &str) -> bool { + rule.name == RULE_NAME + && key_only(rule, key) + && matches!(&rule.to, Some(RuleTarget::Models(ms)) if !ms.is_empty()) + && rule.deny.is_none() + && rule.set.is_none() +} + +/// 选了这个模型时规则的去向:提供它的每一家,按网关排的顺序备用 +pub fn pinned(choice: &ModelChoice) -> Vec { + choice + .providers + .iter() + .map(|p| PinnedModel { + provider: p.clone(), + model: choice.model.clone(), + }) + .collect() +} + +fn models_of(rule: &RuleInput) -> Option> { + match &rule.to { + Some(RuleTarget::Models(ms)) => Some(ms.clone()), + _ => None, + } +} + +/// 同一条路由里已经有一条同名的规则,不是我们加的那条:不覆盖它 +fn name_taken(route: &str) -> Msg { + msg!( + "adopt.plan.claude_desktop.rule_name_taken", route = route, rule = RULE_NAME => + "The route {route} already has a rule named {rule} that was not added for Claude \ + Desktop. Rename or delete that rule on the Routing page, then connect Claude Desktop again." + ) +} + +/// 确认框之后网关这边变了:要选模型却没选、选的已经没有上游提供、或者不再需要选。 +/// 什么都不写,界面重算一份给人看(和 `adopt.plan.stale` 一样处理) +pub fn gateway_changed(client: &str) -> Msg { + msg!( + "adopt.plan.gateway_changed", client = client => + "The models the gateway offers to {client} changed while the change was being reviewed, \ + so nothing was written. Look at the change again" + ) +} + +/// 确认框里给人看的那条规则。**不写任何东西。** +/// +/// `pick` 是 `tw_adopt::desktop::model_pick`(还原时、网关有它认的模型时为空): +/// +/// - 要选:规则在这把密钥所用路由里的位置(已有就是那一条,没有就是第一条),可选的 +/// 模型,以及已有的那条此刻指定的模型; +/// - 不用选:已有的那条(要删的),没有就什么都不用改(`None`)。 +pub fn view( + s: &Snapshot, + key: &str, + pick: Option<&tw_adopt::desktop::ModelPick>, +) -> Result, Msg> { + let shown = + |route: &str, i: usize, rule: Option<&RuleInput>, pick: Option| DesktopRule { + route: route.to_string(), + position: u32::try_from(i + 1).unwrap_or(u32::MAX), + name: RULE_NAME.to_string(), + key: key.to_string(), + pick, + current: rule.and_then(models_of), + }; + match pick { + Some(p) => { + let name = s.route_of(key); + let Some(route) = s.route(name) else { + return Ok(None); + }; + let rules = rules_of(route); + let ours = rules.iter().position(|r| is_ours(r, key)); + if ours.is_none() && rules.iter().any(|r| r.name == RULE_NAME) { + return Err(name_taken(name)); + } + let pick = DesktopPick { + written: p.written.clone(), + choices: s.choices(&p.listed), + }; + let i = ours.unwrap_or(0); + Ok(Some(shown(name, i, ours.map(|i| &rules[i]), Some(pick)))) + } + None => { + Ok(find_ours(s, key) + .map(|(route, rules, i)| shown(&route.name, i, Some(&rules[i]), None))) + } + } +} + +/// 我们那条在哪:这把密钥所用的路由先找,再找别的(密钥后来换了路由)。交回那条路由、 +/// 它写回去的规则和我们那条的位置 +fn find_ours<'a>(s: &'a Snapshot, key: &str) -> Option<(&'a RouteView, Vec, usize)> { + let mine = s.route_of(key); + let routes = s + .route(mine) + .into_iter() + .chain(s.overview.routes.iter().filter(|r| r.name != mine)); + for r in routes { + let rules = rules_of(r); + if let Some(i) = rules.iter().position(|x| is_ours(x, key)) { + return Some((r, rules, i)); + } + } + None +} + +/// 要写的一处:一条路由的规则从 `before` 换成 `after`,按 `base_version` 那一版改 +#[derive(Debug, Clone)] +pub struct Change { + pub route: String, + pub before: Vec, + pub after: Vec, + pub base_version: String, +} + +/// 接管时要对网关做的改动([`view`] 给人看的那一份,按落盘这一刻的网关重算)。 +/// +/// `chosen` 是确认框里选的模型。和此刻的网关对不上(要选却没选、选的没有上游提供了、 +/// 不用选却选了)就是 [`gateway_changed`];已经是这样了是 `None`。 +pub fn change( + s: &Snapshot, + client: &str, + key: &str, + pick: Option<&tw_adopt::desktop::ModelPick>, + chosen: Option<&str>, +) -> Result, Msg> { + let version = s.overview.config_version.clone(); + let Some(p) = pick else { + if chosen.is_some() { + return Err(gateway_changed(client)); + } + // 网关有它认的模型了:之前加的那条要删 + return Ok(removal(s, key)); + }; + let choices = s.choices(&p.listed); + let choice = match chosen { + // 没得选:照写兜底的那个名字,不加规则(和网关一个模型都没有时的其他客户端一样) + None if choices.is_empty() => return Ok(None), + None => return Err(gateway_changed(client)), + Some(m) => choices + .iter() + .find(|c| c.model == m) + .ok_or_else(|| gateway_changed(client))?, + }; + let to = pinned(choice); + let name = s.route_of(key); + let route = s.route(name).ok_or_else(|| route_missing(name))?; + let before = rules_of(route); + let mut after = before.clone(); + match after.iter().position(|r| is_ours(r, key)) { + Some(i) if models_of(&after[i]).as_ref() == Some(&to) => return Ok(None), + // 用户挪过它的位置:留在原处,只换模型 + Some(i) => after[i].to = Some(RuleTarget::Models(to)), + None if after.iter().any(|r| r.name == RULE_NAME) => return Err(name_taken(name)), + None => after.insert( + 0, + RuleInput { + name: RULE_NAME.to_string(), + conditions: vec![ConditionView { + field: ConditionField::Client, + values: vec![key.to_string()], + }], + to: Some(RuleTarget::Models(to)), + deny: None, + set: None, + }, + ), + } + Ok(Some(Change { + route: name.to_string(), + before, + after, + base_version: version, + })) +} + +/// 删掉我们那条(还原时,或者不再需要它时):一次一条路由,先找这把密钥所用的那条。 +/// 那条路由里我们的规则一起删。没有就是 `None` +pub fn removal(s: &Snapshot, key: &str) -> Option { + let (route, before, _) = find_ours(s, key)?; + let after = before + .iter() + .filter(|r| !is_ours(r, key)) + .cloned() + .collect(); + Some(Change { + route: route.name.clone(), + before, + after, + base_version: s.overview.config_version.clone(), + }) +} + +fn route_missing(route: &str) -> Msg { + crate::error::CmdError::plain(format!("The route `{route}` was not found.")).into_msg() +} + +// ---------------------------------------------------------------- 写 + +/// 只换这条路由的规则。**不带 `keys`**:密钥用哪条路由不动 +async fn save( + control: &ControlClient, + route: &str, + rules: &[RuleInput], + base_version: &str, +) -> Result { + let save = RouteSave { + route: RouteInput { + name: route.to_string(), + rules: rules.to_vec(), + }, + base_version: Some(base_version.to_string()), + keys: None, + }; + Ok(control + .call::(&[route], &save) + .await + .map_err(failed)? + .version) +} + +/// 写一处改动。交回写完之后配置的版本(改回去时用) +pub async fn write(control: &ControlClient, c: &Change) -> Result { + save(control, &c.route, &c.after, &c.base_version).await +} + +/// 把 [`write`] 写的那一处改回去:那之后别人又改过配置的话不改(版本对不上,core 拒绝) +pub async fn revert(control: &ControlClient, c: &Change, version: &str) -> Result<(), Msg> { + save(control, &c.route, &c.before, version) + .await + .map(|_| ()) +} + +/// 规则加上了、Claude Desktop 的配置却没写成,规则也没能改回去:两件事一起说 +pub fn not_reverted(c: &Change, error: &Msg, revert: &Msg) -> Msg { + msg!( + "adopt.claude_desktop.rule_not_reverted", + detail = error.text.clone(), + route = c.route.clone(), + rule = RULE_NAME, + reason = revert.text.clone() + => "The routing rule {rule} added to the route {route} for Claude Desktop is still there: \ + Claude Desktop could not be connected ({detail}), and the rule could not be taken out \ + again ({reason}). Delete it on the Routing page." + ) +} + +/// 还原之后删掉这把密钥的那条规则。删不成(或者问不到网关)交回要提醒的那一句 +/// +/// `owner` 是这一份 Claude Desktop 的密钥归在谁名下。一次删一条路由里的,删完再看一遍: +/// 密钥换过路由时,两条路由里可能都有 +pub async fn remove_after_restore(control: &ControlClient, owner: &str) -> Option { + let unchecked = |e: Msg| { + msg!( + "adopt.claude_desktop.rule_unchecked", rule = RULE_NAME, detail = e.text => + "Claude Desktop is restored, but the gateway could not be asked to delete the routing \ + rule {rule} added when it was connected ({detail}). If the rule is still there, delete \ + it on the Routing page." + ) + }; + let mut s = match snapshot(control).await { + Ok(s) => s, + Err(e) => return Some(unchecked(e)), + }; + let key = s + .overview + .clients + .iter() + .find(|k| k.client.as_deref() == Some(owner))? + .name + .clone(); + // 每条路由至多改一次 + for _ in 0..=s.overview.routes.len() { + let c = removal(&s, &key)?; + if let Err(e) = write(control, &c).await { + return Some(msg!( + "adopt.claude_desktop.rule_left", + route = c.route.clone(), + rule = RULE_NAME, + detail = e.text + => "Claude Desktop is restored, but the rule {rule} in the route {route} could \ + not be deleted ({detail}). Delete it on the Routing page." + )); + } + s = match snapshot(control).await { + Ok(s) => s, + Err(e) => return Some(unchecked(e)), + }; + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use tw_adopt::desktop::ModelPick; + + fn rule(name: &str, key: Option<&str>, to: Option) -> RuleInput { + RuleInput { + name: name.into(), + conditions: key + .map(|k| { + vec![ConditionView { + field: ConditionField::Client, + values: vec![k.into()], + }] + }) + .unwrap_or_default(), + to, + deny: None, + set: None, + } + } + + /// 写进配置之后 `/overview` 里读到的样子 + fn view_of(r: RuleInput) -> RuleView { + RuleView { + catch_all: r.conditions.is_empty(), + phase_two: false, + shadowed: false, + name: r.name, + conditions: r.conditions, + to: r.to, + deny: r.deny, + set: r.set, + } + } + + /// 规则和改动按线上的 JSON 比:core 的这几个类型不带 `PartialEq` + fn json(x: &T) -> serde_json::Value { + serde_json::to_value(x).unwrap() + } + + fn change_json(c: &Change) -> serde_json::Value { + serde_json::json!({ + "route": c.route, "before": c.before, "after": c.after, "base_version": c.base_version + }) + } + + /// 一份 `/overview`:路由和密钥之外的几项照一个空网关填 + fn overview(routes: serde_json::Value, clients: serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "config_version": "v1", + "default_route": "default", + "routes": routes, + "clients": clients, + "providers": [], "proxies": [], "groups": [], "client_probes": [], "price_sheets": [], + "listen": {"bind": "loopback", "port": 8788, "allow_from": [], "default_allow_from": [], "exposed": false}, + "security": {"redact": "observe", "inspect_tools": "observe", "content": "observe"}, + "retention": {"body_days": 7, "row_days": 90, "body_max_bytes": 0, "body_bytes_now": 0}, + "failover": {"failures_to_pause": 3, "pause_secs": 60, "max_pause_secs": 600, "no_balance_pause_secs": 1800, + "quota_pause_secs": 3600, "rate_limit_max_pause_secs": 3600, "stream_start_wait_secs": 15} + }) + } + + fn pin(provider: &str, model: &str) -> PinnedModel { + PinnedModel { + provider: provider.into(), + model: model.into(), + } + } + + /// 一个网关:默认路由 `default`(Opus 拒绝、兜底),`codex` 路由;密钥 `claude-desktop` + /// 没绑路由,`cd-2` 绑了 `codex`;glm-4.6 由 zai、bigmodel 提供,kimi-k2 由 moonshot, + /// `fast` 是别名 + fn gateway() -> Snapshot { + let json = overview( + serde_json::json!([ + {"name": "default", "default": true, "builtin": false, "has_catch_all": true, "clients": [], + "rules": [ + {"name": "no-opus", "conditions": [{"field": "model", "values": ["claude-opus-*"]}], + "deny": "Opus is off", "catch_all": false, "phase_two": false, "shadowed": false}, + {"name": "long", "conditions": [{"field": "input_tokens", "values": [">200k"]}], + "to": "gemini", "set": {"model": "gemini-2.5-pro"}, "catch_all": false, "phase_two": false, "shadowed": false}, + {"name": "rest", "conditions": [], "to": "__all__", "catch_all": true, "phase_two": false, "shadowed": false} + ]}, + {"name": "codex", "default": false, "builtin": false, "has_catch_all": true, "clients": ["cd-2"], + "rules": [{"name": "rest", "conditions": [], "to": "chatgpt", "catch_all": true, "phase_two": false, "shadowed": false}]} + ]), + serde_json::json!([ + {"name": "claude-desktop", "key": "tw-x", "client": "claude-desktop", "disabled": false, "default": false}, + {"name": "cd-2", "key": "tw-y", "client": "claude-desktop@Ubuntu", "route": "codex", "disabled": false, "default": false} + ]), + ); + Snapshot { + overview: serde_json::from_value(json).unwrap(), + known: serde_json::from_value(serde_json::json!([ + {"id": "glm-4.6", "providers": ["zai", "bigmodel"], "aliases": []}, + {"id": "kimi-k2", "providers": ["moonshot"], "aliases": []}, + {"id": "fast", "providers": ["zai"], "alias": ["glm-4.6"], "aliases": []}, + {"id": "orphan", "providers": [], "aliases": []} + ])) + .unwrap(), + } + } + + fn pick(listed: &[&str]) -> ModelPick { + ModelPick { + written: "claude-sonnet-5".into(), + listed: listed.iter().map(|s| s.to_string()).collect(), + } + } + + /// 候选按网关列出的顺序;别名、没有上游提供的、清单里没有的不算 + #[test] + fn choices_are_upstream_models_in_the_listed_order() { + let s = gateway(); + let c = s.choices( + &["kimi-k2", "fast", "glm-4.6", "orphan", "nope", "kimi-k2"].map(String::from), + ); + assert_eq!( + c, + vec![ + ModelChoice { + model: "kimi-k2".into(), + providers: vec!["moonshot".into()] + }, + ModelChoice { + model: "glm-4.6".into(), + providers: vec!["zai".into(), "bigmodel".into()] + }, + ] + ); + assert_eq!( + pinned(&c[1]), + vec![pin("zai", "glm-4.6"), pin("bigmodel", "glm-4.6")] + ); + } + + /// 加在这把密钥所用路由的最前面,条件只有它的密钥,别的规则一条不动 + #[test] + fn the_rule_goes_first_in_the_route_of_the_key() { + let s = gateway(); + let v = view(&s, "claude-desktop", Some(&pick(&["glm-4.6", "kimi-k2"]))) + .unwrap() + .unwrap(); + assert_eq!( + (v.route.as_str(), v.position, v.key.as_str()), + ("default", 1, "claude-desktop") + ); + assert_eq!(v.current, None); + assert_eq!(v.pick.as_ref().unwrap().choices.len(), 2); + + let c = change( + &s, + "Claude Desktop", + "claude-desktop", + Some(&pick(&["glm-4.6"])), + Some("glm-4.6"), + ) + .unwrap() + .unwrap(); + assert_eq!(c.route, "default"); + assert_eq!(c.base_version, "v1"); + assert_eq!(c.after.len(), 4); + assert_eq!(json(&c.after[1..]), json(&c.before)); + assert_eq!( + serde_json::to_value(&c.after[0]).unwrap(), + serde_json::json!({ + "name": "Claude Desktop", + "conditions": [{"field": "client", "values": ["claude-desktop"]}], + "to": [{"provider": "zai", "model": "glm-4.6"}, {"provider": "bigmodel", "model": "glm-4.6"}] + }) + ); + // 往返之后别的规则的改写、拒绝原样在,视图里的那几项不写回去 + assert_eq!( + serde_json::to_value(&c.after[2]).unwrap(), + serde_json::json!({ + "name": "long", + "conditions": [{"field": "input_tokens", "values": [">200k"]}], + "to": "gemini", + "set": {"model": "gemini-2.5-pro"} + }) + ); + assert!(is_ours(&c.after[0], "claude-desktop")); + + // 绑了路由的密钥:加在它那条路由里 + let c = change(&s, "x", "cd-2", Some(&pick(&["kimi-k2"])), Some("kimi-k2")) + .unwrap() + .unwrap(); + assert_eq!(c.route, "codex"); + assert_eq!( + c.after[0].to, + Some(RuleTarget::Models(vec![pin("moonshot", "kimi-k2")])) + ); + } + + fn with_ours(s: &mut Snapshot, at: usize, to: Vec) { + s.overview.routes[0].rules.insert( + at, + view_of(rule( + RULE_NAME, + Some("claude-desktop"), + Some(RuleTarget::Models(to)), + )), + ); + } + + /// 再接管一次:已有的那条原地换模型;一样就什么都不用改 + #[test] + fn adopting_again_changes_the_rule_where_it_is() { + let mut s = gateway(); + with_ours(&mut s, 1, vec![pin("moonshot", "kimi-k2")]); + let v = view(&s, "claude-desktop", Some(&pick(&["glm-4.6", "kimi-k2"]))) + .unwrap() + .unwrap(); + assert_eq!(v.position, 2); + assert_eq!(v.current, Some(vec![pin("moonshot", "kimi-k2")])); + let p = pick(&["glm-4.6", "kimi-k2"]); + assert!( + change(&s, "x", "claude-desktop", Some(&p), Some("kimi-k2")) + .unwrap() + .is_none() + ); + let c = change(&s, "x", "claude-desktop", Some(&p), Some("glm-4.6")) + .unwrap() + .unwrap(); + assert_eq!(c.after.len(), c.before.len()); + assert_eq!(c.after[1].name, RULE_NAME); + assert_eq!(models_of(&c.after[1]).unwrap()[0], pin("zai", "glm-4.6")); + } + + /// 网关有它认的模型了(不用选):之前加的那条删掉;没有就什么都不改 + #[test] + fn without_a_pick_the_rule_is_taken_out() { + let s = gateway(); + assert_eq!(view(&s, "claude-desktop", None).unwrap(), None); + assert!( + change(&s, "x", "claude-desktop", None, None) + .unwrap() + .is_none() + ); + + let mut s = gateway(); + with_ours(&mut s, 0, vec![pin("zai", "glm-4.6")]); + let v = view(&s, "claude-desktop", None).unwrap().unwrap(); + assert!(v.pick.is_none()); + assert_eq!(v.current, Some(vec![pin("zai", "glm-4.6")])); + let c = change(&s, "x", "claude-desktop", None, None) + .unwrap() + .unwrap(); + assert_eq!( + json(&c.after), + json(&rules_of(&gateway().overview.routes[0])) + ); + assert_eq!( + removal(&s, "claude-desktop").map(|r| change_json(&r)), + Some(change_json(&c)) + ); + } + + /// 用户改过的不算我们的:改了名字、条件、改成转发到上游、加了改写或拒绝的都不删; + /// 只换了指定模型的仍是这条 + #[test] + fn a_rule_the_user_changed_is_left_alone() { + let models = || Some(RuleTarget::Models(vec![pin("zai", "glm-4.6")])); + let key = "claude-desktop"; + assert!(is_ours(&rule(RULE_NAME, Some(key), models()), key)); + assert!(is_ours( + &rule( + RULE_NAME, + Some(key), + Some(RuleTarget::Models(vec![pin("moonshot", "kimi-k2")])) + ), + key + )); + assert!(!is_ours(&rule("Desktop → GLM", Some(key), models()), key)); + assert!(!is_ours(&rule(RULE_NAME, Some("other"), models()), key)); + assert!(!is_ours(&rule(RULE_NAME, None, models()), key)); + assert!(!is_ours( + &rule(RULE_NAME, Some(key), Some(RuleTarget::Name("zai".into()))), + key + )); + assert!(!is_ours( + &rule(RULE_NAME, Some(key), Some(RuleTarget::Models(vec![]))), + key + )); + let mut more = rule(RULE_NAME, Some(key), models()); + more.conditions.push(ConditionView { + field: ConditionField::Model, + values: vec!["claude-*".into()], + }); + assert!(!is_ours(&more, key)); + let mut set = rule(RULE_NAME, Some(key), models()); + set.set = Some(tw_api::RuleRewrite { + max_tokens: Some(1000), + ..Default::default() + }); + assert!(!is_ours(&set, key)); + + // 还原时不删它 + let mut s = gateway(); + s.overview.routes[0].rules.insert(0, view_of(set.clone())); + assert!(removal(&s, key).is_none()); + // 接管时也不覆盖它:同名,请用户先处理 + let p = pick(&["glm-4.6"]); + assert_eq!( + view(&s, key, Some(&p)).unwrap_err().code, + "adopt.plan.claude_desktop.rule_name_taken" + ); + assert_eq!( + change(&s, "x", key, Some(&p), Some("glm-4.6")) + .unwrap_err() + .code, + "adopt.plan.claude_desktop.rule_name_taken" + ); + // 另一条路由里的同名规则碍不着 + let mut s = gateway(); + s.overview.routes[1].rules.insert(0, view_of(set)); + assert!( + change(&s, "x", key, Some(&p), Some("glm-4.6")) + .unwrap() + .is_some() + ); + } + + /// 密钥换过路由:两条路由里的都找得到,先删它此刻所用的那条里的 + #[test] + fn the_rule_is_found_in_another_route_too() { + let mut s = gateway(); + s.overview.routes[1].rules.insert( + 0, + view_of(rule( + RULE_NAME, + Some("claude-desktop"), + Some(RuleTarget::Models(vec![pin("zai", "glm-4.6")])), + )), + ); + let c = removal(&s, "claude-desktop").unwrap(); + assert_eq!(c.route, "codex"); + assert_eq!( + json(&c.after), + json(&[rule("rest", None, Some(RuleTarget::Name("chatgpt".into())))]) + ); + with_ours(&mut s, 0, vec![pin("zai", "glm-4.6")]); + assert_eq!(removal(&s, "claude-desktop").unwrap().route, "default"); + } + + /// 和确认框里那一份对不上:要选却没选、选的没了、不用选却选了 + #[test] + fn a_choice_that_no_longer_fits_the_gateway_is_refused() { + let s = gateway(); + let p = pick(&["glm-4.6"]); + for (pick, chosen) in [ + (Some(&p), None), + (Some(&p), Some("kimi-k2")), + (Some(&p), Some("gone")), + (None, Some("glm-4.6")), + ] { + assert_eq!( + change(&s, "Claude Desktop", "claude-desktop", pick, chosen) + .unwrap_err() + .code, + "adopt.plan.gateway_changed" + ); + } + // 一个都没得选:照写兜底的名字,不加规则 + assert!( + change(&s, "x", "claude-desktop", Some(&pick(&["nope"])), None) + .unwrap() + .is_none() + ); + } + + // ------------------------------------------------------------ 对着一个假的 core + + use std::sync::{Arc, Mutex}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + /// 假 core 手上的配置:`/overview` 和 `/models` 照它答,`PUT /routes/{name}` 照 core 的 + /// 规矩改它(版本不对就 409,`refuse` 时一律拒绝),每一次写都记下来 + struct Core { + overview: serde_json::Value, + known: serde_json::Value, + version: u32, + writes: Vec, + refuse: bool, + } + + const KEY: &str = "abababababababababababababababababababababababababababababababab"; + + fn json_of(s: &Snapshot) -> (serde_json::Value, serde_json::Value) { + (json(&s.overview), json(&s.known)) + } + + /// 握手照真的来(`tw_link`),之后按 HTTP/1.1 一问一答:控制面客户端每次调用新建一条连接 + async fn fake_core(core: Arc>) -> ControlClient { + let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = l.local_addr().unwrap().port(); + tokio::spawn(async move { + let key = tw_link::ControlKey::parse(KEY).unwrap(); + let acceptor = tw_link::Acceptor::new(move || Some(key.clone()), "9.9.9"); + while let Ok((s, _)) = l.accept().await { + let Ok(a) = acceptor.accept(s).await else { + continue; + }; + let core = core.clone(); + tokio::spawn(answer(a.stream, core)); + } + }); + ControlClient::to(crate::control::Target::Remote( + crate::connection::connector::RemoteTarget { + host: "127.0.0.1".into(), + port, + key: KEY.into(), + }, + )) + } + + async fn answer( + mut s: S, + core: Arc>, + ) { + let mut buf = Vec::new(); + let mut chunk = [0u8; 4096]; + let head_end = loop { + if let Some(i) = buf.windows(4).position(|w| w == b"\r\n\r\n") { + break i + 4; + } + let Ok(n) = s.read(&mut chunk).await else { + return; + }; + if n == 0 { + return; + } + buf.extend_from_slice(&chunk[..n]); + }; + let head = String::from_utf8_lossy(&buf[..head_end]).to_string(); + let len = head + .lines() + .find_map(|l| { + let (k, v) = l.split_once(':')?; + k.eq_ignore_ascii_case("content-length") + .then(|| v.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + while buf.len() < head_end + len { + let Ok(n) = s.read(&mut chunk).await else { + return; + }; + if n == 0 { + return; + } + buf.extend_from_slice(&chunk[..n]); + } + let body: serde_json::Value = + serde_json::from_slice(&buf[head_end..head_end + len]).unwrap_or_default(); + let mut first = head.lines().next().unwrap_or_default().split(' '); + let (method, path) = ( + first.next().unwrap_or_default(), + first.next().unwrap_or_default(), + ); + let (status, reply) = { + let mut c = core.lock().unwrap(); + route(&mut c, method, path, body) + }; + let reply = reply.to_string(); + let text = format!( + "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{reply}", + reply.len() + ); + let _ = s.write_all(text.as_bytes()).await; + let _ = s.flush().await; + } + + fn refused(code: &str, text: &str) -> serde_json::Value { + serde_json::json!({"code": code, "text": text}) + } + + fn route( + c: &mut Core, + method: &str, + path: &str, + body: serde_json::Value, + ) -> (&'static str, serde_json::Value) { + match (method, path) { + ("GET", "/overview") => { + let mut o = c.overview.clone(); + o["config_version"] = serde_json::json!(format!("v{}", c.version)); + ("200 OK", o) + } + ("GET", "/models") => ("200 OK", c.known.clone()), + ("PUT", p) if p.starts_with("/routes/") => { + let name = &p["/routes/".len()..]; + if c.refuse { + return ( + "400 Bad Request", + refused("control.route.duplicate_rule", "refused"), + ); + } + if body["base_version"] != serde_json::json!(format!("v{}", c.version)) { + return ( + "409 Conflict", + refused("control.version_conflict", "stale version"), + ); + } + c.writes.push(body.clone()); + let routes = c.overview["routes"].as_array_mut().unwrap(); + let r = routes.iter_mut().find(|r| r["name"] == name).unwrap(); + // 存进去的是 `RuleInput`,读出来的 `RuleView` 多几项 + let mut rules = body["route"]["rules"].clone(); + for x in rules.as_array_mut().unwrap() { + let all = x["conditions"].as_array().is_none_or(|c| c.is_empty()); + x["catch_all"] = serde_json::json!(all); + x["phase_two"] = serde_json::json!(false); + x["shadowed"] = serde_json::json!(false); + } + r["rules"] = rules; + c.version += 1; + ( + "200 OK", + serde_json::json!({"version": format!("v{}", c.version)}), + ) + } + _ => ( + "404 Not Found", + refused("control.not_found", "no such endpoint"), + ), + } + } + + fn core() -> Arc> { + let (overview, known) = json_of(&gateway()); + Arc::new(Mutex::new(Core { + overview, + known, + version: 1, + writes: Vec::new(), + refuse: false, + })) + } + + fn rules_now(c: &Arc>, route: &str) -> Vec { + let c = c.lock().unwrap(); + let r = c.overview["routes"] + .as_array() + .unwrap() + .iter() + .find(|r| r["name"] == route) + .unwrap(); + serde_json::from_value(r["rules"].clone()).unwrap() + } + + /// 接管:按 core 此刻的配置算出改动、带着那一版的版本写进去,规则在最前面;还原之后 + /// 删掉它,路由回到原样。写进去的 JSON 就是约定里 `RouteSave` 的样子 + #[tokio::test] + async fn the_rule_is_written_to_core_and_taken_out_on_restore() { + let fake = core(); + let control = fake_core(fake.clone()).await; + let before = rules_now(&fake, "default"); + + let s = snapshot(&control).await.unwrap(); + assert_eq!(s.overview.config_version, "v1"); + let c = change( + &s, + "Claude Desktop", + "claude-desktop", + Some(&pick(&["glm-4.6"])), + Some("glm-4.6"), + ) + .unwrap() + .unwrap(); + assert_eq!(write(&control, &c).await.unwrap(), "v2"); + let sent = fake.lock().unwrap().writes[0].clone(); + assert_eq!(sent["base_version"], "v1"); + assert_eq!(sent["route"]["name"], "default"); + assert_eq!( + sent["route"]["rules"][0], + serde_json::json!({ + "name": "Claude Desktop", + "conditions": [{"field": "client", "values": ["claude-desktop"]}], + "to": [{"provider": "zai", "model": "glm-4.6"}, {"provider": "bigmodel", "model": "glm-4.6"}] + }) + ); + assert!(sent.get("keys").is_none(), "不动密钥的路由选择"); + let now = rules_now(&fake, "default"); + assert!(is_ours(&now[0], "claude-desktop")); + assert_eq!(json(&now[1..]), json(&before)); + + // 再看一遍:确认框里给的就是这一条 + let s = snapshot(&control).await.unwrap(); + let v = view(&s, "claude-desktop", None).unwrap().unwrap(); + assert_eq!((v.route.as_str(), v.position), ("default", 1)); + + assert_eq!(remove_after_restore(&control, "claude-desktop").await, None); + assert_eq!(json(&rules_now(&fake, "default")), json(&before)); + // 没有了就不再写 + let n = fake.lock().unwrap().writes.len(); + assert_eq!(remove_after_restore(&control, "claude-desktop").await, None); + assert_eq!(fake.lock().unwrap().writes.len(), n); + } + + /// 配置没写成:把规则改回去,按写完之后的那一版 + #[tokio::test] + async fn a_written_rule_is_taken_back() { + let fake = core(); + let control = fake_core(fake.clone()).await; + let before = rules_now(&fake, "default"); + let s = snapshot(&control).await.unwrap(); + let c = change( + &s, + "x", + "claude-desktop", + Some(&pick(&["kimi-k2"])), + Some("kimi-k2"), + ) + .unwrap() + .unwrap(); + let v = write(&control, &c).await.unwrap(); + revert(&control, &c, &v).await.unwrap(); + assert_eq!(json(&rules_now(&fake, "default")), json(&before)); + // 那之后别人又改过:core 拒绝,不覆盖别人的改动 + let s = snapshot(&control).await.unwrap(); + let c = change( + &s, + "x", + "claude-desktop", + Some(&pick(&["kimi-k2"])), + Some("kimi-k2"), + ) + .unwrap() + .unwrap(); + let v = write(&control, &c).await.unwrap(); + fake.lock().unwrap().version += 1; + let e = revert(&control, &c, &v).await.unwrap_err(); + assert_eq!(e.code, "control.version_conflict"); + let m = not_reverted(&c, &gateway_changed("Claude Desktop"), &e); + assert_eq!(m.code, "adopt.claude_desktop.rule_not_reverted"); + assert_eq!(m.arg("route"), "default"); + } + + /// 还原之后删不成:提醒去路由页删;问不到 core 也提醒 + #[tokio::test] + async fn a_rule_that_cannot_be_deleted_is_reported() { + let fake = core(); + let control = fake_core(fake.clone()).await; + let s = snapshot(&control).await.unwrap(); + let c = change( + &s, + "x", + "claude-desktop", + Some(&pick(&["glm-4.6"])), + Some("glm-4.6"), + ) + .unwrap() + .unwrap(); + write(&control, &c).await.unwrap(); + fake.lock().unwrap().refuse = true; + let w = remove_after_restore(&control, "claude-desktop") + .await + .unwrap(); + assert_eq!(w.code, "adopt.claude_desktop.rule_left"); + assert_eq!(w.arg("route"), "default"); + assert_eq!(w.arg("rule"), RULE_NAME); + + // 没有为它发过密钥:没什么可删的 + assert_eq!(remove_after_restore(&control, "no-such-client").await, None); + + let gone = ControlClient::to(crate::control::Target::Remote( + crate::connection::connector::RemoteTarget { + host: "127.0.0.1".into(), + port: 9, + key: KEY.into(), + }, + )); + let w = remove_after_restore(&gone, "claude-desktop").await.unwrap(); + assert_eq!(w.code, "adopt.claude_desktop.rule_unchecked"); + } +} diff --git a/src-tauri/src/clients/mod.rs b/src-tauri/src/clients/mod.rs index f466a398..475e4831 100644 --- a/src-tauri/src/clients/mod.rs +++ b/src-tauri/src/clients/mod.rs @@ -8,6 +8,7 @@ //! 这一层按 id 找出来再动手 —— 界面递一段任意文字进剪贴板、递一个任意路径给 //! 访达,都是不该开的口子。 +pub mod desktop_rule; pub mod locations; pub mod ops; mod reveal; @@ -441,13 +442,22 @@ pub async fn plan_adopt( Ok((_, key, _)) => models_for(&c, &gw.base, &key).await?, Err(_) => Vec::new(), }; - Ok(ops::plan_adopt( + let (mut v, pick) = ops::plan_adopt_picking( &home_dir(), &id, + &id, &gw, models, &place.around(!state.link.is_remote()).await, - )?) + )?; + // Claude Desktop:网关上那条规则(要加、要换、要删)一起给人看 + if c.id == tw_adopt::desktop::ID + && let Some(key) = v.key.clone() + { + let s = desktop_rule::snapshot(&state.control).await?; + v.desktop_rule = desktop_rule::view(&s, &key, pick.as_ref())?; + } + Ok(v) } Place::Wsl(w) => { let gw = ops::Gateway { @@ -478,12 +488,17 @@ pub async fn plan_adopt( /// /// `expect` 是确认框里那份改动的指纹([`ops::fingerprint`]):文件在人看差异的时候 /// 被改过,就什么都不写,由界面重新算一份给人看。 +/// +/// `model`:接管 Claude Desktop 而网关没有它认的模型时,确认框里选的上游模型。网关上那条 +/// 规则**夹在核对和落盘之间**写([`desktop_rule`]):先有规则再写它的配置,配置没写成就 +/// 把规则改回去 #[tauri::command] pub async fn adopt_client( state: tauri::State<'_, AppState>, id: String, env: Option, expect: Option, + model: Option, ) -> Out { let place = Place::open(env).await?; let c = place.find(&id)?; @@ -494,7 +509,7 @@ pub async fn adopt_client( let key = prepare_key(&state.control, &place.owner(&id)).await?; let models = models_for(&c, &base, &key.key).await?; let around = place.around(!state.link.is_remote()).await; - let mut r = ops::adopt( + let prepared = ops::prepare_adopt( &place.home(), &backups(), &id, @@ -502,6 +517,36 @@ pub async fn adopt_client( expect.as_deref(), &around, )?; + let change = if c.id == tw_adopt::desktop::ID && matches!(place, Place::Here) { + let s = desktop_rule::snapshot(&state.control).await?; + desktop_rule::change( + &s, + c.name, + &key.name, + prepared.pick_model(), + model.as_deref(), + )? + } else if model.is_some() { + // 只有 Claude Desktop 要选模型 + return Err(desktop_rule::gateway_changed(c.name).into()); + } else { + None + }; + let written = match &change { + Some(ch) => Some(desktop_rule::write(&state.control, ch).await?), + None => None, + }; + let mut r = match prepared.write(&backups()) { + Ok(r) => r, + Err(e) => { + if let (Some(ch), Some(version)) = (&change, &written) + && let Err(again) = desktop_rule::revert(&state.control, ch, version).await + { + return Err(desktop_rule::not_reverted(ch, &e, &again).into()); + } + return Err(e.into()); + } + }; if let Place::Wsl(w) = &place { r.real = w.shown(Path::new(&r.real)); } @@ -516,30 +561,45 @@ pub async fn plan_restore( env: Option, ) -> Out { let place = Place::open(env).await?; - place.find(&id)?; + let c = place.find(&id)?; let keys = keys(&state.control).await.unwrap_or_default(); let mut v = ops::plan_restore_as(&place.home(), &id, &place.owner(&id), &keys)?; if let Place::Wsl(w) = &place { v.path = w.shown(Path::new(&v.path)); } + // Claude Desktop:接管时在它的密钥上加的那条规则一起删。问不到 core 就不说 —— 还原 + // 照样做得了,删不成的那一刻再提醒 + if c.id == tw_adopt::desktop::ID + && matches!(place, Place::Here) + && let Some(key) = v.key.clone() + && let Ok(s) = desktop_rule::snapshot(&state.control).await + { + v.desktop_rule = desktop_rule::view(&s, &key, None).ok().flatten(); + } Ok(v) } -/// 还原。**不问 core**:退路不该依赖网关还在不在。`expect` 和 [`adopt_client`] 的一样 +/// 还原。**文件不问 core**:退路不该依赖网关还在不在。`expect` 和 [`adopt_client`] 的一样。 +/// +/// Claude Desktop 还原之后,再删接管时在它的密钥上加的那条规则([`desktop_rule`]):删不成、 +/// 问不到 core 时文件照样还原了,交回一句提醒 #[tauri::command] pub async fn restore_client( + state: tauri::State<'_, AppState>, id: String, env: Option, expect: Option, ) -> Out { let place = Place::open(env).await?; - place.find(&id)?; - Ok(ops::restore( - &place.home(), - &backups(), - &id, - expect.as_deref(), - )?) + let c = place.find(&id)?; + let mut r = ops::restore(&place.home(), &backups(), &id, expect.as_deref())?; + if c.id == tw_adopt::desktop::ID + && matches!(place, Place::Here) + && let Some(w) = desktop_rule::remove_after_restore(&state.control, &place.owner(&id)).await + { + r.warnings.push(w); + } + Ok(r) } /// 「我明明配了,为什么没生效」—— 走一遍优先级链。 diff --git a/src-tauri/src/clients/ops.rs b/src-tauri/src/clients/ops.rs index 66a67b6c..6a6a4259 100644 --- a/src-tauri/src/clients/ops.rs +++ b/src-tauri/src/clients/ops.rs @@ -182,6 +182,7 @@ fn detected_view( verified: d.verified.into(), costs: d.costs, models_stale: false, + models: d.models, movable: false, managed: d.managed.as_ref().map(|by| { plan::PlanError::Managed { @@ -516,6 +517,7 @@ fn view( }) .collect(), bedrock: p.bedrock.clone().map(Into::into), + desktop_rule: None, } } @@ -560,6 +562,20 @@ pub fn plan_adopt_as( models: Vec, around: &Around, ) -> Result { + plan_adopt_picking(home, id, owner, gw, models, around).map(|(v, _)| v) +} + +/// [`plan_adopt_as`],连同「要不要给 Claude Desktop 选模型」 +/// (`tw_adopt::desktop::model_pick`):要选的话,网关上那条规则由调用方去问 core 再算 +/// ([`super::desktop_rule`]) +pub fn plan_adopt_picking( + home: &Path, + id: &str, + owner: &str, + gw: &Gateway, + models: Vec, + around: &Around, +) -> Result<(wire::PlanView, Option), Msg> { let c = find(id, home)?; let (name, value, created) = key_for(gw, owner)?; let target = clients::Gateway { @@ -576,7 +592,7 @@ pub fn plan_adopt_as( ); v.key = Some(name); v.key_created = created; - Ok(v) + Ok((v, p.pick_model)) } /// 接管时写进去的是哪一把:`owner` 名下留着的,没有就按默认那把算(落盘时才 @@ -630,20 +646,53 @@ pub fn adopt( expect: Option<&str>, around: &Around, ) -> Result { + prepare_adopt(home, backups, id, target, expect, around)?.write(backups) +} + +/// 核对过、还没落盘的一份接管改动([`prepare_adopt`])。 +/// +/// 接管 Claude Desktop 时,网关上那条规则要**夹在核对和落盘之间**写:先有规则再写它的 +/// 配置(和「先有钥匙」同一个道理),而核对没过时一条规则都不该留下 +pub struct Prepared { + c: Client, + plan: plan::Plan, +} + +impl Prepared { + /// 要给 Claude Desktop 选模型时,写进它配置的名称和网关列出的候选 + pub fn pick_model(&self) -> Option<&tw_adopt::desktop::ModelPick> { + self.plan.pick_model.as_ref() + } + + /// 落盘 + pub fn write(self, backups: &Path) -> Result { + let a = plan::apply(&self.c, &self.plan, backups).map_err(|e| e.msg())?; + Ok(wire::AdoptResponse { + real: a.real.display().to_string(), + backup: a.backup.display().to_string(), + created: a.created, + warnings: a.warnings, + // **在接管完成那一屏说,不是等五分钟后再说** + takes_effect: self.c.takes_effect.into(), + }) + } +} + +/// [`adopt`] 的前一半:按此刻的文件重算、和确认框里那一份核对。**不写任何东西** +pub fn prepare_adopt( + home: &Path, + backups: &Path, + id: &str, + target: &clients::Gateway, + expect: Option<&str>, + around: &Around, +) -> Result { // 什么时候生效按装着的版本说(opencode v2 不用重启) let c = find(id, home)?.here(home); not_theirs(&c, home, backups)?; - let p = plan_for(&c, home, target, around).map_err(|e| e.msg())?; - still_as_reviewed(expect, &plan_fingerprint(&p), c.name)?; - let a = plan::apply(&c, &p, backups).map_err(|e| e.msg())?; - Ok(wire::AdoptResponse { - real: a.real.display().to_string(), - backup: a.backup.display().to_string(), - created: a.created, - warnings: a.warnings, - // **在接管完成那一屏说,不是等五分钟后再说** - takes_effect: c.takes_effect.into(), - }) + let plan = plan_for(&c, home, target, around).map_err(|e| e.msg())?; + still_as_reviewed(expect, &plan_fingerprint(&plan), c.name)?; + Ok(Prepared { c, plan }) } /// 算一份还原改动。**不写任何东西。** diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 02c16528..cc15004e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,6 +11,8 @@ use tauri::Manager; // 第一个声明:`tr!` 要在后面每个模块里都能用 #[macro_use] pub mod i18n; +/// 新建、编辑模型别名时和这台机器上的客户端有关的提示 +pub mod aliases; /// 整份换掉应用自己的小文件(设置、提醒记录),见模块头上 pub mod atomic_file; pub mod autostart; @@ -200,6 +202,7 @@ pub fn run() { gateway::restart_core, dashboard::dashboard, upstreams::upstream_stats, + aliases::alias_hints, notices::commands::notices_list, notices::commands::mark_notice_read, notices::commands::mark_all_notices_read, diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 6aa87929..0a47f3ac 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -95,6 +95,7 @@ pub fn plan_op(home: &Path, req: &wire::McpOpRequest) -> Result Out> { - crate::clients::blocking(|| restore_everywhere(&tw_adopt::foreign::backup_root())).await +pub async fn restore_all(state: tauri::State<'_, crate::AppState>) -> Out> { + let mut out = + crate::clients::blocking(|| restore_everywhere(&tw_adopt::foreign::backup_root())).await?; + let desktop = tw_adopt::desktop::ID; + if let Some(r) = out + .iter_mut() + .find(|r| r.here && r.id == desktop && r.ok && !r.skipped) + { + r.warning = crate::clients::desktop_rule::remove_after_restore(&state.control, desktop) + .await + .map(|w| crate::core_text::text(&w)); + } + Ok(out) } /// 还原这一份接管着的每一个客户端:这台电脑上的,和每个 WSL 发行版里的。阻塞。 @@ -38,11 +52,13 @@ pub async fn restore_all() -> Out> { /// /// `backups` 是这一份的备份目录(`tw_adopt::foreign::backup_root`),测试里换成临时的 pub fn restore_everywhere(backups: &Path) -> Vec { - let mut out = restore_all_in(&crate::clients::home_dir(), backups, |n| n.to_string()); + let mut out = restore_all_in(&crate::clients::home_dir(), backups, true, |n| { + n.to_string() + }); for d in crate::clients::wsl::distros() { let name = d.name.clone(); match crate::clients::wsl::open(d) { - Ok(w) => out.extend(restore_all_in(&w.home, backups, |n| { + Ok(w) => out.extend(restore_all_in(&w.home, backups, false, |n| { crate::clients::wsl::display_name(n, &w) })), Err(e) => out.push(RestoreOutcome { @@ -50,15 +66,20 @@ pub fn restore_everywhere(backups: &Path) -> Vec { ok: false, skipped: false, detail: crate::core_text::text(&e), + warning: None, + id: String::new(), + here: false, }), } } out } +/// `here`:这台电脑上的(不是某个 WSL 发行版里的) fn restore_all_in( home: &Path, backups: &Path, + here: bool, name: impl Fn(&str) -> String, ) -> Vec { use crate::clients::ops; @@ -72,6 +93,9 @@ fn restore_all_in( Ok(_) => tr!("已还原", "Restored").to_string(), Err(e) => crate::core_text::text(&e), }, + warning: None, + id: c.id.to_string(), + here, } }); let skipped = ops::adopted_elsewhere(home, backups) @@ -85,6 +109,9 @@ fn restore_all_in( "Connected by another ThinkWatch Lite. Restore it from the ThinkWatch Lite that connected it" ) .to_string(), + warning: None, + id: c.id.to_string(), + here, }); restored.chain(skipped).collect() } @@ -97,6 +124,14 @@ pub struct RestoreOutcome { /// 也就谈不上还原不了 skipped: bool, detail: String, + /// 还原了、但该说一声的事:Claude Desktop 接管时加的那条路由规则没删成 + #[serde(skip_serializing_if = "Option::is_none")] + warning: Option, + /// 客户端的 id、是不是这台电脑上的:只在这一侧用 + #[serde(skip)] + id: String, + #[serde(skip)] + here: bool, } /// 还原的结果写成卸载的步骤,一个客户端一行。第二个值:这一份接管的是不是全部 @@ -105,12 +140,19 @@ pub fn restore_steps(restored: Vec) -> (Vec, bool let all = restored.iter().all(|r| r.ok); let log = restored .into_iter() - .map(|r| UninstallStep { - ok: r.ok, - text: tr!( - format!("{}:{}", r.client, r.detail), - format!("{}: {}", r.client, r.detail) - ), + .map(|r| { + // 还原了、但还有一句要说的:接在后面 + let detail = match &r.warning { + Some(w) => w.clone(), + None => r.detail.clone(), + }; + UninstallStep { + ok: r.ok, + text: tr!( + format!("{}:{}", r.client, detail), + format!("{}: {}", r.client, detail) + ), + } }) .collect(); (log, all) @@ -281,7 +323,7 @@ pub fn last_line() -> String { #[tauri::command] pub async fn uninstall(app: tauri::AppHandle, drop_data: bool) -> Out> { use tauri::Manager; - let (mut log, restored_all) = restore_steps(restore_all().await?); + let (mut log, restored_all) = restore_steps(restore_all(app.state()).await?); // 注销 LaunchAgent。**失败只记一句**:它不该挡住卸载,而留下一个 // 开机自启项的后果,用户在系统设置里看得见、也删得掉 #[cfg(not(windows))] @@ -370,7 +412,7 @@ mod tests { &tw_adopt::cloud::Around::default(), ) .unwrap(); - let out = restore_all_in(home.path(), &backups, |n| n.to_string()); + let out = restore_all_in(home.path(), &backups, true, |n| n.to_string()); assert_eq!(out.len(), 1); assert_eq!(out[0].client, "Claude Code"); assert!(out[0].ok, "{}", out[0].detail); @@ -403,7 +445,7 @@ mod tests { let settings = home.path().join(".claude/settings.json"); let before = std::fs::read_to_string(&settings).unwrap(); - let out = restore_all_in(home.path(), &mine, |n| n.to_string()); + let out = restore_all_in(home.path(), &mine, true, |n| n.to_string()); let got: Vec<_> = out .iter() .map(|r| (r.client.as_str(), r.ok, r.skipped)) @@ -444,6 +486,9 @@ mod tests { ok, skipped, detail: String::new(), + warning: None, + id: String::new(), + here: true, }; assert!(restore_steps(vec![r(true, false), r(true, true)]).1); let (log, all) = restore_steps(vec![r(true, false), r(false, false)]); diff --git a/src-tauri/src/wire.rs b/src-tauri/src/wire.rs index 376dd4b0..09cfa2af 100644 --- a/src-tauri/src/wire.rs +++ b/src-tauri/src/wire.rs @@ -95,6 +95,10 @@ pub struct DetectedClient { /// 只有把模型写进配置的客户端(opencode、Pi、oh-my-pi、Grok Build、Qwen Code)会是 /// `true`;点一下走一遍接管的「差异 → 确认 → 写入」重写它,**不在后台悄悄改** pub models_stale: bool, + /// 配置里此刻写着的模型。只有把模型写进配置的客户端(opencode、Pi、oh-my-pi、 + /// Grok Build、Qwen Code)有;删除别名时据此说出哪几个已接管客户端的模型列表写着它 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub models: Option>, /// 配置位置能换(行菜单里给「更改路径…」,见 [`ClientLocations`])。Claude Desktop、 /// DeepSeek Harness 和 WSL 里的不能 pub movable: bool, @@ -293,6 +297,50 @@ pub struct PlanView { /// 「新建上游」的入口;还原时没有 #[serde(default, skip_serializing_if = "Option::is_none")] pub bedrock: Option, + /// 接管、还原 Claude Desktop 时网关上要改的那条路由规则(`clients::desktop_rule`)。 + /// 不用改的、别的客户端没有 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub desktop_rule: Option, +} + +/// 接管 Claude Desktop 时在它的密钥上加的那条「指定模型」规则。 +/// +/// 网关列出的模型它一个都不收时(它只认名称像 Claude 的),接管时选一个上游模型, +/// 这条规则把它的请求都发给那个模型:插在它的密钥所用路由的最前面,条件只有它的 +/// 密钥。取消接管时删掉。别的客户端不受影响。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +pub struct DesktopRule { + /// 规则在(或者要加进)哪条路由 + pub route: String, + /// 是那条路由的第几条,从 1 起 + pub position: u32, + /// 规则的名字 + pub name: String, + /// 条件里的密钥:它的那把 + pub key: String, + /// 要选模型(网关没有它认的模型)时:写进它配置的名称和可选的模型。没有就是 + /// 不用选 —— 这时已有的那条规则要删掉(还原,或者网关已经有它认的模型了) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pick: Option, + /// 已经有这条规则时,它此刻指定的模型 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current: Option>, +} + +/// 给 Claude Desktop 选模型:写进它配置的那个名称,和它的密钥能用的上游模型 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +pub struct DesktopPick { + /// 写进它配置的模型名(它发来的就是这个) + pub written: String, + /// 可选的模型,按网关列出的顺序。选一个,规则按顺序指定提供它的每一家上游 + pub choices: Vec, +} + +/// 一个上游模型,和提供它的上游(按网关排的顺序) +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +pub struct ModelChoice { + pub model: String, + pub providers: Vec, } /// 按客户端原来直连 Bedrock 时的设置新建 Bedrock 上游,要填的那几项。 @@ -803,3 +851,29 @@ pub enum PluginWrite { Done { version: String }, Cancelled, } + +// ---------------------------------------------------------- 模型别名:名称提示 + +/// 新建、编辑模型别名时,名称下面的一条提示(`alias_hints`)。说的是**这台机器上检测到的 +/// 客户端**会怎么对待这个名称,所以由这一侧判断(`crate::aliases`)。只给码和参数,句子在界面 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(tag = "code", rename_all = "snake_case")] +pub enum AliasHint { + /// 名称是 Claude Code 自己的档位名(`sonnet`、`opus`、`opusplan`……):它请求之前先换成 + /// 完整的模型名,它的请求里不会出现这个名称 + ClaudeCodeReserved { name: String }, + /// 名称像 `family` 这一家的模型,列出的上游模型却是别家的(`model` 是其中第一个)。 + /// `clients`:接管着的、按模型名挑请求格式而这个名称和 `model` 格式不同的客户端(产品名) + FamilyMismatch { + family: String, + model: String, + clients: Vec, + }, + /// 接管着的 Claude Desktop 只列出名称像 Claude 的模型:不会显示这个名称 + ClaudeDesktopHidden { name: String }, + /// 接管着的 Claude Desktop 会在模型列表里显示这个名称 + ClaudeDesktopShown { name: String }, + /// 这几个接管着的客户端把网关的模型列表写进了自己的配置(产品名):多了、改了一个名称, + /// 客户端页会提示更新它们的模型列表 + ModelListsUpdate { clients: Vec }, +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4f2562e9..121160ad 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ThinkWatch Lite", - "version": "2026.10.4", + "version": "2026.10.5", "identifier": "app.thinkwatch.lite", "build": { "beforeDevCommand": "pnpm dev", diff --git a/src-tauri/tests/ts_bindings.rs b/src-tauri/tests/ts_bindings.rs index 0ae703c1..8b4486d8 100644 --- a/src-tauri/tests/ts_bindings.rs +++ b/src-tauri/tests/ts_bindings.rs @@ -133,6 +133,7 @@ fn lite_typescript() -> String { c.root::(); c.root::(); c.root::(); + c.root::(); // **契约里已经有的名字从那边引用**(`Msg`,以及 core 还在发的同名同形的类型), // 不另写一份:两份同名的声明在 `types.ts` 里一起转出去是歧义 diff --git a/src/RequestDrawer.i18n.tsx b/src/RequestDrawer.i18n.tsx index 46cb62bb..948e22a6 100644 --- a/src/RequestDrawer.i18n.tsx +++ b/src/RequestDrawer.i18n.tsx @@ -80,6 +80,12 @@ export const requestDrawerText = messages( /** 命中规则后面的标记:决定去向的这一条就是拒绝 */ denied: "拒绝", viaGroup: "经过策略组", + /** 客户端写的是别名:别名的名称,后面一句说它怎么对到各家 */ + alias: "别名", + aliasNote: "按上游换成对应的模型名", + /** 决定去向的规则用了「指定模型」 */ + pinnedModel: "指定模型", + pinnedBy: (rule: string) => `由规则「${rule}」指定,模型名原样发出`, /** 改写了参数的规则,按求值的顺序 */ rewrittenBy: "参数改写", /** 这段对话之前的去向起的作用 */ @@ -92,7 +98,13 @@ export const requestDrawerText = messages( /** 规则写的拒绝理由,或者选中的上游为何都无法服务 */ reason: "原因", attempts: "尝试链", + // 尝试链里一跳发出的模型名和客户端写的不同:悬停按原因说 sentModel: (model: string) => `规则改写了模型名:这一跳发给上游的是 ${model},费用按它计算`, + sentByAlias: (upstream: string, model: string) => `别名:这一跳发给 ${upstream} 的是 ${model},费用按它计算`, + sentPinned: (upstream: string, model: string) => `指定模型:这一跳发给 ${upstream} 的是 ${model},费用按它计算`, + sentByPlugin: (model: string) => `插件改写了模型名:这一跳发给上游的是 ${model},费用按它计算`, + /** 原因对不上现在的配置(规则、别名在这次请求之后改过):只说事实 */ + sentOther: (model: string) => `这一跳发给上游的是 ${model},费用按它计算`, failover: (failed: number) => `已发生故障转移:前 ${failed} 个上游失败,已自动切换至下一个上游。`, failoverDenied: (failed: number, rule: string) => @@ -221,6 +233,10 @@ export const requestDrawerText = messages( matchedRule: "Matched rule", denied: "Denied", viaGroup: "Via group", + alias: "Alias", + aliasNote: "Resolved to each upstream's own model name", + pinnedModel: "Pinned model", + pinnedBy: (rule: string) => `Set by rule “${rule}”; the model name is sent as is`, rewrittenBy: "Rewritten by", continuity: "Conversation", heldRoute: "Kept the route decided at the start of this turn", @@ -230,6 +246,12 @@ export const requestDrawerText = messages( reason: "Reason", attempts: "Attempts", sentModel: (model: string) => `A rule rewrote the model: this attempt sent ${model}, and the cost is priced by it`, + sentByAlias: (upstream: string, model: string) => + `Alias: this attempt sent ${model} to ${upstream}, and the cost is priced by it`, + sentPinned: (upstream: string, model: string) => + `Pinned model: this attempt sent ${model} to ${upstream}, and the cost is priced by it`, + sentByPlugin: (model: string) => `A plugin rewrote the model: this attempt sent ${model}, and the cost is priced by it`, + sentOther: (model: string) => `This attempt sent ${model}, and the cost is priced by it`, failover: (failed: number) => failed === 1 ? "Failover occurred: the first upstream failed, and the request was switched to the next upstream automatically." diff --git a/src/RequestDrawer.tsx b/src/RequestDrawer.tsx index 99862897..888439a8 100644 --- a/src/RequestDrawer.tsx +++ b/src/RequestDrawer.tsx @@ -22,7 +22,9 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/ui/tabs"; import { Tip } from "@/ui/tip"; import { Elapsed, NotSentIcon } from "@/traffic/cells"; import { PanelHeader, PanelHeaderSkeleton, PanelSkeleton } from "@/traffic/PanelHeader"; +import { routingModels, type HopModel } from "@/traffic/modelVia"; import { missingWhy } from "@/traffic/transcript"; +import { useViaConfig } from "@/traffic/useModelVia"; import { useNow } from "@/useNow"; import { KeyLabel } from "./KeyLabel"; import { @@ -245,7 +247,7 @@ function Detail({ id, onClose }: { id: number; onClose: () => void }) { - +
@@ -615,10 +617,14 @@ function CostText({ r, running, short }: { r: HistoryRow; running: boolean; shor * 路由:走的哪条路由、哪条规则决定了去向、经过的策略组、改写了参数的规则、拒绝了它的 * 规则和理由,然后是尝试链。全是 core 记下的(`RoutingView` 和失败的那一句),排法在 * `routingFacts`。 + * + * 别名、指定模型两行和尝试链里每一跳的模型名为什么是那样,是拿记录对着现在的别名表和路由 + * 看出来的(`routingModels`):对不上就不写那一行,悬停只说发出的是什么。 */ -function Routing({ r, running }: { r: HistoryRow; running: boolean }) { +function Routing({ r, plugins, running }: { r: HistoryRow; plugins: PluginRunView[]; running: boolean }) { const t = useText(requestDrawerText); const f = routingFacts(r, running); + const m = routingModels(r, plugins, useViaConfig()); // 只有本地应答的没有:它没到规则那一层 if (!f) return

{t.noRouting}

; const note = f.note && noteText(f.note, t); @@ -638,6 +644,19 @@ function Routing({ r, running }: { r: HistoryRow; running: boolean }) { } /> {f.group && } + {/* 模型那一列写的是客户端的名称:是别名、或者规则指定了模型,这里说一声 */} + {m.alias && ( + + {m.alias} + {t.aliasNote} + + } + /> + )} + {m.pinnedBy && } {/* 按求值的顺序:先是选定上游之前的,再是每一跳之后的 */} {f.rewrittenBy.length > 0 && } {f.continuity && ( @@ -675,14 +694,9 @@ function Routing({ r, running }: { r: HistoryRow; running: boolean }) { {a.provider} - {/* 规则改写过模型名:这一跳发出去的是这个,费用也按它算 */} - {a.model && ( - - - {a.model} - - - )} + {/* 这一跳发出的模型名:有一跳改了名、或者用了别名、指定模型时每一跳都写, + 费用也按它算 */} + {m.show && m.hops[i] && } {denied ? ( // 选定上游之后的规则在这一跳拒绝了它:没有发给这个上游,不是上游的失败 @@ -712,6 +726,31 @@ function Routing({ r, running }: { r: HistoryRow; running: boolean }) { ); } +/** + * 尝试链里一跳发出的模型名。和客户端写的不同时带虚线下划线,悬停按原因说(别名、规则改名、 + * 指定模型、插件);原因对不上现在的配置时只说发出的是什么。 + */ +function SentModel({ hop, provider }: { hop: HopModel; provider: string }) { + const t = useText(requestDrawerText); + const cls = "min-w-0 max-w-[40%] shrink truncate font-mono tw-label text-muted-foreground"; + if (!hop.changed) return {hop.sent}; + const tip = + hop.via === "alias" + ? t.sentByAlias(provider, hop.sent) + : hop.via === "pinned" + ? t.sentPinned(provider, hop.sent) + : hop.via === "rule" + ? t.sentModel(hop.sent) + : hop.via === "plugin" + ? t.sentByPlugin(hop.sent) + : t.sentOther(hop.sent); + return ( + + {hop.sent} + + ); +} + /** 拒绝:禁止符号加字,红色。和路由图上「拒绝」那个节点同一个图形 */ function Denied({ children, className }: { children: ReactNode; className?: string }) { return ( diff --git a/src/aliases/AliasDialog.i18n.ts b/src/aliases/AliasDialog.i18n.ts new file mode 100644 index 00000000..fa8910c1 --- /dev/null +++ b/src/aliases/AliasDialog.i18n.ts @@ -0,0 +1,131 @@ +import { messages } from "@/i18n"; + +export const aliasDialogText = messages( + { + createTitle: "新建别名", + editTitle: (name: string) => `编辑别名 ${name}`, + desc: "给模型起一个客户端用的名称。原来的名称照常可用。", + loading: "正在读取别名", + notFound: (name: string) => `别名 ${name} 已不在配置里。`, + + name: "名称", + nameHint: "客户端用这个名称请求。", + namePlaceholder: "例如 claude-sonnet-5", + /** 名称恰好是某个上游的模型名,自动列成了第一个上游模型 */ + prefilled: (providers: string, name: string) => `${providers} 也有名为 ${name} 的模型,已列为第一个上游模型。`, + /** 名称恰好是没列进来的那些上游的真模型:别名优先,它们不再收到这个名称 */ + shadows: (providers: string, name: string, models: string, creating: boolean) => + `${providers} 有名为 ${name} 的模型。${creating ? "建这个别名后" : "保存后"},所有客户端用 ${name} 请求都会改发给 ${models},不再发给 ${providers}。只想让某个客户端这样用,请在路由里给它的密钥写一条规则,转发至选「指定模型」。`, + writeRule: "改为写路由规则…", + claudeCodeReserved: (name: string) => + `Claude Code 把 ${name} 当作自己的档位名,请求前换成完整的模型名,它的请求用不到这个别名。`, + familyMismatch: (family: string, model: string, clients: string) => + `名称像 ${family} 模型,上游模型却是 ${model}:${clients} 会按 ${family} 模型的格式发出这个名称的请求,由网关转换后发给上游。`, + desktopHidden: (name: string) => `Claude Desktop 只列出名称像 Claude 的模型,不会显示 ${name}。`, + desktopShown: (name: string) => `Claude Desktop 会在模型列表里显示 ${name}。`, + /** 改名前:哪些引用会一起改 */ + renameRefs: (old: string, refs: string) => `改名后,${refs}里的 ${old} 一起改为新名称。`, + keysRef: (keys: string) => `密钥 ${keys} 的可见范围`, + rulesRef: (rules: string) => `规则 ${rules}`, + refsJoin: "和", + + models: "上游模型", + modelsPlaceholder: "从上游的模型里选,或直接输入", + enterToAdd: "回车添加", + removeShort: "删除", + remove: (m: string) => `删除 ${m}`, + unserved: "没有上游提供", + modelsWhat: "同一模型在各上游的名称。上游有其中任一名称,就由它提供;可添加多个。", + modelsUnserved: (n: number) => + n === 1 ? "标黄的模型没有上游提供,请求不会发往它。" : `标黄的 ${n} 个模型没有上游提供,请求不会发往它们。`, + + sameModel: "其他上游的同一模型", + sameModelWhat: "按名称认出的,确认是同一个模型再勾选。", + + sentTo: "发往各上游", + sentHead: (name: string) => `客户端请求 ${name} 时`, + sentHeadUnnamed: "客户端请求这个别名时", + notServing: "(不提供,勾选上面一项后提供)", + noLongerReceives: (name: string) => `(不再收到 ${name} 的请求)`, + sentNone: "列出的模型没有上游提供。", + sentEmpty: "添加上游模型后,这里列出各上游收到的模型名。", + sentWhat: "请求发往哪个上游由路由决定。", + previewFailed: (reason: string) => `无法预览:${reason}`, + + modelListsUpdate: (clients: string, creating: boolean) => + `${creating ? "创建后" : "保存后"},已接管的 ${clients} 会提示更新模型列表`, + needName: "填写名称", + needModels: "添加上游模型", + needOtherName: "添加这个模型在其他上游的名称", + create: "创建", + + renamed: (old: string, name: string) => `已将别名 ${old} 改名为 ${name}`, + renamedIn: (refs: string) => `${refs}里的名称已一起更新。`, + + sep: "、", + }, + { + createTitle: "New alias", + editTitle: (name: string) => `Edit alias ${name}`, + desc: "Give a model a name for clients to use. The original names keep working.", + loading: "Loading the alias", + notFound: (name: string) => `Alias ${name} is no longer in the config.`, + + name: "Name", + nameHint: "Clients request this name.", + namePlaceholder: "e.g. claude-sonnet-5", + prefilled: (providers: string, name: string) => + `${providers} also has a model named ${name}; it is listed as the first upstream model.`, + shadows: (providers: string, name: string, models: string, creating: boolean) => + `${providers} has a model named ${name}. ${creating ? "Once this alias exists" : "Once saved"}, every client requesting ${name} goes to ${models} instead, and ${providers} no longer receives it. To do this for one client only, write a rule for its key in Routing and forward to “Specific models”.`, + writeRule: "Write a routing rule instead…", + claudeCodeReserved: (name: string) => + `Claude Code treats ${name} as its own tier name and swaps in the full model name before requesting, so its requests never use this alias.`, + familyMismatch: (family: string, model: string, clients: string) => + `The name looks like a ${family} model but the upstream model is ${model}: ${clients} will send requests for it in the ${family} format, and the gateway converts them for the upstream.`, + desktopHidden: (name: string) => `Claude Desktop lists only models whose names look like Claude, so it will not show ${name}.`, + desktopShown: (name: string) => `Claude Desktop will show ${name} in its model list.`, + renameRefs: (old: string, refs: string) => `Renaming also changes ${old} to the new name in ${refs}.`, + keysRef: (keys: string) => `the visible models of key ${keys}`, + rulesRef: (rules: string) => `rule ${rules}`, + refsJoin: " and ", + + models: "Upstream models", + modelsPlaceholder: "Pick from upstream models, or type a name", + enterToAdd: "Enter to add", + removeShort: "Remove", + remove: (m: string) => `Remove ${m}`, + unserved: "No upstream serves it", + modelsWhat: + "The names of the same model on different upstreams. An upstream that has any of them serves the alias; add as many as needed.", + modelsUnserved: (n: number) => + n === 1 + ? "No upstream serves the model marked in yellow; no request goes to it." + : `No upstream serves the ${n} models marked in yellow; no request goes to them.`, + + sameModel: "The same model on other upstreams", + sameModelWhat: "Recognised by name. Check one after confirming it is the same model.", + + sentTo: "Sent to each upstream", + sentHead: (name: string) => `When a client requests ${name}`, + sentHeadUnnamed: "When a client requests this alias", + notServing: "(not served; check the entry above to serve it)", + noLongerReceives: (name: string) => `(no longer receives requests for ${name})`, + sentNone: "No upstream serves the listed models.", + sentEmpty: "Once upstream models are added, the name each upstream receives is listed here.", + sentWhat: "Routing decides which upstream a request goes to.", + previewFailed: (reason: string) => `Preview unavailable: ${reason}`, + + modelListsUpdate: (clients: string, creating: boolean) => + `${creating ? "Once created" : "Once saved"}, the connected ${clients} will be offered a model list update`, + needName: "Enter a name", + needModels: "Add an upstream model", + needOtherName: "Add this model's name on other upstreams", + create: "Create", + + renamed: (old: string, name: string) => `Alias ${old} renamed to ${name}`, + renamedIn: (refs: string) => `Updated in ${refs} as well.`, + + sep: ", ", + }, +); diff --git a/src/aliases/AliasDialog.tsx b/src/aliases/AliasDialog.tsx new file mode 100644 index 00000000..2b31c7e7 --- /dev/null +++ b/src/aliases/AliasDialog.tsx @@ -0,0 +1,698 @@ +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { CircleAlertIcon, InfoIcon, TriangleAlertIcon } from "lucide-react"; +import { call } from "@/control"; +import { invalidate, useResource } from "@/lib/resource"; +import { latestOnly } from "@/lib/latestOnly"; +import { cn } from "@/lib/utils"; +import { useNav } from "@/nav"; +import { Button } from "@/ui/button"; +import { Checkbox } from "@/ui/checkbox"; +import { Autocomplete, ComboboxBareInput, ComboboxContent, ComboboxItem, ComboboxList } from "@/ui/combobox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/ui/dialog"; +import { Input } from "@/ui/input"; +import { rowMotion, usePresentList } from "@/ui/motion"; +import { notify } from "@/ui/notify"; +import { Skeleton } from "@/ui/skeleton"; +import { useText } from "@/i18n"; +import { commonText } from "@/i18n/common.i18n"; +import { coreText, errorText } from "@/i18n/core.i18n"; +import type { AliasHint, AliasInput, AliasPreview, AliasSave, AliasUsage, KnownModel, PinnedModel } from "@/types"; +import { Boxed, DialogError, FormItem } from "@/upstreams/parts"; +import { aliasDialogText } from "./AliasDialog.i18n"; +import { api } from "./api"; +import { ONLY_ITSELF, draftOf, onlyAuto, shownProblems, withModel, withName, withoutModel, type Draft } from "./draft"; + + +/** 输入停下来这么久再问 core 和 Rust */ +const PREVIEW_DELAY_MS = 250; + +/** 建议列表最多列几条 */ +const MAX_SUGGESTIONS = 50; + +/** + * 新建、编辑一个模型别名。 + * + * 从上往下:名称(只在有问题时下面出提示)、上游模型(一个名称一行,右边写由哪几个上游 + * 提供)、其他上游的同一模型(认得出时才有,勾选才加入)、发往各上游(只读预览,每个上游 + * 实际收到的模型名)。 + * + * **判断都在 core**:挡保存的问题、谁能服务、谁不再收到这个名称、哪些是同一个模型,都来自 + * `POST /aliases/preview`,草稿一变就问一次。和这台机器上的客户端有关的几条(Claude Code 的 + * 档位名、名称像别家模型、Claude Desktop 显示不显示)来自 Rust 的 `alias_hints`。 + * + * 红字是 core 说的问题,不能保存;黄字是要留意的,可以保存。 + * + * 挂载方:别名标签(新建、编辑),上游模型弹窗(「起别名…」,`initial.models` 是那个模型)。 + */ +export function AliasDialog(props: { + open: boolean; + onOpenChange(open: boolean): void; + /** 编辑哪个别名(原名)。不给就是新建 */ + editing?: string; + /** 新建时预填。编辑时不用给:模型按原名从 core 读 */ + initial?: Partial; + onSaved?(): void; +}) { + const { open, onOpenChange, editing } = props; + return ( + + {/* 点到对话框外面不关:填了一半的表单不该因为一次误点丢掉。Esc、×、取消照常 */} + e.preventDefault()} + onOpenAutoFocus={(e) => { + // 编辑时焦点放在对话框上,不直接进名称:名称通常不改。新建时进名称 + if (editing == null) return; + e.preventDefault(); + (e.currentTarget as HTMLElement | null)?.focus(); + }} + > + {/* 内容只在开着时挂上:每次打开都从头填 */} + onOpenChange(false)} /> + + + ); +} + +function Body({ + editing, + initial, + onSaved, + onClose, +}: { + editing?: string; + initial?: Partial; + onSaved?(): void; + onClose: () => void; +}) { + const t = useText(aliasDialogText); + const c = useText(commonText); + const nav = useNav(); + const creating = editing == null; + + const known = useResource("known-models", () => call("KnownModels", null), { + events: ["models_changed", "config_reloaded"], + }); + const catalog = useMemo(() => known.data ?? [], [known.data]); + /** 上游的真模型(不是别名):名称 → 提供它的上游 */ + const offered = useMemo(() => { + const m = new Map(); + for (const k of catalog) if (!k.alias) m.set(k.id, k.providers); + return m; + }, [catalog]); + + const [draft, setDraft] = useState(() => draftOf(initial?.name ?? editing ?? "", initial?.models ?? [])); + const { name, models, auto } = draft; + /** 编辑时从 core 读到的模型和各自的上游。读到之前是 `null` */ + const [loaded, setLoaded] = useState | null>(() => + editing != null && initial?.models === undefined ? null : new Map(), + ); + const [loadError, setLoadError] = useState(null); + /** 打开时的配置版本,保存时带它(见 KeyDialog 的 `base`) */ + const [base, setBase] = useState(undefined); + const [preview, setPreview] = useState<{ key: string; p: AliasPreview } | null>(null); + const [previewError, setPreviewError] = useState(null); + const [hints, setHints] = useState([]); + const [usage, setUsage] = useState(null); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + api + .configVersion() + .then(setBase) + .catch(() => {}); + }, []); + + // 编辑:按原名读出列着的模型 + useEffect(() => { + if (editing == null || initial?.models !== undefined) return; + let live = true; + api + .aliases() + .then((v) => { + if (!live) return; + const a = v.aliases.find((x) => x.name === editing); + if (!a) { + setLoadError(t.notFound(editing)); + return; + } + setDraft((d) => ({ ...d, models: a.models.map((m) => m.model) })); + setLoaded(new Map(a.models.map((m) => [m.model, m.providers]))); + }) + .catch((e) => live && setLoadError(errorText(e))); + return () => { + live = false; + }; + }, [editing, initial?.models]); + + const trimmed = name.trim(); + const renaming = editing != null && trimmed !== "" && trimmed !== editing; + + // 改名:先读出在用旧名的地方,保存前说清哪些会一起改 + useEffect(() => { + if (!renaming || usage || editing == null) return; + let live = true; + api + .aliasUsage(editing) + .then((u) => live && setUsage(u)) + .catch(() => {}); + return () => { + live = false; + }; + }, [renaming, usage, editing]); + + // 草稿一变:问 core 预览、问 Rust 名称提示。**只认最后一次**:回来时草稿可能又变了 + const draftKey = JSON.stringify([trimmed, models]); + const previewSeq = useRef(latestOnly()); + const hintSeq = useRef(latestOnly()); + useEffect(() => { + if (loaded === null) return; + const timer = setTimeout(() => { + const fresh = hintSeq.current.start(); + api + .hints(trimmed, models) + .then((h) => fresh() && setHints(h)) + .catch(() => fresh() && setHints([])); + if (trimmed === "" && models.length === 0) { + previewSeq.current.drop(); + setPreview(null); + setPreviewError(null); + return; + } + const current = previewSeq.current.start(); + const key = draftKey; + api + .previewAlias({ alias: { name: trimmed, models }, original: editing }) + .then((p) => { + if (!current()) return; + setPreview({ key, p }); + setPreviewError(null); + }) + .catch((e) => { + if (!current()) return; + setPreviewError(errorText(e)); + }); + }, PREVIEW_DELAY_MS); + return () => clearTimeout(timer); + // draftKey 包含 trimmed 和 models + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [draftKey, loaded === null]); + + /** 由哪几个上游提供:上游清单里的,编辑时再用 core 读到的补上 */ + const providersOf = (m: string): string[] => offered.get(m) ?? loaded?.get(m) ?? []; + const sep = t.sep; + + const changeName = (v: string) => setDraft((d) => withName(d, v, (m) => offered.has(m), creating)); + const add = (m: string) => setDraft((d) => withModel(d, m)); + const remove = (m: string) => setDraft((d) => withoutModel(d, m)); + + const p = preview?.p ?? null; + const fresh = preview?.key === draftKey; + const problems = shownProblems(p?.problems ?? [], draft); + const onlyItself = onlyAuto(draft) && fresh && (p?.problems ?? []).some((m) => m.code === ONLY_ITSELF); + const shadows = trimmed && p ? p.shadows : []; + const unserved = new Set(p ? p.unserved : models.filter((m) => known.data && !offered.has(m))); + const sameModel = (p?.same_model ?? []).filter((s) => !models.includes(s.model)); + const listing = hints.find((h) => h.code === "model_lists_update"); + + // 读不到要编辑的别名时,报错就在模型列表下面,页脚不再说「正在读取」 + const loading = loaded === null && loadError === null; + const missing = loadError + ? null + : loading + ? t.loading + : trimmed === "" + ? t.needName + : models.length === 0 + ? t.needModels + : onlyItself + ? t.needOtherName + : null; + const blocked = missing !== null || loadError !== null || (fresh && problems.length > 0); + + /** 建这个别名之后,名称改发给哪些模型(各上游实际收到的,没有就是列出的) */ + const sentModels = unique((p?.served_by ?? []).map((s) => s.model)); + + async function save() { + setSaving(true); + setError(null); + const body: AliasSave = { alias: { name: trimmed, models }, base_version: base }; + try { + if (editing != null) { + const w = await api.updateAlias(editing, body); + const refs = refsText(w.renamed_in, t); + if (trimmed !== editing && refs) notify.success(t.renamed(editing, trimmed), t.renamedIn(refs)); + } else { + await api.createAlias(body); + } + // 列着别名的几处(别名标签、模型建议、上游模型弹窗的别名标记) + invalidate("aliases"); + invalidate("known-models"); + invalidate("upstream-models:"); + onSaved?.(); + onClose(); + } catch (e) { + setError(errorText(e)); + setSaving(false); + } + } + + const renameRefs = renaming && usage ? refsText({ ...usage, requests_24h: 0 }, t) : ""; + + return ( + <> + + {creating ? t.createTitle : t.editTitle(editing ?? "")} + {t.desc} + + +
+ + 0 || undefined} + onChange={(e) => changeName(e.target.value)} + /> + {(problems.length > 0 || shadows.length > 0 || hints.length > 0 || auto || renameRefs) && ( +
+ {problems.map((m) => ( + + {coreText(m)} + + ))} + {auto && models.includes(auto) && ( + {t.prefilled(providersOf(auto).join(sep) || "—", auto)} + )} + {shadows.length > 0 && ( + + {t.shadows( + shadows.join(sep), + trimmed, + (sentModels.length > 0 ? sentModels : models).join(sep), + creating, + )} + + )} + {hints.map((h) => { + const text = hintText(h, t); + if (!text) return null; + return ( + + {text} + + ); + })} + {renameRefs && {t.renameRefs(editing ?? "", renameRefs)}} +
+ )} + {shadows.length > 0 && ( +
+ +
+ )} +
+ + + !k.alias && !models.includes(k.id))} + loading={loading} + onAdd={add} + onRemove={remove} + /> + {loadError ? ( +

{loadError}

+ ) : ( +

0 ? "text-warning" : "text-muted-foreground")}> + {unserved.size > 0 ? t.modelsUnserved(unserved.size) : t.modelsWhat} +

+ )} +
+ + {sameModel.length > 0 && ( + + + {sameModel.map((s) => ( + + ))} + + + )} + + + + +
+ + + + + {missing ? ( + {missing} + ) : listing && listing.code === "model_lists_update" && (creating || renaming) ? ( + + {t.modelListsUpdate(listing.clients.join(sep), creating)} + + ) : null} + + + + + ); +} + +type DialogText = (typeof aliasDialogText)["zh"]; + +/** Rust 给的一条名称提示,写成句子。列进页脚的那一条不在这里说 */ +function hintText(h: AliasHint, t: DialogText): string | null { + switch (h.code) { + case "claude_code_reserved": + return t.claudeCodeReserved(h.name); + case "family_mismatch": + return t.familyMismatch(h.family, h.model, h.clients.join(t.sep)); + case "claude_desktop_hidden": + return t.desktopHidden(h.name); + case "claude_desktop_shown": + return t.desktopShown(h.name); + case "model_lists_update": + return null; + } +} + +/** 改名会一起改掉的引用,写成一段(「密钥 a、b 的可见范围和规则 default / x」)。没有就是空 */ +function refsText(u: AliasUsage, t: DialogText): string { + const parts: string[] = []; + if (u.keys.length > 0) parts.push(t.keysRef(u.keys.join(t.sep))); + if (u.rules.length > 0) parts.push(t.rulesRef(unique(u.rules.map((r) => `${r.route} / ${r.rule}`)).join(t.sep))); + return parts.join(t.refsJoin); +} + +function unique(xs: string[]): string[] { + return [...new Set(xs)]; +} + +/** 名称下面的一行提示:红是挡保存的问题,黄是要留意的,灰是告知 */ +function Line({ tone, children }: { tone: "error" | "warning" | "info"; children: ReactNode }) { + const Icon = tone === "error" ? CircleAlertIcon : tone === "warning" ? TriangleAlertIcon : InfoIcon; + return ( +

+ + {children} +

+ ); +} + +/** + * 上游模型:一个名称一行,右边写由哪几个上游提供,没有上游提供的标黄;最后一行输入新的 + * (和密钥的通配规则、放行网段同一个样子)。删一条写成字,不用 ×。 + */ +function ModelList({ + models, + providersOf, + unserved, + candidates, + loading, + onAdd, + onRemove, +}: { + models: string[]; + providersOf: (m: string) => string[]; + unserved: Set; + candidates: KnownModel[]; + loading: boolean; + onAdd: (m: string) => void; + onRemove: (m: string) => void; +}) { + const t = useText(aliasDialogText); + const rows = usePresentList(models, (m) => m); + return ( + + {loading && ( +
+ +
+ )} + {rows.map(({ item: m, key, presence }) => { + const flagged = unserved.has(m); + const by = providersOf(m); + return ( +
+ + {m} + + + {flagged ? t.unserved : by.join(t.sep)} + + +
+ ); + })} + +
+ ); +} + +/** + * 名单的最后一行:**自由输入 + 建议**。建议是各上游清单里的模型,右边写由哪几个上游提供 + * (和插件适用范围的 `PatternInput` 一样)。清单里没有的名称照样能加:上游清单可能还没取到。 + * + * 所以是 Autocomplete,不是 Combobox:点一条建议、或者用方向键选中再回车,直接加进名单; + * 没选中建议时回车加的是输入的原样。 + */ +function ModelInput({ + candidates, + onAdd, + disabled, +}: { + candidates: KnownModel[]; + onAdd: (m: string) => void; + disabled: boolean; +}) { + const t = useText(aliasDialogText); + const [draft, setDraft] = useState(""); + // **建议列表挂进所在的对话框,不挂在 body 上**:Radix 的模态对话框把 body 设成 + // pointer-events: none,挂在 body 上的列表看得见、点不中(见路由的 `ModelInput`) + const anchor = useRef(null); + const [container, setContainer] = useState(null); + useLayoutEffect(() => { + setContainer(anchor.current?.closest('[role="dialog"]') ?? null); + }, []); + const [open, setOpen] = useState(false); + // **Esc 只收起列表**,不连同对话框(和里面没保存的改动)一起关掉 + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Escape" || e.isComposing) return; + e.stopPropagation(); + setOpen(false); + }; + window.addEventListener("keydown", onKey, true); + return () => window.removeEventListener("keydown", onKey, true); + }, [open]); + /** 方向键选中了一条建议:回车交给列表(加那一条),不是加输入的原样 */ + const highlighted = useRef(undefined); + const notes = useMemo(() => new Map(candidates.map((k) => [k.id, k.providers.join(t.sep)])), [candidates, t.sep]); + const items = useMemo(() => candidates.map((k) => k.id), [candidates]); + const q = draft.trim().toLowerCase(); + const shown = useMemo( + () => items.filter((v) => q === "" || v.toLowerCase().includes(q)).slice(0, MAX_SUGGESTIONS), + [items, q], + ); + + function commit(v: string) { + onAdd(v); + setDraft(""); + } + + return ( +
+ { + if (d.reason === "item-press") commit(v); + else setDraft(v); + }} + open={open && shown.length > 0} + onOpenChange={setOpen} + onItemHighlighted={(v) => { + highlighted.current = v as string | undefined; + }} + openOnInputClick + > + { + if (e.key !== "Enter" || e.nativeEvent.isComposing) return; + // 对话框会把回车当成提交 + e.preventDefault(); + if (open && highlighted.current !== undefined) return; + commit(draft); + }} + // 输了没按回车就去点别处:这一条也算上,不让它悄悄丢掉 + onBlur={() => { + if (!open) commit(draft); + }} + /> + + + {(v: string) => ( + + {v} + {notes.get(v) && {notes.get(v)}} + + )} + + + + {t.enterToAdd} +
+ ); +} + +/** + * 发往各上游:只读。每个上游实际收到的模型名(core 按列表顺序取它有的第一个);认得出、 + * 还没勾选的同一模型所在的上游写「不提供」;有同名真模型、没列进来的上游写「不再收到」。 + */ +function SentTo({ + name, + models, + preview, + error, + sameModel, + shadows, +}: { + name: string; + models: string[]; + preview: AliasPreview | null; + error: string | null; + sameModel: PinnedModel[]; + shadows: string[]; +}) { + const t = useText(aliasDialogText); + if (models.length === 0) return

{t.sentEmpty}

; + if (!preview) { + return error ? ( +

{t.previewFailed(error)}

+ ) : ( + +
+ +
+
+ + +
+
+ ); + } + const served = preview.served_by; + const pending = unique(sameModel.map((s) => s.provider)).filter((pr) => !served.some((s) => s.provider === pr)); + const lost = shadows.filter((pr) => !served.some((s) => s.provider === pr)); + const rows: { provider: string; model?: string; note?: string }[] = [ + ...served.map((s) => ({ provider: s.provider, model: s.model })), + ...pending.map((pr) => ({ provider: pr, note: t.notServing })), + ...lost.map((pr) => ({ provider: pr, note: t.noLongerReceives(name) })), + ]; + return ( +
+ +
+ {name ? t.sentHead(name) : t.sentHeadUnnamed} +
+ {rows.length === 0 ? ( +

{t.sentNone}

+ ) : ( + rows.map((r) => ( +
+ {r.provider} + {r.model ? ( + + {r.model} + + ) : ( + {r.note} + )} +
+ )) + )} +
+ {error &&

{t.previewFailed(error)}

} +
+ ); +} diff --git a/src/aliases/AliasMark.tsx b/src/aliases/AliasMark.tsx new file mode 100644 index 00000000..202257ae --- /dev/null +++ b/src/aliases/AliasMark.tsx @@ -0,0 +1,27 @@ +import type { ComponentProps } from "react"; +import { useText } from "@/i18n"; +import { cn } from "@/lib/utils"; +import { Badge } from "@/ui/badge"; +import { aliasesText } from "./aliases.i18n"; + +/** + * 「别名」小标记。名称是别名、或者请求经别名发出的地方都用这一个,长得一样:密钥的可见 + * 模型、模型输入框和它的建议、上游模型弹窗、规则条件、流量表上游那一格(同一处的「指定」 + * 也用它,两者说的都是发出的模型名为什么不是客户端写的那个)。 + * + * 不给 `children` 时写「别名」。`className` 只管位置(间距、收缩、截断),不改长相。其余 + * 属性原样交给 `Badge`:外面包 `Tip` 时,触发器的属性和 ref 要传得进来。 + */ +export function AliasMark({ children, className, ...rest }: Omit, "variant" | "asChild">) { + const t = useText(aliasesText); + return ( + + {children ?? t.alias} + + ); +} diff --git a/src/aliases/AliasTable.tsx b/src/aliases/AliasTable.tsx new file mode 100644 index 00000000..e359445b --- /dev/null +++ b/src/aliases/AliasTable.tsx @@ -0,0 +1,177 @@ +import { cn } from "@/lib/utils"; +import { useText } from "@/i18n"; +import { commonText } from "@/i18n/common.i18n"; +import { copyText } from "@/traffic/cells"; +import { usd, type ProviderView } from "@/types"; +import { Logo, upstreamGlyph } from "@/ui/logos"; +import { rowMotion, usePresentList } from "@/ui/motion"; +import { RowMenu, RowMenuButton, type MenuItems } from "@/ui/row-menu"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/ui/table"; +import { Tip } from "@/ui/tip"; +import { contextWindow } from "@/upstreams/labels"; +import { keepInRow, openRow } from "@/upstreams/parts"; +import type { AliasView } from "@/types"; +import { aliasesText } from "./aliases.i18n"; +import { aliasWarnings, modelLines, type AliasWarning, type ModelLine } from "./logic"; + +export interface AliasActions { + edit: (name: string) => void; + traffic: (name: string) => void; + remove: (name: string) => void; +} + +/** + * 别名列表。**只读**:单击一行(或 Enter)编辑,行尾按钮和右键是同一份菜单。 + * + * 「上游模型」一格每个上游一行,写发给它的名称 —— 用户要确认的正是「哪几个上游能接 + * 这个名称、各自收到什么」。有要留意的(没有上游提供、同名模型被挡住)在下面用琥珀色 + * 说一句。 + */ +export function AliasTable({ + aliases, + providers, + actions, +}: { + aliases: AliasView[]; + /** 上游的标志要用 */ + providers: Pick[]; + actions: AliasActions; +}) { + const t = useText(aliasesText); + const c = useText(commonText); + const shown = usePresentList(aliases, (a) => a.name); + return ( + + + + {t.alias} + {t.models} + {t.context} + {t.day} + + {t.actionsColumn} + + + + + {shown.map(({ item: a, key, presence }) => { + const items: MenuItems = [ + { kind: "item", label: `${c.edit}…`, onSelect: () => actions.edit(a.name) }, + { kind: "item", label: t.copyName, onSelect: () => void copyText(a.name) }, + { kind: "item", label: t.traffic, onSelect: () => actions.traffic(a.name) }, + { kind: "sep" }, + { kind: "item", label: `${c.delete}…`, onSelect: () => actions.remove(a.name), danger: true }, + ]; + return ( + + actions.edit(a.name), rowMotion(presence))}> + + {a.name} + + + + {aliasWarnings(a).map((w) => ( +

+ +

+ ))} +
+ + {a.context_window ? contextWindow(a.context_window) : ""} + + + + + + + +
+
+ ); + })} +
+
+ ); +} + +/** + * 每个上游一行:标志、上游名、发给它的名称。上游名一列对齐;名称长了折行,不截断 —— + * 窄窗口里 `us.anthropic.claude-sonnet-5-v1:0` 截掉后半截就分不出是哪一个了。 + * 没有上游提供的名称,上游那一格写「—」。 + */ +export function Lines({ + lines, + providers, + className, +}: { + lines: ModelLine[]; + providers: Pick[]; + className?: string; +}) { + const t = useText(aliasesText); + return ( +
+ {lines.map((l, i) => ( + + ))} +
+ ); +} + +function Line({ + line, + providers, + notOffered, +}: { + line: ModelLine; + providers: Pick[]; + notOffered: string; +}) { + if (line.provider === null) { + return ( + <> + + — + + {line.model} + + ); + } + const p = providers.find((x) => x.name === line.provider); + const id = upstreamGlyph({ name: line.provider, baseUrl: p?.base_url, protocol: p?.protocol }); + return ( + <> + + {id ? ( + + ) : ( + + )} + {line.provider} + + {line.model} + + ); +} + +/** 模型名:等宽、淡一档,长了在任意处折行 */ +const MODEL = "min-w-0 font-mono tw-label leading-5 break-all text-muted-foreground"; + +function WarningText({ w, name }: { w: AliasWarning; name: string }) { + const t = useText(aliasesText); + return <>{w.kind === "unserved" ? t.unserved(w.models) : t.shadowed(w.providers, name)}; +} + +/** 24 小时:次数在上、费用在下(淡一档)。一次都没有写「—」;费用取不到就只写次数 */ +function DayFigure({ a }: { a: AliasView }) { + const t = useText(aliasesText); + if (a.requests_24h === 0) return —; + return ( +
+ {t.requests(a.requests_24h)} + {a.cost_micros_24h != null && ( + {usd(a.cost_micros_24h)} + )} +
+ ); +} diff --git a/src/aliases/AliasesTab.tsx b/src/aliases/AliasesTab.tsx new file mode 100644 index 00000000..8e2e3a6d --- /dev/null +++ b/src/aliases/AliasesTab.tsx @@ -0,0 +1,158 @@ +import { useCallback, useState } from "react"; +import { PlusIcon, TagIcon } from "lucide-react"; +import { useText } from "@/i18n"; +import { useNav } from "@/nav"; +import type { ProviderView } from "@/types"; +import { Button } from "@/ui/button"; +import { Count } from "@/ui/count"; +import { EmptyState, Loadable, TableSkeleton } from "@/ui/states"; +import { useDismissedHints } from "@/guide/hints"; +import type { AliasInput, AliasSuggestion, AliasView } from "@/types"; +import { aliasesText } from "./aliases.i18n"; +import { AliasTable } from "./AliasTable"; +import { useAliases, useSuggestions } from "./data"; +import { DeleteAliasDialog } from "./DeleteAliasDialog"; +import { showTabDot, suggestionInput } from "./logic"; +import { SuggestionList, SuggestionsBanner, SuggestionsDialog } from "./Suggestions"; + +/** + * 新建或编辑别名的对话框要打开成什么样(对话框本身是 `AliasDialog.tsx`,上游页挂它)。 + * `initial`:从建议、上游模型弹窗进来时的预填。 + */ +export type AliasDialogMode = { kind: "create"; initial?: Partial } | { kind: "edit"; name: string }; + +type Providers = Pick[]; + +/** + * 上游页的「别名」标签:别名表、顶上的建议、尚无别名时直接列出建议。 + * + * 新建与编辑的对话框不在这里,交给上游页(`onCreate` / `onEdit`):上游模型弹窗里的 + * 「起别名…」打开的是同一个。同一模型不同名称的对话框和删除对话框在这里。 + */ +export function AliasesTab({ + providers, + configVersion, + onCreate, + onEdit, + onChanged, +}: { + providers: Providers; + configVersion: string; + onCreate: (initial?: Partial) => void; + onEdit: (name: string) => void; + /** 写入之后(删除)让外面重读概览 */ + onChanged: () => void; +}) { + const t = useText(aliasesText); + const nav = useNav(); + const aliases = useAliases(); + const { visible, dismiss } = useSuggestions(aliases.data); + const [dialog, setDialog] = useState(null); + const close = useCallback(() => setDialog(null), []); + const fromSuggestion = (s: AliasSuggestion) => { + setDialog(null); + onCreate(suggestionInput(s)); + }; + + return ( + <> + } + errorTitle={t.loadFailed} + isEmpty={(d) => d.aliases.length === 0} + empty={ +
+ } + title={t.empty} + description={t.emptyDesc} + className={visible.length > 0 ? "py-8" : undefined} + action={ + + } + /> + {visible.length > 0 && ( +
+

{t.emptySuggestions}

+ +
+ )} +
+ } + > + {(d) => ( +
+ setDialog({ kind: "suggestions" })} + onDismiss={dismiss} + /> + nav.open("requests", { filter: { model: name } }), + remove: (name) => { + const alias = d.aliases.find((a) => a.name === name); + if (alias) setDialog({ kind: "delete", alias }); + }, + }} + /> +
+ )} +
+ + {dialog?.kind === "suggestions" && ( + + )} + {dialog?.kind === "delete" && ( + { + setDialog(null); + void aliases.reload(); + onChanged(); + }} + /> + )} + + ); +} + +/** + * 标签名上的两样:别名的个数(读到了、而且有才写),和有没点过「忽略」的建议时的小圆点 + * (看着这个标签时不画)。 + */ +export function AliasTabLabel({ active }: { active: boolean }) { + const t = useText(aliasesText); + const aliases = useAliases(); + const dismissed = useDismissedHints(); + const n = aliases.data?.aliases.length ?? 0; + const dot = showTabDot(aliases.data?.suggestions, dismissed, active); + return ( + <> + {t.tab} + {n > 0 && } + {dot && } + + ); +} diff --git a/src/aliases/DeleteAliasDialog.tsx b/src/aliases/DeleteAliasDialog.tsx new file mode 100644 index 00000000..fe33063d --- /dev/null +++ b/src/aliases/DeleteAliasDialog.tsx @@ -0,0 +1,174 @@ +import { useState, type ReactNode } from "react"; +import { ArrowRightIcon } from "lucide-react"; +import { useClients } from "@/clients/data"; +import { useText } from "@/i18n"; +import { commonText } from "@/i18n/common.i18n"; +import { focusSelf } from "@/keys/parts"; +import { useResource } from "@/lib/resource"; +import { useNav } from "@/nav"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/ui/alert-dialog"; +import { Button } from "@/ui/button"; +import { IconClient, IconFlow, IconKey, IconRoute } from "@/ui/icons"; +import { Skeleton } from "@/ui/skeleton"; +import { Spinner } from "@/ui/spinner"; +import { errorText } from "@/upstreams/labels"; +import { DialogError } from "@/upstreams/parts"; +import { api } from "./api"; +import type { AliasRuleRef, AliasView } from "@/types"; +import { aliasesText } from "./aliases.i18n"; +import { clientsListing } from "./logic"; + +/** 在用这个名称的一处:一句话、图标,和能跳过去看的话跳到哪儿 */ +interface Use { + key: string; + icon: ReactNode; + text: string; + show?: () => void; +} + +/** + * 删除别名。**先列出在用这个名称的地方**(core 的 `GET /aliases/{name}/usage`:24 小时的 + * 请求、密钥的可见模型、规则;加上这台电脑上模型列表里写着它的已接管客户端),每一处都能 + * 跳过去看。和上游不同,别名被引用时照样能删 —— 删了之后那些地方写着的是一个没有上游 + * 提供的名称,这句话在对话框里说清。 + * + * 删除失败时对话框留着,原因写在里面,可以直接再点一次。 + */ +export function DeleteAliasDialog({ + alias, + configVersion, + onClose, + onDeleted, +}: { + alias: AliasView; + configVersion: string; + onClose: () => void; + onDeleted: () => void; +}) { + const t = useText(aliasesText); + const c = useText(commonText); + const nav = useNav(); + const name = alias.name; + const usage = useResource(`alias-usage:${name}`, () => api.aliasUsage(name)); + const clients = useClients(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const go = (f: () => void) => () => { + onClose(); + f(); + }; + const uses: Use[] = []; + const u = usage.data; + if (u && u.requests_24h > 0) + uses.push({ + key: "requests", + icon: , + text: t.usedByRequests(u.requests_24h), + show: go(() => nav.open("requests", { filter: { model: name } })), + }); + for (const k of u?.keys ?? []) + uses.push({ key: `key:${k}`, icon: , text: t.usedByKey(k), show: go(() => nav.open("keys", { edit: k })) }); + for (const r of u?.rules ?? []) + uses.push({ + key: `rule:${r.route}/${r.rule}/${r.field}`, + icon: , + text: ruleText(r), + show: go(() => nav.open("routing", { editRoute: r.route })), + }); + const listing = clientsListing(clients.data?.clients, name); + if (listing.length > 0) + uses.push({ + key: "clients", + icon: , + text: t.usedByClients(listing.map((x) => x.name)), + show: go(() => nav.open("clients", listing.length === 1 ? { detail: listing[0]!.id } : undefined)), + }); + + function ruleText(r: AliasRuleRef): string { + if (r.field === "when.model") return t.usedByCondition(r.route, r.rule); + if (r.field === "set.model") return t.usedByRewrite(r.route, r.rule); + return t.usedByRule(r.route, r.rule); + } + + async function run() { + setBusy(true); + setError(null); + try { + await api.deleteAlias(name, configVersion); + onDeleted(); + } catch (e) { + setError(errorText(e)); + setBusy(false); + } + } + + const loading = u === undefined && usage.error === undefined; + const models = [...new Set(alias.models.map((m) => m.model))]; + return ( + !open && !busy && onClose()}> + + + {t.deleteTitle(name)} + {loading || uses.length > 0 ? t.inUse : t.unused} + + {loading ? ( +
+ + +
+ ) : ( + uses.length > 0 && ( +
+ {uses.map((x) => ( +
+ {x.icon} + {x.text} + {x.show && ( + + )} +
+ ))} +
+ ) + )} + {usage.error !== undefined && u === undefined && ( +

{t.usageFailed(errorText(usage.error))}

+ )} + {models.length > 0 &&

{t.kept(models)}

} + + + {c.cancel} + { + // 结果要留在这张对话框里:成功时由调用方关掉,失败时在这里说原因 + e.preventDefault(); + void run(); + }} + > + {busy && } + {c.delete} + + +
+
+ ); +} diff --git a/src/aliases/Suggestions.tsx b/src/aliases/Suggestions.tsx new file mode 100644 index 00000000..16ba2d08 --- /dev/null +++ b/src/aliases/Suggestions.tsx @@ -0,0 +1,141 @@ +import { useEffect } from "react"; +import { useText } from "@/i18n"; +import type { ProviderView } from "@/types"; +import { Banner } from "@/ui/banner"; +import { Button } from "@/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/ui/dialog"; +import type { AliasSuggestion } from "@/types"; +import { aliasesText } from "./aliases.i18n"; +import { Lines } from "./AliasTable"; +import { suggestionKey, suggestionProviders } from "./logic"; + +type Providers = Pick[]; + +/** + * 「别名」标签顶上的一条:同一个模型在几个上游的名称不同。只有一条时直接给「建为别名…」, + * 几条时给个数和「查看…」(打开 `SuggestionsDialog`)。「忽略」记在这台电脑上,和引导 + * 提示的「不再显示」同一处(设置里「重新显示」一起恢复);几条时一次忽略全部。 + */ +export function SuggestionsBanner({ + suggestions, + onCreate, + onView, + onDismiss, +}: { + /** 还该说的建议(没点过「忽略」的) */ + suggestions: AliasSuggestion[]; + onCreate: (s: AliasSuggestion) => void; + onView: () => void; + onDismiss: (s: AliasSuggestion[]) => void; +}) { + const t = useText(aliasesText); + const one = suggestions.length === 1 ? suggestions[0]! : null; + return ( + 0} + layout="inline" + tone="info" + actions={ + <> + {one ? ( + + ) : ( + + )} + + + } + > + {one ? t.oneSuggestion(one.label, suggestionProviders(one)) : t.manySuggestions(suggestions.length)} + + ); +} + +/** + * 一组建议,每条一行:模型的名称、它在各个上游的名称、「建为别名…」和「忽略」。 + * 尚无别名时直接列在空状态下面,几条建议时在对话框里。 + */ +export function SuggestionList({ + suggestions, + providers, + onCreate, + onDismiss, +}: { + suggestions: AliasSuggestion[]; + providers: Providers; + onCreate: (s: AliasSuggestion) => void; + onDismiss: (s: AliasSuggestion[]) => void; +}) { + const t = useText(aliasesText); + return ( +
    + {suggestions.map((s) => ( +
  • + {s.label} + ({ provider: m.provider, model: m.model }))} + providers={providers} + /> +
    + + +
    +
  • + ))} +
+ ); +} + +/** + * 几条建议时,顶上那条的「查看…」打开的对话框。全部建好或忽略完了就自己关上。 + */ +export function SuggestionsDialog({ + suggestions, + providers, + onCreate, + onDismiss, + onClose, +}: { + suggestions: AliasSuggestion[]; + providers: Providers; + onCreate: (s: AliasSuggestion) => void; + onDismiss: (s: AliasSuggestion[]) => void; + onClose: () => void; +}) { + const t = useText(aliasesText); + const none = suggestions.length === 0; + useEffect(() => { + if (none) onClose(); + }, [none, onClose]); + return ( + !open && onClose()}> + + + {t.suggestTitle} + {t.suggestDesc} + + +

{t.suggestScope}

+ +
+
+ ); +} diff --git a/src/aliases/aliases.i18n.ts b/src/aliases/aliases.i18n.ts new file mode 100644 index 00000000..fc81a69e --- /dev/null +++ b/src/aliases/aliases.i18n.ts @@ -0,0 +1,129 @@ +import { messages } from "@/i18n"; + +/** 英文的并列:a、a and b、a, b and c */ +function enList(xs: readonly string[]): string { + if (xs.length <= 1) return xs.join(""); + return `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}`; +} + +/** 上游页「别名」标签:表格、顶部的建议、尚无别名时、同一模型不同名称的对话框、删除 */ +export const aliasesText = messages( + { + tab: "别名", + /** 标签名旁的小圆点,读屏用 */ + tabDot: "有可建为别名的模型", + newAlias: "新建别名", + loadFailed: "别名读取失败", + + alias: "别名", + models: "上游模型", + context: "上下文", + day: "24 小时", + actionsColumn: "操作", + actions: (name: string) => `${name} 的操作`, + requests: (n: number) => `${n.toLocaleString()} 次`, + /** 「上游模型」一格里没有上游提供的那个名称,悬停时 */ + notOffered: "没有上游提供这个名称", + unserved: (models: number): string => + models === 1 + ? "没有上游提供这个模型,使用这个别名的请求会失败" + : "没有上游提供列表中的任何一个模型,使用这个别名的请求会失败", + shadowed: (providers: readonly string[], name: string) => + `${providers.join("、")} 也有名为 ${name} 的模型,未列入,不会被使用`, + + copyName: "复制名称", + traffic: "查看流量", + + /** 顶部只有一条建议时 */ + oneSuggestion: (label: string, providers: readonly string[]) => + `${label} 在 ${providers.join("、")} 上的名称各不相同。建为别名后,这几个上游可以互为备用。`, + /** 顶部有几条建议时 */ + manySuggestions: (n: number) => + `${n} 个模型在几个上游的名称各不相同。建为别名后,这些上游可以互为备用。`, + createFrom: "建为别名…", + view: "查看…", + ignore: "忽略", + + empty: "尚无别名", + emptyDesc: + "别名是客户端使用的一个名称,指向一个模型。同一个模型在几个上游的名称不同时,把这些名称列在一个别名下,这几个上游就能互为备用。", + emptySuggestions: "这些模型在几个上游的名称不同", + + suggestTitle: "同一模型的不同名称", + suggestDesc: "这些模型在几个上游的名称各不相同。建为别名后,这几个上游可以互为备用。", + suggestScope: "只认 Claude 模型在官方、Bedrock、Vertex、OpenRouter 上的名称,日期版本不同的不算同一个。", + + deleteTitle: (name: string) => `删除别名「${name}」`, + inUse: "以下地方在用这个名称。删除后,使用它的请求会失败,按它写的规则和可见范围不再起作用。", + unused: "删除后,此别名将从配置文件中移除。", + usageFailed: (reason: string) => `使用情况读取失败:${reason}`, + usedByRequests: (n: number) => `过去 24 小时 ${n.toLocaleString()} 次请求`, + usedByKey: (key: string) => `密钥「${key}」的可见模型`, + usedByCondition: (route: string, rule: string) => `路由「${route}」· 规则「${rule}」的条件`, + usedByRewrite: (route: string, rule: string) => `路由「${route}」· 规则「${rule}」的模型改写`, + usedByRule: (route: string, rule: string) => `路由「${route}」· 规则「${rule}」`, + usedByClients: (names: readonly string[]) => `已接管的 ${names.join("、")} 的模型列表`, + show: "查看", + kept: (models: readonly string[]) => `上游模型 ${models.join("、")} 照常可用。可在版本历史中恢复。`, + }, + { + tab: "Aliases", + tabDot: "Some models could become aliases", + newAlias: "New alias", + loadFailed: "Could not load the aliases", + + alias: "Alias", + models: "Upstream models", + context: "Context", + day: "24 hours", + actionsColumn: "Actions", + actions: (name: string) => `Actions for ${name}`, + requests: (n: number) => (n === 1 ? "1 request" : `${n.toLocaleString()} requests`), + notOffered: "No upstream offers this name", + unserved: (models: number) => + models === 1 + ? "No upstream offers this model; requests for this alias will fail" + : "No upstream offers any of these models; requests for this alias will fail", + shadowed: (providers: readonly string[], name: string) => + `${enList(providers)} also ${providers.length === 1 ? "has" : "have"} a model named ${name}; it is not listed, so it is not used`, + + copyName: "Copy name", + traffic: "View traffic", + + oneSuggestion: (label: string, providers: readonly string[]) => + `${label} has a different name on each of ${enList(providers)}. As one alias, these upstreams can stand in for each other.`, + manySuggestions: (n: number) => + `${n} models have different names on different upstreams. As aliases, those upstreams can stand in for each other.`, + createFrom: "Create alias…", + view: "View…", + ignore: "Dismiss", + + empty: "No aliases", + emptyDesc: + "An alias is a name clients use for a model. When a model has different names on different upstreams, list those names under one alias and the upstreams can stand in for each other.", + emptySuggestions: "These models have different names on different upstreams", + + suggestTitle: "One model, different names", + suggestDesc: + "These models have different names on different upstreams. As aliases, those upstreams can stand in for each other.", + suggestScope: + "Only Claude model names on Anthropic, Bedrock, Vertex and OpenRouter are recognised; names with different dates count as different models.", + + deleteTitle: (name: string) => `Delete alias “${name}”`, + inUse: + "This name is used in the places below. After deletion, requests using it fail, and rules and visible-model lists that name it no longer apply.", + unused: "Deleting removes this alias from the config file.", + usageFailed: (reason: string) => `Could not load where this name is used: ${reason}`, + usedByRequests: (n: number) => + n === 1 ? "1 request in the last 24 hours" : `${n.toLocaleString()} requests in the last 24 hours`, + usedByKey: (key: string) => `Visible models of key “${key}”`, + usedByCondition: (route: string, rule: string) => `Route “${route}” · condition of rule “${rule}”`, + usedByRewrite: (route: string, rule: string) => `Route “${route}” · model rewrite of rule “${rule}”`, + usedByRule: (route: string, rule: string) => `Route “${route}” · rule “${rule}”`, + usedByClients: (names: readonly string[]) => + `The model ${names.length === 1 ? "list" : "lists"} of ${enList(names)} (connected)`, + show: "Show", + kept: (models: readonly string[]) => + `The upstream ${models.length === 1 ? "model" : "models"} ${enList(models)} remain${models.length === 1 ? "s" : ""} available. The alias can be restored from version history.`, + }, +); diff --git a/src/aliases/api.ts b/src/aliases/api.ts new file mode 100644 index 00000000..7646978a --- /dev/null +++ b/src/aliases/api.ts @@ -0,0 +1,26 @@ +/** + * 别名用到的调用:控制面的 `/aliases` 那几个端点,加上名称提示(Rust 这边的命令)。 + * + * 和上游页一样**只是类型化的调用**:名称校验、谁能服务、改名时一起改掉的引用、 + * 建议哪几组,全在 core。 + */ +import { invoke } from "@tauri-apps/api/core"; +import { call } from "@/control"; +import type { AliasHint, AliasPreviewRequest, AliasSave } from "@/types"; + +export const api = { + /** 全部别名、各自由谁服务,以及同一模型不同名称的建议 */ + aliases: () => call("Aliases", null), + createAlias: (save: AliasSave) => call("CreateAlias", save), + /** 可以改名:密钥范围和规则里写着旧名的地方一起改,改了哪些在 `renamed_in` 里 */ + updateAlias: (name: string, save: AliasSave) => call("UpdateAlias", save, name), + deleteAlias: (name: string, baseVersion: string) => + call("DeleteAlias", { base_version: baseVersion }, name), + previewAlias: (req: AliasPreviewRequest) => call("PreviewAlias", req), + /** 在用这个名称的地方:24 小时的请求、密钥的可见范围、规则 */ + aliasUsage: (name: string) => call("AliasUsage", null, name), + /** 打开对话框那一刻的配置版本:保存时带它 */ + configVersion: async () => (await call("GetConfig", null)).version, + /** 名称提示:这台机器上检测到的客户端会怎么对待这个名称 */ + hints: (name: string, models: string[]) => invoke("alias_hints", { name, models }), +}; diff --git a/src/aliases/data.ts b/src/aliases/data.ts new file mode 100644 index 00000000..36b42beb --- /dev/null +++ b/src/aliases/data.ts @@ -0,0 +1,30 @@ +/** + * 别名表(`GET /aliases`)。上游页的标签名(个数、小圆点)和「别名」标签挂的是同一份缓存。 + */ +import { useResource, type Resource } from "@/lib/resource"; +import { dismissHints, useDismissedHints } from "@/guide/hints"; +import { api } from "./api"; +import type { AliasSuggestion, AliasesView } from "@/types"; +import { suggestionKey, visibleSuggestions } from "./logic"; + +/** + * 配置换了一版(别名、上游、启用范围)、某个上游的模型清单变了,谁能服务、建议哪几组 + * 都跟着变;请求落地了,24 小时的次数和费用跟着变。和上游页其余几份数据是同一组事件。 + */ +export function useAliases(): Resource { + return useResource("aliases", api.aliases, { + events: ["config_reloaded", "models_changed", "request_finished", "request_failed", "request_cancelled"], + }); +} + +/** 还该说的建议(没点过「忽略」的)和「忽略」 */ +export function useSuggestions(view: AliasesView | undefined): { + visible: AliasSuggestion[]; + dismiss: (s: readonly AliasSuggestion[]) => void; +} { + const dismissed = useDismissedHints(); + return { + visible: visibleSuggestions(view?.suggestions ?? [], dismissed), + dismiss: (s) => dismissHints(s.map(suggestionKey)), + }; +} diff --git a/src/aliases/draft.test.ts b/src/aliases/draft.test.ts new file mode 100644 index 00000000..1b36e0c7 --- /dev/null +++ b/src/aliases/draft.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import type { Msg } from "@/types"; +import { draftOf, onlyAuto, shownProblems, withModel, withName, withoutModel } from "./draft"; + +const real = new Set(["claude-opus-5", "deepseek-chat"]); +const offered = (m: string) => real.has(m); +const msg = (code: string): Msg => ({ code, text: code }); + +describe("别名草稿:名称是上游的真模型时自动列为第一个上游模型", () => { + it("新建时列表空着、名称是真模型:列进来,标成自动的", () => { + const d = withName(draftOf("", []), "claude-opus-5", offered, true); + expect(d.models).toEqual(["claude-opus-5"]); + expect(d.auto).toBe("claude-opus-5"); + expect(onlyAuto(d)).toBe(true); + }); + + it("名称再改:自动列进来的那一个跟着撤掉,新名称是真模型就换成它", () => { + let d = withName(draftOf("", []), "claude-opus-5", offered, true); + d = withName(d, "claude-opus-5-x", offered, true); + expect(d.models).toEqual([]); + expect(d.auto).toBeNull(); + d = withName(d, "deepseek-chat", offered, true); + expect(d.models).toEqual(["deepseek-chat"]); + }); + + it("删掉自动列进来的:同一个名称不再自动列", () => { + let d = withName(draftOf("", []), "claude-opus-5", offered, true); + d = withoutModel(d, "claude-opus-5"); + expect(d.models).toEqual([]); + d = withName(d, "claude-opus-", offered, true); + d = withName(d, "claude-opus-5", offered, true); + expect(d.models).toEqual([]); + }); + + it("用户自己加了别的:自动的那一个留下,名称再改也不撤", () => { + let d = withName(draftOf("", []), "claude-opus-5", offered, true); + d = withModel(d, "anthropic/claude-opus-5"); + expect(d.auto).toBeNull(); + d = withName(d, "opus", offered, true); + expect(d.models).toEqual(["claude-opus-5", "anthropic/claude-opus-5"]); + }); + + it("列表里已经有模型(从上游模型弹窗起别名)、编辑时、名称不是真模型:不自动列", () => { + expect(withName(draftOf("", ["deepseek-chat"]), "claude-opus-5", offered, true).models).toEqual(["deepseek-chat"]); + expect(withName(draftOf("x", []), "claude-opus-5", offered, false).models).toEqual([]); + expect(withName(draftOf("", []), "my-model", offered, true).models).toEqual([]); + }); + + it("加模型:去掉首尾空白,空的和已经列着的不加", () => { + let d = withModel(draftOf("a", ["m1"]), " m2 "); + expect(d.models).toEqual(["m1", "m2"]); + d = withModel(d, "m1"); + d = withModel(d, " "); + expect(d.models).toEqual(["m1", "m2"]); + }); +}); + +describe("别名草稿:名称下面用红字列出的问题", () => { + it("页脚已经说了的两条不重复", () => { + const d = draftOf("", []); + expect( + shownProblems([msg("config.alias_empty_name"), msg("config.alias_no_models"), msg("config.alias_reserved")], d).map( + (m) => m.code, + ), + ).toEqual(["config.alias_reserved"]); + }); + + it("「只指向自己」:只有自动列进来的那一个时不用红字,用户自己这样写的照常说", () => { + const problems = [msg("config.alias_only_itself")]; + const auto = withName(draftOf("", []), "claude-opus-5", offered, true); + expect(shownProblems(problems, auto)).toEqual([]); + const typed = draftOf("claude-opus-5", ["claude-opus-5"]); + expect(shownProblems(problems, typed)).toEqual(problems); + }); +}); diff --git a/src/aliases/draft.ts b/src/aliases/draft.ts new file mode 100644 index 00000000..17e75be7 --- /dev/null +++ b/src/aliases/draft.ts @@ -0,0 +1,71 @@ +/** + * 新建、编辑别名的对话框里那份草稿怎么变。和画面分开,好测。 + * + * **名称恰好是上游的模型名时,自动列成第一个上游模型**(`auto`):起一个和真模型同名的 + * 别名,通常是想把这个模型在其他上游的名称收到一起。只在列表还空着时这样做;名称再改, + * 自动列进来的那一个跟着撤掉;用户删了它,同一个名称就不再自动列;用户自己加了别的, + * 它就不再算自动的。 + */ +import type { Msg } from "@/types"; + +export interface Draft { + name: string; + models: string[]; + /** 自动列进来的那一个。用户动过列表就是 `null` */ + auto: string | null; + /** 自动列进来、又被删掉的 */ + declined: string[]; +} + +export function draftOf(name: string, models: string[]): Draft { + return { name, models, auto: null, declined: [] }; +} + +/** 改名称。`offered`:这个名称是不是某个上游的真模型。编辑时不自动列 */ +export function withName(d: Draft, name: string, offered: (m: string) => boolean, creating: boolean): Draft { + if (!creating) return { ...d, name }; + const n = name.trim(); + let models = d.models; + let auto = d.auto; + if (auto !== null && auto !== n) { + models = models.filter((m) => m !== auto); + auto = null; + } + if (n && auto === null && models.length === 0 && !d.declined.includes(n) && offered(n)) { + models = [n]; + auto = n; + } + return { ...d, name, models, auto }; +} + +/** 加一个上游模型。空的、已经列着的不加 */ +export function withModel(d: Draft, model: string): Draft { + const m = model.trim(); + if (!m || d.models.includes(m)) return d; + return { ...d, models: [...d.models, m], auto: null }; +} + +export function withoutModel(d: Draft, model: string): Draft { + const models = d.models.filter((x) => x !== model); + if (model !== d.auto) return { ...d, models }; + return { ...d, models, auto: null, declined: [...d.declined, model] }; +} + +/** 列表里只有自动列进来的那一个 */ +export function onlyAuto(d: Draft): boolean { + return d.auto !== null && d.models.length === 1 && d.models[0] === d.auto; +} + +/** + * 页脚已经说了缺什么(填写名称、添加上游模型),这两条 core 的问题不在名称下面用红字再说一遍 + * —— 而且一打开对话框就是一片红 + */ +const SAID_IN_FOOTER = new Set(["config.alias_empty_name", "config.alias_no_models"]); +/** 别名只指向它自己(`x: x`)。名称自动列成第一个上游模型时,页脚说下一步,不用红字 */ +export const ONLY_ITSELF = "config.alias_only_itself"; + +/** core 说的问题里,名称下面用红字列出的那些 */ +export function shownProblems(problems: readonly Msg[], d: Draft): Msg[] { + const auto = onlyAuto(d); + return problems.filter((m) => !SAID_IN_FOOTER.has(m.code) && !(auto && m.code === ONLY_ITSELF)); +} diff --git a/src/aliases/logic.test.ts b/src/aliases/logic.test.ts new file mode 100644 index 00000000..235dca33 --- /dev/null +++ b/src/aliases/logic.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "vitest"; +import type { DetectedClient } from "@/types"; +import type { AliasSuggestion, AliasView } from "@/types"; +import { + aliasWarnings, + clientsListing, + modelLines, + showTabDot, + suggestionInput, + suggestionKey, + suggestionProviders, + visibleSuggestions, +} from "./logic"; + +const opus: AliasSuggestion = { + label: "Claude Opus 5", + models: [ + { provider: "anthropic", model: "claude-opus-5" }, + { provider: "bedrock", model: "us.anthropic.claude-opus-5-v1:0" }, + { provider: "openrouter", model: "anthropic/claude-opus-5" }, + ], +}; +const haiku: AliasSuggestion = { + label: "Claude Haiku 4.5", + models: [ + { provider: "anthropic", model: "claude-haiku-4-5" }, + { provider: "bedrock", model: "us.anthropic.claude-haiku-4-5-20251001-v1:0" }, + ], +}; + +function alias(over: Partial = {}): AliasView { + return { + name: "claude-sonnet-5", + models: [{ model: "claude-sonnet-5", providers: ["anthropic"] }], + served_by: [{ provider: "anthropic", model: "claude-sonnet-5" }], + shadows: [], + requests_24h: 0, + ...over, + }; +} + +describe("别名的建议", () => { + it("标识由名称和模型名组成:多接一个上游、名称不变时还是同一条", () => { + const relay = { ...opus, models: [...opus.models, { provider: "relay", model: "claude-opus-5" }] }; + expect(suggestionKey(relay)).toBe(suggestionKey(opus)); + // 顺序不同也是同一条 + expect(suggestionKey({ ...opus, models: [...opus.models].reverse() })).toBe(suggestionKey(opus)); + }); + + it("多出一种写法就是一条新建议", () => { + const vertex = { ...opus, models: [...opus.models, { provider: "vertex", model: "claude-opus-5@20260101" }] }; + expect(suggestionKey(vertex)).not.toBe(suggestionKey(opus)); + expect(suggestionKey(opus)).toMatch(/^alias-suggestion:/); + }); + + it("点过「忽略」的不再出现,其余按 core 的顺序", () => { + expect(visibleSuggestions([opus, haiku], [])).toEqual([opus, haiku]); + expect(visibleSuggestions([opus, haiku], [suggestionKey(opus)])).toEqual([haiku]); + expect(visibleSuggestions([opus, haiku], [suggestionKey(opus), suggestionKey(haiku)])).toEqual([]); + }); + + it("标签名旁的小圆点:有没忽略的建议、而且不在这个标签上", () => { + expect(showTabDot([opus], [], false)).toBe(true); + expect(showTabDot([opus], [], true)).toBe(false); + expect(showTabDot([opus], [suggestionKey(opus)], false)).toBe(false); + expect(showTabDot([], [], false)).toBe(false); + // 数据没取到时不画 + expect(showTabDot(undefined, [], false)).toBe(false); + }); + + it("说出现在哪几个上游,不重复", () => { + const twice = { ...opus, models: [...opus.models, { provider: "anthropic", model: "claude-opus-5-latest" }] }; + expect(suggestionProviders(twice)).toEqual(["anthropic", "bedrock", "openrouter"]); + }); + + it("建为别名:名称取不带前缀后缀的那一个,上游模型按顺序、不重复", () => { + expect(suggestionInput(opus)).toEqual({ + name: "claude-opus-5", + models: ["claude-opus-5", "us.anthropic.claude-opus-5-v1:0", "anthropic/claude-opus-5"], + }); + const relay = { ...opus, models: [...opus.models, { provider: "relay", model: "claude-opus-5" }] }; + expect(suggestionInput(relay).models).toHaveLength(3); + }); + + it("没有官方写法时去掉前缀、后缀", () => { + const noOfficial: AliasSuggestion = { + label: "Claude Opus 5", + models: [ + { provider: "bedrock", model: "us.anthropic.claude-opus-5-v1:0" }, + { provider: "openrouter", model: "anthropic/claude-opus-5" }, + ], + }; + expect(suggestionInput(noOfficial).name).toBe("claude-opus-5"); + const vertex: AliasSuggestion = { + label: "Claude Opus 4.1", + models: [ + { provider: "vertex", model: "claude-opus-4-1@20250805" }, + { provider: "bedrock", model: "global.anthropic.claude-opus-4-1-20250805-v1:0" }, + ], + }; + expect(suggestionInput(vertex).name).toBe("claude-opus-4-1-20250805"); + }); +}); + +describe("别名表的一行", () => { + it("每个能服务的上游一行,没有上游提供的名称各一行", () => { + const a = alias({ + models: [ + { model: "claude-sonnet-5", providers: ["anthropic"] }, + { model: "anthropic/claude-sonnet-5", providers: ["openrouter"] }, + { model: "claude-sonnet-5-preview", providers: [] }, + ], + served_by: [ + { provider: "anthropic", model: "claude-sonnet-5" }, + { provider: "openrouter", model: "anthropic/claude-sonnet-5" }, + ], + }); + expect(modelLines(a)).toEqual([ + { provider: "anthropic", model: "claude-sonnet-5" }, + { provider: "openrouter", model: "anthropic/claude-sonnet-5" }, + { provider: null, model: "claude-sonnet-5-preview" }, + ]); + }); + + it("正常的不提醒", () => { + expect(aliasWarnings(alias())).toEqual([]); + }); + + it("没有上游能服务时先说这个,说明列表里有几个名称", () => { + const one = alias({ models: [{ model: "moonshotai/kimi-k2-0905", providers: [] }], served_by: [] }); + expect(aliasWarnings(one)).toEqual([{ kind: "unserved", models: 1 }]); + const two = alias({ + models: [ + { model: "a", providers: [] }, + { model: "b", providers: [] }, + ], + served_by: [], + shadows: ["chatgpt"], + }); + expect(aliasWarnings(two)).toEqual([ + { kind: "unserved", models: 2 }, + { kind: "shadowed", providers: ["chatgpt"] }, + ]); + }); + + it("同名模型被挡住的上游", () => { + const a = alias({ + name: "gpt-5.5", + models: [{ model: "openai/gpt-5.5", providers: ["openrouter"] }], + served_by: [{ provider: "openrouter", model: "openai/gpt-5.5" }], + shadows: ["chatgpt"], + }); + expect(aliasWarnings(a)).toEqual([{ kind: "shadowed", providers: ["chatgpt"] }]); + }); +}); + +describe("删除时列出的客户端", () => { + const base = { + real: "", + path: "", + installed: true, + has_config: true, + other_instance: false, + endpoint: null, + shadows: [], + takes_effect: "on_restart", + warns_when_silent: false, + verified: "fields_only", + costs: [], + models_stale: false, + movable: true, + manual: { steps: [], fields: [] }, + } as unknown as DetectedClient; + const c = (id: string, over: Partial): DetectedClient => ({ ...base, id, name: id, ...over }); + + it("只列这一份接管着、模型列表里写着这个名称的", () => { + const list = [ + c("opencode", { adopted_at_ms: 1, models: ["claude-sonnet-5", "gpt-5.5"] }), + c("pi", { adopted_at_ms: 1, models: ["gpt-5.5"] }), + // 没接管:写着也不算 + c("qwen-code", { adopted_at_ms: null, models: ["claude-sonnet-5"] }), + // 另一个 ThinkWatch Lite 接管的 + c("grok", { adopted_at_ms: null, other_instance: true, models: ["claude-sonnet-5"] }), + // 不写模型的客户端 + c("claude-code", { adopted_at_ms: 1 }), + ]; + expect(clientsListing(list, "claude-sonnet-5").map((x) => x.id)).toEqual(["opencode"]); + expect(clientsListing(list, "gpt-5.5").map((x) => x.id)).toEqual(["opencode", "pi"]); + expect(clientsListing(undefined, "gpt-5.5")).toEqual([]); + }); +}); diff --git a/src/aliases/logic.ts b/src/aliases/logic.ts new file mode 100644 index 00000000..24b1bcc9 --- /dev/null +++ b/src/aliases/logic.ts @@ -0,0 +1,107 @@ +/** + * 「别名」标签上几处要算的东西:哪几条建议还该说、标签名旁的小圆点、每一行下面的 + * 提醒、从建议预填的新建内容、删除时列出的客户端。都是纯函数,组件只管画。 + */ +import type { HintId } from "@/guide/hints"; +import type { DetectedClient } from "@/types"; +import type { AliasInput, AliasSuggestion, AliasView } from "@/types"; + +/** + * 一条建议的标识,「忽略」按它记(`@/guide/hints`,和引导提示的「不再显示」同一处, + * 设置里「重新显示」一起恢复)。 + * + * **由名称和其中的模型名组成,不含上游**:又接了一个上游、提供的还是这几个名称, + * 建议不重新出现;多出一个新的名称(又一种写法)才算一条新建议。 + */ +export function suggestionKey(s: AliasSuggestion): HintId { + const models = [...new Set(s.models.map((m) => m.model))].sort(); + return `alias-suggestion:${s.label}|${models.join(",")}`; +} + +/** 还该说的建议:没点过「忽略」的,按 core 给的顺序 */ +export function visibleSuggestions( + all: readonly AliasSuggestion[], + dismissed: readonly string[], +): AliasSuggestion[] { + return all.filter((s) => !dismissed.includes(suggestionKey(s))); +} + +/** + * 标签名旁的小圆点:有没点过「忽略」的建议,而且此刻看的不是「别名」标签。 + * 数据没取到时不画 —— 不在数据到之前先闪一下。 + */ +export function showTabDot( + suggestions: readonly AliasSuggestion[] | undefined, + dismissed: readonly string[], + active: boolean, +): boolean { + return !active && suggestions !== undefined && visibleSuggestions(suggestions, dismissed).length > 0; +} + +/** 一条建议里出现了哪几个上游,按出现的顺序、不重复 */ +export function suggestionProviders(s: AliasSuggestion): string[] { + return [...new Set(s.models.map((m) => m.provider))]; +} + +/** + * 「建为别名…」打开新建对话框时的预填:上游模型是建议里的几个名称(按 core 给的顺序、 + * 不重复),名称取其中不带前缀和后缀的那一个 —— 通常就是官方上游的写法 + * (`claude-opus-5`),和它同名的请求照常能用。没有这样的名称时取第一个去掉前缀、 + * 后缀之后的样子。对话框里可以改。 + */ +export function suggestionInput(s: AliasSuggestion): Partial { + const models = [...new Set(s.models.map((m) => m.model))]; + const bare = models.find((m) => bareName(m) === m); + return { name: bare ?? (models[0] ? bareName(models[0]) : ""), models }; +} + +/** 去掉路径前缀(`anthropic/`)、Bedrock 的地域与厂商前缀和版本后缀,Vertex 的 `@日期` 换成 `-日期` */ +function bareName(model: string): string { + let m = model.replace(/^.*\//, ""); + if (BEDROCK.test(m)) m = m.replace(BEDROCK, "").replace(/-v\d+(?::\d+)?$/, ""); + return m.replace("@", "-"); +} + +const BEDROCK = /^(?:(?:us|eu|apac|jp|au|us-gov|global)\.)?anthropic\./; + +/** 「上游模型」一格里的一行:哪个上游、发给它的名称。`provider` 为 null = 没有上游提供这个名称 */ +export interface ModelLine { + provider: string | null; + model: string; +} + +/** + * 「上游模型」一格:每个能服务它的上游一行,写实际发给它的名称(core 按列表顺序取 + * 它有的第一个);列表里没有任何上游提供的名称各一行,上游写「—」。 + */ +export function modelLines(a: AliasView): ModelLine[] { + return [ + ...a.served_by.map((p) => ({ provider: p.provider, model: p.model })), + ...a.models.filter((m) => m.providers.length === 0).map((m) => ({ provider: null, model: m.model })), + ]; +} + +/** 一行下面的提醒 */ +export type AliasWarning = + /** 没有上游提供列表里的任何一个名称:用这个别名的请求会失败。`models` 是列表里有几个名称 */ + | { kind: "unserved"; models: number } + /** 这几个上游有和别名同名的模型,但没列进来:这个名称的请求不会发给它们 */ + | { kind: "shadowed"; providers: string[] }; + +/** 一行下面要说的提醒,要紧的在前 */ +export function aliasWarnings(a: AliasView): AliasWarning[] { + const out: AliasWarning[] = []; + if (a.served_by.length === 0) out.push({ kind: "unserved", models: a.models.length }); + if (a.shadows.length > 0) out.push({ kind: "shadowed", providers: a.shadows }); + return out; +} + +/** + * 模型列表里写着这个名称的、这一份接管着的客户端(opencode、Pi 这类把模型写进配置的)。 + * 删掉别名之后,它们的模型列表里留着一个没有上游提供的名称。 + */ +export function clientsListing(clients: readonly DetectedClient[] | undefined, name: string): DetectedClient[] { + return (clients ?? []).filter( + (c) => c.adopted_at_ms != null && !c.other_instance && (c.models ?? []).includes(name), + ); +} diff --git a/src/clients/ClientsPage.tsx b/src/clients/ClientsPage.tsx index 296d41b4..88741ba2 100644 --- a/src/clients/ClientsPage.tsx +++ b/src/clients/ClientsPage.tsx @@ -25,7 +25,7 @@ import { RetargetFailures } from "@/connection/Remote"; import { remoteText } from "@/connection/remote.i18n"; import { useKeys, useKeyUsage } from "@/keys/data"; import { RowsSkeleton } from "@/keys/parts"; -import { api, isStalePlan } from "./api"; +import { api, isGatewayChanged, isStalePlan } from "./api"; import { clientsText } from "./clients.i18n"; import { DetectedTable, ManualTable, Section, type RowContext } from "./ClientsTable"; import { useClients, useWsl } from "./data"; @@ -42,8 +42,11 @@ import { ClientsPageHints } from "@/guide/PageHints"; type DialogState = | null | { kind: "detail"; id: string; env?: string } - /** `stale`:刚才确认时文件已经被改过,这一份是按现在的内容重算的 */ - | { kind: "plan"; id: string; env?: string; restore: boolean; plan: PlanView; stale?: boolean } + /** + * `stale`:刚才确认时什么都没写,这一份是重算的 —— 文件已经被改过(`files`),或者网关 + * 可用的模型变了(`gateway`) + */ + | { kind: "plan"; id: string; env?: string; restore: boolean; plan: PlanView; stale?: "files" | "gateway" } | { kind: "manual"; id: string; env?: string } /** 配置位置(接管、MCP 管理、安全扫描)。只有这台电脑上的 */ | { kind: "path"; id: string } @@ -169,15 +172,17 @@ export default function ClientsPage({ * 落盘。**成功之后不弹第二个对话框** —— 行上的状态会说「等待首个请求」。 * 只有不至于失败、但用户该知道的事(符号链接、权限太松)才另说一句。 */ - function apply(id: string, restore: boolean, digest: string, env?: string) { + function apply(id: string, restore: boolean, digest: string, env?: string, model?: string) { void confirm(async () => { - const r = await (restore ? api.restore(id, digest, env) : api.adopt(id, digest, env)).catch( + const r = await (restore ? api.restore(id, digest, env) : api.adopt(id, digest, env, model)).catch( async (e: unknown) => { - if (!isStalePlan(e)) throw e; - // 看改动的这段时间里文件被改过,什么都没写:按现在的文件重算一份,还在这个 - // 对话框里给人看。**不替人决定照新的写** —— 那就又是一份没人看过的改动 + const stale = isStalePlan(e) ? "files" : isGatewayChanged(e) ? "gateway" : null; + if (!stale) throw e; + // 看改动的这段时间里文件被改过(或者网关可用的模型变了),什么都没写:按现在的 + // 样子重算一份,还在这个对话框里给人看。**不替人决定照新的写** —— 那就又是一份 + // 没人看过的改动 const plan = restore ? await api.planRestore(id, env) : await api.planAdopt(id, env); - setDialog({ kind: "plan", id, env, restore, plan, stale: true }); + setDialog({ kind: "plan", id, env, restore, plan, stale }); return null; }, ); @@ -199,6 +204,8 @@ export default function ClientsPage({ if (bad.length === 0) notify.success(t.restoredAll(rs.length - skipped.length)); else notify.error(t.restoreFailed(bad)); if (skipped.length > 0) notify.info(t.restoreSkipped(skipped)); + // 还原了、但还有一句要说的(Claude Desktop 接管时加的路由规则没删成) + for (const r of rs) if (r.warning) notify.info(r.warning); setDialog(null); await Promise.all([clients.reload(), groups.length > 0 ? wsl.reload() : undefined]); }); @@ -457,7 +464,7 @@ export default function ClientsPage({ pending={confirming} bedrockUpstreams={(providers ?? []).filter((p) => p.protocol === "bedrock").map((p) => p.name)} onCancel={() => setDialog(null)} - onConfirm={() => apply(dialog.id, dialog.restore, dialog.plan.digest, dialog.env)} + onConfirm={(model) => apply(dialog.id, dialog.restore, dialog.plan.digest, dialog.env, model)} /> )} diff --git a/src/clients/PlanDialog.tsx b/src/clients/PlanDialog.tsx index 95969555..2a7900f6 100644 --- a/src/clients/PlanDialog.tsx +++ b/src/clients/PlanDialog.tsx @@ -1,16 +1,17 @@ -import { useMemo, useState } from "react"; +import { useId, useMemo, useState } from "react"; import { ChevronRightIcon } from "lucide-react"; import { Banner } from "@/ui/banner"; import { Button } from "@/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/ui/dialog"; import { Reveal } from "@/ui/motion"; +import { NativeSelect, NativeSelectOption } from "@/ui/native-select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/ui/table"; import { cn } from "@/lib/utils"; import { useNav } from "@/nav"; import { useText } from "@/i18n"; import { commonText } from "@/i18n/common.i18n"; import { coreText } from "@/i18n/core.i18n"; -import type { BedrockDraft, DetectedClient, FieldChange, PlanView } from "@/types"; +import type { BedrockDraft, DesktopRule, DetectedClient, FieldChange, ModelChoice, PinnedModel, PlanView } from "@/types"; import { ClientMark, DISCLOSURE, Tile, useDialogFocus } from "@/keys/parts"; import { clientsText } from "./clients.i18n"; @@ -23,12 +24,15 @@ import { clientsText } from "./clients.i18n"; * * 按下确认之后对话框留着、按钮转圈,写完才关;失败时对话框还在,可以再试。 * 确认之后不再弹第二个对话框:行上的状态会变成「等待首个请求」。 + * + * Claude Desktop 在网关没有它认的模型时(`plan.desktop_rule.pick`),先选它用哪个上游模型, + * 再在「网关配置」里写明加在它密钥上的那条规则;还原时写明删掉它。 */ export function PlanDialog({ plan, client, restore, - stale = false, + stale = null, pending, bedrockUpstreams = [], onCancel, @@ -37,13 +41,17 @@ export function PlanDialog({ plan: PlanView; client: DetectedClient; restore: boolean; - /** 刚才确认时文件已经被改过、什么都没写:这一份是按现在的内容重算的 */ - stale?: boolean; + /** + * 刚才确认时什么都没写,这一份是重算的:`files` 是文件已经被改过,`gateway` 是网关 + * 可用的模型变了 + */ + stale?: "files" | "gateway" | null; pending: boolean; /** 网关里已有的 Bedrock 上游(名字)。客户端原来直连 Bedrock 时要说有没有 */ bedrockUpstreams?: string[]; onCancel: () => void; - onConfirm: () => void; + /** `model`:给 Claude Desktop 选的模型(要选时) */ + onConfirm: (model?: string) => void; }) { const t = useText(clientsText); const common = useText(commonText); @@ -54,9 +62,29 @@ export function PlanDialog({ const also = (plan.also ?? []).filter((a) => !a.noop); const fields = [...plan.fields, ...also.flatMap((a) => a.fields)]; const path = {plan.path}; + const rule = plan.desktop_rule ?? null; + const pick = restore ? null : (rule?.pick ?? null); + const choices = pick?.choices ?? []; + const [picked, setPicked] = useState(null); + // 重算过的一份里没有刚才选的那个:回到默认的那个 + const choice = choices.find((c) => c.model === picked) ?? defaultChoice(rule, choices); + const to = choice ? pinnedOf(choice) : null; + // 网关上要不要改:还原删、要选时和已有的不一样才写、不用选时删已有的 + const ruleChanges = restore ? rule != null : pick ? to != null && !samePinned(to, rule?.current) : rule?.current != null; + const noop = plan.noop && !ruleChanges; + const modelId = useId(); return ( !o && !pending && onCancel()}> - + { + if (!pick) return; + e.preventDefault(); + (e.currentTarget as HTMLElement | null)?.focus(); + }} + > @@ -77,14 +105,40 @@ export function PlanDialog({
- - {t.stale} + + {stale === "gateway" ? t.staleGateway : t.stale} - {plan.noop ? ( + {noop ? (

{t.noop}

) : (
+ {pick && ( +
+ +

+ {choices.length > 0 ? t.desktopModelHint : t.desktopNoModels} +

+ {choices.length > 0 && ( + setPicked(e.target.value)} + > + {choices.map((c) => ( + + {t.desktopChoice(c.model, c.providers)} + + ))} + + )} +
+ )} + {fields.length > 0 && (
@@ -109,6 +163,10 @@ export function PlanDialog({ )} + {rule && (ruleChanges || (pick && to)) && ( + + )} + {!restore && plan.bedrock && ( {common.cancel} - {!plan.noop && ( - )} @@ -171,6 +233,59 @@ export function PlanDialog({ ); } +/** 默认选哪个:已有的规则指定的那个还在就是它,否则是网关列出的第一个 */ +function defaultChoice(rule: DesktopRule | null, choices: ModelChoice[]): ModelChoice | null { + const now = rule?.current?.[0]?.model; + return choices.find((c) => c.model === now) ?? choices[0] ?? null; +} + +/** 选了这个模型时规则的去向:提供它的每一家,按顺序备用(和 Rust 侧 `desktop_rule::pinned` 一样) */ +function pinnedOf(c: ModelChoice): PinnedModel[] { + return c.providers.map((provider) => ({ provider, model: c.model })); +} + +function samePinned(a: PinnedModel[], b: PinnedModel[] | null | undefined): boolean { + return b != null && a.length === b.length && a.every((p, i) => p.provider === b[i]?.provider && p.model === b[i]?.model); +} + +/** + * 「网关配置」:Claude Desktop 的密钥上那条规则在哪、写的什么。`to` 是接管时要指定的模型; + * 为空是删掉已有的那条(还原,或者网关已经有它认的模型了) + */ +function GatewayRule({ rule, to, restore }: { rule: DesktopRule; to: PinnedModel[] | null; restore: boolean }) { + const t = useText(clientsText); + const shown = to ?? rule.current ?? []; + const key = {rule.key}; + // 一个「上游 · 模型」不从中间折开,折在几个之间 + const models = shown.map((p, i) => ( + + {i > 0 && t.pinnedJoin} + + {p.provider} · {p.model} + + + )); + const foot = restore + ? null + : to == null + ? t.ruleUnneeded + : rule.current != null && !samePinned(to, rule.current) + ? t.ruleChanged + : t.ruleAdded; + return ( +
+

{t.gatewayChange}

+
+ {t.rulePlace(rule.route, rule.position)} + + {to == null ? t.ruleDelete(rule.name, key, models) : t.ruleText(rule.name, key, models)} + +
+ {foot &&

{foot}

} +
+ ); +} + /** * 客户端原来直连 Bedrock:网关里有没有 Bedrock 上游,和按它原来的设置新建一个的入口。 * diff --git a/src/clients/api.ts b/src/clients/api.ts index cd2b12c2..24708f72 100644 --- a/src/clients/api.ts +++ b/src/clients/api.ts @@ -28,7 +28,20 @@ import type { * 比;对不上时界面重新算一份,还在原来的对话框里给人看。 */ export function isStalePlan(e: unknown): boolean { - return typeof e === "object" && e !== null && (e as { code?: unknown }).code === "adopt.plan.stale"; + return codeOf(e) === "adopt.plan.stale"; +} + +/** + * 落盘时发现:网关可用的模型在人看改动的这段时间里变了(`adopt.plan.gateway_changed`), + * 什么都没写。Claude Desktop 选的模型没有上游提供了、要选却没得选了、不用选了。和 + * `isStalePlan` 一样处理:重新算一份,还在原来的对话框里给人看 + */ +export function isGatewayChanged(e: unknown): boolean { + return codeOf(e) === "adopt.plan.gateway_changed"; +} + +function codeOf(e: unknown): unknown { + return typeof e === "object" && e !== null ? (e as { code?: unknown }).code : undefined; } export interface RestoreOutcome { @@ -37,6 +50,8 @@ export interface RestoreOutcome { /** 由另一个 ThinkWatch Lite 接管,没动它(不算失败) */ skipped: boolean; detail: string; + /** 还原了、但该说一声的事:Claude Desktop 接管时加的路由规则没删成 */ + warning?: string; } /** @@ -49,9 +64,12 @@ export const api = { wsl: () => invoke("list_wsl"), planAdopt: (id: string, env?: string) => invoke("plan_adopt", { id, env }), planRestore: (id: string, env?: string) => invoke("plan_restore", { id, env }), - /** `expect`:确认的那一份改动的 `digest`。文件在这之后被改过就不写(见 `isStalePlan`) */ - adopt: (id: string, expect: string, env?: string) => - invoke("adopt_client", { id, env, expect }), + /** + * `expect`:确认的那一份改动的 `digest`。文件在这之后被改过就不写(见 `isStalePlan`)。 + * `model`:Claude Desktop 要选模型时(`PlanView.desktop_rule.pick`)选的那个 + */ + adopt: (id: string, expect: string, env?: string, model?: string) => + invoke("adopt_client", { id, env, expect, model }), restore: (id: string, expect: string, env?: string) => invoke("restore_client", { id, env, expect }), restoreAll: () => invoke("restore_all"), diff --git a/src/clients/clients.i18n.tsx b/src/clients/clients.i18n.tsx index 34b96bfc..2f5c2d45 100644 --- a/src/clients/clients.i18n.tsx +++ b/src/clients/clients.i18n.tsx @@ -131,6 +131,30 @@ export const clientsText = messages( bedrockProfile: (name: string) => `AWS profile ${name}`, bedrockNoCredential: "凭据在新建时填写", bedrockCreate: "新建 Bedrock 上游…", + /** 确认时发现网关可用的模型变了:什么都没写,下面是重算的那一份 */ + staleGateway: "网关可用的模型在查看改动期间发生了变化,未写入任何内容。以下改动已按当前情况重新计算,核对后请再次确认。", + // Claude Desktop:网关没有它认的模型时,选一个上游模型,在它的密钥上加一条规则 + desktopModel: "Claude Desktop 使用的模型", + desktopModelHint: + "网关中没有上游提供 Claude 模型,而 Claude Desktop 只接受 Claude 的模型名。接管时在它的密钥上添加一条路由规则,它的请求都发给所选模型;其他客户端不受影响。", + desktopNoModels: "网关中没有该密钥可用的上游模型,接管后 Claude Desktop 的请求无法完成。添加上游后重新接管。", + desktopChoice: (model: string, providers: string[]) => `${model} · ${providers.join("、")}`, + gatewayChange: "网关配置", + rulePlace: (route: string, n: number) => `路由「${route}」第 ${n} 条`, + ruleText: (name: string, key: ReactNode, to: ReactNode) => ( + <> + 规则「{name}」:密钥 {key} → 指定模型 {to} + + ), + ruleDelete: (name: string, key: ReactNode, to: ReactNode) => ( + <> + 删除规则「{name}」:密钥 {key} → 指定模型 {to} + + ), + pinnedJoin: "、", + ruleAdded: "取消接管时删除这条规则。", + ruleChanged: "这条规则已存在,改为指定所选模型;取消接管时删除。", + ruleUnneeded: "网关中已有上游提供 Claude 模型,接管时添加的这条规则不再需要。", // 手动配置 manualDialogTitle: (name: string) => `配置 ${name}`, @@ -322,6 +346,30 @@ export const clientsText = messages( bedrockProfile: (name: string) => `AWS profile ${name}`, bedrockNoCredential: "credentials to be entered", bedrockCreate: "New Bedrock upstream…", + staleGateway: + "The models available in the gateway changed while the change was being reviewed, so nothing was written. The change below was recomputed; review it and confirm again.", + desktopModel: "Model for Claude Desktop", + desktopModelHint: + "No upstream in the gateway offers a Claude model, and Claude Desktop accepts only Claude model names. Connecting adds a routing rule on its key that sends all its requests to the selected model; other clients are not affected.", + desktopNoModels: + "The gateway has no upstream model for this key, so Claude Desktop's requests cannot be served once connected. Add an upstream, then connect it again.", + desktopChoice: (model: string, providers: string[]) => `${model} · ${providers.join(", ")}`, + gatewayChange: "Gateway configuration", + rulePlace: (route: string, n: number) => `Route “${route}”, rule ${n}`, + ruleText: (name: string, key: ReactNode, to: ReactNode) => ( + <> + Rule “{name}”: key {key} → specified model {to} + + ), + ruleDelete: (name: string, key: ReactNode, to: ReactNode) => ( + <> + Delete rule “{name}”: key {key} → specified model {to} + + ), + pinnedJoin: ", ", + ruleAdded: "The rule is deleted when the connection is restored.", + ruleChanged: "The rule already exists and now specifies the selected model; it is deleted when the connection is restored.", + ruleUnneeded: "Upstreams in the gateway now offer Claude models, so the rule added on connecting is no longer needed.", manualDialogTitle: (name: string) => `Set up ${name}`, keyGoesBelow: "The key (below)", diff --git a/src/control.ts b/src/control.ts index 3407f834..ef4084f7 100644 --- a/src/control.ts +++ b/src/control.ts @@ -73,6 +73,12 @@ export const WEBVIEW_ENDPOINTS = [ "UpdateGroup", "DeleteGroup", "KnownModels", + "Aliases", + "CreateAlias", + "UpdateAlias", + "DeleteAlias", + "PreviewAlias", + "AliasUsage", "RouteStats", "UpstreamHealth", "Pricing", diff --git a/src/events.test.ts b/src/events.test.ts index fc1fcdf1..60c5ea8a 100644 --- a/src/events.test.ts +++ b/src/events.test.ts @@ -432,6 +432,82 @@ describe("故障转移之后的上游", () => { }); }); +/** + * 流量表上游那一格的「别名」「指定」要看这一行走的路由、决定去向的规则、改写了参数的规则, + * 和服务它的那一跳发出的模型名(`traffic/modelVia.ts`) + */ +describe("行上的路由", () => { + it("开始时就有路由、规则、改写;路由结论补上第二阶段的改写和服务它的那一跳发出的模型名", () => { + const rows = new Map(); + applyEvent(rows, started({ model: "claude-sonnet-5", rewritten_by: ["long-context"] })); + expect(rows.get(1)).toMatchObject({ route: "default", rule: "catch-all", rewrittenBy: ["long-context"] }); + expect(rows.get(1)?.sentModel).toBeUndefined(); + applyEvent(rows, { + kind: "request_routed", + id: 1, + route: "default", + rule: "catch-all", + group: "__all__", + rewritten_by: ["long-context", "relay-no-thinking"], + attempts: [ + { provider: "anthropic", outcome: "status", status: 529, ms: 40 }, + { provider: "bedrock", model: "us.anthropic.claude-sonnet-5-v1:0", outcome: "served", status: 200, ms: 900 }, + ], + billing: "per-token", + }); + expect(rows.get(1)).toMatchObject({ + provider: "bedrock", + sentModel: "us.anthropic.claude-sonnet-5-v1:0", + rewrittenBy: ["long-context", "relay-no-thinking"], + }); + }); + + it("没有改写的规则就没有这一项;最后一跳发出的和客户端要的相同就没有发出的模型名", () => { + const rows = new Map(); + applyEvent(rows, started()); + applyEvent(rows, { + kind: "request_routed", + id: 1, + route: "default", + rule: "catch-all", + rewritten_by: [], + attempts: [ + { provider: "bedrock", model: "us.anthropic.claude-sonnet-4-5-v1:0", outcome: "status", status: 503, ms: 40 }, + { provider: "anthropic", outcome: "served", status: 200, ms: 900 }, + ], + billing: "per-token", + }); + expect(rows.get(1)?.rewrittenBy).toBeUndefined(); + expect(rows.get(1)?.sentModel).toBeUndefined(); + }); + + it("库里读回来的行带着路由;对账时路由以库里的为准,改写名单没变就不算改动", () => { + const routing = { + route: "default", + rule: "兜底", + rewritten_by: ["relay-no-thinking"], + attempts: [{ provider: "bedrock", model: "us.anthropic.claude-sonnet-5-v1:0", outcome: "served" as const, status: 200, ms: 900 }], + }; + const rows = new Map(); + mergeHistory(rows, [stored({ provider: "bedrock", model: "claude-sonnet-5", routing })]); + expect(rows.get(1)).toMatchObject({ + route: "default", + rule: "兜底", + rewrittenBy: ["relay-no-thinking"], + sentModel: "us.anthropic.claude-sonnet-5-v1:0", + }); + const before = rows.get(1); + expect(mergeHistory(rows, [stored({ provider: "bedrock", model: "claude-sonnet-5", routing: { ...routing, rewritten_by: ["relay-no-thinking"] } })])).toBe(false); + expect(rows.get(1)).toBe(before); + + // 事件流上的那一行没收到路由结论:库里的补上 + const live = new Map(); + applyEvent(live, started({ model: "claude-sonnet-5", provider: "anthropic" })); + mergeHistory(live, [stored({ provider: "bedrock", model: "claude-sonnet-5", routing })]); + expect(live.get(1)).toMatchObject({ provider: "bedrock", sentModel: "us.anthropic.claude-sonnet-5-v1:0", rewrittenBy: ["relay-no-thinking"] }); + }); +}); + /** 库里读回来的一行。默认是一次正常结束的请求 */ function stored(over: Partial = {}): HistoryRow { return { diff --git a/src/generated/lite-api.ts b/src/generated/lite-api.ts index 60a837c5..383fe0a7 100644 --- a/src/generated/lite-api.ts +++ b/src/generated/lite-api.ts @@ -1,6 +1,6 @@ // Generated from src-tauri/src/wire.rs (`tests/ts_bindings.rs`). Do not edit by hand. -import type { CostBucketGroup, CostGroup, Msg, Protocol } from "./tw-api"; +import type { CostBucketGroup, CostGroup, Msg, PinnedModel, Protocol } from "./tw-api"; export type AdoptResponse = { real: string, backup: string, created: boolean, /** @@ -12,6 +12,12 @@ warnings: Array, */ takes_effect: TakesEffect, }; +/** + * 新建、编辑模型别名时,名称下面的一条提示(`alias_hints`)。说的是**这台机器上检测到的 + * 客户端**会怎么对待这个名称,所以由这一侧判断(`crate::aliases`)。只给码和参数,句子在界面 + */ +export type AliasHint = { "code": "claude_code_reserved", name: string, } | { "code": "family_mismatch", family: string, model: string, clients: Array, } | { "code": "claude_desktop_hidden", name: string, } | { "code": "claude_desktop_shown", name: string, } | { "code": "model_lists_update", clients: Array, }; + /** * 按客户端原来直连 Bedrock 时的设置新建 Bedrock 上游,要填的那几项。 * @@ -45,6 +51,53 @@ gateway_base: string, */ keys: Array, }; +/** + * 给 Claude Desktop 选模型:写进它配置的那个名称,和它的密钥能用的上游模型 + */ +export type DesktopPick = { +/** + * 写进它配置的模型名(它发来的就是这个) + */ +written: string, +/** + * 可选的模型,按网关列出的顺序。选一个,规则按顺序指定提供它的每一家上游 + */ +choices: Array, }; + +/** + * 接管 Claude Desktop 时在它的密钥上加的那条「指定模型」规则。 + * + * 网关列出的模型它一个都不收时(它只认名称像 Claude 的),接管时选一个上游模型, + * 这条规则把它的请求都发给那个模型:插在它的密钥所用路由的最前面,条件只有它的 + * 密钥。取消接管时删掉。别的客户端不受影响。 + */ +export type DesktopRule = { +/** + * 规则在(或者要加进)哪条路由 + */ +route: string, +/** + * 是那条路由的第几条,从 1 起 + */ +position: number, +/** + * 规则的名字 + */ +name: string, +/** + * 条件里的密钥:它的那把 + */ +key: string, +/** + * 要选模型(网关没有它认的模型)时:写进它配置的名称和可选的模型。没有就是 + * 不用选 —— 这时已有的那条规则要删掉(还原,或者网关已经有它认的模型了) + */ +pick?: DesktopPick | null, +/** + * 已经有这条规则时,它此刻指定的模型 + */ +current?: Array | null, }; + /** * 一个客户端此刻的样子。 */ @@ -94,6 +147,11 @@ costs: Array, * `true`;点一下走一遍接管的「差异 → 确认 → 写入」重写它,**不在后台悄悄改** */ models_stale: boolean, +/** + * 配置里此刻写着的模型。只有把模型写进配置的客户端(opencode、Pi、oh-my-pi、 + * Grok Build、Qwen Code)有;删除别名时据此说出哪几个已接管客户端的模型列表写着它 + */ +models?: Array | null, /** * 配置位置能换(行菜单里给「更改路径…」,见 [`ClientLocations`])。Claude Desktop、 * DeepSeek Harness 和 WSL 里的不能 @@ -430,6 +488,11 @@ env_keys: Array, enabled: boolean, source: string, */ third_party: boolean, }; +/** + * 一个上游模型,和提供它的上游(按网关排的顺序) + */ +export type ModelChoice = { model: string, providers: Array, }; + /** * 算好但还没落盘的改动。**UI 拿它画 diff 让用户确认。** */ @@ -476,7 +539,12 @@ also?: Array, * 客户端原来直连 Bedrock:按它原来的设置新建 Bedrock 上游要填的。界面据此给一个 * 「新建上游」的入口;还原时没有 */ -bedrock?: BedrockDraft | null, }; +bedrock?: BedrockDraft | null, +/** + * 接管、还原 Claude Desktop 时网关上要改的那条路由规则(`clients::desktop_rule`)。 + * 不用改的、别的客户端没有 + */ +desktop_rule?: DesktopRule | null, }; /** * 批准一个插件磁盘上改过的文件(`plugin_approve_confirmed`)。**文件由 Rust 自己去取**: diff --git a/src/generated/tw-api.ts b/src/generated/tw-api.ts index bb9802d0..9dbdd002 100644 --- a/src/generated/tw-api.ts +++ b/src/generated/tw-api.ts @@ -1,6 +1,6 @@ // Generated by tw-api (`tw_api::ts::export_all`). Do not edit by hand. -export const CONTROL_API_VERSION = 37; +export const CONTROL_API_VERSION = 38; /** * 一个账号上游登的是哪个账号。 @@ -50,6 +50,172 @@ held_route: boolean, */ stayed?: Stay | null, }; +/** + * 一个别名上用户能改的东西:名字,和它在各家上游叫什么(有序)。 + * + * 和配置里 `aliases` 的一项是同一件事:哪家上游提供列表里的任一名称,就能服务这个 + * 别名,发过去用它自己的那个名称(按列表顺序取它有的第一个)。 + */ +export type AliasInput = { name: string, models: Array, }; + +/** + * 别名列表里的一个名称,以及哪些上游的模型清单里有它。 + */ +export type AliasModel = { model: string, +/** + * 清单里有这个名称、而且它在启用范围里的上游,按配置里的顺序。**没有清单的上游不在 + * 这里**:不知道它有什么 + */ +providers: Array, }; + +/** + * 预览的结果:存得存不了,存了之后发往哪儿。 + */ +export type AliasPreview = { +/** + * 挡着保存的问题,和保存时配置校验说的是同一句。空 = 存得了 + */ +problems: Array, +/** + * 存了之后每家上游发出的名称(同 [`AliasView::served_by`]) + */ +served_by: Array, +/** + * 列表里没有哪家上游的清单里有的名称。准入会拒掉这个别名时(`served_by` 是空的) + * 是列表里的全部名称 + */ +unserved: Array, +/** + * 同 [`AliasView::shadows`] + */ +shadows: Array, +/** + * 别的上游上的同一个模型(建议勾选):清单里的名称和列表里某个名称归一之后相等, + * 规则同 [`AliasesView::suggestions`]。列表里已经有的名称、已经有列表里某个名称的 + * 上游不在里面 + */ +same_model: Array, }; + +/** + * 预览一个还没保存的别名(`POST /alias-preview`)。 + */ +export type AliasPreviewRequest = { alias: AliasInput, +/** + * 正在编辑的那个别名原来的名字。新建时不给。**给了才不会把改名当成和自己重名** + */ +original?: string | null, }; + +/** + * 一条写着这个名称的规则。 + */ +export type AliasRuleRef = { route: string, rule: string, +/** + * `when.model` 或 `set.model` + */ +field: string, }; + +/** + * 新建或保存一个别名(`POST /aliases`、`PUT /aliases/{name}`)。保存时名字可以改: + * 引用它的密钥和规则在同一个版本里跟着改(见 [`AliasWritten`])。 + */ +export type AliasSave = { alias: AliasInput, base_version?: string | null, }; + +/** + * 一条建议:几家上游上名称不同、其实是同一个模型。**只认 Claude**(见 + * [`AliasesView::suggestions`])。 + */ +export type AliasSuggestion = { +/** + * 给人看的名字:`Claude Opus 5`;带日期的快照后面跟着日期: + * `Claude Sonnet 4.5 (2025-09-29)` + */ +label: string, +/** + * 每家上游上的名称,按配置里上游的顺序 + */ +models: Array, }; + +/** + * 谁在用这个别名(`GET /aliases/{name}/usage`):删除、改名之前摆给人看。 + */ +export type AliasUsage = { +/** + * 最近 24 小时里客户端用这个名称发来的请求 + */ +requests_24h: number, +/** + * 可见模型(`allow`)里有一项正是这个名称的密钥。**按整项比**,通配不算 + */ +keys: Array, +/** + * 条件(`when.model`)或改写(`set.model`)正是这个名称的规则。阶段二规则的 + * `set.model` 不算:它原样发出,不经过别名表 + */ +rules: Array, }; + +/** + * 别名页的一行。 + */ +export type AliasView = { name: string, +/** + * 按书写顺序 + */ +models: Array, +/** + * 每家能服务它的上游实际发出的名称,按配置里的顺序。停用的上游不在里面;没有清单 + * 的上游当作能服务(取列表里第一个在它启用范围里的名称)。**和网关一致**:有上游 + * 有清单、而有清单的上游谁都不提供列表里的名称时是空的 —— 请求在准入就被拒了, + * 没有清单的上游也轮不到 + */ +served_by: Array, +/** + * 清单里有一个和别名同名的真模型、而别名的列表里没有这个名称的上游:**这个名称 + * 不会再发给它们**(别名优先) + */ +shadows: Array, +/** + * 上下文窗口,来自默认价目表:第一家能服务它的上游发出的那个模型的 + */ +context_window?: number | null, +/** + * 最近 24 小时里客户端用这个名称发来的请求 + */ +requests_24h: number, +/** + * 那些请求的费用(百万分之一美元)。没有请求、或者一条都算不出钱时是空 + */ +cost_micros_24h?: number | null, }; + +/** + * 保存了一个别名(`PUT /aliases/{name}`)。 + */ +export type AliasWritten = { version: string, +/** + * 改名时在同一个版本里一起改掉的引用。没改名时是空的;`requests_24h` 总是 0。 + * + * **旧名也是改名后列表里的一个模型名时**,密钥的 `allow` 和规则的 `when.model` + * 不改,也不在这里:改名之后旧名说的是那个真模型,写真名的放行和条件照样继承到 + * 改名后的别名。`set.model` 照样改(它是要发的名称,不继承) + */ +renamed_in: AliasUsage, }; + +/** + * 别名页(`GET /aliases`)。 + */ +export type AliasesView = { +/** + * 按书写顺序 + */ +aliases: Array, +/** + * 同一个模型在不同上游上叫不同名称的几组。**只认 Claude**:各家清单里的名称归一之后 + * 完全相等才算同一个 —— 去掉路径前缀(`anthropic/`)、Bedrock 的地域前缀、 + * `anthropic.` 和 `-v1:0`,Vertex 的 `@日期` 写成 `-日期`,版本号里的点写成横线; + * **日期保留**,带日期和不带日期的不算同一个。至少两家上游、至少两种写法才成组; + * 已经被某一个别名全部列进去的组不出 + */ +suggestions: Array, }; + /** * 尝试链里一跳的结果。 */ @@ -63,8 +229,9 @@ export type AttemptOutcome = "served" | "status" | "error" | "estimated"; */ export type AttemptView = { provider: string, /** - * 规则改写了模型名:这一跳发给(没发出去的,要发给)上游的是哪个。没改写的 - * 没有 —— 发出去的就是客户端要的那个(`RequestStarted::model`)。 + * 这一跳发给(没发出去的,要发给)上游的模型名,和客户端要的不一样时才有:别名对到 + * 这一家的名称、规则改写、规则指定的模型、插件改名。一样的没有 —— 发出去的就是客户端 + * 要的那个(`RequestStarted::model`)。 * * **费用按它算**:请求改写成另一个模型发出去,上游按那个模型收钱。 */ @@ -712,6 +879,23 @@ export type DefaultRouteSave = { name: string, base_version?: string | null, }; */ export type Dialect = "anthropic" | "openai-chat" | "openai-responses" | "gemini" | "bedrock"; +/** + * 试算里一个候选上游收到的模型名。 + * + * **同一个请求在各家可能是不同的名字**:别名对到每一家自己的名称,规则改写、规则指定的 + * 模型也会换掉它。 + */ +export type DryRunCandidate = { provider: string, +/** + * 发给它的模型名。试算没写模型时没有 + */ +sent_model?: string | null, +/** + * 发出的名字为什么和请求里写的不一样:`alias`(别名对到这一家的名称)、`rule`(规则 + * 改写了模型)、`pinned`(规则指定了这一家发什么模型)。一样时没有 + */ +model_via?: string | null, }; + /** * 一次试算的结论。 */ @@ -781,6 +965,10 @@ reason: string | null, * 候选链,第一个是首选,后面是故障转移的备选 */ candidates: Array, +/** + * 每个候选发出去的模型名,和 `candidates` 一一对应 + */ +candidate_models: Array, /** * 经过了哪个组 */ @@ -1667,8 +1855,25 @@ export type KnownChatgptPlan = "free" | "go" | "plus" | "pro" | "prolite" | "pro /** * 网关知道的一个模型,以及能提供它的上游(已按启用范围与停用过滤)。 + * + * **别名也是一项**:`id` 是别名的名称,`alias` 是它的模型列表。别名和某个真模型同名 + * 时只有一项,就是别名 —— 请求这个名称按别名处理。 + */ +export type KnownModel = { id: string, +/** + * 有模型清单的上游里提供它的那几家,按配置里上游的顺序。别名是能服务它的那几家: + * 它的模型列表里至少有一个名称在这家的清单里、也在启用范围里。没有清单的上游 + * 不在这里(不知道它有什么),但照样收这个名称的请求 + */ +providers: Array, +/** + * 这一项是别名:它的模型列表,按顺序 + */ +alias?: Array | null, +/** + * 列出了这个模型名的别名,按别名表的顺序。别名自己这一项是空的 */ -export type KnownModel = { id: string, providers: Array, }; +aliases: Array, }; /** * 建连的那一步对着谁。 @@ -1679,7 +1884,7 @@ export type L1Peer = "upstream" | "proxy"; * L1 测速:只握手,不发业务请求。**零成本零副作用**。 * * 给了名字就测那一家,不给就测所有上游。代理自己的检测走 - * `/proxies/test`,还没保存的上游走 `/providers/test`。 + * `/proxy-test`,还没保存的上游走 `/provider-test`。 * * **不接受一个「候选 URL 列表」。** cc-switch 有那么一张表,测完还得手动 * 点一下填进去,运行时永远只认当前保存的那一个 —— 同一个概念在一个程序 @@ -1815,7 +2020,7 @@ export type LoginStatus = "pending" | "done" | "failed" | "expired" | "cancelled * **都是插件写的字**。 * * 出错时怎么办、范围、设置的值**都在文件里**:这里读到的就是这份源码装上之后的样子。 - * 要改它们,用 `POST /plugins/rewrite` 改写源码 + * 要改它们,用 `POST /plugin-rewrite` 改写源码 */ export type ManifestView = { name: string, description: string | null, permissions: Array, /** @@ -1943,7 +2148,12 @@ price?: PriceFields | null, price_source?: PriceSourceView | null, /** * 价格是从别的平台借来的。**按它算出来的钱是估算** */ -estimated: boolean, }; +estimated: boolean, +/** + * 列出了这个模型名的别名,按别名表的顺序。**不论这家发不发它**:别名在这家按列表 + * 顺序取它有的第一个,排在后面的名字也算列进了这个别名 + */ +aliases: Array, }; /** * 一个上游的模型清单从哪儿来。 @@ -2118,6 +2328,13 @@ export type PatchValue = string | number | boolean | null; */ export type Permission = "system" | "messages" | "tools" | "params" | "reply_text" | "reply_tool_calls"; +/** + * 规则「指定模型」里的一项,也是别名在某家上游实际发出的名称:哪家上游、发什么模型名。 + * + * **模型名原样发出**,不经过别名表 —— 和配置里 `to: [{provider, model}]` 同一个形状。 + */ +export type PinnedModel = { provider: string, model: string, }; + /** * 批准磁盘上改过的那个文件(`POST /plugins/{id}/approve`、 * `POST /plugins/{id}/approve/confirmed`)。插件改得了回答里的工具调用(新旧两份里有一份的 @@ -2132,12 +2349,12 @@ export type PluginApprove = { sha256: string, base_version?: string | null, }; /** - * 装一个插件(`POST /plugins`、`POST /plugins/confirmed`)。出错时怎么办、范围、设置的值 - * 都在源码里(`POST /plugins/rewrite` 改写)。 + * 装一个插件(`POST /plugins`、`POST /plugin-confirmed`)。出错时怎么办、范围、设置的值 + * 都在源码里(`POST /plugin-rewrite` 改写)。 * * 插件改得了回答里的工具调用(权限有 [`Permission::ReplyToolCalls`])时,`POST /plugins` * 拒绝(403,`control.plugin.needs_confirmation`),要在系统的确认框里点过头、走 - * `POST /plugins/confirmed`。 + * `POST /plugin-confirmed`。 */ export type PluginCreate = { source: string, /** @@ -2209,7 +2426,7 @@ request_id: number | null, hook: PluginHook, level: PluginLogLevel, text: string export type PluginLogLevel = "log" | "info" | "warn" | "error"; /** - * 排顺序(`PUT /plugins/order`):**全部 id**,按新的顺序。 + * 排顺序(`PUT /plugin-order`):**全部 id**,按新的顺序。 */ export type PluginOrder = { ids: Array, base_version?: string | null, }; @@ -2219,7 +2436,7 @@ export type PluginOrder = { ids: Array, base_version?: string | null, }; export type PluginOutcome = "unchanged" | "changed" | "rejected" | "error" | "skipped"; /** - * 改写一份源码里的数据(`POST /plugins/rewrite`):出错时怎么办、范围、设置的值。**什么都不 + * 改写一份源码里的数据(`POST /plugin-rewrite`):出错时怎么办、范围、设置的值。**什么都不 * 留下**,只交回改写之后的源码([`PluginSource`])—— 只换 manifest 字面量那一段,别的字节 * 一个不动;字面量里的注释不保留。 * @@ -2279,7 +2496,7 @@ models: Array, upstreams: Array, }; /** - * 一份源码(`POST /plugins/inspect`)。 + * 一份源码(`POST /plugin-inspect`)。 */ export type PluginSource = { source: string, }; @@ -3002,7 +3219,12 @@ export type ReplayOriginal = { provider: string, status: number | null, ttfb_ms: /** * 报价。**按下确认之前必须看到它**(和 L3 测速同一条纪律)。 */ -export type ReplayQuote = { model: string, provider: string, body_bytes: number, input_tokens: number, +export type ReplayQuote = { +/** + * 重放发给这一家的模型名,**按它报价**:客户端要的是别名时是这一家自己的那个名字, + * 原来那一次发给这一家的名字改写过的是改写后的那个 + */ +model: string, provider: string, body_bytes: number, input_tokens: number, /** * `None` = 这个模型无法计价。**不是 0** */ @@ -3330,7 +3552,11 @@ export type RuleInput = { name: string, /** * 空 = 兜底,匹配全部请求 */ -conditions: Array, to?: string | null, +conditions: Array, +/** + * 去向,和 [`RuleView::to`] 同一个写法。指定模型的列表原样写进配置,交空列表会被拒 + */ +to?: RuleTarget | null, /** * 拒绝,以及返回给客户端的原因 */ @@ -3349,6 +3575,15 @@ model?: string | null, max_tokens?: number | null, */ thinking?: boolean | null, }; +/** + * 规则转发到哪里([`RuleView::to`]、[`RuleInput::to`])。 + * + * **线上和配置里的 `to` 一个写法**:一个字符串是上游或策略组的名字(`__all__` 是全部 + * 上游),一个列表是指定模型 —— 「上游 + 模型」,按顺序备用,模型名原样发出。前端的 + * 类型是 `string | Array`。 + */ +export type RuleTarget = string | Array; + /** * 启用或停用一条规则(内置的或自定义的)。 */ @@ -3395,9 +3630,9 @@ export type RuleView = { name: string, */ conditions: Array, /** - * 去向:上游名或组名。拒绝的规则和只附加改写的规则没有 + * 去向:上游名或组名,或者指定模型的列表。拒绝的规则和只附加改写的规则没有 */ -to?: string | null, +to?: RuleTarget | null, /** * 命中就拒绝。值是返回给客户端的原因 */ @@ -3779,7 +4014,7 @@ export type SecurityView = { redact: GuardMode, inspect_tools: GuardMode, conten /** * 一个上游为什么服务不了这个模型。 */ -export type ServeSkip = "disabled" | "out_of_scope" | "not_offered"; +export type ServeSkip = "disabled" | "out_of_scope" | "not_offered" | "not_allowed"; export type SessionDetail = { session: SessionView, turns: Array, }; @@ -3867,7 +4102,7 @@ export type SheetRef = { "kind": "default" } | { "kind": "named", name: string, */ export type SkippedView = { provider: string, /** - * `disabled` / `out_of_scope` / `not_offered` + * `disabled` / `out_of_scope` / `not_offered` / `not_allowed` */ reason: ServeSkip, }; @@ -3877,7 +4112,11 @@ reason: ServeSkip, }; * **这是「你确认要花钱吗」那个对话框的全部内容。**触发前必须显示它, * 而不是点了才知道。 */ -export type SpeedEstimate = { provider: string, model: string, +export type SpeedEstimate = { provider: string, +/** + * 发给这一家的模型名,按它报价:要测的是别名时是这一家自己的那个名字 + */ +model: string, /** * 输入 token。**精确值** —— 请求是固定的 */ @@ -3914,7 +4153,11 @@ total_micros?: number | null, pricing_date: string, }; /** * 一次 L3 测速的结果。 */ -export type SpeedResult = { provider: string, model: string, ok: boolean, connect_ms: number, +export type SpeedResult = { provider: string, +/** + * 发给这一家的模型名(别名对到这一家的那个名字) + */ +model: string, ok: boolean, connect_ms: number, /** * **首 token。**这一层唯一值得测的东西 */ @@ -4400,17 +4643,23 @@ export const ENDPOINTS = { RotateKey: { method: "POST", path: "/keys/{name}/rotate", params: ["name"], format: "json" }, SetDefaultKey: { method: "PUT", path: "/default_key", params: [], format: "json" }, CreateProvider: { method: "POST", path: "/providers", params: [], format: "json" }, - TestProvider: { method: "POST", path: "/providers/test", params: [], format: "json" }, - PreviewProvider: { method: "POST", path: "/providers/preview", params: [], format: "json" }, + TestProvider: { method: "POST", path: "/provider-test", params: [], format: "json" }, + PreviewProvider: { method: "POST", path: "/provider-preview", params: [], format: "json" }, UpdateProvider: { method: "PUT", path: "/providers/{name}", params: ["name"], format: "json" }, DeleteProvider: { method: "DELETE", path: "/providers/{name}", params: ["name"], format: "json" }, ProviderModels: { method: "GET", path: "/providers/{name}/models", params: ["name"], format: "json" }, RefreshProviderModels: { method: "POST", path: "/providers/{name}/models/refresh", params: ["name"], format: "json" }, RefreshStaleModels: { method: "POST", path: "/models/refresh", params: [], format: "json" }, CreateProxy: { method: "POST", path: "/proxies", params: [], format: "json" }, - TestProxy: { method: "POST", path: "/proxies/test", params: [], format: "json" }, + TestProxy: { method: "POST", path: "/proxy-test", params: [], format: "json" }, UpdateProxy: { method: "PUT", path: "/proxies/{name}", params: ["name"], format: "json" }, DeleteProxy: { method: "DELETE", path: "/proxies/{name}", params: ["name"], format: "json" }, + Aliases: { method: "GET", path: "/aliases", params: [], format: "json" }, + CreateAlias: { method: "POST", path: "/aliases", params: [], format: "json" }, + PreviewAlias: { method: "POST", path: "/alias-preview", params: [], format: "json" }, + UpdateAlias: { method: "PUT", path: "/aliases/{name}", params: ["name"], format: "json" }, + DeleteAlias: { method: "DELETE", path: "/aliases/{name}", params: ["name"], format: "json" }, + AliasUsage: { method: "GET", path: "/aliases/{name}/usage", params: ["name"], format: "json" }, CreateRoute: { method: "POST", path: "/routes", params: [], format: "json" }, UpdateRoute: { method: "PUT", path: "/routes/{name}", params: ["name"], format: "json" }, DeleteRoute: { method: "DELETE", path: "/routes/{name}", params: ["name"], format: "json" }, @@ -4437,11 +4686,11 @@ export const ENDPOINTS = { DeleteCustomRule: { method: "DELETE", path: "/security/{guard}/custom/{name}", params: ["guard", "name"], format: "json" }, TestSecurity: { method: "POST", path: "/security/{guard}/test", params: ["guard"], format: "json" }, Plugins: { method: "GET", path: "/plugins", params: [], format: "json" }, - PluginInspect: { method: "POST", path: "/plugins/inspect", params: [], format: "json" }, - PluginRewrite: { method: "POST", path: "/plugins/rewrite", params: [], format: "json" }, + PluginInspect: { method: "POST", path: "/plugin-inspect", params: [], format: "json" }, + PluginRewrite: { method: "POST", path: "/plugin-rewrite", params: [], format: "json" }, CreatePlugin: { method: "POST", path: "/plugins", params: [], format: "json" }, - CreatePluginConfirmed: { method: "POST", path: "/plugins/confirmed", params: [], format: "json" }, - ReorderPlugins: { method: "PUT", path: "/plugins/order", params: [], format: "json" }, + CreatePluginConfirmed: { method: "POST", path: "/plugin-confirmed", params: [], format: "json" }, + ReorderPlugins: { method: "PUT", path: "/plugin-order", params: [], format: "json" }, SavePlugin: { method: "PUT", path: "/plugins/{id}", params: ["id"], format: "json" }, SavePluginConfirmed: { method: "PUT", path: "/plugins/{id}/confirmed", params: ["id"], format: "json" }, DeletePlugin: { method: "DELETE", path: "/plugins/{id}", params: ["id"], format: "json" }, @@ -4526,6 +4775,12 @@ export type Endpoints = { TestProxy: { req: ProxyTest; res: L1Result }; UpdateProxy: { req: ProxySave; res: ConfigWritten }; DeleteProxy: { req: BaseVersion; res: ConfigWritten }; + Aliases: { req: null; res: AliasesView }; + CreateAlias: { req: AliasSave; res: ConfigWritten }; + PreviewAlias: { req: AliasPreviewRequest; res: AliasPreview }; + UpdateAlias: { req: AliasSave; res: AliasWritten }; + DeleteAlias: { req: BaseVersion; res: ConfigWritten }; + AliasUsage: { req: null; res: AliasUsage }; CreateRoute: { req: RouteSave; res: ConfigWritten }; UpdateRoute: { req: RouteSave; res: ConfigWritten }; DeleteRoute: { req: RouteDelete; res: ConfigWritten }; diff --git a/src/guide/hints.test.ts b/src/guide/hints.test.ts index d3c2ff57..87c19202 100644 --- a/src/guide/hints.test.ts +++ b/src/guide/hints.test.ts @@ -63,6 +63,18 @@ describe("引导提示", () => { expect(JSON.parse(storage.raw()!)).toEqual({ dismissed: [], setupSeen: true }); }); + it("一次记下好几条(别名的建议「忽略」全部),记过的不重复,「重新显示」一起回来", async () => { + const storage = memoryStorage(JSON.stringify({ dismissed: ["next-clients"], setupSeen: false })); + const mod = await load(storage); + const Dismissed = () => mod.useDismissedHints().join(" "); + mod.dismissHints(["alias-suggestion:Claude Opus 5|a,b", "next-clients", "alias-suggestion:Claude Opus 5|a,b"]); + expect(renderToStaticMarkup(createElement(Dismissed))).toBe("next-clients alias-suggestion:Claude Opus 5|a,b"); + const again = await load(memoryStorage(storage.raw())); + expect(renderToStaticMarkup(createElement(() => again.useDismissedHints().length))).toBe("2"); + again.resetHints(); + expect(renderToStaticMarkup(createElement(() => again.useDismissedHints().length))).toBe("0"); + }); + it("「开始使用」出现过才记一笔,只记一次", async () => { const storage = memoryStorage(); const mod = await load(storage); diff --git a/src/guide/hints.ts b/src/guide/hints.ts index b5e3b433..32f93155 100644 --- a/src/guide/hints.ts +++ b/src/guide/hints.ts @@ -19,7 +19,12 @@ export type HintId = /** 流量页:点开一条请求能看到什么 */ | "traffic-open-row" /** 安全页:防护出厂是观察 */ - | "security-observe"; + | "security-observe" + /** + * 上游页「别名」标签:同一模型在几个上游名称不同的一条建议,点过「忽略」。 + * 后面是这条建议的标识(`aliases/logic.ts` 的 `suggestionKey`) + */ + | `alias-suggestion:${string}`; const KEY = "tw-guide"; @@ -89,6 +94,20 @@ export function useHint(id: HintId, when: boolean): { show: boolean; dismiss: () }; } +/** + * 点过「不再显示」的全部提示。一处要看好几条的时候用(「别名」标签上的几条建议、 + * 标签名旁的小圆点),一条一条的用 `useHint` + */ +export function useDismissedHints(): readonly string[] { + return useSyncExternalStore(subscribe, getDismissed, getDismissed); +} + +/** 一次记下好几条「不再显示」(「忽略」全部建议) */ +export function dismissHints(ids: readonly HintId[]) { + const add = [...new Set(ids)].filter((id) => !state.dismissed.includes(id)); + if (add.length > 0) commit({ ...state, dismissed: [...state.dismissed, ...add] }); +} + /** 「开始使用」出现过没有(见 `Stored.setupSeen`) */ export function useSetupSeen(): boolean { return useSyncExternalStore(subscribe, getSetupSeen, getSetupSeen); diff --git a/src/i18n/core.zh.json b/src/i18n/core.zh.json index 0ffd7255..f252d9ec 100644 --- a/src/i18n/core.zh.json +++ b/src/i18n/core.zh.json @@ -12,7 +12,8 @@ "redaction rule": "出站脱敏规则", "tool-call rule": "工具调用审查规则", "content rule": "内容过滤规则", - "plugin": "插件" + "plugin": "插件", + "alias": "别名" }, "setting_kind": { "string": "字符串", @@ -317,6 +318,7 @@ "gw.plugin.unavailable": "插件「{plugin}」无法加载,请求未发送:{detail}", "gw.plugin.cannot_read_body": "插件「{plugin}」无法读取此请求:{detail}", "gw.plugin.model_not_allowed": "插件「{plugin}」将模型改为 {model},而网关密钥「{key}」不允许使用该模型,请求未发送。", + "gw.plugin.alias_unserved": "插件「{plugin}」将模型改为别名 {model},而上游「{upstream}」不提供它列出的任何模型,请求未发往该上游。", "gw.plugin.file_changed": "插件「{plugin}」的文件已更改,此插件不再运行。请在应用中审核并确认更改。", "gw.plugin.failed": "插件运行失败。", "gw.plugin.cpu_limit": "插件使用的 CPU 时间超出上限。", @@ -376,6 +378,12 @@ "gw.model.not_allowed_rewritten": "路由规则把模型 {from} 改写为 {model},网关密钥「{key}」无权使用 {model},可用模型请参见 GET /v1/models。", "gw.model.no_upstream_available": "没有可用的上游提供模型 {model}:{detail}", "gw.model.no_upstream_rewritten": "路由规则把模型 {from} 改写为 {model},但没有上游提供 {model},可用模型请参见 GET /v1/models。", + "gw.model.alias_unserved": "没有上游能服务别名 {model}:{models} 均无上游提供,可用模型请参见 GET /v1/models。", + "gw.model.alias_unserved_rewritten": "路由规则把模型 {from} 改写为别名 {model},但 {models} 均无上游提供,可用模型请参见 GET /v1/models。", + "gw.ws.alias_unserved": "这条 WebSocket 连接接到上游 `{upstream}`,它不提供别名 {model} 的任何一个模型({models}),请求未发出。", + "gw.ws.alias_unserved_rewritten": "路由规则把模型 {from} 改写为别名 {model}。这条 WebSocket 连接接到上游 `{upstream}`,它不提供其中任何一个模型({models}),请求未发出。", + "gw.model.pinned_not_allowed": "规则「{rule}」指定了上游「{upstream}」的模型 {model},而网关密钥「{key}」无权使用该模型。", + "gw.model.pinned_not_offered": "规则「{rule}」指定的以下模型,其上游并不提供:{detail}。", "gw.route.failed": "路由失败:{detail}", "gw.route.rule_failed": "规则求值失败:{detail}", "gw.route.denied": "规则「{rule}」拒绝了此请求:{reason}", @@ -426,6 +434,7 @@ "control.key_not_found": "未找到名为「{key}」的网关密钥。", "control.listen.bad_port": "端口须在 1 到 65535 之间。", "control.replay_bedrock": "重放会原样发送记录下的请求,而「{upstream}」是 Bedrock 上游,只接受客户端不会发送的 Converse 格式。请重放到与客户端格式相同的上游。", + "control.replay_alias_unserved": "{model} 是别名,而上游「{upstream}」不提供它列出的任何模型,无法将请求重放到该上游。", "// ── gw.listen:网关换监听地址 ────────────────────────────────────": "", "gw.listen.port_taken": "{addr} 已被其他程序占用。", "gw.listen.addr_unavailable": "{addr} 当前不是本机的地址。", @@ -549,7 +558,6 @@ "// ── control.plugin:装插件、改插件、批准文件、试运行。{plugin} 在 ID 上是 id,在确认上是插件的名字 ──": "", "control.plugin.not_found": "插件「{plugin}」不存在。", "control.plugin.bad_id": "「{plugin}」不是有效的插件 ID:只能使用小写字母、数字和连字符,1 到 {max} 个字符。", - "control.plugin.reserved_id": "「{plugin}」不能用作插件 ID:控制面自身使用了这个词。", "control.plugin.id_taken": "已存在 ID 为「{plugin}」的插件。", "control.plugin.blank_pattern": "适用范围中有空白项。请删除该项,或填写名称或带 * 的通配。", "control.plugin.unreadable": "无法读取插件文件 {file}:{detail}", @@ -594,6 +602,14 @@ "config.unknown_price_sheet": "上游「{upstream}」使用的价目表「{sheet}」不存在。", "config.empty_models_only": "上游「{upstream}」的模型范围(models_only)为空,不提供任何模型。暂停使用该上游请改为停用(disabled: true)。", "config.blank_models_only": "上游「{upstream}」的模型范围(models_only)中有空白项。", + "config.alias_empty_name": "aliases 中有一个别名的名称为空。", + "config.alias_wildcard": "别名名称「{alias}」含有 * 或 ?。别名只能是一个确切的名称;要匹配多个模型,请在网关密钥的 allow 或规则的 when.model 中写通配。", + "config.alias_reserved": "别名名称「{alias}」以 __ 开头,该前缀保留给内置项,请使用其他名称。", + "config.alias_duplicate": "别名名称「{alias}」重复。", + "config.alias_no_models": "别名「{alias}」没有列出任何模型。请列出各上游对该模型使用的名称,或删除该别名。", + "config.alias_blank_model": "别名「{alias}」列出的模型中有空白项。", + "config.alias_chained": "别名「{alias}」列出的 {model} 本身也是别名。别名列出的应是上游使用的模型名称,不能是其他别名。", + "config.alias_only_itself": "别名「{alias}」只列出了它自己,不起任何作用。请列出各上游使用的名称,或删除该别名。", "config.reserved_name": "{what:kind}名称「{name}」以 __ 开头,该前缀保留给内置项,请使用其他名称。", "config.rule_name_empty": "有一条自定义{what:rule_line}规则没有名称。", "config.rule_name_taken": "自定义{what:rule_line}规则名称「{name}」重复。", @@ -604,7 +620,6 @@ "config.unknown_rule": "security.{guard} 中的「{rule}」不是内置规则。", "config.failover_range": "failover.{field} 为 {value},须在 {min} 到 {max} 之间。", "config.plugin.bad_id": "插件 ID「{plugin}」写法有误:只能使用小写字母、数字和连字符,1 到 {max} 个字符。", - "config.plugin.reserved_id": "「{plugin}」不能用作插件 ID:控制面自身使用了这个词。", "config.plugin.duplicate": "插件 ID「{plugin}」重复。", "config.plugin.file": "插件「{plugin}」的文件为 {file},应为 plugins/{plugin}.js。", "config.plugin.sha256": "插件「{plugin}」的 sha256 应为 64 位小写十六进制字符。", @@ -680,6 +695,12 @@ "engine.duplicate_route": "路由名称「{route}」重复。网关密钥按名称绑定路由,名称必须唯一。", "engine.unknown_default_route": "default_route 指向的路由「{route}」不存在,未绑定路由的网关密钥将无法命中任何规则。", "engine.unknown_route": "网关密钥「{key}」绑定的路由「{route}」不存在。", + "engine.pinned_empty": "规则「{rule}」的指定模型列表为空。请至少列出一个上游和模型,或改为转发到上游或策略组。", + "engine.pinned_unknown_provider": "规则「{rule}」指定模型时写的「{upstream}」不是上游。指定模型须写上游的名称,不能写策略组。", + "engine.pinned_duplicate_provider": "规则「{rule}」的指定模型中,上游「{upstream}」出现了两次。每个上游在列表中只能出现一次。", + "engine.pinned_blank_model": "规则「{rule}」为上游「{upstream}」指定的模型名称为空。", + "engine.pinned_with_provider_would_be": "规则「{rule}」同时设置了指定模型和 provider_would_be。provider_would_be 要在选定上游之后才能判断,而指定模型本身已经选定了上游。", + "engine.pinned_with_set_model": "规则「{rule}」同时设置了指定模型和 set.model。指定的模型名会原样发出,set.model 不起作用,请删除其中一项。", "// 比较式写错。在路由编辑对话框里多带一个 `rule`(是哪条规则)": "", "engine.compare.empty": "{?rule:规则「{rule}」:}条件 {field} 写法有误:比较式为空。", "engine.compare.no_operator": "{?rule:规则「{rule}」:}条件 {field} 写法有误:「{value}」缺少比较符,应以 > < >= <= = 之一开头,例如 \">200k\"。", @@ -868,7 +889,6 @@ "adopt.plan.shadowed.whole_file": "检测到 {paths},{client} 只读取该文件,接管写入的配置不会生效。", "adopt.plan.pi.proxy_env": "已设置 {name},而 NO_PROXY 中没有 {host},Pi 访问网关的请求会经过该代理。将 {host} 加入 NO_PROXY 后改为直接连接。", "adopt.plan.pi.proxy_setting": "{path} 中设置了 httpProxy,而 NO_PROXY 中没有 {host},Pi 访问网关的请求会经过该代理。将 {host} 加入 NO_PROXY 后改为直接连接。", - "adopt.plan.claude_desktop.no_claude_model": "网关列出的模型中没有名称类似 Claude 的模型,而 Claude Desktop 只接受这类名称。将写入 {model} 作为其模型;需要在路由中添加一条规则,把 {model} 改写为上游实际使用的模型,例如:{rule}", "adopt.plan.cloud_off.settings": "{path} 打开了 {name},{client} 因此直连 {cloud:cloud}。接管期间关闭这个开关,还原时重新打开。", "adopt.plan.cloud_off.shell": "{path} 第 {line} 行导出了 {name},{client} 因此直连 {cloud:cloud}。接管期间关闭这个开关,还原时重新打开。", "adopt.plan.cloud_off.env": "环境变量中设置了 {name},{client} 因此直连 {cloud:cloud}。接管期间关闭这个开关,还原时重新打开。", @@ -883,6 +903,10 @@ "adopt.plan.claude_desktop.was_cloud": "Claude Desktop 当前通过配置「{name}」使用 {cloud:cloud}。接管期间改用 ThinkWatch 的配置,还原时切回「{name}」。", "adopt.plan.claude_desktop.bedrock_key": "Claude Desktop 的 Bedrock API 密钥保存在 {path} 中,网关无法读取。新建的上游从环境变量 AWS_BEARER_TOKEN_BEDROCK 读取密钥,需要网关的运行环境中有这个变量;也可以在新建上游时直接填写密钥。", "adopt.plan.claude_desktop.aws_dir": "Claude Desktop 从 {dir} 读取 AWS profile {profile},而网关读取 ~/.aws 中的凭证文件(除非 AWS_SHARED_CREDENTIALS_FILE 和 AWS_CONFIG_FILE 指定了其他文件)。", + "adopt.plan.claude_desktop.rule_name_taken": "路由「{route}」中已有名为「{rule}」的规则,且不是接管 Claude Desktop 时添加的。在路由页重命名或删除该规则后,重新接管。", + "adopt.claude_desktop.rule_left": "Claude Desktop 已还原,但路由「{route}」中的规则「{rule}」未能删除({detail})。请在路由页删除该规则。", + "adopt.claude_desktop.rule_unchecked": "Claude Desktop 已还原,但无法请网关删除接管时添加的路由规则「{rule}」({detail})。如该规则仍在,请在路由页删除。", + "adopt.claude_desktop.rule_not_reverted": "为 Claude Desktop 添加到路由「{route}」的规则「{rule}」仍然存在:Claude Desktop 未能接管({detail}),该规则也未能撤回({reason})。请在路由页删除该规则。", "adopt.restore.config_gone": "配置文件已不存在,仅删除接管记录。", "adopt.restore.secret_lost": "{field} 的原值是密钥,仅保存在全文备份中,而 {backup} 已不存在。该字段已删除,需要手动重新填写。", "adopt.restore.file_removed": "该文件在接管时新建,还原后内容为空,已一并删除。", @@ -908,6 +932,7 @@ "adopt.plan.managed": "这台电脑的 {client} 由组织统一管理({by}),无法接管,未做任何修改。", "adopt.plan.cloud_managed": "{path} 是组织统一管理的配置,其中打开了 {name},{client} 会直连 {cloud:cloud},无法接管,未做任何修改。", "adopt.plan.stale": "{client} 的配置在查看改动期间已被修改,未写入任何内容。请重新查看改动。", + "adopt.plan.gateway_changed": "网关为 {client} 提供的模型在查看改动期间发生了变化,未写入任何内容。请重新查看改动。", "adopt.mcp.unknown_client": "未知的客户端「{client}」。", "adopt.mcp.parse_failed": "无法解析 {client} 的 MCP 配置,未做任何修改:{detail}", "adopt.mcp.not_there": "{client} 中没有名为「{name}」的 MCP server。", diff --git a/src/keys/ModelScope.i18n.ts b/src/keys/ModelScope.i18n.ts index ed7ca10c..0cdddf24 100644 --- a/src/keys/ModelScope.i18n.ts +++ b/src/keys/ModelScope.i18n.ts @@ -26,10 +26,16 @@ export const modelScopeText = messages( source: "来源", byPattern: (p: string) => `由 ${p} 命中`, picked: "单独选中", + /** 别名随它列表里的某个模型一起可见 */ + inherited: (m: string) => `随 ${m} 放行`, + aliasOf: (models: string[]) => `别名,指向 ${models.join("、")}`, + /** 表格下面一句:继承只从上游模型名到别名 */ + aliasRule: "范围里有某个上游模型,指向它的别名也一起可见;只选别名,原来的名称不可见。", rest: (n: number) => `其余 ${n} 个`, noMatch: "没有匹配的模型", /** 规则命中的行点不动,说明为什么 */ lockedHint: (p: string) => `由 ${p} 命中,取消请修改该规则`, + inheritedHint: (m: string) => `随 ${m} 放行,${m} 不在范围里时才可单独取消`, }, { scope: "Visible models", @@ -60,8 +66,13 @@ export const modelScopeText = messages( source: "From", byPattern: (p: string) => `matched by ${p}`, picked: "picked", + inherited: (m: string) => `allowed with ${m}`, + aliasOf: (models: string[]) => `Alias for ${models.join(", ")}`, + aliasRule: + "An alias is visible whenever a model it points to is in scope. Picking only the alias leaves the original names hidden.", rest: (n: number) => (n === 1 ? "1 more" : `${n} more`), noMatch: "No model matches", lockedHint: (p: string) => `Matched by ${p}; edit that pattern to drop it`, + inheritedHint: (m: string) => `Allowed with ${m}; it can be dropped on its own once ${m} is out of scope`, }, ); diff --git a/src/keys/ModelScope.test.tsx b/src/keys/ModelScope.test.tsx index 7f57752c..0acfdd29 100644 --- a/src/keys/ModelScope.test.tsx +++ b/src/keys/ModelScope.test.tsx @@ -1,6 +1,8 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; +import { setLang } from "@/i18n"; import { ModelScope } from "./ModelScope"; +import type { KnownModel } from "@/types"; const noop = () => {}; @@ -12,7 +14,7 @@ describe("可见模型:指定范围", () => { , ); expect(html).toContain("尚未获取到任何上游的模型清单"); - expect(html).toMatch(/]*>gpt-5<\/td>/); + expect(html).toMatch(/]*>gpt-5<\/span><\/td>/); // 勾着的勾选框,点一下就是取消 expect(html).toMatch(/aria-label="gpt-5"[^>]*aria-checked="true"|aria-checked="true"[^>]*aria-label="gpt-5"/); }); @@ -25,3 +27,60 @@ describe("可见模型:指定范围", () => { expect(html).not.toContain("…` 里有这个模型名的那一行 */ +function rowOf(html: string, id: string): string { + return html.split(" r.includes(`aria-label="${id}"`)) ?? ""; +} + +describe("可见模型:别名", () => { + it("别名带一个「别名」标记;随模型放行的勾上、点不动,写明随哪个", () => { + setLang("zh"); + const html = renderToStaticMarkup( + , + ); + const alias = rowOf(html, "deepseek-v4.1"); + expect(alias).toContain(">别名<"); + expect(alias).toContain("随 DeepSeek-v4.1-flash 放行"); + expect(alias).toMatch(/aria-checked="true"/); + expect(alias).toMatch(/disabled=""|data-disabled/); + // 那个模型自己是单独选中的,能取消 + const model = rowOf(html, "DeepSeek-v4.1-flash"); + expect(model).toContain("单独选中"); + expect(model).not.toContain(">别名<"); + expect(model).not.toMatch(/disabled=""|data-disabled/); + // 表格下面说明这条规矩 + expect(html).toContain("范围里有某个上游模型,指向它的别名也一起可见;只选别名,原来的名称不可见。"); + expect(html).toContain("可见 2 / 共 3"); + }); + + it("只选别名,它的模型不勾", () => { + setLang("zh"); + const html = renderToStaticMarkup( + , + ); + expect(rowOf(html, "deepseek-v4.1")).toContain("单独选中"); + expect(rowOf(html, "DeepSeek-v4.1-flash")).toMatch(/aria-checked="false"/); + expect(html).toContain("可见 1 / 共 3"); + }); + + it("目录里没有别名时不写那句说明", () => { + setLang("zh"); + const html = renderToStaticMarkup( + , + ); + expect(html).not.toContain("指向它的别名"); + }); +}); diff --git a/src/keys/ModelScope.tsx b/src/keys/ModelScope.tsx index 06044106..8b4f9757 100644 --- a/src/keys/ModelScope.tsx +++ b/src/keys/ModelScope.tsx @@ -6,12 +6,15 @@ import { Input } from "@/ui/input"; import { InputGroup, InputGroupAddon, InputGroupInput } from "@/ui/input-group"; import { Segmented } from "@/ui/segmented"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/ui/table"; +import { AliasMark } from "@/aliases/AliasMark"; import { useText } from "@/i18n"; -import type { KnownModel } from "@/types"; import { Boxed, FormItem, Note } from "@/upstreams/parts"; +import type { KnownModel } from "@/types"; import { modelScopeText } from "./ModelScope.i18n"; import { addPattern, + hasAliases, + locked, patternHits, removeEntry, rowsOf, @@ -19,6 +22,7 @@ import { toggleModel, visibleCount, type Scope, + type Source, } from "./scope"; /** 一次画多少行。几百个模型全渲染出来,打开对话框会卡一下 */ @@ -30,6 +34,9 @@ const PAGE = 60; * **模型清单来自网关聚合后的目录,不按这把密钥的路由过滤。**core 的准入 * (`resolve_allowed`)只看客户端方言和 `allow`,不看路由 —— 按路由删掉 * 一半,显示的就不是客户端真会看到的那一份。每行写明来自哪个上游。 + * + * **别名也列在里面**,名称旁一个「别名」标记。范围里有它列表里的某个模型时,它跟着 + * 可见:勾上、点不动,来源写「随 … 放行」。反过来不成立 —— 只选别名,它的模型不可见。 */ export function ModelScope({ scope, @@ -40,7 +47,7 @@ export function ModelScope({ }: { scope: Scope; entries: string[]; - /** 网关知道的全部模型。尚未获取到时是空的 */ + /** 网关知道的全部模型,含别名。尚未获取到时是空的 */ catalog: KnownModel[]; onScope: (s: Scope) => void; onEntries: (e: string[]) => void; @@ -173,44 +180,33 @@ export function ModelScope({ - {matched.slice(0, shown).map((r) => { - const byPattern = r.source?.kind === "pattern"; - return ( - - {/* 点不动的那些,把原因挂在格子上 —— 一个禁用的 - 勾选框自己不会说明为什么 */} - - onEntries(toggleModel(entries, r.id, v === true))} - /> - - - {r.id} - - - {r.unknown ? "—" : t.providers(r.providers)} - - - {r.source?.kind === "pattern" - ? t.byPattern(r.source.pattern) - : r.source - ? t.picked - : ""} - - - ); - })} + {matched.slice(0, shown).map((r) => ( + + {/* 点不动的那些,把原因挂在格子上 —— 一个禁用的 + 勾选框自己不会说明为什么 */} + + onEntries(toggleModel(entries, r.id, v === true))} + /> + + + {r.id} + {r.alias && ( + + )} + + + {r.unknown ? "—" : t.providers(r.providers)} + + {sourceText(r.source, t)} + + ))}
{matched.length === 0 && ( @@ -227,6 +223,7 @@ export function ModelScope({ )} + {hasAliases(catalog) && {t.aliasRule}} )} @@ -234,3 +231,26 @@ export function ModelScope({
); } + +type ScopeText = (typeof modelScopeText)["zh"]; + +/** 「来源」那一格 */ +function sourceText(source: Source, t: ScopeText): string { + switch (source?.kind) { + case "pattern": + return t.byPattern(source.pattern); + case "inherited": + return t.inherited(source.model); + case "picked": + return t.picked; + default: + return ""; + } +} + +/** 点不动的那一行为什么点不动 */ +function lockedTitle(source: Source, t: ScopeText): string | null { + if (source?.kind === "pattern") return t.lockedHint(source.pattern); + if (source?.kind === "inherited") return t.inheritedHint(source.model); + return null; +} diff --git a/src/keys/scope.test.ts b/src/keys/scope.test.ts index cca1c253..af3a4df6 100644 --- a/src/keys/scope.test.ts +++ b/src/keys/scope.test.ts @@ -5,6 +5,10 @@ import type { KnownModel } from "@/types"; import { scopeLabel } from "./labels"; import { allowOf, + hasAliases, + inheritedFrom, + locked, + patternHits, rowsOf, sameModel, scopeOf, @@ -15,11 +19,11 @@ import { } from "./scope"; const CATALOG: KnownModel[] = [ - { id: "claude-opus-5", providers: ["anthropic"] }, - { id: "claude-sonnet-5", providers: ["anthropic", "openrouter"] }, - { id: "claude-haiku-4-5", providers: ["anthropic"] }, - { id: "deepseek-chat", providers: ["deepseek"] }, - { id: "gpt-5.5", providers: ["openrouter"] }, + { id: "claude-opus-5", providers: ["anthropic"], aliases: [] }, + { id: "claude-sonnet-5", providers: ["anthropic", "openrouter"], aliases: [] }, + { id: "claude-haiku-4-5", providers: ["anthropic"], aliases: [] }, + { id: "deepseek-chat", providers: ["deepseek"], aliases: [] }, + { id: "gpt-5.5", providers: ["openrouter"], aliases: [] }, ]; describe("可见模型的三态", () => { @@ -73,6 +77,7 @@ describe("表格要画的行", () => { id: "ghost-model", providers: [], unknown: true, + alias: null, source: { kind: "picked" }, }); expect(rows).toHaveLength(CATALOG.length + 1); @@ -125,3 +130,144 @@ describe("表格里「可见模型」那一栏", () => { expect(scopeLabel(null, CATALOG)).toEqual({ text: "全部", warn: false }); }); }); + +/** + * 目录里带别名的样子(core 的 `/models`):别名和同名真模型只出现一次(就是别名), + * 真名那一项的 `aliases` 是指向它的别名 + */ +const WITH_ALIASES: KnownModel[] = [ + { id: "anthropic/claude-sonnet-5", providers: ["openrouter"], aliases: ["claude-sonnet-5"] }, + { + id: "claude-sonnet-5", + providers: ["anthropic", "bedrock", "openrouter"], + alias: ["claude-sonnet-5", "us.anthropic.claude-sonnet-5-v1:0", "anthropic/claude-sonnet-5"], + aliases: [], + }, + { id: "deepseek-v4.1", providers: ["relay-cn"], alias: ["DeepSeek-v4.1-flash"], aliases: [] }, + { id: "DeepSeek-v4.1-flash", providers: ["relay-cn"], aliases: ["deepseek-v4.1"] }, + { id: "glm-4.6", providers: ["zai", "bigmodel"], aliases: [] }, + { id: "us.anthropic.claude-sonnet-5-v1:0", providers: ["bedrock"], aliases: ["claude-sonnet-5"] }, +]; + +const src = (entries: string[], id: string) => { + const m = WITH_ALIASES.find((x) => x.id === id)!; + return sourceOf(entries, m.id, m.alias); +}; + +describe("别名:继承只从真名到别名", () => { + it("范围里有别名列表里的某个模型,别名随它放行,点不动", () => { + const s = src(["DeepSeek-v4.1-flash"], "deepseek-v4.1"); + expect(s).toEqual({ kind: "inherited", model: "DeepSeek-v4.1-flash" }); + expect(locked(s)).toBe(true); + // 那个模型本身是单独选中的,照常能取消 + expect(src(["DeepSeek-v4.1-flash"], "DeepSeek-v4.1-flash")).toEqual({ kind: "picked" }); + expect(locked({ kind: "picked" })).toBe(false); + }); + + it("规则命中别名列表里的模型名,别名同样随那个模型放行", () => { + expect(src(["us.anthropic.*"], "claude-sonnet-5")).toEqual({ + kind: "inherited", + model: "us.anthropic.claude-sonnet-5-v1:0", + }); + expect(src(["deepseek-*"], "deepseek-v4.1")).toEqual({ kind: "pattern", pattern: "deepseek-*" }); + }); + + it("只选别名,它的模型不可见", () => { + const entries = ["deepseek-v4.1"]; + expect(src(entries, "deepseek-v4.1")).toEqual({ kind: "picked" }); + expect(src(entries, "DeepSeek-v4.1-flash")).toBeNull(); + // 规则只命中别名的名称时也一样 + expect(src(["claude-sonnet-*"], "anthropic/claude-sonnet-5")).toBeNull(); + expect(src(["claude-sonnet-*"], "us.anthropic.claude-sonnet-5-v1:0")).toBeNull(); + }); + + it("规则直接命中别名的名称时说规则,不说继承", () => { + // 那一行取消不掉的原因是规则;同时也随某个模型放行,说哪个都对,规则更直接 + expect(src(["claude-sonnet-*", "anthropic/claude-sonnet-5"], "claude-sonnet-5")).toEqual({ + kind: "pattern", + pattern: "claude-sonnet-*", + }); + }); + + it("单独选中了别名、又随某个模型放行时说继承:锁住这一行的是那个模型", () => { + const s = src(["deepseek-v4.1", "DeepSeek-v4.1-flash"], "deepseek-v4.1"); + expect(s).toEqual({ kind: "inherited", model: "DeepSeek-v4.1-flash" }); + expect(locked(s)).toBe(true); + }); + + it("别名列表里和它同名的那个模型不算继承:选中这个名字就是直接选中别名,能取消", () => { + // 写成「随 claude-sonnet-5 放行」的话,这一行就再也取消不掉了 + expect(src(["claude-sonnet-5"], "claude-sonnet-5")).toEqual({ kind: "picked" }); + expect(inheritedFrom(["claude-sonnet-5"], "claude-sonnet-5", WITH_ALIASES[1]!.alias!)).toBeNull(); + expect(inheritedFrom(["Claude-Sonnet-5"], "claude-sonnet-5", ["CLAUDE-SONNET-5"])).toBeNull(); + }); + + it("按列表顺序说第一个放行它的模型", () => { + const entries = ["anthropic/claude-sonnet-5", "us.anthropic.claude-sonnet-5-v1:0"]; + expect(src(entries, "claude-sonnet-5")).toEqual({ + kind: "inherited", + model: "us.anthropic.claude-sonnet-5-v1:0", + }); + }); + + it("继承和 core 一样不分大小写", () => { + expect(src(["deepseek-v4.1-FLASH"], "deepseek-v4.1")).toEqual({ + kind: "inherited", + model: "DeepSeek-v4.1-flash", + }); + }); + + it("真名不随别名放行,也不随别的真名放行", () => { + // 不是别名就不看继承:真名那一项的 `aliases` 只是反查,不参与 + expect(sourceOf(["deepseek-v4.1"], "DeepSeek-v4.1-flash")).toBeNull(); + expect(sourceOf(["deepseek-v4.1"], "DeepSeek-v4.1-flash", null)).toBeNull(); + }); +}); + +describe("别名:数数和画行", () => { + it("可见数算上随模型放行的别名", () => { + // 设计稿:claude-sonnet-* 命中别名、DeepSeek-v4.1-flash 单独选中、deepseek-v4.1 随它放行 + expect(visibleCount(["claude-sonnet-*", "DeepSeek-v4.1-flash"], WITH_ALIASES)).toBe(3); + expect(visibleCount(["deepseek-v4.1"], WITH_ALIASES)).toBe(1); + expect(visibleCount(["us.anthropic.*"], WITH_ALIASES)).toBe(2); + }); + + it("「命中 N 个」只按名称数,别名按它自己的名称", () => { + expect(patternHits("claude-sonnet-*", WITH_ALIASES)).toBe(1); + expect(patternHits("deepseek-*", WITH_ALIASES)).toBe(2); + // 别名 claude-sonnet-5 随这个模型放行,但规则命中的只有模型本身 + expect(patternHits("us.anthropic.*", WITH_ALIASES)).toBe(1); + }); + + it("每一行带上别名的模型列表,真名是 null", () => { + const rows = rowsOf(["DeepSeek-v4.1-flash"], WITH_ALIASES); + const alias = rows.find((r) => r.id === "deepseek-v4.1")!; + expect(alias.alias).toEqual(["DeepSeek-v4.1-flash"]); + expect(alias.source).toEqual({ kind: "inherited", model: "DeepSeek-v4.1-flash" }); + expect(rows.find((r) => r.id === "glm-4.6")!.alias).toBeNull(); + expect(rowsOf(["ghost"], WITH_ALIASES)[0]!.alias).toBeNull(); + }); + + it("单独选中的别名在目录里,不算目录外的明细", () => { + expect(rowsOf(["deepseek-v4.1"], WITH_ALIASES)).toHaveLength(WITH_ALIASES.length); + }); + + it("目录里有没有别名", () => { + expect(hasAliases(WITH_ALIASES)).toBe(true); + expect(hasAliases(CATALOG)).toBe(false); + }); + + it("继承是算出来的,不写进 allow:勾选只加减用户自己的那一条", () => { + const on = toggleModel([], "DeepSeek-v4.1-flash", true); + expect(on).toEqual(["DeepSeek-v4.1-flash"]); + expect(toggleModel(on, "DeepSeek-v4.1-flash", false)).toEqual([]); + // 选别名只写别名 + expect(toggleModel(["claude-sonnet-*"], "deepseek-v4.1", true)).toEqual(["claude-sonnet-*", "deepseek-v4.1"]); + }); + + it("表格里「可见模型」那一栏算上随模型放行的别名", () => { + setLang("zh"); + expect(scopeLabel(["claude-sonnet-*", "DeepSeek-v4.1-flash"], WITH_ALIASES).text).toBe("3 个模型 · 1 条规则"); + expect(scopeLabel(["DeepSeek-v4.1-flash"], WITH_ALIASES).text).toBe("2 个模型"); + }); +}); diff --git a/src/keys/scope.ts b/src/keys/scope.ts index 14e2c709..8eaa9f59 100644 --- a/src/keys/scope.ts +++ b/src/keys/scope.ts @@ -8,6 +8,11 @@ * 界面要把这一个列表讲成两件事 —— 「覆盖一组的规则」和「单独选中的模型」 * —— 因为聚合目录可能有几百个模型:逐个勾不现实,而勾一下就把规则展开成 * 明细会往配置里写几百行。两者并存,互不摧毁。 + * + * **别名只从真名继承,不反过来。**目录里也列着别名(`alias` 是它的模型列表)。 + * `allow` 里有一条放行了别名列表里的某个模型名,这个别名也可见(core 的 + * `resolve_allowed` 同样这么算);只放行别名,它的模型不跟着可见。继承是算出来的, + * **不写进 `allow`**:配置里只有用户自己写的那几条。 */ import { asciiLower, globMatch } from "@/upstreams/glob"; import type { KnownModel } from "@/types"; @@ -48,17 +53,46 @@ export function sameModel(a: string, b: string): boolean { return a.length === b.length && asciiLower(a) === asciiLower(b); } -/** 这个模型为什么可见:被某条规则命中,还是单独选中的 */ -export type Source = { kind: "pattern"; pattern: string } | { kind: "picked" } | null; +/** + * 这个模型为什么可见:被某条规则命中、随它列表里的某个模型放行(只有别名会这样), + * 还是单独选中的 + */ +export type Source = + | { kind: "pattern"; pattern: string } + | { kind: "inherited"; model: string } + | { kind: "picked" } + | null; -export function sourceOf(entries: string[], model: string): Source { - // **规则优先。**同时成立时说「由规则命中」才有用:那一行取消不掉, - // 而原因是规则,不是那条重复的明细 +/** + * 别名随哪个模型放行:列表里第一个被某条条目放行的模型名。没有就是 null。 + * + * **跳过和别名同名的那个模型。**别名常把自己的名字也列进去(`claude-sonnet-5` 指向 + * 官方的 `claude-sonnet-5` 和 Bedrock 的长名称):放行这个名字就是直接放行别名,算 + * 「单独选中」或「由规则命中」,能取消;说成「随 claude-sonnet-5 放行」就锁死了 + */ +export function inheritedFrom(entries: string[], name: string, models: readonly string[]): string | null { + return models.find((m) => !sameModel(m, name) && entries.some((e) => globMatch(e, m))) ?? null; +} + +/** + * `alias` 给了就是别名,按它的模型列表算继承。 + * + * **规则优先,其次继承,最后才是明细。**同时成立时说锁住这一行的那个原因才有用: + * 规则命中、随别的模型放行的行都取消不掉,原因不是那条重复的明细 + */ +export function sourceOf(entries: string[], model: string, alias?: readonly string[] | null): Source { const hit = entries.find((e) => isPattern(e) && globMatch(e, model)); if (hit != null) return { kind: "pattern", pattern: hit }; + const via = alias ? inheritedFrom(entries, model, alias) : null; + if (via != null) return { kind: "inherited", model: via }; return entries.some((e) => !isPattern(e) && sameModel(e, model)) ? { kind: "picked" } : null; } +/** 这一行能不能点:规则命中的和随别的模型放行的都点不动 */ +export function locked(source: Source): boolean { + return source?.kind === "pattern" || source?.kind === "inherited"; +} + /** 表格里要画哪些行:目录里的,加上选中了但目录里没有的 */ export interface ScopeRow { id: string; @@ -66,32 +100,45 @@ export interface ScopeRow { providers: string[]; /** 目录里没有它 —— 上游改了名,或者当初手打错了。**照样列出来**,否则删不掉 */ unknown: boolean; + /** 是别名时它的模型列表,否则 null */ + alias: string[] | null; source: Source; } export function rowsOf(entries: string[], catalog: KnownModel[]): ScopeRow[] { const stale = splitEntries(entries) .picked.filter((e) => !catalog.some((m) => sameModel(e, m.id))) - .map((id) => ({ id, providers: [], unknown: true, source: { kind: "picked" } })); + .map((id) => ({ id, providers: [], unknown: true, alias: null, source: { kind: "picked" } })); const rest = catalog.map((m) => ({ id: m.id, providers: m.providers, unknown: false, - source: sourceOf(entries, m.id), + alias: m.alias ?? null, + source: sourceOf(entries, m.id, m.alias), })); return [...stale, ...rest]; } -/** 目录里有几个模型对这把密钥可见 */ +/** 目录里有几个模型对这把密钥可见。别名算在里面,随它的模型放行的也算 */ export function visibleCount(entries: string[], catalog: KnownModel[]): number { - return catalog.filter((m) => sourceOf(entries, m.id) != null).length; + return catalog.filter((m) => sourceOf(entries, m.id, m.alias) != null).length; } -/** 一条规则命中几个。目录还没有就答不上来 */ +/** + * 一条规则命中几个。目录还没有就答不上来。 + * + * **只按名称数**,别名也按它自己的名称:`claude-sonnet-*` 命中别名 `claude-sonnet-5`, + * 不命中只是列表里有个 `claude-sonnet-5` 的别名 `sonnet`(那一个算「随 … 放行」) + */ export function patternHits(pattern: string, catalog: KnownModel[]): number { return catalog.filter((m) => globMatch(pattern, m.id)).length; } +/** 目录里有没有别名。没有就不必说明别名怎么算 */ +export function hasAliases(catalog: KnownModel[]): boolean { + return catalog.some((m) => m.alias != null); +} + /** * 勾上或取消一个模型。 * diff --git a/src/routing/DryRunDialog.i18n.ts b/src/routing/DryRunDialog.i18n.ts index ac4bf1b6..937b5dd6 100644 --- a/src/routing/DryRunDialog.i18n.ts +++ b/src/routing/DryRunDialog.i18n.ts @@ -32,6 +32,10 @@ export const dryRunText = messages( trace: "规则匹配明细", viaGroup: (name: string) => `转发至策略组「${name}」`, toUpstream: (name: string) => `转发至上游「${name}」`, + toPinned: (provider: string, model: string) => `转发至指定模型 ${provider} · ${model}`, + sends: "发出 ", + via: { alias: "别名", rule: "规则改写", pinned: "指定模型" }, + viaParen: (via: string) => `(${via})`, denied: "拒绝", unavailable: "选中的上游均无法服务此请求,请求将返回错误", noMatch: "无规则命中,请求将返回错误", @@ -78,6 +82,10 @@ export const dryRunText = messages( trace: "Rule matching details", viaGroup: (name: string) => `Forwarded to group “${name}”`, toUpstream: (name: string) => `Forwarded to upstream “${name}”`, + toPinned: (provider: string, model: string) => `Forwarded to the specified model ${provider} · ${model}`, + sends: "Sends ", + via: { alias: "alias", rule: "rule rewrite", pinned: "specified model" }, + viaParen: (via: string) => ` (${via})`, denied: "Denied", unavailable: "No selected upstream can serve this request; it will return an error", noMatch: "No rule matched; the request will return an error", diff --git a/src/routing/DryRunDialog.tsx b/src/routing/DryRunDialog.tsx index 6fb9beb7..db43f1c6 100644 --- a/src/routing/DryRunDialog.tsx +++ b/src/routing/DryRunDialog.tsx @@ -32,14 +32,7 @@ import { targetLabel, translatedText, } from "@/labels"; -import type { - Dialect, - DryRunResult, - KnownModel, - Overview, - RouteInput, - RuleTrace, -} from "@/types"; +import type { Dialect, DryRunResult, KnownModel, Overview, RouteInput, RuleTrace } from "@/types"; import { skipLabel } from "@/upstreams/labels"; import { FormItem } from "@/upstreams/parts"; import { api } from "./api"; @@ -48,6 +41,7 @@ import { ModelInput, onOpenFocus } from "./fields"; import { DIALECTS, usersOf } from "./model"; import { KeyIcon, TargetIcon } from "./parts"; import { routingText } from "./routing.i18n"; +import { modelViaOf, type ModelVia } from "./target"; /** 按什么求值:密钥使用的路由、指定的路由、路由对话框里还没保存的草稿 */ export type DryRunTarget = @@ -62,6 +56,9 @@ export type DryRunTarget = * 右边从上往下:结论(一句话 + 状态点)、这一趟经过的路(密钥 → 路由 → 规则 → * 去向,和页上的路由图同一套标志)、细节、逐条匹配明细。输入一变就重算,重算时 * 旧结果淡一档留着,不闪成空白。 + * + * 尝试顺序里每个上游写出发给它的模型名和来历(别名、规则改写、指定模型):客户端写的 + * 名称和发出的不同,正是要在这里看清的事。 */ export function DryRunDialog({ target, @@ -200,7 +197,7 @@ export function DryRunDialog({ - m.id)} /> +
{t.attempts}
- {r.candidates.map((c, i) => { + {candidates.map((cv, i) => { + const c = cv.provider; const open = r.circuit_open.includes(c); const conv = r.converted.find((x) => x.provider === c); + const via = modelViaOf(cv); return ( -
+ // 指定模型可以在同一个上游上列两个模型:上游名不唯一 +
{i + 1} {c} + {cv.sent_model && via && } {open && ( @@ -550,6 +552,18 @@ function Result({ ); } +/** 发给这个上游的模型名,和它从哪儿来。和请求里写的一样时不写(core 照样给 `sent_model`) */ +function SentModel({ model, via }: { model: string; via: ModelVia }) { + const t = useText(dryRunText); + return ( + + {t.sends} + {model} + {t.viaParen(t.via[via])} + + ); +} + /** 路上的一站:标志加名字 */ function Step({ children, strong, className }: { children: ReactNode; strong?: boolean; className?: string }) { return ( @@ -567,9 +581,13 @@ function Step({ children, strong, className }: { children: ReactNode; strong?: b function headline(r: DryRunResult): string { const m = textOf(dryRunText); + const first = r.candidate_models[0]; switch (r.outcome) { case "route": - return r.via_group ? m.viaGroup(targetLabel(r.via_group)) : m.toUpstream(r.candidates[0] ?? ""); + if (r.via_group) return m.viaGroup(targetLabel(r.via_group)); + return first?.model_via === "pinned" && first.sent_model + ? m.toPinned(first.provider, first.sent_model) + : m.toUpstream(first?.provider ?? ""); case "deny": return m.denied; case "unavailable": diff --git a/src/routing/PinnedModels.i18n.ts b/src/routing/PinnedModels.i18n.ts new file mode 100644 index 00000000..e55c24b0 --- /dev/null +++ b/src/routing/PinnedModels.i18n.ts @@ -0,0 +1,26 @@ +import { messages } from "@/i18n"; + +export const pinnedModelsText = messages( + { + addModel: "添加模型", + addBackup: "添加备用", + note: "按顺序发送,前一个失败换下一个。模型名原样发出,不经过别名表。", + provider: (n: number) => `第 ${n} 个指定模型的上游`, + chooseProvider: "选择上游", + missing: (name: string) => `${name}(不存在)`, + disabled: (name: string) => `${name}(已停用)`, + model: "模型名", + remove: (n: number) => `删除第 ${n} 个指定模型`, + }, + { + addModel: "Add model", + addBackup: "Add backup", + note: "Sent in order; when one fails, the next is used. Model names are sent as written, without going through the alias table.", + provider: (n: number) => `Upstream of specified model ${n}`, + chooseProvider: "Select an upstream", + missing: (name: string) => `${name} (not found)`, + disabled: (name: string) => `${name} (disabled)`, + model: "Model name", + remove: (n: number) => `Remove specified model ${n}`, + }, +); diff --git a/src/routing/PinnedModels.tsx b/src/routing/PinnedModels.tsx new file mode 100644 index 00000000..d5085dde --- /dev/null +++ b/src/routing/PinnedModels.tsx @@ -0,0 +1,138 @@ +import { PlusIcon } from "lucide-react"; +import { Button } from "@/ui/button"; +import { NativeSelect, NativeSelectOption } from "@/ui/native-select"; +import { useText } from "@/i18n"; +import { commonText } from "@/i18n/common.i18n"; +import { useResource } from "@/lib/resource"; +import type { KnownModel, ProviderView } from "@/types"; +import { contextWindow } from "@/upstreams/labels"; +import { api } from "./api"; +import { ModelInput } from "./fields"; +import { blankPinned, type PinnedDraft } from "./model"; +import { pinnedModelsText } from "./PinnedModels.i18n"; + +/** + * 「转发至 → 指定模型」:几行「上游 + 模型」,按顺序备用。 + * + * **模型从这个上游自己的清单里选**(`/providers/{name}/models`,和上游页的模型弹窗是同一份), + * 右边是它的上下文窗口;清单里没有的照样能写(中转刚上的新模型)。模型名原样发出,不经过 + * 别名表,所以候选里**不列别名**。 + */ +export function PinnedModels({ + value, + onChange, + providers, + known, +}: { + value: PinnedDraft[]; + onChange: (next: PinnedDraft[]) => void; + providers: readonly ProviderView[]; + known: readonly KnownModel[]; +}) { + const t = useText(pinnedModelsText); + const patch = (i: number, p: Partial) => + onChange(value.map((x, j) => (j === i ? { ...x, ...p } : x))); + return ( +
+
+ {value.map((p, i) => ( + + // 换了上游:新上游也有这个模型就留着,否则清空重选 + patch(i, { + provider, + model: known.some((m) => m.id === p.model && !m.alias && m.providers.includes(provider)) + ? p.model + : "", + }) + } + onModel={(model) => patch(i, { model })} + onRemove={() => onChange(value.filter((_, j) => j !== i))} + /> + ))} + +
+

{t.note}

+
+ ); +} + +function PinnedRow({ + n, + p, + providers, + known, + onProvider, + onModel, + onRemove, +}: { + n: number; + p: PinnedDraft; + providers: readonly ProviderView[]; + known: readonly KnownModel[]; + onProvider: (provider: string) => void; + onModel: (model: string) => void; + onRemove: () => void; +}) { + const t = useText(pinnedModelsText); + const ct = useText(commonText); + const pv = providers.find((x) => x.name === p.provider); + // 和上游页的模型弹窗共用一份缓存;那边刷新了清单,这里跟着换 + const rows = useResource( + pv ? `upstream-models:${pv.name}` : null, + () => api.providerModels(p.provider), + { deps: [pv?.model_checked_at_ms, pv?.model_fetching, pv?.model_count] }, + ).data?.models; + // 清单还没取到时先用网关的模型目录里这家的那些 + const ids = rows + ? rows.filter((r) => r.enabled).map((r) => r.id) + : known.filter((m) => !m.alias && m.providers.includes(p.provider)).map((m) => m.id); + const ctx = rows?.find((r) => r.id === p.model.trim())?.context_window; + return ( +
+ {n} + onProvider(e.target.value)} + > + {!p.provider && {t.chooseProvider}} + {/* 配置里写着、却已经不在的上游:照样显示出来,保存时由 core 说它不存在 */} + {p.provider && !pv && {t.missing(p.provider)}} + {providers.map((x) => ( + + {x.disabled ? t.disabled(x.name) : x.name} + + ))} + + + + {ctx ? contextWindow(ctx) : ""} + + {/* 写成字:对话框右上角的 × 是关闭 */} + +
+ ); +} diff --git a/src/routing/RouteDialog.i18n.ts b/src/routing/RouteDialog.i18n.ts index cb5b4797..09357392 100644 --- a/src/routing/RouteDialog.i18n.ts +++ b/src/routing/RouteDialog.i18n.ts @@ -42,6 +42,9 @@ export const routeDialogText = messages( addCatchAll: "添加兜底规则", untitled: "新路由", conditionJoin: " 且 ", + alsoAliasesOf: (aliases: string[]) => `也匹配指向它的别名 ${aliases.join("、")}`, + alsoAliases: (aliases: string[]) => `也匹配指向这些模型的别名 ${aliases.join("、")}`, + backups: (list: string[]) => `备用 ${list.join("、")}`, }, { catchAllName: "Catch-all", @@ -87,5 +90,10 @@ export const routeDialogText = messages( addCatchAll: "Add catch-all rule", untitled: "Untitled", conditionJoin: " and ", + alsoAliasesOf: (aliases: string[]) => + `Also matches the ${aliases.length === 1 ? "alias that points" : "aliases that point"} to it: ${aliases.join(", ")}`, + alsoAliases: (aliases: string[]) => + `Also matches the ${aliases.length === 1 ? "alias that points" : "aliases that point"} to these models: ${aliases.join(", ")}`, + backups: (list: string[]) => `Backup: ${list.join(", ")}`, }, ); diff --git a/src/routing/RouteDialog.tsx b/src/routing/RouteDialog.tsx index f025febc..9175be80 100644 --- a/src/routing/RouteDialog.tsx +++ b/src/routing/RouteDialog.tsx @@ -1,5 +1,6 @@ -import { useMemo, useState, type KeyboardEvent, type MouseEvent } from "react"; +import { Fragment, useMemo, useState, type KeyboardEvent, type MouseEvent } from "react"; import { FlaskConicalIcon, GripVerticalIcon, PlusIcon } from "lucide-react"; +import { AliasMark } from "@/aliases/AliasMark"; import { Badge } from "@/ui/badge"; import { Banner } from "@/ui/banner"; import { Button } from "@/ui/button"; @@ -19,7 +20,7 @@ import { RowMenu, RowMenuButton, type MenuItems } from "@/ui/row-menu"; import { EmptyState } from "@/ui/states"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/ui/table"; import { cn } from "@/lib/utils"; -import { textOf, useText } from "@/i18n"; +import { useText } from "@/i18n"; import { commonText } from "@/i18n/common.i18n"; import { errorText } from "@/i18n/core.i18n"; import { ALL_UPSTREAMS, conditionText, targetLabel } from "@/labels"; @@ -47,6 +48,7 @@ import { KeyChips, KeyIcon, TargetIcon } from "./parts"; import { routeDialogText } from "./RouteDialog.i18n"; import { routingText } from "./routing.i18n"; import { RuleDialog } from "./RuleDialog"; +import { aliasNamed, inheritedAliases, pinnedText } from "./target"; import { useReorder } from "./useReorder"; import { hitsOfRoute, hitsOfRule, type HitSpan, type RouteHitsWindow } from "./useRouteHits"; @@ -365,15 +367,15 @@ export function RouteDialog({ )} - + {r.conditions.length === 0 ? ( {rt.allRequests} ) : ( - conditionsText(r) + )} - + @@ -477,14 +479,48 @@ function RuleHitsLine({ n, span }: { n: number; span: HitSpan }) { ); } -function conditionsText(r: RuleDraft): string { - return r.conditions.map(conditionText).join(textOf(routeDialogText).conditionJoin); +/** + * 「条件」一栏。模型条件写的是别名时标出来;写的是上游模型名、又有别名指向它时,次行写明 + * 也匹配这些别名(继承只从真名到别名,和密钥的可见范围是同一条规矩) + */ +function Conditions({ r, known }: { r: RuleDraft; known: readonly KnownModel[] }) { + const t = useText(routeDialogText); + const model = r.conditions.find((c) => c.field === "model"); + const values = model?.values.map((v) => v.trim()).filter(Boolean) ?? []; + const alias = values.length === 1 ? aliasNamed(values[0]!, known) : undefined; + const inherited = inheritedAliases(values, known); + const exact = values.length === 1 && !values[0]!.includes("*"); + return ( +
+ {/* 标记放在截断的文字外面:条件一长,行尾会被截掉,标记不能跟着丢 */} +
+ + {r.conditions.map((c, i) => ( + + {i > 0 && t.conditionJoin} + {conditionText(c)} + + ))} + + {alias && } +
+ {inherited.length > 0 && ( +
+ {exact ? t.alsoAliasesOf(inherited) : t.alsoAliases(inherited)} +
+ )} +
+ ); } -/** 「命中后」一栏:去向(或拒绝、继续匹配),次行是去向的说明或附加项 */ -function Action({ r, ov }: { r: RuleDraft; ov: Overview }) { +/** + * 「命中后」一栏:去向(或拒绝、继续匹配),次行是去向的说明或附加项。指定模型写成 + * 「上游 · 模型」,备用的写在次行 + */ +function Action({ r, ov, known }: { r: RuleDraft; ov: Overview; known: readonly KnownModel[] }) { const rt = useText(routingText); - const addOns = addOnsText(r); + const t = useText(routeDialogText); + const addOns = addOnsText(r, known); if (r.action === "deny") { return (
@@ -504,6 +540,23 @@ function Action({ r, ov }: { r: RuleDraft; ov: Overview }) {
); } + if (r.toKind === "pinned") { + const [first, ...rest] = r.pinned; + const sub = [rest.length ? t.backups(rest.map(pinnedText)) : "", addOns].filter(Boolean).join(" · "); + return ( +
+ {first && ( +
+ + + {first.provider} · {first.model} + +
+ )} + {sub &&
{sub}
} +
+ ); + } const sub = [describeTarget(r.to, ov.groups, ov.providers), addOns].filter(Boolean).join(" · "); return (
diff --git a/src/routing/RuleDialog.i18n.ts b/src/routing/RuleDialog.i18n.ts index 1f844ec5..828353ff 100644 --- a/src/routing/RuleDialog.i18n.ts +++ b/src/routing/RuleDialog.i18n.ts @@ -13,6 +13,8 @@ export const ruleDialogText = messages( onMatch: "命中后", forward: "转发", forwardTo: "转发至", + toTarget: "上游或策略组", + toPinned: "指定模型", chooseTarget: "选择去向", groups: "策略组", newGroupMenu: "新建策略组…", @@ -35,6 +37,10 @@ export const ruleDialogText = messages( globPlaceholder: "例如 claude-opus-*", globMatches: (n: number) => `匹配 ${n} 个已知模型`, globNone: "不匹配任何已知模型", + aliasOnly: (alias: string) => `匹配用别名 ${alias} 请求的。直接用上游模型名请求的不匹配。`, + alsoAliasesOf: (aliases: string[]) => `也匹配指向它的别名 ${aliases.join("、")}`, + alsoAliases: (aliases: string[]) => `也匹配指向这些模型的别名 ${aliases.join("、")}`, + hintSep: ",", compareOp: (name: string) => `${name}的比较方式`, compareAmount: (name: string) => `${name}的数值`, yes: "是", @@ -55,6 +61,8 @@ export const ruleDialogText = messages( onMatch: "On match", forward: "Forward", forwardTo: "Forward to", + toTarget: "Upstream or group", + toPinned: "Specified models", chooseTarget: "Select a destination", groups: "Groups", newGroupMenu: "New group…", @@ -78,6 +86,13 @@ export const ruleDialogText = messages( globPlaceholder: "e.g. claude-opus-*", globMatches: (n: number) => (n === 1 ? "Matches 1 known model" : `Matches ${n} known models`), globNone: "Matches no known model", + aliasOnly: (alias: string) => + `Matches requests that use the alias ${alias}. Requests that use an upstream model name directly do not match.`, + alsoAliasesOf: (aliases: string[]) => + `also matches the ${aliases.length === 1 ? "alias that points" : "aliases that point"} to it: ${aliases.join(", ")}`, + alsoAliases: (aliases: string[]) => + `also matches the ${aliases.length === 1 ? "alias that points" : "aliases that point"} to these models: ${aliases.join(", ")}`, + hintSep: "; ", compareOp: (name: string) => `Comparison for ${name}`, compareAmount: (name: string) => `Value for ${name}`, yes: "Yes", diff --git a/src/routing/RuleDialog.tsx b/src/routing/RuleDialog.tsx index d6c37c6f..fe09f671 100644 --- a/src/routing/RuleDialog.tsx +++ b/src/routing/RuleDialog.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, type ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { ChevronRightIcon, PlusIcon } from "lucide-react"; import { Banner } from "@/ui/banner"; import { Button } from "@/ui/button"; @@ -21,11 +21,10 @@ import { import { Input } from "@/ui/input"; import { NativeSelect, NativeSelectOptGroup, NativeSelectOption } from "@/ui/native-select"; import { cn } from "@/lib/utils"; -import { useText } from "@/i18n"; +import { textOf, useText } from "@/i18n"; import { commonText } from "@/i18n/common.i18n"; import { PROBES, conditionName, formatLabel, probeLabel, targetLabel } from "@/labels"; import type { ConditionField, ConditionView, KnownModel, Overview } from "@/types"; -import { globMatch } from "@/upstreams/glob"; import { Segmented } from "@/ui/segmented"; import { FormItem, Note } from "@/upstreams/parts"; import { GroupDialog } from "./GroupDialog"; @@ -36,18 +35,23 @@ import { DIALECTS, addOnsText, blankCondition, + blankPinned, compareOps, condField, condGroupLabel, describeTarget, isPhaseTwo, + isPinned, ruleProblem, splitCompare, type Action, type RuleDraft, + type ToKind, } from "./model"; +import { PinnedModels } from "./PinnedModels"; import { routingText } from "./routing.i18n"; import { ruleDialogText } from "./RuleDialog.i18n"; +import { modelHint, type ModelHint } from "./target"; /** 目标下拉里「新建策略组…」那一项的值。只活在这个下拉里,不会写进配置 */ const NEW_GROUP = "::new-group"; @@ -58,6 +62,9 @@ const NEW_GROUP = "::new-group"; * **按读规则的顺序排**:条件(什么样的请求)→ 命中后(转发、拒绝,或继续 * 匹配)→ 附加项(改写参数、安全要求)。保存只更新路由对话框里的草稿, * 路由对话框保存时才写入配置。 + * + * 转发有两种去向:上游或策略组(发出的模型名按别名表对到各上游),或者指定模型(几个 + * 「上游 + 模型」按顺序备用,模型名原样发出)。指定模型时「模型改为」不出现。 */ export function RuleDialog({ initial, @@ -97,8 +104,8 @@ export function RuleDialog({ ); const [newGroup, setNewGroup] = useState(false); - const modelIds = useMemo(() => models.map((m) => m.id), [models]); const phaseTwo = isPhaseTwo(d); + const pinned = isPinned(d); const problem = ruleProblem(d, takenNames); /** @@ -112,7 +119,7 @@ export function RuleDialog({ ? [] : (intent?.values ?? []).filter((id) => ov.client_probes.find((p) => p.id === id)?.mode === "intercept"); - const rewriteSummary = addOnsText(d); + const rewriteSummary = addOnsText(d, models); return ( !open && onClose()}> @@ -152,7 +159,7 @@ export function RuleDialog({ key={c.field} c={c} ov={ov} - models={modelIds} + models={models} onChange={(nc) => set({ conditions: d.conditions.map((x, j) => (j === i ? nc : x)) })} onRemove={() => set({ conditions: d.conditions.filter((_, j) => j !== i) })} /> @@ -172,27 +179,54 @@ export function RuleDialog({
-
- - - value={d.action} - onChange={(a) => set({ action: a })} + + + label={t.onMatch} + value={d.action} + onChange={(a) => set({ action: a })} + options={[ + { id: "forward", label: t.forward, disabled: phaseTwo }, + { id: "deny", label: rt.deny }, + { id: "continue", label: rt.continueMatching }, + ]} + /> + {d.action === "continue" &&

{t.continueDesc}

} +
+ {d.action === "forward" && ( + + + label={t.forwardTo} + value={d.toKind} + onChange={(k) => + set({ + toKind: k, + // 头一回切到指定模型:先给一行;原来指的是一个上游,就从它开始 + pinned: + k === "pinned" && d.pinned.length === 0 + ? [blankPinned(ov.providers.some((p) => p.name === d.to) ? d.to : "")] + : d.pinned, + }) + } options={[ - { id: "forward", label: t.forward, disabled: phaseTwo }, - { id: "deny", label: rt.deny }, - { id: "continue", label: rt.continueMatching }, + { id: "target", label: t.toTarget }, + { id: "pinned", label: t.toPinned }, ]} /> - - {d.action === "forward" && ( - + {pinned ? ( + set({ pinned: p })} + providers={ov.providers} + known={models} + /> + ) : ( e.target.value === NEW_GROUP ? setNewGroup(true) : set({ to: e.target.value }) @@ -215,22 +249,19 @@ export function RuleDialog({ ))} - - )} - {d.action === "deny" && ( - - set({ deny: e.target.value })} - /> - - )} - {d.action === "continue" && ( -

{t.continueDesc}

- )} -
+ )} + + )} + {d.action === "deny" && ( + + set({ deny: e.target.value })} + /> + + )} {phaseTwo && {t.phaseTwo}} {d.action !== "deny" && ( @@ -241,16 +272,26 @@ export function RuleDialog({ open={rewriteOpen} onToggle={() => setRewriteOpen((o) => !o)} > -
- - set({ model: v })} - models={modelIds} - placeholder={t.unchanged} - /> - +
+ {/* 指定模型:模型名原样发出,「模型改为」不起作用 */} + {!pinned && ( + + set({ model: v })} + models={models} + placeholder={t.unchanged} + /> + + )}
- {d.model.trim() && {t.modelChangeNote}} + {!pinned && d.model.trim() && {t.modelChangeNote}}
)} @@ -387,7 +428,7 @@ function ConditionRow({ }: { c: ConditionView; ov: Overview; - models: string[]; + models: KnownModel[]; onChange: (c: ConditionView) => void; onRemove: () => void; }) { @@ -408,10 +449,8 @@ function ConditionRow({ placeholder={t.globPlaceholder} /> ); - if (v0.trim() && models.length > 0) { - const n = models.filter((m) => globMatch(v0.trim(), m)).length; - hint = n > 0 ? t.globMatches(n) : t.globNone; - } + const h = modelHint(v0, models); + if (h) hint = modelHintText(h); break; } case "compare": { @@ -521,3 +560,19 @@ function ConditionRow({
); } + +/** + * 模型条件下面的那一句。写别名:只匹配用这个别名的请求;写上游模型名:匹配几个已知模型, + * 以及指向它的别名(继承只从真名到别名) + */ +function modelHintText(h: ModelHint): string { + const t = textOf(ruleDialogText); + if (h.kind === "alias") return t.aliasOnly(h.alias); + const parts: string[] = []; + if (h.matches > 0) parts.push(t.globMatches(h.matches)); + else if (h.inherited.length === 0) parts.push(t.globNone); + if (h.inherited.length > 0) parts.push(h.exact ? t.alsoAliasesOf(h.inherited) : t.alsoAliases(h.inherited)); + // 英文里「也匹配……」可能排在第一句 + const text = parts.join(t.hintSep); + return text.charAt(0).toUpperCase() + text.slice(1); +} diff --git a/src/routing/api.ts b/src/routing/api.ts index 3cc7c5b8..2dbbfb1b 100644 --- a/src/routing/api.ts +++ b/src/routing/api.ts @@ -5,7 +5,6 @@ import { call } from "@/control"; import type { DryRunRequest, GroupSave, RouteSave } from "@/types"; - export const api = { createRoute: (save: RouteSave) => call("CreateRoute", save), updateRoute: (name: string, save: RouteSave) => call("UpdateRoute", save, name), @@ -19,6 +18,8 @@ export const api = { deleteGroup: (name: string, baseVersion: string) => call("DeleteGroup", { base_version: baseVersion }, name), knownModels: () => call("KnownModels", null), + /** 一个上游的模型清单(指定模型时从这里选,带上下文窗口) */ + providerModels: (name: string) => call("ProviderModels", null, name), dryRun: (req: DryRunRequest) => call("DryRun", req), /** 从 `fromMs` 到现在,各条路由、各条规则命中了多少,以及记录从哪一刻起是全的 */ routeStats: (fromMs: number) => call("RouteStats", { from_ms: fromMs }), diff --git a/src/routing/chain.test.ts b/src/routing/chain.test.ts index d6794a99..d47d9d23 100644 --- a/src/routing/chain.test.ts +++ b/src/routing/chain.test.ts @@ -198,6 +198,36 @@ describe("走过的请求", () => { expect(flat.rules.get("default")?.get("catch-all")).toEqual(["up:a"]); }); + it("指定模型连到列表里的每个上游,命中数记在首选那条线上", () => { + const c = buildChain({ + clients: [key("default")], + routes: [ + { + ...route("default", [], { default: true }), + rules: [ + { + ...rule("opus"), + to: [ + { provider: "b", model: "m1" }, + { provider: "a", model: "m2" }, + { provider: "b", model: "m3" }, + // 不存在的上游不画 + { provider: "gone", model: "m4" }, + ], + }, + catchAll("catch-all", "a"), + ], + }, + ], + groups: [ALL], + providers: [up("a"), up("b")], + }); + expect(c.rules.get("default")?.get("opus")).toEqual(["up:b"]); + expect(edge(c, "route:default", "up:b")).toBeDefined(); + expect(edge(c, "route:default", "up:a")).toBeDefined(); + expect(c.nodes.some((n) => n.id === "up:gone")).toBe(false); + }); + it("路由 → 第二列那一段按决定了去向的请求数;直连的线穿过去之后还是它们;连到同一站的规则相加", () => { const c = buildChain(sample()); const t = trafficOf(c, [ diff --git a/src/routing/chain.ts b/src/routing/chain.ts index 787626e5..26e6e465 100644 --- a/src/routing/chain.ts +++ b/src/routing/chain.ts @@ -10,8 +10,11 @@ * **只画决定去向的规则**:被兜底挡住的(`shadowed`)、选定上游之后才判断的 * (`phase_two`)、只附加改写的(继续匹配)不改变请求去哪儿,画出来反而像 * 多了几条路。和路由列表「规则」一栏同一个口径(`flowOf`)。 + * + * 转发到指定模型的规则连到列表里的每一个上游(按顺序备用,和策略组连到它的成员一样)。 */ import type { ClientView, GroupView, Overview, RouteHits, RouteView, RuleView } from "@/types"; +import { pinnedOf, targetNameOf } from "./target"; export type ChainKind = "key" | "route" | "group" | "deny" | "via" | "upstream"; @@ -83,38 +86,48 @@ export function membersOf(g: GroupView, providers: readonly string[]): string[] /** 规则把请求交给的去向 */ type Target = { kind: "group" | "deny" | "upstream"; name: string }; -/** 一条规则的去向。不画的规则(见文件头)和指向不存在的去向的没有 */ -function targetOf(x: RuleView, groups: ReadonlySet, providers: ReadonlySet): Target | null { - if (x.shadowed || x.phase_two) return null; - if (x.to) { - if (groups.has(x.to)) return { kind: "group", name: x.to }; - if (providers.has(x.to)) return { kind: "upstream", name: x.to }; - return null; +/** + * 一条规则的去向。不画的规则(见文件头)和指向不存在的去向的没有;指定模型是列表里的 + * 那几个上游,按顺序、去重 + */ +function targetsOfRule(x: RuleView, groups: ReadonlySet, providers: ReadonlySet): Target[] { + if (x.shadowed || x.phase_two) return []; + const pinned = pinnedOf(x.to); + if (pinned) { + const names = [...new Set(pinned.map((p) => p.provider))].filter((p) => providers.has(p)); + return names.map((name) => ({ kind: "upstream", name })); } - return x.deny != null ? { kind: "deny", name: "" } : null; + const to = targetNameOf(x.to); + if (to) { + if (groups.has(to)) return [{ kind: "group", name: to }]; + if (providers.has(to)) return [{ kind: "upstream", name: to }]; + return []; + } + return x.deny != null ? [{ kind: "deny", name: "" }] : []; } /** 一条路由把请求交给的去向,按规则顺序、去重 */ export function targetsOf(r: RouteView, groups: ReadonlySet, providers: ReadonlySet): Target[] { const out: Target[] = []; const seen = new Set(); - for (const x of r.rules) { - const t = targetOf(x, groups, providers); - if (!t) continue; - const k = `${t.kind}:${t.name}`; - if (seen.has(k)) continue; - seen.add(k); - out.push(t); - } + for (const x of r.rules) + for (const t of targetsOfRule(x, groups, providers)) { + const k = `${t.kind}:${t.name}`; + if (seen.has(k)) continue; + seen.add(k); + out.push(t); + } return out; } /** 路由按列表里的顺序:默认路由在最前 */ -export function orderedRoutes(routes: readonly RouteView[]): RouteView[] { +export function orderedRoutes(routes: readonly R[]): R[] { return [...routes].sort((a, b) => Number(b.default) - Number(a.default)); } -export function buildChain(ov: Pick): Chain { +export function buildChain( + ov: Pick & { routes: readonly RouteView[] }, +): Chain { const routes = orderedRoutes(ov.routes); const routeIndex = new Map(routes.map((r, i) => [r.name, i])); const defaultRoute = routes.find((r) => r.default)?.name ?? null; @@ -155,7 +168,8 @@ export function buildChain(ov: Pick { - const t = targetOf(x, groupNames, providerSet); + // 指定模型连到几个上游时,命中数记在第一个(首选)那条线上 + const t = targetsOfRule(x, groupNames, providerSet)[0]; return t ? [[x.name, stationsOf(t)] as const] : []; }), ), diff --git a/src/routing/fields.i18n.ts b/src/routing/fields.i18n.ts index f2e6a0cd..9bdcad92 100644 --- a/src/routing/fields.i18n.ts +++ b/src/routing/fields.i18n.ts @@ -4,9 +4,13 @@ export const fieldsText = messages( { modelName: "模型名", noMatch: "无匹配项,可直接输入完整模型名", + alias: "别名", + providers: (ps: readonly string[]) => ps.join("、"), }, { modelName: "Model name", noMatch: "No matching model; any full model name can be entered", + alias: "alias", + providers: (ps: readonly string[]) => ps.join(", "), }, ); diff --git a/src/routing/fields.test.ts b/src/routing/fields.test.ts new file mode 100644 index 00000000..56ed9012 --- /dev/null +++ b/src/routing/fields.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { setLang } from "@/i18n"; +import type { KnownModel } from "@/types"; +import { aliasNamed, modelNote, type ModelOption } from "./fields"; + +const OPTIONS: ModelOption[] = [ + { id: "claude-opus-5", providers: ["anthropic", "bedrock"], alias: ["claude-opus-5", "us.anthropic.claude-opus-5-v1:0"] }, + { id: "glm-4.6", providers: ["zai", "bigmodel"] }, + { id: "orphan-alias", providers: [], alias: ["nobody-has-this"] }, + { id: "bare" }, +]; +const byId = new Map(OPTIONS.map((m) => [m.id, m])); + +describe("模型建议右边的灰字", () => { + it("别名写「别名 · 上游」,其余写上游", () => { + setLang("zh"); + expect(modelNote(byId.get("claude-opus-5"))).toBe("别名 · anthropic、bedrock"); + expect(modelNote(byId.get("glm-4.6"))).toBe("zai、bigmodel"); + setLang("en"); + expect(modelNote(byId.get("claude-opus-5"))).toBe("alias · anthropic, bedrock"); + setLang("zh"); + }); + + it("没有上游的别名只写「别名」;只有名称的不写", () => { + setLang("zh"); + expect(modelNote(byId.get("orphan-alias"))).toBe("别名"); + expect(modelNote(byId.get("bare"))).toBeNull(); + expect(modelNote(undefined)).toBeNull(); + }); + + it("目录里的 KnownModel 直接当建议用", () => { + const m: KnownModel = { id: "gpt-5.5", providers: ["chatgpt"], aliases: [] }; + const opt: ModelOption = m; + expect(modelNote(opt)).toBe("chatgpt"); + }); +}); + +describe("输入框里的值是不是别名", () => { + it("按名称认,不分 ASCII 大小写,忽略首尾空白", () => { + expect(aliasNamed("claude-opus-5", byId)?.id).toBe("claude-opus-5"); + expect(aliasNamed(" Claude-Opus-5 ", byId)?.id).toBe("claude-opus-5"); + }); + + it("真名、通配、空值都不是", () => { + expect(aliasNamed("glm-4.6", byId)).toBeNull(); + expect(aliasNamed("claude-*", byId)).toBeNull(); + expect(aliasNamed("", byId)).toBeNull(); + // 别名列表里的模型名不是别名本身 + expect(aliasNamed("us.anthropic.claude-opus-5-v1:0", byId)).toBeNull(); + }); +}); diff --git a/src/routing/fields.tsx b/src/routing/fields.tsx index a888353f..32844de2 100644 --- a/src/routing/fields.tsx +++ b/src/routing/fields.tsx @@ -1,9 +1,11 @@ /** * 路由页几个对话框共用的小件:带建议的模型输入、一排可切换的名称标签。 */ -import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { AliasMark } from "@/aliases/AliasMark"; import { cn } from "@/lib/utils"; -import { useText } from "@/i18n"; +import { textOf, useText } from "@/i18n"; +import { asciiLower } from "@/upstreams/glob"; import { Autocomplete, ComboboxContent, @@ -36,6 +38,10 @@ export function onOpenFocus(e: Event, onDialog: boolean) { * **所以是 Autocomplete,不是 Combobox。**Combobox 在列表收起时把输入框改回选中的 * 那一项、没选过就清空:建议里没有的名字,和比建议短的名字(列表里只有 * `openai.gpt-5.6-sol` 时写 `gpt-5.6-sol`),都在列表收起后被换掉。 + * + * **建议可以带上它是谁提供的。**`models` 给的是目录里的项(`KnownModel`,含别名) + * 而不只是名称时,每条建议右边一句灰字:别名写「别名 · 提供它的上游」,其余写上游; + * 输入框里的值正好是个别名时,框里也标一个「别名」。只给名称的照旧。 */ export function ModelInput({ value, @@ -47,12 +53,20 @@ export function ModelInput({ }: { value: string; onChange: (v: string) => void; - models: string[]; + /** 建议:名称,或目录里的项(`KnownModel` 直接传进来就行) */ + models: readonly (string | ModelOption)[]; placeholder?: string; id?: string; className?: string; }) { const t = useText(fieldsText); + const items = useMemo(() => models.map((m) => (typeof m === "string" ? m : m.id)), [models]); + const options = useMemo(() => { + const byId = new Map(); + for (const m of models) if (typeof m !== "string") byId.set(m.id, m); + return byId; + }, [models]); + const valueIsAlias = useMemo(() => aliasNamed(value, options) != null, [value, options]); // **建议列表挂进所在的对话框,不挂在 body 上。**Radix 的模态对话框把 body // 设成 pointer-events: none,并把对话框外的点击当成「点在外面」,滚轮也 // 只放行对话框内部 —— 挂在 body 上的列表看得见、点不中也滚不动 @@ -77,7 +91,7 @@ export function ModelInput({ return (
- - + + {/* 排在输入框和右边的箭头之间:箭头那一格是 order-last */} + {valueIsAlias && } + + {/* 带灰字时列表至少这么宽:放在窄的输入框(「模型改为」)下面,名称和灰字都挤不下 */} + 0 ? "min-w-80" : undefined}> {t.noMatch} - {(m: string) => ( - - {m} - - )} + {(m: string) => { + const note = modelNote(options.get(m)); + return ( + // 右边有灰字时不给勾选标记留位置:自由输入没有「选中」一说 + + {/* 挤不下时先省略灰字,名称最后才省略 */} + {m} + {note && ( + + {note} + + )} + + ); + }} @@ -101,6 +129,39 @@ export function ModelInput({ ); } +/** + * 一条模型建议。`KnownModel` 就是这个形状:`alias` 给了就是别名(它的模型列表)。 + */ +export type ModelOption = { + id: string; + /** 提供它的上游 */ + providers?: readonly string[]; + /** 是别名时它的模型列表 */ + alias?: readonly string[] | null; +}; + +/** + * 建议右边那句灰字:别名写「别名 · 上游」,其余写上游。只有名称、没有上游的不写 + */ +export function modelNote(m: ModelOption | undefined): string | null { + if (!m) return null; + const t = textOf(fieldsText); + const providers = m.providers?.length ? t.providers(m.providers) : ""; + if (m.alias) return providers ? `${t.alias} · ${providers}` : t.alias; + return providers || null; +} + +/** + * 输入框里的值是哪个别名。按名称认,**不分 ASCII 大小写**:规则的 `when.model` 是 + * glob,core 比的时候两边都转小写 + */ +export function aliasNamed(value: string, options: ReadonlyMap): ModelOption | null { + const v = asciiLower(value.trim()); + if (!v) return null; + for (const m of options.values()) if (m.alias && asciiLower(m.id) === v) return m; + return null; +} + /** * 一排可切换的名称标签(多选)。**和价目表的「使用上游」是同一种控件**:选中的 * 高亮,悬停可以看一句说明(比如它现在用的是哪条路由)。可以带一个标志(密钥是 diff --git a/src/routing/model.i18n.ts b/src/routing/model.i18n.ts index a5c76f1f..8fae7ea0 100644 --- a/src/routing/model.i18n.ts +++ b/src/routing/model.i18n.ts @@ -11,6 +11,9 @@ export const modelText = messages( ruleNameTaken: (name: string) => `规则名称「${name}」已存在`, phaseTwoForward: "含「选定上游」条件的规则不能转发", targetMissing: "选择转发去向", + pinnedMissing: "添加至少一个指定模型", + pinnedProviderMissing: (n: number) => `选择第 ${n} 个指定模型的上游`, + pinnedModelMissing: (n: number) => `填写第 ${n} 个指定模型的模型名`, denyReasonMissing: "填写拒绝原因", continueNeedsAddOns: "继续匹配的规则须设置改写参数或安全要求", maxTokensInvalid: "max_tokens 须为正整数", @@ -22,6 +25,8 @@ export const modelText = messages( upstreamTarget: (protocol: string, disabled: boolean) => `上游 · ${protocol}${disabled ? " · 已停用" : ""}`, unknownTarget: "不存在的去向", setModel: (model: string) => `模型改为 ${model}`, + setModelAlias: (model: string) => `模型改为 ${model},按别名表对应到各上游`, + withBackups: (first: string, n: number) => `${first},备用 ${n} 个`, setMaxTokens: (value: string) => `max_tokens 改为 ${value}`, thinkingOn: "开启扩展思考", thinkingOff: "关闭扩展思考", @@ -43,6 +48,9 @@ export const modelText = messages( ruleNameTaken: (name: string) => `A rule named “${name}” already exists`, phaseTwoForward: "Rules with a “Selected upstream” condition cannot forward", targetMissing: "Select a destination to forward to", + pinnedMissing: "Add at least one specified model", + pinnedProviderMissing: (n: number) => `Select the upstream of specified model ${n}`, + pinnedModelMissing: (n: number) => `Enter the model name of specified model ${n}`, denyReasonMissing: "Enter a deny reason", continueNeedsAddOns: "A rule that continues matching needs parameter rewrites or security requirements", maxTokensInvalid: "max_tokens must be a positive integer", @@ -57,6 +65,8 @@ export const modelText = messages( upstreamTarget: (protocol: string, disabled: boolean) => `Upstream · ${protocol}${disabled ? " · Disabled" : ""}`, unknownTarget: "Destination not found", setModel: (model: string) => `Model set to ${model}`, + setModelAlias: (model: string) => `Model set to ${model}, mapped to each upstream through the alias table`, + withBackups: (first: string, n: number) => `${first}, ${n === 1 ? "1 backup" : `${n} backups`}`, setMaxTokens: (value: string) => `max_tokens set to ${value}`, thinkingOn: "Extended thinking on", thinkingOff: "Extended thinking off", diff --git a/src/routing/model.test.ts b/src/routing/model.test.ts index 0deff6d7..83dfafb9 100644 --- a/src/routing/model.test.ts +++ b/src/routing/model.test.ts @@ -2,8 +2,11 @@ import { describe, expect, it } from "vitest"; import { setLang } from "@/i18n"; import type { ConditionView, RouteView, RuleView } from "@/types"; import { + addOnsText, + blankPinned, blankRule, canLift, + copyDraft, draftFromView, draftNotes, draftToInput, @@ -66,6 +69,68 @@ describe("规则草稿", () => { expect(draftToInput(d).conditions).toEqual([{ field: "input_tokens", values: ["<=32k"] }]); }); + it("指定模型:读进来、交回去是同一个列表,顺序不变", () => { + const to = [ + { provider: "bedrock", model: "us.anthropic.claude-opus-5-v1:0" }, + { provider: "anthropic", model: "claude-opus-5" }, + ]; + const v: RuleView = { + name: "Opus 走 Bedrock", + conditions: [model("claude-opus-5")], + to, + set: { max_tokens: 8192 }, + catch_all: false, + phase_two: false, + shadowed: false, + }; + const d = draftFromView(v); + expect(d.action).toBe("forward"); + expect(d.toKind).toBe("pinned"); + expect(d.to).toBe(""); + expect(d.pinned.map((p) => [p.provider, p.model])).toEqual(to.map((p) => [p.provider, p.model])); + expect(draftToInput(d)).toEqual({ + name: "Opus 走 Bedrock", + conditions: v.conditions, + to, + deny: null, + set: { model: null, max_tokens: 8192, thinking: null }, + }); + // 上游或策略组照旧是一个名称 + expect(draftFromView(view({ name: "兜底", to: "__all__" })).toKind).toBe("target"); + }); + + it("指定模型时「模型改为」不起作用:不写进配置,也不算附加项", () => { + const d = rule("a", { toKind: "pinned", pinned: [blankPinned("anthropic", "claude-opus-5")], model: "x", maxTokens: "4096" }); + expect(draftToInput(d).set).toEqual({ model: null, max_tokens: 4096, thinking: null }); + expect(addOnsText(d)).toBe("max_tokens 改为 4096"); + // 切回上游或策略组,留着的「模型改为」照常生效 + expect(draftToInput({ ...d, toKind: "target" }).set?.model).toBe("x"); + // 只有转发才有指定模型:继续匹配的规则照常改模型 + expect(draftToInput({ ...d, action: "continue" }).set?.model).toBe("x"); + expect(draftToInput({ ...d, action: "continue" }).to).toBeNull(); + }); + + it("指定模型交回去时去掉两头的空白;复制出来的草稿不共用行", () => { + const d = rule("a", { toKind: "pinned", pinned: [blankPinned(" anthropic ", " claude-opus-5 ")] }); + expect(draftToInput(d).to).toEqual([{ provider: "anthropic", model: "claude-opus-5" }]); + const c = copyDraft(d); + expect(c.pinned[0]).not.toBe(d.pinned[0]); + expect(c.pinned[0]!.key).not.toBe(d.pinned[0]!.key); + }); + + it("指定模型缺什么", () => { + const pinned = (...rows: [string, string][]) => + rule("a", { toKind: "pinned", pinned: rows.map(([p, m]) => blankPinned(p, m)) }); + expect(ruleProblem(pinned(), [])).toBe("添加至少一个指定模型"); + expect(ruleProblem(pinned(["anthropic", "claude-opus-5"], ["", "x"]), [])).toBe("选择第 2 个指定模型的上游"); + expect(ruleProblem(pinned(["anthropic", " "]), [])).toBe("填写第 1 个指定模型的模型名"); + expect(ruleProblem(pinned(["anthropic", "claude-opus-5"]), [])).toBeNull(); + // 选定上游之后才判断的规则不能转发,指定模型也一样 + expect( + ruleProblem({ ...pinned(["a", "b"]), conditions: [{ field: "provider_would_be", values: ["a"] }] }, []), + ).toContain("不能转发"); + }); + it("保存按钮旁边说出还缺什么", () => { expect(ruleProblem(rule(""), [])).toBe("填写规则名称"); expect(ruleProblem(rule("兜底"), ["兜底"])).toBe("规则名称「兜底」已存在"); @@ -160,6 +225,28 @@ describe("路由列表", () => { expect(routeProblems(route({ rules: [view({ name: "兜底", to: "x", catch_all: true })] }))).toEqual([]); }); + it("规则一栏里指定模型写成「上游 · 模型」,备用的只说个数", () => { + const r: RouteView = { + ...route({}), + rules: [ + { + ...view({ name: "Opus 走 Bedrock" }), + to: [ + { provider: "bedrock", model: "us.anthropic.claude-opus-5-v1:0" }, + { provider: "anthropic", model: "claude-opus-5" }, + ], + }, + { ...view({ name: "官方 Sonnet" }), to: [{ provider: "anthropic", model: "claude-sonnet-5" }] }, + // 空列表不是去向(core 的校验拦得住,这里只求不当成转发) + { ...view({ name: "空" }), to: [] }, + ], + }; + expect(flowOf(r)).toEqual([ + { rule: "Opus 走 Bedrock", name: "bedrock", target: "bedrock · us.anthropic.claude-opus-5-v1:0,备用 1 个" }, + { rule: "官方 Sonnet", name: "anthropic", target: "anthropic · claude-sonnet-5" }, + ]); + }); + it("英文的数量分单复数", () => { setLang("en"); const shadowed = [view({ name: "a", shadowed: true }), view({ name: "b", shadowed: true })]; diff --git a/src/routing/model.ts b/src/routing/model.ts index 87017b06..3d00ccf7 100644 --- a/src/routing/model.ts +++ b/src/routing/model.ts @@ -5,23 +5,13 @@ * 能不能用、条件写得对不对,最后由 core 说;这里只做对话框里需要实时给出 * 的那几件事:保存按钮旁边缺什么、哪条规则被兜底挡住。 */ -import type { - ClientView, - ConditionField, - ConditionView, - Dialect, - GroupKind, - GroupView, - ProviderView, - RouteView, - RuleInput, - RuleView, -} from "@/types"; +import type { ClientView, ConditionField, ConditionView, Dialect, GroupKind, GroupView, KnownModel, PinnedModel, ProviderView, RouteView, RuleInput, RuleView } from "@/types"; import { textOf } from "@/i18n"; import { ALL_UPSTREAMS, conditionName, groupKindLabel, targetLabel } from "@/labels"; import { protocolLabel } from "@/upstreams/labels"; import { modelText } from "./model.i18n"; import { routingText } from "./routing.i18n"; +import { aliasNamed, hasTarget, pinnedOf, pinnedText, targetNameOf } from "./target"; // ---------------------------------------------------------------- 条件 @@ -121,6 +111,14 @@ export function conditionProblem(c: ConditionView): string | null { export type Action = "forward" | "deny" | "continue"; +/** 转发至:上游或策略组(`to` 是名称),或指定模型(`to` 是「上游 + 模型」的列表) */ +export type ToKind = "target" | "pinned"; + +/** 指定模型列表里的一行。`key` 只给列表用:删掉中间一行时,后面几行的输入框不串 */ +export interface PinnedDraft extends PinnedModel { + key: string; +} + export interface RuleDraft { /** 列表里的稳定标识。规则名在编辑中会变,不能拿来当 key */ key: string; @@ -132,7 +130,11 @@ export interface RuleDraft { name: string; conditions: ConditionView[]; action: Action; + toKind: ToKind; + /** 上游或策略组的名称 */ to: string; + /** 指定模型,按顺序备用 */ + pinned: PinnedDraft[]; deny: string; model: string; maxTokens: string; @@ -152,7 +154,9 @@ export function blankRule(to = ALL_UPSTREAMS): RuleDraft { name: "", conditions: [], action: "forward", + toKind: "target", to, + pinned: [], deny: "", model: "", maxTokens: "", @@ -160,14 +164,21 @@ export function blankRule(to = ALL_UPSTREAMS): RuleDraft { }; } +export function blankPinned(provider = "", model = ""): PinnedDraft { + return { key: nextKey(), provider, model }; +} + export function draftFromView(r: RuleView): RuleDraft { + const pinned = pinnedOf(r.to); return { key: nextKey(), saved: r.name, name: r.name, conditions: r.conditions.map((c) => ({ field: c.field, values: [...c.values] })), - action: r.to ? "forward" : r.deny != null ? "deny" : "continue", - to: r.to ?? "", + action: hasTarget(r.to) ? "forward" : r.deny != null ? "deny" : "continue", + toKind: pinned ? "pinned" : "target", + to: targetNameOf(r.to) ?? "", + pinned: (pinned ?? []).map((p) => blankPinned(p.provider, p.model)), deny: r.deny ?? "", model: r.set?.model ?? "", maxTokens: r.set?.max_tokens != null ? String(r.set.max_tokens) : "", @@ -181,12 +192,23 @@ export function copyDraft(d: RuleDraft): RuleDraft { key: nextKey(), saved: null, conditions: d.conditions.map((c) => ({ ...c, values: [...c.values] })), + pinned: d.pinned.map((p) => blankPinned(p.provider, p.model)), }; } +/** 转发到指定模型:模型名原样发出,「模型改为」不起作用(界面上不出现,也不写进配置) */ +export function isPinned(d: RuleDraft): boolean { + return d.action === "forward" && d.toKind === "pinned"; +} + +/** 「模型改为」的值。转发到指定模型时不算 */ +function setModelOf(d: RuleDraft): string { + return isPinned(d) ? "" : d.model.trim(); +} + /** 附加了改写 */ export function hasAddOns(d: RuleDraft): boolean { - return d.model.trim() !== "" || d.maxTokens.trim() !== "" || d.thinking !== "keep"; + return setModelOf(d) !== "" || d.maxTokens.trim() !== "" || d.thinking !== "keep"; } /** 在选定上游之后才判断:条件里有「选定上游」 */ @@ -207,14 +229,19 @@ export function draftToInput(d: RuleDraft): RuleInput { const deny = d.action === "deny"; const maxTokens = Number.parseInt(d.maxTokens.trim(), 10); const set = { - model: d.model.trim() || null, + model: setModelOf(d) || null, max_tokens: Number.isFinite(maxTokens) && maxTokens > 0 ? maxTokens : null, thinking: d.thinking === "keep" ? null : d.thinking === "on", }; return { name: d.name.trim(), conditions, - to: d.action === "forward" ? d.to : null, + to: + d.action !== "forward" + ? null + : d.toKind === "pinned" + ? d.pinned.map((p) => ({ provider: p.provider.trim(), model: p.model.trim() })) + : d.to, deny: deny ? d.deny.trim() : null, // 拒绝时附加项不起作用:不写进去 set: !deny && (set.model || set.max_tokens || set.thinking != null) ? set : null, @@ -233,7 +260,13 @@ export function ruleProblem(d: RuleDraft, takenNames: string[]): string | null { } if (d.action === "forward") { if (isPhaseTwo(d)) return t.phaseTwoForward; - if (!d.to) return t.targetMissing; + if (d.toKind === "pinned") { + if (d.pinned.length === 0) return t.pinnedMissing; + for (const [i, p] of d.pinned.entries()) { + if (!p.provider.trim()) return t.pinnedProviderMissing(i + 1); + if (!p.model.trim()) return t.pinnedModelMissing(i + 1); + } + } else if (!d.to) return t.targetMissing; } if (d.action === "deny" && !d.deny.trim()) return t.denyReasonMissing; if (d.action === "continue" && !hasAddOns(d)) return t.continueNeedsAddOns; @@ -321,12 +354,26 @@ export function usersOf(route: RouteView, clients: ClientView[]): string[] { /** * 列表「规则」一栏:按顺序列出决定去向的规则。被挡住的、只附加的不列。 - * `name` 是配置里的去向(拿来认上游的标志),`target` 是显示的名字;拒绝时两个都是空。 + * `name` 是配置里的去向(拿来认上游的标志;指定模型时是第一个上游),`target` 是显示的 + * 名字;拒绝时两个都是空。 */ export function flowOf(route: RouteView): { rule: string; name: string | null; target: string | null }[] { return route.rules - .filter((r) => !r.shadowed && !r.phase_two && (r.to || r.deny != null)) - .map((r) => ({ rule: r.name, name: r.to ?? null, target: r.to ? targetLabel(r.to) : null })); + .filter((r) => !r.shadowed && !r.phase_two && (hasTarget(r.to) || r.deny != null)) + .map((r) => { + const pinned = pinnedOf(r.to); + if (pinned?.length) return { rule: r.name, name: pinned[0]!.provider, target: pinnedSummary(pinned) }; + const to = targetNameOf(r.to); + return { rule: r.name, name: to, target: to ? targetLabel(to) : null }; + }); +} + +/** 指定模型写成一行:`bedrock · us.anthropic.claude-opus-5-v1:0,备用 1 个` */ +export function pinnedSummary(pinned: readonly PinnedModel[]): string { + const first = pinned[0]; + if (!first) return ""; + const rest = pinned.length - 1; + return rest > 0 ? textOf(modelText).withBackups(pinnedText(first), rest) : pinnedText(first); } /** 一条路由需要留意的事:有规则被兜底挡住、没有兜底规则。没有就是空 */ @@ -367,11 +414,15 @@ export function membersText(g: Pick ({ id, providers, aliases }); +const alias = (id: string, models: string[], providers = ["p"]): KnownModel => ({ id, providers, alias: models, aliases: [] }); + +const KNOWN: KnownModel[] = [ + alias("claude-opus-5", ["claude-opus-5", "anthropic/claude-opus-5"], ["anthropic", "openrouter"]), + alias("deepseek-v4.1", ["DeepSeek-v4.1-flash"]), + real("DeepSeek-v4.1-flash", ["deepseek-v4.1"]), + real("anthropic/claude-opus-5", ["claude-opus-5"], ["openrouter"]), + real("claude-sonnet-5"), +]; + +describe("规则的去向", () => { + it("名称和指定模型列表分得开", () => { + const pinned = [{ provider: "a", model: "m" }]; + expect(pinnedOf(pinned)).toBe(pinned); + expect(pinnedOf("main")).toBeNull(); + expect(targetNameOf("main")).toBe("main"); + expect(targetNameOf(pinned)).toBeNull(); + expect(targetNameOf("")).toBeNull(); + expect(hasTarget(pinned)).toBe(true); + expect(hasTarget([])).toBe(false); + expect(hasTarget(null)).toBe(false); + }); +}); + +describe("模型条件的提示", () => { + it("写别名:只匹配用这个别名的请求(别名和上游模型同名时也按别名)", () => { + expect(modelHint("claude-opus-5", KNOWN)).toEqual({ kind: "alias", alias: "claude-opus-5" }); + expect(modelHint(" deepseek-v4.1 ", KNOWN)).toEqual({ kind: "alias", alias: "deepseek-v4.1" }); + }); + + it("写上游模型名:也匹配指向它的别名", () => { + expect(modelHint("DeepSeek-v4.1-flash", KNOWN)).toEqual({ + kind: "models", + matches: 1, + inherited: ["deepseek-v4.1"], + exact: true, + }); + // 和 core 一样不区分大小写 + expect(modelHint("deepseek-v4.1-flash", KNOWN)).toMatchObject({ inherited: ["deepseek-v4.1"] }); + }); + + it("通配:按名称匹配到的照常计数,经继承的另列;名称本身已匹配的别名不重复说", () => { + expect(modelHint("anthropic/*", KNOWN)).toEqual({ + kind: "models", + matches: 1, + inherited: ["claude-opus-5"], + exact: false, + }); + // claude-opus-5 这个别名的名称就匹配 claude-*:不算「经继承」 + expect(modelHint("claude-*", KNOWN)).toEqual({ kind: "models", matches: 2, inherited: [], exact: false }); + }); + + it("没有别名的照旧只说匹配几个", () => { + expect(modelHint("claude-sonnet-5", KNOWN)).toEqual({ kind: "models", matches: 1, inherited: [], exact: true }); + expect(modelHint("gpt-*", KNOWN)).toEqual({ kind: "models", matches: 0, inherited: [], exact: false }); + }); + + it("没写、目录还没取到时不提示", () => { + expect(modelHint(" ", KNOWN)).toBeNull(); + expect(modelHint("claude-opus-5", [])).toBeNull(); + }); + + it("别名列着、清单里却没有的模型名照样继承", () => { + // DeepSeek-v4.1-flash 不在目录里(没有上游列出它),别名项的模型列表里有 + const known = KNOWN.filter((m) => m.id !== "DeepSeek-v4.1-flash"); + expect(inheritedAliases(["DeepSeek-v4.1-flash"], known)).toEqual(["deepseek-v4.1"]); + // 几个取值:并集,按目录顺序 + expect(inheritedAliases(["DeepSeek-v4.1-*", "anthropic/claude-opus-5"], KNOWN)).toEqual([ + "claude-opus-5", + "deepseek-v4.1", + ]); + // 名称本身就匹配的别名(DeepSeek-* 不区分大小写地匹配 deepseek-v4.1)不算继承 + expect(inheritedAliases(["DeepSeek-*"], KNOWN)).toEqual([]); + }); + + it("带通配的不当成别名", () => { + expect(aliasNamed("claude-opus-*", KNOWN)).toBeUndefined(); + expect(aliasNamed("claude-sonnet-5", KNOWN)).toBeUndefined(); + expect(aliasNamed("claude-opus-5", KNOWN)?.alias).toEqual(["claude-opus-5", "anthropic/claude-opus-5"]); + }); +}); + +describe("试算的候选", () => { + it("发出模型的来历只认识三种", () => { + expect(modelViaOf({ provider: "openrouter", sent_model: "anthropic/claude-opus-5", model_via: "alias" })).toBe( + "alias", + ); + expect(modelViaOf({ provider: "bedrock", sent_model: "us.anthropic.claude-opus-5-v1:0", model_via: "pinned" })).toBe( + "pinned", + ); + expect(modelViaOf({ provider: "anthropic", sent_model: "claude-opus-5" })).toBeNull(); + expect(modelViaOf({ provider: "anthropic", sent_model: "claude-opus-5", model_via: "later" })).toBeNull(); + }); +}); diff --git a/src/routing/target.ts b/src/routing/target.ts new file mode 100644 index 00000000..cd0ad113 --- /dev/null +++ b/src/routing/target.ts @@ -0,0 +1,96 @@ +/** + * 规则的去向有两种:上游或策略组的名称,或者指定模型的列表(上游 + 模型,按顺序备用)。 + * 读去向的地方都经过这里,不各自去猜 `to` 是哪一种。**纯函数**,路由图也用。 + */ +import type { DryRunCandidate, KnownModel, PinnedModel, RuleTarget } from "@/types"; +import { globMatch } from "@/upstreams/glob"; + +/** 指定模型的列表。去向是上游或策略组、或者没有去向时为空 */ +export function pinnedOf(to: RuleTarget | null | undefined): PinnedModel[] | null { + return Array.isArray(to) ? to : null; +} + +/** 上游或策略组的名称。指定模型、没有去向时为空 */ +export function targetNameOf(to: RuleTarget | null | undefined): string | null { + return typeof to === "string" && to !== "" ? to : null; +} + +/** 有去向:一个名称,或者至少一个指定模型 */ +export function hasTarget(to: RuleTarget | null | undefined): boolean { + const pinned = pinnedOf(to); + return pinned ? pinned.length > 0 : targetNameOf(to) != null; +} + +/** `bedrock · us.anthropic.claude-opus-5-v1:0` */ +export function pinnedText(p: PinnedModel): string { + return `${p.provider} · ${p.model}`; +} + +// ---------------------------------------------------------------- 别名 + +/** 这个名称本身是别名(别名优先:和某个上游模型同名时也算别名) */ +export function aliasNamed(name: string, known: readonly KnownModel[]): KnownModel | undefined { + const n = name.trim(); + if (!n || n.includes("*")) return undefined; + return known.find((m) => m.alias && globMatch(n, m.id)); +} + +/** + * 条件里写上游模型名(可以带 `*`)时,**经继承**也匹配的别名:别名自己的名称不匹配, + * 但它的模型列表里有匹配的。写别名时只管别名本身,这里为空。和 core 的规则条件、 + * 密钥范围是同一条规矩(真名 → 别名,单向)。 + */ +export function inheritedAliases(patterns: readonly string[], known: readonly KnownModel[]): string[] { + const ps = patterns.map((p) => p.trim()).filter(Boolean); + if (ps.length === 0) return []; + const hit = (s: string) => ps.some((p) => globMatch(p, s)); + const out: string[] = []; + const add = (name: string) => { + if (!out.includes(name) && !hit(name)) out.push(name); + }; + for (const m of known) { + if (m.alias) { + if (m.alias.some(hit)) add(m.id); + } else if (hit(m.id)) { + // 清单里没有的模型名也可能被别名列着:两边都看,取并集 + for (const a of m.aliases) add(a); + } + } + return out; +} + +/** 规则条件里模型这一项的提示 */ +export type ModelHint = + /** 写的就是别名:只匹配用这个别名的请求 */ + | { kind: "alias"; alias: string } + /** + * 写的是上游模型名或通配:匹配到几个已知模型,以及经继承也匹配的别名。 + * `exact` 是没有通配的一个名称(「指向它的别名」) + */ + | { kind: "models"; matches: number; inherited: string[]; exact: boolean }; + +/** 已知模型为空(还没取到)时不给提示 */ +export function modelHint(pattern: string, known: readonly KnownModel[]): ModelHint | null { + const p = pattern.trim(); + if (!p || known.length === 0) return null; + const alias = aliasNamed(p, known); + if (alias) return { kind: "alias", alias: alias.id }; + return { + kind: "models", + matches: known.filter((m) => globMatch(p, m.id)).length, + inherited: inheritedAliases([p], known), + exact: !p.includes("*"), + }; +} + +// ---------------------------------------------------------------- 试算 + +/** 发出的模型名为什么和请求里写的不一样:别名、规则改写(`set.model`)、指定模型 */ +export type ModelVia = "alias" | "rule" | "pinned"; + +/** core 说的来历里界面认得的那几种。认不得的(更新的 core)当作没说 */ +export function modelViaOf(c: DryRunCandidate): ModelVia | null { + const v = c.model_via; + return v === "alias" || v === "rule" || v === "pinned" ? v : null; +} + diff --git a/src/traffic/RequestTable.tsx b/src/traffic/RequestTable.tsx index 7b4c4243..68ce5323 100644 --- a/src/traffic/RequestTable.tsx +++ b/src/traffic/RequestTable.tsx @@ -8,6 +8,7 @@ import { ruleName } from "@/security/labels"; import { notSent } from "@/requestRouting"; import { promptTokens, upstreamText, type Filter, type SortDir, type SortKey } from "@/requestTable"; import type { ContentHit, RequestRow } from "@/types"; +import { AliasMark } from "@/aliases/AliasMark"; import { Badge } from "@/ui/badge"; import { Button } from "@/ui/button"; import { UpstreamLogo } from "@/ui/logos"; @@ -18,8 +19,10 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { Tip } from "@/ui/tip"; import { copyText, DIM, Elapsed, Lines, MENU_REVEAL, NotSentIcon, ROW, RowKeyCell } from "./cells"; import { sortWithin, type Cursor, type Group } from "./grouping"; +import { rowMark, type ViaConfig } from "./modelVia"; import { SessionRow } from "./SessionRow"; import { trafficText } from "./Traffic.i18n"; +import { useViaConfig } from "./useModelVia"; /** * 第一帧先画多少行。**其余的在后台补齐**(`useDeferredValue`):一进这一页就要 @@ -89,6 +92,8 @@ export function RequestTable({ const t = useText(trafficText); const shownRows = useDeferredValue(rows, rows.slice(0, FIRST_PAINT)); const shownGroups = useDeferredValue(groups, groups?.slice(0, FIRST_PAINT)); + // 上游那一格的「别名」「指定」要对着现在的别名表和路由看:表头取一次,传给每一行 + const via = useViaConfig(); return ( ; showClient: boolean; hints: boolean; + via: ViaConfig | null; today: number; cursor: Cursor | null; fresh: ReadonlySet; @@ -315,6 +323,7 @@ function RequestRows({ sameProvider={prev !== undefined && upstreamText(prev) === upstreamText(r)} showClient={showClient} hints={hints} + via={via} today={today} selected={atRow === r.id} fresh={fresh.has(r.id)} @@ -385,6 +394,7 @@ const Row = memo(function Row({ sameProvider, showClient, hints, + via, today, selected, fresh, @@ -401,6 +411,8 @@ const Row = memo(function Row({ sameProvider: boolean; showClient: boolean; hints: boolean; + /** 对「别名」「指定」用的配置(见 `modelVia.ts`)。换了一版配置每一行重画一次 */ + via: ViaConfig | null; today: number; selected: boolean; fresh: boolean; @@ -507,7 +519,7 @@ const Row = memo(function Row({ - + {/* 数字右对齐。左对齐时 253ms 和 1486ms 的个位对不齐, @@ -655,9 +667,10 @@ function StatusCell({ r }: { r: RequestRow }) { * 其余各列都不换行,表格变窄时只有这一列收得动。只在徽标之间折,徽标自身 * 不断开:窄了是这一行变高,不是哪一列看不见。 */ -function UpstreamCell({ r }: { r: RequestRow }) { +function UpstreamCell({ r, via }: { r: RequestRow; via: ViaConfig | null }) { const t = useText(trafficText); const sent = notSent(r); + const mark = rowMark(r, via); return (
{/* 本地应答的那一句(英文两个词)可以在词间折行:它不是一个名字,别让它定这一列的最小宽度 */} @@ -681,6 +694,19 @@ function UpstreamCell({ r }: { r: RequestRow }) { )} + {/* 发出的模型名不是客户端写的那个(或者是规则指定的):模型那一列照旧写客户端的名称, + 这里说一声,发出的名称在悬停和详情的路由页里 */} + {mark && ( + + {mark.via === "pinned" ? t.pinned : undefined} + + )} {/* **看不见的安全功能会被用户关掉**,因为他们会怀疑是脱敏搞坏了功能。 所以脱敏发生了就要在列表这一层看得见,而不是藏在详情里 */} {r.secrets && r.secrets.items.length > 0 && ( diff --git a/src/traffic/Traffic.i18n.tsx b/src/traffic/Traffic.i18n.tsx index 56db6212..2b0bdd5d 100644 --- a/src/traffic/Traffic.i18n.tsx +++ b/src/traffic/Traffic.i18n.tsx @@ -136,6 +136,11 @@ export const trafficText = messages( cancelled: "已取消", // 上游一列的徽标和它们的悬浮说明 + /** 客户端写的是别名,按这家上游换成了它的模型名。模型那一列照旧是客户端写的名称 */ + aliasTip: (model: string, upstream: string, sent: string) => `${model} 是别名,发往 ${upstream} 的模型名是 ${sent}。`, + /** 决定去向的规则用了「指定模型」:上游和模型都是规则列出的 */ + pinned: "指定", + pinnedTip: (rule: string, upstream: string, sent: string) => `规则「${rule}」指定了发往 ${upstream} 的模型:${sent}。`, redactedTip: (items: string[]) => `发送前已替换:${items.join("、")}\n模型回显的内容将自动还原。`, redacted: (n: number) => `已脱敏 ${n}`, secretsTip: (items: string[]) => `请求中含有凭据,已原样发出:${items.join("、")}`, @@ -272,6 +277,11 @@ export const trafficText = messages( failed: "Failed", cancelled: "Canceled", + aliasTip: (model: string, upstream: string, sent: string) => + `${model} is an alias; the model sent to ${upstream} is ${sent}.`, + pinned: "Pinned", + pinnedTip: (rule: string, upstream: string, sent: string) => + `Rule “${rule}” pins the model sent to ${upstream}: ${sent}.`, redactedTip: (items: string[]) => `Replaced before sending: ${items.join(", ")}\nContent echoed by the model is restored automatically.`, redacted: (n: number) => `Redacted ${n}`, diff --git a/src/traffic/modelVia.test.ts b/src/traffic/modelVia.test.ts new file mode 100644 index 00000000..880fb7c7 --- /dev/null +++ b/src/traffic/modelVia.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it } from "vitest"; +import type { AttemptView, HistoryRow, KnownModel, PluginRunView, RequestRow, RoutingView } from "@/types"; +import { aliasTable, hopVia, routingModels, rowMark, type HopFacts, type ViaConfig } from "./modelVia"; + +/** 设计稿里的那一份:两个别名、一条指定模型的规则、一条改名的规则、一条第二阶段的改名 */ +const cfg: ViaConfig = { + aliases: new Map([ + ["claude-sonnet-5", ["claude-sonnet-5", "us.anthropic.claude-sonnet-5-v1:0", "anthropic/claude-sonnet-5"]], + ["deepseek-v4.1", ["DeepSeek-v4.1-flash"]], + ]), + routes: [ + { + name: "default", + rules: [ + { + name: "Opus 走 Bedrock", + to: [ + { provider: "bedrock", model: "us.anthropic.claude-opus-5-v1:0" }, + { provider: "anthropic", model: "claude-opus-5" }, + ], + }, + { name: "long-context", set: { model: "claude-sonnet-5" } }, + { name: "relay-rename", set: { model: "anthropic/claude-haiku-4-5" } }, + { name: "relay-sonnet", set: { model: "anthropic/claude-sonnet-5" } }, + { name: "relay-no-thinking", set: { model: null } }, + { name: "兜底", to: "__all__" }, + ], + }, + ], +}; + +const hop = (over: Partial): HopFacts => ({ + client: "claude-sonnet-5", + provider: "anthropic", + sent: "claude-sonnet-5", + route: "default", + rule: "兜底", + rewrittenBy: [], + plugin: false, + ...over, +}); + +describe("别名表", () => { + it("从 GET /models 里挑出带模型列表的那几项", () => { + const models: KnownModel[] = [ + { id: "claude-sonnet-5", providers: ["anthropic", "bedrock"], alias: ["claude-sonnet-5", "us.x"], aliases: [] }, + { id: "claude-haiku-4-5", providers: ["anthropic"], aliases: ["haiku"] }, + { id: "empty", providers: [], alias: [], aliases: [] }, + ]; + expect([...aliasTable(models)]).toEqual([["claude-sonnet-5", ["claude-sonnet-5", "us.x"]]]); + // 目录取不到:一个别名都没有 + expect(aliasTable(undefined).size).toBe(0); + }); +}); + +describe("一跳发出的模型名是怎么来的", () => { + it("别名:发出的是别名列表里的名称;在这家正好同名也算", () => { + expect(hopVia(hop({ provider: "bedrock", sent: "us.anthropic.claude-sonnet-5-v1:0" }), cfg)).toBe("alias"); + expect(hopVia(hop({}), cfg)).toBe("alias"); + expect( + hopVia(hop({ client: "deepseek-v4.1", provider: "relay-cn", sent: "DeepSeek-v4.1-flash" }), cfg), + ).toBe("alias"); + }); + + it("别名表里没有这个名称、或者发出的不在它的列表里:不是别名", () => { + expect(hopVia(hop({ client: "claude-haiku-4-5", sent: "claude-haiku-4-5" }), cfg)).toBeNull(); + // 别名的列表改过了,老记录对不上 + expect(hopVia(hop({ provider: "vertex", sent: "claude-sonnet-5@20260901" }), cfg)).toBeNull(); + }); + + it("指定模型:上游和模型都在决定去向的那条规则的列表里", () => { + const pinned = { client: "claude-opus-5", rule: "Opus 走 Bedrock" }; + expect(hopVia(hop({ ...pinned, provider: "bedrock", sent: "us.anthropic.claude-opus-5-v1:0" }), cfg)).toBe("pinned"); + // 名称没变也是指定的 + expect(hopVia(hop({ ...pinned, provider: "anthropic", sent: "claude-opus-5" }), cfg)).toBe("pinned"); + }); + + it("规则现在是指定模型、这一跳却不在列表里:规则改过,说不准;插件改写过这一跳的说是插件", () => { + const pinned = { client: "claude-opus-5", rule: "Opus 走 Bedrock" }; + expect(hopVia(hop({ ...pinned, provider: "openrouter", sent: "anthropic/claude-opus-5" }), cfg)).toBeNull(); + // 客户端写的恰好是别名也不说别名:那时候这条规则是什么样不知道 + expect(hopVia(hop({ rule: "Opus 走 Bedrock", provider: "openrouter", sent: "anthropic/claude-sonnet-5" }), cfg)).toBeNull(); + expect( + hopVia(hop({ ...pinned, provider: "bedrock", sent: "us.anthropic.claude-opus-5-v1:1", plugin: true }), cfg), + ).toBe("plugin"); + }); + + it("规则改名:改写了参数的规则里有「模型改为」,发出的就是它(或它作为别名对到这家的名称)", () => { + expect( + hopVia(hop({ client: "claude-haiku-4-5", provider: "openrouter", sent: "anthropic/claude-haiku-4-5", rewrittenBy: ["relay-rename"] }), cfg), + ).toBe("rule"); + // 改成的是别名,再按别名表对到 bedrock + expect( + hopVia( + hop({ client: "claude-opus-5", provider: "bedrock", sent: "us.anthropic.claude-sonnet-5-v1:0", rewrittenBy: ["relay-no-thinking", "long-context"] }), + cfg, + ), + ).toBe("rule"); + }); + + it("规则改名排在别名前面;只改了别的参数的规则不算", () => { + // 别名本身也说得通(openrouter 上它就叫这个),但「模型改为」才是改名的那一步 + expect( + hopVia(hop({ provider: "openrouter", sent: "anthropic/claude-sonnet-5", rewrittenBy: ["relay-sonnet"] }), cfg), + ).toBe("rule"); + expect( + hopVia(hop({ provider: "bedrock", sent: "us.anthropic.claude-sonnet-5-v1:0", rewrittenBy: ["relay-no-thinking"] }), cfg), + ).toBe("alias"); + }); + + it("插件:别的都说不通、插件在这一跳改写过请求", () => { + expect(hopVia(hop({ client: "gpt-5.5", provider: "chatgpt", sent: "gpt-5.5-mini", plugin: true }), cfg)).toBe("plugin"); + }); + + it("都对不上:不说原因", () => { + expect(hopVia(hop({ client: "gpt-5.5", provider: "chatgpt", sent: "gpt-5.5-mini" }), cfg)).toBeNull(); + // 改名的那条规则后来删了 + expect( + hopVia(hop({ client: "gpt-5.5", provider: "chatgpt", sent: "gpt-5.5-mini", rewrittenBy: ["deleted-rule"] }), cfg), + ).toBeNull(); + // 路由后来删了 + expect(hopVia(hop({ route: "gone", rule: "Opus 走 Bedrock", provider: "bedrock", sent: "x" }), cfg)).toBeNull(); + }); +}); + +describe("流量表上游那一格的标记", () => { + const row = (over: Partial): RequestRow => ({ + id: 1, + client: "cursor", + provider: "bedrock", + model: "claude-sonnet-5", + path: "/v1/messages", + atMs: 0, + state: "done", + route: "default", + rule: "兜底", + sentModel: "us.anthropic.claude-sonnet-5-v1:0", + ...over, + }); + + it("别名:看服务它的那一跳,发出的名称和客户端写的不同才标", () => { + expect(rowMark(row({}), cfg)).toEqual({ via: "alias", sent: "us.anthropic.claude-sonnet-5-v1:0", rule: "兜底" }); + // 在 anthropic 那里同名:发出的就是模型那一列写的,不标 + expect(rowMark(row({ provider: "anthropic", sentModel: undefined }), cfg)).toBeNull(); + }); + + it("指定:名称相同也标,上游是规则指定的", () => { + expect( + rowMark(row({ model: "claude-opus-5", rule: "Opus 走 Bedrock", sentModel: "us.anthropic.claude-opus-5-v1:0" }), cfg), + ).toEqual({ via: "pinned", sent: "us.anthropic.claude-opus-5-v1:0", rule: "Opus 走 Bedrock" }); + expect(rowMark(row({ model: "claude-opus-5", provider: "anthropic", rule: "Opus 走 Bedrock", sentModel: undefined }), cfg)).toEqual({ + via: "pinned", + sent: "claude-opus-5", + rule: "Opus 走 Bedrock", + }); + }); + + it("规则改名、插件改名不在这里标(插件有自己的徽标)", () => { + expect( + rowMark(row({ model: "claude-haiku-4-5", provider: "openrouter", sentModel: "anthropic/claude-haiku-4-5", rewrittenBy: ["relay-rename"] }), cfg), + ).toBeNull(); + expect(rowMark(row({ model: "gpt-5.5", provider: "chatgpt", sentModel: "gpt-5.5-mini", pluginChanged: true }), cfg)).toBeNull(); + }); + + it("没有发往任何上游、本地应答、还没有路由、配置还没取到、没有模型名:不标", () => { + expect(rowMark(row({ provider: "" }), cfg)).toBeNull(); + expect(rowMark(row({ local: true, provider: "" }), cfg)).toBeNull(); + expect(rowMark(row({ route: undefined, rule: undefined }), cfg)).toBeNull(); + expect(rowMark(row({}), null)).toBeNull(); + expect(rowMark(row({ model: undefined }), cfg)).toBeNull(); + }); +}); + +describe("详情的路由页", () => { + const attempt = (provider: string, model?: string, outcome: AttemptView["outcome"] = "served"): AttemptView => ({ + provider, + outcome, + status: outcome === "served" ? 200 : 529, + ms: 800, + ...(model ? { model } : {}), + }); + const rec = (model: string, routing: Partial): Pick => ({ + model, + routing: { route: "default", rule: "兜底", group: "__all__", rewritten_by: [], denied_by: null, attempts: [], ...routing }, + }); + + it("别名从 anthropic 故障转移到 bedrock:两跳都写模型名,只有改了名的那一跳有悬停", () => { + const m = routingModels( + rec("claude-sonnet-5", { attempts: [attempt("anthropic", undefined, "status"), attempt("bedrock", "us.anthropic.claude-sonnet-5-v1:0")] }), + [], + cfg, + ); + expect(m).toEqual({ + alias: "claude-sonnet-5", + pinnedBy: null, + show: true, + hops: [ + { sent: "claude-sonnet-5", changed: false, via: "alias" }, + { sent: "us.anthropic.claude-sonnet-5-v1:0", changed: true, via: "alias" }, + ], + }); + }); + + it("指定模型:写出是哪条规则", () => { + const m = routingModels( + rec("claude-opus-5", { rule: "Opus 走 Bedrock", group: null, attempts: [attempt("bedrock", "us.anthropic.claude-opus-5-v1:0")] }), + [], + cfg, + ); + expect(m.alias).toBeNull(); + expect(m.pinnedBy).toBe("Opus 走 Bedrock"); + expect(m.hops).toEqual([{ sent: "us.anthropic.claude-opus-5-v1:0", changed: true, via: "pinned" }]); + }); + + it("插件看的是这一跳上的请求钩子改没改过", () => { + const run = (attempt: number, hook: PluginRunView["hook"], outcome: PluginRunView["outcome"]): PluginRunView => ({ + plugin_id: "p", + plugin_name: "p", + hook, + attempt, + outcome, + error: null, + cpu_us: 10, + }); + const r = rec("gpt-5.5", { attempts: [attempt("chatgpt", "gpt-5.5-mini", "status"), attempt("openrouter", "gpt-5.5-mini")] }); + const m = routingModels(r, [run(0, "request", "unchanged"), run(1, "request", "changed"), run(0, "reply", "changed")], cfg); + expect(m.hops.map((h) => h.via)).toEqual([null, "plugin"]); + expect(m.show).toBe(true); + expect(m.alias).toBeNull(); + }); + + it("没改名、不是别名也不是指定:尝试链照旧不写模型名", () => { + const m = routingModels(rec("gpt-5.5", { attempts: [attempt("chatgpt")] }), [], cfg); + expect(m).toEqual({ alias: null, pinnedBy: null, show: false, hops: [{ sent: "gpt-5.5", changed: false, via: null }] }); + }); + + it("配置还没取到:只照记录写发出的名称,不说原因", () => { + const m = routingModels(rec("claude-sonnet-5", { attempts: [attempt("bedrock", "us.anthropic.claude-sonnet-5-v1:0")] }), [], null); + expect(m).toEqual({ + alias: null, + pinnedBy: null, + show: true, + hops: [{ sent: "us.anthropic.claude-sonnet-5-v1:0", changed: true, via: null }], + }); + }); + + it("没有尝试(被拒绝、上游都接不了):两行都不写", () => { + const m = routingModels(rec("claude-sonnet-5", { attempts: [] }), [], cfg); + expect(m).toEqual({ alias: null, pinnedBy: null, show: false, hops: [] }); + }); +}); diff --git a/src/traffic/modelVia.ts b/src/traffic/modelVia.ts new file mode 100644 index 00000000..de3426ac --- /dev/null +++ b/src/traffic/modelVia.ts @@ -0,0 +1,190 @@ +import type { HistoryRow, KnownModel, PluginRunView, RequestRow, RuleTarget } from "@/types"; + +/** + * 发给上游的模型名为什么和客户端写的不一样,或者为什么一样也值得一提。 + * + * · `alias`:客户端写的是别名,按这家上游换成了它的模型名(换完和原名相同也算) + * · `rule`:规则的「模型改为」改了名 + * · `pinned`:决定去向的规则用了「指定模型」,这一跳的上游和模型是它列出来的 + * · `plugin`:插件在这一跳改写过请求 + * + * **请求记录里没有「为什么」这一项**:记下的只有客户端要的名称、每一跳发出的名称(不同才记)、 + * 走的路由和规则、改写了参数的规则。原因是拿这些事实和**现在的**别名表、路由配置对出来的 —— + * 配置改过之后,老记录可能对不上。**对不上就不说**(`null`),不拿现在的配置给老记录编一个原因。 + */ +export type ModelVia = "alias" | "rule" | "pinned" | "plugin"; + +/** 对原因要用到的配置:现在的别名表和路由 */ +export interface ViaConfig { + /** 别名 → 它的模型名,按书写顺序 */ + aliases: ReadonlyMap; + routes: readonly RouteLike[]; +} + +/** 路由里要看的几项。`to` 是列表时是「指定模型」 */ +export interface RouteLike { + name: string; + rules: readonly { + name: string; + to?: RuleTarget | null; + set?: { model?: string | null } | null; + }[]; +} + +/** 从 `GET /models` 里挑出别名:别名 → 它的模型名 */ +export function aliasTable(models: readonly KnownModel[] | undefined): Map { + const out = new Map(); + for (const m of models ?? []) { + if (m.alias && m.alias.length > 0) out.set(m.id, m.alias); + } + return out; +} + +function ruleOf(cfg: ViaConfig, route: string, rule: string) { + return cfg.routes.find((r) => r.name === route)?.rules.find((x) => x.name === rule); +} + +/** 这条规则现在是不是「指定模型」:是的话给出它列的上游和模型 */ +export function pinnedOf(cfg: ViaConfig, route: string, rule: string) { + const to = ruleOf(cfg, route, rule)?.to; + return Array.isArray(to) ? to : null; +} + +/** 一跳的事实:客户端要的名称、发往的上游、这一跳发出的名称,和这次请求的路由 */ +export interface HopFacts { + /** 客户端要的模型名 */ + client: string; + provider: string; + /** 这一跳发出的模型名。记录里没有(和客户端的相同)时就是 `client` */ + sent: string; + route: string; + /** 决定去向的那条规则 */ + rule: string; + /** 改写了参数的规则 */ + rewrittenBy: readonly string[]; + /** 插件在这一跳改写过请求 */ + plugin: boolean; +} + +/** + * 一跳发出的模型名是怎么来的。说不准是 `null`。 + * + * 先后:指定模型(不经过别名表和「模型改为」)→ 规则改名(改出来的名称也可以是别名,再按 + * 别名表对到这家)→ 别名 → 插件。名称没变时只有指定模型和别名说得上:一个是规则列的就是 + * 它,一个是别名在这家正好叫这个名称。 + * + * 决定去向的规则现在是指定模型、这一跳却不在它的列表里:规则在这次请求之后改过,别名、改名 + * 都说不准了;只有插件在这一跳改写过请求时说是插件。 + */ +export function hopVia(h: HopFacts, cfg: ViaConfig): ModelVia | null { + const pinned = pinnedOf(cfg, h.route, h.rule); + if (pinned) { + if (pinned.some((p) => p.provider === h.provider && p.model === h.sent)) return "pinned"; + return h.plugin && h.sent !== h.client ? "plugin" : null; + } + const ofAlias = (name: string) => cfg.aliases.get(name)?.includes(h.sent) === true; + if (h.sent === h.client) return ofAlias(h.client) ? "alias" : null; + for (const name of h.rewrittenBy) { + const to = ruleOf(cfg, h.route, name)?.set?.model; + if (to && to !== h.client && (to === h.sent || ofAlias(to))) return "rule"; + } + if (ofAlias(h.client)) return "alias"; + return h.plugin ? "plugin" : null; +} + +/** 流量表上游那一格的标记:「别名」或「指定」。别的原因不在表里标 */ +export interface RowMark { + via: "alias" | "pinned"; + /** 服务它的那一跳发出的模型名 */ + sent: string; + /** 决定去向的规则(「指定」的悬停要说是哪一条) */ + rule: string; +} + +/** + * 一行请求要不要标「别名」「指定」:看服务它的那一跳(尝试链的最后一跳)。没有发往任何上游的、 + * 本地应答的、还没有路由的、说不准的都不标。 + * + * **「别名」只在发出的名称和客户端写的不同时标。**别名在官方上游往往就叫它自己(`claude-sonnet-5` + * 在 anthropic 那里还是 `claude-sonnet-5`),每一条这样的请求都标上,表里满是一样的标记,而它们 + * 发出的正是模型那一列写的名称,没有要说的。是不是别名在详情的路由页里写。「指定」照样标:上游 + * 是规则指定的,名称相同也是。 + */ +export function rowMark( + r: Pick, + cfg: ViaConfig | null, +): RowMark | null { + if (!cfg || r.local || !r.provider || !r.model || r.route === undefined || r.rule === undefined) return null; + const sent = r.sentModel ?? r.model; + const via = hopVia( + { + client: r.model, + provider: r.provider, + sent, + route: r.route, + rule: r.rule, + rewrittenBy: r.rewrittenBy ?? [], + plugin: r.pluginChanged === true, + }, + cfg, + ); + if (via === "pinned" || (via === "alias" && sent !== r.model)) return { via, sent, rule: r.rule }; + return null; +} + +/** 尝试链里一跳的模型那一格 */ +export interface HopModel { + /** 这一跳发出的模型名 */ + sent: string; + /** 和客户端要的不同 */ + changed: boolean; + via: ModelVia | null; +} + +/** 详情「路由」页里和模型名有关的几项 */ +export interface RoutingModels { + /** 客户端写的是别名:别名的名称。有一跳按别名对上了才有 */ + alias: string | null; + /** 决定去向的规则用了指定模型:规则的名称。有一跳对上了才有 */ + pinnedBy: string | null; + /** 每一跳一项,和尝试链对齐 */ + hops: HopModel[]; + /** 尝试链里要不要写出每一跳的模型名:有改名、或者用了别名或指定模型时写 */ + show: boolean; +} + +/** + * 详情「路由」页的别名、指定模型两行和尝试链里每一跳的模型名。`plugins` 是这次请求上插件的 + * 运行记录:哪一跳的请求钩子改写过请求。 + */ +export function routingModels( + r: Pick, + plugins: readonly PluginRunView[], + cfg: ViaConfig | null, +): RoutingModels { + const routing = r.routing; + const attempts = routing?.attempts ?? []; + const hops: HopModel[] = attempts.map((a, i) => { + const sent = a.model || r.model; + const changed = sent !== r.model; + const via = + cfg && routing && r.model + ? hopVia( + { + client: r.model, + provider: a.provider, + sent, + route: routing.route, + rule: routing.rule, + rewrittenBy: routing.rewritten_by, + plugin: plugins.some((p) => p.attempt === i && p.hook === "request" && p.outcome === "changed"), + }, + cfg, + ) + : null; + return { sent, changed, via }; + }); + const alias = hops.some((h) => h.via === "alias") ? r.model : null; + const pinnedBy = routing && hops.some((h) => h.via === "pinned") ? routing.rule : null; + return { alias, pinnedBy, hops, show: alias !== null || pinnedBy !== null || hops.some((h) => h.changed) }; +} diff --git a/src/traffic/useModelVia.ts b/src/traffic/useModelVia.ts new file mode 100644 index 00000000..ee66efbc --- /dev/null +++ b/src/traffic/useModelVia.ts @@ -0,0 +1,17 @@ +import { useMemo } from "react"; +import { call } from "@/control"; +import { useKnownModels } from "@/keys/data"; +import { useResource } from "@/lib/resource"; +import { aliasTable, type ViaConfig } from "./modelVia"; + +/** + * 对模型名的原因要用的配置(见 `modelVia.ts`):现在的路由(概览里的)和别名表(`GET /models` + * 里的别名项)。概览还没取到是 `null`,什么都不标。模型目录取不到时别名表是空的:只标得出 + * 「指定」,「别名」一个都不标 —— 不知道就不说。 + */ +export function useViaConfig(): ViaConfig | null { + const ov = useResource("overview", () => call("Overview", null), { events: ["config_reloaded"] }); + const models = useKnownModels(); + const routes = ov.data?.routes; + return useMemo(() => (routes ? { aliases: aliasTable(models.data), routes } : null), [routes, models.data]); +} diff --git a/src/types.ts b/src/types.ts index cfa75ce5..045b12b5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -76,8 +76,19 @@ export interface RequestRow { /** 请求带的那把密钥打码后的样子(`tw-re…wb4e`),请求那一刻的 */ keyMasked?: string; provider: string; - /** 哪个模型。**决定这次多贵、多慢的就是它** */ + /** 客户端要的模型名。别名、规则改名、指定模型、插件改名之前的那一个 */ model?: string; + /** + * 服务它的那一跳(尝试链的最后一跳)发出的模型名。**和 `model` 相同时没有**(core 只在不同 + * 时记)。还没有路由结论的也没有 + */ + sentModel?: string; + /** 走的哪条路由。开始时就有;本地应答的没有 */ + route?: string; + /** 决定去向的那条规则 */ + rule?: string; + /** 改写了参数的规则,按求值的顺序。没有就没有这一项 */ + rewrittenBy?: string[]; path: string; atMs: number; /** @@ -151,6 +162,11 @@ export interface RequestRow { session?: string; } +/** 空的列表当作没有:行上少一项,对账时不会因为又来了一个空数组而算作「变了」 */ +export function nonEmpty(xs: readonly T[] | null | undefined): T[] | undefined { + return xs && xs.length > 0 ? [...xs] : undefined; +} + export function applyEvent(rows: Map, ev: CoreEvent): void { switch (ev.kind) { case "request_started": @@ -167,6 +183,10 @@ export function applyEvent(rows: Map, ev: CoreEvent): void { atMs: ev.at_ms, state: "in_flight", session: ev.session ?? undefined, + // 路由的第一阶段在开始之前就走完了:路由、规则、改写开始时就有 + route: ev.route, + rule: ev.rule, + rewrittenBy: nonEmpty(ev.rewritten_by), }); break; case "request_headers": { @@ -223,7 +243,16 @@ export function applyEvent(rows: Map, ev: CoreEvent): void { // 最后一跳,和落库那一行归的是同一家 const r = rows.get(ev.id); const last = ev.attempts[ev.attempts.length - 1]; - if (r && last) r.provider = last.provider; + if (r && last) { + r.provider = last.provider; + r.sentModel = last.model ?? undefined; + } + if (r) { + r.route = ev.route; + r.rule = ev.rule; + // 选定上游之后才判断的改写在这里才有 + r.rewrittenBy = nonEmpty(ev.rewritten_by); + } break; } case "locally_answered": diff --git a/src/upstreams/ModelsPanel.i18n.ts b/src/upstreams/ModelsPanel.i18n.ts index fea24806..d2da5035 100644 --- a/src/upstreams/ModelsPanel.i18n.ts +++ b/src/upstreams/ModelsPanel.i18n.ts @@ -27,6 +27,9 @@ export const modelsPanelText = messages( estimated: "(估)", priceTitle: "输入 / 输出,美元每百万 tokens", context: "上下文窗口", + aliasMark: (alias: string) => `别名 ${alias}`, + aliasTitle: (alias: string) => `别名 ${alias} 列着这个模型:客户端用 ${alias} 请求时可以发往这个模型`, + makeAlias: "起别名…", }, { title: "Models", @@ -58,5 +61,8 @@ export const modelsPanelText = messages( estimated: " (est.)", priceTitle: "Input / output, USD per million tokens", context: "Context window", + aliasMark: (alias: string) => `alias ${alias}`, + aliasTitle: (alias: string) => `Alias ${alias} lists this model: requests for ${alias} can go to it`, + makeAlias: "Add alias…", }, ); diff --git a/src/upstreams/ModelsPanel.tsx b/src/upstreams/ModelsPanel.tsx index 4d8470ce..e63059bf 100644 --- a/src/upstreams/ModelsPanel.tsx +++ b/src/upstreams/ModelsPanel.tsx @@ -1,5 +1,6 @@ import { type ReactNode, useMemo, useState } from "react"; import { ChevronRightIcon, CircleAlertIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; +import { AliasMark } from "@/aliases/AliasMark"; import { cn } from "@/lib/utils"; import { useResource } from "@/lib/resource"; import { Button } from "@/ui/button"; @@ -27,17 +28,23 @@ const FILTER_FROM = 10; * 清单按上游缓存(`upstream-models:<名字>`):再点开一次先画上一次的,后台再读。 * 概览里这一家的获取状态一变(开始问了、问完了)就重读 —— 开着面板等它问完, * 也能看到结果。读的是 core 记下的答案,不联网。 + * + * 列进了别名的模型,名字后面标出别名;悬停一行给「起别名…」(`onAlias`),打开新建别名的 + * 对话框,这个模型已经列为上游模型。 */ export function ModelsPanel({ p, perToken, onEdit, + onAlias, }: { p: ProviderView; /** 按量计费:列出单价。别的计费方式不按单价算费用,列了也没意义 */ perToken: boolean; /** 打开编辑对话框的「模型」一节 */ onEdit: () => void; + /** 给这个模型起别名。不给就不出「起别名…」 */ + onAlias?: (model: string) => void; }) { const t = useText(modelsPanelText); const c = useText(commonText); @@ -166,7 +173,7 @@ export function ModelsPanel({ )}
{shownOn.map((m) => ( - + ))} {off.length > 0 && ( <> @@ -186,7 +193,7 @@ export function ModelsPanel({ {t.notEnabled(off.length)} {(showOff || q !== "") && - shownOff.map((m) => )} + shownOff.map((m) => )} )} {q !== "" && shownOn.length + shownOff.length === 0 && ( @@ -218,7 +225,15 @@ function summary(source: string, total: number, enabled: number, view: ProviderM return view.checked_at_ms ? t.lastTry(clock(view.checked_at_ms)) : t.notFetched; } -function Row({ m, perToken }: { m: ModelRow; perToken: boolean }) { +function Row({ + m, + perToken, + onAlias, +}: { + m: ModelRow; + perToken: boolean; + onAlias?: (model: string) => void; +}) { const t = useText(modelsPanelText); const price = perToken && m.price @@ -227,21 +242,50 @@ function Row({ m, perToken }: { m: ModelRow; perToken: boolean }) { return (
- + {m.id} - {price && ( - - {price} + {m.aliases.map((a) => ( + + {t.aliasMark(a)} + + ))} + {/* 悬停时这一格让给「起别名…」:两样叠在同一个位置,行高不跳 */} + + {price && ( + + {price} + + )} + + {m.context_window ? contextWindow(m.context_window) : ""} - )} - - {m.context_window ? contextWindow(m.context_window) : ""} + {onAlias && ( + + )}
); } diff --git a/src/upstreams/UpstreamTable.tsx b/src/upstreams/UpstreamTable.tsx index 166edcb8..66f1f528 100644 --- a/src/upstreams/UpstreamTable.tsx +++ b/src/upstreams/UpstreamTable.tsx @@ -30,6 +30,7 @@ import { } from "./labels"; import { labelsText } from "./labels.i18n"; import { ModelsPanel } from "./ModelsPanel"; +import { AliasDialog } from "@/aliases/AliasDialog"; import { ProviderTile, keepInRow, openRow } from "./parts"; import { QUOTA_FULL, QuotaBar } from "./QuotaBar"; import { SPARKLINE_WIDTH, Sparkline } from "./Sparkline"; @@ -286,6 +287,8 @@ function ModelsCell({ p, busy, onEdit }: { p: ProviderView; busy: boolean; onEdi const t = useText(upstreamTableText); const l = useText(labelsText); const [open, setOpen] = useState(false); + /** 「起别名…」点的那个模型。对话框挂在弹窗外面:弹窗一收起,里面的东西就卸掉了 */ + const [aliasFor, setAliasFor] = useState(null); if (p.disabled) { return —; } @@ -312,7 +315,7 @@ function ModelsCell({ p, busy, onEdit }: { p: ProviderView; busy: boolean; onEdi )} - + { + setOpen(false); + setAliasFor(model); + }} /> + !o && setAliasFor(null)} + initial={{ models: aliasFor !== null ? [aliasFor] : [] }} + /> ); } diff --git a/src/upstreams/UpstreamsPage.tsx b/src/upstreams/UpstreamsPage.tsx index 4f849ed3..fd9c8fa6 100644 --- a/src/upstreams/UpstreamsPage.tsx +++ b/src/upstreams/UpstreamsPage.tsx @@ -38,8 +38,12 @@ import { CostFigure } from "@/CostFigure"; import { UpstreamTable, problemsOf } from "./UpstreamTable"; import { ZaiLoginDialog } from "./ZaiLoginDialog"; import { NextClientsHint } from "@/guide/PageHints"; +import { AliasesTab, AliasTabLabel, type AliasDialogMode } from "@/aliases/AliasesTab"; +import { AliasDialog } from "@/aliases/AliasDialog"; +import { aliasesText } from "@/aliases/aliases.i18n"; +import type { AliasInput } from "@/types"; -export type UpstreamTab = "upstreams" | "proxies" | "pricing" | "checkup"; +export type UpstreamTab = "upstreams" | "aliases" | "proxies" | "pricing" | "checkup"; /** * 这次打开应用以来最后看的那个标签。切到别的页再回来,还停在那一个。 @@ -60,14 +64,17 @@ type DialogState = | { kind: "proxy"; mode: ProxyDialogMode } | { kind: "delete-proxy"; name: string } | { kind: "sheet"; mode: PriceSheetDialogMode } - | { kind: "delete-sheet"; name: string }; + | { kind: "delete-sheet"; name: string } + /** 新建、编辑别名(`AliasDialog`) */ + | { kind: "alias"; mode: AliasDialogMode }; /** - * 上游页:上游、代理、价目表、体检四个标签。 + * 上游页:上游、别名、代理、价目表、体检五个标签。 * - * 前三者描述出站侧的三个方面 —— 请求发往哪个服务、经过哪条网络路径、按什么 - * 价格结算 —— 而代理和价目表只被上游引用,所以放在同一页里,引用关系在 - * 页内闭合。**列表只读**,新建与编辑都在对话框里完成,一次保存一个版本。 + * 上游、代理、价目表描述出站侧的三个方面 —— 请求发往哪个服务、经过哪条网络路径、 + * 按什么价格结算 —— 而代理和价目表只被上游引用,所以放在同一页里,引用关系在 + * 页内闭合。别名是同一个模型在各个上游的不同名称,也只和上游的模型清单打交道。 + * **列表只读**,新建与编辑都在对话框里完成,一次保存一个版本。 * 体检回答的是「这些上游实际表现如何」:一段时间里各上游的几项事实和相互参照。 * * 页头的摘要说上游的整体状况:几个、几个正常、几个要处理,24 小时的请求与费用。 @@ -85,6 +92,7 @@ export default function UpstreamsPage({ }) { const t = useText(upstreamsPageText); const c = useText(commonText); + const at = useText(aliasesText); const nav = useNav(); const configVersion = ov.config_version; /** 这一页上的启停排成一队,见 `toggle` */ @@ -273,6 +281,8 @@ export default function UpstreamsPage({ const createUpstream = () => setDialog({ kind: "upstream", mode: { kind: "create" } }); const createProxy = () => setDialog({ kind: "proxy", mode: { kind: "create" } }); + const createAlias = (initial?: Partial) => + setDialog({ kind: "alias", mode: { kind: "create", initial } }); const actions = tab === "upstreams" ? ( @@ -296,6 +306,11 @@ export default function UpstreamsPage({ {t.newUpstream} + ) : tab === "aliases" ? ( + ) : tab === "proxies" ? ( <> {proxies.length > 0 && ( @@ -354,6 +369,9 @@ export default function UpstreamsPage({ {t.tabs.upstreams} + + + {t.tabs.proxies} @@ -421,6 +439,16 @@ export default function UpstreamsPage({ )} + + setDialog({ kind: "alias", mode: { kind: "edit", name } })} + onChanged={changed} + /> + + {proxies.length === 0 ? ( )} + {dialog?.kind === "alias" && ( + { + if (!open) setDialog(null); + }} + editing={dialog.mode.kind === "edit" ? dialog.mode.name : undefined} + initial={dialog.mode.kind === "create" ? dialog.mode.initial : undefined} + onSaved={() => { + setDialog(null); + changed(); + }} + /> + )} {dialog?.kind === "delete-sheet" && ( , history: HistoryRow[ // 上游以库里的为准:故障转移之后服务它的是尝试链的最后一跳 if (!h.local) next.provider = h.provider; next.model ??= h.model || undefined; + // 路由也以库里的为准:服务它的那一跳和它发出的模型名是一对,跟着上游走。改写的规则 + // 名单一样就留着原来那个数组,不然每次对账这一行都「变了」 + if (h.routing) { + next.route = h.routing.route; + next.rule = h.routing.rule; + next.sentModel = lastSent(h); + if ((next.rewrittenBy ?? []).join("\n") !== h.routing.rewritten_by.join("\n")) + next.rewrittenBy = nonEmpty(h.routing.rewritten_by); + } next.ttftMs ??= h.ttft_ms ?? undefined; if (h.input_tokens != null) next.inputTokens = h.input_tokens; if (h.output_tokens != null) next.outputTokens = h.output_tokens; @@ -97,6 +107,12 @@ export function mergeHistory(rows: Map, history: HistoryRow[ return dirty; } +/** 服务它的那一跳发出的模型名(和客户端要的不同才有) */ +function lastSent(h: HistoryRow): string | undefined { + const hops = h.routing?.attempts ?? []; + return hops[hops.length - 1]?.model ?? undefined; +} + /** * 库里的一条记录变成表格的一行。读历史、在整份记录里搜索都走它。 * @@ -128,6 +144,10 @@ export function rowFromHistory(h: HistoryRow): RequestRow { translated: h.translated ?? undefined, pluginChanged: h.plugin_changed || undefined, session: h.session ?? undefined, + route: h.routing?.route, + rule: h.routing?.rule, + rewrittenBy: nonEmpty(h.routing?.rewritten_by), + sentModel: lastSent(h), hint: h.client_hint ?? undefined, peer: h.peer ?? undefined, keyMasked: h.key_masked ?? undefined,