diff --git a/README.md b/README.md index b0ab5b9dfe..3e43fd3ae4 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend/internal/handler/admin/grok_oauth_handler_test.go b/backend/internal/handler/admin/grok_oauth_handler_test.go index 6ac77e0e56..0b0f0d1aba 100644 --- a/backend/internal/handler/admin/grok_oauth_handler_test.go +++ b/backend/internal/handler/admin/grok_oauth_handler_test.go @@ -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]) diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index d099f93979..52411d4c6c 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -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 } diff --git a/backend/internal/service/account_base_url_test.go b/backend/internal/service/account_base_url_test.go index a132219398..cf3655dbb8 100644 --- a/backend/internal/service/account_base_url_test.go +++ b/backend/internal/service/account_base_url_test.go @@ -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()) + }) + } +} diff --git a/backend/internal/service/account_test_service_grok_test.go b/backend/internal/service/account_test_service_grok_test.go index d0c812a003..497224b713 100644 --- a/backend/internal/service/account_test_service_grok_test.go +++ b/backend/internal/service/account_test_service_grok_test.go @@ -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()) diff --git a/backend/internal/service/grok_oauth_service.go b/backend/internal/service/grok_oauth_service.go index acad39985f..0a610ef811 100644 --- a/backend/internal/service/grok_oauth_service.go +++ b/backend/internal/service/grok_oauth_service.go @@ -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 } diff --git a/backend/internal/service/grok_oauth_service_test.go b/backend/internal/service/grok_oauth_service_test.go index d0caa5e527..f042c96337 100644 --- a/backend/internal/service/grok_oauth_service_test.go +++ b/backend/internal/service/grok_oauth_service_test.go @@ -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"]) +} diff --git a/backend/internal/service/grok_quota_service_test.go b/backend/internal/service/grok_quota_service_test.go index d306969efa..2248674899 100644 --- a/backend/internal/service/grok_quota_service_test.go +++ b/backend/internal/service/grok_quota_service_test.go @@ -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()) diff --git a/frontend/src/composables/__tests__/useGrokOAuth.spec.ts b/frontend/src/composables/__tests__/useGrokOAuth.spec.ts index 0d7c93d9c6..958a71e67c 100644 --- a/frontend/src/composables/__tests__/useGrokOAuth.spec.ts +++ b/frontend/src/composables/__tests__/useGrokOAuth.spec.ts @@ -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') + }) +}) diff --git a/frontend/src/composables/useGrokOAuth.ts b/frontend/src/composables/useGrokOAuth.ts index ad746bfecf..d02934a818 100644 --- a/frontend/src/composables/useGrokOAuth.ts +++ b/frontend/src/composables/useGrokOAuth.ts @@ -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