Compare commits
20 Commits
84a18f5866
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ddb710507d | |||
| 2ed027da7c | |||
| 2e4253e0bf | |||
| 2562675b8b | |||
| b3dc4365a8 | |||
| 1200a081fc | |||
| c6329d5612 | |||
| c96e1d0350 | |||
| ba25f5e6c8 | |||
| 59c38e8f8f | |||
| d23da551ab | |||
| 654f6ab6d1 | |||
| 0decc63e8c | |||
| 9980123f97 | |||
| 293cf057b9 | |||
| eb90f949a0 | |||
| a187d8e20e | |||
| 700b7cd5d6 | |||
| 4027ed4351 | |||
| 42b70621a1 |
@@ -6,6 +6,7 @@
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
/gophergate
|
/gophergate
|
||||||
|
/gophergate_*
|
||||||
/llm-proxy
|
/llm-proxy
|
||||||
/llm-proxy-go
|
/llm-proxy-go
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -5,19 +5,24 @@ A unified, high-performance LLM proxy gateway built in Go. It provides OpenAI-co
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Unified API:** OpenAI-compatible `/v1/chat/completions`, `/v1/images/generations`, `/v1/responses`, and `/v1/models` endpoints.
|
- **Unified API:** OpenAI-compatible `/v1/chat/completions`, `/v1/images/generations`, `/v1/responses`, and `/v1/models` endpoints.
|
||||||
- The `/v1/responses` endpoint (OpenAI Responses API) is currently supported for OpenAI models only. Non-OpenAI providers (Gemini, DeepSeek, Moonshot, Grok, Ollama) return a "not supported" response.
|
- The `/v1/responses` endpoint (OpenAI Responses API) is supported for OpenAI and DeepSeek models. Non-supported providers (Gemini, Moonshot, Grok, Ollama, Xiaomi) return a "not supported" response.
|
||||||
- **Multi-Provider Support:**
|
- **Multi-Provider Support:**
|
||||||
- **OpenAI:** GPT-4o, GPT-4o Mini, GPT-5, GPT-5.4, o1/o3/o4 reasoning models, DALL-E 2/3 image generation.
|
- **OpenAI:** GPT-4o, GPT-4o Mini, GPT-5, GPT-5.4, o1/o3/o4 reasoning models, DALL-E 2/3 image generation.
|
||||||
- **Google Gemini:** Gemini 2.5 Flash/Pro, Gemini 3 Flash/Pro previews, Imagen 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.
|
- **DeepSeek:** DeepSeek Chat, Reasoner, V4 Flash, V4 Pro.
|
||||||
- **Moonshot:** Kimi K2.5, K2.6 reasoning models.
|
- **Moonshot:** Kimi K2.5, K2.6 reasoning models.
|
||||||
- **xAI Grok:** Grok-3, Grok-4, Grok-4.3 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.
|
- **Ollama:** Local LLMs running on your network.
|
||||||
- **Observability & Tracking:**
|
- **Observability & Tracking:**
|
||||||
- **Asynchronous Logging:** Non-blocking request logging to SQLite using background workers.
|
- **Asynchronous Logging:** Non-blocking request logging to SQLite using background workers.
|
||||||
- **Token Counting:** Precise estimation and tracking of prompt, completion, and reasoning tokens.
|
- **Token Counting:** Precise estimation and tracking of prompt, completion, and reasoning tokens.
|
||||||
- **Database Persistence:** Every request logged to SQLite for historical analysis and dashboard analytics.
|
- **Database Persistence:** Every request logged to SQLite for historical analysis and dashboard analytics.
|
||||||
- **Streaming Support:** Full SSE (Server-Sent Events) support for all providers.
|
- **Streaming Support:** Full SSE (Server-Sent Events) support with `X-Accel-Buffering: no` for unbuffered, low-latency streaming.
|
||||||
|
- **High Performance & Thread Safety:**
|
||||||
|
- **Connection Pooling:** Shared HTTP transport with connection pooling (`MaxIdleConns: 200`), TCP keep-alives, and HTTP/2 multiplexing across all provider drivers.
|
||||||
|
- **In-Memory Token Caching:** In-memory `sync.Map` TTL caching (10s valid, 2s negative cache) for client token authentication to eliminate SQLite bottlenecking.
|
||||||
|
- **Thread-Safe Core:** Full RWMutex locking across provider maps, model registry lookups, and router reloads.
|
||||||
- **Multimodal (Vision):** Image processing (Base64 and remote URLs) across compatible providers.
|
- **Multimodal (Vision):** Image processing (Base64 and remote URLs) across compatible providers.
|
||||||
- **Image Generation:** DALL-E 2/3 (OpenAI) and Imagen 3 (Gemini) via OpenAI-compatible `/v1/images/generations` endpoint.
|
- **Image Generation:** DALL-E 2/3 (OpenAI) and Imagen 3 (Gemini) via OpenAI-compatible `/v1/images/generations` endpoint.
|
||||||
- **Automatic Model Routing:**
|
- **Automatic Model Routing:**
|
||||||
|
|||||||
+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).
|
||||||
@@ -23,6 +23,7 @@ type ServerConfig struct {
|
|||||||
Host string `mapstructure:"host"`
|
Host string `mapstructure:"host"`
|
||||||
AuthTokens []string `mapstructure:"auth_tokens"`
|
AuthTokens []string `mapstructure:"auth_tokens"`
|
||||||
WSAllowedOrigin string `mapstructure:"ws_allowed_origin"`
|
WSAllowedOrigin string `mapstructure:"ws_allowed_origin"`
|
||||||
|
MaxHistoryMessages int `mapstructure:"max_history_messages"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DatabaseConfig struct {
|
type DatabaseConfig struct {
|
||||||
@@ -96,6 +97,7 @@ func Load() (*Config, error) {
|
|||||||
v.SetDefault("server.port", 8080)
|
v.SetDefault("server.port", 8080)
|
||||||
v.SetDefault("server.host", "0.0.0.0")
|
v.SetDefault("server.host", "0.0.0.0")
|
||||||
v.SetDefault("server.auth_tokens", []string{})
|
v.SetDefault("server.auth_tokens", []string{})
|
||||||
|
v.SetDefault("server.max_history_messages", 0)
|
||||||
v.SetDefault("database.path", "./data/llm_proxy.db")
|
v.SetDefault("database.path", "./data/llm_proxy.db")
|
||||||
v.SetDefault("database.max_connections", 10)
|
v.SetDefault("database.max_connections", 10)
|
||||||
|
|
||||||
@@ -142,6 +144,7 @@ func Load() (*Config, error) {
|
|||||||
v.BindEnv("encryption_key", "LLM_PROXY__ENCRYPTION_KEY")
|
v.BindEnv("encryption_key", "LLM_PROXY__ENCRYPTION_KEY")
|
||||||
v.BindEnv("server.port", "LLM_PROXY__SERVER__PORT")
|
v.BindEnv("server.port", "LLM_PROXY__SERVER__PORT")
|
||||||
v.BindEnv("server.host", "LLM_PROXY__SERVER__HOST")
|
v.BindEnv("server.host", "LLM_PROXY__SERVER__HOST")
|
||||||
|
v.BindEnv("server.max_history_messages", "LLM_PROXY__SERVER__MAX_HISTORY_MESSAGES")
|
||||||
v.BindEnv("providers.ollama.enabled", "LLM_PROXY__PROVIDERS__OLLAMA__ENABLED")
|
v.BindEnv("providers.ollama.enabled", "LLM_PROXY__PROVIDERS__OLLAMA__ENABLED")
|
||||||
v.BindEnv("providers.ollama.base_url", "LLM_PROXY__PROVIDERS__OLLAMA__BASE_URL")
|
v.BindEnv("providers.ollama.base_url", "LLM_PROXY__PROVIDERS__OLLAMA__BASE_URL")
|
||||||
v.BindEnv("providers.ollama.models", "LLM_PROXY__PROVIDERS__OLLAMA__MODELS")
|
v.BindEnv("providers.ollama.models", "LLM_PROXY__PROVIDERS__OLLAMA__MODELS")
|
||||||
@@ -174,6 +177,9 @@ func Load() (*Config, error) {
|
|||||||
cfg.Server.Host = host
|
cfg.Server.Host = host
|
||||||
|
|
||||||
}
|
}
|
||||||
|
if maxHistory := os.Getenv("LLM_PROXY__SERVER__MAX_HISTORY_MESSAGES"); maxHistory != "" {
|
||||||
|
fmt.Sscanf(maxHistory, "%d", &cfg.Server.MaxHistoryMessages)
|
||||||
|
}
|
||||||
|
|
||||||
// Ollama overrides
|
// Ollama overrides
|
||||||
if enabled := os.Getenv("LLM_PROXY__PROVIDERS__OLLAMA__ENABLED"); enabled != "" {
|
if enabled := os.Getenv("LLM_PROXY__PROVIDERS__OLLAMA__ENABLED"); enabled != "" {
|
||||||
|
|||||||
+63
-11
@@ -26,7 +26,7 @@ func Init(path string) (*DB, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Connect to SQLite
|
// Connect to SQLite
|
||||||
dsn := fmt.Sprintf("file:%s?_pragma=foreign_keys(1)", path)
|
dsn := fmt.Sprintf("file:%s?_pragma=foreign_keys(1)&_busy_timeout=5000", path)
|
||||||
db, err := sqlx.Connect("sqlite", dsn)
|
db, err := sqlx.Connect("sqlite", dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||||
@@ -203,21 +203,72 @@ func (db *DB) RunMigrations() error {
|
|||||||
|
|
||||||
// Seed default model groups
|
// Seed default model groups
|
||||||
defaultGroups := []struct {
|
defaultGroups := []struct {
|
||||||
id, strategy, targets, selectorModel string
|
id, strategy, targets, selectorModel, heuristicRules string
|
||||||
complexityThreshold, logicLevel *int
|
complexityThreshold, logicLevel *int
|
||||||
primaryUse *string
|
primaryUse *string
|
||||||
}{
|
}{
|
||||||
{"deepseek-auto", "heuristic", `["deepseek-chat","deepseek-reasoner"]`, "", nil, nil, nil},
|
{"deepseek-auto", "heuristic", `["deepseek-chat","deepseek-reasoner"]`, "", "", nil, nil, nil},
|
||||||
{"openai-auto", "heuristic", `["gpt-4o-mini","gpt-4o"]`, "", nil, nil, nil},
|
{"openai-auto", "heuristic", `["gpt-4o-mini","gpt-4o"]`, "", "", nil, nil, nil},
|
||||||
{"gemini-auto", "heuristic", `["gemini-2.5-flash","gemini-2.5-pro"]`, "", nil, nil, nil},
|
{"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.")},
|
{"heavy-logic", "heuristic", `["grok-4.3","kimi-k2.6","deepseek-v4-pro"]`, "", "", nil, intPtr(9), strPtr("Complex Coding, Logic, Agents.")},
|
||||||
{"standard-pro", "heuristic", `["gpt-5.4-mini","gemini-3-flash-preview"]`, "", nil, intPtr(5), strPtr("General Assistant, Long Docs.")},
|
{"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.")},
|
{"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.")},
|
{"dispatcher", "classifier", `["fast-flow","standard-pro","heavy-logic"]`, "gpt-5.4-nano", "", intPtr(10), nil, strPtr("Auto-dispatches to tier groups by complexity.")},
|
||||||
|
{"dustins_stack", "heuristic", `["mimo-v2.5","deepseek-v4-pro","grok-4.3","mimo-v2.5-pro","deepseek-v4-flash","kimi-k2.6"]`, "", `[
|
||||||
|
{
|
||||||
|
"rule_id": "multimodal_tier",
|
||||||
|
"description": "Multimodal input routes to high-throughput multimodal models.",
|
||||||
|
"conditions": { "has_multimodal_input": true },
|
||||||
|
"primary_model": "mimo-v2.5",
|
||||||
|
"fallback_model": "mimo-v2.5-pro"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule_id": "ultra_long_context",
|
||||||
|
"description": "Massive context/document processing (>128k tokens) routes to long-context specialists.",
|
||||||
|
"conditions": { "min_input_tokens": 128000 },
|
||||||
|
"primary_model": "kimi-k2.6",
|
||||||
|
"fallback_model": "deepseek-v4-pro"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule_id": "agentic_code_and_tools",
|
||||||
|
"description": "Tool-heavy agent loops (MCP, repo editing, SWE) to MiMo Pro.",
|
||||||
|
"conditions": { "requires_tool_calling": true, "any_of_tags": ["swe-bench", "tool-heavy"] },
|
||||||
|
"primary_model": "mimo-v2.5-pro",
|
||||||
|
"fallback_model": "deepseek-v4-pro"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule_id": "reasoning_heavy",
|
||||||
|
"description": "Deep reasoning, architecture, system design, and math.",
|
||||||
|
"conditions": { "requires_reasoning": true },
|
||||||
|
"primary_model": "deepseek-v4-pro",
|
||||||
|
"fallback_model": "grok-4.3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule_id": "realtime_and_creative",
|
||||||
|
"description": "Real-time web search, open-ended synthesis, or high-creativity tasks.",
|
||||||
|
"conditions": { "any_of_tags": ["realtime-search", "creative", "synthesis"] },
|
||||||
|
"primary_model": "grok-4.3",
|
||||||
|
"fallback_model": "mimo-v2.5-pro"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule_id": "fast_flow_tier",
|
||||||
|
"description": "Short simple text, no reasoning, no tools.",
|
||||||
|
"conditions": { "max_input_tokens_lt": 16000, "requires_reasoning": false, "requires_tool_calling": false },
|
||||||
|
"primary_model": "deepseek-v4-flash",
|
||||||
|
"fallback_model": "mimo-v2.5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule_id": "regional_fallback_general",
|
||||||
|
"description": "Catch-all default rule.",
|
||||||
|
"conditions": { "is_default_fallback": true },
|
||||||
|
"primary_model": "deepseek-v4-pro",
|
||||||
|
"fallback_model": "deepseek-v4-flash"
|
||||||
|
}
|
||||||
|
]`, nil, nil, strPtr("Dustin's personal agent stack. No Gemini.")},
|
||||||
}
|
}
|
||||||
for _, g := range defaultGroups {
|
for _, g := range defaultGroups {
|
||||||
db.Exec(`INSERT OR IGNORE INTO model_groups (id, strategy, targets, selector_model, complexity_threshold, logic_level, primary_use) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
db.Exec(`INSERT OR IGNORE INTO model_groups (id, strategy, targets, selector_model, heuristic_rules, complexity_threshold, logic_level, primary_use) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
g.id, g.strategy, g.targets, nilStr(g.selectorModel), g.complexityThreshold, g.logicLevel, g.primaryUse)
|
g.id, g.strategy, g.targets, nilStr(g.selectorModel), nilStr(g.heuristicRules), g.complexityThreshold, g.logicLevel, g.primaryUse)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -258,6 +309,7 @@ type LLMRequest struct {
|
|||||||
ResponseBody *string `db:"response_body"`
|
ResponseBody *string `db:"response_body"`
|
||||||
CacheReadTokens int `db:"cache_read_tokens"`
|
CacheReadTokens int `db:"cache_read_tokens"`
|
||||||
CacheWriteTokens int `db:"cache_write_tokens"`
|
CacheWriteTokens int `db:"cache_write_tokens"`
|
||||||
|
ModelGroup string `db:"model_group"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProviderConfig struct {
|
type ProviderConfig struct {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gophergate/internal/db"
|
"gophergate/internal/db"
|
||||||
"gophergate/internal/models"
|
"gophergate/internal/models"
|
||||||
@@ -11,6 +13,15 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type tokenCacheEntry struct {
|
||||||
|
clientID string
|
||||||
|
expiredAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
tokenCache sync.Map // map[string]tokenCacheEntry
|
||||||
|
)
|
||||||
|
|
||||||
func AuthMiddleware(database *db.DB, requireAuth bool) gin.HandlerFunc {
|
func AuthMiddleware(database *db.DB, requireAuth bool) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
authHeader := c.GetHeader("Authorization")
|
authHeader := c.GetHeader("Authorization")
|
||||||
@@ -52,11 +63,39 @@ func AuthMiddleware(database *db.DB, requireAuth bool) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to resolve client from database with a read-only SELECT
|
// Try to resolve client from cache first
|
||||||
var clientID string
|
var clientID string
|
||||||
err := database.Get(&clientID, "SELECT client_id FROM client_tokens WHERE token = ? AND is_active = 1", token)
|
var dbErr error
|
||||||
|
now := time.Now()
|
||||||
|
if cached, ok := tokenCache.Load(token); ok {
|
||||||
|
entry := cached.(tokenCacheEntry)
|
||||||
|
if now.Before(entry.expiredAt) {
|
||||||
|
clientID = entry.clientID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err == nil {
|
// If cache miss (or expired), query database
|
||||||
|
if clientID == "" {
|
||||||
|
var fetchedID string
|
||||||
|
dbErr = database.Get(&fetchedID, "SELECT client_id FROM client_tokens WHERE token = ? AND is_active = 1", token)
|
||||||
|
if dbErr == nil {
|
||||||
|
clientID = fetchedID
|
||||||
|
// Cache valid token for 10 seconds
|
||||||
|
tokenCache.Store(token, tokenCacheEntry{
|
||||||
|
clientID: clientID,
|
||||||
|
expiredAt: now.Add(10 * time.Second),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// If error (invalid/inactive token), cache negative result for 2 seconds
|
||||||
|
// to avoid hammering SQLite on repeated invalid requests
|
||||||
|
tokenCache.Store(token, tokenCacheEntry{
|
||||||
|
clientID: "",
|
||||||
|
expiredAt: now.Add(2 * time.Second),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if clientID != "" {
|
||||||
c.Set("auth", models.AuthInfo{
|
c.Set("auth", models.AuthInfo{
|
||||||
Token: token,
|
Token: token,
|
||||||
ClientID: clientID,
|
ClientID: clientID,
|
||||||
@@ -72,7 +111,15 @@ func AuthMiddleware(database *db.DB, requireAuth bool) gin.HandlerFunc {
|
|||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Token not found, inactive or error in DB: %s (err: %v)", token, err)
|
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{
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||||
"error": gin.H{
|
"error": gin.H{
|
||||||
"message": "Invalid or inactive client token.",
|
"message": "Invalid or inactive client token.",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type ChatMessage struct {
|
|||||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||||
Name *string `json:"name,omitempty"`
|
Name *string `json:"name,omitempty"`
|
||||||
ToolCallID *string `json:"tool_call_id,omitempty"`
|
ToolCallID *string `json:"tool_call_id,omitempty"`
|
||||||
|
Prefix *bool `json:"prefix,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContentPart struct {
|
type ContentPart struct {
|
||||||
@@ -168,6 +169,7 @@ type UnifiedMessage struct {
|
|||||||
ToolCalls []ToolCall
|
ToolCalls []ToolCall
|
||||||
Name *string
|
Name *string
|
||||||
ToolCallID *string
|
ToolCallID *string
|
||||||
|
Prefix *bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type UnifiedContentPart struct {
|
type UnifiedContentPart struct {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
// CanonicalProviders lists the original model creators in priority order.
|
// CanonicalProviders lists the original model creators in priority order.
|
||||||
// When a model name exists in multiple providers (e.g. deepseek-v4-pro in
|
// When a model name exists in multiple providers (e.g. deepseek-v4-pro in
|
||||||
@@ -22,6 +25,7 @@ var CanonicalProviders = []string{
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ModelRegistry struct {
|
type ModelRegistry struct {
|
||||||
|
mu sync.RWMutex
|
||||||
Providers map[string]ProviderInfo `json:"-"`
|
Providers map[string]ProviderInfo `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,6 +177,12 @@ func (r *ModelRegistry) findAllForwardFuzzy(modelID string) (*ModelMetadata, boo
|
|||||||
// etc.) from overriding the original provider's authoritative pricing and
|
// etc.) from overriding the original provider's authoritative pricing and
|
||||||
// limits.
|
// limits.
|
||||||
func (r *ModelRegistry) FindModel(modelID string) *ModelMetadata {
|
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
|
// 1. Exact key match — canonical first, then all
|
||||||
if m, ok := r.findInCanonical(modelID); ok {
|
if m, ok := r.findInCanonical(modelID); ok {
|
||||||
return m
|
return m
|
||||||
|
|||||||
@@ -50,9 +50,13 @@ func (cbp *CircuitBreakerProvider) ChatCompletion(ctx context.Context, req *mode
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (cbp *CircuitBreakerProvider) ChatCompletionStream(ctx context.Context, req *models.UnifiedRequest) (<-chan *models.ChatCompletionStreamResponse, error) {
|
func (cbp *CircuitBreakerProvider) ChatCompletionStream(ctx context.Context, req *models.UnifiedRequest) (<-chan *models.ChatCompletionStreamResponse, error) {
|
||||||
// Circuit breaker for streaming is tricky. We'll just call the provider directly.
|
result, err := cbp.cb.Execute(func() (interface{}, error) {
|
||||||
// Future: Implement a way to track stream failures in the circuit breaker.
|
|
||||||
return cbp.provider.ChatCompletionStream(ctx, req)
|
return cbp.provider.ChatCompletionStream(ctx, req)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result.(<-chan *models.ChatCompletionStreamResponse), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cbp *CircuitBreakerProvider) ImageGeneration(ctx context.Context, req *models.ImageGenerationRequest) (*models.ImageGenerationResponse, error) {
|
func (cbp *CircuitBreakerProvider) ImageGeneration(ctx context.Context, req *models.ImageGenerationRequest) (*models.ImageGenerationResponse, error) {
|
||||||
@@ -76,6 +80,11 @@ func (cbp *CircuitBreakerProvider) Responses(ctx context.Context, req *models.Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (cbp *CircuitBreakerProvider) ResponsesStream(ctx context.Context, req *models.ResponsesRequest) (<-chan *models.ResponsesStreamChunk, error) {
|
func (cbp *CircuitBreakerProvider) ResponsesStream(ctx context.Context, req *models.ResponsesRequest) (<-chan *models.ResponsesStreamChunk, error) {
|
||||||
// Circuit breaker passthrough for streaming (same pattern as ChatCompletionStream)
|
result, err := cbp.cb.Execute(func() (interface{}, error) {
|
||||||
return cbp.provider.ResponsesStream(ctx, req)
|
return cbp.provider.ResponsesStream(ctx, req)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result.(<-chan *models.ResponsesStreamChunk), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type DeepSeekProvider struct {
|
|||||||
|
|
||||||
func NewDeepSeekProvider(cfg config.DeepSeekConfig, apiKey string) *DeepSeekProvider {
|
func NewDeepSeekProvider(cfg config.DeepSeekConfig, apiKey string) *DeepSeekProvider {
|
||||||
return &DeepSeekProvider{
|
return &DeepSeekProvider{
|
||||||
client: resty.New().SetTimeout(10 * time.Minute),
|
client: NewOptimizedRestyClient(10 * time.Minute),
|
||||||
config: cfg,
|
config: cfg,
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
}
|
}
|
||||||
@@ -113,14 +113,11 @@ func (p *DeepSeekProvider) ChatCompletion(ctx context.Context, req *models.Unifi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !resp.IsSuccess() {
|
if !resp.IsSuccess() {
|
||||||
var msg string
|
msg := resp.String()
|
||||||
if resp.RawBody() != nil {
|
if msg == "" && resp.RawBody() != nil {
|
||||||
bodyBytes, _ := io.ReadAll(resp.RawBody())
|
bodyBytes, _ := io.ReadAll(resp.RawBody())
|
||||||
msg = string(bodyBytes)
|
msg = string(bodyBytes)
|
||||||
}
|
}
|
||||||
if msg == "" {
|
|
||||||
msg = resp.String()
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("DeepSeek API error (%d): %s", resp.StatusCode(), msg)
|
return nil, fmt.Errorf("DeepSeek API error (%d): %s", resp.StatusCode(), msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,9 +257,70 @@ func (p *DeepSeekProvider) ImageGeneration(ctx context.Context, req *models.Imag
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *DeepSeekProvider) Responses(ctx context.Context, req *models.ResponsesRequest) (*models.ResponsesResponse, error) {
|
func (p *DeepSeekProvider) Responses(ctx context.Context, req *models.ResponsesRequest) (*models.ResponsesResponse, error) {
|
||||||
return nil, fmt.Errorf("responses API not supported by deepseek")
|
stream := req.Stream != nil && *req.Stream
|
||||||
|
body := BuildOpenAIResponsesBody(req, stream)
|
||||||
|
|
||||||
|
resp, err := p.client.R().
|
||||||
|
SetContext(ctx).
|
||||||
|
SetHeader("Authorization", "Bearer "+p.apiKey).
|
||||||
|
SetBody(body).
|
||||||
|
Post(fmt.Sprintf("%s/responses", p.config.BaseURL))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("responses request failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !resp.IsSuccess() {
|
||||||
|
msg := resp.String()
|
||||||
|
if msg == "" && resp.RawBody() != nil {
|
||||||
|
if bodyBytes, err := io.ReadAll(resp.RawBody()); err == nil {
|
||||||
|
msg = string(bodyBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("DeepSeek Responses API error (%d): %s", resp.StatusCode(), msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
var respJSON map[string]interface{}
|
||||||
|
if err := json.Unmarshal(resp.Body(), &respJSON); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse responses response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ParseOpenAIResponsesResponse(respJSON, req.Model)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *DeepSeekProvider) ResponsesStream(ctx context.Context, req *models.ResponsesRequest) (<-chan *models.ResponsesStreamChunk, error) {
|
func (p *DeepSeekProvider) ResponsesStream(ctx context.Context, req *models.ResponsesRequest) (<-chan *models.ResponsesStreamChunk, error) {
|
||||||
return nil, fmt.Errorf("responses API not supported by deepseek")
|
body := BuildOpenAIResponsesBody(req, true)
|
||||||
|
|
||||||
|
resp, err := p.client.R().
|
||||||
|
SetContext(ctx).
|
||||||
|
SetHeader("Authorization", "Bearer "+p.apiKey).
|
||||||
|
SetBody(body).
|
||||||
|
SetDoNotParseResponse(true).
|
||||||
|
Post(fmt.Sprintf("%s/responses", p.config.BaseURL))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("responses stream request failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !resp.IsSuccess() {
|
||||||
|
msg := resp.String()
|
||||||
|
if msg == "" && resp.RawBody() != nil {
|
||||||
|
if bodyBytes, err := io.ReadAll(resp.RawBody()); err == nil {
|
||||||
|
msg = string(bodyBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("DeepSeek Responses API error (%d): %s", resp.StatusCode(), msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := make(chan *models.ResponsesStreamChunk)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
err := StreamOpenAIResponses(resp.RawBody(), ch)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("DeepSeek Responses stream error: %v\n", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return ch, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+140
-47
@@ -21,7 +21,7 @@ type GeminiProvider struct {
|
|||||||
|
|
||||||
func NewGeminiProvider(cfg config.GeminiConfig, apiKey string) *GeminiProvider {
|
func NewGeminiProvider(cfg config.GeminiConfig, apiKey string) *GeminiProvider {
|
||||||
return &GeminiProvider{
|
return &GeminiProvider{
|
||||||
client: resty.New().SetTimeout(10 * time.Minute),
|
client: NewOptimizedRestyClient(10 * time.Minute),
|
||||||
config: cfg,
|
config: cfg,
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
}
|
}
|
||||||
@@ -59,6 +59,7 @@ type GeminiPart struct {
|
|||||||
InlineData *GeminiInlineData `json:"inlineData,omitempty"`
|
InlineData *GeminiInlineData `json:"inlineData,omitempty"`
|
||||||
FunctionCall *GeminiFunctionCall `json:"functionCall,omitempty"`
|
FunctionCall *GeminiFunctionCall `json:"functionCall,omitempty"`
|
||||||
FunctionResponse *GeminiFunctionResponse `json:"functionResponse,omitempty"`
|
FunctionResponse *GeminiFunctionResponse `json:"functionResponse,omitempty"`
|
||||||
|
ThoughtSignature string `json:"thoughtSignature,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GeminiInlineData struct {
|
type GeminiInlineData struct {
|
||||||
@@ -201,15 +202,7 @@ func (p *GeminiProvider) ResponsesStream(ctx context.Context, req *models.Respon
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.UnifiedRequest) (*models.ChatCompletionResponse, error) {
|
func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.UnifiedRequest) (*models.ChatCompletionResponse, error) {
|
||||||
// Map deprecated or preview model names to active equivalents
|
req.Model = normalizeGeminiModel(req.Model)
|
||||||
switch req.Model {
|
|
||||||
case "gemini-2.0-flash":
|
|
||||||
req.Model = "gemini-2.5-flash"
|
|
||||||
case "gemini-3-flash":
|
|
||||||
req.Model = "gemini-3-flash-preview"
|
|
||||||
case "gemini-3-pro":
|
|
||||||
req.Model = "gemini-3-pro-preview"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gemini mapping
|
// Gemini mapping
|
||||||
var contents []GeminiContent
|
var contents []GeminiContent
|
||||||
@@ -231,6 +224,7 @@ func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.Unified
|
|||||||
Name: tc.Function.Name,
|
Name: tc.Function.Name,
|
||||||
Args: json.RawMessage(tc.Function.Arguments),
|
Args: json.RawMessage(tc.Function.Arguments),
|
||||||
},
|
},
|
||||||
|
ThoughtSignature: "skip_thought_signature_validator",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
contents = append(contents, GeminiContent{Role: "model", Parts: parts})
|
contents = append(contents, GeminiContent{Role: "model", Parts: parts})
|
||||||
@@ -256,16 +250,9 @@ func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.Unified
|
|||||||
if len(m.Content) > 0 {
|
if len(m.Content) > 0 {
|
||||||
text = m.Content[0].Text
|
text = m.Content[0].Text
|
||||||
}
|
}
|
||||||
name := "unknown_function"
|
name := resolveToolName(m, msg.ToolCalls, j-i-1)
|
||||||
if m.Name != nil {
|
|
||||||
name = *m.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
var responseObj interface{}
|
respBytes := ensureJSONObject(text)
|
||||||
if err := json.Unmarshal([]byte(text), &responseObj); err != nil {
|
|
||||||
responseObj = map[string]interface{}{"result": text}
|
|
||||||
}
|
|
||||||
respBytes, _ := json.Marshal(responseObj)
|
|
||||||
|
|
||||||
functionParts = append(functionParts, GeminiPart{
|
functionParts = append(functionParts, GeminiPart{
|
||||||
FunctionResponse: &GeminiFunctionResponse{
|
FunctionResponse: &GeminiFunctionResponse{
|
||||||
@@ -278,7 +265,7 @@ func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.Unified
|
|||||||
}
|
}
|
||||||
|
|
||||||
if foundAny {
|
if foundAny {
|
||||||
contents = append(contents, GeminiContent{Role: "function", Parts: functionParts})
|
contents = append(contents, GeminiContent{Role: "user", Parts: functionParts})
|
||||||
i = j - 1 // Advance outer loop past the tool messages we consumed
|
i = j - 1 // Advance outer loop past the tool messages we consumed
|
||||||
} else {
|
} else {
|
||||||
// If no tool results found but assistant made calls, Gemini WILL error.
|
// If no tool results found but assistant made calls, Gemini WILL error.
|
||||||
@@ -353,7 +340,9 @@ func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.Unified
|
|||||||
geminiTool := GeminiTool{FunctionDeclarations: []models.FunctionDef{}}
|
geminiTool := GeminiTool{FunctionDeclarations: []models.FunctionDef{}}
|
||||||
for _, t := range req.Tools {
|
for _, t := range req.Tools {
|
||||||
if t.Type == "function" {
|
if t.Type == "function" {
|
||||||
geminiTool.FunctionDeclarations = append(geminiTool.FunctionDeclarations, t.Function)
|
funcDef := t.Function
|
||||||
|
funcDef.Parameters = cleanParametersSchema(funcDef.Parameters)
|
||||||
|
geminiTool.FunctionDeclarations = append(geminiTool.FunctionDeclarations, funcDef)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(geminiTool.FunctionDeclarations) > 0 {
|
if len(geminiTool.FunctionDeclarations) > 0 {
|
||||||
@@ -367,15 +356,16 @@ func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.Unified
|
|||||||
if strings.Contains(lowerModel, "preview") ||
|
if strings.Contains(lowerModel, "preview") ||
|
||||||
strings.Contains(lowerModel, "thinking") ||
|
strings.Contains(lowerModel, "thinking") ||
|
||||||
strings.Contains(lowerModel, "gemini-") ||
|
strings.Contains(lowerModel, "gemini-") ||
|
||||||
hasMappedTools {
|
hasMappedTools ||
|
||||||
// Use v1beta for preview, newer models, or when using tools
|
hasHistoryToolCalls(contents) {
|
||||||
|
// Use v1beta for preview, newer models, tool use, or historical tool calls
|
||||||
if !strings.Contains(baseURL, "v1beta") {
|
if !strings.Contains(baseURL, "v1beta") {
|
||||||
baseURL = strings.Replace(baseURL, "/v1", "/v1beta", 1)
|
baseURL = strings.Replace(baseURL, "/v1", "/v1beta", 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/models/%s:generateContent?key=%s", baseURL, req.Model, p.apiKey)
|
url := fmt.Sprintf("%s/models/%s:generateContent?key=%s", baseURL, req.Model, p.apiKey)
|
||||||
fmt.Printf("[Gemini] POST %s\n", url)
|
fmt.Printf("[Gemini] POST %s\n", SanitizeURL(url))
|
||||||
|
|
||||||
resp, err := p.client.R().
|
resp, err := p.client.R().
|
||||||
SetContext(ctx).
|
SetContext(ctx).
|
||||||
@@ -481,15 +471,7 @@ func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.Unified
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.UnifiedRequest) (<-chan *models.ChatCompletionStreamResponse, error) {
|
func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.UnifiedRequest) (<-chan *models.ChatCompletionStreamResponse, error) {
|
||||||
// Map deprecated or preview model names to active equivalents
|
req.Model = normalizeGeminiModel(req.Model)
|
||||||
switch req.Model {
|
|
||||||
case "gemini-2.0-flash":
|
|
||||||
req.Model = "gemini-2.5-flash"
|
|
||||||
case "gemini-3-flash":
|
|
||||||
req.Model = "gemini-3-flash-preview"
|
|
||||||
case "gemini-3-pro":
|
|
||||||
req.Model = "gemini-3-pro-preview"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simplified Gemini mapping
|
// Simplified Gemini mapping
|
||||||
var contents []GeminiContent
|
var contents []GeminiContent
|
||||||
@@ -509,6 +491,7 @@ func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.U
|
|||||||
Name: tc.Function.Name,
|
Name: tc.Function.Name,
|
||||||
Args: json.RawMessage(tc.Function.Arguments),
|
Args: json.RawMessage(tc.Function.Arguments),
|
||||||
},
|
},
|
||||||
|
ThoughtSignature: "skip_thought_signature_validator",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
contents = append(contents, GeminiContent{Role: "model", Parts: parts})
|
contents = append(contents, GeminiContent{Role: "model", Parts: parts})
|
||||||
@@ -522,16 +505,9 @@ func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.U
|
|||||||
if len(m.Content) > 0 {
|
if len(m.Content) > 0 {
|
||||||
text = m.Content[0].Text
|
text = m.Content[0].Text
|
||||||
}
|
}
|
||||||
name := "unknown_function"
|
name := resolveToolName(m, msg.ToolCalls, j-i-1)
|
||||||
if m.Name != nil {
|
|
||||||
name = *m.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
var responseObj interface{}
|
respBytes := ensureJSONObject(text)
|
||||||
if err := json.Unmarshal([]byte(text), &responseObj); err != nil {
|
|
||||||
responseObj = map[string]interface{}{"result": text}
|
|
||||||
}
|
|
||||||
respBytes, _ := json.Marshal(responseObj)
|
|
||||||
|
|
||||||
functionParts = append(functionParts, GeminiPart{
|
functionParts = append(functionParts, GeminiPart{
|
||||||
FunctionResponse: &GeminiFunctionResponse{
|
FunctionResponse: &GeminiFunctionResponse{
|
||||||
@@ -544,7 +520,7 @@ func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.U
|
|||||||
}
|
}
|
||||||
|
|
||||||
if foundAny {
|
if foundAny {
|
||||||
contents = append(contents, GeminiContent{Role: "function", Parts: functionParts})
|
contents = append(contents, GeminiContent{Role: "user", Parts: functionParts})
|
||||||
i = j - 1
|
i = j - 1
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -610,7 +586,9 @@ func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.U
|
|||||||
geminiTool := GeminiTool{FunctionDeclarations: []models.FunctionDef{}}
|
geminiTool := GeminiTool{FunctionDeclarations: []models.FunctionDef{}}
|
||||||
for _, t := range req.Tools {
|
for _, t := range req.Tools {
|
||||||
if t.Type == "function" {
|
if t.Type == "function" {
|
||||||
geminiTool.FunctionDeclarations = append(geminiTool.FunctionDeclarations, t.Function)
|
funcDef := t.Function
|
||||||
|
funcDef.Parameters = cleanParametersSchema(funcDef.Parameters)
|
||||||
|
geminiTool.FunctionDeclarations = append(geminiTool.FunctionDeclarations, funcDef)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(geminiTool.FunctionDeclarations) > 0 {
|
if len(geminiTool.FunctionDeclarations) > 0 {
|
||||||
@@ -624,8 +602,9 @@ func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.U
|
|||||||
if strings.Contains(lowerModel, "preview") ||
|
if strings.Contains(lowerModel, "preview") ||
|
||||||
strings.Contains(lowerModel, "thinking") ||
|
strings.Contains(lowerModel, "thinking") ||
|
||||||
strings.Contains(lowerModel, "gemini-") ||
|
strings.Contains(lowerModel, "gemini-") ||
|
||||||
hasMappedTools {
|
hasMappedTools ||
|
||||||
// Use v1beta for preview, newer models, or when using tools
|
hasHistoryToolCalls(contents) {
|
||||||
|
// Use v1beta for preview, newer models, tool use, or historical tool calls
|
||||||
if !strings.Contains(baseURL, "v1beta") {
|
if !strings.Contains(baseURL, "v1beta") {
|
||||||
baseURL = strings.Replace(baseURL, "/v1", "/v1beta", 1)
|
baseURL = strings.Replace(baseURL, "/v1", "/v1beta", 1)
|
||||||
}
|
}
|
||||||
@@ -633,7 +612,7 @@ func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.U
|
|||||||
|
|
||||||
// Use streamGenerateContent for streaming
|
// Use streamGenerateContent for streaming
|
||||||
url := fmt.Sprintf("%s/models/%s:streamGenerateContent?key=%s", baseURL, req.Model, p.apiKey)
|
url := fmt.Sprintf("%s/models/%s:streamGenerateContent?key=%s", baseURL, req.Model, p.apiKey)
|
||||||
fmt.Printf("[Gemini-Stream] POST %s\n", url)
|
fmt.Printf("[Gemini-Stream] POST %s\n", SanitizeURL(url))
|
||||||
|
|
||||||
resp, err := p.client.R().
|
resp, err := p.client.R().
|
||||||
SetContext(ctx).
|
SetContext(ctx).
|
||||||
@@ -652,6 +631,9 @@ func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.U
|
|||||||
msg = string(body)
|
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)
|
return nil, fmt.Errorf("Gemini API error (%d): %s", resp.StatusCode(), msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -668,3 +650,114 @@ func uint32Ptr(v uint32) *uint32 {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cleanGeminiSchema(schema map[string]interface{}) {
|
||||||
|
delete(schema, "additionalProperties")
|
||||||
|
delete(schema, "$schema")
|
||||||
|
for _, v := range schema {
|
||||||
|
if subMap, ok := v.(map[string]interface{}); ok {
|
||||||
|
cleanGeminiSchema(subMap)
|
||||||
|
} else if subList, ok := v.([]interface{}); ok {
|
||||||
|
for _, item := range subList {
|
||||||
|
if itemMap, ok := item.(map[string]interface{}); ok {
|
||||||
|
cleanGeminiSchema(itemMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanParametersSchema(raw json.RawMessage) json.RawMessage {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
var schema map[string]interface{}
|
||||||
|
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
cleanGeminiSchema(schema)
|
||||||
|
cleaned, err := json.Marshal(schema)
|
||||||
|
if err != nil {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
return json.RawMessage(cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureJSONObject ensures that a tool result string is serialized as a JSON object
|
||||||
|
// for Gemini's functionResponse.response field, which must always be an object.
|
||||||
|
// If the text is a JSON array, primitive, or invalid JSON, it gets wrapped in {"result": ...}.
|
||||||
|
func ensureJSONObject(text string) []byte {
|
||||||
|
if text == "" {
|
||||||
|
b, _ := json.Marshal(map[string]interface{}{"result": ""})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed interface{}
|
||||||
|
if err := json.Unmarshal([]byte(text), &parsed); err != nil {
|
||||||
|
// Not valid JSON — wrap as string
|
||||||
|
b, _ := json.Marshal(map[string]interface{}{"result": text})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow map types through; wrap everything else
|
||||||
|
if _, ok := parsed.(map[string]interface{}); ok {
|
||||||
|
return []byte(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// It's a JSON array, number, string, bool, or null — wrap it
|
||||||
|
b, _ := json.Marshal(map[string]interface{}{"result": parsed})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveToolName determines the function name for a tool response message.
|
||||||
|
// It tries: 1) the Name field on the tool message, 2) matching by ToolCallID
|
||||||
|
// against the preceding assistant's tool calls, 3) positional index match.
|
||||||
|
func resolveToolName(toolMsg models.UnifiedMessage, toolCalls []models.ToolCall, posIndex int) string {
|
||||||
|
if toolMsg.Name != nil && *toolMsg.Name != "" {
|
||||||
|
return *toolMsg.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to match by tool_call_id
|
||||||
|
if toolMsg.ToolCallID != nil && *toolMsg.ToolCallID != "" {
|
||||||
|
for _, tc := range toolCalls {
|
||||||
|
if tc.ID == *toolMsg.ToolCallID {
|
||||||
|
return tc.Function.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Positional fallback
|
||||||
|
if posIndex >= 0 && posIndex < len(toolCalls) {
|
||||||
|
return toolCalls[posIndex].Function.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown_function"
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeGeminiModel(model string) string {
|
||||||
|
switch model {
|
||||||
|
case "gemini-2.0-flash", "gemini-1.5-flash":
|
||||||
|
return "gemini-3.5-flash-lite"
|
||||||
|
case "gemini-3-flash", "gemini-3-flash-preview":
|
||||||
|
return "gemini-3.5-flash-lite"
|
||||||
|
case "gemini-3-pro", "gemini-3-pro-preview", "gemini-1.5-pro":
|
||||||
|
return "gemini-2.5-pro"
|
||||||
|
default:
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasHistoryToolCalls(contents []GeminiContent) bool {
|
||||||
|
for _, c := range contents {
|
||||||
|
if c.Role == "function" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, p := range c.Parts {
|
||||||
|
if p.FunctionCall != nil || p.FunctionResponse != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ type GrokProvider struct {
|
|||||||
|
|
||||||
func NewGrokProvider(cfg config.GrokConfig, apiKey string) *GrokProvider {
|
func NewGrokProvider(cfg config.GrokConfig, apiKey string) *GrokProvider {
|
||||||
return &GrokProvider{
|
return &GrokProvider{
|
||||||
client: resty.New().SetTimeout(10 * time.Minute),
|
client: NewOptimizedRestyClient(10 * time.Minute),
|
||||||
config: cfg,
|
config: cfg,
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
}
|
}
|
||||||
|
|||||||
+110
-25
@@ -5,11 +5,53 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gophergate/internal/models"
|
"gophergate/internal/models"
|
||||||
|
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var keySanitizeRegex = regexp.MustCompile(`(?i)(key|api_key|secret)=[^&]+`)
|
||||||
|
|
||||||
|
// SanitizeURL strips sensitive key query parameters from URLs before logging.
|
||||||
|
func SanitizeURL(rawURL string) string {
|
||||||
|
return keySanitizeRegex.ReplaceAllString(rawURL, "$1=REDACTED")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared HTTP transport configured with high connection pooling, TCP keep-alive,
|
||||||
|
// and HTTP/2 multiplexing to minimize latency when connecting to upstream LLM providers.
|
||||||
|
var sharedHTTPTransport = &http.Transport{
|
||||||
|
Proxy: http.ProxyFromEnvironment,
|
||||||
|
DialContext: (&net.Dialer{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
KeepAlive: 30 * time.Second,
|
||||||
|
}).DialContext,
|
||||||
|
ForceAttemptHTTP2: true,
|
||||||
|
MaxIdleConns: 200,
|
||||||
|
MaxIdleConnsPerHost: 50,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOptimizedRestyClient creates a resty client equipped with HTTP connection pooling.
|
||||||
|
func NewOptimizedRestyClient(timeout time.Duration) *resty.Client {
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Transport: sharedHTTPTransport,
|
||||||
|
}
|
||||||
|
client := resty.NewWithClient(httpClient)
|
||||||
|
if timeout > 0 {
|
||||||
|
client.SetTimeout(timeout)
|
||||||
|
}
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
func sanitizeFunctionName(name string) string {
|
func sanitizeFunctionName(name string) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for _, ch := range name {
|
for _, ch := range name {
|
||||||
@@ -116,6 +158,9 @@ func MessagesToOpenAIJSON(messages []models.UnifiedMessage) ([]interface{}, erro
|
|||||||
if m.Name != nil {
|
if m.Name != nil {
|
||||||
msg["name"] = *m.Name
|
msg["name"] = *m.Name
|
||||||
}
|
}
|
||||||
|
if m.Prefix != nil {
|
||||||
|
msg["prefix"] = *m.Prefix
|
||||||
|
}
|
||||||
result = append(result, msg)
|
result = append(result, msg)
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -402,6 +447,10 @@ type geminiStreamChunk struct {
|
|||||||
Parts []struct {
|
Parts []struct {
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
Thought string `json:"thought,omitempty"`
|
Thought string `json:"thought,omitempty"`
|
||||||
|
FunctionCall *struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Args json.RawMessage `json:"args"`
|
||||||
|
} `json:"functionCall,omitempty"`
|
||||||
} `json:"parts"`
|
} `json:"parts"`
|
||||||
} `json:"content"`
|
} `json:"content"`
|
||||||
FinishReason string `json:"finishReason"`
|
FinishReason string `json:"finishReason"`
|
||||||
@@ -424,6 +473,7 @@ func emitGeminiChunk(ch chan<- *models.ChatCompletionStreamResponse, chunk *gemi
|
|||||||
content := ""
|
content := ""
|
||||||
var reasoning *string
|
var reasoning *string
|
||||||
var finishReason *string
|
var finishReason *string
|
||||||
|
var toolCalls []models.ToolCallDelta
|
||||||
if len(chunk.Candidates) > 0 {
|
if len(chunk.Candidates) > 0 {
|
||||||
for _, p := range chunk.Candidates[0].Content.Parts {
|
for _, p := range chunk.Candidates[0].Content.Parts {
|
||||||
if p.Text != "" {
|
if p.Text != "" {
|
||||||
@@ -435,8 +485,26 @@ func emitGeminiChunk(ch chan<- *models.ChatCompletionStreamResponse, chunk *gemi
|
|||||||
}
|
}
|
||||||
*reasoning += p.Thought
|
*reasoning += p.Thought
|
||||||
}
|
}
|
||||||
|
if p.FunctionCall != nil {
|
||||||
|
name := p.FunctionCall.Name
|
||||||
|
args := string(p.FunctionCall.Args)
|
||||||
|
tcID := fmt.Sprintf("call_%s", name)
|
||||||
|
tcType := "function"
|
||||||
|
toolCalls = append(toolCalls, models.ToolCallDelta{
|
||||||
|
Index: uint32(len(toolCalls)),
|
||||||
|
ID: &tcID,
|
||||||
|
Type: &tcType,
|
||||||
|
Function: &models.FunctionCallDelta{
|
||||||
|
Name: &name,
|
||||||
|
Arguments: &args,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fr := strings.ToLower(chunk.Candidates[0].FinishReason)
|
fr := strings.ToLower(chunk.Candidates[0].FinishReason)
|
||||||
|
if len(toolCalls) > 0 && fr == "" {
|
||||||
|
fr = "tool_calls"
|
||||||
|
}
|
||||||
finishReason = &fr
|
finishReason = &fr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,6 +519,7 @@ func emitGeminiChunk(ch chan<- *models.ChatCompletionStreamResponse, chunk *gemi
|
|||||||
Delta: models.ChatStreamDelta{
|
Delta: models.ChatStreamDelta{
|
||||||
Content: &content,
|
Content: &content,
|
||||||
ReasoningContent: reasoning,
|
ReasoningContent: reasoning,
|
||||||
|
ToolCalls: toolCalls,
|
||||||
},
|
},
|
||||||
FinishReason: finishReason,
|
FinishReason: finishReason,
|
||||||
},
|
},
|
||||||
@@ -490,9 +559,12 @@ func StreamGemini(ctx io.ReadCloser, model string) (<-chan *models.ChatCompletio
|
|||||||
first := string(peek[:n])
|
first := string(peek[:n])
|
||||||
|
|
||||||
if first[0] == '[' {
|
if first[0] == '[' {
|
||||||
// JSON array format
|
// JSON array format — stream parse it in real-time
|
||||||
rest, _ := io.ReadAll(ctx)
|
combined := io.MultiReader(
|
||||||
streamGeminiJSONArray(append([]byte(first), rest...), ch, model)
|
strings.NewReader(string(peek[:n])),
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
streamGeminiJSONArrayStream(combined, ch, model)
|
||||||
return
|
return
|
||||||
} else if strings.HasPrefix(first, "data:") || strings.HasPrefix(first, "data: ") {
|
} else if strings.HasPrefix(first, "data:") || strings.HasPrefix(first, "data: ") {
|
||||||
// SSE format — pre-pend the peeked bytes then run SSE scanner
|
// SSE format — pre-pend the peeked bytes then run SSE scanner
|
||||||
@@ -515,37 +587,50 @@ func StreamGemini(ctx io.ReadCloser, model string) (<-chan *models.ChatCompletio
|
|||||||
return ch, nil
|
return ch, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// readAll reads remaining bytes from a reader (keeps the function signature simple
|
func streamGeminiJSONArrayStream(r io.Reader, ch chan<- *models.ChatCompletionStreamResponse, model string) {
|
||||||
// for the JSON array fallback path).
|
dec := json.NewDecoder(r)
|
||||||
func readAll(r io.Reader) []byte {
|
|
||||||
b, _ := io.ReadAll(r)
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
func streamGeminiJSONArray(data []byte, ch chan<- *models.ChatCompletionStreamResponse, model string) {
|
// Read open bracket '['
|
||||||
var chunks []geminiStreamChunk
|
t, err := dec.Token()
|
||||||
if err := json.Unmarshal(data, &chunks); err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[Gemini-Stream] JSON array parse error: %v\n", err)
|
fmt.Printf("[Gemini-Stream] JSON array token error: %v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Track the last chunk with usage for the final emission
|
delim, ok := t.(json.Delim)
|
||||||
|
if !ok || delim != '[' {
|
||||||
|
fmt.Printf("[Gemini-Stream] JSON array expected '[', got %v\n", t)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var lastUsage *geminiStreamChunk
|
var lastUsage *geminiStreamChunk
|
||||||
for i := range chunks {
|
|
||||||
if chunks[i].UsageMetadata.TotalTokenCount > 0 {
|
// Read array elements
|
||||||
lastUsage = &chunks[i]
|
for dec.More() {
|
||||||
|
var chunk geminiStreamChunk
|
||||||
|
if err := dec.Decode(&chunk); err != nil {
|
||||||
|
fmt.Printf("[Gemini-Stream] JSON array decode error: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if chunk.UsageMetadata.TotalTokenCount > 0 {
|
||||||
|
temp := chunk
|
||||||
|
lastUsage = &temp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit content-bearing chunks immediately
|
||||||
|
if len(chunk.Candidates) > 0 {
|
||||||
|
emitGeminiChunk(ch, &chunk, model)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if lastUsage != nil {
|
|
||||||
// Emit a synthetic final chunk with usage data
|
// Read close bracket ']'
|
||||||
if len(lastUsage.Candidates) == 0 && lastUsage.UsageMetadata.TotalTokenCount > 0 {
|
_, _ = 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)
|
emitGeminiChunk(ch, lastUsage, model)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Also emit each content-bearing chunk
|
|
||||||
for i := range chunks {
|
|
||||||
emitGeminiChunk(ch, &chunks[i], model)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func streamGeminiSSE(r io.Reader, ch chan<- *models.ChatCompletionStreamResponse, model string) {
|
func streamGeminiSSE(r io.Reader, ch chan<- *models.ChatCompletionStreamResponse, model string) {
|
||||||
scanner := bufio.NewScanner(r)
|
scanner := bufio.NewScanner(r)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ type MoonshotProvider struct {
|
|||||||
|
|
||||||
func NewMoonshotProvider(cfg config.MoonshotConfig, apiKey string) *MoonshotProvider {
|
func NewMoonshotProvider(cfg config.MoonshotConfig, apiKey string) *MoonshotProvider {
|
||||||
return &MoonshotProvider{
|
return &MoonshotProvider{
|
||||||
client: resty.New().SetTimeout(10 * time.Minute),
|
client: NewOptimizedRestyClient(10 * time.Minute),
|
||||||
config: cfg,
|
config: cfg,
|
||||||
apiKey: strings.TrimSpace(apiKey),
|
apiKey: strings.TrimSpace(apiKey),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,9 @@ type OllamaProvider struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewOllamaProvider(cfg config.OllamaConfig) *OllamaProvider {
|
func NewOllamaProvider(cfg config.OllamaConfig) *OllamaProvider {
|
||||||
client := resty.New()
|
client := NewOptimizedRestyClient(15 * time.Minute)
|
||||||
// Set reasonable timeouts for local Ollama server (longer for larger models)
|
// Set reasonable timeouts for local Ollama server (longer for larger models)
|
||||||
// For streaming, we want a very long timeout or none at all to handle generation time
|
// For streaming, we want a very long timeout or none at all to handle generation time
|
||||||
client.SetTimeout(15 * time.Minute)
|
|
||||||
client.SetRetryCount(2)
|
client.SetRetryCount(2)
|
||||||
client.SetRetryWaitTime(1 * time.Second)
|
client.SetRetryWaitTime(1 * time.Second)
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type OpenAIProvider struct {
|
|||||||
|
|
||||||
func NewOpenAIProvider(cfg config.OpenAIConfig, apiKey string) *OpenAIProvider {
|
func NewOpenAIProvider(cfg config.OpenAIConfig, apiKey string) *OpenAIProvider {
|
||||||
return &OpenAIProvider{
|
return &OpenAIProvider{
|
||||||
client: resty.New().SetTimeout(10 * time.Minute),
|
client: NewOptimizedRestyClient(10 * time.Minute),
|
||||||
config: cfg,
|
config: cfg,
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
}
|
}
|
||||||
@@ -57,6 +57,9 @@ func (p *OpenAIProvider) ChatCompletion(ctx context.Context, req *models.Unified
|
|||||||
delete(body, "max_tokens")
|
delete(body, "max_tokens")
|
||||||
body["max_completion_tokens"] = maxTokens
|
body["max_completion_tokens"] = maxTokens
|
||||||
}
|
}
|
||||||
|
if len(req.Tools) > 0 {
|
||||||
|
body["reasoning_effort"] = "none"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := p.client.R().
|
resp, err := p.client.R().
|
||||||
@@ -169,6 +172,9 @@ func (p *OpenAIProvider) ChatCompletionStream(ctx context.Context, req *models.U
|
|||||||
delete(body, "max_tokens")
|
delete(body, "max_tokens")
|
||||||
body["max_completion_tokens"] = maxTokens
|
body["max_completion_tokens"] = maxTokens
|
||||||
}
|
}
|
||||||
|
if len(req.Tools) > 0 {
|
||||||
|
body["reasoning_effort"] = "none"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := p.client.R().
|
resp, err := p.client.R().
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ type XiaomiProvider struct {
|
|||||||
|
|
||||||
func NewXiaomiProvider(cfg config.XiaomiConfig, apiKey string) *XiaomiProvider {
|
func NewXiaomiProvider(cfg config.XiaomiConfig, apiKey string) *XiaomiProvider {
|
||||||
return &XiaomiProvider{
|
return &XiaomiProvider{
|
||||||
client: resty.New().SetTimeout(10 * time.Minute),
|
client: NewOptimizedRestyClient(10 * time.Minute),
|
||||||
config: cfg,
|
config: cfg,
|
||||||
apiKey: strings.TrimSpace(apiKey),
|
apiKey: strings.TrimSpace(apiKey),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"gophergate/internal/db"
|
"gophergate/internal/db"
|
||||||
)
|
)
|
||||||
@@ -32,6 +33,7 @@ type ClassifierFunc func(ctx context.Context, selectorModel, systemPrompt, userM
|
|||||||
|
|
||||||
// Router resolves model groups to concrete models.
|
// Router resolves model groups to concrete models.
|
||||||
type Router struct {
|
type Router struct {
|
||||||
|
mu sync.RWMutex
|
||||||
groups map[string]db.ModelGroup
|
groups map[string]db.ModelGroup
|
||||||
classify ClassifierFunc
|
classify ClassifierFunc
|
||||||
}
|
}
|
||||||
@@ -50,6 +52,8 @@ func New(groups []db.ModelGroup, classify ClassifierFunc) *Router {
|
|||||||
|
|
||||||
// Groups returns all registered model group IDs.
|
// Groups returns all registered model group IDs.
|
||||||
func (r *Router) Groups() []string {
|
func (r *Router) Groups() []string {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
ids := make([]string, 0, len(r.groups))
|
ids := make([]string, 0, len(r.groups))
|
||||||
for id := range r.groups {
|
for id := range r.groups {
|
||||||
ids = append(ids, id)
|
ids = append(ids, id)
|
||||||
@@ -59,13 +63,17 @@ func (r *Router) Groups() []string {
|
|||||||
|
|
||||||
// IsGroup returns true if the model name is a group ID.
|
// IsGroup returns true if the model name is a group ID.
|
||||||
func (r *Router) IsGroup(modelID string) bool {
|
func (r *Router) IsGroup(modelID string) bool {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
_, ok := r.groups[modelID]
|
_, ok := r.groups[modelID]
|
||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route resolves a group to a concrete model.
|
// Route resolves a group to a concrete model.
|
||||||
func (r *Router) Route(ctx context.Context, groupID string, routeCtx *RouteContext) (*Decision, error) {
|
func (r *Router) Route(ctx context.Context, groupID string, routeCtx *RouteContext) (*Decision, error) {
|
||||||
|
r.mu.RLock()
|
||||||
group, ok := r.groups[groupID]
|
group, ok := r.groups[groupID]
|
||||||
|
r.mu.RUnlock()
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("unknown model group: %s", groupID)
|
return nil, fmt.Errorf("unknown model group: %s", groupID)
|
||||||
}
|
}
|
||||||
@@ -133,8 +141,12 @@ func (r *Router) RouteToConcrete(ctx context.Context, modelID string, routeCtx *
|
|||||||
|
|
||||||
// Reload replaces the group definitions without recreating the router.
|
// Reload replaces the group definitions without recreating the router.
|
||||||
func (r *Router) Reload(groups []db.ModelGroup) {
|
func (r *Router) Reload(groups []db.ModelGroup) {
|
||||||
r.groups = make(map[string]db.ModelGroup)
|
newGroups := make(map[string]db.ModelGroup)
|
||||||
for _, g := range groups {
|
for _, g := range groups {
|
||||||
r.groups[g.ID] = g
|
newGroups[g.ID] = g
|
||||||
}
|
}
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
r.groups = newGroups
|
||||||
|
r.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,21 @@ func (s *Server) handleUsageSummary(c *gin.Context) {
|
|||||||
miscStats.AvgResponseTime = 0.0
|
miscStats.AvgResponseTime = 0.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lifetime days & start date
|
||||||
|
var lifetimeStats struct {
|
||||||
|
TotalDays int `db:"total_days"`
|
||||||
|
FirstDate string `db:"first_date"`
|
||||||
|
}
|
||||||
|
_ = s.database.Get(&lifetimeStats, `
|
||||||
|
SELECT
|
||||||
|
CAST(ROUND(COALESCE(julianday(substr(MAX(timestamp), 1, 19)) - julianday(substr(MIN(timestamp), 1, 19)), 1.0)) AS INTEGER) as total_days,
|
||||||
|
COALESCE(substr(MIN(timestamp), 1, 10), '') as first_date
|
||||||
|
FROM llm_requests
|
||||||
|
`)
|
||||||
|
if lifetimeStats.TotalDays < 1 {
|
||||||
|
lifetimeStats.TotalDays = 1
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
||||||
"total_requests": totalStats.TotalRequests,
|
"total_requests": totalStats.TotalRequests,
|
||||||
"total_tokens": totalStats.TotalTokens,
|
"total_tokens": totalStats.TotalTokens,
|
||||||
@@ -134,6 +149,8 @@ func (s *Server) handleUsageSummary(c *gin.Context) {
|
|||||||
"today_cost": todayStats.TodayCost,
|
"today_cost": todayStats.TodayCost,
|
||||||
"error_rate": miscStats.ErrorRate,
|
"error_rate": miscStats.ErrorRate,
|
||||||
"avg_response_time": miscStats.AvgResponseTime,
|
"avg_response_time": miscStats.AvgResponseTime,
|
||||||
|
"total_days": lifetimeStats.TotalDays,
|
||||||
|
"first_date": lifetimeStats.FirstDate,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ func (s *Server) handleGetProviders(c *gin.Context) {
|
|||||||
|
|
||||||
status := "disabled"
|
status := "disabled"
|
||||||
if enabled {
|
if enabled {
|
||||||
if _, ok := s.providers[id]; ok {
|
if _, ok := s.getProvider(id); ok {
|
||||||
status = "online"
|
status = "online"
|
||||||
} else {
|
} else {
|
||||||
status = "error"
|
status = "error"
|
||||||
@@ -203,7 +203,7 @@ func (s *Server) handleUpdateProvider(c *gin.Context) {
|
|||||||
|
|
||||||
func (s *Server) handleTestProvider(c *gin.Context) {
|
func (s *Server) handleTestProvider(c *gin.Context) {
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
provider, ok := s.providers[name]
|
provider, ok := s.getProvider(name)
|
||||||
if !ok {
|
if !ok {
|
||||||
c.JSON(http.StatusNotFound, ErrorResponse(fmt.Sprintf("Provider %s not found or not enabled", name)))
|
c.JSON(http.StatusNotFound, ErrorResponse(fmt.Sprintf("Provider %s not found or not enabled", name)))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gophergate/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
func TestIsSoftwareDevelopment(t *testing.T) {
|
func TestIsSoftwareDevelopment(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -29,3 +33,68 @@ func TestIsSoftwareDevelopment(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetRouteCtxTags(t *testing.T) {
|
||||||
|
s := &Server{}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
routeCtx *router.RouteContext
|
||||||
|
expectedTags []string
|
||||||
|
mustContain []string
|
||||||
|
mustExclude []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Standard query with tools",
|
||||||
|
routeCtx: &router.RouteContext{
|
||||||
|
UserMessage: "Search the web for weather in Paris",
|
||||||
|
RequiresToolCalling: true,
|
||||||
|
},
|
||||||
|
mustExclude: []string{"tool-heavy", "swe-bench"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Coding query with tools",
|
||||||
|
routeCtx: &router.RouteContext{
|
||||||
|
UserMessage: "Write a python script to parse logs",
|
||||||
|
RequiresToolCalling: true,
|
||||||
|
IsSoftwareDevelopment: true,
|
||||||
|
},
|
||||||
|
mustContain: []string{"tool-heavy", "swe-bench"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Agent query with tools",
|
||||||
|
routeCtx: &router.RouteContext{
|
||||||
|
UserMessage: "agent please orchestrate the multi-agent task",
|
||||||
|
RequiresToolCalling: true,
|
||||||
|
},
|
||||||
|
mustContain: []string{"tool-heavy", "swe-bench"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
tags := s.getRouteCtxTags(tt.routeCtx)
|
||||||
|
|
||||||
|
for _, expected := range tt.mustContain {
|
||||||
|
found := false
|
||||||
|
for _, tag := range tags {
|
||||||
|
if tag == expected {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("expected tag %q to be present, but was not in %v", expected, tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, excluded := range tt.mustExclude {
|
||||||
|
for _, tag := range tags {
|
||||||
|
if tag == excluded {
|
||||||
|
t.Errorf("expected tag %q to be excluded, but was found in %v", excluded, tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type Server struct {
|
|||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
database *db.DB
|
database *db.DB
|
||||||
providers map[string]providers.Provider
|
providers map[string]providers.Provider
|
||||||
|
providersMu sync.RWMutex
|
||||||
sessions *SessionManager
|
sessions *SessionManager
|
||||||
hub *Hub
|
hub *Hub
|
||||||
logger *RequestLogger
|
logger *RequestLogger
|
||||||
@@ -88,6 +89,8 @@ func (s *Server) RefreshProviders() error {
|
|||||||
dbMap[cfg.ID] = cfg
|
dbMap[cfg.ID] = cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
newProviders := make(map[string]providers.Provider)
|
||||||
|
|
||||||
providerIDs := []string{"openai", "gemini", "deepseek", "moonshot", "grok", "ollama", "xiaomi"}
|
providerIDs := []string{"openai", "gemini", "deepseek", "moonshot", "grok", "ollama", "xiaomi"}
|
||||||
for _, id := range providerIDs {
|
for _, id := range providerIDs {
|
||||||
// Default values from config
|
// Default values from config
|
||||||
@@ -143,7 +146,6 @@ func (s *Server) RefreshProviders() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !enabled {
|
if !enabled {
|
||||||
delete(s.providers, id)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,10 +183,14 @@ func (s *Server) RefreshProviders() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if p != nil {
|
if p != nil {
|
||||||
s.providers[id] = providers.NewCircuitBreakerProvider(p)
|
newProviders[id] = providers.NewCircuitBreakerProvider(p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.providersMu.Lock()
|
||||||
|
s.providers = newProviders
|
||||||
|
s.providersMu.Unlock()
|
||||||
|
|
||||||
s.refreshRouter()
|
s.refreshRouter()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -368,7 +374,7 @@ func (s *Server) handleResponses(c *gin.Context) {
|
|||||||
providerName = "ollama"
|
providerName = "ollama"
|
||||||
}
|
}
|
||||||
|
|
||||||
provider, ok := s.providers[providerName]
|
provider, ok := s.getProvider(providerName)
|
||||||
if !ok {
|
if !ok {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Provider %s not enabled or supported", providerName)})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Provider %s not enabled or supported", providerName)})
|
||||||
return
|
return
|
||||||
@@ -397,6 +403,7 @@ func (s *Server) handleResponses(c *gin.Context) {
|
|||||||
c.Header("Content-Type", "text/event-stream")
|
c.Header("Content-Type", "text/event-stream")
|
||||||
c.Header("Cache-Control", "no-cache")
|
c.Header("Cache-Control", "no-cache")
|
||||||
c.Header("Connection", "keep-alive")
|
c.Header("Connection", "keep-alive")
|
||||||
|
c.Header("X-Accel-Buffering", "no")
|
||||||
|
|
||||||
var lastUsage *models.ResponsesUsage
|
var lastUsage *models.ResponsesUsage
|
||||||
c.Stream(func(w io.Writer) bool {
|
c.Stream(func(w io.Writer) bool {
|
||||||
@@ -520,6 +527,13 @@ func (s *Server) handleListModels(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) getProvider(name string) (providers.Provider, bool) {
|
||||||
|
s.providersMu.RLock()
|
||||||
|
defer s.providersMu.RUnlock()
|
||||||
|
p, ok := s.providers[name]
|
||||||
|
return p, ok
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) selectProvider(modelID string) (providers.Provider, string, error) {
|
func (s *Server) selectProvider(modelID string) (providers.Provider, string, error) {
|
||||||
providerName := "openai" // default
|
providerName := "openai" // default
|
||||||
modelLower := strings.ToLower(modelID)
|
modelLower := strings.ToLower(modelID)
|
||||||
@@ -546,7 +560,7 @@ func (s *Server) selectProvider(modelID string) (providers.Provider, string, err
|
|||||||
providerName = "xiaomi"
|
providerName = "xiaomi"
|
||||||
}
|
}
|
||||||
|
|
||||||
p, ok := s.providers[providerName]
|
p, ok := s.getProvider(providerName)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, "", fmt.Errorf("Provider %s not enabled or supported", providerName)
|
return nil, "", fmt.Errorf("Provider %s not enabled or supported", providerName)
|
||||||
}
|
}
|
||||||
@@ -561,6 +575,31 @@ func (s *Server) handleChatCompletions(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prune message history to sliding window if configured
|
||||||
|
if s.cfg.Server.MaxHistoryMessages > 0 && len(req.Messages) > s.cfg.Server.MaxHistoryMessages {
|
||||||
|
var systemMsgs []models.ChatMessage
|
||||||
|
var otherMsgs []models.ChatMessage
|
||||||
|
for _, msg := range req.Messages {
|
||||||
|
if msg.Role == "system" {
|
||||||
|
systemMsgs = append(systemMsgs, msg)
|
||||||
|
} else {
|
||||||
|
otherMsgs = append(otherMsgs, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keepCount := s.cfg.Server.MaxHistoryMessages - len(systemMsgs)
|
||||||
|
if keepCount < 1 {
|
||||||
|
keepCount = 1
|
||||||
|
}
|
||||||
|
if len(otherMsgs) > keepCount {
|
||||||
|
startIndex := len(otherMsgs) - keepCount
|
||||||
|
otherMsgs = otherMsgs[startIndex:]
|
||||||
|
log.Printf("[DEBUG] Pruned message history: kept %d system messages and last %d messages (total %d out of %d)",
|
||||||
|
len(systemMsgs), len(otherMsgs), len(systemMsgs)+len(otherMsgs), len(req.Messages))
|
||||||
|
req.Messages = append(systemMsgs, otherMsgs...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Strip common prefixes and prepare model ID
|
// Strip common prefixes and prepare model ID
|
||||||
modelID := req.Model
|
modelID := req.Model
|
||||||
prefixes := []string{"gemini/", "google/", "openai/", "deepseek/", "moonshot/", "grok/", "ollama/", "xiaomi/"}
|
prefixes := []string{"gemini/", "google/", "openai/", "deepseek/", "moonshot/", "grok/", "ollama/", "xiaomi/"}
|
||||||
@@ -615,7 +654,10 @@ func (s *Server) handleChatCompletions(c *gin.Context) {
|
|||||||
|
|
||||||
// Inject or cap max_tokens from model registry.
|
// Inject or cap max_tokens from model registry.
|
||||||
s.registryMu.RLock()
|
s.registryMu.RLock()
|
||||||
meta := s.registry.FindModel(modelID)
|
var meta *models.ModelMetadata
|
||||||
|
if s.registry != nil {
|
||||||
|
meta = s.registry.FindModel(modelID)
|
||||||
|
}
|
||||||
s.registryMu.RUnlock()
|
s.registryMu.RUnlock()
|
||||||
|
|
||||||
if meta != nil && meta.Limit != nil && meta.Limit.Output > 0 {
|
if meta != nil && meta.Limit != nil && meta.Limit.Output > 0 {
|
||||||
@@ -658,6 +700,7 @@ func (s *Server) handleChatCompletions(c *gin.Context) {
|
|||||||
ToolCalls: msg.ToolCalls,
|
ToolCalls: msg.ToolCalls,
|
||||||
Name: msg.Name,
|
Name: msg.Name,
|
||||||
ToolCallID: msg.ToolCallID,
|
ToolCallID: msg.ToolCallID,
|
||||||
|
Prefix: msg.Prefix,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle multimodal content
|
// Handle multimodal content
|
||||||
@@ -724,6 +767,7 @@ func (s *Server) handleChatCompletions(c *gin.Context) {
|
|||||||
c.Header("Content-Type", "text/event-stream")
|
c.Header("Content-Type", "text/event-stream")
|
||||||
c.Header("Cache-Control", "no-cache")
|
c.Header("Cache-Control", "no-cache")
|
||||||
c.Header("Connection", "keep-alive")
|
c.Header("Connection", "keep-alive")
|
||||||
|
c.Header("X-Accel-Buffering", "no")
|
||||||
|
|
||||||
var lastUsage *models.Usage
|
var lastUsage *models.Usage
|
||||||
c.Stream(func(w io.Writer) bool {
|
c.Stream(func(w io.Writer) bool {
|
||||||
@@ -807,6 +851,31 @@ func (s *Server) handleImageGenerations(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ponytail: per-model valid size sets. Add new models here.
|
||||||
|
if req.Size != nil {
|
||||||
|
validSizes := map[string][]string{
|
||||||
|
"gpt-image": {"1024x1024", "1024x1536", "1536x1024", "auto"},
|
||||||
|
"dall-e-3": {"1024x1024", "1024x1792", "1792x1024", "auto"},
|
||||||
|
"dall-e-2": {"256x256", "512x512", "1024x1024", "auto"},
|
||||||
|
}
|
||||||
|
for prefix, sizes := range validSizes {
|
||||||
|
if strings.HasPrefix(req.Model, prefix) {
|
||||||
|
valid := false
|
||||||
|
for _, s := range sizes {
|
||||||
|
if *req.Size == s {
|
||||||
|
valid = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
*req.Size = "1024x1024"
|
||||||
|
log.Printf("[WARN] Unsupported size for %s, clamped to 1024x1024", req.Model)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
provider, ok := s.providers[providerName]
|
provider, ok := s.providers[providerName]
|
||||||
if !ok {
|
if !ok {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Provider %s not enabled or supported", providerName)})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Provider %s not enabled or supported", providerName)})
|
||||||
@@ -1103,7 +1172,15 @@ func (s *Server) getRouteCtxTags(routeCtx *router.RouteContext) []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if routeCtx.RequiresToolCalling {
|
hasHeavyLogic := false
|
||||||
|
for _, tag := range tags {
|
||||||
|
if tag == "heavy-logic" {
|
||||||
|
hasHeavyLogic = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if routeCtx.RequiresToolCalling && (routeCtx.IsSoftwareDevelopment || hasHeavyLogic) {
|
||||||
tags = append(tags, "tool-heavy", "multi-step-agent", "swe-bench")
|
tags = append(tags, "tool-heavy", "multi-step-agent", "swe-bench")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,18 +67,27 @@ func (s *Server) handleGetSettings(c *gin.Context) {
|
|||||||
providerCount := 0
|
providerCount := 0
|
||||||
modelCount := 0
|
modelCount := 0
|
||||||
s.registryMu.RLock()
|
s.registryMu.RLock()
|
||||||
defer s.registryMu.RUnlock()
|
|
||||||
if s.registry != nil {
|
if s.registry != nil {
|
||||||
providerCount = len(s.registry.Providers)
|
providerCount = len(s.registry.Providers)
|
||||||
for _, p := range s.registry.Providers {
|
for _, p := range s.registry.Providers {
|
||||||
modelCount += len(p.Models)
|
modelCount += len(p.Models)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
s.registryMu.RUnlock()
|
||||||
|
|
||||||
|
maskedTokens := make([]string, len(s.cfg.Server.AuthTokens))
|
||||||
|
for i, token := range s.cfg.Server.AuthTokens {
|
||||||
|
if len(token) > 8 {
|
||||||
|
maskedTokens[i] = token[:3] + "••••" + token[len(token)-4:]
|
||||||
|
} else {
|
||||||
|
maskedTokens[i] = "••••"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
||||||
"server": gin.H{
|
"server": gin.H{
|
||||||
"version": "1.0.0-go",
|
"version": "1.0.0-go",
|
||||||
"auth_tokens": s.cfg.Server.AuthTokens,
|
"auth_tokens": maskedTokens,
|
||||||
},
|
},
|
||||||
"database": gin.H{
|
"database": gin.H{
|
||||||
"type": "sqlite",
|
"type": "sqlite",
|
||||||
|
|||||||
@@ -70,17 +70,33 @@ func (s *Server) handleUpdateUser(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if req.DisplayName != nil {
|
if req.DisplayName != nil {
|
||||||
s.database.Exec("UPDATE users SET display_name = ? WHERE id = ?", req.DisplayName, id)
|
if _, err := s.database.Exec("UPDATE users SET display_name = ? WHERE id = ?", req.DisplayName, id); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, ErrorResponse("Failed to update display name"))
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if req.Role != nil {
|
if req.Role != nil {
|
||||||
s.database.Exec("UPDATE users SET role = ? WHERE id = ?", req.Role, id)
|
if _, err := s.database.Exec("UPDATE users SET role = ? WHERE id = ?", req.Role, id); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, ErrorResponse("Failed to update role"))
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if req.MustChangePassword != nil {
|
if req.MustChangePassword != nil {
|
||||||
s.database.Exec("UPDATE users SET must_change_password = ? WHERE id = ?", req.MustChangePassword, id)
|
if _, err := s.database.Exec("UPDATE users SET must_change_password = ? WHERE id = ?", req.MustChangePassword, id); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, ErrorResponse("Failed to update password flag"))
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if req.Password != nil {
|
if req.Password != nil {
|
||||||
hash, _ := bcrypt.GenerateFromPassword([]byte(*req.Password), 12)
|
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), 12)
|
||||||
s.database.Exec("UPDATE users SET password_hash = ? WHERE id = ?", string(hash), id)
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, ErrorResponse("Failed to hash password"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.database.Exec("UPDATE users SET password_hash = ? WHERE id = ?", string(hash), id); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, ErrorResponse("Failed to update password"))
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "User updated"}))
|
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "User updated"}))
|
||||||
|
|||||||
+160
-5
@@ -752,21 +752,34 @@ body {
|
|||||||
/* Stat Cards */
|
/* Stat Cards */
|
||||||
.stats-grid {
|
.stats-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
gap: 1.5rem;
|
gap: 1rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1400px) {
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 900px) and (max-width: 1399px) {
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: var(--bg1);
|
background: var(--bg1);
|
||||||
padding: var(--spacing-lg);
|
padding: 1rem 1.1rem;
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
border: 1px solid var(--bg2);
|
border: 1px solid var(--bg2);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1.25rem;
|
gap: 0.85rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card:hover {
|
.stat-card:hover {
|
||||||
@@ -1382,8 +1395,150 @@ body {
|
|||||||
border: 1px solid var(--bg2);
|
border: 1px solid var(--bg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Settings: Warning Card */
|
/* Warning Card */
|
||||||
.warning-card {
|
.warning-card {
|
||||||
border: 1px dashed var(--warning);
|
border: 1px dashed var(--warning);
|
||||||
background: rgba(215, 153, 33, 0.08);
|
background: rgba(215, 153, 33, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==================== MOBILE BAREBONES ==================== */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.sidebar {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
width: 280px;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
.sidebar.mobile-visible {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
.sidebar.collapsed {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
.sidebar.collapsed .menu-item span,
|
||||||
|
.sidebar.collapsed .menu-title,
|
||||||
|
.sidebar.collapsed .user-details {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.sidebar.collapsed .menu-item {
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 0.75rem var(--spacing-lg);
|
||||||
|
}
|
||||||
|
.sidebar.collapsed .menu-item i {
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
.sidebar.collapsed .sidebar-footer {
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
z-index: 1999;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
.sidebar-backdrop.visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
padding-left: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg1);
|
||||||
|
border: 1px solid var(--bg3);
|
||||||
|
color: var(--fg1);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.mobile-menu-btn:active {
|
||||||
|
background: var(--bg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.grid-2 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.grid-3 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
height: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-body {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
.top-bar {
|
||||||
|
padding: 0 var(--spacing-md);
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.top-bar .page-title h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.top-bar-actions {
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator .status-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitoring-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.card-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
padding: 2rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-selector {
|
||||||
|
overflow-x: auto;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
width: 95%;
|
||||||
|
margin: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.mobile-menu-btn { display: inline-flex; }
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.mobile-menu-btn { display: none; }
|
||||||
|
.sidebar-backdrop { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ponytail: single-breakpoint responsive layer, no card-based table fallback
|
||||||
|
add table→card reflow when tables get too wide to scroll horizontally */
|
||||||
|
|||||||
+9
-3
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>GopherGate - Admin Dashboard</title>
|
<title>GopherGate - Admin Dashboard</title>
|
||||||
<link rel="stylesheet" href="/css/dashboard.css?v=11">
|
<link rel="stylesheet" href="/css/dashboard.css?v=12">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
<span>GopherGate</span>
|
<span>GopherGate</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="sidebar-toggle" id="sidebar-toggle">
|
<button class="sidebar-toggle" id="sidebar-toggle">
|
||||||
<i class="fas fa-bars"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -135,11 +135,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<!-- Mobile sidebar backdrop -->
|
||||||
|
<div class="sidebar-backdrop" id="sidebar-backdrop"></div>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<header class="top-bar">
|
<header class="top-bar">
|
||||||
|
<button class="mobile-menu-btn" id="mobile-menu-btn">
|
||||||
|
<i class="fas fa-bars"></i>
|
||||||
|
</button>
|
||||||
<div class="page-title">
|
<div class="page-title">
|
||||||
<h2 id="current-page-title">Overview</h2>
|
<h2 id="page-title">Overview</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="top-bar-actions">
|
<div class="top-bar-actions">
|
||||||
<div id="connection-status" class="status-indicator">
|
<div id="connection-status" class="status-indicator">
|
||||||
|
|||||||
+53
-1
@@ -60,23 +60,75 @@ class Dashboard {
|
|||||||
const toggleBtn = document.getElementById('sidebar-toggle');
|
const toggleBtn = document.getElementById('sidebar-toggle');
|
||||||
const sidebar = document.querySelector('.sidebar');
|
const sidebar = document.querySelector('.sidebar');
|
||||||
const logoutBtn = document.getElementById('logout-btn');
|
const logoutBtn = document.getElementById('logout-btn');
|
||||||
|
const backdrop = document.getElementById('sidebar-backdrop');
|
||||||
|
const mobileBtn = document.getElementById('mobile-menu-btn');
|
||||||
|
|
||||||
|
const isMobile = () => window.innerWidth < 768;
|
||||||
|
|
||||||
|
const closeMobileNav = () => {
|
||||||
|
if (sidebar) sidebar.classList.remove('mobile-visible');
|
||||||
|
if (backdrop) backdrop.classList.remove('visible');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMobileNav = () => {
|
||||||
|
if (!sidebar || !backdrop) return;
|
||||||
|
const opening = !sidebar.classList.contains('mobile-visible');
|
||||||
|
sidebar.classList.toggle('mobile-visible', opening);
|
||||||
|
backdrop.classList.toggle('visible', opening);
|
||||||
|
document.body.style.overflow = opening && isMobile() ? 'hidden' : '';
|
||||||
|
};
|
||||||
|
|
||||||
if (toggleBtn && sidebar) {
|
if (toggleBtn && sidebar) {
|
||||||
toggleBtn.onclick = () => {
|
toggleBtn.onclick = () => {
|
||||||
|
if (isMobile()) {
|
||||||
|
toggleMobileNav();
|
||||||
|
} else {
|
||||||
sidebar.classList.toggle('collapsed');
|
sidebar.classList.toggle('collapsed');
|
||||||
localStorage.setItem('sidebar_collapsed', sidebar.classList.contains('collapsed'));
|
localStorage.setItem('sidebar_collapsed', sidebar.classList.contains('collapsed'));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (localStorage.getItem('sidebar_collapsed') === 'true') {
|
if (!isMobile() && localStorage.getItem('sidebar_collapsed') === 'true') {
|
||||||
sidebar.classList.add('collapsed');
|
sidebar.classList.add('collapsed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mobileBtn) {
|
||||||
|
mobileBtn.onclick = toggleMobileNav;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backdrop) {
|
||||||
|
backdrop.onclick = closeMobileNav;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close sidebar on page navigation (mobile)
|
||||||
|
const menuItems = document.querySelectorAll('.menu-item');
|
||||||
|
menuItems.forEach(item => {
|
||||||
|
const origClick = item.onclick;
|
||||||
|
item.onclick = (e) => {
|
||||||
|
if (isMobile()) closeMobileNav();
|
||||||
|
if (origClick) origClick(e);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
if (logoutBtn) {
|
if (logoutBtn) {
|
||||||
logoutBtn.onclick = () => {
|
logoutBtn.onclick = () => {
|
||||||
|
if (isMobile()) closeMobileNav();
|
||||||
window.authManager.logout();
|
window.authManager.logout();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle resize
|
||||||
|
let resizeTimer;
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = setTimeout(() => {
|
||||||
|
if (!isMobile()) {
|
||||||
|
closeMobileNav();
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setupRefresh() {
|
setupRefresh() {
|
||||||
|
|||||||
@@ -59,7 +59,20 @@ class OverviewPage {
|
|||||||
<div class="stat-value">${window.api.formatNumber(this.stats.total_tokens)}</div>
|
<div class="stat-value">${window.api.formatNumber(this.stats.total_tokens)}</div>
|
||||||
<div class="stat-label">Total Tokens</div>
|
<div class="stat-label">Total Tokens</div>
|
||||||
<div class="stat-change">
|
<div class="stat-change">
|
||||||
Lifetime usage
|
Lifetime usage (${(this.stats.total_days || 1).toLocaleString()} days)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon warning">
|
||||||
|
<i class="fas fa-calendar-alt"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">${(this.stats.total_days || 1).toLocaleString()} Days</div>
|
||||||
|
<div class="stat-label">Days Active</div>
|
||||||
|
<div class="stat-change">
|
||||||
|
Since ${this.stats.first_date || 'launch'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user