converse-mcp-server 3.7.1 → 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.
@@ -9,20 +9,21 @@
9
9
  import { createLogger } from '../utils/logger.js';
10
10
  import { debugLog, debugError } from '../utils/console.js';
11
11
 
12
- import { mapModelToProvider } from '../utils/modelRouting.js';
12
+ import { resolveModelSpec } from '../utils/modelRouting.js';
13
13
 
14
14
  const logger = createLogger('summarization');
15
15
 
16
- // Default fast models for summarization tasks (prioritize GPT-5-nano for speed)
17
- const FAST_MODELS = {
18
- openai: 'gpt-5-nano', // Fastest GPT-5 model with minimal reasoning
19
- google: 'flash',
20
- xai: 'grok-4.5',
21
- anthropic: 'claude-3-5-haiku-latest',
22
- mistral: 'mistral-small-latest',
23
- deepseek: 'deepseek-v4-flash',
24
- openrouter: 'z-ai/glm-5.2',
25
- };
16
+ // Fast models for summarization tasks, tried in order (GPT-5-nano first for
17
+ // speed). Namespaced so each names exactly one provider.
18
+ export const FAST_MODELS = [
19
+ 'openai:gpt-5-nano',
20
+ 'google:gemini-2.5-flash',
21
+ 'xai:grok-4.5',
22
+ 'anthropic:claude-haiku-4-5-20251001',
23
+ 'mistral:mistral-small-2603',
24
+ 'deepseek:deepseek-v4-flash',
25
+ 'openrouter:z-ai/glm-5.2',
26
+ ];
26
27
 
