From ad5c33ae0969ca6579d3f68d2ffab3cd702f37a7 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:47:50 +0800 Subject: [PATCH 01/18] Key scope and model input: show model aliases, inherit from real names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The key dialog's visible-models table now lists aliases from GET /models with an "alias" mark. An alias whose listed model is in scope (picked or matched by a pattern) is shown checked and locked with "allowed with "; picking or matching only the alias leaves its models hidden. Pattern hit counts stay name-based (aliases included), the visible count includes inherited aliases, and a note under the table states the rule. Inheritance is computed, never written: allow keeps the user's entries. ModelInput accepts KnownModel-shaped options besides plain names; each suggestion then carries a right-aligned muted note (providers, or "alias · providers"), and the input marks a value that names an alias. Types come from src/aliases/api.provisional.ts until core's bindings land. Co-Authored-By: Claude Opus 5.5 --- src/aliases/api.provisional.ts | 34 ++++++++ src/keys/ModelScope.i18n.ts | 13 +++ src/keys/ModelScope.test.tsx | 61 +++++++++++++- src/keys/ModelScope.tsx | 109 +++++++++++++++--------- src/keys/scope.test.ts | 147 +++++++++++++++++++++++++++++++++ src/keys/scope.ts | 77 ++++++++++++++--- src/routing/fields.i18n.ts | 4 + src/routing/fields.test.ts | 51 ++++++++++++ src/routing/fields.tsx | 96 ++++++++++++++++++--- 9 files changed, 526 insertions(+), 66 deletions(-) create mode 100644 src/aliases/api.provisional.ts create mode 100644 src/routing/fields.test.ts diff --git a/src/aliases/api.provisional.ts b/src/aliases/api.provisional.ts new file mode 100644 index 00000000..b0b8ead9 --- /dev/null +++ b/src/aliases/api.provisional.ts @@ -0,0 +1,34 @@ +// 临时类型:core 的模型别名合并后由 src/generated/tw-api.ts 生成的同名类型替换。 +import type { Msg } from "@/generated/tw-api"; + +export type PinnedModel = { provider: string; model: string }; +export type AliasInput = { name: string; models: Array }; +export type AliasSave = { alias: AliasInput; base_version?: string }; +export type AliasModel = { model: string; providers: Array }; +export type AliasView = { + name: string; + models: Array; + served_by: Array; + shadows: Array; + context_window?: number; + requests_24h: number; + cost_micros_24h?: number; +}; +export type AliasSuggestion = { label: string; models: Array }; +export type AliasesView = { aliases: Array; suggestions: Array }; +export type AliasPreviewRequest = { alias: AliasInput; original?: string }; +export type AliasPreview = { + problems: Array; + served_by: Array; + unserved: Array; + shadows: Array; + same_model: Array; +}; +export type AliasRuleRef = { route: string; rule: string; field: string }; +export type AliasUsage = { requests_24h: number; keys: Array; rules: Array }; +export type AliasWritten = { version: string; renamed_in: AliasUsage }; +/** 规则的去向:上游或策略组的名称,或指定模型的列表 */ +export type RuleTarget = string | Array; +/** KnownModel / ModelRow 新加的字段 */ +export type KnownModelAliasFields = { alias?: Array; aliases: Array }; +export type ModelRowAliasFields = { aliases: Array }; diff --git a/src/keys/ModelScope.i18n.ts b/src/keys/ModelScope.i18n.ts index ed7ca10c..29ab2416 100644 --- a/src/keys/ModelScope.i18n.ts +++ b/src/keys/ModelScope.i18n.ts @@ -26,10 +26,17 @@ export const modelScopeText = messages( source: "来源", byPattern: (p: string) => `由 ${p} 命中`, picked: "单独选中", + /** 别名随它列表里的某个模型一起可见 */ + inherited: (m: string) => `随 ${m} 放行`, + alias: "别名", + 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 +67,14 @@ export const modelScopeText = messages( source: "From", byPattern: (p: string) => `matched by ${p}`, picked: "picked", + inherited: (m: string) => `allowed with ${m}`, + alias: "alias", + 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..58056539 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 { CatalogModel } from "./scope"; 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..aca35fb0 100644 --- a/src/keys/ModelScope.tsx +++ b/src/keys/ModelScope.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; import { SearchIcon, Trash2Icon } from "lucide-react"; +import { Badge } from "@/ui/badge"; import { Button } from "@/ui/button"; import { Checkbox } from "@/ui/checkbox"; import { Input } from "@/ui/input"; @@ -7,18 +8,21 @@ import { InputGroup, InputGroupAddon, InputGroupInput } from "@/ui/input-group"; import { Segmented } from "@/ui/segmented"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/ui/table"; import { useText } from "@/i18n"; -import type { KnownModel } from "@/types"; import { Boxed, FormItem, Note } from "@/upstreams/parts"; import { modelScopeText } from "./ModelScope.i18n"; import { addPattern, + hasAliases, + locked, patternHits, removeEntry, rowsOf, splitEntries, toggleModel, visibleCount, + type CatalogModel, type Scope, + type Source, } from "./scope"; /** 一次画多少行。几百个模型全渲染出来,打开对话框会卡一下 */ @@ -30,6 +34,9 @@ const PAGE = 60; * **模型清单来自网关聚合后的目录,不按这把密钥的路由过滤。**core 的准入 * (`resolve_allowed`)只看客户端方言和 `allow`,不看路由 —— 按路由删掉 * 一半,显示的就不是客户端真会看到的那一份。每行写明来自哪个上游。 + * + * **别名也列在里面**,名称旁一个「别名」标记。范围里有它列表里的某个模型时,它跟着 + * 可见:勾上、点不动,来源写「随 … 放行」。反过来不成立 —— 只选别名,它的模型不可见。 */ export function ModelScope({ scope, @@ -40,8 +47,8 @@ export function ModelScope({ }: { scope: Scope; entries: string[]; - /** 网关知道的全部模型。尚未获取到时是空的 */ - catalog: KnownModel[]; + /** 网关知道的全部模型,含别名。尚未获取到时是空的 */ + catalog: CatalogModel[]; onScope: (s: Scope) => void; onEntries: (e: string[]) => void; }) { @@ -173,44 +180,40 @@ 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 && ( + + {t.alias} + + )} + + + {r.unknown ? "—" : t.providers(r.providers)} + + {sourceText(r.source, t)} + + ))} {matched.length === 0 && ( @@ -227,6 +230,7 @@ export function ModelScope({ )} + {hasAliases(catalog) && {t.aliasRule}} )} @@ -234,3 +238,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..48d86f68 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, @@ -12,6 +16,7 @@ import { splitEntries, toggleModel, visibleCount, + type CatalogModel, } from "./scope"; const CATALOG: KnownModel[] = [ @@ -73,6 +78,7 @@ describe("表格要画的行", () => { id: "ghost-model", providers: [], unknown: true, + alias: null, source: { kind: "picked" }, }); expect(rows).toHaveLength(CATALOG.length + 1); @@ -125,3 +131,144 @@ describe("表格里「可见模型」那一栏", () => { expect(scopeLabel(null, CATALOG)).toEqual({ text: "全部", warn: false }); }); }); + +/** + * 目录里带别名的样子(core 的 `/models`):别名和同名真模型只出现一次(就是别名), + * 真名那一项的 `aliases` 是指向它的别名 + */ +const WITH_ALIASES: CatalogModel[] = [ + { 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..da152983 100644 --- a/src/keys/scope.ts +++ b/src/keys/scope.ts @@ -8,10 +8,19 @@ * 界面要把这一个列表讲成两件事 —— 「覆盖一组的规则」和「单独选中的模型」 * —— 因为聚合目录可能有几百个模型:逐个勾不现实,而勾一下就把规则展开成 * 明细会往配置里写几百行。两者并存,互不摧毁。 + * + * **别名只从真名继承,不反过来。**目录里也列着别名(`alias` 是它的模型列表)。 + * `allow` 里有一条放行了别名列表里的某个模型名,这个别名也可见(core 的 + * `resolve_allowed` 同样这么算);只放行别名,它的模型不跟着可见。继承是算出来的, + * **不写进 `allow`**:配置里只有用户自己写的那几条。 */ import { asciiLower, globMatch } from "@/upstreams/glob"; +import type { KnownModelAliasFields } from "@/aliases/api.provisional"; import type { KnownModel } from "@/types"; +/** 目录里的一项。别名也在里面:`alias` 是它的模型列表,真名没有这个字段 */ +export type CatalogModel = KnownModel & Partial; + export type Scope = "all" | "some" | "none"; export function scopeOf(allow: string[] | null | undefined): Scope { @@ -48,17 +57,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; + +/** + * 别名随哪个模型放行:列表里第一个被某条条目放行的模型名。没有就是 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; +} -export function sourceOf(entries: string[], model: string): Source { - // **规则优先。**同时成立时说「由规则命中」才有用:那一行取消不掉, - // 而原因是规则,不是那条重复的明细 +/** + * `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 +104,45 @@ export interface ScopeRow { providers: string[]; /** 目录里没有它 —— 上游改了名,或者当初手打错了。**照样列出来**,否则删不掉 */ unknown: boolean; + /** 是别名时它的模型列表,否则 null */ + alias: string[] | null; source: Source; } -export function rowsOf(entries: string[], catalog: KnownModel[]): ScopeRow[] { +export function rowsOf(entries: string[], catalog: CatalogModel[]): 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; +/** 目录里有几个模型对这把密钥可见。别名算在里面,随它的模型放行的也算 */ +export function visibleCount(entries: string[], catalog: CatalogModel[]): number { + return catalog.filter((m) => sourceOf(entries, m.id, m.alias) != null).length; } -/** 一条规则命中几个。目录还没有就答不上来 */ -export function patternHits(pattern: string, catalog: KnownModel[]): number { +/** + * 一条规则命中几个。目录还没有就答不上来。 + * + * **只按名称数**,别名也按它自己的名称:`claude-sonnet-*` 命中别名 `claude-sonnet-5`, + * 不命中只是列表里有个 `claude-sonnet-5` 的别名 `sonnet`(那一个算「随 … 放行」) + */ +export function patternHits(pattern: string, catalog: CatalogModel[]): number { return catalog.filter((m) => globMatch(pattern, m.id)).length; } +/** 目录里有没有别名。没有就不必说明别名怎么算 */ +export function hasAliases(catalog: CatalogModel[]): boolean { + return catalog.some((m) => m.alias != null); +} + /** * 勾上或取消一个模型。 * 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..4e321fee --- /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"] }; + 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..7de839fe 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 { cn } from "@/lib/utils"; -import { useText } from "@/i18n"; +import { textOf, useText } from "@/i18n"; +import { asciiLower } from "@/upstreams/glob"; +import { Badge } from "@/ui/badge"; 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,52 @@ 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; +} + +function AliasMark() { + const t = useText(fieldsText); + return ( + + {t.alias} + + ); +} + /** * 一排可切换的名称标签(多选)。**和价目表的「使用上游」是同一种控件**:选中的 * 高亮,悬停可以看一句说明(比如它现在用的是哪条路由)。可以带一个标志(密钥是 From d7b6b71ebf3142432d605591b2e9f5a5bd368797 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:55:28 +0800 Subject: [PATCH 02/18] Traffic: alias and pinned-model markers, and why each attempt sent its model The traffic table's model column keeps the client's name. The upstream cell now marks "Alias" when an alias made the serving attempt send another name, and "Pinned" when the deciding rule pins upstream + model. The request drawer's Routing tab adds an "Alias" row (resolved per upstream) or a "Pinned model" row naming the rule, and the attempt chain lists each hop's sent model when anything was renamed, with a tooltip worded by cause: alias, rule rewrite (existing wording), pinned model, plugin, or a plain statement when the cause no longer matches the current config. The record has no "why": the cause is worked out from the client model, each attempt's sent model, the route / rule / rewritten_by, and the current alias table (GET /models alias items) and routes (overview). When those disagree, nothing is claimed. Rows now carry route, rule, rewritten_by and the serving hop's sent model from events and history. src/aliases/api.provisional.ts holds the contract's provisional types. Co-Authored-By: Claude Opus 5.5 --- src/RequestDrawer.i18n.tsx | 22 +++ src/RequestDrawer.tsx | 59 ++++++-- src/aliases/api.provisional.ts | 34 +++++ src/events.test.ts | 76 ++++++++++ src/traffic/RequestTable.tsx | 30 +++- src/traffic/Traffic.i18n.tsx | 12 ++ src/traffic/modelVia.test.ts | 253 +++++++++++++++++++++++++++++++++ src/traffic/modelVia.ts | 194 +++++++++++++++++++++++++ src/traffic/useModelVia.ts | 17 +++ src/types.ts | 33 ++++- src/useRequests.ts | 20 +++ 11 files changed, 736 insertions(+), 14 deletions(-) create mode 100644 src/aliases/api.provisional.ts create mode 100644 src/traffic/modelVia.test.ts create mode 100644 src/traffic/modelVia.ts create mode 100644 src/traffic/useModelVia.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/api.provisional.ts b/src/aliases/api.provisional.ts new file mode 100644 index 00000000..b0b8ead9 --- /dev/null +++ b/src/aliases/api.provisional.ts @@ -0,0 +1,34 @@ +// 临时类型:core 的模型别名合并后由 src/generated/tw-api.ts 生成的同名类型替换。 +import type { Msg } from "@/generated/tw-api"; + +export type PinnedModel = { provider: string; model: string }; +export type AliasInput = { name: string; models: Array }; +export type AliasSave = { alias: AliasInput; base_version?: string }; +export type AliasModel = { model: string; providers: Array }; +export type AliasView = { + name: string; + models: Array; + served_by: Array; + shadows: Array; + context_window?: number; + requests_24h: number; + cost_micros_24h?: number; +}; +export type AliasSuggestion = { label: string; models: Array }; +export type AliasesView = { aliases: Array; suggestions: Array }; +export type AliasPreviewRequest = { alias: AliasInput; original?: string }; +export type AliasPreview = { + problems: Array; + served_by: Array; + unserved: Array; + shadows: Array; + same_model: Array; +}; +export type AliasRuleRef = { route: string; rule: string; field: string }; +export type AliasUsage = { requests_24h: number; keys: Array; rules: Array }; +export type AliasWritten = { version: string; renamed_in: AliasUsage }; +/** 规则的去向:上游或策略组的名称,或指定模型的列表 */ +export type RuleTarget = string | Array; +/** KnownModel / ModelRow 新加的字段 */ +export type KnownModelAliasFields = { alias?: Array; aliases: Array }; +export type ModelRowAliasFields = { aliases: Array }; 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/traffic/RequestTable.tsx b/src/traffic/RequestTable.tsx index 7b4c4243..a34331b7 100644 --- a/src/traffic/RequestTable.tsx +++ b/src/traffic/RequestTable.tsx @@ -18,8 +18,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 +91,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 +322,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 +393,7 @@ const Row = memo(function Row({ sameProvider, showClient, hints, + via, today, selected, fresh, @@ -401,6 +410,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 +518,7 @@ const Row = memo(function Row({ - + {/* 数字右对齐。左对齐时 253ms 和 1486ms 的个位对不齐, @@ -655,9 +666,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 +693,20 @@ function UpstreamCell({ r }: { r: RequestRow }) { )} + {/* 发出的模型名不是客户端写的那个(或者是规则指定的):模型那一列照旧写客户端的名称, + 这里说一声,发出的名称在悬停和详情的路由页里 */} + {mark && ( + + {mark.via === "alias" ? t.alias : t.pinned} + + )} {/* **看不见的安全功能会被用户关掉**,因为他们会怀疑是脱敏搞坏了功能。 所以脱敏发生了就要在列表这一层看得见,而不是藏在详情里 */} {r.secrets && r.secrets.items.length > 0 && ( diff --git a/src/traffic/Traffic.i18n.tsx b/src/traffic/Traffic.i18n.tsx index 56db6212..57b7c611 100644 --- a/src/traffic/Traffic.i18n.tsx +++ b/src/traffic/Traffic.i18n.tsx @@ -136,6 +136,12 @@ export const trafficText = messages( cancelled: "已取消", // 上游一列的徽标和它们的悬浮说明 + /** 客户端写的是别名,按这家上游换成了它的模型名。模型那一列照旧是客户端写的名称 */ + alias: "别名", + 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 +278,12 @@ export const trafficText = messages( failed: "Failed", cancelled: "Canceled", + alias: "Alias", + 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..051b57a0 --- /dev/null +++ b/src/traffic/modelVia.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it } from "vitest"; +import type { KnownModelAliasFields } from "@/aliases/api.provisional"; +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: Array> = [ + { 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"]]]); + // core 还没给这两个字段(现在的生成类型)、或者目录取不到:一个别名都没有 + expect(aliasTable([{ id: "gpt-5.5", providers: ["chatgpt"] }]).size).toBe(0); + 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..7ccae0e0 --- /dev/null +++ b/src/traffic/modelVia.ts @@ -0,0 +1,194 @@ +import type { KnownModelAliasFields, RuleTarget } from "@/aliases/api.provisional"; +import type { HistoryRow, KnownModel, PluginRunView, RequestRow } 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` 里的一项。别名那一项带着它的模型名列表(core 合入别名之后生成的类型里才有) */ +type ModelItem = KnownModel & Partial; + +/** 从 `GET /models` 里挑出别名:别名 → 它的模型名 */ +export function aliasTable(models: readonly KnownModel[] | undefined): Map { + const out = new Map(); + for (const m of (models ?? []) as readonly ModelItem[]) { + 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/useRequests.ts b/src/useRequests.ts index 8ecbb7f1..89ab749e 100644 --- a/src/useRequests.ts +++ b/src/useRequests.ts @@ -6,6 +6,7 @@ import { applyInFlight, CORE_STOPPED, interruptInFlight, + nonEmpty, type CoreEvent, type HistoryRow, type LocalEvent, @@ -63,6 +64,15 @@ export function mergeHistory(rows: Map, 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, From e469368d87a033d4a15058bc93a0eb2eb7edcf1d Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:56:38 +0800 Subject: [PATCH 03/18] Routing rules: forward to specified models, alias-aware model conditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The rule dialog's "Forward to" is now a choice between "Upstream or group" (today's select) and "Specified models": an ordered list of upstream + model rows (the model picked from that upstream's own list, with its context window), sent in order and as written, not through the alias table. With specified models, "Change model to" is hidden and not written to the config. - RuleDraft carries the target kind and the pinned list; draftFromView / draftToInput round-trip both shapes of `to`, and validation asks for at least one row with an upstream and a model. - The model condition's hint says that an alias matches only requests that use it, and that an upstream model name also matches the aliases that point to it (keeping the "matches N known models" count). - The route dialog's rule table shows pinned targets as "upstream · model" with backups on the second line, marks an alias condition, and names the aliases a model name also matches. The route list and the route map handle pinned targets. - The dry run shows the model each candidate is sent and where it came from (alias, rule rewrite, specified model). - Provisional types until core's model aliases land: the contract's src/aliases/api.provisional.ts, plus src/routing/provisional.ts for the widened RuleView/RuleInput/DryRunResult shapes. Co-Authored-By: Claude Opus 5.5 --- src/aliases/api.provisional.ts | 34 +++++++ src/routing/DryRunDialog.i18n.ts | 8 ++ src/routing/DryRunDialog.tsx | 56 ++++++---- src/routing/PinnedModels.i18n.ts | 26 +++++ src/routing/PinnedModels.tsx | 140 +++++++++++++++++++++++++ src/routing/RouteDialog.i18n.ts | 10 ++ src/routing/RouteDialog.tsx | 82 ++++++++++++--- src/routing/RoutingPage.tsx | 5 +- src/routing/RuleDialog.i18n.ts | 15 +++ src/routing/RuleDialog.tsx | 169 +++++++++++++++++++++---------- src/routing/api.ts | 11 +- src/routing/chain.test.ts | 30 ++++++ src/routing/chain.ts | 59 +++++++---- src/routing/model.i18n.ts | 10 ++ src/routing/model.test.ts | 88 ++++++++++++++++ src/routing/model.ts | 96 ++++++++++++++---- src/routing/provisional.ts | 45 ++++++++ src/routing/target.test.ts | 120 ++++++++++++++++++++++ src/routing/target.ts | 101 ++++++++++++++++++ 19 files changed, 973 insertions(+), 132 deletions(-) create mode 100644 src/aliases/api.provisional.ts create mode 100644 src/routing/PinnedModels.i18n.ts create mode 100644 src/routing/PinnedModels.tsx create mode 100644 src/routing/provisional.ts create mode 100644 src/routing/target.test.ts create mode 100644 src/routing/target.ts diff --git a/src/aliases/api.provisional.ts b/src/aliases/api.provisional.ts new file mode 100644 index 00000000..b0b8ead9 --- /dev/null +++ b/src/aliases/api.provisional.ts @@ -0,0 +1,34 @@ +// 临时类型:core 的模型别名合并后由 src/generated/tw-api.ts 生成的同名类型替换。 +import type { Msg } from "@/generated/tw-api"; + +export type PinnedModel = { provider: string; model: string }; +export type AliasInput = { name: string; models: Array }; +export type AliasSave = { alias: AliasInput; base_version?: string }; +export type AliasModel = { model: string; providers: Array }; +export type AliasView = { + name: string; + models: Array; + served_by: Array; + shadows: Array; + context_window?: number; + requests_24h: number; + cost_micros_24h?: number; +}; +export type AliasSuggestion = { label: string; models: Array }; +export type AliasesView = { aliases: Array; suggestions: Array }; +export type AliasPreviewRequest = { alias: AliasInput; original?: string }; +export type AliasPreview = { + problems: Array; + served_by: Array; + unserved: Array; + shadows: Array; + same_model: Array; +}; +export type AliasRuleRef = { route: string; rule: string; field: string }; +export type AliasUsage = { requests_24h: number; keys: Array; rules: Array }; +export type AliasWritten = { version: string; renamed_in: AliasUsage }; +/** 规则的去向:上游或策略组的名称,或指定模型的列表 */ +export type RuleTarget = string | Array; +/** KnownModel / ModelRow 新加的字段 */ +export type KnownModelAliasFields = { alias?: Array; aliases: Array }; +export type ModelRowAliasFields = { aliases: Array }; 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..f1ce831c 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, Overview, RuleTrace } from "@/types"; import { skipLabel } from "@/upstreams/labels"; import { FormItem } from "@/upstreams/parts"; import { api } from "./api"; @@ -47,13 +40,15 @@ import { dryRunText } from "./DryRunDialog.i18n"; import { ModelInput, onOpenFocus } from "./fields"; import { DIALECTS, usersOf } from "./model"; import { KeyIcon, TargetIcon } from "./parts"; +import type { DryRunResultX, KnownModelX, RouteInputX } from "./provisional"; import { routingText } from "./routing.i18n"; +import { candidatesOf, type CandidateView } from "./target"; /** 按什么求值:密钥使用的路由、指定的路由、路由对话框里还没保存的草稿 */ export type DryRunTarget = | { kind: "key" } | { kind: "route"; name: string } - | { kind: "draft"; route: RouteInput; keys: string[] }; + | { kind: "draft"; route: RouteInputX; keys: string[] }; /** * 试算。回答的不只是「会走到哪儿」,还有**「为什么没走我以为的那条」**: @@ -62,6 +57,9 @@ export type DryRunTarget = * 右边从上往下:结论(一句话 + 状态点)、这一趟经过的路(密钥 → 路由 → 规则 → * 去向,和页上的路由图同一套标志)、细节、逐条匹配明细。输入一变就重算,重算时 * 旧结果淡一档留着,不闪成空白。 + * + * 尝试顺序里每个上游写出发给它的模型名和来历(别名、规则改写、指定模型):客户端写的 + * 名称和发出的不同,正是要在这里看清的事。 */ export function DryRunDialog({ target, @@ -71,7 +69,7 @@ export function DryRunDialog({ }: { target: DryRunTarget; ov: Overview; - models: KnownModel[]; + models: KnownModelX[]; onClose: () => void; }) { const t = useText(dryRunText); @@ -101,7 +99,7 @@ export function DryRunDialog({ }); const [toolCount, setToolCount] = useState("5"); const [intent, setIntent] = useState(""); - const [r, setR] = useState(null); + const [r, setR] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -200,6 +198,7 @@ export function DryRunDialog({ + {/* 集成(L3):ModelInput 的别名标记 prop 落地后,在这里把 models 的别名信息传进去 */} m.id)} /> @@ -333,7 +332,7 @@ function ResultSkeleton() { } /** 结论的语气:转发成了是 ok,拒绝和无路可走是 error,规则没参与的是 idle */ -function verdictTone(r: DryRunResult): StatusTone { +function verdictTone(r: DryRunResultX): StatusTone { switch (r.outcome) { case "route": return "ok"; @@ -350,7 +349,7 @@ function Result({ client, draft, }: { - r: DryRunResult; + r: DryRunResultX; ov: Overview; client: string; draft: boolean; @@ -368,7 +367,8 @@ function Result({ * 的事,而一条规则都没走。留着它们只会让人以为它真的去了那儿。 */ const short = r.outcome === "intercepted"; - const target = r.outcome === "route" ? (r.via_group ?? r.candidates[0] ?? null) : null; + const candidates = candidatesOf(r); + const target = r.outcome === "route" ? (r.via_group ?? candidates[0]?.provider ?? null) : null; // 这一趟经过的路:密钥 → 路由 → 规则 → 去向。和路由图同一套标志 const steps: ReactNode[] = []; @@ -454,14 +454,17 @@ function Result({ <>
{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); return ( -
+ // 指定模型可以在同一个上游上列两个模型:上游名不唯一 +
{i + 1} {c} + {cv.sent_model && } {open && ( @@ -550,6 +553,19 @@ function Result({ ); } +/** 发给这个上游的模型名,和它从哪儿来 */ +function SentModel({ c }: { c: CandidateView }) { + const t = useText(dryRunText); + const via = c.model_via === "alias" || c.model_via === "rule" || c.model_via === "pinned" ? t.via[c.model_via] : null; + return ( + + {t.sends} + {c.sent_model} + {via && t.viaParen(via)} + + ); +} + /** 路上的一站:标志加名字 */ function Step({ children, strong, className }: { children: ReactNode; strong?: boolean; className?: string }) { return ( @@ -565,11 +581,15 @@ function Step({ children, strong, className }: { children: ReactNode; strong?: b ); } -function headline(r: DryRunResult): string { +function headline(r: DryRunResultX): string { const m = textOf(dryRunText); + const first = candidatesOf(r)[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..bce0f18f --- /dev/null +++ b/src/routing/PinnedModels.tsx @@ -0,0 +1,140 @@ +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 { 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"; +import type { KnownModelX } from "./provisional"; + +/** + * 「转发至 → 指定模型」:几行「上游 + 模型」,按顺序备用。 + * + * **模型从这个上游自己的清单里选**(`/providers/{name}/models`,和上游页的模型弹窗是同一份), + * 右边是它的上下文窗口;清单里没有的照样能写(中转刚上的新模型)。模型名原样发出,不经过 + * 别名表,所以候选里**不列别名**。 + */ +export function PinnedModels({ + value, + onChange, + providers, + known, +}: { + value: PinnedDraft[]; + onChange: (next: PinnedDraft[]) => void; + providers: readonly ProviderView[]; + known: readonly KnownModelX[]; +}) { + 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 KnownModelX[]; + 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} + + ))} + + {/* 集成(L3):ModelInput 加了别名/上游说明的 prop 之后,这里不用传 —— 指定模型只列真名 */} + + + {ctx ? contextWindow(ctx) : ""} + + {/* 写成字:对话框右上角的 × 是关闭 */} + +
+ ); +} diff --git a/src/routing/RouteDialog.i18n.ts b/src/routing/RouteDialog.i18n.ts index cb5b4797..f1419454 100644 --- a/src/routing/RouteDialog.i18n.ts +++ b/src/routing/RouteDialog.i18n.ts @@ -42,6 +42,10 @@ export const routeDialogText = messages( addCatchAll: "添加兜底规则", untitled: "新路由", conditionJoin: " 且 ", + alias: "别名", + alsoAliasesOf: (aliases: string[]) => `也匹配指向它的别名 ${aliases.join("、")}`, + alsoAliases: (aliases: string[]) => `也匹配指向这些模型的别名 ${aliases.join("、")}`, + backups: (list: string[]) => `备用 ${list.join("、")}`, }, { catchAllName: "Catch-all", @@ -87,5 +91,11 @@ export const routeDialogText = messages( addCatchAll: "Add catch-all rule", untitled: "Untitled", conditionJoin: " and ", + alias: "Alias", + 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..d749c747 100644 --- a/src/routing/RouteDialog.tsx +++ b/src/routing/RouteDialog.tsx @@ -1,4 +1,4 @@ -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 { Badge } from "@/ui/badge"; import { Banner } from "@/ui/banner"; @@ -19,11 +19,11 @@ 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"; -import type { KnownModel, Overview, RouteInput } from "@/types"; +import type { Overview } from "@/types"; import { FormItem } from "@/upstreams/parts"; import { api } from "./api"; import { ToggleChips, onOpenFocus } from "./fields"; @@ -44,9 +44,11 @@ import { type RuleDraft, } from "./model"; import { KeyChips, KeyIcon, TargetIcon } from "./parts"; +import type { KnownModelX, RouteInputX } from "./provisional"; 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"; @@ -84,7 +86,7 @@ export function RouteDialog({ }: { mode: RouteDialogMode; ov: Overview; - models: KnownModel[]; + models: KnownModelX[]; /** 最近一段时间的命中数(`useRouteHits`) */ hits: RouteHitsWindow; /** 概览里的配置版本。**只取打开那一刻的**(见 `base`) */ @@ -94,7 +96,7 @@ export function RouteDialog({ /** 保存成功,带着保存后的名字 */ onSaved: (name: string) => void; /** 按对话框里还没保存的内容试算 */ - onDryRun: (draft: RouteInput, keys: string[]) => void; + onDryRun: (draft: RouteInputX, keys: string[]) => void; }) { const t = useText(routeDialogText); const rt = useText(routingText); @@ -157,7 +159,7 @@ export function RouteDialog({ const leaving = mode.kind === "edit" && !isDefault ? original.filter((k) => !keys.includes(k)) : []; - function input(): RouteInput { + function input(): RouteInputX { return { name: trimmed, rules: rules.map(draftToInput) }; } @@ -365,15 +367,15 @@ export function RouteDialog({ )} - + {r.conditions.length === 0 ? ( {rt.allRequests} ) : ( - conditionsText(r) + )} - + @@ -477,14 +479,49 @@ 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 KnownModelX[] }) { + 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)} + {c === model && alias && ( + + {t.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 KnownModelX[] }) { const rt = useText(routingText); - const addOns = addOnsText(r); + const t = useText(routeDialogText); + const addOns = addOnsText(r, known); if (r.action === "deny") { return (
@@ -504,6 +541,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/RoutingPage.tsx b/src/routing/RoutingPage.tsx index ed006910..e119220f 100644 --- a/src/routing/RoutingPage.tsx +++ b/src/routing/RoutingPage.tsx @@ -15,7 +15,8 @@ import { useText } from "@/i18n"; import type { ConfigFocus } from "@/configLocate"; import { targetLabel } from "@/labels"; import { useNav, useNavParams } from "@/nav"; -import type { GroupView, Overview, RouteInput } from "@/types"; +import type { GroupView, Overview } from "@/types"; +import type { RouteInputX } from "./provisional"; import { DeleteDialog } from "@/upstreams/DeleteDialog"; import { api } from "./api"; import type { ChainFocus } from "./chain"; @@ -347,7 +348,7 @@ export default function RoutingPage({ onChanged={onChanged} onClose={() => setDialog(null)} onSaved={(name) => done({ kind: "route", name })} - onDryRun={(route: RouteInput, keys: string[]) => setDryRun({ kind: "draft", route, keys })} + onDryRun={(route: RouteInputX, keys: string[]) => setDryRun({ kind: "draft", route, keys })} /> )} {dialog?.kind === "set-default" && ( 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..72efa677 100644 --- a/src/routing/RuleDialog.tsx +++ b/src/routing/RuleDialog.tsx @@ -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 type { ConditionField, ConditionView, Overview } from "@/types"; import { Segmented } from "@/ui/segmented"; import { FormItem, Note } from "@/upstreams/parts"; import { GroupDialog } from "./GroupDialog"; @@ -36,18 +35,24 @@ 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 type { KnownModelX } from "./provisional"; import { routingText } from "./routing.i18n"; import { ruleDialogText } from "./RuleDialog.i18n"; +import { modelHint, type ModelHint } from "./target"; /** 目标下拉里「新建策略组…」那一项的值。只活在这个下拉里,不会写进配置 */ const NEW_GROUP = "::new-group"; @@ -58,6 +63,9 @@ const NEW_GROUP = "::new-group"; * **按读规则的顺序排**:条件(什么样的请求)→ 命中后(转发、拒绝,或继续 * 匹配)→ 附加项(改写参数、安全要求)。保存只更新路由对话框里的草稿, * 路由对话框保存时才写入配置。 + * + * 转发有两种去向:上游或策略组(发出的模型名按别名表对到各上游),或者指定模型(几个 + * 「上游 + 模型」按顺序备用,模型名原样发出)。指定模型时「模型改为」不出现。 */ export function RuleDialog({ initial, @@ -80,7 +88,7 @@ export function RuleDialog({ /** 同一条路由里其余规则的名字 */ takenNames: string[]; ov: Overview; - models: KnownModel[]; + models: KnownModelX[]; configVersion: string; /** 在这里新建了策略组:外面要重读概览,路由对话框接着用写完的版本 `version` */ onChanged: (version: string) => void; @@ -99,6 +107,7 @@ export function RuleDialog({ const modelIds = useMemo(() => models.map((m) => m.id), [models]); const phaseTwo = isPhaseTwo(d); + const pinned = isPinned(d); const problem = ruleProblem(d, takenNames); /** @@ -112,7 +121,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 +161,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 +181,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 +251,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 +274,27 @@ export function RuleDialog({ open={rewriteOpen} onToggle={() => setRewriteOpen((o) => !o)} > -
- - set({ model: v })} - models={modelIds} - placeholder={t.unchanged} - /> - +
+ {/* 指定模型:模型名原样发出,「模型改为」不起作用 */} + {!pinned && ( + + {/* 集成(L3):ModelInput 的别名标记 prop 落地后,在这里把 models 的别名信息传进去 */} + set({ model: v })} + models={modelIds} + placeholder={t.unchanged} + /> + + )}
- {d.model.trim() && {t.modelChangeNote}} + {!pinned && d.model.trim() && {t.modelChangeNote}}
)} @@ -387,7 +431,7 @@ function ConditionRow({ }: { c: ConditionView; ov: Overview; - models: string[]; + models: KnownModelX[]; onChange: (c: ConditionView) => void; onRemove: () => void; }) { @@ -401,17 +445,16 @@ function ConditionRow({ switch (f.kind) { case "glob": { control = ( + // 集成(L3):ModelInput 的别名标记 prop 落地后,在这里把 models 的别名信息传进去 onChange({ ...c, values: [v] })} - models={models} + models={models.map((m) => m.id)} 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 +564,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..594c3553 100644 --- a/src/routing/api.ts +++ b/src/routing/api.ts @@ -4,11 +4,12 @@ */ import { call } from "@/control"; import type { DryRunRequest, GroupSave, RouteSave } from "@/types"; +import type { DryRunRequestX, DryRunResultX, RouteSaveX } from "./provisional"; - +// 规则的 `to` 可以是指定模型的列表:生成的类型换上之前,按 `provisional.ts` 里的写法交出去 export const api = { - createRoute: (save: RouteSave) => call("CreateRoute", save), - updateRoute: (name: string, save: RouteSave) => call("UpdateRoute", save, name), + createRoute: (save: RouteSaveX) => call("CreateRoute", save as RouteSave), + updateRoute: (name: string, save: RouteSaveX) => call("UpdateRoute", save as RouteSave, name), /** `reassignTo` 为空:使用它的密钥改用默认路由 */ deleteRoute: (name: string, baseVersion: string, reassignTo: string | null) => call("DeleteRoute", { base_version: baseVersion, reassign_to: reassignTo }, name), @@ -19,7 +20,9 @@ export const api = { deleteGroup: (name: string, baseVersion: string) => call("DeleteGroup", { base_version: baseVersion }, name), knownModels: () => call("KnownModels", null), - dryRun: (req: DryRunRequest) => call("DryRun", req), + /** 一个上游的模型清单(指定模型时从这里选,带上下文窗口) */ + providerModels: (name: string) => call("ProviderModels", null, name), + dryRun: (req: DryRunRequestX): Promise => call("DryRun", req as DryRunRequest), /** 从 `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..91d70fec 100644 --- a/src/routing/chain.ts +++ b/src/routing/chain.ts @@ -10,8 +10,12 @@ * **只画决定去向的规则**:被兜底挡住的(`shadowed`)、选定上游之后才判断的 * (`phase_two`)、只附加改写的(继续匹配)不改变请求去哪儿,画出来反而像 * 多了几条路。和路由列表「规则」一栏同一个口径(`flowOf`)。 + * + * 转发到指定模型的规则连到列表里的每一个上游(按顺序备用,和策略组连到它的成员一样)。 */ -import type { ClientView, GroupView, Overview, RouteHits, RouteView, RuleView } from "@/types"; +import type { ClientView, GroupView, Overview, RouteHits } from "@/types"; +import type { RouteViewX, RuleViewX } from "./provisional"; +import { pinnedOf, targetNameOf } from "./target"; export type ChainKind = "key" | "route" | "group" | "deny" | "via" | "upstream"; @@ -83,38 +87,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: RuleViewX, 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[] { +export function targetsOf(r: RouteViewX, 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 RouteViewX[] }, +): 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 +169,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] : []; }), ), @@ -174,7 +189,7 @@ export function buildChain(ov: Pick { + const tailsOf = (r: RouteViewX): { ids: string[]; live: boolean }[] => { const out: { ids: string[]; live: boolean }[] = []; for (const t of targets.get(r.name) ?? []) { if (t.kind === "deny") out.push({ ids: stationsOf(t), live: true }); 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..29d8466c 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, @@ -17,6 +20,7 @@ import { usersOf, type RuleDraft, } from "./model"; +import type { RouteViewX, RuleViewX } from "./provisional"; function view(p: Partial & { name: string }): RuleView { return { conditions: [], catch_all: false, phase_two: false, shadowed: false, ...p }; @@ -66,6 +70,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: RuleViewX = { + 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 +226,28 @@ describe("路由列表", () => { expect(routeProblems(route({ rules: [view({ name: "兜底", to: "x", catch_all: true })] }))).toEqual([]); }); + it("规则一栏里指定模型写成「上游 · 模型」,备用的只说个数", () => { + const r: RouteViewX = { + ...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..b53b3fb5 100644 --- a/src/routing/model.ts +++ b/src/routing/model.ts @@ -14,14 +14,15 @@ import type { GroupView, ProviderView, RouteView, - RuleInput, - RuleView, } from "@/types"; +import type { PinnedModel } from "@/aliases/api.provisional"; import { textOf } from "@/i18n"; import { ALL_UPSTREAMS, conditionName, groupKindLabel, targetLabel } from "@/labels"; import { protocolLabel } from "@/upstreams/labels"; import { modelText } from "./model.i18n"; +import type { KnownModelX, RouteViewX, RuleInputX, RuleViewX } from "./provisional"; import { routingText } from "./routing.i18n"; +import { aliasNamed, hasTarget, pinnedOf, pinnedText, targetNameOf } from "./target"; // ---------------------------------------------------------------- 条件 @@ -121,6 +122,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 +141,11 @@ export interface RuleDraft { name: string; conditions: ConditionView[]; action: Action; + toKind: ToKind; + /** 上游或策略组的名称 */ to: string; + /** 指定模型,按顺序备用 */ + pinned: PinnedDraft[]; deny: string; model: string; maxTokens: string; @@ -152,7 +165,9 @@ export function blankRule(to = ALL_UPSTREAMS): RuleDraft { name: "", conditions: [], action: "forward", + toKind: "target", to, + pinned: [], deny: "", model: "", maxTokens: "", @@ -160,14 +175,21 @@ export function blankRule(to = ALL_UPSTREAMS): RuleDraft { }; } -export function draftFromView(r: RuleView): RuleDraft { +export function blankPinned(provider = "", model = ""): PinnedDraft { + return { key: nextKey(), provider, model }; +} + +export function draftFromView(r: RuleViewX): 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 +203,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"; } /** 在选定上游之后才判断:条件里有「选定上游」 */ @@ -195,7 +228,7 @@ export function isPhaseTwo(d: RuleDraft): boolean { } /** 草稿 → 交给 core 的规则 */ -export function draftToInput(d: RuleDraft): RuleInput { +export function draftToInput(d: RuleDraft): RuleInputX { const conditions = d.conditions.map((c) => { const values = c.values.map((v) => v.trim()).filter(Boolean); if (condField(c.field).kind === "compare") { @@ -207,14 +240,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 +271,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 +365,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 }[] { +export function flowOf(route: RouteViewX): { 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 +425,15 @@ export function membersText(g: Pick & { to?: RuleTarget | null }; +export type RuleInputX = Omit & { to?: RuleTarget | null }; +export type RouteViewX = Omit & { rules: Array }; +export type RouteInputX = Omit & { rules: Array }; +export type RouteSaveX = Omit & { route: RouteInputX }; +export type DryRunRequestX = Omit & { draft?: RouteInputX | null }; + +/** `/models` 的一项。`alias` 有值时这一项本身是别名,值是它的模型列表;`aliases` 是指向这个真名的别名 */ +export type KnownModelX = KnownModel & Partial; + +/** 发出的模型名从哪儿来:别名表、规则改写(`set.model`)、指定模型 */ +export type ModelVia = "alias" | "rule" | "pinned"; + +/** 试算里一个候选发出的模型。和客户端写的名称相同时 core 不给 `sent_model` */ +export type CandidateModel = { sent_model?: string | null; model_via?: string | null }; + +/** + * 试算结果。约定里写的是「每个候选加 `sent_model`、`model_via`」,而现在的 `candidates` 是 + * 上游名的列表:这里按**和 `candidates` 一一对应的 `candidate_models`** 读;core 若改成 + * 候选本身带这两个字段,`candidatesOf`(`target.ts`)两种都认。集成时按生成的类型定下一种。 + */ +export type DryRunResultX = DryRunResult & { candidate_models?: Array | null }; diff --git a/src/routing/target.test.ts b/src/routing/target.test.ts new file mode 100644 index 00000000..77064bd7 --- /dev/null +++ b/src/routing/target.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import type { KnownModelX } from "./provisional"; +import { aliasNamed, candidatesOf, hasTarget, inheritedAliases, modelHint, pinnedOf, targetNameOf } from "./target"; + +// `/models` 的目录:别名项带 `alias`(它的模型列表),真名带 `aliases`(指向它的别名) +const real = (id: string, aliases: string[] = [], providers = ["p"]): KnownModelX => ({ id, providers, aliases }); +const alias = (id: string, models: string[], providers = ["p"]): KnownModelX => ({ id, providers, alias: models, aliases: [] }); + +const KNOWN: KnownModelX[] = [ + 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("试算的候选", () => { + const base = { + route: "default", + outcome: "route" as const, + rule: "x", + reason: null, + via_group: null, + set: [], + trace: [], + circuit_open: [], + skipped: [], + converted: [], + }; + + it("和候选一一对应的发出模型", () => { + const r = { + ...base, + candidates: ["openrouter", "anthropic"], + candidate_models: [{ sent_model: "anthropic/claude-opus-5", model_via: "alias" }, null], + }; + expect(candidatesOf(r)).toEqual([ + { provider: "openrouter", sent_model: "anthropic/claude-opus-5", model_via: "alias" }, + { provider: "anthropic" }, + ]); + expect(candidatesOf({ ...base, candidates: ["a"] })).toEqual([{ provider: "a" }]); + }); + + it("候选本身带着发出模型的写法也认", () => { + const c = { provider: "bedrock", sent_model: "us.anthropic.claude-opus-5-v1:0", model_via: "pinned" }; + expect(candidatesOf({ ...base, candidates: [c] as unknown as string[] })).toEqual([c]); + }); +}); diff --git a/src/routing/target.ts b/src/routing/target.ts new file mode 100644 index 00000000..4f11575d --- /dev/null +++ b/src/routing/target.ts @@ -0,0 +1,101 @@ +/** + * 规则的去向有两种:上游或策略组的名称,或者指定模型的列表(上游 + 模型,按顺序备用)。 + * 读去向的地方都经过这里,不各自去猜 `to` 是哪一种。**纯函数**,路由图也用。 + */ +import type { PinnedModel, RuleTarget } from "@/aliases/api.provisional"; +import type { CandidateModel, DryRunResultX, KnownModelX } from "./provisional"; +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 KnownModelX[]): KnownModelX | 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 KnownModelX[]): 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 KnownModelX[]): 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("*"), + }; +} + +// ---------------------------------------------------------------- 试算 + +export interface CandidateView extends CandidateModel { + provider: string; +} + +/** + * 试算的候选,带上每一个发出的模型。两种写法都认:和 `candidates` 一一对应的 + * `candidate_models`,或者候选本身就是 `{ provider, sent_model, model_via }`(见 `provisional.ts`) + */ +export function candidatesOf(r: DryRunResultX): CandidateView[] { + return (r.candidates as Array).map((c, i) => + typeof c === "string" ? { provider: c, ...(r.candidate_models?.[i] ?? {}) } : c, + ); +} From 54a276d3e19f9e220d523ad48fd8e3f91105f79f Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:00:22 +0800 Subject: [PATCH 04/18] Routing dialogs pass model objects to ModelInput, so suggestions show aliases and providers Co-Authored-By: Claude Opus 5.5 --- src/routing/DryRunDialog.tsx | 3 +-- src/routing/PinnedModels.tsx | 1 - src/routing/RuleDialog.tsx | 9 +++------ 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/routing/DryRunDialog.tsx b/src/routing/DryRunDialog.tsx index f1ce831c..c1604b3d 100644 --- a/src/routing/DryRunDialog.tsx +++ b/src/routing/DryRunDialog.tsx @@ -198,8 +198,7 @@ export function DryRunDialog({ - {/* 集成(L3):ModelInput 的别名标记 prop 落地后,在这里把 models 的别名信息传进去 */} - m.id)} /> + ))} - {/* 集成(L3):ModelInput 加了别名/上游说明的 prop 之后,这里不用传 —— 指定模型只列真名 */} models.map((m) => m.id), [models]); const phaseTwo = isPhaseTwo(d); const pinned = isPinned(d); const problem = ruleProblem(d, takenNames); @@ -285,12 +284,11 @@ export function RuleDialog({ {/* 指定模型:模型名原样发出,「模型改为」不起作用 */} {!pinned && ( - {/* 集成(L3):ModelInput 的别名标记 prop 落地后,在这里把 models 的别名信息传进去 */} set({ model: v })} - models={modelIds} + models={models} placeholder={t.unchanged} /> @@ -445,11 +443,10 @@ function ConditionRow({ switch (f.kind) { case "glob": { control = ( - // 集成(L3):ModelInput 的别名标记 prop 落地后,在这里把 models 的别名信息传进去 onChange({ ...c, values: [v] })} - models={models.map((m) => m.id)} + models={models} placeholder={t.globPlaceholder} /> ); From ae51af9894db838ba8db2b1a1553a579c5167100 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:06:17 +0800 Subject: [PATCH 05/18] Model aliases: the new/edit alias dialog, name hints about local clients, and aliases in the upstream models popover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/aliases/AliasDialog.tsx: name (red = problems from core that block saving, yellow = warnings), upstream models as a list input with the upstreams offering each name and unserved names flagged, the same model on other upstreams as checkboxes, and a read-only preview of the name each upstream receives (including upstreams that no longer receive the name). Previews through POST /aliases/preview as the draft changes; a name equal to a real model is listed first when creating; renames report which keys and rules changed. - src-tauri/src/aliases.rs: `alias_hints` command. Hint codes with params (no sentences) for clients detected on this machine: Claude Code's own tier names, a name that looks like one vendor family listing another family's models, and whether a taken-over Claude Desktop shows the name; plus the taken-over clients whose model lists will be offered an update. tw-adopt's `cloud::is_alias` is now pub. - Upstream models popover: an "alias x" mark on rows listed in an alias, and an "Add alias…" button on hover that opens the dialog with that model listed. - The /aliases endpoints are called through `invoke("call")` with the provisional types until core ships them and the allowlists can name them. Co-Authored-By: Claude Opus 5.5 --- src-tauri/crates/tw-adopt/src/cloud.rs | 5 +- src-tauri/src/aliases.rs | 505 ++++++++++++++++++ src-tauri/src/lib.rs | 3 + src-tauri/src/wire.rs | 26 + src-tauri/tests/ts_bindings.rs | 1 + src/aliases/AliasDialog.i18n.ts | 131 +++++ src/aliases/AliasDialog.tsx | 708 +++++++++++++++++++++++++ src/aliases/api.provisional.ts | 34 ++ src/aliases/dialogApi.ts | 40 ++ src/aliases/draft.test.ts | 75 +++ src/aliases/draft.ts | 71 +++ src/generated/lite-api.ts | 6 + src/upstreams/ModelsPanel.i18n.ts | 6 + src/upstreams/ModelsPanel.tsx | 69 ++- src/upstreams/UpstreamTable.tsx | 14 +- 15 files changed, 1680 insertions(+), 14 deletions(-) create mode 100644 src-tauri/src/aliases.rs create mode 100644 src/aliases/AliasDialog.i18n.ts create mode 100644 src/aliases/AliasDialog.tsx create mode 100644 src/aliases/api.provisional.ts create mode 100644 src/aliases/dialogApi.ts create mode 100644 src/aliases/draft.test.ts create mode 100644 src/aliases/draft.ts 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/src/aliases.rs b/src-tauri/src/aliases.rs new file mode 100644 index 00000000..b445e884 --- /dev/null +++ b/src-tauri/src/aliases.rs @@ -0,0 +1,505 @@ +//! 新建、编辑模型别名时,名称下面那几条和客户端有关的提示(`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 = readers(family) + .iter() + .filter_map(|id| seen.installed.iter().find(|c| c.id == *id)) + .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)) +} + +/// 按模型名决定请求参数的客户端(id),按家:名字像这一家,它就照这一家的参数发。 +/// +/// - Claude Code:上下文长度、思考、Claude 专有的请求头和字段都看模型名; +/// - Codex:按模型名的前缀(`gpt-5`、`o3`、`codex-`)挑工具的写法、推理参数和上下文长度; +/// - Qwen Code:按 `qwen` 系列的名字定上下文长度; +/// - opencode:按模型名里的 `claude`、`gpt`、`gemini`、`qwen`、`glm`、`kimi`、`minimax` 定 +/// 温度、top_p 和各家专有的选项。 +fn readers(family: &str) -> &'static [&'static str] { + match family { + "Claude" => &["claude-code", "opencode"], + "GPT" => &["codex", "opencode"], + "Qwen" => &["qwen-code", "opencode"], + "Gemini" | "GLM" | "Kimi" | "MiniMax" => &["opencode"], + _ => &[], + } +} + +#[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}"); + } + } + + #[test] + fn a_claude_name_for_another_model_names_the_detected_clients() { + let all = seen(&["claude-code", "opencode", "codex"], &[]); + assert_eq!( + hints("claude-sonnet-5", &models(&["glm-4.6"]), &all), + vec![AliasHint::FamilyMismatch { + family: "Claude".into(), + model: "glm-4.6".into(), + clients: vec!["Claude Code".into(), "opencode".into()], + }] + ); + // 只装了 Claude Code:只说它 + assert_eq!( + hints( + "claude-sonnet-5", + &models(&["glm-4.6"]), + &seen(&["claude-code"], &[]) + ), + vec![AliasHint::FamilyMismatch { + family: "Claude".into(), + model: "glm-4.6".into(), + clients: vec!["Claude Code".into()], + }] + ); + // 一个看模型名的客户端都没装:不说 + assert!( + hints( + "claude-sonnet-5", + &models(&["glm-4.6"]), + &seen(&["zed"], &[]) + ) + .is_empty() + ); + // GPT 的名字给 DeepSeek:Codex 和 opencode + assert_eq!( + hints("gpt-5", &models(&["deepseek-chat"]), &all), + vec![AliasHint::FamilyMismatch { + family: "GPT".into(), + model: "deepseek-chat".into(), + clients: vec!["Codex".into(), "opencode".into()], + }] + ); + } + + #[test] + fn the_same_family_or_an_unknown_model_is_not_a_mismatch() { + let all = seen(&["claude-code", "opencode", "codex", "qwen-code"], &[]); + // 几家上游的同一个 Claude + assert!( + hints( + "claude-sonnet-5", + &models(&[ + "claude-sonnet-5", + "us.anthropic.claude-sonnet-5-v1:0", + "anthropic/claude-sonnet-5" + ]), + &all + ) + .is_empty() + ); + // 列出的里有一个是这一家的就不说,哪怕还列了别家的 + assert!( + hints( + "claude-sonnet-5", + &models(&["glm-4.6", "claude-sonnet-5"]), + &all + ) + .is_empty() + ); + // 名字认不出、模型认不出:不说 + assert!(hints("my-coder", &models(&["glm-4.6"]), &all).is_empty()); + assert!(hints("claude-sonnet-5", &models(&["local-model"]), &all).is_empty()); + assert!(hints("deepseek-v4.1", &models(&["DeepSeek-v4.1-flash"]), &all).is_empty()); + // 还没列模型 + assert!(hints("claude-sonnet-5", &[], &all).is_empty()); + // 第一个认得出的别家模型 + assert_eq!( + hints( + "qwen-max", + &models(&["local-model", "glm-4.6", "kimi-k2"]), + &all + ), + vec![AliasHint::FamilyMismatch { + family: "Qwen".into(), + model: "glm-4.6".into(), + clients: vec!["Qwen Code".into(), "opencode".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() + ); + // 档位名指向别家的模型:两条都说 + assert_eq!( + hints("opus", &models(&["glm-4.6"]), &seen(&["claude-code"], &[])), + vec![ + AliasHint::ClaudeCodeReserved { + name: "opus".into() + }, + AliasHint::FamilyMismatch { + family: "Claude".into(), + model: "glm-4.6".into(), + clients: vec!["Claude Code".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!["Claude Code".into()], + }) + .unwrap(); + assert_eq!( + v, + serde_json::json!({ + "code": "family_mismatch", + "family": "Claude", + "model": "glm-4.6", + "clients": ["Claude Code"], + }) + ); + } + + #[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/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/wire.rs b/src-tauri/src/wire.rs index 376dd4b0..3dd8c55e 100644 --- a/src-tauri/src/wire.rs +++ b/src-tauri/src/wire.rs @@ -803,3 +803,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`:检测到的、按模型名决定请求参数的客户端(产品名) + 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/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/aliases/AliasDialog.i18n.ts b/src/aliases/AliasDialog.i18n.ts new file mode 100644 index 00000000..1199cdc2 --- /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 with ${family} context lengths and parameters.`, + 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..8a549fb4 --- /dev/null +++ b/src/aliases/AliasDialog.tsx @@ -0,0 +1,708 @@ +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, KnownModel } from "@/types"; +import { Boxed, DialogError, FormItem } from "@/upstreams/parts"; +import { aliasDialogText } from "./AliasDialog.i18n"; +import type { + AliasInput, + AliasPreview, + AliasSave, + AliasUsage, + KnownModelAliasFields, + PinnedModel, +} from "./api.provisional"; +import { dialogApi } from "./dialogApi"; +import { ONLY_ITSELF, draftOf, onlyAuto, shownProblems, withModel, withName, withoutModel, type Draft } from "./draft"; + +/** `GET /models` 的一项,带上别名的两个新字段(core 发版前是临时的) */ +type Known = KnownModel & Partial; + +/** 输入停下来这么久再问 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 ?? []) as Known[], [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(() => { + dialogApi + .configVersion() + .then(setBase) + .catch(() => {}); + }, []); + + // 编辑:按原名读出列着的模型 + useEffect(() => { + if (editing == null || initial?.models !== undefined) return; + let live = true; + dialogApi + .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; + dialogApi + .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(); + dialogApi + .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; + dialogApi + .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 dialogApi.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 dialogApi.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: Known[]; + 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: Known[]; + 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/api.provisional.ts b/src/aliases/api.provisional.ts new file mode 100644 index 00000000..b0b8ead9 --- /dev/null +++ b/src/aliases/api.provisional.ts @@ -0,0 +1,34 @@ +// 临时类型:core 的模型别名合并后由 src/generated/tw-api.ts 生成的同名类型替换。 +import type { Msg } from "@/generated/tw-api"; + +export type PinnedModel = { provider: string; model: string }; +export type AliasInput = { name: string; models: Array }; +export type AliasSave = { alias: AliasInput; base_version?: string }; +export type AliasModel = { model: string; providers: Array }; +export type AliasView = { + name: string; + models: Array; + served_by: Array; + shadows: Array; + context_window?: number; + requests_24h: number; + cost_micros_24h?: number; +}; +export type AliasSuggestion = { label: string; models: Array }; +export type AliasesView = { aliases: Array; suggestions: Array }; +export type AliasPreviewRequest = { alias: AliasInput; original?: string }; +export type AliasPreview = { + problems: Array; + served_by: Array; + unserved: Array; + shadows: Array; + same_model: Array; +}; +export type AliasRuleRef = { route: string; rule: string; field: string }; +export type AliasUsage = { requests_24h: number; keys: Array; rules: Array }; +export type AliasWritten = { version: string; renamed_in: AliasUsage }; +/** 规则的去向:上游或策略组的名称,或指定模型的列表 */ +export type RuleTarget = string | Array; +/** KnownModel / ModelRow 新加的字段 */ +export type KnownModelAliasFields = { alias?: Array; aliases: Array }; +export type ModelRowAliasFields = { aliases: Array }; diff --git a/src/aliases/dialogApi.ts b/src/aliases/dialogApi.ts new file mode 100644 index 00000000..4eba4103 --- /dev/null +++ b/src/aliases/dialogApi.ts @@ -0,0 +1,40 @@ +/** + * 新建、编辑别名的对话框用到的调用。 + * + * 控制面那几个(ThinkWatch-Core#283 的 `/aliases`)**还不在白名单里**:core 发版、 + * `src/generated/tw-api.ts` 重新生成之后,端点名加进 `src/control.ts` 的 `WEBVIEW_ENDPOINTS` + * 和 `src-tauri/src/call.rs` 的 `ALLOWED`,`pending` 换成 `call`,并进 `./api.ts`。 + * + * 名称提示(`alias_hints`)是 Rust 这边的命令:说的是这台机器上的客户端。 + */ +import { invoke } from "@tauri-apps/api/core"; +import { call } from "@/control"; +import type { AliasHint, ConfigWritten } from "@/types"; +import type { + AliasPreview, + AliasPreviewRequest, + AliasSave, + AliasUsage, + AliasWritten, + AliasesView, +} from "./api.provisional"; + +/** 端点进白名单之前的调用:和 `call` 走同一个命令,只是类型手写 */ +function pending(endpoint: string, req: unknown, ...params: string[]): Promise { + return invoke("call", { endpoint, params, req }); +} + +export const dialogApi = { + /** 编辑时读出这个别名现在列着的模型 */ + aliases: () => pending("Aliases", null), + createAlias: (save: AliasSave) => pending("CreateAlias", save), + /** 可以改名:密钥范围和规则里写着旧名的地方一起改,改了哪些在 `renamed_in` 里 */ + updateAlias: (name: string, save: AliasSave) => pending("UpdateAlias", save, name), + previewAlias: (req: AliasPreviewRequest) => pending("PreviewAlias", req), + /** 在用这个名称的地方。改名前列出会一起改的引用 */ + aliasUsage: (name: string) => pending("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/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/generated/lite-api.ts b/src/generated/lite-api.ts index 60a837c5..e9d6b118 100644 --- a/src/generated/lite-api.ts +++ b/src/generated/lite-api.ts @@ -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 上游,要填的那几项。 * 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..4c203624 100644 --- a/src/upstreams/ModelsPanel.tsx +++ b/src/upstreams/ModelsPanel.tsx @@ -2,6 +2,7 @@ import { type ReactNode, useMemo, useState } from "react"; import { ChevronRightIcon, CircleAlertIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { useResource } from "@/lib/resource"; +import { Badge } from "@/ui/badge"; import { Button } from "@/ui/button"; import { InputGroup, InputGroupAddon, InputGroupInput } from "@/ui/input-group"; import { Skeleton } from "@/ui/skeleton"; @@ -10,6 +11,7 @@ import { StatusLabel } from "@/ui/status-dot"; import { textOf, useText } from "@/i18n"; import { commonText } from "@/i18n/common.i18n"; import type { ModelRow, ProviderModelsView, ProviderView } from "@/types"; +import type { ModelRowAliasFields } from "@/aliases/api.provisional"; import { api } from "./api"; import { contextWindow, coreText, errorText, perMillion } from "./labels"; import { modelsPanelText } from "./ModelsPanel.i18n"; @@ -27,17 +29,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 +174,7 @@ export function ModelsPanel({ )}
{shownOn.map((m) => ( - + ))} {off.length > 0 && ( <> @@ -186,7 +194,7 @@ export function ModelsPanel({ {t.notEnabled(off.length)} {(showOff || q !== "") && - shownOff.map((m) => )} + shownOff.map((m) => )} )} {q !== "" && shownOn.length + shownOff.length === 0 && ( @@ -218,30 +226,69 @@ 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 & Partial; + perToken: boolean; + onAlias?: (model: string) => void; +}) { const t = useText(modelsPanelText); const price = perToken && m.price ? `$${perMillion(m.price.input)} / $${perMillion(m.price.output)}${m.estimated ? t.estimated : ""}` : null; + const aliases = m.aliases ?? []; return (
- + {m.id} - {price && ( - - {price} + {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] : [] }} + /> ); } From eaca4d3f6fa3e187fedf5e6bc1d1495fa0e76ac9 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:07:15 +0800 Subject: [PATCH 06/18] Claude Desktop takeover: choose its model and pin it with a rule on its key When the gateway lists no model Claude Desktop accepts, the takeover dialog now asks which upstream model Claude Desktop should use instead of telling the user to hand-write a rewrite rule. Connecting writes claude-sonnet-5 into its configuration as before and adds a pinned-model rule on Claude Desktop's own key, first in the route that key uses: { name: Claude Desktop, when: { client: }, to: [{ provider, model }, ...] } # every upstream offering the model Other clients are unaffected and no global alias is created. The rule is written after the plan is checked and before the files, and is put back if the files cannot be written. Restoring (one client, restore all, uninstall) deletes it afterwards; when it cannot be deleted, the restore still succeeds and says so. Connecting again changes the rule in place, and deletes it once the gateway offers Claude models. A rule is ours only while it keeps its name, its single key condition and a pinned target with no deny or rewrite; rules the user changed are left alone, and a same-named rule of the user's in that route stops the takeover. - tw-adopt: Plan.pick_model (desktop::model_pick) replaces the adopt.plan.claude_desktop.no_claude_model note - clients::desktop_rule: the rule's view, change, write, revert and removal, against the model-alias contract's JSON (to: string or [{provider, model}]) with provisional types until tw-api has them; tested against a fake control plane - PlanView.desktop_rule; adopt_client takes the chosen model; new codes adopt.plan.gateway_changed, adopt.plan.claude_desktop.rule_name_taken, adopt.claude_desktop.rule_left / rule_unchecked / rule_not_reverted - PlanDialog: the model choice and a "Gateway configuration" summary - src/aliases/api.provisional.ts from the model-alias contract Co-Authored-By: Claude Opus 5.5 --- src-tauri/crates/tw-adopt/src/desktop.rs | 89 +- src-tauri/crates/tw-adopt/src/plan.rs | 6 + .../crates/tw-adopt/tests/claude_desktop.rs | 74 +- src-tauri/msg-codes.txt | 6 +- src-tauri/src/clients/desktop_rule.rs | 1137 +++++++++++++++++ src-tauri/src/clients/mod.rs | 84 +- src-tauri/src/clients/ops.rs | 72 +- src-tauri/src/mcp.rs | 1 + src-tauri/src/uninstall.rs | 71 +- src-tauri/src/wire.rs | 51 + src/aliases/api.provisional.ts | 34 + src/clients/ClientsPage.tsx | 27 +- src/clients/PlanDialog.tsx | 139 +- src/clients/api.ts | 26 +- src/clients/clients.i18n.tsx | 48 + src/generated/lite-api.ts | 64 +- src/i18n/core.zh.json | 6 +- 17 files changed, 1825 insertions(+), 110 deletions(-) create mode 100644 src-tauri/src/clients/desktop_rule.rs create mode 100644 src/aliases/api.provisional.ts 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/clients/desktop_rule.rs b/src-tauri/src/clients/desktop_rule.rs new file mode 100644 index 00000000..da153d59 --- /dev/null +++ b/src-tauri/src/clients/desktop_rule.rs @@ -0,0 +1,1137 @@ +//! 接管 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`]、条件恰好只有「密钥 = 它的那把」, +//! 而且仍是我们加的样子(去向是指定模型、没有拒绝和改写)。用户改过名字或条件,或者改成了 +//! 转发到上游、策略组,加了拒绝、改写,就不再是这里管的那条:还原不删它,接管也不覆盖它 +//! (同一条路由里同名时报错,请用户先处理)。用户只换了指定的模型的,仍是这条。 +//! +//! 先有规则再写它的配置(和「先有钥匙」同一个道理);配置没写成就把规则改回去。还原时先 +//! 还原文件、再删规则 —— 退路不依赖网关,删不成就提醒去路由页删。 +//! +//! **临时类型**:规则的去向(`RuleView.to` / `RuleInput.to`)在 core 的模型别名 +//! (ThinkWatch-Core#283,协议 38)里变成「名称,或者指定模型的列表」;钉着的那版 tw-api +//! 还读不了列表,所以这里按约定的 JSON 自己读写 `/overview`、`/models`、`PUT /routes/{name}` +//! 用到的那几个字段([`Rule`]、[`RuleTarget`])。core 发版后换成 `tw_api::RuleTarget`、 +//! `tw_api::PinnedModel` 和 `ep::Overview` / `ep::KnownModels` / `ep::UpdateRoute`。 + +use serde::{Deserialize, Serialize}; +use tw_api::{Endpoint, ep}; +use tw_types::{Msg, msg}; + +use crate::control::ControlClient; +use crate::wire::{DesktopPick, DesktopRule, ModelChoice, PinnedModel}; + +/// 那条规则的名字 +pub const RULE_NAME: &str = "Claude Desktop"; + +// ---------------------------------------------------------------- 临时类型 + +/// 规则的去向:上游或策略组的名称,或者指定模型的列表(约定里的 `RuleTarget`) +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum RuleTarget { + Name(String), + Models(Vec), +} + +/// 规则里的一个条件(`tw_api::ConditionView`)。字段按字符串读:新版 core 多了 +/// 条件种类时照样读得进来、原样写回去 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Condition { + pub field: String, + pub values: Vec, +} + +/// 一条规则。**读 `RuleView`、写 `RuleInput` 是同一套字段**(路由页也是这样往返的), +/// 只读视图里的那几项(`catch_all`、`phase_two`、`shadowed`)不要。改写原样往返 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Rule { + pub name: String, + #[serde(default)] + pub conditions: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub to: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deny: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub set: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Route { + pub name: String, + pub rules: Vec, +} + +/// 一把网关密钥:名字、为哪个客户端发的、绑的哪条路由 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Key { + pub name: String, + #[serde(default)] + pub client: Option, + #[serde(default)] + pub route: Option, +} + +/// `/overview` 里用得上的那几项 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Overview { + pub config_version: String, + pub routes: Vec, + pub clients: Vec, + pub default_route: String, +} + +/// `/models` 的一项。`alias` 有值的是别名,不是哪家上游的模型 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Known { + pub id: String, + pub providers: Vec, + #[serde(default)] + pub alias: Option>, +} + +#[derive(Debug, Clone, Serialize)] +struct RouteSave<'a> { + route: RouteInput<'a>, + base_version: &'a str, +} + +#[derive(Debug, Clone, Serialize)] +struct RouteInput<'a> { + name: &'a str, + rules: &'a [Rule], +} + +/// `ep::*` 那个端点的方法和路径,请求和响应换成这里的临时类型 +macro_rules! provisional { + ($name:ident = $real:ty, $req:ty => $res:ty) => { + struct $name; + impl Endpoint for $name { + const METHOD: tw_api::Method = <$real as Endpoint>::METHOD; + const PATH: &'static str = <$real as Endpoint>::PATH; + const PARAMS: &'static [&'static str] = <$real as Endpoint>::PARAMS; + const FORMAT: tw_api::Format = <$real as Endpoint>::FORMAT; + const NAME: &'static str = <$real as Endpoint>::NAME; + type Req = $req; + type Res = $res; + } + }; +} + +provisional!(OverviewNow = ep::Overview, () => Overview); +provisional!(KnownNow = ep::KnownModels, () => Vec); +provisional!(SaveRoute = ep::UpdateRoute, serde_json::Value => tw_api::ConfigWritten); + +// ---------------------------------------------------------------- 网关此刻的样子 + +/// 算这条规则要知道的:路由、密钥([`Overview`])和各家上游提供的模型([`Known`]) +#[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<&Route> { + 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 + } +} + +/// 这把密钥的那条规则:只有它的密钥这一个条件 +fn key_only(rule: &Rule, key: &str) -> bool { + matches!(rule.conditions.as_slice(), [c] if c.field == "client" && c.values == [key]) +} + +/// 是接管时加的那一条(见文件头):名字、条件对得上,仍是指定模型、没有拒绝和改写 +pub fn is_ours(rule: &Rule, 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: &Rule) -> 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<&Rule>, 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 ours = route.rules.iter().position(|r| is_ours(r, key)); + if ours.is_none() && route.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| &route.rules[i]), + Some(pick), + ))) + } + None => { + Ok(find_ours(s, key) + .map(|(route, i)| shown(&route.name, i, Some(&route.rules[i]), None))) + } + } +} + +/// 我们那条在哪:这把密钥所用的路由先找,再找别的(密钥后来换了路由) +fn find_ours<'a>(s: &'a Snapshot, key: &str) -> Option<(&'a Route, 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 { + if let Some(i) = r.rules.iter().position(|x| is_ours(x, key)) { + return Some((r, i)); + } + } + None +} + +/// 要写的一处:一条路由的规则从 `before` 换成 `after`,按 `base_version` 那一版改 +#[derive(Debug, Clone, PartialEq)] +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 mut after = route.rules.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, + Rule { + name: RULE_NAME.to_string(), + conditions: vec![Condition { + field: "client".into(), + values: vec![key.to_string()], + }], + to: Some(RuleTarget::Models(to)), + deny: None, + set: None, + }, + ), + } + Ok(Some(Change { + route: name.to_string(), + before: route.rules.clone(), + after, + base_version: version, + })) +} + +/// 删掉我们那条(还原时,或者不再需要它时):一次一条路由,先找这把密钥所用的那条。 +/// 那条路由里我们的规则一起删。没有就是 `None` +pub fn removal(s: &Snapshot, key: &str) -> Option { + let (route, _) = find_ours(s, key)?; + Some(Change { + route: route.name.clone(), + before: route.rules.clone(), + after: route + .rules + .iter() + .filter(|r| !is_ours(r, key)) + .cloned() + .collect(), + 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() +} + +// ---------------------------------------------------------------- 写 + +async fn save( + control: &ControlClient, + route: &str, + rules: &[Rule], + base_version: &str, +) -> Result { + let save = RouteSave { + route: RouteInput { name: route, rules }, + base_version, + }; + let req = serde_json::to_value(&save) + .map_err(|e| crate::error::CmdError::plain(e.to_string()).into_msg())?; + Ok(control + .call::(&[route], &req) + .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) -> Rule { + Rule { + name: name.into(), + conditions: key + .map(|k| { + vec![Condition { + field: "client".into(), + values: vec![k.into()], + }] + }) + .unwrap_or_default(), + to, + deny: None, + set: None, + } + } + + 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 = serde_json::json!({ + "config_version": "v1", + "default_route": "default", + "routes": [ + {"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"}]} + ], + "clients": [ + {"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} + ], + "providers": [], "listen": {} + }); + 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": []} + ])) + .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!(&c.after[1..], &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, + 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_eq!( + change(&s, "x", "claude-desktop", Some(&p), Some("kimi-k2")).unwrap(), + 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_eq!(change(&s, "x", "claude-desktop", None, None).unwrap(), 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!(c.after, gateway().overview.routes[0].rules); + assert_eq!(removal(&s, "claude-desktop"), Some(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(Condition { + field: "model".into(), + values: vec!["claude-*".into()], + }); + assert!(!is_ours(&more, key)); + let mut set = rule(RULE_NAME, Some(key), models()); + set.set = Some(serde_json::json!({"max_tokens": 1000})); + assert!(!is_ours(&set, key)); + + // 还原时不删它 + let mut s = gateway(); + s.overview.routes[0].rules.insert(0, set.clone()); + assert_eq!(removal(&s, key), 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, 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, + 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!( + c.after, + vec![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_eq!( + change(&s, "x", "claude-desktop", Some(&pick(&["nope"])), None).unwrap(), + None + ); + } + + /// 去向两种写法都读得进来:名称是字符串,指定模型是列表 + #[test] + fn both_shapes_of_a_target_are_read() { + let r: Rule = serde_json::from_value(serde_json::json!({ + "name": "a", "conditions": [], "to": [{"provider": "p", "model": "m"}], "catch_all": true + })) + .unwrap(); + assert_eq!(r.to, Some(RuleTarget::Models(vec![pin("p", "m")]))); + let r: Rule = + serde_json::from_value(serde_json::json!({"name": "b", "to": "main"})).unwrap(); + assert_eq!(r.to, Some(RuleTarget::Name("main".into()))); + } + + // ------------------------------------------------------------ 对着一个假的 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) { + ( + serde_json::to_value(&s.overview).unwrap(), + serde_json::to_value(&s.known).unwrap(), + ) + } + + /// 握手照真的来(`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(); + r["rules"] = body["route"]["rules"].clone(); + 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!(&now[1..], &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!(rules_now(&fake, "default"), 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!(rules_now(&fake, "default"), 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..33321f81 100644 --- a/src-tauri/src/clients/ops.rs +++ b/src-tauri/src/clients/ops.rs @@ -516,6 +516,7 @@ fn view( }) .collect(), bedrock: p.bedrock.clone().map(Into::into), + desktop_rule: None, } } @@ -560,6 +561,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 +591,7 @@ pub fn plan_adopt_as( ); v.key = Some(name); v.key_created = created; - Ok(v) + Ok((v, p.pick_model)) } /// 接管时写进去的是哪一把:`owner` 名下留着的,没有就按默认那把算(落盘时才 @@ -630,20 +645,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/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..e80edd33 100644 --- a/src-tauri/src/wire.rs +++ b/src-tauri/src/wire.rs @@ -293,6 +293,57 @@ 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, +} + +/// 规则去向里的一个「指定模型」:发到这家上游的这个模型,模型名原样发出 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +pub struct PinnedModel { + pub provider: String, + pub model: String, } /// 按客户端原来直连 Bedrock 时的设置新建 Bedrock 上游,要填的那几项。 diff --git a/src/aliases/api.provisional.ts b/src/aliases/api.provisional.ts new file mode 100644 index 00000000..b0b8ead9 --- /dev/null +++ b/src/aliases/api.provisional.ts @@ -0,0 +1,34 @@ +// 临时类型:core 的模型别名合并后由 src/generated/tw-api.ts 生成的同名类型替换。 +import type { Msg } from "@/generated/tw-api"; + +export type PinnedModel = { provider: string; model: string }; +export type AliasInput = { name: string; models: Array }; +export type AliasSave = { alias: AliasInput; base_version?: string }; +export type AliasModel = { model: string; providers: Array }; +export type AliasView = { + name: string; + models: Array; + served_by: Array; + shadows: Array; + context_window?: number; + requests_24h: number; + cost_micros_24h?: number; +}; +export type AliasSuggestion = { label: string; models: Array }; +export type AliasesView = { aliases: Array; suggestions: Array }; +export type AliasPreviewRequest = { alias: AliasInput; original?: string }; +export type AliasPreview = { + problems: Array; + served_by: Array; + unserved: Array; + shadows: Array; + same_model: Array; +}; +export type AliasRuleRef = { route: string; rule: string; field: string }; +export type AliasUsage = { requests_24h: number; keys: Array; rules: Array }; +export type AliasWritten = { version: string; renamed_in: AliasUsage }; +/** 规则的去向:上游或策略组的名称,或指定模型的列表 */ +export type RuleTarget = string | Array; +/** KnownModel / ModelRow 新加的字段 */ +export type KnownModelAliasFields = { alias?: Array; aliases: Array }; +export type ModelRowAliasFields = { aliases: Array }; 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/generated/lite-api.ts b/src/generated/lite-api.ts index 60a837c5..f60fc7d4 100644 --- a/src/generated/lite-api.ts +++ b/src/generated/lite-api.ts @@ -45,6 +45,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, }; + /** * 一个客户端此刻的样子。 */ @@ -430,6 +477,16 @@ env_keys: Array, enabled: boolean, source: string, */ third_party: boolean, }; +/** + * 一个上游模型,和提供它的上游(按网关排的顺序) + */ +export type ModelChoice = { model: string, providers: Array, }; + +/** + * 规则去向里的一个「指定模型」:发到这家上游的这个模型,模型名原样发出 + */ +export type PinnedModel = { provider: string, model: string, }; + /** * 算好但还没落盘的改动。**UI 拿它画 diff 让用户确认。** */ @@ -476,7 +533,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/i18n/core.zh.json b/src/i18n/core.zh.json index 0ffd7255..ac4725d8 100644 --- a/src/i18n/core.zh.json +++ b/src/i18n/core.zh.json @@ -868,7 +868,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 +882,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 +911,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。", From 1984bbd22bf9967ca90645be36aceead184b6ed9 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:11:00 +0800 Subject: [PATCH 07/18] Model aliases: the Aliases tab on the upstreams page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new tab between Upstreams and Proxies lists the aliases from GET /aliases: each alias with the name every upstream receives for it, its context window and 24-hour requests and cost, plus amber lines when no upstream serves it or when an upstream's same-named model is shadowed. - Suggestions for one model under different names show as a banner (one: "Create alias…"; several: a count and a dialog), and are listed directly in the empty state. "Dismiss" is remembered per suggestion with the guidance hints, so Settings' "show hidden hints" brings them back. - The tab label carries the alias count and a dot while undismissed suggestions exist and the tab is not active. - Row menu (also on right-click): Edit…, Copy name, View traffic, Delete…. - The delete dialog lists where the name is used (GET /aliases/{name}/usage plus connected clients whose config lists it) with links to each place. - DetectedClient now carries the model list written in the client's config, so the delete dialog can name the clients that list the alias. The create/edit dialog is mounted at integration (marked in UpstreamsPage). The six alias endpoints are called through the `call` command with provisional types until they are in the generated contract and the webview allow-lists. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/clients/ops.rs | 1 + src-tauri/src/wire.rs | 4 + src/aliases/AliasTable.tsx | 177 +++++++++++++++++++++++++++ src/aliases/AliasesTab.tsx | 158 ++++++++++++++++++++++++ src/aliases/DeleteAliasDialog.tsx | 174 +++++++++++++++++++++++++++ src/aliases/Suggestions.tsx | 141 ++++++++++++++++++++++ src/aliases/aliases.i18n.ts | 129 ++++++++++++++++++++ src/aliases/api.provisional.ts | 34 ++++++ src/aliases/api.ts | 40 +++++++ src/aliases/data.ts | 30 +++++ src/aliases/logic.test.ts | 191 ++++++++++++++++++++++++++++++ src/aliases/logic.ts | 107 +++++++++++++++++ src/generated/lite-api.ts | 5 + src/guide/hints.test.ts | 12 ++ src/guide/hints.ts | 21 +++- src/upstreams/UpstreamsPage.tsx | 49 +++++++- 16 files changed, 1266 insertions(+), 7 deletions(-) create mode 100644 src/aliases/AliasTable.tsx create mode 100644 src/aliases/AliasesTab.tsx create mode 100644 src/aliases/DeleteAliasDialog.tsx create mode 100644 src/aliases/Suggestions.tsx create mode 100644 src/aliases/aliases.i18n.ts create mode 100644 src/aliases/api.provisional.ts create mode 100644 src/aliases/api.ts create mode 100644 src/aliases/data.ts create mode 100644 src/aliases/logic.test.ts create mode 100644 src/aliases/logic.ts diff --git a/src-tauri/src/clients/ops.rs b/src-tauri/src/clients/ops.rs index 66a67b6c..b3ad8512 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 { diff --git a/src-tauri/src/wire.rs b/src-tauri/src/wire.rs index 376dd4b0..8ca8b750 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, diff --git a/src/aliases/AliasTable.tsx b/src/aliases/AliasTable.tsx new file mode 100644 index 00000000..8104edab --- /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 "./api.provisional"; +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..4b17dc9d --- /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 "./api.provisional"; +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..922ade06 --- /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 "./api.provisional"; +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..78fbe73b --- /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 "./api.provisional"; +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.provisional.ts b/src/aliases/api.provisional.ts new file mode 100644 index 00000000..b0b8ead9 --- /dev/null +++ b/src/aliases/api.provisional.ts @@ -0,0 +1,34 @@ +// 临时类型:core 的模型别名合并后由 src/generated/tw-api.ts 生成的同名类型替换。 +import type { Msg } from "@/generated/tw-api"; + +export type PinnedModel = { provider: string; model: string }; +export type AliasInput = { name: string; models: Array }; +export type AliasSave = { alias: AliasInput; base_version?: string }; +export type AliasModel = { model: string; providers: Array }; +export type AliasView = { + name: string; + models: Array; + served_by: Array; + shadows: Array; + context_window?: number; + requests_24h: number; + cost_micros_24h?: number; +}; +export type AliasSuggestion = { label: string; models: Array }; +export type AliasesView = { aliases: Array; suggestions: Array }; +export type AliasPreviewRequest = { alias: AliasInput; original?: string }; +export type AliasPreview = { + problems: Array; + served_by: Array; + unserved: Array; + shadows: Array; + same_model: Array; +}; +export type AliasRuleRef = { route: string; rule: string; field: string }; +export type AliasUsage = { requests_24h: number; keys: Array; rules: Array }; +export type AliasWritten = { version: string; renamed_in: AliasUsage }; +/** 规则的去向:上游或策略组的名称,或指定模型的列表 */ +export type RuleTarget = string | Array; +/** KnownModel / ModelRow 新加的字段 */ +export type KnownModelAliasFields = { alias?: Array; aliases: Array }; +export type ModelRowAliasFields = { aliases: Array }; diff --git a/src/aliases/api.ts b/src/aliases/api.ts new file mode 100644 index 00000000..c05fa7aa --- /dev/null +++ b/src/aliases/api.ts @@ -0,0 +1,40 @@ +/** + * 别名用到的控制面调用(ThinkWatch-Core#283 的 `/aliases` 那几个端点)。 + * + * 和上游页一样**只是类型化的调用**:名称校验、谁能服务、改名时一起改掉的引用、 + * 建议哪几组,全在 core。 + * + * 集成:core 发版、`src/generated/tw-api.ts` 重新生成之后 —— + * · 六个端点名加进 `src/control.ts` 的 `WEBVIEW_ENDPOINTS` 和 `src-tauri/src/call.rs` + * 的 `ALLOWED`; + * · 下面的 `pending` 换成 `call`(`call("Aliases", null)` …),类型从 `@/types` 取, + * 删掉 `api.provisional.ts`。 + */ +import { invoke } from "@tauri-apps/api/core"; +import type { ConfigWritten } from "@/types"; +import type { + AliasPreview, + AliasPreviewRequest, + AliasSave, + AliasUsage, + AliasWritten, + AliasesView, +} from "./api.provisional"; + +/** 端点进白名单之前的调用:和 `call` 走同一个命令,只是类型手写 */ +function pending(endpoint: string, req: unknown, ...params: string[]): Promise { + return invoke("call", { endpoint, params, req }); +} + +export const api = { + /** 全部别名、各自由谁服务,以及同一模型不同名称的建议 */ + aliases: () => pending("Aliases", null), + createAlias: (save: AliasSave) => pending("CreateAlias", save), + /** 可以改名:密钥范围和规则里写着旧名的地方一起改,改了哪些在 `renamed_in` 里 */ + updateAlias: (name: string, save: AliasSave) => pending("UpdateAlias", save, name), + deleteAlias: (name: string, baseVersion: string) => + pending("DeleteAlias", { base_version: baseVersion }, name), + previewAlias: (req: AliasPreviewRequest) => pending("PreviewAlias", req), + /** 在用这个名称的地方:24 小时的请求、密钥的可见范围、规则 */ + aliasUsage: (name: string) => pending("AliasUsage", null, name), +}; diff --git a/src/aliases/data.ts b/src/aliases/data.ts new file mode 100644 index 00000000..8c64c096 --- /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 "./api.provisional"; +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/logic.test.ts b/src/aliases/logic.test.ts new file mode 100644 index 00000000..5345a50e --- /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 "./api.provisional"; +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..aab604de --- /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 "./api.provisional"; + +/** + * 一条建议的标识,「忽略」按它记(`@/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/generated/lite-api.ts b/src/generated/lite-api.ts index 60a837c5..e1dd6f9d 100644 --- a/src/generated/lite-api.ts +++ b/src/generated/lite-api.ts @@ -94,6 +94,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 里的不能 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/upstreams/UpstreamsPage.tsx b/src/upstreams/UpstreamsPage.tsx index 4f849ed3..59b4e246 100644 --- a/src/upstreams/UpstreamsPage.tsx +++ b/src/upstreams/UpstreamsPage.tsx @@ -38,8 +38,11 @@ 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 { aliasesText } from "@/aliases/aliases.i18n"; +import type { AliasInput } from "@/aliases/api.provisional"; -export type UpstreamTab = "upstreams" | "proxies" | "pricing" | "checkup"; +export type UpstreamTab = "upstreams" | "aliases" | "proxies" | "pricing" | "checkup"; /** * 这次打开应用以来最后看的那个标签。切到别的页再回来,还停在那一个。 @@ -60,14 +63,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 +91,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 +280,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 +305,11 @@ export default function UpstreamsPage({ {t.newUpstream} + ) : tab === "aliases" ? ( + ) : tab === "proxies" ? ( <> {proxies.length > 0 && ( @@ -354,6 +368,9 @@ export default function UpstreamsPage({ {t.tabs.upstreams} + + + {t.tabs.proxies} @@ -421,6 +438,16 @@ export default function UpstreamsPage({ )} + + setDialog({ kind: "alias", mode: { kind: "edit", name } })} + onChanged={changed} + /> + + {proxies.length === 0 ? ( )} + {/* + 集成点(L2):新建、编辑别名的对话框挂在这里 —— + {dialog?.kind === "alias" && ( + setDialog(null)} + onSaved={() => { setDialog(null); changed(); }} /> + )} + `dialog.mode` 是 `AliasDialogMode`(`@/aliases/AliasesTab`):新建可带预填(建议、上游模型 + 弹窗的「起别名…」),编辑带别名的名称。保存之后 core 发 `config_reloaded`,别名表自己重读 + */} {dialog?.kind === "delete-sheet" && ( Date: Mon, 5 Oct 2026 10:12:56 +0800 Subject: [PATCH 08/18] Upstreams page mounts the alias dialog for create and edit Co-Authored-By: Claude Opus 5.5 --- src/upstreams/UpstreamsPage.tsx | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/upstreams/UpstreamsPage.tsx b/src/upstreams/UpstreamsPage.tsx index 59b4e246..ab525ab0 100644 --- a/src/upstreams/UpstreamsPage.tsx +++ b/src/upstreams/UpstreamsPage.tsx @@ -39,6 +39,7 @@ 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 "@/aliases/api.provisional"; @@ -64,7 +65,7 @@ type DialogState = | { kind: "delete-proxy"; name: string } | { kind: "sheet"; mode: PriceSheetDialogMode } | { kind: "delete-sheet"; name: string } - /** 新建、编辑别名(`AliasDialog`,集成时挂上,见页尾) */ + /** 新建、编辑别名(`AliasDialog`) */ | { kind: "alias"; mode: AliasDialogMode }; /** @@ -603,16 +604,20 @@ export default function UpstreamsPage({ }} /> )} - {/* - 集成点(L2):新建、编辑别名的对话框挂在这里 —— - {dialog?.kind === "alias" && ( - setDialog(null)} - onSaved={() => { setDialog(null); changed(); }} /> - )} - `dialog.mode` 是 `AliasDialogMode`(`@/aliases/AliasesTab`):新建可带预填(建议、上游模型 - 弹窗的「起别名…」),编辑带别名的名称。保存之后 core 发 `config_reloaded`,别名表自己重读 - */} + {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" && ( Date: Mon, 5 Oct 2026 10:42:06 +0800 Subject: [PATCH 09/18] Pin core to the model-aliases commit (protocol 38) Points the six core dependencies at ThinkWatch-Core 4a1775d on feat/model-aliases until v0.62.0 is tagged. Co-Authored-By: Claude Opus 5.5 --- src-tauri/Cargo.lock | 42 +++++++++++++++++++++--------------------- src-tauri/Cargo.toml | 12 ++++++------ 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6b53bc71..1894f285 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.59.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.59.0", ] [[package]] @@ -1171,7 +1171,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[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.59.0", ] [[package]] @@ -3601,7 +3601,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3657,7 +3657,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[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,10 +4740,10 @@ 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", + "windows-sys 0.59.0", ] [[package]] @@ -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]] @@ -5269,7 +5269,7 @@ dependencies = [ [[package]] name = "tw-api" version = "0.61.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.61.0#57f0ac21afdced765c8cf4f3d841196e285507c8" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=4a1775d924e4f069d565a00af116ee375444672e#4a1775d924e4f069d565a00af116ee375444672e" dependencies = [ "serde", "serde_json", @@ -5281,7 +5281,7 @@ dependencies = [ [[package]] name = "tw-dialect" version = "0.61.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.61.0#57f0ac21afdced765c8cf4f3d841196e285507c8" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=4a1775d924e4f069d565a00af116ee375444672e#4a1775d924e4f069d565a00af116ee375444672e" dependencies = [ "serde", "serde_json", @@ -5290,7 +5290,7 @@ dependencies = [ [[package]] name = "tw-guard" version = "0.61.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.61.0#57f0ac21afdced765c8cf4f3d841196e285507c8" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=4a1775d924e4f069d565a00af116ee375444672e#4a1775d924e4f069d565a00af116ee375444672e" dependencies = [ "base64 0.22.1", "bytes", @@ -5306,7 +5306,7 @@ dependencies = [ [[package]] name = "tw-link" version = "0.61.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.61.0#57f0ac21afdced765c8cf4f3d841196e285507c8" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=4a1775d924e4f069d565a00af116ee375444672e#4a1775d924e4f069d565a00af116ee375444672e" dependencies = [ "serde", "serde_json", @@ -5333,7 +5333,7 @@ dependencies = [ [[package]] name = "tw-types" version = "0.61.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.61.0#57f0ac21afdced765c8cf4f3d841196e285507c8" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=4a1775d924e4f069d565a00af116ee375444672e#4a1775d924e4f069d565a00af116ee375444672e" dependencies = [ "serde", "ts-rs", @@ -5342,7 +5342,7 @@ dependencies = [ [[package]] name = "tw-watch" version = "0.61.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.61.0#57f0ac21afdced765c8cf4f3d841196e285507c8" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=4a1775d924e4f069d565a00af116ee375444672e#4a1775d924e4f069d565a00af116ee375444672e" dependencies = [ "notify", "thiserror 2.0.21", @@ -5352,7 +5352,7 @@ dependencies = [ [[package]] name = "tw-yaml" version = "0.61.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.61.0#57f0ac21afdced765c8cf4f3d841196e285507c8" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=4a1775d924e4f069d565a00af116ee375444672e#4a1775d924e4f069d565a00af116ee375444672e" 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]] @@ -5816,7 +5816,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[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..f4943f30 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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", rev = "4a1775d924e4f069d565a00af116ee375444672e" } +tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "4a1775d924e4f069d565a00af116ee375444672e" } +tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "4a1775d924e4f069d565a00af116ee375444672e" } +tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "4a1775d924e4f069d565a00af116ee375444672e" } +tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "4a1775d924e4f069d565a00af116ee375444672e" } +tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "4a1775d924e4f069d565a00af116ee375444672e" } [lib] name = "thinkwatch_lite_lib" From 7b0927bfc8b7216ac8962e8921cbe5c60ad8a6c7 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:51:56 +0800 Subject: [PATCH 10/18] Model aliases UI: use the generated core types and allowlist the alias endpoints Regenerates src/generated against protocol 38 and replaces every provisional type with the generated one: the alias views, RuleTarget and PinnedModel, KnownModel.alias/aliases, ModelRow.aliases and the dry-run candidate_models. The six /aliases endpoints join WEBVIEW_ENDPOINTS and ALLOWED, so src/aliases/api.ts calls them through the typed call() and absorbs the dialog's separate API module. The dry run now reads candidate_models directly; core sends sent_model for every candidate, so the attempt list only names it when model_via says it differs. The screenshot fixtures are regenerated against the same core and the screenshot mocks answer the alias endpoints. Co-Authored-By: Claude Opus 5.5 --- scripts/shots/core/en/dryrun.json | 10 ++ scripts/shots/core/en/security.json | 15 ++ scripts/shots/core/en/status.json | 2 +- scripts/shots/core/zh/dryrun.json | 10 ++ scripts/shots/core/zh/security.json | 15 ++ scripts/shots/core/zh/status.json | 2 +- scripts/shots/mock/config.ts | 6 +- scripts/shots/mock/core.ts | 7 + src-tauri/src/call.rs | 7 + src/aliases/AliasDialog.tsx | 34 ++-- src/aliases/AliasTable.tsx | 2 +- src/aliases/AliasesTab.tsx | 2 +- src/aliases/DeleteAliasDialog.tsx | 2 +- src/aliases/Suggestions.tsx | 2 +- src/aliases/api.provisional.ts | 34 ---- src/aliases/api.ts | 40 ++--- src/aliases/data.ts | 2 +- src/aliases/dialogApi.ts | 40 ----- src/aliases/logic.test.ts | 2 +- src/aliases/logic.ts | 2 +- src/control.ts | 6 + src/generated/lite-api.ts | 7 +- src/generated/tw-api.ts | 270 ++++++++++++++++++++++++++-- src/keys/ModelScope.test.tsx | 6 +- src/keys/ModelScope.tsx | 4 +- src/keys/scope.test.ts | 13 +- src/keys/scope.ts | 12 +- src/routing/DryRunDialog.tsx | 33 ++-- src/routing/PinnedModels.tsx | 7 +- src/routing/RouteDialog.tsx | 13 +- src/routing/RoutingPage.tsx | 5 +- src/routing/RuleDialog.tsx | 7 +- src/routing/api.ts | 8 +- src/routing/chain.ts | 11 +- src/routing/fields.test.ts | 2 +- src/routing/model.test.ts | 5 +- src/routing/model.ts | 21 +-- src/routing/provisional.ts | 45 ----- src/routing/target.test.ts | 48 ++--- src/routing/target.ts | 29 ++- src/traffic/modelVia.test.ts | 6 +- src/traffic/modelVia.ts | 8 +- src/upstreams/ModelsPanel.tsx | 6 +- src/upstreams/UpstreamsPage.tsx | 2 +- 44 files changed, 463 insertions(+), 347 deletions(-) delete mode 100644 src/aliases/api.provisional.ts delete mode 100644 src/aliases/dialogApi.ts delete mode 100644 src/routing/provisional.ts 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/src/call.rs b/src-tauri/src/call.rs index c1692722..e1c69bfb 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, // 上游体检:各家上游的事实和样本数,界面按它写成自己的话 diff --git a/src/aliases/AliasDialog.tsx b/src/aliases/AliasDialog.tsx index 8a549fb4..2b31c7e7 100644 --- a/src/aliases/AliasDialog.tsx +++ b/src/aliases/AliasDialog.tsx @@ -23,22 +23,12 @@ 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, KnownModel } from "@/types"; +import type { AliasHint, AliasInput, AliasPreview, AliasSave, AliasUsage, KnownModel, PinnedModel } from "@/types"; import { Boxed, DialogError, FormItem } from "@/upstreams/parts"; import { aliasDialogText } from "./AliasDialog.i18n"; -import type { - AliasInput, - AliasPreview, - AliasSave, - AliasUsage, - KnownModelAliasFields, - PinnedModel, -} from "./api.provisional"; -import { dialogApi } from "./dialogApi"; +import { api } from "./api"; import { ONLY_ITSELF, draftOf, onlyAuto, shownProblems, withModel, withName, withoutModel, type Draft } from "./draft"; -/** `GET /models` 的一项,带上别名的两个新字段(core 发版前是临时的) */ -type Known = KnownModel & Partial; /** 输入停下来这么久再问 core 和 Rust */ const PREVIEW_DELAY_MS = 250; @@ -110,7 +100,7 @@ function Body({ const known = useResource("known-models", () => call("KnownModels", null), { events: ["models_changed", "config_reloaded"], }); - const catalog = useMemo(() => (known.data ?? []) as Known[], [known.data]); + const catalog = useMemo(() => known.data ?? [], [known.data]); /** 上游的真模型(不是别名):名称 → 提供它的上游 */ const offered = useMemo(() => { const m = new Map(); @@ -135,7 +125,7 @@ function Body({ const [error, setError] = useState(null); useEffect(() => { - dialogApi + api .configVersion() .then(setBase) .catch(() => {}); @@ -145,7 +135,7 @@ function Body({ useEffect(() => { if (editing == null || initial?.models !== undefined) return; let live = true; - dialogApi + api .aliases() .then((v) => { if (!live) return; @@ -170,7 +160,7 @@ function Body({ useEffect(() => { if (!renaming || usage || editing == null) return; let live = true; - dialogApi + api .aliasUsage(editing) .then((u) => live && setUsage(u)) .catch(() => {}); @@ -187,7 +177,7 @@ function Body({ if (loaded === null) return; const timer = setTimeout(() => { const fresh = hintSeq.current.start(); - dialogApi + api .hints(trimmed, models) .then((h) => fresh() && setHints(h)) .catch(() => fresh() && setHints([])); @@ -199,7 +189,7 @@ function Body({ } const current = previewSeq.current.start(); const key = draftKey; - dialogApi + api .previewAlias({ alias: { name: trimmed, models }, original: editing }) .then((p) => { if (!current()) return; @@ -257,11 +247,11 @@ function Body({ const body: AliasSave = { alias: { name: trimmed, models }, base_version: base }; try { if (editing != null) { - const w = await dialogApi.updateAlias(editing, body); + 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 dialogApi.createAlias(body); + await api.createAlias(body); } // 列着别名的几处(别名标签、模型建议、上游模型弹窗的别名标记) invalidate("aliases"); @@ -478,7 +468,7 @@ function ModelList({ models: string[]; providersOf: (m: string) => string[]; unserved: Set; - candidates: Known[]; + candidates: KnownModel[]; loading: boolean; onAdd: (m: string) => void; onRemove: (m: string) => void; @@ -538,7 +528,7 @@ function ModelInput({ onAdd, disabled, }: { - candidates: Known[]; + candidates: KnownModel[]; onAdd: (m: string) => void; disabled: boolean; }) { diff --git a/src/aliases/AliasTable.tsx b/src/aliases/AliasTable.tsx index 8104edab..e359445b 100644 --- a/src/aliases/AliasTable.tsx +++ b/src/aliases/AliasTable.tsx @@ -10,7 +10,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { Tip } from "@/ui/tip"; import { contextWindow } from "@/upstreams/labels"; import { keepInRow, openRow } from "@/upstreams/parts"; -import type { AliasView } from "./api.provisional"; +import type { AliasView } from "@/types"; import { aliasesText } from "./aliases.i18n"; import { aliasWarnings, modelLines, type AliasWarning, type ModelLine } from "./logic"; diff --git a/src/aliases/AliasesTab.tsx b/src/aliases/AliasesTab.tsx index 4b17dc9d..8e2e3a6d 100644 --- a/src/aliases/AliasesTab.tsx +++ b/src/aliases/AliasesTab.tsx @@ -7,7 +7,7 @@ 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 "./api.provisional"; +import type { AliasInput, AliasSuggestion, AliasView } from "@/types"; import { aliasesText } from "./aliases.i18n"; import { AliasTable } from "./AliasTable"; import { useAliases, useSuggestions } from "./data"; diff --git a/src/aliases/DeleteAliasDialog.tsx b/src/aliases/DeleteAliasDialog.tsx index 922ade06..fe33063d 100644 --- a/src/aliases/DeleteAliasDialog.tsx +++ b/src/aliases/DeleteAliasDialog.tsx @@ -23,7 +23,7 @@ import { Spinner } from "@/ui/spinner"; import { errorText } from "@/upstreams/labels"; import { DialogError } from "@/upstreams/parts"; import { api } from "./api"; -import type { AliasRuleRef, AliasView } from "./api.provisional"; +import type { AliasRuleRef, AliasView } from "@/types"; import { aliasesText } from "./aliases.i18n"; import { clientsListing } from "./logic"; diff --git a/src/aliases/Suggestions.tsx b/src/aliases/Suggestions.tsx index 78fbe73b..16ba2d08 100644 --- a/src/aliases/Suggestions.tsx +++ b/src/aliases/Suggestions.tsx @@ -11,7 +11,7 @@ import { DialogHeader, DialogTitle, } from "@/ui/dialog"; -import type { AliasSuggestion } from "./api.provisional"; +import type { AliasSuggestion } from "@/types"; import { aliasesText } from "./aliases.i18n"; import { Lines } from "./AliasTable"; import { suggestionKey, suggestionProviders } from "./logic"; diff --git a/src/aliases/api.provisional.ts b/src/aliases/api.provisional.ts deleted file mode 100644 index b0b8ead9..00000000 --- a/src/aliases/api.provisional.ts +++ /dev/null @@ -1,34 +0,0 @@ -// 临时类型:core 的模型别名合并后由 src/generated/tw-api.ts 生成的同名类型替换。 -import type { Msg } from "@/generated/tw-api"; - -export type PinnedModel = { provider: string; model: string }; -export type AliasInput = { name: string; models: Array }; -export type AliasSave = { alias: AliasInput; base_version?: string }; -export type AliasModel = { model: string; providers: Array }; -export type AliasView = { - name: string; - models: Array; - served_by: Array; - shadows: Array; - context_window?: number; - requests_24h: number; - cost_micros_24h?: number; -}; -export type AliasSuggestion = { label: string; models: Array }; -export type AliasesView = { aliases: Array; suggestions: Array }; -export type AliasPreviewRequest = { alias: AliasInput; original?: string }; -export type AliasPreview = { - problems: Array; - served_by: Array; - unserved: Array; - shadows: Array; - same_model: Array; -}; -export type AliasRuleRef = { route: string; rule: string; field: string }; -export type AliasUsage = { requests_24h: number; keys: Array; rules: Array }; -export type AliasWritten = { version: string; renamed_in: AliasUsage }; -/** 规则的去向:上游或策略组的名称,或指定模型的列表 */ -export type RuleTarget = string | Array; -/** KnownModel / ModelRow 新加的字段 */ -export type KnownModelAliasFields = { alias?: Array; aliases: Array }; -export type ModelRowAliasFields = { aliases: Array }; diff --git a/src/aliases/api.ts b/src/aliases/api.ts index c05fa7aa..7646978a 100644 --- a/src/aliases/api.ts +++ b/src/aliases/api.ts @@ -1,40 +1,26 @@ /** - * 别名用到的控制面调用(ThinkWatch-Core#283 的 `/aliases` 那几个端点)。 + * 别名用到的调用:控制面的 `/aliases` 那几个端点,加上名称提示(Rust 这边的命令)。 * * 和上游页一样**只是类型化的调用**:名称校验、谁能服务、改名时一起改掉的引用、 * 建议哪几组,全在 core。 - * - * 集成:core 发版、`src/generated/tw-api.ts` 重新生成之后 —— - * · 六个端点名加进 `src/control.ts` 的 `WEBVIEW_ENDPOINTS` 和 `src-tauri/src/call.rs` - * 的 `ALLOWED`; - * · 下面的 `pending` 换成 `call`(`call("Aliases", null)` …),类型从 `@/types` 取, - * 删掉 `api.provisional.ts`。 */ import { invoke } from "@tauri-apps/api/core"; -import type { ConfigWritten } from "@/types"; -import type { - AliasPreview, - AliasPreviewRequest, - AliasSave, - AliasUsage, - AliasWritten, - AliasesView, -} from "./api.provisional"; - -/** 端点进白名单之前的调用:和 `call` 走同一个命令,只是类型手写 */ -function pending(endpoint: string, req: unknown, ...params: string[]): Promise { - return invoke("call", { endpoint, params, req }); -} +import { call } from "@/control"; +import type { AliasHint, AliasPreviewRequest, AliasSave } from "@/types"; export const api = { /** 全部别名、各自由谁服务,以及同一模型不同名称的建议 */ - aliases: () => pending("Aliases", null), - createAlias: (save: AliasSave) => pending("CreateAlias", save), + aliases: () => call("Aliases", null), + createAlias: (save: AliasSave) => call("CreateAlias", save), /** 可以改名:密钥范围和规则里写着旧名的地方一起改,改了哪些在 `renamed_in` 里 */ - updateAlias: (name: string, save: AliasSave) => pending("UpdateAlias", save, name), + updateAlias: (name: string, save: AliasSave) => call("UpdateAlias", save, name), deleteAlias: (name: string, baseVersion: string) => - pending("DeleteAlias", { base_version: baseVersion }, name), - previewAlias: (req: AliasPreviewRequest) => pending("PreviewAlias", req), + call("DeleteAlias", { base_version: baseVersion }, name), + previewAlias: (req: AliasPreviewRequest) => call("PreviewAlias", req), /** 在用这个名称的地方:24 小时的请求、密钥的可见范围、规则 */ - aliasUsage: (name: string) => pending("AliasUsage", null, name), + 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 index 8c64c096..36b42beb 100644 --- a/src/aliases/data.ts +++ b/src/aliases/data.ts @@ -4,7 +4,7 @@ import { useResource, type Resource } from "@/lib/resource"; import { dismissHints, useDismissedHints } from "@/guide/hints"; import { api } from "./api"; -import type { AliasSuggestion, AliasesView } from "./api.provisional"; +import type { AliasSuggestion, AliasesView } from "@/types"; import { suggestionKey, visibleSuggestions } from "./logic"; /** diff --git a/src/aliases/dialogApi.ts b/src/aliases/dialogApi.ts deleted file mode 100644 index 4eba4103..00000000 --- a/src/aliases/dialogApi.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * 新建、编辑别名的对话框用到的调用。 - * - * 控制面那几个(ThinkWatch-Core#283 的 `/aliases`)**还不在白名单里**:core 发版、 - * `src/generated/tw-api.ts` 重新生成之后,端点名加进 `src/control.ts` 的 `WEBVIEW_ENDPOINTS` - * 和 `src-tauri/src/call.rs` 的 `ALLOWED`,`pending` 换成 `call`,并进 `./api.ts`。 - * - * 名称提示(`alias_hints`)是 Rust 这边的命令:说的是这台机器上的客户端。 - */ -import { invoke } from "@tauri-apps/api/core"; -import { call } from "@/control"; -import type { AliasHint, ConfigWritten } from "@/types"; -import type { - AliasPreview, - AliasPreviewRequest, - AliasSave, - AliasUsage, - AliasWritten, - AliasesView, -} from "./api.provisional"; - -/** 端点进白名单之前的调用:和 `call` 走同一个命令,只是类型手写 */ -function pending(endpoint: string, req: unknown, ...params: string[]): Promise { - return invoke("call", { endpoint, params, req }); -} - -export const dialogApi = { - /** 编辑时读出这个别名现在列着的模型 */ - aliases: () => pending("Aliases", null), - createAlias: (save: AliasSave) => pending("CreateAlias", save), - /** 可以改名:密钥范围和规则里写着旧名的地方一起改,改了哪些在 `renamed_in` 里 */ - updateAlias: (name: string, save: AliasSave) => pending("UpdateAlias", save, name), - previewAlias: (req: AliasPreviewRequest) => pending("PreviewAlias", req), - /** 在用这个名称的地方。改名前列出会一起改的引用 */ - aliasUsage: (name: string) => pending("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/logic.test.ts b/src/aliases/logic.test.ts index 5345a50e..235dca33 100644 --- a/src/aliases/logic.test.ts +++ b/src/aliases/logic.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { DetectedClient } from "@/types"; -import type { AliasSuggestion, AliasView } from "./api.provisional"; +import type { AliasSuggestion, AliasView } from "@/types"; import { aliasWarnings, clientsListing, diff --git a/src/aliases/logic.ts b/src/aliases/logic.ts index aab604de..24b1bcc9 100644 --- a/src/aliases/logic.ts +++ b/src/aliases/logic.ts @@ -4,7 +4,7 @@ */ import type { HintId } from "@/guide/hints"; import type { DetectedClient } from "@/types"; -import type { AliasInput, AliasSuggestion, AliasView } from "./api.provisional"; +import type { AliasInput, AliasSuggestion, AliasView } from "@/types"; /** * 一条建议的标识,「忽略」按它记(`@/guide/hints`,和引导提示的「不再显示」同一处, 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/generated/lite-api.ts b/src/generated/lite-api.ts index 563ebd79..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, /** @@ -493,11 +493,6 @@ third_party: boolean, }; */ export type ModelChoice = { model: string, providers: Array, }; -/** - * 规则去向里的一个「指定模型」:发到这家上游的这个模型,模型名原样发出 - */ -export type PinnedModel = { provider: string, model: string, }; - /** * 算好但还没落盘的改动。**UI 拿它画 diff 让用户确认。** */ diff --git a/src/generated/tw-api.ts b/src/generated/tw-api.ts index bb9802d0..b6916338 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,165 @@ 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, +/** + * 列表里没有哪家上游的清单里有的名称 + */ +unserved: Array, +/** + * 同 [`AliasView::shadows`] + */ +shadows: Array, +/** + * 别的上游上的同一个模型(建议勾选):清单里的名称和列表里某个名称归一之后相等, + * 规则同 [`AliasesView::suggestions`]。列表里已经有的名称、已经有列表里某个名称的 + * 上游不在里面 + */ +same_model: Array, }; + +/** + * 预览一个还没保存的别名(`POST /aliases/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 + */ +renamed_in: AliasUsage, }; + +/** + * 别名页(`GET /aliases`)。 + */ +export type AliasesView = { +/** + * 按书写顺序 + */ +aliases: Array, +/** + * 同一个模型在不同上游上叫不同名称的几组。**只认 Claude**:各家清单里的名称归一之后 + * 完全相等才算同一个 —— 去掉路径前缀(`anthropic/`)、Bedrock 的地域前缀、 + * `anthropic.` 和 `-v1:0`,Vertex 的 `@日期` 写成 `-日期`,版本号里的点写成横线; + * **日期保留**,带日期和不带日期的不算同一个。至少两家上游、至少两种写法才成组; + * 已经被某一个别名全部列进去的组不出 + */ +suggestions: Array, }; + /** * 尝试链里一跳的结果。 */ @@ -63,8 +222,9 @@ export type AttemptOutcome = "served" | "status" | "error" | "estimated"; */ export type AttemptView = { provider: string, /** - * 规则改写了模型名:这一跳发给(没发出去的,要发给)上游的是哪个。没改写的 - * 没有 —— 发出去的就是客户端要的那个(`RequestStarted::model`)。 + * 这一跳发给(没发出去的,要发给)上游的模型名,和客户端要的不一样时才有:别名对到 + * 这一家的名称、规则改写、规则指定的模型、插件改名。一样的没有 —— 发出去的就是客户端 + * 要的那个(`RequestStarted::model`)。 * * **费用按它算**:请求改写成另一个模型发出去,上游按那个模型收钱。 */ @@ -712,6 +872,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 +958,10 @@ reason: string | null, * 候选链,第一个是首选,后面是故障转移的备选 */ candidates: Array, +/** + * 每个候选发出去的模型名,和 `candidates` 一一对应 + */ +candidate_models: Array, /** * 经过了哪个组 */ @@ -1667,8 +1848,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, }; /** * 建连的那一步对着谁。 @@ -1943,7 +2141,12 @@ price?: PriceFields | null, price_source?: PriceSourceView | null, /** * 价格是从别的平台借来的。**按它算出来的钱是估算** */ -estimated: boolean, }; +estimated: boolean, +/** + * 列出了这个模型名的别名,按别名表的顺序。**不论这家发不发它**:别名在这家按列表 + * 顺序取它有的第一个,排在后面的名字也算列进了这个别名 + */ +aliases: Array, }; /** * 一个上游的模型清单从哪儿来。 @@ -2118,6 +2321,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`)。插件改得了回答里的工具调用(新旧两份里有一份的 @@ -3002,7 +3212,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 +3545,11 @@ export type RuleInput = { name: string, /** * 空 = 兜底,匹配全部请求 */ -conditions: Array, to?: string | null, +conditions: Array, +/** + * 去向,和 [`RuleView::to`] 同一个写法。指定模型的列表原样写进配置,交空列表会被拒 + */ +to?: RuleTarget | null, /** * 拒绝,以及返回给客户端的原因 */ @@ -3349,6 +3568,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 +3623,9 @@ export type RuleView = { name: string, */ conditions: Array, /** - * 去向:上游名或组名。拒绝的规则和只附加改写的规则没有 + * 去向:上游名或组名,或者指定模型的列表。拒绝的规则和只附加改写的规则没有 */ -to?: string | null, +to?: RuleTarget | null, /** * 命中就拒绝。值是返回给客户端的原因 */ @@ -3877,7 +4105,11 @@ reason: ServeSkip, }; * **这是「你确认要花钱吗」那个对话框的全部内容。**触发前必须显示它, * 而不是点了才知道。 */ -export type SpeedEstimate = { provider: string, model: string, +export type SpeedEstimate = { provider: string, +/** + * 发给这一家的模型名,按它报价:要测的是别名时是这一家自己的那个名字 + */ +model: string, /** * 输入 token。**精确值** —— 请求是固定的 */ @@ -3914,7 +4146,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。**这一层唯一值得测的东西 */ @@ -4411,6 +4647,12 @@ export const ENDPOINTS = { TestProxy: { method: "POST", path: "/proxies/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: "/aliases/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" }, @@ -4526,6 +4768,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/keys/ModelScope.test.tsx b/src/keys/ModelScope.test.tsx index 58056539..0acfdd29 100644 --- a/src/keys/ModelScope.test.tsx +++ b/src/keys/ModelScope.test.tsx @@ -2,7 +2,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { setLang } from "@/i18n"; import { ModelScope } from "./ModelScope"; -import type { CatalogModel } from "./scope"; +import type { KnownModel } from "@/types"; const noop = () => {}; @@ -28,7 +28,7 @@ describe("可见模型:指定范围", () => { }); }); -const ALIASED: CatalogModel[] = [ +const ALIASED: KnownModel[] = [ { 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: [] }, @@ -76,7 +76,7 @@ describe("可见模型:别名", () => { , diff --git a/src/keys/ModelScope.tsx b/src/keys/ModelScope.tsx index aca35fb0..063b1aec 100644 --- a/src/keys/ModelScope.tsx +++ b/src/keys/ModelScope.tsx @@ -9,6 +9,7 @@ import { Segmented } from "@/ui/segmented"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/ui/table"; import { useText } from "@/i18n"; import { Boxed, FormItem, Note } from "@/upstreams/parts"; +import type { KnownModel } from "@/types"; import { modelScopeText } from "./ModelScope.i18n"; import { addPattern, @@ -20,7 +21,6 @@ import { splitEntries, toggleModel, visibleCount, - type CatalogModel, type Scope, type Source, } from "./scope"; @@ -48,7 +48,7 @@ export function ModelScope({ scope: Scope; entries: string[]; /** 网关知道的全部模型,含别名。尚未获取到时是空的 */ - catalog: CatalogModel[]; + catalog: KnownModel[]; onScope: (s: Scope) => void; onEntries: (e: string[]) => void; }) { diff --git a/src/keys/scope.test.ts b/src/keys/scope.test.ts index 48d86f68..af3a4df6 100644 --- a/src/keys/scope.test.ts +++ b/src/keys/scope.test.ts @@ -16,15 +16,14 @@ import { splitEntries, toggleModel, visibleCount, - type CatalogModel, } 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("可见模型的三态", () => { @@ -136,7 +135,7 @@ describe("表格里「可见模型」那一栏", () => { * 目录里带别名的样子(core 的 `/models`):别名和同名真模型只出现一次(就是别名), * 真名那一项的 `aliases` 是指向它的别名 */ -const WITH_ALIASES: CatalogModel[] = [ +const WITH_ALIASES: KnownModel[] = [ { id: "anthropic/claude-sonnet-5", providers: ["openrouter"], aliases: ["claude-sonnet-5"] }, { id: "claude-sonnet-5", diff --git a/src/keys/scope.ts b/src/keys/scope.ts index da152983..8eaa9f59 100644 --- a/src/keys/scope.ts +++ b/src/keys/scope.ts @@ -15,12 +15,8 @@ * **不写进 `allow`**:配置里只有用户自己写的那几条。 */ import { asciiLower, globMatch } from "@/upstreams/glob"; -import type { KnownModelAliasFields } from "@/aliases/api.provisional"; import type { KnownModel } from "@/types"; -/** 目录里的一项。别名也在里面:`alias` 是它的模型列表,真名没有这个字段 */ -export type CatalogModel = KnownModel & Partial; - export type Scope = "all" | "some" | "none"; export function scopeOf(allow: string[] | null | undefined): Scope { @@ -109,7 +105,7 @@ export interface ScopeRow { source: Source; } -export function rowsOf(entries: string[], catalog: CatalogModel[]): ScopeRow[] { +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, alias: null, source: { kind: "picked" } })); @@ -124,7 +120,7 @@ export function rowsOf(entries: string[], catalog: CatalogModel[]): ScopeRow[] { } /** 目录里有几个模型对这把密钥可见。别名算在里面,随它的模型放行的也算 */ -export function visibleCount(entries: string[], catalog: CatalogModel[]): number { +export function visibleCount(entries: string[], catalog: KnownModel[]): number { return catalog.filter((m) => sourceOf(entries, m.id, m.alias) != null).length; } @@ -134,12 +130,12 @@ export function visibleCount(entries: string[], catalog: CatalogModel[]): number * **只按名称数**,别名也按它自己的名称:`claude-sonnet-*` 命中别名 `claude-sonnet-5`, * 不命中只是列表里有个 `claude-sonnet-5` 的别名 `sonnet`(那一个算「随 … 放行」) */ -export function patternHits(pattern: string, catalog: CatalogModel[]): number { +export function patternHits(pattern: string, catalog: KnownModel[]): number { return catalog.filter((m) => globMatch(pattern, m.id)).length; } /** 目录里有没有别名。没有就不必说明别名怎么算 */ -export function hasAliases(catalog: CatalogModel[]): boolean { +export function hasAliases(catalog: KnownModel[]): boolean { return catalog.some((m) => m.alias != null); } diff --git a/src/routing/DryRunDialog.tsx b/src/routing/DryRunDialog.tsx index c1604b3d..db43f1c6 100644 --- a/src/routing/DryRunDialog.tsx +++ b/src/routing/DryRunDialog.tsx @@ -32,7 +32,7 @@ import { targetLabel, translatedText, } from "@/labels"; -import type { Dialect, Overview, 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"; @@ -40,15 +40,14 @@ import { dryRunText } from "./DryRunDialog.i18n"; import { ModelInput, onOpenFocus } from "./fields"; import { DIALECTS, usersOf } from "./model"; import { KeyIcon, TargetIcon } from "./parts"; -import type { DryRunResultX, KnownModelX, RouteInputX } from "./provisional"; import { routingText } from "./routing.i18n"; -import { candidatesOf, type CandidateView } from "./target"; +import { modelViaOf, type ModelVia } from "./target"; /** 按什么求值:密钥使用的路由、指定的路由、路由对话框里还没保存的草稿 */ export type DryRunTarget = | { kind: "key" } | { kind: "route"; name: string } - | { kind: "draft"; route: RouteInputX; keys: string[] }; + | { kind: "draft"; route: RouteInput; keys: string[] }; /** * 试算。回答的不只是「会走到哪儿」,还有**「为什么没走我以为的那条」**: @@ -69,7 +68,7 @@ export function DryRunDialog({ }: { target: DryRunTarget; ov: Overview; - models: KnownModelX[]; + models: KnownModel[]; onClose: () => void; }) { const t = useText(dryRunText); @@ -99,7 +98,7 @@ export function DryRunDialog({ }); const [toolCount, setToolCount] = useState("5"); const [intent, setIntent] = useState(""); - const [r, setR] = useState(null); + const [r, setR] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -331,7 +330,7 @@ function ResultSkeleton() { } /** 结论的语气:转发成了是 ok,拒绝和无路可走是 error,规则没参与的是 idle */ -function verdictTone(r: DryRunResultX): StatusTone { +function verdictTone(r: DryRunResult): StatusTone { switch (r.outcome) { case "route": return "ok"; @@ -348,7 +347,7 @@ function Result({ client, draft, }: { - r: DryRunResultX; + r: DryRunResult; ov: Overview; client: string; draft: boolean; @@ -366,7 +365,7 @@ function Result({ * 的事,而一条规则都没走。留着它们只会让人以为它真的去了那儿。 */ const short = r.outcome === "intercepted"; - const candidates = candidatesOf(r); + const candidates = r.candidate_models; const target = r.outcome === "route" ? (r.via_group ?? candidates[0]?.provider ?? null) : null; // 这一趟经过的路:密钥 → 路由 → 规则 → 去向。和路由图同一套标志 @@ -457,13 +456,14 @@ function Result({ 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 && } + {cv.sent_model && via && } {open && ( @@ -552,15 +552,14 @@ function Result({ ); } -/** 发给这个上游的模型名,和它从哪儿来 */ -function SentModel({ c }: { c: CandidateView }) { +/** 发给这个上游的模型名,和它从哪儿来。和请求里写的一样时不写(core 照样给 `sent_model`) */ +function SentModel({ model, via }: { model: string; via: ModelVia }) { const t = useText(dryRunText); - const via = c.model_via === "alias" || c.model_via === "rule" || c.model_via === "pinned" ? t.via[c.model_via] : null; return ( {t.sends} - {c.sent_model} - {via && t.viaParen(via)} + {model} + {t.viaParen(t.via[via])} ); } @@ -580,9 +579,9 @@ function Step({ children, strong, className }: { children: ReactNode; strong?: b ); } -function headline(r: DryRunResultX): string { +function headline(r: DryRunResult): string { const m = textOf(dryRunText); - const first = candidatesOf(r)[0]; + const first = r.candidate_models[0]; switch (r.outcome) { case "route": if (r.via_group) return m.viaGroup(targetLabel(r.via_group)); diff --git a/src/routing/PinnedModels.tsx b/src/routing/PinnedModels.tsx index c4edd1d2..d5085dde 100644 --- a/src/routing/PinnedModels.tsx +++ b/src/routing/PinnedModels.tsx @@ -4,13 +4,12 @@ import { NativeSelect, NativeSelectOption } from "@/ui/native-select"; import { useText } from "@/i18n"; import { commonText } from "@/i18n/common.i18n"; import { useResource } from "@/lib/resource"; -import type { ProviderView } from "@/types"; +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"; -import type { KnownModelX } from "./provisional"; /** * 「转发至 → 指定模型」:几行「上游 + 模型」,按顺序备用。 @@ -28,7 +27,7 @@ export function PinnedModels({ value: PinnedDraft[]; onChange: (next: PinnedDraft[]) => void; providers: readonly ProviderView[]; - known: readonly KnownModelX[]; + known: readonly KnownModel[]; }) { const t = useText(pinnedModelsText); const patch = (i: number, p: Partial) => @@ -83,7 +82,7 @@ function PinnedRow({ n: number; p: PinnedDraft; providers: readonly ProviderView[]; - known: readonly KnownModelX[]; + known: readonly KnownModel[]; onProvider: (provider: string) => void; onModel: (model: string) => void; onRemove: () => void; diff --git a/src/routing/RouteDialog.tsx b/src/routing/RouteDialog.tsx index d749c747..bb507e5b 100644 --- a/src/routing/RouteDialog.tsx +++ b/src/routing/RouteDialog.tsx @@ -23,7 +23,7 @@ import { useText } from "@/i18n"; import { commonText } from "@/i18n/common.i18n"; import { errorText } from "@/i18n/core.i18n"; import { ALL_UPSTREAMS, conditionText, targetLabel } from "@/labels"; -import type { Overview } from "@/types"; +import type { KnownModel, Overview, RouteInput } from "@/types"; import { FormItem } from "@/upstreams/parts"; import { api } from "./api"; import { ToggleChips, onOpenFocus } from "./fields"; @@ -44,7 +44,6 @@ import { type RuleDraft, } from "./model"; import { KeyChips, KeyIcon, TargetIcon } from "./parts"; -import type { KnownModelX, RouteInputX } from "./provisional"; import { routeDialogText } from "./RouteDialog.i18n"; import { routingText } from "./routing.i18n"; import { RuleDialog } from "./RuleDialog"; @@ -86,7 +85,7 @@ export function RouteDialog({ }: { mode: RouteDialogMode; ov: Overview; - models: KnownModelX[]; + models: KnownModel[]; /** 最近一段时间的命中数(`useRouteHits`) */ hits: RouteHitsWindow; /** 概览里的配置版本。**只取打开那一刻的**(见 `base`) */ @@ -96,7 +95,7 @@ export function RouteDialog({ /** 保存成功,带着保存后的名字 */ onSaved: (name: string) => void; /** 按对话框里还没保存的内容试算 */ - onDryRun: (draft: RouteInputX, keys: string[]) => void; + onDryRun: (draft: RouteInput, keys: string[]) => void; }) { const t = useText(routeDialogText); const rt = useText(routingText); @@ -159,7 +158,7 @@ export function RouteDialog({ const leaving = mode.kind === "edit" && !isDefault ? original.filter((k) => !keys.includes(k)) : []; - function input(): RouteInputX { + function input(): RouteInput { return { name: trimmed, rules: rules.map(draftToInput) }; } @@ -483,7 +482,7 @@ function RuleHitsLine({ n, span }: { n: number; span: HitSpan }) { * 「条件」一栏。模型条件写的是别名时标出来;写的是上游模型名、又有别名指向它时,次行写明 * 也匹配这些别名(继承只从真名到别名,和密钥的可见范围是同一条规矩) */ -function Conditions({ r, known }: { r: RuleDraft; known: readonly KnownModelX[] }) { +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) ?? []; @@ -518,7 +517,7 @@ function Conditions({ r, known }: { r: RuleDraft; known: readonly KnownModelX[] * 「命中后」一栏:去向(或拒绝、继续匹配),次行是去向的说明或附加项。指定模型写成 * 「上游 · 模型」,备用的写在次行 */ -function Action({ r, ov, known }: { r: RuleDraft; ov: Overview; known: readonly KnownModelX[] }) { +function Action({ r, ov, known }: { r: RuleDraft; ov: Overview; known: readonly KnownModel[] }) { const rt = useText(routingText); const t = useText(routeDialogText); const addOns = addOnsText(r, known); diff --git a/src/routing/RoutingPage.tsx b/src/routing/RoutingPage.tsx index e119220f..ed006910 100644 --- a/src/routing/RoutingPage.tsx +++ b/src/routing/RoutingPage.tsx @@ -15,8 +15,7 @@ import { useText } from "@/i18n"; import type { ConfigFocus } from "@/configLocate"; import { targetLabel } from "@/labels"; import { useNav, useNavParams } from "@/nav"; -import type { GroupView, Overview } from "@/types"; -import type { RouteInputX } from "./provisional"; +import type { GroupView, Overview, RouteInput } from "@/types"; import { DeleteDialog } from "@/upstreams/DeleteDialog"; import { api } from "./api"; import type { ChainFocus } from "./chain"; @@ -348,7 +347,7 @@ export default function RoutingPage({ onChanged={onChanged} onClose={() => setDialog(null)} onSaved={(name) => done({ kind: "route", name })} - onDryRun={(route: RouteInputX, keys: string[]) => setDryRun({ kind: "draft", route, keys })} + onDryRun={(route: RouteInput, keys: string[]) => setDryRun({ kind: "draft", route, keys })} /> )} {dialog?.kind === "set-default" && ( diff --git a/src/routing/RuleDialog.tsx b/src/routing/RuleDialog.tsx index 2fe355a1..fe09f671 100644 --- a/src/routing/RuleDialog.tsx +++ b/src/routing/RuleDialog.tsx @@ -24,7 +24,7 @@ import { cn } from "@/lib/utils"; import { textOf, useText } from "@/i18n"; import { commonText } from "@/i18n/common.i18n"; import { PROBES, conditionName, formatLabel, probeLabel, targetLabel } from "@/labels"; -import type { ConditionField, ConditionView, Overview } from "@/types"; +import type { ConditionField, ConditionView, KnownModel, Overview } from "@/types"; import { Segmented } from "@/ui/segmented"; import { FormItem, Note } from "@/upstreams/parts"; import { GroupDialog } from "./GroupDialog"; @@ -49,7 +49,6 @@ import { type ToKind, } from "./model"; import { PinnedModels } from "./PinnedModels"; -import type { KnownModelX } from "./provisional"; import { routingText } from "./routing.i18n"; import { ruleDialogText } from "./RuleDialog.i18n"; import { modelHint, type ModelHint } from "./target"; @@ -88,7 +87,7 @@ export function RuleDialog({ /** 同一条路由里其余规则的名字 */ takenNames: string[]; ov: Overview; - models: KnownModelX[]; + models: KnownModel[]; configVersion: string; /** 在这里新建了策略组:外面要重读概览,路由对话框接着用写完的版本 `version` */ onChanged: (version: string) => void; @@ -429,7 +428,7 @@ function ConditionRow({ }: { c: ConditionView; ov: Overview; - models: KnownModelX[]; + models: KnownModel[]; onChange: (c: ConditionView) => void; onRemove: () => void; }) { diff --git a/src/routing/api.ts b/src/routing/api.ts index 594c3553..2dbbfb1b 100644 --- a/src/routing/api.ts +++ b/src/routing/api.ts @@ -4,12 +4,10 @@ */ import { call } from "@/control"; import type { DryRunRequest, GroupSave, RouteSave } from "@/types"; -import type { DryRunRequestX, DryRunResultX, RouteSaveX } from "./provisional"; -// 规则的 `to` 可以是指定模型的列表:生成的类型换上之前,按 `provisional.ts` 里的写法交出去 export const api = { - createRoute: (save: RouteSaveX) => call("CreateRoute", save as RouteSave), - updateRoute: (name: string, save: RouteSaveX) => call("UpdateRoute", save as RouteSave, name), + createRoute: (save: RouteSave) => call("CreateRoute", save), + updateRoute: (name: string, save: RouteSave) => call("UpdateRoute", save, name), /** `reassignTo` 为空:使用它的密钥改用默认路由 */ deleteRoute: (name: string, baseVersion: string, reassignTo: string | null) => call("DeleteRoute", { base_version: baseVersion, reassign_to: reassignTo }, name), @@ -22,7 +20,7 @@ export const api = { knownModels: () => call("KnownModels", null), /** 一个上游的模型清单(指定模型时从这里选,带上下文窗口) */ providerModels: (name: string) => call("ProviderModels", null, name), - dryRun: (req: DryRunRequestX): Promise => call("DryRun", req as DryRunRequest), + dryRun: (req: DryRunRequest) => call("DryRun", req), /** 从 `fromMs` 到现在,各条路由、各条规则命中了多少,以及记录从哪一刻起是全的 */ routeStats: (fromMs: number) => call("RouteStats", { from_ms: fromMs }), }; diff --git a/src/routing/chain.ts b/src/routing/chain.ts index 91d70fec..26e6e465 100644 --- a/src/routing/chain.ts +++ b/src/routing/chain.ts @@ -13,8 +13,7 @@ * * 转发到指定模型的规则连到列表里的每一个上游(按顺序备用,和策略组连到它的成员一样)。 */ -import type { ClientView, GroupView, Overview, RouteHits } from "@/types"; -import type { RouteViewX, RuleViewX } from "./provisional"; +import type { ClientView, GroupView, Overview, RouteHits, RouteView, RuleView } from "@/types"; import { pinnedOf, targetNameOf } from "./target"; export type ChainKind = "key" | "route" | "group" | "deny" | "via" | "upstream"; @@ -91,7 +90,7 @@ type Target = { kind: "group" | "deny" | "upstream"; name: string }; * 一条规则的去向。不画的规则(见文件头)和指向不存在的去向的没有;指定模型是列表里的 * 那几个上游,按顺序、去重 */ -function targetsOfRule(x: RuleViewX, groups: ReadonlySet, providers: ReadonlySet): Target[] { +function targetsOfRule(x: RuleView, groups: ReadonlySet, providers: ReadonlySet): Target[] { if (x.shadowed || x.phase_two) return []; const pinned = pinnedOf(x.to); if (pinned) { @@ -108,7 +107,7 @@ function targetsOfRule(x: RuleViewX, groups: ReadonlySet, providers: Rea } /** 一条路由把请求交给的去向,按规则顺序、去重 */ -export function targetsOf(r: RouteViewX, groups: ReadonlySet, providers: ReadonlySet): Target[] { +export function targetsOf(r: RouteView, groups: ReadonlySet, providers: ReadonlySet): Target[] { const out: Target[] = []; const seen = new Set(); for (const x of r.rules) @@ -127,7 +126,7 @@ export function orderedRoutes(routes: readonly R } export function buildChain( - ov: Pick & { routes: readonly RouteViewX[] }, + ov: Pick & { routes: readonly RouteView[] }, ): Chain { const routes = orderedRoutes(ov.routes); const routeIndex = new Map(routes.map((r, i) => [r.name, i])); @@ -189,7 +188,7 @@ export function buildChain( } keysOf.set(r, [...(keysOf.get(r) ?? []), k]); } - const tailsOf = (r: RouteViewX): { ids: string[]; live: boolean }[] => { + const tailsOf = (r: RouteView): { ids: string[]; live: boolean }[] => { const out: { ids: string[]; live: boolean }[] = []; for (const t of targets.get(r.name) ?? []) { if (t.kind === "deny") out.push({ ids: stationsOf(t), live: true }); diff --git a/src/routing/fields.test.ts b/src/routing/fields.test.ts index 4e321fee..56ed9012 100644 --- a/src/routing/fields.test.ts +++ b/src/routing/fields.test.ts @@ -29,7 +29,7 @@ describe("模型建议右边的灰字", () => { }); it("目录里的 KnownModel 直接当建议用", () => { - const m: KnownModel = { id: "gpt-5.5", providers: ["chatgpt"] }; + const m: KnownModel = { id: "gpt-5.5", providers: ["chatgpt"], aliases: [] }; const opt: ModelOption = m; expect(modelNote(opt)).toBe("chatgpt"); }); diff --git a/src/routing/model.test.ts b/src/routing/model.test.ts index 29d8466c..83dfafb9 100644 --- a/src/routing/model.test.ts +++ b/src/routing/model.test.ts @@ -20,7 +20,6 @@ import { usersOf, type RuleDraft, } from "./model"; -import type { RouteViewX, RuleViewX } from "./provisional"; function view(p: Partial & { name: string }): RuleView { return { conditions: [], catch_all: false, phase_two: false, shadowed: false, ...p }; @@ -75,7 +74,7 @@ describe("规则草稿", () => { { provider: "bedrock", model: "us.anthropic.claude-opus-5-v1:0" }, { provider: "anthropic", model: "claude-opus-5" }, ]; - const v: RuleViewX = { + const v: RuleView = { name: "Opus 走 Bedrock", conditions: [model("claude-opus-5")], to, @@ -227,7 +226,7 @@ describe("路由列表", () => { }); it("规则一栏里指定模型写成「上游 · 模型」,备用的只说个数", () => { - const r: RouteViewX = { + const r: RouteView = { ...route({}), rules: [ { diff --git a/src/routing/model.ts b/src/routing/model.ts index b53b3fb5..3d00ccf7 100644 --- a/src/routing/model.ts +++ b/src/routing/model.ts @@ -5,22 +5,11 @@ * 能不能用、条件写得对不对,最后由 core 说;这里只做对话框里需要实时给出 * 的那几件事:保存按钮旁边缺什么、哪条规则被兜底挡住。 */ -import type { - ClientView, - ConditionField, - ConditionView, - Dialect, - GroupKind, - GroupView, - ProviderView, - RouteView, -} from "@/types"; -import type { PinnedModel } from "@/aliases/api.provisional"; +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 type { KnownModelX, RouteViewX, RuleInputX, RuleViewX } from "./provisional"; import { routingText } from "./routing.i18n"; import { aliasNamed, hasTarget, pinnedOf, pinnedText, targetNameOf } from "./target"; @@ -179,7 +168,7 @@ export function blankPinned(provider = "", model = ""): PinnedDraft { return { key: nextKey(), provider, model }; } -export function draftFromView(r: RuleViewX): RuleDraft { +export function draftFromView(r: RuleView): RuleDraft { const pinned = pinnedOf(r.to); return { key: nextKey(), @@ -228,7 +217,7 @@ export function isPhaseTwo(d: RuleDraft): boolean { } /** 草稿 → 交给 core 的规则 */ -export function draftToInput(d: RuleDraft): RuleInputX { +export function draftToInput(d: RuleDraft): RuleInput { const conditions = d.conditions.map((c) => { const values = c.values.map((v) => v.trim()).filter(Boolean); if (condField(c.field).kind === "compare") { @@ -368,7 +357,7 @@ export function usersOf(route: RouteView, clients: ClientView[]): string[] { * `name` 是配置里的去向(拿来认上游的标志;指定模型时是第一个上游),`target` 是显示的 * 名字;拒绝时两个都是空。 */ -export function flowOf(route: RouteViewX): { rule: string; name: string | null; target: string | null }[] { +export function flowOf(route: RouteView): { rule: string; name: string | null; target: string | null }[] { return route.rules .filter((r) => !r.shadowed && !r.phase_two && (hasTarget(r.to) || r.deny != null)) .map((r) => { @@ -429,7 +418,7 @@ export function membersText(g: Pick & { to?: RuleTarget | null }; -export type RuleInputX = Omit & { to?: RuleTarget | null }; -export type RouteViewX = Omit & { rules: Array }; -export type RouteInputX = Omit & { rules: Array }; -export type RouteSaveX = Omit & { route: RouteInputX }; -export type DryRunRequestX = Omit & { draft?: RouteInputX | null }; - -/** `/models` 的一项。`alias` 有值时这一项本身是别名,值是它的模型列表;`aliases` 是指向这个真名的别名 */ -export type KnownModelX = KnownModel & Partial; - -/** 发出的模型名从哪儿来:别名表、规则改写(`set.model`)、指定模型 */ -export type ModelVia = "alias" | "rule" | "pinned"; - -/** 试算里一个候选发出的模型。和客户端写的名称相同时 core 不给 `sent_model` */ -export type CandidateModel = { sent_model?: string | null; model_via?: string | null }; - -/** - * 试算结果。约定里写的是「每个候选加 `sent_model`、`model_via`」,而现在的 `candidates` 是 - * 上游名的列表:这里按**和 `candidates` 一一对应的 `candidate_models`** 读;core 若改成 - * 候选本身带这两个字段,`candidatesOf`(`target.ts`)两种都认。集成时按生成的类型定下一种。 - */ -export type DryRunResultX = DryRunResult & { candidate_models?: Array | null }; diff --git a/src/routing/target.test.ts b/src/routing/target.test.ts index 77064bd7..c5110554 100644 --- a/src/routing/target.test.ts +++ b/src/routing/target.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; -import type { KnownModelX } from "./provisional"; -import { aliasNamed, candidatesOf, hasTarget, inheritedAliases, modelHint, pinnedOf, targetNameOf } from "./target"; +import type { KnownModel } from "@/types"; +import { aliasNamed, hasTarget, inheritedAliases, modelHint, modelViaOf, pinnedOf, targetNameOf } from "./target"; // `/models` 的目录:别名项带 `alias`(它的模型列表),真名带 `aliases`(指向它的别名) -const real = (id: string, aliases: string[] = [], providers = ["p"]): KnownModelX => ({ id, providers, aliases }); -const alias = (id: string, models: string[], providers = ["p"]): KnownModelX => ({ id, providers, alias: models, aliases: [] }); +const real = (id: string, aliases: string[] = [], providers = ["p"]): KnownModel => ({ id, providers, aliases }); +const alias = (id: string, models: string[], providers = ["p"]): KnownModel => ({ id, providers, alias: models, aliases: [] }); -const KNOWN: KnownModelX[] = [ +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"]), @@ -87,34 +87,14 @@ describe("模型条件的提示", () => { }); describe("试算的候选", () => { - const base = { - route: "default", - outcome: "route" as const, - rule: "x", - reason: null, - via_group: null, - set: [], - trace: [], - circuit_open: [], - skipped: [], - converted: [], - }; - - it("和候选一一对应的发出模型", () => { - const r = { - ...base, - candidates: ["openrouter", "anthropic"], - candidate_models: [{ sent_model: "anthropic/claude-opus-5", model_via: "alias" }, null], - }; - expect(candidatesOf(r)).toEqual([ - { provider: "openrouter", sent_model: "anthropic/claude-opus-5", model_via: "alias" }, - { provider: "anthropic" }, - ]); - expect(candidatesOf({ ...base, candidates: ["a"] })).toEqual([{ provider: "a" }]); - }); - - it("候选本身带着发出模型的写法也认", () => { - const c = { provider: "bedrock", sent_model: "us.anthropic.claude-opus-5-v1:0", model_via: "pinned" }; - expect(candidatesOf({ ...base, candidates: [c] as unknown as string[] })).toEqual([c]); + 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 index 4f11575d..cd0ad113 100644 --- a/src/routing/target.ts +++ b/src/routing/target.ts @@ -2,8 +2,7 @@ * 规则的去向有两种:上游或策略组的名称,或者指定模型的列表(上游 + 模型,按顺序备用)。 * 读去向的地方都经过这里,不各自去猜 `to` 是哪一种。**纯函数**,路由图也用。 */ -import type { PinnedModel, RuleTarget } from "@/aliases/api.provisional"; -import type { CandidateModel, DryRunResultX, KnownModelX } from "./provisional"; +import type { DryRunCandidate, KnownModel, PinnedModel, RuleTarget } from "@/types"; import { globMatch } from "@/upstreams/glob"; /** 指定模型的列表。去向是上游或策略组、或者没有去向时为空 */ @@ -30,7 +29,7 @@ export function pinnedText(p: PinnedModel): string { // ---------------------------------------------------------------- 别名 /** 这个名称本身是别名(别名优先:和某个上游模型同名时也算别名) */ -export function aliasNamed(name: string, known: readonly KnownModelX[]): KnownModelX | undefined { +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)); @@ -41,7 +40,7 @@ export function aliasNamed(name: string, known: readonly KnownModelX[]): KnownMo * 但它的模型列表里有匹配的。写别名时只管别名本身,这里为空。和 core 的规则条件、 * 密钥范围是同一条规矩(真名 → 别名,单向)。 */ -export function inheritedAliases(patterns: readonly string[], known: readonly KnownModelX[]): string[] { +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)); @@ -54,7 +53,7 @@ export function inheritedAliases(patterns: readonly string[], known: readonly Kn if (m.alias.some(hit)) add(m.id); } else if (hit(m.id)) { // 清单里没有的模型名也可能被别名列着:两边都看,取并集 - for (const a of m.aliases ?? []) add(a); + for (const a of m.aliases) add(a); } } return out; @@ -71,7 +70,7 @@ export type ModelHint = | { kind: "models"; matches: number; inherited: string[]; exact: boolean }; /** 已知模型为空(还没取到)时不给提示 */ -export function modelHint(pattern: string, known: readonly KnownModelX[]): ModelHint | null { +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); @@ -86,16 +85,12 @@ export function modelHint(pattern: string, known: readonly KnownModelX[]): Model // ---------------------------------------------------------------- 试算 -export interface CandidateView extends CandidateModel { - provider: string; -} +/** 发出的模型名为什么和请求里写的不一样:别名、规则改写(`set.model`)、指定模型 */ +export type ModelVia = "alias" | "rule" | "pinned"; -/** - * 试算的候选,带上每一个发出的模型。两种写法都认:和 `candidates` 一一对应的 - * `candidate_models`,或者候选本身就是 `{ provider, sent_model, model_via }`(见 `provisional.ts`) - */ -export function candidatesOf(r: DryRunResultX): CandidateView[] { - return (r.candidates as Array).map((c, i) => - typeof c === "string" ? { provider: c, ...(r.candidate_models?.[i] ?? {}) } : c, - ); +/** 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/modelVia.test.ts b/src/traffic/modelVia.test.ts index 051b57a0..880fb7c7 100644 --- a/src/traffic/modelVia.test.ts +++ b/src/traffic/modelVia.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from "vitest"; -import type { KnownModelAliasFields } from "@/aliases/api.provisional"; import type { AttemptView, HistoryRow, KnownModel, PluginRunView, RequestRow, RoutingView } from "@/types"; import { aliasTable, hopVia, routingModels, rowMark, type HopFacts, type ViaConfig } from "./modelVia"; @@ -43,14 +42,13 @@ const hop = (over: Partial): HopFacts => ({ describe("别名表", () => { it("从 GET /models 里挑出带模型列表的那几项", () => { - const models: Array> = [ + 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"]]]); - // core 还没给这两个字段(现在的生成类型)、或者目录取不到:一个别名都没有 - expect(aliasTable([{ id: "gpt-5.5", providers: ["chatgpt"] }]).size).toBe(0); + // 目录取不到:一个别名都没有 expect(aliasTable(undefined).size).toBe(0); }); }); diff --git a/src/traffic/modelVia.ts b/src/traffic/modelVia.ts index 7ccae0e0..de3426ac 100644 --- a/src/traffic/modelVia.ts +++ b/src/traffic/modelVia.ts @@ -1,5 +1,4 @@ -import type { KnownModelAliasFields, RuleTarget } from "@/aliases/api.provisional"; -import type { HistoryRow, KnownModel, PluginRunView, RequestRow } from "@/types"; +import type { HistoryRow, KnownModel, PluginRunView, RequestRow, RuleTarget } from "@/types"; /** * 发给上游的模型名为什么和客户端写的不一样,或者为什么一样也值得一提。 @@ -32,13 +31,10 @@ export interface RouteLike { }[]; } -/** `GET /models` 里的一项。别名那一项带着它的模型名列表(core 合入别名之后生成的类型里才有) */ -type ModelItem = KnownModel & Partial; - /** 从 `GET /models` 里挑出别名:别名 → 它的模型名 */ export function aliasTable(models: readonly KnownModel[] | undefined): Map { const out = new Map(); - for (const m of (models ?? []) as readonly ModelItem[]) { + for (const m of models ?? []) { if (m.alias && m.alias.length > 0) out.set(m.id, m.alias); } return out; diff --git a/src/upstreams/ModelsPanel.tsx b/src/upstreams/ModelsPanel.tsx index 4c203624..0b3db228 100644 --- a/src/upstreams/ModelsPanel.tsx +++ b/src/upstreams/ModelsPanel.tsx @@ -11,7 +11,6 @@ import { StatusLabel } from "@/ui/status-dot"; import { textOf, useText } from "@/i18n"; import { commonText } from "@/i18n/common.i18n"; import type { ModelRow, ProviderModelsView, ProviderView } from "@/types"; -import type { ModelRowAliasFields } from "@/aliases/api.provisional"; import { api } from "./api"; import { contextWindow, coreText, errorText, perMillion } from "./labels"; import { modelsPanelText } from "./ModelsPanel.i18n"; @@ -231,7 +230,7 @@ function Row({ perToken, onAlias, }: { - m: ModelRow & Partial; + m: ModelRow; perToken: boolean; onAlias?: (model: string) => void; }) { @@ -240,7 +239,6 @@ function Row({ perToken && m.price ? `$${perMillion(m.price.input)} / $${perMillion(m.price.output)}${m.estimated ? t.estimated : ""}` : null; - const aliases = m.aliases ?? []; return (
{m.id} - {aliases.map((a) => ( + {m.aliases.map((a) => ( Date: Mon, 5 Oct 2026 10:51:56 +0800 Subject: [PATCH 11/18] Translate the model-alias messages from core Chinese for the eight config.alias_* and six engine.pinned_* checks, the four gw.model admission errors, control.replay_alias_unserved and gw.plugin.alias_unserved, and 'alias' in the kind vocabulary used by config.edit.* and control.name_*. Co-Authored-By: Claude Opus 5.5 --- src/i18n/core.zh.json | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/i18n/core.zh.json b/src/i18n/core.zh.json index ac4725d8..9f2cdb64 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,10 @@ "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.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 +432,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} 当前不是本机的地址。", @@ -594,6 +601,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}」重复。", @@ -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\"。", From 90fac2bede5d692bd690cb8b5c9762766fc64498 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:55:02 +0800 Subject: [PATCH 12/18] Claude Desktop rule: read and write routes with the core types desktop_rule.rs drops its own copies of the route, rule, key and model types and calls ep::Overview, ep::KnownModels and ep::UpdateRoute directly; rules read as RuleView are written back as RuleInput. The pinned models shown in the confirmation are tw_api::PinnedModel, so lite-api.ts keeps importing that type from tw-api.ts. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/clients/desktop_rule.rs | 387 ++++++++++++-------------- src-tauri/src/wire.rs | 9 +- 2 files changed, 175 insertions(+), 221 deletions(-) diff --git a/src-tauri/src/clients/desktop_rule.rs b/src-tauri/src/clients/desktop_rule.rs index da153d59..a781a23e 100644 --- a/src-tauri/src/clients/desktop_rule.rs +++ b/src-tauri/src/clients/desktop_rule.rs @@ -24,136 +24,33 @@ //! //! 先有规则再写它的配置(和「先有钥匙」同一个道理);配置没写成就把规则改回去。还原时先 //! 还原文件、再删规则 —— 退路不依赖网关,删不成就提醒去路由页删。 -//! -//! **临时类型**:规则的去向(`RuleView.to` / `RuleInput.to`)在 core 的模型别名 -//! (ThinkWatch-Core#283,协议 38)里变成「名称,或者指定模型的列表」;钉着的那版 tw-api -//! 还读不了列表,所以这里按约定的 JSON 自己读写 `/overview`、`/models`、`PUT /routes/{name}` -//! 用到的那几个字段([`Rule`]、[`RuleTarget`])。core 发版后换成 `tw_api::RuleTarget`、 -//! `tw_api::PinnedModel` 和 `ep::Overview` / `ep::KnownModels` / `ep::UpdateRoute`。 - -use serde::{Deserialize, Serialize}; -use tw_api::{Endpoint, ep}; + +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, PinnedModel}; +use crate::wire::{DesktopPick, DesktopRule, ModelChoice}; /// 那条规则的名字 pub const RULE_NAME: &str = "Claude Desktop"; -// ---------------------------------------------------------------- 临时类型 - -/// 规则的去向:上游或策略组的名称,或者指定模型的列表(约定里的 `RuleTarget`) -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(untagged)] -pub enum RuleTarget { - Name(String), - Models(Vec), -} - -/// 规则里的一个条件(`tw_api::ConditionView`)。字段按字符串读:新版 core 多了 -/// 条件种类时照样读得进来、原样写回去 -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Condition { - pub field: String, - pub values: Vec, -} - -/// 一条规则。**读 `RuleView`、写 `RuleInput` 是同一套字段**(路由页也是这样往返的), -/// 只读视图里的那几项(`catch_all`、`phase_two`、`shadowed`)不要。改写原样往返 -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Rule { - pub name: String, - #[serde(default)] - pub conditions: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub to: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub deny: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub set: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Route { - pub name: String, - pub rules: Vec, -} - -/// 一把网关密钥:名字、为哪个客户端发的、绑的哪条路由 -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Key { - pub name: String, - #[serde(default)] - pub client: Option, - #[serde(default)] - pub route: Option, -} - -/// `/overview` 里用得上的那几项 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Overview { - pub config_version: String, - pub routes: Vec, - pub clients: Vec, - pub default_route: String, -} - -/// `/models` 的一项。`alias` 有值的是别名,不是哪家上游的模型 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Known { - pub id: String, - pub providers: Vec, - #[serde(default)] - pub alias: Option>, -} - -#[derive(Debug, Clone, Serialize)] -struct RouteSave<'a> { - route: RouteInput<'a>, - base_version: &'a str, -} - -#[derive(Debug, Clone, Serialize)] -struct RouteInput<'a> { - name: &'a str, - rules: &'a [Rule], -} - -/// `ep::*` 那个端点的方法和路径,请求和响应换成这里的临时类型 -macro_rules! provisional { - ($name:ident = $real:ty, $req:ty => $res:ty) => { - struct $name; - impl Endpoint for $name { - const METHOD: tw_api::Method = <$real as Endpoint>::METHOD; - const PATH: &'static str = <$real as Endpoint>::PATH; - const PARAMS: &'static [&'static str] = <$real as Endpoint>::PARAMS; - const FORMAT: tw_api::Format = <$real as Endpoint>::FORMAT; - const NAME: &'static str = <$real as Endpoint>::NAME; - type Req = $req; - type Res = $res; - } - }; -} - -provisional!(OverviewNow = ep::Overview, () => Overview); -provisional!(KnownNow = ep::KnownModels, () => Vec); -provisional!(SaveRoute = ep::UpdateRoute, serde_json::Value => tw_api::ConfigWritten); - // ---------------------------------------------------------------- 网关此刻的样子 -/// 算这条规则要知道的:路由、密钥([`Overview`])和各家上游提供的模型([`Known`]) +/// 算这条规则要知道的:路由、密钥(`/overview`)和各家上游提供的模型(`/models`) #[derive(Debug, Clone)] pub struct Snapshot { pub overview: Overview, - pub known: Vec, + pub known: Vec, } /// 一起问 pub async fn snapshot(control: &ControlClient) -> Result { let (overview, known) = tokio::join!( - control.call::(&[], &()), - control.call::(&[], &()) + control.call::(&[], &()), + control.call::(&[], &()) ); Ok(Snapshot { overview: overview.map_err(failed)?, @@ -176,7 +73,7 @@ impl Snapshot { .unwrap_or(&self.overview.default_route) } - fn route(&self, name: &str) -> Option<&Route> { + fn route(&self, name: &str) -> Option<&RouteView> { self.overview.routes.iter().find(|r| r.name == name) } @@ -203,13 +100,30 @@ impl Snapshot { } } +/// 读到的一条规则写回去的样子。**读 `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: &Rule, key: &str) -> bool { - matches!(rule.conditions.as_slice(), [c] if c.field == "client" && c.values == [key]) +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: &Rule, key: &str) -> bool { +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()) @@ -229,7 +143,7 @@ pub fn pinned(choice: &ModelChoice) -> Vec { .collect() } -fn models_of(rule: &Rule) -> Option> { +fn models_of(rule: &RuleInput) -> Option> { match &rule.to { Some(RuleTarget::Models(ms)) => Some(ms.clone()), _ => None, @@ -268,7 +182,7 @@ pub fn view( pick: Option<&tw_adopt::desktop::ModelPick>, ) -> Result, Msg> { let shown = - |route: &str, i: usize, rule: Option<&Rule>, pick: Option| DesktopRule { + |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(), @@ -282,8 +196,9 @@ pub fn view( let Some(route) = s.route(name) else { return Ok(None); }; - let ours = route.rules.iter().position(|r| is_ours(r, key)); - if ours.is_none() && route.rules.iter().any(|r| r.name == RULE_NAME) { + 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 { @@ -291,41 +206,38 @@ pub fn view( choices: s.choices(&p.listed), }; let i = ours.unwrap_or(0); - Ok(Some(shown( - name, - i, - ours.map(|i| &route.rules[i]), - Some(pick), - ))) + Ok(Some(shown(name, i, ours.map(|i| &rules[i]), Some(pick)))) } None => { Ok(find_ours(s, key) - .map(|(route, i)| shown(&route.name, i, Some(&route.rules[i]), None))) + .map(|(route, rules, i)| shown(&route.name, i, Some(&rules[i]), None))) } } } -/// 我们那条在哪:这把密钥所用的路由先找,再找别的(密钥后来换了路由) -fn find_ours<'a>(s: &'a Snapshot, key: &str) -> Option<(&'a Route, usize)> { +/// 我们那条在哪:这把密钥所用的路由先找,再找别的(密钥后来换了路由)。交回那条路由、 +/// 它写回去的规则和我们那条的位置 +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 { - if let Some(i) = r.rules.iter().position(|x| is_ours(x, key)) { - return Some((r, i)); + 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, PartialEq)] +#[derive(Debug, Clone)] pub struct Change { pub route: String, - pub before: Vec, - pub after: Vec, + pub before: Vec, + pub after: Vec, pub base_version: String, } @@ -361,7 +273,8 @@ pub fn change( let to = pinned(choice); let name = s.route_of(key); let route = s.route(name).ok_or_else(|| route_missing(name))?; - let mut after = route.rules.clone(); + 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), // 用户挪过它的位置:留在原处,只换模型 @@ -369,10 +282,10 @@ pub fn change( None if after.iter().any(|r| r.name == RULE_NAME) => return Err(name_taken(name)), None => after.insert( 0, - Rule { + RuleInput { name: RULE_NAME.to_string(), - conditions: vec![Condition { - field: "client".into(), + conditions: vec![ConditionView { + field: ConditionField::Client, values: vec![key.to_string()], }], to: Some(RuleTarget::Models(to)), @@ -383,7 +296,7 @@ pub fn change( } Ok(Some(Change { route: name.to_string(), - before: route.rules.clone(), + before, after, base_version: version, })) @@ -392,16 +305,16 @@ pub fn change( /// 删掉我们那条(还原时,或者不再需要它时):一次一条路由,先找这把密钥所用的那条。 /// 那条路由里我们的规则一起删。没有就是 `None` pub fn removal(s: &Snapshot, key: &str) -> Option { - let (route, _) = find_ours(s, key)?; + 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: route.rules.clone(), - after: route - .rules - .iter() - .filter(|r| !is_ours(r, key)) - .cloned() - .collect(), + before, + after, base_version: s.overview.config_version.clone(), }) } @@ -412,20 +325,23 @@ fn route_missing(route: &str) -> Msg { // ---------------------------------------------------------------- 写 +/// 只换这条路由的规则。**不带 `keys`**:密钥用哪条路由不动 async fn save( control: &ControlClient, route: &str, - rules: &[Rule], + rules: &[RuleInput], base_version: &str, ) -> Result { let save = RouteSave { - route: RouteInput { name: route, rules }, - base_version, + route: RouteInput { + name: route.to_string(), + rules: rules.to_vec(), + }, + base_version: Some(base_version.to_string()), + keys: None, }; - let req = serde_json::to_value(&save) - .map_err(|e| crate::error::CmdError::plain(e.to_string()).into_msg())?; Ok(control - .call::(&[route], &req) + .call::(&[route], &save) .await .map_err(failed)? .version) @@ -507,13 +423,13 @@ mod tests { use super::*; use tw_adopt::desktop::ModelPick; - fn rule(name: &str, key: Option<&str>, to: Option) -> Rule { - Rule { + fn rule(name: &str, key: Option<&str>, to: Option) -> RuleInput { + RuleInput { name: name.into(), conditions: key .map(|k| { - vec![Condition { - field: "client".into(), + vec![ConditionView { + field: ConditionField::Client, values: vec![k.into()], }] }) @@ -524,6 +440,47 @@ mod tests { } } + /// 写进配置之后 `/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(), @@ -535,10 +492,8 @@ mod tests { /// 没绑路由,`cd-2` 绑了 `codex`;glm-4.6 由 zai、bigmodel 提供,kimi-k2 由 moonshot, /// `fast` 是别名 fn gateway() -> Snapshot { - let json = serde_json::json!({ - "config_version": "v1", - "default_route": "default", - "routes": [ + 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-*"]}], @@ -548,21 +503,20 @@ mod tests { {"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"}]} - ], - "clients": [ + "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} - ], - "providers": [], "listen": {} - }); + ]), + ); 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": []} + {"id": "orphan", "providers": [], "aliases": []} ])) .unwrap(), } @@ -627,7 +581,7 @@ mod tests { assert_eq!(c.route, "default"); assert_eq!(c.base_version, "v1"); assert_eq!(c.after.len(), 4); - assert_eq!(&c.after[1..], &c.before[..]); + assert_eq!(json(&c.after[1..]), json(&c.before)); assert_eq!( serde_json::to_value(&c.after[0]).unwrap(), serde_json::json!({ @@ -662,11 +616,11 @@ mod tests { fn with_ours(s: &mut Snapshot, at: usize, to: Vec) { s.overview.routes[0].rules.insert( at, - rule( + view_of(rule( RULE_NAME, Some("claude-desktop"), Some(RuleTarget::Models(to)), - ), + )), ); } @@ -681,9 +635,10 @@ mod tests { assert_eq!(v.position, 2); assert_eq!(v.current, Some(vec![pin("moonshot", "kimi-k2")])); let p = pick(&["glm-4.6", "kimi-k2"]); - assert_eq!( - change(&s, "x", "claude-desktop", Some(&p), Some("kimi-k2")).unwrap(), - None + 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() @@ -698,7 +653,11 @@ mod tests { fn without_a_pick_the_rule_is_taken_out() { let s = gateway(); assert_eq!(view(&s, "claude-desktop", None).unwrap(), None); - assert_eq!(change(&s, "x", "claude-desktop", None, 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")]); @@ -708,8 +667,14 @@ mod tests { let c = change(&s, "x", "claude-desktop", None, None) .unwrap() .unwrap(); - assert_eq!(c.after, gateway().overview.routes[0].rules); - assert_eq!(removal(&s, "claude-desktop"), Some(c)); + 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)) + ); } /// 用户改过的不算我们的:改了名字、条件、改成转发到上游、加了改写或拒绝的都不删; @@ -739,19 +704,22 @@ mod tests { key )); let mut more = rule(RULE_NAME, Some(key), models()); - more.conditions.push(Condition { - field: "model".into(), + 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(serde_json::json!({"max_tokens": 1000})); + 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, set.clone()); - assert_eq!(removal(&s, key), None); + 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!( @@ -766,7 +734,7 @@ mod tests { ); // 另一条路由里的同名规则碍不着 let mut s = gateway(); - s.overview.routes[1].rules.insert(0, set); + s.overview.routes[1].rules.insert(0, view_of(set)); assert!( change(&s, "x", key, Some(&p), Some("glm-4.6")) .unwrap() @@ -780,17 +748,17 @@ mod tests { let mut s = gateway(); s.overview.routes[1].rules.insert( 0, - rule( + 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!( - c.after, - vec![rule("rest", None, Some(RuleTarget::Name("chatgpt".into())))] + 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"); @@ -815,25 +783,13 @@ mod tests { ); } // 一个都没得选:照写兜底的名字,不加规则 - assert_eq!( - change(&s, "x", "claude-desktop", Some(&pick(&["nope"])), None).unwrap(), - None + assert!( + change(&s, "x", "claude-desktop", Some(&pick(&["nope"])), None) + .unwrap() + .is_none() ); } - /// 去向两种写法都读得进来:名称是字符串,指定模型是列表 - #[test] - fn both_shapes_of_a_target_are_read() { - let r: Rule = serde_json::from_value(serde_json::json!({ - "name": "a", "conditions": [], "to": [{"provider": "p", "model": "m"}], "catch_all": true - })) - .unwrap(); - assert_eq!(r.to, Some(RuleTarget::Models(vec![pin("p", "m")]))); - let r: Rule = - serde_json::from_value(serde_json::json!({"name": "b", "to": "main"})).unwrap(); - assert_eq!(r.to, Some(RuleTarget::Name("main".into()))); - } - // ------------------------------------------------------------ 对着一个假的 core use std::sync::{Arc, Mutex}; @@ -852,10 +808,7 @@ mod tests { const KEY: &str = "abababababababababababababababababababababababababababababababab"; fn json_of(s: &Snapshot) -> (serde_json::Value, serde_json::Value) { - ( - serde_json::to_value(&s.overview).unwrap(), - serde_json::to_value(&s.known).unwrap(), - ) + (json(&s.overview), json(&s.known)) } /// 握手照真的来(`tw_link`),之后按 HTTP/1.1 一问一答:控制面客户端每次调用新建一条连接 @@ -973,7 +926,15 @@ mod tests { 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(); - r["rules"] = body["route"]["rules"].clone(); + // 存进去的是 `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", @@ -998,7 +959,7 @@ mod tests { })) } - fn rules_now(c: &Arc>, route: &str) -> Vec { + fn rules_now(c: &Arc>, route: &str) -> Vec { let c = c.lock().unwrap(); let r = c.overview["routes"] .as_array() @@ -1043,7 +1004,7 @@ mod tests { assert!(sent.get("keys").is_none(), "不动密钥的路由选择"); let now = rules_now(&fake, "default"); assert!(is_ours(&now[0], "claude-desktop")); - assert_eq!(&now[1..], &before[..]); + assert_eq!(json(&now[1..]), json(&before)); // 再看一遍:确认框里给的就是这一条 let s = snapshot(&control).await.unwrap(); @@ -1051,7 +1012,7 @@ mod tests { assert_eq!((v.route.as_str(), v.position), ("default", 1)); assert_eq!(remove_after_restore(&control, "claude-desktop").await, None); - assert_eq!(rules_now(&fake, "default"), before); + 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); @@ -1076,7 +1037,7 @@ mod tests { .unwrap(); let v = write(&control, &c).await.unwrap(); revert(&control, &c, &v).await.unwrap(); - assert_eq!(rules_now(&fake, "default"), before); + assert_eq!(json(&rules_now(&fake, "default")), json(&before)); // 那之后别人又改过:core 拒绝,不覆盖别人的改动 let s = snapshot(&control).await.unwrap(); let c = change( diff --git a/src-tauri/src/wire.rs b/src-tauri/src/wire.rs index 0be73d28..bb371632 100644 --- a/src-tauri/src/wire.rs +++ b/src-tauri/src/wire.rs @@ -324,7 +324,7 @@ pub struct DesktopRule { pub pick: Option, /// 已经有这条规则时,它此刻指定的模型 #[serde(default, skip_serializing_if = "Option::is_none")] - pub current: Option>, + pub current: Option>, } /// 给 Claude Desktop 选模型:写进它配置的那个名称,和它的密钥能用的上游模型 @@ -343,13 +343,6 @@ pub struct ModelChoice { pub providers: Vec, } -/// 规则去向里的一个「指定模型」:发到这家上游的这个模型,模型名原样发出 -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] -pub struct PinnedModel { - pub provider: String, - pub model: String, -} - /// 按客户端原来直连 Bedrock 时的设置新建 Bedrock 上游,要填的那几项。 /// /// **凭据只有 `${变量名}` 和 profile 的名字**:客户端配置里写着的明文密钥不抄,也不经过这里 From 44522b3731ea3c963de6503048d81cfe161b536d Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:57:58 +0800 Subject: [PATCH 13/18] One alias mark for every place that shows it src/aliases/AliasMark.tsx replaces the five marks the lanes drew on their own (key scope, model input, upstream models popover, rule conditions, traffic upstream cell): a small secondary badge in the label size. The traffic cell's Pinned mark shares it, since both explain why the model sent differs from the one the client asked for. Co-Authored-By: Claude Opus 5.5 --- src/aliases/AliasMark.tsx | 27 +++++++++++++++++++++++++++ src/keys/ModelScope.i18n.ts | 2 -- src/keys/ModelScope.tsx | 11 ++--------- src/routing/RouteDialog.i18n.ts | 2 -- src/routing/RouteDialog.tsx | 5 ++--- src/routing/fields.tsx | 17 ++--------------- src/traffic/RequestTable.tsx | 10 +++++----- src/traffic/Traffic.i18n.tsx | 2 -- src/upstreams/ModelsPanel.tsx | 9 ++++----- 9 files changed, 42 insertions(+), 43 deletions(-) create mode 100644 src/aliases/AliasMark.tsx 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/keys/ModelScope.i18n.ts b/src/keys/ModelScope.i18n.ts index 29ab2416..0cdddf24 100644 --- a/src/keys/ModelScope.i18n.ts +++ b/src/keys/ModelScope.i18n.ts @@ -28,7 +28,6 @@ export const modelScopeText = messages( picked: "单独选中", /** 别名随它列表里的某个模型一起可见 */ inherited: (m: string) => `随 ${m} 放行`, - alias: "别名", aliasOf: (models: string[]) => `别名,指向 ${models.join("、")}`, /** 表格下面一句:继承只从上游模型名到别名 */ aliasRule: "范围里有某个上游模型,指向它的别名也一起可见;只选别名,原来的名称不可见。", @@ -68,7 +67,6 @@ export const modelScopeText = messages( byPattern: (p: string) => `matched by ${p}`, picked: "picked", inherited: (m: string) => `allowed with ${m}`, - alias: "alias", 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.", diff --git a/src/keys/ModelScope.tsx b/src/keys/ModelScope.tsx index 063b1aec..8b4f9757 100644 --- a/src/keys/ModelScope.tsx +++ b/src/keys/ModelScope.tsx @@ -1,12 +1,12 @@ import { useMemo, useState } from "react"; import { SearchIcon, Trash2Icon } from "lucide-react"; -import { Badge } from "@/ui/badge"; import { Button } from "@/ui/button"; import { Checkbox } from "@/ui/checkbox"; 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 { Boxed, FormItem, Note } from "@/upstreams/parts"; import type { KnownModel } from "@/types"; @@ -198,14 +198,7 @@ export function ModelScope({ {r.id} {r.alias && ( - - {t.alias} - + )} diff --git a/src/routing/RouteDialog.i18n.ts b/src/routing/RouteDialog.i18n.ts index f1419454..09357392 100644 --- a/src/routing/RouteDialog.i18n.ts +++ b/src/routing/RouteDialog.i18n.ts @@ -42,7 +42,6 @@ export const routeDialogText = messages( addCatchAll: "添加兜底规则", untitled: "新路由", conditionJoin: " 且 ", - alias: "别名", alsoAliasesOf: (aliases: string[]) => `也匹配指向它的别名 ${aliases.join("、")}`, alsoAliases: (aliases: string[]) => `也匹配指向这些模型的别名 ${aliases.join("、")}`, backups: (list: string[]) => `备用 ${list.join("、")}`, @@ -91,7 +90,6 @@ export const routeDialogText = messages( addCatchAll: "Add catch-all rule", untitled: "Untitled", conditionJoin: " and ", - alias: "Alias", alsoAliasesOf: (aliases: string[]) => `Also matches the ${aliases.length === 1 ? "alias that points" : "aliases that point"} to it: ${aliases.join(", ")}`, alsoAliases: (aliases: string[]) => diff --git a/src/routing/RouteDialog.tsx b/src/routing/RouteDialog.tsx index bb507e5b..1ea6177b 100644 --- a/src/routing/RouteDialog.tsx +++ b/src/routing/RouteDialog.tsx @@ -1,5 +1,6 @@ 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"; @@ -497,9 +498,7 @@ function Conditions({ r, known }: { r: RuleDraft; known: readonly KnownModel[] } {i > 0 && t.conditionJoin} {conditionText(c)} {c === model && alias && ( - - {t.alias} - + )} ))} diff --git a/src/routing/fields.tsx b/src/routing/fields.tsx index 7de839fe..32844de2 100644 --- a/src/routing/fields.tsx +++ b/src/routing/fields.tsx @@ -2,10 +2,10 @@ * 路由页几个对话框共用的小件:带建议的模型输入、一排可切换的名称标签。 */ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { AliasMark } from "@/aliases/AliasMark"; import { cn } from "@/lib/utils"; import { textOf, useText } from "@/i18n"; import { asciiLower } from "@/upstreams/glob"; -import { Badge } from "@/ui/badge"; import { Autocomplete, ComboboxContent, @@ -101,7 +101,7 @@ export function ModelInput({ > {/* 排在输入框和右边的箭头之间:箭头那一格是 order-last */} - {valueIsAlias && } + {valueIsAlias && } {/* 带灰字时列表至少这么宽:放在窄的输入框(「模型改为」)下面,名称和灰字都挤不下 */} 0 ? "min-w-80" : undefined}> @@ -162,19 +162,6 @@ export function aliasNamed(value: string, options: ReadonlyMap - {t.alias} - - ); -} - /** * 一排可切换的名称标签(多选)。**和价目表的「使用上游」是同一种控件**:选中的 * 高亮,悬停可以看一句说明(比如它现在用的是哪条路由)。可以带一个标志(密钥是 diff --git a/src/traffic/RequestTable.tsx b/src/traffic/RequestTable.tsx index a34331b7..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"; @@ -696,16 +697,15 @@ function UpstreamCell({ r, via }: { r: RequestRow; via: ViaConfig | null }) { {/* 发出的模型名不是客户端写的那个(或者是规则指定的):模型那一列照旧写客户端的名称, 这里说一声,发出的名称在悬停和详情的路由页里 */} {mark && ( - - {mark.via === "alias" ? t.alias : t.pinned} - + {mark.via === "pinned" ? t.pinned : undefined} + )} {/* **看不见的安全功能会被用户关掉**,因为他们会怀疑是脱敏搞坏了功能。 所以脱敏发生了就要在列表这一层看得见,而不是藏在详情里 */} diff --git a/src/traffic/Traffic.i18n.tsx b/src/traffic/Traffic.i18n.tsx index 57b7c611..2b0bdd5d 100644 --- a/src/traffic/Traffic.i18n.tsx +++ b/src/traffic/Traffic.i18n.tsx @@ -137,7 +137,6 @@ export const trafficText = messages( // 上游一列的徽标和它们的悬浮说明 /** 客户端写的是别名,按这家上游换成了它的模型名。模型那一列照旧是客户端写的名称 */ - alias: "别名", aliasTip: (model: string, upstream: string, sent: string) => `${model} 是别名,发往 ${upstream} 的模型名是 ${sent}。`, /** 决定去向的规则用了「指定模型」:上游和模型都是规则列出的 */ pinned: "指定", @@ -278,7 +277,6 @@ export const trafficText = messages( failed: "Failed", cancelled: "Canceled", - alias: "Alias", aliasTip: (model: string, upstream: string, sent: string) => `${model} is an alias; the model sent to ${upstream} is ${sent}.`, pinned: "Pinned", diff --git a/src/upstreams/ModelsPanel.tsx b/src/upstreams/ModelsPanel.tsx index 0b3db228..e63059bf 100644 --- a/src/upstreams/ModelsPanel.tsx +++ b/src/upstreams/ModelsPanel.tsx @@ -1,8 +1,8 @@ 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 { Badge } from "@/ui/badge"; import { Button } from "@/ui/button"; import { InputGroup, InputGroupAddon, InputGroupInput } from "@/ui/input-group"; import { Skeleton } from "@/ui/skeleton"; @@ -251,15 +251,14 @@ function Row({ {m.id} {m.aliases.map((a) => ( - {t.aliasMark(a)} - + ))} {/* 悬停时这一格让给「起别名…」:两样叠在同一个位置,行高不跳 */} Date: Mon, 5 Oct 2026 11:02:21 +0800 Subject: [PATCH 14/18] Alias name hints: name only clients whose format choice the adoption code makes The 'name looks like another family' hint listed clients from memory (Claude Code, Codex, Qwen Code, opencode deciding parameters by model name), which nothing in tw-adopt backs. It now names the adopted clients whose config the adoption writes with a request format picked by model name, Pi and oh-my-pi (pi::api_for) and Grok Build (grok::backend_for), and only when the alias name and the upstream model get different formats. The sentence says what follows: the request goes out in that family's format and the gateway converts it. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/aliases.rs | 140 ++++++++++++++++++-------------- src-tauri/src/wire.rs | 2 +- src/aliases/AliasDialog.i18n.ts | 4 +- 3 files changed, 80 insertions(+), 66 deletions(-) diff --git a/src-tauri/src/aliases.rs b/src-tauri/src/aliases.rs index b445e884..912ca4f7 100644 --- a/src-tauri/src/aliases.rs +++ b/src-tauri/src/aliases.rs @@ -3,8 +3,8 @@ //! 别名怎么解析、挡不挡保存,是 core 的事(`/aliases/preview`)。这几条说的是**这台机器上的 //! 客户端**会怎么对待这个名称:只有这一侧知道装了哪些、接管着哪些,所以在这里判断。 //! -//! **只说检测到的客户端。**没装的客户端会怎样,用户不关心;Claude Desktop 只在这一份接管着 -//! 它的时候说 —— 只有接管着,它才从网关取模型列表。 +//! **只说检测到的客户端。**没装的客户端会怎样,用户不关心;Claude Desktop、按名称挑请求格式 +//! 的那几个只在这一份接管着它们的时候说 —— 只有接管着,它们才用得上网关的模型。 //! //! 给界面的是码和参数([`AliasHint`]),句子在界面那边(`src/aliases/AliasDialog.i18n.ts`)。 @@ -66,9 +66,10 @@ pub fn hints(name: &str, models: &[String], seen: &Seen) -> Vec { out.push(AliasHint::ClaudeCodeReserved { name: name.into() }); } if let Some((family, model)) = mismatch(name, models) { - let clients: Vec = readers(family) + let clients: Vec = seen + .adopted .iter() - .filter_map(|id| seen.installed.iter().find(|c| c.id == *id)) + .filter(|c| format_differs(c.id, name, model)) .map(|c| c.name.to_string()) .collect(); if !clients.is_empty() { @@ -176,20 +177,19 @@ fn mismatch<'a>(name: &str, models: &'a [String]) -> Option<(&'static str, &'a s other.map(|m| (want, m)) } -/// 按模型名决定请求参数的客户端(id),按家:名字像这一家,它就照这一家的参数发。 +/// 这个客户端发这个名称的请求和发 `model` 用的格式不同。 /// -/// - Claude Code:上下文长度、思考、Claude 专有的请求头和字段都看模型名; -/// - Codex:按模型名的前缀(`gpt-5`、`o3`、`codex-`)挑工具的写法、推理参数和上下文长度; -/// - Qwen Code:按 `qwen` 系列的名字定上下文长度; -/// - opencode:按模型名里的 `claude`、`gpt`、`gemini`、`qwen`、`glm`、`kimi`、`minimax` 定 -/// 温度、top_p 和各家专有的选项。 -fn readers(family: &str) -> &'static [&'static str] { - match family { - "Claude" => &["claude-code", "opencode"], - "GPT" => &["codex", "opencode"], - "Qwen" => &["qwen-code", "opencode"], - "Gemini" | "GLM" | "Kimi" | "MiniMax" => &["opencode"], - _ => &[], +/// 网关的模型清单不说一个模型背后是哪种上游,接管时写进客户端配置的请求格式**只看模型名**: +/// 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, } } @@ -263,56 +263,75 @@ mod tests { } } + /// 接管着的、按名称挑请求格式的客户端: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_detected_clients() { - let all = seen(&["claude-code", "opencode", "codex"], &[]); + fn a_claude_name_for_another_model_names_the_clients_that_pick_formats_by_name() { assert_eq!( - hints("claude-sonnet-5", &models(&["glm-4.6"]), &all), + about_name("claude-sonnet-5", &models(&["glm-4.6"]), &by_name()), vec![AliasHint::FamilyMismatch { family: "Claude".into(), model: "glm-4.6".into(), - clients: vec!["Claude Code".into(), "opencode".into()], + clients: vec!["Pi".into(), "oh-my-pi".into(), "Grok Build".into()], }] ); - // 只装了 Claude Code:只说它 - assert_eq!( - hints( + // 只是装着、没接管:它们的配置里没有网关的模型,不说 + let installed = ["claude-code", "pi", "omp", "grok-build"]; + assert!( + about_name( "claude-sonnet-5", &models(&["glm-4.6"]), - &seen(&["claude-code"], &[]) - ), - vec![AliasHint::FamilyMismatch { - family: "Claude".into(), - model: "glm-4.6".into(), - clients: vec!["Claude Code".into()], - }] + &seen(&installed, &[]) + ) + .is_empty() ); - // 一个看模型名的客户端都没装:不说 + // 接管着的都不按名称挑格式:不说 assert!( - hints( + about_name( "claude-sonnet-5", &models(&["glm-4.6"]), - &seen(&["zed"], &[]) + &seen(&["claude-code", "codex"], &["claude-code", "codex"]) ) .is_empty() ); - // GPT 的名字给 DeepSeek:Codex 和 opencode + // GPT 的名字给 DeepSeek:三个都把它当 Responses 发 assert_eq!( - hints("gpt-5", &models(&["deepseek-chat"]), &all), + about_name("gpt-5", &models(&["deepseek-chat"]), &by_name()), vec![AliasHint::FamilyMismatch { family: "GPT".into(), model: "deepseek-chat".into(), - clients: vec!["Codex".into(), "opencode".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_or_an_unknown_model_is_not_a_mismatch() { - let all = seen(&["claude-code", "opencode", "codex", "qwen-code"], &[]); + fn the_same_family_the_same_format_or_an_unknown_model_is_not_a_mismatch() { + let all = by_name(); // 几家上游的同一个 Claude assert!( - hints( + about_name( "claude-sonnet-5", &models(&[ "claude-sonnet-5", @@ -325,7 +344,7 @@ mod tests { ); // 列出的里有一个是这一家的就不说,哪怕还列了别家的 assert!( - hints( + about_name( "claude-sonnet-5", &models(&["glm-4.6", "claude-sonnet-5"]), &all @@ -333,22 +352,24 @@ mod tests { .is_empty() ); // 名字认不出、模型认不出:不说 - assert!(hints("my-coder", &models(&["glm-4.6"]), &all).is_empty()); - assert!(hints("claude-sonnet-5", &models(&["local-model"]), &all).is_empty()); - assert!(hints("deepseek-v4.1", &models(&["DeepSeek-v4.1-flash"]), &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!(hints("claude-sonnet-5", &[], &all).is_empty()); + assert!(about_name("claude-sonnet-5", &[], &all).is_empty()); // 第一个认得出的别家模型 assert_eq!( - hints( - "qwen-max", + about_name( + "gemini-2.5-pro", &models(&["local-model", "glm-4.6", "kimi-k2"]), &all ), vec![AliasHint::FamilyMismatch { - family: "Qwen".into(), + family: "Gemini".into(), model: "glm-4.6".into(), - clients: vec!["Qwen Code".into(), "opencode".into()], + clients: vec!["Pi".into(), "oh-my-pi".into()], }] ); } @@ -392,19 +413,12 @@ mod tests { ) .is_empty() ); - // 档位名指向别家的模型:两条都说 + // 档位名指向别家的模型:Pi 和 Grok Build 不把 `opus` 当 Claude 发,只说档位名 assert_eq!( - hints("opus", &models(&["glm-4.6"]), &seen(&["claude-code"], &[])), - vec![ - AliasHint::ClaudeCodeReserved { - name: "opus".into() - }, - AliasHint::FamilyMismatch { - family: "Claude".into(), - model: "glm-4.6".into(), - clients: vec!["Claude Code".into()], - }, - ] + about_name("opus", &models(&["glm-4.6"]), &by_name()), + vec![AliasHint::ClaudeCodeReserved { + name: "opus".into() + }] ); } @@ -471,7 +485,7 @@ mod tests { let v = serde_json::to_value(AliasHint::FamilyMismatch { family: "Claude".into(), model: "glm-4.6".into(), - clients: vec!["Claude Code".into()], + clients: vec!["Pi".into()], }) .unwrap(); assert_eq!( @@ -480,7 +494,7 @@ mod tests { "code": "family_mismatch", "family": "Claude", "model": "glm-4.6", - "clients": ["Claude Code"], + "clients": ["Pi"], }) ); } diff --git a/src-tauri/src/wire.rs b/src-tauri/src/wire.rs index bb371632..09cfa2af 100644 --- a/src-tauri/src/wire.rs +++ b/src-tauri/src/wire.rs @@ -863,7 +863,7 @@ pub enum AliasHint { /// 完整的模型名,它的请求里不会出现这个名称 ClaudeCodeReserved { name: String }, /// 名称像 `family` 这一家的模型,列出的上游模型却是别家的(`model` 是其中第一个)。 - /// `clients`:检测到的、按模型名决定请求参数的客户端(产品名) + /// `clients`:接管着的、按模型名挑请求格式而这个名称和 `model` 格式不同的客户端(产品名) FamilyMismatch { family: String, model: String, diff --git a/src/aliases/AliasDialog.i18n.ts b/src/aliases/AliasDialog.i18n.ts index 1199cdc2..fa8910c1 100644 --- a/src/aliases/AliasDialog.i18n.ts +++ b/src/aliases/AliasDialog.i18n.ts @@ -20,7 +20,7 @@ export const aliasDialogText = messages( claudeCodeReserved: (name: string) => `Claude Code 把 ${name} 当作自己的档位名,请求前换成完整的模型名,它的请求用不到这个别名。`, familyMismatch: (family: string, model: string, clients: string) => - `名称像 ${family} 模型,上游模型却是 ${model}:${clients} 会按 ${family} 模型的上下文长度和参数发请求。`, + `名称像 ${family} 模型,上游模型却是 ${model}:${clients} 会按 ${family} 模型的格式发出这个名称的请求,由网关转换后发给上游。`, desktopHidden: (name: string) => `Claude Desktop 只列出名称像 Claude 的模型,不会显示 ${name}。`, desktopShown: (name: string) => `Claude Desktop 会在模型列表里显示 ${name}。`, /** 改名前:哪些引用会一起改 */ @@ -82,7 +82,7 @@ export const aliasDialogText = messages( 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 with ${family} context lengths and parameters.`, + `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}.`, From 0672098648ee5228caf9f74dad6fa979383d50ca Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:51:01 +0800 Subject: [PATCH 15/18] Route dialog keeps the alias mark visible when a long condition is truncated Co-Authored-By: Claude Opus 5.5 --- src/routing/RouteDialog.tsx | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/routing/RouteDialog.tsx b/src/routing/RouteDialog.tsx index 1ea6177b..9175be80 100644 --- a/src/routing/RouteDialog.tsx +++ b/src/routing/RouteDialog.tsx @@ -492,16 +492,17 @@ function Conditions({ r, known }: { r: RuleDraft; known: readonly KnownModel[] } const exact = values.length === 1 && !values[0]!.includes("*"); return (
-
- {r.conditions.map((c, i) => ( - - {i > 0 && t.conditionJoin} - {conditionText(c)} - {c === model && alias && ( - - )} - - ))} + {/* 标记放在截断的文字外面:条件一长,行尾会被截掉,标记不能跟着丢 */} +
+ + {r.conditions.map((c, i) => ( + + {i > 0 && t.conditionJoin} + {conditionText(c)} + + ))} + + {alias && }
{inherited.length > 0 && (
From c040fe4f87a80745fe19e97820c2c11a45f108d7 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:53:32 +0800 Subject: [PATCH 16/18] Follow the final core: not_allowed skip label, WebSocket alias messages, moved plugin path Co-Authored-By: Claude Opus 5.5 --- src-tauri/Cargo.lock | 44 +++++++++++++++---------------- src-tauri/Cargo.toml | 12 ++++----- src-tauri/src/call.rs | 2 +- src/generated/tw-api.ts | 51 ++++++++++++++++++++---------------- src/i18n/core.zh.json | 4 +-- src/upstreams/labels.i18n.ts | 2 ++ src/upstreams/labels.ts | 2 ++ 7 files changed, 64 insertions(+), 53 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1894f285..7741a0cb 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.59.0", + "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.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1171,7 +1171,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[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.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3601,7 +3601,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3657,7 +3657,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -4743,7 +4743,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -5268,8 +5268,8 @@ dependencies = [ [[package]] name = "tw-api" -version = "0.61.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=4a1775d924e4f069d565a00af116ee375444672e#4a1775d924e4f069d565a00af116ee375444672e" +version = "0.62.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=4fce88f75e9f8c0149b8446951ee213fe5d45eb1#4fce88f75e9f8c0149b8446951ee213fe5d45eb1" 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?rev=4a1775d924e4f069d565a00af116ee375444672e#4a1775d924e4f069d565a00af116ee375444672e" +version = "0.62.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=4fce88f75e9f8c0149b8446951ee213fe5d45eb1#4fce88f75e9f8c0149b8446951ee213fe5d45eb1" 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?rev=4a1775d924e4f069d565a00af116ee375444672e#4a1775d924e4f069d565a00af116ee375444672e" +version = "0.62.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=4fce88f75e9f8c0149b8446951ee213fe5d45eb1#4fce88f75e9f8c0149b8446951ee213fe5d45eb1" 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?rev=4a1775d924e4f069d565a00af116ee375444672e#4a1775d924e4f069d565a00af116ee375444672e" +version = "0.62.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=4fce88f75e9f8c0149b8446951ee213fe5d45eb1#4fce88f75e9f8c0149b8446951ee213fe5d45eb1" 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?rev=4a1775d924e4f069d565a00af116ee375444672e#4a1775d924e4f069d565a00af116ee375444672e" +version = "0.62.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=4fce88f75e9f8c0149b8446951ee213fe5d45eb1#4fce88f75e9f8c0149b8446951ee213fe5d45eb1" 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?rev=4a1775d924e4f069d565a00af116ee375444672e#4a1775d924e4f069d565a00af116ee375444672e" +version = "0.62.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=4fce88f75e9f8c0149b8446951ee213fe5d45eb1#4fce88f75e9f8c0149b8446951ee213fe5d45eb1" 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?rev=4a1775d924e4f069d565a00af116ee375444672e#4a1775d924e4f069d565a00af116ee375444672e" +version = "0.62.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=4fce88f75e9f8c0149b8446951ee213fe5d45eb1#4fce88f75e9f8c0149b8446951ee213fe5d45eb1" dependencies = [ "saphyr-parser", "thiserror 2.0.21", @@ -5816,7 +5816,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f4943f30..426e019f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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", rev = "4a1775d924e4f069d565a00af116ee375444672e" } -tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "4a1775d924e4f069d565a00af116ee375444672e" } -tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "4a1775d924e4f069d565a00af116ee375444672e" } -tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "4a1775d924e4f069d565a00af116ee375444672e" } -tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "4a1775d924e4f069d565a00af116ee375444672e" } -tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "4a1775d924e4f069d565a00af116ee375444672e" } +tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "4fce88f75e9f8c0149b8446951ee213fe5d45eb1" } +tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "4fce88f75e9f8c0149b8446951ee213fe5d45eb1" } +tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "4fce88f75e9f8c0149b8446951ee213fe5d45eb1" } +tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "4fce88f75e9f8c0149b8446951ee213fe5d45eb1" } +tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "4fce88f75e9f8c0149b8446951ee213fe5d45eb1" } +tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "4fce88f75e9f8c0149b8446951ee213fe5d45eb1" } [lib] name = "thinkwatch_lite_lib" diff --git a/src-tauri/src/call.rs b/src-tauri/src/call.rs index e1c69bfb..afa29d7f 100644 --- a/src-tauri/src/call.rs +++ b/src-tauri/src/call.rs @@ -213,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/generated/tw-api.ts b/src/generated/tw-api.ts index b6916338..9dbdd002 100644 --- a/src/generated/tw-api.ts +++ b/src/generated/tw-api.ts @@ -81,7 +81,8 @@ problems: Array, */ served_by: Array, /** - * 列表里没有哪家上游的清单里有的名称 + * 列表里没有哪家上游的清单里有的名称。准入会拒掉这个别名时(`served_by` 是空的) + * 是列表里的全部名称 */ unserved: Array, /** @@ -96,7 +97,7 @@ shadows: Array, same_model: Array, }; /** - * 预览一个还没保存的别名(`POST /aliases/preview`)。 + * 预览一个还没保存的别名(`POST /alias-preview`)。 */ export type AliasPreviewRequest = { alias: AliasInput, /** @@ -162,7 +163,9 @@ export type AliasView = { name: string, models: Array, /** * 每家能服务它的上游实际发出的名称,按配置里的顺序。停用的上游不在里面;没有清单 - * 的上游当作能服务(取列表里第一个在它启用范围里的名称) + * 的上游当作能服务(取列表里第一个在它启用范围里的名称)。**和网关一致**:有上游 + * 有清单、而有清单的上游谁都不提供列表里的名称时是空的 —— 请求在准入就被拒了, + * 没有清单的上游也轮不到 */ served_by: Array, /** @@ -188,7 +191,11 @@ cost_micros_24h?: number | null, }; */ export type AliasWritten = { version: string, /** - * 改名时在同一个版本里一起改掉的引用。没改名时是空的;`requests_24h` 总是 0 + * 改名时在同一个版本里一起改掉的引用。没改名时是空的;`requests_24h` 总是 0。 + * + * **旧名也是改名后列表里的一个模型名时**,密钥的 `allow` 和规则的 `when.model` + * 不改,也不在这里:改名之后旧名说的是那个真模型,写真名的放行和条件照样继承到 + * 改名后的别名。`set.model` 照样改(它是要发的名称,不继承) */ renamed_in: AliasUsage, }; @@ -1877,7 +1884,7 @@ export type L1Peer = "upstream" | "proxy"; * L1 测速:只握手,不发业务请求。**零成本零副作用**。 * * 给了名字就测那一家,不给就测所有上游。代理自己的检测走 - * `/proxies/test`,还没保存的上游走 `/providers/test`。 + * `/proxy-test`,还没保存的上游走 `/provider-test`。 * * **不接受一个「候选 URL 列表」。** cc-switch 有那么一张表,测完还得手动 * 点一下填进去,运行时永远只认当前保存的那一个 —— 同一个概念在一个程序 @@ -2013,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, /** @@ -2342,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, /** @@ -2419,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, }; @@ -2429,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 字面量那一段,别的字节 * 一个不动;字面量里的注释不保留。 * @@ -2489,7 +2496,7 @@ models: Array, upstreams: Array, }; /** - * 一份源码(`POST /plugins/inspect`)。 + * 一份源码(`POST /plugin-inspect`)。 */ export type PluginSource = { source: string, }; @@ -4007,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, }; @@ -4095,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, }; @@ -4636,20 +4643,20 @@ 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: "/aliases/preview", 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" }, @@ -4679,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" }, diff --git a/src/i18n/core.zh.json b/src/i18n/core.zh.json index 9f2cdb64..f252d9ec 100644 --- a/src/i18n/core.zh.json +++ b/src/i18n/core.zh.json @@ -380,6 +380,8 @@ "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}", @@ -556,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}", @@ -619,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 位小写十六进制字符。", diff --git a/src/upstreams/labels.i18n.ts b/src/upstreams/labels.i18n.ts index 047d2917..a61d09ef 100644 --- a/src/upstreams/labels.i18n.ts +++ b/src/upstreams/labels.i18n.ts @@ -61,6 +61,7 @@ export const labelsText = messages( disabled: "已停用", out_of_scope: "不在启用范围内", not_offered: "未提供此模型", + not_allowed: "密钥不允许使用此模型", }, defaultSheet: "默认价目表", unpriced: "无法计价", @@ -140,6 +141,7 @@ export const labelsText = messages( disabled: "Disabled", out_of_scope: "Model not enabled", not_offered: "Model not offered", + not_allowed: "Model not allowed for this key", }, defaultSheet: "Default price sheet", unpriced: "Unpriced", diff --git a/src/upstreams/labels.ts b/src/upstreams/labels.ts index c8977a4b..460884fe 100644 --- a/src/upstreams/labels.ts +++ b/src/upstreams/labels.ts @@ -354,6 +354,8 @@ export function skipLabel(reason: ServeSkip): string { return t.out_of_scope; case "not_offered": return t.not_offered; + case "not_allowed": + return t.not_allowed; } } From 1a883cd10fd1b06facc504d631bfe2f06f6a0e9a Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:59:40 +0800 Subject: [PATCH 17/18] 2026.10.5 Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- release-notes/2026.10.5.md | 26 ++++++++++++++++++++++++++ src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 5 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 release-notes/2026.10.5.md 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/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7741a0cb..e6b077a2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4766,7 +4766,7 @@ dependencies = [ [[package]] name = "thinkwatch-lite" -version = "2026.10.4" +version = "2026.10.5" dependencies = [ "anyhow", "block2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 426e019f..76e6684f 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 和 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", From 720b97e1b5ed35e3cbb02ab5c472e514602dfe01 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:05:08 +0800 Subject: [PATCH 18/18] Use core v0.62.0 Co-Authored-By: Claude Opus 5.5 --- src-tauri/Cargo.lock | 14 +++++++------- src-tauri/Cargo.toml | 12 ++++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e6b077a2..dffa0c2c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5269,7 +5269,7 @@ dependencies = [ [[package]] name = "tw-api" version = "0.62.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=4fce88f75e9f8c0149b8446951ee213fe5d45eb1#4fce88f75e9f8c0149b8446951ee213fe5d45eb1" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.62.0#51ef803bee644e72668cb8725d7bc27f6bb77a3f" dependencies = [ "serde", "serde_json", @@ -5281,7 +5281,7 @@ dependencies = [ [[package]] name = "tw-dialect" version = "0.62.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=4fce88f75e9f8c0149b8446951ee213fe5d45eb1#4fce88f75e9f8c0149b8446951ee213fe5d45eb1" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.62.0#51ef803bee644e72668cb8725d7bc27f6bb77a3f" dependencies = [ "serde", "serde_json", @@ -5290,7 +5290,7 @@ dependencies = [ [[package]] name = "tw-guard" version = "0.62.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=4fce88f75e9f8c0149b8446951ee213fe5d45eb1#4fce88f75e9f8c0149b8446951ee213fe5d45eb1" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.62.0#51ef803bee644e72668cb8725d7bc27f6bb77a3f" dependencies = [ "base64 0.22.1", "bytes", @@ -5306,7 +5306,7 @@ dependencies = [ [[package]] name = "tw-link" version = "0.62.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=4fce88f75e9f8c0149b8446951ee213fe5d45eb1#4fce88f75e9f8c0149b8446951ee213fe5d45eb1" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.62.0#51ef803bee644e72668cb8725d7bc27f6bb77a3f" dependencies = [ "serde", "serde_json", @@ -5333,7 +5333,7 @@ dependencies = [ [[package]] name = "tw-types" version = "0.62.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=4fce88f75e9f8c0149b8446951ee213fe5d45eb1#4fce88f75e9f8c0149b8446951ee213fe5d45eb1" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.62.0#51ef803bee644e72668cb8725d7bc27f6bb77a3f" dependencies = [ "serde", "ts-rs", @@ -5342,7 +5342,7 @@ dependencies = [ [[package]] name = "tw-watch" version = "0.62.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=4fce88f75e9f8c0149b8446951ee213fe5d45eb1#4fce88f75e9f8c0149b8446951ee213fe5d45eb1" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.62.0#51ef803bee644e72668cb8725d7bc27f6bb77a3f" dependencies = [ "notify", "thiserror 2.0.21", @@ -5352,7 +5352,7 @@ dependencies = [ [[package]] name = "tw-yaml" version = "0.62.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?rev=4fce88f75e9f8c0149b8446951ee213fe5d45eb1#4fce88f75e9f8c0149b8446951ee213fe5d45eb1" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.62.0#51ef803bee644e72668cb8725d7bc27f6bb77a3f" dependencies = [ "saphyr-parser", "thiserror 2.0.21", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 76e6684f..05be5b2e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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", rev = "4fce88f75e9f8c0149b8446951ee213fe5d45eb1" } -tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "4fce88f75e9f8c0149b8446951ee213fe5d45eb1" } -tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "4fce88f75e9f8c0149b8446951ee213fe5d45eb1" } -tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "4fce88f75e9f8c0149b8446951ee213fe5d45eb1" } -tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "4fce88f75e9f8c0149b8446951ee213fe5d45eb1" } -tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", rev = "4fce88f75e9f8c0149b8446951ee213fe5d45eb1" } +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"