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
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Servidor MCP (stdio) do token-trace-manager — sem dependências externas.
|
|
3
|
+
// Transporte: JSON-RPC 2.0, uma mensagem JSON por linha (spec MCP stdio).
|
|
4
|
+
// Funciona em qualquer cliente MCP: Claude Code (plugin.json mcpServers),
|
|
5
|
+
// OpenCode (opencode.json "mcp"), etc. Garante o daemon do proxy de pé.
|
|
6
|
+
|
|
7
|
+
import { execFileSync } from 'node:child_process'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import readline from 'node:readline'
|
|
10
|
+
import { readConfig } from './config.mjs'
|
|
11
|
+
import { ensureDaemon, daemonHealth, fetchDaemon } from './daemon-client.mjs'
|
|
12
|
+
|
|
13
|
+
// O servidor MCP roda DENTRO do ambiente do TUI (cwd = projeto do usuário) —
|
|
14
|
+
// é daqui que repo/branch são detectados e viram tags nos traces.
|
|
15
|
+
function detectContext() {
|
|
16
|
+
const git = args => {
|
|
17
|
+
try {
|
|
18
|
+
return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
|
|
19
|
+
} catch {
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const top = git(['rev-parse', '--show-toplevel'])
|
|
24
|
+
return {
|
|
25
|
+
repo: top ? path.basename(top) : null,
|
|
26
|
+
branch: git(['branch', '--show-current']) || null,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const TICKET_RE = /[A-Z][A-Z0-9]+-\d+/i
|
|
31
|
+
|
|
32
|
+
const SERVER_INFO = { name: 'token-trace-manager', version: '0.6.0' }
|
|
33
|
+
// Negociação (spec): se a versão pedida está na lista, ecoa; senão responde
|
|
34
|
+
// com a nossa mais recente (não é erro — o client decide se desconecta).
|
|
35
|
+
const SUPPORTED_PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2024-11-05']
|
|
36
|
+
|
|
37
|
+
const TOOLS = [
|
|
38
|
+
{
|
|
39
|
+
name: 'ttm_status',
|
|
40
|
+
description:
|
|
41
|
+
'Estado do proxy de rastreio de tokens: daemon rodando ou não, porta, label padrão atual e URL do dashboard.',
|
|
42
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'ttm_activate',
|
|
46
|
+
description:
|
|
47
|
+
'Ativa o rastreio de tokens numa tarefa A PARTIR DE AGORA (pode trocar a qualquer momento da sessão). Sem label, detecta o ticket pelo nome da branch git do ambiente atual. Também detecta repo/branch e os carimba como tags em cada trace. Com retroactive_minutes, mensagens recentes SEM tarefa — de QUALQUER TUI/cliente que use o proxy — entram na tarefa nova (caso o card tenha sido criado depois do trabalho começar).',
|
|
48
|
+
inputSchema: {
|
|
49
|
+
type: 'object',
|
|
50
|
+
properties: {
|
|
51
|
+
label: {
|
|
52
|
+
type: ['string', 'null'],
|
|
53
|
+
description: 'Ticket (ex: PROJ-123). Omitido/null = detectar da branch git.',
|
|
54
|
+
},
|
|
55
|
+
retroactive_minutes: {
|
|
56
|
+
type: 'number',
|
|
57
|
+
description: 'Atribui também as mensagens sem tarefa dos últimos N minutos (máx 1440).',
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
additionalProperties: false,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'ttm_set_label',
|
|
65
|
+
description:
|
|
66
|
+
'Define/troca o label padrão (ticket Jira, ex: PROJ-123) do tráfego do proxy — vale para o que vier DEPOIS da troca. Passe null para limpar. Prefira ttm_activate, que também detecta repo/branch.',
|
|
67
|
+
inputSchema: {
|
|
68
|
+
type: 'object',
|
|
69
|
+
properties: {
|
|
70
|
+
label: {
|
|
71
|
+
type: ['string', 'null'],
|
|
72
|
+
description: 'Chave do ticket (ex: PROJ-123) ou null para limpar',
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
required: ['label'],
|
|
76
|
+
additionalProperties: false,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: 'ttm_usage_summary',
|
|
81
|
+
description:
|
|
82
|
+
'Resumo de uso de tokens e custo registrado pelo proxy: total e quebras por label/ticket, provedor, modelo, cliente e dia.',
|
|
83
|
+
inputSchema: {
|
|
84
|
+
type: 'object',
|
|
85
|
+
properties: {
|
|
86
|
+
since: {
|
|
87
|
+
type: 'string',
|
|
88
|
+
description: "Janela: '7d', '24h', '30d' (padrão) ou 'all'",
|
|
89
|
+
},
|
|
90
|
+
label: { type: 'string', description: 'Filtrar por um label/ticket específico' },
|
|
91
|
+
},
|
|
92
|
+
additionalProperties: false,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'ttm_dashboard_url',
|
|
97
|
+
description: 'Garante que o daemon está de pé e retorna a URL do dashboard local ao vivo.',
|
|
98
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
99
|
+
},
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
async function callTool(name, args) {
|
|
103
|
+
const cfg = readConfig()
|
|
104
|
+
switch (name) {
|
|
105
|
+
case 'ttm_status': {
|
|
106
|
+
const h = await daemonHealth()
|
|
107
|
+
const label = h ? (await fetchDaemon('/api/label')).defaultLabel : null
|
|
108
|
+
return {
|
|
109
|
+
daemon: h ? 'rodando' : 'parado',
|
|
110
|
+
proxyUrl: `http://127.0.0.1:${cfg.port}`,
|
|
111
|
+
pid: h?.pid ?? null,
|
|
112
|
+
uptime_s: h?.uptime_s ?? null,
|
|
113
|
+
defaultLabel: label,
|
|
114
|
+
dashboard: `http://127.0.0.1:${cfg.port}/dashboard`,
|
|
115
|
+
targets: Object.fromEntries(Object.entries(cfg.targets).map(([k, t]) => [k, t.targetUrl])),
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
case 'ttm_activate': {
|
|
119
|
+
await ensureDaemon()
|
|
120
|
+
const context = detectContext()
|
|
121
|
+
let label = args.label ?? null
|
|
122
|
+
let labelSource = label ? 'informado' : null
|
|
123
|
+
if (!label && context.branch) {
|
|
124
|
+
const m = TICKET_RE.exec(context.branch)
|
|
125
|
+
if (m) {
|
|
126
|
+
label = m[0].toUpperCase()
|
|
127
|
+
labelSource = `detectado da branch "${context.branch}"`
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (!label) {
|
|
131
|
+
return {
|
|
132
|
+
ativado: false,
|
|
133
|
+
motivo: `nenhum label informado e a branch atual (${context.branch ?? 'sem git'}) não contém um ticket tipo PROJ-123 — pergunte ao usuário qual tarefa usar e chame de novo com label`,
|
|
134
|
+
context,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const r = await fetchDaemon('/api/label', {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
body: {
|
|
140
|
+
label,
|
|
141
|
+
context,
|
|
142
|
+
retroactiveMinutes: args.retroactive_minutes ?? 0,
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
return {
|
|
146
|
+
ativado: true,
|
|
147
|
+
tarefa: r.defaultLabel,
|
|
148
|
+
origem: labelSource,
|
|
149
|
+
tags: r.context,
|
|
150
|
+
mensagensRetroativas: r.relabeled,
|
|
151
|
+
nota: 'todo tráfego novo do proxy (sem label na URL) cai nesta tarefa até trocar — use ttm_activate de novo para mudar',
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
case 'ttm_set_label': {
|
|
155
|
+
await ensureDaemon()
|
|
156
|
+
const r = await fetchDaemon('/api/label', { method: 'POST', body: { label: args.label ?? null } })
|
|
157
|
+
return { defaultLabel: r.defaultLabel }
|
|
158
|
+
}
|
|
159
|
+
case 'ttm_usage_summary': {
|
|
160
|
+
await ensureDaemon()
|
|
161
|
+
const params = new URLSearchParams()
|
|
162
|
+
if (args.since) params.set('since', args.since)
|
|
163
|
+
if (args.label) params.set('label', args.label)
|
|
164
|
+
return await fetchDaemon(`/api/summary?${params}`)
|
|
165
|
+
}
|
|
166
|
+
case 'ttm_dashboard_url': {
|
|
167
|
+
await ensureDaemon()
|
|
168
|
+
return { url: `http://127.0.0.1:${cfg.port}/dashboard` }
|
|
169
|
+
}
|
|
170
|
+
default:
|
|
171
|
+
throw new Error(`tool desconhecida: ${name}`)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function send(msg) {
|
|
176
|
+
process.stdout.write(JSON.stringify(msg) + '\n')
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function reply(id, result) {
|
|
180
|
+
send({ jsonrpc: '2.0', id, result })
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function replyError(id, code, message) {
|
|
184
|
+
send({ jsonrpc: '2.0', id, error: { code, message } })
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function handle(msg) {
|
|
188
|
+
const { id, method, params } = msg
|
|
189
|
+
|
|
190
|
+
// Notifications (sem id) — só reconhecemos, nada a responder.
|
|
191
|
+
// (id: 0 é válido em JSON-RPC e passa por aqui corretamente.)
|
|
192
|
+
if (id === undefined || id === null) return
|
|
193
|
+
|
|
194
|
+
if (typeof method !== 'string' || !method) {
|
|
195
|
+
return replyError(id, -32600, 'Invalid Request: método ausente')
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
switch (method) {
|
|
199
|
+
case 'initialize': {
|
|
200
|
+
const requested = params?.protocolVersion
|
|
201
|
+
const version = SUPPORTED_PROTOCOL_VERSIONS.includes(requested)
|
|
202
|
+
? requested
|
|
203
|
+
: SUPPORTED_PROTOCOL_VERSIONS[0]
|
|
204
|
+
reply(id, {
|
|
205
|
+
protocolVersion: version,
|
|
206
|
+
capabilities: { tools: {} },
|
|
207
|
+
serverInfo: SERVER_INFO,
|
|
208
|
+
})
|
|
209
|
+
// Sobe o daemon em background — sem bloquear o handshake
|
|
210
|
+
ensureDaemon().catch(() => {})
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
case 'ping':
|
|
214
|
+
return reply(id, {})
|
|
215
|
+
case 'tools/list':
|
|
216
|
+
return reply(id, { tools: TOOLS })
|
|
217
|
+
case 'tools/call': {
|
|
218
|
+
if (typeof params?.name !== 'string' || !params.name) {
|
|
219
|
+
return replyError(id, -32602, 'Invalid params: "name" é obrigatório em tools/call')
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const result = await callTool(params.name, params.arguments ?? {})
|
|
223
|
+
return reply(id, {
|
|
224
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
225
|
+
})
|
|
226
|
+
} catch (err) {
|
|
227
|
+
return reply(id, {
|
|
228
|
+
content: [{ type: 'text', text: `erro: ${err.message}` }],
|
|
229
|
+
isError: true,
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
case 'resources/list':
|
|
234
|
+
return reply(id, { resources: [] })
|
|
235
|
+
case 'prompts/list':
|
|
236
|
+
return reply(id, { prompts: [] })
|
|
237
|
+
default:
|
|
238
|
+
return replyError(id, -32601, `método não suportado: ${method}`)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// stdin fechar NÃO pode matar handlers assíncronos em voo (ex: ttm_activate
|
|
243
|
+
// esperando o daemon subir) — sai só quando o último terminar.
|
|
244
|
+
let pending = 0
|
|
245
|
+
let stdinClosed = false
|
|
246
|
+
function maybeExit() {
|
|
247
|
+
if (stdinClosed && pending === 0) process.exit(0)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity })
|
|
251
|
+
rl.on('line', line => {
|
|
252
|
+
if (!line.trim()) return
|
|
253
|
+
let msg
|
|
254
|
+
try {
|
|
255
|
+
msg = JSON.parse(line)
|
|
256
|
+
} catch {
|
|
257
|
+
return // linha inválida — ignora, nunca derruba o servidor
|
|
258
|
+
}
|
|
259
|
+
// "null", números e strings são JSON válidos mas não são mensagens JSON-RPC —
|
|
260
|
+
// desestruturar null derrubaria o processo (unhandled rejection)
|
|
261
|
+
if (typeof msg !== 'object' || msg === null || Array.isArray(msg)) return
|
|
262
|
+
pending++
|
|
263
|
+
handle(msg)
|
|
264
|
+
.catch(err => {
|
|
265
|
+
if (msg.id !== undefined && msg.id !== null) replyError(msg.id, -32603, err.message)
|
|
266
|
+
})
|
|
267
|
+
.finally(() => {
|
|
268
|
+
pending--
|
|
269
|
+
maybeExit()
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
rl.on('close', () => {
|
|
273
|
+
stdinClosed = true
|
|
274
|
+
maybeExit()
|
|
275
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Exportador OTLP/HTTP (JSON) opcional — desligado por padrão.
|
|
2
|
+
//
|
|
3
|
+
// Um span por chamada LLM, com atributos GenAI semconv + os detalhes que o
|
|
4
|
+
// Langfuse ingere (usage_details/cost_details). Funciona com o endpoint OTel
|
|
5
|
+
// do Langfuse (cloud ou self-hosted) e com qualquer collector OTel genérico.
|
|
6
|
+
//
|
|
7
|
+
// Config (config.json no diretório de dados):
|
|
8
|
+
// "otel": {
|
|
9
|
+
// "endpoint": "https://cloud.langfuse.com/api/public/otel", // sem /v1/traces
|
|
10
|
+
// "headers": { "Authorization": "Basic <base64(pk-lf-...:sk-lf-...)>" }
|
|
11
|
+
// }
|
|
12
|
+
//
|
|
13
|
+
// Sem "otel" na config => nenhum byte sai da máquina (padrão local-first).
|
|
14
|
+
|
|
15
|
+
import crypto from 'node:crypto'
|
|
16
|
+
import { readConfig } from './config.mjs'
|
|
17
|
+
|
|
18
|
+
const BATCH_MAX = 20
|
|
19
|
+
const FLUSH_MS = 5000
|
|
20
|
+
|
|
21
|
+
let queue = []
|
|
22
|
+
let timer = null
|
|
23
|
+
|
|
24
|
+
function attr(key, value) {
|
|
25
|
+
if (value === null || value === undefined) return null
|
|
26
|
+
if (typeof value === 'number' && Number.isInteger(value)) return { key, value: { intValue: String(value) } }
|
|
27
|
+
if (typeof value === 'number') return { key, value: { doubleValue: value } }
|
|
28
|
+
return { key, value: { stringValue: String(value) } }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function eventToSpan(ev) {
|
|
32
|
+
const endMs = ev.ts ? Date.parse(ev.ts) : NaN
|
|
33
|
+
const end = Number.isNaN(endMs) ? 0 : endMs
|
|
34
|
+
const start = end - (ev.durationMs ?? 0)
|
|
35
|
+
const usageDetails = {
|
|
36
|
+
input: ev.inputTokens,
|
|
37
|
+
output: ev.outputTokens,
|
|
38
|
+
cache_read_input_tokens: ev.cacheReadTokens,
|
|
39
|
+
cache_creation_input_tokens: ev.cacheCreateTokens,
|
|
40
|
+
total: ev.inputTokens + ev.cacheCreateTokens + ev.cacheReadTokens + ev.outputTokens,
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
traceId: crypto.randomBytes(16).toString('hex'),
|
|
44
|
+
spanId: crypto.randomBytes(8).toString('hex'),
|
|
45
|
+
name: ev.label ? `llm ${ev.label}` : 'llm',
|
|
46
|
+
kind: 3, // CLIENT
|
|
47
|
+
startTimeUnixNano: String(start) + '000000',
|
|
48
|
+
endTimeUnixNano: String(end) + '000000',
|
|
49
|
+
attributes: [
|
|
50
|
+
attr('gen_ai.system', ev.target),
|
|
51
|
+
attr('gen_ai.response.model', ev.model),
|
|
52
|
+
attr('gen_ai.response.id', ev.providerId),
|
|
53
|
+
attr('gen_ai.usage.input_tokens', ev.inputTokens + ev.cacheCreateTokens + ev.cacheReadTokens),
|
|
54
|
+
attr('gen_ai.usage.output_tokens', ev.outputTokens),
|
|
55
|
+
attr('langfuse.observation.type', 'generation'),
|
|
56
|
+
attr('langfuse.observation.usage_details', JSON.stringify(usageDetails)),
|
|
57
|
+
ev.costUsd !== null && ev.costUsd !== undefined
|
|
58
|
+
? attr('langfuse.observation.cost_details', JSON.stringify({ total: ev.costUsd }))
|
|
59
|
+
: null,
|
|
60
|
+
attr('langfuse.trace.name', ev.label ?? 'sem-label'),
|
|
61
|
+
attr('langfuse.session.id', ev.label ?? undefined),
|
|
62
|
+
attr('ttm.client', ev.client),
|
|
63
|
+
attr('ttm.label', ev.label ?? undefined),
|
|
64
|
+
].filter(Boolean),
|
|
65
|
+
status: { code: ev.status && ev.status >= 400 ? 2 : 1 },
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function flush() {
|
|
70
|
+
// relê a config NA HORA do flush: trocar/desligar o endpoint vale também
|
|
71
|
+
// para spans já enfileirados (desligou => fila descartada, nada sai)
|
|
72
|
+
const otelCfg = readConfig().otel
|
|
73
|
+
const spans = queue
|
|
74
|
+
queue = []
|
|
75
|
+
if (!spans.length || !otelCfg?.endpoint) return
|
|
76
|
+
const body = JSON.stringify({
|
|
77
|
+
resourceSpans: [
|
|
78
|
+
{
|
|
79
|
+
resource: {
|
|
80
|
+
attributes: [attr('service.name', 'token-trace-manager'), attr('service.version', otelCfg.serviceVersion ?? '0.4.0')],
|
|
81
|
+
},
|
|
82
|
+
scopeSpans: [{ scope: { name: 'ttm-proxy' }, spans }],
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
})
|
|
86
|
+
try {
|
|
87
|
+
const res = await fetch(otelCfg.endpoint.replace(/\/$/, '') + '/v1/traces', {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: { 'content-type': 'application/json', ...(otelCfg.headers || {}) },
|
|
90
|
+
body,
|
|
91
|
+
signal: AbortSignal.timeout(10_000),
|
|
92
|
+
})
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
process.stderr.write(`ttm: export OTel falhou (HTTP ${res.status}) — eventos locais intactos\n`)
|
|
95
|
+
}
|
|
96
|
+
} catch (err) {
|
|
97
|
+
process.stderr.write(`ttm: export OTel falhou (${err.message}) — eventos locais intactos\n`)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Fire-and-forget: exportar NUNCA pode atrasar nem derrubar o proxy.
|
|
102
|
+
export function exportUsageEvent(ev, otelCfg) {
|
|
103
|
+
if (!otelCfg?.endpoint) return
|
|
104
|
+
try {
|
|
105
|
+
queue.push(eventToSpan(ev))
|
|
106
|
+
} catch {
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
if (queue.length >= BATCH_MAX) {
|
|
110
|
+
if (timer) clearTimeout(timer)
|
|
111
|
+
timer = null
|
|
112
|
+
void flush()
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
if (!timer) {
|
|
116
|
+
timer = setTimeout(() => {
|
|
117
|
+
timer = null
|
|
118
|
+
void flush()
|
|
119
|
+
}, otelCfg.flushMs ?? FLUSH_MS)
|
|
120
|
+
timer.unref?.()
|
|
121
|
+
}
|
|
122
|
+
}
|
package/lib/pricing.mjs
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { dataDir } from './db.mjs'
|
|
4
|
+
|
|
5
|
+
// USD por 1M de tokens. Anthropic confirmado via skill claude-api (2026-06-24).
|
|
6
|
+
// OpenAI/Gemini preenchidos pela varredura de pesquisa — marcar a data ao atualizar.
|
|
7
|
+
// Modelos desconhecidos => custo null (tokens continuam registrados).
|
|
8
|
+
export const PRICING_LAST_UPDATED = '2026-07-06'
|
|
9
|
+
|
|
10
|
+
// { input, output, cacheRead?, cacheWrite? } — cacheRead default = 0.1x input
|
|
11
|
+
// e cacheWrite default = 1.25x input (regra Anthropic); OpenAI/Gemini têm
|
|
12
|
+
// cacheRead explícito e cacheWrite 0 (não cobram escrita de cache à parte).
|
|
13
|
+
// Fontes: platform.claude.com/docs pricing, developers.openai.com/api/docs/pricing,
|
|
14
|
+
// help.openai.com codex rate card, ai.google.dev/gemini-api/docs/pricing.
|
|
15
|
+
const BASE_TABLE = {
|
|
16
|
+
// Anthropic
|
|
17
|
+
'claude-fable-5': { input: 10.0, output: 50.0 },
|
|
18
|
+
'claude-mythos-5': { input: 10.0, output: 50.0 },
|
|
19
|
+
'claude-opus-4-8': { input: 5.0, output: 25.0 },
|
|
20
|
+
'claude-opus-4-7': { input: 5.0, output: 25.0 },
|
|
21
|
+
'claude-opus-4-6': { input: 5.0, output: 25.0 },
|
|
22
|
+
'claude-opus-4-5': { input: 5.0, output: 25.0 },
|
|
23
|
+
// Sonnet 5: preço introdutório ($2/$10) vigente até 2026-08-31; a partir
|
|
24
|
+
// de 2026-09-01 vira $3/$15 — atualizar aqui ou via pricing.override.json.
|
|
25
|
+
'claude-sonnet-5': { input: 2.0, output: 10.0 },
|
|
26
|
+
'claude-sonnet-4-6': { input: 3.0, output: 15.0 },
|
|
27
|
+
'claude-sonnet-4-5': { input: 3.0, output: 15.0 },
|
|
28
|
+
'claude-haiku-4-5': { input: 1.0, output: 5.0 },
|
|
29
|
+
|
|
30
|
+
// OpenAI (sem cobrança de cache write; cacheRead explícito)
|
|
31
|
+
'gpt-5.5': { input: 5.0, output: 30.0, cacheRead: 0.5, cacheWrite: 0 },
|
|
32
|
+
'gpt-5.5-pro': { input: 30.0, output: 180.0, cacheRead: 30.0, cacheWrite: 0 },
|
|
33
|
+
'gpt-5.5-codex': { input: 5.0, output: 30.0, cacheRead: 0.5, cacheWrite: 0 },
|
|
34
|
+
'gpt-5.4': { input: 2.5, output: 15.0, cacheRead: 0.25, cacheWrite: 0 },
|
|
35
|
+
'gpt-5.4-mini': { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 },
|
|
36
|
+
'gpt-5.4-nano': { input: 0.2, output: 1.25, cacheRead: 0.02, cacheWrite: 0 },
|
|
37
|
+
'gpt-5.4-pro': { input: 30.0, output: 180.0, cacheRead: 30.0, cacheWrite: 0 },
|
|
38
|
+
'gpt-5.4-codex': { input: 2.5, output: 15.0, cacheRead: 0.25, cacheWrite: 0 },
|
|
39
|
+
'gpt-5.3-codex': { input: 1.75, output: 14.0, cacheRead: 0.175, cacheWrite: 0 },
|
|
40
|
+
'gpt-5.2-codex': { input: 1.75, output: 14.0, cacheRead: 0.175, cacheWrite: 0 },
|
|
41
|
+
'o3-deep-research': { input: 5.0, output: 20.0, cacheRead: 5.0, cacheWrite: 0 },
|
|
42
|
+
'o4-mini-deep-research': { input: 1.0, output: 4.0, cacheRead: 1.0, cacheWrite: 0 },
|
|
43
|
+
|
|
44
|
+
// Google Gemini (preço da faixa <=200K de contexto quando em camadas)
|
|
45
|
+
'gemini-3.5-flash': { input: 1.5, output: 9.0, cacheRead: 0.15, cacheWrite: 0 },
|
|
46
|
+
'gemini-3.1-pro-preview': { input: 2.0, output: 12.0, cacheRead: 0.2, cacheWrite: 0 },
|
|
47
|
+
'gemini-3.1-flash-lite': { input: 0.25, output: 1.5, cacheRead: 0.025, cacheWrite: 0 },
|
|
48
|
+
'gemini-3-flash-preview': { input: 0.5, output: 3.0, cacheRead: 0.05, cacheWrite: 0 },
|
|
49
|
+
'gemini-2.5-pro': { input: 1.25, output: 10.0, cacheRead: 0.125, cacheWrite: 0 },
|
|
50
|
+
'gemini-2.5-flash': { input: 0.3, output: 2.5, cacheRead: 0.03, cacheWrite: 0 },
|
|
51
|
+
'gemini-2.5-flash-lite': { input: 0.1, output: 0.4, cacheRead: 0.01, cacheWrite: 0 },
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function baseAlias(model) {
|
|
55
|
+
// Alguns modelos aparecem com sufixo de snapshot datado (ex:
|
|
56
|
+
// claude-haiku-4-5-20251001, gpt-5.2-2026-01-15); normaliza pro alias.
|
|
57
|
+
return model.replace(/[-@]\d{4}-?\d{2}-?\d{2}$/, '').replace(/-\d{8}$/, '')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let overrideTable = null
|
|
61
|
+
|
|
62
|
+
function overridePath() {
|
|
63
|
+
return path.join(dataDir(), 'pricing.override.json')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function loadOverrides() {
|
|
67
|
+
if (overrideTable) return overrideTable
|
|
68
|
+
try {
|
|
69
|
+
overrideTable = JSON.parse(fs.readFileSync(overridePath(), 'utf8'))
|
|
70
|
+
} catch {
|
|
71
|
+
overrideTable = {}
|
|
72
|
+
}
|
|
73
|
+
return overrideTable
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function rateFor(model) {
|
|
77
|
+
if (!model) return null
|
|
78
|
+
const overrides = loadOverrides()
|
|
79
|
+
const entry =
|
|
80
|
+
overrides[model] ??
|
|
81
|
+
BASE_TABLE[model] ??
|
|
82
|
+
overrides[baseAlias(model)] ??
|
|
83
|
+
BASE_TABLE[baseAlias(model)] ??
|
|
84
|
+
null
|
|
85
|
+
if (!entry) return null
|
|
86
|
+
return {
|
|
87
|
+
input: entry.input,
|
|
88
|
+
output: entry.output,
|
|
89
|
+
cacheRead: entry.cacheRead ?? entry.input * 0.1,
|
|
90
|
+
cacheWrite: entry.cacheWrite ?? entry.input * 1.25, // Anthropic: TTL 5m = 1.25x
|
|
91
|
+
cacheWrite1h: entry.cacheWrite1h ?? (entry.cacheWrite === 0 ? 0 : entry.input * 2), // TTL 1h = 2x
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function knownModel(model) {
|
|
96
|
+
return rateFor(model) !== null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// O "recibo" auditável do cálculo: custo total + as taxas aplicadas ($/1M) +
|
|
100
|
+
// cada parcela. Guardado por evento (coluna rates) para o custo ser sempre
|
|
101
|
+
// re-verificável mesmo se a tabela de preços mudar depois.
|
|
102
|
+
// usage normalizado (ver adapters.mjs): input já exclui cache; output já
|
|
103
|
+
// inclui reasoning; cacheCreate1h é subconjunto de cacheCreate.
|
|
104
|
+
export function costBreakdown(model, u) {
|
|
105
|
+
const r = rateFor(model)
|
|
106
|
+
if (!r) return { cost: null, rates: null }
|
|
107
|
+
const create1h = Math.min(u.cacheCreate1hTokens || 0, u.cacheCreateTokens || 0)
|
|
108
|
+
const create5m = (u.cacheCreateTokens || 0) - create1h
|
|
109
|
+
const cost =
|
|
110
|
+
(u.inputTokens / 1e6) * r.input +
|
|
111
|
+
(u.cacheReadTokens / 1e6) * r.cacheRead +
|
|
112
|
+
(create5m / 1e6) * r.cacheWrite +
|
|
113
|
+
(create1h / 1e6) * r.cacheWrite1h +
|
|
114
|
+
(u.outputTokens / 1e6) * r.output
|
|
115
|
+
return {
|
|
116
|
+
cost,
|
|
117
|
+
rates: {
|
|
118
|
+
input: r.input,
|
|
119
|
+
output: r.output,
|
|
120
|
+
cacheRead: r.cacheRead,
|
|
121
|
+
cacheWrite5m: r.cacheWrite,
|
|
122
|
+
cacheWrite1h: r.cacheWrite1h,
|
|
123
|
+
pricingDate: PRICING_LAST_UPDATED,
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function costForUsage(model, u) {
|
|
129
|
+
return costBreakdown(model, u).cost
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function registerPricing(entries) {
|
|
133
|
+
const current = loadOverrides()
|
|
134
|
+
const next = { ...current, ...entries }
|
|
135
|
+
fs.writeFileSync(overridePath(), JSON.stringify(next, null, 2))
|
|
136
|
+
overrideTable = next
|
|
137
|
+
}
|