converse-mcp-server 3.7.0 → 4.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/.env.example +17 -3
- package/README.md +67 -49
- package/docs/API.md +55 -18
- package/docs/EXAMPLES.md +38 -0
- package/docs/PROVIDERS.md +92 -52
- package/package.json +10 -10
- package/src/config.js +29 -5
- package/src/prompts/helpPrompt.js +27 -2
- package/src/providers/anthropic.js +5 -1
- package/src/providers/claude.js +39 -88
- package/src/providers/codex.js +73 -144
- package/src/providers/copilot.js +42 -149
- package/src/providers/deepseek.js +1 -0
- package/src/providers/gemini-cli.js +64 -97
- package/src/providers/google.js +5 -1
- package/src/providers/mistral.js +5 -1
- package/src/providers/openai-compatible.js +4 -1
- package/src/providers/openai.js +5 -1
- package/src/providers/openrouter.js +1 -0
- package/src/providers/xai.js +5 -1
- package/src/services/summarizationService.js +45 -49
- package/src/tools/chat.js +56 -52
- package/src/tools/modes/roundtable.js +60 -49
- package/src/utils/localProviderAuth.js +63 -0
- package/src/utils/modelCatalog.js +38 -0
- package/src/utils/modelRouting.js +580 -343
|
@@ -1,16 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Model Routing
|
|
2
|
+
* Model Routing
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Maps a model spec to the provider(s) that serve it. Every provider owns a
|
|
5
|
+
* catalog (getSupportedModels(): canonical ID → config with `aliases`), and
|
|
6
|
+
* routing is derived from those catalogs alone — there is no keyword guessing
|
|
7
|
+
* and no silent passthrough of unknown names.
|
|
8
|
+
*
|
|
9
|
+
* Spec grammar:
|
|
10
|
+
* - `provider:model` — route to that provider; `model` must be one of its
|
|
11
|
+
* canonical IDs or aliases (case-insensitive).
|
|
12
|
+
* - `provider` — that provider's default model (per-provider env override or
|
|
13
|
+
* the provider's hardcoded default).
|
|
14
|
+
* - `model` — every provider whose catalog defines the name, in
|
|
15
|
+
* BARE_NAME_PRIORITY order (local CLI/SDK providers first). The first
|
|
16
|
+
* available one serves it and the rest are failover candidates.
|
|
17
|
+
* - `auto` — handled by the callers via getAutoCandidates().
|
|
18
|
+
*
|
|
19
|
+
* Anything that matches nothing is rejected with "did you mean" suggestions.
|
|
20
|
+
* The one open-ended namespace is OpenRouter, whose `vendor/model` slugs are
|
|
21
|
+
* validated against the live OpenRouter catalog at invoke time.
|
|
8
22
|
*/
|
|
9
23
|
|
|
24
|
+
import { findCatalogId } from './modelCatalog.js';
|
|
25
|
+
|
|
10
26
|
/**
|
|
11
|
-
* Provider auto
|
|
12
|
-
*
|
|
13
|
-
* routing prefers them. Shared by every mode's model-resolution path.
|
|
27
|
+
* Provider order for "auto" selection. Subscription-based CLI/SDK providers
|
|
28
|
+
* come before API-key providers.
|
|
14
29
|
* @type {string[]}
|
|
15
30
|
*/
|
|
16
31
|
export const PROVIDER_PRIORITY = [
|
|
@@ -28,210 +43,600 @@ export const PROVIDER_PRIORITY = [
|
|
|
28
43
|
];
|
|
29
44
|
|
|
30
45
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
46
|
+
* Provider order for bare model names. Copilot is absent: it mirrors models
|
|
47
|
+
* other providers already serve and spends premium-request quota, so it is
|
|
48
|
+
* reached only through its `copilot:` namespace.
|
|
49
|
+
* @type {string[]}
|
|
34
50
|
*/
|
|
35
|
-
export
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
'gemini-cli': 'gemini',
|
|
39
|
-
claude: 'claude',
|
|
40
|
-
copilot: 'copilot',
|
|
41
|
-
openai: 'gpt-6',
|
|
42
|
-
xai: 'grok-4.5',
|
|
43
|
-
google: 'gemini-pro',
|
|
44
|
-
anthropic: 'claude-opus-5-5',
|
|
45
|
-
mistral: 'mistral-medium-3-5',
|
|
46
|
-
deepseek: 'deepseek-v4-pro',
|
|
47
|
-
openrouter: 'z-ai/glm-5.2',
|
|
48
|
-
};
|
|
51
|
+
export const BARE_NAME_PRIORITY = PROVIDER_PRIORITY.filter(
|
|
52
|
+
(name) => name !== 'copilot',
|
|
53
|
+
);
|
|
49
54
|
|
|
50
|
-
|
|
51
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Namespace tokens per provider. The first token is the canonical namespace
|
|
57
|
+
* used when suggesting `namespace:model` forms. A bare token names the
|
|
58
|
+
* provider's default model.
|
|
59
|
+
* @type {Object<string, string[]>}
|
|
60
|
+
*/
|
|
61
|
+
export const PROVIDER_NAMESPACES = {
|
|
62
|
+
codex: ['codex'],
|
|
63
|
+
'gemini-cli': ['gemini', 'agy', 'antigravity', 'gemini-cli'],
|
|
64
|
+
claude: ['claude', 'claude-code', 'claude-sdk'],
|
|
65
|
+
copilot: ['copilot', 'github-copilot', 'copilot-sdk'],
|
|
66
|
+
openai: ['openai'],
|
|
67
|
+
google: ['google'],
|
|
68
|
+
xai: ['xai'],
|
|
69
|
+
anthropic: ['anthropic'],
|
|
70
|
+
mistral: ['mistral'],
|
|
71
|
+
deepseek: ['deepseek'],
|
|
72
|
+
openrouter: ['openrouter'],
|
|
73
|
+
};
|
|
52
74
|
|
|
53
75
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* routing-parity checks are enumerable and capability-gating gets a deterministic
|
|
58
|
-
* canonical ID regardless of provider-level alias quirks. Copilot's aliases stay
|
|
59
|
-
* inside copilot.js because they are reached only via the `copilot:` namespace
|
|
60
|
-
* (bare `gpt-*`/`claude-*`/`gemini-*` keyword-route to their native API providers).
|
|
61
|
-
* OpenRouter models are reached by full slug or the `openrouter:` namespace, not
|
|
62
|
-
* by friendly aliases. Keys are lowercase.
|
|
63
|
-
* @type {Object<string, {provider: string, canonicalModel: string}>}
|
|
76
|
+
* Env var that overrides each provider's default model. The value must name a
|
|
77
|
+
* model in that provider's catalog; it is validated at startup.
|
|
78
|
+
* @type {Object<string, string>}
|
|
64
79
|
*/
|
|
65
|
-
export const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
'mistral-large': { provider: 'mistral', canonicalModel: 'mistral-large-2512' },
|
|
78
|
-
'mistral-large-2512': { provider: 'mistral', canonicalModel: 'mistral-large-2512' },
|
|
79
|
-
// DeepSeek (native — OpenRouter DeepSeek models use their full slug instead)
|
|
80
|
-
deepseek: { provider: 'deepseek', canonicalModel: 'deepseek-v4-pro' },
|
|
81
|
-
'deepseek-pro': { provider: 'deepseek', canonicalModel: 'deepseek-v4-pro' },
|
|
82
|
-
'deepseek-v4-pro': { provider: 'deepseek', canonicalModel: 'deepseek-v4-pro' },
|
|
83
|
-
'deepseek-flash': { provider: 'deepseek', canonicalModel: 'deepseek-v4-flash' },
|
|
84
|
-
'deepseek-v4-flash': { provider: 'deepseek', canonicalModel: 'deepseek-v4-flash' },
|
|
80
|
+
export const DEFAULT_MODEL_ENV_VARS = {
|
|
81
|
+
codex: 'CODEX_DEFAULT_MODEL',
|
|
82
|
+
'gemini-cli': 'AGY_DEFAULT_MODEL',
|
|
83
|
+
claude: 'CLAUDE_DEFAULT_MODEL',
|
|
84
|
+
copilot: 'COPILOT_DEFAULT_MODEL',
|
|
85
|
+
openai: 'OPENAI_DEFAULT_MODEL',
|
|
86
|
+
google: 'GOOGLE_DEFAULT_MODEL',
|
|
87
|
+
xai: 'XAI_DEFAULT_MODEL',
|
|
88
|
+
anthropic: 'ANTHROPIC_DEFAULT_MODEL',
|
|
89
|
+
mistral: 'MISTRAL_DEFAULT_MODEL',
|
|
90
|
+
deepseek: 'DEEPSEEK_DEFAULT_MODEL',
|
|
91
|
+
openrouter: 'OPENROUTER_DEFAULT_MODEL',
|
|
85
92
|
};
|
|
86
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Env vars that predate DEFAULT_MODEL_ENV_VARS and are still honored when the
|
|
96
|
+
* new variable is unset.
|
|
97
|
+
* @type {Object<string, string>}
|
|
98
|
+
*/
|
|
99
|
+
export const LEGACY_DEFAULT_MODEL_ENV_VARS = {
|
|
100
|
+
codex: 'CODEX_MODEL',
|
|
101
|
+
copilot: 'COPILOT_MODEL',
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const NAMESPACE_TO_PROVIDER = Object.fromEntries(
|
|
105
|
+
Object.entries(PROVIDER_NAMESPACES).flatMap(([provider, tokens]) =>
|
|
106
|
+
tokens.map((token) => [token, provider]),
|
|
107
|
+
),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Provider registry name for a namespace token, or null.
|
|
112
|
+
* @param {string} token
|
|
113
|
+
* @returns {string|null}
|
|
114
|
+
*/
|
|
115
|
+
export function providerForNamespace(token) {
|
|
116
|
+
return NAMESPACE_TO_PROVIDER[String(token || '').trim().toLowerCase()] || null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function envConfigKey(envVar) {
|
|
120
|
+
return envVar.toLowerCase().replace(/_/g, '');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function getCatalog(provider) {
|
|
124
|
+
if (!provider || typeof provider.getSupportedModels !== 'function') {
|
|
125
|
+
return {};
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
return provider.getSupportedModels() || {};
|
|
129
|
+
} catch {
|
|
130
|
+
return {};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Resolve a name against one provider's catalog.
|
|
136
|
+
* @param {object} provider - Provider implementation
|
|
137
|
+
* @param {string} name - Canonical ID or alias (case-insensitive)
|
|
138
|
+
* @returns {string|null} Canonical catalog ID
|
|
139
|
+
*/
|
|
140
|
+
export function findCatalogModel(provider, name) {
|
|
141
|
+
return findCatalogId(getCatalog(provider), name);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Every name (canonical IDs and aliases) a provider's catalog accepts, as
|
|
146
|
+
* suggestion entries grouped by model.
|
|
147
|
+
* @param {object} provider
|
|
148
|
+
* @param {string} [prefix=''] - Prepended to each label (e.g. 'copilot:')
|
|
149
|
+
* @returns {Array<{label: string, key: string, group: string}>}
|
|
150
|
+
*/
|
|
151
|
+
function catalogEntries(provider, prefix = '') {
|
|
152
|
+
const entries = [];
|
|
153
|
+
for (const [id, entry] of Object.entries(getCatalog(provider))) {
|
|
154
|
+
const group = modelIdentity(id);
|
|
155
|
+
for (const name of [id, ...(entry?.aliases || []).map(String)]) {
|
|
156
|
+
entries.push({ label: `${prefix}${name}`, key: name, group });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return entries;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Configured default-model override for a provider, if any.
|
|
164
|
+
* @param {string} providerName
|
|
165
|
+
* @param {object} config
|
|
166
|
+
* @returns {{ envVar: string, value: string }|null}
|
|
167
|
+
*/
|
|
168
|
+
function getDefaultModelOverride(providerName, config) {
|
|
169
|
+
for (const table of [DEFAULT_MODEL_ENV_VARS, LEGACY_DEFAULT_MODEL_ENV_VARS]) {
|
|
170
|
+
const envVar = table[providerName];
|
|
171
|
+
const value = envVar ? config?.providers?.[envConfigKey(envVar)] : undefined;
|
|
172
|
+
if (typeof value === 'string' && value.trim()) {
|
|
173
|
+
return { envVar, value: value.trim() };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Default model for a provider: the env override when set, otherwise the
|
|
181
|
+
* provider's hardcoded `defaultModel`.
|
|
182
|
+
* @param {string} providerName - Provider registry name
|
|
183
|
+
* @param {object} providers - Provider instances
|
|
184
|
+
* @param {object} [config] - Configuration
|
|
185
|
+
* @returns {string} Canonical model ID
|
|
186
|
+
*/
|
|
187
|
+
export function getDefaultModelForProvider(providerName, providers, config) {
|
|
188
|
+
const provider = providers?.[providerName];
|
|
189
|
+
const override = getDefaultModelOverride(providerName, config);
|
|
190
|
+
if (override) {
|
|
191
|
+
return findCatalogModel(provider, override.value) || override.value;
|
|
192
|
+
}
|
|
193
|
+
if (provider?.defaultModel) return provider.defaultModel;
|
|
194
|
+
return Object.keys(getCatalog(provider))[0] || null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Check every configured default-model override against its provider's
|
|
199
|
+
* catalog. OpenRouter also accepts any `vendor/model` slug.
|
|
200
|
+
* @param {object} providers - Provider instances
|
|
201
|
+
* @param {object} config - Configuration
|
|
202
|
+
* @returns {string[]} Error messages (empty when valid)
|
|
203
|
+
*/
|
|
204
|
+
export function validateDefaultModelOverrides(providers, config) {
|
|
205
|
+
const errors = [];
|
|
206
|
+
for (const providerName of Object.keys(DEFAULT_MODEL_ENV_VARS)) {
|
|
207
|
+
const override = getDefaultModelOverride(providerName, config);
|
|
208
|
+
const provider = providers?.[providerName];
|
|
209
|
+
if (!override || !provider) continue;
|
|
210
|
+
if (findCatalogModel(provider, override.value)) continue;
|
|
211
|
+
if (providerName === 'openrouter' && override.value.includes('/')) continue;
|
|
212
|
+
const suggestions = suggestSimilar(override.value, catalogEntries(provider));
|
|
213
|
+
errors.push(
|
|
214
|
+
`${override.envVar}="${override.value}" is not a ${providerName} model.${formatSuggestions(suggestions)}`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
return errors;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// --- Similarity suggestions ----------------------------------------------------
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Optimal string alignment distance (Levenshtein plus adjacent transposition),
|
|
224
|
+
* so "gtp-6" is one edit from "gpt-6".
|
|
225
|
+
*/
|
|
226
|
+
function editDistance(a, b) {
|
|
227
|
+
const rows = a.length + 1;
|
|
228
|
+
const cols = b.length + 1;
|
|
229
|
+
const d = Array.from({ length: rows }, () => new Array(cols).fill(0));
|
|
230
|
+
for (let i = 0; i < rows; i++) d[i][0] = i;
|
|
231
|
+
for (let j = 0; j < cols; j++) d[0][j] = j;
|
|
232
|
+
for (let i = 1; i < rows; i++) {
|
|
233
|
+
for (let j = 1; j < cols; j++) {
|
|
234
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
235
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
|
|
236
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
|
237
|
+
d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return d[rows - 1][cols - 1];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Up to `limit` vocabulary entries close to `input`. An entry qualifies when
|
|
246
|
+
* its edit distance is within a third of the longer string (at least 1), or
|
|
247
|
+
* when one string contains the other (4+ chars). Entries sharing a `group`
|
|
248
|
+
* (aliases of one model) yield only their closest member, so the suggestions
|
|
249
|
+
* name distinct models. Ties keep vocabulary order, so callers list preferred
|
|
250
|
+
* names first.
|
|
251
|
+
* @param {string} input
|
|
252
|
+
* @param {Array<string|{label: string, key?: string, group?: string}>} vocabulary
|
|
253
|
+
* Plain strings, or entries whose `key` is compared and `label` is shown
|
|
254
|
+
* @param {object} [options]
|
|
255
|
+
* @param {number} [options.limit=3]
|
|
256
|
+
* @returns {string[]} Labels
|
|
257
|
+
*/
|
|
258
|
+
export function suggestSimilar(input, vocabulary, { limit = 3 } = {}) {
|
|
259
|
+
const target = String(input || '').trim().toLowerCase();
|
|
260
|
+
if (!target) return [];
|
|
261
|
+
const bestByGroup = new Map();
|
|
262
|
+
vocabulary.forEach((raw, order) => {
|
|
263
|
+
const entry = typeof raw === 'string' ? { label: raw } : raw;
|
|
264
|
+
const key = String(entry.key ?? entry.label).toLowerCase();
|
|
265
|
+
const distance = editDistance(target, key);
|
|
266
|
+
const threshold = Math.max(1, Math.floor(Math.max(target.length, key.length) / 3));
|
|
267
|
+
const contains =
|
|
268
|
+
Math.min(target.length, key.length) >= 4 &&
|
|
269
|
+
(key.includes(target) || target.includes(key));
|
|
270
|
+
let rank;
|
|
271
|
+
if (distance <= threshold) rank = distance;
|
|
272
|
+
else if (contains) rank = threshold + 1;
|
|
273
|
+
else return;
|
|
274
|
+
const group = entry.group ?? entry.label;
|
|
275
|
+
const best = bestByGroup.get(group);
|
|
276
|
+
if (!best || rank < best.rank) {
|
|
277
|
+
bestByGroup.set(group, { label: entry.label, rank, order: best?.order ?? order });
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
const labels = [];
|
|
281
|
+
for (const s of [...bestByGroup.values()].sort((x, y) => x.rank - y.rank || x.order - y.order)) {
|
|
282
|
+
if (!labels.includes(s.label)) labels.push(s.label);
|
|
283
|
+
if (labels.length >= limit) break;
|
|
284
|
+
}
|
|
285
|
+
return labels;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function formatSuggestions(suggestions) {
|
|
289
|
+
return suggestions.length
|
|
290
|
+
? ` Did you mean: ${suggestions.join(', ')}?`
|
|
291
|
+
: '';
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// --- Spec parsing ----------------------------------------------------------------
|
|
295
|
+
|
|
87
296
|
/**
|
|
88
297
|
* Parse OpenRouter model decorations off a slug. `:online` is consumed into a
|
|
89
298
|
* `webSearch` flag (the provider attaches the web plugin from the flag) and is
|
|
90
299
|
* never carried on the request/lookup ID; other suffixes such as `:free` are
|
|
91
300
|
* preserved on the request model but stripped from the bare lookup base.
|
|
92
|
-
* @param {string} slug - Slug with
|
|
93
|
-
* @returns {{ base: string,
|
|
301
|
+
* @param {string} slug - Slug with any `openrouter:` namespace already removed
|
|
302
|
+
* @returns {{ base: string, decorations: string[], webSearch: boolean }}
|
|
94
303
|
*/
|
|
95
304
|
function parseOpenRouterDecorations(slug) {
|
|
96
305
|
const segments = String(slug).split(':');
|
|
97
|
-
const base = segments[0];
|
|
98
306
|
const decorations = segments.slice(1);
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
307
|
+
return {
|
|
308
|
+
base: segments[0],
|
|
309
|
+
decorations: decorations.filter((d) => d !== 'online'),
|
|
310
|
+
webSearch: decorations.includes('online'),
|
|
311
|
+
};
|
|
103
312
|
}
|
|
104
313
|
|
|
105
314
|
/**
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* unchanged (never silently substituted).
|
|
112
|
-
* @param {string} spec - Model specification
|
|
113
|
-
* @param {object} providers - Provider instances
|
|
114
|
-
* @returns {{ providerName: string, canonicalModel: string, options: object }}
|
|
315
|
+
* Split a spec into its namespace and model parts. A leading `token:` is a
|
|
316
|
+
* namespace only when the token has no `/` — `vendor/model:free` is a bare
|
|
317
|
+
* OpenRouter slug with a decoration.
|
|
318
|
+
* @param {string} spec
|
|
319
|
+
* @returns {{ namespace: string|null, providerName: string|null, name: string }}
|
|
115
320
|
*/
|
|
116
|
-
function
|
|
117
|
-
const raw = String(spec);
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (lower === 'auto') {
|
|
122
|
-
const providerName = mapModelToProvider('auto', providers);
|
|
321
|
+
export function parseModelSpec(spec) {
|
|
322
|
+
const raw = String(spec ?? '').trim();
|
|
323
|
+
const colon = raw.indexOf(':');
|
|
324
|
+
if (colon > 0 && !raw.slice(0, colon).includes('/')) {
|
|
325
|
+
const namespace = raw.slice(0, colon).trim().toLowerCase();
|
|
123
326
|
return {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
327
|
+
namespace,
|
|
328
|
+
providerName: providerForNamespace(namespace),
|
|
329
|
+
name: raw.slice(colon + 1).trim(),
|
|
127
330
|
};
|
|
128
331
|
}
|
|
332
|
+
return { namespace: null, providerName: null, name: raw };
|
|
333
|
+
}
|
|
129
334
|
|
|
130
|
-
|
|
131
|
-
// parse `:online`/`:free` decorations before any lookup.
|
|
132
|
-
if (lower.startsWith('openrouter:')) {
|
|
133
|
-
const { modelForRequest, webSearch } = parseOpenRouterDecorations(
|
|
134
|
-
raw.slice('openrouter:'.length),
|
|
135
|
-
);
|
|
136
|
-
if (webSearch) options.web_search = true;
|
|
137
|
-
return { providerName: 'openrouter', canonicalModel: modelForRequest, options };
|
|
138
|
-
}
|
|
335
|
+
// --- Resolution ------------------------------------------------------------------
|
|
139
336
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
lower.startsWith('gemini:')
|
|
148
|
-
) {
|
|
149
|
-
return {
|
|
150
|
-
providerName: mapModelToProvider(raw, providers),
|
|
151
|
-
canonicalModel: raw,
|
|
152
|
-
options,
|
|
153
|
-
};
|
|
154
|
-
}
|
|
337
|
+
/**
|
|
338
|
+
* Loose model identity, so the same model spelled differently by two
|
|
339
|
+
* providers ("claude-opus-5-5" vs "claude-opus-5.5") counts as one.
|
|
340
|
+
*/
|
|
341
|
+
function modelIdentity(id) {
|
|
342
|
+
return String(id).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
343
|
+
}
|
|
155
344
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
canonicalModel: curated.canonicalModel,
|
|
162
|
-
options,
|
|
163
|
-
};
|
|
345
|
+
function isAvailable(provider, config) {
|
|
346
|
+
try {
|
|
347
|
+
return Boolean(provider?.isAvailable?.(config));
|
|
348
|
+
} catch {
|
|
349
|
+
return false;
|
|
164
350
|
}
|
|
351
|
+
}
|
|
165
352
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
}
|
|
174
|
-
return { providerName, canonicalModel: modelForRequest, options };
|
|
353
|
+
/**
|
|
354
|
+
* Resolve one provider's name for a model, applying OpenRouter decorations.
|
|
355
|
+
* Returns null when the provider does not serve the name.
|
|
356
|
+
*/
|
|
357
|
+
function resolveForProvider(providerName, provider, name) {
|
|
358
|
+
if (providerName !== 'openrouter') {
|
|
359
|
+
const id = findCatalogModel(provider, name);
|
|
360
|
+
return id ? { resolvedModel: id, options: {} } : null;
|
|
175
361
|
}
|
|
362
|
+
const { base, decorations, webSearch } = parseOpenRouterDecorations(name);
|
|
363
|
+
const id =
|
|
364
|
+
findCatalogModel(provider, base) ||
|
|
365
|
+
(base.includes('/') ? base : null);
|
|
366
|
+
if (!id) return null;
|
|
367
|
+
return {
|
|
368
|
+
resolvedModel: decorations.length ? `${id}:${decorations.join(':')}` : id,
|
|
369
|
+
options: webSearch ? { web_search: true } : {},
|
|
370
|
+
};
|
|
371
|
+
}
|
|
176
372
|
|
|
177
|
-
|
|
178
|
-
const providerName = mapModelToProvider(raw, providers);
|
|
373
|
+
function candidate(providerName, provider, resolved) {
|
|
179
374
|
return {
|
|
180
375
|
providerName,
|
|
181
|
-
|
|
182
|
-
|
|
376
|
+
provider,
|
|
377
|
+
resolvedModel: resolved.resolvedModel,
|
|
378
|
+
options: resolved.options,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function okResult(candidates) {
|
|
383
|
+
const [first] = candidates;
|
|
384
|
+
return {
|
|
385
|
+
status: 'ok',
|
|
386
|
+
providerName: first.providerName,
|
|
387
|
+
provider: first.provider,
|
|
388
|
+
resolvedModel: first.resolvedModel,
|
|
389
|
+
options: first.options,
|
|
390
|
+
candidates,
|
|
391
|
+
error: null,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function failResult(status, error, providerName = null, provider = null) {
|
|
396
|
+
return {
|
|
397
|
+
status,
|
|
398
|
+
providerName,
|
|
399
|
+
provider,
|
|
400
|
+
resolvedModel: null,
|
|
401
|
+
options: {},
|
|
402
|
+
candidates: [],
|
|
403
|
+
error,
|
|
183
404
|
};
|
|
184
405
|
}
|
|
185
406
|
|
|
186
407
|
/**
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
* @returns {string} Concrete model name
|
|
408
|
+
* Vocabulary for suggesting a replacement for an unknown bare name:
|
|
409
|
+
* namespaces, then every bare-routable name in priority order, then
|
|
410
|
+
* `copilot:`-qualified names (Copilot is namespace-only).
|
|
191
411
|
*/
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
412
|
+
function bareVocabulary(providers) {
|
|
413
|
+
const vocab = ['auto', ...namespaceEntries()];
|
|
414
|
+
for (const name of BARE_NAME_PRIORITY) {
|
|
415
|
+
if (providers[name]) vocab.push(...catalogEntries(providers[name]));
|
|
195
416
|
}
|
|
417
|
+
for (const name of PROVIDER_PRIORITY) {
|
|
418
|
+
if (!BARE_NAME_PRIORITY.includes(name) && providers[name]) {
|
|
419
|
+
const ns = PROVIDER_NAMESPACES[name][0];
|
|
420
|
+
vocab.push(
|
|
421
|
+
...catalogEntries(providers[name], `${ns}:`).map((e) => ({
|
|
422
|
+
...e,
|
|
423
|
+
group: `${ns}:${e.group}`,
|
|
424
|
+
})),
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return vocab;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Namespace tokens as suggestion entries, grouped by provider. */
|
|
432
|
+
function namespaceEntries() {
|
|
433
|
+
return Object.entries(NAMESPACE_TO_PROVIDER).map(([token, provider]) => ({
|
|
434
|
+
label: token,
|
|
435
|
+
group: `provider:${provider}`,
|
|
436
|
+
}));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* `namespace:name` forms for providers other than `exclude` whose catalog
|
|
441
|
+
* contains `name` exactly — the likeliest fix for a right model under the
|
|
442
|
+
* wrong prefix.
|
|
443
|
+
*/
|
|
444
|
+
function exactMatchesElsewhere(name, providers, exclude) {
|
|
445
|
+
const matches = [];
|
|
446
|
+
for (const providerName of PROVIDER_PRIORITY) {
|
|
447
|
+
if (providerName === exclude || !providers[providerName]) continue;
|
|
448
|
+
if (findCatalogModel(providers[providerName], name)) {
|
|
449
|
+
matches.push(`${PROVIDER_NAMESPACES[providerName][0]}:${name}`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return matches;
|
|
453
|
+
}
|
|
196
454
|
|
|
197
|
-
|
|
455
|
+
function unavailableMessageFor(providerNames) {
|
|
456
|
+
return providerNames
|
|
457
|
+
.map((name) => `${name} (${getProviderSetupHint(name)})`)
|
|
458
|
+
.join('; ');
|
|
198
459
|
}
|
|
199
460
|
|
|
200
461
|
/**
|
|
201
|
-
*
|
|
202
|
-
*
|
|
462
|
+
* Resolve a model spec into routing facts. `candidates` lists every provider
|
|
463
|
+
* that can serve the spec, in failover order; the top-level provider fields
|
|
464
|
+
* mirror the first candidate. Callers switch on `status`:
|
|
465
|
+
* - 'ok' — at least one available candidate
|
|
466
|
+
* - 'unknown' — nothing matches; `error` carries "did you mean" suggestions
|
|
467
|
+
* - 'unavailable' — the model exists but no provider serving it is available
|
|
468
|
+
* @param {string} spec - Model specification
|
|
469
|
+
* @param {object} providers - Provider instances keyed by registry name
|
|
470
|
+
* @param {object} config - Configuration
|
|
471
|
+
* @returns {{ status: string, providerName: string|null, provider: object|null,
|
|
472
|
+
* resolvedModel: string|null, options: object, candidates: Array, error: string|null }}
|
|
203
473
|
*/
|
|
204
|
-
|
|
474
|
+
export function resolveModelSpec(spec, providers, config) {
|
|
475
|
+
const raw = String(spec ?? '').trim();
|
|
476
|
+
if (!raw) {
|
|
477
|
+
return failResult('unknown', 'Empty model name.');
|
|
478
|
+
}
|
|
479
|
+
const { namespace, providerName: nsProvider, name } = parseModelSpec(raw);
|
|
480
|
+
|
|
481
|
+
if (namespace !== null) {
|
|
482
|
+
if (!nsProvider) {
|
|
483
|
+
const suggestions = suggestSimilar(namespace, namespaceEntries()).map(
|
|
484
|
+
(ns) => `${ns}:${name}`,
|
|
485
|
+
);
|
|
486
|
+
return failResult(
|
|
487
|
+
'unknown',
|
|
488
|
+
`Unknown provider "${namespace}" in "${raw}".${formatSuggestions(suggestions)}`,
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
return resolveNamespaced(raw, nsProvider, name, providers, config);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const bareProvider = providerForNamespace(raw);
|
|
495
|
+
if (bareProvider) {
|
|
496
|
+
return resolveNamespaced(raw, bareProvider, '', providers, config);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return resolveBare(raw, providers, config);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function resolveNamespaced(raw, providerName, name, providers, config) {
|
|
503
|
+
const provider = providers[providerName];
|
|
504
|
+
if (!provider) {
|
|
505
|
+
return failResult('unknown', `Provider ${providerName} is not registered.`, providerName);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const resolved = name
|
|
509
|
+
? resolveForProvider(providerName, provider, name)
|
|
510
|
+
: resolveForProvider(
|
|
511
|
+
providerName,
|
|
512
|
+
provider,
|
|
513
|
+
getDefaultModelForProvider(providerName, providers, config),
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
if (!resolved) {
|
|
517
|
+
const ns = PROVIDER_NAMESPACES[providerName][0];
|
|
518
|
+
const suggestions = [
|
|
519
|
+
...exactMatchesElsewhere(name, providers, providerName),
|
|
520
|
+
...suggestSimilar(name, catalogEntries(provider, `${ns}:`)),
|
|
521
|
+
].slice(0, 3);
|
|
522
|
+
return failResult(
|
|
523
|
+
'unknown',
|
|
524
|
+
`Unknown ${providerName} model "${name}" in "${raw}".${formatSuggestions(suggestions)}`,
|
|
525
|
+
providerName,
|
|
526
|
+
provider,
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (!isAvailable(provider, config)) {
|
|
531
|
+
return failResult(
|
|
532
|
+
'unavailable',
|
|
533
|
+
getProviderUnavailableMessage(providerName),
|
|
534
|
+
providerName,
|
|
535
|
+
provider,
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return okResult([candidate(providerName, provider, resolved)]);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function resolveBare(raw, providers, config) {
|
|
543
|
+
const defining = [];
|
|
544
|
+
for (const providerName of BARE_NAME_PRIORITY) {
|
|
545
|
+
const provider = providers[providerName];
|
|
546
|
+
if (!provider) continue;
|
|
547
|
+
const resolved = resolveForProvider(providerName, provider, raw);
|
|
548
|
+
if (resolved) defining.push({ providerName, provider, resolved });
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (defining.length === 0) {
|
|
552
|
+
const suggestions = suggestSimilar(raw, bareVocabulary(providers));
|
|
553
|
+
return failResult('unknown', `Unknown model "${raw}".${formatSuggestions(suggestions)}`);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const available = defining.filter((d) => isAvailable(d.provider, config));
|
|
557
|
+
if (available.length === 0) {
|
|
558
|
+
const names = defining.map((d) => d.providerName);
|
|
559
|
+
return failResult(
|
|
560
|
+
'unavailable',
|
|
561
|
+
`Model "${raw}" is served by ${names.join(', ')}, but none is available: ${unavailableMessageFor(names)}.`,
|
|
562
|
+
names[0],
|
|
563
|
+
defining[0].provider,
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Failover stays on the same model: a provider whose alias points at a
|
|
568
|
+
// different model than the first available one is not a substitute.
|
|
569
|
+
const identity = modelIdentity(available[0].resolved.resolvedModel);
|
|
570
|
+
const candidates = available
|
|
571
|
+
.filter((d) => modelIdentity(d.resolved.resolvedModel) === identity)
|
|
572
|
+
.map((d) => candidate(d.providerName, d.provider, d.resolved));
|
|
573
|
+
return okResult(candidates);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// --- Availability ------------------------------------------------------------------
|
|
577
|
+
|
|
578
|
+
const API_KEY_ENV_VARS = {
|
|
579
|
+
openai: 'OPENAI_API_KEY',
|
|
580
|
+
google: 'GOOGLE_API_KEY (or GEMINI_API_KEY, or Vertex AI settings)',
|
|
581
|
+
xai: 'XAI_API_KEY',
|
|
582
|
+
anthropic: 'ANTHROPIC_API_KEY',
|
|
583
|
+
mistral: 'MISTRAL_API_KEY',
|
|
584
|
+
deepseek: 'DEEPSEEK_API_KEY',
|
|
585
|
+
openrouter: 'OPENROUTER_API_KEY',
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
const LOCAL_PROVIDER_SETUP_HINTS = {
|
|
589
|
+
codex: 'run `codex login` or set CODEX_API_KEY',
|
|
590
|
+
claude: 'run `claude login` or set CLAUDE_CODE_OAUTH_TOKEN',
|
|
205
591
|
'gemini-cli':
|
|
206
|
-
'
|
|
592
|
+
'install the Antigravity CLI and run `agy` once to log in (https://antigravity.google)',
|
|
593
|
+
copilot: 'install @github/copilot-sdk and sign in to GitHub Copilot',
|
|
207
594
|
};
|
|
208
595
|
|
|
209
596
|
/**
|
|
210
|
-
*
|
|
597
|
+
* One-line setup hint for a provider.
|
|
598
|
+
* @param {string} providerName
|
|
599
|
+
* @returns {string}
|
|
600
|
+
*/
|
|
601
|
+
export function getProviderSetupHint(providerName) {
|
|
602
|
+
if (LOCAL_PROVIDER_SETUP_HINTS[providerName]) {
|
|
603
|
+
return LOCAL_PROVIDER_SETUP_HINTS[providerName];
|
|
604
|
+
}
|
|
605
|
+
return API_KEY_ENV_VARS[providerName]
|
|
606
|
+
? `set ${API_KEY_ENV_VARS[providerName]}`
|
|
607
|
+
: 'check its configuration';
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Build the "provider not available" error message with a setup hint.
|
|
211
612
|
* @param {string} providerName - Provider registry name
|
|
212
613
|
* @returns {string}
|
|
213
614
|
*/
|
|
214
615
|
export function getProviderUnavailableMessage(providerName) {
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
return hint ? `${base} ${hint}` : base;
|
|
616
|
+
const hint = getProviderSetupHint(providerName);
|
|
617
|
+
return `Provider ${providerName} is not available: ${hint}.`;
|
|
218
618
|
}
|
|
219
619
|
|
|
220
620
|
/**
|
|
221
|
-
* Whether a provider's default model supports image inputs. Used by
|
|
222
|
-
* selection
|
|
223
|
-
*
|
|
224
|
-
* image-capable (fail open — they surface their own errors downstream).
|
|
621
|
+
* Whether a provider's default model supports image inputs. Used by "auto"
|
|
622
|
+
* selection to skip text-only providers when the request includes images.
|
|
623
|
+
* Providers without a resolvable config fail open.
|
|
225
624
|
* @param {object} providerInstance - Provider implementation
|
|
226
625
|
* @param {string} providerName - Provider registry name
|
|
626
|
+
* @param {object} [providers] - Provider instances
|
|
627
|
+
* @param {object} [config] - Configuration
|
|
227
628
|
* @returns {boolean}
|
|
228
629
|
*/
|
|
229
|
-
export function providerSupportsImages(providerInstance, providerName) {
|
|
630
|
+
export function providerSupportsImages(providerInstance, providerName, providers, config) {
|
|
230
631
|
if (!providerInstance || typeof providerInstance.getModelConfig !== 'function') {
|
|
231
632
|
return true;
|
|
232
633
|
}
|
|
233
634
|
try {
|
|
234
|
-
const defaultModel = getDefaultModelForProvider(
|
|
635
|
+
const defaultModel = getDefaultModelForProvider(
|
|
636
|
+
providerName,
|
|
637
|
+
providers || { [providerName]: providerInstance },
|
|
638
|
+
config,
|
|
639
|
+
);
|
|
235
640
|
const modelConfig = providerInstance.getModelConfig(defaultModel);
|
|
236
641
|
if (!modelConfig) return true;
|
|
237
642
|
return modelConfig.supportsImages !== false;
|
|
@@ -241,9 +646,8 @@ export function providerSupportsImages(providerInstance, providerName) {
|
|
|
241
646
|
}
|
|
242
647
|
|
|
243
648
|
/**
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
* count. Shared by every mode's "auto" expansion path.
|
|
649
|
+
* Available provider names in PROVIDER_PRIORITY order, optionally skipping
|
|
650
|
+
* text-only providers when the request has images and capping the count.
|
|
247
651
|
* @param {object} providers - Provider instances
|
|
248
652
|
* @param {object} config - Configuration
|
|
249
653
|
* @param {object} [options]
|
|
@@ -256,207 +660,40 @@ export function getAvailableProviders(providers, config, { hasImages = false, li
|
|
|
256
660
|
for (const name of PROVIDER_PRIORITY) {
|
|
257
661
|
if (names.length >= limit) break;
|
|
258
662
|
const provider = providers[name];
|
|
259
|
-
if (!provider || !
|
|
260
|
-
if (hasImages && !providerSupportsImages(provider, name)) continue;
|
|
663
|
+
if (!provider || !isAvailable(provider, config)) continue;
|
|
664
|
+
if (hasImages && !providerSupportsImages(provider, name, providers, config)) continue;
|
|
261
665
|
names.push(name);
|
|
262
666
|
}
|
|
263
667
|
return names;
|
|
264
668
|
}
|
|
265
669
|
|
|
266
670
|
/**
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
* own their error wording and structural handling by switching on `status`
|
|
270
|
-
* ('ok' | 'not_found' | 'unavailable').
|
|
271
|
-
* @param {string} spec - Model specification
|
|
671
|
+
* "auto" candidates: each available provider's default model, in priority
|
|
672
|
+
* order.
|
|
272
673
|
* @param {object} providers - Provider instances
|
|
273
674
|
* @param {object} config - Configuration
|
|
274
|
-
* @
|
|
675
|
+
* @param {object} [options] - Same as getAvailableProviders
|
|
676
|
+
* @returns {Array<{ providerName: string, provider: object, resolvedModel: string, options: object }>}
|
|
275
677
|
*/
|
|
276
|
-
export function
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
providers,
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
return { providerName, provider, resolvedModel: canonicalModel, status, options };
|
|
678
|
+
export function getAutoCandidates(providers, config, options = {}) {
|
|
679
|
+
return getAvailableProviders(providers, config, options).map((providerName) => ({
|
|
680
|
+
providerName,
|
|
681
|
+
provider: providers[providerName],
|
|
682
|
+
resolvedModel: getDefaultModelForProvider(providerName, providers, config),
|
|
683
|
+
options: {},
|
|
684
|
+
}));
|
|
284
685
|
}
|
|
285
686
|
|
|
286
687
|
/**
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
* @param {object} providers -
|
|
290
|
-
* @
|
|
688
|
+
* "auto" expanded into explicit `namespace:model` specs, for modes that turn
|
|
689
|
+
* each auto pick into its own labeled participant.
|
|
690
|
+
* @param {object} providers - Provider instances
|
|
691
|
+
* @param {object} config - Configuration
|
|
692
|
+
* @param {object} [options] - Same as getAvailableProviders
|
|
693
|
+
* @returns {string[]}
|
|
291
694
|
*/
|
|
292
|
-
export function
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
if (modelLower === 'auto') {
|
|
297
|
-
if (providers['codex']) {
|
|
298
|
-
return 'codex';
|
|
299
|
-
}
|
|
300
|
-
if (providers['gemini-cli']) {
|
|
301
|
-
return 'gemini-cli';
|
|
302
|
-
}
|
|
303
|
-
if (providers['claude']) {
|
|
304
|
-
return 'claude';
|
|
305
|
-
}
|
|
306
|
-
if (providers['copilot']) {
|
|
307
|
-
return 'copilot';
|
|
308
|
-
}
|
|
309
|
-
return 'openai';
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
// Check Codex: bare `codex` or the `codex:<backend>` namespace (e.g. codex:astra,
|
|
313
|
-
// codex:gpt-5.6-sol). Bare "gpt-*" names never route here — they are OpenAI API models.
|
|
314
|
-
if (modelLower === 'codex' || modelLower.startsWith('codex:')) {
|
|
315
|
-
return 'codex';
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// Check Gemini CLI (exact match only - routes to CLI provider instead of Google API)
|
|
319
|
-
if (modelLower === 'gemini' || modelLower === 'gemini-cli') {
|
|
320
|
-
return 'gemini-cli';
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// Check gemini: prefix (e.g., gemini:flash, gemini:pro) - routes to Antigravity
|
|
324
|
-
// CLI provider. Must be before the google flash/pro keyword rule below so it
|
|
325
|
-
// wins over Google API routing. Bare gemini-pro/gemini-flash still hit google.
|
|
326
|
-
if (modelLower.startsWith('gemini:')) {
|
|
327
|
-
return 'gemini-cli';
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// Check Claude SDK (exact match only - routes to SDK provider instead of Anthropic API)
|
|
331
|
-
if (
|
|
332
|
-
modelLower === 'claude' ||
|
|
333
|
-
modelLower === 'claude-sdk' ||
|
|
334
|
-
modelLower === 'claude-code'
|
|
335
|
-
) {
|
|
336
|
-
return 'claude';
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// Check claude: prefix (e.g., claude:fable, claude:opus) - routes to SDK provider
|
|
340
|
-
// Must be before keyword matching to prevent misrouting to Anthropic API
|
|
341
|
-
if (modelLower.startsWith('claude:')) {
|
|
342
|
-
return 'claude';
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// Check Copilot SDK (exact match only - routes to SDK provider)
|
|
346
|
-
if (
|
|
347
|
-
modelLower === 'copilot' ||
|
|
348
|
-
modelLower === 'copilot-sdk' ||
|
|
349
|
-
modelLower === 'github-copilot'
|
|
350
|
-
) {
|
|
351
|
-
return 'copilot';
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// Check copilot: prefix (e.g., copilot:gpt-5.2, copilot:claude-sonnet-4.6)
|
|
355
|
-
// Must be before slash-format and keyword matching to prevent misrouting
|
|
356
|
-
if (modelLower.startsWith('copilot:')) {
|
|
357
|
-
return 'copilot';
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// Check openrouter: prefix (e.g., openrouter:z-ai/glm-5.2). Routes to
|
|
361
|
-
// OpenRouter without the OPENROUTER_DYNAMIC_MODELS gate. Must be before the
|
|
362
|
-
// slash-format check so the namespaced slug is not probed against native
|
|
363
|
-
// providers.
|
|
364
|
-
if (modelLower.startsWith('openrouter:')) {
|
|
365
|
-
return 'openrouter';
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// Check OpenRouter-specific patterns first
|
|
369
|
-
if (
|
|
370
|
-
modelLower === 'openrouter auto' ||
|
|
371
|
-
modelLower === 'auto router' ||
|
|
372
|
-
modelLower === 'auto-router' ||
|
|
373
|
-
modelLower === 'openrouter-auto'
|
|
374
|
-
) {
|
|
375
|
-
return 'openrouter';
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// If model contains "/", check if native provider supports it
|
|
379
|
-
if (modelLower.includes('/')) {
|
|
380
|
-
// Check each provider to see if they have this exact model
|
|
381
|
-
for (const [providerName, provider] of Object.entries(providers)) {
|
|
382
|
-
if (provider && provider.getModelConfig) {
|
|
383
|
-
const modelConfig = provider.getModelConfig(model);
|
|
384
|
-
if (
|
|
385
|
-
modelConfig &&
|
|
386
|
-
!modelConfig.isDynamic &&
|
|
387
|
-
!modelConfig.needsApiUpdate
|
|
388
|
-
) {
|
|
389
|
-
// Model exists in this provider's static list
|
|
390
|
-
return providerName;
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
// No native provider has this model, route to OpenRouter
|
|
395
|
-
return 'openrouter';
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
// For non-slash models, use keyword matching as before
|
|
399
|
-
|
|
400
|
-
// OpenAI models
|
|
401
|
-
if (
|
|
402
|
-
modelLower.includes('gpt') ||
|
|
403
|
-
modelLower.includes('o1') ||
|
|
404
|
-
modelLower.includes('o3') ||
|
|
405
|
-
modelLower.includes('o4')
|
|
406
|
-
) {
|
|
407
|
-
return 'openai';
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
// XAI models
|
|
411
|
-
if (modelLower.includes('grok')) {
|
|
412
|
-
return 'xai';
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
// Google models
|
|
416
|
-
if (
|
|
417
|
-
modelLower.includes('flash') ||
|
|
418
|
-
modelLower.includes('pro') ||
|
|
419
|
-
modelLower === 'google'
|
|
420
|
-
) {
|
|
421
|
-
return 'google';
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
// Anthropic models
|
|
425
|
-
if (
|
|
426
|
-
modelLower.includes('claude') ||
|
|
427
|
-
modelLower.includes('fable') ||
|
|
428
|
-
modelLower.includes('opus') ||
|
|
429
|
-
modelLower.includes('sonnet') ||
|
|
430
|
-
modelLower.includes('haiku')
|
|
431
|
-
) {
|
|
432
|
-
return 'anthropic';
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
// Mistral models
|
|
436
|
-
if (modelLower.includes('mistral') || modelLower.includes('magistral')) {
|
|
437
|
-
return 'mistral';
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
// DeepSeek models
|
|
441
|
-
if (
|
|
442
|
-
modelLower.includes('deepseek') ||
|
|
443
|
-
modelLower === 'reasoner' ||
|
|
444
|
-
modelLower === 'r1' ||
|
|
445
|
-
modelLower === 'chat'
|
|
446
|
-
) {
|
|
447
|
-
return 'deepseek';
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// OpenRouter models (specific model patterns)
|
|
451
|
-
if (
|
|
452
|
-
modelLower.includes('qwen') ||
|
|
453
|
-
modelLower.includes('kimi') ||
|
|
454
|
-
modelLower.includes('moonshot') ||
|
|
455
|
-
modelLower === 'k2'
|
|
456
|
-
) {
|
|
457
|
-
return 'openrouter';
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// Default fallback
|
|
461
|
-
return 'openai';
|
|
695
|
+
export function getAutoModelSpecs(providers, config, options = {}) {
|
|
696
|
+
return getAutoCandidates(providers, config, options).map(
|
|
697
|
+
(c) => `${PROVIDER_NAMESPACES[c.providerName]?.[0] || c.providerName}:${c.resolvedModel}`,
|
|
698
|
+
);
|
|
462
699
|
}
|