diff --git a/backend/internal/handler/admin/grok_oauth_handler.go b/backend/internal/handler/admin/grok_oauth_handler.go
index 258c68853..ac05fd8a6 100644
--- a/backend/internal/handler/admin/grok_oauth_handler.go
+++ b/backend/internal/handler/admin/grok_oauth_handler.go
@@ -373,8 +373,7 @@ func (h *GrokOAuthHandler) createAccountFromSSOToken(ctx context.Context, req Gr
return grokSSOImportWorkerResult{item: GrokSSOToOAuthItemResult{Index: index, Error: grokSSOImportErrorMessage(err)}}
}
- credentials := h.grokOAuthService.BuildAccountCredentials(tokenInfo)
- credentials = service.MergeCredentials(cloneGrokSSOMap(req.Credentials), credentials)
+ credentials := grokSSOImportCredentials(h.grokOAuthService.BuildAccountCredentials(tokenInfo), req.Credentials)
name := grokSSOImportAccountName(req.Name, tokenInfo, index, total)
expiresAt, autoPauseOnExpired := grokSSOImportExpiry(req.ExpiresAt, req.AutoPauseOnExpired, tokenInfo)
account, err := h.adminService.CreateAccount(ctx, &service.CreateAccountInput{
@@ -408,6 +407,18 @@ func (h *GrokOAuthHandler) createAccountFromSSOToken(ctx context.Context, req Gr
}
}
+// grokSSOImportCredentials 合并 SSO 兑换出的凭据与导入请求携带的运营侧配置。
+// token 字段以 BuildAccountCredentials 为准(请求不可覆盖);但 base_url 是运营侧
+// 配置且 Build 恒写官方地址,会吞掉导入时指定的自定义转发地址——与
+// RefreshAccountToken 的保留逻辑对齐,请求显式提供时以请求为准。
+func grokSSOImportCredentials(built map[string]any, reqCredentials map[string]any) map[string]any {
+ credentials := service.MergeCredentials(cloneGrokSSOMap(reqCredentials), built)
+ if reqBaseURL, ok := reqCredentials["base_url"].(string); ok && strings.TrimSpace(reqBaseURL) != "" {
+ credentials["base_url"] = strings.TrimSpace(reqBaseURL)
+ }
+ return credentials
+}
+
func grokSSOImportExpiry(requestExpiresAt *int64, requestAutoPause *bool, tokenInfo *service.GrokTokenInfo) (*int64, *bool) {
if tokenInfo == nil || strings.TrimSpace(tokenInfo.RefreshToken) != "" || tokenInfo.ExpiresAt <= 0 {
return requestExpiresAt, requestAutoPause
diff --git a/backend/internal/handler/admin/grok_oauth_handler_test.go b/backend/internal/handler/admin/grok_oauth_handler_test.go
index a87afa8c1..9d87fc0a9 100644
--- a/backend/internal/handler/admin/grok_oauth_handler_test.go
+++ b/backend/internal/handler/admin/grok_oauth_handler_test.go
@@ -223,6 +223,45 @@ func TestGrokSSOImportExpiryPreservesRequestSettingsWithRefreshToken(t *testing.
require.Same(t, &requestedAutoPause, autoPause)
}
+func TestGrokSSOImportCredentialsPreservesRequestedBaseURL(t *testing.T) {
+ built := map[string]any{
+ "access_token": "at-1",
+ "base_url": xai.DefaultCLIBaseURL,
+ }
+ reqCredentials := map[string]any{
+ "base_url": "https://relay.example.com/v1",
+ "header_override_enabled": true,
+ "header_overrides": map[string]any{"x-relay-key": "k"},
+ }
+
+ credentials := grokSSOImportCredentials(built, reqCredentials)
+
+ // token 字段以兑换结果为准;base_url 是运营侧配置,必须保留请求里的自定义地址
+ require.Equal(t, "at-1", credentials["access_token"])
+ require.Equal(t, "https://relay.example.com/v1", credentials["base_url"])
+ require.Equal(t, true, credentials["header_override_enabled"])
+ require.Equal(t, map[string]any{"x-relay-key": "k"}, credentials["header_overrides"])
+ // 入参不被污染(req.Credentials 会被多个 worker 并发读取)
+ require.Equal(t, "https://relay.example.com/v1", reqCredentials["base_url"])
+}
+
+func TestGrokSSOImportCredentialsDefaultsToOfficialBaseURL(t *testing.T) {
+ built := map[string]any{
+ "access_token": "at-1",
+ "base_url": xai.DefaultCLIBaseURL,
+ }
+
+ credentials := grokSSOImportCredentials(built, nil)
+ require.Equal(t, xai.DefaultCLIBaseURL, credentials["base_url"])
+
+ credentials = grokSSOImportCredentials(map[string]any{
+ "access_token": "at-2",
+ "base_url": xai.DefaultCLIBaseURL,
+ }, map[string]any{"base_url": " "})
+ require.Equal(t, xai.DefaultCLIBaseURL, credentials["base_url"])
+ require.Equal(t, "at-2", credentials["access_token"])
+}
+
func TestGrokSSOImportWorkerRecoversPanic(t *testing.T) {
h := &GrokOAuthHandler{}
result := h.safeCreateAccountFromSSOToken(context.Background(), GrokSSOToOAuthRequest{}, "token", 2, 3)
diff --git a/backend/internal/pkg/xai/oauth.go b/backend/internal/pkg/xai/oauth.go
index 1799415df..07d2227c2 100644
--- a/backend/internal/pkg/xai/oauth.go
+++ b/backend/internal/pkg/xai/oauth.go
@@ -41,7 +41,9 @@ const (
var (
oauthEndpointAllowedHosts = []string{"x.ai", "*.x.ai"}
- baseURLAllowedHosts = []string{"api.x.ai", "cli-chat-proxy.grok.com"}
+ // *.api.x.ai 覆盖 xAI 区域端点(us-east-1/us-west-2/eu-west-1 等),
+ // 运营方可在端点间手动切换以规避单点不可用。
+ baseURLAllowedHosts = []string{"api.x.ai", "*.api.x.ai", "cli-chat-proxy.grok.com"}
)
// OAuthSession stores one PKCE OAuth flow.
@@ -332,10 +334,17 @@ func normalizeKnownBaseURLPath(raw string) (string, error) {
return strings.TrimRight(parsed.String(), "/"), nil
}
-// IsOfficialBaseURLHost 报告 host 是否属于官方 API / CLI 网关主机。
+// IsOfficialBaseURLHost 报告 host 是否属于官方 API / 区域 API / CLI 网关主机。
func IsOfficialBaseURLHost(host string) bool {
host = strings.ToLower(strings.TrimSpace(host))
for _, allowed := range baseURLAllowedHosts {
+ if strings.HasPrefix(allowed, "*.") {
+ suffix := strings.TrimPrefix(allowed, "*.")
+ if host == suffix || strings.HasSuffix(host, "."+suffix) {
+ return true
+ }
+ continue
+ }
if host == allowed {
return true
}
@@ -343,7 +352,18 @@ func IsOfficialBaseURLHost(host string) bool {
return false
}
-// IsOfficialBaseURL 报告 raw 是否指向官方主机(api.x.ai 或 CLI 网关),
+// IsParseableBaseURL 报告 raw 是否能解析出 host。
+// 供读取路径判定存量脏数据:无法解析的值应回落默认端点,而不是把流量发往未定义目标。
+func IsParseableBaseURL(raw string) bool {
+ trimmed := strings.TrimSpace(raw)
+ if trimmed == "" {
+ return false
+ }
+ parsed, err := url.Parse(trimmed)
+ return err == nil && parsed.Host != ""
+}
+
+// IsOfficialBaseURL 报告 raw 是否指向官方主机(api.x.ai / *.api.x.ai 区域端点 / CLI 网关),
// 容忍存量凭证中的历史变体(大小写、显式 443 端口、百分号编码 path 等)。
// 无法解析的值一并视为官方,调用方据此回落默认端点而不是把流量发往未定义目标。
func IsOfficialBaseURL(raw string) bool {
diff --git a/backend/internal/pkg/xai/oauth_test.go b/backend/internal/pkg/xai/oauth_test.go
index 2a2dbf278..53c898310 100644
--- a/backend/internal/pkg/xai/oauth_test.go
+++ b/backend/internal/pkg/xai/oauth_test.go
@@ -211,12 +211,32 @@ func TestIsOfficialBaseURL(t *testing.T) {
"https://relay.example.test/xai/v1",
"http://relay.example.test/v1",
"https://grok.com.evil.example.test/v1",
+ "https://api.x.ai.evil.example.test/v1", // 后缀伪装不属于 *.api.x.ai
}
for _, raw := range custom {
require.False(t, IsOfficialBaseURL(raw), "expected custom: %q", raw)
}
}
+func TestRegionalAPIEndpointsAreOfficialAndTrusted(t *testing.T) {
+ regional := []string{
+ "https://us-east-1.api.x.ai/v1",
+ "https://us-west-2.api.x.ai/v1",
+ "https://eu-west-1.api.x.ai/v1",
+ }
+ for _, raw := range regional {
+ require.True(t, IsOfficialBaseURL(raw), "expected official: %q", raw)
+
+ validated, err := ValidateTrustedBaseURL(raw)
+ require.NoError(t, err, "trusted validation should accept regional endpoint %q", raw)
+ require.Equal(t, raw, validated)
+ }
+
+ // 区域端点作为官方主机同样强制 /v1 path
+ _, err := ValidateTrustedBaseURL("https://us-east-1.api.x.ai/other")
+ require.Error(t, err)
+}
+
func TestValidateBaseURLsRejectEmptyQueryDelimiter(t *testing.T) {
_, err := ValidateBaseURL("https://grok.example.test/v1?")
require.Error(t, err)
diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go
index e9a4bcd93..1dfd5732d 100644
--- a/backend/internal/service/account.go
+++ b/backend/internal/service/account.go
@@ -1281,11 +1281,12 @@ func (a *Account) GetGrokBaseURL() string {
}
baseURL := strings.TrimSpace(a.GetCredential("base_url"))
if a.IsGrokOAuth() {
- // Subscription traffic defaults to the supported CLI gateway. Stored
- // official-host values (written by credential creation/refresh, or
- // legacy variants) mean "not customized"; only an explicit custom-host
- // forwarding address redirects traffic.
- if baseURL == "" || xai.IsOfficialBaseURL(baseURL) {
+ // Operators switch subscription traffic between the official CLI
+ // gateway, the official/regional API hosts and third-party relays
+ // (individual endpoints go down from time to time), so a stored
+ // value is always honored as-is. Only empty or unparseable values
+ // fall back to the default CLI gateway.
+ if baseURL == "" || !xai.IsParseableBaseURL(baseURL) {
return xai.DefaultCLIBaseURL
}
return baseURL
diff --git a/backend/internal/service/account_base_url_test.go b/backend/internal/service/account_base_url_test.go
index c5d1ee437..9883525f3 100644
--- a/backend/internal/service/account_base_url_test.go
+++ b/backend/internal/service/account_base_url_test.go
@@ -178,7 +178,7 @@ func TestGetGrokBaseURLUsesSubscriptionProxyForOAuth(t *testing.T) {
expected: xai.DefaultCLIBaseURL,
},
{
- name: "oauth legacy API default is migrated at runtime to CLI subscription proxy",
+ name: "oauth stored official API endpoint is honored (manual endpoint switch)",
account: Account{
Type: AccountTypeOAuth,
Platform: PlatformGrok,
@@ -186,81 +186,37 @@ func TestGetGrokBaseURLUsesSubscriptionProxyForOAuth(t *testing.T) {
"base_url": xai.DefaultBaseURL,
},
},
- expected: xai.DefaultCLIBaseURL,
+ expected: xai.DefaultBaseURL,
},
{
- name: "oauth legacy API default with trailing slash is migrated at runtime",
+ name: "oauth stored regional API endpoint is honored",
account: Account{
Type: AccountTypeOAuth,
Platform: PlatformGrok,
Credentials: map[string]any{
- "base_url": xai.DefaultBaseURL + "/",
+ "base_url": "https://us-west-2.api.x.ai/v1",
+ },
+ },
+ expected: "https://us-west-2.api.x.ai/v1",
+ },
+ {
+ name: "oauth stored CLI proxy is honored verbatim",
+ account: Account{
+ Type: AccountTypeOAuth,
+ Platform: PlatformGrok,
+ Credentials: map[string]any{
+ "base_url": xai.DefaultCLIBaseURL,
},
},
expected: xai.DefaultCLIBaseURL,
},
{
- name: "oauth legacy API root is migrated at runtime",
+ name: "oauth unparseable base_url falls back to CLI proxy",
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 pinned to CLI proxy",
- account: Account{
- Type: AccountTypeOAuth,
- Platform: PlatformGrok,
- Credentials: map[string]any{
- "base_url": "https://api.x.ai:8443/v1",
+ "base_url": "not a url",
},
},
expected: xai.DefaultCLIBaseURL,
@@ -318,7 +274,7 @@ func TestGetGrokBaseURLHonorsOAuthCustomRegardlessOfUnsafeOverrides(t *testing.T
require.Equal(t, "https://custom.example.com/v1", account.GetGrokBaseURL())
}
-func TestGetGrokMediaBaseURLPinsOAuthMediaToCLIProxy(t *testing.T) {
+func TestGetGrokMediaBaseURLFollowsTextTrafficResolution(t *testing.T) {
tests := []struct {
name string
account Account
@@ -345,18 +301,7 @@ func TestGetGrokMediaBaseURLPinsOAuthMediaToCLIProxy(t *testing.T) {
expected: xai.DefaultCLIBaseURL,
},
{
- name: "oauth stored CLI proxy variant is canonicalized to CLI proxy",
- account: Account{
- Type: AccountTypeOAuth,
- Platform: PlatformGrok,
- Credentials: map[string]any{
- "base_url": "HTTPS://CLI-CHAT-PROXY.GROK.COM:443/%76%31/",
- },
- },
- expected: xai.DefaultCLIBaseURL,
- },
- {
- name: "oauth legacy official API is pinned to CLI proxy",
+ name: "oauth stored official API endpoint is honored (manual endpoint switch)",
account: Account{
Type: AccountTypeOAuth,
Platform: PlatformGrok,
@@ -364,7 +309,7 @@ func TestGetGrokMediaBaseURLPinsOAuthMediaToCLIProxy(t *testing.T) {
"base_url": xai.DefaultBaseURL,
},
},
- expected: xai.DefaultCLIBaseURL,
+ expected: xai.DefaultBaseURL,
},
{
name: "oauth custom base_url redirects media traffic",
diff --git a/backend/internal/service/grok_upstream_url_test.go b/backend/internal/service/grok_upstream_url_test.go
index 5cbab7aa4..61c824e2a 100644
--- a/backend/internal/service/grok_upstream_url_test.go
+++ b/backend/internal/service/grok_upstream_url_test.go
@@ -120,19 +120,36 @@ func TestGrokOAuthURLPolicy(t *testing.T) {
require.Equal(t, xai.DefaultCLIBaseURL+"/responses", target)
})
- t.Run("stored official-host variant stays on CLI gateway", func(t *testing.T) {
+ t.Run("stored official API endpoint is honored (manual endpoint switch)", func(t *testing.T) {
account := &Account{
Platform: PlatformGrok,
Type: AccountTypeOAuth,
Credentials: map[string]any{
- "base_url": "HTTPS://API.X.AI:443/",
+ "base_url": xai.DefaultBaseURL,
},
}
cfg := &config.Config{}
target, err := buildGrokResponsesURL(account, cfg)
require.NoError(t, err)
- require.Equal(t, xai.DefaultCLIBaseURL+"/responses", target)
+ require.Equal(t, xai.DefaultBaseURL+"/responses", target)
+ })
+
+ t.Run("stored regional API endpoint is trusted even under restrictive allowlist", func(t *testing.T) {
+ account := &Account{
+ Platform: PlatformGrok,
+ Type: AccountTypeOAuth,
+ Credentials: map[string]any{
+ "base_url": "https://us-west-2.api.x.ai/v1",
+ },
+ }
+ cfg := &config.Config{}
+ cfg.Security.URLAllowlist.Enabled = true
+ cfg.Security.URLAllowlist.UpstreamHosts = []string{"other.example.test"}
+
+ target, err := buildGrokResponsesURL(account, cfg)
+ require.NoError(t, err)
+ require.Equal(t, "https://us-west-2.api.x.ai/v1/responses", target)
})
t.Run("custom forwarding address follows operator policy", func(t *testing.T) {
diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go
index d80cf725c..1b605ae81 100644
--- a/backend/internal/service/openai_gateway_grok_test.go
+++ b/backend/internal/service/openai_gateway_grok_test.go
@@ -288,20 +288,20 @@ func TestBuildGrokResponsesRequestAllowsPublicAPIKeyBaseURLByDefault(t *testing.
require.NotEqual(t, grokUpstreamUserAgent, req.Header.Get("User-Agent"))
}
-func TestBuildGrokResponsesRequestPinsOAuthOfficialVariantBaseURL(t *testing.T) {
+func TestBuildGrokResponsesRequestHonorsOAuthOfficialEndpointSwitch(t *testing.T) {
t.Parallel()
account := &Account{
Platform: PlatformGrok,
Type: AccountTypeOAuth,
Credentials: map[string]any{
- "base_url": "HTTPS://API.X.AI:443/",
+ "base_url": xai.DefaultBaseURL,
},
}
req, err := buildGrokResponsesRequest(context.Background(), nil, account, []byte(`{"model":"grok-4.3"}`), "access-token", "", nil)
require.NoError(t, err)
- require.Equal(t, xai.DefaultCLIBaseURL+"/responses", req.URL.String())
+ require.Equal(t, xai.DefaultBaseURL+"/responses", req.URL.String())
}
func TestBuildGrokResponsesRequestAppliesHeaderOverridesLast(t *testing.T) {
diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue
index f6d941ea4..5f744c0fc 100644
--- a/frontend/src/components/account/BulkEditAccountModal.vue
+++ b/frontend/src/components/account/BulkEditAccountModal.vue
@@ -110,6 +110,11 @@
:placeholder="t('admin.accounts.bulkEdit.baseUrlPlaceholder')"
aria-labelledby="bulk-edit-base-url-label"
/>
+
{{ t('admin.accounts.bulkEdit.baseUrlNotice') }}
@@ -1221,6 +1226,7 @@ import { HEADER_OVERRIDES_CREDENTIAL_KEY, type HeaderOverrideRow } from '@/components/account/credentialsBuilder' +import GrokBaseUrlPresets from '@/components/account/GrokBaseUrlPresets.vue' import { OPENAI_WS_MODE_CTX_POOL, OPENAI_WS_MODE_OFF, @@ -1260,6 +1266,12 @@ const targetMode = computed(() => props.target?.mode ?? 'selected') const targetPreviewCount = computed(() => props.target?.previewCount ?? props.accountIds.length) const targetSelectedPlatforms = computed(() => props.target?.selectedPlatforms ?? props.selectedPlatforms) const targetSelectedTypes = computed(() => props.target?.selectedTypes ?? props.selectedTypes) +// Grok 快捷端点仅在所选账号全部为 grok 平台时展示(其他平台不显示) +const allTargetsGrok = computed( + () => + targetSelectedPlatforms.value.length > 0 && + targetSelectedPlatforms.value.every((p) => p === 'grok') +) const isMixedPlatform = computed(() => targetSelectedPlatforms.value.length > 1) const allOpenAIPassthroughCapable = computed(() => { diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 5c21f95fd..7eebce530 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -1117,6 +1117,11 @@ " />{{ baseUrlHint }}
+{{ baseUrlHint }}
+