Merge pull request #2374 from VooDisss/codex-cache-clean
fix(codex): restore prompt cache continuity for Codex requests
This commit is contained in:
125
internal/runtime/executor/codex_continuity.go
Normal file
125
internal/runtime/executor/codex_continuity.go
Normal file
@@ -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"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -111,7 +111,6 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
|
|||||||
body, _ = sjson.SetBytes(body, "model", baseModel)
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||||
body, _ = sjson.SetBytes(body, "stream", true)
|
body, _ = sjson.SetBytes(body, "stream", true)
|
||||||
body, _ = sjson.DeleteBytes(body, "previous_response_id")
|
body, _ = sjson.DeleteBytes(body, "previous_response_id")
|
||||||
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
|
|
||||||
body, _ = sjson.DeleteBytes(body, "safety_identifier")
|
body, _ = sjson.DeleteBytes(body, "safety_identifier")
|
||||||
body, _ = sjson.DeleteBytes(body, "stream_options")
|
body, _ = sjson.DeleteBytes(body, "stream_options")
|
||||||
if !gjson.GetBytes(body, "instructions").Exists() {
|
if !gjson.GetBytes(body, "instructions").Exists() {
|
||||||
@@ -119,11 +118,12 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
|
|||||||
}
|
}
|
||||||
|
|
||||||
url := strings.TrimSuffix(baseURL, "/") + "/responses"
|
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 {
|
if err != nil {
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)
|
applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)
|
||||||
|
logCodexRequestDiagnostics(ctx, auth, req, opts, httpReq.Header, body, continuity)
|
||||||
var authID, authLabel, authType, authValue string
|
var authID, authLabel, authType, authValue string
|
||||||
if auth != nil {
|
if auth != nil {
|
||||||
authID = auth.ID
|
authID = auth.ID
|
||||||
@@ -223,11 +223,12 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
|
|||||||
body, _ = sjson.DeleteBytes(body, "stream")
|
body, _ = sjson.DeleteBytes(body, "stream")
|
||||||
|
|
||||||
url := strings.TrimSuffix(baseURL, "/") + "/responses/compact"
|
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 {
|
if err != nil {
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
applyCodexHeaders(httpReq, auth, apiKey, false, e.cfg)
|
applyCodexHeaders(httpReq, auth, apiKey, false, e.cfg)
|
||||||
|
logCodexRequestDiagnostics(ctx, auth, req, opts, httpReq.Header, body, continuity)
|
||||||
var authID, authLabel, authType, authValue string
|
var authID, authLabel, authType, authValue string
|
||||||
if auth != nil {
|
if auth != nil {
|
||||||
authID = auth.ID
|
authID = auth.ID
|
||||||
@@ -310,7 +311,6 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
|
|||||||
requestedModel := payloadRequestedModel(opts, req.Model)
|
requestedModel := payloadRequestedModel(opts, req.Model)
|
||||||
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
|
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
|
||||||
body, _ = sjson.DeleteBytes(body, "previous_response_id")
|
body, _ = sjson.DeleteBytes(body, "previous_response_id")
|
||||||
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
|
|
||||||
body, _ = sjson.DeleteBytes(body, "safety_identifier")
|
body, _ = sjson.DeleteBytes(body, "safety_identifier")
|
||||||
body, _ = sjson.DeleteBytes(body, "stream_options")
|
body, _ = sjson.DeleteBytes(body, "stream_options")
|
||||||
body, _ = sjson.SetBytes(body, "model", baseModel)
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||||
@@ -319,11 +319,12 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
|
|||||||
}
|
}
|
||||||
|
|
||||||
url := strings.TrimSuffix(baseURL, "/") + "/responses"
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)
|
applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)
|
||||||
|
logCodexRequestDiagnostics(ctx, auth, req, opts, httpReq.Header, body, continuity)
|
||||||
var authID, authLabel, authType, authValue string
|
var authID, authLabel, authType, authValue string
|
||||||
if auth != nil {
|
if auth != nil {
|
||||||
authID = auth.ID
|
authID = auth.ID
|
||||||
@@ -599,8 +600,9 @@ func (e *CodexExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*
|
|||||||
return auth, nil
|
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
|
var cache codexCache
|
||||||
|
continuity := codexContinuity{}
|
||||||
if from == "claude" {
|
if from == "claude" {
|
||||||
userIDResult := gjson.GetBytes(req.Payload, "metadata.user_id")
|
userIDResult := gjson.GetBytes(req.Payload, "metadata.user_id")
|
||||||
if userIDResult.Exists() {
|
if userIDResult.Exists() {
|
||||||
@@ -613,30 +615,26 @@ func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Form
|
|||||||
}
|
}
|
||||||
setCodexCache(key, cache)
|
setCodexCache(key, cache)
|
||||||
}
|
}
|
||||||
|
continuity = codexContinuity{Key: cache.ID, Source: "claude_user_cache"}
|
||||||
}
|
}
|
||||||
} else if from == "openai-response" {
|
} else if from == "openai-response" {
|
||||||
promptCacheKey := gjson.GetBytes(req.Payload, "prompt_cache_key")
|
promptCacheKey := gjson.GetBytes(req.Payload, "prompt_cache_key")
|
||||||
if promptCacheKey.Exists() {
|
if promptCacheKey.Exists() {
|
||||||
cache.ID = promptCacheKey.String()
|
cache.ID = promptCacheKey.String()
|
||||||
|
continuity = codexContinuity{Key: cache.ID, Source: "prompt_cache_key"}
|
||||||
}
|
}
|
||||||
} else if from == "openai" {
|
} else if from == "openai" {
|
||||||
if apiKey := strings.TrimSpace(apiKeyFromContext(ctx)); apiKey != "" {
|
continuity = resolveCodexContinuity(ctx, auth, req, opts)
|
||||||
cache.ID = uuid.NewSHA1(uuid.NameSpaceOID, []byte("cli-proxy-api:codex:prompt-cache:"+apiKey)).String()
|
cache.ID = continuity.Key
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if cache.ID != "" {
|
rawJSON = applyCodexContinuityBody(rawJSON, continuity)
|
||||||
rawJSON, _ = sjson.SetBytes(rawJSON, "prompt_cache_key", cache.ID)
|
|
||||||
}
|
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(rawJSON))
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(rawJSON))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, continuity, err
|
||||||
}
|
}
|
||||||
if cache.ID != "" {
|
applyCodexContinuityHeaders(httpReq.Header, continuity)
|
||||||
httpReq.Header.Set("Conversation_id", cache.ID)
|
return httpReq, continuity, nil
|
||||||
httpReq.Header.Set("Session_id", cache.ID)
|
|
||||||
}
|
|
||||||
return httpReq, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, cfg *config.Config) {
|
func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, cfg *config.Config) {
|
||||||
@@ -649,7 +647,7 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, s
|
|||||||
}
|
}
|
||||||
|
|
||||||
misc.EnsureHeader(r.Header, ginHeaders, "Version", "")
|
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-Codex-Turn-Metadata", "")
|
||||||
misc.EnsureHeader(r.Header, ginHeaders, "X-Client-Request-Id", "")
|
misc.EnsureHeader(r.Header, ginHeaders, "X-Client-Request-Id", "")
|
||||||
cfgUserAgent, _ := codexHeaderDefaults(cfg, auth)
|
cfgUserAgent, _ := codexHeaderDefaults(cfg, auth)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"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"
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
@@ -27,7 +28,7 @@ func TestCodexExecutorCacheHelper_OpenAIChatCompletions_StablePromptCacheKeyFrom
|
|||||||
}
|
}
|
||||||
url := "https://example.com/responses"
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("cacheHelper error: %v", err)
|
t.Fatalf("cacheHelper error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -42,14 +43,14 @@ func TestCodexExecutorCacheHelper_OpenAIChatCompletions_StablePromptCacheKeyFrom
|
|||||||
if gotKey != expectedKey {
|
if gotKey != expectedKey {
|
||||||
t.Fatalf("prompt_cache_key = %q, want %q", gotKey, expectedKey)
|
t.Fatalf("prompt_cache_key = %q, want %q", gotKey, expectedKey)
|
||||||
}
|
}
|
||||||
if gotConversation := httpReq.Header.Get("Conversation_id"); gotConversation != expectedKey {
|
if gotSession := httpReq.Header.Get("session_id"); gotSession != expectedKey {
|
||||||
t.Fatalf("Conversation_id = %q, want %q", gotConversation, expectedKey)
|
t.Fatalf("session_id = %q, want %q", gotSession, expectedKey)
|
||||||
}
|
}
|
||||||
if gotSession := httpReq.Header.Get("Session_id"); gotSession != expectedKey {
|
if got := httpReq.Header.Get("Conversation_id"); got != "" {
|
||||||
t.Fatalf("Session_id = %q, want %q", gotSession, expectedKey)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("cacheHelper error (second call): %v", err)
|
t.Fatalf("cacheHelper error (second call): %v", err)
|
||||||
}
|
}
|
||||||
@@ -62,3 +63,118 @@ func TestCodexExecutorCacheHelper_OpenAIChatCompletions_StablePromptCacheKeyFrom
|
|||||||
t.Fatalf("prompt_cache_key (second call) = %q, want %q", gotKey2, expectedKey)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -178,7 +178,6 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
|
|||||||
body, _ = sjson.SetBytes(body, "model", baseModel)
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||||
body, _ = sjson.SetBytes(body, "stream", true)
|
body, _ = sjson.SetBytes(body, "stream", true)
|
||||||
body, _ = sjson.DeleteBytes(body, "previous_response_id")
|
body, _ = sjson.DeleteBytes(body, "previous_response_id")
|
||||||
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
|
|
||||||
body, _ = sjson.DeleteBytes(body, "safety_identifier")
|
body, _ = sjson.DeleteBytes(body, "safety_identifier")
|
||||||
if !gjson.GetBytes(body, "instructions").Exists() {
|
if !gjson.GetBytes(body, "instructions").Exists() {
|
||||||
body, _ = sjson.SetBytes(body, "instructions", "")
|
body, _ = sjson.SetBytes(body, "instructions", "")
|
||||||
@@ -190,7 +189,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
|
|||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
body, wsHeaders := applyCodexPromptCacheHeaders(from, req, body)
|
body, wsHeaders, continuity := applyCodexPromptCacheHeaders(ctx, auth, from, req, opts, body)
|
||||||
wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg)
|
wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg)
|
||||||
|
|
||||||
var authID, authLabel, authType, authValue string
|
var authID, authLabel, authType, authValue string
|
||||||
@@ -209,6 +208,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
|
|||||||
}
|
}
|
||||||
|
|
||||||
wsReqBody := buildCodexWebsocketRequestBody(body)
|
wsReqBody := buildCodexWebsocketRequestBody(body)
|
||||||
|
logCodexRequestDiagnostics(ctx, auth, req, opts, wsHeaders, body, continuity)
|
||||||
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
|
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
|
||||||
URL: wsURL,
|
URL: wsURL,
|
||||||
Method: "WEBSOCKET",
|
Method: "WEBSOCKET",
|
||||||
@@ -385,7 +385,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
body, wsHeaders := applyCodexPromptCacheHeaders(from, req, body)
|
body, wsHeaders, continuity := applyCodexPromptCacheHeaders(ctx, auth, from, req, opts, body)
|
||||||
wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg)
|
wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg)
|
||||||
|
|
||||||
var authID, authLabel, authType, authValue string
|
var authID, authLabel, authType, authValue string
|
||||||
@@ -403,6 +403,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
|
|||||||
}
|
}
|
||||||
|
|
||||||
wsReqBody := buildCodexWebsocketRequestBody(body)
|
wsReqBody := buildCodexWebsocketRequestBody(body)
|
||||||
|
logCodexRequestDiagnostics(ctx, auth, req, opts, wsHeaders, body, continuity)
|
||||||
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
|
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
|
||||||
URL: wsURL,
|
URL: wsURL,
|
||||||
Method: "WEBSOCKET",
|
Method: "WEBSOCKET",
|
||||||
@@ -761,13 +762,14 @@ func buildCodexResponsesWebsocketURL(httpURL string) (string, error) {
|
|||||||
return parsed.String(), nil
|
return parsed.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyCodexPromptCacheHeaders(from sdktranslator.Format, req cliproxyexecutor.Request, rawJSON []byte) ([]byte, http.Header) {
|
func applyCodexPromptCacheHeaders(ctx context.Context, auth *cliproxyauth.Auth, from sdktranslator.Format, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, rawJSON []byte) ([]byte, http.Header, codexContinuity) {
|
||||||
headers := http.Header{}
|
headers := http.Header{}
|
||||||
if len(rawJSON) == 0 {
|
if len(rawJSON) == 0 {
|
||||||
return rawJSON, headers
|
return rawJSON, headers, codexContinuity{}
|
||||||
}
|
}
|
||||||
|
|
||||||
var cache codexCache
|
var cache codexCache
|
||||||
|
continuity := codexContinuity{}
|
||||||
if from == "claude" {
|
if from == "claude" {
|
||||||
userIDResult := gjson.GetBytes(req.Payload, "metadata.user_id")
|
userIDResult := gjson.GetBytes(req.Payload, "metadata.user_id")
|
||||||
if userIDResult.Exists() {
|
if userIDResult.Exists() {
|
||||||
@@ -781,20 +783,22 @@ func applyCodexPromptCacheHeaders(from sdktranslator.Format, req cliproxyexecuto
|
|||||||
}
|
}
|
||||||
setCodexCache(key, cache)
|
setCodexCache(key, cache)
|
||||||
}
|
}
|
||||||
|
continuity = codexContinuity{Key: cache.ID, Source: "claude_user_cache"}
|
||||||
}
|
}
|
||||||
} else if from == "openai-response" {
|
} else if from == "openai-response" {
|
||||||
if promptCacheKey := gjson.GetBytes(req.Payload, "prompt_cache_key"); promptCacheKey.Exists() {
|
if promptCacheKey := gjson.GetBytes(req.Payload, "prompt_cache_key"); promptCacheKey.Exists() {
|
||||||
cache.ID = promptCacheKey.String()
|
cache.ID = promptCacheKey.String()
|
||||||
|
continuity = codexContinuity{Key: cache.ID, Source: "prompt_cache_key"}
|
||||||
}
|
}
|
||||||
|
} else if from == "openai" {
|
||||||
|
continuity = resolveCodexContinuity(ctx, auth, req, opts)
|
||||||
|
cache.ID = continuity.Key
|
||||||
}
|
}
|
||||||
|
|
||||||
if cache.ID != "" {
|
rawJSON = applyCodexContinuityBody(rawJSON, continuity)
|
||||||
rawJSON, _ = sjson.SetBytes(rawJSON, "prompt_cache_key", cache.ID)
|
applyCodexContinuityHeaders(headers, continuity)
|
||||||
headers.Set("Conversation_id", cache.ID)
|
|
||||||
headers.Set("Session_id", cache.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return rawJSON, headers
|
return rawJSON, headers, continuity
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth *cliproxyauth.Auth, token string, cfg *config.Config) http.Header {
|
func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth *cliproxyauth.Auth, token string, cfg *config.Config) http.Header {
|
||||||
@@ -826,7 +830,7 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth *
|
|||||||
betaHeader = codexResponsesWebsocketBetaHeaderValue
|
betaHeader = codexResponsesWebsocketBetaHeaderValue
|
||||||
}
|
}
|
||||||
headers.Set("OpenAI-Beta", betaHeader)
|
headers.Set("OpenAI-Beta", betaHeader)
|
||||||
misc.EnsureHeader(headers, ginHeaders, "Session_id", uuid.NewString())
|
misc.EnsureHeader(headers, ginHeaders, "session_id", uuid.NewString())
|
||||||
ensureHeaderWithConfigPrecedence(headers, ginHeaders, "User-Agent", cfgUserAgent, codexUserAgent)
|
ensureHeaderWithConfigPrecedence(headers, ginHeaders, "User-Agent", cfgUserAgent, codexUserAgent)
|
||||||
|
|
||||||
isAPIKey := false
|
isAPIKey := false
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||||
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,6 +34,49 @@ func TestBuildCodexWebsocketRequestBodyPreservesPreviousResponseID(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyCodexPromptCacheHeaders_PreservesPromptCacheRetention(t *testing.T) {
|
||||||
|
req := cliproxyexecutor.Request{
|
||||||
|
Model: "gpt-5-codex",
|
||||||
|
Payload: []byte(`{"prompt_cache_key":"cache-key-1","prompt_cache_retention":"persistent"}`),
|
||||||
|
}
|
||||||
|
body := []byte(`{"model":"gpt-5-codex","stream":true,"prompt_cache_retention":"persistent"}`)
|
||||||
|
|
||||||
|
updatedBody, headers, _ := applyCodexPromptCacheHeaders(context.Background(), nil, sdktranslator.FromString("openai-response"), req, cliproxyexecutor.Options{}, body)
|
||||||
|
|
||||||
|
if got := gjson.GetBytes(updatedBody, "prompt_cache_key").String(); got != "cache-key-1" {
|
||||||
|
t.Fatalf("prompt_cache_key = %q, want %q", got, "cache-key-1")
|
||||||
|
}
|
||||||
|
if got := gjson.GetBytes(updatedBody, "prompt_cache_retention").String(); got != "persistent" {
|
||||||
|
t.Fatalf("prompt_cache_retention = %q, want %q", got, "persistent")
|
||||||
|
}
|
||||||
|
if got := headers.Get("session_id"); got != "cache-key-1" {
|
||||||
|
t.Fatalf("session_id = %q, want %q", got, "cache-key-1")
|
||||||
|
}
|
||||||
|
if got := headers.Get("Conversation_id"); got != "" {
|
||||||
|
t.Fatalf("Conversation_id = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyCodexPromptCacheHeaders_ClaudePreservesContinuity(t *testing.T) {
|
||||||
|
req := cliproxyexecutor.Request{
|
||||||
|
Model: "claude-3-7-sonnet",
|
||||||
|
Payload: []byte(`{"metadata":{"user_id":"user-1"}}`),
|
||||||
|
}
|
||||||
|
body := []byte(`{"model":"gpt-5.4","stream":true}`)
|
||||||
|
|
||||||
|
updatedBody, headers, continuity := applyCodexPromptCacheHeaders(context.Background(), nil, sdktranslator.FromString("claude"), req, cliproxyexecutor.Options{}, body)
|
||||||
|
|
||||||
|
if continuity.Key == "" {
|
||||||
|
t.Fatal("continuity.Key = empty, want non-empty")
|
||||||
|
}
|
||||||
|
if got := gjson.GetBytes(updatedBody, "prompt_cache_key").String(); got != continuity.Key {
|
||||||
|
t.Fatalf("prompt_cache_key = %q, want %q", got, continuity.Key)
|
||||||
|
}
|
||||||
|
if got := headers.Get("session_id"); got != continuity.Key {
|
||||||
|
t.Fatalf("session_id = %q, want %q", got, continuity.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApplyCodexWebsocketHeadersDefaultsToCurrentResponsesBeta(t *testing.T) {
|
func TestApplyCodexWebsocketHeadersDefaultsToCurrentResponsesBeta(t *testing.T) {
|
||||||
headers := applyCodexWebsocketHeaders(context.Background(), http.Header{}, nil, "", nil)
|
headers := applyCodexWebsocketHeaders(context.Background(), http.Header{}, nil, "", nil)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user