SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
9.6 KB · 186 lines typescript
Raw Blame History
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