// Lazy-loaded: OpenAI SDK is imported on first use, not at startup.
// This avoids penalizing users who don't use OpenAI models.
import type OpenAI from "openai";
import type {
	ChatCompletionAssistantMessageParam,
	ChatCompletionChunk,
	ChatCompletionContentPart,
	ChatCompletionContentPartImage,
	ChatCompletionContentPartText,
	ChatCompletionMessageParam,
	ChatCompletionReasoningEffort,
	ChatCompletionToolMessageParam,
} from "openai/resources/chat/completions.js";
import type { FunctionParameters } from "openai/resources/shared.js";
import { getEnvApiKey } from "../env-api-keys.js";
import { resolveWireModelId } from "../model-identity.js";
import { calculateCost, supportsXhigh } from "../models.js";
import type {
	Context,
	ImageContent,
	Message,
	Model,
	OpenAICompletionsCompat,
	SimpleStreamOptions,
	StopReason,
	StreamFunction,
	StreamOptions,
	TextContent,
	ThinkingContent,
	Tool,
	ToolCall,
	ToolResultMessage,
} from "../types.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { parseStreamingJson } from "../utils/json-parse.js";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
import {
	assertStreamSuccess,
	buildInitialOutput,
	createOpenAIClient,
	finalizeStream,
	handleStreamError,
} from "./openai-shared.js";
import { sanitizeToolCallArgumentsForSerialization } from "./sanitize-tool-arguments.js";
import {
	buildBaseOptions,
	clampReasoning,
	resolveReasoningLevel,
} from "./simple-options.js";
import { transformMessagesWithReport } from "./transform-messages.js";

/**
 * Check if conversation messages contain tool calls or tool results.
 * This is needed because Anthropic (via proxy) requires the tools param
 * to be present when messages include tool_calls or tool role messages.
 */
function hasToolHistory(messages: Message[]): boolean {
	for (const msg of messages) {
		if (msg.role === "toolResult") {
			return true;
		}
		if (msg.role === "assistant") {
			if (msg.content.some((block) => block.type === "toolCall")) {
				return true;
			}
		}
	}
	return false;
}

export interface OpenAICompletionsOptions extends StreamOptions {
	toolChoice?:
		| "auto"
		| "none"
		| "required"
		| { type: "function"; function: { name: string } };
	reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh";
}

export const streamOpenAICompletions: StreamFunction<
	"openai-completions",
	OpenAICompletionsOptions
