| author | |
| committer | |
| log | 7f02b849584332368a5489631d3286409eed90a8 |
| tree | 6c30b755624619a6bad42664c6c56de910f56eba |
| parent | 499a53c307ec6a0fbff5c83efde355729bd7d343 |
| signature | Signed by PGP key B5690EEEBB952194 |
6 files changed, 93 insertions(+), 19 deletions(-)
apps/server/src/provider/Drivers/claudeUsage.ts+1-1| ... | @@ -98,7 +98,7 @@ export const claudeUsageFormat: TranscriptUsageFormat<void> = { | ... | @@ -98,7 +98,7 @@ export const claudeUsageFormat: TranscriptUsageFormat<void> = { |
| 98 | costUSD: true, | 98 | costUSD: true, |
| 99 | message: { id: true, model: true, usage: true }, | 99 | message: { id: true, model: true, usage: true }, |
| 100 | }, | 100 | }, |
| 101 | mightCarryUsage: (line) => line.includes('"usage"'), | 101 | usageMarkers: ['"usage"'], |
| 102 | parseLine: (line) => orEmpty(parseClaudeLine(line)), | 102 | parseLine: (line) => orEmpty(parseClaudeLine(line)), |
| 103 | parseProjected: (projected) => orEmpty(parseClaudeRecord(projected)), | 103 | parseProjected: (projected) => orEmpty(parseClaudeRecord(projected)), |
| 104 | }; | 104 | }; |
apps/server/src/provider/Drivers/codexUsage.ts+1-5| ... | @@ -231,11 +231,7 @@ export const codexUsageFormat: TranscriptUsageFormat<CodexScanState> = { | ... | @@ -231,11 +231,7 @@ export const codexUsageFormat: TranscriptUsageFormat<CodexScanState> = { |
| 231 | }, | 231 | }, |
| 232 | // `turn_context`, `thread_settings_applied` and `session_meta` lines hold no | 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. | 233 | // usage, but they carry the model, tier and session the reducer needs. |
| 234 | mightCarryUsage: (line) => | 234 | usageMarkers: ['"token_count"', '"turn_context"', '"thread_settings_applied"', '"session_meta"'], |
| 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)), | 235 | parseLine: (line, state) => orEmpty(parseCodexLine(line, state)), |
| 240 | parseProjected: (projected, state) => orEmpty(parseCodexRecord(projected, state)), | 236 | parseProjected: (projected, state) => orEmpty(parseCodexRecord(projected, state)), |
| 241 | state: { initial: initialCodexScanState, schema: CodexScanState }, | 237 | state: { initial: initialCodexScanState, schema: CodexScanState }, |
apps/server/src/usage/usageTranscriptReader.test.ts+55-1| ... | @@ -8,7 +8,7 @@ import * as NodePath from "node:path"; | ... | @@ -8,7 +8,7 @@ import * as NodePath from "node:path"; |
| 8 | import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest"; | 8 | import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest"; |
| 9 | 9 | ||
| 10 | import { TEST_FORMATS } from "./usageTestFormats.ts"; | 10 | import { TEST_FORMATS } from "./usageTestFormats.ts"; |
| 11 | import { readTranscriptRecords } from "./usageTranscriptReader.ts"; | 11 | import { readTranscriptRecords, transcriptLineGate } from "./usageTranscriptReader.ts"; |
| 12 | 12 | ||
| 13 | let dir: string; | 13 | let dir: string; |
| 14 | 14 | ||
| ... | @@ -218,9 +218,63 @@ describe("readTranscriptRecords resume", () => { | ... | @@ -218,9 +218,63 @@ describe("readTranscriptRecords resume", () => { |
| 218 | ); | 218 | ); |
| 219 | }); | 219 | }); |
| 220 | 220 | ||
| 221 | it("keeps a CRLF Codex reducer line whose marker straddles a stream chunk", async () => { | ||
| 222 | const path = NodePath.join(dir, "rollout.jsonl"); | ||
| 223 | const crlf = (line: string) => line.replace("\n", "\r\n"); | ||
| 224 | const modelLine = crlf(codexModelLine("gpt-5.2-codex")); | ||
| 225 | // Multi-byte tool output that fails the gate, sized so the 256 KiB chunk | ||
| 226 | // boundary falls inside the model line's `"turn_context"` marker. | ||
| 227 | const head = crlf(codexMetaLine()); | ||
| 228 | const fillerBytes = | ||
| 229 | 256 * 1024 - Buffer.byteLength(head) - modelLine.indexOf("turn_context") - 4; | ||
| 230 | const fillerEnvelope = crlf(`${JSON.stringify({ type: "response_item", output: "" })}\n`); | ||
| 231 | const padding = "é".repeat((fillerBytes - Buffer.byteLength(fillerEnvelope)) / 2); | ||
| 232 | const filler = crlf(`${JSON.stringify({ type: "response_item", output: padding })}\n`); | ||
| 233 | const content = head + filler + modelLine + crlf(codexUsageLine(40, 2)); | ||
| 234 | await NodeFSP.writeFile(path, content); | ||
| 235 | |||
| 236 | const result = await readTranscriptRecords(path, TEST_FORMATS.codex); | ||
| 237 | assert.isNotNull(result); | ||
| 238 | assert.strictEqual(result.records.length, 1); | ||
| 239 | assert.strictEqual(result.records[0]?.model, "gpt-5.2-codex"); | ||
| 240 | assert.strictEqual(result.records[0]?.sessionId, "codex-session-1"); | ||
| 241 | assert.strictEqual(result.position.resumeOffset, Buffer.byteLength(content)); | ||
| 242 | }); | ||
| 243 | |||
| 221 | it("returns null for an unreadable file", async () => { | 244 | it("returns null for an unreadable file", async () => { |
| 222 | assert.isNull( | 245 | assert.isNull( |
| 223 | await readTranscriptRecords(NodePath.join(dir, "missing.jsonl"), TEST_FORMATS.claude), | 246 | await readTranscriptRecords(NodePath.join(dir, "missing.jsonl"), TEST_FORMATS.claude), |
| 224 | ); | 247 | ); |
| 225 | }); | 248 | }); |
| 226 | }); | 249 | }); |
| 250 | |||
| 251 | describe("transcriptLineGate", () => { | ||
| 252 | const gates = { | ||
| 253 | one: transcriptLineGate(['"usage"']), | ||
| 254 | several: transcriptLineGate(['"token_count"', '"session_meta"', '"a.b(c)"']), | ||
| 255 | }; | ||
| 256 | const markers = { one: ['"usage"'], several: ['"token_count"', '"session_meta"', '"a.b(c)"'] }; | ||
| 257 | |||
| 258 | it("passes only lines containing one of its markers", () => { | ||
| 259 | for (const kind of ["one", "several"] as const) { | ||
| 260 | for (const marker of markers[kind]) { | ||
| 261 | assert.isTrue(gates[kind](Buffer.from(`{"payload":{${marker}:1}}`))); | ||
| 262 | // The same text without its quotes, or with one character changed. | ||
| 263 | assert.isFalse(gates[kind](Buffer.from(`{"text":"see ${marker.slice(1, -1)} here"}`))); | ||
| 264 | assert.isFalse(gates[kind](Buffer.from(`{${marker.replace(/[._]|usage/, "x")}:1}`))); | ||
| 265 | } | ||
| 266 | assert.isFalse(gates[kind](Buffer.alloc(0))); | ||
| 267 | } | ||
| 268 | }); | ||
| 269 | |||
| 270 | it("matches markers beside multi-byte and malformed UTF-8", () => { | ||
| 271 | const invalid = Buffer.from([0xff, 0xc3]); | ||
| 272 | for (const kind of ["one", "several"] as const) { | ||
| 273 | for (const marker of markers[kind]) { | ||
| 274 | assert.isTrue(gates[kind](Buffer.from(`{"text":"日本語 é",${marker}:1}`))); | ||
| 275 | assert.isTrue(gates[kind](Buffer.concat([invalid, Buffer.from(marker), invalid]))); | ||
| 276 | assert.isFalse(gates[kind](Buffer.from(`{"text":"日本語 é ${marker.slice(1, -1)}"}`))); | ||
| 277 | } | ||
| 278 | } | ||
| 279 | }); | ||
| 280 | }); |
apps/server/src/usage/usageTranscriptReader.ts+30-7| ... | @@ -206,6 +206,26 @@ async function guardMatches( | ... | @@ -206,6 +206,26 @@ async function guardMatches( |
| 206 | } | 206 | } |
| 207 | } | 207 | } |
| 208 | 208 | ||
| 209 | /** | ||
| 210 | * Builds the byte-level test for a format's `usageMarkers`: whether a line | ||
| 211 | * contains any of them. | ||
| 212 | * | ||
| 213 | * One marker is a single `Buffer` search. Several would be one pass over the | ||
| 214 | * line each, so they are matched together as one alternation over the line's | ||
| 215 | * latin1 view instead: latin1 maps every byte to one character without | ||
| 216 | * validating it, so an ASCII marker matches exactly where its bytes do. | ||
| 217 | */ | ||
| 218 | export function transcriptLineGate(markers: readonly [string, ...string[]]) { | ||
| 219 | if (markers.length === 1) { | ||
| 220 | const marker = Buffer.from(markers[0]); | ||
| 221 | return (line: Buffer) => line.includes(marker); | ||
| 222 | } | ||
| 223 | const pattern = new RegExp( | ||
| 224 | markers.map((marker) => marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"), | ||
| 225 | ); | ||
| 226 | return (line: Buffer) => pattern.test(line.toString("latin1")); | ||
| 227 | } | ||
| 228 | |||
| 209 | /** | 229 | /** |
| 210 | * Streams one transcript and returns the usage records it contains, or `null` | 230 | * Streams one transcript and returns the usage records it contains, or `null` |
| 211 | * when the file could not be read. | 231 | * when the file could not be read. |
| ... | @@ -259,11 +279,6 @@ export async function readTranscriptRecords<State>( | ... | @@ -259,11 +279,6 @@ export async function readTranscriptRecords<State>( |
| 259 | resumed = true; | 279 | resumed = true; |
| 260 | } | 280 | } |
| 261 | 281 | ||
| 262 | const parseLine = (line: string, lineState: State, out: UsageRecord[]): void => { | ||
| 263 | if (!format.mightCarryUsage(line)) return; | ||
| 264 | for (const record of format.parseLine(line, lineState)) out.push(record); | ||
| 265 | }; | ||
| 266 | |||
| 267 | const toLineString = (lineBuffer: Buffer): string => { | 282 | const toLineString = (lineBuffer: Buffer): string => { |
| 268 | const content = | 283 | const content = |
| 269 | lineBuffer.length > 0 && lineBuffer[lineBuffer.length - 1] === CARRIAGE_RETURN | 284 | lineBuffer.length > 0 && lineBuffer[lineBuffer.length - 1] === CARRIAGE_RETURN |
| ... | @@ -272,6 +287,14 @@ export async function readTranscriptRecords<State>( | ... | @@ -272,6 +287,14 @@ export async function readTranscriptRecords<State>( |
| 272 | return content.toString("utf8"); | 287 | return content.toString("utf8"); |
| 273 | }; | 288 | }; |
| 274 | 289 | ||
| 290 | // Gate on the raw bytes so the lines that fail it, which is most of them, | ||
| 291 | // are never decoded. | ||
| 292 | const mightCarryUsage = transcriptLineGate(format.usageMarkers); | ||
| 293 | const parseLine = (lineBuffer: Buffer, lineState: State, out: UsageRecord[]): void => { | ||
| 294 | if (!mightCarryUsage(lineBuffer)) return; | ||
| 295 | for (const record of format.parseLine(toLineString(lineBuffer), lineState)) out.push(record); | ||
| 296 | }; | ||
| 297 | |||
| 275 | const records: UsageRecord[] = []; | 298 | const records: UsageRecord[] = []; |
| 276 | // Byte offsets remain independent of UTF-8 decoding. Only complete lines | 299 | // Byte offsets remain independent of UTF-8 decoding. Only complete lines |
| 277 | // commit the resume point; an unfinished tail is replayed on the next scan. | 300 | // commit the resume point; an unfinished tail is replayed on the next scan. |
| ... | @@ -310,7 +333,7 @@ export async function readTranscriptRecords<State>( | ... | @@ -310,7 +333,7 @@ export async function readTranscriptRecords<State>( |
| 310 | pendingChunks.length === 1 | 333 | pendingChunks.length === 1 |
| 311 | ? pendingChunks[0]! | 334 | ? pendingChunks[0]! |
| 312 | : Buffer.concat(pendingChunks, pendingBytes); | 335 | : Buffer.concat(pendingChunks, pendingBytes); |
| 313 | parseLine(toLineString(line), lineState, out); | 336 | parseLine(line, lineState, out); |
| 314 | } | 337 | } |
| 315 | pendingChunks = []; | 338 | pendingChunks = []; |
| 316 | pendingBytes = 0; | 339 | pendingBytes = 0; |
| ... | @@ -333,7 +356,7 @@ export async function readTranscriptRecords<State>( | ... | @@ -333,7 +356,7 @@ export async function readTranscriptRecords<State>( |
| 333 | // Most lines fit in the current chunk. Avoid buffering/streaming | 356 | // Most lines fit in the current chunk. Avoid buffering/streaming |
| 334 | // machinery on this hot path. | 357 | // machinery on this hot path. |
| 335 | if (!streaming && pendingBytes === 0) { | 358 | if (!streaming && pendingBytes === 0) { |
| 336 | parseLine(toLineString(chunk.subarray(lineStart, newlineIndex)), state, records); | 359 | parseLine(chunk.subarray(lineStart, newlineIndex), state, records); |
| 337 | } else { | 360 | } else { |
| 338 | append(chunk.subarray(lineStart, newlineIndex)); | 361 | append(chunk.subarray(lineStart, newlineIndex)); |
| 339 | finish(state, records); | 362 | finish(state, records); |
packages/provider-core/src/server/usage.ts+5-4| ... | @@ -90,11 +90,12 @@ export type SelectedFields = { readonly [key: string]: true | SelectedFields }; | ... | @@ -90,11 +90,12 @@ export type SelectedFields = { readonly [key: string]: true | SelectedFields }; |
| 90 | export interface TranscriptUsageFormat<State> { | 90 | export interface TranscriptUsageFormat<State> { |
| 91 | readonly selectFields: SelectedFields; | 91 | readonly selectFields: SelectedFields; |
| 92 | /** | 92 | /** |
| 93 | * Cheap substring gate applied before `JSON.parse`. Transcripts are mostly | 93 | * ASCII substrings, at least one of which every line that matters contains. |
| 94 | * tool output; skipping lines that cannot matter is worth an order of | 94 | * Transcripts are mostly tool output, so the engine matches these against a |
| 95 | * magnitude. Lines that only update `State` must pass too. | 95 | * line's raw bytes and neither decodes nor parses the rest, which is worth an |
| 96 | * order of magnitude. Lines that only update `State` must match too. | ||
| 96 | */ | 97 | */ |
| 97 | mightCarryUsage(line: string): boolean; | 98 | readonly usageMarkers: readonly [string, ...string[]]; |
| 98 | parseLine(line: string, state: State): readonly UsageRecord[]; | 99 | parseLine(line: string, state: State): readonly UsageRecord[]; |
| 99 | parseProjected(projected: unknown, state: State): readonly UsageRecord[]; | 100 | parseProjected(projected: unknown, state: State): readonly UsageRecord[]; |
| 100 | /** | 101 | /** |
packages/provider-grok/src/server/usage.ts+1-1| ... | @@ -218,7 +218,7 @@ export const grokUsageFormat: TranscriptUsageFormat<void> = { | ... | @@ -218,7 +218,7 @@ export const grokUsageFormat: TranscriptUsageFormat<void> = { |
| 218 | update: { sessionUpdate: true, prompt_id: true, usage: true }, | 218 | update: { sessionUpdate: true, prompt_id: true, usage: true }, |
| 219 | }, | 219 | }, |
| 220 | }, | 220 | }, |
| 221 | mightCarryUsage: (line) => line.includes('"turn_completed"'), | 221 | usageMarkers: ['"turn_completed"'], |
| 222 | parseLine: (line) => parseGrokLine(line), | 222 | parseLine: (line) => parseGrokLine(line), |
| 223 | parseProjected: (projected) => parseGrokRecord(projected), | 223 | parseProjected: (projected) => parseGrokRecord(projected), |
| 224 | }; | 224 | }; |