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
@@ -17,7 +17,6 @@ const SUPPORTED_MODELS = {
17
17
  maxOutputTokens: 128000,
18
18
  supportsStreaming: true,
19
19
  supportsImages: true,
20
- supportsTemperature: false,
21
20
  supportsWebSearch: true,
22
21
  supportsResponsesAPI: true,
23
22
  supportsNoneReasoningEffort: true,
@@ -43,7 +42,6 @@ const SUPPORTED_MODELS = {
43
42
  maxOutputTokens: 128000,
44
43
  supportsStreaming: true,
45
44
  supportsImages: true,
46
- supportsTemperature: false,
47
45
  supportsWebSearch: true,
48
46
  supportsResponsesAPI: true,
49
47
  supportsNoneReasoningEffort: true,
@@ -59,7 +57,6 @@ const SUPPORTED_MODELS = {
59
57
  maxOutputTokens: 128000,
60
58
  supportsStreaming: true,
61
59
  supportsImages: true,
62
- supportsTemperature: false,
63
60
  supportsWebSearch: true,
64
61
  supportsResponsesAPI: true,
65
62
  supportsNoneReasoningEffort: true,
@@ -75,7 +72,6 @@ const SUPPORTED_MODELS = {
75
72
  maxOutputTokens: 128000,
76
73
  supportsStreaming: true,
77
74
  supportsImages: true,
78
- supportsTemperature: false,
79
75
  supportsWebSearch: true,
80
76
  supportsResponsesAPI: true,
81
77
  supportsNoneReasoningEffort: true,
@@ -94,7 +90,6 @@ const SUPPORTED_MODELS = {
94
90
  maxOutputTokens: 128000,
95
91
  supportsStreaming: true,
96
92
  supportsImages: true,
97
- supportsTemperature: false, // GPT-5 models don't support temperature
98
93
  supportsWebSearch: true,
99
94
  supportsResponsesAPI: true,
100
95
  timeout: 1800000, // 30 minutes
@@ -109,7 +104,6 @@ const SUPPORTED_MODELS = {
109
104
  maxOutputTokens: 128000,
110
105
  supportsStreaming: true,
111
106
  supportsImages: true,
112
- supportsTemperature: false, // GPT-5 models don't support temperature
113
107
  supportsWebSearch: false, // GPT-5-nano doesn't support web search
114
108
  supportsResponsesAPI: true,
115
109
  timeout: 600000, // 10 minutes
@@ -124,7 +118,6 @@ const SUPPORTED_MODELS = {
124
118
  maxOutputTokens: 128000,
125
119
  supportsStreaming: true,
126
120
  supportsImages: true,
127
- supportsTemperature: false,
128
121
  supportsWebSearch: true,
129
122
  supportsResponsesAPI: true,
130
123
  timeout: 1800000, // 30 minutes
@@ -144,7 +137,6 @@ const SUPPORTED_MODELS = {
144
137
  maxOutputTokens: 128000,
145
138
  supportsStreaming: true,
146
139
  supportsImages: true,
147
- supportsTemperature: false,
148
140
  supportsWebSearch: false,
149
141
  supportsResponsesAPI: true,
150
142
  timeout: 600000, // 10 minutes
@@ -164,7 +156,6 @@ const SUPPORTED_MODELS = {
164
156
  maxOutputTokens: 272000,
165
157
  supportsStreaming: false, // GPT-5 Pro doesn't support streaming
166
158
  supportsImages: true,
167
- supportsTemperature: false,
168
159
  supportsWebSearch: true,
169
160
  supportsResponsesAPI: true,
170
161
  supportsDeepResearch: false,
@@ -186,7 +177,6 @@ const SUPPORTED_MODELS = {
186
177
  maxOutputTokens: 100000,
187
178
  supportsStreaming: true,
188
179
  supportsImages: true,
189
- supportsTemperature: false,
190
180
  supportsWebSearch: true,
191
181
  supportsResponsesAPI: true,
192
182
  timeout: 600000, // 10 minutes
@@ -201,7 +191,6 @@ const SUPPORTED_MODELS = {
201
191
  maxOutputTokens: 100000,
202
192
  supportsStreaming: true,
203
193
  supportsImages: true,
204
- supportsTemperature: false,
205
194
  supportsWebSearch: true,
206
195
  supportsResponsesAPI: true,
207
196
  timeout: 3600000, // 60 minutes
@@ -216,7 +205,6 @@ const SUPPORTED_MODELS = {
216
205
  maxOutputTokens: 100000,
217
206
  supportsStreaming: true,
218
207
  supportsImages: true,
219
- supportsTemperature: false,
220
208
  supportsWebSearch: true,
221
209
  supportsResponsesAPI: true,
222
210
  timeout: 180000, // 3 minutes
@@ -231,7 +219,6 @@ const SUPPORTED_MODELS = {
231
219
  maxOutputTokens: 32768,
232
220
  supportsStreaming: true,
233
221
  supportsImages: true,
234
- supportsTemperature: true,
235
222
  supportsWebSearch: true,
236
223
  supportsResponsesAPI: true,
237
224
  timeout: 300000,
@@ -246,7 +233,6 @@ const SUPPORTED_MODELS = {
246
233
  maxOutputTokens: 100000,
247
234
  supportsStreaming: true,
248
235
  supportsImages: true,
249
- supportsTemperature: false,
250
236
  supportsWebSearch: true,
251
237
  supportsResponsesAPI: true,
252
238
  supportsDeepResearch: true,
@@ -267,7 +253,6 @@ const SUPPORTED_MODELS = {
267
253
  maxOutputTokens: 100000,
268
254
  supportsStreaming: true,
269
255
  supportsImages: true,
270
- supportsTemperature: false,
271
256
  supportsWebSearch: true,
272
257
  supportsResponsesAPI: true,
273
258
  supportsDeepResearch: true,
@@ -465,12 +450,9 @@ export const openaiProvider = {
465
450
  async invoke(messages, options = {}) {
466
451
  const {
467
452
  model = 'gpt-5.6',
468
- temperature = 0.7,
469
453
  maxTokens = null,
470
454
  stream = false,
471
455
  reasoning_effort = 'medium',
472
- verbosity = 'medium',
473
- use_websearch = false,
474
456
  signal,
475
457
  config,
476
458
  // Filter out options not meant for the API
@@ -522,20 +504,13 @@ export const openaiProvider = {
522
504
  ...otherOptions,
523
505
  };
524
506
 
525
- // Add web search tools only if requested and model supports it
526
- if (use_websearch && modelConfig.supportsWebSearch) {
507
+ // Attach web search where the model supports it; the model decides
508
+ // per-request whether to actually search.
509
+ if (modelConfig.supportsWebSearch) {
527
510
  // Use web_search_preview tool for all models in Responses API
528
511
  requestPayload.tools = [{ type: 'web_search_preview' }];
529
512
  }
530
513
 
531
- // Add temperature if model supports it
532
- if (
533
- modelConfig.supportsTemperature !== false &&
534
- temperature !== undefined
535
- ) {
536
- requestPayload.temperature = Math.max(0, Math.min(2, temperature));
537
- }
538
-
539
514
  // Add reasoning effort for thinking models (o3 series and GPT-5 family)
540
515
  if (
541
516
  (resolvedModel.startsWith('o3') || resolvedModel.startsWith('gpt-5')) &&
@@ -546,18 +521,9 @@ export const openaiProvider = {
546
521
  summary: 'auto', // Enable reasoning summaries
547
522
  };
548
523
  }
549
-
550
- // Add verbosity for GPT-5 models
551
- if (resolvedModel.startsWith('gpt-5') && verbosity) {
552
- requestPayload.text = { verbosity };
553
- }
554
524
  } else {
555
525
  // Build Chat Completions API payload
556
- const {
557
- reasoning_effort: _unused,
558
- verbosity: _unused2,
559
- ...cleanOptions
560
- } = otherOptions;
526
+ const { reasoning_effort: _unused, ...cleanOptions } = otherOptions;
561
527
  requestPayload = {
562
528
  model: resolvedModel,
563
529
  messages: openaiMessages,
@@ -565,14 +531,6 @@ export const openaiProvider = {
565
531
  ...cleanOptions,
566
532
  };
567
533
 
568
- // Add temperature if model supports it
569
- if (
570
- modelConfig.supportsTemperature !== false &&
571
- temperature !== undefined
572
- ) {
573
- requestPayload.temperature = Math.max(0, Math.min(2, temperature));
574
- }
575
-
576
534
  // Add reasoning effort for thinking models (o3 series and GPT-5 family)
577
535
  if (
578
536
  (resolvedModel.startsWith('o3') || resolvedModel.startsWith('gpt-5')) &&
@@ -583,11 +541,6 @@ export const openaiProvider = {
583
541
  reasoning_effort,
584
542
  );
585
543
  }
586
-
587
- // Add verbosity for GPT-5 models
588
- if (resolvedModel.startsWith('gpt-5') && verbosity) {
589
- requestPayload.verbosity = verbosity;
590
- }
591
544
  }
592
545
 
593
546
  // Add max tokens if specified (both APIs)
@@ -626,7 +579,6 @@ export const openaiProvider = {
626
579
  shouldUseResponsesAPI,
627
580
  resolvedModel,
628
581
  modelConfig,
629
- use_websearch,
630
582
  signal,
631
583
  );
632
584
  }
@@ -635,10 +587,9 @@ export const openaiProvider = {
635
587
  const apiType = shouldUseResponsesAPI
636
588
  ? 'Responses API'
637
589
  : 'Chat Completions API';
638
- const searchInfo =
639
- use_websearch && modelConfig.supportsWebSearch
640
- ? ' (with web search)'
641
- : '';
590
+ const searchInfo = modelConfig.supportsWebSearch
591
+ ? ' (with web search)'
592
+ : '';
642
593
  debugLog(
643
594
  `[OpenAI] Calling ${resolvedModel} via ${apiType} with ${openaiMessages.length} messages${searchInfo}`,
644
595
  );
@@ -753,8 +704,8 @@ export const openaiProvider = {
753
704
  usage = response.usage || {};
754
705
  }
755
706
 
756
- // Determine web search usage
757
- const webSearchUsed = use_websearch && modelConfig.supportsWebSearch;
707
+ // Web search is available whenever the model supports it
708
+ const webSearchUsed = !!modelConfig.supportsWebSearch;
758
709
  const webSearchType = webSearchUsed ? 'web_search_preview' : null;
759
710
 
760
711
  // Return unified response format
@@ -836,7 +787,6 @@ export const openaiProvider = {
836
787
  * @param {boolean} shouldUseResponsesAPI - Whether to use Responses API
837
788
  * @param {string} resolvedModel - Resolved model name
838
789
  * @param {Object} modelConfig - Model configuration
839
- * @param {boolean} use_websearch - Whether web search is enabled
840
790
  * @returns {AsyncGenerator} - Streaming generator yielding events
841
791
  */
842
792
  async *_createStreamingGenerator(
@@ -845,16 +795,14 @@ export const openaiProvider = {
845
795
  shouldUseResponsesAPI,
846
796
  resolvedModel,
847
797
  modelConfig,
848
- use_websearch,
849
798
  signal,
850
799
  ) {
851
800
  const apiType = shouldUseResponsesAPI
852
801
  ? 'Responses API'
853
802
  : 'Chat Completions API';
854
- const searchInfo =
855
- use_websearch && modelConfig.supportsWebSearch
856
- ? ' (with web search)'
857
- : '';
803
+ const searchInfo = modelConfig.supportsWebSearch
804
+ ? ' (with web search)'
805
+ : '';
858
806
 
859
807
  debugLog(
860
808
  `[OpenAI] Starting streaming for ${resolvedModel} via ${apiType} with ${requestPayload.input?.length || requestPayload.messages?.length} messages${searchInfo}`,
@@ -1029,8 +977,8 @@ export const openaiProvider = {
1029
977
  };
1030
978
  }
1031
979
 
1032
- // Determine web search usage
1033
- const webSearchUsed = use_websearch && modelConfig.supportsWebSearch;
980
+ // Web search is available whenever the model supports it
981
+ const webSearchUsed = !!modelConfig.supportsWebSearch;
1034
982
  const webSearchType = webSearchUsed ? 'web_search_preview' : null;
1035
983
 
1036
984
  // Yield end event with final metadata
@@ -0,0 +1,308 @@
1
+ /**
2
+ * OpenRouter Model Metadata Discovery Adapter
3
+ *
4
+ * Discovers capability metadata for explicit OpenRouter slugs that are not in
5
+ * the provider's static curated catalog. Uses the public bulk
6
+ * `GET https://openrouter.ai/api/v1/models` endpoint (no auth, ~one 520KB
7
+ * payload for the whole catalog) because that response carries the structured
8
+ * per-model `reasoning` object (`mandatory`/`default_enabled`/`supported_efforts`/
9
+ * `default_effort`) — a far better capability source than the flat
10
+ * `supported_parameters` list.
11
+ *
12
+ * Transport is plain `fetch` (no `@openrouter/sdk`): the endpoint is public and
13
+ * un-paginated, so the pre-1.0, daily-churning SDK buys no material typing or
14
+ * pagination benefit and only adds version-churn risk.
15
+ *
16
+ * Since the source is the whole-catalog bulk list (there is no per-slug HTTP
17
+ * 404), "not found" means "absent from a fresh successful bulk fetch", surfaced
18
+ * as a local `catalog_miss` status the provider maps to `MODEL_NOT_FOUND`.
19
+ *
20
+ * Caching rules (deliberate):
21
+ * - successes are cached (bounded);
22
+ * - ONLY authoritative catalog-misses are negatively cached (short TTL);
23
+ * - transient failures (timeout, 429, malformed, abort, auth) are NEVER
24
+ * negatively cached, so they never mask a slug that is really there;
25
+ * - concurrent lookups share a single in-flight bulk fetch (single-flight);
26
+ * - credentials and raw response bodies are never cached or logged.
27
+ */
28
+
29
+ import { debugLog, debugError } from '../utils/console.js';
30
+
31
+ const MODELS_URL = 'https://openrouter.ai/api/v1/models';
32
+ const DEFAULT_TIMEOUT_MS = 8000;
33
+ const SUCCESS_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
34
+ const NEGATIVE_TTL_MS = 5 * 60 * 1000; // 5 minutes (catalog-miss only)
35
+ const MAX_SUCCESS_ENTRIES = 512;
36
+ const MAX_NEGATIVE_ENTRIES = 512;
37
+
38
+ /**
39
+ * Discovery result statuses.
40
+ * - ok: metadata found (modelConfig present)
41
+ * - catalog_miss: authoritative absence from a fresh successful bulk fetch
42
+ * - auth: 401 from the endpoint (unexpected for a public endpoint)
43
+ * - rate_limit: 429
44
+ * - timeout: request timed out or was aborted
45
+ * - malformed: non-OK HTTP, unparseable JSON, or unexpected payload shape
46
+ */
47
+ export const DiscoveryStatus = {
48
+ OK: 'ok',
49
+ CATALOG_MISS: 'catalog_miss',
50
+ AUTH: 'auth',
51
+ RATE_LIMIT: 'rate_limit',
52
+ TIMEOUT: 'timeout',
53
+ MALFORMED: 'malformed',
54
+ };
55
+
56
+ // Bounded success cache (slug -> { modelConfig, expiry }); short negative cache
57
+ // (slug -> expiry) for authoritative catalog-misses only.
58
+ const successCache = new Map();
59
+ const negativeCache = new Map();
60
+ // Single-flight: at most one in-flight bulk fetch shared by all callers.
61
+ let inflightBulkFetch = null;
62
+
63
+ /**
64
+ * Build a request-local modelConfig from a raw bulk-endpoint model object.
65
+ * The structured `reasoning` object is preserved verbatim for the provider's
66
+ * effort-mapping logic. Never merged into getSupportedModels().
67
+ * @param {object} raw - One model object from the bulk `data[]` array
68
+ * @returns {object} modelConfig
69
+ */
70
+ export function metadataToModelConfig(raw) {
71
+ const inputModalities = raw?.architecture?.input_modalities || [];
72
+ const reasoning = raw?.reasoning || null;
73
+ const supportsReasoning = !!(
74
+ reasoning &&
75
+ (reasoning.mandatory === true ||
76
+ reasoning.default_enabled === true ||
77
+ (Array.isArray(reasoning.supported_efforts) &&
78
+ reasoning.supported_efforts.length > 0))
79
+ );
80
+
81
+ return {
82
+ modelName: raw.id,
83
+ friendlyName: raw.name || `${raw.id} (via OpenRouter)`,
84
+ contextWindow: raw.context_length || raw.top_provider?.context_length || 8192,
85
+ maxOutputTokens:
86
+ raw.top_provider?.max_completion_tokens || raw.context_length || 8192,
87
+ supportsStreaming: true,
88
+ supportsImages: inputModalities.includes('image'),
89
+ supportsWebSearch: false,
90
+ supportsReasoning,
91
+ // Structured capability object consumed by the OpenRouter reasoning mapper.
92
+ reasoning,
93
+ timeout: 300000,
94
+ isDynamic: true,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Classify a fetch/HTTP failure into a transient discovery status. Never
100
+ * returns catalog_miss (that is only decided from a successful catalog).
101
+ * @param {Error} error
102
+ * @param {number|null} status - HTTP status if the response was received
103
+ * @returns {string}
104
+ */
105
+ function classifyFailure(error, status) {
106
+ if (status === 401) return DiscoveryStatus.AUTH;
107
+ if (status === 429) return DiscoveryStatus.RATE_LIMIT;
108
+ if (error?.name === 'AbortError') return DiscoveryStatus.TIMEOUT;
109
+ return DiscoveryStatus.MALFORMED;
110
+ }
111
+
112
+ /**
113
+ * Perform one bulk catalog fetch and return a slug->rawModel Map. Throws a
114
+ * tagged error ({ discoveryStatus }) on any transient failure so the caller can
115
+ * classify without caching.
116
+ * @param {object} [opts]
117
+ * @param {AbortSignal} [opts.signal] - Caller abort signal
118
+ * @param {number} [opts.timeoutMs]
119
+ * @returns {Promise<Map<string, object>>}
120
+ */
121
+ async function fetchBulkCatalog({ signal, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
122
+ const controller = new AbortController();
123
+ const onAbort = () => controller.abort();
124
+ if (signal) {
125
+ if (signal.aborted) controller.abort();
126
+ else signal.addEventListener('abort', onAbort, { once: true });
127
+ }
128
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
129
+
130
+ // The timeout / caller-abort must stay armed through the body read: fetch
131
+ // resolves on headers, and reading the ~520KB JSON body can itself stall.
132
+ // Keeping the timer live until json() completes stops a stalled body from
133
+ // leaving the shared single-flight promise pending forever.
134
+ try {
135
+ let response;
136
+ try {
137
+ response = await globalThis.fetch(MODELS_URL, {
138
+ method: 'GET',
139
+ headers: { Accept: 'application/json' },
140
+ signal: controller.signal,
141
+ });
142
+ } catch (error) {
143
+ const tagged = new Error('OpenRouter discovery fetch failed');
144
+ tagged.discoveryStatus = classifyFailure(error, null);
145
+ throw tagged;
146
+ }
147
+
148
+ if (!response.ok) {
149
+ const tagged = new Error(`OpenRouter discovery HTTP ${response.status}`);
150
+ tagged.discoveryStatus = classifyFailure(null, response.status);
151
+ throw tagged;
152
+ }
153
+
154
+ let payload;
155
+ try {
156
+ payload = await response.json();
157
+ } catch (error) {
158
+ const tagged = new Error('OpenRouter discovery malformed JSON');
159
+ // An abort during the body read is a timeout/cancellation, not an
160
+ // authoritative malformed payload.
161
+ tagged.discoveryStatus =
162
+ error?.name === 'AbortError'
163
+ ? DiscoveryStatus.TIMEOUT
164
+ : DiscoveryStatus.MALFORMED;
165
+ throw tagged;
166
+ }
167
+
168
+ if (!payload || !Array.isArray(payload.data)) {
169
+ const tagged = new Error('OpenRouter discovery unexpected payload');
170
+ tagged.discoveryStatus = DiscoveryStatus.MALFORMED;
171
+ throw tagged;
172
+ }
173
+
174
+ const map = new Map();
175
+ for (const model of payload.data) {
176
+ if (model && typeof model.id === 'string') {
177
+ map.set(model.id, model);
178
+ }
179
+ }
180
+
181
+ // A healthy bulk catalog always carries hundreds of models; a map with zero
182
+ // usable entries means a truncated/degenerate snapshot, not an authoritative
183
+ // catalog. Treat it as transient (malformed) so a real slug is never
184
+ // negatively cached against a bad snapshot.
185
+ if (map.size === 0) {
186
+ const tagged = new Error('OpenRouter discovery empty catalog');
187
+ tagged.discoveryStatus = DiscoveryStatus.MALFORMED;
188
+ throw tagged;
189
+ }
190
+
191
+ return map;
192
+ } finally {
193
+ clearTimeout(timer);
194
+ if (signal) signal.removeEventListener('abort', onAbort);
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Single-flight wrapper around fetchBulkCatalog: concurrent callers share one
200
+ * in-flight request.
201
+ */
202
+ function getBulkCatalog(opts) {
203
+ if (inflightBulkFetch) {
204
+ return inflightBulkFetch;
205
+ }
206
+ inflightBulkFetch = fetchBulkCatalog(opts).finally(() => {
207
+ inflightBulkFetch = null;
208
+ });
209
+ return inflightBulkFetch;
210
+ }
211
+
212
+ function readSuccessCache(slug) {
213
+ const entry = successCache.get(slug);
214
+ if (!entry) return null;
215
+ if (Date.now() > entry.expiry) {
216
+ successCache.delete(slug);
217
+ return null;
218
+ }
219
+ return entry.modelConfig;
220
+ }
221
+
222
+ function writeSuccessCache(slug, modelConfig) {
223
+ // Bounded: evict the oldest entry when at capacity (Map preserves insertion
224
+ // order).
225
+ if (successCache.size >= MAX_SUCCESS_ENTRIES) {
226
+ const oldest = successCache.keys().next().value;
227
+ if (oldest !== undefined) successCache.delete(oldest);
228
+ }
229
+ successCache.set(slug, { modelConfig, expiry: Date.now() + SUCCESS_TTL_MS });
230
+ }
231
+
232
+ function isNegativelyCached(slug) {
233
+ const expiry = negativeCache.get(slug);
234
+ if (expiry === undefined) return false;
235
+ if (Date.now() > expiry) {
236
+ negativeCache.delete(slug);
237
+ return false;
238
+ }
239
+ return true;
240
+ }
241
+
242
+ // Bounded write for the negative cache so a flood of unique bogus slugs can't
243
+ // grow it without limit (expired entries are otherwise reclaimed only on a
244
+ // repeat lookup of the same slug).
245
+ function writeNegativeCache(slug) {
246
+ if (negativeCache.size >= MAX_NEGATIVE_ENTRIES) {
247
+ const oldest = negativeCache.keys().next().value;
248
+ if (oldest !== undefined) negativeCache.delete(oldest);
249
+ }
250
+ negativeCache.set(slug, Date.now() + NEGATIVE_TTL_MS);
251
+ }
252
+
253
+ /**
254
+ * Look up capability metadata for an explicit OpenRouter slug.
255
+ * @param {string} slug - Bare `provider/model` slug (no `openrouter:`/decorations)
256
+ * @param {object} [opts]
257
+ * @param {AbortSignal} [opts.signal]
258
+ * @param {number} [opts.timeoutMs]
259
+ * @returns {Promise<{ status: string, modelConfig: (object|null) }>}
260
+ */
261
+ export async function lookupOpenRouterModel(slug, opts = {}) {
262
+ const cachedConfig = readSuccessCache(slug);
263
+ if (cachedConfig) {
264
+ return { status: DiscoveryStatus.OK, modelConfig: cachedConfig };
265
+ }
266
+ if (isNegativelyCached(slug)) {
267
+ return { status: DiscoveryStatus.CATALOG_MISS, modelConfig: null };
268
+ }
269
+
270
+ let catalog;
271
+ try {
272
+ catalog = await getBulkCatalog(opts);
273
+ } catch (error) {
274
+ // Transient failure — never negatively cached. Log the classification only,
275
+ // never the response body or any credential.
276
+ const status = error?.discoveryStatus || DiscoveryStatus.MALFORMED;
277
+ debugLog(`[OpenRouter Discovery] Lookup for ${slug} failed: ${status}`);
278
+ return { status, modelConfig: null };
279
+ }
280
+
281
+ const raw = catalog.get(slug);
282
+ if (!raw) {
283
+ // Authoritative catalog-miss from a fresh successful fetch — negative-cache
284
+ // briefly so a hot loop doesn't refetch, but keep the TTL short so a
285
+ // newly-added slug is picked up soon.
286
+ writeNegativeCache(slug);
287
+ return { status: DiscoveryStatus.CATALOG_MISS, modelConfig: null };
288
+ }
289
+
290
+ let modelConfig;
291
+ try {
292
+ modelConfig = metadataToModelConfig(raw);
293
+ } catch (error) {
294
+ debugError('[OpenRouter Discovery] Failed to build model config', error);
295
+ return { status: DiscoveryStatus.MALFORMED, modelConfig: null };
296
+ }
297
+ writeSuccessCache(slug, modelConfig);
298
+ return { status: DiscoveryStatus.OK, modelConfig };
299
+ }
300
+
301
+ /**
302
+ * Clear all discovery caches and any in-flight fetch. Test-only.
303
+ */
304
+ export function _resetDiscoveryCaches() {
305
+ successCache.clear();
306
+ negativeCache.clear();
307
+ inflightBulkFetch = null;
308
+ }