feat(ai-proxy): add OpenRouter provider support (#2823)

This commit is contained in:
澄潭
2025-08-28 19:26:21 +08:00
committed by GitHub
parent b2ffeff7b8
commit 44c33617fa
11 changed files with 5684 additions and 184 deletions

View File

@@ -10,10 +10,49 @@ import (
)
// ClaudeToOpenAIConverter converts Claude protocol requests to OpenAI protocol
type ClaudeToOpenAIConverter struct{}
type ClaudeToOpenAIConverter struct {
// State tracking for streaming conversion
messageStartSent bool
messageStopSent bool
messageId string
// Cache stop_reason until we get usage info
pendingStopReason *string
// Content block tracking with dynamic index allocation
nextContentIndex int
thinkingBlockIndex int
thinkingBlockStarted bool
thinkingBlockStopped bool
textBlockIndex int
textBlockStarted bool
textBlockStopped bool
toolBlockIndex int
toolBlockStarted bool
toolBlockStopped bool
// Tool call state tracking
toolCallStates map[string]*toolCallState
}
// contentConversionResult represents the result of converting Claude content to OpenAI format
type contentConversionResult struct {
textParts []string
toolCalls []toolCall
toolResults []claudeChatMessageContent
openaiContents []chatMessageContent
hasNonTextContent bool
}
// toolCallState tracks the state of a tool call during streaming
type toolCallState struct {
id string
name string
argumentsBuffer string
isComplete bool
}
// ConvertClaudeRequestToOpenAI converts a Claude chat completion request to OpenAI format
func (c *ClaudeToOpenAIConverter) ConvertClaudeRequestToOpenAI(body []byte) ([]byte, error) {
log.Debugf("[Claude->OpenAI] Original Claude request body: %s", string(body))
var claudeRequest claudeTextGenRequest
if err := json.Unmarshal(body, &claudeRequest); err != nil {
return nil, fmt.Errorf("unable to unmarshal claude request: %v", err)
@@ -31,58 +70,74 @@ func (c *ClaudeToOpenAIConverter) ConvertClaudeRequestToOpenAI(body []byte) ([]b
// Convert messages from Claude format to OpenAI format
for _, claudeMsg := range claudeRequest.Messages {
openaiMsg := chatMessage{
Role: claudeMsg.Role,
}
// Handle different content types
switch content := claudeMsg.Content.(type) {
case string:
// Handle different content types using the type-safe wrapper
if claudeMsg.Content.IsString {
// Simple text content
openaiMsg.Content = content
case []claudeChatMessageContent:
// Multi-modal content
var openaiContents []chatMessageContent
for _, claudeContent := range content {
switch claudeContent.Type {
case "text":
openaiContents = append(openaiContents, chatMessageContent{
Type: contentTypeText,
Text: claudeContent.Text,
})
case "image":
if claudeContent.Source != nil {
if claudeContent.Source.Type == "base64" {
// Convert base64 image to OpenAI format
dataUrl := fmt.Sprintf("data:%s;base64,%s", claudeContent.Source.MediaType, claudeContent.Source.Data)
openaiContents = append(openaiContents, chatMessageContent{
Type: contentTypeImageUrl,
ImageUrl: &chatMessageContentImageUrl{
Url: dataUrl,
},
})
} else if claudeContent.Source.Type == "url" {
openaiContents = append(openaiContents, chatMessageContent{
Type: contentTypeImageUrl,
ImageUrl: &chatMessageContentImageUrl{
Url: claudeContent.Source.Url,
},
})
}
openaiMsg := chatMessage{
Role: claudeMsg.Role,
Content: claudeMsg.Content.GetStringValue(),
}
openaiRequest.Messages = append(openaiRequest.Messages, openaiMsg)
} else {
// Multi-modal content - process with convertContentArray
conversionResult := c.convertContentArray(claudeMsg.Content.GetArrayValue())
// Handle tool calls if present
if len(conversionResult.toolCalls) > 0 {
// Use tool_calls format (current OpenAI standard)
openaiMsg := chatMessage{
Role: claudeMsg.Role,
ToolCalls: conversionResult.toolCalls,
}
// Add text content if present, otherwise set to null
if len(conversionResult.textParts) > 0 {
openaiMsg.Content = strings.Join(conversionResult.textParts, "\n\n")
} else {
openaiMsg.Content = nil
}
openaiRequest.Messages = append(openaiRequest.Messages, openaiMsg)
}
// Handle tool results if present
if len(conversionResult.toolResults) > 0 {
for _, toolResult := range conversionResult.toolResults {
toolMsg := chatMessage{
Role: "tool",
Content: toolResult.Content,
ToolCallId: toolResult.ToolUseId,
}
openaiRequest.Messages = append(openaiRequest.Messages, toolMsg)
}
}
openaiMsg.Content = openaiContents
}
openaiRequest.Messages = append(openaiRequest.Messages, openaiMsg)
// Handle regular content if no tool calls or tool results
if len(conversionResult.toolCalls) == 0 && len(conversionResult.toolResults) == 0 {
var content interface{}
if !conversionResult.hasNonTextContent && len(conversionResult.textParts) > 0 {
// Simple text content
content = strings.Join(conversionResult.textParts, "\n\n")
} else {
// Multi-modal content or empty content
content = conversionResult.openaiContents
}
openaiMsg := chatMessage{
Role: claudeMsg.Role,
Content: content,
}
openaiRequest.Messages = append(openaiRequest.Messages, openaiMsg)
}
}
}
// Handle system message - Claude has separate system field
if claudeRequest.System != "" {
systemStr := claudeRequest.System.String()
if systemStr != "" {
systemMsg := chatMessage{
Role: roleSystem,
Content: claudeRequest.System,
Content: systemStr,
}
// Insert system message at the beginning
openaiRequest.Messages = append([]chatMessage{systemMsg}, openaiRequest.Messages...)
@@ -119,11 +174,44 @@ func (c *ClaudeToOpenAIConverter) ConvertClaudeRequestToOpenAI(body []byte) ([]b
openaiRequest.ParallelToolCalls = !claudeRequest.ToolChoice.DisableParallelToolUse
}
return json.Marshal(openaiRequest)
// Convert thinking configuration if present
if claudeRequest.Thinking != nil {
log.Debugf("[Claude->OpenAI] Found thinking config: type=%s, budget_tokens=%d",
claudeRequest.Thinking.Type, claudeRequest.Thinking.BudgetTokens)
if claudeRequest.Thinking.Type == "enabled" {
openaiRequest.ReasoningMaxTokens = claudeRequest.Thinking.BudgetTokens
// Set ReasoningEffort based on budget_tokens
// low: <4096, medium: >=4096 and <16384, high: >=16384
if claudeRequest.Thinking.BudgetTokens < 4096 {
openaiRequest.ReasoningEffort = "low"
} else if claudeRequest.Thinking.BudgetTokens < 16384 {
openaiRequest.ReasoningEffort = "medium"
} else {
openaiRequest.ReasoningEffort = "high"
}
log.Debugf("[Claude->OpenAI] Converted thinking config: budget_tokens=%d, reasoning_effort=%s, reasoning_max_tokens=%d",
claudeRequest.Thinking.BudgetTokens, openaiRequest.ReasoningEffort, openaiRequest.ReasoningMaxTokens)
}
} else {
log.Debugf("[Claude->OpenAI] No thinking config found")
}
result, err := json.Marshal(openaiRequest)
if err != nil {
return nil, fmt.Errorf("unable to marshal openai request: %v", err)
}
log.Debugf("[Claude->OpenAI] Converted OpenAI request body: %s", string(result))
return result, nil
}
// ConvertOpenAIResponseToClaude converts an OpenAI response back to Claude format
func (c *ClaudeToOpenAIConverter) ConvertOpenAIResponseToClaude(ctx wrapper.HttpContext, body []byte) ([]byte, error) {
log.Debugf("[OpenAI->Claude] Original OpenAI response body: %s", string(body))
var openaiResponse chatCompletionResponse
if err := json.Unmarshal(body, &openaiResponse); err != nil {
return nil, fmt.Errorf("unable to unmarshal openai response: %v", err)
@@ -135,21 +223,73 @@ func (c *ClaudeToOpenAIConverter) ConvertOpenAIResponseToClaude(ctx wrapper.Http
Type: "message",
Role: "assistant",
Model: openaiResponse.Model,
Usage: claudeTextGenUsage{
}
// Only include usage if it's available
if openaiResponse.Usage != nil {
claudeResponse.Usage = claudeTextGenUsage{
InputTokens: openaiResponse.Usage.PromptTokens,
OutputTokens: openaiResponse.Usage.CompletionTokens,
},
}
}
// Convert the first choice content
if len(openaiResponse.Choices) > 0 {
choice := openaiResponse.Choices[0]
if choice.Message != nil {
content := claudeTextGenContent{
Type: "text",
Text: choice.Message.StringContent(),
var contents []claudeTextGenContent
// Add reasoning content (thinking) if present - check both reasoning and reasoning_content fields
var reasoningText string
if choice.Message.Reasoning != "" {
reasoningText = choice.Message.Reasoning
} else if choice.Message.ReasoningContent != "" {
reasoningText = choice.Message.ReasoningContent
}
claudeResponse.Content = []claudeTextGenContent{content}
if reasoningText != "" {
contents = append(contents, claudeTextGenContent{
Type: "thinking",
Signature: "", // OpenAI doesn't provide signature, use empty string
Thinking: reasoningText,
})
log.Debugf("[OpenAI->Claude] Added thinking content: %s", reasoningText)
}
// Add text content if present
if choice.Message.StringContent() != "" {
contents = append(contents, claudeTextGenContent{
Type: "text",
Text: choice.Message.StringContent(),
})
}
// Add tool calls if present
if len(choice.Message.ToolCalls) > 0 {
for _, toolCall := range choice.Message.ToolCalls {
if !toolCall.Function.IsEmpty() {
// Parse arguments from JSON string to map
var input map[string]interface{}
if toolCall.Function.Arguments != "" {
if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &input); err != nil {
log.Errorf("Failed to parse tool call arguments: %v", err)
input = map[string]interface{}{}
}
} else {
input = map[string]interface{}{}
}
contents = append(contents, claudeTextGenContent{
Type: "tool_use",
Id: toolCall.Id,
Name: toolCall.Function.Name,
Input: input,
})
}
}
}
claudeResponse.Content = contents
}
// Convert finish reason
@@ -159,11 +299,24 @@ func (c *ClaudeToOpenAIConverter) ConvertOpenAIResponseToClaude(ctx wrapper.Http
}
}
return json.Marshal(claudeResponse)
result, err := json.Marshal(claudeResponse)
if err != nil {
return nil, fmt.Errorf("unable to marshal claude response: %v", err)
}
log.Debugf("[OpenAI->Claude] Converted Claude response body: %s", string(result))
return result, nil
}
// ConvertOpenAIStreamResponseToClaude converts OpenAI streaming response to Claude format
func (c *ClaudeToOpenAIConverter) ConvertOpenAIStreamResponseToClaude(ctx wrapper.HttpContext, chunk []byte) ([]byte, error) {
log.Debugf("[OpenAI->Claude] Original OpenAI streaming chunk: %s", string(chunk))
// Initialize tool call states if needed
if c.toolCallStates == nil {
c.toolCallStates = make(map[string]*toolCallState)
}
// For streaming responses, we need to handle the Server-Sent Events format
lines := strings.Split(string(chunk), "\n")
var result strings.Builder
@@ -172,88 +325,413 @@ func (c *ClaudeToOpenAIConverter) ConvertOpenAIStreamResponseToClaude(ctx wrappe
if strings.HasPrefix(line, "data: ") {
data := strings.TrimPrefix(line, "data: ")
// Skip [DONE] messages
// Handle [DONE] messages
if data == "[DONE]" {
log.Debugf("[OpenAI->Claude] Processing [DONE] message, finalizing stream")
// Send final content_block_stop events for any active blocks
if c.thinkingBlockStarted && !c.thinkingBlockStopped {
c.thinkingBlockStopped = true
log.Debugf("[OpenAI->Claude] Sending final thinking content_block_stop event at index %d", c.thinkingBlockIndex)
stopEvent := &claudeTextGenStreamResponse{
Type: "content_block_stop",
Index: &c.thinkingBlockIndex,
}
stopData, _ := json.Marshal(stopEvent)
result.WriteString(fmt.Sprintf("data: %s\n\n", stopData))
}
if c.textBlockStarted && !c.textBlockStopped {
c.textBlockStopped = true
log.Debugf("[OpenAI->Claude] Sending final text content_block_stop event at index %d", c.textBlockIndex)
stopEvent := &claudeTextGenStreamResponse{
Type: "content_block_stop",
Index: &c.textBlockIndex,
}
stopData, _ := json.Marshal(stopEvent)
result.WriteString(fmt.Sprintf("data: %s\n\n", stopData))
}
if c.toolBlockStarted && !c.toolBlockStopped {
c.toolBlockStopped = true
log.Debugf("[OpenAI->Claude] Sending final tool content_block_stop event at index %d", c.toolBlockIndex)
stopEvent := &claudeTextGenStreamResponse{
Type: "content_block_stop",
Index: &c.toolBlockIndex,
}
stopData, _ := json.Marshal(stopEvent)
result.WriteString(fmt.Sprintf("data: %s\n\n", stopData))
}
// If we have a pending stop_reason but no usage, send message_delta with just stop_reason
if c.pendingStopReason != nil {
log.Debugf("[OpenAI->Claude] Sending final message_delta with pending stop_reason: %s", *c.pendingStopReason)
messageDelta := &claudeTextGenStreamResponse{
Type: "message_delta",
Delta: &claudeTextGenDelta{
Type: "message_delta",
StopReason: c.pendingStopReason,
},
}
stopData, _ := json.Marshal(messageDelta)
result.WriteString(fmt.Sprintf("data: %s\n\n", stopData))
c.pendingStopReason = nil
}
if c.messageStartSent && !c.messageStopSent {
c.messageStopSent = true
log.Debugf("[OpenAI->Claude] Sending final message_stop event")
messageStopEvent := &claudeTextGenStreamResponse{
Type: "message_stop",
}
stopData, _ := json.Marshal(messageStopEvent)
result.WriteString(fmt.Sprintf("data: %s\n\n", stopData))
}
// Reset all state for next request
c.messageStartSent = false
c.messageStopSent = false
c.messageId = ""
c.pendingStopReason = nil
c.nextContentIndex = 0
c.thinkingBlockIndex = -1
c.thinkingBlockStarted = false
c.thinkingBlockStopped = false
c.textBlockIndex = -1
c.textBlockStarted = false
c.textBlockStopped = false
c.toolBlockIndex = -1
c.toolBlockStarted = false
c.toolBlockStopped = false
c.toolCallStates = make(map[string]*toolCallState)
log.Debugf("[OpenAI->Claude] Reset converter state for next request")
continue
}
var openaiStreamResponse chatCompletionResponse
if err := json.Unmarshal([]byte(data), &openaiStreamResponse); err != nil {
log.Errorf("unable to unmarshal openai stream response: %v", err)
log.Debugf("unable to unmarshal openai stream response: %v, data: %s", err, data)
continue
}
// Convert to Claude streaming format
claudeStreamResponse := c.buildClaudeStreamResponse(ctx, &openaiStreamResponse)
if claudeStreamResponse != nil {
claudeStreamResponses := c.buildClaudeStreamResponse(ctx, &openaiStreamResponse)
log.Debugf("[OpenAI->Claude] Generated %d Claude stream events from OpenAI chunk", len(claudeStreamResponses))
for i, claudeStreamResponse := range claudeStreamResponses {
responseData, err := json.Marshal(claudeStreamResponse)
if err != nil {
log.Errorf("unable to marshal claude stream response: %v", err)
continue
}
log.Debugf("[OpenAI->Claude] Stream event [%d/%d]: %s", i+1, len(claudeStreamResponses), string(responseData))
result.WriteString(fmt.Sprintf("data: %s\n\n", responseData))
}
}
}
return []byte(result.String()), nil
claudeChunk := []byte(result.String())
log.Debugf("[OpenAI->Claude] Converted Claude streaming chunk: %s", string(claudeChunk))
return claudeChunk, nil
}
// buildClaudeStreamResponse builds a Claude streaming response from OpenAI streaming response
func (c *ClaudeToOpenAIConverter) buildClaudeStreamResponse(ctx wrapper.HttpContext, openaiResponse *chatCompletionResponse) *claudeTextGenStreamResponse {
// buildClaudeStreamResponse builds Claude streaming responses from OpenAI streaming response
func (c *ClaudeToOpenAIConverter) buildClaudeStreamResponse(ctx wrapper.HttpContext, openaiResponse *chatCompletionResponse) []*claudeTextGenStreamResponse {
if len(openaiResponse.Choices) == 0 {
log.Debugf("[OpenAI->Claude] No choices in OpenAI response, skipping")
return nil
}
choice := openaiResponse.Choices[0]
var responses []*claudeTextGenStreamResponse
// Determine the response type based on the content
if choice.Delta != nil && choice.Delta.Content != "" {
// Content delta
if deltaContent, ok := choice.Delta.Content.(string); ok {
return &claudeTextGenStreamResponse{
Type: "content_block_delta",
Index: choice.Index,
Delta: &claudeTextGenDelta{
Type: "text_delta",
Text: deltaContent,
},
// Log what we're processing
hasRole := choice.Delta != nil && choice.Delta.Role != ""
hasContent := choice.Delta != nil && choice.Delta.Content != ""
hasFinishReason := choice.FinishReason != nil
hasUsage := openaiResponse.Usage != nil
log.Debugf("[OpenAI->Claude] Processing OpenAI chunk - Role: %v, Content: %v, FinishReason: %v, Usage: %v",
hasRole, hasContent, hasFinishReason, hasUsage)
// Handle message start (only once)
// Note: OpenRouter may send multiple messages with role but empty content at the start
// We only send message_start for the first one
if choice.Delta != nil && choice.Delta.Role != "" && !c.messageStartSent {
c.messageId = openaiResponse.Id
c.messageStartSent = true
message := &claudeTextGenResponse{
Id: openaiResponse.Id,
Type: "message",
Role: "assistant",
Model: openaiResponse.Model,
Content: []claudeTextGenContent{},
}
// Only include usage if it's available
if openaiResponse.Usage != nil {
message.Usage = claudeTextGenUsage{
InputTokens: openaiResponse.Usage.PromptTokens,
OutputTokens: 0,
}
}
} else if choice.FinishReason != nil {
// Message completed
claudeFinishReason := openAIFinishReasonToClaude(*choice.FinishReason)
return &claudeTextGenStreamResponse{
Type: "message_delta",
Index: choice.Index,
responses = append(responses, &claudeTextGenStreamResponse{
Type: "message_start",
Message: message,
})
log.Debugf("[OpenAI->Claude] Generated message_start event for id: %s", openaiResponse.Id)
} else if choice.Delta != nil && choice.Delta.Role != "" && c.messageStartSent {
// Skip duplicate role messages from OpenRouter
log.Debugf("[OpenAI->Claude] Skipping duplicate role message for id: %s", openaiResponse.Id)
}
// Handle reasoning content (thinking) first - check both reasoning and reasoning_content fields
var reasoningText string
if choice.Delta != nil {
if choice.Delta.Reasoning != "" {
reasoningText = choice.Delta.Reasoning
} else if choice.Delta.ReasoningContent != "" {
reasoningText = choice.Delta.ReasoningContent
}
}
if reasoningText != "" {
log.Debugf("[OpenAI->Claude] Processing reasoning content delta: %s", reasoningText)
// Send content_block_start for thinking only once with dynamic index
if !c.thinkingBlockStarted {
c.thinkingBlockIndex = c.nextContentIndex
c.nextContentIndex++
c.thinkingBlockStarted = true
log.Debugf("[OpenAI->Claude] Generated content_block_start event for thinking at index %d", c.thinkingBlockIndex)
responses = append(responses, &claudeTextGenStreamResponse{
Type: "content_block_start",
Index: &c.thinkingBlockIndex,
ContentBlock: &claudeTextGenContent{
Type: "thinking",
Signature: "", // OpenAI doesn't provide signature
Thinking: "",
},
})
}
// Send content_block_delta for thinking
log.Debugf("[OpenAI->Claude] Generated content_block_delta event with thinking: %s", reasoningText)
responses = append(responses, &claudeTextGenStreamResponse{
Type: "content_block_delta",
Index: &c.thinkingBlockIndex,
Delta: &claudeTextGenDelta{
Type: "message_delta",
StopReason: &claudeFinishReason,
Type: "thinking_delta", // Use thinking_delta for reasoning content
Text: reasoningText,
},
})
}
// Handle content
if choice.Delta != nil && choice.Delta.Content != nil && choice.Delta.Content != "" {
deltaContent, ok := choice.Delta.Content.(string)
if !ok {
log.Debugf("[OpenAI->Claude] Content is not a string: %T", choice.Delta.Content)
return responses
}
log.Debugf("[OpenAI->Claude] Processing content delta: %s", deltaContent)
// Close thinking content block if it's still open
if c.thinkingBlockStarted && !c.thinkingBlockStopped {
c.thinkingBlockStopped = true
log.Debugf("[OpenAI->Claude] Closing thinking content block before text")
responses = append(responses, &claudeTextGenStreamResponse{
Type: "content_block_stop",
Index: &c.thinkingBlockIndex,
})
}
// Send content_block_start only once for text content with dynamic index
if !c.textBlockStarted {
c.textBlockIndex = c.nextContentIndex
c.nextContentIndex++
c.textBlockStarted = true
log.Debugf("[OpenAI->Claude] Generated content_block_start event for text at index %d", c.textBlockIndex)
responses = append(responses, &claudeTextGenStreamResponse{
Type: "content_block_start",
Index: &c.textBlockIndex,
ContentBlock: &claudeTextGenContent{
Type: "text",
Text: "",
},
})
}
// Send content_block_delta
log.Debugf("[OpenAI->Claude] Generated content_block_delta event with text: %s", deltaContent)
responses = append(responses, &claudeTextGenStreamResponse{
Type: "content_block_delta",
Index: &c.textBlockIndex,
Delta: &claudeTextGenDelta{
Type: "text_delta",
Text: deltaContent,
},
})
}
// Handle tool calls in streaming response
if choice.Delta != nil && len(choice.Delta.ToolCalls) > 0 {
for _, toolCall := range choice.Delta.ToolCalls {
if !toolCall.Function.IsEmpty() {
log.Debugf("[OpenAI->Claude] Processing tool call delta")
// Get or create tool call state
state := c.toolCallStates[toolCall.Id]
if state == nil {
state = &toolCallState{
id: toolCall.Id,
name: toolCall.Function.Name,
argumentsBuffer: "",
isComplete: false,
}
c.toolCallStates[toolCall.Id] = state
log.Debugf("[OpenAI->Claude] Created new tool call state for id: %s, name: %s", toolCall.Id, toolCall.Function.Name)
}
// Accumulate arguments
if toolCall.Function.Arguments != "" {
state.argumentsBuffer += toolCall.Function.Arguments
log.Debugf("[OpenAI->Claude] Accumulated tool arguments: %s", state.argumentsBuffer)
}
// Try to parse accumulated arguments as JSON to check if complete
var input map[string]interface{}
if state.argumentsBuffer != "" {
if err := json.Unmarshal([]byte(state.argumentsBuffer), &input); err == nil {
// Successfully parsed - arguments are complete
if !state.isComplete {
state.isComplete = true
log.Debugf("[OpenAI->Claude] Tool call arguments complete for %s: %s", state.name, state.argumentsBuffer)
// Close thinking content block if it's still open
if c.thinkingBlockStarted && !c.thinkingBlockStopped {
c.thinkingBlockStopped = true
log.Debugf("[OpenAI->Claude] Closing thinking content block before tool use")
responses = append(responses, &claudeTextGenStreamResponse{
Type: "content_block_stop",
Index: &c.thinkingBlockIndex,
})
}
// Close text content block if it's still open
if c.textBlockStarted && !c.textBlockStopped {
c.textBlockStopped = true
log.Debugf("[OpenAI->Claude] Closing text content block before tool use")
responses = append(responses, &claudeTextGenStreamResponse{
Type: "content_block_stop",
Index: &c.textBlockIndex,
})
}
// Send content_block_start for tool_use only when we have complete arguments with dynamic index
if !c.toolBlockStarted {
c.toolBlockIndex = c.nextContentIndex
c.nextContentIndex++
c.toolBlockStarted = true
log.Debugf("[OpenAI->Claude] Generated content_block_start event for tool_use at index %d", c.toolBlockIndex)
responses = append(responses, &claudeTextGenStreamResponse{
Type: "content_block_start",
Index: &c.toolBlockIndex,
ContentBlock: &claudeTextGenContent{
Type: "tool_use",
Id: toolCall.Id,
Name: state.name,
Input: input,
},
})
}
}
} else {
// Still accumulating arguments
log.Debugf("[OpenAI->Claude] Tool arguments not yet complete, continuing to accumulate: %v", err)
}
}
}
}
}
// Handle finish reason
if choice.FinishReason != nil {
claudeFinishReason := openAIFinishReasonToClaude(*choice.FinishReason)
log.Debugf("[OpenAI->Claude] Processing finish_reason: %s -> %s", *choice.FinishReason, claudeFinishReason)
// Send content_block_stop for any active content blocks
if c.thinkingBlockStarted && !c.thinkingBlockStopped {
c.thinkingBlockStopped = true
log.Debugf("[OpenAI->Claude] Generated thinking content_block_stop event at index %d", c.thinkingBlockIndex)
responses = append(responses, &claudeTextGenStreamResponse{
Type: "content_block_stop",
Index: &c.thinkingBlockIndex,
})
}
if c.textBlockStarted && !c.textBlockStopped {
c.textBlockStopped = true
log.Debugf("[OpenAI->Claude] Generated text content_block_stop event at index %d", c.textBlockIndex)
responses = append(responses, &claudeTextGenStreamResponse{
Type: "content_block_stop",
Index: &c.textBlockIndex,
})
}
if c.toolBlockStarted && !c.toolBlockStopped {
c.toolBlockStopped = true
log.Debugf("[OpenAI->Claude] Generated tool content_block_stop event at index %d", c.toolBlockIndex)
responses = append(responses, &claudeTextGenStreamResponse{
Type: "content_block_stop",
Index: &c.toolBlockIndex,
})
}
// Cache stop_reason until we get usage info (Claude protocol requires them together)
c.pendingStopReason = &claudeFinishReason
log.Debugf("[OpenAI->Claude] Cached stop_reason: %s, waiting for usage", claudeFinishReason)
}
// Handle usage information
if openaiResponse.Usage != nil && choice.FinishReason == nil {
log.Debugf("[OpenAI->Claude] Processing usage info - input: %d, output: %d",
openaiResponse.Usage.PromptTokens, openaiResponse.Usage.CompletionTokens)
// Send message_delta with both stop_reason and usage (Claude protocol requirement)
messageDelta := &claudeTextGenStreamResponse{
Type: "message_delta",
Delta: &claudeTextGenDelta{
Type: "message_delta",
},
Usage: &claudeTextGenUsage{
InputTokens: openaiResponse.Usage.PromptTokens,
OutputTokens: openaiResponse.Usage.CompletionTokens,
},
}
} else if choice.Delta != nil && choice.Delta.Role != "" {
// Message start
return &claudeTextGenStreamResponse{
Type: "message_start",
Index: choice.Index,
Message: &claudeTextGenResponse{
Id: openaiResponse.Id,
Type: "message",
Role: "assistant",
Model: openaiResponse.Model,
Usage: claudeTextGenUsage{
InputTokens: openaiResponse.Usage.PromptTokens,
OutputTokens: 0,
},
},
// Include cached stop_reason if available
if c.pendingStopReason != nil {
log.Debugf("[OpenAI->Claude] Combining cached stop_reason %s with usage", *c.pendingStopReason)
messageDelta.Delta.StopReason = c.pendingStopReason
c.pendingStopReason = nil // Clear cache
}
log.Debugf("[OpenAI->Claude] Generated message_delta event with usage and stop_reason")
responses = append(responses, messageDelta)
// Send message_stop after combined message_delta
if !c.messageStopSent {
c.messageStopSent = true
log.Debugf("[OpenAI->Claude] Generated message_stop event")
responses = append(responses, &claudeTextGenStreamResponse{
Type: "message_stop",
})
}
}
return nil
return responses
}
// openAIFinishReasonToClaude converts OpenAI finish reason to Claude format
@@ -269,3 +747,78 @@ func openAIFinishReasonToClaude(reason string) string {
return reason
}
}
// convertContentArray converts an array of Claude content to OpenAI content format
func (c *ClaudeToOpenAIConverter) convertContentArray(claudeContents []claudeChatMessageContent) *contentConversionResult {
result := &contentConversionResult{
textParts: []string{},
toolCalls: []toolCall{},
toolResults: []claudeChatMessageContent{},
openaiContents: []chatMessageContent{},
hasNonTextContent: false,
}
for _, claudeContent := range claudeContents {
switch claudeContent.Type {
case "text":
if claudeContent.Text != "" {
result.textParts = append(result.textParts, claudeContent.Text)
result.openaiContents = append(result.openaiContents, chatMessageContent{
Type: contentTypeText,
Text: claudeContent.Text,
})
}
case "image":
result.hasNonTextContent = true
if claudeContent.Source != nil {
if claudeContent.Source.Type == "base64" {
// Convert base64 image to OpenAI format
dataUrl := fmt.Sprintf("data:%s;base64,%s", claudeContent.Source.MediaType, claudeContent.Source.Data)
result.openaiContents = append(result.openaiContents, chatMessageContent{
Type: contentTypeImageUrl,
ImageUrl: &chatMessageContentImageUrl{
Url: dataUrl,
},
})
} else if claudeContent.Source.Type == "url" {
result.openaiContents = append(result.openaiContents, chatMessageContent{
Type: contentTypeImageUrl,
ImageUrl: &chatMessageContentImageUrl{
Url: claudeContent.Source.Url,
},
})
}
}
case "tool_use":
result.hasNonTextContent = true
// Convert Claude tool_use to OpenAI tool_calls format
if claudeContent.Id != "" && claudeContent.Name != "" {
// Convert input to JSON string for OpenAI format
var argumentsStr string
if claudeContent.Input != nil {
if argBytes, err := json.Marshal(claudeContent.Input); err == nil {
argumentsStr = string(argBytes)
}
}
toolCall := toolCall{
Id: claudeContent.Id,
Type: "function",
Function: functionCall{
Name: claudeContent.Name,
Arguments: argumentsStr,
},
}
result.toolCalls = append(result.toolCalls, toolCall)
log.Debugf("[Claude->OpenAI] Converted tool_use to tool_call: %s", claudeContent.Name)
}
case "tool_result":
result.hasNonTextContent = true
// Store tool results for processing
result.toolResults = append(result.toolResults, claudeContent)
log.Debugf("[Claude->OpenAI] Found tool_result for tool_use_id: %s", claudeContent.ToolUseId)
}
}
return result
}