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.
- package/README.md +25 -8
- package/bin/free-coding-models.js +20 -0
- package/changelog/v0.5.82.md +23 -0
- package/changelog/v0.5.83.md +22 -0
- package/changelog/v0.5.84.md +32 -0
- package/package.json +2 -2
- package/sources.js +167 -106
- package/src/core/benchmark.js +1 -1
- package/src/core/config.js +5 -0
- package/src/core/endpoint-installer.js +7 -2
- 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/ping.js +14 -4
- package/src/core/provider-key-tester.js +2 -1
- package/src/core/provider-metadata.js +11 -2
- package/src/core/quota-capabilities.js +1 -0
- package/src/core/router-daemon.js +47 -6
- package/src/core/router-dashboard.js +6 -1
- package/src/core/security.js +132 -65
- package/src/core/sync-set.js +1 -1
- package/src/core/tool-launchers.js +15 -5
- package/src/core/utils.js +88 -1
- package/src/data/benchmarks.json +6072 -216
- package/src/tui/app.js +7 -5
- package/src/tui/cli-help.js +4 -1
- package/src/tui/command-palette.js +3 -2
- package/src/tui/key-handler.js +172 -61
- package/src/tui/overlays.js +135 -94
- package/src/tui/render-helpers.js +42 -3
- package/src/tui/render-table.js +74 -4
- package/src/tui/theme.js +2 -0
- package/src/tui/tui-state.js +15 -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-Bb5I0K1-.js +0 -40
package/src/core/benchmark.js
CHANGED
|
@@ -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
|
}
|
package/src/core/config.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|
package/src/core/opencode.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
63
|
-
if (!
|
|
64
|
-
|
|
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: '
|
|
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
|
|
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
|
-
|
|
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
|
})
|
|
@@ -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
|
-
|
|
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(', ') || 'โ'
|