/** * OpenAI Chat Completions compatibility helpers. * * This module only translates API shapes. It does not call Cloud Code directly. */ import crypto from 'crypto'; import { resolveModelAlias } from './model-aliases.js'; export class OpenAICompatError extends Error { constructor(message, { statusCode = 400, type = 'invalid_request_error', code = 'invalid_request', param = null } = {}) { super(message); this.name = 'OpenAICompatError'; this.statusCode = statusCode; this.type = type; this.code = code; this.param = param; } } export function createOpenAIError(message, { type = 'api_error', code = null, param = null } = {}) { return { error: { message, type, param, code } }; } export function classifyOpenAIError(error) { if (error instanceof OpenAICompatError) { return { statusCode: error.statusCode, body: createOpenAIError(error.message, { type: error.type, code: error.code, param: error.param }) }; } const message = String(error?.message || 'Internal server error'); const upper = message.toUpperCase(); if (upper.includes('401') || upper.includes('UNAUTHENTICATED') || upper.includes('AUTH_INVALID')) { return { statusCode: 401, body: createOpenAIError('Authentication failed.', { type: 'authentication_error', code: 'invalid_api_key' }) }; } if (upper.includes('429') || upper.includes('RESOURCE_EXHAUSTED') || upper.includes('RATE LIMIT')) { return { statusCode: 429, body: createOpenAIError(message, { type: 'rate_limit_error', code: 'rate_limit_exceeded' }) }; } if (upper.includes('INVALID MODEL') || upper.includes('INVALID_REQUEST_ERROR') || upper.includes('INVALID_ARGUMENT')) { return { statusCode: 400, body: createOpenAIError(message.replace(/^invalid_request_error:\s*/i, ''), { type: 'invalid_request_error', code: 'invalid_request' }) }; } if (upper.includes('PERMISSION_DENIED') || upper.includes('ACCOUNT_FORBIDDEN')) { return { statusCode: 403, body: createOpenAIError(message, { type: 'permission_error', code: 'permission_denied' }) }; } if (upper.includes('TIMEOUT')) { return { statusCode: 504, body: createOpenAIError(message, { type: 'api_error', code: 'timeout' }) }; } if (upper.includes('NO ACCOUNTS AVAILABLE') || upper.includes('ALL ENDPOINTS FAILED')) { return { statusCode: 503, body: createOpenAIError(message, { type: 'api_error', code: 'service_unavailable' }) }; } return { statusCode: 500, body: createOpenAIError(message, { type: 'api_error', code: 'internal_error' }) }; } function normalizeTextContent(content) { if (content === null || content === undefined) return ''; if (typeof content === 'string') return content; if (!Array.isArray(content)) { throw new OpenAICompatError('message.content must be a string, null, or an array', { param: 'messages' }); } return content .filter(part => part && (part.type === 'text' || part.type === 'input_text')) .map(part => part.text || '') .join('\n'); } function convertOpenAIContentParts(content) { if (typeof content === 'string') return content; if (content === null || content === undefined) return ''; if (!Array.isArray(content)) { throw new OpenAICompatError('message.content must be a string, null, or an array', { param: 'messages' }); } const blocks = []; for (const part of content) { if (!part) continue; if (part.type === 'text' || part.type === 'input_text') { if (part.text) blocks.push({ type: 'text', text: part.text }); continue; } if (part.type === 'image_url') { const url = typeof part.image_url === 'string' ? part.image_url : part.image_url?.url; if (!url) { throw new OpenAICompatError('image_url content requires image_url.url', { param: 'messages' }); } const dataUrlMatch = url.match(/^data:([^;,]+);base64,(.+)$/s); if (dataUrlMatch) { blocks.push({ type: 'image', source: { type: 'base64', media_type: dataUrlMatch[1], data: dataUrlMatch[2] } }); } else { blocks.push({ type: 'image', source: { type: 'url', media_type: 'image/jpeg', url } }); } continue; } throw new OpenAICompatError(`Unsupported message content part type: ${part.type || 'unknown'}`, { param: 'messages' }); } return blocks; } function parseToolArguments(rawArguments, param) { if (rawArguments === undefined || rawArguments === null || rawArguments === '') return {}; if (typeof rawArguments === 'object') return rawArguments; if (typeof rawArguments !== 'string') { throw new OpenAICompatError('tool call arguments must be a JSON string or object', { param }); } try { return JSON.parse(rawArguments); } catch { throw new OpenAICompatError('tool call arguments must contain valid JSON', { param }); } } function appendMessage(messages, role, content) { const previous = messages[messages.length - 1]; if (previous && previous.role === role && Array.isArray(previous.content) && Array.isArray(content)) { previous.content.push(...content); return; } messages.push({ role, content }); } function convertMessages(openAIMessages) { if (!Array.isArray(openAIMessages) || openAIMessages.length === 0) { throw new OpenAICompatError('messages is required and must be a non-empty array', { param: 'messages' }); } const systemParts = []; const messages = []; const toolNamesById = new Map(); for (let index = 0; index < openAIMessages.length; index++) { const message = openAIMessages[index]; if (!message || typeof message !== 'object') { throw new OpenAICompatError(`messages[${index}] must be an object`, { param: `messages[${index}]` }); } const role = message.role; if (role === 'system' || role === 'developer') { const text = normalizeTextContent(message.content); if (text) systemParts.push(text); continue; } if (role === 'user') { appendMessage(messages, 'user', convertOpenAIContentParts(message.content)); continue; } if (role === 'assistant') { const blocks = []; const convertedContent = convertOpenAIContentParts(message.content); if (typeof convertedContent === 'string') { if (convertedContent) blocks.push({ type: 'text', text: convertedContent }); } else { blocks.push(...convertedContent); } if (Array.isArray(message.tool_calls)) { for (let toolIndex = 0; toolIndex < message.tool_calls.length; toolIndex++) { const toolCall = message.tool_calls[toolIndex]; if (toolCall?.type !== 'function' || !toolCall.function?.name) { throw new OpenAICompatError('Only function tool_calls are supported', { param: `messages[${index}].tool_calls[${toolIndex}]` }); } const id = toolCall.id || `call_${crypto.randomUUID().replace(/-/g, '')}`; toolNamesById.set(id, toolCall.function.name); blocks.push({ type: 'tool_use', id, name: toolCall.function.name, input: parseToolArguments( toolCall.function.arguments, `messages[${index}].tool_calls[${toolIndex}].function.arguments` ) }); } } appendMessage(messages, 'assistant', blocks.length > 0 ? blocks : ''); continue; } if (role === 'tool') { if (!message.tool_call_id) { throw new OpenAICompatError('tool messages require tool_call_id', { param: `messages[${index}].tool_call_id` }); } const content = normalizeTextContent(message.content); appendMessage(messages, 'user', [{ type: 'tool_result', tool_use_id: message.tool_call_id, name: message.name || toolNamesById.get(message.tool_call_id), content }]); continue; } throw new OpenAICompatError(`Unsupported message role: ${role}`, { param: `messages[${index}].role` }); } if (messages.length === 0) { throw new OpenAICompatError('At least one non-system message is required', { param: 'messages' }); } return { system: systemParts.length > 0 ? systemParts.join('\n\n') : undefined, messages }; } function convertTools(tools) { if (tools === undefined) return undefined; if (!Array.isArray(tools)) { throw new OpenAICompatError('tools must be an array', { param: 'tools' }); } return tools.map((tool, index) => { if (['web_search', 'web_search_preview', 'google_search'].includes(tool?.type)) { return { googleSearch: {} }; } if (tool?.type !== 'function' || !tool.function?.name) { throw new OpenAICompatError('Only function and web search tools are supported', { param: `tools[${index}]` }); } return { name: tool.function.name, description: tool.function.description || '', input_schema: tool.function.parameters || { type: 'object', properties: {} } }; }); } function convertToolChoice(toolChoice) { if (toolChoice === undefined || toolChoice === null || toolChoice === 'auto') { return undefined; } if (toolChoice === 'none') return { type: 'none' }; if (toolChoice === 'required') return { type: 'any' }; if (typeof toolChoice === 'object' && toolChoice.type === 'function' && toolChoice.function?.name) { return { type: 'tool', name: toolChoice.function.name }; } throw new OpenAICompatError('Unsupported tool_choice value', { param: 'tool_choice' }); } function normalizeStop(stop) { if (stop === undefined || stop === null) return undefined; if (typeof stop === 'string') return [stop]; if (Array.isArray(stop) && stop.every(item => typeof item === 'string')) return stop; throw new OpenAICompatError('stop must be a string or an array of strings', { param: 'stop' }); } export function convertOpenAIRequestToAnthropic(openAIRequest, configuredMappings = {}) { if (!openAIRequest || typeof openAIRequest !== 'object' || Array.isArray(openAIRequest)) { throw new OpenAICompatError('Request body must be a JSON object'); } if (!openAIRequest.model || typeof openAIRequest.model !== 'string') { throw new OpenAICompatError('model is required and must be a string', { param: 'model' }); } if (openAIRequest.n !== undefined && openAIRequest.n !== 1) { throw new OpenAICompatError('Only n=1 is supported', { param: 'n' }); } if (openAIRequest.logprobs === true) { throw new OpenAICompatError('logprobs is not supported', { param: 'logprobs' }); } if (openAIRequest.response_format && openAIRequest.response_format.type !== 'text') { throw new OpenAICompatError('response_format is not supported in this migration', { param: 'response_format' }); } const convertedMessages = convertMessages(openAIRequest.messages); const model = resolveModelAlias(openAIRequest.model, configuredMappings); const maxTokens = openAIRequest.max_completion_tokens ?? openAIRequest.max_tokens ?? 32768; if (!Number.isInteger(maxTokens) || maxTokens <= 0) { throw new OpenAICompatError('max_tokens must be a positive integer', { param: openAIRequest.max_completion_tokens !== undefined ? 'max_completion_tokens' : 'max_tokens' }); } return { model, messages: convertedMessages.messages, system: convertedMessages.system, max_tokens: maxTokens, stream: openAIRequest.stream === true, tools: convertTools(openAIRequest.tools), tool_choice: convertToolChoice(openAIRequest.tool_choice), temperature: openAIRequest.temperature, top_p: openAIRequest.top_p, top_k: openAIRequest.top_k, stop_sequences: normalizeStop(openAIRequest.stop), reasoning_effort: openAIRequest.reasoning_effort ?? 'auto' }; } function mapFinishReason(stopReason) { if (stopReason === 'max_tokens') return 'length'; if (stopReason === 'tool_use') return 'tool_calls'; return 'stop'; } export function convertAnthropicResponseToOpenAI(anthropicResponse, requestedModel) { const text = []; const reasoning = []; const toolCalls = []; for (const block of anthropicResponse?.content || []) { if (block?.type === 'text') { text.push(block.text || ''); } else if (block?.type === 'thinking') { reasoning.push(block.thinking || ''); } else if (block?.type === 'tool_use') { toolCalls.push({ id: block.id, type: 'function', function: { name: block.name, arguments: JSON.stringify(block.input || {}) } }); } } const message = { role: 'assistant', content: text.length > 0 ? text.join('') : null }; if (reasoning.length > 0) message.reasoning_content = reasoning.join(''); if (toolCalls.length > 0) message.tool_calls = toolCalls; // Surface search grounding as OpenAI-style url_citation annotations // (plus the richer normalized object for queries/sources). if (anthropicResponse?.grounding) { if (anthropicResponse.grounding.annotations?.length > 0) { message.annotations = anthropicResponse.grounding.annotations; } message.grounding = anthropicResponse.grounding; } const inputTokens = anthropicResponse?.usage?.input_tokens || 0; const outputTokens = anthropicResponse?.usage?.output_tokens || 0; return { id: anthropicResponse?.id?.replace(/^msg_/, 'chatcmpl_') || `chatcmpl_${crypto.randomUUID().replace(/-/g, '')}`, object: 'chat.completion', created: Math.floor(Date.now() / 1000), model: requestedModel, choices: [{ index: 0, message, finish_reason: mapFinishReason(anthropicResponse?.stop_reason), logprobs: null }], usage: { prompt_tokens: inputTokens, completion_tokens: outputTokens, total_tokens: inputTokens + outputTokens } }; }