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
|
@@ -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
|
package/src/providers/mistral.js
CHANGED
|
@@ -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
|
-
'
|
|
14
|
-
modelName: '
|
|
15
|
-
friendlyName: '
|
|
16
|
-
contextWindow:
|
|
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,
|
|
20
|
-
supportsTemperature: true,
|
|
21
|
+
supportsImages: true,
|
|
21
22
|
supportsWebSearch: false,
|
|
22
23
|
supportsReasoning: true,
|
|
23
24
|
timeout: 300000,
|
|
24
25
|
description:
|
|
25
|
-
'
|
|
26
|
+
'Mistral Medium 3.5 - Frontier-class multimodal model with adjustable reasoning',
|
|
26
27
|
aliases: [
|
|
27
|
-
'
|
|
28
|
-
'
|
|
29
|
-
'
|
|
30
|
-
'
|
|
31
|
-
'magistral-medium-1.2',
|
|
28
|
+
'mistral',
|
|
29
|
+
'mistral-medium',
|
|
30
|
+
'mistral-medium-latest',
|
|
31
|
+
'mistral-medium-3.5',
|
|
32
32
|
],
|
|
33
33
|
},
|
|
34
|
-
'
|
|
35
|
-
modelName: '
|
|
36
|
-
friendlyName: '
|
|
37
|
-
contextWindow:
|
|
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,
|
|
41
|
-
supportsTemperature: true,
|
|
40
|
+
supportsImages: true,
|
|
42
41
|
supportsWebSearch: false,
|
|
43
42
|
supportsReasoning: true,
|
|
44
43
|
timeout: 180000,
|
|
45
44
|
description:
|
|
46
|
-
'
|
|
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-
|
|
55
|
-
modelName: 'mistral-
|
|
56
|
-
friendlyName: 'Mistral
|
|
57
|
-
contextWindow:
|
|
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
|
|
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 = '
|
|
260
|
-
temperature = 0.7,
|
|
327
|
+
model = 'mistral-medium-3-5',
|
|
261
328
|
maxTokens = null,
|
|
262
329
|
stream = false,
|
|
263
|
-
|
|
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
|
-
//
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
374
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
|
519
|
-
if (
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
394
|
-
//
|
|
395
|
-
|
|
396
|
-
//
|
|
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
|
-
|
|
448
|
-
|
|
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
|
-
|
|
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.
|
|
650
|
+
content: choice.delta.reasoning_content,
|
|
575
651
|
timestamp: new Date().toISOString(),
|
|
576
652
|
};
|
|
577
653
|
}
|
|
654
|
+
}
|
|
578
655
|
|
|
579
|
-
|
|
580
|
-
|
|
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',
|