@providerprotocol/ai 0.0.4 → 0.0.5

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