| author | |
| committer | |
| log | e5dca2332fed450c6f1b3c7472666fbe80531c5a |
| tree | 06d561046512d0e98a2e6474bd06990ead410c18 |
| parent | 507361bc7301982279b9f115bbae8f22268ce9d6 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>34 files changed, 1731 insertions(+), 1411 deletions(-)
apps/server/src/provider/Drivers/ClaudeDriver.ts+3-1| ... | ... | @@ -34,6 +34,7 @@ import { |
| 34 | 34 | type ClaudeAdapterV2DriverEnv, |
| 35 | 35 | } from "../../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; |
| 36 | 36 | import { ProviderDriverError } from "../Errors.ts"; |
| 37 | import { claudeUsageReader } from "./claudeUsage.ts"; | |
| 37 | 38 | import { makeClaudeScopedLimitNames } from "../claudeUsageLimits.ts"; |
| 38 | 39 | import * as ClaudeResetCredits from "../claudeResetCredits.ts"; |
| 39 | 40 | import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts"; |
| ... | ... | @@ -107,7 +108,7 @@ export type ClaudeDriverEnv = |
| 107 | 108 | | ProviderEventLoggers.ProviderEventLoggers |
| 108 | 109 | | ServerConfig.ServerConfig; |
| 109 | 110 | |
| 110 | export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { | |
| 111 | export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv, Path.Path> = { | |
| 111 | 112 | driverKind: DRIVER_KIND, |
| 112 | 113 | metadata: { |
| 113 | 114 | displayName: "Claude", |
| ... | ... | @@ -115,6 +116,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { |
| 115 | 116 | }, |
| 116 | 117 | configSchema: ClaudeSettings, |
| 117 | 118 | defaultConfig: (): ClaudeSettings => decodeClaudeSettings({}), |
| 119 | usage: claudeUsageReader, | |
| 118 | 120 | create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => |
| 119 | 121 | Effect.gen(function* () { |
| 120 | 122 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
apps/server/src/provider/Drivers/CodexDriver.ts+3-1| ... | ... | @@ -73,6 +73,7 @@ import { |
| 73 | 73 | resolveCodexHomeLayout, |
| 74 | 74 | } from "./CodexHomeLayout.ts"; |
| 75 | 75 | import { makeManagedCodexProvider } from "./CodexManagedProvider.ts"; |
| 76 | import { codexUsageReader } from "./codexUsage.ts"; | |
| 76 | 77 | import * as CodexInstallation from "../CodexInstallation.ts"; |
| 77 | 78 | import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; |
| 78 | 79 | import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; |
| ... | ... | @@ -125,7 +126,7 @@ export type CodexDriverEnv = |
| 125 | 126 | | ServerEnvironment.ServerEnvironmentIdentity |
| 126 | 127 | | CodexInstallation.CodexInstallation; |
| 127 | 128 | |
| 128 | export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { | |
| 129 | export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Path> = { | |
| 129 | 130 | driverKind: DRIVER_KIND, |
| 130 | 131 | metadata: { |
| 131 | 132 | displayName: "Codex", |
| ... | ... | @@ -133,6 +134,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { |
| 133 | 134 | }, |
| 134 | 135 | configSchema: CodexSettings, |
| 135 | 136 | defaultConfig: (): CodexSettings => decodeCodexSettings({}), |
| 137 | usage: codexUsageReader, | |
| 136 | 138 | create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => |
| 137 | 139 | Effect.gen(function* () { |
| 138 | 140 | if (config.setupMode === "managed") |
apps/server/src/provider/Drivers/claudeUsage.test.ts created+74| ... | ... | @@ -0,0 +1,74 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { parseClaudeLine } from "./claudeUsage.ts"; | |
| 4 | ||
| 5 | /** Shaped after a real Claude Code assistant record. */ | |
| 6 | function claudeLine(overrides: { | |
| 7 | messageId: string; | |
| 8 | contentType: string; | |
| 9 | model?: string; | |
| 10 | outputTokens?: number; | |
| 11 | speed?: string; | |
| 12 | }): string { | |
| 13 | return JSON.stringify({ | |
| 14 | type: "assistant", | |
| 15 | timestamp: "2026-08-07T04:05:13.944Z", | |
| 16 | sessionId: "5a128faa-8253-489e-b935-6c08e8e670c0", | |
| 17 | cwd: "/home/theo/project", | |
| 18 | message: { | |
| 19 | id: overrides.messageId, | |
| 20 | role: "assistant", | |
| 21 | model: overrides.model ?? "claude-fable-5", | |
| 22 | content: [{ type: overrides.contentType }], | |
| 23 | usage: { | |
| 24 | input_tokens: 2, | |
| 25 | cache_creation_input_tokens: 66818, | |
| 26 | cache_read_input_tokens: 1000, | |
| 27 | output_tokens: overrides.outputTokens ?? 286, | |
| 28 | ...(overrides.speed === undefined ? {} : { speed: overrides.speed }), | |
| 29 | }, | |
| 30 | }, | |
| 31 | }); | |
| 32 | } | |
| 33 | ||
| 34 | describe("parseClaudeLine", () => { | |
| 35 | it("extracts token totals and a dedupe key", () => { | |
| 36 | const record = parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text" })); | |
| 37 | ||
| 38 | expect(record).not.toBeNull(); | |
| 39 | expect(record?.provider).toBe("claude"); | |
| 40 | expect(record?.model).toBe("claude-fable-5"); | |
| 41 | expect(record?.totals).toEqual({ | |
| 42 | uncachedInputTokens: 2, | |
| 43 | cachedInputTokens: 1000, | |
| 44 | cacheCreationTokens: 66818, | |
| 45 | outputTokens: 286, | |
| 46 | reasoningTokens: 0, | |
| 47 | }); | |
| 48 | expect(record?.dedupeKey).toBe("msg_1:"); | |
| 49 | expect(record?.speed).toBe("standard"); | |
| 50 | }); | |
| 51 | ||
| 52 | it("marks fast-mode requests", () => { | |
| 53 | const line = (speed: string) => | |
| 54 | parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text", speed })); | |
| 55 | ||
| 56 | expect(line("fast")?.speed).toBe("fast"); | |
| 57 | expect(line("standard")?.speed).toBe("standard"); | |
| 58 | }); | |
| 59 | ||
| 60 | it("gives every content block of one message the same dedupe key", () => { | |
| 61 | // T3 Code writes one record per content block, each repeating the parent | |
| 62 | // message's full usage. Summing them would overcount ~2.4x on real data. | |
| 63 | const text = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "text" })); | |
| 64 | const toolUse = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "tool_use" })); | |
| 65 | ||
| 66 | expect(text?.dedupeKey).toBe(toolUse?.dedupeKey); | |
| 67 | expect(text?.totals).toEqual(toolUse?.totals); | |
| 68 | }); | |
| 69 | ||
| 70 | it("ignores records that are not assistant messages", () => { | |
| 71 | expect(parseClaudeLine(JSON.stringify({ type: "user", message: {} }))).toBeNull(); | |
| 72 | expect(parseClaudeLine("not json")).toBeNull(); | |
| 73 | }); | |
| 74 | }); |
apps/server/src/provider/Drivers/claudeUsage.ts created+120| ... | ... | @@ -0,0 +1,120 @@ |
| 1 | /** | |
| 2 | * Usage history for Claude Code: the transcript format of its | |
| 3 | * `<home>/projects/**.jsonl` session files and where each instance keeps them. | |
| 4 | * | |
| 5 | * @module provider/Drivers/claudeUsage | |
| 6 | */ | |
| 7 | import * as NodeOS from "node:os"; | |
| 8 | ||
| 9 | import type { ClaudeSettings } from "@t3tools/contracts"; | |
| 10 | import * as Effect from "effect/Effect"; | |
| 11 | import * as Path from "effect/Path"; | |
| 12 | ||
| 13 | import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; | |
| 14 | import { | |
| 15 | parseTimestampMs, | |
| 16 | tokenCount, | |
| 17 | type ProviderUsageReader, | |
| 18 | type TranscriptUsageFormat, | |
| 19 | type UsageRecord, | |
| 20 | } from "@t3tools/provider-core/server/usage"; | |
| 21 | ||
| 22 | /** | |
| 23 | * Parses one line of a Claude Code transcript. | |
| 24 | * | |
| 25 | * T3 Code writes one record per assistant *content block*, and every one of | |
| 26 | * those records repeats the same complete `usage` object for the parent | |
| 27 | * message. Summing them overcounts by roughly 2.4x on a real workload, so the | |
| 28 | * caller must drop repeats by `dedupeKey` and keep the first. | |
| 29 | */ | |
| 30 | export function parseClaudeLine(line: string): UsageRecord | null { | |
| 31 | let parsed: unknown; | |
| 32 | try { | |
| 33 | parsed = JSON.parse(line); | |
| 34 | } catch { | |
| 35 | return null; | |
| 36 | } | |
| 37 | return parseClaudeRecord(parsed); | |
| 38 | } | |
| 39 | ||
| 40 | function parseClaudeRecord(parsed: unknown): UsageRecord | null { | |
| 41 | if (typeof parsed !== "object" || parsed === null) return null; | |
| 42 | ||
| 43 | const record = parsed as Record<string, unknown>; | |
| 44 | if (record["type"] !== "assistant") return null; | |
| 45 | ||
| 46 | const message = record["message"]; | |
| 47 | if (typeof message !== "object" || message === null) return null; | |
| 48 | const messageRecord = message as Record<string, unknown>; | |
| 49 | ||
| 50 | const usage = messageRecord["usage"]; | |
| 51 | if (typeof usage !== "object" || usage === null) return null; | |
| 52 | const usageRecord = usage as Record<string, unknown>; | |
| 53 | ||
| 54 | const timestampMs = parseTimestampMs(record["timestamp"]); | |
| 55 | if (timestampMs === null) return null; | |
| 56 | ||
| 57 | const model = typeof messageRecord["model"] === "string" ? messageRecord["model"] : ""; | |
| 58 | if (model.length === 0) return null; | |
| 59 | ||
| 60 | const messageId = typeof messageRecord["id"] === "string" ? messageRecord["id"] : null; | |
| 61 | const requestId = typeof record["requestId"] === "string" ? record["requestId"] : null; | |
| 62 | // Matches ccusage: prefer the message/request pair, fall back to whichever | |
| 63 | // half exists. Records with neither cannot be de-duplicated. | |
| 64 | const dedupeKey = | |
| 65 | messageId === null && requestId === null ? null : `${messageId ?? ""}:${requestId ?? ""}`; | |
| 66 | ||
| 67 | const cost = record["costUSD"]; | |
| 68 | ||
| 69 | return { | |
| 70 | provider: "claude", | |
| 71 | timestampMs, | |
| 72 | model, | |
| 73 | sessionId: typeof record["sessionId"] === "string" ? record["sessionId"] : "", | |
| 74 | totals: { | |
| 75 | uncachedInputTokens: tokenCount(usageRecord["input_tokens"]), | |
| 76 | cachedInputTokens: tokenCount(usageRecord["cache_read_input_tokens"]), | |
| 77 | cacheCreationTokens: tokenCount(usageRecord["cache_creation_input_tokens"]), | |
| 78 | outputTokens: tokenCount(usageRecord["output_tokens"]), | |
| 79 | // Anthropic folds thinking tokens into output and does not break them out. | |
| 80 | reasoningTokens: 0, | |
| 81 | }, | |
| 82 | reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null, | |
| 83 | speed: usageRecord["speed"] === "fast" ? "fast" : "standard", | |
| 84 | dedupeKey, | |
| 85 | }; | |
| 86 | } | |
| 87 | ||
| 88 | const orEmpty = (record: UsageRecord | null): readonly UsageRecord[] => | |
| 89 | record === null ? [] : [record]; | |
| 90 | ||
| 91 | export const claudeUsageFormat: TranscriptUsageFormat<void> = { | |
| 92 | // Keeps the dedupe and cost metadata. The `usage` subtree keeps future token fields. | |
| 93 | selectFields: { | |
| 94 | type: true, | |
| 95 | timestamp: true, | |
| 96 | requestId: true, | |
| 97 | sessionId: true, | |
| 98 | costUSD: true, | |
| 99 | message: { id: true, model: true, usage: true }, | |
| 100 | }, | |
| 101 | mightCarryUsage: (line) => line.includes('"usage"'), | |
| 102 | parseLine: (line) => orEmpty(parseClaudeLine(line)), | |
| 103 | parseProjected: (projected) => orEmpty(parseClaudeRecord(projected)), | |
| 104 | }; | |
| 105 | ||
| 106 | export const claudeUsageReader: ProviderUsageReader<ClaudeSettings, Path.Path> = { | |
| 107 | kind: "transcripts", | |
| 108 | provider: "claude", | |
| 109 | format: claudeUsageFormat, | |
| 110 | directories: Effect.fn("claudeUsageReader.directories")(function* ({ config, environment }) { | |
| 111 | // An undecodable config has no trustworthy home to read. | |
| 112 | if (config === undefined) return []; | |
| 113 | const path = yield* Path.Path; | |
| 114 | const configured = config.homePath.trim(); | |
| 115 | const home = configured | |
| 116 | ? expandHomePath(configured) | |
| 117 | : environment.CLAUDE_CONFIG_DIR?.trim() || path.join(NodeOS.homedir(), ".claude"); | |
| 118 | return [{ dir: path.resolve(home, "projects") }]; | |
| 119 | }), | |
| 120 | }; |
apps/server/src/provider/Drivers/codexUsage.test.ts created+193| ... | ... | @@ -0,0 +1,193 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { initialCodexScanState, parseCodexLine } from "./codexUsage.ts"; | |
| 4 | ||
| 5 | describe("parseCodexLine", () => { | |
| 6 | const sessionMeta = JSON.stringify({ | |
| 7 | type: "session_meta", | |
| 8 | timestamp: "2026-08-01T05:17:41.289Z", | |
| 9 | payload: { type: "session_meta", id: "019fbbc1-b12c-7360-a685-28c181f0025f" }, | |
| 10 | }); | |
| 11 | const turnContext = JSON.stringify({ | |
| 12 | type: "turn_context", | |
| 13 | timestamp: "2026-08-01T05:17:42.694Z", | |
| 14 | payload: { type: "turn_context", model: "gpt-5.6-sol" }, | |
| 15 | }); | |
| 16 | const tokenCount = (inputTokens: number, cached: number, output: number, reasoning: number) => | |
| 17 | JSON.stringify({ | |
| 18 | type: "event_msg", | |
| 19 | timestamp: "2026-08-01T05:17:49.919Z", | |
| 20 | payload: { | |
| 21 | type: "token_count", | |
| 22 | info: { | |
| 23 | last_token_usage: { | |
| 24 | input_tokens: inputTokens, | |
| 25 | cached_input_tokens: cached, | |
| 26 | cache_write_input_tokens: 0, | |
| 27 | output_tokens: output, | |
| 28 | reasoning_output_tokens: reasoning, | |
| 29 | }, | |
| 30 | }, | |
| 31 | }, | |
| 32 | }); | |
| 33 | ||
| 34 | it("attributes usage to the model from the preceding turn context", () => { | |
| 35 | const state = initialCodexScanState(); | |
| 36 | parseCodexLine(sessionMeta, state); | |
| 37 | parseCodexLine(turnContext, state); | |
| 38 | const record = parseCodexLine(tokenCount(19239, 11008, 299, 116), state); | |
| 39 | ||
| 40 | expect(record?.provider).toBe("codex"); | |
| 41 | expect(record?.model).toBe("gpt-5.6-sol"); | |
| 42 | expect(record?.sessionId).toBe("019fbbc1-b12c-7360-a685-28c181f0025f"); | |
| 43 | // Codex reports input_tokens inclusive of the cached portion. | |
| 44 | expect(record?.totals.uncachedInputTokens).toBe(19239 - 11008); | |
| 45 | expect(record?.totals.cachedInputTokens).toBe(11008); | |
| 46 | expect(record?.totals.reasoningTokens).toBe(116); | |
| 47 | }); | |
| 48 | ||
| 49 | it("skips a repeated token_count so deltas are not double counted", () => { | |
| 50 | const state = initialCodexScanState(); | |
| 51 | parseCodexLine(turnContext, state); | |
| 52 | const first = parseCodexLine(tokenCount(100, 0, 10, 0), state); | |
| 53 | const repeat = parseCodexLine(tokenCount(100, 0, 10, 0), state); | |
| 54 | ||
| 55 | expect(first).not.toBeNull(); | |
| 56 | expect(repeat).toBeNull(); | |
| 57 | }); | |
| 58 | ||
| 59 | it("drops usage that arrives before any model is known", () => { | |
| 60 | const state = initialCodexScanState(); | |
| 61 | expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).toBeNull(); | |
| 62 | }); | |
| 63 | ||
| 64 | it("does not let a pre-model event poison the duplicate signature", () => { | |
| 65 | // A token_count before its turn_context is dropped; the identical event | |
| 66 | // re-emitted once the model is known must still be counted. | |
| 67 | const state = initialCodexScanState(); | |
| 68 | expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).toBeNull(); | |
| 69 | parseCodexLine(turnContext, state); | |
| 70 | expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).not.toBeNull(); | |
| 71 | }); | |
| 72 | ||
| 73 | it("carries the service tier from the latest thread settings", () => { | |
| 74 | const settings = (thread_settings: Record<string, unknown>) => | |
| 75 | JSON.stringify({ | |
| 76 | type: "event_msg", | |
| 77 | timestamp: "2026-08-01T05:17:42.000Z", | |
| 78 | payload: { type: "thread_settings_applied", thread_settings }, | |
| 79 | }); | |
| 80 | const state = initialCodexScanState(); | |
| 81 | parseCodexLine(turnContext, state); | |
| 82 | const speedAfter = (line: string, output: number) => { | |
| 83 | parseCodexLine(line, state); | |
| 84 | return parseCodexLine(tokenCount(100, 0, output, 0), state)?.speed; | |
| 85 | }; | |
| 86 | ||
| 87 | expect(parseCodexLine(tokenCount(100, 0, 1, 0), state)?.speed).toBe("standard"); | |
| 88 | expect(speedAfter(settings({ service_tier: "ultrafast" }), 2)).toBe("ultrafast"); | |
| 89 | expect(speedAfter(settings({ service_tier: "priority" }), 3)).toBe("fast"); | |
| 90 | // Codex omits the field when no tier was requested. | |
| 91 | expect(speedAfter(settings({ model: "gpt-6-astra" }), 4)).toBe("standard"); | |
| 92 | }); | |
| 93 | ||
| 94 | // A forked/subagent rollout opens with the parent's history copied in and | |
| 95 | // every line re-stamped to the fork instant, then the ancestors' session | |
| 96 | // metas. Counting those again multiplied usage ~1.85x on real data (#5758). | |
| 97 | describe("forked rollouts", () => { | |
| 98 | const meta = (overrides: { | |
| 99 | id: string; | |
| 100 | timestamp: string; | |
| 101 | forkedFromId?: string; | |
| 102 | spawnParentId?: string; | |
| 103 | }) => | |
| 104 | JSON.stringify({ | |
| 105 | type: "session_meta", | |
| 106 | timestamp: overrides.timestamp, | |
| 107 | payload: { | |
| 108 | type: "session_meta", | |
| 109 | id: overrides.id, | |
| 110 | ...(overrides.forkedFromId === undefined | |
| 111 | ? {} | |
| 112 | : { forked_from_id: overrides.forkedFromId }), | |
| 113 | ...(overrides.spawnParentId === undefined | |
| 114 | ? {} | |
| 115 | : { | |
| 116 | source: { | |
| 117 | subagent: { thread_spawn: { parent_thread_id: overrides.spawnParentId } }, | |
| 118 | }, | |
| 119 | }), | |
| 120 | }, | |
| 121 | }); | |
| 122 | const stamped = (timestamp: string, line: string) => { | |
| 123 | const parsed = JSON.parse(line) as { timestamp: string }; | |
| 124 | parsed.timestamp = timestamp; | |
| 125 | return JSON.stringify(parsed); | |
| 126 | }; | |
| 127 | ||
| 128 | it("keeps the child session id over copied ancestor metas", () => { | |
| 129 | const state = initialCodexScanState(); | |
| 130 | parseCodexLine(meta({ id: "child", timestamp: "2026-08-01T05:00:00.000Z" }), state); | |
| 131 | parseCodexLine(meta({ id: "parent", timestamp: "2026-08-01T05:00:00.000Z" }), state); | |
| 132 | parseCodexLine(turnContext, state); | |
| 133 | const record = parseCodexLine(tokenCount(100, 0, 10, 0), state); | |
| 134 | ||
| 135 | expect(record?.sessionId).toBe("child"); | |
| 136 | }); | |
| 137 | ||
| 138 | it("drops the re-stamped copied burst and keeps the first real event", () => { | |
| 139 | const state = initialCodexScanState(); | |
| 140 | const forkInstant = "2026-08-01T05:00:00.000Z"; | |
| 141 | parseCodexLine(meta({ id: "child", timestamp: forkInstant, forkedFromId: "parent" }), state); | |
| 142 | parseCodexLine(meta({ id: "parent", timestamp: forkInstant }), state); | |
| 143 | parseCodexLine(stamped(forkInstant, turnContext), state); | |
| 144 | ||
| 145 | // Copied history: written in one burst at the fork instant. | |
| 146 | expect( | |
| 147 | parseCodexLine(stamped("2026-08-01T05:00:00.001Z", tokenCount(100, 0, 10, 0)), state), | |
| 148 | ).toBeNull(); | |
| 149 | expect( | |
| 150 | parseCodexLine(stamped("2026-08-01T05:00:00.002Z", tokenCount(200, 0, 20, 0)), state), | |
| 151 | ).toBeNull(); | |
| 152 | ||
| 153 | // The child's first genuine turn lands seconds later and must count. | |
| 154 | const real = parseCodexLine( | |
| 155 | stamped("2026-08-01T05:00:06.000Z", tokenCount(300, 0, 30, 0)), | |
| 156 | state, | |
| 157 | ); | |
| 158 | expect(real).not.toBeNull(); | |
| 159 | expect(real?.totals.outputTokens).toBe(30); | |
| 160 | ||
| 161 | // Suppression never restarts, even for closely spaced later events. | |
| 162 | const next = parseCodexLine( | |
| 163 | stamped("2026-08-01T05:00:06.100Z", tokenCount(400, 0, 40, 0)), | |
| 164 | state, | |
| 165 | ); | |
| 166 | expect(next).not.toBeNull(); | |
| 167 | }); | |
| 168 | ||
| 169 | it("recognizes subagent spawns without forked_from_id", () => { | |
| 170 | const state = initialCodexScanState(); | |
| 171 | const spawnInstant = "2026-08-01T05:00:00.000Z"; | |
| 172 | parseCodexLine( | |
| 173 | meta({ id: "child", timestamp: spawnInstant, spawnParentId: "parent" }), | |
| 174 | state, | |
| 175 | ); | |
| 176 | parseCodexLine(stamped(spawnInstant, turnContext), state); | |
| 177 | expect( | |
| 178 | parseCodexLine(stamped("2026-08-01T05:00:00.001Z", tokenCount(100, 0, 10, 0)), state), | |
| 179 | ).toBeNull(); | |
| 180 | }); | |
| 181 | ||
| 182 | it("does not suppress anything in a rollout that is not a fork", () => { | |
| 183 | const state = initialCodexScanState(); | |
| 184 | parseCodexLine(meta({ id: "root", timestamp: "2026-08-01T05:00:00.000Z" }), state); | |
| 185 | parseCodexLine(stamped("2026-08-01T05:00:00.100Z", turnContext), state); | |
| 186 | const record = parseCodexLine( | |
| 187 | stamped("2026-08-01T05:00:00.200Z", tokenCount(100, 0, 10, 0)), | |
| 188 | state, | |
| 189 | ); | |
| 190 | expect(record).not.toBeNull(); | |
| 191 | }); | |
| 192 | }); | |
| 193 | }); |
apps/server/src/provider/Drivers/codexUsage.ts created+265| ... | ... | @@ -0,0 +1,265 @@ |
| 1 | /** | |
| 2 | * Usage history for Codex: the rollout format of `<home>/sessions/**.jsonl` | |
| 3 | * and where each instance's shared home keeps them. | |
| 4 | * | |
| 5 | * @module provider/Drivers/codexUsage | |
| 6 | */ | |
| 7 | import type { CodexSettings } from "@t3tools/contracts"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | import * as Path from "effect/Path"; | |
| 10 | import * as Schema from "effect/Schema"; | |
| 11 | ||
| 12 | import { | |
| 13 | parseTimestampMs, | |
| 14 | tokenCount, | |
| 15 | totalTokens, | |
| 16 | type ProviderUsageReader, | |
| 17 | type TranscriptUsageFormat, | |
| 18 | type UsageRecord, | |
| 19 | type UsageSpeed, | |
| 20 | } from "@t3tools/provider-core/server/usage"; | |
| 21 | import type { UsageTokenTotals } from "@t3tools/contracts"; | |
| 22 | ||
| 23 | import { resolveCodexHomeLayout } from "./CodexHomeLayout.ts"; | |
| 24 | ||
| 25 | /** | |
| 26 | * Rolling state for a single Codex rollout file. | |
| 27 | * | |
| 28 | * Codex `token_count` events carry no model or service tier, so both are | |
| 29 | * carried forward: the model from the most recent `turn_context`, the tier from | |
| 30 | * the most recent `thread_settings_applied`. Sessions that switch either | |
| 31 | * mid-run attribute correctly from the switch onward. | |
| 32 | * | |
| 33 | * Persisted in the scan cache at each resume point. The field order is the | |
| 34 | * stored JSON's, so keep it stable. | |
| 35 | */ | |
| 36 | export const CodexScanState = Schema.Struct({ | |
| 37 | model: Schema.mutableKey(Schema.String), | |
| 38 | speed: Schema.mutableKey(Schema.Literals(["standard", "fast", "ultrafast"])), | |
| 39 | sessionId: Schema.mutableKey(Schema.String), | |
| 40 | lastUsageSignature: Schema.mutableKey(Schema.NullOr(Schema.String)), | |
| 41 | sawSessionMeta: Schema.mutableKey(Schema.Boolean), | |
| 42 | /** While true, leading usage events are re-stamped copies of parent history. */ | |
| 43 | suppressingForkCopies: Schema.mutableKey(Schema.Boolean), | |
| 44 | forkCopyAnchorMs: Schema.mutableKey(Schema.Finite), | |
| 45 | }); | |
| 46 | export type CodexScanState = typeof CodexScanState.Type; | |
| 47 | ||
| 48 | export function initialCodexScanState(): CodexScanState { | |
| 49 | return { | |
| 50 | model: "", | |
| 51 | speed: "standard", | |
| 52 | sessionId: "", | |
| 53 | lastUsageSignature: null, | |
| 54 | sawSessionMeta: false, | |
| 55 | suppressingForkCopies: false, | |
| 56 | forkCopyAnchorMs: 0, | |
| 57 | }; | |
| 58 | } | |
| 59 | ||
| 60 | /** | |
| 61 | * A forked or subagent rollout opens with the parent's full history copied in, | |
| 62 | * every line re-stamped to the fork instant. Those copies are written in one | |
| 63 | * synchronous burst (observed gaps 0-40ms), while the child's first genuine | |
| 64 | * usage event only lands after a real model turn (observed 5s+). One second of | |
| 65 | * separation splits the two cleanly; `ccusage` uses the same threshold. | |
| 66 | */ | |
| 67 | const FORK_COPY_MAX_GAP_MS = 1000; | |
| 68 | ||
| 69 | /** Whether a `session_meta` payload marks the rollout as a fork or subagent. */ | |
| 70 | function isForkedSessionMeta(payload: Record<string, unknown>): boolean { | |
| 71 | if (typeof payload["forked_from_id"] === "string") return true; | |
| 72 | const source = payload["source"]; | |
| 73 | if (typeof source !== "object" || source === null) return false; | |
| 74 | const subagent = (source as Record<string, unknown>)["subagent"]; | |
| 75 | if (typeof subagent !== "object" || subagent === null) return false; | |
| 76 | const spawn = (subagent as Record<string, unknown>)["thread_spawn"]; | |
| 77 | if (typeof spawn !== "object" || spawn === null) return false; | |
| 78 | return typeof (spawn as Record<string, unknown>)["parent_thread_id"] === "string"; | |
| 79 | } | |
| 80 | ||
| 81 | /** | |
| 82 | * Feeds one line of a Codex rollout into `state`, returning a record when the | |
| 83 | * line was a usage event. | |
| 84 | * | |
| 85 | * Deltas come from `last_token_usage`. Summing those across a session | |
| 86 | * reconciles with the session's final `total_token_usage`, provided | |
| 87 | * consecutive duplicate events are dropped, which this does. | |
| 88 | */ | |
| 89 | export function parseCodexLine(line: string, state: CodexScanState): UsageRecord | null { | |
| 90 | let parsed: unknown; | |
| 91 | try { | |
| 92 | parsed = JSON.parse(line); | |
| 93 | } catch { | |
| 94 | return null; | |
| 95 | } | |
| 96 | return parseCodexRecord(parsed, state); | |
| 97 | } | |
| 98 | ||
| 99 | function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | null { | |
| 100 | if (typeof parsed !== "object" || parsed === null) return null; | |
| 101 | ||
| 102 | const record = parsed as Record<string, unknown>; | |
| 103 | const payload = record["payload"]; | |
| 104 | if (typeof payload !== "object" || payload === null) return null; | |
| 105 | const payloadRecord = payload as Record<string, unknown>; | |
| 106 | const payloadType = payloadRecord["type"]; | |
| 107 | ||
| 108 | if (record["type"] === "session_meta") { | |
| 109 | // Only the first meta describes this file's own session. A forked rollout | |
| 110 | // repeats the ancestors' metas right after it; letting those through would | |
| 111 | // reassign every subsequent record to an ancestor session. | |
| 112 | if (state.sawSessionMeta) return null; | |
| 113 | state.sawSessionMeta = true; | |
| 114 | const id = payloadRecord["id"] ?? payloadRecord["session_id"]; | |
| 115 | if (typeof id === "string") state.sessionId = id; | |
| 116 | const metaTimestampMs = parseTimestampMs(record["timestamp"]); | |
| 117 | if (metaTimestampMs !== null && isForkedSessionMeta(payloadRecord)) { | |
| 118 | state.suppressingForkCopies = true; | |
| 119 | state.forkCopyAnchorMs = metaTimestampMs; | |
| 120 | } | |
| 121 | return null; | |
| 122 | } | |
| 123 | ||
| 124 | if (record["type"] === "turn_context") { | |
| 125 | if (typeof payloadRecord["model"] === "string") state.model = payloadRecord["model"]; | |
| 126 | return null; | |
| 127 | } | |
| 128 | ||
| 129 | if (payloadType === "thread_settings_applied") { | |
| 130 | const settings = payloadRecord["thread_settings"]; | |
| 131 | if (typeof settings === "object" && settings !== null) { | |
| 132 | state.speed = codexSpeed((settings as Record<string, unknown>)["service_tier"]); | |
| 133 | } | |
| 134 | return null; | |
| 135 | } | |
| 136 | ||
| 137 | if (payloadType !== "token_count") return null; | |
| 138 | ||
| 139 | const info = payloadRecord["info"]; | |
| 140 | if (typeof info !== "object" || info === null) return null; | |
| 141 | const last = (info as Record<string, unknown>)["last_token_usage"]; | |
| 142 | if (typeof last !== "object" || last === null) return null; | |
| 143 | const lastRecord = last as Record<string, unknown>; | |
| 144 | ||
| 145 | // Only an event that is otherwise eligible may consume the duplicate | |
| 146 | // signature. A token_count arriving before its turn_context (no model yet) | |
| 147 | // must not poison it, or the re-emitted copy after the model is known would | |
| 148 | // be skipped as a duplicate and those tokens never counted. | |
| 149 | const timestampMs = parseTimestampMs(record["timestamp"]); | |
| 150 | if (timestampMs === null) return null; | |
| 151 | if (state.model.length === 0) return null; | |
| 152 | ||
| 153 | // Codex re-emits an unchanged token_count on some stream boundaries. Summing | |
| 154 | // those would double count, so identical consecutive payloads are skipped. | |
| 155 | const signature = JSON.stringify(lastRecord); | |
| 156 | if (signature === state.lastUsageSignature) return null; | |
| 157 | state.lastUsageSignature = signature; | |
| 158 | ||
| 159 | // In a forked rollout the copied parent history was already counted from the | |
| 160 | // parent's own file. Drop the leading burst; the first usage event separated | |
| 161 | // from its predecessor by a real turn's worth of time ends it for good. | |
| 162 | if (state.suppressingForkCopies) { | |
| 163 | if (timestampMs - state.forkCopyAnchorMs < FORK_COPY_MAX_GAP_MS) { | |
| 164 | state.forkCopyAnchorMs = timestampMs; | |
| 165 | return null; | |
| 166 | } | |
| 167 | state.suppressingForkCopies = false; | |
| 168 | } | |
| 169 | ||
| 170 | const inputTokens = tokenCount(lastRecord["input_tokens"]); | |
| 171 | const cachedInputTokens = tokenCount(lastRecord["cached_input_tokens"]); | |
| 172 | const cacheCreationTokens = tokenCount(lastRecord["cache_write_input_tokens"]); | |
| 173 | const outputTokens = tokenCount(lastRecord["output_tokens"]); | |
| 174 | ||
| 175 | const totals: UsageTokenTotals = { | |
| 176 | // Codex reports `input_tokens` inclusive of the cached portion. | |
| 177 | uncachedInputTokens: Math.max(0, inputTokens - cachedInputTokens - cacheCreationTokens), | |
| 178 | cachedInputTokens, | |
| 179 | cacheCreationTokens, | |
| 180 | outputTokens, | |
| 181 | // Reported inside output_tokens, surfaced separately for the token mix. | |
| 182 | reasoningTokens: Math.min(outputTokens, tokenCount(lastRecord["reasoning_output_tokens"])), | |
| 183 | }; | |
| 184 | ||
| 185 | if (totalTokens(totals) === 0) return null; | |
| 186 | ||
| 187 | return { | |
| 188 | provider: "codex", | |
| 189 | timestampMs, | |
| 190 | model: state.model, | |
| 191 | sessionId: state.sessionId, | |
| 192 | totals, | |
| 193 | // Codex does not report cost in the rollout. | |
| 194 | reportedCostUsd: null, | |
| 195 | speed: state.speed, | |
| 196 | // Events surviving the fork-copy suppression above are unique to this | |
| 197 | // rollout, so they need no global dedup. | |
| 198 | dedupeKey: null, | |
| 199 | }; | |
| 200 | } | |
| 201 | ||
| 202 | /** | |
| 203 | * Maps a Codex `service_tier` to its billing speed. Codex omits the field when | |
| 204 | * no tier was requested, which bills as standard, as do `default` and | |
| 205 | * `standard`. `fast` is accepted as an alias of `priority`. | |
| 206 | */ | |
| 207 | function codexSpeed(serviceTier: unknown): UsageSpeed { | |
| 208 | if (serviceTier === "priority" || serviceTier === "fast") return "fast"; | |
| 209 | if (serviceTier === "ultrafast") return "ultrafast"; | |
| 210 | return "standard"; | |
| 211 | } | |
| 212 | ||
| 213 | const orEmpty = (record: UsageRecord | null): readonly UsageRecord[] => | |
| 214 | record === null ? [] : [record]; | |
| 215 | ||
| 216 | export const codexUsageFormat: TranscriptUsageFormat<CodexScanState> = { | |
| 217 | // Keeps the fields the reducer reads as well as the usage itself. | |
| 218 | selectFields: { | |
| 219 | type: true, | |
| 220 | timestamp: true, | |
| 221 | payload: { | |
| 222 | type: true, | |
| 223 | id: true, | |
| 224 | session_id: true, | |
| 225 | model: true, | |
| 226 | thread_settings: { service_tier: true }, | |
| 227 | forked_from_id: true, | |
| 228 | source: { subagent: { thread_spawn: { parent_thread_id: true } } }, | |
| 229 | info: { last_token_usage: true }, | |
| 230 | }, | |
| 231 | }, | |
| 232 | // `turn_context`, `thread_settings_applied` and `session_meta` lines hold no | |
| 233 | // usage, but they carry the model, tier and session the reducer needs. | |
| 234 | mightCarryUsage: (line) => | |
| 235 | line.includes('"token_count"') || | |
| 236 | line.includes('"turn_context"') || | |
| 237 | line.includes('"thread_settings_applied"') || | |
| 238 | line.includes('"session_meta"'), | |
| 239 | parseLine: (line, state) => orEmpty(parseCodexLine(line, state)), | |
| 240 | parseProjected: (projected, state) => orEmpty(parseCodexRecord(projected, state)), | |
| 241 | state: { initial: initialCodexScanState, schema: CodexScanState }, | |
| 242 | // A rollout that moved after it was read leaves a copy in both places. | |
| 243 | sharedSessionsAcrossFiles: true, | |
| 244 | }; | |
| 245 | ||
| 246 | export const codexUsageReader: ProviderUsageReader<CodexSettings, Path.Path> = { | |
| 247 | kind: "transcripts", | |
| 248 | provider: "codex", | |
| 249 | format: codexUsageFormat, | |
| 250 | directories: Effect.fn("codexUsageReader.directories")(function* ({ config, environment }) { | |
| 251 | // An undecodable config has no trustworthy home to read. | |
| 252 | if (config === undefined) return []; | |
| 253 | const path = yield* Path.Path; | |
| 254 | const environmentHome = environment.CODEX_HOME?.trim(); | |
| 255 | const layout = yield* resolveCodexHomeLayout( | |
| 256 | config.setupMode !== "managed" && | |
| 257 | !config.homePath.trim() && | |
| 258 | !config.shadowHomePath.trim() && | |
| 259 | environmentHome | |
| 260 | ? { ...config, homePath: environmentHome } | |
| 261 | : config, | |
| 262 | ); | |
| 263 | return [{ dir: path.resolve(layout.sharedHomePath, "sessions") }]; | |
| 264 | }), | |
| 265 | }; |
apps/server/src/provider/builtInDrivers.ts+19-1| ... | ... | @@ -32,7 +32,10 @@ import { GrokDriver, type GrokDriverEnv } from "@t3tools/provider-grok/server"; |
| 32 | 32 | import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server"; |
| 33 | 33 | import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server"; |
| 34 | 34 | import { PiDriver, type PiDriverEnv } from "@t3tools/provider-pi/server"; |
| 35 | import type { AnyProviderDriver } from "@t3tools/provider-core/server/driver"; | |
| 35 | import type { | |
| 36 | AnyProviderDriver, | |
| 37 | ProviderUsageReaderEnv, | |
| 38 | } from "@t3tools/provider-core/server/driver"; | |
| 36 | 39 | |
| 37 | 40 | /** |
| 38 | 41 | * Union of infrastructure services required to construct any built-in |
| ... | ... | @@ -66,3 +69,18 @@ export const BUILT_IN_DRIVERS: ReadonlyArray<AnyProviderDriver<BuiltInDriversEnv |
| 66 | 69 | MuseDriver, |
| 67 | 70 | AcpRegistryDriver, |
| 68 | 71 | ]; |
| 72 | ||
| 73 | /** Services the built-in usage readers need. */ | |
| 74 | export type BuiltInUsageReadersEnv = | |
| 75 | | ProviderUsageReaderEnv<typeof ClaudeDriver> | |
| 76 | | ProviderUsageReaderEnv<typeof CodexDriver> | |
| 77 | | ProviderUsageReaderEnv<typeof GrokDriver>; | |
| 78 | ||
| 79 | /** | |
| 80 | * The drivers that keep usage history, in the order the usage page reads | |
| 81 | * them: transcript readers first, then scan readers. Aggregation keeps the | |
| 82 | * first copy of a duplicate record, so the order is part of the result. | |
| 83 | */ | |
| 84 | export const BUILT_IN_USAGE_DRIVERS: ReadonlyArray< | |
| 85 | AnyProviderDriver<BuiltInDriversEnv, BuiltInUsageReadersEnv> | |
| 86 | > = [ClaudeDriver, CodexDriver, GrokDriver]; |
apps/server/src/usage/UsageService.test.ts+3-1| ... | ... | @@ -33,7 +33,7 @@ import { HttpClient, HttpClientResponse } from "effect/http"; |
| 33 | 33 | import * as ServerConfig from "../config.ts"; |
| 34 | 34 | import * as ServerSettings from "../serverSettings.ts"; |
| 35 | 35 | import * as CursorUsageReader from "./cursorUsageReader.ts"; |
| 36 | import type { UsageRecord } from "./usageTranscripts.ts"; | |
| 36 | import type { UsageRecord } from "@t3tools/provider-core/server/usage"; | |
| 37 | 37 | import * as UsageService from "./UsageService.ts"; |
| 38 | 38 | |
| 39 | 39 | const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); |
| ... | ... | @@ -805,6 +805,8 @@ describe("UsageService", () => { |
| 805 | 805 | }, |
| 806 | 806 | [ProviderInstanceId.make("grok-work")]: { |
| 807 | 807 | driver: ProviderDriverKind.make("grok"), |
| 808 | // An undecodable config must not hide history Grok reads by home alone. | |
| 809 | config: { customModels: "not-a-list" }, | |
| 808 | 810 | environment: [{ name: "GROK_HOME", value: grokHome, sensitive: false }], |
| 809 | 811 | }, |
| 810 | 812 | }, |
apps/server/src/usage/UsageService.ts+97-82| ... | ... | @@ -21,8 +21,6 @@ |
| 21 | 21 | import * as NodeOS from "node:os"; |
| 22 | 22 | |
| 23 | 23 | import { |
| 24 | ClaudeSettings, | |
| 25 | CodexSettings, | |
| 26 | 24 | type ProviderInstanceConfig, |
| 27 | 25 | ProviderInstanceId, |
| 28 | 26 | USAGE_CONTRACT_VERSION, |
| ... | ... | @@ -56,9 +54,15 @@ import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; |
| 56 | 54 | import * as ServerConfig from "../config.ts"; |
| 57 | 55 | import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; |
| 58 | 56 | import * as ServerSettings from "../serverSettings.ts"; |
| 59 | import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; | |
| 60 | 57 | import { resolveAntigravityInstanceDirectories } from "../provider/antigravityAuthSupport.ts"; |
| 58 | import { BUILT_IN_USAGE_DRIVERS, type BuiltInUsageReadersEnv } from "../provider/builtInDrivers.ts"; | |
| 59 | import type { ProviderDriver } from "@t3tools/provider-core/server/driver"; | |
| 61 | 60 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; |
| 61 | import type { | |
| 62 | ProviderUsageInstance, | |
| 63 | TranscriptUsageFormat, | |
| 64 | UsageRecord, | |
| 65 | } from "@t3tools/provider-core/server/usage"; | |
| 62 | 66 | import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; |
| 63 | 67 | import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts"; |
| 64 | 68 | import { |
| ... | ... | @@ -90,7 +94,6 @@ import { |
| 90 | 94 | type CachedFile, |
| 91 | 95 | type ScanCache, |
| 92 | 96 | } from "./usageScanCache.ts"; |
| 93 | import type { UsageRecord } from "./usageTranscripts.ts"; | |
| 94 | 97 | |
| 95 | 98 | const LITELLM_RATES_URL = |
| 96 | 99 | "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"; |
| ... | ... | @@ -119,8 +122,24 @@ const CURSOR_ACCOUNT_READ_ERROR = "Cursor account usage could not be read."; |
| 119 | 122 | /** Transcripts parsed at once. More gains little once the disk stays busy. */ |
| 120 | 123 | const TRANSCRIPT_READ_CONCURRENCY = 4; |
| 121 | 124 | |
| 122 | const decodeCodexSettings = Schema.decodeOption(CodexSettings); | |
| 123 | const decodeClaudeSettings = Schema.decodeOption(ClaudeSettings); | |
| 125 | /** The transcript readers, in driver order. */ | |
| 126 | const transcriptReaders = BUILT_IN_USAGE_DRIVERS.flatMap((driver) => | |
| 127 | driver.usage?.kind === "transcripts" ? [{ driver, reader: driver.usage }] : [], | |
| 128 | ); | |
| 129 | ||
| 130 | /** Transcript formats by provider, for decoding the persisted scan cache. */ | |
| 131 | const transcriptFormats = new Map( | |
| 132 | transcriptReaders.map(({ reader }) => [reader.provider, reader.format] as const), | |
| 133 | ); | |
| 134 | ||
| 135 | /** One transcript directory to scan. */ | |
| 136 | interface TranscriptSource { | |
| 137 | readonly provider: UsageProviderKind; | |
| 138 | readonly format: TranscriptUsageFormat<unknown>; | |
| 139 | readonly dir: string; | |
| 140 | readonly volumeId: string; | |
| 141 | readonly fileName?: string; | |
| 142 | } | |
| 124 | 143 | |
| 125 | 144 | /** On-disk shape of the rate snapshot. */ |
| 126 | 145 | const RatesCacheFile = Schema.Struct({ |
| ... | ... | @@ -145,13 +164,21 @@ function isLaterRead(a: CachedFile, b: CachedFile): boolean { |
| 145 | 164 | return a.mtimeMs > b.mtimeMs || (a.mtimeMs === b.mtimeMs && a.size > b.size); |
| 146 | 165 | } |
| 147 | 166 | |
| 167 | /** Providers whose format lets one session's records appear in several files. */ | |
| 168 | const sharedSessionProviders: ReadonlySet<UsageProviderKind> = new Set( | |
| 169 | transcriptReaders.flatMap(({ reader }) => | |
| 170 | reader.format.sharedSessionsAcrossFiles ? [reader.provider] : [], | |
| 171 | ), | |
| 172 | ); | |
| 173 | ||
| 148 | 174 | /** |
| 149 | * Codex sessions with records in more than one file, such as a rollout that | |
| 150 | * moved after it was read. Only these need cross-file dedupe keys: within one | |
| 151 | * file the occurrence count already keeps every key unique, so keying the rest | |
| 152 | * would only build and hash a string for each of their records. | |
| 175 | * Sessions of `sharedSessionProviders` with records in more than one file, | |
| 176 | * such as a rollout that moved after it was read. Only these need cross-file | |
| 177 | * dedupe keys: within one file the occurrence count already keeps every key | |
| 178 | * unique, so keying the rest would only build and hash a string for each of | |
| 179 | * their records. | |
| 153 | 180 | */ |
| 154 | function sharedCodexSessions( | |
| 181 | function sharedSessions( | |
| 155 | 182 | files: readonly { readonly records: readonly UsageRecord[] }[], |
| 156 | 183 | ): ReadonlySet<string> { |
| 157 | 184 | const firstFile = new Map<string, number>(); |
| ... | ... | @@ -159,7 +186,8 @@ function sharedCodexSessions( |
| 159 | 186 | for (const [index, file] of files.entries()) { |
| 160 | 187 | let previous = ""; |
| 161 | 188 | for (const { provider, sessionId } of file.records) { |
| 162 | if (provider !== "codex" || sessionId === previous || sessionId.length === 0) continue; | |
| 189 | if (!sharedSessionProviders.has(provider) || sessionId === previous || sessionId.length === 0) | |
| 190 | continue; | |
| 163 | 191 | previous = sessionId; |
| 164 | 192 | const first = firstFile.get(sessionId); |
| 165 | 193 | if (first === undefined) firstFile.set(sessionId, index); |
| ... | ... | @@ -218,6 +246,8 @@ export const make = Effect.gen(function* () { |
| 218 | 246 | const hostEnvironment = yield* HostProcessEnvironment; |
| 219 | 247 | const platform = yield* HostProcessPlatform; |
| 220 | 248 | const cursorAccountReader = yield* CursorUsageReader.CursorAccountReader; |
| 249 | // The readers yield their own services; scans run them against this context. | |
| 250 | const readerContext = yield* Effect.context<BuiltInUsageReadersEnv>(); | |
| 221 | 251 | |
| 222 | 252 | const fileCache: ScanCache = new Map(); |
| 223 | 253 | const antigravityCache = makeAntigravityUsageCache(); |
| ... | ... | @@ -338,60 +368,45 @@ export const make = Effect.gen(function* () { |
| 338 | 368 | ), |
| 339 | 369 | ); |
| 340 | 370 | |
| 341 | /** Resolves the transcript directory for each provider. */ | |
| 371 | /** | |
| 372 | * Every instance of `driver` with its decoded config, or none when it does | |
| 373 | * not decode. Disabled accounts still have history. An unconfigured default | |
| 374 | * slot runs with default config, just as it does in the provider registry. | |
| 375 | */ | |
| 376 | const usageInstances = <Config>( | |
| 377 | driver: ProviderDriver<Config, unknown, unknown>, | |
| 378 | settings: ServerSettingsValue, | |
| 379 | ): Array<ProviderUsageInstance<Config>> => { | |
| 380 | const entries: Array< | |
| 381 | readonly [ProviderInstanceId, Pick<ProviderInstanceConfig, "config" | "environment">, boolean] | |
| 382 | > = Object.entries(settings.providerInstances) | |
| 383 | .filter(([, instance]) => instance.driver === driver.driverKind) | |
| 384 | .map(([id, instance]) => [ProviderInstanceId.make(id), instance, true] as const); | |
| 385 | if (!Object.hasOwn(settings.providerInstances, driver.driverKind)) { | |
| 386 | entries.push([ProviderInstanceId.make(driver.driverKind), {}, false]); | |
| 387 | } | |
| 388 | const decodeConfig = Schema.decodeUnknownOption(driver.configSchema); | |
| 389 | return entries.map(([instanceId, instance, configured]) => ({ | |
| 390 | instanceId, | |
| 391 | config: Option.getOrUndefined(decodeConfig(instance.config ?? {})), | |
| 392 | environment: mergeProviderInstanceEnvironment(instance.environment, hostEnvironment), | |
| 393 | configured, | |
| 394 | })); | |
| 395 | }; | |
| 396 | ||
| 397 | /** Resolves every transcript directory the usage readers point at. */ | |
| 342 | 398 | const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* ( |
| 343 | 399 | settings: ServerSettingsValue, |
| 344 | 400 | retentionCutoffMs: number, |
| 345 | 401 | ) { |
| 346 | const dirs: Array<{ | |
| 347 | provider: UsageProviderKind; | |
| 348 | dir: string; | |
| 349 | volumeId: string; | |
| 350 | fileName?: string; | |
| 351 | }> = []; | |
| 402 | const dirs: Array<TranscriptSource> = []; | |
| 352 | 403 | const seen = new Set<string>(); |
| 353 | for (const driver of ["claudeAgent", "codex", "grok"] as const) { | |
| 354 | // Disabled accounts still have history. An unconfigured default slot | |
| 355 | // runs with default config, just as it does in the provider registry. | |
| 356 | const instances: Array< | |
| 357 | Pick<ProviderInstanceConfig, "config" | "environment"> & { instanceId: ProviderInstanceId } | |
| 358 | > = Object.entries(settings.providerInstances) | |
| 359 | .filter(([, instance]) => instance.driver === driver) | |
| 360 | .map(([id, instance]) => ({ ...instance, instanceId: ProviderInstanceId.make(id) })); | |
| 361 | if (!Object.hasOwn(settings.providerInstances, driver)) { | |
| 362 | instances.push({ instanceId: ProviderInstanceId.make(driver) }); | |
| 363 | } | |
| 364 | for (const instance of instances) { | |
| 365 | const environment = mergeProviderInstanceEnvironment(instance.environment, hostEnvironment); | |
| 366 | const provider = driver === "claudeAgent" ? "claude" : driver; | |
| 367 | let home: string; | |
| 368 | if (driver === "codex") { | |
| 369 | const decoded = decodeCodexSettings(instance.config ?? {}); | |
| 370 | if (Option.isNone(decoded)) continue; | |
| 371 | const codexConfig = decoded.value; | |
| 372 | const environmentHome = environment.CODEX_HOME?.trim(); | |
| 373 | const layout = yield* resolveCodexHomeLayout( | |
| 374 | codexConfig.setupMode !== "managed" && | |
| 375 | !codexConfig.homePath.trim() && | |
| 376 | !codexConfig.shadowHomePath.trim() && | |
| 377 | environmentHome | |
| 378 | ? { ...codexConfig, homePath: environmentHome } | |
| 379 | : codexConfig, | |
| 380 | ); | |
| 381 | home = layout.sharedHomePath; | |
| 382 | } else if (driver === "claudeAgent") { | |
| 383 | const decoded = decodeClaudeSettings(instance.config ?? {}); | |
| 384 | if (Option.isNone(decoded)) continue; | |
| 385 | const configured = decoded.value.homePath.trim(); | |
| 386 | home = configured | |
| 387 | ? expandHomePath(configured) | |
| 388 | : environment.CLAUDE_CONFIG_DIR?.trim() || path.join(NodeOS.homedir(), ".claude"); | |
| 389 | } else { | |
| 390 | home = expandHomePath( | |
| 391 | environment.GROK_HOME?.trim() || path.join(NodeOS.homedir(), ".grok"), | |
| 392 | ); | |
| 393 | } | |
| 394 | const directory = path.resolve(home, provider === "claude" ? "projects" : "sessions"); | |
| 404 | for (const { driver, reader } of transcriptReaders) { | |
| 405 | const { provider, format } = reader; | |
| 406 | const directories = yield* Effect.forEach(usageInstances(driver, settings), (instance) => | |
| 407 | reader.directories(instance), | |
| 408 | ); | |
| 409 | for (const { dir: directory, fileName } of directories.flat()) { | |
| 395 | 410 | const sourceKey = provider + "\0" + directory; |
| 396 | 411 | const previous = sourceCache.get(sourceKey); |
| 397 | 412 | // Keep canonical paths and source fingerprints stable after root cleanup, |
| ... | ... | @@ -423,9 +438,10 @@ export const make = Effect.gen(function* () { |
| 423 | 438 | seen.add(key); |
| 424 | 439 | dirs.push({ |
| 425 | 440 | provider, |
| 441 | format, | |
| 426 | 442 | dir, |
| 427 | 443 | volumeId, |
| 428 | ...(provider === "grok" ? { fileName: "updates.jsonl" } : {}), | |
| 444 | ...(fileName === undefined ? {} : { fileName }), | |
| 429 | 445 | }); |
| 430 | 446 | } |
| 431 | 447 | } |
| ... | ... | @@ -456,7 +472,9 @@ export const make = Effect.gen(function* () { |
| 456 | 472 | cacheDirty = document !== null; |
| 457 | 473 | } |
| 458 | 474 | if (document === null) return; |
| 459 | for (const [path, entry] of decodeScanCache(document)) fileCache.set(path, entry); | |
| 475 | for (const [path, entry] of decodeScanCache(document, transcriptFormats)) { | |
| 476 | fileCache.set(path, entry); | |
| 477 | } | |
| 460 | 478 | const sources = decodeCachedSources(document); |
| 461 | 479 | if (Option.isSome(sources)) { |
| 462 | 480 | for (const [key, source] of Object.entries(sources.value.sources)) |
| ... | ... | @@ -536,6 +554,7 @@ export const make = Effect.gen(function* () { |
| 536 | 554 | size: number, |
| 537 | 555 | mtimeMs: number, |
| 538 | 556 | provider: UsageProviderKind, |
| 557 | format: TranscriptUsageFormat<unknown>, | |
| 539 | 558 | ): Effect.Effect<{ |
| 540 | 559 | readonly records: readonly UsageRecord[]; |
| 541 | 560 | readonly update?: { readonly entry: CachedFile; readonly replaces: CachedFile | undefined }; |
| ... | ... | @@ -566,7 +585,7 @@ export const make = Effect.gen(function* () { |
| 566 | 585 | : undefined; |
| 567 | 586 | |
| 568 | 587 | const parsed = yield* Effect.promise(() => |
| 569 | readTranscriptRecords(filePath, provider, resumeFrom), | |
| 588 | readTranscriptRecords(filePath, format, resumeFrom), | |
| 570 | 589 | ); |
| 571 | 590 | // A read failure is not an empty transcript: caching it under this |
| 572 | 591 | // (size, mtime) would silently drop the file's usage until it changes. |
| ... | ... | @@ -611,15 +630,10 @@ export const make = Effect.gen(function* () { |
| 611 | 630 | } |
| 612 | 631 | |
| 613 | 632 | const scanTranscriptDir = Effect.fn("UsageService.scanTranscriptDir")(function* ( |
| 614 | source: { | |
| 615 | readonly provider: UsageProviderKind; | |
| 616 | readonly dir: string; | |
| 617 | readonly volumeId: string; | |
| 618 | readonly fileName?: string; | |
| 619 | }, | |
| 633 | source: TranscriptSource, | |
| 620 | 634 | windowStartMs: number, |
| 621 | 635 | ) { |
| 622 | const { provider, dir, volumeId, fileName } = source; | |
| 636 | const { provider, format, dir, volumeId, fileName } = source; | |
| 623 | 637 | const exists = yield* fileSystem |
| 624 | 638 | .exists(dir) |
| 625 | 639 | .pipe(Effect.catchCause(() => Effect.succeed(false))); |
| ... | ... | @@ -632,7 +646,7 @@ export const make = Effect.gen(function* () { |
| 632 | 646 | const read = yield* Effect.forEach( |
| 633 | 647 | files, |
| 634 | 648 | (file) => |
| 635 | readFileRecords(file.path, file.size, file.mtimeMs, provider).pipe( | |
| 649 | readFileRecords(file.path, file.size, file.mtimeMs, provider, format).pipe( | |
| 636 | 650 | Effect.map((result) => ({ path: file.path, ...result })), |
| 637 | 651 | ), |
| 638 | 652 | { concurrency: TRANSCRIPT_READ_CONCURRENCY }, |
| ... | ... | @@ -818,10 +832,8 @@ export const make = Effect.gen(function* () { |
| 818 | 832 | retentionCutoffMs: number, |
| 819 | 833 | awaitRefresh: boolean, |
| 820 | 834 | ) { |
| 821 | // The home resolvers ask for `Path` themselves; satisfy them from the | |
| 822 | // instance we already hold so the scan stays context-free. | |
| 823 | 835 | const dirs = yield* resolveTranscriptDirs(settings, retentionCutoffMs).pipe( |
| 824 | Effect.provideService(Path.Path, path), | |
| 836 | Effect.provideContext(readerContext), | |
| 825 | 837 | ); |
| 826 | 838 | |
| 827 | 839 | const home = NodeOS.homedir(); |
| ... | ... | @@ -1100,7 +1112,7 @@ export const make = Effect.gen(function* () { |
| 1100 | 1112 | } |
| 1101 | 1113 | return retainedFiles; |
| 1102 | 1114 | }); |
| 1103 | const sharedSessions = sharedCodexSessions(filesByDir.flat()); | |
| 1115 | const crossFileSessions = sharedSessions(filesByDir.flat()); | |
| 1104 | 1116 | |
| 1105 | 1117 | for (const [ |
| 1106 | 1118 | index, |
| ... | ... | @@ -1118,12 +1130,15 @@ export const make = Effect.gen(function* () { |
| 1118 | 1130 | continue; |
| 1119 | 1131 | } |
| 1120 | 1132 | scannedFiles += 1; |
| 1121 | const codexEventOccurrences = new Map<string, number>(); | |
| 1133 | const eventOccurrences = new Map<string, number>(); | |
| 1122 | 1134 | for (const record of file.records) { |
| 1123 | 1135 | let usageRecord = record; |
| 1124 | if (record.provider === "codex" && sharedSessions.has(record.sessionId)) { | |
| 1125 | // Match moved rollout copies without collapsing repeated equal events | |
| 1126 | // within one rollout (timestamps can have only second precision). | |
| 1136 | if ( | |
| 1137 | sharedSessionProviders.has(record.provider) && | |
| 1138 | crossFileSessions.has(record.sessionId) | |
| 1139 | ) { | |
| 1140 | // Match moved copies without collapsing repeated equal events | |
| 1141 | // within one file (timestamps can have only second precision). | |
| 1127 | 1142 | // Only sessions seen in several files can have a copy to match. |
| 1128 | 1143 | const key = encodeUsageRecordKey([ |
| 1129 | 1144 | record.provider, |
| ... | ... | @@ -1132,8 +1147,8 @@ export const make = Effect.gen(function* () { |
| 1132 | 1147 | record.model, |
| 1133 | 1148 | record.totals, |
| 1134 | 1149 | ]); |
| 1135 | const occurrence = (codexEventOccurrences.get(key) ?? 0) + 1; | |
| 1136 | codexEventOccurrences.set(key, occurrence); | |
| 1150 | const occurrence = (eventOccurrences.get(key) ?? 0) + 1; | |
| 1151 | eventOccurrences.set(key, occurrence); | |
| 1137 | 1152 | usageRecord = { ...record, dedupeKey: key + ":" + occurrence }; |
| 1138 | 1153 | } |
| 1139 | 1154 | // Only sessions contributing in-window count; the mtime slack can |
apps/server/src/usage/antigravityUsageReader.ts+1-1| ... | ... | @@ -5,7 +5,7 @@ import * as NodePath from "node:path"; |
| 5 | 5 | import * as NodeSqlite from "node:sqlite"; |
| 6 | 6 | import * as NodeTimersPromises from "node:timers/promises"; |
| 7 | 7 | |
| 8 | import type { UsageRecord } from "./usageTranscripts.ts"; | |
| 8 | import type { UsageRecord } from "@t3tools/provider-core/server/usage"; | |
| 9 | 9 | |
| 10 | 10 | type FieldValue = number | bigint | Uint8Array; |
| 11 | 11 | type Fields = Map<number, FieldValue[]>; |
apps/server/src/usage/cursorAccountCache.test.ts+1-1| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | 2 | |
| 3 | 3 | import { cursorFetchRange, mergeCursorFetch } from "./cursorAccountCache.ts"; |
| 4 | import type { UsageRecord } from "./usageTranscripts.ts"; | |
| 4 | import type { UsageRecord } from "@t3tools/provider-core/server/usage"; | |
| 5 | 5 | |
| 6 | 6 | const DAY_MS = 24 * 60 * 60 * 1000; |
| 7 | 7 |
apps/server/src/usage/cursorAccountCache.ts+1-1| ... | ... | @@ -13,7 +13,7 @@ |
| 13 | 13 | * @module cursorAccountCache |
| 14 | 14 | */ |
| 15 | 15 | import { cursorRateModel } from "./cursorUsageReader.ts"; |
| 16 | import type { UsageRecord } from "./usageTranscripts.ts"; | |
| 16 | import type { UsageRecord } from "@t3tools/provider-core/server/usage"; | |
| 17 | 17 | |
| 18 | 18 | export type CursorCredentialSource = string | { readonly kind: "keychain" }; |
| 19 | 19 |
apps/server/src/usage/cursorUsageReader.ts+1-1| ... | ... | @@ -8,7 +8,7 @@ import * as Context from "effect/Context"; |
| 8 | 8 | import * as Effect from "effect/Effect"; |
| 9 | 9 | import * as Layer from "effect/Layer"; |
| 10 | 10 | |
| 11 | import type { UsageRecord } from "./usageTranscripts.ts"; | |
| 11 | import type { UsageRecord } from "@t3tools/provider-core/server/usage"; | |
| 12 | 12 | import { |
| 13 | 13 | CursorKeychainTimeoutError, |
| 14 | 14 | readMacCursorAccessToken, |
apps/server/src/usage/opencodeUsageReader.ts+1-1| ... | ... | @@ -5,7 +5,7 @@ import * as NodePath from "node:path"; |
| 5 | 5 | import * as NodeSqlite from "node:sqlite"; |
| 6 | 6 | import * as NodeTimersPromises from "node:timers/promises"; |
| 7 | 7 | |
| 8 | import { totalTokens, type UsageRecord } from "./usageTranscripts.ts"; | |
| 8 | import { totalTokens, type UsageRecord } from "@t3tools/provider-core/server/usage"; | |
| 9 | 9 | |
| 10 | 10 | function object(value: unknown): Record<string, unknown> { |
| 11 | 11 | return typeof value === "object" && value !== null && !Array.isArray(value) |
apps/server/src/usage/usageAggregation.test.ts+1-1| ... | ... | @@ -2,7 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | |
| 3 | 3 | import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; |
| 4 | 4 | import type { RateTable } from "./usagePricing.ts"; |
| 5 | import type { UsageRecord } from "./usageTranscripts.ts"; | |
| 5 | import type { UsageRecord } from "@t3tools/provider-core/server/usage"; | |
| 6 | 6 | |
| 7 | 7 | const rates: RateTable = new Map([ |
| 8 | 8 | [ |
apps/server/src/usage/usageAggregation.ts+1-1| ... | ... | @@ -20,7 +20,7 @@ import type { |
| 20 | 20 | UsageTokenTotals, |
| 21 | 21 | } from "@t3tools/contracts"; |
| 22 | 22 | |
| 23 | import { EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts"; | |
| 23 | import { EMPTY_TOTALS, type UsageRecord } from "@t3tools/provider-core/server/usage"; | |
| 24 | 24 | import { cacheSavingsUsd, priceUsage, type RateTable } from "./usagePricing.ts"; |
| 25 | 25 | |
| 26 | 26 | /** |
apps/server/src/usage/usagePricing.test.ts+1-1| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | |
| 3 | 3 | import { cursorRateModel } from "./cursorUsageReader.ts"; |
| 4 | import type { UsageSpeed } from "./usageTranscripts.ts"; | |
| 4 | import type { UsageSpeed } from "@t3tools/provider-core/server/usage"; | |
| 5 | 5 | import { |
| 6 | 6 | cacheSavingsUsd, |
| 7 | 7 | createOverrideRateTable, |
apps/server/src/usage/usagePricing.ts+1-1| ... | ... | @@ -14,7 +14,7 @@ import type { |
| 14 | 14 | UsageTokenTotals, |
| 15 | 15 | } from "@t3tools/contracts"; |
| 16 | 16 | |
| 17 | import type { UsageRecord, UsageSpeed } from "./usageTranscripts.ts"; | |
| 17 | import type { UsageRecord, UsageSpeed } from "@t3tools/provider-core/server/usage"; | |
| 18 | 18 | |
| 19 | 19 | /** Token rates for one billing speed. All values are USD per token. */ |
| 20 | 20 | export interface TokenRates { |
apps/server/src/usage/usageScanCache.test.ts+29-16| ... | ... | @@ -9,7 +9,9 @@ import { |
| 9 | 9 | type CachedFile, |
| 10 | 10 | type ScanCache, |
| 11 | 11 | } from "./usageScanCache.ts"; |
| 12 | import type { UsageRecord } from "./usageTranscripts.ts"; | |
| 12 | import type { UsageRecord } from "@t3tools/provider-core/server/usage"; | |
| 13 | ||
| 14 | import { TEST_FORMAT_MAP } from "./usageTestFormats.ts"; | |
| 13 | 15 | |
| 14 | 16 | function record(overrides: Partial<UsageRecord> = {}): UsageRecord { |
| 15 | 17 | return { |
| ... | ... | @@ -36,7 +38,7 @@ function position(overrides: Partial<CachedFile["position"]> = {}): CachedFile[" |
| 36 | 38 | resumeOffset: 120, |
| 37 | 39 | guardLength: 64, |
| 38 | 40 | guardHash: 0xdeadbeef, |
| 39 | codexState: null, | |
| 41 | state: null, | |
| 40 | 42 | ...overrides, |
| 41 | 43 | }; |
| 42 | 44 | } |
| ... | ... | @@ -85,7 +87,7 @@ describe("scan cache round trip", () => { |
| 85 | 87 | ], |
| 86 | 88 | tailRecords: [], |
| 87 | 89 | position: position({ |
| 88 | codexState: { | |
| 90 | state: { | |
| 89 | 91 | model: "gpt-6-astra", |
| 90 | 92 | speed: "ultrafast", |
| 91 | 93 | sessionId: "session-c", |
| ... | ... | @@ -97,7 +99,10 @@ describe("scan cache round trip", () => { |
| 97 | 99 | }), |
| 98 | 100 | }); |
| 99 | 101 | |
| 100 | const restored = decodeScanCache(JSON.parse(JSON.stringify(encodeScanCache(original)))); | |
| 102 | const restored = decodeScanCache( | |
| 103 | JSON.parse(JSON.stringify(encodeScanCache(original))), | |
| 104 | TEST_FORMAT_MAP, | |
| 105 | ); | |
| 101 | 106 | |
| 102 | 107 | expect(restored.size).toBe(4); |
| 103 | 108 | expect(restored.get("/a.jsonl")).toEqual(original.get("/a.jsonl")); |
| ... | ... | @@ -117,7 +122,9 @@ describe("scan cache round trip", () => { |
| 117 | 122 | }, |
| 118 | 123 | }; |
| 119 | 124 | |
| 120 | expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); | |
| 125 | expect( | |
| 126 | decodeScanCache(JSON.parse(JSON.stringify(poisoned)), TEST_FORMAT_MAP).has("/a.jsonl"), | |
| 127 | ).toBe(false); | |
| 121 | 128 | }); |
| 122 | 129 | |
| 123 | 130 | it("drops an entry whose guard length is outside the supported range", () => { |
| ... | ... | @@ -129,7 +136,9 @@ describe("scan cache round trip", () => { |
| 129 | 136 | files: { "/a.jsonl": { ...encoded.files["/a.jsonl"]!, gl: 1e20 } }, |
| 130 | 137 | }; |
| 131 | 138 | |
| 132 | expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); | |
| 139 | expect( | |
| 140 | decodeScanCache(JSON.parse(JSON.stringify(poisoned)), TEST_FORMAT_MAP).has("/a.jsonl"), | |
| 141 | ).toBe(false); | |
| 133 | 142 | }); |
| 134 | 143 | |
| 135 | 144 | it("drops an entry whose speed is not a known index", () => { |
| ... | ... | @@ -140,14 +149,16 @@ describe("scan cache round trip", () => { |
| 140 | 149 | files: { "/a.jsonl": { ...encoded.files["/a.jsonl"]!, r: [[...row.slice(0, 10), true]] } }, |
| 141 | 150 | }; |
| 142 | 151 | |
| 143 | expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); | |
| 152 | expect( | |
| 153 | decodeScanCache(JSON.parse(JSON.stringify(poisoned)), TEST_FORMAT_MAP).has("/a.jsonl"), | |
| 154 | ).toBe(false); | |
| 144 | 155 | }); |
| 145 | 156 | |
| 146 | 157 | it("rejects a document from before records carried a speed", () => { |
| 147 | 158 | const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); |
| 148 | 159 | const previous = { ...encoded, version: 3 }; |
| 149 | 160 | |
| 150 | expect(decodeScanCache(JSON.parse(JSON.stringify(previous))).size).toBe(0); | |
| 161 | expect(decodeScanCache(JSON.parse(JSON.stringify(previous)), TEST_FORMAT_MAP).size).toBe(0); | |
| 151 | 162 | }); |
| 152 | 163 | |
| 153 | 164 | it("rewrites only changed entries and still restores the whole cache", () => { |
| ... | ... | @@ -157,7 +168,7 @@ describe("scan cache round trip", () => { |
| 157 | 168 | ["/b.jsonl", 200, [record({ sessionId: "session-b" })]], |
| 158 | 169 | ]); |
| 159 | 170 | const sources = { "claude\u0000/projects": { dir: "/projects", volumeId: "1:2" } }; |
| 160 | expect(decodeScanCache(JSON.parse(write(cache, { sources })))).toEqual(cache); | |
| 171 | expect(decodeScanCache(JSON.parse(write(cache, { sources })), TEST_FORMAT_MAP)).toEqual(cache); | |
| 161 | 172 | |
| 162 | 173 | // The replacement adds intern entries; /a's memoised indexes must hold. |
| 163 | 174 | cache.set("/b.jsonl", { |
| ... | ... | @@ -166,7 +177,7 @@ describe("scan cache round trip", () => { |
| 166 | 177 | records: [record({ sessionId: "session-c", model: "claude-opus-5-5", dedupeKey: "msg_3:" })], |
| 167 | 178 | }); |
| 168 | 179 | const document = JSON.parse(write(cache, { sources })); |
| 169 | expect(decodeScanCache(document)).toEqual(cache); | |
| 180 | expect(decodeScanCache(document, TEST_FORMAT_MAP)).toEqual(cache); | |
| 170 | 181 | expect(document.sources).toEqual(sources); |
| 171 | 182 | }); |
| 172 | 183 | |
| ... | ... | @@ -181,9 +192,11 @@ describe("scan cache round trip", () => { |
| 181 | 192 | |
| 182 | 193 | it("treats a corrupt or foreign document as an empty cache", () => { |
| 183 | 194 | // A bad cache should cost one cold scan, never a broken page. |
| 184 | expect(decodeScanCache(null).size).toBe(0); | |
| 185 | expect(decodeScanCache("nonsense").size).toBe(0); | |
| 186 | expect(decodeScanCache({ version: 999, models: [], sessions: [], files: {} }).size).toBe(0); | |
| 195 | expect(decodeScanCache(null, TEST_FORMAT_MAP).size).toBe(0); | |
| 196 | expect(decodeScanCache("nonsense", TEST_FORMAT_MAP).size).toBe(0); | |
| 197 | expect( | |
| 198 | decodeScanCache({ version: 999, models: [], sessions: [], files: {} }, TEST_FORMAT_MAP).size, | |
| 199 | ).toBe(0); | |
| 187 | 200 | }); |
| 188 | 201 | |
| 189 | 202 | it("skips malformed file entries but keeps good ones", () => { |
| ... | ... | @@ -193,7 +206,7 @@ describe("scan cache round trip", () => { |
| 193 | 206 | files: { ...encoded.files, "/bad.jsonl": { s: "nope", m: 1, p: "claude", r: [] } }, |
| 194 | 207 | }; |
| 195 | 208 | |
| 196 | const restored = decodeScanCache(JSON.parse(JSON.stringify(withJunk))); | |
| 209 | const restored = decodeScanCache(JSON.parse(JSON.stringify(withJunk)), TEST_FORMAT_MAP); | |
| 197 | 210 | expect([...restored.keys()]).toEqual(["/good.jsonl"]); |
| 198 | 211 | }); |
| 199 | 212 | |
| ... | ... | @@ -203,7 +216,7 @@ describe("scan cache round trip", () => { |
| 203 | 216 | const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); |
| 204 | 217 | const poisoned = { ...encoded, models: [1] }; |
| 205 | 218 | |
| 206 | expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).size).toBe(0); | |
| 219 | expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned)), TEST_FORMAT_MAP).size).toBe(0); | |
| 207 | 220 | }); |
| 208 | 221 | |
| 209 | 222 | it("drops the whole entry when any row is corrupt, forcing a cold re-parse", () => { |
| ... | ... | @@ -223,7 +236,7 @@ describe("scan cache round trip", () => { |
| 223 | 236 | }, |
| 224 | 237 | }; |
| 225 | 238 | |
| 226 | const restored = decodeScanCache(JSON.parse(JSON.stringify(poisoned))); | |
| 239 | const restored = decodeScanCache(JSON.parse(JSON.stringify(poisoned)), TEST_FORMAT_MAP); | |
| 227 | 240 | expect(restored.has("/a.jsonl")).toBe(false); |
| 228 | 241 | }); |
| 229 | 242 | }); |
apps/server/src/usage/usageScanCache.ts+52-51| ... | ... | @@ -15,9 +15,14 @@ |
| 15 | 15 | * @module usageScanCache |
| 16 | 16 | */ |
| 17 | 17 | import type { UsageProviderKind } from "@t3tools/contracts"; |
| 18 | import type { | |
| 19 | TranscriptUsageFormat, | |
| 20 | UsageRecord, | |
| 21 | UsageSpeed, | |
| 22 | } from "@t3tools/provider-core/server/usage"; | |
| 23 | import * as Schema from "effect/Schema"; | |
| 18 | 24 | |
| 19 | 25 | import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts"; |
| 20 | import type { CodexScanState, UsageRecord, UsageSpeed } from "./usageTranscripts.ts"; | |
| 21 | 26 | |
| 22 | 27 | // v2: Codex fork-copy suppression changed what a file parses to, so v1 |
| 23 | 28 | // entries would keep serving double-counted records forever. |
| ... | ... | @@ -25,7 +30,7 @@ import type { CodexScanState, UsageRecord, UsageSpeed } from "./usageTranscripts |
| 25 | 30 | // re-parses only its appended bytes instead of starting over. |
| 26 | 31 | // v4: records carry Claude fast mode, which v3 rows never captured. |
| 27 | 32 | // v5: Codex records carry their service tier. v4 rows store speed the same |
| 28 | // way, so v4 entries still load; see `decodeScanCache` for v4 Codex entries. | |
| 33 | // way, so v4 entries still load; see `decodeScanCache` for v4 stateful entries. | |
| 29 | 34 | const USAGE_SCAN_CACHE_VERSION = 5 as const; |
| 30 | 35 | const SPEED_COMPATIBLE_SINCE_VERSION = 4; |
| 31 | 36 | |
| ... | ... | @@ -41,10 +46,6 @@ export const LEGACY_SCAN_CACHE_FILE_NAME = "usage-scan-cache.json"; |
| 41 | 46 | /** Serialised as the index into this list. */ |
| 42 | 47 | const SPEEDS: readonly UsageSpeed[] = ["standard", "fast", "ultrafast"]; |
| 43 | 48 | |
| 44 | function isSpeed(value: unknown): value is UsageSpeed { | |
| 45 | return SPEEDS.some((speed) => speed === value); | |
| 46 | } | |
| 47 | ||
| 48 | 49 | export interface CachedFile { |
| 49 | 50 | readonly size: number; |
| 50 | 51 | readonly mtimeMs: number; |
| ... | ... | @@ -92,8 +93,8 @@ interface SerializedFile { |
| 92 | 93 | readonly o: number; |
| 93 | 94 | readonly gl: number; |
| 94 | 95 | readonly gh: number; |
| 95 | /** Codex reducer state at `o`; `null` for stateless providers. */ | |
| 96 | readonly cs: CodexScanState | null; | |
| 96 | /** The format's encoded reducer state at `o`; `null` for stateless formats. */ | |
| 97 | readonly cs: unknown; | |
| 97 | 98 | } |
| 98 | 99 | |
| 99 | 100 | interface SerializedCache { |
| ... | ... | @@ -147,7 +148,7 @@ function serializeFile(entry: CachedFile, tables: InternTables): SerializedFile |
| 147 | 148 | o: entry.position.resumeOffset, |
| 148 | 149 | gl: entry.position.guardLength, |
| 149 | 150 | gh: entry.position.guardHash, |
| 150 | cs: entry.position.codexState, | |
| 151 | cs: entry.position.state, | |
| 151 | 152 | }; |
| 152 | 153 | } |
| 153 | 154 | |
| ... | ... | @@ -210,10 +211,15 @@ function isRecordArray(value: unknown): value is readonly unknown[] { |
| 210 | 211 | * Rebuilds the cache from a parsed document. |
| 211 | 212 | * |
| 212 | 213 | * Anything malformed yields an empty cache rather than an error: a corrupt |
| 213 | * cache should cost one cold scan, never a broken page. | |
| 214 | * cache should cost one cold scan, never a broken page. Entries of a provider | |
| 215 | * without a transcript format in `formats` are dropped. | |
| 214 | 216 | */ |
| 215 | export function decodeScanCache(document: unknown): ScanCache { | |
| 217 | export function decodeScanCache( | |
| 218 | document: unknown, | |
| 219 | formats: ReadonlyMap<UsageProviderKind, TranscriptUsageFormat<unknown>>, | |
| 220 | ): ScanCache { | |
| 216 | 221 | const cache: ScanCache = new Map(); |
| 222 | const isValidState = makeStateValidators(formats); | |
| 217 | 223 | if (typeof document !== "object" || document === null) return cache; |
| 218 | 224 | |
| 219 | 225 | const root = document as Partial<SerializedCache>; |
| ... | ... | @@ -300,7 +306,9 @@ export function decodeScanCache(document: unknown): ScanCache { |
| 300 | 306 | if (typeof raw !== "object" || raw === null) continue; |
| 301 | 307 | const entry = raw as Partial<SerializedFile>; |
| 302 | 308 | if (typeof entry.s !== "number" || typeof entry.m !== "number") continue; |
| 303 | if (entry.p !== "claude" && entry.p !== "codex" && entry.p !== "grok") continue; | |
| 309 | if (entry.p === undefined) continue; | |
| 310 | const format = formats.get(entry.p); | |
| 311 | if (format === undefined) continue; | |
| 304 | 312 | if (!isRecordArray(entry.r) || !isRecordArray(entry.t)) continue; |
| 305 | 313 | // Position fields feed byte offsets and a Buffer allocation in the reader, |
| 306 | 314 | // so anything outside their real ranges must reject the entry: a bogus |
| ... | ... | @@ -320,27 +328,34 @@ export function decodeScanCache(document: unknown): ScanCache { |
| 320 | 328 | ) { |
| 321 | 329 | continue; |
| 322 | 330 | } |
| 323 | // v4 Codex records predate service tiers, so they all priced as standard. | |
| 324 | // Keep them, because the rollout may be gone, but make a live rollout | |
| 325 | // re-parse whole: no file has size -1, and a zero position cannot resume. | |
| 326 | const legacyCodex = entry.p === "codex" && version < USAGE_SCAN_CACHE_VERSION; | |
| 327 | const codexState = legacyCodex ? null : decodeCodexState(entry.cs); | |
| 328 | if (codexState === undefined) continue; | |
| 331 | // v4 records of stateful formats (Codex) predate service tiers, so they | |
| 332 | // all priced as standard. Keep them, because the rollout may be gone, but | |
| 333 | // make a live rollout re-parse whole: no file has size -1, and a zero | |
| 334 | // position cannot resume. | |
| 335 | const legacy = format.state !== undefined && version < USAGE_SCAN_CACHE_VERSION; | |
| 336 | // A corrupt state disqualifies the entry: resuming with it would attach | |
| 337 | // appended usage to the wrong model or replay fork-copied history. | |
| 338 | if (!legacy && !isValidState.get(entry.p)?.(entry.cs)) continue; | |
| 329 | 339 | |
| 330 | const provider: UsageProviderKind = entry.p; | |
| 340 | const provider = entry.p; | |
| 331 | 341 | const records = decodeRecords(entry.r, provider); |
| 332 | 342 | const tailRecords = decodeRecords(entry.t, provider); |
| 333 | 343 | if (records === null || tailRecords === null) continue; |
| 334 | 344 | |
| 335 | 345 | cache.set(path, { |
| 336 | size: legacyCodex ? -1 : entry.s, | |
| 346 | size: legacy ? -1 : entry.s, | |
| 337 | 347 | mtimeMs: entry.m, |
| 338 | 348 | provider, |
| 339 | 349 | records, |
| 340 | 350 | tailRecords, |
| 341 | position: legacyCodex | |
| 342 | ? { resumeOffset: 0, guardLength: 0, guardHash: 0, codexState: null } | |
| 343 | : { resumeOffset: entry.o, guardLength: entry.gl, guardHash: entry.gh, codexState }, | |
| 351 | position: legacy | |
| 352 | ? { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null } | |
| 353 | : { | |
| 354 | resumeOffset: entry.o, | |
| 355 | guardLength: entry.gl, | |
| 356 | guardHash: entry.gh, | |
| 357 | state: entry.cs, | |
| 358 | }, | |
| 344 | 359 | }); |
| 345 | 360 | } |
| 346 | 361 | |
| ... | ... | @@ -348,35 +363,21 @@ export function decodeScanCache(document: unknown): ScanCache { |
| 348 | 363 | } |
| 349 | 364 | |
| 350 | 365 | /** |
| 351 | * Validates a persisted Codex reducer state. Returns `undefined` for a corrupt | |
| 352 | * value, which disqualifies the entry: resuming with a bad state would attach | |
| 353 | * appended usage to the wrong model or replay fork-copied history. | |
| 366 | * Per provider, whether a persisted state is valid: `null`, or one the | |
| 367 | * format's schema accepts. | |
| 354 | 368 | */ |
| 355 | function decodeCodexState(value: unknown): CodexScanState | null | undefined { | |
| 356 | if (value === null) return null; | |
| 357 | if (typeof value !== "object") return undefined; | |
| 358 | const state = value as Partial<CodexScanState>; | |
| 359 | if ( | |
| 360 | typeof state.model !== "string" || | |
| 361 | !isSpeed(state.speed) || | |
| 362 | typeof state.sessionId !== "string" || | |
| 363 | (state.lastUsageSignature !== null && typeof state.lastUsageSignature !== "string") || | |
| 364 | typeof state.sawSessionMeta !== "boolean" || | |
| 365 | typeof state.suppressingForkCopies !== "boolean" || | |
| 366 | typeof state.forkCopyAnchorMs !== "number" || | |
| 367 | !Number.isFinite(state.forkCopyAnchorMs) | |
| 368 | ) { | |
| 369 | return undefined; | |
| 370 | } | |
| 371 | return { | |
| 372 | model: state.model, | |
| 373 | speed: state.speed, | |
| 374 | sessionId: state.sessionId, | |
| 375 | lastUsageSignature: state.lastUsageSignature ?? null, | |
| 376 | sawSessionMeta: state.sawSessionMeta, | |
| 377 | suppressingForkCopies: state.suppressingForkCopies, | |
| 378 | forkCopyAnchorMs: state.forkCopyAnchorMs, | |
| 379 | }; | |
| 369 | function makeStateValidators( | |
| 370 | formats: ReadonlyMap<UsageProviderKind, TranscriptUsageFormat<unknown>>, | |
| 371 | ): ReadonlyMap<UsageProviderKind, (value: unknown) => boolean> { | |
| 372 | return new Map( | |
| 373 | [...formats].map(([provider, format]) => { | |
| 374 | const isState = format.state === undefined ? undefined : Schema.is(format.state.schema); | |
| 375 | return [ | |
| 376 | provider, | |
| 377 | (value: unknown) => value === null || (isState !== undefined && isState(value)), | |
| 378 | ] as const; | |
| 379 | }), | |
| 380 | ); | |
| 380 | 381 | } |
| 381 | 382 | |
| 382 | 383 | /** Keeps saved usage after transcript cleanup, until the reporting retention expires. */ |
apps/server/src/usage/usageTestFormats.ts created+21| ... | ... | @@ -0,0 +1,21 @@ |
| 1 | /** | |
| 2 | * The built-in transcript formats by provider, for engine and cache tests. | |
| 3 | * | |
| 4 | * @module usageTestFormats | |
| 5 | */ | |
| 6 | import type { UsageProviderKind } from "@t3tools/contracts"; | |
| 7 | import type { TranscriptUsageFormat } from "@t3tools/provider-core/server/usage"; | |
| 8 | import { grokUsageFormat } from "@t3tools/provider-grok/server/usage"; | |
| 9 | ||
| 10 | import { claudeUsageFormat } from "../provider/Drivers/claudeUsage.ts"; | |
| 11 | import { codexUsageFormat } from "../provider/Drivers/codexUsage.ts"; | |
| 12 | ||
| 13 | export const TEST_FORMATS: Record<"claude" | "codex" | "grok", TranscriptUsageFormat<unknown>> = { | |
| 14 | claude: claudeUsageFormat, | |
| 15 | codex: codexUsageFormat, | |
| 16 | grok: grokUsageFormat, | |
| 17 | }; | |
| 18 | ||
| 19 | export const TEST_FORMAT_MAP = new Map<UsageProviderKind, TranscriptUsageFormat<unknown>>( | |
| 20 | Object.entries(TEST_FORMATS) as Array<[UsageProviderKind, TranscriptUsageFormat<unknown>]>, | |
| 21 | ); |
apps/server/src/usage/usageTranscriptReader.test.ts+18-15| ... | ... | @@ -8,6 +8,7 @@ import * as NodeSqlite from "node:sqlite"; |
| 8 | 8 | |
| 9 | 9 | import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest"; |
| 10 | 10 | |
| 11 | import { TEST_FORMATS } from "./usageTestFormats.ts"; | |
| 11 | 12 | import { readTranscriptRecords } from "./usageTranscriptReader.ts"; |
| 12 | 13 | import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; |
| 13 | 14 | import { readCursorAccountUsage } from "./cursorUsageReader.ts"; |
| ... | ... | @@ -99,20 +100,20 @@ describe("readTranscriptRecords resume", () => { |
| 99 | 100 | it("parses only appended lines when resuming a grown file", async () => { |
| 100 | 101 | const path = NodePath.join(dir, "claude.jsonl"); |
| 101 | 102 | await NodeFSP.writeFile(path, claudeLine(1, 5) + claudeLine(2, 7)); |
| 102 | const first = await readTranscriptRecords(path, "claude"); | |
| 103 | const first = await readTranscriptRecords(path, TEST_FORMATS.claude); | |
| 103 | 104 | assert.isNotNull(first); |
| 104 | 105 | assert.strictEqual(first.records.length, 2); |
| 105 | 106 | assert.isFalse(first.resumed); |
| 106 | 107 | |
| 107 | 108 | await NodeFSP.appendFile(path, claudeLine(3, 11)); |
| 108 | const second = await readTranscriptRecords(path, "claude", first.position); | |
| 109 | const second = await readTranscriptRecords(path, TEST_FORMATS.claude, first.position); | |
| 109 | 110 | assert.isNotNull(second); |
| 110 | 111 | assert.isTrue(second.resumed); |
| 111 | 112 | assert.strictEqual(second.records.length, 1); |
| 112 | 113 | assert.strictEqual(second.records[0]?.totals.outputTokens, 11); |
| 113 | 114 | |
| 114 | 115 | // The stitched result matches a from-scratch parse of the whole file. |
| 115 | const full = await readTranscriptRecords(path, "claude"); | |
| 116 | const full = await readTranscriptRecords(path, TEST_FORMATS.claude); | |
| 116 | 117 | assert.isNotNull(full); |
| 117 | 118 | assert.deepStrictEqual([...first.records, ...second.records], [...full.records]); |
| 118 | 119 | }); |
| ... | ... | @@ -123,7 +124,7 @@ describe("readTranscriptRecords resume", () => { |
| 123 | 124 | path, |
| 124 | 125 | codexMetaLine() + codexModelLine("gpt-5.2-codex") + codexTierLine("ultrafast"), |
| 125 | 126 | ); |
| 126 | const first = await readTranscriptRecords(path, "codex"); | |
| 127 | const first = await readTranscriptRecords(path, TEST_FORMATS.codex); | |
| 127 | 128 | assert.isNotNull(first); |
| 128 | 129 | assert.strictEqual(first.records.length, 0); |
| 129 | 130 | |
| ... | ... | @@ -131,7 +132,7 @@ describe("readTranscriptRecords resume", () => { |
| 131 | 132 | // session_meta of its own; model, tier, and session must come from the |
| 132 | 133 | // state captured before the boundary. |
| 133 | 134 | await NodeFSP.appendFile(path, codexUsageLine(9, 5)); |
| 134 | const second = await readTranscriptRecords(path, "codex", first.position); | |
| 135 | const second = await readTranscriptRecords(path, TEST_FORMATS.codex, first.position); | |
| 135 | 136 | assert.isNotNull(second); |
| 136 | 137 | assert.isTrue(second.resumed); |
| 137 | 138 | assert.strictEqual(second.records.length, 1); |
| ... | ... | @@ -146,14 +147,14 @@ describe("readTranscriptRecords resume", () => { |
| 146 | 147 | path, |
| 147 | 148 | codexMetaLine() + codexModelLine("gpt-5.2-codex") + codexUsageLine(9, 5), |
| 148 | 149 | ); |
| 149 | const first = await readTranscriptRecords(path, "codex"); | |
| 150 | const first = await readTranscriptRecords(path, TEST_FORMATS.codex); | |
| 150 | 151 | assert.isNotNull(first); |
| 151 | 152 | assert.strictEqual(first.records.length, 1); |
| 152 | 153 | |
| 153 | 154 | // Codex re-emits an unchanged token_count on stream boundaries; the copy |
| 154 | 155 | // lands after the resume point and must still be dropped. |
| 155 | 156 | await NodeFSP.appendFile(path, codexUsageLine(9, 5) + codexUsageLine(21, 8)); |
| 156 | const second = await readTranscriptRecords(path, "codex", first.position); | |
| 157 | const second = await readTranscriptRecords(path, TEST_FORMATS.codex, first.position); | |
| 157 | 158 | assert.isNotNull(second); |
| 158 | 159 | assert.isTrue(second.resumed); |
| 159 | 160 | assert.deepStrictEqual( |
| ... | ... | @@ -166,7 +167,7 @@ describe("readTranscriptRecords resume", () => { |
| 166 | 167 | const path = NodePath.join(dir, "claude.jsonl"); |
| 167 | 168 | const unterminated = claudeLine(2, 7).trimEnd(); |
| 168 | 169 | await NodeFSP.writeFile(path, claudeLine(1, 5) + unterminated); |
| 169 | const first = await readTranscriptRecords(path, "claude"); | |
| 170 | const first = await readTranscriptRecords(path, TEST_FORMATS.claude); | |
| 170 | 171 | assert.isNotNull(first); |
| 171 | 172 | assert.strictEqual(first.records.length, 1); |
| 172 | 173 | assert.strictEqual(first.tailRecords.length, 1); |
| ... | ... | @@ -175,7 +176,7 @@ describe("readTranscriptRecords resume", () => { |
| 175 | 176 | // Completing the line and appending another re-reads from the resume |
| 176 | 177 | // point, so the once-tail record arrives exactly once as a line record. |
| 177 | 178 | await NodeFSP.appendFile(path, `\n${claudeLine(3, 11)}`); |
| 178 | const second = await readTranscriptRecords(path, "claude", first.position); | |
| 179 | const second = await readTranscriptRecords(path, TEST_FORMATS.claude, first.position); | |
| 179 | 180 | assert.isNotNull(second); |
| 180 | 181 | assert.isTrue(second.resumed); |
| 181 | 182 | assert.deepStrictEqual( |
| ... | ... | @@ -188,12 +189,12 @@ describe("readTranscriptRecords resume", () => { |
| 188 | 189 | it("re-parses from the start when the guard bytes no longer match", async () => { |
| 189 | 190 | const path = NodePath.join(dir, "claude.jsonl"); |
| 190 | 191 | await NodeFSP.writeFile(path, claudeLine(1, 5)); |
| 191 | const first = await readTranscriptRecords(path, "claude"); | |
| 192 | const first = await readTranscriptRecords(path, TEST_FORMATS.claude); | |
| 192 | 193 | assert.isNotNull(first); |
| 193 | 194 | |
| 194 | 195 | // Same path, larger size, different content: a replaced file, not growth. |
| 195 | 196 | await NodeFSP.writeFile(path, claudeLine(4, 13) + claudeLine(5, 17)); |
| 196 | const second = await readTranscriptRecords(path, "claude", first.position); | |
| 197 | const second = await readTranscriptRecords(path, TEST_FORMATS.claude, first.position); | |
| 197 | 198 | assert.isNotNull(second); |
| 198 | 199 | assert.isFalse(second.resumed); |
| 199 | 200 | assert.deepStrictEqual( |
| ... | ... | @@ -205,11 +206,11 @@ describe("readTranscriptRecords resume", () => { |
| 205 | 206 | it("re-parses from the start when the file shrank below the resume point", async () => { |
| 206 | 207 | const path = NodePath.join(dir, "claude.jsonl"); |
| 207 | 208 | await NodeFSP.writeFile(path, claudeLine(1, 5) + claudeLine(2, 7)); |
| 208 | const first = await readTranscriptRecords(path, "claude"); | |
| 209 | const first = await readTranscriptRecords(path, TEST_FORMATS.claude); | |
| 209 | 210 | assert.isNotNull(first); |
| 210 | 211 | |
| 211 | 212 | await NodeFSP.writeFile(path, claudeLine(3, 11)); |
| 212 | const second = await readTranscriptRecords(path, "claude", first.position); | |
| 213 | const second = await readTranscriptRecords(path, TEST_FORMATS.claude, first.position); | |
| 213 | 214 | assert.isNotNull(second); |
| 214 | 215 | assert.isFalse(second.resumed); |
| 215 | 216 | assert.deepStrictEqual( |
| ... | ... | @@ -236,7 +237,7 @@ describe("readTranscriptRecords resume", () => { |
| 236 | 237 | })}\n`; |
| 237 | 238 | await NodeFSP.writeFile(path, bigLine + claudeLine(2, 7)); |
| 238 | 239 | |
| 239 | const parsed = await readTranscriptRecords(path, "claude"); | |
| 240 | const parsed = await readTranscriptRecords(path, TEST_FORMATS.claude); | |
| 240 | 241 | assert.isNotNull(parsed); |
| 241 | 242 | assert.deepStrictEqual( |
| 242 | 243 | parsed.records.map((record) => record.totals.outputTokens), |
| ... | ... | @@ -245,7 +246,9 @@ describe("readTranscriptRecords resume", () => { |
| 245 | 246 | }); |
| 246 | 247 | |
| 247 | 248 | it("returns null for an unreadable file", async () => { |
| 248 | assert.isNull(await readTranscriptRecords(NodePath.join(dir, "missing.jsonl"), "claude")); | |
| 249 | assert.isNull( | |
| 250 | await readTranscriptRecords(NodePath.join(dir, "missing.jsonl"), TEST_FORMATS.claude), | |
| 251 | ); | |
| 249 | 252 | }); |
| 250 | 253 | }); |
| 251 | 254 |
apps/server/src/usage/usageTranscriptReader.ts+45-101| ... | ... | @@ -19,23 +19,16 @@ import * as NodeFSP from "node:fs/promises"; |
| 19 | 19 | import * as NodePath from "node:path"; |
| 20 | 20 | import * as NodeStringDecoder from "node:string_decoder"; |
| 21 | 21 | |
| 22 | import type { UsageProviderKind } from "@t3tools/contracts"; | |
| 22 | import type { | |
| 23 | SelectedFields, | |
| 24 | TranscriptUsageFormat, | |
| 25 | UsageRecord, | |
| 26 | } from "@t3tools/provider-core/server/usage"; | |
| 27 | import * as Option from "effect/Option"; | |
| 28 | import * as Schema from "effect/Schema"; | |
| 23 | 29 | |
| 24 | 30 | import { createTranscriptJsonReader } from "../project/AgentSessionJson.ts"; |
| 25 | 31 | |
| 26 | import { | |
| 27 | initialCodexScanState, | |
| 28 | mightCarryUsage, | |
| 29 | parseClaudeLine, | |
| 30 | parseClaudeRecord, | |
| 31 | parseCodexLine, | |
| 32 | parseCodexRecord, | |
| 33 | parseGrokLine, | |
| 34 | parseGrokRecord, | |
| 35 | type CodexScanState, | |
| 36 | type UsageRecord, | |
| 37 | } from "./usageTranscripts.ts"; | |
| 38 | ||
| 39 | 32 | export interface TranscriptFile { |
| 40 | 33 | readonly path: string; |
| 41 | 34 | readonly size: number; |
| ... | ... | @@ -60,8 +53,11 @@ export interface TranscriptParsePosition { |
| 60 | 53 | readonly guardLength: number; |
| 61 | 54 | /** FNV-1a hash of that window. */ |
| 62 | 55 | readonly guardHash: number; |
| 63 | /** Codex reducer state as of `resumeOffset`; `null` for stateless providers. */ | |
| 64 | readonly codexState: CodexScanState | null; | |
| 56 | /** | |
| 57 | * The format's reducer state as of `resumeOffset`, encoded by its schema; | |
| 58 | * `null` for stateless formats. | |
| 59 | */ | |
| 60 | readonly state: unknown; | |
| 65 | 61 | } |
| 66 | 62 | |
| 67 | 63 | export interface TranscriptParseResult { |
| ... | ... | @@ -89,45 +85,7 @@ const NEWLINE = 0x0a; |
| 89 | 85 | const STAT_CONCURRENCY = 32; |
| 90 | 86 | const CARRIAGE_RETURN = 0x0d; |
| 91 | 87 | |
| 92 | type SelectedFields = { readonly [key: string]: true | SelectedFields }; | |
| 93 | ||
| 94 | // Keep the fields consumed by usageTranscripts, including reducer state and | |
| 95 | // dedupe/cost metadata. A selected subtree (usage) keeps future token fields. | |
| 96 | const USAGE_FIELDS: Record<"claude" | "codex" | "grok", SelectedFields> = { | |
| 97 | claude: { | |
| 98 | type: true, | |
| 99 | timestamp: true, | |
| 100 | requestId: true, | |
| 101 | sessionId: true, | |
| 102 | costUSD: true, | |
| 103 | message: { id: true, model: true, usage: true }, | |
| 104 | }, | |
| 105 | codex: { | |
| 106 | type: true, | |
| 107 | timestamp: true, | |
| 108 | payload: { | |
| 109 | type: true, | |
| 110 | id: true, | |
| 111 | session_id: true, | |
| 112 | model: true, | |
| 113 | thread_settings: { service_tier: true }, | |
| 114 | forked_from_id: true, | |
| 115 | source: { subagent: { thread_spawn: { parent_thread_id: true } } }, | |
| 116 | info: { last_token_usage: true }, | |
| 117 | }, | |
| 118 | }, | |
| 119 | grok: { | |
| 120 | timestamp: true, | |
| 121 | params: { | |
| 122 | sessionId: true, | |
| 123 | _meta: { agentTimestampMs: true }, | |
| 124 | update: { sessionUpdate: true, prompt_id: true, usage: true }, | |
| 125 | }, | |
| 126 | }, | |
| 127 | }; | |
| 128 | ||
| 129 | function selectUsageFields(provider: UsageProviderKind) { | |
| 130 | const fields = USAGE_FIELDS[provider === "codex" || provider === "grok" ? provider : "claude"]; | |
| 88 | function selectUsageFields(fields: SelectedFields) { | |
| 131 | 89 | return (path: ReadonlyArray<string | number | null>): boolean => { |
| 132 | 90 | let selected: true | SelectedFields = fields; |
| 133 | 91 | for (const key of path) { |
| ... | ... | @@ -257,14 +215,12 @@ async function guardMatches( |
| 257 | 215 | * still match, so only appended lines are read; otherwise the whole file is |
| 258 | 216 | * re-parsed from the start and `resumed` reports `false`. |
| 259 | 217 | * |
| 260 | * Codex carries the active model on `turn_context` lines and the service tier | |
| 261 | * on `thread_settings_applied` lines. Neither holds usage of its own, but both | |
| 262 | * still have to pass through the reducer to keep attribution and pricing | |
| 263 | * correct. | |
| 218 | * A stateful format resumes with the state saved at that position; a saved | |
| 219 | * state its schema rejects re-parses the file whole. | |
| 264 | 220 | */ |
| 265 | export async function readTranscriptRecords( | |
| 221 | export async function readTranscriptRecords<State>( | |
| 266 | 222 | filePath: string, |
| 267 | provider: UsageProviderKind, | |
| 223 | format: TranscriptUsageFormat<State>, | |
| 268 | 224 | resumeFrom?: TranscriptParsePosition, |
| 269 | 225 | options?: { readonly streamingThresholdBytes?: number }, |
| 270 | 226 | ): Promise<TranscriptParseResult | null> { |
| ... | ... | @@ -277,41 +233,31 @@ export async function readTranscriptRecords( |
| 277 | 233 | } |
| 278 | 234 | |
| 279 | 235 | try { |
| 280 | let codexState = initialCodexScanState(); | |
| 236 | const codec = format.state; | |
| 237 | const decodeState = codec === undefined ? undefined : Schema.decodeUnknownOption(codec.schema); | |
| 238 | const encodeState = codec === undefined ? undefined : Schema.encodeSync(codec.schema); | |
| 239 | // Stateless formats never read their state argument. | |
| 240 | let state = codec === undefined ? (undefined as State) : codec.initial(); | |
| 281 | 241 | let resumed = false; |
| 282 | 242 | let start = 0; |
| 243 | const savedState = | |
| 244 | resumeFrom === undefined || decodeState === undefined || resumeFrom.state === null | |
| 245 | ? Option.none() | |
| 246 | : decodeState(resumeFrom.state); | |
| 283 | 247 | if ( |
| 284 | 248 | resumeFrom !== undefined && |
| 285 | 249 | resumeFrom.resumeOffset > 0 && |
| 286 | (provider !== "codex" || resumeFrom.codexState !== null) && | |
| 250 | (codec === undefined || Option.isSome(savedState)) && | |
| 287 | 251 | (await guardMatches(handle, resumeFrom)) |
| 288 | 252 | ) { |
| 289 | if (resumeFrom.codexState !== null) codexState = { ...resumeFrom.codexState }; | |
| 253 | if (Option.isSome(savedState)) state = savedState.value; | |
| 290 | 254 | start = resumeFrom.resumeOffset; |
| 291 | 255 | resumed = true; |
| 292 | 256 | } |
| 293 | 257 | |
| 294 | const parseLine = (line: string, state: CodexScanState, out: UsageRecord[]): void => { | |
| 295 | if (provider === "codex") { | |
| 296 | if ( | |
| 297 | !mightCarryUsage(line, provider) && | |
| 298 | !line.includes('"turn_context"') && | |
| 299 | !line.includes('"thread_settings_applied"') && | |
| 300 | !line.includes('"session_meta"') | |
| 301 | ) { | |
| 302 | return; | |
| 303 | } | |
| 304 | const record = parseCodexLine(line, state); | |
| 305 | if (record !== null) out.push(record); | |
| 306 | return; | |
| 307 | } | |
| 308 | if (!mightCarryUsage(line, provider)) return; | |
| 309 | if (provider === "grok") { | |
| 310 | for (const grokRecord of parseGrokLine(line)) out.push(grokRecord); | |
| 311 | return; | |
| 312 | } | |
| 313 | const record = parseClaudeLine(line); | |
| 314 | if (record !== null) out.push(record); | |
| 258 | const parseLine = (line: string, lineState: State, out: UsageRecord[]): void => { | |
| 259 | if (!format.mightCarryUsage(line)) return; | |
| 260 | for (const record of format.parseLine(line, lineState)) out.push(record); | |
| 315 | 261 | }; |
| 316 | 262 | |
| 317 | 263 | const toLineString = (lineBuffer: Buffer): string => { |
| ... | ... | @@ -331,7 +277,7 @@ export async function readTranscriptRecords( |
| 331 | 277 | let pendingBytes = 0; |
| 332 | 278 | let streaming: ReturnType<typeof createTranscriptJsonReader> | undefined; |
| 333 | 279 | let decoder: NodeStringDecoder.StringDecoder | undefined; |
| 334 | const selectPath = selectUsageFields(provider); | |
| 280 | const selectPath = selectUsageFields(format.selectFields); | |
| 335 | 281 | |
| 336 | 282 | const append = (segment: Buffer) => { |
| 337 | 283 | if (!streaming && pendingBytes + segment.length <= streamingThresholdBytes) { |
| ... | ... | @@ -350,25 +296,17 @@ export async function readTranscriptRecords( |
| 350 | 296 | } |
| 351 | 297 | streaming.write(decoder!.write(segment)); |
| 352 | 298 | }; |
| 353 | const finish = (state: CodexScanState, out: UsageRecord[]) => { | |
| 299 | const finish = (lineState: State, out: UsageRecord[]) => { | |
| 354 | 300 | if (streaming) { |
| 355 | 301 | streaming.write(decoder!.end()); |
| 356 | 302 | const projected = streaming.finish(); |
| 357 | if (provider === "grok") { | |
| 358 | out.push(...parseGrokRecord(projected)); | |
| 359 | } else { | |
| 360 | const record = | |
| 361 | provider === "codex" | |
| 362 | ? parseCodexRecord(projected, state) | |
| 363 | : parseClaudeRecord(projected); | |
| 364 | if (record !== null) out.push(record); | |
| 365 | } | |
| 303 | for (const record of format.parseProjected(projected, lineState)) out.push(record); | |
| 366 | 304 | } else if (pendingBytes > 0) { |
| 367 | 305 | const line = |
| 368 | 306 | pendingChunks.length === 1 |
| 369 | 307 | ? pendingChunks[0]! |
| 370 | 308 | : Buffer.concat(pendingChunks, pendingBytes); |
| 371 | parseLine(toLineString(line), state, out); | |
| 309 | parseLine(toLineString(line), lineState, out); | |
| 372 | 310 | } |
| 373 | 311 | pendingChunks = []; |
| 374 | 312 | pendingBytes = 0; |
| ... | ... | @@ -391,10 +329,10 @@ export async function readTranscriptRecords( |
| 391 | 329 | // Most lines fit in the current chunk. Avoid buffering/streaming |
| 392 | 330 | // machinery on this hot path. |
| 393 | 331 | if (!streaming && pendingBytes === 0) { |
| 394 | parseLine(toLineString(chunk.subarray(lineStart, newlineIndex)), codexState, records); | |
| 332 | parseLine(toLineString(chunk.subarray(lineStart, newlineIndex)), state, records); | |
| 395 | 333 | } else { |
| 396 | 334 | append(chunk.subarray(lineStart, newlineIndex)); |
| 397 | finish(codexState, records); | |
| 335 | finish(state, records); | |
| 398 | 336 | } |
| 399 | 337 | lineStart = newlineIndex + 1; |
| 400 | 338 | resumeOffset = scanOffset + lineStart; |
| ... | ... | @@ -402,8 +340,14 @@ export async function readTranscriptRecords( |
| 402 | 340 | scanOffset += chunk.length; |
| 403 | 341 | } |
| 404 | 342 | |
| 343 | // The unfinished tail parses against a copy, so the saved state stays at | |
| 344 | // `resumeOffset` and the next scan replays the tail from it. | |
| 345 | const encodedState = encodeState === undefined ? null : encodeState(state); | |
| 405 | 346 | const tailRecords: UsageRecord[] = []; |
| 406 | finish({ ...codexState }, tailRecords); | |
| 347 | finish( | |
| 348 | decodeState === undefined ? state : Option.getOrThrow(decodeState(encodedState)), | |
| 349 | tailRecords, | |
| 350 | ); | |
| 407 | 351 | |
| 408 | 352 | const guardLength = Math.min(GUARD_LENGTH, resumeOffset); |
| 409 | 353 | let guardHash = 0; |
| ... | ... | @@ -420,7 +364,7 @@ export async function readTranscriptRecords( |
| 420 | 364 | resumeOffset, |
| 421 | 365 | guardLength, |
| 422 | 366 | guardHash, |
| 423 | codexState: provider === "codex" ? codexState : null, | |
| 367 | state: encodedState, | |
| 424 | 368 | }, |
| 425 | 369 | resumed, |
| 426 | 370 | }; |
apps/server/src/usage/usageTranscriptStreaming.test.ts+6-2| ... | ... | @@ -5,6 +5,7 @@ import * as NodePath from "node:path"; |
| 5 | 5 | |
| 6 | 6 | import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; |
| 7 | 7 | |
| 8 | import { TEST_FORMATS } from "./usageTestFormats.ts"; | |
| 8 | 9 | import { |
| 9 | 10 | readTranscriptRecords as readWithDefaultThreshold, |
| 10 | 11 | type TranscriptParsePosition, |
| ... | ... | @@ -16,7 +17,10 @@ const readTranscriptRecords = ( |
| 16 | 17 | path: string, |
| 17 | 18 | provider: "claude" | "codex" | "grok", |
| 18 | 19 | position?: TranscriptParsePosition, |
| 19 | ) => readWithDefaultThreshold(path, provider, position, { streamingThresholdBytes: 256 * 1024 }); | |
| 20 | ) => | |
| 21 | readWithDefaultThreshold(path, TEST_FORMATS[provider], position, { | |
| 22 | streamingThresholdBytes: 256 * 1024, | |
| 23 | }); | |
| 20 | 24 | |
| 21 | 25 | let dir: string; |
| 22 | 26 | beforeEach(async () => { |
| ... | ... | @@ -144,7 +148,7 @@ describe("large usage records", () => { |
| 144 | 148 | ); |
| 145 | 149 | const actual = await scan(large, provider); |
| 146 | 150 | expect(actual.records).toEqual(expected.records); |
| 147 | expect(actual.position.codexState).toEqual(expected.position.codexState); | |
| 151 | expect(actual.position.state).toEqual(expected.position.state); | |
| 148 | 152 | } |
| 149 | 153 | }, |
| 150 | 154 | ); |
apps/server/src/usage/usageTranscripts.test.ts deleted-598| ... | ... | @@ -1,598 +0,0 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | GROK_COST_USD_TICKS_PER_DOLLAR, | |
| 5 | initialCodexScanState, | |
| 6 | parseClaudeLine, | |
| 7 | parseCodexLine, | |
| 8 | parseGrokLine, | |
| 9 | totalTokens, | |
| 10 | } from "./usageTranscripts.ts"; | |
| 11 | ||
| 12 | /** Shaped after a real Claude Code assistant record. */ | |
| 13 | function claudeLine(overrides: { | |
| 14 | messageId: string; | |
| 15 | contentType: string; | |
| 16 | model?: string; | |
| 17 | outputTokens?: number; | |
| 18 | speed?: string; | |
| 19 | }): string { | |
| 20 | return JSON.stringify({ | |
| 21 | type: "assistant", | |
| 22 | timestamp: "2026-08-07T04:05:13.944Z", | |
| 23 | sessionId: "5a128faa-8253-489e-b935-6c08e8e670c0", | |
| 24 | cwd: "/home/theo/project", | |
| 25 | message: { | |
| 26 | id: overrides.messageId, | |
| 27 | role: "assistant", | |
| 28 | model: overrides.model ?? "claude-fable-5", | |
| 29 | content: [{ type: overrides.contentType }], | |
| 30 | usage: { | |
| 31 | input_tokens: 2, | |
| 32 | cache_creation_input_tokens: 66818, | |
| 33 | cache_read_input_tokens: 1000, | |
| 34 | output_tokens: overrides.outputTokens ?? 286, | |
| 35 | ...(overrides.speed === undefined ? {} : { speed: overrides.speed }), | |
| 36 | }, | |
| 37 | }, | |
| 38 | }); | |
| 39 | } | |
| 40 | ||
| 41 | describe("parseClaudeLine", () => { | |
| 42 | it("extracts token totals and a dedupe key", () => { | |
| 43 | const record = parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text" })); | |
| 44 | ||
| 45 | expect(record).not.toBeNull(); | |
| 46 | expect(record?.provider).toBe("claude"); | |
| 47 | expect(record?.model).toBe("claude-fable-5"); | |
| 48 | expect(record?.totals).toEqual({ | |
| 49 | uncachedInputTokens: 2, | |
| 50 | cachedInputTokens: 1000, | |
| 51 | cacheCreationTokens: 66818, | |
| 52 | outputTokens: 286, | |
| 53 | reasoningTokens: 0, | |
| 54 | }); | |
| 55 | expect(record?.dedupeKey).toBe("msg_1:"); | |
| 56 | expect(record?.speed).toBe("standard"); | |
| 57 | }); | |
| 58 | ||
| 59 | it("marks fast-mode requests", () => { | |
| 60 | const line = (speed: string) => | |
| 61 | parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text", speed })); | |
| 62 | ||
| 63 | expect(line("fast")?.speed).toBe("fast"); | |
| 64 | expect(line("standard")?.speed).toBe("standard"); | |
| 65 | }); | |
| 66 | ||
| 67 | it("gives every content block of one message the same dedupe key", () => { | |
| 68 | // T3 Code writes one record per content block, each repeating the parent | |
| 69 | // message's full usage. Summing them would overcount ~2.4x on real data. | |
| 70 | const text = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "text" })); | |
| 71 | const toolUse = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "tool_use" })); | |
| 72 | ||
| 73 | expect(text?.dedupeKey).toBe(toolUse?.dedupeKey); | |
| 74 | expect(text?.totals).toEqual(toolUse?.totals); | |
| 75 | }); | |
| 76 | ||
| 77 | it("ignores records that are not assistant messages", () => { | |
| 78 | expect(parseClaudeLine(JSON.stringify({ type: "user", message: {} }))).toBeNull(); | |
| 79 | expect(parseClaudeLine("not json")).toBeNull(); | |
| 80 | }); | |
| 81 | }); | |
| 82 | ||
| 83 | describe("parseCodexLine", () => { | |
| 84 | const sessionMeta = JSON.stringify({ | |
| 85 | type: "session_meta", | |
| 86 | timestamp: "2026-08-01T05:17:41.289Z", | |
| 87 | payload: { type: "session_meta", id: "019fbbc1-b12c-7360-a685-28c181f0025f" }, | |
| 88 | }); | |
| 89 | const turnContext = JSON.stringify({ | |
| 90 | type: "turn_context", | |
| 91 | timestamp: "2026-08-01T05:17:42.694Z", | |
| 92 | payload: { type: "turn_context", model: "gpt-5.6-sol" }, | |
| 93 | }); | |
| 94 | const tokenCount = (inputTokens: number, cached: number, output: number, reasoning: number) => | |
| 95 | JSON.stringify({ | |
| 96 | type: "event_msg", | |
| 97 | timestamp: "2026-08-01T05:17:49.919Z", | |
| 98 | payload: { | |
| 99 | type: "token_count", | |
| 100 | info: { | |
| 101 | last_token_usage: { | |
| 102 | input_tokens: inputTokens, | |
| 103 | cached_input_tokens: cached, | |
| 104 | cache_write_input_tokens: 0, | |
| 105 | output_tokens: output, | |
| 106 | reasoning_output_tokens: reasoning, | |
| 107 | }, | |
| 108 | }, | |
| 109 | }, | |
| 110 | }); | |
| 111 | ||
| 112 | it("attributes usage to the model from the preceding turn context", () => { | |
| 113 | const state = initialCodexScanState(); | |
| 114 | parseCodexLine(sessionMeta, state); | |
| 115 | parseCodexLine(turnContext, state); | |
| 116 | const record = parseCodexLine(tokenCount(19239, 11008, 299, 116), state); | |
| 117 | ||
| 118 | expect(record?.provider).toBe("codex"); | |
| 119 | expect(record?.model).toBe("gpt-5.6-sol"); | |
| 120 | expect(record?.sessionId).toBe("019fbbc1-b12c-7360-a685-28c181f0025f"); | |
| 121 | // Codex reports input_tokens inclusive of the cached portion. | |
| 122 | expect(record?.totals.uncachedInputTokens).toBe(19239 - 11008); | |
| 123 | expect(record?.totals.cachedInputTokens).toBe(11008); | |
| 124 | expect(record?.totals.reasoningTokens).toBe(116); | |
| 125 | }); | |
| 126 | ||
| 127 | it("skips a repeated token_count so deltas are not double counted", () => { | |
| 128 | const state = initialCodexScanState(); | |
| 129 | parseCodexLine(turnContext, state); | |
| 130 | const first = parseCodexLine(tokenCount(100, 0, 10, 0), state); | |
| 131 | const repeat = parseCodexLine(tokenCount(100, 0, 10, 0), state); | |
| 132 | ||
| 133 | expect(first).not.toBeNull(); | |
| 134 | expect(repeat).toBeNull(); | |
| 135 | }); | |
| 136 | ||
| 137 | it("drops usage that arrives before any model is known", () => { | |
| 138 | const state = initialCodexScanState(); | |
| 139 | expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).toBeNull(); | |
| 140 | }); | |
| 141 | ||
| 142 | it("does not let a pre-model event poison the duplicate signature", () => { | |
| 143 | // A token_count before its turn_context is dropped; the identical event | |
| 144 | // re-emitted once the model is known must still be counted. | |
| 145 | const state = initialCodexScanState(); | |
| 146 | expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).toBeNull(); | |
| 147 | parseCodexLine(turnContext, state); | |
| 148 | expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).not.toBeNull(); | |
| 149 | }); | |
| 150 | ||
| 151 | it("carries the service tier from the latest thread settings", () => { | |
| 152 | const settings = (thread_settings: Record<string, unknown>) => | |
| 153 | JSON.stringify({ | |
| 154 | type: "event_msg", | |
| 155 | timestamp: "2026-08-01T05:17:42.000Z", | |
| 156 | payload: { type: "thread_settings_applied", thread_settings }, | |
| 157 | }); | |
| 158 | const state = initialCodexScanState(); | |
| 159 | parseCodexLine(turnContext, state); | |
| 160 | const speedAfter = (line: string, output: number) => { | |
| 161 | parseCodexLine(line, state); | |
| 162 | return parseCodexLine(tokenCount(100, 0, output, 0), state)?.speed; | |
| 163 | }; | |
| 164 | ||
| 165 | expect(parseCodexLine(tokenCount(100, 0, 1, 0), state)?.speed).toBe("standard"); | |
| 166 | expect(speedAfter(settings({ service_tier: "ultrafast" }), 2)).toBe("ultrafast"); | |
| 167 | expect(speedAfter(settings({ service_tier: "priority" }), 3)).toBe("fast"); | |
| 168 | // Codex omits the field when no tier was requested. | |
| 169 | expect(speedAfter(settings({ model: "gpt-6-astra" }), 4)).toBe("standard"); | |
| 170 | }); | |
| 171 | ||
| 172 | // A forked/subagent rollout opens with the parent's history copied in and | |
| 173 | // every line re-stamped to the fork instant, then the ancestors' session | |
| 174 | // metas. Counting those again multiplied usage ~1.85x on real data (#5758). | |
| 175 | describe("forked rollouts", () => { | |
| 176 | const meta = (overrides: { | |
| 177 | id: string; | |
| 178 | timestamp: string; | |
| 179 | forkedFromId?: string; | |
| 180 | spawnParentId?: string; | |
| 181 | }) => | |
| 182 | JSON.stringify({ | |
| 183 | type: "session_meta", | |
| 184 | timestamp: overrides.timestamp, | |
| 185 | payload: { | |
| 186 | type: "session_meta", | |
| 187 | id: overrides.id, | |
| 188 | ...(overrides.forkedFromId === undefined | |
| 189 | ? {} | |
| 190 | : { forked_from_id: overrides.forkedFromId }), | |
| 191 | ...(overrides.spawnParentId === undefined | |
| 192 | ? {} | |
| 193 | : { | |
| 194 | source: { | |
| 195 | subagent: { thread_spawn: { parent_thread_id: overrides.spawnParentId } }, | |
| 196 | }, | |
| 197 | }), | |
| 198 | }, | |
| 199 | }); | |
| 200 | const stamped = (timestamp: string, line: string) => { | |
| 201 | const parsed = JSON.parse(line) as { timestamp: string }; | |
| 202 | parsed.timestamp = timestamp; | |
| 203 | return JSON.stringify(parsed); | |
| 204 | }; | |
| 205 | ||
| 206 | it("keeps the child session id over copied ancestor metas", () => { | |
| 207 | const state = initialCodexScanState(); | |
| 208 | parseCodexLine(meta({ id: "child", timestamp: "2026-08-01T05:00:00.000Z" }), state); | |
| 209 | parseCodexLine(meta({ id: "parent", timestamp: "2026-08-01T05:00:00.000Z" }), state); | |
| 210 | parseCodexLine(turnContext, state); | |
| 211 | const record = parseCodexLine(tokenCount(100, 0, 10, 0), state); | |
| 212 | ||
| 213 | expect(record?.sessionId).toBe("child"); | |
| 214 | }); | |
| 215 | ||
| 216 | it("drops the re-stamped copied burst and keeps the first real event", () => { | |
| 217 | const state = initialCodexScanState(); | |
| 218 | const forkInstant = "2026-08-01T05:00:00.000Z"; | |
| 219 | parseCodexLine(meta({ id: "child", timestamp: forkInstant, forkedFromId: "parent" }), state); | |
| 220 | parseCodexLine(meta({ id: "parent", timestamp: forkInstant }), state); | |
| 221 | parseCodexLine(stamped(forkInstant, turnContext), state); | |
| 222 | ||
| 223 | // Copied history: written in one burst at the fork instant. | |
| 224 | expect( | |
| 225 | parseCodexLine(stamped("2026-08-01T05:00:00.001Z", tokenCount(100, 0, 10, 0)), state), | |
| 226 | ).toBeNull(); | |
| 227 | expect( | |
| 228 | parseCodexLine(stamped("2026-08-01T05:00:00.002Z", tokenCount(200, 0, 20, 0)), state), | |
| 229 | ).toBeNull(); | |
| 230 | ||
| 231 | // The child's first genuine turn lands seconds later and must count. | |
| 232 | const real = parseCodexLine( | |
| 233 | stamped("2026-08-01T05:00:06.000Z", tokenCount(300, 0, 30, 0)), | |
| 234 | state, | |
| 235 | ); | |
| 236 | expect(real).not.toBeNull(); | |
| 237 | expect(real?.totals.outputTokens).toBe(30); | |
| 238 | ||
| 239 | // Suppression never restarts, even for closely spaced later events. | |
| 240 | const next = parseCodexLine( | |
| 241 | stamped("2026-08-01T05:00:06.100Z", tokenCount(400, 0, 40, 0)), | |
| 242 | state, | |
| 243 | ); | |
| 244 | expect(next).not.toBeNull(); | |
| 245 | }); | |
| 246 | ||
| 247 | it("recognizes subagent spawns without forked_from_id", () => { | |
| 248 | const state = initialCodexScanState(); | |
| 249 | const spawnInstant = "2026-08-01T05:00:00.000Z"; | |
| 250 | parseCodexLine( | |
| 251 | meta({ id: "child", timestamp: spawnInstant, spawnParentId: "parent" }), | |
| 252 | state, | |
| 253 | ); | |
| 254 | parseCodexLine(stamped(spawnInstant, turnContext), state); | |
| 255 | expect( | |
| 256 | parseCodexLine(stamped("2026-08-01T05:00:00.001Z", tokenCount(100, 0, 10, 0)), state), | |
| 257 | ).toBeNull(); | |
| 258 | }); | |
| 259 | ||
| 260 | it("does not suppress anything in a rollout that is not a fork", () => { | |
| 261 | const state = initialCodexScanState(); | |
| 262 | parseCodexLine(meta({ id: "root", timestamp: "2026-08-01T05:00:00.000Z" }), state); | |
| 263 | parseCodexLine(stamped("2026-08-01T05:00:00.100Z", turnContext), state); | |
| 264 | const record = parseCodexLine( | |
| 265 | stamped("2026-08-01T05:00:00.200Z", tokenCount(100, 0, 10, 0)), | |
| 266 | state, | |
| 267 | ); | |
| 268 | expect(record).not.toBeNull(); | |
| 269 | }); | |
| 270 | }); | |
| 271 | }); | |
| 272 | ||
| 273 | describe("totalTokens", () => { | |
| 274 | it("does not add reasoning on top of output", () => { | |
| 275 | expect( | |
| 276 | totalTokens({ | |
| 277 | uncachedInputTokens: 10, | |
| 278 | cachedInputTokens: 20, | |
| 279 | cacheCreationTokens: 30, | |
| 280 | outputTokens: 40, | |
| 281 | reasoningTokens: 25, | |
| 282 | }), | |
| 283 | ).toBe(100); | |
| 284 | }); | |
| 285 | }); | |
| 286 | ||
| 287 | describe("parseGrokLine", () => { | |
| 288 | /** Shaped after a real Grok Build `turn_completed` session update. */ | |
| 289 | function turnCompleted(overrides?: { | |
| 290 | sessionId?: string; | |
| 291 | promptId?: string; | |
| 292 | timestamp?: number; | |
| 293 | agentTimestampMs?: number; | |
| 294 | usage?: Record<string, unknown>; | |
| 295 | modelUsage?: Record<string, Record<string, unknown>> | null; | |
| 296 | }): string { | |
| 297 | const modelUsage = | |
| 298 | overrides && "modelUsage" in overrides | |
| 299 | ? overrides.modelUsage | |
| 300 | : { | |
| 301 | "grok-4.5-build": { | |
| 302 | inputTokens: 20_272, | |
| 303 | outputTokens: 272, | |
| 304 | totalTokens: 20_544, | |
| 305 | cachedReadTokens: 11_264, | |
| 306 | cacheCreationTokens: 0, | |
| 307 | reasoningTokens: 180, | |
| 308 | costUsdTicks: 230_272_000, | |
| 309 | }, | |
| 310 | }; | |
| 311 | ||
| 312 | return JSON.stringify({ | |
| 313 | timestamp: overrides?.timestamp ?? 1_786_372_566, | |
| 314 | method: "_x.ai/session/update", | |
| 315 | params: { | |
| 316 | sessionId: overrides?.sessionId ?? "019fec1a-12f7-72f2-9b1f-7778a00aea3c", | |
| 317 | update: { | |
| 318 | sessionUpdate: "turn_completed", | |
| 319 | prompt_id: overrides?.promptId ?? "prompt-1", | |
| 320 | stop_reason: "end_turn", | |
| 321 | usage: { | |
| 322 | inputTokens: 20_272, | |
| 323 | outputTokens: 272, | |
| 324 | totalTokens: 20_544, | |
| 325 | cachedReadTokens: 11_264, | |
| 326 | cacheCreationTokens: 0, | |
| 327 | reasoningTokens: 180, | |
| 328 | costUsdTicks: 230_272_000, | |
| 329 | ...(modelUsage === null ? {} : { modelUsage }), | |
| 330 | ...overrides?.usage, | |
| 331 | }, | |
| 332 | }, | |
| 333 | _meta: { | |
| 334 | eventId: "event-1", | |
| 335 | agentTimestampMs: overrides?.agentTimestampMs ?? 1_786_372_566_485, | |
| 336 | }, | |
| 337 | }, | |
| 338 | }); | |
| 339 | } | |
| 340 | ||
| 341 | it("extracts per-model totals and provider-reported cost ticks", () => { | |
| 342 | const records = parseGrokLine(turnCompleted()); | |
| 343 | ||
| 344 | expect(records).toHaveLength(1); | |
| 345 | const [record] = records; | |
| 346 | expect(record?.provider).toBe("grok"); | |
| 347 | expect(record?.model).toBe("grok-4.5-build"); | |
| 348 | expect(record?.sessionId).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c"); | |
| 349 | expect(record?.timestampMs).toBe(1_786_372_566_485); | |
| 350 | expect(record?.totals).toEqual({ | |
| 351 | uncachedInputTokens: 20_272 - 11_264, | |
| 352 | cachedInputTokens: 11_264, | |
| 353 | cacheCreationTokens: 0, | |
| 354 | outputTokens: 272, | |
| 355 | reasoningTokens: 180, | |
| 356 | }); | |
| 357 | expect(record?.reportedCostUsd).toBeCloseTo(230_272_000 / GROK_COST_USD_TICKS_PER_DOLLAR, 12); | |
| 358 | expect(record?.dedupeKey).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c:prompt-1:grok-4.5-build"); | |
| 359 | }); | |
| 360 | ||
| 361 | it("emits one record per model when modelUsage has several entries", () => { | |
| 362 | const records = parseGrokLine( | |
| 363 | turnCompleted({ | |
| 364 | modelUsage: { | |
| 365 | "grok-4.5": { | |
| 366 | inputTokens: 1000, | |
| 367 | outputTokens: 50, | |
| 368 | cachedReadTokens: 400, | |
| 369 | reasoningTokens: 20, | |
| 370 | costUsdTicks: 50_000_000, | |
| 371 | }, | |
| 372 | "grok-composer-2.5-fast": { | |
| 373 | inputTokens: 200, | |
| 374 | outputTokens: 30, | |
| 375 | cachedReadTokens: 100, | |
| 376 | reasoningTokens: 0, | |
| 377 | costUsdTicks: 10_000_000, | |
| 378 | }, | |
| 379 | }, | |
| 380 | }), | |
| 381 | ); | |
| 382 | ||
| 383 | expect(records.map((record) => record.model).toSorted()).toEqual([ | |
| 384 | "grok-4.5", | |
| 385 | "grok-composer-2.5-fast", | |
| 386 | ]); | |
| 387 | expect(records.every((record) => record.provider === "grok")).toBe(true); | |
| 388 | expect(records.find((record) => record.model === "grok-4.5")?.reportedCostUsd).toBeCloseTo( | |
| 389 | 0.005, | |
| 390 | 12, | |
| 391 | ); | |
| 392 | }); | |
| 393 | ||
| 394 | it("inherits top-level cost ticks for a single model without its own ticks", () => { | |
| 395 | const records = parseGrokLine( | |
| 396 | turnCompleted({ | |
| 397 | modelUsage: { | |
| 398 | "grok-4.5-build": { | |
| 399 | inputTokens: 1000, | |
| 400 | outputTokens: 10, | |
| 401 | cachedReadTokens: 0, | |
| 402 | reasoningTokens: 0, | |
| 403 | }, | |
| 404 | }, | |
| 405 | usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR }, | |
| 406 | }), | |
| 407 | ); | |
| 408 | ||
| 409 | expect(records).toHaveLength(1); | |
| 410 | expect(records[0]?.reportedCostUsd).toBe(1); | |
| 411 | }); | |
| 412 | ||
| 413 | it("falls back to a generic grok model when modelUsage is absent", () => { | |
| 414 | const records = parseGrokLine(turnCompleted({ modelUsage: null })); | |
| 415 | ||
| 416 | expect(records).toHaveLength(1); | |
| 417 | const [record] = records; | |
| 418 | expect(record?.provider).toBe("grok"); | |
| 419 | expect(record?.model).toBe("grok"); | |
| 420 | expect(record?.totals).toEqual({ | |
| 421 | uncachedInputTokens: 20_272 - 11_264, | |
| 422 | cachedInputTokens: 11_264, | |
| 423 | cacheCreationTokens: 0, | |
| 424 | outputTokens: 272, | |
| 425 | reasoningTokens: 180, | |
| 426 | }); | |
| 427 | expect(record?.reportedCostUsd).toBeCloseTo(230_272_000 / GROK_COST_USD_TICKS_PER_DOLLAR, 12); | |
| 428 | expect(record?.dedupeKey).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c:prompt-1:grok"); | |
| 429 | }); | |
| 430 | ||
| 431 | it("pro-rates top-level cost ticks across multi-model turns without per-model ticks", () => { | |
| 432 | const records = parseGrokLine( | |
| 433 | turnCompleted({ | |
| 434 | modelUsage: { | |
| 435 | "grok-4.5": { | |
| 436 | inputTokens: 300, | |
| 437 | outputTokens: 0, | |
| 438 | cachedReadTokens: 0, | |
| 439 | reasoningTokens: 0, | |
| 440 | }, | |
| 441 | "grok-composer-2.5-fast": { | |
| 442 | inputTokens: 100, | |
| 443 | outputTokens: 0, | |
| 444 | cachedReadTokens: 0, | |
| 445 | reasoningTokens: 0, | |
| 446 | }, | |
| 447 | }, | |
| 448 | usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR }, | |
| 449 | }), | |
| 450 | ); | |
| 451 | ||
| 452 | expect(records).toHaveLength(2); | |
| 453 | const byModel = Object.fromEntries(records.map((record) => [record.model, record])); | |
| 454 | expect(byModel["grok-4.5"]?.reportedCostUsd).toBeCloseTo(0.75, 12); | |
| 455 | expect(byModel["grok-composer-2.5-fast"]?.reportedCostUsd).toBeCloseTo(0.25, 12); | |
| 456 | const sum = | |
| 457 | (byModel["grok-4.5"]?.reportedCostUsd ?? 0) + | |
| 458 | (byModel["grok-composer-2.5-fast"]?.reportedCostUsd ?? 0); | |
| 459 | expect(sum).toBeCloseTo(1, 12); | |
| 460 | }); | |
| 461 | ||
| 462 | it("pro-rates aggregate cost when a zero-token sibling carries costUsdTicks: 0", () => { | |
| 463 | const records = parseGrokLine( | |
| 464 | turnCompleted({ | |
| 465 | modelUsage: { | |
| 466 | "grok-4.5": { | |
| 467 | inputTokens: 300, | |
| 468 | outputTokens: 0, | |
| 469 | cachedReadTokens: 0, | |
| 470 | reasoningTokens: 0, | |
| 471 | }, | |
| 472 | "grok-composer-2.5-fast": { | |
| 473 | inputTokens: 100, | |
| 474 | outputTokens: 0, | |
| 475 | cachedReadTokens: 0, | |
| 476 | reasoningTokens: 0, | |
| 477 | }, | |
| 478 | "empty-sibling": { | |
| 479 | inputTokens: 0, | |
| 480 | outputTokens: 0, | |
| 481 | cachedReadTokens: 0, | |
| 482 | reasoningTokens: 0, | |
| 483 | costUsdTicks: 0, | |
| 484 | }, | |
| 485 | }, | |
| 486 | usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR }, | |
| 487 | }), | |
| 488 | ); | |
| 489 | ||
| 490 | expect(records).toHaveLength(2); | |
| 491 | expect(records.every((record) => record.model !== "empty-sibling")).toBe(true); | |
| 492 | const byModel = Object.fromEntries(records.map((record) => [record.model, record])); | |
| 493 | expect(byModel["grok-4.5"]?.reportedCostUsd).toBeCloseTo(0.75, 12); | |
| 494 | expect(byModel["grok-composer-2.5-fast"]?.reportedCostUsd).toBeCloseTo(0.25, 12); | |
| 495 | const sum = | |
| 496 | (byModel["grok-4.5"]?.reportedCostUsd ?? 0) + | |
| 497 | (byModel["grok-composer-2.5-fast"]?.reportedCostUsd ?? 0); | |
| 498 | expect(sum).toBeCloseTo(1, 12); | |
| 499 | }); | |
| 500 | ||
| 501 | it("allocates leftover aggregate ticks to models that omit per-model ticks", () => { | |
| 502 | const records = parseGrokLine( | |
| 503 | turnCompleted({ | |
| 504 | modelUsage: { | |
| 505 | "grok-4.5": { | |
| 506 | inputTokens: 300, | |
| 507 | outputTokens: 0, | |
| 508 | cachedReadTokens: 0, | |
| 509 | reasoningTokens: 0, | |
| 510 | costUsdTicks: 0.4 * GROK_COST_USD_TICKS_PER_DOLLAR, | |
| 511 | }, | |
| 512 | "grok-composer-2.5-fast": { | |
| 513 | inputTokens: 100, | |
| 514 | outputTokens: 0, | |
| 515 | cachedReadTokens: 0, | |
| 516 | reasoningTokens: 0, | |
| 517 | }, | |
| 518 | }, | |
| 519 | usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR }, | |
| 520 | }), | |
| 521 | ); | |
| 522 | ||
| 523 | expect(records).toHaveLength(2); | |
| 524 | const byModel = Object.fromEntries(records.map((record) => [record.model, record])); | |
| 525 | expect(byModel["grok-4.5"]?.reportedCostUsd).toBeCloseTo(0.4, 12); | |
| 526 | expect(byModel["grok-composer-2.5-fast"]?.reportedCostUsd).toBeCloseTo(0.6, 12); | |
| 527 | const sum = | |
| 528 | (byModel["grok-4.5"]?.reportedCostUsd ?? 0) + | |
| 529 | (byModel["grok-composer-2.5-fast"]?.reportedCostUsd ?? 0); | |
| 530 | expect(sum).toBeCloseTo(1, 12); | |
| 531 | }); | |
| 532 | ||
| 533 | it("does not invent a colliding dedupe key when prompt_id is missing", () => { | |
| 534 | const line = JSON.stringify({ | |
| 535 | timestamp: 1_786_372_566, | |
| 536 | method: "_x.ai/session/update", | |
| 537 | params: { | |
| 538 | sessionId: "s1", | |
| 539 | update: { | |
| 540 | sessionUpdate: "turn_completed", | |
| 541 | usage: { | |
| 542 | inputTokens: 10, | |
| 543 | outputTokens: 2, | |
| 544 | modelUsage: { | |
| 545 | "grok-4.5": { inputTokens: 10, outputTokens: 2 }, | |
| 546 | }, | |
| 547 | }, | |
| 548 | }, | |
| 549 | }, | |
| 550 | }); | |
| 551 | ||
| 552 | expect(parseGrokLine(line)[0]?.dedupeKey).toBeNull(); | |
| 553 | }); | |
| 554 | ||
| 555 | it("ignores non-turn lines and empty usage", () => { | |
| 556 | expect(parseGrokLine(JSON.stringify({ method: "session/update", params: {} }))).toEqual([]); | |
| 557 | expect(parseGrokLine("not json")).toEqual([]); | |
| 558 | expect( | |
| 559 | parseGrokLine( | |
| 560 | turnCompleted({ | |
| 561 | modelUsage: { | |
| 562 | "grok-4.5-build": { | |
| 563 | inputTokens: 0, | |
| 564 | outputTokens: 0, | |
| 565 | cachedReadTokens: 0, | |
| 566 | reasoningTokens: 0, | |
| 567 | costUsdTicks: 0, | |
| 568 | }, | |
| 569 | }, | |
| 570 | }), | |
| 571 | ), | |
| 572 | ).toEqual([]); | |
| 573 | }); | |
| 574 | ||
| 575 | it("falls back to the outer unix-seconds timestamp when agent meta is missing", () => { | |
| 576 | const line = JSON.stringify({ | |
| 577 | timestamp: 1_786_372_566, | |
| 578 | method: "_x.ai/session/update", | |
| 579 | params: { | |
| 580 | sessionId: "s1", | |
| 581 | update: { | |
| 582 | sessionUpdate: "turn_completed", | |
| 583 | prompt_id: "p1", | |
| 584 | usage: { | |
| 585 | inputTokens: 10, | |
| 586 | outputTokens: 2, | |
| 587 | modelUsage: { | |
| 588 | "grok-4.5": { inputTokens: 10, outputTokens: 2 }, | |
| 589 | }, | |
| 590 | }, | |
| 591 | }, | |
| 592 | }, | |
| 593 | }); | |
| 594 | ||
| 595 | const records = parseGrokLine(line); | |
| 596 | expect(records[0]?.timestampMs).toBe(1_786_372_566_000); | |
| 597 | }); | |
| 598 | }); |
apps/server/src/usage/usageTranscripts.ts deleted-530| ... | ... | @@ -1,530 +0,0 @@ |
| 1 | /** | |
| 2 | * Pure parsers for the provider CLIs' on-disk session transcripts. | |
| 3 | * | |
| 4 | * Each parser is a line-at-a-time reducer so callers can stream large files | |
| 5 | * without materialising them. None of them touch the filesystem. | |
| 6 | * | |
| 7 | * @module usageTranscripts | |
| 8 | */ | |
| 9 | import type { UsageProviderKind, UsageTokenTotals } from "@t3tools/contracts"; | |
| 10 | ||
| 11 | /** | |
| 12 | * Billing speed of a request. Faster speeds bill at a model-specific premium. | |
| 13 | * Claude fast mode and Codex `priority` are `fast`; Codex `ultrafast` is its | |
| 14 | * own, more expensive tier. | |
| 15 | */ | |
| 16 | export type UsageSpeed = "standard" | "fast" | "ultrafast"; | |
| 17 | ||
| 18 | export interface UsageRecord { | |
| 19 | readonly provider: UsageProviderKind; | |
| 20 | readonly timestampMs: number; | |
| 21 | readonly model: string; | |
| 22 | /** | |
| 23 | * Rate-table key when the provider's display name carries tiers the table | |
| 24 | * does not know, such as Cursor's `claude-opus-5-5-high`. Defaults to `model`. | |
| 25 | */ | |
| 26 | readonly rateModel?: string; | |
| 27 | readonly sessionId: string; | |
| 28 | readonly totals: UsageTokenTotals; | |
| 29 | readonly reportedCostUsd: number | null; | |
| 30 | /** Only Claude Code and Codex record a speed; other providers are `standard`. */ | |
| 31 | readonly speed: UsageSpeed; | |
| 32 | /** | |
| 33 | * Key for cross-file de-duplication, or `null` when the record is inherently | |
| 34 | * unique and needs no dedup. | |
| 35 | */ | |
| 36 | readonly dedupeKey: string | null; | |
| 37 | } | |
| 38 | ||
| 39 | const EMPTY_TOTALS: UsageTokenTotals = { | |
| 40 | uncachedInputTokens: 0, | |
| 41 | cachedInputTokens: 0, | |
| 42 | cacheCreationTokens: 0, | |
| 43 | outputTokens: 0, | |
| 44 | reasoningTokens: 0, | |
| 45 | }; | |
| 46 | ||
| 47 | function int(value: unknown): number { | |
| 48 | return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; | |
| 49 | } | |
| 50 | ||
| 51 | function parseTimestampMs(value: unknown): number | null { | |
| 52 | if (typeof value !== "string") return null; | |
| 53 | const parsed = Date.parse(value); | |
| 54 | return Number.isNaN(parsed) ? null : parsed; | |
| 55 | } | |
| 56 | ||
| 57 | export function totalTokens(totals: UsageTokenTotals): number { | |
| 58 | // reasoningTokens is a subset of outputTokens and must not be added again. | |
| 59 | return ( | |
| 60 | totals.uncachedInputTokens + | |
| 61 | totals.cachedInputTokens + | |
| 62 | totals.cacheCreationTokens + | |
| 63 | totals.outputTokens | |
| 64 | ); | |
| 65 | } | |
| 66 | ||
| 67 | /** | |
| 68 | * Cheap substring gate applied before `JSON.parse`. | |
| 69 | * | |
| 70 | * Transcripts are mostly tool output; only a minority of lines carry usage. On | |
| 71 | * a 30-day window this skips roughly half the lines outright and is worth about | |
| 72 | * an order of magnitude. | |
| 73 | */ | |
| 74 | export function mightCarryUsage(line: string, provider: UsageProviderKind): boolean { | |
| 75 | if (provider === "claude") return line.includes('"usage"'); | |
| 76 | if (provider === "grok") return line.includes('"turn_completed"'); | |
| 77 | return line.includes('"token_count"'); | |
| 78 | } | |
| 79 | ||
| 80 | /** | |
| 81 | * Grok reports cost in integer ticks where `1 USD = 10^10` ticks. See Grok | |
| 82 | * headless `total_cost_usd_ticks`. Convert to dollars for pricing. | |
| 83 | */ | |
| 84 | export const GROK_COST_USD_TICKS_PER_DOLLAR = 10_000_000_000; | |
| 85 | ||
| 86 | function grokCostTicksToUsd(ticks: unknown): number | null { | |
| 87 | if (typeof ticks !== "number" || !Number.isFinite(ticks) || ticks < 0) return null; | |
| 88 | return ticks / GROK_COST_USD_TICKS_PER_DOLLAR; | |
| 89 | } | |
| 90 | ||
| 91 | /* -------------------------------------------------------------------------- */ | |
| 92 | /* Claude Code */ | |
| 93 | /* -------------------------------------------------------------------------- */ | |
| 94 | ||
| 95 | /** | |
| 96 | * Parses one line of a Claude Code transcript. | |
| 97 | * | |
| 98 | * T3 Code writes one record per assistant *content block*, and every one of | |
| 99 | * those records repeats the same complete `usage` object for the parent | |
| 100 | * message. Summing them overcounts by roughly 2.4x on a real workload, so the | |
| 101 | * caller must drop repeats by `dedupeKey` and keep the first. | |
| 102 | */ | |
| 103 | export function parseClaudeLine(line: string): UsageRecord | null { | |
| 104 | let parsed: unknown; | |
| 105 | try { | |
| 106 | parsed = JSON.parse(line); | |
| 107 | } catch { | |
| 108 | return null; | |
| 109 | } | |
| 110 | return parseClaudeRecord(parsed); | |
| 111 | } | |
| 112 | ||
| 113 | export function parseClaudeRecord(parsed: unknown): UsageRecord | null { | |
| 114 | if (typeof parsed !== "object" || parsed === null) return null; | |
| 115 | ||
| 116 | const record = parsed as Record<string, unknown>; | |
| 117 | if (record["type"] !== "assistant") return null; | |
| 118 | ||
| 119 | const message = record["message"]; | |
| 120 | if (typeof message !== "object" || message === null) return null; | |
| 121 | const messageRecord = message as Record<string, unknown>; | |
| 122 | ||
| 123 | const usage = messageRecord["usage"]; | |
| 124 | if (typeof usage !== "object" || usage === null) return null; | |
| 125 | const usageRecord = usage as Record<string, unknown>; | |
| 126 | ||
| 127 | const timestampMs = parseTimestampMs(record["timestamp"]); | |
| 128 | if (timestampMs === null) return null; | |
| 129 | ||
| 130 | const model = typeof messageRecord["model"] === "string" ? messageRecord["model"] : ""; | |
| 131 | if (model.length === 0) return null; | |
| 132 | ||
| 133 | const messageId = typeof messageRecord["id"] === "string" ? messageRecord["id"] : null; | |
| 134 | const requestId = typeof record["requestId"] === "string" ? record["requestId"] : null; | |
| 135 | // Matches ccusage: prefer the message/request pair, fall back to whichever | |
| 136 | // half exists. Records with neither cannot be de-duplicated. | |
| 137 | const dedupeKey = | |
| 138 | messageId === null && requestId === null ? null : `${messageId ?? ""}:${requestId ?? ""}`; | |
| 139 | ||
| 140 | const cost = record["costUSD"]; | |
| 141 | ||
| 142 | return { | |
| 143 | provider: "claude", | |
| 144 | timestampMs, | |
| 145 | model, | |
| 146 | sessionId: typeof record["sessionId"] === "string" ? record["sessionId"] : "", | |
| 147 | totals: { | |
| 148 | uncachedInputTokens: int(usageRecord["input_tokens"]), | |
| 149 | cachedInputTokens: int(usageRecord["cache_read_input_tokens"]), | |
| 150 | cacheCreationTokens: int(usageRecord["cache_creation_input_tokens"]), | |
| 151 | outputTokens: int(usageRecord["output_tokens"]), | |
| 152 | // Anthropic folds thinking tokens into output and does not break them out. | |
| 153 | reasoningTokens: 0, | |
| 154 | }, | |
| 155 | reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null, | |
| 156 | speed: usageRecord["speed"] === "fast" ? "fast" : "standard", | |
| 157 | dedupeKey, | |
| 158 | }; | |
| 159 | } | |
| 160 | ||
| 161 | /* -------------------------------------------------------------------------- */ | |
| 162 | /* Codex */ | |
| 163 | /* -------------------------------------------------------------------------- */ | |
| 164 | ||
| 165 | /** | |
| 166 | * Rolling state for a single Codex rollout file. | |
| 167 | * | |
| 168 | * Codex `token_count` events carry no model or service tier, so both are | |
| 169 | * carried forward: the model from the most recent `turn_context`, the tier from | |
| 170 | * the most recent `thread_settings_applied`. Sessions that switch either | |
| 171 | * mid-run attribute correctly from the switch onward. | |
| 172 | */ | |
| 173 | export interface CodexScanState { | |
| 174 | model: string; | |
| 175 | speed: UsageSpeed; | |
| 176 | sessionId: string; | |
| 177 | lastUsageSignature: string | null; | |
| 178 | sawSessionMeta: boolean; | |
| 179 | /** While true, leading usage events are re-stamped copies of parent history. */ | |
| 180 | suppressingForkCopies: boolean; | |
| 181 | forkCopyAnchorMs: number; | |
| 182 | } | |
| 183 | ||
| 184 | export function initialCodexScanState(): CodexScanState { | |
| 185 | return { | |
| 186 | model: "", | |
| 187 | speed: "standard", | |
| 188 | sessionId: "", | |
| 189 | lastUsageSignature: null, | |
| 190 | sawSessionMeta: false, | |
| 191 | suppressingForkCopies: false, | |
| 192 | forkCopyAnchorMs: 0, | |
| 193 | }; | |
| 194 | } | |
| 195 | ||
| 196 | /** | |
| 197 | * A forked or subagent rollout opens with the parent's full history copied in, | |
| 198 | * every line re-stamped to the fork instant. Those copies are written in one | |
| 199 | * synchronous burst (observed gaps 0-40ms), while the child's first genuine | |
| 200 | * usage event only lands after a real model turn (observed 5s+). One second of | |
| 201 | * separation splits the two cleanly; `ccusage` uses the same threshold. | |
| 202 | */ | |
| 203 | const FORK_COPY_MAX_GAP_MS = 1000; | |
| 204 | ||
| 205 | /** Whether a `session_meta` payload marks the rollout as a fork or subagent. */ | |
| 206 | function isForkedSessionMeta(payload: Record<string, unknown>): boolean { | |
| 207 | if (typeof payload["forked_from_id"] === "string") return true; | |
| 208 | const source = payload["source"]; | |
| 209 | if (typeof source !== "object" || source === null) return false; | |
| 210 | const subagent = (source as Record<string, unknown>)["subagent"]; | |
| 211 | if (typeof subagent !== "object" || subagent === null) return false; | |
| 212 | const spawn = (subagent as Record<string, unknown>)["thread_spawn"]; | |
| 213 | if (typeof spawn !== "object" || spawn === null) return false; | |
| 214 | return typeof (spawn as Record<string, unknown>)["parent_thread_id"] === "string"; | |
| 215 | } | |
| 216 | ||
| 217 | /** | |
| 218 | * Feeds one line of a Codex rollout into `state`, returning a record when the | |
| 219 | * line was a usage event. | |
| 220 | * | |
| 221 | * Deltas come from `last_token_usage`. Summing those across a session | |
| 222 | * reconciles with the session's final `total_token_usage`, provided | |
| 223 | * consecutive duplicate events are dropped, which this does. | |
| 224 | */ | |
| 225 | export function parseCodexLine(line: string, state: CodexScanState): UsageRecord | null { | |
| 226 | let parsed: unknown; | |
| 227 | try { | |
| 228 | parsed = JSON.parse(line); | |
| 229 | } catch { | |
| 230 | return null; | |
| 231 | } | |
| 232 | return parseCodexRecord(parsed, state); | |
| 233 | } | |
| 234 | ||
| 235 | export function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | null { | |
| 236 | if (typeof parsed !== "object" || parsed === null) return null; | |
| 237 | ||
| 238 | const record = parsed as Record<string, unknown>; | |
| 239 | const payload = record["payload"]; | |
| 240 | if (typeof payload !== "object" || payload === null) return null; | |
| 241 | const payloadRecord = payload as Record<string, unknown>; | |
| 242 | const payloadType = payloadRecord["type"]; | |
| 243 | ||
| 244 | if (record["type"] === "session_meta") { | |
| 245 | // Only the first meta describes this file's own session. A forked rollout | |
| 246 | // repeats the ancestors' metas right after it; letting those through would | |
| 247 | // reassign every subsequent record to an ancestor session. | |
| 248 | if (state.sawSessionMeta) return null; | |
| 249 | state.sawSessionMeta = true; | |
| 250 | const id = payloadRecord["id"] ?? payloadRecord["session_id"]; | |
| 251 | if (typeof id === "string") state.sessionId = id; | |
| 252 | const metaTimestampMs = parseTimestampMs(record["timestamp"]); | |
| 253 | if (metaTimestampMs !== null && isForkedSessionMeta(payloadRecord)) { | |
| 254 | state.suppressingForkCopies = true; | |
| 255 | state.forkCopyAnchorMs = metaTimestampMs; | |
| 256 | } | |
| 257 | return null; | |
| 258 | } | |
| 259 | ||
| 260 | if (record["type"] === "turn_context") { | |
| 261 | if (typeof payloadRecord["model"] === "string") state.model = payloadRecord["model"]; | |
| 262 | return null; | |
| 263 | } | |
| 264 | ||
| 265 | if (payloadType === "thread_settings_applied") { | |
| 266 | const settings = payloadRecord["thread_settings"]; | |
| 267 | if (typeof settings === "object" && settings !== null) { | |
| 268 | state.speed = codexSpeed((settings as Record<string, unknown>)["service_tier"]); | |
| 269 | } | |
| 270 | return null; | |
| 271 | } | |
| 272 | ||
| 273 | if (payloadType !== "token_count") return null; | |
| 274 | ||
| 275 | const info = payloadRecord["info"]; | |
| 276 | if (typeof info !== "object" || info === null) return null; | |
| 277 | const last = (info as Record<string, unknown>)["last_token_usage"]; | |
| 278 | if (typeof last !== "object" || last === null) return null; | |
| 279 | const lastRecord = last as Record<string, unknown>; | |
| 280 | ||
| 281 | // Only an event that is otherwise eligible may consume the duplicate | |
| 282 | // signature. A token_count arriving before its turn_context (no model yet) | |
| 283 | // must not poison it, or the re-emitted copy after the model is known would | |
| 284 | // be skipped as a duplicate and those tokens never counted. | |
| 285 | const timestampMs = parseTimestampMs(record["timestamp"]); | |
| 286 | if (timestampMs === null) return null; | |
| 287 | if (state.model.length === 0) return null; | |
| 288 | ||
| 289 | // Codex re-emits an unchanged token_count on some stream boundaries. Summing | |
| 290 | // those would double count, so identical consecutive payloads are skipped. | |
| 291 | const signature = JSON.stringify(lastRecord); | |
| 292 | if (signature === state.lastUsageSignature) return null; | |
| 293 | state.lastUsageSignature = signature; | |
| 294 | ||
| 295 | // In a forked rollout the copied parent history was already counted from the | |
| 296 | // parent's own file. Drop the leading burst; the first usage event separated | |
| 297 | // from its predecessor by a real turn's worth of time ends it for good. | |
| 298 | if (state.suppressingForkCopies) { | |
| 299 | if (timestampMs - state.forkCopyAnchorMs < FORK_COPY_MAX_GAP_MS) { | |
| 300 | state.forkCopyAnchorMs = timestampMs; | |
| 301 | return null; | |
| 302 | } | |
| 303 | state.suppressingForkCopies = false; | |
| 304 | } | |
| 305 | ||
| 306 | const inputTokens = int(lastRecord["input_tokens"]); | |
| 307 | const cachedInputTokens = int(lastRecord["cached_input_tokens"]); | |
| 308 | const cacheCreationTokens = int(lastRecord["cache_write_input_tokens"]); | |
| 309 | const outputTokens = int(lastRecord["output_tokens"]); | |
| 310 | ||
| 311 | const totals: UsageTokenTotals = { | |
| 312 | // Codex reports `input_tokens` inclusive of the cached portion. | |
| 313 | uncachedInputTokens: Math.max(0, inputTokens - cachedInputTokens - cacheCreationTokens), | |
| 314 | cachedInputTokens, | |
| 315 | cacheCreationTokens, | |
| 316 | outputTokens, | |
| 317 | // Reported inside output_tokens, surfaced separately for the token mix. | |
| 318 | reasoningTokens: Math.min(outputTokens, int(lastRecord["reasoning_output_tokens"])), | |
| 319 | }; | |
| 320 | ||
| 321 | if (totalTokens(totals) === 0) return null; | |
| 322 | ||
| 323 | return { | |
| 324 | provider: "codex", | |
| 325 | timestampMs, | |
| 326 | model: state.model, | |
| 327 | sessionId: state.sessionId, | |
| 328 | totals, | |
| 329 | // Codex does not report cost in the rollout. | |
| 330 | reportedCostUsd: null, | |
| 331 | speed: state.speed, | |
| 332 | // Events surviving the fork-copy suppression above are unique to this | |
| 333 | // rollout, so they need no global dedup. | |
| 334 | dedupeKey: null, | |
| 335 | }; | |
| 336 | } | |
| 337 | ||
| 338 | /** | |
| 339 | * Maps a Codex `service_tier` to its billing speed. Codex omits the field when | |
| 340 | * no tier was requested, which bills as standard, as do `default` and | |
| 341 | * `standard`. `fast` is accepted as an alias of `priority`. | |
| 342 | */ | |
| 343 | function codexSpeed(serviceTier: unknown): UsageSpeed { | |
| 344 | if (serviceTier === "priority" || serviceTier === "fast") return "fast"; | |
| 345 | if (serviceTier === "ultrafast") return "ultrafast"; | |
| 346 | return "standard"; | |
| 347 | } | |
| 348 | ||
| 349 | /* -------------------------------------------------------------------------- */ | |
| 350 | /* Grok Build */ | |
| 351 | /* -------------------------------------------------------------------------- */ | |
| 352 | ||
| 353 | interface GrokUsageTotals { | |
| 354 | readonly inputTokens: number; | |
| 355 | readonly outputTokens: number; | |
| 356 | readonly cachedReadTokens: number; | |
| 357 | readonly cacheCreationTokens: number; | |
| 358 | readonly reasoningTokens: number; | |
| 359 | readonly costUsdTicks: number | null; | |
| 360 | } | |
| 361 | ||
| 362 | function readGrokUsageTotals(value: unknown): GrokUsageTotals | null { | |
| 363 | if (typeof value !== "object" || value === null) return null; | |
| 364 | const record = value as Record<string, unknown>; | |
| 365 | return { | |
| 366 | inputTokens: int(record["inputTokens"]), | |
| 367 | outputTokens: int(record["outputTokens"]), | |
| 368 | cachedReadTokens: int(record["cachedReadTokens"]), | |
| 369 | cacheCreationTokens: int(record["cacheCreationTokens"]), | |
| 370 | reasoningTokens: int(record["reasoningTokens"]), | |
| 371 | costUsdTicks: | |
| 372 | typeof record["costUsdTicks"] === "number" && Number.isFinite(record["costUsdTicks"]) | |
| 373 | ? record["costUsdTicks"] | |
| 374 | : null, | |
| 375 | }; | |
| 376 | } | |
| 377 | ||
| 378 | function grokTotalsToUsage(totals: GrokUsageTotals): UsageTokenTotals { | |
| 379 | const cachedInputTokens = totals.cachedReadTokens; | |
| 380 | const cacheCreationTokens = totals.cacheCreationTokens; | |
| 381 | // Grok reports `inputTokens` inclusive of the cached portion, matching Codex. | |
| 382 | const uncachedInputTokens = Math.max( | |
| 383 | 0, | |
| 384 | totals.inputTokens - cachedInputTokens - cacheCreationTokens, | |
| 385 | ); | |
| 386 | const outputTokens = totals.outputTokens; | |
| 387 | return { | |
| 388 | uncachedInputTokens, | |
| 389 | cachedInputTokens, | |
| 390 | cacheCreationTokens, | |
| 391 | outputTokens, | |
| 392 | reasoningTokens: Math.min(outputTokens, totals.reasoningTokens), | |
| 393 | }; | |
| 394 | } | |
| 395 | ||
| 396 | /** | |
| 397 | * Parses one line of a Grok Build `updates.jsonl` session log. | |
| 398 | * | |
| 399 | * Usage lands on `turn_completed` session updates. Per-model breakdowns live | |
| 400 | * under `usage.modelUsage`; when present each model becomes its own record. | |
| 401 | * | |
| 402 | * Returns every record for the line (0 or more). Callers stream line-by-line | |
| 403 | * and flatten. | |
| 404 | */ | |
| 405 | export function parseGrokLine(line: string): readonly UsageRecord[] { | |
| 406 | let parsed: unknown; | |
| 407 | try { | |
| 408 | parsed = JSON.parse(line); | |
| 409 | } catch { | |
| 410 | return []; | |
| 411 | } | |
| 412 | return parseGrokRecord(parsed); | |
| 413 | } | |
| 414 | ||
| 415 | export function parseGrokRecord(parsed: unknown): readonly UsageRecord[] { | |
| 416 | if (typeof parsed !== "object" || parsed === null) return []; | |
| 417 | ||
| 418 | const record = parsed as Record<string, unknown>; | |
| 419 | const params = record["params"]; | |
| 420 | if (typeof params !== "object" || params === null) return []; | |
| 421 | const paramsRecord = params as Record<string, unknown>; | |
| 422 | ||
| 423 | const update = paramsRecord["update"]; | |
| 424 | if (typeof update !== "object" || update === null) return []; | |
| 425 | const updateRecord = update as Record<string, unknown>; | |
| 426 | if (updateRecord["sessionUpdate"] !== "turn_completed") return []; | |
| 427 | ||
| 428 | const usage = updateRecord["usage"]; | |
| 429 | if (typeof usage !== "object" || usage === null) return []; | |
| 430 | const usageRecord = usage as Record<string, unknown>; | |
| 431 | ||
| 432 | const sessionId = typeof paramsRecord["sessionId"] === "string" ? paramsRecord["sessionId"] : ""; | |
| 433 | const promptId = typeof updateRecord["prompt_id"] === "string" ? updateRecord["prompt_id"] : null; | |
| 434 | ||
| 435 | // Prefer the high-resolution agent clock; fall back to the outer unix seconds. | |
| 436 | const meta = paramsRecord["_meta"]; | |
| 437 | let timestampMs: number | null = null; | |
| 438 | if (typeof meta === "object" && meta !== null) { | |
| 439 | const agentTimestampMs = (meta as Record<string, unknown>)["agentTimestampMs"]; | |
| 440 | if (typeof agentTimestampMs === "number" && Number.isFinite(agentTimestampMs)) { | |
| 441 | timestampMs = agentTimestampMs; | |
| 442 | } | |
| 443 | } | |
| 444 | if (timestampMs === null) { | |
| 445 | const timestamp = record["timestamp"]; | |
| 446 | if (typeof timestamp === "number" && Number.isFinite(timestamp)) { | |
| 447 | timestampMs = timestamp > 1e12 ? timestamp : timestamp * 1000; | |
| 448 | } | |
| 449 | } | |
| 450 | if (timestampMs === null) return []; | |
| 451 | ||
| 452 | const topLevel = readGrokUsageTotals(usageRecord); | |
| 453 | if (topLevel === null) return []; | |
| 454 | ||
| 455 | const modelUsage = usageRecord["modelUsage"]; | |
| 456 | const modelEntries: Array<{ model: string; totals: GrokUsageTotals }> = []; | |
| 457 | if (typeof modelUsage === "object" && modelUsage !== null) { | |
| 458 | for (const [model, raw] of Object.entries(modelUsage as Record<string, unknown>)) { | |
| 459 | if (model.length === 0) continue; | |
| 460 | const totals = readGrokUsageTotals(raw); | |
| 461 | if (totals === null) continue; | |
| 462 | modelEntries.push({ model, totals }); | |
| 463 | } | |
| 464 | } | |
| 465 | ||
| 466 | if (modelEntries.length === 0) { | |
| 467 | if (totalTokens(grokTotalsToUsage(topLevel)) === 0) return []; | |
| 468 | return [ | |
| 469 | { | |
| 470 | provider: "grok", | |
| 471 | timestampMs, | |
| 472 | model: "grok", | |
| 473 | sessionId, | |
| 474 | totals: grokTotalsToUsage(topLevel), | |
| 475 | reportedCostUsd: grokCostTicksToUsd(topLevel.costUsdTicks), | |
| 476 | speed: "standard", | |
| 477 | // No prompt id means we cannot tell two same-second updates apart. | |
| 478 | dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:grok`, | |
| 479 | }, | |
| 480 | ]; | |
| 481 | } | |
| 482 | ||
| 483 | // Cost allocation: | |
| 484 | // 1. Emitted models with their own costUsdTicks keep those values. | |
| 485 | // 2. Remaining aggregate cost (top-level minus those per-model ticks, | |
| 486 | // clamped at 0) is pro-rated across emitted models that lack ticks, | |
| 487 | // by token share among the unticked models only. | |
| 488 | // 3. When no model has per-model ticks, remaining equals the full | |
| 489 | // aggregate and every emitted model gets a token-share slice. | |
| 490 | // Zero-token rows are never emitted and never count toward used ticks. | |
| 491 | const topLevelCostUsd = grokCostTicksToUsd(topLevel.costUsdTicks); | |
| 492 | let usedTickedCostUsd = 0; | |
| 493 | let untickedTokenDenominator = 0; | |
| 494 | for (const entry of modelEntries) { | |
| 495 | const tokens = totalTokens(grokTotalsToUsage(entry.totals)); | |
| 496 | if (tokens === 0) continue; | |
| 497 | if (entry.totals.costUsdTicks !== null) { | |
| 498 | usedTickedCostUsd += grokCostTicksToUsd(entry.totals.costUsdTicks) ?? 0; | |
| 499 | } else { | |
| 500 | untickedTokenDenominator += tokens; | |
| 501 | } | |
| 502 | } | |
| 503 | const remainingCostUsd = | |
| 504 | topLevelCostUsd === null ? null : Math.max(0, topLevelCostUsd - usedTickedCostUsd); | |
| 505 | ||
| 506 | const results: UsageRecord[] = []; | |
| 507 | for (const entry of modelEntries) { | |
| 508 | const totals = grokTotalsToUsage(entry.totals); | |
| 509 | if (totalTokens(totals) === 0) continue; | |
| 510 | ||
| 511 | let reportedCostUsd = grokCostTicksToUsd(entry.totals.costUsdTicks); | |
| 512 | if (reportedCostUsd === null && remainingCostUsd !== null && untickedTokenDenominator > 0) { | |
| 513 | reportedCostUsd = remainingCostUsd * (totalTokens(totals) / untickedTokenDenominator); | |
| 514 | } | |
| 515 | ||
| 516 | results.push({ | |
| 517 | provider: "grok", | |
| 518 | timestampMs, | |
| 519 | model: entry.model, | |
| 520 | sessionId, | |
| 521 | totals, | |
| 522 | reportedCostUsd, | |
| 523 | speed: "standard", | |
| 524 | dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:${entry.model}`, | |
| 525 | }); | |
| 526 | } | |
| 527 | return results; | |
| 528 | } | |
| 529 | ||
| 530 | export { EMPTY_TOTALS }; |
packages/provider-core/package.json+4| ... | ... | @@ -175,6 +175,10 @@ |
| 175 | 175 | "types": "./src/server/threadTitleContext.ts", |
| 176 | 176 | "import": "./src/server/threadTitleContext.ts" |
| 177 | 177 | }, |
| 178 | "./server/usage": { | |
| 179 | "types": "./src/server/usage.ts", | |
| 180 | "import": "./src/server/usage.ts" | |
| 181 | }, | |
| 178 | 182 | "./server/usageLimits": { |
| 179 | 183 | "types": "./src/server/usageLimits.ts", |
| 180 | 184 | "import": "./src/server/usageLimits.ts" |
packages/provider-core/src/server/driver.ts+12-2| ... | ... | @@ -42,6 +42,7 @@ import type { ProviderAuthController } from "./auth.ts"; |
| 42 | 42 | import type { ProviderDriverError } from "./errors.ts"; |
| 43 | 43 | import type { ManagedServerProvider } from "./snapshot.ts"; |
| 44 | 44 | import type { ProviderTextGeneration } from "./textGeneration.ts"; |
| 45 | import type { ProviderUsageReader } from "./usage.ts"; | |
| 45 | 46 | |
| 46 | 47 | /** |
| 47 | 48 | * Static metadata advertised by a driver. Used for default presentation |
| ... | ... | @@ -172,7 +173,7 @@ export interface ProviderDriverCreateInput<Config> { |
| 172 | 173 | * scope closes. Two calls to `create` with different `instanceId` / |
| 173 | 174 | * `config` MUST yield instances with no shared mutable state. |
| 174 | 175 | */ |
| 175 | export interface ProviderDriver<Config, R = never> { | |
| 176 | export interface ProviderDriver<Config, R = never, UsageR = never> { | |
| 176 | 177 | readonly driverKind: ProviderDriverKind; |
| 177 | 178 | readonly metadata: ProviderDriverMetadata; |
| 178 | 179 | /** |
| ... | ... | @@ -207,6 +208,11 @@ export interface ProviderDriver<Config, R = never> { |
| 207 | 208 | readonly create: ( |
| 208 | 209 | input: ProviderDriverCreateInput<Config>, |
| 209 | 210 | ) => Effect.Effect<ProviderInstance, ProviderDriverError, R | Scope.Scope>; |
| 211 | /** | |
| 212 | * Where the usage page reads this provider's token history, when it keeps | |
| 213 | * one. `UsageR` is what the reader needs, usually far less than `R`. | |
| 214 | */ | |
| 215 | readonly usage?: ProviderUsageReader<Config, UsageR>; | |
| 210 | 216 | } |
| 211 | 217 | |
| 212 | 218 | /** |
| ... | ... | @@ -219,4 +225,8 @@ export interface ProviderDriver<Config, R = never> { |
| 219 | 225 | // needs the original `Config` type. Using `unknown` instead would force |
| 220 | 226 | // `create` callers into casts since `unknown` is not assignable to a |
| 221 | 227 | // concrete `Config` from inside the driver body. |
| 222 | export type AnyProviderDriver<R = never> = ProviderDriver<any, R>; | |
| 228 | export type AnyProviderDriver<R = never, UsageR = unknown> = ProviderDriver<any, R, UsageR>; | |
| 229 | ||
| 230 | /** The services a driver's usage reader needs. */ | |
| 231 | export type ProviderUsageReaderEnv<Driver> = | |
| 232 | Driver extends ProviderDriver<any, any, infer UsageR> ? UsageR : never; |
packages/provider-core/src/server/usage.test.ts created+17| ... | ... | @@ -0,0 +1,17 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { totalTokens } from "./usage.ts"; | |
| 4 | ||
| 5 | describe("totalTokens", () => { | |
| 6 | it("does not add reasoning on top of output", () => { | |
| 7 | expect( | |
| 8 | totalTokens({ | |
| 9 | uncachedInputTokens: 10, | |
| 10 | cachedInputTokens: 20, | |
| 11 | cacheCreationTokens: 30, | |
| 12 | outputTokens: 40, | |
| 13 | reasoningTokens: 25, | |
| 14 | }), | |
| 15 | ).toBe(100); | |
| 16 | }); | |
| 17 | }); |
packages/provider-core/src/server/usage.ts created+179| ... | ... | @@ -0,0 +1,179 @@ |
| 1 | /** | |
| 2 | * The usage reader contract: how a driver tells the server's usage page where | |
| 3 | * its history lives and how to read it. | |
| 4 | * | |
| 5 | * A driver either points at JSONL transcripts that the server's shared engine | |
| 6 | * streams, caches and resumes (`kind: "transcripts"`), or scans its own | |
| 7 | * sources and returns their records (`kind: "scan"`). The server owns | |
| 8 | * pricing, aggregation, de-duplication and the scan cache. | |
| 9 | * | |
| 10 | * @module provider-core/server/usage | |
| 11 | */ | |
| 12 | import type { | |
| 13 | ProviderInstanceId, | |
| 14 | UsageProviderKind, | |
| 15 | UsageSource, | |
| 16 | UsageTokenTotals, | |
| 17 | } from "@t3tools/contracts"; | |
| 18 | import type * as Effect from "effect/Effect"; | |
| 19 | import type * as Schema from "effect/Schema"; | |
| 20 | ||
| 21 | /** | |
| 22 | * Billing speed of a request. Faster speeds bill at a model-specific premium. | |
| 23 | * Claude fast mode and Codex `priority` are `fast`; Codex `ultrafast` is its | |
| 24 | * own, more expensive tier. | |
| 25 | */ | |
| 26 | export type UsageSpeed = "standard" | "fast" | "ultrafast"; | |
| 27 | ||
| 28 | export interface UsageRecord { | |
| 29 | readonly provider: UsageProviderKind; | |
| 30 | readonly timestampMs: number; | |
| 31 | readonly model: string; | |
| 32 | /** | |
| 33 | * Rate-table key when the provider's display name carries tiers the table | |
| 34 | * does not know, such as Cursor's `claude-opus-5-5-high`. Defaults to `model`. | |
| 35 | */ | |
| 36 | readonly rateModel?: string; | |
| 37 | readonly sessionId: string; | |
| 38 | readonly totals: UsageTokenTotals; | |
| 39 | readonly reportedCostUsd: number | null; | |
| 40 | /** Only Claude Code and Codex record a speed; other providers are `standard`. */ | |
| 41 | readonly speed: UsageSpeed; | |
| 42 | /** | |
| 43 | * Key for cross-file de-duplication, or `null` when the record is inherently | |
| 44 | * unique and needs no dedup. | |
| 45 | */ | |
| 46 | readonly dedupeKey: string | null; | |
| 47 | } | |
| 48 | ||
| 49 | export const EMPTY_TOTALS: UsageTokenTotals = { | |
| 50 | uncachedInputTokens: 0, | |
| 51 | cachedInputTokens: 0, | |
| 52 | cacheCreationTokens: 0, | |
| 53 | outputTokens: 0, | |
| 54 | reasoningTokens: 0, | |
| 55 | }; | |
| 56 | ||
| 57 | export function totalTokens(totals: UsageTokenTotals): number { | |
| 58 | // reasoningTokens is a subset of outputTokens and must not be added again. | |
| 59 | return ( | |
| 60 | totals.uncachedInputTokens + | |
| 61 | totals.cachedInputTokens + | |
| 62 | totals.cacheCreationTokens + | |
| 63 | totals.outputTokens | |
| 64 | ); | |
| 65 | } | |
| 66 | ||
| 67 | /** A token count from untrusted JSON: a positive finite number, truncated, else 0. */ | |
| 68 | export function tokenCount(value: unknown): number { | |
| 69 | return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; | |
| 70 | } | |
| 71 | ||
| 72 | /** Epoch milliseconds of an ISO timestamp string, or `null`. */ | |
| 73 | export function parseTimestampMs(value: unknown): number | null { | |
| 74 | if (typeof value !== "string") return null; | |
| 75 | const parsed = Date.parse(value); | |
| 76 | return Number.isNaN(parsed) ? null : parsed; | |
| 77 | } | |
| 78 | ||
| 79 | /** JSON fields a format reads. `true` keeps the whole subtree. */ | |
| 80 | export type SelectedFields = { readonly [key: string]: true | SelectedFields }; | |
| 81 | ||
| 82 | /** | |
| 83 | * How the server's JSONL engine reads one provider's transcript lines. | |
| 84 | * | |
| 85 | * Lines arrive one at a time. A line too large to parse whole is projected to | |
| 86 | * `selectFields` and handed to `parseProjected` instead of `parseLine`. | |
| 87 | */ | |
| 88 | export interface TranscriptUsageFormat<State> { | |
| 89 | readonly selectFields: SelectedFields; | |
| 90 | /** | |
| 91 | * Cheap substring gate applied before `JSON.parse`. Transcripts are mostly | |
| 92 | * tool output; skipping lines that cannot matter is worth an order of | |
| 93 | * magnitude. Lines that only update `State` must pass too. | |
| 94 | */ | |
| 95 | mightCarryUsage(line: string): boolean; | |
| 96 | parseLine(line: string, state: State): readonly UsageRecord[]; | |
| 97 | parseProjected(projected: unknown, state: State): readonly UsageRecord[]; | |
| 98 | /** | |
| 99 | * Present for formats whose records depend on earlier lines. The state at a | |
| 100 | * resume point is persisted in the scan cache through `schema`, so a grown | |
| 101 | * file resumes with it. Absent means every line stands alone. | |
| 102 | */ | |
| 103 | readonly state?: { | |
| 104 | readonly initial: () => State; | |
| 105 | readonly schema: Schema.Codec<State, unknown>; | |
| 106 | }; | |
| 107 | /** | |
| 108 | * Whether one session's records may appear in several files, such as a | |
| 109 | * rollout copied when it moves. The server then keys those sessions' | |
| 110 | * records for cross-file de-duplication. | |
| 111 | */ | |
| 112 | readonly sharedSessionsAcrossFiles?: true; | |
| 113 | } | |
| 114 | ||
| 115 | /** A configured instance of the driver, its config already decoded by `driver.configSchema`. */ | |
| 116 | export interface ProviderUsageInstance<Config> { | |
| 117 | readonly instanceId: ProviderInstanceId; | |
| 118 | /** | |
| 119 | * `undefined` when the stored config does not decode. The instance still has | |
| 120 | * history on disk, so readers that need no config read it anyway. | |
| 121 | */ | |
| 122 | readonly config: Config | undefined; | |
| 123 | /** The instance's environment merged over the host environment. */ | |
| 124 | readonly environment: NodeJS.ProcessEnv; | |
| 125 | /** Whether settings list the instance, rather than it being the implicit default. */ | |
| 126 | readonly configured: boolean; | |
| 127 | } | |
| 128 | ||
| 129 | /** One source a `scan` reader read. */ | |
| 130 | export interface ProviderUsageScan { | |
| 131 | readonly dir: string; | |
| 132 | readonly volumeId: string; | |
| 133 | /** Overrides the server's host name in the source fingerprint, for account-wide sources. */ | |
| 134 | readonly hostId?: string; | |
| 135 | readonly status?: UsageSource["status"]; | |
| 136 | readonly message?: string; | |
| 137 | readonly action?: UsageSource["action"]; | |
| 138 | /** Parsed records per file, or `null` when the source does not exist. */ | |
| 139 | readonly files: | |
| 140 | | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[] | |
| 141 | | null; | |
| 142 | /** Answered from a cache while a refresh runs. */ | |
| 143 | readonly refreshing?: true; | |
| 144 | } | |
| 145 | ||
| 146 | export interface ProviderUsageScanInput<Config> { | |
| 147 | readonly instances: ReadonlyArray<ProviderUsageInstance<Config>>; | |
| 148 | /** Files last written before this cannot hold records inside the window. */ | |
| 149 | readonly windowStartMs: number; | |
| 150 | /** The oldest usage the server keeps; account caches cover back to here. */ | |
| 151 | readonly retentionCutoffMs: number; | |
| 152 | /** Wait for a slow refresh instead of answering from a cache. */ | |
| 153 | readonly awaitRefresh: boolean; | |
| 154 | } | |
| 155 | ||
| 156 | export type ProviderUsageReader<Config, R> = | |
| 157 | | { | |
| 158 | readonly kind: "transcripts"; | |
| 159 | readonly provider: UsageProviderKind; | |
| 160 | readonly format: TranscriptUsageFormat<unknown>; | |
| 161 | /** | |
| 162 | * Transcript directories for one instance. The server canonicalizes, | |
| 163 | * de-duplicates and scans them; `fileName` limits a walk to one basename. | |
| 164 | */ | |
| 165 | readonly directories: ( | |
| 166 | instance: ProviderUsageInstance<Config>, | |
| 167 | ) => Effect.Effect< | |
| 168 | ReadonlyArray<{ readonly dir: string; readonly fileName?: string }>, | |
| 169 | never, | |
| 170 | R | |
| 171 | >; | |
| 172 | } | |
| 173 | | { | |
| 174 | readonly kind: "scan"; | |
| 175 | readonly provider: UsageProviderKind; | |
| 176 | readonly scan: ( | |
| 177 | input: ProviderUsageScanInput<Config>, | |
| 178 | ) => Effect.Effect<ReadonlyArray<ProviderUsageScan>, never, R>; | |
| 179 | }; |
packages/provider-grok/package.json+4| ... | ... | @@ -15,6 +15,10 @@ |
| 15 | 15 | "types": "./src/server.ts", |
| 16 | 16 | "import": "./src/server.ts" |
| 17 | 17 | }, |
| 18 | "./server/usage": { | |
| 19 | "types": "./src/server/usage.ts", | |
| 20 | "import": "./src/server/usage.ts" | |
| 21 | }, | |
| 18 | 22 | "./testing": { |
| 19 | 23 | "types": "./src/testing.ts", |
| 20 | 24 | "import": "./src/testing.ts" |
packages/provider-grok/src/server/driver.ts+3-1| ... | ... | @@ -19,6 +19,7 @@ import { |
| 19 | 19 | checkGrokProviderStatus, |
| 20 | 20 | enrichGrokSnapshot, |
| 21 | 21 | } from "./status.ts"; |
| 22 | import { grokUsageReader } from "./usage.ts"; | |
| 22 | 23 | import { readGrokAccount } from "./usageLimits.ts"; |
| 23 | 24 | import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; |
| 24 | 25 | import { |
| ... | ... | @@ -82,7 +83,7 @@ export type GrokDriverEnv = |
| 82 | 83 | | Path.Path |
| 83 | 84 | | ProviderEventLoggers.ProviderEventLoggers; |
| 84 | 85 | |
| 85 | export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = { | |
| 86 | export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv, Path.Path> = { | |
| 86 | 87 | driverKind: DRIVER_KIND, |
| 87 | 88 | metadata: { |
| 88 | 89 | displayName: "Grok", |
| ... | ... | @@ -90,6 +91,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = { |
| 90 | 91 | }, |
| 91 | 92 | configSchema: GrokSettings, |
| 92 | 93 | defaultConfig: (): GrokSettings => decodeGrokSettings({}), |
| 94 | usage: grokUsageReader, | |
| 93 | 95 | create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => |
| 94 | 96 | Effect.gen(function* () { |
| 95 | 97 | const crypto = yield* Crypto.Crypto; |
packages/provider-grok/src/server/usage.test.ts created+316| ... | ... | @@ -0,0 +1,316 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { GROK_COST_USD_TICKS_PER_DOLLAR, parseGrokLine } from "./usage.ts"; | |
| 4 | ||
| 5 | describe("parseGrokLine", () => { | |
| 6 | /** Shaped after a real Grok Build `turn_completed` session update. */ | |
| 7 | function turnCompleted(overrides?: { | |
| 8 | sessionId?: string; | |
| 9 | promptId?: string; | |
| 10 | timestamp?: number; | |
| 11 | agentTimestampMs?: number; | |
| 12 | usage?: Record<string, unknown>; | |
| 13 | modelUsage?: Record<string, Record<string, unknown>> | null; | |
| 14 | }): string { | |
| 15 | const modelUsage = | |
| 16 | overrides && "modelUsage" in overrides | |
| 17 | ? overrides.modelUsage | |
| 18 | : { | |
| 19 | "grok-4.5-build": { | |
| 20 | inputTokens: 20_272, | |
| 21 | outputTokens: 272, | |
| 22 | totalTokens: 20_544, | |
| 23 | cachedReadTokens: 11_264, | |
| 24 | cacheCreationTokens: 0, | |
| 25 | reasoningTokens: 180, | |
| 26 | costUsdTicks: 230_272_000, | |
| 27 | }, | |
| 28 | }; | |
| 29 | ||
| 30 | return JSON.stringify({ | |
| 31 | timestamp: overrides?.timestamp ?? 1_786_372_566, | |
| 32 | method: "_x.ai/session/update", | |
| 33 | params: { | |
| 34 | sessionId: overrides?.sessionId ?? "019fec1a-12f7-72f2-9b1f-7778a00aea3c", | |
| 35 | update: { | |
| 36 | sessionUpdate: "turn_completed", | |
| 37 | prompt_id: overrides?.promptId ?? "prompt-1", | |
| 38 | stop_reason: "end_turn", | |
| 39 | usage: { | |
| 40 | inputTokens: 20_272, | |
| 41 | outputTokens: 272, | |
| 42 | totalTokens: 20_544, | |
| 43 | cachedReadTokens: 11_264, | |
| 44 | cacheCreationTokens: 0, | |
| 45 | reasoningTokens: 180, | |
| 46 | costUsdTicks: 230_272_000, | |
| 47 | ...(modelUsage === null ? {} : { modelUsage }), | |
| 48 | ...overrides?.usage, | |
| 49 | }, | |
| 50 | }, | |
| 51 | _meta: { | |
| 52 | eventId: "event-1", | |
| 53 | agentTimestampMs: overrides?.agentTimestampMs ?? 1_786_372_566_485, | |
| 54 | }, | |
| 55 | }, | |
| 56 | }); | |
| 57 | } | |
| 58 | ||
| 59 | it("extracts per-model totals and provider-reported cost ticks", () => { | |
| 60 | const records = parseGrokLine(turnCompleted()); | |
| 61 | ||
| 62 | expect(records).toHaveLength(1); | |
| 63 | const [record] = records; | |
| 64 | expect(record?.provider).toBe("grok"); | |
| 65 | expect(record?.model).toBe("grok-4.5-build"); | |
| 66 | expect(record?.sessionId).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c"); | |
| 67 | expect(record?.timestampMs).toBe(1_786_372_566_485); | |
| 68 | expect(record?.totals).toEqual({ | |
| 69 | uncachedInputTokens: 20_272 - 11_264, | |
| 70 | cachedInputTokens: 11_264, | |
| 71 | cacheCreationTokens: 0, | |
| 72 | outputTokens: 272, | |
| 73 | reasoningTokens: 180, | |
| 74 | }); | |
| 75 | expect(record?.reportedCostUsd).toBeCloseTo(230_272_000 / GROK_COST_USD_TICKS_PER_DOLLAR, 12); | |
| 76 | expect(record?.dedupeKey).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c:prompt-1:grok-4.5-build"); | |
| 77 | }); | |
| 78 | ||
| 79 | it("emits one record per model when modelUsage has several entries", () => { | |
| 80 | const records = parseGrokLine( | |
| 81 | turnCompleted({ | |
| 82 | modelUsage: { | |
| 83 | "grok-4.5": { | |
| 84 | inputTokens: 1000, | |
| 85 | outputTokens: 50, | |
| 86 | cachedReadTokens: 400, | |
| 87 | reasoningTokens: 20, | |
| 88 | costUsdTicks: 50_000_000, | |
| 89 | }, | |
| 90 | "grok-composer-2.5-fast": { | |
| 91 | inputTokens: 200, | |
| 92 | outputTokens: 30, | |
| 93 | cachedReadTokens: 100, | |
| 94 | reasoningTokens: 0, | |
| 95 | costUsdTicks: 10_000_000, | |
| 96 | }, | |
| 97 | }, | |
| 98 | }), | |
| 99 | ); | |
| 100 | ||
| 101 | expect(records.map((record) => record.model).toSorted()).toEqual([ | |
| 102 | "grok-4.5", | |
| 103 | "grok-composer-2.5-fast", | |
| 104 | ]); | |
| 105 | expect(records.every((record) => record.provider === "grok")).toBe(true); | |
| 106 | expect(records.find((record) => record.model === "grok-4.5")?.reportedCostUsd).toBeCloseTo( | |
| 107 | 0.005, | |
| 108 | 12, | |
| 109 | ); | |
| 110 | }); | |
| 111 | ||
| 112 | it("inherits top-level cost ticks for a single model without its own ticks", () => { | |
| 113 | const records = parseGrokLine( | |
| 114 | turnCompleted({ | |
| 115 | modelUsage: { | |
| 116 | "grok-4.5-build": { | |
| 117 | inputTokens: 1000, | |
| 118 | outputTokens: 10, | |
| 119 | cachedReadTokens: 0, | |
| 120 | reasoningTokens: 0, | |
| 121 | }, | |
| 122 | }, | |
| 123 | usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR }, | |
| 124 | }), | |
| 125 | ); | |
| 126 | ||
| 127 | expect(records).toHaveLength(1); | |
| 128 | expect(records[0]?.reportedCostUsd).toBe(1); | |
| 129 | }); | |
| 130 | ||
| 131 | it("falls back to a generic grok model when modelUsage is absent", () => { | |
| 132 | const records = parseGrokLine(turnCompleted({ modelUsage: null })); | |
| 133 | ||
| 134 | expect(records).toHaveLength(1); | |
| 135 | const [record] = records; | |
| 136 | expect(record?.provider).toBe("grok"); | |
| 137 | expect(record?.model).toBe("grok"); | |
| 138 | expect(record?.totals).toEqual({ | |
| 139 | uncachedInputTokens: 20_272 - 11_264, | |
| 140 | cachedInputTokens: 11_264, | |
| 141 | cacheCreationTokens: 0, | |
| 142 | outputTokens: 272, | |
| 143 | reasoningTokens: 180, | |
| 144 | }); | |
| 145 | expect(record?.reportedCostUsd).toBeCloseTo(230_272_000 / GROK_COST_USD_TICKS_PER_DOLLAR, 12); | |
| 146 | expect(record?.dedupeKey).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c:prompt-1:grok"); | |
| 147 | }); | |
| 148 | ||
| 149 | it("pro-rates top-level cost ticks across multi-model turns without per-model ticks", () => { | |
| 150 | const records = parseGrokLine( | |
| 151 | turnCompleted({ | |
| 152 | modelUsage: { | |
| 153 | "grok-4.5": { | |
| 154 | inputTokens: 300, | |
| 155 | outputTokens: 0, | |
| 156 | cachedReadTokens: 0, | |
| 157 | reasoningTokens: 0, | |
| 158 | }, | |
| 159 | "grok-composer-2.5-fast": { | |
| 160 | inputTokens: 100, | |
| 161 | outputTokens: 0, | |
| 162 | cachedReadTokens: 0, | |
| 163 | reasoningTokens: 0, | |
| 164 | }, | |
| 165 | }, | |
| 166 | usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR }, | |
| 167 | }), | |
| 168 | ); | |
| 169 | ||
| 170 | expect(records).toHaveLength(2); | |
| 171 | const byModel = Object.fromEntries(records.map((record) => [record.model, record])); | |
| 172 | expect(byModel["grok-4.5"]?.reportedCostUsd).toBeCloseTo(0.75, 12); | |
| 173 | expect(byModel["grok-composer-2.5-fast"]?.reportedCostUsd).toBeCloseTo(0.25, 12); | |
| 174 | const sum = | |
| 175 | (byModel["grok-4.5"]?.reportedCostUsd ?? 0) + | |
| 176 | (byModel["grok-composer-2.5-fast"]?.reportedCostUsd ?? 0); | |
| 177 | expect(sum).toBeCloseTo(1, 12); | |
| 178 | }); | |
| 179 | ||
| 180 | it("pro-rates aggregate cost when a zero-token sibling carries costUsdTicks: 0", () => { | |
| 181 | const records = parseGrokLine( | |
| 182 | turnCompleted({ | |
| 183 | modelUsage: { | |
| 184 | "grok-4.5": { | |
| 185 | inputTokens: 300, | |
| 186 | outputTokens: 0, | |
| 187 | cachedReadTokens: 0, | |
| 188 | reasoningTokens: 0, | |
| 189 | }, | |
| 190 | "grok-composer-2.5-fast": { | |
| 191 | inputTokens: 100, | |
| 192 | outputTokens: 0, | |
| 193 | cachedReadTokens: 0, | |
| 194 | reasoningTokens: 0, | |
| 195 | }, | |
| 196 | "empty-sibling": { | |
| 197 | inputTokens: 0, | |
| 198 | outputTokens: 0, | |
| 199 | cachedReadTokens: 0, | |
| 200 | reasoningTokens: 0, | |
| 201 | costUsdTicks: 0, | |
| 202 | }, | |
| 203 | }, | |
| 204 | usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR }, | |
| 205 | }), | |
| 206 | ); | |
| 207 | ||
| 208 | expect(records).toHaveLength(2); | |
| 209 | expect(records.every((record) => record.model !== "empty-sibling")).toBe(true); | |
| 210 | const byModel = Object.fromEntries(records.map((record) => [record.model, record])); | |
| 211 | expect(byModel["grok-4.5"]?.reportedCostUsd).toBeCloseTo(0.75, 12); | |
| 212 | expect(byModel["grok-composer-2.5-fast"]?.reportedCostUsd).toBeCloseTo(0.25, 12); | |
| 213 | const sum = | |
| 214 | (byModel["grok-4.5"]?.reportedCostUsd ?? 0) + | |
| 215 | (byModel["grok-composer-2.5-fast"]?.reportedCostUsd ?? 0); | |
| 216 | expect(sum).toBeCloseTo(1, 12); | |
| 217 | }); | |
| 218 | ||
| 219 | it("allocates leftover aggregate ticks to models that omit per-model ticks", () => { | |
| 220 | const records = parseGrokLine( | |
| 221 | turnCompleted({ | |
| 222 | modelUsage: { | |
| 223 | "grok-4.5": { | |
| 224 | inputTokens: 300, | |
| 225 | outputTokens: 0, | |
| 226 | cachedReadTokens: 0, | |
| 227 | reasoningTokens: 0, | |
| 228 | costUsdTicks: 0.4 * GROK_COST_USD_TICKS_PER_DOLLAR, | |
| 229 | }, | |
| 230 | "grok-composer-2.5-fast": { | |
| 231 | inputTokens: 100, | |
| 232 | outputTokens: 0, | |
| 233 | cachedReadTokens: 0, | |
| 234 | reasoningTokens: 0, | |
| 235 | }, | |
| 236 | }, | |
| 237 | usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR }, | |
| 238 | }), | |
| 239 | ); | |
| 240 | ||
| 241 | expect(records).toHaveLength(2); | |
| 242 | const byModel = Object.fromEntries(records.map((record) => [record.model, record])); | |
| 243 | expect(byModel["grok-4.5"]?.reportedCostUsd).toBeCloseTo(0.4, 12); | |
| 244 | expect(byModel["grok-composer-2.5-fast"]?.reportedCostUsd).toBeCloseTo(0.6, 12); | |
| 245 | const sum = | |
| 246 | (byModel["grok-4.5"]?.reportedCostUsd ?? 0) + | |
| 247 | (byModel["grok-composer-2.5-fast"]?.reportedCostUsd ?? 0); | |
| 248 | expect(sum).toBeCloseTo(1, 12); | |
| 249 | }); | |
| 250 | ||
| 251 | it("does not invent a colliding dedupe key when prompt_id is missing", () => { | |
| 252 | const line = JSON.stringify({ | |
| 253 | timestamp: 1_786_372_566, | |
| 254 | method: "_x.ai/session/update", | |
| 255 | params: { | |
| 256 | sessionId: "s1", | |
| 257 | update: { | |
| 258 | sessionUpdate: "turn_completed", | |
| 259 | usage: { | |
| 260 | inputTokens: 10, | |
| 261 | outputTokens: 2, | |
| 262 | modelUsage: { | |
| 263 | "grok-4.5": { inputTokens: 10, outputTokens: 2 }, | |
| 264 | }, | |
| 265 | }, | |
| 266 | }, | |
| 267 | }, | |
| 268 | }); | |
| 269 | ||
| 270 | expect(parseGrokLine(line)[0]?.dedupeKey).toBeNull(); | |
| 271 | }); | |
| 272 | ||
| 273 | it("ignores non-turn lines and empty usage", () => { | |
| 274 | expect(parseGrokLine(JSON.stringify({ method: "session/update", params: {} }))).toEqual([]); | |
| 275 | expect(parseGrokLine("not json")).toEqual([]); | |
| 276 | expect( | |
| 277 | parseGrokLine( | |
| 278 | turnCompleted({ | |
| 279 | modelUsage: { | |
| 280 | "grok-4.5-build": { | |
| 281 | inputTokens: 0, | |
| 282 | outputTokens: 0, | |
| 283 | cachedReadTokens: 0, | |
| 284 | reasoningTokens: 0, | |
| 285 | costUsdTicks: 0, | |
| 286 | }, | |
| 287 | }, | |
| 288 | }), | |
| 289 | ), | |
| 290 | ).toEqual([]); | |
| 291 | }); | |
| 292 | ||
| 293 | it("falls back to the outer unix-seconds timestamp when agent meta is missing", () => { | |
| 294 | const line = JSON.stringify({ | |
| 295 | timestamp: 1_786_372_566, | |
| 296 | method: "_x.ai/session/update", | |
| 297 | params: { | |
| 298 | sessionId: "s1", | |
| 299 | update: { | |
| 300 | sessionUpdate: "turn_completed", | |
| 301 | prompt_id: "p1", | |
| 302 | usage: { | |
| 303 | inputTokens: 10, | |
| 304 | outputTokens: 2, | |
| 305 | modelUsage: { | |
| 306 | "grok-4.5": { inputTokens: 10, outputTokens: 2 }, | |
| 307 | }, | |
| 308 | }, | |
| 309 | }, | |
| 310 | }, | |
| 311 | }); | |
| 312 | ||
| 313 | const records = parseGrokLine(line); | |
| 314 | expect(records[0]?.timestampMs).toBe(1_786_372_566_000); | |
| 315 | }); | |
| 316 | }); |
packages/provider-grok/src/server/usage.ts created+239| ... | ... | @@ -0,0 +1,239 @@ |
| 1 | /** | |
| 2 | * Usage history for Grok Build: the format of its per-session | |
| 3 | * `updates.jsonl` logs and where each instance's `GROK_HOME` keeps them. | |
| 4 | * | |
| 5 | * @module provider-grok/server/usage | |
| 6 | */ | |
| 7 | import * as NodeOS from "node:os"; | |
| 8 | ||
| 9 | import type { UsageTokenTotals } from "@t3tools/contracts"; | |
| 10 | import * as Effect from "effect/Effect"; | |
| 11 | import * as Path from "effect/Path"; | |
| 12 | ||
| 13 | import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; | |
| 14 | import { | |
| 15 | tokenCount, | |
| 16 | totalTokens, | |
| 17 | type ProviderUsageReader, | |
| 18 | type TranscriptUsageFormat, | |
| 19 | type UsageRecord, | |
| 20 | } from "@t3tools/provider-core/server/usage"; | |
| 21 | ||
| 22 | import type { GrokSettings } from "../settings.ts"; | |
| 23 | ||
| 24 | /** | |
| 25 | * Grok reports cost in integer ticks where `1 USD = 10^10` ticks. See Grok | |
| 26 | * headless `total_cost_usd_ticks`. Convert to dollars for pricing. | |
| 27 | */ | |
| 28 | export const GROK_COST_USD_TICKS_PER_DOLLAR = 10_000_000_000; | |
| 29 | ||
| 30 | function grokCostTicksToUsd(ticks: unknown): number | null { | |
| 31 | if (typeof ticks !== "number" || !Number.isFinite(ticks) || ticks < 0) return null; | |
| 32 | return ticks / GROK_COST_USD_TICKS_PER_DOLLAR; | |
| 33 | } | |
| 34 | ||
| 35 | interface GrokUsageTotals { | |
| 36 | readonly inputTokens: number; | |
| 37 | readonly outputTokens: number; | |
| 38 | readonly cachedReadTokens: number; | |
| 39 | readonly cacheCreationTokens: number; | |
| 40 | readonly reasoningTokens: number; | |
| 41 | readonly costUsdTicks: number | null; | |
| 42 | } | |
| 43 | ||
| 44 | function readGrokUsageTotals(value: unknown): GrokUsageTotals | null { | |
| 45 | if (typeof value !== "object" || value === null) return null; | |
| 46 | const record = value as Record<string, unknown>; | |
| 47 | return { | |
| 48 | inputTokens: tokenCount(record["inputTokens"]), | |
| 49 | outputTokens: tokenCount(record["outputTokens"]), | |
| 50 | cachedReadTokens: tokenCount(record["cachedReadTokens"]), | |
| 51 | cacheCreationTokens: tokenCount(record["cacheCreationTokens"]), | |
| 52 | reasoningTokens: tokenCount(record["reasoningTokens"]), | |
| 53 | costUsdTicks: | |
| 54 | typeof record["costUsdTicks"] === "number" && Number.isFinite(record["costUsdTicks"]) | |
| 55 | ? record["costUsdTicks"] | |
| 56 | : null, | |
| 57 | }; | |
| 58 | } | |
| 59 | ||
| 60 | function grokTotalsToUsage(totals: GrokUsageTotals): UsageTokenTotals { | |
| 61 | const cachedInputTokens = totals.cachedReadTokens; | |
| 62 | const cacheCreationTokens = totals.cacheCreationTokens; | |
| 63 | // Grok reports `inputTokens` inclusive of the cached portion, matching Codex. | |
| 64 | const uncachedInputTokens = Math.max( | |
| 65 | 0, | |
| 66 | totals.inputTokens - cachedInputTokens - cacheCreationTokens, | |
| 67 | ); | |
| 68 | const outputTokens = totals.outputTokens; | |
| 69 | return { | |
| 70 | uncachedInputTokens, | |
| 71 | cachedInputTokens, | |
| 72 | cacheCreationTokens, | |
| 73 | outputTokens, | |
| 74 | reasoningTokens: Math.min(outputTokens, totals.reasoningTokens), | |
| 75 | }; | |
| 76 | } | |
| 77 | ||
| 78 | /** | |
| 79 | * Parses one line of a Grok Build `updates.jsonl` session log. | |
| 80 | * | |
| 81 | * Usage lands on `turn_completed` session updates. Per-model breakdowns live | |
| 82 | * under `usage.modelUsage`; when present each model becomes its own record. | |
| 83 | * | |
| 84 | * Returns every record for the line (0 or more). Callers stream line-by-line | |
| 85 | * and flatten. | |
| 86 | */ | |
| 87 | export function parseGrokLine(line: string): readonly UsageRecord[] { | |
| 88 | let parsed: unknown; | |
| 89 | try { | |
| 90 | parsed = JSON.parse(line); | |
| 91 | } catch { | |
| 92 | return []; | |
| 93 | } | |
| 94 | return parseGrokRecord(parsed); | |
| 95 | } | |
| 96 | ||
| 97 | function parseGrokRecord(parsed: unknown): readonly UsageRecord[] { | |
| 98 | if (typeof parsed !== "object" || parsed === null) return []; | |
| 99 | ||
| 100 | const record = parsed as Record<string, unknown>; | |
| 101 | const params = record["params"]; | |
| 102 | if (typeof params !== "object" || params === null) return []; | |
| 103 | const paramsRecord = params as Record<string, unknown>; | |
| 104 | ||
| 105 | const update = paramsRecord["update"]; | |
| 106 | if (typeof update !== "object" || update === null) return []; | |
| 107 | const updateRecord = update as Record<string, unknown>; | |
| 108 | if (updateRecord["sessionUpdate"] !== "turn_completed") return []; | |
| 109 | ||
| 110 | const usage = updateRecord["usage"]; | |
| 111 | if (typeof usage !== "object" || usage === null) return []; | |
| 112 | const usageRecord = usage as Record<string, unknown>; | |
| 113 | ||
| 114 | const sessionId = typeof paramsRecord["sessionId"] === "string" ? paramsRecord["sessionId"] : ""; | |
| 115 | const promptId = typeof updateRecord["prompt_id"] === "string" ? updateRecord["prompt_id"] : null; | |
| 116 | ||
| 117 | // Prefer the high-resolution agent clock; fall back to the outer unix seconds. | |
| 118 | const meta = paramsRecord["_meta"]; | |
| 119 | let timestampMs: number | null = null; | |
| 120 | if (typeof meta === "object" && meta !== null) { | |
| 121 | const agentTimestampMs = (meta as Record<string, unknown>)["agentTimestampMs"]; | |
| 122 | if (typeof agentTimestampMs === "number" && Number.isFinite(agentTimestampMs)) { | |
| 123 | timestampMs = agentTimestampMs; | |
| 124 | } | |
| 125 | } | |
| 126 | if (timestampMs === null) { | |
| 127 | const timestamp = record["timestamp"]; | |
| 128 | if (typeof timestamp === "number" && Number.isFinite(timestamp)) { | |
| 129 | timestampMs = timestamp > 1e12 ? timestamp : timestamp * 1000; | |
| 130 | } | |
| 131 | } | |
| 132 | if (timestampMs === null) return []; | |
| 133 | ||
| 134 | const topLevel = readGrokUsageTotals(usageRecord); | |
| 135 | if (topLevel === null) return []; | |
| 136 | ||
| 137 | const modelUsage = usageRecord["modelUsage"]; | |
| 138 | const modelEntries: Array<{ model: string; totals: GrokUsageTotals }> = []; | |
| 139 | if (typeof modelUsage === "object" && modelUsage !== null) { | |
| 140 | for (const [model, raw] of Object.entries(modelUsage as Record<string, unknown>)) { | |
| 141 | if (model.length === 0) continue; | |
| 142 | const totals = readGrokUsageTotals(raw); | |
| 143 | if (totals === null) continue; | |
| 144 | modelEntries.push({ model, totals }); | |
| 145 | } | |
| 146 | } | |
| 147 | ||
| 148 | if (modelEntries.length === 0) { | |
| 149 | if (totalTokens(grokTotalsToUsage(topLevel)) === 0) return []; | |
| 150 | return [ | |
| 151 | { | |
| 152 | provider: "grok", | |
| 153 | timestampMs, | |
| 154 | model: "grok", | |
| 155 | sessionId, | |
| 156 | totals: grokTotalsToUsage(topLevel), | |
| 157 | reportedCostUsd: grokCostTicksToUsd(topLevel.costUsdTicks), | |
| 158 | speed: "standard", | |
| 159 | // No prompt id means we cannot tell two same-second updates apart. | |
| 160 | dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:grok`, | |
| 161 | }, | |
| 162 | ]; | |
| 163 | } | |
| 164 | ||
| 165 | // Cost allocation: | |
| 166 | // 1. Emitted models with their own costUsdTicks keep those values. | |
| 167 | // 2. Remaining aggregate cost (top-level minus those per-model ticks, | |
| 168 | // clamped at 0) is pro-rated across emitted models that lack ticks, | |
| 169 | // by token share among the unticked models only. | |
| 170 | // 3. When no model has per-model ticks, remaining equals the full | |
| 171 | // aggregate and every emitted model gets a token-share slice. | |
| 172 | // Zero-token rows are never emitted and never count toward used ticks. | |
| 173 | const topLevelCostUsd = grokCostTicksToUsd(topLevel.costUsdTicks); | |
| 174 | let usedTickedCostUsd = 0; | |
| 175 | let untickedTokenDenominator = 0; | |
| 176 | for (const entry of modelEntries) { | |
| 177 | const tokens = totalTokens(grokTotalsToUsage(entry.totals)); | |
| 178 | if (tokens === 0) continue; | |
| 179 | if (entry.totals.costUsdTicks !== null) { | |
| 180 | usedTickedCostUsd += grokCostTicksToUsd(entry.totals.costUsdTicks) ?? 0; | |
| 181 | } else { | |
| 182 | untickedTokenDenominator += tokens; | |
| 183 | } | |
| 184 | } | |
| 185 | const remainingCostUsd = | |
| 186 | topLevelCostUsd === null ? null : Math.max(0, topLevelCostUsd - usedTickedCostUsd); | |
| 187 | ||
| 188 | const results: UsageRecord[] = []; | |
| 189 | for (const entry of modelEntries) { | |
| 190 | const totals = grokTotalsToUsage(entry.totals); | |
| 191 | if (totalTokens(totals) === 0) continue; | |
| 192 | ||
| 193 | let reportedCostUsd = grokCostTicksToUsd(entry.totals.costUsdTicks); | |
| 194 | if (reportedCostUsd === null && remainingCostUsd !== null && untickedTokenDenominator > 0) { | |
| 195 | reportedCostUsd = remainingCostUsd * (totalTokens(totals) / untickedTokenDenominator); | |
| 196 | } | |
| 197 | ||
| 198 | results.push({ | |
| 199 | provider: "grok", | |
| 200 | timestampMs, | |
| 201 | model: entry.model, | |
| 202 | sessionId, | |
| 203 | totals, | |
| 204 | reportedCostUsd, | |
| 205 | speed: "standard", | |
| 206 | dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:${entry.model}`, | |
| 207 | }); | |
| 208 | } | |
| 209 | return results; | |
| 210 | } | |
| 211 | ||
| 212 | export const grokUsageFormat: TranscriptUsageFormat<void> = { | |
| 213 | selectFields: { | |
| 214 | timestamp: true, | |
| 215 | params: { | |
| 216 | sessionId: true, | |
| 217 | _meta: { agentTimestampMs: true }, | |
| 218 | update: { sessionUpdate: true, prompt_id: true, usage: true }, | |
| 219 | }, | |
| 220 | }, | |
| 221 | mightCarryUsage: (line) => line.includes('"turn_completed"'), | |
| 222 | parseLine: (line) => parseGrokLine(line), | |
| 223 | parseProjected: (projected) => parseGrokRecord(projected), | |
| 224 | }; | |
| 225 | ||
| 226 | export const grokUsageReader: ProviderUsageReader<GrokSettings, Path.Path> = { | |
| 227 | kind: "transcripts", | |
| 228 | provider: "grok", | |
| 229 | format: grokUsageFormat, | |
| 230 | directories: Effect.fn("grokUsageReader.directories")(function* ({ environment }) { | |
| 231 | const path = yield* Path.Path; | |
| 232 | const home = expandHomePath( | |
| 233 | environment.GROK_HOME?.trim() || path.join(NodeOS.homedir(), ".grok"), | |
| 234 | ); | |
| 235 | // Sessions also ship multi-megabyte `chat_history` and `events` logs that | |
| 236 | // never carry usage; only `updates.jsonl` does. | |
| 237 | return [{ dir: path.resolve(home, "sessions"), fileName: "updates.jsonl" }]; | |
| 238 | }), | |
| 239 | }; |