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,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
+ }
@@ -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
+ }