Files
router-for-me--cliproxyapi/internal/cache/xai_reasoning_replay_cache_test.go
2026-07-13 12:35:43 +08:00

282 lines
10 KiB
Go

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