diff --git a/README.md b/README.md index 3f6f0e809f..b0ab5b9dfe 100644 --- a/README.md +++ b/README.md @@ -637,14 +637,14 @@ Simple Mode is designed for individual developers or internal teams who want qui --- -## Grok / xAI OAuth Support +## Grok / xAI Support -Sub2API supports Grok subscription accounts through xAI OAuth and forwards OpenAI-compatible Responses traffic to xAI. +Sub2API supports both Grok subscription accounts through xAI OAuth and standard xAI API-key accounts. Both account types forward OpenAI-compatible Responses traffic to xAI. ### Supported Scope - Platform name: `grok` -- Account type: OAuth subscription accounts +- 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 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` @@ -668,7 +668,7 @@ The Grok OAuth flow uses PKCE and does not require committing private secrets. T | `XAI_BASE_URL` | `https://api.x.ai/v1` | | `XAI_GROK_CLI_VERSION` | `0.2.93`; optional override for the client identity sent to `cli-chat-proxy.grok.com` | -Administrators can create or reauthorize Grok accounts from the dashboard, or use the admin API: +Administrators can create Grok OAuth or API-key accounts from the dashboard. OAuth authorization and reauthorization are also available through the admin API: | Endpoint | Purpose | |----------|---------| @@ -677,11 +677,13 @@ Administrators can create or reauthorize Grok accounts from the dashboard, or us | `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 | -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`, optional `email`, optional `subscription_tier`, and `entitlement_status`. + +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. ### Grok Build CLI Configuration -1. In the Sub2API admin dashboard, add a `grok` OAuth account and complete xAI authorization. +1. In the Sub2API admin dashboard, add either a `grok` OAuth account and complete xAI authorization, or add a Grok API-key account. 2. Create a Grok group, attach the account to it, then create a Sub2API API key assigned to that group. 3. In the user API-key page, click **Use Key** and select **Grok CLI**. The modal generates the correct file and base URL for macOS/Linux or Windows. It also provides an OpenCode configuration on the **OpenCode** tab. 4. If configuring manually, save the following as `~/.grok/config.toml` (Windows: `%USERPROFILE%\.grok\config.toml`): @@ -715,7 +717,7 @@ The `base_url` above is the public Sub2API URL ending in `/v1`, not `api.x.ai` o xAI quota is passive. Sub2API does not invent subscription quota values; it records whitelisted xAI rate-limit headers from successful or rate-limited upstream responses when xAI sends them. Before the first usable upstream response, the dashboard shows quota as unknown and still displays local Sub2API usage stats. -`401` responses mark the account as needing reauthorization. `403` responses are treated as entitlement or subscription-tier failures instead of token-refresh loops. `429` responses use `Retry-After` or a short cooldown to temporarily remove the account from scheduling. +`401` responses temporarily remove accounts with invalid credentials from scheduling. `403` responses are treated as access or entitlement failures instead of token-refresh loops. `429` responses use `Retry-After` or a short cooldown to temporarily remove the account from scheduling. --- diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index e947e2ec8e..e62f459e08 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -654,16 +654,10 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account return s.processOpenAIStream(c, resp.Body) } -// testGrokAccountConnection tests a Grok OAuth account through xAI's Responses API. +// testGrokAccountConnection tests a Grok OAuth or API-key account through xAI's Responses API. func (s *AccountTestService) testGrokAccountConnection(c *gin.Context, account *Account, modelID string) error { ctx := c.Request.Context() - if account.Type != AccountTypeOAuth { - return s.sendErrorAndEnd(c, fmt.Sprintf("Unsupported Grok account type: %s", account.Type)) - } - if s.grokTokenProvider == nil { - return s.sendErrorAndEnd(c, "Grok token provider not configured") - } if s.httpUpstream == nil { return s.sendErrorAndEnd(c, "HTTP upstream not configured") } @@ -676,9 +670,24 @@ func (s *AccountTestService) testGrokAccountConnection(c *gin.Context, account * testModelID = mapped } - authToken, err := s.grokTokenProvider.GetAccessToken(ctx, account) - if err != nil { - return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to get Grok access token: %s", err.Error())) + var authToken string + switch account.Type { + case AccountTypeOAuth: + if s.grokTokenProvider == nil { + return s.sendErrorAndEnd(c, "Grok token provider not configured") + } + var err error + authToken, err = s.grokTokenProvider.GetAccessToken(ctx, account) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to get Grok access token: %s", err.Error())) + } + case AccountTypeAPIKey: + authToken = strings.TrimSpace(account.GetCredential("api_key")) + if authToken == "" { + return s.sendErrorAndEnd(c, "Grok API key is missing") + } + default: + return s.sendErrorAndEnd(c, fmt.Sprintf("Unsupported Grok account type: %s", account.Type)) } apiURL, err := xai.BuildResponsesURL(account.GetGrokBaseURL()) diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index b598cf8436..502f99f707 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -35,8 +35,8 @@ func (s *OpenAIGatewayService) forwardGrokResponses( reqStream bool, startTime time.Time, ) (*OpenAIForwardResult, error) { - if account.Type != AccountTypeOAuth { - return nil, fmt.Errorf("grok account type %s is not supported by subscription forwarding", account.Type) + if account.Type != AccountTypeOAuth && account.Type != AccountTypeAPIKey { + return nil, fmt.Errorf("grok account type %s is not supported by Responses forwarding", account.Type) } upstreamModel := account.GetMappedModel(originalModel) @@ -880,9 +880,9 @@ func (s *OpenAIGatewayService) handleGrokAccountUpstreamError(ctx context.Contex s.updateGrokUsageSnapshot(ctx, account, parseGrokQuotaSnapshot(headers, statusCode, now)) switch statusCode { case http.StatusUnauthorized: - s.tempUnscheduleGrok(ctx, account, 10*time.Minute, "grok oauth token unauthorized") + s.tempUnscheduleGrok(ctx, account, 10*time.Minute, "grok credentials unauthorized") case http.StatusForbidden: - s.tempUnscheduleGrok(ctx, account, 30*time.Minute, "grok entitlement or subscription tier denied") + s.tempUnscheduleGrok(ctx, account, 30*time.Minute, "grok access or entitlement denied") case http.StatusTooManyRequests: // updateGrokUsageSnapshot installs both runtime and durable rate-limit state. default: diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index 10099d19d3..6a0942eb72 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -793,6 +793,150 @@ func TestForwardGrokResponsesStreamingUsesXAIResponsesAndSnapshots(t *testing.T) require.NotNil(t, repo.updates[52][grokQuotaSnapshotExtraKey]) } +func TestForwardGrokResponsesAPIKeyUsesXAIResponses(t *testing.T) { + gin.SetMode(gin.TestMode) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + body := []byte(`{"model":"grok","input":"hi","stream":true}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + account := &Account{ + ID: 53, + Name: "grok-api-key", + Platform: PlatformGrok, + Type: AccountTypeAPIKey, + Concurrency: 2, + Credentials: map[string]any{ + "api_key": "xai-test-key", + "base_url": "https://api.x.ai/v1", + }, + } + upstreamBody := strings.Join([]string{ + `data: {"type":"response.output_text.delta","sequence_number":0,"delta":"ok"}`, + "", + `data: {"type":"response.completed","sequence_number":1,"response":{"id":"resp_grok_api_key","model":"grok-4.5","usage":{"input_tokens":2,"output_tokens":1}}}`, + "", + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(upstreamBody)), + }} + svc := &OpenAIGatewayService{httpUpstream: upstream} + + result, err := svc.forwardGrokResponses(context.Background(), c, account, body, "grok", true, time.Now()) + require.NoError(t, err) + require.Equal(t, "https://api.x.ai/v1/responses", upstream.lastReq.URL.String()) + require.Equal(t, "Bearer xai-test-key", upstream.lastReq.Header.Get("Authorization")) + require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "resp_grok_api_key", result.ResponseID) + require.Equal(t, 2, result.Usage.InputTokens) + require.Equal(t, 1, result.Usage.OutputTokens) +} + +func TestAccountTestServiceGrokAPIKeyUsesXAIResponses(t *testing.T) { + gin.SetMode(gin.TestMode) + + account := &Account{ + ID: 54, + Name: "grok-api-key", + Platform: PlatformGrok, + Type: AccountTypeAPIKey, + Concurrency: 2, + Credentials: map[string]any{ + "api_key": "xai-test-key", + "base_url": "https://api.x.ai/v1", + }, + } + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\n" + + "data: {\"type\":\"response.completed\"}\n\n", + )), + }} + svc := &AccountTestService{httpUpstream: upstream} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/54/test", nil) + + err := svc.testGrokAccountConnection(c, account, "grok") + require.NoError(t, err) + require.Equal(t, "https://api.x.ai/v1/responses", upstream.lastReq.URL.String()) + require.Equal(t, "Bearer xai-test-key", upstream.lastReq.Header.Get("Authorization")) + require.Contains(t, recorder.Body.String(), `"type":"test_complete"`) +} + +func TestForwardAsChatCompletionsForGrokStreamingUsesRawXAIChatCompletions(t *testing.T) { + gin.SetMode(gin.TestMode) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + body := []byte(`{"model":"grok","messages":[{"role":"user","content":"hi"}],"stream":true}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + account := &Account{ + ID: 53, + Name: "grok", + Platform: PlatformGrok, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "access-token", + "expires_at": time.Now().Add(time.Hour).UTC().Format(time.RFC3339), + "base_url": xai.DefaultCLIBaseURL, + }, + } + repo := &grokQuotaAccountRepo{ + mockAccountRepoForPlatform: &mockAccountRepoForPlatform{ + accountsByID: map[int64]*Account{53: account}, + }, + } + upstreamBody := strings.Join([]string{ + `data: {"id":"chatcmpl_grok","object":"chat.completion.chunk","model":"grok-4.3","choices":[{"index":0,"delta":{"content":"ok"}}]}`, + "", + `data: {"id":"chatcmpl_grok","object":"chat.completion.chunk","model":"grok-4.3","choices":[],"usage":{"prompt_tokens":6,"completion_tokens":4,"total_tokens":10,"prompt_tokens_details":{"cached_tokens":1}}}`, + "", + "data: [DONE]", + "", + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"text/event-stream"}, + "X-Request-Id": []string{"chat-stream-req"}, + "X-Ratelimit-Limit-Requests": []string{"10"}, + "X-Ratelimit-Remaining-Requests": []string{"7"}, + }, + Body: io.NopCloser(strings.NewReader(upstreamBody)), + }} + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + grokTokenProvider: NewGrokTokenProvider(repo, nil), + accountRepo: repo, + } + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + require.NoError(t, err) + require.Equal(t, xai.DefaultCLIBaseURL+"/chat/completions", upstream.lastReq.URL.String()) + require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization")) + require.Equal(t, "text/event-stream", upstream.lastReq.Header.Get("Accept")) + require.Equal(t, "sub2api-grok/1.0", upstream.lastReq.Header.Get("User-Agent")) + require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.lastBody, "model").String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "stream_options.include_usage").Bool()) + require.True(t, result.Stream) + require.Equal(t, 6, result.Usage.InputTokens) + require.Equal(t, 4, result.Usage.OutputTokens) + require.Equal(t, 1, result.Usage.CacheReadInputTokens) + require.Contains(t, recorder.Body.String(), "data: [DONE]") + require.NotNil(t, repo.updates[53][grokQuotaSnapshotExtraKey]) +} + func TestForwardGrokResponsesNonStreamingUsesCacheIdentityAndCachedUsage(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 67750e4e34..dad7c7a26f 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -352,7 +352,7 @@ - +
@@ -381,10 +381,34 @@ {{ t('admin.accounts.types.grokOauth') }}
+ +
-

