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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
23 changes: 23 additions & 0 deletions release-notes/2026.10.29.md
Original file line number Diff line number Diff line change
@@ -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. 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:**
- 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.
4 changes: 2 additions & 2 deletions scripts/shots/core/en/status.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"api_version": 52,
"api_version": 54,
"clients": 4,
"config_path": "",
"draining": 0,
Expand All @@ -16,5 +16,5 @@
"reachable": []
},
"uptime_secs": 0,
"version": "0.82.0"
"version": "0.83.1"
}
4 changes: 2 additions & 2 deletions scripts/shots/core/zh/status.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"api_version": 52,
"api_version": 54,
"clients": 4,
"config_path": "",
"draining": 0,
Expand All @@ -16,5 +16,5 @@
"reachable": []
},
"uptime_secs": 0,
"version": "0.82.0"
"version": "0.83.1"
}
30 changes: 15 additions & 15 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 7 additions & 7 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 和
Expand All @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/src/notices/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,9 @@ 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,
effort: None,
}],
billing: tw_api::Billing::PerToken,
egress: None,
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions src/Update.i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const updateText = messages(
available: (version: string) => `ThinkWatch Lite ${version} 可用`,
closeWhileWaiting: "关闭此窗口不影响更新,更新完成后将发送通知。",
/** 能在窗口里直接装的那几档(网页下载的、Windows 的绿色版)都是这一句 */
standalone: "下载完成后自动安装。网关将在进行中的请求全部结束后重新启动。",
standalone: "下载完成后自动安装并重新启动,进行中的请求不会中断。",
later: "稍后",
install: "下载并安装",
/** 下载或安装中途失败时那条提示的标题,正文是原因 */
Expand All @@ -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",
Expand Down
82 changes: 75 additions & 7 deletions src/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -865,9 +865,9 @@ describe("排队中(request_queued)", () => {
const rows = new Map<number, RequestRow>();
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");
});

Expand All @@ -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):等到了、正在发,路由事件还没到也不再说在排队", () => {
Expand All @@ -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<number, RequestRow>();
Expand All @@ -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("排着队就结束了(等到头、客户端断开、手动中止):不再说在排队", () => {
Expand All @@ -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<number, RequestRow>();
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("排队那一条换成新对象(表格据此重画);上游限流的事件不碰任何一行", () => {
Expand All @@ -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<number, RequestRow>();
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<number, RequestRow>();
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<number, RequestRow>();
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<number, RequestRow>();
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");
});
});
Loading
Loading