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
@@ -32,11 +32,9 @@
32
32
  * Options for provider invocation
33
33
  * @typedef {Object} InvokeOptions
34
34
  * @property {string} [model] - Model identifier (provider-specific)
35
- * @property {number} [temperature] - Sampling temperature (0.0-2.0)
36
35
  * @property {number} [maxTokens] - Maximum tokens to generate
37
36
  * @property {boolean} [stream] - Whether to stream the response
38
37
  * @property {string} [reasoning_effort] - Reasoning depth for thinking models
39
- * @property {boolean} [use_websearch] - Enable web search if supported
40
38
  * @property {AbortSignal} [signal] - AbortSignal for cancelling requests
41
39
  * @property {Object} config - Provider configuration
42
40
  * @property {Object} config.apiKeys - API keys for providers
@@ -69,7 +67,6 @@
69
67
  * @property {number} maxOutputTokens - Maximum output tokens
70
68
  * @property {boolean} supportsStreaming - Whether streaming is supported
71
69
  * @property {boolean} supportsImages - Whether images are supported
72
- * @property {boolean} supportsTemperature - Whether temperature is supported
73
70
  * @property {boolean} [supportsWebSearch] - Whether web search is supported
74
71
  * @property {boolean} [supportsThinking] - Whether thinking/reasoning is supported
75
72
  * @property {number} [maxThinkingTokens] - Maximum thinking tokens
@@ -8,73 +8,141 @@
8
8
  import { debugLog, debugError } from '../utils/console.js';
9
9
  import { ProviderError, ErrorCodes, StopReasons } from './interface.js';
10
10
 
