Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/labels.i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ export const labelsText = messages(
notSent: {
denied: "规则拒绝",
unavailable: "无可用上游",
model: "模型不可用",
unrouted: "未能路由",
limited: "用量上限",
busy: "并发已满",
throttled: "上游限流中",
Expand Down Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions src/requestRouting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
47 changes: 36 additions & 11 deletions src/requestRouting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = new Set(["gw.route.failed", "gw.config.no_upstreams"]);
/**
* 能服务的上游都暂时发不出去、等到期限也没有一家恢复:都在限流暂停(`gw.wait.rate_limited`)、
* 都到了分钟或小时的用量上限(`gw.wait.window_full`)、都用完了天周月的用量上限
Expand All @@ -29,6 +39,8 @@ const WAIT: Record<string, NotSent> = {
*
* · `denied`:规则拒绝了它(选定上游之前)
* · `unavailable`:规则选中的上游一个都接不了(停用、不在范围内、不提供这个模型)
* · `model`:请求的模型过不了准入(名字写错了、没有上游提供、密钥不让用)
* · `unrouted`:没有为它选出去向(没有规则命中、规则求不了值、还没配上游)
* · `limited`:这把网关密钥的用量上限拒绝了它(选定上游之后、发出之前)
* · `busy`:能服务它的上游都满着(各自的并发上限),等过了也没空出位置
* · `throttled`:能服务它的上游都因 429 暂停着,等过了也没恢复
Expand All @@ -39,24 +51,34 @@ const WAIT: Record<string, NotSent> = {
*
* **上游是空的就是没有发往任何上游**: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<NotSent> = 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;
if (r.error.code === BUSY_ALL) return "busy";
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";
}

/**
Expand Down Expand Up @@ -98,6 +120,8 @@ export interface Hop {
* · `denied_after_pick`:唯一的那一跳被规则 `rule` 拒绝:没有发往任何上游
* · `denied_before_pick`:选定上游之前规则 `rule` 就拒绝了它:没有发往任何上游
* · `unavailable`:规则选中的上游都接不了:没有发往任何上游
* · `model`:请求的模型过不了准入:没有发往任何上游
* · `unrouted`:没有为它选出去向:没有发往任何上游
* · `pending`:还在跑,路由还没有结论
* · `none`:没有尝试记录(上游应答之前客户端就走了、被请求防护挡下……)
*
Expand All @@ -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" };

Expand Down Expand Up @@ -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 };
}

Expand All @@ -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) {
Expand Down
6 changes: 4 additions & 2 deletions src/traffic/cells.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -99,6 +99,8 @@ export function RowKeyCell({ r, hints }: { r: RequestRow; hints: boolean }) {
const NOT_SENT: Record<NotSent, { Icon: typeof IconDenied; color: string }> = {
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" },
Expand All @@ -108,7 +110,7 @@ const NOT_SENT: Record<NotSent, { Icon: typeof IconDenied; color: string }> = {

/**
* 没有上游接下的请求在上游标志的位置上画什么:被规则拒绝是禁止符号(和路由图上
* 「拒绝」那个节点同一个),没有可用的上游是划掉的上游,密钥的用量到了上限是顶到线的
* 「拒绝」那个节点同一个),没有可用的上游和没选出去向的是划掉的上游,模型用不了是划掉的放大镜,密钥的用量到了上限是顶到线的
* 箭头,上游都满着是沙漏。上游都在限流暂停、几种都有的同样是沙漏(等过了没等到),上游
* 到了自己的用量上限和密钥的一样是顶到线的箭头。和上游标志一样大,名字对得齐。
*
Expand Down
4 changes: 4 additions & 0 deletions src/traffic/drawer/RequestDrawer.i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ export const requestDrawerText = messages(
deniedAfterPick: (rule: string) => `选定上游后,规则「${rule}」拒绝了此请求,未发往任何上游。`,
deniedBeforePick: (rule: string) => `选定上游之前,规则「${rule}」已拒绝此请求,未发往任何上游。`,
unavailable: "规则选中的上游均无法服务此请求,未发往任何上游。",
model: "请求的模型无法使用,未发往任何上游。",
unrouted: "未能为此请求选出去向,未发往任何上游。",
/** 前面几跳里有满着跳过的:它们不是上游的失败 */
switched: (n: number) => `已自动切换上游:前 ${n} 次尝试未接下此请求。`,
/** 最后接下它的是前面试过的一家:等它恢复之后发了过去 */
Expand Down Expand Up @@ -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."
Expand Down
4 changes: 4 additions & 0 deletions src/traffic/drawer/Routing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
1 change: 1 addition & 0 deletions src/ui/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"; // 上游都满着、等不到空位 —— 沙漏
Expand Down
Loading