free-coding-models 0.5.83 โ 0.5.85
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 +3 -1
- package/changelog/v0.5.84.md +32 -0
- package/changelog/v0.5.85.md +21 -0
- package/package.json +2 -2
- package/sources.js +133 -63
- package/src/core/config.js +5 -0
- package/src/core/kilo.js +7 -0
- package/src/core/model-family.js +176 -0
- package/src/core/models-dev-index.js +1 -0
- package/src/core/opencode.js +14 -0
- package/src/core/provider-key-tester.js +1 -0
- package/src/core/provider-metadata.js +9 -0
- package/src/core/quota-capabilities.js +2 -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/src/tui/theme.js +2 -0
- package/web/dist/assets/index-DWUely84.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
|
+
}
|
package/src/core/opencode.js
CHANGED
|
@@ -432,6 +432,13 @@ export async function startOpenCode(model, fcmConfig) {
|
|
|
432
432
|
options: { baseURL: 'https://api.orcarouter.ai/v1', apiKey: '{env:ORCAROUTER_API_KEY}' },
|
|
433
433
|
models: {}
|
|
434
434
|
}
|
|
435
|
+
} else if (providerKey === 'vercel-gateway') {
|
|
436
|
+
config.provider['vercel-gateway'] = {
|
|
437
|
+
npm: '@ai-sdk/openai-compatible',
|
|
438
|
+
name: 'Vercel AI Gateway',
|
|
439
|
+
options: { baseURL: 'https://ai-gateway.vercel.sh/v1', apiKey: '{env:VERCEL_AI_GATEWAY_API_KEY}' },
|
|
440
|
+
models: {}
|
|
441
|
+
}
|
|
435
442
|
} else if (providerKey === 'huggingface') {
|
|
436
443
|
config.provider.huggingface = {
|
|
437
444
|
npm: '@ai-sdk/openai-compatible',
|
|
@@ -807,6 +814,13 @@ export async function startOpenCodeDesktop(model, fcmConfig) {
|
|
|
807
814
|
options: { baseURL: 'https://api.orcarouter.ai/v1', apiKey: '{env:ORCAROUTER_API_KEY}' },
|
|
808
815
|
models: {}
|
|
809
816
|
}
|
|
817
|
+
} else if (providerKey === 'vercel-gateway') {
|
|
818
|
+
config.provider['vercel-gateway'] = {
|
|
819
|
+
npm: '@ai-sdk/openai-compatible',
|
|
820
|
+
name: 'Vercel AI Gateway',
|
|
821
|
+
options: { baseURL: 'https://ai-gateway.vercel.sh/v1', apiKey: '{env:VERCEL_AI_GATEWAY_API_KEY}' },
|
|
822
|
+
models: {}
|
|
823
|
+
}
|
|
810
824
|
} else if (providerKey === 'huggingface') {
|
|
811
825
|
config.provider.huggingface = {
|
|
812
826
|
npm: '@ai-sdk/openai-compatible',
|
|
@@ -69,6 +69,7 @@ export const PROVIDER_AUTH_ENDPOINTS = {
|
|
|
69
69
|
pollinations: null,
|
|
70
70
|
requesty: null,
|
|
71
71
|
orcarouter: { url: 'https://api.orcarouter.ai/v1/models', method: 'GET' },
|
|
72
|
+
'vercel-gateway': { url: 'https://ai-gateway.vercel.sh/v1/models', method: 'GET' },
|
|
72
73
|
together: { url: 'https://api.together.xyz/v1/models', method: 'GET' },
|
|
73
74
|
perplexity: { url: 'https://api.perplexity.ai/v1/models', method: 'GET' },
|
|
74
75
|
chutes: { url: 'https://chutes.ai/v1/models', method: 'GET' },
|
|
@@ -66,6 +66,7 @@ export const ENV_VAR_NAMES = {
|
|
|
66
66
|
siliconflow: 'SILICONFLOW_API_KEY',
|
|
67
67
|
requesty: 'REQUESTY_API_KEY',
|
|
68
68
|
orcarouter: 'ORCAROUTER_API_KEY',
|
|
69
|
+
'vercel-gateway': 'VERCEL_AI_GATEWAY_API_KEY',
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
// ๐ OPENCODE_MODEL_MAP: sparse table of model IDs that differ between sources.js and OpenCode's
|
|
@@ -339,6 +340,14 @@ export const PROVIDER_METADATA = {
|
|
|
339
340
|
rateLimits: 'Free Hacker tier ยท zero token markup ยท 3 API keys',
|
|
340
341
|
detailedLimits: 'Zero-markup AI gateway: token prices are passed through at provider rates.\nFree Hacker plan: 3 API keys, adaptive routing + automatic failover + guardrails included.\nOnly the explicit $-0 models are listed in this catalog; the orcarouter/fusion family is pay-as-you-go.',
|
|
341
342
|
},
|
|
343
|
+
'vercel-gateway': {
|
|
344
|
+
label: 'Vercel AI Gateway',
|
|
345
|
+
color: chalk.rgb(140, 170, 255),
|
|
346
|
+
signupUrl: 'https://vercel.com/ai-gateway',
|
|
347
|
+
signupHint: 'AI Gateway โ API Keys ($5 credits/30 days, no card)',
|
|
348
|
+
rateLimits: 'Free tier ยท $5 credits/30 days + $0 models',
|
|
349
|
+
detailedLimits: 'Official Vercel gateway at list prices, zero markup.\nEvery account gets $5 of credits every 30 days, no card required.\nThe monthly credit covers a subset of the catalog; the explicit $0 models stay free.\nCaution: purchasing credits once permanently moves the account to the paid tier and the monthly free credit stops.',
|
|
350
|
+
},
|
|
342
351
|
'ollama-cloud': {
|
|
343
352
|
label: 'Ollama Cloud',
|
|
344
353
|
color: chalk.rgb(230, 230, 230),
|
|
@@ -65,6 +65,8 @@ export const PROVIDER_CAPABILITIES = {
|
|
|
65
65
|
siliconflow: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'daily' },
|
|
66
66
|
requesty: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'daily' },
|
|
67
67
|
orcarouter: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'unknown' },
|
|
68
|
+
// ๐ Vercel AI Gateway exposes GET /v1/credits but no fetcher is wired yet; treat as monthly reset.
|
|
69
|
+
'vercel-gateway': { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'monthly' },
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
/** Fallback for unrecognized providers */
|
|
@@ -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(', ') || 'โ'
|