free-coding-models 0.1.82 → 0.1.84

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.
@@ -0,0 +1,197 @@
1
+ /**
2
+ * @file analysis.js
3
+ * @description Analysis functions for model reliability scoring and dynamic model discovery.
4
+ *
5
+ * @details
6
+ * This module provides high-level analysis functions:
7
+ * - Fiable mode: 10-second reliability analysis to find the most stable model
8
+ * - Dynamic OpenRouter model discovery: Fetch free models from OpenRouter API
9
+ * - Tier filtering with validation
10
+ *
11
+ * 🎯 Key features:
12
+ * - Run 10-second reliability analysis across all models
13
+ * - Find best model based on uptime, avg latency, and stability
14
+ * - Fetch real-time OpenRouter free models (replaces static list)
15
+ * - Tier filtering validation with helpful error messages
16
+ *
17
+ * → Functions:
18
+ * - `runFiableMode`: Analyze models for 10 seconds and output the most reliable one
19
+ * - `filterByTierOrExit`: Filter models by tier, exit with error if tier is invalid
20
+ * - `fetchOpenRouterFreeModels`: Fetch live free models from OpenRouter API
21
+ *
22
+ * 📦 Dependencies:
23
+ * - ../sources.js: MODELS, sources
24
+ * - ../src/utils.js: findBestModel, filterByTier, formatCtxWindow, labelFromId
25
+ * - ../src/config.js: isProviderEnabled, getApiKey
26
+ * - ../src/ping.js: ping
27
+ * - chalk: Terminal colors and formatting
28
+ * - ../src/constants.js: TIER_LETTER_MAP (for validation)
29
+ *
30
+ * ⚙️ Configuration:
31
+ * - Analysis duration: 10 seconds (hardcoded in runFiableMode)
32
+ * - OpenRouter tier map: Known SWE-bench scores for popular models (fallback for unknown)
33
+ *
34
+ * @see {@link ../src/utils.js} findBestModel implementation
35
+ * @see {@link ../src/ping.js} ping implementation
36
+ */
37
+
38
+ import { MODELS, sources } from '../sources.js'
39
+ import { findBestModel, filterByTier, formatCtxWindow, labelFromId, TIER_LETTER_MAP } from '../src/utils.js'
40
+ import { isProviderEnabled, getApiKey } from '../src/config.js'
41
+ import { ping } from '../src/ping.js'
42
+ import chalk from 'chalk'
43
+
44
+ // 📖 runFiableMode: Analyze models for reliability over 10 seconds, output the best one.
45
+ // 📖 Filters to enabled providers with keys, runs initial pings, then waits.
46
+ // 📖 Uses findBestModel() from utils.js to select based on uptime/avg/stability.
47
+ export async function runFiableMode(config) {
48
+ console.log(chalk.cyan(' ⚡ Analyzing models for reliability (10 seconds)...'))
49
+ console.log()
50
+
51
+ // 📖 Only include models from enabled providers that have API keys
52
+ let results = MODELS
53
+ .filter(([,,,,,providerKey]) => {
54
+ return isProviderEnabled(config, providerKey) && getApiKey(config, providerKey)
55
+ })
56
+ .map(([modelId, label, tier, sweScore, ctx, providerKey], i) => ({
57
+ idx: i + 1, modelId, label, tier, sweScore, ctx, providerKey,
58
+ status: 'pending',
59
+ pings: [],
60
+ httpCode: null,
61
+ }))
62
+
63
+ const startTime = Date.now()
64
+ const analysisDuration = 10000 // 10 seconds
65
+
66
+ // 📖 Run initial pings using per-provider API key and URL
67
+ const pingPromises = results.map(r => {
68
+ const rApiKey = getApiKey(config, r.providerKey)
69
+ const url = sources[r.providerKey]?.url
70
+ return ping(rApiKey, r.modelId, r.providerKey, url).then(({ code, ms }) => {
71
+ r.pings.push({ ms, code })
72
+ if (code === '200') {
73
+ r.status = 'up'
74
+ } else if (code === '000') {
75
+ r.status = 'timeout'
76
+ } else {
77
+ r.status = 'down'
78
+ r.httpCode = code
79
+ }
80
+ })
81
+ })
82
+
83
+ await Promise.allSettled(pingPromises)
84
+
85
+ // 📖 Continue pinging for the remaining time
86
+ const remainingTime = Math.max(0, analysisDuration - (Date.now() - startTime))
87
+ if (remainingTime > 0) {
88
+ await new Promise(resolve => setTimeout(resolve, remainingTime))
89
+ }
90
+
91
+ // 📖 Find best model
92
+ const best = findBestModel(results)
93
+
94
+ if (!best) {
95
+ console.log(chalk.red(' ✖ No reliable model found'))
96
+ process.exit(1)
97
+ }
98
+
99
+ // 📖 Output in format: providerName/modelId
100
+ const providerName = sources[best.providerKey]?.name ?? best.providerKey ?? 'nvidia'
101
+ console.log(chalk.green(` ✓ Most reliable model:`))
102
+ console.log(chalk.bold(` ${providerName}/${best.modelId}`))
103
+ console.log()
104
+ console.log(chalk.dim(` 📊 Stats:`))
105
+ const { getAvg, getUptime } = await import('./utils.js')
106
+ console.log(chalk.dim(` Avg ping: ${getAvg(best)}ms`))
107
+ console.log(chalk.dim(` Uptime: ${getUptime(best)}%`))
108
+ console.log(chalk.dim(` Status: ${best.status === 'up' ? '✅ UP' : '❌ DOWN'}`))
109
+
110
+ process.exit(0)
111
+ }
112
+
113
+ // 📖 filterByTierOrExit: Filter models by tier letter (S/A/B/C).
114
+ // 📖 Wrapper around filterByTier() that exits with error message instead of returning null.
115
+ // 📖 This is used by CLI argument parsing to fail fast on invalid tier input.
116
+ export function filterByTierOrExit(results, tierLetter) {
117
+ const filtered = filterByTier(results, tierLetter)
118
+ if (filtered === null) {
119
+ console.error(chalk.red(` ✖ Unknown tier "${tierLetter}". Valid tiers: S, A, B, C`))
120
+ process.exit(1)
121
+ }
122
+ return filtered
123
+ }
124
+
125
+ // ─── Dynamic OpenRouter free model discovery ──────────────────────────────────
126
+ // 📖 Fetches the live list of free models from OpenRouter's public API at startup.
127
+ // 📖 Replaces the static openrouter entries in MODELS with fresh data so new free
128
+ // 📖 models appear automatically without a code update.
129
+ // 📖 Falls back silently to the static list on network failure.
130
+
131
+ // 📖 Known SWE-bench scores for OpenRouter free models.
132
+ // 📖 Keyed by base model ID (without the :free suffix).
133
+ // 📖 Unknown models default to tier 'B' / '25.0%'.
134
+ const OPENROUTER_TIER_MAP = {
135
+ 'qwen/qwen3-coder': ['S+', '70.6%'],
136
+ 'mistralai/devstral-2': ['S+', '72.2%'],
137
+ 'stepfun/step-3.5-flash': ['S+', '74.4%'],
138
+ 'deepseek/deepseek-r1-0528': ['S', '61.0%'],
139
+ 'qwen/qwen3-next-80b-a3b-instruct': ['S', '65.0%'],
140
+ 'openai/gpt-oss-120b': ['S', '60.0%'],
141
+ 'openai/gpt-oss-20b': ['A', '42.0%'],
142
+ 'nvidia/nemotron-3-nano-30b-a3b': ['A', '43.0%'],
143
+ 'meta-llama/llama-3.3-70b-instruct': ['A-', '39.5%'],
144
+ 'mimo-v2-flash': ['A', '45.0%'],
145
+ 'google/gemma-3-27b-it': ['A-', '36.0%'],
146
+ 'google/gemma-3-12b-it': ['B+', '30.0%'],
147
+ 'google/gemma-3-4b-it': ['B', '22.0%'],
148
+ 'google/gemma-3n-e4b-it': ['B', '22.0%'],
149
+ 'google/gemma-3n-e2b-it': ['B', '18.0%'],
150
+ 'meta-llama/llama-3.2-3b-instruct': ['B', '20.0%'],
151
+ 'mistralai/mistral-small-3.1-24b-instruct': ['A-', '35.0%'],
152
+ 'qwen/qwen3-4b': ['B', '22.0%'],
153
+ 'nousresearch/hermes-3-llama-3.1-405b': ['A', '40.0%'],
154
+ 'nvidia/nemotron-nano-9b-v2': ['B+', '28.0%'],
155
+ 'nvidia/nemotron-nano-12b-v2-vl': ['B+', '30.0%'],
156
+ 'z-ai/glm-4.5-air': ['A-', '38.0%'],
157
+ 'arcee-ai/trinity-large-preview': ['A', '40.0%'],
158
+ 'arcee-ai/trinity-mini': ['B+', '28.0%'],
159
+ 'upstage/solar-pro-3': ['A-', '35.0%'],
160
+ 'cognitivecomputations/dolphin-mistral-24b-venice-edition': ['B+', '28.0%'],
161
+ 'liquid/lfm-2.5-1.2b-thinking': ['B', '18.0%'],
162
+ 'liquid/lfm-2.5-1.2b-instruct': ['B', '18.0%'],
163
+ }
164
+
165
+ // 📖 fetchOpenRouterFreeModels: Fetch live free models from OpenRouter API.
166
+ // 📖 Returns array of tuples [modelId, label, tier, sweScore, ctx] or null on failure.
167
+ // 📖 Formats context windows using formatCtxWindow and labels using labelFromId.
168
+ // 📖 Uses OPENROUTER_TIER_MAP for known models; others default to tier 'B'/'25.0%'.
169
+ export async function fetchOpenRouterFreeModels() {
170
+ try {
171
+ const controller = new AbortController()
172
+ const timeout = setTimeout(() => controller.abort(), 5000)
173
+ const res = await fetch('https://openrouter.ai/api/v1/models', {
174
+ signal: controller.signal,
175
+ headers: {
176
+ 'HTTP-Referer': 'https://github.com/vava-nessa/free-coding-models',
177
+ 'X-Title': 'free-coding-models',
178
+ },
179
+ })
180
+ clearTimeout(timeout)
181
+ if (!res.ok) return null
182
+ const json = await res.json()
183
+ if (!json.data || !Array.isArray(json.data)) return null
184
+
185
+ const freeModels = json.data.filter(m => m.id && m.id.endsWith(':free'))
186
+
187
+ return freeModels.map(m => {
188
+ const baseId = m.id.replace(/:free$/, '')
189
+ const [tier, swe] = OPENROUTER_TIER_MAP[baseId] || ['B', '25.0%']
190
+ const ctx = formatCtxWindow(m.context_length)
191
+ const label = labelFromId(m.id)
192
+ return [m.id, label, tier, swe, ctx]
193
+ })
194
+ } catch {
195
+ return null
196
+ }
197
+ }
@@ -85,6 +85,9 @@
85
85
  * → loadConfig() — Read ~/.free-coding-models.json; auto-migrate old plain-text config if needed
