Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions internal/runtime/executor/codex_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ const (
codexDefaultImageToolModel = "gpt-image-2"
)

var refreshCodexAuthForTokenInvalidatedRetry = func(ctx context.Context, e *CodexExecutor, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
return e.Refresh(ctx, auth)
}

var dataTag = []byte("data:")

// Streamed Codex responses may emit response.output_item.done events while leaving
Expand Down Expand Up @@ -837,9 +841,63 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
}
helps.AppendAPIResponseChunk(ctx, e.cfg, b)
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
if isCodexTokenInvalidatedResponse(httpResp.StatusCode, b) && auth != nil {
if refreshedAuth, refreshErr := refreshCodexAuthForTokenInvalidatedRetry(ctx, e, auth); refreshErr == nil && refreshedAuth != nil {
auth = refreshedAuth
apiKey, baseURL = codexCreds(auth)
if baseURL == "" {
baseURL = "https://chatgpt.com/backend-api/codex"
}
url = strings.TrimSuffix(baseURL, "/") + "/responses"
retryReq, retryUpstreamBody, retryIdentityState, retryReqErr := e.cacheHelper(ctx, from, url, auth, req, originalPayloadSource, body)
if retryReqErr != nil {
err = retryReqErr
return resp, err
}
identityState = retryIdentityState
applyCodexHeaders(retryReq, auth, apiKey, true, e.cfg)
applyCodexIdentityConfuseHeaders(retryReq.Header, &identityState)
if auth != nil {
authID = auth.ID
authLabel = auth.Label
authType, authValue = auth.AccountInfo()
}
helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{
URL: url,
Method: http.MethodPost,
Headers: retryReq.Header.Clone(),
Body: retryUpstreamBody,
Provider: e.Identifier(),
AuthID: authID,
AuthLabel: authLabel,
AuthType: authType,
AuthValue: authValue,
})
if errClose := httpResp.Body.Close(); errClose != nil {
log.Errorf("codex executor: close response body error: %v", errClose)
}
httpResp, err = httpClient.Do(retryReq)

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 Avoid clobbering httpResp on failed retry

When the retry request fails at the transport layer after the initial token_invalidated 401 (for example a proxy error or context cancellation), this assignment replaces the successful first httpResp with the nil response returned by Do. The deferred close above then executes httpResp.Body.Close() and panics instead of returning the retry error; use a separate retry response variable or close the first response before reassigning. The compact retry path has the same pattern.

Useful? React with 👍 / 👎.

if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
return resp, err
}
Comment on lines +879 to +883

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

If httpClient.Do(retryReq) returns an error, httpResp will be assigned nil. Since the deferred function registered at line 830 calls httpResp.Body.Close() without a nil check, this will result in a nil pointer dereference panic.

To prevent this, use a temporary variable to capture the result of the retry request, and only reassign httpResp if the request succeeds.

Suggested change
httpResp, err = httpClient.Do(retryReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
return resp, err
}
retryResp, retryErr := httpClient.Do(retryReq)
if retryErr != nil {
helps.RecordAPIResponseError(ctx, e.cfg, retryErr)
return resp, retryErr
}
httpResp = retryResp

helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
if httpResp.StatusCode >= 200 && httpResp.StatusCode < 300 {
goto codexReadExecuteResponse
}
b, _ = io.ReadAll(httpResp.Body)

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 Apply replay invalidation to retry errors

When the refreshed retry returns a non-2xx invalid_encrypted_content or invalid thinking-signature error, this branch reads and returns the retry body without running clearCodexReasoningReplayOnInvalidSignature, unlike the initial error path above. That leaves the bad cached reasoning replay in place after a token-invalidated first attempt, so subsequent requests can keep replaying the same invalid block instead of self-healing; run the same replay-clear check on retry error bodies before returning.

Useful? React with 👍 / 👎.

