free-coding-models 0.3.55 → 0.3.56

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.
Files changed (73) hide show
  1. package/CHANGELOG.md +47 -56
  2. package/README.md +236 -160
  3. package/bin/free-coding-models.js +46 -0
  4. package/package.json +2 -2
  5. package/sources.js +133 -309
  6. package/src/analysis.js +23 -10
  7. package/src/app.js +113 -7
  8. package/src/cache.js +1 -1
  9. package/src/cli-help.js +9 -0
  10. package/src/command-palette.js +16 -12
  11. package/src/config.js +199 -32
  12. package/src/endpoint-installer.js +45 -1
  13. package/src/favorites.js +22 -0
  14. package/src/graphify-out/cache/089db1c1def873cf6d112f1590da4490e61e691aff0db41e006aa2fb15ba0656.json +1 -0
  15. package/src/graphify-out/cache/0b510b53cf1a1393fb52b1fc3bbbf88b63938e961ec5b82119a2e9715fee8bd7.json +1 -0
  16. package/src/graphify-out/cache/0ec9a95a326bde58e0316889018b278062d06d494d0f31ba177c9de71e5fed2d.json +1 -0
  17. package/src/graphify-out/cache/1548663a24a68dce740ebab1bd1d3091048c9604e9d067a1650a42a6d82541d4.json +1 -0
  18. package/src/graphify-out/cache/1783af63cb6d0dfb4d469009f71ac83a74ba0b33d48186ff2c6e63f9429e900a.json +1 -0
  19. package/src/graphify-out/cache/1e109f5eb5dc4fd285871c3613e32b6b14a8c225f4080ee34b51c7e1a1764571.json +1 -0
  20. package/src/graphify-out/cache/1eb24dbeb69b46c8bc1caf925df2f2a964af0f33aea143adf8ddf88e017db6ca.json +1 -0
  21. package/src/graphify-out/cache/21e1bcfed11685e8347243f9d8516072dda183266a4bfe22c52fb31753a446c8.json +1 -0
  22. package/src/graphify-out/cache/2327473478b9c4b1940bf7ef66c9ee960b3cba8d5302e56b625df8274246e0b4.json +1 -0
  23. package/src/graphify-out/cache/25955b81fd25454c8fa90fb71a47db8d1215cf621beb8ff3cbd580aaf011b4f3.json +1 -0
  24. package/src/graphify-out/cache/2739677f19c702f88f3de0a0bac475066adbda98709907ad3de967aef689f86d.json +1 -0
  25. package/src/graphify-out/cache/2bba03422f6b3ee7f5b5d29cc90314a064d259e5822a176657bda3e04505cf00.json +1 -0
  26. package/src/graphify-out/cache/2ddf1d2c6d10147b0402446bc71a7988187b79b6210dd7e7250be8c555b9ff35.json +1 -0
  27. package/src/graphify-out/cache/2ee07457a5767c95a57f8e9eb95b28f800044f35666e0715e9d88ad1103a092e.json +1 -0
  28. package/src/graphify-out/cache/2fe9f75dc2951c417f2c8dd22749092cf550dc67599f1c8d1866900dc6e9154e.json +1 -0
  29. package/src/graphify-out/cache/41c4b7c27e7fc3e2948d3a4bf95a72de2ed9a6f0463994babdce8ed2cc84598c.json +1 -0
  30. package/src/graphify-out/cache/5028defd54b7fbd3c7e444973e493de036e097e9b1d2a7cae7f19b88d68aacde.json +1 -0
  31. package/src/graphify-out/cache/5b133aba3fb16410c5b1fdbd1730039fc7fa1ac93abd99d7be08f60da70fc8d4.json +1 -0
  32. package/src/graphify-out/cache/74252e5b0978d85ab3421a3de1a9384aa282ffd2be2cfe7db2530139089f4275.json +1 -0
  33. package/src/graphify-out/cache/7695ebeea056095edd14332963cc43354ef3a097caf46f1e28d0f01369642901.json +1 -0
  34. package/src/graphify-out/cache/777aa7085c395a935c6556bbde182cd871edb61f3a685ed8068ec0c8f6fb0075.json +1 -0
  35. package/src/graphify-out/cache/82a723881980e82273c113def8315533d7da28827e300413d9ad30f27b7407df.json +1 -0
  36. package/src/graphify-out/cache/86b87c9603e6cd188f42c7eed3b86c291d48a781c223a707e74f3e7ed0c02a21.json +1 -0
  37. package/src/graphify-out/cache/890fead9a78cadaed560a2d2453916121fa605c3e43a334910ac4bc951a9ef6d.json +1 -0
  38. package/src/graphify-out/cache/89d3ea66f52783caa775ef9a30923d7d6225e1d8ae9e962f4741b8c7785dab1e.json +1 -0
  39. package/src/graphify-out/cache/8cc82cd9edce41f0e1c092f14a94fd52bf847addf3237b616dc5a9e505bd05bd.json +1 -0
  40. package/src/graphify-out/cache/93ba2e25e3ff7ad525f397902345fbd375df7315de7b402e20cc803c14eccde8.json +1 -0
  41. package/src/graphify-out/cache/99beed29580b9c7bfecfee794cb3d8e535fcf0eb3b92113108f88bdd0a8e79b3.json +1 -0
  42. package/src/graphify-out/cache/aeeb931fa477c65ce2e51d8149957350fa54225c613222bbbe8448998d1afd3d.json +1 -0
  43. package/src/graphify-out/cache/baf91bef5b5ecb2a476433b6cc0c48c563c54ee2d07fc3c192e543685e3e7222.json +1 -0
  44. package/src/graphify-out/cache/bd98b94ac4e9b92b6336d47b26e0366b51a4eaf0711d722f05f98dfae23ab42b.json +1 -0
  45. package/src/graphify-out/cache/bfcb51e9328e9cbfbee4f6fee0f56635d7b03488addc9f6c4e4b190b70a73362.json +1 -0
  46. package/src/graphify-out/cache/c0d3dabeb093aa758c49eadf41b87ecc96a16c1449c2670aaf48cbfc891d8da6.json +1 -0
  47. package/src/graphify-out/cache/c20d6630236f473c1406068c3ae205853e649b216495c93dfec055dd222c55cf.json +1 -0
  48. package/src/graphify-out/cache/c22b9122816bebce0a2f79af41a986559d01e00163dbcd579c5755621b4cb483.json +1 -0
  49. package/src/graphify-out/cache/ca556ec14453ddb8f9e0c5a832dac90d77111b9bad5f8c2d80d272e2e7a06371.json +1 -0
  50. package/src/graphify-out/cache/d6dbc9135dfa35a756b3b09b06700e4bc229fdccba11bb963f2ba44028e0bbae.json +1 -0
  51. package/src/graphify-out/cache/e1cf71276f1779d0fa075f79bd7c8a9fd0b8eef6932ac043137451b7c7fa7cbe.json +1 -0
  52. package/src/graphify-out/cache/e4b3be14494467df2d2ed389bc4f18f099021cb5bc355b901fa88387b2d8b8a2.json +1 -0
  53. package/src/graphify-out/cache/eaea0dded097f6f9553b654220046c6ec0c9be592a5973d906564ee60af34e0d.json +1 -0
  54. package/src/graphify-out/cache/ef07d0cd2675d1f79d2a2fdbf3bc3319687638751e9ce89b0d0d97ed1cd9f7e1.json +1 -0
  55. package/src/graphify-out/cache/f81272d6eb8aaff9e96d5a1d9f06777db70ac3652a646b951ded51f79871d733.json +1 -0
  56. package/src/graphify-out/cache/f9619dd92186f75a6dbda937e0c606647153918524cdb5763f956e6ec2a9e386.json +1 -0
  57. package/src/graphify-out/cache/fd88b1b2ff4bfcae08559d9c2aaeeb9a3f1e2f5cd8928762c311196956c170a5.json +1 -0
  58. package/src/key-handler.js +312 -12
  59. package/src/kilo.js +20 -1
  60. package/src/opencode.js +23 -2
  61. package/src/overlays.js +206 -5
  62. package/src/provider-metadata.js +26 -17
  63. package/src/quota-capabilities.js +6 -10
  64. package/src/render-table.js +37 -4
  65. package/src/router-daemon.js +1986 -0
  66. package/src/router-dashboard.js +893 -0
  67. package/src/sync-set.js +479 -0
  68. package/src/theme.js +4 -0
  69. package/src/tool-launchers.js +1 -0
  70. package/src/tool-metadata.js +6 -2
  71. package/src/utils.js +30 -6
  72. package/web/dist/assets/{index-C03JjCgA.js → index-DNRCaWPi.js} +2 -2
  73. package/web/dist/index.html +1 -1
