From 6c19fa6238201f0ee98f43aaf373969a87ac9d1b Mon Sep 17 00:00:00 2001 From: TriDefender <173548745+TriDefender@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:58:23 +0000 Subject: [PATCH 1/5] =?UTF-8?q?bump=E4=BA=86=E6=8C=87=E7=BA=B9=E9=BB=98?= =?UTF-8?q?=E8=AE=A4ver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.example.yaml | 8 ++++---- src/auth/oauth.test.ts | 4 ++-- src/config/loader.test.ts | 4 ++-- src/config/loader.ts | 2 +- src/config/template.ts | 4 ++-- src/tui/app.ts | 7 +++++++ 6 files changed, 18 insertions(+), 11 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 89763ff..b45a7b2 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -53,8 +53,8 @@ models: identity: # Mirrors process.env.ZCODE_APP_VERSION in the ZCode bundle. # Must be printable ASCII; non-conforming values fall back to the default. - # Default: "3.14.0" (current ZCode release). Override to match your real client. - appVersion: "3.14.0" + # Default: "3.15.1" (current ZCode release). Override to match your real client. + appVersion: "3.15.1" # X-Title suffix → "Z Code@{sourceTitle}". Default "cli". sourceTitle: "cli" # HTTP-Referer URL. Default "https://zcode.z.ai". @@ -160,8 +160,8 @@ async: # `auth login`), and identity.appVersion must be >= the campaign's minimum # client version or the server rejects with `ineligible` — an app_version # below the floor can also surface as a silently empty preview list -# (measured floor 3.14.0 for the 2026-09 campaign; the default appVersion -# "3.14.0" satisfies it). + # (measured floor 3.14.0 for the 2026-09 campaign; the default appVersion + # "3.15.1" satisfies it). # # Env overrides: ZCODE_CLAIM_ENABLED, ZCODE_CLAIM_AUTO, ZCODE_CLAIM_ORIGIN, # ZCODE_CLAIM_POLL_INTERVAL_MS. diff --git a/src/auth/oauth.test.ts b/src/auth/oauth.test.ts index 32cf67e..87b6a39 100644 --- a/src/auth/oauth.test.ts +++ b/src/auth/oauth.test.ts @@ -175,7 +175,7 @@ describe("ZaiOAuthClient (server-mediated cli login)", () => { expect(interstitial).toStartWith("https://zcode.z.ai/app/oauth/login?"); const inner = new URL(interstitial); expect(inner.searchParams.get("redirect")).toBe("zcode://oauth/callback"); - expect(inner.searchParams.get("app_version")).toBe("3.14.0"); + expect(inner.searchParams.get("app_version")).toBe("3.15.1"); } finally { await client.close(); } @@ -256,7 +256,7 @@ describe("BigmodelPollOAuthClient (server-mediated cli login)", () => { expect(url.origin + url.pathname).toBe("https://bigmodel.cn/login"); const interstitial = url.searchParams.get("redirect") ?? ""; expect(interstitial).toStartWith("https://zcode.z.ai/app/oauth/login?"); - expect(new URL(interstitial).searchParams.get("app_version")).toBe("3.14.0"); + expect(new URL(interstitial).searchParams.get("app_version")).toBe("3.15.1"); expect(url.searchParams.get("redirect_uri")).toBeNull(); expect(started.callbackUrl).toBe(""); } finally { diff --git a/src/config/loader.test.ts b/src/config/loader.test.ts index f5eb4ed..3c8f5a4 100644 --- a/src/config/loader.test.ts +++ b/src/config/loader.test.ts @@ -437,7 +437,7 @@ models: const path = writeYaml(` `); const cfg = loadConfig(path); - expect(cfg.identity.appVersion).toBe("3.14.0"); + expect(cfg.identity.appVersion).toBe("3.15.1"); expect(cfg.identity.sourceTitle).toBe("cli"); expect(cfg.identity.refererOrigin).toBe("https://zcode.z.ai"); }); @@ -471,6 +471,6 @@ identity: appVersion: "v3.3.3-中文" `); const cfg = loadConfig(path); - expect(cfg.identity.appVersion).toBe("3.14.0"); + expect(cfg.identity.appVersion).toBe("3.15.1"); }); }); diff --git a/src/config/loader.ts b/src/config/loader.ts index 1b9e153..a6a5300 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -31,7 +31,7 @@ const ENV = { /** Mirrors the ZCode desktop release (`_reverse/NOTEPAD.md`); bump per client * release or User-Agent/X-ZCode-App-Version become distinguishable. */ -export const DEFAULT_APP_VERSION = "3.14.0"; +export const DEFAULT_APP_VERSION = "3.15.1"; const DEFAULTS = { PORT: 8080, diff --git a/src/config/template.ts b/src/config/template.ts index 2924069..b2163b5 100644 --- a/src/config/template.ts +++ b/src/config/template.ts @@ -62,8 +62,8 @@ models: identity: # Mirrors process.env.ZCODE_APP_VERSION in the ZCode bundle. # Must be printable ASCII; non-conforming values fall back to the default. - # Default: "3.14.0" (current ZCode release). Override to match your real client. - appVersion: "3.14.0" + # Default: "3.15.1" (current ZCode release). Override to match your real client. + appVersion: "3.15.1" # X-Title suffix → "Z Code@{sourceTitle}". Default "cli". sourceTitle: "cli" # HTTP-Referer URL. Default "https://zcode.z.ai". diff --git a/src/tui/app.ts b/src/tui/app.ts index 38f0330..2b92057 100644 --- a/src/tui/app.ts +++ b/src/tui/app.ts @@ -424,6 +424,13 @@ export async function runTui(args: ServeArgs): Promise { let activeOauth: { client: OAuthFlowClient; provider: ProviderId } | null = null; async function startLogin(opts: { paste?: boolean } = {}): Promise { + // Stop-first guard (logout/switching discipline): a login completing + // mid-run hot-swaps the credential into the LIVE proxy (settleLogin), + // silently switching the account serving traffic. + if (state.serverStatus === "running" || state.serverStatus === "starting") { + setToast("stop the proxy before logging in (press s)", "err"); + return; + } if (state.loginInFlight) { setToast("login already in progress", "info"); return; From 41d998dd298c2ec553924d7b609f66b0d6668767 Mon Sep 17 00:00:00 2001 From: TriDefender <173548745+TriDefender@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:58:59 +0000 Subject: [PATCH 2/5] fixed glm high-speed details --- config.example.yaml | 16 +- src/config/template.ts | 16 +- src/highspeed/manager.test.ts | 234 +++++++++++++++++++++------ src/highspeed/manager.ts | 240 ++++++++++++++++++++-------- src/index.ts | 7 + src/proxy/handler-highspeed.test.ts | 6 +- 6 files changed, 388 insertions(+), 131 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index b45a7b2..f8557bd 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -204,13 +204,15 @@ clientSigning: # HighSpeed 加速卡 (mirrors the ZCode 3.15.1 HighspeedCardService — Coding Plan # subscriber benefit, server-side random draw). Enabled by default, matching the -# official client (which has no toggle): eligible coding-plan requests lazily -# trigger POST {origin}/api/v1/highspeed/draw; on a hit, requests inside the -# card window are rewritten to the card model with the highspeed auth set -# (X-Highspeed-Card-ID) and stay unsigned, exactly like the official client. -# Misses/cooldowns honor server next_draw_at; accounts outside the rollout -# (503/3404) are negative-cached 10min. task_id reuses the local client-session -# affinity inference. Env override: ZCODE_HIGHSPEED_ENABLED=false. +# official client (which has no toggle): the first eligible coding-plan request +# ignites a background draw loop (POST {origin}/api/v1/highspeed/draw at ~1/s — +# the measured token-bucket refill rate; server next_draw_at is ignored on a +# miss); on a hit, requests inside the card window are rewritten to the card +# model with the highspeed auth set (X-Highspeed-Card-ID) and stay unsigned, +# exactly like the official client. The loop self-extinguishes 5 minutes after +# the last eligible request; account unavailability (503/3404) is +# negative-cached 10min, HTTP 429 backs off 30s. task_id reuses the local +# client-session affinity inference. Env override: ZCODE_HIGHSPEED_ENABLED=false. highspeed: enabled: true origin: "https://zcode.z.ai" diff --git a/src/config/template.ts b/src/config/template.ts index b2163b5..4b4ad9b 100644 --- a/src/config/template.ts +++ b/src/config/template.ts @@ -106,13 +106,15 @@ clientSigning: # HighSpeed 加速卡 (mirrors the ZCode 3.15.1 HighspeedCardService — Coding Plan # subscriber benefit, server-side random draw). Enabled by default, matching the -# official client (which has no toggle): eligible coding-plan requests lazily -# trigger POST {origin}/api/v1/highspeed/draw; on a hit, requests inside the -# card window are rewritten to the card model with the highspeed auth set -# (X-Highspeed-Card-ID) and stay unsigned, exactly like the official client. -# Misses/cooldowns honor server next_draw_at; accounts outside the rollout -# (503/3404) are negative-cached 10min. task_id reuses the local client-session -# affinity inference. Env override: ZCODE_HIGHSPEED_ENABLED=false. +# official client (which has no toggle): the first eligible coding-plan request +# ignites a background draw loop (POST {origin}/api/v1/highspeed/draw at ~1/s — +# the measured token-bucket refill rate; server next_draw_at is ignored on a +# miss); on a hit, requests inside the card window are rewritten to the card +# model with the highspeed auth set (X-Highspeed-Card-ID) and stay unsigned, +# exactly like the official client. The loop self-extinguishes 5 minutes after +# the last eligible request; account unavailability (503/3404) is +# negative-cached 10min, HTTP 429 backs off 30s. task_id reuses the local +# client-session affinity inference. Env override: ZCODE_HIGHSPEED_ENABLED=false. highspeed: enabled: true origin: "https://zcode.z.ai" diff --git a/src/highspeed/manager.test.ts b/src/highspeed/manager.test.ts index 75f6f86..1ace4f4 100644 --- a/src/highspeed/manager.test.ts +++ b/src/highspeed/manager.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, afterEach } from "bun:test"; import { HighspeedManager, applyHighspeedCardHeaders, @@ -14,34 +14,57 @@ const SESSION_TASK = "11111111-2222-3333-4444-555555555555"; interface DrawCall { url: string; body: Record; headers: Headers; } -function drawResponse(card: Record | null, nextDrawAt: number): Response { - return new Response(JSON.stringify({ code: 0, msg: "success", data: { card, next_draw_at: nextDrawAt } }), { status: 200 }); +function drawResponse(card: Record | null, nextDrawAt?: number): Response { + return new Response(JSON.stringify({ code: 0, msg: "success", data: { card, next_draw_at: nextDrawAt ?? 0 } }), { status: 200 }); } function hitCard(now: number, model = "glm-5.3-highspeed"): Record { return { card_id: "hsc_test_1", task_id: SESSION_TASK, provider: "builtin:zai-coding-plan", model, issued_at: now, expires_at: now + 300_000 }; } -function makeManager(fetchImpl: typeof fetch, clock: { now: number }) { - return new HighspeedManager({ +/** Live managers registry — afterEach stops every heartbeat timer. */ +const live: HighspeedManager[] = []; +afterEach(() => { + for (const m of live.splice(0)) m.stop(); +}); + +function makeManager( + fetchImpl: typeof fetch, + clock: { now: number }, + opts: { retryMs?: number; onEvent?: (message: string) => void } = {}, +): HighspeedManager { + const manager = new HighspeedManager({ origin: "https://zcode.z.ai", models: ["glm-5.3", "glm-5.2", "glm-5.1"], fetchImpl, now: () => clock.now, + retryMs: opts.retryMs ?? 2, + onEvent: opts.onEvent, }); + live.push(manager); + return manager; } async function settle(): Promise { await new Promise((r) => setTimeout(r, 5)); } +/** Poll until cond() holds or ~1s elapses (real-timer tests). */ +async function until(cond: () => boolean): Promise { + for (let i = 0; i < 200; i++) { + if (cond()) return true; + await new Promise((r) => setTimeout(r, 5)); + } + return cond(); +} + describe("HighspeedManager", () => { - it("draws on eligible request, holds the card, and reuses it without redrawing", async () => { + it("draws on the first eligible request and holds the single card slot", async () => { const clock = { now: 1_000_000 }; const calls: DrawCall[] = []; const manager = makeManager((async (input: RequestInfo | URL, init?: RequestInit) => { calls.push({ url: String(input), body: JSON.parse(String(init?.body)) as Record, headers: new Headers(init?.headers) }); - return drawResponse(hitCard(clock.now), clock.now + 120_000); + return drawResponse(hitCard(clock.now)); }) as unknown as typeof fetch, clock); manager.onRequest("glm-5.1", SESSION_TASK, AUTH); @@ -64,9 +87,42 @@ describe("HighspeedManager", () => { expect(calls[0]!.body.task_id).toBe(SESSION_TASK); expect(calls[0]!.body.provider).toBe("builtin:zai-coding-plan"); expect(calls[0]!.body.model).toBe("glm-5.1"); + // deviceMid omitted when not provided (makeManager passes none) + expect(calls[0]!.headers.get("x-device-mid")).toBeNull(); + }); + + it("sends X-Device-Mid on draws when provided (activity-gate 3001 fix, live 2026-10-12)", async () => { + const clock = { now: 1_000_000 }; + const calls: DrawCall[] = []; + const manager = new HighspeedManager({ + origin: "https://zcode.z.ai", + models: ["glm-5.3"], + deviceMid: " e6e1ef31-b69a-496b-8428-ae909abc8904 ", + fetchImpl: (async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ url: String(input), body: JSON.parse(String(init?.body)) as Record, headers: new Headers(init?.headers) }); + return drawResponse(hitCard(clock.now)); + }) as unknown as typeof fetch, + now: () => clock.now, + }); + manager.onRequest("glm-5.3", SESSION_TASK, AUTH); + await settle(); + expect(calls.length).toBe(1); + expect(calls[0]!.headers.get("x-device-mid")).toBe("e6e1ef31-b69a-496b-8428-ae909abc8904"); + }); + + it("draws synchronously on ignite: the first fetch starts before any await", () => { + const clock = { now: 1_000_000 }; + let fetches = 0; + const manager = makeManager((async () => { + fetches++; + return drawResponse(null); + }) as unknown as typeof fetch, clock); + + manager.onRequest("glm-5.3", SESSION_TASK, AUTH); + expect(fetches).toBe(1); // fired within onRequest, not on a timer }); - it("treats a miss as cooldown: no redraw until server next_draw_at", async () => { + it("retries at the heartbeat cadence after a miss — server next_draw_at is ignored", async () => { const clock = { now: 1_000_000 }; let fetches = 0; const manager = makeManager((async () => { @@ -75,40 +131,67 @@ describe("HighspeedManager", () => { }) as unknown as typeof fetch, clock); manager.onRequest("glm-5.3", SESSION_TASK, AUTH); - await settle(); + expect(await until(() => fetches >= 3)).toBeTrue(); // ≥3 tries without new requests + }); + + it("stops the loop 5 minutes after the last eligible request (idle self-extinguish)", async () => { + const clock = { now: 1_000_000 }; + let fetches = 0; + const events: string[] = []; + const manager = makeManager((async () => { + fetches++; + return drawResponse(null); + }) as unknown as typeof fetch, clock, { onEvent: (m) => events.push(m) }); + + manager.onRequest("glm-5.3", SESSION_TASK, AUTH); + await until(() => fetches >= 2); + clock.now += 5 * 60_000 + 1; // idle window closed + expect(await until(() => events.some((m) => m.includes("idle-stopped")))).toBeTrue(); + const fetchesAtStop = fetches; clock.now += 60_000; + await settle(); + expect(fetches).toBe(fetchesAtStop); // loop is dead — no more draws + }); + + it("re-ignites after idle-stop when a new request arrives", async () => { + const clock = { now: 1_000_000 }; + const manager = makeManager((async () => drawResponse(hitCard(clock.now))) as unknown as typeof fetch, clock); + manager.onRequest("glm-5.3", SESSION_TASK, AUTH); await settle(); - expect(fetches).toBe(1); // still cooling down + expect(manager.activeCard()).toBeDefined(); - clock.now += 61_000; // past next_draw_at + // card expires, loop had stopped; 10 idle minutes pass + clock.now += 295_000; + expect(manager.activeCard()).toBeNull(); + clock.now += 10 * 60_000; manager.onRequest("glm-5.3", SESSION_TASK, AUTH); await settle(); - expect(fetches).toBe(2); + expect(manager.activeCard()).toBeDefined(); // re-ignited and drew again }); - it("negative-caches account unavailability (503 / biz 3404) for 10 minutes", async () => { + it("keeps redrawing after the card expires mid-idle-window", async () => { const clock = { now: 1_000_000 }; - let fetches = 0; + let missesThenHit = 0; const manager = makeManager((async () => { - fetches++; - return new Response(JSON.stringify({ code: 3404, msg: "highspeed is temporarily unavailable" }), { status: 503 }); + missesThenHit++; + return missesThenHit <= 4 + ? drawResponse(null) + : drawResponse(hitCard(clock.now)); }) as unknown as typeof fetch, clock); - manager.onRequest("glm-5.2", SESSION_TASK, AUTH); - await settle(); - clock.now += 120_000; // past a normal cooldown - manager.onRequest("glm-5.2", SESSION_TASK, AUTH); + manager.onRequest("glm-5.3", SESSION_TASK, AUTH); await settle(); - expect(fetches).toBe(1); // still negative-cached + expect(manager.activeCard()).toBeDefined(); - clock.now += 10 * 60_000 + 1_000; - manager.onRequest("glm-5.2", SESSION_TASK, AUTH); + clock.now += 295_000; // 5s left — under the minimum-remaining guard + expect(manager.activeCard()).toBeNull(); + manager.onRequest("glm-5.3", SESSION_TASK, AUTH); // re-ignite inside idle window await settle(); - expect(fetches).toBe(2); + expect(manager.activeCard()).toBeDefined(); }); - it("backs off 30s on HTTP 429", async () => { + it("backs off 30s on HTTP 429, then resumes the heartbeat", async () => { const clock = { now: 1_000_000 }; let fetches = 0; const manager = makeManager((async () => { @@ -117,31 +200,52 @@ describe("HighspeedManager", () => { }) as unknown as typeof fetch, clock); manager.onRequest("glm-5.1", SESSION_TASK, AUTH); + await until(() => fetches >= 2); + const atTwo = fetches; + clock.now += 20_000; // inside the 30s backoff await settle(); - clock.now += 20_000; - manager.onRequest("glm-5.1", SESSION_TASK, AUTH); - await settle(); - expect(fetches).toBe(1); - clock.now += 11_000; - manager.onRequest("glm-5.1", SESSION_TASK, AUTH); - await settle(); - expect(fetches).toBe(2); + expect(fetches).toBe(atTwo); // no retries while backing off + + clock.now += 11_000; // past backoff + expect(await until(() => fetches > atTwo)).toBeTrue(); }); - it("expires cards and applies a minimum-remaining window", async () => { + it("negative-caches account unavailability (503 / biz 3404) for 10 minutes", async () => { const clock = { now: 1_000_000 }; - const manager = makeManager((async () => drawResponse(hitCard(clock.now), clock.now + 120_000)) as unknown as typeof fetch, clock); - manager.onRequest("glm-5.3", SESSION_TASK, AUTH); + let fetches = 0; + const manager = makeManager((async () => { + fetches++; + return new Response(JSON.stringify({ code: 3404, msg: "highspeed is temporarily unavailable" }), { status: 503 }); + }) as unknown as typeof fetch, clock); + + manager.onRequest("glm-5.2", SESSION_TASK, AUTH); + await until(() => fetches >= 2); + const atTwo = fetches; + // Stay inside the idle window across both clock jumps so the gate under + // test is the negative cache, not the idle self-extinguish. + clock.now += 120_000; + manager.onRequest("glm-5.2", SESSION_TASK, AUTH); await settle(); - expect(manager.activeCard()).toBeDefined(); + expect(fetches).toBe(atTwo); // still negative-cached - clock.now += 295_000; // 5s left on the window - expect(manager.activeCard()).toBeNull(); // under the 10s minimum-remaining guard + clock.now += 8 * 60_000 + 1_000; // past 10min negative cache + manager.onRequest("glm-5.2", SESSION_TASK, AUTH); + expect(await until(() => fetches > atTwo)).toBeTrue(); + }); - clock.now += 10_000; // past expires_at - manager.onRequest("glm-5.3", SESSION_TASK, AUTH); // redraw allowed - await settle(); - // second draw fired (fetch mock returns a fresh card each call) + it("draws with the latest request's model after a mid-loop model change", async () => { + const clock = { now: 1_000_000 }; + const bodies: Record[] = []; + const manager = makeManager((async (_input: RequestInfo | URL, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body)) as Record); + return drawResponse(null); + }) as unknown as typeof fetch, clock); + + manager.onRequest("glm-5.3", SESSION_TASK, AUTH); + manager.onRequest("glm-5.2", SESSION_TASK, AUTH); // newer params win + await until(() => bodies.length >= 3); + expect(bodies.slice(1).every((b) => b.model === "glm-5.2")).toBeTrue(); + expect(bodies[0]!.model).toBe("glm-5.3"); }); it("deduplicates concurrent draws into a single fetch", async () => { @@ -150,7 +254,7 @@ describe("HighspeedManager", () => { const manager = makeManager((async () => { fetches++; await new Promise((r) => setTimeout(r, 10)); - return drawResponse(hitCard(clock.now), clock.now + 120_000); + return drawResponse(hitCard(clock.now)); }) as unknown as typeof fetch, clock); manager.onRequest("glm-5.3", SESSION_TASK, AUTH); @@ -161,15 +265,32 @@ describe("HighspeedManager", () => { expect(fetches).toBe(1); }); - it("skips drawing without a JWT and matches only configured model prefixes", async () => { + it("drops the card on upstream rejection and redraws immediately", async () => { + const clock = { now: 1_000_000 }; let fetches = 0; const manager = makeManager((async () => { fetches++; - return drawResponse(null, 0); + return drawResponse(hitCard(clock.now)); + }) as unknown as typeof fetch, clock); + + manager.onRequest("glm-5.3", SESSION_TASK, AUTH); + await settle(); + expect(manager.activeCard()).toBeDefined(); + + manager.dropCard("upstream_rejected"); + await settle(); + expect(await until(() => manager.activeCard()?.cardId === "hsc_test_1")).toBeTrue(); + expect(fetches).toBeGreaterThanOrEqual(2); + }); + + it("skips drawing without a JWT and matches only configured model prefixes", () => { + let fetches = 0; + const manager = makeManager((async () => { + fetches++; + return drawResponse(null); }) as unknown as typeof fetch, { now: 1_000_000 }); manager.onRequest("glm-5.3", SESSION_TASK, { ...AUTH, jwt: " " }); - await settle(); expect(fetches).toBe(0); expect(manager.isEligibleModel("glm-5.3")).toBeTrue(); @@ -177,9 +298,26 @@ describe("HighspeedManager", () => { expect(manager.isEligibleModel("glm-4.6")).toBeFalse(); expect(manager.isEligibleModel(undefined)).toBeFalse(); manager.onRequest("glm-4.6", SESSION_TASK, AUTH); - await settle(); expect(fetches).toBe(0); }); + + it("surfaces idle-stop exactly once via onEvent", async () => { + const clock = { now: 1_000_000 }; + let fetches = 0; + const events: string[] = []; + const manager = makeManager((async () => { + fetches++; + return drawResponse(null); + }) as unknown as typeof fetch, clock, { onEvent: (m) => events.push(m) }); + + manager.onRequest("glm-5.3", SESSION_TASK, AUTH); + await until(() => fetches >= 1); + clock.now += 5 * 60_000 + 1; + expect(await until(() => events.filter((m) => m.includes("idle-stopped")).length === 1)).toBeTrue(); + clock.now += 60_000; + await settle(); + expect(events.filter((m) => m.includes("idle-stopped")).length).toBe(1); // no repeat + }); }); describe("highspeed helpers", () => { diff --git a/src/highspeed/manager.ts b/src/highspeed/manager.ts index 40025da..d453333 100644 --- a/src/highspeed/manager.ts +++ b/src/highspeed/manager.ts @@ -3,22 +3,25 @@ * (host chunk-3JWYEFTD `wo`; transport `Ar` = createHighspeedHttpTransport). * * 官方语义(`_reverse/NOTEPAD.md` §C): - * - 每条消息提交时尝试 `POST {origin}/api/v1/highspeed/draw`(1s 预算, - * mock `Ys=1e3`);命中获得限时加速卡(mock `expires_at = now + 3e5` ≈ 5min), - * 未命中/冷却由服务端 `next_draw_at`(mock ≈ 2min)节流。 + * - 每条消息提交时尝试 `POST {origin}/api/v1/highspeed/draw`(1s 预算); + * 命中获得限时加速卡(mock `expires_at = now + 3e5` ≈ 5min)。 * - 单卡槽:持卡期间不再抽;卡绑 task_id(官方=会话/任务)。 * - 鉴权(`Js` buildHighspeedCodingPlanAuthHeaders,与 MCP relay 同配方): * Authorization: Bearer + X-Bigmodel-Authorization: Bearer + Bigmodel-Target-Type。2026-10-10 实弹:API-key 配方同样过 auth 层。 * - * 反代偏差(deliberate deviations,官方为"每消息尝试、卡绑单任务"): - * - 抽卡由合格请求到达触发(懒启动 worker),不逐消息阻塞; + * 反代偏差(deliberate deviations,官方为"每消息内联尝试、miss 尊重 next_draw_at"): + * - 抽卡为后台最大速率循环:首个合格请求同步点燃(当拍即发第一次 draw), + * 此后按心跳节拍(≈1s,实测服务端令牌桶容量 8/回填 1/s——即真实最大可持续 + * 速率,更快只会换来 429→30s 退避)持续重试;miss(card=null)**忽略服务端 + * next_draw_at**,下一个心跳立即重试(2026-10-12 用户决策:抽卡窗口价值在 + * 请求活跃期,等 2min 冷却等于放弃加速)。 + * - 空闲自熄:最后一次合格请求 5min 后循环停止;新请求重新点燃。 * - task_id 复用 client-session affinity 推断(`upstreamSessionId`),无会话时 - * 每次抽卡用随机 UUID; - * - 持卡窗口内的所有合格请求共享该卡(官方仅卡所属任务可用;推理路径只带 - * X-Highspeed-Card-ID,无服务端 task 校验证据)。 + * 每次抽卡用随机 UUID;持卡窗口内所有合格请求共享该卡(官方仅卡所属任务)。 * - 503/biz 3404(账号未灰度,2026-10-10 实弹稳态)负缓存 10min,避免 12.6s - * 级联延迟反复触发。 + * 级联延迟反复触发;429 退避 30s;传输失败退避 60s。心跳定时器 unref—— + * 永不自持进程(对齐 update-check 惯例,#58 的 docker-stop 教训)。 */ import { randomUUID } from "node:crypto"; import type { UpstreamHeaderPair } from "../proxy/upstream.js"; @@ -27,12 +30,14 @@ import { credentialString } from "../auth/types.js"; const DRAW_PATH = "/api/v1/highspeed/draw"; const DRAW_TIMEOUT_MS = 5_000; +/** Heartbeat cadence of the draw loop ≈ measured token-bucket refill (1/s). */ +const DRAW_RETRY_MS = 1_000; +/** Stop drawing this long after the last eligible request (idle self-extinguish). */ +const IDLE_MS = 5 * 60_000; /** Negative cache after account-level unavailability (503 / biz 3404). */ const UNAVAILABLE_MS = 10 * 60_000; /** Backoff after an HTTP 429 (measured token bucket ≈ capacity 8, refill 1/s). */ const RATE_LIMIT_BACKOFF_MS = 30_000; -/** Fallback cooldown when the server does not send next_draw_at. */ -const DEFAULT_COOLDOWN_MS = 2 * 60_000; /** Cooldown after transport failure (network / timeout). */ const TRANSPORT_FAILURE_MS = 60_000; /** Skip applying a card with less than this left on its window. */ @@ -58,7 +63,7 @@ export interface HighspeedAuth { interface DrawResponseBody { code?: unknown; msg?: unknown; - data?: { card?: DrawCardWire | null; next_draw_at?: unknown }; + data?: { card?: DrawCardWire | null }; } interface DrawCardWire { @@ -70,31 +75,54 @@ interface DrawCardWire { expires_at?: unknown; } +interface LatestDrawRequest { + model: string; + taskId: string; + auth: HighspeedAuth; +} + export class HighspeedManager { private readonly origin: string; private readonly models: string[]; + private readonly deviceMid: string | undefined; private readonly fetchImpl: typeof fetch; private readonly now: () => number; + /** Heartbeat interval — production default 1s; tests inject small values. */ + private readonly retryMs: number; private readonly notedKeys = new Set(); onEvent?: (message: string) => void; private card: HighspeedCard | null = null; - private nextDrawAt = 0; private unavailableUntil = 0; - private drawInFlight: { model: string; taskId: string; auth: HighspeedAuth; promise: Promise } | null = null; + /** Earliest next draw attempt (429 / transport backoff gate). */ + private nextAttemptAt = 0; + /** Loop deadline = last eligible request + IDLE_MS. */ + private loopUntil = 0; + private timer: ReturnType | null = null; + private drawInFlight: Promise | null = null; + /** Most recent eligible request — the loop always draws with these params. */ + private latest: LatestDrawRequest | null = null; constructor(opts: { origin: string; models: string[]; + /** Injected as X-Device-Mid (claim-plane convention) — required on first + * draw access once the activity opens (live 2026-10-12: without it every + * draw gets 400 biz 3001 "parameter error"). */ + deviceMid?: string; fetchImpl?: typeof fetch; now?: () => number; + retryMs?: number; onEvent?: (message: string) => void; }) { this.origin = opts.origin.replace(/\/+$/u, ""); this.models = opts.models.map((m) => m.trim().toLowerCase()).filter(Boolean); + this.deviceMid = opts.deviceMid?.trim() || undefined; this.fetchImpl = opts.fetchImpl ?? fetch; this.now = opts.now ?? Date.now; + this.retryMs = opts.retryMs ?? DRAW_RETRY_MS; this.onEvent = opts.onEvent; + liveManagers.add(this); } /** Prefix match against the configured eligible families (glm-5.3/5.2/5.1 …). */ @@ -106,31 +134,18 @@ export class HighspeedManager { /** * Fire-and-forget draw trigger — call on every eligible coding-plan request. - * Respects the single card slot, server cooldown and the unavailability - * window; concurrent calls deduplicate into one in-flight draw. + * Stamps the idle window with the latest request params and (re)ignites the + * background loop. While a card is held nothing is drawn (single slot); + * concurrent draws deduplicate into one in-flight request. */ onRequest(model: string, sessionTaskId: string | undefined, auth: HighspeedAuth): void { if (!this.isEligibleModel(model)) return; const now = this.now(); this.expireCard(now); if (this.card) return; - if (now < this.nextDrawAt || now < this.unavailableUntil) return; - if (this.drawInFlight) return; - if (!auth.jwt.trim()) { - this.noteOnce("no-jwt", "credential has no zcode JWT — highspeed disabled until re-login"); - return; - } - const taskId = sessionTaskId ?? randomUUID(); - const promise = this.draw(model, taskId, auth) - .catch(() => { - // draw() never throws (all outcomes map to cooldown windows); network - // throws land here as an extra guard. - this.nextDrawAt = this.now() + TRANSPORT_FAILURE_MS; - }) - .finally(() => { - if (this.drawInFlight?.promise === promise) this.drawInFlight = null; - }); - this.drawInFlight = { model, taskId, auth, promise }; + this.latest = { model, taskId: sessionTaskId ?? randomUUID(), auth }; + this.loopUntil = now + IDLE_MS; + this.ensureLoop(); } /** The active card, or null when outside the window (expiry-checked). */ @@ -146,6 +161,14 @@ export class HighspeedManager { if (!this.card) return; this.card = null; this.onEvent?.(`highspeed: card dropped (${reason})`); + // Mid-idle-window restart: while the session is still active a replacement + // card is redrawn immediately (first draw fires synchronously). + if (this.now() < this.loopUntil) this.ensureLoop(); + } + + /** Stop the draw loop (shutdown / manager recreation). Idempotent, no-throw. */ + stop(): void { + this.stopTimer(); } private expireCard(now: number): void { @@ -155,6 +178,77 @@ export class HighspeedManager { } } + /** Ignite the loop if it is not running. First draw fires synchronously. */ + private ensureLoop(): void { + if (this.timer) return; + const latest = this.latest; + if (!latest) return; + if (!latest.auth.jwt.trim()) { + this.noteOnce("no-jwt", "credential has no zcode JWT — highspeed disabled until re-login"); + return; + } + if (this.drawInFlight) { + // In-flight draw with a stopped timer (idle-stop raced a request): + // re-arm the heartbeat; tick() applies the gates. + this.arm(); + return; + } + void this.tick(); + } + + /** One loop beat: gate checks, maybe draw, re-arm. */ + private tick(): void { + const now = this.now(); + this.expireCard(now); + if (this.card) { + this.stopTimer(); + return; + } + if (now >= this.loopUntil) { + this.stopTimer(); + this.onEvent?.("highspeed: draw loop idle-stopped (5min without eligible requests)"); + return; + } + if (this.drawInFlight || now < this.nextAttemptAt || now < this.unavailableUntil) { + this.arm(); + return; + } + const latest = this.latest; + if (!latest || !latest.auth.jwt.trim()) { + this.stopTimer(); + return; + } + const promise = this.draw(latest.model, latest.taskId, latest.auth) + .catch(() => { + // draw() never throws (all outcomes map to backoff windows); network + // throws land here as an extra guard. + this.nextAttemptAt = this.now() + TRANSPORT_FAILURE_MS; + }) + .finally(() => { + if (this.drawInFlight === promise) this.drawInFlight = null; + }); + this.drawInFlight = promise; + this.arm(); + } + + /** Re-arm the heartbeat. unref'd: the loop never keeps the process alive. */ + private arm(): void { + this.stopTimer(); + const timer = setTimeout(() => { + this.timer = null; + this.tick(); + }, this.retryMs); + timer.unref?.(); + this.timer = timer; + } + + private stopTimer(): void { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + } + private async draw(model: string, taskId: string, auth: HighspeedAuth): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), DRAW_TIMEOUT_MS); @@ -169,6 +263,7 @@ export class HighspeedManager { "X-Bigmodel-Authorization": `Bearer ${auth.codingPlanAuth}`, "Bigmodel-Target-Type": "PERSONAL", "x-request-id": randomUUID(), + ...(this.deviceMid ? { "X-Device-Mid": this.deviceMid } : {}), }, body: JSON.stringify({ task_id: taskId, @@ -185,7 +280,7 @@ export class HighspeedManager { body = null; } } catch { - this.nextDrawAt = this.now() + TRANSPORT_FAILURE_MS; + this.nextAttemptAt = this.now() + TRANSPORT_FAILURE_MS; this.noteOnce("transport", "highspeed draw failed (network/timeout) — retrying later"); return; } finally { @@ -194,7 +289,7 @@ export class HighspeedManager { const now = this.now(); if (status === 429) { - this.nextDrawAt = now + RATE_LIMIT_BACKOFF_MS; + this.nextAttemptAt = now + RATE_LIMIT_BACKOFF_MS; this.noteOnce("429", "highspeed draw rate-limited (429) — backing off 30s"); return; } @@ -211,11 +306,9 @@ export class HighspeedManager { this.noteOnce(`biz${code}`, `highspeed draw rejected (code=${code} ${String(body.msg ?? "").slice(0, 60)}) — negative-cached 10min`); return; } - const data = body.data ?? {}; - this.nextDrawAt = typeof data.next_draw_at === "number" && data.next_draw_at > now - ? data.next_draw_at - : now + DEFAULT_COOLDOWN_MS; - const wire = data.card; + // Deliberate deviation: `next_draw_at` is ignored on a miss — the loop + // retries at the heartbeat cadence until the idle window closes. + const wire = body.data?.card; if (wire && typeof wire.card_id === "string" && wire.card_id && typeof wire.model === "string" && wire.model && typeof wire.expires_at === "number" && wire.expires_at > now) { @@ -226,9 +319,10 @@ export class HighspeedManager { issuedAt: typeof wire.issued_at === "number" ? wire.issued_at : now, expiresAt: wire.expires_at, }; + this.stopTimer(); this.onEvent?.(`highspeed: card drawn (${this.card.cardId.slice(0, 12)}… model=${this.card.model} window=${Math.round((this.card.expiresAt - now) / 1000)}s)`); } else { - this.noteOnce("miss", "highspeed draw miss (card=null) — honoring next_draw_at cooldown"); + this.noteOnce("miss", "highspeed draw miss (card=null) — retrying at max rate"); } } @@ -239,6 +333,44 @@ export class HighspeedManager { } } +/** Every constructed manager — shutdown hygiene for the background loops. */ +const liveManagers = new Set(); + +/** Stop every draw loop (serve-mode shutdown path, alongside claim/captcha). */ +export function shutdownAllHighspeed(): void { + for (const manager of liveManagers) manager.stop(); + liveManagers.clear(); +} + +let defaultManager: HighspeedManager | null = null; +let defaultManagerKey = ""; + +/** + * Process-wide manager (single card slot, like the official host service). + * Returns null when `highspeed.enabled` is false. Recreated when origin/models + * change (Android `setConfig`) — the previous instance's loop is stopped. + */ +export function getDefaultHighspeedManager(config: { + highspeed: { enabled: boolean; origin: string; models: string[] }; + identity: { deviceMid?: string }; +}): HighspeedManager | null { + if (!config.highspeed.enabled) return null; + const key = `${config.highspeed.origin}\n${config.highspeed.models.join(",")}\n${config.identity.deviceMid}`; + if (!defaultManager || key !== defaultManagerKey) { + defaultManager?.stop(); + defaultManager = new HighspeedManager({ + origin: config.highspeed.origin, + models: config.highspeed.models, + deviceMid: config.identity.deviceMid, + // State transitions (drawn / miss / cooldown / dropped / idle-stopped) are + // otherwise silent in production — surface each once per kind. + onEvent: (message) => console.log(`client-${message}`), + }); + defaultManagerKey = key; + } + return defaultManager; +} + /** Rewrite the `model` field of a JSON request body (parse failure → unchanged). */ export function rewriteBodyModel(body: string, model: string): string { try { @@ -303,29 +435,3 @@ export function highspeedAuthFrom(cred: Credential): HighspeedAuth | null { family: cred.provider === "bigmodel" ? "bigmodel" : "zai", }; } - -let defaultManager: HighspeedManager | null = null; -let defaultManagerKey = ""; - -/** - * Process-wide manager (single card slot, like the official host service). - * Returns null when `highspeed.enabled` is false. Recreated when origin/models - * change (Android `setConfig`). - */ -export function getDefaultHighspeedManager(config: { - highspeed: { enabled: boolean; origin: string; models: string[] }; -}): HighspeedManager | null { - if (!config.highspeed.enabled) return null; - const key = `${config.highspeed.origin}\n${config.highspeed.models.join(",")}`; - if (!defaultManager || key !== defaultManagerKey) { - defaultManager = new HighspeedManager({ - origin: config.highspeed.origin, - models: config.highspeed.models, - // State transitions (drawn / miss / cooldown / dropped) are otherwise - // silent in production — surface each once per kind. - onEvent: (message) => console.log(`client-${message}`), - }); - defaultManagerKey = key; - } - return defaultManager; -} diff --git a/src/index.ts b/src/index.ts index 94f25c6..0a0f0fb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,7 @@ import { type PanelSettings, } from "./server/panel.js"; import { checkForUpdate } from "./update/check.js"; +import { shutdownAllHighspeed } from "./highspeed/manager.js"; import { readFileSync, existsSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; @@ -475,6 +476,12 @@ async function serve(configPath: string | undefined, debug: boolean): Promise 0) console.log(`shutdown: cleared ${cleared.join(" + ")} timers`); if (serverRef.current) { // Closes the listener and exits the process (`stop(true)`). diff --git a/src/proxy/handler-highspeed.test.ts b/src/proxy/handler-highspeed.test.ts index e48559f..cc5bb75 100644 --- a/src/proxy/handler-highspeed.test.ts +++ b/src/proxy/handler-highspeed.test.ts @@ -164,8 +164,10 @@ describe("proxyRequest highspeed wiring", () => { expect(upstreamCalls[1]!.headers.get("x-api-key")).toBe(CRED); expect(upstreamCalls[1]!.headers.get("authorization")).toBe(`Bearer ${CRED}`); expect(JSON.parse(upstreamCalls[1]!.body).model).toBe("glm-5.1"); - // card dropped: subsequent requests go uncarded until a fresh draw - expect(manager.activeCard()).toBeNull(); + // card dropped; the active session re-ignites the loop and the always-hit + // draw mock hands back a fresh card (2026-10-12 max-rate loop contract) + await new Promise((r) => setTimeout(r, 5)); + expect(manager.activeCard()?.cardId).toBe("hsc_wiring_1"); }); it("keeps card headers on connect-retry rebuilds (attempt ≥ 2 rebuilds from the swapped pairs)", async () => { From d8694ee1a5117e624a4cd16cedae52b966706e99 Mon Sep 17 00:00:00 2001 From: TriDefender <173548745+TriDefender@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:36:54 +0000 Subject: [PATCH 3/5] Fixed clientIdentity --- src/auth/resolver.ts | 5 +- src/auth/types.ts | 8 ++ src/highspeed/manager.test.ts | 26 ++-- src/highspeed/manager.ts | 10 +- src/proxy/client-signing.test.ts | 107 +++++++++++++++- src/proxy/client-signing.ts | 211 ++++++++++++++++++++++++++++--- src/proxy/handler.ts | 2 + src/proxy/responses-handler.ts | 2 + 8 files changed, 335 insertions(+), 36 deletions(-) diff --git a/src/auth/resolver.ts b/src/auth/resolver.ts index 8762487..9e60145 100644 --- a/src/auth/resolver.ts +++ b/src/auth/resolver.ts @@ -168,6 +168,9 @@ export class KeyResolver { if (secret) fullKey = `${apiKey}.${secret}`; } catch { /* use apiKey only */ } - return { apiKey: fullKey, provider: "bigmodel", userId }; + // 3.15.1 project-access-token plane: the raw OAuth token mints the signing + // JWT (`…/access_tokens`); without it signing falls back to the apiKey + // form and fails open on the bigmodel handshake. + return { apiKey: fullKey, provider: "bigmodel", userId, oauthAccessToken: accessToken }; } } diff --git a/src/auth/types.ts b/src/auth/types.ts index b6f9c09..deb54dd 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -19,6 +19,14 @@ export interface Credential { userId?: string; /** JWT token for start-plan (zcode.z.ai). Present when login captured the ZCode plan token. */ jwt?: string; + /** + * Raw provider OAuth access token (bigmodel). Required by the 3.15.1 + * project-access-token plane: getCustomerInfo (bare) and + * `…/api_keys/{id}/access_tokens` (Bearer) mint the signing JWT + * (`project_access`, 5h). Absent for API-key imports and pre-patch logins + * — signing then falls back to the apiKey form and fails open. + */ + oauthAccessToken?: string; } /** diff --git a/src/highspeed/manager.test.ts b/src/highspeed/manager.test.ts index 1ace4f4..ac68c27 100644 --- a/src/highspeed/manager.test.ts +++ b/src/highspeed/manager.test.ts @@ -144,7 +144,7 @@ describe("HighspeedManager", () => { }) as unknown as typeof fetch, clock, { onEvent: (m) => events.push(m) }); manager.onRequest("glm-5.3", SESSION_TASK, AUTH); - await until(() => fetches >= 2); + expect(await until(() => fetches >= 2)).toBeTrue(); clock.now += 5 * 60_000 + 1; // idle window closed expect(await until(() => events.some((m) => m.includes("idle-stopped")))).toBeTrue(); const fetchesAtStop = fetches; @@ -159,7 +159,7 @@ describe("HighspeedManager", () => { manager.onRequest("glm-5.3", SESSION_TASK, AUTH); await settle(); - expect(manager.activeCard()).toBeDefined(); + expect(manager.activeCard()?.cardId).toBe("hsc_test_1"); // card expires, loop had stopped; 10 idle minutes pass clock.now += 295_000; @@ -167,28 +167,28 @@ describe("HighspeedManager", () => { clock.now += 10 * 60_000; manager.onRequest("glm-5.3", SESSION_TASK, AUTH); await settle(); - expect(manager.activeCard()).toBeDefined(); // re-ignited and drew again + expect(manager.activeCard()?.cardId).toBe("hsc_test_1"); // re-ignited and drew again }); it("keeps redrawing after the card expires mid-idle-window", async () => { const clock = { now: 1_000_000 }; - let missesThenHit = 0; + let draws = 0; const manager = makeManager((async () => { - missesThenHit++; - return missesThenHit <= 4 + draws++; + return draws <= 4 ? drawResponse(null) : drawResponse(hitCard(clock.now)); }) as unknown as typeof fetch, clock); manager.onRequest("glm-5.3", SESSION_TASK, AUTH); - await settle(); - expect(manager.activeCard()).toBeDefined(); + expect(await until(() => manager.activeCard()?.cardId === "hsc_test_1")).toBeTrue(); // hit on the 5th draw + const drawsAtFirstHit = draws; clock.now += 295_000; // 5s left — under the minimum-remaining guard expect(manager.activeCard()).toBeNull(); - manager.onRequest("glm-5.3", SESSION_TASK, AUTH); // re-ignite inside idle window - await settle(); - expect(manager.activeCard()).toBeDefined(); + manager.onRequest("glm-5.3", SESSION_TASK, AUTH); // re-ignite inside idle window (sub-min card must not block) + expect(await until(() => draws > drawsAtFirstHit && manager.activeCard()?.cardId === "hsc_test_1")).toBeTrue(); + expect(draws).toBeGreaterThan(drawsAtFirstHit); // fresh draws actually fired past the first hit }); it("backs off 30s on HTTP 429, then resumes the heartbeat", async () => { @@ -200,7 +200,7 @@ describe("HighspeedManager", () => { }) as unknown as typeof fetch, clock); manager.onRequest("glm-5.1", SESSION_TASK, AUTH); - await until(() => fetches >= 2); + await settle(); const atTwo = fetches; clock.now += 20_000; // inside the 30s backoff await settle(); @@ -219,7 +219,7 @@ describe("HighspeedManager", () => { }) as unknown as typeof fetch, clock); manager.onRequest("glm-5.2", SESSION_TASK, AUTH); - await until(() => fetches >= 2); + await settle(); const atTwo = fetches; // Stay inside the idle window across both clock jumps so the gate under // test is the negative cache, not the idle self-extinguish. diff --git a/src/highspeed/manager.ts b/src/highspeed/manager.ts index d453333..0db62d3 100644 --- a/src/highspeed/manager.ts +++ b/src/highspeed/manager.ts @@ -142,7 +142,10 @@ export class HighspeedManager { if (!this.isEligibleModel(model)) return; const now = this.now(); this.expireCard(now); - if (this.card) return; + // an unusable card (< CARD_MIN_REMAINING_MS) must not block re-ignition — + // requests during those last seconds go out uncarded anyway, and holding + // the slot would stall the loop until full expiry + if (this.card && this.card.expiresAt - now >= CARD_MIN_REMAINING_MS) return; this.latest = { model, taskId: sessionTaskId ?? randomUUID(), auth }; this.loopUntil = now + IDLE_MS; this.ensureLoop(); @@ -200,7 +203,9 @@ export class HighspeedManager { private tick(): void { const now = this.now(); this.expireCard(now); - if (this.card) { + // same sub-min-remaining rule as onRequest: an unusable card must not + // stop the loop — the replacement draw fires while the old one winds down + if (this.card && this.card.expiresAt - now >= CARD_MIN_REMAINING_MS) { this.stopTimer(); return; } @@ -358,6 +363,7 @@ export function getDefaultHighspeedManager(config: { const key = `${config.highspeed.origin}\n${config.highspeed.models.join(",")}\n${config.identity.deviceMid}`; if (!defaultManager || key !== defaultManagerKey) { defaultManager?.stop(); + if (defaultManager) liveManagers.delete(defaultManager); defaultManager = new HighspeedManager({ origin: config.highspeed.origin, models: config.highspeed.models, diff --git a/src/proxy/client-signing.test.ts b/src/proxy/client-signing.test.ts index 29ed4c7..b792d46 100644 --- a/src/proxy/client-signing.test.ts +++ b/src/proxy/client-signing.test.ts @@ -38,7 +38,7 @@ interface HandshakeFixture { cipherB64: string; } -async function buildHandshakeFixture(): Promise { +async function buildHandshakeFixture(secret = API_KEY_SECRET, apiKeyId = API_KEY_ID): Promise { const keyPair = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]) as CryptoKeyPair; const pkcs8 = new Uint8Array(await crypto.subtle.exportKey("pkcs8", keyPair.privateKey)); const publicKeyRaw = new Uint8Array(await crypto.subtle.exportKey("raw", keyPair.publicKey)); @@ -48,11 +48,11 @@ async function buildHandshakeFixture(): Promise { for (const b of pkcs8) pkcs8Binary += String.fromCharCode(b); const plainBytes = new TextEncoder().encode(btoa(pkcs8Binary)); - const aesKeyBits = hkdf(API_KEY_SECRET, "ed25519_priv"); + const aesKeyBits = hkdf(secret, "ed25519_priv"); const aesKey = await crypto.subtle.importKey("raw", new Uint8Array(aesKeyBits), "AES-GCM", false, ["encrypt"]); const iv = crypto.getRandomValues(new Uint8Array(12)); const encrypted = new Uint8Array(await crypto.subtle.encrypt( - { name: "AES-GCM", iv, additionalData: new TextEncoder().encode(API_KEY_ID), tagLength: 128 }, + { name: "AES-GCM", iv, additionalData: new TextEncoder().encode(apiKeyId), tagLength: 128 }, aesKey, plainBytes, )); @@ -356,3 +356,104 @@ describe("poW nonce/format details", () => { expect(pair(a, "X-Client-Nonce")).not.toBe(pair(b, "X-Client-Nonce")); }); }); + +// ── 3.15.1 project-access-token plane (bigmodel OAuth, `FSr` dual form) ── + +function b64url(obj: unknown): string { + return Buffer.from(JSON.stringify(obj)).toString("base64url"); +} +function makeProjectJwt(apiKey: string, expSeconds: number): string { + return `${b64url({ alg: "HS256", typ: "JWT", sign_type: "SIGN" })}.${b64url({ token_use: "project_access", api_key: apiKey, exp: expSeconds })}.sig`; +} +const OAUTH = "oauth-es512-token"; +const JWT_CRED = makeProjectJwt("testkey", Math.floor(Date.now() / 1000) + 3600); +const JWT_URL = "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions"; + +describe("project-access JWT signing form (bigmodel OAuth)", () => { + it("parses the JWT form: apiKeyId from the api_key claim, handshake body field accessToken, Bearer auth", async () => { + // privateCipher must be encrypted under the JWT (the signing secret), AAD = api_key claim + const fixture = await buildHandshakeFixture(JWT_CRED, "testkey"); + const calls: MockCalls = { handshakes: [] }; + const manager = new ClientSigningManager({ + fetchImpl: (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + if (url === JWT_URL) throw new Error("LLM request must not leak into signing fetch"); + if (url.endsWith("/api/paas/c1f3a7e2/v2/client")) { + calls.handshakes.push({ + url, + body: JSON.parse(String(init?.body)) as Record, + auth: new Headers(init?.headers).get("authorization") ?? "", + }); + return new Response(JSON.stringify({ code: 200, data: { privateCipher: fixture.cipherB64 } }), { status: 200 }); + } + throw new Error(`unexpected fetch: ${url}`); + }) as unknown as typeof fetch, + }); + + const signed = await manager.sign(JWT_URL, BASE_PAIRS, { credential: JWT_CRED, appVersion: "3.8.1" }); + expect(pair(signed, "X-Client-Sig")).toBeDefined(); + const hs = calls.handshakes[0]!; + // FSr semantics: body field switches to accessToken, auth = Bearer JWT + expect(Object.keys(hs.body)).toContain("accessToken"); + expect(hs.body.accessToken).toBe(JWT_CRED); + expect(hs.auth).toBe(`Bearer ${JWT_CRED}`); + expect(hs.body.nonce).toMatch(/^[0-9a-f]{32}$/); + }); + + it("mints a project JWT via getCustomerInfo + access_tokens (bare vs Bearer auth split), then signs", async () => { + const mintedJwt = makeProjectJwt("testkey", Math.floor(Date.now() / 1000) + 3600); + const fixture = await buildHandshakeFixture(mintedJwt, "testkey"); + const calls: MockCalls = { handshakes: [] }; + const mintUrls: string[] = []; + const mintAuths: string[] = []; + const manager = new ClientSigningManager({ + fetchImpl: (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + const auth = new Headers(init?.headers).get("authorization") ?? ""; + if (url.endsWith("/api/biz/customer/getCustomerInfo")) { + mintUrls.push(url); mintAuths.push(auth); + // bare token (no Bearer) on bigmodel getCustomerInfo + expect(auth).toBe(OAUTH); + return new Response(JSON.stringify({ code: 200, data: { organizations: [{ organizationId: "org-1", projects: [{ projectId: "proj-1", projectType: "1" }] }] } }), { status: 200 }); + } + if (url.includes("/api_keys/testkey/access_tokens")) { + mintUrls.push(url); mintAuths.push(auth); + // Bearer form on access_tokens + expect(auth).toBe(`Bearer ${OAUTH}`); + expect(JSON.parse(String(init?.body))).toEqual({ clientType: "zcode", clientVersion: "3.15.1" }); + return new Response(JSON.stringify({ code: 200, data: { accessToken: mintedJwt } }), { status: 200 }); + } + if (url.endsWith("/api/paas/c1f3a7e2/v2/client")) { + calls.handshakes.push({ url, body: JSON.parse(String(init?.body)) as Record, auth: new Headers(init?.headers).get("authorization") ?? "" }); + return new Response(JSON.stringify({ code: 200, data: { privateCipher: fixture.cipherB64 } }), { status: 200 }); + } + throw new Error(`unexpected fetch: ${url}`); + }) as unknown as typeof fetch, + }); + + const signed = await manager.sign(JWT_URL, BASE_PAIRS, { + credential: CRED, appVersion: "3.8.1", oauthAccessToken: OAUTH, provider: "bigmodel", + }); + console.log("MINTS:", JSON.stringify(mintUrls)); + expect(pair(signed, "X-Client-Sig")).toBeDefined(); + expect(mintUrls.some((u) => u.includes("/api_keys/testkey/access_tokens"))).toBeTrue(); + // minted JWT form handshake: accessToken body field + expect(Object.keys(calls.handshakes[0]!.body)).toContain("accessToken"); + }); + + it("negative-caches mint failures (one mint attempt per cooldown window)", async () => { + let mints = 0; + const manager = new ClientSigningManager({ + fetchImpl: (async (input: RequestInfo | URL) => { + const url = String(input instanceof Request ? input.url : input); + if (url.includes("/access_tokens")) { mints++; return new Response(JSON.stringify({ code: 404 }), { status: 200 }); } + if (url.includes("getCustomerInfo")) return new Response(JSON.stringify({ code: 200, data: { organizations: [{ organizationId: "org-1", projects: [{ projectId: "p1" }] }] } }), { status: 200 }); + throw new Error(`unexpected fetch: ${url}`); + }) as unknown as typeof fetch, + }); + const cred = { credential: CRED, appVersion: "3.8.1", oauthAccessToken: OAUTH, provider: "bigmodel" as const }; + await manager.sign(JWT_URL, BASE_PAIRS, cred); + await manager.sign(JWT_URL, BASE_PAIRS, cred); + expect(mints).toBe(1); // negative cache: no re-mint inside the window + }); +}); diff --git a/src/proxy/client-signing.ts b/src/proxy/client-signing.ts index 32b363a..f390132 100644 --- a/src/proxy/client-signing.ts +++ b/src/proxy/client-signing.ts @@ -48,6 +48,8 @@ const KDF_INFO_HMAC = "getSignKey_hmac"; const KDF_INFO_ED25519 = "ed25519_priv"; const HANDSHAKE_METHOD = "get_sign_key"; const HANDSHAKE_TIMEOUT_MS = 10_000; +/** Negative cache after a failed project-token mint (stale token / biz error). */ +const MINT_FAILURE_COOLDOWN_MS = 60_000; const VERIFY_SIGNATURE_INVALID = "VERIFY_SIGNATURE_INVALID"; const VERIFY_APIKEY_EXPIRED = "VERIFY_APIKEY_EXPIRED"; @@ -71,6 +73,14 @@ const SIGNING_HEADER_NAMES = new Set([ export interface SigningCredential { credential: string; appVersion: string; + /** + * Raw provider OAuth access token (bigmodel OAuth logins). When present the + * manager mints a project-access JWT (`FSr` accessToken form) for the + * handshake instead of the `{apiKeyId}.{secret}` form — the official + * 3.15.1 client signs OAuth-provisioned accounts with that JWT. + */ + oauthAccessToken?: string; + provider?: "zai" | "bigmodel"; } export interface SignOutcome { @@ -78,9 +88,19 @@ export interface SignOutcome { signed: boolean; } -interface ParsedCredential { +/** + * Dual-form signing credential — mirrors 3.15.1 `FSr` + * (parseClientSigningCredential): + * - JWT (3 dot-separated segments): `apiKeyId = payload.api_key`, + * `secret = the whole JWT`, handshake body field `accessToken` + * (`project_access` token, 5h, minted via `…/access_tokens`). + * - `{apiKeyId}.{secret}`: handshake body field `apiKey` (Z.AI keys, and + * Bigmodel keys when no OAuth token is available). + */ +interface ParsedSigningCredential { apiKeyId: string; - apiKeySecret: string; + secret: string; + handshakeField: "apiKey" | "accessToken"; } interface SignerState { @@ -208,13 +228,31 @@ async function createProofOfWork(apiKeyId: string, sessionId: string, ts: string throw new Error("Unable to solve client request proof of work"); } -function parseSigningCredential(credential: string): ParsedCredential | undefined { +/** + * Dual-form credential parse (3.15.1 `FSr`). JWT form: `apiKeyId` comes from + * the `api_key` payload claim, `secret` is the whole JWT, and the handshake + * body field switches to `accessToken`. + */ +function parseSigningCredentialForm(credential: string): ParsedSigningCredential | undefined { + const segments = credential.split("."); + if (segments.length === 3 && segments[2]) { + try { + const decode = (s: string) => JSON.parse(Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")) as Record; + const header = decode(segments[0]!) as { alg?: string; typ?: string; sign_type?: string }; + if (header.alg !== "HS256" || header.sign_type !== "SIGN") return undefined; + const payload = decode(segments[1]!) as { token_use?: string; api_key?: string }; + if (payload.token_use !== "project_access" || typeof payload.api_key !== "string" || !payload.api_key.trim()) return undefined; + return { apiKeyId: payload.api_key, secret: credential, handshakeField: "accessToken" }; + } catch { + return undefined; + } + } const dot = credential.indexOf("."); if (dot <= 0 || dot !== credential.lastIndexOf(".")) return undefined; const apiKeyId = credential.slice(0, dot); - const apiKeySecret = credential.slice(dot + 1); - if (!apiKeyId.trim() || !apiKeySecret.trim()) return undefined; - return { apiKeyId, apiKeySecret }; + const secret = credential.slice(dot + 1); + if (!apiKeyId.trim() || !secret.trim()) return undefined; + return { apiKeyId, secret, handshakeField: "apiKey" }; } function isUnsignedPath(pathname: string): boolean { @@ -238,15 +276,23 @@ function findHeader(pairs: UpstreamHeaderPair[], name: string): string | undefin export class ClientSigningManager { private readonly fetchImpl: typeof fetch; + private readonly now: () => number; private readonly states = new Map(); private readonly notedKeys = new Set(); + /** Cached project-access JWT (bigmodel OAuth): keyed by the api credential. */ + private projectJwt: { credentialKey: string; jwt: string; expMs: number } | null = null; + /** Negative cache after a failed project-token mint (stale OAuth token etc.). */ + private mintNegUntil = 0; + private mintInFlight: Promise | null = null; onEvent?: (message: string) => void; constructor(opts: { fetchImpl?: typeof fetch; + now?: () => number; onEvent?: (message: string) => void; } = {}) { this.fetchImpl = opts.fetchImpl ?? fetch; + this.now = opts.now ?? Date.now; this.onEvent = opts.onEvent; } @@ -282,7 +328,7 @@ export class ClientSigningManager { const state = this.stateFor(stateKey); if (state.bypass) return { pairs, signed: false }; - const parsedCred = parseSigningCredential(cred.credential); + const parsedCred = await this.resolveParsedCredential(cred, stateKey); if (!parsedCred) { this.noteOnce(stateKey, "credential has no {apiKeyId}.{apiKeySecret} separator — signing skipped"); return { pairs, signed: false }; @@ -310,7 +356,7 @@ export class ClientSigningManager { stateKey: string, pairs: UpstreamHeaderPair[], privateKey: CryptoKey, - parsedCred: ParsedCredential, + parsedCred: ParsedSigningCredential, sessionId: string, appVersion: string, ): Promise { @@ -407,9 +453,125 @@ export class ClientSigningManager { return state; } + /** + * Resolve the signing credential form. Bigmodel OAuth logins mint a + * project-access JWT from the raw OAuth token (live-verified 2026-10-12: + * the apiKey form handshakes 200 but its privateCipher can never be + * decrypted on bigmodel) and cache it until 5 minutes before the JWT exp. + * Any mint failure falls back to the apiKey form (unsigned via the usual + * ladder). Z.AI keys pass through unchanged (apiKey form, live-verified). + */ + private async resolveParsedCredential(cred: SigningCredential, stateKey: string): Promise { + const direct = parseSigningCredentialForm(cred.credential); + if (direct?.handshakeField === "accessToken") return direct; + if (!cred.oauthAccessToken || cred.provider !== "bigmodel") return direct; + + const now = this.now(); + if (this.projectJwt && this.projectJwt.credentialKey === cred.credential && this.projectJwt.expMs - 5 * 60_000 > now) { + const cached = parseSigningCredentialForm(this.projectJwt.jwt); + if (cached) return cached; + this.projectJwt = null; // stale/malformed cache entry — fall through to a fresh mint + } + if (now < this.mintNegUntil) return direct; + if (this.mintInFlight) return this.mintInFlight; + + const promise = this.mintProjectJwt(cred.oauthAccessToken, direct?.apiKeyId) + .then((minted) => { + if (!minted) { + // negative-cache so a stale OAuth token can't burn two doomed + // fetches on every request (mirrors the highspeed manager's gate) + this.mintNegUntil = this.now() + MINT_FAILURE_COOLDOWN_MS; + return direct; + } + const parsed = parseSigningCredentialForm(minted.jwt); + if (!parsed) { + // strict parse rejected the minted token — don't cache it, don't + // disable signing; the apiKey form stays available + this.mintNegUntil = this.now() + MINT_FAILURE_COOLDOWN_MS; + this.noteOnce("pt-parse", "minted project token failed strict parse — falling back to apiKey form"); + return direct; + } + this.projectJwt = { credentialKey: cred.credential, jwt: minted.jwt, expMs: minted.expMs }; + // the private key is keyed to the previous secret — force a re-handshake + this.invalidateState(stateKey); + return parsed; + }) + .finally(() => { + if (this.mintInFlight === promise) this.mintInFlight = null; + }); + this.mintInFlight = promise; + return promise; + } + + /** + * Mirror of the 3.15.1 ProjectAccessTokenClient: getCustomerInfo (bare + * OAuth token — no Bearer on bigmodel) resolves the default org/project + * (name contains 默认机构/默认项目 preferred, else first, projectType "2" + * excluded), then `…/api_keys/{apiKeyId}/access_tokens` (Bearer) mints the + * `project_access` JWT. + */ + private async mintProjectJwt(oauthAccessToken: string, apiKeyId: string | undefined): Promise<{ jwt: string; expMs: number } | null> { + if (!apiKeyId) { + this.noteOnce("pt-key", "project token mint skipped: credential has no apiKeyId segment"); + return null; + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 15_000); + try { + const bare = oauthAccessToken.startsWith("Bearer ") ? oauthAccessToken.slice(7).trim() : oauthAccessToken; + const infoResp = await this.fetchImpl("https://bigmodel.cn/api/biz/customer/getCustomerInfo", { + headers: { Authorization: bare, "Content-Type": "application/json" }, + redirect: "manual", + signal: controller.signal, + }); + const info = await infoResp.json() as { + data?: { + organizations?: Array<{ + organizationId?: string; organizationName?: string; + projects?: Array<{ projectId?: string; projectName?: string; projectType?: string | number }>; + }>; + }; + }; + const orgs = (info.data?.organizations ?? []) + .map((o) => ({ ...o, projects: (o.projects ?? []).filter((p) => String(p.projectType ?? "").trim() !== "2") })) + .filter((o) => o.organizationId && o.projects.length > 0); + const org = orgs.find((o) => o.organizationName?.includes("默认机构")) ?? orgs[0]; + const project = org?.projects.find((p) => p.projectName?.includes("默认项目")) ?? org?.projects[0]; + if (!org?.organizationId || !project?.projectId) { + this.noteOnce("pt-project", "project token mint failed: no usable org/project in getCustomerInfo (re-login may help)"); + return null; + } + const ptResp = await this.fetchImpl(`https://bigmodel.cn/api/biz/v1/organization/${encodeURIComponent(org.organizationId)}/projects/${encodeURIComponent(project.projectId)}/api_keys/${encodeURIComponent(apiKeyId)}/access_tokens`, { + method: "POST", + headers: { Authorization: `Bearer ${bare}`, "Content-Type": "application/json" }, + body: JSON.stringify({ clientType: "zcode", clientVersion: "3.15.1" }), + redirect: "manual", + signal: controller.signal, + }); + const pt = await ptResp.json() as { code?: number; data?: { accessToken?: string; enabled?: boolean } }; + const jwt = pt.data?.accessToken; + if (pt.code !== 200 || !jwt) { + this.noteOnce("pt-mint", `project token mint rejected (code=${pt.code ?? ptResp.status})`); + return null; + } + let expMs = this.now() + 60 * 60_000; + try { + const payload = JSON.parse(Buffer.from(jwt.split(".")[1]!.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")) as { exp?: number }; + if (typeof payload.exp === "number") expMs = payload.exp * 1000; + } catch { /* keep fallback expiry */ } + this.noteOnce("pt-ok", "project access token minted — signing with the accessToken (JWT) form"); + return { jwt, expMs }; + } catch { + this.noteOnce("pt-network", "project token mint failed (network/timeout)"); + return null; + } finally { + clearTimeout(timer); + } + } + private async ensurePrivateKey( state: SignerState, - parsedCred: ParsedCredential, + parsedCred: ParsedSigningCredential, origin: string, ): Promise { if (state.privKey) return state.privKey; @@ -428,21 +590,32 @@ export class ClientSigningManager { return handshake; } - private async performHandshake(parsedCred: ParsedCredential, origin: string): Promise { + private async performHandshake(parsedCred: ParsedSigningCredential, origin: string): Promise { const ts = String(Date.now()); const nonce = randomHex(NONCE_BYTES); const sig = await handshakeSignature( - parsedCred.apiKeySecret, + parsedCred.secret, `${HANDSHAKE_METHOD}\n${parsedCred.apiKeyId}\n${ts}\n${nonce}`, ); + // accessToken form (project_access JWT): Bearer auth + accessToken body + // field, live-verified 2026-10-12; apiKey form keeps the bare + // `{id}.{secret}` auth and the full string as the apiKey body value. + const authHeader = parsedCred.handshakeField === "accessToken" + ? `Bearer ${parsedCred.secret}` + : `${parsedCred.apiKeyId}.${parsedCred.secret}`; + const authValue = parsedCred.handshakeField === "accessToken" + ? parsedCred.secret + : `${parsedCred.apiKeyId}.${parsedCred.secret}`; + const bodyKey = parsedCred.handshakeField === "accessToken" ? "accessToken" : "apiKey"; + const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), HANDSHAKE_TIMEOUT_MS); try { const resp = await this.fetchImpl(`${origin}${HANDSHAKE_PATH}`, { method: "POST", - headers: { Authorization: `${parsedCred.apiKeyId}.${parsedCred.apiKeySecret}`, "Content-Type": "application/json" }, - body: JSON.stringify({ apiKey: `${parsedCred.apiKeyId}.${parsedCred.apiKeySecret}`, nonce, sig, ts }), + headers: { Authorization: authHeader, "Content-Type": "application/json" }, + body: JSON.stringify({ [bodyKey]: authValue, nonce, sig, ts }), redirect: "manual", signal: controller.signal, }); @@ -452,7 +625,7 @@ export class ClientSigningManager { if (envelope.code !== 200) throw new Error(`handshake_rejected: ${String(envelope.msg)}`); const cipher = envelope.data?.privateCipher; if (typeof cipher !== "string" || !cipher) throw new Error("handshake_omitted_privateCipher"); - return await decryptSigningPrivateKey(parsedCred.apiKeyId, parsedCred.apiKeySecret, cipher); + return await decryptSigningPrivateKey(parsedCred.apiKeyId, parsedCred.secret, cipher); } finally { clearTimeout(timer); } @@ -470,6 +643,9 @@ export interface SendWithSigningParams { headerPairs: UpstreamHeaderPair[]; credential: string; appVersion: string; + /** Raw provider OAuth token — enables the project-access JWT signing form (bigmodel). */ + oauthAccessToken?: string; + provider?: "zai" | "bigmodel"; send: (headerPairs: UpstreamHeaderPair[]) => Promise; debug?: (message: string) => void; } @@ -483,16 +659,17 @@ export async function sendWithClientSigning( signer: ClientSigningManager | null, params: SendWithSigningParams, ): Promise { - const { url, headerPairs, credential, appVersion, send, debug } = params; + const { url, headerPairs, credential, appVersion, oauthAccessToken, provider, send, debug } = params; if (!signer) return send(headerPairs); - const first = await signer.signWithStatus(url, headerPairs, { credential, appVersion }); + const signingCred = { credential, appVersion, ...(oauthAccessToken ? { oauthAccessToken } : {}), ...(provider ? { provider } : {}) }; + const first = await signer.signWithStatus(url, headerPairs, signingCred); let resp = await send(first.pairs); if (!first.signed || !await signer.isVerifyFailure(resp)) return resp; debug?.("401 VERIFY_SIGNATURE_* — invalidating signing key and retrying once"); signer.invalidate(url, credential); - const second = await signer.signWithStatus(url, headerPairs, { credential, appVersion }); + const second = await signer.signWithStatus(url, headerPairs, signingCred); resp = await send(second.pairs); if (!second.signed || !await signer.isVerifyFailure(resp)) return resp; diff --git a/src/proxy/handler.ts b/src/proxy/handler.ts index 8134840..7ac6000 100644 --- a/src/proxy/handler.ts +++ b/src/proxy/handler.ts @@ -269,6 +269,8 @@ export async function proxyRequest( url: req.url, headerPairs: pairs, credential: credentialString(cred), + oauthAccessToken: cred.oauthAccessToken, + provider: cred.provider, appVersion: config.identity.appVersion, debug: debug ? (message) => debugLine(reqId, message) : undefined, send: (finalPairs) => { diff --git a/src/proxy/responses-handler.ts b/src/proxy/responses-handler.ts index 9253839..7f8559b 100644 --- a/src/proxy/responses-handler.ts +++ b/src/proxy/responses-handler.ts @@ -219,6 +219,8 @@ export async function handleResponses( url: upstreamReq.url, headerPairs: pairs, credential: credentialString(cred), + oauthAccessToken: cred.oauthAccessToken, + provider: cred.provider, appVersion: opts.config.identity.appVersion, debug: debug ? (message) => console.log(`[responses] ${message}`) : undefined, send: (finalPairs) => { From 0cfb0934bf7ceaba615c5fcbfd8947ee17bad844 Mon Sep 17 00:00:00 2001 From: TriDefender <173548745+TriDefender@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:03:26 +0000 Subject: [PATCH 4/5] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=97=A7=E5=8D=A1?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/highspeed/manager.test.ts | 29 +++++++++++++++++++++++++++++ src/highspeed/manager.ts | 8 +++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/highspeed/manager.test.ts b/src/highspeed/manager.test.ts index ac68c27..9d0f068 100644 --- a/src/highspeed/manager.test.ts +++ b/src/highspeed/manager.test.ts @@ -283,6 +283,35 @@ describe("HighspeedManager", () => { expect(fetches).toBeGreaterThanOrEqual(2); }); + it("redraws with the current account's auth after a cross-account card rejection", async () => { + const clock = { now: 1_000_000 }; + const calls: DrawCall[] = []; + const authA: HighspeedAuth = { jwt: "jwt-account-a", codingPlanAuth: "keyA.secret", family: "zai" }; + const authB: HighspeedAuth = { jwt: "jwt-account-b", codingPlanAuth: "keyB.secret", family: "bigmodel" }; + const manager = makeManager((async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ url: String(input), body: JSON.parse(String(init?.body)) as Record, headers: new Headers(init?.headers) }); + return drawResponse(hitCard(clock.now)); + }) as unknown as typeof fetch, clock); + + // account A ignites the loop and holds the card + manager.onRequest("glm-5.3", SESSION_TASK, authA); + await settle(); + expect(manager.activeCard()?.cardId).toBe("hsc_test_1"); + + // credential switch (proxy restart keeps the manager alive): B's requests + // early-return while the card is held — `latest` must still move to B + manager.onRequest("glm-5.3", SESSION_TASK, authB); + await settle(); + expect(calls.length).toBe(1); // held card suppresses drawing + + // upstream rejects B's use of A's card → selectionFallback redraw + manager.dropCard("upstream_rejected"); + expect(await until(() => calls.length >= 2)).toBeTrue(); + expect(calls[1]!.headers.get("authorization")).toBe("Bearer jwt-account-b"); + expect(calls[1]!.headers.get("x-bigmodel-authorization")).toBe("Bearer keyB.secret"); + expect(calls[1]!.body.provider).toBe("builtin:bigmodel-coding-plan"); + }); + it("skips drawing without a JWT and matches only configured model prefixes", () => { let fetches = 0; const manager = makeManager((async () => { diff --git a/src/highspeed/manager.ts b/src/highspeed/manager.ts index 0db62d3..bdee2e1 100644 --- a/src/highspeed/manager.ts +++ b/src/highspeed/manager.ts @@ -142,11 +142,17 @@ export class HighspeedManager { if (!this.isEligibleModel(model)) return; const now = this.now(); this.expireCard(now); + // Refresh latest before the held-card early return below: the process-wide + // manager outlives proxy restarts and credential switches, so a held card + // drawn by the previous account can be rejected (401/3402) by the new + // account's request — and dropCard() re-ignites the loop from `latest`, + // which must then carry the current account's auth or the redraw burns the + // old account's token. + this.latest = { model, taskId: sessionTaskId ?? randomUUID(), auth }; // an unusable card (< CARD_MIN_REMAINING_MS) must not block re-ignition — // requests during those last seconds go out uncarded anyway, and holding // the slot would stall the loop until full expiry if (this.card && this.card.expiresAt - now >= CARD_MIN_REMAINING_MS) return; - this.latest = { model, taskId: sessionTaskId ?? randomUUID(), auth }; this.loopUntil = now + IDLE_MS; this.ensureLoop(); } From 83463729073cd420959a830959f57ad619c9ad8c Mon Sep 17 00:00:00 2001 From: TriDefender <173548745+TriDefender@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:08:38 +0000 Subject: [PATCH 5/5] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E7=9A=84=E7=AB=9F=E6=80=81=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/proxy/client-signing.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/proxy/client-signing.test.ts b/src/proxy/client-signing.test.ts index b792d46..2a7a1f1 100644 --- a/src/proxy/client-signing.test.ts +++ b/src/proxy/client-signing.test.ts @@ -275,7 +275,13 @@ describe("sendWithClientSigning", () => { appVersion: "3.8.1", send: async (pairs) => { sends.push(pairs); - return sends.length === 1 ? verify401() : new Response("ok", { status: 200 }); + if (sends.length > 1) return new Response("ok", { status: 200 }); + // Yield real time before the 401: the retry re-sign must land in a + // later millisecond than the first, or the X-Client-Ts inequality + // below races (Date.now() is ms-granular; all-microtask mocks can + // otherwise complete both signs within the same ms). + await new Promise((r) => setTimeout(r, 2)); + return verify401(); }, }); expect(resp.status).toBe(200);