converse-mcp-server 2.29.2 → 3.0.1

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.
Files changed (42) hide show
  1. package/.env.example +6 -3
  2. package/README.md +96 -94
  3. package/docs/API.md +703 -1562
  4. package/docs/ARCHITECTURE.md +13 -11
  5. package/docs/EXAMPLES.md +241 -667
  6. package/docs/PROVIDERS.md +104 -79
  7. package/package.json +1 -1
  8. package/src/async/asyncJobStore.js +2 -2
  9. package/src/async/jobRunner.js +6 -1
  10. package/src/async/providerStreamNormalizer.js +59 -1
  11. package/src/config.js +4 -3
  12. package/src/prompts/helpPrompt.js +43 -61
  13. package/src/providers/anthropic.js +0 -31
  14. package/src/providers/claude.js +0 -14
  15. package/src/providers/codex.js +15 -21
  16. package/src/providers/copilot.js +36 -202
  17. package/src/providers/deepseek.js +73 -53
  18. package/src/providers/gemini-cli.js +0 -3
  19. package/src/providers/google.js +10 -27
  20. package/src/providers/interface.js +0 -3
  21. package/src/providers/mistral.js +169 -63
  22. package/src/providers/openai-compatible.js +113 -28
  23. package/src/providers/openai.js +14 -66
  24. package/src/providers/openrouter-discovery.js +308 -0
  25. package/src/providers/openrouter.js +452 -282
  26. package/src/providers/xai.js +298 -280
  27. package/src/services/summarizationService.js +4 -14
  28. package/src/systemPrompts.js +19 -2
  29. package/src/tools/cancelJob.js +7 -1
  30. package/src/tools/chat.js +957 -995
  31. package/src/tools/checkStatus.js +1 -0
  32. package/src/tools/index.js +2 -6
  33. package/src/tools/modes/parallel.js +488 -0
  34. package/src/tools/modes/roundtable.js +495 -0
  35. package/src/tools/modes/streamShared.js +31 -0
  36. package/src/utils/contextProcessor.js +1 -38
  37. package/src/utils/conversationExporter.js +6 -26
  38. package/src/utils/formatStatus.js +46 -19
  39. package/src/utils/modelRouting.js +207 -4
  40. package/src/providers/openrouter-endpoints-client.js +0 -220
  41. package/src/tools/consensus.js +0 -1813
  42. package/src/tools/conversation.js +0 -1233
@@ -10,6 +10,16 @@ import { SummarizationService } from '../services/summarizationService.js';
10
10
  import { debugLog, debugError } from './console.js';
11
11
  import { JOB_STATUS } from '../async/asyncJobStore.js';
12
12
 
