Files
llmgw/internal/router/cache.go
T
Ilker ManapandClaude Opus 5.5 3a3db8cd20
CI / test (push) Successful in 8m52s
Answers to similar questions, not only the same one
llmgw keys cache --similar 0.95 also gives a chat question the answer to
one like it: the last user message embedded by router.cache.embedding_model
(asked on the gateway's account, as a judge is, within 2 s), compared by
cosine with the questions answered in the same conversation — system
prompt, earlier turns, tools and sampling the same. The exact cache is
asked first; an exact hit embeds nothing. Said as x-llmgw-cache: similar
with x-llmgw-cache-similarity, charged as a hit.

The vectors live with their answers in the gateway's memory, counted in
max_bytes and evicted with them, not in a pgvector table as BACKLOG I1
planned: the cache keeps answer text out of the store on purpose. A
question over 2,000 characters is reused only when the same. Migration
0051 holds the setting. BACKLOG I1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:47:11 +01:00

603 lines
19 KiB
Go

package router
import (
"bytes"
"container/list"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
"gitea.mynodes.xyz/ilker/llmgw/internal/router/proxy"
storepkg "gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// Reusing answers, for a project that asks: the same request to the same
// model, from the same project and within its TTL, is answered with what
// the model said the first time rather than asking it again.
//
// Only an answer that would come out the same is reused, unless the project
// says otherwise: a chat or a completion asked at temperature 0 or with a
// seed, and an embedding, a rerank or a score, which have no sampling at
// all. A request sampled above temperature 0 is asked for a new answer, and
// handing it an old one would quietly turn variety into repetition.
//
// The answers are held in this process's memory and nowhere else. A shared
// cache in the store would write every answer of every project that turned
// this on to the database — the very thing the request log is opt-in for —
// and add a round trip to each request to save one; with two control planes
// behind a balancer each fills its own, which costs some hits and no
// correctness.
// cacheHeader says what the cache did for a request, hit, miss or bypass,
// and is how a client asks it to stay out of the way: "no".
const cacheHeader = "x-llmgw-cache"
// Defaults for router.cache.
const (
defaultCacheBytes = 128 << 20
defaultCacheEntryBytes = 1 << 20
)
// cacheEndpoints are those whose answers can be reused. The chat and
// completion endpoints only when the answer is reproducible; the others
// always, as they sample nothing. Audio is a form, not JSON, and is not
// among them.
var cacheEndpoints = map[string]bool{
"/v1/chat/completions": true,
"/v1/completions": true,
"/v1/embeddings": true,
"/v1/rerank": true,
"/v1/score": true,
}
// sampledEndpoints are those whose answer depends on sampling.
var sampledEndpoints = map[string]bool{
"/v1/chat/completions": true,
"/v1/completions": true,
}
// cacheState is where a request's answer is kept, for one whose answer
// may be reused and was not found.
type cacheState struct {
// key is the answer's place in the cache; empty when it is not kept.
key string
// project is whose answer it is, and ttl how long it is reused.
project string
ttl time.Duration
// group and vec place it among answers to similar questions, for a
// project that reuses those; empty otherwise.
group string
vec []float32
}
// cachedAnswer is one reusable answer.
type cachedAnswer struct {
key, project string
// model is the model that answered, by its catalog name.
model string
path string
// body is the answer as one JSON object — a chat.completion, a
// text_completion, a list of embeddings — even when it was streamed.
body []byte
at time.Time
// expires is when the TTL in force when it was kept runs out. The TTL
// in force when it is asked for is checked too, so shortening a
// project's TTL shortens the life of what it already has.
expires time.Time
size int64
// group is the conversation the question was asked in, everything of
// the request but its last user message; vec is that message embedded,
// of unit length. Empty for an answer reused only by the same request.
group string
vec []float32
}
// answerCache is a least-recently-used cache of answers, bounded in bytes.
type answerCache struct {
maxBytes, maxEntry int64
mu sync.Mutex
lru *list.List // of *cachedAnswer, most recently used at the front
by map[string]*list.Element
// groups are the answers to similar questions, by conversation.
groups map[string]map[*list.Element]struct{}
bytes int64
// swept is when expired answers were last cleared out.
swept time.Time
// size reports bytes and answers held, after each change.
size func(bytes int64, n int)
}
func newAnswerCache(maxBytes, maxEntry int64, size func(int64, int)) *answerCache {
if maxBytes <= 0 {
maxBytes = defaultCacheBytes
}
if maxEntry <= 0 {
maxEntry = defaultCacheEntryBytes
}
maxEntry = min(maxEntry, maxBytes)
if size == nil {
size = func(int64, int) {}
}
return &answerCache{maxBytes: maxBytes, maxEntry: maxEntry, lru: list.New(),
by: map[string]*list.Element{}, groups: map[string]map[*list.Element]struct{}{}, size: size}
}
// get is the answer kept under key, when it is younger than ttl and has
// not expired.
func (c *answerCache) get(key string, ttl time.Duration, now time.Time) *cachedAnswer {
c.mu.Lock()
defer c.mu.Unlock()
el, ok := c.by[key]
if !ok {
return nil
}
a := el.Value.(*cachedAnswer)
if now.After(a.expires) || now.Sub(a.at) >= ttl {
c.remove(el)
c.size(c.bytes, len(c.by))
return nil
}
c.lru.MoveToFront(el)
return a
}
// put keeps an answer, making room for it by dropping those used longest
// ago. One larger than an entry may be is not kept.
func (c *answerCache) put(a *cachedAnswer, now time.Time) bool {
// What the entry costs besides its body: the key twice, the names, and
// the bookkeeping, roughly.
a.size = int64(len(a.body) + 2*len(a.key) + len(a.project) + len(a.model) + len(a.path) + 160 +
len(a.group) + 4*len(a.vec))
if a.size > c.maxEntry {
return false
}
c.mu.Lock()
defer c.mu.Unlock()
if el, ok := c.by[a.key]; ok {
c.remove(el)
}
// Now and then, those past their time, so a quiet project's answers
// do not sit in memory until pushed out by others.
if now.Sub(c.swept) > time.Minute {
c.swept = now
for el := c.lru.Back(); el != nil; {
prev := el.Prev()
if now.After(el.Value.(*cachedAnswer).expires) {
c.remove(el)
}
el = prev
}
}
for c.bytes+a.size > c.maxBytes && c.lru.Len() > 0 {
c.remove(c.lru.Back())
}
el := c.lru.PushFront(a)
c.by[a.key] = el
if a.group != "" {
if c.groups[a.group] == nil {
c.groups[a.group] = map[*list.Element]struct{}{}
}
c.groups[a.group][el] = struct{}{}
}
c.bytes += a.size
c.size(c.bytes, len(c.by))
return true
}
func (c *answerCache) remove(el *list.Element) {
a := el.Value.(*cachedAnswer)
c.lru.Remove(el)
delete(c.by, a.key)
if g := c.groups[a.group]; g != nil {
delete(g, el)
if len(g) == 0 {
delete(c.groups, a.group)
}
}
c.bytes -= a.size
}
// nearest is the answer in group whose question is most like vec, when
// that reaches least and the answer is younger than ttl, and how alike.
func (c *answerCache) nearest(group string, vec []float32, least float64, ttl time.Duration, now time.Time) (*cachedAnswer, float64) {
c.mu.Lock()
defer c.mu.Unlock()
var best *list.Element
bestSim := least
for el := range c.groups[group] {
a := el.Value.(*cachedAnswer)
if now.After(a.expires) || now.Sub(a.at) >= ttl || len(a.vec) != len(vec) {
continue
}
if s := dot(a.vec, vec); s >= bestSim {
best, bestSim = el, s
}
}
if best == nil {
return nil, 0
}
c.lru.MoveToFront(best)
return best.Value.(*cachedAnswer), bestSim
}
// answerCacheOf is a project's setting, read at most every requestLogTTL
// with its other settings. Unreadable, it reuses nothing: asking the model
// again is the safe way to be wrong.
func (r *Router) answerCacheOf(ctx context.Context, project string) storepkg.AnswerCache {
if r.store == nil || project == "" {
return storepkg.AnswerCache{}
}
r.reqLogs.mu.Lock()
c, ok := r.reqLogs.by[project]
r.reqLogs.mu.Unlock()
if ok && time.Since(c.at) < requestLogTTL {
return c.cache
}
c, err := r.readProjectPolicy(ctx, project)
if err != nil {
r.log.Warn("could not read whether a project reuses answers", "project", project, "err", err)
}
return c.cache
}
// cacheLookup decides whether a request's answer may come from its
// project's cache, says what it decided in x-llmgw-cache, and returns the
// answer when there is one. Run after the guardrails have judged the
// request, so a cached answer reaches only a request that would have been
// sent to the model, and the request it is looked up by is the one the
// model would have been sent.
func (r *Router) cacheLookup(ctx context.Context, w http.ResponseWriter, req *parsedRequest) *cachedAnswer {
if r.answers == nil || req.internal || req.ProjectID == "" {
return nil
}
set := r.answerCacheOf(ctx, req.ProjectID)
if set.TTL <= 0 {
// Says nothing: there is no cache here to have bypassed.
return nil
}
result := "bypass"
defer func() {
w.Header().Set(cacheHeader, result)
r.m.cacheRequests.WithLabelValues(r.metricModel(req.Model), result).Inc()
}()
if !reusable(req, set.Any) {
return nil
}
model, _ := r.resolve(req.Model)
key, ok := answerKey(req.ProjectID, req.Path, model, req.UpstreamBody)
if !ok {
return nil
}
now := time.Now()
if a := r.answers.get(key, set.TTL, now); a != nil && a.project == req.ProjectID {
result = "hit"
// Age is HTTP's own way of saying how old a reused answer is.
w.Header().Set("Age", strconv.Itoa(int(now.Sub(a.at).Seconds())))
return a
}
result = "miss"
req.cache = cacheState{key: key, project: req.ProjectID, ttl: set.TTL}
if set.Similar > 0 {
if a, sim := r.similarLookup(ctx, req, model, set); a != nil {
result = "similar"
w.Header().Set("Age", strconv.Itoa(int(now.Sub(a.at).Seconds())))
w.Header().Set(similarityHeader, strconv.FormatFloat(sim, 'f', 3, 64))
req.cache = cacheState{}
return a
}
}
return nil
}
// reusable says whether a request's answer may be reused at all: not one
// the client asked to be answered afresh, not one continuing a stored
// conversation (each turn is new, and is saved), not one under a policy
// that keeps nothing — a cached answer is a kept one — and, for chat and
// completions, only one asked at temperature 0 or with a seed unless the
// project reuses any answer.
func reusable(req *parsedRequest, anyAnswer bool) bool {
if req.noStore || req.ThreadID != "" || req.ContentType != "" || !cacheEndpoints[req.Path] {
return false
}
// An answer from knowledge bases is the caller's own: two keys of one
// project can read different collections.
if req.knowledge || req.Header.Get(knowledgeHeader) != "" {
return false
}
if v := strings.ToLower(strings.TrimSpace(req.Header.Get(cacheHeader))); v == "no" || v == "off" || v == "bypass" {
return false
}
for _, v := range req.Header.Values("Cache-Control") {
v = strings.ToLower(v)
if strings.Contains(v, "no-cache") || strings.Contains(v, "no-store") {
return false
}
}
if !sampledEndpoints[req.Path] || anyAnswer {
return true
}
var b struct {
Temperature *float64 `json:"temperature"`
Seed json.RawMessage `json:"seed"`
}
if json.Unmarshal(req.UpstreamBody, &b) != nil {
return false
}
seeded := len(b.Seed) > 0 && string(b.Seed) != "null"
return seeded || b.Temperature != nil && *b.Temperature == 0
}
// answerFields are taken out of a request before it is looked up: they
// change how an answer is delivered or recorded, never what it says.
var answerFields = []string{"model", "stream", "stream_options", "user", "metadata", "store", "service_tier"}
// answerKey is where a request's answer is kept: the project, the endpoint,
// the model by its catalog name, and the request with what does not change
// the answer taken out and its keys sorted. The project is spelled out
// rather than only hashed, so no answer is ever looked up across projects.
func answerKey(project, path, model string, body []byte) (string, bool) {
m, ok := decodeRequest(body)
if !ok {
return "", false
}
return keyOf(project, path, model, m)
}
// decodeRequest is a request as a map, its numbers kept as written and
// what does not change the answer taken out.
func decodeRequest(body []byte) (map[string]any, bool) {
d := json.NewDecoder(bytes.NewReader(body))
d.UseNumber()
var m map[string]any
if err := d.Decode(&m); err != nil || m == nil {
return nil, false
}
for _, f := range answerFields {
delete(m, f)
}
return m, true
}
func keyOf(project, path, model string, m map[string]any) (string, bool) {
// encoding/json writes map keys sorted, at every depth.
norm, err := json.Marshal(canonicalNumbers(m))
if err != nil {
return "", false
}
h := sha256.New()
for _, part := range [][]byte{[]byte(path), []byte(model), norm} {
h.Write(part)
h.Write([]byte{0})
}
return project + "\x00" + hex.EncodeToString(h.Sum(nil)), true
}
// canonicalNumbers writes each number one way, so 0, 0.0 and 0e0 are the
// same temperature. Whole numbers are left as written: a seed above 2^53
// would lose its last digits as a float, and two seeds become one.
func canonicalNumbers(v any) any {
switch x := v.(type) {
case map[string]any:
for k, e := range x {
x[k] = canonicalNumbers(e)
}
case []any:
for i, e := range x {
x[i] = canonicalNumbers(e)
}
case json.Number:
s := string(x)
if _, err := strconv.ParseInt(s, 10, 64); err == nil {
return x
}
if f, err := strconv.ParseFloat(s, 64); err == nil {
if f == float64(int64(f)) && f > -1<<53 && f < 1<<53 {
return json.Number(strconv.FormatInt(int64(f), 10))
}
return json.Number(strconv.FormatFloat(f, 'g', -1, 64))
}
}
return v
}
// cacheWriter keeps a copy of what is written to the client, up to a
// bound, so an answer can be kept once it is known to be a good one.
type cacheWriter struct {
http.ResponseWriter
status int
buf bytes.Buffer
limit int
over bool
}
func (c *cacheWriter) WriteHeader(code int) {
if c.status == 0 {
c.status = code
}
c.ResponseWriter.WriteHeader(code)
}
func (c *cacheWriter) Write(p []byte) (int, error) {
if c.status == 0 {
c.status = http.StatusOK
}
if !c.over {
if c.buf.Len()+len(p) > c.limit {
c.over = true
c.buf = bytes.Buffer{}
} else {
c.buf.Write(p)
}
}
return c.ResponseWriter.Write(p)
}
// Flush passes through, which the proxy needs to stream.
func (c *cacheWriter) Flush() {
if f, ok := c.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (c *cacheWriter) Unwrap() http.ResponseWriter { return c.ResponseWriter }
// cacheWriterFor wraps w when a request's answer may be kept.
func (r *Router) cacheWriterFor(w http.ResponseWriter, req *parsedRequest) (http.ResponseWriter, *cacheWriter) {
if req.cache.key == "" {
return w, nil
}
cw := &cacheWriter{ResponseWriter: w, limit: int(r.answers.maxEntry)}
return cw, cw
}
// cacheKeep keeps an answer that may be reused: a whole 200 from the model
// asked for — not a fallback, which is another model, nor a canary, which
// is weights on trial — that no guard changed or stopped.
func (r *Router) cacheKeep(req *parsedRequest, res dispatchResult, status int, cw *cacheWriter) {
if cw == nil || cw.over || status != http.StatusOK || cw.status != http.StatusOK || res.err != nil ||
res.outcome.Status != http.StatusOK || res.outcome.Stopped || res.fallback || res.onCanary {
return
}
if res.judged && res.guard.Action != guard.Allow && !res.guard.Shadow {
return
}
var body []byte
captured := bytes.TrimSpace(cw.buf.Bytes())
if strings.Contains(cw.Header().Get("Content-Type"), "text/event-stream") ||
bytes.HasPrefix(captured, []byte("data:")) {
b, ok := assembleStream(req.Path, captured, res.outcome.Usage)
if !ok {
return
}
body = b
} else {
if len(captured) == 0 || captured[0] != '{' || !json.Valid(captured) {
return
}
body = bytes.Clone(captured)
}
model := res.model
if model == "" {
model, _ = r.resolve(req.Model)
}
now := time.Now()
r.answers.put(&cachedAnswer{key: req.cache.key, project: req.cache.project, model: model,
path: req.Path, body: body, at: now, expires: now.Add(req.cache.ttl),
group: req.cache.group, vec: req.cache.vec}, now)
}
// serveCached answers a request from the cache, as the model would have:
// as one JSON object, or as a stream of chunks to a client that asked for
// one. The guardrails' output policy judges it as it judges any answer —
// the policy in force now, not the one when it was first given — and the
// usage is recorded as a request that generated nothing: no tokens, no
// cost, marked as a cache hit.
func (r *Router) serveCached(ctx context.Context, w http.ResponseWriter, req *parsedRequest, a *cachedAnswer, start time.Time) {
body := a.body
res := dispatchResult{servedModel: servedModel{model: a.model}, cached: true}
if r.guard != nil {
if f := r.guard.Output(ctx, r.subject(req, a.model)); f != nil {
// A copy: the kept answer is shared by every request reading it,
// and a filter is free to rewrite what it is given.
body = f.Buffered(bytes.Clone(body))
res.guard, res.judged = f.Verdict(), true
if res.guard.Stricter(req.guardIn) {
setGuardHeaders(w.Header(), res.guard)
}
}
}
var werr error
if req.Stream && sampledEndpoints[req.Path] {
werr = writeCachedStream(w, req, body)
} else {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, werr = w.Write(body)
}
status := http.StatusOK
if werr != nil {
status = 499
}
res.outcome = proxy.Outcome{Status: http.StatusOK, Wrote: true, Usage: &proxy.Usage{},
Reply: replyText(body)}
took := time.Since(start)
r.m.observeRequest(r.metricModel(req.Model), "cache", status, took, nil, 0)
r.account(req, res, status, took)
r.accessLog(req, res, status, took)
r.observeOutput(req, res, res.judged)
if req.logRetain > 0 {
go r.keepRequest(context.WithoutCancel(ctx), req, res, status)
}
}
// writeCachedStream sends a kept answer as the chunks a model streams, with
// the usage chunk only to a client that asked for one.
func writeCachedStream(w http.ResponseWriter, req *parsedRequest, body []byte) error {
events, err := streamFromAnswer(req.Path, body, clientWantsUsage(req))
if err != nil {
// Not an answer that can be streamed — a guard's replacement, say.
// Sent whole, which a client reads as a model that does not stream.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, err = w.Write(body)
return err
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.WriteHeader(http.StatusOK)
for _, e := range append(events, "[DONE]") {
if _, err := io.WriteString(w, "data: "+e+"\n\n"); err != nil {
return err
}
}
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
return nil
}
// clientWantsUsage says whether the client asked for the usage chunk
// itself, rather than the gateway asking for it on its behalf.
func clientWantsUsage(req *parsedRequest) bool {
if req.SuppressUsageChunk {
return false
}
var b struct {
StreamOptions *struct {
IncludeUsage bool `json:"include_usage"`
} `json:"stream_options"`
}
return json.Unmarshal(req.UpstreamBody, &b) == nil && b.StreamOptions != nil && b.StreamOptions.IncludeUsage
}
// replyText is an answer's first choice as text, for the request log.
func replyText(body []byte) string {
var c struct {
Choices []struct {
Message struct {
Content *string `json:"content"`
} `json:"message"`
Text *string `json:"text"`
} `json:"choices"`
}
if json.Unmarshal(body, &c) != nil || len(c.Choices) == 0 {
return ""
}
if s := c.Choices[0].Message.Content; s != nil {
return *s
}
if s := c.Choices[0].Text; s != nil {
return *s
}
return ""
}