free-coding-models 0.3.5 → 0.3.9
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/CHANGELOG.md +24 -0
- package/README.md +67 -1
- package/bin/free-coding-models.js +102 -64
- package/package.json +4 -2
- package/src/cli-help.js +8 -0
- package/src/config.js +332 -37
- package/src/endpoint-installer.js +2 -2
- package/src/favorites.js +31 -10
- package/src/key-handler.js +45 -24
- package/src/overlays.js +6 -3
- package/src/testfcm.js +451 -0
- package/src/token-usage-reader.js +53 -11
- package/src/utils.js +39 -0
package/src/testfcm.js
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/testfcm.js
|
|
3
|
+
* @description Shared helpers for the AI-driven `/testfcm` workflow.
|
|
4
|
+
*
|
|
5
|
+
* @details
|
|
6
|
+
* 📖 These helpers stay side-effect free on purpose so the reporting logic can
|
|
7
|
+
* 📖 be unit-tested without spawning a PTY or touching the user's machine.
|
|
8
|
+
*
|
|
9
|
+
* 📖 The runner in `scripts/testfcm-runner.mjs` handles the live terminal work:
|
|
10
|
+
* 📖 copying config into an isolated HOME, driving the TUI, launching a tool,
|
|
11
|
+
* 📖 sending a prompt, collecting logs, and writing the final Markdown report.
|
|
12
|
+
* 📖 This module focuses on the pieces that should remain stable and reusable:
|
|
13
|
+
* 📖 tool metadata, transcript classification, JSON extraction, and report text.
|
|
14
|
+
*
|
|
15
|
+
* @functions
|
|
16
|
+
* → `normalizeTestfcmToolName` — map aliases like `claude` to canonical FCM tool modes
|
|
17
|
+
* → `resolveTestfcmToolSpec` — return the runner metadata for one tool mode
|
|
18
|
+
* → `hasConfiguredKey` — decide whether a config entry really contains an API key
|
|
19
|
+
* → `createTestfcmRunId` — build a stable timestamp-based run id for artifacts
|
|
20
|
+
* → `extractJsonPayload` — recover JSON mode output even when logs prefix stdout
|
|
21
|
+
* → `detectTranscriptFindings` — map raw tool output to actionable failure findings
|
|
22
|
+
* → `classifyToolTranscript` — classify a run as passed, failed, or inconclusive
|
|
23
|
+
* → `buildFixTasks` — convert findings into concrete follow-up work items
|
|
24
|
+
* → `buildTestfcmReport` — render the final Markdown report written under `task/`
|
|
25
|
+
*
|
|
26
|
+
* @exports TESTFCM_TOOL_SPECS, normalizeTestfcmToolName, resolveTestfcmToolSpec
|
|
27
|
+
* @exports hasConfiguredKey, createTestfcmRunId, extractJsonPayload
|
|
28
|
+
* @exports detectTranscriptFindings, classifyToolTranscript, buildFixTasks
|
|
29
|
+
* @exports buildTestfcmReport
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export const TESTFCM_TOOL_SPECS = {
|
|
33
|
+
crush: {
|
|
34
|
+
mode: 'crush',
|
|
35
|
+
label: 'Crush',
|
|
36
|
+
command: 'crush',
|
|
37
|
+
flag: '--crush',
|
|
38
|
+
prefersProxy: true,
|
|
39
|
+
configPaths: ['.config/crush/crush.json'],
|
|
40
|
+
},
|
|
41
|
+
codex: {
|
|
42
|
+
mode: 'codex',
|
|
43
|
+
label: 'Codex CLI',
|
|
44
|
+
command: 'codex',
|
|
45
|
+
flag: '--codex',
|
|
46
|
+
prefersProxy: true,
|
|
47
|
+
configPaths: [],
|
|
48
|
+
},
|
|
49
|
+
'claude-code': {
|
|
50
|
+
mode: 'claude-code',
|
|
51
|
+
label: 'Claude Code',
|
|
52
|
+
command: 'claude',
|
|
53
|
+
flag: '--claude-code',
|
|
54
|
+
prefersProxy: true,
|
|
55
|
+
configPaths: [],
|
|
56
|
+
},
|
|
57
|
+
gemini: {
|
|
58
|
+
mode: 'gemini',
|
|
59
|
+
label: 'Gemini CLI',
|
|
60
|
+
command: 'gemini',
|
|
61
|
+
flag: '--gemini',
|
|
62
|
+
prefersProxy: true,
|
|
63
|
+
configPaths: ['.gemini/settings.json'],
|
|
64
|
+
},
|
|
65
|
+
goose: {
|
|
66
|
+
mode: 'goose',
|
|
67
|
+
label: 'Goose',
|
|
68
|
+
command: 'goose',
|
|
69
|
+
flag: '--goose',
|
|
70
|
+
prefersProxy: true,
|
|
71
|
+
configPaths: ['.config/goose/config.yaml'],
|
|
72
|
+
},
|
|
73
|
+
aider: {
|
|
74
|
+
mode: 'aider',
|
|
75
|
+
label: 'Aider',
|
|
76
|
+
command: 'aider',
|
|
77
|
+
flag: '--aider',
|
|
78
|
+
prefersProxy: false,
|
|
79
|
+
configPaths: ['.aider.conf.yml'],
|
|
80
|
+
},
|
|
81
|
+
qwen: {
|
|
82
|
+
mode: 'qwen',
|
|
83
|
+
label: 'Qwen Code',
|
|
84
|
+
command: 'qwen',
|
|
85
|
+
flag: '--qwen',
|
|
86
|
+
prefersProxy: false,
|
|
87
|
+
configPaths: ['.qwen/settings.json'],
|
|
88
|
+
},
|
|
89
|
+
amp: {
|
|
90
|
+
mode: 'amp',
|
|
91
|
+
label: 'Amp',
|
|
92
|
+
command: 'amp',
|
|
93
|
+
flag: '--amp',
|
|
94
|
+
prefersProxy: false,
|
|
95
|
+
configPaths: ['.config/amp/settings.json'],
|
|
96
|
+
},
|
|
97
|
+
pi: {
|
|
98
|
+
mode: 'pi',
|
|
99
|
+
label: 'Pi',
|
|
100
|
+
command: 'pi',
|
|
101
|
+
flag: '--pi',
|
|
102
|
+
prefersProxy: false,
|
|
103
|
+
configPaths: ['.pi/agent/models.json', '.pi/agent/settings.json'],
|
|
104
|
+
},
|
|
105
|
+
opencode: {
|
|
106
|
+
mode: 'opencode',
|
|
107
|
+
label: 'OpenCode CLI',
|
|
108
|
+
command: 'opencode',
|
|
109
|
+
flag: '--opencode',
|
|
110
|
+
prefersProxy: false,
|
|
111
|
+
configPaths: ['.config/opencode/opencode.json'],
|
|
112
|
+
},
|
|
113
|
+
openhands: {
|
|
114
|
+
mode: 'openhands',
|
|
115
|
+
label: 'OpenHands',
|
|
116
|
+
command: 'openhands',
|
|
117
|
+
flag: '--openhands',
|
|
118
|
+
prefersProxy: false,
|
|
119
|
+
configPaths: [],
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const TESTFCM_TOOL_ALIASES = {
|
|
124
|
+
claude: 'claude-code',
|
|
125
|
+
claudecode: 'claude-code',
|
|
126
|
+
codexcli: 'codex',
|
|
127
|
+
opencodecli: 'opencode',
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const TRANSCRIPT_FINDING_RULES = [
|
|
131
|
+
{
|
|
132
|
+
id: 'tool_missing',
|
|
133
|
+
title: 'Tool binary missing',
|
|
134
|
+
severity: 'high',
|
|
135
|
+
regex: /could not find "[^"]+" in path|command not found|enoent/i,
|
|
136
|
+
task: 'Install the requested tool binary or pass `--tool-bin-dir` so FCM can launch it during `/testfcm`.',
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: 'invalid_api_key',
|
|
140
|
+
title: 'Invalid or missing API auth',
|
|
141
|
+
severity: 'high',
|
|
142
|
+
regex: /invalid api|bad api key|incorrect api key|authentication failed|unauthorized|forbidden|missing api key|no api key|anthropic_auth_token|401\b|403\b/i,
|
|
143
|
+
task: 'Validate the provider key used by the selected model, then re-run `/testfcm` and inspect the proxy request log for the failing request.',
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
id: 'rate_limited',
|
|
147
|
+
title: 'Provider rate limited',
|
|
148
|
+
severity: 'medium',
|
|
149
|
+
regex: /rate limit|too many requests|quota exceeded|429\b/i,
|
|
150
|
+
task: 'Retry with another configured provider or inspect the retry-after and cooldown handling in the proxy/tool launch flow.',
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
id: 'proxy_failure',
|
|
154
|
+
title: 'Proxy startup or routing failure',
|
|
155
|
+
severity: 'high',
|
|
156
|
+
regex: /failed to start proxy|proxy mode .* required|selected model may not exist|routing reload is taking longer than expected|proxy launch is blocked/i,
|
|
157
|
+
task: 'Inspect `request-log.jsonl`, proxy startup messages, and the isolated config to verify the tool can reach the local FCM proxy.',
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
id: 'tool_launch_failed',
|
|
161
|
+
title: 'Tool launch failed',
|
|
162
|
+
severity: 'high',
|
|
163
|
+
regex: /failed to launch|failed to start|process exited with code 1|syntaxerror|traceback|fatal:/i,
|
|
164
|
+
task: 'Inspect the tool transcript and generated tool config under the isolated HOME to find the exact launcher failure.',
|
|
165
|
+
},
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
const SUCCESS_PATTERNS = [
|
|
169
|
+
/hello[,! ]/i,
|
|
170
|
+
/how can i help/i,
|
|
171
|
+
/how may i help/i,
|
|
172
|
+
/how can i assist/i,
|
|
173
|
+
/ready to help/i,
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 📖 Normalize a user/tool alias to the canonical FCM tool mode.
|
|
178
|
+
*
|
|
179
|
+
* @param {string | null | undefined} value
|
|
180
|
+
* @returns {string | null}
|
|
181
|
+
*/
|
|
182
|
+
export function normalizeTestfcmToolName(value) {
|
|
183
|
+
if (typeof value !== 'string' || value.trim().length === 0) return null
|
|
184
|
+
const normalized = value.trim().toLowerCase()
|
|
185
|
+
return TESTFCM_TOOL_ALIASES[normalized] || normalized
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 📖 Resolve one `/testfcm` tool spec from user input.
|
|
190
|
+
*
|
|
191
|
+
* @param {string | null | undefined} value
|
|
192
|
+
* @returns {typeof TESTFCM_TOOL_SPECS[keyof typeof TESTFCM_TOOL_SPECS] | null}
|
|
193
|
+
*/
|
|
194
|
+
export function resolveTestfcmToolSpec(value) {
|
|
195
|
+
const normalized = normalizeTestfcmToolName(value)
|
|
196
|
+
if (!normalized) return null
|
|
197
|
+
return TESTFCM_TOOL_SPECS[normalized] || null
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 📖 Treat both string and multi-key array config entries as "configured" when at
|
|
202
|
+
* 📖 least one non-empty key is present.
|
|
203
|
+
*
|
|
204
|
+
* @param {unknown} value
|
|
205
|
+
* @returns {boolean}
|
|
206
|
+
*/
|
|
207
|
+
export function hasConfiguredKey(value) {
|
|
208
|
+
if (typeof value === 'string') return value.trim().length > 0
|
|
209
|
+
if (Array.isArray(value)) return value.some((entry) => typeof entry === 'string' && entry.trim().length > 0)
|
|
210
|
+
return false
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 📖 Build an artifact-friendly run id such as `20260316-184512`.
|
|
215
|
+
*
|
|
216
|
+
* @param {Date} [date]
|
|
217
|
+
* @returns {string}
|
|
218
|
+
*/
|
|
219
|
+
export function createTestfcmRunId(date = new Date()) {
|
|
220
|
+
const iso = date.toISOString()
|
|
221
|
+
return iso
|
|
222
|
+
.replace(/\.\d{3}Z$/, '')
|
|
223
|
+
.replace(/:/g, '')
|
|
224
|
+
.replace(/-/g, '')
|
|
225
|
+
.replace('T', '-')
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 📖 Extract the first valid JSON array payload from mixed stdout text.
|
|
230
|
+
*
|
|
231
|
+
* @param {string} text
|
|
232
|
+
* @returns {Array<object> | null}
|
|
233
|
+
*/
|
|
234
|
+
export function extractJsonPayload(text) {
|
|
235
|
+
const source = String(text || '')
|
|
236
|
+
let offset = source.indexOf('[')
|
|
237
|
+
while (offset !== -1) {
|
|
238
|
+
const candidate = source.slice(offset).trim()
|
|
239
|
+
try {
|
|
240
|
+
const parsed = JSON.parse(candidate)
|
|
241
|
+
return Array.isArray(parsed) ? parsed : null
|
|
242
|
+
} catch {
|
|
243
|
+
offset = source.indexOf('[', offset + 1)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return null
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* 📖 Detect known failure patterns in the raw tool transcript.
|
|
251
|
+
*
|
|
252
|
+
* @param {string} output
|
|
253
|
+
* @returns {Array<{ id: string, title: string, severity: string, task: string, excerpt: string }>}
|
|
254
|
+
*/
|
|
255
|
+
export function detectTranscriptFindings(output) {
|
|
256
|
+
const transcript = String(output || '')
|
|
257
|
+
const findings = []
|
|
258
|
+
|
|
259
|
+
for (const rule of TRANSCRIPT_FINDING_RULES) {
|
|
260
|
+
const match = transcript.match(rule.regex)
|
|
261
|
+
if (!match) continue
|
|
262
|
+
|
|
263
|
+
findings.push({
|
|
264
|
+
id: rule.id,
|
|
265
|
+
title: rule.title,
|
|
266
|
+
severity: rule.severity,
|
|
267
|
+
task: rule.task,
|
|
268
|
+
excerpt: match[0],
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return findings
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* 📖 Decide whether the tool transcript proves success, proves failure, or stays
|
|
277
|
+
* 📖 too ambiguous to trust.
|
|
278
|
+
*
|
|
279
|
+
* @param {string} output
|
|
280
|
+
* @returns {{ status: 'passed' | 'failed' | 'inconclusive', findings: Array<{ id: string, title: string, severity: string, task: string, excerpt: string }>, matchedSuccess: string | null }}
|
|
281
|
+
*/
|
|
282
|
+
export function classifyToolTranscript(output) {
|
|
283
|
+
const transcript = String(output || '')
|
|
284
|
+
const matchedSuccess = SUCCESS_PATTERNS.find((pattern) => pattern.test(transcript))
|
|
285
|
+
|
|
286
|
+
if (matchedSuccess) {
|
|
287
|
+
return {
|
|
288
|
+
status: 'passed',
|
|
289
|
+
findings: [],
|
|
290
|
+
matchedSuccess: matchedSuccess.source,
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const findings = detectTranscriptFindings(transcript)
|
|
295
|
+
if (findings.length > 0) {
|
|
296
|
+
return {
|
|
297
|
+
status: 'failed',
|
|
298
|
+
findings,
|
|
299
|
+
matchedSuccess: null,
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
status: 'inconclusive',
|
|
305
|
+
findings: [],
|
|
306
|
+
matchedSuccess: null,
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* 📖 Collapse findings into unique human-readable follow-up tasks.
|
|
312
|
+
*
|
|
313
|
+
* @param {Array<{ task: string }>} findings
|
|
314
|
+
* @returns {string[]}
|
|
315
|
+
*/
|
|
316
|
+
export function buildFixTasks(findings) {
|
|
317
|
+
const tasks = new Set()
|
|
318
|
+
for (const finding of findings) {
|
|
319
|
+
if (typeof finding?.task === 'string' && finding.task.trim().length > 0) {
|
|
320
|
+
tasks.add(finding.task.trim())
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return [...tasks]
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* 📖 Render the final Markdown report saved under `task/reports/`.
|
|
328
|
+
*
|
|
329
|
+
* @param {{
|
|
330
|
+
* runId: string,
|
|
331
|
+
* status: 'passed' | 'failed' | 'blocked',
|
|
332
|
+
* startedAt: string,
|
|
333
|
+
* finishedAt: string,
|
|
334
|
+
* toolLabel: string,
|
|
335
|
+
* toolMode: string,
|
|
336
|
+
* prompt: string,
|
|
337
|
+
* configuredProviders: string[],
|
|
338
|
+
* toolBinaryPath: string | null,
|
|
339
|
+
* isolatedHome: string,
|
|
340
|
+
* preflightSummary: string,
|
|
341
|
+
* findings: Array<{ id: string, title: string, severity: string, excerpt: string }>,
|
|
342
|
+
* tasks: string[],
|
|
343
|
+
* evidenceFiles: string[],
|
|
344
|
+
* requestLogSummary: string[],
|
|
345
|
+
* notes: string[],
|
|
346
|
+
* transcriptExcerpt: string
|
|
347
|
+
* }} input
|
|
348
|
+
* @returns {string}
|
|
349
|
+
*/
|
|
350
|
+
export function buildTestfcmReport(input) {
|
|
351
|
+
const lines = []
|
|
352
|
+
const findings = Array.isArray(input.findings) ? input.findings : []
|
|
353
|
+
const tasks = Array.isArray(input.tasks) ? input.tasks : []
|
|
354
|
+
const evidenceFiles = Array.isArray(input.evidenceFiles) ? input.evidenceFiles : []
|
|
355
|
+
const requestLogSummary = Array.isArray(input.requestLogSummary) ? input.requestLogSummary : []
|
|
356
|
+
const notes = Array.isArray(input.notes) ? input.notes : []
|
|
357
|
+
const configuredProviders = Array.isArray(input.configuredProviders) ? input.configuredProviders : []
|
|
358
|
+
const transcriptExcerpt = String(input.transcriptExcerpt || '').trim()
|
|
359
|
+
|
|
360
|
+
lines.push(`# /testfcm Report - ${input.runId}`)
|
|
361
|
+
lines.push('')
|
|
362
|
+
lines.push(`- Status: **${input.status.toUpperCase()}**`)
|
|
363
|
+
lines.push(`- Started: ${input.startedAt}`)
|
|
364
|
+
lines.push(`- Finished: ${input.finishedAt}`)
|
|
365
|
+
lines.push(`- Tool: ${input.toolLabel} (${input.toolMode})`)
|
|
366
|
+
lines.push(`- Prompt sent: \`${input.prompt}\``)
|
|
367
|
+
lines.push(`- Configured providers in isolated run: ${configuredProviders.length > 0 ? configuredProviders.join(', ') : '(none)'}`)
|
|
368
|
+
lines.push(`- Tool binary: ${input.toolBinaryPath || '(not found on PATH)'}`)
|
|
369
|
+
lines.push(`- Isolated HOME: \`${input.isolatedHome}\``)
|
|
370
|
+
lines.push('')
|
|
371
|
+
lines.push('## Summary')
|
|
372
|
+
lines.push('')
|
|
373
|
+
lines.push(input.preflightSummary)
|
|
374
|
+
lines.push('')
|
|
375
|
+
|
|
376
|
+
if (findings.length > 0) {
|
|
377
|
+
lines.push('## Bugs Found')
|
|
378
|
+
lines.push('')
|
|
379
|
+
for (const finding of findings) {
|
|
380
|
+
lines.push(`- [${finding.severity}] ${finding.title} - evidence: \`${finding.excerpt}\``)
|
|
381
|
+
}
|
|
382
|
+
lines.push('')
|
|
383
|
+
} else {
|
|
384
|
+
lines.push('## Bugs Found')
|
|
385
|
+
lines.push('')
|
|
386
|
+
lines.push('- No blocking bug pattern matched the captured transcript in this run.')
|
|
387
|
+
lines.push('')
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
lines.push('## Tasks To Resolve')
|
|
391
|
+
lines.push('')
|
|
392
|
+
if (tasks.length > 0) {
|
|
393
|
+
for (const task of tasks) {
|
|
394
|
+
lines.push(`- ${task}`)
|
|
395
|
+
}
|
|
396
|
+
} else {
|
|
397
|
+
lines.push('- No follow-up task was generated from the captured evidence.')
|
|
398
|
+
}
|
|
399
|
+
lines.push('')
|
|
400
|
+
|
|
401
|
+
lines.push('## Evidence')
|
|
402
|
+
lines.push('')
|
|
403
|
+
if (evidenceFiles.length > 0) {
|
|
404
|
+
for (const file of evidenceFiles) {
|
|
405
|
+
lines.push(`- ${file}`)
|
|
406
|
+
}
|
|
407
|
+
} else {
|
|
408
|
+
lines.push('- No artifact file was captured.')
|
|
409
|
+
}
|
|
410
|
+
lines.push('')
|
|
411
|
+
|
|
412
|
+
lines.push('## Request Log Summary')
|
|
413
|
+
lines.push('')
|
|
414
|
+
if (requestLogSummary.length > 0) {
|
|
415
|
+
for (const entry of requestLogSummary) {
|
|
416
|
+
lines.push(`- ${entry}`)
|
|
417
|
+
}
|
|
418
|
+
} else {
|
|
419
|
+
lines.push('- No proxy request log entry was captured for this run.')
|
|
420
|
+
}
|
|
421
|
+
lines.push('')
|
|
422
|
+
|
|
423
|
+
lines.push('## Notes')
|
|
424
|
+
lines.push('')
|
|
425
|
+
if (notes.length > 0) {
|
|
426
|
+
for (const note of notes) {
|
|
427
|
+
lines.push(`- ${note}`)
|
|
428
|
+
}
|
|
429
|
+
} else {
|
|
430
|
+
lines.push('- No extra notes.')
|
|
431
|
+
}
|
|
432
|
+
lines.push('')
|
|
433
|
+
|
|
434
|
+
lines.push('## Transcript Excerpt')
|
|
435
|
+
lines.push('')
|
|
436
|
+
if (transcriptExcerpt) {
|
|
437
|
+
lines.push('```text')
|
|
438
|
+
lines.push(transcriptExcerpt)
|
|
439
|
+
lines.push('```')
|
|
440
|
+
} else {
|
|
441
|
+
lines.push('```text')
|
|
442
|
+
lines.push('(empty transcript excerpt)')
|
|
443
|
+
lines.push('```')
|
|
444
|
+
}
|
|
445
|
+
lines.push('')
|
|
446
|
+
lines.push('## Next Step')
|
|
447
|
+
lines.push('')
|
|
448
|
+
lines.push('Ask the AI to read this report, summarize the blockers, and propose or apply the fixes.')
|
|
449
|
+
|
|
450
|
+
return lines.join('\n')
|
|
451
|
+
}
|
|
@@ -26,28 +26,70 @@
|
|
|
26
26
|
* @see src/render-table.js
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
30
|
+
import { join } from 'node:path'
|
|
31
|
+
import { homedir } from 'node:os'
|
|
29
32
|
import { loadRecentLogs } from './log-reader.js'
|
|
30
33
|
|
|
34
|
+
const DEFAULT_DATA_DIR = join(homedir(), '.free-coding-models')
|
|
35
|
+
const STATS_FILE = join(DEFAULT_DATA_DIR, 'token-stats.json')
|
|
36
|
+
|
|
31
37
|
// 📖 buildProviderModelTokenKey keeps provider-scoped totals isolated even when
|
|
32
38
|
// 📖 multiple Origins expose the same model ID.
|
|
33
39
|
export function buildProviderModelTokenKey(providerKey, modelId) {
|
|
34
40
|
return `${providerKey}::${modelId}`
|
|
35
41
|
}
|
|
36
42
|
|
|
37
|
-
// 📖 loadTokenUsageByProviderModel
|
|
38
|
-
// 📖
|
|
39
|
-
export function loadTokenUsageByProviderModel({ logFile, limit = 50_000 } = {}) {
|
|
40
|
-
|
|
43
|
+
// 📖 loadTokenUsageByProviderModel prioritizes token-stats.json for accurate
|
|
44
|
+
// 📖 historical totals. If missing, it falls back to parsing the bounded log history.
|
|
45
|
+
export function loadTokenUsageByProviderModel({ logFile, statsFile = STATS_FILE, limit = 50_000 } = {}) {
|
|
46
|
+
// 📖 If a custom logFile is provided (Test Mode), ONLY use that file.
|
|
47
|
+
if (logFile) {
|
|
48
|
+
const testTotals = {}
|
|
49
|
+
const rows = loadRecentLogs({ logFile, limit })
|
|
50
|
+
for (const row of rows) {
|
|
51
|
+
const key = buildProviderModelTokenKey(row.provider, row.model)
|
|
52
|
+
testTotals[key] = (testTotals[key] || 0) + (Number(row.tokens) || 0)
|
|
53
|
+
}
|
|
54
|
+
return testTotals
|
|
55
|
+
}
|
|
56
|
+
|
|
41
57
|
const totals = {}
|
|
42
58
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
59
|
+
// 📖 Phase 1: Try to load from the aggregated stats file (canonical source for totals)
|
|
60
|
+
try {
|
|
61
|
+
if (existsSync(statsFile)) {
|
|
62
|
+
const stats = JSON.parse(readFileSync(statsFile, 'utf8'))
|
|
63
|
+
// 📖 Aggregate byAccount entries (which use providerKey/slug/keyIdx as ID)
|
|
64
|
+
// 📖 into providerKey::modelId buckets.
|
|
65
|
+
if (stats.byAccount && typeof stats.byAccount === 'object') {
|
|
66
|
+
for (const [accountId, acct] of Object.entries(stats.byAccount)) {
|
|
67
|
+
const tokens = Number(acct.tokens) || 0
|
|
68
|
+
if (tokens <= 0) continue
|
|
69
|
+
|
|
70
|
+
// 📖 Extract providerKey and modelId from accountId (provider/model/index)
|
|
71
|
+
const parts = accountId.split('/')
|
|
72
|
+
if (parts.length >= 2) {
|
|
73
|
+
const providerKey = parts[0]
|
|
74
|
+
const modelId = parts[1]
|
|
75
|
+
const key = buildProviderModelTokenKey(providerKey, modelId)
|
|
76
|
+
totals[key] = (totals[key] || 0) + tokens
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} catch (err) {
|
|
82
|
+
// 📖 Silently fall back to log parsing if stats file is corrupt or unreadable
|
|
83
|
+
}
|
|
48
84
|
|
|
49
|
-
|
|
50
|
-
|
|
85
|
+
// 📖 Phase 2: Supplement with recent log entries if totals are still empty
|
|
86
|
+
// 📖 (e.g. fresh install or token-stats.json deleted)
|
|
87
|
+
if (Object.keys(totals).length === 0) {
|
|
88
|
+
const rows = loadRecentLogs({ limit })
|
|
89
|
+
for (const row of rows) {
|
|
90
|
+
const key = buildProviderModelTokenKey(row.provider, row.model)
|
|
91
|
+
totals[key] = (totals[key] || 0) + (Number(row.tokens) || 0)
|
|
92
|
+
}
|
|
51
93
|
}
|
|
52
94
|
|
|
53
95
|
return totals
|
package/src/utils.js
CHANGED
|
@@ -414,10 +414,29 @@ export function parseArgs(argv) {
|
|
|
414
414
|
? profileIdx + 1
|
|
415
415
|
: -1
|
|
416
416
|
|
|
417
|
+
// New value flags
|
|
418
|
+
const sortIdx = args.findIndex(a => a.toLowerCase() === '--sort')
|
|
419
|
+
const sortValueIdx = (sortIdx !== -1 && args[sortIdx + 1] && !args[sortIdx + 1].startsWith('--'))
|
|
420
|
+
? sortIdx + 1
|
|
421
|
+
: -1
|
|
422
|
+
|
|
423
|
+
const originIdx = args.findIndex(a => a.toLowerCase() === '--origin')
|
|
424
|
+
const originValueIdx = (originIdx !== -1 && args[originIdx + 1] && !args[originIdx + 1].startsWith('--'))
|
|
425
|
+
? originIdx + 1
|
|
426
|
+
: -1
|
|
427
|
+
|
|
428
|
+
const pingIntervalIdx = args.findIndex(a => a.toLowerCase() === '--ping-interval')
|
|
429
|
+
const pingIntervalValueIdx = (pingIntervalIdx !== -1 && args[pingIntervalIdx + 1] && !args[pingIntervalIdx + 1].startsWith('--'))
|
|
430
|
+
? pingIntervalIdx + 1
|
|
431
|
+
: -1
|
|
432
|
+
|
|
417
433
|
// 📖 Set of arg indices that are values for flags (not API keys)
|
|
418
434
|
const skipIndices = new Set()
|
|
419
435
|
if (tierValueIdx !== -1) skipIndices.add(tierValueIdx)
|
|
420
436
|
if (profileValueIdx !== -1) skipIndices.add(profileValueIdx)
|
|
437
|
+
if (sortValueIdx !== -1) skipIndices.add(sortValueIdx)
|
|
438
|
+
if (originValueIdx !== -1) skipIndices.add(originValueIdx)
|
|
439
|
+
if (pingIntervalValueIdx !== -1) skipIndices.add(pingIntervalValueIdx)
|
|
421
440
|
|
|
422
441
|
for (const [i, arg] of args.entries()) {
|
|
423
442
|
if (arg.startsWith('--') || arg === '-h') {
|
|
@@ -448,8 +467,20 @@ export function parseArgs(argv) {
|
|
|
448
467
|
const cleanProxyMode = flags.includes('--clean-proxy') || flags.includes('--proxy-clean')
|
|
449
468
|
const jsonMode = flags.includes('--json')
|
|
450
469
|
const helpMode = flags.includes('--help') || flags.includes('-h')
|
|
470
|
+
const premiumMode = flags.includes('--premium')
|
|
471
|
+
|
|
472
|
+
// New boolean flags
|
|
473
|
+
const sortDesc = flags.includes('--desc')
|
|
474
|
+
const sortAscFlag = flags.includes('--asc')
|
|
475
|
+
const hideUnconfigured = flags.includes('--hide-unconfigured')
|
|
476
|
+
const showUnconfigured = flags.includes('--show-unconfigured')
|
|
477
|
+
const disableWidthsWarning = flags.includes('--disable-widths-warning')
|
|
451
478
|
|
|
452
479
|
let tierFilter = tierValueIdx !== -1 ? args[tierValueIdx].toUpperCase() : null
|
|
480
|
+
let sortColumn = sortValueIdx !== -1 ? args[sortValueIdx].toLowerCase() : null
|
|
481
|
+
let originFilter = originValueIdx !== -1 ? args[originValueIdx] : null
|
|
482
|
+
let pingInterval = pingIntervalValueIdx !== -1 ? parseInt(args[pingIntervalValueIdx], 10) : null
|
|
483
|
+
let sortDirection = sortDesc ? 'desc' : (sortAscFlag ? 'asc' : null)
|
|
453
484
|
|
|
454
485
|
const profileName = profileValueIdx !== -1 ? args[profileValueIdx] : null
|
|
455
486
|
|
|
@@ -478,6 +509,14 @@ export function parseArgs(argv) {
|
|
|
478
509
|
jsonMode,
|
|
479
510
|
helpMode,
|
|
480
511
|
tierFilter,
|
|
512
|
+
sortColumn,
|
|
513
|
+
sortDirection,
|
|
514
|
+
originFilter,
|
|
515
|
+
pingInterval,
|
|
516
|
+
hideUnconfigured,
|
|
517
|
+
showUnconfigured,
|
|
518
|
+
disableWidthsWarning,
|
|
519
|
+
premiumMode,
|
|
481
520
|
profileName,
|
|
482
521
|
recommendMode
|
|
483
522
|
}
|