- {{ t('admin.accounts.oauth.grok.oauthOnlyHint') }} -

@@ -1087,10 +1111,12 @@ ? 'https://api.openai.com' : form.platform === 'gemini' ? 'https://generativelanguage.googleapis.com' - : 'https://api.anthropic.com' + : form.platform === 'grok' + ? 'https://api.x.ai/v1' + : 'https://api.anthropic.com' " /> -

{{ baseUrlHint }}

+

{{ baseUrlHint }}

@@ -1104,10 +1130,12 @@ ? 'sk-proj-...' : form.platform === 'gemini' ? 'AIza...' - : 'sk-ant-...' + : form.platform === 'grok' + ? 'xai-...' + : 'sk-ant-...' " /> -

{{ apiKeyHint }}

+

{{ apiKeyHint }}

@@ -3483,14 +3511,14 @@ const oauthStepTitle = computed(() => { const baseUrlHint = computed(() => { if (form.platform === 'openai') return t('admin.accounts.openai.baseUrlHint') if (form.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint') - if (form.platform === 'grok') return t('admin.accounts.grok.baseUrlHint') + if (form.platform === 'grok') return '' return t('admin.accounts.baseUrlHint') }) const apiKeyHint = computed(() => { if (form.platform === 'openai') return t('admin.accounts.openai.apiKeyHint') if (form.platform === 'gemini') return t('admin.accounts.gemini.apiKeyHint') - if (form.platform === 'grok') return t('admin.accounts.grok.apiKeyHint') + if (form.platform === 'grok') return '' return t('admin.accounts.apiKeyHint') }) @@ -4887,7 +4915,9 @@ const handleSubmit = async () => { ? 'https://api.openai.com' : form.platform === 'gemini' ? 'https://generativelanguage.googleapis.com' - : 'https://api.anthropic.com' + : form.platform === 'grok' + ? 'https://api.x.ai/v1' + : 'https://api.anthropic.com' // Build credentials with optional model mapping const credentials: Record = { diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 9b5ab82fc5..3d16a901fe 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -41,10 +41,12 @@ ? 'https://generativelanguage.googleapis.com' : account.platform === 'antigravity' ? 'https://cloudcode-pa.googleapis.com' - : 'https://api.anthropic.com' + : account.platform === 'grok' + ? 'https://api.x.ai/v1' + : 'https://api.anthropic.com' " /> -

{{ baseUrlHint }}

+

{{ baseUrlHint }}

@@ -63,7 +65,9 @@ ? 'AIza...' : account.platform === 'antigravity' ? 'sk-...' - : 'sk-ant-...' + : account.platform === 'grok' + ? 'xai-...' + : 'sk-ant-...' " />

{{ t('admin.accounts.leaveEmptyToKeep') }}

@@ -2594,6 +2598,7 @@ const baseUrlHint = computed(() => { if (!props.account) return t('admin.accounts.baseUrlHint') if (props.account.platform === 'openai') return t('admin.accounts.openai.baseUrlHint') if (props.account.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint') + if (props.account.platform === 'grok') return '' return t('admin.accounts.baseUrlHint') }) @@ -3043,6 +3048,7 @@ const tempUnschedPresets = computed(() => [ const defaultBaseUrl = computed(() => { if (props.account?.platform === 'openai') return 'https://api.openai.com' if (props.account?.platform === 'gemini') return 'https://generativelanguage.googleapis.com' + if (props.account?.platform === 'grok') return 'https://api.x.ai/v1' return 'https://api.anthropic.com' }) @@ -3335,7 +3341,9 @@ const syncFormFromAccount = (newAccount: Account | null) => { ? 'https://api.openai.com' : newAccount.platform === 'gemini' ? 'https://generativelanguage.googleapis.com' - : 'https://api.anthropic.com' + : newAccount.platform === 'grok' + ? 'https://api.x.ai/v1' + : 'https://api.anthropic.com' editBaseUrl.value = (credentials.base_url as string) || platformDefaultUrl // Load model mappings and detect mode @@ -3411,7 +3419,9 @@ const syncFormFromAccount = (newAccount: Account | null) => { ? 'https://api.openai.com' : newAccount.platform === 'gemini' ? 'https://generativelanguage.googleapis.com' - : 'https://api.anthropic.com' + : newAccount.platform === 'grok' + ? 'https://api.x.ai/v1' + : 'https://api.anthropic.com' editBaseUrl.value = platformDefaultUrl // Load model mappings for OpenAI/Grok OAuth accounts diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.grok.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.grok.spec.ts new file mode 100644 index 0000000000..0df3663f43 --- /dev/null +++ b/frontend/src/components/account/__tests__/CreateAccountModal.grok.spec.ts @@ -0,0 +1,19 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const source = readFileSync( + resolve(process.cwd(), 'src/components/account/CreateAccountModal.vue'), + 'utf8' +) + +describe('CreateAccountModal Grok account types', () => { + it('offers API-key setup alongside OAuth with the official xAI default', () => { + expect(source).toContain('data-testid="grok-account-type-api-key"') + expect(source).toContain("@click=\"accountCategory = 'apikey'\"") + expect(source).toContain("newPlatform === 'grok'") + expect(source).toContain("? 'https://api.x.ai/v1'") + expect(source).toContain("form.platform === 'grok'") + expect(source).toContain("? 'xai-...'") + }) +}) diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index c148d04a6f..44691200f9 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -267,6 +267,18 @@ function buildGrokOAuthAccount() { } as any } +function buildGrokAPIKeyAccount() { + return { + ...buildAccount(), + id: 6, + name: 'Grok API Key', + platform: 'grok', + credentials: {}, + credentials_status: { has_api_key: true }, + concurrency: 2 + } as any +} + function buildOpenAISetupTokenAccount() { return { ...buildAccount(), @@ -412,6 +424,24 @@ describe('EditAccountModal', () => { }) }) + it('uses the official xAI base URL when a Grok API-key account omits base_url', async () => { + const account = buildGrokAPIKeyAccount() + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + + expect((wrapper.get('input[placeholder="https://api.x.ai/v1"]').element as HTMLInputElement).value) + .toBe('https://api.x.ai/v1') + + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.base_url).toBe('https://api.x.ai/v1') + }) + it('only submits model mapping credentials when saving an OpenAI spark shadow account', async () => { authIsSimpleMode.value = false const account = buildOpenAISparkShadowAccount()