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/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
- let testBadge = chalk.dim('[Test β€”]')
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('Sort:')} ${chalk.yellow('Y')} ${chalk.dim('Cycle:')} ${chalk.yellow('T')}`)
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 = 36
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 + 10)))
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 = chalk.white(row.model.slice(0, W_MODEL).padEnd(W_MODEL))
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,
@@ -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
- signupHint: 'Settings β†’ Access Tokens',
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: {
@@ -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({
@@ -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 info = getProxyStatusInfo(proxyStartupStatus, !!proxyInstance)
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 portPart = info.port ? chalk.dim(` :${info.port}`) : ''
200
- const acctPart = info.accountCount != null ? chalk.dim(` Β· ${info.accountCount} account${info.accountCount === 1 ? '' : 's'}`) : ''
201
- return chalk.dim(' ') + chalk.rgb(57, 255, 20)('πŸ”€ Proxy') + chalk.rgb(57, 255, 20)(' running') + portPart + acctPart
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('βœ— Proxy failed') + chalk.dim(` β€” ${info.reason}`)
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
- // stopped / not configured β€” dim but always present
207
- return chalk.dim(' πŸ”€ Proxy not configured')
213
+ return chalk.dim(' ') + chalk.red('πŸ”€ Proxy Stopped')
208
214
  }
209
215
  }
210
216
 
@@ -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 β€” gives visibility to less-obvious features
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(' β€’ ') +
@@ -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 */ }