free-coding-models 0.1.67 → 0.1.69

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/lib/config.js CHANGED
@@ -28,7 +28,8 @@
28
28
  * "siliconflow":"sk-xxx",
29
29
  * "together": "together-xxx",
30
30
  * "cloudflare": "cf-xxx",
31
- * "perplexity": "pplx-xxx"
31
+ * "perplexity": "pplx-xxx",
32
+ * "zai": "zai-xxx"
32
33
  * },
33
34
  * "providers": {
34
35
  * "nvidia": { "enabled": true },
@@ -47,18 +48,35 @@
47
48
  * "siliconflow":{ "enabled": true },
48
49
  * "together": { "enabled": true },
49
50
  * "cloudflare": { "enabled": true },
50
- * "perplexity": { "enabled": true }
51
+ * "perplexity": { "enabled": true },
52
+ * "zai": { "enabled": true }
51
53
  * },
52
54
  * "favorites": [
53
55
  * "nvidia/deepseek-ai/deepseek-v3.2"
54
- * },
56
+ * ],
55
57
  * "telemetry": {
56
58
  * "enabled": true,
57
59
  * "consentVersion": 1,
58
60
  * "anonymousId": "anon_550e8400-e29b-41d4-a716-446655440000"
61
+ * },
62
+ * "activeProfile": "work",
63
+ * "profiles": {
64
+ * "work": { "apiKeys": {...}, "providers": {...}, "favorites": [...], "settings": {...} },
65
+ * "personal": { "apiKeys": {...}, "providers": {...}, "favorites": [...], "settings": {...} },
66
+ * "fast": { "apiKeys": {...}, "providers": {...}, "favorites": [...], "settings": {...} }
59
67
  * }
60
68
  * }
61
69
  *
70
+ * 📖 Profiles store a snapshot of the user's configuration. Each profile contains:
71
+ * - apiKeys: API keys per provider (can differ between work/personal setups)
72
+ * - providers: enabled/disabled state per provider
73
+ * - favorites: list of pinned favorite models
74
+ * - settings: extra TUI preferences (tierFilter, sortColumn, sortAsc, pingInterval)
75
+ *
76
+ * 📖 When a profile is loaded via --profile <name> or Shift+P, the main config's
77
+ * apiKeys/providers/favorites are replaced with the profile's values. The profile
78
+ * data itself stays in the profiles section — it's a named snapshot, not a fork.
79
+ *
62
80
  * 📖 Migration: On first run, if the old plain-text ~/.free-coding-models exists
63
81
  * and the new JSON file does not, the old key is auto-migrated as the nvidia key.
64
82
  * The old file is left in place (not deleted) for safety.
@@ -68,8 +86,17 @@
68
86
  * → saveConfig(config) — Write config to ~/.free-coding-models.json with 0o600 permissions
69
87
  * → getApiKey(config, providerKey) — Get effective API key (env var override > config > null)
70
88
  * → isProviderEnabled(config, providerKey) — Check if provider is enabled (defaults true)
89
+ * → saveAsProfile(config, name) — Snapshot current apiKeys/providers/favorites/settings into a named profile
90
+ * → loadProfile(config, name) — Apply a named profile's values onto the live config
91
+ * → listProfiles(config) — Return array of profile names
92
+ * → deleteProfile(config, name) — Remove a named profile
93
+ * → getActiveProfileName(config) — Get the currently active profile name (or null)
94
+ * → setActiveProfile(config, name) — Set which profile is active (null to clear)
95
+ * → _emptyProfileSettings() — Default TUI settings for a profile
71
96
  *
72
- * @exports loadConfig, saveConfig, getApiKey
97
+ * @exports loadConfig, saveConfig, getApiKey, isProviderEnabled
98
+ * @exports saveAsProfile, loadProfile, listProfiles, deleteProfile
99
+ * @exports getActiveProfileName, setActiveProfile
73
100
  * @exports CONFIG_PATH — path to the JSON config file
74
101
  *
75
102
  * @see bin/free-coding-models.js — main CLI that uses these functions
@@ -106,6 +133,8 @@ const ENV_VARS = {
106
133
  together: 'TOGETHER_API_KEY',
107
134
  cloudflare: ['CLOUDFLARE_API_TOKEN', 'CLOUDFLARE_API_KEY'],
108
135
  perplexity: ['PERPLEXITY_API_KEY', 'PPLX_API_KEY'],
136
+ zai: 'ZAI_API_KEY',
137
+ iflow: 'IFLOW_API_KEY',
109
138
  }
