Skip to content
125 changes: 125 additions & 0 deletions internal/runtime/executor/codex_continuity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package executor

import (
"context"
"fmt"
"net/http"
"strings"

"github.com/google/uuid"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)

type codexContinuity struct {
Key string
Source string
}

func metadataString(meta map[string]any, key string) string {
if len(meta) == 0 {
return ""
}
raw, ok := meta[key]
if !ok || raw == nil {
return ""
}
switch v := raw.(type) {
case string:
return strings.TrimSpace(v)
case []byte:
return strings.TrimSpace(string(v))
default:
return ""
}
}

func principalString(raw any) string {
switch v := raw.(type) {
case string:
return strings.TrimSpace(v)
case fmt.Stringer:
return strings.TrimSpace(v.String())
default:
return strings.TrimSpace(fmt.Sprintf("%v", raw))
}
}

func resolveCodexContinuity(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) codexContinuity {
if promptCacheKey := strings.TrimSpace(gjson.GetBytes(req.Payload, "prompt_cache_key").String()); promptCacheKey != "" {
return codexContinuity{Key: promptCacheKey, Source: "prompt_cache_key"}
}
if executionSession := metadataString(opts.Metadata, cliproxyexecutor.ExecutionSessionMetadataKey); executionSession != "" {
return codexContinuity{Key: executionSession, Source: "execution_session"}
}
if ginCtx := ginContextFrom(ctx); ginCtx != nil {
if ginCtx.Request != nil {
if v := strings.TrimSpace(ginCtx.GetHeader("Idempotency-Key")); v != "" {
return codexContinuity{Key: v, Source: "idempotency_key"}
Comment on lines +60 to +61
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid request-scoped Idempotency-Key as primary continuity key

In resolveCodexContinuity, Idempotency-Key is chosen before stable identities (client_principal / auth_id), so any client that sends a different idempotency key per request will get a different prompt_cache_key/session_id on every turn. That breaks the continuity this change is trying to restore (cache/session reuse across turns) and can regress performance for common retry-safe clients that auto-generate per-request idempotency keys. Consider demoting this source below stable caller/auth-derived keys (or only using it for explicit retry contexts).

Useful? React with 👍 / 👎.

}
}
if v, exists := ginCtx.Get("apiKey"); exists && v != nil {
if trimmed := principalString(v); trimmed != "" {
return codexContinuity{Key: uuid.NewSHA1(uuid.NameSpaceOID, []byte("cli-proxy-api:codex:prompt-cache:"+trimmed)).String(), Source: "client_principal"}
}
}
}
if auth != nil {
if authID := strings.TrimSpace(auth.ID); authID != "" {
return codexContinuity{Key: uuid.NewSHA1(uuid.NameSpaceOID, []byte("cli-proxy-api:codex:prompt-cache:auth:"+authID)).String(), Source: "auth_id"}
}
}
return codexContinuity{}
}

func applyCodexContinuityBody(rawJSON []byte, continuity codexContinuity) []byte {
if continuity.Key == "" {
return rawJSON
}
rawJSON, _ = sjson.SetBytes(rawJSON, "prompt_cache_key", continuity.Key)
return rawJSON
}

func applyCodexContinuityHeaders(headers http.Header, continuity codexContinuity) {
if headers == nil || continuity.Key == "" {
return
}
headers.Set("session_id", continuity.Key)
}

func logCodexRequestDiagnostics(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, headers http.Header, body []byte, continuity codexContinuity) {
if !log.IsLevelEnabled(log.DebugLevel) {
return
}
entry := logWithRequestID(ctx)
authID := ""
authFile := ""
if auth != nil {
authID = strings.TrimSpace(auth.ID)
authFile = strings.TrimSpace(auth.FileName)
}
selectedAuthID := metadataString(opts.Metadata, cliproxyexecutor.SelectedAuthMetadataKey)
executionSessionID := metadataString(opts.Metadata, cliproxyexecutor.ExecutionSessionMetadataKey)
entry.Debugf(
"codex request diagnostics auth_id=%s selected_auth_id=%s auth_file=%s exec_session=%s continuity_source=%s session_id=%s prompt_cache_key=%s prompt_cache_retention=%s store=%t has_instructions=%t reasoning_effort=%s reasoning_summary=%s chatgpt_account_id=%t originator=%s model=%s source_format=%s",
authID,
selectedAuthID,
authFile,
executionSessionID,
continuity.Source,
strings.TrimSpace(headers.Get("session_id")),
gjson.GetBytes(body, "prompt_cache_key").String(),
gjson.GetBytes(body, "prompt_cache_retention").String(),
gjson.GetBytes(body, "store").Bool(),
gjson.GetBytes(body, "instructions").Exists(),
gjson.GetBytes(body, "reasoning.effort").String(),
gjson.GetBytes(body, "reasoning.summary").String(),
strings.TrimSpace(headers.Get("Chatgpt-Account-Id")) != "",
strings.TrimSpace(headers.Get("Originator")),
req.Model,
opts.SourceFormat.String(),
)
}
36 changes: 17 additions & 19 deletions internal/runtime/executor/codex_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,18 +111,18 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
body, _ = sjson.SetBytes(body, "model", baseModel)
body, _ = sjson.SetBytes(body, "stream", true)
body, _ = sjson.DeleteBytes(body, "previous_response_id")
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
body, _ = sjson.DeleteBytes(body, "safety_identifier")
if !gjson.GetBytes(body, "instructions").Exists() {
body, _ = sjson.SetBytes(body, "instructions", "")
}

