From da01cacf5c99d50b2cb36312201a662be7c595a9 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:16:52 +0800 Subject: [PATCH 1/9] Traffic: show requests waiting to retry an upstream that failed transiently - Types: add the request_retrying event and AttemptView.retry / backoff_ms (control API version 53), ahead of the core release that sends them. - Traffic: an in-flight request backing off before a retry reads "Retrying" and lists the upstreams it will retry, styled like a queued request; it can be aborted the same way. Snapshot replay handles the event like request_queued. RequestRow.queued becomes RequestRow.waiting with an optional retry number. - Upstreams: a request backing off is not counted against any upstream, as with queueing. - Request drawer: a retried hop shows "Retry n" and the backoff it waited; a chain whose last hop is a retry says it was retried automatically instead of "sent once the upstream was available again". - Settings: the failover wait limit description covers queueing and retrying. Co-Authored-By: Claude Opus 5.5 --- src/events.test.ts | 82 +++++++++++++++++++++-- src/generated/tw-api.ts | 30 ++++++++- src/requestRouting.test.ts | 12 ++++ src/requestRouting.ts | 16 +++-- src/requestTable.test.ts | 19 ++++-- src/requestTable.ts | 8 +-- src/settings/FailoverSection.i18n.ts | 4 +- src/traffic/RequestTable.tsx | 24 ++++--- src/traffic/Traffic.i18n.tsx | 11 ++- src/traffic/drawer/RequestDrawer.i18n.tsx | 12 ++++ src/traffic/drawer/Routing.tsx | 17 ++++- src/types.ts | 35 ++++++---- src/upstreams/data.ts | 9 +-- src/useRequests.ts | 1 + 14 files changed, 225 insertions(+), 55 deletions(-) diff --git a/src/events.test.ts b/src/events.test.ts index 67dcc697..c22f9ef5 100644 --- a/src/events.test.ts +++ b/src/events.test.ts @@ -865,9 +865,9 @@ describe("排队中(request_queued)", () => { const rows = new Map(); applyEvent(rows, started()); applyEvent(rows, queued(1)); - expect(rows.get(1)?.queued).toEqual({ upstreams: ["anthropic", "openrouter"], untilMs: 1_120_000 }); + expect(rows.get(1)?.waiting).toEqual({ upstreams: ["anthropic", "openrouter"], untilMs: 1_120_000 }); applyEvent(rows, routed(1)); - expect(rows.get(1)?.queued).toBeUndefined(); + expect(rows.get(1)?.waiting).toBeUndefined(); expect(rows.get(1)?.provider).toBe("openrouter"); }); @@ -877,7 +877,7 @@ describe("排队中(request_queued)", () => { applyEvent(rows, queued(1)); applyEvent(rows, routed(1)); applyEvent(rows, queued(1, ["openrouter"])); - expect(rows.get(1)?.queued?.upstreams).toEqual(["openrouter"]); + expect(rows.get(1)?.waiting?.upstreams).toEqual(["openrouter"]); }); it("不排了(request_dequeued):等到了、正在发,路由事件还没到也不再说在排队", () => { @@ -887,7 +887,7 @@ describe("排队中(request_queued)", () => { const before = rows.get(1); expect(applyBatch(rows, [{ kind: "request_dequeued", id: 1, at_ms: 1_004_000 }])).toBe(true); expect(rows.get(1)).not.toBe(before); - expect(rows.get(1)?.queued).toBeUndefined(); + expect(rows.get(1)?.waiting).toBeUndefined(); expect(rows.get(1)?.state).toBe("in_flight"); // 半路才打开:快照里排过、又不排了的,照原样重放之后也不在排队 const later = new Map(); @@ -896,7 +896,7 @@ describe("排队中(request_queued)", () => { [open(started({ id: 7 }), queued(7, ["anthropic"]), { kind: "request_dequeued", id: 7, at_ms: 1_004_000 })], { started: new Set(), ended: new Set() }, ); - expect(later.get(7)?.queued).toBeUndefined(); + expect(later.get(7)?.waiting).toBeUndefined(); }); it("排着队就结束了(等到头、客户端断开、手动中止):不再说在排队", () => { @@ -922,13 +922,13 @@ describe("排队中(request_queued)", () => { sent_bytes: 1, received_bytes: 1, }); - for (const id of [1, 2, 3]) expect(rows.get(id)?.queued).toBeUndefined(); + for (const id of [1, 2, 3]) expect(rows.get(id)?.waiting).toBeUndefined(); }); it("半路才打开:快照里的排队照原样重放", () => { const rows = new Map(); applyInFlight(rows, [open(started({ id: 7 }), queued(7, ["anthropic"]))], { started: new Set(), ended: new Set() }); - expect(rows.get(7)?.queued?.upstreams).toEqual(["anthropic"]); + expect(rows.get(7)?.waiting?.upstreams).toEqual(["anthropic"]); }); it("排队那一条换成新对象(表格据此重画);上游限流的事件不碰任何一行", () => { @@ -952,3 +952,71 @@ describe("排队中(request_queued)", () => { expect(rows.get(1)).toBe(now); }); }); + +describe("重试中(request_retrying)", () => { + const retrying = (id: number, attempt = 1, upstreams: string[] = ["anthropic"]) => + ({ + kind: "request_retrying", + id, + upstreams, + attempt, + retry_at_ms: 1_002_000, + until_ms: 1_120_000, + at_ms: 1_000_000, + }) satisfies CoreEvent; + const dequeued = (id: number) => ({ kind: "request_dequeued", id, at_ms: 1_002_000 }) satisfies CoreEvent; + + it("退避时在等:要重试的几家、第几次和最晚等到什么时候;发出去了就撤掉", () => { + const rows = new Map(); + applyEvent(rows, started()); + const before = rows.get(1); + expect(applyBatch(rows, [retrying(1, 1, ["anthropic", "openrouter"])])).toBe(true); + expect(rows.get(1)).not.toBe(before); + expect(rows.get(1)?.waiting).toEqual({ upstreams: ["anthropic", "openrouter"], untilMs: 1_120_000, retry: 1 }); + expect(rows.get(1)?.state).toBe("in_flight"); + applyEvent(rows, dequeued(1)); + expect(rows.get(1)?.waiting).toBeUndefined(); + // 又失败、再退避:第二次 + applyEvent(rows, retrying(1, 2)); + expect(rows.get(1)?.waiting?.retry).toBe(2); + }); + + it("排队之后转为重试、重试之后又排队:以最后一条为准", () => { + const rows = new Map(); + applyEvent(rows, started()); + applyEvent(rows, { kind: "request_queued", id: 1, upstreams: ["anthropic"], until_ms: 1_120_000, at_ms: 1_000_500 }); + applyEvent(rows, dequeued(1)); + applyEvent(rows, retrying(1)); + expect(rows.get(1)?.waiting?.retry).toBe(1); + applyEvent(rows, dequeued(1)); + applyEvent(rows, { kind: "request_queued", id: 1, upstreams: ["anthropic"], until_ms: 1_120_000, at_ms: 1_004_000 }); + expect(rows.get(1)?.waiting).toEqual({ upstreams: ["anthropic"], untilMs: 1_120_000 }); + }); + + it("等着重试就结束了(等到头、手动中止):不再说在重试", () => { + const rows = new Map(); + applyEvent(rows, started()); + applyEvent(rows, retrying(1)); + applyEvent(rows, { + kind: "request_failed", + id: 1, + model: "claude-sonnet-4-5", + source: "upstream", + message: { code: "gw.request.aborted", text: "" }, + }); + expect(rows.get(1)?.waiting).toBeUndefined(); + expect(rows.get(1)?.state).toBe("failed"); + }); + + it("半路才打开:快照里的重试照原样重放,等过、发出去了的不再说在重试", () => { + const rows = new Map(); + applyInFlight( + rows, + [open(started({ id: 7 }), retrying(7, 3)), open(started({ id: 8 }), retrying(8), dequeued(8))], + { started: new Set(), ended: new Set() }, + ); + expect(rows.get(7)?.waiting).toEqual({ upstreams: ["anthropic"], untilMs: 1_120_000, retry: 3 }); + expect(rows.get(8)?.waiting).toBeUndefined(); + expect(rows.get(8)?.state).toBe("in_flight"); + }); +}); diff --git a/src/generated/tw-api.ts b/src/generated/tw-api.ts index 03314f23..28037af6 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 = 52; +export const CONTROL_API_VERSION = 53; /** * 手动中止(`POST /request/{id}/abort`、`POST /sessions/{id}/abort`)叫停了哪几个请求。 @@ -310,7 +310,15 @@ pause_ms?: number | null, * 的一跳(满着、被拒、转换不了……)是它要走的那个 —— 连不上的那一跳,问题可能就出在 * 这个代理上。WebSocket 只连直连的上游,一律是 None */ -proxy?: string | null, }; +proxy?: string | null, +/** + * 这一跳是第几次重试(从 1 起)。第一轮的几跳没有 + */ +retry?: number | null, +/** + * 这一跳发出之前退避等了多少毫秒。不算在 `ms` 里 + */ +backoff_ms?: number | null, }; /** * 上游接不接受凭据。 @@ -1500,7 +1508,23 @@ upstreams: Array, /** * 最多等到什么时候(Unix 毫秒):这个请求的等待期限(`failover.wait_secs`) */ -until_ms: number, at_ms: number, } | { "kind": "request_dequeued", id: number, at_ms: number, } | { "kind": "request_routed", id: number, +until_ms: number, at_ms: number, } | { "kind": "request_dequeued", id: number, at_ms: number, } | { "kind": "request_retrying", id: number, +/** + * 要重试哪几家,按候选的顺序 + */ +upstreams: Array, +/** + * 接下来是第几次重试,从 1 起 + */ +attempt: number, +/** + * 下一次什么时候发出(Unix 毫秒) + */ +retry_at_ms: number, +/** + * 最多等到什么时候(Unix 毫秒):这个请求的等待期限,同 `RequestQueued::until_ms` + */ +until_ms: number, at_ms: number, } | { "kind": "request_routed", id: number, /** * 走的哪条路由,同 `RequestStarted::route` */ diff --git a/src/requestRouting.test.ts b/src/requestRouting.test.ts index f5d67990..bff2623d 100644 --- a/src/requestRouting.test.ts +++ b/src/requestRouting.test.ts @@ -171,6 +171,18 @@ describe("路由那一页", () => { expect(f.note).toEqual({ kind: "waited", count: 2 }); }); + it("候选都暂时出错、退避之后又发了一轮:说已重试,不说成等它恢复", () => { + const retried: AttemptView = { ...overloaded("anthropic"), retry: 1, backoff_ms: 2_100 }; + const last: AttemptView = { ...served("anthropic"), retry: 2, backoff_ms: 3_900 }; + const f = routingFacts(row({ attempts: [overloaded("anthropic"), retried, last] }), false)!; + expect(f.note).toEqual({ kind: "retried", count: 2 }); + expect(f.hops.map((h) => h.attempt.retry ?? null)).toEqual([null, 1, 2]); + // 两家都试过、第二轮换到了另一家:同样是重试 + const again: AttemptView = { ...served("anthropic"), retry: 1, backoff_ms: 2_000 }; + const two = routingFacts(row({ attempts: [overloaded("anthropic"), overloaded("openrouter"), again] }), false)!; + expect(two.note).toEqual({ kind: "retried", count: 2 }); + }); + it("等到了空位:只有一跳,排队的时间在那一跳上,不加说明", () => { const f = routingFacts(row({ attempts: [{ ...served("anthropic"), queued_ms: 1_240 }] }), false)!; expect(f.note).toBeNull(); diff --git a/src/requestRouting.ts b/src/requestRouting.ts index 1c0aa7fa..114f3c5e 100644 --- a/src/requestRouting.ts +++ b/src/requestRouting.ts @@ -111,6 +111,8 @@ export interface Hop { * 429 暂停了的 —— 那不是上游的失败,不说「失败」(每一跳为什么没接下在它自己那一行) * · `waited`:前 `count` 跳没有接下它,最后接下的是前面试过的一家:等它恢复(限流结束、 * 用量窗口空出来、空出位置)之后发了过去,没有换上游 + * · `retried`:前 `count` 跳没有接下它,最后一跳是重试(`retry`):候选都试过、最后一次是 + * 暂时的故障,退避之后又发了一轮。和 `waited` 一样最后那一家可能是前面试过的,但不是等它恢复 * · `limited`:这把网关密钥的用量上限拒绝了它:没有发往任何上游 * · `busy`:剩下的上游都满着,等过了也没空出位置。`tried` 是在那之前真的发出去、没成的 * 几跳;0 就是没有发往任何上游 @@ -131,6 +133,7 @@ export type RoutingNote = | { kind: "failover"; failed: number } | { kind: "switched"; count: number } | { kind: "waited"; count: number } + | { kind: "retried"; count: number } | { kind: "limited" } | { kind: "busy" | "throttled" | "full" | "timeout"; tried: number } | { kind: "failover_denied"; failed: number; rule: string } @@ -221,11 +224,14 @@ export function routingFacts( const earlier = attempts.slice(0, -1); const last = attempts[n - 1]!; const plain = earlier.every((a) => !notFailed(a)); - note = earlier.some((a) => a.provider === last.provider) - ? { kind: "waited", count: n - 1 } - : plain - ? { kind: "failover", failed: n - 1 } - : { kind: "switched", count: n - 1 }; + note = + last.retry != null + ? { kind: "retried", count: n - 1 } + : earlier.some((a) => a.provider === last.provider) + ? { kind: "waited", count: n - 1 } + : plain + ? { kind: "failover", failed: n - 1 } + : { kind: "switched", count: n - 1 }; } return { diff --git a/src/requestTable.test.ts b/src/requestTable.test.ts index fa59543c..468d304f 100644 --- a/src/requestTable.test.ts +++ b/src/requestTable.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { EMPTY_FILTER, facets, filterRows, queuedFor, sortRows, upstreamText } from "./requestTable"; +import { EMPTY_FILTER, facets, filterRows, waitingFor, sortRows, upstreamText } from "./requestTable"; import type { RequestRow } from "./types"; import { setLang } from "@/i18n"; import { plain } from "@/i18n/core.i18n"; @@ -249,16 +249,23 @@ describe("只看无法计价的", () => { }); }); -describe("排着队的那一行", () => { +describe("排着队、等着重试的那一行", () => { const waiting = { upstreams: ["anthropic", "openrouter"], untilMs: 2_000_000 }; it("在跑、排着队:在等的那几家", () => { - expect(queuedFor(row({ id: 1, state: "in_flight", queued: waiting }))).toEqual(["anthropic", "openrouter"]); + expect(waitingFor(row({ id: 1, state: "in_flight", waiting }))).toEqual(["anthropic", "openrouter"]); + }); + + it("在跑、等着重试:在等的那几家,和排队一样", () => { + expect(waitingFor(row({ id: 1, state: "in_flight", waiting: { ...waiting, retry: 2 } }))).toEqual([ + "anthropic", + "openrouter", + ]); }); it("没在排、已经结束、说不出等谁的:没有", () => { - expect(queuedFor(row({ id: 1, state: "in_flight" }))).toBeNull(); - expect(queuedFor(row({ id: 1, state: "failed", queued: waiting }))).toBeNull(); - expect(queuedFor(row({ id: 1, state: "in_flight", queued: { upstreams: [], untilMs: 0 } }))).toBeNull(); + expect(waitingFor(row({ id: 1, state: "in_flight" }))).toBeNull(); + expect(waitingFor(row({ id: 1, state: "failed", waiting }))).toBeNull(); + expect(waitingFor(row({ id: 1, state: "in_flight", waiting: { upstreams: [], untilMs: 0 } }))).toBeNull(); }); }); diff --git a/src/requestTable.ts b/src/requestTable.ts index 2eadf5c1..99bbd0e3 100644 --- a/src/requestTable.ts +++ b/src/requestTable.ts @@ -89,11 +89,11 @@ export function upstreamText(r: RequestRow): string { } /** - * 排着队的那一行在等哪几家上游(`request_queued` 之后、路由出结论之前)。不在排队、 - * 已经结束的是 null。事件里说不出是哪几家(空的)也当没在排 + * 排着队、等着重试的那一行在等哪几家上游(`request_queued`、`request_retrying` 之后,路由出 + * 结论之前)。不在等、已经结束的是 null。事件里说不出是哪几家(空的)也当没在等 */ -export function queuedFor(r: Pick): string[] | null { - return r.state === "in_flight" && r.queued && r.queued.upstreams.length > 0 ? r.queued.upstreams : null; +export function waitingFor(r: Pick): string[] | null { + return r.state === "in_flight" && r.waiting && r.waiting.upstreams.length > 0 ? r.waiting.upstreams : null; } function valueOf(r: RequestRow, key: SortKey): number | null { diff --git a/src/settings/FailoverSection.i18n.ts b/src/settings/FailoverSection.i18n.ts index 50176c79..220533b7 100644 --- a/src/settings/FailoverSection.i18n.ts +++ b/src/settings/FailoverSection.i18n.ts @@ -24,7 +24,7 @@ export const failoverText = messages( idle_timeout_secs: "上游连续这么久没有发出内容即放弃:尚未向客户端发出内容时转到下一个上游,已经发出时以错误结束。默认 300 秒。", wait_secs: - "上游并发已满、被限流或用量上限用满,以及密钥的分钟、小时上限用满时,请求合计最多等待的时长,超过后返回错误。默认 120 秒,0 表示不等待。", + "请求排队等待上游空出位置、限流结束或用量上限恢复(含密钥的分钟、小时上限),以及上游暂时出错后重试,两者合计最多等待的时长,超过后返回错误。默认 120 秒,0 表示不排队也不重试。", }, times: "次", secs: "秒", @@ -61,7 +61,7 @@ export const failoverText = messages( idle_timeout_secs: "An upstream that sends no content for this long is given up on: the request moves to the next upstream if nothing has reached the client yet, and ends with an error otherwise. 300 s by default.", wait_secs: - "Total time a request waits while upstreams are full, rate-limited or at a usage limit, or while a key's per-minute or per-hour limit is used up. After that it ends with an error. 120 s by default; 0 means no wait.", + "Total time a request spends queueing for a full, rate-limited or usage-limited upstream (or a key's per-minute or per-hour limit) and retrying upstreams that failed temporarily; after that it ends with an error. 120 s by default; 0 means no queueing or retrying.", }, times: "times", secs: "s", diff --git a/src/traffic/RequestTable.tsx b/src/traffic/RequestTable.tsx index 9e52d6a1..ac5486f5 100644 --- a/src/traffic/RequestTable.tsx +++ b/src/traffic/RequestTable.tsx @@ -19,7 +19,7 @@ import { compact, latency, money, ms, statusTone, tokens, traffic, when } from " import { failureKind, notSentText, translatedText } from "@/labels"; import { ruleName } from "@/security/labels"; import { notSent } from "@/requestRouting"; -import { promptTokens, queuedFor, upstreamText, type Filter, type SortDir, type SortKey } from "@/requestTable"; +import { promptTokens, upstreamText, waitingFor, type Filter, type SortDir, type SortKey } from "@/requestTable"; import { resumeClock } from "@/upstreams/throttle"; import type { ContentHit, RequestRow } from "@/types"; import { AliasMark } from "@/aliases/AliasMark"; @@ -420,8 +420,8 @@ function requestWidths(r: RequestRow, t: Text, today: number, via: ViaConfig | n const sent = notSent(r); const mark = rowMark(r, via); const phrase = r.local || sent; - const waiting = queuedFor(r); - const text = sent ? notSentText(sent) : waiting ? t.queuedFor(waiting) : r.local || r.provider ? upstreamText(r) : "—"; + const waiting = waitingFor(r); + const text = sent ? notSentText(sent) : waiting ? t.waitingFor(waiting) : r.local || r.provider ? upstreamText(r) : "—"; // 上游的名字和标志一起封顶;本地应答、没有发往上游的那一句是说明,不截断 const name = (phrase ? textWidth(text) : capped(textWidth(text), NAME_PX.upstream, 22)) + 3; // 那一句可以在词间折行(见 `UpstreamCell`):最窄是最长的那个词 @@ -1165,7 +1165,8 @@ function LatencyCell({ r }: { r: RequestRow }) { /** 状态那一格的字:在跑的写状态码(响应头到了才有),失败、取消写成字 */ function statusText(r: RequestRow, t: Text): string { - if (r.state === "in_flight") return r.queued ? t.queued : r.status != null ? String(r.status) : "…"; + if (r.state === "in_flight") + return r.waiting ? (r.waiting.retry != null ? t.retrying : t.queued) : r.status != null ? String(r.status) : "…"; if (r.state === "failed") return failureKind(r.error) === "aborted" ? t.aborted : t.failed; if (r.state === "cancelled") return t.cancelled; return r.status != null ? String(r.status) : ""; @@ -1198,7 +1199,7 @@ function StatusCell({ r }: { r: RequestRow }) { function UpstreamCell({ r, via }: { r: RequestRow; via: ViaConfig | null }) { const t = useText(trafficText); const sent = notSent(r); - const waiting = queuedFor(r); + const waiting = waitingFor(r); const mark = rowMark(r, via); const secrets = r.secrets && r.secrets.items.length > 0 ? secretsMark(r.secrets) : null; const edits = contentEdits(r.stripped); @@ -1221,11 +1222,18 @@ function UpstreamCell({ r, via }: { r: RequestRow; via: ViaConfig | null }) { {notSentText(sent)} ) : waiting ? ( - // 排着队:写在等的那几家(首选的那一家可能正是限流中的),悬停说全和最晚等到什么时候 + // 排着队、等着重试:写在等的那几家(首选的那一家可能正是限流中的),悬停说全和最晚等到 + // 什么时候 <> - - {t.queuedFor(waiting)} + + {t.waitingFor(waiting)} ) : ( diff --git a/src/traffic/Traffic.i18n.tsx b/src/traffic/Traffic.i18n.tsx index f65d92c6..1a2059bd 100644 --- a/src/traffic/Traffic.i18n.tsx +++ b/src/traffic/Traffic.i18n.tsx @@ -144,9 +144,12 @@ export const trafficText = messages( aborted: "手动中止", /** 在跑、但要去的上游此刻发不了(限流中、用量已满、满着),在等其中一家 */ queued: "排队中", - /** 排队的那一行,上游一格写在等的几家;悬停说全,和最晚等到什么时候 */ - queuedFor: (names: string[]) => names.join("、"), + /** 在跑、候选都试过了,最后一次是暂时的故障,过一会儿再试那几家。样子和排队中一样 */ + retrying: "重试中", + /** 排队、等着重试的那一行,上游一格写在等的几家;悬停说全,和最晚等到什么时候 */ + waitingFor: (names: string[]) => names.join("、"), queuedTip: (names: string[], at: string) => `排队等待上游:${names.join("、")}。最晚等到 ${at}。`, + retryingTip: (names: string[], at: string) => `等待重试上游:${names.join("、")}。最晚等到 ${at}。`, // 上游一列的徽标和它们的悬浮说明 /** 客户端写的是别名,按这家上游换成了它的模型名。模型那一列照旧是客户端写的名称 */ @@ -297,8 +300,10 @@ export const trafficText = messages( cancelled: "Canceled", aborted: "Aborted", queued: "Queued", - queuedFor: (names: string[]) => names.join(", "), + retrying: "Retrying", + waitingFor: (names: string[]) => names.join(", "), queuedTip: (names: string[], at: string) => `Queued for ${names.join(", ")}. Waits until ${at} at most.`, + retryingTip: (names: string[], at: string) => `Retrying ${names.join(", ")}. Waits until ${at} at most.`, aliasTip: (model: string, upstream: string, sent: string) => `${model} is an alias; the model sent to ${upstream} is ${sent}.`, diff --git a/src/traffic/drawer/RequestDrawer.i18n.tsx b/src/traffic/drawer/RequestDrawer.i18n.tsx index 84e4fa3d..558f4e32 100644 --- a/src/traffic/drawer/RequestDrawer.i18n.tsx +++ b/src/traffic/drawer/RequestDrawer.i18n.tsx @@ -111,6 +111,10 @@ export const requestDrawerText = messages( attempts: "尝试链", /** 这一跳等空位等了多久(上游满着)。秒数已经按一位小数写好 */ queued: (s: string) => `排队 ${s} 秒`, + /** 第二轮起的一跳:第几次重试 */ + retry: (n: number) => `第 ${n} 次重试`, + /** 这一跳发出之前退避等了多久。秒数的写法同 `queued` */ + backoff: (s: string) => `等待 ${s} 秒`, /** 放弃了的一跳(无响应超时)下面那一行:上游没报用量时,网关估的输入 */ abandonedEstimate: (n: string) => `输入约 ${n} token`, mayBeBilled: "上游可能已计费", @@ -135,6 +139,8 @@ export const requestDrawerText = messages( switched: (n: number) => `已自动切换上游:前 ${n} 次尝试未接下此请求。`, /** 最后接下它的是前面试过的一家:等它恢复之后发了过去 */ waited: (n: number) => `前 ${n} 次尝试未接下此请求,上游恢复后已发出。`, + /** 最后一跳是重试:候选都试过、最后一次是暂时的故障,又发了一轮 */ + retried: (n: number) => `前 ${n} 次尝试未接下此请求,已自动重试。`, limited: "网关密钥已达到用量上限,此请求未发往任何上游。", busy: "上游均已达到并发上限,等待期间没有空出位置,此请求未发往任何上游。", busyAfterTries: (n: number) => `发出的 ${n} 次尝试未成功,其余上游均已达到并发上限,等待期间没有空出位置。`, @@ -281,6 +287,8 @@ export const requestDrawerText = messages( reason: "Reason", attempts: "Attempts", queued: (s: string) => `Queued ${s} s`, + retry: (n: number) => `Retry ${n}`, + backoff: (s: string) => `Waited ${s} s`, abandonedEstimate: (n: string) => `About ${n} input tokens`, mayBeBilled: "may have been billed by the upstream", mayBeBilledTip: "Whether the upstream charged for these tokens is unknown; they are not included in this request's cost.", @@ -314,6 +322,10 @@ export const requestDrawerText = messages( n === 1 ? "The first attempt did not take this request; it was sent once the upstream was available again." : `The first ${n} attempts did not take this request; it was sent once the upstream was available again.`, + retried: (n: number) => + n === 1 + ? "The first attempt did not take this request; it was retried automatically." + : `The first ${n} attempts did not take this request; it was retried automatically.`, limited: "The gateway key had reached a usage limit; this request was not sent to any upstream.", busy: "Every upstream was at its concurrency limit and none freed up in time; this request was not sent to any upstream.", busyAfterTries: (n: number) => diff --git a/src/traffic/drawer/Routing.tsx b/src/traffic/drawer/Routing.tsx index e4e81127..dbeb08d8 100644 --- a/src/traffic/drawer/Routing.tsx +++ b/src/traffic/drawer/Routing.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import { cn } from "@/lib/utils"; +import { Badge } from "@/ui/badge"; import { useText } from "@/i18n"; import { coreText } from "@/i18n/core.i18n"; import { ms } from "@/format"; @@ -98,6 +99,12 @@ export function Routing({ r, plugins, running }: { r: HistoryRow; plugins: Plugi {a.provider} + {/* 第二轮起的几跳:候选都试过、最后一次是暂时的故障,退避之后又发的 */} + {a.retry != null && ( + + {t.retry(a.retry)} + + )} {/* 这一跳发出的模型名:有一跳改了名、或者用了别名、指定模型时每一跳都写, 费用也按它算 */} {m.show && m.hops[i] && } @@ -125,6 +132,12 @@ export function Routing({ r, plugins, running }: { r: HistoryRow; plugins: Plugi )} )} + {/* 重试之前退避等的时间,和排队一样不算在这一跳的耗时里,另写一项 */} + {a.backoff_ms != null && a.backoff_ms > 0 && ( + + {t.backoff(seconds(a.backoff_ms))} + + )} {/* 排队的时间(等空位、等限流或用量恢复)不算在这一跳的耗时里,另写一项 */} {a.queued_ms != null && a.queued_ms > 0 && ( @@ -180,7 +193,7 @@ function AbandonedUsage({ usage: u }: { usage: AttemptUsage }) { ); } -/** 排队等了多久:不到 10 秒的留一位小数,最少写 0.1 */ +/** 排队、重试前退避等了多久:不到 10 秒的留一位小数,最少写 0.1 */ function seconds(ms: number): string { const s = ms / 1000; return String(s < 10 ? Math.max(0.1, Math.round(s * 10) / 10) : Math.round(s)); @@ -230,6 +243,8 @@ function noteText(n: RoutingNote, t: (typeof requestDrawerText)["zh"]): string { return t.switched(n.count); case "waited": return t.waited(n.count); + case "retried": + return t.retried(n.count); case "limited": return t.limited; case "busy": diff --git a/src/types.ts b/src/types.ts index b102e705..9fc8a32d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -109,13 +109,19 @@ export interface RequestRow { */ state: "in_flight" | "done" | "failed" | "cancelled"; /** - * 正在排队:要去的上游此刻发不了(限流中、用量已满、满着),在等其中一家先空出来,最多等到 - * `untilMs`。`request_queued` 到了才有;不排了(`request_dequeued`:等到了、被中止了、等不到 - * 了)、路由出了结论(`request_routed`)或者请求结束就撤掉。**路由事件要等整条尝试链走完**, - * 等到了、正在发的那一段靠 `request_dequeued` 撤。发出去又收到 429、回去接着等的,会再来一条 - * `request_queued` + * 正在等,还没有发给哪一家,最多等到 `untilMs`。两种: + * + * · 排队(`request_queued`,没有 `retry`):要去的上游此刻发不了(限流中、用量已满、满着), + * 在等其中一家先空出来 + * · 重试前的退避(`request_retrying`,`retry` 是接下来第几次重试):候选都试过了,最后一次 + * 是暂时的故障、客户端还没收到内容,过一会儿再试暂时出错的那几家 + * + * 不等了(`request_dequeued`:等到了、被中止了、等不到了)、路由出了结论(`request_routed`) + * 或者请求结束就撤掉。**路由事件要等整条尝试链走完**,等到了、正在发的那一段靠 + * `request_dequeued` 撤。发出去又收到 429、回去接着等的,会再来一条 `request_queued`; + * 重试又失败、再退避的,再来一条 `request_retrying` */ - queued?: { upstreams: string[]; untilMs: number }; + waiting?: { upstreams: string[]; untilMs: number; retry?: number }; status?: number; /** 响应头到的时刻 */ ttfbMs?: number; @@ -234,19 +240,24 @@ export function applyEvent(rows: Map, ev: CoreEvent): void { } case "request_queued": { const r = rows.get(ev.id); - if (r) r.queued = { upstreams: [...ev.upstreams], untilMs: ev.until_ms }; + if (r) r.waiting = { upstreams: [...ev.upstreams], untilMs: ev.until_ms }; + break; + } + case "request_retrying": { + const r = rows.get(ev.id); + if (r) r.waiting = { upstreams: [...ev.upstreams], untilMs: ev.until_ms, retry: ev.attempt }; break; } case "request_dequeued": { const r = rows.get(ev.id); - if (r) r.queued = undefined; + if (r) r.waiting = undefined; break; } case "request_finished": { const r = rows.get(ev.id); if (r) { r.state = "done"; - r.queued = undefined; + r.waiting = undefined; r.status = ev.status; r.sentBytes = ev.sent_bytes; r.receivedBytes = ev.received_bytes; @@ -265,7 +276,7 @@ export function applyEvent(rows: Map, ev: CoreEvent): void { const r = rows.get(ev.id); if (r) { r.state = "cancelled"; - r.queued = undefined; + r.waiting = undefined; // 响应头之前就断开的没有状态码 —— 那就保留原样(也是没有) if (ev.status != null) r.status = ev.status; // 还没发给上游就走了的没有(不是 0) @@ -293,7 +304,7 @@ export function applyEvent(rows: Map, ev: CoreEvent): void { r.sentModel = last.model ?? undefined; } if (r) { - r.queued = undefined; + r.waiting = undefined; r.route = ev.route; r.rule = ev.rule; // 选定上游之后才判断的改写在这里才有 @@ -373,7 +384,7 @@ export function applyEvent(rows: Map, ev: CoreEvent): void { const r = rows.get(ev.id); if (r) { r.state = "failed"; - r.queued = undefined; + r.waiting = undefined; r.error = ev.message; if (ev.duration_ms != null) r.durationMs = ev.duration_ms; // 一跳都没发出去的失败没有(被规则拒绝、上游都满着……) diff --git a/src/upstreams/data.ts b/src/upstreams/data.ts index 4bfc0624..f5d8830d 100644 --- a/src/upstreams/data.ts +++ b/src/upstreams/data.ts @@ -239,15 +239,15 @@ const ENDED_CAP = 512; * 此刻每个上游有几个请求在途。 * * 开始事件里就有上游;换了上游(转移)以 `request_routed` 的最后一跳为准;三种结局 - * 都算结束。**排着队的不算**(`request_queued` 之后、`request_dequeued` 或 `request_routed` - * 之前):它在等上游空出来,还没有发给哪一家。**页面半路挂上**时,已经在飞的那几个没有开始事件可听 —— 挂上时读一次 + * 都算结束。**排着队、等着重试的不算**(`request_queued`、`request_retrying` 之后, + * `request_dequeued` 或 `request_routed` 之前):它在等,还没有发给哪一家。**页面半路挂上**时,已经在飞的那几个没有开始事件可听 —— 挂上时读一次 * `/in-flight` 的快照,把每个请求到目前为止的事件照原样重放(比快照先到的结束事件 * 记下来,免得补回一个已经结束的)。core 停了、重启了,在途的全部作废:它们不会再有 * 结局。 */ export function useInFlight(): ReadonlyMap { const live = useRef(new Map()); - /** 正在排队的:在 `live` 里,但不算在哪一家头上 */ + /** 正在排队、等着重试的:在 `live` 里,但不算在哪一家头上 */ const queued = useRef(new Set()); const ended = useRef(new Set()); const [tick, setTick] = useState(0); @@ -258,7 +258,7 @@ export function useInFlight(): ReadonlyMap { /** 开始、路由落到「哪个请求在哪个上游」上。事件流和快照重放用的是同一段 */ const track = (ev: CoreEvent) => { if (ev.kind === "request_started") live.current.set(ev.id, ev.provider); - else if (ev.kind === "request_queued") { + else if (ev.kind === "request_queued" || ev.kind === "request_retrying") { if (live.current.has(ev.id)) queued.current.add(ev.id); } else if (ev.kind === "request_dequeued") queued.current.delete(ev.id); else if (ev.kind === "request_routed") { @@ -299,6 +299,7 @@ export function useInFlight(): ReadonlyMap { break; case "request_routed": case "request_queued": + case "request_retrying": case "request_dequeued": track(ev); break; diff --git a/src/useRequests.ts b/src/useRequests.ts index 54dd790a..6b657880 100644 --- a/src/useRequests.ts +++ b/src/useRequests.ts @@ -206,6 +206,7 @@ function touches(ev: CoreEvent): number | null { case "request_failed": case "request_routed": case "request_queued": + case "request_retrying": case "request_dequeued": case "request_priced": case "secrets_found": From a8d58270a85fc4364c16207095697d8316efaecb Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:55:25 +0800 Subject: [PATCH 2/9] Regenerate core API types for retries; fill the new attempt fields in a test Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/notices/tests.rs | 2 ++ src/generated/tw-api.ts | 25 +++++++++++++------------ 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/notices/tests.rs b/src-tauri/src/notices/tests.rs index 1400e5d9..40c67b82 100644 --- a/src-tauri/src/notices/tests.rs +++ b/src-tauri/src/notices/tests.rs @@ -692,6 +692,8 @@ async fn an_unreachable_upstream_is_only_listed_and_needs_real_evidence_to_clear skipped: None, pause_ms: None, proxy: None, + retry: None, + backoff_ms: None, }], billing: tw_api::Billing::PerToken, egress: None, diff --git a/src/generated/tw-api.ts b/src/generated/tw-api.ts index 28037af6..496eb571 100644 --- a/src/generated/tw-api.ts +++ b/src/generated/tw-api.ts @@ -306,19 +306,20 @@ skipped?: ServeSkip | null, */ pause_ms?: number | null, /** - * 这一跳走的代理:这家上游配置的代理名(跟随系统的是 `system`),直连是 None。没发出去 - * 的一跳(满着、被拒、转换不了……)是它要走的那个 —— 连不上的那一跳,问题可能就出在 - * 这个代理上。WebSocket 只连直连的上游,一律是 None + * 这一跳是第几次重试,从 1 数(见 [`Event::RequestRetrying`]):一轮重试里的每一跳都带着。 + * 第一轮的没有 */ -proxy?: string | null, +retry?: number | null, /** - * 这一跳是第几次重试(从 1 起)。第一轮的几跳没有 + * 这一跳发出去之前退避了多少毫秒:一轮重试的头一跳带着。不算在 `ms` 里。别的没有 */ -retry?: number | null, +backoff_ms?: number | null, /** - * 这一跳发出之前退避等了多少毫秒。不算在 `ms` 里 + * 这一跳走的代理:这家上游配置的代理名(跟随系统的是 `system`),直连是 None。没发出去 + * 的一跳(满着、被拒、转换不了……)是它要走的那个 —— 连不上的那一跳,问题可能就出在 + * 这个代理上。WebSocket 只连直连的上游,一律是 None */ -backoff_ms?: number | null, }; +proxy?: string | null, }; /** * 上游接不接受凭据。 @@ -1510,19 +1511,19 @@ upstreams: Array, */ until_ms: number, at_ms: number, } | { "kind": "request_dequeued", id: number, at_ms: number, } | { "kind": "request_retrying", id: number, /** - * 要重试哪几家,按候选的顺序 + * 要再试哪几家,按候选的顺序 */ upstreams: Array, /** - * 接下来是第几次重试,从 1 起 + * 接下来是第几次重试,从 1 数 */ attempt: number, /** - * 下一次什么时候发出(Unix 毫秒) + * 下一次什么时候发出去(Unix 毫秒) */ retry_at_ms: number, /** - * 最多等到什么时候(Unix 毫秒):这个请求的等待期限,同 `RequestQueued::until_ms` + * 这个请求的等待期限(Unix 毫秒),同 [`Event::RequestQueued`] 的 `until_ms` */ until_ms: number, at_ms: number, } | { "kind": "request_routed", id: number, /** From b721eb9ad792e8afdeb11d420271f379bcc8d37c Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:43:02 +0800 Subject: [PATCH 3/9] Translate gw.upstream.stream_ended_early Co-Authored-By: Claude Opus 5.5 --- src/i18n/core.zh.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/i18n/core.zh.json b/src/i18n/core.zh.json index 4fb7d4c2..67e669fa 100644 --- a/src/i18n/core.zh.json +++ b/src/i18n/core.zh.json @@ -507,6 +507,7 @@ "gw.upstream.status": "上游「{upstream}」返回 {status}。", "gw.upstream.status_message": "上游「{upstream}」返回 {status}:{message}", "gw.upstream.stream_opening_error": "上游「{upstream}」开始回答后、给出任何内容之前报错({kind}):{message}", + "gw.upstream.stream_ended_early": "上游在给出任何内容之前结束了回答。", "gw.upstream.idle_timeout": "上游「{upstream}」在 {secs} 秒内没有发出内容。", "gw.upstream.idle_timeout_mid_stream": "上游「{upstream}」回答到一半后停止发送内容,{secs} 秒内没有新的内容。", "gw.request.aborted": "请求已被手动中止。", From 800a3519e44524b4b91ecb9db41f7ad8a74b056d Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:15:13 +0800 Subject: [PATCH 4/9] Hand-edit core API types for the reasoning override (until core lands) RuleRewrite.reasoning (RuleReasoning) replaces thinking, SetField "reasoning" replaces "thinking", AttemptView.effort (EffortChange, EffortReason), protocol 54. To be replaced by the types generated from core. Co-Authored-By: Claude Opus 5.5 --- src/generated/tw-api.ts | 41 +++++++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/src/generated/tw-api.ts b/src/generated/tw-api.ts index 496eb571..3cec1bb0 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 = 53; +export const CONTROL_API_VERSION = 54; /** * 手动中止(`POST /request/{id}/abort`、`POST /sessions/{id}/abort`)叫停了哪几个请求。 @@ -319,7 +319,12 @@ backoff_ms?: number | null, * 的一跳(满着、被拒、转换不了……)是它要走的那个 —— 连不上的那一跳,问题可能就出在 * 这个代理上。WebSocket 只连直连的上游,一律是 None */ -proxy?: string | null, }; +proxy?: string | null, +/** + * 这一跳发出的推理强度和客户端要的不一样时才有:规则改写、上游拒收后改档重发、记住了该模型 + * 不支持的档位。被拒收的那一跳本身是一跳普通的 `status: 400`,改档重发的那一跳带着它 + */ +effort?: EffortChange | null, }; /** * 上游接不接受凭据。 @@ -1300,6 +1305,25 @@ skipped: Array, */ converted: Array, }; +/** + * 一跳发出的推理强度和客户端要的不一样([`AttemptView::effort`])。 + */ +export type EffortChange = { +/** + * 客户端要的档位(`minimal` … `max`) + */ +asked: string, +/** + * 发出去的档位;None = 去掉了推理强度 + */ +sent: string | null, reason: EffortReason, }; + +/** + * 推理强度为什么变了:`rule`(规则改写)/ `rejected`(上游拒收了原来的档位,改档重发)/ + * `remembered`(之前记住了该模型不支持原来的档位) + */ +export type EffortReason = "rule" | "rejected" | "remembered"; + /** * 一次请求的观测事件。UI 的实时列表吃这个。 */ @@ -4713,9 +4737,14 @@ export type RuleRewrite = { */ model?: string | null, max_tokens?: number | null, /** - * 打开或关闭扩展思考 + * 推理:`off` 去掉推理配置;一个档位只改已开启推理的请求的推理强度 + */ +reasoning?: RuleReasoning | null, }; + +/** + * 规则改写的推理:关闭,或者一个推理强度档位。 */ -thinking?: boolean | null, }; +export type RuleReasoning = "off" | "low" | "medium" | "high" | "xhigh" | "max"; /** * 命中那一刻的规则([`SecurityEventView::rule_snapshot`])。**跟着记录存**:规则之后改了、 @@ -5349,14 +5378,14 @@ peak_input_tokens: number, models: Array, errors: number, }; /** * 路由规则 `set` 里的改写。 */ -export type SetField = "model" | "max_tokens" | "thinking" | "only_at_session_start"; +export type SetField = "model" | "max_tokens" | "reasoning" | "only_at_session_start"; /** * 一项参数改写。 */ export type SetView = { /** - * `model`(换模型,整个 prompt cache 作废)/ `max_tokens` / `thinking` / + * `model`(换模型,整个 prompt cache 作废)/ `max_tokens` / `reasoning`(值是 [`RuleReasoning`] 的标识)/ * `only_at_session_start`(以上改写只在新会话开始时应用,值是 `true`) */ field: SetField, value: string, }; From 587a246a85aac75473e95f545a09ca64e4e100e7 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:15:13 +0800 Subject: [PATCH 5/9] Routing: reasoning override in rules; show effort changes in the attempt chain The rule dialog's extended-thinking control (keep/on/off) becomes Reasoning: Unchanged | Off | low | medium | high | xhigh | max, on its own row with a note that a level only rewrites requests that already have reasoning on. Rule summaries and dry-run rewrites read "Reasoning max" / "Reasoning off". Each attempt whose reasoning effort differs from what the client asked gets a line saying why (rule, rejected and resent, or remembered). A rejected hop and its resend count as one attempt in the note under the chain, so it no longer says the upstream recovered. Co-Authored-By: Claude Opus 5.5 --- src/labels.i18n.ts | 23 ++++++++++++++++++++ src/labels.test.ts | 38 ++++++++++++++++++++++++++++++++++ src/labels.ts | 27 ++++++++++++++++++++++++ src/requestRouting.test.ts | 16 ++++++++++++++ src/requestRouting.ts | 25 ++++++++++++++-------- src/routing/RuleDialog.i18n.ts | 9 ++++---- src/routing/RuleDialog.tsx | 31 +++++++++++++-------------- src/routing/chain.test.ts | 2 +- src/routing/model.i18n.ts | 4 ---- src/routing/model.test.ts | 30 +++++++++++++++++++++++++-- src/routing/model.ts | 22 ++++++++++++-------- src/traffic/drawer/Routing.tsx | 5 ++++- 12 files changed, 187 insertions(+), 45 deletions(-) diff --git a/src/labels.i18n.ts b/src/labels.i18n.ts index 2d7e8969..d47ce388 100644 --- a/src/labels.i18n.ts +++ b/src/labels.i18n.ts @@ -67,7 +67,18 @@ export const labelsText = messages( setModel: (model: string) => `模型改为 ${model},prompt cache 整体失效`, onlyAtSessionStart: "以上改写仅在新会话开始时应用", setField: (field: string, value: string) => `${field} 改为 ${value}`, + reasoningLevel: (level: string) => `推理 ${level}`, + reasoningOff: "关闭推理", // 尝试链 + /** 一跳发出的推理强度和客户端要的不同 */ + effort: { + rule: (asked: string, sent: string) => `推理强度 ${asked} → ${sent}:规则改写`, + ruleRemoved: "已去掉推理强度:规则改写", + rejected: (asked: string, sent: string) => `推理强度 ${asked} 被上游拒收,已改用 ${sent} 重发`, + rejectedRemoved: (asked: string) => `推理强度 ${asked} 被上游拒收,已去掉后重发`, + remembered: (asked: string, sent: string) => `推理强度 ${asked} → ${sent}:该模型不支持 ${asked}`, + rememberedRemoved: (asked: string) => `已去掉推理强度:该模型不支持 ${asked}`, + }, served: "成功", servedStatus: (status: number) => `成功 · ${status}`, rejected: (status: number) => `${status} · 请求被上游拒绝`, @@ -185,6 +196,18 @@ export const labelsText = messages( setModel: (model: string) => `Model set to ${model}; the entire prompt cache is invalidated`, onlyAtSessionStart: "The rewrites above apply only when a new session starts", setField: (field: string, value: string) => `${field} set to ${value}`, + reasoningLevel: (level: string) => `Reasoning ${level}`, + reasoningOff: "Reasoning off", + effort: { + rule: (asked: string, sent: string) => `Reasoning effort ${asked} → ${sent}: rewritten by a rule`, + ruleRemoved: "Reasoning effort removed: rewritten by a rule", + rejected: (asked: string, sent: string) => + `Reasoning effort ${asked} was rejected by the upstream; resent with ${sent}`, + rejectedRemoved: (asked: string) => `Reasoning effort ${asked} was rejected by the upstream; resent without it`, + remembered: (asked: string, sent: string) => + `Reasoning effort ${asked} → ${sent}: the model does not support ${asked}`, + rememberedRemoved: (asked: string) => `Reasoning effort removed: the model does not support ${asked}`, + }, served: "Succeeded", servedStatus: (status: number) => `Succeeded · ${status}`, rejected: (status: number) => `${status} · Rejected by the upstream`, diff --git a/src/labels.test.ts b/src/labels.test.ts index 7116bf8c..87b485be 100644 --- a/src/labels.test.ts +++ b/src/labels.test.ts @@ -4,6 +4,7 @@ import { GROUP_KINDS, PROBES, attemptText, + effortText, conditionName, failureKind, conditionText, @@ -71,6 +72,11 @@ describe("规则的条件与改写", () => { "Model set to claude-haiku-4-5; the entire prompt cache is invalidated", ); expect(setText({ field: "max_tokens", value: "4096" })).toBe("max_tokens set to 4096"); + expect(setText({ field: "reasoning", value: "max" })).toBe("Reasoning max"); + expect(setText({ field: "reasoning", value: "off" })).toBe("Reasoning off"); + setLang("zh"); + expect(setText({ field: "reasoning", value: "low" })).toBe("推理 low"); + expect(setText({ field: "reasoning", value: "off" })).toBe("关闭推理"); }); it("时段条件写成人话;写不对的按原文", () => { @@ -84,6 +90,38 @@ describe("规则的条件与改写", () => { }); }); +describe("尝试链里一跳改了推理强度", () => { + it("规则改写、上游拒收后重发、记住了不支持的档位;改成别的档位和去掉各一句", () => { + expect(effortText({ asked: "xhigh", sent: "max", reason: "rule" })).toBe("推理强度 xhigh → max:规则改写"); + expect(effortText({ asked: "xhigh", sent: null, reason: "rule" })).toBe("已去掉推理强度:规则改写"); + expect(effortText({ asked: "xhigh", sent: "max", reason: "rejected" })).toBe( + "推理强度 xhigh 被上游拒收,已改用 max 重发", + ); + expect(effortText({ asked: "xhigh", sent: null, reason: "rejected" })).toBe("推理强度 xhigh 被上游拒收,已去掉后重发"); + expect(effortText({ asked: "xhigh", sent: "max", reason: "remembered" })).toBe( + "推理强度 xhigh → max:该模型不支持 xhigh", + ); + expect(effortText({ asked: "xhigh", sent: null, reason: "remembered" })).toBe("已去掉推理强度:该模型不支持 xhigh"); + setLang("en"); + expect(effortText({ asked: "xhigh", sent: "max", reason: "rule" })).toBe( + "Reasoning effort xhigh → max: rewritten by a rule", + ); + expect(effortText({ asked: "xhigh", sent: null, reason: "rule" })).toBe("Reasoning effort removed: rewritten by a rule"); + expect(effortText({ asked: "xhigh", sent: "high", reason: "rejected" })).toBe( + "Reasoning effort xhigh was rejected by the upstream; resent with high", + ); + expect(effortText({ asked: "xhigh", sent: null, reason: "rejected" })).toBe( + "Reasoning effort xhigh was rejected by the upstream; resent without it", + ); + expect(effortText({ asked: "max", sent: "high", reason: "remembered" })).toBe( + "Reasoning effort max → high: the model does not support max", + ); + expect(effortText({ asked: "max", sent: null, reason: "remembered" })).toBe( + "Reasoning effort removed: the model does not support max", + ); + }); +}); + describe("尝试链里的一跳", () => { const quiet = { provider: "anthropic", diff --git a/src/labels.ts b/src/labels.ts index 64d0294a..bdc524b2 100644 --- a/src/labels.ts +++ b/src/labels.ts @@ -15,9 +15,11 @@ import { type BalanceBy, type ConditionView, type ConfigStage, + type EffortChange, type GroupKind, type MismatchView, type ReplayQuote, + type RuleReasoning, type SetView, type TakesEffect, type TranslatedView, @@ -146,6 +148,12 @@ export function mismatchText(m: MismatchView): string { : t.valueMismatch(name, want, got); } +/** 规则改写的推理:`关闭推理` / `推理 max`。规则摘要和试算明细共用 */ +export function reasoningText(r: RuleReasoning): string { + const t = textOf(labelsText); + return r === "off" ? t.reasoningOff : t.reasoningLevel(r); +} + /** 规则命中后的一项参数改写 */ export function setText(s: SetView): string { const t = textOf(labelsText); @@ -154,6 +162,8 @@ export function setText(s: SetView): string { return t.setModel(s.value); case "only_at_session_start": return t.onlyAtSessionStart; + case "reasoning": + return reasoningText(s.value as RuleReasoning); default: return t.setField(s.field, s.value); } @@ -212,6 +222,23 @@ export function attemptText(a: AttemptView): { text: string; ok: boolean; idle?: } } +/** + * 尝试链里一跳发出的推理强度和客户端要的不同(`AttemptView::effort`):为什么、改成了什么。 + * `sent` 为空是去掉了推理强度(按模型的默认) + */ +export function effortText(e: EffortChange): string { + const t = textOf(labelsText).effort; + const sent = e.sent ?? null; + switch (e.reason) { + case "rule": + return sent == null ? t.ruleRemoved : t.rule(e.asked, sent); + case "rejected": + return sent == null ? t.rejectedRemoved(e.asked) : t.rejected(e.asked, sent); + case "remembered": + return sent == null ? t.rememberedRemoved(e.asked) : t.remembered(e.asked, sent); + } +} + /** 尝试链里被规则拒绝的那一跳(选定上游之后判断的规则):没有发给这个上游 */ export function deniedHopText(rule: string): string { return textOf(labelsText).deniedHop(rule); diff --git a/src/requestRouting.test.ts b/src/requestRouting.test.ts index bff2623d..3cee4ef1 100644 --- a/src/requestRouting.test.ts +++ b/src/requestRouting.test.ts @@ -183,6 +183,22 @@ describe("路由那一页", () => { expect(two.note).toEqual({ kind: "retried", count: 2 }); }); + it("推理强度被拒收、改档在同一家重发:和被拒收的那一跳算一次,不说成等它恢复", () => { + const rejected: AttemptView = { provider: "anthropic", outcome: "status", status: 400, ms: 420 }; + const resent: AttemptView = { ...served("anthropic"), effort: { asked: "xhigh", sent: "high", reason: "rejected" } }; + expect(routingFacts(row({ attempts: [rejected, resent] }), false)!.note).toBeNull(); + // 之前换过上游:照常说故障转移,被拒收又重发的那一家只算一次 + const f = routingFacts(row({ attempts: [overloaded("openrouter"), rejected, resent] }), false)!; + expect(f.note).toEqual({ kind: "failover", failed: 1 }); + expect(f.hops).toHaveLength(3); + // 规则改写、记住了不支持的档位:不是重发,照常判断 + const rule: AttemptView = { ...served("anthropic"), effort: { asked: "xhigh", sent: "max", reason: "rule" } }; + expect(routingFacts(row({ attempts: [overloaded("openrouter"), rule] }), false)!.note).toEqual({ + kind: "failover", + failed: 1, + }); + }); + it("等到了空位:只有一跳,排队的时间在那一跳上,不加说明", () => { const f = routingFacts(row({ attempts: [{ ...served("anthropic"), queued_ms: 1_240 }] }), false)!; expect(f.note).toBeNull(); diff --git a/src/requestRouting.ts b/src/requestRouting.ts index 114f3c5e..2b7c6a0e 100644 --- a/src/requestRouting.ts +++ b/src/requestRouting.ts @@ -220,18 +220,25 @@ export function routingFacts( ? { kind: "failover_denied", failed: n - 1, rule: deniedBy } : { kind: "denied_after_pick", rule: deniedBy }; } else if (n > 1) { + // 最后一跳是推理强度被拒收后、改档在同一家重发的:被拒收的那一跳和它算一次尝试,不是 + // 「等上游恢复」。为什么重发在那一跳自己那一行 + const resent = attempts[n - 1]!.effort?.reason === "rejected"; + const chain = resent ? [...attempts.slice(0, -2), attempts[n - 1]!] : attempts; + const m = chain.length; // 满着、限流中跳过,收到 429 暂停,都不是上游的失败(无响应超时是) - const earlier = attempts.slice(0, -1); - const last = attempts[n - 1]!; + const earlier = chain.slice(0, -1); + const last = chain[m - 1]!; const plain = earlier.every((a) => !notFailed(a)); note = - last.retry != null - ? { kind: "retried", count: n - 1 } - : earlier.some((a) => a.provider === last.provider) - ? { kind: "waited", count: n - 1 } - : plain - ? { kind: "failover", failed: n - 1 } - : { kind: "switched", count: n - 1 }; + m === 1 + ? null + : last.retry != null + ? { kind: "retried", count: m - 1 } + : earlier.some((a) => a.provider === last.provider) + ? { kind: "waited", count: m - 1 } + : plain + ? { kind: "failover", failed: m - 1 } + : { kind: "switched", count: m - 1 }; } return { diff --git a/src/routing/RuleDialog.i18n.ts b/src/routing/RuleDialog.i18n.ts index 828353ff..7f42d2da 100644 --- a/src/routing/RuleDialog.i18n.ts +++ b/src/routing/RuleDialog.i18n.ts @@ -28,9 +28,9 @@ export const ruleDialogText = messages( notSet: "未设置", setModel: "模型改为", unchanged: "不改变", - thinking: "扩展思考", - on: "开启", + reasoning: "推理", off: "关闭", + reasoningNote: "指定档位时,仅改写已开启推理的请求;未开启推理的请求(如生成标题)保持不变。", modelChangeNote: "更换模型后,已缓存的 prompt 不再命中。", add: "添加", addCondition: "添加条件", @@ -77,9 +77,10 @@ export const ruleDialogText = messages( notSet: "Not set", setModel: "Change model to", unchanged: "Unchanged", - thinking: "Extended thinking", - on: "On", + reasoning: "Reasoning", off: "Off", + reasoningNote: + "A level applies only to requests that already have reasoning on; requests without reasoning (such as title generation) are left unchanged.", modelChangeNote: "Once the model is changed, cached prompts no longer hit.", add: "Add", addCondition: "Add condition", diff --git a/src/routing/RuleDialog.tsx b/src/routing/RuleDialog.tsx index 71323a2b..dc63233a 100644 --- a/src/routing/RuleDialog.tsx +++ b/src/routing/RuleDialog.tsx @@ -33,6 +33,7 @@ import { TargetIcon } from "./parts"; import { COND_FIELDS, DIALECTS, + REASONING, addOnsText, blankCondition, blankPinned, @@ -101,7 +102,7 @@ export function RuleDialog({ const [d, setD] = useState(initial); const set = (patch: Partial) => setD((x) => ({ ...x, ...patch })); const [rewriteOpen, setRewriteOpen] = useState( - () => initial.model !== "" || initial.maxTokens !== "" || initial.thinking !== "keep", + () => initial.model !== "" || initial.maxTokens !== "" || initial.reasoning !== "keep", ); const [newGroup, setNewGroup] = useState(false); @@ -276,9 +277,7 @@ export function RuleDialog({
{/* 指定模型:模型名原样发出,「模型改为」不起作用 */} @@ -303,19 +302,21 @@ export function RuleDialog({ onChange={(e) => set({ maxTokens: e.target.value })} /> - - - value={d.thinking} - onChange={(v) => set({ thinking: v })} - options={[ - { id: "keep", label: t.unchanged }, - { id: "on", label: t.on }, - { id: "off", label: t.off }, - ]} - /> -
{!pinned && d.model.trim() && {t.modelChangeNote}} + {/* 七段放不进上面那一行:单独一行、各段等宽 */} + + + block + label={t.reasoning} + value={d.reasoning} + onChange={(v) => set({ reasoning: v })} + options={[ + { id: "keep", label: t.unchanged }, + ...REASONING.map((r) => ({ id: r, label: r === "off" ? t.off : r })), + ]} + /> + )} diff --git a/src/routing/chain.test.ts b/src/routing/chain.test.ts index 1c4285b9..e3c60761 100644 --- a/src/routing/chain.test.ts +++ b/src/routing/chain.test.ts @@ -129,7 +129,7 @@ describe("路由图的连线", () => { route( "default", [ - rule("rewrite", { set: { model: "m", max_tokens: null, thinking: null } }), + rule("rewrite", { set: { model: "m", max_tokens: null, reasoning: null } }), rule("late", { to: null, deny: "x", phase_two: true }), catchAll("catch-all", "a"), rule("after", { to: "b", shadowed: true }), diff --git a/src/routing/model.i18n.ts b/src/routing/model.i18n.ts index 82179130..cd267794 100644 --- a/src/routing/model.i18n.ts +++ b/src/routing/model.i18n.ts @@ -31,8 +31,6 @@ export const modelText = messages( setModelAlias: (model: string) => `模型改为 ${model},按别名表对应到各上游`, withBackups: (first: string, n: number) => `${first},备用 ${n} 个`, setMaxTokens: (value: string) => `max_tokens 改为 ${value}`, - thinkingOn: "开启扩展思考", - thinkingOff: "关闭扩展思考", strategies: { fallback: "依次使用成员,前一个不可用时使用下一个。", select: "使用选定的上游;它不可用时,按顺序使用其余成员。", @@ -74,8 +72,6 @@ export const modelText = messages( 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", strategies: { fallback: "Uses the members in order, moving to the next when one is unavailable.", select: "Uses the selected upstream; when it is unavailable, uses the other members in order.", diff --git a/src/routing/model.test.ts b/src/routing/model.test.ts index 61d45ff5..e4e79f3c 100644 --- a/src/routing/model.test.ts +++ b/src/routing/model.test.ts @@ -12,6 +12,7 @@ import { draftNotes, draftToInput, flowOf, + hasAddOns, insertIndex, liftShadowed, move, @@ -97,7 +98,7 @@ describe("规则草稿", () => { conditions: v.conditions, to, deny: null, - set: { model: null, max_tokens: 8192, thinking: null }, + set: { model: null, max_tokens: 8192, reasoning: null }, }); // 上游或策略组照旧是一个名称 expect(draftFromView(view({ name: "兜底", to: "__all__" })).toKind).toBe("target"); @@ -105,7 +106,7 @@ describe("规则草稿", () => { 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(draftToInput(d).set).toEqual({ model: null, max_tokens: 4096, reasoning: null }); expect(addOnsText(d)).toBe("max_tokens 改为 4096"); // 切回上游或策略组,留着的「模型改为」照常生效 expect(draftToInput({ ...d, toKind: "target" }).set?.model).toBe("x"); @@ -152,6 +153,31 @@ describe("规则草稿", () => { expect(ruleProblem(rule("a", { action: "continue", maxTokens: "0" }), [])).toContain("max_tokens"); expect(ruleProblem(rule("a", { action: "continue", maxTokens: "4096" }), [])).toBeNull(); }); + + it("推理:读进来、交回去是同一个标识;不改变不写进配置", () => { + const max = draftFromView(view({ name: "a", to: "pool", set: { model: null, max_tokens: null, reasoning: "max" } })); + expect(max.reasoning).toBe("max"); + expect(hasAddOns(max)).toBe(true); + expect(draftToInput(max).set).toEqual({ model: null, max_tokens: null, reasoning: "max" }); + const off = draftFromView(view({ name: "b", to: "pool", set: { reasoning: "off" } })); + expect(off.reasoning).toBe("off"); + expect(draftToInput(off).set?.reasoning).toBe("off"); + // 没有改写的规则:不改变,整个 set 不写 + const keep = draftFromView(view({ name: "c", to: "pool" })); + expect(keep.reasoning).toBe("keep"); + expect(hasAddOns(keep)).toBe(false); + expect(draftToInput(keep).set).toBeNull(); + // 拒绝时附加项不起作用 + expect(draftToInput({ ...max, action: "deny", deny: "x" }).set).toBeNull(); + }); + + it("推理写进规则摘要", () => { + expect(addOnsText(rule("a", { reasoning: "max" }))).toBe("推理 max"); + expect(addOnsText(rule("a", { maxTokens: "4096", reasoning: "off" }))).toBe("max_tokens 改为 4096 · 关闭推理"); + setLang("en"); + expect(addOnsText(rule("a", { reasoning: "xhigh" }))).toBe("Reasoning xhigh"); + expect(addOnsText(rule("a", { reasoning: "off" }))).toBe("Reasoning off"); + }); }); describe("规则在路由里的处境", () => { diff --git a/src/routing/model.ts b/src/routing/model.ts index 39bea1d3..ba810b80 100644 --- a/src/routing/model.ts +++ b/src/routing/model.ts @@ -5,9 +5,9 @@ * 能不能用、条件写得对不对,最后由 core 说;这里只做对话框里需要实时给出 * 的那几件事:保存按钮旁边缺什么、哪条规则被兜底挡住。 */ -import type { ClientView, ConditionField, ConditionView, Dialect, GroupKind, GroupView, KnownModel, PinnedModel, ProviderView, RouteView, RuleInput, RuleView } from "@/types"; +import type { ClientView, ConditionField, ConditionView, Dialect, GroupKind, GroupView, KnownModel, PinnedModel, ProviderView, RouteView, RuleInput, RuleReasoning, RuleView } from "@/types"; import { textOf } from "@/i18n"; -import { ALL_UPSTREAMS, balanceByLabel, conditionName, groupKindLabel, targetLabel } from "@/labels"; +import { ALL_UPSTREAMS, balanceByLabel, conditionName, groupKindLabel, reasoningText, targetLabel } from "@/labels"; import { protocolLabel } from "@/upstreams/labels"; import { modelText } from "./model.i18n"; import { routingText } from "./routing.i18n"; @@ -146,9 +146,13 @@ export interface RuleDraft { deny: string; model: string; maxTokens: string; - thinking: "keep" | "on" | "off"; + /** 推理:不改变,或者改成关闭、某一档推理强度 */ + reasoning: "keep" | RuleReasoning; } +/** 规则里可选的推理改写,按对话框里的顺序:关闭,然后由低到高的推理强度 */ +export const REASONING: readonly RuleReasoning[] = ["off", "low", "medium", "high", "xhigh", "max"]; + let seq = 0; function nextKey(): string { seq += 1; @@ -168,7 +172,7 @@ export function blankRule(to = ALL_UPSTREAMS): RuleDraft { deny: "", model: "", maxTokens: "", - thinking: "keep", + reasoning: "keep", }; } @@ -190,7 +194,7 @@ export function draftFromView(r: RuleView): RuleDraft { deny: r.deny ?? "", model: r.set?.model ?? "", maxTokens: r.set?.max_tokens != null ? String(r.set.max_tokens) : "", - thinking: r.set?.thinking == null ? "keep" : r.set.thinking ? "on" : "off", + reasoning: r.set?.reasoning ?? "keep", }; } @@ -216,7 +220,7 @@ function setModelOf(d: RuleDraft): string { /** 附加了改写 */ export function hasAddOns(d: RuleDraft): boolean { - return setModelOf(d) !== "" || d.maxTokens.trim() !== "" || d.thinking !== "keep"; + return setModelOf(d) !== "" || d.maxTokens.trim() !== "" || d.reasoning !== "keep"; } /** 在选定上游之后才判断:条件里有「选定上游」 */ @@ -239,7 +243,7 @@ export function draftToInput(d: RuleDraft): RuleInput { const set = { model: setModelOf(d) || null, max_tokens: Number.isFinite(maxTokens) && maxTokens > 0 ? maxTokens : null, - thinking: d.thinking === "keep" ? null : d.thinking === "on", + reasoning: d.reasoning === "keep" ? null : d.reasoning, }; return { name: d.name.trim(), @@ -252,7 +256,7 @@ export function draftToInput(d: RuleDraft): RuleInput { : d.to, deny: deny ? d.deny.trim() : null, // 拒绝时附加项不起作用:不写进去 - set: !deny && (set.model || set.max_tokens || set.thinking != null) ? set : null, + set: !deny && (set.model || set.max_tokens || set.reasoning != null) ? set : null, }; } @@ -481,7 +485,7 @@ export function addOnsText(d: RuleDraft, known: readonly KnownModel[] = []): str const model = setModelOf(d); if (model) parts.push(aliasNamed(model, known) ? t.setModelAlias(model) : t.setModel(model)); if (d.maxTokens.trim()) parts.push(t.setMaxTokens(d.maxTokens.trim())); - if (d.thinking !== "keep") parts.push(d.thinking === "on" ? t.thinkingOn : t.thinkingOff); + if (d.reasoning !== "keep") parts.push(reasoningText(d.reasoning)); return parts.join(" · "); } diff --git a/src/traffic/drawer/Routing.tsx b/src/traffic/drawer/Routing.tsx index dbeb08d8..b0fcf2e8 100644 --- a/src/traffic/drawer/Routing.tsx +++ b/src/traffic/drawer/Routing.tsx @@ -8,7 +8,7 @@ import { IconDenied } from "@/ui/icons"; import { UpstreamLogo } from "@/ui/logos"; import { StatusLabel } from "@/ui/status-dot"; import { Tip } from "@/ui/tip"; -import { attemptText, deniedHopText, targetLabel } from "@/labels"; +import { attemptText, deniedHopText, effortText, targetLabel } from "@/labels"; import { routingFacts, skippedHop, type RoutingNote } from "@/requestRouting"; import type { AttemptUsage, HistoryRow, PluginRunView } from "@/types"; import { egressLabel } from "@/upstreams/labels"; @@ -150,6 +150,9 @@ export function Routing({ r, plugins, running }: { r: HistoryRow; plugins: Plugi {/* 这一跳走的代理,在下面一行(和上游名对齐):挤在同一行会把结果那一格挤短。 直连的不写 —— 多数上游是直连的,每一跳都写一遍只是噪声 */} {a.proxy &&

{t.viaProxy(egressLabel(a.proxy))}

} + {/* 这一跳发出的推理强度和客户端要的不同:规则改写、上游拒收后改档重发、记住了 + 该模型不支持的档位。被拒收的那一跳是普通的 400,改档重发的这一跳带着它 */} + {a.effort &&

{effortText(a.effort)}

} {/* 放弃了的这一跳(无响应超时)上游可能已经按输入收了钱:不在这个请求的费用里 */} {a.usage && } From dde3cdb5b4be4bc7ec20f0b7f88a40040571d483 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:50:30 +0800 Subject: [PATCH 6/9] Regenerate core API types from the reasoning-override branch Co-Authored-By: Claude Opus 5.5 --- src/generated/tw-api.ts | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/src/generated/tw-api.ts b/src/generated/tw-api.ts index 3cec1bb0..2c406dd9 100644 --- a/src/generated/tw-api.ts +++ b/src/generated/tw-api.ts @@ -314,17 +314,18 @@ retry?: number | null, * 这一跳发出去之前退避了多少毫秒:一轮重试的头一跳带着。不算在 `ms` 里。别的没有 */ backoff_ms?: number | null, +/** + * 这一跳发出去的推理强度和客户端要的不一样:规则改写的、上游拒收了改用别的一档重发的、 + * 记得这个模型不认那一档直接改了的(见 [`EffortChange`])。一样的没有。被拒收的那一跳 + * 自己是一跳普通的 `status`(400),重发的那一跳带着 `rejected` + */ +effort?: EffortChange | null, /** * 这一跳走的代理:这家上游配置的代理名(跟随系统的是 `system`),直连是 None。没发出去 * 的一跳(满着、被拒、转换不了……)是它要走的那个 —— 连不上的那一跳,问题可能就出在 * 这个代理上。WebSocket 只连直连的上游,一律是 None */ -proxy?: string | null, -/** - * 这一跳发出的推理强度和客户端要的不一样时才有:规则改写、上游拒收后改档重发、记住了该模型 - * 不支持的档位。被拒收的那一跳本身是一跳普通的 `status: 400`,改档重发的那一跳带着它 - */ -effort?: EffortChange | null, }; +proxy?: string | null, }; /** * 上游接不接受凭据。 @@ -1306,21 +1307,20 @@ skipped: Array, converted: Array, }; /** - * 一跳发出的推理强度和客户端要的不一样([`AttemptView::effort`])。 + * 一跳发出去的推理强度和客户端要的不一样([`AttemptView::effort`])。 */ export type EffortChange = { /** - * 客户端要的档位(`minimal` … `max`) + * 客户端要的那一档:`minimal` / `low` / `medium` / `high` / `xhigh` / `max` */ asked: string, /** - * 发出去的档位;None = 去掉了推理强度 + * 发出去的那一档,同一个词表。null 是去掉了推理强度,按模型的默认 */ sent: string | null, reason: EffortReason, }; /** - * 推理强度为什么变了:`rule`(规则改写)/ `rejected`(上游拒收了原来的档位,改档重发)/ - * `remembered`(之前记住了该模型不支持原来的档位) + * 推理强度为什么改了([`EffortChange::reason`])。 */ export type EffortReason = "rule" | "rejected" | "remembered"; @@ -4728,6 +4728,11 @@ to?: RuleTarget | null, */ deny?: string | null, set?: RuleRewrite | null, }; +/** + * 规则改写的推理([`RuleRewrite::reasoning`])。没有 `minimal` + */ +export type RuleReasoning = "off" | "low" | "medium" | "high" | "xhigh" | "max"; + /** * 规则里的参数改写。每一项不写就是不改。 */ @@ -4737,15 +4742,10 @@ export type RuleRewrite = { */ model?: string | null, max_tokens?: number | null, /** - * 推理:`off` 去掉推理配置;一个档位只改已开启推理的请求的推理强度 + * 推理:关掉,或者改成一档。**只改开着推理的请求**,没开推理的(生成标题这类)原样不动 */ reasoning?: RuleReasoning | null, }; -/** - * 规则改写的推理:关闭,或者一个推理强度档位。 - */ -export type RuleReasoning = "off" | "low" | "medium" | "high" | "xhigh" | "max"; - /** * 命中那一刻的规则([`SecurityEventView::rule_snapshot`])。**跟着记录存**:规则之后改了、 * 删了,这一条说的还是当时按什么认出来的。 @@ -5385,8 +5385,9 @@ export type SetField = "model" | "max_tokens" | "reasoning" | "only_at_session_s */ export type SetView = { /** - * `model`(换模型,整个 prompt cache 作废)/ `max_tokens` / `reasoning`(值是 [`RuleReasoning`] 的标识)/ - * `only_at_session_start`(以上改写只在新会话开始时应用,值是 `true`) + * `model`(换模型,整个 prompt cache 作废)/ `max_tokens` / `reasoning`(值是 + * [`RuleReasoning`] 的那个词)/ `only_at_session_start`(以上改写只在新会话开始时应用, + * 值是 `true`) */ field: SetField, value: string, }; From 1b538ea497e019f34f8b7a79e07c291afd789952 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:33:51 +0800 Subject: [PATCH 7/9] Show reasoning turned off (none) in the attempt chain; regenerate core API types Co-Authored-By: Claude Opus 5.5 --- src/generated/tw-api.ts | 16 ++++++++++------ src/labels.i18n.ts | 6 ++++++ src/labels.test.ts | 12 ++++++++++++ src/labels.ts | 3 +++ 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/generated/tw-api.ts b/src/generated/tw-api.ts index 2c406dd9..4aa74cd2 100644 --- a/src/generated/tw-api.ts +++ b/src/generated/tw-api.ts @@ -315,9 +315,9 @@ retry?: number | null, */ backoff_ms?: number | null, /** - * 这一跳发出去的推理强度和客户端要的不一样:规则改写的、上游拒收了改用别的一档重发的、 - * 记得这个模型不认那一档直接改了的(见 [`EffortChange`])。一样的没有。被拒收的那一跳 - * 自己是一跳普通的 `status`(400),重发的那一跳带着 `rejected` + * 这一跳改了推理强度:规则改写的、上游拒收了改用别的一档重发的、记得这个模型不认那一档直接 + * 改了的(见 [`EffortChange`])。改了也和原来一样的没有。被拒收的那一跳自己是一跳普通的 + * `status`(400),重发的那一跳带着 `rejected` */ effort?: EffortChange | null, /** @@ -1307,15 +1307,19 @@ skipped: Array, converted: Array, }; /** - * 一跳发出去的推理强度和客户端要的不一样([`AttemptView::effort`])。 + * 一跳改了推理强度([`AttemptView::effort`])。 */ export type EffortChange = { /** - * 客户端要的那一档:`minimal` / `low` / `medium` / `high` / `xhigh` / `max` + * 这一次改之前要发的那一档:`rule` 是客户端要的;`rejected`、`remembered` 是被拒收、被换掉的 + * 那一档(规则改过之后的)—— 客户端要 high、规则改成 xhigh、被拒后改用 max,这一项是 + * `xhigh`。词表:`none`(OpenAI 的「关掉推理」)/ `minimal` / `low` / `medium` / `high` / + * `xhigh` / `max` */ asked: string, /** - * 发出去的那一档,同一个词表。null 是去掉了推理强度,按模型的默认 + * 发出去的那一档,同一个词表(规则 `off` 在 OpenAI 的格式里是 `none`)。null 是去掉了推理 + * 强度,按模型的默认 */ sent: string | null, reason: EffortReason, }; diff --git a/src/labels.i18n.ts b/src/labels.i18n.ts index d47ce388..6c9cf414 100644 --- a/src/labels.i18n.ts +++ b/src/labels.i18n.ts @@ -74,10 +74,13 @@ export const labelsText = messages( effort: { rule: (asked: string, sent: string) => `推理强度 ${asked} → ${sent}:规则改写`, ruleRemoved: "已去掉推理强度:规则改写", + ruleOff: "已关闭推理:规则改写", rejected: (asked: string, sent: string) => `推理强度 ${asked} 被上游拒收,已改用 ${sent} 重发`, rejectedRemoved: (asked: string) => `推理强度 ${asked} 被上游拒收,已去掉后重发`, + rejectedOff: "关闭推理(none)被上游拒收,已去掉该字段后重发", remembered: (asked: string, sent: string) => `推理强度 ${asked} → ${sent}:该模型不支持 ${asked}`, rememberedRemoved: (asked: string) => `已去掉推理强度:该模型不支持 ${asked}`, + rememberedOff: "已去掉推理强度:该模型不支持关闭推理(none)", }, served: "成功", servedStatus: (status: number) => `成功 · ${status}`, @@ -201,12 +204,15 @@ export const labelsText = messages( effort: { rule: (asked: string, sent: string) => `Reasoning effort ${asked} → ${sent}: rewritten by a rule`, ruleRemoved: "Reasoning effort removed: rewritten by a rule", + ruleOff: "Reasoning turned off: rewritten by a rule", rejected: (asked: string, sent: string) => `Reasoning effort ${asked} was rejected by the upstream; resent with ${sent}`, rejectedRemoved: (asked: string) => `Reasoning effort ${asked} was rejected by the upstream; resent without it`, + rejectedOff: "Turning reasoning off (none) was rejected by the upstream; resent without the field", remembered: (asked: string, sent: string) => `Reasoning effort ${asked} → ${sent}: the model does not support ${asked}`, rememberedRemoved: (asked: string) => `Reasoning effort removed: the model does not support ${asked}`, + rememberedOff: "Reasoning effort removed: the model does not support turning reasoning off (none)", }, served: "Succeeded", servedStatus: (status: number) => `Succeeded · ${status}`, diff --git a/src/labels.test.ts b/src/labels.test.ts index 87b485be..f9b5b886 100644 --- a/src/labels.test.ts +++ b/src/labels.test.ts @@ -120,6 +120,18 @@ describe("尝试链里一跳改了推理强度", () => { "Reasoning effort removed: the model does not support max", ); }); + + it("关闭推理写成 none:规则关闭、none 被拒收、记住了不支持 none", () => { + expect(effortText({ asked: "high", sent: "none", reason: "rule" })).toBe("已关闭推理:规则改写"); + expect(effortText({ asked: "none", sent: null, reason: "rejected" })).toBe( + "关闭推理(none)被上游拒收,已去掉该字段后重发", + ); + expect(effortText({ asked: "none", sent: null, reason: "remembered" })).toBe( + "已去掉推理强度:该模型不支持关闭推理(none)", + ); + setLang("en"); + expect(effortText({ asked: "high", sent: "none", reason: "rule" })).toBe("Reasoning turned off: rewritten by a rule"); + }); }); describe("尝试链里的一跳", () => { diff --git a/src/labels.ts b/src/labels.ts index bdc524b2..fc108252 100644 --- a/src/labels.ts +++ b/src/labels.ts @@ -231,10 +231,13 @@ export function effortText(e: EffortChange): string { const sent = e.sent ?? null; switch (e.reason) { case "rule": + if (sent === "none") return t.ruleOff; return sent == null ? t.ruleRemoved : t.rule(e.asked, sent); case "rejected": + if (e.asked === "none") return t.rejectedOff; return sent == null ? t.rejectedRemoved(e.asked) : t.rejected(e.asked, sent); case "remembered": + if (e.asked === "none") return t.rememberedOff; return sent == null ? t.rememberedRemoved(e.asked) : t.remembered(e.asked, sent); } } From 08e8ac982ec06566a8d6cefdc47c48be4497caa4 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:02:24 +0800 Subject: [PATCH 8/9] 2026.10.29: pin core v0.83.1; version and release notes - Pin core v0.83.1 (protocol 54) and fill the new attempt field in a test. - The attempt chain says when turning reasoning off was refused and a level was used instead. - The update window no longer says the gateway waits for requests to finish before restarting. - Screenshot oracle regenerated for 0.83.1. - Version 2026.10.29 and its release notes. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- release-notes/2026.10.29.md | 23 +++++++++++++++++++++++ scripts/shots/core/en/status.json | 4 ++-- scripts/shots/core/zh/status.json | 4 ++-- src-tauri/Cargo.lock | 30 +++++++++++++++--------------- src-tauri/Cargo.toml | 14 +++++++------- src-tauri/src/notices/tests.rs | 1 + src-tauri/tauri.conf.json | 2 +- src/Update.i18n.ts | 4 ++-- src/labels.i18n.ts | 4 ++++ src/labels.test.ts | 9 +++++++++ src/labels.ts | 4 ++-- 12 files changed, 69 insertions(+), 32 deletions(-) create mode 100644 release-notes/2026.10.29.md diff --git a/package.json b/package.json index eb072dd0..425cdf67 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.28", + "version": "2026.10.29", "type": "module", "packageManager": "pnpm@11.13.0", "scripts": { diff --git a/release-notes/2026.10.29.md b/release-notes/2026.10.29.md new file mode 100644 index 00000000..586f5d29 --- /dev/null +++ b/release-notes/2026.10.29.md @@ -0,0 +1,23 @@ +**Upgrade notes:** +- The bundled core is now 0.83.1 and the control-plane protocol is 54. A remote server running an older core must be upgraded to 0.83.0 or later to stay connected. +- Nothing is cleared; the request history, stored payloads and settings carry over. +- A routing rule that turned extended thinking on or off (`thinking:` under the rule's `set`) no longer loads, and the app opens in safe mode. Fix removes the field; set the rule's Reasoning again afterwards (Off corresponds to the old "off"). +- Updating to this version still restarts the gateway the previous way. Seamless upgrades apply from the next update on. + +**Temporary upstream failures are retried:** +- When an upstream fails before the answer starts, because it is overloaded, returns a server error, cannot be reached or drops the connection, the gateway waits 2, 4, 8, then 16 seconds and sends the request again, also when that upstream is the only one. Other upstreams are tried first, and a longer wait asked for by the upstream is respected. +- Retrying stops at Settings > Failover > Wait at most (120 seconds by default), which now covers queueing and retrying together. Setting it to 0 turns both off. +- An answer that has already started is never sent again. Errors caused by the request itself, an exhausted quota, rejected credentials or a rate limit are not retried. +- Traffic shows a request as Retrying while it waits, and the request details mark each retry and how long it waited. + +**Reasoning effort in routing rules:** +- A rule can set Reasoning to Off or to a level from low to max, for example to keep requests that Claude Code sends to a GLM upstream at max. A level only changes requests that already have reasoning on. +- When an upstream rejects a reasoning level, the request is sent again once with a level the upstream accepts, and the choice is remembered for that upstream and model. The request details say when and why the level was changed. + +**Seamless upgrades:** +- Updates no longer stop the gateway port or cut requests in flight. The running core hands its sockets to the new core and finishes its requests before it exits. Restart Gateway works the same way. +- Homebrew upgrades are recognised and handled like an in-app update. After an update the window comes back as it was. +- The overview and the menu bar count the requests the previous core is still finishing. + +**Traffic:** +- Answers, thinking and system prompts in the request details and in conversations render their Markdown: headings, bold, lists, tables and code blocks. HTML in an answer is shown as text, links are not clickable and images are not loaded. diff --git a/scripts/shots/core/en/status.json b/scripts/shots/core/en/status.json index 588cd118..69d5c4c0 100644 --- a/scripts/shots/core/en/status.json +++ b/scripts/shots/core/en/status.json @@ -1,5 +1,5 @@ { - "api_version": 52, + "api_version": 54, "clients": 4, "config_path": "", "draining": 0, @@ -16,5 +16,5 @@ "reachable": [] }, "uptime_secs": 0, - "version": "0.82.0" + "version": "0.83.1" } diff --git a/scripts/shots/core/zh/status.json b/scripts/shots/core/zh/status.json index 588cd118..69d5c4c0 100644 --- a/scripts/shots/core/zh/status.json +++ b/scripts/shots/core/zh/status.json @@ -1,5 +1,5 @@ { - "api_version": 52, + "api_version": 54, "clients": 4, "config_path": "", "draining": 0, @@ -16,5 +16,5 @@ "reachable": [] }, "uptime_secs": 0, - "version": "0.82.0" + "version": "0.83.1" } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b2a256a6..5fd3081f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4833,7 +4833,7 @@ dependencies = [ [[package]] name = "thinkwatch-lite" -version = "2026.10.28" +version = "2026.10.29" dependencies = [ "anyhow", "base64 0.22.1", @@ -5338,8 +5338,8 @@ dependencies = [ [[package]] name = "tw-api" -version = "0.82.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.82.0#7e7ca2e3a081ff27afde9dd37e450f5791bd7912" +version = "0.83.1" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.83.1#0a34ac1ad9d1a6691cb98e38b3a9b7e4a1803bb9" dependencies = [ "serde", "serde_json", @@ -5350,8 +5350,8 @@ dependencies = [ [[package]] name = "tw-dialect" -version = "0.82.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.82.0#7e7ca2e3a081ff27afde9dd37e450f5791bd7912" +version = "0.83.1" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.83.1#0a34ac1ad9d1a6691cb98e38b3a9b7e4a1803bb9" dependencies = [ "memchr", "serde", @@ -5360,8 +5360,8 @@ dependencies = [ [[package]] name = "tw-guard" -version = "0.82.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.82.0#7e7ca2e3a081ff27afde9dd37e450f5791bd7912" +version = "0.83.1" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.83.1#0a34ac1ad9d1a6691cb98e38b3a9b7e4a1803bb9" dependencies = [ "aho-corasick", "base64 0.22.1", @@ -5379,8 +5379,8 @@ dependencies = [ [[package]] name = "tw-link" -version = "0.82.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.82.0#7e7ca2e3a081ff27afde9dd37e450f5791bd7912" +version = "0.83.1" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.83.1#0a34ac1ad9d1a6691cb98e38b3a9b7e4a1803bb9" dependencies = [ "serde", "serde_json", @@ -5406,8 +5406,8 @@ dependencies = [ [[package]] name = "tw-types" -version = "0.82.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.82.0#7e7ca2e3a081ff27afde9dd37e450f5791bd7912" +version = "0.83.1" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.83.1#0a34ac1ad9d1a6691cb98e38b3a9b7e4a1803bb9" dependencies = [ "serde", "ts-rs", @@ -5415,8 +5415,8 @@ dependencies = [ [[package]] name = "tw-watch" -version = "0.82.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.82.0#7e7ca2e3a081ff27afde9dd37e450f5791bd7912" +version = "0.83.1" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.83.1#0a34ac1ad9d1a6691cb98e38b3a9b7e4a1803bb9" dependencies = [ "libc", "notify", @@ -5426,8 +5426,8 @@ dependencies = [ [[package]] name = "tw-yaml" -version = "0.82.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.82.0#7e7ca2e3a081ff27afde9dd37e450f5791bd7912" +version = "0.83.1" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.83.1#0a34ac1ad9d1a6691cb98e38b3a9b7e4a1803bb9" dependencies = [ "saphyr-parser", "thiserror 2.0.21", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f39d405e..b6c4c65e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "thinkwatch-lite" -version = "2026.10.28" +version = "2026.10.29" edition = "2024" # **这个数决定依赖能升到哪一版。**edition 2024 的解析器只挑声明的 Rust # 版本编得动的依赖:写 1.85 的时候,`cargo update` 一直停在旧的 time 和 @@ -26,12 +26,12 @@ license = "MIT" # twcore 二进制必须和这里编译进去的协议镜像来自同一个 core 版本 —— 打包脚本正是 # 从 tw-api 锁到的 tag 去取二进制的(见 scripts/fetch-core.sh)。 [workspace.dependencies] -tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.82.0" } -tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.82.0" } -tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.82.0" } -tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.82.0" } -tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.82.0" } -tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.82.0" } +tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.83.1" } +tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.83.1" } +tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.83.1" } +tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.83.1" } +tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.83.1" } +tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.83.1" } [lib] name = "thinkwatch_lite_lib" diff --git a/src-tauri/src/notices/tests.rs b/src-tauri/src/notices/tests.rs index 40c67b82..a0552631 100644 --- a/src-tauri/src/notices/tests.rs +++ b/src-tauri/src/notices/tests.rs @@ -694,6 +694,7 @@ async fn an_unreachable_upstream_is_only_listed_and_needs_real_evidence_to_clear proxy: None, retry: None, backoff_ms: None, + effort: None, }], billing: tw_api::Billing::PerToken, egress: None, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c878c0a7..f5652b7a 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.28", + "version": "2026.10.29", "identifier": "app.thinkwatch.lite", "build": { "beforeDevCommand": "pnpm dev", diff --git a/src/Update.i18n.ts b/src/Update.i18n.ts index 8f7e137c..cea65ab7 100644 --- a/src/Update.i18n.ts +++ b/src/Update.i18n.ts @@ -11,7 +11,7 @@ export const updateText = messages( available: (version: string) => `ThinkWatch Lite ${version} 可用`, closeWhileWaiting: "关闭此窗口不影响更新,更新完成后将发送通知。", /** 能在窗口里直接装的那几档(网页下载的、Windows 的绿色版)都是这一句 */ - standalone: "下载完成后自动安装。网关将在进行中的请求全部结束后重新启动。", + standalone: "下载完成后自动安装并重新启动,进行中的请求不会中断。", later: "稍后", install: "下载并安装", /** 下载或安装中途失败时那条提示的标题,正文是原因 */ @@ -34,7 +34,7 @@ export const updateText = messages( available: (version: string) => `ThinkWatch Lite ${version} is available`, closeWhileWaiting: "Closing this window does not stop the update. A notification is sent when it is complete.", standalone: - "Installs automatically after downloading. The gateway restarts once all requests in progress have finished.", + "Installs and restarts automatically after downloading. Requests in progress are not interrupted.", later: "Later", install: "Download and install", failed: "The update did not finish", diff --git a/src/labels.i18n.ts b/src/labels.i18n.ts index 6c9cf414..017c6e27 100644 --- a/src/labels.i18n.ts +++ b/src/labels.i18n.ts @@ -78,9 +78,11 @@ export const labelsText = messages( rejected: (asked: string, sent: string) => `推理强度 ${asked} 被上游拒收,已改用 ${sent} 重发`, rejectedRemoved: (asked: string) => `推理强度 ${asked} 被上游拒收,已去掉后重发`, rejectedOff: "关闭推理(none)被上游拒收,已去掉该字段后重发", + rejectedOffTo: (sent: string) => `关闭推理(none)被上游拒收,已改用 ${sent} 重发`, remembered: (asked: string, sent: string) => `推理强度 ${asked} → ${sent}:该模型不支持 ${asked}`, rememberedRemoved: (asked: string) => `已去掉推理强度:该模型不支持 ${asked}`, rememberedOff: "已去掉推理强度:该模型不支持关闭推理(none)", + rememberedOffTo: (sent: string) => `推理强度 none → ${sent}:该模型不支持关闭推理`, }, served: "成功", servedStatus: (status: number) => `成功 · ${status}`, @@ -209,10 +211,12 @@ export const labelsText = messages( `Reasoning effort ${asked} was rejected by the upstream; resent with ${sent}`, rejectedRemoved: (asked: string) => `Reasoning effort ${asked} was rejected by the upstream; resent without it`, rejectedOff: "Turning reasoning off (none) was rejected by the upstream; resent without the field", + rejectedOffTo: (sent: string) => `Turning reasoning off (none) was rejected by the upstream; resent with ${sent}`, remembered: (asked: string, sent: string) => `Reasoning effort ${asked} → ${sent}: the model does not support ${asked}`, rememberedRemoved: (asked: string) => `Reasoning effort removed: the model does not support ${asked}`, rememberedOff: "Reasoning effort removed: the model does not support turning reasoning off (none)", + rememberedOffTo: (sent: string) => `Reasoning effort none → ${sent}: the model does not support turning reasoning off`, }, served: "Succeeded", servedStatus: (status: number) => `Succeeded · ${status}`, diff --git a/src/labels.test.ts b/src/labels.test.ts index f9b5b886..bc59e608 100644 --- a/src/labels.test.ts +++ b/src/labels.test.ts @@ -129,8 +129,17 @@ describe("尝试链里一跳改了推理强度", () => { expect(effortText({ asked: "none", sent: null, reason: "remembered" })).toBe( "已去掉推理强度:该模型不支持关闭推理(none)", ); + expect(effortText({ asked: "none", sent: "low", reason: "rejected" })).toBe( + "关闭推理(none)被上游拒收,已改用 low 重发", + ); + expect(effortText({ asked: "none", sent: "minimal", reason: "remembered" })).toBe( + "推理强度 none → minimal:该模型不支持关闭推理", + ); setLang("en"); expect(effortText({ asked: "high", sent: "none", reason: "rule" })).toBe("Reasoning turned off: rewritten by a rule"); + expect(effortText({ asked: "none", sent: "low", reason: "rejected" })).toBe( + "Turning reasoning off (none) was rejected by the upstream; resent with low", + ); }); }); diff --git a/src/labels.ts b/src/labels.ts index fc108252..d87b9894 100644 --- a/src/labels.ts +++ b/src/labels.ts @@ -234,10 +234,10 @@ export function effortText(e: EffortChange): string { if (sent === "none") return t.ruleOff; return sent == null ? t.ruleRemoved : t.rule(e.asked, sent); case "rejected": - if (e.asked === "none") return t.rejectedOff; + if (e.asked === "none") return sent == null ? t.rejectedOff : t.rejectedOffTo(sent); return sent == null ? t.rejectedRemoved(e.asked) : t.rejected(e.asked, sent); case "remembered": - if (e.asked === "none") return t.rememberedOff; + if (e.asked === "none") return sent == null ? t.rememberedOff : t.rememberedOffTo(sent); return sent == null ? t.rememberedRemoved(e.asked) : t.remembered(e.asked, sent); } } From 6867c50b58db73ceb343ac2bde94f4d9b45eb383 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:03:05 +0800 Subject: [PATCH 9/9] Release notes: name the Repair button as the app does Co-Authored-By: Claude Opus 5.5 --- release-notes/2026.10.29.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release-notes/2026.10.29.md b/release-notes/2026.10.29.md index 586f5d29..8f3df6ee 100644 --- a/release-notes/2026.10.29.md +++ b/release-notes/2026.10.29.md @@ -1,7 +1,7 @@ **Upgrade notes:** - The bundled core is now 0.83.1 and the control-plane protocol is 54. A remote server running an older core must be upgraded to 0.83.0 or later to stay connected. - Nothing is cleared; the request history, stored payloads and settings carry over. -- A routing rule that turned extended thinking on or off (`thinking:` under the rule's `set`) no longer loads, and the app opens in safe mode. Fix removes the field; set the rule's Reasoning again afterwards (Off corresponds to the old "off"). +- A routing rule that turned extended thinking on or off (`thinking:` under the rule's `set`) no longer loads, and the app opens in safe mode. Repair removes the field; set the rule's Reasoning again afterwards (Off corresponds to the old "off"). - Updating to this version still restarts the gateway the previous way. Seamless upgrades apply from the next update on. **Temporary upstream failures are retried:**