UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

824 lines (823 loc) • 29.8 kB
/** * OpenAI Chat Completions API format conversion layer. * * Provides a request parser (OpenAI -> NeuroLink), a response serializer * (NeuroLink -> OpenAI), a streaming SSE state machine, and an error * envelope helper. Together they allow NeuroLink to act as a * drop-in OpenAI API proxy. * * Reference: https://platform.openai.com/docs/api-reference/chat/create */ import { jsonSchema, tool } from "../utils/tool.js"; import { randomBytes } from "crypto"; import { normalizeJsonSchemaObject } from "../utils/schemaConversion.js"; import { applyClaudeRequestCacheBreakpoints } from "../utils/anthropicCacheBreakpoints.js"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Generate a unique chat completion ID in the OpenAI format. */ export function generateChatCompletionId() { return `chatcmpl-${randomBytes(12).toString("base64url").slice(0, 24)}`; } /** Generate an OpenAI-format tool call ID (`call_` + random chars). */ export function generateOpenAIToolCallId() { return `call_${randomBytes(12).toString("base64url").slice(0, 24)}`; } // --------------------------------------------------------------------------- // Request parser: OpenAI -> NeuroLink internal format // --------------------------------------------------------------------------- /** * Parse an incoming OpenAI Chat Completions request into an intermediate * representation consumable by NeuroLink's generate/stream pipeline. * * Handles: * - System prompt extraction from system messages * - Message flattening (text + image parts) * - Tool definition conversion * - tool_choice mapping * - top_p, temperature, max_tokens pass-through */ export function parseOpenAIRequest(body) { // --- system prompt --- const systemParts = []; for (const msg of body.messages) { if (msg.role === "system") { systemParts.push(msg.content); } } const systemPrompt = systemParts.length > 0 ? systemParts.join("\n\n") : undefined; // --- messages --- // Find the index of the last user message so we can distinguish the // current turn from history. Images from historical messages are kept // inline as text references; only images from the latest user message // are extracted into the top-level `images` array. const conversationMessages = []; const images = []; let lastUserPrompt = ""; let lastUserMsgIdx = -1; for (let i = body.messages.length - 1; i >= 0; i--) { if (body.messages[i].role === "user") { lastUserMsgIdx = i; break; } } // NOTE: This loop intentionally does NOT use MessageBuilder because the proxy // layer translates between OpenAI's wire format and NeuroLink's internal // representation. MessageBuilder is for SDK-side message construction from // user inputs (files, images, etc.). for (let msgIdx = 0; msgIdx < body.messages.length; msgIdx++) { const msg = body.messages[msgIdx]; const isLatestUserMsg = msgIdx === lastUserMsgIdx; if (msg.role === "system") { // System messages are already extracted above; skip them in the // conversation history to avoid duplication. continue; } if (msg.role === "user") { if (typeof msg.content === "string") { conversationMessages.push({ role: msg.role, content: msg.content }); lastUserPrompt = msg.content; } else if (Array.isArray(msg.content)) { const textParts = []; for (const part of msg.content) { if (part.type === "text") { textParts.push(part.text); } else if (part.type === "image_url") { if (isLatestUserMsg) { images.push(part.image_url.url); } else { textParts.push("[image]"); } } } const combined = textParts.join("\n"); conversationMessages.push({ role: msg.role, content: combined }); lastUserPrompt = combined; } } else if (msg.role === "assistant") { const textParts = []; if (msg.content) { textParts.push(msg.content); } if (msg.tool_calls) { for (const tc of msg.tool_calls) { textParts.push(`[tool_use:${tc.id}:${tc.function.name}] ${tc.function.arguments}`); } } const combined = textParts.join("\n"); conversationMessages.push({ role: msg.role, content: combined }); } else if (msg.role === "tool") { conversationMessages.push({ role: "user", content: `[tool_result:${msg.tool_call_id}] ${msg.content}`, }); } } // --- tools --- const tools = {}; if (body.tools) { for (const t of body.tools) { tools[t.function.name] = tool({ description: t.function.description ?? "", inputSchema: jsonSchema(normalizeJsonSchemaObject(t.function.parameters ?? { type: "object" })), }); } } // --- tool_choice --- let toolChoice; let toolChoiceName; if (body.tool_choice) { if (typeof body.tool_choice === "string") { switch (body.tool_choice) { case "auto": toolChoice = "auto"; break; case "required": toolChoice = "required"; break; case "none": toolChoice = "none"; break; } } else if (typeof body.tool_choice === "object" && body.tool_choice.type === "function") { toolChoice = "required"; toolChoiceName = body.tool_choice.function.name; } } // --- stop sequences --- let stopSequences; if (body.stop) { stopSequences = Array.isArray(body.stop) ? body.stop : [body.stop]; } // --- response format --- let responseFormat; if (body.response_format) { responseFormat = { type: body.response_format.type, jsonSchema: body.response_format.json_schema, }; } return { model: body.model, maxTokens: body.max_tokens ?? body.max_completion_tokens ?? 4096, temperature: body.temperature, topP: body.top_p, systemPrompt, stream: body.stream === true, prompt: lastUserPrompt, images, conversationMessages, tools, toolChoice, toolChoiceName, stopSequences, responseFormat, }; } // --------------------------------------------------------------------------- // Response serializer: NeuroLink result -> OpenAI response // --------------------------------------------------------------------------- /** * Map NeuroLink finish-reason strings to OpenAI finish_reason values. */ function mapFinishReason(finishReason) { switch (finishReason) { case "stop": case "end_turn": return "stop"; case "length": case "max_tokens": return "length"; case "tool-calls": case "tool_use": return "tool_calls"; case "content_filter": case "safety": return "content_filter"; default: return finishReason ? "stop" : null; } } /** * Serialize a NeuroLink GenerateResult into an OpenAI Chat Completions response. */ export function serializeOpenAIResponse(result, requestModel) { const inferredFinishReason = result.toolCalls && result.toolCalls.length > 0 && (!result.finishReason || result.finishReason === "stop") ? "tool_calls" : result.finishReason; // Build tool_calls array if present let toolCalls; if (result.toolCalls && result.toolCalls.length > 0) { toolCalls = result.toolCalls.map((tc) => ({ id: tc.toolCallId || generateOpenAIToolCallId(), type: "function", function: { name: tc.toolName, arguments: JSON.stringify(tc.args ?? {}), }, })); } // Content is null when only tool calls are present (no text content) const content = toolCalls && !result.content ? null : result.content || ""; return { id: generateChatCompletionId(), object: "chat.completion", created: Math.floor(Date.now() / 1000), model: result.model ?? requestModel, choices: [ { index: 0, message: { role: "assistant", content, ...(toolCalls ? { tool_calls: toolCalls } : {}), }, finish_reason: mapFinishReason(inferredFinishReason), }, ], usage: { prompt_tokens: result.usage?.input ?? 0, completion_tokens: result.usage?.output ?? 0, total_tokens: result.usage?.total ?? 0, }, }; } // --------------------------------------------------------------------------- // Error envelope // --------------------------------------------------------------------------- /** Map HTTP status codes to OpenAI error types. */ function errorTypeFromStatus(status) { switch (status) { case 400: return "invalid_request_error"; case 401: return "authentication_error"; case 403: return "permission_error"; case 404: return "not_found_error"; case 429: return "rate_limit_error"; default: return status >= 500 ? "server_error" : "invalid_request_error"; } } /** * Build an OpenAI-compatible error envelope. */ export function buildOpenAIError(status, message) { return { error: { message, type: errorTypeFromStatus(status), code: null, }, }; } // --------------------------------------------------------------------------- // SSE helpers // --------------------------------------------------------------------------- /** * Format a single OpenAI SSE frame. * OpenAI uses only `data:` lines (no `event:` prefix unlike Claude). */ export function formatOpenAISSE(data) { return `data: ${JSON.stringify(data)}\n\n`; } // --------------------------------------------------------------------------- // Streaming SSE state machine // --------------------------------------------------------------------------- /** * Stateful SSE serializer that emits a well-formed OpenAI streaming response. * * Tracks lifecycle state (`idle` -> `streaming` -> `done`) and the current * tool call index for multi-tool streaming. * * Usage: * ```ts * const sse = new OpenAIStreamSerializer(requestModel); * * // Start the stream * yield* sse.start(); * * // Text deltas * for await (const chunk of textStream) { * yield* sse.pushDelta(chunk); * } * * // Tool use * yield* sse.pushToolUse(toolId, toolName, toolInput); * * // Finalize * yield* sse.finish("stop", usage); * ``` */ export class OpenAIStreamSerializer { state = "idle"; id; model; started = false; toolCallIndex = -1; constructor(model) { this.id = generateChatCompletionId(); this.model = model; } /** Current lifecycle state (exposed for testing). */ getState() { return this.state; } // ----------------------------------------------------------------------- // Internal helpers // ----------------------------------------------------------------------- makeChunk(delta, finishReason = null, usage) { return { id: this.id, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model: this.model, choices: [ { index: 0, delta, finish_reason: finishReason, }, ], ...(usage ? { usage } : {}), }; } // ----------------------------------------------------------------------- // Public API // ----------------------------------------------------------------------- /** * Emit the opening frame with `role: "assistant"`. */ *start() { if (this.state !== "idle") { return; } this.started = true; this.state = "streaming"; yield formatOpenAISSE(this.makeChunk({ role: "assistant" })); } /** * Push a text content delta. */ *pushDelta(text) { if (this.state === "done" || this.state === "error") { return; } if (!this.started) { yield* this.start(); } yield formatOpenAISSE(this.makeChunk({ content: text })); } /** * Push the start of a tool call (id, name, empty arguments). */ *pushToolCallStart(id, name) { if (this.state === "done" || this.state === "error") { return; } if (!this.started) { yield* this.start(); } this.toolCallIndex += 1; yield formatOpenAISSE(this.makeChunk({ tool_calls: [ { index: this.toolCallIndex, id, type: "function", function: { name, arguments: "" }, }, ], })); } /** * Push an arguments delta for the current tool call. */ *pushToolCallArgDelta(index, argsChunk) { if (this.state === "done" || this.state === "error") { return; } yield formatOpenAISSE(this.makeChunk({ tool_calls: [ { index, function: { arguments: argsChunk }, }, ], })); } /** * Push a complete tool use: emits tool call start followed by chunked * argument deltas (~100 chars per chunk). */ *pushToolUse(id, name, input) { if (this.state === "done" || this.state === "error") { return; } yield* this.pushToolCallStart(id, name); const jsonStr = JSON.stringify(input ?? {}); const CHUNK_SIZE = 100; const currentIndex = this.toolCallIndex; for (let i = 0; i < jsonStr.length; i += CHUNK_SIZE) { const chunk = jsonStr.slice(i, i + CHUNK_SIZE); yield* this.pushToolCallArgDelta(currentIndex, chunk); } // If the input was empty, still emit at least one delta if (jsonStr.length === 0) { yield* this.pushToolCallArgDelta(currentIndex, "{}"); } } /** * Finalize the stream: emit finish_reason chunk, then `data: [DONE]`. */ *finish(finishReason, usage) { if (this.state === "idle") { yield* this.start(); } if (this.state === "done" || this.state === "error") { return; } const mappedReason = mapFinishReason(finishReason) ?? "stop"; const usagePayload = usage ? { prompt_tokens: usage.input, completion_tokens: usage.output, total_tokens: usage.total, } : undefined; yield formatOpenAISSE(this.makeChunk({}, mappedReason, usagePayload)); yield "data: [DONE]\n\n"; this.state = "done"; } /** * Emit an error event. Transitions to terminal ERROR state. */ *emitError(message) { this.state = "error"; yield formatOpenAISSE({ error: { message, type: "server_error" }, }); } } // --------------------------------------------------------------------------- // OpenAI <-> Claude (Anthropic) format bridge // --------------------------------------------------------------------------- /** * Convert an OpenAI Chat Completions request to a Claude Messages API request. * * Used by the OpenAI proxy endpoint to internally loopback requests targeting * Claude models through the proxy's native /v1/messages passthrough path, * so they benefit from OAuth account rotation, retry, SSE interception, etc. */ export function convertOpenAIToClaudeRequest(openai) { // --- system messages --- const systemMessages = openai.messages.filter((m) => m.role === "system"); const system = systemMessages.length > 0 ? systemMessages.map((m) => ({ type: "text", text: m.content })) : undefined; // --- conversation messages --- const messages = []; for (const msg of openai.messages) { if (msg.role === "system") { continue; } if (msg.role === "user") { if (typeof msg.content === "string") { messages.push({ role: "user", content: msg.content }); } else if (Array.isArray(msg.content)) { const blocks = msg.content.map((part) => { if (part.type === "text") { return { type: "text", text: part.text }; } if (part.type === "image_url") { return { type: "image", source: { type: "url", url: part.image_url.url }, }; } return { type: "text", text: "" }; }); messages.push({ role: "user", content: blocks }); } } else if (msg.role === "assistant") { const blocks = []; if (msg.content) { blocks.push({ type: "text", text: msg.content }); } if (msg.tool_calls) { for (const tc of msg.tool_calls) { let input; try { input = tc.function.arguments ? JSON.parse(tc.function.arguments) : {}; } catch { input = {}; } blocks.push({ type: "tool_use", id: tc.id, name: tc.function.name, input, }); } } messages.push({ role: "assistant", content: blocks.length === 1 && blocks[0].type === "text" ? blocks[0].text : blocks, }); } else if (msg.role === "tool") { messages.push({ role: "user", content: [ { type: "tool_result", tool_use_id: msg.tool_call_id, content: msg.content, }, ], }); } } // --- tools --- const tools = openai.tools?.map((t) => ({ name: t.function.name, description: t.function.description || "", input_schema: t.function.parameters, })); // --- tool_choice --- let tool_choice; if (openai.tool_choice === "auto") { tool_choice = { type: "auto" }; } else if (openai.tool_choice === "required") { tool_choice = { type: "any" }; } else if (openai.tool_choice === "none") { tool_choice = { type: "none" }; } else if (typeof openai.tool_choice === "object" && openai.tool_choice.type === "function") { tool_choice = { type: "tool", name: openai.tool_choice.function.name }; } const result = { model: openai.model, messages, max_tokens: openai.max_tokens ?? openai.max_completion_tokens ?? 4096, stream: openai.stream ?? false, }; if (system) { result.system = system; } if (openai.temperature !== undefined) { result.temperature = openai.temperature; } if (openai.top_p !== undefined) { result.top_p = openai.top_p; } if (tools && tools.length > 0) { result.tools = tools; } if (tool_choice) { result.tool_choice = tool_choice; } if (openai.stop) { result.stop_sequences = Array.isArray(openai.stop) ? openai.stop : [openai.stop]; } // An OpenAI-shaped caller has no way to ask for caching: its provider does it // automatically from the first token, so the format carries no equivalent of // `cache_control`. Translating faithfully therefore produces a request // Anthropic caches at 0% on every turn — and a caller that arrives here after // its own pool ran dry would burn the replacement quota faster than the one // it was spared. Synthesise the breakpoints the caller could not express. return applyClaudeRequestCacheBreakpoints(result); } /** * Convert a non-streaming Claude Messages response to an OpenAI Chat * Completions response by bridging through {@link InternalResult}. */ export function convertClaudeToOpenAIResponse(claude, requestModel) { const content = claude.content .filter((b) => b.type === "text") .map((b) => b.text) .join(""); const toolCalls = claude.content .filter((b) => b.type === "tool_use") .map((b) => { const tu = b; return { toolCallId: tu.id, toolName: tu.name, args: tu.input ?? {}, }; }); const internal = { content, model: claude.model, finishReason: claude.stop_reason ?? "end_turn", usage: { input: claude.usage.input_tokens, output: claude.usage.output_tokens, total: claude.usage.input_tokens + claude.usage.output_tokens, }, toolCalls: toolCalls.length > 0 ? toolCalls : undefined, }; return serializeOpenAIResponse(internal, requestModel); } /** * Create a TransformStream that parses Claude Messages API SSE events from * the upstream response and re-emits them as OpenAI Chat Completions SSE * frames. * * Handles the canonical Claude SSE event types: * - message_start -> emits the opening `role: "assistant"` chunk * - content_block_start -> text block: no-op; tool_use block: emit tool call start * - content_block_delta -> text_delta: emit content delta; * input_json_delta: emit tool call argument delta * - content_block_stop -> no-op * - message_delta -> captures stop_reason and output token usage * - message_stop -> emits the final `finish_reason` chunk + `[DONE]` */ export function createClaudeToOpenAIStreamTransform(requestModel, options = {}) { const serializer = new OpenAIStreamSerializer(requestModel); const encoder = new TextEncoder(); const decoder = new TextDecoder(); let buffer = ""; // Track per-content-block state so we can map Claude's indexed block // stream to OpenAI's flat delta stream. const blockState = new Map(); let stopReason; let usage; let inputTokens = 0; let nextToolCallIndex = 0; let finished = false; const emit = (controller, gen) => { for (const frame of gen) { controller.enqueue(encoder.encode(frame)); } }; const handleEvent = (eventName, dataStr, controller) => { let data; try { data = JSON.parse(dataStr); } catch { return; } if (finished) { return; } switch (eventName) { case "message_start": { const message = (data.message ?? {}); if (message.usage?.input_tokens !== undefined) { inputTokens = message.usage.input_tokens; } emit(controller, serializer.start()); return; } case "content_block_start": { const index = typeof data.index === "number" ? data.index : 0; const block = (data.content_block ?? {}); if (block.type === "tool_use") { const toolCallIndex = nextToolCallIndex++; blockState.set(index, { kind: "tool_use", toolCallIndex }); emit(controller, serializer.pushToolCallStart(block.id ?? "", block.name ?? "")); } else if (block.type === "text") { blockState.set(index, { kind: "text" }); } else if (block.type === "thinking") { blockState.set(index, { kind: "thinking" }); } else { blockState.set(index, { kind: "other" }); } return; } case "content_block_delta": { const index = typeof data.index === "number" ? data.index : 0; const delta = (data.delta ?? {}); const state = blockState.get(index); if (!state) { return; } if (delta.type === "text_delta" && state.kind === "text") { emit(controller, serializer.pushDelta(delta.text ?? "")); } else if (delta.type === "input_json_delta" && state.kind === "tool_use" && state.toolCallIndex !== undefined) { emit(controller, serializer.pushToolCallArgDelta(state.toolCallIndex, delta.partial_json ?? "")); } // thinking_delta is intentionally dropped — OpenAI has no equivalent. return; } case "content_block_stop": { // No-op: OpenAI stream has no per-block close event. return; } case "message_delta": { const delta = (data.delta ?? {}); if (delta.stop_reason) { stopReason = delta.stop_reason; } const u = (data.usage ?? {}); if (u.output_tokens !== undefined) { usage = { input: inputTokens, output: u.output_tokens, total: inputTokens + u.output_tokens, }; } return; } case "message_stop": { if (!finished) { finished = true; emit(controller, serializer.finish(stopReason, usage)); } return; } case "error": { const error = (data.error ?? {}); const message = typeof error.message === "string" ? error.message : "Anthropic stream failed"; finished = true; options.onError?.(message); emit(controller, serializer.emitError(message)); controller.terminate(); return; } default: // ping and unknown events are ignored. return; } }; // Parse any complete SSE events present in `buffer`, mutating it as events // are consumed. Shared between the streaming `transform` and the terminal // `flush` (after the decoder is drained) so trailing events aren't lost. const drainBufferedEvents = (controller) => { // Claude SSE events are separated by blank lines (`\n\n`). let sepIdx = buffer.indexOf("\n\n"); while (sepIdx !== -1) { const rawEvent = buffer.slice(0, sepIdx); buffer = buffer.slice(sepIdx + 2); let eventName = ""; const dataLines = []; for (const line of rawEvent.split("\n")) { if (line.startsWith("event:")) { eventName = line.slice(6).trim(); } else if (line.startsWith("data:")) { dataLines.push(line.slice(5).trim()); } } if (eventName && dataLines.length > 0) { handleEvent(eventName, dataLines.join("\n"), controller); } sepIdx = buffer.indexOf("\n\n"); } }; return new TransformStream({ transform(chunk, controller) { buffer += decoder.decode(chunk, { stream: true }); drainBufferedEvents(controller); }, flush(controller) { // Drain any bytes still held inside the TextDecoder, then re-run the // event parser so a complete trailing event that arrived without a // closing `\n\n` is not silently lost. buffer += decoder.decode(); drainBufferedEvents(controller); // Closing without message_stop is an interrupted stream, not success. if (!finished) { finished = true; const message = "Anthropic stream ended before message_stop"; options.onError?.(message); emit(controller, serializer.emitError(message)); } }, }); }