fix(grok): repair OAuth routing regressions

This commit is contained in:
Heatherm Huang
2026-07-16 16:52:25 +08:00
parent 393a8fe56a
commit 115116e8bf
8 changed files with 386 additions and 24 deletions
+70 -3
View File
@@ -1020,7 +1020,7 @@ func (h *GatewayHandler) Models(c *gin.Context) {
}
if len(availableModels) > 0 {
writeModelsList(c, availableModels)
writeModelsList(c, platform, availableModels)
return
}
@@ -1040,6 +1040,10 @@ func (h *GatewayHandler) Models(c *gin.Context) {
})
return
}
if platform == service.PlatformGrok {
writeGrokModelsList(c, xai.DefaultModelIDs())
return
}
c.JSON(http.StatusOK, gin.H{
"object": "list",
@@ -1047,7 +1051,11 @@ func (h *GatewayHandler) Models(c *gin.Context) {
})
}
func writeModelsList(c *gin.Context, modelIDs []string) {
func writeModelsList(c *gin.Context, platform string, modelIDs []string) {
if platform == service.PlatformGrok {
writeGrokModelsList(c, modelIDs)
return
}
models := make([]claude.Model, 0, len(modelIDs))
for _, modelID := range modelIDs {
models = append(models, claude.Model{
@@ -1068,7 +1076,66 @@ func writeCustomModelsList(c *gin.Context, platform string, modelIDs []string) {
writeOpenAIModelsList(c, modelIDs)
return
}
writeModelsList(c, modelIDs)
writeModelsList(c, platform, modelIDs)
}
type grokReasoningEffortOption struct {
Value string `json:"value"`
Label string `json:"label"`
Default bool `json:"default,omitempty"`
}
type grokModelListItem struct {
xai.Model
SupportsReasoningEffort bool `json:"supportsReasoningEffort,omitempty"`
ReasoningEffort string `json:"reasoningEffort,omitempty"`
ReasoningEfforts []grokReasoningEffortOption `json:"reasoningEfforts,omitempty"`
}
func writeGrokModelsList(c *gin.Context, modelIDs []string) {
defaults := xai.DefaultModels()
defaultsByID := make(map[string]xai.Model, len(defaults))
for _, model := range defaults {
defaultsByID[model.ID] = model
}
models := make([]grokModelListItem, 0, len(modelIDs))
for _, modelID := range modelIDs {
model, ok := defaultsByID[modelID]
if !ok {
model = xai.Model{
ID: modelID,
Object: "model",
OwnedBy: "xai",
DisplayName: modelID,
}
}
item := grokModelListItem{Model: model}
if grokModelSupportsConfigurableReasoning(modelID) {
item.SupportsReasoningEffort = true
item.ReasoningEffort = "high"
item.ReasoningEfforts = []grokReasoningEffortOption{
{Value: "low", Label: "Low"},
{Value: "medium", Label: "Medium"},
{Value: "high", Label: "High", Default: true},
}
}
models = append(models, item)
}
c.JSON(http.StatusOK, gin.H{
"object": "list",
"data": models,
})
}
func grokModelSupportsConfigurableReasoning(modelID string) bool {
switch strings.ToLower(strings.TrimSpace(modelID)) {
case "grok-4.5", "grok-4.5-latest", "grok", "grok-latest", "grok-build", "grok-build-latest", "grok-build-0.1":
return true
default:
return false
}
}
func writeOpenAIModelsList(c *gin.Context, modelIDs []string) {
@@ -25,11 +25,20 @@ type gatewayModelsResponseForTest struct {
}
type gatewayModelItemForTest struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
OwnedBy string `json:"owned_by"`
CreatedAt string `json:"created_at"`
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
OwnedBy string `json:"owned_by"`
CreatedAt string `json:"created_at"`
SupportsReasoningEffort bool `json:"supportsReasoningEffort"`
ReasoningEffort string `json:"reasoningEffort"`
ReasoningEfforts []gatewayReasoningEffortOptionForTest `json:"reasoningEfforts"`
}
type gatewayReasoningEffortOptionForTest struct {
Value string `json:"value"`
Label string `json:"label"`
Default bool `json:"default"`
}
func (s *gatewayModelsAccountRepoStub) ListSchedulableByGroupID(ctx context.Context, groupID int64) ([]service.Account, error) {
@@ -84,6 +93,50 @@ func TestGatewayModels_GeminiGroupFallsBackToGeminiModels(t *testing.T) {
require.NotContains(t, modelIDsForTest(got.Data), "claude-sonnet-4-6")
}
func TestGatewayModels_Grok45AdvertisesReasoningEffortForGrokBuild(t *testing.T) {
gin.SetMode(gin.TestMode)
groupID := int64(4409)
h := newGatewayModelsHandlerForTest(
&gatewayModelsAccountRepoStub{
byGroup: map[int64][]service.Account{
groupID: {
{
ID: 1,
Platform: service.PlatformGrok,
Credentials: map[string]any{
"model_mapping": map[string]any{"grok-4.5": "grok-4.5"},
},
},
},
},
},
)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
Group: &service.Group{ID: groupID, Platform: service.PlatformGrok},
})
h.Models(c)
require.Equal(t, http.StatusOK, rec.Code)
var got gatewayModelsResponseForTest
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
require.Len(t, got.Data, 1)
model := got.Data[0]
require.Equal(t, "grok-4.5", model.ID)
require.True(t, model.SupportsReasoningEffort)
require.Equal(t, "high", model.ReasoningEffort)
require.Equal(t, []gatewayReasoningEffortOptionForTest{
{Value: "low", Label: "Low"},
{Value: "medium", Label: "Medium"},
{Value: "high", Label: "High", Default: true},
}, model.ReasoningEfforts)
}
func TestGatewayModels_GeminiGroupFiltersMappedModelsByPlatform(t *testing.T) {
gin.SetMode(gin.TestMode)
+132 -2
View File
@@ -2,6 +2,7 @@ package repository
import (
"bufio"
"bytes"
"compress/flate"
"compress/gzip"
"context"
@@ -73,8 +74,10 @@ const (
// client version. Keep a known-good stable version in the binary while
// allowing operators to bump it without waiting for a Sub2API release.
grokCLIProxyHost = "cli-chat-proxy.grok.com"
grokOfficialAPIHost = "api.x.ai"
grokCLIStableVersion = "0.2.93"
grokCLIVersionOverride = "XAI_GROK_CLI_VERSION"
grokFallbackBodyLimit = 64 << 10
)
const (
@@ -195,7 +198,9 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i
}
// 执行请求
resp, err := servertiming.Do(httpClientForUpstreamRequest(entry.client, req), req)
client := httpClientForUpstreamRequest(entry.client, req)
client = httpClientWithGrokAccessDeniedFallback(client)
resp, err := servertiming.Do(client, req)
if err != nil {
s.recordOpenAIHTTP2Failure(profile, entry.protocolMode, entry.proxyKey, err)
// 请求失败,立即减少计数
@@ -257,7 +262,9 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco
return nil, err
}
resp, err := servertiming.Do(httpClientForUpstreamRequest(entry.client, req), req)
client := httpClientForUpstreamRequest(entry.client, req)
client = httpClientWithGrokAccessDeniedFallback(client)
resp, err := servertiming.Do(client, req)
if err != nil {
atomic.AddInt64(&entry.inFlight, -1)
atomic.StoreInt64(&entry.lastUsed, time.Now().UnixNano())
@@ -286,6 +293,129 @@ func httpClientForUpstreamRequest(client *http.Client, req *http.Request) *http.
return &clone
}
// grokAccessDeniedFallbackTransport preserves the subscription CLI proxy as
// the primary OAuth route, but retries a replayable request against api.x.ai
// when the proxy returns its compatibility-specific 403 "Access denied".
// Trial subscriptions can hit this boundary while the same OAuth credential
// remains valid on the official API. Other entitlement failures stay on the
// original response so account scheduling semantics do not change.
type grokAccessDeniedFallbackTransport struct {
base http.RoundTripper
}
func httpClientWithGrokAccessDeniedFallback(client *http.Client) *http.Client {
if client == nil {
return nil
}
clone := *client
base := clone.Transport
if base == nil {
base = http.DefaultTransport
}
clone.Transport = &grokAccessDeniedFallbackTransport{base: base}
return &clone
}
func (t *grokAccessDeniedFallbackTransport) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := t.base.RoundTrip(req)
if err != nil || !isGrokCLIAccessDeniedFallbackCandidate(req, resp) {
return resp, err
}
body, ok := bufferSmallResponseBody(resp, grokFallbackBodyLimit)
if !ok || !bytes.Contains(bytes.ToLower(body), []byte("access denied")) {
return resp, nil
}
fallbackReq, err := newGrokOfficialAPIFallbackRequest(req)
if err != nil {
return resp, nil
}
fallbackResp, fallbackErr := t.base.RoundTrip(fallbackReq)
if fallbackErr != nil {
slog.Debug("grok_cli_access_denied_api_fallback_failed", "path", req.URL.EscapedPath(), "error", fallbackErr)
return resp, nil
}
if fallbackResp.StatusCode < http.StatusOK || fallbackResp.StatusCode >= http.StatusMultipleChoices {
if fallbackResp.Body != nil {
_ = fallbackResp.Body.Close()
}
return resp, nil
}
if resp.Body != nil {
_ = resp.Body.Close()
}
slog.Warn("grok_cli_access_denied_api_fallback_succeeded", "method", req.Method, "path", req.URL.EscapedPath())
return fallbackResp, nil
}
func isGrokCLIAccessDeniedFallbackCandidate(req *http.Request, resp *http.Response) bool {
return req != nil && req.URL != nil && req.GetBody != nil && resp != nil &&
resp.StatusCode == http.StatusForbidden &&
strings.EqualFold(strings.TrimSpace(req.URL.Hostname()), grokCLIProxyHost) &&
strings.EqualFold(strings.TrimSpace(req.Header.Get("X-XAI-Token-Auth")), "xai-grok-cli") &&
strings.HasPrefix(strings.ToLower(strings.TrimSpace(req.Header.Get("Authorization"))), "bearer ")
}
func newGrokOfficialAPIFallbackRequest(req *http.Request) (*http.Request, error) {
body, err := req.GetBody()
if err != nil {
return nil, err
}
fallbackReq := req.Clone(req.Context())
fallbackReq.Body = body
fallbackReq.URL = cloneURL(req.URL)
fallbackReq.URL.Scheme = "https"
fallbackReq.URL.Host = grokOfficialAPIHost
fallbackReq.Host = ""
fallbackReq.RequestURI = ""
fallbackReq.Header = req.Header.Clone()
for _, header := range []string{
"X-XAI-Token-Auth",
"X-Grok-Client-Version",
"X-Grok-Client-Surface",
"X-UserID",
"X-Email",
"User-Agent",
} {
fallbackReq.Header.Del(header)
}
return fallbackReq, nil
}
func cloneURL(value *url.URL) *url.URL {
if value == nil {
return nil
}
clone := *value
return &clone
}
func bufferSmallResponseBody(resp *http.Response, limit int64) ([]byte, bool) {
if resp == nil || resp.Body == nil || limit <= 0 {
return nil, false
}
original := resp.Body
body, err := io.ReadAll(io.LimitReader(original, limit+1))
if err != nil || int64(len(body)) > limit {
resp.Body = &prefixedReadCloser{
Reader: io.MultiReader(bytes.NewReader(body), original),
Closer: original,
}
return nil, false
}
_ = original.Close()
resp.Body = io.NopCloser(bytes.NewReader(body))
resp.ContentLength = int64(len(body))
return body, true
}
type prefixedReadCloser struct {
io.Reader
io.Closer
}
// applyGrokCLIProxyHeaders applies the official Grok Build client identity at
// the final shared transport boundary. Keying this behavior to the exact CLI
// proxy host keeps direct api.x.ai traffic unchanged and automatically covers
@@ -1,6 +1,7 @@
package repository
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
@@ -9,6 +10,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
@@ -240,6 +242,100 @@ func TestHTTPUpstreamDoAppliesGrokCLIIdentityBeforeOAuthRoundTrip(t *testing.T)
}
}
func TestHTTPUpstreamDoFallsBackToOfficialGrokAPIOnCLIAccessDenied(t *testing.T) {
upstream := NewHTTPUpstream(nil)
svc, ok := upstream.(*httpUpstreamService)
require.True(t, ok)
const accountID int64 = 4421
isolation := svc.getIsolationMode()
profile := service.HTTPUpstreamProfileDefault
proxyKey := directProxyKey
protocolMode := svc.resolveProtocolMode(profile, proxyKey, nil)
settings := svc.resolvePoolSettings(isolation, 1)
settings = svc.applyProfilePoolSettings(settings, profile)
cacheKey := buildCacheKey(isolation, proxyKey, accountID, protocolMode)
payload := []byte(`{"model":"grok-4.5","input":"hello"}`)
var calls int
var fallbackBody []byte
var fallbackHeaders http.Header
svc.clients[cacheKey] = &upstreamClientEntry{
client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
body, err := io.ReadAll(req.Body)
require.NoError(t, err)
if calls == 1 {
require.Equal(t, grokCLIProxyHost, req.URL.Hostname())
require.Equal(t, "xai-grok-cli", req.Header.Get("X-XAI-Token-Auth"))
return &http.Response{
StatusCode: http.StatusForbidden,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"error":"Access denied"}`)),
Request: req,
}, nil
}
fallbackBody = body
fallbackHeaders = req.Header.Clone()
require.Equal(t, grokOfficialAPIHost, req.URL.Hostname())
require.Equal(t, "/v1/responses", req.URL.Path)
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"id":"response-ok"}`)),
Request: req,
}, nil
})},
proxyKey: proxyKey,
poolKey: buildPoolKey(settings, protocolMode),
protocolMode: protocolMode,
}
req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", bytes.NewReader(payload))
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer oauth-token")
resp, err := svc.Do(req, "", accountID, 1)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
responseBody, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.JSONEq(t, `{"id":"response-ok"}`, string(responseBody))
require.Equal(t, 2, calls)
require.Equal(t, payload, fallbackBody)
require.Equal(t, "Bearer oauth-token", fallbackHeaders.Get("Authorization"))
require.Empty(t, fallbackHeaders.Get("X-XAI-Token-Auth"))
require.Empty(t, fallbackHeaders.Get("x-grok-client-version"))
require.Empty(t, fallbackHeaders.Get("User-Agent"))
}
func TestHTTPUpstreamDoDoesNotFallbackForGrokEntitlementDenial(t *testing.T) {
transport := &grokAccessDeniedFallbackTransport{
base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusForbidden,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"error":"subscription required"}`)),
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.StatusForbidden, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.JSONEq(t, `{"error":"subscription required"}`, string(body))
}
func TestApplyGrokCLIProxyHeaders(t *testing.T) {
t.Run("uses pinned stable version for the CLI proxy", func(t *testing.T) {
t.Setenv("XAI_GROK_CLI_VERSION", "")
+10 -2
View File
@@ -1297,12 +1297,20 @@ func (a *Account) GetGrokBaseURL() string {
}
// GetGrokMediaBaseURL selects the upstream used by Grok Imagine APIs.
// It currently resolves the same way as text traffic; the separate accessor
// preserves the media/text distinction at call sites.
// OAuth text traffic uses the subscription CLI proxy, but large image and
// video payloads must use api.x.ai because the CLI proxy enforces a smaller
// request-body limit. Explicit custom forwarding hosts remain operator-owned.
func (a *Account) GetGrokMediaBaseURL() string {
if !a.IsGrok() {
return ""
}
if a.IsGrokOAuth() {
baseURL := strings.TrimSpace(a.GetCredential("base_url"))
if baseURL == "" || xai.IsOfficialBaseURL(baseURL) {
return xai.DefaultBaseURL
}
return baseURL
}
return a.GetGrokBaseURL()
}
@@ -318,23 +318,23 @@ func TestGetGrokBaseURLHonorsOAuthCustomRegardlessOfUnsafeOverrides(t *testing.T
require.Equal(t, "https://custom.example.com/v1", account.GetGrokBaseURL())
}
func TestGetGrokMediaBaseURLPinsOAuthMediaToCLIProxy(t *testing.T) {
func TestGetGrokMediaBaseURLUsesOfficialAPIForOAuthMedia(t *testing.T) {
tests := []struct {
name string
account Account
expected string
}{
{
name: "oauth without base_url uses CLI subscription proxy",
name: "oauth without base_url uses official media API",
account: Account{
Type: AccountTypeOAuth,
Platform: PlatformGrok,
Credentials: map[string]any{},
},
expected: xai.DefaultCLIBaseURL,
expected: xai.DefaultBaseURL,
},
{
name: "oauth stored CLI proxy stays on CLI subscription proxy",
name: "oauth stored CLI proxy is separated from the media API",
account: Account{
Type: AccountTypeOAuth,
Platform: PlatformGrok,
@@ -342,10 +342,10 @@ func TestGetGrokMediaBaseURLPinsOAuthMediaToCLIProxy(t *testing.T) {
"base_url": xai.DefaultCLIBaseURL,
},
},
expected: xai.DefaultCLIBaseURL,
expected: xai.DefaultBaseURL,
},
{
name: "oauth stored CLI proxy variant is canonicalized to CLI proxy",
name: "oauth stored CLI proxy variant is canonicalized to the media API",
account: Account{
Type: AccountTypeOAuth,
Platform: PlatformGrok,
@@ -353,10 +353,10 @@ func TestGetGrokMediaBaseURLPinsOAuthMediaToCLIProxy(t *testing.T) {
"base_url": "HTTPS://CLI-CHAT-PROXY.GROK.COM:443/%76%31/",
},
},
expected: xai.DefaultCLIBaseURL,
expected: xai.DefaultBaseURL,
},
{
name: "oauth legacy official API is pinned to CLI proxy",
name: "oauth legacy official API stays on the media API",
account: Account{
Type: AccountTypeOAuth,
Platform: PlatformGrok,
@@ -364,7 +364,7 @@ func TestGetGrokMediaBaseURLPinsOAuthMediaToCLIProxy(t *testing.T) {
"base_url": xai.DefaultBaseURL,
},
},
expected: xai.DefaultCLIBaseURL,
expected: xai.DefaultBaseURL,
},
{
name: "oauth custom base_url redirects media traffic",
+7 -1
View File
@@ -9,6 +9,7 @@ import (
"mime"
"mime/multipart"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -319,7 +320,7 @@ func (s *OpenAIGatewayService) ForwardGrokMedia(
}
upstreamReq.Header.Set("Authorization", "Bearer "+token)
upstreamReq.Header.Set("Accept", "application/json")
if account.IsGrokOAuth() {
if account.IsGrokOAuth() && isGrokCLIProxyTarget(targetURL) {
applyGrokCLIHeaders(upstreamReq.Header)
}
if endpoint.RequiresRequestBody() {
@@ -376,6 +377,11 @@ func (s *OpenAIGatewayService) ForwardGrokMedia(
}, nil
}
func isGrokCLIProxyTarget(rawURL string) bool {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
return err == nil && strings.EqualFold(parsed.Hostname(), "cli-chat-proxy.grok.com")
}
func prepareGrokMediaForwardBody(endpoint GrokMediaEndpoint, body []byte, contentType string) ([]byte, string, error) {
if endpoint != GrokMediaEndpointImagesEdits || gjson.ValidBytes(body) {
return body, contentType, nil
@@ -666,7 +666,7 @@ func TestForwardGrokMediaVideoGenerationPreservesImageToVideoModel(t *testing.T)
require.Equal(t, VideoBillingDefaultDurationSeconds, result.VideoDurationSeconds)
}
func TestForwardGrokMediaOAuthImageToVideoKeepsCLIGatewayForLargeBody(t *testing.T) {
func TestForwardGrokMediaOAuthImageToVideoUsesOfficialAPIForLargeBody(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
@@ -702,7 +702,9 @@ func TestForwardGrokMediaOAuthImageToVideoKeepsCLIGatewayForLargeBody(t *testing
_, err := svc.ForwardGrokMedia(context.Background(), c, account, GrokMediaEndpointVideosGenerations, "", body, "application/json")
require.NoError(t, err)
require.Equal(t, xai.DefaultCLIBaseURL+"/videos/generations", upstream.lastReq.URL.String())
require.Equal(t, xai.DefaultBaseURL+"/videos/generations", upstream.lastReq.URL.String())
require.Empty(t, upstream.lastReq.Header.Get("X-XAI-Token-Auth"))
require.Empty(t, upstream.lastReq.Header.Get("x-grok-client-version"))
require.Equal(t, "data:image/png;base64,"+imageData, gjson.GetBytes(upstream.lastBody, "image.image_url").String())
}