| |
| |
| |
| |
|
|
| 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() |
|
|