feat: apikey 账号支持请求头覆写(Anthropic/OpenAI)

- 账号 credentials 新增 header_override_enabled / header_overrides,
  仅对 anthropic/openai 平台的 api_key 账号生效
- 转发前对同名请求头做大小写不敏感覆盖(EqualFold 全量删除后按
  wire casing 写入单值),值为空的条目视为占位不参与覆盖
- 覆盖全部出站路径:/v1/messages(标准+透传)、count_tokens、
  /v1/responses、chat completions、embeddings、images、全部 WS 模式、
  账号测试/探测、上游模型列表
- 创建/编辑/批量更新统一校验:RFC 7230 名称格式、去重、长度/条目上限、
  24 个禁止覆写头(认证/连接控制/accept-encoding/sec-websocket-*/
  会话隔离头),应用时二次防御过滤
- 前端三个账号弹窗新增开关+键值行编辑器+模板按钮(Claude Code CLI /
  Codex CLI 标准头,值为空),本地校验与后端规则对齐,i18n 中英文
This commit is contained in:
shaw
2026-07-06 19:11:08 +08:00
parent 6cea1c35bb
commit ec7b20649e
21 changed files with 1526 additions and 3 deletions
@@ -486,6 +486,125 @@
</div>
</div>
<!-- Header Override (anthropic/openai apikey only) -->
<div v-if="allHeaderOverrideCapable" class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="flex items-center justify-between">
<div class="flex-1 pr-4">
<label
id="bulk-edit-header-override-label"
class="input-label mb-0"
for="bulk-edit-header-override-enabled"
>
{{ 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>
<input
v-model="enableHeaderOverride"
id="bulk-edit-header-override-enabled"
type="checkbox"
aria-controls="bulk-edit-header-override-body"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</div>
<div v-if="enableHeaderOverride" id="bulk-edit-header-override-body" class="mt-3 space-y-3">
<button
type="button"
: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'
]"
@click="headerOverrideEnabled = !headerOverrideEnabled"
>
<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 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"
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
@click="removeHeaderOverrideRow(index)"
>
<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"
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"
@click="addHeaderOverrideRow"
>
<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"
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>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.emptyValueHint') }}
</p>
</div>
<p v-else class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.bulkDisableHint') }}
</p>
</div>
</div>
<!-- Proxy -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
@@ -1149,6 +1268,16 @@ import {
buildModelMappingObject as buildModelMappingPayload,
getPresetMappingsByPlatform
} from '@/composables/useModelWhitelist'
import {
buildHeaderOverridesObject,
getHeaderOverrideTemplate,
isHeaderOverridePlatform,
validateHeaderOverrideRows,
HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY,
HEADER_OVERRIDES_CREDENTIAL_KEY,
type HeaderOverrideRow
} from '@/components/account/credentialsBuilder'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
import {
OPENAI_WS_MODE_CTX_POOL,
OPENAI_WS_MODE_OFF,
@@ -1217,6 +1346,16 @@ 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')
)
})
// 是否全部为 Anthropic OAuth/SetupToken(RPM 配置仅在此条件下显示)
const allAnthropicOAuthOrSetupToken = computed(() => {
return (
@@ -1253,6 +1392,7 @@ const enableBaseUrl = ref(false)
const enableModelRestriction = ref(false)
const enableCustomErrorCodes = ref(false)
const enableInterceptWarmup = ref(false)
const enableHeaderOverride = ref(false)
const enableProxy = ref(false)
const enableConcurrency = ref(false)
const enableLoadFactor = ref(false)
@@ -1281,6 +1421,36 @@ const modelMappings = ref<ModelMapping[]>([])
const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false)
const headerOverrideEnabled = ref(false)
const headerOverrideRows = ref<HeaderOverrideRow[]>([])
const getHeaderOverrideRowKey = createStableObjectKeyResolver<HeaderOverrideRow>('bulk-header-override-row')
const addHeaderOverrideRow = () => {
headerOverrideRows.value.push({ name: '', value: '' })
}
const removeHeaderOverrideRow = (index: number) => {
headerOverrideRows.value.splice(index, 1)
}
// 模板按钮:填入标准客户端请求头名称(值留空),跳过已存在的同名行。
// 目标全为 openai 时用 Codex 模板,否则用 Claude Code 模板。
const fillHeaderOverrideTemplate = () => {
const platform =
targetSelectedPlatforms.value.length === 1 && targetSelectedPlatforms.value[0] === 'openai'
? 'openai'
: 'anthropic'
const existing = new Set(
headerOverrideRows.value.map((row) => row.name.trim().toLowerCase()).filter(Boolean)
)
const rows = headerOverrideRows.value.filter((row) => row.name.trim() || row.value.trim())
for (const row of getHeaderOverrideTemplate(platform)) {
if (!existing.has(row.name)) {
rows.push(row)
}
}
headerOverrideRows.value = rows
}
const proxyId = ref<number | null>(null)
const concurrency = ref(1)
const loadFactor = ref<number | null>(null)
@@ -1523,6 +1693,15 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
credentialsChanged = true
}
if (enableHeaderOverride.value) {
// 后端使用 JSONB || merge 语义:关闭时显式写入 false + 空对象以清除旧配置
credentials[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY] = headerOverrideEnabled.value
credentials[HEADER_OVERRIDES_CREDENTIAL_KEY] = headerOverrideEnabled.value
? buildHeaderOverridesObject(headerOverrideRows.value)
: {}
credentialsChanged = true
}
if (enableOpenAIWSMode.value) {
const extra = ensureExtra()
extra.openai_oauth_responses_websockets_v2_mode = openaiOAuthResponsesWebSocketV2Mode.value
@@ -1651,6 +1830,7 @@ const handleSubmit = async () => {
enableModelRestriction.value ||
enableCustomErrorCodes.value ||
enableInterceptWarmup.value ||
enableHeaderOverride.value ||
enableProxy.value ||
enableConcurrency.value ||
enableLoadFactor.value ||
@@ -1672,6 +1852,14 @@ const handleSubmit = async () => {
return
}
if (enableHeaderOverride.value && headerOverrideEnabled.value) {
const headerError = validateHeaderOverrideRows(headerOverrideRows.value)
if (headerError) {
appStore.showError(t(`admin.accounts.headerOverride.${headerError}`))
return
}
}
const built = buildUpdatePayload()
if (!built) {
appStore.showError(t('admin.accounts.bulkEdit.noFieldsSelected'))
@@ -1753,6 +1941,7 @@ watch(
enableModelRestriction.value = false
enableCustomErrorCodes.value = false
enableInterceptWarmup.value = false
enableHeaderOverride.value = false
enableProxy.value = false
enableConcurrency.value = false
enableLoadFactor.value = false
@@ -1778,6 +1967,8 @@ watch(
selectedErrorCodes.value = []
customErrorCodeInput.value = null
interceptWarmupRequests.value = false
headerOverrideEnabled.value = false
headerOverrideRows.value = []
proxyId.value = null
concurrency.value = 1
loadFactor.value = null
@@ -1468,6 +1468,110 @@
</div>
</div>
<!-- Header Override Section (anthropic/openai apikey only) -->
<div
v-if="isHeaderOverridePlatform(form.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 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') }}
</p>
</div>
</div>
</div>
<!-- Bedrock credentials (only for Anthropic Bedrock type) -->
@@ -3328,7 +3432,12 @@ import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
import {
applyAntigravityProjectID,
applyInterceptWarmup
applyHeaderOverride,
applyInterceptWarmup,
getHeaderOverrideTemplate,
isHeaderOverridePlatform,
validateHeaderOverrideRows,
type HeaderOverrideRow
} from '@/components/account/credentialsBuilder'
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
@@ -3512,6 +3621,30 @@ function parsePoolModeRetryStatusCodes(input: string): number[] {
const customErrorCodesEnabled = ref(false)
const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null)
const headerOverrideEnabled = ref(false)
const headerOverrideRows = ref<HeaderOverrideRow[]>([])
const addHeaderOverrideRow = () => {
headerOverrideRows.value.push({ name: '', value: '' })
}
const removeHeaderOverrideRow = (index: number) => {
headerOverrideRows.value.splice(index, 1)
}
// 模板按钮:填入标准客户端请求头名称(值留空),跳过已存在的同名行
const fillHeaderOverrideTemplate = () => {
const existing = new Set(
headerOverrideRows.value.map((row) => row.name.trim().toLowerCase()).filter(Boolean)
)
const rows = headerOverrideRows.value.filter((row) => row.name.trim() || row.value.trim())
for (const row of getHeaderOverrideTemplate(form.platform)) {
if (!existing.has(row.name)) {
rows.push(row)
}
}
headerOverrideRows.value = rows
}
const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(true)
const openaiPassthroughEnabled = ref(false)
@@ -3569,6 +3702,7 @@ const vertexServiceAccountDragActive = ref(false)
const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-model-mapping')
const getHeaderOverrideRowKey = createStableObjectKeyResolver<HeaderOverrideRow>('create-header-override-row')
const getOpenAICompactModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-openai-compact-model-mapping')
const getAntigravityModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-antigravity-model-mapping')
const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>('create-temp-unsched-rule')
@@ -4359,6 +4493,8 @@ const resetForm = () => {
customErrorCodesEnabled.value = false
selectedErrorCodes.value = []
customErrorCodeInput.value = null
headerOverrideEnabled.value = false
headerOverrideRows.value = []
interceptWarmupRequests.value = false
autoPauseOnExpired.value = true
openaiPassthroughEnabled.value = false
@@ -4789,6 +4925,18 @@ const handleSubmit = async () => {
credentials.custom_error_codes = [...selectedErrorCodes.value]
}
// Add header override if enabled (anthropic/openai apikey only)
if (isHeaderOverridePlatform(form.platform)) {
if (headerOverrideEnabled.value) {
const headerError = validateHeaderOverrideRows(headerOverrideRows.value)
if (headerError) {
appStore.showError(t(`admin.accounts.headerOverride.${headerError}`))
return
}
}
applyHeaderOverride(credentials, headerOverrideEnabled.value, headerOverrideRows.value, 'create')
}
applyInterceptWarmup(credentials, interceptWarmupRequests.value, 'create')
if (!applyTempUnschedConfig(credentials)) {
return
@@ -417,6 +417,110 @@
</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 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') }}
</p>
</div>
</div>
</div>
<!-- OpenAI/Grok OAuth Model Mapping (OAuth 类型没有 apikey 容器,需要独立的模型映射区域) -->
@@ -2433,7 +2537,15 @@ import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
import {
applyAntigravityProjectID,
applyInterceptWarmup
applyHeaderOverride,
applyInterceptWarmup,
getHeaderOverrideTemplate,
isHeaderOverridePlatform,
splitHeaderOverridesObject,
validateHeaderOverrideRows,
HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY,
HEADER_OVERRIDES_CREDENTIAL_KEY,
type HeaderOverrideRow
} from '@/components/account/credentialsBuilder'
import { formatDateTime, formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
@@ -2564,6 +2676,30 @@ function formatPoolModeRetryStatusCodes(value: unknown): string {
const customErrorCodesEnabled = ref(false)
const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null)
const headerOverrideEnabled = ref(false)
const headerOverrideRows = ref<HeaderOverrideRow[]>([])
const addHeaderOverrideRow = () => {
headerOverrideRows.value.push({ name: '', value: '' })
}
const removeHeaderOverrideRow = (index: number) => {
headerOverrideRows.value.splice(index, 1)
}
// 模板按钮:填入标准客户端请求头名称(值留空),跳过已存在的同名行
const fillHeaderOverrideTemplate = () => {
const existing = new Set(
headerOverrideRows.value.map((row) => row.name.trim().toLowerCase()).filter(Boolean)
)
const rows = headerOverrideRows.value.filter((row) => row.name.trim() || row.value.trim())
for (const row of getHeaderOverrideTemplate(props.account?.platform || '')) {
if (!existing.has(row.name)) {
rows.push(row)
}
}
headerOverrideRows.value = rows
}
const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(false)
const autoPause5hThreshold = ref<number | null>(null)
@@ -2580,6 +2716,7 @@ const isSyncingAntigravityUpstream = ref(false)
const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-model-mapping')
const getHeaderOverrideRowKey = createStableObjectKeyResolver<HeaderOverrideRow>('edit-header-override-row')
const getOpenAICompactModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-openai-compact-model-mapping')
const getAntigravityModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-antigravity-model-mapping')
const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>('edit-temp-unsched-rule')
@@ -3186,6 +3323,10 @@ const syncFormFromAccount = (newAccount: Account | null) => {
loadTempUnschedRules(credentials)
// Reset header override state (loaded below only for apikey accounts)
headerOverrideEnabled.value = false
headerOverrideRows.value = []
// Initialize API Key fields for apikey type
if (newAccount.type === 'apikey' && newAccount.credentials) {
const credentials = newAccount.credentials as Record<string, unknown>
@@ -3215,6 +3356,14 @@ const syncFormFromAccount = (newAccount: Account | null) => {
} else {
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'
@@ -3850,6 +3999,18 @@ const handleSubmit = async () => {
delete newCredentials.custom_error_codes
}
// Add header override if enabled (anthropic/openai apikey only)
if (isHeaderOverridePlatform(props.account.platform)) {
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')
}
// Add intercept warmup requests setting
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
if (!applyTempUnschedConfig(newCredentials)) {
@@ -1,8 +1,16 @@
import { describe, it, expect } from 'vitest'
import {
ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY,
HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY,
HEADER_OVERRIDES_CREDENTIAL_KEY,
applyAntigravityProjectID,
applyInterceptWarmup
applyHeaderOverride,
applyInterceptWarmup,
buildHeaderOverridesObject,
getHeaderOverrideTemplate,
isHeaderOverridePlatform,
splitHeaderOverridesObject,
validateHeaderOverrideRows
} from '../credentialsBuilder'
describe('applyInterceptWarmup', () => {
@@ -82,3 +90,185 @@ describe('applyAntigravityProjectID', () => {
expect(creds[ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY]).toBe('configured-project')
})
})
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('validateHeaderOverrideRows', () => {
it('accepts valid rows and empty placeholder rows', () => {
expect(
validateHeaderOverrideRows([
{ name: 'user-agent', value: 'my-agent/1.0' },
{ name: 'x-app', value: '' },
{ name: '', value: '' }
])
).toBeNull()
})
it('rejects empty name with non-empty value', () => {
expect(validateHeaderOverrideRows([{ name: '', value: 'v' }])).toBe('invalidName')
})
it('rejects invalid header names', () => {
expect(validateHeaderOverrideRows([{ name: 'bad name', value: '' }])).toBe('invalidName')
expect(validateHeaderOverrideRows([{ name: 'bad:name', value: '' }])).toBe('invalidName')
expect(validateHeaderOverrideRows([{ name: '名称', value: '' }])).toBe('invalidName')
})
it('rejects blocked header names case-insensitively', () => {
expect(validateHeaderOverrideRows([{ name: 'Authorization', value: '' }])).toBe('blockedName')
expect(validateHeaderOverrideRows([{ name: 'X-Api-Key', value: '' }])).toBe('blockedName')
expect(validateHeaderOverrideRows([{ name: 'host', value: '' }])).toBe('blockedName')
expect(validateHeaderOverrideRows([{ name: 'Content-Length', value: '' }])).toBe('blockedName')
})
it('rejects duplicate names case-insensitively', () => {
expect(
validateHeaderOverrideRows([
{ name: 'User-Agent', value: 'a' },
{ name: 'user-agent', value: 'b' }
])
).toBe('duplicateName')
})
})
describe('buildHeaderOverridesObject / splitHeaderOverridesObject', () => {
it('lowercases names, trims values and drops empty-name rows', () => {
expect(
buildHeaderOverridesObject([
{ name: ' User-Agent ', value: ' my-agent ' },
{ name: 'X-App', value: '' },
{ name: '', value: 'ignored' }
])
).toEqual({ 'user-agent': 'my-agent', 'x-app': '' })
})
it('splits an object into sorted rows and ignores non-string values', () => {
expect(
splitHeaderOverridesObject({ 'x-app': 'cli', 'user-agent': 'ua', bogus: 42 })
).toEqual([
{ name: 'user-agent', value: 'ua' },
{ name: 'x-app', value: 'cli' }
])
expect(splitHeaderOverridesObject(null)).toEqual([])
expect(splitHeaderOverridesObject(['a'])).toEqual([])
expect(splitHeaderOverridesObject('str')).toEqual([])
})
it('roundtrips through build and split', () => {
const rows = [
{ name: 'user-agent', value: 'ua' },
{ name: 'x-app', value: 'cli' }
]
expect(splitHeaderOverridesObject(buildHeaderOverridesObject(rows))).toEqual(rows)
})
})
describe('getHeaderOverrideTemplate', () => {
it('returns Claude Code CLI headers with empty values for anthropic', () => {
const rows = getHeaderOverrideTemplate('anthropic')
expect(rows.every((r) => r.value === '')).toBe(true)
const names = rows.map((r) => r.name)
expect(names).toContain('user-agent')
expect(names).toContain('x-app')
expect(names).toContain('anthropic-beta')
expect(names).toContain('x-stainless-lang')
expect(validateHeaderOverrideRows(rows)).toBeNull()
})
it('returns Codex CLI headers with empty values for openai', () => {
const rows = getHeaderOverrideTemplate('openai')
expect(rows.every((r) => r.value === '')).toBe(true)
const names = rows.map((r) => r.name)
expect(names).toContain('user-agent')
expect(names).toContain('originator')
expect(names).toContain('openai-beta')
expect(validateHeaderOverrideRows(rows)).toBeNull()
})
})
describe('applyHeaderOverride', () => {
it('create + enabled: writes enabled flag and overrides object', () => {
const creds: Record<string, unknown> = { api_key: 'sk' }
applyHeaderOverride(creds, true, [{ name: 'User-Agent', value: 'ua' }], 'create')
expect(creds[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY]).toBe(true)
expect(creds[HEADER_OVERRIDES_CREDENTIAL_KEY]).toEqual({ 'user-agent': 'ua' })
})
it('create + disabled: does not add fields', () => {
const creds: Record<string, unknown> = { api_key: 'sk' }
applyHeaderOverride(creds, false, [{ name: 'user-agent', value: 'ua' }], 'create')
expect(HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY in creds).toBe(false)
expect(HEADER_OVERRIDES_CREDENTIAL_KEY in creds).toBe(false)
})
it('edit + disabled: deletes existing fields', () => {
const creds: Record<string, unknown> = {
api_key: 'sk',
[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY]: true,
[HEADER_OVERRIDES_CREDENTIAL_KEY]: { 'user-agent': 'ua' }
}
applyHeaderOverride(creds, false, [], 'edit')
expect(HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY in creds).toBe(false)
expect(HEADER_OVERRIDES_CREDENTIAL_KEY in creds).toBe(false)
expect(creds.api_key).toBe('sk')
})
it('edit + enabled: replaces overrides object wholesale', () => {
const creds: Record<string, unknown> = {
[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY]: true,
[HEADER_OVERRIDES_CREDENTIAL_KEY]: { 'x-old': 'old' }
}
applyHeaderOverride(creds, true, [{ name: 'x-new', value: 'new' }], 'edit')
expect(creds[HEADER_OVERRIDES_CREDENTIAL_KEY]).toEqual({ 'x-new': 'new' })
})
})
describe('validateHeaderOverrideRows value/entry limits', () => {
it('rejects websocket handshake headers', () => {
expect(validateHeaderOverrideRows([{ name: 'Sec-WebSocket-Key', value: '' }])).toBe(
'blockedName'
)
})
it('rejects control characters in values', () => {
expect(validateHeaderOverrideRows([{ name: 'x-app', value: 'a\x0bb' }])).toBe('invalidValue')
})
it('rejects oversized values', () => {
expect(validateHeaderOverrideRows([{ name: 'x-app', value: 'a'.repeat(8193) }])).toBe(
'invalidValue'
)
})
it('rejects too many entries', () => {
const rows = Array.from({ length: 65 }, (_, i) => ({ name: `x-h-${i}`, value: 'v' }))
expect(validateHeaderOverrideRows(rows)).toBe('tooManyEntries')
})
})
describe('validateHeaderOverrideRows session isolation headers', () => {
it('rejects per-request session headers', () => {
expect(validateHeaderOverrideRows([{ name: 'session_id', value: '' }])).toBe('blockedName')
expect(validateHeaderOverrideRows([{ name: 'Conversation_ID', value: '' }])).toBe('blockedName')
expect(validateHeaderOverrideRows([{ name: 'x-codex-turn-state', value: '' }])).toBe(
'blockedName'
)
})
it('allows tab inside value', () => {
expect(validateHeaderOverrideRows([{ name: 'x-app', value: 'a\tb' }])).toBeNull()
})
it('rejects oversized names', () => {
expect(validateHeaderOverrideRows([{ name: 'x'.repeat(201), value: 'v' }])).toBe('invalidName')
})
})
@@ -24,3 +24,170 @@ export function applyAntigravityProjectID(
delete credentials[ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY]
}
}
// ========== 请求头覆写(仅 anthropic/openai 平台的 api_key 账号) ==========
export const HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY = 'header_override_enabled'
export const HEADER_OVERRIDES_CREDENTIAL_KEY = 'header_overrides'
export interface HeaderOverrideRow {
name: string
value: string
}
/** 请求头覆写支持的平台(与后端 IsHeaderOverrideEligible 保持一致) */
export function isHeaderOverridePlatform(platform: string): boolean {
return platform === 'anthropic' || platform === 'openai'
}
/** 禁止覆写的请求头(与后端 headerOverrideBlockedNames 保持一致) */
const HEADER_OVERRIDE_BLOCKED_NAMES = new Set([
'host',
'content-length',
'transfer-encoding',
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'proxy-connection',
'te',
'trailer',
'upgrade',
'authorization',
'x-api-key',
'accept-encoding',
'sec-websocket-key',
'sec-websocket-version',
'sec-websocket-extensions',
'sec-websocket-protocol',
'sec-websocket-accept',
'session_id',
'conversation_id',
'x-codex-turn-state',
'x-codex-turn-metadata',
'chatgpt-account-id'
])
/** RFC 7230 token:合法的 HTTP header 名称字符集 */
const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
export function isValidHeaderOverrideName(name: string): boolean {
return HEADER_NAME_PATTERN.test(name)
}
export function isBlockedHeaderOverrideName(name: string): boolean {
return HEADER_OVERRIDE_BLOCKED_NAMES.has(name.trim().toLowerCase())
}
/** 模板:Claude Code CLI 标准客户端请求头(值留空由管理员填写) */
const ANTHROPIC_HEADER_OVERRIDE_TEMPLATE = [
'user-agent',
'x-app',
'anthropic-beta',
'anthropic-version',
'anthropic-dangerous-direct-browser-access',
'x-stainless-lang',
'x-stainless-package-version',
'x-stainless-os',
'x-stainless-arch',
'x-stainless-runtime',
'x-stainless-runtime-version',
'x-stainless-retry-count',
'x-stainless-timeout'
]
/** 模板:Codex CLI 标准客户端请求头(值留空由管理员填写) */
const OPENAI_HEADER_OVERRIDE_TEMPLATE = [
'user-agent',
'originator',
'openai-beta',
'version',
'accept',
'accept-language'
]
export function getHeaderOverrideTemplate(platform: string): HeaderOverrideRow[] {
const names =
platform === 'openai' ? OPENAI_HEADER_OVERRIDE_TEMPLATE : ANTHROPIC_HEADER_OVERRIDE_TEMPLATE
return names.map((name) => ({ name, value: '' }))
}
/** 与后端 maxHeaderOverride* 常量保持一致 */
const HEADER_OVERRIDE_MAX_ENTRIES = 64
const HEADER_OVERRIDE_MAX_NAME_LENGTH = 200
const HEADER_OVERRIDE_MAX_VALUE_LENGTH = 8192
/** header value 不允许包含控制字符(与后端 httpguts.ValidHeaderFieldValue 对齐) */
// eslint-disable-next-line no-control-regex
const HEADER_VALUE_INVALID_PATTERN = /[\x00-\x08\x0a-\x1f\x7f]/
/**
* 校验请求头覆写行,返回首个错误的 i18n key(无错误返回 null)。
* 名称为空但值非空 → invalidName;名称非法 → invalidName;
* 禁止覆写 → blockedName;大小写不敏感重名 → duplicateName;
* 值含控制字符或超长 → invalidValue;条目过多 → tooManyEntries。
*/
export function validateHeaderOverrideRows(
rows: HeaderOverrideRow[]
): 'invalidName' | 'blockedName' | 'duplicateName' | 'invalidValue' | 'tooManyEntries' | null {
const seen = new Set<string>()
for (const row of rows) {
const name = row.name.trim()
const value = row.value.trim()
if (!name) {
if (value) return 'invalidName'
continue
}
if (!isValidHeaderOverrideName(name) || name.length > HEADER_OVERRIDE_MAX_NAME_LENGTH) {
return 'invalidName'
}
const lower = name.toLowerCase()
if (HEADER_OVERRIDE_BLOCKED_NAMES.has(lower)) return 'blockedName'
if (seen.has(lower)) return 'duplicateName'
if (HEADER_VALUE_INVALID_PATTERN.test(value) || value.length > HEADER_OVERRIDE_MAX_VALUE_LENGTH) {
return 'invalidValue'
}
seen.add(lower)
}
if (seen.size > HEADER_OVERRIDE_MAX_ENTRIES) return 'tooManyEntries'
return null
}
/** 行数组 → credentials 存储对象(名称小写化,丢弃空行) */
export function buildHeaderOverridesObject(rows: HeaderOverrideRow[]): Record<string, string> {
const result: Record<string, string> = {}
for (const row of rows) {
const name = row.name.trim().toLowerCase()
if (!name) continue
result[name] = row.value.trim()
}
return result
}
/** credentials 存储对象 → 行数组(按名称排序保证稳定展示) */
export function splitHeaderOverridesObject(record: unknown): HeaderOverrideRow[] {
if (!record || typeof record !== 'object' || Array.isArray(record)) return []
return Object.entries(record as Record<string, unknown>)
.filter(([, value]) => typeof value === 'string')
.map(([name, value]) => ({ name, value: value as string }))
.sort((a, b) => a.name.localeCompare(b.name))
}
/**
* 将请求头覆写写入 credentials。
* create 模式:关闭时不写入任何字段;edit 模式:关闭时删除字段(全量替换语义)。
*/
export function applyHeaderOverride(
credentials: Record<string, unknown>,
enabled: boolean,
rows: HeaderOverrideRow[],
mode: 'create' | 'edit'
): void {
if (enabled) {
credentials[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY] = true
credentials[HEADER_OVERRIDES_CREDENTIAL_KEY] = buildHeaderOverridesObject(rows)
} else if (mode === 'edit') {
delete credentials[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY]
delete credentials[HEADER_OVERRIDES_CREDENTIAL_KEY]
}
}
+16
View File
@@ -3674,6 +3674,22 @@ export default {
interceptWarmupRequests: 'Intercept Warmup Requests',
interceptWarmupRequestsDesc:
'When enabled, warmup requests like title generation will return mock responses without consuming upstream tokens',
headerOverride: {
title: 'Header Override',
hint: 'Override same-named request headers on forwarding (case-insensitive)',
info: 'Applies to outbound requests of this account only: configured headers override client/gateway-generated headers of the same name before forwarding. Auth headers (authorization, x-api-key) and connection-control headers cannot be overridden.',
namePlaceholder: 'Header name (e.g. user-agent)',
valuePlaceholder: 'Override value (leave empty to skip)',
addRow: 'Add Header',
fillTemplate: 'Fill Template',
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.',
invalidName: 'Invalid header name (only letters, digits and !#$%&\'*+-.^_`|~ are allowed)',
blockedName: 'This header cannot be overridden (auth and connection-control headers are managed by the system)',
duplicateName: 'Duplicate header name (matching is case-insensitive)',
invalidValue: 'Invalid header value (control characters are not allowed; max length 8192)',
tooManyEntries: 'Too many header override entries (max 64)'
},
autoPauseOnExpired: 'Auto Pause On Expired',
autoPauseOnExpiredDesc: 'When enabled, the account will auto pause scheduling after it expires',
autoPause5hThreshold: '5h Usage Threshold (%)',
+16
View File
@@ -3842,6 +3842,22 @@ export default {
errorCodeExists: '该错误码已被选中',
interceptWarmupRequests: '拦截预热请求',
interceptWarmupRequestsDesc: '启用后,标题生成等预热请求将返回 mock 响应,不消耗上游 token',
headerOverride: {
title: '请求头覆写',
hint: '转发时用配置值覆盖同名请求头(不区分大小写)',
info: '仅对本账号的出站请求生效:配置的请求头会在转发前覆盖客户端/网关生成的同名头。认证头(authorization、x-api-key)与连接控制头不允许覆写。',
namePlaceholder: '请求头名称(如 user-agent)',
valuePlaceholder: '覆写值(留空表示不覆写)',
addRow: '添加请求头',
fillTemplate: '填入模板',
emptyValueHint: '值留空的行不会参与覆盖,仅作为待填写的占位。',
bulkDisableHint: '保存后将关闭所选账号的请求头覆写并清空已有配置。',
invalidName: '请求头名称格式不正确(仅允许字母、数字和 !#$%&\'*+-.^_`|~ 字符)',
blockedName: '该请求头不允许覆写(认证头与连接控制头由系统管理)',
duplicateName: '存在重复的请求头名称(匹配不区分大小写)',
invalidValue: '请求头值不合法(不允许控制字符,长度不超过 8192)',
tooManyEntries: '请求头覆写条目过多(最多 64 条)'
},
autoPauseOnExpired: '过期自动暂停调度',
autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度',
autoPause5hThreshold: '5h 用量阈值(%)',