@lenylvt/pi-ai 0.64.0

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 (167) hide show
  1. package/README.md +203 -0
  2. package/dist/api-registry.d.ts +20 -0
  3. package/dist/api-registry.d.ts.map +1 -0
  4. package/dist/api-registry.js +44 -0
  5. package/dist/api-registry.js.map +1 -0
  6. package/dist/cli.d.ts +3 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +119 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/env-api-keys.d.ts +7 -0
  11. package/dist/env-api-keys.d.ts.map +1 -0
  12. package/dist/env-api-keys.js +13 -0
  13. package/dist/env-api-keys.js.map +1 -0
  14. package/dist/index.d.ts +20 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +14 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/models.d.ts +24 -0
  19. package/dist/models.d.ts.map +1 -0
  20. package/dist/models.generated.d.ts +2332 -0
  21. package/dist/models.generated.d.ts.map +1 -0
  22. package/dist/models.generated.js +2186 -0
  23. package/dist/models.generated.js.map +1 -0
  24. package/dist/models.js +60 -0
  25. package/dist/models.js.map +1 -0
  26. package/dist/oauth.d.ts +2 -0
  27. package/dist/oauth.d.ts.map +1 -0
  28. package/dist/oauth.js +2 -0
  29. package/dist/oauth.js.map +1 -0
  30. package/dist/providers/anthropic.d.ts +40 -0
  31. package/dist/providers/anthropic.d.ts.map +1 -0
  32. package/dist/providers/anthropic.js +749 -0
  33. package/dist/providers/anthropic.js.map +1 -0
  34. package/dist/providers/faux.d.ts +56 -0
  35. package/dist/providers/faux.d.ts.map +1 -0
  36. package/dist/providers/faux.js +367 -0
  37. package/dist/providers/faux.js.map +1 -0
  38. package/dist/providers/github-copilot-headers.d.ts +8 -0
  39. package/dist/providers/github-copilot-headers.d.ts.map +1 -0
  40. package/dist/providers/github-copilot-headers.js +29 -0
  41. package/dist/providers/github-copilot-headers.js.map +1 -0
  42. package/dist/providers/openai-codex-responses.d.ts +9 -0
  43. package/dist/providers/openai-codex-responses.d.ts.map +1 -0
  44. package/dist/providers/openai-codex-responses.js +741 -0
  45. package/dist/providers/openai-codex-responses.js.map +1 -0
  46. package/dist/providers/openai-completions.d.ts +15 -0
  47. package/dist/providers/openai-completions.d.ts.map +1 -0
  48. package/dist/providers/openai-completions.js +687 -0
  49. package/dist/providers/openai-completions.js.map +1 -0
  50. package/dist/providers/openai-responses-shared.d.ts +17 -0
  51. package/dist/providers/openai-responses-shared.d.ts.map +1 -0
  52. package/dist/providers/openai-responses-shared.js +458 -0
  53. package/dist/providers/openai-responses-shared.js.map +1 -0
  54. package/dist/providers/openai-responses.d.ts +13 -0
  55. package/dist/providers/openai-responses.d.ts.map +1 -0
  56. package/dist/providers/openai-responses.js +190 -0
  57. package/dist/providers/openai-responses.js.map +1 -0
  58. package/dist/providers/register-builtins.d.ts +16 -0
  59. package/dist/providers/register-builtins.d.ts.map +1 -0
  60. package/dist/providers/register-builtins.js +140 -0
  61. package/dist/providers/register-builtins.js.map +1 -0
  62. package/dist/providers/simple-options.d.ts +8 -0
  63. package/dist/providers/simple-options.d.ts.map +1 -0
  64. package/dist/providers/simple-options.js +35 -0
  65. package/dist/providers/simple-options.js.map +1 -0
  66. package/dist/providers/transform-messages.d.ts +8 -0
  67. package/dist/providers/transform-messages.d.ts.map +1 -0
  68. package/dist/providers/transform-messages.js +155 -0
  69. package/dist/providers/transform-messages.js.map +1 -0
  70. package/dist/stream.d.ts +8 -0
  71. package/dist/stream.d.ts.map +1 -0
  72. package/dist/stream.js +27 -0
  73. package/dist/stream.js.map +1 -0
  74. package/dist/types.d.ts +283 -0
  75. package/dist/types.d.ts.map +1 -0
  76. package/dist/types.js +2 -0
  77. package/dist/types.js.map +1 -0
  78. package/dist/utils/event-stream.d.ts +21 -0
  79. package/dist/utils/event-stream.d.ts.map +1 -0
  80. package/dist/utils/event-stream.js +81 -0
  81. package/dist/utils/event-stream.js.map +1 -0
  82. package/dist/utils/hash.d.ts +3 -0
  83. package/dist/utils/hash.d.ts.map +1 -0
  84. package/dist/utils/hash.js +14 -0
  85. package/dist/utils/hash.js.map +1 -0
  86. package/dist/utils/json-parse.d.ts +9 -0
  87. package/dist/utils/json-parse.d.ts.map +1 -0
  88. package/dist/utils/json-parse.js +29 -0
  89. package/dist/utils/json-parse.js.map +1 -0
  90. package/dist/utils/oauth/anthropic.d.ts +25 -0
  91. package/dist/utils/oauth/anthropic.d.ts.map +1 -0
  92. package/dist/utils/oauth/anthropic.js +335 -0
  93. package/dist/utils/oauth/anthropic.js.map +1 -0
  94. package/dist/utils/oauth/github-copilot.d.ts +30 -0
  95. package/dist/utils/oauth/github-copilot.d.ts.map +1 -0
  96. package/dist/utils/oauth/github-copilot.js +292 -0
  97. package/dist/utils/oauth/github-copilot.js.map +1 -0
  98. package/dist/utils/oauth/index.d.ts +36 -0
  99. package/dist/utils/oauth/index.d.ts.map +1 -0
  100. package/dist/utils/oauth/index.js +92 -0
  101. package/dist/utils/oauth/index.js.map +1 -0
  102. package/dist/utils/oauth/oauth-page.d.ts +3 -0
  103. package/dist/utils/oauth/oauth-page.d.ts.map +1 -0
  104. package/dist/utils/oauth/oauth-page.js +105 -0
  105. package/dist/utils/oauth/oauth-page.js.map +1 -0
  106. package/dist/utils/oauth/openai-codex.d.ts +34 -0
  107. package/dist/utils/oauth/openai-codex.d.ts.map +1 -0
  108. package/dist/utils/oauth/openai-codex.js +373 -0
  109. package/dist/utils/oauth/openai-codex.js.map +1 -0
  110. package/dist/utils/oauth/pkce.d.ts +13 -0
  111. package/dist/utils/oauth/pkce.d.ts.map +1 -0
  112. package/dist/utils/oauth/pkce.js +31 -0
  113. package/dist/utils/oauth/pkce.js.map +1 -0
  114. package/dist/utils/oauth/types.d.ts +47 -0
  115. package/dist/utils/oauth/types.d.ts.map +1 -0
  116. package/dist/utils/oauth/types.js +2 -0
  117. package/dist/utils/oauth/types.js.map +1 -0
  118. package/dist/utils/overflow.d.ts +53 -0
  119. package/dist/utils/overflow.d.ts.map +1 -0
  120. package/dist/utils/overflow.js +119 -0
  121. package/dist/utils/overflow.js.map +1 -0
  122. package/dist/utils/sanitize-unicode.d.ts +22 -0
  123. package/dist/utils/sanitize-unicode.d.ts.map +1 -0
  124. package/dist/utils/sanitize-unicode.js +26 -0
  125. package/dist/utils/sanitize-unicode.js.map +1 -0
  126. package/dist/utils/typebox-helpers.d.ts +17 -0
  127. package/dist/utils/typebox-helpers.d.ts.map +1 -0
  128. package/dist/utils/typebox-helpers.js +21 -0
  129. package/dist/utils/typebox-helpers.js.map +1 -0
  130. package/dist/utils/validation.d.ts +18 -0
  131. package/dist/utils/validation.d.ts.map +1 -0
  132. package/dist/utils/validation.js +80 -0
  133. package/dist/utils/validation.js.map +1 -0
  134. package/package.json +89 -0
  135. package/src/api-registry.ts +98 -0
  136. package/src/cli.ts +136 -0
  137. package/src/env-api-keys.ts +22 -0
  138. package/src/index.ts +29 -0
  139. package/src/models.generated.ts +2188 -0
  140. package/src/models.ts +82 -0
  141. package/src/oauth.ts +1 -0
  142. package/src/providers/anthropic.ts +905 -0
  143. package/src/providers/faux.ts +498 -0
  144. package/src/providers/github-copilot-headers.ts +37 -0
  145. package/src/providers/openai-codex-responses.ts +929 -0
  146. package/src/providers/openai-completions.ts +811 -0
  147. package/src/providers/openai-responses-shared.ts +513 -0
  148. package/src/providers/openai-responses.ts +251 -0
  149. package/src/providers/register-builtins.ts +232 -0
  150. package/src/providers/simple-options.ts +46 -0
  151. package/src/providers/transform-messages.ts +172 -0
  152. package/src/stream.ts +59 -0
  153. package/src/types.ts +294 -0
  154. package/src/utils/event-stream.ts +87 -0
  155. package/src/utils/hash.ts +13 -0
  156. package/src/utils/json-parse.ts +28 -0
  157. package/src/utils/oauth/anthropic.ts +402 -0
  158. package/src/utils/oauth/github-copilot.ts +396 -0
  159. package/src/utils/oauth/index.ts +123 -0
  160. package/src/utils/oauth/oauth-page.ts +109 -0
  161. package/src/utils/oauth/openai-codex.ts +450 -0
  162. package/src/utils/oauth/pkce.ts +34 -0
  163. package/src/utils/oauth/types.ts +59 -0
  164. package/src/utils/overflow.ts +125 -0
  165. package/src/utils/sanitize-unicode.ts +25 -0
  166. package/src/utils/typebox-helpers.ts +24 -0
  167. package/src/utils/validation.ts +93 -0
