Add projects

This commit is contained in:
Alois 2026-08-24 00:10:41 +02:00
commit 8b607dd700
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
1802 changed files with 503346 additions and 2 deletions

View 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()
}

View 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
View 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)
}

View 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)
}
}

View 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()
}

View 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)
}
}

View 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()
}

View 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)
}
}

View 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()
}

View 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")
}
}

View 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()
}

View 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)
}
}

View 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()
}

View 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])
}
}