Files
2026-09-11 06:14:38 +02:00

323 lines
7.7 KiB
Go

package conversation
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"sort"
"sync"
"time"
"github.com/example/ollama-fair-gateway/internal/config"
"github.com/example/ollama-fair-gateway/internal/state"
)
var ErrContextTooLarge = errors.New("conversation context exceeds configured max_content_bytes")
// Entry is the minimum content-bearing state required to implement
// previous_response_id semantics for the OpenAI Responses API. Context is a
// JSON array containing the flattened input/output items through this response.
type Entry struct {
ID string `json:"id"`
Tenant string `json:"tenant"`
Actor string `json:"actor"`
Model string `json:"model,omitempty"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
Context json.RawMessage `json:"context"`
}
type Status struct {
Enabled bool `json:"enabled"`
Entries int `json:"entries"`
Oldest time.Time `json:"oldest_at,omitempty"`
Newest time.Time `json:"newest_at,omitempty"`
ExpiresAt time.Time `json:"next_expiry_at,omitempty"`
}
type Store struct {
mu sync.Mutex
cfg config.ConversationsConfig
file state.AtomicJSON
key [32]byte
now func() time.Time
data map[string]Entry
}
type diskEnvelope struct {
Version int `json:"version"`
Algorithm string `json:"algorithm"`
Nonce string `json:"nonce"`
Ciphertext string `json:"ciphertext"`
UpdatedAt time.Time `json:"updated_at"`
}
type diskPlaintext struct {
Version int `json:"version"`
Entries map[string]Entry `json:"entries"`
}
func New(cfg config.ConversationsConfig, path string) (*Store, error) {
s := &Store{cfg: cfg, file: state.AtomicJSON{Path: path, Mode: 0600}, now: time.Now, data: map[string]Entry{}}
s.key = sha256.Sum256([]byte(cfg.EncryptionKey))
if !cfg.Enabled {
return s, nil
}
if err := s.load(); err != nil {
return nil, err
}
return s, nil
}
func (s *Store) Enabled() bool { return s != nil && s.cfg.Enabled }
// StartCleanup enforces retention even when no requests touch the store.
func (s *Store) StartCleanup(ctx context.Context, onError func(error)) {
if !s.Enabled() {
return
}
interval := s.cfg.Retention.Value() / 4
if interval < time.Minute {
interval = time.Minute
}
if interval > 15*time.Minute {
interval = 15 * time.Minute
}
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := s.pruneAndSave(); err != nil && onError != nil {
onError(err)
}
}
}
}()
}
func (s *Store) pruneAndSave() error {
s.mu.Lock()
defer s.mu.Unlock()
if !s.pruneLocked(s.now().UTC()) {
return nil
}
return s.saveLocked()
}
func (s *Store) Get(id, tenant, actor string) (Entry, bool, error) {
if !s.Enabled() || id == "" {
return Entry{}, false, nil
}
s.mu.Lock()
defer s.mu.Unlock()
changed := s.pruneLocked(s.now().UTC())
e, ok := s.data[id]
if !ok || e.Tenant != tenant || e.Actor != actor {
if changed {
_ = s.saveLocked()
}
return Entry{}, false, nil
}
if changed {
if err := s.saveLocked(); err != nil {
return Entry{}, false, err
}
}
e.Context = append(json.RawMessage(nil), e.Context...)
return e, true, nil
}
func (s *Store) Put(e Entry) error {
if !s.Enabled() {
return nil
}
if e.ID == "" || e.Tenant == "" || e.Actor == "" {
return errors.New("conversation entry requires id, tenant, and actor")
}
if len(e.Context) == 0 || !json.Valid(e.Context) {
return errors.New("conversation entry context must contain valid JSON")
}
if s.cfg.MaxContentBytes > 0 && int64(len(e.Context)) > s.cfg.MaxContentBytes {
return ErrContextTooLarge
}
now := s.now().UTC()
if e.CreatedAt.IsZero() {
e.CreatedAt = now
} else {
e.CreatedAt = e.CreatedAt.UTC()
}
if e.ExpiresAt.IsZero() {
e.ExpiresAt = e.CreatedAt.Add(s.cfg.Retention.Value())
} else {
e.ExpiresAt = e.ExpiresAt.UTC()
}
e.Context = append(json.RawMessage(nil), e.Context...)
s.mu.Lock()
defer s.mu.Unlock()
s.pruneLocked(now)
s.data[e.ID] = e
s.enforceLimitLocked()
return s.saveLocked()
}
func (s *Store) Delete(id, tenant, actor string) error {
if !s.Enabled() || id == "" {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.data[id]
if !ok || e.Tenant != tenant || e.Actor != actor {
return nil
}
delete(s.data, id)
return s.saveLocked()
}
func (s *Store) Compact() error {
if !s.Enabled() {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
s.pruneLocked(s.now().UTC())
s.enforceLimitLocked()
return s.saveLocked()
}
func (s *Store) Status() Status {
st := Status{Enabled: s.Enabled()}
if !s.Enabled() {
return st
}
s.mu.Lock()
defer s.mu.Unlock()
now := s.now().UTC()
_ = s.pruneLocked(now)
st.Entries = len(s.data)
for _, e := range s.data {
if st.Oldest.IsZero() || e.CreatedAt.Before(st.Oldest) {
st.Oldest = e.CreatedAt
}
if st.Newest.IsZero() || e.CreatedAt.After(st.Newest) {
st.Newest = e.CreatedAt
}
if st.ExpiresAt.IsZero() || e.ExpiresAt.Before(st.ExpiresAt) {
st.ExpiresAt = e.ExpiresAt
}
}
return st
}
func (s *Store) load() error {
var env diskEnvelope
if err := s.file.Load(&env); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
if env.Version != 1 || env.Algorithm != "AES-256-GCM" {
return fmt.Errorf("unsupported conversation store envelope version/algorithm")
}
nonce, err := base64.StdEncoding.DecodeString(env.Nonce)
if err != nil {
return fmt.Errorf("decode conversation nonce: %w", err)
}
ct, err := base64.StdEncoding.DecodeString(env.Ciphertext)
if err != nil {
return fmt.Errorf("decode conversation ciphertext: %w", err)
}
block, err := aes.NewCipher(s.key[:])
if err != nil {
return err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return err
}
plain, err := gcm.Open(nil, nonce, ct, []byte("ollama-fair-gateway/conversations/v1"))
if err != nil {
return errors.New("decrypt conversation store: encryption key mismatch or file corruption")
}
var snap diskPlaintext
if err := json.Unmarshal(plain, &snap); err != nil {
return fmt.Errorf("decode conversation store: %w", err)
}
if snap.Version != 1 {
return fmt.Errorf("unsupported conversation plaintext version %d", snap.Version)
}
if snap.Entries != nil {
s.data = snap.Entries
}
s.pruneLocked(s.now().UTC())
s.enforceLimitLocked()
return nil
}
func (s *Store) saveLocked() error {
plain, err := json.Marshal(diskPlaintext{Version: 1, Entries: s.data})
if err != nil {
return err
}
block, err := aes.NewCipher(s.key[:])
if err != nil {
return err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return err
}
ct := gcm.Seal(nil, nonce, plain, []byte("ollama-fair-gateway/conversations/v1"))
env := diskEnvelope{Version: 1, Algorithm: "AES-256-GCM", Nonce: base64.StdEncoding.EncodeToString(nonce), Ciphertext: base64.StdEncoding.EncodeToString(ct), UpdatedAt: s.now().UTC()}
return s.file.Save(env)
}
func (s *Store) pruneLocked(now time.Time) bool {
changed := false
for id, e := range s.data {
if !e.ExpiresAt.IsZero() && !e.ExpiresAt.After(now) {
delete(s.data, id)
changed = true
}
}
return changed
}
func (s *Store) enforceLimitLocked() {
max := s.cfg.MaxEntries
if max <= 0 || len(s.data) <= max {
return
}
type pair struct {
id string
t time.Time
}
xs := make([]pair, 0, len(s.data))
for id, e := range s.data {
xs = append(xs, pair{id: id, t: e.CreatedAt})
}
sort.Slice(xs, func(i, j int) bool { return xs[i].t.Before(xs[j].t) })
for len(s.data) > max && len(xs) > 0 {
delete(s.data, xs[0].id)
xs = xs[1:]
}
}