mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
I2 (controller ruling): docs/superpowers/specs/2026-08-19-traefik-llmgateway-design.md
described limits as "user overrides group" / "user limit if set, else
group limit, else unlimited" — stale relative to the actual, already-correct
implementation (buildLimitScopes/checkAndCount evaluate every configured
scope and return the first violation). Amended §2 and §7 to state both
scopes are enforced together and the tighter bound wins.
I4 (manifest hedge): .traefik.yml's groups.default was a bare "{}",
the shape empirically reproduced as a decode failure outside this
repo's own (necessarily simplified) yaegi-check harness. Changed to
"providers: []" — same zero semantics, no longer a bare empty object.
manifest_test.go's JSON mirror and drift-check token updated to match;
make yaegi-check still passes (its YAML-subset parser already handles
an inline "[]" under a nested map key).
M9 (pricing provenance): pricing.go's built-in table comment replaced
"verify before release" with a real source note (each provider's own
published list prices, recorded 2026-08, approximate). README's limits
section gained the matching caveat and points to the "pricing" config
override for billing-grade accuracy.
Doc nits: README's Kubernetes section now says the example manifest
applies cleanly once Traefik's CRDs (Middleware, IngressRoute) are
installed, rather than claiming kubectl apply -f works unconditionally.
README's Security notes section no longer claims "no auth-event logging
at all" (see the paired fix: commit) — it now describes what is logged
and that key material never appears.
This commit and its sibling fix: commit are the final wave closing out
the whole-branch review findings for docs/superpowers/sdd/2026-08-20-
traefik-llmgateway.
Gates: go test ./... -count=1 (-race) green, gofmt -l . empty, go vet
clean, make yaegi-check OK, golangci-lint 0 issues.
81 lines
2.6 KiB
Go
81 lines
2.6 KiB
Go
package traefikllmgateway
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"regexp"
|
|
"testing"
|
|
)
|
|
|
|
// testDataConfig decodes a hand-maintained JSON mirror of the .traefik.yml
|
|
// testData block — we cannot import a YAML library (stdlib only). The
|
|
// mirror is drift-checked against the raw manifest by the assertions below:
|
|
// every top-level key and scalar value the mirror encodes must also appear
|
|
// in .traefik.yml's raw bytes, so an edit to either side that removes or
|
|
// renames a testData field fails this test instead of silently going
|
|
// stale.
|
|
func testDataConfig(t *testing.T) *Config {
|
|
t.Helper()
|
|
raw, err := os.ReadFile(".traefik.yml")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !regexp.MustCompile(`(?m)^testData:`).Match(raw) {
|
|
t.Fatal(".traefik.yml missing testData")
|
|
}
|
|
|
|
// Drift check: every top-level key and scalar value referenced by the
|
|
// mirror blob below must appear in the raw manifest bytes.
|
|
mirrorTokens := []string{
|
|
"providers", "groups", "users", // top-level testData keys
|
|
"openai", "type: openai", "test-key", "gpt-test", // providers.openai
|
|
"default", // groups.default and users.inline[0].group
|
|
"providers: []", // groups.default.providers — zero-semantic hedge, not bare {}
|
|
"tester", // users.inline[0].name
|
|
"test-user-key", // users.inline[0].apiKey
|
|
}
|
|
var missing []string
|
|
for _, tok := range mirrorTokens {
|
|
if !bytes.Contains(raw, []byte(tok)) {
|
|
missing = append(missing, tok)
|
|
}
|
|
}
|
|
if len(missing) > 0 {
|
|
t.Fatalf("manifest drift: mirror blob references %v not found in .traefik.yml — update the mirror or the manifest so they match", missing)
|
|
}
|
|
|
|
cfg := CreateConfig()
|
|
// mirror of testData, kept in sync by this test living next to the manifest
|
|
blob := `{"providers":{"openai":{"type":"openai","apiKey":"test-key","models":["gpt-test"]}},
|
|
"groups":{"default":{"providers":[]}},
|
|
"users":{"inline":[{"name":"tester","group":"default","apiKey":"test-user-key"}]}}`
|
|
if err := json.Unmarshal([]byte(blob), cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
func TestNewFromTestData(t *testing.T) {
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
h, err := New(context.Background(), next, testDataConfig(t), "llmgw")
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
if h == nil {
|
|
t.Fatal("nil handler")
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/definitely-unknown", nil)
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("want 404, got %d", rec.Code)
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
|
t.Fatalf("want JSON error envelope, got %q", ct)
|
|
}
|
|
}
|