Implement OpenAI-to-Anthropic proxy with streaming support
- Add request/response converters (OpenAI <-> Anthropic formats) - Implement SSE streaming conversion (Anthropic events -> OpenAI SSE) - Add /v1/models endpoint with Claude model list - Add /v1/chat/completions endpoint with streaming and non-streaming support - Fix context key type matching bug (sessionIDKey) - Configurable upstream URL via config.yaml - Mimic claude-code CLI headers for upstream requests
This commit is contained in:
parent
87a74edbf5
commit
8450d96e2e
10 changed files with 1270 additions and 0 deletions
227
converter.go
Normal file
227
converter.go
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ConvertOpenAIRequest converts an OpenAI ChatCompletionRequest to Anthropic format
|
||||
func ConvertOpenAIRequest(req *ChatCompletionRequest) *AnthropicRequest {
|
||||
system, remainingMessages := extractSystemMessage(req.Messages)
|
||||
|
||||
anthropicReq := &AnthropicRequest{
|
||||
Model: req.Model,
|
||||
Messages: convertMessages(remainingMessages),
|
||||
System: system,
|
||||
MaxTokens: 32000,
|
||||
}
|
||||
|
||||
if req.MaxTokens != nil {
|
||||
anthropicReq.MaxTokens = *req.MaxTokens
|
||||
}
|
||||
if req.Stream != nil {
|
||||
anthropicReq.Stream = *req.Stream
|
||||
}
|
||||
if req.Temperature != nil {
|
||||
anthropicReq.Temperature = req.Temperature
|
||||
}
|
||||
if req.TopP != nil {
|
||||
anthropicReq.TopP = req.TopP
|
||||
}
|
||||
if len(req.Stop) > 0 {
|
||||
anthropicReq.StopSequences = req.Stop
|
||||
}
|
||||
if len(req.Tools) > 0 {
|
||||
anthropicReq.Tools = convertTools(req.Tools)
|
||||
}
|
||||
if req.ToolChoices != nil {
|
||||
anthropicReq.ToolChoice = convertToolChoice(req.ToolChoices)
|
||||
}
|
||||
|
||||
return anthropicReq
|
||||
}
|
||||
|
||||
// extractSystemMessage pulls role="system" messages and joins them
|
||||
func extractSystemMessage(messages []Message) (string, []Message) {
|
||||
var systemParts []string
|
||||
var rest []Message
|
||||
|
||||
for _, msg := range messages {
|
||||
if msg.Role == "system" {
|
||||
if content, ok := msg.Content.(string); ok {
|
||||
systemParts = append(systemParts, content)
|
||||
}
|
||||
} else {
|
||||
rest = append(rest, msg)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(systemParts, "\n"), rest
|
||||
}
|
||||
|
||||
// convertMessages converts OpenAI messages to Anthropic content blocks
|
||||
func convertMessages(messages []Message) []AnthropicMessage {
|
||||
var result []AnthropicMessage
|
||||
|
||||
for _, msg := range messages {
|
||||
anthropicMsg := AnthropicMessage{
|
||||
Role: msg.Role,
|
||||
}
|
||||
|
||||
switch content := msg.Content.(type) {
|
||||
case string:
|
||||
anthropicMsg.Content = content
|
||||
case []interface{}:
|
||||
var blocks []ContentBlock
|
||||
for _, part := range content {
|
||||
partMap, ok := part.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
partType, _ := partMap["type"].(string)
|
||||
if partType == "text" {
|
||||
text, _ := partMap["text"].(string)
|
||||
blocks = append(blocks, ContentBlock{Type: "text", Text: text})
|
||||
}
|
||||
// Image parts: skip for now, Anthropic uses different format
|
||||
}
|
||||
anthropicMsg.Content = blocks
|
||||
}
|
||||
|
||||
result = append(result, anthropicMsg)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// convertTools converts OpenAI function tools to Anthropic tool format
|
||||
func convertTools(tools []Tool) []AnthropicTool {
|
||||
var result []AnthropicTool
|
||||
for _, tool := range tools {
|
||||
anthropicTool := AnthropicTool{
|
||||
Name: tool.Function.Name,
|
||||
Description: tool.Function.Description,
|
||||
InputSchema: tool.Function.Parameters,
|
||||
}
|
||||
result = append(result, anthropicTool)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// convertToolChoice converts OpenAI tool_choice to Anthropic format
|
||||
func convertToolChoice(tc *ToolChoice) *AnthropicToolChoice {
|
||||
if tc == nil {
|
||||
return nil
|
||||
}
|
||||
result := &AnthropicToolChoice{}
|
||||
if tc.Type != "" {
|
||||
if tc.Type == "required" {
|
||||
result.Type = "any"
|
||||
} else {
|
||||
result.Type = tc.Type
|
||||
}
|
||||
}
|
||||
if tc.Function != nil {
|
||||
result.Name = tc.Function.Name
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ConvertAnthropicResponse converts an Anthropic response to OpenAI format
|
||||
func ConvertAnthropicResponse(resp *AnthropicResponse, model string) *ChatCompletionResponse {
|
||||
response := &ChatCompletionResponse{
|
||||
ID: resp.Id,
|
||||
Object: "chat.completion",
|
||||
Created: 1234567890,
|
||||
Model: model,
|
||||
Choices: make([]Choice, 0),
|
||||
Usage: Usage{
|
||||
PromptTokens: resp.Usage.InputTokens,
|
||||
CompletionTokens: resp.Usage.OutputTokens,
|
||||
TotalTokens: resp.Usage.InputTokens + resp.Usage.OutputTokens,
|
||||
},
|
||||
}
|
||||
|
||||
if len(resp.Content) == 0 {
|
||||
response.Choices = append(response.Choices, Choice{
|
||||
Index: 0,
|
||||
Message: Message{Role: "assistant", Content: ""},
|
||||
FinishReason: mapStopReason(resp.StopReason),
|
||||
})
|
||||
return response
|
||||
}
|
||||
|
||||
// First pass: collect text and tool calls
|
||||
var textContent string
|
||||
var toolCalls []ToolCall
|
||||
|
||||
for _, block := range resp.Content {
|
||||
if block.Type == "text" {
|
||||
textContent += block.Text
|
||||
} else if block.Type == "tool_use" {
|
||||
// Serialize the input back to JSON
|
||||
inputJSON, _ := json.Marshal(block.Input)
|
||||
toolCalls = append(toolCalls, ToolCall{
|
||||
ID: block.Id,
|
||||
Type: "function",
|
||||
Function: FunctionCall{
|
||||
Name: block.Name,
|
||||
Arguments: string(inputJSON),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(toolCalls) > 0 {
|
||||
response.Choices = append(response.Choices, Choice{
|
||||
Index: 0,
|
||||
Message: Message{
|
||||
Role: "assistant",
|
||||
Content: textContent,
|
||||
ToolCalls: toolCalls,
|
||||
},
|
||||
FinishReason: mapStopReason(resp.StopReason),
|
||||
})
|
||||
} else {
|
||||
response.Choices = append(response.Choices, Choice{
|
||||
Index: 0,
|
||||
Message: Message{Role: "assistant", Content: textContent},
|
||||
FinishReason: mapStopReason(resp.StopReason),
|
||||
})
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// mapStopReason maps Anthropic stop reasons to OpenAI finish reasons
|
||||
func mapStopReason(reason string) string {
|
||||
switch reason {
|
||||
case "end_turn":
|
||||
return "stop"
|
||||
case "tool_use":
|
||||
return "tool_calls"
|
||||
case "max_tokens":
|
||||
return "length"
|
||||
default:
|
||||
return "stop"
|
||||
}
|
||||
}
|
||||
|
||||
// buildToolCalls builds OpenAI ToolCall slice from Anthropic ContentBlocks
|
||||
func buildToolCalls(content []ContentBlock) []ToolCall {
|
||||
var toolCalls []ToolCall
|
||||
for _, block := range content {
|
||||
if block.Type == "tool_use" {
|
||||
inputJSON, _ := json.Marshal(block.Input)
|
||||
toolCalls = append(toolCalls, ToolCall{
|
||||
ID: block.Id,
|
||||
Type: "function",
|
||||
Function: FunctionCall{
|
||||
Name: block.Name,
|
||||
Arguments: string(inputJSON),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
return toolCalls
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue