token-trace-manager 0.7.0 → 0.7.1
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 +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +16 -0
- package/README.md +23 -32
- package/bin/ttm.mjs +11 -4
- package/commands/setup.md +1 -1
- package/lib/daemon-client.mjs +4 -1
- package/lib/daemon.mjs +75 -30
- package/lib/db.mjs +42 -16
- package/lib/mcp-server.mjs +7 -1
- package/lib/report-data.mjs +2 -1
- package/lib/setup-snippets.mjs +8 -4
- package/lib/tui-config.mjs +22 -2
- package/package.json +1 -1
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "Proxy local universal de rastreio de tokens/custo para TUIs de código, com labels por ticket e dashboard local",
|
|
8
|
-
"version": "0.7.
|
|
8
|
+
"version": "0.7.1"
|
|
9
9
|
},
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "ttm",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"description": "Proxy local universal de rastreio de tokens/custo para TUIs de código (Claude Code, Codex, OpenCode, Gemini CLI, Kilo...), com labels por ticket e dashboard local.",
|
|
15
|
-
"version": "0.7.
|
|
15
|
+
"version": "0.7.1",
|
|
16
16
|
"category": "productivity"
|
|
17
17
|
}
|
|
18
18
|
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ttm",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Proxy local universal de rastreio de tokens/custo para TUIs de código (Claude Code, Codex, OpenCode, Gemini CLI, Kilo...), com labels por ticket e dashboard local.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Mpaape"
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
Mudanças notáveis deste projeto. Formato: [Keep a Changelog](https://keepachangelog.com/), [semver](https://semver.org/).
|
|
4
4
|
|
|
5
|
+
## [0.7.1] - 2026-07-09
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- Roteamento de labels por URL agora é inequívoco: tickets continuam em `/{target}/PROJ-123/...`, e slugs descritivos usam `/{target}/-/{slug}/...`, sem roubar paths hifenizados reais do upstream.
|
|
9
|
+
- Payloads redigem recursivamente campos secretos em bodies JSON (`api_key`, `access_token`, `authorization`, `client_secret`, etc.) antes de gravar `payloads/*.json`.
|
|
10
|
+
- `/api/summary` sem `since` volta a usar o default documentado de `30d`.
|
|
11
|
+
- `ttm config` redige `otel.headers` antes de imprimir a configuração.
|
|
12
|
+
- `ttm audit --since all` aceita `all` sem gerar data inválida.
|
|
13
|
+
- MCP reporta a versão real do pacote e `ttm_set_label` sem `label` não limpa mais o estado por acidente.
|
|
14
|
+
- `ttm link codex` cria um provider `ttm` completo quando `~/.codex/config.toml` existe mas ainda não tem provider ativo.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- SQLite inicializa com WAL, `busy_timeout` e índices adicionais para reduzir colisões entre daemon, auditoria e consultas.
|
|
18
|
+
- Erros reais de schema/migração SQLite deixam de cair silenciosamente para o backend JSON; fallback JSON fica restrito a runtimes sem `node:sqlite` ou ao hook de teste.
|
|
19
|
+
- E2E ampliado para 154 asserções cobrindo as regressões acima, backend JSON, redaction de body e smoke de CLI.
|
|
20
|
+
|
|
5
21
|
## [0.7.0] - 2026-07-08
|
|
6
22
|
|
|
7
23
|
### Added
|
package/README.md
CHANGED
|
@@ -16,7 +16,8 @@ Um único daemon local intercepta as chamadas de API de todas as suas ferramenta
|
|
|
16
16
|
Claude Code ──┐ ┌──> api.anthropic.com
|
|
17
17
|
Codex ────────┤ proxyURL ├──> api.openai.com
|
|
18
18
|
OpenCode ─────┼─> http://127.0.0.1:25519 ┼──> generativelanguage.googleapis.com
|
|
19
|
-
Gemini CLI ───┤
|
|
19
|
+
Gemini CLI ───┤ /{target}/{TICKET?}/… ├──> openrouter.ai/api
|
|
20
|
+
│ /{target}/-/{slug}/… │
|
|
20
21
|
Kilo, etc ────┘ │ └──> (targets customizados)
|
|
21
22
|
│ tee (só metadados de usage)
|
|
22
23
|
v
|
|
@@ -26,48 +27,38 @@ Um único daemon local intercepta as chamadas de API de todas as suas ferramenta
|
|
|
26
27
|
```
|
|
27
28
|
|
|
28
29
|
- **targetUrl** — a URL real do provedor que o TUI chamaria sem proxy (já vem preenchida para `anthropic`, `openai`, `gemini` e `openrouter`; adicione outras no config).
|
|
29
|
-
- **proxyURL** — `http://127.0.0.1:25519`. O primeiro segmento do path escolhe o target; um segundo segmento opcional no formato `TICKET-123` vira o label daquela chamada.
|
|
30
|
+
- **proxyURL** — `http://127.0.0.1:25519`. O primeiro segmento do path escolhe o target; um segundo segmento opcional no formato `TICKET-123` vira o label daquela chamada. Para labels descritivos que não são ticket, use a forma explícita `/{target}/-/{slug}/...`, sem ambiguidade com o path real do provedor.
|
|
30
31
|
|
|
31
32
|
## Instalação
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
> Pré-requisito: **Node ≥ 22.5**. Sem dependências, sem build — é Node puro.
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
3. Reinicie o Claude Code (plugins carregam na inicialização)
|
|
41
|
-
4. /ttm:setup (sobe o daemon e, com sua aprovação,
|
|
42
|
-
aponta o ANTHROPIC_BASE_URL pro proxy)
|
|
43
|
-
5. Reinicie o Claude Code de novo (a env var só vale pra sessão nova)
|
|
36
|
+
```bash
|
|
37
|
+
npm install -g token-trace-manager # 1. instala o bin `ttm`
|
|
38
|
+
ttm start # 2. sobe o daemon (porta 25519, só 127.0.0.1)
|
|
39
|
+
ttm link claude-code # 3. insere o ttm no meio do seu TUI
|
|
40
|
+
ttm status # pronto — daemon rodando
|
|
44
41
|
```
|
|
45
42
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
Quer rastrear outros TUIs além do Claude Code? O daemon já é o mesmo — só falta apontar cada ferramenta: `/ttm:setup codex` (ou `opencode`, `gemini-cli`...) mostra o snippet.
|
|
43
|
+
`ttm link` suporta `claude-code`, `codex`, `opencode`, `gemini-cli`. Ele descobre a base URL que o TUI já usa, vira upstream dela e reescreve a config do TUI pra passar pelo ttm. Depois, é só usar o TUI normalmente — cada chamada conta tokens.
|
|
49
44
|
|
|
50
|
-
|
|
45
|
+

|
|
51
46
|
|
|
52
|
-
|
|
53
|
-
npm install -g token-trace-manager # instala o bin `ttm` (Node ≥ 22.5)
|
|
54
|
-
|
|
55
|
-
ttm start # 1. sobe o daemon (idempotente)
|
|
56
|
-
ttm link claude-code # 2. descobre a base URL do TUI e se insere no meio
|
|
57
|
-
# (use: claude-code, codex, opencode, gemini-cli)
|
|
58
|
-
ttm status # 3. confirma que está de pé
|
|
59
|
-
```
|
|
47
|
+
> **Já redireciona pra um compatível-anthropic (z.ai/GLM/...)?** O `ttm link` **preserva** seu redirect — ele descobre pra onde você aponta, vira upstream disso, e reescreve a config do TUI. Continua batendo no mesmo lugar, só que com contagem de tokens.
|
|
60
48
|
|
|
61
|
-
>
|
|
49
|
+
<details>
|
|
50
|
+
<summary><strong>Outros caminhos</strong> (sem npm, plugin do Claude Code, etc.)</summary>
|
|
62
51
|
|
|
63
|
-
Sem npm
|
|
52
|
+
**Sem npm — via git:**
|
|
64
53
|
```bash
|
|
65
54
|
git clone https://github.com/cloud104/token-trace-manager
|
|
66
55
|
cd token-trace-manager
|
|
67
|
-
npm link
|
|
56
|
+
npm link # deixa `ttm` no PATH (sem isso, use `node bin/ttm.mjs`)
|
|
68
57
|
```
|
|
69
58
|
|
|
70
|
-
|
|
59
|
+
**Plugin do Claude Code (`/plugin install`):** exige que o repo seja **público** (`/plugin marketplace add cloud104/token-trace-manager` clona o repo). Enquanto o repo estiver privado, use o caminho do `npm` acima — funciona pro Claude Code também, e o `ttm link claude-code` cuida de tudo (inclusive do redirect). Não há perda de função: as tools MCP (`ttm_status`, `ttm_set_label`, `ttm_usage_summary`) ficam disponíveis em qualquer TUI compatível com MCP via `.mcp.json`.
|
|
60
|
+
|
|
61
|
+
</details>
|
|
71
62
|
|
|
72
63
|
## No dia a dia
|
|
73
64
|
|
|
@@ -143,11 +134,11 @@ período: 24h — 1 chamadas, 42.9k tokens, $0.52
|
|
|
143
134
|
TESTE-1 42.9k tok $0.52 (1 chamadas)
|
|
144
135
|
```
|
|
145
136
|
|
|
146
|
-
Se apareceu, está tudo validado de uma vez: daemon de pé, passthrough com sua autenticação intacta, extração de usage, atribuição por label e custo calculado. Abra `http://127.0.0.1:25519/dashboard` para ver no navegador. (Para testar os internos sem rede: `npm test` —
|
|
137
|
+
Se apareceu, está tudo validado de uma vez: daemon de pé, passthrough com sua autenticação intacta, extração de usage, atribuição por label e custo calculado. Abra `http://127.0.0.1:25519/dashboard` para ver no navegador. (Para testar os internos sem rede: `npm test` — 154 asserções contra um upstream fake.)
|
|
147
138
|
|
|
148
139
|
## Configurando cada TUI
|
|
149
140
|
|
|
150
|
-
O padrão é sempre o mesmo: trocar a base URL da ferramenta de `targetUrl` (a URL padrão do provedor) para `proxyURL/{target}`. Com um ticket no path (`proxyURL/{target}/PROJ-123`), todo o tráfego daquela configuração é atribuído ao ticket. `ttm setup <tui>` imprime o snippet da sua porta atual.
|
|
141
|
+
O padrão é sempre o mesmo: trocar a base URL da ferramenta de `targetUrl` (a URL padrão do provedor) para `proxyURL/{target}`. Com um ticket no path (`proxyURL/{target}/PROJ-123`), todo o tráfego daquela configuração é atribuído ao ticket. Para slug descritivo, use `proxyURL/{target}/-/meu-slug`. `ttm setup <tui>` imprime o snippet da sua porta atual.
|
|
151
142
|
|
|
152
143
|
### Claude Code
|
|
153
144
|
`targetUrl` padrão: `https://api.anthropic.com` — override por env var `ANTHROPIC_BASE_URL` (precisa existir **antes** do processo iniciar):
|
|
@@ -267,7 +258,7 @@ O modo padrão (backend proprietário do Copilot) não é rastreável.
|
|
|
267
258
|
|
|
268
259
|
Duas formas, combináveis:
|
|
269
260
|
|
|
270
|
-
1. **Na URL** (recomendado, por repo/branch): `http://127.0.0.1:25519/anthropic/PROJ-123` — tudo que passar por essa configuração cai no `PROJ-123`. Ideal via `.claude/settings.json` por repositório, ou um `opencode.json` por projeto.
|
|
261
|
+
1. **Na URL** (recomendado, por repo/branch): `http://127.0.0.1:25519/anthropic/PROJ-123` — tudo que passar por essa configuração cai no `PROJ-123`. Para um label que não é ticket, use `http://127.0.0.1:25519/anthropic/-/meu-slug`. Ideal via `.claude/settings.json` por repositório, ou um `opencode.json` por projeto.
|
|
271
262
|
2. **Label padrão global**: `ttm label PROJ-123` (ou tool MCP `ttm_set_label`, ou `/ttm:link PROJ-123` no Claude Code) — vale para todo tráfego **sem** label na URL, de qualquer TUI, até trocar. `ttm label none` limpa.
|
|
272
263
|
|
|
273
264
|
Chamadas sem nenhum label aparecem no dashboard como "Sem label" — nada se perde.
|
|
@@ -359,7 +350,7 @@ Arquivo: `~/.claude/plugins/data/token-trace-manager/config.json` (criado sob de
|
|
|
359
350
|
node test/e2e.mjs
|
|
360
351
|
```
|
|
361
352
|
|
|
362
|
-
Sobe um upstream fake + o daemon real e valida: passthrough de streaming e não-streaming nos 4 protocolos, labels por URL e por padrão, normalização de tokens (cache/reasoning), cálculo de custo, injeção+filtragem de `include_usage`, endpoints locais, dashboard, privacidade (
|
|
353
|
+
Sobe um upstream fake + o daemon real e valida: passthrough de streaming e não-streaming nos 4 protocolos, labels por URL e por padrão, normalização de tokens (cache/reasoning), cálculo de custo, injeção+filtragem de `include_usage`, endpoints locais, dashboard, privacidade (headers e segredos no body não persistem) e regressões adversariais (XSS via User-Agent, corrupção por `$'` no template, rotas herdadas de protótipo, paths hifenizados, erros de protocolo MCP). 150+ asserções, cobrindo também retenção/rollups, contexto repo/branch, relabel retroativo, sessionização de traces, backend JSON e o exportador OTel.
|
|
363
354
|
|
|
364
355
|
## Upgrade
|
|
365
356
|
|
package/bin/ttm.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { readConfig, writeConfig, setTarget, removeTarget, validateConfig, KNOWN
|
|
|
9
9
|
import { ensureDaemon, stopDaemon, daemonHealth, fetchDaemon } from '../lib/daemon-client.mjs'
|
|
10
10
|
import { linkTui, unlinkTui } from '../lib/link.mjs'
|
|
11
11
|
import { PRICING_LAST_UPDATED } from '../lib/pricing.mjs'
|
|
12
|
+
import { sinceToIso } from '../lib/report-data.mjs'
|
|
12
13
|
|
|
13
14
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
14
15
|
|
|
@@ -59,6 +60,14 @@ function fmtTok(n) {
|
|
|
59
60
|
|
|
60
61
|
const fmtUsd = n => '$' + (n || 0).toFixed(2)
|
|
61
62
|
|
|
63
|
+
function printableConfig(cfg) {
|
|
64
|
+
const out = JSON.parse(JSON.stringify(cfg))
|
|
65
|
+
if (out.otel?.headers) {
|
|
66
|
+
out.otel.headers = Object.fromEntries(Object.keys(out.otel.headers).map(k => [k, '[REDACTED]']))
|
|
67
|
+
}
|
|
68
|
+
return out
|
|
69
|
+
}
|
|
70
|
+
|
|
62
71
|
async function main() {
|
|
63
72
|
const [cmd, ...args] = process.argv.slice(2)
|
|
64
73
|
const cfg = readConfig()
|
|
@@ -131,9 +140,7 @@ async function main() {
|
|
|
131
140
|
case 'audit': {
|
|
132
141
|
const { runAudit } = await import('../lib/audit.mjs')
|
|
133
142
|
const since = flag(args, '--since', null)
|
|
134
|
-
const sinceIso = since
|
|
135
|
-
? new Date(Date.now() - parseInt(since, 10) * (since.endsWith('h') ? 3600000 : 86400000)).toISOString()
|
|
136
|
-
: null
|
|
143
|
+
const sinceIso = sinceToIso(since)
|
|
137
144
|
const r = await runAudit({ sinceIso })
|
|
138
145
|
console.log(`auditoria (${since ?? 'tudo'}) — ${r.eventCount} eventos\n`)
|
|
139
146
|
console.log(`[1] matemática interna (custo = tokens x taxas gravadas na hora da chamada)`)
|
|
@@ -262,7 +269,7 @@ async function main() {
|
|
|
262
269
|
console.log(`target "${name}" -> ${url} [protocolo: ${protocol}${envVar ? `, env: ${envVar}` : ''}]`)
|
|
263
270
|
return
|
|
264
271
|
}
|
|
265
|
-
console.log(JSON.stringify({ ...cfg, pricingLastUpdated: PRICING_LAST_UPDATED }, null, 2))
|
|
272
|
+
console.log(JSON.stringify(printableConfig({ ...cfg, pricingLastUpdated: PRICING_LAST_UPDATED }), null, 2))
|
|
266
273
|
return
|
|
267
274
|
}
|
|
268
275
|
|
package/commands/setup.md
CHANGED
|
@@ -3,7 +3,7 @@ description: Configura o Claude Code (ou mostra o snippet de outro TUI) para pas
|
|
|
3
3
|
argument-hint: "[claude-code | codex | opencode | gemini-cli | kilo | ...]"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
O objetivo é apontar a base URL do TUI para o proxy local (`http://127.0.0.1:<porta>/<target>[/<TICKET-123>]
|
|
6
|
+
O objetivo é apontar a base URL do TUI para o proxy local (`http://127.0.0.1:<porta>/<target>[/<TICKET-123>]`; para slug descritivo use `/<target>/-/<slug>`). Chame `ttm_status` para obter a porta e os targets atuais.
|
|
7
7
|
|
|
8
8
|
**Se `$ARGUMENTS` for vazio ou `claude-code`** — o TUI é o próprio Claude Code:
|
|
9
9
|
1. Explique que a variável `ANTHROPIC_BASE_URL` precisa estar definida ANTES do Claude Code iniciar, então a mudança só vale para sessões novas.
|
package/lib/daemon-client.mjs
CHANGED
|
@@ -50,7 +50,10 @@ export async function ensureDaemon() {
|
|
|
50
50
|
// Daemon de uma versão antiga ainda de pé após upgrade: template/dados
|
|
51
51
|
// ficariam inconsistentes — reinicia com o código novo automaticamente.
|
|
52
52
|
if (health) {
|
|
53
|
-
await stopDaemon()
|
|
53
|
+
const stopped = await stopDaemon()
|
|
54
|
+
if (!stopped.stopped || stopped.graceful === false) {
|
|
55
|
+
throw new Error(`daemon antigo (pid ${health.pid}) não parou com segurança: ${stopped.reason ?? 'timeout aguardando saída'}`)
|
|
56
|
+
}
|
|
54
57
|
const h = await spawnDaemon()
|
|
55
58
|
return { started: true, restartedFrom: health.version, health: h }
|
|
56
59
|
}
|
package/lib/daemon.mjs
CHANGED
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
// storePayloads estiver ligado (default: ligado) — chaves de API nunca,
|
|
9
9
|
// em nenhuma hipótese, vão parar lá.
|
|
10
10
|
//
|
|
11
|
-
// Roteamento:
|
|
12
|
-
// target
|
|
13
|
-
// label
|
|
14
|
-
//
|
|
11
|
+
// Roteamento:
|
|
12
|
+
// http://127.0.0.1:PORT/{target}/{TICKET-123}/{caminho-upstream}
|
|
13
|
+
// http://127.0.0.1:PORT/{target}/-/{label-slug}/{caminho-upstream}
|
|
14
|
+
// O ticket curto continua conveniente; labels descritivos usam "/-/" para não
|
|
15
|
+
// roubar segmentos reais do path do provedor (ex: /api-v2/chat/completions).
|
|
15
16
|
|
|
16
17
|
import fs from 'node:fs'
|
|
17
18
|
import http from 'node:http'
|
|
@@ -32,12 +33,10 @@ const VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.j
|
|
|
32
33
|
|
|
33
34
|
// Label = qualquer identificador de tarefa: ticket real (PROJ-123) OU slug
|
|
34
35
|
// 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
36
|
const LABEL_RE = /^[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)+$/
|
|
37
|
+
const TICKET_LABEL_RE = /^[A-Za-z][A-Za-z0-9]*-\d+$/
|
|
40
38
|
const LABEL_MAX_LEN = 120
|
|
39
|
+
const SECRET_KEY_RE = /(?:api[_-]?key|access[_-]?token|auth(?:orization)?|auth[_-]?token|bearer[_-]?token|client[_-]?secret|refresh[_-]?token|id[_-]?token|password|secret|credential)/i
|
|
41
40
|
const HOP_BY_HOP = new Set([
|
|
42
41
|
'connection',
|
|
43
42
|
'keep-alive',
|
|
@@ -53,6 +52,67 @@ const MAX_JSON_CAPTURE = 32 * 1024 * 1024
|
|
|
53
52
|
|
|
54
53
|
const startedAt = Date.now()
|
|
55
54
|
|
|
55
|
+
function isValidLabel(label) {
|
|
56
|
+
return typeof label === 'string' && label.length > 0 && label.length <= LABEL_MAX_LEN && LABEL_RE.test(label)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function invalidLabelMessage(label, action = 'inválido') {
|
|
60
|
+
return `label "${label ?? ''}" ${action} (ex: PROJ-123 ou /-/um-slug-descritivo, até ${LABEL_MAX_LEN} caracteres)`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseProxyRoute(segments, search) {
|
|
64
|
+
let label = null
|
|
65
|
+
let restIdx = 1
|
|
66
|
+
const second = segments[1]
|
|
67
|
+
|
|
68
|
+
if (second === '-') {
|
|
69
|
+
const explicit = segments[2]
|
|
70
|
+
if (!isValidLabel(explicit)) return { error: invalidLabelMessage(explicit) }
|
|
71
|
+
label = explicit.toUpperCase()
|
|
72
|
+
restIdx = 3
|
|
73
|
+
} else if (second && TICKET_LABEL_RE.test(second)) {
|
|
74
|
+
if (second.length > LABEL_MAX_LEN) return { error: invalidLabelMessage(second) }
|
|
75
|
+
label = second.toUpperCase()
|
|
76
|
+
restIdx = 2
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
label,
|
|
81
|
+
upstreamPath: '/' + segments.slice(restIdx).join('/') + search,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function redactRawSecrets(text) {
|
|
86
|
+
return text
|
|
87
|
+
.replace(/("(?:api[_-]?key|access[_-]?token|auth(?:orization)?|auth[_-]?token|client[_-]?secret|password|secret)"\s*:\s*")([^"]*)(")/gi, '$1[REDACTED]$3')
|
|
88
|
+
.replace(/\b(api[_-]?key|access[_-]?token|authorization|auth[_-]?token|client[_-]?secret|password|secret)\s*[:=]\s*([^\s&;,]+)/gi, '$1=[REDACTED]')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function redactSecrets(value, seen = new WeakSet()) {
|
|
92
|
+
if (!value || typeof value !== 'object') return value
|
|
93
|
+
if (seen.has(value)) return '[Circular]'
|
|
94
|
+
seen.add(value)
|
|
95
|
+
if (Array.isArray(value)) return value.map(v => redactSecrets(v, seen))
|
|
96
|
+
|
|
97
|
+
const out = {}
|
|
98
|
+
for (const [k, v] of Object.entries(value)) {
|
|
99
|
+
out[k] = SECRET_KEY_RE.test(k) ? '[REDACTED]' : redactSecrets(v, seen)
|
|
100
|
+
}
|
|
101
|
+
return out
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function payloadRequestFromBody(requestBody) {
|
|
105
|
+
if (requestBody.length > 4 * 1024 * 1024) {
|
|
106
|
+
return { _truncado: true, _bytes: requestBody.length }
|
|
107
|
+
}
|
|
108
|
+
const text = requestBody.toString('utf8')
|
|
109
|
+
try {
|
|
110
|
+
return redactSecrets(JSON.parse(text))
|
|
111
|
+
} catch {
|
|
112
|
+
return { _raw: redactRawSecrets(text).slice(0, 1024 * 1024) }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
56
116
|
function sendJson(res, status, obj) {
|
|
57
117
|
const body = JSON.stringify(obj)
|
|
58
118
|
res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) })
|
|
@@ -101,8 +161,8 @@ async function handleLocal(req, res, pathname, searchParams) {
|
|
|
101
161
|
})
|
|
102
162
|
}
|
|
103
163
|
const label = payload.label ?? null
|
|
104
|
-
if (label !== null &&
|
|
105
|
-
return sendJson(res, 400, { error:
|
|
164
|
+
if (label !== null && !isValidLabel(label)) {
|
|
165
|
+
return sendJson(res, 400, { error: invalidLabelMessage(label) })
|
|
106
166
|
}
|
|
107
167
|
const normalized = label ? label.toUpperCase() : null
|
|
108
168
|
await setKv('default_label', normalized)
|
|
@@ -227,7 +287,7 @@ async function handleLocal(req, res, pathname, searchParams) {
|
|
|
227
287
|
let label = payload.label ?? null
|
|
228
288
|
if (label !== null) {
|
|
229
289
|
if (label.length > LABEL_MAX_LEN || !LABEL_RE.test(label)) {
|
|
230
|
-
return sendJson(res, 400, { error:
|
|
290
|
+
return sendJson(res, 400, { error: invalidLabelMessage(label, 'inválido para deleção') })
|
|
231
291
|
}
|
|
232
292
|
label = label.toUpperCase()
|
|
233
293
|
}
|
|
@@ -469,16 +529,6 @@ async function proxyRequest(req, res, cfg, targetName, label, upstreamPath) {
|
|
|
469
529
|
if (!storePayloads || !id) return
|
|
470
530
|
// Conteúdo da chamada para inspeção (segue a retenção; SEM
|
|
471
531
|
// 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
532
|
fs.writeFileSync(
|
|
483
533
|
payloadPath(id),
|
|
484
534
|
JSON.stringify({
|
|
@@ -486,7 +536,7 @@ async function proxyRequest(req, res, cfg, targetName, label, upstreamPath) {
|
|
|
486
536
|
ts: evRecord.ts ?? new Date().toISOString(),
|
|
487
537
|
label: evRecord.label,
|
|
488
538
|
model: evRecord.model,
|
|
489
|
-
request,
|
|
539
|
+
request: payloadRequestFromBody(requestBody),
|
|
490
540
|
response: { text: u.text || null },
|
|
491
541
|
}),
|
|
492
542
|
)
|
|
@@ -535,14 +585,9 @@ export function createDaemonServer() {
|
|
|
535
585
|
return await handleLocal(req, res, parsed.pathname, parsed.searchParams)
|
|
536
586
|
}
|
|
537
587
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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)
|
|
588
|
+
const route = parseProxyRoute(segments, parsed.search)
|
|
589
|
+
if (route.error) return sendJson(res, 400, { error: route.error })
|
|
590
|
+
await proxyRequest(req, res, cfg, first, route.label, route.upstreamPath)
|
|
546
591
|
} catch (err) {
|
|
547
592
|
if (!res.headersSent) sendJson(res, 500, { error: err.message })
|
|
548
593
|
else res.destroy()
|
package/lib/db.mjs
CHANGED
|
@@ -58,6 +58,8 @@ CREATE TABLE IF NOT EXISTS usage_events (
|
|
|
58
58
|
);
|
|
59
59
|
CREATE INDEX IF NOT EXISTS idx_usage_events_ts ON usage_events(ts);
|
|
60
60
|
CREATE INDEX IF NOT EXISTS idx_usage_events_label ON usage_events(label);
|
|
61
|
+
CREATE INDEX IF NOT EXISTS idx_usage_events_target ON usage_events(target);
|
|
62
|
+
CREATE INDEX IF NOT EXISTS idx_usage_events_label_ts ON usage_events(label, ts);
|
|
61
63
|
|
|
62
64
|
CREATE TABLE IF NOT EXISTS kv (
|
|
63
65
|
key TEXT PRIMARY KEY,
|
|
@@ -83,6 +85,8 @@ CREATE TABLE IF NOT EXISTS daily_rollups (
|
|
|
83
85
|
events INTEGER DEFAULT 0,
|
|
84
86
|
PRIMARY KEY (day, label, target, model, client)
|
|
85
87
|
);
|
|
88
|
+
CREATE INDEX IF NOT EXISTS idx_daily_rollups_day ON daily_rollups(day);
|
|
89
|
+
CREATE INDEX IF NOT EXISTS idx_daily_rollups_label_day ON daily_rollups(label, day);
|
|
86
90
|
`
|
|
87
91
|
|
|
88
92
|
// Migração aditiva: bases criadas por versões antigas ganham as colunas novas.
|
|
@@ -110,26 +114,38 @@ function migrate(db) {
|
|
|
110
114
|
|
|
111
115
|
let backend = null // { kind: 'sqlite', db } | { kind: 'json', file }
|
|
112
116
|
|
|
117
|
+
function jsonBackend() {
|
|
118
|
+
const file = path.join(dataDir(), 'ttm-events.json')
|
|
119
|
+
if (!fs.existsSync(file)) {
|
|
120
|
+
fs.writeFileSync(file, JSON.stringify({ nextId: 1, events: [], kv: {} }, null, 2))
|
|
121
|
+
}
|
|
122
|
+
backend = { kind: 'json', file }
|
|
123
|
+
return backend
|
|
124
|
+
}
|
|
125
|
+
|
|
113
126
|
async function getBackend() {
|
|
114
127
|
if (backend) return backend
|
|
128
|
+
if (process.env.TTM_FORCE_JSON_BACKEND === '1') return jsonBackend()
|
|
129
|
+
|
|
130
|
+
let DatabaseSync
|
|
131
|
+
try {
|
|
132
|
+
;({ DatabaseSync } = await import('node:sqlite'))
|
|
133
|
+
} catch (err) {
|
|
134
|
+
// Fallback JSON existe só quando o runtime não oferece node:sqlite.
|
|
135
|
+
if (err?.code === 'ERR_UNKNOWN_BUILTIN_MODULE' || /node:sqlite/.test(err?.message || '')) return jsonBackend()
|
|
136
|
+
throw err
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const db = new DatabaseSync(path.join(dataDir(), 'ttm.sqlite'))
|
|
115
140
|
try {
|
|
116
|
-
|
|
117
|
-
const db = new DatabaseSync(path.join(dataDir(), 'ttm.sqlite'))
|
|
141
|
+
db.exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000')
|
|
118
142
|
db.exec(SCHEMA)
|
|
119
143
|
migrate(db)
|
|
120
|
-
backend = { kind: 'sqlite', db }
|
|
121
144
|
} catch (err) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (err?.code !== 'ERR_UNKNOWN_BUILTIN_MODULE' && !/node:sqlite/.test(err?.message || '')) {
|
|
125
|
-
process.stderr.write(`ttm: sqlite indisponível (${err.message}) — usando fallback JSON\n`)
|
|
126
|
-
}
|
|
127
|
-
const file = path.join(dataDir(), 'ttm-events.json')
|
|
128
|
-
if (!fs.existsSync(file)) {
|
|
129
|
-
fs.writeFileSync(file, JSON.stringify({ nextId: 1, events: [], kv: {} }, null, 2))
|
|
130
|
-
}
|
|
131
|
-
backend = { kind: 'json', file }
|
|
145
|
+
try { db.close() } catch {}
|
|
146
|
+
throw new Error(`sqlite falhou ao inicializar/migrar: ${err.message}`)
|
|
132
147
|
}
|
|
148
|
+
backend = { kind: 'sqlite', db }
|
|
133
149
|
return backend
|
|
134
150
|
}
|
|
135
151
|
|
|
@@ -354,12 +370,22 @@ export async function listRollups({ sinceDay, label } = {}) {
|
|
|
354
370
|
const b = await getBackend()
|
|
355
371
|
let rows
|
|
356
372
|
if (b.kind === 'sqlite') {
|
|
357
|
-
|
|
373
|
+
const where = []
|
|
374
|
+
const params = []
|
|
375
|
+
if (sinceDay) {
|
|
376
|
+
where.push('day >= ?')
|
|
377
|
+
params.push(sinceDay)
|
|
378
|
+
}
|
|
379
|
+
if (label) {
|
|
380
|
+
where.push('label = ?')
|
|
381
|
+
params.push(label)
|
|
382
|
+
}
|
|
383
|
+
rows = b.db.prepare('SELECT * FROM daily_rollups' + (where.length ? ' WHERE ' + where.join(' AND ') : '')).all(...params)
|
|
358
384
|
} else {
|
|
359
385
|
rows = Object.values(readJson(b.file).rollups || {})
|
|
360
386
|
}
|
|
361
|
-
if (sinceDay) rows = rows.filter(r => r.day >= sinceDay)
|
|
362
|
-
if (label) rows = rows.filter(r => (r.label || '') === label) // '' interno = sem label; filtro '' não chega aqui (guard truthy)
|
|
387
|
+
if (b.kind !== 'sqlite' && sinceDay) rows = rows.filter(r => r.day >= sinceDay)
|
|
388
|
+
if (b.kind !== 'sqlite' && label) rows = rows.filter(r => (r.label || '') === label) // '' interno = sem label; filtro '' não chega aqui (guard truthy)
|
|
363
389
|
return rows
|
|
364
390
|
}
|
|
365
391
|
|
package/lib/mcp-server.mjs
CHANGED
|
@@ -5,11 +5,16 @@
|
|
|
5
5
|
// OpenCode (opencode.json "mcp"), etc. Garante o daemon do proxy de pé.
|
|
6
6
|
|
|
7
7
|
import { execFileSync } from 'node:child_process'
|
|
8
|
+
import fs from 'node:fs'
|
|
8
9
|
import path from 'node:path'
|
|
9
10
|
import readline from 'node:readline'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
10
12
|
import { readConfig } from './config.mjs'
|
|
11
13
|
import { ensureDaemon, daemonHealth, fetchDaemon } from './daemon-client.mjs'
|
|
12
14
|
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
16
|
+
const VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version
|
|
17
|
+
|
|
13
18
|
// O servidor MCP roda DENTRO do ambiente do TUI (cwd = projeto do usuário) —
|
|
14
19
|
// é daqui que repo/branch são detectados e viram tags nos traces.
|
|
15
20
|
function detectContext() {
|
|
@@ -29,7 +34,7 @@ function detectContext() {
|
|
|
29
34
|
|
|
30
35
|
const TICKET_RE = /[A-Z][A-Z0-9]+-\d+/i
|
|
31
36
|
|
|
32
|
-
const SERVER_INFO = { name: 'token-trace-manager', version:
|
|
37
|
+
const SERVER_INFO = { name: 'token-trace-manager', version: VERSION }
|
|
33
38
|
// Negociação (spec): se a versão pedida está na lista, ecoa; senão responde
|
|
34
39
|
// com a nossa mais recente (não é erro — o client decide se desconecta).
|
|
35
40
|
const SUPPORTED_PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2024-11-05']
|
|
@@ -153,6 +158,7 @@ async function callTool(name, args) {
|
|
|
153
158
|
}
|
|
154
159
|
case 'ttm_set_label': {
|
|
155
160
|
await ensureDaemon()
|
|
161
|
+
if (!Object.hasOwn(args, 'label')) throw new Error('label é obrigatório; passe string ou null para limpar')
|
|
156
162
|
const r = await fetchDaemon('/api/label', { method: 'POST', body: { label: args.label ?? null } })
|
|
157
163
|
return { defaultLabel: r.defaultLabel }
|
|
158
164
|
}
|
package/lib/report-data.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { listUsageEvents, listRollups } from './db.mjs'
|
|
2
2
|
|
|
3
|
-
function sinceToIso(since) {
|
|
3
|
+
export function sinceToIso(since) {
|
|
4
4
|
if (!since || since === 'all') return null
|
|
5
5
|
if (/^\d{4}-\d{2}-\d{2}/.test(since)) return since
|
|
6
6
|
const m = /^(\d+)([dh])$/.exec(since)
|
|
@@ -10,6 +10,7 @@ function sinceToIso(since) {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export async function buildReport({ since = '30d', label = null } = {}) {
|
|
13
|
+
since = since ?? '30d'
|
|
13
14
|
if (label) label = label.toUpperCase() // labels são armazenados em maiúsculas
|
|
14
15
|
const sinceIso = sinceToIso(since)
|
|
15
16
|
const rows = await listUsageEvents({ since: sinceIso, label })
|
package/lib/setup-snippets.mjs
CHANGED
|
@@ -21,7 +21,8 @@ export function setupSnippet(tui, cfg) {
|
|
|
21
21
|
Para desfazer: ttm unlink claude-code
|
|
22
22
|
|
|
23
23
|
Manual (sem auto-link): setar ANTHROPIC_BASE_URL=${P}/anthropic
|
|
24
|
-
(ou ${P}/anthropic/PROJ-123 p/ ticket embutido
|
|
24
|
+
(ou ${P}/anthropic/PROJ-123 p/ ticket embutido; para slug descritivo,
|
|
25
|
+
use ${P}/anthropic/-/meu-slug). Apontar pra host !=
|
|
25
26
|
api.anthropic.com desativa o Remote Control do Claude Code (v2.1.196+).
|
|
26
27
|
Auth continua passando intacta.`,
|
|
27
28
|
|
|
@@ -41,6 +42,7 @@ export function setupSnippet(tui, cfg) {
|
|
|
41
42
|
wire_api = "responses"
|
|
42
43
|
env_key = "OPENAI_API_KEY"
|
|
43
44
|
(com ticket: base_url = "${P}/openai/PROJ-123/v1")
|
|
45
|
+
(com slug: base_url = "${P}/openai/-/meu-slug/v1")
|
|
44
46
|
Prefira model_providers a openai_base_url/OPENAI_BASE_URL — há releases do
|
|
45
47
|
Codex com bug que ignora essas formas (issue #16719).`,
|
|
46
48
|
|
|
@@ -59,7 +61,8 @@ export function setupSnippet(tui, cfg) {
|
|
|
59
61
|
"openai": { "options": { "baseURL": "${P}/openai/v1" } }
|
|
60
62
|
}
|
|
61
63
|
}
|
|
62
|
-
(com ticket: "baseURL": "${P}/anthropic/PROJ-123"
|
|
64
|
+
(com ticket: "baseURL": "${P}/anthropic/PROJ-123";
|
|
65
|
+
com slug: "baseURL": "${P}/anthropic/-/meu-slug")`,
|
|
63
66
|
|
|
64
67
|
'gemini-cli': `Gemini CLI (Google) — caminho primário: auto-link:
|
|
65
68
|
|
|
@@ -73,7 +76,8 @@ export function setupSnippet(tui, cfg) {
|
|
|
73
76
|
Manual: env vars (http:// permitido para 127.0.0.1):
|
|
74
77
|
export GEMINI_API_KEY=sua-chave
|
|
75
78
|
export GOOGLE_GEMINI_BASE_URL="${P}/gemini"
|
|
76
|
-
(com ticket: export GOOGLE_GEMINI_BASE_URL="${P}/gemini/PROJ-123"
|
|
79
|
+
(com ticket: export GOOGLE_GEMINI_BASE_URL="${P}/gemini/PROJ-123";
|
|
80
|
+
com slug: export GOOGLE_GEMINI_BASE_URL="${P}/gemini/-/meu-slug")
|
|
77
81
|
Limitação: só o modo API key é rastreável. No login OAuth pessoal o Gemini
|
|
78
82
|
CLI usa o backend interno Code Assist, que essa variável não afeta.`,
|
|
79
83
|
|
|
@@ -178,7 +182,7 @@ export function setupSnippet(tui, cfg) {
|
|
|
178
182
|
return (
|
|
179
183
|
`TUIs suportados: ${Object.keys(snippets).join(', ')}\n\n` +
|
|
180
184
|
`Uso: ttm setup <tui>\n` +
|
|
181
|
-
`Padrão geral: aponte a base URL da ferramenta para ${P}/{target}[/{TICKET-123}]\n` +
|
|
185
|
+
`Padrão geral: aponte a base URL da ferramenta para ${P}/{target}[/{TICKET-123}] ou ${P}/{target}/-/{slug}\n` +
|
|
182
186
|
`targets: ${Object.keys(cfg.targets).join(', ')}`
|
|
183
187
|
)
|
|
184
188
|
}
|
package/lib/tui-config.mjs
CHANGED
|
@@ -100,6 +100,20 @@ function tomlTopLevelKey(text, key) {
|
|
|
100
100
|
return null
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
function tomlSetTopLevelKey(text, key, value) {
|
|
104
|
+
const lines = text.split('\n')
|
|
105
|
+
let firstSection = lines.findIndex(line => /^\s*\[[^\]]+\]\s*$/.test(line))
|
|
106
|
+
if (firstSection === -1) firstSection = lines.length
|
|
107
|
+
for (let i = 0; i < firstSection; i++) {
|
|
108
|
+
if (new RegExp(`^\\s*${key}\\s*=`).test(lines[i])) {
|
|
109
|
+
lines[i] = `${key} = "${value}"`
|
|
110
|
+
return lines.join('\n')
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
lines.splice(firstSection, 0, `${key} = "${value}"`, '')
|
|
114
|
+
return lines.join('\n')
|
|
115
|
+
}
|
|
116
|
+
|
|
103
117
|
function tomlSectionKey(text, section, key) {
|
|
104
118
|
const sections = parseTomlSections(text)
|
|
105
119
|
const s = sections.find(x => x.header === section)
|
|
@@ -274,14 +288,14 @@ export function discoverTui(tui, { cwd = process.cwd() } = {}) {
|
|
|
274
288
|
tui,
|
|
275
289
|
baseUrl: base ?? null,
|
|
276
290
|
source: base ? 'config.toml' : 'default',
|
|
277
|
-
canWriteBack:
|
|
291
|
+
canWriteBack: true,
|
|
278
292
|
writeTarget: p,
|
|
279
293
|
repoWriteTarget: null,
|
|
280
294
|
envVar,
|
|
281
295
|
authEnv,
|
|
282
296
|
protocol: 'openai',
|
|
283
297
|
defaultUpstream: DEFAULT_UPSTREAM[tui],
|
|
284
|
-
tomlSection: section,
|
|
298
|
+
tomlSection: section ?? 'model_providers.ttm',
|
|
285
299
|
}
|
|
286
300
|
}
|
|
287
301
|
|
|
@@ -369,6 +383,12 @@ export function writeTuiBaseUrl(tui, newUrl, { writeTarget, cwd = process.cwd()
|
|
|
369
383
|
} catch {
|
|
370
384
|
txt = `model_provider = "ttm"\n\n[${section}]\nname = "OpenAI via ttm"\nwire_api = "responses"\nenv_key = "OPENAI_API_KEY"\n`
|
|
371
385
|
}
|
|
386
|
+
if (section === 'model_providers.ttm') {
|
|
387
|
+
txt = tomlSetTopLevelKey(txt, 'model_provider', 'ttm')
|
|
388
|
+
txt = tomlSetSectionKey(txt, section, 'name', 'OpenAI via ttm')
|
|
389
|
+
txt = tomlSetSectionKey(txt, section, 'wire_api', 'responses')
|
|
390
|
+
txt = tomlSetSectionKey(txt, section, 'env_key', 'OPENAI_API_KEY')
|
|
391
|
+
}
|
|
372
392
|
txt = tomlSetSectionKey(txt, section, 'base_url', newUrl)
|
|
373
393
|
fs.mkdirSync(path.dirname(writeTarget), { recursive: true })
|
|
374
394
|
fs.writeFileSync(writeTarget, txt)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "token-trace-manager",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Proxy local universal de rastreio de tokens de LLM para TUIs de código (Claude Code, Codex, OpenCode, Gemini CLI, Kilo...), com labels por tarefa, dashboard local e auto-link que preserva redirects prévios (z.ai/GLM/...).",
|