> = (
	model: Model<"openai-completions">,
	context: Context,
	options?: OpenAICompletionsOptions,
): AssistantMessageEventStream => {
	const stream = new AssistantMessageEventStream();

	(async () => {
		const output = buildInitialOutput(model);

		try {
			const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
			const isZai =
				model.provider === "zai" || model.baseUrl.includes("api.z.ai");
			const client = await createOpenAIClient(model, context, apiKey, {
				optionsHeaders: options?.headers,
				extraClientOptions: isZai
					? { timeout: 100_000, maxRetries: 4 }
					: undefined,
			});
			let params = buildParams(model, context, options);
			const nextParams = await options?.onPayload?.(params, model);
			if (nextParams !== undefined) {
				params =
					nextParams as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming;
			}
			const openaiStream = await client.chat.completions.create(params, {
				signal: options?.signal,
			});
			stream.push({ type: "start", partial: output });

			let currentBlock:
				| TextContent
				| ThinkingContent
				| (ToolCall & { partialArgs?: string })
				| null = null;
			const blocks = output.content;
			const blockIndex = () => blocks.length - 1;
			const finishCurrentBlock = (block?: typeof currentBlock) => {
				if (block) {
					if (block.type === "text") {
						stream.push({
							type: "text_end",
							contentIndex: blockIndex(),
							content: block.text,
							partial: output,
						});
					} else if (block.type === "thinking") {
						stream.push({
							type: "thinking_end",
							contentIndex: blockIndex(),
							content: block.thinking,
							partial: output,
						});
					} else if (block.type === "toolCall") {
						block.arguments = parseStreamingJson(block.partialArgs);
						delete block.partialArgs;
						stream.push({
							type: "toolcall_end",
							contentIndex: blockIndex(),
							toolCall: block,
							partial: output,
						});
					}
				}
			};

			for await (const chunk of openaiStream) {
				if (!chunk || typeof chunk !== "object") continue;

				if (chunk.usage) {
					const cachedTokens =
						chunk.usage.prompt_tokens_details?.cached_tokens || 0;
					const reasoningTokens =
						chunk.usage.completion_tokens_details?.reasoning_tokens || 0;
					const input = (chunk.usage.prompt_tokens || 0) - cachedTokens;
					const outputTokens =
						(chunk.usage.completion_tokens || 0) + reasoningTokens;
					output.usage = {
						// OpenAI includes cached tokens in prompt_tokens, so subtract to get non-cached input
						input,
						output: outputTokens,
						cacheRead: cachedTokens,
						cacheWrite: 0,
						// Compute totalTokens ourselves since we add reasoning_tokens to output
						// and some providers (e.g., Groq) don't include them in total_tokens
						totalTokens: input + outputTokens + cachedTokens,
						cost: {
							input: 0,
							output: 0,
							cacheRead: 0,
							cacheWrite: 0,
							total: 0,
						},
					};
					calculateCost(model, output.usage);
				}

				const choice = Array.isArray(chunk.choices)
					? chunk.choices[0]
					: undefined;
				if (!choice) continue;

				if (choice.finish_reason) {
					output.stopReason = mapStopReason(choice.finish_reason);
				}

				if (choice.delta) {
					if (
						choice.delta.content !== null &&
						choice.delta.content !== undefined &&
						choice.delta.content.length > 0
					) {
						if (!currentBlock || currentBlock.type !== "text") {
							finishCurrentBlock(currentBlock);
							currentBlock = { type: "text", text: "" };
							output.content.push(currentBlock);
							stream.push({
								type: "text_start",
								contentIndex: blockIndex(),
								partial: output,
							});
						}

						if (currentBlock.type === "text") {
							currentBlock.text += choice.delta.content;
							stream.push({
								type: "text_delta",
								contentIndex: blockIndex(),
								delta: choice.delta.content,
								partial: output,
							});
						}
					}

					// Some endpoints return reasoning in reasoning_content (llama.cpp),
					// or reasoning (other openai compatible endpoints)
					// Use the first non-empty reasoning field to avoid duplication
					// (e.g., chutes.ai returns both reasoning_content and reasoning with same content)
					// SDK-divergence: reasoning_content / reasoning / reasoning_text are vendor extensions
					// not present on ChatCompletionChunk.Choice.Delta in the official OpenAI SDK.
					const deltaExt = choice.delta as unknown as Record<string, unknown>;
					const reasoningFields = [
						"reasoning_content",
						"reasoning",
						"reasoning_text",
					];
					let foundReasoningField: string | null = null;
					for (const field of reasoningFields) {
						if (
							deltaExt[field] !== null &&
							deltaExt[field] !== undefined &&
							(deltaExt[field] as string).length > 0
						) {
							if (!foundReasoningField) {
								foundReasoningField = field;
								break;
							}
						}
					}

					if (foundReasoningField) {
						if (!currentBlock || currentBlock.type !== "thinking") {
							finishCurrentBlock(currentBlock);
							currentBlock = {
								type: "thinking",
								thinking: "",
								thinkingSignature: foundReasoningField,
							};
							output.content.push(currentBlock);
							stream.push({
								type: "thinking_start",
								contentIndex: blockIndex(),
								partial: output,
							});
						}

						if (currentBlock.type === "thinking") {
							const delta = deltaExt[foundReasoningField] as string;
							currentBlock.thinking += delta;
							stream.push({
								type: "thinking_delta",
								contentIndex: blockIndex(),
								delta,
								partial: output,
							});
						}
					}

					if (choice?.delta?.tool_calls) {
						for (const toolCall of choice.delta.tool_calls) {
							if (
								!currentBlock ||
								currentBlock.type !== "toolCall" ||
								(toolCall.id && currentBlock.id !== toolCall.id)
							) {
								finishCurrentBlock(currentBlock);
								currentBlock = {
									type: "toolCall",
									id: toolCall.id || "",
									name: toolCall.function?.name || "",
									arguments: {},
									partialArgs: "",
								};
								output.content.push(currentBlock);
								stream.push({
									type: "toolcall_start",
									contentIndex: blockIndex(),
									partial: output,
								});
							}

							if (currentBlock.type === "toolCall") {
								if (toolCall.id) currentBlock.id = toolCall.id;
								if (toolCall.function?.name)
									currentBlock.name = toolCall.function.name;
								let delta = "";
								if (toolCall.function?.arguments) {
									delta = toolCall.function.arguments;
									currentBlock.partialArgs += toolCall.function.arguments;
									currentBlock.arguments = parseStreamingJson(
										currentBlock.partialArgs,
									);
								}
								stream.push({
									type: "toolcall_delta",
									contentIndex: blockIndex(),
									delta,
									partial: output,
								});
							}
						}
					}

					// SDK-divergence: reasoning_details is a vendor extension (OpenAI Responses API via
					// completions-compat path) not present on ChatCompletionChunk.Choice.Delta.
					const reasoningDetails = deltaExt.reasoning_details;
					if (reasoningDetails && Array.isArray(reasoningDetails)) {
						for (const detail of reasoningDetails) {
							if (
								detail.type === "reasoning.encrypted" &&
								detail.id &&
								detail.data
							) {
								const matchingToolCall = output.content.find(
									(b) => b.type === "toolCall" && b.id === detail.id,
								) as ToolCall | undefined;
								if (matchingToolCall) {
									matchingToolCall.thoughtSignature = JSON.stringify(detail);
								}
							}
						}
					}
				}
			}

			finishCurrentBlock(currentBlock);
			assertStreamSuccess(output, options?.signal);
			finalizeStream(stream, output);
		} catch (error) {
			// Some providers via OpenRouter give additional information in this field.
			// SDK-divergence: APIError.error is typed as Object | undefined; the nested
			// metadata.raw field is an OpenRouter-specific extension not in the SDK type.
			const rawMetadata = (
				error as unknown as { error?: { metadata?: { raw?: string } } }
			)?.error?.metadata?.raw;
			handleStreamError(stream, output, error, options?.signal, rawMetadata);
		}
	})();

	return stream;
};

