Compare commits

...

44 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
newkirk a187d8e20e fix(phase-4): add DB error checking, mask sensitive auth tokens in API and middleware logs
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
2026-07-21 13:23:02 -04:00
newkirk 700b7cd5d6 fix(phase-3): enable circuit breaker for streaming requests 2026-07-21 13:21:39 -04:00
newkirk 4027ed4351 fix(phase-2): add thread-safety to ModelRegistry and Router reload operations 2026-07-21 13:20:29 -04:00
newkirk 42b70621a1 fix(phase-1): redact API key query params from log output and add mutex thread-safety to providers map 2026-07-21 13:18:31 -04:00
newkirk 84a18f5866 fix(classifier): add tier boundaries and signal keywords to dispatcher prompt
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
- Added tier boundary descriptions (fast/standard/heavy) to the classifier
  system prompt so gpt-5.4-nano understands what each complexity band means
- Added signal keywords for higher/lower ratings to reduce misclassification
  of simple requests as complex and vice versa
2026-07-09 15:46:35 -04:00
hobokenchicken 25e246061f fix: resolve retired/preview gemini model routing and test configuration errors
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
2026-06-18 13:32:34 +00:00
hobokenchicken 73a82e6175 feat: implement advanced condition-based heuristic model routing
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
Upgrades the routing engine to support tag, token limit, multimodal, reasoning, and tool calling conditions. Adds unit tests for the new routing features.
2026-06-05 15:05:13 +00:00
newkirk b3354a1bbc Add Xiaomi MiMo provider (mimo-v2.5) support
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
2026-05-29 12:19:24 -04:00
newkirk 1dc5f586b9 fix: improve OpenAI error body capture and log request body on 400
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
- Use resp.Body() instead of resp.RawBody() for non-streaming error responses
- Fall back to RawBody() for streaming responses
- Log the full request body on API errors for debugging
2026-05-17 19:57:59 -04:00
newkirk 40f055cb57 fix: correct deepseek pricing, gemini streaming tokens, and group-name logging
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
- Add promo discount system for deepseek-v4-pro (75% off until 2026-05-31)
- Rewrite StreamGemini to handle both SSE and JSON array response formats,
  fixing 0-token logging for gemini-3-flash and gemini-3-flash-preview
- Fall back to model group name for cost lookup when concrete model
  isnt in the registry (fixes $0 cost on deepseek-auto entries)
- Move registry lock before FindModel call to fix data race
2026-05-17 19:49:37 -04:00
hobokenchicken 970e778703 chore: update .gitignore to ignore nohup.out and bak files
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
2026-05-11 03:13:54 +00:00
hobokenchicken 477a811999 fix: remove tool call ID truncation and improve DeepSeek reasoning handling
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
The 40-character truncation of tool call IDs in helper.go caused collisions
when models (like deepseek-v4-flash) generated longer IDs, leading to
"Duplicate value for 'tool_call_id'" errors. Removed the limit to allow
full unique IDs.

DeepSeek: updated reasoning_content injection to use an empty string
instead of a space, better matching provider expectations for history.

Improved API error reporting across all providers by capturing raw body
content when response parsing fails or returns empty strings.
2026-05-11 03:13:33 +00:00
hobokenchicken d2b9da89d9 fix FindModel: prioritize canonical providers to prevent reseller limit overrides
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
FindModel iterates providers in random map order, so when deepseek-v4-pro
exists in both 'deepseek' (output=384000) and 'ollama-cloud' (output=1048576),
it sometimes returned the wrong metadata. The proxy then injected
max_tokens=1048576 into DeepSeek's API, which rejected it with 400
(valid range is [1, 393216]).

Fix: define CanonicalProviders list (deepseek, openai, google, xai, etc.)
and search them in priority order before falling back to all providers.
Each of the four lookup strategies (exact key, metadata ID, reverse fuzzy,
forward fuzzy) checks canonical providers first.
2026-05-07 14:47:17 -04:00
hobokenchicken b7df3108fa docs: update README, TODO, and deployment docs
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
README: Added hierarchical routing, classifier bucket mapping, two-level
dispatch, model groups table, DeepSeek language note, deploy script, and
updated model names to match current models.dev registry.

TODO: Added 15 completed items covering model groups, routing, dispatch,
and provider fixes from May 7 session.

deployment.md: Added deploy.sh instructions.
2026-05-07 14:07:52 -04:00
hobokenchicken 28b8271c1d fix: inject English system prompt for DeepSeek provider
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
DeepSeek models default to Chinese for some prompts. The ensureEnglish()
function prepends 'Always respond in English' as a system message when
no system prompt is already set. Applied to both ChatCompletion and
ChatCompletionStream paths.
2026-05-07 14:03:39 -04:00
hobokenchicken eb585c0001 fix: switch dispatcher classifier to gpt-5.4-nano
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
gpt-5.4-nano correctly discriminates complexity (1 vs 10)
while deepseek-v4-flash rated everything as 1/10.
2026-05-07 14:00:19 -04:00
hobokenchicken 4aea7a3b4c fix: select provider AFTER routing resolves model groups
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
Previously, provider selection happened on the raw client-requested model
name (e.g. 'dispatcher') which defaulted to OpenAI. After routing resolved
it to 'deepseek-v4-flash', the provider was never re-selected.

Now prefix-stripping + routing runs first, then selectProvider() picks
the correct provider based on the resolved concrete model.
2026-05-07 13:54:42 -04:00
hobokenchicken 330eaa57d1 fix: update model names to match current models.dev registry
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
heavy-logic: kimi-k2.5 -> kimi-k2.6
standard-pro: gemini-3-flash -> gemini-3-flash-preview
2026-05-07 13:48:33 -04:00
hobokenchicken 0ae30036f0 fix: classifier selector model now routes to correct provider
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
Extracted selectProvider() method from handleChatCompletions' inline
logic. The classifier callback now calls selectProvider(selectorModel)
instead of hardcoding openaiProvider.

This fixes the 'circuit breaker is open' error when dispatcher tries
to use deepseek-v4-flash as its selector model.
2026-05-07 13:37:19 -04:00
hobokenchicken 3c0b59622e feat: classifier bucket mapping + dispatcher seed group
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
Classifier: When complexity_threshold is set (e.g. 10), uses it as the
rating scale and maps ratings proportionally to target buckets instead
of 1:1. Formula: idx = rating * len(targets) / (threshold + 1).

With threshold=10 and 3 targets: 1-3→target[0], 4-7→target[1], 8-10→target[2].

Seed: Added 'dispatcher' group (classifier, threshold=10, selector=deepseek-v4-flash)
that auto-routes to fast-flow/standard-pro/heavy-logic by complexity score.

Combined with hierarchical routing, this enables two-level dispatch:
  dispatcher scores 1-10 → routes to tier group → tier picks concrete model.
2026-05-07 13:18:35 -04:00
hobokenchicken 7517307c11 feat: add hierarchical routing — groups can target other groups
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
RouteToConcrete() recursively resolves group chains until a concrete
model is reached, with cycle detection and max depth (10) guard.

Example: all-purpose -> fast-flow -> deepseek-v4-flash
The dashboard log shows the full chain: 'deepseek-v4-flash (hierarchical:
fast-flow (default (first target)) -> deepseek-v4-flash (default (first target)))'
2026-05-07 12:28:31 -04:00
hobokenchicken 19517b0847 chore: add deploy.sh for prod restarts
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
2026-05-07 12:02:28 -04:00
hobokenchicken a3a6f765e7 feat: add logic_level and primary_use metadata to model groups
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
Schema: Added logic_level (INTEGER) and primary_use (TEXT) columns
to model_groups table with auto-migration for existing databases.

Seed: Three new default groups:
  heavy-logic  (level 9) — Complex Coding, Logic, Agents
  standard-pro (level 5) — General Assistant, Long Docs
  fast-flow    (level 2) — Classification, JSON, Basic Q&A

Admin API: INSERT/UPDATE handlers now accept and persist the new fields.
Dashboard: Table shows Level and Primary Use columns; form includes
both fields with appropriate inputs and placeholders.
2026-05-07 12:01:28 -04:00
hobokenchicken 79dd122b56 feat: expose model groups in /v1/models endpoint
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
Add Groups() method to Router so handleListModels can append model
group IDs (e.g. 'deepseek-auto', 'openai-auto') to the model list,
marked with owned_by: 'gophergate'. This lets clients discover and
use groups via the standard OpenAI /v1/models endpoint.
2026-05-07 11:26:05 -04:00
hobokenchicken 3021e4b2b4 fix: log resolved model name instead of group name in Recent Activity
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
When using model groups (e.g. 'deepseek-auto'), the dashboard logged the
group name instead of the concrete resolved model (e.g. 'deepseek-reasoner').

Now:
- logRequest passes the resolved modelID (concrete) + modelGroup (group name)
- RequestLog struct has a new ModelGroup field (omitempty)
- Dashboard displays resolved model (via group) when a group was used

Files changed:
  internal/server/logging.go  - add ModelGroup field
  internal/server/server.go   - pass resolved modelID, capture modelGroup
  static/js/websocket.js      - show group annotation in Recent Activity
  static/js/pages/overview.js - show group annotation in overview table
  static/js/pages/monitoring.js - show group annotation in stream
