Compare commits

...

16 Commits

Author SHA1 Message Date
hobokenchicken ddb710507d feat(deepseek): add support for Responses API and Chat Prefix Completion
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
2026-08-03 03:54:00 +00:00
newkirk 2ed027da7c fix: scope reasoning_effort=none to OpenAI provider only (Xiaomi rejects 'none')
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
2026-07-30 15:26:28 -04:00
newkirk 2e4253e0bf fix: add ModelGroup field to LLMRequest struct matching DB schema
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
2026-07-30 15:20:53 -04:00
newkirk 2562675b8b fix: set reasoning_effort=none when tools present for OpenAI reasoning models
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
2026-07-30 15:18:40 -04:00
newkirk b3dc4365a8 fix(dustins_stack): correct kimi model name kimi-2.6 → kimi-k2.6
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
2026-07-29 12:27:07 -04:00
newkirk 1200a081fc fix(dustins_stack): remove gemini-3.5-flash-lite from targets and fallbacks
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
- Removed gemini-3.5-flash-lite from dustins_stack targets (7→6 models)
- Replaced Gemini fallbacks in multimodal_tier, ultra_long_context, fast_flow_tier
- Added dustins_stack seed data to db.go for fresh deploys
- Also commits pending Gemini role=function→user fix for ChatCompletion and ChatCompletionStream
2026-07-29 12:23:33 -04:00
hobokenchicken c6329d5612 fix(gemini): resolve latency and error issues with model normalization and v1beta tool routing
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
2026-07-28 17:58:21 +00:00
newkirk c96e1d0350 mobile: off-canvas sidebar, responsive grids/charts/top-bar
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
- Sidebar becomes slide-in overlay under 768px with backdrop
- Hamburger button in top-bar for mobile nav toggle
- Grids stack single-column, charts shrink to 280px
- Tables scroll horizontally with touch momentum
- Status text hidden on mobile (dot stays visible)
- Card headers/actions stack vertically
- Period selectors scroll horizontally