27
28
  export class SummarizationService {
28
29
  constructor(providers, config) {
@@ -51,16 +52,12 @@ export class SummarizationService {
51
52
 
52
53
  try {
53
54
  // Select fast model if not specified
54
- const selectedModel = model || this._selectFastModel();
55
- const providerName = mapModelToProvider(selectedModel, this.providers);
56
- const provider = this.providers[providerName];
57
-
58
- if (!provider || !provider.isAvailable(this.config)) {
59
- debugLog(
60
- `Summarization: Provider ${providerName} not available for title generation`,
61
- );
55
+ const selected = this._resolve(model || this._selectFastModel());
56
+ if (!selected) {
57
+ debugLog('Summarization: No available model for title generation');
62
58
  return this._fallbackTitle(prompt);
63
59
  }
60
+ const { provider, resolvedModel: selectedModel } = selected;
64
61
 
65
62
  // Create messages for title generation
66
63
  const messages = [
@@ -112,16 +109,12 @@ export class SummarizationService {
112
109
 
113
110
  try {
114
111
  // Select fast model if not specified
115
- const selectedModel = model || this._selectFastModel();
116
- const providerName = mapModelToProvider(selectedModel, this.providers);
117
- const provider = this.providers[providerName];
118
-
119
- if (!provider || !provider.isAvailable(this.config)) {
120
- debugLog(
121
- `Summarization: Provider ${providerName} not available for streaming summary`,
122
- );
112
+ const selected = this._resolve(model || this._selectFastModel());
113
+ if (!selected) {
114
+ debugLog('Summarization: No available model for streaming summary');
123
115
  return this._fallbackStreamingSummary(content, currentFocus);
124
116
  }
117
+ const { provider, resolvedModel: selectedModel } = selected;
125
118
 
126
119
  // Create messages for streaming summary
127
120
  const messages = [
@@ -175,16 +168,12 @@ export class SummarizationService {
175
168
 
176
169
  try {
177
170
  // Select fast model if not specified
178
- const selectedModel = model || this._selectFastModel();
179
- const providerName = mapModelToProvider(selectedModel, this.providers);
180
- const provider = this.providers[providerName];
181
-
182
- if (!provider || !provider.isAvailable(this.config)) {
183
- debugLog(
184
- `Summarization: Provider ${providerName} not available for final summary`,
185
- );
171
+ const selected = this._resolve(model || this._selectFastModel());
172
+ if (!selected) {
173
+ debugLog('Summarization: No available model for final summary');
186
174
  return this._fallbackFinalSummary(content);
187
175
  }
176
+ const { provider, resolvedModel: selectedModel } = selected;
188
177
 
189
178
  // Create messages for final summary
190
179
  const messages = [
@@ -221,6 +210,20 @@ export class SummarizationService {
221
210
  }
222
211
  }
223
212
 
213
+ /**
214
+ * Route a model spec to its first available provider.
215
+ * @private
216
+ * @returns {{ provider: object, providerName: string, resolvedModel: string }|null}
217
+ */
218
+ _resolve(spec) {
219
+ const resolution = resolveModelSpec(spec, this.providers, this.config);
220
+ if (resolution.status !== 'ok') {
221
+ debugLog(`Summarization: ${resolution.error}`);
222
+ return null;
223
+ }
224
+ return resolution;
225
+ }
226
+
224
227
  /**
225
228
  * Select the best available fast model
226
229
  * @private
@@ -228,14 +231,10 @@ export class SummarizationService {
228
231
  _selectFastModel() {
229
232
  // If a model is configured, try to use it first
230
233
  if (this.configuredModel) {
231
- const providerName = mapModelToProvider(
232
- this.configuredModel,
233
- this.providers,
234
- );
235
- const provider = this.providers[providerName];
236
- if (provider && provider.isAvailable(this.config)) {
234
+ const resolution = resolveModelSpec(this.configuredModel, this.providers, this.config);
235
+ if (resolution.status === 'ok') {
237
236
  debugLog(
238
- `Summarization: Using configured model ${this.configuredModel} from ${providerName}`,
237
+ `Summarization: Using configured model ${this.configuredModel} from ${resolution.providerName}`,
239
238
  );
240
239
  return this.configuredModel;
241
240
  }
@@ -244,13 +243,10 @@ export class SummarizationService {
244
243
  );
245
244
  }
246
245
 
247
- // Check which providers are available and return the first fast model
248
- for (const [providerName, fastModel] of Object.entries(FAST_MODELS)) {
249
- const provider = this.providers[providerName];
250
- if (provider && provider.isAvailable(this.config)) {
251
- debugLog(
252
- `Summarization: Selected fast model ${fastModel} from ${providerName}`,
253
- );
246
+ // Return the first fast model whose provider is available
247
+ for (const fastModel of FAST_MODELS) {
248
+ if (resolveModelSpec(fastModel, this.providers, this.config).status === 'ok') {
249
+ debugLog(`Summarization: Selected fast model ${fastModel}`);
254
250
  return fastModel;
255
251
  }
256
252
  }
package/src/tools/chat.js CHANGED
@@ -29,9 +29,8 @@ import { SummarizationService } from '../services/summarizationService.js';
29
29
  import { exportConversation } from '../utils/conversationExporter.js';
30
30
  import { EFFORT_LADDER } from '../utils/reasoningEffort.js';
31
31
  import {
32
- getDefaultModelForProvider,
33
- getProviderUnavailableMessage,
34
- getAvailableProviders,
32
+ getAutoCandidates,
33
+ getAutoModelSpecs,
35
34
  resolveModelSpec,
36
35
  } from '../utils/modelRouting.js';
37
36
  import {
@@ -857,24 +856,52 @@ function buildAsyncResult(pipeline, title, finalSummary) {
857
856
  // --- Model resolution helpers ------------------------------------------------
858
857
 
859
858
  /**
860
- * Build the full provider-priority candidate list for an "auto" spec (used for
861
- * chat-mode failover). Skips text-only providers when the request has images.
859
+ * Convert router candidates into the engine's call-plan candidate shape.
862
860
  */
863
- function buildAutoCandidates(providers, config, hasImages) {
864
- return getAvailableProviders(providers, config, { hasImages }).map((name) => ({
865
- name,
866
- providerInstance: providers[name],
867
- resolvedModel: getDefaultModelForProvider(name),
868
- displayModel: 'auto',
861
+ function toPlanCandidates(candidates, displayModel) {
862
+ return candidates.map((c) => ({
863
+ name: c.providerName,
864
+ providerInstance: c.provider,
865
+ resolvedModel: c.resolvedModel,
866
+ displayModel,
867
+ resolveOptions: c.options,
869
868
  }));
870
869
  }
871
870
 
871
+ /**
872
+ * Resolve one explicit spec into a call plan, or a pre-failed entry carrying
873
+ * the router's error (unknown names include "did you mean" suggestions). A
874
+ * bare model name served by several providers yields a multi-candidate plan
875
+ * that fails over in provider priority order.
876
+ */
877
+ function resolveExplicitPlan(spec, providers, config) {
878
+ const resolution = resolveModelSpec(spec, providers, config);
879
+ if (resolution.status !== 'ok') {
880
+ return {
881
+ preFailed: {
882
+ model: spec,
883
+ ...(resolution.providerName && { provider: resolution.providerName }),
884
+ error: resolution.error,
885
+ },
886
+ };
887
+ }
888
+ return {
889
+ plan: {
890
+ modelSpec: spec,
891
+ displayModel: spec,
892
+ threadKey: spec,
893
+ candidates: toPlanCandidates(resolution.candidates, spec),
894
+ },
895
+ };
896
+ }
897
+
872
898
  /**
873
899
  * Resolve chat-mode call plans. Each "auto" spec (whether the list is exactly
874
900
  * ["auto"] or "auto" appears alongside explicit models) yields a plan with the
875
901
  * full provider-priority candidate list (failover); explicit models yield one
876
- * single-candidate plan each. Unavailable/unknown explicit models are returned
877
- * as pre-failed entries (surfaced as per-model failures).
902
+ * plan each, with failover candidates when a bare name is served by several
903
+ * providers. Unavailable/unknown explicit models are returned as pre-failed
904
+ * entries (surfaced as per-model failures).
878
905
  */
879
906
  function resolveChatCallPlans(models, providers, config, hasImages) {
880
907
  const callPlans = [];
@@ -882,7 +909,10 @@ function resolveChatCallPlans(models, providers, config, hasImages) {
882
909
 
883
910
  for (const spec of models) {
884
911
  if (String(spec).toLowerCase() === 'auto') {
885
- const candidates = buildAutoCandidates(providers, config, hasImages);
912
+ const candidates = toPlanCandidates(
913
+ getAutoCandidates(providers, config, { hasImages }),
914
+ 'auto',
915
+ );
886
916
  if (candidates.length === 0) {
887
917
  // A single ["auto"] with no providers is a hard error; an "auto" entry
888
918
  // in a multi-model list becomes a per-model failure instead.
@@ -909,28 +939,11 @@ function resolveChatCallPlans(models, providers, config, hasImages) {
909
939
  continue;
910
940
  }
911
941
 
912
- const { providerName, provider, resolvedModel, status, options } = resolveModelSpec(spec, providers, config);
913
- if (status === 'not_found') {
914
- preFailed.push({
915
- model: spec,
916
- provider: providerName,
917
- error: `Provider not found for model: ${spec}`,
918
- });
919
- } else if (status === 'unavailable') {
920
- preFailed.push({
921
- model: spec,
922
- provider: providerName,
923
- error: getProviderUnavailableMessage(providerName),
924
- });
942
+ const { plan, preFailed: failed } = resolveExplicitPlan(spec, providers, config);
943
+ if (failed) {
944
+ preFailed.push(failed);
925
945
  } else {
926
- callPlans.push({
927
- modelSpec: spec,
928
- displayModel: spec,
929
- threadKey: spec,
930
- candidates: [
931
- { name: providerName, providerInstance: provider, resolvedModel, displayModel: spec, resolveOptions: options },
932
- ],
933
- });
946
+ callPlans.push(plan);
934
947
  }
935
948
  }
936
949
  return { callPlans, preFailed, error: null };
@@ -938,15 +951,15 @@ function resolveChatCallPlans(models, providers, config, hasImages) {
938
951
 
939
952
  /**
940
953
  * Resolve consensus-mode call plans. Single "auto" expands to the first 3
941
- * available providers' default models; each spec becomes a single-candidate plan.
954
+ * available providers' default models (as `namespace:model` specs); each spec
955
+ * becomes one plan.
942
956
  */
943
957
  function resolveConsensusCallPlans(models, providers, config, images) {
944
958
  const hasImages = Array.isArray(images) && images.length > 0;
945
959
 
946
960
  let modelsToProcess = models;
947
961
  if (models.length === 1 && String(models[0]).toLowerCase() === 'auto') {
948
- const available = getAvailableProviders(providers, config, { hasImages, limit: 3 });
949
- modelsToProcess = available.map((name) => getDefaultModelForProvider(name));
962
+ modelsToProcess = getAutoModelSpecs(providers, config, { hasImages, limit: 3 });
950
963
  }
951
964
 
952
965
  const resolved = [];
@@ -956,20 +969,11 @@ function resolveConsensusCallPlans(models, providers, config, images) {
956
969
  preFailed.push({ model: spec || 'unknown', error: 'Invalid model specification' });
957
970
  continue;
958
971
  }
959
- const { providerName, provider, resolvedModel, status, options } = resolveModelSpec(spec, providers, config);
960
- if (status === 'not_found') {
961
- preFailed.push({ model: spec, provider: providerName, error: `Provider not found: ${providerName}` });
962
- } else if (status === 'unavailable') {
963
- preFailed.push({ model: spec, provider: providerName, error: getProviderUnavailableMessage(providerName) });
972
+ const { plan, preFailed: failed } = resolveExplicitPlan(spec, providers, config);
973
+ if (failed) {
974
+ preFailed.push(failed);
964
975
  } else {
965
- resolved.push({
966
- modelSpec: spec,
967
- displayModel: spec,
968
- threadKey: spec,
969
- candidates: [
970
- { name: providerName, providerInstance: provider, resolvedModel, displayModel: spec, resolveOptions: options },
971
- ],
972
- });
976
+ resolved.push(plan);
973
977
  }
974
978
  }
975
979
  return { resolved, preFailed };
@@ -1161,7 +1165,7 @@ chatTool.inputSchema = {
1161
1165
  items: { type: 'string' },
1162
1166
  minItems: 1,
1163
1167
  description:
1164
- 'Models to use. Examples: ["auto"] (recommended), ["codex"], ["codex", "gemini", "claude"]. In mode "chat" each model answers independently; in "consensus" they refine after seeing each other; in "roundtable" they speak in the given ORDER, each seeing the transcript. Default: ["auto"].',
1168
+ 'Models to use. Examples: ["auto"] (recommended), ["codex"], ["codex", "gemini", "claude"], ["codex:astra"], ["gpt-6-astra"]. Forms: "provider" (its default model), "provider:model" (that provider only), or a bare "model" (served by the first configured provider that offers it, local CLI providers first, failing over to the next). Providers: codex, gemini (agy), claude, copilot, openai, google, xai, anthropic, mistral, deepseek, openrouter. Unknown names are rejected with suggestions. In mode "chat" each model answers independently; in "consensus" they refine after seeing each other; in "roundtable" they speak in the given ORDER, each seeing the transcript. Default: ["auto"].',
1165
1169
  },
1166
1170
  mode: {
1167
1171
  type: 'string',
@@ -20,12 +20,8 @@
20
20
 
21
21
  import { debugLog } from '../../utils/console.js';
22
22
  import { acquireProviderStream } from './streamShared.js';
23
- import {
24
- getDefaultModelForProvider,
25
- getProviderUnavailableMessage,
26
- getAvailableProviders,
27
- resolveModelSpec,
28
- } from '../../utils/modelRouting.js';
23
+ import { getAutoModelSpecs, resolveModelSpec } from '../../utils/modelRouting.js';
24
+ import { shouldFailoverToNextProvider } from './parallel.js';
29
25
 
30
26
  /**
31
27
  * Render a stored transcript (from prior laps or a prior chat/consensus thread)
@@ -169,7 +165,9 @@ export function formatLapTranscript(lapTurns) {
169
165
  * Resolve the ordered model list into a turn plan. Unlike the parallel engine,
170
166
  * unknown or unavailable models are NOT dropped — they are recorded with a
171
167
  * preFailReason so they keep their position in the order (and produce a failed
172
- * turn).
168
+ * turn). A bare model name served by several providers carries every serving
169
+ * provider in `candidates`, tried in order when a turn fails with a
170
+ * failover-worthy error.
173
171
  * @param {Array<string>} models - Ordered model list
174
172
  * @param {object} providers - Provider instances
175
173
  * @param {object} config - Configuration
@@ -181,16 +179,14 @@ export function resolveTurnPlan(models, providers, config, hasImages = false) {
181
179
  // (a single-model round-table is valid). Multiple explicit models resolve per-entry.
182
180
  let modelsToProcess = models;
183
181
  if (models.length === 1 && String(models[0]).toLowerCase() === 'auto') {
184
- const [firstAvailable] = getAvailableProviders(providers, config, {
182
+ const [firstAvailable] = getAutoModelSpecs(providers, config, {
185
183
  hasImages,
186
184
  limit: 1,
187
185
  });
188
186
 
189
187
  // If a provider is available, use its default model. Otherwise keep "auto"
190
188
  // so it resolves to a turn that fails cleanly (all-fail laps must complete).
191
- modelsToProcess = firstAvailable
192
- ? [getDefaultModelForProvider(firstAvailable)]
193
- : ['auto'];
189
+ modelsToProcess = firstAvailable ? [firstAvailable] : ['auto'];
194
190
  }
195
191
 
196
192
  return modelsToProcess.map((modelName) => {
@@ -200,39 +196,31 @@ export function resolveTurnPlan(models, providers, config, hasImages = false) {
200
196
  provider: null,
201
197
  providerInstance: null,
202
198
  resolvedModel: null,
199
+ candidates: [],
203
200
  preFailReason: 'Invalid model specification',
204
201
  };
205
202
  }
206
203
 
207
- const { providerName, provider, resolvedModel, status, options } =
208
- resolveModelSpec(modelName, providers, config);
204
+ const resolution = resolveModelSpec(modelName, providers, config);
209
205
 
210
- if (status === 'not_found') {
206
+ if (resolution.status !== 'ok') {
211
207
  return {
212
208
  model: modelName,
213
- provider: providerName,
209
+ provider: resolution.providerName,
214
210
  providerInstance: null,
215
- resolvedModel,
216
- preFailReason: `Provider not found: ${providerName}`,
217
- };
218
- }
219
-
220
- if (status === 'unavailable') {
221
- return {
222
- model: modelName,
223
- provider: providerName,
224
- providerInstance: null,
225
- resolvedModel,
226
- preFailReason: getProviderUnavailableMessage(providerName),
211
+ resolvedModel: null,
212
+ candidates: [],
213
+ preFailReason: resolution.error,
227
214
  };
228
215
  }
229
216
 
230
217
  return {
231
218
  model: modelName,
232
- provider: providerName,
233
- providerInstance: provider,
234
- resolvedModel,
235
- resolveOptions: options,
219
+ provider: resolution.providerName,
220
+ providerInstance: resolution.provider,
221
+ resolvedModel: resolution.resolvedModel,
222
+ resolveOptions: resolution.options,
223
+ candidates: resolution.candidates,
236
224
  preFailReason: null,
237
225
  };
238
226
  });
@@ -254,7 +242,9 @@ function buildTurnUserContent(packetText, contextMessage) {
254
242
  * Execute a single turn. Streams (updating job progress) when a job context is
255
243
  * present; otherwise performs a plain invoke. Cancellation propagates by throwing
256
244
  * so the lap aborts rather than demoting to a failed turn.
257
- * @returns {Promise<object>} Turn result { model, provider, status, response|error }
245
+ * @returns {Promise<object>} Turn result { model, provider, status, response|error };
246
+ * failed turns also carry the thrown `cause` for the failover decision
247
+ * (stripped before the turn is recorded)
258
248
  */
259
249
  async function executeTurn(
260
250
  plan,
@@ -353,6 +343,7 @@ async function executeTurn(
353
343
  provider: plan.provider,
354
344
  status: 'failed',
355
345
  error: error.message,
346
+ cause: error,
356
347
  };
357
348
  }
358
349
  }
@@ -436,24 +427,44 @@ export async function runRoundtableLap({
436
427
  { role: 'user', content: finalUserContent },
437
428
  ];
438
429
 
439
- const turnResult = await executeTurn(
440
- plan,
441
- messages,
442
- {
443
- reasoning_effort,
444
- signal: activeSignal,
445
- config,
446
- model: plan.resolvedModel,
447
- // Web search opt-in from an OpenRouter `:online` decoration; only ever
448
- // set for OpenRouter turns.
449
- ...(plan.resolveOptions?.web_search && { web_search: true }),
450
- },
451
- context,
452
- providerStreamNormalizer,
453
- i,
454
- );
430
+ let turnResult;
431
+ for (let ci = 0; ci < plan.candidates.length; ci++) {
432
+ const candidate = plan.candidates[ci];
433
+ turnResult = await executeTurn(
434
+ {
435
+ model: plan.model,
436
+ provider: candidate.providerName,
437
+ providerInstance: candidate.provider,
438
+ },
439
+ messages,
440
+ {
441
+ reasoning_effort,
442
+ signal: activeSignal,
443
+ config,
444
+ model: candidate.resolvedModel,
445
+ // Web search opt-in from an OpenRouter `:online` decoration; only ever
446
+ // set for OpenRouter turns.
447
+ ...(candidate.options?.web_search && { web_search: true }),
448
+ },
449
+ context,
450
+ providerStreamNormalizer,
451
+ i,
452
+ );
453
+ const isLastCandidate = ci === plan.candidates.length - 1;
454
+ if (
455
+ turnResult.status === 'success' ||
456
+ isLastCandidate ||
457
+ !shouldFailoverToNextProvider(turnResult.cause)
458
+ ) {
459
+ break;
460
+ }
461
+ debugLog(
462
+ `[Roundtable] Turn ${i + 1} (${plan.model}) failed on ${candidate.providerName}; failing over`,
463
+ );
464
+ }
455
465
 
456
- lapTurns.push({ ...turnResult, position: i });
466
+ const { cause: _cause, ...turn } = turnResult;
467
+ lapTurns.push({ ...turn, position: i });
457
468
  }
458
469
 
459
470
  if (context) {
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Local Provider Availability Probes
3
+ *
4
+ * Cheap, synchronous checks for the CLI/SDK providers: the SDK package
5
+ * resolves and a credential is present. They never spawn a process or call a
6
+ * network endpoint, so they cannot prove a login is still valid — a stale
7
+ * credential surfaces as an auth error at invoke time, where bare-name and
8
+ * "auto" routing fail over to the next provider.
9
+ */
10
+
11
+ import { existsSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { join } from 'node:path';
14
+
15
+ const resolvedPackages = new Map();
16
+
17
+ /**
18
+ * Whether an npm package resolves from this module. Cached per process:
19
+ * installing a package requires a restart to load it anyway.
20
+ * @param {string} packageName
21
+ * @returns {boolean}
22
+ */
23
+ export function isPackageResolvable(packageName) {
24
+ if (!resolvedPackages.has(packageName)) {
25
+ let resolvable;
26
+ try {
27
+ import.meta.resolve(packageName);
28
+ resolvable = true;
29
+ } catch {
30
+ resolvable = false;
31
+ }
32
+ resolvedPackages.set(packageName, resolvable);
33
+ }
34
+ return resolvedPackages.get(packageName);
35
+ }
36
+
37
+ /**
38
+ * Codex credentials: CODEX_API_KEY, or the ChatGPT login file the Codex CLI
39
+ * writes to $CODEX_HOME/auth.json (default ~/.codex).
40
+ * @param {object} config
41
+ * @returns {boolean}
42
+ */
43
+ export function hasCodexCredentials(config) {
44
+ if (config?.providers?.codexapikey) return true;
45
+ const codexHome = process.env.CODEX_HOME || join(homedir(), '.codex');
46
+ return existsSync(join(codexHome, 'auth.json'));
47
+ }
48
+
49
+ /**
50
+ * Claude Code credentials: an OAuth token or API key in the environment the
51
+ * spawned CLI inherits, or the login file in $CLAUDE_CONFIG_DIR (default
52
+ * ~/.claude). macOS keeps the login in the Keychain, which cannot be read
53
+ * without spawning a process, so a login there is assumed.
54
+ * @returns {boolean}
55
+ */
56
+ export function hasClaudeCredentials() {
57
+ if (process.env.CLAUDE_CODE_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY) {
58
+ return true;
59
+ }
60
+ if (process.platform === 'darwin') return true;
61
+ const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
62
+ return existsSync(join(configDir, '.credentials.json'));
63
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Model Catalog Helpers
3
+ *
4
+ * Every provider keeps a catalog keyed by canonical model ID, each entry
5
+ * carrying an `aliases` array. These helpers are the single lookup rule for
6
+ * those catalogs: case-insensitive, canonical ID first, then aliases.
7
+ */
8
+
9
+ /**
10
+ * Canonical catalog ID for a name, or null.
11
+ * @param {Object<string, {aliases?: string[]}>} catalog
12
+ * @param {string} name - Canonical ID or alias
13
+ * @returns {string|null}
14
+ */
15
+ export function findCatalogId(catalog, name) {
16
+ const lower = String(name ?? '').trim().toLowerCase();
17
+ if (!lower || !catalog) return null;
18
+ for (const id of Object.keys(catalog)) {
19
+ if (id.toLowerCase() === lower) return id;
20
+ }
21
+ for (const [id, entry] of Object.entries(catalog)) {
22
+ if (entry?.aliases?.some((alias) => String(alias).toLowerCase() === lower)) {
23
+ return id;
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+
29
+ /**
30
+ * Catalog entry for a name, or null.
31
+ * @param {Object<string, object>} catalog
32
+ * @param {string} name - Canonical ID or alias
33
+ * @returns {object|null}
34
+ */
35
+ export function findCatalogEntry(catalog, name) {
36
+ const id = findCatalogId(catalog, name);
37
+ return id ? catalog[id] : null;
38
+ }