export const streamSimpleOpenAICompletions: StreamFunction<
	"openai-completions",
	SimpleStreamOptions
> = (
	model: Model<"openai-completions">,
	context: Context,
	options?: SimpleStreamOptions,
): AssistantMessageEventStream => {
	const apiKey = options?.apiKey || getEnvApiKey(model.provider);
	if (!apiKey) {
		throw new Error(`No API key for provider: ${model.provider}`);
	}

	const base = buildBaseOptions(model, options, apiKey);
	const effectiveReasoning = resolveReasoningLevel(model, options?.reasoning);
	const reasoningEffort = supportsXhigh(model)
		? effectiveReasoning
		: clampReasoning(effectiveReasoning);
	const toolChoice = (options as OpenAICompletionsOptions | undefined)
		?.toolChoice;

	return streamOpenAICompletions(model, context, {
		...base,
		reasoningEffort,
		toolChoice,
	} satisfies OpenAICompletionsOptions);
};

function buildParams(
	model: Model<"openai-completions">,
	context: Context,
	options?: OpenAICompletionsOptions,
) {
	const compat = getCompat(model);
	const messages = convertMessages(model, context, compat);
	maybeAddOpenRouterAnthropicCacheControl(model, messages);

	const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
		model: resolveWireModelId(model),
		messages,
		stream: true,
	};

	if (compat.supportsUsageInStreaming !== false) {
		params.stream_options = { include_usage: true };
	}

	if (compat.supportsStore) {
		params.store = false;
	}

	if (options?.maxTokens) {
		if (compat.maxTokensField === "max_tokens") {
			// max_tokens is a deprecated but valid field on ChatCompletionCreateParamsBase,
			// kept for providers (e.g. chutes.ai) that reject max_completion_tokens.
			params.max_tokens = options.maxTokens;
		} else {
			params.max_completion_tokens = options.maxTokens;
		}
	}

	if (options?.temperature !== undefined) {
		params.temperature = options.temperature;
	}

	if (context.tools && context.tools.length > 0) {
		params.tools = convertTools(context.tools, compat);
		maybeAddOpenRouterAnthropicToolCacheControl(model, params.tools);
	} else if (hasToolHistory(context.messages)) {
		// Anthropic (via LiteLLM/proxy) requires tools param when conversation has tool_calls/tool_results
		params.tools = [];
	}

	if (options?.toolChoice) {
		params.tool_choice = options.toolChoice;
	}

	// For vendor-specific fields not in ChatCompletionCreateParamsBase, use a typed extension object.
	const paramsExt = params as unknown as Record<string, unknown>;

	if (
		(compat.thinkingFormat === "zai" || compat.thinkingFormat === "qwen") &&
		model.reasoning
	) {
		// SDK-divergence: enable_thinking is a Z.ai / Qwen vendor extension not in the OpenAI SDK type.
		paramsExt.enable_thinking = !!options?.reasoningEffort;
	} else if (
		options?.reasoningEffort &&
		model.reasoning &&
		compat.supportsReasoningEffort
	) {
		// reasoning_effort is in ChatCompletionCreateParamsBase, but mapReasoningEffort returns a
		// plain string (from a provider-specific map) which may not match the SDK's ReasoningEffort
		// literal union — cast to the SDK type to satisfy the checker.
		params.reasoning_effort = mapReasoningEffort(
			options.reasoningEffort,
			compat.reasoningEffortMap,
		) as ChatCompletionReasoningEffort;
	}

	// OpenRouter provider routing preferences
	if (
		model.baseUrl.includes("openrouter.ai") &&
		model.compat?.openRouterRouting
	) {
		// SDK-divergence: provider routing is an OpenRouter vendor extension not in the OpenAI SDK type.
		paramsExt.provider = model.compat.openRouterRouting;
	}

	// Vercel AI Gateway provider routing preferences
	if (
		model.baseUrl.includes("ai-gateway.vercel.sh") &&
		model.compat?.vercelGatewayRouting
	) {
		const routing = model.compat.vercelGatewayRouting;
		if (routing.only || routing.order) {
			const gatewayOptions: Record<string, string[]> = {};
			if (routing.only) gatewayOptions.only = routing.only;
			if (routing.order) gatewayOptions.order = routing.order;
			// SDK-divergence: providerOptions is a Vercel AI Gateway vendor extension not in the OpenAI SDK type.
			paramsExt.providerOptions = { gateway: gatewayOptions };
		}
	}

	return params;
}

