From c92ee87d74e0f8371bae72a07aacd22b0c36d38e Mon Sep 17 00:00:00 2001 From: GT610 Date: Wed, 21 Oct 2026 21:01:39 +0800 Subject: [PATCH 01/12] =?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/12] =?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/12] =?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/12] =?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/12] =?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/12] =?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/12] =?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/12] =?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/12] =?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/12] =?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 重装即消失,与本批改动无关。) From 4a0921a53e295df1360f3ca4d82b5b17b596590e Mon Sep 17 00:00:00 2001 From: Mars-Sea <1195751600@qq.com> Date: Sat, 24 Oct 2026 16:02:05 +0800 Subject: [PATCH 11/12] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20PR=20#98=20?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E5=8F=91=E7=8E=B0=E7=9A=84=E5=9B=9B=E7=B1=BB?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 图片预算门抢在凭据之前抛错(R1/R2,src/adapter.ts) 预算门改为纯会计(projectSurfaceBudget),抛错推迟到凭据与真实通道 落定之后:无凭据时先报 MISSING_CREDENTIAL,不再让宿主对一次发不出去 的请求先做不可逆的持久化图片卸载;预算门与缓存门一次算全 (max(预算, 缓存)),不再退化成两步卸载、也不会把 cache 溢出报成 budget。 withSurfaceOffload() 保留为 413 降档/协议回退的一次性形式。 2. loaded() 只认联网来源(R3/R4,src/gateway-facts.ts) CatalogState 增加 origin(none/disk/network):snapshot() 灌入的磁盘行 与刷新失败后的空目录都不再被当成选择器首屏;冷启动先聊天再开选择器 不会画出无年龄校验的旧目录,刷新失败后也不会先闪一次空列表。 3. 可见性监听按任意卸载顺序解绑(R6,src/client/panel.ts) unsubscribe 句柄从函数局部提升到模块层,启动循环的 surface 先卸载时 不再泄漏 visibilitychange 监听。 4. 账户卡片首屏接线补真实渲染测试(R7) 新增 tests/accounts-card-render.test.ts(react-test-renderer + 既有 CSS/primitives loader):首屏必须调 onRefreshAuto(),刷新按钮必须调 onRefresh();改回旧接线时用例失败。此前只有两个入口存在性断言。 其余测试:gateway-facts 新增 4 条 loaded() 来源判据,adapter 新增 2 条 预算门顺序用例,panel 新增 1 条任意卸载顺序解绑用例。新增 react-test-renderer / @types/react-test-renderer 开发依赖。 验证:typecheck 退出码 0;npm test 1125/1125 通过;npm run build 完成, lib/ 已重建。 --- lib/client.js | 22 +++- lib/index.js | 75 ++++++++---- package-lock.json | 68 +++++++++++ package.json | 2 + src/adapter.ts | 89 ++++++++++----- src/client/panel.ts | 21 +++- src/client/section.tsx | 9 +- src/gateway-facts.ts | 33 ++++-- tests/accounts-card-render.test.ts | 178 +++++++++++++++++++++++++++++ tests/adapter.test.ts | 51 ++++++++- tests/gateway-facts.test.ts | 46 ++++++++ tests/panel.test.ts | 21 ++++ 12 files changed, 549 insertions(+), 66 deletions(-) create mode 100644 tests/accounts-card-render.test.ts diff --git a/lib/client.js b/lib/client.js index 6f45d71..993e68e 100644 --- a/lib/client.js +++ b/lib/client.js @@ -6471,6 +6471,11 @@ window.__ModuleLoader__.load({ * The unified account card: every account the pool rotates through, one row * each, with its status and quota inline — so one account's facts no longer * have to be cross-read across separate surfaces. + * + * Exported for the render test (`tests/accounts-card-render.test.ts`) that pins + * the first-paint auto-refresh wiring against a real React tree: the page mounts + * this card, and a test that only checks the injected function exists cannot see + * the card reaching for it. */ function AccountsCard({ t, state, usage, login, disabled, actions, onRefresh, onRefreshAuto, openAnalytics, createEnrollment, expanded, onToggle }) { const [adding, setAdding] = (0, react.useState)(false); @@ -7929,6 +7934,16 @@ window.__ModuleLoader__.load({ /** Pending timer handle of the running loop. */ let handle; /** + * Unsubscribe of the visibility listener owned by the RUNNING loop. + * + * Module-level on purpose: only the surface that started the loop receives the + * subscribe handle, but that surface is not necessarily the last to unmount. + * Keeping the handle on the same layer as `activeTicket`/`handle` lets any + * teardown order release it; a function-local handle leaked the listener + * whenever the starter unmounted first. + */ + let visibilityUnsubscribe; + /** * Start the shared auto refresh. One fetch when the surface appears (the sidebar * row is the point of the panel — it must be current, not wait for a click), then * a tick every {@link PANEL_AUTO_REFRESH_MS} while a surface stays mounted. @@ -7961,12 +7976,11 @@ window.__ModuleLoader__.load({ 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); fetch(); - unsubscribe = visibility.subscribe?.(() => { + visibilityUnsubscribe = visibility.subscribe?.(() => { if (activeTicket !== ticket || !visibility.isVisible()) return; fetch(); }); @@ -7978,8 +7992,8 @@ window.__ModuleLoader__.load({ references -= 1; if (references > 0) return; activeTicket = void 0; - unsubscribe?.(); - unsubscribe = void 0; + visibilityUnsubscribe?.(); + visibilityUnsubscribe = void 0; if (handle !== void 0) { timer.clear(handle); handle = void 0; diff --git a/lib/index.js b/lib/index.js index 7ad5fb5..572d05a 100644 --- a/lib/index.js +++ b/lib/index.js @@ -6369,7 +6369,8 @@ var GatewayFacts = class { if (this.state?.source !== identity) this.state = { source: identity, rows: [], - expiresAt: 0 + expiresAt: 0, + origin: "none" }; return this.state; } @@ -6379,16 +6380,17 @@ var GatewayFacts = class { return row === void 0 ? void 0 : copyRows([row])[0]; } /** - * 本进程已经加载过的目录;从未加载过时返回 undefined。 + * 本进程**联网成功过**的目录;否则返回 undefined。 * - * 与 {@link snapshot} 的区别是它绝不读磁盘:调用方要区分「有已知行可以先画」与 - * 「只有一份可能过期的磁盘副本」。磁盘缓存没有年龄校验,用它给选择器当首屏会让 - * 冷启动显示一份不知道多久以前的目录;而内存里的行要么刚联网取回,要么是同一 - * 进程里刚刚成功刷新过的结果,可以安全地先画、再后台更新。 + * 判据是来源而不是「内存里有行」:`snapshot()` 会把磁盘缓存写进同一份 + * `state.rows`(生成路径需要它),若只看行数,冷启动先发过一次聊天再打开 + * 选择器时,首屏拿到的就是没有年龄校验的磁盘副本——正是本方法要排除的东西。 + * 空目录同样返回 undefined:刷新失败后的空行(`expiresAt` 已进入重试窗口) + * 不能让选择器把「暂时读不到」画成「没有模型」,调用方应回落到 `refresh()`。 */ loaded(source) { const state = this.forSource(source); - if (state.rows.length === 0 && state.expiresAt === 0) return void 0; + if (state.origin !== "network" || state.rows.length === 0) return void 0; return copyRows(state.rows); } /** 生成只读取本地目录;捕获状态后不再转向实例的新来源。 */ @@ -6398,7 +6400,10 @@ var GatewayFacts = class { state.disk ??= readCache(source).catch(() => []); const rows = await state.disk; if (state.rows.length > 0 || state.expiresAt > 0) return copyRows(state.rows); - if (state.inflight === void 0) state.rows = rows; + if (state.inflight === void 0) { + state.rows = rows; + if (rows.length > 0 && state.origin === "none") state.origin = "disk"; + } return copyRows(rows); } async refresh(source, signal) { @@ -6411,8 +6416,10 @@ var GatewayFacts = class { const mayPublish = () => this.state === state && generations.get(source.apiBase) === generation; state.inflight = (async () => { let rows, ttl = MODEL_CATALOG_TTL_MS; + let network = false; try { rows = parseCatalog$1(await this.fetchCatalog(source), source.apiBase); + network = true; if (mayPublish()) { publish(source.apiBase, rows); await writeCache(source, rows, mayPublish).catch(() => void 0); @@ -6424,6 +6431,8 @@ var GatewayFacts = class { } state.rows = rows; state.expiresAt = Date.now() + ttl; + if (network) state.origin = "network"; + else if (state.origin === "none" && rows.length > 0) state.origin = "disk"; return rows; })().finally(() => { delete state.inflight; @@ -7060,26 +7069,47 @@ function coreBudget(budget) { */ const imageVersionBytes = (block) => block.attachment.bytes; /** +* The accounting half of the image gate: project the surface's durable offload +* marks and count what the byte budget still needs — WITHOUT throwing. +* +* The throw has to wait until the credential is known. A request that cannot +* authenticate must not trigger a durable (irreversible) image offload before +* the caller learns the key is missing; keeping the count pure lets +* `streamAttempt()` run it before ANY I/O and report ONE error later, once the +* real transport is known. +*/ +function projectSurfaceBudget(options, policy, budget) { + const marked = policy.projectOffloadedImages(options.messages, (ref) => offloadedImageText(ref)); + const messages = marked === options.messages ? options.messages : [...marked]; + return { + options: messages === options.messages ? options : { + ...options, + messages + }, + budgetMissing: policy.requiredImageOffload(messages, coreBudget(budget), imageVersionBytes) + }; +} +/** * Request options under one budget rung: the surface's durable marks become * placeholder text, and an over-budget history FAILS with the count to offload * instead of being evicted here. Throws rather than returning, so no caller can * accidentally send a body this route already knows is too large. * +* This is the one-shot form (budget + cache in a single error) used by the 413 +* downshift and the protocol fallback. `streamAttempt()`'s first pass uses +* {@link projectSurfaceBudget} instead, because there the throw must wait for +* the credential. +* * The next rung is applied to the CURRENT projection, never to the raw history — * otherwise the 413 retry could reintroduce images the first rung had already * evicted. */ function withSurfaceOffload(options, policy, budget, cacheOffloadModel) { - const marked = policy.projectOffloadedImages(options.messages, (ref) => offloadedImageText(ref)); - const messages = marked === options.messages ? options.messages : [...marked]; - const budgetMissing = policy.requiredImageOffload(messages, coreBudget(budget), imageVersionBytes); - const cacheMissing = cacheOffloadModel === void 0 ? 0 : seenImageOffloadCount(messages, cacheOffloadModel); - const missing = Math.max(budgetMissing, cacheMissing); - if (missing > 0) throw imageOffloadRequired(missing, cacheMissing > budgetMissing ? "cache" : "budget"); - return messages === options.messages ? options : { - ...options, - messages - }; + const projected = projectSurfaceBudget(options, policy, budget); + const cacheMissing = cacheOffloadModel === void 0 ? 0 : seenImageOffloadCount(projected.options.messages, cacheOffloadModel); + const missing = Math.max(projected.budgetMissing, cacheMissing); + if (missing > 0) throw imageOffloadRequired(missing, cacheMissing > projected.budgetMissing ? "cache" : "budget"); + return projected.options; } /** * Count retained images already followed by an answer from this same model. @@ -8778,7 +8808,8 @@ var CommandCodeAdapter = class CommandCodeAdapter extends LlmAdapter { 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 requestOptions = withSurfaceOffload(options, this.surfaceOffload, REQUEST_IMAGE_BUDGETS[0]); + const projected = projectSurfaceBudget(options, this.surfaceOffload, REQUEST_IMAGE_BUDGETS[0]); + let requestOptions = projected.options; const buildBody = async (target) => { const finishBody = timing.phase("body"); try { @@ -8800,7 +8831,7 @@ var CommandCodeAdapter = class CommandCodeAdapter extends LlmAdapter { 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); + const provisionalBody = connection.offloadSeenImagesForCache === true || projected.budgetMissing > 0 ? void 0 : buildBody(provisionalProtocol); provisionalBody?.catch(() => void 0); let apiKey; try { @@ -8811,7 +8842,9 @@ var CommandCodeAdapter = class CommandCodeAdapter extends LlmAdapter { 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); + const cacheMissing = protocol === "cli" && connection.offloadSeenImagesForCache === true ? seenImageOffloadCount(requestOptions.messages, options.model) : 0; + const missing = Math.max(projected.budgetMissing, cacheMissing); + if (missing > 0) throw imageOffloadRequired(missing, cacheMissing > projected.budgetMissing ? "cache" : "budget"); let messagesPermissionRejection; let body = provisionalBody !== void 0 && protocol === provisionalProtocol ? await provisionalBody : await buildBody(protocol); if (maxTokens !== builtMaxTokens) { diff --git a/package-lock.json b/package-lock.json index f5fc370..8ee3665 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,8 +32,10 @@ "@deepseek-ai/schemastery": "~3.18.5", "@types/node": "^22.0.0", "@types/react": "^18.3.1", + "@types/react-test-renderer": "^19.3.0", "parse5": "8.0.1", "react": "^18.2.0", + "react-test-renderer": "^18.3.1", "tsdown": "^0.22.2", "tsx": "^4.23.12", "typescript": "^6.0.3" @@ -1341,6 +1343,16 @@ "csstype": "^3.2.2" } }, + "node_modules/@types/react-test-renderer": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.3.0.tgz", + "integrity": "sha512-TX4p2D4Z2oigM3SXU7+QyLSUnSR0vR1S1Pfxm/qFL9nHQ3jclgi6ioGoWQmlIr1DeKRVmPJrmNlhgM0JlWlLUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@yuku-codegen/binding-android-arm64": { "version": "0.8.7", "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-android-arm64/-/binding-android-arm64-0.8.7.tgz", @@ -2215,6 +2227,16 @@ "node": ">=20" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/obug": { "version": "2.2.1", "dev": true, @@ -2275,6 +2297,42 @@ "node": ">=0.10.0" } }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-shallow-renderer": { + "version": "16.15.0", + "resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz", + "integrity": "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.1", + "react-is": "^16.12.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-test-renderer": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-18.3.1.tgz", + "integrity": "sha512-KkAgygexHUkQqtvvx/otwxtuFu5cVjfzTCtjXLH9boS19/Nbtg84zS7wIQn39G8IlrhThBpQsMKkq5ZHZIYFXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-is": "^18.3.1", + "react-shallow-renderer": "^16.15.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "dev": true, @@ -2375,6 +2433,16 @@ } } }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/semver": { "version": "7.8.5", "dev": true, diff --git a/package.json b/package.json index a493efc..dbbd771 100644 --- a/package.json +++ b/package.json @@ -141,8 +141,10 @@ "@deepseek-ai/schemastery": "~3.18.5", "@types/node": "^22.0.0", "@types/react": "^18.3.1", + "@types/react-test-renderer": "^19.3.0", "parse5": "8.0.1", "react": "^18.2.0", + "react-test-renderer": "^18.3.1", "tsdown": "^0.22.2", "tsx": "^4.23.12", "typescript": "^6.0.3" diff --git a/src/adapter.ts b/src/adapter.ts index c00a8d1..c1645ad 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -933,12 +933,40 @@ function coreBudget(budget: RequestImageBudget): CoreImageBudget { */ const imageVersionBytes: ImageVersionBytes = (block) => block.attachment.bytes +/** + * The accounting half of the image gate: project the surface's durable offload + * marks and count what the byte budget still needs — WITHOUT throwing. + * + * The throw has to wait until the credential is known. A request that cannot + * authenticate must not trigger a durable (irreversible) image offload before + * the caller learns the key is missing; keeping the count pure lets + * `streamAttempt()` run it before ANY I/O and report ONE error later, once the + * real transport is known. + */ +function projectSurfaceBudget( + options: GenerateOptions, + policy: SurfaceImagePolicy, + budget: RequestImageBudget, +): { options: GenerateOptions; budgetMissing: number } { + const marked = policy.projectOffloadedImages(options.messages, (ref) => offloadedImageText(ref)) + const messages = marked === options.messages ? options.messages : [...marked] + return { + options: messages === options.messages ? options : { ...options, messages }, + budgetMissing: policy.requiredImageOffload(messages, coreBudget(budget), imageVersionBytes), + } +} + /** * Request options under one budget rung: the surface's durable marks become * placeholder text, and an over-budget history FAILS with the count to offload * instead of being evicted here. Throws rather than returning, so no caller can * accidentally send a body this route already knows is too large. * + * This is the one-shot form (budget + cache in a single error) used by the 413 + * downshift and the protocol fallback. `streamAttempt()`'s first pass uses + * {@link projectSurfaceBudget} instead, because there the throw must wait for + * the credential. + * * The next rung is applied to the CURRENT projection, never to the raw history — * otherwise the 413 retry could reintroduce images the first rung had already * evicted. @@ -949,13 +977,11 @@ function withSurfaceOffload( budget: RequestImageBudget, cacheOffloadModel?: string, ): GenerateOptions { - const marked = policy.projectOffloadedImages(options.messages, (ref) => offloadedImageText(ref)) - const messages = marked === options.messages ? options.messages : [...marked] - const budgetMissing = policy.requiredImageOffload(messages, coreBudget(budget), imageVersionBytes) - const cacheMissing = cacheOffloadModel === undefined ? 0 : seenImageOffloadCount(messages, cacheOffloadModel) - const missing = Math.max(budgetMissing, cacheMissing) - if (missing > 0) throw imageOffloadRequired(missing, cacheMissing > budgetMissing ? 'cache' : 'budget') - return messages === options.messages ? options : { ...options, messages } + const projected = projectSurfaceBudget(options, policy, budget) + const cacheMissing = cacheOffloadModel === undefined ? 0 : seenImageOffloadCount(projected.options.messages, cacheOffloadModel) + const missing = Math.max(projected.budgetMissing, cacheMissing) + if (missing > 0) throw imageOffloadRequired(missing, cacheMissing > projected.budgetMissing ? 'cache' : 'budget') + return projected.options } /** @@ -3422,15 +3448,17 @@ export class CommandCodeAdapter> => { const finishBody = timing.phase('body') try { @@ -3468,8 +3496,10 @@ export class CommandCodeAdapter 0 ? undefined : buildBody(provisionalProtocol) void provisionalBody?.catch(() => undefined) @@ -3484,15 +3514,22 @@ export class CommandCodeAdapter 0) { + throw imageOffloadRequired(missing, cacheMissing > projected.budgetMissing ? 'cache' : 'budget') } // 反推 messages 端点对本次账号的权限拒绝(issue #84)。只活在一次 stream() // 调用里,并在最终错误文案中留痕:它描述的是这一条 Claude 链路,既不能 diff --git a/src/client/panel.ts b/src/client/panel.ts index fda58a6..47b3069 100644 --- a/src/client/panel.ts +++ b/src/client/panel.ts @@ -569,6 +569,16 @@ let activeTicket: number | undefined let ticketSeq = 0 /** Pending timer handle of the running loop. */ let handle: unknown +/** + * Unsubscribe of the visibility listener owned by the RUNNING loop. + * + * Module-level on purpose: only the surface that started the loop receives the + * subscribe handle, but that surface is not necessarily the last to unmount. + * Keeping the handle on the same layer as `activeTicket`/`handle` lets any + * teardown order release it; a function-local handle leaked the listener + * whenever the starter unmounted first. + */ +let visibilityUnsubscribe: (() => void) | undefined /** * Start the shared auto refresh. One fetch when the surface appears (the sidebar @@ -611,7 +621,6 @@ export function startPanelAutoRefresh( 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) @@ -621,7 +630,7 @@ export function startPanelAutoRefresh( 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?.(() => { + visibilityUnsubscribe = visibility.subscribe?.(() => { if (activeTicket !== ticket || !visibility.isVisible()) return fetch() }) @@ -637,8 +646,10 @@ export function startPanelAutoRefresh( // is not necessarily the last to unmount. if (references > 0) return activeTicket = undefined - unsubscribe?.() - unsubscribe = undefined + // The handle lives on the module layer, so this teardown releases it no + // matter which surface started the loop. + visibilityUnsubscribe?.() + visibilityUnsubscribe = undefined if (handle !== undefined) { timer.clear(handle) handle = undefined @@ -656,5 +667,7 @@ export function startPanelAutoRefresh( export function resetPanelAutoRefresh(): void { references = 0 activeTicket = undefined + visibilityUnsubscribe?.() + visibilityUnsubscribe = undefined handle = undefined } diff --git a/src/client/section.tsx b/src/client/section.tsx index 3b559fb..3be93d5 100644 --- a/src/client/section.tsx +++ b/src/client/section.tsx @@ -1003,7 +1003,7 @@ interface FormCardProps { } /** Whether a group is open, and how to flip it; owned by the page (./group-fold.ts). */ -interface FoldProps { +export interface FoldProps { expanded: boolean onToggle(): void } @@ -2156,8 +2156,13 @@ function AddAccountPanel({ state, disabled, t, enrollment, snapshot, onClose }: * The unified account card: every account the pool rotates through, one row * each, with its status and quota inline — so one account's facts no longer * have to be cross-read across separate surfaces. + * + * Exported for the render test (`tests/accounts-card-render.test.ts`) that pins + * the first-paint auto-refresh wiring against a real React tree: the page mounts + * this card, and a test that only checks the injected function exists cannot see + * the card reaching for it. */ -function AccountsCard({ t, state, usage, login, disabled, actions, onRefresh, onRefreshAuto, openAnalytics, createEnrollment, expanded, onToggle }: FoldProps & { +export function AccountsCard({ t, state, usage, login, disabled, actions, onRefresh, onRefreshAuto, openAnalytics, createEnrollment, expanded, onToggle }: FoldProps & { t: Translate state: SettingsPageState usage: UsagePageState diff --git a/src/gateway-facts.ts b/src/gateway-facts.ts index 405c5eb..7dc2098 100644 --- a/src/gateway-facts.ts +++ b/src/gateway-facts.ts @@ -96,6 +96,12 @@ interface CatalogState { source: string rows: CommandCodeModel[] expiresAt: number + /** + * 这批 `rows` 从哪来。`'none'` = 本进程还没加载;`'disk'` = 只读过磁盘缓存 + * (`snapshot()` 灌入,没有联网成功过);`'network'` = 本进程联网成功过。 + * `loaded()` 只认 `'network'`:磁盘副本没有年龄校验,不能当首屏。 + */ + origin: 'none' | 'disk' | 'network' disk?: Promise inflight?: Promise } @@ -108,7 +114,7 @@ export class GatewayFacts { constructor(private readonly fetchCatalog: (source: GatewaySource) => Promise) {} private forSource(source: GatewaySource): CatalogState { const identity = JSON.stringify([source.apiBase, source.modelsCachePath]) - if (this.state?.source !== identity) this.state = { source: identity, rows: [], expiresAt: 0 } + if (this.state?.source !== identity) this.state = { source: identity, rows: [], expiresAt: 0, origin: 'none' } return this.state } /** 模型解析保持原有热目录直读;不把生成的磁盘冷读取推广到其他入口。 */ @@ -117,16 +123,17 @@ export class GatewayFacts { return row === undefined ? undefined : copyRows([row])[0] } /** - * 本进程已经加载过的目录;从未加载过时返回 undefined。 + * 本进程**联网成功过**的目录;否则返回 undefined。 * - * 与 {@link snapshot} 的区别是它绝不读磁盘:调用方要区分「有已知行可以先画」与 - * 「只有一份可能过期的磁盘副本」。磁盘缓存没有年龄校验,用它给选择器当首屏会让 - * 冷启动显示一份不知道多久以前的目录;而内存里的行要么刚联网取回,要么是同一 - * 进程里刚刚成功刷新过的结果,可以安全地先画、再后台更新。 + * 判据是来源而不是「内存里有行」:`snapshot()` 会把磁盘缓存写进同一份 + * `state.rows`(生成路径需要它),若只看行数,冷启动先发过一次聊天再打开 + * 选择器时,首屏拿到的就是没有年龄校验的磁盘副本——正是本方法要排除的东西。 + * 空目录同样返回 undefined:刷新失败后的空行(`expiresAt` 已进入重试窗口) + * 不能让选择器把「暂时读不到」画成「没有模型」,调用方应回落到 `refresh()`。 */ loaded(source: GatewaySource): CommandCodeModel[] | undefined { const state = this.forSource(source) - if (state.rows.length === 0 && state.expiresAt === 0) return undefined + if (state.origin !== 'network' || state.rows.length === 0) return undefined return copyRows(state.rows) } /** 生成只读取本地目录;捕获状态后不再转向实例的新来源。 */ @@ -136,7 +143,11 @@ export class GatewayFacts { state.disk ??= readCache(source).catch(() => []) const rows = await state.disk if (state.rows.length > 0 || state.expiresAt > 0) return copyRows(state.rows) - if (state.inflight === undefined) state.rows = rows + if (state.inflight === undefined) { + state.rows = rows + // 只标记来源,不改变 snapshot 的返回值:生成路径照旧立刻拿到磁盘行。 + if (rows.length > 0 && state.origin === 'none') state.origin = 'disk' + } return copyRows(rows) } async refresh(source: GatewaySource, signal?: AbortSignal): Promise { @@ -149,8 +160,10 @@ export class GatewayFacts { const mayPublish = () => this.state === state && generations.get(source.apiBase) === generation state.inflight = (async () => { let rows: CommandCodeModel[], ttl = MODEL_CATALOG_TTL_MS + let network = false try { rows = parseCatalog(await this.fetchCatalog(source), source.apiBase) + network = true if (mayPublish()) { publish(source.apiBase, rows) await writeCache(source, rows, mayPublish).catch(() => undefined) @@ -163,6 +176,10 @@ export class GatewayFacts { } state.rows = rows state.expiresAt = Date.now() + ttl + // 来源只升不降:一次联网成功后,后来的失败回退仍是「联网过的行」; + // 从未联网、只拿到磁盘副本时如实记为磁盘来源,`loaded()` 不会用它当首屏。 + if (network) state.origin = 'network' + else if (state.origin === 'none' && rows.length > 0) state.origin = 'disk' return rows })().finally(() => { delete state.inflight }) } diff --git a/tests/accounts-card-render.test.ts b/tests/accounts-card-render.test.ts new file mode 100644 index 0000000..4649c3c --- /dev/null +++ b/tests/accounts-card-render.test.ts @@ -0,0 +1,178 @@ +/** + * AccountsCard 首屏读取接线的真实渲染测试(node:test + react-test-renderer)。 + * + * 为什么需要真的渲染:`refreshUsageAuto()` 存在性测试只能证明注入面提供了入口, + * 证明不了「账户卡片首屏真的走它」。17b2b5d 修过一次「提交说明说走 auto、实现 + * 却停在显式」的错,且没有任何行为测试能发现回退——把组件改回 `onRefresh()` 时 + * 全量测试仍然全绿。这里用真实 React 树钉住两条线: + * 1. 首屏 effect 必须调用 `onRefreshAuto()`(可被 5 秒窗口合并); + * 2. 刷新按钮必须调用 `onRefresh()`(用户点的,永远重新读取)。 + * + * 运行环境说明:`section.tsx` 会导入宿主的 UI primitives 与 `*.module.css`, + * 两者在 node 下都不可直接解析,所以先注册 `_css-module-loader.mjs`(它把 CSS + * 映射为空模块、把 primitives 换成最小替身),再动态 import 组件——静态 import + * 会提升到 `register()` 调用之前。 + */ + +import { register } from 'node:module' + +register(new URL('./_css-module-loader.mjs', import.meta.url).href) + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createElement, type ComponentProps } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import type { Translate } from '@deepseek-ai/dsh-client-ui-slots' + +import type { SettingsCommandCodeKey } from '../src/client/locales.ts' +import type { SettingsPageState, StagedField } from '../src/client/settings.ts' +import type { UsagePageState } from '../src/client/usage.ts' +import type { LoginPageState } from '../src/client/login.ts' +import type { AccountEnrollmentController } from '../src/client/enrollment.ts' + +const { AccountsCard } = await import('../src/client/section.tsx') + +// React 18 的 act 环境标记:不设置会为 effect 打印 "not wrapped in act" 警告。 +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +function stagedField(): StagedField { + return { text: '', clear: false, overridden: false, invalid: false, invalidReason: undefined } +} + +/** 完整的 SettingsPageState;本用例只关心读取接线,其余字段取空值。 */ +function makeState(overrides: Partial = {}): SettingsPageState { + return { + available: true, + writable: true, + apiKeyConfigured: false, + apiKeyKnown: true, + anyAccountConfigured: false, + apiKeyWritable: true, + apiKey: stagedField(), + apiBase: stagedField(), + requestTimeoutMs: stagedField(), + streamIdleTimeoutMs: stagedField(), + transportMaxRetries: stagedField(), + offloadSeenImagesForCache: stagedField(), + filterModelsByPlan: stagedField(), + webSearch: stagedField(), + showSidebarQuota: stagedField(), + zdr: stagedField(), + sidebarQuota: false, + activeAccount: '', + accounts: [], + accountModels: {}, + accountBusy: false, + accountFailed: undefined, + pendingCredentialCleanup: [], + accountEnrollmentRefs: [], + visibleModels: [], + visibleModelsAll: true, + contextWindow: stagedField(), + maxOutputTokens: stagedField(), + contextWindowOverrides: [], + catalogModels: [], + catalogFailed: false, + dirty: false, + invalid: false, + saving: false, + failed: false, + savedCount: 0, + saveResult: undefined, + accountResult: undefined, + credentialRefreshFailed: false, + ...overrides, + } +} + +const usageIdle: UsagePageState = { status: 'idle', report: undefined, error: undefined, fetchedAt: undefined } +const loginIdle: LoginPageState = { + phase: 'idle', + authUrl: undefined, + userName: undefined, + keyName: undefined, + reason: undefined, + message: undefined, +} + +/** 账户卡会接上的动作面;异步操作一律立即成功。 */ +const actions = { + refreshCredentials: () => {}, + cleanup: async () => true, + rename: async () => true, + remove: async () => true, + setKey: async () => true, + clearKey: async () => true, + setActive: async () => true, + setModels: async () => true, + beginLogin: () => {}, + cancelLogin: () => {}, +} + +/** 开通控制器在首屏只被 activate/dispose;给一个不会联网的最小替身。 */ +const ENROLLMENT_SNAPSHOT = { active: undefined, pending: [] as const, error: undefined } +const enrollment = { + store: { + subscribe: () => () => {}, + // React 要求 getSnapshot 返回稳定引用,否则会反复重渲染。 + getSnapshot: () => ENROLLMENT_SNAPSHOT, + }, + activate: () => {}, + dispose: () => {}, + refresh: async () => {}, +} as unknown as AccountEnrollmentController + +type CardProps = ComponentProps + +function makeProps(calls: string[]): CardProps { + return { + t: ((key: string) => key) as unknown as Translate, + state: makeState(), + usage: usageIdle, + login: loginIdle, + disabled: false, + actions, + onRefresh: () => { calls.push('explicit') }, + onRefreshAuto: () => { calls.push('auto') }, + createEnrollment: () => enrollment, + expanded: true, + onToggle: () => {}, + } as unknown as CardProps +} + +/** 递归取出一棵 React children 里的纯文本(图标是元素,不能 JSON 序列化)。 */ +function textOf(node: unknown): string { + if (typeof node === 'string' || typeof node === 'number') return String(node) + if (Array.isArray(node)) return node.map(textOf).join('') + if (node !== null && typeof node === 'object' && 'props' in node) { + return textOf((node as { props: { children?: unknown } }).props.children) + } + return '' +} + +/** 找到标题为「刷新」的按钮(`t` 在这里返回 key 本身)。 */ +function refreshButton(root: ReactTestRenderer) { + const button = root.root.findAllByType('button').find((node) => + textOf(node.props.children).includes('usageRefresh')) + assert.ok(button, '账户卡必须有刷新按钮') + return button +} + +test('账户卡片首屏只调用自动路径,不调用显式路径', () => { + const calls: string[] = [] + let root!: ReactTestRenderer + act(() => { root = create(createElement(AccountsCard, makeProps(calls))) }) + assert.deepEqual(calls, ['auto'], '首屏不是用户点的,必须走可合并的 auto 路径') + act(() => { root.unmount() }) +}) + +test('账户卡片的刷新按钮走显式路径,不调用自动路径', () => { + const calls: string[] = [] + let root!: ReactTestRenderer + act(() => { root = create(createElement(AccountsCard, makeProps(calls))) }) + // 首屏那次是 auto;只看按钮点击,先把记录清空。 + calls.length = 0 + act(() => { refreshButton(root).props.onClick({}) }) + assert.deepEqual(calls, ['explicit'], '用户点刷新必须走显式路径,永远重新读取') + act(() => { root.unmount() }) +}) diff --git a/tests/adapter.test.ts b/tests/adapter.test.ts index 607c5af..ebe1a16 100644 --- a/tests/adapter.test.ts +++ b/tests/adapter.test.ts @@ -49,7 +49,7 @@ import { } from '../src/capabilities.ts' import type { CommandCodeAdapterDeps, CommandCodeConnectionOptions, SurfaceImagePolicy } from '../src/adapter.ts' import type { ContentBlock, GenerateOptions, Message, RequestMessage, StreamChunk } from '@deepseek-ai/dsh-llm' -import { MessageId, ToolCallId, ReasoningEffortId, type LlmError } from '@deepseek-ai/dsh-llm' +import { MessageId, ToolCallId, ReasoningEffortId, LlmError } from '@deepseek-ai/dsh-llm' import type { RequestTimingSummary } from '../src/request-timing.ts' import type { AttachmentStore, @@ -8418,3 +8418,52 @@ test('every new-arrival model carries an output ceiling or is left to runtime le // 1 048 576-token window, so the table is still demonstrably not the window. assert.ok(MODEL_OUTPUT_TOKEN_LIMITS.get('meta/muse-wonder-1.0')! < 1_048_576) }) + +// --------------------------------------------------------------------------- +// 图片预算门与凭据解析的顺序(PR #98 审查修复 R1/R2) +// +// 预算门只能在凭据与真实通道落定之后抛:否则一次根本发不出去的请求会先让 +// 宿主把最老的图片持久化卸载(不可逆),然后才暴露「没有凭据」。预算门与 +// 缓存门还必须一次算全、一次抛出,否则宿主会多跑一轮卸载往返,并把 +// cache 溢出报成 budget。 +// --------------------------------------------------------------------------- + +/** 61 张 1 MiB 的图:数量超 60、base64 字节超 32 MiB,预算门必触发。 */ +function oversizedImageHistory() { + return imageHistory(Array.from({ length: 61 }, () => 1024 * 1024)) +} + +test('凭据缺失时图片预算门不抢跑:先报 MISSING_CREDENTIAL,不发请求', async () => { + const { messages, images } = oversizedImageHistory() + let fetched = false + const adapter = makeAdapter({ + resolveApiKey: async () => { throw new LlmError('未配置 Command Code API 密钥', 'MISSING_CREDENTIAL') }, + resolveAttachments: () => fakeAttachments(images), + fetchImpl: (async () => { fetched = true; return okStream('openai') }) as unknown as typeof fetch, + options: OPENAI_OPTIONS, + }) + let error: unknown + try { + await collect(adapter.stream({ + provider: 'commandcode', model: 'deepseek/deepseek-v4.1-flash', messages, + })) + } catch (thrown) { error = thrown } + assert.equal((error as { code?: string }).code, 'MISSING_CREDENTIAL', + '凭据错误必须先于图片错误:否则宿主的持久化卸载会先发生') + assert.equal(fetched, false, '凭据缺失的请求不应走到网络') +}) + +test('预算与缓存门同时超限只报一次,且报两者较大的缺数', async () => { + const { messages, images } = oversizedImageHistory() + // 让这 61 张图都落在「当前模型已看过」的区间里:缓存门 count = 61 > 预算门。 + const answered: Message[] = [...messages, { + id: messageId(), role: 'assistant', content: [{ type: 'text', text: '都看过了' }], + source: { kind: 'model', provider: 'commandcode', model: 'deepseek/deepseek-v4.1-flash' }, + }] + const result = await runAttempts('cli', answered, images, undefined, undefined, true) + assert.equal(result.bodies.length, 0, '门未通过前不得发出任何请求') + assert.equal((result.error as { code?: string }).code, 'IMAGE_OFFLOAD_REQUIRED') + // 旧实现先报预算门、重试后才报缓存门 61;一次算全时直接报两者的最大值 61。 + assert.match((result.error as { message?: string }).message ?? '', /61 already-seen oldest image/ + ) +}) diff --git a/tests/gateway-facts.test.ts b/tests/gateway-facts.test.ts index 197c267..d66e756 100644 --- a/tests/gateway-facts.test.ts +++ b/tests/gateway-facts.test.ts @@ -122,3 +122,49 @@ test('上限拒绝以实发值判断,共享学习只降低且按网关隔离', assert.equal(modelOutputTokenLimit(a, model), 20000) assert.equal(modelOutputTokenLimit(b, model), 131072) }) + +// --------------------------------------------------------------------------- +// `loaded()` 的来源判据(PR #98 审查修复 R3/R4) +// +// 选择器首屏只允许用「本进程联网成功过」的非空目录:`snapshot()` 会把磁盘 +// 缓存灌进同一份 state.rows,若只看行数,冷启动先聊过天再打开选择器就会拿 +// 没有年龄校验的磁盘副本当首屏;刷新失败后的空目录也不配冒充热数据。 +// --------------------------------------------------------------------------- + +test('loaded() 在进程从未加载目录时返回 undefined', () => { + const s = source('facts-loaded-cold') + assert.equal(new GatewayFacts(async () => catalog()).loaded(s), undefined) +}) + +test('snapshot() 灌入的磁盘行不算联网来源,loaded() 仍返回 undefined', async t => { + const dir = await mkdtemp(join(tmpdir(), 'gateway-loaded-')) + t.after(() => rm(dir, { recursive: true, force: true })) + const s = { ...source('facts-loaded-disk'), modelsCachePath: join(dir, 'models.json') } + await writeFile(s.modelsCachePath, JSON.stringify({ version: 3, apiBase: s.apiBase, models: [{ id: model, name: '缓存模型', contextWindow: 1000000, maxTokens: 1, publishedMaxTokens: 20000, supportedEndpoints: ['/messages'] }] })) + let calls = 0 + const facts = new GatewayFacts(async () => { calls += 1; return catalog(90000) }) + assert.equal((await facts.snapshot(s))[0]?.maxTokens, 20000, '生成路径照旧立刻拿到磁盘行') + assert.equal(facts.loaded(s), undefined, '磁盘副本不能当选择器首屏') + await facts.refresh(s) + assert.equal(facts.loaded(s)?.[0]?.maxTokens, 90000, '联网成功之后才允许先画') + assert.equal(calls, 1) +}) + +test('刷新失败且无磁盘缓存时 loaded() 返回 undefined,不把空目录当热数据', async () => { + const s = source('facts-loaded-failure') + const facts = new GatewayFacts(async () => { throw new Error('合成失败') }) + assert.deepEqual(await facts.refresh(s), []) + assert.equal(facts.loaded(s), undefined) +}) + +test('loaded() 返回独立副本,调用方改写不会污染内部目录', async () => { + const s = source('facts-loaded-copy') + const facts = new GatewayFacts(async () => catalog(90000)) + await facts.refresh(s) + const painted = facts.loaded(s)! + painted[0]!.maxTokens = 1 + ;(painted[0]!.supportedEndpoints as string[]).splice(0) + const again = facts.loaded(s)! + assert.equal(again[0]?.maxTokens, 90000) + assert.deepEqual(again[0]?.supportedEndpoints, ['/messages']) +}) diff --git a/tests/panel.test.ts b/tests/panel.test.ts index e4f1cd4..fab023e 100644 --- a/tests/panel.test.ts +++ b/tests/panel.test.ts @@ -686,3 +686,24 @@ test('omitted purchased and free balances remain unknown while explicit zero is const missing = buildPanelView({ apiKeyConfigured: true, usage: usage({ report: { accounts: [entry({ configured: false })] } }) }) assert.equal(missing.noKey, true, 'only the Host can establish that no usable key exists') }) + +test('启动者先卸载时可见性监听也必须解绑,任意卸载顺序都不泄漏', () => { + // 只有启动循环的那次 start 拿到订阅句柄,但它不一定是最后卸载的 surface。 + // 句柄若留在函数局部,启动者先停就永远走不到解绑分支,监听随挂载周期累积。 + resetPanelAutoRefresh() + const timer = fakeTimer() + let subscribes = 0 + let unsubscribes = 0 + const visibility = { + isVisible: () => true, + subscribe: () => { subscribes += 1; return () => { unsubscribes += 1 } }, + } + const source = { state: () => usage(), refresh: async () => {} } + const stopStarter = startPanelAutoRefresh(source, () => true, timer, visibility) + const stopSecond = startPanelAutoRefresh(source, () => true, timer, visibility) + assert.equal(subscribes, 1, '只有启动循环的那次订阅') + stopStarter() + stopSecond() + assert.equal(unsubscribes, 1, '任意卸载顺序都必须解绑一次') + resetPanelAutoRefresh() +}) From 31d95de539f63c523f37ddf882a8b53963549532 Mon Sep 17 00:00:00 2001 From: Mars-Sea <1195751600@qq.com> Date: Sat, 24 Oct 2026 16:02:05 +0800 Subject: [PATCH 12/12] =?UTF-8?q?docs:=20=E5=90=8C=E6=AD=A5=20PR=20#98=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=90=8E=E7=9A=84=E8=A1=8C=E4=B8=BA=E5=A5=91?= =?UTF-8?q?=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG:修正「只可能抛错,不会改变档位」「loaded 只读内存」等 过度声明;记录 R1/R2/R3/R4/R6 的修复与真实渲染测试;如实写明账户 事实共享只覆盖 whoami/subscriptions、Go 账号会多装配一次正文。 - adapter-protocol:图片预算段改为「会计与抛错分离」的口径。 - model-catalog:loaded() 以联网来源为准,磁盘行与空目录不算热数据。 - client-and-settings:显式刷新在途时仍按 inFlight 折叠;可见性监听 句柄在模块层、任意卸载顺序都解绑;补首屏接线的渲染用例指引。 --- CHANGELOG.md | 22 ++++++++++++++------- docs/agent-reference/adapter-protocol.md | 15 ++++++++++---- docs/agent-reference/client-and-settings.md | 4 ++-- docs/agent-reference/model-catalog.md | 14 +++++++------ 4 files changed, 36 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 872532a..d73bcd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,16 +9,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Changed -- **请求路径与账户读取的一批性能优化(不改变线上字节)。** - - `streamAttempt()` 的凭据解析与正文装配改为并发:两者互不依赖(正文从不携带密钥,健康账号池的解析只读内存),从前却是严格串行,带图请求因此把图片读取与 JSON 装配整个排在 `resolveApiKey()` 之后。与密钥无关的通道判断先算成 `provisionalProtocol`,凭据返回后目标一致即复用,不一致才重建一次(与既有协议回退、账号轮换同价)。图片预算的 `withSurfaceOffload()` 仍先于任何 I/O 执行;CLI 专属的「已看过图片」缓存项在拿到密钥后按真实通道再判一次(只可能抛错,不改变档位)。 - - `listModels()` 在**本进程已经加载过**该网关目录时不再等一次联网刷新,改用内存中的行立即返回、后台刷新留给下次打开;进程首次加载仍照旧等待。新判据 `GatewayFacts.loaded()` 刻意与 `snapshot()` 区分——后者读磁盘而磁盘缓存没有年龄校验,拿它当首屏会画出不知道多久以前的目录。`resolveModel()` 与 `stream()` 不变,仍等真实刷新。 +- **请求路径与账户读取的一批性能优化(线上字节不变)。** + - `streamAttempt()` 的凭据解析与正文装配改为并发:两者互不依赖(正文从不携带密钥,健康账号池的解析只读内存),从前却是严格串行,带图请求因此把图片读取与 JSON 装配整个排在 `resolveApiKey()` 之后。与密钥无关的通道判断先算成 `provisionalProtocol`,凭据返回后目标一致即复用,不一致才重建一次(与既有协议回退、账号轮换同价)。代价是 Go 计划/已学 CLI 通道的账号每次请求会多装配一次正文(线上字节不变,纯 CPU 开销)。图片预算的**会计**仍先于任何 I/O 执行,但**抛错**推迟到凭据与真实通道落定之后:预算门与缓存门一次算全、一次抛出。 + - `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(injected 面因此分成 `refreshUsageAuto()` 与 `refreshUsage()` 两个入口),可在 `USAGE_AUTO_REFRESH_COALESCE_MS`(5 秒)内复用刚刚读到的结果;按钮、保存、换凭据等显式调用永远重新读取,`fetchedAt` 因此仍如实反映读取时刻。 - - 面板自动刷新在文档隐藏时跳过读取但照旧重排定时器,重新可见立即补读一次;首屏仍无条件读取。可见性做成可注入接缝(`AutoRefreshVisibility`),非浏览器环境视为始终可见。 + - 用量卡片与选择器的套餐过滤共享账户端点响应:两处读的是同一批端点、同一个账号,成功响应按 `[apiBase, key, 完整 URL]` 记入短命表(`ACCOUNT_FACTS_TTL_MS` 30 秒)。实际共享的是 `whoami` 与 `subscriptions`——`credits` 的 URL 形状带 `orgId`,两处不同,不会互相命中。只缓存成功响应(失败必须重试,否则报告的 `failures` 与 `classifyTotalFailure` 结论会描述一次没真正发出的请求);`/alpha/usage/summary` 明确不共享——那是用户正盯着、旁边写着读取时间的数字。 + - 用量刷新区分自动与显式路径:`refresh({ auto: true })` 用于账户卡片首屏、面板首屏与面板 tick(injected 面因此分成 `refreshUsageAuto()` 与 `refreshUsage()` 两个入口),可在 `USAGE_AUTO_REFRESH_COALESCE_MS`(5 秒)内复用刚刚读到的结果;按钮、保存、换凭据等显式调用不查合并窗口(请求在途时仍按既有 `inFlight` 规则折叠),`fetchedAt` 因此仍如实反映读取时刻。 + - 面板自动刷新在文档隐藏时跳过读取但照旧重排定时器,重新可见立即补读一次(该补读走 auto,5 秒内仍可能被合并);首屏仍无条件读取。可见性做成可注入接缝(`AutoRefreshVisibility`),非浏览器环境视为始终可见;监听句柄随循环状态放在模块层,任意卸载顺序都能解绑。 + +### Fixed + +- **图片预算门不再抢在凭据之前抛错。** 预算门只做会计,凭据解析失败时先报 `MISSING_CREDENTIAL`:避免宿主对一次根本发不出去的请求先做不可逆的持久化图片卸载。预算门与缓存门在凭据与真实通道落定后一次算全(`max(预算, 缓存)`),不再退化成两步卸载,也不会把 cache 溢出报成 budget。(`src/adapter.ts`) +- **`loaded()` 只认联网来源。** `snapshot()` 灌入的磁盘行与刷新失败后的空目录都不再被当成选择器首屏:冷启动"先聊天再开选择器"不会画出无年龄校验的旧目录,刷新失败后也不会先闪一次空列表。(`src/gateway-facts.ts`) +- **可见性监听按任意卸载顺序解绑。** `unsubscribe` 句柄从函数局部提升到模块层,启动循环的 surface 先卸载时不再泄漏 `visibilitychange` 监听。(`src/client/panel.ts`) +- **账户卡片首屏接线补真实渲染测试。** 新增 `tests/accounts-card-render.test.ts`(`react-test-renderer`):首屏必须调 `onRefreshAuto()`、刷新按钮必须调 `onRefresh()`;把组件改回旧接线时该用例失败。此前只有"两个入口存在"的断言,回退后测试仍全绿。 + + 用例:`tests/adapter.test.ts` 新增「凭据解析与图片读取并行」「目录已在内存时不再等一次联网刷新」「超时定级复用已序列化的正文」「同一份工具 schema 只归一化一次」「用量卡片与选择器共享同一份账户事实」「凭据缺失时预算门不抢跑」「双超限一次报数」;`tests/gateway-facts.test.ts` 新增 4 条 `loaded()` 来源判据;`tests/panel.test.ts` 与 `tests/usage-client.test.ts` 新增自动/显式刷新、可见性与任意卸载顺序解绑用例;`tests/accounts-card-render.test.ts` 新增 2 条真实渲染接线用例。**验证边界**:`npm run typecheck` 退出码 0;`npm ci` 干净安装后 `npm test` **1125/1125** 通过;`npm run build` 重建 `lib/`。未在真实宿主中人工验证。 - 用例:`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.4] - 2026-10-24 ### Changed diff --git a/docs/agent-reference/adapter-protocol.md b/docs/agent-reference/adapter-protocol.md index 9bcf949..b82e132 100644 --- a/docs/agent-reference/adapter-protocol.md +++ b/docs/agent-reference/adapter-protocol.md @@ -282,11 +282,18 @@ Task-specific reference moved from the former root `AGENTS.md`. All source and t dozens of screenshots) used to cross the cap once and then fail EVERY later request on this route with HTTP 413, while the same conversation worked on another provider. `stream()` therefore builds every body from a PROJECTED history, never the raw one, and the offload set is a DURABLE session fact: - `withSurfaceOffload()` renders the surface's `offloaded` marks through the engine's - `projectOffloadedImages` (so an evicted image can never be re-sent as pixels) and an over-budget history - FAILS with `IMAGE_OFFLOAD_REQUIRED` + the count from `requiredImageOffload`, which the default + `projectSurfaceBudget()` renders the surface's `offloaded` marks through the engine's + `projectOffloadedImages` (so an evicted image can never be re-sent as pixels) and counts what the byte + budget still needs; `withSurfaceOffload()` is the one-shot form (byte budget + CLI cache gate) used by + the 413 downshift and the protocol fallback, and an over-budget history FAILS with + `IMAGE_OFFLOAD_REQUIRED` + the count from `requiredImageOffload`, which the default `dsh-compaction-image-offload` plugin records as one `image/offload` event and retries — the omission - then survives restore and fork, and the token meter stops counting evicted images as context. Those + then survives restore and fork, and the token meter stops counting evicted images as context. On the + stream path the accounting and the throw are deliberately SEPARATE: the count runs before any I/O (a + history that must commit an offload costs neither a credential round trip nor an image read), but the + ONE error (`max(byte budget, CLI cache gate)`) is raised only after the credential and the real + transport are known — a request that cannot authenticate must report `MISSING_CREDENTIAL` instead of + first making the host offload images irreversibly. Those three symbols are STATIC imports of `@deepseek-ai/dsh-llm` (they are the reason the peer range is pinned so tightly: a named import of an absent export is a link-time failure that takes the whole plugin down, issue #43). `REQUEST_IMAGE_BUDGETS` is a two-rung ladder: rung 0 is the standing budget (32 MiB base64 / diff --git a/docs/agent-reference/client-and-settings.md b/docs/agent-reference/client-and-settings.md index 20971d7..0fd660d 100644 --- a/docs/agent-reference/client-and-settings.md +++ b/docs/agent-reference/client-and-settings.md @@ -14,8 +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`),隐藏时跳过读取但照旧重排定时器;重新可见立即补读一次。首屏那次无条件读取 —— 那一刻没有数据可显示。可见性是可注入接缝,非浏览器环境视为始终可见。停止最后一个挂载时解绑监听。 +- **用量读取的自动与显式路径**:一份用量报告是四次上游调用,而面板与账户卡片都会在无人操作时各自读取。`CommandCodeUsageController.refresh({ auto: true })` 标记「没人点过」的刷新,只有它在 `USAGE_AUTO_REFRESH_COALESCE_MS`(5 秒)内复用刚刚读取的结果;首屏 effect 与面板 tick 都走这条路径。按钮、保存、换/清凭据等显式调用不传 `auto`,不查合并窗口,永远真的去读(请求在途时仍按既有 `inFlight` 规则折叠,不会因点击再发一份)。窗口刻意很小,`fetchedAt` 因此仍如实反映数字被读取的时刻。首屏接线的真实渲染用例见 `tests/accounts-card-render.test.ts`:首屏必须调 `onRefreshAuto()`,刷新按钮必须调 `onRefresh()`。 +- **隐藏页面不轮询**:`startPanelAutoRefresh()` 的 tick 先问 `AutoRefreshVisibility.isVisible()`(默认读 `document.visibilityState` 并订阅 `visibilitychange`),隐藏时跳过读取但照旧重排定时器;重新可见立即补读一次(该补读走 `auto`,5 秒内仍可能被合并)。首屏那次无条件读取 —— 那一刻没有数据可显示。可见性是可注入接缝,非浏览器环境视为始终可见。监听句柄与循环状态同层(模块级),因此启动循环的 surface 先卸载时也照样解绑,任意卸载顺序都不泄漏。 - **运行中请求的配置生效**:已确认的网关、账号列表、固定账号和模型规则用于新调用,已经开始的适配器调用继续使用自己的快照。引用凭据每次账号选择仍重新解析;清除后可能按既有优先级使用默认账号的授权文件兜底,不撤回已发请求或当前尝试已取得的密钥。 - **账户移除与清理**:`accounts`、`modelAccountRules`、固定账户及 `credentialCleanupRefs` 在一次带版本检查的 `remote.settings.mutate` 中提交,再清理凭据。失败队列持久化,页面刷新后仍显示重试;重新引用、继承配置和默认密钥受保护。无法确认凭据事实时保留队列。宿主配置与凭据服务并无共同事务,不能承诺任意外部并发重建与凭据删除跨服务原子化。 diff --git a/docs/agent-reference/model-catalog.md b/docs/agent-reference/model-catalog.md index 94366b3..077f50d 100644 --- a/docs/agent-reference/model-catalog.md +++ b/docs/agent-reference/model-catalog.md @@ -293,12 +293,14 @@ 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. + - **Reading the catalog never blocks on the network once this process has FETCHED it.** `listModels()` asks + `GatewayFacts.loaded()` for rows this process successfully fetched; only a source that never completed a + network refresh awaits `refresh()`. A warm source refreshes in the background for the NEXT open. The + source matters, not just "rows in memory": `snapshot()` loads the on-disk cache into the SAME state for + the generation path, and that disk copy carries no age check — serving it as a first paint would draw a + catalog of unknown vintage. An empty row set (a failed refresh inside its retry window) is not warm data + either; it falls back to `refresh()` instead of painting an empty picker. `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