b = applyCodexIdentityConfuseResponsePayload(b, identityState)
helps.AppendAPIResponseChunk(ctx, e.cfg, b)
helps.LogWithRequestID(ctx).Debugf("request error after refresh retry, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
} else if refreshErr != nil {
helps.LogWithRequestID(ctx).Debugf("codex token_invalidated refresh retry failed: %v", refreshErr)
}
}
err = newCodexStatusErr(httpResp.StatusCode, b)
return resp, err
}

codexReadExecuteResponse:
data, err := io.ReadAll(httpResp.Body)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
Expand Down Expand Up @@ -1006,9 +1064,53 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
b = applyCodexIdentityConfuseResponsePayload(b, identityState)
helps.AppendAPIResponseChunk(ctx, e.cfg, b)
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
if isCodexTokenInvalidatedResponse(httpResp.StatusCode, b) && auth != nil {
if refreshedAuth, refreshErr := refreshCodexAuthForTokenInvalidatedRetry(ctx, e, auth); refreshErr == nil && refreshedAuth != nil {
auth = refreshedAuth
apiKey, baseURL = codexCreds(auth)
if baseURL == "" {
baseURL = "https://chatgpt.com/backend-api/codex"
}
url = strings.TrimSuffix(baseURL, "/") + "/responses/compact"
retryReq, retryUpstreamBody, retryIdentityState, retryReqErr := e.cacheHelper(ctx, from, url, auth, req, originalPayloadSource, body)
if retryReqErr != nil {
err = retryReqErr
return resp, err
}
identityState = retryIdentityState
applyCodexHeaders(retryReq, auth, apiKey, false, e.cfg)
applyCodexIdentityConfuseHeaders(retryReq.Header, &identityState)
if auth != nil {
authID = auth.ID
authLabel = auth.Label
authType, authValue = auth.AccountInfo()
}
helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{URL: url, Method: http.MethodPost, Headers: retryReq.Header.Clone(), Body: retryUpstreamBody, Provider: e.Identifier(), AuthID: authID, AuthLabel: authLabel, AuthType: authType, AuthValue: authValue})
if errClose := httpResp.Body.Close(); errClose != nil {
log.Errorf("codex executor: close response body error: %v", errClose)
}
httpResp, err = httpClient.Do(retryReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
return resp, err
}
Comment on lines +1092 to +1096

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

If httpClient.Do(retryReq) returns an error, httpResp will be assigned nil. Since the deferred function registered at line 1056 calls httpResp.Body.Close() without a nil check, this will result in a nil pointer dereference panic.

To prevent this, use a temporary variable to capture the result of the retry request, and only reassign httpResp if the request succeeds.

Suggested change
httpResp, err = httpClient.Do(retryReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
return resp, err
}
retryResp, retryErr := httpClient.Do(retryReq)
if retryErr != nil {
helps.RecordAPIResponseError(ctx, e.cfg, retryErr)
return resp, retryErr
}
httpResp = retryResp

helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
if httpResp.StatusCode >= 200 && httpResp.StatusCode < 300 {
goto codexReadCompactResponse
}
b, _ = io.ReadAll(httpResp.Body)
b = applyCodexIdentityConfuseResponsePayload(b, identityState)
helps.AppendAPIResponseChunk(ctx, e.cfg, b)
helps.LogWithRequestID(ctx).Debugf("request error after refresh retry, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
} else if refreshErr != nil {
helps.LogWithRequestID(ctx).Debugf("codex token_invalidated refresh retry failed: %v", refreshErr)
}
}
err = newCodexStatusErr(httpResp.StatusCode, b)
return resp, err
}

