gravityyy-proxyyy / src /format /openai-compat.js
proxy-edit
feat: surface Gemini search grounding as OpenAI url_citation annotations
d059023
Raw
History Blame Contribute Delete
16.4 kB
/**
* 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
}
};
}