mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
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:
@@ -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 模式:关闭时删除字段(全量替换语义)。
|
||||
|
||||
@@ -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 (%)',
|
||||
|
||||
@@ -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 用量阈值(%)',
|
||||
|
||||
Reference in New Issue
Block a user