Spaces:
Running
Running
| /** | |
| * 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 | |
| } | |
| }; | |
| } | |