also: image size validation clamp in handleImageGenerations
2026-07-27 23:47:31 -04:00
hobokenchicken ba25f5e6c8 style: optimize stat-card grid to fit on 1 line for desktop screens or 2 lines for laptops
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
2026-07-21 18:14:24 +00:00
hobokenchicken 59c38e8f8f feat: add Days Active card and total days lifetime span to dashboard overview
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
2026-07-21 18:11:22 +00:00
newkirk d23da551ab docs: update README with Xiaomi MiMo support, HTTP connection pooling, token caching, and thread safety
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
2026-07-21 13:43:57 -04:00
hobokenchicken 654f6ab6d1 perf: add HTTP connection pooling across providers and disable proxy buffering on SSE streams
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
2026-07-21 17:40:18 +00:00
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
22 changed files with 1102 additions and 119 deletions
+1
View File
@@ -6,6 +6,7 @@
.env.*
!.env.example
/gophergate
/gophergate_*
/llm-proxy
/llm-proxy-go
*.log
+7 -2
View File
@@ -5,19 +5,24 @@ A unified, high-performance LLM proxy gateway built in Go. It provides OpenAI-co
## Features
- **Unified API:** OpenAI-compatible `/v1/chat/completions`, `/v1/images/generations`, `/v1/responses`, and `/v1/models` endpoints.
- The `/v1/responses` endpoint (OpenAI Responses API) is currently supported for OpenAI models only. Non-OpenAI providers (Gemini, DeepSeek, Moonshot, Grok, Ollama) return a "not supported" response.
- The `/v1/responses` endpoint (OpenAI Responses API) is supported for OpenAI and DeepSeek models. Non-supported providers (Gemini, Moonshot, Grok, Ollama, Xiaomi) return a "not supported" response.
- **Multi-Provider Support:**
- **OpenAI:** GPT-4o, GPT-4o Mini, GPT-5, GPT-5.4, o1/o3/o4 reasoning models, DALL-E 2/3 image generation.
- **Google Gemini:** Gemini 2.5 Flash/Pro, Gemini 3 Flash/Pro previews, Imagen 3 image generation.
- **DeepSeek:** DeepSeek Chat, Reasoner, V4 Flash, V4 Pro.
- **Moonshot:** Kimi K2.5, K2.6 reasoning models.
- **xAI Grok:** Grok-3, Grok-4, Grok-4.3 reasoning models.
- **Xiaomi MiMo:** MiMo v2.5 models.
- **Ollama:** Local LLMs running on your network.
- **Observability & Tracking:**
- **Asynchronous Logging:** Non-blocking request logging to SQLite using background workers.
- **Token Counting:** Precise estimation and tracking of prompt, completion, and reasoning tokens.
- **Database Persistence:** Every request logged to SQLite for historical analysis and dashboard analytics.
- **Streaming Support:** Full SSE (Server-Sent Events) support for all providers.
- **Streaming Support:** Full SSE (Server-Sent Events) support with `X-Accel-Buffering: no` for unbuffered, low-latency streaming.
- **High Performance & Thread Safety:**
- **Connection Pooling:** Shared HTTP transport with connection pooling (`MaxIdleConns: 200`), TCP keep-alives, and HTTP/2 multiplexing across all provider drivers.
- **In-Memory Token Caching:** In-memory `sync.Map` TTL caching (10s valid, 2s negative cache) for client token authentication to eliminate SQLite bottlenecking.
- **Thread-Safe Core:** Full RWMutex locking across provider maps, model registry lookups, and router reloads.
- **Multimodal (Vision):** Image processing (Base64 and remote URLs) across compatible providers.
- **Image Generation:** DALL-E 2/3 (OpenAI) and Imagen 3 (Gemini) via OpenAI-compatible `/v1/images/generations` endpoint.
- **Automatic Model Routing:**
+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 != "" {
+63 -11
View File
@@ -26,7 +26,7 @@ func Init(path string) (*DB, error) {
}
// Connect to SQLite
dsn := fmt.Sprintf("file:%s?_pragma=foreign_keys(1)", path)
dsn := fmt.Sprintf("file:%s?_pragma=foreign_keys(1)&_busy_timeout=5000", path)
db, err := sqlx.Connect("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
@@ -203,21 +203,72 @@ func (db *DB) RunMigrations() error {
// Seed default model groups
defaultGroups := []struct {
id, strategy, targets, selectorModel string
id, strategy, targets, selectorModel, heuristicRules string
complexityThreshold, logicLevel *int
primaryUse *string
}{
{"deepseek-auto", "heuristic", `["deepseek-chat","deepseek-reasoner"]`, "", nil, nil, nil},
{"openai-auto", "heuristic", `["gpt-4o-mini","gpt-4o"]`, "", nil, nil, nil},
{"gemini-auto", "heuristic", `["gemini-2.5-flash","gemini-2.5-pro"]`, "", nil, nil, nil},
{"heavy-logic", "heuristic", `["grok-4.3","kimi-k2.6","deepseek-v4-pro"]`, "", nil, intPtr(9), strPtr("Complex Coding, Logic, Agents.")},
{"standard-pro", "heuristic", `["gpt-5.4-mini","gemini-3-flash-preview"]`, "", nil, intPtr(5), strPtr("General Assistant, Long Docs.")},
{"fast-flow", "heuristic", `["deepseek-v4-flash","gpt-5.4-nano"]`, "", nil, intPtr(2), strPtr("Classification, JSON, Basic Q&A.")},
{"dispatcher", "classifier", `["fast-flow","standard-pro","heavy-logic"]`, "gpt-5.4-nano", intPtr(10), nil, strPtr("Auto-dispatches to tier groups by complexity.")},
{"deepseek-auto", "heuristic", `["deepseek-chat","deepseek-reasoner"]`, "", "", nil, nil, nil},
{"openai-auto", "heuristic", `["gpt-4o-mini","gpt-4o"]`, "", "", nil, nil, nil},
{"gemini-auto", "heuristic", `["gemini-3.5-flash-lite","gemini-3.1-flash-lite","gemini-2.5-flash"]`, "", "", nil, nil, nil},
{"heavy-logic", "heuristic", `["grok-4.3","kimi-k2.6","deepseek-v4-pro"]`, "", "", nil, intPtr(9), strPtr("Complex Coding, Logic, Agents.")},
{"standard-pro", "heuristic", `["gpt-5.4-mini","gemini-3.5-flash-lite"]`, "", "", nil, intPtr(5), strPtr("General Assistant, Long Docs.")},
{"fast-flow", "heuristic", `["deepseek-v4-flash","gpt-5.4-nano"]`, "", "", nil, intPtr(2), strPtr("Classification, JSON, Basic Q&A.")},
{"dispatcher", "classifier", `["fast-flow","standard-pro","heavy-logic"]`, "gpt-5.4-nano", "", intPtr(10), nil, strPtr("Auto-dispatches to tier groups by complexity.")},
{"dustins_stack", "heuristic", `["mimo-v2.5","deepseek-v4-pro","grok-4.3","mimo-v2.5-pro","deepseek-v4-flash","kimi-k2.6"]`, "", `[
{
"rule_id": "multimodal_tier",
"description": "Multimodal input routes to high-throughput multimodal models.",
"conditions": { "has_multimodal_input": true },
"primary_model": "mimo-v2.5",
"fallback_model": "mimo-v2.5-pro"
},
{
"rule_id": "ultra_long_context",
"description": "Massive context/document processing (>128k tokens) routes to long-context specialists.",
"conditions": { "min_input_tokens": 128000 },
"primary_model": "kimi-k2.6",
"fallback_model": "deepseek-v4-pro"
},
{
"rule_id": "agentic_code_and_tools",
"description": "Tool-heavy agent loops (MCP, repo editing, SWE) to MiMo Pro.",
"conditions": { "requires_tool_calling": true, "any_of_tags": ["swe-bench", "tool-heavy"] },
"primary_model": "mimo-v2.5-pro",
"fallback_model": "deepseek-v4-pro"
},
{
"rule_id": "reasoning_heavy",
"description": "Deep reasoning, architecture, system design, and math.",
"conditions": { "requires_reasoning": true },
"primary_model": "deepseek-v4-pro",
"fallback_model": "grok-4.3"
},
{
"rule_id": "realtime_and_creative",
"description": "Real-time web search, open-ended synthesis, or high-creativity tasks.",
"conditions": { "any_of_tags": ["realtime-search", "creative", "synthesis"] },
"primary_model": "grok-4.3",
"fallback_model": "mimo-v2.5-pro"
},
{
"rule_id": "fast_flow_tier",
"description": "Short simple text, no reasoning, no tools.",
"conditions": { "max_input_tokens_lt": 16000, "requires_reasoning": false, "requires_tool_calling": false },
"primary_model": "deepseek-v4-flash",
"fallback_model": "mimo-v2.5"
},
{
"rule_id": "regional_fallback_general",
"description": "Catch-all default rule.",
"conditions": { "is_default_fallback": true },
"primary_model": "deepseek-v4-pro",
"fallback_model": "deepseek-v4-flash"
}
]`, nil, nil, strPtr("Dustin's personal agent stack. No Gemini.")},
}
for _, g := range defaultGroups {
db.Exec(`INSERT OR IGNORE INTO model_groups (id, strategy, targets, selector_model, complexity_threshold, logic_level, primary_use) VALUES (?, ?, ?, ?, ?, ?, ?)`,
g.id, g.strategy, g.targets, nilStr(g.selectorModel), g.complexityThreshold, g.logicLevel, g.primaryUse)
db.Exec(`INSERT OR IGNORE INTO model_groups (id, strategy, targets, selector_model, heuristic_rules, complexity_threshold, logic_level, primary_use) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
g.id, g.strategy, g.targets, nilStr(g.selectorModel), nilStr(g.heuristicRules), g.complexityThreshold, g.logicLevel, g.primaryUse)
}
return nil
@@ -258,6 +309,7 @@ type LLMRequest struct {
ResponseBody *string `db:"response_body"`
CacheReadTokens int `db:"cache_read_tokens"`
CacheWriteTokens int `db:"cache_write_tokens"`
ModelGroup string `db:"model_group"`
}
type ProviderConfig struct {
+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.",
+2
View File
@@ -32,6 +32,7 @@ type ChatMessage struct {
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
Name *string `json:"name,omitempty"`
ToolCallID *string `json:"tool_call_id,omitempty"`
Prefix *bool `json:"prefix,omitempty"`
}
type ContentPart struct {
@@ -168,6 +169,7 @@ type UnifiedMessage struct {
ToolCalls []ToolCall
Name *string
ToolCallID *string
Prefix *bool
}
type UnifiedContentPart struct {
+64 -3
View File
@@ -22,7 +22,7 @@ type DeepSeekProvider struct {
func NewDeepSeekProvider(cfg config.DeepSeekConfig, apiKey string) *DeepSeekProvider {
return &DeepSeekProvider{
client: resty.New().SetTimeout(10 * time.Minute),
client: NewOptimizedRestyClient(10 * time.Minute),
config: cfg,
apiKey: apiKey,
}
@@ -257,9 +257,70 @@ func (p *DeepSeekProvider) ImageGeneration(ctx context.Context, req *models.Imag
}
func (p *DeepSeekProvider) Responses(ctx context.Context, req *models.ResponsesRequest) (*models.ResponsesResponse, error) {
return nil, fmt.Errorf("responses API not supported by deepseek")
stream := req.Stream != nil && *req.Stream
body := BuildOpenAIResponsesBody(req, stream)
resp, err := p.client.R().
SetContext(ctx).
SetHeader("Authorization", "Bearer "+p.apiKey).
SetBody(body).
Post(fmt.Sprintf("%s/responses", p.config.BaseURL))
if err != nil {
return nil, fmt.Errorf("responses request failed: %w", err)
}
if !resp.IsSuccess() {
msg := resp.String()
if msg == "" && resp.RawBody() != nil {
if bodyBytes, err := io.ReadAll(resp.RawBody()); err == nil {
msg = string(bodyBytes)
}
}
return nil, fmt.Errorf("DeepSeek Responses API error (%d): %s", resp.StatusCode(), msg)
}
var respJSON map[string]interface{}
if err := json.Unmarshal(resp.Body(), &respJSON); err != nil {
return nil, fmt.Errorf("failed to parse responses response: %w", err)
}
return ParseOpenAIResponsesResponse(respJSON, req.Model)
}
func (p *DeepSeekProvider) ResponsesStream(ctx context.Context, req *models.ResponsesRequest) (<-chan *models.ResponsesStreamChunk, error) {
return nil, fmt.Errorf("responses API not supported by deepseek")
body := BuildOpenAIResponsesBody(req, true)
resp, err := p.client.R().
SetContext(ctx).
SetHeader("Authorization", "Bearer "+p.apiKey).
SetBody(body).
SetDoNotParseResponse(true).
Post(fmt.Sprintf("%s/responses", p.config.BaseURL))
if err != nil {
return nil, fmt.Errorf("responses stream request failed: %w", err)
}
if !resp.IsSuccess() {
msg := resp.String()
if msg == "" && resp.RawBody() != nil {
if bodyBytes, err := io.ReadAll(resp.RawBody()); err == nil {
msg = string(bodyBytes)
}
}
return nil, fmt.Errorf("DeepSeek Responses API error (%d): %s", resp.StatusCode(), msg)
}
ch := make(chan *models.ResponsesStreamChunk)
go func() {
defer close(ch)
err := StreamOpenAIResponses(resp.RawBody(), ch)
if err != nil {
fmt.Printf("DeepSeek Responses stream error: %v\n", err)
}
}()
return ch, nil
}
+138 -45
View File
@@ -21,7 +21,7 @@ type GeminiProvider struct {
func NewGeminiProvider(cfg config.GeminiConfig, apiKey string) *GeminiProvider {
return &GeminiProvider{
client: resty.New().SetTimeout(10 * time.Minute),
client: NewOptimizedRestyClient(10 * time.Minute),
config: cfg,
apiKey: apiKey,
}
@@ -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 {
@@ -201,15 +202,7 @@ func (p *GeminiProvider) ResponsesStream(ctx context.Context, req *models.Respon
}
func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.UnifiedRequest) (*models.ChatCompletionResponse, error) {
// Map deprecated or preview model names to active equivalents
switch req.Model {
case "gemini-2.0-flash":
req.Model = "gemini-2.5-flash"
case "gemini-3-flash":
req.Model = "gemini-3-flash-preview"
case "gemini-3-pro":
req.Model = "gemini-3-pro-preview"
}
req.Model = normalizeGeminiModel(req.Model)
// Gemini mapping
var contents []GeminiContent
@@ -231,6 +224,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 +250,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{
@@ -278,7 +265,7 @@ func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.Unified
}
if foundAny {
contents = append(contents, GeminiContent{Role: "function", Parts: functionParts})
contents = append(contents, GeminiContent{Role: "user", Parts: functionParts})
i = j - 1 // Advance outer loop past the tool messages we consumed
} else {
// If no tool results found but assistant made calls, Gemini WILL error.
@@ -353,7 +340,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 {
@@ -367,8 +356,9 @@ func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.Unified
if strings.Contains(lowerModel, "preview") ||
strings.Contains(lowerModel, "thinking") ||
strings.Contains(lowerModel, "gemini-") ||
hasMappedTools {
// Use v1beta for preview, newer models, or when using tools
hasMappedTools ||
hasHistoryToolCalls(contents) {
// Use v1beta for preview, newer models, tool use, or historical tool calls
if !strings.Contains(baseURL, "v1beta") {
baseURL = strings.Replace(baseURL, "/v1", "/v1beta", 1)
}
@@ -481,15 +471,7 @@ func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.Unified
}
func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.UnifiedRequest) (<-chan *models.ChatCompletionStreamResponse, error) {
// Map deprecated or preview model names to active equivalents
switch req.Model {
case "gemini-2.0-flash":
req.Model = "gemini-2.5-flash"
case "gemini-3-flash":
req.Model = "gemini-3-flash-preview"
case "gemini-3-pro":
req.Model = "gemini-3-pro-preview"
}
req.Model = normalizeGeminiModel(req.Model)
// Simplified Gemini mapping
var contents []GeminiContent
@@ -509,6 +491,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 +505,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{
@@ -544,7 +520,7 @@ func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.U
}
if foundAny {
contents = append(contents, GeminiContent{Role: "function", Parts: functionParts})
contents = append(contents, GeminiContent{Role: "user", Parts: functionParts})
i = j - 1
}
continue
@@ -610,7 +586,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 {
@@ -624,8 +602,9 @@ func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.U
if strings.Contains(lowerModel, "preview") ||
strings.Contains(lowerModel, "thinking") ||
strings.Contains(lowerModel, "gemini-") ||
hasMappedTools {
// Use v1beta for preview, newer models, or when using tools
hasMappedTools ||
hasHistoryToolCalls(contents) {
// Use v1beta for preview, newer models, tool use, or historical tool calls
if !strings.Contains(baseURL, "v1beta") {
baseURL = strings.Replace(baseURL, "/v1", "/v1beta", 1)
}
@@ -652,6 +631,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 +650,114 @@ 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"
}
func normalizeGeminiModel(model string) string {
switch model {
case "gemini-2.0-flash", "gemini-1.5-flash":
return "gemini-3.5-flash-lite"
case "gemini-3-flash", "gemini-3-flash-preview":
return "gemini-3.5-flash-lite"
case "gemini-3-pro", "gemini-3-pro-preview", "gemini-1.5-pro":
return "gemini-2.5-pro"
default:
return model
}
}
func hasHistoryToolCalls(contents []GeminiContent) bool {
for _, c := range contents {
if c.Role == "function" {
return true
}
for _, p := range c.Parts {
if p.FunctionCall != nil || p.FunctionResponse != nil {
return true
}
}
}
return false
}
+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)
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ type GrokProvider struct {
func NewGrokProvider(cfg config.GrokConfig, apiKey string) *GrokProvider {
return &GrokProvider{
client: resty.New().SetTimeout(10 * time.Minute),
client: NewOptimizedRestyClient(10 * time.Minute),
config: cfg,
apiKey: apiKey,
}
+104 -28
View File
@@ -5,10 +5,15 @@ import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"regexp"
"strings"
"time"
"gophergate/internal/models"
"github.com/go-resty/resty/v2"
)
var keySanitizeRegex = regexp.MustCompile(`(?i)(key|api_key|secret)=[^&]+`)
@@ -18,6 +23,34 @@ func SanitizeURL(rawURL string) string {
return keySanitizeRegex.ReplaceAllString(rawURL, "$1=REDACTED")
}
// Shared HTTP transport configured with high connection pooling, TCP keep-alive,
// and HTTP/2 multiplexing to minimize latency when connecting to upstream LLM providers.
var sharedHTTPTransport = &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 200,
MaxIdleConnsPerHost: 50,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
// NewOptimizedRestyClient creates a resty client equipped with HTTP connection pooling.
func NewOptimizedRestyClient(timeout time.Duration) *resty.Client {
httpClient := &http.Client{
Transport: sharedHTTPTransport,
}
client := resty.NewWithClient(httpClient)
if timeout > 0 {
client.SetTimeout(timeout)
}
return client
}
func sanitizeFunctionName(name string) string {
var sb strings.Builder
@@ -125,6 +158,9 @@ func MessagesToOpenAIJSON(messages []models.UnifiedMessage) ([]interface{}, erro
if m.Name != nil {
msg["name"] = *m.Name
}
if m.Prefix != nil {
msg["prefix"] = *m.Prefix
}
result = append(result, msg)
}
return result, nil
@@ -409,8 +445,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 +473,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 +485,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 +519,7 @@ func emitGeminiChunk(ch chan<- *models.ChatCompletionStreamResponse, chunk *gemi
Delta: models.ChatStreamDelta{
Content: &content,
ReasoningContent: reasoning,
ToolCalls: toolCalls,
},
FinishReason: finishReason,
},
@@ -499,9 +559,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 +587,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 -1
View File
@@ -21,7 +21,7 @@ type MoonshotProvider struct {
func NewMoonshotProvider(cfg config.MoonshotConfig, apiKey string) *MoonshotProvider {
return &MoonshotProvider{
client: resty.New().SetTimeout(10 * time.Minute),
client: NewOptimizedRestyClient(10 * time.Minute),
config: cfg,
apiKey: strings.TrimSpace(apiKey),
}
+1 -2
View File
@@ -20,10 +20,9 @@ type OllamaProvider struct {
}
func NewOllamaProvider(cfg config.OllamaConfig) *OllamaProvider {
client := resty.New()
client := NewOptimizedRestyClient(15 * time.Minute)
// Set reasonable timeouts for local Ollama server (longer for larger models)
// For streaming, we want a very long timeout or none at all to handle generation time
client.SetTimeout(15 * time.Minute)
client.SetRetryCount(2)
client.SetRetryWaitTime(1 * time.Second)
+7 -1
View File
@@ -22,7 +22,7 @@ type OpenAIProvider struct {
func NewOpenAIProvider(cfg config.OpenAIConfig, apiKey string) *OpenAIProvider {
return &OpenAIProvider{
client: resty.New().SetTimeout(10 * time.Minute),
client: NewOptimizedRestyClient(10 * time.Minute),
config: cfg,
apiKey: apiKey,
}
@@ -57,6 +57,9 @@ func (p *OpenAIProvider) ChatCompletion(ctx context.Context, req *models.Unified
delete(body, "max_tokens")
body["max_completion_tokens"] = maxTokens
}
if len(req.Tools) > 0 {
body["reasoning_effort"] = "none"
}
}
resp, err := p.client.R().
@@ -169,6 +172,9 @@ func (p *OpenAIProvider) ChatCompletionStream(ctx context.Context, req *models.U
delete(body, "max_tokens")
body["max_completion_tokens"] = maxTokens
}
if len(req.Tools) > 0 {
body["reasoning_effort"] = "none"
}
}
resp, err := p.client.R().
+1 -1
View File
@@ -21,7 +21,7 @@ type XiaomiProvider struct {
func NewXiaomiProvider(cfg config.XiaomiConfig, apiKey string) *XiaomiProvider {
return &XiaomiProvider{
client: resty.New().SetTimeout(10 * time.Minute),
client: NewOptimizedRestyClient(10 * time.Minute),
config: cfg,
apiKey: strings.TrimSpace(apiKey),
}
+17
View File
@@ -123,6 +123,21 @@ func (s *Server) handleUsageSummary(c *gin.Context) {
miscStats.AvgResponseTime = 0.0
}
// Lifetime days & start date
var lifetimeStats struct {
TotalDays int `db:"total_days"`
FirstDate string `db:"first_date"`
}
_ = s.database.Get(&lifetimeStats, `
SELECT
CAST(ROUND(COALESCE(julianday(substr(MAX(timestamp), 1, 19)) - julianday(substr(MIN(timestamp), 1, 19)), 1.0)) AS INTEGER) as total_days,
COALESCE(substr(MIN(timestamp), 1, 10), '') as first_date
FROM llm_requests
`)
if lifetimeStats.TotalDays < 1 {
lifetimeStats.TotalDays = 1
}
c.JSON(http.StatusOK, SuccessResponse(gin.H{
"total_requests": totalStats.TotalRequests,
"total_tokens": totalStats.TotalTokens,
@@ -134,6 +149,8 @@ func (s *Server) handleUsageSummary(c *gin.Context) {
"today_cost": todayStats.TodayCost,
"error_rate": miscStats.ErrorRate,
"avg_response_time": miscStats.AvgResponseTime,
"total_days": lifetimeStats.TotalDays,
"first_date": lifetimeStats.FirstDate,
}))
}
+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)
}
}
}
})
}
}
+62 -1
View File
@@ -403,6 +403,7 @@ func (s *Server) handleResponses(c *gin.Context) {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
var lastUsage *models.ResponsesUsage
c.Stream(func(w io.Writer) bool {
@@ -574,6 +575,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/"}
@@ -674,6 +700,7 @@ func (s *Server) handleChatCompletions(c *gin.Context) {
ToolCalls: msg.ToolCalls,
Name: msg.Name,
ToolCallID: msg.ToolCallID,
Prefix: msg.Prefix,
}
// Handle multimodal content
@@ -740,6 +767,7 @@ func (s *Server) handleChatCompletions(c *gin.Context) {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
var lastUsage *models.Usage
c.Stream(func(w io.Writer) bool {
@@ -823,6 +851,31 @@ func (s *Server) handleImageGenerations(c *gin.Context) {
}
}
// ponytail: per-model valid size sets. Add new models here.
if req.Size != nil {
validSizes := map[string][]string{
"gpt-image": {"1024x1024", "1024x1536", "1536x1024", "auto"},
"dall-e-3": {"1024x1024", "1024x1792", "1792x1024", "auto"},
"dall-e-2": {"256x256", "512x512", "1024x1024", "auto"},
}
for prefix, sizes := range validSizes {
if strings.HasPrefix(req.Model, prefix) {
valid := false
for _, s := range sizes {
if *req.Size == s {
valid = true
break
}
}
if !valid {
*req.Size = "1024x1024"
log.Printf("[WARN] Unsupported size for %s, clamped to 1024x1024", req.Model)
}
break
}
}
}
provider, ok := s.providers[providerName]
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Provider %s not enabled or supported", providerName)})
@@ -1119,7 +1172,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")
}
+160 -5
View File
@@ -752,21 +752,34 @@ body {
/* Stat Cards */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
@media (min-width: 1400px) {
.stats-grid {
grid-template-columns: repeat(7, 1fr);
}
}
@media (min-width: 900px) and (max-width: 1399px) {
.stats-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.stat-card {
background: var(--bg1);
padding: var(--spacing-lg);
padding: 1rem 1.1rem;
border-radius: var(--border-radius);
border: 1px solid var(--bg2);
box-shadow: var(--shadow-sm);
display: flex;
gap: 1.25rem;
gap: 0.85rem;
align-items: center;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
min-width: 0;
}
.stat-card:hover {
@@ -1382,8 +1395,150 @@ body {
border: 1px solid var(--bg2);
}
/* Settings: Warning Card */
/* Warning Card */
.warning-card {
border: 1px dashed var(--warning);
background: rgba(215, 153, 33, 0.08);
}
/* ==================== MOBILE BAREBONES ==================== */
@media (max-width: 767px) {
.sidebar {
transform: translateX(-100%);
width: 280px;
z-index: 2000;
}
.sidebar.mobile-visible {
transform: translateX(0);
}
.sidebar.collapsed {
width: 280px;
}
.sidebar.collapsed .menu-item span,
.sidebar.collapsed .menu-title,
.sidebar.collapsed .user-details {
display: block;
}
.sidebar.collapsed .menu-item {
justify-content: flex-start;
padding: 0.75rem var(--spacing-lg);
}
.sidebar.collapsed .menu-item i {
font-size: inherit;
}
.sidebar.collapsed .sidebar-footer {
justify-content: space-between;
padding: var(--spacing-lg);
}
.sidebar-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 1999;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
}
.sidebar-backdrop.visible {
opacity: 1;
pointer-events: auto;
}
.main-content {
padding-left: 0 !important;
}
.mobile-menu-btn {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--bg1);
border: 1px solid var(--bg3);
color: var(--fg1);
width: 36px;
height: 36px;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
flex-shrink: 0;
}
.mobile-menu-btn:active {
background: var(--bg2);
}
.stats-grid {
grid-template-columns: 1fr;
}
.grid-2 {
grid-template-columns: 1fr;
}
.grid-3 {
grid-template-columns: 1fr;
}
.chart-container {
height: 280px;
}
.table-container {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.content-body {
padding: var(--spacing-md);
}
.top-bar {
padding: 0 var(--spacing-md);
gap: 0.5rem;
}
.top-bar .page-title h2 {
font-size: 1rem;
}
.top-bar-actions {
gap: var(--spacing-sm);
}
.status-indicator .status-text {
display: none;
}
.monitoring-layout {
grid-template-columns: 1fr;
}
.card-header {
flex-direction: column;
align-items: flex-start;
}
.card-actions {
width: 100%;
}
.login-card {
padding: 2rem 1.25rem;
}
.period-selector {
overflow-x: auto;
flex-wrap: nowrap;
-webkit-overflow-scrolling: touch;
}
.modal-content {
width: 95%;
margin: 1rem;
}
}
@media (max-width: 767px) {
.mobile-menu-btn { display: inline-flex; }
}
@media (min-width: 768px) {
.mobile-menu-btn { display: none; }
.sidebar-backdrop { display: none; }
}
/* ponytail: single-breakpoint responsive layer, no card-based table fallback
add tablecard reflow when tables get too wide to scroll horizontally */
+9 -3
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GopherGate - Admin Dashboard</title>
<link rel="stylesheet" href="/css/dashboard.css?v=11">
<link rel="stylesheet" href="/css/dashboard.css?v=12">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -51,7 +51,7 @@
<span>GopherGate</span>
</div>
<button class="sidebar-toggle" id="sidebar-toggle">
<i class="fas fa-bars"></i>
<i class="fas fa-times"></i>
</button>
</div>
@@ -135,11 +135,17 @@
</div>
</nav>
<!-- Mobile sidebar backdrop -->
<div class="sidebar-backdrop" id="sidebar-backdrop"></div>
<!-- Main Content -->
<main class="main-content">
<header class="top-bar">
<button class="mobile-menu-btn" id="mobile-menu-btn">
<i class="fas fa-bars"></i>
</button>
<div class="page-title">
<h2 id="current-page-title">Overview</h2>
<h2 id="page-title">Overview</h2>
</div>
<div class="top-bar-actions">
<div id="connection-status" class="status-indicator">
+57 -5
View File
@@ -60,23 +60,75 @@ class Dashboard {
const toggleBtn = document.getElementById('sidebar-toggle');
const sidebar = document.querySelector('.sidebar');
const logoutBtn = document.getElementById('logout-btn');
const backdrop = document.getElementById('sidebar-backdrop');
const mobileBtn = document.getElementById('mobile-menu-btn');
const isMobile = () => window.innerWidth < 768;
const closeMobileNav = () => {
if (sidebar) sidebar.classList.remove('mobile-visible');
if (backdrop) backdrop.classList.remove('visible');
};
const toggleMobileNav = () => {
if (!sidebar || !backdrop) return;
const opening = !sidebar.classList.contains('mobile-visible');
sidebar.classList.toggle('mobile-visible', opening);
backdrop.classList.toggle('visible', opening);
document.body.style.overflow = opening && isMobile() ? 'hidden' : '';
};
if (toggleBtn && sidebar) {
toggleBtn.onclick = () => {
sidebar.classList.toggle('collapsed');
localStorage.setItem('sidebar_collapsed', sidebar.classList.contains('collapsed'));
if (isMobile()) {
toggleMobileNav();
} else {
sidebar.classList.toggle('collapsed');
localStorage.setItem('sidebar_collapsed', sidebar.classList.contains('collapsed'));
}
};
if (localStorage.getItem('sidebar_collapsed') === 'true') {
if (!isMobile() && localStorage.getItem('sidebar_collapsed') === 'true') {
sidebar.classList.add('collapsed');
}
}
if (mobileBtn) {
mobileBtn.onclick = toggleMobileNav;
}
if (backdrop) {
backdrop.onclick = closeMobileNav;
}
// Close sidebar on page navigation (mobile)
const menuItems = document.querySelectorAll('.menu-item');
menuItems.forEach(item => {
const origClick = item.onclick;
item.onclick = (e) => {
if (isMobile()) closeMobileNav();
if (origClick) origClick(e);
};
});
if (logoutBtn) {
logoutBtn.onclick = () => {
if (isMobile()) closeMobileNav();
window.authManager.logout();
};
}
// Handle resize
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (!isMobile()) {
closeMobileNav();
document.body.style.overflow = '';
}
}, 200);
});
}
setupRefresh() {
+14 -1
View File
@@ -59,7 +59,20 @@ class OverviewPage {
<div class="stat-value">${window.api.formatNumber(this.stats.total_tokens)}</div>
<div class="stat-label">Total Tokens</div>
<div class="stat-change">
Lifetime usage
Lifetime usage (${(this.stats.total_days || 1).toLocaleString()} days)
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon warning">
<i class="fas fa-calendar-alt"></i>
</div>
<div class="stat-content">
<div class="stat-value">${(this.stats.total_days || 1).toLocaleString()} Days</div>
<div class="stat-label">Days Active</div>
<div class="stat-change">
Since ${this.stats.first_date || 'launch'}
</div>
</div>
</div>