mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-03-31 17:55:26 +00:00
Restore Claude continuity after the continuity refactor, keep auth-affinity keys out of upstream Codex session identifiers, and only persist affinity after successful execution so retries can still rotate to healthy credentials when the first auth fails.
337 lines
12 KiB
Go
337 lines
12 KiB
Go
package executor
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
|
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"
|
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
func TestBuildCodexWebsocketRequestBodyPreservesPreviousResponseID(t *testing.T) {
|
|
body := []byte(`{"model":"gpt-5-codex","previous_response_id":"resp-1","input":[{"type":"message","id":"msg-1"}]}`)
|
|
|
|
wsReqBody := buildCodexWebsocketRequestBody(body)
|
|
|
|
if got := gjson.GetBytes(wsReqBody, "type").String(); got != "response.create" {
|
|
t.Fatalf("type = %s, want response.create", got)
|
|
}
|
|
if got := gjson.GetBytes(wsReqBody, "previous_response_id").String(); got != "resp-1" {
|
|
t.Fatalf("previous_response_id = %s, want resp-1", got)
|
|
}
|
|
if gjson.GetBytes(wsReqBody, "input.0.id").String() != "msg-1" {
|
|
t.Fatalf("input item id mismatch")
|
|
}
|
|
if got := gjson.GetBytes(wsReqBody, "type").String(); got == "response.append" {
|
|
t.Fatalf("unexpected websocket request type: %s", got)
|
|
}
|
|
}
|
|
|
|
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) {
|
|
headers := applyCodexWebsocketHeaders(context.Background(), http.Header{}, nil, "", nil)
|
|
|
|
if got := headers.Get("OpenAI-Beta"); got != codexResponsesWebsocketBetaHeaderValue {
|
|
t.Fatalf("OpenAI-Beta = %s, want %s", got, codexResponsesWebsocketBetaHeaderValue)
|
|
}
|
|
if got := headers.Get("User-Agent"); got != codexUserAgent {
|
|
t.Fatalf("User-Agent = %s, want %s", got, codexUserAgent)
|
|
}
|
|
if got := headers.Get("Version"); got != "" {
|
|
t.Fatalf("Version = %q, want empty", got)
|
|
}
|
|
if got := headers.Get("x-codex-beta-features"); got != "" {
|
|
t.Fatalf("x-codex-beta-features = %q, want empty", got)
|
|
}
|
|
if got := headers.Get("X-Codex-Turn-Metadata"); got != "" {
|
|
t.Fatalf("X-Codex-Turn-Metadata = %q, want empty", got)
|
|
}
|
|
if got := headers.Get("X-Client-Request-Id"); got != "" {
|
|
t.Fatalf("X-Client-Request-Id = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestApplyCodexWebsocketHeadersPassesThroughClientIdentityHeaders(t *testing.T) {
|
|
auth := &cliproxyauth.Auth{
|
|
Provider: "codex",
|
|
Metadata: map[string]any{"email": "user@example.com"},
|
|
}
|
|
ctx := contextWithGinHeaders(map[string]string{
|
|
"Originator": "Codex Desktop",
|
|
"Version": "0.115.0-alpha.27",
|
|
"X-Codex-Turn-Metadata": `{"turn_id":"turn-1"}`,
|
|
"X-Client-Request-Id": "019d2233-e240-7162-992d-38df0a2a0e0d",
|
|
})
|
|
|
|
headers := applyCodexWebsocketHeaders(ctx, http.Header{}, auth, "", nil)
|
|
|
|
if got := headers.Get("Originator"); got != "Codex Desktop" {
|
|
t.Fatalf("Originator = %s, want %s", got, "Codex Desktop")
|
|
}
|
|
if got := headers.Get("Version"); got != "0.115.0-alpha.27" {
|
|
t.Fatalf("Version = %s, want %s", got, "0.115.0-alpha.27")
|
|
}
|
|
if got := headers.Get("X-Codex-Turn-Metadata"); got != `{"turn_id":"turn-1"}` {
|
|
t.Fatalf("X-Codex-Turn-Metadata = %s, want %s", got, `{"turn_id":"turn-1"}`)
|
|
}
|
|
if got := headers.Get("X-Client-Request-Id"); got != "019d2233-e240-7162-992d-38df0a2a0e0d" {
|
|
t.Fatalf("X-Client-Request-Id = %s, want %s", got, "019d2233-e240-7162-992d-38df0a2a0e0d")
|
|
}
|
|
}
|
|
|
|
func TestApplyCodexWebsocketHeadersUsesConfigDefaultsForOAuth(t *testing.T) {
|
|
cfg := &config.Config{
|
|
CodexHeaderDefaults: config.CodexHeaderDefaults{
|
|
UserAgent: "my-codex-client/1.0",
|
|
BetaFeatures: "feature-a,feature-b",
|
|
},
|
|
}
|
|
auth := &cliproxyauth.Auth{
|
|
Provider: "codex",
|
|
Metadata: map[string]any{"email": "user@example.com"},
|
|
}
|
|
|
|
headers := applyCodexWebsocketHeaders(context.Background(), http.Header{}, auth, "", cfg)
|
|
|
|
if got := headers.Get("User-Agent"); got != "my-codex-client/1.0" {
|
|
t.Fatalf("User-Agent = %s, want %s", got, "my-codex-client/1.0")
|
|
}
|
|
if got := headers.Get("x-codex-beta-features"); got != "feature-a,feature-b" {
|
|
t.Fatalf("x-codex-beta-features = %s, want %s", got, "feature-a,feature-b")
|
|
}
|
|
if got := headers.Get("OpenAI-Beta"); got != codexResponsesWebsocketBetaHeaderValue {
|
|
t.Fatalf("OpenAI-Beta = %s, want %s", got, codexResponsesWebsocketBetaHeaderValue)
|
|
}
|
|
}
|
|
|
|
func TestApplyCodexWebsocketHeadersPrefersExistingHeadersOverClientAndConfig(t *testing.T) {
|
|
cfg := &config.Config{
|
|
CodexHeaderDefaults: config.CodexHeaderDefaults{
|
|
UserAgent: "config-ua",
|
|
BetaFeatures: "config-beta",
|
|
},
|
|
}
|
|
auth := &cliproxyauth.Auth{
|
|
Provider: "codex",
|
|
Metadata: map[string]any{"email": "user@example.com"},
|
|
}
|
|
ctx := contextWithGinHeaders(map[string]string{
|
|
"User-Agent": "client-ua",
|
|
"X-Codex-Beta-Features": "client-beta",
|
|
})
|
|
headers := http.Header{}
|
|
headers.Set("User-Agent", "existing-ua")
|
|
headers.Set("X-Codex-Beta-Features", "existing-beta")
|
|
|
|
got := applyCodexWebsocketHeaders(ctx, headers, auth, "", cfg)
|
|
|
|
if gotVal := got.Get("User-Agent"); gotVal != "existing-ua" {
|
|
t.Fatalf("User-Agent = %s, want %s", gotVal, "existing-ua")
|
|
}
|
|
if gotVal := got.Get("x-codex-beta-features"); gotVal != "existing-beta" {
|
|
t.Fatalf("x-codex-beta-features = %s, want %s", gotVal, "existing-beta")
|
|
}
|
|
}
|
|
|
|
func TestApplyCodexWebsocketHeadersConfigUserAgentOverridesClientHeader(t *testing.T) {
|
|
cfg := &config.Config{
|
|
CodexHeaderDefaults: config.CodexHeaderDefaults{
|
|
UserAgent: "config-ua",
|
|
BetaFeatures: "config-beta",
|
|
},
|
|
}
|
|
auth := &cliproxyauth.Auth{
|
|
Provider: "codex",
|
|
Metadata: map[string]any{"email": "user@example.com"},
|
|
}
|
|
ctx := contextWithGinHeaders(map[string]string{
|
|
"User-Agent": "client-ua",
|
|
"X-Codex-Beta-Features": "client-beta",
|
|
})
|
|
|
|
headers := applyCodexWebsocketHeaders(ctx, http.Header{}, auth, "", cfg)
|
|
|
|
if got := headers.Get("User-Agent"); got != "config-ua" {
|
|
t.Fatalf("User-Agent = %s, want %s", got, "config-ua")
|
|
}
|
|
if got := headers.Get("x-codex-beta-features"); got != "client-beta" {
|
|
t.Fatalf("x-codex-beta-features = %s, want %s", got, "client-beta")
|
|
}
|
|
}
|
|
|
|
func TestApplyCodexWebsocketHeadersIgnoresConfigForAPIKeyAuth(t *testing.T) {
|
|
cfg := &config.Config{
|
|
CodexHeaderDefaults: config.CodexHeaderDefaults{
|
|
UserAgent: "config-ua",
|
|
BetaFeatures: "config-beta",
|
|
},
|
|
}
|
|
auth := &cliproxyauth.Auth{
|
|
Provider: "codex",
|
|
Attributes: map[string]string{"api_key": "sk-test"},
|
|
}
|
|
|
|
headers := applyCodexWebsocketHeaders(context.Background(), http.Header{}, auth, "sk-test", cfg)
|
|
|
|
if got := headers.Get("User-Agent"); got != codexUserAgent {
|
|
t.Fatalf("User-Agent = %s, want %s", got, codexUserAgent)
|
|
}
|
|
if got := headers.Get("x-codex-beta-features"); got != "" {
|
|
t.Fatalf("x-codex-beta-features = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestApplyCodexHeadersUsesConfigUserAgentForOAuth(t *testing.T) {
|
|
req, err := http.NewRequest(http.MethodPost, "https://example.com/responses", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest() error = %v", err)
|
|
}
|
|
cfg := &config.Config{
|
|
CodexHeaderDefaults: config.CodexHeaderDefaults{
|
|
UserAgent: "config-ua",
|
|
BetaFeatures: "config-beta",
|
|
},
|
|
}
|
|
auth := &cliproxyauth.Auth{
|
|
Provider: "codex",
|
|
Metadata: map[string]any{"email": "user@example.com"},
|
|
}
|
|
req = req.WithContext(contextWithGinHeaders(map[string]string{
|
|
"User-Agent": "client-ua",
|
|
}))
|
|
|
|
applyCodexHeaders(req, auth, "oauth-token", true, cfg)
|
|
|
|
if got := req.Header.Get("User-Agent"); got != "config-ua" {
|
|
t.Fatalf("User-Agent = %s, want %s", got, "config-ua")
|
|
}
|
|
if got := req.Header.Get("x-codex-beta-features"); got != "" {
|
|
t.Fatalf("x-codex-beta-features = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestApplyCodexHeadersPassesThroughClientIdentityHeaders(t *testing.T) {
|
|
req, err := http.NewRequest(http.MethodPost, "https://example.com/responses", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest() error = %v", err)
|
|
}
|
|
auth := &cliproxyauth.Auth{
|
|
Provider: "codex",
|
|
Metadata: map[string]any{"email": "user@example.com"},
|
|
}
|
|
req = req.WithContext(contextWithGinHeaders(map[string]string{
|
|
"Originator": "Codex Desktop",
|
|
"Version": "0.115.0-alpha.27",
|
|
"X-Codex-Turn-Metadata": `{"turn_id":"turn-1"}`,
|
|
"X-Client-Request-Id": "019d2233-e240-7162-992d-38df0a2a0e0d",
|
|
}))
|
|
|
|
applyCodexHeaders(req, auth, "oauth-token", true, nil)
|
|
|
|
if got := req.Header.Get("Originator"); got != "Codex Desktop" {
|
|
t.Fatalf("Originator = %s, want %s", got, "Codex Desktop")
|
|
}
|
|
if got := req.Header.Get("Version"); got != "0.115.0-alpha.27" {
|
|
t.Fatalf("Version = %s, want %s", got, "0.115.0-alpha.27")
|
|
}
|
|
if got := req.Header.Get("X-Codex-Turn-Metadata"); got != `{"turn_id":"turn-1"}` {
|
|
t.Fatalf("X-Codex-Turn-Metadata = %s, want %s", got, `{"turn_id":"turn-1"}`)
|
|
}
|
|
if got := req.Header.Get("X-Client-Request-Id"); got != "019d2233-e240-7162-992d-38df0a2a0e0d" {
|
|
t.Fatalf("X-Client-Request-Id = %s, want %s", got, "019d2233-e240-7162-992d-38df0a2a0e0d")
|
|
}
|
|
}
|
|
|
|
func TestApplyCodexHeadersDoesNotInjectClientOnlyHeadersByDefault(t *testing.T) {
|
|
req, err := http.NewRequest(http.MethodPost, "https://example.com/responses", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest() error = %v", err)
|
|
}
|
|
|
|
applyCodexHeaders(req, nil, "oauth-token", true, nil)
|
|
|
|
if got := req.Header.Get("Version"); got != "" {
|
|
t.Fatalf("Version = %q, want empty", got)
|
|
}
|
|
if got := req.Header.Get("X-Codex-Turn-Metadata"); got != "" {
|
|
t.Fatalf("X-Codex-Turn-Metadata = %q, want empty", got)
|
|
}
|
|
if got := req.Header.Get("X-Client-Request-Id"); got != "" {
|
|
t.Fatalf("X-Client-Request-Id = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func contextWithGinHeaders(headers map[string]string) context.Context {
|
|
gin.SetMode(gin.TestMode)
|
|
recorder := httptest.NewRecorder()
|
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
|
ginCtx.Request = httptest.NewRequest(http.MethodPost, "/", nil)
|
|
ginCtx.Request.Header = make(http.Header, len(headers))
|
|
for key, value := range headers {
|
|
ginCtx.Request.Header.Set(key, value)
|
|
}
|
|
return context.WithValue(context.Background(), "gin", ginCtx)
|
|
}
|
|
|
|
func TestNewProxyAwareWebsocketDialerDirectDisablesProxy(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dialer := newProxyAwareWebsocketDialer(
|
|
&config.Config{SDKConfig: sdkconfig.SDKConfig{ProxyURL: "http://global-proxy.example.com:8080"}},
|
|
&cliproxyauth.Auth{ProxyURL: "direct"},
|
|
)
|
|
|
|
if dialer.Proxy != nil {
|
|
t.Fatal("expected websocket proxy function to be nil for direct mode")
|
|
}
|
|
}
|