UNPKG

@juspay/neurolink

Version:

Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio

610 lines 22.4 kB
/** * Claude Messages API format conversion layer. * * Provides a request parser (Claude -> NeuroLink), a response serializer * (NeuroLink -> Claude), a streaming SSE state machine, and an error * envelope helper. Together they allow NeuroLink to act as a * drop-in Claude API proxy. * * Reference: https://docs.anthropic.com/en/api/messages */ import { jsonSchema, tool } from "ai"; import { randomBytes } from "crypto"; import { normalizeJsonSchemaObject } from "../utils/schemaConversion.js"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- let _idCounter = 0; /** Generate a unique message id in the Claude format. */ export function generateMessageId() { _idCounter += 1; const rand = Math.random().toString(36).slice(2, 10); return `msg_${Date.now().toString(36)}${rand}${_idCounter}`; } /** Generate a Claude-format tool use ID (`toolu_` + 24 random chars). */ export function generateToolUseId() { return `toolu_${randomBytes(18).toString("base64url").slice(0, 24)}`; } /** * Reset the internal id counter (useful in tests). * @internal */ export function _resetIdCounter() { _idCounter = 0; } // --------------------------------------------------------------------------- // Request parser: Claude -> NeuroLink internal format // --------------------------------------------------------------------------- /** * Parse an incoming Claude Messages API request into an intermediate * representation consumable by NeuroLink's generate/stream pipeline. * * Handles: * - System prompt extraction (string or content-block array) * - Message flattening (text + image blocks) * - Tool definition conversion * - tool_choice mapping * - top_p pass-through * - thinking configuration */ export function parseClaudeRequest(body) { // --- system prompt --- let systemPrompt; if (typeof body.system === "string") { systemPrompt = body.system; } else if (Array.isArray(body.system)) { systemPrompt = body.system.map((b) => b.text).join("\n\n"); } // --- 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 in their conversation message; only images // from the latest user message are extracted into the top-level `images` // array (which feeds NeuroLink's multimodal pipeline). 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 Claude's wire format and NeuroLink's internal // representation. MessageBuilder is for SDK-side message construction from // user inputs (files, images, etc.). Claude's API content blocks (text, // image, tool_use, tool_result, thinking) are fully handled here. Document/ // PDF/CSV blocks do not exist in the Claude API format. for (let msgIdx = 0; msgIdx < body.messages.length; msgIdx++) { const msg = body.messages[msgIdx]; const isLatestUserMsg = msgIdx === lastUserMsgIdx; if (typeof msg.content === "string") { conversationMessages.push({ role: msg.role, content: msg.content }); if (msg.role === "user") { lastUserPrompt = msg.content; } } else if (Array.isArray(msg.content)) { const textParts = []; for (const block of msg.content) { if (block.type === "text") { textParts.push(block.text); } else if (block.type === "image") { if (isLatestUserMsg) { // Current turn: extract full URI to top-level images for the pipeline let imageUri = ""; if (block.source.type === "base64" && block.source.data) { const mediaType = block.source.media_type || "image/png"; imageUri = `data:${mediaType};base64,${block.source.data}`; } else if (block.source.type === "url" && block.source.url) { imageUri = block.source.url; } if (imageUri) { images.push(imageUri); } } else { // Historical turn: compact placeholder to avoid bloating the prompt textParts.push(`[image: ${block.source.type}]`); } } else if (block.type === "tool_use") { // Preserve assistant tool_use blocks so multi-turn tool // conversations retain the full call/result chain. const inputStr = block.input !== undefined ? JSON.stringify(block.input) : "{}"; textParts.push(`[tool_use:${block.id}:${block.name}] ${inputStr}`); } else if (block.type === "tool_result") { const resultContent = typeof block.content === "string" ? block.content : Array.isArray(block.content) ? block.content .map((b) => (b.type === "text" ? b.text : `[${b.type}]`)) .join("\n") : ""; textParts.push(`[tool_result:${block.tool_use_id}] ${resultContent}`); } else if (block.type) { // Preserve unknown block types (thinking, document, etc.) // so they are visible in translated history instead of silently dropped. const { type, ...rest } = block; const preview = JSON.stringify(rest); const truncated = preview.length > 200 ? preview.slice(0, 200) + "…" : preview; textParts.push(`[${type}: ${truncated}]`); } } const combined = textParts.join("\n"); conversationMessages.push({ role: msg.role, content: combined }); if (msg.role === "user") { lastUserPrompt = combined; } } } // --- tools --- const tools = {}; if (body.tools) { for (const t of body.tools) { tools[t.name] = tool({ description: t.description ?? "", // Fallback providers consume AI SDK-style tools, not Claude wire-format // tool descriptors. Wrap the raw JSON schema once here so every // downstream provider sees a canonical `inputSchema` shape. inputSchema: jsonSchema(normalizeJsonSchemaObject(t.input_schema ?? { type: "object" })), }); } } // --- tool_choice --- let toolChoice; let toolChoiceName; if (body.tool_choice) { switch (body.tool_choice.type) { case "auto": toolChoice = "auto"; break; case "any": toolChoice = "required"; break; case "tool": toolChoice = "required"; toolChoiceName = body.tool_choice.name; break; case "none": toolChoice = "none"; break; } } // --- thinking --- let thinkingConfig; if (body.thinking) { // Claude thinking types: "enabled" (fixed budget), "adaptive" (auto budget), "disabled" const isEnabled = body.thinking.type === "enabled" || body.thinking.type === "adaptive"; thinkingConfig = { enabled: isEnabled, budgetTokens: body.thinking.budget_tokens, // Pass the raw type so providers can map "adaptive" appropriately ...(body.thinking.type === "adaptive" ? { thinkingLevel: "medium" } : {}), }; } return { model: body.model, maxTokens: body.max_tokens, temperature: body.temperature, topP: body.top_p, topK: body.top_k, systemPrompt, stream: body.stream === true, prompt: lastUserPrompt, images, conversationMessages, tools, toolChoice, toolChoiceName, thinkingConfig, metadata: body.metadata, stopSequences: body.stop_sequences, }; } // --------------------------------------------------------------------------- // Response serializer: NeuroLink result -> Claude response // --------------------------------------------------------------------------- /** * Map NeuroLink finish-reason strings to Claude stop_reason values. */ function mapStopReason(finishReason) { switch (finishReason) { case "stop": case "end_turn": return "end_turn"; case "length": case "max_tokens": return "max_tokens"; case "tool-calls": case "tool_use": return "tool_use"; case "content_filter": case "safety": return "stop_sequence"; // closest match default: return finishReason ?? "end_turn"; } } /** * Serialize a NeuroLink GenerateResult into a Claude Messages API response. */ export function serializeClaudeResponse(result, requestModel) { const content = []; const inferredFinishReason = result.toolCalls && result.toolCalls.length > 0 && (!result.finishReason || result.finishReason === "stop") ? "tool_use" : result.finishReason; // Thinking/reasoning content block (if present) if (result.reasoning) { content.push({ type: "thinking", thinking: result.reasoning }); } // Text content if (result.content) { content.push({ type: "text", text: result.content }); } // Tool use blocks — normalize IDs to Claude `toolu_` format if (result.toolCalls && result.toolCalls.length > 0) { for (const tc of result.toolCalls) { const toolInput = tc.args ?? tc.parameters ?? tc.input ?? {}; content.push({ type: "tool_use", id: generateToolUseId(), name: tc.toolName, input: toolInput, }); } } // If no content at all, push an empty text block if (content.length === 0) { content.push({ type: "text", text: "" }); } return { id: generateMessageId(), type: "message", role: "assistant", content, model: result.model ?? requestModel, stop_reason: mapStopReason(inferredFinishReason), stop_sequence: null, usage: { input_tokens: result.usage?.input ?? 0, output_tokens: result.usage?.output ?? 0, ...(result.usage?.cacheCreationTokens !== undefined && { cache_creation_input_tokens: result.usage.cacheCreationTokens, }), ...(result.usage?.cacheReadTokens !== undefined && { cache_read_input_tokens: result.usage.cacheReadTokens, }), }, }; } // --------------------------------------------------------------------------- // Error envelope // --------------------------------------------------------------------------- /** Map HTTP status codes to Claude 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 413: return "request_too_large"; case 429: return "rate_limit_error"; case 500: case 502: case 503: return "api_error"; case 529: return "overloaded_error"; default: return "api_error"; } } /** * Build a Claude-compatible error envelope. */ export function buildClaudeError(status, message, errorType) { return { type: "error", error: { type: errorType ?? errorTypeFromStatus(status), message, }, }; } // --------------------------------------------------------------------------- // Streaming SSE state machine // --------------------------------------------------------------------------- /** * Format a single SSE frame (one `event:` + `data:` pair). */ export function formatSSE(eventType, data) { const json = JSON.stringify(data); return `event: ${eventType}\ndata: ${json}\n\n`; } /** * Stateful SSE serializer that emits a well-formed Claude SSE stream. * * Tracks both lifecycle state (`idle` -> `streaming` -> `done`) and the * current content block type (`text`, `thinking`, `tool_use`). Each * content block gets a unique, monotonically increasing `blockIndex`. * * Usage: * ```ts * const sse = new ClaudeStreamSerializer(requestModel, inputTokens); * * // Thinking deltas * for await (const thought of thinkingStream) { * yield* sse.pushThinkingDelta(thought); * } * * // Text deltas * for await (const chunk of textStream) { * yield* sse.pushDelta(chunk); * } * * // Tool use * yield* sse.pushToolUse(toolId, toolName, toolInput); * * // Finalize * yield* sse.finish(outputTokens, finishReason); * ``` */ export class ClaudeStreamSerializer { state = "idle"; currentBlockType = null; sawToolUseBlock = false; blockIndex = 0; hasOpenedBlock = false; outputTokens = 0; messageStarted = false; messageId; model; inputTokens; constructor(model, inputTokens = 0) { this.messageId = generateMessageId(); this.model = model; this.inputTokens = inputTokens; } /** Current lifecycle state (exposed for testing). */ getState() { return this.state; } /** Current content block type (exposed for testing). */ getCurrentBlockType() { return this.currentBlockType; } /** Current block index (exposed for testing). */ getBlockIndex() { return this.blockIndex; } /** * Emit a ping event frame. The actual periodic timer is wired in * the route handler; this method just formats the SSE frame. */ static pingEvent() { return formatSSE("ping", { type: "ping" }); } // ----------------------------------------------------------------------- // Internal helpers // ----------------------------------------------------------------------- /** * Emit `message_start` and initial ping if not already done. */ *ensureMessageStarted() { if (this.messageStarted) { return; } const messageStart = { type: "message_start", message: { id: this.messageId, type: "message", role: "assistant", content: [], model: this.model, stop_reason: null, stop_sequence: null, usage: { input_tokens: this.inputTokens, output_tokens: 0, }, }, }; yield formatSSE("message_start", messageStart); yield formatSSE("ping", { type: "ping" }); this.messageStarted = true; this.state = "streaming"; } /** * Close the current content block if one is open. */ *closeCurrentBlock() { if (this.currentBlockType === null) { return; } yield formatSSE("content_block_stop", { type: "content_block_stop", index: this.blockIndex, }); this.currentBlockType = null; } /** * Open a new content block of the given type. * Increments blockIndex for each new block. */ *openBlock(descriptor) { // Close any existing block first yield* this.closeCurrentBlock(); // Increment index for every block after the first. // Use a persistent flag instead of checking currentBlockType, // because closeCurrentBlock() (and pushToolUse which calls it) // resets currentBlockType to null before we get here. if (this.hasOpenedBlock) { this.blockIndex += 1; } this.hasOpenedBlock = true; const blockStart = { type: "content_block_start", index: this.blockIndex, content_block: descriptor, }; yield formatSSE("content_block_start", blockStart); this.currentBlockType = descriptor.type; } // ----------------------------------------------------------------------- // Public API // ----------------------------------------------------------------------- /** * Emit the opening frames: message_start and ping. * The first actual content decides which content block opens next. */ *start() { if (this.state !== "idle") { return; } yield* this.ensureMessageStarted(); } /** * Push a text delta. Returns zero or more SSE frames. * If currently in a thinking block, the thinking block is closed first. */ *pushDelta(text) { if (this.state === "done" || this.state === "error") { return; } yield* this.ensureMessageStarted(); // Transition from thinking/tool_use to text, or start first text block if (this.currentBlockType !== "text") { yield* this.openBlock({ type: "text", text: "" }); } const delta = { type: "content_block_delta", index: this.blockIndex, delta: { type: "text_delta", text }, }; yield formatSSE("content_block_delta", delta); } /** * Push a thinking delta. Returns zero or more SSE frames. * If currently in a text or tool_use block, that block is closed first * and a new thinking block is opened. */ *pushThinkingDelta(text) { if (this.state === "done" || this.state === "error") { return; } yield* this.ensureMessageStarted(); // Open a thinking block if not already in one if (this.currentBlockType !== "thinking") { yield* this.openBlock({ type: "thinking", thinking: "" }); } const delta = { type: "content_block_delta", index: this.blockIndex, delta: { type: "thinking_delta", thinking: text }, }; yield formatSSE("content_block_delta", delta); } /** * Push a complete tool use block. * * 1. Closes any open content block * 2. Emits `content_block_start` with `{type: "tool_use", id, name}` * 3. JSON-stringifies the input, chunks it into ~100 char segments * 4. Emits `content_block_delta` with `{type: "input_json_delta", partial_json}` per chunk * 5. Emits `content_block_stop` */ *pushToolUse(id, name, input) { if (this.state === "done" || this.state === "error") { return; } this.sawToolUseBlock = true; yield* this.ensureMessageStarted(); // Open a tool_use block (closes any current block) yield* this.openBlock({ type: "tool_use", id, name, input: "" }); // Serialize and chunk the input JSON const jsonStr = JSON.stringify(input ?? {}); const CHUNK_SIZE = 100; for (let i = 0; i < jsonStr.length; i += CHUNK_SIZE) { const chunk = jsonStr.slice(i, i + CHUNK_SIZE); const delta = { type: "content_block_delta", index: this.blockIndex, delta: { type: "input_json_delta", partial_json: chunk }, }; yield formatSSE("content_block_delta", delta); } // If the input was empty object, still emit at least one delta if (jsonStr.length === 0) { const delta = { type: "content_block_delta", index: this.blockIndex, delta: { type: "input_json_delta", partial_json: "{}" }, }; yield formatSSE("content_block_delta", delta); } // Close the tool_use block yield* this.closeCurrentBlock(); } /** * Finalize the stream: content_block_stop, message_delta, message_stop. */ *finish(outputTokens, finishReason) { // If we never started (empty response), start first if (this.state === "idle") { yield* this.ensureMessageStarted(); } if (this.state === "done" || this.state === "error") { return; } this.outputTokens = outputTokens ?? this.outputTokens; const resolvedFinishReason = this.sawToolUseBlock && (!finishReason || finishReason === "stop") ? "tool_use" : finishReason; // Close any open content block yield* this.closeCurrentBlock(); // message_delta const messageDelta = { type: "message_delta", delta: { stop_reason: mapStopReason(resolvedFinishReason), stop_sequence: null, }, usage: { output_tokens: this.outputTokens }, }; yield formatSSE("message_delta", messageDelta); // message_stop yield formatSSE("message_stop", { type: "message_stop" }); this.state = "done"; } /** * Emit an error event. Transitions to terminal ERROR state. */ *emitError(status, message) { this.state = "error"; const errorPayload = buildClaudeError(status, message); yield formatSSE("error", errorPayload); } } //# sourceMappingURL=claudeFormat.js.map