function maybeAddOpenRouterAnthropicToolCacheControl(
	model: Model<"openai-completions">,
	tools: OpenAI.Chat.Completions.ChatCompletionTool[] | undefined,
): void {
	if (model.provider !== "openrouter" || !model.id.startsWith("anthropic/"))
		return;
	if (!tools?.length) return;

	const lastTool = tools[tools.length - 1];
	if ("function" in lastTool) {
		Object.assign(lastTool.function, { cache_control: { type: "ephemeral" } });
	}
}

function supportsOpenRouterAnthropicCacheControl(
	model: Model<"openai-completions">,
): boolean {
	return model.provider === "openrouter" && model.id.startsWith("anthropic/");
}

function mapReasoningEffort(
	effort: NonNullable<OpenAICompletionsOptions["reasoningEffort"]>,
	reasoningEffortMap: Partial<
		Record<NonNullable<OpenAICompletionsOptions["reasoningEffort"]>, string>
	>,
): string {
	return reasoningEffortMap[effort] ?? effort;
}

function maybeAddOpenRouterAnthropicCacheControl(
	model: Model<"openai-completions">,
	messages: ChatCompletionMessageParam[],
): void {
	if (!supportsOpenRouterAnthropicCacheControl(model)) return;

	// Anthropic-style caching requires cache_control on a text part. Add a breakpoint
	// on the last user/assistant message (walking backwards until we find text content).
	for (let i = messages.length - 1; i >= 0; i--) {
		const msg = messages[i];
		if (msg.role !== "user" && msg.role !== "assistant") continue;

		const content = msg.content;
		if (typeof content === "string") {
			msg.content = [
				Object.assign(
					{ type: "text" as const, text: content },
					{ cache_control: { type: "ephemeral" } },
				),
			];
			return;
		}

		if (!Array.isArray(content)) continue;

		// Skip if any part already has cache_control — the prompt pipeline already
		// placed breakpoints at the correct static/dynamic boundary.
		const alreadyMarked = content.some(
			(p) =>
				p &&
				typeof p === "object" &&
				(p as unknown as Record<string, unknown>).cache_control !== undefined,
		);
		if (alreadyMarked) return;

		// Find last text part and add cache_control
		for (let j = content.length - 1; j >= 0; j--) {
			const part = content[j];
			if (part?.type === "text") {
				Object.assign(part, { cache_control: { type: "ephemeral" } });
				return;
			}
		}
	}
}

