Files
llmgw/internal/router/context_limit_test.go
T
Ilker ManapandClaude Opus 5 e9fe3ad379
CI / test (push) Successful in 1m32s
Know how big a model's context is, and what it can do
Three things the gateway did not know about its own models, all of which
surfaced this week as somebody else's bug.

Context length. Nothing read it, /v1/models did not publish it, and an
over-long prompt was not refused: it was admitted, scheduled, given a
replica and a slot, and rejected by the engine in whatever shape that
engine chose. A client sizing prompts from /v1/models had nothing to read.
It is now declared per model, published when known, and checked before
authentication work or a queue slot is spent — with a ten percent margin,
because the estimate is bytes over four and being a few percent out must
not turn a valid request into a refusal.

Capabilities. The gateway forwards a tools array to any model, and one that
cannot use them answers in prose; every OpenAI-compatible client then
behaves as though the model is broken, and the only way to find out was to
try it. Declared rather than discovered, because no engine will say — which
is why these are claims, and why the next commit makes engine-check verify
the tool-calling one instead of trusting it.

And a bug found while wiring the first: the token estimate was taken from
the routing prefix. That prefix stops at 2 KB by design, so every prompt
above it estimated as 512 tokens however long it really was, and TPM limits
undercounted exactly the requests that consume the most. Prefix routing
wants a bounded head of the conversation; a token estimate wants all of it.
They are now separate functions, and structured multimodal content counts
rather than measuring zero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 23:07:49 +02:00

106 lines
3.7 KiB
Go

package router
import (
"encoding/json"
"strings"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
)
func TestAModelWithNoDeclaredContextRefusesNothing(t *testing.T) {
// Every model was in this state until now, and guessing a limit would turn
// working requests into errors.
m := catalog.Model{Name: "m"}
if why := checkContextFits(m, 1_000_000, 1_000_000); why != "" {
t.Errorf("refused a request against an unknown context: %q", why)
}
}
func TestAPromptOverTheContextIsRefused(t *testing.T) {
m := catalog.Model{Name: "coder", ContextLength: 2048}
why := checkContextFits(m, 5000, 0)
if why == "" {
t.Fatal("a prompt well over the context was allowed")
}
for _, want := range []string{"5000", "2048", "coder"} {
if !strings.Contains(why, want) {
t.Errorf("the message does not mention %s: %q", want, why)
}
}
}
func TestPromptPlusMaxTokensMustFitTogether(t *testing.T) {
// The failure people actually hit: a prompt that fits, asking for a
// completion that cannot follow it.
m := catalog.Model{Name: "coder", ContextLength: 2048}
if why := checkContextFits(m, 1800, 1000); why == "" {
t.Error("prompt plus max_tokens over the window was allowed")
}
if why := checkContextFits(m, 1000, 500); why != "" {
t.Errorf("a request that fits was refused: %q", why)
}
}
func TestTheEstimateIsGivenRoomToBeWrong(t *testing.T) {
// Bytes over four is wrong in both directions — code and non-Latin scripts
// tokenize nothing like English prose — and being a few percent out must
// not turn a valid request into a refusal.
m := catalog.Model{Name: "m", ContextLength: 1000}
if why := checkContextFits(m, 1050, 0); why != "" {
t.Errorf("a request just over the estimate was refused: %q", why)
}
if why := checkContextFits(m, 2000, 0); why == "" {
t.Error("a prompt at twice the context was allowed; the margin is too wide")
}
}
func TestMaxOutputIsCheckedOnItsOwn(t *testing.T) {
// Asking for more output than the model can produce is worth catching even
// when the total would fit.
m := catalog.Model{Name: "m", ContextLength: 32768, MaxOutput: 4096}
why := checkContextFits(m, 10, 8000)
if why == "" {
t.Fatal("max_tokens over max_output was allowed")
}
if !strings.Contains(why, "4096") {
t.Errorf("the message does not say the limit: %q", why)
}
}
func TestTheTokenEstimateCoversTheWholePrompt(t *testing.T) {
// It used to be taken from the routing prefix, which stops at prefixBytes,
// so every prompt over 2 KB estimated as 512 tokens however long it was.
// TPM limits therefore undercounted exactly the requests that consume most,
// and no context check could work.
long := strings.Repeat("x", 40_000)
b := &inferenceBody{Messages: []struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
}{{Role: "user", Content: json.RawMessage(`"` + long + `"`)}}}
got := promptBytes(b)
if got < 40_000 {
t.Fatalf("promptBytes = %d for a 40,000 byte prompt", got)
}
if prefix := len(promptPrefix(b)); prefix > prefixBytes {
t.Errorf("the routing prefix grew past its bound: %d", prefix)
}
// The two must stay different things: bounded for routing, whole for limits.
if got <= prefixBytes {
t.Error("the estimate is still bounded by the routing prefix")
}
}
func TestStructuredContentIsCounted(t *testing.T) {
// Multimodal messages carry an array of parts rather than a string. Counting
// zero for those would let a large request through unmeasured.
b := &inferenceBody{Messages: []struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
}{{Role: "user", Content: json.RawMessage(`[{"type":"text","text":"` + strings.Repeat("y", 5000) + `"}]`)}}}
if got := promptBytes(b); got < 5000 {
t.Errorf("structured content counted as %d bytes", got)
}
}