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.
@@ -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
- for (const candidate of candidates) {
2089
- if (attemptIndex >= maxAttempts) break
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
- const next = candidates.find((entry) => !tried.includes(entry.key) && !blockedProviders.has(entry.provider))
2107
- this.logger.warn(`Failover ${candidate.key}${next ? ` -> ${next.key}` : ''}`, { request_id: requestId, reason: result.reason })
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
- row.failover ? 'failover' : '',
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(', ') || '—'