export function convertMessages(
	model: Model<"openai-completions">,
	context: Context,
	compat: Required<OpenAICompletionsCompat>,
): ChatCompletionMessageParam[] {
	const params: ChatCompletionMessageParam[] = [];

	const normalizeToolCallId = (id: string): string => {
		// Handle pipe-separated IDs from OpenAI Responses API
		// Format: {call_id}|{id} where {id} can be 400+ chars with special chars (+, /, =)
		// These come from providers like github-copilot, openai-codex, opencode
		// Extract just the call_id part and normalize it
		if (id.includes("|")) {
			const [callId] = id.split("|");
			// Sanitize to allowed chars and truncate to 40 chars (OpenAI limit)
			return callId.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40);
		}

		if (model.provider === "openai")
			return id.length > 40 ? id.slice(0, 40) : id;
		return id;
	};

	const transformedMessages = transformMessagesWithReport(
		context.messages,
		model,
		(id) => normalizeToolCallId(id),
		"openai-completions",
	);

	if (context.systemPrompt) {
		const useDeveloperRole = model.reasoning && compat.supportsDeveloperRole;
		const role = useDeveloperRole ? "developer" : "system";
		params.push({
			role: role,
			content: sanitizeSurrogates(context.systemPrompt),
		});
	}

	let lastRole: string | null = null;

	for (let i = 0; i < transformedMessages.length; i++) {
		const msg = transformedMessages[i];
		// Some providers don't allow user messages directly after tool results
		// Insert a synthetic assistant message to bridge the gap
		if (
			compat.requiresAssistantAfterToolResult &&
			lastRole === "toolResult" &&
			msg.role === "user"
		) {
			params.push({
				role: "assistant",
				content: "I have processed the tool results.",
			});
		}

		if (msg.role === "user") {
			if (typeof msg.content === "string") {
				params.push({
					role: "user",
					content: sanitizeSurrogates(msg.content),
				});
			} else {
				const content: ChatCompletionContentPart[] = msg.content.map(
					(item): ChatCompletionContentPart => {
						if (item.type === "text") {
							const part: ChatCompletionContentPartText = {
								type: "text",
								text: sanitizeSurrogates(item.text),
							};
							// Preserve cache_control if present (set upstream for Anthropic prompt caching).
							// The property is not in the OpenAI SDK type but is accepted by providers
							// that support Anthropic-style caching (openrouter/anthropic/*).
							const cacheControl = supportsOpenRouterAnthropicCacheControl(
								model,
							)
								? (item as unknown as Record<string, unknown>).cache_control
								: undefined;
							if (cacheControl) {
								(part as unknown as Record<string, unknown>).cache_control =
									cacheControl;
							}
							return part;
						} else {
							return {
								type: "image_url",
								image_url: {
									url: `data:${item.mimeType};base64,${item.data}`,
								},
							} satisfies ChatCompletionContentPartImage;
						}
					},
				);
				const filteredContent = !model.input.includes("image")
					? content.filter((c) => c.type !== "image_url")
					: content;
				if (filteredContent.length === 0) continue;
				params.push({
					role: "user",
					content: filteredContent,
				});
			}
		} else if (msg.role === "assistant") {
			// Some providers don't accept null content, use empty string instead
			const assistantMsg: ChatCompletionAssistantMessageParam = {
				role: "assistant",
				content: compat.requiresAssistantAfterToolResult ? "" : null,
			};

			const textBlocks = msg.content.filter(
				(b) => b.type === "text",
			) as TextContent[];
			// Filter out empty text blocks to avoid API validation errors
			const nonEmptyTextBlocks = textBlocks.filter(
				(b) => b.text && b.text.trim().length > 0,
			);
			if (nonEmptyTextBlocks.length > 0) {
				// GitHub Copilot requires assistant content as a string, not an array.
				// Sending as array causes Claude models to re-answer all previous prompts.
				if (model.provider === "github-copilot") {
					assistantMsg.content = nonEmptyTextBlocks
						.map((b) => sanitizeSurrogates(b.text))
						.join("");
				} else {
					assistantMsg.content = nonEmptyTextBlocks.map((b) => {
						return { type: "text", text: sanitizeSurrogates(b.text) };
					});
				}
			}

			// Handle thinking blocks
			const thinkingBlocks = msg.content.filter(
				(b) => b.type === "thinking",
			) as ThinkingContent[];
			// Filter out empty thinking blocks to avoid API validation errors
			const nonEmptyThinkingBlocks = thinkingBlocks.filter(
				(b) => b.thinking && b.thinking.trim().length > 0,
			);
			if (nonEmptyThinkingBlocks.length > 0) {
				if (compat.requiresThinkingAsText) {
					// Convert thinking blocks to plain text (no tags to avoid model mimicking them)
					const thinkingText = nonEmptyThinkingBlocks
						.map((b) => b.thinking)
						.join("\n\n");
					const textContent = assistantMsg.content as Array<{
						type: "text";
						text: string;
					}> | null;
					if (textContent) {
						textContent.unshift({ type: "text", text: thinkingText });
					} else {
						assistantMsg.content = [{ type: "text", text: thinkingText }];
					}
				} else {
					// Use the signature from the first thinking block if available (for llama.cpp server + gpt-oss)
					const signature = nonEmptyThinkingBlocks[0].thinkingSignature;
					if (signature && signature.length > 0) {
						// SDK-divergence: llama.cpp / gpt-oss return a dynamic per-field name for the
						// reasoning content (e.g. "reasoning_content"). The field is not in
						// ChatCompletionAssistantMessageParam, so we use a typed extension object.
						(assistantMsg as unknown as Record<string, unknown>)[signature] =
							nonEmptyThinkingBlocks.map((b) => b.thinking).join("\n");
					}
				}
			}

			const toolCalls = msg.content.filter(
				(b) => b.type === "toolCall",
			) as ToolCall[];
			if (toolCalls.length > 0) {
				assistantMsg.tool_calls = toolCalls.map((tc) => ({
					id: tc.id,
					type: "function" as const,
					function: {
						name: tc.name,
						arguments: JSON.stringify(
							sanitizeToolCallArgumentsForSerialization(tc.arguments),
						),
					},
				}));
				const reasoningDetails = toolCalls
					.filter((tc) => tc.thoughtSignature)
					.map((tc) => {
						try {
							return JSON.parse(tc.thoughtSignature!);
						} catch {
							return null;
						}
					})
					.filter(Boolean);
				if (reasoningDetails.length > 0) {
					// SDK-divergence: reasoning_details is a vendor extension (gpt-oss / OpenAI Responses
					// compat path) not present on ChatCompletionAssistantMessageParam.
					(
						assistantMsg as unknown as Record<string, unknown>
					).reasoning_details = reasoningDetails;
				}
			}
			// Skip assistant messages that have no content and no tool calls.
			// Some providers require "either content or tool_calls, but not none".
			// Other providers also don't accept empty assistant messages.
			// This handles aborted assistant responses that got no content.
			const content = assistantMsg.content;
			const hasContent =
				content !== null &&
				content !== undefined &&
				(typeof content === "string" ? content.length > 0 : content.length > 0);
			if (!hasContent && !assistantMsg.tool_calls) {
				continue;
			}
			params.push(assistantMsg);
		} else if (msg.role === "toolResult") {
			const imageBlocks: Array<{
				type: "image_url";
				image_url: { url: string };
			}> = [];
			let j = i;

			for (
				;
				j < transformedMessages.length &&
				transformedMessages[j].role === "toolResult";
				j++
			) {
				const toolMsg = transformedMessages[j] as ToolResultMessage;

				// Extract text and image content
				const textResult = toolMsg.content
					.filter((c) => c.type === "text")
					.map((c) => (c as TextContent).text)
					.join("\n");
				const hasImages = toolMsg.content.some((c) => c.type === "image");

				// Always send tool result with text (or placeholder if only images)
				const hasText = textResult.length > 0;
				// Some providers require the 'name' field in tool results
				const toolResultMsg: ChatCompletionToolMessageParam = {
					role: "tool",
					content: sanitizeSurrogates(
						hasText ? textResult : "(see attached image)",
					),
					tool_call_id: toolMsg.toolCallId,
				};
				if (compat.requiresToolResultName && toolMsg.toolName) {
					// SDK-divergence: the `name` field on tool results is required by some providers
					// (e.g., Mistral) but is not part of the ChatCompletionToolMessageParam type.
					(toolResultMsg as unknown as Record<string, unknown>).name =
						toolMsg.toolName;
				}
				params.push(toolResultMsg);

				if (hasImages && model.input.includes("image")) {
					for (const block of toolMsg.content) {
						if (block.type === "image") {
							const imageBlock = block as ImageContent;
							imageBlocks.push({
								type: "image_url",
								image_url: {
									url: `data:${imageBlock.mimeType};base64,${imageBlock.data}`,
								},
							});
						}
					}
				}
			}

			i = j - 1;

			if (imageBlocks.length > 0) {
				if (compat.requiresAssistantAfterToolResult) {
					params.push({
						role: "assistant",
						content: "I have processed the tool results.",
					});
				}

				params.push({
					role: "user",
					content: [
						{
							type: "text",
							text: "Attached image(s) from tool result:",
						},
						...imageBlocks,
					],
				});
				lastRole = "user";
			} else {
				lastRole = "toolResult";
			}
			continue;
		}

		lastRole = msg.role;
	}

	return params;
}

