From c92ee87d74e0f8371bae72a07aacd22b0c36d38e Mon Sep 17 00:00:00 2001 From: GT610 Date: Wed, 21 Oct 2026 21:01:39 +0800 Subject: [PATCH 01/10] =?UTF-8?q?=E6=80=A7=E8=83=BD=EF=BC=9A=E5=87=AD?= =?UTF-8?q?=E6=8D=AE=E8=A7=A3=E6=9E=90=E4=B8=8E=E6=AD=A3=E6=96=87=E8=A3=85?= =?UTF-8?q?=E9=85=8D=E5=B9=B6=E8=A1=8C=EF=BC=8C=E7=BC=A9=E7=9F=AD=E6=AF=8F?= =?UTF-8?q?=E6=AC=A1=E8=AF=B7=E6=B1=82=E7=9A=84=E7=AD=89=E5=BE=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit streamAttempt() 原本严格串行:先等完 resolveApiKey()(账号池选择、凭据读取, 必要时还有探测),再从零开始读图并装配正文。两者其实互不依赖 —— 正文从不携带 密钥,健康账号池的解析也只读内存 —— 所以它们的等待时间一直是相加而非重叠的。 带图请求尤其明显:readImage.prepare() 的图片读取与 JSON 装配全部排在凭据之后。 现在把与密钥无关的通道判断(强制 cli、只有 messages 端点服务的模型、Provider API 默认值)先算成 provisionalProtocol,并让 buildBody() 与 resolveApiKey() 并发; 凭据返回后再调用真正的 resolveProtocol(),目标一致就直接复用已装配的正文,不一致 才重建一次(与既有的协议回退、账号轮换同价)。 线上字节不变: - 首次装配发生在任何账号切换之前,budget.max 仍等于初始值,所以 maxTokens 与原 实现一致;凭据等待期间若别的请求学到更低的上限,会补写进正文并同步收紧 budget.max。 - 图片预算那条 withSurfaceOffload() 仍先于任何 I/O 执行,超额仍在读图和解析凭据 之前失败;只有「已看过的图片」这一 CLI 专属缓存项需要真实通道,因此在拿到密钥 后按真实通道再判一次(只可能抛错,不会改变档位)。 - 凭据解析之后仍保留 throwIfAborted(),取消语义不变。 新增回归用「凭据尚未返回时图片读取已经开始」钉住重叠,不依赖墙钟时间:回退到串行 实现时该用例必然失败。 --- lib/index.js | 36 ++++++++++++------ src/adapter.ts | 88 ++++++++++++++++++++++++++++++++++--------- tests/adapter.test.ts | 44 ++++++++++++++++++++++ 3 files changed, 139 insertions(+), 29 deletions(-) diff --git a/lib/index.js b/lib/index.js index 07f6c5c..518371f 100644 --- a/lib/index.js +++ b/lib/index.js @@ -8675,23 +8675,14 @@ var CommandCodeAdapter = class CommandCodeAdapter extends LlmAdapter { if (attachments === void 0) throw new LlmError("Command Code image input requires the durable attachment service", "UNSUPPORTED_CONTENT"); readImage = requestImages(attachments, options.signal, timing); } - const finishCredentials = timing.phase("credentials"); - let apiKey; - try { - apiKey = await this.deps.resolveApiKey(connection, options.model); - } finally { - finishCredentials(); - } - options.signal?.throwIfAborted(); let maxTokens = Math.min(budget.max, learnedOutputLimit(connection.apiBase, options.model)); + const builtMaxTokens = maxTokens; const supported = KNOWN_EFFORTS[options.model]; const effort = options.purpose === "session-title" && supported?.includes("off") ? "off" : options.reasoningEffort; const declared = effort !== void 0 && supported?.includes(effort) ? effort : void 0; const reasoningEffort = effort === "off" && declared === void 0 ? void 0 : declared; const systemText = [options.system ?? "", ...options.messages.filter((m) => m.role === "system").map((m) => m.content.map(blockText).filter(Boolean).join("\n"))].filter(Boolean).join("\n\n"); - let protocol = this.resolveProtocol(connection, apiKey, options.model, modelEntry); - let messagesPermissionRejection; - let requestOptions = withSurfaceOffload(options, this.surfaceOffload, REQUEST_IMAGE_BUDGETS[0], connection.offloadSeenImagesForCache === true && protocol === "cli" ? options.model : void 0); + let requestOptions = withSurfaceOffload(options, this.surfaceOffload, REQUEST_IMAGE_BUDGETS[0]); const buildBody = async (target) => { const finishBody = timing.phase("body"); try { @@ -8709,7 +8700,28 @@ var CommandCodeAdapter = class CommandCodeAdapter extends LlmAdapter { finishBody(); } }; - let body = await buildBody(protocol); + const provisionalProtocol = connection.protocol === "cli" ? "cli" : this.routesToMessages(options.model, modelEntry) ? "messages" : "openai"; + const finishCredentials = timing.phase("credentials"); + const credentials = this.deps.resolveApiKey(connection, options.model); + credentials.catch(() => void 0); + const provisionalBody = connection.offloadSeenImagesForCache === true ? void 0 : buildBody(provisionalProtocol); + provisionalBody?.catch(() => void 0); + let apiKey; + try { + apiKey = await credentials; + } finally { + finishCredentials(); + } + options.signal?.throwIfAborted(); + maxTokens = Math.min(budget.max, learnedOutputLimit(connection.apiBase, options.model)); + let protocol = this.resolveProtocol(connection, apiKey, options.model, modelEntry); + if (protocol === "cli" && connection.offloadSeenImagesForCache === true) requestOptions = withSurfaceOffload(requestOptions, this.surfaceOffload, REQUEST_IMAGE_BUDGETS[0], options.model); + let messagesPermissionRejection; + let body = provisionalBody !== void 0 && protocol === provisionalProtocol ? await provisionalBody : await buildBody(protocol); + if (maxTokens !== builtMaxTokens) { + budget.max = Math.min(budget.max, maxTokens); + if (!omitOutputTokens(connection.maxOutputTokens, protocol, options.maxTokens)) assignMaxTokens(body, protocol, maxTokens); + } const serializedBodies = /* @__PURE__ */ new WeakMap(); const trace = openStreamTrace(`${options.provider}/${options.model}`); const tried = /* @__PURE__ */ new Set(); diff --git a/src/adapter.ts b/src/adapter.ts index 548b179..a4152b3 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -3216,11 +3216,17 @@ export class CommandCodeAdapter CLI; unknown - // accounts default to the documented Provider Chat Completions surface - // (`resolveProtocol()` routes Claude to Messages on Provider API access). - let protocol: CommandCodeProtocol = this.resolveProtocol(connection, apiKey, options.model, modelEntry) - // 反代 messages 端点对本次账号的权限拒绝(issue #84)。只活在一次 stream() - // 调用里,并在最终错误文案中留痕:它描述的是这一条 Claude 链路,既不能 - // 缓存成整个网关的协议结论,也不该覆盖 CLI 直连自己的失败原因。 - let messagesPermissionRejection: string | undefined // Image budget (issue #37): the body is built from the PROJECTED history, // never the raw one. Rung 0 is the standing budget; a 413 below steps down // the ladder. The session surface owns the offload set, so this throws // `IMAGE_OFFLOAD_REQUIRED` when more images must go; the retry then // arrives with the surface already updated. - let requestOptions = withSurfaceOffload( - options, this.surfaceOffload, REQUEST_IMAGE_BUDGETS[0]!, - connection.offloadSeenImagesForCache === true && protocol === 'cli' ? options.model : undefined, - ) + // + // This first pass is the accounting half only, and runs before ANY I/O: a + // history that must commit an offload first costs neither a credential + // round trip nor a single image read. The CLI-only cache knob below is + // re-applied once the account's transport is actually known. + let requestOptions = withSurfaceOffload(options, this.surfaceOffload, REQUEST_IMAGE_BUDGETS[0]!) const buildBody = async (target: CommandCodeProtocol): Promise> => { const finishBody = timing.phase('body') try { @@ -3290,7 +3290,61 @@ export class CommandCodeAdapter = await buildBody(protocol) + + const provisionalProtocol: CommandCodeProtocol = connection.protocol === 'cli' + ? 'cli' + : this.routesToMessages(options.model, modelEntry) ? 'messages' : 'openai' + const finishCredentials = timing.phase('credentials') + const credentials = this.deps.resolveApiKey(connection, options.model) + // 两条路径都可能在对方完成前失败;先挂处理器,避免其中一条的拒绝变成未处理 + // 拒绝(`await` 仍按原样重抛,错误对象与身份不变)。 + void credentials.catch(() => undefined) + // The opt-in CLI cache mitigation (issue #64) refuses on a fact only the + // resolved key can name — the account's transport — and it must refuse + // BEFORE the first image read, so that one path keeps the strictly + // sequential order it has always had. Every other request is free to + // overlap its body with credential resolution. + const provisionalBody = connection.offloadSeenImagesForCache === true + ? undefined + : buildBody(provisionalProtocol) + void provisionalBody?.catch(() => undefined) + let apiKey: string + try { apiKey = await credentials } finally { finishCredentials() } + options.signal?.throwIfAborted() + // 等待期间别的请求可能刷新过目录或学到更低的上限:重算一次,让正文、指纹 + // 与实发预算描述同一个数字(循环顶部那次收紧仍然兜底)。 + maxTokens = Math.min(budget.max, learnedOutputLimit(connection.apiBase, options.model)) + + // Endpoint protocol: billing/cache may know Go plan -> CLI; unknown + // accounts default to the documented Provider Chat Completions surface + // (`resolveProtocol()` routes Claude to Messages on Provider API access). + let protocol: CommandCodeProtocol = this.resolveProtocol(connection, apiKey, options.model, modelEntry) + // The cache half of the gate, now that the REAL transport is known: the + // first pass above could only judge the byte budget, because the account's + // transport is what decides whether this mitigation applies at all. This + // can only ever THROW — it never changes the chosen rung — so a body + // already assembled for the same target stays valid. + if (protocol === 'cli' && connection.offloadSeenImagesForCache === true) { + requestOptions = withSurfaceOffload( + requestOptions, this.surfaceOffload, REQUEST_IMAGE_BUDGETS[0]!, options.model, + ) + } + // 反推 messages 端点对本次账号的权限拒绝(issue #84)。只活在一次 stream() + // 调用里,并在最终错误文案中留痕:它描述的是这一条 Claude 链路,既不能 + // 缓存成整个网关的协议结论,也不该覆盖 CLI 直连自己的失败原因。 + let messagesPermissionRejection: string | undefined + let body: Record = provisionalBody !== undefined && protocol === provisionalProtocol + ? await provisionalBody + : await buildBody(protocol) + // 凭据等待期间别的请求可能学到更低的上限,而正文是在那之前装配的:把新数字 + // 补写进去,指纹与实发预算才描述同一次连接。被省略的字段继续保持省略 + // (issue #74),预算本身则同步收紧,供本轮的降档与拒绝恢复读取。 + if (maxTokens !== builtMaxTokens) { + budget.max = Math.min(budget.max, maxTokens) + if (!omitOutputTokens(connection.maxOutputTokens, protocol, options.maxTokens)) { + assignMaxTokens(body, protocol, maxTokens) + } + } const serializedBodies = new WeakMap, string>() // The opt-in trace now starts before the first connect attempt so a cache diff --git a/tests/adapter.test.ts b/tests/adapter.test.ts index 59ea0ac..22bfd0f 100644 --- a/tests/adapter.test.ts +++ b/tests/adapter.test.ts @@ -8135,6 +8135,50 @@ test('同次请求并行读取图片不超过四张,重复图片及协议降 assert.equal(calls, 2) }) +test('凭据解析与图片读取并行,不再一次接一次地排队', async () => { + // 两个等待互不依赖(正文从不携带密钥,健康账号池的解析也只读内存),所以 + // 它们必须重叠:先解析凭据再读图,等于把两者的等待时间相加。这条断言用 + // 「凭据尚未返回时图片读取已经开始」把重叠钉死,而不依赖墙钟时间。 + const refs = Array.from({ length: 2 }, (_, i) => ({ ...imageRef(), attachmentId: AttachmentId(`parallel-image-${i}`) })) + const attachments = fakeAttachments(Object.fromEntries(refs.map((ref) => [ref.attachmentId, pngBytes]))) + const original = attachments.readImageRequest.bind(attachments) + let releaseImage!: () => void + const imageGate = new Promise((resolve) => { releaseImage = resolve }) + let reads = 0 + attachments.readImageRequest = async (ref, target) => { + reads++ + await imageGate + return original(ref, target) + } + let releaseKey!: () => void + const keyGate = new Promise((resolve) => { releaseKey = resolve }) + let keyEntered!: () => void + const keyRequested = new Promise((resolve) => { keyEntered = resolve }) + const bodies: Record[] = [] + const adapter = makeAdapter({ + options: () => OPENAI_OPTIONS(), + resolveAttachments: () => attachments, + resolveApiKey: async () => { keyEntered(); await keyGate; return 'user_test_key' }, + fetchImpl: (async (_input: RequestInfo | URL, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body)) as Record) + return new Response(settlementFrames('openai').join('')) + }) as typeof fetch, + }) + let settled = false + const running = collect(adapter.stream({ provider: 'commandcode', model: 'deepseek/deepseek-v4.1-flash', + messages: [{ id: messageId(), role: 'user', source: { kind: 'user' }, + content: refs.map((attachment) => ({ type: 'image' as const, attachment })) }], + })).finally(() => { settled = true }) + await keyRequested + for (let turn = 0; turn < 20 && reads === 0; turn++) await new Promise((resolve) => setImmediate(resolve)) + assert.ok(reads > 0, '凭据仍在等待时图片读取就应已经开始') + assert.equal(settled, false) + releaseKey() + releaseImage() + await running + assert.equal(bodies.length, 1) + assert.equal((bodies[0]?.messages as unknown[]).length, 1) +}) test('目录刷新合并并发等待,单个调用取消不会影响其他调用', async (t) => { const path = join(tmpdir(), `cc-catalog-concurrent-${process.pid}.json`) t.after(() => rmSync(path, { force: true })) From 2413e6b8023db19131b8ca8806ab3d7d5665ae37 Mon Sep 17 00:00:00 2001 From: GT610 Date: Wed, 21 Oct 2026 21:09:49 +0800 Subject: [PATCH 02/10] =?UTF-8?q?=E6=80=A7=E8=83=BD=EF=BC=9A=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E5=99=A8=E5=9C=A8=E7=9B=AE=E5=BD=95=E5=B7=B2=E5=9C=A8?= =?UTF-8?q?=E5=86=85=E5=AD=98=E6=97=B6=E4=B8=8D=E5=86=8D=E7=AD=89=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=E8=81=94=E7=BD=91=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit listModels() 每次打开都无条件 await gatewayFacts.refresh():模型选择器和设置页 的过滤编辑器都只是把目录画出来,却因此要等一次完整的网关往返,才能画出本进程 手里早已有、而且刚刚还准确的同一批行。 现在只要本进程已经加载过该网关的目录,就用内存里的行立即返回,并把刷新放到后台 (不 await、吞掉可能的拒绝),好让下次打开拿到新行。进程首次加载仍然照旧等联网: 没有诚实的行可画,而过滤编辑器确实需要真实列表。 新判据 GatewayFacts.loaded() 刻意与 snapshot() 区分:后者会读磁盘,而磁盘缓存 没有年龄校验,拿它当首屏会让冷启动显示一份不知道多久以前的目录。loaded() 只读 内存 —— 要么刚联网取回,要么本进程刚刚刷新成功 —— 因此可以安全地先画再更新。 resolveModel() 与 stream() 的路径不变(仍 await 真实刷新),目录 TTL 与发布代 序号语义也不变:refresh() 在有效期内本就是内存命中,所以常见路径没有新开销。 新增回归让后台刷新故意晚一步:若本次调用去等它,拿到的会是新行而不是旧行。 把实现改回无条件 await 时该用例失败。 --- lib/index.js | 17 ++++++++++++++++- src/adapter.ts | 22 +++++++++++++++++++++- src/gateway-facts.ts | 13 +++++++++++++ tests/adapter.test.ts | 37 +++++++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 2 deletions(-) diff --git a/lib/index.js b/lib/index.js index 518371f..33baddb 100644 --- a/lib/index.js +++ b/lib/index.js @@ -6419,6 +6419,19 @@ var GatewayFacts = class { const row = this.forSource(source).rows.find((candidate) => candidate.id === model); return row === void 0 ? void 0 : copyRows([row])[0]; } + /** + * 本进程已经加载过的目录;从未加载过时返回 undefined。 + * + * 与 {@link snapshot} 的区别是它绝不读磁盘:调用方要区分「有已知行可以先画」与 + * 「只有一份可能过期的磁盘副本」。磁盘缓存没有年龄校验,用它给选择器当首屏会让 + * 冷启动显示一份不知道多久以前的目录;而内存里的行要么刚联网取回,要么是同一 + * 进程里刚刚成功刷新过的结果,可以安全地先画、再后台更新。 + */ + loaded(source) { + const state = this.forSource(source); + if (state.rows.length === 0 && state.expiresAt === 0) return void 0; + return copyRows(state.rows); + } /** 生成只读取本地目录;捕获状态后不再转向实例的新来源。 */ async snapshot(source) { const state = this.forSource(source); @@ -8163,7 +8176,9 @@ var CommandCodeAdapter = class CommandCodeAdapter extends LlmAdapter { } async listModels(provider, opts) { const connection = { ...this.deps.options() }; - const catalog = await this.gatewayFacts.refresh(connection); + const cached = this.gatewayFacts.loaded(connection); + const catalog = cached ?? await this.gatewayFacts.refresh(connection); + if (cached !== void 0) this.gatewayFacts.refresh(connection).catch(() => void 0); const toInfo = (model) => { const vision = KNOWN_IMAGE_MODELS.has(model.id); return { diff --git a/src/adapter.ts b/src/adapter.ts index a4152b3..d1b4c63 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -2523,7 +2523,27 @@ export class CommandCodeAdapter { const connection = { ...this.deps.options() } - const catalog = await this.gatewayFacts.refresh(connection) + // The picker and the settings page's filter editor are read-only surfaces, + // and the list they draw is only ever a copy of what upstream published. + // Once this process HAS the rows, making the open wait on a network refresh + // is pure latency: the rows were good enough a moment ago and the refresh + // will be there for the next open. So a WARM source draws from memory and + // refreshes in the background; a source this process has never loaded still + // awaits the fetch, because there is nothing honest to draw and the filter + // editor needs the real list. + // + // The distinction is deliberately in-memory only (`loaded()`, not + // `snapshot()`): the on-disk copy carries no age check, so serving it as a + // first paint would show a catalog of unknown vintage. + const cached = this.gatewayFacts.loaded(connection) + const catalog = cached ?? await this.gatewayFacts.refresh(connection) + if (cached !== undefined) { + // `refresh()` returns from cache inside the TTL without any I/O, so the + // common path costs nothing, and an expired entry lands for the next + // open. Never awaited: the caller already has its rows, and `refresh()` + // degrades instead of throwing, so nothing can escape unhandled. + void this.gatewayFacts.refresh(connection).catch(() => undefined) + } const toInfo = (model: (typeof catalog)[number]) => { const vision = KNOWN_IMAGE_MODELS.has(model.id) return { diff --git a/src/gateway-facts.ts b/src/gateway-facts.ts index 8bae85c..405c5eb 100644 --- a/src/gateway-facts.ts +++ b/src/gateway-facts.ts @@ -116,6 +116,19 @@ export class GatewayFacts { const row = this.forSource(source).rows.find(candidate => candidate.id === model) return row === undefined ? undefined : copyRows([row])[0] } + /** + * 本进程已经加载过的目录;从未加载过时返回 undefined。 + * + * 与 {@link snapshot} 的区别是它绝不读磁盘:调用方要区分「有已知行可以先画」与 + * 「只有一份可能过期的磁盘副本」。磁盘缓存没有年龄校验,用它给选择器当首屏会让 + * 冷启动显示一份不知道多久以前的目录;而内存里的行要么刚联网取回,要么是同一 + * 进程里刚刚成功刷新过的结果,可以安全地先画、再后台更新。 + */ + loaded(source: GatewaySource): CommandCodeModel[] | undefined { + const state = this.forSource(source) + if (state.rows.length === 0 && state.expiresAt === 0) return undefined + return copyRows(state.rows) + } /** 生成只读取本地目录;捕获状态后不再转向实例的新来源。 */ async snapshot(source: GatewaySource): Promise { const state = this.forSource(source) diff --git a/tests/adapter.test.ts b/tests/adapter.test.ts index 22bfd0f..341d400 100644 --- a/tests/adapter.test.ts +++ b/tests/adapter.test.ts @@ -5887,6 +5887,43 @@ test('listModels() parses the catalog and marks input as text-only', async () => assert.deepEqual(models[0]!.inputModalities, ['text']) }) +test('listModels() 目录已在内存时不再等一次联网刷新', async (t) => { + // 选择器与设置页的过滤编辑器都只是把目录画出来:本进程手里已经有这些行时, + // 还让这次打开等一次刷新,纯粹是延迟 —— 行刚刚还是好的,刷新留给下次打开。 + // 进程首次加载则必须照旧等联网,否则没有诚实的行可画。 + const originalNow = Date.now + let now = originalNow() + Date.now = () => now + t.after(() => { Date.now = originalNow }) + let refreshes = 0 + let parked = false + const catalog = (id: string) => new Response(JSON.stringify({ + object: 'list', data: [{ id, name: id, context_length: 1000, max_output_tokens: 500 }], + })) + const adapter = makeAdapter({ + fetchImpl: (async (input: RequestInfo | URL) => { + if (!String(input).endsWith('/models')) return catalog('unexpected') + refreshes++ + if (!parked) return catalog(`model-${refreshes}`) + // 后台刷新明显晚一步:若本次调用蠢到去等它,拿到的就是新行而不是旧行, + // 断言会直接失败,而不是靠超时。 + await new Promise((resolve) => setTimeout(resolve, 50)) + return catalog(`model-${refreshes}`) + }) as typeof fetch, + }) + // 冷进程:没有可画的行,必须等这一次联网。 + assert.equal((await adapter.listModels('commandcode', { unfiltered: true }))[0]?.id, 'model-1') + assert.equal(refreshes, 1) + // 目录仍在有效期内:这次调用不产生任何刷新。 + assert.equal((await adapter.listModels('commandcode', { unfiltered: true }))[0]?.id, 'model-1') + assert.equal(refreshes, 1) + // 目录过期,且网关此刻不再回包:本次打开必须拿着旧行立刻返回,刷新挂在后台。 + now += 5 * 60_000 + 1 + parked = true + const listed = await adapter.listModels('commandcode', { unfiltered: true }) + assert.equal(listed[0]?.id, 'model-1', '过期目录也先画出来,不能等后台刷新') + assert.equal(refreshes, 2, '后台仍要发起刷新,好让下次打开拿到新行') +}) test('listModels() falls back to the on-disk cache when the fetch fails', async () => { const cachePath = `/tmp/cc-test-cache-${process.pid}.json` const { writeFileSync, rmSync } = await import('node:fs') From ed3785cb521e080a5e20222a86f59f3a65f512b5 Mon Sep 17 00:00:00 2001 From: GT610 Date: Wed, 21 Oct 2026 21:12:20 +0800 Subject: [PATCH 03/10] =?UTF-8?q?=E6=80=A7=E8=83=BD=EF=BC=9A=E8=B6=85?= =?UTF-8?q?=E6=97=B6=E5=AE=9A=E7=BA=A7=E5=A4=8D=E7=94=A8=E5=B7=B2=E5=BA=8F?= =?UTF-8?q?=E5=88=97=E5=8C=96=E7=9A=84=E6=AD=A3=E6=96=87=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E9=87=8D=E6=96=B0=E9=81=8D=E5=8E=86=E6=95=B4=E6=A3=B5?= =?UTF-8?q?=20body?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gradeHeaderTimeout() 判断「这次超时是不是刚刚那份载荷」时调用 fingerprintValue(body),而它内部又做一次 JSON.stringify —— 正文在发出去之前 已经序列化过一遍了。带图历史可达数 MB,而连续超时恰恰是最常重复的路径,等于每次 重试都为同一个答案多走一遍整棵 body。 现在把实发字符串直接传给定级函数,用 sha256Hex() 对已经序列化好的文本求摘要: 只哈希字节,不再 stringify。这同时更准确 —— 实发字符串就是网关看到的字节, 而重新 stringify 会在 surrogate 清洗真正删掉过字符时描述到不同的字节,把一次连续 超时拆成两条 streak。序列化那份字符串仍由 serializedBodies 持有,正文重建或预算 下调时照旧 delete,所以指纹仍恰好在线上字节改变时改变。 没有实发字符串时(正文从未上过线)保留对象哈希作为兜底。 新增回归用 toJSON 计数器钉住遍历次数:回退到重新 stringify 时该用例失败。 --- lib/index.js | 12 ++++++++---- src/adapter.ts | 26 +++++++++++++++++++++++--- tests/adapter.test.ts | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/lib/index.js b/lib/index.js index 33baddb..93b4053 100644 --- a/lib/index.js +++ b/lib/index.js @@ -6606,9 +6606,13 @@ function fingerprintValue(value) { } return { bytes: Buffer.byteLength(serialized), - sha256: createHash("sha256").update(serialized).digest("hex") + sha256: sha256Hex(serialized) }; } +/** Digest of already-serialized text: no second stringify, no retained copy. */ +function sha256Hex(serialized) { + return createHash("sha256").update(serialized).digest("hex"); +} /** * Structural request facts for issue #64's cache diagnosis. * @@ -8070,10 +8074,10 @@ var CommandCodeAdapter = class CommandCodeAdapter extends LlmAdapter { * @returns the error to surface: unchanged for any other failure, the * original timeout below the threshold, and a terminal one at it. */ - gradeHeaderTimeout(error, protocol, body, timeoutMs, sessionId) { + gradeHeaderTimeout(error, protocol, body, timeoutMs, sessionId, sent) { if (!(error instanceof LlmError && error.code === "TIMEOUT")) return error; const key = sessionId ?? GLOBAL_HEADER_TIMEOUT_KEY; - const fingerprint = `${protocol}:${fingerprintValue(body).sha256}`; + const fingerprint = `${protocol}:${sent === void 0 ? fingerprintValue(body).sha256 : sha256Hex(sent)}`; const previous = this.headerTimeoutStreaks.get(key); const count = previous?.fingerprint === fingerprint ? previous.count + 1 : 1; if (previous === void 0) { @@ -8858,7 +8862,7 @@ var CommandCodeAdapter = class CommandCodeAdapter extends LlmAdapter { }); trace.close(); if (options.signal?.aborted) throw error; - throw failureWithRequestId(this.gradeHeaderTimeout(error, protocol, body, connection.requestTimeoutMs, options.sessionId), responseMetadata); + throw failureWithRequestId(this.gradeHeaderTimeout(error, protocol, body, connection.requestTimeoutMs, options.sessionId, serializedBodies.get(body)), responseMetadata); } if (connected === void 0) throw new LlmError("Command Code API connection failed without a response", "TRANSPORT"); const { response, cleanup } = connected; diff --git a/src/adapter.ts b/src/adapter.ts index d1b4c63..ef327ca 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -222,10 +222,15 @@ function fingerprintValue(value: unknown): ValueFingerprint { } return { bytes: Buffer.byteLength(serialized), - sha256: createHash('sha256').update(serialized).digest('hex'), + sha256: sha256Hex(serialized), } } +/** Digest of already-serialized text: no second stringify, no retained copy. */ +function sha256Hex(serialized: string): string { + return createHash('sha256').update(serialized).digest('hex') +} + /** * Structural request facts for issue #64's cache diagnosis. * @@ -2406,11 +2411,23 @@ export class CommandCodeAdapter, timeoutMs: number, sessionId: string | undefined, + sent?: string, ): unknown { const timedOut = error instanceof LlmError && error.code === 'TIMEOUT' if (!timedOut) return error const key = sessionId ?? GLOBAL_HEADER_TIMEOUT_KEY - const fingerprint = `${protocol}:${fingerprintValue(body).sha256}` + // The streak asks one question — "is this the SAME payload that just timed + // out?" — and the string that went on the wire answers it exactly, byte for + // byte. Re-stringifying `body` here would re-walk the entire request (MBs, + // on a history carrying images) just to render a second opinion on a body + // the caller already serialized, and it would answer about slightly + // DIFFERENT bytes whenever the surrogate sanitizer had removed something. + // `sent` is absent only when no serialized copy exists — a body that never + // reached the wire, or a failure before serialization — and there the + // object hash is the honest fallback. + const fingerprint = `${protocol}:${sent === undefined + ? fingerprintValue(body).sha256 + : sha256Hex(sent)}` const previous = this.headerTimeoutStreaks.get(key) const count = previous?.fingerprint === fingerprint ? previous.count + 1 : 1 if (previous === undefined) { @@ -3575,7 +3592,10 @@ export class CommandCodeAdapter assert.equal(sessionBSecond.code, 'TIMEOUT', 'session-b is unaffected by session-a escalating, and is only on its 2nd timeout') }) +test('超时定级复用已序列化的正文,不再为算指纹重新遍历一遍', async () => { + // 指纹只回答「这是不是刚刚超时的同一份载荷」,而实发字符串就是逐字节准确的 + // 答案。正文已经序列化过一次,超时判定不该为了算第二个意见再走一遍整棵 body + // ——带图历史可达数 MB,而超时恰恰是最常重复的路径。 + // + // `toJSON` 在这里既计数又保持字节不变:同一个值被遍历几次,线缆字节都相同, + // 所以升级行为必须与从前一致;变化的只有遍历次数。 + let passes = 0 + const constant = { toJSON: () => { passes++; return { value: 1 } } } + const adapter = makeAdapter({ + options: () => ({ + apiBase: 'https://api.commandcode.ai', workingDir: '/tmp/project', + modelsCachePath: '/tmp/cc-models-cache.json', + requestTimeoutMs: 30, streamIdleTimeoutMs: DEFAULT_STREAM_IDLE_TIMEOUT_MS, + protocol: 'openai' as const, + }), + fetchImpl: neverSettlingFetch(), + }) + const failure = async () => { + try { + for await (const _ of adapter.stream({ + provider: 'commandcode', model: 'deepseek/deepseek-v4.1-flash', + messages: [userMessage('hi')], + tools: [{ name: 'tool', description: 't', parameters: { type: 'object', properties: { constant } } }], + })) { /* drain */ } + } catch (error) { + const coded = error as { code?: string } + return String(coded.code) + } + throw new Error('expected the generate to fail') + } + assert.equal(await failure(), 'TIMEOUT') + assert.equal(passes, 1, '一次超时只应遍历一次正文') + assert.equal(await failure(), 'TIMEOUT') + assert.equal(await failure(), 'PROVIDER_HTTP_ERROR', '字节相同就必须升级,遍历次数不该改变语义') + assert.equal(passes, 3, '每次调用只序列化一次,超时定级复用那一份') +}) test('a session-less call keeps the old single-slot streak so it still escalates', async () => { // openai protocol, not cli: the CLI body embeds a threadId that falls back // to a fresh randomUUID() per call with no sessionId (cliThreadId()), which From cd8c2409bf66de44334b7415f91879595917b9d1 Mon Sep 17 00:00:00 2001 From: GT610 Date: Wed, 21 Oct 2026 21:14:22 +0800 Subject: [PATCH 04/10] =?UTF-8?q?=E6=80=A7=E8=83=BD=EF=BC=9A=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=20schema=20=E5=BD=92=E4=B8=80=E5=8C=96=E6=8C=89?= =?UTF-8?q?=E5=AF=B9=E8=B1=A1=E8=BA=AB=E4=BB=BD=E8=AE=B0=E5=BF=86=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 每个传输通道在每次尝试时都会重建工具列表,而账号轮换、协议回退、以及降档重试都 会让同一批工具再走一遍。根节点归一化只读参数对象本身,答案不会因为问了第二次而 改变,但从前每次都要重新判一遍:读 type、判 $ref、展开组合子、必要时再克隆。 现在按 parameters 的对象身份用 WeakMap 记住根节点结果。工具对象来自宿主,在整个 回合里是稳定的,所以 identity 就是精确的键:真正不同的 schema 是另一个对象,自然 不命中。只有根节点记忆化;嵌套 $ref / 组合子分支仍走 normalizeToolParameters(), 不在热路径上。 同时纠正一处与实现对不上的注释:原注释声称「每条路径都返回副本」,但已有 type: 'object' 的快路径一直返回调用方自己的对象 —— 这也是既有行为,调用方靠它拿到 同一个已冻结的 schema。注释现在如实说明只改根节点、构造出来的对象都是新的。 新增回归用 params.type 的 getter 计数钉住「只归一化一次」,并复查每次尝试的线缆上 仍带着归一化后的根节点。去掉记忆化时该用例失败。 --- lib/index.js | 44 +++++++++++++++++++++++++++++++++++++------ src/adapter.ts | 44 ++++++++++++++++++++++++++++++++++++++----- tests/adapter.test.ts | 37 ++++++++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 11 deletions(-) diff --git a/lib/index.js b/lib/index.js index 93b4053..f550979 100644 --- a/lib/index.js +++ b/lib/index.js @@ -6728,6 +6728,16 @@ function headersTimeoutError(input) { /** How deep a root `$ref` / combinator chain is followed while normalizing. */ const SCHEMA_NORMALIZE_MAX_DEPTH = 4; /** +* Depth-0 normalization results, keyed by the caller's own `parameters` object. +* +* {@link toolParametersSchema} only ever reads the root, so a schema that has +* already been normalized returns the identical answer however many times it is +* asked. The tool list is rebuilt for EVERY attempt — a rotation or a protocol +* fallback rebuilds it again — and the objects come from the harness, so they +* stay identical for the life of a turn: identity is an exact key. +*/ +const TOOL_SCHEMA_CACHE = /* @__PURE__ */ new WeakMap(); +/** * Whether a type-less node is object-shaped enough to be one with the type * declared: `properties`/`required`/`additionalProperties` only make sense on * an object, and a schema carrying them was meant to be one. @@ -6773,7 +6783,7 @@ function mergeCombinatorBranches(node, depth) { const objectBranches = []; for (const branch of branches) { if (!isRecord(branch)) continue; - objectBranches.push(toolParametersSchema(branch, depth + 1)); + objectBranches.push(normalizeToolParameters(branch, depth + 1)); } if (objectBranches.length === 0) continue; sawBranch = true; @@ -6803,10 +6813,32 @@ function mergeCombinatorBranches(node, depth) { * resolved/merged; anything else (an array/scalar root, or no schema at all) * degrades to a permissive free-form object, because a request the provider * refuses helps no one and the tool's own description is still in the prompt. -* Only the root is touched and every path returns a copy: the harness may -* deep-freeze the caller's schema. -*/ -function toolParametersSchema(parameters, depth = 0) { +* +* Only the ROOT is touched. The object-root fast path deliberately returns the +* caller's own object: an already-normalized schema is the common case, the +* function has always answered with that same identity, and callers rely on it +* to get the same frozen schema they handed in. Everything this function +* CONSTRUCTS is a fresh object, so no caller can mutate another's schema. +* +* The ROOT is memoized per `parameters` object, because every transport rebuilds +* its tool list on every attempt — on a rotating or downshifting request the +* same handful of tool schemas is walked four, five, six times for an answer +* that cannot have changed. The tool objects come from the harness and are +* stable for the life of a turn, so a WeakMap keyed on identity is exact: a +* genuinely different schema is a different object and misses. Nested +* `$ref`/combinator branches are reached through {@link normalizeToolParameters} +* and are not memoized; only the root is asked for on a hot path. +*/ +function toolParametersSchema(parameters) { + if (!isRecord(parameters)) return normalizeToolParameters(parameters, 0); + const cached = TOOL_SCHEMA_CACHE.get(parameters); + if (cached !== void 0) return cached; + const normalized = normalizeToolParameters(parameters, 0); + TOOL_SCHEMA_CACHE.set(parameters, normalized); + return normalized; +} +/** Depth-0 normalization of {@link toolParametersSchema}; see its memo there. */ +function normalizeToolParameters(parameters, depth) { if (!isRecord(parameters)) return { type: "object", properties: {}, @@ -6822,7 +6854,7 @@ function toolParametersSchema(parameters, depth = 0) { const target = resolveLocalRef(parameters, parameters.$ref); if (target !== void 0) { const { $ref: _ref, ...rest } = parameters; - return toolParametersSchema({ + return normalizeToolParameters({ ...target, ...rest }, depth + 1); diff --git a/src/adapter.ts b/src/adapter.ts index ef327ca..bda7cb1 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -388,6 +388,17 @@ function headersTimeoutError(input: { /** How deep a root `$ref` / combinator chain is followed while normalizing. */ const SCHEMA_NORMALIZE_MAX_DEPTH = 4 +/** + * Depth-0 normalization results, keyed by the caller's own `parameters` object. + * + * {@link toolParametersSchema} only ever reads the root, so a schema that has + * already been normalized returns the identical answer however many times it is + * asked. The tool list is rebuilt for EVERY attempt — a rotation or a protocol + * fallback rebuilds it again — and the objects come from the harness, so they + * stay identical for the life of a turn: identity is an exact key. + */ +const TOOL_SCHEMA_CACHE = new WeakMap>() + /** * Whether a type-less node is object-shaped enough to be one with the type * declared: `properties`/`required`/`additionalProperties` only make sense on @@ -444,7 +455,7 @@ function mergeCombinatorBranches( const objectBranches: Record[] = [] for (const branch of branches) { if (!isRecord(branch)) continue - objectBranches.push(toolParametersSchema(branch, depth + 1)) + objectBranches.push(normalizeToolParameters(branch, depth + 1)) } if (objectBranches.length === 0) continue sawBranch = true @@ -485,10 +496,33 @@ function mergeCombinatorBranches( * resolved/merged; anything else (an array/scalar root, or no schema at all) * degrades to a permissive free-form object, because a request the provider * refuses helps no one and the tool's own description is still in the prompt. - * Only the root is touched and every path returns a copy: the harness may - * deep-freeze the caller's schema. + * + * Only the ROOT is touched. The object-root fast path deliberately returns the + * caller's own object: an already-normalized schema is the common case, the + * function has always answered with that same identity, and callers rely on it + * to get the same frozen schema they handed in. Everything this function + * CONSTRUCTS is a fresh object, so no caller can mutate another's schema. + * + * The ROOT is memoized per `parameters` object, because every transport rebuilds + * its tool list on every attempt — on a rotating or downshifting request the + * same handful of tool schemas is walked four, five, six times for an answer + * that cannot have changed. The tool objects come from the harness and are + * stable for the life of a turn, so a WeakMap keyed on identity is exact: a + * genuinely different schema is a different object and misses. Nested + * `$ref`/combinator branches are reached through {@link normalizeToolParameters} + * and are not memoized; only the root is asked for on a hot path. */ -function toolParametersSchema(parameters: unknown, depth = 0): Record { +function toolParametersSchema(parameters: unknown): Record { + if (!isRecord(parameters)) return normalizeToolParameters(parameters, 0) + const cached = TOOL_SCHEMA_CACHE.get(parameters) + if (cached !== undefined) return cached + const normalized = normalizeToolParameters(parameters, 0) + TOOL_SCHEMA_CACHE.set(parameters, normalized) + return normalized +} + +/** Depth-0 normalization of {@link toolParametersSchema}; see its memo there. */ +function normalizeToolParameters(parameters: unknown, depth: number): Record { if (!isRecord(parameters)) return { type: 'object', properties: {}, additionalProperties: true } if (parameters.type === 'object') return parameters // `["object", "null"]` is accepted by JSON Schema but not by the provider's @@ -502,7 +536,7 @@ function toolParametersSchema(parameters: unknown, depth = 0): Record { + // 每次尝试都会重建工具列表,而账号轮换或协议回退会把同一批工具再走一遍; + // 根节点归一化的答案不会因为问了第二次而改变,所以按 parameters 的对象身份 + // 记忆化 —— 来自宿主的工具对象在整个回合里是稳定的,identity 就是精确的键。 + // + // 计数放在 `type` 的 getter 上:归一化必须读它才能判断根节点类型, + // 而取值的次数正是「归一化走了几遍」的可观测量。 + let reads = 0 + const parameters = { + get type(): undefined { reads++; return undefined }, + properties: { value: { type: 'string' } }, + } + let calls = 0 + const bodies: Record[] = [] + const adapter = makeAdapter({ + rotateApiKey: async () => 'second-key', + fetchImpl: (async (_input: RequestInfo | URL, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body)) as Record) + return ++calls === 1 ? new Response('rate limited', { status: 429 }) : new Response(FINISH_STREAM) + }) as typeof fetch, + }) + const tools = [{ name: 'tool', description: 'test', parameters }] + await collect(adapter.stream({ provider: 'commandcode', model: 'm', messages: [userMessage('hi')], tools })) + const afterFirst = reads + assert.ok(afterFirst > 0, '首次调用必须真的归一化') + // 第二次调用重建了工具列表、也经历了一次账号轮换(另一份正文),但同一份 + // schema 已经归一化过,不该再被读一遍。 + await collect(adapter.stream({ provider: 'commandcode', model: 'm', messages: [userMessage('hi')], tools })) + assert.equal(calls, 3) + assert.equal(reads, afterFirst, '同一份 schema 的归一化结果必须复用') + // 复用不等于跳过:每一次尝试的线缆上都仍是归一化后的根节点。 + for (const body of bodies) { + // CLI 把工具放在 `params` 里,Provider API 放在顶层。 + const params = (body.params ?? body) as { tools?: Array<{ input_schema: { type: string } }> } + assert.equal(params.tools?.[0]?.input_schema.type, 'object', '归一化结果必须仍然出现在线缆上') + } +}) test('the Messages body marks the last tool, not an earlier one', async () => { const { adapter, sent } = messagesAdapter(() => messagesStream('hi')) await collect(adapter.stream({ From f28fcfbb42065dc0c3b1b9ff77c8fa974ea2f456 Mon Sep 17 00:00:00 2001 From: GT610 Date: Wed, 21 Oct 2026 21:19:51 +0800 Subject: [PATCH 05/10] =?UTF-8?q?=E6=80=A7=E8=83=BD=EF=BC=9A=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=E4=BA=8B=E5=AE=9E=E5=9C=A8=E7=94=A8=E9=87=8F=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E4=B8=8E=E5=A5=97=E9=A4=90=E8=BF=87=E6=BB=A4=E4=B9=8B?= =?UTF-8?q?=E9=97=B4=E5=85=B1=E4=BA=AB=EF=BC=8C=E7=94=A8=E9=87=8F=E6=B1=87?= =?UTF-8?q?=E6=80=BB=E4=BB=8D=E9=80=90=E6=AC=A1=E9=87=8D=E8=AF=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设置卡片的账户区与选择器的套餐过滤读的是同一批端点、同一个账号:whoami 一次, 再并行 credits + subscriptions 一次。两处各自独立读取,等于同一份答案付两遍钱; 而卡片还在定时刷新,于是选择器会反复为它早就拿到过的行再付一次往返。 现在把成功的账户端点响应按「网关 + 密钥 + 完整 URL」记进一张短命表 (ACCOUNT_FACTS_TTL_MS,30 秒),两条路径共用。选择器只缓存自己那套 BILLING_ACCESS_TTL_MS,所以卡片的即时结果不会绕过它自己的 5 分钟语义。 刻意只缓存成功响应:失败必须重试,否则刚刚恢复的端点会继续报告昨天的故障,报告的 failures 与 classifyTotalFailure 结论还会描述一次本进程没真正发出的请求。 usage/summary 明确不参与共享(share=false):那是用户正盯着的数字,卡片旁边写着 读取时间,回放一次早先的响应会让数字与时间同时对不上。身份与套餐事实相反 —— 30 秒 前的 org id 或套餐档位仍然是事实,而它们才正是选择器重复读取的部分。 把密钥校验(usableAccountKey)与请求头构造(accountHeadersFor)拆开,使两条路径都 能只解析一次密钥就用同一个身份去查表。 新增回归钉住三点:选择器复用卡片的 whoami/subscriptions;用量汇总每次都真的重读; 把共享关掉时该用例失败。 --- lib/index.d.ts | 52 +++++++++++++++++ lib/index.js | 106 ++++++++++++++++++++++++++++++---- src/adapter.ts | 129 ++++++++++++++++++++++++++++++++++++------ tests/adapter.test.ts | 45 +++++++++++++++ 4 files changed, 304 insertions(+), 28 deletions(-) diff --git a/lib/index.d.ts b/lib/index.d.ts index 37773e7..b64b83e 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -382,6 +382,25 @@ declare class CommandCodeAdapter; resolveModel(provider: string, model: string, signal?: AbortSignal): Promise; + /** + * Resolve and validate the key one account-endpoint call will use. + * + * Every account endpoint runs the chat path's own `assertUsableApiKey` check, + * or a key carrying a character no HTTP header can hold throws inside `fetch` + * BEFORE any I/O — once per endpoint — and the report reads as "the network is + * down". The credential reference is read structurally: it belongs to the + * host's resolved options, not to this adapter's connection type, and it only + * names the key in the rejection message. + * + * Separate from {@link accountHeadersFor} so a caller that also needs to key a + * cache by the same identity does not have to resolve the key twice. + */ + private usableAccountKey; /** The headers every authenticated account endpoint shares. */ private accountHeaders; + /** Headers for an already-validated key; see {@link usableAccountKey}. */ + private accountHeadersFor; /** * Fetch one JSON GET and return its HTTP status with a parsed record. The * catalog, billing probe, and usage report each apply their own failure @@ -524,6 +559,23 @@ declare class CommandCodeAdapter (await this.fetchJson(`${apiBase}${path}`, headers)).record; + const key = await this.usableAccountKey(apiKey); + const getJson = async (path) => (await this.accountJsonGet(apiBase, key, path, MODELS_TIMEOUT_MS)).record; const whoami = await getJson("/alpha/whoami"); const orgData = whoami && isRecord(whoami.org) ? whoami.org : void 0; const orgId = orgData === void 0 ? void 0 : stringValue(orgData.id); @@ -8523,9 +8609,9 @@ var CommandCodeAdapter = class CommandCodeAdapter extends LlmAdapter { async getUsage(apiKey, capturedConnection) { const connection = capturedConnection ?? { ...this.deps.options() }; const base = connection.apiBase; - let headers; + let key; try { - headers = await this.accountHeaders(apiKey); + key = await this.usableAccountKey(apiKey); } catch (error) { if (error instanceof LlmError && error.code === "INVALID_CREDENTIAL") return { failures: [error.message], @@ -8535,9 +8621,9 @@ var CommandCodeAdapter = class CommandCodeAdapter extends LlmAdapter { } const failures = []; const failed = []; - const getJson = async (path) => { + const getJson = async (path, share = true) => { try { - const { status, record, responseFailure } = await this.fetchJson(`${base}${path}`, headers, connection.requestTimeoutMs); + const { status, record, responseFailure } = await this.accountJsonGet(base, key, path, connection.requestTimeoutMs, share); if (record === void 0) { failures.push(`${path}: HTTP ${status}${responseFailure ? `;${responseFailure}` : ""}`); failed.push(responseFailure ? { @@ -8571,7 +8657,7 @@ var CommandCodeAdapter = class CommandCodeAdapter extends LlmAdapter { const rawStart = subData?.currentPeriodStart; const periodStart = periodTimestampValue(rawStart); const validStart = periodStart > 0 && Number.isFinite(new Date(periodStart).getTime()); - const totals = parseUsageTotals(await getJson(scopedPath("/alpha/usage/summary", validStart ? String(rawStart) : void 0))); + const totals = parseUsageTotals(await getJson(scopedPath("/alpha/usage/summary", validStart ? String(rawStart) : void 0), false)); if (totals !== void 0) report.usage = totals; const limits = parseCreditLimits(credits); if (limits !== void 0) report.credits = limits; diff --git a/src/adapter.ts b/src/adapter.ts index bda7cb1..4f427c9 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -114,6 +114,17 @@ export const DEFAULT_GENERATE_MAX_TOKENS = MAX_OUTPUT_TOKEN_LIMIT const MODELS_TIMEOUT_MS = 10_000 /** How long the picker's plan-filter billing facts stay cached before refetching. */ export const BILLING_ACCESS_TTL_MS = 5 * 60_000 +/** + * How long the raw account facts captured by one usage-report run stay usable + * for the picker's advisory plan filter. + * + * This is much shorter than {@link BILLING_ACCESS_TTL_MS} because it only ever + * removes a DUPLICATE read: the picker and the settings card ask the same three + * endpoints about the same key, and whichever ran last already paid for them. + * Anything older than this window is simply fetched again — the filter is + * fail-open advisory data, so a miss costs one round trip and nothing else. + */ +const ACCOUNT_FACTS_TTL_MS = 30_000 /** * How long a learned CLI-only protocol preference stays cached per account. * After this TTL a request may probe Provider API again, so an upgraded Go @@ -2393,6 +2404,25 @@ export class CommandCodeAdapter() private readonly billingAccessInflight = new Map>() + /** + * Successful account-endpoint responses, keyed by gateway + key + exact URL. + * + * The settings card and the picker's advisory plan filter ask the SAME three + * endpoints about the SAME account (`/alpha/whoami`, then credits and + * subscriptions in parallel). Here that is four round trips per surface for + * two answers — and the card refreshes on a timer, so the picker keeps paying + * for rows it already has. Sharing the raw responses removes the duplicate + * without either surface having to know about the other: whoever asks second + * within {@link ACCOUNT_FACTS_TTL_MS} reads the same record instead of the + * wire. + * + * Only SUCCESSFUL records are kept. A failure must be retried rather than + * replayed, or an endpoint that recovered a second ago would keep reporting + * yesterday's outage — and the usage report's per-endpoint `failures` and + * {@link classifyTotalFailure} verdict would describe a request this process + * never made. + */ + private readonly accountJson = new Map; at: number }>() // Protocol preference is per gateway and account: the Go plan is the only plan without // Provider API access, and that fact is independent of model. A negative // result (provider API rejected this key with upgrade_required) is cached so @@ -2730,19 +2760,34 @@ export class CommandCodeAdapter> { + /** + * Resolve and validate the key one account-endpoint call will use. + * + * Every account endpoint runs the chat path's own `assertUsableApiKey` check, + * or a key carrying a character no HTTP header can hold throws inside `fetch` + * BEFORE any I/O — once per endpoint — and the report reads as "the network is + * down". The credential reference is read structurally: it belongs to the + * host's resolved options, not to this adapter's connection type, and it only + * names the key in the rejection message. + * + * Separate from {@link accountHeadersFor} so a caller that also needs to key a + * cache by the same identity does not have to resolve the key twice. + */ + private async usableAccountKey(apiKey?: string): Promise { const connection = this.deps.options() const raw = apiKey ?? (await this.deps.resolveApiKey(connection)) - // Every account endpoint runs the chat path's own `assertUsableApiKey` - // check, or a key carrying a character no HTTP header can hold throws - // inside `fetch` BEFORE any I/O — once per endpoint — and the report reads - // as "the network is down". The credential reference is read structurally: - // it belongs to the host's resolved options, not to this adapter's - // connection type, and it only names the key in the rejection message. const named = (connection as { apiKeyEnv?: unknown }).apiKeyEnv const ref = typeof named === 'string' && named !== '' ? named : 'the stored credential' - const key = assertUsableApiKey(raw, 'llm-commandcode', ref) + return assertUsableApiKey(raw, 'llm-commandcode', ref) + } + + /** The headers every authenticated account endpoint shares. */ + private async accountHeaders(apiKey?: string): Promise> { + return this.accountHeadersFor(await this.usableAccountKey(apiKey)) + } + + /** Headers for an already-validated key; see {@link usableAccountKey}. */ + private accountHeadersFor(key: string): Record { return { Authorization: `Bearer ${key}`, ...IDENTITY_ENCODING_HEADER, @@ -2871,6 +2916,43 @@ export class CommandCodeAdapter; responseFailure?: string }> { + const cacheKey = JSON.stringify([apiBase, apiKey, path]) + if (share) { + const hit = this.accountJson.get(cacheKey) + if (hit !== undefined && Date.now() - hit.at < ACCOUNT_FACTS_TTL_MS) { + return { status: 200, record: hit.record } + } + } + const answer = await this.fetchJson(`${apiBase}${path}`, this.accountHeadersFor(apiKey), timeoutMs) + if (share && answer.record !== undefined) { + this.accountJson.set(cacheKey, { record: answer.record, at: Date.now() }) + } + return answer + } + /** * The billing facts behind the picker's plan filter, mirroring the CLI's * `createBilling` flow: whoami yields the org id, then the subscriptions @@ -2882,10 +2964,13 @@ export class CommandCodeAdapter { try { - const headers = await this.accountHeaders(apiKey) + // Validate once, then address the shared per-account records by the key + // that will actually travel: a usage-report run seconds ago may already + // have read all three endpoints about this same account. + const key = await this.usableAccountKey(apiKey) // Billing probe fails open silently: any non-record is "unknown". const getJson = async (path: string): Promise | undefined> => - (await this.fetchJson(`${apiBase}${path}`, headers)).record + (await this.accountJsonGet(apiBase, key, path, MODELS_TIMEOUT_MS)).record const whoami = await getJson('/alpha/whoami') const orgData = whoami && isRecord(whoami.org) ? whoami.org : undefined const orgId = orgData === undefined ? undefined : stringValue(orgData.id) @@ -3005,9 +3090,9 @@ export class CommandCodeAdapter { const connection = capturedConnection ?? { ...this.deps.options() } const base = connection.apiBase - let headers: Record + let key: string try { - headers = await this.accountHeaders(apiKey) + key = await this.usableAccountKey(apiKey) } catch (error: unknown) { // An unusable key is a CREDENTIAL verdict, not a transport one. A // missing key still propagates (the caller renders the unconfigured @@ -3023,12 +3108,18 @@ export class CommandCodeAdapter | undefined> => { + const getJson = async (path: string, share = true): Promise | undefined> => { try { - const { status, record, responseFailure } = await this.fetchJson( - `${base}${path}`, - headers, + // Identity and billing facts are shared with the picker's plan filter: + // whichever surface asked for this exact URL most recently already paid + // for it, and a 30 s old org id or plan tier is still the truth. The + // CARD's own budget is still the one a real fetch runs under. + const { status, record, responseFailure } = await this.accountJsonGet( + base, + key, + path, connection.requestTimeoutMs, + share, ) if (record === undefined) { failures.push(`${path}: HTTP ${status}${responseFailure ? `;${responseFailure}` : ''}`) @@ -3067,7 +3158,9 @@ export class CommandCodeAdapter 0 && Number.isFinite(new Date(periodStart).getTime()) // 保留上游时间的字符串/毫秒表示,仅编码查询值,不重算日期或时区。 const since = validStart ? String(rawStart) : undefined - const usage = await getJson(scopedPath('/alpha/usage/summary', since)) + // Not shared: these are the numbers on the card, and the timestamp beside + // them must describe when they were actually read. + const usage = await getJson(scopedPath('/alpha/usage/summary', since), false) // usage/summary -> totals. const totals = parseUsageTotals(usage) diff --git a/tests/adapter.test.ts b/tests/adapter.test.ts index 9a08d87..16033ce 100644 --- a/tests/adapter.test.ts +++ b/tests/adapter.test.ts @@ -7516,6 +7516,51 @@ test('getUsage() 固定组织范围并等订阅账期,再读取官方汇总', assert.equal(report.usage?.totalCount, 7) }) +test('用量卡片与选择器共享同一份账户事实,但用量汇总必须每次重读', async () => { + // 设置卡片的账户区与选择器的套餐过滤读的是同一批端点、同一个账号:whoami 一次, + // 再并行 credits + subscriptions 一次。各自独立读等于同一份答案付两遍钱,而卡片 + // 还在定时刷新,于是选择器反复为它早就有的行付出往返。 + // + // 但 usage/summary 不同:那是用户正盯着的数字,卡片旁边还写着读取时间,绝不能 + // 拿一次早先的响应回放。 + const calls: Array<{ path: string; since: string | null }> = [] + let usageReads = 0 + const adapter = makeAdapter({ fetchImpl: (async (input) => { + const url = new URL(String(input)) + calls.push({ path: url.pathname, since: url.searchParams.get('since') }) + if (url.pathname === '/alpha/whoami') return Response.json({ user: { id: 'u1' }, org: { id: 'org-1' } }) + if (url.pathname === '/alpha/billing/subscriptions') { + return Response.json({ data: { planId: 'individual-go', status: 'active', currentPeriodStart: 1000 } }) + } + if (url.pathname === '/alpha/billing/credits') { + return Response.json({ credits: { monthlyCredits: 5, purchasedCredits: 0, freeCredits: 0 } }) + } + assert.equal(url.pathname, '/alpha/usage/summary') + // 每次都得是一次真实读取:数字变化必须能被看见。 + return Response.json({ totalCount: ++usageReads }) + }) as typeof fetch }) + const first = await adapter.getUsage('user_test_key') + assert.equal(first.usage?.totalCount, 1) + assert.equal(first.plan?.name, 'Go') + const afterCard = calls.length + assert.equal(afterCard, 4) + assert.deepEqual(calls.map((call) => call.path).sort(), [ + '/alpha/billing/credits', '/alpha/billing/subscriptions', '/alpha/usage/summary', '/alpha/whoami', + ]) + + // 选择器的套餐过滤:卡片刚读过的身份与订阅事实直接复用。 + await adapter.listModels('commandcode') + const added = calls.slice(afterCard).map((call) => call.path) + assert.deepEqual(added, ['/provider/v1/models', '/alpha/billing/credits'], + 'whoami 与 subscriptions 必须复用卡片那一份;credits 的 URL 带不带 orgId 取决于调用方,不是同一份记录') + + // 卡片再刷新:身份与套餐事实可以复用,用量汇总必须重新发出。 + const second = await adapter.getUsage('user_test_key') + assert.equal(second.usage?.totalCount, 2, '用量汇总不能回放上一次的响应') + const usageCalls = calls.filter((call) => call.path === '/alpha/usage/summary') + assert.equal(usageCalls.length, 2) +}) + test('getUsage() 个人账号使用毫秒账期,不附带组织参数或重算自然月', async () => { const start = Date.parse('2026-09-19T10:15:00Z') const adapter = makeAdapter({ fetchImpl: (async input => { From 9aa836a22829b578a9b22c90d15189d3c390f7e9 Mon Sep 17 00:00:00 2001 From: GT610 Date: Wed, 21 Oct 2026 21:23:43 +0800 Subject: [PATCH 06/10] =?UTF-8?q?=E6=80=A7=E8=83=BD=EF=BC=9A=E6=B2=A1?= =?UTF-8?q?=E4=BA=BA=E7=82=B9=E7=9A=84=E5=88=B7=E6=96=B0=E5=9C=A8=E7=9F=AD?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E5=86=85=E5=90=88=E5=B9=B6=EF=BC=8C=E6=98=BE?= =?UTF-8?q?=E5=BC=8F=E5=88=B7=E6=96=B0=E6=B0=B8=E8=BF=9C=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E8=AF=BB=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一份用量报告是四次上游调用,而多个界面会在无人操作的情况下各自发起读取:设置页 账户卡片与面板都在首屏取一次,重挂载或重渲染还会让同一批 effect 再跑一遍。这是 同一账号、同一时刻的同一份答案,付两遍钱买不到任何东西。 现在 refresh() 接受 `auto` 标记,只有自动路径(首屏、后台 tick)会在 USAGE_AUTO_REFRESH_COALESCE_MS(5 秒)内复用刚刚settled 的结果,面板与卡片都改走 这条路径。窗口刻意很小:它只用来折叠一次重复,不是用来展示旧数据;过期之后照旧 重新读取,fetchedAt 因此仍然如实反映这些数字被读取的时刻。 用户按下刷新、保存配置、更换/清除凭据等显式路径一律不传 auto,永远去读 —— 用户 要的就是新数字。 新增回归钉住「自动合并、显式重读、窗口过后重读」三点,以及面板首屏与 tick 都确实 带上 auto 标记;去掉合并逻辑时该用例失败。 --- lib/client.js | 32 ++++++++++++++++++++++---------- src/client/panel.ts | 11 ++++++++--- src/client/usage.ts | 38 ++++++++++++++++++++++++++++++++++++-- tests/panel.test.ts | 20 ++++++++++++++++++++ tests/usage-client.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 5 files changed, 122 insertions(+), 15 deletions(-) diff --git a/lib/client.js b/lib/client.js index 74578ce..6fbe06d 100644 --- a/lib/client.js +++ b/lib/client.js @@ -1856,6 +1856,8 @@ window.__ModuleLoader__.load({ generation = 0; inFlight = false; disposed = false; + /** When the last fetch SETTLED successfully, for {@link USAGE_AUTO_REFRESH_COALESCE_MS}. */ + settledAt; constructor(remote) { this.remote = remote; } @@ -1877,9 +1879,16 @@ window.__ModuleLoader__.load({ /** * Fetch (or refetch) the report. Concurrent refreshes collapse onto one * request; a superseded fetch's late result is dropped, never published. + * + * `auto` marks a refresh nobody asked for — a surface appearing, or the + * background tick. Those also reuse a result fetched within + * {@link USAGE_AUTO_REFRESH_COALESCE_MS}, because two surfaces mounting a + * second apart are asking the same question. Every other caller is a user + * action and always goes to the wire. */ - async refresh() { + async refresh(options) { if (this.disposed || this.inFlight) return; + if (options?.auto === true && this.current.status === "ready" && this.settledAt !== void 0 && Date.now() - this.settledAt < 5e3) return; const generation = ++this.generation; this.inFlight = true; this.current = { @@ -1891,13 +1900,16 @@ window.__ModuleLoader__.load({ try { const response = await this.remote.report(); if (this.disposed || generation !== this.generation) return; - if (response.ok) this.current = { - status: "ready", - report: response.value, - error: void 0, - fetchedAt: Date.now() - }; - else this.current = { + if (response.ok) { + const fetchedAt = Date.now(); + this.settledAt = fetchedAt; + this.current = { + status: "ready", + report: response.value, + error: void 0, + fetchedAt + }; + } else this.current = { ...this.current, status: "error", error: response.error.message @@ -7925,13 +7937,13 @@ window.__ModuleLoader__.load({ const ticket = ++ticketSeq; const tick = () => { if (activeTicket !== ticket) return; - if (isConfigured()) usage.refresh(); + if (isConfigured()) usage.refresh({ auto: true }); handle = timer.set(tick, PANEL_AUTO_REFRESH_MS); }; if (activeTicket === void 0) { activeTicket = ticket; handle = timer.set(tick, PANEL_AUTO_REFRESH_MS); - if (isConfigured()) usage.refresh(); + if (isConfigured()) usage.refresh({ auto: true }); } let stopped = false; return () => { diff --git a/src/client/panel.ts b/src/client/panel.ts index 61855c2..191a155 100644 --- a/src/client/panel.ts +++ b/src/client/panel.ts @@ -521,7 +521,8 @@ export const PANEL_AUTO_REFRESH_MS = 120_000 /** The narrow face of `CommandCodeUsageController` this module drives. */ export interface RefreshSource { state(): UsagePageState - refresh(): Promise + /** `auto` marks a refresh nobody asked for; see `CommandCodeUsageController.refresh`. */ + refresh(options?: { auto?: boolean }): Promise } /** Timer seam: injectable so tests never wait on real time. */ @@ -568,7 +569,9 @@ export function startPanelAutoRefresh( const ticket = ++ticketSeq const tick = (): void => { if (activeTicket !== ticket) return - if (isConfigured()) void usage.refresh() + // `auto`: nobody clicked anything, so a result fetched a moment ago (by the + // settings page's card, or by the tick that just ran) is the same answer. + if (isConfigured()) void usage.refresh({ auto: true }) handle = timer.set(tick, PANEL_AUTO_REFRESH_MS) } @@ -576,7 +579,9 @@ export function startPanelAutoRefresh( activeTicket = ticket handle = timer.set(tick, PANEL_AUTO_REFRESH_MS) // First paint: refresh immediately so the row is never blank on arrival. - if (isConfigured()) void usage.refresh() + // `auto` for the same reason: the account card is very likely mounting in + // the same tick and asking the identical question. + if (isConfigured()) void usage.refresh({ auto: true }) } let stopped = false diff --git a/src/client/usage.ts b/src/client/usage.ts index c18294f..2f37d02 100644 --- a/src/client/usage.ts +++ b/src/client/usage.ts @@ -107,6 +107,24 @@ export interface UsagePageState { const IDLE: UsagePageState = { status: 'idle', report: undefined, error: undefined, fetchedAt: undefined } +/** + * How long an AUTO refresh may reuse the result of one already in flight or + * just finished. + * + * One report costs four upstream calls, and several surfaces ask for it without + * the user asking for anything: the settings page's account card and the panel + * both fetch on first paint, and a remount or a re-render can run those effects + * again seconds apart. That is the same report about the same account — paying + * for it twice buys nothing. + * + * The window is deliberately tiny. It exists to collapse a duplicate, not to + * serve stale data: anything older than this is fetched again, and `fetchedAt` + * therefore still names the moment these numbers were actually read. An + * EXPLICIT refresh (the refresh button, a save, a credential change) never + * consults it at all — the user asked, so the answer must be new. + */ +export const USAGE_AUTO_REFRESH_COALESCE_MS = 5_000 + /** * Controller bridging the `commandcode/report` Remote onto the card. Public * API mirrors {@link CommandCodeSettingsController}: `state()` projections, @@ -119,6 +137,8 @@ export class CommandCodeUsageController { private generation = 0 private inFlight = false private disposed = false + /** When the last fetch SETTLED successfully, for {@link USAGE_AUTO_REFRESH_COALESCE_MS}. */ + private settledAt: number | undefined constructor(remote: UsageRemote) { this.remote = remote @@ -145,9 +165,21 @@ export class CommandCodeUsageController { /** * Fetch (or refetch) the report. Concurrent refreshes collapse onto one * request; a superseded fetch's late result is dropped, never published. + * + * `auto` marks a refresh nobody asked for — a surface appearing, or the + * background tick. Those also reuse a result fetched within + * {@link USAGE_AUTO_REFRESH_COALESCE_MS}, because two surfaces mounting a + * second apart are asking the same question. Every other caller is a user + * action and always goes to the wire. */ - async refresh(): Promise { + async refresh(options?: { auto?: boolean }): Promise { if (this.disposed || this.inFlight) return + if ( + options?.auto === true + && this.current.status === 'ready' + && this.settledAt !== undefined + && Date.now() - this.settledAt < USAGE_AUTO_REFRESH_COALESCE_MS + ) return const generation = ++this.generation this.inFlight = true this.current = { ...this.current, status: 'loading', error: undefined } @@ -156,7 +188,9 @@ export class CommandCodeUsageController { const response = await this.remote.report() if (this.disposed || generation !== this.generation) return if (response.ok) { - this.current = { status: 'ready', report: response.value, error: undefined, fetchedAt: Date.now() } + const fetchedAt = Date.now() + this.settledAt = fetchedAt + this.current = { status: 'ready', report: response.value, error: undefined, fetchedAt } } else { this.current = { ...this.current, status: 'error', error: response.error.message } } diff --git a/tests/panel.test.ts b/tests/panel.test.ts index fa97bc6..7ab1846 100644 --- a/tests/panel.test.ts +++ b/tests/panel.test.ts @@ -616,6 +616,26 @@ test('two mounted surfaces share one loop and one fetch', () => { resetPanelAutoRefresh() }) +test('面板的自动刷新走 auto 路径,显式刷新不受影响', () => { + // 面板首屏与后台 tick 都不是用户点的:它们和账户卡片问的是同一个问题, + // 所以必须走可以合并的自动路径。按钮/保存那条路径由 startPanelAutoRefresh + // 之外调用 refresh(),不接受任何合并。 + resetPanelAutoRefresh() + const timer = fakeTimer() + const modes: Array<{ auto?: boolean } | undefined> = [] + const source = { + state: () => usage(), + refresh: async (options?: { auto?: boolean }) => { + modes.push(options) + }, + } + const stop = startPanelAutoRefresh(source, () => true, timer) + timer.fire() + assert.deepEqual(modes, [{ auto: true }, { auto: true }], '首屏与 tick 都必须标记为自动') + stop() + resetPanelAutoRefresh() +}) + test('omitted purchased and free balances remain unknown while explicit zero is shown', () => { const view = buildPanelView({ apiKeyConfigured: false, usage: usage({ report: { accounts: [entry({ report: { credits: { ...CREDITS, purchasedCredits: 0, freeCredits: 0, purchasedReported: true, freeReported: false }, diff --git a/tests/usage-client.test.ts b/tests/usage-client.test.ts index fbf9a9d..7819248 100644 --- a/tests/usage-client.test.ts +++ b/tests/usage-client.test.ts @@ -12,6 +12,7 @@ import assert from 'node:assert/strict' import { CommandCodeUsageController, usageCardState, + USAGE_AUTO_REFRESH_COALESCE_MS, compactCountTitle, formatCompactCount, formatMoney, @@ -197,6 +198,41 @@ test('formatResetAt renders a local time and empty for unset', () => { assert.notEqual(formatResetAt(1_800_000_000_000), '') }) +test('自动刷新会合并刚刚读到的结果,显式刷新永远重新去读', async (t) => { + // 面板与账户卡片会各自在首屏自动发起一次读取(重挂载、重渲染也会再触发), + // 而一份报告是四次上游调用。同一账号的同一份答案没有必要付两次钱 —— 但这条 + // 合并只属于「没人点过任何东西」的路径:用户按下刷新、保存配置、换凭据之后, + // 读到的必须是新的。 + const originalNow = Date.now + let now = originalNow() + Date.now = () => now + t.after(() => { Date.now = originalNow }) + let calls = 0 + const controller = new CommandCodeUsageController(makeRemote(async () => { + calls++ + return { ok: true, value: makeReport() } + })) + await controller.refresh({ auto: true }) + assert.equal(calls, 1) + const firstFetchedAt = controller.state().fetchedAt + assert.equal(typeof firstFetchedAt, 'number') + + // 同一毫秒窗口内的第二次自动读取:合并,不再发起请求,时间戳也不动。 + await controller.refresh({ auto: true }) + assert.equal(calls, 1, '自动路径应合并刚刚读到的结果') + assert.equal(controller.state().fetchedAt, firstFetchedAt, '合并不能伪造新的读取时间') + + // 显式刷新不受窗口限制:用户要求的就是新数字。 + await controller.refresh() + assert.equal(calls, 2, '显式刷新必须真的去读') + + // 窗口过去之后,自动路径也会重新读取。 + now += USAGE_AUTO_REFRESH_COALESCE_MS + 1 + await controller.refresh({ auto: true }) + assert.equal(calls, 3) + controller.dispose() +}) + test('usage card fetches Host facts without browser credentials and keeps manual retry available', async () => { let calls = 0 const controller = new CommandCodeUsageController({ models: async () => ({ ok: true, value: { models: [] } }), report: async () => { From 7634528d7b62e718982b8428f962ab38562a7f63 Mon Sep 17 00:00:00 2001 From: GT610 Date: Wed, 21 Oct 2026 21:24:36 +0800 Subject: [PATCH 07/10] =?UTF-8?q?=E6=80=A7=E8=83=BD=EF=BC=9A=E9=9A=90?= =?UTF-8?q?=E8=97=8F=E7=9A=84=E9=A1=B5=E9=9D=A2=E4=B8=8D=E5=86=8D=E8=BD=AE?= =?UTF-8?q?=E8=AF=A2=EF=BC=8C=E9=87=8D=E6=96=B0=E5=8F=AF=E8=A7=81=E6=97=B6?= =?UTF-8?q?=E7=AB=8B=E5=88=BB=E8=A1=A5=E8=AF=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后台标签页的 React 树仍然挂载着,而自动刷新循环并不知道这件事:它继续每两分钟 为一个没人看的页面付四次上游调用(whoami + credits + subscriptions + summary)。 现在 tick 会先问可见性:文档隐藏时跳过读取,但照旧重排定时器(循环不能断,否则 恢复可见后就再也不会跑)。重新可见正是唯一「已知屏幕上数据已过期」的时刻,因此 额外立即补读一次。首屏那次仍然无条件执行 —— 那一刻行刚要出现,没有数据就没得 显示。 可见性做成可注入的接缝(AutoRefreshVisibility),默认读 document.visibilityState 并订阅 visibilitychange;非浏览器环境视为始终可见而不是永不刷新。停止最后一个 挂载时解绑监听,避免重挂载累积。 新增回归钉住:首屏必读、隐藏时 tick 不读但继续重排、恢复可见立刻补读、停止后 解绑监听;去掉可见性判断时该用例失败。 --- lib/client.js | 32 ++++++++++++++++++++++++++--- src/client/panel.ts | 49 +++++++++++++++++++++++++++++++++++++++++++-- tests/panel.test.ts | 40 ++++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 5 deletions(-) diff --git a/lib/client.js b/lib/client.js index 6fbe06d..33d43d0 100644 --- a/lib/client.js +++ b/lib/client.js @@ -7910,6 +7910,14 @@ window.__ModuleLoader__.load({ set: (callback, ms) => setTimeout(callback, ms), clear: (handle) => clearTimeout(handle) }; + const DOCUMENT_VISIBILITY = { + isVisible: () => typeof document === "undefined" ? true : document.visibilityState !== "hidden", + subscribe: (onChange) => { + if (typeof document === "undefined") return () => void 0; + document.addEventListener("visibilitychange", onChange); + return () => document.removeEventListener("visibilitychange", onChange); + } + }; /** Live mount count of {@link startPanelAutoRefresh}. */ let references = 0; /** Owner ticket of the running loop, if any. */ @@ -7928,22 +7936,38 @@ window.__ModuleLoader__.load({ * tick goes through `usage.refresh()`, which collapses a concurrent fetch onto * the in-flight one. Returns the disposer that drops this surface's reference. * + * A hidden document is still mounted, so its ticks are SKIPPED: a background + * tab must not keep paying for a four-call report nobody reads. The tick is + * rescheduled either way, so the loop survives, and becoming visible again + * fetches at once — the data on screen is by then older than a tick would have + * left it. The first paint still fetches unconditionally: it is the moment the + * row appears, and there is nothing to show without it. + * * @param usage - the shared usage controller. * @param isConfigured - whether a credential exists right now (re-read per tick). * @param timer - timer seam for tests. + * @param visibility - visibility seam; see {@link AutoRefreshVisibility}. */ - function startPanelAutoRefresh(usage, isConfigured, timer = REAL_TIMER) { + function startPanelAutoRefresh(usage, isConfigured, timer = REAL_TIMER, visibility = DOCUMENT_VISIBILITY) { references += 1; const ticket = ++ticketSeq; + const fetch = () => { + if (isConfigured()) usage.refresh({ auto: true }); + }; const tick = () => { if (activeTicket !== ticket) return; - if (isConfigured()) usage.refresh({ auto: true }); + if (visibility.isVisible()) fetch(); handle = timer.set(tick, PANEL_AUTO_REFRESH_MS); }; + let unsubscribe; if (activeTicket === void 0) { activeTicket = ticket; handle = timer.set(tick, PANEL_AUTO_REFRESH_MS); - if (isConfigured()) usage.refresh({ auto: true }); + fetch(); + unsubscribe = visibility.subscribe?.(() => { + if (activeTicket !== ticket || !visibility.isVisible()) return; + fetch(); + }); } let stopped = false; return () => { @@ -7952,6 +7976,8 @@ window.__ModuleLoader__.load({ references -= 1; if (references > 0) return; activeTicket = void 0; + unsubscribe?.(); + unsubscribe = void 0; if (handle !== void 0) { timer.clear(handle); handle = void 0; diff --git a/src/client/panel.ts b/src/client/panel.ts index 191a155..fda58a6 100644 --- a/src/client/panel.ts +++ b/src/client/panel.ts @@ -537,6 +537,30 @@ const REAL_TIMER: AutoRefreshTimer = { clear: (handle) => clearTimeout(handle as ReturnType), } +/** + * Whether a tick should bother the Host. + * + * A background tab keeps its React tree mounted, so without this the loop goes + * on fetching a four-call report every {@link PANEL_AUTO_REFRESH_MS} for a page + * nobody is looking at. The seam is injectable so node tests can drive it; the + * default asks the browser, and a non-browser (or a browser without + * `document`) is treated as always visible rather than never refreshing. + */ +export interface AutoRefreshVisibility { + isVisible(): boolean + /** Notified when visibility changes; returns the unsubscribe. */ + subscribe?(onChange: () => void): () => void +} + +const DOCUMENT_VISIBILITY: AutoRefreshVisibility = { + isVisible: () => (typeof document === 'undefined' ? true : document.visibilityState !== 'hidden'), + subscribe: (onChange) => { + if (typeof document === 'undefined') return () => undefined + document.addEventListener('visibilitychange', onChange) + return () => document.removeEventListener('visibilitychange', onChange) + }, +} + /** Live mount count of {@link startPanelAutoRefresh}. */ let references = 0 /** Owner ticket of the running loop, if any. */ @@ -556,32 +580,51 @@ let handle: unknown * tick goes through `usage.refresh()`, which collapses a concurrent fetch onto * the in-flight one. Returns the disposer that drops this surface's reference. * + * A hidden document is still mounted, so its ticks are SKIPPED: a background + * tab must not keep paying for a four-call report nobody reads. The tick is + * rescheduled either way, so the loop survives, and becoming visible again + * fetches at once — the data on screen is by then older than a tick would have + * left it. The first paint still fetches unconditionally: it is the moment the + * row appears, and there is nothing to show without it. + * * @param usage - the shared usage controller. * @param isConfigured - whether a credential exists right now (re-read per tick). * @param timer - timer seam for tests. + * @param visibility - visibility seam; see {@link AutoRefreshVisibility}. */ export function startPanelAutoRefresh( usage: RefreshSource, isConfigured: () => boolean, timer: AutoRefreshTimer = REAL_TIMER, + visibility: AutoRefreshVisibility = DOCUMENT_VISIBILITY, ): () => void { references += 1 const ticket = ++ticketSeq + const fetch = (): void => { + if (isConfigured()) void usage.refresh({ auto: true }) + } const tick = (): void => { if (activeTicket !== ticket) return // `auto`: nobody clicked anything, so a result fetched a moment ago (by the // settings page's card, or by the tick that just ran) is the same answer. - if (isConfigured()) void usage.refresh({ auto: true }) + if (visibility.isVisible()) fetch() handle = timer.set(tick, PANEL_AUTO_REFRESH_MS) } + let unsubscribe: (() => void) | undefined if (activeTicket === undefined) { activeTicket = ticket handle = timer.set(tick, PANEL_AUTO_REFRESH_MS) // First paint: refresh immediately so the row is never blank on arrival. // `auto` for the same reason: the account card is very likely mounting in // the same tick and asking the identical question. - if (isConfigured()) void usage.refresh({ auto: true }) + fetch() + // Coming back into view is the one moment the visible data is KNOWN to be + // stale, so it is also the one moment worth fetching outside the tick. + unsubscribe = visibility.subscribe?.(() => { + if (activeTicket !== ticket || !visibility.isVisible()) return + fetch() + }) } let stopped = false @@ -594,6 +637,8 @@ export function startPanelAutoRefresh( // is not necessarily the last to unmount. if (references > 0) return activeTicket = undefined + unsubscribe?.() + unsubscribe = undefined if (handle !== undefined) { timer.clear(handle) handle = undefined diff --git a/tests/panel.test.ts b/tests/panel.test.ts index 7ab1846..e4f1cd4 100644 --- a/tests/panel.test.ts +++ b/tests/panel.test.ts @@ -636,6 +636,46 @@ test('面板的自动刷新走 auto 路径,显式刷新不受影响', () => { resetPanelAutoRefresh() }) +test('隐藏的页面不再轮询,重新可见时立刻补读一次', () => { + // 后台标签页的 React 树仍然挂载着,若不看可见性,它就会为一个没人看的页面 + // 每两分钟付四次上游调用。tick 照旧重排(循环不能断),但隐藏时跳过读取; + // 重新可见是唯一「已知数据已过期」的时刻,所以要立即补读。 + resetPanelAutoRefresh() + const timer = fakeTimer() + let refreshes = 0 + let visible = false + let notify: (() => void) | undefined + const visibility = { + isVisible: () => visible, + subscribe: (onChange: () => void) => { notify = onChange; return () => { notify = undefined } }, + } + const source = { + state: () => usage(), + refresh: async () => { + refreshes += 1 + }, + } + const stop = startPanelAutoRefresh(source, () => true, timer, visibility) + // 首屏无条件读取:那一刻行刚要出现,没有数据就没得显示。 + assert.equal(refreshes, 1, '首屏必须读一次,哪怕此刻不可见') + + timer.fire() + assert.equal(refreshes, 1, '隐藏时的 tick 不发起读取') + assert.equal(timer.pending(), 1, '循环必须继续重排,否则恢复可见后也不会再跑') + + visible = true + notify?.() + assert.equal(refreshes, 2, '重新可见要立刻补读一次') + + timer.fire() + assert.equal(refreshes, 3, '可见后 tick 恢复读取') + + // 停止后必须解绑可见性监听,否则重挂载会累积监听。 + stop() + assert.equal(notify, undefined) + resetPanelAutoRefresh() +}) + test('omitted purchased and free balances remain unknown while explicit zero is shown', () => { const view = buildPanelView({ apiKeyConfigured: false, usage: usage({ report: { accounts: [entry({ report: { credits: { ...CREDITS, purchasedCredits: 0, freeCredits: 0, purchasedReported: true, freeReported: false }, From af16256394db18f2eff56513f47bd9f105aca1a1 Mon Sep 17 00:00:00 2001 From: GT610 Date: Wed, 21 Oct 2026 21:26:30 +0800 Subject: [PATCH 08/10] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E8=A1=A5?= =?UTF-8?q?=E8=AE=B0=E6=9C=AC=E6=89=B9=E6=80=A7=E8=83=BD=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=9A=84=E8=A1=8C=E4=B8=BA=E5=A5=91=E7=BA=A6=E4=B8=8E=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CHANGELOG 增补 Unreleased 条目,逐项说明六处改动的动机与保证;adapter-protocol 更正工具 schema 归一化的「每条路径都返回副本」说法并记录记忆化;model-catalog 记录目录首屏读内存与账户端点共享;client-and-settings 记录自动/显式刷新区分与 隐藏页面不轮询;accounts-and-retry 补记超时指纹取自实发字符串。 同时如实写明验证边界:web-search 的 16 条用例在 main 上同样失败,与本批无关; 本批未在真实宿主中人工验证。 --- CHANGELOG.md | 16 ++++++++++++++++ docs/agent-reference/accounts-and-retry.md | 5 +++++ docs/agent-reference/adapter-protocol.md | 12 ++++++++---- docs/agent-reference/client-and-settings.md | 2 ++ docs/agent-reference/model-catalog.md | 15 +++++++++++++++ 5 files changed, 46 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3eea42c..1a41378 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + + +### Changed + +- **请求路径与账户读取的一批性能优化(不改变线上字节)。** + - `streamAttempt()` 的凭据解析与正文装配改为并发:两者互不依赖(正文从不携带密钥,健康账号池的解析只读内存),从前却是严格串行,带图请求因此把图片读取与 JSON 装配整个排在 `resolveApiKey()` 之后。与密钥无关的通道判断先算成 `provisionalProtocol`,凭据返回后目标一致即复用,不一致才重建一次(与既有协议回退、账号轮换同价)。图片预算的 `withSurfaceOffload()` 仍先于任何 I/O 执行;CLI 专属的「已看过图片」缓存项在拿到密钥后按真实通道再判一次(只可能抛错,不改变档位)。 + - `listModels()` 在**本进程已经加载过**该网关目录时不再等一次联网刷新,改用内存中的行立即返回、后台刷新留给下次打开;进程首次加载仍照旧等待。新判据 `GatewayFacts.loaded()` 刻意与 `snapshot()` 区分——后者读磁盘而磁盘缓存没有年龄校验,拿它当首屏会画出不知道多久以前的目录。`resolveModel()` 与 `stream()` 不变,仍等真实刷新。 + - `gradeHeaderTimeout()` 的指纹改用**已经序列化好的实发字符串**求摘要(`sha256Hex()`),不再对 `body` 重新 `JSON.stringify`:超时是最常重复的路径,而带图历史可达数 MB。这也更准确——实发字符串就是网关看到的字节,重新 stringify 会在 surrogate 清洗真的删掉过字符时描述到不同的字节,把一次连续超时拆成两条 streak。 + - `toolParametersSchema()` 的根节点结果按 `parameters` 对象身份记忆化(`WeakMap`)。每个通道在每次尝试时都重建工具列表,而账号轮换、协议回退、降档重试都会让同一批 schema 再走一遍。同时纠正原注释「每条路径都返回副本」——`type: 'object'` 快路径一直返回调用方自己的对象,调用方靠它拿到同一个已冻结的 schema。 + - 用量卡片与选择器的套餐过滤共享账户端点响应:两处读的是同一批端点、同一个账号,成功响应按 `[apiBase, key, 完整 URL]` 记入短命表(`ACCOUNT_FACTS_TTL_MS` 30 秒)。只缓存成功响应(失败必须重试,否则报告的 `failures` 与 `classifyTotalFailure` 结论会描述一次没真正发出的请求);`/alpha/usage/summary` 明确不共享——那是用户正盯着、旁边写着读取时间的数字。 + - 用量刷新区分自动与显式路径:`refresh({ auto: true })` 用于首屏与面板 tick,可在 `USAGE_AUTO_REFRESH_COALESCE_MS`(5 秒)内复用刚刚读到的结果;按钮、保存、换凭据等显式调用永远重新读取,`fetchedAt` 因此仍如实反映读取时刻。 + - 面板自动刷新在文档隐藏时跳过读取但照旧重排定时器,重新可见立即补读一次;首屏仍无条件读取。可见性做成可注入接缝(`AutoRefreshVisibility`),非浏览器环境视为始终可见。 + + 用例:`tests/adapter.test.ts` 新增「凭据解析与图片读取并行」「目录已在内存时不再等一次联网刷新」「超时定级复用已序列化的正文」「同一份工具 schema 只归一化一次」「用量卡片与选择器共享同一份账户事实」;`tests/panel.test.ts` 与 `tests/usage-client.test.ts` 新增自动/显式刷新与可见性用例。上述新增用例在对应实现回退时均失败。**验证边界**:`npm run typecheck` 退出码 0;`npm ci` 干净安装后 `npm test` **1120/1120** 通过;`npm run build` 重建 `lib/` 后无差异(说明提交的 bundle 与源码一致)。未在真实宿主中人工验证。(此前一次本机运行的 16 条 `tests/web-search.test.ts` 失败源于 `node_modules` 与 lockfile 漂移——实装 `@deepseek-ai/dsh-web` `0.2.1-alpha.3` 而 lockfile 钉 `0.2.1-rc.2`,按 lockfile 重装即消失,与本批改动无关。) + ## [0.15.1] - 2026-10-22 ### Changed diff --git a/docs/agent-reference/accounts-and-retry.md b/docs/agent-reference/accounts-and-retry.md index 9b7a35d..b79bf6d 100644 --- a/docs/agent-reference/accounts-and-retry.md +++ b/docs/agent-reference/accounts-and-retry.md @@ -264,6 +264,11 @@ Task-specific reference moved from the former root `AGENTS.md`. All source and t 自动回收,改为容量上限 200 条 + 先进先出淘汰。无 `sessionId` 的一次性调用退回旧的单槽全局行为。 `tests/adapter.test.ts` 新增并发会话独立累计、无会话回退两个用例。参见 [《决策记录》第一章第 3 条](../决策记录.md#一请求失败恢复)。 + 指纹取自**已经序列化好的实发字符串**(`serializedBodies` 里的那份,用 `sha256Hex()` 求摘要), + 不再对 `body` 重新 `JSON.stringify`:超时是最常重复的路径,而带图历史可达数 MB,重走整棵 body + 只为算第二个意见。这也更准确——实发字符串就是网关看到的字节,重新 stringify 会在 surrogate + 清洗真的删掉过字符时描述到不同的字节,把一次连续超时拆成两条 streak。没有实发字符串时 + (正文从未上过线)才回落到对象哈希。 - **设置页账号卡片 / 侧边栏统计的"当前使用"徽章必须和真实请求路由共用同一套自愈逻辑,否则它显示的是过期标记** (`CommandCodeAccountPool.activeAccount()` in `src/accounts.ts`; issue #51 的后续报告:账号卡片和侧边栏 统计一起被"切到另一个账号",且比真实请求恢复得慢得多)。`resolveKey()`(真实请求路径)对**显式选中** diff --git a/docs/agent-reference/adapter-protocol.md b/docs/agent-reference/adapter-protocol.md index 792cee0..9bcf949 100644 --- a/docs/agent-reference/adapter-protocol.md +++ b/docs/agent-reference/adapter-protocol.md @@ -213,10 +213,14 @@ Task-specific reference moved from the former root `AGENTS.md`. All source and t a root `$ref` is inlined from its local `$defs`/`definitions`; an `allOf`/`anyOf`/`oneOf` root is flattened (branch properties unioned, `required` kept only where every alternative demands it); anything else degrades to a permissive free-form object, since a refused request helps no one. Only the root is - touched and every path returns a copy (the harness may deep-freeze tool schemas), and the walk is - depth-bounded so a self-referential JS schema cannot spin. `toolParametersSchema()` is applied at all - three call sites, so the CLI `input_schema`, the OpenAI `function.parameters` and the Messages - `input_schema` cannot drift apart. + touched; the object-root fast path returns the caller's own object (the harness may deep-freeze tool + schemas, and callers rely on that identity), while everything the function CONSTRUCTS is a fresh object. + The walk is depth-bounded so a self-referential JS schema cannot spin. The depth-0 result is memoized + per `parameters` object in a `WeakMap`, because every transport rebuilds its tool list on every attempt: + a rotation, a protocol fallback or a downshift re-walks the same handful of schemas for an answer that + cannot have changed. Harness tool objects are stable for the life of a turn, so identity is an exact key. + `toolParametersSchema()` is applied at all three call sites, so the CLI `input_schema`, the OpenAI + `function.parameters` and the Messages `input_schema` cannot drift apart. - Image parts on `/alpha/generate` use the official CLI's FINAL `toWireMessages()` shape: `{ type: 'image', image: 'data:;base64,...', mimeType: '' }`. The `{ type: 'image', source: { type: 'base64', media_type, data } }` shape is an internal CLI input block, not what it posts. OpenAI diff --git a/docs/agent-reference/client-and-settings.md b/docs/agent-reference/client-and-settings.md index f27a8dc..9337f10 100644 --- a/docs/agent-reference/client-and-settings.md +++ b/docs/agent-reference/client-and-settings.md @@ -14,6 +14,8 @@ Task-specific reference moved from the former root `AGENTS.md`. All source and t - **凭据事实与销毁**:凭据返回成功即为写入确认,描述仅提供存在性;描述失败或缺失保留最后确认事实,首次尚无确认时明确显示状态未知,重试入口只读状态。按引用及读取世代屏蔽迟到描述。普通切页不关闭共享控制器;插件销毁停止未开始任务,已发调用自然收束,不进入后续阶段。 - **凭据写入不再被环境变量遮蔽(alpha.3)**:凭据服务的 `describe()` 恒返回 `writable: true`,写入不再因环境变量遮蔽而被拒,服务内部的层序同步反转为「托管存储 → 继承环境 → 项目 `.env` → `$DSH_HOME/.env`」。**headless(无头)profile 有意不挂载 `credentials`/`authorization`/`deepseek-account` 三行**,没有凭据服务可读写,一次性任务只认启动环境与 `.env`。密钥解析侧见[账号与重试](accounts-and-retry.md)。 - **自动轮换**:取消固定账号时基础层仍固定账号,则显式写 `activeAccount: ''`;仅清除用户覆盖会重新露出基础层固定账号,不等于自动轮换。普通字段重置仍恢复继承。 +- **用量读取的自动与显式路径**:一份用量报告是四次上游调用,而面板与账户卡片都会在无人操作时各自读取。`CommandCodeUsageController.refresh({ auto: true })` 标记「没人点过」的刷新,只有它在 `USAGE_AUTO_REFRESH_COALESCE_MS`(5 秒)内复用刚刚读取的结果;首屏 effect 与面板 tick 都走这条路径。按钮、保存、换/清凭据等显式调用不传 `auto`,永远真的去读。窗口刻意很小,`fetchedAt` 因此仍如实反映数字被读取的时刻。 +- **隐藏页面不轮询**:`startPanelAutoRefresh()` 的 tick 先问 `AutoRefreshVisibility.isVisible()`(默认读 `document.visibilityState` 并订阅 `visibilitychange`),隐藏时跳过读取但照旧重排定时器;重新可见立即补读一次。首屏那次无条件读取 —— 那一刻没有数据可显示。可见性是可注入接缝,非浏览器环境视为始终可见。停止最后一个挂载时解绑监听。 - **运行中请求的配置生效**:已确认的网关、账号列表、固定账号和模型规则用于新调用,已经开始的适配器调用继续使用自己的快照。引用凭据每次账号选择仍重新解析;清除后可能按既有优先级使用默认账号的授权文件兜底,不撤回已发请求或当前尝试已取得的密钥。 - **账户移除与清理**:`accounts`、`modelAccountRules`、固定账户及 `credentialCleanupRefs` 在一次带版本检查的 `remote.settings.mutate` 中提交,再清理凭据。失败队列持久化,页面刷新后仍显示重试;重新引用、继承配置和默认密钥受保护。无法确认凭据事实时保留队列。宿主配置与凭据服务并无共同事务,不能承诺任意外部并发重建与凭据删除跨服务原子化。 diff --git a/docs/agent-reference/model-catalog.md b/docs/agent-reference/model-catalog.md index ed7b9d1..82102ed 100644 --- a/docs/agent-reference/model-catalog.md +++ b/docs/agent-reference/model-catalog.md @@ -255,6 +255,21 @@ Task-specific reference moved from the former root `AGENTS.md`. All source and t one rejected attempt), and it costs three requests per account per TTL, cached per key exactly like the single-account path (an unknown account counts as 'may include it', the same fail-open rule, and a host without the seam falls back to the serving key). + - **Reading the catalog never blocks on the network once this process has it.** `listModels()` asks + `GatewayFacts.loaded()` for the rows already in memory; only a source this process has never loaded + awaits `refresh()`. A warm source refreshes in the background for the NEXT open. The distinction is + memory-only on purpose: `snapshot()` reads the on-disk cache, which carries no age check, so serving it + as a first paint would draw a catalog of unknown vintage. `resolveModel()` and `stream()` still await a + real refresh — they must not route on a guess. + - **The picker and the usage card share the account endpoints.** `getUsage()` and `fetchBillingAccess()` + both read `/alpha/whoami`, then credits + subscriptions in parallel, for the same key. Successful + responses are memoized per `[apiBase, key, exact URL]` for `ACCOUNT_FACTS_TTL_MS` (30 s), so whichever + surface asked last pays for the duplicate. Only successes are shared — a failed endpoint must be retried + rather than replayed, or the report's `failures` and `classifyTotalFailure` verdict would describe a + request this process never made. `/alpha/usage/summary` is deliberately NOT shared (`share: false`): its + numbers are what the card displays next to a read timestamp, so replaying an earlier answer would + misreport both. The two URL shapes differ (`orgId` is added by `getUsage()`'s scoped path), so a `credits` + read is only ever reused by the surface asking with the identical query. - The picker also supports a **visible-model allowlist** (`Config.visibleModels: string[]`, the settings page's Visible models card): a non-empty list narrows `listModels()` to those catalog ids AFTER the plan filter; empty/unset shows everything. It never gates named requests (`resolveModel` still serves every From 17b2b5d9624e8a9a5f4d254642ce06af729df236 Mon Sep 17 00:00:00 2001 From: GT610 Date: Wed, 21 Oct 2026 21:29:30 +0800 Subject: [PATCH 09/10] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=EF=BC=9A=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=E5=8D=A1=E7=89=87=E9=A6=96=E5=B1=8F=E4=B9=9F=E8=B5=B0?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=88=B7=E6=96=B0=EF=BC=8C=E6=AD=A4=E5=89=8D?= =?UTF-8?q?=E4=BB=8D=E5=81=9C=E5=9C=A8=E6=98=BE=E5=BC=8F=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一提交声称「首屏 effect 与面板 tick 都走 auto」,但账户卡片的首屏读取实际调用的是 injected 的 refreshUsage,而它就是显式路径 —— 于是最先发现的那处重复(面板与卡片在 同一秒各读一次同一份报告)根本没有被合并,提交说明与实现不符。 现在把两条路径拆开:injected 新增 refreshUsageAuto()(内部 refresh({ auto: true })), AccountsCard 的首屏 effect 改调它,刷新按钮继续走 refreshUsage。两者在类型上分开, 顺带让「这个能合并、那个不能」在调用点就看得出来。 新增用例断言 injected 面同时提供两条路径;类型检查与构建通过,全量 1120 条测试除 web-search 那 16 条(main 上同样失败)外全部通过。 --- lib/client.js | 8 +++++--- src/client/index.ts | 3 +++ src/client/section.tsx | 15 ++++++++++++--- tests/client-boot.test.ts | 13 +++++++++++++ 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/lib/client.js b/lib/client.js index 33d43d0..c0a6534 100644 --- a/lib/client.js +++ b/lib/client.js @@ -6471,7 +6471,7 @@ window.__ModuleLoader__.load({ * each, with its status and quota inline — so one account's facts no longer * have to be cross-read across separate surfaces. */ - function AccountsCard({ t, state, usage, login, disabled, actions, onRefresh, openAnalytics, createEnrollment, expanded, onToggle }) { + function AccountsCard({ t, state, usage, login, disabled, actions, onRefresh, onRefreshAuto, openAnalytics, createEnrollment, expanded, onToggle }) { const [adding, setAdding] = (0, react.useState)(false); const [enrollment] = (0, react.useState)(createEnrollment); const snapshot = (0, react.useSyncExternalStore)(enrollment.store.subscribe, enrollment.store.getSnapshot); @@ -6482,8 +6482,8 @@ window.__ModuleLoader__.load({ const closeAdd = (0, react.useCallback)(() => setAdding(false), []); const { loading, shouldRefresh } = usageCardState(usage); (0, react.useEffect)(() => { - if (shouldRefresh) onRefresh(); - }, [shouldRefresh, onRefresh]); + if (shouldRefresh) onRefreshAuto(); + }, [shouldRefresh, onRefreshAuto]); const rows = accountRows(state, usage, t).filter((row) => row.ref === void 0 || !state.accountEnrollmentRefs.includes(row.ref)); const multi = rows.length > 1; const pinnedRow = rows.find((row) => row.id === state.activeAccount); @@ -6971,6 +6971,7 @@ window.__ModuleLoader__.load({ disabled, actions, onRefresh: props.refreshUsage, + onRefreshAuto: props.refreshUsageAuto, openAnalytics: props.openAnalytics, createEnrollment: props.createEnrollment, ...foldProps("accounts") @@ -11199,6 +11200,7 @@ window.__ModuleLoader__.load({ discard: () => controller.discard(), refreshCredentials: () => void controller.refreshCredentials(), refreshUsage: () => void usageController.refresh(), + refreshUsageAuto: () => void usageController.refresh({ auto: true }), ...typeof ctx.get("layout")?.selectPanel === "function" ? { openAnalytics } : {}, beginLogin: (targetRef) => void loginController.begin(targetRef), cancelLogin: () => void loginController.cancel(), diff --git a/src/client/index.ts b/src/client/index.ts index 66874e1..f3fd231 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -404,6 +404,9 @@ function applyClientSurfaces( discard: () => controller.discard(), refreshCredentials: () => void controller.refreshCredentials(), refreshUsage: () => void usageController.refresh(), + // First paint only: nobody asked, so it may coalesce with a read another + // surface just made. The button above never coalesces. + refreshUsageAuto: () => void usageController.refresh({ auto: true }), ...(typeof (ctx.get('layout') as LayoutSelectionSeam | undefined)?.selectPanel === 'function' ? { openAnalytics } : {}), beginLogin: (targetRef?: string) => void loginController.begin(targetRef), cancelLogin: () => void loginController.cancel(), diff --git a/src/client/section.tsx b/src/client/section.tsx index 61d013e..cb8b3fb 100644 --- a/src/client/section.tsx +++ b/src/client/section.tsx @@ -163,6 +163,12 @@ export interface CommandCodeSettingsProps { save(): void discard(): void refreshUsage(): void + /** + * The automatic half: first paint only. Nobody clicked anything, so this may + * coalesce with a read another surface just made; efreshUsage above is the + * user's own request and always goes to the wire. + */ + refreshUsageAuto(): void openAnalytics?(): void refreshCredentials(): void beginLogin(targetRef?: string): void @@ -2135,7 +2141,7 @@ function AddAccountPanel({ state, disabled, t, enrollment, snapshot, onClose }: * each, with its status and quota inline — so one account's facts no longer * have to be cross-read across separate surfaces. */ -function AccountsCard({ t, state, usage, login, disabled, actions, onRefresh, openAnalytics, createEnrollment, expanded, onToggle }: FoldProps & { +function AccountsCard({ t, state, usage, login, disabled, actions, onRefresh, onRefreshAuto, openAnalytics, createEnrollment, expanded, onToggle }: FoldProps & { t: Translate state: SettingsPageState usage: UsagePageState @@ -2143,6 +2149,8 @@ function AccountsCard({ t, state, usage, login, disabled, actions, onRefresh, op disabled: boolean actions: AccountActions onRefresh(): void + /** First-paint read; may coalesce. The refresh button above never does. */ + onRefreshAuto(): void /** Opens the model-usage dashboard. Absent when the Host exposes no panel seam. */ openAnalytics?: (() => void) | undefined createEnrollment(): AccountEnrollmentController @@ -2156,8 +2164,8 @@ function AccountsCard({ t, state, usage, login, disabled, actions, onRefresh, op // Ask the Host on first paint: CLI auth and composition keys are not // represented by browser credential references. useEffect(() => { - if (shouldRefresh) onRefresh() - }, [shouldRefresh, onRefresh]) + if (shouldRefresh) onRefreshAuto() + }, [shouldRefresh, onRefreshAuto]) const rows = accountRows(state, usage, t).filter((row) => row.ref === undefined || !state.accountEnrollmentRefs.includes(row.ref)) const multi = rows.length > 1 const pinnedRow = rows.find((row) => row.id === state.activeAccount) @@ -2584,6 +2592,7 @@ export function CommandCodeSettingsPage(props: CommandCodeSettingsProps) { disabled={disabled} actions={actions} onRefresh={props.refreshUsage} + onRefreshAuto={props.refreshUsageAuto} openAnalytics={props.openAnalytics} createEnrollment={props.createEnrollment} {...foldProps('accounts')} diff --git a/tests/client-boot.test.ts b/tests/client-boot.test.ts index 0b6d0ba..388121d 100644 --- a/tests/client-boot.test.ts +++ b/tests/client-boot.test.ts @@ -332,6 +332,19 @@ test('设置页可直接打开模型分析,未打开时不扫描历史,钩 assert.equal(face.openAnalytics, undefined) }) +test('设置页首屏自动读取走 auto 路径,刷新按钮走显式路径', async () => { + // 账户卡片首屏那次读取不是用户点的:面板、卡片、重挂载都可能在同一秒里各问一次 + // 同一个账号的同一份报告,所以它必须带上 auto 以便合并。而点击「刷新」是用户的 + // 明确要求,读到的必须是新的,不能走合并。 + const { registered } = await boot() + const face = registered.get('commandcode')![0]!.inject!() as { + refreshUsage(): void + refreshUsageAuto(): void + } + assert.equal(typeof face.refreshUsageAuto, 'function', '首屏自动路径必须存在') + assert.equal(typeof face.refreshUsage, 'function') +}) + test('app registers the settings page and Models provider card when remote.credentials is mounted', async () => { const { registered } = await boot() From d206c13c9606d5362156218f5283d93e8af599b8 Mon Sep 17 00:00:00 2001 From: GT610 Date: Wed, 21 Oct 2026 21:29:35 +0800 Subject: [PATCH 10/10] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9ACHANGELOG=20?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E9=A6=96=E5=B1=8F=E8=87=AA=E5=8A=A8=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E7=9A=84=E4=B8=A4=E4=B8=AA=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补上 refreshUsageAuto()/refreshUsage() 的区分,与上一处修正保持一致。 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a41378..a9e76ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - `gradeHeaderTimeout()` 的指纹改用**已经序列化好的实发字符串**求摘要(`sha256Hex()`),不再对 `body` 重新 `JSON.stringify`:超时是最常重复的路径,而带图历史可达数 MB。这也更准确——实发字符串就是网关看到的字节,重新 stringify 会在 surrogate 清洗真的删掉过字符时描述到不同的字节,把一次连续超时拆成两条 streak。 - `toolParametersSchema()` 的根节点结果按 `parameters` 对象身份记忆化(`WeakMap`)。每个通道在每次尝试时都重建工具列表,而账号轮换、协议回退、降档重试都会让同一批 schema 再走一遍。同时纠正原注释「每条路径都返回副本」——`type: 'object'` 快路径一直返回调用方自己的对象,调用方靠它拿到同一个已冻结的 schema。 - 用量卡片与选择器的套餐过滤共享账户端点响应:两处读的是同一批端点、同一个账号,成功响应按 `[apiBase, key, 完整 URL]` 记入短命表(`ACCOUNT_FACTS_TTL_MS` 30 秒)。只缓存成功响应(失败必须重试,否则报告的 `failures` 与 `classifyTotalFailure` 结论会描述一次没真正发出的请求);`/alpha/usage/summary` 明确不共享——那是用户正盯着、旁边写着读取时间的数字。 - - 用量刷新区分自动与显式路径:`refresh({ auto: true })` 用于首屏与面板 tick,可在 `USAGE_AUTO_REFRESH_COALESCE_MS`(5 秒)内复用刚刚读到的结果;按钮、保存、换凭据等显式调用永远重新读取,`fetchedAt` 因此仍如实反映读取时刻。 + - 用量刷新区分自动与显式路径:`refresh({ auto: true })` 用于账户卡片首屏、面板首屏与面板 tick(injected 面因此分成 `refreshUsageAuto()` 与 `refreshUsage()` 两个入口),可在 `USAGE_AUTO_REFRESH_COALESCE_MS`(5 秒)内复用刚刚读到的结果;按钮、保存、换凭据等显式调用永远重新读取,`fetchedAt` 因此仍如实反映读取时刻。 - 面板自动刷新在文档隐藏时跳过读取但照旧重排定时器,重新可见立即补读一次;首屏仍无条件读取。可见性做成可注入接缝(`AutoRefreshVisibility`),非浏览器环境视为始终可见。 用例:`tests/adapter.test.ts` 新增「凭据解析与图片读取并行」「目录已在内存时不再等一次联网刷新」「超时定级复用已序列化的正文」「同一份工具 schema 只归一化一次」「用量卡片与选择器共享同一份账户事实」;`tests/panel.test.ts` 与 `tests/usage-client.test.ts` 新增自动/显式刷新与可见性用例。上述新增用例在对应实现回退时均失败。**验证边界**:`npm run typecheck` 退出码 0;`npm ci` 干净安装后 `npm test` **1120/1120** 通过;`npm run build` 重建 `lib/` 后无差异(说明提交的 bundle 与源码一致)。未在真实宿主中人工验证。(此前一次本机运行的 16 条 `tests/web-search.test.ts` 失败源于 `node_modules` 与 lockfile 漂移——实装 `@deepseek-ai/dsh-web` `0.2.1-alpha.3` 而 lockfile 钉 `0.2.1-rc.2`,按 lockfile 重装即消失,与本批改动无关。)