@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,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
+ }