spb/chat-spboucher-ai
Public
1import OpenAI from "openai";2import { env } from "../env";3import type { ChatRequest, ProviderAdapter, StreamEvent, Part } from "./types";45let client: OpenAI | null = null;6export function openaiClient(): OpenAI {7 if (!client) client = new OpenAI({ apiKey: env.OPENAI_API_KEY, timeout: 15 * 60 * 1000, maxRetries: 2 });8 return client;9}1011const isReasoningModel = (id: string) => /^(o\d|gpt-5|gpt-6)/.test(id) && !/chat-latest$/.test(id);12/** Models where `temperature` is rejected (reasoning family). */13const supportsTemperature = (id: string) => !isReasoningModel(id) && !/^chat-latest$/.test(id);1415async function toInput(req: ChatRequest) {16 const input: OpenAI.Responses.ResponseInputItem[] = [];17 for (const m of req.messages) {18 if (m.role === "system") { input.push({ role: "system", content: m.parts.filter(p => p.type === "text").map(p => (p as { text: string }).text).join("\n") }); continue; }19 if (m.role === "assistant") {20 const text = m.parts.filter(p => p.type === "text").map(p => (p as { text: string }).text).join("");21 if (text) input.push({ role: "assistant", content: text });22 for (const p of m.parts) if (p.type === "tool_call") {23 input.push({ type: "function_call", call_id: p.id, name: p.name, arguments: JSON.stringify(p.input ?? {}) } as OpenAI.Responses.ResponseFunctionToolCall);24 if (p.status === "ok" || p.status === "error" || p.status === "denied")25 input.push({ type: "function_call_output", call_id: p.id, output: typeof p.output === "string" ? p.output : JSON.stringify(p.status === "ok" ? p.output ?? null : { error: p.error ?? p.status }) });26 }27 continue;28 }29 // user30 const content: OpenAI.Responses.ResponseInputContent[] = [];31 for (const p of m.parts) {32 if (p.type === "text") content.push({ type: "input_text", text: p.text });33 else if (p.type === "image") {34 const a = await req.loadAttachment(p.attachmentId);35 if (a) content.push({ type: "input_image", image_url: `data:${a.mime};base64,${a.data.toString("base64")}`, detail: "auto" });36 } else if (p.type === "file") {37 const a = await req.loadAttachment(p.attachmentId);38 if (!a) continue;39 if (a.mime === "application/pdf") content.push({ type: "input_file", filename: a.name, file_data: `data:application/pdf;base64,${a.data.toString("base64")}` });40 else if (a.text) content.push({ type: "input_text", text: `<file name="${a.name}">\n${a.text}\n</file>` });41 }42 }43 if (content.length) input.push({ role: "user", content });44 }45 return input;46}4748export const openaiAdapter: ProviderAdapter = {49 provider: "openai",50 async *stream(req: ChatRequest): AsyncGenerator<StreamEvent> {51 const id = req.model.id;52 const s = req.settings ?? {};53 const body: OpenAI.Responses.ResponseCreateParamsStreaming = {54 model: id,55 input: await toInput(req),56 stream: true,57 max_output_tokens: s.maxOutputTokens ?? Math.min(req.model.maxOutputTokens ?? 32_000, 32_000),58 store: false,59 };60 if (req.system) body.instructions = req.system;61 if (isReasoningModel(id)) {62 const effort = s.effort === "max" ? "xhigh" : s.effort;63 body.reasoning = { ...(effort ? { effort: effort as "low" | "medium" | "high" | "xhigh" } : {}), ...(s.reasoningSummary !== false ? { summary: "auto" } : {}) } as OpenAI.Responses.ResponseCreateParams["reasoning"];64 } else if (s.temperature != null && supportsTemperature(id)) body.temperature = s.temperature;65 if (req.tools?.length) body.tools = req.tools.map(t => ({ type: "function", name: t.name, description: t.description, parameters: t.inputSchema, strict: false }));6667 let stream: AsyncIterable<OpenAI.Responses.ResponseStreamEvent>;68 try { stream = await openaiClient().responses.create(body, { signal: req.signal }); }69 catch (e) { yield errorEvent(e); return; }7071 const calls = new Map<string, { name: string; args: string; callId: string }>();72 try {73 for await (const ev of stream) {74 switch (ev.type) {75 case "response.output_text.delta": yield { type: "text", delta: ev.delta }; break;76 case "response.reasoning_summary_text.delta": yield { type: "reasoning", delta: ev.delta }; break;77 case "response.output_item.added":78 if (ev.item.type === "function_call") calls.set(ev.item.id ?? ev.item.call_id, { name: ev.item.name, args: "", callId: ev.item.call_id });79 break;80 case "response.function_call_arguments.delta": { const c = calls.get(ev.item_id); if (c) c.args += ev.delta; break; }81 case "response.output_item.done":82 if (ev.item.type === "function_call") {83 const c = calls.get(ev.item.id ?? ev.item.call_id);84 let input: unknown = {}; try { input = JSON.parse(ev.item.arguments || c?.args || "{}"); } catch { input = { _raw: ev.item.arguments }; }85 yield { type: "tool_call", id: ev.item.call_id, name: ev.item.name, input };86 }87 break;88 case "response.completed": case "response.incomplete": {89 const r = ev.response; const u = r.usage;90 if (u) yield { type: "usage", usage: { input: u.input_tokens, output: u.output_tokens, cached: u.input_tokens_details?.cached_tokens, reasoning: u.output_tokens_details?.reasoning_tokens } };91 yield { type: "stop", reason: ev.type === "response.incomplete" ? (r.incomplete_details?.reason ?? "incomplete") : calls.size ? "tool_use" : "end_turn" };92 break;93 }94 case "response.failed": yield { type: "error", message: ev.response.error?.message ?? "response failed" }; break;95 case "error": yield { type: "error", message: (ev as { message?: string }).message ?? "stream error" }; break;96 }97 }98 } catch (e) { yield errorEvent(e); }99 },100};101102function errorEvent(e: unknown): StreamEvent {103 if (e instanceof OpenAI.APIError) return { type: "error", message: e.message, status: e.status, retryable: e.status === 429 || (e.status ?? 0) >= 500 };104 if ((e as Error)?.name === "AbortError") return { type: "stop", reason: "cancelled" };105 return { type: "error", message: String((e as Error)?.message ?? e) };106}107108export function partsText(parts: Part[]): string { return parts.filter(p => p.type === "text").map(p => (p as { text: string }).text).join(""); }109110// ---------------------------------------------------------------- specialized endpoints111export async function openaiEmbed(texts: string[], model = "text-embedding-3-small"): Promise<{ vectors: number[][]; tokens: number }> {112 const r = await openaiClient().embeddings.create({ model, input: texts });113 return { vectors: r.data.map(d => d.embedding), tokens: r.usage?.total_tokens ?? 0 };114}115116export async function openaiTranscribe(file: File, model = "gpt-4o-mini-transcribe", language?: string): Promise<string> {117 const r = await openaiClient().audio.transcriptions.create({ model, file, ...(language ? { language } : {}) });118 return r.text;119}120121export async function openaiSpeak(text: string, voice = "alloy", model = "gpt-4o-mini-tts", instructions?: string): Promise<Buffer> {122 const r = await openaiClient().audio.speech.create({ model, voice, input: text, response_format: "mp3", ...(instructions && model !== "tts-1" && model !== "tts-1-hd" ? { instructions } : {}) });123 return Buffer.from(await r.arrayBuffer());124}125126export async function openaiGenerateImage(prompt: string, opts: { model?: string; size?: string; quality?: string; n?: number } = {}) {127 const model = opts.model ?? "gpt-image-1.5";128 const params: OpenAI.Images.ImageGenerateParams = { model, prompt, n: opts.n ?? 1, size: (opts.size ?? "1024x1024") as OpenAI.Images.ImageGenerateParams["size"] };129 if (opts.quality) (params as { quality?: string }).quality = opts.quality;130 const r = await openaiClient().images.generate(params);131 return { images: (r.data ?? []).map(d => ({ b64: d.b64_json!, revisedPrompt: d.revised_prompt })), usage: r.usage, model };132}133134export async function openaiEditImage(image: File, prompt: string, model = "gpt-image-1.5") {135 const r = await openaiClient().images.edit({ model, image, prompt });136 return { images: (r.data ?? []).map(d => ({ b64: d.b64_json! })), usage: r.usage, model };137}138