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
|
@@ -1,121 +1,177 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* OpenRouter Provider
|
|
3
3
|
*
|
|
4
|
-
* Provider implementation for OpenRouter's unified API gateway
|
|
5
|
-
*
|
|
4
|
+
* Provider implementation for OpenRouter's unified API gateway, built on the
|
|
5
|
+
* shared OpenAI-compatible base. Exposes a curated static catalog of current
|
|
6
|
+
* flagship slugs; any other explicit `provider/model` slug is discovered
|
|
7
|
+
* request-locally through the Foundation discovery adapter (never merged into
|
|
8
|
+
* getSupportedModels()).
|
|
6
9
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
10
|
+
* Capability notes:
|
|
11
|
+
* - Web search is explicit opt-in via the `:online` slug decoration (parsed by
|
|
12
|
+
* the shared resolver into `options.web_search`) because OpenRouter web
|
|
13
|
+
* search adds real per-request cost. `supportsWebSearch` stays false on every
|
|
14
|
+
* curated model; the plugin is attached from the flag, never silently.
|
|
15
|
+
* - Reasoning is metadata-driven from each model's structured `reasoning`
|
|
16
|
+
* object and capability-gated: unknown/retired pass-through IDs get no
|
|
17
|
+
* reasoning field.
|
|
18
|
+
* - Attribution headers (`HTTP-Referer`, canonical `X-OpenRouter-Title`) are
|
|
19
|
+
* optional; omitting them only forgoes OpenRouter ranking credit.
|
|
9
20
|
*/
|
|
10
21
|
|
|
11
22
|
import { createOpenAICompatibleProvider } from './openai-compatible.js';
|
|
12
23
|
import { debugLog } from '../utils/console.js';
|
|
13
24
|
import { ProviderError, ErrorCodes } from './interface.js';
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
25
|
+
import {
|
|
26
|
+
lookupOpenRouterModel,
|
|
27
|
+
DiscoveryStatus,
|
|
28
|
+
} from './openrouter-discovery.js';
|
|
29
|
+
|
|
30
|
+
// Curated static catalog (verified live 2026-07-11). getSupportedModels() must
|
|
31
|
+
// return exactly these 8 slugs even after a discovery call has run — dynamic
|
|
32
|
+
// metadata is request-local and never merged here.
|
|
33
|
+
//
|
|
34
|
+
// Each entry carries a structured `reasoning` object mirroring the discovery
|
|
35
|
+
// adapter's shape so the reasoning mapper (buildOpenRouterReasoning) is uniform
|
|
36
|
+
// across static and discovered models:
|
|
37
|
+
// - effort-tiered: { supported_efforts: ['xhigh','high'], default_effort:'high' }
|
|
38
|
+
// - enable/disable-only (binary): { mandatory:false, default_enabled:true }
|
|
39
|
+
// - mandatory: { mandatory:true } (reasoning cannot be disabled)
|
|
40
|
+
// - passthrough: { passthrough:true } (openrouter/auto — the router chooses)
|
|
18
41
|
const SUPPORTED_MODELS = {
|
|
19
|
-
'
|
|
20
|
-
modelName: '
|
|
21
|
-
friendlyName: '
|
|
22
|
-
contextWindow:
|
|
23
|
-
maxOutputTokens:
|
|
42
|
+
'z-ai/glm-5.2': {
|
|
43
|
+
modelName: 'z-ai/glm-5.2',
|
|
44
|
+
friendlyName: 'Z.ai GLM 5.2 (via OpenRouter)',
|
|
45
|
+
contextWindow: 1048576,
|
|
46
|
+
maxOutputTokens: 131072,
|
|
24
47
|
supportsStreaming: true,
|
|
25
48
|
supportsImages: false,
|
|
26
|
-
supportsTemperature: true,
|
|
27
49
|
supportsWebSearch: false,
|
|
28
|
-
|
|
50
|
+
supportsReasoning: true,
|
|
51
|
+
reasoning: {
|
|
52
|
+
mandatory: false,
|
|
53
|
+
default_enabled: true,
|
|
54
|
+
supported_efforts: ['xhigh', 'high'],
|
|
55
|
+
default_effort: 'high',
|
|
56
|
+
},
|
|
29
57
|
timeout: 300000,
|
|
30
58
|
description:
|
|
31
|
-
'
|
|
32
|
-
aliases: [
|
|
33
|
-
'qwen3-thinking',
|
|
34
|
-
'qwen-thinking',
|
|
35
|
-
'qwen3 thinking',
|
|
36
|
-
'qwen thinking',
|
|
37
|
-
'qwen3-235b-thinking',
|
|
38
|
-
],
|
|
59
|
+
'Z.ai GLM 5.2 — large-scale reasoning model with a 1M-token context',
|
|
60
|
+
aliases: ['glm-5.2', 'glm5.2', 'glm'],
|
|
39
61
|
},
|
|
40
|
-
'
|
|
41
|
-
modelName: '
|
|
42
|
-
friendlyName: '
|
|
43
|
-
contextWindow:
|
|
44
|
-
maxOutputTokens:
|
|
62
|
+
'deepseek/deepseek-v4-pro': {
|
|
63
|
+
modelName: 'deepseek/deepseek-v4-pro',
|
|
64
|
+
friendlyName: 'DeepSeek V4 Pro (via OpenRouter)',
|
|
65
|
+
contextWindow: 1048576,
|
|
66
|
+
maxOutputTokens: 384000,
|
|
45
67
|
supportsStreaming: true,
|
|
46
68
|
supportsImages: false,
|
|
47
|
-
supportsTemperature: true,
|
|
48
69
|
supportsWebSearch: false,
|
|
70
|
+
supportsReasoning: true,
|
|
71
|
+
reasoning: {
|
|
72
|
+
mandatory: false,
|
|
73
|
+
default_enabled: true,
|
|
74
|
+
supported_efforts: ['xhigh', 'high'],
|
|
75
|
+
default_effort: 'high',
|
|
76
|
+
},
|
|
49
77
|
timeout: 300000,
|
|
50
|
-
description: '
|
|
51
|
-
aliases: [
|
|
52
|
-
'qwen3-coder',
|
|
53
|
-
'qwen-coder',
|
|
54
|
-
'qwen3 coder',
|
|
55
|
-
'qwen coder',
|
|
56
|
-
'qwen-3-coder',
|
|
57
|
-
],
|
|
78
|
+
description: 'DeepSeek V4 Pro reasoning model (via OpenRouter)',
|
|
79
|
+
aliases: [],
|
|
58
80
|
},
|
|
59
|
-
'
|
|
60
|
-
modelName: '
|
|
61
|
-
friendlyName: '
|
|
62
|
-
contextWindow:
|
|
63
|
-
maxOutputTokens:
|
|
81
|
+
'deepseek/deepseek-v4-flash': {
|
|
82
|
+
modelName: 'deepseek/deepseek-v4-flash',
|
|
83
|
+
friendlyName: 'DeepSeek V4 Flash (via OpenRouter)',
|
|
84
|
+
contextWindow: 1048576,
|
|
85
|
+
maxOutputTokens: 384000,
|
|
64
86
|
supportsStreaming: true,
|
|
65
87
|
supportsImages: false,
|
|
66
|
-
supportsTemperature: true,
|
|
67
88
|
supportsWebSearch: false,
|
|
89
|
+
supportsReasoning: true,
|
|
90
|
+
reasoning: {
|
|
91
|
+
mandatory: false,
|
|
92
|
+
default_enabled: true,
|
|
93
|
+
supported_efforts: ['xhigh', 'high'],
|
|
94
|
+
default_effort: 'high',
|
|
95
|
+
},
|
|
68
96
|
timeout: 300000,
|
|
69
|
-
description: '
|
|
70
|
-
aliases: [
|
|
71
|
-
'kimi-k2',
|
|
72
|
-
'moonshot-kimi',
|
|
73
|
-
'kimi k2',
|
|
74
|
-
'kimi',
|
|
75
|
-
'moonshot kimi',
|
|
76
|
-
'moonshot-k2',
|
|
77
|
-
'k2',
|
|
78
|
-
],
|
|
97
|
+
description: 'DeepSeek V4 Flash — faster, lower-cost DeepSeek V4 tier',
|
|
98
|
+
aliases: [],
|
|
79
99
|
},
|
|
80
|
-
'
|
|
81
|
-
modelName: '
|
|
82
|
-
friendlyName: '
|
|
83
|
-
contextWindow:
|
|
84
|
-
maxOutputTokens:
|
|
100
|
+
'qwen/qwen3.7-max': {
|
|
101
|
+
modelName: 'qwen/qwen3.7-max',
|
|
102
|
+
friendlyName: 'Qwen3.7 Max (via OpenRouter)',
|
|
103
|
+
contextWindow: 1000000,
|
|
104
|
+
maxOutputTokens: 65536,
|
|
85
105
|
supportsStreaming: true,
|
|
86
|
-
supportsImages: false,
|
|
87
|
-
supportsTemperature: true,
|
|
106
|
+
supportsImages: false,
|
|
88
107
|
supportsWebSearch: false,
|
|
108
|
+
supportsReasoning: true,
|
|
109
|
+
// Enable/disable-only — no effort tiers exposed.
|
|
110
|
+
reasoning: { mandatory: false, default_enabled: true },
|
|
89
111
|
timeout: 300000,
|
|
90
|
-
description:
|
|
91
|
-
|
|
92
|
-
aliases: [
|
|
93
|
-
'openrouter auto',
|
|
94
|
-
'auto router',
|
|
95
|
-
'auto-router',
|
|
96
|
-
'openrouter-auto',
|
|
97
|
-
],
|
|
112
|
+
description: 'Qwen3.7 Max — flagship Qwen with a 1M-token context',
|
|
113
|
+
aliases: ['qwen3.7-max'],
|
|
98
114
|
},
|
|
99
|
-
'
|
|
100
|
-
modelName: '
|
|
101
|
-
friendlyName: '
|
|
102
|
-
contextWindow:
|
|
103
|
-
maxOutputTokens:
|
|
115
|
+
'qwen/qwen3.7-plus': {
|
|
116
|
+
modelName: 'qwen/qwen3.7-plus',
|
|
117
|
+
friendlyName: 'Qwen3.7 Plus (via OpenRouter)',
|
|
118
|
+
contextWindow: 1000000,
|
|
119
|
+
maxOutputTokens: 65536,
|
|
104
120
|
supportsStreaming: true,
|
|
105
|
-
supportsImages:
|
|
106
|
-
supportsTemperature: true,
|
|
121
|
+
supportsImages: true,
|
|
107
122
|
supportsWebSearch: false,
|
|
123
|
+
supportsReasoning: true,
|
|
124
|
+
// Enable/disable-only — no effort tiers exposed.
|
|
125
|
+
reasoning: { mandatory: false, default_enabled: true },
|
|
108
126
|
timeout: 300000,
|
|
109
|
-
description:
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
127
|
+
description: 'Qwen3.7 Plus — image-capable Qwen with a 1M-token context',
|
|
128
|
+
aliases: ['qwen3.7-plus'],
|
|
129
|
+
},
|
|
130
|
+
'moonshotai/kimi-k2.7-code': {
|
|
131
|
+
modelName: 'moonshotai/kimi-k2.7-code',
|
|
132
|
+
friendlyName: 'Kimi K2.7 Code (via OpenRouter)',
|
|
133
|
+
contextWindow: 262144,
|
|
134
|
+
maxOutputTokens: 262144,
|
|
135
|
+
supportsStreaming: true,
|
|
136
|
+
supportsImages: true,
|
|
137
|
+
supportsWebSearch: false,
|
|
138
|
+
supportsReasoning: true,
|
|
139
|
+
// Mandatory reasoning — cannot be disabled.
|
|
140
|
+
reasoning: { mandatory: true, default_enabled: true },
|
|
141
|
+
timeout: 300000,
|
|
142
|
+
description: 'Moonshot Kimi K2.7 Code — coding model with mandatory reasoning',
|
|
143
|
+
aliases: ['kimi-k2.7-code'],
|
|
144
|
+
},
|
|
145
|
+
'moonshotai/kimi-k2.6': {
|
|
146
|
+
modelName: 'moonshotai/kimi-k2.6',
|
|
147
|
+
friendlyName: 'Kimi K2.6 (via OpenRouter)',
|
|
148
|
+
contextWindow: 262144,
|
|
149
|
+
maxOutputTokens: 262144,
|
|
150
|
+
supportsStreaming: true,
|
|
151
|
+
supportsImages: true,
|
|
152
|
+
supportsWebSearch: false,
|
|
153
|
+
supportsReasoning: true,
|
|
154
|
+
// Enable/disable-only — no effort tiers exposed.
|
|
155
|
+
reasoning: { mandatory: false, default_enabled: true },
|
|
156
|
+
timeout: 300000,
|
|
157
|
+
description: 'Moonshot Kimi K2.6 — image-capable general model',
|
|
158
|
+
aliases: ['kimi-k2.6'],
|
|
159
|
+
},
|
|
160
|
+
'openrouter/auto': {
|
|
161
|
+
modelName: 'openrouter/auto',
|
|
162
|
+
friendlyName: 'OpenRouter Auto',
|
|
163
|
+
contextWindow: 2000000,
|
|
164
|
+
maxOutputTokens: 131072,
|
|
165
|
+
supportsStreaming: true,
|
|
166
|
+
supportsImages: true,
|
|
167
|
+
supportsWebSearch: false,
|
|
168
|
+
supportsReasoning: true,
|
|
169
|
+
// Router selects the underlying model (and its effort) — do not fabricate a
|
|
170
|
+
// reasoning field.
|
|
171
|
+
reasoning: { passthrough: true },
|
|
172
|
+
timeout: 300000,
|
|
173
|
+
description: 'Auto-selects the best model for your prompt via OpenRouter',
|
|
174
|
+
aliases: ['auto-router', 'openrouter-auto'],
|
|
119
175
|
},
|
|
120
176
|
};
|
|
121
177
|
|
|
@@ -140,287 +196,401 @@ function validateApiKey(apiKey) {
|
|
|
140
196
|
}
|
|
141
197
|
|
|
142
198
|
/**
|
|
143
|
-
*
|
|
199
|
+
* Build optional OpenRouter attribution headers. Both are optional — omitting
|
|
200
|
+
* them only forgoes ranking credit, it never breaks a request. When a title is
|
|
201
|
+
* configured, emit a single canonical `X-OpenRouter-Title` (not the legacy
|
|
202
|
+
* `X-Title`), while still accepting the legacy `openrouterreferer`/
|
|
203
|
+
* `openroutertitle` config-key spellings as input.
|
|
144
204
|
*/
|
|
145
205
|
function getCustomHeaders(config) {
|
|
146
206
|
const headers = {};
|
|
147
207
|
|
|
148
|
-
// REQUIRED: HTTP-Referer header for compliance
|
|
149
|
-
// Handle both camelCase (from tests) and lowercase (from config.js) keys
|
|
150
208
|
const referer =
|
|
151
209
|
config?.providers?.openrouterreferer ||
|
|
152
|
-
config?.providers?.openrouterReferer
|
|
153
|
-
|
|
154
|
-
|
|
210
|
+
config?.providers?.openrouterReferer;
|
|
211
|
+
if (referer) {
|
|
212
|
+
headers['HTTP-Referer'] = referer;
|
|
213
|
+
}
|
|
155
214
|
|
|
156
|
-
// Optional: X-Title header for request tracking
|
|
157
215
|
const title =
|
|
158
216
|
config?.providers?.openroutertitle || config?.providers?.openrouterTitle;
|
|
159
217
|
if (title) {
|
|
160
|
-
headers['X-Title'] = title;
|
|
218
|
+
headers['X-OpenRouter-Title'] = title;
|
|
161
219
|
}
|
|
162
220
|
|
|
163
|
-
debugLog(`[OpenRouter] Using referer: ${referer}`);
|
|
164
|
-
|
|
165
221
|
return headers;
|
|
166
222
|
}
|
|
167
223
|
|
|
168
224
|
/**
|
|
169
|
-
*
|
|
225
|
+
* Concatenate the visible reasoning text from a `reasoning_details[]` array.
|
|
226
|
+
* `reasoning.text` and `reasoning.summary` are visible; `reasoning.encrypted`
|
|
227
|
+
* is provider-side ciphertext and is NEVER rendered as visible reasoning.
|
|
228
|
+
* @param {Array<object>} details
|
|
229
|
+
* @returns {string}
|
|
170
230
|
*/
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const
|
|
231
|
+
function extractReasoningText(details) {
|
|
232
|
+
if (!Array.isArray(details)) return '';
|
|
233
|
+
const parts = [];
|
|
234
|
+
for (const detail of details) {
|
|
235
|
+
if (!detail || typeof detail !== 'object') continue;
|
|
236
|
+
if (detail.type === 'reasoning.text' && typeof detail.text === 'string') {
|
|
237
|
+
parts.push(detail.text);
|
|
238
|
+
} else if (
|
|
239
|
+
detail.type === 'reasoning.summary' &&
|
|
240
|
+
typeof detail.summary === 'string'
|
|
241
|
+
) {
|
|
242
|
+
parts.push(detail.summary);
|
|
243
|
+
}
|
|
244
|
+
// reasoning.encrypted → intentionally skipped (never surfaced as reasoning)
|
|
245
|
+
}
|
|
246
|
+
return parts.join('');
|
|
247
|
+
}
|
|
174
248
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
249
|
+
/**
|
|
250
|
+
* Map a Converse reasoning_effort level to OpenRouter's `reasoning` request
|
|
251
|
+
* field, driven by the model's structured `reasoning` metadata. Capability-gated:
|
|
252
|
+
* returns null (no field) unless the resolved modelConfig indicates reasoning
|
|
253
|
+
* support, so unknown/retired pass-through IDs never receive reasoning params.
|
|
254
|
+
*
|
|
255
|
+
* 1. passthrough (openrouter/auto) → null (router decides)
|
|
256
|
+
* 2. mandatory (kimi-k2.7-code) → { enabled: true } (cannot disable)
|
|
257
|
+
* 3. effort-tiered (glm/deepseek) → clamp into supported_efforts
|
|
258
|
+
* (max→xhigh, else→high; none→disabled)
|
|
259
|
+
* 4. enable/disable-only (qwen/kimi) → { enabled: false } for none, else true
|
|
260
|
+
* 5. unavailable metadata → null (omit conservatively)
|
|
261
|
+
*
|
|
262
|
+
* `none` uses `{ enabled: false }` where disabling is allowed — never
|
|
263
|
+
* `exclude: true` (exclude still reasons, just hides it).
|
|
264
|
+
* @param {object} modelConfig
|
|
265
|
+
* @param {string} reasoningEffort - Raw Converse level
|
|
266
|
+
* @returns {object|null}
|
|
267
|
+
*/
|
|
268
|
+
function buildOpenRouterReasoning(modelConfig, reasoningEffort) {
|
|
269
|
+
if (!modelConfig?.supportsReasoning) return null;
|
|
270
|
+
const reasoning = modelConfig.reasoning;
|
|
271
|
+
if (!reasoning) return null;
|
|
272
|
+
|
|
273
|
+
if (reasoning.passthrough) return null;
|
|
274
|
+
if (reasoning.mandatory) return { enabled: true };
|
|
275
|
+
|
|
276
|
+
const level = reasoningEffort || 'medium';
|
|
277
|
+
|
|
278
|
+
if (
|
|
279
|
+
Array.isArray(reasoning.supported_efforts) &&
|
|
280
|
+
reasoning.supported_efforts.length > 0
|
|
281
|
+
) {
|
|
282
|
+
if (level === 'none') return { enabled: false };
|
|
283
|
+
const wanted = level === 'max' ? 'xhigh' : 'high';
|
|
284
|
+
const efforts = reasoning.supported_efforts;
|
|
285
|
+
const effort = efforts.includes(wanted)
|
|
286
|
+
? wanted
|
|
287
|
+
: efforts.includes('high')
|
|
288
|
+
? 'high'
|
|
289
|
+
: efforts[0];
|
|
290
|
+
return { effort };
|
|
180
291
|
}
|
|
181
292
|
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
293
|
+
// Enable/disable-only.
|
|
294
|
+
if (level === 'none') return { enabled: false };
|
|
295
|
+
return { enabled: true };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Transform request: attach the metadata-driven, capability-gated `reasoning`
|
|
300
|
+
* field. The requested effort arrives via the Foundation-widened context.
|
|
301
|
+
*/
|
|
302
|
+
async function transformRequest(requestPayload, { modelConfig, reasoningEffort }) {
|
|
303
|
+
const transformed = { ...requestPayload };
|
|
304
|
+
|
|
305
|
+
const reasoning = buildOpenRouterReasoning(modelConfig, reasoningEffort);
|
|
306
|
+
if (reasoning) {
|
|
307
|
+
transformed.reasoning = reasoning;
|
|
186
308
|
}
|
|
187
309
|
|
|
188
310
|
return transformed;
|
|
189
311
|
}
|
|
190
312
|
|
|
191
313
|
/**
|
|
192
|
-
* Transform response
|
|
314
|
+
* Transform response (non-streaming): capture OpenRouter-specific metadata.
|
|
315
|
+
* Usage cost is automatic now — read `usage.cost` and `usage.cost_details`
|
|
316
|
+
* (the legacy `prompt_cost`/`completion_cost`/`total_cost` fields do not exist).
|
|
317
|
+
* Preserve the top-level upstream `provider`, the request id, typed
|
|
318
|
+
* `reasoning_details` (with a visible-text projection), and `url_citation`
|
|
319
|
+
* annotations. In streaming these ride the streaming metadataPatch instead (the
|
|
320
|
+
* synthetic streaming rawResponse lacks the message body).
|
|
193
321
|
*/
|
|
194
322
|
async function transformResponse(result, rawResponse) {
|
|
195
|
-
// OpenRouter adds additional metadata
|
|
196
323
|
if (rawResponse.id) {
|
|
197
324
|
result.metadata.request_id = rawResponse.id;
|
|
198
325
|
}
|
|
199
326
|
|
|
200
|
-
|
|
201
|
-
if (
|
|
202
|
-
if (
|
|
203
|
-
result.metadata.
|
|
204
|
-
}
|
|
205
|
-
if (rawResponse.usage.completion_cost) {
|
|
206
|
-
result.metadata.completion_cost = rawResponse.usage.completion_cost;
|
|
327
|
+
const usage = rawResponse.usage;
|
|
328
|
+
if (usage) {
|
|
329
|
+
if (typeof usage.cost === 'number') {
|
|
330
|
+
result.metadata.cost = usage.cost;
|
|
207
331
|
}
|
|
208
|
-
if (
|
|
209
|
-
result.metadata.
|
|
332
|
+
if (usage.cost_details) {
|
|
333
|
+
result.metadata.cost_details = usage.cost_details;
|
|
210
334
|
}
|
|
211
335
|
}
|
|
212
336
|
|
|
213
|
-
// OpenRouter may return the actual provider used
|
|
214
337
|
if (rawResponse.provider) {
|
|
215
338
|
result.metadata.actual_provider = rawResponse.provider;
|
|
216
339
|
}
|
|
217
340
|
|
|
341
|
+
const message = rawResponse.choices?.[0]?.message;
|
|
342
|
+
if (message) {
|
|
343
|
+
if (
|
|
344
|
+
Array.isArray(message.reasoning_details) &&
|
|
345
|
+
message.reasoning_details.length > 0
|
|
346
|
+
) {
|
|
347
|
+
result.metadata.reasoning_details = message.reasoning_details;
|
|
348
|
+
const reasoningText = extractReasoningText(message.reasoning_details);
|
|
349
|
+
if (reasoningText) {
|
|
350
|
+
result.metadata.reasoning = reasoningText;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (Array.isArray(message.annotations) && message.annotations.length > 0) {
|
|
354
|
+
const citations = message.annotations.filter(
|
|
355
|
+
(annotation) => annotation?.type === 'url_citation',
|
|
356
|
+
);
|
|
357
|
+
if (citations.length > 0) {
|
|
358
|
+
result.metadata.citations = citations;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
218
363
|
return result;
|
|
219
364
|
}
|
|
220
365
|
|
|
221
366
|
/**
|
|
222
|
-
*
|
|
367
|
+
* Per-chunk streaming hook. Emits streamed reasoning as `thinking` events (so it
|
|
368
|
+
* survives the normalizer), accumulates typed reasoning_details / `url_citation`
|
|
369
|
+
* annotations / cost / upstream provider / request id into the final metadata,
|
|
370
|
+
* and terminates the stream as FAILED on an in-band SSE error — detected
|
|
371
|
+
* independently as a top-level `error` object OR `finish_reason: "error"` —
|
|
372
|
+
* while leaving already-emitted deltas intact.
|
|
373
|
+
* @param {object} chunk - Parsed SSE chunk
|
|
374
|
+
* @param {object} streamState - Persistent per-stream scratch object
|
|
375
|
+
* @returns {{events: Array, metadataPatch: (object|null), suppressDefault: boolean, terminalError: (object|null)}}
|
|
223
376
|
*/
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
377
|
+
function transformStreamChunk(chunk, streamState) {
|
|
378
|
+
const choice = chunk?.choices?.[0];
|
|
379
|
+
|
|
380
|
+
// In-band SSE errors terminate the stream as failed. Detect a top-level error
|
|
381
|
+
// object and finish_reason==='error' independently.
|
|
382
|
+
if (chunk?.error) {
|
|
383
|
+
return {
|
|
384
|
+
events: [],
|
|
385
|
+
metadataPatch: null,
|
|
386
|
+
suppressDefault: true,
|
|
387
|
+
terminalError: {
|
|
388
|
+
message: chunk.error.message || 'OpenRouter stream error',
|
|
389
|
+
code:
|
|
390
|
+
typeof chunk.error.code === 'string'
|
|
391
|
+
? chunk.error.code
|
|
392
|
+
: 'OPENROUTER_STREAM_ERROR',
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
if (choice?.finish_reason === 'error') {
|
|
397
|
+
return {
|
|
398
|
+
events: [],
|
|
399
|
+
metadataPatch: null,
|
|
400
|
+
suppressDefault: true,
|
|
401
|
+
terminalError: {
|
|
402
|
+
message: 'OpenRouter stream terminated (finish_reason=error)',
|
|
403
|
+
code: 'OPENROUTER_STREAM_ERROR',
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
}
|
|
239
407
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
408
|
+
const events = [];
|
|
409
|
+
|
|
410
|
+
// Streamed reasoning_details → thinking events; accumulate the full typed
|
|
411
|
+
// array for the end metadata. The thinking text carries the reasoning to the
|
|
412
|
+
// normalizer (which accumulates it into metadata.reasoning) — so we do NOT
|
|
413
|
+
// also put a reasoning text string in the metadataPatch (avoids double count).
|
|
414
|
+
const deltaDetails = choice?.delta?.reasoning_details;
|
|
415
|
+
if (Array.isArray(deltaDetails) && deltaDetails.length > 0) {
|
|
416
|
+
if (!streamState.reasoningDetails) streamState.reasoningDetails = [];
|
|
417
|
+
for (const detail of deltaDetails) {
|
|
418
|
+
streamState.reasoningDetails.push(detail);
|
|
419
|
+
}
|
|
420
|
+
const text = extractReasoningText(deltaDetails);
|
|
421
|
+
if (text) {
|
|
422
|
+
events.push({
|
|
423
|
+
type: 'thinking',
|
|
424
|
+
content: text,
|
|
425
|
+
timestamp: new Date().toISOString(),
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Accumulate url_citation annotations, deduped by URL across chunks.
|
|
431
|
+
const deltaAnnotations =
|
|
432
|
+
choice?.delta?.annotations || choice?.message?.annotations;
|
|
433
|
+
if (Array.isArray(deltaAnnotations) && deltaAnnotations.length > 0) {
|
|
434
|
+
if (!streamState.annotations) streamState.annotations = [];
|
|
435
|
+
if (!streamState.citationUrls) streamState.citationUrls = new Set();
|
|
436
|
+
for (const annotation of deltaAnnotations) {
|
|
437
|
+
const url = annotation?.url_citation?.url;
|
|
438
|
+
if (
|
|
439
|
+
annotation?.type === 'url_citation' &&
|
|
440
|
+
url &&
|
|
441
|
+
!streamState.citationUrls.has(url)
|
|
442
|
+
) {
|
|
443
|
+
streamState.citationUrls.add(url);
|
|
444
|
+
streamState.annotations.push(annotation);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const patch = {};
|
|
450
|
+
if (chunk?.usage) {
|
|
451
|
+
if (typeof chunk.usage.cost === 'number') patch.cost = chunk.usage.cost;
|
|
452
|
+
if (chunk.usage.cost_details) patch.cost_details = chunk.usage.cost_details;
|
|
453
|
+
}
|
|
454
|
+
if (chunk?.provider) patch.actual_provider = chunk.provider;
|
|
455
|
+
if (chunk?.id && !streamState.requestIdCaptured) {
|
|
456
|
+
patch.request_id = chunk.id;
|
|
457
|
+
streamState.requestIdCaptured = true;
|
|
458
|
+
}
|
|
459
|
+
// Live references, not snapshots: streamState is per-stream scratch that is
|
|
460
|
+
// never mutated after the stream ends, and only the final merged patch is
|
|
461
|
+
// consumed — re-slicing the growing arrays every chunk would be O(n²).
|
|
462
|
+
if (streamState.reasoningDetails?.length) {
|
|
463
|
+
patch.reasoning_details = streamState.reasoningDetails;
|
|
464
|
+
}
|
|
465
|
+
if (streamState.annotations?.length) {
|
|
466
|
+
patch.citations = streamState.annotations;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
events,
|
|
471
|
+
metadataPatch: Object.keys(patch).length > 0 ? patch : null,
|
|
472
|
+
suppressDefault: false,
|
|
473
|
+
terminalError: null,
|
|
474
|
+
};
|
|
245
475
|
}
|
|
246
476
|
|
|
247
477
|
/**
|
|
248
|
-
*
|
|
478
|
+
* Conservative request-local config for an explicit slug when discovery is
|
|
479
|
+
* transiently unavailable (auth/rate_limit/timeout/malformed). Lets the request
|
|
480
|
+
* proceed with cautious capabilities: reasoning is omitted (capability-gated
|
|
481
|
+
* off), and `supportsImages` is left undefined so images are not hard-blocked
|
|
482
|
+
* during a discovery outage.
|
|
249
483
|
*/
|
|
250
|
-
function
|
|
484
|
+
function createConservativeModelConfig(modelName) {
|
|
251
485
|
return {
|
|
252
486
|
modelName,
|
|
253
487
|
friendlyName: `${modelName} (via OpenRouter)`,
|
|
254
|
-
contextWindow: 8192,
|
|
255
|
-
maxOutputTokens:
|
|
488
|
+
contextWindow: 8192,
|
|
489
|
+
maxOutputTokens: 8192,
|
|
256
490
|
supportsStreaming: true,
|
|
257
|
-
supportsImages: false, // Conservative default
|
|
258
|
-
supportsTemperature: true,
|
|
259
491
|
supportsWebSearch: false,
|
|
260
492
|
timeout: 300000,
|
|
261
|
-
|
|
262
|
-
isDynamic: true, // Flag to identify dynamic models
|
|
493
|
+
isDynamic: true,
|
|
263
494
|
};
|
|
264
495
|
}
|
|
265
496
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
return allModels;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
return staticModels;
|
|
284
|
-
};
|
|
285
|
-
|
|
286
|
-
// Create an async version of getModelConfig for API fetching
|
|
287
|
-
openrouterProvider.getModelConfigAsync = async function (modelName) {
|
|
288
|
-
// First check static models
|
|
289
|
-
const staticConfig = this.getModelConfig(modelName);
|
|
290
|
-
if (staticConfig && !staticConfig.isDynamic) {
|
|
291
|
-
return staticConfig;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// Check if already in dynamic models cache
|
|
295
|
-
if (dynamicModels.has(modelName)) {
|
|
296
|
-
return dynamicModels.get(modelName);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// If dynamic models are enabled and model follows format, fetch from API
|
|
300
|
-
const config = this._lastConfig || {};
|
|
301
|
-
const dynamicModelsEnabled =
|
|
302
|
-
config?.providers?.openrouterdynamicmodels ||
|
|
303
|
-
config?.providers?.openrouterDynamicModels;
|
|
304
|
-
if (dynamicModelsEnabled && isOpenRouterModelFormat(modelName)) {
|
|
305
|
-
debugLog(`[OpenRouter] Fetching dynamic model config for: ${modelName}`);
|
|
306
|
-
|
|
307
|
-
// Fetch from API with caching
|
|
308
|
-
const apiConfig = await fetchModelEndpointsWithCache(modelName);
|
|
309
|
-
|
|
310
|
-
if (apiConfig) {
|
|
311
|
-
// Store in dynamic models cache
|
|
312
|
-
dynamicModels.set(modelName, apiConfig);
|
|
313
|
-
return apiConfig;
|
|
314
|
-
} else {
|
|
315
|
-
// Model not found on API, create default config to avoid repeated lookups
|
|
316
|
-
const defaultConfig = createDynamicModelConfig(modelName);
|
|
317
|
-
defaultConfig.notFoundOnApi = true;
|
|
318
|
-
dynamicModels.set(modelName, defaultConfig);
|
|
319
|
-
return defaultConfig;
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
return null;
|
|
324
|
-
};
|
|
497
|
+
/**
|
|
498
|
+
* Request-local model-config resolver. Static curated
|
|
499
|
+
* models are authoritative and never trigger discovery. An explicit non-curated
|
|
500
|
+
* `provider/model` slug is looked up through the discovery adapter:
|
|
501
|
+
* - ok → use the discovered metadata;
|
|
502
|
+
* - catalog_miss → throw MODEL_NOT_FOUND (fails before inference);
|
|
503
|
+
* - transient → proceed with conservative capabilities.
|
|
504
|
+
* Dynamic metadata stays request-local and is never merged into
|
|
505
|
+
* getSupportedModels().
|
|
506
|
+
*/
|
|
507
|
+
async function resolveModelConfig(resolvedModel, { signal }) {
|
|
508
|
+
// A `:free`-style decoration may ride on the request model; discovery/static
|
|
509
|
+
// lookup uses the bare base slug.
|
|
510
|
+
const base = String(resolvedModel).split(':')[0];
|
|
325
511
|
|
|
326
|
-
const
|
|
327
|
-
openrouterProvider.getModelConfig = function (modelName) {
|
|
328
|
-
// First check static models
|
|
329
|
-
const staticConfig = originalGetModelConfig.call(this, modelName);
|
|
512
|
+
const staticConfig = SUPPORTED_MODELS[base];
|
|
330
513
|
if (staticConfig) {
|
|
331
514
|
return staticConfig;
|
|
332
515
|
}
|
|
333
516
|
|
|
334
|
-
//
|
|
335
|
-
|
|
336
|
-
|
|
517
|
+
// Rolling aliases (`~author/model-latest`) are explicit opt-in pass-through
|
|
518
|
+
// values: OpenRouter resolves them server-side and they never appear under
|
|
519
|
+
// that name in the bulk catalog, so validating them through discovery would
|
|
520
|
+
// wrongly fail them as absent. Proceed with conservative capabilities and let
|
|
521
|
+
// the API resolve the target.
|
|
522
|
+
if (base.startsWith('~')) {
|
|
523
|
+
return createConservativeModelConfig(base);
|
|
337
524
|
}
|
|
338
525
|
|
|
339
|
-
//
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
config?.providers?.openrouterDynamicModels;
|
|
344
|
-
|
|
345
|
-
// Only allow dynamic models if explicitly enabled AND model has slash format
|
|
346
|
-
if (dynamicModelsEnabled && isOpenRouterModelFormat(modelName)) {
|
|
347
|
-
// Note: This is a fallback for synchronous calls
|
|
348
|
-
// The async version should be preferred for accurate model info
|
|
349
|
-
const dynamicConfig = createDynamicModelConfig(modelName);
|
|
350
|
-
dynamicConfig.needsApiUpdate = true;
|
|
351
|
-
return dynamicConfig;
|
|
526
|
+
// Not a slash-format slug: nothing to discover; the base falls back to an
|
|
527
|
+
// empty config (unknown-ID passthrough).
|
|
528
|
+
if (!base.includes('/')) {
|
|
529
|
+
return null;
|
|
352
530
|
}
|
|
353
531
|
|
|
354
|
-
|
|
355
|
-
// This will cause the model to be rejected as not found
|
|
356
|
-
return null;
|
|
357
|
-
};
|
|
532
|
+
const { status, modelConfig } = await lookupOpenRouterModel(base, { signal });
|
|
358
533
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
this._lastConfig = options.config;
|
|
364
|
-
|
|
365
|
-
// Validate referer configuration
|
|
366
|
-
// Handle both camelCase (from tests) and lowercase (from config.js) keys
|
|
367
|
-
if (
|
|
368
|
-
!options.config?.providers?.openrouterreferer &&
|
|
369
|
-
!options.config?.providers?.openrouterReferer
|
|
370
|
-
) {
|
|
534
|
+
if (status === DiscoveryStatus.OK && modelConfig) {
|
|
535
|
+
return modelConfig;
|
|
536
|
+
}
|
|
537
|
+
if (status === DiscoveryStatus.CATALOG_MISS) {
|
|
371
538
|
throw new OpenRouterProviderError(
|
|
372
|
-
'
|
|
373
|
-
ErrorCodes.
|
|
539
|
+
`Model '${base}' was not found in the OpenRouter catalog`,
|
|
540
|
+
ErrorCodes.MODEL_NOT_FOUND,
|
|
374
541
|
);
|
|
375
542
|
}
|
|
376
543
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
// If model not found and has slash format, check if dynamic models are enabled
|
|
383
|
-
if (!existingConfig && isOpenRouterModelFormat(modelName)) {
|
|
384
|
-
const dynamicModelsEnabled =
|
|
385
|
-
options.config?.providers?.openrouterdynamicmodels ||
|
|
386
|
-
options.config?.providers?.openrouterDynamicModels;
|
|
387
|
-
if (!dynamicModelsEnabled) {
|
|
388
|
-
throw new OpenRouterProviderError(
|
|
389
|
-
`Model '${modelName}' requires OPENROUTER_DYNAMIC_MODELS=true to be set`,
|
|
390
|
-
ErrorCodes.MODEL_NOT_FOUND,
|
|
391
|
-
);
|
|
392
|
-
}
|
|
393
|
-
}
|
|
544
|
+
debugLog(
|
|
545
|
+
`[OpenRouter] Discovery unavailable for ${base} (${status}); proceeding with conservative capabilities`,
|
|
546
|
+
);
|
|
547
|
+
return createConservativeModelConfig(base);
|
|
548
|
+
}
|
|
394
549
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
550
|
+
/**
|
|
551
|
+
* Create OpenRouter provider using OpenAI-compatible base
|
|
552
|
+
*/
|
|
553
|
+
export const openrouterProvider = createOpenAICompatibleProvider({
|
|
554
|
+
baseURL: 'https://openrouter.ai/api/v1',
|
|
555
|
+
providerName: 'OpenRouter',
|
|
556
|
+
supportedModels: SUPPORTED_MODELS,
|
|
557
|
+
validateApiKey,
|
|
558
|
+
transformRequest,
|
|
559
|
+
transformResponse,
|
|
560
|
+
transformStreamChunk,
|
|
561
|
+
resolveModelConfig,
|
|
562
|
+
customHeaders: {}, // Attribution headers are dynamic, injected via invoke override
|
|
563
|
+
defaultParams: {
|
|
564
|
+
top_p: 1,
|
|
565
|
+
frequency_penalty: 0,
|
|
566
|
+
presence_penalty: 0,
|
|
567
|
+
},
|
|
568
|
+
});
|
|
406
569
|
|
|
407
|
-
|
|
570
|
+
// Override invoke to inject optional attribution headers and translate the
|
|
571
|
+
// resolver's `:online` web-search opt-in into an OpenRouter `web` plugin. All
|
|
572
|
+
// dynamic-model handling lives in the resolveModelConfig hook above.
|
|
573
|
+
const originalInvoke = openrouterProvider.invoke;
|
|
574
|
+
openrouterProvider.invoke = async function (messages, options = {}) {
|
|
408
575
|
const modifiedOptions = {
|
|
409
576
|
...options,
|
|
410
577
|
config: {
|
|
411
578
|
...options.config,
|
|
412
|
-
// Inject custom headers into the provider config
|
|
413
579
|
providers: {
|
|
414
|
-
...options.config
|
|
580
|
+
...options.config?.providers,
|
|
415
581
|
_customHeaders: getCustomHeaders(options.config),
|
|
416
582
|
},
|
|
417
583
|
},
|
|
418
584
|
};
|
|
419
585
|
|
|
420
|
-
//
|
|
586
|
+
// Web search is strictly opt-in: the shared resolver sets options.web_search
|
|
587
|
+
// from a parsed `:online` decoration. Attach the web plugin exactly once
|
|
588
|
+
// (never both a `:online` slug and a plugin). Ordinary requests attach
|
|
589
|
+
// nothing — no plugin, no `:online`, no web-search option.
|
|
590
|
+
if (options.web_search) {
|
|
591
|
+
const existing = Array.isArray(options.plugins) ? options.plugins : [];
|
|
592
|
+
modifiedOptions.plugins = [...existing, { id: 'web' }];
|
|
593
|
+
}
|
|
594
|
+
|
|
421
595
|
return originalInvoke.call(this, messages, modifiedOptions);
|
|
422
596
|
};
|
|
423
|
-
|
|
424
|
-
// Note: The base module needs to be updated to use _customHeaders if present
|
|
425
|
-
// This is a temporary workaround - in production, the openai-compatible.js
|
|
426
|
-
// should be updated to accept a function for customHeaders
|