86
86
  * → saveConfig(config) — Write config to ~/.free-coding-models.json with 0o600 permissions
87
87
  * → getApiKey(config, providerKey) — Get effective API key (env var override > config > null)
88
+ * → addApiKey(config, providerKey, key) — Append a key (string→array); ignores empty/duplicate
89
+ * → removeApiKey(config, providerKey, index?) — Remove key at index (or last); collapses array-of-1 to string; deletes when empty
90
+ * → listApiKeys(config, providerKey) — Return all keys for a provider as normalized array
88
91
  * → isProviderEnabled(config, providerKey) — Check if provider is enabled (defaults true)
89
92
  * → saveAsProfile(config, name) — Snapshot current apiKeys/providers/favorites/settings into a named profile
90
93
  * → loadProfile(config, name) — Apply a named profile's values onto the live config
@@ -95,6 +98,7 @@
95
98
  * → _emptyProfileSettings() — Default TUI settings for a profile
96
99
  *
97
100
  * @exports loadConfig, saveConfig, getApiKey, isProviderEnabled
101
+ * @exports addApiKey, removeApiKey, listApiKeys — multi-key management helpers
98
102
  * @exports saveAsProfile, loadProfile, listProfiles, deleteProfile
99
103
  * @exports getActiveProfileName, setActiveProfile
