From 7f5d067af21c836b359aef9a70863bd90bf9f5a5 Mon Sep 17 00:00:00 2001 From: shaw Date: Thu, 16 Jul 2026 19:10:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(grok):=20=E6=94=AF=E6=8C=81=E4=B8=8A?= =?UTF-8?q?=E6=B8=B8=E7=AB=AF=E7=82=B9=E6=89=8B=E5=8A=A8=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E4=B8=8E=E5=BF=AB=E6=8D=B7=E7=AB=AF=E7=82=B9=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20SSO=20=E5=BB=BA=E5=8F=B7=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E5=9C=B0=E5=9D=80=E8=A2=AB=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 官方端点(api.x.ai / cli-chat-proxy.grok.com)偶发不可用,运营方需要在 端点间手动切换。旧语义把 OAuth 账号存储的官方 host 一律视同"未定制"并 回落默认 CLI 网关:填了官方地址保存成功却不生效、重新编辑开关回到关闭、 再次保存直接删值,形成"修改不生效"的静默循环。 后端: - GetGrokBaseURL OAuth 分支改为"存了什么用什么":官方 API / 区域 API / 第三方转发地址一律按填写值转发与探测,仅空值或无法解析的脏数据回落 默认 CLI 网关;删除官方变体运行时迁移逻辑 - *.api.x.ai 区域端点(us-east-1/us-west-2/eu-west-1 等)纳入可信 host, OAuth 使用时不受运营方 URL 白名单限制,官方 host 仍强制 /v1 path - 修复 SSO 批量建号 MergeCredentials 方向缺陷:BuildAccountCredentials 恒写官方 base_url,会覆盖导入请求指定的自定义转发地址;抽出 grokSSOImportCredentials 显式保留请求值(与 RefreshAccountToken 对齐) 前端: - isCustomGrokBaseUrl 仅默认 CLI 网关 host 视同未定制:api.x.ai 与区域 端点保存后正常回显(开关开启 + 显示地址),不再被静默吞掉 - 新增 GrokBaseUrlPresets 快捷端点组件(Grok Build CLI / 官方 API / us-east-1 / us-west-2 / eu-west-1),接入编辑(OAuth 自定义区 + apikey Base URL)、新增(同前)与批量编辑(所选平台全为 grok 时显示,点击 自动勾选 base_url);仅快速填充,输入框仍可自由填写任意第三方地址 --- .../handler/admin/grok_oauth_handler.go | 15 +- .../handler/admin/grok_oauth_handler_test.go | 39 +++ backend/internal/pkg/xai/oauth.go | 26 +- backend/internal/pkg/xai/oauth_test.go | 20 ++ backend/internal/service/account.go | 11 +- .../internal/service/account_base_url_test.go | 95 ++------ .../service/grok_upstream_url_test.go | 23 +- .../service/openai_gateway_grok_test.go | 6 +- .../account/BulkEditAccountModal.vue | 12 + .../components/account/CreateAccountModal.vue | 9 +- .../components/account/EditAccountModal.vue | 9 +- .../components/account/GrokBaseUrlPresets.vue | 32 +++ .../__tests__/BulkEditAccountModal.spec.ts | 88 +++++++ .../EditAccountModal.grokUpstream.spec.ts | 228 ++++++++++++++++++ .../__tests__/credentialsBuilder.spec.ts | 37 ++- .../components/account/credentialsBuilder.ts | 33 ++- .../src/i18n/locales/en/admin/accounts.ts | 6 +- .../src/i18n/locales/zh/admin/accounts.ts | 6 +- 18 files changed, 591 insertions(+), 104 deletions(-) create mode 100644 frontend/src/components/account/GrokBaseUrlPresets.vue create mode 100644 frontend/src/components/account/__tests__/EditAccountModal.grokUpstream.spec.ts diff --git a/backend/internal/handler/admin/grok_oauth_handler.go b/backend/internal/handler/admin/grok_oauth_handler.go index 258c68853d..ac05fd8a64 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 a87afa8c1c..9d87fc0a98 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 1799415dfb..07d2227c20 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 2a2dbf2782..53c8983103 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 e9a4bcd938..1dfd5732d9 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 c5d1ee4372..9883525f3b 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 5cbab7aa4c..61c824e2ad 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 d80cf725cb..1b605ae818 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 f6d941ea4a..5f744c0fc1 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 5c21f95fd3..7eebce530a 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -1117,6 +1117,11 @@ " />

{{ baseUrlHint }}

