mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
feat(grok): support xAI API key accounts
Allow Grok API-key accounts in Responses forwarding and connection tests, expose creation and edit defaults in the dashboard, and document the supported setup.
This commit is contained in:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -352,7 +352,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Type Selection (Grok - OAuth only) -->
|
||||
<!-- Account Type Selection (Grok) -->
|
||||
<div v-if="form.platform === 'grok'">
|
||||
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
|
||||
<div class="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-2" data-tour="account-form-type">
|
||||
@@ -381,10 +381,34 @@
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.grokOauth') }}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="grok-account-type-api-key"
|
||||
@click="accountCategory = 'apikey'"
|
||||
:class="[
|
||||
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
|
||||
accountCategory === 'apikey'
|
||||
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
|
||||
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
|
||||
accountCategory === 'apikey'
|
||||
? 'bg-purple-500 text-white'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
|
||||
]"
|
||||
>
|
||||
<Icon name="key" size="sm" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">API Key</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.responsesApi') }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.oauth.grok.oauthOnlyHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Account Type Selection (Gemini) -->
|
||||
@@ -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'
|
||||
"
|
||||
/>
|
||||
<p class="input-hint">{{ baseUrlHint }}</p>
|
||||
<p v-if="baseUrlHint" class="input-hint">{{ baseUrlHint }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.apiKeyRequired') }}</label>
|
||||
@@ -1104,10 +1130,12 @@
|
||||
? 'sk-proj-...'
|
||||
: form.platform === 'gemini'
|
||||
? 'AIza...'
|
||||
: 'sk-ant-...'
|
||||
: form.platform === 'grok'
|
||||
? 'xai-...'
|
||||
: 'sk-ant-...'
|
||||
"
|
||||
/>
|
||||
<p class="input-hint">{{ apiKeyHint }}</p>
|
||||
<p v-if="apiKeyHint" class="input-hint">{{ apiKeyHint }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Gemini API Key tier selection -->
|
||||
@@ -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<string, unknown> = {
|
||||
|
||||
@@ -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'
|
||||
"
|
||||
/>
|
||||
<p class="input-hint">{{ baseUrlHint }}</p>
|
||||
<p v-if="baseUrlHint" class="input-hint">{{ baseUrlHint }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.apiKey') }}</label>
|
||||
@@ -63,7 +65,9 @@
|
||||
? 'AIza...'
|
||||
: account.platform === 'antigravity'
|
||||
? 'sk-...'
|
||||
: 'sk-ant-...'
|
||||
: account.platform === 'grok'
|
||||
? 'xai-...'
|
||||
: 'sk-ant-...'
|
||||
"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.leaveEmptyToKeep') }}</p>
|
||||
@@ -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
|
||||
|
||||
@@ -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-...'")
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user