@providerprotocol/ai 0.0.4 → 0.0.6

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