diff --git a/README.md b/README.md
index 661cd8e7eb..3e43fd3ae4 100644
--- a/README.md
+++ b/README.md
@@ -637,19 +637,19 @@ 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
-- Public Responses targets: `/v1/responses`, `/responses`, and `/backend-api/codex/responses`, forwarded to `${XAI_BASE_URL:-https://api.x.ai/v1}/responses`
+- Account types: OAuth subscription accounts and xAI API-key accounts
+- 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
-- Initial text models: `grok-4.3`, `grok-build-0.1`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning`, and `grok-4.20-multi-agent-0309`
+- 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.
- Media models: `grok-imagine`, `grok-imagine-image-quality`, `grok-imagine-image`, `grok-imagine-edit`, `grok-imagine-video`, and `grok-imagine-video-1.5`
- Out of scope for this provider: TTS, transcription, browser automation, cookies, and Grok web scraping
@@ -665,9 +665,10 @@ 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 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 |
|----------|---------|
@@ -676,13 +677,47 @@ 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`, `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.
+
+### Grok Build CLI Configuration
+
+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`):
+
+```toml
+[models]
+default = "sub2api-grok"
+web_search = "sub2api-grok"
+
+[model."sub2api-grok"]
+model = "grok-4.5"
+base_url = "https://your-sub2api.example.com/v1"
+name = "Grok 4.5 via Sub2API"
+description = "Grok 4.5 through a Sub2API Grok group"
+api_key = "sk-your-sub2api-key"
+api_backend = "responses"
+context_window = 1000000
+supports_backend_search = true
+```
+
+Back up an existing `config.toml` before merging the entry. The file contains a Sub2API API key, so keep it private and restrict its permissions where supported. Verify the effective configuration and make a smoke request:
+
+```bash
+grok inspect
+grok -p "Reply with sub2api-ok" -m sub2api-grok
+```
+
+The `base_url` above is the public Sub2API URL ending in `/v1`, not `api.x.ai` or the internal xAI OAuth proxy URL.
### Usage And Quota Display
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/go.mod b/backend/go.mod
index a06f06437d..64b5c30d96 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -44,6 +44,7 @@ require (
go.uber.org/zap v1.24.0
golang.org/x/crypto v0.51.0
golang.org/x/image v0.39.0
+ golang.org/x/mod v0.35.0
golang.org/x/net v0.55.0
golang.org/x/sync v0.20.0
golang.org/x/term v0.43.0
@@ -176,7 +177,6 @@ require (
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
- golang.org/x/mod v0.35.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.44.0 // indirect
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/handler/no_account_error.go b/backend/internal/handler/no_account_error.go
index a3bf3b049e..001cef611d 100644
--- a/backend/internal/handler/no_account_error.go
+++ b/backend/internal/handler/no_account_error.go
@@ -107,3 +107,31 @@ func classifyNoAccountErrorFromGin(
}
return classifyNoAccountError(ctx, diag, apiKey, routingModel, displayModel, platform)
}
+
+func classifyOpenAICompatibleNoAccountErrorFromGin(
+ c *gin.Context,
+ diag service.ModelAvailabilityDiagnoser,
+ apiKey *service.APIKey,
+ routingModel string,
+ displayModel string,
+) noAccountErrorClassification {
+ return classifyNoAccountErrorFromGin(
+ c,
+ diag,
+ apiKey,
+ routingModel,
+ displayModel,
+ openAICompatibleRequestPlatform(apiKey),
+ )
+}
+
+func openAICompatibleSelectionErrorForLog(err error, platform string) error {
+ if err == nil || platform != service.PlatformGrok {
+ return err
+ }
+ message := strings.ReplaceAll(err.Error(), "OpenAI accounts", "Grok accounts")
+ if message == err.Error() {
+ return err
+ }
+ return fmt.Errorf("%s", message)
+}
diff --git a/backend/internal/handler/no_account_error_test.go b/backend/internal/handler/no_account_error_test.go
index cfe41bb34f..174da82cc7 100644
--- a/backend/internal/handler/no_account_error_test.go
+++ b/backend/internal/handler/no_account_error_test.go
@@ -4,6 +4,7 @@ package handler
import (
"context"
+ "fmt"
"net/http"
"net/http/httptest"
"testing"
@@ -114,6 +115,33 @@ func TestClassifyNoAccountError_ModelNotSupported_Returns404(t *testing.T) {
require.Equal(t, int64(42), *fd.calls[0].GroupID)
}
+func TestClassifyOpenAICompatibleNoAccountError_GrokUsesGrokPlatform(t *testing.T) {
+ c := newTestGinContextWithRequest()
+ fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
+ groupID := int64(43)
+ apiKey := &service.APIKey{
+ GroupID: &groupID,
+ Group: &service.Group{
+ ID: groupID,
+ Platform: service.PlatformGrok,
+ },
+ }
+
+ cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, fd, apiKey, "grok-4.5", "grok-4.5")
+
+ require.Equal(t, http.StatusNotFound, cls.Status)
+ require.Equal(t, "model_not_found", cls.ErrType)
+ require.True(t, cls.ModelNotFound)
+ require.Len(t, fd.calls, 1)
+ require.Equal(t, service.PlatformGrok, fd.calls[0].Platform)
+
+ logErr := openAICompatibleSelectionErrorForLog(
+ fmt.Errorf("no available OpenAI accounts supporting model: grok-4.5"),
+ service.PlatformGrok,
+ )
+ require.EqualError(t, logErr, "no available Grok accounts supporting model: grok-4.5")
+}
+
func TestClassifyNoAccountError_HasModelSupport_KeepsRoutingMessageGenerationToCaller(t *testing.T) {
c := newTestGinContextWithRequest()
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: true}}
diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go
index 9da5296c0d..636e143740 100644
--- a/backend/internal/handler/openai_chat_completions.go
+++ b/backend/internal/handler/openai_chat_completions.go
@@ -151,11 +151,11 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
)
if err != nil {
reqLog.Warn("openai_chat_completions.account_select_failed",
- zap.Error(err),
+ zap.Error(openAICompatibleSelectionErrorForLog(err, requestPlatform)),
zap.Int("excluded_account_count", len(failedAccountIDs)),
)
if len(failedAccountIDs) == 0 {
- cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformOpenAI)
+ cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
@@ -171,7 +171,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
}
}
if selection == nil || selection.Account == nil {
- cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformOpenAI)
+ cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimited(c)
}
diff --git a/backend/internal/handler/openai_gateway_count_tokens.go b/backend/internal/handler/openai_gateway_count_tokens.go
index 0461017067..0a010cc176 100644
--- a/backend/internal/handler/openai_gateway_count_tokens.go
+++ b/backend/internal/handler/openai_gateway_count_tokens.go
@@ -115,8 +115,9 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) {
)
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
if err != nil {
- reqLog.Warn("openai_count_tokens.account_select_failed", zap.Error(err))
- cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, currentRoutingModel, reqModel, service.PlatformOpenAI)
+ requestPlatform := openAICompatibleRequestPlatform(apiKey)
+ reqLog.Warn("openai_count_tokens.account_select_failed", zap.Error(openAICompatibleSelectionErrorForLog(err, requestPlatform)))
+ cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, currentRoutingModel, reqModel)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
@@ -124,7 +125,7 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) {
return
}
if selection == nil || selection.Account == nil {
- cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, currentRoutingModel, reqModel, service.PlatformOpenAI)
+ cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, currentRoutingModel, reqModel)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimited(c)
}
diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go
index 473200df9a..afa2a5073a 100644
--- a/backend/internal/handler/openai_gateway_handler.go
+++ b/backend/internal/handler/openai_gateway_handler.go
@@ -351,7 +351,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
)
if err != nil {
reqLog.Warn("openai.account_select_failed",
- zap.Error(err),
+ zap.Error(openAICompatibleSelectionErrorForLog(err, requestPlatform)),
zap.Int("excluded_account_count", len(failedAccountIDs)),
)
if len(failedAccountIDs) == 0 {
@@ -360,7 +360,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "compact_not_supported", "No available OpenAI accounts support /responses/compact", streamStarted)
return
}
- cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformOpenAI)
+ cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
@@ -375,7 +375,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
return
}
if selection == nil || selection.Account == nil {
- cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformOpenAI)
+ cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimited(c)
}
@@ -855,12 +855,12 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
)
if err != nil {
reqLog.Warn("openai_messages.account_select_failed",
- zap.Error(err),
+ zap.Error(openAICompatibleSelectionErrorForLog(err, requestPlatform)),
zap.Int("excluded_account_count", len(failedAccountIDs)),
)
if len(failedAccountIDs) == 0 {
if err != nil {
- cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, currentRoutingModel, reqModel, service.PlatformOpenAI)
+ cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, currentRoutingModel, reqModel)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
@@ -877,7 +877,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
}
}
if selection == nil || selection.Account == nil {
- cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, currentRoutingModel, reqModel, service.PlatformOpenAI)
+ cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, currentRoutingModel, reqModel)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimited(c)
}
@@ -1456,7 +1456,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
)
if err != nil {
reqLog.Warn("openai.websocket_account_select_failed",
- zap.Error(err),
+ zap.Error(openAICompatibleSelectionErrorForLog(err, requestPlatform)),
zap.Int("excluded_account_count", len(failedAccountIDs)),
)
if lastFailoverErr != nil {
diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go
index eac60d6268..bb079b0789 100644
--- a/backend/internal/repository/http_upstream.go
+++ b/backend/internal/repository/http_upstream.go
@@ -13,6 +13,7 @@ import (
"net"
"net/http"
"net/url"
+ "os"
"strings"
"sync"
"sync/atomic"
@@ -27,6 +28,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
+ "golang.org/x/mod/semver"
)
// 默认配置常量
@@ -57,6 +59,13 @@ const (
defaultOpenAIHTTP2FallbackErrorThreshold = 2
defaultOpenAIHTTP2FallbackWindow = 60 * time.Second
defaultOpenAIHTTP2FallbackTTL = 10 * time.Minute
+
+ // The Grok CLI proxy rejects requests that do not identify a supported
+ // client version. Keep a known-good stable version in the binary while
+ // allowing operators to bump it without waiting for a Sub2API release.
+ grokCLIProxyHost = "cli-chat-proxy.grok.com"
+ grokCLIStableVersion = "0.2.93"
+ grokCLIVersionOverride = "XAI_GROK_CLI_VERSION"
)
const (
@@ -161,6 +170,7 @@ func NewHTTPUpstream(cfg *config.Config) service.HTTPUpstream {
// - 调用方必须关闭 resp.Body,否则会导致 inFlight 计数泄漏
// - inFlight > 0 的客户端不会被淘汰,确保活跃请求不被中断
func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
+ applyGrokCLIProxyHeaders(req)
if err := s.validateRequestHost(req); err != nil {
return nil, err
}
@@ -207,6 +217,7 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco
if profile == nil {
return s.Do(req, proxyURL, accountID, accountConcurrency)
}
+ applyGrokCLIProxyHeaders(req)
upstreamProfile := service.HTTPUpstreamProfileDefault
if req != nil {
upstreamProfile = service.HTTPUpstreamProfileFromContext(req.Context())
@@ -250,6 +261,34 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco
return resp, nil
}
+// applyGrokCLIProxyHeaders applies the official Grok Build client identity at
+// the final shared transport boundary. Keying this behavior to the exact CLI
+// proxy host keeps direct api.x.ai traffic unchanged and automatically covers
+// Responses, Chat Completions, media, quota probes, and account tests.
+func applyGrokCLIProxyHeaders(req *http.Request) {
+ if req == nil || req.URL == nil || !strings.EqualFold(strings.TrimSpace(req.URL.Hostname()), grokCLIProxyHost) {
+ return
+ }
+ if req.Header == nil {
+ req.Header = make(http.Header)
+ }
+ version := strings.TrimSpace(os.Getenv(grokCLIVersionOverride))
+ if !isSupportedGrokCLIVersion(version) {
+ version = grokCLIStableVersion
+ }
+ req.Header.Set("X-XAI-Token-Auth", "xai-grok-cli")
+ req.Header.Set("x-grok-client-version", version)
+ req.Header.Set("User-Agent", "xai-grok-workspace/"+version)
+}
+
+func isSupportedGrokCLIVersion(version string) bool {
+ canonical := "v" + version
+ minimum := "v" + grokCLIStableVersion
+ return semver.IsValid(canonical) &&
+ semver.Canonical(canonical) == canonical &&
+ semver.Compare(canonical, minimum) >= 0
+}
+
// acquireClientWithTLS 获取或创建带 TLS 指纹的客户端
func (s *httpUpstreamService) acquireClientWithTLS(proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile, upstreamProfile service.HTTPUpstreamProfile) (*upstreamClientEntry, error) {
return s.getClientEntryWithTLS(proxyURL, accountID, accountConcurrency, profile, upstreamProfile, true, true)
diff --git a/backend/internal/repository/http_upstream_test.go b/backend/internal/repository/http_upstream_test.go
index f331e00589..cb8fe70d5b 100644
--- a/backend/internal/repository/http_upstream_test.go
+++ b/backend/internal/repository/http_upstream_test.go
@@ -15,6 +15,151 @@ import (
"github.com/stretchr/testify/suite"
)
+func TestHTTPUpstreamDoAppliesGrokCLIIdentityBeforeOAuthRoundTrip(t *testing.T) {
+ t.Setenv("XAI_GROK_CLI_VERSION", "")
+
+ for _, endpoint := range []string{"responses", "chat/completions"} {
+ t.Run(endpoint, func(t *testing.T) {
+ upstream := NewHTTPUpstream(nil)
+ svc, ok := upstream.(*httpUpstreamService)
+ require.True(t, ok)
+
+ const accountID int64 = 4084
+ isolation := svc.getIsolationMode()
+ profile := service.HTTPUpstreamProfileDefault
+ proxyKey := directProxyKey
+ protocolMode := svc.resolveProtocolMode(profile, proxyKey, nil)
+ settings := svc.resolvePoolSettings(isolation, 1)
+ settings = svc.applyProfilePoolSettings(settings, profile)
+ cacheKey := buildCacheKey(isolation, proxyKey, accountID, protocolMode)
+
+ var capturedHeaders http.Header
+ svc.clients[cacheKey] = &upstreamClientEntry{
+ client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ capturedHeaders = req.Header.Clone()
+ statusCode := http.StatusOK
+ if req.Header.Get("X-XAI-Token-Auth") != "xai-grok-cli" {
+ statusCode = http.StatusForbidden
+ }
+ return &http.Response{
+ StatusCode: statusCode,
+ Header: make(http.Header),
+ Body: http.NoBody,
+ Request: req,
+ }, nil
+ })},
+ proxyKey: proxyKey,
+ poolKey: buildPoolKey(settings, protocolMode),
+ protocolMode: protocolMode,
+ }
+
+ req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/"+endpoint, nil)
+ require.NoError(t, err)
+ req.Header.Set("User-Agent", "sub2api-grok/1.0")
+
+ resp, err := svc.Do(req, "", accountID, 1)
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+ require.NoError(t, resp.Body.Close())
+
+ require.Equal(t, "0.2.93", capturedHeaders.Get("x-grok-client-version"))
+ require.Equal(t, "xai-grok-cli", capturedHeaders.Get("X-XAI-Token-Auth"))
+ require.Equal(t, "xai-grok-workspace/0.2.93", capturedHeaders.Get("User-Agent"))
+ })
+ }
+}
+
+func TestApplyGrokCLIProxyHeaders(t *testing.T) {
+ t.Run("uses pinned stable version for the CLI proxy", func(t *testing.T) {
+ t.Setenv("XAI_GROK_CLI_VERSION", "")
+ req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil)
+ require.NoError(t, err)
+ req.Header.Set("User-Agent", "sub2api-grok/1.0")
+
+ applyGrokCLIProxyHeaders(req)
+
+ require.Equal(t, "0.2.93", req.Header.Get("x-grok-client-version"))
+ require.Equal(t, "xai-grok-cli", req.Header.Get("X-XAI-Token-Auth"))
+ require.Equal(t, "xai-grok-workspace/0.2.93", req.Header.Get("User-Agent"))
+ })
+
+ t.Run("accepts a valid operator override", func(t *testing.T) {
+ t.Setenv("XAI_GROK_CLI_VERSION", "0.2.95-alpha.1")
+ req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/chat/completions", nil)
+ require.NoError(t, err)
+
+ applyGrokCLIProxyHeaders(req)
+
+ require.Equal(t, "0.2.95-alpha.1", req.Header.Get("x-grok-client-version"))
+ require.Equal(t, "xai-grok-workspace/0.2.95-alpha.1", req.Header.Get("User-Agent"))
+ })
+
+ t.Run("rejects an unsafe override", func(t *testing.T) {
+ t.Setenv("XAI_GROK_CLI_VERSION", "0.2.95\r\nX-Injected: true")
+ req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil)
+ require.NoError(t, err)
+
+ applyGrokCLIProxyHeaders(req)
+
+ require.Equal(t, "0.2.93", req.Header.Get("x-grok-client-version"))
+ require.Empty(t, req.Header.Get("X-Injected"))
+ })
+
+ t.Run("rejects an override below the supported minimum", func(t *testing.T) {
+ t.Setenv("XAI_GROK_CLI_VERSION", "0.2.92")
+ req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil)
+ require.NoError(t, err)
+
+ applyGrokCLIProxyHeaders(req)
+
+ require.Equal(t, "0.2.93", req.Header.Get("x-grok-client-version"))
+ require.Equal(t, "xai-grok-workspace/0.2.93", req.Header.Get("User-Agent"))
+ })
+
+ t.Run("rejects a prerelease override at the minimum version", func(t *testing.T) {
+ t.Setenv("XAI_GROK_CLI_VERSION", "0.2.93-beta.1")
+ req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil)
+ require.NoError(t, err)
+
+ applyGrokCLIProxyHeaders(req)
+
+ require.Equal(t, "0.2.93", req.Header.Get("x-grok-client-version"))
+ require.Equal(t, "xai-grok-workspace/0.2.93", req.Header.Get("User-Agent"))
+ })
+
+ for _, version := range []string{
+ "0.2.093",
+ "0.2.94-alpha..1",
+ "0.3",
+ "1",
+ "0.2.95+build.1",
+ } {
+ t.Run("rejects invalid semver "+version, func(t *testing.T) {
+ t.Setenv("XAI_GROK_CLI_VERSION", version)
+ req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil)
+ require.NoError(t, err)
+
+ applyGrokCLIProxyHeaders(req)
+
+ require.Equal(t, "0.2.93", req.Header.Get("x-grok-client-version"))
+ require.Equal(t, "xai-grok-workspace/0.2.93", req.Header.Get("User-Agent"))
+ })
+ }
+
+ t.Run("leaves direct xAI API requests unchanged", func(t *testing.T) {
+ t.Setenv("XAI_GROK_CLI_VERSION", "0.2.95")
+ req, err := http.NewRequest(http.MethodPost, "https://api.x.ai/v1/responses", nil)
+ require.NoError(t, err)
+ req.Header.Set("User-Agent", "sub2api-grok/1.0")
+
+ applyGrokCLIProxyHeaders(req)
+
+ require.Empty(t, req.Header.Get("x-grok-client-version"))
+ require.Empty(t, req.Header.Get("X-XAI-Token-Auth"))
+ require.Equal(t, "sub2api-grok/1.0", req.Header.Get("User-Agent"))
+ })
+}
+
// HTTPUpstreamSuite HTTP 上游服务测试套件
// 使用 testify/suite 组织测试,支持 SetupTest 初始化
type HTTPUpstreamSuite struct {
diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go
index d099f93979..62cb5a2e66 100644
--- a/backend/internal/service/account.go
+++ b/backend/internal/service/account.go
@@ -6,6 +6,7 @@ import (
"errors"
"hash/fnv"
"log/slog"
+ "net/url"
"reflect"
"sort"
"strconv"
@@ -1255,12 +1256,39 @@ func (a *Account) GetGrokBaseURL() string {
return ""
}
baseURL := a.GetCredential("base_url")
+ if a.IsGrokOAuth() {
+ if strings.TrimSpace(baseURL) == "" || isOfficialGrokAPIBaseURL(baseURL) {
+ return xai.DefaultCLIBaseURL
+ }
+ }
if baseURL != "" {
return baseURL
}
return xai.DefaultBaseURL
}
+func isOfficialGrokAPIBaseURL(raw string) bool {
+ parsed, err := url.Parse(strings.TrimSpace(raw))
+ if err != nil || parsed == nil || parsed.Opaque != "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
+ return false
+ }
+ defaultURL, err := url.Parse(xai.DefaultBaseURL)
+ if err != nil {
+ return false
+ }
+ if !strings.EqualFold(parsed.Scheme, defaultURL.Scheme) || !strings.EqualFold(parsed.Hostname(), defaultURL.Hostname()) {
+ return false
+ }
+ if port := parsed.Port(); port != "" {
+ portNumber, err := strconv.Atoi(port)
+ if err != nil || portNumber != 443 {
+ return false
+ }
+ }
+ path := strings.TrimRight(parsed.Path, "/")
+ return path == "" || path == strings.TrimRight(defaultURL.Path, "/")
+}
+
func (a *Account) GetGrokAccessToken() string {
if !a.IsGrok() {
return ""
diff --git a/backend/internal/service/account_base_url_test.go b/backend/internal/service/account_base_url_test.go
index a132219398..0ffaa21ae4 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,135 @@ 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 legacy API root is migrated at runtime",
+ account: Account{
+ Type: AccountTypeOAuth,
+ Platform: PlatformGrok,
+ Credentials: map[string]any{
+ "base_url": "https://api.x.ai",
+ },
+ },
+ expected: xai.DefaultCLIBaseURL,
+ },
+ {
+ name: "oauth legacy API root with canonical HTTPS port is migrated at runtime",
+ account: Account{
+ Type: AccountTypeOAuth,
+ Platform: PlatformGrok,
+ Credentials: map[string]any{
+ "base_url": "HTTPS://API.X.AI:443/",
+ },
+ },
+ expected: xai.DefaultCLIBaseURL,
+ },
+ {
+ name: "oauth legacy API canonical port with leading zeroes is migrated at runtime",
+ account: Account{
+ Type: AccountTypeOAuth,
+ Platform: PlatformGrok,
+ Credentials: map[string]any{
+ "base_url": "https://api.x.ai:0443/v1",
+ },
+ },
+ expected: xai.DefaultCLIBaseURL,
+ },
+ {
+ name: "oauth legacy API encoded version path is migrated at runtime",
+ account: Account{
+ Type: AccountTypeOAuth,
+ Platform: PlatformGrok,
+ Credentials: map[string]any{
+ "base_url": "https://api.x.ai/%76%31",
+ },
+ },
+ expected: xai.DefaultCLIBaseURL,
+ },
+ {
+ name: "oauth legacy API encoded trailing slash is migrated at runtime",
+ account: Account{
+ Type: AccountTypeOAuth,
+ Platform: PlatformGrok,
+ Credentials: map[string]any{
+ "base_url": "https://api.x.ai/v1%2F",
+ },
+ },
+ expected: xai.DefaultCLIBaseURL,
+ },
+ {
+ name: "oauth non-default API port remains an explicit override",
+ account: Account{
+ Type: AccountTypeOAuth,
+ Platform: PlatformGrok,
+ Credentials: map[string]any{
+ "base_url": "https://api.x.ai:8443/v1",
+ },
+ },
+ expected: "https://api.x.ai:8443/v1",
+ },
+ {
+ 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.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/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/billing_service.go b/backend/internal/service/billing_service.go
index e6bcaa1db8..43d7f22d78 100644
--- a/backend/internal/service/billing_service.go
+++ b/backend/internal/service/billing_service.go
@@ -563,15 +563,19 @@ func (s *BillingService) initFallbackPricing() {
s.fallbackPrices["grok-4.3"] = &ModelPricing{
InputPricePerToken: 1.25e-6,
OutputPricePerToken: 2.5e-6,
- CacheReadPricePerToken: 0,
+ CacheReadPricePerToken: 0.2e-6,
SupportsCacheBreakdown: false,
LongContextInputThreshold: 1000000,
LongContextInputMultiplier: 1,
}
- // xAI Grok Build 0.1 (official docs: $1 input / $2 output per MTok)
+ // xAI Grok Build 0.1 (official docs: $1 input / $0.20 cached input /
+ // $2 output per MTok). Composer is available only through Grok Build and
+ // has no standalone public API rate card, so its aliases use this coding
+ // model rate instead of silently billing at zero.
s.fallbackPrices["grok-build-0.1"] = &ModelPricing{
InputPricePerToken: 1e-6,
OutputPricePerToken: 2e-6,
+ CacheReadPricePerToken: 0.2e-6,
SupportsCacheBreakdown: false,
}
}
@@ -745,9 +749,14 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing {
switch modelLower {
case "grok", "grok-latest", "grok-4.5", "grok-4.5-latest", "grok-build-latest":
return s.fallbackPrices["grok-4.5"]
- case "grok-4.3":
+ case "grok-4.3",
+ "grok-4.20-0309-reasoning",
+ "grok-4.20-0309-non-reasoning",
+ "grok-4.20-multi-agent-0309",
+ "grok-4.20-reasoning",
+ "grok-4.20-non-reasoning":
return s.fallbackPrices["grok-4.3"]
- case "grok-build", "grok-build-0.1":
+ case "grok-build", "grok-build-0.1", "grok-composer", "grok-composer-2.5-fast", "composer-2.5":
return s.fallbackPrices["grok-build-0.1"]
}
diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go
index c1f3f6e557..53014412fd 100644
--- a/backend/internal/service/billing_service_test.go
+++ b/backend/internal/service/billing_service_test.go
@@ -1039,6 +1039,58 @@ func TestGetModelPricing_Grok45OfficialFallback(t *testing.T) {
}
}
+func TestGetModelPricing_GrokCatalogFallbacks(t *testing.T) {
+ svc := newTestBillingService()
+
+ tests := []struct {
+ name string
+ models []string
+ input float64
+ cacheRead float64
+ output float64
+ }{
+ {
+ name: "Grok 4.3 family",
+ models: []string{
+ "grok-4.3",
+ "grok-4.20-0309-reasoning",
+ "grok-4.20-0309-non-reasoning",
+ "grok-4.20-multi-agent-0309",
+ "grok-4.20-reasoning",
+ "grok-4.20-non-reasoning",
+ },
+ input: 1.25e-6,
+ cacheRead: 0.2e-6,
+ output: 2.5e-6,
+ },
+ {
+ name: "Grok coding and Composer family",
+ models: []string{
+ "grok-build",
+ "grok-build-0.1",
+ "grok-composer",
+ "grok-composer-2.5-fast",
+ "composer-2.5",
+ },
+ input: 1e-6,
+ cacheRead: 0.2e-6,
+ output: 2e-6,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ for _, model := range tt.models {
+ pricing, err := svc.GetModelPricing(model)
+ require.NoError(t, err, "model %s", model)
+ require.InDelta(t, tt.input, pricing.InputPricePerToken, 1e-12, "model %s input", model)
+ require.InDelta(t, tt.cacheRead, pricing.CacheReadPricePerToken, 1e-12, "model %s cached input", model)
+ require.InDelta(t, tt.output, pricing.OutputPricePerToken, 1e-12, "model %s output", model)
+ }
+ })
+ }
+}
+
func TestCalculateCost_SupportsCacheBreakdown(t *testing.T) {
svc := &BillingService{
cfg: &config.Config{},
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/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go
index 1d0d69ffd4..379b586136 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)
@@ -154,6 +154,10 @@ func patchGrokResponsesBody(body []byte, upstreamModel string) ([]byte, error) {
if err != nil {
return nil, err
}
+ out, err = sanitizeGrokResponsesModelCapabilities(out, upstreamModel)
+ if err != nil {
+ return nil, err
+ }
for _, unsupportedField := range []string{"prompt_cache_retention", "safety_identifier"} {
if gjson.GetBytes(out, unsupportedField).Exists() {
out, err = sjson.DeleteBytes(out, unsupportedField)
@@ -176,6 +180,10 @@ func patchGrokResponsesBody(body []byte, upstreamModel string) ([]byte, error) {
if err != nil {
return nil, err
}
+ out, err = sanitizeGrokResponsesInput(out)
+ if err != nil {
+ return nil, err
+ }
out, err = sanitizeGrokResponsesTools(out)
if err != nil {
return nil, err
@@ -183,6 +191,38 @@ func patchGrokResponsesBody(body []byte, upstreamModel string) ([]byte, error) {
return out, nil
}
+func sanitizeGrokResponsesModelCapabilities(body []byte, upstreamModel string) ([]byte, error) {
+ if !grokModelRejectsReasoningEffort(upstreamModel) {
+ return body, nil
+ }
+
+ out := body
+ for _, field := range []string{"reasoning", "reasoning_effort", "reasoningEffort"} {
+ if !gjson.GetBytes(out, field).Exists() {
+ continue
+ }
+ var err error
+ out, err = sjson.DeleteBytes(out, field)
+ if err != nil {
+ return nil, fmt.Errorf("remove unsupported Grok Composer %s: %w", field, err)
+ }
+ }
+ return out, nil
+}
+
+func grokModelRejectsReasoningEffort(model string) bool {
+ model = strings.TrimSpace(strings.ToLower(model))
+ if slash := strings.LastIndex(model, "/"); slash >= 0 {
+ model = strings.TrimSpace(model[slash+1:])
+ }
+ switch model {
+ case "grok-composer", "grok-composer-2.5-fast", "composer-2.5":
+ return true
+ default:
+ return false
+ }
+}
+
var grokResponsesUnsupportedRecursiveFields = map[string]struct{}{
"external_web_access": {},
}
@@ -231,6 +271,38 @@ func deleteJSONFields(value any, fields map[string]struct{}) bool {
}
}
+// additional_tools is a Codex/Responses Lite private input carrier. xAI's
+// Responses schema accepts ordinary message/function-call input items but
+// rejects this carrier before inference with a ModelInput deserialization
+// error. Top-level supported tools remain available through the separate
+// sanitizeGrokResponsesTools path.
+func sanitizeGrokResponsesInput(body []byte) ([]byte, error) {
+ if !bytes.Contains(body, []byte(`"additional_tools"`)) {
+ return body, nil
+ }
+ input := gjson.GetBytes(body, "input")
+ if !input.Exists() || !input.IsArray() {
+ return body, nil
+ }
+
+ rawItems := input.Array()
+ filtered := make([]json.RawMessage, 0, len(rawItems))
+ for _, item := range rawItems {
+ if strings.TrimSpace(item.Get("type").String()) == "additional_tools" {
+ continue
+ }
+ filtered = append(filtered, json.RawMessage(item.Raw))
+ }
+ if len(filtered) == len(rawItems) {
+ return body, nil
+ }
+ encoded, err := json.Marshal(filtered)
+ if err != nil {
+ return nil, err
+ }
+ return sjson.SetRawBytes(body, "input", encoded)
+}
+
var grokResponsesSupportedToolTypes = map[string]struct{}{
"code_execution": {},
"code_interpreter": {},
@@ -844,9 +916,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 241aa702f1..71e427c790 100644
--- a/backend/internal/service/openai_gateway_grok_test.go
+++ b/backend/internal/service/openai_gateway_grok_test.go
@@ -42,6 +42,50 @@ func TestPatchGrokResponsesBodySetsMappedModelAndDropsUnsupportedFields(t *testi
require.Equal(t, "high", gjson.GetBytes(patched, "reasoning.effort").String())
}
+func TestPatchGrokResponsesBodySanitizesComposerReasoningParameters(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ upstreamModel string
+ wantReasoning bool
+ }{
+ {name: "composer fast", upstreamModel: "grok-composer-2.5-fast"},
+ {name: "composer shorthand", upstreamModel: "grok-composer"},
+ {name: "composer legacy alias", upstreamModel: "composer-2.5"},
+ {name: "provider-prefixed composer", upstreamModel: "xai/grok-composer-2.5-fast"},
+ {name: "grok 4.5", upstreamModel: "grok-4.5", wantReasoning: true},
+ }
+
+ body := []byte(`{
+ "model": "grok",
+ "input": "hello",
+ "reasoning": {"effort": "medium", "summary": "auto"},
+ "reasoning_effort": "medium",
+ "reasoningEffort": "medium"
+ }`)
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ patched, err := patchGrokResponsesBody(body, tt.upstreamModel)
+ require.NoError(t, err)
+ require.True(t, json.Valid(patched))
+ require.Equal(t, tt.upstreamModel, gjson.GetBytes(patched, "model").String())
+
+ if tt.wantReasoning {
+ require.Equal(t, "medium", gjson.GetBytes(patched, "reasoning.effort").String())
+ require.Equal(t, "medium", gjson.GetBytes(patched, "reasoning_effort").String())
+ require.Equal(t, "medium", gjson.GetBytes(patched, "reasoningEffort").String())
+ return
+ }
+
+ require.False(t, gjson.GetBytes(patched, "reasoning").Exists())
+ require.False(t, gjson.GetBytes(patched, "reasoning_effort").Exists())
+ require.False(t, gjson.GetBytes(patched, "reasoningEffort").Exists())
+ })
+ }
+}
+
func TestExtractGrokResponsesReasoningEffortSupportsOpenAICompatibleField(t *testing.T) {
t.Parallel()
@@ -162,6 +206,45 @@ func TestPatchGrokResponsesBodyDropsToolChoiceWhenNoSupportedToolsRemain(t *test
require.False(t, gjson.GetBytes(patched, "tool_choice").Exists())
}
+func TestPatchGrokResponsesBodyDropsCodexAdditionalToolsInputItems(t *testing.T) {
+ t.Parallel()
+
+ body := []byte(`{
+ "model": "grok",
+ "input": [
+ {
+ "type": "additional_tools",
+ "role": "developer",
+ "tools": [
+ {"type": "namespace", "name": "image_gen"},
+ {"type": "function", "name": "wait"}
+ ]
+ },
+ {
+ "type": "message",
+ "role": "developer",
+ "content": [{"type": "input_text", "text": "system prompt"}]
+ },
+ {
+ "type": "message",
+ "role": "user",
+ "content": [{"type": "input_text", "text": "hello"}]
+ }
+ ]
+ }`)
+
+ patched, err := patchGrokResponsesBody(body, "grok-4.5")
+ require.NoError(t, err)
+ require.True(t, json.Valid(patched))
+ require.Equal(t, "grok-4.5", gjson.GetBytes(patched, "model").String())
+ require.Equal(t, 2, len(gjson.GetBytes(patched, "input").Array()))
+ require.False(t, gjson.GetBytes(patched, `input.#(type=="additional_tools")`).Exists())
+ require.Equal(t, "developer", gjson.GetBytes(patched, "input.0.role").String())
+ require.Equal(t, "system prompt", gjson.GetBytes(patched, "input.0.content.0.text").String())
+ require.Equal(t, "user", gjson.GetBytes(patched, "input.1.role").String())
+ require.Equal(t, "hello", gjson.GetBytes(patched, "input.1.content.0.text").String())
+}
+
func TestBuildGrokResponsesRequestUsesAccountBaseURLAndBearerToken(t *testing.T) {
t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true")
@@ -754,6 +837,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)
@@ -1159,14 +1386,14 @@ func TestHandleGrokAccountUpstreamErrorTempUnschedulesNonRateLimitStates(t *test
{
name: "unauthorized reauth",
status: http.StatusUnauthorized,
- wantReason: "grok oauth token unauthorized",
+ wantReason: "grok credentials unauthorized",
wantMinCooldown: 10*time.Minute - time.Second,
wantMaxCooldown: 10*time.Minute + time.Second,
},
{
name: "forbidden entitlement",
status: http.StatusForbidden,
- wantReason: "grok entitlement or subscription tier denied",
+ wantReason: "grok access or entitlement denied",
wantMinCooldown: 30*time.Minute - time.Second,
wantMaxCooldown: 30*time.Minute + time.Second,
},
diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue
index 81d97efb9c..5d7af74fc6 100644
--- a/frontend/src/components/account/AccountUsageCell.vue
+++ b/frontend/src/components/account/AccountUsageCell.vue
@@ -386,6 +386,7 @@
:label="t('admin.accounts.usageWindow.grokRequests')"
:utilization="grokRequestQuotaBar.utilization"
:resets-at="grokRequestQuotaBar.resetsAt"
+ :remaining-capacity="true"
color="indigo"
/>
- {{ 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 }}
{{ apiKeyHint }}
+{{ apiKeyHint }}
{{ baseUrlHint }}
+{{ baseUrlHint }}
{{ 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/UsageProgressBar.vue b/frontend/src/components/account/UsageProgressBar.vue index 6a69357318..2f8b9a88a7 100644 --- a/frontend/src/components/account/UsageProgressBar.vue +++ b/frontend/src/components/account/UsageProgressBar.vue @@ -69,6 +69,7 @@ const props = defineProps<{ color: 'indigo' | 'emerald' | 'purple' | 'amber' windowStats?: WindowStats | null showNowWhenIdle?: boolean + remainingCapacity?: boolean }>() const { t } = useI18n() @@ -109,6 +110,14 @@ const labelClass = computed(() => { // Progress bar color based on utilization const barClass = computed(() => { + if (props.remainingCapacity) { + if (props.utilization <= 20) { + return 'bg-red-500' + } else if (props.utilization <= 50) { + return 'bg-amber-500' + } + return 'bg-green-500' + } if (props.utilization >= 100) { return 'bg-red-500' } else if (props.utilization >= 80) { @@ -120,6 +129,14 @@ const barClass = computed(() => { // Text color based on utilization const textClass = computed(() => { + if (props.remainingCapacity) { + if (props.utilization <= 20) { + return 'text-red-600 dark:text-red-400' + } else if (props.utilization <= 50) { + return 'text-amber-600 dark:text-amber-400' + } + return 'text-gray-600 dark:text-gray-400' + } if (props.utilization >= 100) { return 'text-red-600 dark:text-red-400' } else if (props.utilization >= 80) { @@ -131,12 +148,16 @@ const textClass = computed(() => { // Bar width (capped at 100%) const barWidth = computed(() => { - return `${Math.min(props.utilization, 100)}%` + return `${Math.min(Math.max(props.utilization, 0), 100)}%` }) // Display percentage (cap at 999% for readability) const displayPercent = computed(() => { - const percent = Math.round(props.utilization) + const percent = Math.round( + props.remainingCapacity + ? Math.min(Math.max(props.utilization, 0), 100) + : props.utilization + ) return percent > 999 ? '>999%' : `${percent}%` }) diff --git a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts index 2df3cc07e2..2abf6513ca 100644 --- a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts +++ b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts @@ -566,7 +566,7 @@ describe('AccountUsageCell', () => { expect(badges.some(node => node.attributes('title') === 'usage.userBilled')).toBe(true) }) - it('Grok OAuth 会展示本地 user billed 用量并保留超限百分比', async () => { + it('Grok OAuth 会展示本地 user billed 用量并把耗尽配额显示为 0% 剩余', async () => { getUsage.mockResolvedValue({ grok_local_usage: { requests: 4, @@ -611,13 +611,55 @@ describe('AccountUsageCell', () => { expect(wrapper.text()).toContain('1.2K') expect(wrapper.text()).toContain('A $0.12') expect(wrapper.text()).toContain('U $0.34') - expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokRequests|120|2026-07-09T16:00:00Z') + expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokRequests|0|2026-07-09T16:00:00Z') const badges = wrapper.findAll('span[title]') expect(badges.some(node => node.attributes('title') === 'usage.accountBilled')).toBe(true) expect(badges.some(node => node.attributes('title') === 'usage.userBilled')).toBe(true) }) + it('Grok OAuth 配额条按剩余容量显示 100% 满格和 25% 低量', async () => { + getUsage.mockResolvedValue({ + grok_request_quota: { + limit: 100, + remaining: 100, + reset_at: '2026-07-09T16:00:00Z' + }, + grok_token_quota: { + limit: 1000, + remaining: 250, + reset_at: '2026-07-09T16:00:00Z' + }, + grok_quota_snapshot_state: 'observed' + }) + + const wrapper = mount(AccountUsageCell, { + props: { + account: makeAccount({ + id: 4073, + platform: 'grok', + type: 'oauth', + extra: {} + }) + }, + global: { + stubs: { + UsageProgressBar: { + props: ['label', 'utilization', 'resetsAt', 'color', 'remainingCapacity'], + template: '' + }, + AccountQuotaInfo: true, + GrokQuotaProbeCell: true + } + } + }) + + await flushPromises() + + expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokRequests|100|true') + expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokTokens|25|true') + }) + it('Key 账号在 today stats loading 时显示骨架屏', async () => { const wrapper = mount(AccountUsageCell, { props: { 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() diff --git a/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts b/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts index 6fa6575f54..af5fc5d66d 100644 --- a/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts +++ b/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts @@ -96,4 +96,54 @@ describe('UsageProgressBar', () => { expect(wrapper.text()).toContain('usage.resetNow') expect(wrapper.text()).not.toContain('usage.resetPending') }) + + it('剩余容量模式在 100% 时显示满格绿色', () => { + const wrapper = mount(UsageProgressBar, { + props: { + label: 'Req', + utilization: 100, + remainingCapacity: true, + color: 'indigo' + } + }) + + expect(wrapper.text()).toContain('100%') + expect(wrapper.get('.h-1\\.5 > div').attributes('style')).toContain('width: 100%') + expect(wrapper.get('.h-1\\.5 > div').classes()).toContain('bg-green-500') + }) + + it('剩余容量模式在低量和耗尽时缩短并变红', async () => { + const wrapper = mount(UsageProgressBar, { + props: { + label: 'Req', + utilization: 15, + remainingCapacity: true, + color: 'indigo' + } + }) + + expect(wrapper.text()).toContain('15%') + expect(wrapper.get('.h-1\\.5 > div').attributes('style')).toContain('width: 15%') + expect(wrapper.get('.h-1\\.5 > div').classes()).toContain('bg-red-500') + + await wrapper.setProps({ utilization: 0 }) + + expect(wrapper.text()).toContain('0%') + expect(wrapper.get('.h-1\\.5 > div').attributes('style')).toContain('width: 0%') + expect(wrapper.get('.h-1\\.5 > div').classes()).toContain('bg-red-500') + }) + + it('默认利用率模式仍把超限显示为满格红色', () => { + const wrapper = mount(UsageProgressBar, { + props: { + label: '5h', + utilization: 120, + color: 'indigo' + } + }) + + expect(wrapper.text()).toContain('120%') + expect(wrapper.get('.h-1\\.5 > div').attributes('style')).toContain('width: 100%') + expect(wrapper.get('.h-1\\.5 > div').classes()).toContain('bg-red-500') + }) }) diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index d3bca5188a..e6bce556da 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -181,6 +181,8 @@ const defaultClientTab = computed(() => { switch (props.platform) { case 'openai': return 'codex' + case 'grok': + return 'grok' case 'gemini': return 'gemini' case 'antigravity': @@ -288,6 +290,11 @@ const clientTabs = computed((): TabConfig[] => { { id: 'gemini', label: t('keys.useKeyModal.cliTabs.geminiCli'), icon: SparkleIcon }, { id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon } ] + case 'grok': + return [ + { id: 'grok', label: t('keys.useKeyModal.cliTabs.grokCli'), icon: TerminalIcon }, + { id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon } + ] default: return [ { id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon }, @@ -313,7 +320,7 @@ const showShellTabs = computed(() => activeClientTab.value !== 'opencode') const currentTabs = computed(() => { if (!showShellTabs.value) return [] - if (activeClientTab.value === 'codex' || activeClientTab.value === 'codex-ws') { + if (activeClientTab.value === 'codex' || activeClientTab.value === 'codex-ws' || activeClientTab.value === 'grok') { return openaiTabs } return shellTabs @@ -330,6 +337,8 @@ const platformDescription = computed(() => { return t('keys.useKeyModal.gemini.description') case 'antigravity': return t('keys.useKeyModal.antigravity.description') + case 'grok': + return t('keys.useKeyModal.grok.description') default: return t('keys.useKeyModal.description') } @@ -350,6 +359,10 @@ const platformNote = computed(() => { return activeClientTab.value === 'claude' ? t('keys.useKeyModal.antigravity.claudeNote') : t('keys.useKeyModal.antigravity.geminiNote') + case 'grok': + return activeTab.value === 'windows' + ? t('keys.useKeyModal.grok.noteWindows') + : t('keys.useKeyModal.grok.note') default: return t('keys.useKeyModal.note') } @@ -407,6 +420,8 @@ const currentFiles = computed((): FileConfig[] => { generateOpenCodeConfig('antigravity-claude', antigravityBase, apiKey, 'opencode.json (Claude)'), generateOpenCodeConfig('antigravity-gemini', antigravityGeminiBase, apiKey, 'opencode.json (Gemini)') ] + case 'grok': + return [generateOpenCodeConfig('grok', apiBase, apiKey)] default: return [generateOpenCodeConfig('openai', apiBase, apiKey)] } @@ -428,6 +443,8 @@ const currentFiles = computed((): FileConfig[] => { return [generateGeminiCliContent(`${baseUrl}/antigravity`, apiKey)] } return generateAnthropicFiles(`${baseUrl}/antigravity`, apiKey) + case 'grok': + return generateGrokFiles(apiBase, apiKey) default: return generateAnthropicFiles(baseUrl, apiKey) } @@ -568,6 +585,30 @@ goals = true` ] } +function generateGrokFiles(baseUrl: string, apiKey: string): FileConfig[] { + const isWindows = activeTab.value === 'windows' + const configDir = isWindows ? '%userprofile%\\.grok' : '~/.grok' + const configContent = `[models] +default = "sub2api-grok" +web_search = "sub2api-grok" + +[model."sub2api-grok"] +model = "grok-4.5" +base_url = "${baseUrl}" +name = "Grok 4.5 via Sub2API" +description = "Grok 4.5 through a Sub2API Grok group" +api_key = "${apiKey}" +api_backend = "responses" +context_window = 1000000 +supports_backend_search = true` + + return [{ + path: `${configDir}/config.toml`, + content: configContent, + hint: t('keys.useKeyModal.grok.configTomlHint') + }] +} + function generateOpenAIWsFiles(baseUrl: string, apiKey: string): FileConfig[] { const isWindows = activeTab.value === 'windows' const configDir = isWindows ? '%userprofile%\\.codex' : '~/.codex' @@ -1072,6 +1113,24 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin } } } + const grokModels = { + 'grok-4.5': { + name: 'Grok 4.5', + limit: { context: 1000000, output: 128000 } + }, + 'grok-4.3': { + name: 'Grok 4.3', + limit: { context: 1000000, output: 128000 } + }, + 'grok-build-0.1': { + name: 'Grok Build 0.1', + limit: { context: 256000, output: 128000 } + }, + 'grok-composer-2.5-fast': { + name: 'Grok Composer 2.5 Fast', + limit: { context: 500000, output: 128000 } + } + } if (platform === 'gemini') { provider[platform].npm = '@ai-sdk/google' @@ -1088,6 +1147,10 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin provider[platform].models = antigravityGeminiModels } else if (platform === 'openai') { provider[platform].models = openaiModels + } else if (platform === 'grok') { + provider[platform].npm = '@ai-sdk/openai' + provider[platform].name = 'Grok via Sub2API' + provider[platform].models = grokModels } const agent = diff --git a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts index f8395dc75f..24581d9f6b 100644 --- a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts +++ b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts @@ -17,6 +17,67 @@ vi.mock('@/composables/useClipboard', () => ({ import UseKeyModal from '../UseKeyModal.vue' describe('UseKeyModal', () => { + it('renders Grok Build and OpenCode setup for Grok groups', async () => { + const wrapper = mount(UseKeyModal, { + props: { + show: true, + apiKey: 'sk-grok-test', + baseUrl: 'https://example.com/v1', + platform: 'grok' + }, + global: { + stubs: { + BaseDialog: { + template: '