11
- // Define supported Mistral models with their capabilities
11
+ // Define supported Mistral models with their capabilities.
12
+ // Reasoning (`reasoning_effort`) is supported only on Medium 3.5 and Small 4;
13
+ // Mistral Large 3 has no adjustable reasoning (see resolveReasoningEffort).
12
14
  const SUPPORTED_MODELS = {
13
- 'magistral-medium-2509': {
14
- modelName: 'magistral-medium-2509',
15
- friendlyName: 'Magistral Medium 1.2',
16
- contextWindow: 128000,
15
+ 'mistral-medium-3-5': {
16
+ modelName: 'mistral-medium-3-5',
17
+ friendlyName: 'Mistral Medium 3.5',
18
+ contextWindow: 262144,
17
19
  maxOutputTokens: 32768,
18
20
  supportsStreaming: true,
19
- supportsImages: true, // Version 1.2 adds vision support
20
- supportsTemperature: true,
21
+ supportsImages: true,
21
22
  supportsWebSearch: false,
22
23
  supportsReasoning: true,
23
24
  timeout: 300000,
24
25
  description:
25
- 'Magistral Medium 1.2 - Frontier-class reasoning model with vision support (September 2025)',
26
+ 'Mistral Medium 3.5 - Frontier-class multimodal model with adjustable reasoning',
26
27
  aliases: [
27
- 'magistral-medium',
28
- 'magistral-medium-latest',
29
- 'magistral',
30
- 'magistral medium',
31
- 'magistral-medium-1.2',
28
+ 'mistral',
29
+ 'mistral-medium',
30
+ 'mistral-medium-latest',
31
+ 'mistral-medium-3.5',
32
32
  ],
33
33
  },
34
- 'magistral-small-2509': {
35
- modelName: 'magistral-small-2509',
36
- friendlyName: 'Magistral Small 1.2',
37
- contextWindow: 128000,
34
+ 'mistral-small-2603': {
35
+ modelName: 'mistral-small-2603',
36
+ friendlyName: 'Mistral Small 4',
37
+ contextWindow: 262144,
38
38
  maxOutputTokens: 32768,
39
39
  supportsStreaming: true,
40
- supportsImages: true, // Version 1.2 adds vision support
41
- supportsTemperature: true,
40
+ supportsImages: true,
42
41
  supportsWebSearch: false,
43
42
  supportsReasoning: true,
44
43
  timeout: 180000,
45
44
  description:
46
- 'Magistral Small 1.2 - Small reasoning model with vision support (September 2025)',
47
- aliases: [
48
- 'magistral-small',
49
- 'magistral-small-latest',
50
- 'magistral small',
51
- 'magistral-small-1.2',
52
- ],
45
+ 'Mistral Small 4 - Hybrid multimodal model unifying instruct, reasoning, and coding',
46
+ aliases: ['mistral-small', 'mistral-small-latest'],
53
47
  },
54
- 'mistral-medium-2508': {
55
- modelName: 'mistral-medium-2508',
56
- friendlyName: 'Mistral Medium 3.1',
57
- contextWindow: 128000,
48
+ 'mistral-large-2512': {
49
+ modelName: 'mistral-large-2512',
50
+ friendlyName: 'Mistral Large 3',
51
+ contextWindow: 262144,
58
52
  maxOutputTokens: 32768,
59
53
  supportsStreaming: true,
60
54
  supportsImages: true,
61
- supportsTemperature: true,
62
55
  supportsWebSearch: false,
56
+ supportsReasoning: false,
63
57
  timeout: 300000,
64
58
  description:
65
- 'Mistral Medium 3.1 - Frontier-class multimodal model with improved tone and performance (August 2025)',
66
- aliases: [
67
- 'mistral-medium-3.1',
68
- 'mistral-medium-latest',
69
- 'mistral-medium',
70
- 'mistral medium 3.1',
71
- 'mistral',
72
- 'medium-3.1',
73
- 'mistral-medium-3',
74
- ],
59
+ 'Mistral Large 3 - Open-weight MoE flagship (no adjustable reasoning)',
60
+ aliases: ['mistral-large', 'mistral-large-latest'],
75
61
  },
76
62
  };
77
63
 
64
+ /**
65
+ * Map a Converse reasoning_effort level to Mistral's documented request value.
66
+ * Mistral documents only "high" and "none". Every enabled level maps to "high"
67
+ * to preserve enabled-reasoning intent (Converse's default `medium` runs
68
+ * thinking-on); only `none` disables. Returns null when reasoning must not be
69
+ * forwarded — i.e. the model does not support it (Large 3) or is an unknown
70
+ * pass-through ID (capability-gated).
71
+ */
72
+ function resolveReasoningEffort(level, modelConfig) {
73
+ if (!modelConfig?.supportsReasoning) {
74
+ return null;
75
+ }
76
+ return level === 'none' ? 'none' : 'high';
77
+ }
78
+
79
+ /**
80
+ * Walk a Mistral `message.content` array, separating reasoning (ThinkChunk)
81
+ * from the visible answer (TextChunk). A ReferenceChunk carries citation
82
+ * metadata only (reference_ids, no visible text) and is ignored for text.
83
+ * Any other content-bearing chunk type is unexpected and raises.
84
+ * @returns {{ text: string, reasoning: string }}
85
+ */
86
+ function normalizeContentChunks(content) {
87
+ let text = '';
88
+ let reasoning = '';
89
+
90
+ for (const chunk of content) {
91
+ const type = chunk?.type;
92
+ if (type === 'thinking') {
93
+ for (const inner of chunk.thinking || []) {
94
+ if (inner?.type === 'text') {
95
+ reasoning += inner.text || '';
96
+ }
97
+ }
98
+ } else if (type === 'text') {
99
+ text += chunk.text || '';
100
+ } else if (type === 'reference') {
101
+ // Citation metadata (reference_ids) — no visible text; ignore.
102
+ } else {
103
+ throw new MistralProviderError(
104
+ `Unexpected Mistral content chunk type "${type}"`,
105
+ ErrorCodes.API_ERROR,
106
+ );
107
+ }
108
+ }
109
+
110
+ return { text, reasoning };
111
+ }
112
+
113
+ /**
114
+ * Convert one streaming `delta.content` array into ordered normalized events.
115
+ * ThinkChunk → thinking, TextChunk → delta, ReferenceChunk → ignored. Unknown
116
+ * chunk types in a streamed delta are skipped (non-fatal) rather than aborting
117
+ * an in-flight stream.
118
+ * @returns {Array<{ kind: 'delta'|'thinking', text: string }>}
119
+ */
120
+ function streamEventsFromDelta(delta) {
121
+ const events = [];
122
+
123
+ for (const chunk of delta) {
124
+ const type = chunk?.type;
125
+ if (type === 'thinking') {
126
+ let thinkText = '';
127
+ for (const inner of chunk.thinking || []) {
128
+ if (inner?.type === 'text') {
129
+ thinkText += inner.text || '';
130
+ }
131
+ }
132
+ if (thinkText) {
133
+ events.push({ kind: 'thinking', text: thinkText });
134
+ }
135
+ } else if (type === 'text') {
136
+ if (chunk.text) {
137
+ events.push({ kind: 'delta', text: chunk.text });
138
+ }
139
+ }
140
+ // ReferenceChunk and any unknown chunk type carry no visible delta text.
141
+ }
142
+
143
+ return events;
144
+ }
145
+
78
146
  /**
79
147
  * Map Mistral finish reasons to unified format
80
148
  */
@@ -256,16 +324,13 @@ export const mistralProvider = {
256
324
  */
257
325
  async invoke(messages, options = {}) {
258
326
  const {
259
- model = 'magistral-medium-2509',
260
- temperature = 0.7,
327
+ model = 'mistral-medium-3-5',
261
328
  maxTokens = null,
262
329
  stream = false,
263
- // eslint-disable-next-line no-unused-vars
264
- reasoning_effort = 'medium', // Not supported by Mistral, ignored
265
- // eslint-disable-next-line no-unused-vars
266
- use_websearch = false, // Not supported by Mistral, ignored
330
+ reasoning_effort = 'medium',
267
331
  config,
268
332
  // Filter out options not meant for the API
333
+ web_search, // eslint-disable-line no-unused-vars
269
334
  continuation_id, // eslint-disable-line no-unused-vars
270
335
  continuationStore, // eslint-disable-line no-unused-vars
271
336
  ...otherOptions
@@ -323,9 +388,11 @@ export const mistralProvider = {
323
388
  ...otherOptions,
324
389
  };
325
390
 
326
- // Add temperature if specified
327
- if (temperature !== undefined) {
328
- requestPayload.temperature = Math.max(0, Math.min(1, temperature));
391
+ // Forward reasoning effort only when the resolved model supports it
392
+ // (Mistral Large 3 and unknown pass-through IDs receive no reasoning param).
393
+ const reasoningEffort = resolveReasoningEffort(reasoning_effort, modelConfig);
394
+ if (reasoningEffort) {
395
+ requestPayload.reasoning_effort = reasoningEffort;
329
396
  }
330
397
 
331
398
  // Add max tokens if specified
@@ -370,8 +437,24 @@ export const mistralProvider = {
370
437
  );
371
438
  }
372
439
 
373
- const content = choice.message?.content;
374
- if (!content) {
440
+ // Content may be a plain string or an array of ThinkChunk/TextChunk/
441
+ // ReferenceChunk (when reasoning_effort="high" or citations are used).
442
+ const rawContent = choice.message?.content;
443
+ let content = '';
444
+ let reasoningContent = '';
445
+ if (typeof rawContent === 'string') {
446
+ content = rawContent;
447
+ } else if (Array.isArray(rawContent)) {
448
+ const parsed = normalizeContentChunks(rawContent);
449
+ content = parsed.text;
450
+ reasoningContent = parsed.reasoning;
451
+ }
452
+
453
+ // A reasoning turn may carry empty visible content but present reasoning
454
+ // or tool calls — accept those (an empty tool_calls array does not count).
455
+ const toolCalls = choice.message?.tool_calls;
456
+ const hasToolCalls = Array.isArray(toolCalls) && toolCalls.length > 0;
457
+ if (!content && !reasoningContent && !hasToolCalls) {
375
458
  throw new MistralProviderError(
376
459
  'No content in response from Mistral',
377
460
  ErrorCodes.NO_RESPONSE_CONTENT,
@@ -404,6 +487,7 @@ export const mistralProvider = {
404
487
  finish_reason: finishReason,
405
488
  provider: 'mistral',
406
489
  rate_limit: rateLimitInfo,
490
+ ...(reasoningContent && { reasoning_content: reasoningContent }),
407
491
  },
408
492
  };
409
493
  } catch (error) {
@@ -476,14 +560,14 @@ export const mistralProvider = {
476
560
  * @param {Object} mistral - Mistral client instance
477
561
  * @param {Object} requestPayload - Request payload for the API
478
562
  * @param {string} resolvedModel - Resolved model name
479
- * @param {Object} modelConfig - Model configuration
563
+ * @param {Object} _modelConfig - Model configuration (reserved)
480
564
  * @returns {AsyncGenerator} - Streaming generator yielding events
481
565
  */
482
566
  async *_createStreamingGenerator(
483
567
  mistral,
484
568
  requestPayload,
485
569
  resolvedModel,
486
- modelConfig,
570
+ _modelConfig,
487
571
  ) {
488
572
  debugLog(
489
573
  `[Mistral] Starting streaming for ${resolvedModel} with ${requestPayload.messages?.length} messages`,
@@ -512,17 +596,39 @@ export const mistralProvider = {
512
596
  // Mistral wraps the response in a "data" field
513
597
  const chunkData = chunk.data || chunk;
514
598
 
515
- // Extract content from the chunk
599
+ // Extract content from the chunk. delta.content may be a plain
600
+ // string (answer phase / reasoning_effort="none"), a list of
601
+ // ThinkChunks (thinking phase), or a mixed list carrying a closing
602
+ // ThinkChunk plus the first TextChunk in one delta (transition).
516
603
  const choice = chunkData.choices?.[0];
517
604
  if (choice) {
518
- const content = choice.delta?.content || '';
519
- if (content) {
520
- totalContent += content;
521
- yield {
522
- type: 'delta',
523
- content,
524
- timestamp: new Date().toISOString(),
525
- };
605
+ const delta = choice.delta?.content;
606
+ if (typeof delta === 'string') {
607
+ if (delta) {
608
+ totalContent += delta;
609
+ yield {
610
+ type: 'delta',
611
+ content: delta,
612
+ timestamp: new Date().toISOString(),
613
+ };
614
+ }
615
+ } else if (Array.isArray(delta)) {
616
+ for (const event of streamEventsFromDelta(delta)) {
617
+ if (event.kind === 'delta') {
618
+ totalContent += event.text;
619
+ yield {
620
+ type: 'delta',
621
+ content: event.text,
622
+ timestamp: new Date().toISOString(),
623
+ };
624
+ } else {
625
+ yield {
626
+ type: 'thinking',
627
+ content: event.text,
628
+ timestamp: new Date().toISOString(),
629
+ };
630
+ }
631
+ }
526
632
  }
527
633
 
528
634
  // Capture finish reason when available
@@ -270,6 +270,8 @@ export function createOpenAICompatibleProvider(providerConfig) {
270
270
  validateApiKey = defaultValidateApiKey,
271
271
  transformRequest,
272
272
  transformResponse,
273
+ transformStreamChunk,
274
+ resolveModelConfig,
273
275
  defaultParams = {},
274
276
  } = providerConfig;
275
277
 
@@ -288,18 +290,17 @@ export function createOpenAICompatibleProvider(providerConfig) {
288
290
  async invoke(messages, options = {}) {
289
291
  const {
290
292
  model = Object.keys(supportedModels)[0], // Default to first model
291
- temperature = 0.7,
292
293
  maxTokens = null,
293
294
  stream = false,
294
- // eslint-disable-next-line no-unused-vars
295
295
  reasoning_effort = 'medium',
296
- // eslint-disable-next-line no-unused-vars
297
- use_websearch = false,
298
296
  signal,
299
297
  config,
300
298
  // Filter out options not meant for the API
301
299
  continuation_id, // eslint-disable-line no-unused-vars
302
300
  continuationStore, // eslint-disable-line no-unused-vars
301
+ // Consumed by provider invoke overrides (e.g. OpenRouter maps this to a
302
+ // web plugin); never forwarded to the API payload.
303
+ web_search, // eslint-disable-line no-unused-vars
303
304
  ...otherOptions
304
305
  } = options;
305
306
 
@@ -333,9 +334,24 @@ export function createOpenAICompatibleProvider(providerConfig) {
333
334
  },
334
335
  };
335
336
 
336
- // Add timeout if specified in model config
337
+ // Resolve the model config. A provider may supply an async
338
+ // resolveModelConfig hook to obtain a request-local config (e.g. dynamic
339
+ // OpenRouter metadata) — this rides through here and is NEVER merged into
340
+ // getSupportedModels(). It may throw (e.g. an authoritative catalog-miss)
341
+ // to fail before inference.
337
342
  const resolvedModel = resolveModelName(model, supportedModels);
338
- const modelConfig = supportedModels[resolvedModel] || {};
343
+ let modelConfig = supportedModels[resolvedModel] || {};
344
+ if (resolveModelConfig) {
345
+ const dynamicConfig = await resolveModelConfig(resolvedModel, {
346
+ config,
347
+ signal,
348
+ });
349
+ if (dynamicConfig) {
350
+ modelConfig = dynamicConfig;
351
+ }
352
+ }
353
+
354
+ // Add timeout if specified in model config
339
355
  if (modelConfig.timeout) {
340
356
  clientOptions.timeout = modelConfig.timeout;
341
357
  }
@@ -368,15 +384,6 @@ export function createOpenAICompatibleProvider(providerConfig) {
368
384
  ...otherOptions,
369
385
  };
370
386
 
371
- // Add temperature if model supports it and not already set by defaultParams
372
- if (
373
- modelConfig.supportsTemperature !== false &&
374
- temperature !== undefined &&
375
- !defaultParams.temperature
376
- ) {
377
- requestPayload.temperature = Math.max(0, Math.min(2, temperature));
378
- }
379
-
380
387
  // Add max tokens if specified
381
388
  if (maxTokens) {
382
389
  requestPayload.max_tokens = Math.min(
@@ -390,14 +397,16 @@ export function createOpenAICompatibleProvider(providerConfig) {
390
397
  requestPayload.stream_options = { include_usage: true };
391
398
  }
392
399
 
393
- // Note: Most OpenAI-compatible APIs don't support reasoning_effort or use_websearch
394
- // These are silently ignored unless the provider has custom handling
395
-
396
- // Apply custom request transformation if provided
400
+ // Apply custom request transformation if provided. The context exposes the
401
+ // requested reasoning effort and abort signal so providers can build
402
+ // capability-gated reasoning fields; reasoning_effort itself is never
403
+ // forwarded to the API payload (it is destructured out above).
397
404
  if (transformRequest) {
398
405
  requestPayload = await transformRequest(requestPayload, {
399
406
  model: resolvedModel,
400
407
  modelConfig,
408
+ reasoningEffort: reasoning_effort,
409
+ signal,
401
410
  });
402
411
  }
403
412
 
@@ -444,8 +453,26 @@ export function createOpenAICompatibleProvider(providerConfig) {
444
453
  );
445
454
  }
446
455
 
447
- const content = choice.message?.content;
448
- if (!content) {
456
+ // A reasoning turn may carry empty visible content but present
457
+ // reasoning_content / reasoning_details / tool_calls — accept those and
458
+ // normalize nullable content to ''. An empty array does not count as
459
+ // present merely for being truthy.
460
+ const rawContent = choice.message?.content;
461
+ const reasoningContent = choice.message?.reasoning_content;
462
+ const reasoningDetails = choice.message?.reasoning_details;
463
+ const toolCalls = choice.message?.tool_calls;
464
+ const content = typeof rawContent === 'string' ? rawContent : '';
465
+ const hasReasoningContent =
466
+ typeof reasoningContent === 'string' && reasoningContent.length > 0;
467
+ const hasReasoningDetails =
468
+ Array.isArray(reasoningDetails) && reasoningDetails.length > 0;
469
+ const hasToolCalls = Array.isArray(toolCalls) && toolCalls.length > 0;
470
+ if (
471
+ content.length === 0 &&
472
+ !hasReasoningContent &&
473
+ !hasReasoningDetails &&
474
+ !hasToolCalls
475
+ ) {
449
476
  throw new CustomProviderError(
450
477
  'No content in response',
451
478
  ErrorCodes.NO_RESPONSE_CONTENT,
@@ -475,6 +502,7 @@ export function createOpenAICompatibleProvider(providerConfig) {
475
502
  response_time_ms: responseTime,
476
503
  finish_reason: finishReason,
477
504
  provider: providerName.toLowerCase(),
505
+ ...(hasReasoningContent && { reasoning_content: reasoningContent }),
478
506
  },
479
507
  };
480
508
 
@@ -521,6 +549,13 @@ export function createOpenAICompatibleProvider(providerConfig) {
521
549
  let lastUsage = null;
522
550
  let finishReason = null;
523
551
  let finalModel = resolvedModel;
552
+ // Extra metadata accumulated from per-chunk hooks (reasoning_details,
553
+ // annotations, usage.cost/cost_details, upstream provider, request id) that
554
+ // the streaming path's synthetic transformResponse cannot see.
555
+ const streamMetadataPatch = {};
556
+ // Persistent per-stream scratch object handed to transformStreamChunk so a
557
+ // provider can accumulate state (e.g. concatenated reasoning) across chunks.
558
+ const streamState = { modelConfig, resolvedModel };
524
559
 
525
560
  try {
526
561
  // Check if already aborted before starting
@@ -553,8 +588,45 @@ export function createOpenAICompatibleProvider(providerConfig) {
553
588
  );
554
589
  break;
555
590
  }
591
+
592
+ // Optional per-chunk hook: yields extra normalized events, patches
593
+ // final metadata, can suppress default delta handling, and can
594
+ // terminate the stream as failed on a fatal in-band error.
595
+ let suppressDefault = false;
596
+ if (transformStreamChunk) {
597
+ const hookResult =
598
+ transformStreamChunk(chunk, streamState) || {};
599
+ const {
600
+ events = [],
601
+ metadataPatch = null,
602
+ suppressDefault: hookSuppress = false,
603
+ terminalError = null,
604
+ } = hookResult;
605
+ for (const extraEvent of events) {
606
+ yield extraEvent;
607
+ }
608
+ if (metadataPatch) {
609
+ Object.assign(streamMetadataPatch, metadataPatch);
610
+ }
611
+ if (terminalError) {
612
+ // Emit exactly one failure event and stop WITHOUT a later end
613
+ // event, leaving already-emitted deltas intact.
614
+ yield {
615
+ type: 'error',
616
+ error: {
617
+ message: terminalError.message || 'Stream terminated',
618
+ code: terminalError.code || 'STREAMING_ERROR',
619
+ recoverable: false,
620
+ },
621
+ timestamp: new Date().toISOString(),
622
+ };
623
+ return;
624
+ }
625
+ suppressDefault = hookSuppress;
626
+ }
627
+
556
628
  const choice = chunk.choices?.[0];
557
- if (choice) {
629
+ if (!suppressDefault && choice) {
558
630
  const content = choice.delta?.content || '';
559
631
 
560
632
  // Handle regular content
@@ -567,18 +639,22 @@ export function createOpenAICompatibleProvider(providerConfig) {
567
639
  };
568
640
  }
569
641
 
570
- // Handle reasoning/thinking content if supported
571
- if (choice.delta?.reasoning && modelConfig.supportsReasoning) {
642
+ // Handle reasoning/thinking content if supported. DeepSeek and
643
+ // OpenRouter expose streamed reasoning as delta.reasoning_content.
644
+ if (
645
+ choice.delta?.reasoning_content &&
646
+ modelConfig.supportsReasoning
647
+ ) {
572
648
  yield {
573
649
  type: 'thinking',
574
- content: choice.delta.reasoning,
650
+ content: choice.delta.reasoning_content,
575
651
  timestamp: new Date().toISOString(),
576
652
  };
577
653
  }
654
+ }
578
655
 
579
- if (choice.finish_reason) {
580
- finishReason = choice.finish_reason;
581
- }
656
+ if (choice?.finish_reason) {
657
+ finishReason = choice.finish_reason;
582
658
  }
583
659
 
584
660
  // Handle usage information (typically in final chunk)
@@ -653,6 +729,15 @@ export function createOpenAICompatibleProvider(providerConfig) {
653
729
  finalResult = await transformResponse(finalResult, mockRawResponse);
654
730
  }
655
731
 
732
+ // Merge any per-chunk metadata patches last so hook-supplied fields
733
+ // (reasoning_details, annotations, cost, upstream provider) win.
734
+ if (Object.keys(streamMetadataPatch).length > 0) {
735
+ finalResult.metadata = {
736
+ ...finalResult.metadata,
737
+ ...streamMetadataPatch,
738
+ };
739
+ }
740
+
656
741
  // Yield end event with final metadata
657
742
  yield {
658
743
  type: 'end',