Files
llmgw/internal/store/answercache.go
T
Ilker ManapandClaude Opus 5.5 44f3a9e31d Reusing answers: a response cache per project, exact match, opt-in with a TTL
llmgw keys cache --project P --ttl 1h [--any] | --off, and Cache answers… on
the console's project menu. The same request — project, endpoint, canonical
model and the body without stream, stream_options, user, metadata, store and
service_tier, keys sorted — is answered from the gateway's memory for the
TTL. Only reproducible answers (temperature 0 or a seed; embeddings, rerank,
score) unless --any; only a whole 200 from the model asked for, not a
fallback's, a canary's, or one a guardrail changed.

Looked up after the limits and the input guardrails; the output policy
judges a reused answer; no_store and stored conversations bypass it.
Streamed answers are kept put back together and streamed back as chunks.
x-llmgw-cache: hit|miss|bypass, Age on a hit, Cache-Control: no-cache or
x-llmgw-cache: no to bypass. A hit is recorded with no tokens and no cost,
marked cache_hit (migration 0027) and shown by ctl request. An LRU bounded
by router.cache.max_bytes and max_entry_bytes; llmgw_cache_requests_total,
llmgw_cache_bytes, llmgw_cache_entries.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:50:53 +02:00

60 lines
1.8 KiB
Go

package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// AnswerCache is how a project reuses answers: for how long one is served
// again to the same request, and whether that is any request's answer or
// only one that would come out the same every time. Zero TTL reuses none.
//
// Only the setting lives in the store. The answers are held in each
// gateway's memory, so turning this on writes no text anywhere.
type AnswerCache struct {
TTL time.Duration
// Any reuses answers to requests sampled at a temperature above 0
// with no seed, which would otherwise each be asked again.
Any bool
}
// SetAnswerCache turns reusing answers on for a project, or off with ttl 0.
// The TTL is kept in whole seconds, at least one.
func (s *DB) SetAnswerCache(ctx context.Context, projectID string, ttl time.Duration, anyAnswer bool) error {
var secs any
a := 0
if ttl > 0 {
secs = max(int64(ttl/time.Second), 1)
if anyAnswer {
a = 1
}
}
res, err := s.db.ExecContext(ctx,
`UPDATE projects SET cache_ttl_seconds = ?, cache_any = ? WHERE id = ?`, secs, a, projectID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("store: no project %q", projectID)
}
return nil
}
// AnswerCacheOf is a project's setting; zero when it reuses none.
func (s *DB) AnswerCacheOf(ctx context.Context, projectID string) (AnswerCache, error) {
var secs sql.NullInt64
var a int
err := s.db.QueryRowContext(ctx,
`SELECT cache_ttl_seconds, cache_any FROM projects WHERE id = ?`, projectID).Scan(&secs, &a)
if errors.Is(err, sql.ErrNoRows) || err == nil && (!secs.Valid || secs.Int64 <= 0) {
return AnswerCache{}, nil
}
if err != nil {
return AnswerCache{}, err
}
return AnswerCache{TTL: time.Duration(secs.Int64) * time.Second, Any: a == 1}, nil
}