mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
GET /admin/api/events returns recent failures, newest first. It covers rate limits, budget and unpriced-model refusals, upstream errors, timeouts and capacity rejections. Events go into an in-process ring of 200 entries and are copied to a Redis list (llmgw:events, LPUSH plus LTRIM to 200). The copy happens only on the error path, is throttled to 50 per second per replica, and never blocks a request. When Redis is absent or down, the feed falls back to this replica's ring. Messages are fixed strings or scrubbed provider errors. Fields are assigned one at a time, because Yaegi drops values from a multi-value assignment that contains a call. Rejections per day are counted per scope and in total, in the same store call that already rolls back a rejected request. /admin/api/usage returns them as rejectionsPerDay. /admin/api/usage/models accepts span, which sums that many buckets, and prefix, which filters model ids before the limit applies. The overview reports the replica id, the instance name and the warnings logged while the middleware was built. The failover and retry fan-out warning is now logged at WARN. Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
1086 lines
42 KiB
Go
1086 lines
42 KiB
Go
package traefikllmgateway
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestCreateConfig_ReturnsEmptyConfig(t *testing.T) {
|
|
cfg := CreateConfig()
|
|
if cfg == nil {
|
|
t.Fatal("CreateConfig returned nil")
|
|
}
|
|
if len(cfg.Providers) != 0 {
|
|
t.Fatalf("want zero providers, got %d", len(cfg.Providers))
|
|
}
|
|
}
|
|
|
|
func TestNew_NoProviders_ReturnsError(t *testing.T) {
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
h, err := New(context.Background(), next, CreateConfig(), "llmgw")
|
|
if err == nil {
|
|
t.Fatal("want error for config with no providers, got nil")
|
|
}
|
|
if h != nil {
|
|
t.Fatal("want nil handler on error")
|
|
}
|
|
}
|
|
|
|
func TestNew_NilConfig_ReturnsError(t *testing.T) {
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
h, err := New(context.Background(), next, nil, "llmgw")
|
|
if err == nil {
|
|
t.Fatal("want error for nil config, got nil")
|
|
}
|
|
if h != nil {
|
|
t.Fatal("want nil handler on error")
|
|
}
|
|
}
|
|
|
|
func TestGateway_PassthroughUnknown_ForwardsToNext(t *testing.T) {
|
|
called := false
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
called = true
|
|
w.WriteHeader(http.StatusTeapot)
|
|
})
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
|
cfg.PassthroughUnknown = true
|
|
h, err := New(context.Background(), next, cfg, "llmgw")
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/anything", nil)
|
|
h.ServeHTTP(rec, req)
|
|
if !called {
|
|
t.Fatal("want next handler called when PassthroughUnknown is true")
|
|
}
|
|
if rec.Code != http.StatusTeapot {
|
|
t.Fatalf("want %d from next handler, got %d", http.StatusTeapot, rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestWriteOAIError_WritesEnvelope(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
writeOAIError(rec, http.StatusBadRequest, "invalid_request_error", "bad request")
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("want status %d, got %d", http.StatusBadRequest, rec.Code)
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
|
t.Fatalf("want application/json, got %q", ct)
|
|
}
|
|
|
|
var body struct {
|
|
Error struct {
|
|
Message string `json:"message"`
|
|
Type string `json:"type"`
|
|
Code string `json:"code"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if body.Error.Message != "bad request" {
|
|
t.Fatalf("want message %q, got %q", "bad request", body.Error.Message)
|
|
}
|
|
if body.Error.Type != "invalid_request_error" {
|
|
t.Fatalf("want type %q, got %q", "invalid_request_error", body.Error.Type)
|
|
}
|
|
wantCode := strconv.Itoa(http.StatusBadRequest)
|
|
if body.Error.Code != wantCode {
|
|
t.Fatalf("want code %q (JSON string), got %q", wantCode, body.Error.Code)
|
|
}
|
|
}
|
|
|
|
func TestServeHTTP_PanicRecovery_Returns500Envelope(t *testing.T) {
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
|
h, err := New(context.Background(), next, cfg, "llmgw")
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
gw, ok := h.(*Gateway)
|
|
if !ok {
|
|
t.Fatal("handler is not *Gateway")
|
|
}
|
|
|
|
origStderr := os.Stderr
|
|
r, w, _ := os.Pipe()
|
|
os.Stderr = w
|
|
defer func() { os.Stderr = origStderr }()
|
|
|
|
rec := httptest.NewRecorder()
|
|
sw := &statusTrackingWriter{ResponseWriter: rec}
|
|
func() {
|
|
defer recoverPanic(sw, gw)
|
|
panic("boom")
|
|
}()
|
|
|
|
_ = w.Close() // closing the pipe write end to unblock the read; error not actionable in a test
|
|
os.Stderr = origStderr
|
|
var buf bytes.Buffer
|
|
if _, err := io.Copy(&buf, r); err != nil {
|
|
t.Fatalf("io.Copy: %v", err)
|
|
}
|
|
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Fatalf("want %d, got %d", http.StatusInternalServerError, rec.Code)
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
|
t.Fatalf("want application/json, got %q", ct)
|
|
}
|
|
if !strings.Contains(buf.String(), "llmgw[llmgw] ERROR ") {
|
|
t.Fatalf("want stderr to contain error log prefix, got %q", buf.String())
|
|
}
|
|
}
|
|
|
|
func TestGateway_Logf_WritesInfoPrefix(t *testing.T) {
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
|
h, err := New(context.Background(), next, cfg, "mygw")
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
gw := h.(*Gateway)
|
|
|
|
origStderr := os.Stderr
|
|
r, w, _ := os.Pipe()
|
|
os.Stderr = w
|
|
|
|
gw.logf("hello %s", "world")
|
|
|
|
_ = w.Close() // closing the pipe write end to unblock the read; error not actionable in a test
|
|
os.Stderr = origStderr
|
|
var buf bytes.Buffer
|
|
if _, err := io.Copy(&buf, r); err != nil {
|
|
t.Fatalf("io.Copy: %v", err)
|
|
}
|
|
|
|
want := "llmgw[mygw] INFO hello world"
|
|
if !strings.Contains(buf.String(), want) {
|
|
t.Fatalf("want stderr to contain %q, got %q", want, buf.String())
|
|
}
|
|
}
|
|
|
|
// TestServeHTTP_AuthFailed_LogsMethodPathNotKey proves a failed
|
|
// authentication attempt (I3) emits a log line naming the method and path,
|
|
// and proves the presented (wrong) API key never appears in that line —
|
|
// key material must never be logged, even on a failure.
|
|
func TestServeHTTP_AuthFailed_LogsMethodPathNotKey(t *testing.T) {
|
|
const presentedKey = "sk-wrong-should-never-be-logged"
|
|
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
|
cfg.Groups = map[string]*GroupConfig{"default": {}}
|
|
cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice"}}}
|
|
h, err := New(context.Background(), next, cfg, "llmgw")
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
|
|
origStderr := os.Stderr
|
|
r, w, _ := os.Pipe()
|
|
os.Stderr = w
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{}`))
|
|
req.Header.Set("Authorization", "Bearer "+presentedKey)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
|
|
_ = w.Close() // closing the pipe write end to unblock the read; error not actionable in a test
|
|
os.Stderr = origStderr
|
|
var buf bytes.Buffer
|
|
if _, err := io.Copy(&buf, r); err != nil {
|
|
t.Fatalf("io.Copy: %v", err)
|
|
}
|
|
logged := buf.String()
|
|
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want 401, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if !strings.Contains(logged, "auth failed") || !strings.Contains(logged, "POST") || !strings.Contains(logged, "/v1/chat/completions") {
|
|
t.Fatalf("want stderr to contain an auth-failed line with method and path, got %q", logged)
|
|
}
|
|
if strings.Contains(logged, presentedKey) {
|
|
t.Fatalf("stderr contains the presented API key %q — key material must never be logged: %q", presentedKey, logged)
|
|
}
|
|
}
|
|
|
|
// TestServeHTTP_AuthOK_LogsUserNameAndRoute proves a successful
|
|
// authentication attempt logs the resolved user's name and the route,
|
|
// without ever logging the API key that authenticated them.
|
|
func TestServeHTTP_AuthOK_LogsUserNameAndRoute(t *testing.T) {
|
|
const presentedKey = "sk-alice-should-never-be-logged"
|
|
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
|
cfg.Groups = map[string]*GroupConfig{"default": {}}
|
|
cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: presentedKey}}}
|
|
h, err := New(context.Background(), next, cfg, "llmgw")
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
|
|
origStderr := os.Stderr
|
|
r, w, _ := os.Pipe()
|
|
os.Stderr = w
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
req.Header.Set("Authorization", "Bearer "+presentedKey)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
|
|
_ = w.Close() // closing the pipe write end to unblock the read; error not actionable in a test
|
|
os.Stderr = origStderr
|
|
var buf bytes.Buffer
|
|
if _, err := io.Copy(&buf, r); err != nil {
|
|
t.Fatalf("io.Copy: %v", err)
|
|
}
|
|
logged := buf.String()
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if !strings.Contains(logged, "auth ok") || !strings.Contains(logged, `"alice"`) || !strings.Contains(logged, "/v1/models") {
|
|
t.Fatalf("want stderr to contain an auth-ok line naming user alice and route /v1/models, got %q", logged)
|
|
}
|
|
if strings.Contains(logged, presentedKey) {
|
|
t.Fatalf("stderr contains the presented API key %q — key material must never be logged: %q", presentedKey, logged)
|
|
}
|
|
}
|
|
|
|
func TestGateway_Errorf_WritesErrorPrefix(t *testing.T) {
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
|
h, err := New(context.Background(), next, cfg, "mygw")
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
gw := h.(*Gateway)
|
|
|
|
origStderr := os.Stderr
|
|
r, w, _ := os.Pipe()
|
|
os.Stderr = w
|
|
|
|
gw.errorf("failed: %s", "reason")
|
|
|
|
_ = w.Close() // closing the pipe write end to unblock the read; error not actionable in a test
|
|
os.Stderr = origStderr
|
|
var buf bytes.Buffer
|
|
if _, err := io.Copy(&buf, r); err != nil {
|
|
t.Fatalf("io.Copy: %v", err)
|
|
}
|
|
|
|
want := "llmgw[mygw] ERROR failed: reason"
|
|
if !strings.Contains(buf.String(), want) {
|
|
t.Fatalf("want stderr to contain %q, got %q", want, buf.String())
|
|
}
|
|
}
|
|
|
|
func TestStatusTrackingWriter_WriteHeaderSetsFlag(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
sw := &statusTrackingWriter{ResponseWriter: rec}
|
|
if sw.wroteHeader {
|
|
t.Fatal("want wroteHeader false before any write")
|
|
}
|
|
sw.WriteHeader(http.StatusAccepted)
|
|
if !sw.wroteHeader {
|
|
t.Fatal("want wroteHeader true after WriteHeader")
|
|
}
|
|
if rec.Code != http.StatusAccepted {
|
|
t.Fatalf("want delegated status %d, got %d", http.StatusAccepted, rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestStatusTrackingWriter_WriteSetsFlag(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
sw := &statusTrackingWriter{ResponseWriter: rec}
|
|
if _, err := sw.Write([]byte("hi")); err != nil {
|
|
t.Fatalf("Write: %v", err)
|
|
}
|
|
if !sw.wroteHeader {
|
|
t.Fatal("want wroteHeader true after an implicit-header Write")
|
|
}
|
|
if rec.Body.String() != "hi" {
|
|
t.Fatalf("want delegated body %q, got %q", "hi", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestStatusTrackingWriter_FlushDelegates(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
sw := &statusTrackingWriter{ResponseWriter: rec}
|
|
sw.Flush()
|
|
if !rec.Flushed {
|
|
t.Fatal("want Flush to delegate to the underlying http.Flusher")
|
|
}
|
|
}
|
|
|
|
// TestServeHTTP_PanicAfterHeadersCommitted_DoesNotOverwriteResponse is the
|
|
// regression test for the recoverPanic header-guard: a handler that panics
|
|
// after it has already written a status code and body bytes (e.g.
|
|
// mid-stream) must not get a second, conflicting error envelope appended.
|
|
func TestServeHTTP_PanicAfterHeadersCommitted_DoesNotOverwriteResponse(t *testing.T) {
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
if _, err := w.Write([]byte("partial")); err != nil {
|
|
t.Fatalf("next handler Write: %v", err)
|
|
}
|
|
panic("boom mid-stream")
|
|
})
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
|
cfg.PassthroughUnknown = true
|
|
h, err := New(context.Background(), next, cfg, "llmgw")
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
|
|
origStderr := os.Stderr
|
|
pr, pw, _ := os.Pipe()
|
|
os.Stderr = pw
|
|
defer func() { os.Stderr = origStderr }()
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/stream", nil)
|
|
h.ServeHTTP(rec, req)
|
|
|
|
_ = pw.Close() // closing the pipe write end to unblock the read; error not actionable in a test
|
|
os.Stderr = origStderr
|
|
var logBuf bytes.Buffer
|
|
if _, err := io.Copy(&logBuf, pr); err != nil {
|
|
t.Fatalf("io.Copy: %v", err)
|
|
}
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("want status left at %d (already committed by next), got %d", http.StatusOK, rec.Code)
|
|
}
|
|
if got := rec.Body.String(); got != "partial" {
|
|
t.Fatalf("want body left as %q (no error envelope appended), got %q", "partial", got)
|
|
}
|
|
if !strings.Contains(logBuf.String(), "llmgw[llmgw] ERROR ") {
|
|
t.Fatalf("want panic still logged even though no envelope was written, got %q", logBuf.String())
|
|
}
|
|
}
|
|
|
|
// TestNewGateway_UsersFile_InitialLoadFailure_ReturnsConstructorError is the
|
|
// fail-fast case: a malformed users file must fail plugin construction, not
|
|
// leave the plugin running with an empty file-sourced user set.
|
|
func TestNewGateway_UsersFile_InitialLoadFailure_ReturnsConstructorError(t *testing.T) {
|
|
dir := t.TempDir()
|
|
fp := filepath.Join(dir, "bad.json")
|
|
if err := os.WriteFile(fp, []byte("{not json"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
|
cfg.Users = &UsersConfig{File: fp}
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
h, err := New(context.Background(), next, cfg, "llmgw")
|
|
if err == nil {
|
|
t.Fatal("want constructor error for a malformed initial users file")
|
|
}
|
|
if h != nil {
|
|
t.Fatal("want nil handler on error")
|
|
}
|
|
}
|
|
|
|
// TestNewGateway_UsersFile_InitialLoadMissingFile_ReturnsConstructorError
|
|
// covers the other fail-fast path: a configured file that does not exist.
|
|
func TestNewGateway_UsersFile_InitialLoadMissingFile_ReturnsConstructorError(t *testing.T) {
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
|
cfg.Users = &UsersConfig{File: filepath.Join(t.TempDir(), "nope.json")}
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
h, err := New(context.Background(), next, cfg, "llmgw")
|
|
if err == nil {
|
|
t.Fatal("want constructor error for a missing initial users file")
|
|
}
|
|
if h != nil {
|
|
t.Fatal("want nil handler on error")
|
|
}
|
|
}
|
|
|
|
// TestNewGateway_UsersFile_ValidFile_LoadsUsersSynchronously verifies the
|
|
// synchronous initial load: a file-sourced user must be identifiable
|
|
// immediately after New returns, with no reload wait.
|
|
func TestNewGateway_UsersFile_ValidFile_LoadsUsersSynchronously(t *testing.T) {
|
|
dir := t.TempDir()
|
|
fp := filepath.Join(dir, "users.json")
|
|
writeUsersDoc(t, fp, []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice"}}, time.Time{})
|
|
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
|
cfg.Groups = map[string]*GroupConfig{"default": {}}
|
|
cfg.Users = &UsersConfig{File: fp}
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
h, err := New(context.Background(), next, cfg, "llmgw")
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
gw, ok := h.(*Gateway)
|
|
if !ok {
|
|
t.Fatal("handler is not *Gateway")
|
|
}
|
|
if _, _, ok := identifyWithKey(gw.auth, "sk-alice"); !ok {
|
|
t.Fatal("want file-sourced user identifiable immediately after construction")
|
|
}
|
|
}
|
|
|
|
// TestServeHTTP_NoUsersFile_MaybeReloadIsNoOp verifies that entry-time
|
|
// maybeReload is a cheap no-op — and, crucially, does not panic — when no
|
|
// users file is configured.
|
|
func TestServeHTTP_NoUsersFile_MaybeReloadIsNoOp(t *testing.T) {
|
|
called := false
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
called = true
|
|
})
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
|
cfg.PassthroughUnknown = true
|
|
h, err := New(context.Background(), next, cfg, "llmgw")
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
|
h.ServeHTTP(rec, req)
|
|
if !called {
|
|
t.Fatal("want next handler called")
|
|
}
|
|
}
|
|
|
|
// TestServeHTTP_TriggersUsersFileReload is the wire-up regression: ServeHTTP
|
|
// must call g.auth.maybeReload() at entry, not just newGateway's initial
|
|
// load. It drives a real request through ServeHTTP (never calling
|
|
// maybeReload directly) and expects the file change to have been picked up.
|
|
func TestServeHTTP_TriggersUsersFileReload(t *testing.T) {
|
|
dir := t.TempDir()
|
|
fp := filepath.Join(dir, "users.json")
|
|
writeUsersDoc(t, fp, []*UserConfig{{Name: "f1", Group: "default", APIKey: "sk-f1"}}, time.Time{})
|
|
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
|
cfg.Groups = map[string]*GroupConfig{"default": {}}
|
|
cfg.Users = &UsersConfig{File: fp}
|
|
cfg.PassthroughUnknown = true
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
h, err := New(context.Background(), next, cfg, "llmgw")
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
gw, ok := h.(*Gateway)
|
|
if !ok {
|
|
t.Fatal("handler is not *Gateway")
|
|
}
|
|
|
|
// Force the throttle window open and give the file an unambiguous
|
|
// future mtime, then drive one request through ServeHTTP.
|
|
clock := &fakeClock{now: gw.auth.nowFn().Add(reloadEvery + time.Second)}
|
|
gw.auth.nowFn = clock.Now
|
|
writeUsersDoc(t, fp, []*UserConfig{{Name: "f2", Group: "default", APIKey: "sk-f2"}}, time.Now().Add(time.Hour))
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
|
h.ServeHTTP(rec, req)
|
|
|
|
if _, _, ok := identifyWithKey(gw.auth, "sk-f2"); !ok {
|
|
t.Fatal("want ServeHTTP's entry-time maybeReload call to have picked up the file change")
|
|
}
|
|
}
|
|
|
|
// TestNewGateway_RejectsInvalidGroupLimits is carried-item (a): a negative
|
|
// limit value in a group's LimitsConfig must fail plugin construction, not
|
|
// silently behave as unlimited.
|
|
func TestNewGateway_RejectsInvalidGroupLimits(t *testing.T) {
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
|
cfg.Groups = map[string]*GroupConfig{"default": {Limits: &LimitsConfig{RequestsPerDay: -1}}}
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
if _, err := New(context.Background(), next, cfg, "llmgw"); err == nil {
|
|
t.Fatal("want constructor error for a negative group limit")
|
|
}
|
|
}
|
|
|
|
// TestNewGateway_RejectsInvalidUserLimits mirrors the group case for an
|
|
// inline user's own limits override.
|
|
func TestNewGateway_RejectsInvalidUserLimits(t *testing.T) {
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
|
cfg.Groups = map[string]*GroupConfig{"default": {}}
|
|
cfg.Users = &UsersConfig{Inline: []*UserConfig{
|
|
{Name: "a", Group: "default", APIKey: "sk-a", Limits: &LimitsConfig{CostPerMonthUSD: -1}},
|
|
}}
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
if _, err := New(context.Background(), next, cfg, "llmgw"); err == nil {
|
|
t.Fatal("want constructor error for a negative user limit")
|
|
}
|
|
}
|
|
|
|
// TestNewGateway_RejectsInvalidFileUserLimits covers the file-sourced user
|
|
// path (attachUsersFile -> replaceFileUsers -> buildEntry), the other
|
|
// caller of LimitsConfig.validate besides newAuthStore's inline loop.
|
|
func TestNewGateway_RejectsInvalidFileUserLimits(t *testing.T) {
|
|
dir := t.TempDir()
|
|
fp := filepath.Join(dir, "users.json")
|
|
writeUsersDoc(t, fp, []*UserConfig{
|
|
{Name: "f", Group: "default", APIKey: "sk-f", Limits: &LimitsConfig{TokensPerDay: -1}},
|
|
}, time.Time{})
|
|
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
|
cfg.Groups = map[string]*GroupConfig{"default": {}}
|
|
cfg.Users = &UsersConfig{File: fp}
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
if _, err := New(context.Background(), next, cfg, "llmgw"); err == nil {
|
|
t.Fatal("want constructor error for a negative file-sourced user limit")
|
|
}
|
|
}
|
|
|
|
// TestNewGateway_NoRedis_UsesMemoryLimiterFailOpenDefaultTrue is
|
|
// carried-item (d)'s "else" branch: no Redis configured wires
|
|
// newLimiter(nil, true) — a nil store (memoryStore fallback only) and
|
|
// failOpen defaulted true.
|
|
func TestNewGateway_NoRedis_UsesMemoryLimiterFailOpenDefaultTrue(t *testing.T) {
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
h, err := New(context.Background(), next, cfg, "llmgw")
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
gw := h.(*Gateway)
|
|
if gw.limiter == nil {
|
|
t.Fatal("want a non-nil limiter")
|
|
}
|
|
if gw.limiter.store != nil {
|
|
t.Errorf("gw.limiter.store = %#v, want nil (no Redis configured)", gw.limiter.store)
|
|
}
|
|
if !gw.limiter.failOpen {
|
|
t.Error("gw.limiter.failOpen = false, want true (default)")
|
|
}
|
|
}
|
|
|
|
// TestNewGateway_RedisConfigured_WiresRedisStoreAndFailOpen is
|
|
// carried-item (d)'s main branch: a configured Redis block wires a
|
|
// redisStore (backed by a respClient) into the limiter, and an explicit
|
|
// FailOpen=false is honored rather than overridden by the default.
|
|
func TestNewGateway_RedisConfigured_WiresRedisStoreAndFailOpen(t *testing.T) {
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
|
failOpen := false
|
|
cfg.Redis = &RedisConfig{Address: "127.0.0.1:0", FailOpen: &failOpen}
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
h, err := New(context.Background(), next, cfg, "llmgw")
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
gw := h.(*Gateway)
|
|
if gw.limiter == nil {
|
|
t.Fatal("want a non-nil limiter")
|
|
}
|
|
if _, ok := gw.limiter.store.(*redisStore); !ok {
|
|
t.Errorf("gw.limiter.store = %#v (%T), want *redisStore", gw.limiter.store, gw.limiter.store)
|
|
}
|
|
if gw.limiter.failOpen {
|
|
t.Error("gw.limiter.failOpen = true, want false (explicit config)")
|
|
}
|
|
}
|
|
|
|
// TestGateway_Close_NoRedis_NilSafeNoOp is the review fix (Should-Fix 4,
|
|
// 2026-08-2x): a Gateway built with no Redis configured has a nil
|
|
// redisClient, and Close must be a nil-safe no-op, not a panic.
|
|
func TestGateway_Close_NoRedis_NilSafeNoOp(t *testing.T) {
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
h, err := New(context.Background(), next, cfg, "llmgw")
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
gw := h.(*Gateway)
|
|
if gw.redisClient != nil {
|
|
t.Fatal("gw.redisClient != nil, want nil (no Redis configured)")
|
|
}
|
|
if err := gw.Close(); err != nil {
|
|
t.Errorf("Close: %v, want nil", err)
|
|
}
|
|
}
|
|
|
|
// TestGateway_Close_WithRedis_DrainsPooledConnections is the review fix
|
|
// (Should-Fix 4, 2026-08-2x) end-to-end proof: a Gateway built with Redis
|
|
// configured wires redisClient, and Close drains its connection pool
|
|
// (every slot, dialled or not — construction never eagerly dials, so
|
|
// this exercises Close's drain path rather than a real socket close, but
|
|
// proves the wiring: gw.Close() actually reaches gw.redisClient.Close(),
|
|
// not a stub that does nothing).
|
|
func TestGateway_Close_WithRedis_DrainsPooledConnections(t *testing.T) {
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
|
cfg.Redis = &RedisConfig{Address: "127.0.0.1:0", PoolSize: 3}
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
h, err := New(context.Background(), next, cfg, "llmgw")
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
gw := h.(*Gateway)
|
|
if gw.redisClient == nil {
|
|
t.Fatal("gw.redisClient = nil, want a non-nil client (Redis configured)")
|
|
}
|
|
if got := len(gw.redisClient.free); got != 3 {
|
|
t.Fatalf("len(redisClient.free) before Close = %d, want 3", got)
|
|
}
|
|
if err := gw.Close(); err != nil {
|
|
t.Fatalf("Close: %v", err)
|
|
}
|
|
if got := len(gw.redisClient.free); got != 0 {
|
|
t.Errorf("len(redisClient.free) after Close = %d, want 0 (drained)", got)
|
|
}
|
|
}
|
|
|
|
// TestNewGateway_RedisFailOpenNilDefaultsTrue asserts a configured Redis
|
|
// block with FailOpen left nil defaults to true, matching the no-Redis
|
|
// default.
|
|
func TestNewGateway_RedisFailOpenNilDefaultsTrue(t *testing.T) {
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
|
cfg.Redis = &RedisConfig{Address: "127.0.0.1:0"}
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
h, err := New(context.Background(), next, cfg, "llmgw")
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
gw := h.(*Gateway)
|
|
if !gw.limiter.failOpen {
|
|
t.Error("gw.limiter.failOpen = false, want true (FailOpen left nil)")
|
|
}
|
|
}
|
|
|
|
// TestNewGateway_RedisConfigured_EmptyAddress_ReturnsConstructorError
|
|
// asserts a Redis block with no address fails construction immediately,
|
|
// rather than deferring the error to the first lazy-connect attempt on
|
|
// the request path.
|
|
func TestNewGateway_RedisConfigured_EmptyAddress_ReturnsConstructorError(t *testing.T) {
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
|
cfg.Redis = &RedisConfig{}
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
if _, err := New(context.Background(), next, cfg, "llmgw"); err == nil {
|
|
t.Fatal("want constructor error for a Redis block with an empty address")
|
|
}
|
|
}
|
|
|
|
// TestNewGateway_RedisConfigured_NegativeDB_ReturnsConstructorError is
|
|
// review item 5: a negative Redis.DB must fail construction immediately,
|
|
// same as an empty address, rather than reaching SELECT -1 on the wire.
|
|
func TestNewGateway_RedisConfigured_NegativeDB_ReturnsConstructorError(t *testing.T) {
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
|
cfg.Redis = &RedisConfig{Address: "127.0.0.1:0", DB: -1}
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
if _, err := New(context.Background(), next, cfg, "llmgw"); err == nil {
|
|
t.Fatal("want constructor error for a negative Redis db")
|
|
}
|
|
}
|
|
|
|
// TestBuildRedisClient_NegativePoolSize_ReturnsConstructorError mirrors
|
|
// the negative-DB case above: RedisConfig.PoolSize must fail construction
|
|
// immediately, same as an empty address or a negative db, rather than
|
|
// reaching newRESPClientPool with a value it would otherwise silently
|
|
// clamp up to 1.
|
|
func TestBuildRedisClient_NegativePoolSize_ReturnsConstructorError(t *testing.T) {
|
|
_, err := buildRedisClient(&RedisConfig{Address: "127.0.0.1:0", PoolSize: -1})
|
|
if err == nil {
|
|
t.Fatal("want constructor error for a negative Redis poolSize")
|
|
}
|
|
}
|
|
|
|
// TestBuildRedisClient_PoolSizeAboveCeiling_ReturnsConstructorError is the
|
|
// review fix (Should-Fix 1, 2026-08-2x): PoolSize had no upper bound —
|
|
// PoolSize: 1<<20 was accepted and eagerly allocated over a million
|
|
// *respConn structs at construction. A value above respPoolConfigMax must
|
|
// fail construction immediately, mirroring the negative-poolSize check
|
|
// above.
|
|
func TestBuildRedisClient_PoolSizeAboveCeiling_ReturnsConstructorError(t *testing.T) {
|
|
_, err := buildRedisClient(&RedisConfig{Address: "127.0.0.1:0", PoolSize: respPoolConfigMax + 1})
|
|
if err == nil {
|
|
t.Fatal("want constructor error for a Redis poolSize above respPoolConfigMax")
|
|
}
|
|
}
|
|
|
|
// TestBuildRedisClient_PoolSizeAtCeiling_Allowed asserts the ceiling
|
|
// itself is inclusive — respPoolConfigMax is a valid, accepted value, not
|
|
// an off-by-one rejection.
|
|
func TestBuildRedisClient_PoolSizeAtCeiling_Allowed(t *testing.T) {
|
|
client, err := buildRedisClient(&RedisConfig{Address: "127.0.0.1:0", PoolSize: respPoolConfigMax})
|
|
if err != nil {
|
|
t.Fatalf("buildRedisClient: %v, want no error at exactly respPoolConfigMax", err)
|
|
}
|
|
if got := cap(client.free); got != respPoolConfigMax {
|
|
t.Errorf("cap(client.free) = %d, want %d", got, respPoolConfigMax)
|
|
}
|
|
}
|
|
|
|
// TestBuildRedisClient_PoolSize_ZeroUsesSelfTunedDefault_ExplicitOverrides
|
|
// is perf finding 1's config-wiring proof: RedisConfig.PoolSize left at 0
|
|
// gets defaultRespPoolSize's own self-tuned pool, but any explicit
|
|
// positive value always overrides it — the house engineering rule
|
|
// (self-tuning over operator knobs, explicit override always wins).
|
|
func TestBuildRedisClient_PoolSize_ZeroUsesSelfTunedDefault_ExplicitOverrides(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
poolSize int
|
|
wantPoolSize int
|
|
}{
|
|
{name: "zero uses the self-tuned default", poolSize: 0, wantPoolSize: defaultRespPoolSize()},
|
|
{name: "explicit override wins", poolSize: 3, wantPoolSize: 3},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
client, err := buildRedisClient(&RedisConfig{Address: "127.0.0.1:0", PoolSize: c.poolSize})
|
|
if err != nil {
|
|
t.Fatalf("buildRedisClient: %v", err)
|
|
}
|
|
if got := cap(client.free); got != c.wantPoolSize {
|
|
t.Errorf("cap(client.free) = %d, want %d", got, c.wantPoolSize)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestNewGateway_RedisPassword_ResolvedViaSecret is an end-to-end proof
|
|
// that newGateway resolves Config.Redis.Password through resolveSecret
|
|
// (env:/file:/literal) before handing it to respClient: an "env:" password
|
|
// must arrive at the fake server as its resolved value, not the literal
|
|
// "env:..." string.
|
|
func TestNewGateway_RedisPassword_ResolvedViaSecret(t *testing.T) {
|
|
t.Setenv("LLMGW_TEST_REDIS_PASSWORD", "resolved-pw")
|
|
|
|
ln := newFakeListener(t)
|
|
runFakeRESPServer(t, ln, []respStep{
|
|
{wantArgs: []string{"AUTH", "resolved-pw"}, reply: []byte("+OK\r\n")},
|
|
{wantArgs: []string{"SELECT", "0"}, reply: []byte("+OK\r\n")},
|
|
{wantArgs: []string{"GET", "k"}, reply: []byte("$-1\r\n")},
|
|
})
|
|
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
|
cfg.Redis = &RedisConfig{Address: ln.Addr().String(), Password: "env:LLMGW_TEST_REDIS_PASSWORD"} // #nosec G101 -- not a credential, a resolveSecret "env:" reference to an env var name
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
h, err := New(context.Background(), next, cfg, "llmgw")
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
gw := h.(*Gateway)
|
|
store, ok := gw.limiter.store.(*redisStore)
|
|
if !ok {
|
|
t.Fatalf("gw.limiter.store = %#v (%T), want *redisStore", gw.limiter.store, gw.limiter.store)
|
|
}
|
|
if _, err := getOne(store, "k"); err != nil {
|
|
t.Fatalf("store.getMulti: %v (want the resolved password to authenticate against the fake server)", err)
|
|
}
|
|
}
|
|
|
|
// TestNewGateway_RedisPassword_UnresolvableSecret_ReturnsConstructorError
|
|
// asserts an "env:" password referencing an unset variable fails
|
|
// construction with resolveSecret's error, rather than deferring to a
|
|
// confusing AUTH failure at request time.
|
|
func TestNewGateway_RedisPassword_UnresolvableSecret_ReturnsConstructorError(t *testing.T) {
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
|
cfg.Redis = &RedisConfig{Address: "127.0.0.1:0", Password: "env:LLMGW_TEST_REDIS_PASSWORD_UNSET"} // #nosec G101 -- not a credential, a resolveSecret "env:" reference to an env var name
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
if _, err := New(context.Background(), next, cfg, "llmgw"); err == nil {
|
|
t.Fatal("want constructor error for an unresolvable Redis password secret")
|
|
}
|
|
}
|
|
|
|
// TestNewGateway_InvalidPricingOverride_ReturnsConstructorError is finding
|
|
// F4, 2026-09 review: New must reject a negative Config.Pricing entry at
|
|
// construction (validatePricing, pricing.go) rather than letting it reach
|
|
// costMicrosKnown and silently disable that model's cost budget forever.
|
|
func TestNewGateway_InvalidPricingOverride_ReturnsConstructorError(t *testing.T) {
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
|
cfg.Pricing = map[string]*ModelPricing{"gpt-5": {InputPerM: -1, OutputPerM: 1}}
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
if _, err := New(context.Background(), next, cfg, "llmgw"); err == nil {
|
|
t.Fatal("want a constructor error for a negative pricing.inputPerM")
|
|
}
|
|
}
|
|
|
|
// TestNewGateway_MetricsPath_Validation is review-auth finding F9,
|
|
// 2026-09 review, corrected by the verify-core round-4 fix: metrics.path
|
|
// must still start with "/" (a hard construction error — a value with no
|
|
// leading "/" can never match ServeHTTP's route comparison, silently
|
|
// making the endpoint unreachable), but a path sitting at or under a
|
|
// reserved route prefix (/admin, /v1, /mcp, /a2a) is no longer refused.
|
|
// ServeHTTP matches the metrics route by exact path before it ever
|
|
// dispatches into those namespaces, so a config like "/v1/metrics"
|
|
// already routed correctly before finding F9's fix existed at all —
|
|
// refusing to build the middleware for it broke a previously working
|
|
// config on upgrade. It now only logs a WARN line naming the shadowed
|
|
// prefix.
|
|
func TestNewGateway_MetricsPath_Validation(t *testing.T) {
|
|
cases := []struct {
|
|
path string
|
|
wantErr bool
|
|
wantWarn bool
|
|
}{
|
|
{"/internal/metrics", false, false},
|
|
{"", false, false}, // empty resolves to the default "/metrics"
|
|
{"metrics", true, false}, // no leading "/": still a hard error
|
|
{"/admin", false, true}, // exact collision: now just a warning
|
|
{"/admin/api/usage", false, true}, // under the admin namespace
|
|
{"/v1/metrics", false, true}, // under the OpenAI-compatible namespace — previously working; must still build
|
|
{"/mcp/prometheus", false, true}, // under the MCP target-proxy namespace
|
|
{"/a2a/prometheus", false, true}, // under the A2A target-proxy namespace
|
|
{"/administrivia", false, false}, // string prefix only, not a path-segment collision
|
|
{"/v1/models", false, true}, // GET /v1/models is matched before metrics: unreachable
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.path, func(t *testing.T) {
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
|
cfg.Metrics = &MetricsConfig{Enabled: true, Path: c.path}
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
|
|
origStderr := os.Stderr
|
|
r, w, _ := os.Pipe()
|
|
os.Stderr = w
|
|
|
|
_, err := New(context.Background(), next, cfg, "llmgw")
|
|
|
|
_ = w.Close() // closing the pipe write end to unblock the read; error not actionable in a test
|
|
os.Stderr = origStderr
|
|
var buf bytes.Buffer
|
|
if _, copyErr := io.Copy(&buf, r); copyErr != nil {
|
|
t.Fatalf("io.Copy: %v", copyErr)
|
|
}
|
|
|
|
if (err != nil) != c.wantErr {
|
|
t.Errorf("New with metrics.path = %q: err = %v, wantErr %v", c.path, err, c.wantErr)
|
|
}
|
|
if gotWarn := strings.Contains(buf.String(), "llmgw[llmgw] WARN config: metrics.path"); gotWarn != c.wantWarn {
|
|
t.Errorf("New with metrics.path = %q: shadow warning logged = %v, want %v (stderr: %q)", c.path, gotWarn, c.wantWarn, buf.String())
|
|
}
|
|
wantUnreachable := c.path == "/v1/models"
|
|
if gotUnreachable := strings.Contains(buf.String(), "unreachable"); gotUnreachable != wantUnreachable {
|
|
t.Errorf("New with metrics.path = %q: unreachable warning = %v, want %v (stderr: %q)", c.path, gotUnreachable, wantUnreachable, buf.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGateway_PricingWarn_LogsUnderOwnInstance: an unpriced model's
|
|
// warning routed through g.pricingWarn reaches that gateway's own log
|
|
// prefix (finding F11 / review-auth F8: no package-global warn func).
|
|
func TestGateway_PricingWarn_LogsUnderOwnInstance(t *testing.T) {
|
|
warnedModelsMu.Lock()
|
|
prevWarned, prevCapNotified := warnedModels, warnCapNotified
|
|
warnedModels = map[string]bool{}
|
|
warnCapNotified = false
|
|
warnedModelsMu.Unlock()
|
|
t.Cleanup(func() {
|
|
warnedModelsMu.Lock()
|
|
warnedModels, warnCapNotified = prevWarned, prevCapNotified
|
|
warnedModelsMu.Unlock()
|
|
})
|
|
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
h, err := New(context.Background(), next, cfg, "mygw")
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
gw := h.(*Gateway)
|
|
|
|
origStderr := os.Stderr
|
|
r, w, _ := os.Pipe()
|
|
os.Stderr = w
|
|
|
|
costMicrosKnownFor("totally-unpriced-model-for-newgateway-test", usage{prompt: 1}, nil, gw.pricingWarn)
|
|
|
|
_ = w.Close() // closing the pipe write end to unblock the read; error not actionable in a test
|
|
os.Stderr = origStderr
|
|
var buf bytes.Buffer
|
|
if _, err := io.Copy(&buf, r); err != nil {
|
|
t.Fatalf("io.Copy: %v", err)
|
|
}
|
|
if !strings.Contains(buf.String(), "llmgw[mygw]") {
|
|
t.Fatalf("want the pricing warning routed through the gateway's own logf, got %q", buf.String())
|
|
}
|
|
}
|
|
|
|
// TestNewCollectsConfigWarnings is F10 (v0.3 dashboard task): every
|
|
// construction-time warnf call — here, metrics.path="/v1/models"
|
|
// (shadowed-route warning) AND cache.ttl="0s" (zero-TTL warning),
|
|
// triggered together in one construction — lands in g.configWarnings,
|
|
// readable via GET /admin/api/overview's Warnings field. Both are real
|
|
// existing warnf call sites (TestNewGateway_MetricsPath_Validation/
|
|
// TestBuildResponseCache_NonPositiveTTL_WarnsAndAccepts prove they fire
|
|
// at all); this test proves F10's NEW behavior — that warnf's own line
|
|
// is ALSO collected, not just written to stderr.
|
|
func TestNewCollectsConfigWarnings(t *testing.T) {
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
|
cfg.Metrics = &MetricsConfig{Enabled: true, Path: "/v1/models"}
|
|
cfg.Cache = CacheConfig{Enabled: true, TTL: "0s"} // no Redis configured — buildResponseCache still warns before its own nil-client return
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
|
|
h, err := New(context.Background(), next, cfg, "llmgw")
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
gw := h.(*Gateway)
|
|
|
|
warnings, dropped := gw.configWarningsSnapshot()
|
|
if dropped != 0 {
|
|
t.Errorf("dropped = %d, want 0", dropped)
|
|
}
|
|
var sawMetricsPath, sawCacheTTL bool
|
|
for _, w := range warnings {
|
|
if strings.Contains(w, "metrics.path") {
|
|
sawMetricsPath = true
|
|
}
|
|
if strings.Contains(w, "cache: ttl") {
|
|
sawCacheTTL = true
|
|
}
|
|
}
|
|
if !sawMetricsPath {
|
|
t.Errorf("warnings = %v, want one mentioning metrics.path", warnings)
|
|
}
|
|
if !sawCacheTTL {
|
|
t.Errorf("warnings = %v, want one mentioning cache: ttl", warnings)
|
|
}
|
|
}
|
|
|
|
// TestNoteConfigWarning_CapAndDropped proves configWarningsCap bounds
|
|
// g.configWarnings and configWarningsDropped counts what did not fit —
|
|
// F10's own overflow behavior, exercised directly against noteConfigWarning
|
|
// (via warnf) rather than contriving 50+ real construction warnings.
|
|
func TestNoteConfigWarning_CapAndDropped(t *testing.T) {
|
|
g := &Gateway{name: "llmgw", collectingWarnings: true}
|
|
const over = 5
|
|
for i := 0; i < configWarningsCap+over; i++ {
|
|
g.warnf("warning %d", i)
|
|
}
|
|
warnings, dropped := g.configWarningsSnapshot()
|
|
if len(warnings) != configWarningsCap {
|
|
t.Errorf("len(warnings) = %d, want %d", len(warnings), configWarningsCap)
|
|
}
|
|
if dropped != over {
|
|
t.Errorf("dropped = %d, want %d", dropped, over)
|
|
}
|
|
if warnings[0] != "warning 0" {
|
|
t.Errorf("warnings[0] = %q, want %q (the first configWarningsCap warnings are kept, not the last)", warnings[0], "warning 0")
|
|
}
|
|
}
|
|
|
|
// TestNoteConfigWarning_RuntimeWarnfNotCollected proves a warnf call
|
|
// AFTER collectingWarnings is false (construction finished — newGateway's
|
|
// own final step) is never appended to g.configWarnings: only
|
|
// construction-time warnings belong in GET /admin/api/overview's
|
|
// Warnings field.
|
|
func TestNoteConfigWarning_RuntimeWarnfNotCollected(t *testing.T) {
|
|
g := &Gateway{name: "llmgw", collectingWarnings: true}
|
|
g.warnf("during construction")
|
|
g.collectingWarnings = false
|
|
g.warnf("after construction, must not be collected")
|
|
|
|
warnings, _ := g.configWarningsSnapshot()
|
|
if len(warnings) != 1 || warnings[0] != "during construction" {
|
|
t.Errorf("warnings = %v, want exactly [%q]", warnings, "during construction")
|
|
}
|
|
}
|
|
|
|
// TestShouldSendTelemetry pins the guard that keeps unstamped builds from
|
|
// phoning home. Every developer checkout and every CI run carries
|
|
// devPluginVersion, so the false cases are the state this code is in
|
|
// nearly all of the time: if the guard inverts, `go test` itself starts
|
|
// emitting pings.
|
|
func TestShouldSendTelemetry(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
version string
|
|
want bool
|
|
}{
|
|
{"dev sentinel stays silent", devPluginVersion, false},
|
|
{"empty version stays silent", "", false},
|
|
{"stamped release sends", "0.2.38", true},
|
|
{"stamped prerelease sends", "1.0.0-rc1", true},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := shouldSendTelemetry(tt.version); got != tt.want {
|
|
t.Errorf("shouldSendTelemetry(%q) = %v, want %v", tt.version, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPluginVersion_UnstampedInSourceTree guards the committed state: the
|
|
// repository must never carry a stamped version, or a developer's own
|
|
// build would phone home. workflow-prepare.sh stamps a COPY at release
|
|
// time; if a stamped value is ever committed by mistake, this fails.
|
|
func TestPluginVersion_UnstampedInSourceTree(t *testing.T) {
|
|
if pluginVersion != devPluginVersion {
|
|
t.Errorf("committed pluginVersion = %q, want the %q sentinel — a stamped version must never be committed", pluginVersion, devPluginVersion)
|
|
}
|
|
}
|
|
|
|
// TestNew_DoesNotPanicOnRepeatedConstruction covers the telemetry
|
|
// sync.Once gate under the real call pattern: Traefik constructs this
|
|
// middleware once PER ROUTE, so New runs many times in one process.
|
|
// Construction must stay safe and cheap on every later call.
|
|
func TestNew_DoesNotPanicOnRepeatedConstruction(t *testing.T) {
|
|
t.Setenv("DO_NOT_TRACK", "1")
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
|
|
for i := 0; i < 5; i++ {
|
|
h, err := New(context.Background(), http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), cfg, "llmgw")
|
|
if err != nil {
|
|
t.Fatalf("New #%d: %v", i, err)
|
|
}
|
|
if h == nil {
|
|
t.Fatalf("New #%d returned a nil handler", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSendStartupTelemetry_OnceAcrossConcurrentCalls pins the sync.Once
|
|
// gate itself: Traefik can construct several routes concurrently at
|
|
// startup, and oss-telemetry does not deduplicate client-side, so a
|
|
// missing gate would emit one ping per route.
|
|
func TestSendStartupTelemetry_OnceAcrossConcurrentCalls(t *testing.T) {
|
|
t.Setenv("DO_NOT_TRACK", "1")
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 20; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
sendStartupTelemetry()
|
|
}()
|
|
}
|
|
wg.Wait() // -race proves the Once is the only synchronisation needed
|
|
}
|