url := strings.TrimSuffix(baseURL, "/") + "/responses"
httpReq, err := e.cacheHelper(ctx, from, url, req, body)
httpReq, continuity, err := e.cacheHelper(ctx, auth, from, url, req, opts, body)
if err != nil {
return resp, err
}
applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)
logCodexRequestDiagnostics(ctx, auth, req, opts, httpReq.Header, body, continuity)
var authID, authLabel, authType, authValue string
if auth != nil {
authID = auth.ID
Expand Down Expand Up @@ -222,11 +222,12 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
body, _ = sjson.DeleteBytes(body, "stream")

url := strings.TrimSuffix(baseURL, "/") + "/responses/compact"
httpReq, err := e.cacheHelper(ctx, from, url, req, body)
httpReq, continuity, err := e.cacheHelper(ctx, auth, from, url, req, opts, body)
if err != nil {
return resp, err
}
applyCodexHeaders(httpReq, auth, apiKey, false, e.cfg)
logCodexRequestDiagnostics(ctx, auth, req, opts, httpReq.Header, body, continuity)
var authID, authLabel, authType, authValue string
if auth != nil {
authID = auth.ID
Expand Down Expand Up @@ -309,19 +310,19 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
requestedModel := payloadRequestedModel(opts, req.Model)
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
body, _ = sjson.DeleteBytes(body, "previous_response_id")
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
body, _ = sjson.DeleteBytes(body, "safety_identifier")
body, _ = sjson.SetBytes(body, "model", baseModel)
if !gjson.GetBytes(body, "instructions").Exists() {
body, _ = sjson.SetBytes(body, "instructions", "")
}

url := strings.TrimSuffix(baseURL, "/") + "/responses"
httpReq, err := e.cacheHelper(ctx, from, url, req, body)
httpReq, continuity, err := e.cacheHelper(ctx, auth, from, url, req, opts, body)
if err != nil {
return nil, err
}
applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)
logCodexRequestDiagnostics(ctx, auth, req, opts, httpReq.Header, body, continuity)
var authID, authLabel, authType, authValue string
if auth != nil {
authID = auth.ID
Expand Down Expand Up @@ -596,8 +597,9 @@ func (e *CodexExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*
return auth, nil
}

func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Format, url string, req cliproxyexecutor.Request, rawJSON []byte) (*http.Request, error) {
func (e *CodexExecutor) cacheHelper(ctx context.Context, auth *cliproxyauth.Auth, from sdktranslator.Format, url string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, rawJSON []byte) (*http.Request, codexContinuity, error) {
var cache codexCache
continuity := codexContinuity{}
if from == "claude" {
userIDResult := gjson.GetBytes(req.Payload, "metadata.user_id")
if userIDResult.Exists() {
Expand All @@ -610,30 +612,26 @@ func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Form
}
setCodexCache(key, cache)
}
continuity = codexContinuity{Key: cache.ID, Source: "claude_user_cache"}
}
} else if from == "openai-response" {
promptCacheKey := gjson.GetBytes(req.Payload, "prompt_cache_key")
if promptCacheKey.Exists() {
cache.ID = promptCacheKey.String()
continuity = codexContinuity{Key: cache.ID, Source: "prompt_cache_key"}
}
} else if from == "openai" {
if apiKey := strings.TrimSpace(apiKeyFromContext(ctx)); apiKey != "" {
cache.ID = uuid.NewSHA1(uuid.NameSpaceOID, []byte("cli-proxy-api:codex:prompt-cache:"+apiKey)).String()
}
continuity = resolveCodexContinuity(ctx, auth, req, opts)
cache.ID = continuity.Key
}

if cache.ID != "" {
rawJSON, _ = sjson.SetBytes(rawJSON, "prompt_cache_key", cache.ID)
}
rawJSON = applyCodexContinuityBody(rawJSON, continuity)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve Claude cache ID when applying continuity

cacheHelper still computes a Claude-scoped cache.ID, but this refactor now applies body/header continuity only through continuity; because the Claude branch never assigns continuity, prompt_cache_key and session_id stop being forwarded for Claude-origin requests. That regresses previously working prompt-cache/session continuity for Claude→Codex flows and causes repeated turns to miss upstream cache reuse.

Useful? React with 👍 / 👎.

Comment on lines +624 to +628
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Carry Claude cache ID into continuity before applying headers

The Claude branch still computes cache.ID, but only continuity is applied to prompt_cache_key/session_id now. Because continuity is never set for from == "claude", this path silently drops continuity fields that were previously sent, so repeated Claude→Codex requests lose cache/session reuse. The same pattern is also present in websocket continuity handling (applyCodexPromptCacheHeaders).

Useful? React with 👍 / 👎.

httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(rawJSON))
if err != nil {
return nil, err
}
if cache.ID != "" {
httpReq.Header.Set("Conversation_id", cache.ID)
httpReq.Header.Set("Session_id", cache.ID)
return nil, continuity, err
}
return httpReq, nil
applyCodexContinuityHeaders(httpReq.Header, continuity)
return httpReq, continuity, nil
}