+
@@ -1933,7 +1938,7 @@ />
-
+
+
@@ -3519,6 +3525,7 @@ import ProxyAdBanner from '@/components/common/ProxyAdBanner.vue' import GroupSelector from '@/components/common/GroupSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue' +import GrokBaseUrlPresets from '@/components/account/GrokBaseUrlPresets.vue' import HeaderOverrideEditor from '@/components/account/HeaderOverrideEditor.vue' import { applyAntigravityProjectID, diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index eed9920bb2..228d7de2a3 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -47,6 +47,11 @@ " />

{{ baseUrlHint }}

+
@@ -452,7 +457,7 @@ />
-
+
+
@@ -2584,6 +2590,7 @@ import ProxyAdBanner from '@/components/common/ProxyAdBanner.vue' import GroupSelector from '@/components/common/GroupSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue' +import GrokBaseUrlPresets from '@/components/account/GrokBaseUrlPresets.vue' import HeaderOverrideEditor from '@/components/account/HeaderOverrideEditor.vue' import { applyAntigravityProjectID, diff --git a/frontend/src/components/account/GrokBaseUrlPresets.vue b/frontend/src/components/account/GrokBaseUrlPresets.vue new file mode 100644 index 0000000000..68a298611d --- /dev/null +++ b/frontend/src/components/account/GrokBaseUrlPresets.vue @@ -0,0 +1,32 @@ + + + diff --git a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts index 31f6e3bd26..489b00632b 100644 --- a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts @@ -130,6 +130,94 @@ describe('BulkEditAccountModal', () => { }) }) + it('全部目标为 Grok OAuth 时,官方主机 base_url 作为手动端点切换正常提交', async () => { + const wrapper = mountModal({ + selectedPlatforms: ['grok'], + selectedTypes: ['oauth'] + }) + + await wrapper.get('#bulk-edit-base-url-enabled').setValue(true) + await wrapper.get('#bulk-edit-base-url').setValue('https://api.x.ai/v1') + await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1) + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], { + credentials: { + base_url: 'https://api.x.ai/v1' + } + }) + }) + + it('所选全为 grok 时展示快捷端点,点击后填入并自动勾选 base_url', async () => { + const wrapper = mountModal({ + selectedPlatforms: ['grok'], + selectedTypes: ['oauth'] + }) + + const presets = wrapper.findAll('[data-testid="grok-base-url-preset"]') + expect(presets.length).toBe(5) + + // 第三个预设为区域 API (us-east-1.api.x.ai/v1) + await presets[2].trigger('click') + await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1) + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], { + credentials: { + base_url: 'https://us-east-1.api.x.ai/v1' + } + }) + }) + + it('所选含非 grok 平台时不展示快捷端点', async () => { + const wrapper = mountModal({ + selectedPlatforms: ['grok', 'anthropic'], + selectedTypes: ['apikey'] + }) + + expect(wrapper.findAll('[data-testid="grok-base-url-preset"]').length).toBe(0) + }) + + it('全部目标为 Grok OAuth 时,第三方 base_url 正常提交', async () => { + const wrapper = mountModal({ + selectedPlatforms: ['grok'], + selectedTypes: ['oauth'] + }) + + await wrapper.get('#bulk-edit-base-url-enabled').setValue(true) + await wrapper.get('#bulk-edit-base-url').setValue('https://relay.example.com/v1') + await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1) + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], { + credentials: { + base_url: 'https://relay.example.com/v1' + } + }) + }) + + it('混合类型选择(含 apikey)时官方主机 base_url 不拦截', async () => { + const wrapper = mountModal({ + selectedPlatforms: ['grok'], + selectedTypes: ['apikey', 'oauth'] + }) + + await wrapper.get('#bulk-edit-base-url-enabled').setValue(true) + await wrapper.get('#bulk-edit-base-url').setValue('https://api.x.ai/v1') + await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1) + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], { + credentials: { + base_url: 'https://api.x.ai/v1' + } + }) + }) + it('OpenAI 账号批量编辑可开启自动透传', async () => { const wrapper = mountModal({ selectedPlatforms: ['openai'], diff --git a/frontend/src/components/account/__tests__/EditAccountModal.grokUpstream.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.grokUpstream.spec.ts new file mode 100644 index 0000000000..310a370889 --- /dev/null +++ b/frontend/src/components/account/__tests__/EditAccountModal.grokUpstream.spec.ts @@ -0,0 +1,228 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent } from 'vue' +import { mount } from '@vue/test-utils' + +const { updateAccountMock, checkMixedChannelRiskMock, authIsSimpleMode } = vi.hoisted(() => ({ + updateAccountMock: vi.fn(), + checkMixedChannelRiskMock: vi.fn(), + authIsSimpleMode: { value: true } +})) + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ + showError: vi.fn(), + showSuccess: vi.fn(), + showInfo: vi.fn() + }) +})) + +vi.mock('@/stores/auth', () => ({ + useAuthStore: () => ({ + get isSimpleMode() { + return authIsSimpleMode.value + } + }) +})) + +vi.mock('@/api/admin', () => ({ + adminAPI: { + accounts: { + update: updateAccountMock, + checkMixedChannelRisk: checkMixedChannelRiskMock + }, + settings: { + getWebSearchEmulationConfig: vi.fn().mockResolvedValue({ enabled: false, providers: [] }), + getSettings: vi.fn().mockResolvedValue({}) + }, + tlsFingerprintProfiles: { + list: vi.fn().mockResolvedValue([]) + } + } +})) + +vi.mock('@/api/admin/accounts', () => ({ + getAntigravityDefaultModelMapping: vi.fn() +})) + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ + t: (key: string) => key + }) + } +}) + +import EditAccountModal from '../EditAccountModal.vue' + +const BaseDialogStub = defineComponent({ + name: 'BaseDialog', + props: { + show: { + type: Boolean, + default: false + } + }, + template: '
' +}) + +function buildGrokOAuthAccount(credentials: Record = {}) { + return { + id: 5, + name: 'Grok OAuth', + notes: '', + platform: 'grok', + type: 'oauth', + credentials: { + expires_at: '2027-01-01T00:00:00Z', + token_type: 'Bearer', + ...credentials + }, + credentials_status: { has_access_token: true, has_refresh_token: true }, + extra: {}, + proxy_id: null, + concurrency: 1, + priority: 1, + rate_multiplier: 1, + status: 'active', + group_ids: [], + expires_at: null, + auto_pause_on_expired: false + } as any +} + +function mountModal(account: any) { + return mount(EditAccountModal, { + props: { + show: true, + account, + proxies: [], + groups: [] + }, + global: { + stubs: { + BaseDialog: BaseDialogStub, + Select: true, + Icon: true, + ProxySelector: true, + GroupSelector: true, + ModelWhitelistSelector: true + } + } + }) +} + +describe('EditAccountModal Grok OAuth upstream config', () => { + beforeEach(() => { + authIsSimpleMode.value = true + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + }) + + it('enabling the custom base URL toggle and saving persists base_url', async () => { + const account = buildGrokOAuthAccount({ base_url: 'https://cli-chat-proxy.grok.com/v1' }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + + // 官方地址 → 开关初始为关(视同未定制) + const toggle = wrapper.get('[data-testid="grok-custom-base-url-toggle"]') + await toggle.trigger('click') + + const input = wrapper.get('[data-testid="grok-custom-base-url-input"]') + await input.setValue('https://my-relay.example.com') + + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + await vi.waitFor(() => expect(updateAccountMock).toHaveBeenCalledTimes(1)) + + const payload = updateAccountMock.mock.calls[0]?.[1] + expect(payload?.credentials?.base_url).toBe('https://my-relay.example.com') + }) + + it('accepts the official API host as a manual endpoint switch and persists it', async () => { + const account = buildGrokOAuthAccount({ base_url: 'https://cli-chat-proxy.grok.com/v1' }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + + await wrapper.get('[data-testid="grok-custom-base-url-toggle"]').trigger('click') + await wrapper.get('[data-testid="grok-custom-base-url-input"]').setValue('https://api.x.ai/v1') + + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + await vi.waitFor(() => expect(updateAccountMock).toHaveBeenCalledTimes(1)) + + const payload = updateAccountMock.mock.calls[0]?.[1] + expect(payload?.credentials?.base_url).toBe('https://api.x.ai/v1') + }) + + it('echoes a stored official API endpoint with the toggle on', async () => { + const account = buildGrokOAuthAccount({ base_url: 'https://us-west-2.api.x.ai/v1' }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + + const input = wrapper.get('[data-testid="grok-custom-base-url-input"]') + expect((input.element as HTMLInputElement).value).toBe('https://us-west-2.api.x.ai/v1') + }) + + it('fills the input from an endpoint preset chip', async () => { + const account = buildGrokOAuthAccount({ base_url: 'https://cli-chat-proxy.grok.com/v1' }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + + await wrapper.get('[data-testid="grok-custom-base-url-toggle"]').trigger('click') + const presets = wrapper.findAll('[data-testid="grok-base-url-preset"]') + expect(presets.length).toBe(5) + + // 第二个预设为官方 API (api.x.ai/v1) + await presets[1].trigger('click') + const input = wrapper.get('[data-testid="grok-custom-base-url-input"]') + expect((input.element as HTMLInputElement).value).toBe('https://api.x.ai/v1') + }) + + it('loads an existing custom base_url with the toggle on and keeps it on save', async () => { + const account = buildGrokOAuthAccount({ base_url: 'https://my-relay.example.com' }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + + const input = wrapper.get('[data-testid="grok-custom-base-url-input"]') + expect((input.element as HTMLInputElement).value).toBe('https://my-relay.example.com') + + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + await vi.waitFor(() => expect(updateAccountMock).toHaveBeenCalledTimes(1)) + + const payload = updateAccountMock.mock.calls[0]?.[1] + expect(payload?.credentials?.base_url).toBe('https://my-relay.example.com') + }) + + it('keeps stored header overrides intact on an untouched save', async () => { + const account = buildGrokOAuthAccount({ + header_override_enabled: true, + header_overrides: { + 'user-agent': 'grok-pager/0.2.93', + 'x-grok-client-identifier': 'grok-pager', + 'x-grok-client-version': '0.2.93', + 'x-xai-token-auth': 'xai-grok-cli' + } + }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + await vi.waitFor(() => expect(updateAccountMock).toHaveBeenCalledTimes(1)) + + const payload = updateAccountMock.mock.calls[0]?.[1] + expect(payload?.credentials?.header_override_enabled).toBe(true) + expect(payload?.credentials?.header_overrides).toEqual({ + 'user-agent': 'grok-pager/0.2.93', + 'x-grok-client-identifier': 'grok-pager', + 'x-grok-client-version': '0.2.93', + 'x-xai-token-auth': 'xai-grok-cli' + }) + }) +}) diff --git a/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts b/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts index 207cacc3d3..6d4b59e2a7 100644 --- a/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts +++ b/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts @@ -11,6 +11,7 @@ import { buildPlanTypeOptions, isCustomGrokBaseUrl, isHeaderOverrideCapable, + GROK_BASE_URL_PRESETS, parseHeaderOverridesJson, planTypeDisplayLabel, readPlanType, @@ -169,11 +170,15 @@ describe('serializeHeaderOverrideRows', () => { }) describe('isCustomGrokBaseUrl', () => { - it('treats official hosts and their variants as not customized', () => { - expect(isCustomGrokBaseUrl('https://api.x.ai/v1')).toBe(false) + it('treats only the default CLI gateway host as not customized', () => { expect(isCustomGrokBaseUrl('https://cli-chat-proxy.grok.com/v1')).toBe(false) - expect(isCustomGrokBaseUrl('HTTPS://API.X.AI:443/')).toBe(false) - expect(isCustomGrokBaseUrl('https://api.x.ai:8443/v1')).toBe(false) + expect(isCustomGrokBaseUrl('HTTPS://CLI-CHAT-PROXY.GROK.COM:443/')).toBe(false) + }) + + it('treats manually switched official/regional endpoints as customized (must echo back)', () => { + expect(isCustomGrokBaseUrl('https://api.x.ai/v1')).toBe(true) + expect(isCustomGrokBaseUrl('https://us-west-2.api.x.ai/v1')).toBe(true) + expect(isCustomGrokBaseUrl('https://eu-west-1.api.x.ai/v1')).toBe(true) }) it('treats empty, non-string and unparseable values as not customized', () => { @@ -191,6 +196,30 @@ describe('isCustomGrokBaseUrl', () => { }) }) +describe('GROK_BASE_URL_PRESETS', () => { + it('covers the CLI gateway, official API and regional endpoints', () => { + const urls = GROK_BASE_URL_PRESETS.map((p) => p.url) + expect(urls).toEqual([ + 'https://cli-chat-proxy.grok.com/v1', + 'https://api.x.ai/v1', + '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 (const preset of GROK_BASE_URL_PRESETS) { + // 每个预设要么有 i18n 标签键,要么有区域标识等字面标签 + expect(Boolean(preset.labelKey) || Boolean(preset.label)).toBe(true) + if (preset.labelKey) { + expect(['cli', 'official']).toContain(preset.labelKey) + } + } + // 区域端点用区域标识作字面标签(us-east-1 这样的专有名词不做 i18n) + expect(GROK_BASE_URL_PRESETS[2].label).toBe('us-east-1') + expect(GROK_BASE_URL_PRESETS[3].label).toBe('us-west-2') + expect(GROK_BASE_URL_PRESETS[4].label).toBe('eu-west-1') + }) +}) + describe('validateHeaderOverrideRows', () => { it('accepts valid rows and empty placeholder rows', () => { expect( diff --git a/frontend/src/components/account/credentialsBuilder.ts b/frontend/src/components/account/credentialsBuilder.ts index 32726a43d4..3221415250 100644 --- a/frontend/src/components/account/credentialsBuilder.ts +++ b/frontend/src/components/account/credentialsBuilder.ts @@ -199,12 +199,15 @@ export function serializeHeaderOverrideRows(rows: HeaderOverrideRow[]): string { // ========== Grok 自定义转发地址(base_url 仅改写转发端点,凭证生命周期不受影响) ========== -const GROK_OFFICIAL_BASE_URL_HOSTS = new Set(['api.x.ai', 'cli-chat-proxy.grok.com']) +/** OAuth 账号建号/刷新默认写入的 CLI 网关 host——只有它视同"未定制"。 */ +const GROK_DEFAULT_GATEWAY_HOST = 'cli-chat-proxy.grok.com' /** - * 判断 Grok 账号存储的 base_url 是否为自定义转发地址。 - * 官方主机的任意变体与无法解析的值均视为"未定制"(与后端 IsOfficialBaseURL 对齐), - * 用于 OAuth 账号编辑时决定"自定义上游地址"开关的初始状态。 + * 判断 Grok 账号存储的 base_url 是否为主动指定的上游端点。 + * 运营方可在官方 API / 区域 API / 第三方转发地址之间手动切换(应对单端点 + * 不可用),这些值都必须回显(开关开启 + 显示地址)。仅默认 CLI 网关 + * (建号/刷新自动写入)、空值与无法解析的值视为"未定制"(与后端 + * GetGrokBaseURL 的回落语义对齐),用于 OAuth 账号编辑时决定开关初始状态。 */ export function isCustomGrokBaseUrl(value: unknown): boolean { if (typeof value !== 'string') return false @@ -216,9 +219,29 @@ export function isCustomGrokBaseUrl(value: unknown): boolean { } catch { return false } - return !GROK_OFFICIAL_BASE_URL_HOSTS.has(parsed.hostname.toLowerCase()) + return parsed.hostname.toLowerCase() !== GROK_DEFAULT_GATEWAY_HOST } +export interface GrokBaseUrlPreset { + /** i18n 子键:admin.accounts.grokCustomBaseUrl.presets. */ + labelKey?: 'cli' | 'official' + /** 字面标签(如区域标识 us-east-1),专有名词不参与 i18n */ + label?: string + url: string +} + +/** + * Grok 快捷端点(仅供快速填充,输入框仍可自由填写任意转发地址)。 + * 官方端点偶发不可用时,运营方靠这组预设在端点间手动切换。 + */ +export const GROK_BASE_URL_PRESETS: GrokBaseUrlPreset[] = [ + { labelKey: 'cli', url: 'https://cli-chat-proxy.grok.com/v1' }, + { labelKey: 'official', url: 'https://api.x.ai/v1' }, + { label: 'us-east-1', url: 'https://us-east-1.api.x.ai/v1' }, + { label: 'us-west-2', url: 'https://us-west-2.api.x.ai/v1' }, + { label: 'eu-west-1', url: 'https://eu-west-1.api.x.ai/v1' } +] + /** * 将请求头覆写写入 credentials。 * create 模式:关闭时不写入任何字段;edit 模式:关闭时删除字段(全量替换语义)。 diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index b7ea0244d8..950ea80af1 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -626,7 +626,11 @@ export default { hint: 'When enabled, account traffic (chat/media/probes) is forwarded to the specified address. OAuth authorization and token refresh are unaffected and stay on the official endpoints.', placeholder: 'https://relay.example.com/v1', required: 'An address is required when Custom Upstream URL is enabled', - invalid: 'Invalid upstream address (must be a full http(s):// URL)' + invalid: 'Invalid upstream address (must be a full http(s):// URL)', + presets: { + cli: 'Grok Build CLI', + official: 'Official API' + } }, autoPauseOnExpired: 'Auto Pause On Expired', autoPauseOnExpiredDesc: 'When enabled, the account will auto pause scheduling after it expires', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index e4e38e0709..917a4ea205 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -719,7 +719,11 @@ export default { hint: '开启后账号流量(对话/媒体/探测)改发指定地址;OAuth 授权与令牌刷新不受影响,仍走官方端点。', placeholder: 'https://relay.example.com/v1', required: '开启自定义上游地址后必须填写地址', - invalid: '上游地址格式不正确(需为 http(s):// 开头的完整地址)' + invalid: '上游地址格式不正确(需为 http(s):// 开头的完整地址)', + presets: { + cli: 'Grok Build CLI', + official: '官方 API' + } }, autoPauseOnExpired: '过期自动暂停调度', autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度',