415 lines
14 KiB
Go
415 lines
14 KiB
Go
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()
|
|
}
|