-
-
Notifications
You must be signed in to change notification settings - Fork 6.5k
fix: retry Codex refresh on token invalidation #4088
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||||||||||||||||
|
|
@@ -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) | ||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||
| helps.RecordAPIResponseError(ctx, e.cfg, err) | ||||||||||||||||||||||||
| return resp, err | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
Comment on lines
+879
to
+883
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If To prevent this, use a temporary variable to capture the result of the retry request, and only reassign
Suggested change
|
||||||||||||||||||||||||
| helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) | ||||||||||||||||||||||||
| if httpResp.StatusCode >= 200 && httpResp.StatusCode < 300 { | ||||||||||||||||||||||||
| goto codexReadExecuteResponse | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| b, _ = io.ReadAll(httpResp.Body) | ||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the refreshed retry returns a non-2xx 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) | ||||||||||||||||||||||||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If To prevent this, use a temporary variable to capture the result of the retry request, and only reassign
Suggested change
|
||||||||||||||||||||||||
| 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) | ||||||||||||||||||||||||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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
Suggested change
|
||||||||||||||||||||||||
| 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) | ||||||||||||||||||||||||
|
|
@@ -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) { | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| 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") | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the retry request fails at the transport layer after the initial
token_invalidated401 (for example a proxy error or context cancellation), this assignment replaces the successful firsthttpRespwith the nil response returned byDo. The deferred close above then executeshttpResp.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 👍 / 👎.