import OpenAI from "openai"; import { env } from "../env"; import type { ChatRequest, ProviderAdapter, StreamEvent, Part } from "./types"; let client: OpenAI | null = null; export function openaiClient(): OpenAI { if (!client) client = new OpenAI({ apiKey: env.OPENAI_API_KEY, timeout: 15 * 60 * 1000, maxRetries: 2 }); return client; } const isReasoningModel = (id: string) => /^(o\d|gpt-5|gpt-6)/.test(id) && !/chat-latest$/.test(id); /** Models where `temperature` is rejected (reasoning family). */ const supportsTemperature = (id: string) => !isReasoningModel(id) && !/^chat-latest$/.test(id); async function toInput(req: ChatRequest) { const input: OpenAI.Responses.ResponseInputItem[] = []; for (const m of req.messages) { 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; } if (m.role === "assistant") { const text = m.parts.filter(p => p.type === "text").map(p => (p as { text: string }).text).join(""); if (text) input.push({ role: "assistant", content: text }); for (const p of m.parts) if (p.type === "tool_call") { input.push({ type: "function_call", call_id: p.id, name: p.name, arguments: JSON.stringify(p.input ?? {}) } as OpenAI.Responses.ResponseFunctionToolCall); if (p.status === "ok" || p.status === "error" || p.status === "denied") 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 }) }); } continue; } // user const content: OpenAI.Responses.ResponseInputContent[] = []; for (const p of m.parts) { if (p.type === "text") content.push({ type: "input_text", text: p.text }); else if (p.type === "image") { const a = await req.loadAttachment(p.attachmentId); if (a) content.push({ type: "input_image", image_url: `data:${a.mime};base64,${a.data.toString("base64")}`, detail: "auto" }); } else if (p.type === "file") { const a = await req.loadAttachment(p.attachmentId); if (!a) continue; if (a.mime === "application/pdf") content.push({ type: "input_file", filename: a.name, file_data: `data:application/pdf;base64,${a.data.toString("base64")}` }); else if (a.text) content.push({ type: "input_text", text: `\n${a.text}\n` }); } } if (content.length) input.push({ role: "user", content }); } return input; } export const openaiAdapter: ProviderAdapter = { provider: "openai", async *stream(req: ChatRequest): AsyncGenerator { const id = req.model.id; const s = req.settings ?? {}; const body: OpenAI.Responses.ResponseCreateParamsStreaming = { model: id, input: await toInput(req), stream: true, max_output_tokens: s.maxOutputTokens ?? Math.min(req.model.maxOutputTokens ?? 32_000, 32_000), store: false, }; if (req.system) body.instructions = req.system; if (isReasoningModel(id)) { const effort = s.effort === "max" ? "xhigh" : s.effort; body.reasoning = { ...(effort ? { effort: effort as "low" | "medium" | "high" | "xhigh" } : {}), ...(s.reasoningSummary !== false ? { summary: "auto" } : {}) } as OpenAI.Responses.ResponseCreateParams["reasoning"]; } else if (s.temperature != null && supportsTemperature(id)) body.temperature = s.temperature; if (req.tools?.length) body.tools = req.tools.map(t => ({ type: "function", name: t.name, description: t.description, parameters: t.inputSchema, strict: false })); let stream: AsyncIterable; try { stream = await openaiClient().responses.create(body, { signal: req.signal }); } catch (e) { yield errorEvent(e); return; } const calls = new Map(); try { for await (const ev of stream) { switch (ev.type) { case "response.output_text.delta": yield { type: "text", delta: ev.delta }; break; case "response.reasoning_summary_text.delta": yield { type: "reasoning", delta: ev.delta }; break; case "response.output_item.added": 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 }); break; case "response.function_call_arguments.delta": { const c = calls.get(ev.item_id); if (c) c.args += ev.delta; break; } case "response.output_item.done": if (ev.item.type === "function_call") { const c = calls.get(ev.item.id ?? ev.item.call_id); let input: unknown = {}; try { input = JSON.parse(ev.item.arguments || c?.args || "{}"); } catch { input = { _raw: ev.item.arguments }; } yield { type: "tool_call", id: ev.item.call_id, name: ev.item.name, input }; } break; case "response.completed": case "response.incomplete": { const r = ev.response; const u = r.usage; 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 } }; yield { type: "stop", reason: ev.type === "response.incomplete" ? (r.incomplete_details?.reason ?? "incomplete") : calls.size ? "tool_use" : "end_turn" }; break; } case "response.failed": yield { type: "error", message: ev.response.error?.message ?? "response failed" }; break; case "error": yield { type: "error", message: (ev as { message?: string }).message ?? "stream error" }; break; } } } catch (e) { yield errorEvent(e); } }, }; function errorEvent(e: unknown): StreamEvent { if (e instanceof OpenAI.APIError) return { type: "error", message: e.message, status: e.status, retryable: e.status === 429 || (e.status ?? 0) >= 500 }; if ((e as Error)?.name === "AbortError") return { type: "stop", reason: "cancelled" }; return { type: "error", message: String((e as Error)?.message ?? e) }; } export function partsText(parts: Part[]): string { return parts.filter(p => p.type === "text").map(p => (p as { text: string }).text).join(""); } // ---------------------------------------------------------------- specialized endpoints export async function openaiEmbed(texts: string[], model = "text-embedding-3-small"): Promise<{ vectors: number[][]; tokens: number }> { const r = await openaiClient().embeddings.create({ model, input: texts }); return { vectors: r.data.map(d => d.embedding), tokens: r.usage?.total_tokens ?? 0 }; } export async function openaiTranscribe(file: File, model = "gpt-4o-mini-transcribe", language?: string): Promise { const r = await openaiClient().audio.transcriptions.create({ model, file, ...(language ? { language } : {}) }); return r.text; } export async function openaiSpeak(text: string, voice = "alloy", model = "gpt-4o-mini-tts", instructions?: string): Promise { const r = await openaiClient().audio.speech.create({ model, voice, input: text, response_format: "mp3", ...(instructions && model !== "tts-1" && model !== "tts-1-hd" ? { instructions } : {}) }); return Buffer.from(await r.arrayBuffer()); } export async function openaiGenerateImage(prompt: string, opts: { model?: string; size?: string; quality?: string; n?: number } = {}) { const model = opts.model ?? "gpt-image-1.5"; const params: OpenAI.Images.ImageGenerateParams = { model, prompt, n: opts.n ?? 1, size: (opts.size ?? "1024x1024") as OpenAI.Images.ImageGenerateParams["size"] }; if (opts.quality) (params as { quality?: string }).quality = opts.quality; const r = await openaiClient().images.generate(params); return { images: (r.data ?? []).map(d => ({ b64: d.b64_json!, revisedPrompt: d.revised_prompt })), usage: r.usage, model }; } export async function openaiEditImage(image: File, prompt: string, model = "gpt-image-1.5") { const r = await openaiClient().images.edit({ model, image, prompt }); return { images: (r.data ?? []).map(d => ({ b64: d.b64_json! })), usage: r.usage, model }; }