Compare commits

...

4 Commits

Author SHA1 Message Date
hobokenchicken 0decc63e8c feat: add token caching middleware and max history messages config
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
2026-07-21 17:30:22 +00:00
hobokenchicken 9980123f97 fix: optimize Gemini stream parsing to support real-time streaming of JSON arrays 2026-07-21 17:29:57 +00:00
hobokenchicken 293cf057b9 fix: restrict tool-heavy/swe-bench tagging to software development or agent-related tasks 2026-07-21 17:29:57 +00:00
hobokenchicken eb90f949a0 fix: Gemini provider compatibility with Gemini 3 models
- Strip additionalProperties and $schema from tool parameter schemas
  (Gemini API rejects these unsupported JSON Schema fields)
- Add thoughtSignature to functionCall parts for multi-turn tool calling
  (Gemini 3 models require thought signatures on function call history)
- Ensure functionResponse.response is always a JSON object, never an
  array or primitive (wrap non-objects in {"result": ...})
- Resolve tool names from tool_call_id or positional index when the
  tool message doesn't carry a name field
- Add functionCall parsing in streaming responses (emitGeminiChunk)
  to properly relay tool calls via SSE
- Add error logging for Gemini stream failures with request body dump
- Add unit tests for schema cleaning and streaming tool call emission
2026-07-21 17:29:57 +00:00
7 changed files with 595 additions and 58 deletions
+10 -4
View File
@@ -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 != "" {
+47 -4
View File
@@ -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
View File
@@ -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"
}
+266
View File
@@ -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)
}
}
+68 -28
View File
@@ -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)
}
}
+70 -1
View File
@@ -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)
}
}
}
})
}
}
+34 -1
View File
@@ -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")
}