@f5-sales-demo/pi-ai 19.51.2

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 (128) hide show
  1. package/CHANGELOG.md +1997 -0
  2. package/README.md +1160 -0
  3. package/package.json +135 -0
  4. package/src/api-registry.ts +95 -0
  5. package/src/auth-storage.ts +2694 -0
  6. package/src/cli.ts +493 -0
  7. package/src/index.ts +42 -0
  8. package/src/model-cache.ts +97 -0
  9. package/src/model-manager.ts +349 -0
  10. package/src/model-thinking.ts +561 -0
  11. package/src/models.json +49439 -0
  12. package/src/models.json.d.ts +9 -0
  13. package/src/models.ts +56 -0
  14. package/src/prompts/turn-aborted-guidance.md +4 -0
  15. package/src/provider-details.ts +81 -0
  16. package/src/provider-models/descriptors.ts +285 -0
  17. package/src/provider-models/google.ts +90 -0
  18. package/src/provider-models/index.ts +4 -0
  19. package/src/provider-models/openai-compat.ts +2074 -0
  20. package/src/provider-models/special.ts +106 -0
  21. package/src/providers/amazon-bedrock.ts +706 -0
  22. package/src/providers/anthropic.ts +1682 -0
  23. package/src/providers/azure-openai-responses.ts +391 -0
  24. package/src/providers/cursor/gen/agent_pb.ts +15274 -0
  25. package/src/providers/cursor/proto/agent.proto +3526 -0
  26. package/src/providers/cursor/proto/buf.gen.yaml +6 -0
  27. package/src/providers/cursor/proto/buf.yaml +17 -0
  28. package/src/providers/cursor.ts +2218 -0
  29. package/src/providers/github-copilot-headers.ts +140 -0
  30. package/src/providers/gitlab-duo.ts +381 -0
  31. package/src/providers/google-gemini-cli.ts +1133 -0
  32. package/src/providers/google-shared.ts +354 -0
  33. package/src/providers/google-vertex.ts +436 -0
  34. package/src/providers/google.ts +381 -0
  35. package/src/providers/kimi.ts +151 -0
  36. package/src/providers/openai-codex/constants.ts +43 -0
  37. package/src/providers/openai-codex/request-transformer.ts +158 -0
  38. package/src/providers/openai-codex/response-handler.ts +81 -0
  39. package/src/providers/openai-codex-responses.ts +2345 -0
  40. package/src/providers/openai-completions-compat.ts +159 -0
  41. package/src/providers/openai-completions.ts +1290 -0
  42. package/src/providers/openai-responses-shared.ts +452 -0
  43. package/src/providers/openai-responses.ts +519 -0
  44. package/src/providers/register-builtins.ts +329 -0
  45. package/src/providers/synthetic.ts +154 -0
  46. package/src/providers/transform-messages.ts +234 -0
  47. package/src/rate-limit-utils.ts +84 -0
  48. package/src/stream.ts +728 -0
  49. package/src/types.ts +546 -0
  50. package/src/usage/claude.ts +337 -0
  51. package/src/usage/gemini.ts +248 -0
  52. package/src/usage/github-copilot.ts +421 -0
  53. package/src/usage/google-antigravity.ts +200 -0
  54. package/src/usage/kimi.ts +286 -0
  55. package/src/usage/minimax-code.ts +31 -0
  56. package/src/usage/openai-codex.ts +387 -0
  57. package/src/usage/zai.ts +247 -0
  58. package/src/usage.ts +130 -0
  59. package/src/utils/abort.ts +36 -0
  60. package/src/utils/anthropic-auth.ts +293 -0
  61. package/src/utils/discovery/antigravity.ts +261 -0
  62. package/src/utils/discovery/codex.ts +371 -0
  63. package/src/utils/discovery/cursor.ts +306 -0
  64. package/src/utils/discovery/gemini.ts +248 -0
  65. package/src/utils/discovery/index.ts +5 -0
  66. package/src/utils/discovery/openai-compatible.ts +224 -0
  67. package/src/utils/event-stream.ts +209 -0
  68. package/src/utils/http-inspector.ts +165 -0
  69. package/src/utils/idle-iterator.ts +176 -0
  70. package/src/utils/json-parse.ts +28 -0
  71. package/src/utils/oauth/alibaba-coding-plan.ts +59 -0
  72. package/src/utils/oauth/anthropic.ts +134 -0
  73. package/src/utils/oauth/api-key-validation.ts +92 -0
  74. package/src/utils/oauth/callback-server.ts +276 -0
  75. package/src/utils/oauth/cerebras.ts +59 -0
  76. package/src/utils/oauth/cloudflare-ai-gateway.ts +48 -0
  77. package/src/utils/oauth/cursor.ts +157 -0
  78. package/src/utils/oauth/github-copilot.ts +358 -0
  79. package/src/utils/oauth/gitlab-duo.ts +123 -0
  80. package/src/utils/oauth/google-antigravity.ts +275 -0
  81. package/src/utils/oauth/google-gemini-cli.ts +334 -0
  82. package/src/utils/oauth/huggingface.ts +62 -0
  83. package/src/utils/oauth/index.ts +512 -0
  84. package/src/utils/oauth/kagi.ts +47 -0
  85. package/src/utils/oauth/kilo.ts +87 -0
  86. package/src/utils/oauth/kimi.ts +251 -0
  87. package/src/utils/oauth/litellm.ts +81 -0
  88. package/src/utils/oauth/lm-studio.ts +40 -0
  89. package/src/utils/oauth/minimax-code.ts +78 -0
  90. package/src/utils/oauth/moonshot.ts +59 -0
  91. package/src/utils/oauth/nanogpt.ts +51 -0
  92. package/src/utils/oauth/nvidia.ts +70 -0
  93. package/src/utils/oauth/oauth.html +199 -0
  94. package/src/utils/oauth/ollama.ts +47 -0
  95. package/src/utils/oauth/openai-codex.ts +190 -0
  96. package/src/utils/oauth/opencode.ts +49 -0
  97. package/src/utils/oauth/parallel.ts +46 -0
  98. package/src/utils/oauth/perplexity.ts +200 -0
  99. package/src/utils/oauth/pkce.ts +18 -0
  100. package/src/utils/oauth/qianfan.ts +58 -0
  101. package/src/utils/oauth/qwen-portal.ts +60 -0
  102. package/src/utils/oauth/synthetic.ts +60 -0
  103. package/src/utils/oauth/tavily.ts +46 -0
  104. package/src/utils/oauth/together.ts +59 -0
  105. package/src/utils/oauth/types.ts +89 -0
  106. package/src/utils/oauth/venice.ts +59 -0
  107. package/src/utils/oauth/vercel-ai-gateway.ts +47 -0
  108. package/src/utils/oauth/vllm.ts +40 -0
  109. package/src/utils/oauth/xiaomi.ts +88 -0
  110. package/src/utils/oauth/zai.ts +60 -0
  111. package/src/utils/oauth/zenmux.ts +51 -0
  112. package/src/utils/overflow.ts +134 -0
  113. package/src/utils/retry-after.ts +110 -0
  114. package/src/utils/retry.ts +93 -0
  115. package/src/utils/schema/CONSTRAINTS.md +160 -0
  116. package/src/utils/schema/adapt.ts +20 -0
  117. package/src/utils/schema/compatibility.ts +397 -0
  118. package/src/utils/schema/dereference.ts +93 -0
  119. package/src/utils/schema/equality.ts +93 -0
  120. package/src/utils/schema/fields.ts +147 -0
  121. package/src/utils/schema/index.ts +9 -0
  122. package/src/utils/schema/normalize-cca.ts +479 -0
  123. package/src/utils/schema/sanitize-google.ts +212 -0
  124. package/src/utils/schema/strict-mode.ts +385 -0
  125. package/src/utils/schema/types.ts +5 -0
  126. package/src/utils/tool-choice.ts +81 -0
  127. package/src/utils/validation.ts +664 -0
  128. package/src/utils.ts +147 -0
