mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 15:38:22 +08:00
feat(grok): 支持上游端点手动切换与快捷端点,修复 SSO 建号自定义地址被覆盖
官方端点(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);仅快速填充,输入框仍可自由填写任意第三方地址
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -110,6 +110,11 @@
|
||||
:placeholder="t('admin.accounts.bulkEdit.baseUrlPlaceholder')"
|
||||
aria-labelledby="bulk-edit-base-url-label"
|
||||
/>
|
||||
<GrokBaseUrlPresets
|
||||
v-if="allTargetsGrok"
|
||||
class="mt-2"
|
||||
@select="baseUrl = $event; enableBaseUrl = true"
|
||||
/>
|
||||
<p class="input-hint">
|
||||
{{ t('admin.accounts.bulkEdit.baseUrlNotice') }}
|
||||
</p>
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -1117,6 +1117,11 @@
|
||||
"
|
||||
/>
|
||||
<p v-if="baseUrlHint" class="input-hint">{{ baseUrlHint }}</p>
|
||||
<GrokBaseUrlPresets
|
||||
v-if="form.platform === 'grok'"
|
||||
class="mt-2"
|
||||
@select="apiKeyBaseUrl = $event"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.apiKeyRequired') }}</label>
|
||||
@@ -1933,7 +1938,7 @@
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="grokOAuthCustomBaseUrlEnabled">
|
||||
<div v-if="grokOAuthCustomBaseUrlEnabled" class="space-y-2">
|
||||
<input
|
||||
v-model="grokOAuthBaseUrl"
|
||||
type="text"
|
||||
@@ -1941,6 +1946,7 @@
|
||||
data-testid="grok-custom-base-url-input"
|
||||
:placeholder="t('admin.accounts.grokCustomBaseUrl.placeholder')"
|
||||
/>
|
||||
<GrokBaseUrlPresets @select="grokOAuthBaseUrl = $event" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -47,6 +47,11 @@
|
||||
"
|
||||
/>
|
||||
<p v-if="baseUrlHint" class="input-hint">{{ baseUrlHint }}</p>
|
||||
<GrokBaseUrlPresets
|
||||
v-if="account.platform === 'grok'"
|
||||
class="mt-2"
|
||||
@select="editBaseUrl = $event"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.apiKey') }}</label>
|
||||
@@ -452,7 +457,7 @@
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="grokOAuthCustomBaseUrlEnabled">
|
||||
<div v-if="grokOAuthCustomBaseUrlEnabled" class="space-y-2">
|
||||
<input
|
||||
v-model="grokOAuthBaseUrl"
|
||||
type="text"
|
||||
@@ -460,6 +465,7 @@
|
||||
data-testid="grok-custom-base-url-input"
|
||||
:placeholder="t('admin.accounts.grokCustomBaseUrl.placeholder')"
|
||||
/>
|
||||
<GrokBaseUrlPresets @select="grokOAuthBaseUrl = $event" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="preset in GROK_BASE_URL_PRESETS"
|
||||
:key="preset.url"
|
||||
type="button"
|
||||
data-testid="grok-base-url-preset"
|
||||
class="rounded-lg bg-gray-100 px-3 py-1 text-xs text-gray-700 transition-colors hover:bg-primary-50 hover:text-primary-700 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-primary-900/30 dark:hover:text-primary-400"
|
||||
@click="emit('select', preset.url)"
|
||||
>
|
||||
{{ presetLabel(preset) }} ({{ displayUrl(preset.url) }})
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { GROK_BASE_URL_PRESETS, type GrokBaseUrlPreset } from './credentialsBuilder'
|
||||
|
||||
// Grok 快捷端点:点击把预设地址填入调用方的输入框。
|
||||
// 仅是快速填充,不限制可填值——输入框仍接受任意第三方转发地址。
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', url: string): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const presetLabel = (preset: GrokBaseUrlPreset) =>
|
||||
preset.label ?? t(`admin.accounts.grokCustomBaseUrl.presets.${preset.labelKey}`)
|
||||
|
||||
const displayUrl = (url: string) => url.replace(/^https?:\/\//i, '')
|
||||
</script>
|
||||
@@ -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'],
|
||||
|
||||
@@ -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<typeof import('vue-i18n')>('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: '<div v-if="show"><slot /><slot name="footer" /></div>'
|
||||
})
|
||||
|
||||
function buildGrokOAuthAccount(credentials: Record<string, unknown> = {}) {
|
||||
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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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> */
|
||||
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 模式:关闭时删除字段(全量替换语义)。
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '启用后,账号过期将自动暂停调度',
|
||||
|
||||
Reference in New Issue
Block a user