196 lines
9.7 KiB
JavaScript
196 lines
9.7 KiB
JavaScript
const SUPPORTED_IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
|
|
|
|
function anthropicError(message, type = 'invalid_request_error') {
|
|
const error = new Error(message);
|
|
error.status = 400;
|
|
error.body = { type: 'error', error: { type, message } };
|
|
return error;
|
|
}
|
|
|
|
function textBlock(block) {
|
|
if (typeof block === 'string') return block;
|
|
if (block?.type === 'text' && typeof block.text === 'string') return block.text;
|
|
throw anthropicError(`Unsupported content block type: ${block?.type || 'unknown'}`);
|
|
}
|
|
|
|
function imageBlock(block) {
|
|
const source = block?.source;
|
|
if (block?.type !== 'image' || source?.type !== 'base64' || !SUPPORTED_IMAGE_TYPES.has(source.media_type)) {
|
|
throw anthropicError('Only base64 JPEG, PNG, GIF, and WebP image blocks are supported');
|
|
}
|
|
return { type: 'image_url', image_url: { url: `data:${source.media_type};base64,${source.data}` } };
|
|
}
|
|
|
|
function toOpenAIContent(content) {
|
|
if (typeof content === 'string') return content;
|
|
if (!Array.isArray(content)) throw anthropicError('Message content must be a string or array of content blocks');
|
|
return content.map((block) => {
|
|
if (block?.type === 'text') return { type: 'text', text: textBlock(block) };
|
|
if (block?.type === 'image') return imageBlock(block);
|
|
throw anthropicError(`Unsupported content block type: ${block?.type || 'unknown'}`);
|
|
});
|
|
}
|
|
|
|
function toOpenAITool(tool) {
|
|
if (!tool || tool.type === 'custom' || !tool.name || !tool.input_schema) {
|
|
throw anthropicError('Tools must include a name and input_schema');
|
|
}
|
|
return {
|
|
type: 'function',
|
|
function: { name: tool.name, description: tool.description, parameters: tool.input_schema },
|
|
};
|
|
}
|
|
|
|
export function translateAnthropicRequest(body = {}) {
|
|
if (!body.model) throw anthropicError('model is required');
|
|
if (!Number.isFinite(body.max_tokens)) throw anthropicError('max_tokens is required');
|
|
if (!Array.isArray(body.messages)) throw anthropicError('messages must be an array');
|
|
|
|
const messages = [];
|
|
if (body.system !== undefined) {
|
|
messages.push({ role: 'system', content: toOpenAIContent(body.system) });
|
|
}
|
|
|
|
for (const message of body.messages) {
|
|
if (message?.role === 'system' || message?.role === 'developer') {
|
|
messages.push({ role: 'system', content: toOpenAIContent(message.content) });
|
|
continue;
|
|
}
|
|
if (message?.role === 'tool') {
|
|
messages.push({ role: 'tool', tool_call_id: message.tool_call_id || message.tool_use_id, content: toOpenAIContent(message.content || '') });
|
|
continue;
|
|
}
|
|
if (!['user', 'assistant'].includes(message?.role)) throw anthropicError(`Unsupported message role: ${message?.role || 'missing'}`);
|
|
if (!Array.isArray(message.content)) {
|
|
messages.push({ role: message.role, content: toOpenAIContent(message.content) });
|
|
continue;
|
|
}
|
|
const ordinary = message.content.filter((block) => block?.type !== 'tool_result' && block?.type !== 'tool_use' && block?.type !== 'thinking');
|
|
const thinking = message.content.filter((block) => block?.type === 'thinking');
|
|
if (thinking.length && message.role !== 'assistant') throw anthropicError('thinking blocks are only valid in assistant messages');
|
|
if (ordinary.length || thinking.length) {
|
|
const converted = { role: message.role, content: ordinary.length ? toOpenAIContent(ordinary) : null };
|
|
if (thinking.length) {
|
|
if (thinking.some((block) => typeof block.thinking !== 'string')) throw anthropicError('thinking blocks must include thinking text');
|
|
converted.reasoning_content = thinking.map((block) => block.thinking).join('');
|
|
}
|
|
messages.push(converted);
|
|
}
|
|
for (const block of message.content) {
|
|
if (block.type === 'tool_result') {
|
|
if (!block.tool_use_id) throw anthropicError('tool_result must include tool_use_id');
|
|
messages.push({ role: 'tool', tool_call_id: block.tool_use_id, content: toOpenAIContent(block.content || '') });
|
|
} else if (block.type === 'tool_use') {
|
|
if (!block.id || !block.name) throw anthropicError('tool_use must include id and name');
|
|
const assistant = messages.at(-1)?.role === 'assistant' ? messages.at(-1) : null;
|
|
if (assistant) {
|
|
assistant.tool_calls = assistant.tool_calls || [];
|
|
assistant.tool_calls.push({ id: block.id, type: 'function', function: { name: block.name, arguments: JSON.stringify(block.input ?? {}) } });
|
|
} else {
|
|
messages.push({ role: 'assistant', content: null, tool_calls: [{ id: block.id, type: 'function', function: { name: block.name, arguments: JSON.stringify(block.input ?? {}) } }] });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const result = {
|
|
model: body.model,
|
|
messages,
|
|
max_tokens: body.max_tokens,
|
|
};
|
|
for (const field of ['temperature', 'top_p']) if (body[field] !== undefined) result[field] = body[field];
|
|
if (body.stop_sequences !== undefined) result.stop = body.stop_sequences;
|
|
if (body.stream !== undefined) result.stream = body.stream;
|
|
if (body.tools !== undefined) result.tools = body.tools.map(toOpenAITool);
|
|
if (body.tool_choice !== undefined) {
|
|
if (body.tool_choice.type === 'auto') result.tool_choice = 'auto';
|
|
else if (body.tool_choice.type === 'any') result.tool_choice = 'required';
|
|
else if (body.tool_choice.type === 'tool' && body.tool_choice.name) result.tool_choice = { type: 'function', function: { name: body.tool_choice.name } };
|
|
else throw anthropicError('Unsupported tool_choice');
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function usageOf(usage = {}) {
|
|
return { input_tokens: usage.prompt_tokens ?? 0, output_tokens: usage.completion_tokens ?? 0 };
|
|
}
|
|
|
|
export function translateAnthropicResponse(payload, requestedModel) {
|
|
const choice = payload.choices?.[0] || {};
|
|
const message = choice.message || {};
|
|
const content = [];
|
|
if (message.reasoning_content) content.push({ type: 'thinking', thinking: message.reasoning_content });
|
|
if (message.content) content.push({ type: 'text', text: message.content });
|
|
for (const call of message.tool_calls || []) {
|
|
let input = {};
|
|
try { input = JSON.parse(call.function?.arguments || '{}'); } catch { input = {}; }
|
|
content.push({ type: 'tool_use', id: call.id, name: call.function?.name, input });
|
|
}
|
|
const finish = choice.finish_reason;
|
|
return {
|
|
id: payload.id || `msg_${Date.now()}`,
|
|
type: 'message', role: 'assistant', model: payload.model || requestedModel,
|
|
content, stop_reason: finish === 'tool_calls' ? 'tool_use' : finish === 'length' ? 'max_tokens' : finish === 'stop' ? 'end_turn' : null,
|
|
stop_sequence: null, usage: usageOf(payload.usage),
|
|
};
|
|
}
|
|
|
|
export function translateAnthropicModels(payload) {
|
|
return {
|
|
object: 'list',
|
|
data: (payload.data || []).map((model) => ({
|
|
id: model.id, display_name: model.name || model.id, created_at: new Date((model.created || 0) * 1000 || Date.now()).toISOString(), type: 'model',
|
|
context_length: model.context_length, context_window: model.context_window, max_context_tokens: model.max_context_tokens,
|
|
})),
|
|
has_more: false, first_id: payload.data?.[0]?.id, last_id: payload.data?.at(-1)?.id,
|
|
};
|
|
}
|
|
|
|
function event(type, data) { return `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`; }
|
|
|
|
export function translateOpenAIChunk(chunk, state) {
|
|
const choice = chunk.choices?.[0];
|
|
if (!choice) return '';
|
|
const delta = choice.delta || {};
|
|
let output = '';
|
|
if (!state.started) {
|
|
state.started = true;
|
|
output += event('message_start', { type: 'message_start', message: { id: state.id, type: 'message', role: 'assistant', model: state.model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0 } } });
|
|
}
|
|
if (delta.reasoning_content) {
|
|
if (!state.reasoningStarted) {
|
|
state.reasoningStarted = true;
|
|
state.reasoningIndex = state.nextBlockIndex++;
|
|
output += event('content_block_start', { type: 'content_block_start', index: state.reasoningIndex, content_block: { type: 'thinking', thinking: '' } });
|
|
}
|
|
output += event('content_block_delta', { type: 'content_block_delta', index: state.reasoningIndex, delta: { type: 'thinking_delta', thinking: delta.reasoning_content } });
|
|
}
|
|
if (delta.content) {
|
|
if (!state.contentStarted) {
|
|
state.contentStarted = true;
|
|
state.contentIndex = state.nextBlockIndex++;
|
|
output += event('content_block_start', { type: 'content_block_start', index: state.contentIndex, content_block: { type: 'text', text: '' } });
|
|
}
|
|
output += event('content_block_delta', { type: 'content_block_delta', index: state.contentIndex, delta: { type: 'text_delta', text: delta.content } });
|
|
}
|
|
if (delta.tool_calls?.length) {
|
|
const call = delta.tool_calls[0];
|
|
if (!state.toolStarted) {
|
|
state.toolStarted = true;
|
|
state.toolIndex = state.nextBlockIndex++;
|
|
output += event('content_block_start', { type: 'content_block_start', index: state.toolIndex, content_block: { type: 'tool_use', id: call.id || 'tool_use', name: call.function?.name || '', input: {} } });
|
|
}
|
|
if (call.function?.arguments) output += event('content_block_delta', { type: 'content_block_delta', index: state.toolIndex, delta: { type: 'input_json_delta', partial_json: call.function.arguments } });
|
|
}
|
|
if (choice.finish_reason) {
|
|
for (const index of [state.reasoningIndex, state.contentIndex, state.toolIndex]) {
|
|
if (index !== undefined) output += event('content_block_stop', { type: 'content_block_stop', index });
|
|
}
|
|
output += event('message_delta', { type: 'message_delta', delta: { stop_reason: choice.finish_reason === 'tool_calls' ? 'tool_use' : choice.finish_reason === 'length' ? 'max_tokens' : 'end_turn', stop_sequence: null }, usage: { output_tokens: chunk.usage?.completion_tokens || 0 } });
|
|
output += event('message_stop', { type: 'message_stop' });
|
|
}
|
|
return output;
|
|
}
|
|
|
|
export { anthropicError, event };
|