100
104
  * @exports CONFIG_PATH — path to the JSON config file
@@ -239,6 +243,124 @@ export function getApiKey(config, providerKey) {
239
243
  return null
240
244
  }
241
245
 
246
+ /**
247
+ * addApiKey: Append a new API key for a provider.
248
+ *
249
+ * - If the provider has no key yet, sets it as a plain string.
250
+ * - If the provider already has one string key, converts to array [existing, new].
251
+ * - If the provider already has an array, pushes the new key.
252
+ * - Ignores empty/whitespace keys.
253
+ * - Ignores exact duplicates (same string already present).
254
+ *
255
+ * @param {object} config — Live config object (will be mutated)
256
+ * @param {string} providerKey — Provider identifier (e.g. 'groq')
257
+ * @param {string} key — New API key to add
258
+ * @returns {boolean} true if added, false if ignored (empty or duplicate)
259
+ */
260
+ export function addApiKey(config, providerKey, key) {
261
+ const trimmed = typeof key === 'string' ? key.trim() : ''
262
+ if (!trimmed) return false
263
+ if (!config.apiKeys) config.apiKeys = {}
264
+ const current = config.apiKeys[providerKey]
265
+ if (!current) {
266
+ config.apiKeys[providerKey] = trimmed
267
+ return true
268
+ }
269
+ if (typeof current === 'string') {
270
+ if (current === trimmed) return false // duplicate
271
+ config.apiKeys[providerKey] = [current, trimmed]
272
+ return true
273
+ }
274
+ if (Array.isArray(current)) {
275
+ if (current.includes(trimmed)) return false // duplicate
276
+ current.push(trimmed)
277
+ return true
278
+ }
279
+ // unknown shape — replace
280
+ config.apiKeys[providerKey] = trimmed
281
+ return true
282
+ }
283
+
284
+ /**
285
+ * removeApiKey: Remove an API key for a provider by index, or remove the last one.
286
+ *
287
+ * - Removes the key at `index` if provided, else removes the last key.
288
+ * - If only one key remains after removal, collapses array to string.
289
+ * - If the last key is removed, deletes the provider entry entirely.
290
+ *
291
+ * @param {object} config — Live config object (will be mutated)
292
+ * @param {string} providerKey — Provider identifier (e.g. 'groq')
293
+ * @param {number} [index] — 0-based index to remove; omit to remove last
294
+ * @returns {boolean} true if a key was removed, false if nothing to remove
295
+ */
296
+ export function removeApiKey(config, providerKey, index) {
297
+ if (!config.apiKeys) return false
298
+ const current = config.apiKeys[providerKey]
299
+ if (!current) return false
300
+
301
+ if (typeof current === 'string') {
302
+ // Only one key — remove it
303
+ delete config.apiKeys[providerKey]
304
+ return true
305
+ }
306
+
307
+ if (Array.isArray(current)) {
308
+ const idx = (index !== undefined && index >= 0 && index < current.length) ? index : current.length - 1
309
+ current.splice(idx, 1)
310
+ if (current.length === 0) {
311
+ delete config.apiKeys[providerKey]
312
+ } else if (current.length === 1) {
313
+ config.apiKeys[providerKey] = current[0] // collapse array-of-1 to string
314
+ }
315
+ return true
316
+ }
317
+
318
+ return false
319
+ }
320
+
321
+ /**
322
+ * listApiKeys: Return all configured API keys for a provider as a normalized array.
323
+ * Empty when no key is configured.
324
+ *
325
+ * @param {object} config
326
+ * @param {string} providerKey
327
+ * @returns {string[]}
328
+ */
329
+ export function listApiKeys(config, providerKey) {
330
+ return resolveApiKeys(config, providerKey)
331
+ }
332
+
333
+ /**
334
+ * Resolve all API keys for a provider as an array.
335
+ * Handles: string → [string], string[] → string[], missing → []
336
+ * Filters empty strings. Falls back to envVarName if no config key.
337
+ */
338
+ export function resolveApiKeys(config, providerKey, envVarName) {
339
+ const raw = config?.apiKeys?.[providerKey]
340
+ let keys = []
341
+ if (Array.isArray(raw)) {
342
+ keys = raw
343
+ } else if (typeof raw === 'string' && raw.length > 0) {
344
+ keys = [raw]
345
+ } else if (envVarName && process.env[envVarName]) {
346
+ keys = [process.env[envVarName]]
347
+ }
348
+ return keys.filter(k => typeof k === 'string' && k.length > 0)
349
+ }
350
+
351
+ /**
352
+ * Normalize config for disk persistence.
353
+ * Single-element arrays collapse to string. Multi-element arrays stay.
354
+ */
355
+ export function normalizeApiKeyConfig(config) {
356
+ if (!config?.apiKeys) return
357
+ for (const [key, val] of Object.entries(config.apiKeys)) {
358
+ if (Array.isArray(val) && val.length === 1) {
359
+ config.apiKeys[key] = val[0]
360
+ }
361
+ }
362
+ }
363
+
242
364
  /**
243
365
  * 📖 isProviderEnabled: Check if a provider is enabled in config.
244
366
  *
@@ -0,0 +1,116 @@
1
+ /**
2
+ * @file constants.js
3
+ * @description Pure terminal/TUI constants extracted from bin/free-coding-models.js.
4
+ *
5
+ * @details
6
+ * This module centralises every "magic number" and escape-sequence constant that
7
+ * the TUI rendering pipeline depends on. Having them here means:
8
+ * - They are importable by unit tests without pulling in the entire CLI entry point.
9
+ * - A single source of truth for column widths, timing values, overlay colours, etc.
10
+ * - `msCell` and `spinCell` live here too because they only depend on `CELL_W`,
11
+ * `FRAMES`, and chalk — all of which are available at module scope.
12
+ *
13
+ * ⚙️ Key configuration:
14
+ * - `PING_TIMEOUT` / `PING_INTERVAL` control how aggressive the health-check loop is.
15
+ * - `FPS` controls animation frame rate (braille spinner).
16
+ * - `COL_MODEL` / `COL_MS` control legacy ping-column widths (retained for compat).
17
+ * - `CELL_W` is derived from `COL_MS` and used by `msCell` / `spinCell`.
18
+ * - `TABLE_HEADER_LINES` + `TABLE_FOOTER_LINES` = `TABLE_FIXED_LINES` must stay in sync
19
+ * with the actual number of lines rendered by `renderTable()` in bin/.
20
+ * - Overlay background colours (chalk.bgRgb) make each overlay panel visually distinct.
21
+ *
22
+ * @functions
23
+ * → msCell(ms) — Formats a latency value into a fixed-width coloured cell string
24
+ * → spinCell(f, o) — Returns a braille spinner cell at frame f with optional offset o
25
+ *
26
+ * @exports
27
+ * ALT_ENTER, ALT_LEAVE, ALT_HOME,
28
+ * PING_TIMEOUT, PING_INTERVAL,
29
+ * FPS, COL_MODEL, COL_MS, CELL_W,
30
+ * FRAMES, TIER_CYCLE,
31
+ * SETTINGS_OVERLAY_BG, HELP_OVERLAY_BG, RECOMMEND_OVERLAY_BG, LOG_OVERLAY_BG,
32
+ * OVERLAY_PANEL_WIDTH,
33
+ * TABLE_HEADER_LINES, TABLE_FOOTER_LINES, TABLE_FIXED_LINES,
34
+ * msCell, spinCell
35
+ *
36
+ * @see bin/free-coding-models.js — main entry point that imports these constants
37
+ * @see src/tier-colors.js — TIER_COLOR map (chalk-dependent, separate module)
38
+ */
39
+
40
+ import chalk from 'chalk'
41
+
42
+ // 📖 Alternate screen ANSI escape sequences used to enter/leave the TUI buffer.
43
+ // 📖 \x1b[?1049h = enter alt screen \x1b[?1049l = leave alt screen
44
+ // 📖 \x1b[?25l = hide cursor \x1b[?25h = show cursor
45
+ // 📖 \x1b[H = cursor to top
46
+ // 📖 \x1b[?7l disables auto-wrap so wide rows clip at the right edge instead of
47
+ // 📖 wrapping to the next line (which would double the row height and overflow).
48
+ export const ALT_ENTER = '\x1b[?1049h\x1b[?25l\x1b[?7l'
49
+ export const ALT_LEAVE = '\x1b[?7h\x1b[?1049l\x1b[?25h'
50
+ export const ALT_HOME = '\x1b[H'
51
+
52
+ // 📖 Timing constants — control how fast the health-check loop runs.
53
+ export const PING_TIMEOUT = 15_000 // 📖 15s per attempt before abort
54
+ export const PING_INTERVAL = 3_000 // 📖 3s between pings for fast model selection feedback
55
+
56
+ // 📖 Animation and column-width constants.
57
+ export const FPS = 12
58
+ export const COL_MODEL = 22
59
+ // 📖 COL_MS = dashes in hline per ping column = visual width including 2 padding spaces.
60
+ // 📖 Max value: 12001ms = 7 chars. padStart(COL_MS-2) fits content, +2 spaces = COL_MS dashes.
61
+ export const COL_MS = 11
62
+
63
+ // 📖 CELL_W = visual content width of a single ms/spinner cell (COL_MS minus 2 border spaces).
64
+ export const CELL_W = COL_MS - 2 // 📖 9 chars of content per ms cell
65
+
66
+ // 📖 Braille spinner frames for the "pinging..." animation.
67
+ export const FRAMES = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏']
68
+
69
+ // 📖 TIER_CYCLE: ordered list of tier-filter states cycled by the T key.
70
+ // 📖 Index 0 = no filter (show all), then each tier name in descending quality order.
71
+ export const TIER_CYCLE = [null, 'S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C']
72
+
73
+ // 📖 Overlay background chalk functions — each overlay panel has a distinct tint
74
+ // 📖 so users can tell Settings, Help, Recommend, and Log panels apart at a glance.
75
+ export const SETTINGS_OVERLAY_BG = chalk.bgRgb(14, 20, 30)
76
+ export const HELP_OVERLAY_BG = chalk.bgRgb(24, 16, 32)
77
+ export const RECOMMEND_OVERLAY_BG = chalk.bgRgb(10, 25, 15) // 📖 Green tint for Smart Recommend
78
+ export const LOG_OVERLAY_BG = chalk.bgRgb(10, 20, 26) // 📖 Dark blue-green tint for Log page
79
+
80
+ // 📖 OVERLAY_PANEL_WIDTH: fixed character width of all overlay panels so background
81
+ // 📖 tint fills the panel consistently regardless of content length.
82
+ export const OVERLAY_PANEL_WIDTH = 116
83
+
84
+ // 📖 Table row-budget constants — must stay in sync with renderTable()'s actual output.
85
+ // 📖 If this drifts, model rows overflow and can push the title row out of view.
86
+ export const TABLE_HEADER_LINES = 4 // 📖 title, spacer, column headers, separator
87
+ export const TABLE_FOOTER_LINES = 5 // 📖 spacer, hints line 1, hints line 2, spacer, credit+links
88
+ export const TABLE_FIXED_LINES = TABLE_HEADER_LINES + TABLE_FOOTER_LINES
89
+
90
+ // ─── Small cell-formatting helpers ────────────────────────────────────────────
91
+
92
+ /**
93
+ * 📖 msCell: Renders a latency measurement into a right-padded coloured cell.
94
+ * 📖 null → dim dash (not yet pinged)
95
+ * 📖 'TIMEOUT' → red TIMEOUT text
96
+ * 📖 <500ms → bright green, <1500ms → yellow, else red
97
+ * @param {number|string|null} ms
98
+ * @returns {string}
99
+ */
100
+ export const msCell = (ms) => {
101
+ if (ms === null) return chalk.dim('—'.padStart(CELL_W))
102
+ const str = String(ms).padStart(CELL_W)
103
+ if (ms === 'TIMEOUT') return chalk.red(str)
104
+ if (ms < 500) return chalk.greenBright(str)
105
+ if (ms < 1500) return chalk.yellow(str)
106
+ return chalk.red(str)
107
+ }
108
+
109
+ /**
110
+ * 📖 spinCell: Returns a braille spinner character padded to CELL_W.
111
+ * 📖 f = current frame index, o = row offset so each row animates differently.
112
+ * @param {number} f - global frame counter
113
+ * @param {number} [o=0] - per-row offset to stagger animation
114
+ * @returns {string}
115
+ */
116
+ export const spinCell = (f, o = 0) => chalk.dim.yellow(FRAMES[(f + o) % FRAMES.length].padEnd(CELL_W))
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Error types:
3
+ * - QUOTA_EXHAUSTED: Skip account until quota resets
4
+ * - RATE_LIMITED: Backoff, try another account
5
+ * - MODEL_CAPACITY: Server overloaded, retry after delay
6
+ * - SERVER_ERROR: Backoff, count toward circuit breaker
7
+ * - AUTH_ERROR: Disable account permanently
8
+ * - NETWORK_ERROR: Connection failure, try another
9
+ * - MODEL_NOT_FOUND: Provider does not have/serve this model; skip and try next account
10
+ * - UNKNOWN: Generic, no retry
11
+ */
12
+ export const ErrorType = {
13
+ QUOTA_EXHAUSTED: 'QUOTA_EXHAUSTED',
14
+ RATE_LIMITED: 'RATE_LIMITED',
15
+ MODEL_CAPACITY: 'MODEL_CAPACITY',
16
+ SERVER_ERROR: 'SERVER_ERROR',
17
+ AUTH_ERROR: 'AUTH_ERROR',
18
+ NETWORK_ERROR: 'NETWORK_ERROR',
19
+ MODEL_NOT_FOUND: 'MODEL_NOT_FOUND',
20
+ UNKNOWN: 'UNKNOWN',
21
+ }
22
+
23
+ const QUOTA_KEYWORDS = ['quota', 'limit exceeded', 'billing', 'insufficient_quota', 'exceeded your']
24
+ const CAPACITY_KEYWORDS = ['overloaded', 'capacity', 'busy', 'unavailable']
25
+ /**
26
+ * Keywords that indicate a provider-level 404/410 means the model is not
27
+ * available on *this account/provider*, not a generic routing 404.
28
+ * These trigger rotation to the next provider rather than forwarding the error.
29
+ */
30
+ const MODEL_NOT_FOUND_KEYWORDS = [
31
+ 'model not found',
32
+ 'inaccessible',
33
+ 'not deployed',
34
+ 'model is not available',
35
+ 'model unavailable',
36
+ 'no such model',
37
+ ]
38
+
39
+ /**
40
+ * Classify the confidence level for a 429 response.
41
+ *
42
+ * Returns:
43
+ * - 'quota_exhaustion_likely' — body contains keywords indicating the account's quota is depleted
44
+ * - 'generic_rate_limit' — plain rate-limit with no quota-specific signal (or non-429 status)
45
+ *
46
+ * @param {number} statusCode
47
+ * @param {string} body
48
+ * @param {Object} headers
49
+ * @returns {'quota_exhaustion_likely'|'generic_rate_limit'}
50
+ */
51
+ export function rateLimitConfidence(statusCode, body, headers) {
52
+ if (statusCode !== 429) return 'generic_rate_limit'
53
+ const bodyLower = (body || '').toLowerCase()
54
+ const isQuota = QUOTA_KEYWORDS.some(kw => bodyLower.includes(kw))
55
+ return isQuota ? 'quota_exhaustion_likely' : 'generic_rate_limit'
56
+ }
57
+
58
+ /**
59
+ * Classify an HTTP error response.
60
+ * @param {number} statusCode - 0 for network errors
61
+ * @param {string} body - Response body text or error message
62
+ * @param {Object} headers - Response headers (lowercased keys)
63
+ * @returns {{ type: string, retryAfterSec: number|null, shouldRetry: boolean, skipAccount: boolean, rateLimitConfidence?: string }}
64
+ */
65
+ export function classifyError(statusCode, body, headers) {
66
+ const bodyLower = (body || '').toLowerCase()
67
+ const retryAfter = headers?.['retry-after']
68
+ const retryAfterSec = retryAfter ? parseInt(retryAfter, 10) || null : null
69
+
70
+ // Network/connection errors
71
+ if (statusCode === 0 || statusCode === undefined) {
72
+ return { type: ErrorType.NETWORK_ERROR, retryAfterSec: 5, shouldRetry: true, skipAccount: false }
73
+ }
74
+
75
+ if (statusCode === 401 || statusCode === 403) {
76
+ return { type: ErrorType.AUTH_ERROR, retryAfterSec: null, shouldRetry: false, skipAccount: true }
77
+ }
78
+
79
+ // Provider-level 404/410: model not found / inaccessible / not deployed on this account.
80
+ // These are NOT generic routing 404s — they mean this specific provider doesn't serve
81
+ // the requested model. Rotate to the next account rather than forwarding the error.
82
+ if (statusCode === 404 || statusCode === 410) {
83
+ const isModelNotFound = MODEL_NOT_FOUND_KEYWORDS.some(kw => bodyLower.includes(kw))
84
+ if (isModelNotFound) {
85
+ return { type: ErrorType.MODEL_NOT_FOUND, retryAfterSec: null, shouldRetry: true, skipAccount: true }
86
+ }
87
+ // Generic 404 (wrong URL, endpoint not found, etc.) — not retryable
88
+ return { type: ErrorType.UNKNOWN, retryAfterSec: null, shouldRetry: false, skipAccount: false }
89
+ }
90
+
91
+ if (statusCode === 429) {
92
+ const isQuota = QUOTA_KEYWORDS.some(kw => bodyLower.includes(kw))
93
+ const confidence = isQuota ? 'quota_exhaustion_likely' : 'generic_rate_limit'
94
+ if (isQuota) {
95
+ return { type: ErrorType.QUOTA_EXHAUSTED, retryAfterSec, shouldRetry: true, skipAccount: true, rateLimitConfidence: confidence }
96
+ }
97
+ return { type: ErrorType.RATE_LIMITED, retryAfterSec, shouldRetry: true, skipAccount: false, rateLimitConfidence: confidence }
98
+ }
99
+
100
+ if (statusCode === 503 || statusCode === 502) {
101
+ return { type: ErrorType.MODEL_CAPACITY, retryAfterSec: retryAfterSec || 5, shouldRetry: true, skipAccount: false }
102
+ }
103
+
104
+ if (statusCode >= 500) {
105
+ return { type: ErrorType.SERVER_ERROR, retryAfterSec: retryAfterSec || 10, shouldRetry: true, skipAccount: false }
106
+ }
107
+
108
+ return { type: ErrorType.UNKNOWN, retryAfterSec: null, shouldRetry: false, skipAccount: false }
109
+ }
110
+
111
+ /**
112
+ * Circuit breaker: CLOSED → OPEN (after threshold failures) → HALF_OPEN (after cooldown) → CLOSED (on success) or → OPEN (on failure)
113
+ */
114
+ export class CircuitBreaker {
115
+ constructor({ threshold = 5, cooldownMs = 60000 } = {}) {
116
+ this.threshold = threshold
117
+ this.cooldownMs = cooldownMs
118
+ this.consecutiveFailures = 0
119
+ this.openedAt = null
120
+ this.state = 'CLOSED'
121
+ }
122
+
123
+ recordFailure() {
124
+ this.consecutiveFailures++
125
+ if (this.consecutiveFailures >= this.threshold || this.state === 'HALF_OPEN') {
126
+ this.state = 'OPEN'
127
+ this.openedAt = Date.now()
128
+ }
129
+ }
130
+
131
+ recordSuccess() {
132
+ this.consecutiveFailures = 0
133
+ this.state = 'CLOSED'
134
+ this.openedAt = null
135
+ }
136
+
137
+ isOpen() {
138
+ if (this.state === 'CLOSED') return false
139
+ if (this.state === 'OPEN' && Date.now() - this.openedAt >= this.cooldownMs) {
140
+ this.state = 'HALF_OPEN'
141
+ return false
142
+ }
143
+ if (this.state === 'HALF_OPEN') return false
144
+ return true
145
+ }
146
+
147
+ isHalfOpen() { return this.state === 'HALF_OPEN' }
148
+
149
+ reset() {
150
+ this.consecutiveFailures = 0
151
+ this.state = 'CLOSED'
152
+ this.openedAt = null
153
+ }
154
+ }