2026-05-07 11:16:36 -04:00
hobokenchicken 14de7e9ebf fix: wrap model-groups API responses in SuccessResponse for api.js client
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
2026-05-05 11:41:23 -04:00
hobokenchicken 4fef201e95 fix: remove /api prefix from model-groups API calls (api.js already prepends it)
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
2026-05-05 11:33:05 -04:00
hobokenchicken bac03de051 docs: add automatic model routing to README
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
2026-05-05 11:28:59 -04:00
49 changed files with 4447 additions and 413 deletions
+3
View File
@@ -18,6 +18,9 @@ DEEPSEEK_API_KEY=sk-...
MOONSHOT_API_KEY=sk-... MOONSHOT_API_KEY=sk-...
GROK_API_KEY=xai-... GROK_API_KEY=xai-...
# Xiaomi MiMo
XIAOMI_API_KEY=sk-...
# ============================================================================== # ==============================================================================
# Server Configuration # Server Configuration
# ============================================================================== # ==============================================================================
+5
View File
@@ -6,6 +6,7 @@
.env.* .env.*
!.env.example !.env.example
/gophergate /gophergate
/gophergate_*
/llm-proxy /llm-proxy
/llm-proxy-go /llm-proxy-go
*.log *.log
@@ -14,3 +15,7 @@
.pi-lens/cache/ .pi-lens/cache/
server.pid server.pid
/target /target
nohup.out
*.bak
.antigravitycli/
+919
View File
@@ -0,0 +1,919 @@
# Automatic Model Routing — Implementation Plan
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** Add a model-group router that lets clients send `model: "deepseek-auto"` and have gophergate pick the best concrete model based on heuristic rules or an optional classifier LLM.
**Architecture:** A new `internal/router/` package with heuristic and classifier strategies, backed by a `model_groups` DB table. The router injects into `handleChatCompletions` after provider resolution but before the provider call — zero changes to the Provider interface. Admin CRUD endpoints and a dashboard tab for management.
**Tech Stack:** Go 1.22+, Gin, sqlx (SQLite), resty, existing OpenAI provider for classifier calls.
---
## Task 1: Add `model_groups` DB migration and struct
**Objective:** Create the `model_groups` table and Go struct.
**Files:**
- Modify: `internal/db/db.go`
**Step 1: Add CREATE TABLE to migrations**
In `RunMigrations()`, add to the `queries` slice (after `client_tokens`):
```go
`CREATE TABLE IF NOT EXISTS model_groups (
id TEXT PRIMARY KEY,
strategy TEXT NOT NULL DEFAULT 'heuristic',
selector_model TEXT,
targets TEXT NOT NULL DEFAULT '[]',
complexity_threshold INTEGER,
heuristic_rules TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
```
**Step 2: Add the Go struct**
After the `ClientToken` struct (around line 264), add:
```go
type ModelGroup struct {
ID string `db:"id" json:"id"`
Strategy string `db:"strategy" json:"strategy"`
SelectorModel *string `db:"selector_model" json:"selector_model"`
Targets string `db:"targets" json:"targets"` // JSON array
ComplexityThreshold *int `db:"complexity_threshold" json:"complexity_threshold"`
HeuristicRules *string `db:"heuristic_rules" json:"heuristic_rules"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
```
**Step 3: Seed default groups**
After the "Default client" block in `RunMigrations()`, add:
```go
// Seed default model groups
defaultGroups := []struct {
id, strategy, targets string
}{
{"deepseek-auto", "heuristic", `["deepseek-chat","deepseek-reasoner"]`},
{"openai-auto", "heuristic", `["gpt-4o-mini","gpt-4o"]`},
{"gemini-auto", "heuristic", `["gemini-2.0-flash","gemini-2.5-pro"]`},
}
for _, g := range defaultGroups {
db.Exec(`INSERT OR IGNORE INTO model_groups (id, strategy, targets) VALUES (?, ?, ?)`,
g.id, g.strategy, g.targets)
}
```
**Step 4: Build and verify**
```bash
cd ~/Documents/projects/web_projects/gophergate && go build ./...
```
**Step 5: Commit**
```bash
git add internal/db/db.go
git commit -m "feat: add model_groups table and default seed data"
```
---
## Task 2: Create router package — interface and heuristic router
**Objective:** Create `internal/router/` with the Router interface and heuristic implementation.
**Files:**
- Create: `internal/router/router.go`
- Create: `internal/router/heuristic.go`
**Step 1: Create `internal/router/router.go`**
```go
package router
import (
"context"
"encoding/json"
"gophergate/internal/db"
)
// Decision holds the result of a routing decision.
type Decision struct {
SelectedModel string `json:"selected_model"`
Strategy string `json:"strategy"` // "heuristic" or "classifier"
Reason string `json:"reason"`
}
// ClassifierFunc is the callback for classifier-based routing.
// Takes a system prompt, user message, and selector model.
// Returns a complexity rating string (e.g. "3").
type ClassifierFunc func(ctx context.Context, selectorModel, systemPrompt, userMessage string) (string, error)
// Router resolves model groups to concrete models.
type Router struct {
groups map[string]db.ModelGroup
classify ClassifierFunc
}
// New creates a Router. classify may be nil if no classifier groups exist.
func New(groups []db.ModelGroup, classify ClassifierFunc) *Router {
r := &Router{
groups: make(map[string]db.ModelGroup),
classify: classify,
}
for _, g := range groups {
r.groups[g.ID] = g
}
return r
}
// IsGroup returns true if the model name is a group ID.
func (r *Router) IsGroup(modelID string) bool {
_, ok := r.groups[modelID]
return ok
}
// Route resolves a group to a concrete model.
// Extracts the user message from the request body JSON bytes.
func (r *Router) Route(ctx context.Context, groupID string, userMessage string) (*Decision, error) {
group, ok := r.groups[groupID]
if !ok {
return nil, fmt.Errorf("unknown model group: %s", groupID)
}
var targets []string
if err := json.Unmarshal([]byte(group.Targets), &targets); err != nil || len(targets) == 0 {
return nil, fmt.Errorf("invalid or empty targets for group %s", groupID)
}
switch group.Strategy {
case "heuristic":
return routeHeuristic(group, targets, userMessage)
case "classifier":
if r.classify == nil {
// Fall back to heuristic if no classifier is available
return routeHeuristic(group, targets, userMessage)
}
return routeClassifier(ctx, r.classify, group, targets, userMessage)
default:
return nil, fmt.Errorf("unknown strategy: %s", group.Strategy)
}
}
// Reload replaces the group definitions without recreating the router.
func (r *Router) Reload(groups []db.ModelGroup) {
r.groups = make(map[string]db.ModelGroup)
for _, g := range groups {
r.groups[g.ID] = g
}
}
```
**Step 2: Create `internal/router/heuristic.go`**
```go
package router
import (
"context"
"encoding/json"
"strings"
"gophergate/internal/db"
)
// HeuristicRule defines a pattern-based routing rule.
type HeuristicRule struct {
Pattern string `json:"pattern"` // substring to match in user message
TargetIdx int `json:"target"` // index into targets array (0-based)
CaseSensitive bool `json:"case_sensitive,omitempty"`
}
func routeHeuristic(group db.ModelGroup, targets []string, userMessage string) (*Decision, error) {
// Default to first target (cheapest/fastest)
selected := targets[0]
reason := "default (first target)"
// If heuristic_rules is set, use them
if group.HeuristicRules != nil && *group.HeuristicRules != "" {
var rules []HeuristicRule
if err := json.Unmarshal([]byte(*group.HeuristicRules), &rules); err == nil {
searchMsg := userMessage
for _, rule := range rules {
pattern := rule.Pattern
msg := searchMsg
if !rule.CaseSensitive {
pattern = strings.ToLower(pattern)
msg = strings.ToLower(msg)
}
if strings.Contains(msg, pattern) {
if rule.TargetIdx >= 0 && rule.TargetIdx < len(targets) {
selected = targets[rule.TargetIdx]
reason = "matched heuristic rule: " + rule.Pattern
break
}
}
}
}
}
// Built-in fallback heuristics (apply even without custom rules)
if reason == "default (first target)" && len(targets) > 1 {
msgLower := strings.ToLower(userMessage)
// Complex task indicators → last target (usually the smarter model)
complexIndicators := []string{
"step by step", "explain in detail", "reason through",
"think carefully", "analyze", "debug", "write code",
"implement", "refactor", "architecture",
}
for _, indicator := range complexIndicators {
if strings.Contains(msgLower, indicator) {
selected = targets[len(targets)-1]
reason = "complex task indicator: " + indicator
break
}
}
}
return &Decision{
SelectedModel: selected,
Strategy: "heuristic",
Reason: reason,
}, nil
}
// routeHeuristic exists as a package-level func for direct use.
var _ = routeHeuristic // suppress unused warning when classifier is the only caller
```
Hmm, actually let me simplify. The `routeHeuristic` function IS used by `Router.Route()`. Let me not use the blank identifier trick.
**Step 3: Build**
```bash
cd ~/Documents/projects/web_projects/gophergate && go build ./...
```
Fix any compilation errors (missing imports, etc.).
**Step 4: Commit**
```bash
git add internal/router/
git commit -m "feat: add router package with heuristic strategy"
```
---
## Task 3: Add classifier router
**Objective:** Implement the classifier strategy that uses a cheap LLM to rate task complexity.
**Files:**
- Create: `internal/router/classifier.go`
**Step 1: Create `internal/router/classifier.go`**
```go
package router
import (
"context"
"fmt"
"strconv"
"strings"
"gophergate/internal/db"
)
const classifierSystemPrompt = `You are a task complexity classifier. Rate the following user message on a scale of 1 to %d, where:
1 = trivial/simple (basic facts, greetings, simple math)
%d = highly complex (multi-step reasoning, code generation, architecture design)
Reply with ONLY the number. No explanation.`
func routeClassifier(ctx context.Context, classify ClassifierFunc, group db.ModelGroup, targets []string, userMessage string) (*Decision, error) {
maxRating := len(targets)
if maxRating < 2 {
maxRating = 2
}
prompt := fmt.Sprintf(classifierSystemPrompt, maxRating, maxRating)
ratingStr, err := classify(ctx, getSelectorModel(group, targets), prompt, userMessage)
if err != nil {
// Classifier failed — fall back to heuristic
return routeHeuristic(group, targets, userMessage)
}
rating, err := strconv.Atoi(strings.TrimSpace(ratingStr))
if err != nil || rating < 1 {
rating = 1
}
if rating > maxRating {
rating = maxRating
}
idx := rating - 1 // 0-based index into targets
return &Decision{
SelectedModel: targets[idx],
Strategy: "classifier",
Reason: fmt.Sprintf("complexity rating: %d/%d", rating, maxRating),
}, nil
}
func getSelectorModel(group db.ModelGroup, targets []string) string {
if group.SelectorModel != nil && *group.SelectorModel != "" {
return *group.SelectorModel
}
// Default: use the first (cheapest) target model as the selector
return targets[0]
}
```
**Step 2: Build**
```bash
cd ~/Documents/projects/web_projects/gophergate && go build ./...
```
**Step 3: Commit**
```bash
git add internal/router/classifier.go
git commit -m "feat: add classifier routing strategy with LLM complexity rating"
```
---
## Task 4: Wire router into the server
**Objective:** Add the Router to the Server struct, initialize it, and inject it into `handleChatCompletions`.
**Files:**
- Modify: `internal/server/server.go`
**Step 1: Add router field to Server struct**
In the `Server` struct (around line 23), add after the `registryMu` field:
```go
router *router.Router
```
**Step 2: Add import**
Add to the imports block:
```go
"gophergate/internal/router"
```
**Step 3: Initialize router in NewServer**
After `s.setupRoutes()` (line 66), add:
```go
// Initialize model group router
s.refreshRouter()
```
**Step 4: Add refreshRouter method**
Add a new method on Server:
```go
func (s *Server) refreshRouter() {
var groups []db.ModelGroup
if err := s.database.Select(&groups, "SELECT * FROM model_groups"); err != nil {
fmt.Printf("Warning: Failed to load model groups: %v\n", err)
groups = nil
}
// Build classifier function using the OpenAI provider
var classifyFn router.ClassifierFunc
if openaiProvider, ok := s.providers["openai"]; ok {
classifyFn = func(ctx context.Context, selectorModel, systemPrompt, userMessage string) (string, error) {
req := &models.UnifiedRequest{
Model: selectorModel,
Messages: []models.UnifiedMessage{
{Role: "system", Content: []models.ContentPart{{Type: "text", Text: systemPrompt}}},
{Role: "user", Content: []models.ContentPart{{Type: "text", Text: userMessage}}},
},
MaxTokens: uint32Ptr(5),
Stream: false,
}
resp, err := openaiProvider.ChatCompletion(ctx, req)
if err != nil {
return "", err
}
if len(resp.Choices) == 0 {
return "", fmt.Errorf("no choices in classifier response")
}
return resp.Choices[0].Message.Content, nil
}
}
if s.router == nil {
s.router = router.New(groups, classifyFn)
} else {
s.router.Reload(groups)
}
}
```
**Step 5: Add uint32Ptr helper (if not already in the codebase)**
At the bottom of server.go, add:
```go
func uint32Ptr(v uint32) *uint32 { return &v }
```
**Step 6: Inject router into handleChatCompletions**
In `handleChatCompletions`, after the model prefix stripping block (after line 475) and before building the UnifiedRequest (line 478), add:
```go
// Check if model is a group and route to a concrete model
if s.router != nil && s.router.IsGroup(modelID) {
userMessage := extractUserMessage(req.Messages)
decision, err := s.router.Route(c.Request.Context(), modelID, userMessage)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("model routing failed: %v", err)})
return
}
modelID = decision.SelectedModel
log.Printf("[ROUTER] %s → %s (%s: %s)", req.Model, modelID, decision.Strategy, decision.Reason)
}
```
**Step 7: Add extractUserMessage helper**
```go
func extractUserMessage(messages []models.ChatCompletionMessage) string {
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role == "user" {
if s, ok := messages[i].Content.(string); ok {
return s
}
// It might be a content array — grab text from first part
if parts, ok := messages[i].Content.([]interface{}); ok && len(parts) > 0 {
if part, ok := parts[0].(map[string]interface{}); ok {
if text, ok := part["text"].(string); ok {
return text
}
}
}
return ""
}
}
return ""
}
```
**Step 8: Add router refresh to RefreshProviders**
At the end of `RefreshProviders()` (before `return nil` at line 171), add:
```go
s.refreshRouter()
```
**Step 9: Build**
```bash
cd ~/Documents/projects/web_projects/gophergate && go build ./...
```
Expect compilation errors — need to check the `ChatCompletionMessage` type. The handler uses `models.ChatCompletionRequest` which has `Messages []ChatCompletionMessage`. Let me verify the type. If it's `[]models.ChatCompletionMessage` with `Content` as a string field, the helper is simpler. Fix as needed.
**Step 10: Commit**
```bash
git add internal/server/server.go
git commit -m "feat: wire model group router into chat completions handler"
```
---
## Task 5: Add admin API endpoints for model groups
**Objective:** CRUD endpoints at `/api/model-groups` for dashboard management.
**Files:**
- Create: `internal/server/model_groups_admin.go`
**Step 1: Create `internal/server/model_groups_admin.go`**
```go
package server
import (
"net/http"
"gophergate/internal/db"
"github.com/gin-gonic/gin"
)
func (s *Server) handleGetModelGroups(c *gin.Context) {
var groups []db.ModelGroup
if err := s.database.Select(&groups, "SELECT * FROM model_groups ORDER BY id"); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if groups == nil {
groups = []db.ModelGroup{}
}
c.JSON(http.StatusOK, groups)
}
func (s *Server) handleCreateModelGroup(c *gin.Context) {
var group db.ModelGroup
if err := c.ShouldBindJSON(&group); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_, err := s.database.Exec(`
INSERT INTO model_groups (id, strategy, selector_model, targets, complexity_threshold, heuristic_rules)
VALUES (?, ?, ?, ?, ?, ?)`,
group.ID, group.Strategy, group.SelectorModel, group.Targets,
group.ComplexityThreshold, group.HeuristicRules)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
s.refreshRouter()
c.JSON(http.StatusCreated, group)
}
func (s *Server) handleUpdateModelGroup(c *gin.Context) {
id := c.Param("id")
var group db.ModelGroup
if err := c.ShouldBindJSON(&group); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_, err := s.database.Exec(`
UPDATE model_groups SET strategy=?, selector_model=?, targets=?, complexity_threshold=?, heuristic_rules=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?`,
group.Strategy, group.SelectorModel, group.Targets,
group.ComplexityThreshold, group.HeuristicRules, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
s.refreshRouter()
c.JSON(http.StatusOK, group)
}
func (s *Server) handleDeleteModelGroup(c *gin.Context) {
id := c.Param("id")
_, err := s.database.Exec("DELETE FROM model_groups WHERE id=?", id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
s.refreshRouter()
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
}
```
**Step 2: Register routes in setupRoutes()**
In `setupRoutes()`, add under the admin group (after the models endpoints around line 229):
```go
admin.GET("/model-groups", s.handleGetModelGroups)
admin.POST("/model-groups", s.handleCreateModelGroup)
admin.PUT("/model-groups/:id", s.handleUpdateModelGroup)
admin.DELETE("/model-groups/:id", s.handleDeleteModelGroup)
```
**Step 3: Build**
```bash
cd ~/Documents/projects/web_projects/gophergate && go build ./...
```
**Step 4: Commit**
```bash
git add internal/server/model_groups_admin.go internal/server/server.go
git commit -m "feat: add model groups CRUD admin API endpoints"
```
---
## Task 6: Add dashboard UI — sidebar entry and page module
**Objective:** Add a "Model Groups" tab to the dashboard sidebar and a page module for CRUD management.
**Files:**
- Modify: `static/index.html`
- Create: `static/js/pages/model_groups.js`
**Step 1: Add sidebar menu item in index.html**
In the MANAGEMENT section (after line 91, before `</ul>`), add:
```html
<li class="menu-item" data-page="model-groups">
<i class="fas fa-code-branch"></i>
<span>Model Groups</span>
</li>
```
**Step 2: Add script tag in index.html**
After the users.js script (line 179), add:
```html
<script src="/js/pages/model_groups.js?v=8"></script>
```
**Step 3: Create `static/js/pages/model_groups.js`**
```javascript
// Model Groups Management Page
class ModelGroupsPage {
constructor() {
this.container = document.getElementById('page-content');
}
async render() {
this.container.innerHTML = `
<div class="page-header">
<h3>Model Groups</h3>
<p class="text-muted">Define auto-routing groups that pick the best model for each request.</p>
<button class="btn btn-primary" onclick="modelGroupsPage.showCreateForm()">
<i class="fas fa-plus"></i> Add Group
</button>
</div>
<div id="model-groups-list" class="table-container"></div>
<div id="model-group-form" class="form-container" style="display:none;"></div>
`;
await this.loadGroups();
}
async loadGroups() {
try {
const groups = await api.get('/api/model-groups');
const list = document.getElementById('model-groups-list');
if (!groups || groups.length === 0) {
list.innerHTML = '<div class="empty-state">No model groups defined. Create one to enable auto-routing.</div>';
return;
}
let targets;
try { targets = JSON.parse(g.targets); } catch { targets = []; }
const heuristicRules = g.heuristic_rules ? JSON.parse(g.heuristic_rules) : null;
let html = '<table class="data-table"><thead><tr>';
html += '<th>Group ID</th><th>Strategy</th><th>Targets</th><th>Actions</th>';
html += '</tr></thead><tbody>';
groups.forEach(g => {
html += `<tr>
<td><code>${this.esc(g.id)}</code></td>
<td><span class="badge">${this.esc(g.strategy)}</span></td>
<td><code>${this.esc(g.targets)}</code></td>
<td>
<button class="btn btn-sm" onclick="modelGroupsPage.showEditForm('${this.esc(g.id)}')">Edit</button>
<button class="btn btn-sm btn-danger" onclick="modelGroupsPage.deleteGroup('${this.esc(g.id)}')">Delete</button>
</td>
</tr>`;
});
html += '</tbody></table>';
list.innerHTML = html;
} catch (err) {
document.getElementById('model-groups-list').innerHTML =
`<div class="error-message">Failed to load model groups: ${this.esc(err.message)}</div>`;
}
}
showCreateForm() {
this.renderForm(null);
}
async showEditForm(id) {
const groups = await api.get('/api/model-groups');
const group = groups.find(g => g.id === id);
if (group) this.renderForm(group);
}
renderForm(group) {
const isEdit = !!group;
const form = document.getElementById('model-group-form');
form.style.display = 'block';
form.innerHTML = `
<h4>${isEdit ? 'Edit' : 'Create'} Model Group</h4>
<form onsubmit="modelGroupsPage.saveGroup(event, ${isEdit})">
<div class="form-control">
<label>Group ID</label>
<input type="text" id="mg-id" value="${this.esc(group?.id || '')}" ${isEdit ? 'readonly' : 'required'}
placeholder="e.g. deepseek-auto">
<small>Clients use this as the model name.</small>
</div>
<div class="form-control">
<label>Strategy</label>
<select id="mg-strategy">
<option value="heuristic" ${group?.strategy === 'heuristic' ? 'selected' : ''}>Heuristic (rules-based)</option>
<option value="classifier" ${group?.strategy === 'classifier' ? 'selected' : ''}>Classifier (LLM judge)</option>
</select>
</div>
<div class="form-control">
<label>Targets (JSON array)</label>
<input type="text" id="mg-targets" value='${this.esc(group?.targets || '["cheap-model","smart-model"]')}' required>
<small>First target = cheapest/fastest. Last target = smartest/most expensive.</small>
</div>
<div class="form-control" id="mg-selector-row" ${group?.strategy === 'classifier' ? '' : 'style="display:none"'}>
<label>Selector Model</label>
<input type="text" id="mg-selector-model" value="${this.esc(group?.selector_model || 'gpt-4o-mini')}"
placeholder="Model used to judge task complexity">
</div>
<div class="form-control" id="mg-threshold-row" ${group?.strategy === 'classifier' ? '' : 'style="display:none"'}>
<label>Complexity Threshold</label>
<input type="number" id="mg-threshold" value="${group?.complexity_threshold || ''}" min="1"
placeholder="Tasks rated >= this go to the smart model">
</div>
<div class="form-control" id="mg-rules-row" ${group?.strategy === 'heuristic' ? '' : 'style="display:none"'}>
<label>Heuristic Rules (JSON array)</label>
<textarea id="mg-rules" rows="4" placeholder='[{"pattern":"step by step","target":1}]'>${group?.heuristic_rules || ''}</textarea>
<small>Pattern to match in user messages. target = index into targets array.</small>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn" onclick="document.getElementById('model-group-form').style.display='none'">Cancel</button>
</div>
</form>
`;
// Toggle strategy-specific fields
document.getElementById('mg-strategy').onchange = function() {
const isClassifier = this.value === 'classifier';
document.getElementById('mg-selector-row').style.display = isClassifier ? '' : 'none';
document.getElementById('mg-threshold-row').style.display = isClassifier ? '' : 'none';
document.getElementById('mg-rules-row').style.display = isClassifier ? 'none' : '';
};
}
async saveGroup(event, isEdit) {
event.preventDefault();
const id = document.getElementById('mg-id').value.trim();
const strategy = document.getElementById('mg-strategy').value;
const targets = document.getElementById('mg-targets').value;
const selectorModel = document.getElementById('mg-selector-model').value.trim() || null;
const thresholdVal = document.getElementById('mg-threshold').value;
const rules = document.getElementById('mg-rules').value.trim() || null;
// Validate JSON
try { JSON.parse(targets); } catch { alert('Targets must be valid JSON array'); return; }
if (rules) { try { JSON.parse(rules); } catch { alert('Heuristic rules must be valid JSON'); return; } }
const body = { id, strategy, targets, selector_model: selectorModel, heuristic_rules: rules };
if (thresholdVal) body.complexity_threshold = parseInt(thresholdVal);
try {
if (isEdit) {
await api.put(`/api/model-groups/${encodeURIComponent(id)}`, body);
} else {
await api.post('/api/model-groups', body);
}
document.getElementById('model-group-form').style.display = 'none';
await this.loadGroups();
} catch (err) {
alert('Failed to save: ' + err.message);
}
}
async deleteGroup(id) {
if (!confirm(`Delete model group "${id}"?`)) return;
try {
await api.delete(`/api/model-groups/${encodeURIComponent(id)}`);
await this.loadGroups();
} catch (err) {
alert('Failed to delete: ' + err.message);
}
}
esc(str) {
if (!str) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
}
const modelGroupsPage = new ModelGroupsPage();
```
**Step 4: Register page in dashboard.js**
In `static/js/dashboard.js`, find the page loading logic. The `loadPage` method dynamically imports page modules based on `this.currentPage`. The naming convention uses hyphens in `data-page` attributes (e.g., `data-page="model-groups"`). Check how the existing pages are loaded and ensure "model-groups" maps to the new module.
Looking at the existing pattern, pages are loaded via script tags in index.html and their constructors handle rendering when the page is navigated to. The dashboard.js `loadPage` method calls page-specific init. Let me check if there's a page registry pattern.
Actually, based on the index.html, pages are loaded as separate script files and the dashboard dispatches to them. The pattern seems to be: each page script defines a class or object, and the dashboard calls a `render()` or `init()` method on it when that page is selected. Let me add the dispatch logic.
In `dashboard.js`, find the `loadPage` method and ensure it handles "model-groups":
```javascript
// In the loadPage switch/if-else, add:
else if (page === 'model-groups') {
if (typeof modelGroupsPage !== 'undefined') {
modelGroupsPage.render();
}
}
```
**Step 5: Commit**
```bash
git add static/index.html static/js/pages/model_groups.js static/js/dashboard.js
git commit -m "feat: add model groups dashboard page with CRUD UI"
```
---
## Task 7: Integration test — build, run, verify
**Objective:** Ensure everything compiles and the routing works end-to-end.
**Step 1: Full build**
```bash
cd ~/Documents/projects/web_projects/gophergate && go build -o gophergate ./cmd/gophergate
```
**Step 2: Start server and test**
```bash
# In one terminal:
./gophergate
# In another terminal, test that default groups loaded:
curl -s -u admin:admin123 http://localhost:8080/api/model-groups | jq
# Expected: array with deepseek-auto and openai-auto groups
```
**Step 3: Test routing via API**
```bash
# Send a request using a model group
curl -s http://localhost:8080/v1/chat/completions \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"model": "openai-auto",
"messages": [{"role": "user", "content": "What is 2+2?"}]
}' | jq
# Check server logs for [ROUTER] line showing the decision
```
**Step 4: Commit any fixes**
If any issues found during testing, fix and commit.
---
## Architecture Notes
### Why this approach
- **No Provider interface changes** — the router is a pre-processing step in the handler, transparent to providers
- **Groups stored in DB** — manageable from the dashboard, no config file sprawl
- **Classifier is optional** — heuristic mode works with zero added latency or cost
- **Fallback chain** — classifier failure falls back to heuristic; missing router falls back to direct passthrough
### Edge cases handled
- No groups defined → router never activates, all models pass through as before
- Unknown group ID → returns error to client
- Empty targets → returns error
- Classifier call fails → falls back to heuristic
- Classifier returns garbage → clamped to valid range
- OpenAI provider disabled → classifier groups fall back to heuristic mode
### What's NOT in this plan (future work)
- Streaming classifier support (the ~300ms classifier call happens before streaming begins — acceptable for now)
- responses endpoint routing (`handleResponses` could also use the router but needs a different message extraction)
- Per-client group overrides
- A/B testing / multi-armed bandit routing
- Caching classifier decisions for identical messages
+96
View File
@@ -0,0 +1,96 @@
# GopherGate Remediation Action Plan
Based on the findings from the [Code Review Report](file:///home/newkirk/Projects/gophergate/code_review.md), this document outlines a phased execution plan to fix all security, concurrency, reliability, and code quality issues in **GopherGate**.
---
## Phase 1: Critical Security & Crash Fixes (Immediate)
### Task 1.1: Redact API Keys in Gemini Provider Logs
- **Target File:** [`internal/providers/gemini.go`](file:///home/newkirk/Projects/gophergate/internal/providers/gemini.go#L378)
- **Problem:** `fmt.Printf("[Gemini] POST %s\n", url)` logs the raw request URL containing `?key=AIzaSy...`, leaking API secrets to logs.
- **Action Items:**
1. Create a helper function `sanitizeURL(rawURL string) string` in `internal/providers/helpers.go` to strip or replace sensitive query parameters (`key=...`).
2. Replace all instances of raw URL printing in `gemini.go` (L378, L636) with `log.Printf("[Gemini] POST %s", sanitizeURL(url))`.
### Task 1.2: Add Thread Safety to Server Providers Map
- **Target File:** [`internal/server/server.go`](file:///home/newkirk/Projects/gophergate/internal/server/server.go#L30)
- **Problem:** `s.providers` map is mutated during `RefreshProviders()` without mutex locking while HTTP handlers read from it concurrently (`selectProvider()`).
- **Action Items:**
1. Add `providersMu sync.RWMutex` field to `Server` struct in `server.go`.
2. Acquire `s.providersMu.Lock()` before modifying `s.providers` in `RefreshProviders()`.
3. Acquire `s.providersMu.RLock()` in `selectProvider()` and deferred `s.providersMu.RUnlock()`.
---
## Phase 2: Thread-Safety & Race Condition Hardening
### Task 2.1: Synchronize `ModelRegistry` Lookups and Reloads
- **Target Files:** [`internal/models/registry.go`](file:///home/newkirk/Projects/gophergate/internal/models/registry.go#L24), [`internal/server/server.go`](file:///home/newkirk/Projects/gophergate/internal/server/server.go#L56)
- **Problem:** Background goroutines update `s.registry` while concurrent requests access `r.FindModel()`.
- **Action Items:**
1. Add `sync.RWMutex` to `ModelRegistry` struct in `registry.go`.
2. Wrap `FindModel()` methods with `r.mu.RLock()` and `r.mu.RUnlock()`.
3. Alternatively, implement `atomic.Pointer[models.ModelRegistry]` in `Server` to enable lock-free atomic pointer swaps on registry refresh.
### Task 2.2: Synchronize `Router.Reload()`
- **Target File:** [`internal/router/router.go`](file:///home/newkirk/Projects/gophergate/internal/router/router.go#L34)
- **Problem:** `Router.Reload()` replaces `r.groups` map without holding a mutex lock during active routing requests.
- **Action Items:**
1. Add `mu sync.RWMutex` to `Router` struct in `router.go`.
2. Acquire `r.mu.RLock()` during `Route()`, `RouteToConcrete()`, and `IsGroup()`.
3. Acquire `r.mu.Lock()` during `Reload()`.
---
## Phase 3: Reliability & Stream Fault Tolerance
### Task 3.1: Enable Circuit Breaker Protection for Streaming Endpoints
- **Target File:** [`internal/providers/circuit_breaker.go`](file:///home/newkirk/Projects/gophergate/internal/providers/circuit_breaker.go#L52)
- **Problem:** `ChatCompletionStream` and `ResponsesStream` bypass `gobreaker` entirely.
- **Action Items:**
1. Wrap the initial `ChatCompletionStream` call inside `cb.Execute()`.
2. Implement stream response wrapper that monitors streaming errors and reports failure back to the circuit breaker state tracker.
### Task 3.2: Prevent Goroutine Leaks on Client Disconnect
- **Target Files:** [`internal/providers/helpers.go`](file:///home/newkirk/Projects/gophergate/internal/providers/helpers.go#L379), [`internal/providers/deepseek.go`](file:///home/newkirk/Projects/gophergate/internal/providers/deepseek.go#L226)
- **Problem:** Scanner loops push chunks via `ch <- chunk` without selecting on `ctx.Done()`.
- **Action Items:**
1. Update channel writes in `StreamOpenAI` and `StreamGemini`:
```go
select {
case ch <- chunk:
case <-ctx.Done():
return
}
```
---
## Phase 4: Error Handling & Security Hygiene
### Task 4.1: Fix Resty Response Body Draining in Error Handlers
- **Target Files:** `internal/providers/gemini.go`, `deepseek.go`, `xiaomi.go`
- **Problem:** Code attempts `io.ReadAll(resp.RawBody())` on consumed bodies.
- **Action Items:**
1. Replace `io.ReadAll(resp.RawBody())` with `resp.Body()` or `resp.String()`.
### Task 4.2: Handle Database Error Returns
- **Target Files:** [`internal/server/users.go`](file:///home/newkirk/Projects/gophergate/internal/server/users.go#L73), [`internal/server/clients.go`](file:///home/newkirk/Projects/gophergate/internal/server/clients.go#L165)
- **Problem:** Errors returned by `database.Exec()` are ignored in user and client token updates.
- **Action Items:**
1. Check and log/return HTTP error responses for all `database.Exec()` calls.
### Task 4.3: Mask Auth Tokens in System Settings API
- **Target File:** [`internal/server/system.go`](file:///home/newkirk/Projects/gophergate/internal/server/system.go#L81)
- **Problem:** `/api/system/settings` returns unmasked `auth_tokens`.
- **Action Items:**
1. Mask static API tokens before returning in JSON output (e.g. `sk-***1234`).
---
## Verification & Validation Plan
1. **Unit Testing:** Run `go test -v -race ./...` to verify zero data races.
2. **Integration Verification:** Run test completions across all providers (`openai`, `gemini`, `deepseek`, `grok`, `ollama`) to confirm streaming and non-streaming responses work correctly.
3. **Security Audit:** Verify log output contains no plain-text API keys or tokens.
+3
View File
@@ -46,6 +46,9 @@ Implements HMAC-SHA256 signed tokens for dashboard authentication. Tokens secure
### 5. WebSocket Hub (`internal/server/websocket.go`) ### 5. WebSocket Hub (`internal/server/websocket.go`)
A centralized hub for managing WebSocket connections, allowing real-time broadcast of system events, system metrics, and request logs to the dashboard. A centralized hub for managing WebSocket connections, allowing real-time broadcast of system events, system metrics, and request logs to the dashboard.
### 6. Model Group Router (`internal/router/`)
Resolves model groups (e.g., `deepseek-auto`, `dustins_stack`) into concrete models. It supports a Classifier strategy (uses a cheap LLM to rate complexity) and an upgraded Heuristic strategy (evaluates custom condition rules like tags, token counts, multimodal inputs, reasoning, and tool calling flags or legacy keyword patterns).
## Concurrency Model ## Concurrency Model
Go's goroutines and channels are used extensively: Go's goroutines and channels are used extensively:
+94 -20
View File
@@ -5,28 +5,44 @@ A unified, high-performance LLM proxy gateway built in Go. It provides OpenAI-co
## Features ## Features
- **Unified API:** OpenAI-compatible `/v1/chat/completions`, `/v1/images/generations`, `/v1/responses`, and `/v1/models` endpoints. - **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:** - **Multi-Provider Support:**
- **OpenAI:** GPT-4o, GPT-4o Mini, o1, o3 reasoning models, DALL-E 2/3 image generation. - **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.0 Flash, Pro, and vision models (with native CoT support), Imagen 3 image generation. - **Google Gemini:** Gemini 2.5 Flash/Pro, Gemini 3 Flash/Pro previews, Imagen 3 image generation.
- **DeepSeek:** DeepSeek Chat and Reasoner (R1) models. - **DeepSeek:** DeepSeek Chat, Reasoner, V4 Flash, V4 Pro.
- **Moonshot:** Kimi K2.5 and other Kimi models. - **Moonshot:** Kimi K2.5, K2.6 reasoning models.
- **xAI Grok:** Grok-4 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. - **Ollama:** Local LLMs running on your network.
- **Observability & Tracking:** - **Observability & Tracking:**
- **Asynchronous Logging:** Non-blocking request logging to SQLite using background workers. - **Asynchronous Logging:** Non-blocking request logging to SQLite using background workers.
- **Token Counting:** Precise estimation and tracking of prompt, completion, and reasoning tokens. - **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. - **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. - **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. - **Image Generation:** DALL-E 2/3 (OpenAI) and Imagen 3 (Gemini) via OpenAI-compatible `/v1/images/generations` endpoint.
- **Automatic Model Routing:**
- **Hierarchical Routing:** Groups can target other groups, cascading through multiple levels until a concrete model is reached. Cycle detection and depth limiting (max 10) prevent infinite loops.
- **Heuristic strategy:** Free, zero-latency routing supporting both keyword matching (regex/substrings) and condition-based checks (evaluating tags, token limits, multimodal inputs, reasoning, and tool calling requirements).
- **Classifier strategy:** Uses a cheap LLM to rate task complexity on a configurable scale (1-10), then selects the appropriate model. Bucket mapping distributes ratings proportionally across targets.
- **Two-Level Dispatch:** A `dispatcher` group (classifier, threshold=10) auto-routes to tier groups by complexity score, which then apply their own internal strategies.
- **Metadata:** Groups support `logic_level` (1-10 complexity scale) and `primary_use` (description) fields for organizational clarity.
- Pre-seeded with provider groups, tier groups (heavy-logic / standard-pro / fast-flow), and a dispatcher. Model groups are exposed in `/v1/models` so clients can discover them.
- **Multi-User Access Control:** - **Multi-User Access Control:**
- **Admin Role:** Full access to all dashboard features, user management, and system configuration. - **Admin Role:** Full access to all dashboard features, user management, and system configuration.
- **Viewer Role:** Read-only access to usage analytics, costs, and monitoring. - **Viewer Role:** Read-only access to usage analytics, costs, and monitoring.
- **Client API Keys:** Create and manage multiple client tokens for external integrations. - **Client API Keys:** Create and manage multiple client tokens for external integrations.
- **Reliability:** - **Reliability:**
- **Circuit Breaking:** Automatically protects when providers are down (coming soon). - **Circuit Breaking:** Protects providers when they are down, auto-recovers after timeout.
- **Rate Limiting:** Per-client and global rate limits (coming soon). - **Provider-Aware Classification:** Classifier selector models are routed to the correct provider automatically.
## DeepSeek Language Note
DeepSeek models default to Chinese for some prompts. GopherGate automatically injects an English system prompt ("Always respond in English.") when no system message is present. If the client provides its own system prompt, it is left untouched.
## Security ## Security
@@ -71,7 +87,9 @@ GopherGate is designed with security in mind:
# LLM_PROXY__ENCRYPTION_KEY=... (32-byte hex or base64 string) # LLM_PROXY__ENCRYPTION_KEY=... (32-byte hex or base64 string)
# OPENAI_API_KEY=sk-... # OPENAI_API_KEY=sk-...
# GEMINI_API_KEY=AIza... # GEMINI_API_KEY=AIza...
# DEEPSEEK_API_KEY=sk-...
# MOONSHOT_API_KEY=... # MOONSHOT_API_KEY=...
# GROK_API_KEY=xai-...
# For Ollama (optional): Set base URL and enable # For Ollama (optional): Set base URL and enable
# LLM_PROXY__PROVIDERS__OLLAMA__BASE_URL=http://localhost:11434/v1 # LLM_PROXY__PROVIDERS__OLLAMA__BASE_URL=http://localhost:11434/v1
# LLM_PROXY__PROVIDERS__OLLAMA__ENABLED=true # LLM_PROXY__PROVIDERS__OLLAMA__ENABLED=true
@@ -83,7 +101,16 @@ GopherGate is designed with security in mind:
./gophergate ./gophergate
``` ```
The server starts on `http://0.0.0.0:8080` by default. The server starts on `http://0.0.0.0:8080` by default. Configure `LLM_PROXY__SERVER__PORT` in `.env` to change it.
### Quick Deploy Script
A `deploy.sh` script is included for production restarts:
```bash
./deploy.sh
# git pull -> go build -> stop old process -> start new process
```
### Deployment (Docker) ### Deployment (Docker)
@@ -106,6 +133,8 @@ Access the dashboard at `http://localhost:8080`.
- **Usage:** Summary stats, time-series analytics, and provider breakdown. - **Usage:** Summary stats, time-series analytics, and provider breakdown.
- **Clients:** API key management and per-client usage tracking. - **Clients:** API key management and per-client usage tracking.
- **Providers:** Provider configuration and status monitoring. - **Providers:** Provider configuration and status monitoring.
- **Model Groups:** Define auto-routing groups with heuristic or classifier strategies. Supports logic level and primary use metadata.
- **Models:** Model enable/disable and cost configuration.
- **Users:** Admin-only user management for dashboard access. - **Users:** Admin-only user management for dashboard access.
- **Monitoring:** Live request stream via WebSocket. - **Monitoring:** Live request stream via WebSocket.
@@ -125,14 +154,6 @@ You can reset the admin password to default by running:
The proxy is a drop-in replacement for OpenAI. Configure your client: The proxy is a drop-in replacement for OpenAI. Configure your client:
Moonshot models are available through the same OpenAI-compatible endpoint. For
example, use `kimi-k2.5` as the model name after setting `MOONSHOT_API_KEY` in
your environment.
Ollama models (like `llama3`, `gemma2`, `mistral`) are also available through the same
endpoint after enabling Ollama in configuration and setting the base URL to your
Ollama server (default: `http://localhost:11434/v1`).
### Python ### Python
```python ```python
@@ -170,7 +191,60 @@ response = client.responses.create(
print(response.output_text) print(response.output_text)
``` ```
**Note:** The `/v1/responses` endpoint is currently supported for OpenAI models only. Requests routed to Gemini, DeepSeek, Moonshot, Grok, or Ollama models return a "not supported" error. **Note:** The `/v1/responses` endpoint is currently supported for OpenAI models only.
### Automatic Model Routing
Use a model group name to let gophergate pick the best model automatically:
```python
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:8080/v1",
api_key="YOUR_CLIENT_API_KEY"
)
# Simple query -- routes to the cheap/fast model
response = client.chat.completions.create(
model="fast-flow",
messages=[{"role": "user", "content": "What is 2+2?"}]
)
# Complex query -- routes to the reasoning model automatically
response = client.chat.completions.create(
model="heavy-logic",
messages=[{"role": "user", "content": "Write a Python red-black tree implementation."}]
)
```
### Two-Level Dispatch
The `dispatcher` group uses a classifier to score prompts 1-10, then routes to the appropriate tier group:
```python
# Automatically routed based on complexity:
# 1-3 -> fast-flow (classification, basic Q&A)
# 4-7 -> standard-pro (general assistant, long docs)
# 8-10 -> heavy-logic (complex coding, logic, agents)
response = client.chat.completions.create(
model="dispatcher",
messages=[{"role": "user", "content": "Debug this race condition in my Go code."}]
)
# This goes: dispatcher -> heavy-logic -> deepseek-v4-pro
```
Pre-seeded groups:
| Group | Level | Strategy | Targets | Primary Use |
|-------|-------|----------|---------|-------------|
| `fast-flow` | 2 | heuristic | deepseek-v4-flash, gpt-5.4-nano | Classification, JSON, Basic Q&A |
| `standard-pro` | 5 | heuristic | gpt-5.4-mini, gemini-3-flash-preview | General Assistant, Long Docs |
| `heavy-logic` | 9 | heuristic | grok-4.3, kimi-k2.6, deepseek-v4-pro | Complex Coding, Logic, Agents |
| `dispatcher` | - | classifier | fast-flow, standard-pro, heavy-logic | Auto-dispatches by complexity |
| `deepseek-auto` | - | heuristic | deepseek-chat, deepseek-reasoner | Legacy provider group |
| `openai-auto` | - | heuristic | gpt-4o-mini, gpt-4o | Legacy provider group |
| `gemini-auto` | - | heuristic | gemini-2.5-flash, gemini-2.5-pro | Legacy provider group |
### Image Generation (DALL-E / Imagen) ### Image Generation (DALL-E / Imagen)
@@ -191,7 +265,7 @@ resp = client.images.generate(
) )
print(resp.data[0].url) print(resp.data[0].url)
# Imagen 3 (Gemini) uses same endpoint # Imagen 3 (Gemini) -- uses same endpoint
resp = client.images.generate( resp = client.images.generate(
model="imagen-3.0-generate-001", model="imagen-3.0-generate-001",
prompt="A gopher coding in Go", prompt="A gopher coding in Go",
+14 -1
View File
@@ -15,11 +15,24 @@
- [x] Dashboard Analytics & Usage Summary (Fixed SQL robustness) - [x] Dashboard Analytics & Usage Summary (Fixed SQL robustness)
- [x] WebSocket for real-time dashboard updates (Hub with client counting) - [x] WebSocket for real-time dashboard updates (Hub with client counting)
- [x] Asynchronous Request Logging to SQLite - [x] Asynchronous Request Logging to SQLite
- [x] Update documentation (README, deployment, architecture)
- [x] Cost Tracking accuracy (Registry integration with `models.dev`) - [x] Cost Tracking accuracy (Registry integration with `models.dev`)
- [x] Model Listing endpoint (`/v1/models`) with provider filtering - [x] Model Listing endpoint (`/v1/models`) with provider filtering
- [x] System Metrics endpoint (`/api/system/metrics` using `gopsutil`) - [x] System Metrics endpoint (`/api/system/metrics` using `gopsutil`)
- [x] Fixed dashboard 404s and 500s - [x] Fixed dashboard 404s and 500s
- [x] Model groups with heuristic and classifier routing strategies
- [x] Hierarchical routing — groups can target other groups with cycle detection
- [x] Classifier bucket mapping via complexity_threshold (1-10 scale -> N targets)
- [x] Two-level dispatch — classifier router delegates to tier groups
- [x] Model groups exposed in /v1/models endpoint (owned_by: gophergate)
- [x] logic_level and primary_use metadata on model groups
- [x] Model group CRUD dashboard page
- [x] dispatcher, heavy-logic, standard-pro, fast-flow seed groups
- [x] Provider selection moved after routing resolution (fixes group routing)
- [x] Classifier selector model routed to correct provider (selectProvider)
- [x] DeepSeek English system prompt injection (ensureEnglish)
- [x] Deploy script (deploy.sh)
- [x] Recent Activity pane shows resolved model + group annotation
- [x] Model names aligned with models.dev registry
## Planned Resolutions (High Priority) ## Planned Resolutions (High Priority)
+142
View File
@@ -0,0 +1,142 @@
# GopherGate Comprehensive Code Review & Architecture Report
**Date:** July 21, 2026
**Target Repository:** `LobotomyLabs/GopherGate`
**Language:** Go (1.23+) & JavaScript (Vanilla Frontend)
**Status:** Completed
---
## 1. Executive Summary
A comprehensive code review and architectural analysis of the **GopherGate** unified LLM proxy gateway codebase was conducted. The application is written in Go using `gin-gonic/gin`, `sqlx`, `sqlite`, `gobreaker`, and `resty`.
Overall, the codebase is well-structured, modular, and cleanly separated into logical packages (`cmd`, `internal/config`, `internal/db`, `internal/middleware`, `internal/models`, `internal/providers`, `internal/router`, `internal/server`, `internal/utils`).
However, several critical and high-severity security, concurrency, and reliability issues were uncovered during the review. Addressing these items will ensure production-grade security, thread safety, and resilience.
---
## 2. Findings Summary Matrix
| ID | Category | Severity | Component | Issue Description |
|---|---|---|---|---|
| **SEC-01** | Security | 🔴 **Critical** | `internal/providers/gemini.go` | API Key exposure in plain-text debug log outputs (`?key=...`) |
| **CONC-01** | Concurrency | 🟠 **High** | `internal/server/server.go` | Un-mutexed map access to `s.providers` during runtime updates |
| **CONC-02** | Concurrency | 🟠 **High** | `internal/models/registry.go` | Data race on `ModelRegistry` map during concurrent model lookups |
| **CONC-03** | Concurrency | 🟠 **High** | `internal/router/router.go` | Unsynchronized map replacement in `Router.Reload()` |
| **REL-01** | Reliability | 🟠 **High** | `internal/providers/circuit_breaker.go` | Circuit breaker bypassed for all streaming completions |
| **REL-02** | Reliability | 🟡 **Medium** | `internal/providers/helpers.go` | Goroutine leaks on stream disconnect (missing `ctx.Done()` check) |
| **ERR-01** | Error Handling | 🟡 **Medium** | `internal/providers/*.go` | Empty error strings due to reading consumed `resp.RawBody()` |
| **SEC-02** | Security | 🟡 **Medium** | `internal/server/users.go` | Swallowed DB error outputs on user updates & default credentials |
| **SEC-03** | Security | 🔵 **Low** | `internal/server/system.go` | Raw auth tokens returned in settings API response |
| **CODE-01**| Code Quality | 🔵 **Low** | `internal/server/logging.go` | Fixed channel buffer (100) drops log entries under load |
---
## 3. Detailed Findings & Remediation Guidelines
### 🔴 SEC-01: Plain-Text API Key Exposure in Logs
- **Location:** [`internal/providers/gemini.go:L378`](file:///home/newkirk/Projects/gophergate/internal/providers/gemini.go#L378), [`L636`](file:///home/newkirk/Projects/gophergate/internal/providers/gemini.go#L636)
- **Impact:** Gemini REST endpoints accept authentication via query parameter `?key=YOUR_API_KEY`. The provider prints debugging information using `fmt.Printf("[Gemini] POST %s\n", url)`. This logs live API keys directly into server stdout / log aggregators.
- **Remediation:** Remove plain-text URL prints or sanitize query parameters before logging:
```go
// Sanitize key query parameter before logging
sanitizedURL := regexp.MustCompile(`key=[^&]+`).ReplaceAllString(url, "key=REDACTED")
log.Printf("[Gemini] POST %s", sanitizedURL)
```
---
### 🟠 CONC-01: Concurrent Map Mutation on `s.providers`
- **Location:** [`internal/server/server.go:L146-L185`](file:///home/newkirk/Projects/gophergate/internal/server/server.go#L146-L185)
- **Impact:** During `RefreshProviders()` (triggered by background initialization or via `/api/providers/:name` admin updates), `delete(s.providers, id)` and `s.providers[id] = ...` mutate `s.providers` without holding a write lock. Concurrent HTTP requests accessing `s.selectProvider()` or `handleChatCompletions()` read from `s.providers`, causing a Go runtime fatal map panic (`fatal error: concurrent map read and map write`).
- **Remediation:** Guard `s.providers` with a `sync.RWMutex`:
```go
s.providersMu.Lock()
s.providers[id] = providers.NewCircuitBreakerProvider(p)
s.providersMu.Unlock()
```
---
### 🟠 CONC-02: Data Race on `ModelRegistry` Dynamic Updates
- **Location:** [`internal/models/registry.go:L175-L211`](file:///home/newkirk/Projects/gophergate/internal/models/registry.go#L175-L211) & [`internal/server/server.go:L56-L65`](file:///home/newkirk/Projects/gophergate/internal/server/server.go#L56-L65)
- **Impact:** The server fetches `models.dev` in a background goroutine and overwrites `s.registry`. However, `ModelRegistry.FindModel()` traverses internal maps (`r.Providers`) without acquiring read locks. If a lookup occurs while `s.registry` or nested provider maps are being updated, a data race or nil-pointer dereference will occur.
- **Remediation:** Protect registry lookups with `RWMutex` locks, or use `atomic.Pointer[models.ModelRegistry]` for lock-free hot swapping.
---
### 🟠 CONC-03: Unsynchronized `Router.Reload()`
- **Location:** [`internal/router/router.go:L135-L140`](file:///home/newkirk/Projects/gophergate/internal/router/router.go#L135-L140)
- **Impact:** Calling `r.Reload(groups)` instantiates a new `r.groups = make(...)` map directly on the existing `Router` struct while active HTTP requests are concurrently calling `r.IsGroup()` or `r.Route()`.
- **Remediation:** Add a `sync.RWMutex` to `Router` and acquire `RLock()` during `Route()` / `IsGroup()` and `Lock()` during `Reload()`.
---
### 🟠 REL-01: Circuit Breaker Bypassed for Streaming Requests
- **Location:** [`internal/providers/circuit_breaker.go:L52-L56`](file:///home/newkirk/Projects/gophergate/internal/providers/circuit_breaker.go#L52-L56), [`L78-L81`](file:///home/newkirk/Projects/gophergate/internal/providers/circuit_breaker.go#L78-L81)
- **Impact:** Streaming methods (`ChatCompletionStream` and `ResponsesStream`) bypass `gobreaker` execution logic entirely. Upstream provider outages during streaming requests will not trigger the circuit breaker, leaving backends vulnerable to request flooding and thread exhaustion.
- **Remediation:** Execute initial connection setup inside `cb.Execute()` and propagate stream channel failure signals back to the circuit breaker.
---
### 🟡 REL-02: Streaming Goroutine Leaks on Client Disconnect
- **Location:** [`internal/providers/helpers.go:L379-L396`](file:///home/newkirk/Projects/gophergate/internal/providers/helpers.go#L379-L396), [`L474-L516`](file:///home/newkirk/Projects/gophergate/internal/providers/helpers.go#L474-L516)
- **Impact:** In `StreamOpenAI` and `StreamGemini`, chunk forwarding goroutines execute `ch <- chunk` without selecting on `ctx.Done()`. If a client disconnects prematurely, writing to `ch` blocks indefinitely, causing goroutine leaks.
- **Remediation:** Use select statement for channel writes:
```go
select {
case ch <- chunk:
case <-ctx.Done():
return
}
```
---
### 🟡 ERR-01: Empty Provider Error Messages
- **Location:** [`internal/providers/gemini.go:L130`](file:///home/newkirk/Projects/gophergate/internal/providers/gemini.go#L130), [`deepseek.go:L115`](file:///home/newkirk/Projects/gophergate/internal/providers/deepseek.go#L115), [`xiaomi.go:L56`](file:///home/newkirk/Projects/gophergate/internal/providers/xiaomi.go#L56)
- **Impact:** Non-streaming error handling reads `io.ReadAll(resp.RawBody())` on `resty.Response`. Resty auto-drains and closes `RawBody()` on request completion unless configured otherwise, causing `io.ReadAll` to return an empty slice and masking upstream API errors.
- **Remediation:** Use `resp.Body()` or `resp.String()` instead of `resp.RawBody()`.
---
### 🟡 SEC-02: Swallowed Database Errors & Default Credentials
- **Location:** [`internal/server/users.go:L73-L84`](file:///home/newkirk/Projects/gophergate/internal/server/users.go#L73-L84), [`internal/db/db.go:L179-L195`](file:///home/newkirk/Projects/gophergate/internal/db/db.go#L179-L195)
- **Impact:** `handleUpdateUser` ignores error outputs from `s.database.Exec()`. Database update failures (e.g., constraint violations or locks) return HTTP 200 OK. Furthermore, initial setup seeds default admin credentials (`admin` / `admin123`).
- **Remediation:** Always capture and handle `err` from `db.Exec()`. Require explicit password set on initial setup.
---
### 🔵 SEC-03: Sensitive Auth Tokens Exposed in Settings API
- **Location:** [`internal/server/system.go:L81`](file:///home/newkirk/Projects/gophergate/internal/server/system.go#L81)
- **Impact:** `/api/system/settings` includes the raw static `auth_tokens` slice from server config in the JSON response payload.
- **Remediation:** Omit raw tokens from settings response or return masked representations (e.g., `["sk-***..."]`).
---
### 🔵 CODE-01: Fixed Request Logger Channel Buffer
- **Location:** [`internal/server/logging.go:L39`](file:///home/newkirk/Projects/gophergate/internal/server/logging.go#L39)
- **Impact:** The request logging channel is initialized with a capacity of 100 (`make(chan RequestLog, 100)`). High request concurrency causes non-blocking sends to drop analytics log entries silently.
- **Remediation:** Increase channel capacity or introduce a dynamic ring buffer worker pool.
---
## 4. Verification & Testing Summary
All existing unit tests in the codebase pass cleanly:
```bash
go test -cover ./...
```
- `gophergate/internal/models`: **61.2%** test coverage
- `gophergate/internal/router`: **45.3%** test coverage
- Static analysis check (`go vet ./...`): **Clean** (0 warnings)
---
## 5. Next Steps & Recommendations
1. **Immediate Patch:** Sanitize logging in `gemini.go` (SEC-01) and add `sync.RWMutex` to `s.providers` (CONC-01).
2. **Concurrency Audit:** Implement atomic/mutex locking for `ModelRegistry` (CONC-02) and `Router.Reload()` (CONC-03).
3. **Resilience Patch:** Add `ctx.Done()` checks in streaming loops (REL-02) and wrap initial stream calls in `gobreaker` (REL-01).
Executable
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
set -e
BINARY_NAME="gophergate"
SOURCE_PATH="./cmd/gophergate/main.go"
echo "Pulling latest changes from git..."
git stash || true
git pull
echo "Building the application..."
go build -o "$BINARY_NAME" "$SOURCE_PATH"
echo "Restarting service..."
systemctl restart gophergate
sleep 2
if systemctl is-active --quiet gophergate; then
echo "Deploy complete. Service is running."
systemctl status gophergate --no-pager | head -5
else
echo "Service failed to start! Check: journalctl -u gophergate -n 20"
exit 1
fi
+17
View File
@@ -26,6 +26,22 @@ go build -o gophergate ./cmd/gophergate
./gophergate ./gophergate
``` ```
### Quick Deploy Script
A `deploy.sh` script is provided for production restarts:
```bash
./deploy.sh
```
This script will:
1. Stop any running gophergate process
2. Pull latest changes from git
3. Build the application
4. Start it in the background (logs to `gophergate.log`)
If the build fails, the previous binary is left untouched and the script exits.
## Docker Deployment ## Docker Deployment
The project includes a multi-stage `Dockerfile` for minimal image size. The project includes a multi-stage `Dockerfile` for minimal image size.
@@ -50,3 +66,4 @@ docker run -d \
- **SSL/TLS:** It is recommended to run the proxy behind a reverse proxy like Nginx or Caddy for SSL termination. - **SSL/TLS:** It is recommended to run the proxy behind a reverse proxy like Nginx or Caddy for SSL termination.
- **Backups:** Regularly backup the `data/llm_proxy.db` file. - **Backups:** Regularly backup the `data/llm_proxy.db` file.
- **Monitoring:** Monitor the `/health` endpoint for system status. - **Monitoring:** Monitor the `/health` endpoint for system status.
- **Logs:** When started with `deploy.sh` or `nohup`, logs are written to `gophergate.log`.
+23 -2
View File
@@ -23,6 +23,7 @@ type ServerConfig struct {
Host string `mapstructure:"host"` Host string `mapstructure:"host"`
AuthTokens []string `mapstructure:"auth_tokens"` AuthTokens []string `mapstructure:"auth_tokens"`
WSAllowedOrigin string `mapstructure:"ws_allowed_origin"` WSAllowedOrigin string `mapstructure:"ws_allowed_origin"`
MaxHistoryMessages int `mapstructure:"max_history_messages"`
} }
type DatabaseConfig struct { type DatabaseConfig struct {
@@ -37,6 +38,7 @@ type ProviderConfig struct {
Moonshot MoonshotConfig `mapstructure:"moonshot"` Moonshot MoonshotConfig `mapstructure:"moonshot"`
Grok GrokConfig `mapstructure:"grok"` Grok GrokConfig `mapstructure:"grok"`
Ollama OllamaConfig `mapstructure:"ollama"` Ollama OllamaConfig `mapstructure:"ollama"`
Xiaomi XiaomiConfig `mapstructure:"xiaomi"`
} }
type OpenAIConfig struct { type OpenAIConfig struct {
@@ -81,6 +83,13 @@ type OllamaConfig struct {
Models []string `mapstructure:"models"` Models []string `mapstructure:"models"`
} }
type XiaomiConfig struct {
APIKeyEnv string `mapstructure:"api_key_env"`
BaseURL string `mapstructure:"base_url"`
DefaultModel string `mapstructure:"default_model"`
Enabled bool `mapstructure:"enabled"`
}
func Load() (*Config, error) { func Load() (*Config, error) {
v := viper.New() v := viper.New()
@@ -88,6 +97,7 @@ func Load() (*Config, error) {
v.SetDefault("server.port", 8080) v.SetDefault("server.port", 8080)
v.SetDefault("server.host", "0.0.0.0") v.SetDefault("server.host", "0.0.0.0")
v.SetDefault("server.auth_tokens", []string{}) v.SetDefault("server.auth_tokens", []string{})
v.SetDefault("server.max_history_messages", 0)
v.SetDefault("database.path", "./data/llm_proxy.db") v.SetDefault("database.path", "./data/llm_proxy.db")
v.SetDefault("database.max_connections", 10) v.SetDefault("database.max_connections", 10)
@@ -98,7 +108,7 @@ func Load() (*Config, error) {
v.SetDefault("providers.gemini.api_key_env", "GEMINI_API_KEY") v.SetDefault("providers.gemini.api_key_env", "GEMINI_API_KEY")
v.SetDefault("providers.gemini.base_url", "https://generativelanguage.googleapis.com/v1") v.SetDefault("providers.gemini.base_url", "https://generativelanguage.googleapis.com/v1")
v.SetDefault("providers.gemini.default_model", "gemini-2.0-flash") v.SetDefault("providers.gemini.default_model", "gemini-2.5-flash")
v.SetDefault("providers.gemini.enabled", true) v.SetDefault("providers.gemini.enabled", true)
v.SetDefault("providers.deepseek.api_key_env", "DEEPSEEK_API_KEY") v.SetDefault("providers.deepseek.api_key_env", "DEEPSEEK_API_KEY")
@@ -108,7 +118,7 @@ func Load() (*Config, error) {
v.SetDefault("providers.moonshot.api_key_env", "MOONSHOT_API_KEY") v.SetDefault("providers.moonshot.api_key_env", "MOONSHOT_API_KEY")
v.SetDefault("providers.moonshot.base_url", "https://api.moonshot.ai/v1") v.SetDefault("providers.moonshot.base_url", "https://api.moonshot.ai/v1")
v.SetDefault("providers.moonshot.default_model", "kimi-k2.5") v.SetDefault("providers.moonshot.default_model", "kimi-k2.7-code")
v.SetDefault("providers.moonshot.enabled", true) v.SetDefault("providers.moonshot.enabled", true)
v.SetDefault("providers.grok.api_key_env", "GROK_API_KEY") v.SetDefault("providers.grok.api_key_env", "GROK_API_KEY")
@@ -120,6 +130,11 @@ func Load() (*Config, error) {
v.SetDefault("providers.ollama.enabled", false) v.SetDefault("providers.ollama.enabled", false)
v.SetDefault("providers.ollama.models", []string{}) v.SetDefault("providers.ollama.models", []string{})
v.SetDefault("providers.xiaomi.api_key_env", "XIAOMI_API_KEY")
v.SetDefault("providers.xiaomi.base_url", "https://api.xiaomimimo.com/v1")
v.SetDefault("providers.xiaomi.default_model", "mimo-v2.5")
v.SetDefault("providers.xiaomi.enabled", true)
// Environment variables // Environment variables
v.SetEnvPrefix("LLM_PROXY") v.SetEnvPrefix("LLM_PROXY")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "__")) v.SetEnvKeyReplacer(strings.NewReplacer(".", "__"))
@@ -129,6 +144,7 @@ func Load() (*Config, error) {
v.BindEnv("encryption_key", "LLM_PROXY__ENCRYPTION_KEY") v.BindEnv("encryption_key", "LLM_PROXY__ENCRYPTION_KEY")
v.BindEnv("server.port", "LLM_PROXY__SERVER__PORT") v.BindEnv("server.port", "LLM_PROXY__SERVER__PORT")
v.BindEnv("server.host", "LLM_PROXY__SERVER__HOST") 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.enabled", "LLM_PROXY__PROVIDERS__OLLAMA__ENABLED")
v.BindEnv("providers.ollama.base_url", "LLM_PROXY__PROVIDERS__OLLAMA__BASE_URL") v.BindEnv("providers.ollama.base_url", "LLM_PROXY__PROVIDERS__OLLAMA__BASE_URL")
v.BindEnv("providers.ollama.models", "LLM_PROXY__PROVIDERS__OLLAMA__MODELS") v.BindEnv("providers.ollama.models", "LLM_PROXY__PROVIDERS__OLLAMA__MODELS")
@@ -161,6 +177,9 @@ func Load() (*Config, error) {
cfg.Server.Host = host cfg.Server.Host = host
} }
if maxHistory := os.Getenv("LLM_PROXY__SERVER__MAX_HISTORY_MESSAGES"); maxHistory != "" {
fmt.Sscanf(maxHistory, "%d", &cfg.Server.MaxHistoryMessages)
}
// Ollama overrides // Ollama overrides
if enabled := os.Getenv("LLM_PROXY__PROVIDERS__OLLAMA__ENABLED"); enabled != "" { if enabled := os.Getenv("LLM_PROXY__PROVIDERS__OLLAMA__ENABLED"); enabled != "" {
@@ -210,6 +229,8 @@ func (c *Config) GetAPIKey(provider string) (string, error) {
case "ollama": case "ollama":
// Ollama doesn't require an API key // Ollama doesn't require an API key
return "", nil return "", nil
case "xiaomi":
envVar = c.Providers.Xiaomi.APIKeyEnv
default: default:
return "", fmt.Errorf("unknown provider: %s", provider) return "", fmt.Errorf("unknown provider: %s", provider)
} }
+92 -7
View File
@@ -26,12 +26,20 @@ func Init(path string) (*DB, error) {
} }
// Connect to SQLite // 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) db, err := sqlx.Connect("sqlite", dsn)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err) return nil, fmt.Errorf("failed to connect to database: %w", err)
} }
// Enable Write-Ahead Logging (WAL) and set a busy timeout to handle concurrent access
if _, err := db.Exec("PRAGMA journal_mode=WAL;"); err != nil {
log.Printf("failed to enable WAL mode: %v", err)
}
if _, err := db.Exec("PRAGMA busy_timeout=5000;"); err != nil {
log.Printf("failed to set busy timeout: %v", err)
}
instance := &DB{db} instance := &DB{db}
// Run migrations // Run migrations
@@ -130,6 +138,8 @@ func (db *DB) RunMigrations() error {
targets TEXT NOT NULL DEFAULT '[]', targets TEXT NOT NULL DEFAULT '[]',
complexity_threshold INTEGER, complexity_threshold INTEGER,
heuristic_rules TEXT, heuristic_rules TEXT,
logic_level INTEGER,
primary_use TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`, )`,
@@ -162,6 +172,10 @@ func (db *DB) RunMigrations() error {
} }
} }
// Add columns to existing model_groups tables (safe — SQLite ignores duplicates on error)
db.Exec("ALTER TABLE model_groups ADD COLUMN logic_level INTEGER")
db.Exec("ALTER TABLE model_groups ADD COLUMN primary_use TEXT")
// Default admin user // Default admin user
var count int var count int
if err := db.Get(&count, "SELECT COUNT(*) FROM users"); err != nil { if err := db.Get(&count, "SELECT COUNT(*) FROM users"); err != nil {
@@ -189,15 +203,72 @@ func (db *DB) RunMigrations() error {
// Seed default model groups // Seed default model groups
defaultGroups := []struct { defaultGroups := []struct {
id, strategy, targets string id, strategy, targets, selectorModel, heuristicRules string
complexityThreshold, logicLevel *int
primaryUse *string
}{ }{
{"deepseek-auto", "heuristic", `["deepseek-chat","deepseek-reasoner"]`}, {"deepseek-auto", "heuristic", `["deepseek-chat","deepseek-reasoner"]`, "", "", nil, nil, nil},
{"openai-auto", "heuristic", `["gpt-4o-mini","gpt-4o"]`}, {"openai-auto", "heuristic", `["gpt-4o-mini","gpt-4o"]`, "", "", nil, nil, nil},
{"gemini-auto", "heuristic", `["gemini-2.0-flash","gemini-2.5-pro"]`}, {"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 { for _, g := range defaultGroups {
db.Exec(`INSERT OR IGNORE INTO model_groups (id, strategy, targets) VALUES (?, ?, ?)`, 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) g.id, g.strategy, g.targets, nilStr(g.selectorModel), nilStr(g.heuristicRules), g.complexityThreshold, g.logicLevel, g.primaryUse)
} }
return nil return nil
@@ -238,6 +309,7 @@ type LLMRequest struct {
ResponseBody *string `db:"response_body"` ResponseBody *string `db:"response_body"`
CacheReadTokens int `db:"cache_read_tokens"` CacheReadTokens int `db:"cache_read_tokens"`
CacheWriteTokens int `db:"cache_write_tokens"` CacheWriteTokens int `db:"cache_write_tokens"`
ModelGroup string `db:"model_group"`
} }
type ProviderConfig struct { type ProviderConfig struct {
@@ -293,6 +365,19 @@ type ModelGroup struct {
Targets string `db:"targets" json:"targets"` // JSON array Targets string `db:"targets" json:"targets"` // JSON array
ComplexityThreshold *int `db:"complexity_threshold" json:"complexity_threshold"` ComplexityThreshold *int `db:"complexity_threshold" json:"complexity_threshold"`
HeuristicRules *string `db:"heuristic_rules" json:"heuristic_rules"` HeuristicRules *string `db:"heuristic_rules" json:"heuristic_rules"`
LogicLevel *int `db:"logic_level" json:"logic_level"`
PrimaryUse *string `db:"primary_use" json:"primary_use"`
CreatedAt time.Time `db:"created_at" json:"created_at"` CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
} }
func intPtr(v int) *int { return &v }
func strPtr(v string) *string { return &v }
// nilStr returns a *string for non-empty strings, nil for empty.
func nilStr(v string) *string {
if v == "" {
return nil
}
return &v
}
+92 -6
View File
@@ -4,6 +4,8 @@ import (
"log" "log"
"net/http" "net/http"
"strings" "strings"
"sync"
"time"
"gophergate/internal/db" "gophergate/internal/db"
"gophergate/internal/models" "gophergate/internal/models"
@@ -11,12 +13,33 @@ import (
"github.com/gin-gonic/gin" "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 { func AuthMiddleware(database *db.DB, requireAuth bool) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization") authHeader := c.GetHeader("Authorization")
if authHeader == "" {
// Fallback to checking "Authentication" header in case the client library used the wrong name
authHeader = c.GetHeader("Authentication")
}
if authHeader == "" { if authHeader == "" {
if requireAuth { if requireAuth {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": gin.H{
"message": "Missing Authorization or Authentication header.",
"type": "invalid_request_error",
"param": nil,
"code": "401",
},
})
return return
} }
c.Next() c.Next()
@@ -25,23 +48,86 @@ func AuthMiddleware(database *db.DB, requireAuth bool) gin.HandlerFunc {
token := strings.TrimPrefix(authHeader, "Bearer ") token := strings.TrimPrefix(authHeader, "Bearer ")
if token == authHeader { // No "Bearer " prefix if token == authHeader { // No "Bearer " prefix
if requireAuth {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": gin.H{
"message": "Invalid authorization header format. Bearer token required.",
"type": "invalid_request_error",
"param": nil,
"code": "401",
},
})
return
}
c.Next() c.Next()
return return
} }
// Try to resolve client from database // Try to resolve client from cache first
var clientID string var clientID string
err := database.Get(&clientID, "UPDATE client_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token = ? AND is_active = 1 RETURNING client_id", 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{ c.Set("auth", models.AuthInfo{
Token: token, Token: token,
ClientID: clientID, ClientID: clientID,
}) })
// Update last_used_at asynchronously so that database locks or write delays
// do not block or fail the client's request authentication.
go func(t string) {
if _, updateErr := database.Exec("UPDATE client_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token = ?", t); updateErr != nil {
log.Printf("Warning: failed to update client token last_used_at: %v", updateErr)
}
}(token)
c.Next() c.Next()
} else { } else {
log.Printf("Token not found or inactive in DB: %s", token) maskedToken := "••••"
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or inactive token"}) if len(token) > 8 {
maskedToken = token[:3] + "••••" + token[len(token)-4:]
}
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.",
"type": "invalid_request_error",
"param": nil,
"code": "401",
},
})
} }
} }
} }
+2
View File
@@ -32,6 +32,7 @@ type ChatMessage struct {
ToolCalls []ToolCall `json:"tool_calls,omitempty"` ToolCalls []ToolCall `json:"tool_calls,omitempty"`
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
ToolCallID *string `json:"tool_call_id,omitempty"` ToolCallID *string `json:"tool_call_id,omitempty"`
Prefix *bool `json:"prefix,omitempty"`
} }
type ContentPart struct { type ContentPart struct {
@@ -168,6 +169,7 @@ type UnifiedMessage struct {
ToolCalls []ToolCall ToolCalls []ToolCall
Name *string Name *string
ToolCallID *string ToolCallID *string
Prefix *bool
} }
type UnifiedContentPart struct { type UnifiedContentPart struct {
+162 -19
View File
@@ -1,8 +1,31 @@
package models package models
import "strings" import (
"strings"
"sync"
)
// CanonicalProviders lists the original model creators in priority order.
// When a model name exists in multiple providers (e.g. deepseek-v4-pro in
// deepseek, ollama-cloud, openrouter, etc.), these providers take precedence
// so the proxy uses authoritative metadata (pricing, limits) rather than a
// reseller's values.
var CanonicalProviders = []string{
"openai",
"google",
"deepseek",
"xai",
"moonshotai",
"moonshotai-cn",
"anthropic",
"mistral",
"cohere",
"minimax",
"xiaomi",
}
type ModelRegistry struct { type ModelRegistry struct {
mu sync.RWMutex
Providers map[string]ProviderInfo `json:"-"` Providers map[string]ProviderInfo `json:"-"`
} }
@@ -39,40 +62,160 @@ type ModelModalities struct {
Output []string `json:"output"` Output []string `json:"output"`
} }
func (r *ModelRegistry) FindModel(modelID string) *ModelMetadata { // findInCanonical searches the canonical providers in order for an exact model
// First try exact match in models map // key match. Returns the metadata and true if found.
for _, provider := range r.Providers { func (r *ModelRegistry) findInCanonical(modelID string) (*ModelMetadata, bool) {
if model, ok := provider.Models[modelID]; ok { for _, key := range CanonicalProviders {
return &model if p, ok := r.Providers[key]; ok {
if m, ok := p.Models[modelID]; ok {
return &m, true
} }
} }
}
return nil, false
}
// Try searching by ID in metadata // findInAll searches all providers (map iteration, random order) for an exact
for _, provider := range r.Providers { // model key match. Used as fallback when canonical search fails.
for _, model := range provider.Models { func (r *ModelRegistry) findInAll(modelID string) (*ModelMetadata, bool) {
if model.ID == modelID { for _, p := range r.Providers {
return &model if m, ok := p.Models[modelID]; ok {
return &m, true
} }
} }
return nil, false
} }
// Try reverse fuzzy matching (e.g. 'gpt-5.4-mini' matching 'gpt-5.4-mini-2026-04-01') // findInCanonicalByID searches canonical providers for a model whose metadata
for _, provider := range r.Providers { // ID field matches modelID.
for id, model := range provider.Models { func (r *ModelRegistry) findInCanonicalByID(modelID string) (*ModelMetadata, bool) {
for _, key := range CanonicalProviders {
if p, ok := r.Providers[key]; ok {
for _, m := range p.Models {
if m.ID == modelID {
return &m, true
}
}
}
}
return nil, false
}
// findInAllByID searches all providers for a model whose metadata ID field
// matches modelID.
func (r *ModelRegistry) findInAllByID(modelID string) (*ModelMetadata, bool) {
for _, p := range r.Providers {
for _, m := range p.Models {
if m.ID == modelID {
return &m, true
}
}
}
return nil, false
}
// findCanonicalReverseFuzzy searches canonical providers for any model whose
// key starts with modelID.
func (r *ModelRegistry) findCanonicalReverseFuzzy(modelID string) (*ModelMetadata, bool) {
for _, key := range CanonicalProviders {
if p, ok := r.Providers[key]; ok {
for id, m := range p.Models {
if strings.HasPrefix(id, modelID) { if strings.HasPrefix(id, modelID) {
return &model return &m, true
} }
} }
} }
}
return nil, false
}
// Try fuzzy matching (e.g. 'gpt-4o-2024-05-13' matching 'gpt-4o') // findAllReverseFuzzy searches all providers for any model whose key starts
for _, provider := range r.Providers { // with modelID.
for id, model := range provider.Models { func (r *ModelRegistry) findAllReverseFuzzy(modelID string) (*ModelMetadata, bool) {
for _, p := range r.Providers {
for id, m := range p.Models {
if strings.HasPrefix(id, modelID) {
return &m, true
}
}
}
return nil, false
}
// findCanonicalForwardFuzzy searches canonical providers for any model whose
// key is a prefix of modelID.
func (r *ModelRegistry) findCanonicalForwardFuzzy(modelID string) (*ModelMetadata, bool) {
for _, key := range CanonicalProviders {
if p, ok := r.Providers[key]; ok {
for id, m := range p.Models {
if strings.HasPrefix(modelID, id) { if strings.HasPrefix(modelID, id) {
return &model return &m, true
} }
} }
} }
}
return nil, false
}
// findAllForwardFuzzy searches all providers for any model whose key is a
// prefix of modelID.
func (r *ModelRegistry) findAllForwardFuzzy(modelID string) (*ModelMetadata, bool) {
for _, p := range r.Providers {
for id, m := range p.Models {
if strings.HasPrefix(modelID, id) {
return &m, true
}
}
}
return nil, false
}
// FindModel looks up model metadata by ID. It searches canonical providers
// first at each strategy level (exact key, metadata ID, reverse fuzzy,
// forward fuzzy) and falls back to all providers only when canonical search
// yields no result. This prevents reseller entries (ollama-cloud, openrouter,
// etc.) from overriding the original provider's authoritative pricing and
// limits.
func (r *ModelRegistry) FindModel(modelID string) *ModelMetadata {
if r == nil {
return nil
}
r.mu.RLock()
defer r.mu.RUnlock()
// 1. Exact key match — canonical first, then all
if m, ok := r.findInCanonical(modelID); ok {
return m
}
if m, ok := r.findInAll(modelID); ok {
return m
}
// 2. Match by metadata ID field — canonical first, then all
if m, ok := r.findInCanonicalByID(modelID); ok {
return m
}
if m, ok := r.findInAllByID(modelID); ok {
return m
}
// 3. Reverse fuzzy: model key starts with modelID
// e.g. 'gpt-5.4-mini' matching 'gpt-5.4-mini-2026-04-01'
if m, ok := r.findCanonicalReverseFuzzy(modelID); ok {
return m
}
if m, ok := r.findAllReverseFuzzy(modelID); ok {
return m
}
// 4. Forward fuzzy: modelID starts with model key
// e.g. 'gpt-4o-2024-05-13' matching 'gpt-4o'
if m, ok := r.findCanonicalForwardFuzzy(modelID); ok {
return m
}
if m, ok := r.findAllForwardFuzzy(modelID); ok {
return m
}
return nil return nil
} }
+29
View File
@@ -59,6 +59,35 @@ func TestModelRegistry_FindModel_NotFound(t *testing.T) {
} }
} }
func TestModelRegistry_FindModel_CanonicalPriority(t *testing.T) {
// Same model name in canonical (deepseek) and reseller (ollama-cloud).
// Canonical must win so the proxy uses authoritative limits.
r := &ModelRegistry{
Providers: map[string]ProviderInfo{
"ollama-cloud": {
Models: map[string]ModelMetadata{
"deepseek-v4-pro": {ID: "deepseek-v4-pro", Name: "DSv4 Pro (Ollama Cloud)", Limit: &ModelLimit{Context: 1048576, Output: 1048576}},
},
},
"deepseek": {
Models: map[string]ModelMetadata{
"deepseek-v4-pro": {ID: "deepseek-v4-pro", Name: "DeepSeek v4 Pro", Limit: &ModelLimit{Context: 1000000, Output: 384000}},
},
},
},
}
m := r.FindModel("deepseek-v4-pro")
if m == nil {
t.Fatal("expected to find deepseek-v4-pro")
}
if m.Name != "DeepSeek v4 Pro" {
t.Fatalf("expected DeepSeek v4 Pro (canonical), got %s", m.Name)
}
if m.Limit.Output != 384000 {
t.Fatalf("expected output limit 384000 (canonical), got %d", m.Limit.Output)
}
}
func TestModelRegistry_FindModel_ReverseFuzzy(t *testing.T) { func TestModelRegistry_FindModel_ReverseFuzzy(t *testing.T) {
r := &ModelRegistry{ r := &ModelRegistry{
Providers: map[string]ProviderInfo{ Providers: map[string]ProviderInfo{
+12 -3
View File
@@ -50,9 +50,13 @@ func (cbp *CircuitBreakerProvider) ChatCompletion(ctx context.Context, req *mode
} }
func (cbp *CircuitBreakerProvider) ChatCompletionStream(ctx context.Context, req *models.UnifiedRequest) (<-chan *models.ChatCompletionStreamResponse, error) { func (cbp *CircuitBreakerProvider) ChatCompletionStream(ctx context.Context, req *models.UnifiedRequest) (<-chan *models.ChatCompletionStreamResponse, error) {
// Circuit breaker for streaming is tricky. We'll just call the provider directly. result, err := cbp.cb.Execute(func() (interface{}, error) {
// Future: Implement a way to track stream failures in the circuit breaker.
return cbp.provider.ChatCompletionStream(ctx, req) return cbp.provider.ChatCompletionStream(ctx, req)
})
if err != nil {
return nil, err
}
return result.(<-chan *models.ChatCompletionStreamResponse), nil
} }
func (cbp *CircuitBreakerProvider) ImageGeneration(ctx context.Context, req *models.ImageGenerationRequest) (*models.ImageGenerationResponse, error) { func (cbp *CircuitBreakerProvider) ImageGeneration(ctx context.Context, req *models.ImageGenerationRequest) (*models.ImageGenerationResponse, error) {
@@ -76,6 +80,11 @@ func (cbp *CircuitBreakerProvider) Responses(ctx context.Context, req *models.Re
} }
func (cbp *CircuitBreakerProvider) ResponsesStream(ctx context.Context, req *models.ResponsesRequest) (<-chan *models.ResponsesStreamChunk, error) { func (cbp *CircuitBreakerProvider) ResponsesStream(ctx context.Context, req *models.ResponsesRequest) (<-chan *models.ResponsesStreamChunk, error) {
// Circuit breaker passthrough for streaming (same pattern as ChatCompletionStream) result, err := cbp.cb.Execute(func() (interface{}, error) {
return cbp.provider.ResponsesStream(ctx, req) return cbp.provider.ResponsesStream(ctx, req)
})
if err != nil {
return nil, err
}
return result.(<-chan *models.ResponsesStreamChunk), nil
} }
+102 -9
View File
@@ -22,7 +22,7 @@ type DeepSeekProvider struct {
func NewDeepSeekProvider(cfg config.DeepSeekConfig, apiKey string) *DeepSeekProvider { func NewDeepSeekProvider(cfg config.DeepSeekConfig, apiKey string) *DeepSeekProvider {
return &DeepSeekProvider{ return &DeepSeekProvider{
client: resty.New().SetTimeout(10 * time.Minute), client: NewOptimizedRestyClient(10 * time.Minute),
config: cfg, config: cfg,
apiKey: apiKey, apiKey: apiKey,
} }
@@ -62,6 +62,9 @@ func (u *deepSeekUsage) ToUnified() *models.Usage {
} }
func (p *DeepSeekProvider) ChatCompletion(ctx context.Context, req *models.UnifiedRequest) (*models.ChatCompletionResponse, error) { func (p *DeepSeekProvider) ChatCompletion(ctx context.Context, req *models.UnifiedRequest) (*models.ChatCompletionResponse, error) {
// Ensure English responses — DeepSeek defaults to Chinese for some prompts
ensureEnglish(req)
messagesJSON, err := MessagesToOpenAIJSON(req.Messages) messagesJSON, err := MessagesToOpenAIJSON(req.Messages)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to convert messages: %w", err) return nil, fmt.Errorf("failed to convert messages: %w", err)
@@ -69,17 +72,24 @@ func (p *DeepSeekProvider) ChatCompletion(ctx context.Context, req *models.Unifi
body := BuildOpenAIBody(req, messagesJSON, false) body := BuildOpenAIBody(req, messagesJSON, false)
// Sanitize for deepseek-reasoner // Sanitize for models that support reasoning/thinking mode
if req.Model == "deepseek-reasoner" { isReasoner := strings.Contains(req.Model, "reasoner") || strings.Contains(req.Model, "v4") || strings.Contains(req.Model, "r1")
if isReasoner {
// deepseek-reasoner (R1) does not support these parameters
if req.Model == "deepseek-reasoner" || strings.HasPrefix(req.Model, "deepseek-r1") {
delete(body, "temperature") delete(body, "temperature")
delete(body, "top_p") delete(body, "top_p")
delete(body, "presence_penalty") delete(body, "presence_penalty")
delete(body, "frequency_penalty") delete(body, "frequency_penalty")
}
if msgs, ok := body["messages"].([]interface{}); ok { if msgs, ok := body["messages"].([]interface{}); ok {
for _, m := range msgs { for _, m := range msgs {
if msg, ok := m.(map[string]interface{}); ok { if msg, ok := m.(map[string]interface{}); ok {
if msg["role"] == "assistant" { if msg["role"] == "assistant" {
// DeepSeek requires reasoning_content to be passed back in history
// if the model is in thinking mode.
if msg["reasoning_content"] == nil { if msg["reasoning_content"] == nil {
msg["reasoning_content"] = "" msg["reasoning_content"] = ""
} }
@@ -103,7 +113,12 @@ func (p *DeepSeekProvider) ChatCompletion(ctx context.Context, req *models.Unifi
} }
if !resp.IsSuccess() { if !resp.IsSuccess() {
return nil, fmt.Errorf("DeepSeek API error (%d): %s", resp.StatusCode(), resp.String()) msg := resp.String()
if msg == "" && resp.RawBody() != nil {
bodyBytes, _ := io.ReadAll(resp.RawBody())
msg = string(bodyBytes)
}
return nil, fmt.Errorf("DeepSeek API error (%d): %s", resp.StatusCode(), msg)
} }
var respJSON map[string]interface{} var respJSON map[string]interface{}
@@ -129,6 +144,8 @@ func (p *DeepSeekProvider) ChatCompletion(ctx context.Context, req *models.Unifi
} }
func (p *DeepSeekProvider) ChatCompletionStream(ctx context.Context, req *models.UnifiedRequest) (<-chan *models.ChatCompletionStreamResponse, error) { func (p *DeepSeekProvider) ChatCompletionStream(ctx context.Context, req *models.UnifiedRequest) (<-chan *models.ChatCompletionStreamResponse, error) {
ensureEnglish(req)
messagesJSON, err := MessagesToOpenAIJSON(req.Messages) messagesJSON, err := MessagesToOpenAIJSON(req.Messages)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to convert messages: %w", err) return nil, fmt.Errorf("failed to convert messages: %w", err)
@@ -136,17 +153,24 @@ func (p *DeepSeekProvider) ChatCompletionStream(ctx context.Context, req *models
body := BuildOpenAIBody(req, messagesJSON, true) body := BuildOpenAIBody(req, messagesJSON, true)
// Sanitize for deepseek-reasoner // Sanitize for models that support reasoning/thinking mode
if req.Model == "deepseek-reasoner" { isReasoner := strings.Contains(req.Model, "reasoner") || strings.Contains(req.Model, "v4") || strings.Contains(req.Model, "r1")
if isReasoner {
// deepseek-reasoner (R1) does not support these parameters
if req.Model == "deepseek-reasoner" || strings.HasPrefix(req.Model, "deepseek-r1") {
delete(body, "temperature") delete(body, "temperature")
delete(body, "top_p") delete(body, "top_p")
delete(body, "presence_penalty") delete(body, "presence_penalty")
delete(body, "frequency_penalty") delete(body, "frequency_penalty")
}
if msgs, ok := body["messages"].([]interface{}); ok { if msgs, ok := body["messages"].([]interface{}); ok {
for _, m := range msgs { for _, m := range msgs {
if msg, ok := m.(map[string]interface{}); ok { if msg, ok := m.(map[string]interface{}); ok {
if msg["role"] == "assistant" { if msg["role"] == "assistant" {
// DeepSeek requires reasoning_content to be passed back in history
// if the model is in thinking mode.
if msg["reasoning_content"] == nil { if msg["reasoning_content"] == nil {
msg["reasoning_content"] = "" msg["reasoning_content"] = ""
} }
@@ -171,7 +195,15 @@ func (p *DeepSeekProvider) ChatCompletionStream(ctx context.Context, req *models
} }
if !resp.IsSuccess() { if !resp.IsSuccess() {
return nil, fmt.Errorf("DeepSeek API error (%d): %s", resp.StatusCode(), resp.String()) var msg string
if resp.RawBody() != nil {
bodyBytes, _ := io.ReadAll(resp.RawBody())
msg = string(bodyBytes)
}
if msg == "" {
msg = resp.String()
}
return nil, fmt.Errorf("DeepSeek API error (%d): %s", resp.StatusCode(), msg)
} }
ch := make(chan *models.ChatCompletionStreamResponse) ch := make(chan *models.ChatCompletionStreamResponse)
@@ -225,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) { 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) { 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
} }
+181 -41
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"strings" "strings"
"time" "time"
@@ -20,7 +21,7 @@ type GeminiProvider struct {
func NewGeminiProvider(cfg config.GeminiConfig, apiKey string) *GeminiProvider { func NewGeminiProvider(cfg config.GeminiConfig, apiKey string) *GeminiProvider {
return &GeminiProvider{ return &GeminiProvider{
client: resty.New().SetTimeout(10 * time.Minute), client: NewOptimizedRestyClient(10 * time.Minute),
config: cfg, config: cfg,
apiKey: apiKey, apiKey: apiKey,
} }
@@ -58,6 +59,7 @@ type GeminiPart struct {
InlineData *GeminiInlineData `json:"inlineData,omitempty"` InlineData *GeminiInlineData `json:"inlineData,omitempty"`
FunctionCall *GeminiFunctionCall `json:"functionCall,omitempty"` FunctionCall *GeminiFunctionCall `json:"functionCall,omitempty"`
FunctionResponse *GeminiFunctionResponse `json:"functionResponse,omitempty"` FunctionResponse *GeminiFunctionResponse `json:"functionResponse,omitempty"`
ThoughtSignature string `json:"thoughtSignature,omitempty"`
} }
type GeminiInlineData struct { type GeminiInlineData struct {
@@ -125,7 +127,13 @@ func (p *GeminiProvider) ImageGeneration(ctx context.Context, req *models.ImageG
} }
if !resp.IsSuccess() { if !resp.IsSuccess() {
return nil, fmt.Errorf("Gemini Imagen API error (%d): %s", resp.StatusCode(), resp.String()) msg := resp.String()
if msg == "" {
if body, err := io.ReadAll(resp.RawBody()); err == nil {
msg = string(body)
}
}
return nil, fmt.Errorf("Gemini Imagen API error (%d): %s", resp.StatusCode(), msg)
} }
// Parse Imagen response // Parse Imagen response
@@ -194,6 +202,8 @@ func (p *GeminiProvider) ResponsesStream(ctx context.Context, req *models.Respon
} }
func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.UnifiedRequest) (*models.ChatCompletionResponse, error) { func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.UnifiedRequest) (*models.ChatCompletionResponse, error) {
req.Model = normalizeGeminiModel(req.Model)
// Gemini mapping // Gemini mapping
var contents []GeminiContent var contents []GeminiContent
@@ -214,6 +224,7 @@ func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.Unified
Name: tc.Function.Name, Name: tc.Function.Name,
Args: json.RawMessage(tc.Function.Arguments), Args: json.RawMessage(tc.Function.Arguments),
}, },
ThoughtSignature: "skip_thought_signature_validator",
}) })
} }
contents = append(contents, GeminiContent{Role: "model", Parts: parts}) contents = append(contents, GeminiContent{Role: "model", Parts: parts})
@@ -239,16 +250,9 @@ func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.Unified
if len(m.Content) > 0 { if len(m.Content) > 0 {
text = m.Content[0].Text text = m.Content[0].Text
} }
name := "unknown_function" name := resolveToolName(m, msg.ToolCalls, j-i-1)
if m.Name != nil {
name = *m.Name
}
var responseObj interface{} respBytes := ensureJSONObject(text)
if err := json.Unmarshal([]byte(text), &responseObj); err != nil {
responseObj = map[string]interface{}{"result": text}
}
respBytes, _ := json.Marshal(responseObj)
functionParts = append(functionParts, GeminiPart{ functionParts = append(functionParts, GeminiPart{
FunctionResponse: &GeminiFunctionResponse{ FunctionResponse: &GeminiFunctionResponse{
@@ -261,7 +265,7 @@ func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.Unified
} }
if foundAny { 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 i = j - 1 // Advance outer loop past the tool messages we consumed
} else { } else {
// If no tool results found but assistant made calls, Gemini WILL error. // If no tool results found but assistant made calls, Gemini WILL error.
@@ -331,27 +335,37 @@ func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.Unified
} }
// Map Tools // Map Tools
hasMappedTools := false
if len(req.Tools) > 0 { if len(req.Tools) > 0 {
geminiTool := GeminiTool{FunctionDeclarations: []models.FunctionDef{}} geminiTool := GeminiTool{FunctionDeclarations: []models.FunctionDef{}}
for _, t := range req.Tools { for _, t := range req.Tools {
if t.Type == "function" { 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 {
body.Tools = []GeminiTool{geminiTool} body.Tools = []GeminiTool{geminiTool}
hasMappedTools = true
}
} }
baseURL := p.config.BaseURL baseURL := p.config.BaseURL
lowerModel := strings.ToLower(req.Model) lowerModel := strings.ToLower(req.Model)
if strings.Contains(lowerModel, "preview") || strings.Contains(lowerModel, "3.1") || strings.Contains(lowerModel, "2.0") || strings.Contains(lowerModel, "thinking") { if strings.Contains(lowerModel, "preview") ||
// Use v1beta for preview and newer models strings.Contains(lowerModel, "thinking") ||
strings.Contains(lowerModel, "gemini-") ||
hasMappedTools ||
hasHistoryToolCalls(contents) {
// Use v1beta for preview, newer models, tool use, or historical tool calls
if !strings.Contains(baseURL, "v1beta") { if !strings.Contains(baseURL, "v1beta") {
baseURL = strings.Replace(baseURL, "/v1", "/v1beta", 1) baseURL = strings.Replace(baseURL, "/v1", "/v1beta", 1)
} }
} }
url := fmt.Sprintf("%s/models/%s:generateContent?key=%s", baseURL, req.Model, p.apiKey) url := fmt.Sprintf("%s/models/%s:generateContent?key=%s", baseURL, req.Model, p.apiKey)
fmt.Printf("[Gemini] POST %s\n", url) fmt.Printf("[Gemini] POST %s\n", SanitizeURL(url))
resp, err := p.client.R(). resp, err := p.client.R().
SetContext(ctx). SetContext(ctx).
@@ -363,11 +377,17 @@ func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.Unified
} }
if !resp.IsSuccess() { if !resp.IsSuccess() {
fmt.Printf("[Gemini] API Error %d: %s\n", resp.StatusCode(), resp.String()) msg := resp.String()
if msg == "" {
if body, err := io.ReadAll(resp.RawBody()); err == nil {
msg = string(body)
}
}
fmt.Printf("[Gemini] API Error %d: %s\n", resp.StatusCode(), msg)
// Also log the request body for debugging (careful with API keys if logged elsewhere) // Also log the request body for debugging (careful with API keys if logged elsewhere)
reqJSON, _ := json.Marshal(body) reqJSON, _ := json.Marshal(body)
fmt.Printf("[Gemini] Request Body: %s\n", string(reqJSON)) fmt.Printf("[Gemini] Request Body: %s\n", string(reqJSON))
return nil, fmt.Errorf("Gemini API error (%d): %s", resp.StatusCode(), resp.String()) return nil, fmt.Errorf("Gemini API error (%d): %s", resp.StatusCode(), msg)
} }
// Parse Gemini response and convert to OpenAI format // Parse Gemini response and convert to OpenAI format
@@ -451,6 +471,8 @@ func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.Unified
} }
func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.UnifiedRequest) (<-chan *models.ChatCompletionStreamResponse, error) { func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.UnifiedRequest) (<-chan *models.ChatCompletionStreamResponse, error) {
req.Model = normalizeGeminiModel(req.Model)
// Simplified Gemini mapping // Simplified Gemini mapping
var contents []GeminiContent var contents []GeminiContent
for i := 0; i < len(req.Messages); i++ { for i := 0; i < len(req.Messages); i++ {
@@ -469,6 +491,7 @@ func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.U
Name: tc.Function.Name, Name: tc.Function.Name,
Args: json.RawMessage(tc.Function.Arguments), Args: json.RawMessage(tc.Function.Arguments),
}, },
ThoughtSignature: "skip_thought_signature_validator",
}) })
} }
contents = append(contents, GeminiContent{Role: "model", Parts: parts}) contents = append(contents, GeminiContent{Role: "model", Parts: parts})
@@ -482,16 +505,9 @@ func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.U
if len(m.Content) > 0 { if len(m.Content) > 0 {
text = m.Content[0].Text text = m.Content[0].Text
} }
name := "unknown_function" name := resolveToolName(m, msg.ToolCalls, j-i-1)
if m.Name != nil {
name = *m.Name
}
var responseObj interface{} respBytes := ensureJSONObject(text)
if err := json.Unmarshal([]byte(text), &responseObj); err != nil {
responseObj = map[string]interface{}{"result": text}
}
respBytes, _ := json.Marshal(responseObj)
functionParts = append(functionParts, GeminiPart{ functionParts = append(functionParts, GeminiPart{
FunctionResponse: &GeminiFunctionResponse{ FunctionResponse: &GeminiFunctionResponse{
@@ -504,7 +520,7 @@ func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.U
} }
if foundAny { if foundAny {
contents = append(contents, GeminiContent{Role: "function", Parts: functionParts}) contents = append(contents, GeminiContent{Role: "user", Parts: functionParts})
i = j - 1 i = j - 1
} }
continue continue
@@ -565,20 +581,30 @@ func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.U
GenerationConfig: genConfig, GenerationConfig: genConfig,
} }
hasMappedTools := false
if len(req.Tools) > 0 { if len(req.Tools) > 0 {
geminiTool := GeminiTool{FunctionDeclarations: []models.FunctionDef{}} geminiTool := GeminiTool{FunctionDeclarations: []models.FunctionDef{}}
for _, t := range req.Tools { for _, t := range req.Tools {
if t.Type == "function" { 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 {
body.Tools = []GeminiTool{geminiTool} body.Tools = []GeminiTool{geminiTool}
hasMappedTools = true
}
} }
baseURL := p.config.BaseURL baseURL := p.config.BaseURL
lowerModel := strings.ToLower(req.Model) lowerModel := strings.ToLower(req.Model)
if strings.Contains(lowerModel, "preview") || strings.Contains(lowerModel, "3.1") || strings.Contains(lowerModel, "2.0") || strings.Contains(lowerModel, "thinking") { if strings.Contains(lowerModel, "preview") ||
// Use v1beta for preview and newer models strings.Contains(lowerModel, "thinking") ||
strings.Contains(lowerModel, "gemini-") ||
hasMappedTools ||
hasHistoryToolCalls(contents) {
// Use v1beta for preview, newer models, tool use, or historical tool calls
if !strings.Contains(baseURL, "v1beta") { if !strings.Contains(baseURL, "v1beta") {
baseURL = strings.Replace(baseURL, "/v1", "/v1beta", 1) baseURL = strings.Replace(baseURL, "/v1", "/v1beta", 1)
} }
@@ -586,7 +612,7 @@ func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.U
// Use streamGenerateContent for streaming // Use streamGenerateContent for streaming
url := fmt.Sprintf("%s/models/%s:streamGenerateContent?key=%s", baseURL, req.Model, p.apiKey) url := fmt.Sprintf("%s/models/%s:streamGenerateContent?key=%s", baseURL, req.Model, p.apiKey)
fmt.Printf("[Gemini-Stream] POST %s\n", url) fmt.Printf("[Gemini-Stream] POST %s\n", SanitizeURL(url))
resp, err := p.client.R(). resp, err := p.client.R().
SetContext(ctx). SetContext(ctx).
@@ -599,19 +625,22 @@ func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.U
} }
if !resp.IsSuccess() { if !resp.IsSuccess() {
return nil, fmt.Errorf("Gemini API error (%d): %s", resp.StatusCode(), resp.String()) msg := resp.String()
if msg == "" {
if body, err := io.ReadAll(resp.RawBody()); err == nil {
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)
} }
ch := make(chan *models.ChatCompletionStreamResponse) ch, err := StreamGemini(resp.RawBody(), req.Model)
go func() {
defer close(ch)
err := StreamGemini(resp.RawBody(), ch, req.Model)
if err != nil { if err != nil {
fmt.Printf("Gemini Stream error: %v\n", err) return nil, fmt.Errorf("gemini stream init error: %w", err)
} }
}()
return ch, nil return ch, nil
} }
@@ -621,3 +650,114 @@ func uint32Ptr(v uint32) *uint32 {
} }
return nil 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)
}
}
+16 -3
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"time" "time"
"github.com/go-resty/resty/v2" "github.com/go-resty/resty/v2"
@@ -19,7 +20,7 @@ type GrokProvider struct {
func NewGrokProvider(cfg config.GrokConfig, apiKey string) *GrokProvider { func NewGrokProvider(cfg config.GrokConfig, apiKey string) *GrokProvider {
return &GrokProvider{ return &GrokProvider{
client: resty.New().SetTimeout(10 * time.Minute), client: NewOptimizedRestyClient(10 * time.Minute),
config: cfg, config: cfg,
apiKey: apiKey, apiKey: apiKey,
} }
@@ -48,7 +49,13 @@ func (p *GrokProvider) ChatCompletion(ctx context.Context, req *models.UnifiedRe
} }
if !resp.IsSuccess() { if !resp.IsSuccess() {
return nil, fmt.Errorf("Grok API error (%d): %s", resp.StatusCode(), resp.String()) msg := resp.String()
if msg == "" {
if body, err := io.ReadAll(resp.RawBody()); err == nil {
msg = string(body)
}
}
return nil, fmt.Errorf("Grok API error (%d): %s", resp.StatusCode(), msg)
} }
var respJSON map[string]interface{} var respJSON map[string]interface{}
@@ -79,7 +86,13 @@ func (p *GrokProvider) ChatCompletionStream(ctx context.Context, req *models.Uni
} }
if !resp.IsSuccess() { if !resp.IsSuccess() {
return nil, fmt.Errorf("Grok API error (%d): %s", resp.StatusCode(), resp.String()) msg := resp.String()
if msg == "" {
if body, err := io.ReadAll(resp.RawBody()); err == nil {
msg = string(body)
}
}
return nil, fmt.Errorf("Grok API error (%d): %s", resp.StatusCode(), msg)
} }
ch := make(chan *models.ChatCompletionStreamResponse) ch := make(chan *models.ChatCompletionStreamResponse)
+289 -41
View File
@@ -5,16 +5,79 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net"
"net/http"
"regexp"
"strings" "strings"
"time"
"gophergate/internal/models" "gophergate/internal/models"
"github.com/go-resty/resty/v2"
) )
var keySanitizeRegex = regexp.MustCompile(`(?i)(key|api_key|secret)=[^&]+`)
// SanitizeURL strips sensitive key query parameters from URLs before logging.
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
for _, ch := range name {
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' {
sb.WriteRune(ch)
} else {
sb.WriteRune('_')
}
}
res := sb.String()
if res == "" {
return "function"
}
return res
}
// MessagesToOpenAIJSON converts unified messages to OpenAI-compatible JSON, including tools and images. // MessagesToOpenAIJSON converts unified messages to OpenAI-compatible JSON, including tools and images.
func MessagesToOpenAIJSON(messages []models.UnifiedMessage) ([]interface{}, error) { func MessagesToOpenAIJSON(messages []models.UnifiedMessage) ([]interface{}, error) {
var result []interface{} var result []interface{}
for _, m := range messages { for _, m := range messages {
if m.Role == "tool" { role := strings.ToLower(m.Role)
if role == "model" {
role = "assistant"
}
if role == "tool" || role == "function" {
text := "" text := ""
if len(m.Content) > 0 { if len(m.Content) > 0 {
text = m.Content[0].Text text = m.Content[0].Text
@@ -23,15 +86,14 @@ func MessagesToOpenAIJSON(messages []models.UnifiedMessage) ([]interface{}, erro
"role": "tool", "role": "tool",
"content": text, "content": text,
} }
id := "unknown"
if m.ToolCallID != nil { if m.ToolCallID != nil {
id := *m.ToolCallID id = *m.ToolCallID
if len(id) > 40 {
id = id[:40]
} }
msg["tool_call_id"] = id msg["tool_call_id"] = id
}
if m.Name != nil { if m.Name != nil {
msg["name"] = *m.Name msg["name"] = sanitizeFunctionName(*m.Name)
} }
result = append(result, msg) result = append(result, msg)
continue continue
@@ -59,7 +121,9 @@ func MessagesToOpenAIJSON(messages []models.UnifiedMessage) ([]interface{}, erro
} }
var finalContent interface{} var finalContent interface{}
if len(parts) == 1 { if len(parts) == 0 {
finalContent = nil
} else if len(parts) == 1 {
if p, ok := parts[0].(map[string]interface{}); ok && p["type"] == "text" { if p, ok := parts[0].(map[string]interface{}); ok && p["type"] == "text" {
finalContent = p["text"] finalContent = p["text"]
} else { } else {
@@ -70,7 +134,7 @@ func MessagesToOpenAIJSON(messages []models.UnifiedMessage) ([]interface{}, erro
} }
msg := map[string]interface{}{ msg := map[string]interface{}{
"role": m.Role, "role": role,
"content": finalContent, "content": finalContent,
} }
@@ -82,20 +146,21 @@ func MessagesToOpenAIJSON(messages []models.UnifiedMessage) ([]interface{}, erro
sanitizedCalls := make([]models.ToolCall, len(m.ToolCalls)) sanitizedCalls := make([]models.ToolCall, len(m.ToolCalls))
copy(sanitizedCalls, m.ToolCalls) copy(sanitizedCalls, m.ToolCalls)
for i := range sanitizedCalls { for i := range sanitizedCalls {
if len(sanitizedCalls[i].ID) > 40 { if sanitizedCalls[i].Type == "" {
sanitizedCalls[i].ID = sanitizedCalls[i].ID[:40] sanitizedCalls[i].Type = "function"
} }
sanitizedCalls[i].Function.Name = sanitizeFunctionName(sanitizedCalls[i].Function.Name)
} }
msg["tool_calls"] = sanitizedCalls msg["tool_calls"] = sanitizedCalls
if len(parts) == 0 { msg["content"] = "" // OpenAI requirement: content must be string if tool_calls present
msg["content"] = ""
}
} }
if m.Name != nil { if m.Name != nil {
msg["name"] = *m.Name msg["name"] = *m.Name
} }
if m.Prefix != nil {
msg["prefix"] = *m.Prefix
}
result = append(result, msg) result = append(result, msg)
} }
return result, nil return result, nil
@@ -121,11 +186,25 @@ func BuildOpenAIBody(request *models.UnifiedRequest, messagesJSON []interface{},
body["max_tokens"] = *request.MaxTokens body["max_tokens"] = *request.MaxTokens
} }
if len(request.Tools) > 0 { if len(request.Tools) > 0 {
body["tools"] = request.Tools sanitizedTools := make([]models.Tool, len(request.Tools))
copy(sanitizedTools, request.Tools)
for i := range sanitizedTools {
if sanitizedTools[i].Type == "function" {
sanitizedTools[i].Function.Name = sanitizeFunctionName(sanitizedTools[i].Function.Name)
}
}
body["tools"] = sanitizedTools
} }
if request.ToolChoice != nil { if request.ToolChoice != nil {
var toolChoice interface{} var toolChoice interface{}
if err := json.Unmarshal(request.ToolChoice, &toolChoice); err == nil { if err := json.Unmarshal(request.ToolChoice, &toolChoice); err == nil {
if tcMap, ok := toolChoice.(map[string]interface{}); ok {
if funcMap, ok := tcMap["function"].(map[string]interface{}); ok {
if name, ok := funcMap["name"].(string); ok {
funcMap["name"] = sanitizeFunctionName(name)
}
}
}
body["tool_choice"] = toolChoice body["tool_choice"] = toolChoice
} }
} }
@@ -361,23 +440,17 @@ func StreamOpenAI(ctx io.ReadCloser, ch chan<- *models.ChatCompletionStreamRespo
return scanner.Err() return scanner.Err()
} }
func StreamGemini(ctx io.ReadCloser, ch chan<- *models.ChatCompletionStreamResponse, model string) error { // geminiStreamChunk is the shared data structure for parsing Gemini streaming responses.
defer ctx.Close() type geminiStreamChunk struct {
dec := json.NewDecoder(ctx)
t, err := dec.Token()
if err != nil {
return err
}
if delim, ok := t.(json.Delim); ok && delim == '[' {
for dec.More() {
var geminiChunk struct {
Candidates []struct { Candidates []struct {
Content struct { Content struct {
Parts []struct { Parts []struct {
Text string `json:"text,omitempty"` Text string `json:"text,omitempty"`
Thought string `json:"thought,omitempty"` Thought string `json:"thought,omitempty"`
FunctionCall *struct {
Name string `json:"name"`
Args json.RawMessage `json:"args"`
} `json:"functionCall,omitempty"`
} `json:"parts"` } `json:"parts"`
} `json:"content"` } `json:"content"`
FinishReason string `json:"finishReason"` FinishReason string `json:"finishReason"`
@@ -390,15 +463,19 @@ func StreamGemini(ctx io.ReadCloser, ch chan<- *models.ChatCompletionStreamRespo
} `json:"usageMetadata"` } `json:"usageMetadata"`
} }
if err := dec.Decode(&geminiChunk); err != nil { // emitGeminiChunk builds a ChatCompletionStreamResponse from a parsed geminiStreamChunk
return err // and sends it to the channel. Returns true if anything was emitted.
func emitGeminiChunk(ch chan<- *models.ChatCompletionStreamResponse, chunk *geminiStreamChunk, model string) bool {
if len(chunk.Candidates) == 0 && chunk.UsageMetadata.TotalTokenCount == 0 {
return false
} }
if len(geminiChunk.Candidates) > 0 || geminiChunk.UsageMetadata.TotalTokenCount > 0 {
content := "" content := ""
var reasoning *string var reasoning *string
if len(geminiChunk.Candidates) > 0 { var finishReason *string
for _, p := range geminiChunk.Candidates[0].Content.Parts { var toolCalls []models.ToolCallDelta
if len(chunk.Candidates) > 0 {
for _, p := range chunk.Candidates[0].Content.Parts {
if p.Text != "" { if p.Text != "" {
content += p.Text content += p.Text
} }
@@ -408,12 +485,26 @@ func StreamGemini(ctx io.ReadCloser, ch chan<- *models.ChatCompletionStreamRespo
} }
*reasoning += p.Thought *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)
var finishReason *string if len(toolCalls) > 0 && fr == "" {
if len(geminiChunk.Candidates) > 0 { fr = "tool_calls"
fr := strings.ToLower(geminiChunk.Candidates[0].FinishReason) }
finishReason = &fr finishReason = &fr
} }
@@ -428,20 +519,177 @@ func StreamGemini(ctx io.ReadCloser, ch chan<- *models.ChatCompletionStreamRespo
Delta: models.ChatStreamDelta{ Delta: models.ChatStreamDelta{
Content: &content, Content: &content,
ReasoningContent: reasoning, ReasoningContent: reasoning,
ToolCalls: toolCalls,
}, },
FinishReason: finishReason, FinishReason: finishReason,
}, },
}, },
Usage: &models.Usage{ Usage: &models.Usage{
PromptTokens: geminiChunk.UsageMetadata.PromptTokenCount, PromptTokens: chunk.UsageMetadata.PromptTokenCount,
CompletionTokens: geminiChunk.UsageMetadata.CandidatesTokenCount, CompletionTokens: chunk.UsageMetadata.CandidatesTokenCount,
TotalTokens: geminiChunk.UsageMetadata.TotalTokenCount, TotalTokens: chunk.UsageMetadata.TotalTokenCount,
CacheReadTokens: uint32Ptr(geminiChunk.UsageMetadata.CachedContentTokenCount), CacheReadTokens: uint32Ptr(chunk.UsageMetadata.CachedContentTokenCount),
}, },
} }
return true
} }
// StreamGemini handles Gemini streaming responses in two formats:
// 1. SSE format (newer models): each line is "data: {...}"
// 2. JSON array format (older models): response body is [ {...}, {...} ]
//
// Usage metadata is only present in the final chunk, which we accumulate
// and emit so the server can log it on stream end.
func StreamGemini(ctx io.ReadCloser, model string) (<-chan *models.ChatCompletionStreamResponse, error) {
ch := make(chan *models.ChatCompletionStreamResponse)
go func() {
defer func() {
_ = ctx.Close()
}()
defer close(ch)
// Peek at the first byte to detect format
peek := make([]byte, 6)
n, _ := io.ReadAtLeast(ctx, peek, 1)
if n == 0 {
return
}
first := string(peek[:n])
if first[0] == '[' {
// 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
combined := io.MultiReader(
strings.NewReader(string(peek[:n])),
ctx,
)
streamGeminiSSE(combined, ch, model)
} else {
// Unknown format — might still be SSE starting after a peek char
// Pre-pend peeked bytes and try SSE
combined := io.MultiReader(
strings.NewReader(string(peek[:n])),
ctx,
)
streamGeminiSSE(combined, ch, model)
}
}()
return ch, nil
}
func streamGeminiJSONArrayStream(r io.Reader, ch chan<- *models.ChatCompletionStreamResponse, model string) {
dec := json.NewDecoder(r)
// Read open bracket '['
t, err := dec.Token()
if err != nil {
fmt.Printf("[Gemini-Stream] JSON array token error: %v\n", err)
return
}
delim, ok := t.(json.Delim)
if !ok || delim != '[' {
fmt.Printf("[Gemini-Stream] JSON array expected '[', got %v\n", t)
return
}
var lastUsage *geminiStreamChunk
// 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)
} }
} }
return nil // 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)
}
}
func streamGeminiSSE(r io.Reader, ch chan<- *models.ChatCompletionStreamResponse, model string) {
scanner := bufio.NewScanner(r)
// Track the last seen usage for emission at end of stream
var lastUsage geminiStreamChunk
for scanner.Scan() {
line := scanner.Text()
if line == "" || !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
// Emit final usage if we have one
if lastUsage.UsageMetadata.TotalTokenCount > 0 {
emitGeminiChunk(ch, &lastUsage, model)
}
break
}
var chunk geminiStreamChunk
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
continue
}
// Capture usage from any chunk (Gemini puts it in the final response)
if chunk.UsageMetadata.TotalTokenCount > 0 {
lastUsage = chunk
}
// Emit content chunks as they arrive
if len(chunk.Candidates) > 0 {
emitGeminiChunk(ch, &chunk, model)
}
}
// If stream ended without [DONE] marker but we collected usage, emit it
if lastUsage.UsageMetadata.TotalTokenCount > 0 {
emitGeminiChunk(ch, &lastUsage, model)
}
if err := scanner.Err(); err != nil {
fmt.Printf("[Gemini-Stream] SSE scan error: %v\n", err)
}
}
// ensureEnglish injects a system message instructing the model to respond in
// English when no system prompt is already present. Some providers (e.g. DeepSeek)
// default to Chinese for certain prompts.
func ensureEnglish(req *models.UnifiedRequest) {
if len(req.Messages) > 0 && req.Messages[0].Role == "system" {
return // already has a system prompt, don't interfere
}
enMsg := models.UnifiedMessage{
Role: "system",
Content: []models.UnifiedContentPart{
{Type: "text", Text: "You are a helpful assistant. Always respond in English."},
},
}
req.Messages = append([]models.UnifiedMessage{enMsg}, req.Messages...)
} }
+127
View File
@@ -0,0 +1,127 @@
package providers
import (
"encoding/json"
"testing"
"gophergate/internal/models"
)
func TestSanitizeFunctionName(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"google-search", "google-search"},
{"google.search", "google_search"},
{"google search", "google_search"},
{"web_search(query)", "web_search_query_"},
{"", "function"},
{"123_abc-XYZ", "123_abc-XYZ"},
{"invalid.name.with.dots", "invalid_name_with_dots"},
}
for _, tc := range tests {
actual := sanitizeFunctionName(tc.input)
if actual != tc.expected {
t.Errorf("sanitizeFunctionName(%q) = %q; expected %q", tc.input, actual, tc.expected)
}
}
}
func TestMessagesToOpenAIJSON_SanitizeToolCalls(t *testing.T) {
messages := []models.UnifiedMessage{
{
Role: "assistant",
Content: []models.UnifiedContentPart{
{Type: "text", Text: "I will use search."},
},
ToolCalls: []models.ToolCall{
{
ID: "call_1",
Type: "function",
Function: models.FunctionCall{
Name: "google.search",
Arguments: `{"query": "hello"}`,
},
},
},
},
{
Role: "tool",
Content: []models.UnifiedContentPart{
{Type: "text", Text: `{"result": "success"}`},
},
ToolCallID: stringPtr("call_1"),
Name: stringPtr("google.search"),
},
}
res, err := MessagesToOpenAIJSON(messages)
if err != nil {
t.Fatalf("MessagesToOpenAIJSON failed: %v", err)
}
if len(res) != 2 {
t.Fatalf("expected 2 messages, got %d", len(res))
}
// Verify assistant message
msg1 := res[0].(map[string]interface{})
if msg1["role"] != "assistant" {
t.Errorf("expected role assistant, got %v", msg1["role"])
}
calls := msg1["tool_calls"].([]models.ToolCall)
if len(calls) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(calls))
}
if calls[0].Function.Name != "google_search" {
t.Errorf("expected function name google_search, got %q", calls[0].Function.Name)
}
// Verify tool response message
msg2 := res[1].(map[string]interface{})
if msg2["role"] != "tool" {
t.Errorf("expected role tool, got %v", msg2["role"])
}
if msg2["name"] != "google_search" {
t.Errorf("expected tool name google_search, got %v", msg2["name"])
}
}
func TestBuildOpenAIBody_SanitizeToolsAndChoice(t *testing.T) {
req := &models.UnifiedRequest{
Model: "gpt-4o",
Tools: []models.Tool{
{
Type: "function",
Function: models.FunctionDef{
Name: "google.search",
},
},
},
ToolChoice: json.RawMessage(`{"type": "function", "function": {"name": "google.search"}}`),
}
body := BuildOpenAIBody(req, nil, false)
// Verify tools
tools := body["tools"].([]models.Tool)
if len(tools) != 1 {
t.Fatalf("expected 1 tool, got %d", len(tools))
}
if tools[0].Function.Name != "google_search" {
t.Errorf("expected tool function name google_search, got %q", tools[0].Function.Name)
}
// Verify tool_choice
toolChoice := body["tool_choice"].(map[string]interface{})
funcObj := toolChoice["function"].(map[string]interface{})
if funcObj["name"] != "google_search" {
t.Errorf("expected tool_choice function name google_search, got %q", funcObj["name"])
}
}
func stringPtr(s string) *string {
return &s
}
+26 -3
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"strings" "strings"
"time" "time"
@@ -20,7 +21,7 @@ type MoonshotProvider struct {
func NewMoonshotProvider(cfg config.MoonshotConfig, apiKey string) *MoonshotProvider { func NewMoonshotProvider(cfg config.MoonshotConfig, apiKey string) *MoonshotProvider {
return &MoonshotProvider{ return &MoonshotProvider{
client: resty.New().SetTimeout(10 * time.Minute), client: NewOptimizedRestyClient(10 * time.Minute),
config: cfg, config: cfg,
apiKey: strings.TrimSpace(apiKey), apiKey: strings.TrimSpace(apiKey),
} }
@@ -43,6 +44,11 @@ func (p *MoonshotProvider) ChatCompletion(ctx context.Context, req *models.Unifi
body["max_completion_tokens"] = maxTokens body["max_completion_tokens"] = maxTokens
} }
} }
if strings.Contains(strings.ToLower(req.Model), "kimi-k2.6") {
if _, ok := body["temperature"]; ok {
body["temperature"] = 1.0
}
}
baseURL := strings.TrimRight(p.config.BaseURL, "/") baseURL := strings.TrimRight(p.config.BaseURL, "/")
@@ -59,7 +65,13 @@ func (p *MoonshotProvider) ChatCompletion(ctx context.Context, req *models.Unifi
} }
if !resp.IsSuccess() { if !resp.IsSuccess() {
return nil, fmt.Errorf("Moonshot API error (%d): %s", resp.StatusCode(), resp.String()) msg := resp.String()
if msg == "" {
if body, err := io.ReadAll(resp.RawBody()); err == nil {
msg = string(body)
}
}
return nil, fmt.Errorf("Moonshot API error (%d): %s", resp.StatusCode(), msg)
} }
var respJSON map[string]interface{} var respJSON map[string]interface{}
@@ -83,6 +95,11 @@ func (p *MoonshotProvider) ChatCompletionStream(ctx context.Context, req *models
body["max_completion_tokens"] = maxTokens body["max_completion_tokens"] = maxTokens
} }
} }
if strings.Contains(strings.ToLower(req.Model), "kimi-k2.6") {
if _, ok := body["temperature"]; ok {
body["temperature"] = 1.0
}
}
baseURL := strings.TrimRight(p.config.BaseURL, "/") baseURL := strings.TrimRight(p.config.BaseURL, "/")
@@ -100,7 +117,13 @@ func (p *MoonshotProvider) ChatCompletionStream(ctx context.Context, req *models
} }
if !resp.IsSuccess() { if !resp.IsSuccess() {
return nil, fmt.Errorf("Moonshot API error (%d): %s", resp.StatusCode(), resp.String()) msg := resp.String()
if msg == "" {
if body, err := io.ReadAll(resp.RawBody()); err == nil {
msg = string(body)
}
}
return nil, fmt.Errorf("Moonshot API error (%d): %s", resp.StatusCode(), msg)
} }
ch := make(chan *models.ChatCompletionStreamResponse) ch := make(chan *models.ChatCompletionStreamResponse)
+15 -4
View File
@@ -20,10 +20,9 @@ type OllamaProvider struct {
} }
func NewOllamaProvider(cfg config.OllamaConfig) *OllamaProvider { 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) // 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 // 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.SetRetryCount(2)
client.SetRetryWaitTime(1 * time.Second) client.SetRetryWaitTime(1 * time.Second)
@@ -56,7 +55,13 @@ func (p *OllamaProvider) ChatCompletion(ctx context.Context, req *models.Unified
} }
if !resp.IsSuccess() { if !resp.IsSuccess() {
return nil, fmt.Errorf("Ollama API error (%d): %s", resp.StatusCode(), resp.String()) msg := resp.String()
if msg == "" {
if body, err := io.ReadAll(resp.RawBody()); err == nil {
msg = string(body)
}
}
return nil, fmt.Errorf("Ollama API error (%d): %s", resp.StatusCode(), msg)
} }
var respJSON map[string]interface{} var respJSON map[string]interface{}
@@ -86,7 +91,13 @@ func (p *OllamaProvider) ChatCompletionStream(ctx context.Context, req *models.U
} }
if !resp.IsSuccess() { if !resp.IsSuccess() {
return nil, fmt.Errorf("Ollama API error (%d): %s", resp.StatusCode(), resp.String()) msg := resp.String()
if msg == "" {
if body, err := io.ReadAll(resp.RawBody()); err == nil {
msg = string(body)
}
}
return nil, fmt.Errorf("Ollama API error (%d): %s", resp.StatusCode(), msg)
} }
ch := make(chan *models.ChatCompletionStreamResponse) ch := make(chan *models.ChatCompletionStreamResponse)
+64 -4
View File
@@ -4,6 +4,8 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log"
"strings" "strings"
"time" "time"
@@ -20,7 +22,7 @@ type OpenAIProvider struct {
func NewOpenAIProvider(cfg config.OpenAIConfig, apiKey string) *OpenAIProvider { func NewOpenAIProvider(cfg config.OpenAIConfig, apiKey string) *OpenAIProvider {
return &OpenAIProvider{ return &OpenAIProvider{
client: resty.New().SetTimeout(10 * time.Minute), client: NewOptimizedRestyClient(10 * time.Minute),
config: cfg, config: cfg,
apiKey: apiKey, apiKey: apiKey,
} }
@@ -38,12 +40,26 @@ func (p *OpenAIProvider) ChatCompletion(ctx context.Context, req *models.Unified
body := BuildOpenAIBody(req, messagesJSON, false) body := BuildOpenAIBody(req, messagesJSON, false)
// Debug message sequence
for i, m := range messagesJSON {
mMap, _ := m.(map[string]interface{})
role, _ := mMap["role"].(string)
hasToolCalls := false
if tc, ok := mMap["tool_calls"]; ok && tc != nil {
hasToolCalls = true
}
log.Printf("[DEBUG] OpenAI Msg[%d]: role=%s, hasToolCalls=%v", i, role, hasToolCalls)
}
// Transition: Newer models require max_completion_tokens // Transition: Newer models require max_completion_tokens
if strings.HasPrefix(req.Model, "o1-") || strings.HasPrefix(req.Model, "o3-") || strings.Contains(req.Model, "gpt-5") { if strings.HasPrefix(req.Model, "o1-") || strings.HasPrefix(req.Model, "o3-") || strings.Contains(req.Model, "gpt-5") {
if maxTokens, ok := body["max_tokens"]; ok { if maxTokens, ok := body["max_tokens"]; ok {
delete(body, "max_tokens") delete(body, "max_tokens")
body["max_completion_tokens"] = maxTokens body["max_completion_tokens"] = maxTokens
} }
if len(req.Tools) > 0 {
body["reasoning_effort"] = "none"
}
} }
resp, err := p.client.R(). resp, err := p.client.R().
@@ -57,7 +73,17 @@ func (p *OpenAIProvider) ChatCompletion(ctx context.Context, req *models.Unified
} }
if !resp.IsSuccess() { if !resp.IsSuccess() {
return nil, fmt.Errorf("OpenAI API error (%d): %s", resp.StatusCode(), resp.String()) msg := resp.String()
if msg == "" {
if b := resp.Body(); len(b) > 0 {
msg = string(b)
}
}
// Log the request body for debugging
reqJSON, _ := json.Marshal(body)
log.Printf("OpenAI API Error (%d): %s", resp.StatusCode(), msg)
log.Printf("OpenAI request body: %s", string(reqJSON))
return nil, fmt.Errorf("OpenAI API error (%d): %s", resp.StatusCode(), msg)
} }
var respJSON map[string]interface{} var respJSON map[string]interface{}
@@ -104,7 +130,13 @@ func (p *OpenAIProvider) ImageGeneration(ctx context.Context, req *models.ImageG
} }
if !resp.IsSuccess() { if !resp.IsSuccess() {
return nil, fmt.Errorf("OpenAI image API error (%d): %s", resp.StatusCode(), resp.String()) msg := resp.String()
if msg == "" {
if body, err := io.ReadAll(resp.RawBody()); err == nil {
msg = string(body)
}
}
return nil, fmt.Errorf("OpenAI image API error (%d): %s", resp.StatusCode(), msg)
} }
var result models.ImageGenerationResponse var result models.ImageGenerationResponse
@@ -123,12 +155,26 @@ func (p *OpenAIProvider) ChatCompletionStream(ctx context.Context, req *models.U
body := BuildOpenAIBody(req, messagesJSON, true) body := BuildOpenAIBody(req, messagesJSON, true)
// Debug message sequence
for i, m := range messagesJSON {
mMap, _ := m.(map[string]interface{})
role, _ := mMap["role"].(string)
hasToolCalls := false
if tc, ok := mMap["tool_calls"]; ok && tc != nil {
hasToolCalls = true
}
log.Printf("[DEBUG] OpenAI Stream Msg[%d]: role=%s, hasToolCalls=%v", i, role, hasToolCalls)
}
// Transition: Newer models require max_completion_tokens // Transition: Newer models require max_completion_tokens
if strings.HasPrefix(req.Model, "o1-") || strings.HasPrefix(req.Model, "o3-") || strings.Contains(req.Model, "gpt-5") { if strings.HasPrefix(req.Model, "o1-") || strings.HasPrefix(req.Model, "o3-") || strings.Contains(req.Model, "gpt-5") {
if maxTokens, ok := body["max_tokens"]; ok { if maxTokens, ok := body["max_tokens"]; ok {
delete(body, "max_tokens") delete(body, "max_tokens")
body["max_completion_tokens"] = maxTokens body["max_completion_tokens"] = maxTokens
} }
if len(req.Tools) > 0 {
body["reasoning_effort"] = "none"
}
} }
resp, err := p.client.R(). resp, err := p.client.R().
@@ -143,7 +189,21 @@ func (p *OpenAIProvider) ChatCompletionStream(ctx context.Context, req *models.U
} }
if !resp.IsSuccess() { if !resp.IsSuccess() {
return nil, fmt.Errorf("OpenAI API error (%d): %s", resp.StatusCode(), resp.String()) msg := resp.String()
if msg == "" {
if b := resp.Body(); len(b) > 0 {
msg = string(b)
}
if msg == "" {
if b, err := io.ReadAll(resp.RawBody()); err == nil {
msg = string(b)
}
}
}
reqJSON, _ := json.Marshal(body)
log.Printf("OpenAI API Error (%d): %s", resp.StatusCode(), msg)
log.Printf("OpenAI request body: %s", string(reqJSON))
return nil, fmt.Errorf("OpenAI API error (%d): %s", resp.StatusCode(), msg)
} }
ch := make(chan *models.ChatCompletionStreamResponse) ch := make(chan *models.ChatCompletionStreamResponse)
+15 -2
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"gophergate/internal/models" "gophergate/internal/models"
) )
@@ -26,7 +27,13 @@ func (p *OpenAIProvider) Responses(ctx context.Context, req *models.ResponsesReq
} }
if !resp.IsSuccess() { if !resp.IsSuccess() {
return nil, fmt.Errorf("OpenAI Responses API error (%d): %s", resp.StatusCode(), resp.String()) msg := resp.String()
if msg == "" {
if body, err := io.ReadAll(resp.RawBody()); err == nil {
msg = string(body)
}
}
return nil, fmt.Errorf("OpenAI Responses API error (%d): %s", resp.StatusCode(), msg)
} }
var respJSON map[string]interface{} var respJSON map[string]interface{}
@@ -53,7 +60,13 @@ func (p *OpenAIProvider) ResponsesStream(ctx context.Context, req *models.Respon
} }
if !resp.IsSuccess() { if !resp.IsSuccess() {
return nil, fmt.Errorf("OpenAI Responses API error (%d): %s", resp.StatusCode(), resp.String()) msg := resp.String()
if msg == "" {
if body, err := io.ReadAll(resp.RawBody()); err == nil {
msg = string(body)
}
}
return nil, fmt.Errorf("OpenAI Responses API error (%d): %s", resp.StatusCode(), msg)
} }
ch := make(chan *models.ResponsesStreamChunk) ch := make(chan *models.ResponsesStreamChunk)
+133
View File
@@ -0,0 +1,133 @@
package providers
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
"time"
"github.com/go-resty/resty/v2"
"gophergate/internal/config"
"gophergate/internal/models"
)
type XiaomiProvider struct {
client *resty.Client
config config.XiaomiConfig
apiKey string
}
func NewXiaomiProvider(cfg config.XiaomiConfig, apiKey string) *XiaomiProvider {
return &XiaomiProvider{
client: NewOptimizedRestyClient(10 * time.Minute),
config: cfg,
apiKey: strings.TrimSpace(apiKey),
}
}
func (p *XiaomiProvider) Name() string {
return "xiaomi"
}
func (p *XiaomiProvider) ChatCompletion(ctx context.Context, req *models.UnifiedRequest) (*models.ChatCompletionResponse, error) {
messagesJSON, err := MessagesToOpenAIJSON(req.Messages)
if err != nil {
return nil, fmt.Errorf("failed to convert messages: %w", err)
}
body := BuildOpenAIBody(req, messagesJSON, false)
baseURL := strings.TrimRight(p.config.BaseURL, "/")
resp, err := p.client.R().
SetContext(ctx).
SetHeader("Authorization", "Bearer "+p.apiKey).
SetHeader("Content-Type", "application/json").
SetHeader("Accept", "application/json").
SetBody(body).
Post(fmt.Sprintf("%s/chat/completions", baseURL))
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
if !resp.IsSuccess() {
msg := resp.String()
if msg == "" {
if b := resp.Body(); len(b) > 0 {
msg = string(b)
}
}
if msg == "" {
if body, err := io.ReadAll(resp.RawBody()); err == nil {
msg = string(body)
}
}
return nil, fmt.Errorf("Xiaomi 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 response: %w", err)
}
return ParseOpenAIResponse(respJSON, req.Model)
}
func (p *XiaomiProvider) ChatCompletionStream(ctx context.Context, req *models.UnifiedRequest) (<-chan *models.ChatCompletionStreamResponse, error) {
messagesJSON, err := MessagesToOpenAIJSON(req.Messages)
if err != nil {
return nil, fmt.Errorf("failed to convert messages: %w", err)
}
body := BuildOpenAIBody(req, messagesJSON, true)
baseURL := strings.TrimRight(p.config.BaseURL, "/")
resp, err := p.client.R().
SetContext(ctx).
SetHeader("Authorization", "Bearer "+p.apiKey).
SetHeader("Content-Type", "application/json").
SetHeader("Accept", "text/event-stream").
SetBody(body).
SetDoNotParseResponse(true).
Post(fmt.Sprintf("%s/chat/completions", baseURL))
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
if !resp.IsSuccess() {
msg := resp.String()
if msg == "" {
if body, err := io.ReadAll(resp.RawBody()); err == nil {
msg = string(body)
}
}
return nil, fmt.Errorf("Xiaomi API error (%d): %s", resp.StatusCode(), msg)
}
ch := make(chan *models.ChatCompletionStreamResponse)
go func() {
defer close(ch)
if err := StreamOpenAI(resp.RawBody(), ch); err != nil {
fmt.Printf("Xiaomi Stream error: %v\n", err)
}
}()
return ch, nil
}
func (p *XiaomiProvider) ImageGeneration(ctx context.Context, req *models.ImageGenerationRequest) (*models.ImageGenerationResponse, error) {
return nil, fmt.Errorf("xiaomi does not support image generation")
}
func (p *XiaomiProvider) Responses(ctx context.Context, req *models.ResponsesRequest) (*models.ResponsesResponse, error) {
return nil, fmt.Errorf("responses API not supported by xiaomi")
}
func (p *XiaomiProvider) ResponsesStream(ctx context.Context, req *models.ResponsesRequest) (<-chan *models.ResponsesStreamChunk, error) {
return nil, fmt.Errorf("responses API not supported by xiaomi")
}
+35 -4
View File
@@ -13,19 +13,39 @@ const classifierSystemPrompt = `You are a task complexity classifier. Rate the f
1 = trivial/simple (basic facts, greetings, simple math) 1 = trivial/simple (basic facts, greetings, simple math)
%d = highly complex (multi-step reasoning, code generation, architecture design) %d = highly complex (multi-step reasoning, code generation, architecture design)
TIER BOUNDARIES:
1-3 (fast): Simple Q&A, classification, JSON parsing, short text, greetings, factual lookup, quick code snippets.
4-7 (standard): Summarize, explain, draft emails/docs, compare options, moderate coding, debug simple issues, translate.
8-10 (heavy): Multi-step reasoning, complex code generation, architecture design, agent orchestration, deep debugging, research analysis, long multi-file refactors.
SIGNALS for higher ratings: mentions of "architecture", "distributed", "agent", "multi-file", "system design", "think step by step", "race condition", "refactor entire", code blocks > 50 lines, requests asking to build/ship entire features.
SIGNALS for lower ratings: "what is", "how do I", "define", "list", single yes/no questions, requests under 20 words with no code.
Reply with ONLY the number. No explanation.` Reply with ONLY the number. No explanation.`
func routeClassifier(ctx context.Context, classify ClassifierFunc, group db.ModelGroup, targets []string, userMessage string) (*Decision, error) { func routeClassifier(ctx context.Context, classify ClassifierFunc, group db.ModelGroup, targets []string, routeCtx *RouteContext) (*Decision, error) {
// Determine the rating scale
maxRating := len(targets) maxRating := len(targets)
if maxRating < 2 { if maxRating < 2 {
maxRating = 2 maxRating = 2
} }
// When complexity_threshold is set, use it as a wider scale (e.g., 1-10)
// and map ratings proportionally to target buckets.
bucketMode := group.ComplexityThreshold != nil && *group.ComplexityThreshold > 0
if bucketMode {
maxRating = *group.ComplexityThreshold
}
prompt := fmt.Sprintf(classifierSystemPrompt, maxRating, maxRating) prompt := fmt.Sprintf(classifierSystemPrompt, maxRating, maxRating)
ratingStr, err := classify(ctx, getSelectorModel(group, targets), prompt, userMessage) userMsg := ""
if routeCtx != nil {
userMsg = routeCtx.UserMessage
}
ratingStr, err := classify(ctx, getSelectorModel(group, targets), prompt, userMsg)
if err != nil { if err != nil {
// Classifier failed — fall back to heuristic // Classifier failed — fall back to heuristic
return routeHeuristic(group, targets, userMessage) return routeHeuristic(group, targets, routeCtx)
} }
rating, err := strconv.Atoi(strings.TrimSpace(ratingStr)) rating, err := strconv.Atoi(strings.TrimSpace(ratingStr))
@@ -36,7 +56,18 @@ func routeClassifier(ctx context.Context, classify ClassifierFunc, group db.Mode
rating = maxRating rating = maxRating
} }
idx := rating - 1 // 0-based index into targets var idx int
if bucketMode {
// Proportional mapping: wider scale → N target buckets
// e.g., threshold=10, 3 targets: 1-3→0, 4-7→1, 8-10→2
idx = rating * len(targets) / (maxRating + 1)
if idx >= len(targets) {
idx = len(targets) - 1
}
} else {
idx = rating - 1 // 1:1 mapping
}
return &Decision{ return &Decision{
SelectedModel: targets[idx], SelectedModel: targets[idx],
Strategy: "classifier", Strategy: "classifier",
+171 -10
View File
@@ -2,35 +2,112 @@ package router
import ( import (
"encoding/json" "encoding/json"
"regexp"
"strings" "strings"
"gophergate/internal/db" "gophergate/internal/db"
) )
// HeuristicRule defines a pattern-based routing rule. // HeuristicRule defines a pattern-based routing rule (legacy format).
type HeuristicRule struct { type HeuristicRule struct {
Pattern string `json:"pattern"` Pattern string `json:"pattern"`
TargetIdx int `json:"target"` TargetIdx int `json:"target"`
CaseSensitive bool `json:"case_sensitive,omitempty"` CaseSensitive bool `json:"case_sensitive,omitempty"`
} }
func routeHeuristic(group db.ModelGroup, targets []string, userMessage string) (*Decision, error) { // ConditionRule defines a condition-based routing rule (new format).
type ConditionRule struct {
RuleID string `json:"rule_id"`
Description string `json:"description,omitempty"`
Conditions Conditions `json:"conditions"`
PrimaryModel string `json:"primary_model"`
FallbackModel string `json:"fallback_model,omitempty"`
}
// Conditions defines the matching parameters for a rule.
type Conditions struct {
AnyOfTags []string `json:"any_of_tags,omitempty"`
MaxInputTokensLt *int `json:"max_input_tokens_lt,omitempty"`
RequiresReasoning *bool `json:"requires_reasoning,omitempty"`
RequiresToolCalling *bool `json:"requires_tool_calling,omitempty"`
IsSoftwareDevelopment *bool `json:"is_software_development,omitempty"`
HasMultimodalInput *bool `json:"has_multimodal_input,omitempty"`
IsDefaultFallback *bool `json:"is_default_fallback,omitempty"`
}
func routeHeuristic(group db.ModelGroup, targets []string, routeCtx *RouteContext) (*Decision, error) {
if routeCtx == nil {
routeCtx = &RouteContext{}
}
selected := targets[0] selected := targets[0]
reason := "default (first target)" reason := "default (first target)"
// If heuristic_rules is set, use them // If heuristic_rules is set, determine format and parse
if group.HeuristicRules != nil && *group.HeuristicRules != "" { if group.HeuristicRules != nil && *group.HeuristicRules != "" {
var rules []HeuristicRule rulesJSON := *group.HeuristicRules
if err := json.Unmarshal([]byte(*group.HeuristicRules), &rules); err == nil {
searchMsg := userMessage if isConditionBasedRules(rulesJSON) {
for _, rule := range rules { var condRules []ConditionRule
if err := json.Unmarshal([]byte(rulesJSON), &condRules); err == nil {
for _, rule := range condRules {
if matchConditions(rule.Conditions, routeCtx) {
// Resolve primary/fallback to concrete models in target list
targetModel := ""
if rule.PrimaryModel != "" {
targetModel = getModelInTargets(rule.PrimaryModel, targets)
}
if targetModel == "" && rule.FallbackModel != "" {
targetModel = getModelInTargets(rule.FallbackModel, targets)
}
if targetModel != "" {
selected = targetModel
reason = "matched condition rule: " + rule.RuleID
if rule.Description != "" {
reason += " (" + rule.Description + ")"
}
break
}
}
}
}
} else {
// Fallback to legacy pattern-based rules
var legacyRules []HeuristicRule
if err := json.Unmarshal([]byte(rulesJSON), &legacyRules); err == nil {
searchMsg := routeCtx.UserMessage
for _, rule := range legacyRules {
pattern := rule.Pattern pattern := rule.Pattern
if pattern == "" {
continue // Avoid infinite matches with empty patterns
}
msg := searchMsg msg := searchMsg
if !rule.CaseSensitive { if !rule.CaseSensitive {
pattern = strings.ToLower(pattern) pattern = strings.ToLower(pattern)
msg = strings.ToLower(msg) msg = strings.ToLower(msg)
} }
if strings.Contains(msg, pattern) {
// Support both regex matching (if pattern is valid regex) and literal contains
matched := false
if strings.Contains(rule.Pattern, "(") || strings.Contains(rule.Pattern, "\\b") {
var re *regexp.Regexp
var err error
if !rule.CaseSensitive {
re, err = regexp.Compile("(?i)" + rule.Pattern)
} else {
re, err = regexp.Compile(rule.Pattern)
}
if err == nil {
matched = re.MatchString(routeCtx.UserMessage)
}
}
if !matched && strings.Contains(msg, pattern) {
matched = true
}
if matched {
if rule.TargetIdx >= 0 && rule.TargetIdx < len(targets) { if rule.TargetIdx >= 0 && rule.TargetIdx < len(targets) {
selected = targets[rule.TargetIdx] selected = targets[rule.TargetIdx]
reason = "matched heuristic rule: " + rule.Pattern reason = "matched heuristic rule: " + rule.Pattern
@@ -40,10 +117,11 @@ func routeHeuristic(group db.ModelGroup, targets []string, userMessage string) (
} }
} }
} }
}
// Built-in fallback heuristics // Built-in fallback heuristics (if no custom rule matched)
if reason == "default (first target)" && len(targets) > 1 { if reason == "default (first target)" && len(targets) > 1 {
msgLower := strings.ToLower(userMessage) msgLower := strings.ToLower(routeCtx.UserMessage)
complexIndicators := []string{ complexIndicators := []string{
"step by step", "explain in detail", "reason through", "step by step", "explain in detail", "reason through",
"think carefully", "analyze", "debug", "write code", "think carefully", "analyze", "debug", "write code",
@@ -64,3 +142,86 @@ func routeHeuristic(group db.ModelGroup, targets []string, userMessage string) (
Reason: reason, Reason: reason,
}, nil }, nil
} }
// isConditionBasedRules returns true if the JSON represents condition-based rules.
func isConditionBasedRules(rulesJSON string) bool {
var rules []ConditionRule
if err := json.Unmarshal([]byte(rulesJSON), &rules); err == nil && len(rules) > 0 {
// If the rule has either conditions or primary_model/rule_id, treat it as condition-based
return rules[0].PrimaryModel != "" || rules[0].RuleID != ""
}
return false
}
// matchConditions evaluates whether the given conditions match the RouteContext.
func matchConditions(cond Conditions, routeCtx *RouteContext) bool {
if cond.IsDefaultFallback != nil && *cond.IsDefaultFallback {
return true
}
// Check tags: must match any_of_tags if specified
if len(cond.AnyOfTags) > 0 {
tagMatched := false
for _, ruleTag := range cond.AnyOfTags {
for _, ctxTag := range routeCtx.Tags {
if strings.EqualFold(ruleTag, ctxTag) {
tagMatched = true
break
}
}
if tagMatched {
break
}
}
if !tagMatched {
return false
}
}
// Check max input tokens
if cond.MaxInputTokensLt != nil {
if routeCtx.InputTokens >= *cond.MaxInputTokensLt {
return false
}
}
// Check reasoning flag
if cond.RequiresReasoning != nil {
if routeCtx.RequiresReasoning != *cond.RequiresReasoning {
return false
}
}
// Check tool calling flag
if cond.RequiresToolCalling != nil {
if routeCtx.RequiresToolCalling != *cond.RequiresToolCalling {
return false
}
}
// Check software development flag
if cond.IsSoftwareDevelopment != nil {
if routeCtx.IsSoftwareDevelopment != *cond.IsSoftwareDevelopment {
return false
}
}
// Check multimodal flag
if cond.HasMultimodalInput != nil {
if routeCtx.HasMultimodalInput != *cond.HasMultimodalInput {
return false
}
}
return true
}
// getModelInTargets returns the model name if it exists in targets, or empty string.
func getModelInTargets(modelName string, targets []string) string {
for _, t := range targets {
if strings.EqualFold(t, modelName) {
return t
}
}
return ""
}
+204
View File
@@ -0,0 +1,204 @@
package router
import (
"testing"
"gophergate/internal/db"
)
func TestRouteHeuristic_ConditionRules(t *testing.T) {
targets := []string{
"deepseek-v4-flash", // index 0
"gemini-3-flash", // index 1
"grok-build-0.1", // index 2
"kimi-k2.6", // index 3
"mimo-v2.5-pro", // index 4
"grok-4.3", // index 5
"deepseek-v4-pro", // index 6
}
rulesJSON := `[
{
"rule_id": "fast_flow_extraction",
"conditions": {
"any_of_tags": ["fast-flow", "classification"],
"max_input_tokens_lt": 8000,
"requires_reasoning": false
},
"primary_model": "deepseek-v4-flash",
"fallback_model": "grok-build-0.1"
},
{
"rule_id": "multimodal_long_context",
"conditions": {
"any_of_tags": ["standard-pro", "long-doc"],
"has_multimodal_input": true
},
"primary_model": "gemini-3-flash",
"fallback_model": "mimo-v2.5-pro"
},
{
"rule_id": "regional_fallback_general",
"conditions": {
"is_default_fallback": true
},
"primary_model": "kimi-k2.6"
}
]`
group := db.ModelGroup{
ID: "dustins_stack",
Strategy: "heuristic",
HeuristicRules: &rulesJSON,
}
// 1. Test Match Fast Flow (condition success)
ctx1 := &RouteContext{
UserMessage: "classify this JSON",
InputTokens: 500,
HasMultimodalInput: false,
RequiresReasoning: false,
Tags: []string{"fast-flow", "classification"},
}
dec1, err := routeHeuristic(group, targets, ctx1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if dec1.SelectedModel != "deepseek-v4-flash" {
t.Fatalf("expected deepseek-v4-flash, got %s", dec1.SelectedModel)
}
// 2. Test Multimodal Long Context (condition success)
ctx2 := &RouteContext{
UserMessage: "explain this video",
InputTokens: 15000,
HasMultimodalInput: true,
RequiresReasoning: false,
Tags: []string{"standard-pro", "video-analysis"},
}
dec2, err := routeHeuristic(group, targets, ctx2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if dec2.SelectedModel != "gemini-3-flash" {
t.Fatalf("expected gemini-3-flash, got %s", dec2.SelectedModel)
}
// 3. Test Fallback general rule
ctx3 := &RouteContext{
UserMessage: "hello there",
InputTokens: 100,
HasMultimodalInput: false,
RequiresReasoning: false,
Tags: []string{"general"},
}
dec3, err := routeHeuristic(group, targets, ctx3)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if dec3.SelectedModel != "kimi-k2.6" {
t.Fatalf("expected kimi-k2.6, got %s", dec3.SelectedModel)
}
}
func TestRouteHeuristic_LegacyRules(t *testing.T) {
targets := []string{"gpt-4o-mini", "deepseek-v4-pro", "kimi-k2.6"}
// Legacy pattern-based rule with regex
rulesJSON := `[
{"pattern": "\\b(agent|agents|tool use)\\b", "target": 1},
{"pattern": "summarize", "target": 2}
]`
group := db.ModelGroup{
ID: "heavy-logic",
Strategy: "heuristic",
HeuristicRules: &rulesJSON,
}
// 1. Test regex match
ctx1 := &RouteContext{
UserMessage: "We need an agent to do tool use",
}
dec1, err := routeHeuristic(group, targets, ctx1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if dec1.SelectedModel != "deepseek-v4-pro" {
t.Fatalf("expected deepseek-v4-pro, got %s", dec1.SelectedModel)
}
// 2. Test literal match
ctx2 := &RouteContext{
UserMessage: "Please summarize this text",
}
dec2, err := routeHeuristic(group, targets, ctx2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if dec2.SelectedModel != "kimi-k2.6" {
t.Fatalf("expected kimi-k2.6, got %s", dec2.SelectedModel)
}
}
func TestRouteHeuristic_SoftwareDevelopmentCondition(t *testing.T) {
targets := []string{
"gemini-3-flash",
"kimi-k2.7-code",
"deepseek-v4-pro",
"mimo-v2.5-pro",
}
rulesJSON := `[
{
"rule_id": "agentic_code_and_tools",
"conditions": {
"requires_tool_calling": true,
"is_software_development": true
},
"primary_model": "kimi-k2.7-code",
"fallback_model": "deepseek-v4-pro"
},
{
"rule_id": "agentic_general_tasks",
"conditions": {
"requires_tool_calling": true
},
"primary_model": "mimo-v2.5-pro"
}
]`
group := db.ModelGroup{
ID: "dustins_stack",
Strategy: "heuristic",
HeuristicRules: &rulesJSON,
}
// 1. Tool calling + Software dev should route to kimi-k2.7-code
ctx1 := &RouteContext{
UserMessage: "Write a python script to parse logs",
RequiresToolCalling: true,
IsSoftwareDevelopment: true,
}
dec1, err := routeHeuristic(group, targets, ctx1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if dec1.SelectedModel != "kimi-k2.7-code" {
t.Fatalf("expected kimi-k2.7-code, got %s", dec1.SelectedModel)
}
// 2. Tool calling but NOT Software dev should route to mimo-v2.5-pro
ctx2 := &RouteContext{
UserMessage: "Search the web for weather in New York",
RequiresToolCalling: true,
IsSoftwareDevelopment: false,
}
dec2, err := routeHeuristic(group, targets, ctx2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if dec2.SelectedModel != "mimo-v2.5-pro" {
t.Fatalf("expected mimo-v2.5-pro, got %s (reason: %s)", dec2.SelectedModel, dec2.Reason)
}
}
+82 -6
View File
@@ -4,6 +4,8 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"sync"
"gophergate/internal/db" "gophergate/internal/db"
) )
@@ -15,11 +17,23 @@ type Decision struct {
Reason string `json:"reason"` Reason string `json:"reason"`
} }
// RouteContext holds metadata of the request to evaluate condition rules.
type RouteContext struct {
UserMessage string `json:"user_message"`
InputTokens int `json:"input_tokens"`
HasMultimodalInput bool `json:"has_multimodal_input"`
RequiresToolCalling bool `json:"requires_tool_calling"`
RequiresReasoning bool `json:"requires_reasoning"`
IsSoftwareDevelopment bool `json:"is_software_development"`
Tags []string `json:"tags"`
}
// ClassifierFunc is the callback for classifier-based routing. // ClassifierFunc is the callback for classifier-based routing.
type ClassifierFunc func(ctx context.Context, selectorModel, systemPrompt, userMessage string) (string, error) type ClassifierFunc func(ctx context.Context, selectorModel, systemPrompt, userMessage string) (string, error)
// Router resolves model groups to concrete models. // Router resolves model groups to concrete models.
type Router struct { type Router struct {
mu sync.RWMutex
groups map[string]db.ModelGroup groups map[string]db.ModelGroup
classify ClassifierFunc classify ClassifierFunc
} }
@@ -36,15 +50,30 @@ func New(groups []db.ModelGroup, classify ClassifierFunc) *Router {
return r return r
} }
// Groups returns all registered model group IDs.
func (r *Router) Groups() []string {
r.mu.RLock()
defer r.mu.RUnlock()
ids := make([]string, 0, len(r.groups))
for id := range r.groups {
ids = append(ids, id)
}
return ids
}
// IsGroup returns true if the model name is a group ID. // IsGroup returns true if the model name is a group ID.
func (r *Router) IsGroup(modelID string) bool { func (r *Router) IsGroup(modelID string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
_, ok := r.groups[modelID] _, ok := r.groups[modelID]
return ok return ok
} }
// Route resolves a group to a concrete model. // Route resolves a group to a concrete model.
func (r *Router) Route(ctx context.Context, groupID string, userMessage string) (*Decision, error) { func (r *Router) Route(ctx context.Context, groupID string, routeCtx *RouteContext) (*Decision, error) {
r.mu.RLock()
group, ok := r.groups[groupID] group, ok := r.groups[groupID]
r.mu.RUnlock()
if !ok { if !ok {
return nil, fmt.Errorf("unknown model group: %s", groupID) return nil, fmt.Errorf("unknown model group: %s", groupID)
} }
@@ -56,21 +85,68 @@ func (r *Router) Route(ctx context.Context, groupID string, userMessage string)
switch group.Strategy { switch group.Strategy {
case "heuristic": case "heuristic":
return routeHeuristic(group, targets, userMessage) return routeHeuristic(group, targets, routeCtx)
case "classifier": case "classifier":
if r.classify == nil { if r.classify == nil {
return routeHeuristic(group, targets, userMessage) return routeHeuristic(group, targets, routeCtx)
} }
return routeClassifier(ctx, r.classify, group, targets, userMessage) return routeClassifier(ctx, r.classify, group, targets, routeCtx)
default: default:
return nil, fmt.Errorf("unknown strategy: %s", group.Strategy) return nil, fmt.Errorf("unknown strategy: %s", group.Strategy)
} }
} }
// RouteToConcrete resolves a model name to a concrete model, following group
// chains recursively until a non-group target is reached. Returns the original
// name unchanged if it is not a group.
func (r *Router) RouteToConcrete(ctx context.Context, modelID string, routeCtx *RouteContext) (*Decision, error) {
const maxDepth = 10
visited := make(map[string]bool)
current := modelID
var chain []*Decision
for depth := 0; depth < maxDepth; depth++ {
if !r.IsGroup(current) {
// Build a composite reason showing the chain traversed
reason := "direct"
if len(chain) > 0 {
parts := make([]string, len(chain))
for i, d := range chain {
parts[i] = d.SelectedModel + " (" + d.Reason + ")"
}
reason = strings.Join(parts, " -> ")
}
return &Decision{
SelectedModel: current,
Strategy: "hierarchical",
Reason: reason,
}, nil
}
if visited[current] {
return nil, fmt.Errorf("routing cycle detected: group %s already visited", current)
}
visited[current] = true
decision, err := r.Route(ctx, current, routeCtx)
if err != nil {
return nil, err
}
chain = append(chain, decision)
current = decision.SelectedModel
}
return nil, fmt.Errorf("routing depth exceeded: reached max depth of %d", maxDepth)
}
// Reload replaces the group definitions without recreating the router. // Reload replaces the group definitions without recreating the router.
func (r *Router) Reload(groups []db.ModelGroup) { func (r *Router) Reload(groups []db.ModelGroup) {
r.groups = make(map[string]db.ModelGroup) newGroups := make(map[string]db.ModelGroup)
for _, g := range groups { for _, g := range groups {
r.groups[g.ID] = g newGroups[g.ID] = g
} }
r.mu.Lock()
r.groups = newGroups
r.mu.Unlock()
} }
+17
View File
@@ -123,6 +123,21 @@ func (s *Server) handleUsageSummary(c *gin.Context) {
miscStats.AvgResponseTime = 0.0 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{ c.JSON(http.StatusOK, SuccessResponse(gin.H{
"total_requests": totalStats.TotalRequests, "total_requests": totalStats.TotalRequests,
"total_tokens": totalStats.TotalTokens, "total_tokens": totalStats.TotalTokens,
@@ -134,6 +149,8 @@ func (s *Server) handleUsageSummary(c *gin.Context) {
"today_cost": todayStats.TodayCost, "today_cost": todayStats.TodayCost,
"error_rate": miscStats.ErrorRate, "error_rate": miscStats.ErrorRate,
"avg_response_time": miscStats.AvgResponseTime, "avg_response_time": miscStats.AvgResponseTime,
"total_days": lifetimeStats.TotalDays,
"first_date": lifetimeStats.FirstDate,
})) }))
} }
+1
View File
@@ -12,6 +12,7 @@ type RequestLog struct {
ClientID string `json:"client_id"` ClientID string `json:"client_id"`
Provider string `json:"provider"` Provider string `json:"provider"`
Model string `json:"model"` Model string `json:"model"`
ModelGroup string `json:"model_group,omitempty"`
PromptTokens uint32 `json:"prompt_tokens"` PromptTokens uint32 `json:"prompt_tokens"`
CompletionTokens uint32 `json:"completion_tokens"` CompletionTokens uint32 `json:"completion_tokens"`
ReasoningTokens uint32 `json:"reasoning_tokens"` ReasoningTokens uint32 `json:"reasoning_tokens"`
+8 -8
View File
@@ -17,7 +17,7 @@ func (s *Server) handleGetModelGroups(c *gin.Context) {
if groups == nil { if groups == nil {
groups = []db.ModelGroup{} groups = []db.ModelGroup{}
} }
c.JSON(http.StatusOK, groups) c.JSON(http.StatusOK, SuccessResponse(groups))
} }
func (s *Server) handleCreateModelGroup(c *gin.Context) { func (s *Server) handleCreateModelGroup(c *gin.Context) {
@@ -28,17 +28,17 @@ func (s *Server) handleCreateModelGroup(c *gin.Context) {
} }
_, err := s.database.Exec(` _, err := s.database.Exec(`
INSERT INTO model_groups (id, strategy, selector_model, targets, complexity_threshold, heuristic_rules) INSERT INTO model_groups (id, strategy, selector_model, targets, complexity_threshold, heuristic_rules, logic_level, primary_use)
VALUES (?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
group.ID, group.Strategy, group.SelectorModel, group.Targets, group.ID, group.Strategy, group.SelectorModel, group.Targets,
group.ComplexityThreshold, group.HeuristicRules) group.ComplexityThreshold, group.HeuristicRules, group.LogicLevel, group.PrimaryUse)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
s.refreshRouter() s.refreshRouter()
c.JSON(http.StatusCreated, group) c.JSON(http.StatusCreated, SuccessResponse(group))
} }
func (s *Server) handleUpdateModelGroup(c *gin.Context) { func (s *Server) handleUpdateModelGroup(c *gin.Context) {
@@ -50,17 +50,17 @@ func (s *Server) handleUpdateModelGroup(c *gin.Context) {
} }
_, err := s.database.Exec(` _, err := s.database.Exec(`
UPDATE model_groups SET strategy=?, selector_model=?, targets=?, complexity_threshold=?, heuristic_rules=?, updated_at=CURRENT_TIMESTAMP UPDATE model_groups SET strategy=?, selector_model=?, targets=?, complexity_threshold=?, heuristic_rules=?, logic_level=?, primary_use=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?`, WHERE id=?`,
group.Strategy, group.SelectorModel, group.Targets, group.Strategy, group.SelectorModel, group.Targets,
group.ComplexityThreshold, group.HeuristicRules, id) group.ComplexityThreshold, group.HeuristicRules, group.LogicLevel, group.PrimaryUse, id)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
s.refreshRouter() s.refreshRouter()
c.JSON(http.StatusOK, group) c.JSON(http.StatusOK, SuccessResponse(group))
} }
func (s *Server) handleDeleteModelGroup(c *gin.Context) { func (s *Server) handleDeleteModelGroup(c *gin.Context) {
+3
View File
@@ -19,6 +19,7 @@ func (s *Server) handleGetModels(c *gin.Context) {
"deepseek": "deepseek", "deepseek": "deepseek",
"xai": "grok", "xai": "grok",
"ollama": "ollama", "ollama": "ollama",
"xiaomi": "xiaomi",
} }
// Merge registry models with DB overrides // Merge registry models with DB overrides
@@ -48,6 +49,7 @@ func (s *Server) handleGetModels(c *gin.Context) {
var result []gin.H var result []gin.H
s.registryMu.RLock() s.registryMu.RLock()
defer s.registryMu.RUnlock()
if s.registry != nil { if s.registry != nil {
for pID, pInfo := range s.registry.Providers { for pID, pInfo := range s.registry.Providers {
proxyProvider, allowed := allowedRegistryProviders[pID] proxyProvider, allowed := allowedRegistryProviders[pID]
@@ -207,6 +209,7 @@ func (s *Server) handleUpdateModel(c *gin.Context) {
} }
} }
} }
s.registryMu.RUnlock()
_, err := s.database.Exec(` _, err := s.database.Exec(`
INSERT INTO model_configs (id, provider_id, enabled, prompt_cost_per_m, completion_cost_per_m, cache_read_cost_per_m, cache_write_cost_per_m, mapping) INSERT INTO model_configs (id, provider_id, enabled, prompt_cost_per_m, completion_cost_per_m, cache_read_cost_per_m, cache_write_cost_per_m, mapping)
+14 -5
View File
@@ -25,7 +25,7 @@ func (s *Server) handleGetProviders(c *gin.Context) {
dbMap[cfg.ID] = cfg dbMap[cfg.ID] = cfg
} }
providerIDs := []string{"openai", "gemini", "deepseek", "moonshot", "grok", "ollama"} providerIDs := []string{"openai", "gemini", "deepseek", "moonshot", "grok", "ollama", "xiaomi"}
var result []gin.H var result []gin.H
for _, id := range providerIDs { for _, id := range providerIDs {
@@ -54,6 +54,10 @@ func (s *Server) handleGetProviders(c *gin.Context) {
name = "xAI Grok" name = "xAI Grok"
enabled = s.cfg.Providers.Grok.Enabled enabled = s.cfg.Providers.Grok.Enabled
baseURL = s.cfg.Providers.Grok.BaseURL baseURL = s.cfg.Providers.Grok.BaseURL
case "xiaomi":
name = "Xiaomi MiMo"
enabled = s.cfg.Providers.Xiaomi.Enabled
baseURL = s.cfg.Providers.Xiaomi.BaseURL
case "ollama": case "ollama":
name = "Ollama" name = "Ollama"
enabled = s.cfg.Providers.Ollama.Enabled enabled = s.cfg.Providers.Ollama.Enabled
@@ -78,7 +82,7 @@ func (s *Server) handleGetProviders(c *gin.Context) {
status := "disabled" status := "disabled"
if enabled { if enabled {
if _, ok := s.providers[id]; ok { if _, ok := s.getProvider(id); ok {
status = "online" status = "online"
} else { } else {
status = "error" status = "error"
@@ -109,6 +113,9 @@ func (s *Server) handleGetProviders(c *gin.Context) {
if id == "grok" { if id == "grok" {
registryID = "xai" registryID = "xai"
} }
if id == "xiaomi" {
registryID = "xiaomi"
}
if pInfo, ok := s.registry.Providers[registryID]; ok { if pInfo, ok := s.registry.Providers[registryID]; ok {
for mID := range pInfo.Models { for mID := range pInfo.Models {
@@ -196,7 +203,7 @@ func (s *Server) handleUpdateProvider(c *gin.Context) {
func (s *Server) handleTestProvider(c *gin.Context) { func (s *Server) handleTestProvider(c *gin.Context) {
name := c.Param("name") name := c.Param("name")
provider, ok := s.providers[name] provider, ok := s.getProvider(name)
if !ok { if !ok {
c.JSON(http.StatusNotFound, ErrorResponse(fmt.Sprintf("Provider %s not found or not enabled", name))) c.JSON(http.StatusNotFound, ErrorResponse(fmt.Sprintf("Provider %s not found or not enabled", name)))
return return
@@ -219,13 +226,15 @@ func (s *Server) handleTestProvider(c *gin.Context) {
// Adjust model for non-openai providers // Adjust model for non-openai providers
if name == "gemini" { if name == "gemini" {
testReq.Model = "gemini-2.0-flash" testReq.Model = "gemini-2.5-flash"
} else if name == "deepseek" { } else if name == "deepseek" {
testReq.Model = "deepseek-chat" testReq.Model = "deepseek-chat"
} else if name == "moonshot" { } else if name == "moonshot" {
testReq.Model = "kimi-k2.5" testReq.Model = "kimi-k2.7-code"
} else if name == "grok" { } else if name == "grok" {
testReq.Model = "grok-4-1-fast-non-reasoning" testReq.Model = "grok-4-1-fast-non-reasoning"
} else if name == "xiaomi" {
testReq.Model = "mimo-v2.5"
} }
_, err := provider.ChatCompletion(c.Request.Context(), testReq) _, err := provider.ChatCompletion(c.Request.Context(), testReq)
+100
View File
@@ -0,0 +1,100 @@
package server
import (
"testing"
"gophergate/internal/router"
)
func TestIsSoftwareDevelopment(t *testing.T) {
tests := []struct {
message string
expected bool
}{
{"can you check the logs? it looks like a lot of requests are being routed to kimi-k2.7-code when they don't need to be", false},
{"it looks like its still routing to kimi when it shouldn't need to", false},
{"Write a python script to parse logs", true},
{"Search the web for weather in New York", false},
{"How to build a compiler in Go", true},
{"Check my vscode config", false},
{"Let's decode this barcode", false},
{"go ahead and email both docxs and the ppt to kayla\n\n<memory-context>... HELPipedia_Telethon_Speaker_Script.docx ... mental health program specialist ...", false},
{"academic program development", false},
{"write a script for the video presentation", false},
{"How to write a bash script", true},
{"refactor this sql query", true},
{"can you organize Downloads and Documents real quick?\n\n<memory-context>\nCLI wrapper: ~/Projects/rag-engine/rag (bash script activating .venv). Python venv@~/Projects/rag-engine/.venv/.\n</memory-context>", false},
}
for _, tt := range tests {
result := isSoftwareDevelopment(tt.message)
if result != tt.expected {
t.Errorf("isSoftwareDevelopment(%q) = %v; expected %v", tt.message, result, tt.expected)
}
}
}
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)
}
}
}
})
}
}
+404 -66
View File
@@ -1,12 +1,13 @@
package server package server
import ( import (
"encoding/json"
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"log" "log"
"net/http" "net/http"
"regexp"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -27,6 +28,7 @@ type Server struct {
cfg *config.Config cfg *config.Config
database *db.DB database *db.DB
providers map[string]providers.Provider providers map[string]providers.Provider
providersMu sync.RWMutex
sessions *SessionManager sessions *SessionManager
hub *Hub hub *Hub
logger *RequestLogger logger *RequestLogger
@@ -57,7 +59,9 @@ func NewServer(cfg *config.Config, database *db.DB) *Server {
if err != nil { if err != nil {
fmt.Printf("Warning: Failed to fetch initial model registry: %v\n", err) fmt.Printf("Warning: Failed to fetch initial model registry: %v\n", err)
} else { } else {
s.registryMu.Lock()
s.registry = registry s.registry = registry
s.registryMu.Unlock()
} }
}() }()
@@ -85,7 +89,9 @@ func (s *Server) RefreshProviders() error {
dbMap[cfg.ID] = cfg dbMap[cfg.ID] = cfg
} }
providerIDs := []string{"openai", "gemini", "deepseek", "moonshot", "grok", "ollama"} newProviders := make(map[string]providers.Provider)
providerIDs := []string{"openai", "gemini", "deepseek", "moonshot", "grok", "ollama", "xiaomi"}
for _, id := range providerIDs { for _, id := range providerIDs {
// Default values from config // Default values from config
enabled := false enabled := false
@@ -113,6 +119,10 @@ func (s *Server) RefreshProviders() error {
enabled = s.cfg.Providers.Grok.Enabled enabled = s.cfg.Providers.Grok.Enabled
baseURL = s.cfg.Providers.Grok.BaseURL baseURL = s.cfg.Providers.Grok.BaseURL
apiKey, _ = s.cfg.GetAPIKey("grok") apiKey, _ = s.cfg.GetAPIKey("grok")
case "xiaomi":
enabled = s.cfg.Providers.Xiaomi.Enabled
baseURL = s.cfg.Providers.Xiaomi.BaseURL
apiKey, _ = s.cfg.GetAPIKey("xiaomi")
} }
// Overrides from DB // Overrides from DB
@@ -136,7 +146,6 @@ func (s *Server) RefreshProviders() error {
} }
if !enabled { if !enabled {
delete(s.providers, id)
continue continue
} }
@@ -167,13 +176,21 @@ func (s *Server) RefreshProviders() error {
cfg := s.cfg.Providers.Ollama cfg := s.cfg.Providers.Ollama
cfg.BaseURL = baseURL cfg.BaseURL = baseURL
p = providers.NewOllamaProvider(cfg) p = providers.NewOllamaProvider(cfg)
case "xiaomi":
cfg := s.cfg.Providers.Xiaomi
cfg.BaseURL = baseURL
p = providers.NewXiaomiProvider(cfg, apiKey)
} }
if p != nil { if p != nil {
s.providers[id] = providers.NewCircuitBreakerProvider(p) newProviders[id] = providers.NewCircuitBreakerProvider(p)
} }
} }
s.providersMu.Lock()
s.providers = newProviders
s.providersMu.Unlock()
s.refreshRouter() s.refreshRouter()
return nil return nil
} }
@@ -186,8 +203,11 @@ func (s *Server) refreshRouter() {
} }
var classifyFn router.ClassifierFunc var classifyFn router.ClassifierFunc
if openaiProvider, ok := s.providers["openai"]; ok {
classifyFn = func(ctx context.Context, selectorModel, systemPrompt, userMessage string) (string, error) { classifyFn = func(ctx context.Context, selectorModel, systemPrompt, userMessage string) (string, error) {
provider, _, err := s.selectProvider(selectorModel)
if err != nil {
return "", err
}
req := &models.UnifiedRequest{ req := &models.UnifiedRequest{
Model: selectorModel, Model: selectorModel,
Messages: []models.UnifiedMessage{ Messages: []models.UnifiedMessage{
@@ -197,7 +217,7 @@ func (s *Server) refreshRouter() {
MaxTokens: uint32Ptr(5), MaxTokens: uint32Ptr(5),
Stream: false, Stream: false,
} }
resp, err := openaiProvider.ChatCompletion(ctx, req) resp, err := provider.ChatCompletion(ctx, req)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -210,7 +230,6 @@ func (s *Server) refreshRouter() {
} }
return content, nil return content, nil
} }
}
if s.modelRouter == nil { if s.modelRouter == nil {
s.modelRouter = router.New(groups, classifyFn) s.modelRouter = router.New(groups, classifyFn)
@@ -307,9 +326,33 @@ func (s *Server) handleResponses(c *gin.Context) {
return return
} }
// Select provider based on model name // Strip common prefixes and resolve model groups to concrete models
// (same pattern as handleChatCompletions).
modelGroup := ""
modelID := req.Model
prefixes := []string{"gemini/", "google/", "openai/", "deepseek/", "moonshot/", "grok/", "ollama/", "xiaomi/"}
for _, p := range prefixes {
if strings.HasPrefix(modelID, p) {
modelID = strings.TrimPrefix(modelID, p)
break
}
}
if s.modelRouter != nil {
routeCtx := s.buildRouteContextFromResponses(req)
decision, err := s.modelRouter.RouteToConcrete(c.Request.Context(), modelID, routeCtx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("model routing failed: %v", err)})
return
}
if decision.SelectedModel != modelID {
modelGroup = modelID
}
modelID = decision.SelectedModel
}
// Select provider based on resolved model name
providerName := "openai" // default for Responses API providerName := "openai" // default for Responses API
modelLower := strings.ToLower(req.Model) modelLower := strings.ToLower(modelID)
if strings.HasPrefix(modelLower, "gemini/") || strings.Contains(modelLower, "gemini") || strings.HasPrefix(modelLower, "google/") { if strings.HasPrefix(modelLower, "gemini/") || strings.Contains(modelLower, "gemini") || strings.HasPrefix(modelLower, "google/") {
providerName = "gemini" providerName = "gemini"
} else if strings.HasPrefix(modelLower, "deepseek/") || (strings.Contains(modelLower, "deepseek") && !strings.Contains(modelLower, "ollama")) { } else if strings.HasPrefix(modelLower, "deepseek/") || (strings.Contains(modelLower, "deepseek") && !strings.Contains(modelLower, "ollama")) {
@@ -331,23 +374,13 @@ func (s *Server) handleResponses(c *gin.Context) {
providerName = "ollama" providerName = "ollama"
} }
provider, ok := s.providers[providerName] provider, ok := s.getProvider(providerName)
if !ok { if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Provider %s not enabled or supported", providerName)}) c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Provider %s not enabled or supported", providerName)})
return return
} }
// Strip common prefixes from model name // Use resolved model for the actual API call
modelID := req.Model
prefixes := []string{"gemini/", "google/", "openai/", "deepseek/", "moonshot/", "grok/", "ollama/"}
for _, p := range prefixes {
if strings.HasPrefix(modelID, p) {
modelID = strings.TrimPrefix(modelID, p)
break
}
}
// Use the stripped model name for the actual API call
req.Model = modelID req.Model = modelID
clientID := "default" clientID := "default"
@@ -362,7 +395,7 @@ func (s *Server) handleResponses(c *gin.Context) {
if stream { if stream {
ch, err := provider.ResponsesStream(c.Request.Context(), &req) ch, err := provider.ResponsesStream(c.Request.Context(), &req)
if err != nil { if err != nil {
s.logRequest(startTime, clientID, providerName, req.Model, nil, err, false) s.logRequest(startTime, clientID, providerName, modelID, modelGroup, nil, err, false)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -370,6 +403,7 @@ func (s *Server) handleResponses(c *gin.Context) {
c.Header("Content-Type", "text/event-stream") c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache") c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive") c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
var lastUsage *models.ResponsesUsage var lastUsage *models.ResponsesUsage
c.Stream(func(w io.Writer) bool { c.Stream(func(w io.Writer) bool {
@@ -377,9 +411,9 @@ func (s *Server) handleResponses(c *gin.Context) {
if !ok { if !ok {
fmt.Fprintf(w, "data: [DONE]\n\n") fmt.Fprintf(w, "data: [DONE]\n\n")
if lastUsage != nil { if lastUsage != nil {
s.logRequest(startTime, clientID, providerName, req.Model, lastUsage.ToUsage(), nil, false) s.logRequest(startTime, clientID, providerName, modelID, modelGroup, lastUsage.ToUsage(), nil, false)
} else { } else {
s.logRequest(startTime, clientID, providerName, req.Model, nil, nil, false) s.logRequest(startTime, clientID, providerName, modelID, modelGroup, nil, nil, false)
} }
return false return false
} }
@@ -399,15 +433,15 @@ func (s *Server) handleResponses(c *gin.Context) {
resp, err := provider.Responses(c.Request.Context(), &req) resp, err := provider.Responses(c.Request.Context(), &req)
if err != nil { if err != nil {
s.logRequest(startTime, clientID, providerName, req.Model, nil, err, false) s.logRequest(startTime, clientID, providerName, modelID, modelGroup, nil, err, false)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
if resp.Usage != nil { if resp.Usage != nil {
s.logRequest(startTime, clientID, providerName, req.Model, resp.Usage.ToUsage(), nil, false) s.logRequest(startTime, clientID, providerName, modelID, modelGroup, resp.Usage.ToUsage(), nil, false)
} else { } else {
s.logRequest(startTime, clientID, providerName, req.Model, nil, nil, false) s.logRequest(startTime, clientID, providerName, modelID, modelGroup, nil, nil, false)
} }
c.JSON(http.StatusOK, resp) c.JSON(http.StatusOK, resp)
} }
@@ -431,6 +465,7 @@ func (s *Server) handleListModels(c *gin.Context) {
"xai": true, // Models from models.dev use 'xai' ID for Grok "xai": true, // Models from models.dev use 'xai' ID for Grok
"llmgateway": true, // Catch-all for newer models "llmgateway": true, // Catch-all for newer models
"ollama": true, "ollama": true,
"xiaomi": true, // Xiaomi MiMo models
} }
s.registryMu.RLock() s.registryMu.RLock()
@@ -467,6 +502,20 @@ func (s *Server) handleListModels(c *gin.Context) {
} }
} }
// Add model groups so clients can discover them
if s.modelRouter != nil {
for _, gid := range s.modelRouter.Groups() {
if _, exists := modelMap[gid]; !exists {
modelMap[gid] = OpenAIModel{
ID: gid,
Object: "model",
Created: 1700000000,
OwnedBy: "gophergate",
}
}
}
}
var data []OpenAIModel var data []OpenAIModel
for _, m := range modelMap { for _, m := range modelMap {
data = append(data, m) data = append(data, m)
@@ -478,21 +527,19 @@ func (s *Server) handleListModels(c *gin.Context) {
}) })
} }
func (s *Server) handleChatCompletions(c *gin.Context) { func (s *Server) getProvider(name string) (providers.Provider, bool) {
startTime := time.Now() s.providersMu.RLock()
var req models.ChatCompletionRequest defer s.providersMu.RUnlock()
if err := c.ShouldBindJSON(&req); err != nil { p, ok := s.providers[name]
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return p, ok
return
} }
// Select provider based on model name func (s *Server) selectProvider(modelID string) (providers.Provider, string, error) {
providerName := "openai" // default providerName := "openai" // default
modelLower := strings.ToLower(req.Model) modelLower := strings.ToLower(modelID)
if strings.HasPrefix(modelLower, "gemini/") || strings.Contains(modelLower, "gemini") || strings.HasPrefix(modelLower, "google/") { if strings.HasPrefix(modelLower, "gemini/") || strings.Contains(modelLower, "gemini") || strings.HasPrefix(modelLower, "google/") {
providerName = "gemini" providerName = "gemini"
} else if strings.HasPrefix(modelLower, "deepseek/") || (strings.Contains(modelLower, "deepseek") && !strings.Contains(modelLower, "ollama")) { } else if strings.HasPrefix(modelLower, "deepseek/") || (strings.Contains(modelLower, "deepseek") && !strings.Contains(modelLower, "ollama")) {
// Only use deepseek provider if it's not explicitly tagged for ollama
providerName = "deepseek" providerName = "deepseek"
} else if strings.HasPrefix(modelLower, "moonshot/") || strings.Contains(modelLower, "kimi") || strings.Contains(modelLower, "moonshot") { } else if strings.HasPrefix(modelLower, "moonshot/") || strings.Contains(modelLower, "kimi") || strings.Contains(modelLower, "moonshot") {
providerName = "moonshot" providerName = "moonshot"
@@ -509,17 +556,53 @@ func (s *Server) handleChatCompletions(c *gin.Context) {
strings.Contains(modelLower, "codellama") || strings.Contains(modelLower, "codellama") ||
strings.Contains(modelLower, "command-r") { strings.Contains(modelLower, "command-r") {
providerName = "ollama" providerName = "ollama"
} else if strings.HasPrefix(modelLower, "xiaomi/") || strings.Contains(modelLower, "mimo") || strings.Contains(modelLower, "xiaomi") {
providerName = "xiaomi"
} }
provider, ok := s.providers[providerName] p, ok := s.getProvider(providerName)
if !ok { if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Provider %s not enabled or supported", providerName)}) return nil, "", fmt.Errorf("Provider %s not enabled or supported", providerName)
}
return p, providerName, nil
}
func (s *Server) handleChatCompletions(c *gin.Context) {
startTime := time.Now()
var req models.ChatCompletionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
// Strip common prefixes // 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 modelID := req.Model
prefixes := []string{"gemini/", "google/", "openai/", "deepseek/", "moonshot/", "grok/", "ollama/"} prefixes := []string{"gemini/", "google/", "openai/", "deepseek/", "moonshot/", "grok/", "ollama/", "xiaomi/"}
for _, p := range prefixes { for _, p := range prefixes {
if strings.HasPrefix(modelID, p) { if strings.HasPrefix(modelID, p) {
modelID = strings.TrimPrefix(modelID, p) modelID = strings.TrimPrefix(modelID, p)
@@ -527,16 +610,30 @@ func (s *Server) handleChatCompletions(c *gin.Context) {
} }
} }
// Check if model is a group and route to a concrete model // Resolve model groups to concrete models (hierarchical — groups can target groups)
if s.modelRouter != nil && s.modelRouter.IsGroup(modelID) { modelGroup := ""
userMessage := extractUserMessage(req.Messages) for i, m := range req.Messages {
decision, err := s.modelRouter.Route(c.Request.Context(), modelID, userMessage) log.Printf("[DEBUG] Incoming Msg[%d]: role=%s, hasToolCalls=%v, hasContent=%v", i, m.Role, len(m.ToolCalls) > 0, m.Content != nil)
}
if s.modelRouter != nil {
routeCtx := s.buildRouteContextFromChat(req)
decision, err := s.modelRouter.RouteToConcrete(c.Request.Context(), modelID, routeCtx)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("model routing failed: %v", err)}) c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("model routing failed: %v", err)})
return return
} }
if decision.SelectedModel != modelID {
modelGroup = modelID
}
modelID = decision.SelectedModel modelID = decision.SelectedModel
log.Printf("[ROUTER] %s -> %s (%s: %s)", req.Model, modelID, decision.Strategy, decision.Reason) log.Printf("[ROUTER] %s (%s: %s)", modelID, decision.Strategy, decision.Reason)
}
// Select provider based on the resolved model name
provider, providerName, err := s.selectProvider(modelID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
} }
// Convert ChatCompletionRequest to UnifiedRequest // Convert ChatCompletionRequest to UnifiedRequest
@@ -555,26 +652,30 @@ func (s *Server) handleChatCompletions(c *gin.Context) {
ToolChoice: req.ToolChoice, ToolChoice: req.ToolChoice,
} }
// Inject max_tokens from model registry when client doesn't specify one. // Inject or cap max_tokens from model registry.
// Prevents providers from applying a low default output cap.
// DEBUG: Trace max_tokens through the proxy
clientMaxTokens := "nil"
if unifiedReq.MaxTokens != nil {
clientMaxTokens = fmt.Sprintf("%d", *unifiedReq.MaxTokens)
}
log.Printf("[DEBUG] %s: client max_tokens=%s", modelID, clientMaxTokens)
if unifiedReq.MaxTokens == nil {
s.registryMu.RLock() s.registryMu.RLock()
meta := s.registry.FindModel(modelID) var meta *models.ModelMetadata
if s.registry != nil {
meta = s.registry.FindModel(modelID)
}
s.registryMu.RUnlock() s.registryMu.RUnlock()
if meta != nil && meta.Limit != nil && meta.Limit.Output > 0 { if meta != nil && meta.Limit != nil && meta.Limit.Output > 0 {
if unifiedReq.MaxTokens == nil {
unifiedReq.MaxTokens = &meta.Limit.Output unifiedReq.MaxTokens = &meta.Limit.Output
log.Printf("[DEBUG] %s: injected registry max_tokens=%d", modelID, meta.Limit.Output) log.Printf("[DEBUG] %s: injected registry max_tokens=%d", modelID, meta.Limit.Output)
} else if *unifiedReq.MaxTokens > meta.Limit.Output {
log.Printf("[DEBUG] %s: capping client max_tokens (%d) to registry limit (%d)", modelID, *unifiedReq.MaxTokens, meta.Limit.Output)
unifiedReq.MaxTokens = &meta.Limit.Output
} else { } else {
log.Printf("[DEBUG] %s: no registry limit found, leaving max_tokens nil (provider default)", modelID) log.Printf("[DEBUG] %s: using client max_tokens (%d)", modelID, *unifiedReq.MaxTokens)
} }
} else { } else {
log.Printf("[DEBUG] %s: using client's max_tokens=%d", modelID, *unifiedReq.MaxTokens) if unifiedReq.MaxTokens == nil {
log.Printf("[DEBUG] %s: no registry limit found, leaving max_tokens nil", modelID)
} else {
log.Printf("[DEBUG] %s: using client max_tokens (%d), no registry limit to cap", modelID, *unifiedReq.MaxTokens)
}
} }
// Handle Stop sequences // Handle Stop sequences
@@ -599,6 +700,7 @@ if unifiedReq.MaxTokens == nil {
ToolCalls: msg.ToolCalls, ToolCalls: msg.ToolCalls,
Name: msg.Name, Name: msg.Name,
ToolCallID: msg.ToolCallID, ToolCallID: msg.ToolCallID,
Prefix: msg.Prefix,
} }
// Handle multimodal content // Handle multimodal content
@@ -657,7 +759,7 @@ if unifiedReq.MaxTokens == nil {
if unifiedReq.Stream { if unifiedReq.Stream {
ch, err := provider.ChatCompletionStream(c.Request.Context(), unifiedReq) ch, err := provider.ChatCompletionStream(c.Request.Context(), unifiedReq)
if err != nil { if err != nil {
s.logRequest(startTime, clientID, providerName, req.Model, nil, err, unifiedReq.HasImages) s.logRequest(startTime, clientID, providerName, modelID, modelGroup, nil, err, unifiedReq.HasImages)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -665,13 +767,14 @@ if unifiedReq.MaxTokens == nil {
c.Header("Content-Type", "text/event-stream") c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache") c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive") c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
var lastUsage *models.Usage var lastUsage *models.Usage
c.Stream(func(w io.Writer) bool { c.Stream(func(w io.Writer) bool {
chunk, ok := <-ch chunk, ok := <-ch
if !ok { if !ok {
fmt.Fprintf(w, "data: [DONE]\n\n") fmt.Fprintf(w, "data: [DONE]\n\n")
s.logRequest(startTime, clientID, providerName, req.Model, lastUsage, nil, unifiedReq.HasImages) s.logRequest(startTime, clientID, providerName, modelID, modelGroup, lastUsage, nil, unifiedReq.HasImages)
return false return false
} }
if chunk.Usage != nil { if chunk.Usage != nil {
@@ -689,12 +792,12 @@ if unifiedReq.MaxTokens == nil {
resp, err := provider.ChatCompletion(c.Request.Context(), unifiedReq) resp, err := provider.ChatCompletion(c.Request.Context(), unifiedReq)
if err != nil { if err != nil {
s.logRequest(startTime, clientID, providerName, req.Model, nil, err, unifiedReq.HasImages) s.logRequest(startTime, clientID, providerName, modelID, modelGroup, nil, err, unifiedReq.HasImages)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
s.logRequest(startTime, clientID, providerName, req.Model, resp.Usage, nil, unifiedReq.HasImages) s.logRequest(startTime, clientID, providerName, modelID, modelGroup, resp.Usage, nil, unifiedReq.HasImages)
c.JSON(http.StatusOK, resp) c.JSON(http.StatusOK, resp)
} }
@@ -748,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] provider, ok := s.providers[providerName]
if !ok { if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Provider %s not enabled or supported", providerName)}) c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Provider %s not enabled or supported", providerName)})
@@ -763,7 +891,7 @@ func (s *Server) handleImageGenerations(c *gin.Context) {
resp, err := provider.ImageGeneration(c.Request.Context(), &req) resp, err := provider.ImageGeneration(c.Request.Context(), &req)
if err != nil { if err != nil {
s.logRequest(startTime, clientID, providerName, req.Model, nil, err, false) s.logRequest(startTime, clientID, providerName, req.Model, "", nil, err, false)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -777,7 +905,7 @@ func (s *Server) handleImageGenerations(c *gin.Context) {
// Calculate per-image cost (not per-token like chat) // Calculate per-image cost (not per-token like chat)
cost := imageGenCost(providerName, req.Model, req.Size, uint32(len(resp.Data))) cost := imageGenCost(providerName, req.Model, req.Size, uint32(len(resp.Data)))
s.logRequest(startTime, clientID, providerName, req.Model, &models.Usage{ s.logRequest(startTime, clientID, providerName, req.Model, "", &models.Usage{
PromptTokens: promptTokens, PromptTokens: promptTokens,
CompletionTokens: uint32(len(resp.Data)), CompletionTokens: uint32(len(resp.Data)),
TotalTokens: promptTokens + uint32(len(resp.Data)), TotalTokens: promptTokens + uint32(len(resp.Data)),
@@ -819,12 +947,13 @@ func imageGenCost(provider, model string, size *string, n uint32) float64 {
return perImage * float64(n) return perImage * float64(n)
} }
func (s *Server) logRequest(start time.Time, clientID, provider, model string, usage *models.Usage, err error, hasImages bool) { func (s *Server) logRequest(start time.Time, clientID, provider, model, modelGroup string, usage *models.Usage, err error, hasImages bool) {
entry := RequestLog{ entry := RequestLog{
Timestamp: start, Timestamp: start,
ClientID: clientID, ClientID: clientID,
Provider: provider, Provider: provider,
Model: model, Model: model,
ModelGroup: modelGroup,
Status: "success", Status: "success",
DurationMS: time.Since(start).Milliseconds(), DurationMS: time.Since(start).Milliseconds(),
HasImages: hasImages, HasImages: hasImages,
@@ -849,9 +978,14 @@ func (s *Server) logRequest(start time.Time, clientID, provider, model string, u
entry.CacheWriteTokens = *usage.CacheWriteTokens entry.CacheWriteTokens = *usage.CacheWriteTokens
} }
// Calculate cost using registry // Calculate cost using registry; if the resolved model is unknown,
// fall back to the model group so group requests still get priced.
s.registryMu.RLock() s.registryMu.RLock()
entry.Cost = utils.CalculateCost(s.registry, model, entry.PromptTokens, entry.CompletionTokens, entry.ReasoningTokens, entry.CacheReadTokens, entry.CacheWriteTokens) pricingModel := model
if s.registry != nil && s.registry.FindModel(pricingModel) == nil && modelGroup != "" {
pricingModel = modelGroup
}
entry.Cost = utils.CalculateCost(s.registry, pricingModel, entry.PromptTokens, entry.CompletionTokens, entry.ReasoningTokens, entry.CacheReadTokens, entry.CacheWriteTokens)
s.registryMu.RUnlock() s.registryMu.RUnlock()
} }
@@ -880,3 +1014,207 @@ func (s *Server) Run() error {
} }
func uint32Ptr(v uint32) *uint32 { return &v } func uint32Ptr(v uint32) *uint32 { return &v }
func (s *Server) buildRouteContextFromChat(req models.ChatCompletionRequest) *router.RouteContext {
userMessage := extractUserMessage(req.Messages)
requiresToolCalling := len(req.Tools) > 0
hasMultimodal := false
inputTokens := 0
for _, msg := range req.Messages {
if strContent, ok := msg.Content.(string); ok {
inputTokens += len(strContent) / 4
} else if parts, ok := msg.Content.([]interface{}); ok {
for _, part := range parts {
if partMap, ok := part.(map[string]interface{}); ok {
partType, _ := partMap["type"].(string)
if partType == "text" {
text, _ := partMap["text"].(string)
inputTokens += len(text) / 4
} else if partType == "image_url" {
hasMultimodal = true
inputTokens += 1000 // Approximate cost of an image in tokens
}
}
}
}
}
msgLower := strings.ToLower(userMessage)
requiresReasoning := strings.Contains(msgLower, "reason") ||
strings.Contains(msgLower, "think step by step") ||
strings.Contains(msgLower, "mathematics") ||
strings.Contains(msgLower, "architecture") ||
strings.Contains(msgLower, "explain in detail")
routeCtx := &router.RouteContext{
UserMessage: userMessage,
InputTokens: inputTokens,
HasMultimodalInput: hasMultimodal,
RequiresToolCalling: requiresToolCalling,
RequiresReasoning: requiresReasoning,
IsSoftwareDevelopment: isSoftwareDevelopment(userMessage),
}
log.Printf("[DEBUG] RouteContext built: msg=%q, IsSoftwareDevelopment=%v", userMessage, routeCtx.IsSoftwareDevelopment)
routeCtx.Tags = s.getRouteCtxTags(routeCtx)
return routeCtx
}
func (s *Server) buildRouteContextFromResponses(req models.ResponsesRequest) *router.RouteContext {
var userMessage string
hasMultimodal := false
inputTokens := len(req.Instructions) / 4
requiresToolCalling := len(req.Tools) > 0 && string(req.Tools) != "null" && string(req.Tools) != ""
var strInput string
if err := json.Unmarshal(req.Input, &strInput); err == nil {
userMessage = strInput
inputTokens += len(userMessage) / 4
} else {
var msgs []models.ResponseInputMessage
if err := json.Unmarshal(req.Input, &msgs); err == nil {
for _, m := range msgs {
var contentStr string
if err := json.Unmarshal(m.Content, &contentStr); err == nil {
if m.Role == "user" {
userMessage = contentStr
}
inputTokens += len(contentStr) / 4
} else {
var parts []models.ContentPart
if err := json.Unmarshal(m.Content, &parts); err == nil {
for _, p := range parts {
if p.Type == "text" {
if m.Role == "user" {
userMessage = p.Text
}
inputTokens += len(p.Text) / 4
} else if p.Type == "image_url" {
hasMultimodal = true
inputTokens += 1000
}
}
}
}
}
}
}
msgLower := strings.ToLower(userMessage)
requiresReasoning := strings.Contains(msgLower, "reason") ||
strings.Contains(msgLower, "think step by step") ||
strings.Contains(msgLower, "mathematics") ||
strings.Contains(msgLower, "architecture") ||
strings.Contains(msgLower, "explain in detail")
routeCtx := &router.RouteContext{
UserMessage: userMessage,
InputTokens: inputTokens,
HasMultimodalInput: hasMultimodal,
RequiresToolCalling: requiresToolCalling,
RequiresReasoning: requiresReasoning,
IsSoftwareDevelopment: isSoftwareDevelopment(userMessage),
}
log.Printf("[DEBUG] RouteContext built: msg=%q, IsSoftwareDevelopment=%v", userMessage, routeCtx.IsSoftwareDevelopment)
routeCtx.Tags = s.getRouteCtxTags(routeCtx)
return routeCtx
}
func (s *Server) getRouteCtxTags(routeCtx *router.RouteContext) []string {
var tags []string
msgLower := strings.ToLower(routeCtx.UserMessage)
// fast-flow keywords
fastFlowKeywords := []string{
"classify", "classification", "label", "tag", "route", "routing", "intent",
"json", "yaml", "csv", "schema", "parse", "extract", "transform", "format", "regex",
"short answer", "brief", "concise", "tl;dr", "one line", "simple",
"fix this", "small bug", "quick fix", "typo", "syntax error",
}
for _, kw := range fastFlowKeywords {
if strings.Contains(msgLower, kw) {
tags = append(tags, "fast-flow", "classification", "json-extraction", "basic-qa")
break
}
}
// standard-pro keywords
standardProKeywords := []string{
"explain", "summarize", "rewrite", "draft", "edit", "polish", "outline",
"long doc", "document", "email", "memo", "proposal", "report", "handout", "notes",
"compare", "choose", "recommend", "tradeoff", "pros and cons", "analysis",
"code review", "debug", "bug", "feature", "api", "endpoint", "implement",
"plan", "planning", "workflow", "integration",
}
for _, kw := range standardProKeywords {
if strings.Contains(msgLower, kw) {
tags = append(tags, "standard-pro", "long-doc")
break
}
}
if routeCtx.HasMultimodalInput {
tags = append(tags, "video-analysis", "multimodal-qa")
}
// heavy-logic keywords
heavyLogicKeywords := []string{
"agent", "agents", "tool use", "function calling", "multi-agent", "orchestrate",
"system design", "scaling", "performance", "architecture review", "distributed",
"hard bug", "race condition", "deadlock", "memory leak", "crash", "production outage",
"long context", "large codebase", "many files", "complex refactor", "migration",
"research", "deep dive", "literature", "paper", "scholarly", "thorough analysis",
"deep reasoning", "think step by step", "reason through", "careful analysis",
}
for _, kw := range heavyLogicKeywords {
if strings.Contains(msgLower, kw) {
tags = append(tags, "heavy-logic", "deep-reasoning", "architecture", "hard-debugging")
break
}
}
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")
}
return tags
}
var (
modelStripRegex = regexp.MustCompile(`(?i)\b[a-z0-9/._:-]*(code|coder|codex)[a-z0-9/._:-]*\b`)
memoryContextRegex = regexp.MustCompile(`(?s)<memory-context>.*?</memory-context>`)
softwareDevRegex = regexp.MustCompile(`(?i)\b(code|coding|programmer|programming|software|refactor|refactoring|compile|compiling|compiler|git|github|debug|debugging|repository|repo|unittest|unit test|test suite|test case|test cases|pull request|merge conflict|syntax error|source code|python|golang|javascript|typescript|ruby|php|perl|swift|kotlin|scala|rustlang|bash|powershell|sql|script|snippet|api|endpoint|patch|diff)\b`)
)
func isSoftwareDevelopment(userMessage string) bool {
msgLower := strings.ToLower(userMessage)
sanitized := memoryContextRegex.ReplaceAllString(msgLower, "")
sanitized = modelStripRegex.ReplaceAllString(sanitized, "")
if strings.Contains(sanitized, "script") {
nonCodingScriptTerms := []string{
"speaker script", "dialogue script", "movie script", "theatre script",
"theater script", "script writing", "script writer", "video script",
"audio script", "podcast script", "script for Kayla", "script for the",
}
hasNonCodingScript := false
for _, term := range nonCodingScriptTerms {
if strings.Contains(sanitized, strings.ToLower(term)) {
hasNonCodingScript = true
break
}
}
if hasNonCodingScript {
sanitized = strings.ReplaceAll(sanitized, "script", "")
}
}
return softwareDevRegex.MatchString(sanitized)
}
+11 -1
View File
@@ -73,11 +73,21 @@ func (s *Server) handleGetSettings(c *gin.Context) {
modelCount += len(p.Models) modelCount += len(p.Models)
} }
} }
s.registryMu.RUnlock()
maskedTokens := make([]string, len(s.cfg.Server.AuthTokens))
for i, token := range s.cfg.Server.AuthTokens {
if len(token) > 8 {
maskedTokens[i] = token[:3] + "••••" + token[len(token)-4:]
} else {
maskedTokens[i] = "••••"
}
}
c.JSON(http.StatusOK, SuccessResponse(gin.H{ c.JSON(http.StatusOK, SuccessResponse(gin.H{
"server": gin.H{ "server": gin.H{
"version": "1.0.0-go", "version": "1.0.0-go",
"auth_tokens": s.cfg.Server.AuthTokens, "auth_tokens": maskedTokens,
}, },
"database": gin.H{ "database": gin.H{
"type": "sqlite", "type": "sqlite",
+21 -5
View File
@@ -70,17 +70,33 @@ func (s *Server) handleUpdateUser(c *gin.Context) {
} }
if req.DisplayName != nil { if req.DisplayName != nil {
s.database.Exec("UPDATE users SET display_name = ? WHERE id = ?", req.DisplayName, id) if _, err := s.database.Exec("UPDATE users SET display_name = ? WHERE id = ?", req.DisplayName, id); err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse("Failed to update display name"))
return
}
} }
if req.Role != nil { if req.Role != nil {
s.database.Exec("UPDATE users SET role = ? WHERE id = ?", req.Role, id) if _, err := s.database.Exec("UPDATE users SET role = ? WHERE id = ?", req.Role, id); err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse("Failed to update role"))
return
}
} }
if req.MustChangePassword != nil { if req.MustChangePassword != nil {
s.database.Exec("UPDATE users SET must_change_password = ? WHERE id = ?", req.MustChangePassword, id) if _, err := s.database.Exec("UPDATE users SET must_change_password = ? WHERE id = ?", req.MustChangePassword, id); err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse("Failed to update password flag"))
return
}
} }
if req.Password != nil { if req.Password != nil {
hash, _ := bcrypt.GenerateFromPassword([]byte(*req.Password), 12) hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), 12)
s.database.Exec("UPDATE users SET password_hash = ? WHERE id = ?", string(hash), id) if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse("Failed to hash password"))
return
}
if _, err := s.database.Exec("UPDATE users SET password_hash = ? WHERE id = ?", string(hash), id); err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse("Failed to update password"))
return
}
} }
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "User updated"})) c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "User updated"}))
+25
View File
@@ -45,6 +45,24 @@ func FetchRegistry() (*models.ModelRegistry, error) {
return nil, fmt.Errorf("failed to fetch registry after 3 attempts: %w", lastErr) return nil, fmt.Errorf("failed to fetch registry after 3 attempts: %w", lastErr)
} }
// promoDiscount describes a temporary pricing discount applied on top of
// the standard (list) price from the model registry.
type promoDiscount struct {
Factor float64 // multiplier applied after standard calculation (0.25 = 75% off)
ExpiresAt time.Time // discount ends at this time (UTC)
}
// promoDiscounts maps model IDs to active promotional discounts.
// Sources:
// - DeepSeek v4 Pro: 75% off list pricing until 2026-05-31
// https://api-docs.deepseek.com/quick_start/pricing
var promoDiscounts = map[string]promoDiscount{
"deepseek-v4-pro": {
Factor: 0.25,
ExpiresAt: time.Date(2026, 5, 31, 23, 59, 59, 0, time.UTC),
},
}
func CalculateCost(registry *models.ModelRegistry, modelID string, promptTokens, completionTokens, reasoningTokens, cacheRead, cacheWrite uint32) float64 { func CalculateCost(registry *models.ModelRegistry, modelID string, promptTokens, completionTokens, reasoningTokens, cacheRead, cacheWrite uint32) float64 {
meta := registry.FindModel(modelID) meta := registry.FindModel(modelID)
if meta == nil || meta.Cost == nil { if meta == nil || meta.Cost == nil {
@@ -72,5 +90,12 @@ func CalculateCost(registry *models.ModelRegistry, modelID string, promptTokens,
cost += float64(cacheWrite) * (*meta.Cost.CacheWrite) / 1000000.0 cost += float64(cacheWrite) * (*meta.Cost.CacheWrite) / 1000000.0
} }
// Apply promotional discounts (e.g. DeepSeek 75% off until 2026-05-31).
if discount, ok := promoDiscounts[modelID]; ok {
if time.Now().UTC().Before(discount.ExpiresAt) {
cost *= discount.Factor
}
}
return cost return cost
} }
+160 -5
View File
@@ -752,21 +752,34 @@ body {
/* Stat Cards */ /* Stat Cards */
.stats-grid { .stats-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 1.5rem; gap: 1rem;
margin-bottom: 1.5rem; 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 { .stat-card {
background: var(--bg1); background: var(--bg1);
padding: var(--spacing-lg); padding: 1rem 1.1rem;
border-radius: var(--border-radius); border-radius: var(--border-radius);
border: 1px solid var(--bg2); border: 1px solid var(--bg2);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
display: flex; display: flex;
gap: 1.25rem; gap: 0.85rem;
align-items: center; align-items: center;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
min-width: 0;
} }
.stat-card:hover { .stat-card:hover {
@@ -1382,8 +1395,150 @@ body {
border: 1px solid var(--bg2); border: 1px solid var(--bg2);
} }
/* Settings: Warning Card */ /* Warning Card */
.warning-card { .warning-card {
border: 1px dashed var(--warning); border: 1px dashed var(--warning);
background: rgba(215, 153, 33, 0.08); 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 */
+10 -4
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GopherGate - Admin Dashboard</title> <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="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.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -51,7 +51,7 @@
<span>GopherGate</span> <span>GopherGate</span>
</div> </div>
<button class="sidebar-toggle" id="sidebar-toggle"> <button class="sidebar-toggle" id="sidebar-toggle">
<i class="fas fa-bars"></i> <i class="fas fa-times"></i>
</button> </button>
</div> </div>
@@ -135,11 +135,17 @@
</div> </div>
</nav> </nav>
<!-- Mobile sidebar backdrop -->
<div class="sidebar-backdrop" id="sidebar-backdrop"></div>
<!-- Main Content --> <!-- Main Content -->
<main class="main-content"> <main class="main-content">
<header class="top-bar"> <header class="top-bar">
<button class="mobile-menu-btn" id="mobile-menu-btn">
<i class="fas fa-bars"></i>
</button>
<div class="page-title"> <div class="page-title">
<h2 id="current-page-title">Overview</h2> <h2 id="page-title">Overview</h2>
</div> </div>
<div class="top-bar-actions"> <div class="top-bar-actions">
<div id="connection-status" class="status-indicator"> <div id="connection-status" class="status-indicator">
@@ -181,6 +187,6 @@
<script src="/js/pages/settings.js?v=7"></script> <script src="/js/pages/settings.js?v=7"></script>
<script src="/js/pages/logs.js?v=7"></script> <script src="/js/pages/logs.js?v=7"></script>
<script src="/js/pages/users.js?v=7"></script> <script src="/js/pages/users.js?v=7"></script>
<script src="/js/pages/model_groups.js?v=8"></script> <script src="/js/pages/model_groups.js?v=9"></script>
</body> </body>
</html> </html>
+53 -1
View File
@@ -60,23 +60,75 @@ class Dashboard {
const toggleBtn = document.getElementById('sidebar-toggle'); const toggleBtn = document.getElementById('sidebar-toggle');
const sidebar = document.querySelector('.sidebar'); const sidebar = document.querySelector('.sidebar');
const logoutBtn = document.getElementById('logout-btn'); 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) { if (toggleBtn && sidebar) {
toggleBtn.onclick = () => { toggleBtn.onclick = () => {
if (isMobile()) {
toggleMobileNav();
} else {
sidebar.classList.toggle('collapsed'); sidebar.classList.toggle('collapsed');
localStorage.setItem('sidebar_collapsed', sidebar.classList.contains('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'); 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) { if (logoutBtn) {
logoutBtn.onclick = () => { logoutBtn.onclick = () => {
if (isMobile()) closeMobileNav();
window.authManager.logout(); window.authManager.logout();
}; };
} }
// Handle resize
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (!isMobile()) {
closeMobileNav();
document.body.style.overflow = '';
}
}, 200);
});
} }
setupRefresh() { setupRefresh() {
+24 -6
View File
@@ -22,7 +22,7 @@ class ModelGroupsPage {
async loadGroups() { async loadGroups() {
try { try {
const groups = await api.get('/api/model-groups'); const groups = await api.get('/model-groups');
const list = document.getElementById('model-groups-list'); const list = document.getElementById('model-groups-list');
if (!groups || groups.length === 0) { if (!groups || groups.length === 0) {
list.innerHTML = '<div class="empty-state">No model groups defined. Create one to enable auto-routing.</div>'; list.innerHTML = '<div class="empty-state">No model groups defined. Create one to enable auto-routing.</div>';
@@ -30,12 +30,14 @@ class ModelGroupsPage {
} }
let html = '<table class="data-table"><thead><tr>'; let html = '<table class="data-table"><thead><tr>';
html += '<th>Group ID</th><th>Strategy</th><th>Targets</th><th>Actions</th>'; html += '<th>Group ID</th><th>Level</th><th>Primary Use</th><th>Strategy</th><th>Targets</th><th>Actions</th>';
html += '</tr></thead><tbody>'; html += '</tr></thead><tbody>';
groups.forEach(g => { groups.forEach(g => {
html += '<tr>'; html += '<tr>';
html += '<td><code>' + this.esc(g.id) + '</code></td>'; html += '<td><code>' + this.esc(g.id) + '</code></td>';
html += '<td>' + (g.logic_level != null ? g.logic_level : '&mdash;') + '</td>';
html += '<td>' + this.esc(g.primary_use || '&mdash;') + '</td>';
html += '<td><span class="badge">' + this.esc(g.strategy) + '</span></td>'; html += '<td><span class="badge">' + this.esc(g.strategy) + '</span></td>';
html += '<td><code>' + this.esc(g.targets) + '</code></td>'; html += '<td><code>' + this.esc(g.targets) + '</code></td>';
html += '<td>'; html += '<td>';
@@ -58,7 +60,7 @@ class ModelGroupsPage {
async showEditForm(id) { async showEditForm(id) {
try { try {
const groups = await api.get('/api/model-groups'); const groups = await api.get('/model-groups');
const group = groups.find(g => g.id === id); const group = groups.find(g => g.id === id);
if (group) this.renderForm(group); if (group) this.renderForm(group);
} catch (err) { } catch (err) {
@@ -106,6 +108,18 @@ class ModelGroupsPage {
<textarea id="mg-rules" rows="4" placeholder='[{"pattern":"step by step","target":1}]'>${group && group.heuristic_rules ? group.heuristic_rules : ''}</textarea> <textarea id="mg-rules" rows="4" placeholder='[{"pattern":"step by step","target":1}]'>${group && group.heuristic_rules ? group.heuristic_rules : ''}</textarea>
<small>Pattern to match in user messages. target = index into targets array.</small> <small>Pattern to match in user messages. target = index into targets array.</small>
</div> </div>
<div class="form-control">
<label>Logic Level (1-10)</label>
<input type="number" id="mg-level" value="${group && group.logic_level != null ? group.logic_level : ''}" min="1" max="10"
placeholder="e.g. 8 for heavy logic, 2 for fast/basic">
<small>Rough complexity scale. 1-3: fast/light, 4-7: standard, 8-10: heavy.</small>
</div>
<div class="form-control">
<label>Primary Use</label>
<input type="text" id="mg-primary-use" value="${this.esc(group && group.primary_use ? group.primary_use : '')}"
placeholder="e.g. Complex Coding, Logic, Agents.">
<small>Brief description of what this group is best used for.</small>
</div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary">Save</button> <button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn" onclick="document.getElementById('model-group-form').style.display='none'">Cancel</button> <button type="button" class="btn" onclick="document.getElementById('model-group-form').style.display='none'">Cancel</button>
@@ -129,18 +143,22 @@ class ModelGroupsPage {
var selectorModel = document.getElementById('mg-selector-model').value.trim() || null; var selectorModel = document.getElementById('mg-selector-model').value.trim() || null;
var thresholdVal = document.getElementById('mg-threshold').value; var thresholdVal = document.getElementById('mg-threshold').value;
var rules = document.getElementById('mg-rules').value.trim() || null; var rules = document.getElementById('mg-rules').value.trim() || null;
var logicLevelVal = document.getElementById('mg-level').value;
var primaryUse = document.getElementById('mg-primary-use').value.trim() || null;
try { JSON.parse(targets); } catch (e) { alert('Targets must be valid JSON array'); return; } try { JSON.parse(targets); } catch (e) { alert('Targets must be valid JSON array'); return; }
if (rules) { try { JSON.parse(rules); } catch (e) { alert('Heuristic rules must be valid JSON'); return; } } if (rules) { try { JSON.parse(rules); } catch (e) { alert('Heuristic rules must be valid JSON'); return; } }
var body = { id: id, strategy: strategy, targets: targets, selector_model: selectorModel, heuristic_rules: rules }; var body = { id: id, strategy: strategy, targets: targets, selector_model: selectorModel, heuristic_rules: rules };
if (thresholdVal) body.complexity_threshold = parseInt(thresholdVal); if (thresholdVal) body.complexity_threshold = parseInt(thresholdVal);
if (logicLevelVal) body.logic_level = parseInt(logicLevelVal);
if (primaryUse) body.primary_use = primaryUse;
try { try {
if (isEdit) { if (isEdit) {
await api.put('/api/model-groups/' + encodeURIComponent(id), body); await api.put('/model-groups/' + encodeURIComponent(id), body);
} else { } else {
await api.post('/api/model-groups', body); await api.post('/model-groups', body);
} }
document.getElementById('model-group-form').style.display = 'none'; document.getElementById('model-group-form').style.display = 'none';
await this.loadGroups(); await this.loadGroups();
@@ -152,7 +170,7 @@ class ModelGroupsPage {
async deleteGroup(id) { async deleteGroup(id) {
if (!confirm('Delete model group "' + id + '"? This cannot be undone.')) return; if (!confirm('Delete model group "' + id + '"? This cannot be undone.')) return;
try { try {
await api.delete('/api/model-groups/' + encodeURIComponent(id)); await api.delete('/model-groups/' + encodeURIComponent(id));
await this.loadGroups(); await this.loadGroups();
} catch (err) { } catch (err) {
alert('Failed to delete: ' + err.message); alert('Failed to delete: ' + err.message);
+2 -2
View File
@@ -392,7 +392,7 @@ class MonitoringPage {
</div> </div>
<div class="stream-entry-content"> <div class="stream-entry-content">
<strong>${request.client_id || 'Unknown'}</strong> <strong>${request.client_id || 'Unknown'}</strong>
${request.provider || 'Unknown'} (${request.model || 'Unknown'}) ${request.provider || 'Unknown'} (${request.model || 'Unknown'}${request.model_group ? ` via ${request.model_group}` : ''})
<div class="stream-entry-details"> <div class="stream-entry-details">
${request.total_tokens || request.tokens || 0} tokens ${request.duration_ms || request.duration || 0}ms ${request.total_tokens || request.tokens || 0} tokens ${request.duration_ms || request.duration || 0}ms
</div> </div>
@@ -492,7 +492,7 @@ class MonitoringPage {
simulateRequest() { simulateRequest() {
const clients = ['client-1', 'client-2', 'client-3', 'client-4', 'client-5']; const clients = ['client-1', 'client-2', 'client-3', 'client-4', 'client-5'];
const providers = ['OpenAI', 'Gemini', 'DeepSeek', 'Grok']; const providers = ['OpenAI', 'Gemini', 'DeepSeek', 'Grok'];
const models = ['gpt-4o', 'gpt-4o-mini', 'gemini-2.0-flash', 'deepseek-chat', 'grok-4-1-fast-non-reasoning']; const models = ['gpt-4o', 'gpt-4o-mini', 'gemini-2.5-flash', 'deepseek-chat', 'grok-4-1-fast-non-reasoning'];
const statuses = ['success', 'success', 'success', 'error', 'warning']; // Mostly success const statuses = ['success', 'success', 'success', 'error', 'warning']; // Mostly success
const request = { const request = {
+16 -3
View File
@@ -59,7 +59,20 @@ class OverviewPage {
<div class="stat-value">${window.api.formatNumber(this.stats.total_tokens)}</div> <div class="stat-value">${window.api.formatNumber(this.stats.total_tokens)}</div>
<div class="stat-label">Total Tokens</div> <div class="stat-label">Total Tokens</div>
<div class="stat-change"> <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> </div>
</div> </div>
@@ -252,7 +265,7 @@ class OverviewPage {
<td>${time}</td> <td>${time}</td>
<td><span class="badge-client">${request.client_id}</span></td> <td><span class="badge-client">${request.client_id}</span></td>
<td>${request.provider}</td> <td>${request.provider}</td>
<td><code class="code-sm">${request.model}</code></td> <td><code class="code-sm">${request.model}${request.model_group ? ` (via ${request.model_group})` : ''}</code></td>
<td>${request.tokens.toLocaleString()}</td> <td>${request.tokens.toLocaleString()}</td>
<td> <td>
<span class="status-badge ${statusClass}"> <span class="status-badge ${statusClass}">
@@ -313,7 +326,7 @@ class OverviewPage {
<td>${time}</td> <td>${time}</td>
<td><span class="badge-client">${request.client_id}</span></td> <td><span class="badge-client">${request.client_id}</span></td>
<td>${request.provider}</td> <td>${request.provider}</td>
<td><code class="code-sm">${request.model}</code></td> <td><code class="code-sm">${request.model}${request.model_group ? ` (via ${request.model_group})` : ''}</code></td>
<td>${(request.total_tokens || request.tokens || 0).toLocaleString()}</td> <td>${(request.total_tokens || request.tokens || 0).toLocaleString()}</td>
<td> <td>
<span class="status-badge ${statusClass}"> <span class="status-badge ${statusClass}">
+2 -2
View File
@@ -309,7 +309,7 @@ class WebSocketManager {
<td>${time}</td> <td>${time}</td>
<td>${request.client_id || 'Unknown'}</td> <td>${request.client_id || 'Unknown'}</td>
<td>${request.provider || 'Unknown'}</td> <td>${request.provider || 'Unknown'}</td>
<td>${request.model || 'Unknown'}</td> <td>${request.model || 'Unknown'}${request.model_group ? ` (via ${request.model_group})` : ''}</td>
<td>${(request.total_tokens || request.tokens || 0)}</td> <td>${(request.total_tokens || request.tokens || 0)}</td>
<td> <td>
<span class="status-badge ${statusClass}"> <span class="status-badge ${statusClass}">
@@ -358,7 +358,7 @@ class WebSocketManager {
</div> </div>
<div class="stream-entry-content"> <div class="stream-entry-content">
<strong>${request.client_id || 'Unknown'}</strong> <strong>${request.client_id || 'Unknown'}</strong>
${request.provider || 'Unknown'} (${request.model || 'Unknown'}) ${request.provider || 'Unknown'} (${request.model || 'Unknown'}${request.model_group ? ` via ${request.model_group}` : ''})
<div class="stream-entry-details"> <div class="stream-entry-details">
${(request.total_tokens || request.tokens || 0)} tokens ${(request.duration_ms || request.duration || 0)}ms ${(request.total_tokens || request.tokens || 0)} tokens ${(request.duration_ms || request.duration || 0)}ms
</div> </div>