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.
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * @details
6
6
  * This module encapsulates the full onKeyPress switch used by the TUI,
7
- * including settings navigation, overlays, profile management, and
7
+ * including settings navigation, install-endpoint flow, overlays, profile management, and
8
8
  * OpenCode/OpenClaw launch actions. It also keeps the live key bindings
9
9
  * aligned with the highlighted letters shown in the table headers.
10
10
  *
@@ -19,9 +19,10 @@
19
19
  * - `parseProviderModelIds` — extract model ids from an OpenAI-style `/models` payload
20
20
  * - `listProviderTestModels` — build an ordered candidate list for provider key verification
21
21
  * - `classifyProviderTestOutcome` — convert attempted HTTP codes into a settings badge state
22
+ * - `buildProviderTestDetail` — turn probe attempts into a readable failure explanation
22
23
  * - `createKeyHandler` — returns the async keypress handler
23
24
  *
24
- * @exports { buildProviderModelsUrl, parseProviderModelIds, listProviderTestModels, classifyProviderTestOutcome, createKeyHandler }
25
+ * @exports { buildProviderModelsUrl, parseProviderModelIds, listProviderTestModels, classifyProviderTestOutcome, buildProviderTestDetail, createKeyHandler }
25
26
  */
26
27
 
27
28
  // 📖 Some providers need an explicit probe model because the first catalog entry
@@ -31,6 +32,17 @@ const PROVIDER_TEST_MODEL_OVERRIDES = {
31
32
  nvidia: ['deepseek-ai/deepseek-v3.1-terminus', 'openai/gpt-oss-120b'],
32
33
  }
33
34
 
35
+ // 📖 Settings key tests retry retryable failures across several models so a
36
+ // 📖 single stale catalog entry or transient timeout does not mark a valid key as dead.
37
+ const SETTINGS_TEST_MAX_ATTEMPTS = 10
38
+ const SETTINGS_TEST_RETRY_DELAY_MS = 4000
39
+
40
+ // 📖 Sleep helper kept local to this module so the Settings key test flow can
41
+ // 📖 back off between retries without leaking timer logic into the rest of the TUI.
42
+ function sleep(ms) {
43
+ return new Promise((resolve) => setTimeout(resolve, ms))
44
+ }
45
+
34
46
  /**
35
47
  * 📖 buildProviderModelsUrl derives the matching `/models` endpoint for providers
36
48
  * 📖 that expose an OpenAI-compatible model list next to `/chat/completions`.
@@ -86,16 +98,52 @@ export function listProviderTestModels(providerKey, src, discoveredModelIds = []
86
98
  * 📖 - `rate_limited` means the key is valid but the provider is currently throttling
87
99
  * 📖 - `no_callable_model` means the provider responded, but none of the attempted models were callable
88
100
  * @param {string[]} codes
89
- * @returns {'ok'|'fail'|'rate_limited'|'no_callable_model'}
101
+ * @returns {'ok'|'auth_error'|'rate_limited'|'no_callable_model'|'fail'}
90
102
  */
91
103
  export function classifyProviderTestOutcome(codes) {
92
104
  if (codes.includes('200')) return 'ok'
93
- if (codes.includes('401') || codes.includes('403')) return 'fail'
105
+ if (codes.includes('401') || codes.includes('403')) return 'auth_error'
94
106
  if (codes.length > 0 && codes.every(code => code === '429')) return 'rate_limited'
95
107
  if (codes.length > 0 && codes.every(code => code === '404' || code === '410')) return 'no_callable_model'
96
108
  return 'fail'
97
109
  }
98
110
 
