This commit replaces the Axum/Rust backend with a Gin/Go implementation. The original Rust code has been archived in the 'rust' branch.
260 lines
5.9 KiB
Go
260 lines
5.9 KiB
Go
package providers
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"llm-proxy/internal/models"
|
|
)
|
|
|
|
// MessagesToOpenAIJSON converts unified messages to OpenAI-compatible JSON, including tools and images.
|
|
func MessagesToOpenAIJSON(messages []models.UnifiedMessage) ([]interface{}, error) {
|
|
var result []interface{}
|
|
for _, m := range messages {
|
|
if m.Role == "tool" {
|
|
text := ""
|
|
if len(m.Content) > 0 {
|
|
text = m.Content[0].Text
|
|
}
|
|
msg := map[string]interface{}{
|
|
"role": "tool",
|
|
"content": text,
|
|
}
|
|
if m.ToolCallID != nil {
|
|
id := *m.ToolCallID
|
|
if len(id) > 40 {
|
|
id = id[:40]
|
|
}
|
|
msg["tool_call_id"] = id
|
|
}
|
|
if m.Name != nil {
|
|
msg["name"] = *m.Name
|
|
}
|
|
result = append(result, msg)
|
|
continue
|
|
}
|
|
|
|
var parts []interface{}
|
|
for _, p := range m.Content {
|
|
if p.Type == "text" {
|
|
parts = append(parts, map[string]interface{}{
|
|
"type": "text",
|
|
"text": p.Text,
|
|
})
|
|
} else if p.Image != nil {
|
|
base64Data, mimeType, err := p.Image.ToBase64()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to convert image to base64: %w", err)
|
|
}
|
|
parts = append(parts, map[string]interface{}{
|
|
"type": "image_url",
|
|
"image_url": map[string]interface{}{
|
|
"url": fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data),
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
msg := map[string]interface{}{
|
|
"role": m.Role,
|
|
"content": parts,
|
|
}
|
|
|
|
if m.ReasoningContent != nil {
|
|
msg["reasoning_content"] = *m.ReasoningContent
|
|
}
|
|
|
|
if len(m.ToolCalls) > 0 {
|
|
sanitizedCalls := make([]models.ToolCall, len(m.ToolCalls))
|
|
copy(sanitizedCalls, m.ToolCalls)
|
|
for i := range sanitizedCalls {
|
|
if len(sanitizedCalls[i].ID) > 40 {
|
|
sanitizedCalls[i].ID = sanitizedCalls[i].ID[:40]
|
|
}
|
|
}
|
|
msg["tool_calls"] = sanitizedCalls
|
|
if len(parts) == 0 {
|
|
msg["content"] = ""
|
|
}
|
|
}
|
|
|
|
if m.Name != nil {
|
|
msg["name"] = *m.Name
|
|
}
|
|
|
|
result = append(result, msg)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func BuildOpenAIBody(request *models.UnifiedRequest, messagesJSON []interface{}, stream bool) map[string]interface{} {
|
|
body := map[string]interface{}{
|
|
"model": request.Model,
|
|
"messages": messagesJSON,
|
|
"stream": stream,
|
|
}
|
|
|
|
if stream {
|
|
body["stream_options"] = map[string]interface{}{
|
|
"include_usage": true,
|
|
}
|
|
}
|
|
|
|
if request.Temperature != nil {
|
|
body["temperature"] = *request.Temperature
|
|
}
|
|
if request.MaxTokens != nil {
|
|
body["max_tokens"] = *request.MaxTokens
|
|
}
|
|
if len(request.Tools) > 0 {
|
|
body["tools"] = request.Tools
|
|
}
|
|
if request.ToolChoice != nil {
|
|
var toolChoice interface{}
|
|
if err := json.Unmarshal(request.ToolChoice, &toolChoice); err == nil {
|
|
body["tool_choice"] = toolChoice
|
|
}
|
|
}
|
|
|
|
return body
|
|
}
|
|
|
|
func ParseOpenAIResponse(respJSON map[string]interface{}, model string) (*models.ChatCompletionResponse, error) {
|
|
data, err := json.Marshal(respJSON)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var resp models.ChatCompletionResponse
|
|
if err := json.Unmarshal(data, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &resp, nil
|
|
}
|
|
|
|
// Streaming support
|
|
|
|
func ParseOpenAIStreamChunk(line string) (*models.ChatCompletionStreamResponse, bool, error) {
|
|
if line == "" {
|
|
return nil, false, nil
|
|
}
|
|
if !strings.HasPrefix(line, "data: ") {
|
|
return nil, false, nil
|
|
}
|
|
|
|
data := strings.TrimPrefix(line, "data: ")
|
|
if data == "[DONE]" {
|
|
return nil, true, nil
|
|
}
|
|
|
|
var chunk models.ChatCompletionStreamResponse
|
|
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
|
return nil, false, fmt.Errorf("failed to unmarshal stream chunk: %w", err)
|
|
}
|
|
|
|
return &chunk, false, nil
|
|
}
|
|
|
|
func StreamOpenAI(ctx io.ReadCloser, ch chan<- *models.ChatCompletionStreamResponse) error {
|
|
defer ctx.Close()
|
|
scanner := bufio.NewScanner(ctx)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
chunk, done, err := ParseOpenAIStreamChunk(line)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if done {
|
|
break
|
|
}
|
|
if chunk != nil {
|
|
ch <- chunk
|
|
}
|
|
}
|
|
return scanner.Err()
|
|
}
|
|
|
|
func StreamGemini(ctx io.ReadCloser, ch chan<- *models.ChatCompletionStreamResponse, model string) error {
|
|
defer ctx.Close()
|
|
|
|
dec := json.NewDecoder(ctx)
|
|
|
|
t, err := dec.Token()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if delim, ok := t.(json.Delim); ok && delim == '[' {
|
|
for dec.More() {
|
|
var geminiChunk struct {
|
|
Candidates []struct {
|
|
Content struct {
|
|
Parts []struct {
|
|
Text string `json:"text,omitempty"`
|
|
Thought string `json:"thought,omitempty"`
|
|
} `json:"parts"`
|
|
} `json:"content"`
|
|
FinishReason string `json:"finishReason"`
|
|
} `json:"candidates"`
|
|
UsageMetadata struct {
|
|
PromptTokenCount uint32 `json:"promptTokenCount"`
|
|
CandidatesTokenCount uint32 `json:"candidatesTokenCount"`
|
|
TotalTokenCount uint32 `json:"totalTokenCount"`
|
|
} `json:"usageMetadata"`
|
|
}
|
|
|
|
if err := dec.Decode(&geminiChunk); err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(geminiChunk.Candidates) > 0 {
|
|
content := ""
|
|
var reasoning *string
|
|
for _, p := range geminiChunk.Candidates[0].Content.Parts {
|
|
if p.Text != "" {
|
|
content += p.Text
|
|
}
|
|
if p.Thought != "" {
|
|
if reasoning == nil {
|
|
reasoning = new(string)
|
|
}
|
|
*reasoning += p.Thought
|
|
}
|
|
}
|
|
|
|
finishReason := strings.ToLower(geminiChunk.Candidates[0].FinishReason)
|
|
if finishReason == "stop" {
|
|
finishReason = "stop"
|
|
}
|
|
|
|
ch <- &models.ChatCompletionStreamResponse{
|
|
ID: "gemini-stream",
|
|
Object: "chat.completion.chunk",
|
|
Created: 0,
|
|
Model: model,
|
|
Choices: []models.ChatStreamChoice{
|
|
{
|
|
Index: 0,
|
|
Delta: models.ChatStreamDelta{
|
|
Content: &content,
|
|
ReasoningContent: reasoning,
|
|
},
|
|
FinishReason: &finishReason,
|
|
},
|
|
},
|
|
Usage: &models.Usage{
|
|
PromptTokens: geminiChunk.UsageMetadata.PromptTokenCount,
|
|
CompletionTokens: geminiChunk.UsageMetadata.CandidatesTokenCount,
|
|
TotalTokens: geminiChunk.UsageMetadata.TotalTokenCount,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|