mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-08 01:16:47 +08:00
feat(executor): implement agent-scoped cache key isolation and enhance replay handling
- Introduced agent-specific cache key isolation to ensure distinct prompt caching across agents. - Improved reasoning replay handling for Claude models, including cumulative tool turn restoration and validation. - Enhanced HTTP and WebSocket session consistency during execution scope and cache replay synchronization. - Added new unit tests and extended coverage for prompt caching, agent isolation, and replay cache functionality. Closes: #4352
This commit is contained in:
+159
-3
@@ -16,6 +16,9 @@ import (
|
||||
)
|
||||
|
||||
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
|
||||
@@ -24,6 +27,12 @@ const (
|
||||
// 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
|
||||
@@ -42,6 +51,7 @@ var (
|
||||
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)
|
||||
}
|
||||
@@ -105,13 +115,132 @@ func CacheCodexReasoningReplayItemsBestEffort(ctx context.Context, modelName, se
|
||||
return true
|
||||
}
|
||||
|
||||
// GetCodexReasoningReplayItem retrieves a normalized reasoning replay item.
|
||||
// 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 || len(items) == 0 {
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return items[0], true
|
||||
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.
|
||||
@@ -223,12 +352,15 @@ func normalizeCodexReasoningReplayItems(items [][]byte) ([][]byte, bool) {
|
||||
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":
|
||||
@@ -240,6 +372,30 @@ func normalizeCodexReasoningReplayItem(item []byte) ([]byte, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
+117
@@ -1,18 +1,22 @@
|
||||
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
|
||||
@@ -31,6 +35,8 @@ func newFakeCodexReasoningReplayKVClient() *fakeCodexReasoningReplayKVClient {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -43,6 +49,8 @@ func (c *fakeCodexReasoningReplayKVClient) KVGet(_ context.Context, key string)
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -52,7 +60,25 @@ func (c *fakeCodexReasoningReplayKVClient) KVSet(_ context.Context, key string,
|
||||
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
|
||||
@@ -68,6 +94,8 @@ func (c *fakeCodexReasoningReplayKVClient) KVDel(_ context.Context, keys ...stri
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -185,6 +213,95 @@ func TestCodexReasoningReplayBestEffortHomeWriteFailureDoesNotUseLocalCache(t *t
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -647,6 +647,40 @@ func (c *Client) KVSetNX(ctx context.Context, key string, value []byte, ttl time
|
||||
return c.KVSet(ctx, key, value, opts)
|
||||
}
|
||||
|
||||
// KVCompareAndSwap atomically replaces a value only when its current state matches the expected state.
|
||||
func (c *Client) KVCompareAndSwap(ctx context.Context, key string, expected []byte, expectedExists bool, value []byte, ttl time.Duration) (bool, error) {
|
||||
cmd, errClient := c.commandClient()
|
||||
if errClient != nil {
|
||||
return false, errClient
|
||||
}
|
||||
const script = `
|
||||
local current = redis.call("GET", KEYS[1])
|
||||
if ARGV[1] == "1" then
|
||||
if not current or current ~= ARGV[2] then
|
||||
return 0
|
||||
end
|
||||
elseif current then
|
||||
return 0
|
||||
end
|
||||
local ttl = tonumber(ARGV[4])
|
||||
if ttl and ttl > 0 then
|
||||
redis.call("SET", KEYS[1], ARGV[3], "PX", ttl)
|
||||
else
|
||||
redis.call("SET", KEYS[1], ARGV[3])
|
||||
end
|
||||
return 1
|
||||
`
|
||||
expectedFlag := "0"
|
||||
if expectedExists {
|
||||
expectedFlag = "1"
|
||||
}
|
||||
result, errEval := cmd.Eval(ctx, script, []string{key}, expectedFlag, expected, value, durationCeil(ttl, time.Millisecond)).Int64()
|
||||
if errEval != nil {
|
||||
return false, errEval
|
||||
}
|
||||
return result == 1, nil
|
||||
}
|
||||
|
||||
func (c *Client) KVDel(ctx context.Context, keys ...string) (int64, error) {
|
||||
if len(keys) == 0 {
|
||||
return 0, nil
|
||||
|
||||
@@ -254,6 +254,26 @@ func TestKVSetConditionUnmetReturnsFalse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestKVCompareAndSwapReturnsScriptResult(t *testing.T) {
|
||||
client, commands := newRedisCommandTestClient(t, func(args []string) string {
|
||||
if len(args) > 0 && strings.EqualFold(args[0], "EVAL") {
|
||||
return ":1\r\n"
|
||||
}
|
||||
return "-ERR unexpected command\r\n"
|
||||
})
|
||||
|
||||
swapped, errCAS := client.KVCompareAndSwap(context.Background(), "key", []byte("old"), true, []byte("new"), 1500*time.Millisecond)
|
||||
if errCAS != nil {
|
||||
t.Fatalf("KVCompareAndSwap() error = %v", errCAS)
|
||||
}
|
||||
if !swapped {
|
||||
t.Fatal("KVCompareAndSwap() swapped = false, want true")
|
||||
}
|
||||
if lastCommand := commands.Last(); len(lastCommand) < 2 || !strings.EqualFold(lastCommand[0], "EVAL") {
|
||||
t.Fatalf("last command = %#v, want EVAL", lastCommand)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKVMSetUsesStableKeyOrder(t *testing.T) {
|
||||
client, commands := newRedisCommandTestClient(t, func(args []string) string {
|
||||
if len(args) > 0 && strings.EqualFold(args[0], "MSET") {
|
||||
|
||||
@@ -302,8 +302,9 @@ func translateCodexRequestPair(from, to sdktranslator.Format, model string, orig
|
||||
}
|
||||
|
||||
type codexReasoningReplayScope struct {
|
||||
modelName string
|
||||
sessionKey string
|
||||
modelName string
|
||||
sessionKey string
|
||||
requestFingerprint string
|
||||
}
|
||||
|
||||
func (s codexReasoningReplayScope) valid() bool {
|
||||
@@ -324,11 +325,7 @@ func applyCodexReasoningReplayCacheRequired(ctx context.Context, from sdktransla
|
||||
if errReplay != nil || !ok {
|
||||
return body, scope, errReplay
|
||||
}
|
||||
items = filterCodexReasoningReplayItemsForInput(body, items)
|
||||
if len(items) == 0 {
|
||||
return body, scope, nil
|
||||
}
|
||||
updated, ok := insertCodexReasoningReplayItems(body, items)
|
||||
updated, ok := insertCodexReasoningReplayTurns(body, items)
|
||||
if !ok {
|
||||
return body, scope, nil
|
||||
}
|
||||
@@ -339,9 +336,15 @@ func codexReasoningReplayScopeFromRequest(ctx context.Context, from sdktranslato
|
||||
if !codexReasoningReplayEnabledForSource(from) {
|
||||
return codexReasoningReplayScope{}
|
||||
}
|
||||
modelName := strings.TrimSpace(gjson.GetBytes(body, "model").String())
|
||||
if modelName == "" {
|
||||
modelName = thinking.ParseSuffix(req.Model).ModelName
|
||||
}
|
||||
inputItems := gjson.GetBytes(body, "input").Array()
|
||||
return codexReasoningReplayScope{
|
||||
modelName: thinking.ParseSuffix(req.Model).ModelName,
|
||||
sessionKey: codexReasoningReplaySessionKey(ctx, from, req, opts, body),
|
||||
modelName: modelName,
|
||||
sessionKey: codexReasoningReplaySessionKey(ctx, from, req, opts, body),
|
||||
requestFingerprint: codexReplayInputPrefixFingerprint(inputItems, len(inputItems)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,17 +357,19 @@ func sourceFormatEqual(from, want sdktranslator.Format) bool {
|
||||
}
|
||||
|
||||
func codexClaudeCodeReplaySessionKey(ctx context.Context, payload []byte, headers http.Header) string {
|
||||
sessionID := helps.ExtractClaudeCodeSessionID(ctx, payload, headers)
|
||||
if sessionID == "" {
|
||||
return ""
|
||||
}
|
||||
return "claude:" + sessionID
|
||||
sessionKey, _ := helps.ClaudeCodeExecutionScope(ctx, payload, headers)
|
||||
return sessionKey
|
||||
}
|
||||
|
||||
func codexReasoningReplaySessionKey(ctx context.Context, from sdktranslator.Format, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, body []byte) string {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if sourceFormatEqual(from, sdktranslator.FormatClaude) {
|
||||
if sessionKey := codexClaudeCodeReplaySessionKey(ctx, req.Payload, opts.Headers); sessionKey != "" {
|
||||
return sessionKey
|
||||
}
|
||||
}
|
||||
if value := metadataString(opts.Metadata, cliproxyexecutor.ExecutionSessionMetadataKey); value != "" {
|
||||
return "execution:" + value
|
||||
}
|
||||
@@ -385,9 +390,6 @@ func codexReasoningReplaySessionKey(ctx context.Context, from sdktranslator.Form
|
||||
return value
|
||||
}
|
||||
}
|
||||
if sourceFormatEqual(from, sdktranslator.FormatClaude) {
|
||||
return codexClaudeCodeReplaySessionKey(ctx, req.Payload, opts.Headers)
|
||||
}
|
||||
if sourceFormatEqual(from, sdktranslator.FormatOpenAI) {
|
||||
if apiKey := strings.TrimSpace(helps.APIKeyFromContext(ctx)); apiKey != "" {
|
||||
return "prompt-cache:" + uuid.NewSHA1(uuid.NameSpaceOID, []byte("cli-proxy-api:codex:prompt-cache:"+apiKey)).String()
|
||||
@@ -483,6 +485,261 @@ func codexInputHasValidReasoningEncryptedContent(body []byte) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type codexReasoningReplayTurn struct {
|
||||
marked bool
|
||||
assistantFingerprint string
|
||||
requestFingerprint string
|
||||
callIDs []string
|
||||
items [][]byte
|
||||
}
|
||||
|
||||
func insertCodexReasoningReplayTurns(body []byte, replayItems [][]byte) ([]byte, bool) {
|
||||
input := gjson.GetBytes(body, "input")
|
||||
if !input.IsArray() || len(replayItems) == 0 {
|
||||
return body, false
|
||||
}
|
||||
inputItems := input.Array()
|
||||
turns := splitCodexReasoningReplayTurns(replayItems)
|
||||
insertions := make(map[int][][]byte)
|
||||
usedAnchorIndexes := make(map[int]bool)
|
||||
fallbackAnchorEnd := len(inputItems) - 1
|
||||
inserted := false
|
||||
for turnIndex := len(turns) - 1; turnIndex >= 0; turnIndex-- {
|
||||
turn := turns[turnIndex]
|
||||
if len(turn.items) == 0 {
|
||||
continue
|
||||
}
|
||||
if !turn.marked {
|
||||
items := filterCodexReasoningReplayItemsForInput(body, turn.items)
|
||||
if len(items) == 0 {
|
||||
continue
|
||||
}
|
||||
index := codexReasoningReplayInsertIndex(inputItems, items)
|
||||
items = codexAlignReasoningReplayToolCallIDs(inputItems, items)
|
||||
insertions[index] = append(items, insertions[index]...)
|
||||
inserted = true
|
||||
continue
|
||||
}
|
||||
|
||||
anchorIndex, matched := codexReasoningReplayTurnAnchorIndex(inputItems, turn, fallbackAnchorEnd, usedAnchorIndexes)
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
usedAnchorIndexes[anchorIndex] = true
|
||||
if turn.requestFingerprint == "" {
|
||||
fallbackAnchorEnd = anchorIndex - 1
|
||||
}
|
||||
items := filterCodexReasoningReplayTurnItems(inputItems, turn.items)
|
||||
if len(items) == 0 {
|
||||
continue
|
||||
}
|
||||
items = codexAlignReasoningReplayToolCallIDs(inputItems, items)
|
||||
insertions[anchorIndex] = append(items, insertions[anchorIndex]...)
|
||||
inserted = true
|
||||
}
|
||||
if !inserted {
|
||||
return body, false
|
||||
}
|
||||
|
||||
items := make([]string, 0, len(inputItems)+len(replayItems))
|
||||
for index, inputItem := range inputItems {
|
||||
for _, replayItem := range insertions[index] {
|
||||
items = append(items, string(replayItem))
|
||||
}
|
||||
items = append(items, inputItem.Raw)
|
||||
}
|
||||
for _, replayItem := range insertions[len(inputItems)] {
|
||||
items = append(items, string(replayItem))
|
||||
}
|
||||
updated, err := sjson.SetRawBytes(body, "input", []byte("["+strings.Join(items, ",")+"]"))
|
||||
if err != nil {
|
||||
return body, false
|
||||
}
|
||||
return updated, true
|
||||
}
|
||||
|
||||
func splitCodexReasoningReplayTurns(items [][]byte) []codexReasoningReplayTurn {
|
||||
turns := make([]codexReasoningReplayTurn, 0)
|
||||
current := codexReasoningReplayTurn{}
|
||||
appendCurrent := func() {
|
||||
if len(current.items) > 0 {
|
||||
turns = append(turns, current)
|
||||
}
|
||||
}
|
||||
for _, item := range items {
|
||||
itemResult := gjson.ParseBytes(item)
|
||||
if strings.TrimSpace(itemResult.Get("type").String()) == internalcache.CodexReasoningReplayTurnType {
|
||||
appendCurrent()
|
||||
current = codexReasoningReplayTurn{
|
||||
marked: true,
|
||||
assistantFingerprint: strings.TrimSpace(itemResult.Get("assistant_fingerprint").String()),
|
||||
requestFingerprint: strings.TrimSpace(itemResult.Get("request_fingerprint").String()),
|
||||
}
|
||||
if callIDs := itemResult.Get("call_ids"); callIDs.IsArray() {
|
||||
for _, callIDResult := range callIDs.Array() {
|
||||
if callID := strings.TrimSpace(callIDResult.String()); callID != "" {
|
||||
current.callIDs = append(current.callIDs, callID)
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
current.items = append(current.items, item)
|
||||
}
|
||||
appendCurrent()
|
||||
return turns
|
||||
}
|
||||
|
||||
func codexReasoningReplayTurnAnchorIndex(inputItems []gjson.Result, turn codexReasoningReplayTurn, fallbackEnd int, used map[int]bool) (int, bool) {
|
||||
searchEnd := fallbackEnd
|
||||
if turn.requestFingerprint != "" {
|
||||
searchEnd = len(inputItems) - 1
|
||||
}
|
||||
if searchEnd >= len(inputItems) {
|
||||
searchEnd = len(inputItems) - 1
|
||||
}
|
||||
matchesRequestPrefix := func(index int) bool {
|
||||
return turn.requestFingerprint == "" || codexReplayInputPrefixFingerprint(inputItems, index) == turn.requestFingerprint
|
||||
}
|
||||
if len(turn.callIDs) > 0 {
|
||||
callIDs := make(map[string]bool)
|
||||
for _, callID := range turn.callIDs {
|
||||
for _, candidate := range codexReplayComparableCallIDs(callID) {
|
||||
callIDs[candidate] = true
|
||||
}
|
||||
}
|
||||
for index := searchEnd; index >= 0; index-- {
|
||||
if used[index] || !matchesRequestPrefix(index) {
|
||||
continue
|
||||
}
|
||||
itemType := strings.TrimSpace(inputItems[index].Get("type").String())
|
||||
if itemType != "function_call" && itemType != "custom_tool_call" && itemType != "function_call_output" && itemType != "custom_tool_call_output" {
|
||||
continue
|
||||
}
|
||||
for _, candidate := range codexReplayComparableCallIDs(inputItems[index].Get("call_id").String()) {
|
||||
if callIDs[candidate] {
|
||||
return index, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if turn.assistantFingerprint != "" {
|
||||
for index := searchEnd; index >= 0; index-- {
|
||||
if used[index] || !matchesRequestPrefix(index) {
|
||||
continue
|
||||
}
|
||||
if codexReplayAssistantMessageFingerprint(inputItems[index]) == turn.assistantFingerprint {
|
||||
return index, true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(turn.callIDs) == 0 && turn.assistantFingerprint == "" {
|
||||
return codexReasoningReplayInsertIndex(inputItems, turn.items), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func filterCodexReasoningReplayTurnItems(inputItems []gjson.Result, items [][]byte) [][]byte {
|
||||
existingReasoning := make(map[string]bool)
|
||||
existingCalls := make(map[string]bool)
|
||||
existingOutputs := make(map[string]bool)
|
||||
for _, inputItem := range inputItems {
|
||||
itemType := strings.TrimSpace(inputItem.Get("type").String())
|
||||
switch itemType {
|
||||
case "reasoning":
|
||||
if encryptedContent := strings.TrimSpace(inputItem.Get("encrypted_content").String()); encryptedContent != "" {
|
||||
existingReasoning[encryptedContent] = true
|
||||
}
|
||||
case "function_call_output", "custom_tool_call_output":
|
||||
for _, candidate := range codexReplayComparableCallIDs(inputItem.Get("call_id").String()) {
|
||||
existingOutputs[candidate] = true
|
||||
}
|
||||
}
|
||||
for _, key := range codexReplayToolCallKeys(inputItem) {
|
||||
existingCalls[key] = true
|
||||
}
|
||||
}
|
||||
|
||||
filtered := make([][]byte, 0, len(items))
|
||||
for _, item := range items {
|
||||
itemResult := gjson.ParseBytes(item)
|
||||
switch strings.TrimSpace(itemResult.Get("type").String()) {
|
||||
case "reasoning":
|
||||
if existingReasoning[strings.TrimSpace(itemResult.Get("encrypted_content").String())] {
|
||||
continue
|
||||
}
|
||||
case "function_call", "custom_tool_call":
|
||||
keys := codexReplayToolCallKeys(itemResult)
|
||||
if len(keys) == 0 || codexReplayAnyToolCallKeyExists(existingCalls, keys) {
|
||||
continue
|
||||
}
|
||||
hasMatchingOutput := false
|
||||
for _, candidate := range codexReplayComparableCallIDs(itemResult.Get("call_id").String()) {
|
||||
if existingOutputs[candidate] {
|
||||
hasMatchingOutput = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasMatchingOutput {
|
||||
continue
|
||||
}
|
||||
for _, key := range keys {
|
||||
existingCalls[key] = true
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func codexReplayAssistantMessageFingerprint(item gjson.Result) string {
|
||||
itemType := strings.TrimSpace(item.Get("type").String())
|
||||
if itemType != "" && itemType != "message" {
|
||||
return ""
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(item.Get("role").String()), "assistant") {
|
||||
return ""
|
||||
}
|
||||
content := item.Get("content")
|
||||
var builder strings.Builder
|
||||
if content.Type == gjson.String {
|
||||
builder.WriteString(content.String())
|
||||
} else if content.IsArray() {
|
||||
for _, part := range content.Array() {
|
||||
switch strings.TrimSpace(part.Get("type").String()) {
|
||||
case "input_text", "output_text":
|
||||
builder.WriteString(part.Get("text").String())
|
||||
case "refusal":
|
||||
builder.WriteString("\x00refusal\x00")
|
||||
builder.WriteString(part.Get("refusal").String())
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
if builder.Len() == 0 {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte(builder.String()))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func codexReplayInputPrefixFingerprint(inputItems []gjson.Result, end int) string {
|
||||
if end < 0 || end > len(inputItems) {
|
||||
return ""
|
||||
}
|
||||
hasher := sha256.New()
|
||||
for index := 0; index < end; index++ {
|
||||
_, _ = hasher.Write([]byte("\x00item\x00"))
|
||||
_, _ = hasher.Write([]byte(inputItems[index].Raw))
|
||||
}
|
||||
return hex.EncodeToString(hasher.Sum(nil))
|
||||
}
|
||||
|
||||
func filterCodexReasoningReplayItemsForInput(body []byte, items [][]byte) [][]byte {
|
||||
input := gjson.GetBytes(body, "input")
|
||||
if !input.IsArray() {
|
||||
@@ -751,18 +1008,53 @@ func cacheCodexReasoningReplayFromCompleted(scope codexReasoningReplayScope, com
|
||||
if !output.IsArray() {
|
||||
return
|
||||
}
|
||||
items := make([][]byte, 0, len(output.Array()))
|
||||
replayItems := make([][]byte, 0, len(output.Array()))
|
||||
callIDs := make([]string, 0)
|
||||
assistantFingerprint := ""
|
||||
for _, item := range output.Array() {
|
||||
switch strings.TrimSpace(item.Get("type").String()) {
|
||||
case "reasoning", "function_call", "custom_tool_call":
|
||||
items = append(items, []byte(item.Raw))
|
||||
default:
|
||||
continue
|
||||
case "reasoning":
|
||||
replayItems = append(replayItems, []byte(item.Raw))
|
||||
case "function_call", "custom_tool_call":
|
||||
replayItems = append(replayItems, []byte(item.Raw))
|
||||
if callID := strings.TrimSpace(item.Get("call_id").String()); callID != "" {
|
||||
callIDs = append(callIDs, callID)
|
||||
}
|
||||
case "message":
|
||||
if fingerprint := codexReplayAssistantMessageFingerprint(item); fingerprint != "" {
|
||||
assistantFingerprint = fingerprint
|
||||
}
|
||||
}
|
||||
}
|
||||
if !internalcache.CacheCodexReasoningReplayItemsBestEffort(context.Background(), scope.modelName, scope.sessionKey, items) {
|
||||
internalcache.DeleteCodexReasoningReplayItem(scope.modelName, scope.sessionKey)
|
||||
if len(replayItems) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
hasher := sha256.New()
|
||||
_, _ = hasher.Write([]byte(scope.requestFingerprint))
|
||||
_, _ = hasher.Write([]byte("\x00assistant\x00" + assistantFingerprint))
|
||||
for _, callID := range callIDs {
|
||||
_, _ = hasher.Write([]byte("\x00call\x00" + callID))
|
||||
}
|
||||
for _, item := range replayItems {
|
||||
_, _ = hasher.Write([]byte("\x00item\x00"))
|
||||
_, _ = hasher.Write(item)
|
||||
}
|
||||
marker := []byte(`{"type":"` + internalcache.CodexReasoningReplayTurnType + `"}`)
|
||||
marker, _ = sjson.SetBytes(marker, "id", hex.EncodeToString(hasher.Sum(nil)))
|
||||
if assistantFingerprint != "" {
|
||||
marker, _ = sjson.SetBytes(marker, "assistant_fingerprint", assistantFingerprint)
|
||||
}
|
||||
if scope.requestFingerprint != "" {
|
||||
marker, _ = sjson.SetBytes(marker, "request_fingerprint", scope.requestFingerprint)
|
||||
}
|
||||
for _, callID := range callIDs {
|
||||
marker, _ = sjson.SetBytes(marker, "call_ids.-1", callID)
|
||||
}
|
||||
items := make([][]byte, 0, len(replayItems)+1)
|
||||
items = append(items, marker)
|
||||
items = append(items, replayItems...)
|
||||
internalcache.AppendCodexReasoningReplayItemsBestEffort(context.Background(), scope.modelName, scope.sessionKey, items)
|
||||
}
|
||||
|
||||
func clearCodexReasoningReplayOnInvalidSignature(ctx context.Context, scope codexReasoningReplayScope, statusCode int, body []byte) error {
|
||||
@@ -864,7 +1156,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
|
||||
|
||||
url := strings.TrimSuffix(baseURL, "/") + "/responses"
|
||||
var identityState codexIdentityConfuseState
|
||||
httpReq, upstreamBody, identityState, err := e.cacheHelper(ctx, from, url, auth, req, originalPayloadSource, body)
|
||||
httpReq, upstreamBody, identityState, err := e.cacheHelper(ctx, from, url, auth, req, originalPayloadSource, body, opts.Headers)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
@@ -1040,7 +1332,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
|
||||
|
||||
url := strings.TrimSuffix(baseURL, "/") + "/responses/compact"
|
||||
var identityState codexIdentityConfuseState
|
||||
httpReq, upstreamBody, identityState, err := e.cacheHelper(ctx, from, url, auth, req, originalPayloadSource, body)
|
||||
httpReq, upstreamBody, identityState, err := e.cacheHelper(ctx, from, url, auth, req, originalPayloadSource, body, opts.Headers)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
@@ -1155,7 +1447,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
|
||||
|
||||
url := strings.TrimSuffix(baseURL, "/") + "/responses"
|
||||
var identityState codexIdentityConfuseState
|
||||
httpReq, upstreamBody, identityState, err := e.cacheHelper(ctx, from, url, auth, req, originalPayloadSource, body)
|
||||
httpReq, upstreamBody, identityState, err := e.cacheHelper(ctx, from, url, auth, req, originalPayloadSource, body, opts.Headers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1506,10 +1798,18 @@ type codexIdentityReplacement struct {
|
||||
confused string
|
||||
}
|
||||
|
||||
func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Format, url string, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, userPayload []byte, rawJSON []byte) (*http.Request, []byte, codexIdentityConfuseState, error) {
|
||||
func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Format, url string, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, userPayload []byte, rawJSON []byte, headerSets ...http.Header) (*http.Request, []byte, codexIdentityConfuseState, error) {
|
||||
var headers http.Header
|
||||
if len(headerSets) > 0 {
|
||||
headers = headerSets[0]
|
||||
}
|
||||
var cache helps.CodexCache
|
||||
if sourceFormatEqual(from, sdktranslator.FormatClaude) {
|
||||
cached, ok, errCache := helps.ClaudeCodePromptCache(ctx, req.Model, req.Payload, nil)
|
||||
modelName := strings.TrimSpace(gjson.GetBytes(rawJSON, "model").String())
|
||||
if modelName == "" {
|
||||
modelName = thinking.ParseSuffix(req.Model).ModelName
|
||||
}
|
||||
cached, ok, errCache := helps.ClaudeCodePromptCache(ctx, modelName, req.Payload, headers)
|
||||
if errCache != nil {
|
||||
return nil, nil, codexIdentityConfuseState{}, errCache
|
||||
}
|
||||
|
||||
@@ -307,3 +307,62 @@ func TestCodexExecutorCacheHelper_ClaudeUsesSessionHeader(t *testing.T) {
|
||||
t.Fatalf("same Claude Code session header produced different prompt_cache_key: first=%q second=%q", firstKey, secondKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexExecutorCacheHelper_ClaudeAgentScopeUsesResolvedModelAcrossHTTPAndWebsocket(t *testing.T) {
|
||||
executor := &CodexExecutor{}
|
||||
url := "https://example.com/responses"
|
||||
req := cliproxyexecutor.Request{
|
||||
Model: "requested-alias-high",
|
||||
Payload: []byte(`{"model":"requested-alias","messages":[{"role":"user","content":"hello"}]}`),
|
||||
}
|
||||
rootHeaders := http.Header{}
|
||||
rootHeaders.Set(helps.ClaudeCodeSessionHeader, "resolved-model-session")
|
||||
childHeaders := rootHeaders.Clone()
|
||||
childHeaders.Set(helps.ClaudeCodeAgentHeader, "agent-a")
|
||||
rawJSON := []byte(`{"model":"gpt-5.4","stream":true}`)
|
||||
|
||||
rootRequest, _, _, errRoot := executor.cacheHelper(context.Background(), sdktranslator.FromString("claude"), url, nil, req, req.Payload, rawJSON, rootHeaders)
|
||||
if errRoot != nil {
|
||||
t.Fatalf("root cacheHelper error: %v", errRoot)
|
||||
}
|
||||
rootBody, errReadRoot := io.ReadAll(rootRequest.Body)
|
||||
if errReadRoot != nil {
|
||||
t.Fatalf("read root body: %v", errReadRoot)
|
||||
}
|
||||
rootKey := gjson.GetBytes(rootBody, "prompt_cache_key").String()
|
||||
|
||||
childRequest, _, _, errChild := executor.cacheHelper(context.Background(), sdktranslator.FromString("claude"), url, nil, req, req.Payload, rawJSON, childHeaders)
|
||||
if errChild != nil {
|
||||
t.Fatalf("child cacheHelper error: %v", errChild)
|
||||
}
|
||||
childBody, errReadChild := io.ReadAll(childRequest.Body)
|
||||
if errReadChild != nil {
|
||||
t.Fatalf("read child body: %v", errReadChild)
|
||||
}
|
||||
childKey := gjson.GetBytes(childBody, "prompt_cache_key").String()
|
||||
if rootKey == "" || childKey == "" || rootKey == childKey {
|
||||
t.Fatalf("agent prompt keys are not isolated: root=%q child=%q", rootKey, childKey)
|
||||
}
|
||||
|
||||
aliasReq := req
|
||||
aliasReq.Model = "another-local-alias-low"
|
||||
aliasRequest, _, _, errAlias := executor.cacheHelper(context.Background(), sdktranslator.FromString("claude"), url, nil, aliasReq, aliasReq.Payload, rawJSON, childHeaders)
|
||||
if errAlias != nil {
|
||||
t.Fatalf("alias cacheHelper error: %v", errAlias)
|
||||
}
|
||||
aliasBody, errReadAlias := io.ReadAll(aliasRequest.Body)
|
||||
if errReadAlias != nil {
|
||||
t.Fatalf("read alias body: %v", errReadAlias)
|
||||
}
|
||||
if aliasKey := gjson.GetBytes(aliasBody, "prompt_cache_key").String(); aliasKey != childKey {
|
||||
t.Fatalf("resolved model key fragmented by request alias: first=%q alias=%q", childKey, aliasKey)
|
||||
}
|
||||
|
||||
websocketBody, _, errWebsocket := applyCodexPromptCacheHeadersWithContext(context.Background(), sdktranslator.FromString("claude"), aliasReq, rawJSON, childHeaders)
|
||||
if errWebsocket != nil {
|
||||
t.Fatalf("websocket prompt cache error: %v", errWebsocket)
|
||||
}
|
||||
if websocketKey := gjson.GetBytes(websocketBody, "prompt_cache_key").String(); websocketKey != childKey {
|
||||
t.Fatalf("HTTP/WebSocket prompt keys differ: http=%q websocket=%q", childKey, websocketKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,8 +154,34 @@ func TestCodexExecutorReasoningReplaySessionKeyUsesClaudeCodeJSONSessionID(t *te
|
||||
body := []byte(`{"model":"gpt-5.4","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"next"}]}]}`)
|
||||
|
||||
got := codexReasoningReplaySessionKey(context.Background(), from, req, cliproxyexecutor.Options{SourceFormat: from}, body)
|
||||
if got != "claude:session-json-1" {
|
||||
t.Fatalf("codexReasoningReplaySessionKey() = %q, want claude:session-json-1", got)
|
||||
if got != "claude:session-json-1:agent:main" {
|
||||
t.Fatalf("codexReasoningReplaySessionKey() = %q, want claude:session-json-1:agent:main", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexExecutorReasoningReplaySessionKeyIsolatesClaudeCodeAgents(t *testing.T) {
|
||||
from := sdktranslator.FromString("claude")
|
||||
req := cliproxyexecutor.Request{
|
||||
Model: "local-alias-high",
|
||||
Payload: []byte(`{"model":"local-alias","messages":[{"role":"user","content":"next"}]}`),
|
||||
}
|
||||
body := []byte(`{"model":"gpt-5.4","prompt_cache_key":"shared-client-key","input":[{"type":"message","role":"user","content":"next"}]}`)
|
||||
rootHeaders := http.Header{}
|
||||
rootHeaders.Set("X-Claude-Code-Session-Id", "session-agents")
|
||||
childAHeaders := rootHeaders.Clone()
|
||||
childAHeaders.Set("X-Claude-Code-Agent-Id", "agent-a")
|
||||
childBHeaders := rootHeaders.Clone()
|
||||
childBHeaders.Set("X-Claude-Code-Agent-Id", "agent-b")
|
||||
|
||||
metadata := map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: "shared-execution-session"}
|
||||
root := codexReasoningReplayScopeFromRequest(context.Background(), from, req, cliproxyexecutor.Options{SourceFormat: from, Headers: rootHeaders, Metadata: metadata}, body)
|
||||
childA := codexReasoningReplayScopeFromRequest(context.Background(), from, req, cliproxyexecutor.Options{SourceFormat: from, Headers: childAHeaders, Metadata: metadata}, body)
|
||||
childB := codexReasoningReplayScopeFromRequest(context.Background(), from, req, cliproxyexecutor.Options{SourceFormat: from, Headers: childBHeaders, Metadata: metadata}, body)
|
||||
if root.modelName != "gpt-5.4" || childA.modelName != "gpt-5.4" || childB.modelName != "gpt-5.4" {
|
||||
t.Fatalf("replay scopes did not use resolved model: root=%#v a=%#v b=%#v", root, childA, childB)
|
||||
}
|
||||
if root.sessionKey == childA.sessionKey || childA.sessionKey == childB.sessionKey || root.sessionKey == childB.sessionKey {
|
||||
t.Fatalf("agent replay scopes are not isolated: root=%#v a=%#v b=%#v", root, childA, childB)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,7 +393,7 @@ func TestCodexExecutorReasoningReplayCacheDoesNotDuplicateClaudeClientReasoning(
|
||||
|
||||
cachedEncryptedContent := validCodexReasoningEncryptedContentForTestSeed(5)
|
||||
clientEncryptedContent := validCodexReasoningEncryptedContentForTestSeed(6)
|
||||
internalcache.CacheCodexReasoningReplayItem("gpt-5.4", "claude:session-2", []byte(`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+cachedEncryptedContent+`"}`))
|
||||
internalcache.CacheCodexReasoningReplayItem("gpt-5.4", "claude:session-2:agent:main", []byte(`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+cachedEncryptedContent+`"}`))
|
||||
|
||||
var gotBody []byte
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -418,7 +444,7 @@ func TestCodexExecutorReasoningReplayCacheInsertsReasoningBeforeAssistantOutputI
|
||||
t.Cleanup(internalcache.ClearCodexReasoningReplayCache)
|
||||
|
||||
cachedEncryptedContent := validCodexReasoningEncryptedContentForTestSeed(7)
|
||||
internalcache.CacheCodexReasoningReplayItem("gpt-5.4", "claude:session-history", []byte(`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+cachedEncryptedContent+`"}`))
|
||||
internalcache.CacheCodexReasoningReplayItem("gpt-5.4", "claude:session-history:agent:main", []byte(`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+cachedEncryptedContent+`"}`))
|
||||
|
||||
var gotBody []byte
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -546,7 +572,7 @@ func TestCodexExecutorReasoningReplayCacheClearsOnNonStreamResponseFailedInvalid
|
||||
t.Cleanup(internalcache.ClearCodexReasoningReplayCache)
|
||||
|
||||
cachedEncryptedContent := validCodexReasoningEncryptedContentForTestSeed(9)
|
||||
internalcache.CacheCodexReasoningReplayItem("gpt-5.4", "claude:session-invalid-nonstream", []byte(`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+cachedEncryptedContent+`"}`))
|
||||
internalcache.CacheCodexReasoningReplayItem("gpt-5.4", "claude:session-invalid-nonstream:agent:main", []byte(`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+cachedEncryptedContent+`"}`))
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.ReadAll(r.Body)
|
||||
@@ -572,7 +598,7 @@ func TestCodexExecutorReasoningReplayCacheClearsOnNonStreamResponseFailedInvalid
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid signature error")
|
||||
}
|
||||
if _, ok := internalcache.GetCodexReasoningReplayItem("gpt-5.4", "claude:session-invalid-nonstream"); ok {
|
||||
if _, ok := internalcache.GetCodexReasoningReplayItem("gpt-5.4", "claude:session-invalid-nonstream:agent:main"); ok {
|
||||
t.Fatal("invalid signature response.failed should clear cached replay item")
|
||||
}
|
||||
}
|
||||
@@ -582,7 +608,7 @@ func TestCodexExecutorReasoningReplayCacheClearsOnStreamResponseFailedInvalidSig
|
||||
t.Cleanup(internalcache.ClearCodexReasoningReplayCache)
|
||||
|
||||
cachedEncryptedContent := validCodexReasoningEncryptedContentForTestSeed(10)
|
||||
internalcache.CacheCodexReasoningReplayItem("gpt-5.4", "claude:session-invalid-stream", []byte(`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+cachedEncryptedContent+`"}`))
|
||||
internalcache.CacheCodexReasoningReplayItem("gpt-5.4", "claude:session-invalid-stream:agent:main", []byte(`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+cachedEncryptedContent+`"}`))
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.ReadAll(r.Body)
|
||||
@@ -618,7 +644,7 @@ func TestCodexExecutorReasoningReplayCacheClearsOnStreamResponseFailedInvalidSig
|
||||
if !gotChunkErr {
|
||||
t.Fatal("expected stream chunk error for invalid signature response.failed")
|
||||
}
|
||||
if _, ok := internalcache.GetCodexReasoningReplayItem("gpt-5.4", "claude:session-invalid-stream"); ok {
|
||||
if _, ok := internalcache.GetCodexReasoningReplayItem("gpt-5.4", "claude:session-invalid-stream:agent:main"); ok {
|
||||
t.Fatal("invalid signature response.failed should clear cached replay item")
|
||||
}
|
||||
}
|
||||
@@ -710,6 +736,224 @@ func TestCodexExecutorReasoningReplayCacheReplaysFunctionCallForClaudeToolResult
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexExecutorReasoningReplayCacheRestoresCumulativeToolTurns(t *testing.T) {
|
||||
internalcache.ClearCodexReasoningReplayCache()
|
||||
t.Cleanup(internalcache.ClearCodexReasoningReplayCache)
|
||||
|
||||
scope := codexReasoningReplayScope{
|
||||
modelName: "gpt-5.4",
|
||||
sessionKey: "claude:session-cumulative-tools:agent:main",
|
||||
}
|
||||
firstEncrypted := validCodexReasoningEncryptedContentForTestSeed(21)
|
||||
secondEncrypted := validCodexReasoningEncryptedContentForTestSeed(22)
|
||||
cacheCodexReasoningReplayFromCompleted(scope, []byte(`{"response":{"output":[`+
|
||||
`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+firstEncrypted+`"},`+
|
||||
`{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\"q\":\"first\"}"}`+
|
||||
`]}}`))
|
||||
cacheCodexReasoningReplayFromCompleted(scope, []byte(`{"response":{"output":[`+
|
||||
`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+secondEncrypted+`"},`+
|
||||
`{"type":"function_call","call_id":"call_2","name":"lookup","arguments":"{\"q\":\"second\"}"}`+
|
||||
`]}}`))
|
||||
|
||||
body := []byte(`{"model":"gpt-5.4","input":[` +
|
||||
`{"type":"message","role":"user","content":"first"},` +
|
||||
`{"type":"function_call_output","call_id":"call_1","output":"one"},` +
|
||||
`{"type":"message","role":"user","content":"second"},` +
|
||||
`{"type":"function_call_output","call_id":"call_2","output":"two"},` +
|
||||
`{"type":"message","role":"user","content":"third"}` +
|
||||
`]}`)
|
||||
req := cliproxyexecutor.Request{
|
||||
Model: "gpt-5.4",
|
||||
Payload: []byte(`{"metadata":{"user_id":"{\"session_id\":\"session-cumulative-tools\"}"}}`),
|
||||
}
|
||||
updated, gotScope := applyCodexReasoningReplayCache(context.Background(), sdktranslator.FromString("claude"), req, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude")}, body)
|
||||
if gotScope.modelName != scope.modelName || gotScope.sessionKey != scope.sessionKey {
|
||||
t.Fatalf("replay scope = %#v, want model/session %#v", gotScope, scope)
|
||||
}
|
||||
wantTypes := []string{"message", "reasoning", "function_call", "function_call_output", "message", "reasoning", "function_call", "function_call_output", "message"}
|
||||
gotItems := gjson.GetBytes(updated, "input").Array()
|
||||
if len(gotItems) != len(wantTypes) {
|
||||
t.Fatalf("input length = %d, want %d; body=%s", len(gotItems), len(wantTypes), updated)
|
||||
}
|
||||
for index, wantType := range wantTypes {
|
||||
if gotType := gotItems[index].Get("type").String(); gotType != wantType {
|
||||
t.Fatalf("input.%d.type = %q, want %q; body=%s", index, gotType, wantType, updated)
|
||||
}
|
||||
}
|
||||
if gotItems[1].Get("encrypted_content").String() != firstEncrypted || gotItems[5].Get("encrypted_content").String() != secondEncrypted {
|
||||
t.Fatalf("cumulative reasoning was not restored in turn order: %s", updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexExecutorReasoningReplayCacheRestoresCumulativeAssistantTurns(t *testing.T) {
|
||||
internalcache.ClearCodexReasoningReplayCache()
|
||||
t.Cleanup(internalcache.ClearCodexReasoningReplayCache)
|
||||
|
||||
scope := codexReasoningReplayScope{
|
||||
modelName: "gpt-5.4",
|
||||
sessionKey: "claude:session-cumulative-messages:agent:main",
|
||||
}
|
||||
firstEncrypted := validCodexReasoningEncryptedContentForTestSeed(23)
|
||||
secondEncrypted := validCodexReasoningEncryptedContentForTestSeed(24)
|
||||
cacheCodexReasoningReplayFromCompleted(scope, []byte(`{"response":{"output":[`+
|
||||
`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+firstEncrypted+`"},`+
|
||||
`{"type":"message","role":"assistant","content":[{"type":"output_text","text":"first answer"}]}`+
|
||||
`]}}`))
|
||||
cacheCodexReasoningReplayFromCompleted(scope, []byte(`{"response":{"output":[`+
|
||||
`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+secondEncrypted+`"},`+
|
||||
`{"type":"message","role":"assistant","content":[{"type":"output_text","text":"second answer"}]}`+
|
||||
`]}}`))
|
||||
|
||||
body := []byte(`{"model":"gpt-5.4","input":[` +
|
||||
`{"type":"message","role":"user","content":"first"},` +
|
||||
`{"type":"message","role":"assistant","content":[{"type":"output_text","text":"first answer"}]},` +
|
||||
`{"type":"message","role":"user","content":"second"},` +
|
||||
`{"type":"message","role":"assistant","content":[{"type":"output_text","text":"second answer"}]},` +
|
||||
`{"type":"message","role":"user","content":"third"}` +
|
||||
`]}`)
|
||||
req := cliproxyexecutor.Request{
|
||||
Model: "gpt-5.4",
|
||||
Payload: []byte(`{"metadata":{"user_id":"{\"session_id\":\"session-cumulative-messages\"}"}}`),
|
||||
}
|
||||
updated, gotScope := applyCodexReasoningReplayCache(context.Background(), sdktranslator.FromString("claude"), req, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude")}, body)
|
||||
if gotScope.modelName != scope.modelName || gotScope.sessionKey != scope.sessionKey {
|
||||
t.Fatalf("replay scope = %#v, want model/session %#v", gotScope, scope)
|
||||
}
|
||||
wantTypes := []string{"message", "reasoning", "message", "message", "reasoning", "message", "message"}
|
||||
gotItems := gjson.GetBytes(updated, "input").Array()
|
||||
if len(gotItems) != len(wantTypes) {
|
||||
t.Fatalf("input length = %d, want %d; body=%s", len(gotItems), len(wantTypes), updated)
|
||||
}
|
||||
for index, wantType := range wantTypes {
|
||||
if gotType := gotItems[index].Get("type").String(); gotType != wantType {
|
||||
t.Fatalf("input.%d.type = %q, want %q; body=%s", index, gotType, wantType, updated)
|
||||
}
|
||||
}
|
||||
if gotItems[1].Get("encrypted_content").String() != firstEncrypted || gotItems[4].Get("encrypted_content").String() != secondEncrypted {
|
||||
t.Fatalf("assistant reasoning was not restored at its original turns: %s", updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexExecutorReasoningReplayCacheSkipsDetachedTurnAfterCompaction(t *testing.T) {
|
||||
internalcache.ClearCodexReasoningReplayCache()
|
||||
t.Cleanup(internalcache.ClearCodexReasoningReplayCache)
|
||||
|
||||
scope := codexReasoningReplayScope{
|
||||
modelName: "gpt-5.4",
|
||||
sessionKey: "claude:session-compacted:agent:main",
|
||||
}
|
||||
detachedEncrypted := validCodexReasoningEncryptedContentForTestSeed(25)
|
||||
retainedEncrypted := validCodexReasoningEncryptedContentForTestSeed(26)
|
||||
cacheCodexReasoningReplayFromCompleted(scope, []byte(`{"response":{"output":[`+
|
||||
`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+detachedEncrypted+`"},`+
|
||||
`{"type":"message","role":"assistant","content":[{"type":"output_text","text":"removed answer"}]}`+
|
||||
`]}}`))
|
||||
cacheCodexReasoningReplayFromCompleted(scope, []byte(`{"response":{"output":[`+
|
||||
`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+retainedEncrypted+`"},`+
|
||||
`{"type":"message","role":"assistant","content":[{"type":"output_text","text":"retained answer"}]}`+
|
||||
`]}}`))
|
||||
|
||||
body := []byte(`{"model":"gpt-5.4","input":[` +
|
||||
`{"type":"message","role":"user","content":"compacted summary"},` +
|
||||
`{"type":"message","role":"assistant","content":[{"type":"output_text","text":"retained answer"}]},` +
|
||||
`{"type":"message","role":"user","content":"continue"}` +
|
||||
`]}`)
|
||||
req := cliproxyexecutor.Request{
|
||||
Model: "gpt-5.4",
|
||||
Payload: []byte(`{"metadata":{"user_id":"{\"session_id\":\"session-compacted\"}"}}`),
|
||||
}
|
||||
updated, _ := applyCodexReasoningReplayCache(context.Background(), sdktranslator.FromString("claude"), req, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude")}, body)
|
||||
gotItems := gjson.GetBytes(updated, "input").Array()
|
||||
if len(gotItems) != 4 || gotItems[1].Get("encrypted_content").String() != retainedEncrypted {
|
||||
t.Fatalf("retained turn reasoning was not restored: %s", updated)
|
||||
}
|
||||
for _, item := range gotItems {
|
||||
if item.Get("encrypted_content").String() == detachedEncrypted {
|
||||
t.Fatalf("detached reasoning moved into compacted history: %s", updated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexExecutorReasoningReplayCacheMatchesNewestDuplicateAssistantAfterCompaction(t *testing.T) {
|
||||
internalcache.ClearCodexReasoningReplayCache()
|
||||
t.Cleanup(internalcache.ClearCodexReasoningReplayCache)
|
||||
|
||||
scope := codexReasoningReplayScope{
|
||||
modelName: "gpt-5.4",
|
||||
sessionKey: "claude:session-duplicate-compaction:agent:main",
|
||||
}
|
||||
oldEncrypted := validCodexReasoningEncryptedContentForTestSeed(27)
|
||||
newEncrypted := validCodexReasoningEncryptedContentForTestSeed(28)
|
||||
for _, encryptedContent := range []string{oldEncrypted, newEncrypted} {
|
||||
cacheCodexReasoningReplayFromCompleted(scope, []byte(`{"response":{"output":[`+
|
||||
`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+encryptedContent+`"},`+
|
||||
`{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Done"}]}`+
|
||||
`]}}`))
|
||||
}
|
||||
|
||||
body := []byte(`{"model":"gpt-5.4","input":[` +
|
||||
`{"type":"message","role":"user","content":"compacted summary"},` +
|
||||
`{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Done"}]},` +
|
||||
`{"type":"message","role":"user","content":"continue"}` +
|
||||
`]}`)
|
||||
req := cliproxyexecutor.Request{
|
||||
Model: "gpt-5.4",
|
||||
Payload: []byte(`{"metadata":{"user_id":"{\"session_id\":\"session-duplicate-compaction\"}"}}`),
|
||||
}
|
||||
updated, _ := applyCodexReasoningReplayCache(context.Background(), sdktranslator.FromString("claude"), req, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude")}, body)
|
||||
gotItems := gjson.GetBytes(updated, "input").Array()
|
||||
if len(gotItems) != 4 || gotItems[1].Get("encrypted_content").String() != newEncrypted {
|
||||
t.Fatalf("newest duplicate assistant turn was not retained: %s", updated)
|
||||
}
|
||||
for _, item := range gotItems {
|
||||
if item.Get("encrypted_content").String() == oldEncrypted {
|
||||
t.Fatalf("detached duplicate assistant reasoning was restored: %s", updated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexExecutorReasoningReplayCacheUsesRequestPrefixForDuplicateOutOfOrderTurns(t *testing.T) {
|
||||
internalcache.ClearCodexReasoningReplayCache()
|
||||
t.Cleanup(internalcache.ClearCodexReasoningReplayCache)
|
||||
|
||||
body := []byte(`{"model":"gpt-5.4","input":[` +
|
||||
`{"type":"message","role":"user","content":"first"},` +
|
||||
`{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Done"}]},` +
|
||||
`{"type":"message","role":"user","content":"second"},` +
|
||||
`{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Done"}]},` +
|
||||
`{"type":"message","role":"user","content":"third"}` +
|
||||
`]}`)
|
||||
inputItems := gjson.GetBytes(body, "input").Array()
|
||||
baseScope := codexReasoningReplayScope{
|
||||
modelName: "gpt-5.4",
|
||||
sessionKey: "claude:session-duplicate-prefix:agent:main",
|
||||
}
|
||||
oldEncrypted := validCodexReasoningEncryptedContentForTestSeed(29)
|
||||
newEncrypted := validCodexReasoningEncryptedContentForTestSeed(30)
|
||||
newScope := baseScope
|
||||
newScope.requestFingerprint = codexReplayInputPrefixFingerprint(inputItems, 3)
|
||||
cacheCodexReasoningReplayFromCompleted(newScope, []byte(`{"response":{"output":[`+
|
||||
`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+newEncrypted+`"},`+
|
||||
`{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Done"}]}`+
|
||||
`]}}`))
|
||||
oldScope := baseScope
|
||||
oldScope.requestFingerprint = codexReplayInputPrefixFingerprint(inputItems, 1)
|
||||
cacheCodexReasoningReplayFromCompleted(oldScope, []byte(`{"response":{"output":[`+
|
||||
`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+oldEncrypted+`"},`+
|
||||
`{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Done"}]}`+
|
||||
`]}}`))
|
||||
|
||||
req := cliproxyexecutor.Request{
|
||||
Model: "gpt-5.4",
|
||||
Payload: []byte(`{"metadata":{"user_id":"{\"session_id\":\"session-duplicate-prefix\"}"}}`),
|
||||
}
|
||||
updated, _ := applyCodexReasoningReplayCache(context.Background(), sdktranslator.FromString("claude"), req, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude")}, body)
|
||||
gotItems := gjson.GetBytes(updated, "input").Array()
|
||||
if len(gotItems) != 7 || gotItems[1].Get("encrypted_content").String() != oldEncrypted || gotItems[4].Get("encrypted_content").String() != newEncrypted {
|
||||
t.Fatalf("duplicate out-of-order turns were not matched by request prefix: %s", updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexExecutorReasoningReplayCacheDropsFunctionCallWithoutMatchingOutput(t *testing.T) {
|
||||
internalcache.ClearCodexReasoningReplayCache()
|
||||
t.Cleanup(internalcache.ClearCodexReasoningReplayCache)
|
||||
@@ -717,7 +961,7 @@ func TestCodexExecutorReasoningReplayCacheDropsFunctionCallWithoutMatchingOutput
|
||||
encryptedContent := validCodexReasoningEncryptedContentForTestSeed(14)
|
||||
scope := codexReasoningReplayScope{
|
||||
modelName: "gpt-5.4",
|
||||
sessionKey: "claude:session-dropped-tool",
|
||||
sessionKey: "claude:session-dropped-tool:agent:main",
|
||||
}
|
||||
cacheCodexReasoningReplayFromCompleted(scope, []byte(`{"response":{"output":[`+
|
||||
`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+encryptedContent+`"},`+
|
||||
@@ -741,21 +985,18 @@ func TestCodexExecutorReasoningReplayCacheDropsFunctionCallWithoutMatchingOutput
|
||||
cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude")},
|
||||
body,
|
||||
)
|
||||
if replayScope != scope {
|
||||
t.Fatalf("replay scope = %#v, want %#v", replayScope, scope)
|
||||
if replayScope.modelName != scope.modelName || replayScope.sessionKey != scope.sessionKey {
|
||||
t.Fatalf("replay scope = %#v, want model/session %#v", replayScope, scope)
|
||||
}
|
||||
if got := gjson.GetBytes(updated, "input.0.type").String(); got != "reasoning" {
|
||||
t.Fatalf("input.0.type = %q, want reasoning; body=%s", got, string(updated))
|
||||
if got := gjson.GetBytes(updated, "input.0.role").String(); got != "user" {
|
||||
t.Fatalf("input.0.role = %q, want detached turn to be skipped; body=%s", got, string(updated))
|
||||
}
|
||||
if got := gjson.GetBytes(updated, "input.0.encrypted_content").String(); got != encryptedContent {
|
||||
t.Fatalf("input.0.encrypted_content = %q, want cached reasoning; body=%s", got, string(updated))
|
||||
if gjson.GetBytes(updated, `input.#(type=="reasoning")`).Exists() {
|
||||
t.Fatalf("detached turn reasoning should not move to the front; body=%s", string(updated))
|
||||
}
|
||||
if gjson.GetBytes(updated, `input.#(call_id=="call_dropped")`).Exists() {
|
||||
t.Fatalf("cached function_call without matching output should not be replayed; body=%s", string(updated))
|
||||
}
|
||||
if got := gjson.GetBytes(updated, "input.1.role").String(); got != "user" {
|
||||
t.Fatalf("input.1.role = %q, want user; body=%s", got, string(updated))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexExecutorReasoningReplayCacheMatchesShortenedClaudeToolResultCallID(t *testing.T) {
|
||||
|
||||
@@ -275,6 +275,10 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
|
||||
body = ensureImageGenerationTool(body, baseModel, auth, opts.Headers)
|
||||
}
|
||||
body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex websockets executor", body)
|
||||
body, replayScope, errReplay := applyCodexReasoningReplayCacheRequired(ctx, from, req, opts, body)
|
||||
if errReplay != nil {
|
||||
return resp, errReplay
|
||||
}
|
||||
|
||||
httpURL := strings.TrimSuffix(baseURL, "/") + "/responses"
|
||||
wsURL, err := buildCodexResponsesWebsocketURL(httpURL)
|
||||
@@ -282,7 +286,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
|
||||
return resp, err
|
||||
}
|
||||
|
||||
body, wsHeaders, errPromptCache := applyCodexPromptCacheHeadersWithContext(ctx, from, req, body)
|
||||
body, wsHeaders, errPromptCache := applyCodexPromptCacheHeadersWithContext(ctx, from, req, body, opts.Headers)
|
||||
if errPromptCache != nil {
|
||||
return resp, errPromptCache
|
||||
}
|
||||
@@ -405,6 +409,8 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
|
||||
}
|
||||
}
|
||||
|
||||
outputItemsByIndex := make(map[int64][]byte)
|
||||
var outputItemsFallback [][]byte
|
||||
for {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return resp, ctx.Err()
|
||||
@@ -439,13 +445,27 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
|
||||
if sess != nil {
|
||||
e.invalidateUpstreamConn(sess, conn, "upstream_error", wsErr)
|
||||
}
|
||||
if errClearReplay := clearCodexReasoningReplayOnWebsocketError(ctx, replayScope, payload); errClearReplay != nil {
|
||||
return resp, errClearReplay
|
||||
}
|
||||
helps.RecordAPIWebsocketError(ctx, e.cfg, "upstream_error", wsErr)
|
||||
return resp, wsErr
|
||||
}
|
||||
if streamErr, terminalBody, ok := codexTerminalFailureErr(payload); ok {
|
||||
if errClearReplay := clearCodexReasoningReplayOnInvalidSignature(ctx, replayScope, streamErr.StatusCode(), terminalBody); errClearReplay != nil {
|
||||
return resp, errClearReplay
|
||||
}
|
||||
return resp, streamErr
|
||||
}
|
||||
|
||||
payload = normalizeCodexWebsocketCompletion(payload)
|
||||
eventType := gjson.GetBytes(payload, "type").String()
|
||||
if eventType == "response.completed" {
|
||||
switch eventType {
|
||||
case "response.output_item.done":
|
||||
collectCodexOutputItemDone(payload, outputItemsByIndex, &outputItemsFallback)
|
||||
case "response.completed":
|
||||
payload = patchCodexCompletedOutput(payload, outputItemsByIndex, outputItemsFallback)
|
||||
cacheCodexReasoningReplayFromCompleted(replayScope, payload)
|
||||
if detail, ok := helps.ParseCodexUsage(payload); ok {
|
||||
reporter.Publish(ctx, detail)
|
||||
}
|
||||
@@ -479,11 +499,12 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
|
||||
from := opts.SourceFormat
|
||||
responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts)
|
||||
to := sdktranslator.FromString("codex")
|
||||
body := req.Payload
|
||||
userPayload := req.Payload
|
||||
originalPayloadSource := req.Payload
|
||||
if len(opts.OriginalRequest) > 0 {
|
||||
userPayload = opts.OriginalRequest
|
||||
originalPayloadSource = opts.OriginalRequest
|
||||
}
|
||||
originalPayload := originalPayloadSource
|
||||
originalTranslated, body := translateCodexRequestPair(from, to, baseModel, originalPayload, req.Payload, true)
|
||||
|
||||
body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
|
||||
if err != nil {
|
||||
@@ -492,13 +513,17 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
|
||||
|
||||
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
|
||||
requestPath := helps.PayloadRequestPath(opts)
|
||||
body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, body, requestedModel, requestPath, opts.Headers)
|
||||
body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers)
|
||||
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||
body = normalizeCodexInstructions(body)
|
||||
if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff {
|
||||
body = ensureImageGenerationTool(body, baseModel, auth, opts.Headers)
|
||||
}
|
||||
body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex websockets executor", body)
|
||||
body, replayScope, errReplay := applyCodexReasoningReplayCacheRequired(ctx, from, req, opts, body)
|
||||
if errReplay != nil {
|
||||
return nil, errReplay
|
||||
}
|
||||
|
||||
httpURL := strings.TrimSuffix(baseURL, "/") + "/responses"
|
||||
wsURL, err := buildCodexResponsesWebsocketURL(httpURL)
|
||||
@@ -506,13 +531,13 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, wsHeaders, errPromptCache := applyCodexPromptCacheHeadersWithContext(ctx, from, req, body)
|
||||
body, wsHeaders, errPromptCache := applyCodexPromptCacheHeadersWithContext(ctx, from, req, body, opts.Headers)
|
||||
if errPromptCache != nil {
|
||||
return nil, errPromptCache
|
||||
}
|
||||
clientBody := body
|
||||
var identityState codexIdentityConfuseState
|
||||
upstreamBody, identityState := applyCodexIdentityConfuseBody(e.cfg, auth, userPayload, body)
|
||||
upstreamBody, identityState := applyCodexIdentityConfuseBody(e.cfg, auth, originalPayloadSource, body)
|
||||
reporter.SetTranslatedReasoningEffort(clientBody, to.String())
|
||||
wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg)
|
||||
applyModelHeaderOverrides(wsHeaders, baseModel)
|
||||
@@ -659,6 +684,8 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
|
||||
}
|
||||
|
||||
var param any
|
||||
outputItemsByIndex := make(map[int64][]byte)
|
||||
var outputItemsFallback [][]byte
|
||||
for {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
terminateReason = "context_done"
|
||||
@@ -709,24 +736,54 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
|
||||
if wsErr, ok := parseCodexWebsocketError(payload); ok {
|
||||
terminateReason = "upstream_error"
|
||||
terminateErr = wsErr
|
||||
helps.RecordAPIWebsocketError(ctx, e.cfg, "upstream_error", wsErr)
|
||||
reporter.PublishFailure(ctx, wsErr)
|
||||
if sess != nil {
|
||||
e.invalidateUpstreamConn(sess, conn, "upstream_error", wsErr)
|
||||
}
|
||||
if errClearReplay := clearCodexReasoningReplayOnWebsocketError(ctx, replayScope, payload); errClearReplay != nil {
|
||||
terminateErr = errClearReplay
|
||||
helps.RecordAPIWebsocketError(ctx, e.cfg, "replay_clear_error", errClearReplay)
|
||||
reporter.PublishFailure(ctx, errClearReplay)
|
||||
_ = send(cliproxyexecutor.StreamChunk{Err: errClearReplay})
|
||||
return
|
||||
}
|
||||
helps.RecordAPIWebsocketError(ctx, e.cfg, "upstream_error", wsErr)
|
||||
reporter.PublishFailure(ctx, wsErr)
|
||||
_ = send(cliproxyexecutor.StreamChunk{Err: wsErr})
|
||||
return
|
||||
}
|
||||
if streamErr, terminalBody, ok := codexTerminalFailureErr(payload); ok {
|
||||
terminateReason = "upstream_error"
|
||||
terminateErr = streamErr
|
||||
if errClearReplay := clearCodexReasoningReplayOnInvalidSignature(ctx, replayScope, streamErr.StatusCode(), terminalBody); errClearReplay != nil {
|
||||
terminateErr = errClearReplay
|
||||
helps.RecordAPIWebsocketError(ctx, e.cfg, "replay_clear_error", errClearReplay)
|
||||
reporter.PublishFailure(ctx, errClearReplay)
|
||||
_ = send(cliproxyexecutor.StreamChunk{Err: errClearReplay})
|
||||
return
|
||||
}
|
||||
helps.RecordAPIWebsocketError(ctx, e.cfg, "upstream_error", streamErr)
|
||||
reporter.PublishFailure(ctx, streamErr)
|
||||
_ = send(cliproxyexecutor.StreamChunk{Err: streamErr})
|
||||
return
|
||||
}
|
||||
|
||||
eventType := gjson.GetBytes(payload, "type").String()
|
||||
isTerminalEvent := eventType == "response.completed" || eventType == "response.done" || eventType == "error"
|
||||
if eventType == "response.output_item.done" {
|
||||
collectCodexOutputItemDone(payload, outputItemsByIndex, &outputItemsFallback)
|
||||
}
|
||||
completedPayload := payload
|
||||
if eventType == "response.completed" || eventType == "response.done" {
|
||||
completedPayload = normalizeCodexWebsocketCompletion(completedPayload)
|
||||
completedPayload = patchCodexCompletedOutput(completedPayload, outputItemsByIndex, outputItemsFallback)
|
||||
cacheCodexReasoningReplayFromCompleted(replayScope, completedPayload)
|
||||
if detail, ok := helps.ParseCodexUsage(completedPayload); ok {
|
||||
reporter.Publish(ctx, detail)
|
||||
}
|
||||
}
|
||||
|
||||
clientPayload := applyCodexIdentityExposeResponsePayload(payload, identityState)
|
||||
if cliproxyexecutor.DownstreamWebsocket(ctx) {
|
||||
if eventType == "response.completed" || eventType == "response.done" {
|
||||
if detail, ok := helps.ParseCodexUsage(payload); ok {
|
||||
reporter.Publish(ctx, detail)
|
||||
}
|
||||
}
|
||||
if !send(cliproxyexecutor.StreamChunk{Payload: clientPayload}) {
|
||||
terminateReason = "context_done"
|
||||
terminateErr = ctx.Err()
|
||||
@@ -739,16 +796,13 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
|
||||
}
|
||||
|
||||
payload = normalizeCodexWebsocketCompletion(payload)
|
||||
eventType = gjson.GetBytes(payload, "type").String()
|
||||
if eventType == "response.completed" || eventType == "response.done" {
|
||||
if detail, ok := helps.ParseCodexUsage(payload); ok {
|
||||
reporter.Publish(ctx, detail)
|
||||
}
|
||||
payload = completedPayload
|
||||
}
|
||||
|
||||
eventType = gjson.GetBytes(payload, "type").String()
|
||||
clientPayload = applyCodexIdentityExposeResponsePayload(payload, identityState)
|
||||
line := encodeCodexWebsocketAsSSE(clientPayload)
|
||||
chunks := sdktranslator.TranslateStream(ctx, to, responseFormat, req.Model, clientBody, clientBody, line, ¶m)
|
||||
chunks := sdktranslator.TranslateStream(ctx, to, responseFormat, req.Model, originalPayload, clientBody, line, ¶m)
|
||||
for i := range chunks {
|
||||
if !send(cliproxyexecutor.StreamChunk{Payload: chunks[i]}) {
|
||||
terminateReason = "context_done"
|
||||
@@ -941,15 +995,23 @@ func applyCodexPromptCacheHeaders(from sdktranslator.Format, req cliproxyexecuto
|
||||
return body, headers
|
||||
}
|
||||
|
||||
func applyCodexPromptCacheHeadersWithContext(ctx context.Context, from sdktranslator.Format, req cliproxyexecutor.Request, rawJSON []byte) ([]byte, http.Header, error) {
|
||||
func applyCodexPromptCacheHeadersWithContext(ctx context.Context, from sdktranslator.Format, req cliproxyexecutor.Request, rawJSON []byte, headerSets ...http.Header) ([]byte, http.Header, error) {
|
||||
headers := http.Header{}
|
||||
if len(rawJSON) == 0 {
|
||||
return rawJSON, headers, nil
|
||||
}
|
||||
|
||||
var requestHeaders http.Header
|
||||
if len(headerSets) > 0 {
|
||||
requestHeaders = headerSets[0]
|
||||
}
|
||||
var cache helps.CodexCache
|
||||
if sourceFormatEqual(from, sdktranslator.FormatClaude) {
|
||||
cached, ok, errCache := helps.ClaudeCodePromptCache(ctx, req.Model, req.Payload, nil)
|
||||
modelName := strings.TrimSpace(gjson.GetBytes(rawJSON, "model").String())
|
||||
if modelName == "" {
|
||||
modelName = thinking.ParseSuffix(req.Model).ModelName
|
||||
}
|
||||
cached, ok, errCache := helps.ClaudeCodePromptCache(ctx, modelName, req.Payload, requestHeaders)
|
||||
if errCache != nil {
|
||||
return nil, nil, errCache
|
||||
}
|
||||
@@ -1271,6 +1333,17 @@ func parseCodexWebsocketError(payload []byte) (error, bool) {
|
||||
}, true
|
||||
}
|
||||
|
||||
func clearCodexReasoningReplayOnWebsocketError(ctx context.Context, scope codexReasoningReplayScope, payload []byte) error {
|
||||
status := int(gjson.GetBytes(payload, "status").Int())
|
||||
if status == 0 {
|
||||
status = int(gjson.GetBytes(payload, "status_code").Int())
|
||||
}
|
||||
if status <= 0 {
|
||||
return nil
|
||||
}
|
||||
return clearCodexReasoningReplayOnInvalidSignature(ctx, scope, status, buildCodexWebsocketErrorPayload(payload, status))
|
||||
}
|
||||
|
||||
func buildCodexWebsocketErrorPayload(payload []byte, status int) []byte {
|
||||
out := []byte(`{}`)
|
||||
out, _ = sjson.SetBytes(out, "status", status)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
internalcache "github.com/router-for-me/CLIProxyAPI/v7/internal/cache"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
@@ -73,6 +74,97 @@ func TestBuildCodexWebsocketRequestBodyShortensOverlongInputItemIDs(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexWebsocketsExecuteRestoresClaudeAgentReasoningReplay(t *testing.T) {
|
||||
internalcache.ClearCodexReasoningReplayCache()
|
||||
t.Cleanup(internalcache.ClearCodexReasoningReplayCache)
|
||||
|
||||
encryptedContent := validCodexReasoningEncryptedContentForTestSeed(31)
|
||||
cacheCodexReasoningReplayFromCompleted(codexReasoningReplayScope{
|
||||
modelName: "gpt-5.4",
|
||||
sessionKey: "claude:ws-replay-session:agent:agent-a",
|
||||
}, []byte(`{"response":{"output":[`+
|
||||
`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+encryptedContent+`"},`+
|
||||
`{"type":"message","role":"assistant","content":[{"type":"output_text","text":"previous answer"}]}`+
|
||||
`]}}`))
|
||||
|
||||
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
||||
capturedPayload := make(chan []byte, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, errUpgrade := upgrader.Upgrade(w, r, nil)
|
||||
if errUpgrade != nil {
|
||||
t.Fatalf("upgrade websocket: %v", errUpgrade)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
_, payload, errRead := conn.ReadMessage()
|
||||
if errRead != nil {
|
||||
t.Fatalf("read upstream websocket message: %v", errRead)
|
||||
}
|
||||
capturedPayload <- bytes.Clone(payload)
|
||||
completed := []byte(`{"type":"response.completed","response":{"id":"resp-ws-replay","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"next answer"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`)
|
||||
if errWrite := conn.WriteMessage(websocket.TextMessage, completed); errWrite != nil {
|
||||
t.Fatalf("write completed websocket message: %v", errWrite)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
exec := NewCodexWebsocketsExecutor(&config.Config{SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll}})
|
||||
auth := &cliproxyauth.Auth{Provider: "codex", Attributes: map[string]string{"api_key": "sk-test", "base_url": server.URL}}
|
||||
req := cliproxyexecutor.Request{
|
||||
Model: "gpt-5.4",
|
||||
Payload: []byte(`{
|
||||
"model":"gpt-5.4",
|
||||
"messages":[
|
||||
{"role":"user","content":"first"},
|
||||
{"role":"assistant","content":"previous answer"},
|
||||
{"role":"user","content":"next"}
|
||||
]
|
||||
}`),
|
||||
}
|
||||
headers := http.Header{}
|
||||
headers.Set("X-Claude-Code-Session-Id", "ws-replay-session")
|
||||
headers.Set("X-Claude-Code-Agent-Id", "agent-a")
|
||||
opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude"), Headers: headers}
|
||||
|
||||
if _, errExecute := exec.Execute(context.Background(), auth, req, opts); errExecute != nil {
|
||||
t.Fatalf("Execute() error = %v", errExecute)
|
||||
}
|
||||
|
||||
select {
|
||||
case payload := <-capturedPayload:
|
||||
input := gjson.GetBytes(payload, "input").Array()
|
||||
if len(input) != 4 {
|
||||
t.Fatalf("upstream input length = %d, want 4; payload=%s", len(input), payload)
|
||||
}
|
||||
if input[1].Get("type").String() != "reasoning" || input[1].Get("encrypted_content").String() != encryptedContent {
|
||||
t.Fatalf("websocket reasoning replay missing before assistant message: %s", payload)
|
||||
}
|
||||
if input[2].Get("role").String() != "assistant" {
|
||||
t.Fatalf("input.2.role = %q, want assistant; payload=%s", input[2].Get("role").String(), payload)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for upstream websocket payload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearCodexReasoningReplayOnWebsocketInvalidSignature(t *testing.T) {
|
||||
internalcache.ClearCodexReasoningReplayCache()
|
||||
t.Cleanup(internalcache.ClearCodexReasoningReplayCache)
|
||||
|
||||
scope := codexReasoningReplayScope{modelName: "gpt-5.4", sessionKey: "claude:ws-invalid:agent:main"}
|
||||
encryptedContent := validCodexReasoningEncryptedContentForTestSeed(32)
|
||||
if !internalcache.CacheCodexReasoningReplayItem(scope.modelName, scope.sessionKey, []byte(`{"type":"reasoning","summary":[],"content":null,"encrypted_content":"`+encryptedContent+`"}`)) {
|
||||
t.Fatal("failed to seed websocket replay cache")
|
||||
}
|
||||
payload := []byte(`{"type":"error","status":400,"body":{"error":{"message":"Invalid signature in thinking block","type":"invalid_request_error","code":"invalid_request_error"}}}`)
|
||||
if errClear := clearCodexReasoningReplayOnWebsocketError(context.Background(), scope, payload); errClear != nil {
|
||||
t.Fatalf("clear websocket replay error: %v", errClear)
|
||||
}
|
||||
if _, ok := internalcache.GetCodexReasoningReplayItem(scope.modelName, scope.sessionKey); ok {
|
||||
t.Fatal("websocket invalid signature did not clear replay state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexWebsocketsExecuteResponsesLiteDoesNotInjectImageGenerationTool(t *testing.T) {
|
||||
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
||||
capturedPayload := make(chan []byte, 1)
|
||||
|
||||
@@ -5,32 +5,75 @@ import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const ClaudeCodeSessionHeader = "X-Claude-Code-Session-Id"
|
||||
const (
|
||||
ClaudeCodeSessionHeader = "X-Claude-Code-Session-Id"
|
||||
ClaudeCodeAgentHeader = "X-Claude-Code-Agent-Id"
|
||||
ClaudeCodeMainAgentID = "main"
|
||||
)
|
||||
|
||||
var claudeCodeSessionSuffixPattern = regexp.MustCompile(`_session_([a-f0-9-]+)$`)
|
||||
|
||||
// ExtractClaudeCodeSessionID resolves a Claude Code session ID, preferring X-Claude-Code-Session-Id over payload metadata.
|
||||
func ExtractClaudeCodeSessionID(ctx context.Context, payload []byte, headers http.Header) string {
|
||||
if headers != nil {
|
||||
if sessionID := strings.TrimSpace(headers.Get(ClaudeCodeSessionHeader)); sessionID != "" {
|
||||
return sessionID
|
||||
}
|
||||
if sessionID := claudeCodeHeader(ctx, headers, ClaudeCodeSessionHeader); sessionID != "" {
|
||||
return sessionID
|
||||
}
|
||||
return extractClaudeCodeSessionIDFromPayload(payload)
|
||||
}
|
||||
|
||||
// ExtractClaudeCodeAgentID resolves the Claude Code agent ID and uses a stable sentinel for the root agent.
|
||||
func ExtractClaudeCodeAgentID(ctx context.Context, headers http.Header) string {
|
||||
if agentID := claudeCodeHeader(ctx, headers, ClaudeCodeAgentHeader); agentID != "" {
|
||||
return agentID
|
||||
}
|
||||
return ClaudeCodeMainAgentID
|
||||
}
|
||||
|
||||
// ClaudeCodeExecutionScope returns the stable root-session and agent identity used by Codex execution state.
|
||||
func ClaudeCodeExecutionScope(ctx context.Context, payload []byte, headers http.Header) (string, bool) {
|
||||
sessionID := ExtractClaudeCodeSessionID(ctx, payload, headers)
|
||||
if sessionID == "" {
|
||||
return "", false
|
||||
}
|
||||
return "claude:" + sessionID + ":agent:" + ExtractClaudeCodeAgentID(ctx, headers), true
|
||||
}
|
||||
|
||||
func claudeCodeHeader(ctx context.Context, headers http.Header, name string) string {
|
||||
if value := headerValueCaseInsensitive(headers, name); value != "" {
|
||||
return value
|
||||
}
|
||||
if ctx != nil {
|
||||
if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
|
||||
if sessionID := strings.TrimSpace(ginCtx.Request.Header.Get(ClaudeCodeSessionHeader)); sessionID != "" {
|
||||
return sessionID
|
||||
return headerValueCaseInsensitive(ginCtx.Request.Header, name)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func headerValueCaseInsensitive(headers http.Header, name string) string {
|
||||
if headers == nil {
|
||||
return ""
|
||||
}
|
||||
if value := strings.TrimSpace(headers.Get(name)); value != "" {
|
||||
return value
|
||||
}
|
||||
for key, values := range headers {
|
||||
if !strings.EqualFold(key, name) {
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return extractClaudeCodeSessionIDFromPayload(payload)
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractClaudeCodeSessionIDFromPayload(payload []byte) string {
|
||||
@@ -50,22 +93,13 @@ func extractClaudeCodeSessionIDFromPayload(payload []byte) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// ClaudeCodePromptCache maps a Claude Code session to a stable upstream prompt_cache_key.
|
||||
// ClaudeCodePromptCache derives a deterministic upstream prompt_cache_key for one Claude Code agent.
|
||||
func ClaudeCodePromptCache(ctx context.Context, modelName string, payload []byte, headers http.Header) (CodexCache, bool, error) {
|
||||
sessionID := ExtractClaudeCodeSessionID(ctx, payload, headers)
|
||||
if sessionID == "" {
|
||||
modelName = strings.TrimSpace(modelName)
|
||||
executionScope, ok := ClaudeCodeExecutionScope(ctx, payload, headers)
|
||||
if modelName == "" || !ok {
|
||||
return CodexCache{}, false, nil
|
||||
}
|
||||
key := CodexPromptCacheKey(modelName, "claude:"+sessionID)
|
||||
if cache, ok, errCache := GetCodexCacheRequired(ctx, key); errCache != nil || ok {
|
||||
return cache, ok, errCache
|
||||
}
|
||||
cache := CodexCache{
|
||||
ID: uuid.New().String(),
|
||||
Expire: time.Now().Add(1 * time.Hour),
|
||||
}
|
||||
if errSet := SetCodexCacheRequired(ctx, key, cache); errSet != nil {
|
||||
return CodexCache{}, false, errSet
|
||||
}
|
||||
return cache, true, nil
|
||||
identity := strings.Join([]string{"cli-proxy-api:codex:claude-code", modelName, executionScope}, "\x00")
|
||||
return CodexCache{ID: uuid.NewSHA1(uuid.NameSpaceOID, []byte(identity)).String()}, true, nil
|
||||
}
|
||||
|
||||
@@ -59,3 +59,64 @@ func TestExtractClaudeCodeSessionIDPrefersHeaderOverPayload(t *testing.T) {
|
||||
t.Fatalf("ExtractClaudeCodeSessionID() = %q, want header-session", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeCodeExecutionScopeAcceptsLowercaseHeaderMapKeys(t *testing.T) {
|
||||
headers := http.Header{
|
||||
"x-claude-code-session-id": []string{"lower-session"},
|
||||
"x-claude-code-agent-id": []string{"lower-agent"},
|
||||
}
|
||||
|
||||
scope, ok := ClaudeCodeExecutionScope(context.Background(), nil, headers)
|
||||
if !ok || scope != "claude:lower-session:agent:lower-agent" {
|
||||
t.Fatalf("lowercase header scope = %q, %v", scope, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeCodeExecutionScopeIsolatesAgents(t *testing.T) {
|
||||
rootHeaders := http.Header{}
|
||||
rootHeaders.Set(ClaudeCodeSessionHeader, "session-agents")
|
||||
childAHeaders := rootHeaders.Clone()
|
||||
childAHeaders.Set(ClaudeCodeAgentHeader, "agent-a")
|
||||
childBHeaders := rootHeaders.Clone()
|
||||
childBHeaders.Set(ClaudeCodeAgentHeader, "agent-b")
|
||||
|
||||
rootScope, ok := ClaudeCodeExecutionScope(context.Background(), nil, rootHeaders)
|
||||
if !ok || rootScope != "claude:session-agents:agent:main" {
|
||||
t.Fatalf("root scope = %q, %v", rootScope, ok)
|
||||
}
|
||||
childAScope, ok := ClaudeCodeExecutionScope(context.Background(), nil, childAHeaders)
|
||||
if !ok || childAScope != "claude:session-agents:agent:agent-a" {
|
||||
t.Fatalf("child A scope = %q, %v", childAScope, ok)
|
||||
}
|
||||
childBScope, ok := ClaudeCodeExecutionScope(context.Background(), nil, childBHeaders)
|
||||
if !ok || childBScope != "claude:session-agents:agent:agent-b" {
|
||||
t.Fatalf("child B scope = %q, %v", childBScope, ok)
|
||||
}
|
||||
if rootScope == childAScope || childAScope == childBScope || rootScope == childBScope {
|
||||
t.Fatalf("agent scopes are not isolated: root=%q a=%q b=%q", rootScope, childAScope, childBScope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeCodePromptCacheDeterministicAndAgentScoped(t *testing.T) {
|
||||
rootHeaders := http.Header{}
|
||||
rootHeaders.Set(ClaudeCodeSessionHeader, "session-cache-agents")
|
||||
childHeaders := rootHeaders.Clone()
|
||||
childHeaders.Set(ClaudeCodeAgentHeader, "agent-a")
|
||||
|
||||
rootFirst, ok, errFirst := ClaudeCodePromptCache(context.Background(), "gpt-5.4", nil, rootHeaders)
|
||||
if errFirst != nil || !ok {
|
||||
t.Fatalf("root first cache = %#v, %v, %v", rootFirst, ok, errFirst)
|
||||
}
|
||||
rootSecond, ok, errSecond := ClaudeCodePromptCache(context.Background(), "gpt-5.4", nil, rootHeaders)
|
||||
if errSecond != nil || !ok || rootSecond.ID != rootFirst.ID {
|
||||
t.Fatalf("root second cache = %#v, %v, %v; want ID %q", rootSecond, ok, errSecond, rootFirst.ID)
|
||||
}
|
||||
child, ok, errChild := ClaudeCodePromptCache(context.Background(), "gpt-5.4", nil, childHeaders)
|
||||
if errChild != nil || !ok || child.ID == rootFirst.ID {
|
||||
t.Fatalf("child cache = %#v, %v, %v; root ID %q", child, ok, errChild, rootFirst.ID)
|
||||
}
|
||||
otherModel, ok, errModel := ClaudeCodePromptCache(context.Background(), "gpt-5.5", nil, rootHeaders)
|
||||
if errModel != nil || !ok || otherModel.ID == rootFirst.ID {
|
||||
t.Fatalf("other model cache = %#v, %v, %v; root ID %q", otherModel, ok, errModel, rootFirst.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1150,7 +1150,7 @@ func xaiResolveComposerSessionID(ctx context.Context, req cliproxyexecutor.Reque
|
||||
if !xaiRequiresIsolatedConversation(baseModel) {
|
||||
return "", nil
|
||||
}
|
||||
cached, ok, errCache := helps.ClaudeCodePromptCache(ctx, req.Model, req.Payload, opts.Headers)
|
||||
cached, ok, errCache := helps.ClaudeCodePromptCache(ctx, baseModel, req.Payload, opts.Headers)
|
||||
if errCache != nil {
|
||||
return "", errCache
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user