mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
fix(grok): route OAuth subscriptions through CLI proxy
This commit is contained in:
@@ -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])
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user