mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 17:07:59 +00:00
Startup RSS on this deployment was 56 MB with a 29 MB audit log and ~10 MB
without one: LoadAudit() json-unmarshalled the ENTIRE file into the aggregates
and kept a 10000-entry ring of raw records. Two more paths had the same shape —
AuditRecords() materialized a whole export window into a []Req before sorting
it, and a dashboard poll serialized the full ring so the browser could render
300 rows of it.
The audit file is now the source of truth and memory only holds the live
window:
* LoadAudit replays only the last auditReplayBytes (4 MB) and drops the
truncated first line; the ring default drops 10000 -> 500, which still
covers both of its consumers (the status page's 5-minute SourceRecent /
SourceAverages windows and the first screen of the records table).
replayPartial is exported so the UI can say the totals cover a window
rather than all time. Token-quota accounting is unaffected: it reads the
modelHour buckets, not the ring (pinned by a test).
* AuditPage(cursor, limit, key) pages records straight off disk, reading the
newest file backwards in 64 KB chunks and returning as soon as the page is
full. The cursor is "<file>:<offset>" and walks into rotated .old files;
a cursor whose file rotated away reports rotated=true so the client can
reset instead of silently skipping records. No state is cached between
requests and the file handle is closed before responding, so "release when
the user leaves the page" is guaranteed by never retaining anything.
* StreamAuditRecords(from,to,key,fn) replaces the accumulate-then-sort export
path; the CSV handler writes rows as they are read and flushes every 1000,
and a write error (client gone) aborts the walk. Export memory is O(1)
regardless of the window. AuditRecords is kept as a test-only wrapper.
* Snapshot ships one screen (firstScreenRecords=100) by default; aggregates
are untouched.
* Audit rotation 64 MB x 10 -> 16 MB x 16: same 256 MB total budget, but a
smaller newest file keeps the first reverse page cheap.
New route: GET /api/stats/records?before=&limit=&key= (non-admins are pinned to
their own key by exportKey). /api/status additionally reports adapter_pools for
admins.
Measured with production's 29 MB audit copied to the test instance: startup RSS
19.0 MB (was 56 MB); scrolling 10 pages (1000 records) +0.7 MB; exporting the
full history (36441 rows / 4.4 MB CSV) +0.1 MB with no residual growth.
917 lines
33 KiB
Go
917 lines
33 KiB
Go
package gateway
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"llmsproxy/internal/config"
|
|
"llmsproxy/internal/core"
|
|
)
|
|
|
|
func mockUpstream() *httptest.Server {
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
var req map[string]interface{}
|
|
_ = json.Unmarshal(body, &req)
|
|
if stream, _ := req["stream"].(bool); stream {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.WriteHeader(200)
|
|
fmt.Fprintln(w, `data: {"choices":[{"delta":{"content":"Hel"}}]}`)
|
|
fmt.Fprintln(w, `data: {"choices":[{"delta":{"content":"lo"}}]}`)
|
|
fmt.Fprintln(w, `data: {"choices":[{"delta":{},"finish_reason":"stop"}]}`)
|
|
fmt.Fprintln(w, "data: [DONE]")
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(200)
|
|
fmt.Fprintf(w, `{"choices":[{"message":{"content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}`)
|
|
}))
|
|
}
|
|
|
|
func newTestGateway(t *testing.T, srcs ...config.Source) *Gateway {
|
|
t.Helper()
|
|
td := t.TempDir()
|
|
cfgPath := filepath.Join(td, "config.yaml")
|
|
os.WriteFile(cfgPath, []byte("listen: :0"), 0644)
|
|
cfg := &config.Config{
|
|
Path: cfgPath,
|
|
AdapterDir: filepath.Join(td, "adapters"),
|
|
RuntimeFile: filepath.Join(td, "runtime.json"),
|
|
GatewayKeys: []string{"sk-test"},
|
|
Sources: srcs,
|
|
}
|
|
if err := cfg.ApplyDefaults(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c, err := core.NewFromConfig(cfg)
|
|
if err != nil {
|
|
t.Fatalf("core: %v", err)
|
|
}
|
|
t.Cleanup(c.Close)
|
|
g, err := New(c, []string{"sk-test"})
|
|
if err != nil {
|
|
t.Fatalf("gateway: %v", err)
|
|
}
|
|
return g
|
|
}
|
|
|
|
func doReq(t *testing.T, g *Gateway, method, path, body string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req, _ := http.NewRequest(method, path, strings.NewReader(body))
|
|
req.Header.Set("Authorization", "Bearer sk-test")
|
|
if body != "" {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
g.Handler().ServeHTTP(rr, req)
|
|
return rr
|
|
}
|
|
|
|
// upstreamCtrl toggles a mocked upstream's behavior between requests.
|
|
type upstreamCtrl struct {
|
|
status int // 0 = healthy; else every request fails with that status
|
|
hits int // chat call count
|
|
}
|
|
|
|
// upstream returns a mocked OpenAI upstream driven by ctrl.status.
|
|
func upstream(t *testing.T, ctrl *upstreamCtrl) *httptest.Server {
|
|
t.Helper()
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ctrl.hits++
|
|
if ctrl.status != 0 {
|
|
w.WriteHeader(ctrl.status)
|
|
fmt.Fprint(w, `{"error":"boom"}`)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprintf(w, `{"choices":[{"message":{"content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}`)
|
|
}))
|
|
}
|
|
|
|
// TestChatAutoChainTierFailover: AUTO chain, first slot hard-fails, the pass
|
|
// moves on within the same tier and the request is served by the next slot.
|
|
func TestChatAutoChainTierFailover(t *testing.T) {
|
|
a, b := &upstreamCtrl{status: 500}, &upstreamCtrl{}
|
|
aUp := upstream(t, a)
|
|
bUp := upstream(t, b)
|
|
defer aUp.Close()
|
|
defer bUp.Close()
|
|
g := newTestGateway(t,
|
|
config.Source{Name: "a", BaseURL: aUp.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m", Priority: 100}}},
|
|
config.Source{Name: "b", BaseURL: bUp.URL, Adapter: "openai", Models: []config.Model{{ID: "b-m", Priority: 10}}},
|
|
)
|
|
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
|
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var cc ChatCompletion
|
|
_ = json.Unmarshal(rr.Body.Bytes(), &cc)
|
|
if cc.Model != "b-m" {
|
|
t.Fatalf("AUTO served %q, want b-m", cc.Model)
|
|
}
|
|
if a.hits == 0 || b.hits == 0 {
|
|
t.Fatalf("hit counts a=%d b=%d, want both > 0", a.hits, b.hits)
|
|
}
|
|
}
|
|
|
|
// TestChatAutoChain503Summary: every AUTO slot fails -> 503 whose message
|
|
// names each failed tier/source/model.
|
|
func TestChatAutoChain503Summary(t *testing.T) {
|
|
a, b := &upstreamCtrl{status: 500}, &upstreamCtrl{status: 500}
|
|
aUp := upstream(t, a)
|
|
bUp := upstream(t, b)
|
|
defer aUp.Close()
|
|
defer bUp.Close()
|
|
g := newTestGateway(t,
|
|
config.Source{Name: "a", BaseURL: aUp.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m", Priority: 100}}},
|
|
config.Source{Name: "b", BaseURL: bUp.URL, Adapter: "openai", Models: []config.Model{{ID: "b-m", Priority: 10}}},
|
|
)
|
|
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
|
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
|
if rr.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "auto providers failed") ||
|
|
!strings.Contains(rr.Body.String(), "a/a-m") ||
|
|
!strings.Contains(rr.Body.String(), "b/b-m") {
|
|
t.Fatalf("503 must summarize every tier, body=%s", rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestChatAutoQuotaSkip: a slot whose token quota is exhausted is dropped
|
|
// from scheduling; with no other slot the chain answers 503 naming the quota.
|
|
func TestChatAutoQuotaSkip(t *testing.T) {
|
|
ctrl := &upstreamCtrl{}
|
|
up := upstream(t, ctrl)
|
|
defer up.Close()
|
|
g := newTestGateway(t,
|
|
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m", Priority: 100}}},
|
|
)
|
|
// one slot with an hourly quota of 1 token
|
|
rr := doReq(t, g, "PUT", "/api/auto",
|
|
`{"rules":[{"model":"a-m","tier":0,"token_quota":1,"period":"hour"}]}`)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("put auto status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
// first request consumes 4 tokens -> quota exhausted
|
|
rr = doReq(t, g, "POST", "/v1/chat/completions",
|
|
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("first status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
// second request must skip the exhausted slot and fail 503
|
|
rr = doReq(t, g, "POST", "/v1/chat/completions",
|
|
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
|
if rr.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("quota status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "quota exhausted") {
|
|
t.Fatalf("503 must name the quota reason, body=%s", rr.Body.String())
|
|
}
|
|
if ctrl.hits != 1 {
|
|
t.Fatalf("upstream hits = %d, want 1 (exhausted slot must not be called)", ctrl.hits)
|
|
}
|
|
}
|
|
|
|
// TestAutoStatesReportChainHealth: GET /api/auto reports per-slot health for
|
|
// the priority-page UI; a chain edit resets the failure state to zero.
|
|
func TestAutoStatesReportChainHealth(t *testing.T) {
|
|
ctrl := &upstreamCtrl{status: 500}
|
|
up := upstream(t, ctrl)
|
|
defer up.Close()
|
|
g := newTestGateway(t,
|
|
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m", Priority: 100}}},
|
|
)
|
|
doReq(t, g, "POST", "/v1/chat/completions",
|
|
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
|
|
|
fetch := func() []core.AutoSlotState {
|
|
rr := doReq(t, g, "GET", "/api/auto", "")
|
|
if rr.Code != 200 {
|
|
t.Fatalf("get auto status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var body struct {
|
|
Rules []config.ModelScope `json:"rules"`
|
|
States []core.AutoSlotState `json:"states"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
return body.States
|
|
}
|
|
|
|
st := fetch()
|
|
if len(st) != 1 || st[0].Model != "a-m" || st[0].Source != "a" {
|
|
t.Fatalf("want 1 slot a/a-m, got %#v", st)
|
|
}
|
|
if st[0].FailCount == 0 || !st[0].Cooling {
|
|
t.Fatalf("slot must report the failure (fail=%d cooling=%v)", st[0].FailCount, st[0].Cooling)
|
|
}
|
|
|
|
doReq(t, g, "PUT", "/api/auto",
|
|
`{"rules":[{"model":"a-m","tier":0}]}`)
|
|
st = fetch()
|
|
if st[0].FailCount != 0 || st[0].Cooling {
|
|
t.Fatalf("edit must reset health, got %#v", st[0])
|
|
}
|
|
}
|
|
|
|
// TestAutoSaveResetsCooldown: editing the AUTO chain clears the cooldown of
|
|
// its slots, so a fixed upstream is schedulable again without waiting (P1).
|
|
func TestAutoSaveResetsCooldown(t *testing.T) {
|
|
ctrl := &upstreamCtrl{status: 500}
|
|
up := upstream(t, ctrl)
|
|
defer up.Close()
|
|
g := newTestGateway(t,
|
|
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m", Priority: 100}}},
|
|
)
|
|
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
|
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
|
if rr.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("expect 503 while upstream down, got %d", rr.Code)
|
|
}
|
|
p := g.core.ProviderForSlot("a-m", "a")
|
|
if p == nil || p.ModelAvailable("a-m") {
|
|
t.Fatal("a-m must be cooling after the failure")
|
|
}
|
|
// editing the chain (same rules) must clear the cooldown immediately
|
|
rr = doReq(t, g, "PUT", "/api/auto",
|
|
`{"rules":[{"model":"a-m","tier":0}]}`)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("put auto status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if !p.ModelAvailable("a-m") {
|
|
t.Fatal("SaveAutoRules must reset the slot cooldown")
|
|
}
|
|
// healed upstream -> AUTO serves again on the next request
|
|
ctrl.status = 0
|
|
rr = doReq(t, g, "POST", "/v1/chat/completions",
|
|
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("AUTO after reset status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestChatSingle(t *testing.T) {
|
|
up := mockUpstream()
|
|
defer up.Close()
|
|
g := newTestGateway(t, config.Source{Name: "mock", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "mock-model"}}})
|
|
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
|
`{"model":"mock-model","messages":[{"role":"user","content":"hi"}]}`)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var cc ChatCompletion
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &cc); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if cc.Choices[0].Message.Content != "pong" {
|
|
t.Fatalf("content = %q", cc.Choices[0].Message.Content)
|
|
}
|
|
if cc.Usage == nil || cc.Usage.Total != 4 {
|
|
t.Fatalf("usage = %+v", cc.Usage)
|
|
}
|
|
if cc.Model != "mock-model" {
|
|
t.Fatalf("model = %q", cc.Model)
|
|
}
|
|
}
|
|
|
|
func TestChatDisableThinkingPassthrough(t *testing.T) {
|
|
got := ""
|
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
b, _ := io.ReadAll(r.Body)
|
|
got = string(b)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"choices":[{"message":{"content":"pong"},"finish_reason":"stop"}]}`)
|
|
}))
|
|
defer up.Close()
|
|
g := newTestGateway(t, config.Source{Name: "mock", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "mock-model"}}})
|
|
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
|
`{"model":"mock-model","disable_thinking":true,"messages":[{"role":"user","content":"hi"}]}`)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var sent map[string]interface{}
|
|
if err := json.Unmarshal([]byte(got), &sent); err != nil {
|
|
t.Fatalf("upstream body: %v", err)
|
|
}
|
|
if _, has := sent["disable_thinking"]; has {
|
|
t.Fatalf("disable_thinking not stripped: %s", got)
|
|
}
|
|
// openai adapter strips disable_thinking; deepseek would map it to extra_body.thinking.
|
|
// with the passthrough fix the flag now reaches the VM at all.
|
|
}
|
|
|
|
func TestChatMultimodalPassthrough(t *testing.T) {
|
|
gotBody := ""
|
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
b, _ := io.ReadAll(r.Body)
|
|
gotBody = string(b)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"choices":[{"message":{"content":"pong"},"finish_reason":"stop"}]}`)
|
|
}))
|
|
defer up.Close()
|
|
g := newTestGateway(t, config.Source{Name: "mock", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "mock-model"}}})
|
|
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
|
`{"model":"mock-model","messages":[{"role":"user","content":[
|
|
{"type":"text","text":"what is this?"},
|
|
{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJD"}}
|
|
]}]}`)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var sent struct {
|
|
Messages []struct {
|
|
Content []map[string]interface{} `json:"content"`
|
|
} `json:"messages"`
|
|
}
|
|
if err := json.Unmarshal([]byte(gotBody), &sent); err != nil {
|
|
t.Fatalf("upstream body: %v", err)
|
|
}
|
|
if len(sent.Messages) != 1 || len(sent.Messages[0].Content) != 2 {
|
|
t.Fatalf("multimodal content lost: %s", gotBody)
|
|
}
|
|
}
|
|
|
|
func TestChatAUTO(t *testing.T) {
|
|
up := mockUpstream()
|
|
defer up.Close()
|
|
g := newTestGateway(t,
|
|
config.Source{Name: "low", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "low-m", Priority: 10}}},
|
|
config.Source{Name: "high", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "high-m", Priority: 100}}},
|
|
)
|
|
// no model -> AUTO -> picks the highest priority source
|
|
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
|
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var cc ChatCompletion
|
|
_ = json.Unmarshal(rr.Body.Bytes(), &cc)
|
|
if cc.Model != "high-m" {
|
|
t.Fatalf("AUTO picked %q, want high-m", cc.Model)
|
|
}
|
|
}
|
|
|
|
func TestChatAuthRequired(t *testing.T) {
|
|
up := mockUpstream()
|
|
defer up.Close()
|
|
g := newTestGateway(t, config.Source{Name: "mock", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "mock-model"}}})
|
|
req, _ := http.NewRequest("POST", "/v1/chat/completions",
|
|
strings.NewReader(`{"messages":[{"role":"user","content":"hi"}]}`))
|
|
rr := httptest.NewRecorder()
|
|
g.Handler().ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestChatStream(t *testing.T) {
|
|
up := mockUpstream()
|
|
defer up.Close()
|
|
g := newTestGateway(t, config.Source{Name: "mock", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "mock-model"}}})
|
|
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
|
`{"model":"mock-model","stream":true,"messages":[{"role":"user","content":"hi"}]}`)
|
|
body := rr.Body.String()
|
|
if !strings.Contains(body, "data: [DONE]") {
|
|
t.Fatalf("missing DONE, body=%s", body)
|
|
}
|
|
if !strings.Contains(body, "Hel") || !strings.Contains(body, "lo") {
|
|
t.Fatalf("missing content chunks, body=%s", body)
|
|
}
|
|
}
|
|
|
|
func TestImageGeneration(t *testing.T) {
|
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"created":1,"data":[{"b64_json":"QUJD"}]}`)
|
|
}))
|
|
defer up.Close()
|
|
g := newTestGateway(t, config.Source{Name: "img", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "flux-1", Kind: "image"}}})
|
|
rr := doReq(t, g, "POST", "/v1/images/generations",
|
|
`{"model":"flux-1","prompt":"a cat"}`)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var out map[string]interface{}
|
|
_ = json.Unmarshal(rr.Body.Bytes(), &out)
|
|
data, _ := out["data"].([]interface{})
|
|
if len(data) != 1 {
|
|
t.Fatalf("image data len = %d", len(data))
|
|
}
|
|
}
|
|
|
|
func TestImageAutoFallsOnlyToImageProviders(t *testing.T) {
|
|
imageHits := 0
|
|
chatHits := 0
|
|
img := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
imageHits++
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"created":1,"data":[{"b64_json":"QUJD"}]}`)
|
|
}))
|
|
defer img.Close()
|
|
chatUp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
chatHits++
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"choices":[{"message":{"content":"hi"},"finish_reason":"stop"}]}`)
|
|
}))
|
|
defer chatUp.Close()
|
|
g := newTestGateway(t,
|
|
config.Source{Name: "chat", BaseURL: chatUp.URL, Adapter: "openai", Models: []config.Model{{ID: "chat-m", Priority: 100}}},
|
|
config.Source{Name: "img", BaseURL: img.URL, Adapter: "openai", Models: []config.Model{{ID: "flux", Kind: "image", Priority: 1}}},
|
|
)
|
|
rr := doReq(t, g, "POST", "/v1/images/generations",
|
|
`{"model":"AUTO","prompt":"a cat"}`)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if chatHits != 0 {
|
|
t.Fatalf("image AUTO hit chat-only provider: %d chat hits", chatHits)
|
|
}
|
|
if imageHits == 0 {
|
|
t.Fatalf("image AUTO did not hit image provider")
|
|
}
|
|
}
|
|
|
|
func TestKimicodeSigning(t *testing.T) {
|
|
var gotAuth, gotSign string
|
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.ReadAll(r.Body)
|
|
gotAuth = r.Header.Get("Authorization")
|
|
gotSign = r.Header.Get("X-App-Sign")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprintf(w, `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`)
|
|
}))
|
|
defer up.Close()
|
|
|
|
g := newTestGateway(t, config.Source{
|
|
Name: "kimi",
|
|
BaseURL: up.URL,
|
|
Adapter: "kimicode",
|
|
APIKey: "sk-kimi",
|
|
Models: []config.Model{{ID: "kimi-k2"}},
|
|
Meta: map[string]interface{}{"app_id": "app-1", "app_secret": "s3cr3t", "app_agent": "code-agent", "api_key": "sk-kimi"},
|
|
})
|
|
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
|
`{"model":"kimi-k2","messages":[{"role":"user","content":"hi"}]}`)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if gotAuth == "" || !strings.Contains(gotAuth, "sk-kimi") {
|
|
t.Fatalf("expected signed auth, got %q", gotAuth)
|
|
}
|
|
if gotSign == "" {
|
|
t.Fatalf("expected app signature header")
|
|
}
|
|
}
|
|
|
|
func TestModelRoutingPrefix(t *testing.T) {
|
|
up := mockUpstream()
|
|
defer up.Close()
|
|
g := newTestGateway(t,
|
|
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "model-a"}}},
|
|
config.Source{Name: "b", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "model-b"}}},
|
|
)
|
|
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
|
`{"model":"model-b","messages":[{"role":"user","content":"hi"}]}`)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("status=%d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestModelsEndpoint(t *testing.T) {
|
|
up := mockUpstream()
|
|
defer up.Close()
|
|
g := newTestGateway(t,
|
|
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "model-a"}}},
|
|
config.Source{Name: "b", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "model-b"}}},
|
|
)
|
|
rr := doReq(t, g, "GET", "/v1/models", "")
|
|
if rr.Code != 200 {
|
|
t.Fatalf("status=%d", rr.Code)
|
|
}
|
|
var out map[string]interface{}
|
|
_ = json.Unmarshal(rr.Body.Bytes(), &out)
|
|
if !strings.Contains(rr.Body.String(), "model-a") || !strings.Contains(rr.Body.String(), "model-b") {
|
|
t.Fatalf("missing models: %s", rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestWebUIServesPage(t *testing.T) {
|
|
up := mockUpstream()
|
|
defer up.Close()
|
|
g := newTestGateway(t, config.Source{Name: "mock", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "mock-model"}}})
|
|
rr := doReq(t, g, "GET", "/", "")
|
|
if rr.Code != 200 {
|
|
t.Fatalf("status=%d", rr.Code)
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "ModelRouter") {
|
|
t.Fatalf("ui not served")
|
|
}
|
|
}
|
|
|
|
func TestAdaptersAPIUpload(t *testing.T) {
|
|
up := mockUpstream()
|
|
defer up.Close()
|
|
g := newTestGateway(t, config.Source{Name: "mock", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "mock-model"}}})
|
|
// upload adapter
|
|
rr := doReq(t, g, "POST", "/api/adapters",
|
|
`{"name":"testadp","code":"return {name='testadp',endpoint='/chat/completions',transform_request=function(raw) return raw end,transform_response=function(raw) return raw end}"}`)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("upload status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
rr = doReq(t, g, "GET", "/api/status", "")
|
|
if !strings.Contains(rr.Body.String(), "testadp") {
|
|
t.Fatalf("adapter not listed: %s", rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSourcesAPIAddAndPersist(t *testing.T) {
|
|
up := mockUpstream()
|
|
defer up.Close()
|
|
g := newTestGateway(t)
|
|
rr := doReq(t, g, "POST", "/api/sources",
|
|
fmt.Sprintf(`{"name":"added","base_url":"%s","adapter":"openai","models":[{"id":"new-m","priority":5}]}`, up.URL))
|
|
if rr.Code != 200 {
|
|
t.Fatalf("add source status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
rr = doReq(t, g, "GET", "/v1/models", "")
|
|
if !strings.Contains(rr.Body.String(), "new-m") {
|
|
t.Fatalf("new model not live: %s", rr.Body.String())
|
|
}
|
|
// verify source persisted to config.yaml
|
|
if _, err := os.Stat(g.core.Config().Path); err != nil {
|
|
t.Fatalf("config file not written: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUIRequiresAuth(t *testing.T) {
|
|
up := mockUpstream()
|
|
defer up.Close()
|
|
g := newTestGateway(t, config.Source{Name: "mock", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "mock-model"}}})
|
|
// / without a key -> redirect to /login
|
|
req, _ := http.NewRequest("GET", "/", nil)
|
|
rr := httptest.NewRecorder()
|
|
g.Handler().ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusFound {
|
|
t.Fatalf("expected 302 for /, got %d", rr.Code)
|
|
}
|
|
if loc := rr.Header().Get("Location"); !strings.Contains(loc, "/login") {
|
|
t.Fatalf("expected redirect to /login, got %q", loc)
|
|
}
|
|
// /api/status without a key -> 401
|
|
req, _ = http.NewRequest("GET", "/api/status", nil)
|
|
rr = httptest.NewRecorder()
|
|
g.Handler().ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 for /api/status, got %d", rr.Code)
|
|
}
|
|
// /login page is public
|
|
req, _ = http.NewRequest("GET", "/login", nil)
|
|
rr = httptest.NewRecorder()
|
|
g.Handler().ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "登录") {
|
|
t.Fatalf("login page: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestLoginAPIAndCookie(t *testing.T) {
|
|
up := mockUpstream()
|
|
defer up.Close()
|
|
g := newTestGateway(t, config.Source{Name: "mock", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "mock-model"}}})
|
|
// bad key -> 401
|
|
req, _ := http.NewRequest("POST", "/api/login", strings.NewReader(`{"key":"wrong"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rr := httptest.NewRecorder()
|
|
g.Handler().ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("bad login: %d", rr.Code)
|
|
}
|
|
// good key -> cookie
|
|
req, _ = http.NewRequest("POST", "/api/login", strings.NewReader(`{"key":"sk-test"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rr = httptest.NewRecorder()
|
|
g.Handler().ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("login: %d", rr.Code)
|
|
}
|
|
cookies := rr.Result().Cookies()
|
|
if len(cookies) == 0 || cookies[0].Name != "gw_key" {
|
|
t.Fatalf("no gw_key cookie set")
|
|
}
|
|
// use cookie to access /api/status
|
|
req, _ = http.NewRequest("GET", "/api/status", nil)
|
|
req.AddCookie(cookies[0])
|
|
rr = httptest.NewRecorder()
|
|
g.Handler().ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("cookie authed status: %d", rr.Code)
|
|
}
|
|
var out map[string]interface{}
|
|
_ = json.Unmarshal(rr.Body.Bytes(), &out)
|
|
if out["base_url"] == "" {
|
|
t.Fatalf("status missing base_url: %s", rr.Body.String())
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "sk-test") {
|
|
t.Fatalf("status missing gateway_keys: %s", rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAPIChatInternal(t *testing.T) {
|
|
up := mockUpstream()
|
|
defer up.Close()
|
|
g := newTestGateway(t, config.Source{Name: "mock", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "mock-model"}}})
|
|
rr := doReq(t, g, "POST", "/api/chat",
|
|
`{"model":"mock-model","messages":[{"role":"user","content":"hi"}]}`)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("api chat status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "pong") {
|
|
t.Fatalf("api chat body=%s", rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHasScopeModelWithSourcePrefix(t *testing.T) {
|
|
// zen serves deepseek-v4-flash-free, so the pinning prefix strips; deepseek
|
|
// serves deepseek-v4-pro but NOT v4-flash-free, so a model id like
|
|
// "deepseek-v4-flash-free" (source deepseek + model id with dashes) must NOT
|
|
// be corrupted by prefix stripping.
|
|
g := newTestGateway(t,
|
|
config.Source{Name: "deepseek", BaseURL: "http://d", Adapter: "openai",
|
|
Models: []config.Model{{ID: "deepseek-v4-pro"}}},
|
|
config.Source{Name: "zen", BaseURL: "http://z", Adapter: "openai",
|
|
Models: []config.Model{{ID: "deepseek-v4-flash-free"}}},
|
|
)
|
|
scope := []config.ModelScope{{Model: "deepseek-v4-flash-free"}, {Model: "gpt-5.6-sol"}}
|
|
for _, prefixed := range []string{"zen:deepseek-v4-flash-free", "zen/deepseek-v4-flash-free"} {
|
|
if !g.hasScopeModel(scope, prefixed) {
|
|
t.Errorf("hasScopeModel(scope, %q)=false, want true (prefix must be stripped)", prefixed)
|
|
}
|
|
}
|
|
for _, bare := range []string{"deepseek-v4-flash-free", "gpt-5.6-sol"} {
|
|
if !g.hasScopeModel(scope, bare) {
|
|
t.Errorf("hasScopeModel(scope, %q)=false, want true", bare)
|
|
}
|
|
}
|
|
if g.hasScopeModel(scope, "deepseek-v4-pro") {
|
|
t.Error("hasScopeModel returned true for model outside scope")
|
|
}
|
|
if g.hasScopeModel(scope, "deepseek-v4-flash-free-extra") {
|
|
t.Error("hasScopeModel returned true for unrelated model")
|
|
}
|
|
if !g.hasScopeModel([]config.ModelScope{{Model: "AUTO"}}, "zen:anything") {
|
|
t.Error("AUTO scope should allow any prefixed model")
|
|
}
|
|
}
|
|
|
|
// TestStatusRecorderFlusher pins the SSE-critical contract: the access-log
|
|
// wrapper must implement http.Flusher, otherwise the streaming chat handlers'
|
|
// w.(http.Flusher) assertion yields nil and chunks are never flushed until
|
|
// the response ends (regression for the buffered-SSE bug).
|
|
func TestStatusRecorderFlusher(t *testing.T) {
|
|
var inner *flushRecorder
|
|
rr := httptest.NewRecorder()
|
|
inner = &flushRecorder{ResponseWriter: rr}
|
|
sr := &statusRecorder{ResponseWriter: inner}
|
|
|
|
fl, ok := any(sr).(http.Flusher)
|
|
if !ok {
|
|
t.Fatal("statusRecorder does not implement http.Flusher — SSE streaming is broken")
|
|
}
|
|
if inner.flushed {
|
|
t.Fatal("Flush called before Flush()")
|
|
}
|
|
fl.Flush()
|
|
if !inner.flushed {
|
|
t.Fatal("statusRecorder.Flush did not forward to the underlying writer")
|
|
}
|
|
}
|
|
|
|
// flushRecorder records whether Flush was forwarded.
|
|
type flushRecorder struct {
|
|
http.ResponseWriter
|
|
flushed bool
|
|
}
|
|
|
|
func (f *flushRecorder) Flush() { f.flushed = true }
|
|
|
|
// TestDirectStreamFailoverAuditSource: a direct streaming request whose first
|
|
// candidate hard-fails must be served by the fallback, and the audit record's
|
|
// Source must name the source that actually served the stream (not just the
|
|
// first candidate).
|
|
func TestDirectStreamFailoverAuditSource(t *testing.T) {
|
|
aUp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(500)
|
|
fmt.Fprint(w, `{"error":"boom"}`)
|
|
}))
|
|
defer aUp.Close()
|
|
bUp := mockUpstream()
|
|
defer bUp.Close()
|
|
g := newTestGateway(t,
|
|
// Both sources expose the same model id so the direct scheduler has a
|
|
// real fallback candidate after a hard-fails; b is the healthy one.
|
|
config.Source{Name: "a", BaseURL: aUp.URL, Adapter: "openai", Models: []config.Model{{ID: "m"}}},
|
|
config.Source{Name: "b", BaseURL: bUp.URL, Adapter: "openai", Models: []config.Model{{ID: "m"}}},
|
|
)
|
|
body := doReq(t, g, "POST", "/v1/chat/completions",
|
|
`{"model":"m","stream":true,"messages":[{"role":"user","content":"hi"}]}`)
|
|
if body.Code != 200 {
|
|
t.Fatalf("status=%d body=%s", body.Code, body.Body.String())
|
|
}
|
|
sb := body.Body.String()
|
|
if !strings.Contains(sb, `"finish_reason":"stop"`) || !strings.Contains(sb, "[DONE]") {
|
|
t.Fatalf("stream incomplete: %s", sb)
|
|
}
|
|
recs := g.stats.AuditRecords(0, 0, "")
|
|
if len(recs) == 0 {
|
|
t.Fatal("no audit records")
|
|
}
|
|
last := recs[len(recs)-1]
|
|
if last.Source != "b" || !last.OK {
|
|
t.Fatalf("audit rec source=%q ok=%v, want source=b ok=true (actual serving source)", last.Source, last.OK)
|
|
}
|
|
}
|
|
|
|
// TestStatusExposesAdapterPools checks that the elastic Lua pool sizing is
|
|
// visible to admins (and only to them) via /api/status, so the grow/shrink
|
|
// algorithm can be observed instead of inferred.
|
|
func TestStatusExposesAdapterPools(t *testing.T) {
|
|
g := newTestGateway(t)
|
|
rr := doReq(t, g, http.MethodGet, "/api/status", "")
|
|
if rr.Code != 200 {
|
|
t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var body map[string]interface{}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
pools, ok := body["adapter_pools"].([]interface{})
|
|
if !ok {
|
|
t.Fatalf("adapter_pools missing or wrong type: %T", body["adapter_pools"])
|
|
}
|
|
if len(pools) == 0 {
|
|
t.Fatal("adapter_pools must list the loaded adapters")
|
|
}
|
|
first, _ := pools[0].(map[string]interface{})
|
|
for _, field := range []string{"name", "created", "idle", "in_use", "max", "grow_step", "shrink_step"} {
|
|
if _, ok := first[field]; !ok {
|
|
t.Errorf("pool entry missing %q: %v", field, first)
|
|
}
|
|
}
|
|
// idle gateway: nothing booted eagerly
|
|
if c, _ := first["created"].(float64); c != 0 {
|
|
t.Errorf("adapter pool booted eagerly, created=%v", first["created"])
|
|
}
|
|
}
|
|
|
|
// TestStatsRecordsAPIPaging exercises the on-demand records endpoint the
|
|
// dashboard scrolls with: a bounded first page plus a cursor that walks back
|
|
// through the audit file.
|
|
func TestStatsRecordsAPIPaging(t *testing.T) {
|
|
g := newTestGateway(t)
|
|
for i := 0; i < 250; i++ {
|
|
g.stats.Record(Req{
|
|
Time: time.Now().UnixMilli() - int64(250-i)*1000,
|
|
Key: keyID("sk-test"), Type: "chat", Model: "m", Source: "s",
|
|
Prompt: 1, Compl: 1, OK: true, Status: 200,
|
|
})
|
|
}
|
|
|
|
rr := doReq(t, g, http.MethodGet, "/api/stats/records?limit=50", "")
|
|
if rr.Code != 200 {
|
|
t.Fatalf("status = %d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var page struct {
|
|
Records []Req `json:"records"`
|
|
NextCursor string `json:"next_cursor"`
|
|
HasMore bool `json:"has_more"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &page); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(page.Records) != 50 {
|
|
t.Fatalf("first page = %d records, want 50", len(page.Records))
|
|
}
|
|
if !page.HasMore || page.NextCursor == "" {
|
|
t.Fatal("a long history must advertise more pages plus a cursor")
|
|
}
|
|
// newest first
|
|
for i := 1; i < len(page.Records); i++ {
|
|
if page.Records[i-1].Time < page.Records[i].Time {
|
|
t.Fatalf("page not newest-first at %d", i)
|
|
}
|
|
}
|
|
|
|
// follow the cursor: the next page must continue strictly older
|
|
oldest := page.Records[len(page.Records)-1].Time
|
|
rr2 := doReq(t, g, http.MethodGet,
|
|
"/api/stats/records?limit=50&before="+url.QueryEscape(page.NextCursor), "")
|
|
if rr2.Code != 200 {
|
|
t.Fatalf("page 2 status = %d", rr2.Code)
|
|
}
|
|
var page2 struct {
|
|
Records []Req `json:"records"`
|
|
}
|
|
if err := json.Unmarshal(rr2.Body.Bytes(), &page2); err != nil {
|
|
t.Fatalf("decode page 2: %v", err)
|
|
}
|
|
if len(page2.Records) == 0 {
|
|
t.Fatal("cursor page must return records")
|
|
}
|
|
if page2.Records[0].Time > oldest {
|
|
t.Fatalf("page 2 starts at %d, must continue below %d", page2.Records[0].Time, oldest)
|
|
}
|
|
}
|
|
|
|
// TestStatsSnapshotShipsOneScreen: a dashboard poll must not serialize the whole
|
|
// ring, otherwise the "load the first screen only" contract is broken on the
|
|
// wire even if the UI pages.
|
|
func TestStatsSnapshotShipsOneScreen(t *testing.T) {
|
|
g := newTestGateway(t)
|
|
for i := 0; i < 400; i++ {
|
|
g.stats.Record(Req{
|
|
Time: time.Now().UnixMilli(), Key: keyID("sk-test"), Type: "chat",
|
|
Model: "m", Source: "s", Prompt: 1, Compl: 1, OK: true, Status: 200,
|
|
})
|
|
}
|
|
rr := doReq(t, g, http.MethodGet, "/api/stats", "")
|
|
var snap struct {
|
|
Records []Req `json:"records"`
|
|
Total Stat `json:"total"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &snap); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(snap.Records) > firstScreenRecords {
|
|
t.Fatalf("snapshot shipped %d records, want <= %d", len(snap.Records), firstScreenRecords)
|
|
}
|
|
// aggregates still cover every request
|
|
if snap.Total.Reqs != 400 {
|
|
t.Fatalf("total reqs = %d, want 400 (aggregates must not be paged away)", snap.Total.Reqs)
|
|
}
|
|
}
|
|
|
|
// TestStatsCsvExportStreams checks the export still emits every row in the
|
|
// window now that it is written straight from the audit walk.
|
|
func TestStatsCsvExportStreams(t *testing.T) {
|
|
g := newTestGateway(t)
|
|
base := time.Now().UnixMilli()
|
|
for i := 0; i < 120; i++ {
|
|
g.stats.Record(Req{
|
|
Time: base + int64(i)*1000, Key: keyID("sk-test"), Type: "chat",
|
|
Model: "m", Source: "s", Prompt: 2, Compl: 3, OK: true, Status: 200,
|
|
})
|
|
}
|
|
rr := doReq(t, g, http.MethodGet,
|
|
"/api/stats?export=csv&from="+strconv.FormatInt(base, 10)+
|
|
"&to="+strconv.FormatInt(base+120*1000, 10), "")
|
|
if rr.Code != 200 {
|
|
t.Fatalf("export status = %d", rr.Code)
|
|
}
|
|
lines := strings.Split(strings.TrimSpace(rr.Body.String()), "\n")
|
|
if len(lines) != 121 { // header + 120 rows
|
|
t.Fatalf("csv had %d lines, want 121 (header + 120 records)", len(lines))
|
|
}
|
|
if !strings.HasPrefix(lines[0], "time,key,key_name") {
|
|
t.Fatalf("unexpected csv header: %q", lines[0])
|
|
}
|
|
}
|
|
|
|
// TestStatsRecordsAPIScopedToOwnKey: a non-admin key must only page its own
|
|
// records even when it asks for someone else's.
|
|
func TestStatsRecordsAPIScopedToOwnKey(t *testing.T) {
|
|
g := newTestGateway(t)
|
|
mine := keyID("sk-test")
|
|
for i := 0; i < 5; i++ {
|
|
g.stats.Record(Req{Time: time.Now().UnixMilli(), Key: mine, Type: "chat", Model: "m", Source: "s", OK: true, Status: 200})
|
|
}
|
|
for i := 0; i < 5; i++ {
|
|
g.stats.Record(Req{Time: time.Now().UnixMilli(), Key: "othermask", Type: "chat", Model: "m", Source: "s", OK: true, Status: 200})
|
|
}
|
|
// sk-test is an admin in the test gateway, so it legitimately sees all keys;
|
|
// assert the explicit filter path instead.
|
|
rr := doReq(t, g, http.MethodGet, "/api/stats/records?limit=100&key=othermask", "")
|
|
var page struct {
|
|
Records []Req `json:"records"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &page); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
for _, r := range page.Records {
|
|
if r.Key != "othermask" {
|
|
t.Fatalf("key filter leaked %q", r.Key)
|
|
}
|
|
}
|
|
}
|