The request path half. A caller sets x-llmgw-thread and sends only the new turn; llmgw puts the stored history in front of it, sends that to whichever model is serving, and stores the exchange afterwards. A header rather than a body field because some SDKs drop fields they do not know and all of them let a caller set a header. Capturing the reply is opt-in and bounded. This is the hottest path in the system, so a request without a thread accumulates nothing at all, and one with a thread stops accumulating at a cap rather than growing to it — a gateway that kept every reply in memory would be one long answer away from trouble. A reply is truncated on a rune boundary rather than dropped: a shortened turn replays as a shortened turn, while nothing at all replays as the assistant having said nothing, and invalid UTF-8 would break the next turn's body for good rather than for one turn. The streamed path sets the captured reply on every exit, not only the one that looks like the end. A stream can also finish with the client gone or the engine stalled, and what the assistant said up to that point is still what it said. A thread on a project that keeps no conversations is refused. The e2e test asserted that and got a cheerful 200: the request was served, nothing was stored, and the caller would have discovered it only when the model had no idea what they were referring to — which reads as the model being broken. It now asks whether storage is on before doing anything else. History is spliced after the model is finally settled, because a budget fallback may have changed it and trimming is measured against the context of the model that will actually serve. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
96 lines
3.1 KiB
Go
96 lines
3.1 KiB
Go
package proxy
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestNothingIsCapturedUnlessAThreadAsked(t *testing.T) {
|
|
// This is the hottest path in the system. A gateway that accumulated every
|
|
// reply in memory would be one long answer away from trouble, so a request
|
|
// that stores nothing must pay nothing.
|
|
var r replyBuilder // cap zero, which is every ordinary request
|
|
r.add("a long assistant reply that nobody asked to keep")
|
|
if got, truncated := r.result(); got != "" || truncated {
|
|
t.Errorf("a request with no thread accumulated %q", got)
|
|
}
|
|
}
|
|
|
|
func TestALongReplyCostsNoMoreThanTheCap(t *testing.T) {
|
|
// Stopping at the cap rather than growing and truncating afterwards is
|
|
// what makes the cap a memory bound instead of a formatting rule.
|
|
r := replyBuilder{cap: 10}
|
|
for range 1000 {
|
|
r.add(strings.Repeat("x", 100))
|
|
}
|
|
got, truncated := r.result()
|
|
if len(got) > 10 {
|
|
t.Errorf("accumulated %d bytes against a cap of 10", len(got))
|
|
}
|
|
if !truncated {
|
|
t.Error("truncation was not reported")
|
|
}
|
|
}
|
|
|
|
func TestATruncatedReplyIsStillValidUTF8(t *testing.T) {
|
|
// It goes back into a JSON body on the next turn. A split multi-byte
|
|
// character would make that body unserialisable, and the thread would
|
|
// break for good rather than for one turn.
|
|
r := replyBuilder{cap: 4}
|
|
r.add("üçüncü") // two-byte runes either side of the cut
|
|
got, _ := r.result()
|
|
for i, ch := range got {
|
|
if ch == '�' {
|
|
t.Fatalf("invalid UTF-8 at byte %d of %q", i, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTheAssistantTextIsFoundInBothResponseShapes(t *testing.T) {
|
|
chat := `{"choices":[{"message":{"role":"assistant","content":"hello"}}]}`
|
|
if got := replyFromJSON([]byte(chat)); got != "hello" {
|
|
t.Errorf("chat completion reply = %q", got)
|
|
}
|
|
// The completions endpoint, which has no message wrapper.
|
|
legacy := `{"choices":[{"text":"hello"}]}`
|
|
if got := replyFromJSON([]byte(legacy)); got != "hello" {
|
|
t.Errorf("completion reply = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestOnlyTheFirstChoiceIsKept(t *testing.T) {
|
|
// A request asking for several is not one continuing a conversation, and
|
|
// storing one of them as "what the assistant said" would invent a
|
|
// conversation that never happened.
|
|
body := `{"choices":[{"message":{"content":"one"}},{"message":{"content":"two"}}]}`
|
|
if got := replyFromJSON([]byte(body)); got != "one" {
|
|
t.Errorf("reply = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestStreamedDeltasAssembleIntoTheReply(t *testing.T) {
|
|
r := replyBuilder{cap: 1024}
|
|
for _, ev := range []string{
|
|
`{"choices":[{"delta":{"role":"assistant"}}]}`,
|
|
`{"choices":[{"delta":{"content":"Hel"}}]}`,
|
|
`{"choices":[{"delta":{"content":"lo"}}]}`,
|
|
`[DONE]`,
|
|
} {
|
|
r.add(replyDelta(ev))
|
|
}
|
|
if got, _ := r.result(); got != "Hello" {
|
|
t.Errorf("assembled %q", got)
|
|
}
|
|
}
|
|
|
|
func TestAMalformedEventContributesNothing(t *testing.T) {
|
|
// An engine sending something unexpected must not corrupt a stored
|
|
// conversation; the worst case is a turn that is shorter than it was.
|
|
if got := replyDelta(`not json at all`); got != "" {
|
|
t.Errorf("garbage produced %q", got)
|
|
}
|
|
if got := replyDelta(``); got != "" {
|
|
t.Errorf("an empty event produced %q", got)
|
|
}
|
|
}
|