Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
307 lines
10 KiB
Go
307 lines
10 KiB
Go
package router
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/prometheus/client_golang/prometheus/testutil"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/kvevents"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/router/kvindex"
|
|
)
|
|
|
|
// kvFakeVLLM is an engine with vLLM's /tokenize beside the completion: it
|
|
// "tokenizes" a chat as the bytes of each message's role and content, which
|
|
// is enough for the router and a test to agree on token ids.
|
|
type kvFakeVLLM struct {
|
|
srv *httptest.Server
|
|
served atomic.Int64
|
|
tokenized atomic.Int64
|
|
failTok atomic.Bool
|
|
lastTok atomic.Value
|
|
}
|
|
|
|
func newKVFakeVLLM(t *testing.T) *kvFakeVLLM {
|
|
t.Helper()
|
|
f := &kvFakeVLLM{}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /metrics", func(w http.ResponseWriter, _ *http.Request) {
|
|
fmt.Fprintf(w, "vllm:num_requests_running 0\nvllm:num_requests_waiting 0\nvllm:kv_cache_usage_perc 0.1\n")
|
|
})
|
|
mux.HandleFunc("POST /tokenize", func(w http.ResponseWriter, r *http.Request) {
|
|
f.tokenized.Add(1)
|
|
b, _ := io.ReadAll(r.Body)
|
|
f.lastTok.Store(b)
|
|
if f.failTok.Load() {
|
|
http.Error(w, "overloaded", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
var req struct {
|
|
Messages []struct{ Role, Content string } `json:"messages"`
|
|
}
|
|
if err := json.Unmarshal(b, &req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
toks := fakeTokens(req.Messages...)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"count": len(toks), "max_model_len": 4096, "tokens": toks})
|
|
})
|
|
mux.HandleFunc("POST /v1/chat/completions", func(w http.ResponseWriter, _ *http.Request) {
|
|
f.served.Add(1)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"choices":[{"message":{"content":"hi"}}],"usage":{"prompt_tokens":4,"completion_tokens":2}}`)
|
|
})
|
|
f.srv = httptest.NewServer(mux)
|
|
t.Cleanup(f.srv.Close)
|
|
return f
|
|
}
|
|
|
|
func fakeTokens(msgs ...struct{ Role, Content string }) []int {
|
|
var out []int
|
|
for _, m := range msgs {
|
|
for _, c := range []byte(m.Role + ":" + m.Content + "\n") {
|
|
out = append(out, int(c))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func chatOf(prompt string) []struct{ Role, Content string } {
|
|
return []struct{ Role, Content string }{{"user", prompt}}
|
|
}
|
|
|
|
// kvRouter is a router over two fake vLLM replicas, a and b, of a model
|
|
// whose profile publishes KV cache events.
|
|
func kvRouter(t *testing.T, tier1 bool) (*Router, *kvFakeVLLM, *kvFakeVLLM) {
|
|
t.Helper()
|
|
a, b := newKVFakeVLLM(t), newKVFakeVLLM(t)
|
|
cfg := baseConfig(
|
|
config.Endpoint{ID: "a", URL: a.srv.URL, Models: []string{"m"}},
|
|
config.Endpoint{ID: "b", URL: b.srv.URL, Models: []string{"m"}},
|
|
config.Endpoint{ID: "o-1", URL: a.srv.URL, Models: []string{"other"}},
|
|
)
|
|
m := catModel("m", nil)
|
|
m.Profiles[0].KVEvents = true
|
|
m.RoutingProfile = "chat"
|
|
other := catModel("other", nil)
|
|
other.RoutingProfile = "chat"
|
|
cfg.Models = []catalog.Model{m, other}
|
|
cfg.Router.Prefix.Enabled = tier1
|
|
rt := newTestRouter(t, cfg)
|
|
// Both scraped before anything is sent: a replica not yet heard from is
|
|
// passed over while another has been, which is admission's rule and not
|
|
// what these tests are about.
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for {
|
|
fresh := 0
|
|
for _, sn := range rt.reg.Snapshots("m") {
|
|
if !sn.Stale {
|
|
fresh++
|
|
}
|
|
}
|
|
if fresh == 2 {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("the replicas were never scraped")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
return rt, a, b
|
|
}
|
|
|
|
// longPrompt is long enough for several blocks of sixteen.
|
|
var longPrompt = strings.Repeat("You are a careful assistant who answers briefly. ", 6)
|
|
|
|
func sendChat(t *testing.T, rt *Router, prompt string) {
|
|
t.Helper()
|
|
rec := post(t, rt.Handler(), map[string]any{
|
|
"model": "m", "messages": []map[string]any{{"role": "user", "content": prompt}},
|
|
}, nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func kvDecisions(rt *Router, d kvindex.Decision) float64 {
|
|
return testutil.ToFloat64(rt.m.kvDecisions.WithLabelValues(string(d)))
|
|
}
|
|
|
|
func TestRequestsGoToTheReplicaHoldingTheLongestBlockPrefix(t *testing.T) {
|
|
rt, a, b := kvRouter(t, false)
|
|
hs := kvevents.BlockHashes(fakeTokens(chatOf(longPrompt)...), 16, 0)
|
|
if len(hs) < 6 {
|
|
t.Fatalf("the prompt should make several blocks, made %d", len(hs))
|
|
}
|
|
// a holds the first two blocks — the opening a chat template shares —
|
|
// and b the whole prompt.
|
|
rt.ApplyKV(kvevents.Update{Replica: "a", BlockSize: 16, Stored: hs[:2]})
|
|
rt.ApplyKV(kvevents.Update{Replica: "b", BlockSize: 16, Stored: hs})
|
|
|
|
for range 10 {
|
|
sendChat(t, rt, longPrompt)
|
|
}
|
|
if b.served.Load() != 10 || a.served.Load() != 0 {
|
|
t.Fatalf("served a=%d b=%d; every request should go to the replica holding its blocks",
|
|
a.served.Load(), b.served.Load())
|
|
}
|
|
if n := kvDecisions(rt, kvindex.Hit); n != 10 {
|
|
t.Fatalf("kv_hit counted %v times, want 10", n)
|
|
}
|
|
|
|
// b evicts everything past the first block; now a holds the longer
|
|
// prefix, and the index follows the engines rather than history.
|
|
rt.ApplyKV(kvevents.Update{Replica: "b", BlockSize: 16, Removed: hs[1:]})
|
|
rt.ApplyKV(kvevents.Update{Replica: "a", BlockSize: 16, Stored: hs[2:]})
|
|
for range 5 {
|
|
sendChat(t, rt, longPrompt)
|
|
}
|
|
if a.served.Load() != 5 {
|
|
t.Fatalf("served a=%d after b evicted; want 5", a.served.Load())
|
|
}
|
|
}
|
|
|
|
func TestTooShortAMatchLeavesTheChoiceToLoad(t *testing.T) {
|
|
rt, _, _ := kvRouter(t, false)
|
|
hs := kvevents.BlockHashes(fakeTokens(chatOf(longPrompt)...), 16, 0)
|
|
rt.ApplyKV(kvevents.Update{Replica: "a", BlockSize: 16, Stored: hs[:1]}) // below min_blocks
|
|
rt.ApplyKV(kvevents.Update{Replica: "b", BlockSize: 16})
|
|
sendChat(t, rt, longPrompt)
|
|
if kvDecisions(rt, kvindex.MissMinLoad) != 1 {
|
|
t.Fatal("one shared block is coincidence, and should be counted as a miss")
|
|
}
|
|
}
|
|
|
|
func TestWithNoEventsTheTreeDecidesAndNothingIsTokenized(t *testing.T) {
|
|
rt, a, b := kvRouter(t, true)
|
|
before := testutil.ToFloat64(rt.m.prefixDecisions.WithLabelValues("cache_miss_min_load"))
|
|
sendChat(t, rt, longPrompt)
|
|
if a.tokenized.Load()+b.tokenized.Load() != 0 {
|
|
t.Fatal("with nothing in the index a tokenize call is a round trip for nothing")
|
|
}
|
|
if kvDecisions(rt, kvindex.FallbackNoIndex) != 1 {
|
|
t.Fatal("the fallback should be counted by its reason")
|
|
}
|
|
if testutil.ToFloat64(rt.m.prefixDecisions.WithLabelValues("cache_miss_min_load")) != before+1 {
|
|
t.Fatal("tier 1 should have decided, and said so in its own metric")
|
|
}
|
|
}
|
|
|
|
func TestAFailedTokenizeFallsBackAndTheRequestStillSucceeds(t *testing.T) {
|
|
rt, a, b := kvRouter(t, true)
|
|
a.failTok.Store(true)
|
|
b.failTok.Store(true)
|
|
rt.ApplyKV(kvevents.Update{Replica: "a", BlockSize: 16, Stored: []uint64{1}})
|
|
rt.ApplyKV(kvevents.Update{Replica: "b", BlockSize: 16, Stored: []uint64{2}})
|
|
sendChat(t, rt, longPrompt)
|
|
if kvDecisions(rt, kvindex.FallbackTokenizeFailed) != 1 {
|
|
t.Fatal("a failed tokenize should fall back to tier 1, counted as such")
|
|
}
|
|
}
|
|
|
|
func TestTheTokenizeCallCarriesWhatShapesTheTokens(t *testing.T) {
|
|
rt, a, b := kvRouter(t, false)
|
|
rt.ApplyKV(kvevents.Update{Replica: "a", BlockSize: 16})
|
|
rt.ApplyKV(kvevents.Update{Replica: "b", BlockSize: 16})
|
|
rec := post(t, rt.Handler(), map[string]any{
|
|
"model": "m", "messages": []map[string]any{{"role": "user", "content": "hi"}},
|
|
"tools": []any{map[string]any{"type": "function", "function": map[string]any{"name": "f"}}},
|
|
"temperature": 0.2, "max_tokens": 5,
|
|
}, nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatal(rec.Body.String())
|
|
}
|
|
var last []byte
|
|
for _, f := range []*kvFakeVLLM{a, b} {
|
|
if v, ok := f.lastTok.Load().([]byte); ok {
|
|
last = v
|
|
}
|
|
}
|
|
var got map[string]any
|
|
if err := json.Unmarshal(last, &got); err != nil {
|
|
t.Fatalf("tokenize body %q: %v", last, err)
|
|
}
|
|
if got["tools"] == nil || got["messages"] == nil || got["model"] != "m" {
|
|
t.Fatalf("the chat template sees the tools, so must the tokenize call: %s", last)
|
|
}
|
|
if _, ok := got["temperature"]; ok {
|
|
t.Fatalf("sampling settings do not change the tokens and should not be sent: %s", last)
|
|
}
|
|
}
|
|
|
|
func TestAnImageMakesARequestUnindexable(t *testing.T) {
|
|
rt, a, b := kvRouter(t, false)
|
|
rt.ApplyKV(kvevents.Update{Replica: "a", BlockSize: 16})
|
|
rt.ApplyKV(kvevents.Update{Replica: "b", BlockSize: 16})
|
|
rec := post(t, rt.Handler(), map[string]any{
|
|
"model": "m", "messages": []map[string]any{{"role": "user", "content": []any{
|
|
map[string]any{"type": "text", "text": "what is this"},
|
|
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,AAAA"}},
|
|
}}},
|
|
}, nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatal(rec.Body.String())
|
|
}
|
|
if a.tokenized.Load()+b.tokenized.Load() != 0 || kvDecisions(rt, kvindex.FallbackUnsupported) != 1 {
|
|
t.Fatal("an image's blocks hash with its identity, which the index cannot know")
|
|
}
|
|
}
|
|
|
|
func TestAModelWithoutKVEventsIsNeverTokenized(t *testing.T) {
|
|
rt, a, _ := kvRouter(t, false)
|
|
rt.ApplyKV(kvevents.Update{Replica: "o-1", BlockSize: 16})
|
|
rec := post(t, rt.Handler(), map[string]any{
|
|
"model": "other", "messages": []map[string]any{{"role": "user", "content": longPrompt}},
|
|
}, nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatal(rec.Body.String())
|
|
}
|
|
if a.tokenized.Load() != 0 {
|
|
t.Fatal("the precise index is opt-in per model")
|
|
}
|
|
}
|
|
|
|
func TestAReplicaThatLeavesIsForgottenByTheIndex(t *testing.T) {
|
|
rt, _, _ := kvRouter(t, false)
|
|
rt.ApplyKV(kvevents.Update{Replica: "a", BlockSize: 16, Stored: []uint64{1, 2}})
|
|
rt.SetEndpoints(t.Context(), nil)
|
|
if len(rt.kv.Stats()) != 0 {
|
|
t.Fatal("a gone replica's cache went with it")
|
|
}
|
|
}
|
|
|
|
// BenchmarkTokenize is the round trip the precise index adds before placing
|
|
// a request, against a fake engine on loopback: the HTTP cost alone, without
|
|
// a real tokenizer behind it.
|
|
func BenchmarkTokenize(b *testing.B) {
|
|
f := &kvFakeVLLM{}
|
|
mux := http.NewServeMux()
|
|
toks := make([]int, 2048)
|
|
mux.HandleFunc("POST /tokenize", func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.Copy(io.Discard, r.Body)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"tokens": toks})
|
|
})
|
|
f.srv = httptest.NewServer(mux)
|
|
defer f.srv.Close()
|
|
body, _ := kvindex.TokenizeBody("/v1/chat/completions",
|
|
[]byte(`{"model":"m","messages":[{"role":"user","content":"`+strings.Repeat("x", 8000)+`"}]}`), "m")
|
|
client := newKVClient()
|
|
b.ResetTimer()
|
|
for b.Loop() {
|
|
got, err := kvindex.Tokenize(b.Context(), client, f.srv.URL, "", body)
|
|
if err != nil || len(got) != 2048 {
|
|
b.Fatal(err)
|
|
}
|
|
_ = kvevents.BlockHashes(got, 16, 0)
|
|
}
|
|
}
|