13
+ /**
14
+ * Normalize a job's mode, mapping the pre-v3 `conversation` tool name to the
15
+ * `roundtable` mode so durable pre-v3 snapshots render with the right branch.
16
+ * @param {string} mode
17
+ * @returns {string}
18
+ */
19
+ function normalizeMode(mode) {
20
+ return mode === 'conversation' ? 'roundtable' : mode;
21
+ }
22
+
13
23
  /**
14
24
  * Format job list as human-readable text with titles and summaries
15
25
  * @param {object} jobsList - Jobs list object with summary
@@ -57,23 +67,26 @@ export async function formatJobListHumanReadable(jobsList, dependencies = {}) {
57
67
  completed_with_errors: '⚠️',
58
68
  }[job.status] || '❓';
59
69
 
70
+ const mode = normalizeMode(job.mode || job.tool);
60
71
  const provider =
61
- job.provider || (job.tool === 'consensus' ? 'multiple' : 'unknown');
72
+ job.provider || (mode === 'chat' ? 'unknown' : 'multiple');
62
73
 
63
74
  // Format start time as readable date/time
64
75
  const startTime = job.created_at
65
76
  ? new Date(job.created_at).toLocaleString()
66
77
  : 'unknown';
67
78
 
68
- // Format: emoji STATUS | TOOL | id | sequence | started | time | [progress for consensus only] | provider
79
+ // Format: emoji STATUS | MODE | id | sequence | started | time | [progress] | provider
69
80
  const sequenceStr = '1/1';
70
81
 
71
82
  // Build base status line
72
- let statusLine = `${statusEmoji} ${job.status.toUpperCase()} | ${job.tool.toUpperCase()} | ${job.continuation_id} | ${sequenceStr} | ${startTime} | ${timeStr}`;
83
+ let statusLine = `${statusEmoji} ${job.status.toUpperCase()} | ${mode.toUpperCase()} | ${job.continuation_id} | ${sequenceStr} | ${startTime} | ${timeStr}`;
73
84
 
74
- // Add consensus progress if applicable
75
- if (job.tool === 'consensus' && job.consensus_progress) {
76
- statusLine += ` | ${job.consensus_progress}`;
85
+ // Add per-mode progress if applicable
86
+ const progressText =
87
+ job.consensus_progress || job.roundtable_progress || job.chat_progress;
88
+ if (progressText) {
89
+ statusLine += ` | ${progressText}`;
77
90
  }
78
91
 
79
92
  statusLine += ` | ${provider}`;
@@ -144,16 +157,18 @@ export async function formatHumanReadableStatus(
144
157
  // Add sequence info if provided
145
158
  const sequenceStr = options.sequence ? ` | ${options.sequence}` : '';
146
159
 
160
+ const mode = normalizeMode(jobStatus.mode || jobStatus.tool);
161
+
147
162
  // Build complete status line with all info
148
- let statusLine = `${statusEmoji} ${jobStatus.status.toUpperCase()} | ${jobStatus.tool.toUpperCase()} | ${jobStatus.continuation_id}${sequenceStr} | Started: ${startTime} | ${timeStr} elapsed`;
163
+ let statusLine = `${statusEmoji} ${jobStatus.status.toUpperCase()} | ${mode.toUpperCase()} | ${jobStatus.continuation_id}${sequenceStr} | Started: ${startTime} | ${timeStr} elapsed`;
149
164
 
150
165
  // Add title if available
151
166
  if (jobStatus.title) {
152
167
  statusLine += ` | "${jobStatus.title}"`;
153
168
  }
154
169
 
155
- // Add progress for consensus tool only (show x/y format)
156
- if (jobStatus.tool === 'consensus') {
170
+ // Add per-mode progress + models list
171
+ if (mode === 'consensus') {
157
172
  if (jobStatus.consensus_progress) {
158
173
  statusLine += ` | ${jobStatus.consensus_progress}`;
159
174
  } else if (jobStatus.providers) {
@@ -166,24 +181,28 @@ export async function formatHumanReadableStatus(
166
181
  const total = providerEntries.length;
167
182
  statusLine += ` | ${completed}/${total} responded`;
168
183
  }
169
- // Add models list for consensus
170
184
  if (jobStatus.models_list) {
171
185
  statusLine += ` | ${jobStatus.models_list}`;
172
186
  }
173
- } else if (jobStatus.tool === 'conversation') {
174
- // Add turn progress and models list for conversation (x/y turns)
175
- if (jobStatus.conversation_progress) {
176
- statusLine += ` | ${jobStatus.conversation_progress} turns`;
187
+ } else if (mode === 'roundtable') {
188
+ // Add turn progress and models list (x/y turns)
189
+ if (jobStatus.roundtable_progress) {
190
+ statusLine += ` | ${jobStatus.roundtable_progress} turns`;
177
191
  }
178
192
  if (jobStatus.models_list) {
179
193
  statusLine += ` | ${jobStatus.models_list}`;
180
194
  }
181
- } else if (jobStatus.tool === 'chat') {
182
- // Add provider/model for chat
195
+ } else {
196
+ // chat mode
197
+ if (jobStatus.chat_progress) {
198
+ statusLine += ` | ${jobStatus.chat_progress}`;
199
+ }
183
200
  if (jobStatus.provider && jobStatus.model) {
184
201
  statusLine += ` | ${jobStatus.provider}/${jobStatus.model}`;
185
202
  } else if (jobStatus.provider) {
186
203
  statusLine += ` | ${jobStatus.provider}`;
204
+ } else if (jobStatus.models_list) {
205
+ statusLine += ` | ${jobStatus.models_list}`;
187
206
  }
188
207
  }
189
208
 
@@ -332,10 +351,16 @@ export function formatJobStatus(job, options = {}) {
332
351
  const elapsedMs = now - startTime;
333
352
  const elapsedSeconds = elapsedMs / 1000;
334
353
 
354
+ // `mode` is the unified chat tool's execution mode; `tool` is kept as a
355
+ // fallback so any durable pre-v3 job snapshot on disk still renders. The old
356
+ // `conversation` tool maps to `roundtable`.
357
+ const mode = normalizeMode(job.mode || job.tool);
358
+
335
359
  const formatted = {
336
360
  continuation_id: job.jobId,
337
361
  status: job.status,
338
362
  tool: job.tool,
363
+ mode,
339
364
  created_at: job.createdAt,
340
365
  updated_at: job.updatedAt,
341
366
  progress: job.overall?.progress || 0,
@@ -346,7 +371,9 @@ export function formatJobStatus(job, options = {}) {
346
371
  model: job.model || null,
347
372
  models_list: job.models_list || null,
348
373
  consensus_progress: job.consensus_progress || null,
349
- conversation_progress: job.conversation_progress || null,
374
+ // `conversation_progress` is the pre-v3 key for what is now roundtable.
375
+ roundtable_progress: job.roundtable_progress || job.conversation_progress || null,
376
+ chat_progress: job.chat_progress || null,
350
377
  // Include new metadata fields if present
351
378
  accumulated_content: job.accumulated_content || null,
352
379
  title: job.title || null,
@@ -354,8 +381,8 @@ export function formatJobStatus(job, options = {}) {
354
381
  reasoning_summary: job.reasoning_summary || null,
355
382
  };
356
383
 
357
- // For consensus, gather provider previews
358
- if (job.tool === 'consensus') {
384
+ // For the parallel modes (chat, consensus), gather per-provider previews.
385
+ if (mode === 'consensus' || mode === 'chat') {
359
386
  const providerPreviews = {};
360
387
  for (let i = 0; i < 10; i++) {
361
388
  // Check up to 10 providers
@@ -7,6 +7,26 @@
7
7
  * importing from another tool module (which would risk circular dependencies).
8
8
  */
