free-coding-models 0.5.35 → 0.5.36

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.
@@ -10,22 +10,11 @@
10
10
  *
11
11
  * 📖 Key I opens the changelog overlay.
12
12
  *
13
- * It also owns the "test key" model selection used by the Settings overlay.
14
- * Anonymous telemetry hooks for model launches and a few high-signal settings
15
- * actions live here too, because this module already sees the final effective
16
- * tool mode, provider, and selected model right before the app hands control
17
- * to an external CLI.
18
- * Some providers expose models in `/v1/models` that are not actually callable
19
- * on the chat-completions endpoint. To avoid false negatives when a user
20
- * presses `T` in Settings, the helpers below discover candidate model IDs,
21
- * merge them with repo defaults, then probe several until one is accepted.
13
+ * It also owns the "test key" model selection used by the Settings overlay,
14
+ * delegated to the shared `provider-key-tester.js` module for consistency
15
+ * with the Web Dashboard's `/api/key/:provider/test` endpoint.
22
16
  *
23
17
  * → Functions:
24
- * - `buildProviderModelsUrl` — derive the matching `/models` endpoint when available
25
- * - `parseProviderModelIds` — extract model ids from an OpenAI-style `/models` payload
26
- * - `listProviderTestModels` — build an ordered candidate list for provider key verification
27
- * - `classifyProviderTestOutcome` — convert attempted HTTP codes into a settings badge state
28
- * - `buildProviderTestDetail` — turn probe attempts into a readable failure explanation
29
18
  * - `createKeyHandler` — returns the async keypress handler
30
19
  *
31
20
  * @exports { buildProviderModelsUrl, parseProviderModelIds, listProviderTestModels, classifyProviderTestOutcome, buildProviderTestDetail, createKeyHandler }
@@ -39,7 +28,6 @@ import { join, dirname } from 'node:path'
39
28
  import { fileURLToPath } from 'node:url'
40
29
  import { spawn } from 'node:child_process'
41
30
  import { cleanupLegacyProxyArtifacts } from '../core/legacy-proxy-cleanup.js'
42
- import { sleep } from '../core/shared-helpers.js'
43
31
  import { getLastLayout, COLUMN_SORT_MAP } from './render-table.js'
44
32
  import { cycleThemeSetting, detectActiveTheme } from './theme.js'
45
33
  import { syncShellEnv, ensureShellRcSource, removeShellEnv } from '../core/shell-env.js'
@@ -64,20 +52,15 @@ import {
64
52
  } from '../core/playground.js'
65
53
  import { benchmarkModel } from '../core/benchmark.js'
66
54
  import { isPackageDevMode } from '../core/updater.js'
67
-
68
- // 📖 Some providers need an explicit probe model because the first catalog entry
69
- // 📖 is not guaranteed to be accepted by their chat endpoint.
70
- const PROVIDER_TEST_MODEL_OVERRIDES = {
71
- sambanova: ['MiniMax-M2.5', 'DeepSeek-V3.1', 'DeepSeek-V3.2'],
72
- nvidia: ['deepseek-ai/deepseek-v4-flash', 'openai/gpt-oss-120b'],
73
- 'github-models': ['openai/gpt-4.1-mini'],
74
- mistral: ['mistral-small-latest', 'devstral-small-latest'],
75
- }
76
-
77
- // 📖 Settings key tests retry retryable failures across several models so a
78
- // 📖 single stale catalog entry or transient timeout does not mark a valid key as dead.
79
- const SETTINGS_TEST_MAX_ATTEMPTS = 10
80
- const SETTINGS_TEST_RETRY_DELAY_MS = 4000
55
+ import {
56
+ runProviderKeyTest,
57
+ testProviderKeyDirect,
58
+ buildProviderModelsUrl,
59
+ parseProviderModelIds,
60
+ listProviderTestModels,
61
+ classifyProviderTestOutcome,
62
+ buildProviderTestDetail,
63
+ } from '../core/provider-key-tester.js'
81
64
 
82
65
  // 📖 spawnDaemonCommand — spawns `--daemon-bg` or `--daemon-stop` and captures
83
66
  // 📖 the JSON result from stdout so we can surface startup errors to the user.
@@ -125,185 +108,6 @@ function spawnDaemonCommand(state, args) {
125
108
  })
126
109
  }
127
110
 
