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
@@ -1,121 +1,177 @@
1
1
  /**
2
2
  * OpenRouter Provider
3
3
  *
4
- * Provider implementation for OpenRouter's unified API gateway using OpenAI-compatible API.
5
- * Implements the unified interface: async invoke(messages, options) => { content, stop_reason, rawResponse }
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
- * OpenRouter provides access to multiple AI models through a single API endpoint.
8
- * IMPORTANT: Requires HTTP-Referer header for compliance tracking.
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 { fetchModelEndpointsWithCache } from './openrouter-endpoints-client.js';
15
-
16
- // Define supported OpenRouter models with their capabilities
17
- // Only including the three specific models requested
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
- 'qwen/qwen3-235b-a22b-thinking-2507': {
20
- modelName: 'qwen/qwen3-235b-a22b-thinking-2507',
21
- friendlyName: 'Qwen3 235B Thinking (via OpenRouter)',
22
- contextWindow: 32768,
23
- maxOutputTokens: 8192,
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
- supportsThinking: true,
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
- 'Qwen3 235B Thinking model with enhanced reasoning capabilities',
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
- 'qwen/qwen3-coder': {
41
- modelName: 'qwen/qwen3-coder',
42
- friendlyName: 'Qwen3 Coder (via OpenRouter)',
43
- contextWindow: 32768,
44
- maxOutputTokens: 8192,
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: 'Qwen3 Coder specialized for programming tasks',
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
- 'moonshotai/kimi-k2': {
60
- modelName: 'moonshotai/kimi-k2',
61
- friendlyName: 'Kimi K2 (via OpenRouter)',
62
- contextWindow: 200000,
63
- maxOutputTokens: 8192,
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: 'Moonshot AI Kimi K2 with extended context window',
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
- 'openrouter/auto': {
81
- modelName: 'openrouter/auto',
82
- friendlyName: 'OpenRouter Auto (via NotDiamond)',
83
- contextWindow: 128000, // Safe default for auto-routing
84
- maxOutputTokens: 8192, // Safe default
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, // Conservative default
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
- 'Auto-selects the best model for your prompt using NotDiamond routing',
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
- 'z-ai/glm-4.6': {
100
- modelName: 'z-ai/glm-4.6',
101
- friendlyName: 'Z.AI GLM 4.6 (via OpenRouter)',
102
- contextWindow: 202752,
103
- maxOutputTokens: 8192,
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: false,
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
- 'Z.AI GLM 4.6 with 200K context - improved coding, reasoning, and agent performance',
111
- aliases: [
112
- 'glm-4.6',
113
- 'glm4.6',
114
- 'glm 4.6',
115
- 'z-ai glm',
116
- 'z-ai-glm',
117
- 'zai-glm',
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
- * Get custom headers for OpenRouter
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
- 'https://github.com/FallDownTheSystem/converse';
154
- headers['HTTP-Referer'] = referer;
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
- * Transform request to handle OpenRouter-specific requirements
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
- async function transformRequest(requestPayload, { modelConfig }) {
172
- // OpenRouter supports additional parameters
173
- const transformed = { ...requestPayload };
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
- // Ensure model name includes provider prefix if not already present
176
- if (!transformed.model.includes('/')) {
177
- debugLog(
178
- `[OpenRouter] Warning: Model name '${transformed.model}' should include provider prefix (e.g., 'anthropic/claude-3.5-sonnet')`,
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
- // OpenRouter supports provider-specific parameters through 'provider' field
183
- // This is useful for passing model-specific settings
184
- if (modelConfig.providerSettings) {
185
- transformed.provider = modelConfig.providerSettings;
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 to handle OpenRouter-specific fields
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
- // OpenRouter provides pricing information
201
- if (rawResponse.usage) {
202
- if (rawResponse.usage.prompt_cost) {
203
- result.metadata.prompt_cost = rawResponse.usage.prompt_cost;
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 (rawResponse.usage.total_cost) {
209
- result.metadata.total_cost = rawResponse.usage.total_cost;
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
- * Create OpenRouter provider using OpenAI-compatible base
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
- export const openrouterProvider = createOpenAICompatibleProvider({
225
- baseURL: 'https://openrouter.ai/api/v1',
226
- providerName: 'OpenRouter',
227
- supportedModels: SUPPORTED_MODELS,
228
- validateApiKey,
229
- transformRequest,
230
- transformResponse,
231
- customHeaders: {}, // Headers are dynamic, set via getCustomHeaders
232
- defaultParams: {
233
- // OpenRouter default parameters
234
- top_p: 1,
235
- frequency_penalty: 0,
236
- presence_penalty: 0,
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
- * Check if a model string follows OpenRouter's provider/model format
242
- */
243
- function isOpenRouterModelFormat(modelName) {
244
- return typeof modelName === 'string' && modelName.includes('/');
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
- * Create a dynamic model configuration from minimal information
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 createDynamicModelConfig(modelName) {
484
+ function createConservativeModelConfig(modelName) {
251
485
  return {
252
486
  modelName,
253
487
  friendlyName: `${modelName} (via OpenRouter)`,
254
- contextWindow: 8192, // Safe default
255
- maxOutputTokens: 4096, // Safe default
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
- description: `Dynamic model: ${modelName}`,
262
- isDynamic: true, // Flag to identify dynamic models
493
+ isDynamic: true,
263
494
  };
264
495
  }
265
496
 
266
- // Store for dynamically discovered models
267
- const dynamicModels = new Map();
268
-
269
- // Override methods to support dynamic models
270
- const originalGetSupportedModels = openrouterProvider.getSupportedModels;
271
- openrouterProvider.getSupportedModels = function () {
272
- const staticModels = originalGetSupportedModels.call(this);
273
-
274
- // Merge dynamic models if any exist
275
- if (dynamicModels.size > 0) {
276
- const allModels = { ...staticModels };
277
- for (const [modelName, config] of dynamicModels) {
278
- allModels[modelName] = config;
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 originalGetModelConfig = openrouterProvider.getModelConfig;
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
- // Check dynamic models cache
335
- if (dynamicModels.has(modelName)) {
336
- return dynamicModels.get(modelName);
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
- // Check if dynamic models are enabled
340
- const config = this._lastConfig || {};
341
- const dynamicModelsEnabled =
342
- config?.providers?.openrouterdynamicmodels ||
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
- // If model has slash format but dynamic models disabled, return null
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
- // Override the invoke method to add dynamic headers and model support
360
- const originalInvoke = openrouterProvider.invoke;
361
- openrouterProvider.invoke = async function (messages, options = {}) {
362
- // Store config for use in getModelConfig
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
- 'OpenRouter requires HTTP-Referer header. Please set OPENROUTER_REFERER in your environment',
373
- ErrorCodes.INVALID_REQUEST,
539
+ `Model '${base}' was not found in the OpenRouter catalog`,
540
+ ErrorCodes.MODEL_NOT_FOUND,
374
541
  );
375
542
  }
376
543
 
377
- // Check if we need to fetch dynamic model config
378
- const modelName = options.model;
379
- if (modelName) {
380
- const existingConfig = this.getModelConfig(modelName);
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
- // If the model needs API update, fetch it now
396
- if (existingConfig?.needsApiUpdate) {
397
- const dynamicModelsEnabled =
398
- options.config?.providers?.openrouterdynamicmodels ||
399
- options.config?.providers?.openrouterDynamicModels;
400
- if (dynamicModelsEnabled) {
401
- debugLog(`[OpenRouter] Fetching API config for model: ${modelName}`);
402
- await this.getModelConfigAsync(modelName);
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
- // Create a modified config with custom headers
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.providers,
580
+ ...options.config?.providers,
415
581
  _customHeaders: getCustomHeaders(options.config),
416
582
  },
417
583
  },
418
584
  };
419
585
 
420
- // Call original invoke with modified options
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