diff --git a/src/labels.i18n.ts b/src/labels.i18n.ts index 71781d8f..2d7e8969 100644 --- a/src/labels.i18n.ts +++ b/src/labels.i18n.ts @@ -88,6 +88,8 @@ export const labelsText = messages( notSent: { denied: "规则拒绝", unavailable: "无可用上游", + model: "模型不可用", + unrouted: "未能路由", limited: "用量上限", busy: "并发已满", throttled: "上游限流中", @@ -199,6 +201,8 @@ export const labelsText = messages( notSent: { denied: "Denied", unavailable: "No upstream", + model: "Model unavailable", + unrouted: "Not routed", limited: "Usage limit", busy: "At capacity", throttled: "Rate limited", diff --git a/src/requestRouting.test.ts b/src/requestRouting.test.ts index f47b1d3e..f5d67990 100644 --- a/src/requestRouting.test.ts +++ b/src/requestRouting.test.ts @@ -62,6 +62,21 @@ describe("没有发往任何上游的请求", () => { expect(notSent({ provider: "", error: { code: "gw.route.all_selected_disabled", args: {}, text: "" } })).toBe("unavailable"); }); + it("模型过不了准入的、没选出去向的:各是一种,gw.models.* 不算模型的", () => { + const m = (code: string) => ({ provider: "", error: { code, args: {}, text: "" } }); + expect(notSent(m("gw.model.unknown"))).toBe("model"); + expect(notSent(m("gw.model.not_allowed_rewritten"))).toBe("model"); + expect(notSent(m("gw.ws.alias_unserved"))).toBe("model"); + expect(notSent(m("gw.models.empty"))).toBe("unavailable"); + expect(notSent(m("gw.model.no_upstream_available"))).toBe("unavailable"); + expect(notSent(m("gw.route.failed"))).toBe("unrouted"); + expect(notSent(m("gw.config.no_upstreams"))).toBe("unrouted"); + const err = { code: "gw.model.unknown", args: { model: "claude-x" }, text: "" }; + const f = routingFacts(row({ rule: "", attempts: [] }, { provider: "", error: err }), false)!; + expect(f.reason).toEqual({ msg: err }); + expect(f.note).toEqual({ kind: "model" }); + }); + it("密钥的用量上限拒绝的:上游是空的,失败的那一句是 gw.key_limit.*", () => { expect(notSent({ provider: "", error: limited })).toBe("limited"); expect(notSent({ provider: "", error: { ...limited, code: "gw.key_limit.requests_rolling" } })).toBe("limited"); diff --git a/src/requestRouting.ts b/src/requestRouting.ts index 3b4b4e7b..1c0aa7fa 100644 --- a/src/requestRouting.ts +++ b/src/requestRouting.ts @@ -11,6 +11,16 @@ const DENIED = "gw.route.denied"; const KEY_LIMIT = "gw.key_limit."; /** 能服务的上游都满着、等过了也没空出位置:`gw.busy_all {upstreams}` */ const BUSY_ALL = "gw.busy_all"; +/** + * 模型过不了准入时 core 说的那几句:`gw.model.*`(写错了、没有上游提供、密钥不让用、别名或 + * 指定模型对不上),WebSocket 一轮里别名对不上的是 `gw.ws.alias_unserved*`。注意不是 + * `gw.models.*`(那是查模型清单的)。`gw.model.no_upstream_available` 也不算:那是规则选中的 + * 上游都不提供这个模型,归「无可用上游」 + */ +const MODEL = ["gw.model.", "gw.ws.alias_unserved"]; +const SELECTED_CANNOT_SERVE = "gw.model.no_upstream_available"; +/** 规则还没做出决定(没有一条命中、求不了值),或者一个上游都还没配 */ +const UNROUTED: ReadonlySet = new Set(["gw.route.failed", "gw.config.no_upstreams"]); /** * 能服务的上游都暂时发不出去、等到期限也没有一家恢复:都在限流暂停(`gw.wait.rate_limited`)、 * 都到了分钟或小时的用量上限(`gw.wait.window_full`)、都用完了天周月的用量上限 @@ -29,6 +39,8 @@ const WAIT: Record = { * * · `denied`:规则拒绝了它(选定上游之前) * · `unavailable`:规则选中的上游一个都接不了(停用、不在范围内、不提供这个模型) + * · `model`:请求的模型过不了准入(名字写错了、没有上游提供、密钥不让用) + * · `unrouted`:没有为它选出去向(没有规则命中、规则求不了值、还没配上游) * · `limited`:这把网关密钥的用量上限拒绝了它(选定上游之后、发出之前) * · `busy`:能服务它的上游都满着(各自的并发上限),等过了也没空出位置 * · `throttled`:能服务它的上游都因 429 暂停着,等过了也没恢复 @@ -39,15 +51,22 @@ const WAIT: Record = { * * **上游是空的就是没有发往任何上游**:core 只在请求一个上游都不会去时把上游记成空的 * (本地应答另有 `local`)。是哪一种看失败的那一句:规则拒绝的是 `gw.route.denied`, - * 用量上限的是 `gw.key_limit.*`,其余是选中的上游接不了。 + * 用量上限的是 `gw.key_limit.*`,模型的是 `gw.model.*`,没选出去向的是 `gw.route.failed`、 + * `gw.config.no_upstreams`,其余是选中的上游接不了。 * * **`busy` 的上游不是空的**:那一行归在尝试链的最后一跳,也就是最后看过、满着的那一家。 * 可那一家一个字节都没收到,结局也不是它的失败 —— 这一格写「并发已满」,不写它的名字 */ -export type NotSent = "denied" | "unavailable" | "limited" | "busy" | "throttled" | "full" | "timeout"; - -/** 等过了、没有一家上游接下的那几种:上游那一格不是空的,也不写它的名字 */ -const WAITED_OUT: ReadonlySet = new Set(["busy", "throttled", "full", "timeout"]); +export type NotSent = + | "denied" + | "unavailable" + | "model" + | "unrouted" + | "limited" + | "busy" + | "throttled" + | "full" + | "timeout"; export function notSent(r: { local?: boolean; provider: string; error?: Msg | null }): NotSent | null { if (r.local || !r.error) return null; @@ -55,8 +74,11 @@ export function notSent(r: { local?: boolean; provider: string; error?: Msg | nu const waited = WAIT[r.error.code]; if (waited) return waited; if (r.provider !== "") return null; - if (r.error.code === DENIED) return "denied"; - return r.error.code.startsWith(KEY_LIMIT) ? "limited" : "unavailable"; + const code = r.error.code; + if (code === DENIED) return "denied"; + if (code !== SELECTED_CANNOT_SERVE && MODEL.some((p) => code.startsWith(p))) return "model"; + if (UNROUTED.has(code)) return "unrouted"; + return code.startsWith(KEY_LIMIT) ? "limited" : "unavailable"; } /** @@ -98,6 +120,8 @@ export interface Hop { * · `denied_after_pick`:唯一的那一跳被规则 `rule` 拒绝:没有发往任何上游 * · `denied_before_pick`:选定上游之前规则 `rule` 就拒绝了它:没有发往任何上游 * · `unavailable`:规则选中的上游都接不了:没有发往任何上游 + * · `model`:请求的模型过不了准入:没有发往任何上游 + * · `unrouted`:没有为它选出去向:没有发往任何上游 * · `pending`:还在跑,路由还没有结论 * · `none`:没有尝试记录(上游应答之前客户端就走了、被请求防护挡下……) * @@ -112,7 +136,7 @@ export type RoutingNote = | { kind: "failover_denied"; failed: number; rule: string } | { kind: "denied_after_pick"; rule: string } | { kind: "denied_before_pick"; rule: string } - | { kind: "unavailable" } + | { kind: "unavailable" | "model" | "unrouted" } | { kind: "pending" } | { kind: "none" }; @@ -171,8 +195,9 @@ export function routingFacts( if (ruleDenied || deniedBy) { const text = denyReason(deniedHop?.error) ?? denyReason(r.error); reason = text ? { text } : null; - } else if ((why === "unavailable" || why === "limited" || (why && WAITED_OUT.has(why))) && r.error) { - // 用量上限的那一句说清是哪一条、用了多少、什么时候重置;满着的那一句列出是哪几家 + } else if (why && why !== "denied" && r.error) { + // 用量上限的那一句说清是哪一条、用了多少、什么时候重置;满着的那一句列出是哪几家; + // 模型的那一句说清是哪个名字、为什么用不了 reason = { msg: r.error }; } @@ -182,7 +207,7 @@ export function routingFacts( note = { kind: why, tried: attempts.filter((a) => !skippedHop(a)).length }; } else if (n === 0) { if (ruleDenied) note = { kind: "denied_before_pick", rule: routing.rule }; - else if (why === "unavailable") note = { kind: "unavailable" }; + else if (why === "unavailable" || why === "model" || why === "unrouted") note = { kind: why }; else if (why === "limited") note = { kind: "limited" }; else note = { kind: running ? "pending" : "none" }; } else if (deniedBy) { diff --git a/src/traffic/cells.tsx b/src/traffic/cells.tsx index 51ed6e14..011862ad 100644 --- a/src/traffic/cells.tsx +++ b/src/traffic/cells.tsx @@ -5,7 +5,7 @@ import { keyText } from "@/KeyLabel"; import { cn } from "@/lib/utils"; import type { NotSent } from "@/requestRouting"; import type { RequestRow } from "@/types"; -import { IconBusy, IconDenied, IconLimitReached, IconNoUpstream, IconRemote } from "@/ui/icons"; +import { IconBusy, IconDenied, IconLimitReached, IconNoModel, IconNoUpstream, IconRemote } from "@/ui/icons"; import { ClientLogo } from "@/ui/logos"; import { Tip } from "@/ui/tip"; import { coreNow, onTick, stopwatch } from "./clock"; @@ -99,6 +99,8 @@ export function RowKeyCell({ r, hints }: { r: RequestRow; hints: boolean }) { const NOT_SENT: Record = { denied: { Icon: IconDenied, color: "text-destructive" }, unavailable: { Icon: IconNoUpstream, color: "text-muted-foreground" }, + model: { Icon: IconNoModel, color: "text-muted-foreground" }, + unrouted: { Icon: IconNoUpstream, color: "text-muted-foreground" }, limited: { Icon: IconLimitReached, color: "text-warning" }, busy: { Icon: IconBusy, color: "text-muted-foreground" }, throttled: { Icon: IconBusy, color: "text-muted-foreground" }, @@ -108,7 +110,7 @@ const NOT_SENT: Record = { /** * 没有上游接下的请求在上游标志的位置上画什么:被规则拒绝是禁止符号(和路由图上 - * 「拒绝」那个节点同一个),没有可用的上游是划掉的上游,密钥的用量到了上限是顶到线的 + * 「拒绝」那个节点同一个),没有可用的上游和没选出去向的是划掉的上游,模型用不了是划掉的放大镜,密钥的用量到了上限是顶到线的 * 箭头,上游都满着是沙漏。上游都在限流暂停、几种都有的同样是沙漏(等过了没等到),上游 * 到了自己的用量上限和密钥的一样是顶到线的箭头。和上游标志一样大,名字对得齐。 * diff --git a/src/traffic/drawer/RequestDrawer.i18n.tsx b/src/traffic/drawer/RequestDrawer.i18n.tsx index bd23469d..13a824a1 100644 --- a/src/traffic/drawer/RequestDrawer.i18n.tsx +++ b/src/traffic/drawer/RequestDrawer.i18n.tsx @@ -128,6 +128,8 @@ export const requestDrawerText = messages( deniedAfterPick: (rule: string) => `选定上游后,规则「${rule}」拒绝了此请求,未发往任何上游。`, deniedBeforePick: (rule: string) => `选定上游之前,规则「${rule}」已拒绝此请求,未发往任何上游。`, unavailable: "规则选中的上游均无法服务此请求,未发往任何上游。", + model: "请求的模型无法使用,未发往任何上游。", + unrouted: "未能为此请求选出去向,未发往任何上游。", /** 前面几跳里有满着跳过的:它们不是上游的失败 */ switched: (n: number) => `已自动切换上游:前 ${n} 次尝试未接下此请求。`, /** 最后接下它的是前面试过的一家:等它恢复之后发了过去 */ @@ -300,6 +302,8 @@ export const requestDrawerText = messages( deniedBeforePick: (rule: string) => `Rule “${rule}” denied this request before an upstream was chosen; it was not sent to any upstream.`, unavailable: "No upstream the rule selected can serve this request; it was not sent to any upstream.", + model: "The requested model cannot be used; the request was not sent to any upstream.", + unrouted: "No destination could be chosen for this request; it was not sent to any upstream.", switched: (n: number) => n === 1 ? "Switched upstreams automatically: the first attempt did not take this request." diff --git a/src/traffic/drawer/Routing.tsx b/src/traffic/drawer/Routing.tsx index 46e53fb0..be29b328 100644 --- a/src/traffic/drawer/Routing.tsx +++ b/src/traffic/drawer/Routing.tsx @@ -247,6 +247,10 @@ function noteText(n: RoutingNote, t: (typeof requestDrawerText)["zh"]): string { return t.deniedBeforePick(n.rule); case "unavailable": return t.unavailable; + case "model": + return t.model; + case "unrouted": + return t.unrouted; case "pending": return t.routingPending; case "none": diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 4434ed65..29de7789 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -26,6 +26,7 @@ export { Plug as IconMcp } from "lucide-react"; // MCP —— 给客户端接上 export { Puzzle as IconPlugin } from "lucide-react"; // 插件 —— 拼进链路里的一块(插头已经是 MCP) export { Server as IconServer } from "lucide-react"; // 上游 —— 一摞机器 export { ServerOff as IconNoUpstream } from "lucide-react"; // 没有可用的上游 —— 那一摞机器划掉 +export { SearchX as IconNoModel } from "lucide-react"; // 请求的模型用不了 —— 找不到 export { Ban as IconDenied } from "lucide-react"; // 被规则拒绝 —— 禁止符号 export { ArrowUpToLine as IconLimitReached } from "lucide-react"; // 密钥的用量到了上限 —— 顶到那条线 export { Hourglass as IconBusy } from "lucide-react"; // 上游都满着、等不到空位 —— 沙漏