Two things that were only ever confusing, and one of them stopped a command working on the cluster. The store type was named SQLite and kept the name when PostgreSQL was added, so OpenPostgres returned a *SQLite and all seventy-odd methods on the production store were spelled after the database it was not running on. Nothing broke; people did. Earlier today I added three methods to *SQLite for a table that on this cluster only ever lives in PostgreSQL, and the name did not once look wrong. It is a field, which is all it ever was. `llmgw users` and `llmgw keys` defaulted --store to a SQLite path. On a deployment whose store is PostgreSQL every one of them failed with "unable to open database file" while the right answer sat in auth.store in the config file beside them; the conclusion an operator draws is that the tool is broken, and they are not far off. They now ask the server's own configuration before falling back, and the error names which of the three sources chose the address — the failure was never the path, it was which one picked it. The single-machine default still applies last, so a deployment with no config file keeps working with no flags. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
320 lines
9.5 KiB
Go
320 lines
9.5 KiB
Go
package e2e
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
// paidConfig turns on auth with a price attached to the test model, so spend
|
|
// is a number rather than always zero.
|
|
func paidConfig(path string) func(*config.Server) {
|
|
return func(c *config.Server) {
|
|
c.Auth = config.Auth{
|
|
Enabled: true, Store: path,
|
|
CacheTTL: 50 * time.Millisecond,
|
|
FlushInterval: 20 * time.Millisecond,
|
|
BatchSize: 8,
|
|
Pricing: map[string]config.Price{
|
|
testModel: {PromptPer1k: 1_000_000, CompletionPer1k: 1_000_000},
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
func usageRows(t *testing.T, path string) []store.Usage {
|
|
t.Helper()
|
|
s, err := store.OpenSQLite(context.Background(), path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
|
|
rows, err := s.SpendSince(context.Background(), time.Now().Add(-time.Hour))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out := make([]store.Usage, 0, len(rows))
|
|
for key, micros := range rows {
|
|
out = append(out, store.Usage{KeyID: key, CostMicros: micros})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestUsageReachesTheStoreWithoutBlockingRequests(t *testing.T) {
|
|
path, mint := provision(t)
|
|
key := mint(t, "spender")
|
|
h := start(t, 2, paidConfig(path))
|
|
|
|
for range 12 {
|
|
if code, msg := h.call(t, key, testModel); code != http.StatusOK {
|
|
t.Fatalf("%d %s", code, msg)
|
|
}
|
|
}
|
|
|
|
// The writer is asynchronous, so this is the one place a wait is correct.
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if rows := usageRows(t, path); len(rows) > 0 && rows[0].CostMicros > 0 {
|
|
return
|
|
}
|
|
time.Sleep(25 * time.Millisecond)
|
|
}
|
|
t.Fatal("no usage reached the store")
|
|
}
|
|
|
|
func TestRequestRateLimitsBindThroughTheGateway(t *testing.T) {
|
|
path, mint := provision(t)
|
|
key := mint(t, "burst")
|
|
withStore(t, path, func(s *store.DB) {
|
|
rpm := 5
|
|
if err := s.PutLimits(context.Background(), store.Limits{
|
|
Scope: store.ScopeKey, ScopeID: "key-burst", RPM: &rpm,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
|
|
h := start(t, 2, paidConfig(path))
|
|
|
|
ok, limited := 0, 0
|
|
for range 20 {
|
|
switch code, _ := h.call(t, key, testModel); code {
|
|
case http.StatusOK:
|
|
ok++
|
|
case http.StatusTooManyRequests:
|
|
limited++
|
|
default:
|
|
t.Fatalf("unexpected status %d", code)
|
|
}
|
|
}
|
|
if ok != 5 {
|
|
t.Errorf("a limit of 5 let %d requests through", ok)
|
|
}
|
|
if limited != 15 {
|
|
t.Errorf("%d requests were refused, expected 15", limited)
|
|
}
|
|
}
|
|
|
|
func TestASpentBudgetRefusesAndDoesNotSuggestRetrying(t *testing.T) {
|
|
path, mint := provision(t)
|
|
key := mint(t, "broke")
|
|
withStore(t, path, func(s *store.DB) {
|
|
// One micro-unit: the first request exhausts it.
|
|
budget := int64(1)
|
|
if err := s.PutLimits(context.Background(), store.Limits{
|
|
Scope: store.ScopeKey, ScopeID: "key-broke",
|
|
Budget: &budget, BudgetPeriod: store.PeriodMonth,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
|
|
h := start(t, 2, paidConfig(path))
|
|
|
|
if code, msg := h.call(t, key, testModel); code != http.StatusOK {
|
|
t.Fatalf("the first request should go through: %d %s", code, msg)
|
|
}
|
|
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if code, _ := h.call(t, key, testModel); code == http.StatusTooManyRequests {
|
|
return
|
|
}
|
|
time.Sleep(25 * time.Millisecond)
|
|
}
|
|
t.Fatal("a key kept spending past its budget")
|
|
}
|
|
|
|
func TestBudgetSurvivesAGatewayRestart(t *testing.T) {
|
|
// The counters live in memory. If a restart reset them, a budget would be a
|
|
// suggestion that anyone could clear by bouncing the process.
|
|
path, mint := provision(t)
|
|
key := mint(t, "persistent")
|
|
|
|
h := start(t, 2, paidConfig(path))
|
|
for range 3 {
|
|
if code, msg := h.call(t, key, testModel); code != http.StatusOK {
|
|
t.Fatalf("%d %s", code, msg)
|
|
}
|
|
}
|
|
// Let the batch writer land before the process goes away.
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if rows := usageRows(t, path); len(rows) > 0 && rows[0].CostMicros > 0 {
|
|
break
|
|
}
|
|
time.Sleep(25 * time.Millisecond)
|
|
}
|
|
|
|
spent := usageRows(t, path)
|
|
if len(spent) == 0 || spent[0].CostMicros == 0 {
|
|
t.Fatal("nothing was recorded to restore from")
|
|
}
|
|
|
|
// A second gateway over the same store, with a budget just under what was
|
|
// already spent: it must refuse immediately rather than starting fresh.
|
|
withStore(t, path, func(s *store.DB) {
|
|
budget := spent[0].CostMicros
|
|
if err := s.PutLimits(context.Background(), store.Limits{
|
|
Scope: store.ScopeKey, ScopeID: "key-persistent",
|
|
Budget: &budget, BudgetPeriod: store.PeriodMonth,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
|
|
h2 := start(t, 2, paidConfig(path))
|
|
if code, _ := h2.call(t, key, testModel); code != http.StatusTooManyRequests {
|
|
t.Errorf("the restarted gateway gave the key a fresh budget: %d", code)
|
|
}
|
|
}
|
|
|
|
func TestLimitsAreNotAppliedWithoutAuthentication(t *testing.T) {
|
|
// Accounting only exists where there is an identity to charge.
|
|
h := start(t, 2)
|
|
for range 30 {
|
|
if code, msg := h.call(t, "", testModel); code != http.StatusOK {
|
|
t.Fatalf("an unauthenticated gateway rate-limited a request: %d %s", code, msg)
|
|
}
|
|
}
|
|
}
|
|
|
|
const cheapModel = "cheap-model"
|
|
|
|
// budgetFallbackConfig prices the main model and leaves the fallback free,
|
|
// which is what makes it affordable to a key with nothing left.
|
|
func budgetFallbackConfig(path string) func(*config.Server) {
|
|
return func(c *config.Server) {
|
|
paidConfig(path)(c)
|
|
// The fallback has no price, so it costs nothing and the budget check
|
|
// exempts it. That exemption has existed all along with a comment
|
|
// saying it is what lets a spent key still be served something cheap;
|
|
// nothing ever tried, so it could never fire.
|
|
c.Models = budgetFallbackModels()
|
|
// The same engines answer for both names, so this is about accounting
|
|
// rather than about placement.
|
|
for i := range c.Endpoints {
|
|
c.Endpoints[i].Models = []string{testModel, cheapModel}
|
|
}
|
|
}
|
|
}
|
|
|
|
// callWithHeaders is call, keeping the response headers.
|
|
func (h *harness) callWithHeaders(t *testing.T, key, model string) (int, http.Header) {
|
|
t.Helper()
|
|
body, _ := json.Marshal(map[string]any{
|
|
"model": model,
|
|
"messages": []map[string]string{{"role": "user", "content": "hi"}},
|
|
"max_tokens": 4,
|
|
})
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost,
|
|
h.router.URL+"/v1/chat/completions", bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+key)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
return resp.StatusCode, resp.Header
|
|
}
|
|
|
|
func TestASpentBudgetIsServedAnAffordableModel(t *testing.T) {
|
|
// A team trying models will exhaust a quota, and being dropped to a smaller
|
|
// model is a far better answer than a refusal: it makes a budget a
|
|
// guardrail rather than a wall.
|
|
path, mint := provision(t)
|
|
key := mint(t, "broke")
|
|
withStore(t, path, func(s *store.DB) {
|
|
budget := int64(1) // one micro-unit: the first request exhausts it
|
|
if err := s.PutLimits(context.Background(), store.Limits{
|
|
Scope: store.ScopeKey, ScopeID: "key-broke",
|
|
Budget: &budget, BudgetPeriod: store.PeriodMonth,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
|
|
h := start(t, 2, budgetFallbackConfig(path))
|
|
|
|
if code, msg := h.call(t, key, testModel); code != http.StatusOK {
|
|
t.Fatalf("the first request should go through: %d %s", code, msg)
|
|
}
|
|
|
|
// Once the spend lands, the priced model is unaffordable and the free one
|
|
// answers instead — with the response saying which, rather than leaving
|
|
// the caller to infer it from the reply.
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
code, hdr := h.callWithHeaders(t, key, testModel)
|
|
if code == http.StatusOK && hdr.Get("x-llmgw-served-model") == cheapModel {
|
|
return
|
|
}
|
|
if code == http.StatusTooManyRequests {
|
|
t.Fatal("a spent budget refused the request instead of falling back")
|
|
}
|
|
time.Sleep(25 * time.Millisecond)
|
|
}
|
|
t.Fatal("the budget never ran out, or the fallback never served")
|
|
}
|
|
|
|
func TestASpentBudgetWithNoFallbackStillRefuses(t *testing.T) {
|
|
// The limit has to remain a limit. A model with nowhere cheaper to go is
|
|
// refused exactly as before, or a budget would stop meaning anything.
|
|
path, mint := provision(t)
|
|
key := mint(t, "broke")
|
|
withStore(t, path, func(s *store.DB) {
|
|
budget := int64(1)
|
|
if err := s.PutLimits(context.Background(), store.Limits{
|
|
Scope: store.ScopeKey, ScopeID: "key-broke",
|
|
Budget: &budget, BudgetPeriod: store.PeriodMonth,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
|
|
h := start(t, 2, paidConfig(path)) // no catalog, so no fallbacks
|
|
|
|
if code, msg := h.call(t, key, testModel); code != http.StatusOK {
|
|
t.Fatalf("the first request should go through: %d %s", code, msg)
|
|
}
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if code, _ := h.call(t, key, testModel); code == http.StatusTooManyRequests {
|
|
return
|
|
}
|
|
time.Sleep(25 * time.Millisecond)
|
|
}
|
|
t.Fatal("a key with no fallback kept spending past its budget")
|
|
}
|
|
|
|
// budgetFallbackModels is a priced model with a free one behind it.
|
|
func budgetFallbackModels() []catalog.Model {
|
|
return []catalog.Model{
|
|
{
|
|
Name: testModel, Source: "/models/test",
|
|
Fallbacks: []string{cheapModel},
|
|
Scaling: catalog.Scaling{Min: 1, Max: 1},
|
|
Profiles: []catalog.Profile{{Name: "p", Engine: "llamacpp", Weight: 1}},
|
|
},
|
|
{
|
|
Name: cheapModel, Source: "/models/cheap",
|
|
Scaling: catalog.Scaling{Min: 1, Max: 1},
|
|
Profiles: []catalog.Profile{{Name: "p", Engine: "llamacpp", Weight: 1}},
|
|
},
|
|
}
|
|
}
|