Compare commits
12 Commits
0decc63e8c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ddb710507d | |||
| 2ed027da7c | |||
| 2e4253e0bf | |||
| 2562675b8b | |||
| b3dc4365a8 | |||
| 1200a081fc | |||
| c6329d5612 | |||
| c96e1d0350 | |||
| ba25f5e6c8 | |||
| 59c38e8f8f | |||
| d23da551ab | |||
| 654f6ab6d1 |
@@ -6,6 +6,7 @@
|
||||
.env.*
|
||||
!.env.example
|
||||
/gophergate
|
||||
/gophergate_*
|
||||
/llm-proxy
|
||||
/llm-proxy-go
|
||||
*.log
|
||||
|
||||
@@ -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:**
|
||||
|
||||
+63
-11
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -202,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
|
||||
@@ -273,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.
|
||||
@@ -364,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)
|
||||
}
|
||||
@@ -478,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
|
||||
@@ -535,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
|
||||
@@ -617,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)
|
||||
}
|
||||
@@ -748,3 +734,30 @@ func resolveToolName(toolMsg models.UnifiedMessage, toolCalls []models.ToolCall,
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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().
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -699,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
|
||||
@@ -765,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 {
|
||||
@@ -848,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)})
|
||||
|
||||
+160
-5
@@ -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 table→card reflow when tables get too wide to scroll horizontally */
|
||||
|
||||
+9
-3
@@ -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">
|
||||
|
||||
+55
-3
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user