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,429 @@
|
|
|
1
|
+
// Discovery e rewrite da config de cada TUI, para o `ttm link <tui>`.
|
|
2
|
+
//
|
|
3
|
+
// Princípio: o ttm descobre a base URL que o TUI JÁ usa (env var ou arquivo
|
|
4
|
+
// de config), vira upstream dela, e reescreve a config do TUI pra apontar pro
|
|
5
|
+
// ttm — um comando, sem editar config.json à mão. Assim, quem já redireciona
|
|
6
|
+
// o Claude Code pra um compatível-anthropic da z.ai/GLM continua batendo lá,
|
|
7
|
+
// só que com contagem de tokens no meio.
|
|
8
|
+
//
|
|
9
|
+
// O protocolo é propriedade do TUI (dialeto que ele EMITE), não do upstream:
|
|
10
|
+
// mesmo apontando pra z.ai, o Claude Code continua emitindo Messages →
|
|
11
|
+
// protocol 'anthropic'. Isso resolve a incerteza de protocolo do redirect.
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs'
|
|
14
|
+
import os from 'node:os'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
|
|
17
|
+
export const AUTOLINKABLE_TUIS = ['claude-code', 'codex', 'opencode', 'gemini-cli']
|
|
18
|
+
|
|
19
|
+
// Default upstream quando o TUI não tem redirect prévio (aponta pro vendor
|
|
20
|
+
// real daquele TUI). Usado só pra registrar o target — não é batido em teste.
|
|
21
|
+
const DEFAULT_UPSTREAM = {
|
|
22
|
+
'claude-code': 'https://api.anthropic.com',
|
|
23
|
+
codex: 'https://api.openai.com',
|
|
24
|
+
opencode: 'https://api.anthropic.com', // opencode fala Anthropic por default
|
|
25
|
+
'gemini-cli': 'https://generativelanguage.googleapis.com',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// helpers de JSON settings (claude-code, opencode)
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
function readJsonFile(p) {
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'))
|
|
35
|
+
} catch {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeJsonAtomic(p, obj) {
|
|
41
|
+
fs.mkdirSync(path.dirname(p), { recursive: true })
|
|
42
|
+
const tmp = p + '.tmp'
|
|
43
|
+
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2))
|
|
44
|
+
try {
|
|
45
|
+
fs.renameSync(tmp, p)
|
|
46
|
+
} catch {
|
|
47
|
+
// Windows: rename sobre existente pode falhar (EPERM) — fallback
|
|
48
|
+
try { fs.unlinkSync(p) } catch {}
|
|
49
|
+
fs.renameSync(tmp, p)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function settingsEnvRead(filePath, key) {
|
|
54
|
+
const j = readJsonFile(filePath)
|
|
55
|
+
return j?.env?.[key] ?? null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function settingsEnvSet(filePath, key, value) {
|
|
59
|
+
const j = readJsonFile(filePath) ?? {}
|
|
60
|
+
if (!j.env || typeof j.env !== 'object') j.env = {}
|
|
61
|
+
j.env[key] = value
|
|
62
|
+
writeJsonAtomic(filePath, j)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function settingsEnvRemove(filePath, key) {
|
|
66
|
+
const j = readJsonFile(filePath)
|
|
67
|
+
if (!j?.env) return
|
|
68
|
+
delete j.env[key]
|
|
69
|
+
writeJsonAtomic(filePath, j)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// TOML mínimo (codex ~/.codex/config.toml) — só o necessário p/ base_url
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
function parseTomlSections(text) {
|
|
77
|
+
// Retorna [{ header, body, start }]; header '' = top-level.
|
|
78
|
+
const lines = text.split('\n')
|
|
79
|
+
const sections = []
|
|
80
|
+
let cur = { header: '', body: [], start: 0 }
|
|
81
|
+
sections.push(cur)
|
|
82
|
+
for (let i = 0; i < lines.length; i++) {
|
|
83
|
+
const line = lines[i]
|
|
84
|
+
const m = /^\s*\[([^\]]+)\]\s*$/.exec(line)
|
|
85
|
+
if (m) {
|
|
86
|
+
cur = { header: m[1], body: [], start: i }
|
|
87
|
+
sections.push(cur)
|
|
88
|
+
} else {
|
|
89
|
+
cur.body.push(line)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return sections
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function tomlTopLevelKey(text, key) {
|
|
96
|
+
for (const line of text.split('\n')) {
|
|
97
|
+
const m = new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"\\s*$`).exec(line)
|
|
98
|
+
if (m) return m[1]
|
|
99
|
+
}
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function tomlSectionKey(text, section, key) {
|
|
104
|
+
const sections = parseTomlSections(text)
|
|
105
|
+
const s = sections.find(x => x.header === section)
|
|
106
|
+
if (!s) return null
|
|
107
|
+
for (const line of s.body) {
|
|
108
|
+
const m = new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"\\s*$`).exec(line)
|
|
109
|
+
if (m) return m[1]
|
|
110
|
+
}
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function tomlSetSectionKey(text, section, key, value) {
|
|
115
|
+
const sections = parseTomlSections(text)
|
|
116
|
+
const s = sections.find(x => x.header === section)
|
|
117
|
+
if (!s) {
|
|
118
|
+
// cria a seção no fim
|
|
119
|
+
const prefix = text && !text.endsWith('\n') ? '\n' : ''
|
|
120
|
+
return text + `${prefix}[${section}]\n${key} = "${value}"\n`
|
|
121
|
+
}
|
|
122
|
+
// procura a linha do key dentro do body e substitui
|
|
123
|
+
const lines = text.split('\n')
|
|
124
|
+
let inSection = false
|
|
125
|
+
let sectionStart = -1
|
|
126
|
+
let keyLineIdx = -1
|
|
127
|
+
for (let i = 0; i < lines.length; i++) {
|
|
128
|
+
const m = /^\s*\[([^\]]+)\]\s*$/.exec(lines[i])
|
|
129
|
+
if (m) {
|
|
130
|
+
inSection = m[1] === section
|
|
131
|
+
if (inSection && sectionStart === -1) sectionStart = i
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
if (inSection) {
|
|
135
|
+
const km = new RegExp(`^(\\s*)${key}\\s*=\\s*".*"\\s*$`).exec(lines[i])
|
|
136
|
+
if (km) {
|
|
137
|
+
keyLineIdx = i
|
|
138
|
+
break
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (keyLineIdx >= 0) {
|
|
143
|
+
lines[keyLineIdx] = `${key} = "${value}"`
|
|
144
|
+
return lines.join('\n')
|
|
145
|
+
}
|
|
146
|
+
// insere logo após o header da seção
|
|
147
|
+
if (sectionStart >= 0) {
|
|
148
|
+
lines.splice(sectionStart + 1, 0, `${key} = "${value}"`)
|
|
149
|
+
return lines.join('\n')
|
|
150
|
+
}
|
|
151
|
+
return text
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function tomlRemoveSectionKey(text, section, key) {
|
|
155
|
+
const lines = text.split('\n')
|
|
156
|
+
let inSection = false
|
|
157
|
+
const out = []
|
|
158
|
+
for (const line of lines) {
|
|
159
|
+
const m = /^\s*\[([^\]]+)\]\s*$/.exec(line)
|
|
160
|
+
if (m) {
|
|
161
|
+
inSection = m[1] === section
|
|
162
|
+
out.push(line)
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
if (inSection && new RegExp(`^\\s*${key}\\s*=\\s*".*"\\s*$`).exec(line)) continue
|
|
166
|
+
out.push(line)
|
|
167
|
+
}
|
|
168
|
+
return out.join('\n')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Discovery por TUI
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
export function discoverTui(tui, { cwd = process.cwd() } = {}) {
|
|
176
|
+
if (!AUTOLINKABLE_TUIS.includes(tui)) {
|
|
177
|
+
return { supported: false, tui }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (tui === 'claude-code') {
|
|
181
|
+
const envVar = 'ANTHROPIC_BASE_URL'
|
|
182
|
+
const authEnv = process.env.ANTHROPIC_AUTH_TOKEN ? 'ANTHROPIC_AUTH_TOKEN' : 'ANTHROPIC_API_KEY'
|
|
183
|
+
const repoPath = path.join(cwd, '.claude', 'settings.json')
|
|
184
|
+
const globalPath = path.join(os.homedir(), '.claude', 'settings.json')
|
|
185
|
+
if (process.env[envVar]) {
|
|
186
|
+
return {
|
|
187
|
+
tui,
|
|
188
|
+
baseUrl: process.env[envVar],
|
|
189
|
+
source: 'env',
|
|
190
|
+
canWriteBack: false,
|
|
191
|
+
writeTarget: null,
|
|
192
|
+
repoWriteTarget: repoPath,
|
|
193
|
+
envVar,
|
|
194
|
+
authEnv,
|
|
195
|
+
protocol: 'anthropic',
|
|
196
|
+
defaultUpstream: DEFAULT_UPSTREAM[tui],
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const repoVal = settingsEnvRead(repoPath, envVar)
|
|
200
|
+
if (repoVal) {
|
|
201
|
+
return {
|
|
202
|
+
tui,
|
|
203
|
+
baseUrl: repoVal,
|
|
204
|
+
source: 'settings-repo',
|
|
205
|
+
canWriteBack: true,
|
|
206
|
+
writeTarget: repoPath,
|
|
207
|
+
repoWriteTarget: repoPath,
|
|
208
|
+
envVar,
|
|
209
|
+
authEnv,
|
|
210
|
+
protocol: 'anthropic',
|
|
211
|
+
defaultUpstream: DEFAULT_UPSTREAM[tui],
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const globalVal = settingsEnvRead(globalPath, envVar)
|
|
215
|
+
if (globalVal) {
|
|
216
|
+
return {
|
|
217
|
+
tui,
|
|
218
|
+
baseUrl: globalVal,
|
|
219
|
+
source: 'settings-global',
|
|
220
|
+
canWriteBack: true,
|
|
221
|
+
writeTarget: globalPath,
|
|
222
|
+
repoWriteTarget: repoPath,
|
|
223
|
+
envVar,
|
|
224
|
+
authEnv,
|
|
225
|
+
protocol: 'anthropic',
|
|
226
|
+
defaultUpstream: DEFAULT_UPSTREAM[tui],
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
tui,
|
|
231
|
+
baseUrl: null,
|
|
232
|
+
source: 'default',
|
|
233
|
+
canWriteBack: true,
|
|
234
|
+
writeTarget: globalPath,
|
|
235
|
+
repoWriteTarget: repoPath,
|
|
236
|
+
envVar,
|
|
237
|
+
authEnv,
|
|
238
|
+
protocol: 'anthropic',
|
|
239
|
+
defaultUpstream: DEFAULT_UPSTREAM[tui],
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (tui === 'codex') {
|
|
244
|
+
const p = path.join(os.homedir(), '.codex', 'config.toml')
|
|
245
|
+
const envVar = 'OPENAI_BASE_URL'
|
|
246
|
+
const authEnv = 'OPENAI_API_KEY'
|
|
247
|
+
// 1) env var (Codex honors OPENAI_BASE_URL em alguns setups)
|
|
248
|
+
if (process.env[envVar]) {
|
|
249
|
+
return {
|
|
250
|
+
tui, baseUrl: process.env[envVar], source: 'env', canWriteBack: false,
|
|
251
|
+
writeTarget: null, repoWriteTarget: null, envVar, authEnv,
|
|
252
|
+
protocol: 'openai', defaultUpstream: DEFAULT_UPSTREAM[tui],
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
let txt = ''
|
|
256
|
+
try {
|
|
257
|
+
txt = fs.readFileSync(p, 'utf8')
|
|
258
|
+
} catch {
|
|
259
|
+
return {
|
|
260
|
+
tui, baseUrl: null, source: 'default', canWriteBack: true,
|
|
261
|
+
writeTarget: p, repoWriteTarget: null, envVar, authEnv,
|
|
262
|
+
protocol: 'openai', defaultUpstream: DEFAULT_UPSTREAM[tui],
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const active = tomlTopLevelKey(txt, 'model_provider')
|
|
266
|
+
let section = active ? `model_providers.${active}` : null
|
|
267
|
+
if (!section) {
|
|
268
|
+
// primeira seção model_providers.* que aparecer
|
|
269
|
+
const m = /\[model_providers\.([^.\]]+)\]/.exec(txt)
|
|
270
|
+
section = m ? `model_providers.${m[1]}` : null
|
|
271
|
+
}
|
|
272
|
+
const base = section ? tomlSectionKey(txt, section, 'base_url') : null
|
|
273
|
+
return {
|
|
274
|
+
tui,
|
|
275
|
+
baseUrl: base ?? null,
|
|
276
|
+
source: base ? 'config.toml' : 'default',
|
|
277
|
+
canWriteBack: Boolean(section),
|
|
278
|
+
writeTarget: p,
|
|
279
|
+
repoWriteTarget: null,
|
|
280
|
+
envVar,
|
|
281
|
+
authEnv,
|
|
282
|
+
protocol: 'openai',
|
|
283
|
+
defaultUpstream: DEFAULT_UPSTREAM[tui],
|
|
284
|
+
tomlSection: section,
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (tui === 'opencode') {
|
|
289
|
+
const envVar = null
|
|
290
|
+
const authEnv = null
|
|
291
|
+
const repoPath = path.join(cwd, 'opencode.json')
|
|
292
|
+
const globalPath = path.join(os.homedir(), '.config', 'opencode', 'opencode.json')
|
|
293
|
+
const tryFile = (p) => {
|
|
294
|
+
const j = readJsonFile(p)
|
|
295
|
+
if (!j?.provider) return null
|
|
296
|
+
for (const [name, prov] of Object.entries(j.provider)) {
|
|
297
|
+
const base = prov?.options?.baseURL
|
|
298
|
+
if (base) return { name, base, file: p }
|
|
299
|
+
}
|
|
300
|
+
return null
|
|
301
|
+
}
|
|
302
|
+
const found = tryFile(repoPath) || tryFile(globalPath)
|
|
303
|
+
if (found) {
|
|
304
|
+
return {
|
|
305
|
+
tui,
|
|
306
|
+
baseUrl: found.base,
|
|
307
|
+
source: 'opencode.json',
|
|
308
|
+
canWriteBack: true,
|
|
309
|
+
writeTarget: found.file,
|
|
310
|
+
repoWriteTarget: repoPath,
|
|
311
|
+
envVar,
|
|
312
|
+
authEnv,
|
|
313
|
+
protocol: found.name === 'openai' ? 'openai' : 'anthropic',
|
|
314
|
+
defaultUpstream: DEFAULT_UPSTREAM[tui],
|
|
315
|
+
opencodeProvider: found.name,
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
tui,
|
|
320
|
+
baseUrl: null,
|
|
321
|
+
source: 'default',
|
|
322
|
+
canWriteBack: true,
|
|
323
|
+
writeTarget: repoPath,
|
|
324
|
+
repoWriteTarget: repoPath,
|
|
325
|
+
envVar,
|
|
326
|
+
authEnv,
|
|
327
|
+
protocol: 'anthropic',
|
|
328
|
+
defaultUpstream: DEFAULT_UPSTREAM[tui],
|
|
329
|
+
opencodeProvider: 'anthropic',
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (tui === 'gemini-cli') {
|
|
334
|
+
const envVar = 'GOOGLE_GEMINI_BASE_URL'
|
|
335
|
+
const authEnv = 'GEMINI_API_KEY'
|
|
336
|
+
const val = process.env[envVar]
|
|
337
|
+
return {
|
|
338
|
+
tui,
|
|
339
|
+
baseUrl: val ?? null,
|
|
340
|
+
source: val ? 'env' : 'default',
|
|
341
|
+
canWriteBack: false, // gemini-cli é só-env: ttm não pode persistir
|
|
342
|
+
writeTarget: null,
|
|
343
|
+
repoWriteTarget: null,
|
|
344
|
+
envVar,
|
|
345
|
+
authEnv,
|
|
346
|
+
protocol: 'gemini',
|
|
347
|
+
defaultUpstream: DEFAULT_UPSTREAM[tui],
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return { supported: false, tui }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// Rewrite (escrever nova base URL no TUI) — só quando canWriteBack
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
export function writeTuiBaseUrl(tui, newUrl, { writeTarget, cwd = process.cwd() } = {}) {
|
|
359
|
+
if (tui === 'claude-code') {
|
|
360
|
+
settingsEnvSet(writeTarget, 'ANTHROPIC_BASE_URL', newUrl)
|
|
361
|
+
return { ok: true, writeTarget }
|
|
362
|
+
}
|
|
363
|
+
if (tui === 'codex') {
|
|
364
|
+
const d = discoverTui(tui, { cwd })
|
|
365
|
+
const section = d.tomlSection || 'model_providers.ttm'
|
|
366
|
+
let txt = ''
|
|
367
|
+
try {
|
|
368
|
+
txt = fs.readFileSync(writeTarget, 'utf8')
|
|
369
|
+
} catch {
|
|
370
|
+
txt = `model_provider = "ttm"\n\n[${section}]\nname = "OpenAI via ttm"\nwire_api = "responses"\nenv_key = "OPENAI_API_KEY"\n`
|
|
371
|
+
}
|
|
372
|
+
txt = tomlSetSectionKey(txt, section, 'base_url', newUrl)
|
|
373
|
+
fs.mkdirSync(path.dirname(writeTarget), { recursive: true })
|
|
374
|
+
fs.writeFileSync(writeTarget, txt)
|
|
375
|
+
return { ok: true, writeTarget }
|
|
376
|
+
}
|
|
377
|
+
if (tui === 'opencode') {
|
|
378
|
+
const d = discoverTui(tui, { cwd })
|
|
379
|
+
const prov = d.opencodeProvider || 'anthropic'
|
|
380
|
+
const j = readJsonFile(writeTarget) ?? {}
|
|
381
|
+
if (!j.provider || typeof j.provider !== 'object') j.provider = {}
|
|
382
|
+
if (!j.provider[prov]) j.provider[prov] = { options: {} }
|
|
383
|
+
if (!j.provider[prov].options) j.provider[prov].options = {}
|
|
384
|
+
j.provider[prov].options.baseURL = newUrl
|
|
385
|
+
writeJsonAtomic(writeTarget, j)
|
|
386
|
+
return { ok: true, writeTarget, provider: prov }
|
|
387
|
+
}
|
|
388
|
+
return { ok: false, reason: 'TUI só-env (use o export impresso)' }
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function restoreTuiBaseUrl(tui, { original, source, writeTarget, cwd = process.cwd() } = {}) {
|
|
392
|
+
if (source === 'env' || source === 'default' && !writeTarget) {
|
|
393
|
+
return { ok: false, reason: 'fonte só-env/default — não dá pra restaurar via arquivo' }
|
|
394
|
+
}
|
|
395
|
+
if (tui === 'claude-code') {
|
|
396
|
+
if (original === null) settingsEnvRemove(writeTarget, 'ANTHROPIC_BASE_URL')
|
|
397
|
+
else settingsEnvSet(writeTarget, 'ANTHROPIC_BASE_URL', original)
|
|
398
|
+
return { ok: true, writeTarget }
|
|
399
|
+
}
|
|
400
|
+
if (tui === 'codex') {
|
|
401
|
+
const d = discoverTui(tui, { cwd })
|
|
402
|
+
const section = d.tomlSection
|
|
403
|
+
if (!section) return { ok: false, reason: 'seção do provider não encontrada' }
|
|
404
|
+
let txt = ''
|
|
405
|
+
try {
|
|
406
|
+
txt = fs.readFileSync(writeTarget, 'utf8')
|
|
407
|
+
} catch {
|
|
408
|
+
return { ok: false, reason: 'config.toml sumiu' }
|
|
409
|
+
}
|
|
410
|
+
if (original === null) txt = tomlRemoveSectionKey(txt, section, 'base_url')
|
|
411
|
+
else txt = tomlSetSectionKey(txt, section, 'base_url', original)
|
|
412
|
+
fs.writeFileSync(writeTarget, txt)
|
|
413
|
+
return { ok: true, writeTarget }
|
|
414
|
+
}
|
|
415
|
+
if (tui === 'opencode') {
|
|
416
|
+
const j = readJsonFile(writeTarget) ?? {}
|
|
417
|
+
const prov = j.provider ? Object.keys(j.provider)[0] : null
|
|
418
|
+
if (!prov) return { ok: false, reason: 'sem provider' }
|
|
419
|
+
if (original === null) {
|
|
420
|
+
delete j.provider[prov].options?.baseURL
|
|
421
|
+
} else {
|
|
422
|
+
if (!j.provider[prov].options) j.provider[prov].options = {}
|
|
423
|
+
j.provider[prov].options.baseURL = original
|
|
424
|
+
}
|
|
425
|
+
writeJsonAtomic(writeTarget, j)
|
|
426
|
+
return { ok: true, writeTarget, provider: prov }
|
|
427
|
+
}
|
|
428
|
+
return { ok: false, reason: 'TUI não-escritável' }
|
|
429
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "token-trace-manager",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
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/...).",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": {
|
|
9
|
+
"name": "Mpaape",
|
|
10
|
+
"url": "https://github.com/Mpaape"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/cloud104/token-trace-manager",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/cloud104/token-trace-manager.git"
|
|
16
|
+
},
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/cloud104/token-trace-manager/issues"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"tokens",
|
|
22
|
+
"llm",
|
|
23
|
+
"proxy",
|
|
24
|
+
"cost",
|
|
25
|
+
"dashboard",
|
|
26
|
+
"jira",
|
|
27
|
+
"mcp",
|
|
28
|
+
"claude-code",
|
|
29
|
+
"codex",
|
|
30
|
+
"opencode",
|
|
31
|
+
"gemini-cli",
|
|
32
|
+
"kilo",
|
|
33
|
+
"observability"
|
|
34
|
+
],
|
|
35
|
+
"bin": {
|
|
36
|
+
"ttm": "bin/ttm.mjs"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"bin",
|
|
40
|
+
"lib",
|
|
41
|
+
"commands",
|
|
42
|
+
"templates",
|
|
43
|
+
".claude-plugin",
|
|
44
|
+
".mcp.json",
|
|
45
|
+
"CHANGELOG.md",
|
|
46
|
+
"LICENSE",
|
|
47
|
+
"README.md"
|
|
48
|
+
],
|
|
49
|
+
"scripts": {
|
|
50
|
+
"test": "node test/e2e.mjs",
|
|
51
|
+
"prepublishOnly": "npm test"
|
|
52
|
+
},
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=22.5"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="pt-BR">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>ttm — config</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--page: #0d0d0d; --surface: #1a1a19; --ink: #f2f1ec; --ink-2: #c3c2b7; --muted: #898781;
|
|
10
|
+
--grid: #2c2c2a; --border: rgba(255,255,255,0.09);
|
|
11
|
+
--accent: #3987e5; --accent-dim: #86b6ef; --accent-wash: rgba(57,135,229,0.13);
|
|
12
|
+
--warning: #fab219; --good: #0ca30c; --err: #e66767;
|
|
13
|
+
--mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace;
|
|
14
|
+
--sans: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
15
|
+
}
|
|
16
|
+
* { box-sizing: border-box; }
|
|
17
|
+
html { color-scheme: dark; }
|
|
18
|
+
body {
|
|
19
|
+
margin: 0;
|
|
20
|
+
background:
|
|
21
|
+
repeating-linear-gradient(0deg, rgba(255,255,255,0.012) 0 1px, transparent 1px 3px),
|
|
22
|
+
var(--page);
|
|
23
|
+
color: var(--ink); font-family: var(--sans); font-size: 13px; line-height: 1.5;
|
|
24
|
+
}
|
|
25
|
+
.wrap { max-width: 860px; margin: 0 auto; padding: 0 28px 56px; }
|
|
26
|
+
.topbar { display: flex; align-items: center; justify-content: space-between; padding: 14px 0 12px; border-bottom: 1px solid var(--border); margin-bottom: 20px; }
|
|
27
|
+
.brand .mark { font-family: var(--mono); font-weight: 700; font-size: 15px; }
|
|
28
|
+
.brand .mark::before { content: "▮▯▮ "; color: var(--accent); letter-spacing: -0.12em; margin-right: 2px; }
|
|
29
|
+
.nav { display: flex; gap: 4px; }
|
|
30
|
+
.nav a { color: var(--muted); text-decoration: none; font-size: 12px; padding: 5px 12px; border-radius: 6px; }
|
|
31
|
+
.nav a:hover { color: var(--ink); background: rgba(255,255,255,0.05); }
|
|
32
|
+
.nav a.active { color: var(--accent-dim); background: var(--accent-wash); font-weight: 600; }
|
|
33
|
+
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 18px 20px; margin-bottom: 16px; }
|
|
34
|
+
.card h2 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.13em; color: var(--muted); margin: 0 0 12px; font-weight: 600; }
|
|
35
|
+
p { color: var(--ink-2); margin: 6px 0; }
|
|
36
|
+
.hint { color: var(--muted); font-size: 12px; }
|
|
37
|
+
input[type="number"] {
|
|
38
|
+
background: var(--page); border: 1px solid var(--border); border-radius: 7px;
|
|
39
|
+
color: var(--ink); padding: 7px 10px; font-size: 13px; width: 110px; font-family: var(--mono);
|
|
40
|
+
}
|
|
41
|
+
input:focus { outline: none; border-color: var(--accent); }
|
|
42
|
+
button {
|
|
43
|
+
background: var(--accent-wash); border: 1px solid rgba(57,135,229,0.5); border-radius: 7px;
|
|
44
|
+
color: var(--accent-dim); padding: 7px 16px; font-size: 12.5px; cursor: pointer; font-family: var(--sans);
|
|
45
|
+
}
|
|
46
|
+
button:hover { background: rgba(57,135,229,0.22); color: var(--ink); }
|
|
47
|
+
button.danger { background: rgba(230,103,103,0.1); border-color: rgba(230,103,103,0.45); color: var(--err); }
|
|
48
|
+
button.danger:hover { background: rgba(230,103,103,0.2); }
|
|
49
|
+
button.ghost { background: none; border-color: var(--border); color: var(--ink-2); }
|
|
50
|
+
.row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 10px 0; }
|
|
51
|
+
.fb { font-size: 12px; font-family: var(--mono); }
|
|
52
|
+
.fb.ok { color: var(--good); }
|
|
53
|
+
.fb.err { color: var(--err); }
|
|
54
|
+
dl { display: grid; grid-template-columns: 190px 1fr; gap: 6px 14px; margin: 0; font-size: 12.5px; }
|
|
55
|
+
dt { color: var(--muted); }
|
|
56
|
+
dd { margin: 0; font-family: var(--mono); color: var(--ink-2); word-break: break-all; }
|
|
57
|
+
</style>
|
|
58
|
+
</head>
|
|
59
|
+
<body>
|
|
60
|
+
<div class="wrap">
|
|
61
|
+
<div class="topbar">
|
|
62
|
+
<div class="brand"><span class="mark">ttm</span></div>
|
|
63
|
+
<nav class="nav">
|
|
64
|
+
<a href="/dashboard">Visão geral</a>
|
|
65
|
+
<a href="/traces">Traces</a>
|
|
66
|
+
<a href="/config" class="active">Config</a>
|
|
67
|
+
</nav>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div class="card">
|
|
71
|
+
<h2>Retenção dos traces</h2>
|
|
72
|
+
<p>Traces detalhados (mensagem a mensagem) são mantidos pelo período abaixo. Ao expirar, cada trace é <strong>somado no histórico diário por tarefa</strong> e só então apagado — o total pro seu ticket nunca se perde, só o detalhe.</p>
|
|
73
|
+
<div class="row">
|
|
74
|
+
<label for="ret">manter por</label>
|
|
75
|
+
<input type="number" id="ret" min="0" max="8760" step="1" />
|
|
76
|
+
<span class="hint">horas (0 = nunca apagar)</span>
|
|
77
|
+
<button id="save-ret">salvar</button>
|
|
78
|
+
<span class="fb" id="ret-fb"></span>
|
|
79
|
+
</div>
|
|
80
|
+
<div class="row">
|
|
81
|
+
<button class="ghost" id="purge-now">rodar limpeza da retenção agora</button>
|
|
82
|
+
<span class="fb" id="purge-fb"></span>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<div class="card">
|
|
87
|
+
<h2>Conteúdo das chamadas</h2>
|
|
88
|
+
<p>Com isto ligado, request/response de cada chamada ficam em JSONs locais (<code>payloads/</code>) e a página Traces mostra o que houve dentro da sessão, mensagem a mensagem. Seguem a <strong>mesma retenção</strong> dos traces. Chaves de API nunca são gravadas.</p>
|
|
89
|
+
<div class="row">
|
|
90
|
+
<label><input type="checkbox" id="store-payloads" /> armazenar conteúdo das chamadas</label>
|
|
91
|
+
<span class="fb" id="sp-fb"></span>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div class="card">
|
|
96
|
+
<h2>Apagar dados</h2>
|
|
97
|
+
<p class="hint">Ações imediatas e irreversíveis — o proxy continua funcionando normalmente depois.</p>
|
|
98
|
+
<div class="row">
|
|
99
|
+
<button class="danger" id="del-events">apagar todos os traces (mantém o histórico por tarefa)</button>
|
|
100
|
+
<button class="danger" id="del-everything">apagar TUDO (traces + histórico)</button>
|
|
101
|
+
<span class="fb" id="del-fb"></span>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div class="card">
|
|
106
|
+
<h2>Resumo da configuração</h2>
|
|
107
|
+
<dl id="summary"></dl>
|
|
108
|
+
<p class="hint" style="margin-top:12px">Arquivo: <code>~/.claude/plugins/data/token-trace-manager/config.json</code> — porta, targets, pricing.override.json e export OTel/Langfuse são editados lá (ver README).</p>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<script id="report-data" type="application/json">"__REPORT_DATA__"</script>
|
|
113
|
+
<script>
|
|
114
|
+
const cfg = JSON.parse(document.getElementById('report-data').textContent)
|
|
115
|
+
|
|
116
|
+
document.getElementById('ret').value = cfg.retentionHours
|
|
117
|
+
const sp = document.getElementById('store-payloads')
|
|
118
|
+
sp.checked = cfg.storePayloads
|
|
119
|
+
sp.addEventListener('change', async () => {
|
|
120
|
+
try {
|
|
121
|
+
const r = await post('/api/settings', { storePayloads: sp.checked })
|
|
122
|
+
fb('sp-fb', r.storePayloads ? 'ligado' : 'desligado — novos payloads não serão gravados', true)
|
|
123
|
+
} catch (e) {
|
|
124
|
+
sp.checked = !sp.checked
|
|
125
|
+
fb('sp-fb', e.message, false)
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const dl = document.getElementById('summary')
|
|
130
|
+
const rows = [
|
|
131
|
+
['versão', 'v' + cfg.version],
|
|
132
|
+
['porta', String(cfg.port) + ' (só 127.0.0.1)'],
|
|
133
|
+
['retenção de traces', cfg.retentionHours === 0 ? 'nunca apagar' : cfg.retentionHours + 'h'],
|
|
134
|
+
['label padrão', cfg.defaultLabel ?? '(nenhum)'],
|
|
135
|
+
['conteúdo das chamadas', cfg.storePayloads ? 'armazenado (payloads/, segue a retenção)' : 'desligado'],
|
|
136
|
+
['injeção include_usage', cfg.injectUsage ? 'ligada' : 'desligada'],
|
|
137
|
+
['export OTel/Langfuse', cfg.otelOn ? 'ligado' : 'desligado (nenhum byte sai da máquina)'],
|
|
138
|
+
['targets', Object.entries(cfg.targets).map(([k, t]) => `/${k} → ${t.targetUrl}`).join('\n')],
|
|
139
|
+
]
|
|
140
|
+
for (const [k, v] of rows) {
|
|
141
|
+
const dt = document.createElement('dt')
|
|
142
|
+
dt.textContent = k
|
|
143
|
+
const dd = document.createElement('dd')
|
|
144
|
+
dd.textContent = v
|
|
145
|
+
dd.style.whiteSpace = 'pre-line'
|
|
146
|
+
dl.append(dt, dd)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function fb(id, msg, ok) {
|
|
150
|
+
const el = document.getElementById(id)
|
|
151
|
+
el.textContent = msg
|
|
152
|
+
el.className = 'fb ' + (ok ? 'ok' : 'err')
|
|
153
|
+
setTimeout(() => (el.textContent = ''), 4000)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function post(path, body) {
|
|
157
|
+
const res = await fetch(path, {
|
|
158
|
+
method: 'POST',
|
|
159
|
+
headers: { 'content-type': 'application/json' },
|
|
160
|
+
body: JSON.stringify(body),
|
|
161
|
+
})
|
|
162
|
+
const json = await res.json()
|
|
163
|
+
if (!res.ok) throw new Error(json.error || 'HTTP ' + res.status)
|
|
164
|
+
return json
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
document.getElementById('save-ret').addEventListener('click', async () => {
|
|
168
|
+
const hours = Number(document.getElementById('ret').value)
|
|
169
|
+
try {
|
|
170
|
+
const r = await post('/api/retention', { hours })
|
|
171
|
+
fb('ret-fb', r.retentionHours === 0 ? 'salvo: nunca apagar' : `salvo: ${r.retentionHours}h`, true)
|
|
172
|
+
} catch (e) {
|
|
173
|
+
fb('ret-fb', e.message, false)
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
document.getElementById('purge-now').addEventListener('click', async () => {
|
|
178
|
+
try {
|
|
179
|
+
const r = await post('/api/purge', { mode: 'expired' })
|
|
180
|
+
fb('purge-fb', r.note ?? `${r.purged} trace(s) agregados e apagados`, true)
|
|
181
|
+
} catch (e) {
|
|
182
|
+
fb('purge-fb', e.message, false)
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
document.getElementById('del-events').addEventListener('click', async () => {
|
|
187
|
+
if (!confirm('Apagar TODOS os traces detalhados? O histórico agregado por tarefa é mantido.')) return
|
|
188
|
+
try {
|
|
189
|
+
const r = await post('/api/purge', { mode: 'events' })
|
|
190
|
+
fb('del-fb', `${r.deleted} registro(s) apagados`, true)
|
|
191
|
+
} catch (e) {
|
|
192
|
+
fb('del-fb', e.message, false)
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
document.getElementById('del-everything').addEventListener('click', async () => {
|
|
197
|
+
if (!confirm('Apagar TUDO — traces E histórico por tarefa? Não tem volta.')) return
|
|
198
|
+
if (!confirm('Confirmação final: apagar todo o banco de uso?')) return
|
|
199
|
+
try {
|
|
200
|
+
const r = await post('/api/purge', { mode: 'everything' })
|
|
201
|
+
fb('del-fb', `${r.deleted} registro(s) apagados`, true)
|
|
202
|
+
} catch (e) {
|
|
203
|
+
fb('del-fb', e.message, false)
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
</script>
|
|
207
|
+
</body>
|
|
208
|
+
</html>
|