TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1import type OpenAI from "openai";2import type { ChatCompletionChunk, ChatCompletionMessageParam, ChatCompletionContentPart } from "openai/resources/chat/completions";3import type { UnifiedMessage, UnifiedStreamEvent, FinishReason, ProviderId } from "@/lib/ai/core/types";4import { inlineTextFile, isTextLike } from "@/lib/ai/core/content";5import { safeJson } from "@/lib/ai/core/stream-utils";67/**8 * OpenAI-shaped Chat Completions helpers, shared by xAI (and any future9 * OpenAI-compatible endpoint such as Groq, Together, local servers).10 */1112export interface ChatCompletionsBuildOptions {13 /** Role used for the system prompt. */14 systemRole?: "system" | "developer";15 /** When the endpoint has no PDF support, text-like files are inlined; others are described. */16 inlineFiles?: boolean;17 /** Replay stored reasoning as `reasoning_content` on assistant turns (required by Kimi/DeepSeek thinking models when tools are used). */18 replayReasoningContent?: boolean;19 /** Provider-specific content part for PDFs (e.g. Mistral `document_url`). When absent, PDFs are described. */20 pdfPart?: (data: string, name: string) => ChatCompletionContentPart;21}2223export function toChatCompletionMessages(system: string | undefined, messages: UnifiedMessage[], opts: ChatCompletionsBuildOptions = {}): ChatCompletionMessageParam[] {24 const out: ChatCompletionMessageParam[] = [];25 if (system?.trim()) out.push({ role: opts.systemRole ?? "system", content: system } as ChatCompletionMessageParam);26 for (const m of messages) {27 if (m.role === "system") {28 out.push({ role: opts.systemRole ?? "system", content: m.content.map((p) => (p.type === "text" ? p.text : "")).join("") } as ChatCompletionMessageParam);29 continue;30 }31 if (m.role === "tool") {32 for (const p of m.content) {33 if (p.type === "tool-result") out.push({ role: "tool", tool_call_id: p.toolCallId, content: typeof p.result === "string" ? p.result : JSON.stringify(p.result ?? null) });34 }35 continue;36 }37 if (m.role === "assistant") {38 const text = m.content.map((p) => (p.type === "text" ? p.text : "")).join("");39 const toolCalls = m.content.filter((p) => p.type === "tool-call");40 const msg: ChatCompletionMessageParam = { role: "assistant", content: text || null };41 if (opts.replayReasoningContent) {42 const reasoning = m.content.filter((p) => p.type === "reasoning").map((p) => (p.type === "reasoning" ? p.text : "")).join("");43 if (reasoning) (msg as unknown as Record<string, unknown>).reasoning_content = reasoning;44 }45 if (toolCalls.length) {46 msg.tool_calls = toolCalls.map((p) => (p.type === "tool-call" ? { id: p.id, type: "function" as const, function: { name: p.name, arguments: p.argumentsText ?? JSON.stringify(p.arguments) } } : null)).filter(Boolean) as NonNullable<typeof msg.tool_calls>;47 }48 // tool results embedded in the assistant unified message (single-turn tool loops)49 out.push(msg);50 for (const p of m.content) {51 if (p.type === "tool-result") out.push({ role: "tool", tool_call_id: p.toolCallId, content: typeof p.result === "string" ? p.result : JSON.stringify(p.result ?? null) });52 }53 continue;54 }55 // user56 const parts: ChatCompletionContentPart[] = [];57 for (const p of m.content) {58 if (p.type === "text") parts.push({ type: "text", text: p.text });59 else if (p.type === "image") parts.push({ type: "image_url", image_url: { url: `data:${p.mimeType};base64,${p.data}`, detail: "auto" } });60 else if (p.type === "file") {61 if (p.mimeType === "application/pdf" && opts.pdfPart) parts.push(opts.pdfPart(p.data, p.name));62 else if (isTextLike(p.mimeType, p.name) || opts.inlineFiles !== false) parts.push({ type: "text", text: isTextLike(p.mimeType, p.name) ? inlineTextFile(p.name, p.data) : `[Attached file "${p.name}" (${p.mimeType}) — this model cannot read binary files of this type.]` });63 }64 }65 if (parts.length === 1 && parts[0].type === "text") out.push({ role: "user", content: parts[0].text });66 else if (parts.length) out.push({ role: "user", content: parts });67 }68 return out;69}7071export function mapChatFinish(reason: string | null | undefined): FinishReason {72 switch (reason) {73 case "stop":74 return "stop";75 case "length":76 return "length";77 case "tool_calls":78 case "function_call":79 return "tool-calls";80 case "content_filter":81 return "content-filter";82 default:83 return "other";84 }85}8687interface ToolAcc {88 id: string;89 name: string;90 args: string;91 started: boolean;92}9394/**95 * Normalize an OpenAI-shaped chat.completion.chunk stream into UnifiedStreamEvents.96 * Handles `delta.reasoning_content` (xAI, DeepSeek-style) and `delta.reasoning` variants.97 */98export async function* normalizeChatCompletionStream(stream: AsyncIterable<ChatCompletionChunk>, provider: ProviderId): AsyncIterable<UnifiedStreamEvent> {99 const tools = new Map<number, ToolAcc>();100 let finish: FinishReason = "other";101 let started = false;102 let sawUsage = false;103 let lastUpstream: string | undefined;104 for await (const chunk of stream) {105 if (!started) {106 started = true;107 yield { type: "start", id: chunk.id, model: chunk.model };108 }109 const upstream = (chunk as { provider?: string }).provider;110 if (upstream && upstream !== lastUpstream) {111 lastUpstream = upstream;112 yield { type: "provider-data", data: { upstreamProvider: upstream } };113 }114 const choice = chunk.choices?.[0];115 if (choice) {116 const annotations = (choice.delta as { annotations?: Array<{ type?: string; url_citation?: { url?: string; title?: string; content?: string } }> }).annotations;117 if (Array.isArray(annotations)) {118 for (const a of annotations) if (a.type === "url_citation" && a.url_citation?.url) yield { type: "citation", citation: { url: a.url_citation.url, title: a.url_citation.title, snippet: a.url_citation.content?.slice(0, 300), source: "web_search" } };119 }120 const delta = choice.delta as Omit<ChatCompletionChunk.Choice.Delta, "content"> & { content?: string | null | Array<{ type?: string; text?: string; thinking?: Array<{ type?: string; text?: string }> | string }>; reasoning_content?: string | null; reasoning?: string | null };121 const reasoning = delta.reasoning_content ?? delta.reasoning;122 if (reasoning) yield { type: "reasoning-delta", text: reasoning };123 if (typeof delta.content === "string" && delta.content) yield { type: "text-delta", text: delta.content };124 else if (Array.isArray(delta.content)) {125 // Mistral (Magistral) streams content as chunks: {type:"thinking", thinking:[{type:"text", text}]} | {type:"text", text}126 for (const c of delta.content) {127 if (c.type === "thinking") {128 const t = typeof c.thinking === "string" ? c.thinking : (c.thinking ?? []).map((x) => x.text ?? "").join("");129 if (t) yield { type: "reasoning-delta", text: t };130 } else if (c.text) yield { type: "text-delta", text: c.text };131 }132 }133 if (delta.tool_calls) {134 for (const tc of delta.tool_calls) {135 const idx = tc.index ?? 0;136 let acc = tools.get(idx);137 if (!acc) {138 acc = { id: tc.id ?? `call_${idx}`, name: tc.function?.name ?? "", args: "", started: false };139 tools.set(idx, acc);140 }141 if (tc.id) acc.id = tc.id;142 if (tc.function?.name) acc.name = tc.function.name;143 if (!acc.started && acc.name) {144 acc.started = true;145 yield { type: "tool-start", id: acc.id, name: acc.name };146 }147 if (tc.function?.arguments) {148 acc.args += tc.function.arguments;149 yield { type: "tool-delta", id: acc.id, argumentsDelta: tc.function.arguments };150 }151 }152 }153 if (choice.finish_reason) finish = mapChatFinish(choice.finish_reason);154 }155 const usage = chunk.usage as (ChatCompletionChunk["usage"] & { prompt_tokens_details?: { cached_tokens?: number | null } | null; completion_tokens_details?: { reasoning_tokens?: number | null } | null; prompt_cache_hit_tokens?: number | null; cost?: number | null; cached_tokens?: number | null }) | null | undefined;156 if (usage) {157 sawUsage = true;158 const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? undefined;159 const cached = usage.prompt_tokens_details?.cached_tokens ?? usage.prompt_cache_hit_tokens ?? usage.cached_tokens ?? undefined;160 // xAI: total_tokens = prompt + completion + reasoning; completion excludes reasoning. We bill reasoning as output.161 const outputTokens = (usage.completion_tokens ?? 0) + (provider === "xai" ? reasoningTokens ?? 0 : 0);162 yield {163 type: "usage",164 usage: {165 inputTokens: usage.prompt_tokens ?? 0,166 outputTokens,167 cachedInputTokens: cached ?? undefined,168 reasoningTokens,169 totalTokens: usage.total_tokens ?? undefined,170 },171 };172 const cost = (usage as { cost_in_usd_ticks?: number }).cost_in_usd_ticks;173 if (typeof cost === "number") yield { type: "provider-data", data: { exactCostUsd: cost / 1e10 } };174 if (typeof usage.cost === "number") yield { type: "provider-data", data: { exactCostUsd: usage.cost } }; // OpenRouter usage accounting175 }176 }177 for (const acc of tools.values()) {178 if (!acc.started) yield { type: "tool-start", id: acc.id, name: acc.name };179 yield { type: "tool-end", id: acc.id, name: acc.name, arguments: safeJson(acc.args), argumentsText: acc.args };180 }181 if (!sawUsage) yield { type: "usage", usage: { inputTokens: 0, outputTokens: 0 } };182 yield { type: "finish", reason: finish };183}184185export type OpenAIClient = OpenAI;186