110
139
 
111
140
  /**
@@ -137,6 +166,9 @@ export function loadConfig() {
137
166
  if (typeof parsed.telemetry.enabled !== 'boolean') parsed.telemetry.enabled = null
138
167
  if (typeof parsed.telemetry.consentVersion !== 'number') parsed.telemetry.consentVersion = 0
139
168
  if (typeof parsed.telemetry.anonymousId !== 'string' || !parsed.telemetry.anonymousId.trim()) parsed.telemetry.anonymousId = null
169
+ // 📖 Ensure profiles section exists (added in profile system)
170
+ if (!parsed.profiles || typeof parsed.profiles !== 'object') parsed.profiles = {}
171
+ if (parsed.activeProfile && typeof parsed.activeProfile !== 'string') parsed.activeProfile = null
140
172
  return parsed
141
173
  } catch {
142
174
  // 📖 Corrupted JSON — return empty config (user will re-enter keys)
@@ -222,6 +254,129 @@ export function isProviderEnabled(config, providerKey) {
222
254
  return providerConfig.enabled !== false
223
255
  }
224
256
 
257
+ // ─── Config Profiles ──────────────────────────────────────────────────────────
258
+
259
+ /**
260
+ * 📖 _emptyProfileSettings: Default TUI settings stored in a profile.
261
+ *
262
+ * 📖 These settings are saved/restored when switching profiles so each profile
263
+ * can have different sort, filter, and ping preferences.
264
+ *
265
+ * @returns {{ tierFilter: string|null, sortColumn: string, sortAsc: boolean, pingInterval: number }}
266
+ */
267
+ export function _emptyProfileSettings() {
268
+ return {
269
+ tierFilter: null, // 📖 null = show all tiers, or 'S'|'A'|'B'|'C'|'D'
270
+ sortColumn: 'avg', // 📖 default sort column
271
+ sortAsc: true, // 📖 true = ascending (fastest first for latency)
272
+ pingInterval: 8000, // 📖 default ms between pings
273
+ }
274
+ }
275
+
276
+ /**
277
+ * 📖 saveAsProfile: Snapshot the current config state into a named profile.
278
+ *
279
+ * 📖 Takes the current apiKeys, providers, favorites, plus explicit TUI settings
280
+ * and stores them under config.profiles[name]. Does NOT change activeProfile —
281
+ * call setActiveProfile() separately if you want to switch to this profile.
282
+ *
283
+ * 📖 If a profile with the same name exists, it's overwritten.
284
+ *
285
+ * @param {object} config — Live config object (will be mutated)
286
+ * @param {string} name — Profile name (e.g. 'work', 'personal', 'fast')
287
+ * @param {object} [settings] — TUI settings to save (tierFilter, sortColumn, etc.)
288
+ * @returns {object} The config object (for chaining)
289
+ */
290
+ export function saveAsProfile(config, name, settings = null) {
291
+ if (!config.profiles || typeof config.profiles !== 'object') config.profiles = {}
292
+ config.profiles[name] = {
293
+ apiKeys: JSON.parse(JSON.stringify(config.apiKeys || {})),
294
+ providers: JSON.parse(JSON.stringify(config.providers || {})),
295
+ favorites: [...(config.favorites || [])],
296
+ settings: settings ? { ..._emptyProfileSettings(), ...settings } : _emptyProfileSettings(),
297
+ }
298
+ return config
299
+ }
300
+
301
+ /**
302
+ * 📖 loadProfile: Apply a named profile's values onto the live config.
303
+ *
304
+ * 📖 Replaces config.apiKeys, config.providers, config.favorites with the
305
+ * profile's stored values. Also sets config.activeProfile to the loaded name.
306
+ *
307
+ * 📖 Returns the profile's TUI settings so the caller (main CLI) can apply them
308
+ * to the live state object (sortColumn, tierFilter, etc.).
309
+ *
310
+ * 📖 If the profile doesn't exist, returns null (caller should show an error).
311
+ *
312
+ * @param {object} config — Live config object (will be mutated)
313
+ * @param {string} name — Profile name to load
314
+ * @returns {{ tierFilter: string|null, sortColumn: string, sortAsc: boolean, pingInterval: number }|null}
315
+ * The profile's TUI settings, or null if profile not found
316
+ */
317
+ export function loadProfile(config, name) {
318
+ const profile = config?.profiles?.[name]
319
+ if (!profile) return null
320
+
321
+ // 📖 Deep-copy the profile data into the live config (don't share references)
322
+ config.apiKeys = JSON.parse(JSON.stringify(profile.apiKeys || {}))
323
+ config.providers = JSON.parse(JSON.stringify(profile.providers || {}))
324
+ config.favorites = [...(profile.favorites || [])]
325
+ config.activeProfile = name
326
+
327
+ return profile.settings ? { ..._emptyProfileSettings(), ...profile.settings } : _emptyProfileSettings()
328
+ }
329
+
330
+ /**
331
+ * 📖 listProfiles: Get all saved profile names.
332
+ *
333
+ * @param {object} config
334
+ * @returns {string[]} Array of profile names, sorted alphabetically
335
+ */
336
+ export function listProfiles(config) {
337
+ if (!config?.profiles || typeof config.profiles !== 'object') return []
338
+ return Object.keys(config.profiles).sort()
339
+ }
340
+
341
+ /**
342
+ * 📖 deleteProfile: Remove a named profile from the config.
343
+ *
344
+ * 📖 If the deleted profile is the active one, clears activeProfile.
345
+ *
346
+ * @param {object} config — Live config object (will be mutated)
347
+ * @param {string} name — Profile name to delete
348
+ * @returns {boolean} True if the profile existed and was deleted
349
+ */
350
+ export function deleteProfile(config, name) {
351
+ if (!config?.profiles?.[name]) return false
352
+ delete config.profiles[name]
353
+ if (config.activeProfile === name) config.activeProfile = null
354
+ return true
355
+ }
356
+
357
+ /**
358
+ * 📖 getActiveProfileName: Get the currently active profile name.
359
+ *
360
+ * @param {object} config
361
+ * @returns {string|null} Profile name, or null if no profile is active
362
+ */
363
+ export function getActiveProfileName(config) {
364
+ return config?.activeProfile || null
365
+ }
366
+
367
+ /**
368
+ * 📖 setActiveProfile: Set which profile is active (or null to clear).
369
+ *
370
+ * 📖 This just stores the name — it does NOT load the profile's data.
371
+ * Call loadProfile() first to actually apply the profile's values.
372
+ *
373
+ * @param {object} config — Live config object (will be mutated)
374
+ * @param {string|null} name — Profile name, or null to clear
375
+ */
376
+ export function setActiveProfile(config, name) {
377
+ config.activeProfile = name || null
378
+ }
379
+
225
380
  // 📖 Internal helper: create a blank config with the right shape
