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