Files
CLIProxyAPI/internal/translator/claude/openai/claude_openai_response.go
2025-08-21 02:53:28 +08:00

396 lines
13 KiB
Go

// Package openai provides response translation functionality for Anthropic to OpenAI API.
// This package handles the conversion of Anthropic API responses into OpenAI Chat Completions-compatible
// JSON format, transforming streaming events and non-streaming responses into the format
// expected by OpenAI API clients. It supports both streaming and non-streaming modes,
// handling text content, tool calls, and usage metadata appropriately.
package openai
import (
"encoding/json"
"strings"
"time"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// ConvertAnthropicResponseToOpenAIParams holds parameters for response conversion
type ConvertAnthropicResponseToOpenAIParams struct {
CreatedAt int64
ResponseID string
FinishReason string
// Tool calls accumulator for streaming
ToolCallsAccumulator map[int]*ToolCallAccumulator
}
// ToolCallAccumulator holds the state for accumulating tool call data
type ToolCallAccumulator struct {
ID string
Name string
Arguments strings.Builder
}
// ConvertAnthropicResponseToOpenAI converts Anthropic streaming response format to OpenAI Chat Completions format.
// This function processes various Anthropic event types and transforms them into OpenAI-compatible JSON responses.
// It handles text content, tool calls, and usage metadata, outputting responses that match the OpenAI API format.
func ConvertAnthropicResponseToOpenAI(rawJSON []byte, param *ConvertAnthropicResponseToOpenAIParams) []string {
root := gjson.ParseBytes(rawJSON)
eventType := root.Get("type").String()
// Base OpenAI streaming response template
template := `{"id":"","object":"chat.completion.chunk","created":0,"model":"","choices":[{"index":0,"delta":{},"finish_reason":null}]}`
// Set model
modelResult := gjson.GetBytes(rawJSON, "model")
modelName := modelResult.String()
if modelName != "" {
template, _ = sjson.Set(template, "model", modelName)
}
// Set response ID and creation time
if param.ResponseID != "" {
template, _ = sjson.Set(template, "id", param.ResponseID)
}
if param.CreatedAt > 0 {
template, _ = sjson.Set(template, "created", param.CreatedAt)
}
switch eventType {
case "message_start":
// Initialize response with message metadata
if message := root.Get("message"); message.Exists() {
param.ResponseID = message.Get("id").String()
param.CreatedAt = time.Now().Unix()
template, _ = sjson.Set(template, "id", param.ResponseID)
template, _ = sjson.Set(template, "model", modelName)
template, _ = sjson.Set(template, "created", param.CreatedAt)
// Set initial role
template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
// Initialize tool calls accumulator
if param.ToolCallsAccumulator == nil {
param.ToolCallsAccumulator = make(map[int]*ToolCallAccumulator)
}
}
return []string{template}
case "content_block_start":
// Start of a content block
if contentBlock := root.Get("content_block"); contentBlock.Exists() {
blockType := contentBlock.Get("type").String()
if blockType == "tool_use" {
// Start of tool call - initialize accumulator
toolCallID := contentBlock.Get("id").String()
toolName := contentBlock.Get("name").String()
index := int(root.Get("index").Int())
if param.ToolCallsAccumulator == nil {
param.ToolCallsAccumulator = make(map[int]*ToolCallAccumulator)
}
param.ToolCallsAccumulator[index] = &ToolCallAccumulator{
ID: toolCallID,
Name: toolName,
}
// Don't output anything yet - wait for complete tool call
return []string{}
}
}
return []string{template}
case "content_block_delta":
// Handle content delta (text or tool use)
if delta := root.Get("delta"); delta.Exists() {
deltaType := delta.Get("type").String()
switch deltaType {
case "text_delta":
// Text content delta
if text := delta.Get("text"); text.Exists() {
template, _ = sjson.Set(template, "choices.0.delta.content", text.String())
}
case "input_json_delta":
// Tool use input delta - accumulate arguments
if partialJSON := delta.Get("partial_json"); partialJSON.Exists() {
index := int(root.Get("index").Int())
if param.ToolCallsAccumulator != nil {
if accumulator, exists := param.ToolCallsAccumulator[index]; exists {
accumulator.Arguments.WriteString(partialJSON.String())
}
}
}
// Don't output anything yet - wait for complete tool call
return []string{}
}
}
return []string{template}
case "content_block_stop":
// End of content block - output complete tool call if it's a tool_use block
index := int(root.Get("index").Int())
if param.ToolCallsAccumulator != nil {
if accumulator, exists := param.ToolCallsAccumulator[index]; exists {
// Build complete tool call
arguments := accumulator.Arguments.String()
if arguments == "" {
arguments = "{}"
}
toolCall := map[string]interface{}{
"index": index,
"id": accumulator.ID,
"type": "function",
"function": map[string]interface{}{
"name": accumulator.Name,
"arguments": arguments,
},
}
template, _ = sjson.Set(template, "choices.0.delta.tool_calls", []interface{}{toolCall})
// Clean up the accumulator for this index
delete(param.ToolCallsAccumulator, index)
return []string{template}
}
}
return []string{}
case "message_delta":
// Handle message-level changes
if delta := root.Get("delta"); delta.Exists() {
if stopReason := delta.Get("stop_reason"); stopReason.Exists() {
param.FinishReason = mapAnthropicStopReasonToOpenAI(stopReason.String())
template, _ = sjson.Set(template, "choices.0.finish_reason", param.FinishReason)
}
}
// Handle usage information
if usage := root.Get("usage"); usage.Exists() {
usageObj := map[string]interface{}{
"prompt_tokens": usage.Get("input_tokens").Int(),
"completion_tokens": usage.Get("output_tokens").Int(),
"total_tokens": usage.Get("input_tokens").Int() + usage.Get("output_tokens").Int(),
}
template, _ = sjson.Set(template, "usage", usageObj)
}
return []string{template}
case "message_stop":
// Final message - send [DONE]
return []string{"[DONE]\n"}
case "ping":
// Ping events - ignore
return []string{}
case "error":
// Error event
if errorData := root.Get("error"); errorData.Exists() {
errorResponse := map[string]interface{}{
"error": map[string]interface{}{
"message": errorData.Get("message").String(),
"type": errorData.Get("type").String(),
},
}
errorJSON, _ := json.Marshal(errorResponse)
return []string{string(errorJSON)}
}
return []string{}
default:
// Unknown event type - ignore
return []string{}
}
}
// mapAnthropicStopReasonToOpenAI maps Anthropic stop reasons to OpenAI stop reasons
func mapAnthropicStopReasonToOpenAI(anthropicReason string) string {
switch anthropicReason {
case "end_turn":
return "stop"
case "tool_use":
return "tool_calls"
case "max_tokens":
return "length"
case "stop_sequence":
return "stop"
default:
return "stop"
}
}
// ConvertAnthropicStreamingResponseToOpenAINonStream aggregates streaming chunks into a single non-streaming response
// following OpenAI Chat Completions API format with reasoning content support
func ConvertAnthropicStreamingResponseToOpenAINonStream(chunks [][]byte) string {
// Base OpenAI non-streaming response template
out := `{"id":"","object":"chat.completion","created":0,"model":"","choices":[{"index":0,"message":{"role":"assistant","content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}`
var messageID string
var model string
var createdAt int64
var inputTokens, outputTokens int64
var reasoningTokens int64
var stopReason string
var contentParts []string
var reasoningParts []string
// Use map to track tool calls by index for proper merging
toolCallsMap := make(map[int]map[string]interface{})
// Track tool call arguments accumulation
toolCallArgsMap := make(map[int]strings.Builder)
for _, chunk := range chunks {
root := gjson.ParseBytes(chunk)
eventType := root.Get("type").String()
switch eventType {
case "message_start":
if message := root.Get("message"); message.Exists() {
messageID = message.Get("id").String()
model = message.Get("model").String()
createdAt = time.Now().Unix()
if usage := message.Get("usage"); usage.Exists() {
inputTokens = usage.Get("input_tokens").Int()
}
}
case "content_block_start":
// Handle different content block types
if contentBlock := root.Get("content_block"); contentBlock.Exists() {
blockType := contentBlock.Get("type").String()
if blockType == "thinking" {
// Start of thinking/reasoning content
continue
} else if blockType == "tool_use" {
// Initialize tool call tracking
index := int(root.Get("index").Int())
toolCallsMap[index] = map[string]interface{}{
"id": contentBlock.Get("id").String(),
"type": "function",
"function": map[string]interface{}{
"name": contentBlock.Get("name").String(),
"arguments": "",
},
}
// Initialize arguments builder for this tool call
toolCallArgsMap[index] = strings.Builder{}
}
}
case "content_block_delta":
if delta := root.Get("delta"); delta.Exists() {
deltaType := delta.Get("type").String()
switch deltaType {
case "text_delta":
if text := delta.Get("text"); text.Exists() {
contentParts = append(contentParts, text.String())
}
case "thinking_delta":
// Anthropic thinking content -> OpenAI reasoning content
if thinking := delta.Get("thinking"); thinking.Exists() {
reasoningParts = append(reasoningParts, thinking.String())
}
case "input_json_delta":
// Accumulate tool call arguments
if partialJSON := delta.Get("partial_json"); partialJSON.Exists() {
index := int(root.Get("index").Int())
if builder, exists := toolCallArgsMap[index]; exists {
builder.WriteString(partialJSON.String())
toolCallArgsMap[index] = builder
}
}
}
}
case "content_block_stop":
// Finalize tool call arguments for this index
index := int(root.Get("index").Int())
if toolCall, exists := toolCallsMap[index]; exists {
if builder, argsExists := toolCallArgsMap[index]; argsExists {
// Set the accumulated arguments
arguments := builder.String()
if arguments == "" {
arguments = "{}"
}
toolCall["function"].(map[string]interface{})["arguments"] = arguments
}
}
case "message_delta":
if delta := root.Get("delta"); delta.Exists() {
if sr := delta.Get("stop_reason"); sr.Exists() {
stopReason = sr.String()
}
}
if usage := root.Get("usage"); usage.Exists() {
outputTokens = usage.Get("output_tokens").Int()
// Estimate reasoning tokens from thinking content
if len(reasoningParts) > 0 {
reasoningTokens = int64(len(strings.Join(reasoningParts, "")) / 4) // Rough estimation
}
}
}
}
// Set basic response fields
out, _ = sjson.Set(out, "id", messageID)
out, _ = sjson.Set(out, "created", createdAt)
out, _ = sjson.Set(out, "model", model)
// Set message content
messageContent := strings.Join(contentParts, "")
out, _ = sjson.Set(out, "choices.0.message.content", messageContent)
// Add reasoning content if available (following OpenAI reasoning format)
if len(reasoningParts) > 0 {
reasoningContent := strings.Join(reasoningParts, "")
// Add reasoning as a separate field in the message
out, _ = sjson.Set(out, "choices.0.message.reasoning", reasoningContent)
}
// Set tool calls if any
if len(toolCallsMap) > 0 {
// Convert tool calls map to array, preserving order by index
var toolCallsArray []interface{}
// Find the maximum index to determine the range
maxIndex := -1
for index := range toolCallsMap {
if index > maxIndex {
maxIndex = index
}
}
// Iterate through all possible indices up to maxIndex
for i := 0; i <= maxIndex; i++ {
if toolCall, exists := toolCallsMap[i]; exists {
toolCallsArray = append(toolCallsArray, toolCall)
}
}
if len(toolCallsArray) > 0 {
out, _ = sjson.Set(out, "choices.0.message.tool_calls", toolCallsArray)
out, _ = sjson.Set(out, "choices.0.finish_reason", "tool_calls")
} else {
out, _ = sjson.Set(out, "choices.0.finish_reason", mapAnthropicStopReasonToOpenAI(stopReason))
}
} else {
out, _ = sjson.Set(out, "choices.0.finish_reason", mapAnthropicStopReasonToOpenAI(stopReason))
}
// Set usage information
totalTokens := inputTokens + outputTokens
out, _ = sjson.Set(out, "usage.prompt_tokens", inputTokens)
out, _ = sjson.Set(out, "usage.completion_tokens", outputTokens)
out, _ = sjson.Set(out, "usage.total_tokens", totalTokens)
// Add reasoning tokens to usage details if available
if reasoningTokens > 0 {
out, _ = sjson.Set(out, "usage.completion_tokens_details.reasoning_tokens", reasoningTokens)
}
return out
}