free-coding-models 0.5.83 → 0.5.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.
- package/README.md +1 -1
- package/changelog/v0.5.84.md +32 -0
- package/package.json +2 -2
- package/sources.js +107 -62
- package/src/core/config.js +4 -0
- package/src/core/model-family.js +176 -0
- package/src/core/router-daemon.js +45 -4
- package/src/core/router-dashboard.js +6 -1
- package/src/data/benchmarks.json +6072 -216
- package/src/tui/cli-help.js +3 -1
- package/web/dist/assets/index-zdQvAW7c.js +40 -0
- package/web/dist/index.html +1 -1
- package/web/src/components/router/RouterView.jsx +38 -1
- package/web/dist/assets/index-CSoKc1qF.js +0 -40
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file model-family.js
|
|
3
|
+
* @description Model family detection + family-preserving failover picker for the Smart Router.
|
|
4
|
+
*
|
|
5
|
+
* @details
|
|
6
|
+
* 📖 When a routed request fails, the router historically fell back to the
|
|
7
|
+
* next entry in the set's priority order, which can be a completely different
|
|
8
|
+
* model family (user asked for DeepSeek-class output and got a Qwen model).
|
|
9
|
+
* This module normalizes every model to a `family` id and provides a
|
|
10
|
+
* two-stage failover picker:
|
|
11
|
+
* Stage 1: retry the same family on a DIFFERENT provider (same brain,
|
|
12
|
+
* different host, e.g. `nvidiaNim/deepseek-v4` → `together/deepseek-v3`).
|
|
13
|
+
* Stage 2: the historical set-order fallback (any healthy untried model).
|
|
14
|
+
*
|
|
15
|
+
* 📖 Family detection is a keyword scan over a "haystack" built from the
|
|
16
|
+
* model id, label, and provider key (lowercased). Mapping ORDER matters:
|
|
17
|
+
* more specific brands must be checked before generic substrings they
|
|
18
|
+
* contain. `gpt` precedes `o1`/`o3` so `gpt-oss-120b` is classified as GPT,
|
|
19
|
+
* and `nemotron` precedes `llama` so `llama-3.1-nemotron-70b` is classified
|
|
20
|
+
* as Nemotron rather than Llama.
|
|
21
|
+
*
|
|
22
|
+
* 📖 `o1`/`o3` are short numeric keywords that would false-positive inside
|
|
23
|
+
* ordinary ids (e.g. `mistral-o1x`), so they only match on word boundaries:
|
|
24
|
+
* preceded/followed by a non-alphanumeric character or string edge.
|
|
25
|
+
*
|
|
26
|
+
* @functions
|
|
27
|
+
* → detectFamily(model) - Normalize a model (string or object) to a family id
|
|
28
|
+
* → getModelFamilyHaystack(model) - Lowercased id+label+provider scan string
|
|
29
|
+
* → pickNextCandidate(opts) - Two-stage failover picker (family first, then set order)
|
|
30
|
+
*
|
|
31
|
+
* @exports BRAND_MAPPINGS, detectFamily, getModelFamilyHaystack, pickNextCandidate
|
|
32
|
+
*
|
|
33
|
+
* @see ./router-daemon.js - pickNextCandidate is used by the routeRequest failover loop
|
|
34
|
+
* @see ../config.js - per-set `familyFailover: true|false` toggle (default true)
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 📖 BRAND_MAPPINGS - ordered keyword → family table. FIRST match wins, so
|
|
39
|
+
* ordering is part of the contract: more specific keywords must come before
|
|
40
|
+
* generic ones they contain. Keep new entries sorted by specificity, not
|
|
41
|
+
* alphabetically.
|
|
42
|
+
*/
|
|
43
|
+
export const BRAND_MAPPINGS = [
|
|
44
|
+
{ keywords: ['claude'], familyId: 'claude', familyName: 'Claude' },
|
|
45
|
+
{ keywords: ['deepseek'], familyId: 'deepseek', familyName: 'DeepSeek' },
|
|
46
|
+
{ keywords: ['gemini'], familyId: 'gemini', familyName: 'Gemini' },
|
|
47
|
+
// 📖 'gpt' MUST precede the openai-o entry: gpt-oss-120b contains neither
|
|
48
|
+
// o1 nor o3 on a word boundary, but ordering keeps that guarantee explicit.
|
|
49
|
+
{ keywords: ['gpt', 'gpt-oss'], familyId: 'gpt', familyName: 'GPT' },
|
|
50
|
+
{ keywords: ['nemotron'], familyId: 'nemotron', familyName: 'Nemotron' },
|
|
51
|
+
{ keywords: ['llama'], familyId: 'llama', familyName: 'Llama' },
|
|
52
|
+
{ keywords: ['minimax'], familyId: 'minimax', familyName: 'MiniMax' },
|
|
53
|
+
{ keywords: ['qwen'], familyId: 'qwen', familyName: 'Qwen' },
|
|
54
|
+
{ keywords: ['kimi', 'moonshot'], familyId: 'kimi', familyName: 'Kimi' },
|
|
55
|
+
{ keywords: ['glm', 'chatglm'], familyId: 'glm', familyName: 'GLM' },
|
|
56
|
+
{ keywords: ['mistral', 'mixtral'], familyId: 'mistral', familyName: 'Mistral' },
|
|
57
|
+
// 📖 o1/o3 are word-boundary matched (see matchesKeyword) to avoid
|
|
58
|
+
// false positives inside unrelated model ids.
|
|
59
|
+
{ keywords: ['o1', 'o3'], familyId: 'openai-o', familyName: 'OpenAI o' },
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 📖 getModelFamilyHaystack - build the lowercase scan string for a model.
|
|
64
|
+
* Accepts a plain string (model id) or an object with any of id/model/label/
|
|
65
|
+
* family/provider/providerKey fields, plus a nested `catalog` object as found
|
|
66
|
+
* on router routing candidates.
|
|
67
|
+
*
|
|
68
|
+
* @param {string|object|null} model
|
|
69
|
+
* @returns {string} lowercase haystack, '' when nothing usable is present
|
|
70
|
+
*/
|
|
71
|
+
export function getModelFamilyHaystack(model) {
|
|
72
|
+
if (typeof model === 'string') return model.toLowerCase()
|
|
73
|
+
if (!model || typeof model !== 'object') return ''
|
|
74
|
+
const catalog = model.catalog && typeof model.catalog === 'object' ? model.catalog : {}
|
|
75
|
+
return [
|
|
76
|
+
model.id,
|
|
77
|
+
model.model,
|
|
78
|
+
model.label,
|
|
79
|
+
model.family,
|
|
80
|
+
model.name,
|
|
81
|
+
model.provider,
|
|
82
|
+
model.providerKey,
|
|
83
|
+
catalog.label,
|
|
84
|
+
catalog.model,
|
|
85
|
+
]
|
|
86
|
+
.filter((value) => typeof value === 'string' && value.trim())
|
|
87
|
+
.join(' ')
|
|
88
|
+
.toLowerCase()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 📖 matchesKeyword - substring match, except for very short (<= 2 chars) or
|
|
93
|
+
* digit-leading keywords (o1, o3) which require word boundaries so they can
|
|
94
|
+
* never fire inside an unrelated identifier.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} haystack lowercased scan string
|
|
97
|
+
* @param {string} keyword lowercase brand keyword (alphanumeric + dashes only)
|
|
98
|
+
* @returns {boolean}
|
|
99
|
+
*/
|
|
100
|
+
function matchesKeyword(haystack, keyword) {
|
|
101
|
+
if (keyword.length <= 2 || /^\d/.test(keyword)) {
|
|
102
|
+
const boundary = new RegExp(`(^|[^a-z0-9])${keyword}([^a-z0-9]|$)`)
|
|
103
|
+
return boundary.test(haystack)
|
|
104
|
+
}
|
|
105
|
+
return haystack.includes(keyword)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 📖 detectFamily - map a model to one of the BRAND_MAPPINGS family ids.
|
|
110
|
+
* Case-insensitive; unknown models return null so callers fall back to their
|
|
111
|
+
* default behaviour (for the router: plain set-order failover).
|
|
112
|
+
*
|
|
113
|
+
* @param {string|object|null} model model id, or object with id/label/provider fields
|
|
114
|
+
* @returns {string|null} family id (e.g. 'deepseek') or null
|
|
115
|
+
*/
|
|
116
|
+
export function detectFamily(model) {
|
|
117
|
+
const haystack = getModelFamilyHaystack(model)
|
|
118
|
+
if (!haystack) return null
|
|
119
|
+
for (const mapping of BRAND_MAPPINGS) {
|
|
120
|
+
if (mapping.keywords.some((keyword) => matchesKeyword(haystack, keyword))) {
|
|
121
|
+
return mapping.familyId
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 📖 pickNextCandidate - the two-stage failover policy, kept pure so it is
|
|
129
|
+
* unit-testable without the daemon.
|
|
130
|
+
*
|
|
131
|
+
* Stage 1 (family preserving): when `familyFailover` is enabled and the failed
|
|
132
|
+
* candidate's family is detected, pick the first eligible candidate of the SAME
|
|
133
|
+
* family hosted on a DIFFERENT provider. Candidates arrive in routing order
|
|
134
|
+
* (priority, circuit state, health - see getRoutingCandidates) so picking the
|
|
135
|
+
* first match respects the user's ranking inside the family.
|
|
136
|
+
*
|
|
137
|
+
* Stage 2 (set order): otherwise fall back to the historical behaviour - the
|
|
138
|
+
* first eligible candidate in routing order, whatever its family.
|
|
139
|
+
*
|
|
140
|
+
* @param {object} opts
|
|
141
|
+
* @param {Array<object>} opts.candidates routing candidates (ordered, already health-filtered)
|
|
142
|
+
* @param {object|null} opts.failedCandidate the candidate that just failed
|
|
143
|
+
* @param {Set<string>} opts.triedKeys candidate keys already attempted
|
|
144
|
+
* @param {Set<string>} opts.blockedProviders providers skipped for this request (auth failures)
|
|
145
|
+
* @param {boolean} [opts.familyFailover=true] per-set toggle (config.js normalization)
|
|
146
|
+
* @returns {{ candidate: object, reason: 'family_failover'|'set_order' }|null}
|
|
147
|
+
* null when every candidate is exhausted
|
|
148
|
+
*/
|
|
149
|
+
export function pickNextCandidate({
|
|
150
|
+
candidates,
|
|
151
|
+
failedCandidate,
|
|
152
|
+
triedKeys,
|
|
153
|
+
blockedProviders,
|
|
154
|
+
familyFailover = true,
|
|
155
|
+
}) {
|
|
156
|
+
if (!Array.isArray(candidates) || candidates.length === 0) return null
|
|
157
|
+
const tried = triedKeys instanceof Set ? triedKeys : new Set(triedKeys || [])
|
|
158
|
+
const blocked = blockedProviders instanceof Set ? blockedProviders : new Set(blockedProviders || [])
|
|
159
|
+
const eligible = candidates.filter((candidate) => {
|
|
160
|
+
if (!candidate || !candidate.key) return false
|
|
161
|
+
if (tried.has(candidate.key)) return false
|
|
162
|
+
if (blocked.has(candidate.provider)) return false
|
|
163
|
+
return true
|
|
164
|
+
})
|
|
165
|
+
if (familyFailover !== false && failedCandidate) {
|
|
166
|
+
const family = detectFamily(failedCandidate)
|
|
167
|
+
if (family) {
|
|
168
|
+
const sameFamily = eligible.find(
|
|
169
|
+
(candidate) => candidate.provider !== failedCandidate.provider && detectFamily(candidate) === family,
|
|
170
|
+
)
|
|
171
|
+
if (sameFamily) return { candidate: sameFamily, reason: 'family_failover' }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const next = eligible[0]
|
|
175
|
+
return next ? { candidate: next, reason: 'set_order' } : null
|
|
176
|
+
}
|
|
@@ -54,6 +54,7 @@ import { sendUsageTelemetry } from './telemetry.js'
|
|
|
54
54
|
import { TIER_ORDER } from './utils.js'
|
|
55
55
|
import { atomicWriteJson, safeJsonParse, sleep, maskApiKey, isRouteableProvider } from './shared-helpers.js'
|
|
56
56
|
import { normalizeRequestBody } from './schema-normalizer.js'
|
|
57
|
+
import { pickNextCandidate } from './model-family.js'
|
|
57
58
|
import {
|
|
58
59
|
loadCache as loadProbeCache,
|
|
59
60
|
flushCache as flushProbeCache,
|
|
@@ -1429,6 +1430,14 @@ class RouterRuntime {
|
|
|
1429
1430
|
}
|
|
1430
1431
|
|
|
1431
1432
|
addRequestLog(entry) {
|
|
1433
|
+
// 📖 failover_reason (t8) - WHY this attempt was picked ('family_failover'
|
|
1434
|
+
// or 'set_order'). The failover loop stores it on the active request right
|
|
1435
|
+
// after choosing the next candidate; every log entry of that attempt
|
|
1436
|
+
// (requestLog, /stats, SSE 'request' event) carries it from here on.
|
|
1437
|
+
if (entry.failover === true && entry.request_id && entry.failover_reason === undefined) {
|
|
1438
|
+
const active = this.activeRequests.get(entry.request_id)
|
|
1439
|
+
if (active?.failoverReason) entry.failover_reason = active.failoverReason
|
|
1440
|
+
}
|
|
1432
1441
|
this.requestLog.unshift({ ...entry, at: nowIso() })
|
|
1433
1442
|
while (this.requestLog.length > MAX_REQUEST_LOG) this.requestLog.pop()
|
|
1434
1443
|
this.broadcast('request', entry)
|
|
@@ -2085,8 +2094,12 @@ class RouterRuntime {
|
|
|
2085
2094
|
const tried = []
|
|
2086
2095
|
const blockedProviders = new Set()
|
|
2087
2096
|
let attemptIndex = 0
|
|
2088
|
-
|
|
2089
|
-
|
|
2097
|
+
// 📖 attemptChain is a private copy of the routing order: on a family
|
|
2098
|
+
// failover (t8) the same-family candidate is swapped in as the NEXT
|
|
2099
|
+
// attempt, so the iteration order itself follows the two-stage policy.
|
|
2100
|
+
const attemptChain = candidates.slice()
|
|
2101
|
+
for (let index = 0; index < attemptChain.length && attemptIndex < maxAttempts; index += 1) {
|
|
2102
|
+
const candidate = attemptChain[index]
|
|
2090
2103
|
if (blockedProviders.has(candidate.provider)) continue
|
|
2091
2104
|
|
|
2092
2105
|
const activeReq = this.activeRequests.get(requestId)
|
|
@@ -2103,8 +2116,35 @@ class RouterRuntime {
|
|
|
2103
2116
|
attemptIndex += 1
|
|
2104
2117
|
if (result.authFailure) blockedProviders.add(candidate.provider)
|
|
2105
2118
|
if (result.failoverToNext && attemptIndex < maxAttempts) {
|
|
2106
|
-
|
|
2107
|
-
|
|
2119
|
+
// 📖 Two-stage failover (t8): prefer a healthy model of the SAME
|
|
2120
|
+
// family on another provider (DeepSeek down on NIM -> DeepSeek on
|
|
2121
|
+
// Together) so the user's output style doesn't change mid-request.
|
|
2122
|
+
// Falls back to the historical set-order pick, and is disabled
|
|
2123
|
+
// per-set via familyFailover: false.
|
|
2124
|
+
const pick = pickNextCandidate({
|
|
2125
|
+
candidates: attemptChain,
|
|
2126
|
+
failedCandidate: candidate,
|
|
2127
|
+
triedKeys: new Set(tried),
|
|
2128
|
+
blockedProviders,
|
|
2129
|
+
familyFailover: set.familyFailover !== false,
|
|
2130
|
+
})
|
|
2131
|
+
const next = pick?.candidate || null
|
|
2132
|
+
// 📖 Reorder the remaining chain so `next` is genuinely the following
|
|
2133
|
+
// attempt. With set-order picks this is already the case (or the
|
|
2134
|
+
// skipped entries are blocked anyway), so behaviour is unchanged.
|
|
2135
|
+
if (next && attemptChain[index + 1] !== next) {
|
|
2136
|
+
const nextIndex = attemptChain.indexOf(next)
|
|
2137
|
+
if (nextIndex > index) {
|
|
2138
|
+
attemptChain.splice(nextIndex, 1)
|
|
2139
|
+
attemptChain.splice(index + 1, 0, next)
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
const activeReqForReason = this.activeRequests.get(requestId)
|
|
2143
|
+
if (next && activeReqForReason) activeReqForReason.failoverReason = pick.reason
|
|
2144
|
+
this.logger.warn(
|
|
2145
|
+
`Failover ${candidate.key}${next ? ` -> ${next.key}` : ''}${pick?.reason === 'family_failover' ? ' [family]' : ''}`,
|
|
2146
|
+
{ request_id: requestId, reason: result.reason },
|
|
2147
|
+
)
|
|
2108
2148
|
void sendUsageTelemetry(this.config, {}, {
|
|
2109
2149
|
event: 'app_router_failover',
|
|
2110
2150
|
mode: 'daemon',
|
|
@@ -2112,6 +2152,7 @@ class RouterRuntime {
|
|
|
2112
2152
|
from_model: candidate.key,
|
|
2113
2153
|
to_model: next?.key || null,
|
|
2114
2154
|
reason: result.reason,
|
|
2155
|
+
failover_reason: pick?.reason || null,
|
|
2115
2156
|
attempt_number: attemptIndex,
|
|
2116
2157
|
},
|
|
2117
2158
|
})
|
|
@@ -251,6 +251,9 @@ function normalizeRequestEntry(entry) {
|
|
|
251
251
|
latency_ms: toFiniteNumber(item.latency_ms, null),
|
|
252
252
|
tokens: toFiniteNumber(item.tokens, 0),
|
|
253
253
|
failover: item.failover === true,
|
|
254
|
+
// 📖 failover_reason (t8) - 'family_failover' | 'set_order'; absent on
|
|
255
|
+
// older daemons, guarded so the dashboard renders fine either way.
|
|
256
|
+
failover_reason: safeString(item.failover_reason, null),
|
|
254
257
|
stream: item.stream === true,
|
|
255
258
|
error: safeString(item.error, null),
|
|
256
259
|
}
|
|
@@ -1066,7 +1069,9 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
1066
1069
|
const statusColor = statusText.startsWith('2') ? themeColors.success : statusText === 'ERR' ? themeColors.error : themeColors.warning
|
|
1067
1070
|
const latency = Number.isFinite(row.latency_ms) ? `${Math.round(row.latency_ms)}ms` : '—'
|
|
1068
1071
|
const detail = [
|
|
1069
|
-
|
|
1072
|
+
// 📖 t8 - family hops get their own tag so a same-family retry is
|
|
1073
|
+
// visibly different from a plain set-order failover.
|
|
1074
|
+
row.failover ? (row.failover_reason === 'family_failover' ? 'family' : 'failover') : '',
|
|
1070
1075
|
row.stream ? 'stream' : '',
|
|
1071
1076
|
row.error || '',
|
|
1072
1077
|
].filter(Boolean).join(', ') || '—'
|