diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index a1de34fac38bb..740e86dfbbe81 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -2687,7 +2687,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.deepStrictEqual( (yield* Effect.promise(() => NodeFSP.readdir(stateDir))) .filter((name) => name.startsWith("usage-scan-cache")) diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index d0e5eaceec269..55c989f3ee3c0 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -248,15 +248,29 @@ 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 = { ...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 = 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, + 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 c2cdc1cbf5abd..2607943d7e87e 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 * the newer one; older files are removed once it is saved. */ -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", @@ -253,7 +254,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; @@ -290,8 +297,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;