UNPKG

@sisu-ai/adapter-openai

Version:

OpenAI‑compatible Chat adapter with tools support.

379 lines (378 loc) 15.8 kB
import { firstConfigValue } from '@sisu-ai/core'; export function openAIAdapter(opts) { const apiKey = opts.apiKey ?? firstConfigValue(['OPENAI_API_KEY', 'API_KEY']) ?? ''; const envBase = firstConfigValue(['OPENAI_BASE_URL', 'BASE_URL']); const baseUrl = (opts.baseUrl ?? envBase ?? 'https://api.openai.com').replace(/\/$/, ''); if (!apiKey) throw new Error('[openAIAdapter] Missing OPENAI_API_KEY or API_KEY — set it in your environment or pass { apiKey }'); const DEBUG = String(process.env.DEBUG_LLM || '').toLowerCase() === 'true' || process.env.DEBUG_LLM === '1'; function generate(messages, genOpts) { const toolsParam = (genOpts?.tools ?? []).map(t => toOpenAiTool(t)); const tool_choice = normalizeToolChoice(genOpts?.toolChoice); const body = { model: opts.model, messages: messages.map(m => toOpenAiMessage(m)), temperature: genOpts?.temperature ?? 0.2, ...(toolsParam.length ? { tools: toolsParam } : {}), // Some providers reject tool_choice when tools are not present; include only when tools exist ...((toolsParam.length && tool_choice !== undefined) ? { tool_choice } : {}), ...(genOpts?.parallelToolCalls !== undefined ? { parallel_tool_calls: Boolean(genOpts.parallelToolCalls) } : {}), ...(genOpts?.stream ? { stream: true } : {}), }; const url = `${baseUrl}/v1/chat/completions`; if (DEBUG) { try { // Print a redacted/summarized payload for troubleshooting const dbgMsgs = body.messages.map((m) => { const toolCalls = Array.isArray(m.tool_calls) ? m.tool_calls.map((tc) => ({ id: tc.id, function: { name: tc.function?.name, arguments: summarize(tc.function?.arguments) } })) : undefined; return { role: m.role, name: m.name, tool_call_id: m.tool_call_id, tool_calls: toolCalls, content: Array.isArray(m.content) ? `[${m.content.length} parts]` : (typeof m.content === 'string' ? summarize(m.content) : m.content === null ? null : typeof m.content), }; }); // eslint-disable-next-line no-console console.error('[DEBUG_LLM] request', JSON.stringify({ url, headers: { Authorization: 'Bearer ***', 'Content-Type': 'application/json', Accept: 'application/json' }, body: { ...body, messages: dbgMsgs } })); } catch (e) { void e; } } if (genOpts?.stream === true) { // Return an async generator that performs the fetch and yields tokens return (async function* () { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': `Bearer ${apiKey}` }, body: JSON.stringify(body) }); if (!res.ok || !res.body) { const err = await res.text(); throw new Error(`OpenAI API error: ${res.status} ${res.statusText}${String(err).slice(0, 500)}`); } const decoder = new TextDecoder(); let buf = ''; let full = ''; for await (const chunk of res.body) { const piece = typeof chunk === 'string' ? chunk : decoder.decode(chunk); buf += piece; const lines = buf.split('\n'); buf = lines.pop() ?? ''; for (const line of lines) { const m = line.match(/^data:\s*(.*)/); if (!m) continue; const data = m[1].trim(); if (!data) continue; if (data === '[DONE]') { // Graceful end-of-stream sentinel from OpenAI; not JSON. // Do not log parse errors for this case. continue; } try { const j = JSON.parse(data); const token = j?.choices?.[0]?.delta?.content; if (typeof token === 'string') { full += token; yield { type: 'token', token }; } } catch (e) { // Some providers may emit non-JSON comment frames; ignore silently unless DEBUG is on if (DEBUG) console.error('[DEBUG_LLM] stream_parse_error', { error: e }); } } } yield { type: 'assistant_message', message: { role: 'assistant', content: full } }; })(); } // Non-stream: return a Promise return (async () => { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': `Bearer ${apiKey}` }, body: JSON.stringify(body) }); const raw = await res.text(); if (!res.ok) { let details = raw; try { const j = JSON.parse(raw); details = j.error?.message ?? j.error ?? raw; } catch (e) { console.error('[DEBUG_LLM] request_error', { error: e }); } throw new Error(`OpenAI API error: ${res.status} ${res.statusText}${String(details).slice(0, 500)}`); } const data = (raw ? JSON.parse(raw) : {}); const choice = data?.choices?.[0]; const toolCalls = (() => { const msgShape = (choice?.message ?? {}); const tcs = msgShape?.tool_calls; if (Array.isArray(tcs) && tcs.length) { return tcs.map((tc) => ({ id: tc.id ?? stableToolCallId(tc.function?.name ?? '', tc.function?.arguments), name: tc.function?.name ?? '', arguments: safeJson(tc.function?.arguments) })); } if (msgShape?.function_call) { return [{ id: stableToolCallId(msgShape.function_call.name, msgShape.function_call.arguments), name: msgShape.function_call.name, arguments: safeJson(msgShape.function_call.arguments) }]; } return undefined; })(); const msg = { role: 'assistant', content: choice?.message?.content ?? '' }; if (toolCalls) msg.tool_calls = toolCalls; const usage = mapUsage(data?.usage); return { message: msg, ...(usage ? { usage } : {}) }; })(); } return { name: 'openai:' + opts.model, capabilities: { functionCall: true, streaming: true }, // Non-standard metadata for tools that may target other OpenAI surfaces (e.g., Responses API) ...(opts.responseModel ? { meta: { responseModel: opts.responseModel } } : {}), generate: generate }; } function toOpenAiTool(tool) { return { type: 'function', function: { name: tool.name, description: tool.description, parameters: toJsonSchema(tool.schema), } }; } function toJsonSchema(schema) { // Minimal zod-ish to JSON Schema converter for common primitives if (!schema || typeof schema !== 'object') return { type: 'object' }; const def = schema._def; const t = def?.typeName; if (t === 'ZodString') return { type: 'string' }; if (t === 'ZodNumber') return { type: 'number' }; if (t === 'ZodBoolean') return { type: 'boolean' }; if (t === 'ZodArray') return { type: 'array', items: toJsonSchema(def?.type) }; if (t === 'ZodOptional') return toJsonSchema(def?.innerType); if (t === 'ZodObject') { const shape = typeof def?.shape === 'function' ? def.shape() : def?.shape; const props = {}; const required = []; for (const [key, val] of Object.entries(shape ?? {})) { props[key] = toJsonSchema(val); const innerTypeName = (val?._def?.typeName); if (innerTypeName !== 'ZodOptional' && innerTypeName !== 'ZodDefault') required.push(key); } return { type: 'object', properties: props, ...(required.length ? { required } : {}), additionalProperties: false }; } // Fallback return { type: 'object' }; } function safeJson(s) { if (typeof s !== 'string') return s; try { return JSON.parse(s); } catch (e) { void e; return s; } } function normalizeToolChoice(choice) { if (!choice) return undefined; if (choice === 'auto' || choice === 'none') return choice; // assume specific function name return { type: 'function', function: { name: choice } }; } function toOpenAiMessage(m) { const base = { role: m.role }; // Tool responses must be simple strings for OpenAI if (m.role === 'tool') { const anyM = m; return { ...base, content: String(anyM.content ?? ''), ...(anyM.tool_call_id ? { tool_call_id: anyM.tool_call_id } : {}), ...(anyM.name && !anyM.tool_call_id ? { name: anyM.name } : {}), }; } const anyM = m; const toolCalls = Array.isArray(anyM.tool_calls) ? anyM.tool_calls.map((tc) => ({ id: tc.id, type: 'function', function: { name: tc.name ?? '', arguments: JSON.stringify(tc.arguments ?? {}) } })) : undefined; // Build content parts if images or structured parts are present const parts = buildContentParts(anyM); // Prefer null content if only tool_calls are present and no content parts if (m.role === 'assistant') { return { ...base, content: (toolCalls && (!hasTextOrImages(parts) && (m.content === undefined || m.content === ''))) ? null : (parts ?? m.content ?? ''), ...(toolCalls ? { tool_calls: toolCalls } : {}), }; } return { ...base, content: parts ?? (m.content ?? ''), ...(m.name ? { name: m.name } : {}), }; } function hasTextOrImages(parts) { if (!parts) return false; if (typeof parts === 'string') return parts.length > 0; return Array.isArray(parts) && parts.length > 0; } // Accepts multiple ways to specify rich content: // - content: string (plain text) // - content: OpenAIContentPart[] (already structured) // - contentParts: Array<string | {type:'text'|'image_url'|'image', text?: string, image_url?: string|{url:string}, url?: string}> // - images: string[] (urls or data:image/*) // - image_url / image: string (single image) function buildContentParts(m) { if (!m || typeof m !== 'object') return undefined; const obj = m; // If content is already an array of parts, normalize and return if (Array.isArray(obj.content)) return normalizePartsArray(obj.content); if (Array.isArray(obj.contentParts)) return normalizePartsArray(obj.contentParts); const images = []; if (Array.isArray(obj.images)) images.push(...obj.images); if (Array.isArray(obj.image_urls)) images.push(...obj.image_urls); if (typeof obj.image_url === 'string') images.push(obj.image_url); if (typeof obj.image === 'string') images.push(obj.image); const hasImages = images.length > 0; const hasText = typeof obj.content === 'string' && obj.content.length > 0; if (!hasImages) return undefined; const parts = []; if (hasText) parts.push({ type: 'text', text: String(obj.content) }); for (const url of images) parts.push({ type: 'image_url', image_url: toImageUrl(url) }); return parts; } function toImageUrl(url) { // OpenAI allows either a string or an object {url} // Keep data: URLs as-is; wrap regular strings in { url } if (typeof url !== 'string') return { url: String(url) }; // We can pass string directly per API spec return url; } function normalizePartsArray(parts) { const out = []; for (const p of parts) { if (typeof p === 'string') { out.push({ type: 'text', text: p }); continue; } if (!p || typeof p !== 'object') continue; const obj = p; const t = obj.type; if (t === 'text' && typeof obj.text === 'string') { out.push({ type: 'text', text: obj.text }); continue; } if (t === 'image_url') { const iu = obj.image_url; if (typeof iu === 'string') { out.push({ type: 'image_url', image_url: iu }); } else if (iu && typeof iu === 'object' && typeof iu.url === 'string') { out.push({ type: 'image_url', image_url: { url: iu.url } }); } continue; } // Common alias: { type: 'image', url: '...' } if (t === 'image' && typeof obj.url === 'string') { out.push({ type: 'image_url', image_url: obj.url }); continue; } // Common alias: { image_url: '...' } or { image: '...' } if (typeof obj.image_url === 'string') { out.push({ type: 'image_url', image_url: obj.image_url }); continue; } if (typeof obj.image === 'string') { out.push({ type: 'image_url', image_url: obj.image }); continue; } } return out; } function summarize(v, max = 300) { if (typeof v !== 'string') return v; return v.length > max ? v.slice(0, max) + '…' : v; } // Create a deterministic id from function name + arguments function stableToolCallId(name, args) { const s = `${name}:${stableStringify(args)}`; let h = 0; for (let i = 0; i < s.length; i++) { h = ((h << 5) - h + s.charCodeAt(i)) | 0; // djb2-like } const hex = (h >>> 0).toString(16); return `fc_${name || 'fn'}_${hex}`; } function stableStringify(v) { try { if (!v || typeof v !== 'object' || Array.isArray(v)) return JSON.stringify(v); const obj = v; const keys = Object.keys(obj).sort(); const sorted = {}; for (const k of keys) sorted[k] = obj[k]; return JSON.stringify(sorted); } catch { return String(v); } } function mapUsage(u) { if (!u || typeof u !== 'object') return undefined; const obj = u; const prompt = obj.prompt_tokens ?? obj.input_tokens; const completion = obj.completion_tokens ?? obj.output_tokens; const total = obj.total_tokens ?? (Number(prompt ?? 0) + Number(completion ?? 0)); return { promptTokens: typeof prompt === 'number' ? prompt : undefined, completionTokens: typeof completion === 'number' ? completion : undefined, totalTokens: typeof total === 'number' ? total : undefined, }; }