@@ -0,0 +1,479 @@
1
+ /**
2
+ * @file src/sync-set.js
3
+ * @description Auto-discover, live-probe, and populate a router set with the best available models.
4
+ *
5
+ * @details
6
+ * 📖 `--sync-set [name]` is a headless CLI command that:
7
+ * 1. Reads the model catalog from sources.js
8
+ * 2. Filters to routeable providers where the user has API keys
9
+ * 3. Ranks candidates by tier, SWE-bench score, and coding affinity
10
+ * 4. Live-probes each candidate (plain response + tool-call test)
11
+ * 5. Writes the best N passing models into a named router set
12
+ * 6. Signals the daemon to reload if running
13
+ *
14
+ * 📖 This replaces manual set management for users who want an always-current
15
+ * "best available" set without hand-picking models or monitoring quotas.
16
+ *
17
+ * 📖 The probe is intentionally conservative: a model must both produce
18
+ * correct plain-text output AND successfully make a tool call to pass.
19
+ * This ensures coding tools (Forge, OpenCode, Aider, etc.) get reliable
20
+ * function-calling models, not just chat-capable ones.
21
+ *
22
+ * @exports syncSet — Main entry point, returns a structured result object
23
+ * @exports buildSyncCandidates — Candidate ranking (exported for testing)
24
+ * @exports probeModel — Single-model probe (exported for testing)
25
+ *
26
+ * @see ./router-daemon.js — daemon lifecycle and set management
27
+ * @see ./config.js — config persistence and API key resolution
28
+ * @see ../sources.js — model catalog
29
+ */
30
+
31
+ import { sources } from '../sources.js'
32
+ import {
33
+ CONFIG_PATH,
34
+ getApiKey,
35
+ loadConfig,
36
+ normalizeRouterConfig,
37
+ saveConfig,
38
+ } from './config.js'
39
+ import { resolveCloudflareUrl } from './ping.js'
40
+ import { ROUTER_PID_PATH } from './router-daemon.js'
41
+ import { existsSync, readFileSync } from 'node:fs'
42
+
43
+ // 📖 Tier ordering — best tiers first, used for scoring candidates.
44
+ const TIER_ORDER = ['S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C']
45
+
46
+ // 📖 Numeric value per tier for composite scoring.
47
+ const TIER_SCORES = {
48
+ 'S+': 900, S: 800, 'A+': 700, A: 600, 'A-': 500, 'B+': 400, B: 300, C: 200,
49
+ }
50
+
51
+ // 📖 Default limits — probe at most MAX_PROBES candidates, keep TARGET_COUNT in the set.
52
+ const DEFAULT_MAX_PROBES = 50
53
+ const DEFAULT_TARGET_COUNT = 8
54
+ const PROBE_TIMEOUT_MS = 40000
55
+
56
+ // 📖 Models that are known to fail tool calls or produce broken output.
57
+ // 📖 Users can override via the `exclude` option.
58
+ const DEFAULT_EXCLUDE_PATTERNS = [
59
+ /thinking/i,
60
+ /gemma/i,
61
+ ]
62
+
63
+ // 📖 Models on googleai tend to include <thought> tags in streamed output,
64
+ // 📖 which breaks structured parsing in coding tools.
65
+ const EXCLUDED_PROVIDERS = new Set(['googleai'])
66
+
67
+ const OPENROUTER_FREE_MODEL_IDS = new Set([
68
+ 'openrouter/free',
69
+ 'openrouter/owl-alpha',
70
+ ])
71
+
72
+ /**
73
+ * Check whether a provider's catalog entry supports routing (has a
74
+ * chat/completions URL and is not CLI-only).
75
+ */
76
+ function isRouteableProvider(providerKey) {
77
+ const source = sources[providerKey]
78
+ return Boolean(source?.url && !source.cliOnly && source.url.includes('/chat/completions'))
79
+ }
80
+
81
+ /**
82
+ * Resolve the upstream URL for a provider, handling Cloudflare template substitution.
83
+ */
84
+ function resolveUrl(providerKey) {
85
+ const url = sources[providerKey]?.url
86
+ if (!url) return null
87
+ return providerKey === 'cloudflare' ? resolveCloudflareUrl(url) : url
88
+ }
89
+
90
+ /**
91
+ * Normalize model ID for API calls (strip provider prefix for ZAI).
92
+ */
93
+ function normalizeModelId(providerKey, modelId) {
94
+ return providerKey === 'zai' ? String(modelId).replace(/^zai\//, '') : String(modelId)
95
+ }
96
+
97
+ function isOpenRouterFreeModelId(modelId) {
98
+ return String(modelId).endsWith(':free') || OPENROUTER_FREE_MODEL_IDS.has(String(modelId))
99
+ }
100
+
101
+ /**
102
+ * Parse a SWE-bench percentage string like "49.2%" to a number.
103
+ */
104
+ function parseSwePercent(value) {
105
+ if (typeof value !== 'string') return 0
106
+ const numeric = parseFloat(value.replace('%', '').trim())
107
+ return Number.isFinite(numeric) ? numeric : 0
108
+ }
109
+
110
+ /**
111
+ * Score a candidate model for ranking. Higher is better.
112
+ *
113
+ * Combines tier score, SWE-bench percentage, coding keyword affinity,
114
+ * and provider reliability bonus into a single comparable number.
115
+ */
116
+ function scoreCandidate(provider, modelId, label, tier, swePercent) {
117
+ const tierScore = TIER_SCORES[tier] || 0
118
+ const codingHint = /coder|code|deepseek|gpt-oss|qwen|glm|starcoder/i.test(`${modelId} ${label}`) ? 40 : 0
119
+ return tierScore + Math.round(swePercent * 10) + codingHint
120
+ }
121
+
122
+ /**
123
+ * Check whether a model should be skipped based on exclude patterns and heuristics.
124
+ */
125
+ function shouldSkipModel(provider, modelId, tier, swePercent, options = {}) {
126
+ if (EXCLUDED_PROVIDERS.has(provider)) return true
127
+ if (tier === 'C') return true
128
+ if (swePercent < (options.minSwePercent || 40)) return true
129
+
130
+ // 📖 For OpenRouter, only consider free models unless the user opts in
131
+ if (provider === 'openrouter' && !isOpenRouterFreeModelId(modelId) && !options.includePaidOpenRouter) return true
132
+
133
+ const excludePatterns = options.excludePatterns || DEFAULT_EXCLUDE_PATTERNS
134
+ for (const pattern of excludePatterns) {
135
+ if (pattern.test(modelId)) return true
136
+ }
137
+
138
+ const excludeSet = options.exclude
139
+ if (excludeSet && excludeSet.has(`${provider}/${modelId}`)) return true
140
+
141
+ return false
142
+ }
143
+
144
+ /**
145
+ * Build and rank candidate models from the sources catalog.
146
+ *
147
+ * @param {Object} apiKeys — Map of provider → API key
148
+ * @param {Object} [options] — Filtering and ranking options
149
+ * @param {Set<string>} [options.exclude] — Set of "provider/model" keys to skip
150
+ * @param {string[]} [options.preferOrder] — Ordered list of "provider/model" keys to prefer
151
+ * @param {number} [options.minSwePercent] — Minimum SWE-bench score (default: 40)
152
+ * @returns {Array<Object>} Ranked candidate list
153
+ */
154
+ export function buildSyncCandidates(apiKeys, options = {}) {
155
+ const candidates = []
156
+
157
+ for (const [providerKey, sourceData] of Object.entries(sources)) {
158
+ if (!apiKeys[providerKey]) continue
159
+ if (!isRouteableProvider(providerKey)) continue
160
+
161
+ for (const tuple of sourceData.models || []) {
162
+ const [modelId, label = '', tier = '', swe = '0%'] = tuple
163
+ if (typeof modelId !== 'string' || !modelId.trim()) continue
164
+ const swePercent = parseSwePercent(swe)
165
+ if (shouldSkipModel(providerKey, modelId, tier, swePercent, options)) continue
166
+ const score = scoreCandidate(providerKey, modelId, label, tier, swePercent)
167
+ candidates.push({
168
+ provider: providerKey,
169
+ model: modelId,
170
+ label,
171
+ tier,
172
+ swePercent,
173
+ score,
174
+ url: sourceData.url,
175
+ })
176
+ }
177
+ }
178
+
179
+ // 📖 De-duplicate and sort: preferred models first, then by score descending
180
+ const preferOrder = options.preferOrder || []
181
+ const preferMap = new Map(preferOrder.map((key, i) => [key, i]))
182
+
183
+ const deduped = []
184
+ const seen = new Set()
185
+ for (const candidate of candidates.sort((a, b) => {
186
+ const keyA = `${a.provider}/${a.model}`
187
+ const keyB = `${b.provider}/${b.model}`
188
+ const prefA = preferMap.has(keyA) ? preferMap.get(keyA) : Number.MAX_SAFE_INTEGER
189
+ const prefB = preferMap.has(keyB) ? preferMap.get(keyB) : Number.MAX_SAFE_INTEGER
190
+ if (prefA !== prefB) return prefA - prefB
191
+ return b.score - a.score
192
+ })) {
193
+ const key = `${candidate.provider}/${candidate.model}`
194
+ if (seen.has(key)) continue
195
+ seen.add(key)
196
+ deduped.push(candidate)
197
+ }
198
+
199
+ return deduped
200
+ }
201
+
202
+ /**
203
+ * Build request headers for a provider, including auth and OpenRouter attribution.
204
+ */
205
+ function buildHeaders(providerKey, apiKey) {
206
+ const headers = {
207
+ 'Content-Type': 'application/json',
208
+ Authorization: `Bearer ${apiKey}`,
209
+ }
210
+ if (providerKey === 'openrouter') {
211
+ headers['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
212
+ headers['X-Title'] = 'free-coding-models'
213
+ }
214
+ return headers
215
+ }
216
+
217
+ /**
218
+ * Send a JSON request to a provider and return the parsed response.
219
+ */
220
+ async function jsonRequest(url, headers, body, timeoutMs = PROBE_TIMEOUT_MS) {
221
+ try {
222
+ const response = await fetch(url, {
223
+ method: 'POST',
224
+ headers,
225
+ body: JSON.stringify(body),
226
+ signal: AbortSignal.timeout(timeoutMs),
227
+ })
228
+ const raw = await response.text()
229
+ let parsed = null
230
+ try { parsed = JSON.parse(raw) } catch {}
231
+ return { ok: response.ok, status: response.status, parsed, raw }
232
+ } catch (error) {
233
+ const timeout = error?.name === 'TimeoutError' || error?.name === 'AbortError'
234
+ return { ok: false, status: null, parsed: null, raw: '', timeout, error }
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Live-probe a single model candidate with two checks:
240
+ * 1. Plain text response — must reply with exactly "OK"
241
+ * 2. Tool call — must produce a valid tool_calls array
242
+ *
243
+ * Both must pass for the model to be considered usable by coding tools.
244
+ *
245
+ * @param {Object} candidate — Candidate from buildSyncCandidates
246
+ * @param {string} apiKey — API key for the provider
247
+ * @returns {Object} Probe result with ok, status, reason fields
248
+ */
249
+ export async function probeModel(candidate, apiKey) {
250
+ const url = candidate.provider === 'cloudflare'
251
+ ? resolveCloudflareUrl(candidate.url)
252
+ : candidate.url
253
+ const headers = buildHeaders(candidate.provider, apiKey)
254
+ const model = normalizeModelId(candidate.provider, candidate.model)
255
+
256
+ // 📖 Step 1: Plain text response correctness
257
+ const plain = await jsonRequest(url, headers, {
258
+ model,
259
+ messages: [{ role: 'user', content: 'Reply with exactly OK and nothing else.' }],
260
+ stream: false,
261
+ max_tokens: 32,
262
+ temperature: 0,
263
+ })
264
+
265
+ if (!plain.ok) {
266
+ return {
267
+ ok: false,
268
+ status: plain.status,
269
+ reason: plain.timeout ? 'timeout_plain' : `http_${plain.status ?? 'err'}_plain`,
270
+ }
271
+ }
272
+
273
+ const content = String(plain.parsed?.choices?.[0]?.message?.content ?? '').trim()
274
+ // 📖 Accept responses that contain "OK" — some models add thinking tags or extra whitespace
275
+ const normalizedContent = content.replace(/<[^>]*>/g, '').replace(/\n/g, ' ').trim()
276
+ if (!normalizedContent.startsWith('OK')) {
277
+ return { ok: false, status: plain.status, reason: 'plain_not_ok' }
278
+ }
279
+
280
+ // 📖 Step 2: Tool call capability
281
+ const tool = await jsonRequest(url, headers, {
282
+ model,
283
+ messages: [{ role: 'user', content: 'Use the echo tool with text exactly OK and nothing else.' }],
284
+ tools: [{
285
+ type: 'function',
286
+ function: {
287
+ name: 'echo',
288
+ description: 'Echo text back',
289
+ parameters: {
290
+ type: 'object',
291
+ properties: { text: { type: 'string' } },
292
+ required: ['text'],
293
+ additionalProperties: false,
294
+ },
295
+ },
296
+ }],
297
+ tool_choice: 'auto',
298
+ stream: false,
299
+ max_tokens: 128,
300
+ temperature: 0,
301
+ })
302
+
303
+ if (!tool.ok) {
304
+ return {
305
+ ok: false,
306
+ status: tool.status,
307
+ reason: tool.timeout ? 'timeout_tool' : `http_${tool.status ?? 'err'}_tool`,
308
+ }
309
+ }
310
+
311
+ const toolCalls = tool.parsed?.choices?.[0]?.message?.tool_calls
312
+ if (!Array.isArray(toolCalls) || toolCalls.length === 0) {
313
+ return { ok: false, status: tool.status, reason: 'no_tool_calls' }
314
+ }
315
+
316
+ return { ok: true, status: tool.status, reason: 'ok' }
317
+ }
318
+
319
+ /**
320
+ * Signal the running daemon to reload config via SIGHUP.
321
+ *
322
+ * @returns {boolean} true if the signal was sent successfully
323
+ */
324
+ function signalDaemonReload() {
325
+ try {
326
+ if (!existsSync(ROUTER_PID_PATH)) return false
327
+ const pid = Number(readFileSync(ROUTER_PID_PATH, 'utf8').trim())
328
+ if (!Number.isFinite(pid) || pid <= 0) return false
329
+ process.kill(pid, 'SIGHUP')
330
+ return true
331
+ } catch {
332
+ return false
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Collect all available API keys from config, environment variables, and
338
+ * secondary credential stores (e.g. Forge credentials).
339
+ */
340
+ function collectApiKeys(config) {
341
+ const apiKeys = {}
342
+
343
+ // 📖 Start with keys from the config file
344
+ for (const [provider, value] of Object.entries(config.apiKeys || {})) {
345
+ const key = getApiKey(config, provider)
346
+ if (key) apiKeys[provider] = key
347
+ }
348
+
349
+ // 📖 Fill in from environment for any missing providers
350
+ for (const providerKey of Object.keys(sources)) {
351
+ if (apiKeys[providerKey]) continue
352
+ const key = getApiKey({}, providerKey)
353
+ if (key) apiKeys[providerKey] = key
354
+ }
355
+
356
+ return apiKeys
357
+ }
358
+
359
+ /**
360
+ * Run the full sync-set pipeline: discover, rank, probe, write.
361
+ *
362
+ * @param {Object} [options] — Configuration options
363
+ * @param {string} [options.name='auto'] — Name for the router set
364
+ * @param {number} [options.maxProbes=10] — Maximum candidates to probe
365
+ * @param {number} [options.targetCount=4] — Target number of models in the set
366
+ * @param {Set<string>} [options.exclude] — "provider/model" keys to skip
367
+ * @param {string[]} [options.preferOrder] — Preferred model ordering
368
+ * @param {number} [options.minSwePercent=40] — Minimum SWE-bench score
369
+ * @param {boolean} [options.activate=true] — Whether to make this the active set
370
+ * @param {boolean} [options.json=false] — Produce JSON output (for scripting)
371
+ * @returns {Object} Result with ok, selected, probeResults, daemonReloaded
372
+ */
373
+ export async function syncSet(options = {}) {
374
+ const name = options.name || 'auto'
375
+ const maxProbes = options.maxProbes || DEFAULT_MAX_PROBES
376
+ const targetCount = options.targetCount || DEFAULT_TARGET_COUNT
377
+ const activate = options.activate !== false
378
+
379
+ const config = loadConfig()
380
+ const apiKeys = collectApiKeys(config)
381
+
382
+ const candidates = buildSyncCandidates(apiKeys, {
383
+ exclude: options.exclude,
384
+ preferOrder: options.preferOrder,
385
+ minSwePercent: options.minSwePercent,
386
+ excludePatterns: options.excludePatterns,
387
+ includePaidOpenRouter: options.includePaidOpenRouter,
388
+ })
389
+
390
+ const selected = []
391
+ const probeResults = []
392
+
393
+ for (const candidate of candidates.slice(0, maxProbes)) {
394
+ if (selected.length >= targetCount) break
395
+
396
+ const key = apiKeys[candidate.provider]
397
+ if (!key) continue
398
+
399
+ const result = await probeModel(candidate, key)
400
+ probeResults.push({
401
+ model: `${candidate.provider}/${candidate.model}`,
402
+ tier: candidate.tier,
403
+ score: candidate.score,
404
+ status: result.status,
405
+ ok: result.ok,
406
+ reason: result.reason,
407
+ })
408
+
409
+ if (result.ok) {
410
+ selected.push({
411
+ provider: candidate.provider,
412
+ model: candidate.model,
413
+ priority: selected.length + 1,
414
+ })
415
+ }
416
+ }
417
+
418
+ // 📖 Assign final priority numbers
419
+ const normalizedSelected = selected.map((entry, index) => ({
420
+ ...entry,
421
+ priority: index + 1,
422
+ }))
423
+
424
+ if (normalizedSelected.length === 0) {
425
+ // 📖 Keep existing set if no probes succeeded
426
+ const existing = config?.router?.sets?.[name]?.models
427
+ if (Array.isArray(existing) && existing.length > 0) {
428
+ return {
429
+ ok: false,
430
+ reusedExisting: true,
431
+ name,
432
+ reason: 'no_probe_success',
433
+ existing,
434
+ probeResults,
435
+ }
436
+ }
437
+ return {
438
+ ok: false,
439
+ name,
440
+ reason: 'no_candidates',
441
+ probeResults,
442
+ }
443
+ }
444
+
445
+ // 📖 Build router config and persist
446
+ if (!config.router || typeof config.router !== 'object') config.router = {}
447
+ const router = config.router
448
+ if (!router.sets || typeof router.sets !== 'object') router.sets = {}
449
+ router.enabled = true
450
+ router.onboardingSeen = true
451
+
452
+ const existingCreated = router.sets[name]?.created
453
+ router.sets[name] = {
454
+ name,
455
+ models: normalizedSelected,
456
+ created: existingCreated || new Date().toISOString(),
457
+ syncedAt: new Date().toISOString(),
458
+ managedBy: 'sync-set',
459
+ }
460
+
461
+ if (activate) {
462
+ router.activeSet = name
463
+ }
464
+
465
+ config.router = normalizeRouterConfig(router)
466
+ saveConfig(config)
467
+
468
+ const daemonReloaded = signalDaemonReload()
469
+
470
+ return {
471
+ ok: true,
472
+ name,
473
+ activated: activate,
474
+ selected: normalizedSelected,
475
+ daemonReloaded,
476
+ probeCount: probeResults.length,
477
+ probeResults,
478
+ }
479
+ }
package/src/theme.js CHANGED
@@ -126,6 +126,8 @@ const PROVIDER_PALETTES = {
126
126
  cerebras: [153, 215, 255],
127
127
  sambanova: [255, 215, 142],
128
128
  openrouter: [228, 191, 239],
129
+ 'github-models': [183, 201, 255],
130
+ mistral: [255, 196, 120],
129
131
  huggingface: [255, 235, 122],
130
132
  replicate: [166, 212, 255],
131
133
  deepinfra: [146, 222, 213],
@@ -151,6 +153,8 @@ const PROVIDER_PALETTES = {
151
153
  cerebras: [0, 102, 177],
152
154
  sambanova: [165, 94, 0],
153
155
  openrouter: [122, 65, 156],
156
+ 'github-models': [52, 83, 166],
157
+ mistral: [166, 96, 29],
154
158
  huggingface: [135, 104, 0],
155
159
  replicate: [0, 94, 163],
156
160
  deepinfra: [0, 122, 117],
@@ -202,6 +202,7 @@ const JCODE_NATIVE_PROVIDERS = {
202
202
  openrouter: { provider: 'openrouter', envKey: 'OPENROUTER_API_KEY' },
203
203
  perplexity: { provider: 'perplexity', envKey: 'PERPLEXITY_API_KEY' },
204
204
  zai: { provider: 'zai', envKey: 'ZHIPU_API_KEY' },
205
+ mistral: { provider: 'mistral', envKey: 'MISTRAL_API_KEY' },
205
206
  codestral: { provider: 'mistral', envKey: 'MISTRAL_API_KEY' },
206
207
  }
207
208
 
@@ -45,6 +45,7 @@ export const TOOL_METADATA = {
45
45
  gemini: { label: 'Gemini CLI', emoji: '♊', flag: '--gemini', color: [66, 165, 245], cliOnly: true },
46
46
  jcode: { label: 'jcode', emoji: '🪼', flag: '--jcode', color: [255, 140, 0] },
47
47
  xcode: { label: 'Xcode Intelligence',emoji: '🛠️', flag: '--xcode', color: [20, 126, 251] },
48
+ fcm_router: { label: 'FCM Router', emoji: '🧭', flag: '--fcm-router', color: [80, 200, 120] },
48
49
  }
49
50
 
50
51
  // 📖 Deduplicated emoji order for the "Compatible with" column.
@@ -63,11 +64,13 @@ export const COMPAT_COLUMN_SLOTS = [
63
64
  { emoji: '⚡', toolKeys: ['amp'], color: [255, 232, 98] },
64
65
  { emoji: '🔮', toolKeys: ['hermes'], color: [200, 160, 255] },
65
66
  { emoji: '▶️', toolKeys: ['continue'], color: [255, 100, 100] },
66
- { emoji: '🧠', toolKeys: ['cline'], color: [100, 220, 180] },
67
- { emoji: '🦘', toolKeys: ['rovo'], color: [148, 163, 184] },
67
+ { emoji: '🧠', toolKeys: ['cline'], color: [100, 220, 180] },
68
+ { emoji: '🧭', toolKeys: ['fcm_router'], color: [80, 200, 120] },
69
+ { emoji: '🦘', toolKeys: ['rovo'], color: [148, 163, 184] },
68
70
  { emoji: '♊', toolKeys: ['gemini'], color: [66, 165, 245] },
69
71
  { emoji: '🪼', toolKeys: ['jcode'], color: [255, 140, 0] },
70
72
  { emoji: '🛠️', toolKeys: ['xcode'], color: [20, 126, 251] },
73
+ { emoji: '🧭', toolKeys: ['fcm_router'], color: [80, 200, 120] },
71
74
  ]
72
75
 
73
76
  export const TOOL_MODE_ORDER = [
@@ -88,6 +91,7 @@ export const TOOL_MODE_ORDER = [
88
91
  'continue',
89
92
  'cline',
90
93
  'xcode',
94
+ 'fcm_router',
91
95
  'rovo',
92
96
  'gemini',
93
97
  ]
package/src/utils.js CHANGED
@@ -16,8 +16,8 @@
16
16
  * 📖 Result object shape (created by the main CLI, consumed by these functions):
17
17
  * {
18
18
  * idx: number, // 1-based index for display
19
- * modelId: string, // e.g. "deepseek-ai/deepseek-v3.2"
20
- * label: string, // e.g. "DeepSeek V3.2" (human-friendly name)
19
+ * modelId: string, // e.g. "deepseek-ai/deepseek-v4-flash"
20
+ * label: string, // e.g. "DeepSeek V4 Flash" (human-friendly name)
21
21
  * tier: string, // e.g. "S+", "A", "B+" — from sources.js
22
22
  * sweScore: string, // e.g. "49.2%", "73.1%" — SWE-bench Verified score
23
23
  * status: string, // "pending" | "up" | "down" | "timeout"
@@ -389,7 +389,8 @@ export function findBestModel(results) {
389
389
  // - API key: first positional arg that does not look like a CLI flag (e.g., "nvapi-xxx")
390
390
  // - Boolean flags: --best, --fiable, --opencode, --opencode-desktop, --opencode-web, --openclaw,
391
391
  // --aider, --crush, --goose, --qwen, --kilo,
392
- // --openhands, --amp, --pi, --no-telemetry, --json, --help/-h (case-insensitive)
392
+ // --openhands, --amp, --pi, --daemon, --daemon-bg, --daemon-stop,
393
+ // --daemon-status, --no-telemetry, --json, --help/-h (case-insensitive)
393
394
  // - Value flag: --tier <letter> (the next non-flag arg is the tier value)
394
395
  //
395
396
  // Returns:
@@ -425,12 +426,19 @@ export function parseArgs(argv) {
425
426
  ? pingIntervalIdx + 1
426
427
  : -1
427
428
 
429
+ // 📖 --sync-set [name] — auto-discover and live-probe models into a named router set
430
+ const syncSetIdx = args.findIndex(a => a.toLowerCase() === '--sync-set')
431
+ const syncSetValueIdx = (syncSetIdx !== -1 && args[syncSetIdx + 1] && !args[syncSetIdx + 1].startsWith('--'))
432
+ ? syncSetIdx + 1
433
+ : -1
434
+
428
435
  // 📖 Set of arg indices that are values for flags (not API keys)
429
436
  const skipIndices = new Set()
430
437
  if (tierValueIdx !== -1) skipIndices.add(tierValueIdx)
431
438
  if (sortValueIdx !== -1) skipIndices.add(sortValueIdx)
432
439
  if (originValueIdx !== -1) skipIndices.add(originValueIdx)
433
440
  if (pingIntervalValueIdx !== -1) skipIndices.add(pingIntervalValueIdx)
441
+ if (syncSetValueIdx !== -1) skipIndices.add(syncSetValueIdx)
434
442
 
435
443
  for (const [i, arg] of args.entries()) {
436
444
  if (arg.startsWith('--') || arg === '-h') {
@@ -464,9 +472,18 @@ export function parseArgs(argv) {
464
472
  const geminiMode = flags.includes('--gemini')
465
473
  const jcodeMode = flags.includes('--jcode')
466
474
  const noTelemetry = flags.includes('--no-telemetry')
475
+ const devMode = flags.includes('--dev')
467
476
  const jsonMode = flags.includes('--json')
468
477
  const helpMode = flags.includes('--help') || flags.includes('-h')
469
478
  const premiumMode = flags.includes('--premium')
479
+ const daemonMode = flags.includes('--daemon')
480
+ const daemonBackgroundMode = flags.includes('--daemon-bg')
481
+ const daemonStopMode = flags.includes('--daemon-stop')
482
+ const daemonStatusMode = flags.includes('--daemon-status')
483
+
484
+ // 📖 --sync-set [name] — auto-discover and populate a router set with best available models
485
+ const syncSetMode = flags.includes('--sync-set')
486
+ const syncSetName = syncSetValueIdx !== -1 ? args[syncSetValueIdx] : null
470
487
 
471
488
  // 📖 --web / --gui / web subcommand — launch the web dashboard instead of the TUI
472
489
  const webMode = flags.includes('--web') || flags.includes('--gui') || args[0] === 'web'
@@ -523,8 +540,15 @@ export function parseArgs(argv) {
523
540
  showUnconfigured,
524
541
  premiumMode,
525
542
  webMode,
543
+ daemonMode,
544
+ daemonBackgroundMode,
545
+ daemonStopMode,
546
+ daemonStatusMode,
526
547
  // 📖 Profile system removed - API keys now persist permanently across all sessions
527
548
  recommendMode,
549
+ devMode,
550
+ syncSetMode,
551
+ syncSetName,
528
552
  }
529
553
  }
530
554
 
@@ -757,11 +781,11 @@ export function getVersionStatusInfo(updateState, latestVersion, startupLatestVe
757
781
  * [
758
782
  * {
759
783
  * "rank": 1,
760
- * "modelId": "nvidia/deepseek-ai/deepseek-v3.2",
761
- * "label": "DeepSeek V3.2",
784
+ * "modelId": "nvidia/deepseek-ai/deepseek-v4-flash",
785
+ * "label": "DeepSeek V4 Flash",
762
786
  * "provider": "nvidia",
763
787
  * "tier": "S+",
764
- * "sweScore": "73.1%",
788
+ * "sweScore": "72.0%",
765
789
  * "context": "128k",
766
790
  * "latestPing": 245,
767
791
  * "avgPing": 260,