func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, cfg *config.Config) {
Expand All @@ -646,7 +644,7 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, s
}

misc.EnsureHeader(r.Header, ginHeaders, "Version", "")
misc.EnsureHeader(r.Header, ginHeaders, "Session_id", uuid.NewString())
misc.EnsureHeader(r.Header, ginHeaders, "session_id", uuid.NewString())
misc.EnsureHeader(r.Header, ginHeaders, "X-Codex-Turn-Metadata", "")
misc.EnsureHeader(r.Header, ginHeaders, "X-Client-Request-Id", "")
cfgUserAgent, _ := codexHeaderDefaults(cfg, auth)
Expand Down
128 changes: 122 additions & 6 deletions internal/runtime/executor/codex_executor_cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/gin-gonic/gin"
"github.com/google/uuid"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
"github.com/tidwall/gjson"
Expand All @@ -27,7 +28,7 @@ func TestCodexExecutorCacheHelper_OpenAIChatCompletions_StablePromptCacheKeyFrom
}
url := "https://example.com/responses"

httpReq, err := executor.cacheHelper(ctx, sdktranslator.FromString("openai"), url, req, rawJSON)
httpReq, _, err := executor.cacheHelper(ctx, nil, sdktranslator.FromString("openai"), url, req, cliproxyexecutor.Options{}, rawJSON)
if err != nil {
t.Fatalf("cacheHelper error: %v", err)
}
Expand All @@ -42,14 +43,14 @@ func TestCodexExecutorCacheHelper_OpenAIChatCompletions_StablePromptCacheKeyFrom
if gotKey != expectedKey {
t.Fatalf("prompt_cache_key = %q, want %q", gotKey, expectedKey)
}
if gotConversation := httpReq.Header.Get("Conversation_id"); gotConversation != expectedKey {
t.Fatalf("Conversation_id = %q, want %q", gotConversation, expectedKey)
if gotSession := httpReq.Header.Get("session_id"); gotSession != expectedKey {
t.Fatalf("session_id = %q, want %q", gotSession, expectedKey)
}
if gotSession := httpReq.Header.Get("Session_id"); gotSession != expectedKey {
t.Fatalf("Session_id = %q, want %q", gotSession, expectedKey)
if got := httpReq.Header.Get("Conversation_id"); got != "" {
t.Fatalf("Conversation_id = %q, want empty", got)
}

httpReq2, err := executor.cacheHelper(ctx, sdktranslator.FromString("openai"), url, req, rawJSON)
httpReq2, _, err := executor.cacheHelper(ctx, nil, sdktranslator.FromString("openai"), url, req, cliproxyexecutor.Options{}, rawJSON)
if err != nil {
t.Fatalf("cacheHelper error (second call): %v", err)
}
Expand All @@ -62,3 +63,118 @@ func TestCodexExecutorCacheHelper_OpenAIChatCompletions_StablePromptCacheKeyFrom
t.Fatalf("prompt_cache_key (second call) = %q, want %q", gotKey2, expectedKey)
}
}

