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:
shaw committed 2026-07-16 19:10:21 +08:00
1 parent b960ec1980
commit 7f5d067af2
18 files changed
+591 -104

No files matched your search

@@ -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 模式:关闭时删除字段(全量替换语义)。