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.
- package/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +18 -0
- package/.mcp.json +8 -0
- package/CHANGELOG.md +29 -0
- package/LICENSE +21 -0
- package/README.md +380 -0
- package/bin/ttm.mjs +343 -0
- package/commands/config.md +13 -0
- package/commands/dashboard.md +7 -0
- package/commands/link.md +12 -0
- package/commands/setup.md +19 -0
- package/commands/status.md +13 -0
- package/lib/adapters.mjs +428 -0
- package/lib/audit.mjs +238 -0
- package/lib/config.mjs +135 -0
- package/lib/daemon-client.mjs +139 -0
- package/lib/daemon.mjs +614 -0
- package/lib/db.mjs +444 -0
- package/lib/link.mjs +152 -0
- package/lib/mcp-server.mjs +275 -0
- package/lib/otel-export.mjs +122 -0
- package/lib/pricing.mjs +137 -0
- package/lib/report-data.mjs +171 -0
- package/lib/setup-snippets.mjs +201 -0
- package/lib/traces-data.mjs +155 -0
- package/lib/tui-config.mjs +429 -0
- package/package.json +56 -0
- package/templates/config.html +208 -0
- package/templates/dashboard.html +629 -0
- package/templates/traces.html +407 -0
package/lib/adapters.mjs
ADDED
|
@@ -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
|
+
}
|