|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const logger = require('../utils/logger') |
|
|
|
|
|
class OpenAIToClaudeConverter { |
|
|
constructor() { |
|
|
|
|
|
this.stopReasonMapping = { |
|
|
end_turn: 'stop', |
|
|
max_tokens: 'length', |
|
|
stop_sequence: 'stop', |
|
|
tool_use: 'tool_calls' |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
convertRequest(openaiRequest) { |
|
|
const claudeRequest = { |
|
|
model: openaiRequest.model, |
|
|
messages: this._convertMessages(openaiRequest.messages), |
|
|
max_tokens: openaiRequest.max_tokens || 4096, |
|
|
temperature: openaiRequest.temperature, |
|
|
top_p: openaiRequest.top_p, |
|
|
stream: openaiRequest.stream || false |
|
|
} |
|
|
|
|
|
|
|
|
const claudeCodeSystemMessage = "You are Claude Code, Anthropic's official CLI for Claude." |
|
|
|
|
|
|
|
|
const systemMessage = this._extractSystemMessage(openaiRequest.messages) |
|
|
if (systemMessage && systemMessage.includes('You are currently in Xcode')) { |
|
|
|
|
|
claudeRequest.system = systemMessage |
|
|
logger.info( |
|
|
`🔍 Xcode request detected, using Xcode system prompt (${systemMessage.length} chars)` |
|
|
) |
|
|
logger.debug(`📋 System prompt preview: ${systemMessage.substring(0, 150)}...`) |
|
|
} else { |
|
|
|
|
|
claudeRequest.system = claudeCodeSystemMessage |
|
|
logger.debug( |
|
|
`📋 Using Claude Code default system prompt${systemMessage ? ' (ignored custom prompt)' : ''}` |
|
|
) |
|
|
} |
|
|
|
|
|
|
|
|
if (openaiRequest.stop) { |
|
|
claudeRequest.stop_sequences = Array.isArray(openaiRequest.stop) |
|
|
? openaiRequest.stop |
|
|
: [openaiRequest.stop] |
|
|
} |
|
|
|
|
|
|
|
|
if (openaiRequest.tools) { |
|
|
claudeRequest.tools = this._convertTools(openaiRequest.tools) |
|
|
if (openaiRequest.tool_choice) { |
|
|
claudeRequest.tool_choice = this._convertToolChoice(openaiRequest.tool_choice) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
logger.debug('📝 Converted OpenAI request to Claude format:', { |
|
|
model: claudeRequest.model, |
|
|
messageCount: claudeRequest.messages.length, |
|
|
hasSystem: !!claudeRequest.system, |
|
|
stream: claudeRequest.stream |
|
|
}) |
|
|
|
|
|
return claudeRequest |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
convertResponse(claudeResponse, requestModel) { |
|
|
const timestamp = Math.floor(Date.now() / 1000) |
|
|
|
|
|
const openaiResponse = { |
|
|
id: `chatcmpl-${this._generateId()}`, |
|
|
object: 'chat.completion', |
|
|
created: timestamp, |
|
|
model: requestModel || 'gpt-4', |
|
|
choices: [ |
|
|
{ |
|
|
index: 0, |
|
|
message: this._convertClaudeMessage(claudeResponse), |
|
|
finish_reason: this._mapStopReason(claudeResponse.stop_reason) |
|
|
} |
|
|
], |
|
|
usage: this._convertUsage(claudeResponse.usage) |
|
|
} |
|
|
|
|
|
logger.debug('📝 Converted Claude response to OpenAI format:', { |
|
|
responseId: openaiResponse.id, |
|
|
finishReason: openaiResponse.choices[0].finish_reason, |
|
|
usage: openaiResponse.usage |
|
|
}) |
|
|
|
|
|
return openaiResponse |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
convertStreamChunk(chunk, requestModel, sessionId) { |
|
|
if (!chunk || chunk.trim() === '') { |
|
|
return '' |
|
|
} |
|
|
|
|
|
|
|
|
const lines = chunk.split('\n') |
|
|
const convertedChunks = [] |
|
|
let hasMessageStop = false |
|
|
|
|
|
for (const line of lines) { |
|
|
if (line.startsWith('data: ')) { |
|
|
const data = line.substring(6) |
|
|
if (data === '[DONE]') { |
|
|
convertedChunks.push('data: [DONE]\n\n') |
|
|
continue |
|
|
} |
|
|
|
|
|
try { |
|
|
const claudeEvent = JSON.parse(data) |
|
|
|
|
|
|
|
|
if (claudeEvent.type === 'message_stop') { |
|
|
hasMessageStop = true |
|
|
} |
|
|
|
|
|
const openaiChunk = this._convertStreamEvent(claudeEvent, requestModel, sessionId) |
|
|
if (openaiChunk) { |
|
|
convertedChunks.push(`data: ${JSON.stringify(openaiChunk)}\n\n`) |
|
|
} |
|
|
} catch (e) { |
|
|
|
|
|
continue |
|
|
} |
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
if (hasMessageStop) { |
|
|
convertedChunks.push('data: [DONE]\n\n') |
|
|
} |
|
|
|
|
|
return convertedChunks.join('') |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_extractSystemMessage(messages) { |
|
|
const systemMessages = messages.filter((msg) => msg.role === 'system') |
|
|
if (systemMessages.length === 0) { |
|
|
return null |
|
|
} |
|
|
|
|
|
|
|
|
return systemMessages.map((msg) => msg.content).join('\n\n') |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_convertMessages(messages) { |
|
|
const claudeMessages = [] |
|
|
|
|
|
for (const msg of messages) { |
|
|
|
|
|
if (msg.role === 'system') { |
|
|
continue |
|
|
} |
|
|
|
|
|
|
|
|
const role = msg.role === 'user' ? 'user' : 'assistant' |
|
|
|
|
|
|
|
|
const { content: rawContent } = msg |
|
|
let content |
|
|
|
|
|
if (typeof rawContent === 'string') { |
|
|
content = rawContent |
|
|
} else if (Array.isArray(rawContent)) { |
|
|
|
|
|
content = this._convertMultimodalContent(rawContent) |
|
|
} else { |
|
|
content = JSON.stringify(rawContent) |
|
|
} |
|
|
|
|
|
const claudeMsg = { |
|
|
role, |
|
|
content |
|
|
} |
|
|
|
|
|
|
|
|
if (msg.tool_calls) { |
|
|
claudeMsg.content = this._convertToolCalls(msg.tool_calls) |
|
|
} |
|
|
|
|
|
|
|
|
if (msg.role === 'tool') { |
|
|
claudeMsg.role = 'user' |
|
|
claudeMsg.content = [ |
|
|
{ |
|
|
type: 'tool_result', |
|
|
tool_use_id: msg.tool_call_id, |
|
|
content: msg.content |
|
|
} |
|
|
] |
|
|
} |
|
|
|
|
|
claudeMessages.push(claudeMsg) |
|
|
} |
|
|
|
|
|
return claudeMessages |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_convertMultimodalContent(content) { |
|
|
return content.map((item) => { |
|
|
if (item.type === 'text') { |
|
|
return { |
|
|
type: 'text', |
|
|
text: item.text |
|
|
} |
|
|
} else if (item.type === 'image_url') { |
|
|
const imageUrl = item.image_url.url |
|
|
|
|
|
|
|
|
if (imageUrl.startsWith('data:')) { |
|
|
|
|
|
const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/) |
|
|
if (matches) { |
|
|
const mediaType = matches[1] |
|
|
const base64Data = matches[2] |
|
|
|
|
|
return { |
|
|
type: 'image', |
|
|
source: { |
|
|
type: 'base64', |
|
|
media_type: mediaType, |
|
|
data: base64Data |
|
|
} |
|
|
} |
|
|
} else { |
|
|
|
|
|
logger.warn('⚠️ Invalid base64 image format, using default parsing') |
|
|
return { |
|
|
type: 'image', |
|
|
source: { |
|
|
type: 'base64', |
|
|
media_type: 'image/jpeg', |
|
|
data: imageUrl.split(',')[1] || '' |
|
|
} |
|
|
} |
|
|
} |
|
|
} else { |
|
|
|
|
|
logger.error( |
|
|
'❌ URL images are not supported by Claude API, only base64 format is accepted' |
|
|
) |
|
|
throw new Error( |
|
|
'Claude API only supports base64 encoded images, not URLs. Please convert the image to base64 format.' |
|
|
) |
|
|
} |
|
|
} |
|
|
return item |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_convertTools(tools) { |
|
|
return tools.map((tool) => { |
|
|
if (tool.type === 'function') { |
|
|
return { |
|
|
name: tool.function.name, |
|
|
description: tool.function.description, |
|
|
input_schema: tool.function.parameters |
|
|
} |
|
|
} |
|
|
return tool |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_convertToolChoice(toolChoice) { |
|
|
if (toolChoice === 'none') { |
|
|
return { type: 'none' } |
|
|
} |
|
|
if (toolChoice === 'auto') { |
|
|
return { type: 'auto' } |
|
|
} |
|
|
if (toolChoice === 'required') { |
|
|
return { type: 'any' } |
|
|
} |
|
|
if (toolChoice.type === 'function') { |
|
|
return { |
|
|
type: 'tool', |
|
|
name: toolChoice.function.name |
|
|
} |
|
|
} |
|
|
return { type: 'auto' } |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_convertToolCalls(toolCalls) { |
|
|
return toolCalls.map((tc) => ({ |
|
|
type: 'tool_use', |
|
|
id: tc.id, |
|
|
name: tc.function.name, |
|
|
input: JSON.parse(tc.function.arguments) |
|
|
})) |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_convertClaudeMessage(claudeResponse) { |
|
|
const message = { |
|
|
role: 'assistant', |
|
|
content: null |
|
|
} |
|
|
|
|
|
|
|
|
if (claudeResponse.content) { |
|
|
if (typeof claudeResponse.content === 'string') { |
|
|
message.content = claudeResponse.content |
|
|
} else if (Array.isArray(claudeResponse.content)) { |
|
|
|
|
|
const textParts = [] |
|
|
const toolCalls = [] |
|
|
|
|
|
for (const item of claudeResponse.content) { |
|
|
if (item.type === 'text') { |
|
|
textParts.push(item.text) |
|
|
} else if (item.type === 'tool_use') { |
|
|
toolCalls.push({ |
|
|
id: item.id, |
|
|
type: 'function', |
|
|
function: { |
|
|
name: item.name, |
|
|
arguments: JSON.stringify(item.input) |
|
|
} |
|
|
}) |
|
|
} |
|
|
} |
|
|
|
|
|
message.content = textParts.join('') || null |
|
|
if (toolCalls.length > 0) { |
|
|
message.tool_calls = toolCalls |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
return message |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_mapStopReason(claudeReason) { |
|
|
return this.stopReasonMapping[claudeReason] || 'stop' |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_convertUsage(claudeUsage) { |
|
|
if (!claudeUsage) { |
|
|
return undefined |
|
|
} |
|
|
|
|
|
return { |
|
|
prompt_tokens: claudeUsage.input_tokens || 0, |
|
|
completion_tokens: claudeUsage.output_tokens || 0, |
|
|
total_tokens: (claudeUsage.input_tokens || 0) + (claudeUsage.output_tokens || 0) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_convertStreamEvent(event, requestModel, sessionId) { |
|
|
const timestamp = Math.floor(Date.now() / 1000) |
|
|
const baseChunk = { |
|
|
id: sessionId, |
|
|
object: 'chat.completion.chunk', |
|
|
created: timestamp, |
|
|
model: requestModel || 'gpt-4', |
|
|
choices: [ |
|
|
{ |
|
|
index: 0, |
|
|
delta: {}, |
|
|
finish_reason: null |
|
|
} |
|
|
] |
|
|
} |
|
|
|
|
|
|
|
|
if (event.type === 'message_start') { |
|
|
|
|
|
baseChunk.choices[0].delta.role = 'assistant' |
|
|
return baseChunk |
|
|
} else if (event.type === 'content_block_start' && event.content_block) { |
|
|
if (event.content_block.type === 'text') { |
|
|
baseChunk.choices[0].delta.content = event.content_block.text || '' |
|
|
} else if (event.content_block.type === 'tool_use') { |
|
|
|
|
|
baseChunk.choices[0].delta.tool_calls = [ |
|
|
{ |
|
|
index: event.index || 0, |
|
|
id: event.content_block.id, |
|
|
type: 'function', |
|
|
function: { |
|
|
name: event.content_block.name, |
|
|
arguments: '' |
|
|
} |
|
|
} |
|
|
] |
|
|
} |
|
|
} else if (event.type === 'content_block_delta' && event.delta) { |
|
|
if (event.delta.type === 'text_delta') { |
|
|
baseChunk.choices[0].delta.content = event.delta.text || '' |
|
|
} else if (event.delta.type === 'input_json_delta') { |
|
|
|
|
|
baseChunk.choices[0].delta.tool_calls = [ |
|
|
{ |
|
|
index: event.index || 0, |
|
|
function: { |
|
|
arguments: event.delta.partial_json || '' |
|
|
} |
|
|
} |
|
|
] |
|
|
} |
|
|
} else if (event.type === 'message_delta' && event.delta) { |
|
|
if (event.delta.stop_reason) { |
|
|
baseChunk.choices[0].finish_reason = this._mapStopReason(event.delta.stop_reason) |
|
|
} |
|
|
if (event.usage) { |
|
|
baseChunk.usage = this._convertUsage(event.usage) |
|
|
} |
|
|
} else if (event.type === 'message_stop') { |
|
|
|
|
|
return null |
|
|
} else { |
|
|
|
|
|
return null |
|
|
} |
|
|
|
|
|
return baseChunk |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_generateId() { |
|
|
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) |
|
|
} |
|
|
} |
|
|
|
|
|
module.exports = new OpenAIToClaudeConverter() |
|
|
|