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.
545 lines
20 KiB
Go
545 lines
20 KiB
Go
package translator
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"github.com/tidwall/sjson"
|
|
"strings"
|
|
|
|
"github.com/luispater/CLIProxyAPI/internal/client"
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
// PrepareRequest translates a raw JSON request from an OpenAI-compatible format
|
|
// to the internal format expected by the backend client. It parses messages,
|
|
// roles, content types (text, image, file), and tool calls.
|
|
func PrepareRequest(rawJson []byte) (string, *client.Content, []client.Content, []client.ToolDeclaration) {
|
|
// Extract the model name from the request, defaulting to "gemini-2.5-pro".
|
|
modelName := "gemini-2.5-pro"
|
|
modelResult := gjson.GetBytes(rawJson, "model")
|
|
if modelResult.Type == gjson.String {
|
|
modelName = modelResult.String()
|
|
}
|
|
|
|
// Initialize data structures for processing conversation messages
|
|
// contents: stores the processed conversation history
|
|
// systemInstruction: stores system-level instructions separate from conversation
|
|
contents := make([]client.Content, 0)
|
|
var systemInstruction *client.Content
|
|
messagesResult := gjson.GetBytes(rawJson, "messages")
|
|
|
|
// Pre-process tool responses to create a lookup map
|
|
// This first pass collects all tool responses so they can be matched with their corresponding calls
|
|
toolItems := make(map[string]*client.FunctionResponse)
|
|
if messagesResult.IsArray() {
|
|
messagesResults := messagesResult.Array()
|
|
for i := 0; i < len(messagesResults); i++ {
|
|
messageResult := messagesResults[i]
|
|
roleResult := messageResult.Get("role")
|
|
if roleResult.Type != gjson.String {
|
|
continue
|
|
}
|
|
contentResult := messageResult.Get("content")
|
|
|
|
// Extract tool responses for later matching with function calls
|
|
if roleResult.String() == "tool" {
|
|
toolCallID := messageResult.Get("tool_call_id").String()
|
|
if toolCallID != "" {
|
|
var responseData string
|
|
// Handle both string and object-based tool response formats
|
|
if contentResult.Type == gjson.String {
|
|
responseData = contentResult.String()
|
|
} else if contentResult.IsObject() && contentResult.Get("type").String() == "text" {
|
|
responseData = contentResult.Get("text").String()
|
|
}
|
|
|
|
// Clean up tool call ID by removing timestamp suffix
|
|
// This normalizes IDs for consistent matching between calls and responses
|
|
toolCallIDs := strings.Split(toolCallID, "-")
|
|
strings.Join(toolCallIDs, "-")
|
|
newToolCallID := strings.Join(toolCallIDs[:len(toolCallIDs)-1], "-")
|
|
|
|
// Create function response object with normalized ID and response data
|
|
functionResponse := client.FunctionResponse{Name: newToolCallID, Response: map[string]interface{}{"result": responseData}}
|
|
toolItems[toolCallID] = &functionResponse
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if messagesResult.IsArray() {
|
|
messagesResults := messagesResult.Array()
|
|
for i := 0; i < len(messagesResults); i++ {
|
|
messageResult := messagesResults[i]
|
|
roleResult := messageResult.Get("role")
|
|
contentResult := messageResult.Get("content")
|
|
if roleResult.Type != gjson.String {
|
|
continue
|
|
}
|
|
|
|
switch roleResult.String() {
|
|
// System messages are converted to a user message followed by a model's acknowledgment.
|
|
case "system":
|
|
if contentResult.Type == gjson.String {
|
|
systemInstruction = &client.Content{Role: "user", Parts: []client.Part{{Text: contentResult.String()}}}
|
|
} else if contentResult.IsObject() {
|
|
// Handle object-based system messages.
|
|
if contentResult.Get("type").String() == "text" {
|
|
systemInstruction = &client.Content{Role: "user", Parts: []client.Part{{Text: contentResult.Get("text").String()}}}
|
|
}
|
|
}
|
|
// User messages can contain simple text or a multi-part body.
|
|
case "user":
|
|
if contentResult.Type == gjson.String {
|
|
contents = append(contents, client.Content{Role: "user", Parts: []client.Part{{Text: contentResult.String()}}})
|
|
} else if contentResult.IsArray() {
|
|
// Handle multi-part user messages (text, images, files).
|
|
contentItemResults := contentResult.Array()
|
|
parts := make([]client.Part, 0)
|
|
for j := 0; j < len(contentItemResults); j++ {
|
|
contentItemResult := contentItemResults[j]
|
|
contentTypeResult := contentItemResult.Get("type")
|
|
switch contentTypeResult.String() {
|
|
case "text":
|
|
parts = append(parts, client.Part{Text: contentItemResult.Get("text").String()})
|
|
case "image_url":
|
|
// Parse data URI for images.
|
|
imageURL := contentItemResult.Get("image_url.url").String()
|
|
if len(imageURL) > 5 {
|
|
imageURLs := strings.SplitN(imageURL[5:], ";", 2)
|
|
if len(imageURLs) == 2 && len(imageURLs[1]) > 7 {
|
|
parts = append(parts, client.Part{InlineData: &client.InlineData{
|
|
MimeType: imageURLs[0],
|
|
Data: imageURLs[1][7:],
|
|
}})
|
|
}
|
|
}
|
|
case "file":
|
|
// Handle file attachments by determining MIME type from extension.
|
|
filename := contentItemResult.Get("file.filename").String()
|
|
fileData := contentItemResult.Get("file.file_data").String()
|
|
ext := ""
|
|
if split := strings.Split(filename, "."); len(split) > 1 {
|
|
ext = split[len(split)-1]
|
|
}
|
|
if mimeType, ok := MimeTypes[ext]; ok {
|
|
parts = append(parts, client.Part{InlineData: &client.InlineData{
|
|
MimeType: mimeType,
|
|
Data: fileData,
|
|
}})
|
|
} else {
|
|
log.Warnf("Unknown file name extension '%s' at index %d, skipping file", ext, j)
|
|
}
|
|
}
|
|
}
|
|
contents = append(contents, client.Content{Role: "user", Parts: parts})
|
|
}
|
|
// Assistant messages can contain text responses or tool calls
|
|
// In the internal format, assistant messages are converted to "model" role
|
|
case "assistant":
|
|
if contentResult.Type == gjson.String {
|
|
// Simple text response from the assistant
|
|
contents = append(contents, client.Content{Role: "model", Parts: []client.Part{{Text: contentResult.String()}}})
|
|
} else if !contentResult.Exists() || contentResult.Type == gjson.Null {
|
|
// Handle complex tool calls made by the assistant
|
|
// This processes function calls and matches them with their responses
|
|
functionIDs := make([]string, 0)
|
|
toolCallsResult := messageResult.Get("tool_calls")
|
|
if toolCallsResult.IsArray() {
|
|
parts := make([]client.Part, 0)
|
|
tcsResult := toolCallsResult.Array()
|
|
|
|
// Process each tool call in the assistant's message
|
|
for j := 0; j < len(tcsResult); j++ {
|
|
tcResult := tcsResult[j]
|
|
|
|
// Extract function call details
|
|
functionID := tcResult.Get("id").String()
|
|
functionIDs = append(functionIDs, functionID)
|
|
|
|
functionName := tcResult.Get("function.name").String()
|
|
functionArgs := tcResult.Get("function.arguments").String()
|
|
|
|
// Parse function arguments from JSON string to map
|
|
var args map[string]any
|
|
if err := json.Unmarshal([]byte(functionArgs), &args); err == nil {
|
|
parts = append(parts, client.Part{
|
|
FunctionCall: &client.FunctionCall{
|
|
Name: functionName,
|
|
Args: args,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
// Add the model's function calls to the conversation
|
|
if len(parts) > 0 {
|
|
contents = append(contents, client.Content{
|
|
Role: "model", Parts: parts,
|
|
})
|
|
|
|
// Create a separate tool response message with the collected responses
|
|
// This matches function calls with their corresponding responses
|
|
toolParts := make([]client.Part, 0)
|
|
for _, functionID := range functionIDs {
|
|
if functionResponse, ok := toolItems[functionID]; ok {
|
|
toolParts = append(toolParts, client.Part{FunctionResponse: functionResponse})
|
|
}
|
|
}
|
|
// Add the tool responses as a separate message in the conversation
|
|
contents = append(contents, client.Content{Role: "tool", Parts: toolParts})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Translate the tool declarations from the request.
|
|
var tools []client.ToolDeclaration
|
|
toolsResult := gjson.GetBytes(rawJson, "tools")
|
|
if toolsResult.IsArray() {
|
|
tools = make([]client.ToolDeclaration, 1)
|
|
tools[0].FunctionDeclarations = make([]any, 0)
|
|
toolsResults := toolsResult.Array()
|
|
for i := 0; i < len(toolsResults); i++ {
|
|
toolResult := toolsResults[i]
|
|
if toolResult.Get("type").String() == "function" {
|
|
functionTypeResult := toolResult.Get("function")
|
|
if functionTypeResult.Exists() && functionTypeResult.IsObject() {
|
|
var functionDeclaration any
|
|
if err := json.Unmarshal([]byte(functionTypeResult.Raw), &functionDeclaration); err == nil {
|
|
tools[0].FunctionDeclarations = append(tools[0].FunctionDeclarations, functionDeclaration)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
tools = make([]client.ToolDeclaration, 0)
|
|
}
|
|
|
|
return modelName, systemInstruction, contents, tools
|
|
}
|
|
|
|
// FunctionCallGroup represents a group of function calls and their responses
|
|
type FunctionCallGroup struct {
|
|
ModelContent map[string]interface{}
|
|
FunctionCalls []gjson.Result
|
|
ResponsesNeeded int
|
|
}
|
|
|
|
// FixCLIToolResponse performs sophisticated tool response format conversion and grouping.
|
|
// This function transforms the CLI tool response format by intelligently grouping function calls
|
|
// with their corresponding responses, ensuring proper conversation flow and API compatibility.
|
|
// It converts from a linear format (1.json) to a grouped format (2.json) where function calls
|
|
// and their responses are properly associated and structured.
|
|
func FixCLIToolResponse(input string) (string, error) {
|
|
// Parse the input JSON to extract the conversation structure
|
|
parsed := gjson.Parse(input)
|
|
|
|
// Extract the contents array which contains the conversation messages
|
|
contents := parsed.Get("request.contents")
|
|
if !contents.Exists() {
|
|
return input, fmt.Errorf("contents not found in input")
|
|
}
|
|
|
|
// Initialize data structures for processing and grouping
|
|
var newContents []interface{} // Final processed contents array
|
|
var pendingGroups []*FunctionCallGroup // Groups awaiting completion with responses
|
|
var collectedResponses []gjson.Result // Standalone responses to be matched
|
|
|
|
// Process each content object in the conversation
|
|
// This iterates through messages and groups function calls with their responses
|
|
contents.ForEach(func(key, value gjson.Result) bool {
|
|
role := value.Get("role").String()
|
|
parts := value.Get("parts")
|
|
|
|
// Check if this content has function responses
|
|
var responsePartsInThisContent []gjson.Result
|
|
parts.ForEach(func(_, part gjson.Result) bool {
|
|
if part.Get("functionResponse").Exists() {
|
|
responsePartsInThisContent = append(responsePartsInThisContent, part)
|
|
}
|
|
return true
|
|
})
|
|
|
|
// If this content has function responses, collect them
|
|
if len(responsePartsInThisContent) > 0 {
|
|
collectedResponses = append(collectedResponses, responsePartsInThisContent...)
|
|
|
|
// Check if any pending groups can be satisfied
|
|
for i := len(pendingGroups) - 1; i >= 0; i-- {
|
|
group := pendingGroups[i]
|
|
if len(collectedResponses) >= group.ResponsesNeeded {
|
|
// Take the needed responses for this group
|
|
groupResponses := collectedResponses[:group.ResponsesNeeded]
|
|
collectedResponses = collectedResponses[group.ResponsesNeeded:]
|
|
|
|
// Create merged function response content
|
|
var responseParts []interface{}
|
|
for _, response := range groupResponses {
|
|
var responseMap map[string]interface{}
|
|
errUnmarshal := json.Unmarshal([]byte(response.Raw), &responseMap)
|
|
if errUnmarshal != nil {
|
|
log.Warnf("failed to unmarshal function response: %v\n", errUnmarshal)
|
|
continue
|
|
}
|
|
responseParts = append(responseParts, responseMap)
|
|
}
|
|
|
|
if len(responseParts) > 0 {
|
|
functionResponseContent := map[string]interface{}{
|
|
"parts": responseParts,
|
|
"role": "function",
|
|
}
|
|
newContents = append(newContents, functionResponseContent)
|
|
}
|
|
|
|
// Remove this group as it's been satisfied
|
|
pendingGroups = append(pendingGroups[:i], pendingGroups[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
|
|
return true // Skip adding this content, responses are merged
|
|
}
|
|
|
|
// If this is a model with function calls, create a new group
|
|
if role == "model" {
|
|
var functionCallsInThisModel []gjson.Result
|
|
parts.ForEach(func(_, part gjson.Result) bool {
|
|
if part.Get("functionCall").Exists() {
|
|
functionCallsInThisModel = append(functionCallsInThisModel, part)
|
|
}
|
|
return true
|
|
})
|
|
|
|
if len(functionCallsInThisModel) > 0 {
|
|
// Add the model content
|
|
var contentMap map[string]interface{}
|
|
errUnmarshal := json.Unmarshal([]byte(value.Raw), &contentMap)
|
|
if errUnmarshal != nil {
|
|
log.Warnf("failed to unmarshal model content: %v\n", errUnmarshal)
|
|
return true
|
|
}
|
|
newContents = append(newContents, contentMap)
|
|
|
|
// Create a new group for tracking responses
|
|
group := &FunctionCallGroup{
|
|
ModelContent: contentMap,
|
|
FunctionCalls: functionCallsInThisModel,
|
|
ResponsesNeeded: len(functionCallsInThisModel),
|
|
}
|
|
pendingGroups = append(pendingGroups, group)
|
|
} else {
|
|
// Regular model content without function calls
|
|
var contentMap map[string]interface{}
|
|
errUnmarshal := json.Unmarshal([]byte(value.Raw), &contentMap)
|
|
if errUnmarshal != nil {
|
|
log.Warnf("failed to unmarshal content: %v\n", errUnmarshal)
|
|
return true
|
|
}
|
|
newContents = append(newContents, contentMap)
|
|
}
|
|
} else {
|
|
// Non-model content (user, etc.)
|
|
var contentMap map[string]interface{}
|
|
errUnmarshal := json.Unmarshal([]byte(value.Raw), &contentMap)
|
|
if errUnmarshal != nil {
|
|
log.Warnf("failed to unmarshal content: %v\n", errUnmarshal)
|
|
return true
|
|
}
|
|
newContents = append(newContents, contentMap)
|
|
}
|
|
|
|
return true
|
|
})
|
|
|
|
// Handle any remaining pending groups with remaining responses
|
|
for _, group := range pendingGroups {
|
|
if len(collectedResponses) >= group.ResponsesNeeded {
|
|
groupResponses := collectedResponses[:group.ResponsesNeeded]
|
|
collectedResponses = collectedResponses[group.ResponsesNeeded:]
|
|
|
|
var responseParts []interface{}
|
|
for _, response := range groupResponses {
|
|
var responseMap map[string]interface{}
|
|
errUnmarshal := json.Unmarshal([]byte(response.Raw), &responseMap)
|
|
if errUnmarshal != nil {
|
|
log.Warnf("failed to unmarshal function response: %v\n", errUnmarshal)
|
|
continue
|
|
}
|
|
responseParts = append(responseParts, responseMap)
|
|
}
|
|
|
|
if len(responseParts) > 0 {
|
|
functionResponseContent := map[string]interface{}{
|
|
"parts": responseParts,
|
|
"role": "function",
|
|
}
|
|
newContents = append(newContents, functionResponseContent)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update the original JSON with the new contents
|
|
result := input
|
|
newContentsJSON, _ := json.Marshal(newContents)
|
|
result, _ = sjson.Set(result, "request.contents", json.RawMessage(newContentsJSON))
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func PrepareClaudeRequest(rawJson []byte) (string, *client.Content, []client.Content, []client.ToolDeclaration) {
|
|
var pathsToDelete []string
|
|
root := gjson.ParseBytes(rawJson)
|
|
walk(root, "", "additionalProperties", &pathsToDelete)
|
|
walk(root, "", "$schema", &pathsToDelete)
|
|
|
|
var err error
|
|
for _, p := range pathsToDelete {
|
|
rawJson, err = sjson.DeleteBytes(rawJson, p)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
}
|
|
rawJson = bytes.Replace(rawJson, []byte(`"url":{"type":"string","format":"uri",`), []byte(`"url":{"type":"string",`), -1)
|
|
|
|
// log.Debug(string(rawJson))
|
|
modelName := "gemini-2.5-pro"
|
|
modelResult := gjson.GetBytes(rawJson, "model")
|
|
if modelResult.Type == gjson.String {
|
|
modelName = modelResult.String()
|
|
}
|
|
|
|
contents := make([]client.Content, 0)
|
|
|
|
var systemInstruction *client.Content
|
|
|
|
systemResult := gjson.GetBytes(rawJson, "system")
|
|
if systemResult.IsArray() {
|
|
systemResults := systemResult.Array()
|
|
systemInstruction = &client.Content{Role: "user", Parts: []client.Part{}}
|
|
for i := 0; i < len(systemResults); i++ {
|
|
systemPromptResult := systemResults[i]
|
|
systemTypePromptResult := systemPromptResult.Get("type")
|
|
if systemTypePromptResult.Type == gjson.String && systemTypePromptResult.String() == "text" {
|
|
systemPrompt := systemPromptResult.Get("text").String()
|
|
systemPart := client.Part{Text: systemPrompt}
|
|
systemInstruction.Parts = append(systemInstruction.Parts, systemPart)
|
|
}
|
|
}
|
|
if len(systemInstruction.Parts) == 0 {
|
|
systemInstruction = nil
|
|
}
|
|
}
|
|
|
|
messagesResult := gjson.GetBytes(rawJson, "messages")
|
|
if messagesResult.IsArray() {
|
|
messageResults := messagesResult.Array()
|
|
for i := 0; i < len(messageResults); i++ {
|
|
messageResult := messageResults[i]
|
|
roleResult := messageResult.Get("role")
|
|
if roleResult.Type != gjson.String {
|
|
continue
|
|
}
|
|
role := roleResult.String()
|
|
if role == "assistant" {
|
|
role = "model"
|
|
}
|
|
clientContent := client.Content{Role: role, Parts: []client.Part{}}
|
|
|
|
contentsResult := messageResult.Get("content")
|
|
if contentsResult.IsArray() {
|
|
contentResults := contentsResult.Array()
|
|
for j := 0; j < len(contentResults); j++ {
|
|
contentResult := contentResults[j]
|
|
contentTypeResult := contentResult.Get("type")
|
|
if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "text" {
|
|
prompt := contentResult.Get("text").String()
|
|
clientContent.Parts = append(clientContent.Parts, client.Part{Text: prompt})
|
|
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_use" {
|
|
functionName := contentResult.Get("name").String()
|
|
functionArgs := contentResult.Get("input").String()
|
|
var args map[string]any
|
|
if err = json.Unmarshal([]byte(functionArgs), &args); err == nil {
|
|
clientContent.Parts = append(clientContent.Parts, client.Part{
|
|
FunctionCall: &client.FunctionCall{
|
|
Name: functionName,
|
|
Args: args,
|
|
},
|
|
})
|
|
}
|
|
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_result" {
|
|
toolCallID := contentResult.Get("tool_use_id").String()
|
|
if toolCallID != "" {
|
|
funcName := toolCallID
|
|
toolCallIDs := strings.Split(toolCallID, "-")
|
|
if len(toolCallIDs) > 1 {
|
|
funcName = strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-")
|
|
}
|
|
responseData := contentResult.Get("content").String()
|
|
functionResponse := client.FunctionResponse{Name: funcName, Response: map[string]interface{}{"result": responseData}}
|
|
clientContent.Parts = append(clientContent.Parts, client.Part{FunctionResponse: &functionResponse})
|
|
}
|
|
}
|
|
}
|
|
contents = append(contents, clientContent)
|
|
} else if contentsResult.Type == gjson.String {
|
|
prompt := contentsResult.String()
|
|
contents = append(contents, client.Content{Role: role, Parts: []client.Part{{Text: prompt}}})
|
|
}
|
|
}
|
|
}
|
|
|
|
var tools []client.ToolDeclaration
|
|
toolsResult := gjson.GetBytes(rawJson, "tools")
|
|
if toolsResult.IsArray() {
|
|
tools = make([]client.ToolDeclaration, 1)
|
|
tools[0].FunctionDeclarations = make([]any, 0)
|
|
toolsResults := toolsResult.Array()
|
|
for i := 0; i < len(toolsResults); i++ {
|
|
toolResult := toolsResults[i]
|
|
inputSchemaResult := toolResult.Get("input_schema")
|
|
if inputSchemaResult.Exists() && inputSchemaResult.IsObject() {
|
|
inputSchema := inputSchemaResult.Raw
|
|
inputSchema, _ = sjson.Delete(inputSchema, "additionalProperties")
|
|
inputSchema, _ = sjson.Delete(inputSchema, "$schema")
|
|
|
|
tool, _ := sjson.Delete(toolResult.Raw, "input_schema")
|
|
tool, _ = sjson.SetRaw(tool, "parameters", inputSchema)
|
|
var toolDeclaration any
|
|
if err = json.Unmarshal([]byte(tool), &toolDeclaration); err == nil {
|
|
tools[0].FunctionDeclarations = append(tools[0].FunctionDeclarations, toolDeclaration)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
tools = make([]client.ToolDeclaration, 0)
|
|
}
|
|
|
|
return modelName, systemInstruction, contents, tools
|
|
}
|
|
|
|
func walk(value gjson.Result, path, field string, pathsToDelete *[]string) {
|
|
switch value.Type {
|
|
case gjson.JSON:
|
|
value.ForEach(func(key, val gjson.Result) bool {
|
|
var childPath string
|
|
if path == "" {
|
|
childPath = key.String()
|
|
} else {
|
|
childPath = path + "." + key.String()
|
|
}
|
|
if key.String() == field {
|
|
*pathsToDelete = append(*pathsToDelete, childPath)
|
|
}
|
|
walk(val, childPath, field, pathsToDelete)
|
|
return true
|
|
})
|
|
case gjson.String, gjson.Number, gjson.True, gjson.False, gjson.Null:
|
|
}
|
|
}
|