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.
- package/.env.example +6 -3
- package/README.md +96 -94
- package/docs/API.md +703 -1562
- package/docs/ARCHITECTURE.md +13 -11
- package/docs/EXAMPLES.md +241 -667
- package/docs/PROVIDERS.md +104 -79
- package/package.json +1 -1
- package/src/async/asyncJobStore.js +2 -2
- package/src/async/jobRunner.js +6 -1
- package/src/async/providerStreamNormalizer.js +59 -1
- package/src/config.js +4 -3
- package/src/prompts/helpPrompt.js +43 -61
- package/src/providers/anthropic.js +0 -31
- package/src/providers/claude.js +0 -14
- package/src/providers/codex.js +15 -21
- package/src/providers/copilot.js +36 -202
- package/src/providers/deepseek.js +73 -53
- package/src/providers/gemini-cli.js +0 -3
- package/src/providers/google.js +10 -27
- package/src/providers/interface.js +0 -3
- package/src/providers/mistral.js +169 -63
- package/src/providers/openai-compatible.js +113 -28
- package/src/providers/openai.js +14 -66
- package/src/providers/openrouter-discovery.js +308 -0
- package/src/providers/openrouter.js +452 -282
- package/src/providers/xai.js +298 -280
- package/src/services/summarizationService.js +4 -14
- package/src/systemPrompts.js +19 -2
- package/src/tools/cancelJob.js +7 -1
- package/src/tools/chat.js +957 -995
- package/src/tools/checkStatus.js +1 -0
- package/src/tools/index.js +2 -6
- package/src/tools/modes/parallel.js +488 -0
- package/src/tools/modes/roundtable.js +495 -0
- package/src/tools/modes/streamShared.js +31 -0
- package/src/utils/contextProcessor.js +1 -38
- package/src/utils/conversationExporter.js +6 -26
- package/src/utils/formatStatus.js +46 -19
- package/src/utils/modelRouting.js +207 -4
- package/src/providers/openrouter-endpoints-client.js +0 -220
- package/src/tools/consensus.js +0 -1813
- package/src/tools/conversation.js +0 -1233
package/src/providers/openai.js
CHANGED
|
@@ -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
|
-
//
|
|
526
|
-
|
|
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
|
-
|
|
640
|
-
|
|
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
|
-
//
|
|
757
|
-
const webSearchUsed =
|
|
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
|
-
|
|
856
|
-
|
|
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
|
-
//
|
|
1033
|
-
const webSearchUsed =
|
|
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
|
+
}
|