Compare commits
4 Commits
a187d8e20e
...
0decc63e8c
| Author | SHA1 | Date | |
|---|---|---|---|
| 0decc63e8c | |||
| 9980123f97 | |||
| 293cf057b9 | |||
| eb90f949a0 |
@@ -19,10 +19,11 @@ type Config struct {
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int `mapstructure:"port"`
|
||||
Host string `mapstructure:"host"`
|
||||
AuthTokens []string `mapstructure:"auth_tokens"`
|
||||
WSAllowedOrigin string `mapstructure:"ws_allowed_origin"`
|
||||
Port int `mapstructure:"port"`
|
||||
Host string `mapstructure:"host"`
|
||||
AuthTokens []string `mapstructure:"auth_tokens"`
|
||||
WSAllowedOrigin string `mapstructure:"ws_allowed_origin"`
|
||||
MaxHistoryMessages int `mapstructure:"max_history_messages"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
@@ -96,6 +97,7 @@ func Load() (*Config, error) {
|
||||
v.SetDefault("server.port", 8080)
|
||||
v.SetDefault("server.host", "0.0.0.0")
|
||||
v.SetDefault("server.auth_tokens", []string{})
|
||||
v.SetDefault("server.max_history_messages", 0)
|
||||
v.SetDefault("database.path", "./data/llm_proxy.db")
|
||||
v.SetDefault("database.max_connections", 10)
|
||||
|
||||
@@ -142,6 +144,7 @@ func Load() (*Config, error) {
|
||||
v.BindEnv("encryption_key", "LLM_PROXY__ENCRYPTION_KEY")
|
||||
v.BindEnv("server.port", "LLM_PROXY__SERVER__PORT")
|
||||
v.BindEnv("server.host", "LLM_PROXY__SERVER__HOST")
|
||||
v.BindEnv("server.max_history_messages", "LLM_PROXY__SERVER__MAX_HISTORY_MESSAGES")
|
||||
v.BindEnv("providers.ollama.enabled", "LLM_PROXY__PROVIDERS__OLLAMA__ENABLED")
|
||||
v.BindEnv("providers.ollama.base_url", "LLM_PROXY__PROVIDERS__OLLAMA__BASE_URL")
|
||||
v.BindEnv("providers.ollama.models", "LLM_PROXY__PROVIDERS__OLLAMA__MODELS")
|
||||
@@ -174,6 +177,9 @@ func Load() (*Config, error) {
|
||||
cfg.Server.Host = host
|
||||
|
||||
}
|
||||
if maxHistory := os.Getenv("LLM_PROXY__SERVER__MAX_HISTORY_MESSAGES"); maxHistory != "" {
|
||||
fmt.Sscanf(maxHistory, "%d", &cfg.Server.MaxHistoryMessages)
|
||||
}
|
||||
|
||||
// Ollama overrides
|
||||
if enabled := os.Getenv("LLM_PROXY__PROVIDERS__OLLAMA__ENABLED"); enabled != "" {
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gophergate/internal/db"
|
||||
"gophergate/internal/models"
|
||||
@@ -11,6 +13,15 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type tokenCacheEntry struct {
|
||||
clientID string
|
||||
expiredAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
tokenCache sync.Map // map[string]tokenCacheEntry
|
||||
)
|
||||
|
||||
func AuthMiddleware(database *db.DB, requireAuth bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
@@ -52,11 +63,39 @@ func AuthMiddleware(database *db.DB, requireAuth bool) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Try to resolve client from database with a read-only SELECT
|
||||
// Try to resolve client from cache first
|
||||
var clientID string
|
||||
err := database.Get(&clientID, "SELECT client_id FROM client_tokens WHERE token = ? AND is_active = 1", token)
|
||||
var dbErr error
|
||||
now := time.Now()
|
||||
if cached, ok := tokenCache.Load(token); ok {
|
||||
entry := cached.(tokenCacheEntry)
|
||||
if now.Before(entry.expiredAt) {
|
||||
clientID = entry.clientID
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
// If cache miss (or expired), query database
|
||||
if clientID == "" {
|
||||
var fetchedID string
|
||||
dbErr = database.Get(&fetchedID, "SELECT client_id FROM client_tokens WHERE token = ? AND is_active = 1", token)
|
||||
if dbErr == nil {
|
||||
clientID = fetchedID
|
||||
// Cache valid token for 10 seconds
|
||||
tokenCache.Store(token, tokenCacheEntry{
|
||||
clientID: clientID,
|
||||
expiredAt: now.Add(10 * time.Second),
|
||||
})
|
||||
} else {
|
||||
// If error (invalid/inactive token), cache negative result for 2 seconds
|
||||
// to avoid hammering SQLite on repeated invalid requests
|
||||
tokenCache.Store(token, tokenCacheEntry{
|
||||
clientID: "",
|
||||
expiredAt: now.Add(2 * time.Second),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if clientID != "" {
|
||||
c.Set("auth", models.AuthInfo{
|
||||
Token: token,
|
||||
ClientID: clientID,
|
||||
@@ -76,7 +115,11 @@ func AuthMiddleware(database *db.DB, requireAuth bool) gin.HandlerFunc {
|
||||
if len(token) > 8 {
|
||||
maskedToken = token[:3] + "••••" + token[len(token)-4:]
|
||||
}
|
||||
log.Printf("Token not found, inactive or error in DB: %s (err: %v)", maskedToken, err)
|
||||
if dbErr != nil {
|
||||
log.Printf("Token not found, inactive or error in DB: %s (err: %v)", maskedToken, dbErr)
|
||||
} else {
|
||||
log.Printf("Token not found or inactive: %s", maskedToken)
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": gin.H{
|
||||
"message": "Invalid or inactive client token.",
|
||||
|
||||
+100
-20
@@ -59,6 +59,7 @@ type GeminiPart struct {
|
||||
InlineData *GeminiInlineData `json:"inlineData,omitempty"`
|
||||
FunctionCall *GeminiFunctionCall `json:"functionCall,omitempty"`
|
||||
FunctionResponse *GeminiFunctionResponse `json:"functionResponse,omitempty"`
|
||||
ThoughtSignature string `json:"thoughtSignature,omitempty"`
|
||||
}
|
||||
|
||||
type GeminiInlineData struct {
|
||||
@@ -231,6 +232,7 @@ func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.Unified
|
||||
Name: tc.Function.Name,
|
||||
Args: json.RawMessage(tc.Function.Arguments),
|
||||
},
|
||||
ThoughtSignature: "skip_thought_signature_validator",
|
||||
})
|
||||
}
|
||||
contents = append(contents, GeminiContent{Role: "model", Parts: parts})
|
||||
@@ -256,16 +258,9 @@ func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.Unified
|
||||
if len(m.Content) > 0 {
|
||||
text = m.Content[0].Text
|
||||
}
|
||||
name := "unknown_function"
|
||||
if m.Name != nil {
|
||||
name = *m.Name
|
||||
}
|
||||
name := resolveToolName(m, msg.ToolCalls, j-i-1)
|
||||
|
||||
var responseObj interface{}
|
||||
if err := json.Unmarshal([]byte(text), &responseObj); err != nil {
|
||||
responseObj = map[string]interface{}{"result": text}
|
||||
}
|
||||
respBytes, _ := json.Marshal(responseObj)
|
||||
respBytes := ensureJSONObject(text)
|
||||
|
||||
functionParts = append(functionParts, GeminiPart{
|
||||
FunctionResponse: &GeminiFunctionResponse{
|
||||
@@ -353,7 +348,9 @@ func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.Unified
|
||||
geminiTool := GeminiTool{FunctionDeclarations: []models.FunctionDef{}}
|
||||
for _, t := range req.Tools {
|
||||
if t.Type == "function" {
|
||||
geminiTool.FunctionDeclarations = append(geminiTool.FunctionDeclarations, t.Function)
|
||||
funcDef := t.Function
|
||||
funcDef.Parameters = cleanParametersSchema(funcDef.Parameters)
|
||||
geminiTool.FunctionDeclarations = append(geminiTool.FunctionDeclarations, funcDef)
|
||||
}
|
||||
}
|
||||
if len(geminiTool.FunctionDeclarations) > 0 {
|
||||
@@ -509,6 +506,7 @@ func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.U
|
||||
Name: tc.Function.Name,
|
||||
Args: json.RawMessage(tc.Function.Arguments),
|
||||
},
|
||||
ThoughtSignature: "skip_thought_signature_validator",
|
||||
})
|
||||
}
|
||||
contents = append(contents, GeminiContent{Role: "model", Parts: parts})
|
||||
@@ -522,16 +520,9 @@ func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.U
|
||||
if len(m.Content) > 0 {
|
||||
text = m.Content[0].Text
|
||||
}
|
||||
name := "unknown_function"
|
||||
if m.Name != nil {
|
||||
name = *m.Name
|
||||
}
|
||||
name := resolveToolName(m, msg.ToolCalls, j-i-1)
|
||||
|
||||
var responseObj interface{}
|
||||
if err := json.Unmarshal([]byte(text), &responseObj); err != nil {
|
||||
responseObj = map[string]interface{}{"result": text}
|
||||
}
|
||||
respBytes, _ := json.Marshal(responseObj)
|
||||
respBytes := ensureJSONObject(text)
|
||||
|
||||
functionParts = append(functionParts, GeminiPart{
|
||||
FunctionResponse: &GeminiFunctionResponse{
|
||||
@@ -610,7 +601,9 @@ func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.U
|
||||
geminiTool := GeminiTool{FunctionDeclarations: []models.FunctionDef{}}
|
||||
for _, t := range req.Tools {
|
||||
if t.Type == "function" {
|
||||
geminiTool.FunctionDeclarations = append(geminiTool.FunctionDeclarations, t.Function)
|
||||
funcDef := t.Function
|
||||
funcDef.Parameters = cleanParametersSchema(funcDef.Parameters)
|
||||
geminiTool.FunctionDeclarations = append(geminiTool.FunctionDeclarations, funcDef)
|
||||
}
|
||||
}
|
||||
if len(geminiTool.FunctionDeclarations) > 0 {
|
||||
@@ -652,6 +645,9 @@ func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.U
|
||||
msg = string(body)
|
||||
}
|
||||
}
|
||||
fmt.Printf("[Gemini-Stream] API Error %d: %s\n", resp.StatusCode(), msg)
|
||||
reqJSON, _ := json.Marshal(body)
|
||||
fmt.Printf("[Gemini-Stream] Request Body: %s\n", string(reqJSON))
|
||||
return nil, fmt.Errorf("Gemini API error (%d): %s", resp.StatusCode(), msg)
|
||||
}
|
||||
|
||||
@@ -668,3 +664,87 @@ func uint32Ptr(v uint32) *uint32 {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanGeminiSchema(schema map[string]interface{}) {
|
||||
delete(schema, "additionalProperties")
|
||||
delete(schema, "$schema")
|
||||
for _, v := range schema {
|
||||
if subMap, ok := v.(map[string]interface{}); ok {
|
||||
cleanGeminiSchema(subMap)
|
||||
} else if subList, ok := v.([]interface{}); ok {
|
||||
for _, item := range subList {
|
||||
if itemMap, ok := item.(map[string]interface{}); ok {
|
||||
cleanGeminiSchema(itemMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cleanParametersSchema(raw json.RawMessage) json.RawMessage {
|
||||
if len(raw) == 0 {
|
||||
return raw
|
||||
}
|
||||
var schema map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
return raw
|
||||
}
|
||||
cleanGeminiSchema(schema)
|
||||
cleaned, err := json.Marshal(schema)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return json.RawMessage(cleaned)
|
||||
}
|
||||
|
||||
// ensureJSONObject ensures that a tool result string is serialized as a JSON object
|
||||
// for Gemini's functionResponse.response field, which must always be an object.
|
||||
// If the text is a JSON array, primitive, or invalid JSON, it gets wrapped in {"result": ...}.
|
||||
func ensureJSONObject(text string) []byte {
|
||||
if text == "" {
|
||||
b, _ := json.Marshal(map[string]interface{}{"result": ""})
|
||||
return b
|
||||
}
|
||||
|
||||
var parsed interface{}
|
||||
if err := json.Unmarshal([]byte(text), &parsed); err != nil {
|
||||
// Not valid JSON — wrap as string
|
||||
b, _ := json.Marshal(map[string]interface{}{"result": text})
|
||||
return b
|
||||
}
|
||||
|
||||
// Only allow map types through; wrap everything else
|
||||
if _, ok := parsed.(map[string]interface{}); ok {
|
||||
return []byte(text)
|
||||
}
|
||||
|
||||
// It's a JSON array, number, string, bool, or null — wrap it
|
||||
b, _ := json.Marshal(map[string]interface{}{"result": parsed})
|
||||
return b
|
||||
}
|
||||
|
||||
// resolveToolName determines the function name for a tool response message.
|
||||
// It tries: 1) the Name field on the tool message, 2) matching by ToolCallID
|
||||
// against the preceding assistant's tool calls, 3) positional index match.
|
||||
func resolveToolName(toolMsg models.UnifiedMessage, toolCalls []models.ToolCall, posIndex int) string {
|
||||
if toolMsg.Name != nil && *toolMsg.Name != "" {
|
||||
return *toolMsg.Name
|
||||
}
|
||||
|
||||
// Try to match by tool_call_id
|
||||
if toolMsg.ToolCallID != nil && *toolMsg.ToolCallID != "" {
|
||||
for _, tc := range toolCalls {
|
||||
if tc.ID == *toolMsg.ToolCallID {
|
||||
return tc.Function.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Positional fallback
|
||||
if posIndex >= 0 && posIndex < len(toolCalls) {
|
||||
return toolCalls[posIndex].Function.Name
|
||||
}
|
||||
|
||||
return "unknown_function"
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gophergate/internal/models"
|
||||
)
|
||||
|
||||
func TestCleanParametersSchema(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "flat additionalProperties and $schema",
|
||||
input: `{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["query"],
|
||||
"additionalProperties": false
|
||||
}`,
|
||||
expected: `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "nested additionalProperties",
|
||||
input: `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}`,
|
||||
expected: `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "nested additionalProperties in array items",
|
||||
input: `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
expected: `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
inputRaw := json.RawMessage(tc.input)
|
||||
cleanedRaw := cleanParametersSchema(inputRaw)
|
||||
|
||||
var cleanedMap, expectedMap map[string]interface{}
|
||||
if err := json.Unmarshal(cleanedRaw, &cleanedMap); err != nil {
|
||||
t.Fatalf("failed to unmarshal cleaned: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(tc.expected), &expectedMap); err != nil {
|
||||
t.Fatalf("failed to unmarshal expected: %v", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(cleanedMap, expectedMap) {
|
||||
t.Errorf("expected %v, got %v", expectedMap, cleanedMap)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitGeminiChunk_ToolCalls(t *testing.T) {
|
||||
ch := make(chan *models.ChatCompletionStreamResponse, 1)
|
||||
defer close(ch)
|
||||
|
||||
chunk := &geminiStreamChunk{}
|
||||
chunk.Candidates = []struct {
|
||||
Content struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
Thought string `json:"thought,omitempty"`
|
||||
FunctionCall *struct {
|
||||
Name string `json:"name"`
|
||||
Args json.RawMessage `json:"args"`
|
||||
} `json:"functionCall,omitempty"`
|
||||
} `json:"parts"`
|
||||
} `json:"content"`
|
||||
FinishReason string `json:"finishReason"`
|
||||
}{
|
||||
{
|
||||
Content: struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
Thought string `json:"thought,omitempty"`
|
||||
FunctionCall *struct {
|
||||
Name string `json:"name"`
|
||||
Args json.RawMessage `json:"args"`
|
||||
} `json:"functionCall,omitempty"`
|
||||
} `json:"parts"`
|
||||
}{
|
||||
Parts: []struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
Thought string `json:"thought,omitempty"`
|
||||
FunctionCall *struct {
|
||||
Name string `json:"name"`
|
||||
Args json.RawMessage `json:"args"`
|
||||
} `json:"functionCall,omitempty"`
|
||||
}{
|
||||
{
|
||||
FunctionCall: &struct {
|
||||
Name string `json:"name"`
|
||||
Args json.RawMessage `json:"args"`
|
||||
}{
|
||||
Name: "google_search",
|
||||
Args: json.RawMessage(`{"query": "test"}`),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
FinishReason: "",
|
||||
},
|
||||
}
|
||||
|
||||
emitted := emitGeminiChunk(ch, chunk, "gemini-3-flash-preview")
|
||||
if !emitted {
|
||||
t.Fatalf("expected emitGeminiChunk to return true")
|
||||
}
|
||||
|
||||
select {
|
||||
case resp := <-ch:
|
||||
if len(resp.Choices) != 1 {
|
||||
t.Fatalf("expected 1 choice, got %d", len(resp.Choices))
|
||||
}
|
||||
choice := resp.Choices[0]
|
||||
if choice.FinishReason == nil || *choice.FinishReason != "tool_calls" {
|
||||
t.Errorf("expected finish_reason 'tool_calls', got %v", choice.FinishReason)
|
||||
}
|
||||
if len(choice.Delta.ToolCalls) != 1 {
|
||||
t.Fatalf("expected 1 tool call in delta, got %d", len(choice.Delta.ToolCalls))
|
||||
}
|
||||
tc := choice.Delta.ToolCalls[0]
|
||||
if tc.ID == nil || *tc.ID != "call_google_search" {
|
||||
t.Errorf("expected ID 'call_google_search', got %v", tc.ID)
|
||||
}
|
||||
if tc.Function == nil || tc.Function.Name == nil || *tc.Function.Name != "google_search" {
|
||||
t.Errorf("expected function name 'google_search', got %v", tc.Function)
|
||||
}
|
||||
if tc.Function == nil || tc.Function.Arguments == nil || *tc.Function.Arguments != `{"query": "test"}` {
|
||||
t.Errorf("expected arguments '{\"query\": \"test\"}', got %v", tc.Function)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("expected response on channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamGeminiJSONArrayStream(t *testing.T) {
|
||||
input := `[
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{"text": "Hello"}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{"text": " world"}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 5,
|
||||
"candidatesTokenCount": 2,
|
||||
"totalTokenCount": 7
|
||||
}
|
||||
}
|
||||
]`
|
||||
|
||||
ch := make(chan *models.ChatCompletionStreamResponse, 5)
|
||||
defer close(ch)
|
||||
|
||||
streamGeminiJSONArrayStream(strings.NewReader(input), ch, "gemini-2.5-flash")
|
||||
|
||||
var texts []string
|
||||
for i := 0; i < 2; i++ {
|
||||
select {
|
||||
case resp := <-ch:
|
||||
if len(resp.Choices) > 0 && resp.Choices[0].Delta.Content != nil {
|
||||
texts = append(texts, *resp.Choices[0].Delta.Content)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("expected chunk %d", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
joined := strings.Join(texts, "")
|
||||
if joined != "Hello world" {
|
||||
t.Errorf("expected 'Hello world', got %q", joined)
|
||||
}
|
||||
}
|
||||
@@ -409,8 +409,12 @@ type geminiStreamChunk struct {
|
||||
Candidates []struct {
|
||||
Content struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
Thought string `json:"thought,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Thought string `json:"thought,omitempty"`
|
||||
FunctionCall *struct {
|
||||
Name string `json:"name"`
|
||||
Args json.RawMessage `json:"args"`
|
||||
} `json:"functionCall,omitempty"`
|
||||
} `json:"parts"`
|
||||
} `json:"content"`
|
||||
FinishReason string `json:"finishReason"`
|
||||
@@ -433,6 +437,7 @@ func emitGeminiChunk(ch chan<- *models.ChatCompletionStreamResponse, chunk *gemi
|
||||
content := ""
|
||||
var reasoning *string
|
||||
var finishReason *string
|
||||
var toolCalls []models.ToolCallDelta
|
||||
if len(chunk.Candidates) > 0 {
|
||||
for _, p := range chunk.Candidates[0].Content.Parts {
|
||||
if p.Text != "" {
|
||||
@@ -444,8 +449,26 @@ func emitGeminiChunk(ch chan<- *models.ChatCompletionStreamResponse, chunk *gemi
|
||||
}
|
||||
*reasoning += p.Thought
|
||||
}
|
||||
if p.FunctionCall != nil {
|
||||
name := p.FunctionCall.Name
|
||||
args := string(p.FunctionCall.Args)
|
||||
tcID := fmt.Sprintf("call_%s", name)
|
||||
tcType := "function"
|
||||
toolCalls = append(toolCalls, models.ToolCallDelta{
|
||||
Index: uint32(len(toolCalls)),
|
||||
ID: &tcID,
|
||||
Type: &tcType,
|
||||
Function: &models.FunctionCallDelta{
|
||||
Name: &name,
|
||||
Arguments: &args,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
fr := strings.ToLower(chunk.Candidates[0].FinishReason)
|
||||
if len(toolCalls) > 0 && fr == "" {
|
||||
fr = "tool_calls"
|
||||
}
|
||||
finishReason = &fr
|
||||
}
|
||||
|
||||
@@ -460,6 +483,7 @@ func emitGeminiChunk(ch chan<- *models.ChatCompletionStreamResponse, chunk *gemi
|
||||
Delta: models.ChatStreamDelta{
|
||||
Content: &content,
|
||||
ReasoningContent: reasoning,
|
||||
ToolCalls: toolCalls,
|
||||
},
|
||||
FinishReason: finishReason,
|
||||
},
|
||||
@@ -499,9 +523,12 @@ func StreamGemini(ctx io.ReadCloser, model string) (<-chan *models.ChatCompletio
|
||||
first := string(peek[:n])
|
||||
|
||||
if first[0] == '[' {
|
||||
// JSON array format
|
||||
rest, _ := io.ReadAll(ctx)
|
||||
streamGeminiJSONArray(append([]byte(first), rest...), ch, model)
|
||||
// JSON array format — stream parse it in real-time
|
||||
combined := io.MultiReader(
|
||||
strings.NewReader(string(peek[:n])),
|
||||
ctx,
|
||||
)
|
||||
streamGeminiJSONArrayStream(combined, ch, model)
|
||||
return
|
||||
} else if strings.HasPrefix(first, "data:") || strings.HasPrefix(first, "data: ") {
|
||||
// SSE format — pre-pend the peeked bytes then run SSE scanner
|
||||
@@ -524,35 +551,48 @@ func StreamGemini(ctx io.ReadCloser, model string) (<-chan *models.ChatCompletio
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// readAll reads remaining bytes from a reader (keeps the function signature simple
|
||||
// for the JSON array fallback path).
|
||||
func readAll(r io.Reader) []byte {
|
||||
b, _ := io.ReadAll(r)
|
||||
return b
|
||||
}
|
||||
func streamGeminiJSONArrayStream(r io.Reader, ch chan<- *models.ChatCompletionStreamResponse, model string) {
|
||||
dec := json.NewDecoder(r)
|
||||
|
||||
func streamGeminiJSONArray(data []byte, ch chan<- *models.ChatCompletionStreamResponse, model string) {
|
||||
var chunks []geminiStreamChunk
|
||||
if err := json.Unmarshal(data, &chunks); err != nil {
|
||||
fmt.Printf("[Gemini-Stream] JSON array parse error: %v\n", err)
|
||||
// Read open bracket '['
|
||||
t, err := dec.Token()
|
||||
if err != nil {
|
||||
fmt.Printf("[Gemini-Stream] JSON array token error: %v\n", err)
|
||||
return
|
||||
}
|
||||
// Track the last chunk with usage for the final emission
|
||||
delim, ok := t.(json.Delim)
|
||||
if !ok || delim != '[' {
|
||||
fmt.Printf("[Gemini-Stream] JSON array expected '[', got %v\n", t)
|
||||
return
|
||||
}
|
||||
|
||||
var lastUsage *geminiStreamChunk
|
||||
for i := range chunks {
|
||||
if chunks[i].UsageMetadata.TotalTokenCount > 0 {
|
||||
lastUsage = &chunks[i]
|
||||
|
||||
// Read array elements
|
||||
for dec.More() {
|
||||
var chunk geminiStreamChunk
|
||||
if err := dec.Decode(&chunk); err != nil {
|
||||
fmt.Printf("[Gemini-Stream] JSON array decode error: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if chunk.UsageMetadata.TotalTokenCount > 0 {
|
||||
temp := chunk
|
||||
lastUsage = &temp
|
||||
}
|
||||
|
||||
// Emit content-bearing chunks immediately
|
||||
if len(chunk.Candidates) > 0 {
|
||||
emitGeminiChunk(ch, &chunk, model)
|
||||
}
|
||||
}
|
||||
if lastUsage != nil {
|
||||
// Emit a synthetic final chunk with usage data
|
||||
if len(lastUsage.Candidates) == 0 && lastUsage.UsageMetadata.TotalTokenCount > 0 {
|
||||
emitGeminiChunk(ch, lastUsage, model)
|
||||
}
|
||||
}
|
||||
// Also emit each content-bearing chunk
|
||||
for i := range chunks {
|
||||
emitGeminiChunk(ch, &chunks[i], model)
|
||||
|
||||
// Read close bracket ']'
|
||||
_, _ = dec.Token()
|
||||
|
||||
// Emit synthetic final chunk with usage if we collected it and it was not yet emitted
|
||||
if lastUsage != nil && len(lastUsage.Candidates) == 0 && lastUsage.UsageMetadata.TotalTokenCount > 0 {
|
||||
emitGeminiChunk(ch, lastUsage, model)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package server
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gophergate/internal/router"
|
||||
)
|
||||
|
||||
func TestIsSoftwareDevelopment(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -29,3 +33,68 @@ func TestIsSoftwareDevelopment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRouteCtxTags(t *testing.T) {
|
||||
s := &Server{}
|
||||
tests := []struct {
|
||||
name string
|
||||
routeCtx *router.RouteContext
|
||||
expectedTags []string
|
||||
mustContain []string
|
||||
mustExclude []string
|
||||
}{
|
||||
{
|
||||
name: "Standard query with tools",
|
||||
routeCtx: &router.RouteContext{
|
||||
UserMessage: "Search the web for weather in Paris",
|
||||
RequiresToolCalling: true,
|
||||
},
|
||||
mustExclude: []string{"tool-heavy", "swe-bench"},
|
||||
},
|
||||
{
|
||||
name: "Coding query with tools",
|
||||
routeCtx: &router.RouteContext{
|
||||
UserMessage: "Write a python script to parse logs",
|
||||
RequiresToolCalling: true,
|
||||
IsSoftwareDevelopment: true,
|
||||
},
|
||||
mustContain: []string{"tool-heavy", "swe-bench"},
|
||||
},
|
||||
{
|
||||
name: "Agent query with tools",
|
||||
routeCtx: &router.RouteContext{
|
||||
UserMessage: "agent please orchestrate the multi-agent task",
|
||||
RequiresToolCalling: true,
|
||||
},
|
||||
mustContain: []string{"tool-heavy", "swe-bench"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tags := s.getRouteCtxTags(tt.routeCtx)
|
||||
|
||||
for _, expected := range tt.mustContain {
|
||||
found := false
|
||||
for _, tag := range tags {
|
||||
if tag == expected {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected tag %q to be present, but was not in %v", expected, tags)
|
||||
}
|
||||
}
|
||||
|
||||
for _, excluded := range tt.mustExclude {
|
||||
for _, tag := range tags {
|
||||
if tag == excluded {
|
||||
t.Errorf("expected tag %q to be excluded, but was found in %v", excluded, tags)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -574,6 +574,31 @@ func (s *Server) handleChatCompletions(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Prune message history to sliding window if configured
|
||||
if s.cfg.Server.MaxHistoryMessages > 0 && len(req.Messages) > s.cfg.Server.MaxHistoryMessages {
|
||||
var systemMsgs []models.ChatMessage
|
||||
var otherMsgs []models.ChatMessage
|
||||
for _, msg := range req.Messages {
|
||||
if msg.Role == "system" {
|
||||
systemMsgs = append(systemMsgs, msg)
|
||||
} else {
|
||||
otherMsgs = append(otherMsgs, msg)
|
||||
}
|
||||
}
|
||||
|
||||
keepCount := s.cfg.Server.MaxHistoryMessages - len(systemMsgs)
|
||||
if keepCount < 1 {
|
||||
keepCount = 1
|
||||
}
|
||||
if len(otherMsgs) > keepCount {
|
||||
startIndex := len(otherMsgs) - keepCount
|
||||
otherMsgs = otherMsgs[startIndex:]
|
||||
log.Printf("[DEBUG] Pruned message history: kept %d system messages and last %d messages (total %d out of %d)",
|
||||
len(systemMsgs), len(otherMsgs), len(systemMsgs)+len(otherMsgs), len(req.Messages))
|
||||
req.Messages = append(systemMsgs, otherMsgs...)
|
||||
}
|
||||
}
|
||||
|
||||
// Strip common prefixes and prepare model ID
|
||||
modelID := req.Model
|
||||
prefixes := []string{"gemini/", "google/", "openai/", "deepseek/", "moonshot/", "grok/", "ollama/", "xiaomi/"}
|
||||
@@ -1119,7 +1144,15 @@ func (s *Server) getRouteCtxTags(routeCtx *router.RouteContext) []string {
|
||||
}
|
||||
}
|
||||
|
||||
if routeCtx.RequiresToolCalling {
|
||||
hasHeavyLogic := false
|
||||
for _, tag := range tags {
|
||||
if tag == "heavy-logic" {
|
||||
hasHeavyLogic = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if routeCtx.RequiresToolCalling && (routeCtx.IsSoftwareDevelopment || hasHeavyLogic) {
|
||||
tags = append(tags, "tool-heavy", "multi-step-agent", "swe-bench")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user