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>
106 lines
3.7 KiB
Go
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)
|
|
}
|
|
}
|