free-coding-models 0.2.0 β 0.2.2
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/README.md +19 -2
- package/bin/free-coding-models.js +43 -14
- package/package.json +1 -1
- package/src/config.js +45 -4
- package/src/endpoint-installer.js +459 -0
- package/src/key-handler.js +344 -16
- package/src/log-reader.js +23 -2
- package/src/opencode.js +14 -2
- package/src/overlays.js +224 -8
- package/src/provider-metadata.js +3 -1
- package/src/proxy-server.js +52 -2
- package/src/render-helpers.js +14 -8
- package/src/render-table.js +18 -5
- package/src/token-stats.js +11 -1
- package/src/tool-launchers.js +50 -7
- package/src/utils.js +37 -4
package/src/overlays.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* @details
|
|
6
6
|
* This module centralizes all overlay rendering in one place:
|
|
7
|
-
* - Settings, Help, Log, Smart Recommend, Feature Request, Bug Report
|
|
7
|
+
* - Settings, Install Endpoints, Help, Log, Smart Recommend, Feature Request, Bug Report
|
|
8
|
+
* - Settings diagnostics for provider key tests, including wrapped retry/error details
|
|
8
9
|
* - Recommend analysis timer orchestration and progress updates
|
|
9
10
|
*
|
|
10
11
|
* The factory pattern keeps stateful UI logic isolated while still
|
|
@@ -48,8 +49,32 @@ export function createOverlayRenderers(state, deps) {
|
|
|
48
49
|
getTopRecommendations,
|
|
49
50
|
adjustScrollOffset,
|
|
50
51
|
getPingModel,
|
|
52
|
+
getConfiguredInstallableProviders,
|
|
53
|
+
getInstallTargetModes,
|
|
54
|
+
getProviderCatalogModels,
|
|
51
55
|
} = deps
|
|
52
56
|
|
|
57
|
+
// π Wrap plain diagnostic text so long Settings messages stay readable inside
|
|
58
|
+
// π the overlay instead of turning into one truncated red line.
|
|
59
|
+
const wrapPlainText = (text, width = 104) => {
|
|
60
|
+
const normalized = typeof text === 'string' ? text.trim() : ''
|
|
61
|
+
if (!normalized) return []
|
|
62
|
+
const words = normalized.split(/\s+/)
|
|
63
|
+
const lines = []
|
|
64
|
+
let current = ''
|
|
65
|
+
for (const word of words) {
|
|
66
|
+
const next = current ? `${current} ${word}` : word
|
|
67
|
+
if (next.length > width && current) {
|
|
68
|
+
lines.push(current)
|
|
69
|
+
current = word
|
|
70
|
+
} else {
|
|
71
|
+
current = next
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (current) lines.push(current)
|
|
75
|
+
return lines
|
|
76
|
+
}
|
|
77
|
+
|
|
53
78
|
// π Keep log token formatting aligned with the main table so the same totals
|
|
54
79
|
// π read the same everywhere in the TUI.
|
|
55
80
|
const formatLogTokens = (totalTokens) => {
|
|
@@ -118,9 +143,13 @@ export function createOverlayRenderers(state, deps) {
|
|
|
118
143
|
|
|
119
144
|
// π Test result badge
|
|
120
145
|
const testResult = state.settingsTestResults[pk]
|
|
121
|
-
|
|
146
|
+
// π Default badge reflects configuration first: a saved key should look
|
|
147
|
+
// π ready to test even before the user has run the probe once.
|
|
148
|
+
let testBadge = keyCount > 0 ? chalk.cyan('[Test]') : chalk.dim('[Missing Key π]')
|
|
122
149
|
if (testResult === 'pending') testBadge = chalk.yellow('[Testingβ¦]')
|
|
123
150
|
else if (testResult === 'ok') testBadge = chalk.greenBright('[Test β
]')
|
|
151
|
+
else if (testResult === 'missing_key') testBadge = chalk.dim('[Missing Key π]')
|
|
152
|
+
else if (testResult === 'auth_error') testBadge = chalk.red('[Auth β]')
|
|
124
153
|
else if (testResult === 'rate_limited') testBadge = chalk.yellow('[Rate limit β³]')
|
|
125
154
|
else if (testResult === 'no_callable_model') testBadge = chalk.magenta('[No model β ]')
|
|
126
155
|
else if (testResult === 'fail') testBadge = chalk.red('[Test β]')
|
|
@@ -151,6 +180,14 @@ export function createOverlayRenderers(state, deps) {
|
|
|
151
180
|
const accountIdStatus = hasAccountId ? chalk.green('CLOUDFLARE_ACCOUNT_ID detected β
') : chalk.yellow('Set CLOUDFLARE_ACCOUNT_ID β ')
|
|
152
181
|
lines.push(chalk.dim(` 4) Export ${chalk.yellow('CLOUDFLARE_ACCOUNT_ID')} in your shell. Status: ${accountIdStatus}`))
|
|
153
182
|
}
|
|
183
|
+
const testDetail = state.settingsTestDetails?.[selectedProviderKey]
|
|
184
|
+
if (testDetail) {
|
|
185
|
+
lines.push('')
|
|
186
|
+
lines.push(chalk.red.bold(' Test Diagnostics'))
|
|
187
|
+
for (const detailLine of wrapPlainText(testDetail)) {
|
|
188
|
+
lines.push(chalk.red(` ${detailLine}`))
|
|
189
|
+
}
|
|
190
|
+
}
|
|
154
191
|
lines.push('')
|
|
155
192
|
}
|
|
156
193
|
|
|
@@ -266,6 +303,168 @@ export function createOverlayRenderers(state, deps) {
|
|
|
266
303
|
return cleared.join('\n')
|
|
267
304
|
}
|
|
268
305
|
|
|
306
|
+
// βββ Install Endpoints overlay renderer βββββββββββββββββββββββββββββββββββ
|
|
307
|
+
// π renderInstallEndpoints drives the provider β tool β scope β model flow
|
|
308
|
+
// π behind the `Y` hotkey. It deliberately reuses the same overlay viewport
|
|
309
|
+
// π helpers as Settings so long provider/model lists stay navigable.
|
|
310
|
+
function renderInstallEndpoints() {
|
|
311
|
+
const EL = '\x1b[K'
|
|
312
|
+
const lines = []
|
|
313
|
+
const cursorLineByRow = {}
|
|
314
|
+
const providerChoices = getConfiguredInstallableProviders(state.config)
|
|
315
|
+
const toolChoices = getInstallTargetModes()
|
|
316
|
+
const scopeChoices = [
|
|
317
|
+
{
|
|
318
|
+
key: 'all',
|
|
319
|
+
label: 'Install all models',
|
|
320
|
+
hint: 'Recommended β FCM will refresh this provider catalog automatically later.',
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
key: 'selected',
|
|
324
|
+
label: 'Install selected models only',
|
|
325
|
+
hint: 'Choose a smaller curated subset for a cleaner model picker.',
|
|
326
|
+
},
|
|
327
|
+
]
|
|
328
|
+
const selectedProviderLabel = state.installEndpointsProviderKey
|
|
329
|
+
? (sources[state.installEndpointsProviderKey]?.name || state.installEndpointsProviderKey)
|
|
330
|
+
: 'β'
|
|
331
|
+
const selectedToolLabel = state.installEndpointsToolMode
|
|
332
|
+
? (state.installEndpointsToolMode === 'opencode-desktop'
|
|
333
|
+
? 'OpenCode Desktop (shared opencode.json)'
|
|
334
|
+
: (state.installEndpointsToolMode === 'opencode'
|
|
335
|
+
? 'OpenCode CLI (shared opencode.json)'
|
|
336
|
+
: state.installEndpointsToolMode === 'openclaw'
|
|
337
|
+
? 'OpenClaw'
|
|
338
|
+
: state.installEndpointsToolMode === 'crush'
|
|
339
|
+
? 'Crush'
|
|
340
|
+
: 'Goose'))
|
|
341
|
+
: 'β'
|
|
342
|
+
|
|
343
|
+
lines.push('')
|
|
344
|
+
lines.push(` ${chalk.bold('π Install Endpoints')} ${chalk.dim('β install provider catalogs into supported coding tools')}`)
|
|
345
|
+
if (state.installEndpointsErrorMsg) {
|
|
346
|
+
lines.push(` ${chalk.yellow(state.installEndpointsErrorMsg)}`)
|
|
347
|
+
}
|
|
348
|
+
lines.push('')
|
|
349
|
+
|
|
350
|
+
if (state.installEndpointsPhase === 'providers') {
|
|
351
|
+
lines.push(` ${chalk.bold('Step 1/4')} ${chalk.cyan('Choose a configured provider')}`)
|
|
352
|
+
lines.push('')
|
|
353
|
+
|
|
354
|
+
if (providerChoices.length === 0) {
|
|
355
|
+
lines.push(chalk.dim(' No configured providers can be installed directly right now.'))
|
|
356
|
+
lines.push(chalk.dim(' Add an API key in Settings (`P`) first, then reopen this screen.'))
|
|
357
|
+
} else {
|
|
358
|
+
providerChoices.forEach((provider, idx) => {
|
|
359
|
+
const isCursor = idx === state.installEndpointsCursor
|
|
360
|
+
const bullet = isCursor ? chalk.bold.cyan(' β― ') : chalk.dim(' ')
|
|
361
|
+
const row = `${bullet}${chalk.bold(provider.label.padEnd(24))} ${chalk.dim(`${provider.modelCount} models`)}`
|
|
362
|
+
cursorLineByRow[idx] = lines.length
|
|
363
|
+
lines.push(isCursor ? chalk.bgRgb(24, 44, 62)(row) : row)
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
lines.push('')
|
|
368
|
+
lines.push(chalk.dim(' ββ Navigate β’ Enter Choose provider β’ Esc Close'))
|
|
369
|
+
} else if (state.installEndpointsPhase === 'tools') {
|
|
370
|
+
lines.push(` ${chalk.bold('Step 2/4')} ${chalk.cyan('Choose the target tool')}`)
|
|
371
|
+
lines.push(chalk.dim(` Provider: ${selectedProviderLabel}`))
|
|
372
|
+
lines.push('')
|
|
373
|
+
|
|
374
|
+
toolChoices.forEach((toolMode, idx) => {
|
|
375
|
+
const isCursor = idx === state.installEndpointsCursor
|
|
376
|
+
const label = toolMode === 'opencode-desktop'
|
|
377
|
+
? 'OpenCode Desktop'
|
|
378
|
+
: toolMode === 'opencode'
|
|
379
|
+
? 'OpenCode CLI'
|
|
380
|
+
: toolMode === 'openclaw'
|
|
381
|
+
? 'OpenClaw'
|
|
382
|
+
: toolMode === 'crush'
|
|
383
|
+
? 'Crush'
|
|
384
|
+
: 'Goose'
|
|
385
|
+
const note = toolMode.startsWith('opencode')
|
|
386
|
+
? chalk.dim('shared config file')
|
|
387
|
+
: chalk.dim('managed provider install')
|
|
388
|
+
const bullet = isCursor ? chalk.bold.cyan(' β― ') : chalk.dim(' ')
|
|
389
|
+
const row = `${bullet}${chalk.bold(label.padEnd(22))} ${note}`
|
|
390
|
+
cursorLineByRow[idx] = lines.length
|
|
391
|
+
lines.push(isCursor ? chalk.bgRgb(24, 44, 62)(row) : row)
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
lines.push('')
|
|
395
|
+
lines.push(chalk.dim(' ββ Navigate β’ Enter Choose tool β’ Esc Back'))
|
|
396
|
+
} else if (state.installEndpointsPhase === 'scope') {
|
|
397
|
+
lines.push(` ${chalk.bold('Step 3/4')} ${chalk.cyan('Choose the install scope')}`)
|
|
398
|
+
lines.push(chalk.dim(` Provider: ${selectedProviderLabel} β’ Tool: ${selectedToolLabel}`))
|
|
399
|
+
lines.push('')
|
|
400
|
+
|
|
401
|
+
scopeChoices.forEach((scope, idx) => {
|
|
402
|
+
const isCursor = idx === state.installEndpointsCursor
|
|
403
|
+
const bullet = isCursor ? chalk.bold.cyan(' β― ') : chalk.dim(' ')
|
|
404
|
+
const row = `${bullet}${chalk.bold(scope.label)}`
|
|
405
|
+
cursorLineByRow[idx] = lines.length
|
|
406
|
+
lines.push(isCursor ? chalk.bgRgb(24, 44, 62)(row) : row)
|
|
407
|
+
lines.push(chalk.dim(` ${scope.hint}`))
|
|
408
|
+
lines.push('')
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
lines.push(chalk.dim(' Enter Continue β’ Esc Back'))
|
|
412
|
+
} else if (state.installEndpointsPhase === 'models') {
|
|
413
|
+
const models = getProviderCatalogModels(state.installEndpointsProviderKey)
|
|
414
|
+
const selectedCount = state.installEndpointsSelectedModelIds.size
|
|
415
|
+
|
|
416
|
+
lines.push(` ${chalk.bold('Step 4/4')} ${chalk.cyan('Choose which models to install')}`)
|
|
417
|
+
lines.push(chalk.dim(` Provider: ${selectedProviderLabel} β’ Tool: ${selectedToolLabel}`))
|
|
418
|
+
lines.push(chalk.dim(` Selected: ${selectedCount}/${models.length}`))
|
|
419
|
+
lines.push('')
|
|
420
|
+
|
|
421
|
+
models.forEach((model, idx) => {
|
|
422
|
+
const isCursor = idx === state.installEndpointsCursor
|
|
423
|
+
const selected = state.installEndpointsSelectedModelIds.has(model.modelId)
|
|
424
|
+
const bullet = isCursor ? chalk.bold.cyan(' β― ') : chalk.dim(' ')
|
|
425
|
+
const checkbox = selected ? chalk.greenBright('[β]') : chalk.dim('[ ]')
|
|
426
|
+
const tier = chalk.cyan(model.tier.padEnd(2))
|
|
427
|
+
const row = `${bullet}${checkbox} ${chalk.bold(model.label.padEnd(26))} ${tier} ${chalk.dim(model.ctx.padEnd(6))} ${chalk.dim(model.modelId)}`
|
|
428
|
+
cursorLineByRow[idx] = lines.length
|
|
429
|
+
lines.push(isCursor ? chalk.bgRgb(24, 44, 62)(row) : row)
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
lines.push('')
|
|
433
|
+
lines.push(chalk.dim(' ββ Navigate β’ Space Toggle model β’ A All/None β’ Enter Install β’ Esc Back'))
|
|
434
|
+
} else if (state.installEndpointsPhase === 'result') {
|
|
435
|
+
const result = state.installEndpointsResult
|
|
436
|
+
const accent = result?.type === 'success' ? chalk.greenBright : chalk.redBright
|
|
437
|
+
lines.push(` ${chalk.bold('Result')} ${accent(result?.title || 'Install result unavailable')}`)
|
|
438
|
+
lines.push('')
|
|
439
|
+
|
|
440
|
+
for (const detail of result?.lines || []) {
|
|
441
|
+
lines.push(` ${detail}`)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (result?.type === 'success') {
|
|
445
|
+
lines.push('')
|
|
446
|
+
lines.push(chalk.dim(' Future FCM launches will refresh this catalog automatically when the provider list evolves.'))
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
lines.push('')
|
|
450
|
+
lines.push(chalk.dim(' Enter or Esc Close'))
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const targetLine = cursorLineByRow[state.installEndpointsCursor] ?? 0
|
|
454
|
+
state.installEndpointsScrollOffset = keepOverlayTargetVisible(
|
|
455
|
+
state.installEndpointsScrollOffset,
|
|
456
|
+
targetLine,
|
|
457
|
+
lines.length,
|
|
458
|
+
state.terminalRows
|
|
459
|
+
)
|
|
460
|
+
const { visible, offset } = sliceOverlayLines(lines, state.installEndpointsScrollOffset, state.terminalRows)
|
|
461
|
+
state.installEndpointsScrollOffset = offset
|
|
462
|
+
|
|
463
|
+
const tintedLines = tintOverlayLines(visible, SETTINGS_OVERLAY_BG)
|
|
464
|
+
const cleared = tintedLines.map((line) => line + EL)
|
|
465
|
+
return cleared.join('\n')
|
|
466
|
+
}
|
|
467
|
+
|
|
269
468
|
// βββ Help overlay renderer ββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
270
469
|
// π renderHelp: Draw the help overlay listing all key bindings.
|
|
271
470
|
// π Toggled with K key. Gives users a quick reference without leaving the TUI.
|
|
@@ -280,7 +479,7 @@ export function createOverlayRenderers(state, deps) {
|
|
|
280
479
|
lines.push(` ${chalk.cyan('Rank')} SWE-bench rank (1 = best coding score) ${chalk.dim('Sort:')} ${chalk.yellow('R')}`)
|
|
281
480
|
lines.push(` ${chalk.dim('Quick glance at which model is objectively the best coder right now.')}`)
|
|
282
481
|
lines.push('')
|
|
283
|
-
lines.push(` ${chalk.cyan('Tier')} S+ / S / A+ / A / A- / B+ / B / C based on SWE-bench score ${chalk.dim('
|
|
482
|
+
lines.push(` ${chalk.cyan('Tier')} S+ / S / A+ / A / A- / B+ / B / C based on SWE-bench score ${chalk.dim('Cycle:')} ${chalk.yellow('T')}`)
|
|
284
483
|
lines.push(` ${chalk.dim('Skip the noise β S/S+ models solve real GitHub issues, C models are for light tasks.')}`)
|
|
285
484
|
lines.push('')
|
|
286
485
|
lines.push(` ${chalk.cyan('SWE%')} SWE-bench score β coding ability benchmark (color-coded) ${chalk.dim('Sort:')} ${chalk.yellow('S')}`)
|
|
@@ -331,6 +530,7 @@ export function createOverlayRenderers(state, deps) {
|
|
|
331
530
|
lines.push(` ${chalk.yellow('X')} Toggle token log page ${chalk.dim('(shows recent request usage from request-log.jsonl)')}`)
|
|
332
531
|
lines.push(` ${chalk.yellow('Z')} Cycle tool mode ${chalk.dim('(OpenCode β Desktop β OpenClaw β Crush β Goose)')}`)
|
|
333
532
|
lines.push(` ${chalk.yellow('F')} Toggle favorite on selected row ${chalk.dim('(β pinned at top, persisted)')}`)
|
|
533
|
+
lines.push(` ${chalk.yellow('Y')} Install endpoints ${chalk.dim('(provider catalog β OpenCode/OpenClaw/Crush/Goose, no proxy)')}`)
|
|
334
534
|
lines.push(` ${chalk.yellow('Q')} Smart Recommend ${chalk.dim('(π― find the best model for your task β questionnaire + live analysis)')}`)
|
|
335
535
|
lines.push(` ${chalk.rgb(57, 255, 20).bold('J')} Request Feature ${chalk.dim('(π send anonymous feedback to the project team)')}`)
|
|
336
536
|
lines.push(` ${chalk.rgb(255, 87, 51).bold('I')} Report Bug ${chalk.dim('(π send anonymous bug report to the project team)')}`)
|
|
@@ -413,7 +613,8 @@ export function createOverlayRenderers(state, deps) {
|
|
|
413
613
|
// π Column widths for the log table
|
|
414
614
|
const W_TIME = 19
|
|
415
615
|
const W_PROV = 14
|
|
416
|
-
const W_MODEL =
|
|
616
|
+
const W_MODEL = 44
|
|
617
|
+
const W_ROUTE = 18
|
|
417
618
|
const W_STATUS = 8
|
|
418
619
|
const W_TOKENS = 12
|
|
419
620
|
const W_LAT = 10
|
|
@@ -422,11 +623,12 @@ export function createOverlayRenderers(state, deps) {
|
|
|
422
623
|
const hTime = chalk.dim('Time'.padEnd(W_TIME))
|
|
423
624
|
const hProv = chalk.dim('Provider'.padEnd(W_PROV))
|
|
424
625
|
const hModel = chalk.dim('Model'.padEnd(W_MODEL))
|
|
626
|
+
const hRoute = chalk.dim('Route'.padEnd(W_ROUTE))
|
|
425
627
|
const hStatus = chalk.dim('Status'.padEnd(W_STATUS))
|
|
426
628
|
const hTok = chalk.dim('Tokens Used'.padEnd(W_TOKENS))
|
|
427
629
|
const hLat = chalk.dim('Latency'.padEnd(W_LAT))
|
|
428
|
-
lines.push(` ${hTime} ${hProv} ${hModel} ${hStatus} ${hTok} ${hLat}`)
|
|
429
|
-
lines.push(chalk.dim(' ' + 'β'.repeat(W_TIME + W_PROV + W_MODEL + W_STATUS + W_TOKENS + W_LAT +
|
|
630
|
+
lines.push(` ${hTime} ${hProv} ${hModel} ${hRoute} ${hStatus} ${hTok} ${hLat}`)
|
|
631
|
+
lines.push(chalk.dim(' ' + 'β'.repeat(W_TIME + W_PROV + W_MODEL + W_ROUTE + W_STATUS + W_TOKENS + W_LAT + 12)))
|
|
430
632
|
|
|
431
633
|
for (const row of logRows) {
|
|
432
634
|
// π Format time as HH:MM:SS (strip the date part for compactness)
|
|
@@ -438,6 +640,11 @@ export function createOverlayRenderers(state, deps) {
|
|
|
438
640
|
}
|
|
439
641
|
} catch { /* keep raw */ }
|
|
440
642
|
|
|
643
|
+
const requestedModelLabel = row.requestedModel || ''
|
|
644
|
+
const displayModel = row.switched && requestedModelLabel && requestedModelLabel !== row.model
|
|
645
|
+
? `${requestedModelLabel} β ${row.model}`
|
|
646
|
+
: row.model
|
|
647
|
+
|
|
441
648
|
// π Color-code status
|
|
442
649
|
let statusCell
|
|
443
650
|
const sc = String(row.status)
|
|
@@ -453,14 +660,22 @@ export function createOverlayRenderers(state, deps) {
|
|
|
453
660
|
|
|
454
661
|
const tokStr = formatLogTokens(row.tokens)
|
|
455
662
|
const latStr = row.latency > 0 ? `${row.latency}ms` : '--'
|
|
663
|
+
const routeLabel = row.switched
|
|
664
|
+
? `SWITCHED β» ${row.switchReason || 'fallback'}`
|
|
665
|
+
: 'direct'
|
|
456
666
|
|
|
457
667
|
const timeCell = chalk.dim(timeStr.slice(0, W_TIME).padEnd(W_TIME))
|
|
458
668
|
const provCell = chalk.cyan(row.provider.slice(0, W_PROV).padEnd(W_PROV))
|
|
459
|
-
const modelCell =
|
|
669
|
+
const modelCell = row.switched
|
|
670
|
+
? chalk.bold.rgb(255, 210, 90)(displayModel.slice(0, W_MODEL).padEnd(W_MODEL))
|
|
671
|
+
: chalk.white(displayModel.slice(0, W_MODEL).padEnd(W_MODEL))
|
|
672
|
+
const routeCell = row.switched
|
|
673
|
+
? chalk.bgRgb(120, 25, 25).yellow.bold(` ${routeLabel.slice(0, W_ROUTE - 2).padEnd(W_ROUTE - 2)} `)
|
|
674
|
+
: chalk.dim(routeLabel.padEnd(W_ROUTE))
|
|
460
675
|
const tokCell = chalk.dim(tokStr.padEnd(W_TOKENS))
|
|
461
676
|
const latCell = chalk.dim(latStr.padEnd(W_LAT))
|
|
462
677
|
|
|
463
|
-
lines.push(` ${timeCell} ${provCell} ${modelCell} ${statusCell} ${tokCell} ${latCell}`)
|
|
678
|
+
lines.push(` ${timeCell} ${provCell} ${modelCell} ${routeCell} ${statusCell} ${tokCell} ${latCell}`)
|
|
464
679
|
}
|
|
465
680
|
}
|
|
466
681
|
|
|
@@ -892,6 +1107,7 @@ export function createOverlayRenderers(state, deps) {
|
|
|
892
1107
|
|
|
893
1108
|
return {
|
|
894
1109
|
renderSettings,
|
|
1110
|
+
renderInstallEndpoints,
|
|
895
1111
|
renderHelp,
|
|
896
1112
|
renderLog,
|
|
897
1113
|
renderRecommend,
|
package/src/provider-metadata.js
CHANGED
|
@@ -114,7 +114,9 @@ export const PROVIDER_METADATA = {
|
|
|
114
114
|
label: 'Hugging Face Inference',
|
|
115
115
|
color: chalk.rgb(255, 245, 157),
|
|
116
116
|
signupUrl: 'https://huggingface.co/settings/tokens',
|
|
117
|
-
|
|
117
|
+
// π Hugging Face serverless inference now expects a fine-grained token with
|
|
118
|
+
// π the dedicated Inference Providers permission, not a generic read token.
|
|
119
|
+
signupHint: 'Settings β Access Tokens β Fine-grained β enable "Make calls to Inference Providers"',
|
|
118
120
|
rateLimits: 'Free monthly credits (~$0.10)',
|
|
119
121
|
},
|
|
120
122
|
replicate: {
|
package/src/proxy-server.js
CHANGED
|
@@ -254,7 +254,28 @@ export class ProxyServer {
|
|
|
254
254
|
})
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
+
const formatSwitchReason = (classified) => {
|
|
258
|
+
switch (classified?.type) {
|
|
259
|
+
case 'QUOTA_EXHAUSTED':
|
|
260
|
+
return 'quota'
|
|
261
|
+
case 'RATE_LIMITED':
|
|
262
|
+
return '429'
|
|
263
|
+
case 'MODEL_NOT_FOUND':
|
|
264
|
+
return '404'
|
|
265
|
+
case 'MODEL_CAPACITY':
|
|
266
|
+
return 'capacity'
|
|
267
|
+
case 'SERVER_ERROR':
|
|
268
|
+
return '5xx'
|
|
269
|
+
case 'NETWORK_ERROR':
|
|
270
|
+
return 'network'
|
|
271
|
+
default:
|
|
272
|
+
return 'retry'
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
257
276
|
// 5. Retry loop
|
|
277
|
+
let pendingSwitchReason = null
|
|
278
|
+
let previousAccount = null
|
|
258
279
|
for (let attempt = 0; attempt < this._retries; attempt++) {
|
|
259
280
|
// First attempt: respect sticky session.
|
|
260
281
|
// Subsequent retries: fresh P2C (don't hammer the same failed account).
|
|
@@ -264,7 +285,13 @@ export class ProxyServer {
|
|
|
264
285
|
const account = this._accountManager.selectAccount(selectOpts)
|
|
265
286
|
if (!account) break // No available accounts β fall through to 503
|
|
266
287
|
|
|
267
|
-
const result = await this._forwardRequest(account, body, clientRes
|
|
288
|
+
const result = await this._forwardRequest(account, body, clientRes, {
|
|
289
|
+
requestedModel,
|
|
290
|
+
switched: attempt > 0,
|
|
291
|
+
switchReason: pendingSwitchReason,
|
|
292
|
+
switchedFromProviderKey: previousAccount?.providerKey,
|
|
293
|
+
switchedFromModelId: previousAccount?.modelId,
|
|
294
|
+
})
|
|
268
295
|
|
|
269
296
|
// Response fully sent (success JSON or SSE pipe established)
|
|
270
297
|
if (result.done) return
|
|
@@ -292,6 +319,8 @@ export class ProxyServer {
|
|
|
292
319
|
)
|
|
293
320
|
}
|
|
294
321
|
// shouldRetry === true β next attempt
|
|
322
|
+
pendingSwitchReason = formatSwitchReason(classified)
|
|
323
|
+
previousAccount = account
|
|
295
324
|
}
|
|
296
325
|
|
|
297
326
|
// All retries consumed, or no accounts available from the start
|
|
@@ -314,9 +343,10 @@ export class ProxyServer {
|
|
|
314
343
|
* @param {{ id: string, apiKey: string, modelId: string, url: string }} account
|
|
315
344
|
* @param {object} body
|
|
316
345
|
* @param {http.ServerResponse} clientRes
|
|
346
|
+
* @param {{ requestedModel?: string, switched?: boolean, switchReason?: string|null, switchedFromProviderKey?: string, switchedFromModelId?: string }} [logContext]
|
|
317
347
|
* @returns {Promise<{ done: boolean }>}
|
|
318
348
|
*/
|
|
319
|
-
_forwardRequest(account, body, clientRes) {
|
|
349
|
+
_forwardRequest(account, body, clientRes, logContext = {}) {
|
|
320
350
|
return new Promise(resolve => {
|
|
321
351
|
// Replace client-supplied model name with the account's model ID
|
|
322
352
|
const newBody = { ...body, model: account.modelId }
|
|
@@ -392,6 +422,11 @@ export class ProxyServer {
|
|
|
392
422
|
completionTokens,
|
|
393
423
|
latencyMs: Date.now() - startTime,
|
|
394
424
|
success: true,
|
|
425
|
+
requestedModelId: logContext.requestedModel,
|
|
426
|
+
switched: logContext.switched === true,
|
|
427
|
+
switchReason: logContext.switchReason,
|
|
428
|
+
switchedFromProviderKey: logContext.switchedFromProviderKey,
|
|
429
|
+
switchedFromModelId: logContext.switchedFromModelId,
|
|
395
430
|
})
|
|
396
431
|
this._accountManager.recordSuccess(account.id, Date.now() - startTime)
|
|
397
432
|
const quotaUpdated = this._accountManager.updateQuota(account.id, headers)
|
|
@@ -445,6 +480,11 @@ export class ProxyServer {
|
|
|
445
480
|
completionTokens,
|
|
446
481
|
latencyMs,
|
|
447
482
|
success: true,
|
|
483
|
+
requestedModelId: logContext.requestedModel,
|
|
484
|
+
switched: logContext.switched === true,
|
|
485
|
+
switchReason: logContext.switchReason,
|
|
486
|
+
switchedFromProviderKey: logContext.switchedFromProviderKey,
|
|
487
|
+
switchedFromModelId: logContext.switchedFromModelId,
|
|
448
488
|
})
|
|
449
489
|
|
|
450
490
|
// Forward stripped response to client
|
|
@@ -474,6 +514,11 @@ export class ProxyServer {
|
|
|
474
514
|
completionTokens: 0,
|
|
475
515
|
latencyMs,
|
|
476
516
|
success: false,
|
|
517
|
+
requestedModelId: logContext.requestedModel,
|
|
518
|
+
switched: logContext.switched === true,
|
|
519
|
+
switchReason: logContext.switchReason,
|
|
520
|
+
switchedFromProviderKey: logContext.switchedFromProviderKey,
|
|
521
|
+
switchedFromModelId: logContext.switchedFromModelId,
|
|
477
522
|
})
|
|
478
523
|
resolve({
|
|
479
524
|
done: false,
|
|
@@ -499,6 +544,11 @@ export class ProxyServer {
|
|
|
499
544
|
completionTokens: 0,
|
|
500
545
|
latencyMs,
|
|
501
546
|
success: false,
|
|
547
|
+
requestedModelId: logContext.requestedModel,
|
|
548
|
+
switched: logContext.switched === true,
|
|
549
|
+
switchReason: logContext.switchReason,
|
|
550
|
+
switchedFromProviderKey: logContext.switchedFromProviderKey,
|
|
551
|
+
switchedFromModelId: logContext.switchedFromModelId,
|
|
502
552
|
})
|
|
503
553
|
// TCP / DNS / timeout errors
|
|
504
554
|
resolve({
|
package/src/render-helpers.js
CHANGED
|
@@ -190,21 +190,27 @@ export function sortResultsWithPinnedFavorites(results, sortColumn, sortDirectio
|
|
|
190
190
|
// π renderProxyStatusLine: Maps proxyStartupStatus + active proxy into a chalk-coloured footer line.
|
|
191
191
|
// π Always returns a non-empty string (no hidden states) so the footer row is always present.
|
|
192
192
|
// π Delegates state classification to the pure getProxyStatusInfo helper (testable in utils.js).
|
|
193
|
-
export function renderProxyStatusLine(proxyStartupStatus, proxyInstance) {
|
|
194
|
-
const
|
|
193
|
+
export function renderProxyStatusLine(proxyStartupStatus, proxyInstance, proxyEnabled = false) {
|
|
194
|
+
const activeStatus = typeof proxyInstance?.getStatus === 'function' ? proxyInstance.getStatus() : null
|
|
195
|
+
const hasLiveProxy = Boolean(proxyInstance) && activeStatus?.running !== false
|
|
196
|
+
const info = getProxyStatusInfo(proxyStartupStatus, hasLiveProxy, proxyEnabled)
|
|
197
|
+
const neonGreen = chalk.rgb(57, 255, 20)
|
|
195
198
|
switch (info.state) {
|
|
196
199
|
case 'starting':
|
|
197
200
|
return chalk.dim(' ') + chalk.yellow('β³ Proxy') + chalk.dim(' startingβ¦')
|
|
198
201
|
case 'running': {
|
|
199
|
-
const
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
+
const resolvedPort = info.port ?? activeStatus?.port ?? activeStatus?.listeningPort ?? null
|
|
203
|
+
const resolvedAccountCount = info.accountCount ?? activeStatus?.accountCount ?? proxyInstance?._accounts?.length ?? null
|
|
204
|
+
const portPart = resolvedPort ? chalk.dim(` :${resolvedPort}`) : ''
|
|
205
|
+
const acctPart = resolvedAccountCount != null ? chalk.dim(` Β· ${resolvedAccountCount} account${resolvedAccountCount === 1 ? '' : 's'}`) : ''
|
|
206
|
+
return chalk.dim(' ') + neonGreen('π Proxy running') + portPart + acctPart
|
|
202
207
|
}
|
|
203
208
|
case 'failed':
|
|
204
|
-
return chalk.dim(' ') + chalk.red('
|
|
209
|
+
return chalk.dim(' ') + chalk.red('π Proxy Stopped') + chalk.dim(` β ${info.reason}`)
|
|
210
|
+
case 'configured':
|
|
211
|
+
return chalk.dim(' ') + chalk.cyan('π Proxy configured') + chalk.dim(' β OpenCode rotation')
|
|
205
212
|
default:
|
|
206
|
-
|
|
207
|
-
return chalk.dim(' π Proxy not configured')
|
|
213
|
+
return chalk.dim(' ') + chalk.red('π Proxy Stopped')
|
|
208
214
|
}
|
|
209
215
|
}
|
|
210
216
|
|
package/src/render-table.js
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* - Viewport clipping with above/below indicators
|
|
15
15
|
* - Smart badges (mode, tier filter, origin filter, profile)
|
|
16
16
|
* - Proxy status line integrated in footer
|
|
17
|
+
* - Install-endpoints shortcut surfaced directly in the footer hints
|
|
18
|
+
* - Distinct auth-failure vs missing-key health labels so configured providers stay honest
|
|
17
19
|
*
|
|
18
20
|
* β Functions:
|
|
19
21
|
* - `setActiveProxy` β Provide the active proxy instance for footer status rendering
|
|
@@ -35,7 +37,7 @@ import { createRequire } from 'module'
|
|
|
35
37
|
import { sources } from '../sources.js'
|
|
36
38
|
import { PING_INTERVAL, FRAMES } from './constants.js'
|
|
37
39
|
import { TIER_COLOR } from './tier-colors.js'
|
|
38
|
-
import { getAvg, getVerdict, getUptime, getStabilityScore } from './utils.js'
|
|
40
|
+
import { getAvg, getVerdict, getUptime, getStabilityScore, getVersionStatusInfo } from './utils.js'
|
|
39
41
|
import { usagePlaceholderForProvider } from './ping.js'
|
|
40
42
|
import { formatTokenTotalCompact } from './token-usage-reader.js'
|
|
41
43
|
import { calculateViewport, sortResultsWithPinnedFavorites, renderProxyStatusLine, padEndDisplay } from './render-helpers.js'
|
|
@@ -89,7 +91,7 @@ export function setActiveProxy(proxyInstance) {
|
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
// βββ renderTable: mode param controls footer hint text (opencode vs openclaw) βββββββββ
|
|
92
|
-
export function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, terminalCols = 0, originFilterMode = 0, activeProfile = null, profileSaveMode = false, profileSaveBuffer = '', proxyStartupStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false, widthWarningStartedAt = null, widthWarningDismissed = false) {
|
|
94
|
+
export function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, terminalCols = 0, originFilterMode = 0, activeProfile = null, profileSaveMode = false, profileSaveBuffer = '', proxyStartupStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false, widthWarningStartedAt = null, widthWarningDismissed = false, settingsUpdateState = 'idle', settingsUpdateLatestVersion = null, proxyEnabled = false) {
|
|
93
95
|
// π Filter out hidden models for display
|
|
94
96
|
const visibleResults = results.filter(r => !r.hidden)
|
|
95
97
|
|
|
@@ -137,6 +139,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
137
139
|
: chalk.bold.rgb(0, 200, 255)
|
|
138
140
|
const modeBadge = toolBadgeColor(' [ ') + chalk.yellow.bold('Z') + toolBadgeColor(` Tool : ${toolMeta.label} ]`)
|
|
139
141
|
const activeHeaderBadge = (text, bg = [57, 255, 20], fg = [0, 0, 0]) => chalk.bgRgb(...bg).rgb(...fg).bold(` ${text} `)
|
|
142
|
+
const versionStatus = getVersionStatusInfo(settingsUpdateState, settingsUpdateLatestVersion)
|
|
140
143
|
|
|
141
144
|
// π Tier filter badge shown when filtering is active (shows exact tier name)
|
|
142
145
|
const TIER_CYCLE_NAMES = [null, 'S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C']
|
|
@@ -433,6 +436,11 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
433
436
|
// π Server responded but needs an API key β shown dimly since it IS reachable
|
|
434
437
|
statusText = `π NO KEY`
|
|
435
438
|
statusColor = (s) => chalk.dim(s)
|
|
439
|
+
} else if (r.status === 'auth_error') {
|
|
440
|
+
// π A key is configured but the provider rejected it β keep this distinct
|
|
441
|
+
// π from "no key" so configured-only mode does not look misleading.
|
|
442
|
+
statusText = `π AUTH FAIL`
|
|
443
|
+
statusColor = (s) => chalk.redBright(s)
|
|
436
444
|
} else if (r.status === 'pending') {
|
|
437
445
|
statusText = `${FRAMES[frame % FRAMES.length]} wait`
|
|
438
446
|
statusColor = (s) => chalk.dim.yellow(s)
|
|
@@ -634,10 +642,15 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
634
642
|
chalk.dim(` β’ `) +
|
|
635
643
|
hotkey('K', ' Help')
|
|
636
644
|
)
|
|
637
|
-
// π Line 2: profiles, recommend, feature request, bug report, and extended hints
|
|
638
|
-
lines.push(chalk.dim(` `) + hotkey('β§P', ' Cycle profile') + chalk.dim(` β’ `) + hotkey('β§S', ' Save profile') + chalk.dim(` β’ `) + hotkey('Q', ' Smart Recommend') + chalk.dim(` β’ `) + hotkey('J', ' Request feature') + chalk.dim(` β’ `) + hotkey('I', ' Report bug'))
|
|
645
|
+
// π Line 2: profiles, install flow, recommend, feature request, bug report, and extended hints.
|
|
646
|
+
lines.push(chalk.dim(` `) + hotkey('β§P', ' Cycle profile') + chalk.dim(` β’ `) + hotkey('β§S', ' Save profile') + chalk.dim(` β’ `) + hotkey('Y', ' Install endpoints') + chalk.dim(` β’ `) + hotkey('Q', ' Smart Recommend') + chalk.dim(` β’ `) + hotkey('J', ' Request feature') + chalk.dim(` β’ `) + hotkey('I', ' Report bug'))
|
|
639
647
|
// π Proxy status line β always rendered with explicit state (starting/running/failed/stopped)
|
|
640
|
-
lines.push(renderProxyStatusLine(proxyStartupStatus, activeProxyRef))
|
|
648
|
+
lines.push(renderProxyStatusLine(proxyStartupStatus, activeProxyRef, proxyEnabled))
|
|
649
|
+
if (versionStatus.isOutdated) {
|
|
650
|
+
const outdatedBadge = chalk.bgRed.bold.yellow(' This version is outdated . ')
|
|
651
|
+
const latestLabel = chalk.redBright(` local v${LOCAL_VERSION} Β· latest v${versionStatus.latestVersion}`)
|
|
652
|
+
lines.push(` ${outdatedBadge}${latestLabel}`)
|
|
653
|
+
}
|
|
641
654
|
lines.push(
|
|
642
655
|
chalk.rgb(255, 150, 200)(' Made with π & β by \x1b]8;;https://github.com/vava-nessa\x1b\\vava-nessa\x1b]8;;\x1b\\') +
|
|
643
656
|
chalk.dim(' β’ ') +
|
package/src/token-stats.js
CHANGED
|
@@ -100,7 +100,7 @@ export class TokenStats {
|
|
|
100
100
|
/**
|
|
101
101
|
* Record a single request's token usage.
|
|
102
102
|
*
|
|
103
|
-
* @param {{ accountId: string, modelId: string, providerKey?: string, statusCode?: number|string, requestType?: string, promptTokens?: number, completionTokens?: number, latencyMs?: number, success?: boolean }} entry
|
|
103
|
+
* @param {{ accountId: string, modelId: string, providerKey?: string, statusCode?: number|string, requestType?: string, promptTokens?: number, completionTokens?: number, latencyMs?: number, success?: boolean, requestedModelId?: string, switched?: boolean, switchReason?: string, switchedFromProviderKey?: string, switchedFromModelId?: string }} entry
|
|
104
104
|
*/
|
|
105
105
|
record(entry) {
|
|
106
106
|
const {
|
|
@@ -113,6 +113,11 @@ export class TokenStats {
|
|
|
113
113
|
completionTokens = 0,
|
|
114
114
|
latencyMs = 0,
|
|
115
115
|
success = true,
|
|
116
|
+
requestedModelId,
|
|
117
|
+
switched = false,
|
|
118
|
+
switchReason,
|
|
119
|
+
switchedFromProviderKey,
|
|
120
|
+
switchedFromModelId,
|
|
116
121
|
} = entry
|
|
117
122
|
const totalTokens = promptTokens + completionTokens
|
|
118
123
|
const now = new Date()
|
|
@@ -157,6 +162,11 @@ export class TokenStats {
|
|
|
157
162
|
completionTokens,
|
|
158
163
|
latencyMs,
|
|
159
164
|
success,
|
|
165
|
+
...(typeof requestedModelId === 'string' && requestedModelId.length > 0 && { requestedModelId }),
|
|
166
|
+
...(switched === true && { switched: true }),
|
|
167
|
+
...(typeof switchReason === 'string' && switchReason.length > 0 && { switchReason }),
|
|
168
|
+
...(typeof switchedFromProviderKey === 'string' && switchedFromProviderKey.length > 0 && { switchedFromProviderKey }),
|
|
169
|
+
...(typeof switchedFromModelId === 'string' && switchedFromModelId.length > 0 && { switchedFromModelId }),
|
|
160
170
|
}
|
|
161
171
|
appendFileSync(this._logFile, JSON.stringify(logEntry) + '\n')
|
|
162
172
|
} catch { /* ignore */ }
|