Compare commits
132 Commits
90874a6721
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ddb710507d | |||
| 2ed027da7c | |||
| 2e4253e0bf | |||
| 2562675b8b | |||
| b3dc4365a8 | |||
| 1200a081fc | |||
| c6329d5612 | |||
| c96e1d0350 | |||
| ba25f5e6c8 | |||
| 59c38e8f8f | |||
| d23da551ab | |||
| 654f6ab6d1 | |||
| 0decc63e8c | |||
| 9980123f97 | |||
| 293cf057b9 | |||
| eb90f949a0 | |||
| a187d8e20e | |||
| 700b7cd5d6 | |||
| 4027ed4351 | |||
| 42b70621a1 | |||
| 84a18f5866 | |||
| 25e246061f | |||
| 73a82e6175 | |||
| b3354a1bbc | |||
| 1dc5f586b9 | |||
| 40f055cb57 | |||
| 970e778703 | |||
| 477a811999 | |||
| d2b9da89d9 | |||
| b7df3108fa | |||
| 28b8271c1d | |||
| eb585c0001 | |||
| 4aea7a3b4c | |||
| 330eaa57d1 | |||
| 0ae30036f0 | |||
| 3c0b59622e | |||
| 7517307c11 | |||
| 19517b0847 | |||
| a3a6f765e7 | |||
| 79dd122b56 | |||
| 3021e4b2b4 | |||
| 14de7e9ebf | |||
| 4fef201e95 | |||
| bac03de051 | |||
| 37949e560b | |||
| f04cb6b8f2 | |||
| 10262c0e5a | |||
| d345f8c41d | |||
| d1f7a57f58 | |||
| dc9af4d79c | |||
| c009d401fb | |||
| e5ef39f327 | |||
| eb67287b56 | |||
| 4aa17b4fd2 | |||
| 79571c6bdc | |||
| d46a333249 | |||
| 7446f3463d | |||
| b1a72f5a10 | |||
| 5ee539d95c | |||
| 14e26a4323 | |||
| 1c3b1c6fe9 | |||
| 5e0c10db01 | |||
| e598150d90 | |||
| 2fa6f0df62 | |||
| db76858072 | |||
| af2c5b95f7 | |||
| 1f574d8134 | |||
| 8a8d8d1477 | |||
| da074f52b4 | |||
| 9b0aa4dbe8 | |||
| 212ac14a1b | |||
| 2929f51556 | |||
| e12418cc4c | |||
| be4ec3482a | |||
| e67aafdac1 | |||
| 21e5204abd | |||
| 4095c68822 | |||
| ef37dc5af0 | |||
| fdbb068a6c | |||
| dbbf48cb14 | |||
| 1e13b0376b | |||
| 1b5cd2815e | |||
| ba4c4af2f8 | |||
| e56a284415 | |||
| cbc9eeb453 | |||
| 2f6b7deb2c | |||
| 9375448087 | |||
| 5be2f6f7aa | |||
| eebcadcba1 | |||
| 6b2bd13903 | |||
| 5dfda0a10c | |||
| a8a02d9e1c | |||
| bd1d17cc4d | |||
| 9207a7231c | |||
| c6efff9034 | |||
| 27fbd8ed15 | |||
| 348341f304 | |||
| 9380580504 | |||
| 08cf5cc1d9 | |||
| 0f0486d8d4 | |||
| 0ea2a3a985 | |||
| 21e5908c35 | |||
| 6f0a159245 | |||
| 4120a83b67 | |||
| 742cd9e921 | |||
| 593971ecb5 | |||
| 03dca998df | |||
| 0ce5f4f490 | |||
| dec4b927dc | |||
| 3f1e6d3407 | |||
| f02fd6c249 | |||
| f23796f0cc | |||
| 3f76a544e0 | |||
| e474549940 | |||
| b7e37b0399 | |||
| 263c0f0dc9 | |||
| 26d8431998 | |||
| 1f3adceda4 | |||
| 9c64a8fe42 | |||
| b04b794705 | |||
| 0f3c5b6eb4 | |||
| 66a1643bca | |||
| edc6445d70 | |||
| 2d8f1a1fd0 | |||
| cd1a1b45aa | |||
| 246a6d88f0 | |||
| 7d43b2c31b | |||
| 45c2d5e643 | |||
| 1d032c6732 | |||
| 2245cca67a | |||
| c7c244992a | |||
| 4f5b55d40f |
+8
-1
@@ -1,4 +1,4 @@
|
||||
# LLM Proxy Gateway Configuration Example
|
||||
# GopherGate Configuration Example
|
||||
# Copy this file to .env and fill in your values
|
||||
|
||||
# ==============================================================================
|
||||
@@ -15,8 +15,12 @@ LLM_PROXY__ENCRYPTION_KEY=your_secure_32_byte_key_here
|
||||
OPENAI_API_KEY=sk-...
|
||||
GEMINI_API_KEY=AIza...
|
||||
DEEPSEEK_API_KEY=sk-...
|
||||
MOONSHOT_API_KEY=sk-...
|
||||
GROK_API_KEY=xai-...
|
||||
|
||||
# Xiaomi MiMo
|
||||
XIAOMI_API_KEY=sk-...
|
||||
|
||||
# ==============================================================================
|
||||
# Server Configuration
|
||||
# ==============================================================================
|
||||
@@ -38,6 +42,9 @@ LLM_PROXY__DATABASE__MAX_CONNECTIONS=10
|
||||
# ==============================================================================
|
||||
# LLM_PROXY__PROVIDERS__OPENAI__BASE_URL=https://api.openai.com/v1
|
||||
# LLM_PROXY__PROVIDERS__GEMINI__ENABLED=true
|
||||
# LLM_PROXY__PROVIDERS__MOONSHOT__BASE_URL=https://api.moonshot.ai/v1
|
||||
# LLM_PROXY__PROVIDERS__MOONSHOT__ENABLED=true
|
||||
# LLM_PROXY__PROVIDERS__MOONSHOT__DEFAULT_MODEL=kimi-k2.5
|
||||
# LLM_PROXY__PROVIDERS__OLLAMA__BASE_URL=http://localhost:11434/v1
|
||||
# LLM_PROXY__PROVIDERS__OLLAMA__ENABLED=true
|
||||
# LLM_PROXY__PROVIDERS__OLLAMA__MODELS=llama3,mistral,llava
|
||||
|
||||
+25
-37
@@ -6,56 +6,44 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo check --all-targets
|
||||
|
||||
clippy:
|
||||
name: Clippy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo clippy --all-targets -- -D warnings
|
||||
|
||||
fmt:
|
||||
name: Formatting
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
go-version: '1.22'
|
||||
cache: true
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v4
|
||||
with:
|
||||
components: rustfmt
|
||||
- run: cargo fmt --all -- --check
|
||||
version: latest
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo test --all-targets
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
cache: true
|
||||
- name: Run Tests
|
||||
run: go test -v ./...
|
||||
|
||||
build-release:
|
||||
name: Release Build
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: [check, clippy, test]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo build --release
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
cache: true
|
||||
- name: Build
|
||||
run: go build -v -o gophergate ./cmd/gophergate
|
||||
|
||||
+16
-6
@@ -1,11 +1,21 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
/target
|
||||
/llm-proxy
|
||||
/llm-proxy-go
|
||||
/data/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
/gophergate
|
||||
/gophergate_*
|
||||
/llm-proxy
|
||||
/llm-proxy-go
|
||||
*.log
|
||||
.opencode/
|
||||
.pi-lens/
|
||||
.pi-lens/cache/
|
||||
server.pid
|
||||
/target
|
||||
nohup.out
|
||||
*.bak
|
||||
.antigravitycli/
|
||||
|
||||
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1,566 +0,0 @@
|
||||
# LLM Proxy - Comprehensive Fix Plan
|
||||
|
||||
## Project Overview
|
||||
Rust-based unified LLM proxy gateway (Axum + SQLite + Tokio) exposing an OpenAI-compatible API that routes to OpenAI, Gemini, DeepSeek, Grok, and Ollama. Includes dashboard with WebSocket monitoring. ~4,354 lines of Rust across 25 source files.
|
||||
|
||||
## Design Decisions
|
||||
- **Session management**: In-memory HashMap with expiry (no new dependencies)
|
||||
- **Provider deduplication**: Shared helper functions approach
|
||||
- **Dashboard refactor**: Full split into sub-modules (auth, usage, clients, providers, system, websocket)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Fix Compilation & Test Issues
|
||||
|
||||
### 1.1 Fix config_path type mismatch
|
||||
**Files**: `src/config/mod.rs:98`, `src/lib.rs:99`
|
||||
|
||||
The `AppConfig.config_path` field is `PathBuf` but `test_utils::create_test_state` sets it to `None`.
|
||||
|
||||
**Fix**: Change `src/config/mod.rs:98` from `pub config_path: PathBuf` to `pub config_path: Option<PathBuf>`. Update `src/config/mod.rs:177` to wrap in `Some()`:
|
||||
```rust
|
||||
config_path: Some(config_path),
|
||||
```
|
||||
|
||||
### 1.2 Fix streaming test compilation errors
|
||||
**File**: `src/utils/streaming.rs:195-201`
|
||||
|
||||
Three issues in the test:
|
||||
1. Line 195-196: `ProviderStreamChunk` missing `reasoning_content` field
|
||||
2. Line 201: `RequestLogger::new()` called with 1 arg but needs 2 (pool + dashboard_tx)
|
||||
|
||||
**Fix**:
|
||||
```rust
|
||||
// Line 195-196: Add reasoning_content field
|
||||
Ok(ProviderStreamChunk { content: "Hello".to_string(), reasoning_content: None, finish_reason: None, model: "test".to_string() }),
|
||||
Ok(ProviderStreamChunk { content: " World".to_string(), reasoning_content: None, finish_reason: Some("stop".to_string()), model: "test".to_string() }),
|
||||
|
||||
// Line 200-201: Add dashboard_tx argument
|
||||
let (dashboard_tx, _) = tokio::sync::broadcast::channel(16);
|
||||
let logger = Arc::new(RequestLogger::new(pool.clone(), dashboard_tx));
|
||||
```
|
||||
|
||||
### 1.3 Fix multimodal test assertion
|
||||
**File**: `src/multimodal/mod.rs:283`
|
||||
|
||||
Line 283 asserts `!model_supports_multimodal("gemini-pro")` but the function at line 187-189 returns `true` for ALL models starting with "gemini".
|
||||
|
||||
**Fix**: Either:
|
||||
- (a) Update the function to exclude non-vision Gemini models (more correct):
|
||||
```rust
|
||||
if model.starts_with("gemini") {
|
||||
// gemini-pro (text-only) doesn't support multimodal, but gemini-pro-vision and gemini-1.5+ do
|
||||
return model.contains("vision") || model.contains("1.5") || model.contains("2.0") || model.contains("flash") || model.contains("ultra");
|
||||
}
|
||||
```
|
||||
- (b) Or remove the failing assertion if all Gemini models actually support vision now.
|
||||
|
||||
**Recommendation**: Option (b) - remove line 283, since modern Gemini models all support multimodal. Replace with a non-multimodal model test like `assert!(!ImageConverter::model_supports_multimodal("claude-3-opus"))`.
|
||||
|
||||
### 1.4 Clean up empty/stale test files
|
||||
**Files**: `tests/streaming_test.rs`, `tests/integration_tests.rs.bak`
|
||||
|
||||
**Fix**:
|
||||
- Delete `tests/streaming_test.rs` (empty file)
|
||||
- Delete `tests/integration_tests.rs.bak` (stale backup referencing old APIs)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Fix Critical Bugs
|
||||
|
||||
### 2.1 Replace `futures::executor::block_on` with async
|
||||
**Files**:
|
||||
- `src/providers/openai.rs:63,151`
|
||||
- `src/providers/deepseek.rs:65`
|
||||
- `src/providers/grok.rs:63,151`
|
||||
- `src/providers/ollama.rs:58`
|
||||
|
||||
`block_on()` inside a Tokio async context will deadlock. The issue is that `image_input.to_base64()` is async but it's called inside a sync `.map()` closure within `serde_json::json!{}`.
|
||||
|
||||
**Fix**: Pre-process messages before building the JSON body. Create a helper function in a new file `src/providers/helpers.rs`:
|
||||
|
||||
```rust
|
||||
use crate::models::{ChatMessage, ContentPart};
|
||||
use crate::errors::AppError;
|
||||
|
||||
/// Convert messages to OpenAI-compatible JSON, resolving images asynchronously
|
||||
pub async fn messages_to_openai_json(messages: &[ChatMessage]) -> Result<Vec<serde_json::Value>, AppError> {
|
||||
let mut result = Vec::new();
|
||||
for m in messages {
|
||||
let mut parts = Vec::new();
|
||||
for p in &m.content {
|
||||
match p {
|
||||
ContentPart::Text { text } => {
|
||||
parts.push(serde_json::json!({ "type": "text", "text": text }));
|
||||
}
|
||||
ContentPart::Image(image_input) => {
|
||||
let (base64_data, mime_type) = image_input.to_base64().await
|
||||
.map_err(|e| AppError::MultimodalError(e.to_string()))?;
|
||||
parts.push(serde_json::json!({
|
||||
"type": "image_url",
|
||||
"image_url": { "url": format!("data:{};base64,{}", mime_type, base64_data) }
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
result.push(serde_json::json!({
|
||||
"role": m.role,
|
||||
"content": parts
|
||||
}));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
```
|
||||
|
||||
Then update each provider's `chat_completion` and `chat_completion_stream` to call:
|
||||
```rust
|
||||
let messages_json = crate::providers::helpers::messages_to_openai_json(&request.messages).await?;
|
||||
let mut body = serde_json::json!({
|
||||
"model": request.model,
|
||||
"messages": messages_json,
|
||||
"stream": false,
|
||||
});
|
||||
```
|
||||
|
||||
Remove all `futures::executor::block_on` calls.
|
||||
|
||||
### 2.2 Fix broken update_client query builder
|
||||
**File**: `src/client/mod.rs:129-163`
|
||||
|
||||
The `updates` vec collects column name strings like `"name = "` but they are **never used** in the actual query. The `query_builder` receives `.push_bind()` values without corresponding column names, producing malformed SQL.
|
||||
|
||||
**Fix**: Replace the broken pattern with proper QueryBuilder usage:
|
||||
```rust
|
||||
let mut query_builder = sqlx::QueryBuilder::new("UPDATE clients SET ");
|
||||
let mut has_updates = false;
|
||||
|
||||
if let Some(name) = &request.name {
|
||||
if has_updates { query_builder.push(", "); }
|
||||
query_builder.push("name = ");
|
||||
query_builder.push_bind(name);
|
||||
has_updates = true;
|
||||
}
|
||||
|
||||
if let Some(description) = &request.description {
|
||||
if has_updates { query_builder.push(", "); }
|
||||
query_builder.push("description = ");
|
||||
query_builder.push_bind(description);
|
||||
has_updates = true;
|
||||
}
|
||||
|
||||
if let Some(is_active) = request.is_active {
|
||||
if has_updates { query_builder.push(", "); }
|
||||
query_builder.push("is_active = ");
|
||||
query_builder.push_bind(is_active);
|
||||
has_updates = true;
|
||||
}
|
||||
|
||||
if let Some(rate_limit) = request.rate_limit_per_minute {
|
||||
if has_updates { query_builder.push(", "); }
|
||||
query_builder.push("rate_limit_per_minute = ");
|
||||
query_builder.push_bind(rate_limit);
|
||||
has_updates = true;
|
||||
}
|
||||
```
|
||||
|
||||
Remove the `updates` vec entirely - it serves no purpose.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Security Hardening
|
||||
|
||||
### 3.1 Implement in-memory session management
|
||||
**New file**: `src/dashboard/sessions.rs`
|
||||
|
||||
```rust
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use chrono::{DateTime, Utc, Duration};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Session {
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SessionManager {
|
||||
sessions: Arc<RwLock<HashMap<String, Session>>>,
|
||||
ttl_hours: i64,
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
pub fn new(ttl_hours: i64) -> Self {
|
||||
Self {
|
||||
sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||
ttl_hours,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_session(&self, username: String, role: String) -> String {
|
||||
let token = format!("session-{}", uuid::Uuid::new_v4());
|
||||
let now = Utc::now();
|
||||
let session = Session {
|
||||
username,
|
||||
role,
|
||||
created_at: now,
|
||||
expires_at: now + Duration::hours(self.ttl_hours),
|
||||
};
|
||||
self.sessions.write().await.insert(token.clone(), session);
|
||||
token
|
||||
}
|
||||
|
||||
pub async fn validate_session(&self, token: &str) -> Option<Session> {
|
||||
let sessions = self.sessions.read().await;
|
||||
sessions.get(token).and_then(|s| {
|
||||
if s.expires_at > Utc::now() {
|
||||
Some(s.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn revoke_session(&self, token: &str) {
|
||||
self.sessions.write().await.remove(token);
|
||||
}
|
||||
|
||||
pub async fn cleanup_expired(&self) {
|
||||
let now = Utc::now();
|
||||
self.sessions.write().await.retain(|_, s| s.expires_at > now);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add `SessionManager` to `DashboardState`. Add it to `AppState` or initialize it in dashboard `router()`.
|
||||
|
||||
### 3.2 Fix handle_auth_status to validate sessions
|
||||
**File**: `src/dashboard/mod.rs:191-199`
|
||||
|
||||
Extract the session token from the `Authorization` header and validate it:
|
||||
|
||||
```rust
|
||||
async fn handle_auth_status(
|
||||
State(state): State<DashboardState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Json<ApiResponse<serde_json::Value>> {
|
||||
let token = headers.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "));
|
||||
|
||||
if let Some(token) = token {
|
||||
if let Some(session) = state.session_manager.validate_session(token).await {
|
||||
return Json(ApiResponse::success(serde_json::json!({
|
||||
"authenticated": true,
|
||||
"user": {
|
||||
"username": session.username,
|
||||
"name": "Administrator",
|
||||
"role": session.role
|
||||
}
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
Json(ApiResponse::error("Not authenticated".to_string()))
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Add middleware to protect dashboard API routes
|
||||
Create an Axum middleware that validates session tokens on all `/api/` routes except `/api/auth/login`.
|
||||
|
||||
### 3.4 Force password change for default admin
|
||||
**File**: `src/database/mod.rs:138-148`
|
||||
|
||||
Add a `must_change_password` column to the `users` table. Set it to `true` for the default admin. Return `must_change_password: true` in the login response so the frontend can prompt.
|
||||
|
||||
### 3.5 Mask auth tokens in settings API response
|
||||
**File**: `src/dashboard/mod.rs:1048`
|
||||
|
||||
Use the existing `mask_token` function (currently `#[allow(dead_code)]` at line 1066):
|
||||
```rust
|
||||
"auth_tokens": state.app_state.auth_tokens.iter().map(|t| mask_token(t)).collect::<Vec<_>>(),
|
||||
```
|
||||
Remove the `#[allow(dead_code)]` attribute.
|
||||
|
||||
### 3.6 Move Gemini API key from URL to header
|
||||
**File**: `src/providers/gemini.rs:172-176,301-305`
|
||||
|
||||
Change from:
|
||||
```rust
|
||||
let url = format!("{}/models/{}:generateContent?key={}", self.config.base_url, request.model, self.api_key);
|
||||
```
|
||||
To:
|
||||
```rust
|
||||
let url = format!("{}/models/{}:generateContent", self.config.base_url, request.model);
|
||||
// ...
|
||||
let response = self.client.post(&url)
|
||||
.header("x-goog-api-key", &self.api_key)
|
||||
.json(&gemini_request)
|
||||
.send()
|
||||
.await
|
||||
```
|
||||
|
||||
Same for the streaming URL at line 301-305.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Implement Stubs & Missing Features
|
||||
|
||||
### 4.1 Implement handle_test_provider
|
||||
**File**: `src/dashboard/mod.rs:840-849`
|
||||
|
||||
Actually test the provider by sending a minimal chat completion:
|
||||
```rust
|
||||
async fn handle_test_provider(
|
||||
State(state): State<DashboardState>,
|
||||
axum::extract::Path(name): axum::extract::Path<String>,
|
||||
) -> Json<ApiResponse<serde_json::Value>> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
if let Some(provider) = state.app_state.provider_manager.get_provider(&name).await {
|
||||
let test_request = UnifiedRequest {
|
||||
model: "test".to_string(), // Provider will use default
|
||||
messages: vec![ChatMessage { role: "user".to_string(), content: vec![ContentPart::Text { text: "Hi".to_string() }] }],
|
||||
temperature: None,
|
||||
max_tokens: Some(5),
|
||||
stream: false,
|
||||
};
|
||||
|
||||
match provider.chat_completion(test_request).await {
|
||||
Ok(_) => {
|
||||
let latency = start.elapsed().as_millis();
|
||||
Json(ApiResponse::success(json!({ "success": true, "latency": latency, "message": "Connection test successful" })))
|
||||
}
|
||||
Err(e) => Json(ApiResponse::error(format!("Provider test failed: {}", e)))
|
||||
}
|
||||
} else {
|
||||
Json(ApiResponse::error(format!("Provider '{}' not found or not enabled", name)))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Implement real system health metrics
|
||||
**File**: `src/dashboard/mod.rs:969-978`
|
||||
|
||||
Read from `/proc/self/status` for memory, calculate from pool stats:
|
||||
```rust
|
||||
// Memory: read RSS from /proc/self/status
|
||||
let memory_kb = std::fs::read_to_string("/proc/self/status")
|
||||
.ok()
|
||||
.and_then(|s| s.lines().find(|l| l.starts_with("VmRSS:")).map(|l| l.to_string()))
|
||||
.and_then(|l| l.split_whitespace().nth(1).and_then(|v| v.parse::<f64>().ok()))
|
||||
.unwrap_or(0.0);
|
||||
let memory_mb = memory_kb / 1024.0;
|
||||
```
|
||||
|
||||
### 4.3 Implement handle_get_client
|
||||
**File**: `src/dashboard/mod.rs:647-651`
|
||||
|
||||
Query client by ID from the `clients` table and return full details.
|
||||
|
||||
### 4.4 Implement handle_client_usage
|
||||
**File**: `src/dashboard/mod.rs:676-680`
|
||||
|
||||
Query `llm_requests` aggregated by the given client_id.
|
||||
|
||||
### 4.5 Implement handle_get_provider
|
||||
**File**: `src/dashboard/mod.rs:776-780`
|
||||
|
||||
Return individual provider details (reuse logic from `handle_get_providers`).
|
||||
|
||||
### 4.6 Implement handle_system_backup
|
||||
**File**: `src/dashboard/mod.rs:1033-1039`
|
||||
|
||||
Use SQLite's backup API via raw SQL:
|
||||
```rust
|
||||
let backup_path = format!("data/backup-{}.db", chrono::Utc::now().timestamp());
|
||||
sqlx::query(&format!("VACUUM INTO '{}'", backup_path))
|
||||
.execute(pool)
|
||||
.await?;
|
||||
```
|
||||
|
||||
### 4.7 Address TODO items
|
||||
- `src/server/mod.rs:211` - Check if request messages contain `ContentPart::Image` to set `has_images: true`
|
||||
- `src/logging/mod.rs:80-81` - Add optional request/response body storage (can remain None for now, just note in code)
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Code Quality
|
||||
|
||||
### 5.1 Extract shared provider logic
|
||||
**New file**: `src/providers/helpers.rs`
|
||||
|
||||
Create shared helper functions:
|
||||
- `messages_to_openai_json()` (from Phase 2)
|
||||
- `build_openai_compatible_body()` - builds the full JSON body with model, messages, stream, temperature, max_tokens
|
||||
- `parse_openai_response()` - extracts content, reasoning_content, usage from response JSON
|
||||
- `create_openai_stream()` - creates SSE stream with standard parsing
|
||||
- `calculate_cost_with_registry()` - shared cost calculation logic
|
||||
|
||||
Update `openai.rs`, `deepseek.rs`, `grok.rs`, `ollama.rs` to call these helpers. Each provider file should shrink from ~210 lines to ~50-80 lines.
|
||||
|
||||
Add `pub mod helpers;` to `src/providers/mod.rs`.
|
||||
|
||||
### 5.2 Replace wildcard re-exports
|
||||
**File**: `src/lib.rs:22-30`
|
||||
|
||||
Replace:
|
||||
```rust
|
||||
pub use auth::*;
|
||||
pub use client::*;
|
||||
// etc.
|
||||
```
|
||||
With explicit re-exports:
|
||||
```rust
|
||||
pub use auth::AuthenticatedClient;
|
||||
pub use client::ClientManager;
|
||||
pub use config::AppConfig;
|
||||
// etc.
|
||||
```
|
||||
|
||||
### 5.3 Fix all Clippy warnings (19 total)
|
||||
|
||||
1. `src/auth/mod.rs:19` - `manual_async_fn`: Use `async fn` instead of returning a future manually
|
||||
2. `src/database/mod.rs:12` - `collapsible_if`: Merge nested if statements
|
||||
3. `src/dashboard/mod.rs:139` - `collapsible_if`: Merge nested if
|
||||
4. `src/dashboard/mod.rs:616` - `to_string_in_format_args`: Remove redundant `.to_string()`
|
||||
5. `src/multimodal/mod.rs:211,220` - `collapsible_if` x2
|
||||
6. `src/providers/openai.rs:123`, `gemini.rs:225`, `deepseek.rs:125`, `grok.rs:123`, `ollama.rs:117` - `collapsible_if` x5 in calculate_cost (will be fixed by deduplication)
|
||||
7. `src/providers/mod.rs:80` - `new_without_default`: Add `impl Default for ProviderManager`
|
||||
8. `src/providers/mod.rs:193,200` - `redundant_closure` x2: Use `Arc::clone` directly instead of `|p| Arc::clone(p)`
|
||||
9. `src/rate_limiting/mod.rs:180,333,334` - `collapsible_if` x3
|
||||
10. `src/rate_limiting/mod.rs:336` - `manual_strip`: Use `.strip_prefix()` pattern
|
||||
11. `src/utils/streaming.rs:33` - `too_many_arguments`: Wrap params in a config struct
|
||||
|
||||
### 5.4 Replace unwrap() in production paths
|
||||
|
||||
1. `src/database/mod.rs:140` - `bcrypt::hash("admin", 12).unwrap()` → Use `?` with proper error propagation
|
||||
2. `src/dashboard/mod.rs:116` - `serde_json::to_string(&event).unwrap()` → Use `unwrap_or_default()` or log error
|
||||
3. `src/server/mod.rs:168` - `.json_data(response).unwrap()` → Handle error with fallback
|
||||
4. `src/config/mod.rs:139` - `std::env::current_dir().unwrap()` → Use `?` or provide a sensible default
|
||||
|
||||
### 5.5 Remove unused dependencies
|
||||
**File**: `Cargo.toml`
|
||||
|
||||
Remove or comment out:
|
||||
- `governor = "0.6"` - Custom TokenBucket is used instead
|
||||
- `async-openai` - Raw reqwest is used for all providers
|
||||
- `once_cell = "1.19"` - Redundant with Rust 2024 edition's `std::sync::LazyLock`
|
||||
|
||||
Verify each is actually unused by checking imports with `rg 'use governor' src/` etc. before removing.
|
||||
|
||||
### 5.6 Split dashboard/mod.rs into sub-modules
|
||||
**Current**: 1077-line monolith at `src/dashboard/mod.rs`
|
||||
|
||||
**Target structure**:
|
||||
```
|
||||
src/dashboard/
|
||||
├── mod.rs (~80 lines) - Module declarations, router(), DashboardState, ApiResponse
|
||||
├── sessions.rs (~80 lines) - SessionManager (new from Phase 3)
|
||||
├── auth.rs (~80 lines) - handle_login, handle_auth_status, handle_change_password
|
||||
├── usage.rs (~200 lines) - handle_usage_summary, handle_time_series, handle_clients_usage, handle_providers_usage, handle_detailed_usage, handle_analytics_breakdown
|
||||
├── clients.rs (~100 lines) - handle_get_clients, handle_create_client, handle_get_client, handle_delete_client, handle_client_usage
|
||||
├── providers.rs (~150 lines) - handle_get_providers, handle_get_provider, handle_update_provider, handle_test_provider
|
||||
├── models.rs (~100 lines) - handle_get_models, handle_update_model
|
||||
├── system.rs (~120 lines) - handle_system_health, handle_system_logs, handle_system_backup, handle_get_settings, handle_update_settings
|
||||
└── websocket.rs (~60 lines) - handle_websocket, handle_websocket_connection, handle_websocket_message
|
||||
```
|
||||
|
||||
The `mod.rs` will declare sub-modules and re-export the `router()` function. All handlers use `DashboardState` which stays in `mod.rs`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Infrastructure
|
||||
|
||||
### 6.1 Add rustfmt.toml
|
||||
```toml
|
||||
max_width = 120
|
||||
tab_spaces = 4
|
||||
edition = "2024"
|
||||
```
|
||||
|
||||
### 6.2 Add clippy.toml
|
||||
```toml
|
||||
too-many-arguments-threshold = 10
|
||||
```
|
||||
|
||||
### 6.3 Add GitHub Actions CI workflow
|
||||
**New file**: `.github/workflows/ci.yml`
|
||||
|
||||
```yaml
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo fmt --check
|
||||
- run: cargo clippy -- -D warnings
|
||||
- run: cargo test
|
||||
- run: cargo build --release
|
||||
```
|
||||
|
||||
### 6.4 Fix test_dashboard.sh
|
||||
**File**: `test_dashboard.sh:33`
|
||||
|
||||
Change `"admin123"` to `"admin"` to match the actual default password.
|
||||
|
||||
### 6.5 Add Dockerfile
|
||||
**New file**: `Dockerfile`
|
||||
|
||||
Multi-stage build for minimal image size:
|
||||
```dockerfile
|
||||
FROM rust:1.85-bookworm AS builder
|
||||
WORKDIR /app
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build --release && rm -rf src
|
||||
COPY . .
|
||||
RUN cargo build --release
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=builder /app/target/release/llm-proxy /usr/local/bin/
|
||||
COPY --from=builder /app/static /app/static
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
CMD ["llm-proxy"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
After all phases, run:
|
||||
```bash
|
||||
cargo fmt --check
|
||||
cargo clippy -- -D warnings
|
||||
cargo test
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
All must pass with zero warnings and zero errors.
|
||||
|
||||
---
|
||||
|
||||
## Issue Summary
|
||||
|
||||
| Severity | Count | Phase |
|
||||
|----------|-------|-------|
|
||||
| Critical | 7 | 1-3 |
|
||||
| High | 5 | 2-3 |
|
||||
| Medium | 14 | 4-5 |
|
||||
| Low | 4 | 6 |
|
||||
| **Total** | **30** | |
|
||||
|
||||
Estimated effort: ~4-6 hours of focused implementation.
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"gopls": {
|
||||
"choice": "yes",
|
||||
"timestamp": 1775750416837
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"version": 1,
|
||||
"files": {
|
||||
"../../../../.npm-packages/lib/node_modules/pi-lens/clients/lsp/index.ts": {
|
||||
"latest": {
|
||||
"commit": "da074f5",
|
||||
"timestamp": "2026-04-26T03:45:14.025Z",
|
||||
"mi": 12.6,
|
||||
"cognitive": 335,
|
||||
"nesting": 6,
|
||||
"lines": 910,
|
||||
"maxCyclomatic": 36,
|
||||
"entropy": 6.97
|
||||
},
|
||||
"history": [
|
||||
{
|
||||
"commit": "da074f5",
|
||||
"timestamp": "2026-04-26T03:45:14.025Z",
|
||||
"mi": 12.6,
|
||||
"cognitive": 335,
|
||||
"nesting": 6,
|
||||
"lines": 910,
|
||||
"maxCyclomatic": 36,
|
||||
"entropy": 6.97
|
||||
}
|
||||
],
|
||||
"trend": "stable"
|
||||
},
|
||||
"../../../../.npm-packages/lib/node_modules/pi-lens/clients/lsp/config.ts": {
|
||||
"latest": {
|
||||
"commit": "da074f5",
|
||||
"timestamp": "2026-04-26T03:45:32.901Z",
|
||||
"mi": 37.7,
|
||||
"cognitive": 49,
|
||||
"nesting": 6,
|
||||
"lines": 173,
|
||||
"maxCyclomatic": 8,
|
||||
"entropy": 6.39
|
||||
},
|
||||
"history": [
|
||||
{
|
||||
"commit": "da074f5",
|
||||
"timestamp": "2026-04-26T03:45:32.901Z",
|
||||
"mi": 37.7,
|
||||
"cognitive": 49,
|
||||
"nesting": 6,
|
||||
"lines": 173,
|
||||
"maxCyclomatic": 8,
|
||||
"entropy": 6.39
|
||||
}
|
||||
],
|
||||
"trend": "stable"
|
||||
},
|
||||
"../../../../.npm-packages/lib/node_modules/pi-lens/clients/lsp/server.ts": {
|
||||
"latest": {
|
||||
"commit": "da074f5",
|
||||
"timestamp": "2026-04-26T03:45:38.756Z",
|
||||
"mi": 3.9,
|
||||
"cognitive": 322,
|
||||
"nesting": 7,
|
||||
"lines": 1506,
|
||||
"maxCyclomatic": 28,
|
||||
"entropy": 7.47
|
||||
},
|
||||
"history": [
|
||||
{
|
||||
"commit": "da074f5",
|
||||
"timestamp": "2026-04-26T03:45:38.756Z",
|
||||
"mi": 3.9,
|
||||
"cognitive": 322,
|
||||
"nesting": 7,
|
||||
"lines": 1506,
|
||||
"maxCyclomatic": 28,
|
||||
"entropy": 7.47
|
||||
}
|
||||
],
|
||||
"trend": "stable"
|
||||
}
|
||||
},
|
||||
"capturedAt": "2026-04-26T03:45:43.756Z"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"files": {},
|
||||
"turnCycles": 0,
|
||||
"maxCycles": 3,
|
||||
"lastUpdated": "2026-04-27T14:41:46.671Z"
|
||||
}
|
||||
@@ -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.
|
||||
+24
-20
@@ -1,6 +1,6 @@
|
||||
# Backend Architecture (Go)
|
||||
|
||||
The LLM Proxy backend is implemented in Go, focusing on high performance, clear concurrency patterns, and maintainability.
|
||||
The GopherGate backend is implemented in Go, focusing on high performance, clear concurrency patterns, and maintainability.
|
||||
|
||||
## Core Technologies
|
||||
|
||||
@@ -9,12 +9,13 @@ The LLM Proxy backend is implemented in Go, focusing on high performance, clear
|
||||
- **Database:** [sqlx](https://github.com/jmoiron/sqlx) - Lightweight wrapper for standard `database/sql`.
|
||||
- **SQLite Driver:** [modernc.org/sqlite](https://modernc.org/sqlite) - CGO-free SQLite implementation for ease of cross-compilation.
|
||||
- **Config:** [Viper](https://github.com/spf13/viper) - Robust configuration management supporting environment variables and files.
|
||||
- **Metrics:** [gopsutil](https://github.com/shirou/gopsutil) - System-level resource monitoring.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
├── cmd/
|
||||
│ └── llm-proxy/ # Entry point (main.go)
|
||||
│ └── gophergate/ # Entry point (main.go)
|
||||
├── internal/
|
||||
│ ├── config/ # Configuration loading and validation
|
||||
│ ├── db/ # Database schema, migrations, and models
|
||||
@@ -22,40 +23,43 @@ The LLM Proxy backend is implemented in Go, focusing on high performance, clear
|
||||
│ ├── models/ # Unified request/response structs
|
||||
│ ├── providers/ # LLM provider implementations (OpenAI, Gemini, etc.)
|
||||
│ ├── server/ # HTTP server, dashboard handlers, and WebSocket hub
|
||||
│ └── utils/ # Common utilities (multimodal, etc.)
|
||||
│ └── utils/ # Common utilities (registry, pricing, etc.)
|
||||
└── static/ # Frontend assets (served by the backend)
|
||||
```
|
||||
|
||||
## Key Components
|
||||
|
||||
### 1. Provider Interface (`internal/providers/provider.go`)
|
||||
Standardized interface for all LLM backends:
|
||||
```go
|
||||
type Provider interface {
|
||||
Name() string
|
||||
ChatCompletion(ctx context.Context, req *models.UnifiedRequest) (*models.ChatCompletionResponse, error)
|
||||
ChatCompletionStream(ctx context.Context, req *models.UnifiedRequest) (<-chan *models.ChatCompletionStreamResponse, error)
|
||||
}
|
||||
```
|
||||
Standardized interface for all LLM backends. Implementations handle mapping between the unified format and provider-specific APIs (OpenAI, Gemini, DeepSeek, Grok, Moonshot, Ollama).
|
||||
|
||||
### 2. Asynchronous Logging (`internal/server/logging.go`)
|
||||
### 2. Model Registry & Pricing (`internal/utils/registry.go`)
|
||||
Integrates with `models.dev/api.json` to provide real-time model metadata and pricing.
|
||||
- **Fuzzy Matching:** Supports matching versioned model IDs (e.g., `gpt-4o-2024-08-06`) to base registry entries.
|
||||
- **Automatic Refreshes:** The registry is fetched at startup and refreshed every 24 hours via a background goroutine.
|
||||
|
||||
### 3. Asynchronous Logging (`internal/server/logging.go`)
|
||||
Uses a buffered channel and background worker to log every request to SQLite without blocking the client response. It also broadcasts logs to the WebSocket hub for real-time dashboard updates.
|
||||
|
||||
### 3. Session Management (`internal/server/sessions.go`)
|
||||
Implements HMAC-SHA256 signed tokens for dashboard authentication. Sessions are stored in-memory with configurable TTL.
|
||||
### 4. Session Management (`internal/server/sessions.go`)
|
||||
Implements HMAC-SHA256 signed tokens for dashboard authentication. Tokens secure the management interface while standard Bearer tokens are used for LLM API access.
|
||||
|
||||
### 4. WebSocket Hub (`internal/server/websocket.go`)
|
||||
A centralized hub for managing WebSocket connections, allowing real-time broadcast of system events and request logs to the dashboard.
|
||||
### 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.
|
||||
|
||||
### 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
|
||||
|
||||
Go's goroutines and channels are used extensively:
|
||||
- **Streaming:** Each streaming request uses a goroutine to read and parse the provider's response, feeding chunks into a channel.
|
||||
- **Logging:** A single background worker processes the `logChan` to perform database writes.
|
||||
- **Streaming:** Each streaming request uses a goroutine to read and parse the provider's response, feeding chunks into a channel for SSE delivery.
|
||||
- **Logging:** A single background worker processes the `logChan` to perform serial database writes.
|
||||
- **WebSocket:** The `Hub` runs in a dedicated goroutine, handling registration and broadcasting.
|
||||
- **Maintenance:** Background tasks handle registry refreshes and status monitoring.
|
||||
|
||||
## Security
|
||||
|
||||
- **Encryption Key:** A mandatory 32-byte key is used for both session signing and encryption of sensitive data in the database.
|
||||
- **Auth Middleware:** Verifies client API keys against the database before proxying requests to LLM providers.
|
||||
- **Encryption Key:** A mandatory 32-byte key is used for both session signing and encryption of sensitive data.
|
||||
- **Auth Middleware:** Scoped to `/v1` routes to verify client API keys against the database.
|
||||
- **Bcrypt:** Passwords for dashboard users are hashed using Bcrypt with a work factor of 12.
|
||||
- **Database Hardening:** Automatic migrations ensure the schema is always current with the code.
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
# LLM Proxy Code Review Plan
|
||||
|
||||
## Overview
|
||||
The **LLM Proxy** project is a Rust-based middleware designed to provide a unified interface for multiple Large Language Models (LLMs). Based on the repository structure, the project aims to implement a high-performance proxy server (`src/`) that handles request routing, usage tracking, and billing logic. A static dashboard (`static/`) provides a management interface for monitoring consumption and managing API keys. The architecture leverages Rust's async capabilities for efficient request handling and SQLite for persistent state management.
|
||||
|
||||
## Review Phases
|
||||
|
||||
### Phase 1: Backend Architecture & Rust Logic (@code-reviewer)
|
||||
- **Focus on:**
|
||||
- **Core Proxy Logic:** Efficiency of the request/response pipeline and streaming support.
|
||||
- **State Management:** Thread-safety and shared state patterns using `Arc` and `Mutex`/`RwLock`.
|
||||
- **Error Handling:** Use of idiomatic Rust error types and propagation.
|
||||
- **Async Performance:** Proper use of `tokio` or similar runtimes to avoid blocking the executor.
|
||||
- **Rust Idioms:** Adherence to Clippy suggestions and standard Rust naming conventions.
|
||||
|
||||
### Phase 2: Security & Authentication Audit (@security-auditor)
|
||||
- **Focus on:**
|
||||
- **API Key Management:** Secure storage, masking in logs, and rotation mechanisms.
|
||||
- **JWT Handling:** Validation logic, signature verification, and expiration checks.
|
||||
- **Input Validation:** Sanitization of prompts and configuration parameters to prevent injection.
|
||||
- **Dependency Audit:** Scanning for known vulnerabilities in the `Cargo.lock` using `cargo-audit`.
|
||||
|
||||
### Phase 3: Database & Data Integrity Review (@database-optimizer)
|
||||
- **Focus on:**
|
||||
- **Schema Design:** Efficiency of the SQLite schema for usage tracking and billing.
|
||||
- **Migration Strategy:** Robustness of the migration scripts to prevent data loss.
|
||||
- **Usage Tracking:** Accuracy of token counting and concurrency handling during increments.
|
||||
- **Query Optimization:** Identifying potential bottlenecks in reporting queries.
|
||||
|
||||
### Phase 4: Frontend & Dashboard Review (@frontend-developer)
|
||||
- **Focus on:**
|
||||
- **Vanilla JS Patterns:** Review of Web Components and modular JS in `static/js`.
|
||||
- **Security:** Protection against XSS in the dashboard and secure handling of local storage.
|
||||
- **UI/UX Consistency:** Ensuring the management interface is intuitive and responsive.
|
||||
- **API Integration:** Robustness of the frontend's communication with the Rust backend.
|
||||
|
||||
### Phase 5: Infrastructure & Deployment Review (@devops-engineer)
|
||||
- **Focus on:**
|
||||
- **Dockerfile Optimization:** Multi-stage builds to minimize image size and attack surface.
|
||||
- **Resource Limits:** Configuration of CPU/Memory limits for the proxy container.
|
||||
- **Deployment Docs:** Clarity of the setup process and environment variable documentation.
|
||||
|
||||
## Timeline (Gantt)
|
||||
|
||||
```mermaid
|
||||
gantt
|
||||
title LLM Proxy Code Review Timeline (March 2026)
|
||||
dateFormat YYYY-MM-DD
|
||||
section Backend & Security
|
||||
Architecture & Rust Logic (Phase 1) :active, p1, 2026-03-06, 1d
|
||||
Security & Auth Audit (Phase 2) :p2, 2026-03-07, 1d
|
||||
section Data & Frontend
|
||||
Database & Integrity (Phase 3) :p3, 2026-03-07, 1d
|
||||
Frontend & Dashboard (Phase 4) :p4, 2026-03-08, 1d
|
||||
section DevOps
|
||||
Infra & Deployment (Phase 5) :p5, 2026-03-08, 1d
|
||||
Final Review & Sign-off :2026-03-08, 4h
|
||||
```
|
||||
|
||||
## Success Criteria
|
||||
- **Security:** Zero high-priority vulnerabilities identified; all API keys masked in logs.
|
||||
- **Performance:** Proxy overhead is minimal (<10ms latency addition); queries are indexed.
|
||||
- **Maintainability:** Code passes all linting (`cargo clippy`) and formatting (`cargo fmt`) checks.
|
||||
- **Documentation:** README and deployment guides are up-to-date and accurate.
|
||||
- **Reliability:** Usage tracking matches actual API consumption with 99.9% accuracy.
|
||||
@@ -1,220 +0,0 @@
|
||||
# LLM Proxy Gateway - Admin Dashboard
|
||||
|
||||
## Overview
|
||||
|
||||
This is a comprehensive admin dashboard for the LLM Proxy Gateway, providing real-time monitoring, analytics, and management capabilities for the proxy service.
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Dashboard Overview
|
||||
- Real-time request counters and statistics
|
||||
- System health indicators
|
||||
- Provider status monitoring
|
||||
- Recent requests stream
|
||||
|
||||
### 2. Usage Analytics
|
||||
- Time series charts for requests, tokens, and costs
|
||||
- Filter by date range, client, provider, and model
|
||||
- Top clients and models analysis
|
||||
- Export functionality to CSV/JSON
|
||||
|
||||
### 3. Cost Management
|
||||
- Cost breakdown by provider, client, and model
|
||||
- Budget tracking with alerts
|
||||
- Cost projections
|
||||
- Pricing configuration management
|
||||
|
||||
### 4. Client Management
|
||||
- List, create, revoke, and rotate API tokens
|
||||
- Client-specific rate limits
|
||||
- Usage statistics per client
|
||||
- Token management interface
|
||||
|
||||
### 5. Provider Configuration
|
||||
- Enable/disable LLM providers
|
||||
- Configure API keys (masked display)
|
||||
- Test provider connections
|
||||
- Model availability management
|
||||
|
||||
### 6. User Management (RBAC)
|
||||
- **Admin Role:** Full access to all dashboard features, user management, system configuration
|
||||
- **Viewer Role:** Read-only access to usage analytics, costs, and monitoring
|
||||
- Create/manage dashboard users with role assignment
|
||||
- Secure password management
|
||||
|
||||
### 7. Real-time Monitoring
|
||||
- Live request stream via WebSocket
|
||||
- System metrics dashboard
|
||||
- Response time and error rate tracking
|
||||
- Live system logs
|
||||
|
||||
### 7. **System Settings**
|
||||
- General configuration
|
||||
- Database management
|
||||
- Logging settings
|
||||
- Security settings
|
||||
|
||||
## Technology Stack
|
||||
|
||||
### Frontend
|
||||
- **HTML5/CSS3**: Modern, responsive design with CSS Grid/Flexbox
|
||||
- **JavaScript (ES6+)**: Vanilla JavaScript with modular architecture
|
||||
- **Chart.js**: Interactive data visualizations
|
||||
- **Luxon**: Date/time manipulation
|
||||
- **WebSocket API**: Real-time updates
|
||||
|
||||
### Backend (Rust/Axum)
|
||||
- **Axum**: Web framework with WebSocket support
|
||||
- **Tokio**: Async runtime
|
||||
- **Serde**: JSON serialization/deserialization
|
||||
- **Broadcast channels**: Real-time event distribution
|
||||
|
||||
## Installation & Setup
|
||||
|
||||
### 1. Build and Run the Server
|
||||
```bash
|
||||
# Build the project
|
||||
cargo build --release
|
||||
|
||||
# Run the server
|
||||
cargo run --release
|
||||
```
|
||||
|
||||
### 2. Access the Dashboard
|
||||
Once the server is running, access the dashboard at:
|
||||
```
|
||||
http://localhost:8080
|
||||
```
|
||||
|
||||
### 3. Default Login Credentials
|
||||
- **Username**: `admin`
|
||||
- **Password**: `admin123`
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication
|
||||
- `POST /api/auth/login` - Dashboard login
|
||||
- `GET /api/auth/status` - Authentication status
|
||||
|
||||
### Analytics
|
||||
- `GET /api/usage/summary` - Overall usage summary
|
||||
- `GET /api/usage/time-series` - Time series data
|
||||
- `GET /api/usage/clients` - Client breakdown
|
||||
- `GET /api/usage/providers` - Provider breakdown
|
||||
|
||||
### Clients
|
||||
- `GET /api/clients` - List all clients
|
||||
- `POST /api/clients` - Create new client
|
||||
- `PUT /api/clients/{id}` - Update client
|
||||
- `DELETE /api/clients/{id}` - Revoke client
|
||||
- `GET /api/clients/{id}/usage` - Client-specific usage
|
||||
|
||||
### Users (RBAC)
|
||||
- `GET /api/users` - List all dashboard users
|
||||
- `POST /api/users` - Create new user
|
||||
- `PUT /api/users/{id}` - Update user (admin only)
|
||||
- `DELETE /api/users/{id}` - Delete user (admin only)
|
||||
|
||||
### Providers
|
||||
- `GET /api/providers` - List providers and status
|
||||
- `PUT /api/providers/{name}` - Update provider config
|
||||
- `POST /api/providers/{name}/test` - Test provider connection
|
||||
|
||||
### System
|
||||
- `GET /api/system/health` - System health
|
||||
- `GET /api/system/logs` - Recent logs
|
||||
- `POST /api/system/backup` - Trigger backup
|
||||
|
||||
### WebSocket
|
||||
- `GET /ws` - WebSocket endpoint for real-time updates
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
llm-proxy/
|
||||
├── src/
|
||||
│ ├── dashboard/ # Dashboard backend module
|
||||
│ │ └── mod.rs # Dashboard routes and handlers
|
||||
│ ├── server/ # Main proxy server
|
||||
│ ├── providers/ # LLM provider implementations
|
||||
│ └── ... # Other modules
|
||||
├── static/ # Frontend dashboard files
|
||||
│ ├── index.html # Main dashboard HTML
|
||||
│ ├── css/
|
||||
│ │ └── dashboard.css # Dashboard styles
|
||||
│ ├── js/
|
||||
│ │ ├── auth.js # Authentication module
|
||||
│ │ ├── dashboard.js # Main dashboard controller
|
||||
│ │ ├── websocket.js # WebSocket manager
|
||||
│ │ ├── charts.js # Chart.js utilities
|
||||
│ │ └── pages/ # Page-specific modules
|
||||
│ │ ├── overview.js
|
||||
│ │ ├── analytics.js
|
||||
│ │ ├── costs.js
|
||||
│ │ ├── clients.js
|
||||
│ │ ├── providers.js
|
||||
│ │ ├── monitoring.js
|
||||
│ │ ├── settings.js
|
||||
│ │ └── logs.js
|
||||
│ ├── img/ # Images and icons
|
||||
│ └── fonts/ # Font files
|
||||
└── Cargo.toml # Rust dependencies
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Adding New Pages
|
||||
1. Create a new JavaScript module in `static/js/pages/`
|
||||
2. Implement the page class with `init()` method
|
||||
3. Register the page in `dashboard.js`
|
||||
4. Add menu item in `index.html`
|
||||
|
||||
### Adding New API Endpoints
|
||||
1. Add route in `src/dashboard/mod.rs`
|
||||
2. Implement handler function
|
||||
3. Update frontend JavaScript to call the endpoint
|
||||
|
||||
### Styling Guidelines
|
||||
- Use CSS custom properties (variables) from `:root`
|
||||
- Follow mobile-first responsive design
|
||||
- Use BEM-like naming convention for CSS classes
|
||||
- Maintain consistent spacing with CSS variables
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Authentication**: Simple password-based auth for demo; replace with proper auth in production
|
||||
2. **API Keys**: Tokens are masked in the UI (only last 4 characters shown)
|
||||
3. **CORS**: Configure appropriate CORS headers for production
|
||||
4. **Rate Limiting**: Implement rate limiting for API endpoints
|
||||
5. **HTTPS**: Always use HTTPS in production
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
1. **Code Splitting**: JavaScript modules are loaded on-demand
|
||||
2. **Caching**: Static assets are served with cache headers
|
||||
3. **WebSocket**: Real-time updates reduce polling overhead
|
||||
4. **Lazy Loading**: Charts and tables load data as needed
|
||||
5. **Compression**: Enable gzip/brotli compression for static files
|
||||
|
||||
## Browser Support
|
||||
|
||||
- Chrome 60+
|
||||
- Firefox 55+
|
||||
- Safari 11+
|
||||
- Edge 79+
|
||||
|
||||
## License
|
||||
|
||||
MIT License - See LICENSE file for details.
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Add tests if applicable
|
||||
5. Submit a pull request
|
||||
|
||||
## Support
|
||||
|
||||
For issues and feature requests, please use the GitHub issue tracker.
|
||||
@@ -1,480 +0,0 @@
|
||||
# Database Review Report for LLM-Proxy Repository
|
||||
|
||||
**Review Date:** 2025-03-06
|
||||
**Reviewer:** Database Optimization Expert
|
||||
**Repository:** llm-proxy
|
||||
**Focus Areas:** Schema Design, Query Optimization, Migration Strategy, Data Integrity, Usage Tracking Accuracy
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The llm-proxy database implementation demonstrates solid foundation with appropriate table structures and clear separation of concerns. However, several areas require improvement to ensure scalability, data consistency, and performance as usage grows. Key findings include:
|
||||
|
||||
1. **Schema Design**: Generally normalized but missing foreign key enforcement and some critical indexes.
|
||||
2. **Query Optimization**: Well-optimized for most queries but missing composite indexes for common filtering patterns.
|
||||
3. **Migration Strategy**: Ad-hoc migration approach that may cause issues with schema evolution.
|
||||
4. **Data Integrity**: Potential race conditions in usage tracking and missing transaction boundaries.
|
||||
5. **Usage Tracking**: Generally accurate but risk of inconsistent state between related tables.
|
||||
|
||||
This report provides detailed analysis and actionable recommendations for each area.
|
||||
|
||||
## 1. Schema Design Review
|
||||
|
||||
### Tables Overview
|
||||
|
||||
The database consists of 6 main tables:
|
||||
|
||||
1. **clients**: Client management with usage aggregates
|
||||
2. **llm_requests**: Request logging with token counts and costs
|
||||
3. **provider_configs**: Provider configuration and credit balances
|
||||
4. **model_configs**: Model-specific configuration and cost overrides
|
||||
5. **users**: Dashboard user authentication
|
||||
6. **client_tokens**: API token storage for client authentication
|
||||
|
||||
### Normalization Assessment
|
||||
|
||||
**Strengths:**
|
||||
- Tables follow 3rd Normal Form (3NF) with appropriate separation
|
||||
- Foreign key relationships properly defined
|
||||
- No obvious data duplication across tables
|
||||
|
||||
**Areas for Improvement:**
|
||||
- **Denormalized aggregates**: `clients.total_requests`, `total_tokens`, `total_cost` are derived from `llm_requests`. This introduces risk of inconsistency.
|
||||
- **Provider credit balance**: Stored in `provider_configs` but also updated based on `llm_requests`. No audit trail for balance changes.
|
||||
|
||||
### Data Type Analysis
|
||||
|
||||
**Appropriate Choices:**
|
||||
- INTEGER for token counts (cast from u32 to i64)
|
||||
- REAL for monetary values
|
||||
- DATETIME for timestamps using SQLite's CURRENT_TIMESTAMP
|
||||
- TEXT for identifiers with appropriate length
|
||||
|
||||
**Potential Issues:**
|
||||
- `llm_requests.request_body` and `response_body` defined as TEXT but always set to NULL - consider removing or making optional columns.
|
||||
- `provider_configs.billing_mode` added via migration but default value not consistently applied to existing rows.
|
||||
|
||||
### Constraints and Foreign Keys
|
||||
|
||||
**Current Constraints:**
|
||||
- Primary keys defined for all tables
|
||||
- UNIQUE constraints on `clients.client_id`, `users.username`, `client_tokens.token`
|
||||
- Foreign key definitions present but **not enforced** (SQLite default)
|
||||
|
||||
**Missing Constraints:**
|
||||
- NOT NULL constraints missing on several columns where nullability not intended
|
||||
- CHECK constraints for positive values (`credit_balance >= 0`)
|
||||
- Foreign key enforcement not enabled
|
||||
|
||||
## 2. Query Optimization Analysis
|
||||
|
||||
### Indexing Strategy
|
||||
|
||||
**Existing Indexes:**
|
||||
- `idx_clients_client_id` - Essential for client lookups
|
||||
- `idx_clients_created_at` - Useful for chronological listing
|
||||
- `idx_llm_requests_timestamp` - Critical for time-based queries
|
||||
- `idx_llm_requests_client_id` - Supports client-specific queries
|
||||
- `idx_llm_requests_provider` - Good for provider breakdowns
|
||||
- `idx_llm_requests_status` - Low cardinality but acceptable
|
||||
- `idx_client_tokens_token` UNIQUE - Essential for authentication
|
||||
- `idx_client_tokens_client_id` - Supports token management
|
||||
|
||||
**Missing Critical Indexes:**
|
||||
1. `model_configs.provider_id` - Foreign key column used in JOINs
|
||||
2. `llm_requests(client_id, timestamp)` - Composite index for client time-series queries
|
||||
3. `llm_requests(provider, timestamp)` - For provider performance analysis
|
||||
4. `llm_requests(status, timestamp)` - For error trend analysis
|
||||
|
||||
### N+1 Query Detection
|
||||
|
||||
**Well-Optimized Areas:**
|
||||
- Model configuration caching prevents repeated database hits
|
||||
- Provider configs loaded in batch for dashboard display
|
||||
- Client listing uses single efficient query
|
||||
|
||||
**Potential N+1 Patterns:**
|
||||
- In `server/mod.rs` list_models function, cache lookup per model but this is in-memory
|
||||
- No significant database N+1 issues identified
|
||||
|
||||
### Inefficient Query Patterns
|
||||
|
||||
**Query 1: Time-series aggregation with strftime()**
|
||||
```sql
|
||||
SELECT strftime('%Y-%m-%d', timestamp) as date, ...
|
||||
FROM llm_requests
|
||||
WHERE 1=1 {}
|
||||
GROUP BY date, client_id, provider, model
|
||||
ORDER BY date DESC
|
||||
LIMIT 200
|
||||
```
|
||||
**Issue:** Function on indexed column prevents index utilization for the WHERE clause when filtering by timestamp range.
|
||||
|
||||
**Recommendation:** Store computed date column or use range queries on timestamp directly.
|
||||
|
||||
**Query 2: Today's stats using strftime()**
|
||||
```sql
|
||||
WHERE strftime('%Y-%m-%d', timestamp) = ?
|
||||
```
|
||||
**Issue:** Non-sargable query prevents index usage.
|
||||
|
||||
**Recommendation:** Use range query:
|
||||
```sql
|
||||
WHERE timestamp >= date(?) AND timestamp < date(?, '+1 day')
|
||||
```
|
||||
|
||||
### Recommended Index Additions
|
||||
|
||||
```sql
|
||||
-- Composite indexes for common query patterns
|
||||
CREATE INDEX idx_llm_requests_client_timestamp ON llm_requests(client_id, timestamp);
|
||||
CREATE INDEX idx_llm_requests_provider_timestamp ON llm_requests(provider, timestamp);
|
||||
CREATE INDEX idx_llm_requests_status_timestamp ON llm_requests(status, timestamp);
|
||||
|
||||
-- Foreign key index
|
||||
CREATE INDEX idx_model_configs_provider_id ON model_configs(provider_id);
|
||||
|
||||
-- Optional: Covering index for client usage queries
|
||||
CREATE INDEX idx_clients_usage ON clients(client_id, total_requests, total_tokens, total_cost);
|
||||
```
|
||||
|
||||
## 3. Migration Strategy Assessment
|
||||
|
||||
### Current Approach
|
||||
|
||||
The migration system uses a hybrid approach:
|
||||
|
||||
1. **Schema synchronization**: `CREATE TABLE IF NOT EXISTS` on startup
|
||||
2. **Ad-hoc migrations**: `ALTER TABLE` statements with error suppression
|
||||
3. **Single migration file**: `migrations/001-add-billing-mode.sql` with transaction wrapper
|
||||
|
||||
**Pros:**
|
||||
- Simple to understand and maintain
|
||||
- Automatic schema creation for new deployments
|
||||
- Error suppression prevents crashes on column existence
|
||||
|
||||
**Cons:**
|
||||
- No version tracking of applied migrations
|
||||
- Potential for inconsistent schema across deployments
|
||||
- `ALTER TABLE` error suppression hides genuine schema issues
|
||||
- No rollback capability
|
||||
|
||||
### Risks and Limitations
|
||||
|
||||
1. **Schema Drift**: Different instances may have different schemas if migrations are applied out of order
|
||||
2. **Data Loss Risk**: No backup/verification before schema changes
|
||||
3. **Production Issues**: Error suppression could mask migration failures until runtime
|
||||
|
||||
### Recommendations
|
||||
|
||||
1. **Implement Proper Migration Tooling**: Use `sqlx migrate` or similar versioned migration system
|
||||
2. **Add Migration Version Table**: Track applied migrations and checksum verification
|
||||
3. **Separate Migration Scripts**: One file per migration with up/down directions
|
||||
4. **Pre-deployment Validation**: Schema checks in CI/CD pipeline
|
||||
5. **Backup Strategy**: Automatic backups before migration execution
|
||||
|
||||
## 4. Data Integrity Evaluation
|
||||
|
||||
### Foreign Key Enforcement
|
||||
|
||||
**Critical Issue:** Foreign key constraints are defined but **not enforced** in SQLite.
|
||||
|
||||
**Impact:** Orphaned records, inconsistent referential integrity.
|
||||
|
||||
**Solution:** Enable foreign key support in connection string:
|
||||
```rust
|
||||
let options = SqliteConnectOptions::from_str(&format!("sqlite:{}", database_path))?
|
||||
.create_if_missing(true)
|
||||
.pragma("foreign_keys", "ON");
|
||||
```
|
||||
|
||||
### Transaction Usage
|
||||
|
||||
**Good Patterns:**
|
||||
- Request logging uses transactions for insert + provider balance update
|
||||
- Atomic UPDATE for client usage statistics
|
||||
|
||||
**Problematic Areas:**
|
||||
|
||||
1. **Split Transactions**: Client usage update and request logging are in separate transactions
|
||||
- In `logging/mod.rs`: `insert_log` transaction includes provider balance update
|
||||
- In `utils/streaming.rs`: Client usage updated separately after logging
|
||||
- **Risk**: Partial updates if one transaction fails
|
||||
|
||||
2. **No Transaction for Client Creation**: Client and token creation not atomic
|
||||
|
||||
**Recommendations:**
|
||||
- Wrap client usage update within the same transaction as request logging
|
||||
- Use transaction for client + token creation
|
||||
- Consider using savepoints for complex operations
|
||||
|
||||
### Race Conditions and Consistency
|
||||
|
||||
**Potential Race Conditions:**
|
||||
1. **Provider credit balance**: Concurrent requests may cause lost updates
|
||||
- Current: `UPDATE provider_configs SET credit_balance = credit_balance - ?`
|
||||
- SQLite provides serializable isolation, but negative balances not prevented
|
||||
|
||||
2. **Client usage aggregates**: Concurrent updates to `total_requests`, `total_tokens`, `total_cost`
|
||||
- Similar UPDATE pattern, generally safe but consider idempotency
|
||||
|
||||
**Recommendations:**
|
||||
- Add check constraint: `CHECK (credit_balance >= 0)`
|
||||
- Implement idempotent request logging with unique request IDs
|
||||
- Consider optimistic concurrency control for critical balances
|
||||
|
||||
## 5. Usage Tracking Accuracy
|
||||
|
||||
### Token Counting Methodology
|
||||
|
||||
**Current Approach:**
|
||||
- Prompt tokens: Estimated using provider-specific estimators
|
||||
- Completion tokens: Estimated or from provider real usage data
|
||||
- Cache tokens: Separately tracked for cache-aware pricing
|
||||
|
||||
**Strengths:**
|
||||
- Fallback to estimation when provider doesn't report usage
|
||||
- Cache token differentiation for accurate pricing
|
||||
|
||||
**Weaknesses:**
|
||||
- Estimation may differ from actual provider counts
|
||||
- No validation of provider-reported token counts
|
||||
|
||||
### Cost Calculation
|
||||
|
||||
**Well Implemented:**
|
||||
- Model-specific cost overrides via `model_configs`
|
||||
- Cache-aware pricing when supported by registry
|
||||
- Provider fallback calculations
|
||||
|
||||
**Potential Issues:**
|
||||
- Floating-point precision for monetary calculations
|
||||
- No rounding strategy for fractional cents
|
||||
|
||||
### Update Consistency
|
||||
|
||||
**Inconsistency Risk:** Client aggregates updated separately from request logging.
|
||||
|
||||
**Example Flow:**
|
||||
1. Request log inserted and provider balance updated (transaction)
|
||||
2. Client usage updated (separate operation)
|
||||
3. If step 2 fails, client stats undercount usage
|
||||
|
||||
**Solution:** Include client update in the same transaction:
|
||||
```rust
|
||||
// In insert_log function, add:
|
||||
UPDATE clients
|
||||
SET total_requests = total_requests + 1,
|
||||
total_tokens = total_tokens + ?,
|
||||
total_cost = total_cost + ?
|
||||
WHERE client_id = ?;
|
||||
```
|
||||
|
||||
### Financial Accuracy
|
||||
|
||||
**Good Practices:**
|
||||
- Token-level granularity for cost calculation
|
||||
- Separation of prompt/completion/cache pricing
|
||||
- Database persistence for audit trail
|
||||
|
||||
**Recommendations:**
|
||||
1. **Audit Trail**: Add `balance_transactions` table for provider credit changes
|
||||
2. **Rounding Policy**: Define rounding strategy (e.g., to 6 decimal places)
|
||||
3. **Validation**: Periodic reconciliation of aggregates vs. detail records
|
||||
|
||||
## 6. Performance Recommendations
|
||||
|
||||
### Schema Improvements
|
||||
|
||||
1. **Partitioning Strategy**: For high-volume `llm_requests`, consider:
|
||||
- Monthly partitioning by timestamp
|
||||
- Archive old data to separate tables
|
||||
|
||||
2. **Data Retention Policy**: Implement automatic cleanup of old request logs
|
||||
```sql
|
||||
DELETE FROM llm_requests WHERE timestamp < date('now', '-90 days');
|
||||
```
|
||||
|
||||
3. **Column Optimization**: Remove unused `request_body`, `response_body` columns or implement compression
|
||||
|
||||
### Query Optimizations
|
||||
|
||||
1. **Avoid Functions on Indexed Columns**: Rewrite date queries as range queries
|
||||
2. **Batch Updates**: Consider batch updates for client usage instead of per-request
|
||||
3. **Read Replicas**: For dashboard queries, consider separate read connection
|
||||
|
||||
### Connection Pooling
|
||||
|
||||
**Current:** SQLx connection pool with default settings
|
||||
|
||||
**Recommendations:**
|
||||
- Configure pool size based on expected concurrency
|
||||
- Implement connection health checks
|
||||
- Monitor pool utilization metrics
|
||||
|
||||
### Monitoring Setup
|
||||
|
||||
**Essential Metrics:**
|
||||
- Query execution times (slow query logging)
|
||||
- Index usage statistics
|
||||
- Table growth trends
|
||||
- Connection pool utilization
|
||||
|
||||
**Implementation:**
|
||||
- Add `sqlx::metrics` integration
|
||||
- Regular `ANALYZE` execution for query planner
|
||||
- Dashboard for database health monitoring
|
||||
|
||||
## 7. Security Considerations
|
||||
|
||||
### Data Protection
|
||||
|
||||
**Sensitive Data:**
|
||||
- `provider_configs.api_key` - Should be encrypted at rest
|
||||
- `users.password_hash` - Already hashed with bcrypt
|
||||
- `client_tokens.token` - Plain text storage
|
||||
|
||||
**Recommendations:**
|
||||
- Encrypt API keys using libsodium or similar
|
||||
- Implement token hashing (similar to password hashing)
|
||||
- Regular security audits of authentication flows
|
||||
|
||||
### SQL Injection Prevention
|
||||
|
||||
**Good Practices:**
|
||||
- Use sqlx query builder with parameter binding
|
||||
- No raw SQL concatenation observed in code review
|
||||
|
||||
**Verification Needed:** Ensure all dynamic SQL uses parameterized queries
|
||||
|
||||
### Access Controls
|
||||
|
||||
**Database Level:**
|
||||
- SQLite lacks built-in user management
|
||||
- Consider file system permissions for database file
|
||||
- Application-level authentication is primary control
|
||||
|
||||
## 8. Summary of Critical Issues
|
||||
|
||||
**Priority 1 (Critical):**
|
||||
1. Foreign key constraints not enabled
|
||||
2. Split transactions risking data inconsistency
|
||||
3. Missing composite indexes for common queries
|
||||
|
||||
**Priority 2 (High):**
|
||||
1. No proper migration versioning system
|
||||
2. Potential race conditions in balance updates
|
||||
3. Non-sargable date queries impacting performance
|
||||
|
||||
**Priority 3 (Medium):**
|
||||
1. Denormalized aggregates without consistency guarantees
|
||||
2. No data retention policy for request logs
|
||||
3. Missing check constraints for data validation
|
||||
|
||||
## 9. Recommended Action Plan
|
||||
|
||||
### Phase 1: Immediate Fixes (1-2 weeks)
|
||||
1. Enable foreign key constraints in database connection
|
||||
2. Add composite indexes for common query patterns
|
||||
3. Fix transaction boundaries for client usage updates
|
||||
4. Rewrite non-sargable date queries
|
||||
|
||||
### Phase 2: Short-term Improvements (3-4 weeks)
|
||||
1. Implement proper migration system with version tracking
|
||||
2. Add check constraints for data validation
|
||||
3. Implement connection pooling configuration
|
||||
4. Create database monitoring dashboard
|
||||
|
||||
### Phase 3: Long-term Enhancements (2-3 months)
|
||||
1. Implement data retention and archiving strategy
|
||||
2. Add audit trail for provider balance changes
|
||||
3. Consider partitioning for high-volume tables
|
||||
4. Implement encryption for sensitive data
|
||||
|
||||
### Phase 4: Ongoing Maintenance
|
||||
1. Regular index maintenance and query plan analysis
|
||||
2. Periodic reconciliation of aggregate vs. detail data
|
||||
3. Security audits and dependency updates
|
||||
4. Performance benchmarking and optimization
|
||||
|
||||
---
|
||||
|
||||
## Appendices
|
||||
|
||||
### A. Sample Migration Implementation
|
||||
|
||||
```sql
|
||||
-- migrations/002-enable-foreign-keys.sql
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- migrations/003-add-composite-indexes.sql
|
||||
CREATE INDEX idx_llm_requests_client_timestamp ON llm_requests(client_id, timestamp);
|
||||
CREATE INDEX idx_llm_requests_provider_timestamp ON llm_requests(provider, timestamp);
|
||||
CREATE INDEX idx_model_configs_provider_id ON model_configs(provider_id);
|
||||
```
|
||||
|
||||
### B. Transaction Fix Example
|
||||
|
||||
```rust
|
||||
async fn insert_log(pool: &SqlitePool, log: RequestLog) -> Result<(), sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// Insert or ignore client
|
||||
sqlx::query("INSERT OR IGNORE INTO clients (client_id, name, description) VALUES (?, ?, 'Auto-created from request')")
|
||||
.bind(&log.client_id)
|
||||
.bind(&log.client_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Insert request log
|
||||
sqlx::query("INSERT INTO llm_requests ...")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Update provider balance
|
||||
if log.cost > 0.0 {
|
||||
sqlx::query("UPDATE provider_configs SET credit_balance = credit_balance - ? WHERE id = ? AND (billing_mode IS NULL OR billing_mode != 'postpaid')")
|
||||
.bind(log.cost)
|
||||
.bind(&log.provider)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Update client aggregates within same transaction
|
||||
sqlx::query("UPDATE clients SET total_requests = total_requests + 1, total_tokens = total_tokens + ?, total_cost = total_cost + ? WHERE client_id = ?")
|
||||
.bind(log.total_tokens as i64)
|
||||
.bind(log.cost)
|
||||
.bind(&log.client_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### C. Monitoring Query Examples
|
||||
|
||||
```sql
|
||||
-- Identify unused indexes
|
||||
SELECT * FROM sqlite_master
|
||||
WHERE type = 'index'
|
||||
AND name NOT IN (
|
||||
SELECT DISTINCT name
|
||||
FROM sqlite_stat1
|
||||
WHERE tbl = 'llm_requests'
|
||||
);
|
||||
|
||||
-- Table size analysis
|
||||
SELECT name, (pgsize * page_count) / 1024 / 1024 as size_mb
|
||||
FROM dbstat
|
||||
WHERE name = 'llm_requests';
|
||||
|
||||
-- Query performance analysis (requires EXPLAIN QUERY PLAN)
|
||||
EXPLAIN QUERY PLAN
|
||||
SELECT * FROM llm_requests
|
||||
WHERE client_id = ? AND timestamp >= ?;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*This report provides a comprehensive analysis of the current database implementation and actionable recommendations for improvement. Regular review and iteration will ensure the database continues to meet performance, consistency, and scalability requirements as the application grows.*
|
||||
+3
-3
@@ -11,7 +11,7 @@ RUN go mod download
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o llm-proxy ./cmd/llm-proxy
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o gophergate ./cmd/gophergate
|
||||
|
||||
# Final stage
|
||||
FROM alpine:latest
|
||||
@@ -21,7 +21,7 @@ RUN apk --no-cache add ca-certificates tzdata
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the binary from the builder stage
|
||||
COPY --from=builder /app/llm-proxy .
|
||||
COPY --from=builder /app/gophergate .
|
||||
COPY --from=builder /app/static ./static
|
||||
|
||||
# Create data directory
|
||||
@@ -31,4 +31,4 @@ RUN mkdir -p /app/data
|
||||
EXPOSE 8080
|
||||
|
||||
# Run the application
|
||||
CMD ["./llm-proxy"]
|
||||
CMD ["./gophergate"]
|
||||
|
||||
-232
@@ -1,232 +0,0 @@
|
||||
# Optimization for 512MB RAM Environment
|
||||
|
||||
This document provides guidance for optimizing the LLM Proxy Gateway for deployment in resource-constrained environments (512MB RAM).
|
||||
|
||||
## Memory Optimization Strategies
|
||||
|
||||
### 1. Build Optimization
|
||||
|
||||
The project is already configured with optimized build settings in `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[profile.release]
|
||||
opt-level = 3 # Maximum optimization
|
||||
lto = true # Link-time optimization
|
||||
codegen-units = 1 # Single codegen unit for better optimization
|
||||
strip = true # Strip debug symbols
|
||||
```
|
||||
|
||||
**Additional optimizations you can apply:**
|
||||
|
||||
```bash
|
||||
# Build with specific target for better optimization
|
||||
cargo build --release --target x86_64-unknown-linux-musl
|
||||
|
||||
# Or for ARM (Raspberry Pi, etc.)
|
||||
cargo build --release --target aarch64-unknown-linux-musl
|
||||
```
|
||||
|
||||
### 2. Runtime Memory Management
|
||||
|
||||
#### Database Connection Pool
|
||||
- Default: 10 connections
|
||||
- Recommended for 512MB: 5 connections
|
||||
|
||||
Update `config.toml`:
|
||||
```toml
|
||||
[database]
|
||||
max_connections = 5
|
||||
```
|
||||
|
||||
#### Rate Limiting Memory Usage
|
||||
- Client rate limit buckets: Store in memory
|
||||
- Circuit breakers: Minimal memory usage
|
||||
- Consider reducing burst capacity if memory is critical
|
||||
|
||||
#### Provider Management
|
||||
- Only enable providers you actually use
|
||||
- Disable unused providers in configuration
|
||||
|
||||
### 3. Configuration for Low Memory
|
||||
|
||||
Create a `config-low-memory.toml`:
|
||||
|
||||
```toml
|
||||
[server]
|
||||
port = 8080
|
||||
host = "0.0.0.0"
|
||||
|
||||
[database]
|
||||
path = "./data/llm_proxy.db"
|
||||
max_connections = 3 # Reduced from default 10
|
||||
|
||||
[providers]
|
||||
# Only enable providers you need
|
||||
openai.enabled = true
|
||||
gemini.enabled = false # Disable if not used
|
||||
deepseek.enabled = false # Disable if not used
|
||||
grok.enabled = false # Disable if not used
|
||||
|
||||
[rate_limiting]
|
||||
# Reduce memory usage for rate limiting
|
||||
client_requests_per_minute = 30 # Reduced from 60
|
||||
client_burst_size = 5 # Reduced from 10
|
||||
global_requests_per_minute = 300 # Reduced from 600
|
||||
```
|
||||
|
||||
### 4. System-Level Optimizations
|
||||
|
||||
#### Linux Kernel Parameters
|
||||
Add to `/etc/sysctl.conf`:
|
||||
```bash
|
||||
# Reduce TCP buffer sizes
|
||||
net.ipv4.tcp_rmem = 4096 87380 174760
|
||||
net.ipv4.tcp_wmem = 4096 65536 131072
|
||||
|
||||
# Reduce connection tracking
|
||||
net.netfilter.nf_conntrack_max = 65536
|
||||
net.netfilter.nf_conntrack_tcp_timeout_established = 1200
|
||||
|
||||
# Reduce socket buffer sizes
|
||||
net.core.rmem_max = 131072
|
||||
net.core.wmem_max = 131072
|
||||
net.core.rmem_default = 65536
|
||||
net.core.wmem_default = 65536
|
||||
```
|
||||
|
||||
#### Systemd Service Configuration
|
||||
Create `/etc/systemd/system/llm-proxy.service`:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=LLM Proxy Gateway
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=llmproxy
|
||||
Group=llmproxy
|
||||
WorkingDirectory=/opt/llm-proxy
|
||||
ExecStart=/opt/llm-proxy/llm-proxy
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# Memory limits
|
||||
MemoryMax=400M
|
||||
MemorySwapMax=100M
|
||||
|
||||
# CPU limits
|
||||
CPUQuota=50%
|
||||
|
||||
# Process limits
|
||||
LimitNOFILE=65536
|
||||
LimitNPROC=512
|
||||
|
||||
Environment="RUST_LOG=info"
|
||||
Environment="LLM_PROXY__DATABASE__MAX_CONNECTIONS=3"
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
### 5. Application-Specific Optimizations
|
||||
|
||||
#### Disable Unused Features
|
||||
- **Multimodal support**: If not using images, disable image processing dependencies
|
||||
- **Dashboard**: The dashboard uses WebSockets and additional memory. Consider disabling if not needed.
|
||||
- **Detailed logging**: Reduce log verbosity in production
|
||||
|
||||
#### Memory Pool Sizes
|
||||
The application uses several memory pools:
|
||||
1. **Database connection pool**: Configured via `max_connections`
|
||||
2. **HTTP client pool**: Reqwest client pool (defaults to reasonable values)
|
||||
3. **Async runtime**: Tokio worker threads
|
||||
|
||||
Reduce Tokio worker threads for low-core systems:
|
||||
```rust
|
||||
// In main.rs, modify tokio runtime creation
|
||||
#[tokio::main(flavor = "current_thread")] // Single-threaded runtime
|
||||
async fn main() -> Result<()> {
|
||||
// Or for multi-threaded with limited threads:
|
||||
// #[tokio::main(worker_threads = 2)]
|
||||
```
|
||||
|
||||
### 6. Monitoring and Profiling
|
||||
|
||||
#### Memory Usage Monitoring
|
||||
```bash
|
||||
# Install heaptrack for memory profiling
|
||||
cargo install heaptrack
|
||||
|
||||
# Profile memory usage
|
||||
heaptrack ./target/release/llm-proxy
|
||||
|
||||
# Monitor with ps
|
||||
ps aux --sort=-%mem | head -10
|
||||
|
||||
# Monitor with top
|
||||
top -p $(pgrep llm-proxy)
|
||||
```
|
||||
|
||||
#### Performance Benchmarks
|
||||
Test with different configurations:
|
||||
```bash
|
||||
# Test with 100 concurrent connections
|
||||
wrk -t4 -c100 -d30s http://localhost:8080/health
|
||||
|
||||
# Test chat completion endpoint
|
||||
ab -n 1000 -c 10 -p test_request.json -T application/json http://localhost:8080/v1/chat/completions
|
||||
```
|
||||
|
||||
### 7. Deployment Checklist for 512MB RAM
|
||||
|
||||
- [ ] Build with release profile: `cargo build --release`
|
||||
- [ ] Configure database with `max_connections = 3`
|
||||
- [ ] Disable unused providers in configuration
|
||||
- [ ] Set appropriate rate limiting limits
|
||||
- [ ] Configure systemd with memory limits
|
||||
- [ ] Set up log rotation to prevent disk space issues
|
||||
- [ ] Monitor memory usage during initial deployment
|
||||
- [ ] Consider using swap space (512MB-1GB) for safety
|
||||
|
||||
### 8. Troubleshooting High Memory Usage
|
||||
|
||||
#### Common Issues and Solutions:
|
||||
|
||||
1. **Database connection leaks**: Ensure connections are properly closed
|
||||
2. **Memory fragmentation**: Use jemalloc or mimalloc as allocator
|
||||
3. **Unbounded queues**: Check WebSocket message queues
|
||||
4. **Cache growth**: Implement cache limits or TTL
|
||||
|
||||
#### Add to Cargo.toml for alternative allocator:
|
||||
```toml
|
||||
[dependencies]
|
||||
mimalloc = { version = "0.1", default-features = false }
|
||||
|
||||
[features]
|
||||
default = ["mimalloc"]
|
||||
```
|
||||
|
||||
#### In main.rs:
|
||||
```rust
|
||||
#[global_allocator]
|
||||
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
|
||||
```
|
||||
|
||||
### 9. Expected Memory Usage
|
||||
|
||||
| Component | Baseline | With 10 clients | With 100 clients |
|
||||
|-----------|----------|-----------------|------------------|
|
||||
| Base executable | 15MB | 15MB | 15MB |
|
||||
| Database connections | 5MB | 8MB | 15MB |
|
||||
| Rate limiting | 2MB | 5MB | 20MB |
|
||||
| HTTP clients | 3MB | 5MB | 10MB |
|
||||
| **Total** | **25MB** | **33MB** | **60MB** |
|
||||
|
||||
**Note**: These are estimates. Actual usage depends on request volume, payload sizes, and configuration.
|
||||
|
||||
### 10. Further Reading
|
||||
|
||||
- [Tokio performance guide](https://tokio.rs/tokio/topics/performance)
|
||||
- [Rust performance book](https://nnethercote.github.io/perf-book/)
|
||||
- [Linux memory management](https://www.kernel.org/doc/html/latest/admin-guide/mm/)
|
||||
- [SQLite performance tips](https://www.sqlite.org/faq.html#q19)
|
||||
@@ -1,99 +1,202 @@
|
||||
# Project Plan: LLM Proxy Enhancements & Security Upgrade
|
||||
# GopherGate — Remediation Plan
|
||||
|
||||
This document outlines the roadmap for standardizing frontend security, cleaning up the codebase, upgrading session management to HMAC-signed tokens, and extending integration testing.
|
||||
|
||||
## Phase 1: Frontend Security Standardization
|
||||
**Primary Agent:** `frontend-developer`
|
||||
|
||||
- [x] Audit `static/js/pages/users.js` for manual HTML string concatenation.
|
||||
- [x] Replace custom escaping or unescaped injections with `window.api.escapeHtml`.
|
||||
- [x] Verify user list and user detail rendering for XSS vulnerabilities.
|
||||
|
||||
## Phase 2: Codebase Cleanup
|
||||
**Primary Agent:** `backend-developer`
|
||||
|
||||
- [x] Identify and remove unused imports in `src/config/mod.rs`.
|
||||
- [x] Identify and remove unused imports in `src/providers/mod.rs`.
|
||||
- [x] Run `cargo clippy` and `cargo fmt` to ensure adherence to standards.
|
||||
|
||||
## Phase 3: HMAC Architectural Upgrade
|
||||
**Primary Agents:** `fullstack-developer`, `security-auditor`, `backend-developer`
|
||||
|
||||
### 3.1 Design (Security Auditor)
|
||||
- [x] Define Token Structure: `base64(payload).signature`.
|
||||
- Payload: `{ "session_id": "...", "username": "...", "role": "...", "exp": ... }`
|
||||
- [x] Select HMAC algorithm (HMAC-SHA256).
|
||||
- [x] Define environment variable for secret key: `SESSION_SECRET`.
|
||||
|
||||
### 3.2 Implementation (Backend Developer)
|
||||
- [x] Refactor `src/dashboard/sessions.rs`:
|
||||
- Integrate `hmac` and `sha2` crates (or similar).
|
||||
- Update `create_session` to return signed tokens.
|
||||
- Update `validate_session` to verify signature before checking store.
|
||||
- [x] Implement activity-based session refresh:
|
||||
- If session is valid and >50% through its TTL, extend `expires_at` and issue new signed token.
|
||||
|
||||
### 3.3 Integration (Fullstack Developer)
|
||||
- [x] Update dashboard API handlers to handle new token format.
|
||||
- [x] Update frontend session storage/retrieval if necessary.
|
||||
|
||||
## Phase 4: Extended Integration Testing
|
||||
**Primary Agent:** `qa-automation`
|
||||
|
||||
- [ ] Setup test environment with encrypted key storage enabled.
|
||||
- [ ] Implement end-to-end flow:
|
||||
1. Store encrypted provider key via API.
|
||||
2. Authenticate through Proxy.
|
||||
3. Make proxied LLM request (verifying decryption and usage).
|
||||
- [ ] Validate HMAC token expiration and refresh logic in automated tests.
|
||||
|
||||
## Phase 5: Code Quality & Refactoring
|
||||
**Primary Agent:** `fullstack-developer`
|
||||
|
||||
- [x] Refactor dashboard monolith into modular sub-modules (`auth.rs`, `usage.rs`, etc.).
|
||||
- [x] Standardize error handling and remove `unwrap()` in production paths.
|
||||
- [x] Implement system health metrics and backup functionality.
|
||||
> 3 phases, 6 weeks total. Each phase independently shippable.
|
||||
|
||||
---
|
||||
|
||||
# Phase 6: Cache Cost & Provider Audit (ACTIVE)
|
||||
**Primary Agents:** `frontend-developer`, `backend-developer`, `database-optimizer`, `lab-assistant`
|
||||
## Phase 1 — Security & Stability (Weeks 1-2)
|
||||
|
||||
## 6.1 Dashboard UI Updates (@frontend-developer)
|
||||
- [ ] **Update Models Page Modal:** Add input fields for `Cache Read Cost` and `Cache Write Cost` in `static/js/pages/models.js`.
|
||||
- [ ] **API Integration:** Ensure `window.api.put` includes these new cost fields in the request body.
|
||||
- [ ] **Verify Costs Page:** Confirm `static/js/pages/costs.js` displays these rates correctly in the pricing table.
|
||||
**Goal:** Patch auth bypass, data races, debug leaks. No new features.
|
||||
|
||||
## 6.2 Provider Audit & Stream Fixes (@backend-developer)
|
||||
- [ ] **Standard DeepSeek Fix:** Modify `src/providers/deepseek.rs` to stop stripping `stream_options` for `deepseek-chat`.
|
||||
- [ ] **Grok Audit:** Verify if Grok correctly returns usage in streaming; it uses `build_openai_body` and doesn't seem to strip it.
|
||||
- [ ] **Gemini Audit:** Confirm Gemini returns `usage_metadata` reliably in the final chunk.
|
||||
- [ ] **Anthropic Audit:** Check if Anthropic streaming requires `include_usage` or similar flags.
|
||||
### 1.1 Fix auth bypass
|
||||
|
||||
## 6.3 Database & Migration Validation (@database-optimizer)
|
||||
- [ ] **Test Migrations:** Run the server to ensure `ALTER TABLE` logic in `src/database/mod.rs` applies the new columns correctly.
|
||||
- [ ] **Schema Verification:** Verify `model_configs` has `cache_read_cost_per_m` and `cache_write_cost_per_m` columns.
|
||||
- [ ] `middleware/auth.go`: Return 401 instead of `c.Next()` when no auth header on `/v1/*`
|
||||
- [ ] Add `requireAuth` param to `AuthMiddleware` constructor: `AuthMiddleware(db, requireAuth bool)`
|
||||
- [ ] `/v1/*` routes → `requireAuth=true`, leave `/health` unauthed
|
||||
- [ ] Add tests: curl request without token → 401
|
||||
|
||||
## 6.4 Token Estimation Refinement (@lab-assistant)
|
||||
- [ ] **Analyze Heuristic:** Review `chars / 4` in `src/utils/tokens.rs`.
|
||||
- [ ] **Background Precise Recount:** Propose a mechanism for a precise token count (using Tiktoken) after the response is finalized.
|
||||
### 1.2 Fix WebSocket origin
|
||||
|
||||
## Critical Path
|
||||
Migration Validation → UI Fields → Provider Stream Usage Reporting.
|
||||
- [ ] `websocket.go`: Replace `return true` with origin check against configured `Server.Host`
|
||||
- [ ] Config option `websocket.allowed_origins []string` (default: same origin)
|
||||
- [ ] Add `xsrf` check on WS upgrade endpoint if behind proxy
|
||||
|
||||
### 1.3 Strip debug prints
|
||||
|
||||
- [ ] `config.go`: Remove `fmt.Printf("Debug Config:...")` and `fmt.Printf("Debug Env:...")`
|
||||
- [ ] `server.go` `logRequest()`: Remove `fmt.Printf("[DEBUG] Request logged:...")`
|
||||
- [ ] `config.go`: Remove `fmt.Printf("[DEBUG] Final Ollama Config:...")`
|
||||
- [ ] `providers/ollama.go`: Remove `fmt.Printf("[Ollama]...")` debug logs or gate behind `LLM_PROXY_DEBUG=1`
|
||||
- [ ] Replace all `fmt.Printf` with structured logger (slog from stdlib)
|
||||
|
||||
### 1.4 Fix registry data race
|
||||
|
||||
- [ ] `server.go`: Add `sync.RWMutex` around `s.registry`
|
||||
- [ ] `handleListModels()`: Lock read
|
||||
- [ ] `logRequest()`: Lock read
|
||||
- [ ] Background refresh goroutines: Lock write
|
||||
- [ ] Verify with `go run -race`
|
||||
|
||||
### 1.5 Session cleanup
|
||||
|
||||
- [ ] `sessions.go`: Add periodic cleanup goroutine for expired sessions
|
||||
- [ ] Cleanup interval: every 15 minutes
|
||||
- [ ] `RevokeSession`: Return error instead of silent no-op
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Reliability & Observability (Weeks 3-4)
|
||||
|
||||
**Goal:** Error handling, timeouts, logging maturity, concurrency hardening.
|
||||
|
||||
### 2.1 Provider HTTP timeouts
|
||||
|
||||
- [ ] Each provider `New*Provider()`: Set `client.SetTimeout(30 * time.Second)` for non-stream
|
||||
- [ ] Streaming: No timeout, but add `context.Context` cancellation from request
|
||||
- [ ] `circuit_breaker.go`: Configure real thresholds
|
||||
- `MaxRequests: 5`
|
||||
- `Interval: 60 * time.Second`
|
||||
- `Timeout: 30 * time.Second`
|
||||
- `ReadyToTrip: func(counts) bool { return counts.ConsecutiveFailures > 3 }`
|
||||
- [ ] Test: Stop Ollama, hit endpoint → circuit opens after 3 failures → auto-recovers after 30s
|
||||
|
||||
### 2.2 Structured logging (slog)
|
||||
|
||||
- [ ] Create `internal/logger/logger.go` — `slog.NewJSONHandler`
|
||||
- [ ] Log levels: error/warn/info/debug
|
||||
- [ ] Replace all `fmt.Printf` in: server, providers, config, logging
|
||||
- [ ] `RequestLogger`: Use slog structured fields, remove manual JSON building
|
||||
- [ ] Log channel: increase buffer from 100 to 10000 or use batch insert every 5s
|
||||
|
||||
### 2.3 Stream error propagation
|
||||
|
||||
- [ ] `ChatCompletionStream`: Send error chunks as SSE events, not just `fmt.Printf`
|
||||
- [ ] Format: `data: {"error":"..."}\n\n`
|
||||
- [ ] Client sees full error in stream instead of silent truncation
|
||||
|
||||
### 2.4 Registry fetch retry
|
||||
|
||||
- [ ] `FetchRegistry()`: Add retry with backoff (3 tries, 1s/2s/4s)
|
||||
- [ ] Cache last-known-good registry so startup works offline
|
||||
|
||||
### 2.5 Token truncation safety
|
||||
|
||||
- [ ] `helpers.go`: Deep-copy ToolCall before truncation, don't mutate original
|
||||
- [ ] Same pattern across all providers that sanitize IDs
|
||||
|
||||
### 2.6 RevokeSession error handling
|
||||
|
||||
- [ ] `RevokeSession(token)` → `RevokeSession(token) error`
|
||||
- [ ] Update all callers to handle error
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Architecture & Maintainability (Weeks 5-6)
|
||||
|
||||
**Goal:** Code splitting, test coverage, billing integrity.
|
||||
|
||||
### 3.1 Split dashboard.go
|
||||
|
||||
- [ ] Create `internal/server/clients.go` — client CRUD handlers
|
||||
- [ ] Create `internal/server/providers.go` — provider handlers
|
||||
- [ ] Create `internal/server/users.go` — user handlers
|
||||
- [ ] Create `internal/server/analytics.go` — usage/analytics handlers
|
||||
- [ ] Create `internal/server/system.go` — health, metrics, logs, backup
|
||||
- [ ] `dashboard.go` shrinks to imports + route wiring only
|
||||
|
||||
### 3.2 Provider routing via config
|
||||
|
||||
- [ ] Replace `strings.Contains` routing table with config-driven model→provider map
|
||||
- [ ] `config.go`: Add `server.model_routing` map (e.g. `"llama-*": "ollama"`)
|
||||
- [ ] Fallback chain: explicit match → prefix match → glob match → default
|
||||
- [ ] Backward-compat: keep old prefix logic as fallback
|
||||
|
||||
### 3.3 Billing integrity
|
||||
|
||||
- [ ] `logging.go`: Add idempotency key to log entries (unique request ID)
|
||||
- [ ] Before deducting balance, check if `request_id` already processed
|
||||
- [ ] `processLog`: Wrap in retry on serialization failure (SQLite busy)
|
||||
- [ ] Credit deduction: move to separate async worker with replay protection
|
||||
|
||||
### 3.4 Add tests
|
||||
|
||||
- [ ] `internal/models/`: Unit tests for `FindModel()`, message conversion
|
||||
- [ ] `internal/providers/helpers_test.go`: Unit tests for `MessagesToOpenAIJSON`, `ParseOpenAIResponse`
|
||||
- [ ] `internal/utils/`: Tests for `Encrypt`/`Decrypt`, `CalculateCost`
|
||||
- [ ] `internal/server/`: Integration test for auth flow (token → chat completion)
|
||||
- [ ] `internal/middleware/`: Test auth bypass fix
|
||||
- [ ] Goal: ≥40% coverage on non-UI packages
|
||||
|
||||
### 3.5 go.mod hygiene
|
||||
|
||||
- [ ] `go mod tidy` (done)
|
||||
- [ ] Add `go vet ./...` to CI/pre-commit hook
|
||||
- [ ] Pin dependencies with `go mod verify`
|
||||
|
||||
---
|
||||
|
||||
## Dependency Map
|
||||
|
||||
```
|
||||
Phase 1 ──────────────────────────▶ Phase 2 ──────────────────────────▶ Phase 3
|
||||
│ │ │
|
||||
├─ 1.1 Auth bypass ──────────▶ 2.3 Stream errors (depends on auth) │
|
||||
├─ 1.2 WS origin │ │
|
||||
├─ 1.3 Debug prints │ │
|
||||
├─ 1.4 Registry race │ │
|
||||
├─ 1.5 Session cleanup │ │
|
||||
│ ├─ 2.1 HTTP timeouts │
|
||||
│ ├─ 2.2 Structured logging ───────────▶ 3.3 Billing (depends on good logs)
|
||||
│ ├─ 2.4 Registry retry │
|
||||
│ ├─ 2.5 Token truncation │
|
||||
│ ├─ 2.6 RevokeSession errors │
|
||||
│ │
|
||||
│ ├─ 3.1 Split dashboard.go
|
||||
│ ├─ 3.2 Config routing
|
||||
│ ├─ 3.4 Tests
|
||||
│ ├─ 3.5 go.mod hygiene
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mermaid Gantt
|
||||
|
||||
```mermaid
|
||||
gantt
|
||||
title Phase 6 Timeline
|
||||
title GopherGate Remediation
|
||||
dateFormat YYYY-MM-DD
|
||||
section Frontend
|
||||
Models Page UI :2026-03-06, 1d
|
||||
Costs Table Update:after Models Page UI, 1d
|
||||
section Backend
|
||||
DeepSeek Fix :2026-03-06, 1d
|
||||
Provider Audit (Grok/Gemini):after DeepSeek Fix, 2d
|
||||
section Database
|
||||
Migration Test :2026-03-06, 1d
|
||||
section Optimization
|
||||
Token Heuristic Review :2026-03-06, 1d
|
||||
axisFormat %b %d
|
||||
|
||||
section Phase 1 — Security
|
||||
Auth bypass fix :p1a, 2026-05-04, 2d
|
||||
WS origin lock :p1b, after p1a, 1d
|
||||
Strip debug prints :p1c, 2026-05-04, 2d
|
||||
Registry race fix :p1d, after p1c, 1d
|
||||
Session cleanup :p1e, after p1d, 2d
|
||||
|
||||
section Phase 2 — Reliability
|
||||
HTTP timeouts + CB :p2a, 2026-05-11, 3d
|
||||
Structured logging :p2b, 2026-05-11, 3d
|
||||
Stream error propagation :p2c, after p2a, 1d
|
||||
Registry retry :p2d, after p2b, 1d
|
||||
Token truncation fix :p2e, after p2a, 1d
|
||||
RevokeSession errors :p2f, after p2b, 1d
|
||||
|
||||
section Phase 3 — Architecture
|
||||
Split dashboard.go :p3a, 2026-05-25, 4d
|
||||
Config-driven routing :p3b, 2026-05-25, 3d
|
||||
Billing integrity :p3c, after p3a, 3d
|
||||
Add tests :p3d, 2026-06-01, 5d
|
||||
go.mod hygiene :p3e, after p3d, 1d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Immediate Next Action
|
||||
|
||||
**Start 1.1 — Fix auth bypass:**
|
||||
|
||||
- Edit `middleware/auth.go` → change `c.Next()` to `c.AbortWithStatusJSON(401, ...)` when no header
|
||||
- Add `RequireAuth` bool param
|
||||
- Update `server.go` `setupRoutes()` to pass `requireAuth=true` for `/v1/*`
|
||||
- `curl localhost:8080/v1/chat/completions -d '{}'` → 401
|
||||
|
||||
@@ -1,33 +1,52 @@
|
||||
# LLM Proxy Gateway
|
||||
# GopherGate
|
||||
|
||||
A unified, high-performance LLM proxy gateway built in Go. It provides a single OpenAI-compatible API to access multiple providers (OpenAI, Gemini, DeepSeek, Grok, Ollama) with built-in token tracking, real-time cost calculation, multi-user authentication, and a management dashboard.
|
||||
A unified, high-performance LLM proxy gateway built in Go. It provides OpenAI-compatible `/v1/chat/completions`, `/v1/images/generations`, `/v1/responses`, and `/v1/models` endpoints to access multiple providers (OpenAI, Gemini, DeepSeek, Moonshot, Grok, Ollama) with built-in token tracking, real-time cost calculation, multi-user authentication, and a management dashboard.
|
||||
|
||||
## Features
|
||||
|
||||
- **Unified API:** OpenAI-compatible `/v1/chat/completions` 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 supported for OpenAI and DeepSeek models. Non-supported providers (Gemini, Moonshot, Grok, Ollama, Xiaomi) return a "not supported" response.
|
||||
- **Multi-Provider Support:**
|
||||
- **OpenAI:** GPT-4o, GPT-4o Mini, o1, o3 reasoning models.
|
||||
- **Google Gemini:** Gemini 2.0 Flash, Pro, and vision models (with native CoT support).
|
||||
- **DeepSeek:** DeepSeek Chat and Reasoner (R1) models.
|
||||
- **xAI Grok:** Grok-beta models.
|
||||
- **OpenAI:** GPT-4o, GPT-4o Mini, GPT-5, GPT-5.4, o1/o3/o4 reasoning models, DALL-E 2/3 image generation.
|
||||
- **Google Gemini:** Gemini 2.5 Flash/Pro, Gemini 3 Flash/Pro previews, Imagen 3 image generation.
|
||||
- **DeepSeek:** DeepSeek Chat, Reasoner, V4 Flash, V4 Pro.
|
||||
- **Moonshot:** Kimi K2.5, K2.6 reasoning models.
|
||||
- **xAI Grok:** Grok-3, Grok-4, Grok-4.3 reasoning models.
|
||||
- **Xiaomi MiMo:** MiMo v2.5 models.
|
||||
- **Ollama:** Local LLMs running on your network.
|
||||
- **Observability & Tracking:**
|
||||
- **Asynchronous Logging:** Non-blocking request logging to SQLite using background workers.
|
||||
- **Token Counting:** Precise estimation and tracking of prompt, completion, and reasoning tokens.
|
||||
- **Database Persistence:** Every request logged to SQLite for historical analysis and dashboard analytics.
|
||||
- **Streaming Support:** Full SSE (Server-Sent Events) support for all providers.
|
||||
- **Streaming Support:** Full SSE (Server-Sent Events) support with `X-Accel-Buffering: no` for unbuffered, low-latency streaming.
|
||||
- **High Performance & Thread Safety:**
|
||||
- **Connection Pooling:** Shared HTTP transport with connection pooling (`MaxIdleConns: 200`), TCP keep-alives, and HTTP/2 multiplexing across all provider drivers.
|
||||
- **In-Memory Token Caching:** In-memory `sync.Map` TTL caching (10s valid, 2s negative cache) for client token authentication to eliminate SQLite bottlenecking.
|
||||
- **Thread-Safe Core:** Full RWMutex locking across provider maps, model registry lookups, and router reloads.
|
||||
- **Multimodal (Vision):** Image processing (Base64 and remote URLs) across compatible providers.
|
||||
- **Image Generation:** DALL-E 2/3 (OpenAI) and Imagen 3 (Gemini) via OpenAI-compatible `/v1/images/generations` endpoint.
|
||||
- **Automatic Model Routing:**
|
||||
- **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:**
|
||||
- **Admin Role:** Full access to all dashboard features, user management, and system configuration.
|
||||
- **Viewer Role:** Read-only access to usage analytics, costs, and monitoring.
|
||||
- **Client API Keys:** Create and manage multiple client tokens for external integrations.
|
||||
- **Reliability:**
|
||||
- **Circuit Breaking:** Automatically protects when providers are down (coming soon).
|
||||
- **Rate Limiting:** Per-client and global rate limits (coming soon).
|
||||
- **Circuit Breaking:** Protects providers when they are down, auto-recovers after timeout.
|
||||
- **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
|
||||
|
||||
LLM Proxy is designed with security in mind:
|
||||
GopherGate is designed with security in mind:
|
||||
|
||||
- **Signed Session Tokens:** Management dashboard sessions are secured using HMAC-SHA256 signed tokens.
|
||||
- **Encrypted Storage:** Support for encrypted provider API keys in the database.
|
||||
@@ -53,39 +72,57 @@ LLM Proxy is designed with security in mind:
|
||||
### Quick Start
|
||||
|
||||
1. Clone and build:
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd llm-proxy
|
||||
go build -o llm-proxy ./cmd/llm-proxy
|
||||
cd gophergate
|
||||
go build -o gophergate ./cmd/gophergate
|
||||
```
|
||||
|
||||
2. Configure environment:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env and add your configuration:
|
||||
# LLM_PROXY__ENCRYPTION_KEY=... (32-byte hex or base64 string)
|
||||
# OPENAI_API_KEY=sk-...
|
||||
# GEMINI_API_KEY=AIza...
|
||||
# DEEPSEEK_API_KEY=sk-...
|
||||
# MOONSHOT_API_KEY=...
|
||||
# GROK_API_KEY=xai-...
|
||||
# For Ollama (optional): Set base URL and enable
|
||||
# LLM_PROXY__PROVIDERS__OLLAMA__BASE_URL=http://localhost:11434/v1
|
||||
# LLM_PROXY__PROVIDERS__OLLAMA__ENABLED=true
|
||||
# LLM_PROXY__PROVIDERS__OLLAMA__MODELS=llama3,gemma2,mistral
|
||||
```
|
||||
|
||||
3. Run the proxy:
|
||||
```bash
|
||||
./llm-proxy
|
||||
./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)
|
||||
|
||||
```bash
|
||||
# Build the container
|
||||
docker build -t llm-proxy .
|
||||
docker build -t gophergate .
|
||||
|
||||
# Run the container
|
||||
docker run -p 8080:8080 \
|
||||
-e LLM_PROXY__ENCRYPTION_KEY=your-secure-key \
|
||||
-v ./data:/app/data \
|
||||
llm-proxy
|
||||
gophergate
|
||||
```
|
||||
|
||||
## Management Dashboard
|
||||
@@ -96,19 +133,29 @@ Access the dashboard at `http://localhost:8080`.
|
||||
- **Usage:** Summary stats, time-series analytics, and provider breakdown.
|
||||
- **Clients:** API key management and per-client usage tracking.
|
||||
- **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.
|
||||
- **Monitoring:** Live request stream via WebSocket.
|
||||
|
||||
### Default Credentials
|
||||
|
||||
- **Username:** `admin`
|
||||
- **Password:** `admin` (You will be prompted to change this or should change it manually in the dashboard)
|
||||
- **Password:** `admin123` (You will be prompted to change this on first login)
|
||||
|
||||
**Forgot Password?**
|
||||
You can reset the admin password to default by running:
|
||||
|
||||
```bash
|
||||
./gophergate -reset-admin
|
||||
```
|
||||
|
||||
## API Usage
|
||||
|
||||
The proxy is a drop-in replacement for OpenAI. Configure your client:
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
@@ -123,6 +170,111 @@ response = client.chat.completions.create(
|
||||
)
|
||||
```
|
||||
|
||||
### Responses API
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
base_url="http://localhost:8080/v1",
|
||||
api_key="YOUR_CLIENT_API_KEY"
|
||||
)
|
||||
|
||||
# OpenAI Responses API (supported for OpenAI models only)
|
||||
response = client.responses.create(
|
||||
model="gpt-4o",
|
||||
input="Explain quantum computing in one paragraph.",
|
||||
instructions="You are a helpful assistant.",
|
||||
temperature=0.7,
|
||||
max_output_tokens=500
|
||||
)
|
||||
print(response.output_text)
|
||||
```
|
||||
|
||||
**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)
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
base_url="http://localhost:8080/v1",
|
||||
api_key="YOUR_CLIENT_API_KEY"
|
||||
)
|
||||
|
||||
# DALL-E 3 (OpenAI)
|
||||
resp = client.images.generate(
|
||||
model="dall-e-3",
|
||||
prompt="A cute gopher wearing a top hat",
|
||||
n=1,
|
||||
size="1024x1024"
|
||||
)
|
||||
print(resp.data[0].url)
|
||||
|
||||
# Imagen 3 (Gemini) -- uses same endpoint
|
||||
resp = client.images.generate(
|
||||
model="imagen-3.0-generate-001",
|
||||
prompt="A gopher coding in Go",
|
||||
n=1,
|
||||
size="1024x1024"
|
||||
)
|
||||
print(resp.data[0].url) # Returns data URI (Gemini returns base64)
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
# LLM Proxy Security Audit Report
|
||||
|
||||
## Executive Summary
|
||||
A comprehensive security audit of the `llm-proxy` repository was conducted. The audit identified **1 critical vulnerability**, **3 high-risk issues**, **4 medium-risk issues**, and **3 low-risk issues**. The most severe findings include Cross-Site Scripting (XSS) in the dashboard interface and insecure storage of provider API keys in the database.
|
||||
|
||||
## Detailed Findings
|
||||
|
||||
### Critical Risk Vulnerabilities
|
||||
#### **CRITICAL-01: Cross-Site Scripting (XSS) in Dashboard Interface**
|
||||
- **Location**: `static/js/pages/clients.js` (multiple locations).
|
||||
- **Description**: User-controlled data (e.g., `client.id`) inserted directly into HTML or `onclick` handlers without escaping.
|
||||
- **Impact**: Arbitrary JavaScript execution in admin context, potentially stealing session tokens.
|
||||
|
||||
#### **CRITICAL-02: Insecure API Key Storage in Database**
|
||||
- **Location**: `src/database/mod.rs`, `src/providers/mod.rs`, `src/dashboard/providers.rs`.
|
||||
- **Description**: Provider API keys are stored in **plaintext** in the SQLite database.
|
||||
- **Impact**: Compromised database file exposes all provider API keys.
|
||||
|
||||
### High Risk Vulnerabilities
|
||||
#### **HIGH-01: Missing Input Validation and Size Limits**
|
||||
- **Location**: `src/server/mod.rs`, `src/models/mod.rs`.
|
||||
- **Impact**: Denial of Service via large payloads.
|
||||
|
||||
#### **HIGH-02: Sensitive Data Logging Without Encryption**
|
||||
- **Location**: `src/database/mod.rs`, `src/logging/mod.rs`.
|
||||
- **Description**: Full request and response bodies stored in `llm_requests` table without encryption or redaction.
|
||||
|
||||
#### **HIGH-03: Weak Default Credentials and Password Policy**
|
||||
- **Description**: Default admin password is 'admin' with only 4-character minimum password length.
|
||||
|
||||
### Medium Risk Vulnerabilities
|
||||
#### **MEDIUM-01: Missing CSRF Protection**
|
||||
- No CSRF tokens or SameSite cookie attributes for state-changing dashboard endpoints.
|
||||
|
||||
#### **MEDIUM-02: Insecure Session Management**
|
||||
- Session tokens stored in localStorage without HttpOnly flag.
|
||||
- Tokens use simple `session-{uuid}` format.
|
||||
|
||||
#### **MEDIUM-03: Error Information Leakage**
|
||||
- Internal error details exposed to clients in some cases.
|
||||
|
||||
#### **MEDIUM-04: Outdated Dependencies**
|
||||
- Outdated versions of `chrono`, `tokio`, and `reqwest`.
|
||||
|
||||
### Low Risk Vulnerabilities
|
||||
- Missing security headers (CSP, HSTS, X-Frame-Options).
|
||||
- Insufficient rate limiting on dashboard authentication.
|
||||
- No database encryption at rest.
|
||||
|
||||
## Recommendations
|
||||
### Immediate Actions
|
||||
1. **Fix XSS Vulnerabilities:** Implement proper HTML escaping for all user-controlled data.
|
||||
2. **Secure API Key Storage:** Encrypt API keys in database using a library like `ring`.
|
||||
3. **Implement Input Validation:** Add maximum payload size limits (e.g., 10MB).
|
||||
4. **Improve Data Protection:** Add option to disable request/response body logging.
|
||||
|
||||
---
|
||||
*Report generated by Security Auditor Agent on March 6, 2026*
|
||||
@@ -2,28 +2,54 @@
|
||||
|
||||
## Completed Tasks
|
||||
- [x] Initial Go project setup
|
||||
- [x] Database schema & migrations
|
||||
- [x] Database schema & migrations (hardcoded in `db.go`)
|
||||
- [x] Configuration loader (Viper)
|
||||
- [x] Auth Middleware
|
||||
- [x] Basic Provider implementations (OpenAI, Gemini, DeepSeek, Grok)
|
||||
- [x] Auth Middleware (scoped to `/v1`)
|
||||
- [x] Basic Provider implementations (OpenAI, Gemini, DeepSeek, Grok, Ollama)
|
||||
- [x] Streaming Support (SSE & Gemini custom streaming)
|
||||
- [x] Archive Rust files to `rust` branch
|
||||
- [x] Clean root and set Go version as `main`
|
||||
- [x] Enhanced `helpers.go` for Multimodal & Tool Calling (OpenAI compatible)
|
||||
- [x] Enhanced `server.go` for robust request conversion
|
||||
- [x] Dashboard Management APIs (Clients, Tokens, Users, Providers)
|
||||
- [x] Dashboard Analytics & Usage Summary
|
||||
- [x] WebSocket for real-time dashboard updates
|
||||
- [x] Dashboard Analytics & Usage Summary (Fixed SQL robustness)
|
||||
- [x] WebSocket for real-time dashboard updates (Hub with client counting)
|
||||
- [x] Asynchronous Request Logging to SQLite
|
||||
- [x] Update documentation (README, deployment, architecture)
|
||||
- [x] Cost Tracking accuracy (Registry integration with `models.dev`)
|
||||
- [x] Model Listing endpoint (`/v1/models`) with provider filtering
|
||||
- [x] System Metrics endpoint (`/api/system/metrics` using `gopsutil`)
|
||||
- [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
|
||||
|
||||
## Feature Parity Checklist (High Priority)
|
||||
## Planned Resolutions (High Priority)
|
||||
|
||||
### Security Fixes
|
||||
- [x] **Critical:** Fix `AuthMiddleware` to reject invalid tokens instead of falling back to insecure prefix derivation.
|
||||
|
||||
### Feature Parity Checklist (High Priority)
|
||||
|
||||
### OpenAI Provider
|
||||
- [x] Tool Calling
|
||||
- [x] Multimodal (Images) support
|
||||
- [ ] Reasoning Content (CoT) support for `o1`, `o3` (need to ensure it's parsed in responses)
|
||||
- [ ] Support for `/v1/responses` API (required for some gpt-5/o1 models)
|
||||
- [x] Accurate usage parsing (cached & reasoning tokens)
|
||||
### Feature Parity: OpenAI Provider Enhancements
|
||||
- [x] **Reasoning Content (CoT) Support (`o1`/`o3`):**
|
||||
- [x] Infrastructure verified. `reasoning_content` is mapped in request/response structures.
|
||||
- [x] **Support for `/v1/responses` API:**
|
||||
- [x] Implemented new route in `internal/server/server.go`.
|
||||
|
||||
### Gemini Provider
|
||||
- [x] Tool Calling (mapping to Gemini format)
|
||||
@@ -35,15 +61,22 @@
|
||||
- [x] Reasoning Content (CoT) support
|
||||
- [x] Parameter sanitization for `deepseek-reasoner`
|
||||
- [x] Tool Calling support
|
||||
- [x] Accurate usage parsing (cache hits & reasoning)
|
||||
|
||||
### Grok Provider
|
||||
- [x] Tool Calling support
|
||||
- [x] Multimodal support
|
||||
- [x] Accurate usage parsing (via OpenAI helper)
|
||||
|
||||
### Ollama Provider
|
||||
- [x] OpenAI-compatible API integration
|
||||
- [x] Streaming support
|
||||
- [x] Model pattern detection for routing
|
||||
- [x] Zero cost calculation (local/free models)
|
||||
|
||||
## Infrastructure & Middleware
|
||||
- [ ] Implement Rate Limiting (`golang.org/x/time/rate`)
|
||||
- [ ] Implement Circuit Breaker (`github.com/sony/gobreaker`)
|
||||
- [ ] Implement Model Cost Calculation logic (needs registry/pricing integration)
|
||||
- [x] Implement Circuit Breaker (`github.com/sony/gobreaker`)
|
||||
|
||||
## Verification
|
||||
- [ ] Unit tests for feature-specific mapping (CoT, Tools, Images)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"gophergate/internal/config"
|
||||
"gophergate/internal/db"
|
||||
"gophergate/internal/server"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func main() {
|
||||
resetAdmin := flag.Bool("reset-admin", false, "Reset admin password to admin123")
|
||||
flag.Parse()
|
||||
|
||||
// Load environment variables
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Println("No .env file found")
|
||||
}
|
||||
|
||||
// Load configuration
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load configuration: %v", err)
|
||||
}
|
||||
|
||||
// Initialize database
|
||||
database, err := db.Init(cfg.Database.Path)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize database: %v", err)
|
||||
}
|
||||
|
||||
if *resetAdmin {
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), 12)
|
||||
_, err = database.Exec("UPDATE users SET password_hash = ?, must_change_password = 1 WHERE username = 'admin'", string(hash))
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to reset admin password: %v", err)
|
||||
}
|
||||
log.Println("Admin password has been reset to 'admin123'")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Initialize server
|
||||
s := server.NewServer(cfg, database)
|
||||
|
||||
// Run server
|
||||
log.Printf("Starting GopherGate on %s:%d", cfg.Server.Host, cfg.Server.Port)
|
||||
if err := s.Run(); err != nil {
|
||||
log.Fatalf("Server failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"llm-proxy/internal/config"
|
||||
"llm-proxy/internal/db"
|
||||
"llm-proxy/internal/server"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load environment variables
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Println("No .env file found")
|
||||
}
|
||||
|
||||
// Load configuration
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load configuration: %v", err)
|
||||
}
|
||||
|
||||
// Initialize database
|
||||
database, err := db.Init(cfg.Database.Path)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize database: %v", err)
|
||||
}
|
||||
|
||||
// Initialize server
|
||||
s := server.NewServer(cfg, database)
|
||||
|
||||
// Run server
|
||||
log.Printf("Starting LLM Proxy on %s:%d", cfg.Server.Host, cfg.Server.Port)
|
||||
if err := s.Run(); err != nil {
|
||||
log.Fatalf("Server failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type MyNullTime struct {
|
||||
Time interface{}
|
||||
Type string
|
||||
}
|
||||
|
||||
func (n *MyNullTime) Scan(value interface{}) error {
|
||||
n.Time = value
|
||||
n.Type = fmt.Sprintf("%T", value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
db, err := sqlx.Connect("sqlite", "/home/newkirk/Documents/projects/web_projects/gophergate/data/backups/llm_proxy.db.20260303T205057Z")
|
||||
if err != nil {
|
||||
fmt.Println("connect err:", err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Test 1: Direct column scan type
|
||||
var d MyNullTime
|
||||
db.Get(&d, "SELECT last_used_at FROM client_tokens WHERE client_id = ? LIMIT 1", "sk-opencode")
|
||||
fmt.Printf("direct SELECT: GoType=%s value=%v\n", d.Type, d.Time)
|
||||
|
||||
// Test 2: MAX aggregate scan type
|
||||
var m MyNullTime
|
||||
db.Get(&m, "SELECT MAX(last_used_at) FROM client_tokens WHERE client_id = ?", "sk-opencode")
|
||||
fmt.Printf("MAX SELECT: GoType=%s value=%v\n", m.Type, m.Time)
|
||||
|
||||
// Test 3: peek at the raw driver types
|
||||
row := db.QueryRow("SELECT last_used_at, MAX(last_used_at) FROM client_tokens WHERE client_id = ? LIMIT 1", "sk-opencode")
|
||||
var a, b interface{}
|
||||
row.Scan(&a, &b)
|
||||
fmt.Printf("\nRaw Scan:\n")
|
||||
fmt.Printf(" last_used_at: type=%T val=%v\n", a, a)
|
||||
fmt.Printf(" MAX(last_used_at): type=%T val=%v\n", b, b)
|
||||
}
|
||||
+142
@@ -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).
|
||||
Binary file not shown.
@@ -1,667 +1,25 @@
|
||||
#!/bin/bash
|
||||
|
||||
# LLM Proxy Gateway Deployment Script
|
||||
# This script automates the deployment of the LLM Proxy Gateway on a Linux server
|
||||
set -e
|
||||
|
||||
set -e # Exit on error
|
||||
set -u # Exit on undefined variable
|
||||
BINARY_NAME="gophergate"
|
||||
SOURCE_PATH="./cmd/gophergate/main.go"
|
||||
|
||||
# Configuration
|
||||
APP_NAME="llm-proxy"
|
||||
APP_USER="llmproxy"
|
||||
APP_GROUP="llmproxy"
|
||||
GIT_REPO="ssh://git.dustin.coffee:2222/hobokenchicken/llm-proxy.git"
|
||||
INSTALL_DIR="/opt/$APP_NAME"
|
||||
CONFIG_DIR="/etc/$APP_NAME"
|
||||
DATA_DIR="/var/lib/$APP_NAME"
|
||||
LOG_DIR="/var/log/$APP_NAME"
|
||||
SERVICE_FILE="/etc/systemd/system/$APP_NAME.service"
|
||||
ENV_FILE="$CONFIG_DIR/.env"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Logging functions
|
||||
log_info() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# Check if running as root
|
||||
check_root() {
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
log_error "This script must be run as root"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Install system dependencies
|
||||
install_dependencies() {
|
||||
log_info "Installing system dependencies..."
|
||||
|
||||
# Detect package manager
|
||||
if command -v apt-get &> /dev/null; then
|
||||
# Debian/Ubuntu
|
||||
apt-get update
|
||||
apt-get install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
sqlite3 \
|
||||
curl \
|
||||
git
|
||||
elif command -v yum &> /dev/null; then
|
||||
# RHEL/CentOS
|
||||
yum groupinstall -y "Development Tools"
|
||||
yum install -y \
|
||||
openssl-devel \
|
||||
sqlite \
|
||||
curl \
|
||||
git
|
||||
elif command -v dnf &> /dev/null; then
|
||||
# Fedora
|
||||
dnf groupinstall -y "Development Tools"
|
||||
dnf install -y \
|
||||
openssl-devel \
|
||||
sqlite \
|
||||
curl \
|
||||
git
|
||||
elif command -v pacman &> /dev/null; then
|
||||
# Arch Linux
|
||||
pacman -Syu --noconfirm \
|
||||
base-devel \
|
||||
openssl \
|
||||
sqlite \
|
||||
curl \
|
||||
git
|
||||
else
|
||||
log_warn "Could not detect package manager. Please install dependencies manually."
|
||||
fi
|
||||
}
|
||||
|
||||
# Install Rust if not present
|
||||
install_rust() {
|
||||
log_info "Checking for Rust installation..."
|
||||
|
||||
if ! command -v rustc &> /dev/null; then
|
||||
log_info "Installing Rust..."
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
source "$HOME/.cargo/env"
|
||||
else
|
||||
log_info "Rust is already installed"
|
||||
fi
|
||||
|
||||
# Verify installation
|
||||
rustc --version
|
||||
cargo --version
|
||||
}
|
||||
|
||||
# Create system user and directories
|
||||
setup_directories() {
|
||||
log_info "Creating system user and directories..."
|
||||
|
||||
# Create user and group if they don't exist
|
||||
if ! id "$APP_USER" &>/dev/null; then
|
||||
# Arch uses /usr/bin/nologin, Debian/Ubuntu use /usr/sbin/nologin
|
||||
NOLOGIN=$(command -v nologin 2>/dev/null || echo "/usr/bin/nologin")
|
||||
useradd -r -s "$NOLOGIN" -M "$APP_USER"
|
||||
fi
|
||||
|
||||
# Create directories
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
mkdir -p "$DATA_DIR"
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
# Set permissions
|
||||
chown -R "$APP_USER:$APP_GROUP" "$INSTALL_DIR"
|
||||
chown -R "$APP_USER:$APP_GROUP" "$CONFIG_DIR"
|
||||
chown -R "$APP_USER:$APP_GROUP" "$DATA_DIR"
|
||||
chown -R "$APP_USER:$APP_GROUP" "$LOG_DIR"
|
||||
|
||||
chmod 750 "$INSTALL_DIR"
|
||||
chmod 750 "$CONFIG_DIR"
|
||||
chmod 750 "$DATA_DIR"
|
||||
chmod 750 "$LOG_DIR"
|
||||
}
|
||||
|
||||
# Build the application
|
||||
build_application() {
|
||||
log_info "Building the application..."
|
||||
|
||||
# Clone or update repository
|
||||
if [[ ! -d "$INSTALL_DIR/.git" ]]; then
|
||||
log_info "Cloning repository..."
|
||||
git clone "$GIT_REPO" "$INSTALL_DIR"
|
||||
else
|
||||
log_info "Updating repository..."
|
||||
cd "$INSTALL_DIR"
|
||||
git pull
|
||||
fi
|
||||
|
||||
# Build in release mode
|
||||
cd "$INSTALL_DIR"
|
||||
log_info "Building release binary..."
|
||||
cargo build --release
|
||||
|
||||
# Verify build
|
||||
if [[ -f "target/release/$APP_NAME" ]]; then
|
||||
log_info "Build successful"
|
||||
else
|
||||
log_error "Build failed"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Create configuration files
|
||||
create_configuration() {
|
||||
log_info "Creating configuration files..."
|
||||
|
||||
# Create .env file with API keys
|
||||
cat > "$ENV_FILE" << EOF
|
||||
# LLM Proxy Gateway Environment Variables
|
||||
# Add your API keys here
|
||||
|
||||
# OpenAI API Key
|
||||
# OPENAI_API_KEY=sk-your-key-here
|
||||
|
||||
# Google Gemini API Key
|
||||
# GEMINI_API_KEY=AIza-your-key-here
|
||||
|
||||
# DeepSeek API Key
|
||||
# DEEPSEEK_API_KEY=sk-your-key-here
|
||||
|
||||
# xAI Grok API Key
|
||||
# GROK_API_KEY=gk-your-key-here
|
||||
|
||||
# Authentication tokens (comma-separated)
|
||||
# LLM_PROXY__SERVER__AUTH_TOKENS=token1,token2,token3
|
||||
EOF
|
||||
|
||||
# Create config.toml
|
||||
cat > "$CONFIG_DIR/config.toml" << EOF
|
||||
# LLM Proxy Gateway Configuration
|
||||
|
||||
[server]
|
||||
port = 8080
|
||||
host = "0.0.0.0"
|
||||
# auth_tokens = ["token1", "token2", "token3"] # Uncomment to enable authentication
|
||||
|
||||
[database]
|
||||
path = "$DATA_DIR/llm_proxy.db"
|
||||
max_connections = 5
|
||||
|
||||
[providers.openai]
|
||||
enabled = true
|
||||
api_key_env = "OPENAI_API_KEY"
|
||||
base_url = "https://api.openai.com/v1"
|
||||
default_model = "gpt-4o"
|
||||
|
||||
[providers.gemini]
|
||||
enabled = true
|
||||
api_key_env = "GEMINI_API_KEY"
|
||||
base_url = "https://generativelanguage.googleapis.com/v1"
|
||||
default_model = "gemini-2.0-flash"
|
||||
|
||||
[providers.deepseek]
|
||||
enabled = true
|
||||
api_key_env = "DEEPSEEK_API_KEY"
|
||||
base_url = "https://api.deepseek.com"
|
||||
default_model = "deepseek-reasoner"
|
||||
|
||||
[providers.grok]
|
||||
enabled = false # Disabled by default until API is researched
|
||||
api_key_env = "GROK_API_KEY"
|
||||
base_url = "https://api.x.ai/v1"
|
||||
default_model = "grok-beta"
|
||||
|
||||
[model_mapping]
|
||||
"gpt-*" = "openai"
|
||||
"gemini-*" = "gemini"
|
||||
"deepseek-*" = "deepseek"
|
||||
"grok-*" = "grok"
|
||||
|
||||
[pricing]
|
||||
openai = { input = 0.01, output = 0.03 }
|
||||
gemini = { input = 0.0005, output = 0.0015 }
|
||||
deepseek = { input = 0.00014, output = 0.00028 }
|
||||
grok = { input = 0.001, output = 0.003 }
|
||||
EOF
|
||||
|
||||
# Set permissions
|
||||
chown "$APP_USER:$APP_GROUP" "$ENV_FILE"
|
||||
chown "$APP_USER:$APP_GROUP" "$CONFIG_DIR/config.toml"
|
||||
chmod 640 "$ENV_FILE"
|
||||
chmod 640 "$CONFIG_DIR/config.toml"
|
||||
}
|
||||
|
||||
# Create systemd service
|
||||
create_systemd_service() {
|
||||
log_info "Creating systemd service..."
|
||||
|
||||
cat > "$SERVICE_FILE" << EOF
|
||||
[Unit]
|
||||
Description=LLM Proxy Gateway
|
||||
Documentation=https://git.dustin.coffee/hobokenchicken/llm-proxy
|
||||
After=network.target
|
||||
Wants=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$APP_USER
|
||||
Group=$APP_GROUP
|
||||
WorkingDirectory=$INSTALL_DIR
|
||||
EnvironmentFile=$ENV_FILE
|
||||
Environment="RUST_LOG=info"
|
||||
Environment="LLM_PROXY__CONFIG_PATH=$CONFIG_DIR/config.toml"
|
||||
Environment="LLM_PROXY__DATABASE__PATH=$DATA_DIR/llm_proxy.db"
|
||||
ExecStart=$INSTALL_DIR/target/release/$APP_NAME
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=$DATA_DIR $LOG_DIR
|
||||
|
||||
# Resource limits (adjust based on your server)
|
||||
MemoryMax=400M
|
||||
MemorySwapMax=100M
|
||||
CPUQuota=50%
|
||||
LimitNOFILE=65536
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=$APP_NAME
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# Reload systemd
|
||||
systemctl daemon-reload
|
||||
}
|
||||
|
||||
# Setup nginx reverse proxy (optional)
|
||||
setup_nginx_proxy() {
|
||||
if ! command -v nginx &> /dev/null; then
|
||||
log_warn "nginx not installed. Skipping reverse proxy setup."
|
||||
return
|
||||
fi
|
||||
|
||||
log_info "Setting up nginx reverse proxy..."
|
||||
|
||||
cat > "/etc/nginx/sites-available/$APP_NAME" << EOF
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com; # Change to your domain
|
||||
|
||||
# Redirect to HTTPS (recommended)
|
||||
return 301 https://\$server_name\$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name your-domain.com; # Change to your domain
|
||||
|
||||
# SSL certificates (adjust paths)
|
||||
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
||||
|
||||
# SSL configuration
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
# Proxy to LLM Proxy Gateway
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
proxy_pass http://127.0.0.1:8080/health;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# Dashboard
|
||||
location /dashboard {
|
||||
proxy_pass http://127.0.0.1:8080/dashboard;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Enable site
|
||||
ln -sf "/etc/nginx/sites-available/$APP_NAME" "/etc/nginx/sites-enabled/"
|
||||
|
||||
# Test nginx configuration
|
||||
nginx -t
|
||||
|
||||
log_info "nginx configuration created. Please update the domain and SSL certificate paths."
|
||||
}
|
||||
|
||||
# Setup firewall
|
||||
setup_firewall() {
|
||||
log_info "Configuring firewall..."
|
||||
|
||||
# Check for ufw (Ubuntu)
|
||||
if command -v ufw &> /dev/null; then
|
||||
ufw allow 22/tcp # SSH
|
||||
ufw allow 80/tcp # HTTP
|
||||
ufw allow 443/tcp # HTTPS
|
||||
ufw --force enable
|
||||
log_info "UFW firewall configured"
|
||||
fi
|
||||
|
||||
# Check for firewalld (RHEL/CentOS)
|
||||
if command -v firewall-cmd &> /dev/null; then
|
||||
firewall-cmd --permanent --add-service=ssh
|
||||
firewall-cmd --permanent --add-service=http
|
||||
firewall-cmd --permanent --add-service=https
|
||||
firewall-cmd --reload
|
||||
log_info "Firewalld configured"
|
||||
fi
|
||||
}
|
||||
|
||||
# Initialize database
|
||||
initialize_database() {
|
||||
log_info "Initializing database..."
|
||||
|
||||
# Run the application once to create database
|
||||
sudo -u "$APP_USER" "$INSTALL_DIR/target/release/$APP_NAME" --help &> /dev/null || true
|
||||
|
||||
log_info "Database initialized at $DATA_DIR/llm_proxy.db"
|
||||
}
|
||||
|
||||
# Start and enable service
|
||||
start_service() {
|
||||
log_info "Starting $APP_NAME service..."
|
||||
|
||||
systemctl enable "$APP_NAME"
|
||||
systemctl start "$APP_NAME"
|
||||
|
||||
# Check status
|
||||
sleep 2
|
||||
systemctl status "$APP_NAME" --no-pager
|
||||
}
|
||||
|
||||
# Verify installation
|
||||
verify_installation() {
|
||||
log_info "Verifying installation..."
|
||||
|
||||
# Check if service is running
|
||||
if systemctl is-active --quiet "$APP_NAME"; then
|
||||
log_info "Service is running"
|
||||
else
|
||||
log_error "Service is not running"
|
||||
journalctl -u "$APP_NAME" -n 20 --no-pager
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test health endpoint
|
||||
if curl -s http://localhost:8080/health | grep -q "OK"; then
|
||||
log_info "Health check passed"
|
||||
else
|
||||
log_error "Health check failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test dashboard
|
||||
if curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/dashboard | grep -q "200"; then
|
||||
log_info "Dashboard is accessible"
|
||||
else
|
||||
log_warn "Dashboard may not be accessible (this is normal if not configured)"
|
||||
fi
|
||||
|
||||
log_info "Installation verified successfully!"
|
||||
}
|
||||
|
||||
# Print next steps
|
||||
print_next_steps() {
|
||||
cat << EOF
|
||||
|
||||
${GREEN}=== LLM Proxy Gateway Installation Complete ===${NC}
|
||||
|
||||
${YELLOW}Next steps:${NC}
|
||||
|
||||
1. ${GREEN}Configure API keys${NC}
|
||||
Edit: $ENV_FILE
|
||||
Add your API keys for the providers you want to use
|
||||
|
||||
2. ${GREEN}Configure authentication${NC}
|
||||
Edit: $CONFIG_DIR/config.toml
|
||||
Uncomment and set auth_tokens for client authentication
|
||||
|
||||
3. ${GREEN}Configure nginx${NC}
|
||||
Edit: /etc/nginx/sites-available/$APP_NAME
|
||||
Update domain name and SSL certificate paths
|
||||
|
||||
4. ${GREEN}Test the API${NC}
|
||||
curl -X POST http://localhost:8080/v1/chat/completions \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer your-token" \\
|
||||
-d '{
|
||||
"model": "gpt-4o",
|
||||
"messages": [{"role": "user", "content": "Hello!"}]
|
||||
}'
|
||||
|
||||
5. ${GREEN}Access the dashboard${NC}
|
||||
Open: http://your-server-ip:8080/dashboard
|
||||
Or: https://your-domain.com/dashboard (if nginx configured)
|
||||
|
||||
${YELLOW}Useful commands:${NC}
|
||||
systemctl status $APP_NAME # Check service status
|
||||
journalctl -u $APP_NAME -f # View logs
|
||||
systemctl restart $APP_NAME # Restart service
|
||||
|
||||
${YELLOW}Configuration files:${NC}
|
||||
Service: $SERVICE_FILE
|
||||
Config: $CONFIG_DIR/config.toml
|
||||
Environment: $ENV_FILE
|
||||
Database: $DATA_DIR/llm_proxy.db
|
||||
Logs: $LOG_DIR/
|
||||
|
||||
${GREEN}For more information, see:${NC}
|
||||
https://git.dustin.coffee/hobokenchicken/llm-proxy
|
||||
$INSTALL_DIR/README.md
|
||||
$INSTALL_DIR/deployment.md
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# Main deployment function
|
||||
deploy() {
|
||||
log_info "Starting LLM Proxy Gateway deployment..."
|
||||
|
||||
check_root
|
||||
install_dependencies
|
||||
install_rust
|
||||
setup_directories
|
||||
build_application
|
||||
create_configuration
|
||||
create_systemd_service
|
||||
initialize_database
|
||||
start_service
|
||||
verify_installation
|
||||
print_next_steps
|
||||
|
||||
# Optional steps (uncomment if needed)
|
||||
# setup_nginx_proxy
|
||||
# setup_firewall
|
||||
|
||||
log_info "Deployment completed successfully!"
|
||||
}
|
||||
|
||||
# Update function
|
||||
update() {
|
||||
log_info "Updating LLM Proxy Gateway..."
|
||||
|
||||
check_root
|
||||
|
||||
# Pull latest changes (while service keeps running)
|
||||
cd "$INSTALL_DIR"
|
||||
log_info "Pulling latest changes..."
|
||||
echo "Pulling latest changes from git..."
|
||||
git stash || true
|
||||
git pull
|
||||
|
||||
# Build new binary (service stays up on the old binary)
|
||||
log_info "Building release binary (service still running)..."
|
||||
if ! cargo build --release; then
|
||||
log_error "Build failed — service was NOT interrupted. Fix the error and try again."
|
||||
exit 1
|
||||
fi
|
||||
echo "Building the application..."
|
||||
go build -o "$BINARY_NAME" "$SOURCE_PATH"
|
||||
|
||||
# Verify binary exists
|
||||
if [[ ! -f "target/release/$APP_NAME" ]]; then
|
||||
log_error "Binary not found after build — aborting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Restart service to pick up new binary
|
||||
log_info "Build succeeded. Restarting service..."
|
||||
systemctl restart "$APP_NAME"
|
||||
echo "Restarting service..."
|
||||
systemctl restart gophergate
|
||||
|
||||
sleep 2
|
||||
if systemctl is-active --quiet "$APP_NAME"; then
|
||||
log_info "Update completed successfully!"
|
||||
systemctl status "$APP_NAME" --no-pager
|
||||
if systemctl is-active --quiet gophergate; then
|
||||
echo "Deploy complete. Service is running."
|
||||
systemctl status gophergate --no-pager | head -5
|
||||
else
|
||||
log_error "Service failed to start after update. Check logs:"
|
||||
journalctl -u "$APP_NAME" -n 20 --no-pager
|
||||
echo "Service failed to start! Check: journalctl -u gophergate -n 20"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Uninstall function
|
||||
uninstall() {
|
||||
log_info "Uninstalling LLM Proxy Gateway..."
|
||||
|
||||
check_root
|
||||
|
||||
# Stop and disable service
|
||||
systemctl stop "$APP_NAME" 2>/dev/null || true
|
||||
systemctl disable "$APP_NAME" 2>/dev/null || true
|
||||
rm -f "$SERVICE_FILE"
|
||||
systemctl daemon-reload
|
||||
|
||||
# Remove application files
|
||||
rm -rf "$INSTALL_DIR"
|
||||
rm -rf "$CONFIG_DIR"
|
||||
|
||||
# Keep data and logs (comment out to remove)
|
||||
log_warn "Data directory $DATA_DIR and logs $LOG_DIR have been preserved"
|
||||
log_warn "Remove manually if desired:"
|
||||
log_warn " rm -rf $DATA_DIR $LOG_DIR"
|
||||
|
||||
# Remove user (optional)
|
||||
read -p "Remove user $APP_USER? [y/N]: " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
userdel "$APP_USER" 2>/dev/null || true
|
||||
groupdel "$APP_GROUP" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
log_info "Uninstallation completed!"
|
||||
}
|
||||
|
||||
# Show usage
|
||||
usage() {
|
||||
cat << EOF
|
||||
LLM Proxy Gateway Deployment Script
|
||||
|
||||
Usage: $0 [command]
|
||||
|
||||
Commands:
|
||||
deploy - Install and configure LLM Proxy Gateway
|
||||
update - Pull latest changes, rebuild, and restart
|
||||
status - Show service status and health check
|
||||
logs - Tail the service logs (Ctrl+C to stop)
|
||||
uninstall - Remove LLM Proxy Gateway
|
||||
help - Show this help message
|
||||
|
||||
Examples:
|
||||
$0 deploy # Full installation
|
||||
$0 update # Update to latest version
|
||||
$0 status # Check if service is healthy
|
||||
$0 logs # Follow live logs
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# Status function
|
||||
status() {
|
||||
echo ""
|
||||
log_info "Service status:"
|
||||
systemctl status "$APP_NAME" --no-pager 2>/dev/null || log_warn "Service not found"
|
||||
echo ""
|
||||
|
||||
# Health check
|
||||
if curl -sf http://localhost:8080/health &>/dev/null; then
|
||||
log_info "Health check: OK"
|
||||
else
|
||||
log_warn "Health check: FAILED (service may not be running or port 8080 not responding)"
|
||||
fi
|
||||
|
||||
# Show current git commit
|
||||
if [[ -d "$INSTALL_DIR/.git" ]]; then
|
||||
echo ""
|
||||
log_info "Installed version:"
|
||||
git -C "$INSTALL_DIR" log -1 --format=" %h %s (%cr)" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
# Logs function
|
||||
logs() {
|
||||
log_info "Tailing $APP_NAME logs (Ctrl+C to stop)..."
|
||||
journalctl -u "$APP_NAME" -f
|
||||
}
|
||||
|
||||
# Parse command line arguments
|
||||
case "${1:-}" in
|
||||
deploy)
|
||||
deploy
|
||||
;;
|
||||
update)
|
||||
update
|
||||
;;
|
||||
status)
|
||||
status
|
||||
;;
|
||||
logs)
|
||||
logs
|
||||
;;
|
||||
uninstall)
|
||||
uninstall
|
||||
;;
|
||||
help|--help|-h)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
+23
-6
@@ -1,6 +1,6 @@
|
||||
# Deployment Guide (Go)
|
||||
|
||||
This guide covers deploying the Go-based LLM Proxy Gateway.
|
||||
This guide covers deploying the Go-based GopherGate.
|
||||
|
||||
## Environment Setup
|
||||
|
||||
@@ -18,31 +18,47 @@ This guide covers deploying the Go-based LLM Proxy Gateway.
|
||||
|
||||
### 1. Build
|
||||
```bash
|
||||
go build -o llm-proxy ./cmd/llm-proxy
|
||||
go build -o gophergate ./cmd/gophergate
|
||||
```
|
||||
|
||||
### 2. Run
|
||||
```bash
|
||||
./llm-proxy
|
||||
./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
|
||||
|
||||
The project includes a multi-stage `Dockerfile` for minimal image size.
|
||||
|
||||
### 1. Build Image
|
||||
```bash
|
||||
docker build -t llm-proxy .
|
||||
docker build -t gophergate .
|
||||
```
|
||||
|
||||
### 2. Run Container
|
||||
```bash
|
||||
docker run -d \
|
||||
--name llm-proxy \
|
||||
--name gophergate \
|
||||
-p 8080:8080 \
|
||||
-v $(pwd)/data:/app/data \
|
||||
--env-file .env \
|
||||
llm-proxy
|
||||
gophergate
|
||||
```
|
||||
|
||||
## Production Considerations
|
||||
@@ -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.
|
||||
- **Backups:** Regularly backup the `data/llm_proxy.db` file.
|
||||
- **Monitoring:** Monitor the `/health` endpoint for system status.
|
||||
- **Logs:** When started with `deploy.sh` or `nohup`, logs are written to `gophergate.log`.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module llm-proxy
|
||||
module gophergate
|
||||
|
||||
go 1.26.1
|
||||
|
||||
@@ -9,6 +9,8 @@ require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/shirou/gopsutil/v3 v3.24.5
|
||||
github.com/sony/gobreaker v1.0.0
|
||||
github.com/spf13/viper v1.21.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
modernc.org/sqlite v1.47.0
|
||||
@@ -23,6 +25,7 @@ require (
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
@@ -32,22 +35,28 @@ require (
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
|
||||
@@ -23,6 +23,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -41,6 +43,7 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -68,6 +71,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
@@ -83,6 +88,8 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
@@ -93,6 +100,14 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
||||
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
||||
github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ=
|
||||
github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
@@ -116,10 +131,16 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
@@ -136,7 +157,11 @@ golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
@@ -145,6 +170,7 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -22,6 +22,8 @@ type ServerConfig struct {
|
||||
Port int `mapstructure:"port"`
|
||||
Host string `mapstructure:"host"`
|
||||
AuthTokens []string `mapstructure:"auth_tokens"`
|
||||
WSAllowedOrigin string `mapstructure:"ws_allowed_origin"`
|
||||
MaxHistoryMessages int `mapstructure:"max_history_messages"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
@@ -33,8 +35,10 @@ type ProviderConfig struct {
|
||||
OpenAI OpenAIConfig `mapstructure:"openai"`
|
||||
Gemini GeminiConfig `mapstructure:"gemini"`
|
||||
DeepSeek DeepSeekConfig `mapstructure:"deepseek"`
|
||||
Moonshot MoonshotConfig `mapstructure:"moonshot"`
|
||||
Grok GrokConfig `mapstructure:"grok"`
|
||||
Ollama OllamaConfig `mapstructure:"ollama"`
|
||||
Xiaomi XiaomiConfig `mapstructure:"xiaomi"`
|
||||
}
|
||||
|
||||
type OpenAIConfig struct {
|
||||
@@ -58,6 +62,13 @@ type DeepSeekConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
}
|
||||
|
||||
type MoonshotConfig struct {
|
||||
APIKeyEnv string `mapstructure:"api_key_env"`
|
||||
BaseURL string `mapstructure:"base_url"`
|
||||
DefaultModel string `mapstructure:"default_model"`
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
}
|
||||
|
||||
type GrokConfig struct {
|
||||
APIKeyEnv string `mapstructure:"api_key_env"`
|
||||
BaseURL string `mapstructure:"base_url"`
|
||||
@@ -72,6 +83,13 @@ type OllamaConfig struct {
|
||||
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) {
|
||||
v := viper.New()
|
||||
|
||||
@@ -79,6 +97,7 @@ func Load() (*Config, error) {
|
||||
v.SetDefault("server.port", 8080)
|
||||
v.SetDefault("server.host", "0.0.0.0")
|
||||
v.SetDefault("server.auth_tokens", []string{})
|
||||
v.SetDefault("server.max_history_messages", 0)
|
||||
v.SetDefault("database.path", "./data/llm_proxy.db")
|
||||
v.SetDefault("database.max_connections", 10)
|
||||
|
||||
@@ -89,7 +108,7 @@ func Load() (*Config, error) {
|
||||
|
||||
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.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.deepseek.api_key_env", "DEEPSEEK_API_KEY")
|
||||
@@ -97,20 +116,39 @@ func Load() (*Config, error) {
|
||||
v.SetDefault("providers.deepseek.default_model", "deepseek-reasoner")
|
||||
v.SetDefault("providers.deepseek.enabled", true)
|
||||
|
||||
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.default_model", "kimi-k2.7-code")
|
||||
v.SetDefault("providers.moonshot.enabled", true)
|
||||
|
||||
v.SetDefault("providers.grok.api_key_env", "GROK_API_KEY")
|
||||
v.SetDefault("providers.grok.base_url", "https://api.x.ai/v1")
|
||||
v.SetDefault("providers.grok.default_model", "grok-beta")
|
||||
v.SetDefault("providers.grok.default_model", "grok-4-1-fast-non-reasoning")
|
||||
v.SetDefault("providers.grok.enabled", true)
|
||||
|
||||
v.SetDefault("providers.ollama.base_url", "http://localhost:11434/v1")
|
||||
v.SetDefault("providers.ollama.enabled", false)
|
||||
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
|
||||
v.SetEnvPrefix("LLM_PROXY")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "__"))
|
||||
v.AutomaticEnv()
|
||||
|
||||
// Explicitly bind keys that might use double underscores in .env
|
||||
v.BindEnv("encryption_key", "LLM_PROXY__ENCRYPTION_KEY")
|
||||
v.BindEnv("server.port", "LLM_PROXY__SERVER__PORT")
|
||||
v.BindEnv("server.host", "LLM_PROXY__SERVER__HOST")
|
||||
v.BindEnv("server.max_history_messages", "LLM_PROXY__SERVER__MAX_HISTORY_MESSAGES")
|
||||
v.BindEnv("providers.ollama.enabled", "LLM_PROXY__PROVIDERS__OLLAMA__ENABLED")
|
||||
v.BindEnv("providers.ollama.base_url", "LLM_PROXY__PROVIDERS__OLLAMA__BASE_URL")
|
||||
v.BindEnv("providers.ollama.models", "LLM_PROXY__PROVIDERS__OLLAMA__MODELS")
|
||||
|
||||
// Config file
|
||||
v.SetConfigName("config")
|
||||
v.SetConfigType("toml")
|
||||
@@ -130,6 +168,30 @@ func Load() (*Config, error) {
|
||||
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
// Manual overrides for nested keys which Viper doesn't always bind correctly with AutomaticEnv + SetEnvPrefix
|
||||
if port := os.Getenv("LLM_PROXY__SERVER__PORT"); port != "" {
|
||||
fmt.Sscanf(port, "%d", &cfg.Server.Port)
|
||||
|
||||
}
|
||||
if host := os.Getenv("LLM_PROXY__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
|
||||
if enabled := os.Getenv("LLM_PROXY__PROVIDERS__OLLAMA__ENABLED"); enabled != "" {
|
||||
cfg.Providers.Ollama.Enabled = enabled == "true"
|
||||
}
|
||||
if baseURL := os.Getenv("LLM_PROXY__PROVIDERS__OLLAMA__BASE_URL"); baseURL != "" {
|
||||
cfg.Providers.Ollama.BaseURL = baseURL
|
||||
}
|
||||
if models := os.Getenv("LLM_PROXY__PROVIDERS__OLLAMA__MODELS"); models != "" {
|
||||
cfg.Providers.Ollama.Models = strings.Split(models, ",")
|
||||
}
|
||||
|
||||
// Validate encryption key
|
||||
if cfg.EncryptionKey == "" {
|
||||
return nil, fmt.Errorf("encryption key is required (LLM_PROXY__ENCRYPTION_KEY)")
|
||||
@@ -160,8 +222,15 @@ func (c *Config) GetAPIKey(provider string) (string, error) {
|
||||
envVar = c.Providers.Gemini.APIKeyEnv
|
||||
case "deepseek":
|
||||
envVar = c.Providers.DeepSeek.APIKeyEnv
|
||||
case "moonshot":
|
||||
envVar = c.Providers.Moonshot.APIKeyEnv
|
||||
case "grok":
|
||||
envVar = c.Providers.Grok.APIKeyEnv
|
||||
case "ollama":
|
||||
// Ollama doesn't require an API key
|
||||
return "", nil
|
||||
case "xiaomi":
|
||||
envVar = c.Providers.Xiaomi.APIKeyEnv
|
||||
default:
|
||||
return "", fmt.Errorf("unknown provider: %s", provider)
|
||||
}
|
||||
@@ -170,5 +239,5 @@ func (c *Config) GetAPIKey(provider string) (string, error) {
|
||||
if val == "" {
|
||||
return "", fmt.Errorf("environment variable %s not set for %s", envVar, provider)
|
||||
}
|
||||
return val, nil
|
||||
return strings.TrimSpace(val), nil
|
||||
}
|
||||
|
||||
+129
-10
@@ -26,12 +26,20 @@ func Init(path string) (*DB, error) {
|
||||
}
|
||||
|
||||
// Connect to SQLite
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=foreign_keys(1)", path)
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=foreign_keys(1)&_busy_timeout=5000", path)
|
||||
db, err := sqlx.Connect("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
// 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}
|
||||
|
||||
// Run migrations
|
||||
@@ -122,6 +130,18 @@ func (db *DB) RunMigrations() error {
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at DATETIME,
|
||||
FOREIGN KEY (client_id) REFERENCES clients(client_id) ON DELETE CASCADE
|
||||
)`,
|
||||
`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,
|
||||
logic_level INTEGER,
|
||||
primary_use TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
}
|
||||
|
||||
@@ -152,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
|
||||
var count int
|
||||
if err := db.Get(&count, "SELECT COUNT(*) FROM users"); err != nil {
|
||||
@@ -159,7 +183,7 @@ func (db *DB) RunMigrations() error {
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("admin"), 12)
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("admin123"), 12)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash default password: %w", err)
|
||||
}
|
||||
@@ -167,7 +191,7 @@ func (db *DB) RunMigrations() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert default admin: %w", err)
|
||||
}
|
||||
log.Println("Created default admin user with password 'admin' (must change on first login)")
|
||||
log.Println("Created default admin user with password 'admin123' (must change on first login)")
|
||||
}
|
||||
|
||||
// Default client
|
||||
@@ -177,6 +201,76 @@ func (db *DB) RunMigrations() error {
|
||||
return fmt.Errorf("failed to insert default client: %w", err)
|
||||
}
|
||||
|
||||
// Seed default model groups
|
||||
defaultGroups := []struct {
|
||||
id, strategy, targets, selectorModel, heuristicRules string
|
||||
complexityThreshold, logicLevel *int
|
||||
primaryUse *string
|
||||
}{
|
||||
{"deepseek-auto", "heuristic", `["deepseek-chat","deepseek-reasoner"]`, "", "", nil, nil, nil},
|
||||
{"openai-auto", "heuristic", `["gpt-4o-mini","gpt-4o"]`, "", "", nil, nil, nil},
|
||||
{"gemini-auto", "heuristic", `["gemini-3.5-flash-lite","gemini-3.1-flash-lite","gemini-2.5-flash"]`, "", "", nil, nil, nil},
|
||||
{"heavy-logic", "heuristic", `["grok-4.3","kimi-k2.6","deepseek-v4-pro"]`, "", "", nil, intPtr(9), strPtr("Complex Coding, Logic, Agents.")},
|
||||
{"standard-pro", "heuristic", `["gpt-5.4-mini","gemini-3.5-flash-lite"]`, "", "", nil, intPtr(5), strPtr("General Assistant, Long Docs.")},
|
||||
{"fast-flow", "heuristic", `["deepseek-v4-flash","gpt-5.4-nano"]`, "", "", nil, intPtr(2), strPtr("Classification, JSON, Basic Q&A.")},
|
||||
{"dispatcher", "classifier", `["fast-flow","standard-pro","heavy-logic"]`, "gpt-5.4-nano", "", intPtr(10), nil, strPtr("Auto-dispatches to tier groups by complexity.")},
|
||||
{"dustins_stack", "heuristic", `["mimo-v2.5","deepseek-v4-pro","grok-4.3","mimo-v2.5-pro","deepseek-v4-flash","kimi-k2.6"]`, "", `[
|
||||
{
|
||||
"rule_id": "multimodal_tier",
|
||||
"description": "Multimodal input routes to high-throughput multimodal models.",
|
||||
"conditions": { "has_multimodal_input": true },
|
||||
"primary_model": "mimo-v2.5",
|
||||
"fallback_model": "mimo-v2.5-pro"
|
||||
},
|
||||
{
|
||||
"rule_id": "ultra_long_context",
|
||||
"description": "Massive context/document processing (>128k tokens) routes to long-context specialists.",
|
||||
"conditions": { "min_input_tokens": 128000 },
|
||||
"primary_model": "kimi-k2.6",
|
||||
"fallback_model": "deepseek-v4-pro"
|
||||
},
|
||||
{
|
||||
"rule_id": "agentic_code_and_tools",
|
||||
"description": "Tool-heavy agent loops (MCP, repo editing, SWE) to MiMo Pro.",
|
||||
"conditions": { "requires_tool_calling": true, "any_of_tags": ["swe-bench", "tool-heavy"] },
|
||||
"primary_model": "mimo-v2.5-pro",
|
||||
"fallback_model": "deepseek-v4-pro"
|
||||
},
|
||||
{
|
||||
"rule_id": "reasoning_heavy",
|
||||
"description": "Deep reasoning, architecture, system design, and math.",
|
||||
"conditions": { "requires_reasoning": true },
|
||||
"primary_model": "deepseek-v4-pro",
|
||||
"fallback_model": "grok-4.3"
|
||||
},
|
||||
{
|
||||
"rule_id": "realtime_and_creative",
|
||||
"description": "Real-time web search, open-ended synthesis, or high-creativity tasks.",
|
||||
"conditions": { "any_of_tags": ["realtime-search", "creative", "synthesis"] },
|
||||
"primary_model": "grok-4.3",
|
||||
"fallback_model": "mimo-v2.5-pro"
|
||||
},
|
||||
{
|
||||
"rule_id": "fast_flow_tier",
|
||||
"description": "Short simple text, no reasoning, no tools.",
|
||||
"conditions": { "max_input_tokens_lt": 16000, "requires_reasoning": false, "requires_tool_calling": false },
|
||||
"primary_model": "deepseek-v4-flash",
|
||||
"fallback_model": "mimo-v2.5"
|
||||
},
|
||||
{
|
||||
"rule_id": "regional_fallback_general",
|
||||
"description": "Catch-all default rule.",
|
||||
"conditions": { "is_default_fallback": true },
|
||||
"primary_model": "deepseek-v4-pro",
|
||||
"fallback_model": "deepseek-v4-flash"
|
||||
}
|
||||
]`, nil, nil, strPtr("Dustin's personal agent stack. No Gemini.")},
|
||||
}
|
||||
for _, g := range defaultGroups {
|
||||
db.Exec(`INSERT OR IGNORE INTO model_groups (id, strategy, targets, selector_model, heuristic_rules, complexity_threshold, logic_level, primary_use) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
g.id, g.strategy, g.targets, nilStr(g.selectorModel), nilStr(g.heuristicRules), g.complexityThreshold, g.logicLevel, g.primaryUse)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -215,6 +309,7 @@ type LLMRequest struct {
|
||||
ResponseBody *string `db:"response_body"`
|
||||
CacheReadTokens int `db:"cache_read_tokens"`
|
||||
CacheWriteTokens int `db:"cache_write_tokens"`
|
||||
ModelGroup string `db:"model_group"`
|
||||
}
|
||||
|
||||
type ProviderConfig struct {
|
||||
@@ -244,13 +339,13 @@ type ModelConfig struct {
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int `db:"id"`
|
||||
Username string `db:"username"`
|
||||
PasswordHash string `db:"password_hash"`
|
||||
DisplayName *string `db:"display_name"`
|
||||
Role string `db:"role"`
|
||||
MustChangePassword bool `db:"must_change_password"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
ID int `db:"id" json:"id"`
|
||||
Username string `db:"username" json:"username"`
|
||||
PasswordHash string `db:"password_hash" json:"-"`
|
||||
DisplayName *string `db:"display_name" json:"display_name"`
|
||||
Role string `db:"role" json:"role"`
|
||||
MustChangePassword bool `db:"must_change_password" json:"must_change_password"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
type ClientToken struct {
|
||||
@@ -262,3 +357,27 @@ type ClientToken struct {
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
LastUsedAt *time.Time `db:"last_used_at"`
|
||||
}
|
||||
|
||||
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"`
|
||||
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"`
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var level = slog.LevelInfo
|
||||
|
||||
func init() {
|
||||
env := os.Getenv("LLM_PROXY_LOG_LEVEL")
|
||||
switch strings.ToLower(env) {
|
||||
case "debug":
|
||||
level = slog.LevelDebug
|
||||
case "warn":
|
||||
level = slog.LevelWarn
|
||||
case "error":
|
||||
level = slog.LevelError
|
||||
}
|
||||
|
||||
h := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: level,
|
||||
})
|
||||
slog.SetDefault(slog.New(h))
|
||||
}
|
||||
|
||||
// Warn is a helper to emit structured warnings.
|
||||
func Warn(msg string, args ...any) {
|
||||
slog.Warn(msg, args...)
|
||||
}
|
||||
|
||||
// Error is a helper to emit structured errors.
|
||||
func Error(msg string, args ...any) {
|
||||
slog.Error(msg, args...)
|
||||
}
|
||||
|
||||
// Debug is a helper to emit structured debug messages.
|
||||
func Debug(msg string, args ...any) {
|
||||
slog.Debug(msg, args...)
|
||||
}
|
||||
|
||||
// Ctx wraps slog with context.
|
||||
func Ctx(ctx context.Context) *slog.Logger {
|
||||
return slog.Default()
|
||||
}
|
||||
+96
-15
@@ -2,51 +2,132 @@ package middleware
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"llm-proxy/internal/db"
|
||||
"llm-proxy/internal/models"
|
||||
"gophergate/internal/db"
|
||||
"gophergate/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AuthMiddleware(database *db.DB) gin.HandlerFunc {
|
||||
type tokenCacheEntry struct {
|
||||
clientID string
|
||||
expiredAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
tokenCache sync.Map // map[string]tokenCacheEntry
|
||||
)
|
||||
|
||||
func AuthMiddleware(database *db.DB, requireAuth bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
// Fallback to checking "Authentication" header in case the client library used the wrong name
|
||||
authHeader = c.GetHeader("Authentication")
|
||||
}
|
||||
|
||||
if authHeader == "" {
|
||||
if requireAuth {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": gin.H{
|
||||
"message": "Missing Authorization or Authentication header.",
|
||||
"type": "invalid_request_error",
|
||||
"param": nil,
|
||||
"code": "401",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
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()
|
||||
return
|
||||
}
|
||||
|
||||
// Try to resolve client from database
|
||||
// Try to resolve client from cache first
|
||||
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 {
|
||||
c.Set("auth", models.AuthInfo{
|
||||
Token: token,
|
||||
ClientID: clientID,
|
||||
// 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 {
|
||||
// Fallback to token-prefix derivation (matches Rust behavior)
|
||||
prefixLen := len(token)
|
||||
if prefixLen > 8 {
|
||||
prefixLen = 8
|
||||
// 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),
|
||||
})
|
||||
}
|
||||
clientID = "client_" + token[:prefixLen]
|
||||
}
|
||||
|
||||
if clientID != "" {
|
||||
c.Set("auth", models.AuthInfo{
|
||||
Token: token,
|
||||
ClientID: clientID,
|
||||
})
|
||||
log.Printf("Token not found in DB, using fallback client ID: %s", 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()
|
||||
} else {
|
||||
maskedToken := "••••"
|
||||
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",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ type ChatMessage struct {
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
ToolCallID *string `json:"tool_call_id,omitempty"`
|
||||
Prefix *bool `json:"prefix,omitempty"`
|
||||
}
|
||||
|
||||
type ContentPart struct {
|
||||
@@ -116,6 +117,7 @@ type ChatCompletionStreamResponse struct {
|
||||
Model string `json:"model"`
|
||||
Choices []ChatStreamChoice `json:"choices"`
|
||||
Usage *Usage `json:"usage,omitempty"`
|
||||
Error *string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type ChatStreamChoice struct {
|
||||
@@ -167,6 +169,7 @@ type UnifiedMessage struct {
|
||||
ToolCalls []ToolCall
|
||||
Name *string
|
||||
ToolCallID *string
|
||||
Prefix *bool
|
||||
}
|
||||
|
||||
type UnifiedContentPart struct {
|
||||
@@ -209,6 +212,30 @@ func (i *ImageInput) ToBase64() (string, string, error) {
|
||||
return "", "", fmt.Errorf("empty image input")
|
||||
}
|
||||
|
||||
// Image Generation (DALL-E, Imagen)
|
||||
|
||||
type ImageGenerationRequest struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
N *uint32 `json:"n,omitempty"`
|
||||
Quality *string `json:"quality,omitempty"`
|
||||
ResponseFormat *string `json:"response_format,omitempty"`
|
||||
Size *string `json:"size,omitempty"`
|
||||
Style *string `json:"style,omitempty"`
|
||||
User *string `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
type ImageGenerationResponse struct {
|
||||
Created int64 `json:"created"`
|
||||
Data []ImageData `json:"data"`
|
||||
}
|
||||
|
||||
type ImageData struct {
|
||||
URL string `json:"url,omitempty"`
|
||||
B64JSON string `json:"b64_json,omitempty"`
|
||||
RevisedPrompt string `json:"revised_prompt,omitempty"`
|
||||
}
|
||||
|
||||
// AuthInfo for context
|
||||
type AuthInfo struct {
|
||||
Token string
|
||||
|
||||
+173
-10
@@ -1,6 +1,31 @@
|
||||
package models
|
||||
|
||||
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 {
|
||||
mu sync.RWMutex
|
||||
Providers map[string]ProviderInfo `json:"-"`
|
||||
}
|
||||
|
||||
@@ -37,21 +62,159 @@ type ModelModalities struct {
|
||||
Output []string `json:"output"`
|
||||
}
|
||||
|
||||
func (r *ModelRegistry) FindModel(modelID string) *ModelMetadata {
|
||||
// First try exact match in models map
|
||||
for _, provider := range r.Providers {
|
||||
if model, ok := provider.Models[modelID]; ok {
|
||||
return &model
|
||||
// findInCanonical searches the canonical providers in order for an exact model
|
||||
// key match. Returns the metadata and true if found.
|
||||
func (r *ModelRegistry) findInCanonical(modelID string) (*ModelMetadata, bool) {
|
||||
for _, key := range CanonicalProviders {
|
||||
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
|
||||
for _, provider := range r.Providers {
|
||||
for _, model := range provider.Models {
|
||||
if model.ID == modelID {
|
||||
return &model
|
||||
// findInAll searches all providers (map iteration, random order) for an exact
|
||||
// model key match. Used as fallback when canonical search fails.
|
||||
func (r *ModelRegistry) findInAll(modelID string) (*ModelMetadata, bool) {
|
||||
for _, p := range r.Providers {
|
||||
if m, ok := p.Models[modelID]; ok {
|
||||
return &m, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// findInCanonicalByID searches canonical providers for a model whose metadata
|
||||
// ID field matches modelID.
|
||||
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) {
|
||||
return &m, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// findAllReverseFuzzy searches all providers for any model whose key starts
|
||||
// with modelID.
|
||||
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) {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestModelRegistry_FindModel_Exact(t *testing.T) {
|
||||
r := &ModelRegistry{
|
||||
Providers: map[string]ProviderInfo{
|
||||
"openai": {
|
||||
Models: map[string]ModelMetadata{
|
||||
"gpt-4o": {ID: "gpt-4o", Name: "GPT-4o"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
m := r.FindModel("gpt-4o")
|
||||
if m == nil {
|
||||
t.Fatal("expected to find gpt-4o")
|
||||
}
|
||||
if m.Name != "GPT-4o" {
|
||||
t.Fatalf("expected GPT-4o, got %s", m.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelRegistry_FindModel_Fuzzy(t *testing.T) {
|
||||
r := &ModelRegistry{
|
||||
Providers: map[string]ProviderInfo{
|
||||
"openai": {
|
||||
Models: map[string]ModelMetadata{
|
||||
"gpt-4o": {ID: "gpt-4o", Name: "GPT-4o"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
// Fuzzy: "gpt-4o-2024-05-13" should match "gpt-4o"
|
||||
m := r.FindModel("gpt-4o-2024-05-13")
|
||||
if m == nil {
|
||||
t.Fatal("expected fuzzy match")
|
||||
}
|
||||
if m.Name != "GPT-4o" {
|
||||
t.Fatalf("expected GPT-4o, got %s", m.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelRegistry_FindModel_NotFound(t *testing.T) {
|
||||
r := &ModelRegistry{
|
||||
Providers: map[string]ProviderInfo{
|
||||
"openai": {
|
||||
Models: map[string]ModelMetadata{
|
||||
"gpt-4o": {ID: "gpt-4o", Name: "GPT-4o"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
m := r.FindModel("nonexistent-model")
|
||||
if m != nil {
|
||||
t.Fatal("expected nil for nonexistent model")
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
r := &ModelRegistry{
|
||||
Providers: map[string]ProviderInfo{
|
||||
"openai": {
|
||||
Models: map[string]ModelMetadata{
|
||||
"gpt-5.4-mini-2026-04-01": {ID: "gpt-5.4-mini-2026-04-01", Name: "GPT-5.4 Mini"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
// Reverse fuzzy: "gpt-5.4-mini" should match "gpt-5.4-mini-2026-04-01"
|
||||
m := r.FindModel("gpt-5.4-mini")
|
||||
if m == nil {
|
||||
t.Fatal("expected reverse fuzzy match")
|
||||
}
|
||||
if m.Name != "GPT-5.4 Mini" {
|
||||
t.Fatalf("expected GPT-5.4 Mini, got %s", m.Name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package models
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Responses API request types
|
||||
|
||||
// ResponsesRequest maps to POST /v1/responses body (OpenAI Responses API format).
|
||||
// The `input` field can be a string or an array of message objects.
|
||||
type ResponsesRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input json.RawMessage `json:"input"` // string or []ResponseInputMessage
|
||||
Instructions string `json:"instructions,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
MaxOutputTokens *uint32 `json:"max_output_tokens,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
Stream *bool `json:"stream,omitempty"`
|
||||
Tools json.RawMessage `json:"tools,omitempty"`
|
||||
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
|
||||
Store *bool `json:"store,omitempty"`
|
||||
}
|
||||
|
||||
// ResponseInputMessage represents a single message in the input array.
|
||||
type ResponseInputMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content json.RawMessage `json:"content"` // string or []ContentPart
|
||||
}
|
||||
|
||||
// Responses API response types
|
||||
|
||||
// ResponsesResponse maps to OpenAI /v1/responses response.
|
||||
type ResponsesResponse struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Model string `json:"model"`
|
||||
Output []ResponsesOutputItem `json:"output"`
|
||||
Usage *ResponsesUsage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
// ResponsesOutputItem represents an item in the output array.
|
||||
// For messages: type="message", role, content[].
|
||||
// For function calls: type="function_call", id, name, arguments, status.
|
||||
type ResponsesOutputItem struct {
|
||||
Type string `json:"type"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Content []ResponsesOutputContent `json:"content,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Arguments string `json:"arguments,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// ResponsesOutputContent represents content parts within an output message.
|
||||
type ResponsesOutputContent struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Annotations []json.RawMessage `json:"annotations,omitempty"`
|
||||
}
|
||||
|
||||
// ResponsesUsage maps to the usage block in Responses API.
|
||||
type ResponsesUsage struct {
|
||||
InputTokens uint32 `json:"input_tokens"`
|
||||
OutputTokens uint32 `json:"output_tokens"`
|
||||
TotalTokens uint32 `json:"total_tokens"`
|
||||
InputTokensDetails *ResponsesInputTokensDetails `json:"input_tokens_details,omitempty"`
|
||||
OutputTokensDetails *ResponsesOutputTokensDetails `json:"output_tokens_details,omitempty"`
|
||||
}
|
||||
|
||||
// ResponsesInputTokensDetails maps input token details.
|
||||
type ResponsesInputTokensDetails struct {
|
||||
CachedTokens uint32 `json:"cached_tokens"`
|
||||
}
|
||||
|
||||
// ResponsesOutputTokensDetails maps output token details.
|
||||
type ResponsesOutputTokensDetails struct {
|
||||
ReasoningTokens uint32 `json:"reasoning_tokens"`
|
||||
}
|
||||
|
||||
// ToUsage converts ResponsesUsage to the unified Usage model.
|
||||
func (u *ResponsesUsage) ToUsage() *Usage {
|
||||
usage := &Usage{
|
||||
PromptTokens: u.InputTokens,
|
||||
CompletionTokens: u.OutputTokens,
|
||||
TotalTokens: u.TotalTokens,
|
||||
}
|
||||
if u.InputTokensDetails != nil && u.InputTokensDetails.CachedTokens > 0 {
|
||||
usage.CacheReadTokens = &u.InputTokensDetails.CachedTokens
|
||||
}
|
||||
if u.OutputTokensDetails != nil && u.OutputTokensDetails.ReasoningTokens > 0 {
|
||||
usage.ReasoningTokens = &u.OutputTokensDetails.ReasoningTokens
|
||||
}
|
||||
return usage
|
||||
}
|
||||
|
||||
// ResponsesStreamChunk represents an SSE chunk from the Responses streaming endpoint.
|
||||
type ResponsesStreamChunk struct {
|
||||
Type string `json:"type"`
|
||||
Response *ResponsesStreamPayload `json:"response,omitempty"`
|
||||
Item *ResponsesStreamPayloadItem `json:"item,omitempty"`
|
||||
Delta *ResponsesStreamDelta `json:"delta,omitempty"`
|
||||
}
|
||||
|
||||
// ResponsesStreamPayload represents the "response" field in some SSE chunks.
|
||||
type ResponsesStreamPayload struct {
|
||||
Object string `json:"object"`
|
||||
ID string `json:"id"`
|
||||
Model string `json:"model"`
|
||||
Usage *ResponsesUsage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
// ResponsesStreamPayloadItem represents the "item" field in SSE chunks.
|
||||
type ResponsesStreamPayloadItem struct {
|
||||
Type string `json:"type"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Content []ResponsesOutputContent `json:"content,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// ResponsesStreamDelta represents a content delta in streaming.
|
||||
type ResponsesStreamDelta struct {
|
||||
ContentIndex int `json:"content_index"`
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// UnifiedResponsesRequest is the internal unified format for Responses API.
|
||||
type UnifiedResponsesRequest struct {
|
||||
ClientID string
|
||||
Model string
|
||||
Input string // normalized input text
|
||||
InputMessages []ResponseInputMessage // structured input messages (if provided as array)
|
||||
Instructions string
|
||||
Temperature *float64
|
||||
MaxOutputTokens *uint32
|
||||
TopP *float64
|
||||
Stream bool
|
||||
Tools json.RawMessage
|
||||
ToolChoice json.RawMessage
|
||||
Store bool
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/sony/gobreaker"
|
||||
"gophergate/internal/models"
|
||||
)
|
||||
|
||||
type CircuitBreakerProvider struct {
|
||||
provider Provider
|
||||
cb *gobreaker.CircuitBreaker
|
||||
}
|
||||
|
||||
func NewCircuitBreakerProvider(p Provider) Provider {
|
||||
name := p.Name()
|
||||
var maxRequests uint32 = 5
|
||||
var interval = 60 * time.Second
|
||||
var timeout = 5 * time.Minute
|
||||
|
||||
settings := gobreaker.Settings{
|
||||
Name: name,
|
||||
MaxRequests: maxRequests,
|
||||
Interval: interval,
|
||||
Timeout: timeout,
|
||||
ReadyToTrip: func(counts gobreaker.Counts) bool {
|
||||
// Trip after 3 consecutive failures
|
||||
return counts.ConsecutiveFailures > 3
|
||||
},
|
||||
}
|
||||
return &CircuitBreakerProvider{
|
||||
provider: p,
|
||||
cb: gobreaker.NewCircuitBreaker(settings),
|
||||
}
|
||||
}
|
||||
|
||||
func (cbp *CircuitBreakerProvider) Name() string {
|
||||
return cbp.provider.Name()
|
||||
}
|
||||
|
||||
func (cbp *CircuitBreakerProvider) ChatCompletion(ctx context.Context, req *models.UnifiedRequest) (*models.ChatCompletionResponse, error) {
|
||||
result, err := cbp.cb.Execute(func() (interface{}, error) {
|
||||
return cbp.provider.ChatCompletion(ctx, req)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.(*models.ChatCompletionResponse), nil
|
||||
}
|
||||
|
||||
func (cbp *CircuitBreakerProvider) ChatCompletionStream(ctx context.Context, req *models.UnifiedRequest) (<-chan *models.ChatCompletionStreamResponse, error) {
|
||||
result, err := cbp.cb.Execute(func() (interface{}, error) {
|
||||
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) {
|
||||
result, err := cbp.cb.Execute(func() (interface{}, error) {
|
||||
return cbp.provider.ImageGeneration(ctx, req)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.(*models.ImageGenerationResponse), nil
|
||||
}
|
||||
|
||||
func (cbp *CircuitBreakerProvider) Responses(ctx context.Context, req *models.ResponsesRequest) (*models.ResponsesResponse, error) {
|
||||
result, err := cbp.cb.Execute(func() (interface{}, error) {
|
||||
return cbp.provider.Responses(ctx, req)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.(*models.ResponsesResponse), nil
|
||||
}
|
||||
|
||||
func (cbp *CircuitBreakerProvider) ResponsesStream(ctx context.Context, req *models.ResponsesRequest) (<-chan *models.ResponsesStreamChunk, error) {
|
||||
result, err := cbp.cb.Execute(func() (interface{}, error) {
|
||||
return cbp.provider.ResponsesStream(ctx, req)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.(<-chan *models.ResponsesStreamChunk), nil
|
||||
}
|
||||
+196
-13
@@ -1,13 +1,17 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"llm-proxy/internal/config"
|
||||
"llm-proxy/internal/models"
|
||||
"github.com/go-resty/resty/v2"
|
||||
"gophergate/internal/config"
|
||||
"gophergate/internal/models"
|
||||
)
|
||||
|
||||
type DeepSeekProvider struct {
|
||||
@@ -18,7 +22,7 @@ type DeepSeekProvider struct {
|
||||
|
||||
func NewDeepSeekProvider(cfg config.DeepSeekConfig, apiKey string) *DeepSeekProvider {
|
||||
return &DeepSeekProvider{
|
||||
client: resty.New(),
|
||||
client: NewOptimizedRestyClient(10 * time.Minute),
|
||||
config: cfg,
|
||||
apiKey: apiKey,
|
||||
}
|
||||
@@ -28,7 +32,39 @@ func (p *DeepSeekProvider) Name() string {
|
||||
return "deepseek"
|
||||
}
|
||||
|
||||
type deepSeekUsage struct {
|
||||
PromptTokens uint32 `json:"prompt_tokens"`
|
||||
CompletionTokens uint32 `json:"completion_tokens"`
|
||||
TotalTokens uint32 `json:"total_tokens"`
|
||||
PromptCacheHitTokens uint32 `json:"prompt_cache_hit_tokens"`
|
||||
PromptCacheMissTokens uint32 `json:"prompt_cache_miss_tokens"`
|
||||
CompletionTokensDetails *struct {
|
||||
ReasoningTokens uint32 `json:"reasoning_tokens"`
|
||||
} `json:"completion_tokens_details"`
|
||||
}
|
||||
|
||||
func (u *deepSeekUsage) ToUnified() *models.Usage {
|
||||
usage := &models.Usage{
|
||||
PromptTokens: u.PromptTokens,
|
||||
CompletionTokens: u.CompletionTokens,
|
||||
TotalTokens: u.TotalTokens,
|
||||
}
|
||||
if u.PromptCacheHitTokens > 0 {
|
||||
usage.CacheReadTokens = &u.PromptCacheHitTokens
|
||||
}
|
||||
if u.PromptCacheMissTokens > 0 {
|
||||
usage.CacheWriteTokens = &u.PromptCacheMissTokens
|
||||
}
|
||||
if u.CompletionTokensDetails != nil && u.CompletionTokensDetails.ReasoningTokens > 0 {
|
||||
usage.ReasoningTokens = &u.CompletionTokensDetails.ReasoningTokens
|
||||
}
|
||||
return usage
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert messages: %w", err)
|
||||
@@ -36,18 +72,24 @@ func (p *DeepSeekProvider) ChatCompletion(ctx context.Context, req *models.Unifi
|
||||
|
||||
body := BuildOpenAIBody(req, messagesJSON, false)
|
||||
|
||||
// Sanitize for deepseek-reasoner
|
||||
if req.Model == "deepseek-reasoner" {
|
||||
// Sanitize for models that support reasoning/thinking mode
|
||||
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, "top_p")
|
||||
delete(body, "presence_penalty")
|
||||
delete(body, "frequency_penalty")
|
||||
}
|
||||
|
||||
// Ensure assistant messages have content and reasoning_content
|
||||
if msgs, ok := body["messages"].([]interface{}); ok {
|
||||
for _, m := range msgs {
|
||||
if msg, ok := m.(map[string]interface{}); ok {
|
||||
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 {
|
||||
msg["reasoning_content"] = ""
|
||||
}
|
||||
@@ -71,7 +113,12 @@ func (p *DeepSeekProvider) ChatCompletion(ctx context.Context, req *models.Unifi
|
||||
}
|
||||
|
||||
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{}
|
||||
@@ -79,10 +126,26 @@ func (p *DeepSeekProvider) ChatCompletion(ctx context.Context, req *models.Unifi
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
return ParseOpenAIResponse(respJSON, req.Model)
|
||||
result, err := ParseOpenAIResponse(respJSON, req.Model)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Fix usage for DeepSeek specifically if details were missing in ParseOpenAIResponse
|
||||
if usageData, ok := respJSON["usage"]; ok {
|
||||
var dUsage deepSeekUsage
|
||||
usageBytes, _ := json.Marshal(usageData)
|
||||
if err := json.Unmarshal(usageBytes, &dUsage); err == nil {
|
||||
result.Usage = dUsage.ToUnified()
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *DeepSeekProvider) ChatCompletionStream(ctx context.Context, req *models.UnifiedRequest) (<-chan *models.ChatCompletionStreamResponse, error) {
|
||||
ensureEnglish(req)
|
||||
|
||||
messagesJSON, err := MessagesToOpenAIJSON(req.Messages)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert messages: %w", err)
|
||||
@@ -90,18 +153,24 @@ func (p *DeepSeekProvider) ChatCompletionStream(ctx context.Context, req *models
|
||||
|
||||
body := BuildOpenAIBody(req, messagesJSON, true)
|
||||
|
||||
// Sanitize for deepseek-reasoner
|
||||
if req.Model == "deepseek-reasoner" {
|
||||
// Sanitize for models that support reasoning/thinking mode
|
||||
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, "top_p")
|
||||
delete(body, "presence_penalty")
|
||||
delete(body, "frequency_penalty")
|
||||
}
|
||||
|
||||
// Ensure assistant messages have content and reasoning_content
|
||||
if msgs, ok := body["messages"].([]interface{}); ok {
|
||||
for _, m := range msgs {
|
||||
if msg, ok := m.(map[string]interface{}); ok {
|
||||
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 {
|
||||
msg["reasoning_content"] = ""
|
||||
}
|
||||
@@ -126,14 +195,23 @@ func (p *DeepSeekProvider) ChatCompletionStream(ctx context.Context, req *models
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
err := StreamOpenAI(resp.RawBody(), ch)
|
||||
// Custom scanner loop to handle DeepSeek specific usage in chunks
|
||||
err := StreamDeepSeek(resp.RawBody(), ch)
|
||||
if err != nil {
|
||||
fmt.Printf("DeepSeek Stream error: %v\n", err)
|
||||
}
|
||||
@@ -141,3 +219,108 @@ func (p *DeepSeekProvider) ChatCompletionStream(ctx context.Context, req *models
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func StreamDeepSeek(ctx io.ReadCloser, ch chan<- *models.ChatCompletionStreamResponse) error {
|
||||
defer ctx.Close()
|
||||
scanner := bufio.NewScanner(ctx)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" || !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
if data == "[DONE]" {
|
||||
break
|
||||
}
|
||||
|
||||
var chunk models.ChatCompletionStreamResponse
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Fix DeepSeek specific usage in stream
|
||||
var rawChunk struct {
|
||||
Usage *deepSeekUsage `json:"usage"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &rawChunk); err == nil && rawChunk.Usage != nil {
|
||||
chunk.Usage = rawChunk.Usage.ToUnified()
|
||||
}
|
||||
|
||||
ch <- &chunk
|
||||
}
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
func (p *DeepSeekProvider) ImageGeneration(ctx context.Context, req *models.ImageGenerationRequest) (*models.ImageGenerationResponse, error) {
|
||||
return nil, fmt.Errorf("deepseek does not support image generation")
|
||||
}
|
||||
|
||||
func (p *DeepSeekProvider) Responses(ctx context.Context, req *models.ResponsesRequest) (*models.ResponsesResponse, error) {
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
+573
-64
@@ -4,10 +4,13 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"llm-proxy/internal/config"
|
||||
"llm-proxy/internal/models"
|
||||
"github.com/go-resty/resty/v2"
|
||||
"gophergate/internal/config"
|
||||
"gophergate/internal/models"
|
||||
)
|
||||
|
||||
type GeminiProvider struct {
|
||||
@@ -18,7 +21,7 @@ type GeminiProvider struct {
|
||||
|
||||
func NewGeminiProvider(cfg config.GeminiConfig, apiKey string) *GeminiProvider {
|
||||
return &GeminiProvider{
|
||||
client: resty.New(),
|
||||
client: NewOptimizedRestyClient(10 * time.Minute),
|
||||
config: cfg,
|
||||
apiKey: apiKey,
|
||||
}
|
||||
@@ -30,6 +33,20 @@ func (p *GeminiProvider) Name() string {
|
||||
|
||||
type GeminiRequest struct {
|
||||
Contents []GeminiContent `json:"contents"`
|
||||
Tools []GeminiTool `json:"tools,omitempty"`
|
||||
GenerationConfig *GeminiGenerationConfig `json:"generationConfig,omitempty"`
|
||||
}
|
||||
|
||||
type GeminiTool struct {
|
||||
FunctionDeclarations []models.FunctionDef `json:"functionDeclarations"`
|
||||
}
|
||||
|
||||
type GeminiGenerationConfig struct {
|
||||
Temperature *float32 `json:"temperature,omitempty"`
|
||||
TopP *float32 `json:"topP,omitempty"`
|
||||
TopK *int `json:"topK,omitempty"`
|
||||
MaxOutputTokens *int `json:"maxOutputTokens,omitempty"`
|
||||
StopSequences []string `json:"stopSequences,omitempty"`
|
||||
}
|
||||
|
||||
type GeminiContent struct {
|
||||
@@ -42,6 +59,7 @@ type GeminiPart struct {
|
||||
InlineData *GeminiInlineData `json:"inlineData,omitempty"`
|
||||
FunctionCall *GeminiFunctionCall `json:"functionCall,omitempty"`
|
||||
FunctionResponse *GeminiFunctionResponse `json:"functionResponse,omitempty"`
|
||||
ThoughtSignature string `json:"thoughtSignature,omitempty"`
|
||||
}
|
||||
|
||||
type GeminiInlineData struct {
|
||||
@@ -59,41 +77,220 @@ type GeminiFunctionResponse struct {
|
||||
Response json.RawMessage `json:"response"`
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) ImageGeneration(ctx context.Context, req *models.ImageGenerationRequest) (*models.ImageGenerationResponse, error) {
|
||||
// Gemini Imagen API: POST https://generativelanguage.googleapis.com/v1beta/models/{model}:predict
|
||||
// Map OpenAI-style params to Gemini Imagen params
|
||||
|
||||
n := uint32(1)
|
||||
if req.N != nil && *req.N > 0 {
|
||||
n = *req.N
|
||||
}
|
||||
|
||||
aspectRatio := "1:1"
|
||||
if req.Size != nil {
|
||||
aspectRatio = sizeToGeminiAspectRatio(*req.Size)
|
||||
}
|
||||
|
||||
// Build Imagen request
|
||||
imagenReq := map[string]interface{}{
|
||||
"instances": []map[string]interface{}{
|
||||
{"prompt": req.Prompt},
|
||||
},
|
||||
"parameters": map[string]interface{}{
|
||||
"sampleCount": n,
|
||||
"aspectRatio": aspectRatio,
|
||||
},
|
||||
}
|
||||
|
||||
// Model defaults to imagen-3.0-generate-001 if empty
|
||||
model := req.Model
|
||||
if model == "" {
|
||||
model = "imagen-3.0-generate-001"
|
||||
}
|
||||
|
||||
// Use v1beta for Imagen
|
||||
baseURL := p.config.BaseURL
|
||||
if !strings.Contains(baseURL, "v1beta") {
|
||||
baseURL = strings.Replace(baseURL, "/v1", "/v1beta", 1)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/models/%s:predict?key=%s", baseURL, model, p.apiKey)
|
||||
|
||||
resp, err := p.client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetBody(imagenReq).
|
||||
Post(url)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gemini imagen 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("Gemini Imagen API error (%d): %s", resp.StatusCode(), msg)
|
||||
}
|
||||
|
||||
// Parse Imagen response
|
||||
var imagenResp struct {
|
||||
Predictions []struct {
|
||||
MimeType string `json:"mimeType"`
|
||||
BytesBase64Encoded string `json:"bytesBase64Encoded"`
|
||||
} `json:"predictions"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(resp.Body(), &imagenResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Imagen response: %w", err)
|
||||
}
|
||||
|
||||
respFormat := "url"
|
||||
if req.ResponseFormat != nil && *req.ResponseFormat == "b64_json" {
|
||||
respFormat = "b64_json"
|
||||
}
|
||||
|
||||
var data []models.ImageData
|
||||
for _, pred := range imagenResp.Predictions {
|
||||
imgData := models.ImageData{}
|
||||
if respFormat == "b64_json" {
|
||||
imgData.B64JSON = pred.BytesBase64Encoded
|
||||
} else {
|
||||
// Build a data URI since Gemini returns base64, not a URL
|
||||
mime := pred.MimeType
|
||||
if mime == "" {
|
||||
mime = "image/png"
|
||||
}
|
||||
imgData.URL = fmt.Sprintf("data:%s;base64,%s", mime, pred.BytesBase64Encoded)
|
||||
}
|
||||
data = append(data, imgData)
|
||||
}
|
||||
|
||||
result := &models.ImageGenerationResponse{
|
||||
Created: time.Now().Unix(),
|
||||
Data: data,
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// sizeToGeminiAspectRatio converts OpenAI size format (e.g. "1024x1024") to Gemini aspect ratio (e.g. "1:1")
|
||||
func sizeToGeminiAspectRatio(size string) string {
|
||||
switch size {
|
||||
case "1024x1024":
|
||||
return "1:1"
|
||||
case "1024x1792":
|
||||
return "9:16"
|
||||
case "1792x1024":
|
||||
return "16:9"
|
||||
case "256x256", "512x512":
|
||||
return "1:1"
|
||||
default:
|
||||
return "1:1"
|
||||
}
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) Responses(ctx context.Context, req *models.ResponsesRequest) (*models.ResponsesResponse, error) {
|
||||
return nil, fmt.Errorf("responses API not supported by gemini")
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) ResponsesStream(ctx context.Context, req *models.ResponsesRequest) (<-chan *models.ResponsesStreamChunk, error) {
|
||||
return nil, fmt.Errorf("responses API not supported by gemini")
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.UnifiedRequest) (*models.ChatCompletionResponse, error) {
|
||||
req.Model = normalizeGeminiModel(req.Model)
|
||||
|
||||
// Gemini mapping
|
||||
var contents []GeminiContent
|
||||
for _, msg := range req.Messages {
|
||||
|
||||
for i := 0; i < len(req.Messages); i++ {
|
||||
msg := req.Messages[i]
|
||||
|
||||
if msg.Role == "assistant" && len(msg.ToolCalls) > 0 {
|
||||
// 1. Add the assistant (model) message with tool calls
|
||||
parts := []GeminiPart{}
|
||||
for _, cp := range msg.Content {
|
||||
if cp.Type == "text" && cp.Text != "" {
|
||||
parts = append(parts, GeminiPart{Text: cp.Text})
|
||||
}
|
||||
}
|
||||
for _, tc := range msg.ToolCalls {
|
||||
parts = append(parts, GeminiPart{
|
||||
FunctionCall: &GeminiFunctionCall{
|
||||
Name: tc.Function.Name,
|
||||
Args: json.RawMessage(tc.Function.Arguments),
|
||||
},
|
||||
ThoughtSignature: "skip_thought_signature_validator",
|
||||
})
|
||||
}
|
||||
contents = append(contents, GeminiContent{Role: "model", Parts: parts})
|
||||
|
||||
// 2. The VERY NEXT message MUST be the "function" results for THESE EXACT calls.
|
||||
// Look ahead for tool messages.
|
||||
var functionParts []GeminiPart
|
||||
toolCallIDs := make(map[string]bool)
|
||||
for _, tc := range msg.ToolCalls {
|
||||
toolCallIDs[tc.ID] = true
|
||||
}
|
||||
|
||||
// We need to find tool messages that correspond to these calls.
|
||||
// In many patterns, they follow immediately.
|
||||
j := i + 1
|
||||
foundAny := false
|
||||
for j < len(req.Messages) && req.Messages[j].Role == "tool" {
|
||||
m := req.Messages[j]
|
||||
|
||||
// Try to match by ID or just take them in order if IDs are missing/mismatched
|
||||
// Gemini is strict: you must respond to EVERY call in the previous message.
|
||||
text := ""
|
||||
if len(m.Content) > 0 {
|
||||
text = m.Content[0].Text
|
||||
}
|
||||
name := resolveToolName(m, msg.ToolCalls, j-i-1)
|
||||
|
||||
respBytes := ensureJSONObject(text)
|
||||
|
||||
functionParts = append(functionParts, GeminiPart{
|
||||
FunctionResponse: &GeminiFunctionResponse{
|
||||
Name: name,
|
||||
Response: json.RawMessage(respBytes),
|
||||
},
|
||||
})
|
||||
foundAny = true
|
||||
j++
|
||||
}
|
||||
|
||||
if foundAny {
|
||||
contents = append(contents, GeminiContent{Role: "user", Parts: functionParts})
|
||||
i = j - 1 // Advance outer loop past the tool messages we consumed
|
||||
} else {
|
||||
// If no tool results found but assistant made calls, Gemini WILL error.
|
||||
// We should probably skip the calls or provide dummy results,
|
||||
// but usually this means the conversation is incomplete.
|
||||
// For now, don't add a "function" message if none found.
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Standard message handling (System/User/Assistant without tools)
|
||||
role := "user"
|
||||
if msg.Role == "assistant" {
|
||||
role = "model"
|
||||
} else if msg.Role == "system" {
|
||||
role = "user" // Gemini uses 'user' for system prompts in some versions, or handles it via systemInstruction
|
||||
} else if msg.Role == "tool" {
|
||||
role = "user" // Tool results are user-side in Gemini
|
||||
// Orphaned tool message (not following an assistant call) - Gemini doesn't like this.
|
||||
// Skip or map to user? Skipping is safer for API stability.
|
||||
continue
|
||||
}
|
||||
|
||||
var parts []GeminiPart
|
||||
|
||||
// Handle tool responses
|
||||
if msg.Role == "tool" {
|
||||
text := ""
|
||||
if len(msg.Content) > 0 {
|
||||
text = msg.Content[0].Text
|
||||
}
|
||||
|
||||
// Gemini expects functionResponse to be an object
|
||||
name := "unknown_function"
|
||||
if msg.Name != nil {
|
||||
name = *msg.Name
|
||||
}
|
||||
|
||||
parts = append(parts, GeminiPart{
|
||||
FunctionResponse: &GeminiFunctionResponse{
|
||||
Name: name,
|
||||
Response: json.RawMessage(text),
|
||||
},
|
||||
})
|
||||
} else {
|
||||
for _, cp := range msg.Content {
|
||||
if cp.Type == "text" {
|
||||
if cp.Type == "text" && cp.Text != "" {
|
||||
parts = append(parts, GeminiPart{Text: cp.Text})
|
||||
} else if cp.Image != nil {
|
||||
base64Data, mimeType, _ := cp.Image.ToBase64()
|
||||
@@ -106,30 +303,69 @@ func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.Unified
|
||||
}
|
||||
}
|
||||
|
||||
// Handle assistant tool calls
|
||||
if msg.Role == "assistant" && len(msg.ToolCalls) > 0 {
|
||||
for _, tc := range msg.ToolCalls {
|
||||
parts = append(parts, GeminiPart{
|
||||
FunctionCall: &GeminiFunctionCall{
|
||||
Name: tc.Function.Name,
|
||||
Args: json.RawMessage(tc.Function.Arguments),
|
||||
},
|
||||
})
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
contents = append(contents, GeminiContent{Role: role, Parts: parts})
|
||||
}
|
||||
}
|
||||
|
||||
contents = append(contents, GeminiContent{
|
||||
Role: role,
|
||||
Parts: parts,
|
||||
})
|
||||
genConfig := &GeminiGenerationConfig{}
|
||||
if req.Temperature != nil {
|
||||
t := float32(*req.Temperature)
|
||||
genConfig.Temperature = &t
|
||||
}
|
||||
if req.TopP != nil {
|
||||
tp := float32(*req.TopP)
|
||||
genConfig.TopP = &tp
|
||||
}
|
||||
if req.TopK != nil {
|
||||
tk := int(*req.TopK)
|
||||
genConfig.TopK = &tk
|
||||
}
|
||||
if req.MaxTokens != nil {
|
||||
mt := int(*req.MaxTokens)
|
||||
genConfig.MaxOutputTokens = &mt
|
||||
}
|
||||
if len(req.Stop) > 0 {
|
||||
genConfig.StopSequences = req.Stop
|
||||
}
|
||||
|
||||
body := GeminiRequest{
|
||||
Contents: contents,
|
||||
GenerationConfig: genConfig,
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/models/%s:generateContent?key=%s", p.config.BaseURL, req.Model, p.apiKey)
|
||||
// Map Tools
|
||||
hasMappedTools := false
|
||||
if len(req.Tools) > 0 {
|
||||
geminiTool := GeminiTool{FunctionDeclarations: []models.FunctionDef{}}
|
||||
for _, t := range req.Tools {
|
||||
if t.Type == "function" {
|
||||
funcDef := t.Function
|
||||
funcDef.Parameters = cleanParametersSchema(funcDef.Parameters)
|
||||
geminiTool.FunctionDeclarations = append(geminiTool.FunctionDeclarations, funcDef)
|
||||
}
|
||||
}
|
||||
if len(geminiTool.FunctionDeclarations) > 0 {
|
||||
body.Tools = []GeminiTool{geminiTool}
|
||||
hasMappedTools = true
|
||||
}
|
||||
}
|
||||
|
||||
baseURL := p.config.BaseURL
|
||||
lowerModel := strings.ToLower(req.Model)
|
||||
if strings.Contains(lowerModel, "preview") ||
|
||||
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") {
|
||||
baseURL = strings.Replace(baseURL, "/v1", "/v1beta", 1)
|
||||
}
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/models/%s:generateContent?key=%s", baseURL, req.Model, p.apiKey)
|
||||
fmt.Printf("[Gemini] POST %s\n", SanitizeURL(url))
|
||||
|
||||
resp, err := p.client.R().
|
||||
SetContext(ctx).
|
||||
@@ -141,15 +377,27 @@ func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.Unified
|
||||
}
|
||||
|
||||
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] API Error %d: %s\n", resp.StatusCode(), msg)
|
||||
// Also log the request body for debugging (careful with API keys if logged elsewhere)
|
||||
reqJSON, _ := json.Marshal(body)
|
||||
fmt.Printf("[Gemini] Request Body: %s\n", string(reqJSON))
|
||||
return nil, fmt.Errorf("Gemini API error (%d): %s", resp.StatusCode(), msg)
|
||||
}
|
||||
|
||||
// Parse Gemini response and convert to OpenAI format
|
||||
var geminiResp struct {
|
||||
Candidates []struct {
|
||||
Content struct {
|
||||
Role string `json:"role"`
|
||||
Parts []struct {
|
||||
Text string `json:"text"`
|
||||
FunctionCall *GeminiFunctionCall `json:"functionCall"`
|
||||
} `json:"parts"`
|
||||
} `json:"content"`
|
||||
FinishReason string `json:"finishReason"`
|
||||
@@ -158,6 +406,7 @@ func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.Unified
|
||||
PromptTokenCount uint32 `json:"promptTokenCount"`
|
||||
CandidatesTokenCount uint32 `json:"candidatesTokenCount"`
|
||||
TotalTokenCount uint32 `json:"totalTokenCount"`
|
||||
CachedContentTokenCount uint32 `json:"cachedContentTokenCount"`
|
||||
} `json:"usageMetadata"`
|
||||
}
|
||||
|
||||
@@ -170,14 +419,34 @@ func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.Unified
|
||||
}
|
||||
|
||||
content := ""
|
||||
for _, p := range geminiResp.Candidates[0].Content.Parts {
|
||||
content += p.Text
|
||||
var toolCalls []models.ToolCall
|
||||
for _, part := range geminiResp.Candidates[0].Content.Parts {
|
||||
if part.Text != "" {
|
||||
content += part.Text
|
||||
}
|
||||
if part.FunctionCall != nil {
|
||||
toolCalls = append(toolCalls, models.ToolCall{
|
||||
ID: fmt.Sprintf("call_%s", part.FunctionCall.Name), // Gemini doesn't have call IDs
|
||||
Type: "function",
|
||||
Function: models.FunctionCall{
|
||||
Name: part.FunctionCall.Name,
|
||||
Arguments: string(part.FunctionCall.Args),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
finishReason := strings.ToLower(geminiResp.Candidates[0].FinishReason)
|
||||
if finishReason == "stop" {
|
||||
finishReason = "stop"
|
||||
} else if len(toolCalls) > 0 {
|
||||
finishReason = "tool_calls"
|
||||
}
|
||||
|
||||
openAIResp := &models.ChatCompletionResponse{
|
||||
ID: "gemini-" + req.Model,
|
||||
Object: "chat.completion",
|
||||
Created: 0, // Should be current timestamp
|
||||
Created: 0,
|
||||
Model: req.Model,
|
||||
Choices: []models.ChatChoice{
|
||||
{
|
||||
@@ -185,14 +454,16 @@ func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.Unified
|
||||
Message: models.ChatMessage{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
ToolCalls: toolCalls,
|
||||
},
|
||||
FinishReason: &geminiResp.Candidates[0].FinishReason,
|
||||
FinishReason: &finishReason,
|
||||
},
|
||||
},
|
||||
Usage: &models.Usage{
|
||||
PromptTokens: geminiResp.UsageMetadata.PromptTokenCount,
|
||||
CompletionTokens: geminiResp.UsageMetadata.CandidatesTokenCount,
|
||||
TotalTokens: geminiResp.UsageMetadata.TotalTokenCount,
|
||||
CacheReadTokens: uint32Ptr(geminiResp.UsageMetadata.CachedContentTokenCount),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -200,31 +471,148 @@ func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.Unified
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.UnifiedRequest) (<-chan *models.ChatCompletionStreamResponse, error) {
|
||||
req.Model = normalizeGeminiModel(req.Model)
|
||||
|
||||
// Simplified Gemini mapping
|
||||
var contents []GeminiContent
|
||||
for _, msg := range req.Messages {
|
||||
for i := 0; i < len(req.Messages); i++ {
|
||||
msg := req.Messages[i]
|
||||
|
||||
if msg.Role == "assistant" && len(msg.ToolCalls) > 0 {
|
||||
parts := []GeminiPart{}
|
||||
for _, cp := range msg.Content {
|
||||
if cp.Type == "text" && cp.Text != "" {
|
||||
parts = append(parts, GeminiPart{Text: cp.Text})
|
||||
}
|
||||
}
|
||||
for _, tc := range msg.ToolCalls {
|
||||
parts = append(parts, GeminiPart{
|
||||
FunctionCall: &GeminiFunctionCall{
|
||||
Name: tc.Function.Name,
|
||||
Args: json.RawMessage(tc.Function.Arguments),
|
||||
},
|
||||
ThoughtSignature: "skip_thought_signature_validator",
|
||||
})
|
||||
}
|
||||
contents = append(contents, GeminiContent{Role: "model", Parts: parts})
|
||||
|
||||
var functionParts []GeminiPart
|
||||
j := i + 1
|
||||
foundAny := false
|
||||
for j < len(req.Messages) && req.Messages[j].Role == "tool" {
|
||||
m := req.Messages[j]
|
||||
text := ""
|
||||
if len(m.Content) > 0 {
|
||||
text = m.Content[0].Text
|
||||
}
|
||||
name := resolveToolName(m, msg.ToolCalls, j-i-1)
|
||||
|
||||
respBytes := ensureJSONObject(text)
|
||||
|
||||
functionParts = append(functionParts, GeminiPart{
|
||||
FunctionResponse: &GeminiFunctionResponse{
|
||||
Name: name,
|
||||
Response: json.RawMessage(respBytes),
|
||||
},
|
||||
})
|
||||
foundAny = true
|
||||
j++
|
||||
}
|
||||
|
||||
if foundAny {
|
||||
contents = append(contents, GeminiContent{Role: "user", Parts: functionParts})
|
||||
i = j - 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
role := "user"
|
||||
if msg.Role == "assistant" {
|
||||
role = "model"
|
||||
} else if msg.Role == "system" {
|
||||
role = "user"
|
||||
} else if msg.Role == "tool" {
|
||||
continue
|
||||
}
|
||||
|
||||
var parts []GeminiPart
|
||||
for _, p := range msg.Content {
|
||||
parts = append(parts, GeminiPart{Text: p.Text})
|
||||
for _, cp := range msg.Content {
|
||||
if cp.Type == "text" && cp.Text != "" {
|
||||
parts = append(parts, GeminiPart{Text: cp.Text})
|
||||
} else if cp.Image != nil {
|
||||
base64Data, mimeType, _ := cp.Image.ToBase64()
|
||||
parts = append(parts, GeminiPart{
|
||||
InlineData: &GeminiInlineData{
|
||||
MimeType: mimeType,
|
||||
Data: base64Data,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
contents = append(contents, GeminiContent{
|
||||
Role: role,
|
||||
Parts: parts,
|
||||
})
|
||||
if len(parts) > 0 {
|
||||
contents = append(contents, GeminiContent{Role: role, Parts: parts})
|
||||
}
|
||||
}
|
||||
|
||||
genConfig := &GeminiGenerationConfig{}
|
||||
if req.Temperature != nil {
|
||||
t := float32(*req.Temperature)
|
||||
genConfig.Temperature = &t
|
||||
}
|
||||
if req.TopP != nil {
|
||||
tp := float32(*req.TopP)
|
||||
genConfig.TopP = &tp
|
||||
}
|
||||
if req.TopK != nil {
|
||||
tk := int(*req.TopK)
|
||||
genConfig.TopK = &tk
|
||||
}
|
||||
if req.MaxTokens != nil {
|
||||
mt := int(*req.MaxTokens)
|
||||
genConfig.MaxOutputTokens = &mt
|
||||
}
|
||||
if len(req.Stop) > 0 {
|
||||
genConfig.StopSequences = req.Stop
|
||||
}
|
||||
|
||||
body := GeminiRequest{
|
||||
Contents: contents,
|
||||
GenerationConfig: genConfig,
|
||||
}
|
||||
|
||||
hasMappedTools := false
|
||||
if len(req.Tools) > 0 {
|
||||
geminiTool := GeminiTool{FunctionDeclarations: []models.FunctionDef{}}
|
||||
for _, t := range req.Tools {
|
||||
if t.Type == "function" {
|
||||
funcDef := t.Function
|
||||
funcDef.Parameters = cleanParametersSchema(funcDef.Parameters)
|
||||
geminiTool.FunctionDeclarations = append(geminiTool.FunctionDeclarations, funcDef)
|
||||
}
|
||||
}
|
||||
if len(geminiTool.FunctionDeclarations) > 0 {
|
||||
body.Tools = []GeminiTool{geminiTool}
|
||||
hasMappedTools = true
|
||||
}
|
||||
}
|
||||
|
||||
baseURL := p.config.BaseURL
|
||||
lowerModel := strings.ToLower(req.Model)
|
||||
if strings.Contains(lowerModel, "preview") ||
|
||||
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") {
|
||||
baseURL = strings.Replace(baseURL, "/v1", "/v1beta", 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Use streamGenerateContent for streaming
|
||||
url := fmt.Sprintf("%s/models/%s:streamGenerateContent?key=%s", p.config.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", SanitizeURL(url))
|
||||
|
||||
resp, err := p.client.R().
|
||||
SetContext(ctx).
|
||||
@@ -237,18 +625,139 @@ func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.U
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
err := StreamGemini(resp.RawBody(), ch, req.Model)
|
||||
ch, err := StreamGemini(resp.RawBody(), req.Model)
|
||||
if err != nil {
|
||||
fmt.Printf("Gemini Stream error: %v\n", err)
|
||||
return nil, fmt.Errorf("gemini stream init error: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func uint32Ptr(v uint32) *uint32 {
|
||||
if v > 0 {
|
||||
return &v
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"llm-proxy/internal/config"
|
||||
"llm-proxy/internal/models"
|
||||
"github.com/go-resty/resty/v2"
|
||||
"gophergate/internal/config"
|
||||
"gophergate/internal/models"
|
||||
)
|
||||
|
||||
type GrokProvider struct {
|
||||
@@ -18,7 +20,7 @@ type GrokProvider struct {
|
||||
|
||||
func NewGrokProvider(cfg config.GrokConfig, apiKey string) *GrokProvider {
|
||||
return &GrokProvider{
|
||||
client: resty.New(),
|
||||
client: NewOptimizedRestyClient(10 * time.Minute),
|
||||
config: cfg,
|
||||
apiKey: apiKey,
|
||||
}
|
||||
@@ -47,7 +49,13 @@ func (p *GrokProvider) ChatCompletion(ctx context.Context, req *models.UnifiedRe
|
||||
}
|
||||
|
||||
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{}
|
||||
@@ -78,7 +86,13 @@ func (p *GrokProvider) ChatCompletionStream(ctx context.Context, req *models.Uni
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -93,3 +107,15 @@ func (p *GrokProvider) ChatCompletionStream(ctx context.Context, req *models.Uni
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (p *GrokProvider) ImageGeneration(ctx context.Context, req *models.ImageGenerationRequest) (*models.ImageGenerationResponse, error) {
|
||||
return nil, fmt.Errorf("grok does not support image generation")
|
||||
}
|
||||
|
||||
func (p *GrokProvider) Responses(ctx context.Context, req *models.ResponsesRequest) (*models.ResponsesResponse, error) {
|
||||
return nil, fmt.Errorf("responses API not supported by grok")
|
||||
}
|
||||
|
||||
func (p *GrokProvider) ResponsesStream(ctx context.Context, req *models.ResponsesRequest) (<-chan *models.ResponsesStreamChunk, error) {
|
||||
return nil, fmt.Errorf("responses API not supported by grok")
|
||||
}
|
||||
|
||||
+477
-41
@@ -5,16 +5,79 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"llm-proxy/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.
|
||||
func MessagesToOpenAIJSON(messages []models.UnifiedMessage) ([]interface{}, error) {
|
||||
var result []interface{}
|
||||
for _, m := range messages {
|
||||
if m.Role == "tool" {
|
||||
role := strings.ToLower(m.Role)
|
||||
if role == "model" {
|
||||
role = "assistant"
|
||||
}
|
||||
|
||||
if role == "tool" || role == "function" {
|
||||
text := ""
|
||||
if len(m.Content) > 0 {
|
||||
text = m.Content[0].Text
|
||||
@@ -23,15 +86,14 @@ func MessagesToOpenAIJSON(messages []models.UnifiedMessage) ([]interface{}, erro
|
||||
"role": "tool",
|
||||
"content": text,
|
||||
}
|
||||
id := "unknown"
|
||||
if m.ToolCallID != nil {
|
||||
id := *m.ToolCallID
|
||||
if len(id) > 40 {
|
||||
id = id[:40]
|
||||
id = *m.ToolCallID
|
||||
}
|
||||
msg["tool_call_id"] = id
|
||||
}
|
||||
|
||||
if m.Name != nil {
|
||||
msg["name"] = *m.Name
|
||||
msg["name"] = sanitizeFunctionName(*m.Name)
|
||||
}
|
||||
result = append(result, msg)
|
||||
continue
|
||||
@@ -58,9 +120,22 @@ func MessagesToOpenAIJSON(messages []models.UnifiedMessage) ([]interface{}, erro
|
||||
}
|
||||
}
|
||||
|
||||
var finalContent interface{}
|
||||
if len(parts) == 0 {
|
||||
finalContent = nil
|
||||
} else if len(parts) == 1 {
|
||||
if p, ok := parts[0].(map[string]interface{}); ok && p["type"] == "text" {
|
||||
finalContent = p["text"]
|
||||
} else {
|
||||
finalContent = parts
|
||||
}
|
||||
} else {
|
||||
finalContent = parts
|
||||
}
|
||||
|
||||
msg := map[string]interface{}{
|
||||
"role": m.Role,
|
||||
"content": parts,
|
||||
"role": role,
|
||||
"content": finalContent,
|
||||
}
|
||||
|
||||
if m.ReasoningContent != nil {
|
||||
@@ -71,20 +146,21 @@ func MessagesToOpenAIJSON(messages []models.UnifiedMessage) ([]interface{}, erro
|
||||
sanitizedCalls := make([]models.ToolCall, len(m.ToolCalls))
|
||||
copy(sanitizedCalls, m.ToolCalls)
|
||||
for i := range sanitizedCalls {
|
||||
if len(sanitizedCalls[i].ID) > 40 {
|
||||
sanitizedCalls[i].ID = sanitizedCalls[i].ID[:40]
|
||||
if sanitizedCalls[i].Type == "" {
|
||||
sanitizedCalls[i].Type = "function"
|
||||
}
|
||||
sanitizedCalls[i].Function.Name = sanitizeFunctionName(sanitizedCalls[i].Function.Name)
|
||||
}
|
||||
msg["tool_calls"] = sanitizedCalls
|
||||
if len(parts) == 0 {
|
||||
msg["content"] = ""
|
||||
}
|
||||
msg["content"] = "" // OpenAI requirement: content must be string if tool_calls present
|
||||
}
|
||||
|
||||
if m.Name != nil {
|
||||
msg["name"] = *m.Name
|
||||
}
|
||||
|
||||
if m.Prefix != nil {
|
||||
msg["prefix"] = *m.Prefix
|
||||
}
|
||||
result = append(result, msg)
|
||||
}
|
||||
return result, nil
|
||||
@@ -110,11 +186,25 @@ func BuildOpenAIBody(request *models.UnifiedRequest, messagesJSON []interface{},
|
||||
body["max_tokens"] = *request.MaxTokens
|
||||
}
|
||||
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 {
|
||||
var toolChoice interface{}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -122,6 +212,160 @@ func BuildOpenAIBody(request *models.UnifiedRequest, messagesJSON []interface{},
|
||||
return body
|
||||
}
|
||||
|
||||
// BuildOpenAIResponsesBody builds the request body for the Responses API endpoint.
|
||||
func BuildOpenAIResponsesBody(req *models.ResponsesRequest, stream bool) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"model": req.Model,
|
||||
"stream": stream,
|
||||
}
|
||||
|
||||
// The input field can be a string or a structured array.
|
||||
// Try to preserve the original format.
|
||||
if req.Input != nil {
|
||||
// Try as string first
|
||||
var inputStr string
|
||||
if err := json.Unmarshal(req.Input, &inputStr); err == nil {
|
||||
body["input"] = inputStr
|
||||
} else {
|
||||
// Try as array of messages
|
||||
var inputArr []interface{}
|
||||
if err := json.Unmarshal(req.Input, &inputArr); err == nil {
|
||||
body["input"] = inputArr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if req.Instructions != "" {
|
||||
body["instructions"] = req.Instructions
|
||||
}
|
||||
if req.Temperature != nil {
|
||||
body["temperature"] = *req.Temperature
|
||||
}
|
||||
if req.MaxOutputTokens != nil {
|
||||
body["max_output_tokens"] = *req.MaxOutputTokens
|
||||
}
|
||||
if req.TopP != nil {
|
||||
body["top_p"] = *req.TopP
|
||||
}
|
||||
if req.Tools != nil {
|
||||
var tools interface{}
|
||||
if err := json.Unmarshal(req.Tools, &tools); err == nil {
|
||||
body["tools"] = tools
|
||||
}
|
||||
}
|
||||
if req.ToolChoice != nil {
|
||||
var toolChoice interface{}
|
||||
if err := json.Unmarshal(req.ToolChoice, &toolChoice); err == nil {
|
||||
body["tool_choice"] = toolChoice
|
||||
}
|
||||
}
|
||||
if req.Store != nil {
|
||||
body["store"] = *req.Store
|
||||
}
|
||||
|
||||
if stream {
|
||||
body["stream_options"] = map[string]interface{}{
|
||||
"include_usage": true,
|
||||
}
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
// ParseOpenAIResponsesResponse parses a raw JSON map into a ResponsesResponse.
|
||||
func ParseOpenAIResponsesResponse(respJSON map[string]interface{}, model string) (*models.ResponsesResponse, error) {
|
||||
data, err := json.Marshal(respJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var resp models.ResponsesResponse
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Re-parse usage with the detailed tokens
|
||||
if usageData, ok := respJSON["usage"]; ok {
|
||||
var responsesUsage models.ResponsesUsage
|
||||
usageBytes, _ := json.Marshal(usageData)
|
||||
if err := json.Unmarshal(usageBytes, &responsesUsage); err == nil {
|
||||
resp.Usage = &responsesUsage
|
||||
}
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// ParseOpenAIResponsesStreamChunk parses a single SSE line into a ResponsesStreamChunk.
|
||||
// Returns the chunk, whether this is the [DONE] signal, and any error.
|
||||
func ParseOpenAIResponsesStreamChunk(line string) (*models.ResponsesStreamChunk, bool, error) {
|
||||
if line == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
if data == "[DONE]" {
|
||||
return nil, true, nil
|
||||
}
|
||||
|
||||
var chunk models.ResponsesStreamChunk
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
return nil, false, fmt.Errorf("failed to unmarshal responses stream chunk: %w", err)
|
||||
}
|
||||
|
||||
return &chunk, false, nil
|
||||
}
|
||||
|
||||
// StreamOpenAIResponses reads SSE chunks from the body and sends them to the channel.
|
||||
func StreamOpenAIResponses(ctx io.ReadCloser, ch chan<- *models.ResponsesStreamChunk) error {
|
||||
defer ctx.Close()
|
||||
scanner := bufio.NewScanner(ctx)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
chunk, done, err := ParseOpenAIResponsesStreamChunk(line)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if done {
|
||||
break
|
||||
}
|
||||
if chunk != nil {
|
||||
ch <- chunk
|
||||
}
|
||||
}
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
type openAIUsage struct {
|
||||
PromptTokens uint32 `json:"prompt_tokens"`
|
||||
CompletionTokens uint32 `json:"completion_tokens"`
|
||||
TotalTokens uint32 `json:"total_tokens"`
|
||||
PromptTokensDetails *struct {
|
||||
CachedTokens uint32 `json:"cached_tokens"`
|
||||
} `json:"prompt_tokens_details"`
|
||||
CompletionTokensDetails *struct {
|
||||
ReasoningTokens uint32 `json:"reasoning_tokens"`
|
||||
} `json:"completion_tokens_details"`
|
||||
}
|
||||
|
||||
func (u *openAIUsage) ToUnified() *models.Usage {
|
||||
usage := &models.Usage{
|
||||
PromptTokens: u.PromptTokens,
|
||||
CompletionTokens: u.CompletionTokens,
|
||||
TotalTokens: u.TotalTokens,
|
||||
}
|
||||
if u.PromptTokensDetails != nil && u.PromptTokensDetails.CachedTokens > 0 {
|
||||
usage.CacheReadTokens = &u.PromptTokensDetails.CachedTokens
|
||||
}
|
||||
if u.CompletionTokensDetails != nil && u.CompletionTokensDetails.ReasoningTokens > 0 {
|
||||
usage.ReasoningTokens = &u.CompletionTokensDetails.ReasoningTokens
|
||||
}
|
||||
return usage
|
||||
}
|
||||
|
||||
func ParseOpenAIResponse(respJSON map[string]interface{}, model string) (*models.ChatCompletionResponse, error) {
|
||||
data, err := json.Marshal(respJSON)
|
||||
if err != nil {
|
||||
@@ -133,6 +377,16 @@ func ParseOpenAIResponse(respJSON map[string]interface{}, model string) (*models
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Manually fix usage because ChatCompletionResponse uses the unified Usage struct
|
||||
// but the provider might have returned more details.
|
||||
if usageData, ok := respJSON["usage"]; ok {
|
||||
var oUsage openAIUsage
|
||||
usageBytes, _ := json.Marshal(usageData)
|
||||
if err := json.Unmarshal(usageBytes, &oUsage); err == nil {
|
||||
resp.Usage = oUsage.ToUnified()
|
||||
}
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
@@ -156,6 +410,14 @@ func ParseOpenAIStreamChunk(line string) (*models.ChatCompletionStreamResponse,
|
||||
return nil, false, fmt.Errorf("failed to unmarshal stream chunk: %w", err)
|
||||
}
|
||||
|
||||
// Handle specialized usage in stream chunks
|
||||
var rawChunk struct {
|
||||
Usage *openAIUsage `json:"usage"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &rawChunk); err == nil && rawChunk.Usage != nil {
|
||||
chunk.Usage = rawChunk.Usage.ToUnified()
|
||||
}
|
||||
|
||||
return &chunk, false, nil
|
||||
}
|
||||
|
||||
@@ -178,23 +440,17 @@ func StreamOpenAI(ctx io.ReadCloser, ch chan<- *models.ChatCompletionStreamRespo
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
func StreamGemini(ctx io.ReadCloser, ch chan<- *models.ChatCompletionStreamResponse, model string) error {
|
||||
defer ctx.Close()
|
||||
|
||||
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 {
|
||||
// geminiStreamChunk is the shared data structure for parsing Gemini streaming responses.
|
||||
type geminiStreamChunk struct {
|
||||
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"`
|
||||
@@ -203,17 +459,23 @@ func StreamGemini(ctx io.ReadCloser, ch chan<- *models.ChatCompletionStreamRespo
|
||||
PromptTokenCount uint32 `json:"promptTokenCount"`
|
||||
CandidatesTokenCount uint32 `json:"candidatesTokenCount"`
|
||||
TotalTokenCount uint32 `json:"totalTokenCount"`
|
||||
CachedContentTokenCount uint32 `json:"cachedContentTokenCount"`
|
||||
} `json:"usageMetadata"`
|
||||
}
|
||||
|
||||
if err := dec.Decode(&geminiChunk); err != nil {
|
||||
return err
|
||||
// emitGeminiChunk builds a ChatCompletionStreamResponse from a parsed geminiStreamChunk
|
||||
// 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 {
|
||||
content := ""
|
||||
var reasoning *string
|
||||
for _, p := range geminiChunk.Candidates[0].Content.Parts {
|
||||
var finishReason *string
|
||||
var toolCalls []models.ToolCallDelta
|
||||
if len(chunk.Candidates) > 0 {
|
||||
for _, p := range chunk.Candidates[0].Content.Parts {
|
||||
if p.Text != "" {
|
||||
content += p.Text
|
||||
}
|
||||
@@ -223,11 +485,27 @@ func StreamGemini(ctx io.ReadCloser, ch chan<- *models.ChatCompletionStreamRespo
|
||||
}
|
||||
*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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
finishReason := strings.ToLower(geminiChunk.Candidates[0].FinishReason)
|
||||
if finishReason == "stop" {
|
||||
finishReason = "stop"
|
||||
}
|
||||
fr := strings.ToLower(chunk.Candidates[0].FinishReason)
|
||||
if len(toolCalls) > 0 && fr == "" {
|
||||
fr = "tool_calls"
|
||||
}
|
||||
finishReason = &fr
|
||||
}
|
||||
|
||||
ch <- &models.ChatCompletionStreamResponse{
|
||||
@@ -241,19 +519,177 @@ func StreamGemini(ctx io.ReadCloser, ch chan<- *models.ChatCompletionStreamRespo
|
||||
Delta: models.ChatStreamDelta{
|
||||
Content: &content,
|
||||
ReasoningContent: reasoning,
|
||||
ToolCalls: toolCalls,
|
||||
},
|
||||
FinishReason: &finishReason,
|
||||
FinishReason: finishReason,
|
||||
},
|
||||
},
|
||||
Usage: &models.Usage{
|
||||
PromptTokens: geminiChunk.UsageMetadata.PromptTokenCount,
|
||||
CompletionTokens: geminiChunk.UsageMetadata.CandidatesTokenCount,
|
||||
TotalTokens: geminiChunk.UsageMetadata.TotalTokenCount,
|
||||
PromptTokens: chunk.UsageMetadata.PromptTokenCount,
|
||||
CompletionTokens: chunk.UsageMetadata.CandidatesTokenCount,
|
||||
TotalTokens: chunk.UsageMetadata.TotalTokenCount,
|
||||
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...)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
"gophergate/internal/config"
|
||||
"gophergate/internal/models"
|
||||
)
|
||||
|
||||
type MoonshotProvider struct {
|
||||
client *resty.Client
|
||||
config config.MoonshotConfig
|
||||
apiKey string
|
||||
}
|
||||
|
||||
func NewMoonshotProvider(cfg config.MoonshotConfig, apiKey string) *MoonshotProvider {
|
||||
return &MoonshotProvider{
|
||||
client: NewOptimizedRestyClient(10 * time.Minute),
|
||||
config: cfg,
|
||||
apiKey: strings.TrimSpace(apiKey),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *MoonshotProvider) Name() string {
|
||||
return "moonshot"
|
||||
}
|
||||
|
||||
func (p *MoonshotProvider) 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)
|
||||
if strings.Contains(strings.ToLower(req.Model), "kimi-k2.5") {
|
||||
if maxTokens, ok := body["max_tokens"]; ok {
|
||||
delete(body, "max_tokens")
|
||||
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, "/")
|
||||
|
||||
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 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{}
|
||||
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 *MoonshotProvider) 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)
|
||||
if strings.Contains(strings.ToLower(req.Model), "kimi-k2.5") {
|
||||
if maxTokens, ok := body["max_tokens"]; ok {
|
||||
delete(body, "max_tokens")
|
||||
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, "/")
|
||||
|
||||
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("Moonshot 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("Moonshot Stream error: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (p *MoonshotProvider) ImageGeneration(ctx context.Context, req *models.ImageGenerationRequest) (*models.ImageGenerationResponse, error) {
|
||||
return nil, fmt.Errorf("moonshot does not support image generation")
|
||||
}
|
||||
|
||||
func (p *MoonshotProvider) Responses(ctx context.Context, req *models.ResponsesRequest) (*models.ResponsesResponse, error) {
|
||||
return nil, fmt.Errorf("responses API not supported by moonshot")
|
||||
}
|
||||
|
||||
func (p *MoonshotProvider) ResponsesStream(ctx context.Context, req *models.ResponsesRequest) (<-chan *models.ResponsesStreamChunk, error) {
|
||||
return nil, fmt.Errorf("responses API not supported by moonshot")
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
"gophergate/internal/config"
|
||||
"gophergate/internal/models"
|
||||
)
|
||||
|
||||
type OllamaProvider struct {
|
||||
client *resty.Client
|
||||
config config.OllamaConfig
|
||||
}
|
||||
|
||||
func NewOllamaProvider(cfg config.OllamaConfig) *OllamaProvider {
|
||||
client := NewOptimizedRestyClient(15 * time.Minute)
|
||||
// Set reasonable timeouts for local Ollama server (longer for larger models)
|
||||
// For streaming, we want a very long timeout or none at all to handle generation time
|
||||
client.SetRetryCount(2)
|
||||
client.SetRetryWaitTime(1 * time.Second)
|
||||
|
||||
return &OllamaProvider{
|
||||
client: client,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *OllamaProvider) Name() string {
|
||||
return "ollama"
|
||||
}
|
||||
|
||||
func (p *OllamaProvider) 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 := BuildOllamaBody(req, messagesJSON, false)
|
||||
url := fmt.Sprintf("%s/chat/completions", p.config.BaseURL)
|
||||
|
||||
resp, err := p.client.R().
|
||||
SetContext(ctx).
|
||||
SetBody(body).
|
||||
Post(url)
|
||||
|
||||
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("Ollama 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 ParseOllamaResponse(respJSON, req.Model)
|
||||
}
|
||||
|
||||
func (p *OllamaProvider) 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 := BuildOllamaBody(req, messagesJSON, true)
|
||||
|
||||
resp, err := p.client.R().
|
||||
SetContext(ctx).
|
||||
SetBody(body).
|
||||
SetDoNotParseResponse(true).
|
||||
Post(fmt.Sprintf("%s/chat/completions", p.config.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("Ollama API error (%d): %s", resp.StatusCode(), msg)
|
||||
}
|
||||
|
||||
ch := make(chan *models.ChatCompletionStreamResponse)
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
err := StreamOllama(resp.RawBody(), ch, req.Model)
|
||||
if err != nil {
|
||||
}
|
||||
}()
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func BuildOllamaBody(request *models.UnifiedRequest, messagesJSON []interface{}, stream bool) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"model": request.Model,
|
||||
"messages": messagesJSON,
|
||||
"stream": stream,
|
||||
}
|
||||
|
||||
options := make(map[string]interface{})
|
||||
modelLower := strings.ToLower(request.Model)
|
||||
|
||||
// Context window size (default 8k for all, 32k+ for modern large-context models)
|
||||
ctxSize := 8192
|
||||
if strings.Contains(modelLower, "llama") ||
|
||||
strings.Contains(modelLower, "gemma") ||
|
||||
strings.Contains(modelLower, "mistral") ||
|
||||
strings.Contains(modelLower, "mixtral") ||
|
||||
strings.Contains(modelLower, "qwen") ||
|
||||
strings.Contains(modelLower, "deepseek") ||
|
||||
strings.Contains(modelLower, "command-r") ||
|
||||
strings.Contains(modelLower, "phi") {
|
||||
ctxSize = 32768
|
||||
}
|
||||
options["num_ctx"] = ctxSize
|
||||
|
||||
if request.Temperature != nil {
|
||||
body["temperature"] = *request.Temperature
|
||||
options["temperature"] = *request.Temperature
|
||||
}
|
||||
|
||||
if request.MaxTokens != nil {
|
||||
body["max_tokens"] = *request.MaxTokens
|
||||
options["num_predict"] = *request.MaxTokens
|
||||
} else {
|
||||
// Default to 8192 for all Ollama models if not specified,
|
||||
// as Ollama's compatibility layer defaults to 128 if neither
|
||||
// max_tokens nor num_predict are provided.
|
||||
body["max_tokens"] = 8192
|
||||
options["num_predict"] = 8192
|
||||
}
|
||||
|
||||
if request.TopP != nil {
|
||||
body["top_p"] = *request.TopP
|
||||
options["top_p"] = *request.TopP
|
||||
}
|
||||
if request.TopK != nil {
|
||||
body["top_k"] = *request.TopK
|
||||
options["top_k"] = *request.TopK
|
||||
}
|
||||
|
||||
if len(request.Stop) > 0 {
|
||||
body["stop"] = request.Stop
|
||||
options["stop"] = request.Stop
|
||||
}
|
||||
|
||||
if len(options) > 0 {
|
||||
body["options"] = options
|
||||
}
|
||||
|
||||
if len(request.Tools) > 0 {
|
||||
body["tools"] = request.Tools
|
||||
// Explicitly set tool_choice to auto if tools are present but choice is not specified
|
||||
if request.ToolChoice == nil {
|
||||
body["tool_choice"] = "auto"
|
||||
}
|
||||
}
|
||||
if request.ToolChoice != nil {
|
||||
var toolChoice interface{}
|
||||
if err := json.Unmarshal(request.ToolChoice, &toolChoice); err == nil {
|
||||
body["tool_choice"] = toolChoice
|
||||
}
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
func ParseOllamaResponse(respJSON map[string]interface{}, model string) (*models.ChatCompletionResponse, error) {
|
||||
data, err := json.Marshal(respJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var resp models.ChatCompletionResponse
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if usageData, ok := respJSON["usage"]; ok {
|
||||
var usage models.Usage
|
||||
usageBytes, _ := json.Marshal(usageData)
|
||||
if err := json.Unmarshal(usageBytes, &usage); err == nil {
|
||||
resp.Usage = &usage
|
||||
}
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func ParseOllamaStreamChunk(line string) (*models.ChatCompletionStreamResponse, bool, error) {
|
||||
if line == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
if data == "[DONE]" {
|
||||
return nil, true, nil
|
||||
}
|
||||
|
||||
var chunk models.ChatCompletionStreamResponse
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
return nil, false, fmt.Errorf("failed to unmarshal stream chunk: %w", err)
|
||||
}
|
||||
|
||||
var rawChunk struct {
|
||||
Usage *models.Usage `json:"usage"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &rawChunk); err == nil && rawChunk.Usage != nil {
|
||||
chunk.Usage = rawChunk.Usage
|
||||
}
|
||||
|
||||
return &chunk, false, nil
|
||||
}
|
||||
|
||||
func StreamOllama(ctx io.ReadCloser, ch chan<- *models.ChatCompletionStreamResponse, model string) error {
|
||||
defer ctx.Close()
|
||||
scanner := bufio.NewScanner(ctx)
|
||||
// Set a larger buffer for scanning to handle large chunks if they occur
|
||||
const maxCapacity = 10 * 1024 * 1024 // 10MB
|
||||
buf := make([]byte, 64*1024)
|
||||
scanner.Buffer(buf, maxCapacity)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
chunk, done, err := ParseOllamaStreamChunk(line)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if done {
|
||||
break
|
||||
}
|
||||
if chunk != nil {
|
||||
ch <- chunk
|
||||
}
|
||||
}
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
func (p *OllamaProvider) ImageGeneration(ctx context.Context, req *models.ImageGenerationRequest) (*models.ImageGenerationResponse, error) {
|
||||
return nil, fmt.Errorf("ollama does not support image generation")
|
||||
}
|
||||
|
||||
func (p *OllamaProvider) Responses(ctx context.Context, req *models.ResponsesRequest) (*models.ResponsesResponse, error) {
|
||||
return nil, fmt.Errorf("responses API not supported by ollama")
|
||||
}
|
||||
|
||||
func (p *OllamaProvider) ResponsesStream(ctx context.Context, req *models.ResponsesRequest) (<-chan *models.ResponsesStreamChunk, error) {
|
||||
return nil, fmt.Errorf("responses API not supported by ollama")
|
||||
}
|
||||
@@ -4,11 +4,14 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"llm-proxy/internal/config"
|
||||
"llm-proxy/internal/models"
|
||||
"github.com/go-resty/resty/v2"
|
||||
"gophergate/internal/config"
|
||||
"gophergate/internal/models"
|
||||
)
|
||||
|
||||
type OpenAIProvider struct {
|
||||
@@ -19,7 +22,7 @@ type OpenAIProvider struct {
|
||||
|
||||
func NewOpenAIProvider(cfg config.OpenAIConfig, apiKey string) *OpenAIProvider {
|
||||
return &OpenAIProvider{
|
||||
client: resty.New(),
|
||||
client: NewOptimizedRestyClient(10 * time.Minute),
|
||||
config: cfg,
|
||||
apiKey: apiKey,
|
||||
}
|
||||
@@ -37,12 +40,26 @@ func (p *OpenAIProvider) ChatCompletion(ctx context.Context, req *models.Unified
|
||||
|
||||
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
|
||||
if strings.HasPrefix(req.Model, "o1-") || strings.HasPrefix(req.Model, "o3-") || strings.Contains(req.Model, "gpt-5") {
|
||||
if maxTokens, ok := body["max_tokens"]; ok {
|
||||
delete(body, "max_tokens")
|
||||
body["max_completion_tokens"] = maxTokens
|
||||
}
|
||||
if len(req.Tools) > 0 {
|
||||
body["reasoning_effort"] = "none"
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := p.client.R().
|
||||
@@ -56,7 +73,17 @@ func (p *OpenAIProvider) ChatCompletion(ctx context.Context, req *models.Unified
|
||||
}
|
||||
|
||||
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{}
|
||||
@@ -67,6 +94,59 @@ func (p *OpenAIProvider) ChatCompletion(ctx context.Context, req *models.Unified
|
||||
return ParseOpenAIResponse(respJSON, req.Model)
|
||||
}
|
||||
|
||||
func (p *OpenAIProvider) ImageGeneration(ctx context.Context, req *models.ImageGenerationRequest) (*models.ImageGenerationResponse, error) {
|
||||
body := map[string]interface{}{
|
||||
"prompt": req.Prompt,
|
||||
"model": req.Model,
|
||||
}
|
||||
|
||||
if req.N != nil {
|
||||
body["n"] = *req.N
|
||||
}
|
||||
if req.Quality != nil {
|
||||
body["quality"] = *req.Quality
|
||||
}
|
||||
if req.ResponseFormat != nil {
|
||||
body["response_format"] = *req.ResponseFormat
|
||||
}
|
||||
if req.Size != nil {
|
||||
body["size"] = *req.Size
|
||||
}
|
||||
if req.Style != nil {
|
||||
body["style"] = *req.Style
|
||||
}
|
||||
if req.User != nil {
|
||||
body["user"] = *req.User
|
||||
}
|
||||
|
||||
resp, err := p.client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("Authorization", "Bearer "+p.apiKey).
|
||||
SetBody(body).
|
||||
Post(fmt.Sprintf("%s/images/generations", p.config.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("OpenAI image API error (%d): %s", resp.StatusCode(), msg)
|
||||
}
|
||||
|
||||
var result models.ImageGenerationResponse
|
||||
if err := json.Unmarshal(resp.Body(), &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (p *OpenAIProvider) ChatCompletionStream(ctx context.Context, req *models.UnifiedRequest) (<-chan *models.ChatCompletionStreamResponse, error) {
|
||||
messagesJSON, err := MessagesToOpenAIJSON(req.Messages)
|
||||
if err != nil {
|
||||
@@ -75,12 +155,26 @@ func (p *OpenAIProvider) ChatCompletionStream(ctx context.Context, req *models.U
|
||||
|
||||
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
|
||||
if strings.HasPrefix(req.Model, "o1-") || strings.HasPrefix(req.Model, "o3-") || strings.Contains(req.Model, "gpt-5") {
|
||||
if maxTokens, ok := body["max_tokens"]; ok {
|
||||
delete(body, "max_tokens")
|
||||
body["max_completion_tokens"] = maxTokens
|
||||
}
|
||||
if len(req.Tools) > 0 {
|
||||
body["reasoning_effort"] = "none"
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := p.client.R().
|
||||
@@ -95,7 +189,21 @@ func (p *OpenAIProvider) ChatCompletionStream(ctx context.Context, req *models.U
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"gophergate/internal/models"
|
||||
)
|
||||
|
||||
// Responses sends a non-streaming request to OpenAI's /v1/responses endpoint.
|
||||
func (p *OpenAIProvider) Responses(ctx context.Context, req *models.ResponsesRequest) (*models.ResponsesResponse, error) {
|
||||
// Determine if streaming was requested
|
||||
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 == "" {
|
||||
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{}
|
||||
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)
|
||||
}
|
||||
|
||||
// ResponsesStream sends a streaming request to OpenAI's /v1/responses endpoint.
|
||||
func (p *OpenAIProvider) ResponsesStream(ctx context.Context, req *models.ResponsesRequest) (<-chan *models.ResponsesStreamChunk, error) {
|
||||
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 == "" {
|
||||
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)
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
err := StreamOpenAIResponses(resp.RawBody(), ch)
|
||||
if err != nil {
|
||||
fmt.Printf("Responses stream error: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
@@ -3,11 +3,14 @@ package providers
|
||||
import (
|
||||
"context"
|
||||
|
||||
"llm-proxy/internal/models"
|
||||
"gophergate/internal/models"
|
||||
)
|
||||
|
||||
type Provider interface {
|
||||
Name() string
|
||||
ChatCompletion(ctx context.Context, req *models.UnifiedRequest) (*models.ChatCompletionResponse, error)
|
||||
ChatCompletionStream(ctx context.Context, req *models.UnifiedRequest) (<-chan *models.ChatCompletionStreamResponse, error)
|
||||
ImageGeneration(ctx context.Context, req *models.ImageGenerationRequest) (*models.ImageGenerationResponse, error)
|
||||
Responses(ctx context.Context, req *models.ResponsesRequest) (*models.ResponsesResponse, error)
|
||||
ResponsesStream(ctx context.Context, req *models.ResponsesRequest) (<-chan *models.ResponsesStreamChunk, error)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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)
|
||||
|
||||
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.`
|
||||
|
||||
func routeClassifier(ctx context.Context, classify ClassifierFunc, group db.ModelGroup, targets []string, routeCtx *RouteContext) (*Decision, error) {
|
||||
// Determine the rating scale
|
||||
maxRating := len(targets)
|
||||
if 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)
|
||||
userMsg := ""
|
||||
if routeCtx != nil {
|
||||
userMsg = routeCtx.UserMessage
|
||||
}
|
||||
ratingStr, err := classify(ctx, getSelectorModel(group, targets), prompt, userMsg)
|
||||
if err != nil {
|
||||
// Classifier failed — fall back to heuristic
|
||||
return routeHeuristic(group, targets, routeCtx)
|
||||
}
|
||||
|
||||
rating, err := strconv.Atoi(strings.TrimSpace(ratingStr))
|
||||
if err != nil || rating < 1 {
|
||||
rating = 1
|
||||
}
|
||||
if rating > maxRating {
|
||||
rating = maxRating
|
||||
}
|
||||
|
||||
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{
|
||||
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]
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gophergate/internal/db"
|
||||
)
|
||||
|
||||
// HeuristicRule defines a pattern-based routing rule (legacy format).
|
||||
type HeuristicRule struct {
|
||||
Pattern string `json:"pattern"`
|
||||
TargetIdx int `json:"target"`
|
||||
CaseSensitive bool `json:"case_sensitive,omitempty"`
|
||||
}
|
||||
|
||||
// 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]
|
||||
reason := "default (first target)"
|
||||
|
||||
// If heuristic_rules is set, determine format and parse
|
||||
if group.HeuristicRules != nil && *group.HeuristicRules != "" {
|
||||
rulesJSON := *group.HeuristicRules
|
||||
|
||||
if isConditionBasedRules(rulesJSON) {
|
||||
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
|
||||
if pattern == "" {
|
||||
continue // Avoid infinite matches with empty patterns
|
||||
}
|
||||
msg := searchMsg
|
||||
if !rule.CaseSensitive {
|
||||
pattern = strings.ToLower(pattern)
|
||||
msg = strings.ToLower(msg)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
selected = targets[rule.TargetIdx]
|
||||
reason = "matched heuristic rule: " + rule.Pattern
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Built-in fallback heuristics (if no custom rule matched)
|
||||
if reason == "default (first target)" && len(targets) > 1 {
|
||||
msgLower := strings.ToLower(routeCtx.UserMessage)
|
||||
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
|
||||
}
|
||||
|
||||
// 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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"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"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
type ClassifierFunc func(ctx context.Context, selectorModel, systemPrompt, userMessage string) (string, error)
|
||||
|
||||
// Router resolves model groups to concrete models.
|
||||
type Router struct {
|
||||
mu sync.RWMutex
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (r *Router) IsGroup(modelID string) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
_, ok := r.groups[modelID]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Route resolves a group to a concrete model.
|
||||
func (r *Router) Route(ctx context.Context, groupID string, routeCtx *RouteContext) (*Decision, error) {
|
||||
r.mu.RLock()
|
||||
group, ok := r.groups[groupID]
|
||||
r.mu.RUnlock()
|
||||
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, routeCtx)
|
||||
case "classifier":
|
||||
if r.classify == nil {
|
||||
return routeHeuristic(group, targets, routeCtx)
|
||||
}
|
||||
return routeClassifier(ctx, r.classify, group, targets, routeCtx)
|
||||
default:
|
||||
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.
|
||||
func (r *Router) Reload(groups []db.ModelGroup) {
|
||||
newGroups := make(map[string]db.ModelGroup)
|
||||
for _, g := range groups {
|
||||
newGroups[g.ID] = g
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.groups = newGroups
|
||||
r.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type UsagePeriodFilter struct {
|
||||
Period string `form:"period"`
|
||||
From string `form:"from"`
|
||||
To string `form:"to"`
|
||||
}
|
||||
|
||||
func (f *UsagePeriodFilter) ToSQL() (string, []interface{}) {
|
||||
period := f.Period
|
||||
if period == "" {
|
||||
period = "all"
|
||||
}
|
||||
|
||||
if period == "custom" {
|
||||
var clauses []string
|
||||
var binds []interface{}
|
||||
if f.From != "" {
|
||||
clauses = append(clauses, "timestamp >= ?")
|
||||
binds = append(binds, f.From)
|
||||
}
|
||||
if f.To != "" {
|
||||
clauses = append(clauses, "timestamp <= ?")
|
||||
binds = append(binds, f.To)
|
||||
}
|
||||
if len(clauses) > 0 {
|
||||
return " AND " + strings.Join(clauses, " AND "), binds
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
var cutoff time.Time
|
||||
switch period {
|
||||
case "today":
|
||||
cutoff = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
case "24h":
|
||||
cutoff = now.Add(-24 * time.Hour)
|
||||
case "7d":
|
||||
cutoff = now.Add(-7 * 24 * time.Hour)
|
||||
case "30d":
|
||||
cutoff = now.Add(-30 * 24 * time.Hour)
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return " AND timestamp >= ?", []interface{}{cutoff.Format(time.RFC3339)}
|
||||
}
|
||||
|
||||
func (s *Server) handleUsageSummary(c *gin.Context) {
|
||||
var filter UsagePeriodFilter
|
||||
if err := c.ShouldBindQuery(&filter); err != nil {
|
||||
// ignore
|
||||
}
|
||||
|
||||
clause, binds := filter.ToSQL()
|
||||
|
||||
// Total stats
|
||||
var totalStats struct {
|
||||
TotalRequests int `db:"total_requests"`
|
||||
TotalTokens int `db:"total_tokens"`
|
||||
CacheReadTokens int `db:"total_cache_read_tokens"`
|
||||
CacheWriteTokens int `db:"total_cache_write_tokens"`
|
||||
TotalCost float64 `db:"total_cost"`
|
||||
ActiveClients int `db:"active_clients"`
|
||||
}
|
||||
err := s.database.Get(&totalStats, fmt.Sprintf(`
|
||||
SELECT
|
||||
COUNT(*) as total_requests,
|
||||
COALESCE(SUM(total_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens,
|
||||
COALESCE(SUM(cache_write_tokens), 0) as total_cache_write_tokens,
|
||||
COALESCE(SUM(cost), 0.0) as total_cost,
|
||||
COUNT(DISTINCT client_id) as active_clients
|
||||
FROM llm_requests
|
||||
WHERE 1=1 %s
|
||||
`, clause), binds...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Today stats
|
||||
var todayStats struct {
|
||||
TodayRequests int `db:"today_requests"`
|
||||
TodayCost float64 `db:"today_cost"`
|
||||
}
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
err = s.database.Get(&todayStats, `
|
||||
SELECT
|
||||
COUNT(*) as today_requests,
|
||||
COALESCE(SUM(cost), 0.0) as today_cost
|
||||
FROM llm_requests
|
||||
WHERE timestamp LIKE ?
|
||||
`, today+"%")
|
||||
if err != nil {
|
||||
todayStats.TodayRequests = 0
|
||||
todayStats.TodayCost = 0.0
|
||||
}
|
||||
|
||||
// Error rate & Avg response time
|
||||
var miscStats struct {
|
||||
ErrorRate float64 `db:"error_rate"`
|
||||
AvgResponseTime float64 `db:"avg_response_time"`
|
||||
}
|
||||
err = s.database.Get(&miscStats, `
|
||||
SELECT
|
||||
CASE WHEN COUNT(*) = 0 THEN 0.0 ELSE (CAST(SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS FLOAT) / COUNT(*)) * 100.0 END as error_rate,
|
||||
COALESCE(AVG(duration_ms), 0.0) as avg_response_time
|
||||
FROM llm_requests
|
||||
`)
|
||||
if err != nil {
|
||||
miscStats.ErrorRate = 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{
|
||||
"total_requests": totalStats.TotalRequests,
|
||||
"total_tokens": totalStats.TotalTokens,
|
||||
"total_cache_read_tokens": totalStats.CacheReadTokens,
|
||||
"total_cache_write_tokens": totalStats.CacheWriteTokens,
|
||||
"total_cost": totalStats.TotalCost,
|
||||
"active_clients": totalStats.ActiveClients,
|
||||
"today_requests": todayStats.TodayRequests,
|
||||
"today_cost": todayStats.TodayCost,
|
||||
"error_rate": miscStats.ErrorRate,
|
||||
"avg_response_time": miscStats.AvgResponseTime,
|
||||
"total_days": lifetimeStats.TotalDays,
|
||||
"first_date": lifetimeStats.FirstDate,
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *Server) handleTimeSeries(c *gin.Context) {
|
||||
var filter UsagePeriodFilter
|
||||
if err := c.ShouldBindQuery(&filter); err != nil {
|
||||
// ignore
|
||||
}
|
||||
|
||||
clause, binds := filter.ToSQL()
|
||||
|
||||
if clause == "" {
|
||||
cutoff := time.Now().UTC().Add(-30 * 24 * time.Hour)
|
||||
clause = " AND timestamp >= ?"
|
||||
binds = []interface{}{cutoff.Format(time.RFC3339)}
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
COALESCE(SUBSTR(timestamp, 1, 10), 'unknown') as bucket,
|
||||
COUNT(*) as requests,
|
||||
COALESCE(SUM(total_tokens), 0) as tokens,
|
||||
COALESCE(SUM(cost), 0.0) as cost
|
||||
FROM llm_requests
|
||||
WHERE 1=1 %s
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`, clause)
|
||||
|
||||
rows, err := s.database.Queryx(query, binds...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var series []gin.H
|
||||
for rows.Next() {
|
||||
var bucket string
|
||||
var requests int
|
||||
var tokens int
|
||||
var cost float64
|
||||
if err := rows.Scan(&bucket, &requests, &tokens, &cost); err != nil {
|
||||
continue
|
||||
}
|
||||
series = append(series, gin.H{
|
||||
"time": bucket,
|
||||
"requests": requests,
|
||||
"tokens": tokens,
|
||||
"cost": cost,
|
||||
})
|
||||
}
|
||||
|
||||
granularity := "day"
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
||||
"series": series,
|
||||
"granularity": granularity,
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *Server) handleProvidersUsage(c *gin.Context) {
|
||||
var filter UsagePeriodFilter
|
||||
if err := c.ShouldBindQuery(&filter); err != nil {
|
||||
// ignore
|
||||
}
|
||||
|
||||
clause, binds := filter.ToSQL()
|
||||
|
||||
rows, err := s.database.Queryx(fmt.Sprintf(`
|
||||
SELECT
|
||||
COALESCE(provider, 'unknown') as provider,
|
||||
COUNT(*) as requests,
|
||||
COALESCE(SUM(cost), 0.0) as cost
|
||||
FROM llm_requests
|
||||
WHERE 1=1 %s
|
||||
GROUP BY provider
|
||||
`, clause), binds...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, SuccessResponse([]interface{}{}))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []gin.H
|
||||
for rows.Next() {
|
||||
var provider string
|
||||
var requests int
|
||||
var cost float64
|
||||
if err := rows.Scan(&provider, &requests, &cost); err == nil {
|
||||
results = append(results, gin.H{"provider": provider, "requests": requests, "cost": cost})
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(results))
|
||||
}
|
||||
|
||||
func (s *Server) handleClientsUsage(c *gin.Context) {
|
||||
var filter UsagePeriodFilter
|
||||
if err := c.ShouldBindQuery(&filter); err != nil {
|
||||
// ignore
|
||||
}
|
||||
|
||||
clause, binds := filter.ToSQL()
|
||||
|
||||
rows, err := s.database.Queryx(fmt.Sprintf(`
|
||||
SELECT COALESCE(client_id, 'unknown') as client_id, COUNT(*) as requests
|
||||
FROM llm_requests
|
||||
WHERE 1=1 %s
|
||||
GROUP BY client_id
|
||||
`, clause), binds...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, SuccessResponse([]interface{}{}))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []gin.H
|
||||
for rows.Next() {
|
||||
var clientID string
|
||||
var requests int
|
||||
if err := rows.Scan(&clientID, &requests); err == nil {
|
||||
results = append(results, gin.H{"client_id": clientID, "requests": requests})
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(results))
|
||||
}
|
||||
|
||||
func (s *Server) handleAnalyticsBreakdown(c *gin.Context) {
|
||||
var filter UsagePeriodFilter
|
||||
if err := c.ShouldBindQuery(&filter); err != nil {
|
||||
// ignore
|
||||
}
|
||||
|
||||
clause, binds := filter.ToSQL()
|
||||
|
||||
// Models breakdown
|
||||
var models []struct {
|
||||
Label string `json:"label"`
|
||||
Value int `json:"value"`
|
||||
}
|
||||
mRows, err := s.database.Queryx(fmt.Sprintf("SELECT COALESCE(model, 'unknown') as label, COUNT(*) as value FROM llm_requests WHERE 1=1 %s GROUP BY model ORDER BY value DESC", clause), binds...)
|
||||
if err == nil {
|
||||
for mRows.Next() {
|
||||
var label string
|
||||
var value int
|
||||
if err := mRows.Scan(&label, &value); err == nil {
|
||||
models = append(models, struct {
|
||||
Label string `json:"label"`
|
||||
Value int `json:"value"`
|
||||
}{label, value})
|
||||
}
|
||||
}
|
||||
mRows.Close()
|
||||
}
|
||||
|
||||
// Clients breakdown
|
||||
var clients []struct {
|
||||
Label string `json:"label"`
|
||||
Value int `json:"value"`
|
||||
}
|
||||
cRows, err := s.database.Queryx(fmt.Sprintf("SELECT COALESCE(client_id, 'unknown') as label, COUNT(*) as value FROM llm_requests WHERE 1=1 %s GROUP BY client_id ORDER BY value DESC", clause), binds...)
|
||||
if err == nil {
|
||||
for cRows.Next() {
|
||||
var label string
|
||||
var value int
|
||||
if err := cRows.Scan(&label, &value); err == nil {
|
||||
clients = append(clients, struct {
|
||||
Label string `json:"label"`
|
||||
Value int `json:"value"`
|
||||
}{label, value})
|
||||
}
|
||||
}
|
||||
cRows.Close()
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
||||
"models": models,
|
||||
"clients": clients,
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *Server) handleDetailedUsage(c *gin.Context) {
|
||||
var filter UsagePeriodFilter
|
||||
if err := c.ShouldBindQuery(&filter); err != nil {
|
||||
// ignore
|
||||
}
|
||||
|
||||
clause, binds := filter.ToSQL()
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
COALESCE(SUBSTR(timestamp, 1, 10), 'unknown') as date,
|
||||
COALESCE(client_id, 'unknown') as client,
|
||||
COALESCE(provider, 'unknown') as provider,
|
||||
COALESCE(model, 'unknown') as model,
|
||||
COUNT(*) as requests,
|
||||
COALESCE(SUM(total_tokens), 0) as tokens,
|
||||
COALESCE(SUM(cache_read_tokens), 0) as cache_read_tokens,
|
||||
COALESCE(SUM(cache_write_tokens), 0) as cache_write_tokens,
|
||||
COALESCE(SUM(cost), 0.0) as cost
|
||||
FROM llm_requests
|
||||
WHERE 1=1 %s
|
||||
GROUP BY date, client, provider, model
|
||||
ORDER BY date DESC, cost DESC
|
||||
`, clause)
|
||||
|
||||
rows, err := s.database.Queryx(query, binds...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, SuccessResponse([]interface{}{}))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []gin.H
|
||||
for rows.Next() {
|
||||
var date, client, provider, model string
|
||||
var requests, tokens, cacheRead, cacheWrite int
|
||||
var cost float64
|
||||
if err := rows.Scan(&date, &client, &provider, &model, &requests, &tokens, &cacheRead, &cacheWrite, &cost); err == nil {
|
||||
results = append(results, gin.H{
|
||||
"date": date,
|
||||
"client": client,
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"requests": requests,
|
||||
"tokens": tokens,
|
||||
"cache_read_tokens": cacheRead,
|
||||
"cache_write_tokens": cacheWrite,
|
||||
"cost": cost,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(results))
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gophergate/internal/db"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (s *Server) handleGetClients(c *gin.Context) {
|
||||
var clients []db.Client
|
||||
err := s.database.Select(&clients, "SELECT * FROM clients ORDER BY created_at DESC")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
type UIClient struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastUsed *time.Time `json:"last_used"`
|
||||
RequestsCount int `json:"requests_count"`
|
||||
TokensCount int `json:"tokens_count"`
|
||||
Status string `json:"status"`
|
||||
RateLimitPerMinute int `json:"rate_limit_per_minute"`
|
||||
}
|
||||
|
||||
uiClients := make([]UIClient, len(clients))
|
||||
for i, cl := range clients {
|
||||
status := "active"
|
||||
if !cl.IsActive {
|
||||
status = "disabled"
|
||||
}
|
||||
|
||||
name := ""
|
||||
if cl.Name != nil {
|
||||
name = *cl.Name
|
||||
}
|
||||
desc := ""
|
||||
if cl.Description != nil {
|
||||
desc = *cl.Description
|
||||
}
|
||||
|
||||
var lastUsedStr string
|
||||
_ = s.database.Get(&lastUsedStr, "SELECT MAX(last_used_at) FROM client_tokens WHERE client_id = ?", cl.ClientID)
|
||||
|
||||
var lastUsed *time.Time
|
||||
if lastUsedStr != "" {
|
||||
if t, err := time.Parse("2006-01-02 15:04:05", lastUsedStr); err == nil {
|
||||
lastUsed = &t
|
||||
}
|
||||
}
|
||||
|
||||
uiClients[i] = UIClient{
|
||||
ID: cl.ClientID,
|
||||
Name: name,
|
||||
Description: desc,
|
||||
CreatedAt: cl.CreatedAt,
|
||||
LastUsed: lastUsed,
|
||||
RequestsCount: cl.TotalRequests,
|
||||
TokensCount: cl.TotalTokens,
|
||||
Status: status,
|
||||
RateLimitPerMinute: cl.RateLimitPerMinute,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(uiClients))
|
||||
}
|
||||
|
||||
func (s *Server) handleGetClient(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var cl db.Client
|
||||
err := s.database.Get(&cl, "SELECT * FROM clients WHERE client_id = ?", id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, ErrorResponse("Client not found"))
|
||||
return
|
||||
}
|
||||
|
||||
name := ""
|
||||
if cl.Name != nil {
|
||||
name = *cl.Name
|
||||
}
|
||||
desc := ""
|
||||
if cl.Description != nil {
|
||||
desc = *cl.Description
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
||||
"id": cl.ClientID,
|
||||
"name": name,
|
||||
"description": desc,
|
||||
"is_active": cl.IsActive,
|
||||
"rate_limit_per_minute": cl.RateLimitPerMinute,
|
||||
"created_at": cl.CreatedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
type UpdateClientRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
IsActive bool `json:"is_active"`
|
||||
RateLimitPerMinute *int `json:"rate_limit_per_minute"`
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateClient(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req UpdateClientRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse("Invalid request"))
|
||||
return
|
||||
}
|
||||
|
||||
_, err := s.database.Exec(`
|
||||
UPDATE clients SET
|
||||
name = ?,
|
||||
description = ?,
|
||||
is_active = ?,
|
||||
rate_limit_per_minute = COALESCE(?, rate_limit_per_minute),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE client_id = ?
|
||||
`, req.Name, req.Description, req.IsActive, req.RateLimitPerMinute, id)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "Client updated"}))
|
||||
}
|
||||
|
||||
type CreateClientRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
ClientID *string `json:"client_id"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateClient(c *gin.Context) {
|
||||
var req CreateClientRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse("Invalid request"))
|
||||
return
|
||||
}
|
||||
|
||||
clientID := ""
|
||||
if req.ClientID != nil {
|
||||
clientID = *req.ClientID
|
||||
} else {
|
||||
clientID = "client-" + uuid.New().String()[:8]
|
||||
}
|
||||
|
||||
_, err := s.database.Exec("INSERT INTO clients (client_id, name, is_active) VALUES (?, ?, 1)", clientID, req.Name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
token := "sk-" + uuid.New().String() + uuid.New().String()
|
||||
token = token[:51]
|
||||
|
||||
_, err = s.database.Exec("INSERT INTO client_tokens (client_id, token, name) VALUES (?, ?, 'default')", clientID, token)
|
||||
if err != nil {
|
||||
// Log error
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
||||
"id": clientID,
|
||||
"name": req.Name,
|
||||
"status": "active",
|
||||
"token": token,
|
||||
"created_at": time.Now(),
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteClient(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "default" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse("Cannot delete default client"))
|
||||
return
|
||||
}
|
||||
|
||||
_, err := s.database.Exec("DELETE FROM clients WHERE client_id = ?", id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "Client deleted"}))
|
||||
}
|
||||
|
||||
func (s *Server) handleGetClientTokens(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var tokens []db.ClientToken
|
||||
err := s.database.Select(&tokens, "SELECT * FROM client_tokens WHERE client_id = ? ORDER BY created_at DESC", id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
type MaskedToken struct {
|
||||
ID int `json:"id"`
|
||||
TokenMasked string `json:"token_masked"`
|
||||
Name string `json:"name"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
}
|
||||
|
||||
masked := make([]MaskedToken, len(tokens))
|
||||
for i, t := range tokens {
|
||||
maskedToken := "••••"
|
||||
if len(t.Token) > 8 {
|
||||
maskedToken = t.Token[:3] + "••••" + t.Token[len(t.Token)-8:]
|
||||
}
|
||||
masked[i] = MaskedToken{
|
||||
ID: t.ID,
|
||||
TokenMasked: maskedToken,
|
||||
Name: t.Name,
|
||||
IsActive: t.IsActive,
|
||||
CreatedAt: t.CreatedAt,
|
||||
LastUsedAt: t.LastUsedAt,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(masked))
|
||||
}
|
||||
|
||||
type CreateTokenRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateClientToken(c *gin.Context) {
|
||||
clientID := c.Param("id")
|
||||
var req CreateTokenRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
// optional name
|
||||
}
|
||||
|
||||
name := "default"
|
||||
if req.Name != "" {
|
||||
name = req.Name
|
||||
}
|
||||
|
||||
token := "sk-" + uuid.New().String() + uuid.New().String()
|
||||
token = token[:51]
|
||||
|
||||
_, err := s.database.Exec("INSERT INTO client_tokens (client_id, token, name) VALUES (?, ?, ?)", clientID, token, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
||||
"token": token,
|
||||
"name": name,
|
||||
"created_at": time.Now(),
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteClientToken(c *gin.Context) {
|
||||
tokenID := c.Param("token_id")
|
||||
|
||||
_, err := s.database.Exec("DELETE FROM client_tokens WHERE id = ?", tokenID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "Token revoked"}))
|
||||
}
|
||||
+59
-570
@@ -4,11 +4,10 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"llm-proxy/internal/db"
|
||||
"gophergate/internal/db"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
@@ -74,25 +73,21 @@ func (s *Server) handleLogin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
token, err := s.sessions.CreateSession(user.Username, user.Role)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse("Failed to create session"))
|
||||
return
|
||||
}
|
||||
|
||||
displayName := user.Username
|
||||
if user.DisplayName != nil {
|
||||
displayName = *user.DisplayName
|
||||
}
|
||||
|
||||
token, err := s.sessions.CreateSession(user.Username, displayName, user.Role)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse("Failed to create session"))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
||||
"token": token,
|
||||
"must_change_password": user.MustChangePassword,
|
||||
"user": gin.H{
|
||||
"username": user.Username,
|
||||
"name": displayName,
|
||||
"role": user.Role,
|
||||
},
|
||||
"user": user,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -109,567 +104,61 @@ func (s *Server) handleAuthStatus(c *gin.Context) {
|
||||
"user": gin.H{
|
||||
"username": session.Username,
|
||||
"role": session.Role,
|
||||
"display_name": session.DisplayName,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
type ChangePasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required"`
|
||||
}
|
||||
|
||||
func (s *Server) handleChangePassword(c *gin.Context) {
|
||||
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
|
||||
session, _, err := s.sessions.ValidateSession(token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, ErrorResponse("Not authenticated"))
|
||||
return
|
||||
}
|
||||
|
||||
var req ChangePasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse("Invalid request"))
|
||||
return
|
||||
}
|
||||
|
||||
var user db.User
|
||||
err = s.database.Get(&user, "SELECT password_hash FROM users WHERE username = ?", session.Username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse("User not found"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.CurrentPassword)); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, ErrorResponse("Current password incorrect"))
|
||||
return
|
||||
}
|
||||
|
||||
newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), 12)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse("Failed to hash new password"))
|
||||
return
|
||||
}
|
||||
|
||||
_, err = s.database.Exec("UPDATE users SET password_hash = ?, must_change_password = 0 WHERE username = ?", string(newHash), session.Username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse("Failed to update password"))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "Password updated successfully"}))
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(c *gin.Context) {
|
||||
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
|
||||
s.sessions.RevokeSession(token)
|
||||
if err := s.sessions.RevokeSession(token); err != nil {
|
||||
fmt.Printf("Error revoking session: %v\n", err)
|
||||
}
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "Logged out"}))
|
||||
}
|
||||
|
||||
type UsagePeriodFilter struct {
|
||||
Period string `form:"period"`
|
||||
From string `form:"from"`
|
||||
To string `form:"to"`
|
||||
}
|
||||
|
||||
func (f *UsagePeriodFilter) ToSQL() (string, []interface{}) {
|
||||
period := f.Period
|
||||
if period == "" {
|
||||
period = "all"
|
||||
}
|
||||
|
||||
if period == "custom" {
|
||||
var clauses []string
|
||||
var binds []interface{}
|
||||
if f.From != "" {
|
||||
clauses = append(clauses, "timestamp >= ?")
|
||||
binds = append(binds, f.From)
|
||||
}
|
||||
if f.To != "" {
|
||||
clauses = append(clauses, "timestamp <= ?")
|
||||
binds = append(binds, f.To)
|
||||
}
|
||||
if len(clauses) > 0 {
|
||||
return " AND " + strings.Join(clauses, " AND "), binds
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
var cutoff time.Time
|
||||
switch period {
|
||||
case "today":
|
||||
cutoff = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
case "24h":
|
||||
cutoff = now.Add(-24 * time.Hour)
|
||||
case "7d":
|
||||
cutoff = now.Add(-7 * 24 * time.Hour)
|
||||
case "30d":
|
||||
cutoff = now.Add(-30 * 24 * time.Hour)
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return " AND timestamp >= ?", []interface{}{cutoff.Format(time.RFC3339)}
|
||||
}
|
||||
|
||||
func (s *Server) handleUsageSummary(c *gin.Context) {
|
||||
var filter UsagePeriodFilter
|
||||
if err := c.ShouldBindQuery(&filter); err != nil {
|
||||
// ignore
|
||||
}
|
||||
|
||||
clause, binds := filter.ToSQL()
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
COUNT(*) as total_requests,
|
||||
COALESCE(SUM(total_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(cost), 0.0) as total_cost,
|
||||
COUNT(DISTINCT client_id) as active_clients
|
||||
FROM llm_requests
|
||||
WHERE 1=1 %s
|
||||
`, clause)
|
||||
|
||||
var stats struct {
|
||||
TotalRequests int `db:"total_requests"`
|
||||
TotalTokens int `db:"total_tokens"`
|
||||
TotalCost float64 `db:"total_cost"`
|
||||
ActiveClients int `db:"active_clients"`
|
||||
}
|
||||
|
||||
err := s.database.Get(&stats, query, binds...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(stats))
|
||||
}
|
||||
|
||||
func (s *Server) handleTimeSeries(c *gin.Context) {
|
||||
var filter UsagePeriodFilter
|
||||
if err := c.ShouldBindQuery(&filter); err != nil {
|
||||
// ignore
|
||||
}
|
||||
|
||||
clause, binds := filter.ToSQL()
|
||||
|
||||
if clause == "" {
|
||||
cutoff := time.Now().UTC().Add(-30 * 24 * time.Hour)
|
||||
clause = " AND timestamp >= ?"
|
||||
binds = []interface{}{cutoff.Format(time.RFC3339)}
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
strftime('%%Y-%%m-%%d', timestamp) as bucket,
|
||||
COUNT(*) as requests,
|
||||
COALESCE(SUM(total_tokens), 0) as tokens,
|
||||
COALESCE(SUM(cost), 0.0) as cost
|
||||
FROM llm_requests
|
||||
WHERE 1=1 %s
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`, clause)
|
||||
|
||||
var rows []struct {
|
||||
Bucket string `db:"bucket"`
|
||||
Requests int `db:"requests"`
|
||||
Tokens int `db:"tokens"`
|
||||
Cost float64 `db:"cost"`
|
||||
}
|
||||
|
||||
err := s.database.Select(&rows, query, binds...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
series := make([]gin.H, len(rows))
|
||||
for i, r := range rows {
|
||||
series[i] = gin.H{
|
||||
"time": r.Bucket,
|
||||
"requests": r.Requests,
|
||||
"tokens": r.Tokens,
|
||||
"cost": r.Cost,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
||||
"series": series,
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *Server) handleAnalyticsBreakdown(c *gin.Context) {
|
||||
var filter UsagePeriodFilter
|
||||
if err := c.ShouldBindQuery(&filter); err != nil {
|
||||
// ignore
|
||||
}
|
||||
|
||||
clause, binds := filter.ToSQL()
|
||||
|
||||
var models []struct {
|
||||
Label string `db:"label"`
|
||||
Value int `db:"value"`
|
||||
}
|
||||
err := s.database.Select(&models, fmt.Sprintf("SELECT model as label, COUNT(*) as value FROM llm_requests WHERE 1=1 %s GROUP BY model ORDER BY value DESC", clause), binds...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
var clients []struct {
|
||||
Label string `db:"label"`
|
||||
Value int `db:"value"`
|
||||
}
|
||||
err = s.database.Select(&clients, fmt.Sprintf("SELECT client_id as label, COUNT(*) as value FROM llm_requests WHERE 1=1 %s GROUP BY client_id ORDER BY value DESC", clause), binds...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
||||
"models": models,
|
||||
"clients": clients,
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *Server) handleGetClients(c *gin.Context) {
|
||||
var clients []db.Client
|
||||
err := s.database.Select(&clients, "SELECT * FROM clients ORDER BY created_at DESC")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, SuccessResponse(clients))
|
||||
}
|
||||
|
||||
type CreateClientRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
ClientID *string `json:"client_id"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateClient(c *gin.Context) {
|
||||
var req CreateClientRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse("Invalid request"))
|
||||
return
|
||||
}
|
||||
|
||||
clientID := ""
|
||||
if req.ClientID != nil {
|
||||
clientID = *req.ClientID
|
||||
} else {
|
||||
clientID = "client-" + uuid.New().String()[:8]
|
||||
}
|
||||
|
||||
_, err := s.database.Exec("INSERT INTO clients (client_id, name, is_active) VALUES (?, ?, 1)", clientID, req.Name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
token := "sk-" + uuid.New().String() + uuid.New().String()
|
||||
token = token[:51]
|
||||
|
||||
_, err = s.database.Exec("INSERT INTO client_tokens (client_id, token, name) VALUES (?, ?, 'default')", clientID, token)
|
||||
if err != nil {
|
||||
// Log error
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
||||
"id": clientID,
|
||||
"name": req.Name,
|
||||
"status": "active",
|
||||
"token": token,
|
||||
"created_at": time.Now(),
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteClient(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "default" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse("Cannot delete default client"))
|
||||
return
|
||||
}
|
||||
|
||||
_, err := s.database.Exec("DELETE FROM clients WHERE client_id = ?", id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "Client deleted"}))
|
||||
}
|
||||
|
||||
func (s *Server) handleGetClientTokens(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var tokens []db.ClientToken
|
||||
err := s.database.Select(&tokens, "SELECT * FROM client_tokens WHERE client_id = ? ORDER BY created_at DESC", id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
type MaskedToken struct {
|
||||
ID int `json:"id"`
|
||||
TokenMasked string `json:"token_masked"`
|
||||
Name string `json:"name"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
}
|
||||
|
||||
masked := make([]MaskedToken, len(tokens))
|
||||
for i, t := range tokens {
|
||||
maskedToken := "••••"
|
||||
if len(t.Token) > 8 {
|
||||
maskedToken = t.Token[:3] + "••••" + t.Token[len(t.Token)-8:]
|
||||
}
|
||||
masked[i] = MaskedToken{
|
||||
ID: t.ID,
|
||||
TokenMasked: maskedToken,
|
||||
Name: t.Name,
|
||||
IsActive: t.IsActive,
|
||||
CreatedAt: t.CreatedAt,
|
||||
LastUsedAt: t.LastUsedAt,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(masked))
|
||||
}
|
||||
|
||||
type CreateTokenRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateClientToken(c *gin.Context) {
|
||||
clientID := c.Param("id")
|
||||
var req CreateTokenRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
// optional name
|
||||
}
|
||||
|
||||
name := "default"
|
||||
if req.Name != "" {
|
||||
name = req.Name
|
||||
}
|
||||
|
||||
token := "sk-" + uuid.New().String() + uuid.New().String()
|
||||
token = token[:51]
|
||||
|
||||
_, err := s.database.Exec("INSERT INTO client_tokens (client_id, token, name) VALUES (?, ?, ?)", clientID, token, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
||||
"token": token,
|
||||
"name": name,
|
||||
"created_at": time.Now(),
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteClientToken(c *gin.Context) {
|
||||
tokenID := c.Param("token_id")
|
||||
|
||||
_, err := s.database.Exec("DELETE FROM client_tokens WHERE id = ?", tokenID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "Token revoked"}))
|
||||
}
|
||||
|
||||
func (s *Server) handleGetProviders(c *gin.Context) {
|
||||
var dbConfigs []db.ProviderConfig
|
||||
err := s.database.Select(&dbConfigs, "SELECT id, enabled, base_url, credit_balance, low_credit_threshold, billing_mode FROM provider_configs")
|
||||
if err != nil {
|
||||
// Log error
|
||||
}
|
||||
|
||||
dbMap := make(map[string]db.ProviderConfig)
|
||||
for _, cfg := range dbConfigs {
|
||||
dbMap[cfg.ID] = cfg
|
||||
}
|
||||
|
||||
providerIDs := []string{"openai", "gemini", "deepseek", "grok", "ollama"}
|
||||
var result []gin.H
|
||||
|
||||
for _, id := range providerIDs {
|
||||
var name string
|
||||
var enabled bool
|
||||
var baseURL string
|
||||
|
||||
switch id {
|
||||
case "openai":
|
||||
name = "OpenAI"
|
||||
enabled = s.cfg.Providers.OpenAI.Enabled
|
||||
baseURL = s.cfg.Providers.OpenAI.BaseURL
|
||||
case "gemini":
|
||||
name = "Google Gemini"
|
||||
enabled = s.cfg.Providers.Gemini.Enabled
|
||||
baseURL = s.cfg.Providers.Gemini.BaseURL
|
||||
case "deepseek":
|
||||
name = "DeepSeek"
|
||||
enabled = s.cfg.Providers.DeepSeek.Enabled
|
||||
baseURL = s.cfg.Providers.DeepSeek.BaseURL
|
||||
case "grok":
|
||||
name = "xAI Grok"
|
||||
enabled = s.cfg.Providers.Grok.Enabled
|
||||
baseURL = s.cfg.Providers.Grok.BaseURL
|
||||
case "ollama":
|
||||
name = "Ollama"
|
||||
enabled = s.cfg.Providers.Ollama.Enabled
|
||||
baseURL = s.cfg.Providers.Ollama.BaseURL
|
||||
}
|
||||
|
||||
var balance float64
|
||||
var threshold float64 = 5.0
|
||||
var billingMode string
|
||||
|
||||
if dbCfg, ok := dbMap[id]; ok {
|
||||
enabled = dbCfg.Enabled
|
||||
if dbCfg.BaseURL != nil {
|
||||
baseURL = *dbCfg.BaseURL
|
||||
}
|
||||
balance = dbCfg.CreditBalance
|
||||
threshold = dbCfg.LowCreditThreshold
|
||||
if dbCfg.BillingMode != nil {
|
||||
billingMode = *dbCfg.BillingMode
|
||||
}
|
||||
}
|
||||
|
||||
status := "disabled"
|
||||
if enabled {
|
||||
if _, ok := s.providers[id]; ok {
|
||||
status = "online"
|
||||
} else {
|
||||
status = "error"
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, gin.H{
|
||||
"id": id,
|
||||
"name": name,
|
||||
"enabled": enabled,
|
||||
"status": status,
|
||||
"base_url": baseURL,
|
||||
"credit_balance": balance,
|
||||
"low_credit_threshold": threshold,
|
||||
"billing_mode": billingMode,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(result))
|
||||
}
|
||||
|
||||
type UpdateProviderRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
BaseURL *string `json:"base_url"`
|
||||
APIKey *string `json:"api_key"`
|
||||
CreditBalance *float64 `json:"credit_balance"`
|
||||
LowCreditThreshold *float64 `json:"low_credit_threshold"`
|
||||
BillingMode *string `json:"billing_mode"`
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateProvider(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
var req UpdateProviderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse("Invalid request"))
|
||||
return
|
||||
}
|
||||
|
||||
_, err := s.database.Exec(`
|
||||
INSERT INTO provider_configs (id, display_name, enabled, base_url, api_key, credit_balance, low_credit_threshold, billing_mode)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
enabled = excluded.enabled,
|
||||
base_url = COALESCE(excluded.base_url, provider_configs.base_url),
|
||||
api_key = COALESCE(excluded.api_key, provider_configs.api_key),
|
||||
credit_balance = COALESCE(excluded.credit_balance, provider_configs.credit_balance),
|
||||
low_credit_threshold = COALESCE(excluded.low_credit_threshold, provider_configs.low_credit_threshold),
|
||||
billing_mode = COALESCE(excluded.billing_mode, provider_configs.billing_mode),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`, name, strings.ToUpper(name), req.Enabled, req.BaseURL, req.APIKey, req.CreditBalance, req.LowCreditThreshold, req.BillingMode)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "Provider updated"}))
|
||||
}
|
||||
|
||||
func (s *Server) handleGetModels(c *gin.Context) {
|
||||
var models []db.ModelConfig
|
||||
err := s.database.Select(&models, "SELECT * FROM model_configs")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, SuccessResponse(models))
|
||||
}
|
||||
|
||||
func (s *Server) handleGetUsers(c *gin.Context) {
|
||||
var users []db.User
|
||||
err := s.database.Select(&users, "SELECT id, username, display_name, role, must_change_password, created_at FROM users")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, SuccessResponse(users))
|
||||
}
|
||||
|
||||
type CreateUserRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
DisplayName *string `json:"display_name"`
|
||||
Role *string `json:"role"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateUser(c *gin.Context) {
|
||||
var req CreateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse("Invalid request"))
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), 12)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse("Failed to hash password"))
|
||||
return
|
||||
}
|
||||
|
||||
role := "viewer"
|
||||
if req.Role != nil {
|
||||
role = *req.Role
|
||||
}
|
||||
|
||||
_, err = s.database.Exec("INSERT INTO users (username, password_hash, display_name, role, must_change_password) VALUES (?, ?, ?, ?, 1)",
|
||||
req.Username, string(hash), req.DisplayName, role)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "User created"}))
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
DisplayName *string `json:"display_name"`
|
||||
Role *string `json:"role"`
|
||||
Password *string `json:"password"`
|
||||
MustChangePassword *bool `json:"must_change_password"`
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateUser(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req UpdateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse("Invalid request"))
|
||||
return
|
||||
}
|
||||
|
||||
if req.DisplayName != nil {
|
||||
s.database.Exec("UPDATE users SET display_name = ? WHERE id = ?", req.DisplayName, id)
|
||||
}
|
||||
if req.Role != nil {
|
||||
s.database.Exec("UPDATE users SET role = ? WHERE id = ?", req.Role, id)
|
||||
}
|
||||
if req.MustChangePassword != nil {
|
||||
s.database.Exec("UPDATE users SET must_change_password = ? WHERE id = ?", req.MustChangePassword, id)
|
||||
}
|
||||
if req.Password != nil {
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(*req.Password), 12)
|
||||
s.database.Exec("UPDATE users SET password_hash = ? WHERE id = ?", string(hash), id)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "User updated"}))
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteUser(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
session, _ := c.Get("session")
|
||||
if sess, ok := session.(*Session); ok {
|
||||
var username string
|
||||
s.database.Get(&username, "SELECT username FROM users WHERE id = ?", id)
|
||||
if username == sess.Username {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse("Cannot delete your own account"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_, err := s.database.Exec("DELETE FROM users WHERE id = ?", id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "User deleted"}))
|
||||
}
|
||||
|
||||
func (s *Server) handleSystemHealth(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
||||
"status": "ok",
|
||||
"db": "connected",
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"llm-proxy/internal/db"
|
||||
"gophergate/internal/db"
|
||||
)
|
||||
|
||||
type RequestLog struct {
|
||||
@@ -12,6 +12,7 @@ type RequestLog struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
ModelGroup string `json:"model_group,omitempty"`
|
||||
PromptTokens uint32 `json:"prompt_tokens"`
|
||||
CompletionTokens uint32 `json:"completion_tokens"`
|
||||
ReasoningTokens uint32 `json:"reasoning_tokens"`
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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, SuccessResponse(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, logic_level, primary_use)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
group.ID, group.Strategy, group.SelectorModel, group.Targets,
|
||||
group.ComplexityThreshold, group.HeuristicRules, group.LogicLevel, group.PrimaryUse)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
s.refreshRouter()
|
||||
c.JSON(http.StatusCreated, SuccessResponse(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=?, logic_level=?, primary_use=?, updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=?`,
|
||||
group.Strategy, group.SelectorModel, group.Targets,
|
||||
group.ComplexityThreshold, group.HeuristicRules, group.LogicLevel, group.PrimaryUse, id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
s.refreshRouter()
|
||||
c.JSON(http.StatusOK, SuccessResponse(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"})
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"gophergate/internal/db"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (s *Server) handleGetModels(c *gin.Context) {
|
||||
usedOnly := c.Query("used_only") == "true"
|
||||
|
||||
// Registry provider normalized name -> Proxy-internal provider ID
|
||||
allowedRegistryProviders := map[string]string{
|
||||
"openai": "openai",
|
||||
"google": "gemini",
|
||||
"deepseek": "deepseek",
|
||||
"xai": "grok",
|
||||
"ollama": "ollama",
|
||||
"xiaomi": "xiaomi",
|
||||
}
|
||||
|
||||
// Merge registry models with DB overrides
|
||||
var dbModels []db.ModelConfig
|
||||
_ = s.database.Select(&dbModels, "SELECT * FROM model_configs")
|
||||
|
||||
dbMap := make(map[string]db.ModelConfig)
|
||||
for _, m := range dbModels {
|
||||
dbMap[m.ID] = m
|
||||
}
|
||||
|
||||
// Fetch specific (model, provider) combinations that have been used
|
||||
type modelProvider struct {
|
||||
Model string `db:"model"`
|
||||
Provider string `db:"provider"`
|
||||
}
|
||||
usedPairs := make(map[string]bool)
|
||||
if usedOnly {
|
||||
var pairs []modelProvider
|
||||
err := s.database.Select(&pairs, "SELECT DISTINCT model, provider FROM llm_requests WHERE status = 'success'")
|
||||
if err == nil {
|
||||
for _, p := range pairs {
|
||||
usedPairs[fmt.Sprintf("%s:%s", p.Model, p.Provider)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var result []gin.H
|
||||
s.registryMu.RLock()
|
||||
defer s.registryMu.RUnlock()
|
||||
if s.registry != nil {
|
||||
for pID, pInfo := range s.registry.Providers {
|
||||
proxyProvider, allowed := allowedRegistryProviders[pID]
|
||||
if !allowed {
|
||||
continue
|
||||
}
|
||||
|
||||
for mID, mMeta := range pInfo.Models {
|
||||
if usedOnly && !usedPairs[fmt.Sprintf("%s:%s", mID, proxyProvider)] {
|
||||
continue
|
||||
}
|
||||
|
||||
enabled := true
|
||||
promptCost := 0.0
|
||||
completionCost := 0.0
|
||||
var cacheReadCost *float64
|
||||
var cacheWriteCost *float64
|
||||
var mapping *string
|
||||
contextLimit := uint32(0)
|
||||
|
||||
if mMeta.Cost != nil {
|
||||
promptCost = mMeta.Cost.Input
|
||||
completionCost = mMeta.Cost.Output
|
||||
cacheReadCost = mMeta.Cost.CacheRead
|
||||
cacheWriteCost = mMeta.Cost.CacheWrite
|
||||
}
|
||||
if mMeta.Limit != nil {
|
||||
contextLimit = mMeta.Limit.Context
|
||||
}
|
||||
|
||||
// Override from DB
|
||||
if dbCfg, ok := dbMap[mID]; ok {
|
||||
enabled = dbCfg.Enabled
|
||||
if dbCfg.PromptCostPerM != nil {
|
||||
promptCost = *dbCfg.PromptCostPerM
|
||||
}
|
||||
if dbCfg.CompletionCostPerM != nil {
|
||||
completionCost = *dbCfg.CompletionCostPerM
|
||||
}
|
||||
if dbCfg.CacheReadCostPerM != nil {
|
||||
cacheReadCost = dbCfg.CacheReadCostPerM
|
||||
}
|
||||
if dbCfg.CacheWriteCostPerM != nil {
|
||||
cacheWriteCost = dbCfg.CacheWriteCostPerM
|
||||
}
|
||||
mapping = dbCfg.Mapping
|
||||
}
|
||||
|
||||
result = append(result, gin.H{
|
||||
"id": mID,
|
||||
"name": mMeta.Name,
|
||||
"provider": proxyProvider,
|
||||
"enabled": enabled,
|
||||
"prompt_cost": promptCost,
|
||||
"completion_cost": completionCost,
|
||||
"cache_read_cost": cacheReadCost,
|
||||
"cache_write_cost": cacheWriteCost,
|
||||
"context_limit": contextLimit,
|
||||
"mapping": mapping,
|
||||
"tool_call": mMeta.ToolCall != nil && *mMeta.ToolCall,
|
||||
"reasoning": mMeta.Reasoning != nil && *mMeta.Reasoning,
|
||||
"modalities": mMeta.Modalities,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add configured Ollama models if they aren't in registry
|
||||
if s.cfg.Providers.Ollama.Enabled {
|
||||
for _, mID := range s.cfg.Providers.Ollama.Models {
|
||||
// Check if already added from registry
|
||||
exists := false
|
||||
for _, r := range result {
|
||||
if r["id"] == mID {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
|
||||
if usedOnly && !usedPairs[fmt.Sprintf("%s:ollama", mID)] {
|
||||
continue
|
||||
}
|
||||
|
||||
enabled := true
|
||||
promptCost := 0.0
|
||||
completionCost := 0.0
|
||||
var cacheReadCost *float64
|
||||
var cacheWriteCost *float64
|
||||
var mapping *string
|
||||
contextLimit := uint32(0)
|
||||
|
||||
// Override from DB
|
||||
if dbCfg, ok := dbMap[mID]; ok {
|
||||
enabled = dbCfg.Enabled
|
||||
if dbCfg.PromptCostPerM != nil {
|
||||
promptCost = *dbCfg.PromptCostPerM
|
||||
}
|
||||
if dbCfg.CompletionCostPerM != nil {
|
||||
completionCost = *dbCfg.CompletionCostPerM
|
||||
}
|
||||
if dbCfg.CacheReadCostPerM != nil {
|
||||
cacheReadCost = dbCfg.CacheReadCostPerM
|
||||
}
|
||||
if dbCfg.CacheWriteCostPerM != nil {
|
||||
cacheWriteCost = dbCfg.CacheWriteCostPerM
|
||||
}
|
||||
mapping = dbCfg.Mapping
|
||||
}
|
||||
|
||||
result = append(result, gin.H{
|
||||
"id": mID,
|
||||
"name": mID,
|
||||
"provider": "ollama",
|
||||
"enabled": enabled,
|
||||
"prompt_cost": promptCost,
|
||||
"completion_cost": completionCost,
|
||||
"cache_read_cost": cacheReadCost,
|
||||
"cache_write_cost": cacheWriteCost,
|
||||
"context_limit": contextLimit,
|
||||
"modalities": gin.H{"input": []string{"text"}, "output": []string{"text"}},
|
||||
"tool_call": false,
|
||||
"reasoning": false,
|
||||
"mapping": mapping,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(result))
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateModel(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
PromptCost float64 `json:"prompt_cost"`
|
||||
CompletionCost float64 `json:"completion_cost"`
|
||||
CacheReadCost *float64 `json:"cache_read_cost"`
|
||||
CacheWriteCost *float64 `json:"cache_write_cost"`
|
||||
Mapping *string `json:"mapping"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse("Invalid request"))
|
||||
return
|
||||
}
|
||||
|
||||
// Find provider for this model
|
||||
providerID := "unknown"
|
||||
s.registryMu.RLock()
|
||||
if s.registry != nil {
|
||||
for pID, pInfo := range s.registry.Providers {
|
||||
if _, ok := pInfo.Models[id]; ok {
|
||||
providerID = pID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
s.registryMu.RUnlock()
|
||||
|
||||
_, 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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
enabled = excluded.enabled,
|
||||
prompt_cost_per_m = excluded.prompt_cost_per_m,
|
||||
completion_cost_per_m = excluded.completion_cost_per_m,
|
||||
cache_read_cost_per_m = excluded.cache_read_cost_per_m,
|
||||
cache_write_cost_per_m = excluded.cache_write_cost_per_m,
|
||||
mapping = excluded.mapping,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`, id, providerID, req.Enabled, req.PromptCost, req.CompletionCost, req.CacheReadCost, req.CacheWriteCost, req.Mapping)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "Model updated"}))
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gophergate/internal/db"
|
||||
"gophergate/internal/models"
|
||||
"gophergate/internal/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (s *Server) handleGetProviders(c *gin.Context) {
|
||||
var dbConfigs []db.ProviderConfig
|
||||
err := s.database.Select(&dbConfigs, "SELECT id, enabled, base_url, credit_balance, low_credit_threshold, billing_mode FROM provider_configs")
|
||||
if err != nil {
|
||||
// Log error
|
||||
}
|
||||
|
||||
dbMap := make(map[string]db.ProviderConfig)
|
||||
for _, cfg := range dbConfigs {
|
||||
dbMap[cfg.ID] = cfg
|
||||
}
|
||||
|
||||
providerIDs := []string{"openai", "gemini", "deepseek", "moonshot", "grok", "ollama", "xiaomi"}
|
||||
var result []gin.H
|
||||
|
||||
for _, id := range providerIDs {
|
||||
var name string
|
||||
var enabled bool
|
||||
var baseURL string
|
||||
|
||||
switch id {
|
||||
case "openai":
|
||||
name = "OpenAI"
|
||||
enabled = s.cfg.Providers.OpenAI.Enabled
|
||||
baseURL = s.cfg.Providers.OpenAI.BaseURL
|
||||
case "gemini":
|
||||
name = "Google Gemini"
|
||||
enabled = s.cfg.Providers.Gemini.Enabled
|
||||
baseURL = s.cfg.Providers.Gemini.BaseURL
|
||||
case "deepseek":
|
||||
name = "DeepSeek"
|
||||
enabled = s.cfg.Providers.DeepSeek.Enabled
|
||||
baseURL = s.cfg.Providers.DeepSeek.BaseURL
|
||||
case "moonshot":
|
||||
name = "Moonshot"
|
||||
enabled = s.cfg.Providers.Moonshot.Enabled
|
||||
baseURL = s.cfg.Providers.Moonshot.BaseURL
|
||||
case "grok":
|
||||
name = "xAI Grok"
|
||||
enabled = s.cfg.Providers.Grok.Enabled
|
||||
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":
|
||||
name = "Ollama"
|
||||
enabled = s.cfg.Providers.Ollama.Enabled
|
||||
baseURL = s.cfg.Providers.Ollama.BaseURL
|
||||
}
|
||||
|
||||
var balance float64
|
||||
var threshold float64 = 5.0
|
||||
var billingMode string
|
||||
|
||||
if dbCfg, ok := dbMap[id]; ok {
|
||||
enabled = dbCfg.Enabled
|
||||
if dbCfg.BaseURL != nil {
|
||||
baseURL = *dbCfg.BaseURL
|
||||
}
|
||||
balance = dbCfg.CreditBalance
|
||||
threshold = dbCfg.LowCreditThreshold
|
||||
if dbCfg.BillingMode != nil {
|
||||
billingMode = *dbCfg.BillingMode
|
||||
}
|
||||
}
|
||||
|
||||
status := "disabled"
|
||||
if enabled {
|
||||
if _, ok := s.getProvider(id); ok {
|
||||
status = "online"
|
||||
} else {
|
||||
status = "error"
|
||||
}
|
||||
}
|
||||
|
||||
// Get last used for this provider
|
||||
var lastUsedStr string
|
||||
_ = s.database.Get(&lastUsedStr, "SELECT MAX(timestamp) FROM llm_requests WHERE provider = ?", id)
|
||||
var lastUsed interface{}
|
||||
if lastUsedStr != "" {
|
||||
if t, err := time.Parse("2006-01-02 15:04:05", lastUsedStr); err == nil {
|
||||
lastUsed = t
|
||||
}
|
||||
}
|
||||
|
||||
// Get models for this provider from registry
|
||||
var models []string
|
||||
s.registryMu.RLock()
|
||||
if s.registry != nil {
|
||||
registryID := id
|
||||
if id == "gemini" {
|
||||
registryID = "google"
|
||||
}
|
||||
if id == "moonshot" {
|
||||
registryID = "moonshot"
|
||||
}
|
||||
if id == "grok" {
|
||||
registryID = "xai"
|
||||
}
|
||||
if id == "xiaomi" {
|
||||
registryID = "xiaomi"
|
||||
}
|
||||
|
||||
if pInfo, ok := s.registry.Providers[registryID]; ok {
|
||||
for mID := range pInfo.Models {
|
||||
models = append(models, mID)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.registryMu.RUnlock()
|
||||
|
||||
// If it's ollama, also include models from config
|
||||
if id == "ollama" {
|
||||
models = append(models, s.cfg.Providers.Ollama.Models...)
|
||||
}
|
||||
|
||||
result = append(result, gin.H{
|
||||
"id": id,
|
||||
"name": name,
|
||||
"enabled": enabled,
|
||||
"status": status,
|
||||
"base_url": baseURL,
|
||||
"credit_balance": balance,
|
||||
"low_credit_threshold": threshold,
|
||||
"billing_mode": billingMode,
|
||||
"last_used": lastUsed,
|
||||
"models": models,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(result))
|
||||
}
|
||||
|
||||
type UpdateProviderRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
BaseURL *string `json:"base_url"`
|
||||
APIKey *string `json:"api_key"`
|
||||
CreditBalance *float64 `json:"credit_balance"`
|
||||
LowCreditThreshold *float64 `json:"low_credit_threshold"`
|
||||
BillingMode *string `json:"billing_mode"`
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateProvider(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
var req UpdateProviderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse("Invalid request"))
|
||||
return
|
||||
}
|
||||
|
||||
apiKeyEncrypted := false
|
||||
var apiKey *string = req.APIKey
|
||||
if req.APIKey != nil && *req.APIKey != "" {
|
||||
encrypted, err := utils.Encrypt(*req.APIKey, s.cfg.KeyBytes)
|
||||
if err == nil {
|
||||
apiKey = &encrypted
|
||||
apiKeyEncrypted = true
|
||||
}
|
||||
}
|
||||
|
||||
_, err := s.database.Exec(`
|
||||
INSERT INTO provider_configs (id, display_name, enabled, base_url, api_key, credit_balance, low_credit_threshold, billing_mode, api_key_encrypted)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
enabled = excluded.enabled,
|
||||
base_url = COALESCE(excluded.base_url, provider_configs.base_url),
|
||||
api_key = COALESCE(excluded.api_key, provider_configs.api_key),
|
||||
api_key_encrypted = excluded.api_key_encrypted,
|
||||
credit_balance = COALESCE(excluded.credit_balance, provider_configs.credit_balance),
|
||||
low_credit_threshold = COALESCE(excluded.low_credit_threshold, provider_configs.low_credit_threshold),
|
||||
billing_mode = COALESCE(excluded.billing_mode, provider_configs.billing_mode),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`, name, strings.ToUpper(name), req.Enabled, req.BaseURL, apiKey, req.CreditBalance, req.LowCreditThreshold, req.BillingMode, apiKeyEncrypted)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh in-memory providers
|
||||
if err := s.RefreshProviders(); err != nil {
|
||||
fmt.Printf("Error refreshing providers: %v\n", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "Provider updated"}))
|
||||
}
|
||||
|
||||
func (s *Server) handleTestProvider(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
provider, ok := s.getProvider(name)
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, ErrorResponse(fmt.Sprintf("Provider %s not found or not enabled", name)))
|
||||
return
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// Prepare a simple test request
|
||||
testReq := &models.UnifiedRequest{
|
||||
Model: "gpt-4o-mini", // Default cheap test model
|
||||
Messages: []models.UnifiedMessage{
|
||||
{
|
||||
Role: "user",
|
||||
Content: []models.UnifiedContentPart{{Type: "text", Text: "Hi"}},
|
||||
},
|
||||
},
|
||||
MaxTokens: new(uint32),
|
||||
}
|
||||
*testReq.MaxTokens = 5
|
||||
|
||||
// Adjust model for non-openai providers
|
||||
if name == "gemini" {
|
||||
testReq.Model = "gemini-2.5-flash"
|
||||
} else if name == "deepseek" {
|
||||
testReq.Model = "deepseek-chat"
|
||||
} else if name == "moonshot" {
|
||||
testReq.Model = "kimi-k2.7-code"
|
||||
} else if name == "grok" {
|
||||
testReq.Model = "grok-4-1-fast-non-reasoning"
|
||||
} else if name == "xiaomi" {
|
||||
testReq.Model = "mimo-v2.5"
|
||||
}
|
||||
|
||||
_, err := provider.ChatCompletion(c.Request.Context(), testReq)
|
||||
latency := time.Since(startTime).Milliseconds()
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, ErrorResponse(fmt.Sprintf("Provider test failed: %v", err)))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
||||
"message": "Connection test successful",
|
||||
"latency": latency,
|
||||
}))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+923
-51
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
type Session struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
@@ -31,6 +32,7 @@ type SessionManager struct {
|
||||
type sessionPayload struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
@@ -43,7 +45,7 @@ func NewSessionManager(secret []byte, ttl time.Duration) *SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *SessionManager) CreateSession(username, role string) (string, error) {
|
||||
func (m *SessionManager) CreateSession(username, displayName, role string) (string, error) {
|
||||
sessionID := uuid.New().String()
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(m.ttl)
|
||||
@@ -51,6 +53,7 @@ func (m *SessionManager) CreateSession(username, role string) (string, error) {
|
||||
m.mu.Lock()
|
||||
m.sessions[sessionID] = Session{
|
||||
Username: username,
|
||||
DisplayName: displayName,
|
||||
Role: role,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: expiresAt,
|
||||
@@ -58,13 +61,14 @@ func (m *SessionManager) CreateSession(username, role string) (string, error) {
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
return m.createSignedToken(sessionID, username, role, expiresAt.Unix())
|
||||
return m.createSignedToken(sessionID, username, displayName, role, expiresAt.Unix())
|
||||
}
|
||||
|
||||
func (m *SessionManager) createSignedToken(sessionID, username, role string, exp int64) (string, error) {
|
||||
func (m *SessionManager) createSignedToken(sessionID, username, displayName, role string, exp int64) (string, error) {
|
||||
payload := sessionPayload{
|
||||
SessionID: sessionID,
|
||||
Username: username,
|
||||
DisplayName: displayName,
|
||||
Role: role,
|
||||
Exp: exp,
|
||||
}
|
||||
@@ -129,23 +133,41 @@ func (m *SessionManager) ValidateSession(token string) (*Session, string, error)
|
||||
return &session, "", nil
|
||||
}
|
||||
|
||||
func (m *SessionManager) RevokeSession(token string) {
|
||||
func (m *SessionManager) RevokeSession(token string) error {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 2 {
|
||||
return
|
||||
return fmt.Errorf("invalid token format")
|
||||
}
|
||||
|
||||
payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return
|
||||
return fmt.Errorf("failed to decode payload: %w", err)
|
||||
}
|
||||
|
||||
var payload sessionPayload
|
||||
if err := json.Unmarshal(payloadJSON, &payload); err != nil {
|
||||
return
|
||||
return fmt.Errorf("failed to parse payload: %w", err)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
delete(m.sessions, payload.SessionID)
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartCleanup runs a background goroutine that removes expired sessions every 15 minutes.
|
||||
func (m *SessionManager) StartCleanup() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(15 * time.Minute)
|
||||
for range ticker.C {
|
||||
m.mu.Lock()
|
||||
now := time.Now()
|
||||
for id, s := range m.sessions {
|
||||
if now.After(s.ExpiresAt) {
|
||||
delete(m.sessions, id)
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gophergate/internal/db"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/shirou/gopsutil/v3/cpu"
|
||||
"github.com/shirou/gopsutil/v3/disk"
|
||||
"github.com/shirou/gopsutil/v3/load"
|
||||
"github.com/shirou/gopsutil/v3/mem"
|
||||
"github.com/shirou/gopsutil/v3/process"
|
||||
)
|
||||
|
||||
func (s *Server) handleSystemHealth(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
||||
"status": "ok",
|
||||
"components": gin.H{
|
||||
"database": "online",
|
||||
"proxy": "online",
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *Server) handleSystemMetrics(c *gin.Context) {
|
||||
v, _ := mem.VirtualMemory()
|
||||
c_usage, _ := cpu.Percent(time.Second, false)
|
||||
d, _ := disk.Usage("/")
|
||||
l, _ := load.Avg()
|
||||
p, _ := process.NewProcess(int32(os.Getpid()))
|
||||
rss, _ := p.MemoryInfo()
|
||||
|
||||
cpuPercent := 0.0
|
||||
if len(c_usage) > 0 {
|
||||
cpuPercent = c_usage[0]
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
||||
"cpu": gin.H{
|
||||
"usage_percent": fmt.Sprintf("%.1f", cpuPercent),
|
||||
"load_average": []float64{l.Load1, l.Load5, l.Load15},
|
||||
},
|
||||
"memory": gin.H{
|
||||
"used_mb": v.Used / 1024 / 1024,
|
||||
"total_mb": v.Total / 1024 / 1024,
|
||||
"usage_percent": fmt.Sprintf("%.1f", v.UsedPercent),
|
||||
"process_rss_mb": rss.RSS / 1024 / 1024,
|
||||
},
|
||||
"disk": gin.H{
|
||||
"used_gb": float64(d.Used) / 1024 / 1024 / 1024,
|
||||
"total_gb": float64(d.Total) / 1024 / 1024 / 1024,
|
||||
"usage_percent": fmt.Sprintf("%.1f", d.UsedPercent),
|
||||
},
|
||||
"connections": gin.H{
|
||||
"db_active": s.database.Stats().OpenConnections,
|
||||
"websocket_listeners": s.hub.GetClientCount(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *Server) handleGetSettings(c *gin.Context) {
|
||||
providerCount := 0
|
||||
modelCount := 0
|
||||
s.registryMu.RLock()
|
||||
if s.registry != nil {
|
||||
providerCount = len(s.registry.Providers)
|
||||
for _, p := range s.registry.Providers {
|
||||
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{
|
||||
"server": gin.H{
|
||||
"version": "1.0.0-go",
|
||||
"auth_tokens": maskedTokens,
|
||||
},
|
||||
"database": gin.H{
|
||||
"type": "sqlite",
|
||||
"path": s.cfg.Database.Path,
|
||||
},
|
||||
"registry": gin.H{
|
||||
"provider_count": providerCount,
|
||||
"model_count": modelCount,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateBackup(c *gin.Context) {
|
||||
// Simplified backup response
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
||||
"backup_id": fmt.Sprintf("backup-%d.db", time.Now().Unix()),
|
||||
"status": "created",
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *Server) handleGetLogs(c *gin.Context) {
|
||||
var logs []db.LLMRequest
|
||||
err := s.database.Select(&logs, "SELECT * FROM llm_requests ORDER BY timestamp DESC LIMIT 100")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Format for UI
|
||||
type UILog struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
ClientID string `json:"client_id"`
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
Tokens int `json:"tokens"`
|
||||
Status string `json:"status"`
|
||||
Duration int `json:"duration"`
|
||||
}
|
||||
|
||||
uiLogs := make([]UILog, len(logs))
|
||||
for i, l := range logs {
|
||||
clientID := "unknown"
|
||||
if l.ClientID != nil {
|
||||
clientID = *l.ClientID
|
||||
}
|
||||
provider := "unknown"
|
||||
if l.Provider != nil {
|
||||
provider = *l.Provider
|
||||
}
|
||||
model := "unknown"
|
||||
if l.Model != nil {
|
||||
model = *l.Model
|
||||
}
|
||||
tokens := 0
|
||||
if l.TotalTokens != nil {
|
||||
tokens = *l.TotalTokens
|
||||
}
|
||||
duration := 0
|
||||
if l.DurationMS != nil {
|
||||
duration = *l.DurationMS
|
||||
}
|
||||
|
||||
uiLogs[i] = UILog{
|
||||
Timestamp: l.Timestamp.Format(time.RFC3339),
|
||||
ClientID: clientID,
|
||||
Provider: provider,
|
||||
Model: model,
|
||||
Tokens: tokens,
|
||||
Status: l.Status,
|
||||
Duration: duration,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(uiLogs))
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gophergate/internal/db"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func (s *Server) handleGetUsers(c *gin.Context) {
|
||||
var users []db.User
|
||||
err := s.database.Select(&users, "SELECT id, username, display_name, role, must_change_password, created_at FROM users")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, SuccessResponse(users))
|
||||
}
|
||||
|
||||
type CreateUserRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
DisplayName *string `json:"display_name"`
|
||||
Role *string `json:"role"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateUser(c *gin.Context) {
|
||||
var req CreateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse("Invalid request"))
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), 12)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse("Failed to hash password"))
|
||||
return
|
||||
}
|
||||
|
||||
role := "viewer"
|
||||
if req.Role != nil {
|
||||
role = *req.Role
|
||||
}
|
||||
|
||||
_, err = s.database.Exec("INSERT INTO users (username, password_hash, display_name, role, must_change_password) VALUES (?, ?, ?, ?, 1)",
|
||||
req.Username, string(hash), req.DisplayName, role)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "User created"}))
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
DisplayName *string `json:"display_name"`
|
||||
Role *string `json:"role"`
|
||||
Password *string `json:"password"`
|
||||
MustChangePassword *bool `json:"must_change_password"`
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateUser(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req UpdateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse("Invalid request"))
|
||||
return
|
||||
}
|
||||
|
||||
if req.DisplayName != nil {
|
||||
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 _, 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 _, 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 {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), 12)
|
||||
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"}))
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteUser(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
session, _ := c.Get("session")
|
||||
if sess, ok := session.(*Session); ok {
|
||||
var username string
|
||||
s.database.Get(&username, "SELECT username FROM users WHERE id = ?", id)
|
||||
if username == sess.Username {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse("Cannot delete your own account"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_, err := s.database.Exec("DELETE FROM users WHERE id = ?", id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "User deleted"}))
|
||||
}
|
||||
@@ -4,18 +4,25 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
func newUpgrader(allowedOrigin string) websocket.Upgrader {
|
||||
return websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // In production, refine this
|
||||
if allowedOrigin == "*" {
|
||||
return true
|
||||
}
|
||||
origin := r.Header.Get("Origin")
|
||||
return origin == "" || origin == allowedOrigin
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type Hub struct {
|
||||
clients map[*websocket.Conn]bool
|
||||
@@ -23,6 +30,7 @@ type Hub struct {
|
||||
register chan *websocket.Conn
|
||||
unregister chan *websocket.Conn
|
||||
mu sync.Mutex
|
||||
clientCount int32
|
||||
}
|
||||
|
||||
func NewHub() *Hub {
|
||||
@@ -40,6 +48,7 @@ func (h *Hub) Run() {
|
||||
case client := <-h.register:
|
||||
h.mu.Lock()
|
||||
h.clients[client] = true
|
||||
atomic.AddInt32(&h.clientCount, 1)
|
||||
h.mu.Unlock()
|
||||
log.Println("WebSocket client registered")
|
||||
case client := <-h.unregister:
|
||||
@@ -47,6 +56,7 @@ func (h *Hub) Run() {
|
||||
if _, ok := h.clients[client]; ok {
|
||||
delete(h.clients, client)
|
||||
client.Close()
|
||||
atomic.AddInt32(&h.clientCount, -1)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
log.Println("WebSocket client unregistered")
|
||||
@@ -58,6 +68,7 @@ func (h *Hub) Run() {
|
||||
log.Printf("WebSocket error: %v", err)
|
||||
client.Close()
|
||||
delete(h.clients, client)
|
||||
atomic.AddInt32(&h.clientCount, -1)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
@@ -65,7 +76,16 @@ func (h *Hub) Run() {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) GetClientCount() int {
|
||||
return int(atomic.LoadInt32(&h.clientCount))
|
||||
}
|
||||
|
||||
func (s *Server) handleWebSocket(c *gin.Context) {
|
||||
allowedOrigin := s.cfg.Server.WSAllowedOrigin
|
||||
if allowedOrigin == "" {
|
||||
allowedOrigin = "*"
|
||||
}
|
||||
upgrader := newUpgrader(allowedOrigin)
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
log.Printf("Failed to set websocket upgrade: %v", err)
|
||||
@@ -81,7 +101,7 @@ func (s *Server) handleWebSocket(c *gin.Context) {
|
||||
// Initial message
|
||||
conn.WriteJSON(gin.H{
|
||||
"type": "connected",
|
||||
"message": "Connected to LLM Proxy Dashboard",
|
||||
"message": "Connected to GopherGate Dashboard",
|
||||
})
|
||||
|
||||
for {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Encrypt encrypts plain text using AES-GCM with the given 32-byte key.
|
||||
func Encrypt(plainText string, key []byte) (string, error) {
|
||||
if len(key) != 32 {
|
||||
return "", fmt.Errorf("encryption key must be 32 bytes")
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// The nonce should be prepended to the ciphertext
|
||||
cipherText := gcm.Seal(nonce, nonce, []byte(plainText), nil)
|
||||
return base64.StdEncoding.EncodeToString(cipherText), nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts base64-encoded cipher text using AES-GCM with the given 32-byte key.
|
||||
func Decrypt(encodedCipherText string, key []byte) (string, error) {
|
||||
if len(key) != 32 {
|
||||
return "", fmt.Errorf("encryption key must be 32 bytes")
|
||||
}
|
||||
|
||||
cipherText, err := base64.StdEncoding.DecodeString(encodedCipherText)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(cipherText) < nonceSize {
|
||||
return "", fmt.Errorf("cipher text too short")
|
||||
}
|
||||
|
||||
nonce, actualCipherText := cipherText[:nonceSize], cipherText[nonceSize:]
|
||||
plainText, err := gcm.Open(nil, nonce, actualCipherText, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(plainText), nil
|
||||
}
|
||||
+57
-10
@@ -6,41 +6,81 @@ import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"llm-proxy/internal/models"
|
||||
"github.com/go-resty/resty/v2"
|
||||
"gophergate/internal/models"
|
||||
)
|
||||
|
||||
const ModelsDevURL = "https://models.dev/api.json"
|
||||
|
||||
func FetchRegistry() (*models.ModelRegistry, error) {
|
||||
log.Printf("Fetching model registry from %s", ModelsDevURL)
|
||||
|
||||
client := resty.New().SetTimeout(10 * time.Second)
|
||||
resp, err := client.R().Get(ModelsDevURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch registry: %w", err)
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
if attempt > 0 {
|
||||
backoff := time.Duration(1<<attempt) * time.Second
|
||||
time.Sleep(backoff)
|
||||
}
|
||||
|
||||
resp, err := client.R().Get(ModelsDevURL)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("attempt %d: %w", attempt+1, err)
|
||||
continue
|
||||
}
|
||||
if !resp.IsSuccess() {
|
||||
return nil, fmt.Errorf("failed to fetch registry: HTTP %d", resp.StatusCode())
|
||||
lastErr = fmt.Errorf("attempt %d: HTTP %d", attempt+1, resp.StatusCode())
|
||||
continue
|
||||
}
|
||||
|
||||
var providers map[string]models.ProviderInfo
|
||||
if err := json.Unmarshal(resp.Body(), &providers); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal registry: %w", err)
|
||||
lastErr = fmt.Errorf("attempt %d: unmarshal: %w", attempt+1, err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Println("Successfully loaded model registry")
|
||||
return &models.ModelRegistry{Providers: providers}, nil
|
||||
}
|
||||
|
||||
func CalculateCost(registry *models.ModelRegistry, modelID string, promptTokens, completionTokens, cacheRead, cacheWrite uint32) float64 {
|
||||
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 {
|
||||
meta := registry.FindModel(modelID)
|
||||
if meta == nil || meta.Cost == nil {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
cost := (float64(promptTokens) * meta.Cost.Input / 1000000.0) +
|
||||
// promptTokens is usually the TOTAL prompt size.
|
||||
// We subtract cacheRead from it to get the uncached part.
|
||||
uncachedTokens := promptTokens
|
||||
if cacheRead > 0 {
|
||||
if cacheRead > promptTokens {
|
||||
uncachedTokens = 0
|
||||
} else {
|
||||
uncachedTokens = promptTokens - cacheRead
|
||||
}
|
||||
}
|
||||
|
||||
cost := (float64(uncachedTokens) * meta.Cost.Input / 1000000.0) +
|
||||
(float64(completionTokens) * meta.Cost.Output / 1000000.0)
|
||||
|
||||
if meta.Cost.CacheRead != nil {
|
||||
@@ -50,5 +90,12 @@ func CalculateCost(registry *models.ModelRegistry, modelID string, promptTokens,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gophergate/internal/models"
|
||||
)
|
||||
|
||||
func TestCalculateCost_NotFound(t *testing.T) {
|
||||
r := &models.ModelRegistry{Providers: make(map[string]models.ProviderInfo)}
|
||||
cost := CalculateCost(r, "unknown-model", 100, 50, 0, 0, 0)
|
||||
if cost != 0.0 {
|
||||
t.Fatalf("expected 0 cost for unknown model, got %f", cost)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateCost_KnownModel(t *testing.T) {
|
||||
inputCost := 2.5 // $2.50 per 1M tokens
|
||||
outputCost := 10.0 // $10.00 per 1M tokens
|
||||
r := &models.ModelRegistry{
|
||||
Providers: map[string]models.ProviderInfo{
|
||||
"openai": {
|
||||
Models: map[string]models.ModelMetadata{
|
||||
"gpt-4o": {
|
||||
Cost: &models.ModelCost{
|
||||
Input: inputCost,
|
||||
Output: outputCost,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cost := CalculateCost(r, "gpt-4o", 1000, 500, 0, 0, 0)
|
||||
expected := (1000 * inputCost / 1000000.0) + (500 * outputCost / 1000000.0)
|
||||
if cost != expected {
|
||||
t.Fatalf("expected %f, got %f", expected, cost)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
-- Migration: add billing_mode to provider_configs
|
||||
-- Adds a billing_mode TEXT column with default 'prepaid'
|
||||
-- After applying, set Gemini to postpaid with:
|
||||
-- UPDATE provider_configs SET billing_mode = 'postpaid' WHERE id = 'gemini';
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
ALTER TABLE provider_configs ADD COLUMN billing_mode TEXT DEFAULT 'prepaid';
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- NOTE: If you use a production SQLite file, run the following to set Gemini to postpaid:
|
||||
-- sqlite3 /path/to/db.sqlite "UPDATE provider_configs SET billing_mode='postpaid' WHERE id='gemini';"
|
||||
@@ -1,13 +0,0 @@
|
||||
-- Migration: add composite indexes for query performance
|
||||
-- Adds three composite indexes:
|
||||
-- 1. idx_llm_requests_client_timestamp on llm_requests(client_id, timestamp)
|
||||
-- 2. idx_llm_requests_provider_timestamp on llm_requests(provider, timestamp)
|
||||
-- 3. idx_model_configs_provider_id on model_configs(provider_id)
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_requests_client_timestamp ON llm_requests(client_id, timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_requests_provider_timestamp ON llm_requests(provider, timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_configs_provider_id ON model_configs(provider_id);
|
||||
|
||||
COMMIT;
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
[2m2026-03-06T20:07:36.737914Z[0m [32m INFO[0m Starting LLM Proxy Gateway v0.1.0
|
||||
[2m2026-03-06T20:07:36.738903Z[0m [32m INFO[0m Configuration loaded from Some("/home/newkirk/Documents/projects/web_projects/llm-proxy/config.toml")
|
||||
[2m2026-03-06T20:07:36.738945Z[0m [32m INFO[0m Encryption initialized
|
||||
[2m2026-03-06T20:07:36.739124Z[0m [32m INFO[0m Connecting to database at ./data/llm_proxy.db
|
||||
[2m2026-03-06T20:07:36.753254Z[0m [32m INFO[0m Database migrations completed
|
||||
[2m2026-03-06T20:07:36.753294Z[0m [32m INFO[0m Database initialized at "./data/llm_proxy.db"
|
||||
[2m2026-03-06T20:07:36.755187Z[0m [32m INFO[0m Fetching model registry from https://models.dev/api.json
|
||||
[2m2026-03-06T20:07:37.000853Z[0m [32m INFO[0m Successfully loaded model registry
|
||||
[2m2026-03-06T20:07:37.001382Z[0m [32m INFO[0m Model config cache initialized
|
||||
[2m2026-03-06T20:07:37.001702Z[0m [33m WARN[0m SESSION_SECRET environment variable not set. Using a randomly generated secret. This will invalidate all sessions on restart. Set SESSION_SECRET to a fixed hex or base64 encoded 32-byte value.
|
||||
[2m2026-03-06T20:07:37.002898Z[0m [32m INFO[0m Server listening on http://0.0.0.0:8082
|
||||
@@ -1 +0,0 @@
|
||||
945904
|
||||
+305
-16
@@ -148,22 +148,54 @@ body {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 1.25rem;
|
||||
border-radius: 16px;
|
||||
background: var(--bg2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--orange);
|
||||
font-size: 2rem;
|
||||
background: rgba(254, 128, 25, 0.15);
|
||||
color: var(--primary);
|
||||
border-radius: 12px;
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
/* GopherGate Logo Icon */
|
||||
.logo-icon-container {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: var(--blue-light);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--shadow);
|
||||
border: 2px solid var(--fg1);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.logo-icon-container.small {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.logo-icon-text {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 700;
|
||||
color: var(--bg0);
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.logo-icon-container.small .logo-icon-text {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 1.75rem;
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
color: var(--fg0);
|
||||
color: var(--primary-light);
|
||||
margin-bottom: 0.5rem;
|
||||
letter-spacing: -0.025em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
@@ -297,6 +329,25 @@ body {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
vertical-align: baseline;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.badge-success { background-color: rgba(152, 151, 26, 0.15); color: var(--green-light); border: 1px solid var(--green); }
|
||||
.badge-info { background-color: rgba(69, 133, 136, 0.15); color: var(--blue-light); border: 1px solid var(--blue); }
|
||||
.badge-warning { background-color: rgba(215, 153, 33, 0.15); color: var(--yellow-light); border: 1px solid var(--yellow); }
|
||||
.badge-danger { background-color: rgba(204, 36, 29, 0.15); color: var(--red-light); border: 1px solid var(--red); }
|
||||
.badge-client { background-color: var(--bg2); color: var(--fg1); border: 1px solid var(--bg3); padding: 2px 6px; font-size: 0.7rem; text-transform: uppercase; }
|
||||
|
||||
/* Responsive Login */
|
||||
@media (max-width: 480px) {
|
||||
.login-card {
|
||||
@@ -375,11 +426,15 @@ body {
|
||||
}
|
||||
|
||||
.sidebar.collapsed .logo {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .logo span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-toggle {
|
||||
opacity: 1;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
@@ -394,6 +449,7 @@ body {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
.sidebar-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -588,17 +644,48 @@ body {
|
||||
|
||||
/* Main Content Area */
|
||||
.main-content {
|
||||
margin-left: 260px;
|
||||
padding-left: 260px;
|
||||
flex: 1;
|
||||
min-height: 100vh;
|
||||
transition: all 0.3s;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.sidebar.collapsed ~ .main-content {
|
||||
margin-left: 80px;
|
||||
.sidebar.collapsed + .main-content {
|
||||
padding-left: 80px;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
height: 70px;
|
||||
background: var(--bg0);
|
||||
border-bottom: 1px solid var(--bg2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--spacing-xl);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.top-bar .page-title h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--fg0);
|
||||
}
|
||||
|
||||
.top-bar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.content-body {
|
||||
padding: var(--spacing-xl);
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.top-nav {
|
||||
@@ -665,21 +752,34 @@ body {
|
||||
/* Stat Cards */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1400px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 900px) and (max-width: 1399px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg1);
|
||||
padding: var(--spacing-lg);
|
||||
padding: 1rem 1.1rem;
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid var(--bg2);
|
||||
box-shadow: var(--shadow-sm);
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
gap: 0.85rem;
|
||||
align-items: center;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
@@ -1047,6 +1147,53 @@ body {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Connection Status Indicator */
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
background: var(--bg1);
|
||||
border: 1px solid var(--bg3);
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--fg3);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--fg4);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background: var(--green-light);
|
||||
box-shadow: 0 0 0 0 rgba(184, 187, 38, 0.4);
|
||||
animation: status-pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background: var(--red-light);
|
||||
}
|
||||
|
||||
.status-dot.connecting {
|
||||
background: var(--yellow-light);
|
||||
}
|
||||
|
||||
.status-dot.error {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
@keyframes status-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(184, 187, 38, 0.4); }
|
||||
70% { box-shadow: 0 0 0 6px rgba(184, 187, 38, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(184, 187, 38, 0); }
|
||||
}
|
||||
|
||||
/* WebSocket Dot Pulse */
|
||||
@keyframes ws-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(184, 187, 38, 0.4); }
|
||||
@@ -1248,8 +1395,150 @@ body {
|
||||
border: 1px solid var(--bg2);
|
||||
}
|
||||
|
||||
/* Settings: Warning Card */
|
||||
/* Warning Card */
|
||||
.warning-card {
|
||||
border: 1px dashed var(--warning);
|
||||
background: rgba(215, 153, 33, 0.08);
|
||||
}
|
||||
|
||||
/* ==================== MOBILE BAREBONES ==================== */
|
||||
@media (max-width: 767px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
width: 280px;
|
||||
z-index: 2000;
|
||||
}
|
||||
.sidebar.mobile-visible {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.sidebar.collapsed {
|
||||
width: 280px;
|
||||
}
|
||||
.sidebar.collapsed .menu-item span,
|
||||
.sidebar.collapsed .menu-title,
|
||||
.sidebar.collapsed .user-details {
|
||||
display: block;
|
||||
}
|
||||
.sidebar.collapsed .menu-item {
|
||||
justify-content: flex-start;
|
||||
padding: 0.75rem var(--spacing-lg);
|
||||
}
|
||||
.sidebar.collapsed .menu-item i {
|
||||
font-size: inherit;
|
||||
}
|
||||
.sidebar.collapsed .sidebar-footer {
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.sidebar-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
z-index: 1999;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.sidebar-backdrop.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
.mobile-menu-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg1);
|
||||
border: 1px solid var(--bg3);
|
||||
color: var(--fg1);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mobile-menu-btn:active {
|
||||
background: var(--bg2);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.grid-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.grid-3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 280px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.content-body {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
.top-bar {
|
||||
padding: 0 var(--spacing-md);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.top-bar .page-title h2 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.top-bar-actions {
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.status-indicator .status-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.monitoring-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.card-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
padding: 2rem 1.25rem;
|
||||
}
|
||||
|
||||
.period-selector {
|
||||
overflow-x: auto;
|
||||
flex-wrap: nowrap;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 95%;
|
||||
margin: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.mobile-menu-btn { display: inline-flex; }
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.mobile-menu-btn { display: none; }
|
||||
.sidebar-backdrop { display: none; }
|
||||
}
|
||||
|
||||
/* ponytail: single-breakpoint responsive layer, no card-based table fallback
|
||||
add table→card reflow when tables get too wide to scroll horizontally */
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1002 B |
+96
-92
@@ -3,49 +3,38 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LLM Proxy Gateway - Admin Dashboard</title>
|
||||
<link rel="stylesheet" href="/css/dashboard.css?v=11">
|
||||
<title>GopherGate - Admin Dashboard</title>
|
||||
<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="icon" href="img/logo-icon.png" type="image/png" sizes="any">
|
||||
<link rel="apple-touch-icon" href="img/logo-icon.png">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/luxon@3.4.4/build/global/luxon.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Login Screen -->
|
||||
<div id="login-screen" class="login-container">
|
||||
<body class="gruvbox-dark">
|
||||
<!-- Auth Page -->
|
||||
<div id="auth-page" class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<i class="fas fa-terminal login-logo-fallback"></i>
|
||||
<h1>LLM Proxy Gateway</h1>
|
||||
<p class="login-subtitle">Admin Dashboard</p>
|
||||
<div class="logo-icon-container">
|
||||
<span class="logo-icon-text">GG</span>
|
||||
</div>
|
||||
<form id="login-form" class="login-form" onsubmit="event.preventDefault();">
|
||||
<div class="form-group">
|
||||
<input type="text" id="username" name="username" placeholder=" " required>
|
||||
<label for="username">
|
||||
<i class="fas fa-user"></i> Username
|
||||
</label>
|
||||
<h1>GopherGate</h1>
|
||||
<p class="login-subtitle">Secure LLM Gateway & Management</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="password" id="password" name="password" placeholder=" " required>
|
||||
<label for="password">
|
||||
<i class="fas fa-lock"></i> Password
|
||||
</label>
|
||||
<form id="login-form">
|
||||
<div class="form-control">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required autocomplete="username">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button type="submit" class="login-btn">
|
||||
<i class="fas fa-sign-in-alt"></i> Sign In
|
||||
</button>
|
||||
</div>
|
||||
<div class="login-footer">
|
||||
<p>Default: <code>admin</code> / <code>admin</code> (change in Settings > Security)</p>
|
||||
<div class="form-control">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit" id="login-btn" class="btn btn-primary btn-block">Sign In</button>
|
||||
</form>
|
||||
<div id="login-error" class="error-message" style="display: none;">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<span>Invalid credentials. Please try again.</span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,124 +45,138 @@
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">
|
||||
<img src="img/logo-icon.png" alt="LLM Proxy" class="sidebar-logo" onerror="this.style.display='none'; this.nextElementSibling.style.display='inline-block';">
|
||||
<i class="fas fa-shield-alt logo-fallback" style="display: none;"></i>
|
||||
<span>LLM Proxy</span>
|
||||
<div class="logo-icon-container small">
|
||||
<span class="logo-icon-text">GG</span>
|
||||
</div>
|
||||
<span>GopherGate</span>
|
||||
</div>
|
||||
<button class="sidebar-toggle" id="sidebar-toggle">
|
||||
<i class="fas fa-bars"></i>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-menu">
|
||||
<div class="menu-section">
|
||||
<h3 class="menu-title">MAIN</h3>
|
||||
<a href="#overview" class="menu-item active" data-page="overview" data-tooltip="Dashboard Overview">
|
||||
<ul class="menu-list">
|
||||
<li class="menu-item active" data-page="overview">
|
||||
<i class="fas fa-th-large"></i>
|
||||
<span>Overview</span>
|
||||
</a>
|
||||
<a href="#analytics" class="menu-item" data-page="analytics" data-tooltip="Usage Analytics">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
</li>
|
||||
<li class="menu-item" data-page="analytics">
|
||||
<i class="fas fa-chart-bar"></i>
|
||||
<span>Analytics</span>
|
||||
</a>
|
||||
<a href="#costs" class="menu-item" data-page="costs" data-tooltip="Cost Tracking">
|
||||
</li>
|
||||
<li class="menu-item" data-page="costs">
|
||||
<i class="fas fa-dollar-sign"></i>
|
||||
<span>Cost Management</span>
|
||||
</a>
|
||||
<span>Costs & Billing</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="menu-section">
|
||||
<h3 class="menu-title">MANAGEMENT</h3>
|
||||
<a href="#clients" class="menu-item" data-page="clients" data-tooltip="API Clients">
|
||||
<ul class="menu-list">
|
||||
<li class="menu-item" data-page="clients">
|
||||
<i class="fas fa-users"></i>
|
||||
<span>Client Management</span>
|
||||
</a>
|
||||
<a href="#providers" class="menu-item" data-page="providers" data-tooltip="Model Providers">
|
||||
<span>Clients</span>
|
||||
</li>
|
||||
<li class="menu-item" data-page="providers">
|
||||
<i class="fas fa-server"></i>
|
||||
<span>Providers</span>
|
||||
</a>
|
||||
<a href="#models" class="menu-item" data-page="models" data-tooltip="Manage Models">
|
||||
<i class="fas fa-cube"></i>
|
||||
</li>
|
||||
<li class="menu-item" data-page="models">
|
||||
<i class="fas fa-brain"></i>
|
||||
<span>Models</span>
|
||||
</a>
|
||||
<a href="#monitoring" class="menu-item" data-page="monitoring" data-tooltip="Live Monitoring">
|
||||
<i class="fas fa-heartbeat"></i>
|
||||
<span>Real-time Monitoring</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item" data-page="model-groups">
|
||||
<i class="fas fa-code-branch"></i>
|
||||
<span>Model Groups</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="menu-section">
|
||||
<h3 class="menu-title">SYSTEM</h3>
|
||||
<a href="#users" class="menu-item admin-only" data-page="users" data-tooltip="User Accounts">
|
||||
<ul class="menu-list">
|
||||
<li class="menu-item" data-page="monitoring">
|
||||
<i class="fas fa-activity"></i>
|
||||
<span>Live Monitoring</span>
|
||||
</li>
|
||||
<li class="menu-item" data-page="logs">
|
||||
<i class="fas fa-list-alt"></i>
|
||||
<span>Logs</span>
|
||||
</li>
|
||||
<li class="menu-item" data-page="users">
|
||||
<i class="fas fa-user-shield"></i>
|
||||
<span>User Management</span>
|
||||
</a>
|
||||
<a href="#settings" class="menu-item admin-only" data-page="settings" data-tooltip="System Settings">
|
||||
<span>Admin Users</span>
|
||||
</li>
|
||||
<li class="menu-item" data-page="settings">
|
||||
<i class="fas fa-cog"></i>
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
<a href="#logs" class="menu-item" data-page="logs" data-tooltip="System Logs">
|
||||
<i class="fas fa-list-alt"></i>
|
||||
<span>System Logs</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="user-info">
|
||||
<div class="user-avatar">
|
||||
<i class="fas fa-user-circle"></i>
|
||||
<i class="fas fa-user"></i>
|
||||
</div>
|
||||
<div class="user-details">
|
||||
<span class="user-name">Loading...</span>
|
||||
<span class="user-role">...</span>
|
||||
<div class="user-name" id="display-username">Admin</div>
|
||||
<div class="user-role" id="display-role">Administrator</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="logout-btn" id="logout-btn" title="Logout">
|
||||
<button id="logout-btn" class="btn-icon" title="Logout">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile sidebar backdrop -->
|
||||
<div class="sidebar-backdrop" id="sidebar-backdrop"></div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<!-- Top Navigation -->
|
||||
<header class="top-nav">
|
||||
<div class="nav-left">
|
||||
<h1 class="page-title" id="page-title">Dashboard Overview</h1>
|
||||
<header class="top-bar">
|
||||
<button class="mobile-menu-btn" id="mobile-menu-btn">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
<div class="page-title">
|
||||
<h2 id="page-title">Overview</h2>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="nav-item" id="ws-status-nav" title="WebSocket Connection Status">
|
||||
<div class="ws-dot"></div>
|
||||
<span class="ws-text">Connecting...</span>
|
||||
<div class="top-bar-actions">
|
||||
<div id="connection-status" class="status-indicator">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">Disconnected</span>
|
||||
</div>
|
||||
<div class="nav-item" title="Refresh Current Page">
|
||||
<i class="fas fa-sync-alt" id="refresh-btn"></i>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<span id="current-time">Loading...</span>
|
||||
<div class="theme-toggle" id="theme-toggle">
|
||||
<i class="fas fa-moon"></i>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Page Content -->
|
||||
<div class="page-content" id="page-content">
|
||||
<!-- Dynamic content container -->
|
||||
<div id="page-content" class="content-body">
|
||||
<!-- Content will be loaded dynamically -->
|
||||
<div class="loader-container">
|
||||
<div class="loader"></div>
|
||||
</div>
|
||||
|
||||
<!-- Global Spinner -->
|
||||
<div class="spinner-container">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Scripts (cache-busted with version query params) -->
|
||||
<!-- Scripts -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/luxon@3.3.0/build/global/luxon.min.js"></script>
|
||||
<script src="/js/api.js?v=7"></script>
|
||||
<script src="/js/auth.js?v=7"></script>
|
||||
<script src="/js/dashboard.js?v=7"></script>
|
||||
<script src="/js/websocket.js?v=7"></script>
|
||||
<script src="/js/charts.js?v=7"></script>
|
||||
<script src="/js/websocket.js?v=7"></script>
|
||||
<script src="/js/dashboard.js?v=8"></script>
|
||||
|
||||
<!-- Page Modules -->
|
||||
<script src="/js/pages/overview.js?v=7"></script>
|
||||
<script src="/js/pages/analytics.js?v=7"></script>
|
||||
<script src="/js/pages/costs.js?v=7"></script>
|
||||
@@ -184,5 +187,6 @@
|
||||
<script src="/js/pages/settings.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/model_groups.js?v=9"></script>
|
||||
</body>
|
||||
</html>
|
||||
+6
-6
@@ -1,4 +1,4 @@
|
||||
// Authentication Module for LLM Proxy Dashboard
|
||||
// Authentication Module for GopherGate Dashboard
|
||||
|
||||
class AuthManager {
|
||||
constructor() {
|
||||
@@ -58,7 +58,7 @@ class AuthManager {
|
||||
|
||||
async login(username, password) {
|
||||
const errorElement = document.getElementById('login-error');
|
||||
const loginBtn = document.querySelector('.login-btn');
|
||||
const loginBtn = document.getElementById('login-btn');
|
||||
|
||||
try {
|
||||
loginBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Authenticating...';
|
||||
@@ -124,7 +124,7 @@ class AuthManager {
|
||||
}
|
||||
|
||||
showLogin() {
|
||||
const loginScreen = document.getElementById('login-screen');
|
||||
const loginScreen = document.getElementById('auth-page');
|
||||
const dashboard = document.getElementById('dashboard');
|
||||
|
||||
if (loginScreen) loginScreen.style.display = 'flex';
|
||||
@@ -139,7 +139,7 @@ class AuthManager {
|
||||
if (errorElement) errorElement.style.display = 'none';
|
||||
|
||||
// Reset button
|
||||
const loginBtn = document.querySelector('.login-btn');
|
||||
const loginBtn = document.getElementById('login-btn');
|
||||
if (loginBtn) {
|
||||
loginBtn.innerHTML = '<i class="fas fa-sign-in-alt"></i> Sign In';
|
||||
loginBtn.disabled = false;
|
||||
@@ -147,7 +147,7 @@ class AuthManager {
|
||||
}
|
||||
|
||||
showDashboard() {
|
||||
const loginScreen = document.getElementById('login-screen');
|
||||
const loginScreen = document.getElementById('auth-page');
|
||||
const dashboard = document.getElementById('dashboard');
|
||||
|
||||
if (loginScreen) loginScreen.style.display = 'none';
|
||||
@@ -167,7 +167,7 @@ class AuthManager {
|
||||
const userRoleElement = document.querySelector('.user-role');
|
||||
|
||||
if (userNameElement && this.user) {
|
||||
userNameElement.textContent = this.user.name || this.user.username || 'User';
|
||||
userNameElement.textContent = this.user.display_name || this.user.username || 'User';
|
||||
}
|
||||
|
||||
if (userRoleElement && this.user) {
|
||||
|
||||
+59
-1
@@ -60,23 +60,75 @@ class Dashboard {
|
||||
const toggleBtn = document.getElementById('sidebar-toggle');
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
const logoutBtn = document.getElementById('logout-btn');
|
||||
const backdrop = document.getElementById('sidebar-backdrop');
|
||||
const mobileBtn = document.getElementById('mobile-menu-btn');
|
||||
|
||||
const isMobile = () => window.innerWidth < 768;
|
||||
|
||||
const closeMobileNav = () => {
|
||||
if (sidebar) sidebar.classList.remove('mobile-visible');
|
||||
if (backdrop) backdrop.classList.remove('visible');
|
||||
};
|
||||
|
||||
const toggleMobileNav = () => {
|
||||
if (!sidebar || !backdrop) return;
|
||||
const opening = !sidebar.classList.contains('mobile-visible');
|
||||
sidebar.classList.toggle('mobile-visible', opening);
|
||||
backdrop.classList.toggle('visible', opening);
|
||||
document.body.style.overflow = opening && isMobile() ? 'hidden' : '';
|
||||
};
|
||||
|
||||
if (toggleBtn && sidebar) {
|
||||
toggleBtn.onclick = () => {
|
||||
if (isMobile()) {
|
||||
toggleMobileNav();
|
||||
} else {
|
||||
sidebar.classList.toggle('collapsed');
|
||||
localStorage.setItem('sidebar_collapsed', sidebar.classList.contains('collapsed'));
|
||||
}
|
||||
};
|
||||
|
||||
if (localStorage.getItem('sidebar_collapsed') === 'true') {
|
||||
if (!isMobile() && localStorage.getItem('sidebar_collapsed') === 'true') {
|
||||
sidebar.classList.add('collapsed');
|
||||
}
|
||||
}
|
||||
|
||||
if (mobileBtn) {
|
||||
mobileBtn.onclick = toggleMobileNav;
|
||||
}
|
||||
|
||||
if (backdrop) {
|
||||
backdrop.onclick = closeMobileNav;
|
||||
}
|
||||
|
||||
// Close sidebar on page navigation (mobile)
|
||||
const menuItems = document.querySelectorAll('.menu-item');
|
||||
menuItems.forEach(item => {
|
||||
const origClick = item.onclick;
|
||||
item.onclick = (e) => {
|
||||
if (isMobile()) closeMobileNav();
|
||||
if (origClick) origClick(e);
|
||||
};
|
||||
});
|
||||
|
||||
if (logoutBtn) {
|
||||
logoutBtn.onclick = () => {
|
||||
if (isMobile()) closeMobileNav();
|
||||
window.authManager.logout();
|
||||
};
|
||||
}
|
||||
|
||||
// Handle resize
|
||||
let resizeTimer;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
if (!isMobile()) {
|
||||
closeMobileNav();
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
setupRefresh() {
|
||||
@@ -119,6 +171,7 @@ class Dashboard {
|
||||
'settings': 'Settings',
|
||||
'logs': 'Logs',
|
||||
'models': 'Models',
|
||||
'model-groups': 'Model Groups',
|
||||
'users': 'User Management'
|
||||
};
|
||||
if (titleElement) titleElement.textContent = titles[page] || 'Dashboard';
|
||||
@@ -130,6 +183,11 @@ class Dashboard {
|
||||
if (content) {
|
||||
content.innerHTML = await this.getPageTemplate(page);
|
||||
await this.initializePageScript(page);
|
||||
|
||||
// Model Groups page uses its own render method
|
||||
if (page === 'model-groups' && typeof modelGroupsPage !== 'undefined') {
|
||||
await modelGroupsPage.render();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error loading page ${page}:`, error);
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// 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('/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 html = '<table class="data-table"><thead><tr>';
|
||||
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>';
|
||||
|
||||
groups.forEach(g => {
|
||||
html += '<tr>';
|
||||
html += '<td><code>' + this.esc(g.id) + '</code></td>';
|
||||
html += '<td>' + (g.logic_level != null ? g.logic_level : '—') + '</td>';
|
||||
html += '<td>' + this.esc(g.primary_use || '—') + '</td>';
|
||||
html += '<td><span class="badge">' + this.esc(g.strategy) + '</span></td>';
|
||||
html += '<td><code>' + this.esc(g.targets) + '</code></td>';
|
||||
html += '<td>';
|
||||
html += '<button class="btn btn-sm" onclick="modelGroupsPage.showEditForm(\'' + this.esc(g.id) + '\')">Edit</button> ';
|
||||
html += '<button class="btn btn-sm btn-danger" onclick="modelGroupsPage.deleteGroup(\'' + this.esc(g.id) + '\')">Delete</button>';
|
||||
html += '</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) {
|
||||
try {
|
||||
const groups = await api.get('/model-groups');
|
||||
const group = groups.find(g => g.id === id);
|
||||
if (group) this.renderForm(group);
|
||||
} catch (err) {
|
||||
alert('Failed to load group: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
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 ? 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 && group.strategy === 'heuristic' ? 'selected' : ''}>Heuristic (rules-based)</option>
|
||||
<option value="classifier" ${group && 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 ? 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" style="${group && group.strategy === 'classifier' ? '' : 'display:none'}">
|
||||
<label>Selector Model</label>
|
||||
<input type="text" id="mg-selector-model" value="${this.esc(group && group.selector_model ? group.selector_model : 'gpt-4o-mini')}"
|
||||
placeholder="Model used to judge task complexity">
|
||||
</div>
|
||||
<div class="form-control" id="mg-threshold-row" style="${group && group.strategy === 'classifier' ? '' : 'display:none'}">
|
||||
<label>Complexity Threshold</label>
|
||||
<input type="number" id="mg-threshold" value="${group && group.complexity_threshold ? group.complexity_threshold : ''}" min="1"
|
||||
placeholder="Tasks rated >= this go to the smart model">
|
||||
</div>
|
||||
<div class="form-control" id="mg-rules-row" style="${group && group.strategy === 'heuristic' ? '' : 'display:none'}">
|
||||
<label>Heuristic Rules (JSON array)</label>
|
||||
<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>
|
||||
</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">
|
||||
<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>
|
||||
`;
|
||||
|
||||
document.getElementById('mg-strategy').onchange = function() {
|
||||
var 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();
|
||||
var id = document.getElementById('mg-id').value.trim();
|
||||
var strategy = document.getElementById('mg-strategy').value;
|
||||
var targets = document.getElementById('mg-targets').value;
|
||||
var selectorModel = document.getElementById('mg-selector-model').value.trim() || null;
|
||||
var thresholdVal = document.getElementById('mg-threshold').value;
|
||||
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; }
|
||||
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 };
|
||||
if (thresholdVal) body.complexity_threshold = parseInt(thresholdVal);
|
||||
if (logicLevelVal) body.logic_level = parseInt(logicLevelVal);
|
||||
if (primaryUse) body.primary_use = primaryUse;
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
await api.put('/model-groups/' + encodeURIComponent(id), body);
|
||||
} else {
|
||||
await api.post('/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 + '"? This cannot be undone.')) return;
|
||||
try {
|
||||
await api.delete('/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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
}
|
||||
|
||||
var modelGroupsPage = new ModelGroupsPage();
|
||||
@@ -392,7 +392,7 @@ class MonitoringPage {
|
||||
</div>
|
||||
<div class="stream-entry-content">
|
||||
<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">
|
||||
${request.total_tokens || request.tokens || 0} tokens • ${request.duration_ms || request.duration || 0}ms
|
||||
</div>
|
||||
@@ -492,7 +492,7 @@ class MonitoringPage {
|
||||
simulateRequest() {
|
||||
const clients = ['client-1', 'client-2', 'client-3', 'client-4', 'client-5'];
|
||||
const providers = ['OpenAI', 'Gemini', 'DeepSeek', 'Grok'];
|
||||
const models = ['gpt-4', 'gpt-3.5-turbo', 'gemini-pro', 'deepseek-chat', 'grok-beta'];
|
||||
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 request = {
|
||||
|
||||
@@ -59,7 +59,20 @@ class OverviewPage {
|
||||
<div class="stat-value">${window.api.formatNumber(this.stats.total_tokens)}</div>
|
||||
<div class="stat-label">Total Tokens</div>
|
||||
<div class="stat-change">
|
||||
Lifetime usage
|
||||
Lifetime usage (${(this.stats.total_days || 1).toLocaleString()} days)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon warning">
|
||||
<i class="fas fa-calendar-alt"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">${(this.stats.total_days || 1).toLocaleString()} Days</div>
|
||||
<div class="stat-label">Days Active</div>
|
||||
<div class="stat-change">
|
||||
Since ${this.stats.first_date || 'launch'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -252,7 +265,7 @@ class OverviewPage {
|
||||
<td>${time}</td>
|
||||
<td><span class="badge-client">${request.client_id}</span></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>
|
||||
<span class="status-badge ${statusClass}">
|
||||
@@ -313,7 +326,7 @@ class OverviewPage {
|
||||
<td>${time}</td>
|
||||
<td><span class="badge-client">${request.client_id}</span></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>
|
||||
<span class="status-badge ${statusClass}">
|
||||
|
||||
@@ -248,21 +248,19 @@ class WebSocketManager {
|
||||
}
|
||||
|
||||
updateStatus(status) {
|
||||
const statusElement = document.getElementById('ws-status-nav');
|
||||
const statusElement = document.getElementById('connection-status');
|
||||
if (!statusElement) return;
|
||||
|
||||
const dot = statusElement.querySelector('.ws-dot');
|
||||
const text = statusElement.querySelector('.ws-text');
|
||||
const dot = statusElement.querySelector('.status-dot');
|
||||
const text = statusElement.querySelector('.status-text');
|
||||
|
||||
if (!dot || !text) return;
|
||||
|
||||
// Remove all status classes
|
||||
dot.classList.remove('connected', 'disconnected');
|
||||
statusElement.classList.remove('connected', 'disconnected');
|
||||
dot.classList.remove('connected', 'disconnected', 'error', 'connecting');
|
||||
|
||||
// Add new status class
|
||||
dot.classList.add(status);
|
||||
statusElement.classList.add(status);
|
||||
|
||||
// Update text
|
||||
const statusText = {
|
||||
@@ -311,7 +309,7 @@ class WebSocketManager {
|
||||
<td>${time}</td>
|
||||
<td>${request.client_id || '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>
|
||||
<span class="status-badge ${statusClass}">
|
||||
@@ -360,7 +358,7 @@ class WebSocketManager {
|
||||
</div>
|
||||
<div class="stream-entry-content">
|
||||
<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">
|
||||
${(request.total_tokens || request.tokens || 0)} tokens • ${(request.duration_ms || request.duration || 0)}ms
|
||||
</div>
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
gantt
|
||||
title LLM Proxy Project Timeline
|
||||
dateFormat YYYY-MM-DD
|
||||
section Frontend
|
||||
Standardize Escaping (users.js) :a1, 2026-03-06, 1d
|
||||
section Backend Cleanup
|
||||
Remove Unused Imports :b1, 2026-03-06, 1d
|
||||
section HMAC Migration
|
||||
Architecture Design :c1, 2026-03-07, 1d
|
||||
Backend Implementation :c2, after c1, 2d
|
||||
Session Refresh Logic :c3, after c2, 1d
|
||||
section Testing
|
||||
Integration Test (Encrypted Keys) :d1, 2026-03-09, 2d
|
||||
HMAC Verification Tests :d2, after c3, 1d
|
||||
Reference in New Issue
Block a user