Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
656
backend/internal/cache/antigravity_reasoning_replay_cache.go
vendored
Normal file
656
backend/internal/cache/antigravity_reasoning_replay_cache.go
vendored
Normal file
|
|
@ -0,0 +1,656 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
homekv "github.com/router-for-me/CLIProxyAPI/v7/internal/home"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
const (
|
||||
// AntigravityReasoningReplayCacheTTL limits how long encrypted reasoning replay
|
||||
// items stay in process memory.
|
||||
AntigravityReasoningReplayCacheTTL = 1 * time.Hour
|
||||
|
||||
// AntigravityReasoningReplayCacheMaxEntries bounds process memory for replay
|
||||
// continuity. Oldest entries are evicted first.
|
||||
AntigravityReasoningReplayCacheMaxEntries = 10240
|
||||
|
||||
// AntigravityReasoningReplayCacheEvictBatchSize leaves headroom after the cache
|
||||
// reaches capacity so high write volume does not rescan the map every turn.
|
||||
AntigravityReasoningReplayCacheEvictBatchSize = 128
|
||||
|
||||
minAntigravityThoughtSignatureReplayLen = 16
|
||||
|
||||
// AntigravityReasoningReplayCacheMaxItemsPerEntry and MaxBytesPerEntry
|
||||
// bound one logical conversation. Oversized chains are not partially cached,
|
||||
// because dropping an arbitrary prefix would break native signature ordering.
|
||||
AntigravityReasoningReplayCacheMaxItemsPerEntry = 4096
|
||||
AntigravityReasoningReplayCacheMaxBytesPerEntry = 16 << 20
|
||||
|
||||
// JSON encodes each normalized []byte item as base64. Leave enough room for
|
||||
// that expansion while rejecting oversized Home values before unmarshalling.
|
||||
antigravityReasoningReplayCacheMaxSerializedBytes = 24 << 20
|
||||
)
|
||||
|
||||
type antigravityReasoningReplayEntry struct {
|
||||
Items [][]byte
|
||||
Timestamp time.Time
|
||||
Revision uint64
|
||||
Branch string
|
||||
Deleted bool
|
||||
}
|
||||
|
||||
const antigravityReasoningReplayGenerationItemType = "cpa_antigravity_replay_generation"
|
||||
|
||||
// AntigravityReasoningReplaySnapshot identifies the exact replay state read for
|
||||
// one request. Its fields are intentionally opaque outside this package.
|
||||
type AntigravityReasoningReplaySnapshot struct {
|
||||
raw []byte
|
||||
items [][]byte
|
||||
loaded bool
|
||||
found bool
|
||||
revision uint64
|
||||
branch string
|
||||
evictionEpoch uint64
|
||||
}
|
||||
|
||||
var (
|
||||
antigravityReasoningReplayMu sync.Mutex
|
||||
antigravityReasoningReplayEntries = make(map[string]antigravityReasoningReplayEntry)
|
||||
antigravityReasoningReplayNextRevision uint64
|
||||
antigravityReasoningReplayEvictionEpoch uint64
|
||||
)
|
||||
|
||||
type antigravityReasoningReplayKVClient interface {
|
||||
KVGet(ctx context.Context, key string) ([]byte, bool, error)
|
||||
KVSet(ctx context.Context, key string, value []byte, opts homekv.KVSetOptions) (bool, error)
|
||||
KVCompareAndSwap(ctx context.Context, key string, expected []byte, expectedExists bool, value []byte, ttl time.Duration) (bool, error)
|
||||
KVExpire(ctx context.Context, key string, ttl time.Duration) (bool, error)
|
||||
}
|
||||
|
||||
var currentAntigravityReasoningReplayKVClient = func() (antigravityReasoningReplayKVClient, bool, error) {
|
||||
return homekv.CurrentKVClient()
|
||||
}
|
||||
|
||||
// CacheAntigravityReasoningReplayItem stores a final GPT/Codex reasoning item for
|
||||
// stateless replay. The stored item is normalized to the minimal shape accepted
|
||||
// by Responses input replay.
|
||||
func CacheAntigravityReasoningReplayItem(modelName, sessionKey string, item []byte) bool {
|
||||
return CacheAntigravityReasoningReplayItems(modelName, sessionKey, [][]byte{item})
|
||||
}
|
||||
|
||||
// CacheAntigravityReasoningReplayItems stores the final GPT/Codex assistant output
|
||||
// items needed to replay a stateless next turn.
|
||||
func CacheAntigravityReasoningReplayItems(modelName, sessionKey string, items [][]byte) bool {
|
||||
return CacheAntigravityReasoningReplayItemsBestEffort(context.Background(), modelName, sessionKey, items)
|
||||
}
|
||||
|
||||
// CacheAntigravityReasoningReplayItemsBestEffort stores replay items for completed response paths.
|
||||
func CacheAntigravityReasoningReplayItemsBestEffort(ctx context.Context, modelName, sessionKey string, items [][]byte) bool {
|
||||
key := antigravityReasoningReplayCacheKey(modelName, sessionKey)
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
normalized, ok := normalizeAntigravityReasoningReplayItems(items)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if client, homeMode, errClient := currentAntigravityReasoningReplayKVClient(); homeMode {
|
||||
if errClient != nil {
|
||||
log.Errorf("home kv best-effort antigravity reasoning replay set failed prefix=cpa:antigravity:*: %v", errClient)
|
||||
return false
|
||||
}
|
||||
raw, errMarshal := marshalAntigravityReasoningReplayHomeValue(normalized, "")
|
||||
if errMarshal != nil {
|
||||
log.Errorf("home kv best-effort antigravity reasoning replay set failed prefix=cpa:antigravity:*: %v", errMarshal)
|
||||
return false
|
||||
}
|
||||
written, errSet := client.KVSet(ctx, antigravityReasoningReplayKVKey(modelName, sessionKey), raw, homekv.KVSetOptions{EX: AntigravityReasoningReplayCacheTTL})
|
||||
if errSet != nil {
|
||||
log.Errorf("home kv best-effort antigravity reasoning replay set failed prefix=cpa:antigravity:*: %v", errSet)
|
||||
return false
|
||||
}
|
||||
return written
|
||||
}
|
||||
|
||||
cacheCleanupOnce.Do(startCacheCleanup)
|
||||
now := time.Now()
|
||||
antigravityReasoningReplayMu.Lock()
|
||||
defer antigravityReasoningReplayMu.Unlock()
|
||||
antigravityReasoningReplayNextRevision++
|
||||
antigravityReasoningReplayEntries[key] = antigravityReasoningReplayEntry{
|
||||
Items: normalized,
|
||||
Timestamp: now,
|
||||
Revision: antigravityReasoningReplayNextRevision,
|
||||
Branch: newAntigravityReasoningReplayGeneration(),
|
||||
}
|
||||
if len(antigravityReasoningReplayEntries) > AntigravityReasoningReplayCacheMaxEntries {
|
||||
evictOldestAntigravityReasoningReplayEntries(AntigravityReasoningReplayCacheEvictBatchSize)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// GetAntigravityReasoningReplayItem retrieves a normalized reasoning replay item.
|
||||
func GetAntigravityReasoningReplayItem(modelName, sessionKey string) ([]byte, bool) {
|
||||
items, ok := GetAntigravityReasoningReplayItems(modelName, sessionKey)
|
||||
if !ok || len(items) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return items[0], true
|
||||
}
|
||||
|
||||
// GetAntigravityReasoningReplayItems retrieves normalized assistant output items.
|
||||
func GetAntigravityReasoningReplayItems(modelName, sessionKey string) ([][]byte, bool) {
|
||||
items, ok, err := GetAntigravityReasoningReplayItemsRequired(context.Background(), modelName, sessionKey)
|
||||
if err == nil {
|
||||
return items, ok
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// GetAntigravityReasoningReplayItemsRequired retrieves replay items for request-time paths.
|
||||
func GetAntigravityReasoningReplayItemsRequired(ctx context.Context, modelName, sessionKey string) ([][]byte, bool, error) {
|
||||
items, _, found, errGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(ctx, modelName, sessionKey)
|
||||
return items, found, errGet
|
||||
}
|
||||
|
||||
// GetAntigravityReasoningReplayItemsWithSnapshotRequired retrieves replay items
|
||||
// and the exact cache state that guarded this request.
|
||||
func GetAntigravityReasoningReplayItemsWithSnapshotRequired(ctx context.Context, modelName, sessionKey string) ([][]byte, AntigravityReasoningReplaySnapshot, bool, error) {
|
||||
key := antigravityReasoningReplayCacheKey(modelName, sessionKey)
|
||||
if key == "" {
|
||||
return nil, AntigravityReasoningReplaySnapshot{}, false, nil
|
||||
}
|
||||
client, homeMode, errClient := currentAntigravityReasoningReplayKVClient()
|
||||
if homeMode {
|
||||
if errClient != nil {
|
||||
return nil, AntigravityReasoningReplaySnapshot{}, false, errClient
|
||||
}
|
||||
kvKey := antigravityReasoningReplayKVKey(modelName, sessionKey)
|
||||
var raw []byte
|
||||
found := false
|
||||
for attempt := 0; attempt < 4; attempt++ {
|
||||
currentRaw, currentFound, errGet := client.KVGet(ctx, kvKey)
|
||||
if errGet != nil {
|
||||
return nil, AntigravityReasoningReplaySnapshot{loaded: true}, false, errGet
|
||||
}
|
||||
if currentFound {
|
||||
raw = currentRaw
|
||||
found = true
|
||||
break
|
||||
}
|
||||
reservation := newAntigravityReasoningReplayTombstone()
|
||||
swapped, errReserve := client.KVCompareAndSwap(ctx, kvKey, nil, false, reservation, AntigravityReasoningReplayCacheTTL)
|
||||
if errReserve != nil {
|
||||
return nil, AntigravityReasoningReplaySnapshot{loaded: true}, false, errReserve
|
||||
}
|
||||
if swapped {
|
||||
raw = reservation
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, AntigravityReasoningReplaySnapshot{loaded: true}, false, fmt.Errorf("could not fence absent antigravity reasoning replay state")
|
||||
}
|
||||
if len(raw) > antigravityReasoningReplayCacheMaxSerializedBytes {
|
||||
return nil, AntigravityReasoningReplaySnapshot{loaded: true, found: true}, false, nil
|
||||
}
|
||||
snapshot := AntigravityReasoningReplaySnapshot{raw: append([]byte(nil), raw...), loaded: true, found: true}
|
||||
homeItems, deleted, _, branch, okDecode := decodeAntigravityReasoningReplayHomeValue(raw)
|
||||
snapshot.branch = branch
|
||||
if !okDecode || deleted || len(homeItems) == 0 {
|
||||
return nil, snapshot, false, nil
|
||||
}
|
||||
if len(homeItems) > AntigravityReasoningReplayCacheMaxItemsPerEntry {
|
||||
return nil, snapshot, false, nil
|
||||
}
|
||||
normalized, okNormalize := normalizeAntigravityReasoningReplayItems(homeItems)
|
||||
if !okNormalize || len(normalized) != len(homeItems) {
|
||||
return nil, snapshot, false, nil
|
||||
}
|
||||
snapshot.items = cloneAntigravityReasoningReplayItems(normalized)
|
||||
if _, errExpire := client.KVExpire(ctx, kvKey, AntigravityReasoningReplayCacheTTL); errExpire != nil {
|
||||
return nil, snapshot, false, errExpire
|
||||
}
|
||||
return normalized, snapshot, true, nil
|
||||
}
|
||||
|
||||
cacheCleanupOnce.Do(startCacheCleanup)
|
||||
now := time.Now()
|
||||
antigravityReasoningReplayMu.Lock()
|
||||
defer antigravityReasoningReplayMu.Unlock()
|
||||
entry, ok := antigravityReasoningReplayEntries[key]
|
||||
if !ok {
|
||||
return nil, reserveAntigravityReasoningReplayAbsentLocked(key, now), false, nil
|
||||
}
|
||||
if now.Sub(entry.Timestamp) > AntigravityReasoningReplayCacheTTL {
|
||||
antigravityReasoningReplayEvictionEpoch++
|
||||
delete(antigravityReasoningReplayEntries, key)
|
||||
return nil, reserveAntigravityReasoningReplayAbsentLocked(key, now), false, nil
|
||||
}
|
||||
entry.Timestamp = now
|
||||
antigravityReasoningReplayEntries[key] = entry
|
||||
snapshot := AntigravityReasoningReplaySnapshot{loaded: true, found: true, revision: entry.Revision, branch: entry.Branch, evictionEpoch: antigravityReasoningReplayEvictionEpoch}
|
||||
if entry.Deleted || len(entry.Items) == 0 {
|
||||
return nil, snapshot, false, nil
|
||||
}
|
||||
snapshot.items = cloneAntigravityReasoningReplayItems(entry.Items)
|
||||
return cloneAntigravityReasoningReplayItems(entry.Items), snapshot, true, nil
|
||||
}
|
||||
|
||||
// reserveAntigravityReasoningReplayAbsentLocked fences a local miss with a
|
||||
// per-key tombstone so eviction of an unrelated key cannot invalidate it.
|
||||
// antigravityReasoningReplayMu must be held by the caller.
|
||||
func reserveAntigravityReasoningReplayAbsentLocked(key string, now time.Time) AntigravityReasoningReplaySnapshot {
|
||||
if len(antigravityReasoningReplayEntries) >= AntigravityReasoningReplayCacheMaxEntries {
|
||||
evictOldestAntigravityReasoningReplayEntries(AntigravityReasoningReplayCacheEvictBatchSize)
|
||||
}
|
||||
antigravityReasoningReplayNextRevision++
|
||||
entry := antigravityReasoningReplayEntry{
|
||||
Timestamp: now,
|
||||
Revision: antigravityReasoningReplayNextRevision,
|
||||
Branch: newAntigravityReasoningReplayGeneration(),
|
||||
Deleted: true,
|
||||
}
|
||||
antigravityReasoningReplayEntries[key] = entry
|
||||
return AntigravityReasoningReplaySnapshot{
|
||||
loaded: true,
|
||||
found: true,
|
||||
revision: entry.Revision,
|
||||
branch: entry.Branch,
|
||||
evictionEpoch: antigravityReasoningReplayEvictionEpoch,
|
||||
}
|
||||
}
|
||||
|
||||
// ReplaceAntigravityReasoningReplayItemsIfUnchanged publishes a completed chain
|
||||
// only when no newer request has changed the state read by this request.
|
||||
func ReplaceAntigravityReasoningReplayItemsIfUnchanged(ctx context.Context, modelName, sessionKey string, snapshot AntigravityReasoningReplaySnapshot, items [][]byte) (bool, error) {
|
||||
key := antigravityReasoningReplayCacheKey(modelName, sessionKey)
|
||||
if key == "" {
|
||||
return false, nil
|
||||
}
|
||||
normalized, okNormalize := normalizeAntigravityReasoningReplayItems(items)
|
||||
if !okNormalize {
|
||||
return false, fmt.Errorf("invalid antigravity reasoning replay items")
|
||||
}
|
||||
if !snapshot.loaded {
|
||||
return CacheAntigravityReasoningReplayItemsBestEffort(ctx, modelName, sessionKey, normalized), nil
|
||||
}
|
||||
client, homeMode, errClient := currentAntigravityReasoningReplayKVClient()
|
||||
if homeMode {
|
||||
if errClient != nil {
|
||||
return false, errClient
|
||||
}
|
||||
kvKey := antigravityReasoningReplayKVKey(modelName, sessionKey)
|
||||
expectedRaw := snapshot.raw
|
||||
expectedFound := snapshot.found
|
||||
branch := snapshot.branch
|
||||
if branch == "" || !antigravityReasoningReplayItemsPrefix(snapshot.items, normalized) {
|
||||
branch = newAntigravityReasoningReplayGeneration()
|
||||
}
|
||||
for attempt := 0; attempt < 4; attempt++ {
|
||||
raw, errMarshal := marshalAntigravityReasoningReplayHomeValue(normalized, branch)
|
||||
if errMarshal != nil {
|
||||
return false, errMarshal
|
||||
}
|
||||
swapped, errCAS := client.KVCompareAndSwap(ctx, kvKey, expectedRaw, expectedFound, raw, AntigravityReasoningReplayCacheTTL)
|
||||
if errCAS != nil || swapped {
|
||||
return swapped, errCAS
|
||||
}
|
||||
currentRaw, currentFound, errGet := client.KVGet(ctx, kvKey)
|
||||
if errGet != nil || !currentFound {
|
||||
return false, errGet
|
||||
}
|
||||
if len(currentRaw) > antigravityReasoningReplayCacheMaxSerializedBytes {
|
||||
return false, nil
|
||||
}
|
||||
currentItems, deleted, _, currentBranch, okDecode := decodeAntigravityReasoningReplayHomeValue(currentRaw)
|
||||
if !okDecode || deleted || snapshot.branch == "" || currentBranch != snapshot.branch {
|
||||
return false, nil
|
||||
}
|
||||
normalizedCurrent, okNormalizeCurrent := normalizeAntigravityReasoningReplayItems(currentItems)
|
||||
if !okNormalizeCurrent || len(normalizedCurrent) != len(currentItems) || !antigravityReasoningReplayItemsPrefix(normalizedCurrent, normalized) {
|
||||
return false, nil
|
||||
}
|
||||
expectedRaw = currentRaw
|
||||
expectedFound = true
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
cacheCleanupOnce.Do(startCacheCleanup)
|
||||
now := time.Now()
|
||||
antigravityReasoningReplayMu.Lock()
|
||||
defer antigravityReasoningReplayMu.Unlock()
|
||||
entry, found := antigravityReasoningReplayEntries[key]
|
||||
matchesSnapshot := found == snapshot.found && ((found && entry.Revision == snapshot.revision) || (!found && snapshot.evictionEpoch == antigravityReasoningReplayEvictionEpoch))
|
||||
isDescendant := found && !entry.Deleted && snapshot.branch != "" && entry.Branch == snapshot.branch && antigravityReasoningReplayItemsPrefix(entry.Items, normalized)
|
||||
if !matchesSnapshot && !isDescendant {
|
||||
return false, nil
|
||||
}
|
||||
branch := snapshot.branch
|
||||
if branch == "" || (matchesSnapshot && !antigravityReasoningReplayItemsPrefix(snapshot.items, normalized)) {
|
||||
branch = newAntigravityReasoningReplayGeneration()
|
||||
}
|
||||
antigravityReasoningReplayNextRevision++
|
||||
antigravityReasoningReplayEntries[key] = antigravityReasoningReplayEntry{Items: normalized, Timestamp: now, Revision: antigravityReasoningReplayNextRevision, Branch: branch}
|
||||
if len(antigravityReasoningReplayEntries) > AntigravityReasoningReplayCacheMaxEntries {
|
||||
evictOldestAntigravityReasoningReplayEntries(AntigravityReasoningReplayCacheEvictBatchSize)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// DeleteAntigravityReasoningReplayItemsIfUnchanged clears replay state only when
|
||||
// it still matches the state read for this request.
|
||||
func DeleteAntigravityReasoningReplayItemsIfUnchanged(ctx context.Context, modelName, sessionKey string, snapshot AntigravityReasoningReplaySnapshot) (bool, error) {
|
||||
key := antigravityReasoningReplayCacheKey(modelName, sessionKey)
|
||||
if key == "" {
|
||||
return false, nil
|
||||
}
|
||||
if !snapshot.loaded {
|
||||
return true, DeleteAntigravityReasoningReplayItemRequired(ctx, modelName, sessionKey)
|
||||
}
|
||||
client, homeMode, errClient := currentAntigravityReasoningReplayKVClient()
|
||||
if homeMode {
|
||||
if errClient != nil {
|
||||
return false, errClient
|
||||
}
|
||||
return client.KVCompareAndSwap(ctx, antigravityReasoningReplayKVKey(modelName, sessionKey), snapshot.raw, snapshot.found, newAntigravityReasoningReplayTombstone(), AntigravityReasoningReplayCacheTTL)
|
||||
}
|
||||
cacheCleanupOnce.Do(startCacheCleanup)
|
||||
antigravityReasoningReplayMu.Lock()
|
||||
defer antigravityReasoningReplayMu.Unlock()
|
||||
entry, found := antigravityReasoningReplayEntries[key]
|
||||
if found != snapshot.found || (found && entry.Revision != snapshot.revision) || (!found && snapshot.evictionEpoch != antigravityReasoningReplayEvictionEpoch) {
|
||||
return false, nil
|
||||
}
|
||||
antigravityReasoningReplayNextRevision++
|
||||
antigravityReasoningReplayEntries[key] = antigravityReasoningReplayEntry{Timestamp: time.Now(), Revision: antigravityReasoningReplayNextRevision, Branch: newAntigravityReasoningReplayGeneration(), Deleted: true}
|
||||
if len(antigravityReasoningReplayEntries) > AntigravityReasoningReplayCacheMaxEntries {
|
||||
evictOldestAntigravityReasoningReplayEntries(AntigravityReasoningReplayCacheEvictBatchSize)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// DeleteAntigravityReasoningReplayItem removes one replay item after upstream rejects
|
||||
// it or the caller otherwise knows it is stale.
|
||||
func DeleteAntigravityReasoningReplayItem(modelName, sessionKey string) {
|
||||
if errDelete := DeleteAntigravityReasoningReplayItemRequired(context.Background(), modelName, sessionKey); errDelete != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteAntigravityReasoningReplayItemRequired removes one replay item for request-time paths.
|
||||
func DeleteAntigravityReasoningReplayItemRequired(ctx context.Context, modelName, sessionKey string) error {
|
||||
key := antigravityReasoningReplayCacheKey(modelName, sessionKey)
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
client, homeMode, errClient := currentAntigravityReasoningReplayKVClient()
|
||||
if homeMode {
|
||||
if errClient != nil {
|
||||
return errClient
|
||||
}
|
||||
_, errSet := client.KVSet(ctx, antigravityReasoningReplayKVKey(modelName, sessionKey), newAntigravityReasoningReplayTombstone(), homekv.KVSetOptions{EX: AntigravityReasoningReplayCacheTTL})
|
||||
return errSet
|
||||
}
|
||||
cacheCleanupOnce.Do(startCacheCleanup)
|
||||
antigravityReasoningReplayMu.Lock()
|
||||
antigravityReasoningReplayNextRevision++
|
||||
antigravityReasoningReplayEntries[key] = antigravityReasoningReplayEntry{Timestamp: time.Now(), Revision: antigravityReasoningReplayNextRevision, Branch: newAntigravityReasoningReplayGeneration(), Deleted: true}
|
||||
if len(antigravityReasoningReplayEntries) > AntigravityReasoningReplayCacheMaxEntries {
|
||||
evictOldestAntigravityReasoningReplayEntries(AntigravityReasoningReplayCacheEvictBatchSize)
|
||||
}
|
||||
antigravityReasoningReplayMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func newAntigravityReasoningReplayGeneration() string {
|
||||
var nonce [16]byte
|
||||
if _, errRead := rand.Read(nonce[:]); errRead != nil {
|
||||
return fmt.Sprintf("fallback-%d", time.Now().UnixNano())
|
||||
}
|
||||
return fmt.Sprintf("%x", nonce[:])
|
||||
}
|
||||
|
||||
func marshalAntigravityReasoningReplayHomeValue(items [][]byte, branch string) ([]byte, error) {
|
||||
if branch == "" {
|
||||
branch = newAntigravityReasoningReplayGeneration()
|
||||
}
|
||||
marker := []byte(`{"type":"","generation":"","branch":""}`)
|
||||
marker, _ = sjson.SetBytes(marker, "type", antigravityReasoningReplayGenerationItemType)
|
||||
marker, _ = sjson.SetBytes(marker, "generation", newAntigravityReasoningReplayGeneration())
|
||||
marker, _ = sjson.SetBytes(marker, "branch", branch)
|
||||
stored := make([][]byte, 0, len(items)+1)
|
||||
stored = append(stored, marker)
|
||||
stored = append(stored, items...)
|
||||
return json.Marshal(stored)
|
||||
}
|
||||
|
||||
func decodeAntigravityReasoningReplayHomeValue(raw []byte) (items [][]byte, deleted bool, generation, branch string, ok bool) {
|
||||
if errUnmarshal := json.Unmarshal(raw, &items); errUnmarshal != nil {
|
||||
return nil, false, "", "", false
|
||||
}
|
||||
if len(items) == 0 || strings.TrimSpace(gjson.GetBytes(items[0], "type").String()) != antigravityReasoningReplayGenerationItemType {
|
||||
return items, false, "", "", true
|
||||
}
|
||||
marker := gjson.ParseBytes(items[0])
|
||||
deleted = marker.Get("deleted").Bool()
|
||||
generation = strings.TrimSpace(marker.Get("generation").String())
|
||||
branch = strings.TrimSpace(marker.Get("branch").String())
|
||||
return items[1:], deleted, generation, branch, true
|
||||
}
|
||||
|
||||
func antigravityReasoningReplayItemsPrefix(prefix, items [][]byte) bool {
|
||||
if len(prefix) > len(items) {
|
||||
return false
|
||||
}
|
||||
for index := range prefix {
|
||||
if !bytes.Equal(prefix[index], items[index]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func newAntigravityReasoningReplayTombstone() []byte {
|
||||
marker := []byte(`{"type":"","generation":"","branch":"","deleted":true}`)
|
||||
marker, _ = sjson.SetBytes(marker, "type", antigravityReasoningReplayGenerationItemType)
|
||||
marker, _ = sjson.SetBytes(marker, "generation", newAntigravityReasoningReplayGeneration())
|
||||
marker, _ = sjson.SetBytes(marker, "branch", newAntigravityReasoningReplayGeneration())
|
||||
raw, _ := json.Marshal([][]byte{marker})
|
||||
return raw
|
||||
}
|
||||
|
||||
// ClearAntigravityReasoningReplayCache clears all Antigravity reasoning replay state.
|
||||
func ClearAntigravityReasoningReplayCache() {
|
||||
antigravityReasoningReplayMu.Lock()
|
||||
antigravityReasoningReplayEntries = make(map[string]antigravityReasoningReplayEntry)
|
||||
antigravityReasoningReplayEvictionEpoch++
|
||||
antigravityReasoningReplayMu.Unlock()
|
||||
}
|
||||
|
||||
func antigravityReasoningReplayCacheKey(modelName, sessionKey string) string {
|
||||
modelName = strings.TrimSpace(modelName)
|
||||
sessionKey = strings.TrimSpace(sessionKey)
|
||||
if modelName == "" || sessionKey == "" {
|
||||
return ""
|
||||
}
|
||||
// The session key is the continuity boundary. Keep this independent from
|
||||
// the selected upstream Codex credential so auth failover can preserve replay.
|
||||
return strings.Join([]string{"antigravity-reasoning-replay", modelName, sessionKey}, "\x00")
|
||||
}
|
||||
|
||||
func antigravityReasoningReplayKVKey(modelName, sessionKey string) string {
|
||||
return "cpa:antigravity:reasoning-replay:" + homekv.HashKeyPart(strings.TrimSpace(modelName)) + ":" + homekv.HashKeyPart(strings.TrimSpace(sessionKey))
|
||||
}
|
||||
|
||||
func normalizeAntigravityReasoningReplayItems(items [][]byte) ([][]byte, bool) {
|
||||
if len(items) > AntigravityReasoningReplayCacheMaxItemsPerEntry {
|
||||
return nil, false
|
||||
}
|
||||
normalized := make([][]byte, 0, len(items))
|
||||
totalBytes := 0
|
||||
for _, item := range items {
|
||||
normalizedItem, ok := normalizeAntigravityReasoningReplayItem(item)
|
||||
if ok {
|
||||
totalBytes += len(normalizedItem)
|
||||
if totalBytes > AntigravityReasoningReplayCacheMaxBytesPerEntry {
|
||||
return nil, false
|
||||
}
|
||||
normalized = append(normalized, normalizedItem)
|
||||
}
|
||||
}
|
||||
return normalized, len(normalized) > 0
|
||||
}
|
||||
|
||||
func normalizeAntigravityReasoningReplayItem(item []byte) ([]byte, bool) {
|
||||
itemResult := gjson.ParseBytes(item)
|
||||
switch strings.TrimSpace(itemResult.Get("type").String()) {
|
||||
case "thought_signature":
|
||||
return normalizeAntigravityThoughtSignatureReplayItem(itemResult)
|
||||
case "function_call_part":
|
||||
return normalizeAntigravityFunctionCallPartReplayItem(itemResult)
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAntigravityThoughtSignatureReplayItem(itemResult gjson.Result) ([]byte, bool) {
|
||||
sig := strings.TrimSpace(itemResult.Get("thoughtSignature").String())
|
||||
if sig == "" {
|
||||
sig = strings.TrimSpace(itemResult.Get("thought_signature").String())
|
||||
}
|
||||
if sig == "" || sig == "skip_thought_signature_validator" || len(sig) < minAntigravityThoughtSignatureReplayLen {
|
||||
return nil, false
|
||||
}
|
||||
normalized := []byte(`{"type":"thought_signature"}`)
|
||||
normalized, _ = sjson.SetBytes(normalized, "thoughtSignature", sig)
|
||||
if contentIndex := itemResult.Get("contentIndex"); contentIndex.Type == gjson.Number {
|
||||
normalized, _ = sjson.SetBytes(normalized, "contentIndex", contentIndex.Int())
|
||||
}
|
||||
if partIndex := itemResult.Get("partIndex"); partIndex.Type == gjson.Number {
|
||||
normalized, _ = sjson.SetBytes(normalized, "partIndex", partIndex.Int())
|
||||
}
|
||||
if targetKind := strings.TrimSpace(itemResult.Get("targetKind").String()); targetKind == "text" || targetKind == "thought" {
|
||||
normalized, _ = sjson.SetBytes(normalized, "targetKind", targetKind)
|
||||
}
|
||||
if targetHash := strings.TrimSpace(itemResult.Get("targetHash").String()); targetHash != "" {
|
||||
normalized, _ = sjson.SetBytes(normalized, "targetHash", targetHash)
|
||||
}
|
||||
if targetOccurrence := itemResult.Get("targetOccurrence"); targetOccurrence.Type == gjson.Number && targetOccurrence.Int() >= 0 {
|
||||
normalized, _ = sjson.SetBytes(normalized, "targetOccurrence", targetOccurrence.Int())
|
||||
}
|
||||
if contextHash := strings.TrimSpace(itemResult.Get("contextHash").String()); contextHash != "" {
|
||||
normalized, _ = sjson.SetBytes(normalized, "contextHash", contextHash)
|
||||
}
|
||||
return normalized, true
|
||||
}
|
||||
|
||||
func normalizeAntigravityFunctionCallPartReplayItem(itemResult gjson.Result) ([]byte, bool) {
|
||||
callID := strings.TrimSpace(itemResult.Get("call_id").String())
|
||||
if callID == "" {
|
||||
callID = strings.TrimSpace(itemResult.Get("id").String())
|
||||
}
|
||||
name := strings.TrimSpace(itemResult.Get("name").String())
|
||||
args := itemResult.Get("args")
|
||||
if name == "" || !args.Exists() {
|
||||
fc := itemResult.Get("functionCall")
|
||||
if fc.Exists() {
|
||||
if callID == "" {
|
||||
callID = strings.TrimSpace(fc.Get("id").String())
|
||||
}
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(fc.Get("name").String())
|
||||
}
|
||||
if !args.Exists() {
|
||||
args = fc.Get("args")
|
||||
}
|
||||
}
|
||||
}
|
||||
if name == "" || !args.Exists() {
|
||||
return nil, false
|
||||
}
|
||||
normalized := []byte(`{"type":"function_call_part"}`)
|
||||
if callID != "" {
|
||||
normalized, _ = sjson.SetBytes(normalized, "call_id", callID)
|
||||
}
|
||||
normalized, _ = sjson.SetBytes(normalized, "name", name)
|
||||
if args.Type == gjson.String {
|
||||
normalized, _ = sjson.SetBytes(normalized, "args", args.String())
|
||||
} else {
|
||||
normalized, _ = sjson.SetRawBytes(normalized, "args", []byte(args.Raw))
|
||||
}
|
||||
sig := strings.TrimSpace(itemResult.Get("thoughtSignature").String())
|
||||
if sig != "" && sig != "skip_thought_signature_validator" {
|
||||
normalized, _ = sjson.SetBytes(normalized, "thoughtSignature", sig)
|
||||
}
|
||||
if contentIndex := itemResult.Get("contentIndex"); contentIndex.Type == gjson.Number {
|
||||
normalized, _ = sjson.SetBytes(normalized, "contentIndex", contentIndex.Int())
|
||||
}
|
||||
if partIndex := itemResult.Get("partIndex"); partIndex.Type == gjson.Number {
|
||||
normalized, _ = sjson.SetBytes(normalized, "partIndex", partIndex.Int())
|
||||
}
|
||||
if targetOccurrence := itemResult.Get("targetOccurrence"); targetOccurrence.Type == gjson.Number && targetOccurrence.Int() >= 0 {
|
||||
normalized, _ = sjson.SetBytes(normalized, "targetOccurrence", targetOccurrence.Int())
|
||||
}
|
||||
if contextHash := strings.TrimSpace(itemResult.Get("contextHash").String()); contextHash != "" {
|
||||
normalized, _ = sjson.SetBytes(normalized, "contextHash", contextHash)
|
||||
}
|
||||
return normalized, true
|
||||
}
|
||||
|
||||
func cloneAntigravityReasoningReplayItems(items [][]byte) [][]byte {
|
||||
cloned := make([][]byte, 0, len(items))
|
||||
for _, item := range items {
|
||||
cloned = append(cloned, append([]byte(nil), item...))
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func evictOldestAntigravityReasoningReplayEntries(count int) {
|
||||
if count <= 0 || len(antigravityReasoningReplayEntries) == 0 {
|
||||
return
|
||||
}
|
||||
type candidate struct {
|
||||
key string
|
||||
timestamp time.Time
|
||||
}
|
||||
candidates := make([]candidate, 0, len(antigravityReasoningReplayEntries))
|
||||
for key, entry := range antigravityReasoningReplayEntries {
|
||||
candidates = append(candidates, candidate{key: key, timestamp: entry.Timestamp})
|
||||
}
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
return candidates[i].timestamp.Before(candidates[j].timestamp)
|
||||
})
|
||||
if count > len(candidates) {
|
||||
count = len(candidates)
|
||||
}
|
||||
for i := 0; i < count; i++ {
|
||||
antigravityReasoningReplayEvictionEpoch++
|
||||
delete(antigravityReasoningReplayEntries, candidates[i].key)
|
||||
}
|
||||
}
|
||||
|
||||
func purgeExpiredAntigravityReasoningReplayCache(now time.Time) {
|
||||
antigravityReasoningReplayMu.Lock()
|
||||
for key, entry := range antigravityReasoningReplayEntries {
|
||||
if now.Sub(entry.Timestamp) > AntigravityReasoningReplayCacheTTL {
|
||||
antigravityReasoningReplayEvictionEpoch++
|
||||
delete(antigravityReasoningReplayEntries, key)
|
||||
}
|
||||
}
|
||||
antigravityReasoningReplayMu.Unlock()
|
||||
}
|
||||
542
backend/internal/cache/antigravity_reasoning_replay_cache_test.go
vendored
Normal file
542
backend/internal/cache/antigravity_reasoning_replay_cache_test.go
vendored
Normal file
|
|
@ -0,0 +1,542 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
homekv "github.com/router-for-me/CLIProxyAPI/v7/internal/home"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
type fakeAntigravityReasoningReplayKVClient struct {
|
||||
mu sync.Mutex
|
||||
values map[string][]byte
|
||||
expireCount int
|
||||
casErr error
|
||||
}
|
||||
|
||||
func newFakeAntigravityReasoningReplayKVClient() *fakeAntigravityReasoningReplayKVClient {
|
||||
return &fakeAntigravityReasoningReplayKVClient{values: make(map[string][]byte)}
|
||||
}
|
||||
|
||||
func (c *fakeAntigravityReasoningReplayKVClient) KVGet(_ context.Context, key string) ([]byte, bool, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
value, ok := c.values[key]
|
||||
return append([]byte(nil), value...), ok, nil
|
||||
}
|
||||
|
||||
func (c *fakeAntigravityReasoningReplayKVClient) KVSet(_ context.Context, key string, value []byte, _ homekv.KVSetOptions) (bool, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.values[key] = append([]byte(nil), value...)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *fakeAntigravityReasoningReplayKVClient) KVCompareAndSwap(_ context.Context, key string, expected []byte, expectedExists bool, value []byte, _ time.Duration) (bool, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.casErr != nil {
|
||||
return false, c.casErr
|
||||
}
|
||||
current, exists := c.values[key]
|
||||
if exists != expectedExists || (exists && !bytes.Equal(current, expected)) {
|
||||
return false, nil
|
||||
}
|
||||
c.values[key] = append([]byte(nil), value...)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *fakeAntigravityReasoningReplayKVClient) KVDel(_ context.Context, keys ...string) (int64, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
var deleted int64
|
||||
for _, key := range keys {
|
||||
if _, ok := c.values[key]; ok {
|
||||
delete(c.values, key)
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func (c *fakeAntigravityReasoningReplayKVClient) KVExpire(_ context.Context, _ string, _ time.Duration) (bool, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.expireCount++
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func useFakeAntigravityReasoningReplayKVClient(t *testing.T, client *fakeAntigravityReasoningReplayKVClient, homeMode bool) {
|
||||
t.Helper()
|
||||
previous := currentAntigravityReasoningReplayKVClient
|
||||
currentAntigravityReasoningReplayKVClient = func() (antigravityReasoningReplayKVClient, bool, error) {
|
||||
return client, homeMode, nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
currentAntigravityReasoningReplayKVClient = previous
|
||||
})
|
||||
}
|
||||
|
||||
func antigravityReplayTestItem(signature string) []byte {
|
||||
return []byte(`{"type":"thought_signature","contentIndex":1,"partIndex":0,"thoughtSignature":"` + signature + `"}`)
|
||||
}
|
||||
|
||||
func TestAntigravityReasoningReplayConditionalMutationRejectsStaleLocalSnapshot(t *testing.T) {
|
||||
ClearAntigravityReasoningReplayCache()
|
||||
t.Cleanup(ClearAntigravityReasoningReplayCache)
|
||||
const model, session = "gemini-3.6-flash-high", "stale-local"
|
||||
oldItem := antigravityReplayTestItem("old-local-signature-123456")
|
||||
newItem := antigravityReplayTestItem("new-local-signature-123456")
|
||||
staleItem := antigravityReplayTestItem("stale-local-signature-123456")
|
||||
if !CacheAntigravityReasoningReplayItems(model, session, [][]byte{oldItem}) {
|
||||
t.Fatal("initial cache write failed")
|
||||
}
|
||||
_, snapshot, found, errGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session)
|
||||
if errGet != nil || !found {
|
||||
t.Fatalf("snapshot read failed: found=%v err=%v", found, errGet)
|
||||
}
|
||||
if !CacheAntigravityReasoningReplayItems(model, session, [][]byte{newItem}) {
|
||||
t.Fatal("newer cache write failed")
|
||||
}
|
||||
if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, snapshot, [][]byte{staleItem}); errSwap != nil || swapped {
|
||||
t.Fatalf("stale replace = %v, %v; want false, nil", swapped, errSwap)
|
||||
}
|
||||
if deleted, errDelete := DeleteAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, snapshot); errDelete != nil || deleted {
|
||||
t.Fatalf("stale delete = %v, %v; want false, nil", deleted, errDelete)
|
||||
}
|
||||
items, ok := GetAntigravityReasoningReplayItems(model, session)
|
||||
if !ok || len(items) != 1 || !bytes.Contains(items[0], []byte("new-local-signature")) {
|
||||
t.Fatalf("newer state was lost: %q, found=%v", items, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntigravityReasoningReplayNonPrefixReplaceRotatesLocalBranch(t *testing.T) {
|
||||
ClearAntigravityReasoningReplayCache()
|
||||
t.Cleanup(ClearAntigravityReasoningReplayCache)
|
||||
const model, session = "gemini-3.6-flash-high", "non-prefix-local"
|
||||
oldItem := antigravityReplayTestItem("non-prefix-old-signature-123456")
|
||||
newItem := antigravityReplayTestItem("non-prefix-new-signature-123456")
|
||||
latestItem := antigravityReplayTestItem("non-prefix-latest-signature-123456")
|
||||
if !CacheAntigravityReasoningReplayItems(model, session, [][]byte{oldItem}) {
|
||||
t.Fatal("old local write failed")
|
||||
}
|
||||
_, firstSnapshot, _, errFirstGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session)
|
||||
_, staleSnapshot, _, errStaleGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session)
|
||||
if errFirstGet != nil || errStaleGet != nil {
|
||||
t.Fatalf("snapshot reads failed: %v, %v", errFirstGet, errStaleGet)
|
||||
}
|
||||
if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, firstSnapshot, [][]byte{newItem}); errSwap != nil || !swapped {
|
||||
t.Fatalf("non-prefix local replace = %v, %v", swapped, errSwap)
|
||||
}
|
||||
if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, staleSnapshot, [][]byte{newItem, latestItem}); errSwap != nil || swapped {
|
||||
t.Fatalf("stale local descendant crossed non-prefix reset: swapped=%v err=%v", swapped, errSwap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntigravityReasoningReplayConditionalReplaceAcceptsDescendantLocalChain(t *testing.T) {
|
||||
ClearAntigravityReasoningReplayCache()
|
||||
t.Cleanup(ClearAntigravityReasoningReplayCache)
|
||||
const model, session = "gemini-3.6-flash-high", "descendant-local"
|
||||
prefix := antigravityReplayTestItem("descendant-prefix-signature-123456")
|
||||
middle := antigravityReplayTestItem("descendant-middle-signature-123456")
|
||||
latest := antigravityReplayTestItem("descendant-latest-signature-123456")
|
||||
if !CacheAntigravityReasoningReplayItems(model, session, [][]byte{prefix}) {
|
||||
t.Fatal("prefix write failed")
|
||||
}
|
||||
_, staleSnapshot, found, errGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session)
|
||||
if errGet != nil || !found {
|
||||
t.Fatalf("prefix snapshot failed: found=%v err=%v", found, errGet)
|
||||
}
|
||||
_, firstSnapshot, _, errFirstGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session)
|
||||
if errFirstGet != nil {
|
||||
t.Fatal(errFirstGet)
|
||||
}
|
||||
if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, firstSnapshot, [][]byte{prefix, middle}); errSwap != nil || !swapped {
|
||||
t.Fatalf("middle conditional write = %v, %v", swapped, errSwap)
|
||||
}
|
||||
if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, staleSnapshot, [][]byte{prefix, middle, latest}); errSwap != nil || !swapped {
|
||||
t.Fatalf("descendant local replace = %v, %v; want true, nil", swapped, errSwap)
|
||||
}
|
||||
items, ok := GetAntigravityReasoningReplayItems(model, session)
|
||||
if !ok || len(items) != 3 {
|
||||
t.Fatalf("descendant local chain = %d items, found=%v", len(items), ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntigravityReasoningReplayDescendantMergeRejectsResetBranchABA(t *testing.T) {
|
||||
ClearAntigravityReasoningReplayCache()
|
||||
t.Cleanup(ClearAntigravityReasoningReplayCache)
|
||||
const model, session = "gemini-3.6-flash-high", "descendant-reset-aba"
|
||||
prefix := antigravityReplayTestItem("reset-prefix-signature-123456")
|
||||
middle := antigravityReplayTestItem("reset-middle-signature-123456")
|
||||
staleLatest := antigravityReplayTestItem("reset-stale-signature-123456")
|
||||
if !CacheAntigravityReasoningReplayItems(model, session, [][]byte{prefix}) {
|
||||
t.Fatal("prefix write failed")
|
||||
}
|
||||
_, staleSnapshot, _, errStaleGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session)
|
||||
_, firstSnapshot, _, errFirstGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session)
|
||||
if errStaleGet != nil || errFirstGet != nil {
|
||||
t.Fatalf("snapshot reads failed: %v, %v", errStaleGet, errFirstGet)
|
||||
}
|
||||
if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, firstSnapshot, [][]byte{prefix, middle}); errSwap != nil || !swapped {
|
||||
t.Fatalf("middle write = %v, %v", swapped, errSwap)
|
||||
}
|
||||
_, currentSnapshot, _, errCurrentGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session)
|
||||
if errCurrentGet != nil {
|
||||
t.Fatal(errCurrentGet)
|
||||
}
|
||||
if deleted, errDelete := DeleteAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, currentSnapshot); errDelete != nil || !deleted {
|
||||
t.Fatalf("branch reset = %v, %v", deleted, errDelete)
|
||||
}
|
||||
_, resetSnapshot, _, errResetGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session)
|
||||
if errResetGet != nil {
|
||||
t.Fatal(errResetGet)
|
||||
}
|
||||
if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, resetSnapshot, [][]byte{prefix}); errSwap != nil || !swapped {
|
||||
t.Fatalf("new branch prefix write = %v, %v", swapped, errSwap)
|
||||
}
|
||||
if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, staleSnapshot, [][]byte{prefix, staleLatest}); errSwap != nil || swapped {
|
||||
t.Fatalf("stale descendant crossed reset branch: swapped=%v err=%v", swapped, errSwap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntigravityReasoningReplayConditionalDeleteTombstoneBlocksStaleFirstWriter(t *testing.T) {
|
||||
ClearAntigravityReasoningReplayCache()
|
||||
t.Cleanup(ClearAntigravityReasoningReplayCache)
|
||||
const model, session = "gemini-3.6-flash-high", "stale-first-writer"
|
||||
_, staleSnapshot, found, errGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session)
|
||||
if errGet != nil || found {
|
||||
t.Fatalf("initial absent snapshot = found %v, err %v", found, errGet)
|
||||
}
|
||||
_, clearSnapshot, _, errClearGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session)
|
||||
if errClearGet != nil {
|
||||
t.Fatal(errClearGet)
|
||||
}
|
||||
if deleted, errDelete := DeleteAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, clearSnapshot); errDelete != nil || !deleted {
|
||||
t.Fatalf("conditional empty clear = %v, %v; want true, nil", deleted, errDelete)
|
||||
}
|
||||
staleItem := antigravityReplayTestItem("stale-first-writer-signature-123456")
|
||||
if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, staleSnapshot, [][]byte{staleItem}); errSwap != nil || swapped {
|
||||
t.Fatalf("stale first write = %v, %v; want false, nil", swapped, errSwap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntigravityReasoningReplayEvictedTombstoneStillBlocksStaleFirstWriter(t *testing.T) {
|
||||
ClearAntigravityReasoningReplayCache()
|
||||
t.Cleanup(ClearAntigravityReasoningReplayCache)
|
||||
const model, session = "gemini-3.6-flash-high", "evicted-stale-first-writer"
|
||||
_, staleSnapshot, found, errGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session)
|
||||
if errGet != nil || found {
|
||||
t.Fatalf("initial absent snapshot = found %v, err %v", found, errGet)
|
||||
}
|
||||
_, clearSnapshot, _, errClearGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session)
|
||||
if errClearGet != nil {
|
||||
t.Fatal(errClearGet)
|
||||
}
|
||||
if deleted, errDelete := DeleteAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, clearSnapshot); errDelete != nil || !deleted {
|
||||
t.Fatalf("conditional clear = %v, %v", deleted, errDelete)
|
||||
}
|
||||
antigravityReasoningReplayMu.Lock()
|
||||
evictOldestAntigravityReasoningReplayEntries(1)
|
||||
antigravityReasoningReplayMu.Unlock()
|
||||
staleItem := antigravityReplayTestItem("evicted-stale-first-writer-signature-123456")
|
||||
if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, staleSnapshot, [][]byte{staleItem}); errSwap != nil || swapped {
|
||||
t.Fatalf("stale first writer crossed tombstone eviction: swapped=%v err=%v", swapped, errSwap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntigravityReasoningReplayUnrelatedEvictionDoesNotBlockAbsentSnapshot(t *testing.T) {
|
||||
ClearAntigravityReasoningReplayCache()
|
||||
t.Cleanup(ClearAntigravityReasoningReplayCache)
|
||||
const model = "gemini-3.6-flash-high"
|
||||
liveItem := antigravityReplayTestItem("evicted-live-signature-123456")
|
||||
if !CacheAntigravityReasoningReplayItems(model, "older-live-entry", [][]byte{liveItem}) {
|
||||
t.Fatal("live entry write failed")
|
||||
}
|
||||
_, snapshot, found, errGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, "untouched-absent-session")
|
||||
if errGet != nil || found {
|
||||
t.Fatalf("initial absent snapshot = found %v, err %v", found, errGet)
|
||||
}
|
||||
antigravityReasoningReplayMu.Lock()
|
||||
evictOldestAntigravityReasoningReplayEntries(1)
|
||||
antigravityReasoningReplayMu.Unlock()
|
||||
firstItem := antigravityReplayTestItem("first-write-after-unrelated-eviction-123456")
|
||||
if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, "untouched-absent-session", snapshot, [][]byte{firstItem}); errSwap != nil || !swapped {
|
||||
t.Fatalf("unrelated eviction blocked first write: swapped=%v err=%v", swapped, errSwap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntigravityReasoningReplayHomeAbsentSnapshotIsFenced(t *testing.T) {
|
||||
client := newFakeAntigravityReasoningReplayKVClient()
|
||||
useFakeAntigravityReasoningReplayKVClient(t, client, true)
|
||||
const model, session = "gemini-3.6-flash-high", "home-absent-fence"
|
||||
_, snapshot, found, errGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session)
|
||||
if errGet != nil || found || !snapshot.found || len(snapshot.raw) == 0 {
|
||||
t.Fatalf("fenced Home miss = found %v snapshotFound %v raw %d err %v", found, snapshot.found, len(snapshot.raw), errGet)
|
||||
}
|
||||
key := antigravityReasoningReplayKVKey(model, session)
|
||||
client.mu.Lock()
|
||||
client.values[key] = []byte(`[[123]]`)
|
||||
delete(client.values, key)
|
||||
client.mu.Unlock()
|
||||
item := antigravityReplayTestItem("home-absent-stale-signature-123456")
|
||||
if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, snapshot, [][]byte{item}); errSwap != nil || swapped {
|
||||
t.Fatalf("stale Home absent snapshot crossed value expiry: swapped=%v err=%v", swapped, errSwap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntigravityReasoningReplayConditionalMutationRejectsStaleHomeSnapshot(t *testing.T) {
|
||||
client := newFakeAntigravityReasoningReplayKVClient()
|
||||
useFakeAntigravityReasoningReplayKVClient(t, client, true)
|
||||
const model, session = "gemini-3.6-flash-high", "stale-home"
|
||||
oldItem := antigravityReplayTestItem("old-home-signature-123456")
|
||||
newItem := antigravityReplayTestItem("new-home-signature-123456")
|
||||
staleItem := antigravityReplayTestItem("stale-home-signature-123456")
|
||||
if !CacheAntigravityReasoningReplayItems(model, session, [][]byte{oldItem}) {
|
||||
t.Fatal("initial Home write failed")
|
||||
}
|
||||
_, snapshot, found, errGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session)
|
||||
if errGet != nil || !found {
|
||||
t.Fatalf("Home snapshot read failed: found=%v err=%v", found, errGet)
|
||||
}
|
||||
if !CacheAntigravityReasoningReplayItems(model, session, [][]byte{newItem}) {
|
||||
t.Fatal("newer Home write failed")
|
||||
}
|
||||
if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, snapshot, [][]byte{staleItem}); errSwap != nil || swapped {
|
||||
t.Fatalf("stale Home replace = %v, %v; want false, nil", swapped, errSwap)
|
||||
}
|
||||
if deleted, errDelete := DeleteAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, snapshot); errDelete != nil || deleted {
|
||||
t.Fatalf("stale Home delete = %v, %v; want false, nil", deleted, errDelete)
|
||||
}
|
||||
items, ok := GetAntigravityReasoningReplayItems(model, session)
|
||||
if !ok || len(items) != 1 || !bytes.Contains(items[0], []byte("new-home-signature")) {
|
||||
t.Fatalf("newer Home state was lost: %q, found=%v", items, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntigravityReasoningReplayNonPrefixReplaceRotatesHomeBranch(t *testing.T) {
|
||||
client := newFakeAntigravityReasoningReplayKVClient()
|
||||
useFakeAntigravityReasoningReplayKVClient(t, client, true)
|
||||
const model, session = "gemini-3.6-flash-high", "non-prefix-home"
|
||||
oldItem := antigravityReplayTestItem("non-prefix-home-old-123456")
|
||||
newItem := antigravityReplayTestItem("non-prefix-home-new-123456")
|
||||
latestItem := antigravityReplayTestItem("non-prefix-home-latest-123456")
|
||||
if !CacheAntigravityReasoningReplayItems(model, session, [][]byte{oldItem}) {
|
||||
t.Fatal("old Home write failed")
|
||||
}
|
||||
_, firstSnapshot, _, errFirstGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session)
|
||||
_, staleSnapshot, _, errStaleGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session)
|
||||
if errFirstGet != nil || errStaleGet != nil {
|
||||
t.Fatalf("Home snapshot reads failed: %v, %v", errFirstGet, errStaleGet)
|
||||
}
|
||||
if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, firstSnapshot, [][]byte{newItem}); errSwap != nil || !swapped {
|
||||
t.Fatalf("non-prefix Home replace = %v, %v", swapped, errSwap)
|
||||
}
|
||||
if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, staleSnapshot, [][]byte{newItem, latestItem}); errSwap != nil || swapped {
|
||||
t.Fatalf("stale Home descendant crossed non-prefix reset: swapped=%v err=%v", swapped, errSwap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntigravityReasoningReplayConditionalReplaceAcceptsDescendantHomeChain(t *testing.T) {
|
||||
client := newFakeAntigravityReasoningReplayKVClient()
|
||||
useFakeAntigravityReasoningReplayKVClient(t, client, true)
|
||||
const model, session = "gemini-3.6-flash-high", "descendant-home"
|
||||
prefix := antigravityReplayTestItem("home-descendant-prefix-123456")
|
||||
middle := antigravityReplayTestItem("home-descendant-middle-123456")
|
||||
latest := antigravityReplayTestItem("home-descendant-latest-123456")
|
||||
if !CacheAntigravityReasoningReplayItems(model, session, [][]byte{prefix}) {
|
||||
t.Fatal("Home prefix write failed")
|
||||
}
|
||||
_, staleSnapshot, found, errGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session)
|
||||
if errGet != nil || !found {
|
||||
t.Fatalf("Home prefix snapshot failed: found=%v err=%v", found, errGet)
|
||||
}
|
||||
_, firstSnapshot, _, errFirstGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session)
|
||||
if errFirstGet != nil {
|
||||
t.Fatal(errFirstGet)
|
||||
}
|
||||
if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, firstSnapshot, [][]byte{prefix, middle}); errSwap != nil || !swapped {
|
||||
t.Fatalf("Home middle conditional write = %v, %v", swapped, errSwap)
|
||||
}
|
||||
if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, staleSnapshot, [][]byte{prefix, middle, latest}); errSwap != nil || !swapped {
|
||||
t.Fatalf("descendant Home replace = %v, %v; want true, nil", swapped, errSwap)
|
||||
}
|
||||
items, ok := GetAntigravityReasoningReplayItems(model, session)
|
||||
if !ok || len(items) != 3 {
|
||||
t.Fatalf("descendant Home chain = %d items, found=%v", len(items), ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntigravityReasoningReplayHomeGenerationRejectsSuccessfulValueABA(t *testing.T) {
|
||||
client := newFakeAntigravityReasoningReplayKVClient()
|
||||
useFakeAntigravityReasoningReplayKVClient(t, client, true)
|
||||
const model, session = "gemini-3.6-flash-high", "home-aba"
|
||||
itemA := antigravityReplayTestItem("home-aba-signature-a-123456")
|
||||
itemB := antigravityReplayTestItem("home-aba-signature-b-123456")
|
||||
if !CacheAntigravityReasoningReplayItems(model, session, [][]byte{itemA}) {
|
||||
t.Fatal("initial A write failed")
|
||||
}
|
||||
_, staleSnapshot, found, errGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session)
|
||||
if errGet != nil || !found {
|
||||
t.Fatalf("A snapshot read failed: found=%v err=%v", found, errGet)
|
||||
}
|
||||
if !CacheAntigravityReasoningReplayItems(model, session, [][]byte{itemB}) || !CacheAntigravityReasoningReplayItems(model, session, [][]byte{itemA}) {
|
||||
t.Fatal("B to A rewrite failed")
|
||||
}
|
||||
if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, staleSnapshot, [][]byte{itemB}); errSwap != nil || swapped {
|
||||
t.Fatalf("stale A snapshot passed Home ABA guard: swapped=%v err=%v", swapped, errSwap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntigravityReasoningReplayHomeReportsCASErrors(t *testing.T) {
|
||||
// The cache layer keeps reporting CAS failures honestly. Deciding that a
|
||||
// replay failure must not fail the request is the executor's job, so this
|
||||
// layer must not start swallowing errors.
|
||||
client := newFakeAntigravityReasoningReplayKVClient()
|
||||
client.casErr = fmt.Errorf("ERR unknown command 'cas'")
|
||||
useFakeAntigravityReasoningReplayKVClient(t, client, true)
|
||||
const model, session = "gemini-3.6-flash-high", "home-cas-error"
|
||||
|
||||
_, _, found, errGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session)
|
||||
if errGet == nil {
|
||||
t.Fatal("GetAntigravityReasoningReplayItemsWithSnapshotRequired() error = nil, want the CAS error")
|
||||
}
|
||||
if found {
|
||||
t.Fatal("GetAntigravityReasoningReplayItemsWithSnapshotRequired() found = true, want false")
|
||||
}
|
||||
|
||||
snapshot := AntigravityReasoningReplaySnapshot{loaded: true}
|
||||
if _, errReplace := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, snapshot, [][]byte{antigravityReplayTestItem("home-cas-error-sig-1")}); errReplace == nil {
|
||||
t.Fatal("ReplaceAntigravityReasoningReplayItemsIfUnchanged() error = nil, want the CAS error")
|
||||
}
|
||||
if _, errDelete := DeleteAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, snapshot); errDelete == nil {
|
||||
t.Fatal("DeleteAntigravityReasoningReplayItemsIfUnchanged() error = nil, want the CAS error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntigravityReasoningReplayHomeCASRetryRejectsOversizedValue(t *testing.T) {
|
||||
client := newFakeAntigravityReasoningReplayKVClient()
|
||||
useFakeAntigravityReasoningReplayKVClient(t, client, true)
|
||||
const model, session = "gemini-3.6-flash-high", "oversized-home-cas"
|
||||
prefix := antigravityReplayTestItem("oversized-home-prefix-123456")
|
||||
latest := antigravityReplayTestItem("oversized-home-latest-123456")
|
||||
if !CacheAntigravityReasoningReplayItems(model, session, [][]byte{prefix}) {
|
||||
t.Fatal("Home prefix write failed")
|
||||
}
|
||||
_, snapshot, found, errGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session)
|
||||
if errGet != nil || !found {
|
||||
t.Fatalf("Home snapshot read failed: found=%v err=%v", found, errGet)
|
||||
}
|
||||
oversized, errMarshal := marshalAntigravityReasoningReplayHomeValue([][]byte{prefix}, snapshot.branch)
|
||||
if errMarshal != nil {
|
||||
t.Fatal(errMarshal)
|
||||
}
|
||||
oversized = append(oversized, bytes.Repeat([]byte(" "), antigravityReasoningReplayCacheMaxSerializedBytes-len(oversized)+1)...)
|
||||
key := antigravityReasoningReplayKVKey(model, session)
|
||||
client.values[key] = oversized
|
||||
if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, snapshot, [][]byte{prefix, latest}); errSwap != nil || swapped {
|
||||
t.Fatalf("oversized Home CAS retry = swapped %v, err %v; want false, nil", swapped, errSwap)
|
||||
}
|
||||
if got := len(client.values[key]); got <= antigravityReasoningReplayCacheMaxSerializedBytes {
|
||||
t.Fatalf("oversized value was unexpectedly replaced: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntigravityReasoningReplayLocalTombstonesStayWithinEntryBound(t *testing.T) {
|
||||
ClearAntigravityReasoningReplayCache()
|
||||
t.Cleanup(ClearAntigravityReasoningReplayCache)
|
||||
for index := 0; index <= AntigravityReasoningReplayCacheMaxEntries; index++ {
|
||||
if errDelete := DeleteAntigravityReasoningReplayItemRequired(context.Background(), "gemini-3.6-flash-high", fmt.Sprintf("tombstone-%d", index)); errDelete != nil {
|
||||
t.Fatal(errDelete)
|
||||
}
|
||||
}
|
||||
antigravityReasoningReplayMu.Lock()
|
||||
entryCount := len(antigravityReasoningReplayEntries)
|
||||
antigravityReasoningReplayMu.Unlock()
|
||||
if entryCount > AntigravityReasoningReplayCacheMaxEntries {
|
||||
t.Fatalf("local tombstone count = %d, max %d", entryCount, AntigravityReasoningReplayCacheMaxEntries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntigravityReasoningReplayLocalAbsenceReservationsStayWithinEntryBound(t *testing.T) {
|
||||
ClearAntigravityReasoningReplayCache()
|
||||
t.Cleanup(ClearAntigravityReasoningReplayCache)
|
||||
const model = "gemini-3.6-flash-high"
|
||||
latestSession := ""
|
||||
for index := 0; index <= AntigravityReasoningReplayCacheMaxEntries; index++ {
|
||||
latestSession = fmt.Sprintf("absent-reservation-%d", index)
|
||||
if _, _, found, errGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, latestSession); errGet != nil || found {
|
||||
t.Fatalf("absence reservation %d = found %v, err %v", index, found, errGet)
|
||||
}
|
||||
}
|
||||
latestKey := antigravityReasoningReplayCacheKey(model, latestSession)
|
||||
antigravityReasoningReplayMu.Lock()
|
||||
entryCount := len(antigravityReasoningReplayEntries)
|
||||
latestEntry, latestFound := antigravityReasoningReplayEntries[latestKey]
|
||||
antigravityReasoningReplayMu.Unlock()
|
||||
if entryCount > AntigravityReasoningReplayCacheMaxEntries {
|
||||
t.Fatalf("local absence reservation count = %d, max %d", entryCount, AntigravityReasoningReplayCacheMaxEntries)
|
||||
}
|
||||
if !latestFound || !latestEntry.Deleted {
|
||||
t.Fatal("latest local absence reservation was evicted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntigravityReasoningReplayHomeWritesRemainLegacyArrayReadable(t *testing.T) {
|
||||
client := newFakeAntigravityReasoningReplayKVClient()
|
||||
useFakeAntigravityReasoningReplayKVClient(t, client, true)
|
||||
const model, session = "gemini-3.6-flash-high", "home-legacy-readable"
|
||||
item := antigravityReplayTestItem("legacy-readable-signature-123456")
|
||||
if !CacheAntigravityReasoningReplayItems(model, session, [][]byte{item}) {
|
||||
t.Fatal("Home write failed")
|
||||
}
|
||||
raw := client.values[antigravityReasoningReplayKVKey(model, session)]
|
||||
var legacyItems [][]byte
|
||||
if errUnmarshal := json.Unmarshal(raw, &legacyItems); errUnmarshal != nil {
|
||||
t.Fatalf("new Home value is not readable as legacy [][]byte: %v", errUnmarshal)
|
||||
}
|
||||
if len(legacyItems) != 2 || gjson.GetBytes(legacyItems[0], "type").String() != antigravityReasoningReplayGenerationItemType || !bytes.Contains(legacyItems[1], []byte("legacy-readable-signature")) {
|
||||
t.Fatalf("legacy-readable Home array malformed: %q", legacyItems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntigravityReasoningReplayHomeReadNormalizesAndRejectsMixedInvalidChain(t *testing.T) {
|
||||
client := newFakeAntigravityReasoningReplayKVClient()
|
||||
useFakeAntigravityReasoningReplayKVClient(t, client, true)
|
||||
const model, session = "gemini-3.6-flash-high", "home-validation"
|
||||
key := antigravityReasoningReplayKVKey(model, session)
|
||||
valid := []byte(`{"type":"function_call_part","name":"run","args":{"b":2,"a":1},"targetOccurrence":1,"thoughtSignature":"valid-home-signature-123456"}`)
|
||||
raw, errMarshal := json.Marshal([][]byte{valid})
|
||||
if errMarshal != nil {
|
||||
t.Fatal(errMarshal)
|
||||
}
|
||||
client.values[key] = raw
|
||||
items, _, found, errGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session)
|
||||
if errGet != nil || !found || len(items) != 1 {
|
||||
t.Fatalf("valid Home read = %q, found=%v err=%v", items, found, errGet)
|
||||
}
|
||||
if !bytes.Contains(items[0], []byte(`"targetOccurrence":1`)) {
|
||||
t.Fatalf("target occurrence was not normalized: %s", items[0])
|
||||
}
|
||||
if client.expireCount != 1 {
|
||||
t.Fatalf("valid Home read expire count = %d, want 1", client.expireCount)
|
||||
}
|
||||
|
||||
invalidRaw, errInvalidMarshal := json.Marshal([][]byte{valid, []byte(`{"type":"unknown"}`)})
|
||||
if errInvalidMarshal != nil {
|
||||
t.Fatal(errInvalidMarshal)
|
||||
}
|
||||
client.values[key] = invalidRaw
|
||||
if _, _, foundInvalid, errInvalid := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session); errInvalid != nil || foundInvalid {
|
||||
t.Fatalf("mixed invalid Home chain = found %v, err %v; want false, nil", foundInvalid, errInvalid)
|
||||
}
|
||||
if client.expireCount != 1 {
|
||||
t.Fatalf("invalid Home read refreshed TTL: count=%d", client.expireCount)
|
||||
}
|
||||
}
|
||||
83
backend/internal/cache/bounded_lru.go
vendored
Normal file
83
backend/internal/cache/bounded_lru.go
vendored
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type boundedLRUEntry[K comparable, V any] struct {
|
||||
key K
|
||||
value V
|
||||
}
|
||||
|
||||
// BoundedLRU stores at most capacity values and evicts the least recently used
|
||||
// value when a new key crosses the bound. The optional eviction callback runs
|
||||
// after the cache lock is released.
|
||||
type BoundedLRU[K comparable, V any] struct {
|
||||
mu sync.Mutex
|
||||
capacity int
|
||||
entries map[K]*list.Element
|
||||
order *list.List
|
||||
onEvict func(K, V)
|
||||
}
|
||||
|
||||
func NewBoundedLRU[K comparable, V any](capacity int, onEvict func(K, V)) *BoundedLRU[K, V] {
|
||||
if capacity < 1 {
|
||||
capacity = 1
|
||||
}
|
||||
return &BoundedLRU[K, V]{
|
||||
capacity: capacity,
|
||||
entries: make(map[K]*list.Element, capacity),
|
||||
order: list.New(),
|
||||
onEvict: onEvict,
|
||||
}
|
||||
}
|
||||
|
||||
// GetOrAdd returns the cached value or creates and stores one while holding the
|
||||
// cache lock. The create function must not call back into this cache.
|
||||
func (cache *BoundedLRU[K, V]) GetOrAdd(key K, create func() V) V {
|
||||
cache.mu.Lock()
|
||||
if element, ok := cache.entries[key]; ok {
|
||||
cache.order.MoveToFront(element)
|
||||
value := element.Value.(boundedLRUEntry[K, V]).value
|
||||
cache.mu.Unlock()
|
||||
return value
|
||||
}
|
||||
|
||||
value := create()
|
||||
element := cache.order.PushFront(boundedLRUEntry[K, V]{key: key, value: value})
|
||||
cache.entries[key] = element
|
||||
|
||||
var evicted boundedLRUEntry[K, V]
|
||||
didEvict := false
|
||||
if cache.order.Len() > cache.capacity {
|
||||
oldest := cache.order.Back()
|
||||
evicted = oldest.Value.(boundedLRUEntry[K, V])
|
||||
delete(cache.entries, evicted.key)
|
||||
cache.order.Remove(oldest)
|
||||
didEvict = true
|
||||
}
|
||||
cache.mu.Unlock()
|
||||
|
||||
if didEvict && cache.onEvict != nil {
|
||||
cache.onEvict(evicted.key, evicted.value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (cache *BoundedLRU[K, V]) Get(key K) (V, bool) {
|
||||
cache.mu.Lock()
|
||||
defer cache.mu.Unlock()
|
||||
if element, ok := cache.entries[key]; ok {
|
||||
cache.order.MoveToFront(element)
|
||||
return element.Value.(boundedLRUEntry[K, V]).value, true
|
||||
}
|
||||
var zero V
|
||||
return zero, false
|
||||
}
|
||||
|
||||
func (cache *BoundedLRU[K, V]) Len() int {
|
||||
cache.mu.Lock()
|
||||
defer cache.mu.Unlock()
|
||||
return len(cache.entries)
|
||||
}
|
||||
57
backend/internal/cache/bounded_lru_test.go
vendored
Normal file
57
backend/internal/cache/bounded_lru_test.go
vendored
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package cache
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBoundedLRUEvictsLeastRecentlyUsed(t *testing.T) {
|
||||
var evicted []string
|
||||
cache := NewBoundedLRU[string, string](2, func(key, value string) {
|
||||
evicted = append(evicted, key+"="+value)
|
||||
})
|
||||
|
||||
if got := cache.GetOrAdd("a", func() string { return "A" }); got != "A" {
|
||||
t.Fatalf("first value = %q, want A", got)
|
||||
}
|
||||
cache.GetOrAdd("b", func() string { return "B" })
|
||||
if got, found := cache.Get("a"); !found || got != "A" {
|
||||
t.Fatalf("Get(a) = %q/%t, want A/true", got, found)
|
||||
}
|
||||
cache.GetOrAdd("c", func() string { return "C" })
|
||||
|
||||
if _, found := cache.Get("b"); found {
|
||||
t.Fatal("least recently used entry b was not evicted")
|
||||
}
|
||||
if got := cache.Len(); got != 2 {
|
||||
t.Fatalf("Len() = %d, want 2", got)
|
||||
}
|
||||
if len(evicted) != 1 || evicted[0] != "b=B" {
|
||||
t.Fatalf("evicted = %v, want [b=B]", evicted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedLRUCreatesOneValuePerKeyConcurrently(t *testing.T) {
|
||||
cache := NewBoundedLRU[string, int](2, nil)
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
results := make(chan int, 2)
|
||||
creates := make(chan struct{}, 2)
|
||||
|
||||
create := func() int {
|
||||
creates <- struct{}{}
|
||||
close(started)
|
||||
<-release
|
||||
return 42
|
||||
}
|
||||
go func() { results <- cache.GetOrAdd("key", create) }()
|
||||
<-started
|
||||
go func() { results <- cache.GetOrAdd("key", func() int { creates <- struct{}{}; return 7 }) }()
|
||||
close(release)
|
||||
|
||||
for range 2 {
|
||||
if got := <-results; got != 42 {
|
||||
t.Fatalf("cached value = %d, want 42", got)
|
||||
}
|
||||
}
|
||||
if got := len(creates); got != 1 {
|
||||
t.Fatalf("create calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
482
backend/internal/cache/claude_thinking_replay_cache.go
vendored
Normal file
482
backend/internal/cache/claude_thinking_replay_cache.go
vendored
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
homekv "github.com/router-for-me/CLIProxyAPI/v7/internal/home"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const (
|
||||
// ClaudeThinkingReplayCacheTTL limits how long signed assistant turns stay replayable.
|
||||
ClaudeThinkingReplayCacheTTL = 1 * time.Hour
|
||||
|
||||
// ClaudeThinkingReplayCacheMaxEntries bounds process memory used by Claude replay continuity.
|
||||
ClaudeThinkingReplayCacheMaxEntries = 10240
|
||||
|
||||
// ClaudeThinkingReplayCacheEvictBatchSize leaves headroom after reaching capacity.
|
||||
ClaudeThinkingReplayCacheEvictBatchSize = 128
|
||||
|
||||
// ClaudeThinkingReplayCacheMaxBytesPerSession bounds all cached assistant turns for one session.
|
||||
ClaudeThinkingReplayCacheMaxBytesPerSession = 8 << 20
|
||||
|
||||
// ClaudeThinkingReplayCacheMaxTurnsPerSession bounds the number of assistant turns per session.
|
||||
ClaudeThinkingReplayCacheMaxTurnsPerSession = 64
|
||||
|
||||
// ClaudeThinkingReplayCacheMaxBlocksPerTurn prevents pathological content arrays.
|
||||
ClaudeThinkingReplayCacheMaxBlocksPerTurn = 512
|
||||
|
||||
// ClaudeThinkingReplayCacheMaxTotalBytes bounds aggregate in-process Claude replay content.
|
||||
ClaudeThinkingReplayCacheMaxTotalBytes = 256 << 20
|
||||
|
||||
claudeThinkingReplayCacheMaxSerializedBytes = ClaudeThinkingReplayCacheMaxBytesPerSession + 1024
|
||||
)
|
||||
|
||||
type claudeThinkingReplayEntry struct {
|
||||
Contents [][]byte
|
||||
Timestamp time.Time
|
||||
Generation string
|
||||
Deleted bool
|
||||
}
|
||||
|
||||
// ClaudeThinkingReplaySnapshot identifies the exact replay generation read for one request.
|
||||
type ClaudeThinkingReplaySnapshot = KimiThinkingReplaySnapshot
|
||||
|
||||
type claudeThinkingReplayHomeValue struct {
|
||||
Generation string `json:"generation"`
|
||||
Deleted bool `json:"deleted,omitempty"`
|
||||
Contents []json.RawMessage `json:"contents,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
claudeThinkingReplayMu sync.Mutex
|
||||
claudeThinkingReplayEntries = make(map[string]claudeThinkingReplayEntry)
|
||||
claudeThinkingReplayTotalBytes int
|
||||
)
|
||||
|
||||
var currentClaudeThinkingReplayKVClient = func() (kimiThinkingReplayKVClient, bool, error) {
|
||||
return homekv.CurrentKVClient()
|
||||
}
|
||||
|
||||
// CacheClaudeThinkingReplayBestEffort stores one complete signed assistant content array.
|
||||
func CacheClaudeThinkingReplayBestEffort(ctx context.Context, modelFamily, sessionKey string, content []byte) bool {
|
||||
key := claudeThinkingReplayCacheKey(modelFamily, sessionKey)
|
||||
if key == "" || !validClaudeThinkingReplayContent(content) {
|
||||
return false
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
contents := [][]byte{append([]byte(nil), content...)}
|
||||
generation := uuid.NewString()
|
||||
if client, homeMode, errClient := currentClaudeThinkingReplayKVClient(); homeMode {
|
||||
if errClient != nil {
|
||||
log.Errorf("home kv best-effort Claude thinking replay set failed: %v", errClient)
|
||||
return false
|
||||
}
|
||||
raw, errMarshal := marshalClaudeThinkingReplayHomeValue(generation, false, contents)
|
||||
if errMarshal != nil {
|
||||
log.Errorf("home kv best-effort Claude thinking replay set failed: %v", errMarshal)
|
||||
return false
|
||||
}
|
||||
written, errSet := client.KVSet(ctx, claudeThinkingReplayKVKey(modelFamily, sessionKey), raw, homekv.KVSetOptions{EX: ClaudeThinkingReplayCacheTTL})
|
||||
if errSet != nil {
|
||||
log.Errorf("home kv best-effort Claude thinking replay set failed: %v", errSet)
|
||||
return false
|
||||
}
|
||||
return written
|
||||
}
|
||||
|
||||
storeClaudeThinkingReplayLocal(key, contents, generation, false, time.Now())
|
||||
return true
|
||||
}
|
||||
|
||||
// GetClaudeThinkingReplayRequired retrieves all cached assistant turns for request-time replay.
|
||||
func GetClaudeThinkingReplayRequired(ctx context.Context, modelFamily, sessionKey string) ([][]byte, bool, error) {
|
||||
contents, _, found, errGet := GetClaudeThinkingReplayWithSnapshotRequired(ctx, modelFamily, sessionKey)
|
||||
return contents, found, errGet
|
||||
}
|
||||
|
||||
// GetClaudeThinkingReplayWithSnapshotRequired retrieves replay content and the exact cache state read.
|
||||
func GetClaudeThinkingReplayWithSnapshotRequired(ctx context.Context, modelFamily, sessionKey string) ([][]byte, ClaudeThinkingReplaySnapshot, bool, error) {
|
||||
key := claudeThinkingReplayCacheKey(modelFamily, sessionKey)
|
||||
if key == "" {
|
||||
return nil, ClaudeThinkingReplaySnapshot{}, false, nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
client, homeMode, errClient := currentClaudeThinkingReplayKVClient()
|
||||
if homeMode {
|
||||
if errClient != nil {
|
||||
return nil, ClaudeThinkingReplaySnapshot{loaded: true}, false, errClient
|
||||
}
|
||||
kvKey := claudeThinkingReplayKVKey(modelFamily, sessionKey)
|
||||
raw, errRead := readOrReserveClaudeThinkingReplayHomeValue(ctx, client, kvKey)
|
||||
if errRead != nil {
|
||||
return nil, ClaudeThinkingReplaySnapshot{loaded: true}, false, errRead
|
||||
}
|
||||
snapshot := ClaudeThinkingReplaySnapshot{raw: append([]byte(nil), raw...), loaded: true, found: true}
|
||||
contents, generation, deleted, okDecode := decodeClaudeThinkingReplayHomeValue(raw)
|
||||
if !okDecode {
|
||||
return nil, snapshot, false, fmt.Errorf("invalid Claude thinking replay content")
|
||||
}
|
||||
snapshot.generation = generation
|
||||
if _, errExpire := client.KVExpire(ctx, kvKey, ClaudeThinkingReplayCacheTTL); errExpire != nil {
|
||||
log.Warnf("home kv Claude thinking replay expire failed: %v", errExpire)
|
||||
}
|
||||
if deleted {
|
||||
return nil, snapshot, false, nil
|
||||
}
|
||||
return cloneClaudeThinkingReplayContents(contents), snapshot, len(contents) > 0, nil
|
||||
}
|
||||
|
||||
cacheCleanupOnce.Do(startCacheCleanup)
|
||||
now := time.Now()
|
||||
claudeThinkingReplayMu.Lock()
|
||||
defer claudeThinkingReplayMu.Unlock()
|
||||
entry, ok := claudeThinkingReplayEntries[key]
|
||||
if !ok || now.Sub(entry.Timestamp) > ClaudeThinkingReplayCacheTTL {
|
||||
if ok {
|
||||
claudeThinkingReplayTotalBytes -= claudeThinkingReplayEntryBytes(entry.Contents)
|
||||
delete(claudeThinkingReplayEntries, key)
|
||||
}
|
||||
entry = reserveClaudeThinkingReplayLocalLocked(key, now)
|
||||
}
|
||||
entry.Timestamp = now
|
||||
claudeThinkingReplayEntries[key] = entry
|
||||
snapshot := ClaudeThinkingReplaySnapshot{generation: entry.Generation, loaded: true, found: true}
|
||||
if entry.Deleted {
|
||||
return nil, snapshot, false, nil
|
||||
}
|
||||
return cloneClaudeThinkingReplayContents(entry.Contents), snapshot, len(entry.Contents) > 0, nil
|
||||
}
|
||||
|
||||
// ReplaceClaudeThinkingReplayIfUnchanged appends a completed assistant turn only if the request snapshot is current.
|
||||
func ReplaceClaudeThinkingReplayIfUnchanged(ctx context.Context, modelFamily, sessionKey string, snapshot ClaudeThinkingReplaySnapshot, content []byte) (bool, error) {
|
||||
key := claudeThinkingReplayCacheKey(modelFamily, sessionKey)
|
||||
if key == "" || !validClaudeThinkingReplayContent(content) {
|
||||
return false, nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if !snapshot.loaded {
|
||||
return CacheClaudeThinkingReplayBestEffort(ctx, modelFamily, sessionKey, content), nil
|
||||
}
|
||||
client, homeMode, errClient := currentClaudeThinkingReplayKVClient()
|
||||
if homeMode {
|
||||
if errClient != nil {
|
||||
return false, errClient
|
||||
}
|
||||
contents, _, deleted, okDecode := decodeClaudeThinkingReplayHomeValue(snapshot.raw)
|
||||
if !okDecode {
|
||||
return false, fmt.Errorf("invalid Claude thinking replay snapshot")
|
||||
}
|
||||
if deleted {
|
||||
contents = nil
|
||||
}
|
||||
contents = appendClaudeThinkingReplayContent(contents, content)
|
||||
generation := uuid.NewString()
|
||||
raw, errMarshal := marshalClaudeThinkingReplayHomeValue(generation, false, contents)
|
||||
if errMarshal != nil {
|
||||
return false, errMarshal
|
||||
}
|
||||
return client.KVCompareAndSwap(ctx, claudeThinkingReplayKVKey(modelFamily, sessionKey), snapshot.raw, snapshot.found, raw, ClaudeThinkingReplayCacheTTL)
|
||||
}
|
||||
|
||||
claudeThinkingReplayMu.Lock()
|
||||
defer claudeThinkingReplayMu.Unlock()
|
||||
entry, found := claudeThinkingReplayEntries[key]
|
||||
if found != snapshot.found || (found && entry.Generation != snapshot.generation) {
|
||||
return false, nil
|
||||
}
|
||||
contents := appendClaudeThinkingReplayContent(entry.Contents, content)
|
||||
claudeThinkingReplayTotalBytes -= claudeThinkingReplayEntryBytes(entry.Contents)
|
||||
claudeThinkingReplayTotalBytes += claudeThinkingReplayEntryBytes(contents)
|
||||
claudeThinkingReplayEntries[key] = claudeThinkingReplayEntry{
|
||||
Contents: contents,
|
||||
Timestamp: time.Now(),
|
||||
Generation: uuid.NewString(),
|
||||
}
|
||||
enforceClaudeThinkingReplayLimitsLocked()
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// DeleteClaudeThinkingReplayIfUnchanged clears replay state only if the request snapshot is current.
|
||||
func DeleteClaudeThinkingReplayIfUnchanged(ctx context.Context, modelFamily, sessionKey string, snapshot ClaudeThinkingReplaySnapshot) (bool, error) {
|
||||
key := claudeThinkingReplayCacheKey(modelFamily, sessionKey)
|
||||
if key == "" {
|
||||
return false, nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if !snapshot.loaded {
|
||||
return true, DeleteClaudeThinkingReplayRequired(ctx, modelFamily, sessionKey)
|
||||
}
|
||||
generation := uuid.NewString()
|
||||
client, homeMode, errClient := currentClaudeThinkingReplayKVClient()
|
||||
if homeMode {
|
||||
if errClient != nil {
|
||||
return false, errClient
|
||||
}
|
||||
tombstone, errMarshal := marshalClaudeThinkingReplayHomeValue(generation, true, nil)
|
||||
if errMarshal != nil {
|
||||
return false, errMarshal
|
||||
}
|
||||
return client.KVCompareAndSwap(ctx, claudeThinkingReplayKVKey(modelFamily, sessionKey), snapshot.raw, snapshot.found, tombstone, ClaudeThinkingReplayCacheTTL)
|
||||
}
|
||||
|
||||
claudeThinkingReplayMu.Lock()
|
||||
defer claudeThinkingReplayMu.Unlock()
|
||||
entry, found := claudeThinkingReplayEntries[key]
|
||||
if found != snapshot.found || (found && entry.Generation != snapshot.generation) {
|
||||
return false, nil
|
||||
}
|
||||
claudeThinkingReplayTotalBytes -= claudeThinkingReplayEntryBytes(entry.Contents)
|
||||
claudeThinkingReplayEntries[key] = claudeThinkingReplayEntry{Timestamp: time.Now(), Generation: generation, Deleted: true}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// DeleteClaudeThinkingReplayRequired removes stale replay state unconditionally.
|
||||
func DeleteClaudeThinkingReplayRequired(ctx context.Context, modelFamily, sessionKey string) error {
|
||||
key := claudeThinkingReplayCacheKey(modelFamily, sessionKey)
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
client, homeMode, errClient := currentClaudeThinkingReplayKVClient()
|
||||
if homeMode {
|
||||
if errClient != nil {
|
||||
return errClient
|
||||
}
|
||||
_, errDelete := client.KVDel(ctx, claudeThinkingReplayKVKey(modelFamily, sessionKey))
|
||||
return errDelete
|
||||
}
|
||||
claudeThinkingReplayMu.Lock()
|
||||
if entry, found := claudeThinkingReplayEntries[key]; found {
|
||||
claudeThinkingReplayTotalBytes -= claudeThinkingReplayEntryBytes(entry.Contents)
|
||||
delete(claudeThinkingReplayEntries, key)
|
||||
}
|
||||
claudeThinkingReplayMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearClaudeThinkingReplayCache clears only Claude replay state.
|
||||
func ClearClaudeThinkingReplayCache() {
|
||||
claudeThinkingReplayMu.Lock()
|
||||
claudeThinkingReplayEntries = make(map[string]claudeThinkingReplayEntry)
|
||||
claudeThinkingReplayTotalBytes = 0
|
||||
claudeThinkingReplayMu.Unlock()
|
||||
}
|
||||
|
||||
func readOrReserveClaudeThinkingReplayHomeValue(ctx context.Context, client kimiThinkingReplayKVClient, key string) ([]byte, error) {
|
||||
for attempt := 0; attempt < 4; attempt++ {
|
||||
raw, found, errGet := client.KVGet(ctx, key)
|
||||
if errGet != nil {
|
||||
return nil, errGet
|
||||
}
|
||||
if found {
|
||||
if len(raw) > claudeThinkingReplayCacheMaxSerializedBytes {
|
||||
return nil, fmt.Errorf("Claude thinking replay value exceeds size limit")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
tombstone, errMarshal := marshalClaudeThinkingReplayHomeValue(uuid.NewString(), true, nil)
|
||||
if errMarshal != nil {
|
||||
return nil, errMarshal
|
||||
}
|
||||
swapped, errReserve := client.KVCompareAndSwap(ctx, key, nil, false, tombstone, ClaudeThinkingReplayCacheTTL)
|
||||
if errReserve != nil {
|
||||
return nil, errReserve
|
||||
}
|
||||
if swapped {
|
||||
return tombstone, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("could not reserve absent Claude thinking replay state")
|
||||
}
|
||||
|
||||
func marshalClaudeThinkingReplayHomeValue(generation string, deleted bool, contents [][]byte) ([]byte, error) {
|
||||
value := claudeThinkingReplayHomeValue{Generation: generation, Deleted: deleted}
|
||||
if !deleted {
|
||||
value.Contents = make([]json.RawMessage, 0, len(contents))
|
||||
for _, content := range contents {
|
||||
value.Contents = append(value.Contents, json.RawMessage(append([]byte(nil), content...)))
|
||||
}
|
||||
}
|
||||
return json.Marshal(value)
|
||||
}
|
||||
|
||||
func decodeClaudeThinkingReplayHomeValue(raw []byte) ([][]byte, string, bool, bool) {
|
||||
if len(raw) == 0 || len(raw) > claudeThinkingReplayCacheMaxSerializedBytes || !gjson.ValidBytes(raw) {
|
||||
return nil, "", false, false
|
||||
}
|
||||
var value claudeThinkingReplayHomeValue
|
||||
if errUnmarshal := json.Unmarshal(raw, &value); errUnmarshal != nil || strings.TrimSpace(value.Generation) == "" {
|
||||
return nil, "", false, false
|
||||
}
|
||||
if value.Deleted {
|
||||
return nil, value.Generation, true, true
|
||||
}
|
||||
contents := make([][]byte, 0, len(value.Contents))
|
||||
for _, content := range value.Contents {
|
||||
if !validClaudeThinkingReplayContent(content) {
|
||||
return nil, "", false, false
|
||||
}
|
||||
contents = append(contents, append([]byte(nil), content...))
|
||||
}
|
||||
if len(contents) == 0 {
|
||||
return nil, "", false, false
|
||||
}
|
||||
return contents, value.Generation, false, true
|
||||
}
|
||||
|
||||
func reserveClaudeThinkingReplayLocalLocked(key string, now time.Time) claudeThinkingReplayEntry {
|
||||
entry := claudeThinkingReplayEntry{Timestamp: now, Generation: uuid.NewString(), Deleted: true}
|
||||
claudeThinkingReplayEntries[key] = entry
|
||||
enforceClaudeThinkingReplayLimitsLocked()
|
||||
return entry
|
||||
}
|
||||
|
||||
func storeClaudeThinkingReplayLocal(key string, contents [][]byte, generation string, deleted bool, now time.Time) {
|
||||
cacheCleanupOnce.Do(startCacheCleanup)
|
||||
claudeThinkingReplayMu.Lock()
|
||||
defer claudeThinkingReplayMu.Unlock()
|
||||
if previous, found := claudeThinkingReplayEntries[key]; found {
|
||||
claudeThinkingReplayTotalBytes -= claudeThinkingReplayEntryBytes(previous.Contents)
|
||||
}
|
||||
cloned := cloneClaudeThinkingReplayContents(contents)
|
||||
claudeThinkingReplayTotalBytes += claudeThinkingReplayEntryBytes(cloned)
|
||||
claudeThinkingReplayEntries[key] = claudeThinkingReplayEntry{Contents: cloned, Timestamp: now, Generation: generation, Deleted: deleted}
|
||||
enforceClaudeThinkingReplayLimitsLocked()
|
||||
}
|
||||
|
||||
func appendClaudeThinkingReplayContent(contents [][]byte, content []byte) [][]byte {
|
||||
cloned := cloneClaudeThinkingReplayContents(contents)
|
||||
for _, existing := range cloned {
|
||||
if claudeThinkingReplayJSONEqual(existing, content) {
|
||||
return cloned
|
||||
}
|
||||
}
|
||||
cloned = append(cloned, append([]byte(nil), content...))
|
||||
for len(cloned) > ClaudeThinkingReplayCacheMaxTurnsPerSession || claudeThinkingReplayEntryBytes(cloned) > ClaudeThinkingReplayCacheMaxBytesPerSession {
|
||||
if len(cloned) == 0 {
|
||||
break
|
||||
}
|
||||
cloned = cloned[1:]
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneClaudeThinkingReplayContents(contents [][]byte) [][]byte {
|
||||
cloned := make([][]byte, 0, len(contents))
|
||||
for _, content := range contents {
|
||||
cloned = append(cloned, append([]byte(nil), content...))
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func claudeThinkingReplayEntryBytes(contents [][]byte) int {
|
||||
total := 0
|
||||
for _, content := range contents {
|
||||
total += len(content)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func claudeThinkingReplayCacheKey(modelFamily, sessionKey string) string {
|
||||
modelFamily = strings.TrimSpace(modelFamily)
|
||||
sessionKey = strings.TrimSpace(sessionKey)
|
||||
if modelFamily == "" || sessionKey == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.Join([]string{"claude-thinking-replay", modelFamily, sessionKey}, "\x00")
|
||||
}
|
||||
|
||||
func claudeThinkingReplayKVKey(modelFamily, sessionKey string) string {
|
||||
return "cpa:claude:thinking-replay:" + homekv.HashKeyPart(strings.TrimSpace(modelFamily)) + ":" + homekv.HashKeyPart(strings.TrimSpace(sessionKey))
|
||||
}
|
||||
|
||||
func validClaudeThinkingReplayContent(content []byte) bool {
|
||||
if len(content) == 0 || len(content) > ClaudeThinkingReplayCacheMaxBytesPerSession || !gjson.ValidBytes(content) {
|
||||
return false
|
||||
}
|
||||
root := gjson.ParseBytes(content)
|
||||
return root.IsArray() && len(root.Array()) > 0 && len(root.Array()) <= ClaudeThinkingReplayCacheMaxBlocksPerTurn
|
||||
}
|
||||
|
||||
func claudeThinkingReplayJSONEqual(left, right []byte) bool {
|
||||
leftCanonical, leftOK := claudeThinkingReplayCanonicalJSON(left)
|
||||
rightCanonical, rightOK := claudeThinkingReplayCanonicalJSON(right)
|
||||
return leftOK && rightOK && bytes.Equal(leftCanonical, rightCanonical)
|
||||
}
|
||||
|
||||
func claudeThinkingReplayCanonicalJSON(raw []byte) ([]byte, bool) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if errDecode := decoder.Decode(&value); errDecode != nil {
|
||||
return nil, false
|
||||
}
|
||||
canonical, errMarshal := json.Marshal(value)
|
||||
return canonical, errMarshal == nil
|
||||
}
|
||||
|
||||
func enforceClaudeThinkingReplayLimitsLocked() {
|
||||
for len(claudeThinkingReplayEntries) > ClaudeThinkingReplayCacheMaxEntries || claudeThinkingReplayTotalBytes > ClaudeThinkingReplayCacheMaxTotalBytes {
|
||||
if len(claudeThinkingReplayEntries) == 0 {
|
||||
claudeThinkingReplayTotalBytes = 0
|
||||
return
|
||||
}
|
||||
evictOldestClaudeThinkingReplayEntriesLocked(ClaudeThinkingReplayCacheEvictBatchSize)
|
||||
}
|
||||
}
|
||||
|
||||
func evictOldestClaudeThinkingReplayEntriesLocked(count int) {
|
||||
if count <= 0 || len(claudeThinkingReplayEntries) == 0 {
|
||||
return
|
||||
}
|
||||
type candidate struct {
|
||||
key string
|
||||
timestamp time.Time
|
||||
}
|
||||
candidates := make([]candidate, 0, len(claudeThinkingReplayEntries))
|
||||
for key, entry := range claudeThinkingReplayEntries {
|
||||
candidates = append(candidates, candidate{key: key, timestamp: entry.Timestamp})
|
||||
}
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
return candidates[i].timestamp.Before(candidates[j].timestamp)
|
||||
})
|
||||
if count > len(candidates) {
|
||||
count = len(candidates)
|
||||
}
|
||||
for i := 0; i < count; i++ {
|
||||
entry := claudeThinkingReplayEntries[candidates[i].key]
|
||||
claudeThinkingReplayTotalBytes -= claudeThinkingReplayEntryBytes(entry.Contents)
|
||||
delete(claudeThinkingReplayEntries, candidates[i].key)
|
||||
}
|
||||
}
|
||||
|
||||
func purgeExpiredClaudeThinkingReplayCache(now time.Time) {
|
||||
claudeThinkingReplayMu.Lock()
|
||||
for key, entry := range claudeThinkingReplayEntries {
|
||||
if now.Sub(entry.Timestamp) > ClaudeThinkingReplayCacheTTL {
|
||||
claudeThinkingReplayTotalBytes -= claudeThinkingReplayEntryBytes(entry.Contents)
|
||||
delete(claudeThinkingReplayEntries, key)
|
||||
}
|
||||
}
|
||||
claudeThinkingReplayMu.Unlock()
|
||||
}
|
||||
89
backend/internal/cache/claude_thinking_replay_cache_test.go
vendored
Normal file
89
backend/internal/cache/claude_thinking_replay_cache_test.go
vendored
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func useFakeClaudeThinkingReplayKVClient(t *testing.T, client *fakeKimiThinkingReplayKVClient) {
|
||||
t.Helper()
|
||||
previous := currentClaudeThinkingReplayKVClient
|
||||
currentClaudeThinkingReplayKVClient = func() (kimiThinkingReplayKVClient, bool, error) {
|
||||
return client, true, nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
currentClaudeThinkingReplayKVClient = previous
|
||||
})
|
||||
}
|
||||
|
||||
func TestClaudeThinkingReplayAppendsAssistantTurns(t *testing.T) {
|
||||
client := newFakeKimiThinkingReplayKVClient()
|
||||
useFakeClaudeThinkingReplayKVClient(t, client)
|
||||
|
||||
const modelFamily = "claude:auth:model"
|
||||
const sessionKey = "execution:multi-turn"
|
||||
first := []byte(`[{"type":"thinking","thinking":"first","signature":"sig-1"},{"type":"tool_use","id":"toolu-1","name":"Read","input":{"path":"one"}}]`)
|
||||
second := []byte(`[{"type":"thinking","thinking":"second","signature":"sig-2"},{"type":"tool_use","id":"toolu-2","name":"Read","input":{"path":"two"}}]`)
|
||||
|
||||
if !CacheClaudeThinkingReplayBestEffort(context.Background(), modelFamily, sessionKey, first) {
|
||||
t.Fatal("failed to seed first Claude replay turn")
|
||||
}
|
||||
_, snapshot, found, errGet := GetClaudeThinkingReplayWithSnapshotRequired(context.Background(), modelFamily, sessionKey)
|
||||
if errGet != nil || !found {
|
||||
t.Fatalf("initial Claude replay read = found %v, error %v", found, errGet)
|
||||
}
|
||||
replaced, errReplace := ReplaceClaudeThinkingReplayIfUnchanged(context.Background(), modelFamily, sessionKey, snapshot, second)
|
||||
if errReplace != nil || !replaced {
|
||||
t.Fatalf("append Claude replay turn = replaced %v, error %v", replaced, errReplace)
|
||||
}
|
||||
|
||||
contents, found, errGet := GetClaudeThinkingReplayRequired(context.Background(), modelFamily, sessionKey)
|
||||
if errGet != nil || !found || len(contents) != 2 {
|
||||
t.Fatalf("Claude replay contents = %d, found %v, error %v; want two turns", len(contents), found, errGet)
|
||||
}
|
||||
if !bytes.Equal(contents[0], first) || !bytes.Equal(contents[1], second) {
|
||||
t.Fatalf("Claude replay contents lost ordering: got %s / %s", contents[0], contents[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeThinkingReplayClearDoesNotClearKimiState(t *testing.T) {
|
||||
previousClaudeClient := currentClaudeThinkingReplayKVClient
|
||||
previousKimiClient := currentKimiThinkingReplayKVClient
|
||||
currentClaudeThinkingReplayKVClient = func() (kimiThinkingReplayKVClient, bool, error) {
|
||||
return nil, false, nil
|
||||
}
|
||||
currentKimiThinkingReplayKVClient = func() (kimiThinkingReplayKVClient, bool, error) {
|
||||
return nil, false, nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
currentClaudeThinkingReplayKVClient = previousClaudeClient
|
||||
currentKimiThinkingReplayKVClient = previousKimiClient
|
||||
})
|
||||
ClearClaudeThinkingReplayCache()
|
||||
ClearKimiThinkingReplayCache()
|
||||
t.Cleanup(ClearClaudeThinkingReplayCache)
|
||||
t.Cleanup(ClearKimiThinkingReplayCache)
|
||||
|
||||
const modelFamily = "shared-model"
|
||||
const sessionKey = "execution:shared-session"
|
||||
kimiContent := []byte(`[{"type":"thinking","signature":"kimi"}]`)
|
||||
claudeContent := []byte(`[{"type":"thinking","signature":"claude"}]`)
|
||||
if !CacheKimiThinkingReplayBestEffort(context.Background(), modelFamily, sessionKey, kimiContent) {
|
||||
t.Fatal("failed to seed Kimi replay state")
|
||||
}
|
||||
if !CacheClaudeThinkingReplayBestEffort(context.Background(), modelFamily, sessionKey, claudeContent) {
|
||||
t.Fatal("failed to seed Claude replay state")
|
||||
}
|
||||
|
||||
ClearClaudeThinkingReplayCache()
|
||||
|
||||
gotKimi, foundKimi, errKimi := GetKimiThinkingReplayRequired(context.Background(), modelFamily, sessionKey)
|
||||
if errKimi != nil || !foundKimi || !bytes.Equal(gotKimi, kimiContent) {
|
||||
t.Fatalf("Kimi replay after Claude clear = %s, found %v, error %v; want preserved state", gotKimi, foundKimi, errKimi)
|
||||
}
|
||||
gotClaude, foundClaude, errClaude := GetClaudeThinkingReplayRequired(context.Background(), modelFamily, sessionKey)
|
||||
if errClaude != nil || foundClaude || len(gotClaude) != 0 {
|
||||
t.Fatalf("Claude replay after Claude clear = %d turns, found %v, error %v; want cleared state", len(gotClaude), foundClaude, errClaude)
|
||||
}
|
||||
}
|
||||
493
backend/internal/cache/codex_reasoning_replay_cache.go
vendored
Normal file
493
backend/internal/cache/codex_reasoning_replay_cache.go
vendored
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
homekv "github.com/router-for-me/CLIProxyAPI/v7/internal/home"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
const (
|
||||
// CodexReasoningReplayTurnType identifies an internal turn-boundary marker.
|
||||
CodexReasoningReplayTurnType = "cpa_codex_replay_turn"
|
||||
|
||||
// CodexReasoningReplayCacheTTL limits how long encrypted reasoning replay
|
||||
// items stay in process memory.
|
||||
CodexReasoningReplayCacheTTL = 1 * time.Hour
|
||||
|
||||
// CodexReasoningReplayCacheMaxEntries bounds process memory for replay
|
||||
// continuity. Oldest entries are evicted first.
|
||||
CodexReasoningReplayCacheMaxEntries = 10240
|
||||
|
||||
// CodexReasoningReplayCacheMaxTurnsPerEntry bounds cumulative state for one agent.
|
||||
CodexReasoningReplayCacheMaxTurnsPerEntry = 256
|
||||
|
||||
// CodexReasoningReplayCacheMaxBytesPerEntry bounds cumulative serialized items for one agent.
|
||||
CodexReasoningReplayCacheMaxBytesPerEntry = 16 << 20
|
||||
|
||||
// CodexReasoningReplayCacheEvictBatchSize leaves headroom after the cache
|
||||
// reaches capacity so high write volume does not rescan the map every turn.
|
||||
CodexReasoningReplayCacheEvictBatchSize = 128
|
||||
)
|
||||
|
||||
type codexReasoningReplayEntry struct {
|
||||
Items [][]byte
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
codexReasoningReplayMu sync.Mutex
|
||||
codexReasoningReplayEntries = make(map[string]codexReasoningReplayEntry)
|
||||
)
|
||||
|
||||
type codexReasoningReplayKVClient interface {
|
||||
KVGet(ctx context.Context, key string) ([]byte, bool, error)
|
||||
KVSet(ctx context.Context, key string, value []byte, opts homekv.KVSetOptions) (bool, error)
|
||||
KVCompareAndSwap(ctx context.Context, key string, expected []byte, expectedExists bool, value []byte, ttl time.Duration) (bool, error)
|
||||
KVDel(ctx context.Context, keys ...string) (int64, error)
|
||||
KVExpire(ctx context.Context, key string, ttl time.Duration) (bool, error)
|
||||
}
|
||||
|
||||
var currentCodexReasoningReplayKVClient = func() (codexReasoningReplayKVClient, bool, error) {
|
||||
return homekv.CurrentKVClient()
|
||||
}
|
||||
|
||||
// CacheCodexReasoningReplayItem stores a final GPT/Codex reasoning item for
|
||||
// stateless replay. The stored item is normalized to the minimal shape accepted
|
||||
// by Responses input replay.
|
||||
func CacheCodexReasoningReplayItem(modelName, sessionKey string, item []byte) bool {
|
||||
return CacheCodexReasoningReplayItems(modelName, sessionKey, [][]byte{item})
|
||||
}
|
||||
|
||||
// CacheCodexReasoningReplayItems stores the final GPT/Codex assistant output
|
||||
// items needed to replay a stateless next turn.
|
||||
func CacheCodexReasoningReplayItems(modelName, sessionKey string, items [][]byte) bool {
|
||||
return CacheCodexReasoningReplayItemsBestEffort(context.Background(), modelName, sessionKey, items)
|
||||
}
|
||||
|
||||
// CacheCodexReasoningReplayItemsBestEffort stores replay items for completed response paths.
|
||||
func CacheCodexReasoningReplayItemsBestEffort(ctx context.Context, modelName, sessionKey string, items [][]byte) bool {
|
||||
key := codexReasoningReplayCacheKey(modelName, sessionKey)
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
normalized, ok := normalizeCodexReasoningReplayItems(items)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if client, homeMode, errClient := currentCodexReasoningReplayKVClient(); homeMode {
|
||||
if errClient != nil {
|
||||
log.Errorf("home kv best-effort codex reasoning replay set failed prefix=cpa:codex:*: %v", errClient)
|
||||
return false
|
||||
}
|
||||
raw, errMarshal := json.Marshal(normalized)
|
||||
if errMarshal != nil {
|
||||
log.Errorf("home kv best-effort codex reasoning replay set failed prefix=cpa:codex:*: %v", errMarshal)
|
||||
return false
|
||||
}
|
||||
written, errSet := client.KVSet(ctx, codexReasoningReplayKVKey(modelName, sessionKey), raw, homekv.KVSetOptions{EX: CodexReasoningReplayCacheTTL})
|
||||
if errSet != nil {
|
||||
log.Errorf("home kv best-effort codex reasoning replay set failed prefix=cpa:codex:*: %v", errSet)
|
||||
return false
|
||||
}
|
||||
return written
|
||||
}
|
||||
|
||||
cacheCleanupOnce.Do(startCacheCleanup)
|
||||
now := time.Now()
|
||||
codexReasoningReplayMu.Lock()
|
||||
defer codexReasoningReplayMu.Unlock()
|
||||
codexReasoningReplayEntries[key] = codexReasoningReplayEntry{
|
||||
Items: normalized,
|
||||
Timestamp: now,
|
||||
}
|
||||
if len(codexReasoningReplayEntries) > CodexReasoningReplayCacheMaxEntries {
|
||||
evictOldestCodexReasoningReplayEntries(CodexReasoningReplayCacheEvictBatchSize)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// AppendCodexReasoningReplayItemsBestEffort appends one completed turn to existing replay state.
|
||||
func AppendCodexReasoningReplayItemsBestEffort(ctx context.Context, modelName, sessionKey string, items [][]byte) bool {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
key := codexReasoningReplayCacheKey(modelName, sessionKey)
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
normalized, ok := normalizeCodexReasoningReplayItems(items)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if client, homeMode, errClient := currentCodexReasoningReplayKVClient(); homeMode {
|
||||
if errClient != nil {
|
||||
log.Errorf("home kv best-effort codex reasoning replay append failed prefix=cpa:codex:*: %v", errClient)
|
||||
return false
|
||||
}
|
||||
kvKey := codexReasoningReplayKVKey(modelName, sessionKey)
|
||||
const maxCASAttempts = 32
|
||||
for attempt := 0; attempt < maxCASAttempts; attempt++ {
|
||||
if errContext := ctx.Err(); errContext != nil {
|
||||
return false
|
||||
}
|
||||
existingRaw, found, errGet := client.KVGet(ctx, kvKey)
|
||||
if errGet != nil {
|
||||
log.Errorf("home kv best-effort codex reasoning replay append failed prefix=cpa:codex:*: %v", errGet)
|
||||
return false
|
||||
}
|
||||
var existing [][]byte
|
||||
if found {
|
||||
if errUnmarshal := json.Unmarshal(existingRaw, &existing); errUnmarshal != nil {
|
||||
log.Errorf("home kv best-effort codex reasoning replay append failed prefix=cpa:codex:*: %v", errUnmarshal)
|
||||
return false
|
||||
}
|
||||
}
|
||||
combined := appendCodexReasoningReplayTurn(existing, normalized)
|
||||
raw, errMarshal := json.Marshal(combined)
|
||||
if errMarshal != nil {
|
||||
log.Errorf("home kv best-effort codex reasoning replay append failed prefix=cpa:codex:*: %v", errMarshal)
|
||||
return false
|
||||
}
|
||||
written, errCAS := client.KVCompareAndSwap(ctx, kvKey, existingRaw, found, raw, CodexReasoningReplayCacheTTL)
|
||||
if errCAS != nil {
|
||||
log.Errorf("home kv best-effort codex reasoning replay append failed prefix=cpa:codex:*: %v", errCAS)
|
||||
return false
|
||||
}
|
||||
if written {
|
||||
return true
|
||||
}
|
||||
}
|
||||
log.Warn("home kv best-effort codex reasoning replay append exhausted compare-and-swap attempts")
|
||||
return false
|
||||
}
|
||||
|
||||
cacheCleanupOnce.Do(startCacheCleanup)
|
||||
now := time.Now()
|
||||
codexReasoningReplayMu.Lock()
|
||||
entry := codexReasoningReplayEntries[key]
|
||||
if now.Sub(entry.Timestamp) > CodexReasoningReplayCacheTTL {
|
||||
entry.Items = nil
|
||||
}
|
||||
entry.Items = appendCodexReasoningReplayTurn(entry.Items, normalized)
|
||||
entry.Timestamp = now
|
||||
codexReasoningReplayEntries[key] = entry
|
||||
if len(codexReasoningReplayEntries) > CodexReasoningReplayCacheMaxEntries {
|
||||
evictOldestCodexReasoningReplayEntries(CodexReasoningReplayCacheEvictBatchSize)
|
||||
}
|
||||
codexReasoningReplayMu.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
func appendCodexReasoningReplayTurn(existing, turn [][]byte) [][]byte {
|
||||
if len(existing) > 0 && strings.TrimSpace(gjson.GetBytes(existing[0], "type").String()) != CodexReasoningReplayTurnType {
|
||||
existing = nil
|
||||
}
|
||||
turnID := ""
|
||||
if len(turn) > 0 && strings.TrimSpace(gjson.GetBytes(turn[0], "type").String()) == CodexReasoningReplayTurnType {
|
||||
turnID = strings.TrimSpace(gjson.GetBytes(turn[0], "id").String())
|
||||
}
|
||||
if turnID != "" {
|
||||
for _, item := range existing {
|
||||
if strings.TrimSpace(gjson.GetBytes(item, "type").String()) == CodexReasoningReplayTurnType &&
|
||||
strings.TrimSpace(gjson.GetBytes(item, "id").String()) == turnID {
|
||||
return trimCodexReasoningReplayItems(cloneCodexReasoningReplayItems(existing))
|
||||
}
|
||||
}
|
||||
}
|
||||
combined := make([][]byte, 0, len(existing)+len(turn))
|
||||
combined = append(combined, cloneCodexReasoningReplayItems(existing)...)
|
||||
combined = append(combined, cloneCodexReasoningReplayItems(turn)...)
|
||||
return trimCodexReasoningReplayItems(combined)
|
||||
}
|
||||
|
||||
func trimCodexReasoningReplayItems(items [][]byte) [][]byte {
|
||||
for {
|
||||
turnStarts := []int{0}
|
||||
totalBytes := 0
|
||||
for index, item := range items {
|
||||
totalBytes += len(item)
|
||||
if index > 0 && strings.TrimSpace(gjson.GetBytes(item, "type").String()) == CodexReasoningReplayTurnType {
|
||||
turnStarts = append(turnStarts, index)
|
||||
}
|
||||
}
|
||||
if len(turnStarts) <= CodexReasoningReplayCacheMaxTurnsPerEntry && totalBytes <= CodexReasoningReplayCacheMaxBytesPerEntry {
|
||||
return items
|
||||
}
|
||||
if len(turnStarts) <= 1 {
|
||||
return nil
|
||||
}
|
||||
items = items[turnStarts[1]:]
|
||||
}
|
||||
}
|
||||
|
||||
// GetCodexReasoningReplayItem retrieves the first normalized upstream replay item.
|
||||
func GetCodexReasoningReplayItem(modelName, sessionKey string) ([]byte, bool) {
|
||||
items, ok := GetCodexReasoningReplayItems(modelName, sessionKey)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
for _, item := range items {
|
||||
if strings.TrimSpace(gjson.GetBytes(item, "type").String()) != CodexReasoningReplayTurnType {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// GetCodexReasoningReplayItems retrieves normalized assistant output items.
|
||||
func GetCodexReasoningReplayItems(modelName, sessionKey string) ([][]byte, bool) {
|
||||
items, ok, err := GetCodexReasoningReplayItemsRequired(context.Background(), modelName, sessionKey)
|
||||
if err == nil {
|
||||
return items, ok
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// GetCodexReasoningReplayItemsRequired retrieves replay items for request-time paths.
|
||||
func GetCodexReasoningReplayItemsRequired(ctx context.Context, modelName, sessionKey string) ([][]byte, bool, error) {
|
||||
key := codexReasoningReplayCacheKey(modelName, sessionKey)
|
||||
if key == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
client, homeMode, errClient := currentCodexReasoningReplayKVClient()
|
||||
if homeMode {
|
||||
if errClient != nil {
|
||||
return nil, false, errClient
|
||||
}
|
||||
raw, found, errGet := client.KVGet(ctx, codexReasoningReplayKVKey(modelName, sessionKey))
|
||||
if errGet != nil || !found {
|
||||
return nil, false, errGet
|
||||
}
|
||||
var homeItems [][]byte
|
||||
if errUnmarshal := json.Unmarshal(raw, &homeItems); errUnmarshal != nil {
|
||||
return nil, false, errUnmarshal
|
||||
}
|
||||
if _, errExpire := client.KVExpire(ctx, codexReasoningReplayKVKey(modelName, sessionKey), CodexReasoningReplayCacheTTL); errExpire != nil {
|
||||
return nil, false, errExpire
|
||||
}
|
||||
return cloneCodexReasoningReplayItems(homeItems), true, nil
|
||||
}
|
||||
|
||||
cacheCleanupOnce.Do(startCacheCleanup)
|
||||
now := time.Now()
|
||||
codexReasoningReplayMu.Lock()
|
||||
defer codexReasoningReplayMu.Unlock()
|
||||
entry, ok := codexReasoningReplayEntries[key]
|
||||
if !ok {
|
||||
return nil, false, nil
|
||||
}
|
||||
if now.Sub(entry.Timestamp) > CodexReasoningReplayCacheTTL {
|
||||
delete(codexReasoningReplayEntries, key)
|
||||
return nil, false, nil
|
||||
}
|
||||
entry.Timestamp = now
|
||||
codexReasoningReplayEntries[key] = entry
|
||||
return cloneCodexReasoningReplayItems(entry.Items), true, nil
|
||||
}
|
||||
|
||||
// DeleteCodexReasoningReplayItem removes one replay item after upstream rejects
|
||||
// it or the caller otherwise knows it is stale.
|
||||
func DeleteCodexReasoningReplayItem(modelName, sessionKey string) {
|
||||
if errDelete := DeleteCodexReasoningReplayItemRequired(context.Background(), modelName, sessionKey); errDelete != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteCodexReasoningReplayItemRequired removes one replay item for request-time paths.
|
||||
func DeleteCodexReasoningReplayItemRequired(ctx context.Context, modelName, sessionKey string) error {
|
||||
key := codexReasoningReplayCacheKey(modelName, sessionKey)
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
client, homeMode, errClient := currentCodexReasoningReplayKVClient()
|
||||
if homeMode {
|
||||
if errClient != nil {
|
||||
return errClient
|
||||
}
|
||||
_, errDel := client.KVDel(ctx, codexReasoningReplayKVKey(modelName, sessionKey))
|
||||
return errDel
|
||||
}
|
||||
codexReasoningReplayMu.Lock()
|
||||
delete(codexReasoningReplayEntries, key)
|
||||
codexReasoningReplayMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearCodexReasoningReplayCache clears all Codex reasoning replay state.
|
||||
func ClearCodexReasoningReplayCache() {
|
||||
codexReasoningReplayMu.Lock()
|
||||
codexReasoningReplayEntries = make(map[string]codexReasoningReplayEntry)
|
||||
codexReasoningReplayMu.Unlock()
|
||||
}
|
||||
|
||||
func codexReasoningReplayCacheKey(modelName, sessionKey string) string {
|
||||
modelName = strings.TrimSpace(modelName)
|
||||
sessionKey = strings.TrimSpace(sessionKey)
|
||||
if modelName == "" || sessionKey == "" {
|
||||
return ""
|
||||
}
|
||||
// The session key is the continuity boundary. Keep this independent from
|
||||
// the selected upstream Codex credential so auth failover can preserve replay.
|
||||
return strings.Join([]string{"codex-reasoning-replay", modelName, sessionKey}, "\x00")
|
||||
}
|
||||
|
||||
func codexReasoningReplayKVKey(modelName, sessionKey string) string {
|
||||
return "cpa:codex:reasoning-replay:" + homekv.HashKeyPart(strings.TrimSpace(modelName)) + ":" + homekv.HashKeyPart(strings.TrimSpace(sessionKey))
|
||||
}
|
||||
|
||||
func normalizeCodexReasoningReplayItems(items [][]byte) ([][]byte, bool) {
|
||||
normalized := make([][]byte, 0, len(items))
|
||||
for _, item := range items {
|
||||
normalizedItem, ok := normalizeCodexReasoningReplayItem(item)
|
||||
if ok {
|
||||
normalized = append(normalized, normalizedItem)
|
||||
}
|
||||
}
|
||||
normalized = trimCodexReasoningReplayItems(normalized)
|
||||
return normalized, len(normalized) > 0
|
||||
}
|
||||
|
||||
func normalizeCodexReasoningReplayItem(item []byte) ([]byte, bool) {
|
||||
itemResult := gjson.ParseBytes(item)
|
||||
switch strings.TrimSpace(itemResult.Get("type").String()) {
|
||||
case CodexReasoningReplayTurnType:
|
||||
return normalizeCodexReasoningReplayTurn(itemResult)
|
||||
case "reasoning":
|
||||
return normalizeCodexReasoningReplayReasoningItem(itemResult)
|
||||
case "function_call":
|
||||
return normalizeCodexReasoningReplayFunctionCallItem(itemResult)
|
||||
case "custom_tool_call":
|
||||
return normalizeCodexReasoningReplayCustomToolCallItem(itemResult)
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCodexReasoningReplayTurn(itemResult gjson.Result) ([]byte, bool) {
|
||||
turnID := strings.TrimSpace(itemResult.Get("id").String())
|
||||
if turnID == "" {
|
||||
return nil, false
|
||||
}
|
||||
normalized := []byte(`{"type":"` + CodexReasoningReplayTurnType + `"}`)
|
||||
normalized, _ = sjson.SetBytes(normalized, "id", turnID)
|
||||
if fingerprint := strings.TrimSpace(itemResult.Get("assistant_fingerprint").String()); fingerprint != "" {
|
||||
normalized, _ = sjson.SetBytes(normalized, "assistant_fingerprint", fingerprint)
|
||||
}
|
||||
if fingerprint := strings.TrimSpace(itemResult.Get("request_fingerprint").String()); fingerprint != "" {
|
||||
normalized, _ = sjson.SetBytes(normalized, "request_fingerprint", fingerprint)
|
||||
}
|
||||
callIDs := itemResult.Get("call_ids")
|
||||
if callIDs.IsArray() {
|
||||
for _, callIDResult := range callIDs.Array() {
|
||||
if callID := strings.TrimSpace(callIDResult.String()); callID != "" {
|
||||
normalized, _ = sjson.SetBytes(normalized, "call_ids.-1", callID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return normalized, true
|
||||
}
|
||||
|
||||
func normalizeCodexReasoningReplayReasoningItem(itemResult gjson.Result) ([]byte, bool) {
|
||||
encryptedContentResult := itemResult.Get("encrypted_content")
|
||||
if encryptedContentResult.Type != gjson.String {
|
||||
return nil, false
|
||||
}
|
||||
encryptedContent := encryptedContentResult.String()
|
||||
if encryptedContent != strings.TrimSpace(encryptedContent) {
|
||||
return nil, false
|
||||
}
|
||||
if _, err := signature.InspectGPTReasoningSignature(encryptedContent); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
normalized := []byte(`{"type":"reasoning","summary":[],"content":null}`)
|
||||
normalized, _ = sjson.SetBytes(normalized, "encrypted_content", encryptedContent)
|
||||
return normalized, true
|
||||
}
|
||||
|
||||
func normalizeCodexReasoningReplayFunctionCallItem(itemResult gjson.Result) ([]byte, bool) {
|
||||
callID := strings.TrimSpace(itemResult.Get("call_id").String())
|
||||
name := strings.TrimSpace(itemResult.Get("name").String())
|
||||
arguments := itemResult.Get("arguments")
|
||||
if callID == "" || name == "" || arguments.Type != gjson.String {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
normalized := []byte(`{"type":"function_call"}`)
|
||||
normalized, _ = sjson.SetBytes(normalized, "call_id", callID)
|
||||
normalized, _ = sjson.SetBytes(normalized, "name", name)
|
||||
normalized, _ = sjson.SetBytes(normalized, "arguments", arguments.String())
|
||||
return normalized, true
|
||||
}
|
||||
|
||||
func normalizeCodexReasoningReplayCustomToolCallItem(itemResult gjson.Result) ([]byte, bool) {
|
||||
callID := strings.TrimSpace(itemResult.Get("call_id").String())
|
||||
name := strings.TrimSpace(itemResult.Get("name").String())
|
||||
input := itemResult.Get("input")
|
||||
if callID == "" || name == "" || !input.Exists() {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
normalized := []byte(`{"type":"custom_tool_call","status":"completed"}`)
|
||||
if status := strings.TrimSpace(itemResult.Get("status").String()); status != "" {
|
||||
normalized, _ = sjson.SetBytes(normalized, "status", status)
|
||||
}
|
||||
normalized, _ = sjson.SetBytes(normalized, "call_id", callID)
|
||||
normalized, _ = sjson.SetBytes(normalized, "name", name)
|
||||
if input.Type == gjson.String {
|
||||
normalized, _ = sjson.SetBytes(normalized, "input", input.String())
|
||||
} else {
|
||||
normalized, _ = sjson.SetRawBytes(normalized, "input", []byte(input.Raw))
|
||||
}
|
||||
return normalized, true
|
||||
}
|
||||
|
||||
func cloneCodexReasoningReplayItems(items [][]byte) [][]byte {
|
||||
cloned := make([][]byte, 0, len(items))
|
||||
for _, item := range items {
|
||||
cloned = append(cloned, append([]byte(nil), item...))
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func evictOldestCodexReasoningReplayEntries(count int) {
|
||||
if count <= 0 || len(codexReasoningReplayEntries) == 0 {
|
||||
return
|
||||
}
|
||||
type candidate struct {
|
||||
key string
|
||||
timestamp time.Time
|
||||
}
|
||||
candidates := make([]candidate, 0, len(codexReasoningReplayEntries))
|
||||
for key, entry := range codexReasoningReplayEntries {
|
||||
candidates = append(candidates, candidate{key: key, timestamp: entry.Timestamp})
|
||||
}
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
return candidates[i].timestamp.Before(candidates[j].timestamp)
|
||||
})
|
||||
if count > len(candidates) {
|
||||
count = len(candidates)
|
||||
}
|
||||
for i := 0; i < count; i++ {
|
||||
delete(codexReasoningReplayEntries, candidates[i].key)
|
||||
}
|
||||
}
|
||||
|
||||
func purgeExpiredCodexReasoningReplayCache(now time.Time) {
|
||||
codexReasoningReplayMu.Lock()
|
||||
for key, entry := range codexReasoningReplayEntries {
|
||||
if now.Sub(entry.Timestamp) > CodexReasoningReplayCacheTTL {
|
||||
delete(codexReasoningReplayEntries, key)
|
||||
}
|
||||
}
|
||||
codexReasoningReplayMu.Unlock()
|
||||
}
|
||||
366
backend/internal/cache/codex_reasoning_replay_cache_test.go
vendored
Normal file
366
backend/internal/cache/codex_reasoning_replay_cache_test.go
vendored
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
homekv "github.com/router-for-me/CLIProxyAPI/v7/internal/home"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
type fakeCodexReasoningReplayKVClient struct {
|
||||
mu sync.Mutex
|
||||
values map[string][]byte
|
||||
getErr error
|
||||
setErr error
|
||||
delErr error
|
||||
expireErr error
|
||||
getCount int
|
||||
setCount int
|
||||
delCount int
|
||||
expireCount int
|
||||
lastSetTTL time.Duration
|
||||
lastExpireTTL time.Duration
|
||||
}
|
||||
|
||||
func newFakeCodexReasoningReplayKVClient() *fakeCodexReasoningReplayKVClient {
|
||||
return &fakeCodexReasoningReplayKVClient{values: make(map[string][]byte)}
|
||||
}
|
||||
|
||||
func (c *fakeCodexReasoningReplayKVClient) KVGet(_ context.Context, key string) ([]byte, bool, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.getCount++
|
||||
if c.getErr != nil {
|
||||
return nil, false, c.getErr
|
||||
}
|
||||
value, ok := c.values[key]
|
||||
if !ok {
|
||||
return nil, false, nil
|
||||
}
|
||||
return append([]byte(nil), value...), true, nil
|
||||
}
|
||||
|
||||
func (c *fakeCodexReasoningReplayKVClient) KVSet(_ context.Context, key string, value []byte, opts homekv.KVSetOptions) (bool, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.setCount++
|
||||
c.lastSetTTL = opts.EX
|
||||
if c.setErr != nil {
|
||||
return false, c.setErr
|
||||
}
|
||||
c.values[key] = append([]byte(nil), value...)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *fakeCodexReasoningReplayKVClient) KVCompareAndSwap(_ context.Context, key string, expected []byte, expectedExists bool, value []byte, ttl time.Duration) (bool, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.setCount++
|
||||
c.lastSetTTL = ttl
|
||||
if c.setErr != nil {
|
||||
return false, c.setErr
|
||||
}
|
||||
current, exists := c.values[key]
|
||||
if exists != expectedExists || (exists && !bytes.Equal(current, expected)) {
|
||||
return false, nil
|
||||
}
|
||||
c.values[key] = append([]byte(nil), value...)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *fakeCodexReasoningReplayKVClient) KVDel(_ context.Context, keys ...string) (int64, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.delCount++
|
||||
if c.delErr != nil {
|
||||
return 0, c.delErr
|
||||
}
|
||||
var deleted int64
|
||||
for _, key := range keys {
|
||||
if _, ok := c.values[key]; ok {
|
||||
delete(c.values, key)
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func (c *fakeCodexReasoningReplayKVClient) KVExpire(_ context.Context, _ string, ttl time.Duration) (bool, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.expireCount++
|
||||
c.lastExpireTTL = ttl
|
||||
if c.expireErr != nil {
|
||||
return false, c.expireErr
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func useFakeCodexReasoningReplayKVClient(t *testing.T, client *fakeCodexReasoningReplayKVClient, homeMode bool, errClient error) {
|
||||
t.Helper()
|
||||
previous := currentCodexReasoningReplayKVClient
|
||||
currentCodexReasoningReplayKVClient = func() (codexReasoningReplayKVClient, bool, error) {
|
||||
return client, homeMode, errClient
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
currentCodexReasoningReplayKVClient = previous
|
||||
})
|
||||
}
|
||||
|
||||
func validCodexReasoningReplayEncryptedContentForTest(seed byte) string {
|
||||
payload := make([]byte, 1+8+16+16+32)
|
||||
payload[0] = 0x80
|
||||
for i := 9; i < len(payload); i++ {
|
||||
payload[i] = seed + byte(i)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(payload)
|
||||
}
|
||||
|
||||
func validCodexReasoningReplayItemForTest(seed byte) []byte {
|
||||
return []byte(`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"` + validCodexReasoningReplayEncryptedContentForTest(seed) + `"}`)
|
||||
}
|
||||
|
||||
func mustCodexReasoningReplayJSON(t *testing.T, items [][]byte) []byte {
|
||||
t.Helper()
|
||||
raw, errMarshal := json.Marshal(items)
|
||||
if errMarshal != nil {
|
||||
t.Fatalf("marshal replay items: %v", errMarshal)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func TestCodexReasoningReplayCacheRejectsInvalidItems(t *testing.T) {
|
||||
ClearCodexReasoningReplayCache()
|
||||
t.Cleanup(ClearCodexReasoningReplayCache)
|
||||
|
||||
if CacheCodexReasoningReplayItem("gpt-5.4", "session", []byte(`{"type":"reasoning","encrypted_content":"bad","summary":[]}`)) {
|
||||
t.Fatal("invalid encrypted_content should not be cached")
|
||||
}
|
||||
if _, ok := GetCodexReasoningReplayItem("gpt-5.4", "session"); ok {
|
||||
t.Fatal("invalid item was cached")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexReasoningReplayRequiredHomeReadAndSlidingExpire(t *testing.T) {
|
||||
ClearCodexReasoningReplayCache()
|
||||
t.Cleanup(ClearCodexReasoningReplayCache)
|
||||
client := newFakeCodexReasoningReplayKVClient()
|
||||
key := codexReasoningReplayKVKey("gpt-5.4", "session-home")
|
||||
item := validCodexReasoningReplayItemForTest(3)
|
||||
client.values[key] = mustCodexReasoningReplayJSON(t, [][]byte{item})
|
||||
useFakeCodexReasoningReplayKVClient(t, client, true, nil)
|
||||
|
||||
items, found, errGet := GetCodexReasoningReplayItemsRequired(context.Background(), "gpt-5.4", "session-home")
|
||||
if errGet != nil {
|
||||
t.Fatalf("GetCodexReasoningReplayItemsRequired() error = %v", errGet)
|
||||
}
|
||||
if !found || len(items) != 1 || string(items[0]) != string(item) {
|
||||
t.Fatalf("GetCodexReasoningReplayItemsRequired() = %q, %v, want item, true", items, found)
|
||||
}
|
||||
if client.expireCount != 1 || client.lastExpireTTL != CodexReasoningReplayCacheTTL {
|
||||
t.Fatalf("KVExpire count/ttl = %d/%v, want 1/%v", client.expireCount, client.lastExpireTTL, CodexReasoningReplayCacheTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexReasoningReplayRequiredHomeFailures(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
client *fakeCodexReasoningReplayKVClient
|
||||
}{
|
||||
{name: "get", client: &fakeCodexReasoningReplayKVClient{values: make(map[string][]byte), getErr: errors.New("get failed")}},
|
||||
{name: "expire", client: &fakeCodexReasoningReplayKVClient{values: map[string][]byte{
|
||||
codexReasoningReplayKVKey("gpt-5.4", "session-home"): mustCodexReasoningReplayJSON(t, [][]byte{validCodexReasoningReplayItemForTest(4)}),
|
||||
}, expireErr: errors.New("expire failed")}},
|
||||
{name: "delete", client: &fakeCodexReasoningReplayKVClient{values: make(map[string][]byte), delErr: errors.New("delete failed")}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
useFakeCodexReasoningReplayKVClient(t, tc.client, true, nil)
|
||||
switch tc.name {
|
||||
case "delete":
|
||||
if errDel := DeleteCodexReasoningReplayItemRequired(context.Background(), "gpt-5.4", "session-home"); errDel == nil {
|
||||
t.Fatalf("DeleteCodexReasoningReplayItemRequired() error = nil, want error")
|
||||
}
|
||||
default:
|
||||
if _, _, errGet := GetCodexReasoningReplayItemsRequired(context.Background(), "gpt-5.4", "session-home"); errGet == nil {
|
||||
t.Fatalf("GetCodexReasoningReplayItemsRequired() error = nil, want error")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexReasoningReplayBestEffortHomeWriteFailureDoesNotUseLocalCache(t *testing.T) {
|
||||
ClearCodexReasoningReplayCache()
|
||||
t.Cleanup(ClearCodexReasoningReplayCache)
|
||||
client := newFakeCodexReasoningReplayKVClient()
|
||||
client.setErr = errors.New("set failed")
|
||||
useFakeCodexReasoningReplayKVClient(t, client, true, nil)
|
||||
|
||||
if CacheCodexReasoningReplayItemsBestEffort(context.Background(), "gpt-5.4", "session-home", [][]byte{validCodexReasoningReplayItemForTest(5)}) {
|
||||
t.Fatalf("CacheCodexReasoningReplayItemsBestEffort() = true, want false")
|
||||
}
|
||||
useFakeCodexReasoningReplayKVClient(t, newFakeCodexReasoningReplayKVClient(), false, nil)
|
||||
if _, found := GetCodexReasoningReplayItems("gpt-5.4", "session-home"); found {
|
||||
t.Fatalf("local replay cache was populated after Home best-effort write failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexReasoningReplayAppendPreservesCumulativeTurnsInHome(t *testing.T) {
|
||||
ClearCodexReasoningReplayCache()
|
||||
t.Cleanup(ClearCodexReasoningReplayCache)
|
||||
client := newFakeCodexReasoningReplayKVClient()
|
||||
useFakeCodexReasoningReplayKVClient(t, client, true, nil)
|
||||
|
||||
first := [][]byte{
|
||||
[]byte(`{"type":"` + CodexReasoningReplayTurnType + `","id":"turn-1","assistant_fingerprint":"answer-1"}`),
|
||||
validCodexReasoningReplayItemForTest(11),
|
||||
}
|
||||
second := [][]byte{
|
||||
[]byte(`{"type":"` + CodexReasoningReplayTurnType + `","id":"turn-2","call_ids":["call-2"]}`),
|
||||
validCodexReasoningReplayItemForTest(12),
|
||||
}
|
||||
if !AppendCodexReasoningReplayItemsBestEffort(context.Background(), "gpt-5.4", "session-home-append", first) {
|
||||
t.Fatal("first append failed")
|
||||
}
|
||||
if !AppendCodexReasoningReplayItemsBestEffort(context.Background(), "gpt-5.4", "session-home-append", second) {
|
||||
t.Fatal("second append failed")
|
||||
}
|
||||
if !AppendCodexReasoningReplayItemsBestEffort(context.Background(), "gpt-5.4", "session-home-append", second) {
|
||||
t.Fatal("duplicate append failed")
|
||||
}
|
||||
|
||||
items, found, errGet := GetCodexReasoningReplayItemsRequired(context.Background(), "gpt-5.4", "session-home-append")
|
||||
if errGet != nil || !found {
|
||||
t.Fatalf("get cumulative turns = found %v err %v", found, errGet)
|
||||
}
|
||||
if len(items) != 4 {
|
||||
t.Fatalf("cumulative item count = %d, want 4: %q", len(items), items)
|
||||
}
|
||||
if got := gjson.GetBytes(items[0], "id").String(); got != "turn-1" {
|
||||
t.Fatalf("first turn id = %q, want turn-1", got)
|
||||
}
|
||||
if got := gjson.GetBytes(items[2], "id").String(); got != "turn-2" {
|
||||
t.Fatalf("second turn id = %q, want turn-2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexReasoningReplayAppendHomeCASPreservesConcurrentTurns(t *testing.T) {
|
||||
ClearCodexReasoningReplayCache()
|
||||
t.Cleanup(ClearCodexReasoningReplayCache)
|
||||
client := newFakeCodexReasoningReplayKVClient()
|
||||
useFakeCodexReasoningReplayKVClient(t, client, true, nil)
|
||||
|
||||
const turnCount = 16
|
||||
var waitGroup sync.WaitGroup
|
||||
for turn := 0; turn < turnCount; turn++ {
|
||||
waitGroup.Add(1)
|
||||
go func(turnID int) {
|
||||
defer waitGroup.Done()
|
||||
items := [][]byte{
|
||||
[]byte(fmt.Sprintf(`{"type":"%s","id":"turn-%d"}`, CodexReasoningReplayTurnType, turnID)),
|
||||
validCodexReasoningReplayItemForTest(byte(30 + turnID)),
|
||||
}
|
||||
if !AppendCodexReasoningReplayItemsBestEffort(context.Background(), "gpt-5.4", "session-home-concurrent", items) {
|
||||
t.Errorf("append turn %d failed", turnID)
|
||||
}
|
||||
}(turn)
|
||||
}
|
||||
waitGroup.Wait()
|
||||
|
||||
items, found, errGet := GetCodexReasoningReplayItemsRequired(context.Background(), "gpt-5.4", "session-home-concurrent")
|
||||
if errGet != nil || !found {
|
||||
t.Fatalf("get concurrent turns = found %v err %v", found, errGet)
|
||||
}
|
||||
if len(items) != turnCount*2 {
|
||||
t.Fatalf("concurrent cumulative item count = %d, want %d", len(items), turnCount*2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexReasoningReplayAppendBoundsTurnsPerEntry(t *testing.T) {
|
||||
items := make([][]byte, 0, (CodexReasoningReplayCacheMaxTurnsPerEntry+1)*2)
|
||||
for turn := 0; turn <= CodexReasoningReplayCacheMaxTurnsPerEntry; turn++ {
|
||||
items = append(items,
|
||||
[]byte(fmt.Sprintf(`{"type":"%s","id":"turn-%d"}`, CodexReasoningReplayTurnType, turn)),
|
||||
validCodexReasoningReplayItemForTest(byte(50+turn)),
|
||||
)
|
||||
}
|
||||
|
||||
trimmed := trimCodexReasoningReplayItems(items)
|
||||
if len(trimmed) != CodexReasoningReplayCacheMaxTurnsPerEntry*2 {
|
||||
t.Fatalf("trimmed item count = %d, want %d", len(trimmed), CodexReasoningReplayCacheMaxTurnsPerEntry*2)
|
||||
}
|
||||
if firstID := gjson.GetBytes(trimmed[0], "id").String(); firstID != "turn-1" {
|
||||
t.Fatalf("first retained turn = %q, want turn-1", firstID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexReasoningReplayHomeRejectsEmptyScopeWithoutKV(t *testing.T) {
|
||||
client := newFakeCodexReasoningReplayKVClient()
|
||||
useFakeCodexReasoningReplayKVClient(t, client, true, nil)
|
||||
|
||||
if _, found, errGet := GetCodexReasoningReplayItemsRequired(context.Background(), "", "session-home"); errGet != nil || found {
|
||||
t.Fatalf("GetCodexReasoningReplayItemsRequired(empty model) = found %v err %v, want false nil", found, errGet)
|
||||
}
|
||||
if CacheCodexReasoningReplayItemsBestEffort(context.Background(), "gpt-5.4", "", [][]byte{validCodexReasoningReplayItemForTest(6)}) {
|
||||
t.Fatalf("CacheCodexReasoningReplayItemsBestEffort(empty session) = true, want false")
|
||||
}
|
||||
if errDel := DeleteCodexReasoningReplayItemRequired(context.Background(), "gpt-5.4", ""); errDel != nil {
|
||||
t.Fatalf("DeleteCodexReasoningReplayItemRequired(empty session) error = %v", errDel)
|
||||
}
|
||||
if client.getCount != 0 || client.setCount != 0 || client.delCount != 0 || client.expireCount != 0 {
|
||||
t.Fatalf("KV calls = get %d set %d del %d expire %d, want all zero", client.getCount, client.setCount, client.delCount, client.expireCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexReasoningReplayCacheScopesByModelAndSession(t *testing.T) {
|
||||
ClearCodexReasoningReplayCache()
|
||||
t.Cleanup(ClearCodexReasoningReplayCache)
|
||||
|
||||
encryptedContent := validCodexReasoningReplayEncryptedContentForTest(7)
|
||||
if !CacheCodexReasoningReplayItem("gpt-5.4", "session-a", []byte(`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+encryptedContent+`"}`)) {
|
||||
t.Fatal("valid item was not cached")
|
||||
}
|
||||
|
||||
if _, ok := GetCodexReasoningReplayItem("gpt-5.5", "session-a"); ok {
|
||||
t.Fatal("cache should not hit across models")
|
||||
}
|
||||
if _, ok := GetCodexReasoningReplayItem("gpt-5.4", "session-b"); ok {
|
||||
t.Fatal("cache should not hit across sessions")
|
||||
}
|
||||
|
||||
item, ok := GetCodexReasoningReplayItem("gpt-5.4", "session-a")
|
||||
if !ok {
|
||||
t.Fatal("cache miss for original model and session")
|
||||
}
|
||||
if string(item) != `{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+encryptedContent+`"}` {
|
||||
t.Fatalf("normalized item = %s", string(item))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexReasoningReplayCacheBatchEvictsWhenFull(t *testing.T) {
|
||||
ClearCodexReasoningReplayCache()
|
||||
t.Cleanup(ClearCodexReasoningReplayCache)
|
||||
|
||||
encryptedContent := validCodexReasoningReplayEncryptedContentForTest(9)
|
||||
item := []byte(`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"` + encryptedContent + `"}`)
|
||||
for i := 0; i <= CodexReasoningReplayCacheMaxEntries; i++ {
|
||||
if !CacheCodexReasoningReplayItem("gpt-5.4", fmt.Sprintf("session-%d", i), item) {
|
||||
t.Fatalf("cache insert %d failed", i)
|
||||
}
|
||||
}
|
||||
|
||||
codexReasoningReplayMu.Lock()
|
||||
gotLen := len(codexReasoningReplayEntries)
|
||||
codexReasoningReplayMu.Unlock()
|
||||
if gotLen >= CodexReasoningReplayCacheMaxEntries {
|
||||
t.Fatalf("cache entries = %d, want batch eviction below max %d", gotLen, CodexReasoningReplayCacheMaxEntries)
|
||||
}
|
||||
}
|
||||
426
backend/internal/cache/kimi_thinking_replay_cache.go
vendored
Normal file
426
backend/internal/cache/kimi_thinking_replay_cache.go
vendored
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
homekv "github.com/router-for-me/CLIProxyAPI/v7/internal/home"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const (
|
||||
// KimiThinkingReplayCacheTTL limits how long signed assistant content stays replayable.
|
||||
KimiThinkingReplayCacheTTL = 1 * time.Hour
|
||||
|
||||
// KimiThinkingReplayCacheMaxEntries bounds process memory used for replay continuity.
|
||||
KimiThinkingReplayCacheMaxEntries = 10240
|
||||
|
||||
// KimiThinkingReplayCacheEvictBatchSize leaves headroom after reaching capacity.
|
||||
KimiThinkingReplayCacheEvictBatchSize = 128
|
||||
|
||||
// KimiThinkingReplayCacheMaxBytesPerEntry bounds one complete assistant content array.
|
||||
KimiThinkingReplayCacheMaxBytesPerEntry = 8 << 20
|
||||
|
||||
// KimiThinkingReplayCacheMaxBlocksPerEntry prevents pathological content arrays.
|
||||
KimiThinkingReplayCacheMaxBlocksPerEntry = 512
|
||||
|
||||
// KimiThinkingReplayCacheMaxTotalBytes bounds aggregate in-process replay content.
|
||||
KimiThinkingReplayCacheMaxTotalBytes = 256 << 20
|
||||
|
||||
kimiThinkingReplayCacheMaxSerializedBytes = KimiThinkingReplayCacheMaxBytesPerEntry + 1024
|
||||
)
|
||||
|
||||
type kimiThinkingReplayEntry struct {
|
||||
Content []byte
|
||||
Timestamp time.Time
|
||||
Generation string
|
||||
Deleted bool
|
||||
}
|
||||
|
||||
// KimiThinkingReplaySnapshot identifies the exact replay generation read for one request.
|
||||
type KimiThinkingReplaySnapshot struct {
|
||||
raw []byte
|
||||
generation string
|
||||
loaded bool
|
||||
found bool
|
||||
}
|
||||
|
||||
type kimiThinkingReplayHomeValue struct {
|
||||
Generation string `json:"generation"`
|
||||
Deleted bool `json:"deleted,omitempty"`
|
||||
Content json.RawMessage `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
kimiThinkingReplayMu sync.Mutex
|
||||
kimiThinkingReplayEntries = make(map[string]kimiThinkingReplayEntry)
|
||||
kimiThinkingReplayTotalBytes int
|
||||
)
|
||||
|
||||
type kimiThinkingReplayKVClient interface {
|
||||
KVGet(ctx context.Context, key string) ([]byte, bool, error)
|
||||
KVSet(ctx context.Context, key string, value []byte, opts homekv.KVSetOptions) (bool, error)
|
||||
KVDel(ctx context.Context, keys ...string) (int64, error)
|
||||
KVCompareAndSwap(ctx context.Context, key string, expected []byte, expectedExists bool, value []byte, ttl time.Duration) (bool, error)
|
||||
KVExpire(ctx context.Context, key string, ttl time.Duration) (bool, error)
|
||||
}
|
||||
|
||||
var currentKimiThinkingReplayKVClient = func() (kimiThinkingReplayKVClient, bool, error) {
|
||||
return homekv.CurrentKVClient()
|
||||
}
|
||||
|
||||
// CacheKimiThinkingReplayBestEffort stores one complete signed assistant content array.
|
||||
func CacheKimiThinkingReplayBestEffort(ctx context.Context, modelFamily, sessionKey string, content []byte) bool {
|
||||
key := kimiThinkingReplayCacheKey(modelFamily, sessionKey)
|
||||
if key == "" || !validKimiThinkingReplayContent(content) {
|
||||
return false
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
cloned := append([]byte(nil), content...)
|
||||
generation := uuid.NewString()
|
||||
if client, homeMode, errClient := currentKimiThinkingReplayKVClient(); homeMode {
|
||||
if errClient != nil {
|
||||
log.Errorf("home kv best-effort kimi thinking replay set failed prefix=cpa:kimi:*: %v", errClient)
|
||||
return false
|
||||
}
|
||||
raw, errMarshal := marshalKimiThinkingReplayHomeValue(generation, false, cloned)
|
||||
if errMarshal != nil {
|
||||
log.Errorf("home kv best-effort kimi thinking replay set failed prefix=cpa:kimi:*: %v", errMarshal)
|
||||
return false
|
||||
}
|
||||
written, errSet := client.KVSet(ctx, kimiThinkingReplayKVKey(modelFamily, sessionKey), raw, homekv.KVSetOptions{EX: KimiThinkingReplayCacheTTL})
|
||||
if errSet != nil {
|
||||
log.Errorf("home kv best-effort kimi thinking replay set failed prefix=cpa:kimi:*: %v", errSet)
|
||||
return false
|
||||
}
|
||||
return written
|
||||
}
|
||||
|
||||
storeKimiThinkingReplayLocal(key, cloned, generation, false, time.Now())
|
||||
return true
|
||||
}
|
||||
|
||||
// GetKimiThinkingReplayRequired retrieves complete assistant content for request-time replay.
|
||||
func GetKimiThinkingReplayRequired(ctx context.Context, modelFamily, sessionKey string) ([]byte, bool, error) {
|
||||
content, _, found, errGet := GetKimiThinkingReplayWithSnapshotRequired(ctx, modelFamily, sessionKey)
|
||||
return content, found, errGet
|
||||
}
|
||||
|
||||
// GetKimiThinkingReplayWithSnapshotRequired retrieves replay content and the exact cache state read.
|
||||
func GetKimiThinkingReplayWithSnapshotRequired(ctx context.Context, modelFamily, sessionKey string) ([]byte, KimiThinkingReplaySnapshot, bool, error) {
|
||||
key := kimiThinkingReplayCacheKey(modelFamily, sessionKey)
|
||||
if key == "" {
|
||||
return nil, KimiThinkingReplaySnapshot{}, false, nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
client, homeMode, errClient := currentKimiThinkingReplayKVClient()
|
||||
if homeMode {
|
||||
if errClient != nil {
|
||||
return nil, KimiThinkingReplaySnapshot{loaded: true}, false, errClient
|
||||
}
|
||||
kvKey := kimiThinkingReplayKVKey(modelFamily, sessionKey)
|
||||
raw, errRead := readOrReserveKimiThinkingReplayHomeValue(ctx, client, kvKey)
|
||||
if errRead != nil {
|
||||
return nil, KimiThinkingReplaySnapshot{loaded: true}, false, errRead
|
||||
}
|
||||
snapshot := KimiThinkingReplaySnapshot{raw: append([]byte(nil), raw...), loaded: true, found: true}
|
||||
content, generation, deleted, okDecode := decodeKimiThinkingReplayHomeValue(raw)
|
||||
if !okDecode {
|
||||
return nil, snapshot, false, fmt.Errorf("invalid kimi thinking replay content")
|
||||
}
|
||||
snapshot.generation = generation
|
||||
if _, errExpire := client.KVExpire(ctx, kvKey, KimiThinkingReplayCacheTTL); errExpire != nil {
|
||||
log.Warnf("home kv kimi thinking replay expire failed prefix=cpa:kimi:*: %v", errExpire)
|
||||
}
|
||||
if deleted {
|
||||
return nil, snapshot, false, nil
|
||||
}
|
||||
return content, snapshot, true, nil
|
||||
}
|
||||
|
||||
cacheCleanupOnce.Do(startCacheCleanup)
|
||||
now := time.Now()
|
||||
kimiThinkingReplayMu.Lock()
|
||||
defer kimiThinkingReplayMu.Unlock()
|
||||
entry, ok := kimiThinkingReplayEntries[key]
|
||||
if !ok || now.Sub(entry.Timestamp) > KimiThinkingReplayCacheTTL {
|
||||
if ok {
|
||||
kimiThinkingReplayTotalBytes -= len(entry.Content)
|
||||
delete(kimiThinkingReplayEntries, key)
|
||||
}
|
||||
entry = reserveKimiThinkingReplayLocalLocked(key, now)
|
||||
}
|
||||
entry.Timestamp = now
|
||||
kimiThinkingReplayEntries[key] = entry
|
||||
snapshot := KimiThinkingReplaySnapshot{generation: entry.Generation, loaded: true, found: true}
|
||||
if entry.Deleted {
|
||||
return nil, snapshot, false, nil
|
||||
}
|
||||
return append([]byte(nil), entry.Content...), snapshot, true, nil
|
||||
}
|
||||
|
||||
// ReplaceKimiThinkingReplayIfUnchanged stores completed content only if the request snapshot is current.
|
||||
func ReplaceKimiThinkingReplayIfUnchanged(ctx context.Context, modelFamily, sessionKey string, snapshot KimiThinkingReplaySnapshot, content []byte) (bool, error) {
|
||||
key := kimiThinkingReplayCacheKey(modelFamily, sessionKey)
|
||||
if key == "" || !validKimiThinkingReplayContent(content) {
|
||||
return false, nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if !snapshot.loaded {
|
||||
return CacheKimiThinkingReplayBestEffort(ctx, modelFamily, sessionKey, content), nil
|
||||
}
|
||||
cloned := append([]byte(nil), content...)
|
||||
generation := uuid.NewString()
|
||||
client, homeMode, errClient := currentKimiThinkingReplayKVClient()
|
||||
if homeMode {
|
||||
if errClient != nil {
|
||||
return false, errClient
|
||||
}
|
||||
raw, errMarshal := marshalKimiThinkingReplayHomeValue(generation, false, cloned)
|
||||
if errMarshal != nil {
|
||||
return false, errMarshal
|
||||
}
|
||||
return client.KVCompareAndSwap(ctx, kimiThinkingReplayKVKey(modelFamily, sessionKey), snapshot.raw, snapshot.found, raw, KimiThinkingReplayCacheTTL)
|
||||
}
|
||||
|
||||
cacheCleanupOnce.Do(startCacheCleanup)
|
||||
kimiThinkingReplayMu.Lock()
|
||||
defer kimiThinkingReplayMu.Unlock()
|
||||
entry, found := kimiThinkingReplayEntries[key]
|
||||
if found != snapshot.found || (found && entry.Generation != snapshot.generation) {
|
||||
return false, nil
|
||||
}
|
||||
kimiThinkingReplayTotalBytes -= len(entry.Content)
|
||||
kimiThinkingReplayTotalBytes += len(cloned)
|
||||
kimiThinkingReplayEntries[key] = kimiThinkingReplayEntry{Content: cloned, Timestamp: time.Now(), Generation: generation}
|
||||
enforceKimiThinkingReplayLimitsLocked()
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// DeleteKimiThinkingReplayIfUnchanged clears replay state only if the request snapshot is current.
|
||||
func DeleteKimiThinkingReplayIfUnchanged(ctx context.Context, modelFamily, sessionKey string, snapshot KimiThinkingReplaySnapshot) (bool, error) {
|
||||
key := kimiThinkingReplayCacheKey(modelFamily, sessionKey)
|
||||
if key == "" {
|
||||
return false, nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if !snapshot.loaded {
|
||||
return true, DeleteKimiThinkingReplayRequired(ctx, modelFamily, sessionKey)
|
||||
}
|
||||
generation := uuid.NewString()
|
||||
client, homeMode, errClient := currentKimiThinkingReplayKVClient()
|
||||
if homeMode {
|
||||
if errClient != nil {
|
||||
return false, errClient
|
||||
}
|
||||
tombstone, errMarshal := marshalKimiThinkingReplayHomeValue(generation, true, nil)
|
||||
if errMarshal != nil {
|
||||
return false, errMarshal
|
||||
}
|
||||
return client.KVCompareAndSwap(ctx, kimiThinkingReplayKVKey(modelFamily, sessionKey), snapshot.raw, snapshot.found, tombstone, KimiThinkingReplayCacheTTL)
|
||||
}
|
||||
|
||||
kimiThinkingReplayMu.Lock()
|
||||
defer kimiThinkingReplayMu.Unlock()
|
||||
entry, found := kimiThinkingReplayEntries[key]
|
||||
if found != snapshot.found || (found && entry.Generation != snapshot.generation) {
|
||||
return false, nil
|
||||
}
|
||||
kimiThinkingReplayTotalBytes -= len(entry.Content)
|
||||
kimiThinkingReplayEntries[key] = kimiThinkingReplayEntry{Timestamp: time.Now(), Generation: generation, Deleted: true}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// DeleteKimiThinkingReplayRequired removes stale replay state unconditionally.
|
||||
func DeleteKimiThinkingReplayRequired(ctx context.Context, modelFamily, sessionKey string) error {
|
||||
key := kimiThinkingReplayCacheKey(modelFamily, sessionKey)
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
client, homeMode, errClient := currentKimiThinkingReplayKVClient()
|
||||
if homeMode {
|
||||
if errClient != nil {
|
||||
return errClient
|
||||
}
|
||||
_, errDelete := client.KVDel(ctx, kimiThinkingReplayKVKey(modelFamily, sessionKey))
|
||||
return errDelete
|
||||
}
|
||||
kimiThinkingReplayMu.Lock()
|
||||
if entry, found := kimiThinkingReplayEntries[key]; found {
|
||||
kimiThinkingReplayTotalBytes -= len(entry.Content)
|
||||
delete(kimiThinkingReplayEntries, key)
|
||||
}
|
||||
kimiThinkingReplayMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearKimiThinkingReplayCache clears all in-process Kimi replay state.
|
||||
func ClearKimiThinkingReplayCache() {
|
||||
kimiThinkingReplayMu.Lock()
|
||||
kimiThinkingReplayEntries = make(map[string]kimiThinkingReplayEntry)
|
||||
kimiThinkingReplayTotalBytes = 0
|
||||
kimiThinkingReplayMu.Unlock()
|
||||
}
|
||||
|
||||
func readOrReserveKimiThinkingReplayHomeValue(ctx context.Context, client kimiThinkingReplayKVClient, key string) ([]byte, error) {
|
||||
for attempt := 0; attempt < 4; attempt++ {
|
||||
raw, found, errGet := client.KVGet(ctx, key)
|
||||
if errGet != nil {
|
||||
return nil, errGet
|
||||
}
|
||||
if found {
|
||||
if len(raw) > kimiThinkingReplayCacheMaxSerializedBytes {
|
||||
return nil, fmt.Errorf("kimi thinking replay value exceeds size limit")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
tombstone, errMarshal := marshalKimiThinkingReplayHomeValue(uuid.NewString(), true, nil)
|
||||
if errMarshal != nil {
|
||||
return nil, errMarshal
|
||||
}
|
||||
swapped, errReserve := client.KVCompareAndSwap(ctx, key, nil, false, tombstone, KimiThinkingReplayCacheTTL)
|
||||
if errReserve != nil {
|
||||
return nil, errReserve
|
||||
}
|
||||
if swapped {
|
||||
return tombstone, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("could not reserve absent kimi thinking replay state")
|
||||
}
|
||||
|
||||
func marshalKimiThinkingReplayHomeValue(generation string, deleted bool, content []byte) ([]byte, error) {
|
||||
value := kimiThinkingReplayHomeValue{Generation: generation, Deleted: deleted}
|
||||
if !deleted {
|
||||
value.Content = append(json.RawMessage(nil), content...)
|
||||
}
|
||||
return json.Marshal(value)
|
||||
}
|
||||
|
||||
func decodeKimiThinkingReplayHomeValue(raw []byte) ([]byte, string, bool, bool) {
|
||||
if len(raw) == 0 || len(raw) > kimiThinkingReplayCacheMaxSerializedBytes || !gjson.ValidBytes(raw) {
|
||||
return nil, "", false, false
|
||||
}
|
||||
root := gjson.ParseBytes(raw)
|
||||
if root.IsArray() {
|
||||
if !validKimiThinkingReplayContent(raw) {
|
||||
return nil, "", false, false
|
||||
}
|
||||
return append([]byte(nil), raw...), "legacy", false, true
|
||||
}
|
||||
var value kimiThinkingReplayHomeValue
|
||||
if errUnmarshal := json.Unmarshal(raw, &value); errUnmarshal != nil || strings.TrimSpace(value.Generation) == "" {
|
||||
return nil, "", false, false
|
||||
}
|
||||
if value.Deleted {
|
||||
return nil, value.Generation, true, true
|
||||
}
|
||||
if !validKimiThinkingReplayContent(value.Content) {
|
||||
return nil, "", false, false
|
||||
}
|
||||
return append([]byte(nil), value.Content...), value.Generation, false, true
|
||||
}
|
||||
|
||||
func reserveKimiThinkingReplayLocalLocked(key string, now time.Time) kimiThinkingReplayEntry {
|
||||
entry := kimiThinkingReplayEntry{Timestamp: now, Generation: uuid.NewString(), Deleted: true}
|
||||
kimiThinkingReplayEntries[key] = entry
|
||||
enforceKimiThinkingReplayLimitsLocked()
|
||||
return entry
|
||||
}
|
||||
|
||||
func storeKimiThinkingReplayLocal(key string, content []byte, generation string, deleted bool, now time.Time) {
|
||||
cacheCleanupOnce.Do(startCacheCleanup)
|
||||
kimiThinkingReplayMu.Lock()
|
||||
defer kimiThinkingReplayMu.Unlock()
|
||||
if previous, found := kimiThinkingReplayEntries[key]; found {
|
||||
kimiThinkingReplayTotalBytes -= len(previous.Content)
|
||||
}
|
||||
kimiThinkingReplayTotalBytes += len(content)
|
||||
kimiThinkingReplayEntries[key] = kimiThinkingReplayEntry{Content: content, Timestamp: now, Generation: generation, Deleted: deleted}
|
||||
enforceKimiThinkingReplayLimitsLocked()
|
||||
}
|
||||
|
||||
func kimiThinkingReplayCacheKey(modelFamily, sessionKey string) string {
|
||||
modelFamily = strings.TrimSpace(modelFamily)
|
||||
sessionKey = strings.TrimSpace(sessionKey)
|
||||
if modelFamily == "" || sessionKey == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.Join([]string{"kimi-thinking-replay", modelFamily, sessionKey}, "\x00")
|
||||
}
|
||||
|
||||
func kimiThinkingReplayKVKey(modelFamily, sessionKey string) string {
|
||||
return "cpa:kimi:thinking-replay:" + homekv.HashKeyPart(strings.TrimSpace(modelFamily)) + ":" + homekv.HashKeyPart(strings.TrimSpace(sessionKey))
|
||||
}
|
||||
|
||||
func validKimiThinkingReplayContent(content []byte) bool {
|
||||
if len(content) == 0 || len(content) > KimiThinkingReplayCacheMaxBytesPerEntry || !gjson.ValidBytes(content) {
|
||||
return false
|
||||
}
|
||||
root := gjson.ParseBytes(content)
|
||||
return root.IsArray() && len(root.Array()) > 0 && len(root.Array()) <= KimiThinkingReplayCacheMaxBlocksPerEntry
|
||||
}
|
||||
|
||||
func enforceKimiThinkingReplayLimitsLocked() {
|
||||
for len(kimiThinkingReplayEntries) > KimiThinkingReplayCacheMaxEntries || kimiThinkingReplayTotalBytes > KimiThinkingReplayCacheMaxTotalBytes {
|
||||
if len(kimiThinkingReplayEntries) == 0 {
|
||||
kimiThinkingReplayTotalBytes = 0
|
||||
return
|
||||
}
|
||||
evictOldestKimiThinkingReplayEntriesLocked(KimiThinkingReplayCacheEvictBatchSize)
|
||||
}
|
||||
}
|
||||
|
||||
func evictOldestKimiThinkingReplayEntriesLocked(count int) {
|
||||
if count <= 0 || len(kimiThinkingReplayEntries) == 0 {
|
||||
return
|
||||
}
|
||||
type candidate struct {
|
||||
key string
|
||||
timestamp time.Time
|
||||
}
|
||||
candidates := make([]candidate, 0, len(kimiThinkingReplayEntries))
|
||||
for key, entry := range kimiThinkingReplayEntries {
|
||||
candidates = append(candidates, candidate{key: key, timestamp: entry.Timestamp})
|
||||
}
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
return candidates[i].timestamp.Before(candidates[j].timestamp)
|
||||
})
|
||||
if count > len(candidates) {
|
||||
count = len(candidates)
|
||||
}
|
||||
for i := 0; i < count; i++ {
|
||||
entry := kimiThinkingReplayEntries[candidates[i].key]
|
||||
kimiThinkingReplayTotalBytes -= len(entry.Content)
|
||||
delete(kimiThinkingReplayEntries, candidates[i].key)
|
||||
}
|
||||
}
|
||||
|
||||
func purgeExpiredKimiThinkingReplayCache(now time.Time) {
|
||||
kimiThinkingReplayMu.Lock()
|
||||
for key, entry := range kimiThinkingReplayEntries {
|
||||
if now.Sub(entry.Timestamp) > KimiThinkingReplayCacheTTL {
|
||||
kimiThinkingReplayTotalBytes -= len(entry.Content)
|
||||
delete(kimiThinkingReplayEntries, key)
|
||||
}
|
||||
}
|
||||
kimiThinkingReplayMu.Unlock()
|
||||
}
|
||||
237
backend/internal/cache/kimi_thinking_replay_cache_test.go
vendored
Normal file
237
backend/internal/cache/kimi_thinking_replay_cache_test.go
vendored
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
homekv "github.com/router-for-me/CLIProxyAPI/v7/internal/home"
|
||||
)
|
||||
|
||||
type fakeKimiThinkingReplayKVClient struct {
|
||||
mu sync.Mutex
|
||||
values map[string][]byte
|
||||
}
|
||||
|
||||
func newFakeKimiThinkingReplayKVClient() *fakeKimiThinkingReplayKVClient {
|
||||
return &fakeKimiThinkingReplayKVClient{values: make(map[string][]byte)}
|
||||
}
|
||||
|
||||
func (c *fakeKimiThinkingReplayKVClient) KVGet(_ context.Context, key string) ([]byte, bool, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
value, found := c.values[key]
|
||||
return append([]byte(nil), value...), found, nil
|
||||
}
|
||||
|
||||
func (c *fakeKimiThinkingReplayKVClient) KVSet(_ context.Context, key string, value []byte, _ homekv.KVSetOptions) (bool, error) {
|
||||
c.mu.Lock()
|
||||
c.values[key] = append([]byte(nil), value...)
|
||||
c.mu.Unlock()
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *fakeKimiThinkingReplayKVClient) KVDel(_ context.Context, keys ...string) (int64, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
var deleted int64
|
||||
for _, key := range keys {
|
||||
if _, found := c.values[key]; found {
|
||||
delete(c.values, key)
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func (c *fakeKimiThinkingReplayKVClient) KVCompareAndSwap(_ context.Context, key string, expected []byte, expectedExists bool, value []byte, _ time.Duration) (bool, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
current, found := c.values[key]
|
||||
if found != expectedExists || (found && !bytes.Equal(current, expected)) {
|
||||
return false, nil
|
||||
}
|
||||
c.values[key] = append([]byte(nil), value...)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *fakeKimiThinkingReplayKVClient) KVExpire(_ context.Context, key string, _ time.Duration) (bool, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
_, found := c.values[key]
|
||||
return found, nil
|
||||
}
|
||||
|
||||
func useFakeKimiThinkingReplayKVClient(t *testing.T, client *fakeKimiThinkingReplayKVClient) {
|
||||
t.Helper()
|
||||
previous := currentKimiThinkingReplayKVClient
|
||||
currentKimiThinkingReplayKVClient = func() (kimiThinkingReplayKVClient, bool, error) {
|
||||
return client, true, nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
currentKimiThinkingReplayKVClient = previous
|
||||
})
|
||||
}
|
||||
|
||||
func TestKimiThinkingReplayConditionalDeleteKeepsNewerContent(t *testing.T) {
|
||||
ClearKimiThinkingReplayCache()
|
||||
t.Cleanup(ClearKimiThinkingReplayCache)
|
||||
|
||||
const modelFamily = "k3"
|
||||
const sessionKey = "execution:conditional-delete"
|
||||
oldContent := []byte(`[{"type":"thinking","signature":"old"}]`)
|
||||
newContent := []byte(`[{"type":"thinking","signature":"new"}]`)
|
||||
if !CacheKimiThinkingReplayBestEffort(context.Background(), modelFamily, sessionKey, oldContent) {
|
||||
t.Fatal("failed to seed old content")
|
||||
}
|
||||
_, snapshot, found, errGet := GetKimiThinkingReplayWithSnapshotRequired(context.Background(), modelFamily, sessionKey)
|
||||
if errGet != nil || !found {
|
||||
t.Fatalf("GetKimiThinkingReplayWithSnapshotRequired() = found %v, error %v", found, errGet)
|
||||
}
|
||||
if !CacheKimiThinkingReplayBestEffort(context.Background(), modelFamily, sessionKey, newContent) {
|
||||
t.Fatal("failed to write newer content")
|
||||
}
|
||||
if !CacheKimiThinkingReplayBestEffort(context.Background(), modelFamily, sessionKey, oldContent) {
|
||||
t.Fatal("failed to write latest content with repeated bytes")
|
||||
}
|
||||
|
||||
deleted, errDelete := DeleteKimiThinkingReplayIfUnchanged(context.Background(), modelFamily, sessionKey, snapshot)
|
||||
if errDelete != nil {
|
||||
t.Fatalf("DeleteKimiThinkingReplayIfUnchanged() error = %v", errDelete)
|
||||
}
|
||||
if deleted {
|
||||
t.Fatal("stale snapshot deleted newer content")
|
||||
}
|
||||
got, found, errGet := GetKimiThinkingReplayRequired(context.Background(), modelFamily, sessionKey)
|
||||
if errGet != nil || !found || !bytes.Equal(got, oldContent) {
|
||||
t.Fatalf("cached content = %s, found %v, error %v; want latest repeated content", got, found, errGet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiThinkingReplayConditionalReplaceKeepsConcurrentContent(t *testing.T) {
|
||||
ClearKimiThinkingReplayCache()
|
||||
t.Cleanup(ClearKimiThinkingReplayCache)
|
||||
|
||||
const modelFamily = "k3"
|
||||
const sessionKey = "execution:conditional-replace"
|
||||
_, snapshot, found, errGet := GetKimiThinkingReplayWithSnapshotRequired(context.Background(), modelFamily, sessionKey)
|
||||
if errGet != nil || found {
|
||||
t.Fatalf("initial cache read = found %v, error %v; want miss", found, errGet)
|
||||
}
|
||||
newContent := []byte(`[{"type":"thinking","signature":"new"}]`)
|
||||
staleContent := []byte(`[{"type":"thinking","signature":"stale"}]`)
|
||||
if !CacheKimiThinkingReplayBestEffort(context.Background(), modelFamily, sessionKey, newContent) {
|
||||
t.Fatal("failed to write concurrent content")
|
||||
}
|
||||
|
||||
replaced, errReplace := ReplaceKimiThinkingReplayIfUnchanged(context.Background(), modelFamily, sessionKey, snapshot, staleContent)
|
||||
if errReplace != nil {
|
||||
t.Fatalf("ReplaceKimiThinkingReplayIfUnchanged() error = %v", errReplace)
|
||||
}
|
||||
if replaced {
|
||||
t.Fatal("stale snapshot replaced concurrent content")
|
||||
}
|
||||
got, found, errGet := GetKimiThinkingReplayRequired(context.Background(), modelFamily, sessionKey)
|
||||
if errGet != nil || !found || !bytes.Equal(got, newContent) {
|
||||
t.Fatalf("cached content = %s, found %v, error %v; want concurrent content", got, found, errGet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiThinkingReplayTombstoneFencesConcurrentMiss(t *testing.T) {
|
||||
ClearKimiThinkingReplayCache()
|
||||
t.Cleanup(ClearKimiThinkingReplayCache)
|
||||
|
||||
const modelFamily = "k3"
|
||||
const sessionKey = "execution:tombstone-fence"
|
||||
_, firstSnapshot, firstFound, errFirst := GetKimiThinkingReplayWithSnapshotRequired(context.Background(), modelFamily, sessionKey)
|
||||
_, secondSnapshot, secondFound, errSecond := GetKimiThinkingReplayWithSnapshotRequired(context.Background(), modelFamily, sessionKey)
|
||||
if errFirst != nil || errSecond != nil || firstFound || secondFound {
|
||||
t.Fatalf("concurrent misses = %v/%v, errors %v/%v", firstFound, secondFound, errFirst, errSecond)
|
||||
}
|
||||
deleted, errDelete := DeleteKimiThinkingReplayIfUnchanged(context.Background(), modelFamily, sessionKey, firstSnapshot)
|
||||
if errDelete != nil || !deleted {
|
||||
t.Fatalf("first miss delete = %v, error %v", deleted, errDelete)
|
||||
}
|
||||
staleContent := []byte(`[{"type":"thinking","signature":"stale"}]`)
|
||||
replaced, errReplace := ReplaceKimiThinkingReplayIfUnchanged(context.Background(), modelFamily, sessionKey, secondSnapshot, staleContent)
|
||||
if errReplace != nil {
|
||||
t.Fatalf("stale miss replace error = %v", errReplace)
|
||||
}
|
||||
if replaced {
|
||||
t.Fatal("stale miss snapshot crossed a newer tombstone")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiThinkingReplayHomeGenerationPreventsABADelete(t *testing.T) {
|
||||
client := newFakeKimiThinkingReplayKVClient()
|
||||
useFakeKimiThinkingReplayKVClient(t, client)
|
||||
|
||||
const modelFamily = "k3"
|
||||
const sessionKey = "execution:home-aba"
|
||||
contentA := []byte(`[{"type":"thinking","signature":"A"}]`)
|
||||
contentB := []byte(`[{"type":"thinking","signature":"B"}]`)
|
||||
if !CacheKimiThinkingReplayBestEffort(context.Background(), modelFamily, sessionKey, contentA) {
|
||||
t.Fatal("failed to seed Home content A")
|
||||
}
|
||||
_, snapshotA, found, errGet := GetKimiThinkingReplayWithSnapshotRequired(context.Background(), modelFamily, sessionKey)
|
||||
if errGet != nil || !found {
|
||||
t.Fatalf("Home snapshot A = found %v, error %v", found, errGet)
|
||||
}
|
||||
if !CacheKimiThinkingReplayBestEffort(context.Background(), modelFamily, sessionKey, contentB) ||
|
||||
!CacheKimiThinkingReplayBestEffort(context.Background(), modelFamily, sessionKey, contentA) {
|
||||
t.Fatal("failed to complete Home A-B-A sequence")
|
||||
}
|
||||
deleted, errDelete := DeleteKimiThinkingReplayIfUnchanged(context.Background(), modelFamily, sessionKey, snapshotA)
|
||||
if errDelete != nil {
|
||||
t.Fatalf("Home stale delete error = %v", errDelete)
|
||||
}
|
||||
if deleted {
|
||||
t.Fatal("Home stale snapshot deleted a newer generation with repeated content")
|
||||
}
|
||||
got, found, errGet := GetKimiThinkingReplayRequired(context.Background(), modelFamily, sessionKey)
|
||||
if errGet != nil || !found || !bytes.Equal(got, contentA) {
|
||||
t.Fatalf("Home cached content = %s, found %v, error %v; want latest A", got, found, errGet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiThinkingReplayTracksAggregateLocalBytes(t *testing.T) {
|
||||
ClearKimiThinkingReplayCache()
|
||||
t.Cleanup(ClearKimiThinkingReplayCache)
|
||||
|
||||
first := []byte(`[{"type":"thinking","signature":"first"}]`)
|
||||
second := []byte(`[{"type":"thinking","signature":"second"}]`)
|
||||
if !CacheKimiThinkingReplayBestEffort(context.Background(), "k3", "execution:bytes-1", first) ||
|
||||
!CacheKimiThinkingReplayBestEffort(context.Background(), "k3", "execution:bytes-2", second) {
|
||||
t.Fatal("failed to seed aggregate byte accounting")
|
||||
}
|
||||
if got, want := kimiThinkingReplayTotalBytes, len(first)+len(second); got != want {
|
||||
t.Fatalf("aggregate bytes = %d, want %d", got, want)
|
||||
}
|
||||
if errDelete := DeleteKimiThinkingReplayRequired(context.Background(), "k3", "execution:bytes-1"); errDelete != nil {
|
||||
t.Fatalf("DeleteKimiThinkingReplayRequired() error = %v", errDelete)
|
||||
}
|
||||
if got, want := kimiThinkingReplayTotalBytes, len(second); got != want {
|
||||
t.Fatalf("aggregate bytes after delete = %d, want %d", got, want)
|
||||
}
|
||||
ClearKimiThinkingReplayCache()
|
||||
if kimiThinkingReplayTotalBytes != 0 {
|
||||
t.Fatalf("aggregate bytes after clear = %d, want 0", kimiThinkingReplayTotalBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiThinkingReplayRejectsOversizedContent(t *testing.T) {
|
||||
ClearKimiThinkingReplayCache()
|
||||
t.Cleanup(ClearKimiThinkingReplayCache)
|
||||
|
||||
content := make([]byte, KimiThinkingReplayCacheMaxBytesPerEntry+1)
|
||||
content[0] = '['
|
||||
for i := 1; i < len(content)-1; i++ {
|
||||
content[i] = ' '
|
||||
}
|
||||
content[len(content)-1] = ']'
|
||||
if CacheKimiThinkingReplayBestEffort(context.Background(), "k3", "execution:oversized", content) {
|
||||
t.Fatal("oversized content was cached")
|
||||
}
|
||||
}
|
||||
342
backend/internal/cache/signature_cache.go
vendored
Normal file
342
backend/internal/cache/signature_cache.go
vendored
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
homekv "github.com/router-for-me/CLIProxyAPI/v7/internal/home"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// SignatureEntry holds a cached thinking signature with timestamp
|
||||
type SignatureEntry struct {
|
||||
Signature string
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
// SignatureCacheTTL is how long signatures are valid
|
||||
SignatureCacheTTL = 3 * time.Hour
|
||||
|
||||
// SignatureTextHashLen is the length of the hash key (16 hex chars = 64-bit key space)
|
||||
SignatureTextHashLen = 16
|
||||
|
||||
// MinValidSignatureLen is the minimum length for a signature to be considered valid
|
||||
MinValidSignatureLen = 50
|
||||
|
||||
// CacheCleanupInterval controls how often stale entries are purged
|
||||
CacheCleanupInterval = 10 * time.Minute
|
||||
)
|
||||
|
||||
// signatureCache stores signatures by model group -> textHash -> SignatureEntry
|
||||
var signatureCache sync.Map
|
||||
|
||||
// cacheCleanupOnce ensures the background cleanup goroutine starts only once
|
||||
var cacheCleanupOnce sync.Once
|
||||
|
||||
type signatureKVClient interface {
|
||||
KVGet(ctx context.Context, key string) ([]byte, bool, error)
|
||||
KVSet(ctx context.Context, key string, value []byte, opts homekv.KVSetOptions) (bool, error)
|
||||
KVDel(ctx context.Context, keys ...string) (int64, error)
|
||||
KVExpire(ctx context.Context, key string, ttl time.Duration) (bool, error)
|
||||
}
|
||||
|
||||
var currentSignatureKVClient = func() (signatureKVClient, bool, error) {
|
||||
return homekv.CurrentKVClient()
|
||||
}
|
||||
|
||||
// groupCache is the inner map type
|
||||
type groupCache struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]SignatureEntry
|
||||
}
|
||||
|
||||
// hashText creates a stable, Unicode-safe key from text content
|
||||
func hashText(text string) string {
|
||||
h := sha256.Sum256([]byte(text))
|
||||
return hex.EncodeToString(h[:])[:SignatureTextHashLen]
|
||||
}
|
||||
|
||||
// getOrCreateGroupCache gets or creates a cache bucket for a model group
|
||||
func getOrCreateGroupCache(groupKey string) *groupCache {
|
||||
// Start background cleanup on first access
|
||||
cacheCleanupOnce.Do(startCacheCleanup)
|
||||
|
||||
if val, ok := signatureCache.Load(groupKey); ok {
|
||||
return val.(*groupCache)
|
||||
}
|
||||
sc := &groupCache{entries: make(map[string]SignatureEntry)}
|
||||
actual, _ := signatureCache.LoadOrStore(groupKey, sc)
|
||||
return actual.(*groupCache)
|
||||
}
|
||||
|
||||
// startCacheCleanup launches a background goroutine that periodically
|
||||
// removes caches where all entries have expired.
|
||||
func startCacheCleanup() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(CacheCleanupInterval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
purgeExpiredCaches()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// purgeExpiredCaches removes caches with no valid (non-expired) entries.
|
||||
func purgeExpiredCaches() {
|
||||
now := time.Now()
|
||||
signatureCache.Range(func(key, value any) bool {
|
||||
sc := value.(*groupCache)
|
||||
sc.mu.Lock()
|
||||
// Remove expired entries
|
||||
for k, entry := range sc.entries {
|
||||
if now.Sub(entry.Timestamp) > SignatureCacheTTL {
|
||||
delete(sc.entries, k)
|
||||
}
|
||||
}
|
||||
isEmpty := len(sc.entries) == 0
|
||||
sc.mu.Unlock()
|
||||
// Remove cache bucket if empty
|
||||
if isEmpty {
|
||||
signatureCache.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
purgeExpiredCodexReasoningReplayCache(now)
|
||||
purgeExpiredXAIReasoningReplayCache(now)
|
||||
purgeExpiredAntigravityReasoningReplayCache(now)
|
||||
purgeExpiredKimiThinkingReplayCache(now)
|
||||
purgeExpiredClaudeThinkingReplayCache(now)
|
||||
}
|
||||
|
||||
// CacheSignature stores a thinking signature for a given model group and text.
|
||||
// Used for Claude models that require signed thinking blocks in multi-turn conversations.
|
||||
func CacheSignature(modelName, text, signature string) {
|
||||
CacheSignatureBestEffort(context.Background(), modelName, text, signature)
|
||||
}
|
||||
|
||||
// CacheSignatureBestEffort stores a thinking signature for completed response paths.
|
||||
func CacheSignatureBestEffort(ctx context.Context, modelName, text, signature string) bool {
|
||||
if text == "" || signature == "" {
|
||||
return false
|
||||
}
|
||||
if len(signature) < MinValidSignatureLen {
|
||||
return false
|
||||
}
|
||||
|
||||
if client, homeMode, errClient := currentSignatureKVClient(); homeMode {
|
||||
if errClient != nil {
|
||||
log.Errorf("home kv best-effort signature set failed prefix=cpa:signature:*: %v", errClient)
|
||||
return false
|
||||
}
|
||||
written, errSet := client.KVSet(ctx, signatureKVKey(modelName, text), []byte(signature), homekv.KVSetOptions{EX: SignatureCacheTTL})
|
||||
if errSet != nil {
|
||||
log.Errorf("home kv best-effort signature set failed prefix=cpa:signature:*: %v", errSet)
|
||||
return false
|
||||
}
|
||||
return written
|
||||
}
|
||||
|
||||
groupKey := GetModelGroup(modelName)
|
||||
textHash := hashText(text)
|
||||
sc := getOrCreateGroupCache(groupKey)
|
||||
sc.mu.Lock()
|
||||
defer sc.mu.Unlock()
|
||||
|
||||
sc.entries[textHash] = SignatureEntry{
|
||||
Signature: signature,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// GetCachedSignature retrieves a cached signature for a given model group and text.
|
||||
// Returns empty string if not found or expired.
|
||||
func GetCachedSignature(modelName, text string) string {
|
||||
signature, errSignature := GetCachedSignatureRequired(context.Background(), modelName, text)
|
||||
if errSignature != nil {
|
||||
return ""
|
||||
}
|
||||
return signature
|
||||
}
|
||||
|
||||
// GetCachedSignatureRequired retrieves a cached signature for request-time paths.
|
||||
func GetCachedSignatureRequired(ctx context.Context, modelName, text string) (string, error) {
|
||||
groupKey := GetModelGroup(modelName)
|
||||
|
||||
if text == "" {
|
||||
if groupKey == "gemini" {
|
||||
return "skip_thought_signature_validator", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if client, homeMode, errClient := currentSignatureKVClient(); homeMode {
|
||||
if errClient != nil {
|
||||
return "", errClient
|
||||
}
|
||||
key := signatureKVKey(modelName, text)
|
||||
raw, found, errGet := client.KVGet(ctx, key)
|
||||
if errGet != nil {
|
||||
return "", errGet
|
||||
}
|
||||
if !found {
|
||||
if groupKey == "gemini" {
|
||||
return "skip_thought_signature_validator", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
if _, errExpire := client.KVExpire(ctx, key, SignatureCacheTTL); errExpire != nil {
|
||||
return "", errExpire
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
val, ok := signatureCache.Load(groupKey)
|
||||
if !ok {
|
||||
if groupKey == "gemini" {
|
||||
return "skip_thought_signature_validator", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
sc := val.(*groupCache)
|
||||
|
||||
textHash := hashText(text)
|
||||
|
||||
now := time.Now()
|
||||
|
||||
sc.mu.Lock()
|
||||
entry, exists := sc.entries[textHash]
|
||||
if !exists {
|
||||
sc.mu.Unlock()
|
||||
if groupKey == "gemini" {
|
||||
return "skip_thought_signature_validator", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
if now.Sub(entry.Timestamp) > SignatureCacheTTL {
|
||||
delete(sc.entries, textHash)
|
||||
sc.mu.Unlock()
|
||||
if groupKey == "gemini" {
|
||||
return "skip_thought_signature_validator", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Refresh TTL on access (sliding expiration).
|
||||
entry.Timestamp = now
|
||||
sc.entries[textHash] = entry
|
||||
sc.mu.Unlock()
|
||||
|
||||
return entry.Signature, nil
|
||||
}
|
||||
|
||||
// ClearSignatureCache clears signature cache for a specific model group or all groups.
|
||||
func ClearSignatureCache(modelName string) {
|
||||
if modelName == "" {
|
||||
signatureCache.Range(func(key, _ any) bool {
|
||||
signatureCache.Delete(key)
|
||||
return true
|
||||
})
|
||||
return
|
||||
}
|
||||
groupKey := GetModelGroup(modelName)
|
||||
signatureCache.Delete(groupKey)
|
||||
}
|
||||
|
||||
// DeleteCachedSignatureRequired removes one exact cached signature.
|
||||
func DeleteCachedSignatureRequired(ctx context.Context, modelName, text string) error {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
if client, homeMode, errClient := currentSignatureKVClient(); homeMode {
|
||||
if errClient != nil {
|
||||
return errClient
|
||||
}
|
||||
_, errDel := client.KVDel(ctx, signatureKVKey(modelName, text))
|
||||
return errDel
|
||||
}
|
||||
groupKey := GetModelGroup(modelName)
|
||||
textHash := hashText(text)
|
||||
val, ok := signatureCache.Load(groupKey)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
sc := val.(*groupCache)
|
||||
sc.mu.Lock()
|
||||
delete(sc.entries, textHash)
|
||||
isEmpty := len(sc.entries) == 0
|
||||
sc.mu.Unlock()
|
||||
if isEmpty {
|
||||
signatureCache.Delete(groupKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasValidSignature checks if a signature is valid (non-empty and long enough)
|
||||
func HasValidSignature(modelName, signature string) bool {
|
||||
return (signature != "" && len(signature) >= MinValidSignatureLen) || (signature == "skip_thought_signature_validator" && GetModelGroup(modelName) == "gemini")
|
||||
}
|
||||
|
||||
func GetModelGroup(modelName string) string {
|
||||
if strings.Contains(modelName, "gpt") {
|
||||
return "gpt"
|
||||
} else if strings.Contains(modelName, "claude") {
|
||||
return "claude"
|
||||
} else if strings.Contains(modelName, "gemini") {
|
||||
return "gemini"
|
||||
}
|
||||
return modelName
|
||||
}
|
||||
|
||||
func signatureKVKey(modelName, text string) string {
|
||||
return fmt.Sprintf("cpa:signature:%s:%s", GetModelGroup(modelName), homekv.HashKeyPart(text))
|
||||
}
|
||||
|
||||
var signatureCacheEnabled atomic.Bool
|
||||
var signatureBypassStrictMode atomic.Bool
|
||||
|
||||
func init() {
|
||||
signatureCacheEnabled.Store(true)
|
||||
signatureBypassStrictMode.Store(false)
|
||||
}
|
||||
|
||||
// SetSignatureCacheEnabled switches Antigravity signature handling between cache mode and bypass mode.
|
||||
func SetSignatureCacheEnabled(enabled bool) {
|
||||
previous := signatureCacheEnabled.Swap(enabled)
|
||||
if previous == enabled {
|
||||
return
|
||||
}
|
||||
if !enabled {
|
||||
log.Info("antigravity signature cache DISABLED - bypass mode active, cached signatures will not be used for request translation")
|
||||
}
|
||||
}
|
||||
|
||||
// SignatureCacheEnabled returns whether signature cache validation is enabled.
|
||||
func SignatureCacheEnabled() bool {
|
||||
return signatureCacheEnabled.Load()
|
||||
}
|
||||
|
||||
// SetSignatureBypassStrictMode controls whether bypass mode uses strict protobuf-tree validation.
|
||||
func SetSignatureBypassStrictMode(strict bool) {
|
||||
previous := signatureBypassStrictMode.Swap(strict)
|
||||
if previous == strict {
|
||||
return
|
||||
}
|
||||
if strict {
|
||||
log.Debug("antigravity bypass signature validation: strict mode (protobuf tree)")
|
||||
} else {
|
||||
log.Debug("antigravity bypass signature validation: basic mode (R/E + 0x12)")
|
||||
}
|
||||
}
|
||||
|
||||
// SignatureBypassStrictMode returns whether bypass mode uses strict protobuf-tree validation.
|
||||
func SignatureBypassStrictMode() bool {
|
||||
return signatureBypassStrictMode.Load()
|
||||
}
|
||||
501
backend/internal/cache/signature_cache_test.go
vendored
Normal file
501
backend/internal/cache/signature_cache_test.go
vendored
Normal file
|
|
@ -0,0 +1,501 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
homekv "github.com/router-for-me/CLIProxyAPI/v7/internal/home"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const testModelName = "claude-sonnet-4-5"
|
||||
|
||||
type fakeSignatureKVClient struct {
|
||||
values map[string][]byte
|
||||
getErr error
|
||||
setErr error
|
||||
delErr error
|
||||
expireErr error
|
||||
getCount int
|
||||
setCount int
|
||||
delCount int
|
||||
expireCount int
|
||||
lastSetTTL time.Duration
|
||||
lastExpireTTL time.Duration
|
||||
}
|
||||
|
||||
func newFakeSignatureKVClient() *fakeSignatureKVClient {
|
||||
return &fakeSignatureKVClient{values: make(map[string][]byte)}
|
||||
}
|
||||
|
||||
func (c *fakeSignatureKVClient) KVGet(_ context.Context, key string) ([]byte, bool, error) {
|
||||
c.getCount++
|
||||
if c.getErr != nil {
|
||||
return nil, false, c.getErr
|
||||
}
|
||||
value, ok := c.values[key]
|
||||
if !ok {
|
||||
return nil, false, nil
|
||||
}
|
||||
return append([]byte(nil), value...), true, nil
|
||||
}
|
||||
|
||||
func (c *fakeSignatureKVClient) KVSet(_ context.Context, key string, value []byte, opts homekv.KVSetOptions) (bool, error) {
|
||||
c.setCount++
|
||||
c.lastSetTTL = opts.EX
|
||||
if c.setErr != nil {
|
||||
return false, c.setErr
|
||||
}
|
||||
c.values[key] = append([]byte(nil), value...)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *fakeSignatureKVClient) KVDel(_ context.Context, keys ...string) (int64, error) {
|
||||
c.delCount++
|
||||
if c.delErr != nil {
|
||||
return 0, c.delErr
|
||||
}
|
||||
var deleted int64
|
||||
for _, key := range keys {
|
||||
if _, ok := c.values[key]; ok {
|
||||
delete(c.values, key)
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func (c *fakeSignatureKVClient) KVExpire(_ context.Context, _ string, ttl time.Duration) (bool, error) {
|
||||
c.expireCount++
|
||||
c.lastExpireTTL = ttl
|
||||
if c.expireErr != nil {
|
||||
return false, c.expireErr
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func useFakeSignatureKVClient(t *testing.T, client *fakeSignatureKVClient, homeMode bool, errClient error) {
|
||||
t.Helper()
|
||||
previous := currentSignatureKVClient
|
||||
currentSignatureKVClient = func() (signatureKVClient, bool, error) {
|
||||
return client, homeMode, errClient
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
currentSignatureKVClient = previous
|
||||
})
|
||||
}
|
||||
|
||||
func TestCacheSignature_BasicStorageAndRetrieval(t *testing.T) {
|
||||
ClearSignatureCache("")
|
||||
|
||||
text := "This is some thinking text content"
|
||||
signature := "abc123validSignature1234567890123456789012345678901234567890"
|
||||
|
||||
// Store signature
|
||||
CacheSignature(testModelName, text, signature)
|
||||
|
||||
// Retrieve signature
|
||||
retrieved := GetCachedSignature(testModelName, text)
|
||||
if retrieved != signature {
|
||||
t.Errorf("Expected signature '%s', got '%s'", signature, retrieved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCachedSignatureRequiredHomeReadAndSlidingExpire(t *testing.T) {
|
||||
ClearSignatureCache("")
|
||||
text := "thinking text"
|
||||
signature := "abc123validSignature1234567890123456789012345678901234567890"
|
||||
client := newFakeSignatureKVClient()
|
||||
client.values[signatureKVKey(testModelName, text)] = []byte(signature)
|
||||
useFakeSignatureKVClient(t, client, true, nil)
|
||||
|
||||
got, errGet := GetCachedSignatureRequired(context.Background(), testModelName, text)
|
||||
if errGet != nil {
|
||||
t.Fatalf("GetCachedSignatureRequired() error = %v", errGet)
|
||||
}
|
||||
if got != signature {
|
||||
t.Fatalf("GetCachedSignatureRequired() = %q, want %q", got, signature)
|
||||
}
|
||||
if client.expireCount != 1 || client.lastExpireTTL != SignatureCacheTTL {
|
||||
t.Fatalf("KVExpire count/ttl = %d/%v, want 1/%v", client.expireCount, client.lastExpireTTL, SignatureCacheTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCachedSignatureRequiredHomeFailures(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
client *fakeSignatureKVClient
|
||||
}{
|
||||
{name: "get", client: &fakeSignatureKVClient{values: make(map[string][]byte), getErr: errors.New("get failed")}},
|
||||
{name: "expire", client: &fakeSignatureKVClient{values: map[string][]byte{
|
||||
signatureKVKey(testModelName, "thinking text"): []byte("abc123validSignature1234567890123456789012345678901234567890"),
|
||||
}, expireErr: errors.New("expire failed")}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
useFakeSignatureKVClient(t, tc.client, true, nil)
|
||||
if _, errGet := GetCachedSignatureRequired(context.Background(), testModelName, "thinking text"); errGet == nil {
|
||||
t.Fatalf("GetCachedSignatureRequired() error = nil, want error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCachedSignatureRequiredHomeMissDoesNotFallbackToLocalCache(t *testing.T) {
|
||||
ClearSignatureCache("")
|
||||
text := "thinking text"
|
||||
signature := "abc123validSignature1234567890123456789012345678901234567890"
|
||||
CacheSignature(testModelName, text, signature)
|
||||
|
||||
client := newFakeSignatureKVClient()
|
||||
useFakeSignatureKVClient(t, client, true, nil)
|
||||
|
||||
got, errGet := GetCachedSignatureRequired(context.Background(), testModelName, text)
|
||||
if errGet != nil {
|
||||
t.Fatalf("GetCachedSignatureRequired() error = %v", errGet)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("GetCachedSignatureRequired() = %q, want Home miss without local fallback", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheSignatureBestEffortHomeWriteFailureDoesNotUseLocalCache(t *testing.T) {
|
||||
ClearSignatureCache("")
|
||||
text := "thinking text"
|
||||
signature := "abc123validSignature1234567890123456789012345678901234567890"
|
||||
client := newFakeSignatureKVClient()
|
||||
client.setErr = errors.New("set failed")
|
||||
useFakeSignatureKVClient(t, client, true, nil)
|
||||
|
||||
if CacheSignatureBestEffort(context.Background(), testModelName, text, signature) {
|
||||
t.Fatalf("CacheSignatureBestEffort() = true, want false")
|
||||
}
|
||||
useFakeSignatureKVClient(t, newFakeSignatureKVClient(), false, nil)
|
||||
if got := GetCachedSignature(testModelName, text); got != "" {
|
||||
t.Fatalf("local cache = %q, want empty after Home write failure", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteCachedSignatureRequiredHomeExactKey(t *testing.T) {
|
||||
ClearSignatureCache("")
|
||||
text := "thinking text"
|
||||
signature := "abc123validSignature1234567890123456789012345678901234567890"
|
||||
client := newFakeSignatureKVClient()
|
||||
client.values[signatureKVKey(testModelName, text)] = []byte(signature)
|
||||
useFakeSignatureKVClient(t, client, true, nil)
|
||||
|
||||
if errDel := DeleteCachedSignatureRequired(context.Background(), testModelName, text); errDel != nil {
|
||||
t.Fatalf("DeleteCachedSignatureRequired() error = %v", errDel)
|
||||
}
|
||||
if _, ok := client.values[signatureKVKey(testModelName, text)]; ok {
|
||||
t.Fatalf("signature key was not deleted")
|
||||
}
|
||||
if client.delCount != 1 {
|
||||
t.Fatalf("KVDel count = %d, want 1", client.delCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearSignatureCacheHomeDoesNotPrefixDelete(t *testing.T) {
|
||||
client := newFakeSignatureKVClient()
|
||||
useFakeSignatureKVClient(t, client, true, nil)
|
||||
|
||||
ClearSignatureCache("")
|
||||
ClearSignatureCache(testModelName)
|
||||
|
||||
if client.delCount != 0 {
|
||||
t.Fatalf("ClearSignatureCache() KVDel count = %d, want 0", client.delCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCachedSignatureRequiredGeminiEmptyThinkingSentinel(t *testing.T) {
|
||||
client := newFakeSignatureKVClient()
|
||||
client.getErr = errors.New("get should not be called")
|
||||
useFakeSignatureKVClient(t, client, true, nil)
|
||||
|
||||
got, errGet := GetCachedSignatureRequired(context.Background(), "gemini-3-pro-preview", "")
|
||||
if errGet != nil {
|
||||
t.Fatalf("GetCachedSignatureRequired() error = %v", errGet)
|
||||
}
|
||||
if got != "skip_thought_signature_validator" {
|
||||
t.Fatalf("GetCachedSignatureRequired() = %q, want Gemini sentinel", got)
|
||||
}
|
||||
if client.getCount != 0 {
|
||||
t.Fatalf("KVGet count = %d, want 0", client.getCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheSignature_DifferentModelGroups(t *testing.T) {
|
||||
ClearSignatureCache("")
|
||||
|
||||
text := "Same text across models"
|
||||
sig1 := "signature1_1234567890123456789012345678901234567890123456"
|
||||
sig2 := "signature2_1234567890123456789012345678901234567890123456"
|
||||
|
||||
geminiModel := "gemini-3-pro-preview"
|
||||
CacheSignature(testModelName, text, sig1)
|
||||
CacheSignature(geminiModel, text, sig2)
|
||||
|
||||
if GetCachedSignature(testModelName, text) != sig1 {
|
||||
t.Error("Claude signature mismatch")
|
||||
}
|
||||
if GetCachedSignature(geminiModel, text) != sig2 {
|
||||
t.Error("Gemini signature mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheSignature_NotFound(t *testing.T) {
|
||||
ClearSignatureCache("")
|
||||
|
||||
// Non-existent session
|
||||
if got := GetCachedSignature(testModelName, "some text"); got != "" {
|
||||
t.Errorf("Expected empty string for nonexistent session, got '%s'", got)
|
||||
}
|
||||
|
||||
// Existing session but different text
|
||||
CacheSignature(testModelName, "text-a", "sigA12345678901234567890123456789012345678901234567890")
|
||||
if got := GetCachedSignature(testModelName, "text-b"); got != "" {
|
||||
t.Errorf("Expected empty string for different text, got '%s'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheSignature_EmptyInputs(t *testing.T) {
|
||||
ClearSignatureCache("")
|
||||
|
||||
// All empty/invalid inputs should be no-ops
|
||||
CacheSignature(testModelName, "", "sig12345678901234567890123456789012345678901234567890")
|
||||
CacheSignature(testModelName, "text", "")
|
||||
CacheSignature(testModelName, "text", "short") // Too short
|
||||
|
||||
if got := GetCachedSignature(testModelName, "text"); got != "" {
|
||||
t.Errorf("Expected empty after invalid cache attempts, got '%s'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheSignature_ShortSignatureRejected(t *testing.T) {
|
||||
ClearSignatureCache("")
|
||||
|
||||
text := "Some text"
|
||||
shortSig := "abc123" // Less than 50 chars
|
||||
|
||||
CacheSignature(testModelName, text, shortSig)
|
||||
|
||||
if got := GetCachedSignature(testModelName, text); got != "" {
|
||||
t.Errorf("Short signature should be rejected, got '%s'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearSignatureCache_ModelGroup(t *testing.T) {
|
||||
ClearSignatureCache("")
|
||||
|
||||
sig := "validSig1234567890123456789012345678901234567890123456"
|
||||
CacheSignature(testModelName, "text", sig)
|
||||
CacheSignature(testModelName, "text-2", sig)
|
||||
|
||||
ClearSignatureCache("session-1")
|
||||
|
||||
if got := GetCachedSignature(testModelName, "text"); got != sig {
|
||||
t.Error("signature should remain when clearing unknown session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearSignatureCache_AllSessions(t *testing.T) {
|
||||
ClearSignatureCache("")
|
||||
|
||||
sig := "validSig1234567890123456789012345678901234567890123456"
|
||||
CacheSignature(testModelName, "text", sig)
|
||||
CacheSignature(testModelName, "text-2", sig)
|
||||
|
||||
ClearSignatureCache("")
|
||||
|
||||
if got := GetCachedSignature(testModelName, "text"); got != "" {
|
||||
t.Error("text should be cleared")
|
||||
}
|
||||
if got := GetCachedSignature(testModelName, "text-2"); got != "" {
|
||||
t.Error("text-2 should be cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasValidSignature(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
modelName string
|
||||
signature string
|
||||
expected bool
|
||||
}{
|
||||
{"valid long signature", testModelName, "abc123validSignature1234567890123456789012345678901234567890", true},
|
||||
{"exactly 50 chars", testModelName, "12345678901234567890123456789012345678901234567890", true},
|
||||
{"49 chars - invalid", testModelName, "1234567890123456789012345678901234567890123456789", false},
|
||||
{"empty string", testModelName, "", false},
|
||||
{"short signature", testModelName, "abc", false},
|
||||
{"gemini sentinel", "gemini-3-pro-preview", "skip_thought_signature_validator", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := HasValidSignature(tt.modelName, tt.signature)
|
||||
if result != tt.expected {
|
||||
t.Errorf("HasValidSignature(%q) = %v, expected %v", tt.signature, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheSignature_TextHashCollisionResistance(t *testing.T) {
|
||||
ClearSignatureCache("")
|
||||
|
||||
// Different texts should produce different hashes
|
||||
text1 := "First thinking text"
|
||||
text2 := "Second thinking text"
|
||||
sig1 := "signature1_1234567890123456789012345678901234567890123456"
|
||||
sig2 := "signature2_1234567890123456789012345678901234567890123456"
|
||||
|
||||
CacheSignature(testModelName, text1, sig1)
|
||||
CacheSignature(testModelName, text2, sig2)
|
||||
|
||||
if GetCachedSignature(testModelName, text1) != sig1 {
|
||||
t.Error("text1 signature mismatch")
|
||||
}
|
||||
if GetCachedSignature(testModelName, text2) != sig2 {
|
||||
t.Error("text2 signature mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheSignature_UnicodeText(t *testing.T) {
|
||||
ClearSignatureCache("")
|
||||
|
||||
text := "한글 텍스트와 이모지 🎉 그리고 特殊文字"
|
||||
sig := "unicodeSig123456789012345678901234567890123456789012345"
|
||||
|
||||
CacheSignature(testModelName, text, sig)
|
||||
|
||||
if got := GetCachedSignature(testModelName, text); got != sig {
|
||||
t.Errorf("Unicode text signature retrieval failed, got '%s'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheSignature_Overwrite(t *testing.T) {
|
||||
ClearSignatureCache("")
|
||||
|
||||
text := "Same text"
|
||||
sig1 := "firstSignature12345678901234567890123456789012345678901"
|
||||
sig2 := "secondSignature1234567890123456789012345678901234567890"
|
||||
|
||||
CacheSignature(testModelName, text, sig1)
|
||||
CacheSignature(testModelName, text, sig2) // Overwrite
|
||||
|
||||
if got := GetCachedSignature(testModelName, text); got != sig2 {
|
||||
t.Errorf("Expected overwritten signature '%s', got '%s'", sig2, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Note: TTL expiration test is tricky to test without mocking time
|
||||
// We test the logic path exists but actual expiration would require time manipulation
|
||||
func TestCacheSignature_ExpirationLogic(t *testing.T) {
|
||||
ClearSignatureCache("")
|
||||
|
||||
// This test verifies the expiration check exists
|
||||
// In a real scenario, we'd mock time.Now()
|
||||
text := "text"
|
||||
sig := "validSig1234567890123456789012345678901234567890123456"
|
||||
|
||||
CacheSignature(testModelName, text, sig)
|
||||
|
||||
// Fresh entry should be retrievable
|
||||
if got := GetCachedSignature(testModelName, text); got != sig {
|
||||
t.Errorf("Fresh entry should be retrievable, got '%s'", got)
|
||||
}
|
||||
|
||||
// We can't easily test actual expiration without time mocking
|
||||
// but the logic is verified by the implementation
|
||||
_ = time.Now() // Acknowledge we're not testing time passage
|
||||
}
|
||||
|
||||
func TestSignatureModeSetters_LogAtInfoLevel(t *testing.T) {
|
||||
logger := log.StandardLogger()
|
||||
previousOutput := logger.Out
|
||||
previousLevel := logger.Level
|
||||
previousCache := SignatureCacheEnabled()
|
||||
previousStrict := SignatureBypassStrictMode()
|
||||
SetSignatureCacheEnabled(true)
|
||||
SetSignatureBypassStrictMode(false)
|
||||
buffer := &bytes.Buffer{}
|
||||
log.SetOutput(buffer)
|
||||
log.SetLevel(log.InfoLevel)
|
||||
t.Cleanup(func() {
|
||||
log.SetOutput(previousOutput)
|
||||
log.SetLevel(previousLevel)
|
||||
SetSignatureCacheEnabled(previousCache)
|
||||
SetSignatureBypassStrictMode(previousStrict)
|
||||
})
|
||||
|
||||
SetSignatureCacheEnabled(false)
|
||||
SetSignatureBypassStrictMode(true)
|
||||
SetSignatureBypassStrictMode(false)
|
||||
|
||||
output := buffer.String()
|
||||
if !strings.Contains(output, "antigravity signature cache DISABLED") {
|
||||
t.Fatalf("expected info output for disabling signature cache, got: %q", output)
|
||||
}
|
||||
if strings.Contains(output, "strict mode (protobuf tree)") {
|
||||
t.Fatalf("expected strict bypass mode log to stay below info level, got: %q", output)
|
||||
}
|
||||
if strings.Contains(output, "basic mode (R/E + 0x12)") {
|
||||
t.Fatalf("expected basic bypass mode log to stay below info level, got: %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignatureModeSetters_DoNotRepeatSameStateLogs(t *testing.T) {
|
||||
logger := log.StandardLogger()
|
||||
previousOutput := logger.Out
|
||||
previousLevel := logger.Level
|
||||
previousCache := SignatureCacheEnabled()
|
||||
previousStrict := SignatureBypassStrictMode()
|
||||
SetSignatureCacheEnabled(false)
|
||||
SetSignatureBypassStrictMode(true)
|
||||
buffer := &bytes.Buffer{}
|
||||
log.SetOutput(buffer)
|
||||
log.SetLevel(log.InfoLevel)
|
||||
t.Cleanup(func() {
|
||||
log.SetOutput(previousOutput)
|
||||
log.SetLevel(previousLevel)
|
||||
SetSignatureCacheEnabled(previousCache)
|
||||
SetSignatureBypassStrictMode(previousStrict)
|
||||
})
|
||||
|
||||
SetSignatureCacheEnabled(false)
|
||||
SetSignatureBypassStrictMode(true)
|
||||
|
||||
if buffer.Len() != 0 {
|
||||
t.Fatalf("expected repeated setter calls with unchanged state to stay silent, got: %q", buffer.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignatureBypassStrictMode_LogsAtDebugLevel(t *testing.T) {
|
||||
logger := log.StandardLogger()
|
||||
previousOutput := logger.Out
|
||||
previousLevel := logger.Level
|
||||
previousStrict := SignatureBypassStrictMode()
|
||||
SetSignatureBypassStrictMode(false)
|
||||
buffer := &bytes.Buffer{}
|
||||
log.SetOutput(buffer)
|
||||
log.SetLevel(log.DebugLevel)
|
||||
t.Cleanup(func() {
|
||||
log.SetOutput(previousOutput)
|
||||
log.SetLevel(previousLevel)
|
||||
SetSignatureBypassStrictMode(previousStrict)
|
||||
})
|
||||
|
||||
SetSignatureBypassStrictMode(true)
|
||||
SetSignatureBypassStrictMode(false)
|
||||
|
||||
output := buffer.String()
|
||||
if !strings.Contains(output, "strict mode (protobuf tree)") {
|
||||
t.Fatalf("expected debug output for strict bypass mode, got: %q", output)
|
||||
}
|
||||
if !strings.Contains(output, "basic mode (R/E + 0x12)") {
|
||||
t.Fatalf("expected debug output for basic bypass mode, got: %q", output)
|
||||
}
|
||||
}
|
||||
414
backend/internal/cache/xai_reasoning_replay_cache.go
vendored
Normal file
414
backend/internal/cache/xai_reasoning_replay_cache.go
vendored
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
homekv "github.com/router-for-me/CLIProxyAPI/v7/internal/home"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
const (
|
||||
// XAIReasoningReplayCacheTTL limits how long encrypted reasoning replay
|
||||
// items stay in process memory.
|
||||
XAIReasoningReplayCacheTTL = 1 * time.Hour
|
||||
|
||||
// XAIReasoningReplayCacheMaxEntries bounds process memory for replay
|
||||
// continuity. Oldest entries are evicted first.
|
||||
XAIReasoningReplayCacheMaxEntries = 10240
|
||||
|
||||
// XAIReasoningReplayCacheEvictBatchSize leaves headroom after the cache
|
||||
// reaches capacity so high write volume does not rescan the map every turn.
|
||||
XAIReasoningReplayCacheEvictBatchSize = 128
|
||||
)
|
||||
|
||||
type xaiReasoningReplayEntry struct {
|
||||
Items [][]byte
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
xaiReasoningReplayMu sync.Mutex
|
||||
xaiReasoningReplayEntries = make(map[string]xaiReasoningReplayEntry)
|
||||
)
|
||||
|
||||
type xaiReasoningReplayKVClient interface {
|
||||
KVGet(ctx context.Context, key string) ([]byte, bool, error)
|
||||
KVSet(ctx context.Context, key string, value []byte, opts homekv.KVSetOptions) (bool, error)
|
||||
KVDel(ctx context.Context, keys ...string) (int64, error)
|
||||
KVExpire(ctx context.Context, key string, ttl time.Duration) (bool, error)
|
||||
}
|
||||
|
||||
var currentXAIReasoningReplayKVClient = func() (xaiReasoningReplayKVClient, bool, error) {
|
||||
return homekv.CurrentKVClient()
|
||||
}
|
||||
|
||||
// CacheXAIReasoningReplayItem stores a final Grok reasoning item for stateless
|
||||
// replay. The stored item is normalized to the minimal shape accepted by
|
||||
// Responses input replay.
|
||||
func CacheXAIReasoningReplayItem(modelName, sessionKey string, item []byte) bool {
|
||||
return CacheXAIReasoningReplayItems(modelName, sessionKey, [][]byte{item})
|
||||
}
|
||||
|
||||
// CacheXAIReasoningReplayItems stores the final Grok assistant output items
|
||||
// needed to replay a stateless next turn.
|
||||
func CacheXAIReasoningReplayItems(modelName, sessionKey string, items [][]byte) bool {
|
||||
return CacheXAIReasoningReplayItemsBestEffort(context.Background(), modelName, sessionKey, items)
|
||||
}
|
||||
|
||||
// XAIReasoningReplayStoreStatus reports why a completed-turn cache write
|
||||
// succeeded or failed so callers can decide whether to keep prior entries.
|
||||
type XAIReasoningReplayStoreStatus int
|
||||
|
||||
const (
|
||||
// XAIReasoningReplayStoreInvalidArgs means model/session were empty.
|
||||
XAIReasoningReplayStoreInvalidArgs XAIReasoningReplayStoreStatus = iota
|
||||
// XAIReasoningReplayStored means a valid reasoning batch was written.
|
||||
XAIReasoningReplayStored
|
||||
// XAIReasoningReplayNoReplayableState means the completed output had no
|
||||
// cacheable reasoning batch (for example reasoning disabled).
|
||||
XAIReasoningReplayNoReplayableState
|
||||
// XAIReasoningReplayStoreBackendError means normalize succeeded but the
|
||||
// storage backend failed; previous entries should be retained.
|
||||
XAIReasoningReplayStoreBackendError
|
||||
)
|
||||
|
||||
// CacheXAIReasoningReplayItemsBestEffort stores replay items for completed response paths.
|
||||
func CacheXAIReasoningReplayItemsBestEffort(ctx context.Context, modelName, sessionKey string, items [][]byte) bool {
|
||||
return StoreXAIReasoningReplayItems(ctx, modelName, sessionKey, items) == XAIReasoningReplayStored
|
||||
}
|
||||
|
||||
// StoreXAIReasoningReplayItems stores replay items and distinguishes empty
|
||||
// completed state from backend failures.
|
||||
func StoreXAIReasoningReplayItems(ctx context.Context, modelName, sessionKey string, items [][]byte) XAIReasoningReplayStoreStatus {
|
||||
key := xaiReasoningReplayCacheKey(modelName, sessionKey)
|
||||
if key == "" {
|
||||
return XAIReasoningReplayStoreInvalidArgs
|
||||
}
|
||||
normalized, ok := normalizeXAIReasoningReplayItems(items)
|
||||
if !ok {
|
||||
return XAIReasoningReplayNoReplayableState
|
||||
}
|
||||
if client, homeMode, errClient := currentXAIReasoningReplayKVClient(); homeMode {
|
||||
if errClient != nil {
|
||||
log.Errorf("home kv best-effort xai reasoning replay set failed prefix=cpa:xai:*: %v", errClient)
|
||||
return XAIReasoningReplayStoreBackendError
|
||||
}
|
||||
raw, errMarshal := json.Marshal(normalized)
|
||||
if errMarshal != nil {
|
||||
log.Errorf("home kv best-effort xai reasoning replay set failed prefix=cpa:xai:*: %v", errMarshal)
|
||||
return XAIReasoningReplayStoreBackendError
|
||||
}
|
||||
written, errSet := client.KVSet(ctx, xaiReasoningReplayKVKey(modelName, sessionKey), raw, homekv.KVSetOptions{EX: XAIReasoningReplayCacheTTL})
|
||||
if errSet != nil {
|
||||
log.Errorf("home kv best-effort xai reasoning replay set failed prefix=cpa:xai:*: %v", errSet)
|
||||
return XAIReasoningReplayStoreBackendError
|
||||
}
|
||||
if !written {
|
||||
return XAIReasoningReplayStoreBackendError
|
||||
}
|
||||
return XAIReasoningReplayStored
|
||||
}
|
||||
|
||||
cacheCleanupOnce.Do(startCacheCleanup)
|
||||
now := time.Now()
|
||||
xaiReasoningReplayMu.Lock()
|
||||
defer xaiReasoningReplayMu.Unlock()
|
||||
xaiReasoningReplayEntries[key] = xaiReasoningReplayEntry{
|
||||
Items: normalized,
|
||||
Timestamp: now,
|
||||
}
|
||||
if len(xaiReasoningReplayEntries) > XAIReasoningReplayCacheMaxEntries {
|
||||
evictOldestXAIReasoningReplayEntriesLocked(XAIReasoningReplayCacheEvictBatchSize)
|
||||
}
|
||||
return XAIReasoningReplayStored
|
||||
}
|
||||
|
||||
// GetXAIReasoningReplayItem retrieves a normalized reasoning replay item.
|
||||
func GetXAIReasoningReplayItem(modelName, sessionKey string) ([]byte, bool) {
|
||||
items, ok := GetXAIReasoningReplayItems(modelName, sessionKey)
|
||||
if !ok || len(items) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return items[0], true
|
||||
}
|
||||
|
||||
// GetXAIReasoningReplayItems retrieves normalized assistant output items.
|
||||
func GetXAIReasoningReplayItems(modelName, sessionKey string) ([][]byte, bool) {
|
||||
items, ok, err := GetXAIReasoningReplayItemsRequired(context.Background(), modelName, sessionKey)
|
||||
if err == nil {
|
||||
return items, ok
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// GetXAIReasoningReplayItemsRequired retrieves replay items for request-time paths.
|
||||
func GetXAIReasoningReplayItemsRequired(ctx context.Context, modelName, sessionKey string) ([][]byte, bool, error) {
|
||||
key := xaiReasoningReplayCacheKey(modelName, sessionKey)
|
||||
if key == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
client, homeMode, errClient := currentXAIReasoningReplayKVClient()
|
||||
if homeMode {
|
||||
if errClient != nil {
|
||||
return nil, false, errClient
|
||||
}
|
||||
raw, found, errGet := client.KVGet(ctx, xaiReasoningReplayKVKey(modelName, sessionKey))
|
||||
if errGet != nil || !found {
|
||||
return nil, false, errGet
|
||||
}
|
||||
var homeItems [][]byte
|
||||
if errUnmarshal := json.Unmarshal(raw, &homeItems); errUnmarshal != nil {
|
||||
return nil, false, errUnmarshal
|
||||
}
|
||||
if _, errExpire := client.KVExpire(ctx, xaiReasoningReplayKVKey(modelName, sessionKey), XAIReasoningReplayCacheTTL); errExpire != nil {
|
||||
log.Warnf("home kv xai reasoning replay expire failed prefix=cpa:xai:*: %v", errExpire)
|
||||
}
|
||||
return cloneXAIReasoningReplayItems(homeItems), true, nil
|
||||
}
|
||||
|
||||
cacheCleanupOnce.Do(startCacheCleanup)
|
||||
now := time.Now()
|
||||
xaiReasoningReplayMu.Lock()
|
||||
defer xaiReasoningReplayMu.Unlock()
|
||||
entry, ok := xaiReasoningReplayEntries[key]
|
||||
if !ok {
|
||||
return nil, false, nil
|
||||
}
|
||||
if now.Sub(entry.Timestamp) > XAIReasoningReplayCacheTTL {
|
||||
delete(xaiReasoningReplayEntries, key)
|
||||
return nil, false, nil
|
||||
}
|
||||
entry.Timestamp = now
|
||||
xaiReasoningReplayEntries[key] = entry
|
||||
return cloneXAIReasoningReplayItems(entry.Items), true, nil
|
||||
}
|
||||
|
||||
// DeleteXAIReasoningReplayItem removes one replay item after upstream rejects
|
||||
// it or the caller otherwise knows it is stale.
|
||||
func DeleteXAIReasoningReplayItem(modelName, sessionKey string) {
|
||||
if errDelete := DeleteXAIReasoningReplayItemRequired(context.Background(), modelName, sessionKey); errDelete != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteXAIReasoningReplayItemRequired removes one replay item for request-time paths.
|
||||
func DeleteXAIReasoningReplayItemRequired(ctx context.Context, modelName, sessionKey string) error {
|
||||
key := xaiReasoningReplayCacheKey(modelName, sessionKey)
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
client, homeMode, errClient := currentXAIReasoningReplayKVClient()
|
||||
if homeMode {
|
||||
if errClient != nil {
|
||||
return errClient
|
||||
}
|
||||
_, errDel := client.KVDel(ctx, xaiReasoningReplayKVKey(modelName, sessionKey))
|
||||
return errDel
|
||||
}
|
||||
xaiReasoningReplayMu.Lock()
|
||||
delete(xaiReasoningReplayEntries, key)
|
||||
xaiReasoningReplayMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearXAIReasoningReplayCache clears all xAI reasoning replay state.
|
||||
func ClearXAIReasoningReplayCache() {
|
||||
xaiReasoningReplayMu.Lock()
|
||||
xaiReasoningReplayEntries = make(map[string]xaiReasoningReplayEntry)
|
||||
xaiReasoningReplayMu.Unlock()
|
||||
}
|
||||
|
||||
func xaiReasoningReplayCacheKey(modelName, sessionKey string) string {
|
||||
modelName = strings.TrimSpace(modelName)
|
||||
sessionKey = strings.TrimSpace(sessionKey)
|
||||
if modelName == "" || sessionKey == "" {
|
||||
return ""
|
||||
}
|
||||
// The session key is the continuity boundary. Keep this independent from
|
||||
// the selected upstream xAI credential so auth failover can preserve replay.
|
||||
return strings.Join([]string{"xai-reasoning-replay", modelName, sessionKey}, "\x00")
|
||||
}
|
||||
|
||||
func xaiReasoningReplayKVKey(modelName, sessionKey string) string {
|
||||
return "cpa:xai:reasoning-replay:" + homekv.HashKeyPart(strings.TrimSpace(modelName)) + ":" + homekv.HashKeyPart(strings.TrimSpace(sessionKey))
|
||||
}
|
||||
|
||||
func normalizeXAIReasoningReplayItems(items [][]byte) ([][]byte, bool) {
|
||||
normalized := make([][]byte, 0, len(items))
|
||||
hasReplayAnchor := false
|
||||
for _, item := range items {
|
||||
normalizedItem, ok := normalizeXAIReasoningReplayItem(item)
|
||||
if ok {
|
||||
normalized = append(normalized, normalizedItem)
|
||||
switch strings.TrimSpace(gjson.GetBytes(normalizedItem, "type").String()) {
|
||||
case "reasoning", "function_call", "custom_tool_call":
|
||||
hasReplayAnchor = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return normalized, hasReplayAnchor
|
||||
}
|
||||
|
||||
func normalizeXAIReasoningReplayItem(item []byte) ([]byte, bool) {
|
||||
itemResult := gjson.ParseBytes(item)
|
||||
switch strings.TrimSpace(itemResult.Get("type").String()) {
|
||||
case "reasoning":
|
||||
return normalizeXAIReasoningReplayReasoningItem(itemResult)
|
||||
case "message":
|
||||
return normalizeXAIReasoningReplayMessageItem(itemResult)
|
||||
case "function_call":
|
||||
return normalizeXAIReasoningReplayFunctionCallItem(itemResult)
|
||||
case "custom_tool_call":
|
||||
return normalizeXAIReasoningReplayCustomToolCallItem(itemResult)
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeXAIReasoningReplayReasoningItem(itemResult gjson.Result) ([]byte, bool) {
|
||||
encryptedContentResult := itemResult.Get("encrypted_content")
|
||||
if encryptedContentResult.Type != gjson.String {
|
||||
return nil, false
|
||||
}
|
||||
encryptedContent := encryptedContentResult.String()
|
||||
if encryptedContent != strings.TrimSpace(encryptedContent) {
|
||||
return nil, false
|
||||
}
|
||||
if _, err := signature.InspectGrokEncryptedContent(encryptedContent); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
normalized := []byte(`{"type":"reasoning","summary":[],"content":null}`)
|
||||
normalized, _ = sjson.SetBytes(normalized, "encrypted_content", encryptedContent)
|
||||
return normalized, true
|
||||
}
|
||||
|
||||
func normalizeXAIReasoningReplayMessageItem(itemResult gjson.Result) ([]byte, bool) {
|
||||
if !strings.EqualFold(strings.TrimSpace(itemResult.Get("role").String()), "assistant") {
|
||||
return nil, false
|
||||
}
|
||||
content := itemResult.Get("content")
|
||||
if !content.IsArray() || len(content.Array()) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
normalized := []byte(`{"type":"message","role":"assistant","content":[]}`)
|
||||
for _, part := range content.Array() {
|
||||
partType := strings.TrimSpace(part.Get("type").String())
|
||||
var nextPart []byte
|
||||
switch partType {
|
||||
case "output_text":
|
||||
textValue := part.Get("text")
|
||||
if textValue.Type != gjson.String {
|
||||
continue
|
||||
}
|
||||
nextPart = []byte(`{"type":"output_text","text":""}`)
|
||||
nextPart, _ = sjson.SetBytes(nextPart, "text", textValue.String())
|
||||
case "refusal":
|
||||
// Responses API refusal parts use the "refusal" field, not "text".
|
||||
refusalValue := part.Get("refusal")
|
||||
if refusalValue.Type != gjson.String {
|
||||
continue
|
||||
}
|
||||
nextPart = []byte(`{"type":"refusal","refusal":""}`)
|
||||
nextPart, _ = sjson.SetBytes(nextPart, "refusal", refusalValue.String())
|
||||
default:
|
||||
continue
|
||||
}
|
||||
updated, errSet := sjson.SetRawBytes(normalized, "content.-1", nextPart)
|
||||
if errSet != nil {
|
||||
return nil, false
|
||||
}
|
||||
normalized = updated
|
||||
}
|
||||
if len(gjson.GetBytes(normalized, "content").Array()) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return normalized, true
|
||||
}
|
||||
|
||||
func normalizeXAIReasoningReplayFunctionCallItem(itemResult gjson.Result) ([]byte, bool) {
|
||||
callID := strings.TrimSpace(itemResult.Get("call_id").String())
|
||||
name := strings.TrimSpace(itemResult.Get("name").String())
|
||||
arguments := itemResult.Get("arguments")
|
||||
if callID == "" || name == "" || arguments.Type != gjson.String {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
normalized := []byte(`{"type":"function_call"}`)
|
||||
normalized, _ = sjson.SetBytes(normalized, "call_id", callID)
|
||||
normalized, _ = sjson.SetBytes(normalized, "name", name)
|
||||
normalized, _ = sjson.SetBytes(normalized, "arguments", arguments.String())
|
||||
return normalized, true
|
||||
}
|
||||
|
||||
func normalizeXAIReasoningReplayCustomToolCallItem(itemResult gjson.Result) ([]byte, bool) {
|
||||
callID := strings.TrimSpace(itemResult.Get("call_id").String())
|
||||
name := strings.TrimSpace(itemResult.Get("name").String())
|
||||
input := itemResult.Get("input")
|
||||
if callID == "" || name == "" || !input.Exists() {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
normalized := []byte(`{"type":"custom_tool_call","status":"completed"}`)
|
||||
if status := strings.TrimSpace(itemResult.Get("status").String()); status != "" {
|
||||
normalized, _ = sjson.SetBytes(normalized, "status", status)
|
||||
}
|
||||
normalized, _ = sjson.SetBytes(normalized, "call_id", callID)
|
||||
normalized, _ = sjson.SetBytes(normalized, "name", name)
|
||||
if input.Type == gjson.String {
|
||||
normalized, _ = sjson.SetBytes(normalized, "input", input.String())
|
||||
} else {
|
||||
normalized, _ = sjson.SetRawBytes(normalized, "input", []byte(input.Raw))
|
||||
}
|
||||
return normalized, true
|
||||
}
|
||||
|
||||
func cloneXAIReasoningReplayItems(items [][]byte) [][]byte {
|
||||
cloned := make([][]byte, 0, len(items))
|
||||
for _, item := range items {
|
||||
cloned = append(cloned, append([]byte(nil), item...))
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func evictOldestXAIReasoningReplayEntriesLocked(count int) {
|
||||
if count <= 0 || len(xaiReasoningReplayEntries) == 0 {
|
||||
return
|
||||
}
|
||||
type candidate struct {
|
||||
key string
|
||||
timestamp time.Time
|
||||
}
|
||||
candidates := make([]candidate, 0, len(xaiReasoningReplayEntries))
|
||||
for key, entry := range xaiReasoningReplayEntries {
|
||||
candidates = append(candidates, candidate{key: key, timestamp: entry.Timestamp})
|
||||
}
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
return candidates[i].timestamp.Before(candidates[j].timestamp)
|
||||
})
|
||||
if count > len(candidates) {
|
||||
count = len(candidates)
|
||||
}
|
||||
for i := 0; i < count; i++ {
|
||||
delete(xaiReasoningReplayEntries, candidates[i].key)
|
||||
}
|
||||
}
|
||||
|
||||
func purgeExpiredXAIReasoningReplayCache(now time.Time) {
|
||||
xaiReasoningReplayMu.Lock()
|
||||
for key, entry := range xaiReasoningReplayEntries {
|
||||
if now.Sub(entry.Timestamp) > XAIReasoningReplayCacheTTL {
|
||||
delete(xaiReasoningReplayEntries, key)
|
||||
}
|
||||
}
|
||||
xaiReasoningReplayMu.Unlock()
|
||||
}
|
||||
281
backend/internal/cache/xai_reasoning_replay_cache_test.go
vendored
Normal file
281
backend/internal/cache/xai_reasoning_replay_cache_test.go
vendored
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
homekv "github.com/router-for-me/CLIProxyAPI/v7/internal/home"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
type fakeXAIReasoningReplayKVClient struct {
|
||||
values map[string][]byte
|
||||
getErr error
|
||||
setErr error
|
||||
delErr error
|
||||
expireErr error
|
||||
getCount int
|
||||
setCount int
|
||||
delCount int
|
||||
expireCount int
|
||||
lastSetTTL time.Duration
|
||||
lastExpireTTL time.Duration
|
||||
}
|
||||
|
||||
func newFakeXAIReasoningReplayKVClient() *fakeXAIReasoningReplayKVClient {
|
||||
return &fakeXAIReasoningReplayKVClient{values: make(map[string][]byte)}
|
||||
}
|
||||
|
||||
func (c *fakeXAIReasoningReplayKVClient) KVGet(_ context.Context, key string) ([]byte, bool, error) {
|
||||
c.getCount++
|
||||
if c.getErr != nil {
|
||||
return nil, false, c.getErr
|
||||
}
|
||||
value, ok := c.values[key]
|
||||
if !ok {
|
||||
return nil, false, nil
|
||||
}
|
||||
return append([]byte(nil), value...), true, nil
|
||||
}
|
||||
|
||||
func (c *fakeXAIReasoningReplayKVClient) KVSet(_ context.Context, key string, value []byte, opts homekv.KVSetOptions) (bool, error) {
|
||||
c.setCount++
|
||||
c.lastSetTTL = opts.EX
|
||||
if c.setErr != nil {
|
||||
return false, c.setErr
|
||||
}
|
||||
c.values[key] = append([]byte(nil), value...)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *fakeXAIReasoningReplayKVClient) KVDel(_ context.Context, keys ...string) (int64, error) {
|
||||
c.delCount++
|
||||
if c.delErr != nil {
|
||||
return 0, c.delErr
|
||||
}
|
||||
var deleted int64
|
||||
for _, key := range keys {
|
||||
if _, ok := c.values[key]; ok {
|
||||
delete(c.values, key)
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func (c *fakeXAIReasoningReplayKVClient) KVExpire(_ context.Context, _ string, ttl time.Duration) (bool, error) {
|
||||
c.expireCount++
|
||||
c.lastExpireTTL = ttl
|
||||
if c.expireErr != nil {
|
||||
return false, c.expireErr
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func useFakeXAIReasoningReplayKVClient(t *testing.T, client *fakeXAIReasoningReplayKVClient, homeMode bool, errClient error) {
|
||||
t.Helper()
|
||||
previous := currentXAIReasoningReplayKVClient
|
||||
currentXAIReasoningReplayKVClient = func() (xaiReasoningReplayKVClient, bool, error) {
|
||||
return client, homeMode, errClient
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
currentXAIReasoningReplayKVClient = previous
|
||||
})
|
||||
}
|
||||
|
||||
func mustXAIReasoningReplayJSON(t *testing.T, items [][]byte) []byte {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal replay items: %v", err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func TestXAIReasoningReplayCacheRejectsCodexEncryptedContent(t *testing.T) {
|
||||
ClearXAIReasoningReplayCache()
|
||||
t.Cleanup(ClearXAIReasoningReplayCache)
|
||||
|
||||
if CacheXAIReasoningReplayItem("grok-4.3", "claude:xai-cache-test", []byte(`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"gAAAAABinvalid-gpt-shape"}`)) {
|
||||
t.Fatal("xAI replay cache should reject GPT/Codex-shaped encrypted_content")
|
||||
}
|
||||
if _, ok := GetXAIReasoningReplayItem("grok-4.3", "claude:xai-cache-test"); ok {
|
||||
t.Fatal("xAI replay cache should not store GPT/Codex-shaped encrypted_content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestXAIReasoningReplayCacheStoresGrokEncryptedContent(t *testing.T) {
|
||||
ClearXAIReasoningReplayCache()
|
||||
t.Cleanup(ClearXAIReasoningReplayCache)
|
||||
|
||||
encryptedContent := validGrokEncryptedContentForReplayCacheTest()
|
||||
if !CacheXAIReasoningReplayItem("grok-4.3", "claude:xai-cache-test", []byte(`{"type":"reasoning","summary":[{"type":"summary_text","text":"visible"}],"content":null,"encrypted_content":"`+encryptedContent+`"}`)) {
|
||||
t.Fatal("xAI replay cache should store valid Grok encrypted_content")
|
||||
}
|
||||
item, ok := GetXAIReasoningReplayItem("grok-4.3", "claude:xai-cache-test")
|
||||
if !ok {
|
||||
t.Fatal("xAI replay cache item missing after store")
|
||||
}
|
||||
if got := gjson.GetBytes(item, "encrypted_content").String(); got != encryptedContent {
|
||||
t.Fatalf("encrypted_content = %q, want %q; item=%s", got, encryptedContent, string(item))
|
||||
}
|
||||
if got := gjson.GetBytes(item, "summary").Array(); len(got) != 0 {
|
||||
t.Fatalf("summary length = %d, want normalized empty summary; item=%s", len(got), string(item))
|
||||
}
|
||||
}
|
||||
|
||||
func TestXAIReasoningReplayCacheStoresAssistantMessageWithReasoning(t *testing.T) {
|
||||
ClearXAIReasoningReplayCache()
|
||||
t.Cleanup(ClearXAIReasoningReplayCache)
|
||||
encryptedContent := validGrokEncryptedContentForReplayCacheTest()
|
||||
|
||||
items := [][]byte{
|
||||
[]byte(`{"id":"rs_1","type":"reasoning","summary":[{"type":"summary_text","text":"visible"}],"encrypted_content":"` + encryptedContent + `"}`),
|
||||
[]byte(`{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"answer","annotations":[],"logprobs":[]}]}`),
|
||||
}
|
||||
if !CacheXAIReasoningReplayItems("grok-4.5", "prompt-cache:session", items) {
|
||||
t.Fatal("expected reasoning replay items to be cached")
|
||||
}
|
||||
|
||||
got, ok := GetXAIReasoningReplayItems("grok-4.5", "prompt-cache:session")
|
||||
if !ok || len(got) != 2 {
|
||||
t.Fatalf("cached items = %q, %v, want two items", got, ok)
|
||||
}
|
||||
if gjson.GetBytes(got[0], "encrypted_content").String() != encryptedContent {
|
||||
t.Fatalf("reasoning encrypted_content not preserved: %s", got[0])
|
||||
}
|
||||
if gotText := gjson.GetBytes(got[1], "content.0.text").String(); gotText != "answer" {
|
||||
t.Fatalf("assistant message text = %q, want answer; item=%s", gotText, got[1])
|
||||
}
|
||||
if gjson.GetBytes(got[1], "id").Exists() || gjson.GetBytes(got[1], "status").Exists() {
|
||||
t.Fatalf("assistant message transport fields were not stripped: %s", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestXAIReasoningReplayCacheRejectsAssistantMessageWithoutReasoning(t *testing.T) {
|
||||
ClearXAIReasoningReplayCache()
|
||||
t.Cleanup(ClearXAIReasoningReplayCache)
|
||||
|
||||
items := [][]byte{
|
||||
[]byte(`{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"answer"}]}`),
|
||||
}
|
||||
if CacheXAIReasoningReplayItems("grok-4.5", "prompt-cache:message-only", items) {
|
||||
t.Fatal("message-only replay batch must not be cached")
|
||||
}
|
||||
if _, ok := GetXAIReasoningReplayItems("grok-4.5", "prompt-cache:message-only"); ok {
|
||||
t.Fatal("message-only replay batch unexpectedly exists in cache")
|
||||
}
|
||||
}
|
||||
|
||||
func TestXAIReasoningReplayCacheStoresToolCallWithoutReasoning(t *testing.T) {
|
||||
ClearXAIReasoningReplayCache()
|
||||
t.Cleanup(ClearXAIReasoningReplayCache)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sessionKey string
|
||||
item []byte
|
||||
wantType string
|
||||
wantPayload string
|
||||
}{
|
||||
{
|
||||
name: "function call",
|
||||
sessionKey: "prompt-cache:function-call-only",
|
||||
item: []byte(`{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\"q\":\"weather\"}"}`),
|
||||
wantType: "function_call",
|
||||
wantPayload: `{"q":"weather"}`,
|
||||
},
|
||||
{
|
||||
name: "custom tool call",
|
||||
sessionKey: "prompt-cache:custom-tool-call-only",
|
||||
item: []byte(`{"type":"custom_tool_call","call_id":"call_2","name":"shell","input":"pwd"}`),
|
||||
wantType: "custom_tool_call",
|
||||
wantPayload: "pwd",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if !CacheXAIReasoningReplayItems("grok-4.3", tt.sessionKey, [][]byte{tt.item}) {
|
||||
t.Fatal("tool-call-only replay batch must be cached")
|
||||
}
|
||||
items, ok := GetXAIReasoningReplayItems("grok-4.3", tt.sessionKey)
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("cached items = %q, %v, want one item", items, ok)
|
||||
}
|
||||
if got := gjson.GetBytes(items[0], "type").String(); got != tt.wantType {
|
||||
t.Fatalf("cached type = %q, want %q; item=%s", got, tt.wantType, items[0])
|
||||
}
|
||||
payloadPath := "arguments"
|
||||
if tt.wantType == "custom_tool_call" {
|
||||
payloadPath = "input"
|
||||
}
|
||||
if got := gjson.GetBytes(items[0], payloadPath).String(); got != tt.wantPayload {
|
||||
t.Fatalf("cached %s = %q, want %q; item=%s", payloadPath, got, tt.wantPayload, items[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestXAIReasoningReplayRequiredHomeExpireFailureReturnsItems(t *testing.T) {
|
||||
ClearXAIReasoningReplayCache()
|
||||
t.Cleanup(ClearXAIReasoningReplayCache)
|
||||
client := newFakeXAIReasoningReplayKVClient()
|
||||
client.expireErr = errors.New("expire failed")
|
||||
key := xaiReasoningReplayKVKey("grok-4.3", "session-home")
|
||||
item := []byte(`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"` + validGrokEncryptedContentForReplayCacheTest() + `"}`)
|
||||
client.values[key] = mustXAIReasoningReplayJSON(t, [][]byte{item})
|
||||
useFakeXAIReasoningReplayKVClient(t, client, true, nil)
|
||||
|
||||
items, found, errGet := GetXAIReasoningReplayItemsRequired(context.Background(), "grok-4.3", "session-home")
|
||||
if errGet != nil {
|
||||
t.Fatalf("GetXAIReasoningReplayItemsRequired() error = %v", errGet)
|
||||
}
|
||||
if !found || len(items) != 1 || string(items[0]) != string(item) {
|
||||
t.Fatalf("GetXAIReasoningReplayItemsRequired() = %q, %v, want item, true", items, found)
|
||||
}
|
||||
if client.expireCount != 1 || client.lastExpireTTL != XAIReasoningReplayCacheTTL {
|
||||
t.Fatalf("KVExpire count/ttl = %d/%v, want 1/%v", client.expireCount, client.lastExpireTTL, XAIReasoningReplayCacheTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func validGrokEncryptedContentForReplayCacheTest() string {
|
||||
buf := make([]byte, 0, 256)
|
||||
for i := 0; len(buf) < 256; i++ {
|
||||
sum := sha256.Sum256([]byte{byte(i), byte(i >> 8), byte(i >> 16), 99})
|
||||
buf = append(buf, sum[:]...)
|
||||
}
|
||||
return base64.RawStdEncoding.EncodeToString(buf[:256])
|
||||
}
|
||||
|
||||
func TestXAIReasoningReplayCacheStoresRefusalMessagePart(t *testing.T) {
|
||||
ClearXAIReasoningReplayCache()
|
||||
t.Cleanup(ClearXAIReasoningReplayCache)
|
||||
encryptedContent := validGrokEncryptedContentForReplayCacheTest()
|
||||
|
||||
items := [][]byte{
|
||||
[]byte(`{"type":"reasoning","summary":[],"encrypted_content":"` + encryptedContent + `"}`),
|
||||
[]byte(`{"type":"message","role":"assistant","content":[{"type":"refusal","refusal":"I cannot help with that"}]}`),
|
||||
}
|
||||
if !CacheXAIReasoningReplayItems("grok-4.5", "prompt-cache:refusal", items) {
|
||||
t.Fatal("expected refusal message with reasoning to be cached")
|
||||
}
|
||||
got, ok := GetXAIReasoningReplayItems("grok-4.5", "prompt-cache:refusal")
|
||||
if !ok || len(got) != 2 {
|
||||
t.Fatalf("cached items = %q, %v, want reasoning + refusal message", got, ok)
|
||||
}
|
||||
if gjson.GetBytes(got[1], "content.0.type").String() != "refusal" {
|
||||
t.Fatalf("message part type = %s, want refusal; item=%s", gjson.GetBytes(got[1], "content.0.type").String(), got[1])
|
||||
}
|
||||
if gjson.GetBytes(got[1], "content.0.refusal").String() != "I cannot help with that" {
|
||||
t.Fatalf("refusal text missing; item=%s", got[1])
|
||||
}
|
||||
if gjson.GetBytes(got[1], "content.0.text").Exists() {
|
||||
t.Fatalf("refusal part should not use text field; item=%s", got[1])
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue