feat(grok): 支持账号级自定义上游地址与请求头覆写

将账号级请求头覆写从 anthropic/openai 的 api_key 账号扩展到 Grok 的
api_key 与 oauth 账号,并放开 Grok OAuth 账号的自定义上游地址(仅作用于
转发端点,授权与 token 刷新链路不变)。

后端:
- IsHeaderOverrideEligible 扩展到 Grok(api_key+oauth),禁止名单新增
  x-grok-conv-id(逐请求会话路由头)。
- 所有 Grok 上游请求路径接线 ApplyHeaderOverrides(Responses/Chat 桥/
  媒体/配额探测/billing 探测/连通性测试),统一置于内置默认头之后。
- GetGrokBaseURL/GetGrokMediaBaseURL 放开 OAuth 自定义地址:官方地址
  视同未定制回落官方网关,仅显式第三方 host 改发转发流量。
- 非官方 host 允许任意 path 前缀,官方 host 仍强制 /v1。
- OAuth base_url 校验按 host 判定官方/自定义,自定义 host 恒受运营方
  URL 策略约束,不受 XAI_ALLOW_UNSAFE_URL_OVERRIDES 调试开关放宽。
- 给 GrokQuotaService 注入 config,使配额/billing 探测与转发共用同一
  URL 策略。

前端:
- Edit 模态为 Grok OAuth 账号新增「自定义上游地址」开关。
- 请求头表单新增 JSON 快速导入与按 JSON 一键复制(复用 useClipboard,
  兼容非安全上下文 HTTP 页面)。
- 门控改为平台×类型判定,Bulk 批量 base_url 增加格式校验,zh/en 文案同步。
This commit is contained in:
shaw
2026-07-15 20:30:23 +08:00
parent d515c3045c
commit 221581400b
28 changed files with 1039 additions and 219 deletions
@@ -589,14 +589,19 @@
{{ t('admin.accounts.headerOverride.addRow') }}
</button>
<div v-if="headerOverrideTemplatePlatform" class="flex flex-wrap gap-2">
<div class="flex flex-wrap gap-2">
<button
v-if="headerOverrideTemplatePlatform"
type="button"
class="rounded-lg bg-primary-50 px-3 py-1 text-xs text-primary-700 transition-colors hover:bg-primary-100 dark:bg-primary-900/30 dark:text-primary-400 dark:hover:bg-primary-900/50"
@click="fillHeaderOverrideTemplate"
>
+ {{ t('admin.accounts.headerOverride.fillTemplate') }}
</button>
<HeaderOverrideJsonTools
:rows="headerOverrideRows"
@update:rows="headerOverrideRows = $event"
/>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
@@ -1272,10 +1277,11 @@ import {
buildModelMappingObject as buildModelMappingPayload,
getPresetMappingsByPlatform
} from '@/composables/useModelWhitelist'
import HeaderOverrideJsonTools from '@/components/account/HeaderOverrideJsonTools.vue'
import {
buildHeaderOverridesObject,
getHeaderOverrideTemplate,
isHeaderOverridePlatform,
isHeaderOverrideCapable,
validateHeaderOverrideRows,
HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY,
HEADER_OVERRIDES_CREDENTIAL_KEY,
@@ -1351,12 +1357,15 @@ const allOpenAIAPIKey = computed(() => {
})
// 是否全部为 anthropic/openai 平台的 apikey 账号(请求头覆写仅在此条件下显示)
// 所选平台 × 所选类型的全组合均需具备覆写资格(实际选中账号是该组合的子集,
// 按交叉积判定偏保守但绝不放行不合资格的账号)
const allHeaderOverrideCapable = computed(() => {
return (
targetSelectedPlatforms.value.length > 0 &&
targetSelectedPlatforms.value.every(p => isHeaderOverridePlatform(p)) &&
targetSelectedTypes.value.length > 0 &&
targetSelectedTypes.value.every(t => t === 'apikey')
targetSelectedPlatforms.value.every(p =>
targetSelectedTypes.value.every(ty => isHeaderOverrideCapable(p, ty))
)
)
})
@@ -1859,6 +1868,16 @@ const handleSubmit = async () => {
return
}
// base_url 现在也会作用于 Grok OAuth 订阅账号的转发端点;坏值会让请求期
// 校验失败、账号请求全挂,因此保存前强制格式校验(与单账号编辑一致)。
if (enableBaseUrl.value) {
const trimmedBaseUrl = baseUrl.value.trim()
if (trimmedBaseUrl && !/^https?:\/\//i.test(trimmedBaseUrl)) {
appStore.showError(t('admin.accounts.grokCustomBaseUrl.invalid'))
return
}
}
if (enableHeaderOverride.value && headerOverrideEnabled.value) {
// 批量保存对 header_overrides 是整键替换:开启但没有任何有效行会把所选账号的
// 既有覆写配置静默清空,必须显式拦截(清空请走关闭开关的路径,有专门提示)
@@ -1498,7 +1498,7 @@
<!-- Header Override Section (anthropic/openai apikey only) -->
<div
v-if="isHeaderOverridePlatform(form.platform)"
v-if="isHeaderOverrideCapable(form.platform, 'apikey')"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="mb-3 flex items-center justify-between">
@@ -1592,6 +1592,10 @@
>
+ {{ t('admin.accounts.headerOverride.fillTemplate') }}
</button>
<HeaderOverrideJsonTools
:rows="headerOverrideRows"
@update:rows="headerOverrideRows = $event"
/>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
@@ -3494,12 +3498,13 @@ 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 HeaderOverrideJsonTools from '@/components/account/HeaderOverrideJsonTools.vue'
import {
applyAntigravityProjectID,
applyHeaderOverride,
applyInterceptWarmup,
getHeaderOverrideTemplate,
isHeaderOverridePlatform,
isHeaderOverrideCapable,
validateHeaderOverrideRows,
type HeaderOverrideRow
} from '@/components/account/credentialsBuilder'
@@ -5019,8 +5024,8 @@ const handleSubmit = async () => {
credentials.custom_error_codes = [...selectedErrorCodes.value]
}
// Add header override if enabled (anthropic/openai apikey only)
if (isHeaderOverridePlatform(form.platform)) {
// Add header override if enabled (anthropic/openai/grok apikey)
if (isHeaderOverrideCapable(form.platform, 'apikey')) {
if (headerOverrideEnabled.value) {
const headerError = validateHeaderOverrideRows(headerOverrideRows.value)
if (headerError) {
@@ -421,110 +421,151 @@
</div>
</div>
<!-- Header Override Section (anthropic/openai apikey only) -->
<div
v-if="isHeaderOverridePlatform(account.platform)"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="mb-3 flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.headerOverride.title') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.hint') }}
</p>
</div>
<button
type="button"
@click="headerOverrideEnabled = !headerOverrideEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
headerOverrideEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
headerOverrideEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
</div>
<div v-if="headerOverrideEnabled" class="space-y-3">
<div class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
<p class="text-xs text-blue-700 dark:text-blue-400">
<Icon name="exclamationCircle" size="sm" class="mr-1 inline" :stroke-width="2" />
{{ t('admin.accounts.headerOverride.info') }}
</p>
</div>
<div v-if="headerOverrideRows.length > 0" class="space-y-2">
<div
v-for="(row, index) in headerOverrideRows"
:key="getHeaderOverrideRowKey(row)"
class="flex items-center gap-2"
>
<input
v-model="row.name"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.headerOverride.namePlaceholder')"
/>
<input
v-model="row.value"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.headerOverride.valuePlaceholder')"
/>
<button
type="button"
@click="removeHeaderOverrideRow(index)"
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
<button
type="button"
@click="addHeaderOverrideRow"
class="w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
>
<svg class="mr-1 inline h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
{{ t('admin.accounts.headerOverride.addRow') }}
</button>
<div class="flex flex-wrap gap-2">
<button
type="button"
@click="fillHeaderOverrideTemplate"
class="rounded-lg bg-primary-50 px-3 py-1 text-xs text-primary-700 transition-colors hover:bg-primary-100 dark:bg-primary-900/30 dark:text-primary-400 dark:hover:bg-primary-900/50"
>
+ {{ t('admin.accounts.headerOverride.fillTemplate') }}
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.emptyValueHint') }}
<!-- Grok OAuth Custom Upstream URL (仅改写转发端点,OAuth 授权/刷新不受影响) -->
<div
v-if="account.platform === 'grok' && account.type === 'oauth'"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="mb-3 flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.grokCustomBaseUrl.title') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.grokCustomBaseUrl.hint') }}
</p>
</div>
<button
type="button"
data-testid="grok-custom-base-url-toggle"
@click="grokOAuthCustomBaseUrlEnabled = !grokOAuthCustomBaseUrlEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
grokOAuthCustomBaseUrlEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
grokOAuthCustomBaseUrlEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="grokOAuthCustomBaseUrlEnabled">
<input
v-model="grokOAuthBaseUrl"
type="text"
class="input"
data-testid="grok-custom-base-url-input"
:placeholder="t('admin.accounts.grokCustomBaseUrl.placeholder')"
/>
</div>
</div>
<!-- Header Override Section (anthropic/openai apikey + grok apikey/oauth) -->
<div v-if="headerOverrideCapable" class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.headerOverride.title') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.hint') }}
</p>
</div>
<button
type="button"
@click="headerOverrideEnabled = !headerOverrideEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
headerOverrideEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
headerOverrideEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="headerOverrideEnabled" class="space-y-3">
<div class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
<p class="text-xs text-blue-700 dark:text-blue-400">
<Icon name="exclamationCircle" size="sm" class="mr-1 inline" :stroke-width="2" />
{{ t('admin.accounts.headerOverride.info') }}
</p>
</div>
<div v-if="headerOverrideRows.length > 0" class="space-y-2">
<div
v-for="(row, index) in headerOverrideRows"
:key="getHeaderOverrideRowKey(row)"
class="flex items-center gap-2"
>
<input
v-model="row.name"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.headerOverride.namePlaceholder')"
/>
<input
v-model="row.value"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.headerOverride.valuePlaceholder')"
/>
<button
type="button"
@click="removeHeaderOverrideRow(index)"
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
<button
type="button"
@click="addHeaderOverrideRow"
class="w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
>
<svg class="mr-1 inline h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
{{ t('admin.accounts.headerOverride.addRow') }}
</button>
<div class="flex flex-wrap gap-2">
<button
type="button"
@click="fillHeaderOverrideTemplate"
class="rounded-lg bg-primary-50 px-3 py-1 text-xs text-primary-700 transition-colors hover:bg-primary-100 dark:bg-primary-900/30 dark:text-primary-400 dark:hover:bg-primary-900/50"
>
+ {{ t('admin.accounts.headerOverride.fillTemplate') }}
</button>
<HeaderOverrideJsonTools
:rows="headerOverrideRows"
@update:rows="headerOverrideRows = $event"
/>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.emptyValueHint') }}
</p>
</div>
</div>
<!-- OpenAI/Grok OAuth Model Mapping (OAuth 类型没有 apikey 容器,需要独立的模型映射区域) -->
@@ -2589,6 +2630,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 HeaderOverrideJsonTools from '@/components/account/HeaderOverrideJsonTools.vue'
import {
applyAntigravityProjectID,
applyHeaderOverride,
@@ -2597,7 +2639,8 @@ import {
buildPlanTypeOptions,
readPlanType,
getHeaderOverrideTemplate,
isHeaderOverridePlatform,
isCustomGrokBaseUrl,
isHeaderOverrideCapable,
splitHeaderOverridesObject,
validateHeaderOverrideRows,
HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY,
@@ -2737,6 +2780,14 @@ const customErrorCodeInput = ref<number | null>(null)
const headerOverrideEnabled = ref(false)
const headerOverrideRows = ref<HeaderOverrideRow[]>([])
const headerOverrideCapable = computed(
() => !!props.account && isHeaderOverrideCapable(props.account.platform, props.account.type)
)
// Grok OAuth 自定义上游地址(仅转发端点;OAuth 授权/令牌刷新不受影响)
const grokOAuthCustomBaseUrlEnabled = ref(false)
const grokOAuthBaseUrl = ref('')
const addHeaderOverrideRow = () => {
headerOverrideRows.value.push({ name: '', value: '' })
}
@@ -3397,9 +3448,27 @@ const syncFormFromAccount = (newAccount: Account | null) => {
loadTempUnschedRules(credentials)
// Reset header override state (loaded below only for apikey accounts)
// Load header override state (anthropic/openai apikey + grok apikey/oauth)
headerOverrideEnabled.value = false
headerOverrideRows.value = []
if (newAccount.credentials && isHeaderOverrideCapable(newAccount.platform, newAccount.type)) {
const overrideCreds = newAccount.credentials as Record<string, unknown>
headerOverrideEnabled.value = overrideCreds[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY] === true
headerOverrideRows.value = splitHeaderOverridesObject(
overrideCreds[HEADER_OVERRIDES_CREDENTIAL_KEY]
)
}
// Load Grok OAuth custom upstream URL state(存储的官方地址视同未定制)
grokOAuthCustomBaseUrlEnabled.value = false
grokOAuthBaseUrl.value = ''
if (newAccount.platform === 'grok' && newAccount.type === 'oauth' && newAccount.credentials) {
const grokCreds = newAccount.credentials as Record<string, unknown>
if (isCustomGrokBaseUrl(grokCreds.base_url)) {
grokOAuthCustomBaseUrlEnabled.value = true
grokOAuthBaseUrl.value = (grokCreds.base_url as string).trim()
}
}
// Initialize API Key fields for apikey type
if (newAccount.type === 'apikey' && newAccount.credentials) {
@@ -3433,13 +3502,6 @@ const syncFormFromAccount = (newAccount: Account | null) => {
selectedErrorCodes.value = []
}
// Load header override (anthropic/openai apikey only)
headerOverrideEnabled.value =
isHeaderOverridePlatform(newAccount.platform) &&
credentials[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY] === true
headerOverrideRows.value = splitHeaderOverridesObject(
credentials[HEADER_OVERRIDES_CREDENTIAL_KEY]
)
} else if (newAccount.type === 'bedrock' && newAccount.credentials) {
const bedrockCreds = newAccount.credentials as Record<string, unknown>
const authMode = (bedrockCreds.auth_mode as string) || 'sigv4'
@@ -4077,8 +4139,8 @@ const handleSubmit = async () => {
delete newCredentials.custom_error_codes
}
// Add header override if enabled (anthropic/openai apikey only)
if (isHeaderOverridePlatform(props.account.platform)) {
// Add header override if enabled (anthropic/openai/grok apikey)
if (isHeaderOverrideCapable(props.account.platform, 'apikey')) {
if (headerOverrideEnabled.value) {
const headerError = validateHeaderOverrideRows(headerOverrideRows.value)
if (headerError) {
@@ -4253,6 +4315,41 @@ const handleSubmit = async () => {
updatePayload.credentials = newCredentials
}
// Grok OAuth: 自定义上游地址 + 请求头覆写。base_url 仅改写转发端点,
// OAuth 授权与令牌刷新链路不读取该值;关闭开关即恢复默认官方网关。
if (props.account.platform === 'grok' && props.account.type === 'oauth') {
const currentCredentials =
(updatePayload.credentials as Record<string, unknown>) ||
((props.account.credentials as Record<string, unknown>) || {})
const newCredentials: Record<string, unknown> = { ...currentCredentials }
if (grokOAuthCustomBaseUrlEnabled.value) {
const trimmedBaseUrl = grokOAuthBaseUrl.value.trim()
if (!trimmedBaseUrl) {
appStore.showError(t('admin.accounts.grokCustomBaseUrl.required'))
return
}
if (!/^https?:\/\//i.test(trimmedBaseUrl)) {
appStore.showError(t('admin.accounts.grokCustomBaseUrl.invalid'))
return
}
newCredentials.base_url = trimmedBaseUrl
} else {
delete newCredentials.base_url
}
if (headerOverrideEnabled.value) {
const headerError = validateHeaderOverrideRows(headerOverrideRows.value)
if (headerError) {
appStore.showError(t(`admin.accounts.headerOverride.${headerError}`))
return
}
}
applyHeaderOverride(newCredentials, headerOverrideEnabled.value, headerOverrideRows.value, 'edit')
updatePayload.credentials = newCredentials
}
// OpenAI: 手动覆盖订阅档位 plan_type(Plus/Pro/Free)。仅 OAuth 非影子账号:
// 影子账号凭据由母账号管理(且后端会 sanitize),setup-token 无订阅调度语义。
if (props.account.platform === 'openai' && props.account.type === 'oauth' && !isSparkShadow.value) {
@@ -0,0 +1,97 @@
<template>
<button
type="button"
class="rounded-lg bg-primary-50 px-3 py-1 text-xs text-primary-700 transition-colors hover:bg-primary-100 dark:bg-primary-900/30 dark:text-primary-400 dark:hover:bg-primary-900/50"
@click="toggleImportPanel"
>
{{ t('admin.accounts.headerOverride.importJson') }}
</button>
<button
type="button"
class="rounded-lg bg-primary-50 px-3 py-1 text-xs text-primary-700 transition-colors hover:bg-primary-100 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-primary-900/30 dark:text-primary-400 dark:hover:bg-primary-900/50"
:disabled="!hasNamedRows"
@click="copyAsJson"
>
{{ t('admin.accounts.headerOverride.copyJson') }}
</button>
<div v-if="showImportPanel" class="w-full space-y-2">
<textarea
v-model="importText"
rows="5"
class="input font-mono text-xs"
:placeholder="t('admin.accounts.headerOverride.importJsonPlaceholder')"
></textarea>
<div class="flex gap-2">
<button
type="button"
class="rounded-lg bg-primary-600 px-3 py-1 text-xs text-white transition-colors hover:bg-primary-700"
@click="applyImport"
>
{{ t('admin.accounts.headerOverride.importJsonApply') }}
</button>
<button
type="button"
class="rounded-lg bg-gray-100 px-3 py-1 text-xs text-gray-600 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500"
@click="closeImportPanel"
>
{{ t('admin.accounts.headerOverride.importJsonCancel') }}
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.importJsonHint') }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useClipboard } from '@/composables/useClipboard'
import {
parseHeaderOverridesJson,
serializeHeaderOverrideRows,
type HeaderOverrideRow
} from './credentialsBuilder'
const props = defineProps<{
rows: HeaderOverrideRow[]
}>()
const emit = defineEmits<{
(e: 'update:rows', rows: HeaderOverrideRow[]): void
}>()
const { t } = useI18n()
const appStore = useAppStore()
const { copyToClipboard } = useClipboard()
const showImportPanel = ref(false)
const importText = ref('')
const hasNamedRows = computed(() => props.rows.some((row) => row.name.trim()))
const toggleImportPanel = () => {
showImportPanel.value = !showImportPanel.value
}
const closeImportPanel = () => {
showImportPanel.value = false
importText.value = ''
}
const applyImport = () => {
const rows = parseHeaderOverridesJson(importText.value)
if (rows === null) {
appStore.showError(t('admin.accounts.headerOverride.importJsonInvalid'))
return
}
emit('update:rows', rows)
closeImportPanel()
}
const copyAsJson = () => {
void copyToClipboard(serializeHeaderOverrideRows(props.rows))
}
</script>
@@ -10,9 +10,12 @@ import {
buildHeaderOverridesObject,
buildPlanTypeOptions,
getHeaderOverrideTemplate,
isHeaderOverridePlatform,
isCustomGrokBaseUrl,
isHeaderOverrideCapable,
parseHeaderOverridesJson,
planTypeDisplayLabel,
readPlanType,
serializeHeaderOverrideRows,
splitHeaderOverridesObject,
validateHeaderOverrideRows
} from '../credentialsBuilder'
@@ -95,14 +98,97 @@ describe('applyAntigravityProjectID', () => {
})
})
describe('isHeaderOverridePlatform', () => {
it('only anthropic and openai are supported', () => {
expect(isHeaderOverridePlatform('anthropic')).toBe(true)
expect(isHeaderOverridePlatform('openai')).toBe(true)
expect(isHeaderOverridePlatform('gemini')).toBe(false)
expect(isHeaderOverridePlatform('grok')).toBe(false)
expect(isHeaderOverridePlatform('antigravity')).toBe(false)
expect(isHeaderOverridePlatform('')).toBe(false)
describe('isHeaderOverrideCapable', () => {
it('anthropic/openai only support apikey accounts', () => {
expect(isHeaderOverrideCapable('anthropic', 'apikey')).toBe(true)
expect(isHeaderOverrideCapable('openai', 'apikey')).toBe(true)
expect(isHeaderOverrideCapable('anthropic', 'oauth')).toBe(false)
expect(isHeaderOverrideCapable('openai', 'oauth')).toBe(false)
})
it('grok supports both apikey and oauth accounts', () => {
expect(isHeaderOverrideCapable('grok', 'apikey')).toBe(true)
expect(isHeaderOverrideCapable('grok', 'oauth')).toBe(true)
expect(isHeaderOverrideCapable('grok', 'bedrock')).toBe(false)
})
it('other platforms are not supported', () => {
expect(isHeaderOverrideCapable('gemini', 'apikey')).toBe(false)
expect(isHeaderOverrideCapable('antigravity', 'apikey')).toBe(false)
expect(isHeaderOverrideCapable('', 'apikey')).toBe(false)
})
})
describe('parseHeaderOverridesJson', () => {
it('parses a flat object and normalizes values to trimmed strings', () => {
expect(
parseHeaderOverridesJson('{"User-Agent": " my-client/1.0 ", "x-num": 3, "x-flag": true}')
).toEqual([
{ name: 'User-Agent', value: 'my-client/1.0' },
{ name: 'x-flag', value: 'true' },
{ name: 'x-num', value: '3' }
])
})
it('drops entries with blank names', () => {
expect(parseHeaderOverridesJson('{" ": "v", "x-app": "cli"}')).toEqual([
{ name: 'x-app', value: 'cli' }
])
})
it('rejects invalid JSON, arrays, primitives and nested values', () => {
expect(parseHeaderOverridesJson('not json')).toBeNull()
expect(parseHeaderOverridesJson('[1,2]')).toBeNull()
expect(parseHeaderOverridesJson('"str"')).toBeNull()
expect(parseHeaderOverridesJson('null')).toBeNull()
expect(parseHeaderOverridesJson('{"a": {"b": 1}}')).toBeNull()
expect(parseHeaderOverridesJson('{"a": null}')).toBeNull()
})
it('parses an empty object to an empty row list', () => {
expect(parseHeaderOverridesJson('{}')).toEqual([])
})
})
describe('serializeHeaderOverrideRows', () => {
it('serializes named rows and skips empty placeholder rows', () => {
const text = serializeHeaderOverrideRows([
{ name: ' user-agent ', value: ' my-client/1.0 ' },
{ name: '', value: 'ignored' },
{ name: 'x-app', value: '' }
])
expect(JSON.parse(text)).toEqual({ 'user-agent': 'my-client/1.0', 'x-app': '' })
})
it('round-trips with parseHeaderOverridesJson', () => {
const rows = [
{ name: 'a-header', value: '1' },
{ name: 'b-header', value: '2' }
]
expect(parseHeaderOverridesJson(serializeHeaderOverrideRows(rows))).toEqual(rows)
})
})
describe('isCustomGrokBaseUrl', () => {
it('treats official hosts and their variants as not customized', () => {
expect(isCustomGrokBaseUrl('https://api.x.ai/v1')).toBe(false)
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)
})
it('treats empty, non-string and unparseable values as not customized', () => {
expect(isCustomGrokBaseUrl('')).toBe(false)
expect(isCustomGrokBaseUrl(' ')).toBe(false)
expect(isCustomGrokBaseUrl(undefined)).toBe(false)
expect(isCustomGrokBaseUrl(42)).toBe(false)
expect(isCustomGrokBaseUrl('not a url')).toBe(false)
})
it('treats third-party hosts as customized', () => {
expect(isCustomGrokBaseUrl('https://relay.example.com/v1')).toBe(true)
expect(isCustomGrokBaseUrl('https://relay.example.com/xai/v1')).toBe(true)
expect(isCustomGrokBaseUrl('http://relay.example.com/v1')).toBe(true)
})
})
@@ -200,6 +286,16 @@ describe('getHeaderOverrideTemplate', () => {
expect(names).toContain('openai-beta')
expect(validateHeaderOverrideRows(rows)).toBeNull()
})
it('returns Grok forwarding headers with empty values for grok', () => {
const rows = getHeaderOverrideTemplate('grok')
expect(rows.every((r) => r.value === '')).toBe(true)
const names = rows.map((r) => r.name)
expect(names).toContain('user-agent')
expect(names).toContain('x-xai-token-auth')
expect(names).toContain('x-grok-client-version')
expect(validateHeaderOverrideRows(rows)).toBeNull()
})
})
describe('applyHeaderOverride', () => {
@@ -25,7 +25,7 @@ export function applyAntigravityProjectID(
}
}
// ========== 请求头覆写(仅 anthropic/openai 平台的 api_key 账号) ==========
// ========== 请求头覆写(anthropic/openai 的 api_key 账号 + grok 的 api_key/oauth 账号) ==========
export const HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY = 'header_override_enabled'
export const HEADER_OVERRIDES_CREDENTIAL_KEY = 'header_overrides'
@@ -35,9 +35,15 @@ export interface HeaderOverrideRow {
value: string
}
/** 请求头覆写支持的平台(与后端 IsHeaderOverrideEligible 保持一致) */
export function isHeaderOverridePlatform(platform: string): boolean {
return platform === 'anthropic' || platform === 'openai'
/** 请求头覆写资格(与后端 IsHeaderOverrideEligible 保持一致) */
export function isHeaderOverrideCapable(platform: string, type: string): boolean {
if (platform === 'anthropic' || platform === 'openai') {
return type === 'apikey'
}
if (platform === 'grok') {
return type === 'apikey' || type === 'oauth'
}
return false
}
/** 禁止覆写的请求头(与后端 headerOverrideBlockedNames 保持一致) */
@@ -70,7 +76,8 @@ const HEADER_OVERRIDE_BLOCKED_NAMES = new Set([
'x-codex-turn-metadata',
'chatgpt-account-id',
'x-claude-code-session-id',
'x-client-request-id'
'x-client-request-id',
'x-grok-conv-id'
])
/** RFC 7230 token:合法的 HTTP header 名称字符集 */
@@ -107,9 +114,16 @@ const OPENAI_HEADER_OVERRIDE_TEMPLATE = [
'accept-language'
]
/** 模板:Grok 转发常用请求头(第三方转发网关通常需要的身份/准入头,值留空由管理员填写) */
const GROK_HEADER_OVERRIDE_TEMPLATE = ['user-agent', 'x-xai-token-auth', 'x-grok-client-version']
export function getHeaderOverrideTemplate(platform: string): HeaderOverrideRow[] {
const names =
platform === 'openai' ? OPENAI_HEADER_OVERRIDE_TEMPLATE : ANTHROPIC_HEADER_OVERRIDE_TEMPLATE
platform === 'openai'
? OPENAI_HEADER_OVERRIDE_TEMPLATE
: platform === 'grok'
? GROK_HEADER_OVERRIDE_TEMPLATE
: ANTHROPIC_HEADER_OVERRIDE_TEMPLATE
return names.map((name) => ({ name, value: '' }))
}
@@ -183,6 +197,68 @@ export function splitHeaderOverridesObject(record: unknown): HeaderOverrideRow[]
.sort((a, b) => a.name.localeCompare(b.name))
}
/**
* 解析粘贴的 JSON 文本为请求头覆写行。
* 仅接受扁平 JSON 对象;值允许 string/number/boolean(统一转字符串),
* 其余类型或非对象输入返回 null 表示格式非法。键为空白的条目直接丢弃。
*/
export function parseHeaderOverridesJson(text: string): HeaderOverrideRow[] | null {
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch {
return null
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
const rows: HeaderOverrideRow[] = []
for (const [rawName, rawValue] of Object.entries(parsed as Record<string, unknown>)) {
const name = rawName.trim()
if (!name) continue
if (
typeof rawValue !== 'string' &&
typeof rawValue !== 'number' &&
typeof rawValue !== 'boolean'
) {
return null
}
rows.push({ name, value: String(rawValue).trim() })
}
return rows.sort((a, b) => a.name.localeCompare(b.name))
}
/** 请求头覆写行 → 便于迁移/备份的 JSON 文本(跳过名称为空的占位行) */
export function serializeHeaderOverrideRows(rows: HeaderOverrideRow[]): string {
const record: Record<string, string> = {}
for (const row of rows) {
const name = row.name.trim()
if (!name) continue
record[name] = row.value.trim()
}
return JSON.stringify(record, null, 2)
}
// ========== Grok 自定义转发地址(base_url 仅改写转发端点,凭证生命周期不受影响) ==========
const GROK_OFFICIAL_BASE_URL_HOSTS = new Set(['api.x.ai', 'cli-chat-proxy.grok.com'])
/**
* 判断 Grok 账号存储的 base_url 是否为自定义转发地址。
* 官方主机的任意变体与无法解析的值均视为"未定制"(与后端 IsOfficialBaseURL 对齐),
* 用于 OAuth 账号编辑时决定"自定义上游地址"开关的初始状态。
*/
export function isCustomGrokBaseUrl(value: unknown): boolean {
if (typeof value !== 'string') return false
const trimmed = value.trim()
if (!trimmed) return false
let parsed: URL
try {
parsed = new URL(trimmed)
} catch {
return false
}
return !GROK_OFFICIAL_BASE_URL_HOSTS.has(parsed.hostname.toLowerCase())
}
/**
* 将请求头覆写写入 credentials。
* create 模式:关闭时不写入任何字段;edit 模式:关闭时删除字段(全量替换语义)。
+15 -1
View File
@@ -322,7 +322,7 @@ export default {
selectionInfo:
'{count} account(s) selected. Only checked or filled fields will be updated; others stay unchanged.',
baseUrlPlaceholder: 'https://api.anthropic.com or https://api.openai.com',
baseUrlNotice: 'Applies to API Key accounts only; leave empty to keep existing value',
baseUrlNotice: 'Applies to API Key accounts and the forwarding endpoint of Grok OAuth accounts; leave empty to keep existing value',
submit: 'Update Accounts',
updating: 'Updating...',
success: 'Updated {count} account(s)',
@@ -579,6 +579,13 @@ export default {
valuePlaceholder: 'Override value (leave empty to skip)',
addRow: 'Add Header',
fillTemplate: 'Fill Template',
importJson: 'Import JSON',
importJsonPlaceholder: '{"user-agent": "my-client/1.0", "x-relay-token": "..."}',
importJsonApply: 'Parse & Fill',
importJsonCancel: 'Cancel',
importJsonHint: 'Paste a flat JSON object (header name → value). Parsing replaces the current rows.',
importJsonInvalid: 'Invalid JSON: expected a flat object of header name → string value',
copyJson: 'Copy as JSON',
emptyValueHint: 'Rows with an empty value are placeholders and do not override anything.',
bulkDisableHint: 'Saving will disable header override and clear existing configuration on the selected accounts.',
bulkReplaceHint: 'Saving will replace the existing header override configuration on all selected accounts with the rows below.',
@@ -589,6 +596,13 @@ export default {
invalidValue: 'Invalid header value (control characters are not allowed; max length 8192)',
tooManyEntries: 'Too many header override entries (max 64)'
},
grokCustomBaseUrl: {
title: 'Custom Upstream URL',
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)'
},
autoPauseOnExpired: 'Auto Pause On Expired',
autoPauseOnExpiredDesc: 'When enabled, the account will auto pause scheduling after it expires',
autoPause5hThreshold: '5h Usage Threshold (%)',
+15 -1
View File
@@ -425,7 +425,7 @@ export default {
title: '批量编辑账号',
selectionInfo: '已选择 {count} 个账号。只更新您勾选或填写的字段,未勾选的字段保持不变。',
baseUrlPlaceholder: 'https://api.anthropic.com 或 https://api.openai.com',
baseUrlNotice: '仅适用于 API Key 账号,留空则不修改',
baseUrlNotice: '适用于 API Key 账号及 Grok OAuth 账号的转发端点,留空则不修改',
submit: '批量更新',
updating: '更新中...',
success: '成功更新 {count} 个账号',
@@ -672,6 +672,13 @@ export default {
valuePlaceholder: '覆写值(留空表示不覆写)',
addRow: '添加请求头',
fillTemplate: '填入模板',
importJson: 'JSON 导入',
importJsonPlaceholder: '{"user-agent": "my-client/1.0", "x-relay-token": "..."}',
importJsonApply: '解析并填入',
importJsonCancel: '取消',
importJsonHint: '粘贴扁平 JSON 对象(请求头名 → 值),解析后将整体替换当前列表。',
importJsonInvalid: 'JSON 格式不正确:需要"请求头名 → 字符串值"的扁平对象',
copyJson: '复制为 JSON',
emptyValueHint: '值留空的行不会参与覆盖,仅作为待填写的占位。',
bulkDisableHint: '保存后将关闭所选账号的请求头覆写并清空已有配置。',
bulkReplaceHint: '保存后将用下方配置整体替换所选账号已有的请求头覆写配置。',
@@ -682,6 +689,13 @@ export default {
invalidValue: '请求头值不合法(不允许控制字符,长度不超过 8192)',
tooManyEntries: '请求头覆写条目过多(最多 64 条)'
},
grokCustomBaseUrl: {
title: '自定义上游地址',
hint: '开启后账号流量(对话/媒体/探测)改发指定地址;OAuth 授权与令牌刷新不受影响,仍走官方端点。',
placeholder: 'https://relay.example.com/v1',
required: '开启自定义上游地址后必须填写地址',
invalid: '上游地址格式不正确(需为 http(s):// 开头的完整地址)'
},
autoPauseOnExpired: '过期自动暂停调度',
autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度',
autoPause5hThreshold: '5h 用量阈值(%)',