@@ -0,0 +1,1290 @@
1
+ import { $env } from "@f5xc-salesdemos/pi-utils";
2
+ import OpenAI from "openai";
3
+ import type {
4
+ ChatCompletionAssistantMessageParam,
5
+ ChatCompletionChunk,
6
+ ChatCompletionContentPart,
7
+ ChatCompletionContentPartImage,
8
+ ChatCompletionContentPartText,
9
+ ChatCompletionMessageParam,
10
+ ChatCompletionToolMessageParam,
11
+ } from "openai/resources/chat/completions";
12
+ import { calculateCost } from "../models";
13
+ import { getEnvApiKey } from "../stream";
14
+ import {
15
+ type AssistantMessage,
16
+ type Context,
17
+ isSpecialServiceTier,
18
+ type Message,
19
+ type MessageAttribution,
20
+ type Model,
21
+ type ServiceTier,
22
+ type StopReason,
23
+ type StreamFunction,
24
+ type StreamOptions,
25
+ type TextContent,
26
+ type ThinkingContent,
27
+ type Tool,
28
+ type ToolCall,
29
+ type ToolChoice,
30
+ type ToolResultMessage,
31
+ } from "../types";
32
+ import { createAbortSourceTracker } from "../utils/abort";
33
+ import { AssistantMessageEventStream } from "../utils/event-stream";
34
+ import {
35
+ type CapturedHttpErrorResponse,
36
+ finalizeErrorMessage,
37
+ type RawHttpRequestDump,
38
+ rewriteCopilotAuthError,
39
+ } from "../utils/http-inspector";
40
+ import {
41
+ createFirstEventWatchdog,
42
+ getOpenAIStreamIdleTimeoutMs,
43
+ getStreamFirstEventTimeoutMs,
44
+ iterateWithIdleTimeout,
45
+ markFirstStreamEvent,
46
+ } from "../utils/idle-iterator";
47
+ import { parseStreamingJson } from "../utils/json-parse";
48
+ import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
49
+ import { getKimiCommonHeaders } from "../utils/oauth/kimi";
50
+ import { extractHttpStatusFromError } from "../utils/retry";
51
+ import { adaptSchemaForStrict, NO_STRICT } from "../utils/schema";
52
+ import { mapToOpenAICompletionsToolChoice } from "../utils/tool-choice";
53
+ import {
54
+ buildCopilotDynamicHeaders,
55
+ hasCopilotVisionInput,
56
+ resolveGitHubCopilotBaseUrl,
57
+ } from "./github-copilot-headers";
58
+ import { detectOpenAICompat, type ResolvedOpenAICompat, resolveOpenAICompat } from "./openai-completions-compat";
59
+ import { transformMessages } from "./transform-messages";
60
+
61
+ /**
62
+ * Normalize tool call ID for Mistral.
63
+ * Mistral requires tool IDs to be exactly 9 alphanumeric characters (a-z, A-Z, 0-9).
64
+ */
65
+ function normalizeMistralToolId(id: string, isMistral: boolean): string {
66
+ if (!isMistral) return id;
67
+ // Remove non-alphanumeric characters
68
+ let normalized = id.replace(/[^a-zA-Z0-9]/g, "");
69
+ // Mistral requires exactly 9 characters
70
+ if (normalized.length < 9) {
71
+ // Pad with deterministic characters based on original ID to ensure matching
72
+ const padding = "ABCDEFGHI";
73
+ normalized = normalized + padding.slice(0, 9 - normalized.length);
74
+ } else if (normalized.length > 9) {
75
+ normalized = normalized.slice(0, 9);
76
+ }
77
+ return normalized;
78
+ }
79
+
80
+ function serializeToolArguments(value: unknown): string {
81
+ if (value && typeof value === "object" && !Array.isArray(value)) {
82
+ try {
83
+ return JSON.stringify(value);
84
+ } catch {
85
+ return "{}";
86
+ }
87
+ }
88
+
89
+ if (typeof value === "string") {
90
+ const trimmed = value.trim();
91
+ if (trimmed.length === 0) return "{}";
92
+ try {
93
+ const parsed = JSON.parse(trimmed);
94
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
95
+ return JSON.stringify(parsed);
96
+ }
97
+ } catch {}
98
+ return "{}";
99
+ }
100
+
101
+ return "{}";
102
+ }
103
+
104
+ /**
105
+ * Check if conversation messages contain tool calls or tool results.
106
+ * This is needed because Anthropic (via proxy) requires the tools param
107
+ * to be present when messages include tool_calls or tool role messages.
108
+ */
109
+ function hasToolHistory(messages: Message[]): boolean {
110
+ for (const msg of messages) {
111
+ if (msg.role === "toolResult") {
112
+ return true;
113
+ }
114
+ if (msg.role === "assistant") {
115
+ if (msg.content.some(block => block.type === "toolCall")) {
116
+ return true;
117
+ }
118
+ }
119
+ }
120
+ return false;
121
+ }
122
+
123
+ export interface OpenAICompletionsOptions extends StreamOptions {
124
+ toolChoice?: ToolChoice;
125
+ reasoning?: "minimal" | "low" | "medium" | "high" | "xhigh";
126
+ serviceTier?: ServiceTier;
127
+ }
128
+
129
+ type OpenAICompletionsSamplingParams = OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming & {
130
+ top_k?: number;
131
+ min_p?: number;
132
+ repetition_penalty?: number;
133
+ };
134
+
135
+ type AppliedToolStrictMode = "mixed" | "all_strict" | "none";
136
+ type ToolStrictModeOverride = Exclude<ResolvedOpenAICompat["toolStrictMode"], "mixed"> | undefined;
137
+
138
+ type BuiltOpenAICompletionTools = {
139
+ tools: OpenAI.Chat.Completions.ChatCompletionTool[];
140
+ toolStrictMode: AppliedToolStrictMode;
141
+ };
142
+
143
+ // LIMITATION: The think tag parser uses naive string matching for <think>/<thinking> tags.
144
+ // If MiniMax models output these literal strings in code blocks, XML examples, or explanations,
145
+ // they will be incorrectly consumed as thinking delimiters, truncating visible output.
146
+ // A streaming parser with arbitrary chunk boundaries cannot reliably detect code block context.
147
+ // This is acceptable because: (1) only enabled for minimax-code providers, (2) MiniMax models
148
+ // use these tags as their actual thinking format, and (3) false positives are rare in practice.
149
+ const MINIMAX_THINK_OPEN_TAGS = ["<think>", "<thinking>"] as const;
150
+ const MINIMAX_THINK_CLOSE_TAGS = ["</think>", "</thinking>"] as const;
151
+
152
+ function findFirstTag(text: string, tags: readonly string[]): { index: number; tag: string } | undefined {
153
+ let earliestIndex = Number.POSITIVE_INFINITY;
154
+ let earliestTag: string | undefined;
155
+ for (const tag of tags) {
156
+ const index = text.indexOf(tag);
157
+ if (index !== -1 && index < earliestIndex) {
158
+ earliestIndex = index;
159
+ earliestTag = tag;
160
+ }
161
+ }
162
+ if (!earliestTag) return undefined;
163
+ return { index: earliestIndex, tag: earliestTag };
164
+ }
165
+
166
+ function getTrailingPartialTag(text: string, tags: readonly string[]): string {
167
+ let maxLength = 0;
168
+ for (const tag of tags) {
169
+ const maxCandidateLength = Math.min(tag.length - 1, text.length);
170
+ for (let length = maxCandidateLength; length > 0; length--) {
171
+ if (text.endsWith(tag.slice(0, length))) {
172
+ if (length > maxLength) maxLength = length;
173
+ break;
174
+ }
175
+ }
176
+ }
177
+ if (maxLength === 0) return "";
178
+ return text.slice(-maxLength);
179
+ }
180
+
181
+ const OPENAI_COMPLETIONS_FIRST_EVENT_TIMEOUT_MESSAGE =
182
+ "OpenAI completions stream timed out while waiting for the first event";
183
+
184
+ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
185
+ model: Model<"openai-completions">,
186
+ context: Context,
187
+ options?: OpenAICompletionsOptions,
188
+ ): AssistantMessageEventStream => {
189
+ const stream = new AssistantMessageEventStream();
190
+
191
+ (async () => {
192
+ const startTime = Date.now();
193
+ let firstTokenTime: number | undefined;
194
+ let getCapturedErrorResponse: (() => CapturedHttpErrorResponse | undefined) | undefined;
195
+
196
+ const output: AssistantMessage = {
197
+ role: "assistant",
198
+ content: [],
199
+ api: model.api,
200
+ provider: model.provider,
201
+ model: model.id,
202
+ usage: {
203
+ input: 0,
204
+ output: 0,
205
+ cacheRead: 0,
206
+ cacheWrite: 0,
207
+ totalTokens: 0,
208
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
209
+ },
210
+ stopReason: "stop",
211
+ timestamp: Date.now(),
212
+ };
213
+ let rawRequestDump: RawHttpRequestDump | undefined;
214
+ const abortTracker = createAbortSourceTracker(options?.signal);
215
+ const firstEventTimeoutAbortError = new Error(OPENAI_COMPLETIONS_FIRST_EVENT_TIMEOUT_MESSAGE);
216
+ const { requestAbortController, requestSignal } = abortTracker;
217
+
218
+ try {
219
+ const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
220
+ const idleTimeoutMs = getOpenAIStreamIdleTimeoutMs();
221
+ const {
222
+ client,
223
+ copilotPremiumRequests,
224
+ baseUrl,
225
+ requestHeaders,
226
+ getCapturedErrorResponse: captureErrorResponse,
227
+ clearCapturedErrorResponse,
228
+ } = await createClient(model, context, apiKey, options?.headers, options?.initiatorOverride);
229
+ getCapturedErrorResponse = captureErrorResponse;
230
+ let appliedToolStrictMode: AppliedToolStrictMode = "mixed";
231
+ const createCompletionsStream = async (toolStrictModeOverride?: ToolStrictModeOverride) => {
232
+ clearCapturedErrorResponse();
233
+ const { params, toolStrictMode } = buildParams(model, context, options, baseUrl, toolStrictModeOverride);
234
+ appliedToolStrictMode = toolStrictMode;
235
+ options?.onPayload?.(params);
236
+ rawRequestDump = {
237
+ provider: model.provider,
238
+ api: output.api,
239
+ model: model.id,
240
+ method: "POST",
241
+ url: `${baseUrl}/chat/completions`,
242
+ headers: requestHeaders,
243
+ body: params,
244
+ };
245
+ return client.chat.completions.create(params, { signal: requestSignal });
246
+ };
247
+ let openaiStream: AsyncIterable<ChatCompletionChunk>;
248
+ try {
249
+ openaiStream = await createCompletionsStream();
250
+ } catch (error) {
251
+ const capturedErrorResponse = getCapturedErrorResponse();
252
+ if (!shouldRetryWithoutStrictTools(error, capturedErrorResponse, appliedToolStrictMode, context.tools)) {
253
+ throw error;
254
+ }
255
+ openaiStream = await createCompletionsStream("none");
256
+ }
257
+ const firstEventWatchdog = createFirstEventWatchdog(
258
+ options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs),
259
+ () => abortTracker.abortLocally(firstEventTimeoutAbortError),
260
+ );
261
+ if (copilotPremiumRequests !== undefined) output.usage.premiumRequests = copilotPremiumRequests;
262
+ stream.push({ type: "start", partial: output });
263
+
264
+ const parseMiniMaxThinkTags = model.provider === "minimax-code";
265
+ type OpenAIStreamBlock = TextContent | ThinkingContent | (ToolCall & { partialArgs: string });
266
+ let currentBlock: OpenAIStreamBlock | undefined;
267
+ const blockIndex = (block: OpenAIStreamBlock | undefined): number => {
268
+ if (!block) return Math.max(0, output.content.length - 1);
269
+ return output.content.indexOf(block);
270
+ };
271
+ const finishCurrentBlock = (block: OpenAIStreamBlock | undefined): void => {
272
+ if (!block) return;
273
+ const contentIndex = blockIndex(block);
274
+ if (contentIndex < 0) return;
275
+ if (block.type === "text") {
276
+ stream.push({ type: "text_end", contentIndex, content: block.text, partial: output });
277
+ return;
278
+ }
279
+ if (block.type === "thinking") {
280
+ stream.push({ type: "thinking_end", contentIndex, content: block.thinking, partial: output });
281
+ return;
282
+ }
283
+ block.arguments = parseStreamingJson(block.partialArgs);
284
+ delete (block as { partialArgs?: string }).partialArgs;
285
+ stream.push({ type: "toolcall_end", contentIndex, toolCall: block, partial: output });
286
+ };
287
+ const appendText = (
288
+ message: AssistantMessage,
289
+ eventStream: AssistantMessageEventStream,
290
+ text: string,
291
+ ): void => {
292
+ if (currentBlock?.type !== "text") {
293
+ finishCurrentBlock(currentBlock);
294
+ currentBlock = { type: "text", text: "" };
295
+ message.content.push(currentBlock);
296
+ eventStream.push({ type: "text_start", contentIndex: blockIndex(currentBlock), partial: message });
297
+ }
298
+ currentBlock.text += text;
299
+ eventStream.push({
300
+ type: "text_delta",
301
+ contentIndex: blockIndex(currentBlock),
302
+ delta: text,
303
+ partial: message,
304
+ });
305
+ };
306
+ const appendThinking = (
307
+ message: AssistantMessage,
308
+ eventStream: AssistantMessageEventStream,
309
+ thinking: string,
310
+ signature?: string,
311
+ ): void => {
312
+ if (
313
+ currentBlock?.type !== "thinking" ||
314
+ (signature !== undefined && currentBlock.thinkingSignature !== signature)
315
+ ) {
316
+ finishCurrentBlock(currentBlock);
317
+ currentBlock = { type: "thinking", thinking: "", thinkingSignature: signature };
318
+ message.content.push(currentBlock);
319
+ eventStream.push({
320
+ type: "thinking_start",
321
+ contentIndex: blockIndex(currentBlock),
322
+ partial: message,
323
+ });
324
+ }
325
+ if (signature !== undefined && !currentBlock.thinkingSignature) {
326
+ currentBlock.thinkingSignature = signature;
327
+ }
328
+ currentBlock.thinking += thinking;
329
+ eventStream.push({
330
+ type: "thinking_delta",
331
+ contentIndex: blockIndex(currentBlock),
332
+ delta: thinking,
333
+ partial: message,
334
+ });
335
+ };
336
+
337
+ let taggedTextBuffer = "";
338
+ let insideTaggedThinking = false;
339
+ const appendTextDelta = (text: string) => {
340
+ if (!text) return;
341
+ if (!firstTokenTime) firstTokenTime = Date.now();
342
+ appendText(output, stream, text);
343
+ };
344
+ const appendThinkingDelta = (thinking: string, signature?: string) => {
345
+ if (!thinking) return;
346
+ if (!firstTokenTime) firstTokenTime = Date.now();
347
+ appendThinking(output, stream, thinking, signature);
348
+ };
349
+
350
+ const flushTaggedTextBuffer = () => {
351
+ while (taggedTextBuffer.length > 0) {
352
+ if (insideTaggedThinking) {
353
+ const closingTag = findFirstTag(taggedTextBuffer, MINIMAX_THINK_CLOSE_TAGS);
354
+ if (closingTag) {
355
+ appendThinkingDelta(taggedTextBuffer.slice(0, closingTag.index));
356
+ taggedTextBuffer = taggedTextBuffer.slice(closingTag.index + closingTag.tag.length);
357
+ insideTaggedThinking = false;
358
+ continue;
359
+ }
360
+
361
+ const trailingPartialTag = getTrailingPartialTag(taggedTextBuffer, MINIMAX_THINK_CLOSE_TAGS);
362
+ const flushLength = taggedTextBuffer.length - trailingPartialTag.length;
363
+ appendThinkingDelta(taggedTextBuffer.slice(0, flushLength));
364
+ taggedTextBuffer = trailingPartialTag;
365
+ break;
366
+ }
367
+
368
+ const openingTag = findFirstTag(taggedTextBuffer, MINIMAX_THINK_OPEN_TAGS);
369
+ if (openingTag) {
370
+ appendTextDelta(taggedTextBuffer.slice(0, openingTag.index));
371
+ taggedTextBuffer = taggedTextBuffer.slice(openingTag.index + openingTag.tag.length);
372
+ insideTaggedThinking = true;
373
+ continue;
374
+ }
375
+
376
+ const trailingPartialTag = getTrailingPartialTag(taggedTextBuffer, MINIMAX_THINK_OPEN_TAGS);
377
+ const flushLength = taggedTextBuffer.length - trailingPartialTag.length;
378
+ appendTextDelta(taggedTextBuffer.slice(0, flushLength));
379
+ taggedTextBuffer = trailingPartialTag;
380
+ break;
381
+ }
382
+ };
383
+
384
+ for await (const chunk of iterateWithIdleTimeout(markFirstStreamEvent(openaiStream, firstEventWatchdog), {
385
+ idleTimeoutMs,
386
+ errorMessage: "OpenAI completions stream stalled while waiting for the next event",
387
+ onIdle: () => requestAbortController.abort(),
388
+ })) {
389
+ if (!chunk || typeof chunk !== "object") continue;
390
+
391
+ // OpenAI documents ChatCompletionChunk.id as the unique chat completion identifier,
392
+ // and each chunk in a streamed completion carries the same id.
393
+ output.responseId ||= chunk.id;
394
+
395
+ if (chunk.usage) {
396
+ output.usage = parseChunkUsage(chunk.usage, model, copilotPremiumRequests);
397
+ }
398
+
399
+ const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined;
400
+ if (!choice) continue;
401
+
402
+ if (!chunk.usage) {
403
+ const choiceUsage = getChoiceUsage(choice);
404
+ if (choiceUsage) {
405
+ output.usage = parseChunkUsage(choiceUsage, model, copilotPremiumRequests);
406
+ }
407
+ }
408
+
409
+ if (choice.finish_reason) {
410
+ const finishReasonResult = mapStopReason(choice.finish_reason);
411
+ output.stopReason = finishReasonResult.stopReason;
412
+ if (finishReasonResult.errorMessage) {
413
+ output.errorMessage = finishReasonResult.errorMessage;
414
+ }
415
+ }
416
+
417
+ if (choice.delta) {
418
+ if (
419
+ choice.delta.content !== null &&
420
+ choice.delta.content !== undefined &&
421
+ choice.delta.content.length > 0
422
+ ) {
423
+ if (!firstTokenTime) firstTokenTime = Date.now();
424
+ if (parseMiniMaxThinkTags) {
425
+ taggedTextBuffer += choice.delta.content;
426
+ flushTaggedTextBuffer();
427
+ } else {
428
+ appendTextDelta(choice.delta.content);
429
+ }
430
+ }
431
+
432
+ // Some endpoints return reasoning in reasoning_content (llama.cpp),
433
+ // or reasoning (other openai compatible endpoints)
434
+ // Use the first non-empty reasoning field to avoid duplication
435
+ // (e.g., chutes.ai returns both reasoning_content and reasoning with same content)
436
+ const reasoningFields = ["reasoning_content", "reasoning", "reasoning_text"];
437
+ let foundReasoningField: string | null = null;
438
+ for (const field of reasoningFields) {
439
+ if (
440
+ (choice.delta as any)[field] !== null &&
441
+ (choice.delta as any)[field] !== undefined &&
442
+ (choice.delta as any)[field].length > 0
443
+ ) {
444
+ if (!foundReasoningField) {
445
+ foundReasoningField = field;
446
+ break;
447
+ }
448
+ }
449
+ }
450
+
451
+ if (foundReasoningField) {
452
+ const delta = (choice.delta as any)[foundReasoningField];
453
+ appendThinkingDelta(delta, foundReasoningField);
454
+ }
455
+
456
+ if (choice?.delta?.tool_calls) {
457
+ for (const toolCall of choice.delta.tool_calls) {
458
+ if (currentBlock?.type !== "toolCall" || (toolCall.id && currentBlock.id !== toolCall.id)) {
459
+ finishCurrentBlock(currentBlock);
460
+ currentBlock = {
461
+ type: "toolCall",
462
+ id: toolCall.id || "",
463
+ name: toolCall.function?.name || "",
464
+ arguments: {},
465
+ partialArgs: "",
466
+ };
467
+ output.content.push(currentBlock);
468
+ stream.push({
469
+ type: "toolcall_start",
470
+ contentIndex: blockIndex(currentBlock),
471
+ partial: output,
472
+ });
473
+ }
474
+
475
+ if (currentBlock.type === "toolCall") {
476
+ if (toolCall.id) currentBlock.id = toolCall.id;
477
+ if (toolCall.function?.name) currentBlock.name = toolCall.function.name;
478
+ let delta = "";
479
+ if (toolCall.function?.arguments) {
480
+ delta = toolCall.function.arguments;
481
+ currentBlock.partialArgs += toolCall.function.arguments;
482
+ currentBlock.arguments = parseStreamingJson(currentBlock.partialArgs);
483
+ }
484
+ stream.push({
485
+ type: "toolcall_delta",
486
+ contentIndex: blockIndex(currentBlock),
487
+ delta,
488
+ partial: output,
489
+ });
490
+ }
491
+ }
492
+ }
493
+
494
+ const reasoningDetails = (choice.delta as any).reasoning_details;
495
+ if (reasoningDetails && Array.isArray(reasoningDetails)) {
496
+ for (const detail of reasoningDetails) {
497
+ if (detail.type === "reasoning.encrypted" && detail.id && detail.data) {
498
+ const matchingToolCall = output.content.find(
499
+ b => b.type === "toolCall" && b.id === detail.id,
500
+ ) as ToolCall | undefined;
501
+ if (matchingToolCall) {
502
+ matchingToolCall.thoughtSignature = JSON.stringify(detail);
503
+ }
504
+ }
505
+ }
506
+ }
507
+ }
508
+ }
509
+
510
+ if (parseMiniMaxThinkTags && taggedTextBuffer.length > 0) {
511
+ if (insideTaggedThinking) {
512
+ appendThinkingDelta(taggedTextBuffer);
513
+ } else {
514
+ appendTextDelta(taggedTextBuffer);
515
+ }
516
+ taggedTextBuffer = "";
517
+ }
518
+
519
+ finishCurrentBlock(currentBlock);
520
+
521
+ const firstEventTimeoutError = abortTracker.getLocalAbortReason();
522
+ if (firstEventTimeoutError) {
523
+ throw firstEventTimeoutError;
524
+ }
525
+ if (abortTracker.wasCallerAbort()) {
526
+ throw new Error("Request was aborted");
527
+ }
528
+
529
+ if (output.stopReason === "aborted") {
530
+ throw new Error("Request was aborted");
531
+ }
532
+ if (output.stopReason === "error") {
533
+ throw new Error(output.errorMessage || "Provider returned an error stop reason");
534
+ }
535
+
536
+ output.duration = Date.now() - startTime;
537
+ if (firstTokenTime) output.ttft = firstTokenTime - startTime;
538
+ stream.push({ type: "done", reason: output.stopReason, message: output });
539
+ stream.end();
540
+ } catch (error) {
541
+ for (const block of output.content) delete (block as any).index;
542
+ const firstEventTimeoutError = abortTracker.getLocalAbortReason();
543
+ output.stopReason = abortTracker.wasCallerAbort() ? "aborted" : "error";
544
+ output.errorMessage =
545
+ firstEventTimeoutError?.message ??
546
+ (await finalizeErrorMessage(error, rawRequestDump, getCapturedErrorResponse?.()));
547
+ // Some providers via OpenRouter include extra details here.
548
+ const rawMetadata = (error as { error?: { metadata?: { raw?: string } } })?.error?.metadata?.raw;
549
+ if (rawMetadata) output.errorMessage += `\n${rawMetadata}`;
550
+ output.errorMessage = rewriteCopilotAuthError(output.errorMessage, error, model.provider);
551
+ output.duration = Date.now() - startTime;
552
+ if (firstTokenTime) output.ttft = firstTokenTime - startTime;
553
+ stream.push({ type: "error", reason: output.stopReason, error: output });
554
+ stream.end();
555
+ }
556
+ })();
557
+
558
+ return stream;
559
+ };
560
+
561
+ async function createClient(
562
+ model: Model<"openai-completions">,
563
+ context: Context,
564
+ apiKey?: string,
565
+ extraHeaders?: Record<string, string>,
566
+ initiatorOverride?: MessageAttribution,
567
+ ): Promise<{
568
+ client: OpenAI;
569
+ copilotPremiumRequests: number | undefined;
570
+ baseUrl: string | undefined;
571
+ requestHeaders: Record<string, string>;
572
+ getCapturedErrorResponse: () => CapturedHttpErrorResponse | undefined;
573
+ clearCapturedErrorResponse: () => void;
574
+ }> {
575
+ if (!apiKey) {
576
+ if (!$env.OPENAI_API_KEY) {
577
+ throw new Error(
578
+ "OpenAI API key is required. Set OPENAI_API_KEY environment variable or pass it as an argument.",
579
+ );
580
+ }
581
+ apiKey = $env.OPENAI_API_KEY;
582
+ }
583
+ const rawApiKey = apiKey;
584
+
585
+ let headers = { ...(model.headers ?? {}), ...(extraHeaders ?? {}) };
586
+ if (model.provider === "openrouter") {
587
+ headers["X-Title"] = "xcsh";
588
+ }
589
+ if (model.provider === "kimi-code") {
590
+ headers = { ...(await getKimiCommonHeaders()), ...headers };
591
+ }
592
+ let copilotPremiumRequests: number | undefined;
593
+
594
+ let baseUrl = model.baseUrl;
595
+ if (model.provider === "github-copilot") {
596
+ apiKey = parseGitHubCopilotApiKey(rawApiKey).accessToken;
597
+ const hasImages = hasCopilotVisionInput(context.messages);
598
+ const copilot = buildCopilotDynamicHeaders({
599
+ messages: context.messages,
600
+ hasImages,
601
+ premiumMultiplier: model.premiumMultiplier,
602
+ headers,
603
+ initiatorOverride,
604
+ });
605
+ Object.assign(headers, copilot.headers);
606
+ copilotPremiumRequests = copilot.premiumRequests;
607
+ baseUrl = resolveGitHubCopilotBaseUrl(model.baseUrl, rawApiKey) ?? model.baseUrl;
608
+ }
609
+ let capturedErrorResponse: CapturedHttpErrorResponse | undefined;
610
+ const wrappedFetch = Object.assign(
611
+ async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
612
+ const response = await fetch(input, init);
613
+ if (response.ok) {
614
+ capturedErrorResponse = undefined;
615
+ return response;
616
+ }
617
+ let bodyText: string | undefined;
618
+ let bodyJson: unknown;
619
+ try {
620
+ bodyText = await response.clone().text();
621
+ if (bodyText.trim().length > 0) {
622
+ try {
623
+ bodyJson = JSON.parse(bodyText);
624
+ } catch {}
625
+ }
626
+ } catch {}
627
+ capturedErrorResponse = {
628
+ status: response.status,
629
+ headers: response.headers,
630
+ bodyText,
631
+ bodyJson,
632
+ };
633
+ return response;
634
+ },
635
+ { preconnect: fetch.preconnect },
636
+ );
637
+ return {
638
+ client: new OpenAI({
639
+ apiKey,
640
+ baseURL: baseUrl,
641
+ dangerouslyAllowBrowser: true,
642
+ maxRetries: 5,
643
+ defaultHeaders: headers,
644
+ fetch: wrappedFetch,
645
+ }),
646
+ copilotPremiumRequests,
647
+ baseUrl,
648
+ requestHeaders: headers,
649
+ getCapturedErrorResponse: () => capturedErrorResponse,
650
+ clearCapturedErrorResponse: () => {
651
+ capturedErrorResponse = undefined;
652
+ },
653
+ };
654
+ }
655
+
656
+ function buildParams(
657
+ model: Model<"openai-completions">,
658
+ context: Context,
659
+ options: OpenAICompletionsOptions | undefined,
660
+ resolvedBaseUrl?: string,
661
+ toolStrictModeOverride?: ToolStrictModeOverride,
662
+ ): { params: OpenAICompletionsSamplingParams; toolStrictMode: AppliedToolStrictMode } {
663
+ const compat = getCompat(model, resolvedBaseUrl);
664
+ const messages = convertMessages(model, context, compat);
665
+ maybeAddOpenRouterAnthropicCacheControl(model, messages);
666
+
667
+ // Kimi (including via OpenRouter) calculates TPM rate limits based on max_tokens, not actual output.
668
+ // Always send max_tokens to avoid their high default causing rate limit issues.
669
+ // Note: Direct kimi-code provider is handled by the dedicated Kimi provider in kimi.ts.
670
+ const isKimi = model.id.includes("moonshotai/kimi");
671
+ const effectiveMaxTokens = options?.maxTokens ?? (isKimi ? model.maxTokens : undefined);
672
+
673
+ const params: OpenAICompletionsSamplingParams = {
674
+ model: model.id,
675
+ messages,
676
+ stream: true,
677
+ };
678
+ let toolStrictMode: AppliedToolStrictMode = "none";
679
+
680
+ if (compat.supportsUsageInStreaming !== false) {
681
+ (params as { stream_options?: { include_usage: boolean } }).stream_options = { include_usage: true };
682
+ }
683
+
684
+ if (compat.supportsStore) {
685
+ params.store = false;
686
+ }
687
+
688
+ if (effectiveMaxTokens) {
689
+ if (compat.maxTokensField === "max_tokens") {
690
+ (params as any).max_tokens = effectiveMaxTokens;
691
+ } else {
692
+ params.max_completion_tokens = effectiveMaxTokens;
693
+ }
694
+ }
695
+
696
+ if (options?.temperature !== undefined) {
697
+ params.temperature = options.temperature;
698
+ }
699
+ if (options?.topP !== undefined) {
700
+ params.top_p = options.topP;
701
+ }
702
+ if (options?.topK !== undefined) {
703
+ params.top_k = options.topK;
704
+ }
705
+ if (options?.minP !== undefined) {
706
+ params.min_p = options.minP;
707
+ }
708
+ if (options?.presencePenalty !== undefined) {
709
+ params.presence_penalty = options.presencePenalty;
710
+ }
711
+ if (options?.repetitionPenalty !== undefined) {
712
+ params.repetition_penalty = options.repetitionPenalty;
713
+ }
714
+ if (isSpecialServiceTier(options?.serviceTier)) {
715
+ params.service_tier = options.serviceTier;
716
+ }
717
+
718
+ if (context.tools) {
719
+ const builtTools = convertTools(context.tools, compat, toolStrictModeOverride);
720
+ params.tools = builtTools.tools;
721
+ toolStrictMode = builtTools.toolStrictMode;
722
+ } else if (hasToolHistory(context.messages)) {
723
+ // Anthropic (via LiteLLM/proxy) requires tools param when conversation has tool_calls/tool_results
724
+ params.tools = [];
725
+ }
726
+
727
+ if (options?.toolChoice && compat.supportsToolChoice) {
728
+ params.tool_choice = mapToOpenAICompletionsToolChoice(options.toolChoice);
729
+ }
730
+
731
+ if (compat.thinkingFormat === "zai" && model.reasoning) {
732
+ // Z.ai uses binary thinking: { type: "enabled" | "disabled" }
733
+ // Must explicitly disable since z.ai defaults to thinking enabled
734
+ Reflect.set(params, "thinking", { type: options?.reasoning ? "enabled" : "disabled" });
735
+ } else if (compat.thinkingFormat === "qwen" && model.reasoning) {
736
+ // Qwen uses top-level enable_thinking: boolean
737
+ Reflect.set(params, "enable_thinking", !!options?.reasoning);
738
+ } else if (compat.thinkingFormat === "qwen-chat-template" && model.reasoning) {
739
+ Reflect.set(params, "chat_template_kwargs", { enable_thinking: !!options?.reasoning });
740
+ } else if (compat.thinkingFormat === "openrouter" && options?.reasoning && model.reasoning) {
741
+ // OpenRouter normalizes reasoning across providers via a nested reasoning object.
742
+ const openRouterParams = params as typeof params & { reasoning?: { effort?: string } };
743
+ openRouterParams.reasoning = {
744
+ effort: mapReasoningEffort(options.reasoning, compat.reasoningEffortMap),
745
+ };
746
+ } else if (options?.reasoning && model.reasoning && compat.supportsReasoningEffort) {
747
+ // OpenAI-style reasoning_effort
748
+ Reflect.set(params, "reasoning_effort", mapReasoningEffort(options.reasoning, compat.reasoningEffortMap));
749
+ }
750
+
751
+ // OpenRouter provider routing preferences
752
+ if (model.baseUrl.includes("openrouter.ai") && compat.openRouterRouting) {
753
+ Reflect.set(params, "provider", compat.openRouterRouting);
754
+ }
755
+
756
+ // Vercel AI Gateway provider routing preferences
757
+ if (model.baseUrl.includes("ai-gateway.vercel.sh") && model.compat?.vercelGatewayRouting) {
758
+ const routing = model.compat.vercelGatewayRouting;
759
+ if (routing.only || routing.order) {
760
+ const gatewayOptions: Record<string, string[]> = {};
761
+ if (routing.only) gatewayOptions.only = routing.only;
762
+ if (routing.order) gatewayOptions.order = routing.order;
763
+ Reflect.set(params, "providerOptions", { gateway: gatewayOptions });
764
+ }
765
+ }
766
+
767
+ if (compat.extraBody) {
768
+ Object.assign(params, compat.extraBody);
769
+ }
770
+
771
+ return buildParamsResult(params, toolStrictMode);
772
+ }
773
+
774
+ function buildParamsResult(
775
+ params: OpenAICompletionsSamplingParams,
776
+ toolStrictMode: AppliedToolStrictMode,
777
+ ): { params: OpenAICompletionsSamplingParams; toolStrictMode: AppliedToolStrictMode } {
778
+ return { params, toolStrictMode };
779
+ }
780
+
781
+ function getOptionalNumberProperty(value: object, key: string): number | undefined {
782
+ const property = Reflect.get(value, key);
783
+ return typeof property === "number" ? property : undefined;
784
+ }
785
+
786
+ function getOptionalObjectProperty(value: object, key: string): object | undefined {
787
+ const property = Reflect.get(value, key);
788
+ return typeof property === "object" && property !== null ? property : undefined;
789
+ }
790
+
791
+ function getChoiceUsage(choice: ChatCompletionChunk.Choice): object | undefined {
792
+ return getOptionalObjectProperty(choice, "usage");
793
+ }
794
+
795
+ function parseChunkUsage(
796
+ rawUsage: object,
797
+ model: Model<"openai-completions">,
798
+ copilotPremiumRequests: number | undefined,
799
+ ): AssistantMessage["usage"] {
800
+ const promptTokenDetails = getOptionalObjectProperty(rawUsage, "prompt_tokens_details");
801
+ const completionTokenDetails = getOptionalObjectProperty(rawUsage, "completion_tokens_details");
802
+ const cachedTokens =
803
+ getOptionalNumberProperty(rawUsage, "cached_tokens") ??
804
+ (promptTokenDetails ? getOptionalNumberProperty(promptTokenDetails, "cached_tokens") : undefined) ??
805
+ 0;
806
+ const reasoningTokens =
807
+ (completionTokenDetails ? getOptionalNumberProperty(completionTokenDetails, "reasoning_tokens") : undefined) ?? 0;
808
+ const input = (getOptionalNumberProperty(rawUsage, "prompt_tokens") ?? 0) - cachedTokens;
809
+ const outputTokens = (getOptionalNumberProperty(rawUsage, "completion_tokens") ?? 0) + reasoningTokens;
810
+ const usage: AssistantMessage["usage"] = {
811
+ input,
812
+ output: outputTokens,
813
+ cacheRead: cachedTokens,
814
+ cacheWrite: 0,
815
+ totalTokens: input + outputTokens + cachedTokens,
816
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
817
+ ...(copilotPremiumRequests !== undefined ? { premiumRequests: copilotPremiumRequests } : {}),
818
+ };
819
+ calculateCost(model, usage);
820
+ return usage;
821
+ }
822
+
823
+ function mapReasoningEffort(
824
+ effort: NonNullable<OpenAICompletionsOptions["reasoning"]>,
825
+ reasoningEffortMap: Partial<Record<NonNullable<OpenAICompletionsOptions["reasoning"]>, string>>,
826
+ ): string {
827
+ return reasoningEffortMap[effort] ?? effort;
828
+ }
829
+
830
+ function maybeAddOpenRouterAnthropicCacheControl(
831
+ model: Model<"openai-completions">,
832
+ messages: ChatCompletionMessageParam[],
833
+ ): void {
834
+ if (model.provider !== "openrouter" || !model.id.startsWith("anthropic/")) return;
835
+
836
+ // Anthropic-style caching requires cache_control on a text part. Add a breakpoint
837
+ // on the last user/assistant message (walking backwards until we find text content).
838
+ for (let i = messages.length - 1; i >= 0; i--) {
839
+ const msg = messages[i];
840
+ if (msg.role !== "user" && msg.role !== "assistant" && msg.role !== "developer") continue;
841
+
842
+ const content = msg.content;
843
+ if (typeof content === "string") {
844
+ msg.content = [
845
+ Object.assign({ type: "text" as const, text: content }, { cache_control: { type: "ephemeral" } }),
846
+ ];
847
+ return;
848
+ }
849
+
850
+ if (!Array.isArray(content)) continue;
851
+
852
+ // Find last text part and add cache_control
853
+ for (let j = content.length - 1; j >= 0; j--) {
854
+ const part = content[j];
855
+ if (part?.type === "text") {
856
+ Object.assign(part, { cache_control: { type: "ephemeral" } });
857
+ return;
858
+ }
859
+ }
860
+ }
861
+ }
862
+
863
+ export function convertMessages(
864
+ model: Model<"openai-completions">,
865
+ context: Context,
866
+ compat: ResolvedOpenAICompat,
867
+ ): ChatCompletionMessageParam[] {
868
+ const params: ChatCompletionMessageParam[] = [];
869
+
870
+ const normalizeToolCallId = (id: string): string => {
871
+ if (compat.requiresMistralToolIds) return normalizeMistralToolId(id, true);
872
+
873
+ // Handle pipe-separated IDs from OpenAI Responses API
874
+ // Format: {call_id}|{id} where {id} can be 400+ chars with special chars (+, /, =)
875
+ // These come from providers like github-copilot, openai-codex, opencode
876
+ // Extract just the call_id part and normalize it
877
+ if (id.includes("|")) {
878
+ const [callId] = id.split("|");
879
+ // Sanitize to allowed chars and truncate to 40 chars (OpenAI limit)
880
+ return callId.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40);
881
+ }
882
+
883
+ if (model.provider === "openai") return id.length > 40 ? id.slice(0, 40) : id;
884
+ return id;
885
+ };
886
+ const transformedMessages = transformMessages(context.messages, model, id => normalizeToolCallId(id));
887
+
888
+ const remappedToolCallIds = new Map<string, string[]>();
889
+ let generatedToolCallIdCounter = 0;
890
+
891
+ const generateFallbackToolCallId = (seed: string): string => {
892
+ generatedToolCallIdCounter += 1;
893
+ const hash = Bun.hash(`${model.provider}:${model.id}:${seed}:${generatedToolCallIdCounter}`).toString(36);
894
+ return `call_${hash}`;
895
+ };
896
+
897
+ const rememberToolCallId = (originalId: string, normalizedId: string): void => {
898
+ const queue = remappedToolCallIds.get(originalId);
899
+ if (queue) {
900
+ queue.push(normalizedId);
901
+ return;
902
+ }
903
+ remappedToolCallIds.set(originalId, [normalizedId]);
904
+ };
905
+
906
+ const consumeToolCallId = (originalId: string): string | null => {
907
+ const queue = remappedToolCallIds.get(originalId);
908
+ if (!queue || queue.length === 0) return null;
909
+ const nextId = queue.shift() ?? null;
910
+ if (queue.length === 0) remappedToolCallIds.delete(originalId);
911
+ return nextId;
912
+ };
913
+
914
+ const ensureToolCallId = (rawId: string, seed: string): string => {
915
+ const normalized = normalizeToolCallId(rawId);
916
+ if (normalized.trim().length > 0) return normalized;
917
+ return generateFallbackToolCallId(seed);
918
+ };
919
+
920
+ if (context.systemPrompt) {
921
+ const useDeveloperRole = model.reasoning && compat.supportsDeveloperRole;
922
+ const role = useDeveloperRole ? "developer" : "system";
923
+ params.push({ role: role, content: context.systemPrompt.toWellFormed() });
924
+ }
925
+
926
+ let lastRole: string | null = null;
927
+
928
+ for (let i = 0; i < transformedMessages.length; i++) {
929
+ const msg = transformedMessages[i];
930
+ // Some providers (e.g. Mistral/Devstral) don't allow user messages directly after tool results
931
+ // Insert a synthetic assistant message to bridge the gap
932
+ if (
933
+ compat.requiresAssistantAfterToolResult &&
934
+ lastRole === "toolResult" &&
935
+ (msg.role === "user" || msg.role === "developer")
936
+ ) {
937
+ params.push({
938
+ role: "assistant",
939
+ content: "I have processed the tool results.",
940
+ });
941
+ }
942
+
943
+ const devAsUser = !compat.supportsDeveloperRole;
944
+ if (msg.role === "user" || msg.role === "developer") {
945
+ const role = !devAsUser && msg.role === "developer" ? "developer" : "user";
946
+ if (typeof msg.content === "string") {
947
+ const text = msg.content.toWellFormed();
948
+ if (text.trim().length === 0) continue;
949
+ params.push({
950
+ role: role,
951
+ content: text,
952
+ });
953
+ } else {
954
+ const content: ChatCompletionContentPart[] = [];
955
+ for (const item of msg.content) {
956
+ if (item.type === "text") {
957
+ const text = item.text.toWellFormed();
958
+ if (text.trim().length === 0) continue;
959
+ content.push({
960
+ type: "text",
961
+ text,
962
+ } satisfies ChatCompletionContentPartText);
963
+ } else {
964
+ content.push({
965
+ type: "image_url",
966
+ image_url: {
967
+ url: `data:${item.mimeType};base64,${item.data}`,
968
+ },
969
+ } satisfies ChatCompletionContentPartImage);
970
+ }
971
+ }
972
+ const filteredContent = !model.input.includes("image")
973
+ ? content.filter(c => c.type !== "image_url")
974
+ : content;
975
+ if (filteredContent.length === 0) continue;
976
+ params.push({
977
+ role: "user",
978
+ content: filteredContent,
979
+ });
980
+ }
981
+ } else if (msg.role === "assistant") {
982
+ // Some providers (e.g. Mistral) don't accept null content, use empty string instead
983
+ const assistantMsg: ChatCompletionAssistantMessageParam = {
984
+ role: "assistant",
985
+ content: compat.requiresAssistantAfterToolResult ? "" : null,
986
+ };
987
+
988
+ const textBlocks = msg.content.filter(b => b.type === "text") as TextContent[];
989
+ // Filter out empty text blocks to avoid API validation errors
990
+ const nonEmptyTextBlocks = textBlocks.filter(b => b.text && b.text.trim().length > 0);
991
+ if (nonEmptyTextBlocks.length > 0) {
992
+ // Always send assistant content as a plain string. Some OpenAI-compatible
993
+ // backends mirror array-of-text-block payloads back to the model literally,
994
+ // causing recursive nested content in subsequent turns.
995
+ assistantMsg.content = nonEmptyTextBlocks.map(b => b.text.toWellFormed()).join("");
996
+ }
997
+
998
+ // Handle thinking blocks
999
+ const thinkingBlocks = msg.content.filter(b => b.type === "thinking") as ThinkingContent[];
1000
+ // Filter out empty thinking blocks to avoid API validation errors
1001
+ const nonEmptyThinkingBlocks = thinkingBlocks.filter(b => b.thinking && b.thinking.trim().length > 0);
1002
+ if (nonEmptyThinkingBlocks.length > 0) {
1003
+ if (compat.requiresThinkingAsText) {
1004
+ // Convert thinking blocks to plain text (no tags to avoid model mimicking them)
1005
+ const thinkingText = nonEmptyThinkingBlocks.map(b => b.thinking).join("\n\n");
1006
+ const textContent = assistantMsg.content as Array<{ type: "text"; text: string }> | null;
1007
+ if (textContent) {
1008
+ textContent.unshift({ type: "text", text: thinkingText });
1009
+ } else {
1010
+ assistantMsg.content = [{ type: "text", text: thinkingText }];
1011
+ }
1012
+ } else {
1013
+ // Use the signature from the first thinking block if available (for llama.cpp server + gpt-oss)
1014
+ const signature = nonEmptyThinkingBlocks[0].thinkingSignature;
1015
+ if (signature && signature.length > 0) {
1016
+ (assistantMsg as any)[signature] = nonEmptyThinkingBlocks.map(b => b.thinking).join("\n");
1017
+ }
1018
+ }
1019
+ }
1020
+
1021
+ if (compat.thinkingFormat === "openai") {
1022
+ const streamedReasoningField = nonEmptyThinkingBlocks[0]?.thinkingSignature;
1023
+ const reasoningField =
1024
+ streamedReasoningField === "reasoning_content" ||
1025
+ streamedReasoningField === "reasoning" ||
1026
+ streamedReasoningField === "reasoning_text"
1027
+ ? streamedReasoningField
1028
+ : (compat.reasoningContentField ?? "reasoning_content");
1029
+ const reasoningContent = (assistantMsg as any)[reasoningField];
1030
+ if (!reasoningContent) {
1031
+ const reasoning = (assistantMsg as any).reasoning;
1032
+ const reasoningText = (assistantMsg as any).reasoning_text;
1033
+ if (reasoning && reasoningField !== "reasoning") {
1034
+ (assistantMsg as any)[reasoningField] = reasoning;
1035
+ } else if (reasoningText && reasoningField !== "reasoning_text") {
1036
+ (assistantMsg as any)[reasoningField] = reasoningText;
1037
+ } else if (nonEmptyThinkingBlocks.length > 0) {
1038
+ (assistantMsg as any)[reasoningField] = nonEmptyThinkingBlocks.map(b => b.thinking).join("\n");
1039
+ }
1040
+ }
1041
+ }
1042
+
1043
+ const toolCalls = msg.content.filter(b => b.type === "toolCall") as ToolCall[];
1044
+ const hasReasoningField =
1045
+ (assistantMsg as any).reasoning_content !== undefined ||
1046
+ (assistantMsg as any).reasoning !== undefined ||
1047
+ (assistantMsg as any).reasoning_text !== undefined;
1048
+ if (
1049
+ toolCalls.length > 0 &&
1050
+ compat.requiresReasoningContentForToolCalls &&
1051
+ compat.thinkingFormat === "openai" &&
1052
+ !hasReasoningField
1053
+ ) {
1054
+ const reasoningField = compat.reasoningContentField ?? "reasoning_content";
1055
+ (assistantMsg as any)[reasoningField] = ".";
1056
+ }
1057
+ if (toolCalls.length > 0) {
1058
+ assistantMsg.tool_calls = toolCalls.map((tc, toolCallIndex) => {
1059
+ const toolCallId = ensureToolCallId(tc.id, `${i}:${toolCallIndex}:${tc.name}`);
1060
+ rememberToolCallId(tc.id, toolCallId);
1061
+ return {
1062
+ id: normalizeMistralToolId(toolCallId, compat.requiresMistralToolIds),
1063
+ type: "function" as const,
1064
+ function: {
1065
+ name: tc.name,
1066
+ arguments: serializeToolArguments(tc.arguments),
1067
+ },
1068
+ };
1069
+ });
1070
+ const reasoningDetails = toolCalls
1071
+ .filter(tc => tc.thoughtSignature)
1072
+ .map(tc => {
1073
+ try {
1074
+ return JSON.parse(tc.thoughtSignature!);
1075
+ } catch {
1076
+ return null;
1077
+ }
1078
+ })
1079
+ .filter(Boolean);
1080
+ if (reasoningDetails.length > 0) {
1081
+ (assistantMsg as any).reasoning_details = reasoningDetails;
1082
+ }
1083
+ }
1084
+ // Skip assistant messages that have no content, no tool calls, and no reasoning payload.
1085
+ // Some OpenAI-compatible backends require replaying reasoning-only assistant turns
1086
+ // so follow-up requests preserve the provider-specific reasoning field name.
1087
+ const content = assistantMsg.content;
1088
+ const hasContent =
1089
+ content !== null &&
1090
+ content !== undefined &&
1091
+ (typeof content === "string" ? content.length > 0 : content.length > 0);
1092
+ if (!hasContent && assistantMsg.tool_calls && compat.requiresAssistantContentForToolCalls) {
1093
+ assistantMsg.content = ".";
1094
+ }
1095
+ if (!hasContent && !assistantMsg.tool_calls && !hasReasoningField) {
1096
+ continue;
1097
+ }
1098
+ params.push(assistantMsg);
1099
+ } else if (msg.role === "toolResult") {
1100
+ // Batch consecutive tool results and collect all images
1101
+ const imageBlocks: Array<{ type: "image_url"; image_url: { url: string } }> = [];
1102
+ let j = i;
1103
+
1104
+ for (; j < transformedMessages.length && transformedMessages[j].role === "toolResult"; j++) {
1105
+ const toolMsg = transformedMessages[j] as ToolResultMessage;
1106
+
1107
+ // Extract text and image content
1108
+ const textResult = toolMsg.content
1109
+ .filter(c => c.type === "text")
1110
+ .map(c => (c as any).text)
1111
+ .join("\n");
1112
+ const hasImages = toolMsg.content.some(c => c.type === "image");
1113
+
1114
+ // Always send tool result with text (or placeholder if only images)
1115
+ const hasText = textResult.length > 0;
1116
+ // Some providers (e.g. Mistral) require the 'name' field in tool results
1117
+ const remappedToolCallId = consumeToolCallId(toolMsg.toolCallId);
1118
+ const resolvedToolCallId =
1119
+ remappedToolCallId ?? ensureToolCallId(toolMsg.toolCallId, `${j}:${toolMsg.toolName ?? "tool"}`);
1120
+ const toolResultMsg: ChatCompletionToolMessageParam = {
1121
+ role: "tool",
1122
+ content: (hasText ? textResult : "(see attached image)").toWellFormed(),
1123
+ tool_call_id: normalizeMistralToolId(resolvedToolCallId, compat.requiresMistralToolIds),
1124
+ };
1125
+ if (compat.requiresToolResultName && toolMsg.toolName) {
1126
+ (toolResultMsg as any).name = toolMsg.toolName;
1127
+ }
1128
+ params.push(toolResultMsg);
1129
+
1130
+ if (hasImages && model.input.includes("image")) {
1131
+ for (const block of toolMsg.content) {
1132
+ if (block.type === "image") {
1133
+ imageBlocks.push({
1134
+ type: "image_url",
1135
+ image_url: {
1136
+ url: `data:${(block as any).mimeType};base64,${(block as any).data}`,
1137
+ },
1138
+ });
1139
+ }
1140
+ }
1141
+ }
1142
+ }
1143
+
1144
+ i = j - 1;
1145
+
1146
+ // After all consecutive tool results, add a single user message with all images
1147
+ if (imageBlocks.length > 0) {
1148
+ if (compat.requiresAssistantAfterToolResult) {
1149
+ params.push({
1150
+ role: "assistant",
1151
+ content: "I have processed the tool results.",
1152
+ });
1153
+ }
1154
+
1155
+ params.push({
1156
+ role: "user",
1157
+ content: [
1158
+ {
1159
+ type: "text",
1160
+ text: "Attached image(s) from tool result:",
1161
+ },
1162
+ ...imageBlocks,
1163
+ ],
1164
+ });
1165
+ lastRole = "user";
1166
+ } else {
1167
+ lastRole = "toolResult";
1168
+ }
1169
+ continue;
1170
+ }
1171
+
1172
+ lastRole =
1173
+ msg.role === "developer"
1174
+ ? model.reasoning && compat.supportsDeveloperRole
1175
+ ? "developer"
1176
+ : "system"
1177
+ : msg.role;
1178
+ }
1179
+
1180
+ return params;
1181
+ }
1182
+
1183
+ function convertTools(
1184
+ tools: Tool[],
1185
+ compat: ResolvedOpenAICompat,
1186
+ toolStrictModeOverride?: ToolStrictModeOverride,
1187
+ ): BuiltOpenAICompletionTools {
1188
+ const adaptedTools = tools.map(tool => {
1189
+ const strict = !NO_STRICT && compat.supportsStrictMode !== false && tool.strict !== false;
1190
+ const baseParameters = tool.parameters as unknown as Record<string, unknown>;
1191
+ const adapted = adaptSchemaForStrict(baseParameters, strict);
1192
+ return {
1193
+ tool,
1194
+ baseParameters,
1195
+ parameters: adapted.schema,
1196
+ strict: adapted.strict,
1197
+ };
1198
+ });
1199
+
1200
+ const requestedStrictMode = toolStrictModeOverride ?? compat.toolStrictMode;
1201
+ const toolStrictMode =
1202
+ requestedStrictMode === "none"
1203
+ ? "none"
1204
+ : requestedStrictMode === "all_strict"
1205
+ ? adaptedTools.every(tool => tool.strict)
1206
+ ? "all_strict"
1207
+ : "none"
1208
+ : "mixed";
1209
+
1210
+ return {
1211
+ tools: adaptedTools.map(({ tool, baseParameters, parameters, strict }) => {
1212
+ const includeStrict = toolStrictMode === "all_strict" || (toolStrictMode === "mixed" && strict);
1213
+ return {
1214
+ type: "function",
1215
+ function: {
1216
+ name: tool.name,
1217
+ description: tool.description || "",
1218
+ parameters: includeStrict ? parameters : baseParameters,
1219
+ // Only include strict if provider supports it. Some reject unknown fields.
1220
+ ...(includeStrict && { strict: true }),
1221
+ },
1222
+ };
1223
+ }),
1224
+ toolStrictMode,
1225
+ };
1226
+ }
1227
+
1228
+ function shouldRetryWithoutStrictTools(
1229
+ error: unknown,
1230
+ capturedErrorResponse: CapturedHttpErrorResponse | undefined,
1231
+ toolStrictMode: AppliedToolStrictMode,
1232
+ tools: Tool[] | undefined,
1233
+ ): boolean {
1234
+ if (!tools || tools.length === 0 || toolStrictMode !== "all_strict") {
1235
+ return false;
1236
+ }
1237
+ const status = extractHttpStatusFromError(error) ?? capturedErrorResponse?.status;
1238
+ if (status !== 400 && status !== 422) {
1239
+ return false;
1240
+ }
1241
+ const messageParts = [error instanceof Error ? error.message : undefined, capturedErrorResponse?.bodyText]
1242
+ .filter((value): value is string => typeof value === "string" && value.trim().length > 0)
1243
+ .join("\n");
1244
+ return /wrong_api_format|mixed values for 'strict'|tool[s]?\b.*strict|\bstrict\b.*tool/i.test(messageParts);
1245
+ }
1246
+
1247
+ function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | string): {
1248
+ stopReason: StopReason;
1249
+ errorMessage?: string;
1250
+ } {
1251
+ if (reason === null) return { stopReason: "stop" };
1252
+ switch (reason) {
1253
+ case "stop":
1254
+ case "end":
1255
+ return { stopReason: "stop" };
1256
+ case "length":
1257
+ return { stopReason: "length" };
1258
+ case "function_call":
1259
+ case "tool_calls":
1260
+ return { stopReason: "toolUse" };
1261
+ case "content_filter":
1262
+ return { stopReason: "error", errorMessage: "Provider finish_reason: content_filter" };
1263
+ case "network_error":
1264
+ return { stopReason: "error", errorMessage: "Provider finish_reason: network_error" };
1265
+ default:
1266
+ return {
1267
+ stopReason: "error",
1268
+ errorMessage: `Provider finish_reason: ${reason}`,
1269
+ };
1270
+ }
1271
+ }
1272
+
1273
+ /**
1274
+ * Detect compatibility settings from provider and baseUrl for known providers.
1275
+ * Provider takes precedence over URL-based detection since it's explicitly configured.
1276
+ * Returns a fully resolved OpenAICompat object with all fields set.
1277
+ */
1278
+ export function detectCompat(model: Model<"openai-completions">): ResolvedOpenAICompat {
1279
+ return detectOpenAICompat(model);
1280
+ }
1281
+
1282
+ /**
1283
+ * Get resolved compatibility settings for a model.
1284
+ * Uses explicit model.compat if provided, otherwise auto-detects from provider/URL.
1285
+ * @param model - The model configuration
1286
+ * @param resolvedBaseUrl - Optional resolved base URL (e.g., after GitHub Copilot proxy-ep resolution).
1287
+ */
1288
+ function getCompat(model: Model<"openai-completions">, resolvedBaseUrl?: string): ResolvedOpenAICompat {
1289
+ return resolveOpenAICompat(model, resolvedBaseUrl);
1290
+ }