9
9
 
10
+ /**
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.
14
+ * @type {string[]}
15
+ */
16
+ export const PROVIDER_PRIORITY = [
17
+ 'codex',
18
+ 'gemini-cli',
19
+ 'claude',
20
+ 'copilot',
21
+ 'openai',
22
+ 'google',
23
+ 'xai',
24
+ 'anthropic',
25
+ 'mistral',
26
+ 'deepseek',
27
+ 'openrouter',
28
+ ];
29
+
10
30
  /**
11
31
  * Get default model for a provider
12
32
  * @param {string} providerName - Provider name
@@ -19,17 +39,149 @@ export function getDefaultModelForProvider(providerName) {
19
39
  claude: 'claude',
20
40
  copilot: 'copilot',
21
41
  openai: 'gpt-5.6',
22
- xai: 'grok-4-0709',
42
+ xai: 'grok-4.5',
23
43
  google: 'gemini-pro',
24
44
  anthropic: 'claude-sonnet-4-20250514',
25
- mistral: 'magistral-medium-2506',
26
- deepseek: 'deepseek-reasoner',
27
- openrouter: 'qwen/qwen3-coder',
45
+ mistral: 'mistral-medium-3-5',
46
+ deepseek: 'deepseek-v4-pro',
47
+ openrouter: 'z-ai/glm-5.2',
28
48
  };
29
49
 
30
50
  return defaults[providerName] || 'gpt-5.6';
31
51
  }
32
52
 
53
+ /**
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}>}
64
+ */
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' },
85
+ };
86
+
87
+ /**
88
+ * Parse OpenRouter model decorations off a slug. `:online` is consumed into a
89
+ * `webSearch` flag (the provider attaches the web plugin from the flag) and is
90
+ * never carried on the request/lookup ID; other suffixes such as `:free` are
91
+ * 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 }}
94
+ */
95
+ function parseOpenRouterDecorations(slug) {
96
+ const segments = String(slug).split(':');
97
+ const base = segments[0];
98
+ 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 };
103
+ }
104
+
105
+ /**
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 }}
115
+ */
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);
123
+ return {
124
+ providerName,
125
+ canonicalModel: getDefaultModelForProvider(providerName),
126
+ options,
127
+ };
128
+ }
129
+
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
+ }
139
+
140
+ // Other explicit namespaces pass the spec through unchanged; the target
141
+ // provider strips its own prefix (preserves current copilot/claude/gemini-cli
142
+ // behavior).
143
+ if (
144
+ lower.startsWith('copilot:') ||
145
+ lower.startsWith('claude:') ||
146
+ lower.startsWith('gemini:')
147
+ ) {
148
+ return {
149
+ providerName: mapModelToProvider(raw, providers),
150
+ canonicalModel: raw,
151
+ options,
152
+ };
153
+ }
154
+
155
+ // Curated friendly alias → provider + canonical ID.
156
+ const curated = CURATED_MODEL_ALIASES[lower];
157
+ if (curated) {
158
+ return {
159
+ providerName: curated.provider,
160
+ canonicalModel: curated.canonicalModel,
161
+ options,
162
+ };
163
+ }
164
+
165
+ // Full provider/model slug: a native provider that statically owns the bare
166
+ // model wins; otherwise it is an OpenRouter slug (decorations parsed).
167
+ if (raw.includes('/')) {
168
+ const { base, modelForRequest, webSearch } = parseOpenRouterDecorations(raw);
169
+ const providerName = mapModelToProvider(base, providers);
170
+ if (providerName === 'openrouter' && webSearch) {
171
+ options.web_search = true;
172
+ }
173
+ return { providerName, canonicalModel: modelForRequest, options };
174
+ }
175
+
176
+ // Keyword routing / unknown-ID passthrough (unchanged model string).
177
+ const providerName = mapModelToProvider(raw, providers);
178
+ return {
179
+ providerName,
180
+ canonicalModel: resolveAutoModel(raw, providerName),
181
+ options,
182
+ };
183
+ }
184
+
33
185
  /**
34
186
  * Resolve "auto" model to default model for the provider
35
187
  * @param {string} model - Model name (may be "auto")
@@ -87,6 +239,49 @@ export function providerSupportsImages(providerInstance, providerName) {
87
239
  }
88
240
  }
89
241
 
242
+ /**
243
+ * Return the available provider names in PROVIDER_PRIORITY order, optionally
244
+ * skipping text-only providers when the request has images and capping the
245
+ * count. Shared by every mode's "auto" expansion path.
246
+ * @param {object} providers - Provider instances
247
+ * @param {object} config - Configuration
248
+ * @param {object} [options]
249
+ * @param {boolean} [options.hasImages=false] - Skip text-only providers when true
250
+ * @param {number} [options.limit=Infinity] - Max number of providers to return
251
+ * @returns {string[]} Ordered available provider names
252
+ */
253
+ export function getAvailableProviders(providers, config, { hasImages = false, limit = Infinity } = {}) {
254
+ const names = [];
255
+ for (const name of PROVIDER_PRIORITY) {
256
+ if (names.length >= limit) break;
257
+ const provider = providers[name];
258
+ if (!provider || !provider.isAvailable(config)) continue;
259
+ if (hasImages && !providerSupportsImages(provider, name)) continue;
260
+ names.push(name);
261
+ }
262
+ return names;
263
+ }
264
+
265
+ /**
266
+ * Resolve a single model spec into routing facts: its provider name, the
267
+ * provider instance, the concrete model, and an availability status. Callers
268
+ * own their error wording and structural handling by switching on `status`
269
+ * ('ok' | 'not_found' | 'unavailable').
270
+ * @param {string} spec - Model specification
271
+ * @param {object} providers - Provider instances
272
+ * @param {object} config - Configuration
273
+ * @returns {{ providerName: string, provider: object, resolvedModel: string, status: string, options: object }}
274
+ */
275
+ export function resolveModelSpec(spec, providers, config) {
276
+ const { providerName, canonicalModel, options } = classifyModelSpec(
277
+ spec,
278
+ providers,
279
+ );
280
+ const provider = providers[providerName];
281
+ const status = !provider ? 'not_found' : !provider.isAvailable(config) ? 'unavailable' : 'ok';
282
+ return { providerName, provider, resolvedModel: canonicalModel, status, options };
283
+ }
284
+
90
285
  /**
91
286
  * Map model name to provider name
92
287
  * @param {string} model - Model name
@@ -160,6 +355,14 @@ export function mapModelToProvider(model, providers) {
160
355
  return 'copilot';
161
356
  }
162
357
 
358
+ // Check openrouter: prefix (e.g., openrouter:z-ai/glm-5.2). Routes to
359
+ // OpenRouter without the OPENROUTER_DYNAMIC_MODELS gate. Must be before the
360
+ // slash-format check so the namespaced slug is not probed against native
361
+ // providers.
362
+ if (modelLower.startsWith('openrouter:')) {
363
+ return 'openrouter';
364
+ }
365
+
163
366
  // Check OpenRouter-specific patterns first
164
367
  if (
165
368
  modelLower === 'openrouter auto' ||
@@ -1,220 +0,0 @@
1
- /**
2
- * OpenRouter Endpoints API Client
3
- *
4
- * Handles fetching model capabilities from OpenRouter's endpoints API.
5
- * Provides caching and error handling for dynamic model discovery.
6
- */
7
-
8
- import { debugLog, debugError } from '../utils/console.js';
9
-
10
- /**
11
- * Parse an OpenRouter model ID into author and slug components
12
- * @param {string} modelId - Model ID in format "author/slug"
13
- * @returns {{author: string, slug: string} | null} Parsed components or null if invalid
14
- */
15
- function parseModelId(modelId) {
16
- if (!modelId || typeof modelId !== 'string') {
17
- return null;
18
- }
19
-
20
- const parts = modelId.split('/');
21
- if (parts.length !== 2) {
22
- return null;
23
- }
24
-
25
- const [author, slug] = parts;
26
- if (!author || !slug) {
27
- return null;
28
- }
29
-
30
- return { author, slug };
31
- }
32
-
33
- /**
34
- * Convert endpoint data to model configuration format
35
- * @param {Object} endpointData - Raw endpoint data from API
36
- * @returns {Object} Model configuration object
37
- */
38
- function convertEndpointToModelConfig(endpointData) {
39
- const data = endpointData.data;
40
- const modelId = data.id;
41
-
42
- // Find the best endpoint (prefer primary providers)
43
- const preferredProviders = ['Anthropic', 'OpenAI', 'Google', 'XAI'];
44
- let selectedEndpoint = data.endpoints[0]; // Default to first
45
-
46
- for (const endpoint of data.endpoints) {
47
- if (preferredProviders.includes(endpoint.provider_name)) {
48
- selectedEndpoint = endpoint;
49
- break;
50
- }
51
- }
52
-
53
- // Extract supported parameters
54
- const supportedParams = selectedEndpoint.supported_parameters || [];
55
-
56
- return {
57
- modelName: modelId,
58
- friendlyName: data.name || `${modelId} (via OpenRouter)`,
59
- description: data.description || `Dynamic model: ${modelId}`,
60
- contextWindow: selectedEndpoint.context_length || 8192,
61
- maxOutputTokens: selectedEndpoint.max_completion_tokens || 4096,
62
- supportsStreaming: true, // Most models support streaming
63
- supportsImages:
64
- data.architecture?.input_modalities?.includes('image') || false,
65
- supportsTemperature: supportedParams.includes('temperature'),
66
- supportsWebSearch: false, // Not in API response, conservative default
67
- supportsThinking: supportedParams.includes('reasoning'),
68
- supportsTools: supportedParams.includes('tools'),
69
- timeout: 300000, // 5 minutes default
70
- isDynamic: true,
71
- // Store additional metadata
72
- metadata: {
73
- architecture: data.architecture,
74
- endpoints: data.endpoints,
75
- pricing: selectedEndpoint.pricing,
76
- selectedProvider: selectedEndpoint.provider_name,
77
- maxPromptTokens: selectedEndpoint.max_prompt_tokens,
78
- },
79
- };
80
- }
81
-
82
- /**
83
- * Fetch model endpoints from OpenRouter API
84
- * @param {string} modelId - Model ID in format "author/slug"
85
- * @returns {Promise<Object|null>} Model configuration or null if not found
86
- */
87
- export async function fetchModelEndpoints(modelId) {
88
- const parsed = parseModelId(modelId);
89
- if (!parsed) {
90
- debugLog(`[OpenRouter Endpoints] Invalid model ID format: ${modelId}`);
91
- return null;
92
- }
93
-
94
- const { author, slug } = parsed;
95
- const url = `https://openrouter.ai/api/v1/models/${author}/${slug}/endpoints`;
96
-
97
- try {
98
- debugLog(`[OpenRouter Endpoints] Fetching endpoints for ${modelId}`);
99
-
100
- const response = await globalThis.fetch(url, {
101
- method: 'GET',
102
- headers: {
103
- Accept: 'application/json',
104
- },
105
- });
106
-
107
- if (response.status === 404) {
108
- debugLog(`[OpenRouter Endpoints] Model not found: ${modelId}`);
109
- return null;
110
- }
111
-
112
- if (!response.ok) {
113
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
114
- }
115
-
116
- const data = await response.json();
117
-
118
- // Validate response structure
119
- if (!data?.data?.id || !data?.data?.endpoints?.length) {
120
- debugLog(
121
- `[OpenRouter Endpoints] Invalid response structure for ${modelId}`,
122
- );
123
- return null;
124
- }
125
-
126
- const modelConfig = convertEndpointToModelConfig(data);
127
- debugLog(
128
- `[OpenRouter Endpoints] Successfully fetched config for ${modelId}`,
129
- );
130
-
131
- return modelConfig;
132
- } catch (error) {
133
- debugError(`[OpenRouter Endpoints] Error fetching ${modelId}:`, error);
134
- return null;
135
- }
136
- }
137
-
138
- /**
139
- * Create a simple in-memory cache for model endpoints
140
- */
141
- export function createEndpointsCache() {
142
- const cache = new Map();
143
- const DEFAULT_TTL = 24 * 60 * 60 * 1000; // 24 hours
144
- const FAILED_TTL = 5 * 60 * 1000; // 5 minutes for failed requests
145
-
146
- return {
147
- /**
148
- * Get a cached value
149
- * @param {string} key - Cache key
150
- * @returns {{found: boolean, value: any}} Cache result
151
- */
152
- get(key) {
153
- const entry = cache.get(key);
154
- if (!entry) {
155
- return { found: false, value: null };
156
- }
157
-
158
- if (Date.now() > entry.expiry) {
159
- cache.delete(key);
160
- return { found: false, value: null };
161
- }
162
-
163
- return { found: true, value: entry.value };
164
- },
165
-
166
- /**
167
- * Set a cached value
168
- * @param {string} key - Cache key
169
- * @param {Object|null} value - Value to cache
170
- * @param {boolean} isFailure - Whether this is a failed request
171
- */
172
- set(key, value, isFailure = false) {
173
- const ttl = isFailure ? FAILED_TTL : DEFAULT_TTL;
174
- cache.set(key, {
175
- value,
176
- expiry: Date.now() + ttl,
177
- });
178
- },
179
-
180
- /**
181
- * Clear the entire cache
182
- */
183
- clear() {
184
- cache.clear();
185
- },
186
-
187
- /**
188
- * Get cache size
189
- * @returns {number} Number of cached entries
190
- */
191
- size() {
192
- return cache.size;
193
- },
194
- };
195
- }
196
-
197
- // Create a singleton cache instance
198
- export const endpointsCache = createEndpointsCache();
199
-
200
- /**
201
- * Fetch model endpoints with caching
202
- * @param {string} modelId - Model ID in format "author/slug"
203
- * @returns {Promise<Object|null>} Model configuration or null if not found
204
- */
205
- export async function fetchModelEndpointsWithCache(modelId) {
206
- // Check cache first
207
- const cached = endpointsCache.get(modelId);
208
- if (cached.found) {
209
- debugLog(`[OpenRouter Endpoints] Using cached config for ${modelId}`);
210
- return cached.value;
211
- }
212
-
213
- // Fetch from API
214
- const config = await fetchModelEndpoints(modelId);
215
-
216
- // Cache the result (including null for not found)
217
- endpointsCache.set(modelId, config, config === null);
218
-
219
- return config;
220
- }