mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-18 20:30:51 +08:00
396 lines
13 KiB
Go
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
|
|
}
|