free-coding-models 0.5.34 → 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.
- package/README.md +2 -0
- package/changelog/v0.5.35.md +4 -0
- package/changelog/v0.5.36.md +13 -0
- package/package.json +1 -1
- package/src/core/provider-key-tester.js +369 -0
- package/src/core/router-daemon.js +53 -19
- package/src/tui/key-handler.js +30 -301
- package/web/dist/assets/{index-zzUvtumw.js → index-DH3ml-b4.js} +2 -2
- package/web/dist/index.html +1 -1
- package/web/server.js +14 -9
package/README.md
CHANGED
|
@@ -676,6 +676,7 @@ Telemetry is enabled by default and can be disabled with any of the following:
|
|
|
676
676
|
<td align="center" width="120"><a href="https://github.com/chindris-mihai-alexandru"><img src="https://avatars.githubusercontent.com/u/12643176?v=4&s=80" width="80" height="80" style="border-radius:50%" alt="chindris-mihai-alexandru"></a></td>
|
|
677
677
|
<td align="center" width="120"><a href="https://github.com/serajbaltu"><img src="https://avatars.githubusercontent.com/u/90699173?v=4&s=80" width="80" height="80" style="border-radius:50%" alt="serajbaltu"></a></td>
|
|
678
678
|
<td align="center" width="120"><a href="https://github.com/stgreenb"><img src="https://avatars.githubusercontent.com/u/18483964?v=4&s=80" width="80" height="80" style="border-radius:50%" alt="stgreenb"></a></td>
|
|
679
|
+
<td align="center" width="120"><a href="https://github.com/MoriDanWork"><img src="https://avatars.githubusercontent.com/u/55363096?v=4&s=80" width="80" height="80" style="border-radius:50%" alt="MoriDanWork"></a></td>
|
|
679
680
|
</tr>
|
|
680
681
|
<tr>
|
|
681
682
|
<td align="center"><a href="https://github.com/vava-nessa"><sub><b>vava-nessa</b></sub></a></td>
|
|
@@ -686,6 +687,7 @@ Telemetry is enabled by default and can be disabled with any of the following:
|
|
|
686
687
|
<td align="center"><a href="https://github.com/chindris-mihai-alexandru"><sub><b>chindris-mihai-alexandru</b></sub></a></td>
|
|
687
688
|
<td align="center"><a href="https://github.com/serajbaltu"><sub><b>serajbaltu</b></sub></a></td>
|
|
688
689
|
<td align="center"><a href="https://github.com/stgreenb"><sub><b>stgreenb</b></sub></a></td>
|
|
690
|
+
<td align="center"><a href="https://github.com/MoriDanWork"><sub><b>MoriDanWork</b></sub></a></td>
|
|
689
691
|
</tr>
|
|
690
692
|
</table>
|
|
691
693
|
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
# Changelog v0.5.35 - 2026-06-17
|
|
2
|
+
|
|
3
|
+
### Notes
|
|
4
|
+
- **Registry note: 0.5.33 was skipped in the auto-publish.** The 0.5.33 release (ZCode tool mode + web modal refactor) exists in git history as commit `ecf740c` with its full changelog file `changelog/v0.5.33.md`, but the GitHub Actions publish workflow did not register a `0.5.33` package on npm — the registry jumped directly from `0.5.32` to `0.5.34`. Per project policy of never skipping versions, this `0.5.35` release is a registry marker: same code as `0.5.34` (schema normalizer + ZCode + web modal refactor), published to close the version gap. Users who installed `0.5.34` already have all the same functionality. CI was investigated; the root cause is being tracked separately. No code changes in this release.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog v0.5.36 - 2026-06-20
|
|
2
|
+
|
|
3
|
+
### Fixed
|
|
4
|
+
- **Router priority ordering now respects explicit priority over circuit state.** Previously, routing candidates were ordered strictly by circuit state (`CLOSED` candidates always before `HALF_OPEN`), then by priority and health score. This meant a high-priority model sitting in `HALF_OPEN` recovery could be skipped in favor of a lower-priority `CLOSED` model — defeating the whole point of explicit priority levels. The comparator now sorts by **explicit priority first**, then by circuit state (`CLOSED` before `HALF_OPEN`), then by health score. Higher-priority models are never again jumped over just because they are mid-recovery.
|
|
5
|
+
|
|
6
|
+
### Changed
|
|
7
|
+
- **Probe loop hardening.** `scheduleProbeLoop()` now clears a stale `probeWatchdog` timer alongside the main probe timer, tracks `lastProbeAt` timestamps after every successful cycle, and wraps the scheduling body in a `try/catch` so a single failed probe cycle can no longer destabilize the background routing daemon. Results in more reliable health-check cadences across the CLI, web, and Tauri surfaces (shared core).
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **Public re-exports of the provider-key-test helpers** from `key-handler.js` (`buildProviderModelsUrl`, `parseProviderModelIds`, `listProviderTestModels`, `classifyProviderTestOutcome`, `buildProviderTestDetail`). Completes the shared-module refactor introduced in #121 (provider key testing moved to `src/core/provider-key-tester.js`) so the TUI key-handler exposes the same surface other modules already consume.
|
|
11
|
+
|
|
12
|
+
### Docs
|
|
13
|
+
- **Credited @MoriDanWork** in the README contributors table and the AGENTS.md contributor list for PR #121 (move provider key testing to a shared module).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "free-coding-models",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.36",
|
|
4
4
|
"description": "Find the fastest coding LLM models in seconds — ping free models from multiple providers, pick the best one for OpenCode, Cursor, or any AI coding assistant.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nvidia",
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file provider-key-tester.js
|
|
3
|
+
* @description Shared provider API key verification logic used by both TUI and Web surfaces.
|
|
4
|
+
*
|
|
5
|
+
* @details
|
|
6
|
+
* This module extracts the key-testing pipeline that was previously embedded in
|
|
7
|
+
* `src/tui/key-handler.js` so the Web Dashboard's `/api/key/:provider/test` endpoint
|
|
8
|
+
* can use the exact same strategy without duplicating code.
|
|
9
|
+
*
|
|
10
|
+
* Pipeline overview:
|
|
11
|
+
* 1. Fast path — parallel auth-only probes (3×8 s) to `/v1/account` or `/v1/models`.
|
|
12
|
+
* Decisive: 200 → key valid, 401/403 → key rejected. Timeouts fall through.
|
|
13
|
+
* 2. Slow path — optional live `/models` discovery, then parallel chat-completion
|
|
14
|
+
* pings against candidate model IDs (up to 10 attempts, 5 parallel).
|
|
15
|
+
*
|
|
16
|
+
* → Functions:
|
|
17
|
+
* - `testProviderKeyDirect` — fast auth-only check
|
|
18
|
+
* - `buildProviderModelsUrl` — derive `/models` from `/chat/completions`
|
|
19
|
+
* - `parseProviderModelIds` — extract ids from OpenAI `/models` response
|
|
20
|
+
* - `listProviderTestModels` — build ordered candidate list
|
|
21
|
+
* - `classifyProviderTestOutcome` — map HTTP codes to outcome label
|
|
22
|
+
* - `buildProviderTestDetail` — human-readable failure explanation
|
|
23
|
+
* - `runProviderKeyTest` — full async pipeline, returns result object
|
|
24
|
+
*
|
|
25
|
+
* @exports testProviderKeyDirect, buildProviderModelsUrl, parseProviderModelIds,
|
|
26
|
+
* listProviderTestModels, classifyProviderTestOutcome, buildProviderTestDetail,
|
|
27
|
+
* runProviderKeyTest, PROVIDER_AUTH_ENDPOINTS
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { ping } from './ping.js'
|
|
31
|
+
import { sleep } from './shared-helpers.js'
|
|
32
|
+
|
|
33
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
// 📖 Some providers need an explicit probe model because the first catalog entry
|
|
36
|
+
// 📖 is not guaranteed to be accepted by their chat endpoint.
|
|
37
|
+
export const PROVIDER_TEST_MODEL_OVERRIDES = {
|
|
38
|
+
sambanova: ['MiniMax-M2.5', 'DeepSeek-V3.1', 'DeepSeek-V3.2'],
|
|
39
|
+
nvidia: ['deepseek-ai/deepseek-v4-flash', 'openai/gpt-oss-120b'],
|
|
40
|
+
'github-models': ['openai/gpt-4.1-mini'],
|
|
41
|
+
mistral: ['mistral-small-latest', 'devstral-small-latest'],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 📖 Settings key tests retry retryable failures across several models so a
|
|
45
|
+
// 📖 single stale catalog entry or transient timeout does not mark a valid key as dead.
|
|
46
|
+
const SETTINGS_TEST_MAX_ATTEMPTS = 10
|
|
47
|
+
const SETTINGS_TEST_RETRY_DELAY_MS = 4000
|
|
48
|
+
const SETTINGS_TEST_PARALLEL_PROBES = 5
|
|
49
|
+
|
|
50
|
+
// 📖 PROVIDER_AUTH_ENDPOINTS maps provider keys to their auth-check URL + method.
|
|
51
|
+
// 📖 For most providers this is the /models endpoint (returns 200=valid, 401=invalid).
|
|
52
|
+
// 📖 Providers without an auth-check endpoint use null (falls back to chat completion ping).
|
|
53
|
+
// 📖 Special cases:
|
|
54
|
+
// 📖 - replicate: uses /v1/predictions (not /models) but needs a different payload
|
|
55
|
+
// 📖 - cloudflare: no auth endpoint — only has chat completions, always uses ping fallback
|
|
56
|
+
export const PROVIDER_AUTH_ENDPOINTS = {
|
|
57
|
+
nvidia: { url: 'https://api.nvidia.com/v1/account', method: 'GET' },
|
|
58
|
+
groq: { url: 'https://api.groq.com/v1/models', method: 'GET' },
|
|
59
|
+
cerebras: { url: 'https://api.cerebras.ai/v1/models', method: 'GET' },
|
|
60
|
+
sambanova: { url: 'https://api.sambanova.ai/v1/models', method: 'GET' },
|
|
61
|
+
openrouter: { url: 'https://openrouter.ai/api/v1/key', method: 'GET' },
|
|
62
|
+
mistral: { url: 'https://api.mistral.ai/v1/models', method: 'GET' },
|
|
63
|
+
huggingface: { url: 'https://router.huggingface.co/v1/models', method: 'GET' },
|
|
64
|
+
deepinfra: { url: 'https://api.deepinfra.com/v1/models', method: 'GET' },
|
|
65
|
+
fireworks: { url: 'https://api.fireworks.ai/v1/models', method: 'GET' },
|
|
66
|
+
hyperbolic: { url: 'https://api.hyperbolic.xyz/v1/models', method: 'GET' },
|
|
67
|
+
scaleway: { url: 'https://api.scaleway.ai/v1/models', method: 'GET' },
|
|
68
|
+
siliconflow: { url: 'https://api.siliconflow.com/v1/models', method: 'GET' },
|
|
69
|
+
together: { url: 'https://api.together.xyz/v1/models', method: 'GET' },
|
|
70
|
+
perplexity: { url: 'https://api.perplexity.ai/v1/models', method: 'GET' },
|
|
71
|
+
chutes: { url: 'https://chutes.ai/v1/models', method: 'GET' },
|
|
72
|
+
ovhcloud: { url: 'https://oai.endpoints.kepler.ai.cloud.ovh.net/v1/models', method: 'GET' },
|
|
73
|
+
qwen: { url: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models', method: 'GET' },
|
|
74
|
+
iflow: { url: 'https://apis.iflow.cn/v1/models', method: 'GET' },
|
|
75
|
+
'github-models': null,
|
|
76
|
+
replicate: null,
|
|
77
|
+
cloudflare: null,
|
|
78
|
+
zai: null,
|
|
79
|
+
googleai: null,
|
|
80
|
+
'opencode-zen': null,
|
|
81
|
+
kilo: { url: 'https://api.kilo.ai/api/gateway/models', method: 'GET' },
|
|
82
|
+
llm7: { url: 'https://api.llm7.io/v1/models', method: 'GET' },
|
|
83
|
+
routeway: { url: 'https://api.routeway.ai/v1/models', method: 'GET' },
|
|
84
|
+
novita: { url: 'https://api.novita.ai/openai/v1/models', method: 'GET' },
|
|
85
|
+
'ollama-cloud': { url: 'https://ollama.com/v1/models', method: 'GET' },
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── Auth-only fast probe ────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 📖 testProviderKeyDirect: Fast auth-only check using /v1/account or /v1/models.
|
|
92
|
+
* 📖 Fires 3 parallel probes to get a fast decisive result (auth error vs timeout vs 200).
|
|
93
|
+
* 📖 Returns { code, ms } from the first non-timeout response, or the best available.
|
|
94
|
+
* @param {string} apiKey
|
|
95
|
+
* @param {string} providerKey
|
|
96
|
+
* @returns {Promise<{ code: number|string, ms: number|string } | null>}
|
|
97
|
+
*/
|
|
98
|
+
export async function testProviderKeyDirect(apiKey, providerKey) {
|
|
99
|
+
const authConfig = PROVIDER_AUTH_ENDPOINTS[providerKey]
|
|
100
|
+
if (!authConfig) return null
|
|
101
|
+
|
|
102
|
+
const { url, method } = authConfig
|
|
103
|
+
const headers = { Authorization: `Bearer ${apiKey}` }
|
|
104
|
+
if (providerKey === 'openrouter') {
|
|
105
|
+
headers['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
|
|
106
|
+
headers['X-Title'] = 'free-coding-models'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const parallel = 3
|
|
110
|
+
const promises = Array.from({ length: parallel }, async () => {
|
|
111
|
+
const ctrl = new AbortController()
|
|
112
|
+
const timer = setTimeout(() => ctrl.abort(), 8000)
|
|
113
|
+
const t0 = performance.now()
|
|
114
|
+
try {
|
|
115
|
+
const resp = await fetch(url, { method, headers, signal: ctrl.signal })
|
|
116
|
+
return { code: resp.status, ms: Math.round(performance.now() - t0) }
|
|
117
|
+
} catch (err) {
|
|
118
|
+
const isTimeout = err.name === 'AbortError'
|
|
119
|
+
return { code: isTimeout ? '000' : 'ERR', ms: isTimeout ? 'TIMEOUT' : Math.round(performance.now() - t0) }
|
|
120
|
+
} finally {
|
|
121
|
+
clearTimeout(timer)
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
const results = await Promise.all(promises)
|
|
126
|
+
const success = results.find(r => r.code === 200)
|
|
127
|
+
if (success) return success
|
|
128
|
+
const authFailure = results.find(r => r.code === 401 || r.code === 403)
|
|
129
|
+
if (authFailure) return authFailure
|
|
130
|
+
return results[0]
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ─── Model discovery helpers ─────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 📖 buildProviderModelsUrl derives the matching `/models` endpoint for providers
|
|
137
|
+
* 📖 that expose an OpenAI-compatible model list next to `/chat/completions`.
|
|
138
|
+
* @param {string} url
|
|
139
|
+
* @returns {string|null}
|
|
140
|
+
*/
|
|
141
|
+
export function buildProviderModelsUrl(url) {
|
|
142
|
+
if (typeof url !== 'string' || !url.includes('/chat/completions')) return null
|
|
143
|
+
return url.replace(/\/chat\/completions$/, '/models')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 📖 parseProviderModelIds extracts ids from a standard OpenAI-style `/models` response.
|
|
148
|
+
* 📖 Invalid payloads return an empty list so the key-test flow can safely fall back.
|
|
149
|
+
* @param {unknown} data
|
|
150
|
+
* @returns {string[]}
|
|
151
|
+
*/
|
|
152
|
+
export function parseProviderModelIds(data) {
|
|
153
|
+
if (!data || typeof data !== 'object' || !Array.isArray(data.data)) return []
|
|
154
|
+
return data.data
|
|
155
|
+
.map(entry => (entry && typeof entry.id === 'string') ? entry.id.trim() : '')
|
|
156
|
+
.filter(Boolean)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 📖 listProviderTestModels builds the ordered probe list used by the Settings `T` key.
|
|
161
|
+
* 📖 Order matters:
|
|
162
|
+
* 📖 1. provider-specific known-good overrides
|
|
163
|
+
* 📖 2. discovered `/models` ids that also exist in this repo
|
|
164
|
+
* 📖 3. all discovered `/models` ids
|
|
165
|
+
* 📖 4. repo static model ids as final fallback
|
|
166
|
+
* @param {string} providerKey
|
|
167
|
+
* @param {{ models?: Array<[string, string, string, string, string]> } | undefined} src
|
|
168
|
+
* @param {string[]} [discoveredModelIds=[]]
|
|
169
|
+
* @returns {string[]}
|
|
170
|
+
*/
|
|
171
|
+
export function listProviderTestModels(providerKey, src, discoveredModelIds = []) {
|
|
172
|
+
const staticModelIds = Array.isArray(src?.models) ? src.models.map(model => model[0]).filter(Boolean) : []
|
|
173
|
+
const staticModelSet = new Set(staticModelIds)
|
|
174
|
+
const preferredDiscoveredIds = discoveredModelIds.filter(modelId => staticModelSet.has(modelId))
|
|
175
|
+
const orderedCandidates = [
|
|
176
|
+
...(PROVIDER_TEST_MODEL_OVERRIDES[providerKey] ?? []),
|
|
177
|
+
...preferredDiscoveredIds,
|
|
178
|
+
...discoveredModelIds,
|
|
179
|
+
...staticModelIds,
|
|
180
|
+
]
|
|
181
|
+
return [...new Set(orderedCandidates)]
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ─── Outcome classification ──────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 📖 classifyProviderTestOutcome maps attempted probe codes to a user-facing test result.
|
|
188
|
+
* @param {string[]} codes
|
|
189
|
+
* @returns {'ok'|'auth_error'|'rate_limited'|'no_callable_model'|'fail'}
|
|
190
|
+
*/
|
|
191
|
+
export function classifyProviderTestOutcome(codes) {
|
|
192
|
+
if (codes.includes('200')) return 'ok'
|
|
193
|
+
if (codes.includes('401') || codes.includes('403')) return 'auth_error'
|
|
194
|
+
if (codes.length > 0 && codes.every(code => code === '429')) return 'rate_limited'
|
|
195
|
+
if (codes.length > 0 && codes.every(code => code === '404' || code === '410')) return 'no_callable_model'
|
|
196
|
+
return 'fail'
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 📖 buildProviderTestDetail explains why the probe failed, with enough context
|
|
201
|
+
* 📖 for the user to know whether the key, model list, or provider quota is the problem.
|
|
202
|
+
* @param {string} providerLabel
|
|
203
|
+
* @param {string} outcome
|
|
204
|
+
* @param {Array<{attempt: number, model: string, code: string}>} [attempts=[]]
|
|
205
|
+
* @param {string} [discoveryNote='']
|
|
206
|
+
* @returns {string}
|
|
207
|
+
*/
|
|
208
|
+
export function buildProviderTestDetail(providerLabel, outcome, attempts = [], discoveryNote = '') {
|
|
209
|
+
const introByOutcome = {
|
|
210
|
+
missing_key: `${providerLabel} has no saved API key right now, so no authenticated test could be sent.`,
|
|
211
|
+
ok: `${providerLabel} accepted the key.`,
|
|
212
|
+
auth_error: `${providerLabel} rejected the configured key with an authentication error.`,
|
|
213
|
+
rate_limited: `${providerLabel} throttled every probe, so the key may still be valid but is currently rate-limited.`,
|
|
214
|
+
no_callable_model: `${providerLabel} answered the requests, but none of the probed models were callable on its chat endpoint.`,
|
|
215
|
+
fail: `${providerLabel} never returned a successful probe during the retry window.`,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const hintsByOutcome = {
|
|
219
|
+
missing_key: 'Save the key with Enter in Settings, then rerun T.',
|
|
220
|
+
ok: attempts.length > 0 ? `Validated on ${attempts[attempts.length - 1].model}.` : 'The provider returned a success response.',
|
|
221
|
+
auth_error: 'This usually means the saved key is invalid, expired, revoked, or truncated before it reached disk.',
|
|
222
|
+
rate_limited: 'Wait for the provider quota window to reset, then rerun T.',
|
|
223
|
+
no_callable_model: 'The provider catalog or repo defaults likely drifted; try another model family or refresh the catalog.',
|
|
224
|
+
fail: 'This can be caused by timeouts, 5xx responses, or a provider-side outage.',
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const attemptSummary = attempts.length > 0
|
|
228
|
+
? `Attempts: ${attempts.map(({ attempt, model, code }) => `#${attempt} ${model} -> ${code}`).join(' | ')}`
|
|
229
|
+
: 'Attempts: none'
|
|
230
|
+
|
|
231
|
+
const segments = [
|
|
232
|
+
introByOutcome[outcome] || introByOutcome.fail,
|
|
233
|
+
hintsByOutcome[outcome] || hintsByOutcome.fail,
|
|
234
|
+
discoveryNote,
|
|
235
|
+
attemptSummary,
|
|
236
|
+
].filter(Boolean)
|
|
237
|
+
|
|
238
|
+
return segments.join(' ')
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ─── Full pipeline ───────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 📖 runProviderKeyTest: Pure async function that verifies an API key for a provider.
|
|
245
|
+
*
|
|
246
|
+
* 📖 Used by both the TUI Settings `T` key and the Web Dashboard's
|
|
247
|
+
* 📖 `/api/key/:provider/test` endpoint. Returns a result object instead of
|
|
248
|
+
* 📖 mutating TUI state so each surface can decide how to display it.
|
|
249
|
+
*
|
|
250
|
+
* @param {string} apiKey — the API key to test
|
|
251
|
+
* @param {string} providerKey — e.g. 'openrouter', 'groq'
|
|
252
|
+
* @param {{ name?: string, url?: string, models?: Array<[string, string, string, string, string]> }} source — provider entry from sources.js
|
|
253
|
+
* @param {object} [options]
|
|
254
|
+
* @param {number} [options.maxAttempts=10]
|
|
255
|
+
* @param {number} [options.parallelProbes=5]
|
|
256
|
+
* @param {number} [options.retryDelayMs=4000]
|
|
257
|
+
* @param {Function} [options.onProgress] — optional callback({ attempts, maxAttempts }) for live updates
|
|
258
|
+
* @returns {Promise<{ outcome: string, detail: string, attempts: Array, discoveryNote: string }>}
|
|
259
|
+
*/
|
|
260
|
+
export async function runProviderKeyTest(apiKey, providerKey, source, options = {}) {
|
|
261
|
+
const {
|
|
262
|
+
maxAttempts = SETTINGS_TEST_MAX_ATTEMPTS,
|
|
263
|
+
parallelProbes = SETTINGS_TEST_PARALLEL_PROBES,
|
|
264
|
+
retryDelayMs = SETTINGS_TEST_RETRY_DELAY_MS,
|
|
265
|
+
onProgress,
|
|
266
|
+
} = options
|
|
267
|
+
|
|
268
|
+
const providerLabel = source?.name || providerKey
|
|
269
|
+
|
|
270
|
+
// 📖 Fast path: parallel auth-only probes (3×8s) to /v1/account or /v1/models.
|
|
271
|
+
const authResult = await testProviderKeyDirect(apiKey, providerKey)
|
|
272
|
+
if (authResult) {
|
|
273
|
+
if (authResult.code === 200) {
|
|
274
|
+
return {
|
|
275
|
+
outcome: 'ok',
|
|
276
|
+
detail: buildProviderTestDetail(providerLabel, 'ok', [], 'Auth-only probe returned HTTP 200.'),
|
|
277
|
+
attempts: [],
|
|
278
|
+
discoveryNote: 'Auth-only probe returned HTTP 200.',
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (authResult.code === 401 || authResult.code === 403) {
|
|
282
|
+
return {
|
|
283
|
+
outcome: 'auth_error',
|
|
284
|
+
detail: buildProviderTestDetail(providerLabel, 'auth_error', [], `Auth probe returned HTTP ${authResult.code}.`),
|
|
285
|
+
attempts: [],
|
|
286
|
+
discoveryNote: `Auth probe returned HTTP ${authResult.code}.`,
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// 📖 Timeout or ERR — fall through to ping-based approach below.
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// 📖 Slow path: ping-based verification (providers without auth endpoint or timeouts).
|
|
293
|
+
const discoveredModelIds = []
|
|
294
|
+
const modelsUrl = buildProviderModelsUrl(source?.url)
|
|
295
|
+
let discoveryNote = ''
|
|
296
|
+
|
|
297
|
+
if (modelsUrl) {
|
|
298
|
+
try {
|
|
299
|
+
const headers = { Authorization: `Bearer ${apiKey}` }
|
|
300
|
+
if (providerKey === 'openrouter') {
|
|
301
|
+
headers['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
|
|
302
|
+
headers['X-Title'] = 'free-coding-models'
|
|
303
|
+
}
|
|
304
|
+
const modelsResp = await fetch(modelsUrl, { headers })
|
|
305
|
+
if (modelsResp.ok) {
|
|
306
|
+
const data = await modelsResp.json()
|
|
307
|
+
discoveredModelIds.push(...parseProviderModelIds(data))
|
|
308
|
+
discoveryNote = discoveredModelIds.length > 0
|
|
309
|
+
? `Live model discovery returned ${discoveredModelIds.length} ids.`
|
|
310
|
+
: 'Live model discovery succeeded but returned no callable ids.'
|
|
311
|
+
} else {
|
|
312
|
+
discoveryNote = `Live model discovery returned HTTP ${modelsResp.status}; falling back to the repo catalog.`
|
|
313
|
+
}
|
|
314
|
+
} catch (err) {
|
|
315
|
+
discoveryNote = `Live model discovery failed (${err?.name || 'error'}); falling back to the repo catalog.`
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const candidateModels = listProviderTestModels(providerKey, source, discoveredModelIds)
|
|
320
|
+
if (candidateModels.length === 0) {
|
|
321
|
+
return {
|
|
322
|
+
outcome: 'fail',
|
|
323
|
+
detail: buildProviderTestDetail(providerLabel, 'fail', [], discoveryNote || 'No candidate model was available for probing.'),
|
|
324
|
+
attempts: [],
|
|
325
|
+
discoveryNote: discoveryNote || 'No candidate model was available for probing.',
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// 📖 Parallel ping burst: fire probes simultaneously to get fast feedback.
|
|
330
|
+
const attempts = []
|
|
331
|
+
let settled = false
|
|
332
|
+
|
|
333
|
+
while (!settled) {
|
|
334
|
+
const batch = []
|
|
335
|
+
for (let i = 0; i < parallelProbes && attempts.length + batch.length < maxAttempts; i++) {
|
|
336
|
+
const testModel = candidateModels[(attempts.length + batch.length) % candidateModels.length]
|
|
337
|
+
batch.push(
|
|
338
|
+
ping(apiKey, testModel, providerKey, source.url)
|
|
339
|
+
.then(({ code }) => ({ attempt: attempts.length + batch.length + 1, model: testModel, code }))
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
const batchResults = await Promise.all(batch)
|
|
343
|
+
attempts.push(...batchResults)
|
|
344
|
+
|
|
345
|
+
if (onProgress) onProgress({ attempts: attempts.length, maxAttempts })
|
|
346
|
+
|
|
347
|
+
// 📖 Check outcome after each parallel batch.
|
|
348
|
+
const outcome = classifyProviderTestOutcome(attempts.map(({ code }) => code))
|
|
349
|
+
if (outcome === 'ok' || outcome === 'auth_error') {
|
|
350
|
+
settled = true
|
|
351
|
+
continue
|
|
352
|
+
}
|
|
353
|
+
if (attempts.length >= maxAttempts) {
|
|
354
|
+
settled = true
|
|
355
|
+
continue
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// 📖 Pause before next round.
|
|
359
|
+
await sleep(retryDelayMs)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const finalOutcome = classifyProviderTestOutcome(attempts.map(({ code }) => code))
|
|
363
|
+
return {
|
|
364
|
+
outcome: finalOutcome,
|
|
365
|
+
detail: buildProviderTestDetail(providerLabel, finalOutcome, attempts, discoveryNote),
|
|
366
|
+
attempts,
|
|
367
|
+
discoveryNote,
|
|
368
|
+
}
|
|
369
|
+
}
|
|
@@ -1193,12 +1193,24 @@ class RouterRuntime {
|
|
|
1193
1193
|
if (!this.getApiKeyForProvider(candidate.provider)) return false
|
|
1194
1194
|
return candidate.circuit?.state === 'CLOSED' || candidate.circuit?.state === 'HALF_OPEN'
|
|
1195
1195
|
})
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
// 📖
|
|
1199
|
-
// 📖
|
|
1200
|
-
const
|
|
1201
|
-
|
|
1196
|
+
// 📖 New ordering: prioritize by explicit priority first, then by circuit state
|
|
1197
|
+
// 📖 (CLOSED before HALF_OPEN), and finally by health score (higher is better).
|
|
1198
|
+
// 📖 This ensures a higher‑priority model is never skipped just because it is
|
|
1199
|
+
// 📖 in HALF_OPEN while a lower‑priority CLOSED model is available.
|
|
1200
|
+
const stateOrder = { CLOSED: 0, HALF_OPEN: 1 }
|
|
1201
|
+
const comparator = (a, b) => {
|
|
1202
|
+
if (a.priority !== b.priority) return a.priority - b.priority
|
|
1203
|
+
const aState = a.circuit?.state || 'UNKNOWN'
|
|
1204
|
+
const bState = b.circuit?.state || 'UNKNOWN'
|
|
1205
|
+
if (aState !== bState) {
|
|
1206
|
+
const aRank = stateOrder[aState] ?? 2
|
|
1207
|
+
const bRank = stateOrder[bState] ?? 2
|
|
1208
|
+
return aRank - bRank
|
|
1209
|
+
}
|
|
1210
|
+
// higher score first
|
|
1211
|
+
return b.score - a.score
|
|
1212
|
+
}
|
|
1213
|
+
return usable.sort(comparator)
|
|
1202
1214
|
}
|
|
1203
1215
|
|
|
1204
1216
|
// 📖 getRoutingOrder - slim projection of getRoutingCandidates for the /stats
|
|
@@ -1738,27 +1750,48 @@ class RouterRuntime {
|
|
|
1738
1750
|
}
|
|
1739
1751
|
|
|
1740
1752
|
scheduleProbeLoop() {
|
|
1753
|
+
// Clear any existing timers
|
|
1741
1754
|
if (this.probeTimer) clearInterval(this.probeTimer)
|
|
1755
|
+
if (this.probeWatchdog) clearInterval(this.probeWatchdog)
|
|
1742
1756
|
for (const timeout of this.probeTimeouts) clearTimeout(timeout)
|
|
1743
1757
|
this.probeTimeouts.clear()
|
|
1758
|
+
|
|
1744
1759
|
const router = this.routerConfig()
|
|
1745
1760
|
const interval = router.probeIntervals[router.probeMode] || DEFAULT_ROUTER_SETTINGS.probeIntervals.balanced
|
|
1761
|
+
// Track last successful probe cycle timestamp
|
|
1762
|
+
this.lastProbeAt = Date.now()
|
|
1763
|
+
|
|
1746
1764
|
this.probeTimer = setInterval(() => {
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1765
|
+
try {
|
|
1766
|
+
const set = this.getSet()
|
|
1767
|
+
if (!set || this.shuttingDown) return
|
|
1768
|
+
const candidates = this.scoreCandidates(set)
|
|
1769
|
+
.filter((candidate) => candidate.catalog?.routeable && !candidate.circuit?.stale)
|
|
1770
|
+
const stagger = candidates.length > 0 ? Math.max(250, Math.floor(interval / candidates.length)) : interval
|
|
1771
|
+
candidates.forEach((candidate, index) => {
|
|
1772
|
+
const timeout = setTimeout(() => {
|
|
1773
|
+
this.probeTimeouts.delete(timeout)
|
|
1774
|
+
void this.probeCandidate(candidate, { eco: router.probeMode === 'eco' })
|
|
1775
|
+
}, index * stagger)
|
|
1776
|
+
timeout.unref?.()
|
|
1777
|
+
this.probeTimeouts.add(timeout)
|
|
1778
|
+
})
|
|
1779
|
+
// Update timestamp after scheduling probes
|
|
1780
|
+
this.lastProbeAt = Date.now()
|
|
1781
|
+
} catch (err) {
|
|
1782
|
+
this.logger.error('[ProbeLoop] error', { error: err })
|
|
1783
|
+
}
|
|
1760
1784
|
}, interval)
|
|
1761
1785
|
this.probeTimer.unref?.()
|
|
1786
|
+
|
|
1787
|
+
// Watchdog: if no successful cycle for 3x interval, restart loop
|
|
1788
|
+
this.probeWatchdog = setInterval(() => {
|
|
1789
|
+
if (this.lastProbeAt && Date.now() - this.lastProbeAt > interval * 3) {
|
|
1790
|
+
this.logger.warn('[ProbeLoop] stall detected, restarting probe loop')
|
|
1791
|
+
this.scheduleProbeLoop()
|
|
1792
|
+
}
|
|
1793
|
+
}, interval)
|
|
1794
|
+
this.probeWatchdog.unref?.()
|
|
1762
1795
|
}
|
|
1763
1796
|
|
|
1764
1797
|
async routeRequest({ req, res, body, setName, requestId }) {
|
|
@@ -2357,6 +2390,7 @@ class RouterRuntime {
|
|
|
2357
2390
|
this.markSetCustomized()
|
|
2358
2391
|
this.broadcast('set_change', { activeSet: this.routerConfig().activeSet, set: name, action: 'add', model: newEntry })
|
|
2359
2392
|
sendJson(res, 201, { set: normalized.sets[name], router: normalized }, { 'x-request-id': requestId })
|
|
2393
|
+
void this.runProbeBurst()
|
|
2360
2394
|
return
|
|
2361
2395
|
}
|
|
2362
2396
|
|