@sisu-ai/adapter-openai
Version:
OpenAI‑compatible Chat adapter with tools support.
379 lines (378 loc) • 15.8 kB
JavaScript
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,
};
}