function convertTools(
	tools: Tool[],
	compat: Required<OpenAICompletionsCompat>,
): OpenAI.Chat.Completions.ChatCompletionTool[] {
	return tools.map((tool) => ({
		type: "function",
		function: {
			name: tool.name,
			description: tool.description,
			parameters: tool.parameters as unknown as FunctionParameters, // TypeBox TSchema is a valid JSON Schema object
			// Only include strict if provider supports it. Some reject unknown fields.
			...(compat.supportsStrictMode !== false && { strict: false }),
		},
	}));
}

function mapStopReason(
	reason: ChatCompletionChunk.Choice["finish_reason"],
): StopReason {
	if (reason === null) return "stop";
	switch (reason) {
		case "stop":
			return "stop";
		case "length":
			return "length";
		case "function_call":
		case "tool_calls":
			return "toolUse";
		case "content_filter":
			return "error";
		default:
			// Third-party and community models (e.g. Qwen GGUF quants) may emit
			// non-standard finish_reason values like "eos_token", "eos", or
			// "end_of_turn". The OpenAI spec defines finish_reason as a string,
			// so we treat unrecognized values as a normal stop rather than
			// throwing — which would abort in-flight tool calls (#863).
			return "stop";
	}
}

/**
 * Detect compatibility settings from provider and baseUrl for known providers.
 * Provider takes precedence over URL-based detection since it's explicitly configured.
 * Returns a fully resolved OpenAICompletionsCompat object with all fields set.
 */