226
381
  function _emptyConfig() {
227
382
  return {
@@ -235,5 +390,9 @@ function _emptyConfig() {
235
390
  consentVersion: 0,
236
391
  anonymousId: null,
237
392
  },
393
+ // 📖 Active profile name — null means no profile is loaded (using raw config).
394
+ activeProfile: null,
395
+ // 📖 Named profiles: each is a snapshot of apiKeys + providers + favorites + settings.
396
+ profiles: {},
238
397
  }
239
398
  }
package/lib/utils.js CHANGED
@@ -39,7 +39,8 @@
39
39
  *
40
40
  * @exports getAvg, getVerdict, getUptime, getP95, getJitter, getStabilityScore
41
41
  * @exports sortResults, filterByTier, findBestModel, parseArgs
42
- * @exports TIER_ORDER, VERDICT_ORDER, TIER_LETTER_MAP
42
+ * @exports scoreModelForTask, getTopRecommendations
43
+ * @exports TIER_ORDER, VERDICT_ORDER, TIER_LETTER_MAP, TASK_TYPES, PRIORITY_TYPES, CONTEXT_BUDGETS
43
44
  *
44
45
  * @see bin/free-coding-models.js — main CLI that imports these utils
45
46
  * @see sources.js — model definitions consumed by these functions
