@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.
@@ -0,0 +1,755 @@
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
+ OpenRouterResponsesRequest,
16
+ OpenRouterResponsesInputItem,
17
+ OpenRouterResponsesContentPart,
18
+ OpenRouterResponsesTool,
19
+ OpenRouterResponsesResponse,
20
+ OpenRouterResponsesStreamEvent,
21
+ OpenRouterResponsesOutputItem,
22
+ OpenRouterResponsesMessageOutput,
23
+ OpenRouterResponsesFunctionCallOutput,
24
+ } from './types.ts';
25
+
26
+ /**
27
+ * Transform UPP request to OpenRouter Responses API format
28
+ */
29
+ export function transformRequest<TParams extends OpenRouterLLMParams>(
30
+ request: LLMRequest<TParams>,
31
+ modelId: string
32
+ ): OpenRouterResponsesRequest {
33
+ const params: OpenRouterLLMParams = request.params ?? {};
34
+
35
+ const openrouterRequest: OpenRouterResponsesRequest = {
36
+ model: modelId,
37
+ input: transformInputItems(request.messages, request.system),
38
+ };
39
+
40
+ // Model parameters
41
+ if (params.temperature !== undefined) {
42
+ openrouterRequest.temperature = params.temperature;
43
+ }
44
+ if (params.top_p !== undefined) {
45
+ openrouterRequest.top_p = params.top_p;
46
+ }
47
+ if (params.max_output_tokens !== undefined) {
48
+ openrouterRequest.max_output_tokens = params.max_output_tokens;
49
+ } else if (params.max_tokens !== undefined) {
50
+ openrouterRequest.max_output_tokens = params.max_tokens;
51
+ }
52
+ if (params.reasoning !== undefined) {
53
+ openrouterRequest.reasoning = { ...params.reasoning };
54
+ }
55
+
56
+ // Tools
57
+ if (request.tools && request.tools.length > 0) {
58
+ openrouterRequest.tools = request.tools.map(transformTool);
59
+ if (params.parallel_tool_calls !== undefined) {
60
+ openrouterRequest.parallel_tool_calls = params.parallel_tool_calls;
61
+ }
62
+ }
63
+
64
+ // Structured output via text.format
65
+ if (request.structure) {
66
+ const schema: Record<string, unknown> = {
67
+ type: 'object',
68
+ properties: request.structure.properties,
69
+ required: request.structure.required,
70
+ ...(request.structure.additionalProperties !== undefined
71
+ ? { additionalProperties: request.structure.additionalProperties }
72
+ : { additionalProperties: false }),
73
+ };
74
+ if (request.structure.description) {
75
+ schema.description = request.structure.description;
76
+ }
77
+
78
+ openrouterRequest.text = {
79
+ format: {
80
+ type: 'json_schema',
81
+ name: 'json_response',
82
+ description: request.structure.description,
83
+ schema,
84
+ strict: true,
85
+ },
86
+ };
87
+ }
88
+
89
+ return openrouterRequest;
90
+ }
91
+
92
+ /**
93
+ * Transform messages to Responses API input items
94
+ */
95
+ function transformInputItems(
96
+ messages: Message[],
97
+ system?: string
98
+ ): OpenRouterResponsesInputItem[] | string {
99
+ const result: OpenRouterResponsesInputItem[] = [];
100
+
101
+ if (system) {
102
+ result.push({
103
+ type: 'message',
104
+ role: 'system',
105
+ content: system,
106
+ });
107
+ }
108
+
109
+ for (const message of messages) {
110
+ const items = transformMessage(message);
111
+ result.push(...items);
112
+ }
113
+
114
+ // If there's only one user message with simple text, return as string
115
+ if (result.length === 1 && result[0]?.type === 'message') {
116
+ const item = result[0] as { role?: string; content?: string | unknown[] };
117
+ if (item.role === 'user' && typeof item.content === 'string') {
118
+ return item.content;
119
+ }
120
+ }
121
+
122
+ return result;
123
+ }
124
+
125
+ /**
126
+ * Filter to only valid content blocks with a type property
127
+ */
128
+ function filterValidContent<T extends { type?: string }>(content: T[]): T[] {
129
+ return content.filter((c) => c && typeof c.type === 'string');
130
+ }
131
+
132
+ /**
133
+ * Transform a UPP Message to OpenRouter Responses API input items
134
+ */
135
+ function transformMessage(message: Message): OpenRouterResponsesInputItem[] {
136
+ if (isUserMessage(message)) {
137
+ const validContent = filterValidContent(message.content);
138
+ // Check if we can use simple string content
139
+ if (validContent.length === 1 && validContent[0]?.type === 'text') {
140
+ return [
141
+ {
142
+ type: 'message',
143
+ role: 'user',
144
+ content: (validContent[0] as TextBlock).text,
145
+ },
146
+ ];
147
+ }
148
+ return [
149
+ {
150
+ type: 'message',
151
+ role: 'user',
152
+ content: validContent.map(transformContentPart),
153
+ },
154
+ ];
155
+ }
156
+
157
+ if (isAssistantMessage(message)) {
158
+ const validContent = filterValidContent(message.content);
159
+ const items: OpenRouterResponsesInputItem[] = [];
160
+
161
+ // Add message content - only text parts for assistant messages
162
+ const contentParts: OpenRouterResponsesContentPart[] = validContent
163
+ .filter((c): c is TextBlock => c.type === 'text')
164
+ .map((c): OpenRouterResponsesContentPart => ({
165
+ type: 'output_text',
166
+ text: c.text,
167
+ annotations: [],
168
+ }));
169
+
170
+ // Get message id from metadata or generate one
171
+ const messageId = message.id ?? `msg_${Date.now()}`;
172
+
173
+ // Add assistant message if we have text content
174
+ if (contentParts.length > 0) {
175
+ items.push({
176
+ type: 'message',
177
+ role: 'assistant',
178
+ id: messageId,
179
+ status: 'completed',
180
+ content: contentParts,
181
+ });
182
+ }
183
+
184
+ // Add function_call items for each tool call (must precede function_call_output)
185
+ const openrouterMeta = message.metadata?.openrouter as
186
+ | { functionCallItems?: Array<{ id: string; call_id: string; name: string; arguments: string }> }
187
+ | undefined;
188
+ const functionCallItems = openrouterMeta?.functionCallItems;
189
+
190
+ if (functionCallItems && functionCallItems.length > 0) {
191
+ for (const fc of functionCallItems) {
192
+ items.push({
193
+ type: 'function_call',
194
+ id: fc.id,
195
+ call_id: fc.call_id,
196
+ name: fc.name,
197
+ arguments: fc.arguments,
198
+ });
199
+ }
200
+ } else if (message.toolCalls && message.toolCalls.length > 0) {
201
+ for (const call of message.toolCalls) {
202
+ items.push({
203
+ type: 'function_call',
204
+ id: `fc_${call.toolCallId}`,
205
+ call_id: call.toolCallId,
206
+ name: call.toolName,
207
+ arguments: JSON.stringify(call.arguments),
208
+ });
209
+ }
210
+ }
211
+
212
+ return items;
213
+ }
214
+
215
+ if (isToolResultMessage(message)) {
216
+ // Tool results are function_call_output items
217
+ return message.results.map((result, index) => ({
218
+ type: 'function_call_output' as const,
219
+ id: `fco_${result.toolCallId}_${index}`,
220
+ call_id: result.toolCallId,
221
+ output:
222
+ typeof result.result === 'string'
223
+ ? result.result
224
+ : JSON.stringify(result.result),
225
+ }));
226
+ }
227
+
228
+ return [];
229
+ }
230
+
231
+ /**
232
+ * Transform a content block to Responses API format
233
+ */
234
+ function transformContentPart(block: ContentBlock): OpenRouterResponsesContentPart {
235
+ switch (block.type) {
236
+ case 'text':
237
+ return { type: 'input_text', text: block.text };
238
+
239
+ case 'image': {
240
+ const imageBlock = block as ImageBlock;
241
+ if (imageBlock.source.type === 'base64') {
242
+ return {
243
+ type: 'input_image',
244
+ image_url: `data:${imageBlock.mimeType};base64,${imageBlock.source.data}`,
245
+ detail: 'auto',
246
+ };
247
+ }
248
+
249
+ if (imageBlock.source.type === 'url') {
250
+ return {
251
+ type: 'input_image',
252
+ image_url: imageBlock.source.url,
253
+ detail: 'auto',
254
+ };
255
+ }
256
+
257
+ if (imageBlock.source.type === 'bytes') {
258
+ // Convert bytes to base64
259
+ const base64 = btoa(
260
+ Array.from(imageBlock.source.data)
261
+ .map((b) => String.fromCharCode(b))
262
+ .join('')
263
+ );
264
+ return {
265
+ type: 'input_image',
266
+ image_url: `data:${imageBlock.mimeType};base64,${base64}`,
267
+ detail: 'auto',
268
+ };
269
+ }
270
+
271
+ throw new Error('Unknown image source type');
272
+ }
273
+
274
+ default:
275
+ throw new Error(`Unsupported content type: ${block.type}`);
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Transform a UPP Tool to Responses API format
281
+ */
282
+ function transformTool(tool: Tool): OpenRouterResponsesTool {
283
+ return {
284
+ type: 'function',
285
+ name: tool.name,
286
+ description: tool.description,
287
+ parameters: {
288
+ type: 'object',
289
+ properties: tool.parameters.properties,
290
+ required: tool.parameters.required,
291
+ ...(tool.parameters.additionalProperties !== undefined
292
+ ? { additionalProperties: tool.parameters.additionalProperties }
293
+ : {}),
294
+ },
295
+ };
296
+ }
297
+
298
+ /**
299
+ * Transform OpenRouter Responses API response to UPP LLMResponse
300
+ */
301
+ export function transformResponse(data: OpenRouterResponsesResponse): LLMResponse {
302
+ // Extract text content and tool calls from output items
303
+ const textContent: TextBlock[] = [];
304
+ const toolCalls: ToolCall[] = [];
305
+ const functionCallItems: Array<{
306
+ id: string;
307
+ call_id: string;
308
+ name: string;
309
+ arguments: string;
310
+ }> = [];
311
+ let hadRefusal = false;
312
+ let structuredData: unknown;
313
+
314
+ for (const item of data.output) {
315
+ if (item.type === 'message') {
316
+ const messageItem = item as OpenRouterResponsesMessageOutput;
317
+ for (const content of messageItem.content) {
318
+ if (content.type === 'output_text') {
319
+ textContent.push({ type: 'text', text: content.text });
320
+ // Try to parse as JSON for structured output (native JSON mode)
321
+ // Only set data if text is valid JSON
322
+ if (structuredData === undefined) {
323
+ try {
324
+ structuredData = JSON.parse(content.text);
325
+ } catch {
326
+ // Not valid JSON - that's fine, might not be structured output
327
+ }
328
+ }
329
+ } else if (content.type === 'refusal') {
330
+ textContent.push({ type: 'text', text: content.refusal });
331
+ hadRefusal = true;
332
+ }
333
+ }
334
+ } else if (item.type === 'function_call') {
335
+ const functionCall = item as OpenRouterResponsesFunctionCallOutput;
336
+ let args: Record<string, unknown> = {};
337
+ try {
338
+ args = JSON.parse(functionCall.arguments);
339
+ } catch {
340
+ // Invalid JSON - use empty object
341
+ }
342
+ toolCalls.push({
343
+ toolCallId: functionCall.call_id,
344
+ toolName: functionCall.name,
345
+ arguments: args,
346
+ });
347
+ functionCallItems.push({
348
+ id: functionCall.id,
349
+ call_id: functionCall.call_id,
350
+ name: functionCall.name,
351
+ arguments: functionCall.arguments,
352
+ });
353
+ }
354
+ }
355
+
356
+ const message = new AssistantMessage(
357
+ textContent,
358
+ toolCalls.length > 0 ? toolCalls : undefined,
359
+ {
360
+ id: data.id,
361
+ metadata: {
362
+ openrouter: {
363
+ model: data.model,
364
+ status: data.status,
365
+ // Store response_id for multi-turn tool calling
366
+ response_id: data.id,
367
+ functionCallItems:
368
+ functionCallItems.length > 0 ? functionCallItems : undefined,
369
+ },
370
+ },
371
+ }
372
+ );
373
+
374
+ const usage: TokenUsage = {
375
+ inputTokens: data.usage.input_tokens,
376
+ outputTokens: data.usage.output_tokens,
377
+ totalTokens: data.usage.total_tokens,
378
+ };
379
+
380
+ // Map status to stop reason
381
+ let stopReason = 'end_turn';
382
+ if (data.status === 'completed') {
383
+ stopReason = toolCalls.length > 0 ? 'tool_use' : 'end_turn';
384
+ } else if (data.status === 'incomplete') {
385
+ stopReason = data.incomplete_details?.reason === 'max_output_tokens'
386
+ ? 'max_tokens'
387
+ : 'end_turn';
388
+ } else if (data.status === 'failed') {
389
+ stopReason = 'error';
390
+ }
391
+ if (hadRefusal && stopReason !== 'error') {
392
+ stopReason = 'content_filter';
393
+ }
394
+
395
+ return {
396
+ message,
397
+ usage,
398
+ stopReason,
399
+ data: structuredData,
400
+ };
401
+ }
402
+
403
+ /**
404
+ * State for accumulating streaming response
405
+ */
406
+ export interface ResponsesStreamState {
407
+ id: string;
408
+ model: string;
409
+ textByIndex: Map<number, string>;
410
+ toolCalls: Map<
411
+ number,
412
+ { itemId?: string; callId?: string; name?: string; arguments: string }
413
+ >;
414
+ status: string;
415
+ inputTokens: number;
416
+ outputTokens: number;
417
+ hadRefusal: boolean;
418
+ }
419
+
420
+ /**
421
+ * Create initial stream state
422
+ */
423
+ export function createStreamState(): ResponsesStreamState {
424
+ return {
425
+ id: '',
426
+ model: '',
427
+ textByIndex: new Map(),
428
+ toolCalls: new Map(),
429
+ status: 'in_progress',
430
+ inputTokens: 0,
431
+ outputTokens: 0,
432
+ hadRefusal: false,
433
+ };
434
+ }
435
+
436
+ /**
437
+ * Transform OpenRouter Responses API stream event to UPP StreamEvent
438
+ * Returns array since one event may produce multiple UPP events
439
+ */
440
+ export function transformStreamEvent(
441
+ event: OpenRouterResponsesStreamEvent,
442
+ state: ResponsesStreamState
443
+ ): StreamEvent[] {
444
+ const events: StreamEvent[] = [];
445
+
446
+ switch (event.type) {
447
+ case 'response.created':
448
+ state.id = event.response.id;
449
+ state.model = event.response.model;
450
+ events.push({ type: 'message_start', index: 0, delta: {} });
451
+ break;
452
+
453
+ case 'response.in_progress':
454
+ state.status = 'in_progress';
455
+ break;
456
+
457
+ case 'response.completed':
458
+ case 'response.done':
459
+ // Handle both event type names (OpenRouter docs show response.done)
460
+ state.status = 'completed';
461
+ if (event.response?.usage) {
462
+ state.inputTokens = event.response.usage.input_tokens;
463
+ state.outputTokens = event.response.usage.output_tokens;
464
+ }
465
+ // CRITICAL: OpenRouter's streaming doesn't send function call arguments incrementally.
466
+ // They only appear in the final response.completed event's output array.
467
+ // We must extract them here to populate state.toolCalls with actual arguments.
468
+ if (event.response?.output) {
469
+ for (let i = 0; i < event.response.output.length; i++) {
470
+ const item = event.response.output[i];
471
+ if (item && item.type === 'function_call') {
472
+ const functionCall = item as OpenRouterResponsesFunctionCallOutput;
473
+ const existing = state.toolCalls.get(i) ?? { arguments: '' };
474
+ existing.itemId = functionCall.id ?? existing.itemId;
475
+ existing.callId = functionCall.call_id ?? existing.callId;
476
+ existing.name = functionCall.name ?? existing.name;
477
+ // This is the key fix: get arguments from the completed response
478
+ if (functionCall.arguments) {
479
+ existing.arguments = functionCall.arguments;
480
+ }
481
+ state.toolCalls.set(i, existing);
482
+ }
483
+ }
484
+ }
485
+ events.push({ type: 'message_stop', index: 0, delta: {} });
486
+ break;
487
+
488
+ case 'response.failed':
489
+ state.status = 'failed';
490
+ events.push({ type: 'message_stop', index: 0, delta: {} });
491
+ break;
492
+
493
+ case 'response.output_item.added':
494
+ if (event.item.type === 'function_call') {
495
+ const functionCall = event.item as OpenRouterResponsesFunctionCallOutput;
496
+ const existing = state.toolCalls.get(event.output_index) ?? {
497
+ arguments: '',
498
+ };
499
+ existing.itemId = functionCall.id;
500
+ existing.callId = functionCall.call_id;
501
+ existing.name = functionCall.name;
502
+ if (functionCall.arguments) {
503
+ existing.arguments = functionCall.arguments;
504
+ }
505
+ state.toolCalls.set(event.output_index, existing);
506
+ }
507
+ events.push({
508
+ type: 'content_block_start',
509
+ index: event.output_index,
510
+ delta: {},
511
+ });
512
+ break;
513
+
514
+ case 'response.output_item.done':
515
+ if (event.item.type === 'function_call') {
516
+ const functionCall = event.item as OpenRouterResponsesFunctionCallOutput;
517
+ const existing = state.toolCalls.get(event.output_index) ?? {
518
+ arguments: '',
519
+ };
520
+ existing.itemId = functionCall.id;
521
+ existing.callId = functionCall.call_id;
522
+ existing.name = functionCall.name;
523
+ if (functionCall.arguments) {
524
+ existing.arguments = functionCall.arguments;
525
+ }
526
+ state.toolCalls.set(event.output_index, existing);
527
+ } else if (event.item.type === 'message') {
528
+ // Extract text from completed message item (may not have incremental deltas)
529
+ const messageItem = event.item as OpenRouterResponsesMessageOutput;
530
+ for (const content of messageItem.content || []) {
531
+ if (content.type === 'output_text') {
532
+ const existingText = state.textByIndex.get(event.output_index) ?? '';
533
+ if (!existingText && content.text) {
534
+ // Only use done text if we didn't get incremental deltas
535
+ state.textByIndex.set(event.output_index, content.text);
536
+ events.push({
537
+ type: 'text_delta',
538
+ index: event.output_index,
539
+ delta: { text: content.text },
540
+ });
541
+ }
542
+ }
543
+ }
544
+ }
545
+ events.push({
546
+ type: 'content_block_stop',
547
+ index: event.output_index,
548
+ delta: {},
549
+ });
550
+ break;
551
+
552
+ case 'response.content_part.delta':
553
+ case 'response.output_text.delta': {
554
+ // Handle both event type names (OpenRouter docs vs OpenAI-style)
555
+ // Accumulate text
556
+ const textDelta = (event as { delta: string }).delta;
557
+ const currentText = state.textByIndex.get(event.output_index) ?? '';
558
+ state.textByIndex.set(event.output_index, currentText + textDelta);
559
+ events.push({
560
+ type: 'text_delta',
561
+ index: event.output_index,
562
+ delta: { text: textDelta },
563
+ });
564
+ break;
565
+ }
566
+
567
+ case 'response.output_text.done':
568
+ case 'response.content_part.done':
569
+ // Handle both event type names
570
+ if ('text' in event) {
571
+ state.textByIndex.set(event.output_index, (event as { text: string }).text);
572
+ }
573
+ break;
574
+
575
+ case 'response.refusal.delta': {
576
+ state.hadRefusal = true;
577
+ const currentRefusal = state.textByIndex.get(event.output_index) ?? '';
578
+ state.textByIndex.set(event.output_index, currentRefusal + event.delta);
579
+ events.push({
580
+ type: 'text_delta',
581
+ index: event.output_index,
582
+ delta: { text: event.delta },
583
+ });
584
+ break;
585
+ }
586
+
587
+ case 'response.refusal.done':
588
+ state.hadRefusal = true;
589
+ state.textByIndex.set(event.output_index, event.refusal);
590
+ break;
591
+
592
+ case 'response.function_call_arguments.delta': {
593
+ // Accumulate function call arguments
594
+ let toolCall = state.toolCalls.get(event.output_index);
595
+ if (!toolCall) {
596
+ toolCall = { arguments: '' };
597
+ state.toolCalls.set(event.output_index, toolCall);
598
+ }
599
+ if (event.item_id && !toolCall.itemId) {
600
+ toolCall.itemId = event.item_id;
601
+ }
602
+ if (event.call_id && !toolCall.callId) {
603
+ toolCall.callId = event.call_id;
604
+ }
605
+ toolCall.arguments += event.delta;
606
+ events.push({
607
+ type: 'tool_call_delta',
608
+ index: event.output_index,
609
+ delta: {
610
+ toolCallId: toolCall.callId ?? toolCall.itemId ?? '',
611
+ toolName: toolCall.name,
612
+ argumentsJson: event.delta,
613
+ },
614
+ });
615
+ break;
616
+ }
617
+
618
+ case 'response.function_call_arguments.done': {
619
+ // Finalize function call
620
+ let toolCall = state.toolCalls.get(event.output_index);
621
+ if (!toolCall) {
622
+ toolCall = { arguments: '' };
623
+ state.toolCalls.set(event.output_index, toolCall);
624
+ }
625
+ if (event.item_id) {
626
+ toolCall.itemId = event.item_id;
627
+ }
628
+ if (event.call_id) {
629
+ toolCall.callId = event.call_id;
630
+ }
631
+ toolCall.name = event.name;
632
+ toolCall.arguments = event.arguments;
633
+ break;
634
+ }
635
+
636
+ case 'response.reasoning.delta':
637
+ // Emit reasoning as a reasoning_delta event
638
+ events.push({
639
+ type: 'reasoning_delta',
640
+ index: 0,
641
+ delta: { text: event.delta },
642
+ });
643
+ break;
644
+
645
+ case 'error':
646
+ // Error events are handled at the handler level
647
+ break;
648
+
649
+ default:
650
+ // Ignore other events
651
+ break;
652
+ }
653
+
654
+ return events;
655
+ }
656
+
657
+ /**
658
+ * Build LLMResponse from accumulated stream state
659
+ */
660
+ export function buildResponseFromState(state: ResponsesStreamState): LLMResponse {
661
+ const textContent: TextBlock[] = [];
662
+ let structuredData: unknown;
663
+
664
+ // Combine all text content
665
+ for (const [, text] of state.textByIndex) {
666
+ if (text) {
667
+ textContent.push({ type: 'text', text });
668
+ // Try to parse as JSON for structured output (native JSON mode)
669
+ if (structuredData === undefined) {
670
+ try {
671
+ structuredData = JSON.parse(text);
672
+ } catch {
673
+ // Not valid JSON - that's fine, might not be structured output
674
+ }
675
+ }
676
+ }
677
+ }
678
+
679
+ const toolCalls: ToolCall[] = [];
680
+ const functionCallItems: Array<{
681
+ id: string;
682
+ call_id: string;
683
+ name: string;
684
+ arguments: string;
685
+ }> = [];
686
+ for (const [, toolCall] of state.toolCalls) {
687
+ let args: Record<string, unknown> = {};
688
+ if (toolCall.arguments) {
689
+ try {
690
+ args = JSON.parse(toolCall.arguments);
691
+ } catch {
692
+ // Invalid JSON - use empty object
693
+ }
694
+ }
695
+ const itemId = toolCall.itemId ?? '';
696
+ const callId = toolCall.callId ?? toolCall.itemId ?? '';
697
+ const name = toolCall.name ?? '';
698
+ toolCalls.push({
699
+ toolCallId: callId,
700
+ toolName: name,
701
+ arguments: args,
702
+ });
703
+
704
+ if (itemId && callId && name) {
705
+ functionCallItems.push({
706
+ id: itemId,
707
+ call_id: callId,
708
+ name,
709
+ arguments: toolCall.arguments,
710
+ });
711
+ }
712
+ }
713
+
714
+ const message = new AssistantMessage(
715
+ textContent,
716
+ toolCalls.length > 0 ? toolCalls : undefined,
717
+ {
718
+ id: state.id,
719
+ metadata: {
720
+ openrouter: {
721
+ model: state.model,
722
+ status: state.status,
723
+ // Store response_id for multi-turn tool calling
724
+ response_id: state.id,
725
+ functionCallItems:
726
+ functionCallItems.length > 0 ? functionCallItems : undefined,
727
+ },
728
+ },
729
+ }
730
+ );
731
+
732
+ const usage: TokenUsage = {
733
+ inputTokens: state.inputTokens,
734
+ outputTokens: state.outputTokens,
735
+ totalTokens: state.inputTokens + state.outputTokens,
736
+ };
737
+
738
+ // Map status to stop reason
739
+ let stopReason = 'end_turn';
740
+ if (state.status === 'completed') {
741
+ stopReason = toolCalls.length > 0 ? 'tool_use' : 'end_turn';
742
+ } else if (state.status === 'failed') {
743
+ stopReason = 'error';
744
+ }
745
+ if (state.hadRefusal && stopReason !== 'error') {
746
+ stopReason = 'content_filter';
747
+ }
748
+
749
+ return {
750
+ message,
751
+ usage,
752
+ stopReason,
753
+ data: structuredData,
754
+ };
755
+ }