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/key-handler.js
CHANGED
|
@@ -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'|'
|
|
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 '
|
|
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
|
-
|
|
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) {
|
|
196
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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,
|
|
1011
|
-
// 📖 T is reserved for tier filter cycling
|
|
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', '
|
|
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
|
|
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')
|