func TestCodexExecutorCacheHelper_OpenAIResponses_PreservesPromptCacheRetention(t *testing.T) {
executor := &CodexExecutor{}
url := "https://example.com/responses"
req := cliproxyexecutor.Request{
Model: "gpt-5.3-codex",
Payload: []byte(`{"model":"gpt-5.3-codex","prompt_cache_key":"cache-key-1","prompt_cache_retention":"persistent"}`),
}
rawJSON := []byte(`{"model":"gpt-5.3-codex","stream":true,"prompt_cache_retention":"persistent"}`)

httpReq, _, err := executor.cacheHelper(context.Background(), nil, sdktranslator.FromString("openai-response"), url, req, cliproxyexecutor.Options{}, rawJSON)
if err != nil {
t.Fatalf("cacheHelper error: %v", err)
}

body, err := io.ReadAll(httpReq.Body)
if err != nil {
t.Fatalf("read request body: %v", err)
}

if got := gjson.GetBytes(body, "prompt_cache_key").String(); got != "cache-key-1" {
t.Fatalf("prompt_cache_key = %q, want %q", got, "cache-key-1")
}
if got := gjson.GetBytes(body, "prompt_cache_retention").String(); got != "persistent" {
t.Fatalf("prompt_cache_retention = %q, want %q", got, "persistent")
}
if got := httpReq.Header.Get("session_id"); got != "cache-key-1" {
t.Fatalf("session_id = %q, want %q", got, "cache-key-1")
}
if got := httpReq.Header.Get("Conversation_id"); got != "" {
t.Fatalf("Conversation_id = %q, want empty", got)
}
}

func TestCodexExecutorCacheHelper_OpenAIChatCompletions_UsesExecutionSessionForContinuity(t *testing.T) {
executor := &CodexExecutor{}
rawJSON := []byte(`{"model":"gpt-5.4","stream":true}`)
req := cliproxyexecutor.Request{
Model: "gpt-5.4",
Payload: []byte(`{"model":"gpt-5.4"}`),
}
opts := cliproxyexecutor.Options{Metadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: "exec-session-1"}}

httpReq, _, err := executor.cacheHelper(context.Background(), nil, sdktranslator.FromString("openai"), "https://example.com/responses", req, opts, rawJSON)
if err != nil {
t.Fatalf("cacheHelper error: %v", err)
}

body, err := io.ReadAll(httpReq.Body)
if err != nil {
t.Fatalf("read request body: %v", err)
}

if got := gjson.GetBytes(body, "prompt_cache_key").String(); got != "exec-session-1" {
t.Fatalf("prompt_cache_key = %q, want %q", got, "exec-session-1")
}
if got := httpReq.Header.Get("session_id"); got != "exec-session-1" {
t.Fatalf("session_id = %q, want %q", got, "exec-session-1")
}
}

func TestCodexExecutorCacheHelper_OpenAIChatCompletions_FallsBackToStableAuthID(t *testing.T) {
executor := &CodexExecutor{}
rawJSON := []byte(`{"model":"gpt-5.4","stream":true}`)
req := cliproxyexecutor.Request{
Model: "gpt-5.4",
Payload: []byte(`{"model":"gpt-5.4"}`),
}
auth := &cliproxyauth.Auth{ID: "codex-auth-1", Provider: "codex"}

httpReq, _, err := executor.cacheHelper(context.Background(), auth, sdktranslator.FromString("openai"), "https://example.com/responses", req, cliproxyexecutor.Options{}, rawJSON)
if err != nil {
t.Fatalf("cacheHelper error: %v", err)
}

body, err := io.ReadAll(httpReq.Body)
if err != nil {
t.Fatalf("read request body: %v", err)
}

expected := uuid.NewSHA1(uuid.NameSpaceOID, []byte("cli-proxy-api:codex:prompt-cache:auth:codex-auth-1")).String()
if got := gjson.GetBytes(body, "prompt_cache_key").String(); got != expected {
t.Fatalf("prompt_cache_key = %q, want %q", got, expected)
}
if got := httpReq.Header.Get("session_id"); got != expected {
t.Fatalf("session_id = %q, want %q", got, expected)
}
}

func TestCodexExecutorCacheHelper_ClaudePreservesCacheContinuity(t *testing.T) {
executor := &CodexExecutor{}
req := cliproxyexecutor.Request{
Model: "claude-3-7-sonnet",
Payload: []byte(`{"metadata":{"user_id":"user-1"}}`),
}
rawJSON := []byte(`{"model":"gpt-5.4","stream":true}`)

httpReq, continuity, err := executor.cacheHelper(context.Background(), nil, sdktranslator.FromString("claude"), "https://example.com/responses", req, cliproxyexecutor.Options{}, rawJSON)
if err != nil {
t.Fatalf("cacheHelper error: %v", err)
}
if continuity.Key == "" {
t.Fatal("continuity.Key = empty, want non-empty")
}
body, err := io.ReadAll(httpReq.Body)
if err != nil {
t.Fatalf("read request body: %v", err)
}
if got := gjson.GetBytes(body, "prompt_cache_key").String(); got != continuity.Key {
t.Fatalf("prompt_cache_key = %q, want %q", got, continuity.Key)
}
if got := httpReq.Header.Get("session_id"); got != continuity.Key {
t.Fatalf("session_id = %q, want %q", got, continuity.Key)
}
}
Loading
Loading