| 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,6 +34,7 @@ import { |
| 34 | type ClaudeAdapterV2DriverEnv, | 34 | type ClaudeAdapterV2DriverEnv, |
| 35 | } from "../../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; | 35 | } from "../../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; |
| 36 | import { ProviderDriverError } from "../Errors.ts"; | 36 | import { ProviderDriverError } from "../Errors.ts"; |
| 37 | import { claudeUsageReader } from "./claudeUsage.ts"; | ||
| 37 | import { makeClaudeScopedLimitNames } from "../claudeUsageLimits.ts"; | 38 | import { makeClaudeScopedLimitNames } from "../claudeUsageLimits.ts"; |
| 38 | import * as ClaudeResetCredits from "../claudeResetCredits.ts"; | 39 | import * as ClaudeResetCredits from "../claudeResetCredits.ts"; |
| 39 | import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts"; | 40 | import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts"; |
| ... | @@ -107,7 +108,7 @@ export type ClaudeDriverEnv = | ... | @@ -107,7 +108,7 @@ export type ClaudeDriverEnv = |
| 107 | | ProviderEventLoggers.ProviderEventLoggers | 108 | | ProviderEventLoggers.ProviderEventLoggers |
| 108 | | ServerConfig.ServerConfig; | 109 | | ServerConfig.ServerConfig; |
| 109 | 110 | ||
| 110 | export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { | 111 | export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv, Path.Path> = { |
| 111 | driverKind: DRIVER_KIND, | 112 | driverKind: DRIVER_KIND, |
| 112 | metadata: { | 113 | metadata: { |
| 113 | displayName: "Claude", | 114 | displayName: "Claude", |
| ... | @@ -115,6 +116,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { | ... | @@ -115,6 +116,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { |
| 115 | }, | 116 | }, |
| 116 | configSchema: ClaudeSettings, | 117 | configSchema: ClaudeSettings, |
| 117 | defaultConfig: (): ClaudeSettings => decodeClaudeSettings({}), | 118 | defaultConfig: (): ClaudeSettings => decodeClaudeSettings({}), |
| 119 | usage: claudeUsageReader, | ||
| 118 | create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => | 120 | create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => |
| 119 | Effect.gen(function* () { | 121 | Effect.gen(function* () { |
| 120 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 122 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
apps/server/src/provider/Drivers/CodexDriver.ts+3-1| ... | @@ -73,6 +73,7 @@ import { | ... | @@ -73,6 +73,7 @@ import { |
| 73 | resolveCodexHomeLayout, | 73 | resolveCodexHomeLayout, |
| 74 | } from "./CodexHomeLayout.ts"; | 74 | } from "./CodexHomeLayout.ts"; |
| 75 | import { makeManagedCodexProvider } from "./CodexManagedProvider.ts"; | 75 | import { makeManagedCodexProvider } from "./CodexManagedProvider.ts"; |
| 76 | import { codexUsageReader } from "./codexUsage.ts"; | ||
| 76 | import * as CodexInstallation from "../CodexInstallation.ts"; | 77 | import * as CodexInstallation from "../CodexInstallation.ts"; |
| 77 | import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; | 78 | import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; |
| 78 | import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; | 79 | import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; |
| ... | @@ -125,7 +126,7 @@ export type CodexDriverEnv = | ... | @@ -125,7 +126,7 @@ export type CodexDriverEnv = |
| 125 | | ServerEnvironment.ServerEnvironmentIdentity | 126 | | ServerEnvironment.ServerEnvironmentIdentity |
| 126 | | CodexInstallation.CodexInstallation; | 127 | | CodexInstallation.CodexInstallation; |
| 127 | 128 | ||
| 128 | export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { | 129 | export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Path> = { |
| 129 | driverKind: DRIVER_KIND, | 130 | driverKind: DRIVER_KIND, |
| 130 | metadata: { | 131 | metadata: { |
| 131 | displayName: "Codex", | 132 | displayName: "Codex", |
| ... | @@ -133,6 +134,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { | ... | @@ -133,6 +134,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { |
| 133 | }, | 134 | }, |
| 134 | configSchema: CodexSettings, | 135 | configSchema: CodexSettings, |
| 135 | defaultConfig: (): CodexSettings => decodeCodexSettings({}), | 136 | defaultConfig: (): CodexSettings => decodeCodexSettings({}), |
| 137 | usage: codexUsageReader, | ||
| 136 | create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => | 138 | create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => |
| 137 | Effect.gen(function* () { | 139 | Effect.gen(function* () { |
| 138 | if (config.setupMode === "managed") | 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,7 +32,10 @@ import { GrokDriver, type GrokDriverEnv } from "@t3tools/provider-grok/server"; |
| 32 | import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server"; | 32 | import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server"; |
| 33 | import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server"; | 33 | import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server"; |
| 34 | import { PiDriver, type PiDriverEnv } from "@t3tools/provider-pi/server"; | 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 | * Union of infrastructure services required to construct any built-in | 41 | * Union of infrastructure services required to construct any built-in |
| ... | @@ -66,3 +69,18 @@ export const BUILT_IN_DRIVERS: ReadonlyArray<AnyProviderDriver<BuiltInDriversEnv | ... | @@ -66,3 +69,18 @@ export const BUILT_IN_DRIVERS: ReadonlyArray<AnyProviderDriver<BuiltInDriversEnv |
| 66 | MuseDriver, | 69 | MuseDriver, |
| 67 | AcpRegistryDriver, | 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,7 +33,7 @@ import { HttpClient, HttpClientResponse } from "effect/http"; |
| 33 | import * as ServerConfig from "../config.ts"; | 33 | import * as ServerConfig from "../config.ts"; |
| 34 | import * as ServerSettings from "../serverSettings.ts"; | 34 | import * as ServerSettings from "../serverSettings.ts"; |
| 35 | import * as CursorUsageReader from "./cursorUsageReader.ts"; | 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 | import * as UsageService from "./UsageService.ts"; | 37 | import * as UsageService from "./UsageService.ts"; |
| 38 | 38 | ||
| 39 | const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); | 39 | const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); |
| ... | @@ -805,6 +805,8 @@ describe("UsageService", () => { | ... | @@ -805,6 +805,8 @@ describe("UsageService", () => { |
| 805 | }, | 805 | }, |
| 806 | [ProviderInstanceId.make("grok-work")]: { | 806 | [ProviderInstanceId.make("grok-work")]: { |
| 807 | driver: ProviderDriverKind.make("grok"), | 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 | environment: [{ name: "GROK_HOME", value: grokHome, sensitive: false }], | 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,8 +21,6 @@ |
| 21 | import * as NodeOS from "node:os"; | 21 | import * as NodeOS from "node:os"; |
| 22 | 22 | ||
| 23 | import { | 23 | import { |
| 24 | ClaudeSettings, | ||
| 25 | CodexSettings, | ||
| 26 | type ProviderInstanceConfig, | 24 | type ProviderInstanceConfig, |
| 27 | ProviderInstanceId, | 25 | ProviderInstanceId, |
| 28 | USAGE_CONTRACT_VERSION, | 26 | USAGE_CONTRACT_VERSION, |
| ... | @@ -56,9 +54,15 @@ import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; | ... | @@ -56,9 +54,15 @@ import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; |
| 56 | import * as ServerConfig from "../config.ts"; | 54 | import * as ServerConfig from "../config.ts"; |
| 57 | import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; | 55 | import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; |
| 58 | import * as ServerSettings from "../serverSettings.ts"; | 56 | import * as ServerSettings from "../serverSettings.ts"; |
| 59 | import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; | ||
| 60 | import { resolveAntigravityInstanceDirectories } from "../provider/antigravityAuthSupport.ts"; | 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 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; | 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 | import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; | 66 | import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; |
| 63 | import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts"; | 67 | import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts"; |
| 64 | import { | 68 | import { |
| ... | @@ -90,7 +94,6 @@ import { | ... | @@ -90,7 +94,6 @@ import { |
| 90 | type CachedFile, | 94 | type CachedFile, |
| 91 | type ScanCache, | 95 | type ScanCache, |
| 92 | } from "./usageScanCache.ts"; | 96 | } from "./usageScanCache.ts"; |
| 93 | import type { UsageRecord } from "./usageTranscripts.ts"; | ||
| 94 | 97 | ||
| 95 | const LITELLM_RATES_URL = | 98 | const LITELLM_RATES_URL = |
| 96 | "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"; | 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,8 +122,24 @@ const CURSOR_ACCOUNT_READ_ERROR = "Cursor account usage could not be read."; |
| 119 | /** Transcripts parsed at once. More gains little once the disk stays busy. */ | 122 | /** Transcripts parsed at once. More gains little once the disk stays busy. */ |
| 120 | const TRANSCRIPT_READ_CONCURRENCY = 4; | 123 | const TRANSCRIPT_READ_CONCURRENCY = 4; |
| 121 | 124 | ||
| 122 | const decodeCodexSettings = Schema.decodeOption(CodexSettings); | 125 | /** The transcript readers, in driver order. */ |
| 123 | const decodeClaudeSettings = Schema.decodeOption(ClaudeSettings); | 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 | /** On-disk shape of the rate snapshot. */ | 144 | /** On-disk shape of the rate snapshot. */ |
| 126 | const RatesCacheFile = Schema.Struct({ | 145 | const RatesCacheFile = Schema.Struct({ |
| ... | @@ -145,13 +164,21 @@ function isLaterRead(a: CachedFile, b: CachedFile): boolean { | ... | @@ -145,13 +164,21 @@ function isLaterRead(a: CachedFile, b: CachedFile): boolean { |
| 145 | return a.mtimeMs > b.mtimeMs || (a.mtimeMs === b.mtimeMs && a.size > b.size); | 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 | 175 | * Sessions of `sharedSessionProviders` with records in more than one file, |
| 150 | * moved after it was read. Only these need cross-file dedupe keys: within one | 176 | * such as a rollout that moved after it was read. Only these need cross-file |
| 151 | * file the occurrence count already keeps every key unique, so keying the rest | 177 | * dedupe keys: within one file the occurrence count already keeps every key |
| 152 | * would only build and hash a string for each of their records. | 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 | files: readonly { readonly records: readonly UsageRecord[] }[], | 182 | files: readonly { readonly records: readonly UsageRecord[] }[], |
| 156 | ): ReadonlySet<string> { | 183 | ): ReadonlySet<string> { |
| 157 | const firstFile = new Map<string, number>(); | 184 | const firstFile = new Map<string, number>(); |
| ... | @@ -159,7 +186,8 @@ function sharedCodexSessions( | ... | @@ -159,7 +186,8 @@ function sharedCodexSessions( |
| 159 | for (const [index, file] of files.entries()) { | 186 | for (const [index, file] of files.entries()) { |
| 160 | let previous = ""; | 187 | let previous = ""; |
| 161 | for (const { provider, sessionId } of file.records) { | 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 | previous = sessionId; | 191 | previous = sessionId; |
| 164 | const first = firstFile.get(sessionId); | 192 | const first = firstFile.get(sessionId); |
| 165 | if (first === undefined) firstFile.set(sessionId, index); | 193 | if (first === undefined) firstFile.set(sessionId, index); |
| ... | @@ -218,6 +246,8 @@ export const make = Effect.gen(function* () { | ... | @@ -218,6 +246,8 @@ export const make = Effect.gen(function* () { |
| 218 | const hostEnvironment = yield* HostProcessEnvironment; | 246 | const hostEnvironment = yield* HostProcessEnvironment; |
| 219 | const platform = yield* HostProcessPlatform; | 247 | const platform = yield* HostProcessPlatform; |
| 220 | const cursorAccountReader = yield* CursorUsageReader.CursorAccountReader; | 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 | const fileCache: ScanCache = new Map(); | 252 | const fileCache: ScanCache = new Map(); |
| 223 | const antigravityCache = makeAntigravityUsageCache(); | 253 | const antigravityCache = makeAntigravityUsageCache(); |
| ... | @@ -338,60 +368,45 @@ export const make = Effect.gen(function* () { | ... | @@ -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 | const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* ( | 398 | const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* ( |
| 343 | settings: ServerSettingsValue, | 399 | settings: ServerSettingsValue, |
| 344 | retentionCutoffMs: number, | 400 | retentionCutoffMs: number, |
| 345 | ) { | 401 | ) { |
| 346 | const dirs: Array<{ | 402 | const dirs: Array<TranscriptSource> = []; |
| 347 | provider: UsageProviderKind; | ||
| 348 | dir: string; | ||
| 349 | volumeId: string; | ||
| 350 | fileName?: string; | ||
| 351 | }> = []; | ||
| 352 | const seen = new Set<string>(); | 403 | const seen = new Set<string>(); |
| 353 | for (const driver of ["claudeAgent", "codex", "grok"] as const) { | 404 | for (const { driver, reader } of transcriptReaders) { |
| 354 | // Disabled accounts still have history. An unconfigured default slot | 405 | const { provider, format } = reader; |
| 355 | // runs with default config, just as it does in the provider registry. | 406 | const directories = yield* Effect.forEach(usageInstances(driver, settings), (instance) => |
| 356 | const instances: Array< | 407 | reader.directories(instance), |
| 357 | Pick<ProviderInstanceConfig, "config" | "environment"> & { instanceId: ProviderInstanceId } | 408 | ); |
| 358 | > = Object.entries(settings.providerInstances) | 409 | for (const { dir: directory, fileName } of directories.flat()) { |
| 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"); | ||
| 395 | const sourceKey = provider + "\0" + directory; | 410 | const sourceKey = provider + "\0" + directory; |
| 396 | const previous = sourceCache.get(sourceKey); | 411 | const previous = sourceCache.get(sourceKey); |
| 397 | // Keep canonical paths and source fingerprints stable after root cleanup, | 412 | // Keep canonical paths and source fingerprints stable after root cleanup, |
| ... | @@ -423,9 +438,10 @@ export const make = Effect.gen(function* () { | ... | @@ -423,9 +438,10 @@ export const make = Effect.gen(function* () { |
| 423 | seen.add(key); | 438 | seen.add(key); |
| 424 | dirs.push({ | 439 | dirs.push({ |
| 425 | provider, | 440 | provider, |
| 441 | format, | ||
| 426 | dir, | 442 | dir, |
| 427 | volumeId, | 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,7 +472,9 @@ export const make = Effect.gen(function* () { |
| 456 | cacheDirty = document !== null; | 472 | cacheDirty = document !== null; |
| 457 | } | 473 | } |
| 458 | if (document === null) return; | 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 | const sources = decodeCachedSources(document); | 478 | const sources = decodeCachedSources(document); |
| 461 | if (Option.isSome(sources)) { | 479 | if (Option.isSome(sources)) { |
| 462 | for (const [key, source] of Object.entries(sources.value.sources)) | 480 | for (const [key, source] of Object.entries(sources.value.sources)) |
| ... | @@ -536,6 +554,7 @@ export const make = Effect.gen(function* () { | ... | @@ -536,6 +554,7 @@ export const make = Effect.gen(function* () { |
| 536 | size: number, | 554 | size: number, |
| 537 | mtimeMs: number, | 555 | mtimeMs: number, |
| 538 | provider: UsageProviderKind, | 556 | provider: UsageProviderKind, |
| 557 | format: TranscriptUsageFormat<unknown>, | ||
| 539 | ): Effect.Effect<{ | 558 | ): Effect.Effect<{ |
| 540 | readonly records: readonly UsageRecord[]; | 559 | readonly records: readonly UsageRecord[]; |
| 541 | readonly update?: { readonly entry: CachedFile; readonly replaces: CachedFile | undefined }; | 560 | readonly update?: { readonly entry: CachedFile; readonly replaces: CachedFile | undefined }; |
| ... | @@ -566,7 +585,7 @@ export const make = Effect.gen(function* () { | ... | @@ -566,7 +585,7 @@ export const make = Effect.gen(function* () { |
| 566 | : undefined; | 585 | : undefined; |
| 567 | 586 | ||
| 568 | const parsed = yield* Effect.promise(() => | 587 | const parsed = yield* Effect.promise(() => |
| 569 | readTranscriptRecords(filePath, provider, resumeFrom), | 588 | readTranscriptRecords(filePath, format, resumeFrom), |
| 570 | ); | 589 | ); |
| 571 | // A read failure is not an empty transcript: caching it under this | 590 | // A read failure is not an empty transcript: caching it under this |
| 572 | // (size, mtime) would silently drop the file's usage until it changes. | 591 | // (size, mtime) would silently drop the file's usage until it changes. |
| ... | @@ -611,15 +630,10 @@ export const make = Effect.gen(function* () { | ... | @@ -611,15 +630,10 @@ export const make = Effect.gen(function* () { |
| 611 | } | 630 | } |
| 612 | 631 | ||
| 613 | const scanTranscriptDir = Effect.fn("UsageService.scanTranscriptDir")(function* ( | 632 | const scanTranscriptDir = Effect.fn("UsageService.scanTranscriptDir")(function* ( |
| 614 | source: { | 633 | source: TranscriptSource, |
| 615 | readonly provider: UsageProviderKind; | ||
| 616 | readonly dir: string; | ||
| 617 | readonly volumeId: string; | ||
| 618 | readonly fileName?: string; | ||
| 619 | }, | ||
| 620 | windowStartMs: number, | 634 | windowStartMs: number, |
| 621 | ) { | 635 | ) { |
| 622 | const { provider, dir, volumeId, fileName } = source; | 636 | const { provider, format, dir, volumeId, fileName } = source; |
| 623 | const exists = yield* fileSystem | 637 | const exists = yield* fileSystem |
| 624 | .exists(dir) | 638 | .exists(dir) |
| 625 | .pipe(Effect.catchCause(() => Effect.succeed(false))); | 639 | .pipe(Effect.catchCause(() => Effect.succeed(false))); |
| ... | @@ -632,7 +646,7 @@ export const make = Effect.gen(function* () { | ... | @@ -632,7 +646,7 @@ export const make = Effect.gen(function* () { |
| 632 | const read = yield* Effect.forEach( | 646 | const read = yield* Effect.forEach( |
| 633 | files, | 647 | files, |
| 634 | (file) => | 648 | (file) => |
| 635 | readFileRecords(file.path, file.size, file.mtimeMs, provider).pipe( | 649 | readFileRecords(file.path, file.size, file.mtimeMs, provider, format).pipe( |
| 636 | Effect.map((result) => ({ path: file.path, ...result })), | 650 | Effect.map((result) => ({ path: file.path, ...result })), |
| 637 | ), | 651 | ), |
| 638 | { concurrency: TRANSCRIPT_READ_CONCURRENCY }, | 652 | { concurrency: TRANSCRIPT_READ_CONCURRENCY }, |
| ... | @@ -818,10 +832,8 @@ export const make = Effect.gen(function* () { | ... | @@ -818,10 +832,8 @@ export const make = Effect.gen(function* () { |
| 818 | retentionCutoffMs: number, | 832 | retentionCutoffMs: number, |
| 819 | awaitRefresh: boolean, | 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 | const dirs = yield* resolveTranscriptDirs(settings, retentionCutoffMs).pipe( | 835 | const dirs = yield* resolveTranscriptDirs(settings, retentionCutoffMs).pipe( |
| 824 | Effect.provideService(Path.Path, path), | 836 | Effect.provideContext(readerContext), |
| 825 | ); | 837 | ); |
| 826 | 838 | ||
| 827 | const home = NodeOS.homedir(); | 839 | const home = NodeOS.homedir(); |
| ... | @@ -1100,7 +1112,7 @@ export const make = Effect.gen(function* () { | ... | @@ -1100,7 +1112,7 @@ export const make = Effect.gen(function* () { |
| 1100 | } | 1112 | } |
| 1101 | return retainedFiles; | 1113 | return retainedFiles; |
| 1102 | }); | 1114 | }); |
| 1103 | const sharedSessions = sharedCodexSessions(filesByDir.flat()); | 1115 | const crossFileSessions = sharedSessions(filesByDir.flat()); |
| 1104 | 1116 | ||
| 1105 | for (const [ | 1117 | for (const [ |
| 1106 | index, | 1118 | index, |
| ... | @@ -1118,12 +1130,15 @@ export const make = Effect.gen(function* () { | ... | @@ -1118,12 +1130,15 @@ export const make = Effect.gen(function* () { |
| 1118 | continue; | 1130 | continue; |
| 1119 | } | 1131 | } |
| 1120 | scannedFiles += 1; | 1132 | scannedFiles += 1; |
| 1121 | const codexEventOccurrences = new Map<string, number>(); | 1133 | const eventOccurrences = new Map<string, number>(); |
| 1122 | for (const record of file.records) { | 1134 | for (const record of file.records) { |
| 1123 | let usageRecord = record; | 1135 | let usageRecord = record; |
| 1124 | if (record.provider === "codex" && sharedSessions.has(record.sessionId)) { | 1136 | if ( |
| 1125 | // Match moved rollout copies without collapsing repeated equal events | 1137 | sharedSessionProviders.has(record.provider) && |
| 1126 | // within one rollout (timestamps can have only second precision). | 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 | // Only sessions seen in several files can have a copy to match. | 1142 | // Only sessions seen in several files can have a copy to match. |
| 1128 | const key = encodeUsageRecordKey([ | 1143 | const key = encodeUsageRecordKey([ |
| 1129 | record.provider, | 1144 | record.provider, |
| ... | @@ -1132,8 +1147,8 @@ export const make = Effect.gen(function* () { | ... | @@ -1132,8 +1147,8 @@ export const make = Effect.gen(function* () { |
| 1132 | record.model, | 1147 | record.model, |
| 1133 | record.totals, | 1148 | record.totals, |
| 1134 | ]); | 1149 | ]); |
| 1135 | const occurrence = (codexEventOccurrences.get(key) ?? 0) + 1; | 1150 | const occurrence = (eventOccurrences.get(key) ?? 0) + 1; |
| 1136 | codexEventOccurrences.set(key, occurrence); | 1151 | eventOccurrences.set(key, occurrence); |
| 1137 | usageRecord = { ...record, dedupeKey: key + ":" + occurrence }; | 1152 | usageRecord = { ...record, dedupeKey: key + ":" + occurrence }; |
| 1138 | } | 1153 | } |
| 1139 | // Only sessions contributing in-window count; the mtime slack can | 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,7 +5,7 @@ import * as NodePath from "node:path"; |
| 5 | import * as NodeSqlite from "node:sqlite"; | 5 | import * as NodeSqlite from "node:sqlite"; |
| 6 | import * as NodeTimersPromises from "node:timers/promises"; | 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 | type FieldValue = number | bigint | Uint8Array; | 10 | type FieldValue = number | bigint | Uint8Array; |
| 11 | type Fields = Map<number, FieldValue[]>; | 11 | type Fields = Map<number, FieldValue[]>; |
apps/server/src/usage/cursorAccountCache.test.ts+1-1| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | 2 | ||
| 3 | import { cursorFetchRange, mergeCursorFetch } from "./cursorAccountCache.ts"; | 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 | const DAY_MS = 24 * 60 * 60 * 1000; | 6 | const DAY_MS = 24 * 60 * 60 * 1000; |
| 7 | 7 |
apps/server/src/usage/cursorAccountCache.ts+1-1| ... | @@ -13,7 +13,7 @@ | ... | @@ -13,7 +13,7 @@ |
| 13 | * @module cursorAccountCache | 13 | * @module cursorAccountCache |
| 14 | */ | 14 | */ |
| 15 | import { cursorRateModel } from "./cursorUsageReader.ts"; | 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 | export type CursorCredentialSource = string | { readonly kind: "keychain" }; | 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,7 +8,7 @@ import * as Context from "effect/Context"; |
| 8 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Layer from "effect/Layer"; | 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 | import { | 12 | import { |
| 13 | CursorKeychainTimeoutError, | 13 | CursorKeychainTimeoutError, |
| 14 | readMacCursorAccessToken, | 14 | readMacCursorAccessToken, |
apps/server/src/usage/opencodeUsageReader.ts+1-1| ... | @@ -5,7 +5,7 @@ import * as NodePath from "node:path"; | ... | @@ -5,7 +5,7 @@ import * as NodePath from "node:path"; |
| 5 | import * as NodeSqlite from "node:sqlite"; | 5 | import * as NodeSqlite from "node:sqlite"; |
| 6 | import * as NodeTimersPromises from "node:timers/promises"; | 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 | function object(value: unknown): Record<string, unknown> { | 10 | function object(value: unknown): Record<string, unknown> { |
| 11 | return typeof value === "object" && value !== null && !Array.isArray(value) | 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,7 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | ||
| 3 | import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; | 3 | import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; |
| 4 | import type { RateTable } from "./usagePricing.ts"; | 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 | const rates: RateTable = new Map([ | 7 | const rates: RateTable = new Map([ |
| 8 | [ | 8 | [ |
apps/server/src/usage/usageAggregation.ts+1-1| ... | @@ -20,7 +20,7 @@ import type { | ... | @@ -20,7 +20,7 @@ import type { |
| 20 | UsageTokenTotals, | 20 | UsageTokenTotals, |
| 21 | } from "@t3tools/contracts"; | 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 | import { cacheSavingsUsd, priceUsage, type RateTable } from "./usagePricing.ts"; | 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,7 +1,7 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | ||
| 3 | import { cursorRateModel } from "./cursorUsageReader.ts"; | 3 | import { cursorRateModel } from "./cursorUsageReader.ts"; |
| 4 | import type { UsageSpeed } from "./usageTranscripts.ts"; | 4 | import type { UsageSpeed } from "@t3tools/provider-core/server/usage"; |
| 5 | import { | 5 | import { |
| 6 | cacheSavingsUsd, | 6 | cacheSavingsUsd, |
| 7 | createOverrideRateTable, | 7 | createOverrideRateTable, |
apps/server/src/usage/usagePricing.ts+1-1| ... | @@ -14,7 +14,7 @@ import type { | ... | @@ -14,7 +14,7 @@ import type { |
| 14 | UsageTokenTotals, | 14 | UsageTokenTotals, |
| 15 | } from "@t3tools/contracts"; | 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 | /** Token rates for one billing speed. All values are USD per token. */ | 19 | /** Token rates for one billing speed. All values are USD per token. */ |
| 20 | export interface TokenRates { | 20 | export interface TokenRates { |
apps/server/src/usage/usageScanCache.test.ts+29-16| ... | @@ -9,7 +9,9 @@ import { | ... | @@ -9,7 +9,9 @@ import { |
| 9 | type CachedFile, | 9 | type CachedFile, |
| 10 | type ScanCache, | 10 | type ScanCache, |
| 11 | } from "./usageScanCache.ts"; | 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 | function record(overrides: Partial<UsageRecord> = {}): UsageRecord { | 16 | function record(overrides: Partial<UsageRecord> = {}): UsageRecord { |
| 15 | return { | 17 | return { |
| ... | @@ -36,7 +38,7 @@ function position(overrides: Partial<CachedFile["position"]> = {}): CachedFile[" | ... | @@ -36,7 +38,7 @@ function position(overrides: Partial<CachedFile["position"]> = {}): CachedFile[" |
| 36 | resumeOffset: 120, | 38 | resumeOffset: 120, |
| 37 | guardLength: 64, | 39 | guardLength: 64, |
| 38 | guardHash: 0xdeadbeef, | 40 | guardHash: 0xdeadbeef, |
| 39 | codexState: null, | 41 | state: null, |
| 40 | ...overrides, | 42 | ...overrides, |
| 41 | }; | 43 | }; |
| 42 | } | 44 | } |
| ... | @@ -85,7 +87,7 @@ describe("scan cache round trip", () => { | ... | @@ -85,7 +87,7 @@ describe("scan cache round trip", () => { |
| 85 | ], | 87 | ], |
| 86 | tailRecords: [], | 88 | tailRecords: [], |
| 87 | position: position({ | 89 | position: position({ |
| 88 | codexState: { | 90 | state: { |
| 89 | model: "gpt-6-astra", | 91 | model: "gpt-6-astra", |
| 90 | speed: "ultrafast", | 92 | speed: "ultrafast", |
| 91 | sessionId: "session-c", | 93 | sessionId: "session-c", |
| ... | @@ -97,7 +99,10 @@ describe("scan cache round trip", () => { | ... | @@ -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 | expect(restored.size).toBe(4); | 107 | expect(restored.size).toBe(4); |
| 103 | expect(restored.get("/a.jsonl")).toEqual(original.get("/a.jsonl")); | 108 | expect(restored.get("/a.jsonl")).toEqual(original.get("/a.jsonl")); |
| ... | @@ -117,7 +122,9 @@ describe("scan cache round trip", () => { | ... | @@ -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 | it("drops an entry whose guard length is outside the supported range", () => { | 130 | it("drops an entry whose guard length is outside the supported range", () => { |
| ... | @@ -129,7 +136,9 @@ describe("scan cache round trip", () => { | ... | @@ -129,7 +136,9 @@ describe("scan cache round trip", () => { |
| 129 | files: { "/a.jsonl": { ...encoded.files["/a.jsonl"]!, gl: 1e20 } }, | 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 | it("drops an entry whose speed is not a known index", () => { | 144 | it("drops an entry whose speed is not a known index", () => { |
| ... | @@ -140,14 +149,16 @@ describe("scan cache round trip", () => { | ... | @@ -140,14 +149,16 @@ describe("scan cache round trip", () => { |
| 140 | files: { "/a.jsonl": { ...encoded.files["/a.jsonl"]!, r: [[...row.slice(0, 10), true]] } }, | 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 | it("rejects a document from before records carried a speed", () => { | 157 | it("rejects a document from before records carried a speed", () => { |
| 147 | const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); | 158 | const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); |
| 148 | const previous = { ...encoded, version: 3 }; | 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 | it("rewrites only changed entries and still restores the whole cache", () => { | 164 | it("rewrites only changed entries and still restores the whole cache", () => { |
| ... | @@ -157,7 +168,7 @@ describe("scan cache round trip", () => { | ... | @@ -157,7 +168,7 @@ describe("scan cache round trip", () => { |
| 157 | ["/b.jsonl", 200, [record({ sessionId: "session-b" })]], | 168 | ["/b.jsonl", 200, [record({ sessionId: "session-b" })]], |
| 158 | ]); | 169 | ]); |
| 159 | const sources = { "claude\u0000/projects": { dir: "/projects", volumeId: "1:2" } }; | 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 | // The replacement adds intern entries; /a's memoised indexes must hold. | 173 | // The replacement adds intern entries; /a's memoised indexes must hold. |
| 163 | cache.set("/b.jsonl", { | 174 | cache.set("/b.jsonl", { |
| ... | @@ -166,7 +177,7 @@ describe("scan cache round trip", () => { | ... | @@ -166,7 +177,7 @@ describe("scan cache round trip", () => { |
| 166 | records: [record({ sessionId: "session-c", model: "claude-opus-5-5", dedupeKey: "msg_3:" })], | 177 | records: [record({ sessionId: "session-c", model: "claude-opus-5-5", dedupeKey: "msg_3:" })], |
| 167 | }); | 178 | }); |
| 168 | const document = JSON.parse(write(cache, { sources })); | 179 | const document = JSON.parse(write(cache, { sources })); |
| 169 | expect(decodeScanCache(document)).toEqual(cache); | 180 | expect(decodeScanCache(document, TEST_FORMAT_MAP)).toEqual(cache); |
| 170 | expect(document.sources).toEqual(sources); | 181 | expect(document.sources).toEqual(sources); |
| 171 | }); | 182 | }); |
| 172 | 183 | ||
| ... | @@ -181,9 +192,11 @@ describe("scan cache round trip", () => { | ... | @@ -181,9 +192,11 @@ describe("scan cache round trip", () => { |
| 181 | 192 | ||
| 182 | it("treats a corrupt or foreign document as an empty cache", () => { | 193 | it("treats a corrupt or foreign document as an empty cache", () => { |
| 183 | // A bad cache should cost one cold scan, never a broken page. | 194 | // A bad cache should cost one cold scan, never a broken page. |
| 184 | expect(decodeScanCache(null).size).toBe(0); | 195 | expect(decodeScanCache(null, TEST_FORMAT_MAP).size).toBe(0); |
| 185 | expect(decodeScanCache("nonsense").size).toBe(0); | 196 | expect(decodeScanCache("nonsense", TEST_FORMAT_MAP).size).toBe(0); |
| 186 | expect(decodeScanCache({ version: 999, models: [], sessions: [], files: {} }).size).toBe(0); | 197 | expect( |
| 198 | decodeScanCache({ version: 999, models: [], sessions: [], files: {} }, TEST_FORMAT_MAP).size, | ||
| 199 | ).toBe(0); | ||
| 187 | }); | 200 | }); |
| 188 | 201 | ||
| 189 | it("skips malformed file entries but keeps good ones", () => { | 202 | it("skips malformed file entries but keeps good ones", () => { |
| ... | @@ -193,7 +206,7 @@ describe("scan cache round trip", () => { | ... | @@ -193,7 +206,7 @@ describe("scan cache round trip", () => { |
| 193 | files: { ...encoded.files, "/bad.jsonl": { s: "nope", m: 1, p: "claude", r: [] } }, | 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 | expect([...restored.keys()]).toEqual(["/good.jsonl"]); | 210 | expect([...restored.keys()]).toEqual(["/good.jsonl"]); |
| 198 | }); | 211 | }); |
| 199 | 212 | ||
| ... | @@ -203,7 +216,7 @@ describe("scan cache round trip", () => { | ... | @@ -203,7 +216,7 @@ describe("scan cache round trip", () => { |
| 203 | const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); | 216 | const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); |
| 204 | const poisoned = { ...encoded, models: [1] }; | 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 | it("drops the whole entry when any row is corrupt, forcing a cold re-parse", () => { | 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,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 | expect(restored.has("/a.jsonl")).toBe(false); | 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,9 +15,14 @@ |
| 15 | * @module usageScanCache | 15 | * @module usageScanCache |
| 16 | */ | 16 | */ |
| 17 | import type { UsageProviderKind } from "@t3tools/contracts"; | 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 | import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts"; | 25 | import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts"; |
| 20 | import type { CodexScanState, UsageRecord, UsageSpeed } from "./usageTranscripts.ts"; | ||
| 21 | 26 | ||
| 22 | // v2: Codex fork-copy suppression changed what a file parses to, so v1 | 27 | // v2: Codex fork-copy suppression changed what a file parses to, so v1 |
| 23 | // entries would keep serving double-counted records forever. | 28 | // entries would keep serving double-counted records forever. |
| ... | @@ -25,7 +30,7 @@ import type { CodexScanState, UsageRecord, UsageSpeed } from "./usageTranscripts | ... | @@ -25,7 +30,7 @@ import type { CodexScanState, UsageRecord, UsageSpeed } from "./usageTranscripts |
| 25 | // re-parses only its appended bytes instead of starting over. | 30 | // re-parses only its appended bytes instead of starting over. |
| 26 | // v4: records carry Claude fast mode, which v3 rows never captured. | 31 | // v4: records carry Claude fast mode, which v3 rows never captured. |
| 27 | // v5: Codex records carry their service tier. v4 rows store speed the same | 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 | const USAGE_SCAN_CACHE_VERSION = 5 as const; | 34 | const USAGE_SCAN_CACHE_VERSION = 5 as const; |
| 30 | const SPEED_COMPATIBLE_SINCE_VERSION = 4; | 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,10 +46,6 @@ export const LEGACY_SCAN_CACHE_FILE_NAME = "usage-scan-cache.json"; |
| 41 | /** Serialised as the index into this list. */ | 46 | /** Serialised as the index into this list. */ |
| 42 | const SPEEDS: readonly UsageSpeed[] = ["standard", "fast", "ultrafast"]; | 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 | export interface CachedFile { | 49 | export interface CachedFile { |
| 49 | readonly size: number; | 50 | readonly size: number; |
| 50 | readonly mtimeMs: number; | 51 | readonly mtimeMs: number; |
| ... | @@ -92,8 +93,8 @@ interface SerializedFile { | ... | @@ -92,8 +93,8 @@ interface SerializedFile { |
| 92 | readonly o: number; | 93 | readonly o: number; |
| 93 | readonly gl: number; | 94 | readonly gl: number; |
| 94 | readonly gh: number; | 95 | readonly gh: number; |
| 95 | /** Codex reducer state at `o`; `null` for stateless providers. */ | 96 | /** The format's encoded reducer state at `o`; `null` for stateless formats. */ |
| 96 | readonly cs: CodexScanState | null; | 97 | readonly cs: unknown; |
| 97 | } | 98 | } |
| 98 | 99 | ||
| 99 | interface SerializedCache { | 100 | interface SerializedCache { |
| ... | @@ -147,7 +148,7 @@ function serializeFile(entry: CachedFile, tables: InternTables): SerializedFile | ... | @@ -147,7 +148,7 @@ function serializeFile(entry: CachedFile, tables: InternTables): SerializedFile |
| 147 | o: entry.position.resumeOffset, | 148 | o: entry.position.resumeOffset, |
| 148 | gl: entry.position.guardLength, | 149 | gl: entry.position.guardLength, |
| 149 | gh: entry.position.guardHash, | 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,10 +211,15 @@ function isRecordArray(value: unknown): value is readonly unknown[] { |
| 210 | * Rebuilds the cache from a parsed document. | 211 | * Rebuilds the cache from a parsed document. |
| 211 | * | 212 | * |
| 212 | * Anything malformed yields an empty cache rather than an error: a corrupt | 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 | const cache: ScanCache = new Map(); | 221 | const cache: ScanCache = new Map(); |
| 222 | const isValidState = makeStateValidators(formats); | ||
| 217 | if (typeof document !== "object" || document === null) return cache; | 223 | if (typeof document !== "object" || document === null) return cache; |
| 218 | 224 | ||
| 219 | const root = document as Partial<SerializedCache>; | 225 | const root = document as Partial<SerializedCache>; |
| ... | @@ -300,7 +306,9 @@ export function decodeScanCache(document: unknown): ScanCache { | ... | @@ -300,7 +306,9 @@ export function decodeScanCache(document: unknown): ScanCache { |
| 300 | if (typeof raw !== "object" || raw === null) continue; | 306 | if (typeof raw !== "object" || raw === null) continue; |
| 301 | const entry = raw as Partial<SerializedFile>; | 307 | const entry = raw as Partial<SerializedFile>; |
| 302 | if (typeof entry.s !== "number" || typeof entry.m !== "number") continue; | 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 | if (!isRecordArray(entry.r) || !isRecordArray(entry.t)) continue; | 312 | if (!isRecordArray(entry.r) || !isRecordArray(entry.t)) continue; |
| 305 | // Position fields feed byte offsets and a Buffer allocation in the reader, | 313 | // Position fields feed byte offsets and a Buffer allocation in the reader, |
| 306 | // so anything outside their real ranges must reject the entry: a bogus | 314 | // so anything outside their real ranges must reject the entry: a bogus |
| ... | @@ -320,27 +328,34 @@ export function decodeScanCache(document: unknown): ScanCache { | ... | @@ -320,27 +328,34 @@ export function decodeScanCache(document: unknown): ScanCache { |
| 320 | ) { | 328 | ) { |
| 321 | continue; | 329 | continue; |
| 322 | } | 330 | } |
| 323 | // v4 Codex records predate service tiers, so they all priced as standard. | 331 | // v4 records of stateful formats (Codex) predate service tiers, so they |
| 324 | // Keep them, because the rollout may be gone, but make a live rollout | 332 | // all priced as standard. Keep them, because the rollout may be gone, but |
| 325 | // re-parse whole: no file has size -1, and a zero position cannot resume. | 333 | // make a live rollout re-parse whole: no file has size -1, and a zero |
| 326 | const legacyCodex = entry.p === "codex" && version < USAGE_SCAN_CACHE_VERSION; | 334 | // position cannot resume. |
| 327 | const codexState = legacyCodex ? null : decodeCodexState(entry.cs); | 335 | const legacy = format.state !== undefined && version < USAGE_SCAN_CACHE_VERSION; |
| 328 | if (codexState === undefined) continue; | 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 | const records = decodeRecords(entry.r, provider); | 341 | const records = decodeRecords(entry.r, provider); |
| 332 | const tailRecords = decodeRecords(entry.t, provider); | 342 | const tailRecords = decodeRecords(entry.t, provider); |
| 333 | if (records === null || tailRecords === null) continue; | 343 | if (records === null || tailRecords === null) continue; |
| 334 | 344 | ||
| 335 | cache.set(path, { | 345 | cache.set(path, { |
| 336 | size: legacyCodex ? -1 : entry.s, | 346 | size: legacy ? -1 : entry.s, |
| 337 | mtimeMs: entry.m, | 347 | mtimeMs: entry.m, |
| 338 | provider, | 348 | provider, |
| 339 | records, | 349 | records, |
| 340 | tailRecords, | 350 | tailRecords, |
| 341 | position: legacyCodex | 351 | position: legacy |
| 342 | ? { resumeOffset: 0, guardLength: 0, guardHash: 0, codexState: null } | 352 | ? { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null } |
| 343 | : { resumeOffset: entry.o, guardLength: entry.gl, guardHash: entry.gh, codexState }, | 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,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 | 366 | * Per provider, whether a persisted state is valid: `null`, or one the |
| 352 | * value, which disqualifies the entry: resuming with a bad state would attach | 367 | * format's schema accepts. |
| 353 | * appended usage to the wrong model or replay fork-copied history. | ||
| 354 | */ | 368 | */ |
| 355 | function decodeCodexState(value: unknown): CodexScanState | null | undefined { | 369 | function makeStateValidators( |
| 356 | if (value === null) return null; | 370 | formats: ReadonlyMap<UsageProviderKind, TranscriptUsageFormat<unknown>>, |
| 357 | if (typeof value !== "object") return undefined; | 371 | ): ReadonlyMap<UsageProviderKind, (value: unknown) => boolean> { |
| 358 | const state = value as Partial<CodexScanState>; | 372 | return new Map( |
| 359 | if ( | 373 | [...formats].map(([provider, format]) => { |
| 360 | typeof state.model !== "string" || | 374 | const isState = format.state === undefined ? undefined : Schema.is(format.state.schema); |
| 361 | !isSpeed(state.speed) || | 375 | return [ |
| 362 | typeof state.sessionId !== "string" || | 376 | provider, |
| 363 | (state.lastUsageSignature !== null && typeof state.lastUsageSignature !== "string") || | 377 | (value: unknown) => value === null || (isState !== undefined && isState(value)), |
| 364 | typeof state.sawSessionMeta !== "boolean" || | 378 | ] as const; |
| 365 | typeof state.suppressingForkCopies !== "boolean" || | 379 | }), |
| 366 | typeof state.forkCopyAnchorMs !== "number" || | 380 | ); |
| 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 | }; | ||
| 380 | } | 381 | } |
| 381 | 382 | ||
| 382 | /** Keeps saved usage after transcript cleanup, until the reporting retention expires. */ | 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,6 +8,7 @@ import * as NodeSqlite from "node:sqlite"; |
| 8 | 8 | ||
| 9 | import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest"; | 9 | import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest"; |
| 10 | 10 | ||
| 11 | import { TEST_FORMATS } from "./usageTestFormats.ts"; | ||
| 11 | import { readTranscriptRecords } from "./usageTranscriptReader.ts"; | 12 | import { readTranscriptRecords } from "./usageTranscriptReader.ts"; |
| 12 | import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; | 13 | import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; |
| 13 | import { readCursorAccountUsage } from "./cursorUsageReader.ts"; | 14 | import { readCursorAccountUsage } from "./cursorUsageReader.ts"; |
| ... | @@ -99,20 +100,20 @@ describe("readTranscriptRecords resume", () => { | ... | @@ -99,20 +100,20 @@ describe("readTranscriptRecords resume", () => { |
| 99 | it("parses only appended lines when resuming a grown file", async () => { | 100 | it("parses only appended lines when resuming a grown file", async () => { |
| 100 | const path = NodePath.join(dir, "claude.jsonl"); | 101 | const path = NodePath.join(dir, "claude.jsonl"); |
| 101 | await NodeFSP.writeFile(path, claudeLine(1, 5) + claudeLine(2, 7)); | 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 | assert.isNotNull(first); | 104 | assert.isNotNull(first); |
| 104 | assert.strictEqual(first.records.length, 2); | 105 | assert.strictEqual(first.records.length, 2); |
| 105 | assert.isFalse(first.resumed); | 106 | assert.isFalse(first.resumed); |
| 106 | 107 | ||
| 107 | await NodeFSP.appendFile(path, claudeLine(3, 11)); | 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 | assert.isNotNull(second); | 110 | assert.isNotNull(second); |
| 110 | assert.isTrue(second.resumed); | 111 | assert.isTrue(second.resumed); |
| 111 | assert.strictEqual(second.records.length, 1); | 112 | assert.strictEqual(second.records.length, 1); |
| 112 | assert.strictEqual(second.records[0]?.totals.outputTokens, 11); | 113 | assert.strictEqual(second.records[0]?.totals.outputTokens, 11); |
| 113 | 114 | ||
| 114 | // The stitched result matches a from-scratch parse of the whole file. | 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 | assert.isNotNull(full); | 117 | assert.isNotNull(full); |
| 117 | assert.deepStrictEqual([...first.records, ...second.records], [...full.records]); | 118 | assert.deepStrictEqual([...first.records, ...second.records], [...full.records]); |
| 118 | }); | 119 | }); |
| ... | @@ -123,7 +124,7 @@ describe("readTranscriptRecords resume", () => { | ... | @@ -123,7 +124,7 @@ describe("readTranscriptRecords resume", () => { |
| 123 | path, | 124 | path, |
| 124 | codexMetaLine() + codexModelLine("gpt-5.2-codex") + codexTierLine("ultrafast"), | 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 | assert.isNotNull(first); | 128 | assert.isNotNull(first); |
| 128 | assert.strictEqual(first.records.length, 0); | 129 | assert.strictEqual(first.records.length, 0); |
| 129 | 130 | ||
| ... | @@ -131,7 +132,7 @@ describe("readTranscriptRecords resume", () => { | ... | @@ -131,7 +132,7 @@ describe("readTranscriptRecords resume", () => { |
| 131 | // session_meta of its own; model, tier, and session must come from the | 132 | // session_meta of its own; model, tier, and session must come from the |
| 132 | // state captured before the boundary. | 133 | // state captured before the boundary. |
| 133 | await NodeFSP.appendFile(path, codexUsageLine(9, 5)); | 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 | assert.isNotNull(second); | 136 | assert.isNotNull(second); |
| 136 | assert.isTrue(second.resumed); | 137 | assert.isTrue(second.resumed); |
| 137 | assert.strictEqual(second.records.length, 1); | 138 | assert.strictEqual(second.records.length, 1); |
| ... | @@ -146,14 +147,14 @@ describe("readTranscriptRecords resume", () => { | ... | @@ -146,14 +147,14 @@ describe("readTranscriptRecords resume", () => { |
| 146 | path, | 147 | path, |
| 147 | codexMetaLine() + codexModelLine("gpt-5.2-codex") + codexUsageLine(9, 5), | 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 | assert.isNotNull(first); | 151 | assert.isNotNull(first); |
| 151 | assert.strictEqual(first.records.length, 1); | 152 | assert.strictEqual(first.records.length, 1); |
| 152 | 153 | ||
| 153 | // Codex re-emits an unchanged token_count on stream boundaries; the copy | 154 | // Codex re-emits an unchanged token_count on stream boundaries; the copy |
| 154 | // lands after the resume point and must still be dropped. | 155 | // lands after the resume point and must still be dropped. |
| 155 | await NodeFSP.appendFile(path, codexUsageLine(9, 5) + codexUsageLine(21, 8)); | 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 | assert.isNotNull(second); | 158 | assert.isNotNull(second); |
| 158 | assert.isTrue(second.resumed); | 159 | assert.isTrue(second.resumed); |
| 159 | assert.deepStrictEqual( | 160 | assert.deepStrictEqual( |
| ... | @@ -166,7 +167,7 @@ describe("readTranscriptRecords resume", () => { | ... | @@ -166,7 +167,7 @@ describe("readTranscriptRecords resume", () => { |
| 166 | const path = NodePath.join(dir, "claude.jsonl"); | 167 | const path = NodePath.join(dir, "claude.jsonl"); |
| 167 | const unterminated = claudeLine(2, 7).trimEnd(); | 168 | const unterminated = claudeLine(2, 7).trimEnd(); |
| 168 | await NodeFSP.writeFile(path, claudeLine(1, 5) + unterminated); | 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 | assert.isNotNull(first); | 171 | assert.isNotNull(first); |
| 171 | assert.strictEqual(first.records.length, 1); | 172 | assert.strictEqual(first.records.length, 1); |
| 172 | assert.strictEqual(first.tailRecords.length, 1); | 173 | assert.strictEqual(first.tailRecords.length, 1); |
| ... | @@ -175,7 +176,7 @@ describe("readTranscriptRecords resume", () => { | ... | @@ -175,7 +176,7 @@ describe("readTranscriptRecords resume", () => { |
| 175 | // Completing the line and appending another re-reads from the resume | 176 | // Completing the line and appending another re-reads from the resume |
| 176 | // point, so the once-tail record arrives exactly once as a line record. | 177 | // point, so the once-tail record arrives exactly once as a line record. |
| 177 | await NodeFSP.appendFile(path, `\n${claudeLine(3, 11)}`); | 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 | assert.isNotNull(second); | 180 | assert.isNotNull(second); |
| 180 | assert.isTrue(second.resumed); | 181 | assert.isTrue(second.resumed); |
| 181 | assert.deepStrictEqual( | 182 | assert.deepStrictEqual( |
| ... | @@ -188,12 +189,12 @@ describe("readTranscriptRecords resume", () => { | ... | @@ -188,12 +189,12 @@ describe("readTranscriptRecords resume", () => { |
| 188 | it("re-parses from the start when the guard bytes no longer match", async () => { | 189 | it("re-parses from the start when the guard bytes no longer match", async () => { |
| 189 | const path = NodePath.join(dir, "claude.jsonl"); | 190 | const path = NodePath.join(dir, "claude.jsonl"); |
| 190 | await NodeFSP.writeFile(path, claudeLine(1, 5)); | 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 | assert.isNotNull(first); | 193 | assert.isNotNull(first); |
| 193 | 194 | ||
| 194 | // Same path, larger size, different content: a replaced file, not growth. | 195 | // Same path, larger size, different content: a replaced file, not growth. |
| 195 | await NodeFSP.writeFile(path, claudeLine(4, 13) + claudeLine(5, 17)); | 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 | assert.isNotNull(second); | 198 | assert.isNotNull(second); |
| 198 | assert.isFalse(second.resumed); | 199 | assert.isFalse(second.resumed); |
| 199 | assert.deepStrictEqual( | 200 | assert.deepStrictEqual( |
| ... | @@ -205,11 +206,11 @@ describe("readTranscriptRecords resume", () => { | ... | @@ -205,11 +206,11 @@ describe("readTranscriptRecords resume", () => { |
| 205 | it("re-parses from the start when the file shrank below the resume point", async () => { | 206 | it("re-parses from the start when the file shrank below the resume point", async () => { |
| 206 | const path = NodePath.join(dir, "claude.jsonl"); | 207 | const path = NodePath.join(dir, "claude.jsonl"); |
| 207 | await NodeFSP.writeFile(path, claudeLine(1, 5) + claudeLine(2, 7)); | 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 | assert.isNotNull(first); | 210 | assert.isNotNull(first); |
| 210 | 211 | ||
| 211 | await NodeFSP.writeFile(path, claudeLine(3, 11)); | 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 | assert.isNotNull(second); | 214 | assert.isNotNull(second); |
| 214 | assert.isFalse(second.resumed); | 215 | assert.isFalse(second.resumed); |
| 215 | assert.deepStrictEqual( | 216 | assert.deepStrictEqual( |
| ... | @@ -236,7 +237,7 @@ describe("readTranscriptRecords resume", () => { | ... | @@ -236,7 +237,7 @@ describe("readTranscriptRecords resume", () => { |
| 236 | })}\n`; | 237 | })}\n`; |
| 237 | await NodeFSP.writeFile(path, bigLine + claudeLine(2, 7)); | 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 | assert.isNotNull(parsed); | 241 | assert.isNotNull(parsed); |
| 241 | assert.deepStrictEqual( | 242 | assert.deepStrictEqual( |
| 242 | parsed.records.map((record) => record.totals.outputTokens), | 243 | parsed.records.map((record) => record.totals.outputTokens), |
| ... | @@ -245,7 +246,9 @@ describe("readTranscriptRecords resume", () => { | ... | @@ -245,7 +246,9 @@ describe("readTranscriptRecords resume", () => { |
| 245 | }); | 246 | }); |
| 246 | 247 | ||
| 247 | it("returns null for an unreadable file", async () => { | 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,23 +19,16 @@ import * as NodeFSP from "node:fs/promises"; |
| 19 | import * as NodePath from "node:path"; | 19 | import * as NodePath from "node:path"; |
| 20 | import * as NodeStringDecoder from "node:string_decoder"; | 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 | import { createTranscriptJsonReader } from "../project/AgentSessionJson.ts"; | 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 | export interface TranscriptFile { | 32 | export interface TranscriptFile { |
| 40 | readonly path: string; | 33 | readonly path: string; |
| 41 | readonly size: number; | 34 | readonly size: number; |
| ... | @@ -60,8 +53,11 @@ export interface TranscriptParsePosition { | ... | @@ -60,8 +53,11 @@ export interface TranscriptParsePosition { |
| 60 | readonly guardLength: number; | 53 | readonly guardLength: number; |
| 61 | /** FNV-1a hash of that window. */ | 54 | /** FNV-1a hash of that window. */ |
| 62 | readonly guardHash: number; | 55 | readonly guardHash: number; |
| 63 | /** Codex reducer state as of `resumeOffset`; `null` for stateless providers. */ | 56 | /** |
| 64 | readonly codexState: CodexScanState | null; | 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 | export interface TranscriptParseResult { | 63 | export interface TranscriptParseResult { |
| ... | @@ -89,45 +85,7 @@ const NEWLINE = 0x0a; | ... | @@ -89,45 +85,7 @@ const NEWLINE = 0x0a; |
| 89 | const STAT_CONCURRENCY = 32; | 85 | const STAT_CONCURRENCY = 32; |
| 90 | const CARRIAGE_RETURN = 0x0d; | 86 | const CARRIAGE_RETURN = 0x0d; |
| 91 | 87 | ||
| 92 | type SelectedFields = { readonly [key: string]: true | SelectedFields }; | 88 | function selectUsageFields(fields: 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"]; | ||
| 131 | return (path: ReadonlyArray<string | number | null>): boolean => { | 89 | return (path: ReadonlyArray<string | number | null>): boolean => { |
| 132 | let selected: true | SelectedFields = fields; | 90 | let selected: true | SelectedFields = fields; |
| 133 | for (const key of path) { | 91 | for (const key of path) { |
| ... | @@ -257,14 +215,12 @@ async function guardMatches( | ... | @@ -257,14 +215,12 @@ async function guardMatches( |
| 257 | * still match, so only appended lines are read; otherwise the whole file is | 215 | * still match, so only appended lines are read; otherwise the whole file is |
| 258 | * re-parsed from the start and `resumed` reports `false`. | 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 | 218 | * A stateful format resumes with the state saved at that position; a saved |
| 261 | * on `thread_settings_applied` lines. Neither holds usage of its own, but both | 219 | * state its schema rejects re-parses the file whole. |
| 262 | * still have to pass through the reducer to keep attribution and pricing | ||
| 263 | * correct. | ||
| 264 | */ | 220 | */ |
| 265 | export async function readTranscriptRecords( | 221 | export async function readTranscriptRecords<State>( |
| 266 | filePath: string, | 222 | filePath: string, |
| 267 | provider: UsageProviderKind, | 223 | format: TranscriptUsageFormat<State>, |
| 268 | resumeFrom?: TranscriptParsePosition, | 224 | resumeFrom?: TranscriptParsePosition, |
| 269 | options?: { readonly streamingThresholdBytes?: number }, | 225 | options?: { readonly streamingThresholdBytes?: number }, |
| 270 | ): Promise<TranscriptParseResult | null> { | 226 | ): Promise<TranscriptParseResult | null> { |
| ... | @@ -277,41 +233,31 @@ export async function readTranscriptRecords( | ... | @@ -277,41 +233,31 @@ export async function readTranscriptRecords( |
| 277 | } | 233 | } |
| 278 | 234 | ||
| 279 | try { | 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 | let resumed = false; | 241 | let resumed = false; |
| 282 | let start = 0; | 242 | let start = 0; |
| 243 | const savedState = | ||
| 244 | resumeFrom === undefined || decodeState === undefined || resumeFrom.state === null | ||
| 245 | ? Option.none() | ||
| 246 | : decodeState(resumeFrom.state); | ||
| 283 | if ( | 247 | if ( |
| 284 | resumeFrom !== undefined && | 248 | resumeFrom !== undefined && |
| 285 | resumeFrom.resumeOffset > 0 && | 249 | resumeFrom.resumeOffset > 0 && |
| 286 | (provider !== "codex" || resumeFrom.codexState !== null) && | 250 | (codec === undefined || Option.isSome(savedState)) && |
| 287 | (await guardMatches(handle, resumeFrom)) | 251 | (await guardMatches(handle, resumeFrom)) |
| 288 | ) { | 252 | ) { |
| 289 | if (resumeFrom.codexState !== null) codexState = { ...resumeFrom.codexState }; | 253 | if (Option.isSome(savedState)) state = savedState.value; |
| 290 | start = resumeFrom.resumeOffset; | 254 | start = resumeFrom.resumeOffset; |
| 291 | resumed = true; | 255 | resumed = true; |
| 292 | } | 256 | } |
| 293 | 257 | ||
| 294 | const parseLine = (line: string, state: CodexScanState, out: UsageRecord[]): void => { | 258 | const parseLine = (line: string, lineState: State, out: UsageRecord[]): void => { |
| 295 | if (provider === "codex") { | 259 | if (!format.mightCarryUsage(line)) return; |
| 296 | if ( | 260 | for (const record of format.parseLine(line, lineState)) out.push(record); |
| 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); | ||
| 315 | }; | 261 | }; |
| 316 | 262 | ||
| 317 | const toLineString = (lineBuffer: Buffer): string => { | 263 | const toLineString = (lineBuffer: Buffer): string => { |
| ... | @@ -331,7 +277,7 @@ export async function readTranscriptRecords( | ... | @@ -331,7 +277,7 @@ export async function readTranscriptRecords( |
| 331 | let pendingBytes = 0; | 277 | let pendingBytes = 0; |
| 332 | let streaming: ReturnType<typeof createTranscriptJsonReader> | undefined; | 278 | let streaming: ReturnType<typeof createTranscriptJsonReader> | undefined; |
| 333 | let decoder: NodeStringDecoder.StringDecoder | undefined; | 279 | let decoder: NodeStringDecoder.StringDecoder | undefined; |
| 334 | const selectPath = selectUsageFields(provider); | 280 | const selectPath = selectUsageFields(format.selectFields); |
| 335 | 281 | ||
| 336 | const append = (segment: Buffer) => { | 282 | const append = (segment: Buffer) => { |
| 337 | if (!streaming && pendingBytes + segment.length <= streamingThresholdBytes) { | 283 | if (!streaming && pendingBytes + segment.length <= streamingThresholdBytes) { |
| ... | @@ -350,25 +296,17 @@ export async function readTranscriptRecords( | ... | @@ -350,25 +296,17 @@ export async function readTranscriptRecords( |
| 350 | } | 296 | } |
| 351 | streaming.write(decoder!.write(segment)); | 297 | streaming.write(decoder!.write(segment)); |
| 352 | }; | 298 | }; |
| 353 | const finish = (state: CodexScanState, out: UsageRecord[]) => { | 299 | const finish = (lineState: State, out: UsageRecord[]) => { |
| 354 | if (streaming) { | 300 | if (streaming) { |
| 355 | streaming.write(decoder!.end()); | 301 | streaming.write(decoder!.end()); |
| 356 | const projected = streaming.finish(); | 302 | const projected = streaming.finish(); |
| 357 | if (provider === "grok") { | 303 | for (const record of format.parseProjected(projected, lineState)) out.push(record); |
| 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 | } | ||
| 366 | } else if (pendingBytes > 0) { | 304 | } else if (pendingBytes > 0) { |
| 367 | const line = | 305 | const line = |
| 368 | pendingChunks.length === 1 | 306 | pendingChunks.length === 1 |
| 369 | ? pendingChunks[0]! | 307 | ? pendingChunks[0]! |
| 370 | : Buffer.concat(pendingChunks, pendingBytes); | 308 | : Buffer.concat(pendingChunks, pendingBytes); |
| 371 | parseLine(toLineString(line), state, out); | 309 | parseLine(toLineString(line), lineState, out); |
| 372 | } | 310 | } |
| 373 | pendingChunks = []; | 311 | pendingChunks = []; |
| 374 | pendingBytes = 0; | 312 | pendingBytes = 0; |
| ... | @@ -391,10 +329,10 @@ export async function readTranscriptRecords( | ... | @@ -391,10 +329,10 @@ export async function readTranscriptRecords( |
| 391 | // Most lines fit in the current chunk. Avoid buffering/streaming | 329 | // Most lines fit in the current chunk. Avoid buffering/streaming |
| 392 | // machinery on this hot path. | 330 | // machinery on this hot path. |
| 393 | if (!streaming && pendingBytes === 0) { | 331 | if (!streaming && pendingBytes === 0) { |
| 394 | parseLine(toLineString(chunk.subarray(lineStart, newlineIndex)), codexState, records); | 332 | parseLine(toLineString(chunk.subarray(lineStart, newlineIndex)), state, records); |
| 395 | } else { | 333 | } else { |
| 396 | append(chunk.subarray(lineStart, newlineIndex)); | 334 | append(chunk.subarray(lineStart, newlineIndex)); |
| 397 | finish(codexState, records); | 335 | finish(state, records); |
| 398 | } | 336 | } |
| 399 | lineStart = newlineIndex + 1; | 337 | lineStart = newlineIndex + 1; |
| 400 | resumeOffset = scanOffset + lineStart; | 338 | resumeOffset = scanOffset + lineStart; |
| ... | @@ -402,8 +340,14 @@ export async function readTranscriptRecords( | ... | @@ -402,8 +340,14 @@ export async function readTranscriptRecords( |
| 402 | scanOffset += chunk.length; | 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 | const tailRecords: UsageRecord[] = []; | 346 | const tailRecords: UsageRecord[] = []; |
| 406 | finish({ ...codexState }, tailRecords); | 347 | finish( |
| 348 | decodeState === undefined ? state : Option.getOrThrow(decodeState(encodedState)), | ||
| 349 | tailRecords, | ||
| 350 | ); | ||
| 407 | 351 | ||
| 408 | const guardLength = Math.min(GUARD_LENGTH, resumeOffset); | 352 | const guardLength = Math.min(GUARD_LENGTH, resumeOffset); |
| 409 | let guardHash = 0; | 353 | let guardHash = 0; |
| ... | @@ -420,7 +364,7 @@ export async function readTranscriptRecords( | ... | @@ -420,7 +364,7 @@ export async function readTranscriptRecords( |
| 420 | resumeOffset, | 364 | resumeOffset, |
| 421 | guardLength, | 365 | guardLength, |
| 422 | guardHash, | 366 | guardHash, |
| 423 | codexState: provider === "codex" ? codexState : null, | 367 | state: encodedState, |
| 424 | }, | 368 | }, |
| 425 | resumed, | 369 | resumed, |
| 426 | }; | 370 | }; |
apps/server/src/usage/usageTranscriptStreaming.test.ts+6-2| ... | @@ -5,6 +5,7 @@ import * as NodePath from "node:path"; | ... | @@ -5,6 +5,7 @@ import * as NodePath from "node:path"; |
| 5 | 5 | ||
| 6 | import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; | 6 | import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; |
| 7 | 7 | ||
| 8 | import { TEST_FORMATS } from "./usageTestFormats.ts"; | ||
| 8 | import { | 9 | import { |
| 9 | readTranscriptRecords as readWithDefaultThreshold, | 10 | readTranscriptRecords as readWithDefaultThreshold, |
| 10 | type TranscriptParsePosition, | 11 | type TranscriptParsePosition, |
| ... | @@ -16,7 +17,10 @@ const readTranscriptRecords = ( | ... | @@ -16,7 +17,10 @@ const readTranscriptRecords = ( |
| 16 | path: string, | 17 | path: string, |
| 17 | provider: "claude" | "codex" | "grok", | 18 | provider: "claude" | "codex" | "grok", |
| 18 | position?: TranscriptParsePosition, | 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 | let dir: string; | 25 | let dir: string; |
| 22 | beforeEach(async () => { | 26 | beforeEach(async () => { |
| ... | @@ -144,7 +148,7 @@ describe("large usage records", () => { | ... | @@ -144,7 +148,7 @@ describe("large usage records", () => { |
| 144 | ); | 148 | ); |
| 145 | const actual = await scan(large, provider); | 149 | const actual = await scan(large, provider); |
| 146 | expect(actual.records).toEqual(expected.records); | 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,6 +175,10 @@ |
| 175 | "types": "./src/server/threadTitleContext.ts", | 175 | "types": "./src/server/threadTitleContext.ts", |
| 176 | "import": "./src/server/threadTitleContext.ts" | 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 | "./server/usageLimits": { | 182 | "./server/usageLimits": { |
| 179 | "types": "./src/server/usageLimits.ts", | 183 | "types": "./src/server/usageLimits.ts", |
| 180 | "import": "./src/server/usageLimits.ts" | 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,6 +42,7 @@ import type { ProviderAuthController } from "./auth.ts"; |
| 42 | import type { ProviderDriverError } from "./errors.ts"; | 42 | import type { ProviderDriverError } from "./errors.ts"; |
| 43 | import type { ManagedServerProvider } from "./snapshot.ts"; | 43 | import type { ManagedServerProvider } from "./snapshot.ts"; |
| 44 | import type { ProviderTextGeneration } from "./textGeneration.ts"; | 44 | import type { ProviderTextGeneration } from "./textGeneration.ts"; |
| 45 | import type { ProviderUsageReader } from "./usage.ts"; | ||
| 45 | 46 | ||
| 46 | /** | 47 | /** |
| 47 | * Static metadata advertised by a driver. Used for default presentation | 48 | * Static metadata advertised by a driver. Used for default presentation |
| ... | @@ -172,7 +173,7 @@ export interface ProviderDriverCreateInput<Config> { | ... | @@ -172,7 +173,7 @@ export interface ProviderDriverCreateInput<Config> { |
| 172 | * scope closes. Two calls to `create` with different `instanceId` / | 173 | * scope closes. Two calls to `create` with different `instanceId` / |
| 173 | * `config` MUST yield instances with no shared mutable state. | 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 | readonly driverKind: ProviderDriverKind; | 177 | readonly driverKind: ProviderDriverKind; |
| 177 | readonly metadata: ProviderDriverMetadata; | 178 | readonly metadata: ProviderDriverMetadata; |
| 178 | /** | 179 | /** |
| ... | @@ -207,6 +208,11 @@ export interface ProviderDriver<Config, R = never> { | ... | @@ -207,6 +208,11 @@ export interface ProviderDriver<Config, R = never> { |
| 207 | readonly create: ( | 208 | readonly create: ( |
| 208 | input: ProviderDriverCreateInput<Config>, | 209 | input: ProviderDriverCreateInput<Config>, |
| 209 | ) => Effect.Effect<ProviderInstance, ProviderDriverError, R | Scope.Scope>; | 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,4 +225,8 @@ export interface ProviderDriver<Config, R = never> { |
| 219 | // needs the original `Config` type. Using `unknown` instead would force | 225 | // needs the original `Config` type. Using `unknown` instead would force |
| 220 | // `create` callers into casts since `unknown` is not assignable to a | 226 | // `create` callers into casts since `unknown` is not assignable to a |
| 221 | // concrete `Config` from inside the driver body. | 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,6 +15,10 @@ |
| 15 | "types": "./src/server.ts", | 15 | "types": "./src/server.ts", |
| 16 | "import": "./src/server.ts" | 16 | "import": "./src/server.ts" |
| 17 | }, | 17 | }, |
| 18 | "./server/usage": { | ||
| 19 | "types": "./src/server/usage.ts", | ||
| 20 | "import": "./src/server/usage.ts" | ||
| 21 | }, | ||
| 18 | "./testing": { | 22 | "./testing": { |
| 19 | "types": "./src/testing.ts", | 23 | "types": "./src/testing.ts", |
| 20 | "import": "./src/testing.ts" | 24 | "import": "./src/testing.ts" |
packages/provider-grok/src/server/driver.ts+3-1| ... | @@ -19,6 +19,7 @@ import { | ... | @@ -19,6 +19,7 @@ import { |
| 19 | checkGrokProviderStatus, | 19 | checkGrokProviderStatus, |
| 20 | enrichGrokSnapshot, | 20 | enrichGrokSnapshot, |
| 21 | } from "./status.ts"; | 21 | } from "./status.ts"; |
| 22 | import { grokUsageReader } from "./usage.ts"; | ||
| 22 | import { readGrokAccount } from "./usageLimits.ts"; | 23 | import { readGrokAccount } from "./usageLimits.ts"; |
| 23 | import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; | 24 | import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; |
| 24 | import { | 25 | import { |
| ... | @@ -82,7 +83,7 @@ export type GrokDriverEnv = | ... | @@ -82,7 +83,7 @@ export type GrokDriverEnv = |
| 82 | | Path.Path | 83 | | Path.Path |
| 83 | | ProviderEventLoggers.ProviderEventLoggers; | 84 | | ProviderEventLoggers.ProviderEventLoggers; |
| 84 | 85 | ||
| 85 | export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = { | 86 | export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv, Path.Path> = { |
| 86 | driverKind: DRIVER_KIND, | 87 | driverKind: DRIVER_KIND, |
| 87 | metadata: { | 88 | metadata: { |
| 88 | displayName: "Grok", | 89 | displayName: "Grok", |
| ... | @@ -90,6 +91,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = { | ... | @@ -90,6 +91,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = { |
| 90 | }, | 91 | }, |
| 91 | configSchema: GrokSettings, | 92 | configSchema: GrokSettings, |
| 92 | defaultConfig: (): GrokSettings => decodeGrokSettings({}), | 93 | defaultConfig: (): GrokSettings => decodeGrokSettings({}), |
| 94 | usage: grokUsageReader, | ||
| 93 | create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => | 95 | create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => |
| 94 | Effect.gen(function* () { | 96 | Effect.gen(function* () { |
| 95 | const crypto = yield* Crypto.Crypto; | 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 | }; | ||