token-trace-manager 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,428 @@
1
+ // Extração de token usage por protocolo, para respostas streaming (SSE) e JSON.
2
+ // Normalização entre protocolos:
3
+ // inputTokens = tokens de entrada NÃO cacheados
4
+ // cacheReadTokens = tokens de entrada lidos do cache
5
+ // cacheCreateTokens = tokens de escrita de cache (só Anthropic cobra separado)
6
+ // outputTokens = total de saída FATURÁVEL (inclui reasoning/thinking)
7
+ // reasoningTokens = subconjunto informativo de outputTokens
8
+ // Além do usage, os extratores acumulam o TEXTO visível da resposta (campo
9
+ // text, limitado) para o payload de inspeção — ver storePayloads no daemon.
10
+
11
+ export class SseParser {
12
+ constructor(onEvent) {
13
+ this.onEvent = onEvent
14
+ this.buf = ''
15
+ }
16
+
17
+ feed(chunk) {
18
+ this.buf += chunk
19
+ let idx
20
+ // Eventos SSE são separados por linha em branco (\n\n ou \r\n\r\n)
21
+ while ((idx = this.buf.search(/\r?\n\r?\n/)) !== -1) {
22
+ const rawEvent = this.buf.slice(0, idx)
23
+ this.buf = this.buf.slice(idx).replace(/^\r?\n\r?\n/, '')
24
+ this.parseEvent(rawEvent)
25
+ }
26
+ }
27
+
28
+ parseEvent(raw) {
29
+ let event = null
30
+ const dataLines = []
31
+ for (const line of raw.split(/\r?\n/)) {
32
+ if (line.startsWith('event:')) event = line.slice(6).trim()
33
+ else if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart())
34
+ }
35
+ if (!dataLines.length) return
36
+ const data = dataLines.join('\n')
37
+ if (data === '[DONE]') return
38
+ let json
39
+ try {
40
+ json = JSON.parse(data)
41
+ } catch {
42
+ return
43
+ }
44
+ this.onEvent(event, json)
45
+ }
46
+ }
47
+
48
+ const MAX_TEXT_CAPTURE = 200_000 // chars — o suficiente pra inspecionar
49
+
50
+ function appendText(u, piece) {
51
+ if (typeof piece !== 'string' || !piece) return
52
+ if (u.text.length >= MAX_TEXT_CAPTURE) return
53
+ u.text += piece.slice(0, MAX_TEXT_CAPTURE - u.text.length)
54
+ }
55
+
56
+ function zeroUsage() {
57
+ return {
58
+ model: null,
59
+ providerId: null, // id da resposta no provedor (msg_.../chatcmpl-.../resp_...) — elo de auditoria
60
+ text: '', // texto visível da resposta (para o payload de inspeção)
61
+ inputTokens: 0,
62
+ cacheCreateTokens: 0,
63
+ cacheCreate1hTokens: 0, // subconjunto de cacheCreate com TTL 1h (Anthropic cobra 2x, não 1.25x)
64
+ cacheReadTokens: 0,
65
+ outputTokens: 0,
66
+ reasoningTokens: 0,
67
+ sawUsage: false,
68
+ // Quais campos de usage o upstream EFETIVAMENTE reportou (ex:
69
+ // ['input_tokens','output_tokens'] num compat que não emite cache).
70
+ // Torna subcontagem visível — ausência não é confundida com 0 real.
71
+ usageFields: [],
72
+ // true quando houve um objeto usage que NÃO bateu com nenhum campo
73
+ // reconhecido do dialeto (ex: shape Cohere num target openai). O daemon
74
+ // grava um evento flagged (tokens 0, usage_unknown=1) em vez de um
75
+ // fantasma "completo" de 0 — fica visível que a contagem falhou.
76
+ usageUnknown: false,
77
+ }
78
+ }
79
+
80
+ function markField(u, name) {
81
+ if (!u.usageFields.includes(name)) u.usageFields.push(name)
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Anthropic Messages API (/v1/messages)
86
+ // Não-stream: body.usage {input_tokens, cache_creation_input_tokens,
87
+ // cache_read_input_tokens, output_tokens}; input_tokens JÁ exclui cache.
88
+ // Stream: message_start carrega message.model + usage inicial;
89
+ // message_delta carrega usage.output_tokens cumulativo (o último vale).
90
+ // ---------------------------------------------------------------------------
91
+ class AnthropicExtractor {
92
+ constructor() {
93
+ this.u = zeroUsage()
94
+ }
95
+
96
+ onSse(event, json) {
97
+ const type = json.type || event
98
+ if (type === 'message_start' && json.message) {
99
+ const usage = json.message.usage || {}
100
+ this.u.model = json.message.model ?? this.u.model
101
+ this.u.providerId = json.message.id ?? this.u.providerId
102
+ const has = []
103
+ if (typeof usage.input_tokens === 'number') has.push('input_tokens')
104
+ if (typeof usage.cache_creation_input_tokens === 'number') has.push('cache_creation_input_tokens')
105
+ if (typeof usage.cache_read_input_tokens === 'number') has.push('cache_read_input_tokens')
106
+ if (typeof usage.output_tokens === 'number') has.push('output_tokens')
107
+ if (!has.length) {
108
+ this.u.usageUnknown = true
109
+ return
110
+ }
111
+ for (const f of has) markField(this.u, f)
112
+ this.u.inputTokens = usage.input_tokens || 0
113
+ this.u.cacheCreateTokens = usage.cache_creation_input_tokens || 0
114
+ this.u.cacheCreate1hTokens = usage.cache_creation?.ephemeral_1h_input_tokens || 0
115
+ if (typeof usage.cache_creation?.ephemeral_1h_input_tokens === 'number') markField(this.u, 'cache_creation_1h')
116
+ this.u.cacheReadTokens = usage.cache_read_input_tokens || 0
117
+ this.u.outputTokens = usage.output_tokens || 0
118
+ this.u.sawUsage = true
119
+ } else if (type === 'content_block_delta' && json.delta?.type === 'text_delta') {
120
+ appendText(this.u, json.delta.text)
121
+ } else if (type === 'message_delta' && json.usage) {
122
+ const has = []
123
+ if (typeof json.usage.output_tokens === 'number') has.push('output_tokens')
124
+ if (typeof json.usage.input_tokens === 'number') has.push('input_tokens')
125
+ if (!has.length) return
126
+ for (const f of has) markField(this.u, f)
127
+ if (typeof json.usage.output_tokens === 'number') this.u.outputTokens = json.usage.output_tokens
128
+ if (typeof json.usage.input_tokens === 'number') this.u.inputTokens = json.usage.input_tokens
129
+ this.u.sawUsage = true
130
+ }
131
+ }
132
+
133
+ onJsonBody(body) {
134
+ const usage = body.usage || {}
135
+ this.u.model = body.model ?? this.u.model
136
+ this.u.providerId = body.id ?? this.u.providerId
137
+ for (const block of body.content ?? []) {
138
+ if (block?.type === 'text') appendText(this.u, block.text)
139
+ }
140
+ if (!('usage' in body)) return
141
+ const has = []
142
+ if (typeof usage.input_tokens === 'number') has.push('input_tokens')
143
+ if (typeof usage.cache_creation_input_tokens === 'number') has.push('cache_creation_input_tokens')
144
+ if (typeof usage.cache_read_input_tokens === 'number') has.push('cache_read_input_tokens')
145
+ if (typeof usage.output_tokens === 'number') has.push('output_tokens')
146
+ if (!has.length) {
147
+ this.u.usageUnknown = true
148
+ return
149
+ }
150
+ for (const f of has) markField(this.u, f)
151
+ this.u.inputTokens = usage.input_tokens || 0
152
+ this.u.cacheCreateTokens = usage.cache_creation_input_tokens || 0
153
+ this.u.cacheCreate1hTokens = usage.cache_creation?.ephemeral_1h_input_tokens || 0
154
+ if (typeof usage.cache_creation?.ephemeral_1h_input_tokens === 'number') markField(this.u, 'cache_creation_1h')
155
+ this.u.cacheReadTokens = usage.cache_read_input_tokens || 0
156
+ this.u.outputTokens = usage.output_tokens || 0
157
+ this.u.sawUsage = true
158
+ }
159
+
160
+ result() {
161
+ return this.u
162
+ }
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // OpenAI Chat Completions (/v1/chat/completions) e compatíveis (OpenRouter etc)
167
+ // prompt_tokens INCLUI os cacheados → input = prompt - cached.
168
+ // completion_tokens INCLUI reasoning → output = completion, reasoning = subset.
169
+ // Stream: usage vem num chunk final (choices vazio), só com
170
+ // stream_options.include_usage — o proxy pode injetar isso na request.
171
+ // ---------------------------------------------------------------------------
172
+ class OpenAiChatExtractor {
173
+ constructor() {
174
+ this.u = zeroUsage()
175
+ }
176
+
177
+ applyUsage(usage, model, id) {
178
+ if (model) this.u.model = model
179
+ if (id) this.u.providerId = id
180
+ if (!usage) return
181
+ const has = []
182
+ if (typeof usage.prompt_tokens === 'number') has.push('prompt_tokens')
183
+ if (typeof usage.completion_tokens === 'number') has.push('completion_tokens')
184
+ if (usage.prompt_tokens_details?.cached_tokens != null) has.push('cached_tokens')
185
+ if (usage.completion_tokens_details?.reasoning_tokens != null) has.push('reasoning_tokens')
186
+ if (!has.length) {
187
+ // usage existente mas shape não-OpenAI (ex: Cohere) — não fabrica 0
188
+ this.u.usageUnknown = true
189
+ return
190
+ }
191
+ for (const f of has) markField(this.u, f)
192
+ const cached = usage.prompt_tokens_details?.cached_tokens || 0
193
+ this.u.inputTokens = Math.max(0, (usage.prompt_tokens || 0) - cached)
194
+ this.u.cacheReadTokens = cached
195
+ this.u.outputTokens = usage.completion_tokens || 0
196
+ this.u.reasoningTokens = usage.completion_tokens_details?.reasoning_tokens || 0
197
+ this.u.sawUsage = true
198
+ }
199
+
200
+ onSse(_event, json) {
201
+ if (json.model && !this.u.model) this.u.model = json.model
202
+ if (json.id && !this.u.providerId) this.u.providerId = json.id
203
+ for (const ch of json.choices ?? []) appendText(this.u, ch?.delta?.content)
204
+ if (json.usage) this.applyUsage(json.usage, json.model, json.id)
205
+ }
206
+
207
+ onJsonBody(body) {
208
+ for (const ch of body.choices ?? []) appendText(this.u, ch?.message?.content)
209
+ this.applyUsage(body.usage, body.model, body.id)
210
+ }
211
+
212
+ result() {
213
+ return this.u
214
+ }
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // OpenAI Responses API (/v1/responses) — protocolo do Codex CLI.
219
+ // Stream: evento response.completed → response.usage
220
+ // {input_tokens, input_tokens_details.cached_tokens,
221
+ // output_tokens, output_tokens_details.reasoning_tokens}.
222
+ // input_tokens INCLUI cache; output_tokens INCLUI reasoning.
223
+ // ---------------------------------------------------------------------------
224
+ class OpenAiResponsesExtractor {
225
+ constructor() {
226
+ this.u = zeroUsage()
227
+ }
228
+
229
+ applyResponse(response) {
230
+ if (!response) return
231
+ if (response.model) this.u.model = response.model
232
+ if (response.id) this.u.providerId = response.id
233
+ const usage = response.usage
234
+ if (!usage) return
235
+ const has = []
236
+ if (typeof usage.input_tokens === 'number') has.push('input_tokens')
237
+ if (typeof usage.output_tokens === 'number') has.push('output_tokens')
238
+ if (usage.input_tokens_details?.cached_tokens != null) has.push('cached_tokens')
239
+ if (usage.output_tokens_details?.reasoning_tokens != null) has.push('reasoning_tokens')
240
+ if (!has.length) {
241
+ this.u.usageUnknown = true
242
+ return
243
+ }
244
+ for (const f of has) markField(this.u, f)
245
+ const cached = usage.input_tokens_details?.cached_tokens || 0
246
+ this.u.inputTokens = Math.max(0, (usage.input_tokens || 0) - cached)
247
+ this.u.cacheReadTokens = cached
248
+ this.u.outputTokens = usage.output_tokens || 0
249
+ this.u.reasoningTokens = usage.output_tokens_details?.reasoning_tokens || 0
250
+ this.u.sawUsage = true
251
+ }
252
+
253
+ collectOutputText(response) {
254
+ if (this.u.text) return // já veio via deltas
255
+ for (const item of response?.output ?? []) {
256
+ for (const c of item?.content ?? []) {
257
+ if (c?.type === 'output_text') appendText(this.u, c.text)
258
+ }
259
+ }
260
+ }
261
+
262
+ onSse(event, json) {
263
+ const type = json.type || event
264
+ if (type === 'response.output_text.delta') {
265
+ appendText(this.u, json.delta)
266
+ } else if (type === 'response.completed' || type === 'response.incomplete' || type === 'response.failed') {
267
+ this.applyResponse(json.response)
268
+ this.collectOutputText(json.response)
269
+ } else if (json.response?.model && !this.u.model) {
270
+ this.u.model = json.response.model
271
+ }
272
+ }
273
+
274
+ onJsonBody(body) {
275
+ const response = body.object === 'response' ? body : body.response ?? body
276
+ this.applyResponse(response)
277
+ this.collectOutputText(response)
278
+ }
279
+
280
+ result() {
281
+ return this.u
282
+ }
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Gemini generateContent / streamGenerateContent
287
+ // usageMetadata {promptTokenCount, candidatesTokenCount,
288
+ // cachedContentTokenCount, thoughtsTokenCount}.
289
+ // promptTokenCount INCLUI cache → input = prompt - cached.
290
+ // candidatesTokenCount EXCLUI thoughts → output faturável = candidates + thoughts.
291
+ // Model vem da URL (/v1beta/models/{model}:...) e/ou modelVersion no body.
292
+ // ---------------------------------------------------------------------------
293
+ class GeminiExtractor {
294
+ constructor(urlPath) {
295
+ this.u = zeroUsage()
296
+ const m = /\/models\/([^:/]+):/.exec(urlPath || '')
297
+ if (m) this.u.model = m[1]
298
+ this.modelFromUrl = Boolean(m)
299
+ }
300
+
301
+ applyUsage(json) {
302
+ // O body não ecoa o model pedido; modelVersion é a versão resolvida
303
+ // (pode ter sufixo que não bate com a tabela de preços) — a URL vence.
304
+ if (json.modelVersion && !this.modelFromUrl) this.u.model = json.modelVersion
305
+ if (json.responseId) this.u.providerId = json.responseId
306
+ for (const cand of json.candidates ?? []) {
307
+ for (const part of cand?.content?.parts ?? []) appendText(this.u, part?.text)
308
+ }
309
+ const usage = json.usageMetadata
310
+ if (!usage) return
311
+ const has = []
312
+ if (typeof usage.promptTokenCount === 'number') has.push('promptTokenCount')
313
+ if (typeof usage.candidatesTokenCount === 'number') has.push('candidatesTokenCount')
314
+ if (usage.cachedContentTokenCount != null) has.push('cachedContentTokenCount')
315
+ if (usage.thoughtsTokenCount != null) has.push('thoughtsTokenCount')
316
+ if (!has.length) {
317
+ this.u.usageUnknown = true
318
+ return
319
+ }
320
+ for (const f of has) markField(this.u, f)
321
+ const cached = usage.cachedContentTokenCount || 0
322
+ const thoughts = usage.thoughtsTokenCount || 0
323
+ this.u.inputTokens = Math.max(0, (usage.promptTokenCount || 0) - cached)
324
+ this.u.cacheReadTokens = cached
325
+ this.u.outputTokens = (usage.candidatesTokenCount || 0) + thoughts
326
+ this.u.reasoningTokens = thoughts
327
+ this.u.sawUsage = true
328
+ }
329
+
330
+ onSse(_event, json) {
331
+ // usageMetadata é CUMULATIVO entre chunks; o último visto tem o valor
332
+ // final — sobrescrever a cada chunk (nunca somar).
333
+ this.applyUsage(json)
334
+ }
335
+
336
+ onJsonBody(body) {
337
+ // streamGenerateContent SEM ?alt=sse devolve um ARRAY JSON de chunks
338
+ if (Array.isArray(body)) {
339
+ for (const chunk of body) this.applyUsage(chunk)
340
+ return
341
+ }
342
+ this.applyUsage(body)
343
+ }
344
+
345
+ result() {
346
+ return this.u
347
+ }
348
+ }
349
+
350
+ // Protocolo de wire desconhecido: NÃO extrai nada. Evita o bug do "default
351
+ // silencioso p/ OpenAiChatExtractor" que produzia 0 tokens sem erro visível
352
+ // quando o usuário declarava um target custom com protocolo errado/typo.
353
+ // Em runtime, readConfig já marca o target como _invalid e o proxyRequest
354
+ // devolve 400 antes mesmo de chegar aqui; este extrator é a defesa em
355
+ // profundidade caso algo escape.
356
+ class NullExtractor {
357
+ constructor() {
358
+ this.u = zeroUsage()
359
+ }
360
+ onSse() {}
361
+ onJsonBody() {}
362
+ result() {
363
+ return this.u
364
+ }
365
+ }
366
+
367
+ export function createExtractor(protocol, { urlPath } = {}) {
368
+ switch (protocol) {
369
+ case 'anthropic':
370
+ return new AnthropicExtractor()
371
+ case 'openai': {
372
+ // /v1/responses e /v1/chat/completions compartilham o mesmo target
373
+ if (/\/responses(\?|$)/.test(urlPath || '')) return new OpenAiResponsesExtractor()
374
+ return new OpenAiChatExtractor()
375
+ }
376
+ case 'openai-responses':
377
+ return new OpenAiResponsesExtractor()
378
+ case 'gemini':
379
+ return new GeminiExtractor(urlPath)
380
+ default:
381
+ return new NullExtractor()
382
+ }
383
+ }
384
+
385
+ // Injeta stream_options.include_usage numa request de chat completions em
386
+ // streaming que não pediu usage — sem isso o chunk final de usage não vem.
387
+ //
388
+ // IMPORTANTE: quando NÓS injetamos, o chunk terminal de usage (choices: []
389
+ // + usage preenchido) precisa ser REMOVIDO da resposta repassada ao cliente
390
+ // — wrappers comuns (LangChain, Haystack, mlflow...) quebram com choices[0]
391
+ // num chunk que não pediram. O daemon usa isUsageOnlyChunk() para isso.
392
+ export function maybeInjectIncludeUsage(protocol, urlPath, bodyBuffer) {
393
+ if (protocol !== 'openai' || !/\/chat\/completions(\?|$)/.test(urlPath || '')) return null
394
+ let body
395
+ try {
396
+ body = JSON.parse(bodyBuffer.toString('utf8'))
397
+ } catch {
398
+ return null
399
+ }
400
+ if (!body || body.stream !== true) return null
401
+ if (body.stream_options?.include_usage) return null
402
+ body.stream_options = { ...(body.stream_options || {}), include_usage: true }
403
+ return Buffer.from(JSON.stringify(body), 'utf8')
404
+ }
405
+
406
+ // Reconhece o chunk terminal exclusivo de usage do chat completions streaming
407
+ // (o que include_usage acrescenta): choices vazio + usage preenchido.
408
+ export function isUsageOnlyChunk(rawSseEvent) {
409
+ const dataLines = []
410
+ for (const line of rawSseEvent.split(/\r?\n/)) {
411
+ if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart())
412
+ }
413
+ if (!dataLines.length) return false
414
+ const data = dataLines.join('\n')
415
+ if (data === '[DONE]') return false
416
+ try {
417
+ const json = JSON.parse(data)
418
+ return (
419
+ json &&
420
+ json.object === 'chat.completion.chunk' &&
421
+ Array.isArray(json.choices) &&
422
+ json.choices.length === 0 &&
423
+ json.usage
424
+ )
425
+ } catch {
426
+ return false
427
+ }
428
+ }
package/lib/audit.mjs ADDED
@@ -0,0 +1,238 @@
1
+ // Auditoria agnóstica ao harness, em duas frentes:
2
+ //
3
+ // 1. MATEMÁTICA INTERNA (universal) — para cada evento, re-multiplica
4
+ // tokens × taxas gravadas no momento da chamada (coluna rates) e compara
5
+ // com cost_usd. Vale para QUALQUER TUI/provedor.
6
+ //
7
+ // 2. FONTES INDEPENDENTES (plugáveis) — cada harness que mantém a própria
8
+ // contabilidade local vira um adaptador de fonte. Estratégias de casamento:
9
+ // - por provider_id (id da resposta no provedor), quando a fonte o grava
10
+ // (Claude Code grava msg_... nos transcripts);
11
+ // - por impressão digital (tupla exata de tokens + mesma janela de tempo),
12
+ // quando a fonte não guarda o id (OpenCode).
13
+ // Novos harnesses = novo adaptador aqui; nada no resto do sistema muda.
14
+
15
+ import fs from 'node:fs'
16
+ import os from 'node:os'
17
+ import path from 'node:path'
18
+ import readline from 'node:readline'
19
+ import { listUsageEvents } from './db.mjs'
20
+
21
+ const FINGERPRINT_WINDOW_MS = 15 * 60 * 1000
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Fonte: Claude Code — transcripts JSONL com usage + message.id (provider_id)
25
+ // ---------------------------------------------------------------------------
26
+ function* walkJsonl(dir) {
27
+ let ents
28
+ try {
29
+ ents = fs.readdirSync(dir, { withFileTypes: true })
30
+ } catch {
31
+ return
32
+ }
33
+ for (const e of ents) {
34
+ const p = path.join(dir, e.name)
35
+ if (e.isDirectory()) yield* walkJsonl(p)
36
+ else if (e.isFile() && e.name.endsWith('.jsonl')) yield p
37
+ }
38
+ }
39
+
40
+ async function indexClaudeCode({ sinceIso, rootDir }) {
41
+ const root = rootDir || path.join(os.homedir(), '.claude', 'projects')
42
+ const entries = []
43
+ for (const file of walkJsonl(root)) {
44
+ let stream
45
+ try {
46
+ stream = fs.createReadStream(file, { encoding: 'utf8' })
47
+ } catch {
48
+ continue
49
+ }
50
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity })
51
+ const byId = new Map()
52
+ for await (const line of rl) {
53
+ if (!line || !line.includes('"usage"')) continue
54
+ let e
55
+ try {
56
+ e = JSON.parse(line)
57
+ } catch {
58
+ continue
59
+ }
60
+ if (e.type !== 'assistant') continue
61
+ const msg = e.message || {}
62
+ const u = msg.usage
63
+ if (!u || !msg.id || msg.model === '<synthetic>') continue
64
+ if (sinceIso && e.timestamp && e.timestamp < sinceIso) continue
65
+ const prev = byId.get(msg.id)
66
+ if (prev && (prev.output_tokens || 0) >= (u.output_tokens || 0)) continue
67
+ byId.set(msg.id, {
68
+ providerId: msg.id,
69
+ model: msg.model,
70
+ ts: e.timestamp,
71
+ input_tokens: u.input_tokens || 0,
72
+ cache_create_tokens: u.cache_creation_input_tokens || 0,
73
+ cache_read_tokens: u.cache_read_input_tokens || 0,
74
+ output_tokens: u.output_tokens || 0,
75
+ })
76
+ }
77
+ entries.push(...byId.values())
78
+ }
79
+ return entries
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Fonte: OpenCode — opencode.db (SQLite), message.data JSON com tokens
84
+ // (sem provider_id -> casa por impressão digital)
85
+ // ---------------------------------------------------------------------------
86
+ async function indexOpenCode({ sinceIso, rootDir }) {
87
+ const dbPath = rootDir
88
+ ? path.join(rootDir, 'opencode.db')
89
+ : path.join(os.homedir(), '.local', 'share', 'opencode', 'opencode.db')
90
+ if (!fs.existsSync(dbPath)) return null // fonte indisponível nesta máquina
91
+ let db
92
+ try {
93
+ const { DatabaseSync } = await import('node:sqlite')
94
+ db = new DatabaseSync(dbPath, { readOnly: true })
95
+ } catch {
96
+ return null
97
+ }
98
+ const sinceMs = sinceIso ? Date.parse(sinceIso) : 0
99
+ const entries = []
100
+ try {
101
+ const rows = db
102
+ .prepare(
103
+ `SELECT data, time_created FROM message
104
+ WHERE data LIKE '%"role":"assistant"%' AND data LIKE '%"tokens"%' AND time_created >= ?`,
105
+ )
106
+ .all(sinceMs)
107
+ for (const row of rows) {
108
+ let d
109
+ try {
110
+ d = JSON.parse(row.data)
111
+ } catch {
112
+ continue
113
+ }
114
+ const t = d.tokens
115
+ if (!t || d.role !== 'assistant') continue
116
+ entries.push({
117
+ providerId: null,
118
+ model: d.modelID ?? null,
119
+ ts: new Date(d.time?.completed ?? d.time?.created ?? row.time_created).toISOString(),
120
+ input_tokens: t.input || 0,
121
+ cache_create_tokens: t.cache?.write || 0,
122
+ cache_read_tokens: t.cache?.read || 0,
123
+ output_tokens: t.output || 0,
124
+ })
125
+ }
126
+ } finally {
127
+ db.close()
128
+ }
129
+ return entries
130
+ }
131
+
132
+ export const AUDIT_SOURCES = [
133
+ { key: 'claude-code', title: 'Claude Code (transcripts ~/.claude/projects)', index: indexClaudeCode, match: 'provider_id' },
134
+ { key: 'opencode', title: 'OpenCode (opencode.db local)', index: indexOpenCode, match: 'fingerprint' },
135
+ ]
136
+
137
+ const TOKEN_FIELDS = ['input_tokens', 'cache_create_tokens', 'cache_read_tokens', 'output_tokens']
138
+
139
+ // Casamento 1:1 — uma entrada da fonte cobre no máximo UM evento do proxy
140
+ // (consumida ao casar), senão um registro poderia "validar" dois eventos.
141
+ function takeFingerprintMatch(ev, pool) {
142
+ const evMs = Date.parse(ev.ts)
143
+ const idx = pool.findIndex(
144
+ t =>
145
+ Math.abs(Date.parse(t.ts) - evMs) <= FINGERPRINT_WINDOW_MS &&
146
+ TOKEN_FIELDS.every(f => (ev[f] || 0) === (t[f] || 0)),
147
+ )
148
+ if (idx === -1) return undefined
149
+ return pool.splice(idx, 1)[0]
150
+ }
151
+
152
+ function recomputeFromRates(ev, rates) {
153
+ const create1h = Math.min(ev.cache_create_1h_tokens || 0, ev.cache_create_tokens || 0)
154
+ const create5m = (ev.cache_create_tokens || 0) - create1h
155
+ return (
156
+ ((ev.input_tokens || 0) / 1e6) * rates.input +
157
+ ((ev.cache_read_tokens || 0) / 1e6) * rates.cacheRead +
158
+ (create5m / 1e6) * rates.cacheWrite5m +
159
+ (create1h / 1e6) * (rates.cacheWrite1h ?? rates.cacheWrite5m) +
160
+ ((ev.output_tokens || 0) / 1e6) * rates.output
161
+ )
162
+ }
163
+
164
+ export async function runAudit({ sinceIso, sourceRoots = {} } = {}) {
165
+ const events = await listUsageEvents({ since: sinceIso })
166
+
167
+ // Frente 1: matemática interna (todo evento, qualquer harness)
168
+ const math = { checked: 0, ok: 0, drift: [], noRates: 0 }
169
+ for (const ev of events) {
170
+ if (!ev.rates) {
171
+ math.noRates++
172
+ continue
173
+ }
174
+ let rates
175
+ try {
176
+ rates = JSON.parse(ev.rates)
177
+ } catch {
178
+ math.noRates++
179
+ continue
180
+ }
181
+ math.checked++
182
+ const expected = recomputeFromRates(ev, rates)
183
+ if (Math.abs(expected - (ev.cost_usd ?? 0)) < 1e-9) math.ok++
184
+ else math.drift.push({ id: ev.id, ts: ev.ts, label: ev.label, stored: ev.cost_usd, recomputed: expected })
185
+ }
186
+
187
+ // Frente 2: cada fonte independente disponível tenta casar cada evento
188
+ const unmatched = new Map(events.map(ev => [ev.id, ev]))
189
+ const sources = []
190
+ for (const src of AUDIT_SOURCES) {
191
+ const entries = await src.index({ sinceIso, rootDir: sourceRoots[src.key] })
192
+ if (entries === null) {
193
+ sources.push({ key: src.key, title: src.title, available: false })
194
+ continue
195
+ }
196
+ const byProviderId = new Map(entries.filter(t => t.providerId).map(t => [t.providerId, t]))
197
+ const pool = [...entries]
198
+ const report = { key: src.key, title: src.title, available: true, strategy: src.match, matched: 0, identical: 0, mismatches: [] }
199
+
200
+ for (const ev of [...unmatched.values()]) {
201
+ let t
202
+ if (src.match === 'provider_id') {
203
+ t = ev.provider_id ? byProviderId.get(ev.provider_id) : undefined
204
+ if (t) byProviderId.delete(ev.provider_id) // 1:1 — consumida ao casar
205
+ } else {
206
+ t = takeFingerprintMatch(ev, pool)
207
+ }
208
+ if (!t) continue
209
+ unmatched.delete(ev.id)
210
+ report.matched++
211
+ const diffs = TOKEN_FIELDS.filter(f => (ev[f] || 0) !== (t[f] || 0))
212
+ if (!diffs.length) report.identical++
213
+ else {
214
+ report.mismatches.push({
215
+ event_id: ev.id,
216
+ provider_id: ev.provider_id,
217
+ label: ev.label,
218
+ fields: Object.fromEntries(diffs.map(f => [f, { proxy: ev[f] || 0, fonte: t[f] || 0 }])),
219
+ })
220
+ }
221
+ }
222
+ sources.push(report)
223
+ }
224
+
225
+ return {
226
+ since: sinceIso ?? 'tudo',
227
+ eventCount: events.length,
228
+ math,
229
+ sources,
230
+ uncovered: [...unmatched.values()].map(ev => ({
231
+ id: ev.id,
232
+ ts: ev.ts,
233
+ label: ev.label,
234
+ client: ev.client,
235
+ target: ev.target,
236
+ })),
237
+ }
238
+ }