Numerous Comments Added and Extensive Optimization Performed using Roo-Code with CLIProxyAPI itself.

This commit is contained in:
Luis Pater
2025-07-04 18:44:55 +08:00
parent 8dd7f8e82f
commit 5ec6450c50
15 changed files with 629 additions and 559 deletions

View File

@@ -2,14 +2,12 @@ package api
import (
"context"
"encoding/json"
"fmt"
"github.com/luispater/CLIProxyAPI/internal/api/translator"
"github.com/luispater/CLIProxyAPI/internal/client"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"net/http"
"strings"
"sync"
"time"
@@ -21,13 +19,15 @@ var (
lastUsedClientIndex = 0
)
// APIHandlers contains the handlers for API endpoints
// APIHandlers contains the handlers for API endpoints.
// It holds a pool of clients to interact with the backend service.
type APIHandlers struct {
cliClients []*client.Client
debug bool
}
// NewAPIHandlers creates a new API handlers instance
// NewAPIHandlers creates a new API handlers instance.
// It takes a slice of clients and a debug flag as input.
func NewAPIHandlers(cliClients []*client.Client, debug bool) *APIHandlers {
return &APIHandlers{
cliClients: cliClients,
@@ -35,6 +35,8 @@ func NewAPIHandlers(cliClients []*client.Client, debug bool) *APIHandlers {
}
}
// Models handles the /v1/models endpoint.
// It returns a hardcoded list of available AI models.
func (h *APIHandlers) Models(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"data": []map[string]any{
@@ -162,15 +164,23 @@ func (h *APIHandlers) Models(c *gin.Context) {
})
}
// ChatCompletions handles the /v1/chat/completions endpoint
// ChatCompletions handles the /v1/chat/completions endpoint.
// It determines whether the request is for a streaming or non-streaming response
// and calls the appropriate handler.
func (h *APIHandlers) ChatCompletions(c *gin.Context) {
rawJson, err := c.GetRawData()
// If data retrieval fails, return 400 error
// If data retrieval fails, return a 400 Bad Request error.
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid request: %v", err), "code": 400})
c.JSON(http.StatusBadRequest, ErrorResponse{
Error: ErrorDetail{
Message: fmt.Sprintf("Invalid request: %v", err),
Type: "invalid_request_error",
},
})
return
}
// Check if the client requested a streaming response.
streamResult := gjson.GetBytes(rawJson, "stream")
if streamResult.Type == gjson.True {
h.handleStreamingResponse(c, rawJson)
@@ -179,184 +189,9 @@ func (h *APIHandlers) ChatCompletions(c *gin.Context) {
}
}
func (h *APIHandlers) prepareRequest(rawJson []byte) (string, []client.Content, []client.ToolDeclaration) {
// 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)
messagesResult := gjson.GetBytes(rawJson, "messages")
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 {
if roleResult.String() == "system" {
if contentResult.Type == gjson.String {
contents = append(contents, client.Content{Role: "user", Parts: []client.Part{{Text: contentResult.String()}}})
} else if contentResult.IsObject() {
contentTypeResult := contentResult.Get("type")
if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "text" {
contentTextResult := contentResult.Get("text")
if contentTextResult.Type == gjson.String {
contents = append(contents, client.Content{Role: "user", Parts: []client.Part{{Text: contentTextResult.String()}}})
contents = append(contents, client.Content{Role: "model", Parts: []client.Part{{Text: "Understood. I will follow these instructions and use my tools to assist you."}}})
}
}
}
} else if roleResult.String() == "user" {
if contentResult.Type == gjson.String {
contents = append(contents, client.Content{Role: "user", Parts: []client.Part{{Text: contentResult.String()}}})
} else if contentResult.IsObject() {
contentTypeResult := contentResult.Get("type")
if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "text" {
contentTextResult := contentResult.Get("text")
if contentTextResult.Type == gjson.String {
contents = append(contents, client.Content{Role: "user", Parts: []client.Part{{Text: contentTextResult.String()}}})
}
}
} else if contentResult.IsArray() {
contentItemResults := contentResult.Array()
parts := make([]client.Part, 0)
for j := 0; j < len(contentItemResults); j++ {
contentItemResult := contentItemResults[j]
contentTypeResult := contentItemResult.Get("type")
if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "text" {
contentTextResult := contentItemResult.Get("text")
if contentTextResult.Type == gjson.String {
parts = append(parts, client.Part{Text: contentTextResult.String()})
}
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "image_url" {
imageURLResult := contentItemResult.Get("image_url.url")
if imageURLResult.Type == gjson.String {
imageURL := imageURLResult.String()
if len(imageURL) > 5 {
imageURLs := strings.SplitN(imageURL[5:], ";", 2)
if len(imageURLs) == 2 {
if len(imageURLs[1]) > 7 {
parts = append(parts, client.Part{InlineData: &client.InlineData{
MimeType: imageURLs[0],
Data: imageURLs[1][7:],
}})
}
}
}
}
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "file" {
filenameResult := contentItemResult.Get("file.filename")
fileDataResult := contentItemResult.Get("file.file_data")
if filenameResult.Type == gjson.String && fileDataResult.Type == gjson.String {
filename := filenameResult.String()
splitFilename := strings.Split(filename, ".")
ext := splitFilename[len(splitFilename)-1]
mimeType, ok := MimeTypes[ext]
if !ok {
log.Warnf("Unknown file name extension '%s' at index %d, skipping file", ext, j)
continue
}
parts = append(parts, client.Part{InlineData: &client.InlineData{
MimeType: mimeType,
Data: fileDataResult.String(),
}})
}
}
}
contents = append(contents, client.Content{Role: "user", Parts: parts})
}
} else if roleResult.String() == "assistant" {
if contentResult.Type == gjson.String {
contents = append(contents, client.Content{Role: "model", Parts: []client.Part{{Text: contentResult.String()}}})
} else if contentResult.IsObject() {
contentTypeResult := contentResult.Get("type")
if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "text" {
contentTextResult := contentResult.Get("text")
if contentTextResult.Type == gjson.String {
contents = append(contents, client.Content{Role: "user", Parts: []client.Part{{Text: contentTextResult.String()}}})
}
}
} else if !contentResult.Exists() || contentResult.Type == gjson.Null {
toolCallsResult := messageResult.Get("tool_calls")
if toolCallsResult.IsArray() {
tcsResult := toolCallsResult.Array()
for j := 0; j < len(tcsResult); j++ {
tcResult := tcsResult[j]
functionNameResult := tcResult.Get("function.name")
functionArguments := tcResult.Get("function.arguments")
if functionNameResult.Exists() && functionNameResult.Type == gjson.String && functionArguments.Exists() && functionArguments.Type == gjson.String {
var args map[string]any
err := json.Unmarshal([]byte(functionArguments.String()), &args)
if err == nil {
contents = append(contents, client.Content{
Role: "model", Parts: []client.Part{
{
FunctionCall: &client.FunctionCall{
Name: functionNameResult.String(),
Args: args,
},
},
},
})
}
}
}
}
}
} else if roleResult.String() == "tool" {
toolCallIDResult := messageResult.Get("tool_call_id")
if toolCallIDResult.Exists() && toolCallIDResult.Type == gjson.String {
if contentResult.Type == gjson.String {
functionResponse := client.FunctionResponse{Name: toolCallIDResult.String(), Response: map[string]interface{}{"result": contentResult.String()}}
contents = append(contents, client.Content{Role: "tool", Parts: []client.Part{{FunctionResponse: &functionResponse}}})
} else if contentResult.IsObject() {
contentTypeResult := contentResult.Get("type")
if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "text" {
contentTextResult := contentResult.Get("text")
if contentTextResult.Type == gjson.String {
functionResponse := client.FunctionResponse{Name: toolCallIDResult.String(), Response: map[string]interface{}{"result": contentResult.String()}}
contents = append(contents, client.Content{Role: "tool", Parts: []client.Part{{FunctionResponse: &functionResponse}}})
}
}
}
}
}
}
}
}
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++ {
toolTypeResult := toolsResults[i].Get("type")
if toolTypeResult.Type != gjson.String || toolTypeResult.String() != "function" {
continue
}
functionTypeResult := toolsResults[i].Get("function")
if functionTypeResult.Exists() && functionTypeResult.IsObject() {
var functionDeclaration any
err := json.Unmarshal([]byte(functionTypeResult.Raw), &functionDeclaration)
if err == nil {
tools[0].FunctionDeclarations = append(tools[0].FunctionDeclarations, functionDeclaration)
}
}
}
} else {
tools = make([]client.ToolDeclaration, 0)
}
return modelName, contents, tools
}
// handleNonStreamingResponse handles non-streaming responses
// handleNonStreamingResponse handles non-streaming chat completion responses.
// It selects a client from the pool, sends the request, and aggregates the response
// before sending it back to the client.
func (h *APIHandlers) handleNonStreamingResponse(c *gin.Context, rawJson []byte) {
c.Header("Content-Type", "application/json")
@@ -372,7 +207,7 @@ func (h *APIHandlers) handleNonStreamingResponse(c *gin.Context, rawJson []byte)
return
}
modelName, contents, tools := h.prepareRequest(rawJson)
modelName, contents, tools := translator.PrepareRequest(rawJson)
cliCtx, cliCancel := context.WithCancel(context.Background())
var cliClient *client.Client
defer func() {
@@ -425,19 +260,13 @@ func (h *APIHandlers) handleNonStreamingResponse(c *gin.Context, rawJson []byte)
cliCancel()
return
} else {
jsonTemplate = h.convertCliToOpenAINonStream(jsonTemplate, chunk)
jsonTemplate = translator.ConvertCliToOpenAINonStream(jsonTemplate, chunk)
}
case err, okError := <-errChan:
if okError {
c.Status(err.StatusCode)
_, _ = fmt.Fprint(c.Writer, err.Error.Error())
flusher.Flush()
// c.JSON(http.StatusInternalServerError, ErrorResponse{
// Error: ErrorDetail{
// Message: err.Error(),
// Type: "server_error",
// },
// })
cliCancel()
return
}
@@ -455,7 +284,7 @@ func (h *APIHandlers) handleStreamingResponse(c *gin.Context, rawJson []byte) {
c.Header("Connection", "keep-alive")
c.Header("Access-Control-Allow-Origin", "*")
// Handle streaming manually
// Get the http.Flusher interface to manually flush the response.
flusher, ok := c.Writer.(http.Flusher)
if !ok {
c.JSON(http.StatusInternalServerError, ErrorResponse{
@@ -466,28 +295,33 @@ func (h *APIHandlers) handleStreamingResponse(c *gin.Context, rawJson []byte) {
})
return
}
modelName, contents, tools := h.prepareRequest(rawJson)
// Prepare the request for the backend client.
modelName, contents, tools := translator.PrepareRequest(rawJson)
cliCtx, cliCancel := context.WithCancel(context.Background())
var cliClient *client.Client
defer func() {
// Ensure the client's mutex is unlocked on function exit.
if cliClient != nil {
cliClient.RequestMutex.Unlock()
}
}()
// Lock the mutex to update the last used page index
// Use a round-robin approach to select the next available client.
// This distributes the load among the available clients.
mutex.Lock()
startIndex := lastUsedClientIndex
currentIndex := (startIndex + 1) % len(h.cliClients)
lastUsedClientIndex = currentIndex
mutex.Unlock()
// Reorder the pages to start from the last used index
// Reorder the clients to start from the next client in the rotation.
reorderedPages := make([]*client.Client, len(h.cliClients))
for i := 0; i < len(h.cliClients); i++ {
reorderedPages[i] = h.cliClients[(startIndex+1+i)%len(h.cliClients)]
}
// Attempt to lock a client for the request.
locked := false
for i := 0; i < len(reorderedPages); i++ {
cliClient = reorderedPages[i]
@@ -496,235 +330,52 @@ func (h *APIHandlers) handleStreamingResponse(c *gin.Context, rawJson []byte) {
break
}
}
// If no client is available, block and wait for the first client.
if !locked {
cliClient = h.cliClients[0]
cliClient.RequestMutex.Lock()
}
log.Debugf("Request use account: %s, project id: %s", cliClient.GetEmail(), cliClient.GetProjectID())
// Send the message and receive response chunks and errors via channels.
respChan, errChan := cliClient.SendMessageStream(cliCtx, rawJson, modelName, contents, tools)
for {
select {
// Handle client disconnection.
case <-c.Request.Context().Done():
if c.Request.Context().Err().Error() == "context canceled" {
log.Debugf("Client disconnected: %v", c.Request.Context().Err())
cliCancel()
cliCancel() // Cancel the backend request.
return
}
// Process incoming response chunks.
case chunk, okStream := <-respChan:
if !okStream {
// Stream is closed, send the final [DONE] message.
_, _ = fmt.Fprintf(c.Writer, "data: [DONE]\n\n")
flusher.Flush()
cliCancel()
return
} else {
openAIFormat := h.convertCliToOpenAI(chunk)
// Convert the chunk to OpenAI format and send it to the client.
openAIFormat := translator.ConvertCliToOpenAI(chunk)
if openAIFormat != "" {
_, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", openAIFormat)
flusher.Flush()
}
}
// Handle errors from the backend.
case err, okError := <-errChan:
if okError {
c.Status(err.StatusCode)
_, _ = fmt.Fprint(c.Writer, err.Error.Error())
flusher.Flush()
// c.JSON(http.StatusInternalServerError, ErrorResponse{
// Error: ErrorDetail{
// Message: err.Error(),
// Type: "server_error",
// },
// })
cliCancel()
return
}
// Send a keep-alive signal to the client.
case <-time.After(500 * time.Millisecond):
_, _ = c.Writer.Write([]byte(": CLI-PROXY-API PROCESSING\n\n"))
flusher.Flush()
}
}
}
func (h *APIHandlers) convertCliToOpenAI(rawJson []byte) string {
// log.Debugf(string(rawJson))
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}]}`
modelVersionResult := gjson.GetBytes(rawJson, "response.modelVersion")
if modelVersionResult.Exists() && modelVersionResult.Type == gjson.String {
template, _ = sjson.Set(template, "model", modelVersionResult.String())
}
createTimeResult := gjson.GetBytes(rawJson, "response.createTime")
if createTimeResult.Exists() && createTimeResult.Type == gjson.String {
t, err := time.Parse(time.RFC3339Nano, createTimeResult.String())
var unixTimestamp int64
if err == nil {
unixTimestamp = t.Unix()
} else {
unixTimestamp = time.Now().Unix()
}
template, _ = sjson.Set(template, "created", unixTimestamp)
}
responseIdResult := gjson.GetBytes(rawJson, "response.responseId")
if responseIdResult.Exists() && responseIdResult.Type == gjson.String {
template, _ = sjson.Set(template, "id", responseIdResult.String())
}
finishReasonResult := gjson.GetBytes(rawJson, "response.candidates.0.finishReason")
if finishReasonResult.Exists() && finishReasonResult.Type == gjson.String {
template, _ = sjson.Set(template, "choices.0.finish_reason", finishReasonResult.String())
template, _ = sjson.Set(template, "choices.0.native_finish_reason", finishReasonResult.String())
}
usageResult := gjson.GetBytes(rawJson, "response.usageMetadata")
candidatesTokenCountResult := usageResult.Get("candidatesTokenCount")
if candidatesTokenCountResult.Exists() && candidatesTokenCountResult.Type == gjson.Number {
template, _ = sjson.Set(template, "usage.completion_tokens", candidatesTokenCountResult.Int())
}
totalTokenCountResult := usageResult.Get("totalTokenCount")
if totalTokenCountResult.Exists() && totalTokenCountResult.Type == gjson.Number {
template, _ = sjson.Set(template, "usage.total_tokens", totalTokenCountResult.Int())
}
thoughtsTokenCountResult := usageResult.Get("thoughtsTokenCount")
promptTokenCountResult := usageResult.Get("promptTokenCount")
if promptTokenCountResult.Exists() && promptTokenCountResult.Type == gjson.Number {
if thoughtsTokenCountResult.Exists() && thoughtsTokenCountResult.Type == gjson.Number {
template, _ = sjson.Set(template, "usage.prompt_tokens", promptTokenCountResult.Int()+thoughtsTokenCountResult.Int())
} else {
template, _ = sjson.Set(template, "usage.prompt_tokens", promptTokenCountResult.Int())
}
}
if thoughtsTokenCountResult.Exists() && thoughtsTokenCountResult.Type == gjson.Number {
template, _ = sjson.Set(template, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCountResult.Int())
}
partResult := gjson.GetBytes(rawJson, "response.candidates.0.content.parts.0")
partTextResult := partResult.Get("text")
functionCallResult := partResult.Get("functionCall")
if partTextResult.Exists() && partTextResult.Type == gjson.String {
partThoughtResult := partResult.Get("thought")
if partThoughtResult.Exists() && partThoughtResult.Type == gjson.True {
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() {
functionCallTemplate := `[{"id": "","type": "function","function": {"name": "","arguments": ""}}]`
fcNameResult := functionCallResult.Get("name")
if fcNameResult.Exists() && fcNameResult.Type == gjson.String {
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "0.id", fcNameResult.String())
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "0.function.name", fcNameResult.String())
}
fcArgsResult := functionCallResult.Get("args")
if fcArgsResult.Exists() && fcArgsResult.IsObject() {
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "0.function.arguments", fcArgsResult.Raw)
}
template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls", functionCallTemplate)
} else {
return ""
}
return template
}
func (h *APIHandlers) convertCliToOpenAINonStream(template string, rawJson []byte) string {
modelVersionResult := gjson.GetBytes(rawJson, "response.modelVersion")
if modelVersionResult.Exists() && modelVersionResult.Type == gjson.String {
template, _ = sjson.Set(template, "model", modelVersionResult.String())
}
createTimeResult := gjson.GetBytes(rawJson, "response.createTime")
if createTimeResult.Exists() && createTimeResult.Type == gjson.String {
t, err := time.Parse(time.RFC3339Nano, createTimeResult.String())
var unixTimestamp int64
if err == nil {
unixTimestamp = t.Unix()
} else {
unixTimestamp = time.Now().Unix()
}
template, _ = sjson.Set(template, "created", unixTimestamp)
}
responseIdResult := gjson.GetBytes(rawJson, "response.responseId")
if responseIdResult.Exists() && responseIdResult.Type == gjson.String {
template, _ = sjson.Set(template, "id", responseIdResult.String())
}
finishReasonResult := gjson.GetBytes(rawJson, "response.candidates.0.finishReason")
if finishReasonResult.Exists() && finishReasonResult.Type == gjson.String {
template, _ = sjson.Set(template, "choices.0.finish_reason", finishReasonResult.String())
template, _ = sjson.Set(template, "choices.0.native_finish_reason", finishReasonResult.String())
}
usageResult := gjson.GetBytes(rawJson, "response.usageMetadata")
candidatesTokenCountResult := usageResult.Get("candidatesTokenCount")
if candidatesTokenCountResult.Exists() && candidatesTokenCountResult.Type == gjson.Number {
template, _ = sjson.Set(template, "usage.completion_tokens", candidatesTokenCountResult.Int())
}
totalTokenCountResult := usageResult.Get("totalTokenCount")
if totalTokenCountResult.Exists() && totalTokenCountResult.Type == gjson.Number {
template, _ = sjson.Set(template, "usage.total_tokens", totalTokenCountResult.Int())
}
thoughtsTokenCountResult := usageResult.Get("thoughtsTokenCount")
promptTokenCountResult := usageResult.Get("promptTokenCount")
if promptTokenCountResult.Exists() && promptTokenCountResult.Type == gjson.Number {
if thoughtsTokenCountResult.Exists() && thoughtsTokenCountResult.Type == gjson.Number {
template, _ = sjson.Set(template, "usage.prompt_tokens", promptTokenCountResult.Int()+thoughtsTokenCountResult.Int())
} else {
template, _ = sjson.Set(template, "usage.prompt_tokens", promptTokenCountResult.Int())
}
}
if thoughtsTokenCountResult.Exists() && thoughtsTokenCountResult.Type == gjson.Number {
template, _ = sjson.Set(template, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCountResult.Int())
}
partResult := gjson.GetBytes(rawJson, "response.candidates.0.content.parts.0")
partTextResult := partResult.Get("text")
functionCallResult := partResult.Get("functionCall")
if partTextResult.Exists() && partTextResult.Type == gjson.String {
partThoughtResult := partResult.Get("thought")
if partThoughtResult.Exists() && partThoughtResult.Type == gjson.True {
reasoningContentResult := gjson.Get(template, "choices.0.message.reasoning_content")
if reasoningContentResult.Type == gjson.String {
template, _ = sjson.Set(template, "choices.0.message.reasoning_content", reasoningContentResult.String()+partTextResult.String())
} else {
template, _ = sjson.Set(template, "choices.0.message.reasoning_content", partTextResult.String())
}
} else {
reasoningContentResult := gjson.Get(template, "choices.0.message.content")
if reasoningContentResult.Type == gjson.String {
template, _ = sjson.Set(template, "choices.0.message.content", reasoningContentResult.String()+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() {
toolCallsResult := gjson.Get(template, "choices.0.message.tool_calls")
if !toolCallsResult.Exists() || toolCallsResult.Type == gjson.Null {
template, _ = sjson.SetRaw(template, "choices.0.message.tool_calls", `[]`)
}
functionCallItemTemplate := `{"id": "","type": "function","function": {"name": "","arguments": ""}}`
fcNameResult := functionCallResult.Get("name")
if fcNameResult.Exists() && fcNameResult.Type == gjson.String {
functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "id", fcNameResult.String())
functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "function.name", fcNameResult.String())
}
fcArgsResult := functionCallResult.Get("args")
if fcArgsResult.Exists() && fcArgsResult.IsObject() {
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 {
return ""
}
return template
}

View File

@@ -1,13 +1,18 @@
package api
// ErrorResponse represents an error response
// ErrorResponse represents a standard error response format for the API.
// It contains a single ErrorDetail field.
type ErrorResponse struct {
Error ErrorDetail `json:"error"`
}
// ErrorDetail represents error details
// ErrorDetail provides specific information about an error that occurred.
// It includes a human-readable message, an error type, and an optional error code.
type ErrorDetail struct {
// A human-readable message providing more details about the error.
Message string `json:"message"`
Type string `json:"type"`
Code string `json:"code,omitempty"`
// The type of error that occurred (e.g., "invalid_request_error").
Type string `json:"type"`
// A short code identifying the error, if applicable.
Code string `json:"code,omitempty"`
}

View File

@@ -11,7 +11,8 @@ import (
"strings"
)
// Server represents the API server
// Server represents the main API server.
// It encapsulates the Gin engine, HTTP server, handlers, and configuration.
type Server struct {
engine *gin.Engine
server *http.Server
@@ -19,14 +20,18 @@ type Server struct {
cfg *ServerConfig
}
// ServerConfig contains configuration for the API server
// ServerConfig contains the configuration for the API server.
type ServerConfig struct {
Port string
Debug bool
// Port is the port number the server will listen on.
Port string
// Debug enables or disables debug mode for the server and Gin.
Debug bool
// ApiKeys is a list of valid API keys for authentication.
ApiKeys []string
}
// NewServer creates a new API server instance
// NewServer creates and initializes a new API server instance.
// It sets up the Gin engine, middleware, routes, and handlers.
func NewServer(config *ServerConfig, cliClients []*client.Client) *Server {
// Set gin mode
if !config.Debug {
@@ -63,7 +68,8 @@ func NewServer(config *ServerConfig, cliClients []*client.Client) *Server {
return s
}
// setupRoutes configures the API routes
// setupRoutes configures the API routes for the server.
// It defines the endpoints and associates them with their respective handlers.
func (s *Server) setupRoutes() {
// OpenAI compatible API routes
v1 := s.engine.Group("/v1")
@@ -86,11 +92,12 @@ func (s *Server) setupRoutes() {
})
}
// Start starts the API server
// Start begins listening for and serving HTTP requests.
// It's a blocking call and will only return on an unrecoverable error.
func (s *Server) Start() error {
log.Debugf("Starting API server on %s", s.server.Addr)
// Start the HTTP server
// Start the HTTP server.
if err := s.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("failed to start HTTP server: %v", err)
}
@@ -98,11 +105,12 @@ func (s *Server) Start() error {
return nil
}
// Stop gracefully stops the API server
// Stop gracefully shuts down the API server without interrupting any
// active connections.
func (s *Server) Stop(ctx context.Context) error {
log.Debug("Stopping API server...")
// Shutdown the HTTP server
// Shutdown the HTTP server.
if err := s.server.Shutdown(ctx); err != nil {
return fmt.Errorf("failed to shutdown HTTP server: %v", err)
}
@@ -111,7 +119,8 @@ func (s *Server) Stop(ctx context.Context) error {
return nil
}
// corsMiddleware adds CORS headers
// corsMiddleware returns a Gin middleware handler that adds CORS headers
// to every response, allowing cross-origin requests.
func corsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
@@ -127,7 +136,8 @@ func corsMiddleware() gin.HandlerFunc {
}
}
// AuthMiddleware authenticates requests using API keys
// AuthMiddleware returns a Gin middleware handler that authenticates requests
// using API keys. If no API keys are configured, it allows all requests.
func AuthMiddleware(cfg *ServerConfig) gin.HandlerFunc {
return func(c *gin.Context) {
if len(cfg.ApiKeys) == 0 {

View File

@@ -1,5 +1,7 @@
package api
package translator
// MimeTypes is a comprehensive map of file extensions to their corresponding MIME types.
// This is used to identify the type of file being uploaded or processed.
var MimeTypes = map[string]string{
"ez": "application/andrew-inset",
"aw": "application/applixware",

View File

@@ -0,0 +1,163 @@
package translator
import (
"encoding/json"
"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.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()
}
// Process the array of messages.
contents := make([]client.Content, 0)
messagesResult := gjson.GetBytes(rawJson, "messages")
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 {
contents = append(contents, client.Content{Role: "user", Parts: []client.Part{{Text: contentResult.String()}}})
contents = append(contents, client.Content{Role: "model", Parts: []client.Part{{Text: "Understood. I will follow these instructions and use my tools to assist you."}}})
} else if contentResult.IsObject() {
// Handle object-based system messages.
if contentResult.Get("type").String() == "text" {
contents = append(contents, client.Content{Role: "user", Parts: []client.Part{{Text: contentResult.Get("text").String()}}})
contents = append(contents, client.Content{Role: "model", Parts: []client.Part{{Text: "Understood. I will follow these instructions and use my tools to assist you."}}})
}
}
// 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 or tool calls.
case "assistant":
if contentResult.Type == gjson.String {
contents = append(contents, client.Content{Role: "model", Parts: []client.Part{{Text: contentResult.String()}}})
} else if !contentResult.Exists() || contentResult.Type == gjson.Null {
// Handle tool calls made by the assistant.
toolCallsResult := messageResult.Get("tool_calls")
if toolCallsResult.IsArray() {
tcsResult := toolCallsResult.Array()
for j := 0; j < len(tcsResult); j++ {
tcResult := tcsResult[j]
functionName := tcResult.Get("function.name").String()
functionArgs := tcResult.Get("function.arguments").String()
var args map[string]any
if err := json.Unmarshal([]byte(functionArgs), &args); err == nil {
contents = append(contents, client.Content{
Role: "model", Parts: []client.Part{{
FunctionCall: &client.FunctionCall{
Name: functionName,
Args: args,
},
}},
})
}
}
}
}
// Tool messages contain the output of a tool call.
case "tool":
toolCallID := messageResult.Get("tool_call_id").String()
if toolCallID != "" {
var responseData string
if contentResult.Type == gjson.String {
responseData = contentResult.String()
} else if contentResult.IsObject() && contentResult.Get("type").String() == "text" {
responseData = contentResult.Get("text").String()
}
functionResponse := client.FunctionResponse{Name: toolCallID, Response: map[string]interface{}{"result": responseData}}
contents = append(contents, client.Content{Role: "tool", Parts: []client.Part{{FunctionResponse: &functionResponse}}})
}
}
}
}
// 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, contents, tools
}

View File

@@ -0,0 +1,169 @@
package translator
import (
"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) string {
// 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())
unixTimestamp := time.Now().Unix()
if err == nil {
unixTimestamp = t.Unix()
}
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.
partResult := gjson.GetBytes(rawJson, "response.candidates.0.content.parts.0")
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.
functionCallTemplate := `[{"id": "","type": "function","function": {"name": "","arguments": ""}}]`
fcName := functionCallResult.Get("name").String()
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "0.id", fcName)
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "0.function.name", fcName)
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "0.function.arguments", fcArgsResult.Raw)
}
template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls", functionCallTemplate)
} else {
// If no usable content is found, return an empty string.
return ""
}
return template
}
// ConvertCliToOpenAINonStream aggregates response chunks from the backend client
// into a single, non-streaming OpenAI-compatible JSON response.
func ConvertCliToOpenAINonStream(template string, rawJson []byte) string {
// Extract and set metadata fields that are typically set once per response.
if gjson.Get(template, "id").String() == "" {
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())
unixTimestamp := time.Now().Unix()
if err == nil {
unixTimestamp = t.Unix()
}
template, _ = sjson.Set(template, "created", unixTimestamp)
}
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.
partResult := gjson.GetBytes(rawJson, "response.candidates.0.content.parts.0")
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() {
currentContent := gjson.Get(template, "choices.0.message.reasoning_content").String()
template, _ = sjson.Set(template, "choices.0.message.reasoning_content", currentContent+partTextResult.String())
} else {
currentContent := gjson.Get(template, "choices.0.message.content").String()
template, _ = sjson.Set(template, "choices.0.message.content", currentContent+partTextResult.String())
}
template, _ = sjson.Set(template, "choices.0.message.role", "assistant")
} else if functionCallResult.Exists() {
// Append function call content to the tool_calls array.
if !gjson.Get(template, "choices.0.message.tool_calls").Exists() {
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", fcName)
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
}