From e3e79e1741c362765c3490dd53d837e9236f1585 Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:13:42 +1100 Subject: [PATCH 1/4] fix(server): Grok usage counts turns from resumed sessions Co-Authored-By: Claude Opus 5.5 --- apps/server/src/usage/UsageService.test.ts | 2 +- apps/server/src/usage/usageScanCache.test.ts | 23 ++++++++++++++----- apps/server/src/usage/usageScanCache.ts | 7 ++++-- .../usage/usageTranscriptStreaming.test.ts | 2 +- .../provider-grok/src/server/usage.test.ts | 19 +++++++++++++-- packages/provider-grok/src/server/usage.ts | 6 +++-- 6 files changed, 45 insertions(+), 14 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 9cf97cd4c7c4d..aca5e6846a4d3 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -2723,7 +2723,7 @@ describe("UsageService", () => { 60 + 20 * (version === 4 ? 1 : 2), ); yield* upgraded.awaitPersisted; - assert.strictEqual(SCAN_CACHE_FILE_NAME, "usage-scan-cache-v11.json"); + assert.strictEqual(SCAN_CACHE_FILE_NAME, "usage-scan-cache-v12.json"); assert.strictEqual( yield* Effect.promise(() => NodeFSP.readFile(legacyPath, "utf8")), legacy, diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index fa659c4a443b3..12ff72942f3f4 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -249,15 +249,26 @@ describe("scan cache round trip", () => { }, ); - it("re-parses a Claude transcript cached before advisor usage was counted", () => { + it("re-parses Claude and Grok transcripts cached before their usage keys changed", () => { const original = cacheWith([["/claude.jsonl", 100, [record()]]]); - const v9 = { ...JSON.parse(makeScanCacheWriter()(original, {})), version: 9 }; - - const restored = decodeScanCache(JSON.parse(JSON.stringify(v9)), TEST_FORMAT_MAP); - expect(restored.get("/claude.jsonl")).toEqual({ - ...original.get("/claude.jsonl"), + original.set("/grok/updates.jsonl", { + ...original.get("/claude.jsonl")!, + provider: "grok", + records: [record({ provider: "grok", model: "grok-4.5", dedupeKey: "s:p:grok-4.5" })], + }); + const encoded = JSON.parse(makeScanCacheWriter()(original, {})); + const reparse = { size: -1, position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, + }; + + const v9 = decodeScanCache({ ...encoded, version: 9 }, TEST_FORMAT_MAP); + expect(v9.get("/claude.jsonl")).toEqual({ ...original.get("/claude.jsonl"), ...reparse }); + const v11 = decodeScanCache({ ...encoded, version: 11 }, TEST_FORMAT_MAP); + expect(v11.get("/claude.jsonl")).toEqual(original.get("/claude.jsonl")); + expect(v11.get("/grok/updates.jsonl")).toEqual({ + ...original.get("/grok/updates.jsonl"), + ...reparse, }); }); diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index c9032d64c05bd..1ee84af4f9ec6 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -9,7 +9,7 @@ import * as Schema from "effect/Schema"; import { CLAUDE_SYNTHETIC_MODEL } from "../provider/Drivers/claudeUsage.ts"; import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts"; -const USAGE_SCAN_CACHE_VERSION = 11 as const; +const USAGE_SCAN_CACHE_VERSION = 12 as const; const SPEED_COMPATIBLE_SINCE_VERSION = 4; /** @@ -17,8 +17,9 @@ const SPEED_COMPATIBLE_SINCE_VERSION = 4; * replace it, dropping saved usage for deleted transcripts. Separate files keep * both. */ -export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v11.json"; +export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v12.json"; export const LEGACY_SCAN_CACHE_FILE_NAMES = [ + "usage-scan-cache-v11.json", "usage-scan-cache-v10.json", "usage-scan-cache-v9.json", "usage-scan-cache-v8.json", @@ -294,8 +295,10 @@ export function decodeScanCache( // Keep history for deleted rollouts, but re-parse incompatible reducer state. // v9 and v11 repair skipped Codex requests; clean v8+ entries stay served while unchanged. // v10 adds Claude advisor calls, which no older Claude entry holds. + // v12 keys Grok turns by time, restoring turns that older entries dropped as repeats. const legacy = (entry.p === "claude" && version < 10) || + (entry.p === "grok" && version < 12) || (format.state !== undefined && (version < 6 || (entry.p === "codex" && (version < 8 || (version < 11 && malformedRecords > 0))))); diff --git a/apps/server/src/usage/usageTranscriptStreaming.test.ts b/apps/server/src/usage/usageTranscriptStreaming.test.ts index 856ba2ac5b9d7..635b5ae4e6bec 100644 --- a/apps/server/src/usage/usageTranscriptStreaming.test.ts +++ b/apps/server/src/usage/usageTranscriptStreaming.test.ts @@ -254,7 +254,7 @@ describe("large usage records", () => { outputTokens: 99, reasoningTokens: 10, }, - dedupeKey: "s1:p1:grok-4.5-build", + dedupeKey: "s1:p1:1785578400123:grok-4.5-build", reportedCostUsd: 0.25, }); }); diff --git a/packages/provider-grok/src/server/usage.test.ts b/packages/provider-grok/src/server/usage.test.ts index 1ed6585a31f73..867e0ccd6fbb0 100644 --- a/packages/provider-grok/src/server/usage.test.ts +++ b/packages/provider-grok/src/server/usage.test.ts @@ -72,7 +72,9 @@ describe("parseGrokLine", () => { reasoningTokens: 180, }); expect(record?.reportedCostUsd).toBeCloseTo(230_272_000 / GROK_COST_USD_TICKS_PER_DOLLAR, 12); - expect(record?.dedupeKey).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c:prompt-1:grok-4.5-build"); + expect(record?.dedupeKey).toBe( + "019fec1a-12f7-72f2-9b1f-7778a00aea3c:prompt-1:1786372566485:grok-4.5-build", + ); }); it("emits one record per model when modelUsage has several entries", () => { @@ -142,7 +144,9 @@ describe("parseGrokLine", () => { reasoningTokens: 180, }); expect(record?.reportedCostUsd).toBeCloseTo(230_272_000 / GROK_COST_USD_TICKS_PER_DOLLAR, 12); - expect(record?.dedupeKey).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c:prompt-1:grok"); + expect(record?.dedupeKey).toBe( + "019fec1a-12f7-72f2-9b1f-7778a00aea3c:prompt-1:1786372566485:grok", + ); }); it("pro-rates top-level cost ticks across multi-model turns without per-model ticks", () => { @@ -230,6 +234,17 @@ describe("parseGrokLine", () => { expect(parseGrokLine(line)[0]?.dedupeKey).toBeNull(); }); + it("keeps later turns that reuse a prompt id after the session resumes in a new process", () => { + const first = parseGrokLine(turnCompleted({ promptId: "t3-xai-prompt-1" }))[0]; + const resumed = parseGrokLine( + turnCompleted({ promptId: "t3-xai-prompt-1", agentTimestampMs: 1_786_459_000_000 }), + )[0]; + const copied = parseGrokLine(turnCompleted({ promptId: "t3-xai-prompt-1" }))[0]; + + expect(resumed?.dedupeKey).not.toBe(first?.dedupeKey); + expect(copied?.dedupeKey).toBe(first?.dedupeKey); + }); + it("ignores non-turn lines and empty usage", () => { expect(parseGrokLine(JSON.stringify({ method: "session/update", params: {} }))).toEqual([]); expect(parseGrokLine("not json")).toEqual([]); diff --git a/packages/provider-grok/src/server/usage.ts b/packages/provider-grok/src/server/usage.ts index 52d5b600ba091..1e0b75cc796b1 100644 --- a/packages/provider-grok/src/server/usage.ts +++ b/packages/provider-grok/src/server/usage.ts @@ -92,6 +92,7 @@ function parseGrokRecord(parsed: unknown): readonly UsageRecord[] { const usageRecord = usage as Record; const sessionId = typeof paramsRecord["sessionId"] === "string" ? paramsRecord["sessionId"] : ""; + // T3's prompt ids restart with each Grok process, so a resumed session reuses them. const promptId = typeof updateRecord["prompt_id"] === "string" ? updateRecord["prompt_id"] : null; const meta = paramsRecord["_meta"]; @@ -136,7 +137,7 @@ function parseGrokRecord(parsed: unknown): readonly UsageRecord[] { reportedCostUsd: grokCostTicksToUsd(topLevel.costUsdTicks), speed: "standard", // No prompt id means we cannot tell two same-second updates apart. - dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:grok`, + dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:${timestampMs}:grok`, }, ]; } @@ -174,7 +175,8 @@ function parseGrokRecord(parsed: unknown): readonly UsageRecord[] { totals, reportedCostUsd, speed: "standard", - dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:${entry.model}`, + dedupeKey: + promptId === null ? null : `${sessionId}:${promptId}:${timestampMs}:${entry.model}`, }); } return results; From 66c621d010ce4b329683114096600ae53263ea6c Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:17:54 +1100 Subject: [PATCH 2/4] fix(server): carry saved Grok usage keys into the time-keyed form Co-Authored-By: Claude Opus 5.5 --- apps/server/src/usage/usageScanCache.test.ts | 3 +++ apps/server/src/usage/usageScanCache.ts | 8 +++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 12ff72942f3f4..132951586c5c0 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -269,6 +269,9 @@ describe("scan cache round trip", () => { expect(v11.get("/grok/updates.jsonl")).toEqual({ ...original.get("/grok/updates.jsonl"), ...reparse, + records: [ + record({ provider: "grok", model: "grok-4.5", dedupeKey: "s:p:1786000000000:grok-4.5" }), + ], }); }); diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 1ee84af4f9ec6..5dd72ab7acd03 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -258,7 +258,13 @@ export function decodeScanCache( ...(version >= 7 && unresolvedServiceTierTurnId !== undefined ? { unresolvedServiceTierTurnId } : {}), - dedupeKey: typeof dedupeKey === "string" ? dedupeKey : null, + dedupeKey: + typeof dedupeKey !== "string" + ? null + : // Saved copies of moved transcripts must still match the re-parsed v12 key. + provider === "grok" && version < 12 && dedupeKey.endsWith(`:${model}`) + ? `${dedupeKey.slice(0, -model.length)}${timestampMs}:${model}` + : dedupeKey, }); } return records; From 65c0725afd5d41955351565c924ec3385fec2329 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 23:41:40 +0000 Subject: [PATCH 3/4] fix(server): live Grok turns win over saved pre-v17 maxima A deleted transcript's v16 cache holds one channel-maximum record per prompt id that resumed sessions reused. Migrated to the time-keyed form, it shares the first turn's key and max-merged with the live copy, counting the later turn twice. Grok reports each turn once and complete, so keep the first copy; live files aggregate before saved ones. Co-Authored-By: Claude Opus 5.5 --- .../server/src/usage/usageAggregation.test.ts | 20 +++++++++ apps/server/src/usage/usageAggregation.ts | 6 ++- apps/server/src/usage/usageScanCache.test.ts | 45 +++++++++---------- 3 files changed, 47 insertions(+), 24 deletions(-) diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index f99615085bd2a..673b8fcf49d45 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -141,6 +141,26 @@ describe("UsageAggregator", () => { } }); + it("keeps a live Grok turn over a saved pre-v17 maximum that shares its key", () => { + const turn = (timestampMs: number, outputTokens: number) => + record({ + provider: "grok", + model: "grok-4.5", + timestampMs, + dedupeKey: `s:p:${timestampMs}:grok-4.5`, + reportedCostUsd: outputTokens / 10, + totals: { ...record().totals, uncachedInputTokens: 0, outputTokens }, + }); + const first = turn(Date.parse("2026-08-08T10:00:00Z"), 10); + const resumed = turn(Date.parse("2026-08-08T11:00:00Z"), 20); + const savedMaximum = { ...first, totals: resumed.totals, reportedCostUsd: 2 }; + + const result = aggregate([first, resumed, savedMaximum]); + + expect(result.duplicatesDropped).toBe(1); + expect(result.buckets).toEqual(aggregate([first, resumed]).buckets); + }); + it("does not count a key whose first copy fell outside the window", () => { const result = aggregate([ record({ dedupeKey: "msg_1:", timestampMs: Date.parse("2026-07-01T12:00:00Z") }), diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 9ff9cb9369462..246c631e67bba 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -217,7 +217,11 @@ export class UsageAggregator { const held = this.#seen.get(record.dedupeKey); if (held !== undefined) { this.#duplicatesDropped += 1; - if (held.bucket !== null) held.record = mergeUsageCopies(held.record, record); + // Grok reports each turn once, complete. A larger later copy is a deleted transcript's + // pre-v17 maximum over turns that shared a prompt id; live files come first and win. + if (held.bucket !== null && record.provider !== "grok") { + held.record = mergeUsageCopies(held.record, record); + } return false; } } diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index d01efad37d8d3..36864768eb525 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -339,29 +339,28 @@ describe("scan cache round trip", () => { }); }); - it.each( - [11, 12, 13, 14, 15, 16].flatMap((version) => - ["s:p:grok-4.5", "s:p:1786000000000:grok-4.5"].map((dedupeKey) => ({ version, dedupeKey })), - ), - )("re-parses v$version Grok transcripts with key $dedupeKey", ({ version, dedupeKey }) => { - const original = cacheWith([ - ["/grok/updates.jsonl", 100, [record({ provider: "grok", model: "grok-4.5", dedupeKey })]], - ]); - original.set("/grok/updates.jsonl", { - ...original.get("/grok/updates.jsonl")!, - provider: "grok", - }); - const encoded = parse(makeScanCacheWriter()(original, {})); - const restored = decodeScanCache({ ...encoded, version }, TEST_FORMAT_MAP); - expect(restored.get("/grok/updates.jsonl")).toEqual({ - ...original.get("/grok/updates.jsonl"), - size: -1, - position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, - records: [ - record({ provider: "grok", model: "grok-4.5", dedupeKey: "s:p:1786000000000:grok-4.5" }), - ], - }); - }); + it.each(["s:p:grok-4.5", "s:p:1786000000000:grok-4.5"])( + "re-parses v16 Grok transcripts with key %s", + (dedupeKey) => { + const original = cacheWith([ + ["/grok/updates.jsonl", 100, [record({ provider: "grok", model: "grok-4.5", dedupeKey })]], + ]); + original.set("/grok/updates.jsonl", { + ...original.get("/grok/updates.jsonl")!, + provider: "grok", + }); + const encoded = parse(makeScanCacheWriter()(original, {})); + const restored = decodeScanCache({ ...encoded, version: 16 }, TEST_FORMAT_MAP); + expect(restored.get("/grok/updates.jsonl")).toEqual({ + ...original.get("/grok/updates.jsonl"), + size: -1, + position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, + records: [ + record({ provider: "grok", model: "grok-4.5", dedupeKey: "s:p:1786000000000:grok-4.5" }), + ], + }); + }, + ); it("rewrites only changed entries and still restores the whole cache", () => { const write = makeScanCacheWriter(); From fcc9760df9bb4d977d8973db5ab1e4a6a7409070 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 23:57:46 +0000 Subject: [PATCH 4/4] fix(server): Grok copies keep the real turn in any source order Keeping the first Grok copy relied on live files preceding saved history, which holds only within one source directory. Across Grok homes a deleted transcript's migrated pre-v17 maximum could arrive first and win. Real Grok copies are equal and that maximum covers the real turn in every channel, so merge Grok copies by channel minimum. Co-Authored-By: Claude Opus 5.5 --- .../server/src/usage/usageAggregation.test.ts | 14 +++++--- apps/server/src/usage/usageAggregation.ts | 33 ++++++++++++++++--- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 673b8fcf49d45..e7af899653aec 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -141,7 +141,7 @@ describe("UsageAggregator", () => { } }); - it("keeps a live Grok turn over a saved pre-v17 maximum that shares its key", () => { + it("keeps a live Grok turn over a saved pre-v17 maximum in either source order", () => { const turn = (timestampMs: number, outputTokens: number) => record({ provider: "grok", @@ -155,10 +155,16 @@ describe("UsageAggregator", () => { const resumed = turn(Date.parse("2026-08-08T11:00:00Z"), 20); const savedMaximum = { ...first, totals: resumed.totals, reportedCostUsd: 2 }; - const result = aggregate([first, resumed, savedMaximum]); + const expected = aggregate([first, resumed]).buckets; - expect(result.duplicatesDropped).toBe(1); - expect(result.buckets).toEqual(aggregate([first, resumed]).buckets); + for (const order of [ + [first, resumed, savedMaximum], + [savedMaximum, first, resumed], + ]) { + const result = aggregate(order); + expect(result.duplicatesDropped).toBe(1); + expect(result.buckets).toEqual(expected); + } }); it("does not count a key whose first copy fell outside the window", () => { diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 246c631e67bba..649133b00b67b 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -170,6 +170,30 @@ export function mergeUsageCopies(held: UsageRecord, copy: UsageRecord): UsageRec }; } +/** + * Grok reports each turn once, complete, so real copies are equal. A larger one is a deleted + * transcript's pre-v17 maximum over turns that shared a prompt id, whichever source it came from. + */ +function smallestGrokCopy(held: UsageRecord, copy: UsageRecord): UsageRecord { + const a = held.totals; + const b = copy.totals; + const reportedCostUsd = + held.reportedCostUsd === null || copy.reportedCostUsd === null + ? (held.reportedCostUsd ?? copy.reportedCostUsd) + : Math.min(held.reportedCostUsd, copy.reportedCostUsd); + return { + ...held, + totals: { + uncachedInputTokens: Math.min(a.uncachedInputTokens, b.uncachedInputTokens), + cachedInputTokens: Math.min(a.cachedInputTokens, b.cachedInputTokens), + cacheCreationTokens: Math.min(a.cacheCreationTokens, b.cacheCreationTokens), + outputTokens: Math.min(a.outputTokens, b.outputTokens), + reasoningTokens: Math.min(a.reasoningTokens, b.reasoningTokens), + }, + reportedCostUsd, + }; +} + interface Contribution { record: UsageRecord; /** Null when the first copy fell outside the window. */ @@ -217,10 +241,11 @@ export class UsageAggregator { const held = this.#seen.get(record.dedupeKey); if (held !== undefined) { this.#duplicatesDropped += 1; - // Grok reports each turn once, complete. A larger later copy is a deleted transcript's - // pre-v17 maximum over turns that shared a prompt id; live files come first and win. - if (held.bucket !== null && record.provider !== "grok") { - held.record = mergeUsageCopies(held.record, record); + if (held.bucket !== null) { + held.record = + record.provider === "grok" + ? smallestGrokCopy(held.record, record) + : mergeUsageCopies(held.record, record); } return false; }