From 2946281a436ce22aa45847f80ee6f78b69727f10 Mon Sep 17 00:00:00 2001 From: Heatherm Huang Date: Sat, 18 Jul 2026 14:32:56 +0800 Subject: [PATCH] fix(grok): retry CLI chat permission denial --- backend/internal/repository/http_upstream.go | 19 ++- .../internal/repository/http_upstream_test.go | 113 ++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index 8e3cf60bb..ec42e189f 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -7,6 +7,7 @@ import ( "compress/gzip" "context" "crypto/tls" + "encoding/json" "errors" "fmt" "io" @@ -323,7 +324,7 @@ func (t *grokAccessDeniedFallbackTransport) RoundTrip(req *http.Request) (*http. } body, ok := bufferSmallResponseBody(resp, grokFallbackBodyLimit) - if !ok || !bytes.Contains(bytes.ToLower(body), []byte("access denied")) { + if !ok || !isGrokCLICompatibilityAccessDenied(body) { return resp, nil } @@ -350,6 +351,22 @@ func (t *grokAccessDeniedFallbackTransport) RoundTrip(req *http.Request) (*http. return fallbackResp, nil } +func isGrokCLICompatibilityAccessDenied(body []byte) bool { + lower := bytes.ToLower(body) + if bytes.Contains(lower, []byte("access denied")) { + return true + } + var payload struct { + Code string `json:"code"` + Error string `json:"error"` + } + if err := json.Unmarshal(body, &payload); err != nil || !strings.EqualFold(strings.TrimSpace(payload.Code), "permission_denied") { + return false + } + const chatEndpointDeniedPrefix = "access to the chat endpoint is denied. please ensure you're using the correct credentials. if you believe this is a mistake, please" + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(payload.Error)), chatEndpointDeniedPrefix) +} + func isGrokCLIAccessDeniedFallbackCandidate(req *http.Request, resp *http.Response) bool { return req != nil && req.URL != nil && req.GetBody != nil && resp != nil && resp.StatusCode == http.StatusForbidden && diff --git a/backend/internal/repository/http_upstream_test.go b/backend/internal/repository/http_upstream_test.go index 77749b7a0..35df74799 100644 --- a/backend/internal/repository/http_upstream_test.go +++ b/backend/internal/repository/http_upstream_test.go @@ -311,6 +311,119 @@ func TestHTTPUpstreamDoFallsBackToOfficialGrokAPIOnCLIAccessDenied(t *testing.T) require.Empty(t, fallbackHeaders.Get("User-Agent")) } +func TestGrokAccessDeniedFallbackRecognizesChatEndpointPermissionDenied(t *testing.T) { + var hosts []string + transport := &grokAccessDeniedFallbackTransport{ + base: roundTripFunc(func(req *http.Request) (*http.Response, error) { + hosts = append(hosts, req.URL.Hostname()) + if req.URL.Hostname() == grokCLIProxyHost { + return &http.Response{ + StatusCode: http.StatusForbidden, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader( + `{"code":"permission_denied","error":"Access to the chat endpoint is denied. Please ensure you're using the correct credentials. If you believe this is a mistake, please contact support."}`, + )), + Request: req, + }, nil + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"id":"response-ok"}`)), + Request: req, + }, nil + }), + } + + req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", strings.NewReader(`{"model":"grok-4.5"}`)) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer oauth-token") + req.Header.Set("X-XAI-Token-Auth", "xai-grok-cli") + + resp, err := transport.RoundTrip(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.NoError(t, resp.Body.Close()) + require.Equal(t, []string{grokCLIProxyHost, grokOfficialAPIHost}, hosts) +} + +func TestIsGrokCLICompatibilityAccessDenied(t *testing.T) { + tests := []struct { + name string + body string + want bool + }{ + {name: "legacy compatibility wording", body: `{"error":"Access denied"}`, want: true}, + { + name: "observed chat endpoint permission denial", + body: `{"code":"permission_denied","error":"Access to the chat endpoint is denied. Please ensure you're using the correct credentials. If you believe this is a mistake, please contact support."}`, + want: true, + }, + { + name: "entitlement denial using the same broad terms", + body: `{"code":"permission_denied","error":"Access to the chat endpoint is denied because a subscription is required"}`, + want: false, + }, + { + name: "different permission denied endpoint", + body: `{"code":"permission_denied","error":"Access to the billing endpoint is denied."}`, + want: false, + }, + { + name: "wrong structured error code", + body: `{"code":"subscription_required","error":"Access to the chat endpoint is denied. Please ensure you're using the correct credentials. If you believe this is a mistake, please contact support."}`, + want: false, + }, + {name: "malformed response", body: `permission_denied: chat endpoint denied`, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, isGrokCLICompatibilityAccessDenied([]byte(tt.body))) + }) + } +} + +func TestIsGrokCLIAccessDeniedFallbackCandidateRequiresAuthenticatedReplayableCLI403(t *testing.T) { + newRequest := func() *http.Request { + req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", strings.NewReader(`{"model":"grok-4.5"}`)) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer oauth-token") + req.Header.Set("X-XAI-Token-Auth", "xai-grok-cli") + return req + } + newResponse := func() *http.Response { return &http.Response{StatusCode: http.StatusForbidden} } + + t.Run("valid candidate", func(t *testing.T) { + require.True(t, isGrokCLIAccessDeniedFallbackCandidate(newRequest(), newResponse())) + }) + t.Run("non CLI host", func(t *testing.T) { + req := newRequest() + req.URL.Host = "api.x.ai" + require.False(t, isGrokCLIAccessDeniedFallbackCandidate(req, newResponse())) + }) + t.Run("missing CLI identity", func(t *testing.T) { + req := newRequest() + req.Header.Del("X-XAI-Token-Auth") + require.False(t, isGrokCLIAccessDeniedFallbackCandidate(req, newResponse())) + }) + t.Run("missing bearer authentication", func(t *testing.T) { + req := newRequest() + req.Header.Del("Authorization") + require.False(t, isGrokCLIAccessDeniedFallbackCandidate(req, newResponse())) + }) + t.Run("non forbidden response", func(t *testing.T) { + resp := newResponse() + resp.StatusCode = http.StatusUnauthorized + require.False(t, isGrokCLIAccessDeniedFallbackCandidate(newRequest(), resp)) + }) + t.Run("non replayable request", func(t *testing.T) { + req := newRequest() + req.GetBody = nil + require.False(t, isGrokCLIAccessDeniedFallbackCandidate(req, newResponse())) + }) +} + func TestHTTPUpstreamDoDoesNotFallbackForGrokEntitlementDenial(t *testing.T) { transport := &grokAccessDeniedFallbackTransport{ base: roundTripFunc(func(req *http.Request) (*http.Response, error) {