free-coding-models 0.5.81 โ†’ 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.
@@ -141,7 +141,7 @@ export function buildBenchmarkRequest(apiKey, modelId, providerKey, url) {
141
141
 
142
142
  const headers = { 'Content-Type': 'application/json' }
143
143
  if (apiKey) headers.Authorization = `Bearer ${apiKey}`
144
- if (providerKey === 'openrouter') {
144
+ if (providerKey === 'openrouter' || providerKey === 'orcarouter') {
145
145
  headers['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
146
146
  headers['X-Title'] = 'free-coding-models'
147
147
  }
@@ -186,6 +186,7 @@ const ENV_VARS = {
186
186
  pollinations: ['POLLINATIONS_API_KEY', 'POLLINATIONS_TOKEN'],
187
187
  siliconflow: 'SILICONFLOW_API_KEY',
188
188
  requesty: 'REQUESTY_API_KEY',
189
+ orcarouter: 'ORCAROUTER_API_KEY',
189
190
  }
190
191
 
191
192
  // ๐Ÿ“– Smart Router defaults are intentionally conservative: balanced probing,
@@ -410,6 +411,10 @@ function normalizeRouterSets(sets) {
410
411
  created: typeof rawSet.created === 'string' && rawSet.created.trim()
411
412
  ? rawSet.created
412
413
  : new Date().toISOString(),
414
+ // ๐Ÿ“– familyFailover (t8): when true (default), a failed request first
415
+ // retries the SAME model family on another provider before falling back
416
+ // to plain set order. See src/core/model-family.js.
417
+ familyFailover: rawSet.familyFailover !== false,
413
418
  }
414
419
  }
415
420
  return normalized
@@ -171,7 +171,10 @@ function resolveProviderBaseUrl(providerKey) {
171
171
  if (providerKey === 'cloudflare') {
172
172
  const accountId = (process.env.CLOUDFLARE_ACCOUNT_ID || '').trim()
173
173
  if (!accountId) return null
174
- return providerUrl.replace('{account_id}', accountId).replace(/\/chat\/completions$/i, '')
174
+ return providerUrl
175
+ .replace(/\{\$CLOUDFLARE_ACCOUNT_ID\}/g, encodeURIComponent(accountId))
176
+ .replace(/\{account_id\}/g, encodeURIComponent(accountId))
177
+ .replace(/\/chat\/completions$/i, '')
175
178
  }
176
179
 
177
180
  return providerUrl
@@ -189,7 +192,9 @@ function resolveGooseBaseUrl(providerKey) {
189
192
  if (providerKey === 'cloudflare') {
190
193
  const accountId = (process.env.CLOUDFLARE_ACCOUNT_ID || '').trim()
191
194
  if (!accountId) return null
192
- return providerUrl.replace('{account_id}', accountId)
195
+ return providerUrl
196
+ .replace(/\{\$CLOUDFLARE_ACCOUNT_ID\}/g, encodeURIComponent(accountId))
197
+ .replace(/\{account_id\}/g, encodeURIComponent(accountId))
193
198
  }
194
199
  return providerUrl
195
200
  }
package/src/core/kilo.js CHANGED
@@ -110,6 +110,13 @@ export async function startKilo(model, fcmConfig) {
110
110
  options: { baseURL: 'https://openrouter.ai/api/v1', apiKey: '{env:OPENROUTER_API_KEY}' },
111
111
  models: {}
112
112
  }
113
+ } else if (providerKey === 'orcarouter') {
114
+ config.provider.orcarouter = {
115
+ npm: '@ai-sdk/openai-compatible',
116
+ name: 'OrcaRouter',
117
+ options: { baseURL: 'https://api.orcarouter.ai/v1', apiKey: '{env:ORCAROUTER_API_KEY}' },
118
+ models: {}
119
+ }
113
120
  } else if (providerKey === 'huggingface') {
114
121
  config.provider.huggingface = {
115
122
  npm: '@ai-sdk/openai-compatible',
@@ -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
+ }
@@ -44,6 +44,7 @@ export const PROVIDER_ALIASES = {
44
44
  nvidiaNim: 'nvidia',
45
45
  nvidia: 'nvidia',
46
46
  openrouter: 'openrouter',
47
+ orcarouter: 'orcarouter',
47
48
  groq: 'groq',
48
49
  cerebras: 'cerebras',
49
50
  github: 'github-models',
@@ -425,6 +425,13 @@ export async function startOpenCode(model, fcmConfig) {
425
425
  options: { baseURL: 'https://openrouter.ai/api/v1', apiKey: '{env:OPENROUTER_API_KEY}' },
426
426
  models: {}
427
427
  }
428
+ } else if (providerKey === 'orcarouter') {
429
+ config.provider.orcarouter = {
430
+ npm: '@ai-sdk/openai-compatible',
431
+ name: 'OrcaRouter',
432
+ options: { baseURL: 'https://api.orcarouter.ai/v1', apiKey: '{env:ORCAROUTER_API_KEY}' },
433
+ models: {}
434
+ }
428
435
  } else if (providerKey === 'huggingface') {
429
436
  config.provider.huggingface = {
430
437
  npm: '@ai-sdk/openai-compatible',
@@ -793,6 +800,13 @@ export async function startOpenCodeDesktop(model, fcmConfig) {
793
800
  options: { baseURL: 'https://openrouter.ai/api/v1', apiKey: '{env:OPENROUTER_API_KEY}' },
794
801
  models: {}
795
802
  }
803
+ } else if (providerKey === 'orcarouter') {
804
+ config.provider.orcarouter = {
805
+ npm: '@ai-sdk/openai-compatible',
806
+ name: 'OrcaRouter',
807
+ options: { baseURL: 'https://api.orcarouter.ai/v1', apiKey: '{env:ORCAROUTER_API_KEY}' },
808
+ models: {}
809
+ }
796
810
  } else if (providerKey === 'huggingface') {
797
811
  config.provider.huggingface = {
798
812
  npm: '@ai-sdk/openai-compatible',
package/src/core/ping.js CHANGED
@@ -56,12 +56,17 @@ const DISABLED_THINKING_RETRY_STATUSES = new Set([400, 422])
56
56
  const disabledThinkingUnsupportedProviders = new Set()
57
57
 
58
58
  // ๐Ÿ“– resolveCloudflareUrl: Cloudflare's OpenAI-compatible endpoint is account-scoped.
59
- // ๐Ÿ“– We resolve {account_id} from env so provider setup can stay simple in config.
59
+ // ๐Ÿ“– We resolve the placeholder from the CLOUDFLARE_ACCOUNT_ID env var so provider
60
+ // ๐Ÿ“– setup can stay simple in config. Supports both the `{account_id}` placeholder
61
+ // ๐Ÿ“– and the explicit `{$CLOUDFLARE_ACCOUNT_ID}` form used in sources.js.
60
62
  export function resolveCloudflareUrl(url) {
61
63
  const accountId = (process.env.CLOUDFLARE_ACCOUNT_ID || '').trim()
62
- if (!url.includes('{account_id}')) return url
63
- if (!accountId) return url.replace('{account_id}', 'missing-account-id')
64
- return url.replace('{account_id}', encodeURIComponent(accountId))
64
+ const hasPlaceholder = url.includes('{$CLOUDFLARE_ACCOUNT_ID}') || url.includes('{account_id}')
65
+ if (!hasPlaceholder) return url
66
+ const replacement = accountId ? encodeURIComponent(accountId) : 'missing-account-id'
67
+ return url
68
+ .replace(/\{\$CLOUDFLARE_ACCOUNT_ID\}/g, replacement)
69
+ .replace(/\{account_id\}/g, replacement)
65
70
  }
66
71
 
67
72
  // ๐Ÿ“– buildChatCompletionPingBody: Use the smallest useful chat-completion probe.
@@ -130,6 +135,11 @@ export function buildPingRequest(apiKey, modelId, providerKey, url, options = {}
130
135
  headers['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
131
136
  headers['X-Title'] = 'free-coding-models'
132
137
  }
138
+ if (providerKey === 'orcarouter') {
139
+ // ๐Ÿ“– OrcaRouter uses the same app-identification convention as OpenRouter.
140
+ headers['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
141
+ headers['X-Title'] = 'free-coding-models'
142
+ }
133
143
 
134
144
  return {
135
145
  url,
@@ -68,6 +68,7 @@ export const PROVIDER_AUTH_ENDPOINTS = {
68
68
  siliconflow: { url: 'https://api.siliconflow.cn/v1/models', method: 'GET' },
69
69
  pollinations: null,
70
70
  requesty: null,
71
+ orcarouter: { url: 'https://api.orcarouter.ai/v1/models', method: 'GET' },
71
72
  together: { url: 'https://api.together.xyz/v1/models', method: 'GET' },
72
73
  perplexity: { url: 'https://api.perplexity.ai/v1/models', method: 'GET' },
73
74
  chutes: { url: 'https://chutes.ai/v1/models', method: 'GET' },
@@ -103,7 +104,7 @@ export async function testProviderKeyDirect(apiKey, providerKey) {
103
104
 
104
105
  const { url, method } = authConfig
105
106
  const headers = { Authorization: `Bearer ${apiKey}` }
106
- if (providerKey === 'openrouter') {
107
+ if (providerKey === 'openrouter' || providerKey === 'orcarouter') {
107
108
  headers['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
108
109
  headers['X-Title'] = 'free-coding-models'
109
110
  }
@@ -65,6 +65,7 @@ export const ENV_VAR_NAMES = {
65
65
  pollinations: 'POLLINATIONS_API_KEY',
66
66
  siliconflow: 'SILICONFLOW_API_KEY',
67
67
  requesty: 'REQUESTY_API_KEY',
68
+ orcarouter: 'ORCAROUTER_API_KEY',
68
69
  }
69
70
 
70
71
  // ๐Ÿ“– OPENCODE_MODEL_MAP: sparse table of model IDs that differ between sources.js and OpenCode's
@@ -255,11 +256,11 @@ export const PROVIDER_METADATA = {
255
256
  rateLimits: 'Depends on provider subscription (e.g., Anthropic, OpenAI)',
256
257
  },
257
258
  'opencode-zen': {
258
- label: 'OpenCode Zen',
259
+ label: 'OpencodeZen',
259
260
  color: chalk.rgb(139, 92, 246), // violet โ€” distinctive from other providers
260
261
  signupUrl: 'https://opencode.ai/auth',
261
262
  signupHint: 'Login at opencode.ai/auth to get your Zen API key',
262
- rateLimits: 'Free tier models โ€” requires OpenCode Zen API key',
263
+ rateLimits: 'Free tier models โ€” requires Zen API key',
263
264
  zenOnly: true,
264
265
  },
265
266
  chutes: {
@@ -330,6 +331,14 @@ export const PROVIDER_METADATA = {
330
331
  signupHint: 'API Keys โ†’ Create (200 req/day free, no card)',
331
332
  rateLimits: 'Free ยท 200 req/day on free models (20 req/min) ยท no card',
332
333
  },
334
+ orcarouter: {
335
+ label: 'OrcaRouter',
336
+ color: chalk.rgb(255, 138, 64),
337
+ signupUrl: 'https://www.orcarouter.ai',
338
+ signupHint: 'Register (GitHub OAuth, no credit card) โ†’ API keys',
339
+ rateLimits: 'Free Hacker tier ยท zero token markup ยท 3 API keys',
340
+ 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
+ },
333
342
  'ollama-cloud': {
334
343
  label: 'Ollama Cloud',
335
344
  color: chalk.rgb(230, 230, 230),
@@ -64,6 +64,7 @@ export const PROVIDER_CAPABILITIES = {
64
64
  pollinations: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'daily' },
65
65
  siliconflow: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'daily' },
66
66
  requesty: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'daily' },
67
+ orcarouter: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'unknown' },
67
68
  }
68
69
 
69
70
  /** 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,
@@ -578,7 +579,7 @@ export function cloneHeadersForUpstream(reqHeaders, apiKey, providerKey) {
578
579
  }
579
580
  headers['Content-Type'] = headers['Content-Type'] || 'application/json'
580
581
  headers.Authorization = `Bearer ${apiKey}`
581
- if (providerKey === 'openrouter') {
582
+ if (providerKey === 'openrouter' || providerKey === 'orcarouter') {
582
583
  headers['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
583
584
  headers['X-Title'] = 'free-coding-models'
584
585
  }
@@ -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
  })
@@ -3619,7 +3660,7 @@ function createDefaultProbeFn(apiKeys) {
3619
3660
  headers.Prefer = 'wait=4'
3620
3661
  } else {
3621
3662
  headers.Authorization = `Bearer ${apiKey}`
3622
- if (provider === 'openrouter') {
3663
+ if (provider === 'openrouter' || provider === 'orcarouter') {
3623
3664
  headers['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
3624
3665
  headers['X-Title'] = 'free-coding-models'
3625
3666
  }
@@ -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(', ') || 'โ€”'