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.
- package/CHANGELOG.md +47 -56
- package/README.md +236 -160
- package/bin/free-coding-models.js +46 -0
- package/package.json +2 -2
- package/sources.js +133 -309
- package/src/analysis.js +23 -10
- package/src/app.js +113 -7
- package/src/cache.js +1 -1
- package/src/cli-help.js +9 -0
- package/src/command-palette.js +16 -12
- package/src/config.js +199 -32
- package/src/endpoint-installer.js +45 -1
- package/src/favorites.js +22 -0
- package/src/graphify-out/cache/089db1c1def873cf6d112f1590da4490e61e691aff0db41e006aa2fb15ba0656.json +1 -0
- package/src/graphify-out/cache/0b510b53cf1a1393fb52b1fc3bbbf88b63938e961ec5b82119a2e9715fee8bd7.json +1 -0
- package/src/graphify-out/cache/0ec9a95a326bde58e0316889018b278062d06d494d0f31ba177c9de71e5fed2d.json +1 -0
- package/src/graphify-out/cache/1548663a24a68dce740ebab1bd1d3091048c9604e9d067a1650a42a6d82541d4.json +1 -0
- package/src/graphify-out/cache/1783af63cb6d0dfb4d469009f71ac83a74ba0b33d48186ff2c6e63f9429e900a.json +1 -0
- package/src/graphify-out/cache/1e109f5eb5dc4fd285871c3613e32b6b14a8c225f4080ee34b51c7e1a1764571.json +1 -0
- package/src/graphify-out/cache/1eb24dbeb69b46c8bc1caf925df2f2a964af0f33aea143adf8ddf88e017db6ca.json +1 -0
- package/src/graphify-out/cache/21e1bcfed11685e8347243f9d8516072dda183266a4bfe22c52fb31753a446c8.json +1 -0
- package/src/graphify-out/cache/2327473478b9c4b1940bf7ef66c9ee960b3cba8d5302e56b625df8274246e0b4.json +1 -0
- package/src/graphify-out/cache/25955b81fd25454c8fa90fb71a47db8d1215cf621beb8ff3cbd580aaf011b4f3.json +1 -0
- package/src/graphify-out/cache/2739677f19c702f88f3de0a0bac475066adbda98709907ad3de967aef689f86d.json +1 -0
- package/src/graphify-out/cache/2bba03422f6b3ee7f5b5d29cc90314a064d259e5822a176657bda3e04505cf00.json +1 -0
- package/src/graphify-out/cache/2ddf1d2c6d10147b0402446bc71a7988187b79b6210dd7e7250be8c555b9ff35.json +1 -0
- package/src/graphify-out/cache/2ee07457a5767c95a57f8e9eb95b28f800044f35666e0715e9d88ad1103a092e.json +1 -0
- package/src/graphify-out/cache/2fe9f75dc2951c417f2c8dd22749092cf550dc67599f1c8d1866900dc6e9154e.json +1 -0
- package/src/graphify-out/cache/41c4b7c27e7fc3e2948d3a4bf95a72de2ed9a6f0463994babdce8ed2cc84598c.json +1 -0
- package/src/graphify-out/cache/5028defd54b7fbd3c7e444973e493de036e097e9b1d2a7cae7f19b88d68aacde.json +1 -0
- package/src/graphify-out/cache/5b133aba3fb16410c5b1fdbd1730039fc7fa1ac93abd99d7be08f60da70fc8d4.json +1 -0
- package/src/graphify-out/cache/74252e5b0978d85ab3421a3de1a9384aa282ffd2be2cfe7db2530139089f4275.json +1 -0
- package/src/graphify-out/cache/7695ebeea056095edd14332963cc43354ef3a097caf46f1e28d0f01369642901.json +1 -0
- package/src/graphify-out/cache/777aa7085c395a935c6556bbde182cd871edb61f3a685ed8068ec0c8f6fb0075.json +1 -0
- package/src/graphify-out/cache/82a723881980e82273c113def8315533d7da28827e300413d9ad30f27b7407df.json +1 -0
- package/src/graphify-out/cache/86b87c9603e6cd188f42c7eed3b86c291d48a781c223a707e74f3e7ed0c02a21.json +1 -0
- package/src/graphify-out/cache/890fead9a78cadaed560a2d2453916121fa605c3e43a334910ac4bc951a9ef6d.json +1 -0
- package/src/graphify-out/cache/89d3ea66f52783caa775ef9a30923d7d6225e1d8ae9e962f4741b8c7785dab1e.json +1 -0
- package/src/graphify-out/cache/8cc82cd9edce41f0e1c092f14a94fd52bf847addf3237b616dc5a9e505bd05bd.json +1 -0
- package/src/graphify-out/cache/93ba2e25e3ff7ad525f397902345fbd375df7315de7b402e20cc803c14eccde8.json +1 -0
- package/src/graphify-out/cache/99beed29580b9c7bfecfee794cb3d8e535fcf0eb3b92113108f88bdd0a8e79b3.json +1 -0
- package/src/graphify-out/cache/aeeb931fa477c65ce2e51d8149957350fa54225c613222bbbe8448998d1afd3d.json +1 -0
- package/src/graphify-out/cache/baf91bef5b5ecb2a476433b6cc0c48c563c54ee2d07fc3c192e543685e3e7222.json +1 -0
- package/src/graphify-out/cache/bd98b94ac4e9b92b6336d47b26e0366b51a4eaf0711d722f05f98dfae23ab42b.json +1 -0
- package/src/graphify-out/cache/bfcb51e9328e9cbfbee4f6fee0f56635d7b03488addc9f6c4e4b190b70a73362.json +1 -0
- package/src/graphify-out/cache/c0d3dabeb093aa758c49eadf41b87ecc96a16c1449c2670aaf48cbfc891d8da6.json +1 -0
- package/src/graphify-out/cache/c20d6630236f473c1406068c3ae205853e649b216495c93dfec055dd222c55cf.json +1 -0
- package/src/graphify-out/cache/c22b9122816bebce0a2f79af41a986559d01e00163dbcd579c5755621b4cb483.json +1 -0
- package/src/graphify-out/cache/ca556ec14453ddb8f9e0c5a832dac90d77111b9bad5f8c2d80d272e2e7a06371.json +1 -0
- package/src/graphify-out/cache/d6dbc9135dfa35a756b3b09b06700e4bc229fdccba11bb963f2ba44028e0bbae.json +1 -0
- package/src/graphify-out/cache/e1cf71276f1779d0fa075f79bd7c8a9fd0b8eef6932ac043137451b7c7fa7cbe.json +1 -0
- package/src/graphify-out/cache/e4b3be14494467df2d2ed389bc4f18f099021cb5bc355b901fa88387b2d8b8a2.json +1 -0
- package/src/graphify-out/cache/eaea0dded097f6f9553b654220046c6ec0c9be592a5973d906564ee60af34e0d.json +1 -0
- package/src/graphify-out/cache/ef07d0cd2675d1f79d2a2fdbf3bc3319687638751e9ce89b0d0d97ed1cd9f7e1.json +1 -0
- package/src/graphify-out/cache/f81272d6eb8aaff9e96d5a1d9f06777db70ac3652a646b951ded51f79871d733.json +1 -0
- package/src/graphify-out/cache/f9619dd92186f75a6dbda937e0c606647153918524cdb5763f956e6ec2a9e386.json +1 -0
- package/src/graphify-out/cache/fd88b1b2ff4bfcae08559d9c2aaeeb9a3f1e2f5cd8928762c311196956c170a5.json +1 -0
- package/src/key-handler.js +312 -12
- package/src/kilo.js +20 -1
- package/src/opencode.js +23 -2
- package/src/overlays.js +206 -5
- package/src/provider-metadata.js +26 -17
- package/src/quota-capabilities.js +6 -10
- package/src/render-table.js +37 -4
- package/src/router-daemon.js +1986 -0
- package/src/router-dashboard.js +893 -0
- package/src/sync-set.js +479 -0
- package/src/theme.js +4 -0
- package/src/tool-launchers.js +1 -0
- package/src/tool-metadata.js +6 -2
- package/src/utils.js +30 -6
- package/web/dist/assets/{index-C03JjCgA.js → index-DNRCaWPi.js} +2 -2
- package/web/dist/index.html +1 -1
package/src/sync-set.js
ADDED
|
@@ -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],
|
package/src/tool-launchers.js
CHANGED
|
@@ -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
|
|
package/src/tool-metadata.js
CHANGED
|
@@ -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
|
-
|
|
67
|
-
{ emoji: '
|
|
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-
|
|
20
|
-
* label: string, // e.g. "DeepSeek
|
|
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, --
|
|
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-
|
|
761
|
-
* "label": "DeepSeek
|
|
784
|
+
* "modelId": "nvidia/deepseek-ai/deepseek-v4-flash",
|
|
785
|
+
* "label": "DeepSeek V4 Flash",
|
|
762
786
|
* "provider": "nvidia",
|
|
763
787
|
* "tier": "S+",
|
|
764
|
-
* "sweScore": "
|
|
788
|
+
* "sweScore": "72.0%",
|
|
765
789
|
* "context": "128k",
|
|
766
790
|
* "latestPing": 245,
|
|
767
791
|
* "avgPing": 260,
|