@providerprotocol/ai 0.0.2 → 0.0.4
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/README.md +5 -1
- package/dist/anthropic/index.d.ts +2 -2
- package/dist/anthropic/index.js +4 -2
- package/dist/anthropic/index.js.map +1 -1
- package/dist/openrouter/index.d.ts +235 -0
- package/dist/openrouter/index.js +1342 -0
- package/dist/openrouter/index.js.map +1 -0
- package/package.json +9 -2
- package/src/openrouter/index.ts +10 -0
- package/src/providers/anthropic/transform.ts +6 -2
- package/src/providers/anthropic/types.ts +3 -3
- package/src/providers/openrouter/index.ts +173 -0
- package/src/providers/openrouter/llm.completions.ts +201 -0
- package/src/providers/openrouter/llm.responses.ts +211 -0
- package/src/providers/openrouter/transform.completions.ts +605 -0
- package/src/providers/openrouter/transform.responses.ts +755 -0
- package/src/providers/openrouter/types.ts +723 -0
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
import type { LLMRequest, LLMResponse } from '../../types/llm.ts';
|
|
2
|
+
import type { Message } from '../../types/messages.ts';
|
|
3
|
+
import type { StreamEvent } from '../../types/stream.ts';
|
|
4
|
+
import type { Tool, ToolCall } from '../../types/tool.ts';
|
|
5
|
+
import type { TokenUsage } from '../../types/turn.ts';
|
|
6
|
+
import type { ContentBlock, TextBlock, ImageBlock } from '../../types/content.ts';
|
|
7
|
+
import {
|
|
8
|
+
AssistantMessage,
|
|
9
|
+
isUserMessage,
|
|
10
|
+
isAssistantMessage,
|
|
11
|
+
isToolResultMessage,
|
|
12
|
+
} from '../../types/messages.ts';
|
|
13
|
+
import type {
|
|
14
|
+
OpenRouterLLMParams,
|
|
15
|
+
OpenRouterCompletionsRequest,
|
|
16
|
+
OpenRouterCompletionsMessage,
|
|
17
|
+
OpenRouterUserContent,
|
|
18
|
+
OpenRouterCompletionsTool,
|
|
19
|
+
OpenRouterCompletionsResponse,
|
|
20
|
+
OpenRouterCompletionsStreamChunk,
|
|
21
|
+
OpenRouterToolCall,
|
|
22
|
+
} from './types.ts';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Transform UPP request to OpenRouter Chat Completions format
|
|
26
|
+
*/
|
|
27
|
+
export function transformRequest<TParams extends OpenRouterLLMParams>(
|
|
28
|
+
request: LLMRequest<TParams>,
|
|
29
|
+
modelId: string
|
|
30
|
+
): OpenRouterCompletionsRequest {
|
|
31
|
+
const params: OpenRouterLLMParams = request.params ?? {};
|
|
32
|
+
|
|
33
|
+
const openrouterRequest: OpenRouterCompletionsRequest = {
|
|
34
|
+
model: modelId,
|
|
35
|
+
messages: transformMessages(request.messages, request.system),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Model parameters
|
|
39
|
+
if (params.temperature !== undefined) {
|
|
40
|
+
openrouterRequest.temperature = params.temperature;
|
|
41
|
+
}
|
|
42
|
+
if (params.top_p !== undefined) {
|
|
43
|
+
openrouterRequest.top_p = params.top_p;
|
|
44
|
+
}
|
|
45
|
+
if (params.top_k !== undefined) {
|
|
46
|
+
openrouterRequest.top_k = params.top_k;
|
|
47
|
+
}
|
|
48
|
+
if (params.min_p !== undefined) {
|
|
49
|
+
openrouterRequest.min_p = params.min_p;
|
|
50
|
+
}
|
|
51
|
+
if (params.top_a !== undefined) {
|
|
52
|
+
openrouterRequest.top_a = params.top_a;
|
|
53
|
+
}
|
|
54
|
+
if (params.max_tokens !== undefined) {
|
|
55
|
+
openrouterRequest.max_tokens = params.max_tokens;
|
|
56
|
+
}
|
|
57
|
+
if (params.frequency_penalty !== undefined) {
|
|
58
|
+
openrouterRequest.frequency_penalty = params.frequency_penalty;
|
|
59
|
+
}
|
|
60
|
+
if (params.presence_penalty !== undefined) {
|
|
61
|
+
openrouterRequest.presence_penalty = params.presence_penalty;
|
|
62
|
+
}
|
|
63
|
+
if (params.repetition_penalty !== undefined) {
|
|
64
|
+
openrouterRequest.repetition_penalty = params.repetition_penalty;
|
|
65
|
+
}
|
|
66
|
+
if (params.stop !== undefined) {
|
|
67
|
+
openrouterRequest.stop = params.stop;
|
|
68
|
+
}
|
|
69
|
+
if (params.logprobs !== undefined) {
|
|
70
|
+
openrouterRequest.logprobs = params.logprobs;
|
|
71
|
+
}
|
|
72
|
+
if (params.top_logprobs !== undefined) {
|
|
73
|
+
openrouterRequest.top_logprobs = params.top_logprobs;
|
|
74
|
+
}
|
|
75
|
+
if (params.seed !== undefined) {
|
|
76
|
+
openrouterRequest.seed = params.seed;
|
|
77
|
+
}
|
|
78
|
+
if (params.user !== undefined) {
|
|
79
|
+
openrouterRequest.user = params.user;
|
|
80
|
+
}
|
|
81
|
+
if (params.logit_bias !== undefined) {
|
|
82
|
+
openrouterRequest.logit_bias = params.logit_bias;
|
|
83
|
+
}
|
|
84
|
+
if (params.prediction !== undefined) {
|
|
85
|
+
openrouterRequest.prediction = params.prediction;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// OpenRouter-specific parameters
|
|
89
|
+
if (params.transforms !== undefined) {
|
|
90
|
+
openrouterRequest.transforms = params.transforms;
|
|
91
|
+
}
|
|
92
|
+
if (params.models !== undefined) {
|
|
93
|
+
openrouterRequest.models = params.models;
|
|
94
|
+
}
|
|
95
|
+
if (params.route !== undefined) {
|
|
96
|
+
openrouterRequest.route = params.route;
|
|
97
|
+
}
|
|
98
|
+
if (params.provider !== undefined) {
|
|
99
|
+
openrouterRequest.provider = params.provider;
|
|
100
|
+
}
|
|
101
|
+
if (params.debug !== undefined) {
|
|
102
|
+
openrouterRequest.debug = params.debug;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Tools
|
|
106
|
+
if (request.tools && request.tools.length > 0) {
|
|
107
|
+
openrouterRequest.tools = request.tools.map(transformTool);
|
|
108
|
+
if (params.parallel_tool_calls !== undefined) {
|
|
109
|
+
openrouterRequest.parallel_tool_calls = params.parallel_tool_calls;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Structured output via response_format
|
|
114
|
+
if (request.structure) {
|
|
115
|
+
const schema: Record<string, unknown> = {
|
|
116
|
+
type: 'object',
|
|
117
|
+
properties: request.structure.properties,
|
|
118
|
+
required: request.structure.required,
|
|
119
|
+
...(request.structure.additionalProperties !== undefined
|
|
120
|
+
? { additionalProperties: request.structure.additionalProperties }
|
|
121
|
+
: { additionalProperties: false }),
|
|
122
|
+
};
|
|
123
|
+
if (request.structure.description) {
|
|
124
|
+
schema.description = request.structure.description;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
openrouterRequest.response_format = {
|
|
128
|
+
type: 'json_schema',
|
|
129
|
+
json_schema: {
|
|
130
|
+
name: 'json_response',
|
|
131
|
+
description: request.structure.description,
|
|
132
|
+
schema,
|
|
133
|
+
strict: true,
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
} else if (params.response_format !== undefined) {
|
|
137
|
+
// Pass through response_format from params if no structure is defined
|
|
138
|
+
openrouterRequest.response_format = params.response_format;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return openrouterRequest;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Transform messages including system prompt
|
|
146
|
+
*/
|
|
147
|
+
function transformMessages(
|
|
148
|
+
messages: Message[],
|
|
149
|
+
system?: string
|
|
150
|
+
): OpenRouterCompletionsMessage[] {
|
|
151
|
+
const result: OpenRouterCompletionsMessage[] = [];
|
|
152
|
+
|
|
153
|
+
// Add system message first if present
|
|
154
|
+
if (system) {
|
|
155
|
+
result.push({
|
|
156
|
+
role: 'system',
|
|
157
|
+
content: system,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Transform each message
|
|
162
|
+
for (const message of messages) {
|
|
163
|
+
// Handle tool result messages specially - they need to produce multiple messages
|
|
164
|
+
if (isToolResultMessage(message)) {
|
|
165
|
+
const toolMessages = transformToolResults(message);
|
|
166
|
+
result.push(...toolMessages);
|
|
167
|
+
} else {
|
|
168
|
+
const transformed = transformMessage(message);
|
|
169
|
+
if (transformed) {
|
|
170
|
+
result.push(transformed);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return result;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Filter to only valid content blocks with a type property
|
|
180
|
+
*/
|
|
181
|
+
function filterValidContent<T extends { type?: string }>(content: T[]): T[] {
|
|
182
|
+
return content.filter((c) => c && typeof c.type === 'string');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Transform a UPP Message to OpenRouter format
|
|
187
|
+
*/
|
|
188
|
+
function transformMessage(message: Message): OpenRouterCompletionsMessage | null {
|
|
189
|
+
if (isUserMessage(message)) {
|
|
190
|
+
const validContent = filterValidContent(message.content);
|
|
191
|
+
// Check if we can use simple string content
|
|
192
|
+
if (validContent.length === 1 && validContent[0]?.type === 'text') {
|
|
193
|
+
return {
|
|
194
|
+
role: 'user',
|
|
195
|
+
content: (validContent[0] as TextBlock).text,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
role: 'user',
|
|
200
|
+
content: validContent.map(transformContentBlock),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (isAssistantMessage(message)) {
|
|
205
|
+
const validContent = filterValidContent(message.content);
|
|
206
|
+
// Extract text content
|
|
207
|
+
const textContent = validContent
|
|
208
|
+
.filter((c): c is TextBlock => c.type === 'text')
|
|
209
|
+
.map((c) => c.text)
|
|
210
|
+
.join('');
|
|
211
|
+
|
|
212
|
+
const assistantMessage: OpenRouterCompletionsMessage = {
|
|
213
|
+
role: 'assistant',
|
|
214
|
+
content: textContent || null,
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// Add tool calls if present
|
|
218
|
+
if (message.toolCalls && message.toolCalls.length > 0) {
|
|
219
|
+
(assistantMessage as { tool_calls?: OpenRouterToolCall[] }).tool_calls =
|
|
220
|
+
message.toolCalls.map((call) => ({
|
|
221
|
+
id: call.toolCallId,
|
|
222
|
+
type: 'function' as const,
|
|
223
|
+
function: {
|
|
224
|
+
name: call.toolName,
|
|
225
|
+
arguments: JSON.stringify(call.arguments),
|
|
226
|
+
},
|
|
227
|
+
}));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return assistantMessage;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (isToolResultMessage(message)) {
|
|
234
|
+
// Tool results are sent as individual tool messages
|
|
235
|
+
// Return the first one and handle multiple in a different way
|
|
236
|
+
// Actually, we need to return multiple messages for multiple tool results
|
|
237
|
+
// This is handled by the caller - transform each result to a message
|
|
238
|
+
const results = message.results.map((result) => ({
|
|
239
|
+
role: 'tool' as const,
|
|
240
|
+
tool_call_id: result.toolCallId,
|
|
241
|
+
content:
|
|
242
|
+
typeof result.result === 'string'
|
|
243
|
+
? result.result
|
|
244
|
+
: JSON.stringify(result.result),
|
|
245
|
+
}));
|
|
246
|
+
|
|
247
|
+
// For now, return the first result - caller should handle multiple
|
|
248
|
+
return results[0] ?? null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Transform multiple tool results to messages
|
|
256
|
+
*/
|
|
257
|
+
export function transformToolResults(
|
|
258
|
+
message: Message
|
|
259
|
+
): OpenRouterCompletionsMessage[] {
|
|
260
|
+
if (!isToolResultMessage(message)) {
|
|
261
|
+
const single = transformMessage(message);
|
|
262
|
+
return single ? [single] : [];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return message.results.map((result) => ({
|
|
266
|
+
role: 'tool' as const,
|
|
267
|
+
tool_call_id: result.toolCallId,
|
|
268
|
+
content:
|
|
269
|
+
typeof result.result === 'string'
|
|
270
|
+
? result.result
|
|
271
|
+
: JSON.stringify(result.result),
|
|
272
|
+
}));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Transform a content block to OpenRouter format
|
|
277
|
+
*/
|
|
278
|
+
function transformContentBlock(block: ContentBlock): OpenRouterUserContent {
|
|
279
|
+
switch (block.type) {
|
|
280
|
+
case 'text':
|
|
281
|
+
return { type: 'text', text: block.text };
|
|
282
|
+
|
|
283
|
+
case 'image': {
|
|
284
|
+
const imageBlock = block as ImageBlock;
|
|
285
|
+
let url: string;
|
|
286
|
+
|
|
287
|
+
if (imageBlock.source.type === 'base64') {
|
|
288
|
+
url = `data:${imageBlock.mimeType};base64,${imageBlock.source.data}`;
|
|
289
|
+
} else if (imageBlock.source.type === 'url') {
|
|
290
|
+
url = imageBlock.source.url;
|
|
291
|
+
} else if (imageBlock.source.type === 'bytes') {
|
|
292
|
+
// Convert bytes to base64
|
|
293
|
+
const base64 = btoa(
|
|
294
|
+
Array.from(imageBlock.source.data)
|
|
295
|
+
.map((b) => String.fromCharCode(b))
|
|
296
|
+
.join('')
|
|
297
|
+
);
|
|
298
|
+
url = `data:${imageBlock.mimeType};base64,${base64}`;
|
|
299
|
+
} else {
|
|
300
|
+
throw new Error('Unknown image source type');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
type: 'image_url',
|
|
305
|
+
image_url: { url },
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
default:
|
|
310
|
+
throw new Error(`Unsupported content type: ${block.type}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Transform a UPP Tool to OpenRouter format
|
|
316
|
+
*/
|
|
317
|
+
function transformTool(tool: Tool): OpenRouterCompletionsTool {
|
|
318
|
+
return {
|
|
319
|
+
type: 'function',
|
|
320
|
+
function: {
|
|
321
|
+
name: tool.name,
|
|
322
|
+
description: tool.description,
|
|
323
|
+
parameters: {
|
|
324
|
+
type: 'object',
|
|
325
|
+
properties: tool.parameters.properties,
|
|
326
|
+
required: tool.parameters.required,
|
|
327
|
+
...(tool.parameters.additionalProperties !== undefined
|
|
328
|
+
? { additionalProperties: tool.parameters.additionalProperties }
|
|
329
|
+
: {}),
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Transform OpenRouter response to UPP LLMResponse
|
|
337
|
+
*/
|
|
338
|
+
export function transformResponse(data: OpenRouterCompletionsResponse): LLMResponse {
|
|
339
|
+
const choice = data.choices[0];
|
|
340
|
+
if (!choice) {
|
|
341
|
+
throw new Error('No choices in OpenRouter response');
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Extract text content
|
|
345
|
+
const textContent: TextBlock[] = [];
|
|
346
|
+
let structuredData: unknown;
|
|
347
|
+
if (choice.message.content) {
|
|
348
|
+
textContent.push({ type: 'text', text: choice.message.content });
|
|
349
|
+
// Try to parse as JSON for structured output (native JSON mode)
|
|
350
|
+
try {
|
|
351
|
+
structuredData = JSON.parse(choice.message.content);
|
|
352
|
+
} catch {
|
|
353
|
+
// Not valid JSON - that's fine, might not be structured output
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Extract tool calls
|
|
358
|
+
const toolCalls: ToolCall[] = [];
|
|
359
|
+
if (choice.message.tool_calls) {
|
|
360
|
+
for (const call of choice.message.tool_calls) {
|
|
361
|
+
let args: Record<string, unknown> = {};
|
|
362
|
+
try {
|
|
363
|
+
args = JSON.parse(call.function.arguments);
|
|
364
|
+
} catch {
|
|
365
|
+
// Invalid JSON - use empty object
|
|
366
|
+
}
|
|
367
|
+
toolCalls.push({
|
|
368
|
+
toolCallId: call.id,
|
|
369
|
+
toolName: call.function.name,
|
|
370
|
+
arguments: args,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const message = new AssistantMessage(
|
|
376
|
+
textContent,
|
|
377
|
+
toolCalls.length > 0 ? toolCalls : undefined,
|
|
378
|
+
{
|
|
379
|
+
id: data.id,
|
|
380
|
+
metadata: {
|
|
381
|
+
openrouter: {
|
|
382
|
+
model: data.model,
|
|
383
|
+
finish_reason: choice.finish_reason,
|
|
384
|
+
system_fingerprint: data.system_fingerprint,
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
}
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
const usage: TokenUsage = {
|
|
391
|
+
inputTokens: data.usage.prompt_tokens,
|
|
392
|
+
outputTokens: data.usage.completion_tokens,
|
|
393
|
+
totalTokens: data.usage.total_tokens,
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
// Map finish reason to stop reason
|
|
397
|
+
let stopReason = 'end_turn';
|
|
398
|
+
switch (choice.finish_reason) {
|
|
399
|
+
case 'stop':
|
|
400
|
+
stopReason = 'end_turn';
|
|
401
|
+
break;
|
|
402
|
+
case 'length':
|
|
403
|
+
stopReason = 'max_tokens';
|
|
404
|
+
break;
|
|
405
|
+
case 'tool_calls':
|
|
406
|
+
stopReason = 'tool_use';
|
|
407
|
+
break;
|
|
408
|
+
case 'content_filter':
|
|
409
|
+
stopReason = 'content_filter';
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
message,
|
|
415
|
+
usage,
|
|
416
|
+
stopReason,
|
|
417
|
+
data: structuredData,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* State for accumulating streaming response
|
|
423
|
+
*/
|
|
424
|
+
export interface CompletionsStreamState {
|
|
425
|
+
id: string;
|
|
426
|
+
model: string;
|
|
427
|
+
text: string;
|
|
428
|
+
toolCalls: Map<number, { id: string; name: string; arguments: string }>;
|
|
429
|
+
finishReason: string | null;
|
|
430
|
+
inputTokens: number;
|
|
431
|
+
outputTokens: number;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Create initial stream state
|
|
436
|
+
*/
|
|
437
|
+
export function createStreamState(): CompletionsStreamState {
|
|
438
|
+
return {
|
|
439
|
+
id: '',
|
|
440
|
+
model: '',
|
|
441
|
+
text: '',
|
|
442
|
+
toolCalls: new Map(),
|
|
443
|
+
finishReason: null,
|
|
444
|
+
inputTokens: 0,
|
|
445
|
+
outputTokens: 0,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Transform OpenRouter stream chunk to UPP StreamEvent
|
|
451
|
+
* Returns array since one chunk may produce multiple events
|
|
452
|
+
*/
|
|
453
|
+
export function transformStreamEvent(
|
|
454
|
+
chunk: OpenRouterCompletionsStreamChunk,
|
|
455
|
+
state: CompletionsStreamState
|
|
456
|
+
): StreamEvent[] {
|
|
457
|
+
const events: StreamEvent[] = [];
|
|
458
|
+
|
|
459
|
+
// Update state with basic info
|
|
460
|
+
if (chunk.id && !state.id) {
|
|
461
|
+
state.id = chunk.id;
|
|
462
|
+
events.push({ type: 'message_start', index: 0, delta: {} });
|
|
463
|
+
}
|
|
464
|
+
if (chunk.model) {
|
|
465
|
+
state.model = chunk.model;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Process choices
|
|
469
|
+
const choice = chunk.choices[0];
|
|
470
|
+
if (choice) {
|
|
471
|
+
// Text delta
|
|
472
|
+
if (choice.delta.content) {
|
|
473
|
+
state.text += choice.delta.content;
|
|
474
|
+
events.push({
|
|
475
|
+
type: 'text_delta',
|
|
476
|
+
index: 0,
|
|
477
|
+
delta: { text: choice.delta.content },
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Tool call deltas
|
|
482
|
+
if (choice.delta.tool_calls) {
|
|
483
|
+
for (const toolCallDelta of choice.delta.tool_calls) {
|
|
484
|
+
const index = toolCallDelta.index;
|
|
485
|
+
let toolCall = state.toolCalls.get(index);
|
|
486
|
+
|
|
487
|
+
if (!toolCall) {
|
|
488
|
+
toolCall = { id: '', name: '', arguments: '' };
|
|
489
|
+
state.toolCalls.set(index, toolCall);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (toolCallDelta.id) {
|
|
493
|
+
toolCall.id = toolCallDelta.id;
|
|
494
|
+
}
|
|
495
|
+
if (toolCallDelta.function?.name) {
|
|
496
|
+
toolCall.name = toolCallDelta.function.name;
|
|
497
|
+
}
|
|
498
|
+
if (toolCallDelta.function?.arguments) {
|
|
499
|
+
toolCall.arguments += toolCallDelta.function.arguments;
|
|
500
|
+
events.push({
|
|
501
|
+
type: 'tool_call_delta',
|
|
502
|
+
index: index,
|
|
503
|
+
delta: {
|
|
504
|
+
toolCallId: toolCall.id,
|
|
505
|
+
toolName: toolCall.name,
|
|
506
|
+
argumentsJson: toolCallDelta.function.arguments,
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Finish reason
|
|
514
|
+
if (choice.finish_reason) {
|
|
515
|
+
state.finishReason = choice.finish_reason;
|
|
516
|
+
events.push({ type: 'message_stop', index: 0, delta: {} });
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Usage info (usually comes at the end with stream_options.include_usage)
|
|
521
|
+
if (chunk.usage) {
|
|
522
|
+
state.inputTokens = chunk.usage.prompt_tokens;
|
|
523
|
+
state.outputTokens = chunk.usage.completion_tokens;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return events;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Build LLMResponse from accumulated stream state
|
|
531
|
+
*/
|
|
532
|
+
export function buildResponseFromState(state: CompletionsStreamState): LLMResponse {
|
|
533
|
+
const textContent: TextBlock[] = [];
|
|
534
|
+
let structuredData: unknown;
|
|
535
|
+
if (state.text) {
|
|
536
|
+
textContent.push({ type: 'text', text: state.text });
|
|
537
|
+
// Try to parse as JSON for structured output (native JSON mode)
|
|
538
|
+
try {
|
|
539
|
+
structuredData = JSON.parse(state.text);
|
|
540
|
+
} catch {
|
|
541
|
+
// Not valid JSON - that's fine, might not be structured output
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const toolCalls: ToolCall[] = [];
|
|
546
|
+
for (const [, toolCall] of state.toolCalls) {
|
|
547
|
+
let args: Record<string, unknown> = {};
|
|
548
|
+
if (toolCall.arguments) {
|
|
549
|
+
try {
|
|
550
|
+
args = JSON.parse(toolCall.arguments);
|
|
551
|
+
} catch {
|
|
552
|
+
// Invalid JSON - use empty object
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
toolCalls.push({
|
|
556
|
+
toolCallId: toolCall.id,
|
|
557
|
+
toolName: toolCall.name,
|
|
558
|
+
arguments: args,
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const message = new AssistantMessage(
|
|
563
|
+
textContent,
|
|
564
|
+
toolCalls.length > 0 ? toolCalls : undefined,
|
|
565
|
+
{
|
|
566
|
+
id: state.id,
|
|
567
|
+
metadata: {
|
|
568
|
+
openrouter: {
|
|
569
|
+
model: state.model,
|
|
570
|
+
finish_reason: state.finishReason,
|
|
571
|
+
},
|
|
572
|
+
},
|
|
573
|
+
}
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
const usage: TokenUsage = {
|
|
577
|
+
inputTokens: state.inputTokens,
|
|
578
|
+
outputTokens: state.outputTokens,
|
|
579
|
+
totalTokens: state.inputTokens + state.outputTokens,
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
// Map finish reason to stop reason
|
|
583
|
+
let stopReason = 'end_turn';
|
|
584
|
+
switch (state.finishReason) {
|
|
585
|
+
case 'stop':
|
|
586
|
+
stopReason = 'end_turn';
|
|
587
|
+
break;
|
|
588
|
+
case 'length':
|
|
589
|
+
stopReason = 'max_tokens';
|
|
590
|
+
break;
|
|
591
|
+
case 'tool_calls':
|
|
592
|
+
stopReason = 'tool_use';
|
|
593
|
+
break;
|
|
594
|
+
case 'content_filter':
|
|
595
|
+
stopReason = 'content_filter';
|
|
596
|
+
break;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return {
|
|
600
|
+
message,
|
|
601
|
+
usage,
|
|
602
|
+
stopReason,
|
|
603
|
+
data: structuredData,
|
|
604
|
+
};
|
|
605
|
+
}
|