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/daemon.mjs
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Daemon do token-trace-manager: proxy transparente + API local + dashboard.
|
|
3
|
+
//
|
|
4
|
+
// Regras de ouro:
|
|
5
|
+
// - Escuta APENAS em 127.0.0.1 (nunca exposto na rede).
|
|
6
|
+
// - Repassa headers de autenticação intactos; nunca guarda nem loga chaves.
|
|
7
|
+
// - Conteúdo de prompts/respostas só é gravado (em payloads/<id>.json) se
|
|
8
|
+
// storePayloads estiver ligado (default: ligado) — chaves de API nunca,
|
|
9
|
+
// em nenhuma hipótese, vão parar lá.
|
|
10
|
+
//
|
|
11
|
+
// Roteamento: http://127.0.0.1:PORT/{target}/{label?}/{caminho-upstream}
|
|
12
|
+
// target : chave em config.targets (anthropic, openai, gemini, ...)
|
|
13
|
+
// label : segmento opcional estilo PROJ-123 para atribuir o custo
|
|
14
|
+
// resto : caminho repassado ao targetUrl do provedor
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs'
|
|
17
|
+
import http from 'node:http'
|
|
18
|
+
import https from 'node:https'
|
|
19
|
+
import path from 'node:path'
|
|
20
|
+
import { fileURLToPath } from 'node:url'
|
|
21
|
+
import { StringDecoder } from 'node:string_decoder'
|
|
22
|
+
import { readConfig, pidFilePath, writeConfig, KNOWN_PROTOCOLS, validateConfig } from './config.mjs'
|
|
23
|
+
import { insertUsageEvent, getKv, setKv, purgeExpired, deleteData, relabelUnlabeled, payloadPath } from './db.mjs'
|
|
24
|
+
import { buildTraces } from './traces-data.mjs'
|
|
25
|
+
import { createExtractor, SseParser, maybeInjectIncludeUsage, isUsageOnlyChunk } from './adapters.mjs'
|
|
26
|
+
import { exportUsageEvent } from './otel-export.mjs'
|
|
27
|
+
import { costBreakdown } from './pricing.mjs'
|
|
28
|
+
import { buildReport } from './report-data.mjs'
|
|
29
|
+
|
|
30
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
31
|
+
const VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version
|
|
32
|
+
|
|
33
|
+
// Label = qualquer identificador de tarefa: ticket real (PROJ-123) OU slug
|
|
34
|
+
// descritivo (TCW-poc-proxy-contabilizacao-custo-ia) — não exige card já criado.
|
|
35
|
+
// Exige AO MENOS UM hífen: é a assinatura que distingue um label de um
|
|
36
|
+
// segmento normal de path do provedor (v1, v1beta, messages, models, ...),
|
|
37
|
+
// nenhum dos quais tem hífen — sem isso o roteamento interpretaria "v1" como
|
|
38
|
+
// label e quebraria toda chamada sem label explícito na URL.
|
|
39
|
+
const LABEL_RE = /^[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)+$/
|
|
40
|
+
const LABEL_MAX_LEN = 120
|
|
41
|
+
const HOP_BY_HOP = new Set([
|
|
42
|
+
'connection',
|
|
43
|
+
'keep-alive',
|
|
44
|
+
'proxy-authenticate',
|
|
45
|
+
'proxy-authorization',
|
|
46
|
+
'te',
|
|
47
|
+
'trailer',
|
|
48
|
+
'transfer-encoding',
|
|
49
|
+
'upgrade',
|
|
50
|
+
'host',
|
|
51
|
+
])
|
|
52
|
+
const MAX_JSON_CAPTURE = 32 * 1024 * 1024
|
|
53
|
+
|
|
54
|
+
const startedAt = Date.now()
|
|
55
|
+
|
|
56
|
+
function sendJson(res, status, obj) {
|
|
57
|
+
const body = JSON.stringify(obj)
|
|
58
|
+
res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) })
|
|
59
|
+
res.end(body)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function collectBody(req) {
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const chunks = []
|
|
65
|
+
req.on('data', c => chunks.push(c))
|
|
66
|
+
req.on('end', () => resolve(Buffer.concat(chunks)))
|
|
67
|
+
req.on('error', reject)
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function handleLocal(req, res, pathname, searchParams) {
|
|
72
|
+
if (pathname === '/healthz') {
|
|
73
|
+
return sendJson(res, 200, {
|
|
74
|
+
ok: true,
|
|
75
|
+
version: VERSION,
|
|
76
|
+
uptime_s: Math.round((Date.now() - startedAt) / 1000),
|
|
77
|
+
pid: process.pid,
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (pathname === '/api/config') {
|
|
82
|
+
const cfg = readConfig()
|
|
83
|
+
return sendJson(res, 200, {
|
|
84
|
+
proxyUrl: cfg.proxyUrl,
|
|
85
|
+
port: cfg.port,
|
|
86
|
+
injectUsage: cfg.injectUsage,
|
|
87
|
+
targets: cfg.targets,
|
|
88
|
+
defaultLabel: await getKv('default_label'),
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (pathname === '/api/label') {
|
|
93
|
+
if (req.method === 'POST') {
|
|
94
|
+
const body = await collectBody(req)
|
|
95
|
+
let payload
|
|
96
|
+
try {
|
|
97
|
+
payload = JSON.parse(body.toString('utf8'))
|
|
98
|
+
} catch {
|
|
99
|
+
return sendJson(res, 400, {
|
|
100
|
+
error: 'JSON inválido; esperado {"label": "PROJ-123"|null, "context"?: {repo, branch}, "retroactiveMinutes"?: n}',
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
const label = payload.label ?? null
|
|
104
|
+
if (label !== null && (label.length > LABEL_MAX_LEN || !LABEL_RE.test(label))) {
|
|
105
|
+
return sendJson(res, 400, { error: `label "${label}" inválido (ex: PROJ-123 ou um-slug-descritivo, até ${LABEL_MAX_LEN} caracteres)` })
|
|
106
|
+
}
|
|
107
|
+
const normalized = label ? label.toUpperCase() : null
|
|
108
|
+
await setKv('default_label', normalized)
|
|
109
|
+
|
|
110
|
+
// Contexto do ambiente do agente (repo/branch, detectados pelo MCP) —
|
|
111
|
+
// carimbado em todo evento novo até ser trocado/limpo.
|
|
112
|
+
if ('context' in payload) {
|
|
113
|
+
const ctx = payload.context
|
|
114
|
+
await setKv(
|
|
115
|
+
'default_context',
|
|
116
|
+
ctx && (ctx.repo || ctx.branch)
|
|
117
|
+
? JSON.stringify({ repo: ctx.repo ?? null, branch: ctx.branch ?? null })
|
|
118
|
+
: null,
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Retroativo: mensagens sem label da janela recebem a tarefa nova
|
|
123
|
+
// (caso "trabalhei antes de criar o card").
|
|
124
|
+
let relabeled = 0
|
|
125
|
+
if (normalized && payload.retroactiveMinutes > 0) {
|
|
126
|
+
const sinceIso = new Date(Date.now() - Math.min(payload.retroactiveMinutes, 24 * 60) * 60000).toISOString()
|
|
127
|
+
relabeled = (await relabelUnlabeled({ label: normalized, sinceIso })).relabeled
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const ctxRaw = await getKv('default_context')
|
|
131
|
+
return sendJson(res, 200, {
|
|
132
|
+
defaultLabel: normalized,
|
|
133
|
+
context: ctxRaw ? JSON.parse(ctxRaw) : null,
|
|
134
|
+
relabeled,
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
const ctxRaw = await getKv('default_context')
|
|
138
|
+
return sendJson(res, 200, {
|
|
139
|
+
defaultLabel: await getKv('default_label'),
|
|
140
|
+
context: ctxRaw ? JSON.parse(ctxRaw) : null,
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// labels são SEMPRE armazenados em maiúsculas — filtros idem, senão
|
|
145
|
+
// ?label=proj-123 devolve silenciosamente um relatório vazio
|
|
146
|
+
const labelParam = p => {
|
|
147
|
+
const v = p.get('label')
|
|
148
|
+
return v ? v.toUpperCase() : v
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (pathname === '/api/summary') {
|
|
152
|
+
const report = await buildReport({
|
|
153
|
+
since: searchParams.get('since'),
|
|
154
|
+
label: labelParam(searchParams),
|
|
155
|
+
})
|
|
156
|
+
return sendJson(res, 200, report)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (pathname === '/api/traces') {
|
|
160
|
+
const traces = await buildTraces({
|
|
161
|
+
since: searchParams.get('since') || '24h',
|
|
162
|
+
label: labelParam(searchParams),
|
|
163
|
+
target: searchParams.get('target'),
|
|
164
|
+
model: searchParams.get('model'),
|
|
165
|
+
client: searchParams.get('client'),
|
|
166
|
+
})
|
|
167
|
+
return sendJson(res, 200, traces)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (pathname === '/api/payload') {
|
|
171
|
+
const id = parseInt(searchParams.get('id') || '', 10)
|
|
172
|
+
if (!Number.isInteger(id) || id <= 0) return sendJson(res, 400, { error: 'id inválido' })
|
|
173
|
+
try {
|
|
174
|
+
const raw = fs.readFileSync(payloadPath(id), 'utf8')
|
|
175
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
176
|
+
return res.end(raw)
|
|
177
|
+
} catch {
|
|
178
|
+
return sendJson(res, 404, { error: 'sem payload para este evento (anterior ao armazenamento, desligado na config, ou expirado)' })
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (pathname === '/api/settings' && req.method === 'POST') {
|
|
183
|
+
const body = await collectBody(req)
|
|
184
|
+
let payload
|
|
185
|
+
try {
|
|
186
|
+
payload = JSON.parse(body.toString('utf8'))
|
|
187
|
+
} catch {
|
|
188
|
+
return sendJson(res, 400, { error: 'JSON inválido; esperado {"storePayloads": true|false}' })
|
|
189
|
+
}
|
|
190
|
+
if (typeof payload.storePayloads !== 'boolean') {
|
|
191
|
+
return sendJson(res, 400, { error: 'storePayloads deve ser booleano' })
|
|
192
|
+
}
|
|
193
|
+
writeConfig({ storePayloads: payload.storePayloads })
|
|
194
|
+
return sendJson(res, 200, { storePayloads: payload.storePayloads })
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (pathname === '/api/retention' && req.method === 'POST') {
|
|
198
|
+
const body = await collectBody(req)
|
|
199
|
+
let hours
|
|
200
|
+
try {
|
|
201
|
+
hours = JSON.parse(body.toString('utf8')).hours
|
|
202
|
+
} catch {
|
|
203
|
+
return sendJson(res, 400, { error: 'JSON inválido; esperado {"hours": 24} (0 = nunca apagar)' })
|
|
204
|
+
}
|
|
205
|
+
if (typeof hours !== 'number' || hours < 0 || hours > 24 * 365) {
|
|
206
|
+
return sendJson(res, 400, { error: 'hours deve ser um número entre 0 (nunca apagar) e 8760' })
|
|
207
|
+
}
|
|
208
|
+
writeConfig({ retentionHours: hours })
|
|
209
|
+
return sendJson(res, 200, { retentionHours: hours })
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (pathname === '/api/purge' && req.method === 'POST') {
|
|
213
|
+
const body = await collectBody(req)
|
|
214
|
+
let payload
|
|
215
|
+
try {
|
|
216
|
+
payload = JSON.parse(body.toString('utf8'))
|
|
217
|
+
} catch {
|
|
218
|
+
return sendJson(res, 400, { error: 'JSON inválido; esperado {"mode": "expired"|"events"|"everything", "label"?} ' })
|
|
219
|
+
}
|
|
220
|
+
if (payload.mode === 'expired') {
|
|
221
|
+
const cfg2 = readConfig()
|
|
222
|
+
if (!cfg2.retentionHours) return sendJson(res, 200, { purged: 0, note: 'retenção desligada (0)' })
|
|
223
|
+
const cutoff = new Date(Date.now() - cfg2.retentionHours * 3600000).toISOString()
|
|
224
|
+
return sendJson(res, 200, await purgeExpired(cutoff))
|
|
225
|
+
}
|
|
226
|
+
if (payload.mode === 'events' || payload.mode === 'everything') {
|
|
227
|
+
let label = payload.label ?? null
|
|
228
|
+
if (label !== null) {
|
|
229
|
+
if (label.length > LABEL_MAX_LEN || !LABEL_RE.test(label)) {
|
|
230
|
+
return sendJson(res, 400, { error: `label "${label}" inválido para deleção (ex: PROJ-123 ou um-slug-descritivo)` })
|
|
231
|
+
}
|
|
232
|
+
label = label.toUpperCase()
|
|
233
|
+
}
|
|
234
|
+
return sendJson(res, 200, await deleteData({ mode: payload.mode, label }))
|
|
235
|
+
}
|
|
236
|
+
return sendJson(res, 400, { error: 'mode deve ser expired | events | everything' })
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (pathname === '/api/events') {
|
|
240
|
+
const { listUsageEvents } = await import('./db.mjs')
|
|
241
|
+
const rows = await listUsageEvents({
|
|
242
|
+
since: searchParams.get('since'),
|
|
243
|
+
label: labelParam(searchParams),
|
|
244
|
+
limit: parseInt(searchParams.get('limit') || '100', 10),
|
|
245
|
+
})
|
|
246
|
+
return sendJson(res, 200, { events: rows })
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// replacement como FUNÇÃO: um "$'" vindo de User-Agent/model no JSON seria
|
|
250
|
+
// interpretado como sequência especial de String.replace e corromperia o HTML
|
|
251
|
+
const servePage = (templateName, data) => {
|
|
252
|
+
const template = fs.readFileSync(path.join(__dirname, '..', 'templates', templateName), 'utf8')
|
|
253
|
+
const json = JSON.stringify(data).replace(/</g, '\\u003c')
|
|
254
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
|
255
|
+
res.end(template.replace('"__REPORT_DATA__"', () => json))
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (pathname === '/dashboard' || pathname === '/dashboard/') {
|
|
259
|
+
const report = await buildReport({ since: searchParams.get('since'), label: searchParams.get('label') })
|
|
260
|
+
return servePage('dashboard.html', report)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (pathname === '/traces' || pathname === '/traces/') {
|
|
264
|
+
const traces = await buildTraces({
|
|
265
|
+
since: searchParams.get('since') || '24h',
|
|
266
|
+
label: labelParam(searchParams),
|
|
267
|
+
target: searchParams.get('target'),
|
|
268
|
+
model: searchParams.get('model'),
|
|
269
|
+
client: searchParams.get('client'),
|
|
270
|
+
})
|
|
271
|
+
return servePage('traces.html', traces)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (pathname === '/config' || pathname === '/config/') {
|
|
275
|
+
const cfg2 = readConfig()
|
|
276
|
+
return servePage('config.html', {
|
|
277
|
+
port: cfg2.port,
|
|
278
|
+
retentionHours: cfg2.retentionHours,
|
|
279
|
+
storePayloads: cfg2.storePayloads,
|
|
280
|
+
injectUsage: cfg2.injectUsage,
|
|
281
|
+
otelOn: Boolean(cfg2.otel?.endpoint),
|
|
282
|
+
targets: cfg2.targets,
|
|
283
|
+
defaultLabel: await getKv('default_label'),
|
|
284
|
+
version: VERSION,
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (pathname === '/') {
|
|
289
|
+
res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' })
|
|
290
|
+
return res.end(
|
|
291
|
+
`token-trace-manager v${VERSION}\n\n` +
|
|
292
|
+
`Dashboard : /dashboard\n` +
|
|
293
|
+
`Health : /healthz\n` +
|
|
294
|
+
`API : /api/summary /api/events /api/label /api/config\n\n` +
|
|
295
|
+
`Uso como proxy: aponte a base URL do seu TUI para\n` +
|
|
296
|
+
` http://127.0.0.1:${readConfig().port}/{target}[/{TICKET-123}]\n` +
|
|
297
|
+
`targets: ${Object.keys(readConfig().targets).join(', ')}\n`,
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
sendJson(res, 404, { error: 'rota não encontrada' })
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function proxyRequest(req, res, cfg, targetName, label, upstreamPath) {
|
|
305
|
+
const target = cfg.targets[targetName]
|
|
306
|
+
|
|
307
|
+
// Protocolo inválido (typo em target custom): falha cedo, sem fabricar
|
|
308
|
+
// evento de 0 tokens. readConfig marca _invalid; aqui é a defesa final.
|
|
309
|
+
if (target._invalid) {
|
|
310
|
+
return sendJson(res, 400, {
|
|
311
|
+
error: `target "${targetName}" tem protocolo inválido "${target.protocol}" (válidos: ${KNOWN_PROTOCOLS.join(', ')})`,
|
|
312
|
+
})
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const started = Date.now()
|
|
316
|
+
const requestBody = await collectBody(req)
|
|
317
|
+
|
|
318
|
+
let outboundBody = requestBody
|
|
319
|
+
let weInjectedUsage = false
|
|
320
|
+
if (cfg.injectUsage) {
|
|
321
|
+
const injected = maybeInjectIncludeUsage(target.protocol, upstreamPath, requestBody)
|
|
322
|
+
if (injected) {
|
|
323
|
+
outboundBody = injected
|
|
324
|
+
weInjectedUsage = true
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Resolve o upstream: env-following (targetUrlEnv) tem precedência sobre
|
|
329
|
+
// targetUrl estática — permite "ttm segue meu redirect" sem reconfig.
|
|
330
|
+
let rawUrl
|
|
331
|
+
if (target.targetUrlEnv) {
|
|
332
|
+
rawUrl = process.env[target.targetUrlEnv]
|
|
333
|
+
if (!rawUrl) {
|
|
334
|
+
return sendJson(res, 502, { error: `target "${targetName}" exige env ${target.targetUrlEnv} (não setada no ambiente do daemon)` })
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
rawUrl = target.targetUrl
|
|
338
|
+
}
|
|
339
|
+
let upstreamUrl
|
|
340
|
+
try {
|
|
341
|
+
upstreamUrl = new URL(rawUrl)
|
|
342
|
+
} catch {
|
|
343
|
+
return sendJson(res, 502, { error: `targetUrl inválida para "${targetName}": ${rawUrl}` })
|
|
344
|
+
}
|
|
345
|
+
const basePath = upstreamUrl.pathname === '/' ? '' : upstreamUrl.pathname.replace(/\/$/, '')
|
|
346
|
+
const transport = upstreamUrl.protocol === 'http:' ? http : https
|
|
347
|
+
|
|
348
|
+
const headers = {}
|
|
349
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
350
|
+
if (!HOP_BY_HOP.has(k)) headers[k] = v
|
|
351
|
+
}
|
|
352
|
+
headers.host = upstreamUrl.host
|
|
353
|
+
headers['accept-encoding'] = 'identity' // sem compressão pra podermos ler usage
|
|
354
|
+
if (outboundBody.length) headers['content-length'] = String(outboundBody.length)
|
|
355
|
+
else delete headers['content-length']
|
|
356
|
+
|
|
357
|
+
const finalLabel = label || (await getKv('default_label'))
|
|
358
|
+
let context = null
|
|
359
|
+
try {
|
|
360
|
+
const raw = await getKv('default_context')
|
|
361
|
+
if (raw) context = JSON.parse(raw)
|
|
362
|
+
} catch {
|
|
363
|
+
/* contexto corrompido — segue sem */
|
|
364
|
+
}
|
|
365
|
+
const extractor = createExtractor(target.protocol, { urlPath: upstreamPath })
|
|
366
|
+
|
|
367
|
+
const upstreamReq = transport.request(
|
|
368
|
+
{
|
|
369
|
+
hostname: upstreamUrl.hostname,
|
|
370
|
+
port: upstreamUrl.port || (upstreamUrl.protocol === 'http:' ? 80 : 443),
|
|
371
|
+
path: basePath + upstreamPath,
|
|
372
|
+
method: req.method,
|
|
373
|
+
headers,
|
|
374
|
+
timeout: 0,
|
|
375
|
+
},
|
|
376
|
+
upstreamRes => {
|
|
377
|
+
const isSse = /text\/event-stream/i.test(upstreamRes.headers['content-type'] || '')
|
|
378
|
+
const sse = isSse ? new SseParser((event, json) => extractor.onSse(event, json)) : null
|
|
379
|
+
const decoder = isSse ? new StringDecoder('utf8') : null
|
|
380
|
+
let jsonCapture = isSse ? null : []
|
|
381
|
+
let jsonCaptured = 0
|
|
382
|
+
|
|
383
|
+
// Quando NÓS injetamos include_usage, o chunk terminal de usage não
|
|
384
|
+
// pode vazar pro cliente — filtramos por evento SSE (UTF-8-safe).
|
|
385
|
+
const mustFilter = isSse && weInjectedUsage
|
|
386
|
+
let filterBuf = ''
|
|
387
|
+
|
|
388
|
+
const responseHeaders = {}
|
|
389
|
+
for (const [k, v] of Object.entries(upstreamRes.headers)) {
|
|
390
|
+
if (!HOP_BY_HOP.has(k)) responseHeaders[k] = v
|
|
391
|
+
}
|
|
392
|
+
if (mustFilter) delete responseHeaders['content-length'] // corpo pode encolher
|
|
393
|
+
res.writeHead(upstreamRes.statusCode, responseHeaders)
|
|
394
|
+
|
|
395
|
+
upstreamRes.on('data', chunk => {
|
|
396
|
+
let text = null
|
|
397
|
+
if (decoder) text = decoder.write(chunk)
|
|
398
|
+
|
|
399
|
+
if (mustFilter) {
|
|
400
|
+
filterBuf += text
|
|
401
|
+
let m
|
|
402
|
+
while ((m = /\r?\n\r?\n/.exec(filterBuf))) {
|
|
403
|
+
const rawEvent = filterBuf.slice(0, m.index)
|
|
404
|
+
const sep = m[0]
|
|
405
|
+
filterBuf = filterBuf.slice(m.index + sep.length)
|
|
406
|
+
if (!isUsageOnlyChunk(rawEvent)) res.write(Buffer.from(rawEvent + sep, 'utf8'))
|
|
407
|
+
}
|
|
408
|
+
} else {
|
|
409
|
+
res.write(chunk)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
if (sse) {
|
|
414
|
+
sse.feed(text)
|
|
415
|
+
} else if (jsonCapture && jsonCaptured < MAX_JSON_CAPTURE) {
|
|
416
|
+
jsonCapture.push(chunk)
|
|
417
|
+
jsonCaptured += chunk.length
|
|
418
|
+
}
|
|
419
|
+
} catch {
|
|
420
|
+
/* extração nunca pode derrubar o passthrough */
|
|
421
|
+
}
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
upstreamRes.on('end', () => {
|
|
425
|
+
if (mustFilter) {
|
|
426
|
+
const tail = filterBuf + (decoder ? decoder.end() : '')
|
|
427
|
+
if (tail && !isUsageOnlyChunk(tail)) res.write(Buffer.from(tail, 'utf8'))
|
|
428
|
+
}
|
|
429
|
+
res.end()
|
|
430
|
+
try {
|
|
431
|
+
if (jsonCapture) {
|
|
432
|
+
try {
|
|
433
|
+
extractor.onJsonBody(JSON.parse(Buffer.concat(jsonCapture).toString('utf8')))
|
|
434
|
+
} catch {
|
|
435
|
+
/* corpo não-JSON (ex: binário) — sem usage */
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const u = extractor.result()
|
|
439
|
+
// sawUsage = campos reconhecidos presentes; usageUnknown = houve
|
|
440
|
+
// objeto usage mas nenhum campo bateu. Gravamos flagged (tokens 0)
|
|
441
|
+
// em vez de fantasma "completo" de 0 — fica visível que falhou.
|
|
442
|
+
if (u.sawUsage || u.usageUnknown) {
|
|
443
|
+
const { cost, rates } = costBreakdown(u.model, u)
|
|
444
|
+
const evRecord = {
|
|
445
|
+
label: finalLabel,
|
|
446
|
+
target: targetName,
|
|
447
|
+
protocol: target.protocol,
|
|
448
|
+
model: u.model,
|
|
449
|
+
providerId: u.providerId,
|
|
450
|
+
inputTokens: u.inputTokens,
|
|
451
|
+
cacheCreateTokens: u.cacheCreateTokens,
|
|
452
|
+
cacheCreate1hTokens: u.cacheCreate1hTokens,
|
|
453
|
+
cacheReadTokens: u.cacheReadTokens,
|
|
454
|
+
outputTokens: u.outputTokens,
|
|
455
|
+
reasoningTokens: u.reasoningTokens,
|
|
456
|
+
costUsd: cost,
|
|
457
|
+
rates,
|
|
458
|
+
usageFields: u.usageFields,
|
|
459
|
+
usageUnknown: u.usageUnknown,
|
|
460
|
+
repo: context?.repo ?? null,
|
|
461
|
+
branch: context?.branch ?? null,
|
|
462
|
+
status: upstreamRes.statusCode,
|
|
463
|
+
durationMs: Date.now() - started,
|
|
464
|
+
client: (req.headers['user-agent'] || '').slice(0, 120) || null,
|
|
465
|
+
}
|
|
466
|
+
const storePayloads = cfg.storePayloads
|
|
467
|
+
insertUsageEvent({ ...evRecord, payload: storePayloads })
|
|
468
|
+
.then(id => {
|
|
469
|
+
if (!storePayloads || !id) return
|
|
470
|
+
// Conteúdo da chamada para inspeção (segue a retenção; SEM
|
|
471
|
+
// headers — chaves de API nunca tocam o disco)
|
|
472
|
+
let request
|
|
473
|
+
if (requestBody.length > 4 * 1024 * 1024) {
|
|
474
|
+
request = { _truncado: true, _bytes: requestBody.length }
|
|
475
|
+
} else {
|
|
476
|
+
try {
|
|
477
|
+
request = JSON.parse(requestBody.toString('utf8'))
|
|
478
|
+
} catch {
|
|
479
|
+
request = { _raw: requestBody.toString('utf8').slice(0, 1024 * 1024) }
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
fs.writeFileSync(
|
|
483
|
+
payloadPath(id),
|
|
484
|
+
JSON.stringify({
|
|
485
|
+
event_id: id,
|
|
486
|
+
ts: evRecord.ts ?? new Date().toISOString(),
|
|
487
|
+
label: evRecord.label,
|
|
488
|
+
model: evRecord.model,
|
|
489
|
+
request,
|
|
490
|
+
response: { text: u.text || null },
|
|
491
|
+
}),
|
|
492
|
+
)
|
|
493
|
+
})
|
|
494
|
+
.catch(err => process.stderr.write(`ttm: falha ao gravar evento/payload: ${err.message}\n`))
|
|
495
|
+
exportUsageEvent({ ...evRecord, ts: new Date().toISOString() }, cfg.otel)
|
|
496
|
+
}
|
|
497
|
+
} catch (err) {
|
|
498
|
+
process.stderr.write(`ttm: falha na extração de usage: ${err.message}\n`)
|
|
499
|
+
}
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
upstreamRes.on('error', () => {
|
|
503
|
+
try {
|
|
504
|
+
res.destroy()
|
|
505
|
+
} catch {
|
|
506
|
+
/* já encerrada */
|
|
507
|
+
}
|
|
508
|
+
})
|
|
509
|
+
},
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
upstreamReq.on('error', err => {
|
|
513
|
+
if (!res.headersSent) {
|
|
514
|
+
sendJson(res, 502, { error: `falha ao contatar ${target.targetUrl}: ${err.message}` })
|
|
515
|
+
} else {
|
|
516
|
+
res.destroy()
|
|
517
|
+
}
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
req.on('close', () => upstreamReq.destroy())
|
|
521
|
+
upstreamReq.end(outboundBody)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export function createDaemonServer() {
|
|
525
|
+
const server = http.createServer(async (req, res) => {
|
|
526
|
+
try {
|
|
527
|
+
const cfg = readConfig()
|
|
528
|
+
const parsed = new URL(req.url, 'http://localhost')
|
|
529
|
+
const segments = parsed.pathname.split('/').filter(Boolean)
|
|
530
|
+
const first = segments[0]
|
|
531
|
+
|
|
532
|
+
// Object.hasOwn (não `in`): /constructor etc. herdados do protótipo
|
|
533
|
+
// não podem virar target de proxy
|
|
534
|
+
if (!first || !Object.hasOwn(cfg.targets, first)) {
|
|
535
|
+
return await handleLocal(req, res, parsed.pathname, parsed.searchParams)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
let label = null
|
|
539
|
+
let restIdx = 1
|
|
540
|
+
if (segments.length > 1 && LABEL_RE.test(segments[1])) {
|
|
541
|
+
label = segments[1].toUpperCase()
|
|
542
|
+
restIdx = 2
|
|
543
|
+
}
|
|
544
|
+
const upstreamPath = '/' + segments.slice(restIdx).join('/') + parsed.search
|
|
545
|
+
await proxyRequest(req, res, cfg, first, label, upstreamPath)
|
|
546
|
+
} catch (err) {
|
|
547
|
+
if (!res.headersSent) sendJson(res, 500, { error: err.message })
|
|
548
|
+
else res.destroy()
|
|
549
|
+
}
|
|
550
|
+
})
|
|
551
|
+
server.requestTimeout = 0 // respostas de LLM podem levar muitos minutos
|
|
552
|
+
server.headersTimeout = 60_000
|
|
553
|
+
return server
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Purge da retenção: ao expirar, agrega no histórico diário e apaga o trace.
|
|
557
|
+
async function runRetentionPurge() {
|
|
558
|
+
try {
|
|
559
|
+
const cfg = readConfig()
|
|
560
|
+
if (!cfg.retentionHours) return // 0 = nunca apagar
|
|
561
|
+
const cutoff = new Date(Date.now() - cfg.retentionHours * 3600000).toISOString()
|
|
562
|
+
const { purged } = await purgeExpired(cutoff)
|
|
563
|
+
if (purged) process.stdout.write(`ttm: retenção — ${purged} trace(s) agregados no histórico e apagados\n`)
|
|
564
|
+
} catch (err) {
|
|
565
|
+
process.stderr.write(`ttm: purge de retenção falhou: ${err.message}\n`)
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export function startDaemon() {
|
|
570
|
+
const cfg = readConfig()
|
|
571
|
+
const { warnings } = validateConfig(cfg)
|
|
572
|
+
for (const w of warnings) process.stderr.write(`ttm: ${w}\n`)
|
|
573
|
+
const server = createDaemonServer()
|
|
574
|
+
return new Promise((resolve, reject) => {
|
|
575
|
+
server.once('error', reject)
|
|
576
|
+
server.listen(cfg.port, '127.0.0.1', () => {
|
|
577
|
+
fs.writeFileSync(pidFilePath(), String(process.pid))
|
|
578
|
+
void runRetentionPurge()
|
|
579
|
+
const t = setInterval(runRetentionPurge, 3600000)
|
|
580
|
+
t.unref?.()
|
|
581
|
+
resolve({ server, port: cfg.port })
|
|
582
|
+
})
|
|
583
|
+
})
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
587
|
+
if (invokedDirectly) {
|
|
588
|
+
startDaemon()
|
|
589
|
+
.then(({ port }) => {
|
|
590
|
+
process.stdout.write(`ttm daemon ouvindo em http://127.0.0.1:${port} (pid ${process.pid})\n`)
|
|
591
|
+
// Shutdown GRACIOSO: para de aceitar conexões novas mas deixa streams
|
|
592
|
+
// em voo terminarem (um restart de upgrade não pode cortar respostas
|
|
593
|
+
// de outros TUIs no meio). Força a saída após 15s se algo pendurar.
|
|
594
|
+
let shuttingDown = false
|
|
595
|
+
const shutdown = () => {
|
|
596
|
+
if (shuttingDown) return
|
|
597
|
+
shuttingDown = true
|
|
598
|
+
try {
|
|
599
|
+
fs.unlinkSync(pidFilePath())
|
|
600
|
+
} catch {
|
|
601
|
+
/* já removido */
|
|
602
|
+
}
|
|
603
|
+
server.close(() => process.exit(0))
|
|
604
|
+
const force = setTimeout(() => process.exit(0), 15_000)
|
|
605
|
+
force.unref?.()
|
|
606
|
+
}
|
|
607
|
+
process.on('SIGTERM', shutdown)
|
|
608
|
+
process.on('SIGINT', shutdown)
|
|
609
|
+
})
|
|
610
|
+
.catch(err => {
|
|
611
|
+
process.stderr.write(`ttm daemon: ${err.message}\n`)
|
|
612
|
+
process.exit(1)
|
|
613
|
+
})
|
|
614
|
+
}
|