128
- // 📖 PROVIDER_AUTH_ENDPOINTS maps provider keys to their auth-check URL + method.
129
- // 📖 For most providers this is the /models endpoint (returns 200=valid, 401=invalid).
130
- // 📖 Providers without an auth-check endpoint use null (falls back to chat completion ping).
131
- // 📖 Special cases:
132
- // 📖 - replicate: uses /v1/predictions (not /models) but needs a different payload
133
- // 📖 - cloudflare: no auth endpoint — only has chat completions, always uses ping fallback
134
- const PROVIDER_AUTH_ENDPOINTS = {
135
- nvidia: { url: 'https://api.nvidia.com/v1/account', method: 'GET' },
136
- groq: { url: 'https://api.groq.com/v1/models', method: 'GET' },
137
- cerebras: { url: 'https://api.cerebras.ai/v1/models', method: 'GET' },
138
- sambanova: { url: 'https://api.sambanova.ai/v1/models', method: 'GET' },
139
- openrouter: { url: 'https://openrouter.ai/api/v1/key', method: 'GET' },
140
- mistral: { url: 'https://api.mistral.ai/v1/models', method: 'GET' },
141
- huggingface: { url: 'https://router.huggingface.co/v1/models', method: 'GET' },
142
- deepinfra: { url: 'https://api.deepinfra.com/v1/models', method: 'GET' },
143
- fireworks: { url: 'https://api.fireworks.ai/v1/models', method: 'GET' },
144
- hyperbolic: { url: 'https://api.hyperbolic.xyz/v1/models', method: 'GET' },
145
- scaleway: { url: 'https://api.scaleway.ai/v1/models', method: 'GET' },
146
- siliconflow: { url: 'https://api.siliconflow.com/v1/models', method: 'GET' },
147
- together: { url: 'https://api.together.xyz/v1/models', method: 'GET' },
148
- perplexity: { url: 'https://api.perplexity.ai/v1/models', method: 'GET' },
149
- chutes: { url: 'https://chutes.ai/v1/models', method: 'GET' },
150
- ovhcloud: { url: 'https://oai.endpoints.kepler.ai.cloud.ovh.net/v1/models', method: 'GET' },
151
- qwen: { url: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models', method: 'GET' },
152
- iflow: { url: 'https://apis.iflow.cn/v1/models', method: 'GET' },
153
- 'github-models': null, // 📖 GitHub Models catalog is public; use chat ping to validate the token.
154
- replicate: null, // 📖 Replicate has no /models endpoint; use chat completions ping
155
- cloudflare: null, // 📖 Workers AI has no auth-check endpoint; use ping only
156
- zai: null, // 📖 ZAI undocumented; use ping only
157
- googleai: null, // 📖 Google AI Studio has no OpenAI-compatible /models; use ping
158
- 'opencode-zen': null, // 📖 OpenCode Zen uses OpenCode auth only; use ping
159
- kilo: { url: 'https://api.kilo.ai/api/gateway/models', method: 'GET' },
160
- llm7: { url: 'https://api.llm7.io/v1/models', method: 'GET' },
161
- routeway: { url: 'https://api.routeway.ai/v1/models', method: 'GET' },
162
- novita: { url: 'https://api.novita.ai/openai/v1/models', method: 'GET' },
163
- 'ollama-cloud': { url: 'https://ollama.com/v1/models', method: 'GET' },
164
- }
165
-
166
- // 📖 Sleep imported from shared-helpers.js
167
-
168
- // 📖 testProviderKeyDirect: Fast auth-only check using /v1/account or /v1/models.
169
- // 📖 Fires 3 parallel probes to get a fast decisive result (auth error vs timeout vs 200).
170
- // 📖 Returns { code, ms } from the first non-timeout response, or the best available.
171
- async function testProviderKeyDirect(apiKey, providerKey) {
172
- const authConfig = PROVIDER_AUTH_ENDPOINTS[providerKey]
173
- if (!authConfig) return null
174
-
175
- const { url, method } = authConfig
176
- const headers = { Authorization: `Bearer ${apiKey}` }
177
- if (providerKey === 'openrouter') {
178
- headers['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
179
- headers['X-Title'] = 'free-coding-models'
180
- }
181
-
182
- const parallel = 3
183
- const promises = Array.from({ length: parallel }, async () => {
184
- const ctrl = new AbortController()
185
- const timer = setTimeout(() => ctrl.abort(), 8000)
186
- const t0 = performance.now()
187
- try {
188
- const resp = await fetch(url, { method, headers, signal: ctrl.signal })
189
- return { code: resp.status, ms: Math.round(performance.now() - t0) }
190
- } catch (err) {
191
- const isTimeout = err.name === 'AbortError'
192
- return { code: isTimeout ? '000' : 'ERR', ms: isTimeout ? 'TIMEOUT' : Math.round(performance.now() - t0) }
193
- } finally {
194
- clearTimeout(timer)
195
- }
196
- })
197
-
198
- const results = await Promise.all(promises)
199
- const success = results.find(r => r.code === 200)
200
- if (success) return success
201
- const authFailure = results.find(r => r.code === 401 || r.code === 403)
202
- if (authFailure) return authFailure
203
- return results[0]
204
- }
205
-
206
- /**
207
- * 📖 buildProviderModelsUrl derives the matching `/models` endpoint for providers
208
- * 📖 that expose an OpenAI-compatible model list next to `/chat/completions`.
209
- * @param {string} url
210
- * @returns {string|null}
211
- */
212
- export function buildProviderModelsUrl(url) {
213
- if (typeof url !== 'string' || !url.includes('/chat/completions')) return null
214
- return url.replace(/\/chat\/completions$/, '/models')
215
- }
216
-
217
- /**
218
- * 📖 parseProviderModelIds extracts ids from a standard OpenAI-style `/models` response.
219
- * 📖 Invalid payloads return an empty list so the key-test flow can safely fall back.
220
- * @param {unknown} data
221
- * @returns {string[]}
222
- */
223
- export function parseProviderModelIds(data) {
224
- if (!data || typeof data !== 'object' || !Array.isArray(data.data)) return []
225
- return data.data
226
- .map(entry => (entry && typeof entry.id === 'string') ? entry.id.trim() : '')
227
- .filter(Boolean)
228
- }
229
-
230
- /**
231
- * 📖 listProviderTestModels builds the ordered probe list used by the Settings `T` key.
232
- * 📖 Order matters:
233
- * 📖 1. provider-specific known-good overrides
234
- * 📖 2. discovered `/models` ids that also exist in this repo
235
- * 📖 3. all discovered `/models` ids
236
- * 📖 4. repo static model ids as final fallback
237
- * @param {string} providerKey
238
- * @param {{ models?: Array<[string, string, string, string, string]> } | undefined} src
239
- * @param {string[]} [discoveredModelIds=[]]
240
- * @returns {string[]}
241
- */
242
- export function listProviderTestModels(providerKey, src, discoveredModelIds = []) {
243
- const staticModelIds = Array.isArray(src?.models) ? src.models.map(model => model[0]).filter(Boolean) : []
244
- const staticModelSet = new Set(staticModelIds)
245
- const preferredDiscoveredIds = discoveredModelIds.filter(modelId => staticModelSet.has(modelId))
246
- const orderedCandidates = [
247
- ...(PROVIDER_TEST_MODEL_OVERRIDES[providerKey] ?? []),
248
- ...preferredDiscoveredIds,
249
- ...discoveredModelIds,
250
- ...staticModelIds,
251
- ]
252
- return [...new Set(orderedCandidates)]
253
- }
254
-
255
- /**
256
- * 📖 classifyProviderTestOutcome maps attempted probe codes to a user-facing test result.
257
- * 📖 This keeps Settings more honest than a binary success/fail badge:
258
- * 📖 - `rate_limited` means the key is valid but the provider is currently throttling
259
- * 📖 - `no_callable_model` means the provider responded, but none of the attempted models were callable
260
- * @param {string[]} codes
261
- * @returns {'ok'|'auth_error'|'rate_limited'|'no_callable_model'|'fail'}
262
- */
263
- export function classifyProviderTestOutcome(codes) {
264
- if (codes.includes('200')) return 'ok'
265
- if (codes.includes('401') || codes.includes('403')) return 'auth_error'
266
- if (codes.length > 0 && codes.every(code => code === '429')) return 'rate_limited'
267
- if (codes.length > 0 && codes.every(code => code === '404' || code === '410')) return 'no_callable_model'
268
- return 'fail'
269
- }
270
-
271
- // 📖 buildProviderTestDetail explains why the Settings `T` probe failed, with
272
- // 📖 enough context for the user to know whether the key, model list, or provider
273
- // 📖 quota is the problem.
274
- export function buildProviderTestDetail(providerLabel, outcome, attempts = [], discoveryNote = '') {
275
- const introByOutcome = {
276
- missing_key: `${providerLabel} has no saved API key right now, so no authenticated test could be sent.`,
277
- ok: `${providerLabel} accepted the key.`,
278
- auth_error: `${providerLabel} rejected the configured key with an authentication error.`,
279
- rate_limited: `${providerLabel} throttled every probe, so the key may still be valid but is currently rate-limited.`,
280
- no_callable_model: `${providerLabel} answered the requests, but none of the probed models were callable on its chat endpoint.`,
281
- fail: `${providerLabel} never returned a successful probe during the retry window.`,
282
- }
283
-
284
- const hintsByOutcome = {
285
- missing_key: 'Save the key with Enter in Settings, then rerun T.',
286
- ok: attempts.length > 0 ? `Validated on ${attempts[attempts.length - 1].model}.` : 'The provider returned a success response.',
287
- auth_error: 'This usually means the saved key is invalid, expired, revoked, or truncated before it reached disk.',
288
- rate_limited: 'Wait for the provider quota window to reset, then rerun T.',
289
- no_callable_model: 'The provider catalog or repo defaults likely drifted; try another model family or refresh the catalog.',
290
- fail: 'This can be caused by timeouts, 5xx responses, or a provider-side outage.',
291
- }
292
-
293
- const attemptSummary = attempts.length > 0
294
- ? `Attempts: ${attempts.map(({ attempt, model, code }) => `#${attempt} ${model} -> ${code}`).join(' | ')}`
295
- : 'Attempts: none'
296
-
297
- const segments = [
298
- introByOutcome[outcome] || introByOutcome.fail,
299
- hintsByOutcome[outcome] || hintsByOutcome.fail,
300
- discoveryNote,
301
- attemptSummary,
302
- ].filter(Boolean)
303
-
304
- return segments.join(' ')
305
- }
306
-
307
111
  export function createKeyHandler(ctx) {
308
112
  const {
309
113
  state,
@@ -634,10 +438,8 @@ export function createKeyHandler(ctx) {
634
438
  }
635
439
 
636
440
  // ─── Settings key test helper ───────────────────────────────────────────────
637
- // 📖 Verifies an API key by first doing a fast parallel auth-only probe (3×8s)
638
- // 📖 to /v1/account or /v1/models, then falling back to chat completion pings.
639
- // 📖 Auth-only result is decisive (200=valid, 401/403=invalid); only timeouts or
640
- // 📖 providers without auth endpoints fall through to the ping-based approach.
441
+ // 📖 Verifies an API key by delegating to the shared runProviderKeyTest()
442
+ // 📖 pipeline. Wraps the result into TUI state for display.
641
443
  async function testProviderKey(providerKey) {
642
444
  const src = sources[providerKey]
643
445
  if (!src) return
@@ -650,98 +452,17 @@ export function createKeyHandler(ctx) {
650
452
  return
651
453
  }
652
454
 
653
- // 📖 Fast path: parallel auth-only probes (3×8s) to /v1/account or /v1/models.
654
- // 📖 200 = key valid and accepted. 401/403 = key rejected. null = no auth endpoint.
655
- const authResult = await testProviderKeyDirect(testKey, providerKey)
656
- if (authResult) {
657
- if (authResult.code === 200) {
658
- state.settingsTestResults[providerKey] = 'ok'
659
- state.settingsTestDetails[providerKey] = buildProviderTestDetail(providerLabel, 'ok', [], `Auth-only probe returned HTTP 200.`)
660
- return
661
- }
662
- if (authResult.code === 401 || authResult.code === 403) {
663
- state.settingsTestResults[providerKey] = 'auth_error'
664
- state.settingsTestDetails[providerKey] = buildProviderTestDetail(providerLabel, 'auth_error', [], `Auth probe returned HTTP ${authResult.code}.`)
665
- return
666
- }
667
- // 📖 Timeout or ERR — fall through to ping-based approach below.
668
- }
669
-
670
- // 📖 Slow path: ping-based verification (providers without auth endpoint or timeouts).
671
455
  state.settingsTestResults[providerKey] = 'pending'
672
- state.settingsTestDetails[providerKey] = `Testing ${providerLabel} across up to ${SETTINGS_TEST_MAX_ATTEMPTS} probes...`
673
- const discoveredModelIds = []
674
- const modelsUrl = buildProviderModelsUrl(src.url)
675
- let discoveryNote = ''
676
-
677
- if (modelsUrl) {
678
- try {
679
- const headers = { Authorization: `Bearer ${testKey}` }
680
- if (providerKey === 'openrouter') {
681
- headers['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
682
- headers['X-Title'] = 'free-coding-models'
683
- }
684
- const modelsResp = await fetch(modelsUrl, { headers })
685
- if (modelsResp.ok) {
686
- const data = await modelsResp.json()
687
- discoveredModelIds.push(...parseProviderModelIds(data))
688
- discoveryNote = discoveredModelIds.length > 0
689
- ? `Live model discovery returned ${discoveredModelIds.length} ids.`
690
- : 'Live model discovery succeeded but returned no callable ids.'
691
- } else {
692
- discoveryNote = `Live model discovery returned HTTP ${modelsResp.status}; falling back to the repo catalog.`
693
- }
694
- } catch (err) {
695
- discoveryNote = `Live model discovery failed (${err?.name || 'error'}); falling back to the repo catalog.`
696
- }
697
- }
456
+ state.settingsTestDetails[providerKey] = `Testing ${providerLabel}...`
698
457
 
699
- const candidateModels = listProviderTestModels(providerKey, src, discoveredModelIds)
700
- if (candidateModels.length === 0) {
701
- state.settingsTestResults[providerKey] = 'fail'
702
- state.settingsTestDetails[providerKey] = buildProviderTestDetail(providerLabel, 'fail', [], discoveryNote || 'No candidate model was available for probing.')
703
- return
704
- }
705
-
706
- // 📖 Parallel ping burst: fire up to 5 probes simultaneously to get fast feedback.
707
- const PARALLEL_PROBES = 5
708
- const attempts = []
709
- let settled = false
710
-
711
- while (!settled) {
712
- const batch = []
713
- for (let i = 0; i < PARALLEL_PROBES && attempts.length + batch.length < SETTINGS_TEST_MAX_ATTEMPTS; i++) {
714
- const testModel = candidateModels[(attempts.length + batch.length) % candidateModels.length]
715
- batch.push(ping(testKey, testModel, providerKey, src.url).then(({ code }) => ({ attempt: attempts.length + batch.length + 1, model: testModel, code })))
716
- }
717
- const batchResults = await Promise.all(batch)
718
- attempts.push(...batchResults)
719
-
720
- // 📖 Check outcome after each parallel batch.
721
- const outcome = classifyProviderTestOutcome(attempts.map(({ code }) => code))
722
- if (outcome === 'ok') {
723
- state.settingsTestResults[providerKey] = 'ok'
724
- state.settingsTestDetails[providerKey] = buildProviderTestDetail(providerLabel, 'ok', attempts, discoveryNote)
725
- settled = true
726
- continue
727
- }
728
- if (outcome === 'auth_error') {
729
- state.settingsTestResults[providerKey] = 'auth_error'
730
- state.settingsTestDetails[providerKey] = buildProviderTestDetail(providerLabel, 'auth_error', attempts, discoveryNote)
731
- settled = true
732
- continue
733
- }
734
- if (attempts.length >= SETTINGS_TEST_MAX_ATTEMPTS) {
735
- state.settingsTestResults[providerKey] = outcome
736
- state.settingsTestDetails[providerKey] = buildProviderTestDetail(providerLabel, outcome, attempts, discoveryNote)
737
- settled = true
738
- continue
739
- }
458
+ const result = await runProviderKeyTest(testKey, providerKey, src, {
459
+ onProgress: ({ attempts, maxAttempts }) => {
460
+ state.settingsTestDetails[providerKey] = `Testing ${providerLabel}... ${attempts}/${maxAttempts} probes tried.`
461
+ },
462
+ })
740
463
 
741
- // 📖 Show progress between batches, then pause before next round.
742
- state.settingsTestDetails[providerKey] = `Testing ${providerLabel}... ${attempts.length}/${SETTINGS_TEST_MAX_ATTEMPTS} probes tried. Retrying in ${SETTINGS_TEST_RETRY_DELAY_MS / 1000}s.`
743
- await sleep(SETTINGS_TEST_RETRY_DELAY_MS)
744
- }
464
+ state.settingsTestResults[providerKey] = result.outcome
465
+ state.settingsTestDetails[providerKey] = result.detail
745
466
  }
746
467
 
747
468
  // 📖 Manual update checker from settings; keeps status visible in maintenance row.
@@ -3763,3 +3484,11 @@ export function createMouseEventHandler(ctx) {
3763
3484
  // 📖 Clicks outside any recognized zone are silently ignored.
3764
3485
  }
3765
3486
  }
3487
+
3488
+ export {
3489
+ buildProviderModelsUrl,
3490
+ parseProviderModelIds,
3491
+ listProviderTestModels,
3492
+ classifyProviderTestOutcome,
3493
+ buildProviderTestDetail,
3494
+ };