@@ -385,17 +386,27 @@ export function parseArgs(argv) {
385
386
  let apiKey = null
386
387
  const flags = []
387
388
 
388
- // Determine which arg index is consumed by --tier so we skip it
389
+ // 📖 Determine which arg indices are consumed by --tier and --profile so we skip them
389
390
  const tierIdx = args.findIndex(a => a.toLowerCase() === '--tier')
390
391
  const tierValueIdx = (tierIdx !== -1 && args[tierIdx + 1] && !args[tierIdx + 1].startsWith('--'))
391
392
  ? tierIdx + 1
392
393
  : -1
393
394
 
395
+ const profileIdx = args.findIndex(a => a.toLowerCase() === '--profile')
396
+ const profileValueIdx = (profileIdx !== -1 && args[profileIdx + 1] && !args[profileIdx + 1].startsWith('--'))
397
+ ? profileIdx + 1
398
+ : -1
399
+
400
+ // 📖 Set of arg indices that are values for flags (not API keys)
401
+ const skipIndices = new Set()
402
+ if (tierValueIdx !== -1) skipIndices.add(tierValueIdx)
403
+ if (profileValueIdx !== -1) skipIndices.add(profileValueIdx)
404
+
394
405
  for (const [i, arg] of args.entries()) {
395
406
  if (arg.startsWith('--')) {
396
407
  flags.push(arg.toLowerCase())
397
- } else if (i === tierValueIdx) {
398
- // Skip -- this is the --tier value, not an API key
408
+ } else if (skipIndices.has(i)) {
409
+ // 📖 Skip this is a value for --tier or --profile, not an API key
399
410
  } else if (!apiKey) {
400
411
  apiKey = arg
401
412
  }
@@ -410,5 +421,161 @@ export function parseArgs(argv) {
410
421
 
411
422
  let tierFilter = tierValueIdx !== -1 ? args[tierValueIdx].toUpperCase() : null
412
423
 
413
- return { apiKey, bestMode, fiableMode, openCodeMode, openCodeDesktopMode, openClawMode, noTelemetry, tierFilter }
424
+ const profileName = profileValueIdx !== -1 ? args[profileValueIdx] : null
425
+
426
+ // 📖 --recommend — launch directly into Smart Recommend mode (Q key equivalent)
427
+ const recommendMode = flags.includes('--recommend')
428
+
429
+ return { apiKey, bestMode, fiableMode, openCodeMode, openCodeDesktopMode, openClawMode, noTelemetry, tierFilter, profileName, recommendMode }
430
+ }
431
+
432
+ // ─── Smart Recommend — Scoring Engine ─────────────────────────────────────────
433
+
434
+ // 📖 Task types for the Smart Recommend questionnaire.
435
+ // 📖 Each task type has different weight priorities — quick fixes favor speed,
436
+ // deep refactors favor SWE score and context, code review needs balanced quality,
437
+ // test generation needs high SWE score + medium context.
438
+ export const TASK_TYPES = {
439
+ quickfix: { label: 'Quick Fix', sweWeight: 0.2, speedWeight: 0.5, ctxWeight: 0.1, stabilityWeight: 0.2 },
440
+ refactor: { label: 'Deep Refactor', sweWeight: 0.4, speedWeight: 0.1, ctxWeight: 0.3, stabilityWeight: 0.2 },
441
+ review: { label: 'Code Review', sweWeight: 0.35, speedWeight: 0.2, ctxWeight: 0.25, stabilityWeight: 0.2 },
442
+ testgen: { label: 'Test Generation', sweWeight: 0.35, speedWeight: 0.15, ctxWeight: 0.2, stabilityWeight: 0.3 },
443
+ }
444
+
445
+ // 📖 Priority presets — bias the scoring toward speed or quality.
446
+ // 📖 'speed' amplifies latency weighting, 'quality' amplifies SWE score weighting.
447
+ export const PRIORITY_TYPES = {
448
+ speed: { label: 'Speed', speedMultiplier: 1.5, sweMultiplier: 0.7 },
449
+ quality: { label: 'Quality', speedMultiplier: 0.7, sweMultiplier: 1.5 },
450
+ balanced:{ label: 'Balanced', speedMultiplier: 1.0, sweMultiplier: 1.0 },
451
+ }
452
+
453
+ // 📖 Context budget categories — match against model's context window size.
454
+ // 📖 'small' (<4K tokens) can use any model. 'large' (>32K) strongly penalizes small-ctx models.
455
+ export const CONTEXT_BUDGETS = {
456
+ small: { label: 'Small file (<4K)', minCtx: 0, idealCtx: 32 },
457
+ medium: { label: 'Medium project (<32K)', minCtx: 32, idealCtx: 128 },
458
+ large: { label: 'Large codebase (>32K)', minCtx: 128, idealCtx: 256 },
459
+ }
460
+
461
+ // 📖 parseCtxToK: Convert context window string ("128k", "1m", "200k") into numeric K tokens.
462
+ // 📖 Used by the scoring engine to compare against CONTEXT_BUDGETS thresholds.
463
+ function parseCtxToK(ctx) {
464
+ if (!ctx || ctx === '—') return 0
465
+ const str = ctx.toLowerCase()
466
+ if (str.includes('m')) return parseFloat(str.replace('m', '')) * 1000
467
+ if (str.includes('k')) return parseFloat(str.replace('k', ''))
468
+ return 0
469
+ }
470
+
471
+ // 📖 parseSweToNum: Convert SWE-bench score string ("49.2%", "73.1%") into a 0–100 number.
472
+ // 📖 Returns 0 for missing or invalid scores.
473
+ function parseSweToNum(sweScore) {
474
+ if (!sweScore || sweScore === '—') return 0
475
+ const num = parseFloat(sweScore.replace('%', ''))
476
+ return isNaN(num) ? 0 : num
477
+ }
478
+
479
+ /**
480
+ * 📖 scoreModelForTask: Score a single model result for a specific task/priority/context combination.
481
+ *
482
+ * 📖 The score is a weighted composite of 4 signals:
483
+ * - SWE quality score (0–100): how good the model is at coding (from sources.js benchmarks)
484
+ * - Speed score (0–100): inverse of average latency (faster = higher score)
485
+ * - Context fit score (0–100): how well the model's context window matches the user's budget
486
+ * - Stability score (0–100): composite p95/jitter/uptime from getStabilityScore()
487
+ *
488
+ * 📖 Each signal is weighted by the task type, then further adjusted by the priority multiplier.
489
+ * 📖 Models that are down/timeout get a harsh penalty but aren't completely excluded
490
+ * (they might come back up during the analysis phase).
491
+ *
492
+ * @param {object} result — A model result object (from state.results)
493
+ * @param {string} taskType — Key from TASK_TYPES ('quickfix'|'refactor'|'review'|'testgen')
494
+ * @param {string} priority — Key from PRIORITY_TYPES ('speed'|'quality'|'balanced')
495
+ * @param {string} contextBudget — Key from CONTEXT_BUDGETS ('small'|'medium'|'large')
496
+ * @returns {number} Score between 0 and 100 (higher = better recommendation)
497
+ */
498
+ export function scoreModelForTask(result, taskType, priority, contextBudget) {
499
+ const task = TASK_TYPES[taskType]
500
+ const prio = PRIORITY_TYPES[priority]
501
+ const budget = CONTEXT_BUDGETS[contextBudget]
502
+ if (!task || !prio || !budget) return 0
503
+
504
+ // 📖 SWE quality signal (0–100) — raw SWE-bench score
505
+ const sweNum = parseSweToNum(result.sweScore)
506
+ const sweScore = Math.min(100, sweNum * (100 / 80)) // 📖 Normalize: 80% SWE → 100 score
507
+
508
+ // 📖 Speed signal (0–100) — inverse latency, capped at 5000ms
509
+ const avg = getAvg(result)
510
+ let speedScore
511
+ if (avg === Infinity) {
512
+ speedScore = 0 // 📖 No data yet — can't judge speed
513
+ } else {
514
+ speedScore = Math.max(0, Math.min(100, 100 * (1 - avg / 5000)))
515
+ }
516
+
517
+ // 📖 Context fit signal (0–100):
518
+ // - Full score if model ctx >= idealCtx
519
+ // - Partial score if model ctx >= minCtx but < idealCtx (linear interpolation)
520
+ // - Zero if model ctx < minCtx (too small for the job)
521
+ const modelCtx = parseCtxToK(result.ctx)
522
+ let ctxScore
523
+ if (modelCtx >= budget.idealCtx) {
524
+ ctxScore = 100
525
+ } else if (modelCtx >= budget.minCtx) {
526
+ ctxScore = budget.idealCtx === budget.minCtx
527
+ ? 100
528
+ : Math.round(100 * (modelCtx - budget.minCtx) / (budget.idealCtx - budget.minCtx))
529
+ } else {
530
+ ctxScore = 0
531
+ }
532
+
533
+ // 📖 Stability signal (0–100) — from getStabilityScore(), or 0 if no data
534
+ const stability = getStabilityScore(result)
535
+ const stabScore = stability === -1 ? 0 : stability
536
+
537
+ // 📖 Weighted combination: task weights × priority multipliers
538
+ const rawScore =
539
+ (sweScore * task.sweWeight * prio.sweMultiplier) +
540
+ (speedScore * task.speedWeight * prio.speedMultiplier) +
541
+ (ctxScore * task.ctxWeight) +
542
+ (stabScore * task.stabilityWeight)
543
+
544
+ // 📖 Normalize by total effective weight to keep result in 0–100 range
545
+ const totalWeight =
546
+ (task.sweWeight * prio.sweMultiplier) +
547
+ (task.speedWeight * prio.speedMultiplier) +
548
+ task.ctxWeight +
549
+ task.stabilityWeight
550
+
551
+ let score = totalWeight > 0 ? rawScore / totalWeight : 0
552
+
553
+ // 📖 Penalty for models that are currently down/timeout — still scoreable but penalized
554
+ if (result.status === 'down' || result.status === 'timeout') {
555
+ score *= 0.2
556
+ }
557
+
558
+ return Math.round(Math.min(100, Math.max(0, score)))
559
+ }
560
+
561
+ /**
562
+ * 📖 getTopRecommendations: Score all models and return the top N recommendations.
563
+ *
564
+ * 📖 Filters out hidden models, scores each one, sorts descending, returns topN.
565
+ * 📖 Each returned item includes the original result + computed score for display.
566
+ *
567
+ * @param {Array} results — Full state.results array
568
+ * @param {string} taskType — Key from TASK_TYPES
569
+ * @param {string} priority — Key from PRIORITY_TYPES
570
+ * @param {string} contextBudget — Key from CONTEXT_BUDGETS
571
+ * @param {number} [topN=3] — How many recommendations to return
572
+ * @returns {Array<{result: object, score: number}>} Top N scored models, descending by score
573
+ */
574
+ export function getTopRecommendations(results, taskType, priority, contextBudget, topN = 3) {
575
+ const scored = results
576
+ .filter(r => !r.hidden)
577
+ .map(r => ({ result: r, score: scoreModelForTask(r, taskType, priority, contextBudget) }))
578
+ .sort((a, b) => b.score - a.score)
579
+
580
+ return scored.slice(0, topN)
414
581
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.1.67",
3
+ "version": "0.1.69",
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",
package/sources.js CHANGED
@@ -27,8 +27,8 @@
27
27
  * 📖 Secondary: https://swe-rebench.com (independent evals, scores are lower)
28
28
  * 📖 Leaderboard tracker: https://www.marc0.dev/en/leaderboard
29
29
  *
30
- * @exports nvidiaNim, groq, cerebras, sambanova, openrouter, huggingface, replicate, deepinfra, fireworks, codestral, hyperbolic, scaleway, googleai, siliconflow, together, cloudflare, perplexity — model arrays per provider
31
- * @exports sources — map of { nvidia, groq, cerebras, sambanova, openrouter, huggingface, replicate, deepinfra, fireworks, codestral, hyperbolic, scaleway, googleai, siliconflow, together, cloudflare, perplexity } each with { name, url, models }
30
+ * @exports nvidiaNim, groq, cerebras, sambanova, openrouter, huggingface, replicate, deepinfra, fireworks, codestral, hyperbolic, scaleway, googleai, siliconflow, together, cloudflare, perplexity, iflow — model arrays per provider
31
+ * @exports sources — map of { nvidia, groq, cerebras, sambanova, openrouter, huggingface, replicate, deepinfra, fireworks, codestral, hyperbolic, scaleway, googleai, siliconflow, together, cloudflare, perplexity, iflow } each with { name, url, models }
32
32
  * @exports MODELS — flat array of [modelId, label, tier, sweScore, ctx, providerKey]
33
33
  *
34
34
  * 📖 MODELS now includes providerKey as 6th element so ping() knows which
@@ -230,6 +230,18 @@ export const googleai = [
230
230
  ['gemma-3-4b-it', 'Gemma 3 4B', 'C', '10.0%', '128k'],
231
231
  ]
232
232
 
233
+ // 📖 ZAI source - https://open.z.ai
234
+ // 📖 Free API keys available at https://open.z.ai — GLM frontier models
235
+ // 📖 OpenAI-compatible endpoint for coding tasks
236
+ export const zai = [
237
+ // ── S+ tier — SWE-bench Verified ≥70% ──
238
+ ['zai/glm-5', 'GLM-5', 'S+', '77.8%', '128k'],
239
+ ['zai/glm-4.7', 'GLM-4.7', 'S+', '73.8%', '200k'],
240
+ ['zai/glm-4.5', 'GLM-4.5', 'S+', '75.0%', '128k'],
241
+ ['zai/glm-4.5-air', 'GLM-4.5-Air', 'S+', '72.0%', '128k'],
242
+ ['zai/glm-4.6', 'GLM-4.6', 'S+', '70.0%', '128k'],
243
+ ]
244
+
233
245
  // 📖 SiliconFlow source - https://cloud.siliconflow.cn
234
246
  // 📖 OpenAI-compatible endpoint: https://api.siliconflow.com/v1/chat/completions
235
247
  // 📖 Free model quotas vary by model and can change over time.
@@ -278,6 +290,27 @@ export const perplexity = [
278
290
  ['sonar', 'Sonar', 'B', '25.0%', '128k'],
279
291
  ]
280
292
 
293
+ // 📖 iFlow source - https://platform.iflow.cn
294
+ // 📖 OpenAI-compatible endpoint: https://apis.iflow.cn/v1/chat/completions
295
+ // 📖 Free for individual users with no request limits (API key expires every 7 days)
296
+ // 📖 Provides high-performance models including DeepSeek, Qwen3, Kimi K2, GLM, and TBStars2
297
+ export const iflow = [
298
+ // ── S+ tier — SWE-bench Verified ≥70% ──
299
+ ['TBStars2-200B-A13B', 'TBStars2 200B', 'S+', '77.8%', '128k'],
300
+ ['deepseek-v3.2', 'DeepSeek V3.2', 'S+', '73.1%', '128k'],
301
+ ['qwen3-coder-plus', 'Qwen3 Coder Plus', 'S+', '72.0%', '256k'],
302
+ ['qwen3-235b-a22b-instruct', 'Qwen3 235B', 'S+', '70.0%', '256k'],
303
+ ['deepseek-r1', 'DeepSeek R1', 'S+', '70.6%', '128k'],
304
+ // ── S tier — SWE-bench Verified 60–70% ──
305
+ ['kimi-k2', 'Kimi K2', 'S', '65.8%', '128k'],
306
+ ['kimi-k2-0905', 'Kimi K2 0905', 'S', '68.0%', '256k'],
307
+ ['glm-4.6', 'GLM 4.6', 'S', '62.0%', '200k'],
308
+ ['deepseek-v3', 'DeepSeek V3', 'S', '62.0%', '128k'],
309
+ // ── A+ tier — SWE-bench Verified 50–60% ──
310
+ ['qwen3-32b', 'Qwen3 32B', 'A+', '50.0%', '128k'],
311
+ ['qwen3-max', 'Qwen3 Max', 'A+', '55.0%', '256k'],
312
+ ]
313
+
281
314
  // 📖 All sources combined - used by the main script
282
315
  // 📖 Each source has: name (display), url (API endpoint), models (array of model tuples)
283
316
  export const sources = {
@@ -346,6 +379,11 @@ export const sources = {
346
379
  url: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions',
347
380
  models: googleai,
348
381
  },
382
+ zai: {
383
+ name: 'ZAI',
384
+ url: 'https://api.z.ai/api/coding/paas/v4/chat/completions',
385
+ models: zai,
386
+ },
349
387
  siliconflow: {
350
388
  name: 'SiliconFlow',
351
389
  url: 'https://api.siliconflow.com/v1/chat/completions',
@@ -366,6 +404,11 @@ export const sources = {
366
404
  url: 'https://api.perplexity.ai/chat/completions',
367
405
  models: perplexity,
368
406
  },
407
+ iflow: {
408
+ name: 'iFlow',
409
+ url: 'https://apis.iflow.cn/v1/chat/completions',
410
+ models: iflow,
411
+ },
369
412
  }
370
413
 
371
414
  // 📖 Flatten all models from all sources — each entry includes providerKey as 6th element