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 };
}