fix(grok): route OAuth subscriptions through CLI proxy

This commit is contained in:
Heatherm Huang
2026-07-13 10:11:33 +08:00
parent d9e466ad3a
commit 3375b4ed2b
10 changed files with 115 additions and 9 deletions
+4 -4
View File
@@ -645,9 +645,9 @@ Sub2API supports both Grok subscription accounts through xAI OAuth and standard
- Platform name: `grok`
- Account types: OAuth subscription accounts and xAI API-key accounts
- Public Responses targets: `/v1/responses`, `/responses`, and `/backend-api/codex/responses`, forwarded to `${XAI_BASE_URL:-https://api.x.ai/v1}/responses`
- Public Responses targets: `/v1/responses`, `/responses`, and `/backend-api/codex/responses`, forwarded to the Grok subscription proxy for OAuth accounts or `https://api.x.ai/v1/responses` for API-key accounts
- Public Claude-compatible target: `/v1/messages`, converted to xAI Responses and returned as Anthropic Messages output for Claude CLI style clients
- Public Chat Completions targets: `/v1/chat/completions` and `/chat/completions`, forwarded to `${XAI_BASE_URL:-https://api.x.ai/v1}/chat/completions`
- Public Chat Completions targets: `/v1/chat/completions` and `/chat/completions`, forwarded to the account-type-specific xAI upstream
- Codex CLI style Responses WebSocket ingress is accepted on the Responses targets and bridged to xAI HTTP/SSE Responses upstream
- Text models: `grok-4.5`, `grok-4.3`, `grok-build-0.1`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning`, and `grok-4.20-multi-agent-0309`
- Media targets for Grok groups: `/v1/images/generations`, `/images/generations`, `/v1/images/edits`, `/images/edits`, `/v1/videos/generations`, `/videos/generations`, `/v1/videos/{request_id}`, and `/videos/{request_id}`. Generation requests require the group image-generation permission.
@@ -665,7 +665,7 @@ The Grok OAuth flow uses PKCE and does not require committing private secrets. T
| `XAI_OAUTH_REDIRECT_URI` | `http://127.0.0.1:56121/callback` |
| `XAI_OAUTH_AUTHORIZE_URL` | `https://auth.x.ai/oauth2/authorize` |
| `XAI_OAUTH_TOKEN_URL` | `https://auth.x.ai/oauth2/token` |
| `XAI_BASE_URL` | `https://api.x.ai/v1` |
| `XAI_BASE_URL` | `https://api.x.ai/v1`; runtime-diagnostics override (account `base_url` controls request forwarding) |
| `XAI_GROK_CLI_VERSION` | `0.2.93`; optional override for the client identity sent to `cli-chat-proxy.grok.com` |
Administrators can create Grok OAuth or API-key accounts from the dashboard. OAuth authorization and reauthorization are also available through the admin API:
@@ -677,7 +677,7 @@ Administrators can create Grok OAuth or API-key accounts from the dashboard. OAu
| `POST /api/v1/admin/grok/oauth/refresh-token` | Validate or refresh a Grok refresh token |
| `POST /api/v1/admin/grok/accounts/:id/refresh` | Refresh an existing Grok account |
OAuth credential storage reuses the existing account JSON fields: `access_token`, `refresh_token`, `token_type`, `expires_at`, optional `email`, optional `subscription_tier`, and `entitlement_status`.
OAuth credential storage reuses the existing account JSON fields: `access_token`, `refresh_token`, `token_type`, `expires_at`, `base_url`, optional `email`, optional `subscription_tier`, and `entitlement_status`. OAuth inference defaults to `https://cli-chat-proxy.grok.com/v1`; existing OAuth accounts that stored the old `https://api.x.ai/v1` default are redirected to the subscription proxy at runtime. Explicit custom upstreams remain unchanged.
For API-key accounts, select **Grok → API Key** in the create-account dialog. The official base URL defaults to `https://api.x.ai/v1`; credentials use the existing `base_url` and `api_key` account fields. OAuth accounts continue to use the subscription flow above.
@@ -98,7 +98,7 @@ func TestGrokOAuthHandlerQueryQuotaProbesUpstream(t *testing.T) {
require.Contains(t, rec.Body.String(), `"source":"active_probe"`)
require.Contains(t, rec.Body.String(), `"headers_observed":true`)
require.NotContains(t, rec.Body.String(), "access-token")
require.Equal(t, xai.DefaultBaseURL+"/responses", upstream.lastReq.URL.String())
require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String())
require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization"))
require.Contains(t, string(upstream.lastBody), `"store":false`)
require.NotNil(t, repo.updates[42])
+6
View File
@@ -1255,6 +1255,12 @@ func (a *Account) GetGrokBaseURL() string {
return ""
}
baseURL := a.GetCredential("base_url")
if a.IsGrokOAuth() {
normalizedBaseURL := strings.TrimRight(strings.TrimSpace(baseURL), "/")
if normalizedBaseURL == "" || strings.EqualFold(normalizedBaseURL, xai.DefaultBaseURL) {
return xai.DefaultCLIBaseURL
}
}
if baseURL != "" {
return baseURL
}
@@ -4,6 +4,9 @@ package service
import (
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/stretchr/testify/require"
)
func TestGetBaseURL(t *testing.T) {
@@ -158,3 +161,69 @@ func TestGetGeminiBaseURL(t *testing.T) {
})
}
}
func TestGetGrokBaseURLUsesSubscriptionProxyForOAuth(t *testing.T) {
tests := []struct {
name string
account Account
expected string
}{
{
name: "oauth without base_url uses CLI subscription proxy",
account: Account{
Type: AccountTypeOAuth,
Platform: PlatformGrok,
Credentials: map[string]any{},
},
expected: xai.DefaultCLIBaseURL,
},
{
name: "oauth legacy API default is migrated at runtime to CLI subscription proxy",
account: Account{
Type: AccountTypeOAuth,
Platform: PlatformGrok,
Credentials: map[string]any{
"base_url": xai.DefaultBaseURL,
},
},
expected: xai.DefaultCLIBaseURL,
},
{
name: "oauth legacy API default with trailing slash is migrated at runtime",
account: Account{
Type: AccountTypeOAuth,
Platform: PlatformGrok,
Credentials: map[string]any{
"base_url": xai.DefaultBaseURL + "/",
},
},
expected: xai.DefaultCLIBaseURL,
},
{
name: "oauth explicit custom base_url remains supported",
account: Account{
Type: AccountTypeOAuth,
Platform: PlatformGrok,
Credentials: map[string]any{
"base_url": "https://custom.example.com/v1",
},
},
expected: "https://custom.example.com/v1",
},
{
name: "API key without base_url uses official credit-backed API",
account: Account{
Type: AccountTypeAPIKey,
Platform: PlatformGrok,
Credentials: map[string]any{},
},
expected: xai.DefaultBaseURL,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.expected, tt.account.GetGrokBaseURL())
})
}
}
@@ -71,7 +71,7 @@ func TestAccountTestService_TestAccountConnection_GrokUsesXAIResponses(t *testin
err := svc.TestAccountConnection(c, account.ID, "grok", "", AccountTestModeDefault)
require.NoError(t, err)
require.Equal(t, "https://api.x.ai/v1/responses", upstream.lastReq.URL.String())
require.Equal(t, "https://cli-chat-proxy.grok.com/v1/responses", upstream.lastReq.URL.String())
require.Equal(t, "Bearer grok-access-token", upstream.lastReq.Header.Get("Authorization"))
require.Equal(t, grokCLIVersion, upstream.lastReq.Header.Get("X-Grok-Client-Version"))
require.Equal(t, "grok-4.3", gjson.GetBytes(upstream.lastBody, "model").String())
@@ -235,7 +235,7 @@ func (s *GrokOAuthService) BuildAccountCredentials(tokenInfo *GrokTokenInfo) map
if tokenInfo.EntitlementStatus != "" {
creds["entitlement_status"] = tokenInfo.EntitlementStatus
}
creds["base_url"] = xai.DefaultBaseURL
creds["base_url"] = xai.DefaultCLIBaseURL
return creds
}
@@ -5,6 +5,7 @@ package service
import (
"context"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/stretchr/testify/require"
@@ -66,3 +67,15 @@ func TestGrokOAuthServiceExchangeCodeRequiresStateForCallbackURLAndConsumesSessi
require.Contains(t, err.Error(), "GROK_OAUTH_SESSION_NOT_FOUND")
require.Zero(t, client.exchangeCalls)
}
func TestGrokOAuthServiceBuildAccountCredentialsDefaultsToSubscriptionProxy(t *testing.T) {
svc := NewGrokOAuthService(nil, &grokOAuthClientStub{})
defer svc.Stop()
credentials := svc.BuildAccountCredentials(&GrokTokenInfo{
AccessToken: "access-token",
ExpiresAt: time.Now().Add(time.Hour).Unix(),
})
require.Equal(t, xai.DefaultCLIBaseURL, credentials["base_url"])
}
@@ -112,7 +112,7 @@ func TestGrokQuotaServiceProbeUsageStoresHeaders(t *testing.T) {
require.NotNil(t, result.Snapshot.Requests)
require.EqualValues(t, 10, *result.Snapshot.Requests.Limit)
require.EqualValues(t, 7, *result.Snapshot.Requests.Remaining)
require.Equal(t, "https://api.x.ai/v1/responses", upstream.lastReq.URL.String())
require.Equal(t, "https://cli-chat-proxy.grok.com/v1/responses", upstream.lastReq.URL.String())
require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization"))
require.Equal(t, grokCLIVersion, upstream.lastReq.Header.Get("X-Grok-Client-Version"))
require.Equal(t, "grok-4.3", gjson.GetBytes(upstream.lastBody, "model").String())
@@ -53,3 +53,20 @@ describe('useGrokOAuth.exchangeAuthCode', () => {
)
})
})
describe('useGrokOAuth.buildCredentials', () => {
it('persists the Grok CLI subscription proxy for OAuth inference', () => {
const oauth = useGrokOAuth()
const credentials = oauth.buildCredentials({
access_token: 'access-token',
token_type: 'Bearer',
expires_at: 1_900_000_000,
client_id: 'client-id',
scope: 'openid grok-cli:access',
email: 'grok@example.com'
})
expect(credentials.base_url).toBe('https://cli-chat-proxy.grok.com/v1')
})
})
+2 -1
View File
@@ -122,7 +122,8 @@ export function useGrokOAuth() {
scope: tokenInfo.scope,
email: tokenInfo.email,
subscription_tier: tokenInfo.subscription_tier,
entitlement_status: tokenInfo.entitlement_status
entitlement_status: tokenInfo.entitlement_status,
base_url: 'https://cli-chat-proxy.grok.com/v1'
}
if (tokenInfo.refresh_token) credentials.refresh_token = tokenInfo.refresh_token
if (tokenInfo.id_token) credentials.id_token = tokenInfo.id_token