111
+ // 📖 buildProviderTestDetail explains why the Settings `T` probe failed, with
112
+ // 📖 enough context for the user to know whether the key, model list, or provider
113
+ // 📖 quota is the problem.
114
+ export function buildProviderTestDetail(providerLabel, outcome, attempts = [], discoveryNote = '') {
115
+ const introByOutcome = {
116
+ missing_key: `${providerLabel} has no saved API key right now, so no authenticated test could be sent.`,
117
+ ok: `${providerLabel} accepted the key.`,
118
+ auth_error: `${providerLabel} rejected the configured key with an authentication error.`,
119
+ rate_limited: `${providerLabel} throttled every probe, so the key may still be valid but is currently rate-limited.`,
120
+ no_callable_model: `${providerLabel} answered the requests, but none of the probed models were callable on its chat endpoint.`,
121
+ fail: `${providerLabel} never returned a successful probe during the retry window.`,
122
+ }
123
+
124
+ const hintsByOutcome = {
125
+ missing_key: 'Save the key with Enter in Settings, then rerun T.',
126
+ ok: attempts.length > 0 ? `Validated on ${attempts[attempts.length - 1].model}.` : 'The provider returned a success response.',
127
+ auth_error: 'This usually means the saved key is invalid, expired, revoked, or truncated before it reached disk.',
128
+ rate_limited: 'Wait for the provider quota window to reset, then rerun T.',
129
+ no_callable_model: 'The provider catalog or repo defaults likely drifted; try another model family or refresh the catalog.',
130
+ fail: 'This can be caused by timeouts, 5xx responses, or a provider-side outage.',
131
+ }
132
+
133
+ const attemptSummary = attempts.length > 0
134
+ ? `Attempts: ${attempts.map(({ attempt, model, code }) => `#${attempt} ${model} -> ${code}`).join(' | ')}`
135
+ : 'Attempts: none'
136
+
137
+ const segments = [
138
+ introByOutcome[outcome] || introByOutcome.fail,
139
+ hintsByOutcome[outcome] || hintsByOutcome.fail,
140
+ discoveryNote,
141
+ attemptSummary,
142
+ ].filter(Boolean)
143
+
144
+ return segments.join(' ')
145
+ }
146
+
99
147
  export function createKeyHandler(ctx) {
100
148
  const {
101
149
  state,
@@ -115,6 +163,10 @@ export function createKeyHandler(ctx) {
115
163
  saveAsProfile,
116
164
  setActiveProfile,
117
165
  saveConfig,
166
+ getConfiguredInstallableProviders,
167
+ getInstallTargetModes,
168
+ getProviderCatalogModels,
169
+ installProviderEndpoints,
118
170
  syncFavoriteFlags,
119
171
  toggleFavoriteModel,
120
172
  sortResultsWithPinnedFavorites,
@@ -168,11 +220,19 @@ export function createKeyHandler(ctx) {
168
220
  const src = sources[providerKey]
169
221
  if (!src) return
170
222
  const testKey = getApiKey(state.config, providerKey)
171
- if (!testKey) { state.settingsTestResults[providerKey] = 'fail'; return }
223
+ const providerLabel = src.name || providerKey
224
+ if (!state.settingsTestDetails) state.settingsTestDetails = {}
225
+ if (!testKey) {
226
+ state.settingsTestResults[providerKey] = 'missing_key'
227
+ state.settingsTestDetails[providerKey] = buildProviderTestDetail(providerLabel, 'missing_key')
228
+ return
229
+ }
172
230
 
173
231
  state.settingsTestResults[providerKey] = 'pending'
232
+ state.settingsTestDetails[providerKey] = `Testing ${providerLabel} across up to ${SETTINGS_TEST_MAX_ATTEMPTS} probes...`
174
233
  const discoveredModelIds = []
175
234
  const modelsUrl = buildProviderModelsUrl(src.url)
235
+ let discoveryNote = ''
176
236
 
177
237
  if (modelsUrl) {
178
238
  try {
@@ -185,30 +245,53 @@ export function createKeyHandler(ctx) {
185
245
  if (modelsResp.ok) {
186
246
  const data = await modelsResp.json()
187
247
  discoveredModelIds.push(...parseProviderModelIds(data))
248
+ discoveryNote = discoveredModelIds.length > 0
249
+ ? `Live model discovery returned ${discoveredModelIds.length} ids.`
250
+ : 'Live model discovery succeeded but returned no callable ids.'
251
+ } else {
252
+ discoveryNote = `Live model discovery returned HTTP ${modelsResp.status}; falling back to the repo catalog.`
188
253
  }
189
- } catch {
254
+ } catch (err) {
190
255
  // 📖 Discovery failure is non-fatal; we still have repo-defined fallbacks.
256
+ discoveryNote = `Live model discovery failed (${err?.name || 'error'}); falling back to the repo catalog.`
191
257
  }
192
258
  }
193
259
 
194
260
  const candidateModels = listProviderTestModels(providerKey, src, discoveredModelIds)
195
- if (candidateModels.length === 0) { state.settingsTestResults[providerKey] = 'fail'; return }
196
- const attemptedCodes = []
261
+ if (candidateModels.length === 0) {
262
+ state.settingsTestResults[providerKey] = 'fail'
263
+ state.settingsTestDetails[providerKey] = buildProviderTestDetail(providerLabel, 'fail', [], discoveryNote || 'No candidate model was available for probing.')
264
+ return
265
+ }
266
+ const attempts = []
197
267
 
198
- for (const testModel of candidateModels.slice(0, 8)) {
268
+ for (let attemptIndex = 0; attemptIndex < SETTINGS_TEST_MAX_ATTEMPTS; attemptIndex++) {
269
+ const testModel = candidateModels[attemptIndex % candidateModels.length]
199
270
  const { code } = await ping(testKey, testModel, providerKey, src.url)
200
- attemptedCodes.push(code)
271
+ attempts.push({ attempt: attemptIndex + 1, model: testModel, code })
272
+
201
273
  if (code === '200') {
202
274
  state.settingsTestResults[providerKey] = 'ok'
275
+ state.settingsTestDetails[providerKey] = buildProviderTestDetail(providerLabel, 'ok', attempts, discoveryNote)
203
276
  return
204
277
  }
205
- if (code === '401' || code === '403') {
206
- state.settingsTestResults[providerKey] = 'fail'
278
+
279
+ const outcome = classifyProviderTestOutcome(attempts.map(({ code: attemptCode }) => attemptCode))
280
+ if (outcome === 'auth_error') {
281
+ state.settingsTestResults[providerKey] = 'auth_error'
282
+ state.settingsTestDetails[providerKey] = buildProviderTestDetail(providerLabel, 'auth_error', attempts, discoveryNote)
207
283
  return
208
284
  }
285
+
286
+ if (attemptIndex < SETTINGS_TEST_MAX_ATTEMPTS - 1) {
287
+ state.settingsTestDetails[providerKey] = `Testing ${providerLabel}... probe ${attemptIndex + 1}/${SETTINGS_TEST_MAX_ATTEMPTS} failed on ${testModel} (${code}). Retrying in ${SETTINGS_TEST_RETRY_DELAY_MS / 1000}s.`
288
+ await sleep(SETTINGS_TEST_RETRY_DELAY_MS)
289
+ }
209
290
  }
210
291
 
211
- state.settingsTestResults[providerKey] = classifyProviderTestOutcome(attemptedCodes)
292
+ const finalOutcome = classifyProviderTestOutcome(attempts.map(({ code }) => code))
293
+ state.settingsTestResults[providerKey] = finalOutcome
294
+ state.settingsTestDetails[providerKey] = buildProviderTestDetail(providerLabel, finalOutcome, attempts, discoveryNote)
212
295
  }
213
296
 
214
297
  // 📖 Manual update checker from settings; keeps status visible in maintenance row.
@@ -242,6 +325,48 @@ export function createKeyHandler(ctx) {
242
325
  runUpdate(latestVersion)
243
326
  }
244
327
 
328
+ function resetInstallEndpointsOverlay() {
329
+ state.installEndpointsOpen = false
330
+ state.installEndpointsPhase = 'providers'
331
+ state.installEndpointsCursor = 0
332
+ state.installEndpointsScrollOffset = 0
333
+ state.installEndpointsProviderKey = null
334
+ state.installEndpointsToolMode = null
335
+ state.installEndpointsScope = null
336
+ state.installEndpointsSelectedModelIds = new Set()
337
+ state.installEndpointsErrorMsg = null
338
+ state.installEndpointsResult = null
339
+ }
340
+
341
+ async function runInstallEndpointsFlow() {
342
+ const selectedModelIds = [...state.installEndpointsSelectedModelIds]
343
+ const result = installProviderEndpoints(
344
+ state.config,
345
+ state.installEndpointsProviderKey,
346
+ state.installEndpointsToolMode,
347
+ {
348
+ scope: state.installEndpointsScope,
349
+ modelIds: selectedModelIds,
350
+ }
351
+ )
352
+
353
+ state.installEndpointsResult = {
354
+ type: 'success',
355
+ title: `${result.modelCount} models installed into ${result.toolLabel}`,
356
+ lines: [
357
+ chalk.bold(`Provider:`) + ` ${result.providerLabel}`,
358
+ chalk.bold(`Scope:`) + ` ${result.scope === 'selected' ? 'Selected models' : 'All current models'}`,
359
+ chalk.bold(`Managed Id:`) + ` ${result.providerId}`,
360
+ chalk.bold(`Config:`) + ` ${result.path}`,
361
+ ...(result.extraPath ? [chalk.bold(`Secrets:`) + ` ${result.extraPath}`] : []),
362
+ ],
363
+ }
364
+ state.installEndpointsPhase = 'result'
365
+ state.installEndpointsCursor = 0
366
+ state.installEndpointsScrollOffset = 0
367
+ state.installEndpointsErrorMsg = null
368
+ }
369
+
245
370
  return async (str, key) => {
246
371
  if (!key) return
247
372
  noteUserActivity()
@@ -287,6 +412,186 @@ export function createKeyHandler(ctx) {
287
412
  return
288
413
  }
289
414
 
415
+ // 📖 Install Endpoints overlay: provider → tool → scope → optional model subset.
416
+ if (state.installEndpointsOpen) {
417
+ if (key.ctrl && key.name === 'c') { exit(0); return }
418
+
419
+ const providerChoices = getConfiguredInstallableProviders(state.config)
420
+ const toolChoices = getInstallTargetModes()
421
+ const modelChoices = state.installEndpointsProviderKey
422
+ ? getProviderCatalogModels(state.installEndpointsProviderKey)
423
+ : []
424
+ const pageStep = Math.max(1, (state.terminalRows || 1) - 4)
425
+
426
+ const maxIndexByPhase = () => {
427
+ if (state.installEndpointsPhase === 'providers') return Math.max(0, providerChoices.length - 1)
428
+ if (state.installEndpointsPhase === 'tools') return Math.max(0, toolChoices.length - 1)
429
+ if (state.installEndpointsPhase === 'scope') return 1
430
+ if (state.installEndpointsPhase === 'models') return Math.max(0, modelChoices.length - 1)
431
+ return 0
432
+ }
433
+
434
+ if (key.name === 'up') {
435
+ state.installEndpointsCursor = Math.max(0, state.installEndpointsCursor - 1)
436
+ return
437
+ }
438
+ if (key.name === 'down') {
439
+ state.installEndpointsCursor = Math.min(maxIndexByPhase(), state.installEndpointsCursor + 1)
440
+ return
441
+ }
442
+ if (key.name === 'pageup') {
443
+ state.installEndpointsCursor = Math.max(0, state.installEndpointsCursor - pageStep)
444
+ return
445
+ }
446
+ if (key.name === 'pagedown') {
447
+ state.installEndpointsCursor = Math.min(maxIndexByPhase(), state.installEndpointsCursor + pageStep)
448
+ return
449
+ }
450
+ if (key.name === 'home') {
451
+ state.installEndpointsCursor = 0
452
+ return
453
+ }
454
+ if (key.name === 'end') {
455
+ state.installEndpointsCursor = maxIndexByPhase()
456
+ return
457
+ }
458
+
459
+ if (key.name === 'escape') {
460
+ state.installEndpointsErrorMsg = null
461
+ if (state.installEndpointsPhase === 'providers' || state.installEndpointsPhase === 'result') {
462
+ resetInstallEndpointsOverlay()
463
+ return
464
+ }
465
+ if (state.installEndpointsPhase === 'tools') {
466
+ state.installEndpointsPhase = 'providers'
467
+ state.installEndpointsCursor = 0
468
+ state.installEndpointsScrollOffset = 0
469
+ return
470
+ }
471
+ if (state.installEndpointsPhase === 'scope') {
472
+ state.installEndpointsPhase = 'tools'
473
+ state.installEndpointsCursor = 0
474
+ state.installEndpointsScrollOffset = 0
475
+ return
476
+ }
477
+ if (state.installEndpointsPhase === 'models') {
478
+ state.installEndpointsPhase = 'scope'
479
+ state.installEndpointsCursor = state.installEndpointsScope === 'selected' ? 1 : 0
480
+ state.installEndpointsScrollOffset = 0
481
+ return
482
+ }
483
+ }
484
+
485
+ if (state.installEndpointsPhase === 'providers') {
486
+ if (key.name === 'return') {
487
+ const selectedProvider = providerChoices[state.installEndpointsCursor]
488
+ if (!selectedProvider) {
489
+ state.installEndpointsErrorMsg = '⚠ No installable configured provider is available yet.'
490
+ return
491
+ }
492
+ state.installEndpointsProviderKey = selectedProvider.providerKey
493
+ state.installEndpointsToolMode = null
494
+ state.installEndpointsScope = null
495
+ state.installEndpointsSelectedModelIds = new Set()
496
+ state.installEndpointsPhase = 'tools'
497
+ state.installEndpointsCursor = 0
498
+ state.installEndpointsScrollOffset = 0
499
+ state.installEndpointsErrorMsg = null
500
+ }
501
+ return
502
+ }
503
+
504
+ if (state.installEndpointsPhase === 'tools') {
505
+ if (key.name === 'return') {
506
+ const selectedToolMode = toolChoices[state.installEndpointsCursor]
507
+ if (!selectedToolMode) return
508
+ state.installEndpointsToolMode = selectedToolMode
509
+ state.installEndpointsPhase = 'scope'
510
+ state.installEndpointsCursor = 0
511
+ state.installEndpointsScrollOffset = 0
512
+ state.installEndpointsErrorMsg = null
513
+ }
514
+ return
515
+ }
516
+
517
+ if (state.installEndpointsPhase === 'scope') {
518
+ if (key.name === 'return') {
519
+ state.installEndpointsScope = state.installEndpointsCursor === 1 ? 'selected' : 'all'
520
+ state.installEndpointsScrollOffset = 0
521
+ state.installEndpointsErrorMsg = null
522
+ if (state.installEndpointsScope === 'all') {
523
+ try {
524
+ await runInstallEndpointsFlow()
525
+ } catch (error) {
526
+ state.installEndpointsResult = {
527
+ type: 'error',
528
+ title: 'Install failed',
529
+ lines: [error instanceof Error ? error.message : String(error)],
530
+ }
531
+ state.installEndpointsPhase = 'result'
532
+ }
533
+ return
534
+ }
535
+
536
+ state.installEndpointsSelectedModelIds = new Set()
537
+ state.installEndpointsPhase = 'models'
538
+ state.installEndpointsCursor = 0
539
+ }
540
+ return
541
+ }
542
+
543
+ if (state.installEndpointsPhase === 'models') {
544
+ if (key.name === 'a') {
545
+ if (state.installEndpointsSelectedModelIds.size === modelChoices.length) {
546
+ state.installEndpointsSelectedModelIds = new Set()
547
+ } else {
548
+ state.installEndpointsSelectedModelIds = new Set(modelChoices.map((model) => model.modelId))
549
+ }
550
+ state.installEndpointsErrorMsg = null
551
+ return
552
+ }
553
+
554
+ if (key.name === 'space') {
555
+ const selectedModel = modelChoices[state.installEndpointsCursor]
556
+ if (!selectedModel) return
557
+ const next = new Set(state.installEndpointsSelectedModelIds)
558
+ if (next.has(selectedModel.modelId)) next.delete(selectedModel.modelId)
559
+ else next.add(selectedModel.modelId)
560
+ state.installEndpointsSelectedModelIds = next
561
+ state.installEndpointsErrorMsg = null
562
+ return
563
+ }
564
+
565
+ if (key.name === 'return') {
566
+ if (state.installEndpointsSelectedModelIds.size === 0) {
567
+ state.installEndpointsErrorMsg = '⚠ Select at least one model before installing.'
568
+ return
569
+ }
570
+
571
+ try {
572
+ await runInstallEndpointsFlow()
573
+ } catch (error) {
574
+ state.installEndpointsResult = {
575
+ type: 'error',
576
+ title: 'Install failed',
577
+ lines: [error instanceof Error ? error.message : String(error)],
578
+ }
579
+ state.installEndpointsPhase = 'result'
580
+ }
581
+ }
582
+ return
583
+ }
584
+
585
+ if (state.installEndpointsPhase === 'result') {
586
+ if (key.name === 'return' || key.name === 'y') {
587
+ resetInstallEndpointsOverlay()
588
+ }
589
+ return
590
+ }
591
+
592
+ return
593
+ }
594
+
290
595
  // 📖 Feature Request overlay: intercept ALL keys while overlay is active.
291
596
  // 📖 Enter → send to Discord, Esc → cancel, Backspace → delete char, printable → append to buffer.
292
597
  if (state.featureRequestOpen) {
@@ -945,6 +1250,21 @@ export function createKeyHandler(ctx) {
945
1250
  return
946
1251
  }
947
1252
 
1253
+ // 📖 Y key: open Install Endpoints flow for configured providers.
1254
+ if (key.name === 'y') {
1255
+ state.installEndpointsOpen = true
1256
+ state.installEndpointsPhase = 'providers'
1257
+ state.installEndpointsCursor = 0
1258
+ state.installEndpointsScrollOffset = 0
1259
+ state.installEndpointsProviderKey = null
1260
+ state.installEndpointsToolMode = null
1261
+ state.installEndpointsScope = null
1262
+ state.installEndpointsSelectedModelIds = new Set()
1263
+ state.installEndpointsErrorMsg = null
1264
+ state.installEndpointsResult = null
1265
+ return
1266
+ }
1267
+
948
1268
  // 📖 Shift+P: cycle through profiles (or show profile picker)
949
1269
  if (key.name === 'p' && key.shift) {
950
1270
  const profiles = listProfiles(state.config)
@@ -1007,11 +1327,11 @@ export function createKeyHandler(ctx) {
1007
1327
  return
1008
1328
  }
1009
1329
 
1010
- // 📖 Sorting keys: R=rank, Y=tier, O=origin, M=model, L=latest ping, A=avg ping, S=SWE-bench, C=context, H=health, V=verdict, B=stability, U=uptime, G=usage
1011
- // 📖 T is reserved for tier filter cycling tier sort moved to Y
1330
+ // 📖 Sorting keys: R=rank, O=origin, M=model, L=latest ping, A=avg ping, S=SWE-bench, C=context, H=health, V=verdict, B=stability, U=uptime, G=usage
1331
+ // 📖 T is reserved for tier filter cycling. Y now opens the install-endpoints flow.
1012
1332
  // 📖 D is now reserved for provider filter cycling
1013
1333
  const sortKeys = {
1014
- 'r': 'rank', 'y': 'tier', 'o': 'origin', 'm': 'model',
1334
+ 'r': 'rank', 'o': 'origin', 'm': 'model',
1015
1335
  'l': 'ping', 'a': 'avg', 's': 'swe', 'c': 'ctx', 'h': 'condition', 'v': 'verdict', 'b': 'stability', 'u': 'uptime', 'g': 'usage'
1016
1336
  }
1017
1337
 
@@ -1142,6 +1462,14 @@ export function createKeyHandler(ctx) {
1142
1462
  const currentIndex = modeOrder.indexOf(state.mode)
1143
1463
  const nextIndex = (currentIndex + 1) % modeOrder.length
1144
1464
  state.mode = modeOrder[nextIndex]
1465
+ if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
1466
+ state.config.settings.preferredToolMode = state.mode
1467
+ if (state.activeProfile && state.config.profiles?.[state.activeProfile]) {
1468
+ const profile = state.config.profiles[state.activeProfile]
1469
+ if (!profile.settings || typeof profile.settings !== 'object') profile.settings = {}
1470
+ profile.settings.preferredToolMode = state.mode
1471
+ }
1472
+ saveConfig(state.config)
1145
1473
  return
1146
1474
  }
1147
1475
 
package/src/log-reader.js CHANGED
@@ -18,10 +18,13 @@
18
18
  * time: string // ISO timestamp string (from entry.timestamp)
19
19
  * requestType: string // e.g. "chat.completions"
20
20
  * model: string // e.g. "llama-3.3-70b-instruct"
21
+ * requestedModel: string // public proxy model originally requested by the client
21
22
  * provider: string // e.g. "nvidia"
22
23
  * status: string // e.g. "200" | "429" | "error"
23
24
  * tokens: number // promptTokens + completionTokens (0 if unknown)
24
25
  * latency: number // ms (0 if unknown)
26
+ * switched: boolean // true when the router retried on a fallback provider/model
27
+ * switchReason: string|null // short reason label shown in the log UI
25
28
  * }
26
29
  *
27
30
  * @exports loadRecentLogs
@@ -88,7 +91,7 @@ function inferRequestType(entry) {
88
91
  * the required `timestamp` field.
89
92
  *
90
93
  * @param {string} line - A single text line from the JSONL file.
91
- * @returns {{ time: string, requestType: string, model: string, provider: string, status: string, tokens: number, latency: number } | null}
94
+ * @returns {{ time: string, requestType: string, model: string, requestedModel: string, provider: string, status: string, tokens: number, latency: number, switched: boolean, switchReason: string|null, switchedFromProvider: string|null, switchedFromModel: string|null } | null}
92
95
  */
93
96
  export function parseLogLine(line) {
94
97
  const trimmed = line.trim()
@@ -106,21 +109,39 @@ export function parseLogLine(line) {
106
109
  if (!normalizedTime) return null
107
110
 
108
111
  const model = String(entry.modelId ?? entry.model ?? 'unknown')
112
+ const requestedModel = typeof entry.requestedModelId === 'string'
113
+ ? entry.requestedModelId
114
+ : (typeof entry.requestedModel === 'string' ? entry.requestedModel : '')
109
115
  const provider = inferProvider(entry)
110
116
  const status = inferStatus(entry)
111
117
  const requestType = inferRequestType(entry)
112
118
  const tokens = (Number(entry.usage?.prompt_tokens ?? entry.promptTokens ?? 0) +
113
119
  Number(entry.usage?.completion_tokens ?? entry.completionTokens ?? 0)) || 0
114
120
  const latency = Number(entry.latencyMs ?? entry.latency ?? 0) || 0
121
+ const switched = entry.switched === true
122
+ const switchReason = typeof entry.switchReason === 'string' && entry.switchReason.trim().length > 0
123
+ ? entry.switchReason.trim()
124
+ : null
125
+ const switchedFromProvider = typeof entry.switchedFromProviderKey === 'string' && entry.switchedFromProviderKey.trim().length > 0
126
+ ? entry.switchedFromProviderKey.trim()
127
+ : null
128
+ const switchedFromModel = typeof entry.switchedFromModelId === 'string' && entry.switchedFromModelId.trim().length > 0
129
+ ? entry.switchedFromModelId.trim()
130
+ : null
115
131
 
116
132
  return {
117
133
  time: normalizedTime,
118
134
  requestType,
119
135
  model,
136
+ requestedModel,
120
137
  provider,
121
138
  status,
122
139
  tokens,
123
140
  latency,
141
+ switched,
142
+ switchReason,
143
+ switchedFromProvider,
144
+ switchedFromModel,
124
145
  }
125
146
  }
126
147
 
@@ -133,7 +154,7 @@ export function parseLogLine(line) {
133
154
  * @param {object} [opts]
134
155
  * @param {string} [opts.logFile] - Path to request-log.jsonl (injectable for tests)
135
156
  * @param {number} [opts.limit] - Maximum rows to return (default 200)
136
- * @returns {Array<{ time: string, requestType: string, model: string, provider: string, status: string, tokens: number, latency: number }>}
157
+ * @returns {Array<{ time: string, requestType: string, model: string, requestedModel: string, provider: string, status: string, tokens: number, latency: number, switched: boolean, switchReason: string|null, switchedFromProvider: string|null, switchedFromModel: string|null }>}
137
158
  */
138
159
  export function loadRecentLogs({ logFile = DEFAULT_LOG_FILE, limit = 200 } = {}) {
139
160
  try {
package/src/opencode.js CHANGED
@@ -66,6 +66,19 @@ export function setOpenCodeModelData(mergedModels, mergedModelByLabel) {
66
66
  mergedModelByLabelRef = mergedModelByLabel instanceof Map ? mergedModelByLabel : new Map()
67
67
  }
68
68
 
69
+ /**
70
+ * 📖 resolveProxyModelId maps a selected provider-specific model to the shared
71
+ * 📖 proxy catalog slug used by `fcm-proxy`. The proxy exposes merged slugs, not
72
+ * 📖 upstream provider ids, so every launcher that targets the proxy must use this.
73
+ *
74
+ * @param {{ label?: string, modelId?: string }} model
75
+ * @returns {string}
76
+ */
77
+ export function resolveProxyModelId(model) {
78
+ const merged = mergedModelByLabelRef.get(model?.label)
79
+ return merged?.slug ?? model?.modelId ?? ''
80
+ }
81
+
69
82
  // 📖 isTcpPortAvailable: checks if a local TCP port is free for OpenCode.
70
83
  // 📖 Used to avoid tmux sub-agent port conflicts when multiple projects run in parallel.
71
84
  function isTcpPortAvailable(port) {
@@ -648,8 +661,7 @@ export async function autoStartProxyIfSynced(fcmConfig, state) {
648
661
  export async function startProxyAndLaunch(model, fcmConfig) {
649
662
  try {
650
663
  const started = await ensureProxyRunning(fcmConfig, { forceRestart: true })
651
- const merged = mergedModelByLabelRef.get(model.label)
652
- const defaultProxyModelId = merged?.slug ?? model.modelId
664
+ const defaultProxyModelId = resolveProxyModelId(model)
653
665
 
654
666
  if (!started.proxyModels || Object.keys(started.proxyModels).length === 0) {
655
667
  throw new Error('Proxy model catalog is empty')