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.4 KB · 189 lines typescript
Raw Blame History
1import type { ResponseInputItem, ResponseStreamEvent, ResponseInputContent } from "openai/resources/responses/responses";2import type { UnifiedMessage, UnifiedStreamEvent, FinishReason, ProviderId } from "@/lib/ai/core/types";3import { inlineTextFile, isTextLike } from "@/lib/ai/core/content";4import { safeJson } from "@/lib/ai/core/stream-utils";56/**7 * OpenAI Responses API helpers — used by OpenAI and by xAI's `/v1/responses`.8 * Conversation state is always sent in full (`store: false`), so BYOK users' prompts are9 * not retained provider-side beyond the request.10 */1112export interface ResponsesBuildOptions {13  /** Include native PDF input (`input_file` with `file_data`). xAI needs an uploaded file id → inline instead. */14  nativeFiles?: boolean;15  /** Replay reasoning items (OpenAI `encrypted_content`) for reasoning continuity across turns. */16  replayReasoning?: boolean;17}1819export function toResponsesInput(messages: UnifiedMessage[], opts: ResponsesBuildOptions = {}): ResponseInputItem[] {20  const out: ResponseInputItem[] = [];21  for (const m of messages) {22    if (m.role === "system") {23      out.push({ role: "developer", content: m.content.map((p) => (p.type === "text" ? p.text : "")).join("") });24      continue;25    }26    if (m.role === "tool") {27      for (const p of m.content) {28        if (p.type === "tool-result") out.push({ type: "function_call_output", call_id: p.toolCallId, output: typeof p.result === "string" ? p.result : JSON.stringify(p.result ?? null) });29      }30      continue;31    }32    if (m.role === "assistant") {33      for (const p of m.content) {34        if (p.type === "reasoning" && opts.replayReasoning && p.providerData && typeof p.providerData === "object" && (p.providerData as { encrypted_content?: string }).encrypted_content) {35          const pd = p.providerData as { id?: string; encrypted_content: string };36          out.push({ type: "reasoning", id: pd.id ?? `rs_${Math.random().toString(36).slice(2)}`, summary: [], encrypted_content: pd.encrypted_content } as ResponseInputItem);37        }38      }39      const text = m.content.map((p) => (p.type === "text" ? p.text : "")).join("");40      if (text) out.push({ role: "assistant", content: [{ type: "output_text", text, annotations: [] }] } as unknown as ResponseInputItem);41      for (const p of m.content) {42        if (p.type === "tool-call") out.push({ type: "function_call", call_id: p.id, name: p.name, arguments: p.argumentsText ?? JSON.stringify(p.arguments) });43      }44      for (const p of m.content) {45        if (p.type === "tool-result") out.push({ type: "function_call_output", call_id: p.toolCallId, output: typeof p.result === "string" ? p.result : JSON.stringify(p.result ?? null) });46      }47      continue;48    }49    const parts: ResponseInputContent[] = [];50    for (const p of m.content) {51      if (p.type === "text") parts.push({ type: "input_text", text: p.text });52      else if (p.type === "image") parts.push({ type: "input_image", image_url: `data:${p.mimeType};base64,${p.data}`, detail: "auto" });53      else if (p.type === "file") {54        if (p.mimeType === "application/pdf" && opts.nativeFiles) parts.push({ type: "input_file", filename: p.name, file_data: `data:application/pdf;base64,${p.data}` });55        else if (isTextLike(p.mimeType, p.name)) parts.push({ type: "input_text", text: inlineTextFile(p.name, p.data) });56        else parts.push({ type: "input_text", text: `[Attached file "${p.name}" (${p.mimeType}) — this model cannot read binary files of this type.]` });57      }58    }59    if (parts.length) out.push({ role: "user", content: parts });60  }61  return out;62}6364function mapIncomplete(reason: string | null | undefined): FinishReason {65  if (reason === "max_output_tokens") return "length";66  if (reason === "content_filter") return "content-filter";67  return "other";68}6970interface ToolAcc {71  callId: string;72  name: string;73  args: string;74}7576/** Normalize a Responses API event stream into UnifiedStreamEvents. */77export async function* normalizeResponsesStream(stream: AsyncIterable<ResponseStreamEvent>, provider: ProviderId): AsyncIterable<UnifiedStreamEvent> {78  const tools = new Map<string, ToolAcc>(); // by item_id79  let finish: FinishReason = "stop";80  let sawToolCall = false;81  let started = false;82  let emittedFinish = false;83  for await (const ev of stream) {84    switch (ev.type) {85      case "response.created":86        if (!started) {87          started = true;88          yield { type: "start", id: ev.response.id, model: ev.response.model };89        }90        break;91      case "response.output_item.added": {92        const item = ev.item as { type: string; id?: string; call_id?: string; name?: string };93        if (item.type === "function_call" && item.id) {94          tools.set(item.id, { callId: item.call_id ?? item.id, name: item.name ?? "", args: "" });95          sawToolCall = true;96          yield { type: "tool-start", id: item.call_id ?? item.id, name: item.name ?? "" };97        } else if (item.type === "web_search_call") yield { type: "server-tool", name: "web_search", status: "started" };98        else if (item.type === "code_interpreter_call") yield { type: "server-tool", name: "code_interpreter", status: "started" };99        else if (item.type === "file_search_call") yield { type: "server-tool", name: "file_search", status: "started" };100        break;101      }102      case "response.output_text.delta":103        yield { type: "text-delta", text: ev.delta };104        break;105      case "response.reasoning_summary_text.delta":106        yield { type: "reasoning-delta", text: ev.delta };107        break;108      case "response.reasoning_summary_part.done":109        yield { type: "reasoning-delta", text: "\n\n" };110        break;111      case "response.function_call_arguments.delta": {112        const acc = tools.get(ev.item_id);113        if (acc) {114          acc.args += ev.delta;115          yield { type: "tool-delta", id: acc.callId, argumentsDelta: ev.delta };116        }117        break;118      }119      case "response.function_call_arguments.done": {120        const acc = tools.get(ev.item_id);121        if (acc) {122          acc.args = ev.arguments || acc.args;123          yield { type: "tool-end", id: acc.callId, name: acc.name, arguments: safeJson(acc.args), argumentsText: acc.args };124          tools.delete(ev.item_id);125        }126        break;127      }128      case "response.output_item.done": {129        const item = ev.item as { type: string; id?: string; call_id?: string; name?: string; arguments?: string; encrypted_content?: string | null; summary?: unknown; action?: { sources?: { url: string; title?: string }[] } };130        if (item.type === "function_call" && item.id && tools.has(item.id)) {131          const acc = tools.get(item.id)!;132          const args = item.arguments ?? acc.args;133          yield { type: "tool-end", id: acc.callId, name: acc.name, arguments: safeJson(args), argumentsText: args };134          tools.delete(item.id);135        } else if (item.type === "reasoning") {136          if (item.encrypted_content) yield { type: "reasoning-signature", signature: "encrypted", providerData: { id: item.id, encrypted_content: item.encrypted_content } };137        } else if (item.type === "web_search_call") {138          yield { type: "server-tool", name: "web_search", status: "completed", data: item.action?.sources?.slice(0, 10) };139        } else if (item.type === "code_interpreter_call") {140          yield { type: "server-tool", name: "code_interpreter", status: "completed" };141        }142        break;143      }144      case "response.output_text.annotation.added": {145        const a = ev.annotation as { type: string; url?: string; title?: string; start_index?: number; end_index?: number };146        if (a.type === "url_citation") yield { type: "citation", citation: { url: a.url, title: a.title, startIndex: a.start_index, endIndex: a.end_index, source: "web_search" } };147        break;148      }149      case "response.completed":150      case "response.incomplete":151      case "response.failed": {152        const r = ev.response;153        const u = r.usage;154        if (u) {155          yield {156            type: "usage",157            usage: {158              inputTokens: u.input_tokens ?? 0,159              outputTokens: u.output_tokens ?? 0,160              cachedInputTokens: u.input_tokens_details?.cached_tokens ?? undefined,161              reasoningTokens: u.output_tokens_details?.reasoning_tokens ?? undefined,162              totalTokens: u.total_tokens ?? undefined,163            },164          };165        }166        if (ev.type === "response.failed") {167          const err = (r as { error?: { code?: string; message?: string } | null }).error;168          yield { type: "error", error: { code: "UNKNOWN_PROVIDER_ERROR", message: err?.message ?? "Response failed", provider, retryable: false, providerCode: err?.code } };169          emittedFinish = true;170          return;171        }172        if (ev.type === "response.incomplete") finish = mapIncomplete(r.incomplete_details?.reason);173        else finish = sawToolCall ? "tool-calls" : "stop";174        if (r.id) yield { type: "provider-data", data: { responseId: r.id } };175        break;176      }177      case "error": {178        const e = ev as { code?: string | null; message?: string };179        yield { type: "error", error: { code: "UNKNOWN_PROVIDER_ERROR", message: e.message ?? "Stream error", provider, retryable: false, providerCode: e.code ?? undefined } };180        emittedFinish = true;181        return;182      }183      default:184        break;185    }186  }187  if (!emittedFinish) yield { type: "finish", reason: finish };188}189