@@ -0,0 +1,811 @@
1
+ import OpenAI from "openai";
2
+ import type {
3
+ ChatCompletionAssistantMessageParam,
4
+ ChatCompletionChunk,
5
+ ChatCompletionContentPart,
6
+ ChatCompletionContentPartImage,
7
+ ChatCompletionContentPartText,
8
+ ChatCompletionMessageParam,
9
+ ChatCompletionToolMessageParam,
10
+ } from "openai/resources/chat/completions.js";
11
+ import { getEnvApiKey } from "../env-api-keys.js";
12
+ import { calculateCost, supportsXhigh } from "../models.js";
13
+ import type {
14
+ AssistantMessage,
15
+ Context,
16
+ Message,
17
+ Model,
18
+ OpenAICompletionsCompat,
19
+ SimpleStreamOptions,
20
+ StopReason,
21
+ StreamFunction,
22
+ StreamOptions,
23
+ TextContent,
24
+ ThinkingContent,
25
+ Tool,
26
+ ToolCall,
27
+ ToolResultMessage,
28
+ } from "../types.js";
29
+ import { AssistantMessageEventStream } from "../utils/event-stream.js";
30
+ import { parseStreamingJson } from "../utils/json-parse.js";
31
+ import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
32
+ import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js";
33
+ import { buildBaseOptions, clampReasoning } from "./simple-options.js";
34
+ import { transformMessages } from "./transform-messages.js";
35
+
36
+ /**
37
+ * Check if conversation messages contain tool calls or tool results.
38
+ * This is needed because Anthropic (via proxy) requires the tools param
39
+ * to be present when messages include tool_calls or tool role messages.
40
+ */
41
+ function hasToolHistory(messages: Message[]): boolean {
42
+ for (const msg of messages) {
43
+ if (msg.role === "toolResult") {
44
+ return true;
45
+ }
46
+ if (msg.role === "assistant") {
47
+ if (msg.content.some((block) => block.type === "toolCall")) {
48
+ return true;
49
+ }
50
+ }
51
+ }
52
+ return false;
53
+ }
54
+
55
+ export interface OpenAICompletionsOptions extends StreamOptions {
56
+ toolChoice?: "auto" | "none" | "required" | { type: "function"; function: { name: string } };
57
+ reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh";
58
+ }
59
+
60
+ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenAICompletionsOptions> = (
61
+ model: Model<"openai-completions">,
62
+ context: Context,
63
+ options?: OpenAICompletionsOptions,
64
+ ): AssistantMessageEventStream => {
65
+ const stream = new AssistantMessageEventStream();
66
+
67
+ (async () => {
68
+ const output: AssistantMessage = {
69
+ role: "assistant",
70
+ content: [],
71
+ api: model.api,
72
+ provider: model.provider,
73
+ model: model.id,
74
+ usage: {
75
+ input: 0,
76
+ output: 0,
77
+ cacheRead: 0,
78
+ cacheWrite: 0,
79
+ totalTokens: 0,
80
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
81
+ },
82
+ stopReason: "stop",
83
+ timestamp: Date.now(),
84
+ };
85
+
86
+ try {
87
+ const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
88
+ const client = createClient(model, context, apiKey, options?.headers);
89
+ let params = buildParams(model, context, options);
90
+ const nextParams = await options?.onPayload?.(params, model);
91
+ if (nextParams !== undefined) {
92
+ params = nextParams as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming;
93
+ }
94
+ const openaiStream = await client.chat.completions.create(params, { signal: options?.signal });
95
+ stream.push({ type: "start", partial: output });
96
+
97
+ let currentBlock: TextContent | ThinkingContent | (ToolCall & { partialArgs?: string }) | null = null;
98
+ const blocks = output.content;
99
+ const blockIndex = () => blocks.length - 1;
100
+ const finishCurrentBlock = (block?: typeof currentBlock) => {
101
+ if (block) {
102
+ if (block.type === "text") {
103
+ stream.push({
104
+ type: "text_end",
105
+ contentIndex: blockIndex(),
106
+ content: block.text,
107
+ partial: output,
108
+ });
109
+ } else if (block.type === "thinking") {
110
+ stream.push({
111
+ type: "thinking_end",
112
+ contentIndex: blockIndex(),
113
+ content: block.thinking,
114
+ partial: output,
115
+ });
116
+ } else if (block.type === "toolCall") {
117
+ block.arguments = parseStreamingJson(block.partialArgs);
118
+ delete block.partialArgs;
119
+ stream.push({
120
+ type: "toolcall_end",
121
+ contentIndex: blockIndex(),
122
+ toolCall: block,
123
+ partial: output,
124
+ });
125
+ }
126
+ }
127
+ };
128
+
129
+ for await (const chunk of openaiStream) {
130
+ if (!chunk || typeof chunk !== "object") continue;
131
+
132
+ // OpenAI documents ChatCompletionChunk.id as the unique chat completion identifier,
133
+ // and each chunk in a streamed completion carries the same id.
134
+ output.responseId ||= chunk.id;
135
+ if (chunk.usage) {
136
+ output.usage = parseChunkUsage(chunk.usage, model);
137
+ }
138
+
139
+ const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined;
140
+ if (!choice) continue;
141
+
142
+ // Fallback: some providers (e.g., Moonshot) return usage
143
+ // in choice.usage instead of the standard chunk.usage
144
+ if (!chunk.usage && (choice as any).usage) {
145
+ output.usage = parseChunkUsage((choice as any).usage, model);
146
+ }
147
+
148
+ if (choice.finish_reason) {
149
+ const finishReasonResult = mapStopReason(choice.finish_reason);
150
+ output.stopReason = finishReasonResult.stopReason;
151
+ if (finishReasonResult.errorMessage) {
152
+ output.errorMessage = finishReasonResult.errorMessage;
153
+ }
154
+ }
155
+
156
+ if (choice.delta) {
157
+ if (
158
+ choice.delta.content !== null &&
159
+ choice.delta.content !== undefined &&
160
+ choice.delta.content.length > 0
161
+ ) {
162
+ if (!currentBlock || currentBlock.type !== "text") {
163
+ finishCurrentBlock(currentBlock);
164
+ currentBlock = { type: "text", text: "" };
165
+ output.content.push(currentBlock);
166
+ stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
167
+ }
168
+
169
+ if (currentBlock.type === "text") {
170
+ currentBlock.text += choice.delta.content;
171
+ stream.push({
172
+ type: "text_delta",
173
+ contentIndex: blockIndex(),
174
+ delta: choice.delta.content,
175
+ partial: output,
176
+ });
177
+ }
178
+ }
179
+
180
+ // Some endpoints return reasoning in reasoning_content (llama.cpp),
181
+ // or reasoning (other openai compatible endpoints)
182
+ // Use the first non-empty reasoning field to avoid duplication
183
+ // (e.g., chutes.ai returns both reasoning_content and reasoning with same content)
184
+ const reasoningFields = ["reasoning_content", "reasoning", "reasoning_text"];
185
+ let foundReasoningField: string | null = null;
186
+ for (const field of reasoningFields) {
187
+ if (
188
+ (choice.delta as any)[field] !== null &&
189
+ (choice.delta as any)[field] !== undefined &&
190
+ (choice.delta as any)[field].length > 0
191
+ ) {
192
+ if (!foundReasoningField) {
193
+ foundReasoningField = field;
194
+ break;
195
+ }
196
+ }
197
+ }
198
+
199
+ if (foundReasoningField) {
200
+ if (!currentBlock || currentBlock.type !== "thinking") {
201
+ finishCurrentBlock(currentBlock);
202
+ currentBlock = {
203
+ type: "thinking",
204
+ thinking: "",
205
+ thinkingSignature: foundReasoningField,
206
+ };
207
+ output.content.push(currentBlock);
208
+ stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output });
209
+ }
210
+
211
+ if (currentBlock.type === "thinking") {
212
+ const delta = (choice.delta as any)[foundReasoningField];
213
+ currentBlock.thinking += delta;
214
+ stream.push({
215
+ type: "thinking_delta",
216
+ contentIndex: blockIndex(),
217
+ delta,
218
+ partial: output,
219
+ });
220
+ }
221
+ }
222
+
223
+ if (choice?.delta?.tool_calls) {
224
+ for (const toolCall of choice.delta.tool_calls) {
225
+ if (
226
+ !currentBlock ||
227
+ currentBlock.type !== "toolCall" ||
228
+ (toolCall.id && currentBlock.id !== toolCall.id)
229
+ ) {
230
+ finishCurrentBlock(currentBlock);
231
+ currentBlock = {
232
+ type: "toolCall",
233
+ id: toolCall.id || "",
234
+ name: toolCall.function?.name || "",
235
+ arguments: {},
236
+ partialArgs: "",
237
+ };
238
+ output.content.push(currentBlock);
239
+ stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output });
240
+ }
241
+
242
+ if (currentBlock.type === "toolCall") {
243
+ if (toolCall.id) currentBlock.id = toolCall.id;
244
+ if (toolCall.function?.name) currentBlock.name = toolCall.function.name;
245
+ let delta = "";
246
+ if (toolCall.function?.arguments) {
247
+ delta = toolCall.function.arguments;
248
+ currentBlock.partialArgs += toolCall.function.arguments;
249
+ currentBlock.arguments = parseStreamingJson(currentBlock.partialArgs);
250
+ }
251
+ stream.push({
252
+ type: "toolcall_delta",
253
+ contentIndex: blockIndex(),
254
+ delta,
255
+ partial: output,
256
+ });
257
+ }
258
+ }
259
+ }
260
+
261
+ const reasoningDetails = (choice.delta as any).reasoning_details;
262
+ if (reasoningDetails && Array.isArray(reasoningDetails)) {
263
+ for (const detail of reasoningDetails) {
264
+ if (detail.type === "reasoning.encrypted" && detail.id && detail.data) {
265
+ const matchingToolCall = output.content.find(
266
+ (b) => b.type === "toolCall" && b.id === detail.id,
267
+ ) as ToolCall | undefined;
268
+ if (matchingToolCall) {
269
+ matchingToolCall.thoughtSignature = JSON.stringify(detail);
270
+ }
271
+ }
272
+ }
273
+ }
274
+ }
275
+ }
276
+
277
+ finishCurrentBlock(currentBlock);
278
+ if (options?.signal?.aborted) {
279
+ throw new Error("Request was aborted");
280
+ }
281
+
282
+ if (output.stopReason === "aborted") {
283
+ throw new Error("Request was aborted");
284
+ }
285
+ if (output.stopReason === "error") {
286
+ throw new Error(output.errorMessage || "Provider returned an error stop reason");
287
+ }
288
+
289
+ stream.push({ type: "done", reason: output.stopReason, message: output });
290
+ stream.end();
291
+ } catch (error) {
292
+ for (const block of output.content) delete (block as any).index;
293
+ output.stopReason = options?.signal?.aborted ? "aborted" : "error";
294
+ output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
295
+ // Some providers via OpenRouter give additional information in this field.
296
+ const rawMetadata = (error as any)?.error?.metadata?.raw;
297
+ if (rawMetadata) output.errorMessage += `\n${rawMetadata}`;
298
+ stream.push({ type: "error", reason: output.stopReason, error: output });
299
+ stream.end();
300
+ }
301
+ })();
302
+
303
+ return stream;
304
+ };
305
+
306
+ export const streamSimpleOpenAICompletions: StreamFunction<"openai-completions", SimpleStreamOptions> = (
307
+ model: Model<"openai-completions">,
308
+ context: Context,
309
+ options?: SimpleStreamOptions,
310
+ ): AssistantMessageEventStream => {
311
+ const apiKey = options?.apiKey || getEnvApiKey(model.provider);
312
+ if (!apiKey) {
313
+ throw new Error(`No API key for provider: ${model.provider}`);
314
+ }
315
+
316
+ const base = buildBaseOptions(model, options, apiKey);
317
+ const reasoningEffort = supportsXhigh(model) ? options?.reasoning : clampReasoning(options?.reasoning);
318
+ const toolChoice = (options as OpenAICompletionsOptions | undefined)?.toolChoice;
319
+
320
+ return streamOpenAICompletions(model, context, {
321
+ ...base,
322
+ reasoningEffort,
323
+ toolChoice,
324
+ } satisfies OpenAICompletionsOptions);
325
+ };
326
+
327
+ function createClient(
328
+ model: Model<"openai-completions">,
329
+ context: Context,
330
+ apiKey?: string,
331
+ optionsHeaders?: Record<string, string>,
332
+ ) {
333
+ if (!apiKey) {
334
+ if (!process.env.OPENAI_API_KEY) {
335
+ throw new Error(
336
+ "OpenAI API key is required. Set OPENAI_API_KEY environment variable or pass it as an argument.",
337
+ );
338
+ }
339
+ apiKey = process.env.OPENAI_API_KEY;
340
+ }
341
+
342
+ const headers = { ...model.headers };
343
+ if (model.provider === "github-copilot") {
344
+ const hasImages = hasCopilotVisionInput(context.messages);
345
+ const copilotHeaders = buildCopilotDynamicHeaders({
346
+ messages: context.messages,
347
+ hasImages,
348
+ });
349
+ Object.assign(headers, copilotHeaders);
350
+ }
351
+
352
+ // Merge options headers last so they can override defaults
353
+ if (optionsHeaders) {
354
+ Object.assign(headers, optionsHeaders);
355
+ }
356
+
357
+ return new OpenAI({
358
+ apiKey,
359
+ baseURL: model.baseUrl,
360
+ dangerouslyAllowBrowser: true,
361
+ defaultHeaders: headers,
362
+ });
363
+ }
364
+
365
+ function buildParams(model: Model<"openai-completions">, context: Context, options?: OpenAICompletionsOptions) {
366
+ const compat = getCompat(model);
367
+ const messages = convertMessages(model, context, compat);
368
+ maybeAddOpenRouterAnthropicCacheControl(model, messages);
369
+
370
+ const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
371
+ model: model.id,
372
+ messages,
373
+ stream: true,
374
+ };
375
+
376
+ if (compat.supportsUsageInStreaming !== false) {
377
+ (params as any).stream_options = { include_usage: true };
378
+ }
379
+
380
+ if (compat.supportsStore) {
381
+ params.store = false;
382
+ }
383
+
384
+ if (options?.maxTokens) {
385
+ if (compat.maxTokensField === "max_tokens") {
386
+ (params as any).max_tokens = options.maxTokens;
387
+ } else {
388
+ params.max_completion_tokens = options.maxTokens;
389
+ }
390
+ }
391
+
392
+ if (options?.temperature !== undefined) {
393
+ params.temperature = options.temperature;
394
+ }
395
+
396
+ if (context.tools) {
397
+ params.tools = convertTools(context.tools, compat);
398
+ } else if (hasToolHistory(context.messages)) {
399
+ // Anthropic (via LiteLLM/proxy) requires tools param when conversation has tool_calls/tool_results
400
+ params.tools = [];
401
+ }
402
+
403
+ if (options?.toolChoice) {
404
+ params.tool_choice = options.toolChoice;
405
+ }
406
+
407
+ if (compat.thinkingFormat === "openrouter" && model.reasoning) {
408
+ // OpenRouter normalizes reasoning across providers via a nested reasoning object.
409
+ const openRouterParams = params as typeof params & { reasoning?: { effort?: string } };
410
+ if (options?.reasoningEffort) {
411
+ openRouterParams.reasoning = {
412
+ effort: mapReasoningEffort(options.reasoningEffort, compat.reasoningEffortMap),
413
+ };
414
+ } else {
415
+ openRouterParams.reasoning = { effort: "none" };
416
+ }
417
+ } else if (options?.reasoningEffort && model.reasoning && compat.supportsReasoningEffort) {
418
+ // OpenAI-style reasoning_effort
419
+ (params as any).reasoning_effort = mapReasoningEffort(options.reasoningEffort, compat.reasoningEffortMap);
420
+ }
421
+
422
+ // OpenRouter provider routing preferences
423
+ if (model.baseUrl.includes("openrouter.ai") && model.compat?.openRouterRouting) {
424
+ (params as any).provider = model.compat.openRouterRouting;
425
+ }
426
+
427
+ return params;
428
+ }
429
+
430
+ function mapReasoningEffort(
431
+ effort: NonNullable<OpenAICompletionsOptions["reasoningEffort"]>,
432
+ reasoningEffortMap: Partial<Record<NonNullable<OpenAICompletionsOptions["reasoningEffort"]>, string>>,
433
+ ): string {
434
+ return reasoningEffortMap[effort] ?? effort;
435
+ }
436
+
437
+ function maybeAddOpenRouterAnthropicCacheControl(
438
+ model: Model<"openai-completions">,
439
+ messages: ChatCompletionMessageParam[],
440
+ ): void {
441
+ if (model.provider !== "openrouter" || !model.id.startsWith("anthropic/")) return;
442
+
443
+ // Anthropic-style caching requires cache_control on a text part. Add a breakpoint
444
+ // on the last user/assistant message (walking backwards until we find text content).
445
+ for (let i = messages.length - 1; i >= 0; i--) {
446
+ const msg = messages[i];
447
+ if (msg.role !== "user" && msg.role !== "assistant") continue;
448
+
449
+ const content = msg.content;
450
+ if (typeof content === "string") {
451
+ msg.content = [
452
+ Object.assign({ type: "text" as const, text: content }, { cache_control: { type: "ephemeral" } }),
453
+ ];
454
+ return;
455
+ }
456
+
457
+ if (!Array.isArray(content)) continue;
458
+
459
+ // Find last text part and add cache_control
460
+ for (let j = content.length - 1; j >= 0; j--) {
461
+ const part = content[j];
462
+ if (part?.type === "text") {
463
+ Object.assign(part, { cache_control: { type: "ephemeral" } });
464
+ return;
465
+ }
466
+ }
467
+ }
468
+ }
469
+
470
+ export function convertMessages(
471
+ model: Model<"openai-completions">,
472
+ context: Context,
473
+ compat: Required<OpenAICompletionsCompat>,
474
+ ): ChatCompletionMessageParam[] {
475
+ const params: ChatCompletionMessageParam[] = [];
476
+
477
+ const normalizeToolCallId = (id: string): string => {
478
+ // Handle pipe-separated IDs from Responses-style APIs:
479
+ // {call_id}|{id} where the item id may contain characters chat-completions rejects.
480
+ if (id.includes("|")) {
481
+ const [callId] = id.split("|");
482
+ return callId.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40);
483
+ }
484
+
485
+ return id;
486
+ };
487
+
488
+ const transformedMessages = transformMessages(context.messages, model, (id) => normalizeToolCallId(id));
489
+
490
+ if (context.systemPrompt) {
491
+ const useDeveloperRole = model.reasoning && compat.supportsDeveloperRole;
492
+ const role = useDeveloperRole ? "developer" : "system";
493
+ params.push({ role: role, content: sanitizeSurrogates(context.systemPrompt) });
494
+ }
495
+
496
+ let lastRole: string | null = null;
497
+
498
+ for (let i = 0; i < transformedMessages.length; i++) {
499
+ const msg = transformedMessages[i];
500
+ // Some providers don't allow user messages directly after tool results
501
+ // Insert a synthetic assistant message to bridge the gap
502
+ if (compat.requiresAssistantAfterToolResult && lastRole === "toolResult" && msg.role === "user") {
503
+ params.push({
504
+ role: "assistant",
505
+ content: "I have processed the tool results.",
506
+ });
507
+ }
508
+
509
+ if (msg.role === "user") {
510
+ if (typeof msg.content === "string") {
511
+ params.push({
512
+ role: "user",
513
+ content: sanitizeSurrogates(msg.content),
514
+ });
515
+ } else {
516
+ const content: ChatCompletionContentPart[] = msg.content.map((item): ChatCompletionContentPart => {
517
+ if (item.type === "text") {
518
+ return {
519
+ type: "text",
520
+ text: sanitizeSurrogates(item.text),
521
+ } satisfies ChatCompletionContentPartText;
522
+ } else {
523
+ return {
524
+ type: "image_url",
525
+ image_url: {
526
+ url: `data:${item.mimeType};base64,${item.data}`,
527
+ },
528
+ } satisfies ChatCompletionContentPartImage;
529
+ }
530
+ });
531
+ const filteredContent = !model.input.includes("image")
532
+ ? content.filter((c) => c.type !== "image_url")
533
+ : content;
534
+ if (filteredContent.length === 0) continue;
535
+ params.push({
536
+ role: "user",
537
+ content: filteredContent,
538
+ });
539
+ }
540
+ } else if (msg.role === "assistant") {
541
+ // Some providers don't accept null content, use empty string instead
542
+ const assistantMsg: ChatCompletionAssistantMessageParam = {
543
+ role: "assistant",
544
+ content: compat.requiresAssistantAfterToolResult ? "" : null,
545
+ };
546
+
547
+ const textBlocks = msg.content.filter((b) => b.type === "text") as TextContent[];
548
+ // Filter out empty text blocks to avoid API validation errors
549
+ const nonEmptyTextBlocks = textBlocks.filter((b) => b.text && b.text.trim().length > 0);
550
+ if (nonEmptyTextBlocks.length > 0) {
551
+ // Always send assistant content as a plain string (OpenAI Chat Completions
552
+ // API standard format). Sending as an array of {type:"text", text:"..."}
553
+ // objects is non-standard and causes some models (e.g. DeepSeek V3.2 via
554
+ // NVIDIA NIM) to mirror the content-block structure literally in their
555
+ // output, producing recursive nesting like [{'type':'text','text':'[{...}]'}].
556
+ assistantMsg.content = nonEmptyTextBlocks.map((b) => sanitizeSurrogates(b.text)).join("");
557
+ }
558
+
559
+ // Handle thinking blocks
560
+ const thinkingBlocks = msg.content.filter((b) => b.type === "thinking") as ThinkingContent[];
561
+ // Filter out empty thinking blocks to avoid API validation errors
562
+ const nonEmptyThinkingBlocks = thinkingBlocks.filter((b) => b.thinking && b.thinking.trim().length > 0);
563
+ if (nonEmptyThinkingBlocks.length > 0) {
564
+ if (compat.requiresThinkingAsText) {
565
+ // Convert thinking blocks to plain text (no tags to avoid model mimicking them)
566
+ const thinkingText = nonEmptyThinkingBlocks.map((b) => b.thinking).join("\n\n");
567
+ const textContent = assistantMsg.content as Array<{ type: "text"; text: string }> | null;
568
+ if (textContent) {
569
+ textContent.unshift({ type: "text", text: thinkingText });
570
+ } else {
571
+ assistantMsg.content = [{ type: "text", text: thinkingText }];
572
+ }
573
+ } else {
574
+ // Use the signature from the first thinking block if available (for llama.cpp server + gpt-oss)
575
+ const signature = nonEmptyThinkingBlocks[0].thinkingSignature;
576
+ if (signature && signature.length > 0) {
577
+ (assistantMsg as any)[signature] = nonEmptyThinkingBlocks.map((b) => b.thinking).join("\n");
578
+ }
579
+ }
580
+ }
581
+
582
+ const toolCalls = msg.content.filter((b) => b.type === "toolCall") as ToolCall[];
583
+ if (toolCalls.length > 0) {
584
+ assistantMsg.tool_calls = toolCalls.map((tc) => ({
585
+ id: tc.id,
586
+ type: "function" as const,
587
+ function: {
588
+ name: tc.name,
589
+ arguments: JSON.stringify(tc.arguments),
590
+ },
591
+ }));
592
+ const reasoningDetails = toolCalls
593
+ .filter((tc) => tc.thoughtSignature)
594
+ .map((tc) => {
595
+ try {
596
+ return JSON.parse(tc.thoughtSignature!);
597
+ } catch {
598
+ return null;
599
+ }
600
+ })
601
+ .filter(Boolean);
602
+ if (reasoningDetails.length > 0) {
603
+ (assistantMsg as any).reasoning_details = reasoningDetails;
604
+ }
605
+ }
606
+ // Skip assistant messages that have no content and no tool calls.
607
+ // Some providers require "either content or tool_calls, but not none".
608
+ // Other providers also don't accept empty assistant messages.
609
+ // This handles aborted assistant responses that got no content.
610
+ const content = assistantMsg.content;
611
+ const hasContent =
612
+ content !== null &&
613
+ content !== undefined &&
614
+ (typeof content === "string" ? content.length > 0 : content.length > 0);
615
+ if (!hasContent && !assistantMsg.tool_calls) {
616
+ continue;
617
+ }
618
+ params.push(assistantMsg);
619
+ } else if (msg.role === "toolResult") {
620
+ const imageBlocks: Array<{ type: "image_url"; image_url: { url: string } }> = [];
621
+ let j = i;
622
+
623
+ for (; j < transformedMessages.length && transformedMessages[j].role === "toolResult"; j++) {
624
+ const toolMsg = transformedMessages[j] as ToolResultMessage;
625
+
626
+ // Extract text and image content
627
+ const textResult = toolMsg.content
628
+ .filter((c) => c.type === "text")
629
+ .map((c) => (c as any).text)
630
+ .join("\n");
631
+ const hasImages = toolMsg.content.some((c) => c.type === "image");
632
+
633
+ // Always send tool result with text (or placeholder if only images)
634
+ const hasText = textResult.length > 0;
635
+ // Some providers require the 'name' field in tool results
636
+ const toolResultMsg: ChatCompletionToolMessageParam = {
637
+ role: "tool",
638
+ content: sanitizeSurrogates(hasText ? textResult : "(see attached image)"),
639
+ tool_call_id: toolMsg.toolCallId,
640
+ };
641
+ if (compat.requiresToolResultName && toolMsg.toolName) {
642
+ (toolResultMsg as any).name = toolMsg.toolName;
643
+ }
644
+ params.push(toolResultMsg);
645
+
646
+ if (hasImages && model.input.includes("image")) {
647
+ for (const block of toolMsg.content) {
648
+ if (block.type === "image") {
649
+ imageBlocks.push({
650
+ type: "image_url",
651
+ image_url: {
652
+ url: `data:${(block as any).mimeType};base64,${(block as any).data}`,
653
+ },
654
+ });
655
+ }
656
+ }
657
+ }
658
+ }
659
+
660
+ i = j - 1;
661
+
662
+ if (imageBlocks.length > 0) {
663
+ if (compat.requiresAssistantAfterToolResult) {
664
+ params.push({
665
+ role: "assistant",
666
+ content: "I have processed the tool results.",
667
+ });
668
+ }
669
+
670
+ params.push({
671
+ role: "user",
672
+ content: [
673
+ {
674
+ type: "text",
675
+ text: "Attached image(s) from tool result:",
676
+ },
677
+ ...imageBlocks,
678
+ ],
679
+ });
680
+ lastRole = "user";
681
+ } else {
682
+ lastRole = "toolResult";
683
+ }
684
+ continue;
685
+ }
686
+
687
+ lastRole = msg.role;
688
+ }
689
+
690
+ return params;
691
+ }
692
+
693
+ function convertTools(
694
+ tools: Tool[],
695
+ compat: Required<OpenAICompletionsCompat>,
696
+ ): OpenAI.Chat.Completions.ChatCompletionTool[] {
697
+ return tools.map((tool) => ({
698
+ type: "function",
699
+ function: {
700
+ name: tool.name,
701
+ description: tool.description,
702
+ parameters: tool.parameters as any, // TypeBox already generates JSON Schema
703
+ // Only include strict if provider supports it. Some reject unknown fields.
704
+ ...(compat.supportsStrictMode !== false && { strict: false }),
705
+ },
706
+ }));
707
+ }
708
+
709
+ function parseChunkUsage(
710
+ rawUsage: {
711
+ prompt_tokens?: number;
712
+ completion_tokens?: number;
713
+ prompt_tokens_details?: { cached_tokens?: number };
714
+ completion_tokens_details?: { reasoning_tokens?: number };
715
+ },
716
+ model: Model<"openai-completions">,
717
+ ): AssistantMessage["usage"] {
718
+ const cachedTokens = rawUsage.prompt_tokens_details?.cached_tokens || 0;
719
+ const reasoningTokens = rawUsage.completion_tokens_details?.reasoning_tokens || 0;
720
+ // OpenAI includes cached tokens in prompt_tokens, so subtract to get non-cached input
721
+ const input = (rawUsage.prompt_tokens || 0) - cachedTokens;
722
+ // Compute totalTokens ourselves since some responses report reasoning tokens separately.
723
+ const outputTokens = (rawUsage.completion_tokens || 0) + reasoningTokens;
724
+ const usage: AssistantMessage["usage"] = {
725
+ input,
726
+ output: outputTokens,
727
+ cacheRead: cachedTokens,
728
+ cacheWrite: 0,
729
+ totalTokens: input + outputTokens + cachedTokens,
730
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
731
+ };
732
+ calculateCost(model, usage);
733
+ return usage;
734
+ }
735
+
736
+ function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | string): {
737
+ stopReason: StopReason;
738
+ errorMessage?: string;
739
+ } {
740
+ if (reason === null) return { stopReason: "stop" };
741
+ switch (reason) {
742
+ case "stop":
743
+ case "end":
744
+ return { stopReason: "stop" };
745
+ case "length":
746
+ return { stopReason: "length" };
747
+ case "function_call":
748
+ case "tool_calls":
749
+ return { stopReason: "toolUse" };
750
+ case "content_filter":
751
+ return { stopReason: "error", errorMessage: "Provider finish_reason: content_filter" };
752
+ case "network_error":
753
+ return { stopReason: "error", errorMessage: "Provider finish_reason: network_error" };
754
+ default:
755
+ return {
756
+ stopReason: "error",
757
+ errorMessage: `Provider finish_reason: ${reason}`,
758
+ };
759
+ }
760
+ }
761
+
762
+ /**
763
+ * Detect compatibility settings from provider and baseUrl for known providers.
764
+ * Provider takes precedence over URL-based detection since it's explicitly configured.
765
+ * Returns a fully resolved OpenAICompletionsCompat object with all fields set.
766
+ */
767
+ function detectCompat(model: Model<"openai-completions">): Required<OpenAICompletionsCompat> {
768
+ const provider = model.provider;
769
+ const baseUrl = model.baseUrl;
770
+ const isOpenRouter = provider === "openrouter" || baseUrl.includes("openrouter.ai");
771
+
772
+ return {
773
+ supportsStore: !isOpenRouter,
774
+ supportsDeveloperRole: !isOpenRouter,
775
+ supportsReasoningEffort: !isOpenRouter,
776
+ reasoningEffortMap: {},
777
+ supportsUsageInStreaming: true,
778
+ maxTokensField: "max_completion_tokens",
779
+ requiresToolResultName: false,
780
+ requiresAssistantAfterToolResult: false,
781
+ requiresThinkingAsText: false,
782
+ thinkingFormat: isOpenRouter ? "openrouter" : "openai",
783
+ openRouterRouting: {},
784
+ supportsStrictMode: true,
785
+ };
786
+ }
787
+
788
+ /**
789
+ * Get resolved compatibility settings for a model.
790
+ * Uses explicit model.compat if provided, otherwise auto-detects from provider/URL.
791
+ */
792
+ function getCompat(model: Model<"openai-completions">): Required<OpenAICompletionsCompat> {
793
+ const detected = detectCompat(model);
794
+ if (!model.compat) return detected;
795
+
796
+ return {
797
+ supportsStore: model.compat.supportsStore ?? detected.supportsStore,
798
+ supportsDeveloperRole: model.compat.supportsDeveloperRole ?? detected.supportsDeveloperRole,
799
+ supportsReasoningEffort: model.compat.supportsReasoningEffort ?? detected.supportsReasoningEffort,
800
+ reasoningEffortMap: model.compat.reasoningEffortMap ?? detected.reasoningEffortMap,
801
+ supportsUsageInStreaming: model.compat.supportsUsageInStreaming ?? detected.supportsUsageInStreaming,
802
+ maxTokensField: model.compat.maxTokensField ?? detected.maxTokensField,
803
+ requiresToolResultName: model.compat.requiresToolResultName ?? detected.requiresToolResultName,
804
+ requiresAssistantAfterToolResult:
805
+ model.compat.requiresAssistantAfterToolResult ?? detected.requiresAssistantAfterToolResult,
806
+ requiresThinkingAsText: model.compat.requiresThinkingAsText ?? detected.requiresThinkingAsText,
807
+ thinkingFormat: model.compat.thinkingFormat ?? detected.thinkingFormat,
808
+ openRouterRouting: model.compat.openRouterRouting ?? {},
809
+ supportsStrictMode: model.compat.supportsStrictMode ?? detected.supportsStrictMode,
810
+ };
811
+ }