function detectCompat(
	model: Model<"openai-completions">,
): Required<OpenAICompletionsCompat> {
	const provider = model.provider;
	const baseUrl = model.baseUrl;

	const isZai = provider === "zai" || baseUrl.includes("api.z.ai");

	const isNonStandard =
		provider === "cerebras" ||
		baseUrl.includes("cerebras.ai") ||
		provider === "xai" ||
		baseUrl.includes("api.x.ai") ||
		baseUrl.includes("chutes.ai") ||
		baseUrl.includes("deepseek.com") ||
		isZai ||
		provider === "opencode" ||
		baseUrl.includes("opencode.ai");

	const useMaxTokens = baseUrl.includes("chutes.ai");

	const isGrok = provider === "xai" || baseUrl.includes("api.x.ai");
	const isGroq = provider === "groq" || baseUrl.includes("groq.com");

	const reasoningEffortMap =
		isGroq && model.id === "qwen/qwen3-32b"
			? {
					minimal: "default",
					low: "default",
					medium: "default",
					high: "default",
					xhigh: "default",
				}
			: {};
	return {
		supportsStore: !isNonStandard,
		supportsDeveloperRole: !isNonStandard,
		supportsReasoningEffort: !isGrok && !isZai,
		reasoningEffortMap,
		supportsUsageInStreaming: true,
		maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens",
		requiresToolResultName: false,
		requiresAssistantAfterToolResult: false,
		requiresThinkingAsText: false,
		thinkingFormat: isZai ? "zai" : "openai",
		openRouterRouting: {},
		vercelGatewayRouting: {},
		supportsStrictMode: true,
	};
}