codexReadCompactResponse:
data, err := io.ReadAll(httpResp.Body)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
Expand Down Expand Up @@ -1126,9 +1228,56 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
}
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), data))
if isCodexTokenInvalidatedResponse(httpResp.StatusCode, data) && auth != nil {
if refreshedAuth, refreshErr := refreshCodexAuthForTokenInvalidatedRetry(ctx, e, auth); refreshErr == nil && refreshedAuth != nil {
auth = refreshedAuth
apiKey, baseURL = codexCreds(auth)
if baseURL == "" {
baseURL = "https://chatgpt.com/backend-api/codex"
}
url = strings.TrimSuffix(baseURL, "/") + "/responses"
retryReq, retryUpstreamBody, retryIdentityState, retryReqErr := e.cacheHelper(ctx, from, url, auth, req, originalPayloadSource, body)
if retryReqErr != nil {
return nil, retryReqErr
}
identityState = retryIdentityState
applyCodexHeaders(retryReq, auth, apiKey, true, e.cfg)
applyCodexIdentityConfuseHeaders(retryReq.Header, &identityState)
if auth != nil {
authID = auth.ID
authLabel = auth.Label
authType, authValue = auth.AccountInfo()
}
helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{URL: url, Method: http.MethodPost, Headers: retryReq.Header.Clone(), Body: retryUpstreamBody, Provider: e.Identifier(), AuthID: authID, AuthLabel: authLabel, AuthType: authType, AuthValue: authValue})
httpResp, err = httpClient.Do(retryReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
return nil, err
}
Comment on lines +1252 to +1256

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

For consistency and defensive programming, use a temporary variable to capture the result of the retry request here as well. This avoids reassigning the outer httpResp and err variables before we know if the call succeeded.

Suggested change
httpResp, err = httpClient.Do(retryReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
return nil, err
}
retryResp, retryErr := httpClient.Do(retryReq)
if retryErr != nil {
helps.RecordAPIResponseError(ctx, e.cfg, retryErr)
return nil, retryErr
}
httpResp = retryResp

helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
if httpResp.StatusCode >= 200 && httpResp.StatusCode < 300 {
goto codexBuildStreamResult
}
data, readErr = io.ReadAll(httpResp.Body)
if errClose := httpResp.Body.Close(); errClose != nil {
log.Errorf("codex executor: close response body error: %v", errClose)
}
if readErr != nil {
helps.RecordAPIResponseError(ctx, e.cfg, readErr)
return nil, readErr
}
data = applyCodexIdentityConfuseResponsePayload(data, identityState)
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
helps.LogWithRequestID(ctx).Debugf("request error after refresh retry, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), data))
} else if refreshErr != nil {
helps.LogWithRequestID(ctx).Debugf("codex token_invalidated refresh retry failed: %v", refreshErr)
}
}
err = newCodexStatusErr(httpResp.StatusCode, data)
return nil, err
}

codexBuildStreamResult:
out := make(chan cliproxyexecutor.StreamChunk)
go func() {
defer close(out)
Expand Down Expand Up @@ -1645,6 +1794,14 @@ func applyCodexHeadersFromSources(r *http.Request, auth *cliproxyauth.Auth, toke
util.ApplyCustomHeadersFromAttrs(r, attrs)
}

func isCodexTokenInvalidatedResponse(statusCode int, body []byte) bool {
if statusCode != http.StatusUnauthorized {
return false
}
lower := strings.ToLower(string(body))
return strings.Contains(lower, "token_invalidated") || strings.Contains(lower, "authentication token has been invalidated")
}

func newCodexStatusErr(statusCode int, body []byte) statusErr {
errCode := statusCode
if isCodexModelCapacityError(body) || isCodexUsageLimitError(body) {
Expand Down
123 changes: 123 additions & 0 deletions internal/runtime/executor/codex_executor_auth_retry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package executor

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

"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
)

func TestCodexExecutorExecuteRefreshesAndRetriesTokenInvalidated(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if calls == 1 {
if got := r.Header.Get("Authorization"); got != "Bearer stale-token" {
t.Fatalf("first Authorization = %q, want stale token", got)
}
w.WriteHeader(http.StatusUnauthorized)
_, _ = fmt.Fprint(w, `{"error":{"message":"Your authentication token has been invalidated. Please try signing in again.","type":"invalid_request_error","code":"token_invalidated"},"status":401}`)
return
}
if got := r.Header.Get("Authorization"); got != "Bearer fresh-token" {
t.Fatalf("retry Authorization = %q, want fresh token", got)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprint(w, "data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"id\":\"msg\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"ok\"}]}}\n\n")
_, _ = fmt.Fprint(w, "data: {\"type\":\"response.completed\",\"response\":{\"output\":[]}}\n\n")
}))
defer server.Close()

oldRefresh := refreshCodexAuthForTokenInvalidatedRetry
refreshCodexAuthForTokenInvalidatedRetry = func(ctx context.Context, e *CodexExecutor, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
auth.Metadata["access_token"] = "fresh-token"
return auth, nil
}
defer func() { refreshCodexAuthForTokenInvalidatedRetry = oldRefresh }()

executor := NewCodexExecutor(&config.Config{SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll}})
auth := &cliproxyauth.Auth{
Provider: "codex",
Metadata: map[string]any{"access_token": "stale-token", "refresh_token": "refresh-token"},
Attributes: map[string]string{"base_url": server.URL},
}

_, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
Model: "gpt-5.5",
Payload: []byte(`{"model":"gpt-5.5","messages":[{"role":"user","content":"hi"}]}`),
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("openai")})
if err != nil {
t.Fatalf("Execute returned error after refresh retry: %v", err)
}
if calls != 2 {
t.Fatalf("upstream calls = %d, want 2", calls)
}
}

func TestCodexExecutorExecuteStreamRefreshesAndRetriesTokenInvalidated(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if calls == 1 {
if got := r.Header.Get("Authorization"); got != "Bearer stale-token" {
t.Fatalf("first Authorization = %q, want stale token", got)
}
w.WriteHeader(http.StatusUnauthorized)
_, _ = fmt.Fprint(w, `{"error":{"code":"token_invalidated","message":"Your authentication token has been invalidated. Please try signing in again."},"status":401}`)
return
}
if got := r.Header.Get("Authorization"); got != "Bearer fresh-token" {
t.Fatalf("retry Authorization = %q, want fresh token", got)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprint(w, "data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\n")
}))
defer server.Close()

oldRefresh := refreshCodexAuthForTokenInvalidatedRetry
refreshCodexAuthForTokenInvalidatedRetry = func(ctx context.Context, e *CodexExecutor, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
auth.Metadata["access_token"] = "fresh-token"
return auth, nil
}
defer func() { refreshCodexAuthForTokenInvalidatedRetry = oldRefresh }()

executor := NewCodexExecutor(&config.Config{SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll}})
auth := &cliproxyauth.Auth{
Provider: "codex",
Metadata: map[string]any{"access_token": "stale-token", "refresh_token": "refresh-token"},
Attributes: map[string]string{"base_url": server.URL},
}

result, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
Model: "gpt-5.5",
Payload: []byte(`{"model":"gpt-5.5","messages":[{"role":"user","content":"hi"}]}`),
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("openai")})
if err != nil {
t.Fatalf("ExecuteStream returned error after refresh retry: %v", err)
}
for range result.Chunks {
}
if calls != 2 {
t.Fatalf("upstream calls = %d, want 2", calls)
}
}

func TestIsCodexTokenInvalidatedResponse(t *testing.T) {
body := []byte(`{"error":{"code":"token_invalidated","message":"Your authentication token has been invalidated. Please try signing in again."},"status":401}`)
if !isCodexTokenInvalidatedResponse(http.StatusUnauthorized, body) {
t.Fatal("expected token_invalidated response to be detected")
}
if isCodexTokenInvalidatedResponse(http.StatusTooManyRequests, body) {
t.Fatal("429 must not be treated as token_invalidated")
}
if isCodexTokenInvalidatedResponse(http.StatusUnauthorized, []byte(strings.Repeat("x", 10))) {
t.Fatal("generic 401 must not be treated as token_invalidated")
}
}
Loading