failsafe-llm-model-resolver 1.0.0
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/LICENSE +21 -0
- package/README.md +194 -0
- package/index.d.ts +67 -0
- package/index.js +272 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# failsafe-llm-model-resolver
|
|
2
|
+
|
|
3
|
+
**Self-healing, failsafe resolver for the current frontier model.**
|
|
4
|
+
|
|
5
|
+
Live `/models` fetch → preference rules → env-pinned fallback.
|
|
6
|
+
A transient network hiccup never kills a batch job.
|
|
7
|
+
|
|
8
|
+
```js
|
|
9
|
+
const { resolveModel, getNextFreeModel } = require('failsafe-llm-model-resolver');
|
|
10
|
+
|
|
11
|
+
const { id, source } = await resolveModel('xai', process.env.XAI_API_KEY);
|
|
12
|
+
// → { id: 'grok-4', source: 'live' }
|
|
13
|
+
// or { id: 'grok-4', source: 'fallback' } when the live list is unreachable
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Why this exists
|
|
19
|
+
|
|
20
|
+
Hard-coding model IDs is fragile. Providers ship new versions weekly; free tiers rotate; rate-limits appear and disappear. The classic pattern:
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
const model = 'grok-4'; // stale next month
|
|
24
|
+
const model = 'openrouter/free'; // may 404 or be rate-limited
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
…breaks batch jobs and agents at the worst moment.
|
|
28
|
+
|
|
29
|
+
**failsafe-llm-model-resolver** does the boring but critical work once:
|
|
30
|
+
|
|
31
|
+
1. Hits each provider’s native model-list endpoint
|
|
32
|
+
2. Applies a stable preference rule (newest non-fast / non-mini / non-haiku)
|
|
33
|
+
3. Caches the result for the process lifetime
|
|
34
|
+
4. Falls back to an env-configurable pinned ID if anything fails
|
|
35
|
+
|
|
36
|
+
You get the current frontier model when the network works, and a known-good model when it doesn’t.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Supported providers
|
|
41
|
+
|
|
42
|
+
| Provider | Endpoint | Preference rule |
|
|
43
|
+
|----------------|-------------------------------------------------------|------------------------------------------------------|
|
|
44
|
+
| **xAI / Grok** | `GET https://api.x.ai/v1/models` | newest `grok-*`, skip `fast` / `mini` / `lite` |
|
|
45
|
+
| **Anthropic** | `GET https://api.anthropic.com/v1/models` | newest `claude-*`, skip `haiku` / `instant` (newest-first) |
|
|
46
|
+
| **Gemini** | `GET …/v1beta/models?key=…` | newest non-`flash` / non-`lite` |
|
|
47
|
+
| **OpenRouter** | `GET https://openrouter.ai/api/v1/models` | free models (`pricing.prompt/completion === "0"`) + round-robin |
|
|
48
|
+
|
|
49
|
+
No heavy SDKs. Native `fetch` only (Node ≥ 18).
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npm install failsafe-llm-model-resolver
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Or, for personal multi-project reuse without publishing:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# place index.js somewhere shared, e.g. ~/dev/shared-lib/failsafe-llm-model-resolver/
|
|
63
|
+
export NODE_PATH="$HOME/dev/shared-lib:$NODE_PATH"
|
|
64
|
+
# then: require('failsafe-llm-model-resolver')
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## API
|
|
70
|
+
|
|
71
|
+
### `resolveModel(provider, apiKey, options?)`
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
type Provider = 'xai' | 'anthropic' | 'gemini' | 'openrouter';
|
|
75
|
+
|
|
76
|
+
interface ResolveResult {
|
|
77
|
+
id: string; // ready to pass to the provider’s chat endpoint
|
|
78
|
+
source: 'live' | 'fallback';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function resolveModel(
|
|
82
|
+
provider: Provider,
|
|
83
|
+
apiKey: string | null | undefined,
|
|
84
|
+
options?: { forceRefresh?: boolean; fallback?: string }
|
|
85
|
+
): Promise<ResolveResult>;
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
- **Paid providers** → live list + preference rule → single best ID
|
|
89
|
+
- **OpenRouter** → first currently free model (use `getNextFreeModel` for rotation)
|
|
90
|
+
- **Any failure / missing key** → env-pinned fallback, `source: 'fallback'`
|
|
91
|
+
|
|
92
|
+
### OpenRouter free-tier helpers
|
|
93
|
+
|
|
94
|
+
Drop-in replacements for the classic `fetchOrFreeModels` / `getNextOrModel` pattern:
|
|
95
|
+
|
|
96
|
+
```js
|
|
97
|
+
const free = await fetchFreeModels(process.env.OPENROUTER_API_KEY); // Set<string>
|
|
98
|
+
const model = await getNextFreeModel(process.env.OPENROUTER_API_KEY); // round-robin
|
|
99
|
+
resetFreeModelIndex(); // call at the start of a batch if desired
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Cache control
|
|
103
|
+
|
|
104
|
+
```js
|
|
105
|
+
clearCache(); // wipe all in-memory caches (mainly for tests)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Env-configurable fallbacks
|
|
111
|
+
|
|
112
|
+
Never let a transient error kill a job. Set any of:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
XAI_MODEL_FALLBACK=grok-4
|
|
116
|
+
# or GROK_MODEL_FALLBACK=grok-4
|
|
117
|
+
|
|
118
|
+
ANTHROPIC_MODEL_FALLBACK=claude-sonnet-4-20250514
|
|
119
|
+
GEMINI_MODEL_FALLBACK=gemini-2.5-pro
|
|
120
|
+
OPENROUTER_MODEL_FALLBACK=openrouter/free
|
|
121
|
+
# or OPENROUTER_FREE_FALLBACK=openrouter/free
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Defaults are sensible current frontier IDs; override them for your own pins.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Quick examples
|
|
129
|
+
|
|
130
|
+
**Simple resolve**
|
|
131
|
+
|
|
132
|
+
```js
|
|
133
|
+
const { resolveModel } = require('failsafe-llm-model-resolver');
|
|
134
|
+
|
|
135
|
+
async function chat(prompt) {
|
|
136
|
+
const { id } = await resolveModel('anthropic', process.env.ANTHROPIC_API_KEY);
|
|
137
|
+
// use `id` with the Anthropic SDK / fetch …
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**Self-healing OpenRouter free rotation** (matches the original working code)
|
|
142
|
+
|
|
143
|
+
```js
|
|
144
|
+
const { getNextFreeModel, resetFreeModelIndex } = require('failsafe-llm-model-resolver');
|
|
145
|
+
|
|
146
|
+
async function callOpenRouter(prompt) {
|
|
147
|
+
resetFreeModelIndex();
|
|
148
|
+
let lastErr;
|
|
149
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
150
|
+
const model = await getNextFreeModel(process.env.OPENROUTER_API_KEY);
|
|
151
|
+
try {
|
|
152
|
+
// … POST /api/v1/chat/completions with model …
|
|
153
|
+
return result;
|
|
154
|
+
} catch (e) {
|
|
155
|
+
lastErr = e; // rate-limit / empty → try next free model
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
throw lastErr;
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**Force a refresh**
|
|
163
|
+
|
|
164
|
+
```js
|
|
165
|
+
const { id } = await resolveModel('xai', key, { forceRefresh: true });
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Design notes
|
|
171
|
+
|
|
172
|
+
- **In-memory cache** – one fetch per provider per process. Matches the proven `_orFreeModels` pattern.
|
|
173
|
+
- **Preference, not exact IDs** – paid providers use regex + “first match after newest-first”. When the next `grok-5` or `claude-sonnet-5` ships, you get it automatically.
|
|
174
|
+
- **OpenRouter free is special** – exact free filter + ordered round-robin. The free pool rotates; the code adapts.
|
|
175
|
+
- **Fail-closed to a known model** – network, 429 on the models endpoint, empty list, missing key → pinned fallback. Batch jobs keep running.
|
|
176
|
+
- **Zero dependencies** – only what Node 18+ already provides.
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Smoke test
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
npm test
|
|
184
|
+
# or with live keys:
|
|
185
|
+
XAI_API_KEY=… ANTHROPIC_API_KEY=… GEMINI_API_KEY=… OPENROUTER_API_KEY=… npm test
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Without keys the module correctly returns every fallback (`source: 'fallback'`).
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## License
|
|
193
|
+
|
|
194
|
+
MIT
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* self-healing-models
|
|
3
|
+
*
|
|
4
|
+
* Self-healing, failsafe resolver for the current frontier model.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type Provider = 'xai' | 'anthropic' | 'gemini' | 'openrouter';
|
|
8
|
+
|
|
9
|
+
export type ResolveSource = 'live' | 'fallback';
|
|
10
|
+
|
|
11
|
+
export interface ResolveResult {
|
|
12
|
+
/** Model ID ready to pass to the provider's chat/completions endpoint */
|
|
13
|
+
id: string;
|
|
14
|
+
/** Whether the ID came from a live /models fetch or from a pinned fallback */
|
|
15
|
+
source: ResolveSource;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ResolveOptions {
|
|
19
|
+
/** Bypass the in-memory process cache */
|
|
20
|
+
forceRefresh?: boolean;
|
|
21
|
+
/** Override the env/hard-coded fallback for this call only */
|
|
22
|
+
fallback?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the best model ID for a provider.
|
|
27
|
+
*
|
|
28
|
+
* Paid providers use live /models + preference rules (newest non-fast/non-mini).
|
|
29
|
+
* OpenRouter returns the first currently free model.
|
|
30
|
+
* On any failure (network, auth, empty list) returns the env-configured fallback
|
|
31
|
+
* so a transient hiccup never kills a batch job.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveModel(
|
|
34
|
+
provider: Provider,
|
|
35
|
+
apiKey: string | undefined | null,
|
|
36
|
+
options?: ResolveOptions
|
|
37
|
+
): Promise<ResolveResult>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Fetch (and cache) the set of currently free OpenRouter model IDs.
|
|
41
|
+
* Drop-in for the classic fetchOrFreeModels() pattern.
|
|
42
|
+
*/
|
|
43
|
+
export function fetchFreeModels(
|
|
44
|
+
apiKey: string,
|
|
45
|
+
forceRefresh?: boolean
|
|
46
|
+
): Promise<Set<string>>;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Round-robin through currently available OpenRouter free models.
|
|
50
|
+
* Drop-in for the classic getNextOrModel() pattern.
|
|
51
|
+
*/
|
|
52
|
+
export function getNextFreeModel(apiKey: string): Promise<string>;
|
|
53
|
+
|
|
54
|
+
/** Reset the free-model round-robin index (call at the start of a batch). */
|
|
55
|
+
export function resetFreeModelIndex(): void;
|
|
56
|
+
|
|
57
|
+
/** Clear all in-memory caches (mainly for tests). */
|
|
58
|
+
export function clearCache(): void;
|
|
59
|
+
|
|
60
|
+
export const DEFAULT_FALLBACKS: Readonly<Record<Provider, string>>;
|
|
61
|
+
|
|
62
|
+
export const PREFERENCE: Readonly<
|
|
63
|
+
Record<
|
|
64
|
+
Exclude<Provider, 'openrouter'>,
|
|
65
|
+
{ include: RegExp; exclude: RegExp }
|
|
66
|
+
>
|
|
67
|
+
>;
|
package/index.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* failsafe-llm-model-resolver
|
|
3
|
+
*
|
|
4
|
+
* Self-healing, failsafe resolver for the current frontier model across
|
|
5
|
+
* xAI, Anthropic, Gemini, and OpenRouter.
|
|
6
|
+
*
|
|
7
|
+
* resolveModel(provider, apiKey, options?) → Promise<{ id, source: 'live'|'fallback' }>
|
|
8
|
+
*
|
|
9
|
+
* Design goals
|
|
10
|
+
* ────────────
|
|
11
|
+
* • Live fetch from each provider’s native /models endpoint
|
|
12
|
+
* • Preference rules:
|
|
13
|
+
* – paid providers: regex + “newest first, skip fast/mini/lite/haiku”
|
|
14
|
+
* – OpenRouter free: filter pricing.prompt/completion === '0', then round-robin
|
|
15
|
+
* • In-memory cache per process
|
|
16
|
+
* • Env-configurable pinned fallbacks so a transient network blip never kills a batch job
|
|
17
|
+
*
|
|
18
|
+
* @see https://github.com/prakar/failsafe-llm-model-resolver
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// In-memory caches (process lifetime)
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
const _cache = {
|
|
27
|
+
xai: null,
|
|
28
|
+
anthropic: null,
|
|
29
|
+
gemini: null,
|
|
30
|
+
openrouter: null, // full free-model Set
|
|
31
|
+
};
|
|
32
|
+
let _orFreeIdx = 0; // round-robin index for OpenRouter free models
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Defaults / env fallbacks
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
const DEFAULT_FALLBACKS = {
|
|
38
|
+
xai: process.env.XAI_MODEL_FALLBACK || process.env.GROK_MODEL_FALLBACK || 'grok-4',
|
|
39
|
+
anthropic: process.env.ANTHROPIC_MODEL_FALLBACK || 'claude-sonnet-4-20250514',
|
|
40
|
+
gemini: process.env.GEMINI_MODEL_FALLBACK || 'gemini-2.5-pro',
|
|
41
|
+
openrouter: process.env.OPENROUTER_MODEL_FALLBACK || process.env.OPENROUTER_FREE_FALLBACK || 'openrouter/free',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Preference patterns for paid providers (applied after newest-first ordering)
|
|
45
|
+
const PREFERENCE = {
|
|
46
|
+
// Prefer flagship grok-*, skip fast / mini / code-fast variants when possible
|
|
47
|
+
xai: {
|
|
48
|
+
include: /^grok-/i,
|
|
49
|
+
exclude: /fast|mini|lite|code-fast/i,
|
|
50
|
+
},
|
|
51
|
+
// Prefer sonnet/opus over haiku; newest first already gives us the latest
|
|
52
|
+
anthropic: {
|
|
53
|
+
include: /^claude-/i,
|
|
54
|
+
exclude: /haiku|instant|mini/i,
|
|
55
|
+
},
|
|
56
|
+
// Prefer pro / non-flash / non-lite when available
|
|
57
|
+
gemini: {
|
|
58
|
+
include: /gemini-/i,
|
|
59
|
+
exclude: /flash|lite|embedding|aqa|imagen|veo|tts|live/i,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Tiny fetch helper (native fetch, Node 18+)
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
async function httpGet(url, headers = {}) {
|
|
67
|
+
const res = await fetch(url, {
|
|
68
|
+
method: 'GET',
|
|
69
|
+
headers: {
|
|
70
|
+
'Accept': 'application/json',
|
|
71
|
+
...headers,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
const body = await res.text().catch(() => '');
|
|
76
|
+
throw new Error(`HTTP ${res.status} ${res.statusText}: ${body.slice(0, 200)}`);
|
|
77
|
+
}
|
|
78
|
+
return res.json();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Per-provider fetch + normalize → string[] of usable model IDs (newest first)
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
async function fetchXaiModels(apiKey) {
|
|
85
|
+
const json = await httpGet('https://api.x.ai/v1/models', {
|
|
86
|
+
Authorization: `Bearer ${apiKey}`,
|
|
87
|
+
});
|
|
88
|
+
// OpenAI-compatible shape: { data: [ { id, ... } ] }
|
|
89
|
+
const list = (json.data || json.models || []).map(m => m.id || m.name).filter(Boolean);
|
|
90
|
+
return list;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function fetchAnthropicModels(apiKey) {
|
|
94
|
+
const json = await httpGet('https://api.anthropic.com/v1/models', {
|
|
95
|
+
'x-api-key': apiKey,
|
|
96
|
+
'anthropic-version': '2023-06-01',
|
|
97
|
+
});
|
|
98
|
+
// Newest-first by design. Shape: { data: [ { id, display_name, created_at } ] }
|
|
99
|
+
const list = (json.data || []).map(m => m.id).filter(Boolean);
|
|
100
|
+
return list;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function fetchGeminiModels(apiKey) {
|
|
104
|
+
// Gemini uses query-string key and a different envelope
|
|
105
|
+
const url = `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}&pageSize=1000`;
|
|
106
|
+
const json = await httpGet(url);
|
|
107
|
+
// Shape: { models: [ { name: "models/gemini-…", … } ] }
|
|
108
|
+
// The ID used in generateContent is the part after "models/"
|
|
109
|
+
const list = (json.models || [])
|
|
110
|
+
.map(m => {
|
|
111
|
+
const name = m.name || '';
|
|
112
|
+
return name.startsWith('models/') ? name.slice(7) : name;
|
|
113
|
+
})
|
|
114
|
+
.filter(id => id && /generateContent/i.test((m => (m.supportedGenerationMethods || m.supported_actions || []).join(','))(m) || 'generateContent'))
|
|
115
|
+
.filter(Boolean);
|
|
116
|
+
return list;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function fetchOpenRouterFreeModels(apiKey) {
|
|
120
|
+
const json = await httpGet('https://openrouter.ai/api/v1/models', {
|
|
121
|
+
Authorization: `Bearer ${apiKey}`,
|
|
122
|
+
});
|
|
123
|
+
// Free = pricing.prompt === "0" && pricing.completion === "0" (string or number)
|
|
124
|
+
const isZero = v => v === 0 || v === '0' || v === '0.0' || v === '0.00';
|
|
125
|
+
const free = (json.data || [])
|
|
126
|
+
.filter(m => m.pricing && isZero(m.pricing.prompt) && isZero(m.pricing.completion))
|
|
127
|
+
.map(m => m.id)
|
|
128
|
+
.filter(Boolean);
|
|
129
|
+
return free;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Preference selector (paid providers)
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
function pickPreferred(ids, rules) {
|
|
136
|
+
if (!ids || ids.length === 0) return null;
|
|
137
|
+
|
|
138
|
+
// 1. Prefer models that match include and do NOT match exclude
|
|
139
|
+
const preferred = ids.filter(id =>
|
|
140
|
+
rules.include.test(id) && !rules.exclude.test(id)
|
|
141
|
+
);
|
|
142
|
+
if (preferred.length) return preferred[0];
|
|
143
|
+
|
|
144
|
+
// 2. Fall back to any that match include (even if they hit exclude)
|
|
145
|
+
const anyInclude = ids.filter(id => rules.include.test(id));
|
|
146
|
+
if (anyInclude.length) return anyInclude[0];
|
|
147
|
+
|
|
148
|
+
// 3. Absolute last resort: first ID in the list
|
|
149
|
+
return ids[0];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// Public API
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Resolve the best model ID for a provider.
|
|
158
|
+
*
|
|
159
|
+
* @param {'xai'|'anthropic'|'gemini'|'openrouter'} provider
|
|
160
|
+
* @param {string} apiKey
|
|
161
|
+
* @param {object} [options]
|
|
162
|
+
* @param {boolean} [options.forceRefresh=false] Bypass cache
|
|
163
|
+
* @param {string} [options.fallback] Override env fallback for this call
|
|
164
|
+
* @returns {Promise<{ id: string, source: 'live'|'fallback' }>}
|
|
165
|
+
*/
|
|
166
|
+
async function resolveModel(provider, apiKey, options = {}) {
|
|
167
|
+
const p = String(provider || '').toLowerCase();
|
|
168
|
+
if (!['xai', 'anthropic', 'gemini', 'openrouter'].includes(p)) {
|
|
169
|
+
throw new Error(`Unknown provider: ${provider}. Expected xai|anthropic|gemini|openrouter`);
|
|
170
|
+
}
|
|
171
|
+
if (!apiKey) {
|
|
172
|
+
// No key → immediate fallback (still useful for offline / test runs)
|
|
173
|
+
return { id: options.fallback || DEFAULT_FALLBACKS[p], source: 'fallback' };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const force = !!options.forceRefresh;
|
|
177
|
+
const pinned = options.fallback || DEFAULT_FALLBACKS[p];
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
let id;
|
|
181
|
+
|
|
182
|
+
if (p === 'openrouter') {
|
|
183
|
+
// For the generic resolve we return the first free model (or the fallback).
|
|
184
|
+
// Callers that want round-robin should use getNextFreeModel().
|
|
185
|
+
const free = await _getOpenRouterFreeSet(apiKey, force);
|
|
186
|
+
id = free.size ? Array.from(free)[0] : null;
|
|
187
|
+
} else {
|
|
188
|
+
let list = _cache[p];
|
|
189
|
+
if (!list || force) {
|
|
190
|
+
if (p === 'xai') list = await fetchXaiModels(apiKey);
|
|
191
|
+
if (p === 'anthropic') list = await fetchAnthropicModels(apiKey);
|
|
192
|
+
if (p === 'gemini') list = await fetchGeminiModels(apiKey);
|
|
193
|
+
_cache[p] = list;
|
|
194
|
+
}
|
|
195
|
+
id = pickPreferred(list, PREFERENCE[p]);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (id) return { id, source: 'live' };
|
|
199
|
+
return { id: pinned, source: 'fallback' };
|
|
200
|
+
} catch (err) {
|
|
201
|
+
// Never let a transient network error kill a batch job
|
|
202
|
+
return { id: pinned, source: 'fallback' };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Return the cached (or freshly fetched) Set of OpenRouter free model IDs.
|
|
208
|
+
* Matches the old fetchOrFreeModels() contract.
|
|
209
|
+
*/
|
|
210
|
+
async function fetchFreeModels(apiKey, forceRefresh = false) {
|
|
211
|
+
return _getOpenRouterFreeSet(apiKey, forceRefresh);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function _getOpenRouterFreeSet(apiKey, force = false) {
|
|
215
|
+
if (_cache.openrouter && !force) return _cache.openrouter;
|
|
216
|
+
try {
|
|
217
|
+
const ids = await fetchOpenRouterFreeModels(apiKey);
|
|
218
|
+
_cache.openrouter = new Set(ids);
|
|
219
|
+
return _cache.openrouter;
|
|
220
|
+
} catch (e) {
|
|
221
|
+
// Preserve previous cache if any; otherwise seed with the hard-coded fallback
|
|
222
|
+
if (!_cache.openrouter) {
|
|
223
|
+
_cache.openrouter = new Set([DEFAULT_FALLBACKS.openrouter]);
|
|
224
|
+
}
|
|
225
|
+
return _cache.openrouter;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Round-robin through currently available OpenRouter free models.
|
|
231
|
+
* Drop-in replacement for the legacy getNextOrModel().
|
|
232
|
+
*/
|
|
233
|
+
async function getNextFreeModel(apiKey) {
|
|
234
|
+
const available = await _getOpenRouterFreeSet(apiKey);
|
|
235
|
+
if (available.size === 0) return DEFAULT_FALLBACKS.openrouter;
|
|
236
|
+
const models = Array.from(available);
|
|
237
|
+
const model = models[_orFreeIdx % models.length];
|
|
238
|
+
_orFreeIdx = (_orFreeIdx + 1) % models.length;
|
|
239
|
+
return model;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Reset the free-model round-robin index (useful at the start of a batch).
|
|
244
|
+
*/
|
|
245
|
+
function resetFreeModelIndex() {
|
|
246
|
+
_orFreeIdx = 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Clear all in-memory caches (mainly for tests).
|
|
251
|
+
*/
|
|
252
|
+
function clearCache() {
|
|
253
|
+
_cache.xai = null;
|
|
254
|
+
_cache.anthropic = null;
|
|
255
|
+
_cache.gemini = null;
|
|
256
|
+
_cache.openrouter = null;
|
|
257
|
+
_orFreeIdx = 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// Exports
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
module.exports = {
|
|
264
|
+
resolveModel,
|
|
265
|
+
fetchFreeModels,
|
|
266
|
+
getNextFreeModel,
|
|
267
|
+
resetFreeModelIndex,
|
|
268
|
+
clearCache,
|
|
269
|
+
// exposed for advanced callers / tests
|
|
270
|
+
DEFAULT_FALLBACKS,
|
|
271
|
+
PREFERENCE,
|
|
272
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "failsafe-llm-model-resolver",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Self-healing, failsafe resolver for the current frontier model across xAI, Anthropic, Gemini, and OpenRouter. Live /models fetch + preference rules + env fallbacks so a network blip never kills a batch job.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"types": "index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "node smoke-test.js",
|
|
9
|
+
"smoke": "node smoke-test.js"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"llm",
|
|
13
|
+
"models",
|
|
14
|
+
"openai",
|
|
15
|
+
"anthropic",
|
|
16
|
+
"claude",
|
|
17
|
+
"gemini",
|
|
18
|
+
"xai",
|
|
19
|
+
"grok",
|
|
20
|
+
"openrouter",
|
|
21
|
+
"self-healing",
|
|
22
|
+
"failover",
|
|
23
|
+
"fallback",
|
|
24
|
+
"model-resolver",
|
|
25
|
+
"frontier"
|
|
26
|
+
],
|
|
27
|
+
"author": "",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/prakar/failsafe-llm-model-resolver.git"
|
|
35
|
+
},
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/prakar/failsafe-llm-model-resolver/issues"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/prakar/failsafe-llm-model-resolver#readme",
|
|
40
|
+
"files": [
|
|
41
|
+
"index.js",
|
|
42
|
+
"index.d.ts",
|
|
43
|
+
"README.md",
|
|
44
|
+
"LICENSE"
|
|
45
|
+
]
|
|
46
|
+
}
|