Files
Ilker ManapandClaude Opus 5 9eb321ae3e
CI / test (push) Has been cancelled
Let a conversation continue on a different model than it started on
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>
2026-09-18 11:50:40 +02:00

100 lines
2.6 KiB
Go

package proxy
import (
"encoding/json"
"strings"
)
// replyFromJSON pulls the assistant's text out of a completed response.
//
// Only the first choice. 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.
func replyFromJSON(body []byte) string {
var b struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
// The completions endpoint, which has no message wrapper.
Text string `json:"text"`
} `json:"choices"`
}
if err := json.Unmarshal(body, &b); err != nil || len(b.Choices) == 0 {
return ""
}
if c := b.Choices[0].Message.Content; c != "" {
return c
}
return b.Choices[0].Text
}
// replyDelta pulls the incremental text out of one streamed event.
func replyDelta(event string) string {
if event == "" || event == "[DONE]" {
return ""
}
var b struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
Text string `json:"text"`
} `json:"choices"`
}
if err := json.Unmarshal([]byte(event), &b); err != nil || len(b.Choices) == 0 {
return ""
}
if c := b.Choices[0].Delta.Content; c != "" {
return c
}
return b.Choices[0].Text
}
// capReply truncates at the cap and says whether it had to.
//
// Truncated rather than dropped, because a shortened turn replays as a
// shortened turn while nothing at all replays as the assistant having said
// nothing — a different conversation, and a confusing one to be handed back.
func capReply(s string, cap int) (string, bool) {
if cap <= 0 || len(s) <= cap {
return s, false
}
// On a rune boundary, so a stored reply is never invalid UTF-8 — it goes
// back into a JSON body, and a split multi-byte character would make that
// body unserialisable.
cut := cap
for cut > 0 && !isRuneStart(s[cut]) {
cut--
}
return s[:cut], true
}
func isRuneStart(b byte) bool { return b&0xC0 != 0x80 }
// replyBuilder accumulates a streamed reply up to a cap.
//
// It stops appending once full rather than growing and truncating at the end,
// so a long answer cannot cost more memory than the cap allows however much
// the engine sends.
type replyBuilder struct {
cap int
sb strings.Builder
truncated bool
}
func (r *replyBuilder) add(s string) {
if r.cap <= 0 || s == "" || r.truncated {
return
}
if room := r.cap - r.sb.Len(); len(s) > room {
part, _ := capReply(s, room)
r.sb.WriteString(part)
r.truncated = true
return
}
r.sb.WriteString(s)
}
func (r *replyBuilder) result() (string, bool) { return r.sb.String(), r.truncated }