/**
 * Get resolved compatibility settings for a model.
 * Uses explicit model.compat if provided, otherwise auto-detects from provider/URL.
 */
function getCompat(
	model: Model<"openai-completions">,
): Required<OpenAICompletionsCompat> {
	const detected = detectCompat(model);
	if (!model.compat) return detected;

	return {
		supportsStore: model.compat.supportsStore ?? detected.supportsStore,
		supportsDeveloperRole:
			model.compat.supportsDeveloperRole ?? detected.supportsDeveloperRole,
		supportsReasoningEffort:
			model.compat.supportsReasoningEffort ?? detected.supportsReasoningEffort,
		reasoningEffortMap:
			model.compat.reasoningEffortMap ?? detected.reasoningEffortMap,
		supportsUsageInStreaming:
			model.compat.supportsUsageInStreaming ??
			detected.supportsUsageInStreaming,
		maxTokensField: model.compat.maxTokensField ?? detected.maxTokensField,
		requiresToolResultName:
			model.compat.requiresToolResultName ?? detected.requiresToolResultName,
		requiresAssistantAfterToolResult:
			model.compat.requiresAssistantAfterToolResult ??
			detected.requiresAssistantAfterToolResult,
		requiresThinkingAsText:
			model.compat.requiresThinkingAsText ?? detected.requiresThinkingAsText,
		thinkingFormat: model.compat.thinkingFormat ?? detected.thinkingFormat,
		openRouterRouting: model.compat.openRouterRouting ?? {},
		vercelGatewayRouting:
			model.compat.vercelGatewayRouting ?? detected.vercelGatewayRouting,
		supportsStrictMode:
			model.compat.supportsStrictMode ?? detected.supportsStrictMode,
	};
}
