mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-02 20:40:52 +08:00
- Integrated Claude API compatibility in handlers, translators, and server routes. - Introduced `/messages` endpoint and upgraded `AuthMiddleware` for `X-Api-Key` header. - Improved streaming response handling with `ConvertCliToClaude` for SSE compatibility. - Enhanced request processing and tool-response mapping in translators. - Updated README to reflect Claude integration and clarify supported features.
383 lines
19 KiB
Go
383 lines
19 KiB
Go
package translator
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
)
|
|
|
|
// ConvertCliToOpenAI translates a single chunk of a streaming response from the
|
|
// backend client format to the OpenAI Server-Sent Events (SSE) format.
|
|
// It returns an empty string if the chunk contains no useful data.
|
|
func ConvertCliToOpenAI(rawJson []byte, unixTimestamp int64, isGlAPIKey bool) string {
|
|
if isGlAPIKey {
|
|
rawJson, _ = sjson.SetRawBytes(rawJson, "response", rawJson)
|
|
}
|
|
|
|
// Initialize the OpenAI SSE template.
|
|
template := `{"id":"","object":"chat.completion.chunk","created":12345,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":null,"native_finish_reason":null}]}`
|
|
|
|
// Extract and set the model version.
|
|
if modelVersionResult := gjson.GetBytes(rawJson, "response.modelVersion"); modelVersionResult.Exists() {
|
|
template, _ = sjson.Set(template, "model", modelVersionResult.String())
|
|
}
|
|
|
|
// Extract and set the creation timestamp.
|
|
if createTimeResult := gjson.GetBytes(rawJson, "response.createTime"); createTimeResult.Exists() {
|
|
t, err := time.Parse(time.RFC3339Nano, createTimeResult.String())
|
|
if err == nil {
|
|
unixTimestamp = t.Unix()
|
|
}
|
|
template, _ = sjson.Set(template, "created", unixTimestamp)
|
|
} else {
|
|
template, _ = sjson.Set(template, "created", unixTimestamp)
|
|
}
|
|
|
|
// Extract and set the response ID.
|
|
if responseIdResult := gjson.GetBytes(rawJson, "response.responseId"); responseIdResult.Exists() {
|
|
template, _ = sjson.Set(template, "id", responseIdResult.String())
|
|
}
|
|
|
|
// Extract and set the finish reason.
|
|
if finishReasonResult := gjson.GetBytes(rawJson, "response.candidates.0.finishReason"); finishReasonResult.Exists() {
|
|
template, _ = sjson.Set(template, "choices.0.finish_reason", finishReasonResult.String())
|
|
template, _ = sjson.Set(template, "choices.0.native_finish_reason", finishReasonResult.String())
|
|
}
|
|
|
|
// Extract and set usage metadata (token counts).
|
|
if usageResult := gjson.GetBytes(rawJson, "response.usageMetadata"); usageResult.Exists() {
|
|
if candidatesTokenCountResult := usageResult.Get("candidatesTokenCount"); candidatesTokenCountResult.Exists() {
|
|
template, _ = sjson.Set(template, "usage.completion_tokens", candidatesTokenCountResult.Int())
|
|
}
|
|
if totalTokenCountResult := usageResult.Get("totalTokenCount"); totalTokenCountResult.Exists() {
|
|
template, _ = sjson.Set(template, "usage.total_tokens", totalTokenCountResult.Int())
|
|
}
|
|
promptTokenCount := usageResult.Get("promptTokenCount").Int()
|
|
thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()
|
|
template, _ = sjson.Set(template, "usage.prompt_tokens", promptTokenCount+thoughtsTokenCount)
|
|
if thoughtsTokenCount > 0 {
|
|
template, _ = sjson.Set(template, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)
|
|
}
|
|
}
|
|
|
|
// Process the main content part of the response.
|
|
partsResult := gjson.GetBytes(rawJson, "response.candidates.0.content.parts")
|
|
if partsResult.IsArray() {
|
|
partResults := partsResult.Array()
|
|
for i := 0; i < len(partResults); i++ {
|
|
partResult := partResults[i]
|
|
partTextResult := partResult.Get("text")
|
|
functionCallResult := partResult.Get("functionCall")
|
|
|
|
if partTextResult.Exists() {
|
|
// Handle text content, distinguishing between regular content and reasoning/thoughts.
|
|
if partResult.Get("thought").Bool() {
|
|
template, _ = sjson.Set(template, "choices.0.delta.reasoning_content", partTextResult.String())
|
|
} else {
|
|
template, _ = sjson.Set(template, "choices.0.delta.content", partTextResult.String())
|
|
}
|
|
template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
|
|
} else if functionCallResult.Exists() {
|
|
// Handle function call content.
|
|
toolCallsResult := gjson.Get(template, "choices.0.delta.tool_calls")
|
|
if !toolCallsResult.Exists() || !toolCallsResult.IsArray() {
|
|
template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls", `[]`)
|
|
}
|
|
|
|
functionCallTemplate := `{"id": "","type": "function","function": {"name": "","arguments": ""}}`
|
|
fcName := functionCallResult.Get("name").String()
|
|
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "id", fmt.Sprintf("%s-%d", fcName, time.Now().UnixNano()))
|
|
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "function.name", fcName)
|
|
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
|
|
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "function.arguments", fcArgsResult.Raw)
|
|
}
|
|
template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
|
|
template, _ = sjson.SetRaw(template, "choices.0.message.tool_calls.-1", functionCallTemplate)
|
|
}
|
|
}
|
|
}
|
|
|
|
return template
|
|
}
|
|
|
|
// ConvertCliToOpenAINonStream aggregates response from the backend client
|
|
// convert a single, non-streaming OpenAI-compatible JSON response.
|
|
func ConvertCliToOpenAINonStream(rawJson []byte, unixTimestamp int64, isGlAPIKey bool) string {
|
|
if isGlAPIKey {
|
|
rawJson, _ = sjson.SetRawBytes(rawJson, "response", rawJson)
|
|
}
|
|
template := `{"id":"","object":"chat.completion","created":123456,"model":"model","choices":[{"index":0,"message":{"role":"assistant","content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":null,"native_finish_reason":null}]}`
|
|
if modelVersionResult := gjson.GetBytes(rawJson, "response.modelVersion"); modelVersionResult.Exists() {
|
|
template, _ = sjson.Set(template, "model", modelVersionResult.String())
|
|
}
|
|
|
|
if createTimeResult := gjson.GetBytes(rawJson, "response.createTime"); createTimeResult.Exists() {
|
|
t, err := time.Parse(time.RFC3339Nano, createTimeResult.String())
|
|
if err == nil {
|
|
unixTimestamp = t.Unix()
|
|
}
|
|
template, _ = sjson.Set(template, "created", unixTimestamp)
|
|
} else {
|
|
template, _ = sjson.Set(template, "created", unixTimestamp)
|
|
}
|
|
|
|
if responseIdResult := gjson.GetBytes(rawJson, "response.responseId"); responseIdResult.Exists() {
|
|
template, _ = sjson.Set(template, "id", responseIdResult.String())
|
|
}
|
|
|
|
if finishReasonResult := gjson.GetBytes(rawJson, "response.candidates.0.finishReason"); finishReasonResult.Exists() {
|
|
template, _ = sjson.Set(template, "choices.0.finish_reason", finishReasonResult.String())
|
|
template, _ = sjson.Set(template, "choices.0.native_finish_reason", finishReasonResult.String())
|
|
}
|
|
|
|
if usageResult := gjson.GetBytes(rawJson, "response.usageMetadata"); usageResult.Exists() {
|
|
if candidatesTokenCountResult := usageResult.Get("candidatesTokenCount"); candidatesTokenCountResult.Exists() {
|
|
template, _ = sjson.Set(template, "usage.completion_tokens", candidatesTokenCountResult.Int())
|
|
}
|
|
if totalTokenCountResult := usageResult.Get("totalTokenCount"); totalTokenCountResult.Exists() {
|
|
template, _ = sjson.Set(template, "usage.total_tokens", totalTokenCountResult.Int())
|
|
}
|
|
promptTokenCount := usageResult.Get("promptTokenCount").Int()
|
|
thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()
|
|
template, _ = sjson.Set(template, "usage.prompt_tokens", promptTokenCount+thoughtsTokenCount)
|
|
if thoughtsTokenCount > 0 {
|
|
template, _ = sjson.Set(template, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)
|
|
}
|
|
}
|
|
|
|
// Process the main content part of the response.
|
|
partsResult := gjson.GetBytes(rawJson, "response.candidates.0.content.parts")
|
|
if partsResult.IsArray() {
|
|
partsResults := partsResult.Array()
|
|
for i := 0; i < len(partsResults); i++ {
|
|
partResult := partsResults[i]
|
|
partTextResult := partResult.Get("text")
|
|
functionCallResult := partResult.Get("functionCall")
|
|
|
|
if partTextResult.Exists() {
|
|
// Append text content, distinguishing between regular content and reasoning.
|
|
if partResult.Get("thought").Bool() {
|
|
template, _ = sjson.Set(template, "choices.0.message.reasoning_content", partTextResult.String())
|
|
} else {
|
|
template, _ = sjson.Set(template, "choices.0.message.content", partTextResult.String())
|
|
}
|
|
template, _ = sjson.Set(template, "choices.0.message.role", "assistant")
|
|
} else if functionCallResult.Exists() {
|
|
// Append function call content to the tool_calls array.
|
|
toolCallsResult := gjson.Get(template, "choices.0.message.tool_calls")
|
|
if !toolCallsResult.Exists() || !toolCallsResult.IsArray() {
|
|
template, _ = sjson.SetRaw(template, "choices.0.message.tool_calls", `[]`)
|
|
}
|
|
functionCallItemTemplate := `{"id": "","type": "function","function": {"name": "","arguments": ""}}`
|
|
fcName := functionCallResult.Get("name").String()
|
|
functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "id", fmt.Sprintf("%s-%d", fcName, time.Now().UnixNano()))
|
|
functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "function.name", fcName)
|
|
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
|
|
functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "function.arguments", fcArgsResult.Raw)
|
|
}
|
|
template, _ = sjson.Set(template, "choices.0.message.role", "assistant")
|
|
template, _ = sjson.SetRaw(template, "choices.0.message.tool_calls.-1", functionCallItemTemplate)
|
|
} else {
|
|
// If no usable content is found, return an empty string.
|
|
return ""
|
|
}
|
|
}
|
|
}
|
|
|
|
return template
|
|
}
|
|
|
|
// ConvertCliToClaude performs sophisticated streaming response format conversion.
|
|
// This function implements a complex state machine that translates backend client responses
|
|
// into Claude-compatible Server-Sent Events (SSE) format. It manages different response types
|
|
// and handles state transitions between content blocks, thinking processes, and function calls.
|
|
//
|
|
// Response type states: 0=none, 1=content, 2=thinking, 3=function
|
|
// The function maintains state across multiple calls to ensure proper SSE event sequencing.
|
|
func ConvertCliToClaude(rawJson []byte, isGlAPIKey, hasFirstResponse bool, responseType, responseIndex *int) string {
|
|
// Normalize the response format for different API key types
|
|
// Generative Language API keys have a different response structure
|
|
if isGlAPIKey {
|
|
rawJson, _ = sjson.SetRawBytes(rawJson, "response", rawJson)
|
|
}
|
|
|
|
// Track whether tools are being used in this response chunk
|
|
usedTool := false
|
|
output := ""
|
|
|
|
// Initialize the streaming session with a message_start event
|
|
// This is only sent for the very first response chunk
|
|
if !hasFirstResponse {
|
|
output = "event: message_start\n"
|
|
|
|
// Create the initial message structure with default values
|
|
// This follows the Claude API specification for streaming message initialization
|
|
messageStartTemplate := `{"type": "message_start", "message": {"id": "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", "type": "message", "role": "assistant", "content": [], "model": "claude-3-5-sonnet-20241022", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 0, "output_tokens": 0}}}`
|
|
|
|
// Override default values with actual response metadata if available
|
|
if modelVersionResult := gjson.GetBytes(rawJson, "response.modelVersion"); modelVersionResult.Exists() {
|
|
messageStartTemplate, _ = sjson.Set(messageStartTemplate, "message.model", modelVersionResult.String())
|
|
}
|
|
if responseIdResult := gjson.GetBytes(rawJson, "response.responseId"); responseIdResult.Exists() {
|
|
messageStartTemplate, _ = sjson.Set(messageStartTemplate, "message.id", responseIdResult.String())
|
|
}
|
|
output = output + fmt.Sprintf("data: %s\n\n\n", messageStartTemplate)
|
|
}
|
|
|
|
// Process the response parts array from the backend client
|
|
// Each part can contain text content, thinking content, or function calls
|
|
partsResult := gjson.GetBytes(rawJson, "response.candidates.0.content.parts")
|
|
if partsResult.IsArray() {
|
|
partResults := partsResult.Array()
|
|
for i := 0; i < len(partResults); i++ {
|
|
partResult := partResults[i]
|
|
|
|
// Extract the different types of content from each part
|
|
partTextResult := partResult.Get("text")
|
|
functionCallResult := partResult.Get("functionCall")
|
|
|
|
// Handle text content (both regular content and thinking)
|
|
if partTextResult.Exists() {
|
|
// Process thinking content (internal reasoning)
|
|
if partResult.Get("thought").Bool() {
|
|
// Continue existing thinking block
|
|
if *responseType == 2 {
|
|
output = output + "event: content_block_delta\n"
|
|
data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, *responseIndex), "delta.thinking", partTextResult.String())
|
|
output = output + fmt.Sprintf("data: %s\n\n\n", data)
|
|
} else {
|
|
// Transition from another state to thinking
|
|
// First, close any existing content block
|
|
if *responseType != 0 {
|
|
if *responseType == 2 {
|
|
output = output + "event: content_block_delta\n"
|
|
output = output + fmt.Sprintf(`data: {"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":null}}`, *responseIndex)
|
|
output = output + "\n\n\n"
|
|
}
|
|
output = output + "event: content_block_stop\n"
|
|
output = output + fmt.Sprintf(`data: {"type":"content_block_stop","index":%d}`, *responseIndex)
|
|
output = output + "\n\n\n"
|
|
*responseIndex++
|
|
}
|
|
|
|
// Start a new thinking content block
|
|
output = output + "event: content_block_start\n"
|
|
output = output + fmt.Sprintf(`data: {"type":"content_block_start","index":%d,"content_block":{"type":"thinking","thinking":""}}`, *responseIndex)
|
|
output = output + "\n\n\n"
|
|
output = output + "event: content_block_delta\n"
|
|
data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, *responseIndex), "delta.thinking", partTextResult.String())
|
|
output = output + fmt.Sprintf("data: %s\n\n\n", data)
|
|
*responseType = 2 // Set state to thinking
|
|
}
|
|
} else {
|
|
// Process regular text content (user-visible output)
|
|
// Continue existing text block
|
|
if *responseType == 1 {
|
|
output = output + "event: content_block_delta\n"
|
|
data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, *responseIndex), "delta.text", partTextResult.String())
|
|
output = output + fmt.Sprintf("data: %s\n\n\n", data)
|
|
} else {
|
|
// Transition from another state to text content
|
|
// First, close any existing content block
|
|
if *responseType != 0 {
|
|
if *responseType == 2 {
|
|
output = output + "event: content_block_delta\n"
|
|
output = output + fmt.Sprintf(`data: {"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":null}}`, *responseIndex)
|
|
output = output + "\n\n\n"
|
|
}
|
|
output = output + "event: content_block_stop\n"
|
|
output = output + fmt.Sprintf(`data: {"type":"content_block_stop","index":%d}`, *responseIndex)
|
|
output = output + "\n\n\n"
|
|
*responseIndex++
|
|
}
|
|
|
|
// Start a new text content block
|
|
output = output + "event: content_block_start\n"
|
|
output = output + fmt.Sprintf(`data: {"type":"content_block_start","index":%d,"content_block":{"type":"text","text":""}}`, *responseIndex)
|
|
output = output + "\n\n\n"
|
|
output = output + "event: content_block_delta\n"
|
|
data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, *responseIndex), "delta.text", partTextResult.String())
|
|
output = output + fmt.Sprintf("data: %s\n\n\n", data)
|
|
*responseType = 1 // Set state to content
|
|
}
|
|
}
|
|
} else if functionCallResult.Exists() {
|
|
// Handle function/tool calls from the AI model
|
|
// This processes tool usage requests and formats them for Claude API compatibility
|
|
usedTool = true
|
|
fcName := functionCallResult.Get("name").String()
|
|
|
|
// Handle state transitions when switching to function calls
|
|
// Close any existing function call block first
|
|
if *responseType == 3 {
|
|
output = output + "event: content_block_stop\n"
|
|
output = output + fmt.Sprintf(`data: {"type":"content_block_stop","index":%d}`, *responseIndex)
|
|
output = output + "\n\n\n"
|
|
*responseIndex++
|
|
*responseType = 0
|
|
}
|
|
|
|
// Special handling for thinking state transition
|
|
if *responseType == 2 {
|
|
output = output + "event: content_block_delta\n"
|
|
output = output + fmt.Sprintf(`data: {"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":null}}`, *responseIndex)
|
|
output = output + "\n\n\n"
|
|
}
|
|
|
|
// Close any other existing content block
|
|
if *responseType != 0 {
|
|
output = output + "event: content_block_stop\n"
|
|
output = output + fmt.Sprintf(`data: {"type":"content_block_stop","index":%d}`, *responseIndex)
|
|
output = output + "\n\n\n"
|
|
*responseIndex++
|
|
}
|
|
|
|
// Start a new tool use content block
|
|
// This creates the structure for a function call in Claude format
|
|
output = output + "event: content_block_start\n"
|
|
|
|
// Create the tool use block with unique ID and function details
|
|
data := fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`, *responseIndex)
|
|
data, _ = sjson.Set(data, "content_block.id", fmt.Sprintf("%s-%d", fcName, time.Now().UnixNano()))
|
|
data, _ = sjson.Set(data, "content_block.name", fcName)
|
|
output = output + fmt.Sprintf("data: %s\n\n\n", data)
|
|
|
|
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
|
|
output = output + "event: content_block_delta\n"
|
|
data, _ = sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, *responseIndex), "delta.partial_json", fcArgsResult.Raw)
|
|
output = output + fmt.Sprintf("data: %s\n\n\n", data)
|
|
}
|
|
*responseType = 3
|
|
}
|
|
}
|
|
}
|
|
|
|
usageResult := gjson.GetBytes(rawJson, "response.usageMetadata")
|
|
if usageResult.Exists() && bytes.Contains(rawJson, []byte(`"finishReason"`)) {
|
|
if candidatesTokenCountResult := usageResult.Get("candidatesTokenCount"); candidatesTokenCountResult.Exists() {
|
|
output = output + "event: content_block_stop\n"
|
|
output = output + fmt.Sprintf(`data: {"type":"content_block_stop","index":%d}`, *responseIndex)
|
|
output = output + "\n\n\n"
|
|
|
|
output = output + "event: message_delta\n"
|
|
output = output + `data: `
|
|
|
|
template := `{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`
|
|
if usedTool {
|
|
template = `{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`
|
|
}
|
|
|
|
thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()
|
|
template, _ = sjson.Set(template, "usage.output_tokens", candidatesTokenCountResult.Int()+thoughtsTokenCount)
|
|
template, _ = sjson.Set(template, "usage.input_tokens", usageResult.Get("promptTokenCount").Int())
|
|
|
|
output = output + template + "\n\n\n"
|
|
}
|
|
}
|
|
|
|
return output
|
|
}
|