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.
@@ -1,16 +1,31 @@
1
1
  /**
2
- * Model Routing Helpers
2
+ * Model Routing
3
3
  *
4
- * Pure helpers for mapping model specifications to providers and resolving
5
- * "auto" model names to a provider's default model. Extracted so multiple
6
- * tools (consensus, conversation) can share the same routing logic without
7
- * importing from another tool module (which would risk circular dependencies).
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-selection priority order. Subscription-based CLI/SDK providers
12
- * (codex, gemini-cli, claude, copilot) come before API-key providers so "auto"
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
- * Get default model for a provider
32
- * @param {string} providerName - Provider name
33
- * @returns {string} Default model name for the provider
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 function getDefaultModelForProvider(providerName) {
36
- const defaults = {
37
- codex: 'codex',
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
- return defaults[providerName] || 'gpt-6';
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
- * Curated friendly-alias → { provider, canonicalModel } table. Single source of
55
- * truth for routing the bare friendly aliases of the API-key providers (xAI,
56
- * Mistral, DeepSeek) to their canonical current-generation model IDs, so the
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 CURATED_MODEL_ALIASES = {
66
- // xAI
67
- grok: { provider: 'xai', canonicalModel: 'grok-4.5' },
68
- 'grok-4.5': { provider: 'xai', canonicalModel: 'grok-4.5' },
69
- 'grok-4.5-latest': { provider: 'xai', canonicalModel: 'grok-4.5' },
70
- 'grok-build-latest': { provider: 'xai', canonicalModel: 'grok-4.5' },
71
- // Mistral
72
- mistral: { provider: 'mistral', canonicalModel: 'mistral-medium-3-5' },
73
- 'mistral-medium': { provider: 'mistral', canonicalModel: 'mistral-medium-3-5' },
74
- 'mistral-medium-3-5': { provider: 'mistral', canonicalModel: 'mistral-medium-3-5' },
75
- 'mistral-small': { provider: 'mistral', canonicalModel: 'mistral-small-2603' },
76
- 'mistral-small-2603': { provider: 'mistral', canonicalModel: 'mistral-small-2603' },
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 the `openrouter:` namespace already removed
93
- * @returns {{ base: string, modelForRequest: string, webSearch: boolean }}
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
- const webSearch = decorations.includes('online');
100
- const kept = decorations.filter((d) => d !== 'online');
101
- const modelForRequest = kept.length ? `${base}:${kept.join(':')}` : base;
102
- return { base, modelForRequest, webSearch };
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
- * Classify a model spec into { providerName, canonicalModel, options } by the
107
- * design resolution order: explicit namespace prefix, curated friendly alias,
108
- * full OpenRouter slug / `openrouter:` prefix (no env gate), then keyword/
109
- * passthrough. `options` carries flags derived from decorations (e.g.
110
- * `web_search` from an OpenRouter `:online`). Unknown explicit IDs pass through
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 classifyModelSpec(spec, providers) {
117
- const raw = String(spec);
118
- const lower = raw.toLowerCase();
119
- const options = {};
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
- providerName,
125
- canonicalModel: getDefaultModelForProvider(providerName),
126
- options,
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
- // Explicit OpenRouter namespace: route without OPENROUTER_DYNAMIC_MODELS and
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
- // Other explicit namespaces pass the spec through unchanged; the target
141
- // provider strips its own prefix (preserves current codex/copilot/claude/gemini-cli
142
- // behavior).
143
- if (
144
- lower.startsWith('codex:') ||
145
- lower.startsWith('copilot:') ||
146
- lower.startsWith('claude:') ||
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
- // Curated friendly alias → provider + canonical ID.
157
- const curated = CURATED_MODEL_ALIASES[lower];
158
- if (curated) {
159
- return {
160
- providerName: curated.provider,
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
- // Full provider/model slug: a native provider that statically owns the bare
167
- // model wins; otherwise it is an OpenRouter slug (decorations parsed).
168
- if (raw.includes('/')) {
169
- const { base, modelForRequest, webSearch } = parseOpenRouterDecorations(raw);
170
- const providerName = mapModelToProvider(base, providers);
171
- if (providerName === 'openrouter' && webSearch) {
172
- options.web_search = true;
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
- // Keyword routing / unknown-ID passthrough (unchanged model string).
178
- const providerName = mapModelToProvider(raw, providers);
373
+ function candidate(providerName, provider, resolved) {
179
374
  return {
180
375
  providerName,
181
- canonicalModel: resolveAutoModel(raw, providerName),
182
- options,
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
- * Resolve "auto" model to default model for the provider
188
- * @param {string} model - Model name (may be "auto")
189
- * @param {string} providerName - Resolved provider name
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
- export function resolveAutoModel(model, providerName) {
193
- if (model.toLowerCase() !== 'auto') {
194
- return model;
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
- return getDefaultModelForProvider(providerName);
455
+ function unavailableMessageFor(providerNames) {
456
+ return providerNames
457
+ .map((name) => `${name} (${getProviderSetupHint(name)})`)
458
+ .join('; ');
198
459
  }
199
460
 
200
461
  /**
201
- * Provider-specific setup hints appended to "Provider X is not available."
202
- * errors so users know how to enable a provider. Keyed by registry name.
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
- const PROVIDER_SETUP_HINTS = {
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
- 'Install the Antigravity CLI and run `agy` once to log in (https://antigravity.google)',
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
- * Build the "provider not available" error message with an optional setup hint.
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 base = `Provider ${providerName} is not available. Check API key configuration.`;
216
- const hint = PROVIDER_SETUP_HINTS[providerName];
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 the "auto"
222
- * selection paths to skip text-only providers (gemini-cli, copilot) when the
223
- * request includes images. Providers without a resolvable config are treated as
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(providerName);
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
- * Return the available provider names in PROVIDER_PRIORITY order, optionally
245
- * skipping text-only providers when the request has images and capping the
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 || !provider.isAvailable(config)) continue;
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
- * Resolve a single model spec into routing facts: its provider name, the
268
- * provider instance, the concrete model, and an availability status. Callers
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
- * @returns {{ providerName: string, provider: object, resolvedModel: string, status: string, options: object }}
675
+ * @param {object} [options] - Same as getAvailableProviders
676
+ * @returns {Array<{ providerName: string, provider: object, resolvedModel: string, options: object }>}
275
677
  */
276
- export function resolveModelSpec(spec, providers, config) {
277
- const { providerName, canonicalModel, options } = classifyModelSpec(
278
- spec,
279
- providers,
280
- );
281
- const provider = providers[providerName];
282
- const status = !provider ? 'not_found' : !provider.isAvailable(config) ? 'unavailable' : 'ok';
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
- * Map model name to provider name
288
- * @param {string} model - Model name
289
- * @param {object} providers - Map of available provider instances keyed by name
290
- * @returns {string} Provider name
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 mapModelToProvider(model, providers) {
293
- const modelLower = model.toLowerCase();
294
-
295
- // Handle "auto" - prioritize: codex > gemini-cli > claude > copilot > openai
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
  }