mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
fix(grok): repair OAuth routing regressions
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user