From ec7b20649e4fa5b0482f4520045bd38b99433cae Mon Sep 17 00:00:00 2001
From: shaw
Date: Mon, 6 Jul 2026 19:11:08 +0800
Subject: [PATCH 1/2] =?UTF-8?q?feat:=20apikey=20=E8=B4=A6=E5=8F=B7?=
=?UTF-8?q?=E6=94=AF=E6=8C=81=E8=AF=B7=E6=B1=82=E5=A4=B4=E8=A6=86=E5=86=99?=
=?UTF-8?q?=EF=BC=88Anthropic/OpenAI=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 账号 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 中英文
---
.../service/account_header_override.go | 226 ++++++++++++
.../service/account_header_override_test.go | 335 ++++++++++++++++++
.../internal/service/account_test_service.go | 15 +
backend/internal/service/admin_service.go | 14 +
backend/internal/service/gateway_service.go | 13 +
.../service/openai_apikey_responses_probe.go | 3 +
backend/internal/service/openai_embeddings.go | 3 +
.../openai_gateway_chat_completions_raw.go | 3 +
.../service/openai_gateway_count_tokens.go | 3 +
.../openai_gateway_responses_chat_fallback.go | 3 +
.../service/openai_gateway_service.go | 6 +
backend/internal/service/openai_images.go | 2 +
.../internal/service/openai_ws_forwarder.go | 4 +
backend/internal/service/upstream_models.go | 4 +
.../account/BulkEditAccountModal.vue | 191 ++++++++++
.../components/account/CreateAccountModal.vue | 150 +++++++-
.../components/account/EditAccountModal.vue | 163 ++++++++-
.../__tests__/credentialsBuilder.spec.ts | 192 +++++++++-
.../components/account/credentialsBuilder.ts | 167 +++++++++
frontend/src/i18n/locales/en.ts | 16 +
frontend/src/i18n/locales/zh.ts | 16 +
21 files changed, 1526 insertions(+), 3 deletions(-)
create mode 100644 backend/internal/service/account_header_override.go
create mode 100644 backend/internal/service/account_header_override_test.go
diff --git a/backend/internal/service/account_header_override.go b/backend/internal/service/account_header_override.go
new file mode 100644
index 0000000000..80c32b648d
--- /dev/null
+++ b/backend/internal/service/account_header_override.go
@@ -0,0 +1,226 @@
+package service
+
+import (
+ "net/http"
+ "sort"
+ "strings"
+
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+
+ "golang.org/x/net/http/httpguts"
+)
+
+// 请求头覆写(header override):仅对 Anthropic / OpenAI 平台的 api_key 账号生效。
+// 管理员在账号上配置一组 header name -> value,转发到上游前用配置值覆盖同名请求头
+// (匹配不区分大小写);value 为空的条目视为"未填写",不参与覆盖。
+const (
+ credKeyHeaderOverrideEnabled = "header_override_enabled"
+ credKeyHeaderOverrides = "header_overrides"
+
+ maxHeaderOverrideEntries = 64
+ maxHeaderOverrideNameLength = 200
+ maxHeaderOverrideValueLength = 8192
+)
+
+// headerOverrideBlockedNames 禁止覆写的请求头(小写)。
+// - 连接控制/逐跳头:由 HTTP 栈管理,覆写会破坏请求传输;
+// - host/content-length:由 Go 的 Request.Host / ContentLength 字段管理,header 覆写不生效或产生冲突;
+// - authorization/x-api-key:上游认证头由账号凭据统一注入,禁止通过覆写篡改;
+// - accept-encoding:强制压缩会破坏网关对上游流式响应(SSE/usage)的解析;
+// - sec-websocket-*:WebSocket 握手头由拨号器管理(OpenAI WS 模式);
+// - session_id/conversation_id 等:逐请求会话隔离头,固定值会造成会话串扰。
+var headerOverrideBlockedNames = map[string]struct{}{
+ "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": {},
+}
+
+func isHeaderOverrideBlockedName(lowerName string) bool {
+ _, blocked := headerOverrideBlockedNames[lowerName]
+ return blocked
+}
+
+// IsHeaderOverrideEligible 报告账号类型是否支持请求头覆写。
+// 目前仅开放 Anthropic / OpenAI 两个平台的 api_key 账号。
+func (a *Account) IsHeaderOverrideEligible() bool {
+ if a == nil || a.Type != AccountTypeAPIKey {
+ return false
+ }
+ return a.Platform == PlatformAnthropic || a.Platform == PlatformOpenAI
+}
+
+// IsHeaderOverrideEnabled 报告账号是否启用了请求头覆写。
+func (a *Account) IsHeaderOverrideEnabled() bool {
+ if !a.IsHeaderOverrideEligible() || a.Credentials == nil {
+ return false
+ }
+ enabled, ok := a.Credentials[credKeyHeaderOverrideEnabled].(bool)
+ return ok && enabled
+}
+
+// GetHeaderOverrides 返回生效的请求头覆写表(key 统一小写)。
+// 未启用、不符合平台/类型条件或配置为空时返回 nil。
+// 空 value 的条目(模板占位)与非法/禁止的 header 名会被跳过。
+func (a *Account) GetHeaderOverrides() map[string]string {
+ if !a.IsHeaderOverrideEnabled() {
+ return nil
+ }
+ raw := stringMappingFromRaw(a.Credentials[credKeyHeaderOverrides])
+ if len(raw) == 0 {
+ return nil
+ }
+ result := make(map[string]string, len(raw))
+ for name, value := range raw {
+ lowerName := strings.ToLower(strings.TrimSpace(name))
+ value = strings.TrimSpace(value)
+ if lowerName == "" || value == "" {
+ continue
+ }
+ // 防御性过滤:保存路径已做校验,这里兜底未经 Normalize 落库的数据
+ if len(lowerName) > maxHeaderOverrideNameLength || len(value) > maxHeaderOverrideValueLength {
+ continue
+ }
+ if isHeaderOverrideBlockedName(lowerName) {
+ continue
+ }
+ if !httpguts.ValidHeaderFieldName(lowerName) || !httpguts.ValidHeaderFieldValue(value) {
+ continue
+ }
+ result[lowerName] = value
+ }
+ if len(result) == 0 {
+ return nil
+ }
+ return result
+}
+
+// ApplyHeaderOverrides 将账号配置的请求头覆写应用到出站请求头。
+// 对每个覆写条目:先删除所有大小写变体(转发链路会以 wire casing 直接写入 map,
+// 可能存在非 canonical key),再按已知 wire casing 写入,避免产生重复头。
+// 账号未启用或不符合条件时为 no-op,可安全地在 OAuth/api_key 共用的构建器中调用。
+func (a *Account) ApplyHeaderOverrides(h http.Header) {
+ if h == nil {
+ return
+ }
+ overrides := a.GetHeaderOverrides()
+ if len(overrides) == 0 {
+ return
+ }
+ names := make([]string, 0, len(overrides))
+ for name := range overrides {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ for _, name := range names {
+ for existing := range h {
+ if strings.EqualFold(existing, name) {
+ delete(h, existing)
+ }
+ }
+ h[resolveWireCasing(name)] = []string{overrides[name]}
+ }
+}
+
+// NormalizeHeaderOverrideCredentials 校验并原地规范化 credentials 中的请求头覆写字段。
+// 供账号创建/更新/批量更新的保存路径调用;credentials 未携带相关字段时为 no-op。
+// 规范化内容:header 名转小写并去除首尾空白,value 去除首尾空白,丢弃名和值均为空的条目。
+func NormalizeHeaderOverrideCredentials(credentials map[string]any) error {
+ if credentials == nil {
+ return nil
+ }
+ if raw, ok := credentials[credKeyHeaderOverrideEnabled]; ok && raw != nil {
+ if _, isBool := raw.(bool); !isBool {
+ return infraerrors.New(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "header_override_enabled must be a boolean")
+ }
+ }
+ raw, ok := credentials[credKeyHeaderOverrides]
+ if !ok || raw == nil {
+ return nil
+ }
+
+ var entries map[string]any
+ switch m := raw.(type) {
+ case map[string]any:
+ entries = m
+ case map[string]string:
+ entries = make(map[string]any, len(m))
+ for k, v := range m {
+ entries[k] = v
+ }
+ default:
+ return infraerrors.New(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "header_overrides must be an object of header name to string value")
+ }
+
+ if len(entries) > maxHeaderOverrideEntries {
+ return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "header_overrides supports at most %d entries", maxHeaderOverrideEntries)
+ }
+
+ normalized := make(map[string]any, len(entries))
+ for name, rawValue := range entries {
+ value, isString := rawValue.(string)
+ if !isString {
+ return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "header %q value must be a string", name)
+ }
+ lowerName := strings.ToLower(strings.TrimSpace(name))
+ value = strings.TrimSpace(value)
+ if lowerName == "" {
+ if value == "" {
+ continue // 丢弃完全为空的占位行
+ }
+ return infraerrors.New(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "header name must not be empty")
+ }
+ if len(lowerName) > maxHeaderOverrideNameLength {
+ return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "header name %q exceeds %d characters", lowerName, maxHeaderOverrideNameLength)
+ }
+ if !httpguts.ValidHeaderFieldName(lowerName) {
+ return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "invalid header name %q", lowerName)
+ }
+ if isHeaderOverrideBlockedName(lowerName) {
+ return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "header %q is not allowed to be overridden", lowerName)
+ }
+ if len(value) > maxHeaderOverrideValueLength {
+ return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "header %q value exceeds %d characters", lowerName, maxHeaderOverrideValueLength)
+ }
+ if !httpguts.ValidHeaderFieldValue(value) {
+ return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "header %q has an invalid value", lowerName)
+ }
+ if _, dup := normalized[lowerName]; dup {
+ return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "duplicate header name %q (matching is case-insensitive)", lowerName)
+ }
+ normalized[lowerName] = value
+ }
+ credentials[credKeyHeaderOverrides] = normalized
+ return nil
+}
diff --git a/backend/internal/service/account_header_override_test.go b/backend/internal/service/account_header_override_test.go
new file mode 100644
index 0000000000..56c95b3fcb
--- /dev/null
+++ b/backend/internal/service/account_header_override_test.go
@@ -0,0 +1,335 @@
+//go:build unit
+
+package service
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func headerOverrideTestAccount(platform, accountType string, credentials map[string]any) *Account {
+ return &Account{
+ Platform: platform,
+ Type: accountType,
+ Credentials: credentials,
+ }
+}
+
+func TestIsHeaderOverrideEligible(t *testing.T) {
+ tests := []struct {
+ name string
+ platform string
+ accType string
+ want bool
+ }{
+ {"anthropic apikey", PlatformAnthropic, AccountTypeAPIKey, true},
+ {"openai apikey", PlatformOpenAI, AccountTypeAPIKey, true},
+ {"anthropic oauth", PlatformAnthropic, AccountTypeOAuth, false},
+ {"openai oauth", PlatformOpenAI, AccountTypeOAuth, false},
+ {"gemini apikey", PlatformGemini, AccountTypeAPIKey, false},
+ {"grok apikey", PlatformGrok, AccountTypeAPIKey, false},
+ {"antigravity apikey", PlatformAntigravity, AccountTypeAPIKey, false},
+ {"anthropic bedrock", PlatformAnthropic, AccountTypeBedrock, false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ acc := headerOverrideTestAccount(tt.platform, tt.accType, nil)
+ require.Equal(t, tt.want, acc.IsHeaderOverrideEligible())
+ })
+ }
+
+ var nilAccount *Account
+ require.False(t, nilAccount.IsHeaderOverrideEligible())
+ require.False(t, nilAccount.IsHeaderOverrideEnabled())
+ require.Nil(t, nilAccount.GetHeaderOverrides())
+}
+
+func TestIsHeaderOverrideEnabled(t *testing.T) {
+ acc := headerOverrideTestAccount(PlatformAnthropic, AccountTypeAPIKey, map[string]any{
+ credKeyHeaderOverrideEnabled: true,
+ })
+ require.True(t, acc.IsHeaderOverrideEnabled())
+
+ // 未配置 / 非 bool / false 均视为未启用
+ require.False(t, headerOverrideTestAccount(PlatformAnthropic, AccountTypeAPIKey, nil).IsHeaderOverrideEnabled())
+ require.False(t, headerOverrideTestAccount(PlatformAnthropic, AccountTypeAPIKey, map[string]any{
+ credKeyHeaderOverrideEnabled: "true",
+ }).IsHeaderOverrideEnabled())
+ require.False(t, headerOverrideTestAccount(PlatformAnthropic, AccountTypeAPIKey, map[string]any{
+ credKeyHeaderOverrideEnabled: false,
+ }).IsHeaderOverrideEnabled())
+
+ // 不符合平台/类型条件时即使配置了 true 也不启用
+ require.False(t, headerOverrideTestAccount(PlatformAnthropic, AccountTypeOAuth, map[string]any{
+ credKeyHeaderOverrideEnabled: true,
+ }).IsHeaderOverrideEnabled())
+ require.False(t, headerOverrideTestAccount(PlatformGemini, AccountTypeAPIKey, map[string]any{
+ credKeyHeaderOverrideEnabled: true,
+ }).IsHeaderOverrideEnabled())
+}
+
+func TestGetHeaderOverrides(t *testing.T) {
+ acc := headerOverrideTestAccount(PlatformOpenAI, AccountTypeAPIKey, map[string]any{
+ credKeyHeaderOverrideEnabled: true,
+ credKeyHeaderOverrides: map[string]any{
+ "User-Agent": "my-agent/1.0", // 大写 key 归一化为小写
+ " X-App ": "cli", // 名称去空白
+ "x-empty": "", // 空 value(模板占位)跳过
+ "authorization": "Bearer leaked", // 禁止覆写的头跳过
+ "bad name": "value", // 非法 header 名跳过
+ "x-padded": " padded ", // value 去空白
+ },
+ })
+ overrides := acc.GetHeaderOverrides()
+ require.Equal(t, map[string]string{
+ "user-agent": "my-agent/1.0",
+ "x-app": "cli",
+ "x-padded": "padded",
+ }, overrides)
+
+ // 未启用时返回 nil
+ disabled := headerOverrideTestAccount(PlatformOpenAI, AccountTypeAPIKey, map[string]any{
+ credKeyHeaderOverrides: map[string]any{"user-agent": "x"},
+ })
+ require.Nil(t, disabled.GetHeaderOverrides())
+
+ // 启用但全部为空 value 时返回 nil
+ empty := headerOverrideTestAccount(PlatformOpenAI, AccountTypeAPIKey, map[string]any{
+ credKeyHeaderOverrideEnabled: true,
+ credKeyHeaderOverrides: map[string]any{"user-agent": ""},
+ })
+ require.Nil(t, empty.GetHeaderOverrides())
+
+ // 未经 Normalize 落库的超长数据 / WebSocket 握手头在应用时被防御性跳过
+ oversizedValue := strings.Repeat("a", maxHeaderOverrideValueLength+1)
+ defensive := headerOverrideTestAccount(PlatformOpenAI, AccountTypeAPIKey, map[string]any{
+ credKeyHeaderOverrideEnabled: true,
+ credKeyHeaderOverrides: map[string]any{
+ "x-big": oversizedValue,
+ "sec-websocket-key": "forged",
+ "x-ok": "ok",
+ },
+ })
+ require.Equal(t, map[string]string{"x-ok": "ok"}, defensive.GetHeaderOverrides())
+}
+
+func TestApplyHeaderOverrides(t *testing.T) {
+ acc := headerOverrideTestAccount(PlatformAnthropic, AccountTypeAPIKey, map[string]any{
+ credKeyHeaderOverrideEnabled: true,
+ credKeyHeaderOverrides: map[string]any{
+ "user-agent": "override-agent/2.0",
+ "anthropic-beta": "custom-beta-1",
+ "x-custom": "custom-value",
+ },
+ })
+
+ h := http.Header{}
+ // 模拟转发链路:canonical key 与 wire casing 原样 key 混合存在
+ h.Set("User-Agent", "claude-cli/2.1.161 (external, cli)")
+ h["anthropic-beta"] = []string{"claude-code-20250219,oauth-2025-04-20"} // 非 canonical 原样 key
+ h.Set("Content-Type", "application/json")
+
+ acc.ApplyHeaderOverrides(h)
+
+ // user-agent 覆盖且只有一个值(已知头恢复 wire casing)
+ require.Equal(t, []string{"override-agent/2.0"}, h["User-Agent"])
+ // anthropic-beta:非 canonical 旧值被清除,写入 wire casing(小写)
+ require.Equal(t, []string{"custom-beta-1"}, h["anthropic-beta"])
+ require.Empty(t, h["Anthropic-Beta"])
+ // 新增头(未知头以小写原样键写入,与转发链路 wire casing 约定一致)
+ require.Equal(t, []string{"custom-value"}, h["x-custom"])
+ require.Equal(t, "custom-value", getHeaderRaw(h, "x-custom"))
+ // 未覆写的头不受影响
+ require.Equal(t, "application/json", h.Get("Content-Type"))
+
+ // 覆盖后不存在任何大小写重复
+ count := 0
+ for k := range h {
+ if k == "anthropic-beta" || k == "Anthropic-Beta" {
+ count++
+ }
+ }
+ require.Equal(t, 1, count)
+}
+
+func TestApplyHeaderOverridesNoOpPaths(t *testing.T) {
+ baseline := func() http.Header {
+ h := http.Header{}
+ h.Set("User-Agent", "orig")
+ return h
+ }
+
+ // OAuth 账号:即使配置了覆写也不生效
+ oauth := headerOverrideTestAccount(PlatformAnthropic, AccountTypeOAuth, map[string]any{
+ credKeyHeaderOverrideEnabled: true,
+ credKeyHeaderOverrides: map[string]any{"user-agent": "hacked"},
+ })
+ h := baseline()
+ oauth.ApplyHeaderOverrides(h)
+ require.Equal(t, "orig", h.Get("User-Agent"))
+
+ // 未启用开关
+ off := headerOverrideTestAccount(PlatformAnthropic, AccountTypeAPIKey, map[string]any{
+ credKeyHeaderOverrides: map[string]any{"user-agent": "hacked"},
+ })
+ h = baseline()
+ off.ApplyHeaderOverrides(h)
+ require.Equal(t, "orig", h.Get("User-Agent"))
+
+ // 禁止覆写的头(authorization / x-api-key / host 等)不会被应用
+ blocked := headerOverrideTestAccount(PlatformOpenAI, AccountTypeAPIKey, map[string]any{
+ credKeyHeaderOverrideEnabled: true,
+ credKeyHeaderOverrides: map[string]any{
+ "Authorization": "Bearer evil",
+ "X-Api-Key": "evil",
+ "Host": "evil.example.com",
+ "Content-Length": "0",
+ },
+ })
+ h = http.Header{}
+ h.Set("Authorization", "Bearer real-key")
+ blocked.ApplyHeaderOverrides(h)
+ require.Equal(t, "Bearer real-key", h.Get("Authorization"))
+ require.Empty(t, h.Get("X-Api-Key"))
+ require.Empty(t, h.Get("Host"))
+
+ // nil header 不 panic
+ blocked.ApplyHeaderOverrides(nil)
+}
+
+func TestNormalizeHeaderOverrideCredentials(t *testing.T) {
+ t.Run("nil credentials no-op", func(t *testing.T) {
+ require.NoError(t, NormalizeHeaderOverrideCredentials(nil))
+ })
+
+ t.Run("missing keys no-op", func(t *testing.T) {
+ creds := map[string]any{"api_key": "sk-xxx"}
+ require.NoError(t, NormalizeHeaderOverrideCredentials(creds))
+ _, exists := creds[credKeyHeaderOverrides]
+ require.False(t, exists)
+ })
+
+ t.Run("normalizes names and values", func(t *testing.T) {
+ creds := map[string]any{
+ credKeyHeaderOverrideEnabled: true,
+ credKeyHeaderOverrides: map[string]any{
+ " User-Agent ": " my-agent ",
+ "X-App": "",
+ "": "", // 完全空行被丢弃
+ },
+ }
+ require.NoError(t, NormalizeHeaderOverrideCredentials(creds))
+ require.Equal(t, map[string]any{
+ "user-agent": "my-agent",
+ "x-app": "",
+ }, creds[credKeyHeaderOverrides])
+ })
+
+ t.Run("accepts map[string]string input", func(t *testing.T) {
+ creds := map[string]any{
+ credKeyHeaderOverrides: map[string]string{"X-App": "cli"},
+ }
+ require.NoError(t, NormalizeHeaderOverrideCredentials(creds))
+ require.Equal(t, map[string]any{"x-app": "cli"}, creds[credKeyHeaderOverrides])
+ })
+
+ t.Run("rejects non-bool enabled", func(t *testing.T) {
+ err := NormalizeHeaderOverrideCredentials(map[string]any{
+ credKeyHeaderOverrideEnabled: "yes",
+ })
+ require.Error(t, err)
+ })
+
+ t.Run("rejects non-object overrides", func(t *testing.T) {
+ err := NormalizeHeaderOverrideCredentials(map[string]any{
+ credKeyHeaderOverrides: []any{"user-agent"},
+ })
+ require.Error(t, err)
+ })
+
+ t.Run("rejects non-string value", func(t *testing.T) {
+ err := NormalizeHeaderOverrideCredentials(map[string]any{
+ credKeyHeaderOverrides: map[string]any{"x-app": 123},
+ })
+ require.Error(t, err)
+ })
+
+ t.Run("rejects invalid header name", func(t *testing.T) {
+ for _, name := range []string{"bad name", "bad:name", "bad\nname", "值"} {
+ err := NormalizeHeaderOverrideCredentials(map[string]any{
+ credKeyHeaderOverrides: map[string]any{name: "v"},
+ })
+ require.Error(t, err, "name %q should be rejected", name)
+ }
+ })
+
+ t.Run("rejects empty name with value", func(t *testing.T) {
+ err := NormalizeHeaderOverrideCredentials(map[string]any{
+ credKeyHeaderOverrides: map[string]any{" ": "v"},
+ })
+ require.Error(t, err)
+ })
+
+ t.Run("rejects blocked headers", func(t *testing.T) {
+ for _, name := range []string{
+ "Authorization", "x-api-key", "Host", "content-length", "Transfer-Encoding",
+ "connection", "accept-encoding", "Sec-WebSocket-Key", "session_id",
+ "conversation_id", "x-codex-turn-state", "chatgpt-account-id",
+ } {
+ err := NormalizeHeaderOverrideCredentials(map[string]any{
+ credKeyHeaderOverrides: map[string]any{name: "v"},
+ })
+ require.Error(t, err, "blocked header %q should be rejected", name)
+ }
+ })
+
+ t.Run("allows tab inside value", func(t *testing.T) {
+ creds := map[string]any{
+ credKeyHeaderOverrides: map[string]any{"x-app": "a\tb"},
+ }
+ require.NoError(t, NormalizeHeaderOverrideCredentials(creds))
+ require.Equal(t, map[string]any{"x-app": "a\tb"}, creds[credKeyHeaderOverrides])
+ })
+
+ t.Run("rejects invalid value", func(t *testing.T) {
+ err := NormalizeHeaderOverrideCredentials(map[string]any{
+ credKeyHeaderOverrides: map[string]any{"x-app": "bad\nvalue"},
+ })
+ require.Error(t, err)
+ })
+
+ t.Run("rejects duplicate names case-insensitively", func(t *testing.T) {
+ err := NormalizeHeaderOverrideCredentials(map[string]any{
+ credKeyHeaderOverrides: map[string]any{
+ "User-Agent": "a",
+ "user-agent": "b",
+ },
+ })
+ require.Error(t, err)
+ })
+
+ t.Run("rejects too many entries", func(t *testing.T) {
+ entries := make(map[string]any, maxHeaderOverrideEntries+1)
+ for i := 0; i <= maxHeaderOverrideEntries; i++ {
+ entries["x-h-"+string(rune('a'+i%26))+string(rune('a'+(i/26)%26))+string(rune('a'+(i/676)%26))] = "v"
+ }
+ err := NormalizeHeaderOverrideCredentials(map[string]any{
+ credKeyHeaderOverrides: entries,
+ })
+ require.Error(t, err)
+ })
+
+ t.Run("rejects oversized value", func(t *testing.T) {
+ big := make([]byte, maxHeaderOverrideValueLength+1)
+ for i := range big {
+ big[i] = 'a'
+ }
+ err := NormalizeHeaderOverrideCredentials(map[string]any{
+ credKeyHeaderOverrides: map[string]any{"x-app": string(big)},
+ })
+ require.Error(t, err)
+ })
+}
diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go
index 80a862b971..b598ac8ced 100644
--- a/backend/internal/service/account_test_service.go
+++ b/backend/internal/service/account_test_service.go
@@ -295,6 +295,9 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
setAnthropicAPIKeyAuthHeader(req.Header, account, authToken)
}
+ // 账号级请求头覆写:测试请求与真实转发保持一致的最终头
+ account.ApplyHeaderOverrides(req.Header)
+
// Get proxy URL
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
@@ -603,6 +606,9 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount)
}
+ // 账号级请求头覆写:测试请求与真实转发保持一致的最终头
+ credentialAccount.ApplyHeaderOverrides(req.Header)
+
// Get proxy URL
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
@@ -756,6 +762,9 @@ func (s *AccountTestService) testOpenAIChatCompletionsConnection(
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Authorization", "Bearer "+authToken)
+ // 账号级请求头覆写:测试请求与真实转发保持一致的最终头
+ account.ApplyHeaderOverrides(req.Header)
+
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
@@ -848,6 +857,9 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account
setOpenAIChatGPTAccountHeaders(req.Header, account)
}
+ // 账号级请求头覆写:测试请求与真实转发保持一致的最终头
+ account.ApplyHeaderOverrides(req.Header)
+
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
@@ -1599,6 +1611,9 @@ func (s *AccountTestService) testOpenAIImageAPIKey(c *gin.Context, ctx context.C
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+authToken)
+ // 账号级请求头覆写:测试请求与真实转发保持一致的最终头
+ account.ApplyHeaderOverrides(req.Header)
+
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go
index ebf1e7e404..f1de60eb47 100644
--- a/backend/internal/service/admin_service.go
+++ b/backend/internal/service/admin_service.go
@@ -2691,6 +2691,11 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou
}
}
+ // 校验并规范化请求头覆写配置(header 名小写化、格式检查)
+ if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil {
+ return nil, err
+ }
+
account := &Account{
Name: input.Name,
Notes: normalizeAccountNotes(input.Notes),
@@ -2821,6 +2826,10 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
// 敏感子键采用"incoming 没提供就保留"的合并语义:前端响应已脱敏,
// 全对象 PUT 编辑时不会再带回 token,避免覆盖时清空已有凭证。
account.Credentials = MergePreservingSensitiveCreds(account.Credentials, input.Credentials)
+ // 校验并规范化请求头覆写配置(header 名小写化、格式检查)
+ if err := NormalizeHeaderOverrideCredentials(account.Credentials); err != nil {
+ return nil, err
+ }
}
// Extra 使用 map:需要区分“未提供(nil)”与“显式清空({})”。
// 关闭配额限制时前端会删除 quota_* 键并提交 extra:{},此时也必须落库。
@@ -3039,6 +3048,11 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
}
}
+ // 校验并规范化请求头覆写配置(批量路径为 JSONB 顶层 key 合并,直接校验增量即可)
+ if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil {
+ return nil, err
+ }
+
// Prepare bulk updates for columns and JSONB fields.
repoUpdates := AccountBulkUpdate{
Credentials: input.Credentials,
diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go
index 54035345d9..e683fc5463 100644
--- a/backend/internal/service/gateway_service.go
+++ b/backend/internal/service/gateway_service.go
@@ -5956,6 +5956,9 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough(
setHeaderRaw(req.Header, "anthropic-version", "2023-06-01")
}
+ // 账号级请求头覆写(最终生效,覆盖上面所有来源的同名头)
+ account.ApplyHeaderOverrides(req.Header)
+
return req, body, nil
}
@@ -6959,6 +6962,10 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
}
}
+ // 账号级请求头覆写(仅 anthropic/openai api_key 账号启用时生效;OAuth 路径 no-op)。
+ // 放在所有 header 逻辑之后,确保配置值对同名头拥有最终决定权。
+ account.ApplyHeaderOverrides(req.Header)
+
// === DEBUG: 打印上游转发请求(headers + body 摘要),与 CLIENT_ORIGINAL 对比 ===
s.debugLogGatewaySnapshot("UPSTREAM_FORWARD", req.Header, body, map[string]string{
"url": req.URL.String(),
@@ -10445,6 +10452,9 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough(
req.Header.Set("anthropic-version", "2023-06-01")
}
+ // 账号级请求头覆写(最终生效,覆盖上面所有来源的同名头)
+ account.ApplyHeaderOverrides(req.Header)
+
return req, nil
}
@@ -10578,6 +10588,9 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
}
}
+ // 账号级请求头覆写(仅 anthropic/openai api_key 账号启用时生效;OAuth 路径 no-op)
+ account.ApplyHeaderOverrides(req.Header)
+
if c != nil && tokenType == "oauth" {
c.Set(claudeMimicDebugInfoKey, buildClaudeMimicDebugLine(req, body, account, tokenType, mimicClaudeCode))
}
diff --git a/backend/internal/service/openai_apikey_responses_probe.go b/backend/internal/service/openai_apikey_responses_probe.go
index 64f963ab9b..10cf050029 100644
--- a/backend/internal/service/openai_apikey_responses_probe.go
+++ b/backend/internal/service/openai_apikey_responses_probe.go
@@ -149,6 +149,9 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Accept", "application/json")
+ // 账号级请求头覆写:能力探测与真实转发保持一致的最终头
+ account.ApplyHeaderOverrides(req.Header)
+
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
diff --git a/backend/internal/service/openai_embeddings.go b/backend/internal/service/openai_embeddings.go
index 0fb3fff1f7..fb2dc5ccbb 100644
--- a/backend/internal/service/openai_embeddings.go
+++ b/backend/internal/service/openai_embeddings.go
@@ -82,6 +82,9 @@ func (s *OpenAIGatewayService) ForwardEmbeddings(
upstreamReq.Header.Set("user-agent", customUA)
}
+ // 账号级请求头覆写(仅 openai api_key 账号启用时生效)
+ account.ApplyHeaderOverrides(upstreamReq.Header)
+
proxyURL := ""
if account.Proxy != nil {
proxyURL = account.Proxy.URL()
diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go
index 6bcb6718b7..348213a992 100644
--- a/backend/internal/service/openai_gateway_chat_completions_raw.go
+++ b/backend/internal/service/openai_gateway_chat_completions_raw.go
@@ -166,6 +166,9 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions(
upstreamReq.Header.Set("user-agent", "sub2api-grok/1.0")
}
+ // 账号级请求头覆写(仅 openai api_key 账号启用时生效)
+ account.ApplyHeaderOverrides(upstreamReq.Header)
+
// 6. Send request
proxyURL := ""
if account.Proxy != nil {
diff --git a/backend/internal/service/openai_gateway_count_tokens.go b/backend/internal/service/openai_gateway_count_tokens.go
index 4a01b143e9..7518a6073a 100644
--- a/backend/internal/service/openai_gateway_count_tokens.go
+++ b/backend/internal/service/openai_gateway_count_tokens.go
@@ -231,6 +231,9 @@ func (s *OpenAIGatewayService) buildInputTokensUpstreamRequest(
}
}
+ // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
+ account.ApplyHeaderOverrides(req.Header)
+
return req, nil
}
diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback.go b/backend/internal/service/openai_gateway_responses_chat_fallback.go
index d33df4c19d..c499bec778 100644
--- a/backend/internal/service/openai_gateway_responses_chat_fallback.go
+++ b/backend/internal/service/openai_gateway_responses_chat_fallback.go
@@ -138,6 +138,9 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions(
upstreamReq.Header.Set("user-agent", customUA)
}
+ // 账号级请求头覆写(仅 openai api_key 账号启用时生效)
+ account.ApplyHeaderOverrides(upstreamReq.Header)
+
proxyURL := ""
if account.Proxy != nil {
proxyURL = account.Proxy.URL()
diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go
index dd3d48aed0..f13c44f3a6 100644
--- a/backend/internal/service/openai_gateway_service.go
+++ b/backend/internal/service/openai_gateway_service.go
@@ -3783,6 +3783,9 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
req.Header.Set("content-type", "application/json")
}
+ // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
+ account.ApplyHeaderOverrides(req.Header)
+
return req, nil
}
@@ -4568,6 +4571,9 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
req.Header.Set("content-type", "application/json")
}
+ // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
+ account.ApplyHeaderOverrides(req.Header)
+
return req, nil
}
diff --git a/backend/internal/service/openai_images.go b/backend/internal/service/openai_images.go
index 7081653d80..09472fbaf1 100644
--- a/backend/internal/service/openai_images.go
+++ b/backend/internal/service/openai_images.go
@@ -760,6 +760,8 @@ func (s *OpenAIGatewayService) buildOpenAIImagesRequest(
if strings.TrimSpace(contentType) != "" {
req.Header.Set("Content-Type", contentType)
}
+ // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
+ account.ApplyHeaderOverrides(req.Header)
return req, nil
}
diff --git a/backend/internal/service/openai_ws_forwarder.go b/backend/internal/service/openai_ws_forwarder.go
index fcc4b98064..bbca9776ab 100644
--- a/backend/internal/service/openai_ws_forwarder.go
+++ b/backend/internal/service/openai_ws_forwarder.go
@@ -1183,6 +1183,10 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders(
headers.Set("user-agent", codexCLIUserAgent)
}
+ // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)。
+ // 覆盖所有 WS 模式(ctx_pool/dedicated/passthrough)的握手头。
+ account.ApplyHeaderOverrides(headers)
+
return headers, sessionResolution, nil
}
diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go
index ee3e6bfc04..e9fa7de451 100644
--- a/backend/internal/service/upstream_models.go
+++ b/backend/internal/service/upstream_models.go
@@ -208,6 +208,8 @@ func (s *AccountTestService) buildAnthropicUpstreamModelsRequest(ctx context.Con
} else {
setAnthropicAPIKeyAuthHeader(req.Header, account, apiKeyAuthToken)
}
+ // 账号级请求头覆写:模型列表探测与真实转发保持一致的最终头
+ account.ApplyHeaderOverrides(req.Header)
return req, nil
}
@@ -277,6 +279,8 @@ func (s *AccountTestService) buildOpenAIUpstreamModelsRequest(ctx context.Contex
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
+ // 账号级请求头覆写:模型列表探测与真实转发保持一致的最终头
+ account.ApplyHeaderOverrides(req.Header)
return req, nil
}
diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue
index d24488b47a..7ce016810e 100644
--- a/frontend/src/components/account/BulkEditAccountModal.vue
+++ b/frontend/src/components/account/BulkEditAccountModal.vue
@@ -486,6 +486,125 @@
+
+
+
+
+
+
+ {{ t('admin.accounts.headerOverride.hint') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.accounts.headerOverride.info') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.accounts.headerOverride.emptyValueHint') }}
+
+
+
+ {{ t('admin.accounts.headerOverride.bulkDisableHint') }}
+
+
+
+
@@ -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([])
const selectedErrorCodes = ref([])
const customErrorCodeInput = ref(null)
const interceptWarmupRequests = ref(false)
+const headerOverrideEnabled = ref(false)
+const headerOverrideRows = ref([])
+const getHeaderOverrideRowKey = createStableObjectKeyResolver('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(null)
const concurrency = ref(1)
const loadFactor = ref(null)
@@ -1523,6 +1693,15 @@ const buildUpdatePayload = (): Record | 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
diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue
index 3514153c82..a00b49cfa0 100644
--- a/frontend/src/components/account/CreateAccountModal.vue
+++ b/frontend/src/components/account/CreateAccountModal.vue
@@ -1468,6 +1468,110 @@
+
+
+
+
+
+
+ {{ t('admin.accounts.headerOverride.hint') }}
+
+
+
+
+
+
+
+
+
+ {{ t('admin.accounts.headerOverride.info') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.accounts.headerOverride.emptyValueHint') }}
+
+
+
+
@@ -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([])
const customErrorCodeInput = ref(null)
+const headerOverrideEnabled = ref(false)
+const headerOverrideRows = ref([])
+
+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([])
const getModelMappingKey = createStableObjectKeyResolver('create-model-mapping')
+const getHeaderOverrideRowKey = createStableObjectKeyResolver('create-header-override-row')
const getOpenAICompactModelMappingKey = createStableObjectKeyResolver('create-openai-compact-model-mapping')
const getAntigravityModelMappingKey = createStableObjectKeyResolver('create-antigravity-model-mapping')
const getTempUnschedRuleKey = createStableObjectKeyResolver('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
diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue
index 3670f233e3..9b5ab82fc5 100644
--- a/frontend/src/components/account/EditAccountModal.vue
+++ b/frontend/src/components/account/EditAccountModal.vue
@@ -417,6 +417,110 @@
+
+
+
+
+
+
+ {{ t('admin.accounts.headerOverride.hint') }}
+
+
+
+
+
+
+
+
+
+ {{ t('admin.accounts.headerOverride.info') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.accounts.headerOverride.emptyValueHint') }}
+
+
+
+
@@ -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([])
const customErrorCodeInput = ref(null)
+const headerOverrideEnabled = ref(false)
+const headerOverrideRows = ref([])
+
+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(null)
@@ -2580,6 +2716,7 @@ const isSyncingAntigravityUpstream = ref(false)
const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref([])
const getModelMappingKey = createStableObjectKeyResolver('edit-model-mapping')
+const getHeaderOverrideRowKey = createStableObjectKeyResolver('edit-header-override-row')
const getOpenAICompactModelMappingKey = createStableObjectKeyResolver('edit-openai-compact-model-mapping')
const getAntigravityModelMappingKey = createStableObjectKeyResolver('edit-antigravity-model-mapping')
const getTempUnschedRuleKey = createStableObjectKeyResolver('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
@@ -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
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)) {
diff --git a/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts b/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts
index 665b1732e7..cbad111f45 100644
--- a/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts
+++ b/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts
@@ -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 = { 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 = { 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 = {
+ 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 = {
+ [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')
+ })
+})
diff --git a/frontend/src/components/account/credentialsBuilder.ts b/frontend/src/components/account/credentialsBuilder.ts
index f138976519..9e175a2712 100644
--- a/frontend/src/components/account/credentialsBuilder.ts
+++ b/frontend/src/components/account/credentialsBuilder.ts
@@ -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()
+ 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 {
+ const result: Record = {}
+ 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)
+ .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,
+ 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]
+ }
+}
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 749d2dcbcd..6e5aa18b28 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -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 (%)',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index a0b1337796..3ec15e2ec6 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -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 用量阈值(%)',
From 31b6e0d94accdbc9a10e947b3a3ea7479a0bb7e7 Mon Sep 17 00:00:00 2001
From: shaw
Date: Mon, 6 Jul 2026 20:05:50 +0800
Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E8=AF=B7=E6=B1=82=E5=A4=B4=E8=A6=86?=
=?UTF-8?q?=E5=86=99=E5=AE=A1=E8=AE=A1=E9=97=AE=E9=A2=98=E4=BF=AE=E5=A4=8D?=
=?UTF-8?q?=EF=BC=88=E7=A6=81=E6=AD=A2=E5=90=8D=E5=8D=95=E7=BC=BA=E5=8F=A3?=
=?UTF-8?q?/beta=20=E5=AF=B9=E7=A7=B0=E6=80=A7/=E6=89=B9=E9=87=8F=E6=B8=85?=
=?UTF-8?q?=E7=A9=BA=E9=98=B2=E6=8A=A4=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
后端:
- 禁止名单补充 content-type(multipart boundary 为每请求随机值,静态覆写必坏
images 路径)、x-claude-code-session-id / x-client-request-id(会击穿每请求
会话同步,与已禁的 session_id 等同类)、cookie / x-goog-api-key(与透传路径
的入站鉴权残留清洗保持一致)
- anthropic-beta 覆写与 body 净化对称:四个 Anthropic 构建器在
sanitizeAnthropicBodyForBetaTokens 前以覆写值为有效 finalBeta,避免覆写
丢 token 后 header/body 不对称被上游 400
- 保存/应用两路径共用 normalizeHeaderOverrideEntry,消除双份校验规则漂移
- GetHeaderOverrides 按 modelMappingCache 先例增加热路径缓存(WS 每消息
重建头场景收益最大);ApplyHeaderOverrides 移除无观测效果的排序
前端:
- 禁止名单镜像同步新增项;值长度改按 UTF-8 字节校验(与后端 len() 对齐)
- Create 弹窗切换平台时重置覆写配置,避免上一平台模板行串台
- BulkEdit:开启覆写但无有效行时拦截保存(防止整键替换静默清空所选账号
既有配置);开启分支显示整体替换警告;混合平台选择时隐藏模板按钮
- 删除未使用的 isBlockedHeaderOverrideName 导出
---
backend/internal/service/account.go | 8 +
.../service/account_header_override.go | 156 ++++++++++++------
.../service/account_header_override_test.go | 10 +-
backend/internal/service/gateway_service.go | 19 +++
.../account/BulkEditAccountModal.vue | 27 ++-
.../components/account/CreateAccountModal.vue | 4 +
.../__tests__/credentialsBuilder.spec.ts | 17 ++
.../components/account/credentialsBuilder.ts | 24 ++-
frontend/src/i18n/locales/en.ts | 2 +
frontend/src/i18n/locales/zh.ts | 2 +
10 files changed, 201 insertions(+), 68 deletions(-)
diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go
index 8db5805e9d..ae25bf387d 100644
--- a/backend/internal/service/account.go
+++ b/backend/internal/service/account.go
@@ -70,6 +70,14 @@ type Account struct {
modelMappingCacheRawPtr uintptr
modelMappingCacheRawLen int
modelMappingCacheRawSig uint64
+
+ // header_overrides 热路径缓存(非持久化字段,同 model_mapping 缓存先例)
+ headerOverrideCache map[string]string
+ headerOverrideCacheReady bool
+ headerOverrideCacheCredentialsPtr uintptr
+ headerOverrideCacheRawPtr uintptr
+ headerOverrideCacheRawLen int
+ headerOverrideCacheRawSig uint64
}
type OpenAIEndpointCapability string
diff --git a/backend/internal/service/account_header_override.go b/backend/internal/service/account_header_override.go
index 80c32b648d..8882bbef91 100644
--- a/backend/internal/service/account_header_override.go
+++ b/backend/internal/service/account_header_override.go
@@ -2,7 +2,6 @@ package service
import (
"net/http"
- "sort"
"strings"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
@@ -25,13 +24,15 @@ const (
// headerOverrideBlockedNames 禁止覆写的请求头(小写)。
// - 连接控制/逐跳头:由 HTTP 栈管理,覆写会破坏请求传输;
// - host/content-length:由 Go 的 Request.Host / ContentLength 字段管理,header 覆写不生效或产生冲突;
-// - authorization/x-api-key:上游认证头由账号凭据统一注入,禁止通过覆写篡改;
+// - content-type:承载报文框架信息(multipart boundary 为每请求随机值),静态覆写必然与 body 不匹配;
+// - authorization/x-api-key/cookie 等:上游认证头由账号凭据统一注入,禁止通过覆写篡改或重新引入;
// - accept-encoding:强制压缩会破坏网关对上游流式响应(SSE/usage)的解析;
// - sec-websocket-*:WebSocket 握手头由拨号器管理(OpenAI WS 模式);
-// - session_id/conversation_id 等:逐请求会话隔离头,固定值会造成会话串扰。
+// - session_id/x-claude-code-session-id 等:逐请求会话隔离头,固定值会造成会话串扰。
var headerOverrideBlockedNames = map[string]struct{}{
"host": {},
"content-length": {},
+ "content-type": {},
"transfer-encoding": {},
"connection": {},
"keep-alive": {},
@@ -43,6 +44,8 @@ var headerOverrideBlockedNames = map[string]struct{}{
"upgrade": {},
"authorization": {},
"x-api-key": {},
+ "x-goog-api-key": {},
+ "cookie": {},
"accept-encoding": {},
"sec-websocket-key": {},
"sec-websocket-version": {},
@@ -54,6 +57,8 @@ var headerOverrideBlockedNames = map[string]struct{}{
"x-codex-turn-state": {},
"x-codex-turn-metadata": {},
"chatgpt-account-id": {},
+ "x-claude-code-session-id": {},
+ "x-client-request-id": {},
}
func isHeaderOverrideBlockedName(lowerName string) bool {
@@ -82,29 +87,59 @@ func (a *Account) IsHeaderOverrideEnabled() bool {
// GetHeaderOverrides 返回生效的请求头覆写表(key 统一小写)。
// 未启用、不符合平台/类型条件或配置为空时返回 nil。
// 空 value 的条目(模板占位)与非法/禁止的 header 名会被跳过。
+// 结果带热路径缓存(同 GetModelMapping 先例):同一 credentials 映射在
+// 一次请求 / 一条 WS 会话内的多次调用只做一次解析与校验。
func (a *Account) GetHeaderOverrides() map[string]string {
if !a.IsHeaderOverrideEnabled() {
return nil
}
- raw := stringMappingFromRaw(a.Credentials[credKeyHeaderOverrides])
+ rawMapping, rawIsAnyMap := a.Credentials[credKeyHeaderOverrides].(map[string]any)
+ if !rawIsAnyMap {
+ // 非 JSON 反序列化产物(如直接注入的 map[string]string):直接解析,不缓存
+ return resolveHeaderOverrides(stringMappingFromRaw(a.Credentials[credKeyHeaderOverrides]))
+ }
+
+ credentialsPtr := mapPtr(a.Credentials)
+ rawPtr := mapPtr(rawMapping)
+ rawLen := len(rawMapping)
+ rawSig := uint64(0)
+ rawSigReady := false
+
+ if a.headerOverrideCacheReady &&
+ a.headerOverrideCacheCredentialsPtr == credentialsPtr &&
+ a.headerOverrideCacheRawPtr == rawPtr &&
+ a.headerOverrideCacheRawLen == rawLen {
+ rawSig = modelMappingSignature(rawMapping)
+ rawSigReady = true
+ if a.headerOverrideCacheRawSig == rawSig {
+ return a.headerOverrideCache
+ }
+ }
+
+ overrides := resolveHeaderOverrides(stringMappingFromRaw(rawMapping))
+ if !rawSigReady {
+ rawSig = modelMappingSignature(rawMapping)
+ }
+
+ a.headerOverrideCache = overrides
+ a.headerOverrideCacheReady = true
+ a.headerOverrideCacheCredentialsPtr = credentialsPtr
+ a.headerOverrideCacheRawPtr = rawPtr
+ a.headerOverrideCacheRawLen = rawLen
+ a.headerOverrideCacheRawSig = rawSig
+ return overrides
+}
+
+// resolveHeaderOverrides 解析并防御性过滤原始覆写表:保存路径已做校验,
+// 这里兜底未经 Normalize 落库的数据(含名单扩充前保存的旧配置),非法条目直接跳过。
+func resolveHeaderOverrides(raw map[string]string) map[string]string {
if len(raw) == 0 {
return nil
}
result := make(map[string]string, len(raw))
for name, value := range raw {
- lowerName := strings.ToLower(strings.TrimSpace(name))
- value = strings.TrimSpace(value)
- if lowerName == "" || value == "" {
- continue
- }
- // 防御性过滤:保存路径已做校验,这里兜底未经 Normalize 落库的数据
- if len(lowerName) > maxHeaderOverrideNameLength || len(value) > maxHeaderOverrideValueLength {
- continue
- }
- if isHeaderOverrideBlockedName(lowerName) {
- continue
- }
- if !httpguts.ValidHeaderFieldName(lowerName) || !httpguts.ValidHeaderFieldValue(value) {
+ lowerName, value, err := normalizeHeaderOverrideEntry(name, value)
+ if err != nil || lowerName == "" || value == "" {
continue
}
result[lowerName] = value
@@ -115,6 +150,13 @@ func (a *Account) GetHeaderOverrides() map[string]string {
return result
}
+// HeaderOverrideValue 返回指定 header(小写名)的生效覆写值。
+// 供转发链路在 header 写入前感知覆写结果(如 anthropic-beta 需要参与 body 净化)。
+func (a *Account) HeaderOverrideValue(lowerName string) (string, bool) {
+ value, ok := a.GetHeaderOverrides()[lowerName]
+ return value, ok
+}
+
// ApplyHeaderOverrides 将账号配置的请求头覆写应用到出站请求头。
// 对每个覆写条目:先删除所有大小写变体(转发链路会以 wire casing 直接写入 map,
// 可能存在非 canonical key),再按已知 wire casing 写入,避免产生重复头。
@@ -127,18 +169,16 @@ func (a *Account) ApplyHeaderOverrides(h http.Header) {
if len(overrides) == 0 {
return
}
- names := make([]string, 0, len(overrides))
- for name := range overrides {
- names = append(names, name)
- }
- sort.Strings(names)
- for _, name := range names {
+ // 覆写名两两不同(大小写不敏感)且各自只操作同名键,应用顺序不影响结果。
+ // 全量 EqualFold 扫描兜底删除任意 casing 的既有键:透传链路可能保留客户端
+ // 原始 casing,非 canonical/wire casing 的键 deleteHeaderAllForms 覆盖不到。
+ for name, value := range overrides {
for existing := range h {
if strings.EqualFold(existing, name) {
delete(h, existing)
}
}
- h[resolveWireCasing(name)] = []string{overrides[name]}
+ h[resolveWireCasing(name)] = []string{value}
}
}
@@ -186,34 +226,12 @@ func NormalizeHeaderOverrideCredentials(credentials map[string]any) error {
return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
"header %q value must be a string", name)
}
- lowerName := strings.ToLower(strings.TrimSpace(name))
- value = strings.TrimSpace(value)
+ lowerName, value, err := normalizeHeaderOverrideEntry(name, value)
+ if err != nil {
+ return err
+ }
if lowerName == "" {
- if value == "" {
- continue // 丢弃完全为空的占位行
- }
- return infraerrors.New(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
- "header name must not be empty")
- }
- if len(lowerName) > maxHeaderOverrideNameLength {
- return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
- "header name %q exceeds %d characters", lowerName, maxHeaderOverrideNameLength)
- }
- if !httpguts.ValidHeaderFieldName(lowerName) {
- return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
- "invalid header name %q", lowerName)
- }
- if isHeaderOverrideBlockedName(lowerName) {
- return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
- "header %q is not allowed to be overridden", lowerName)
- }
- if len(value) > maxHeaderOverrideValueLength {
- return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
- "header %q value exceeds %d characters", lowerName, maxHeaderOverrideValueLength)
- }
- if !httpguts.ValidHeaderFieldValue(value) {
- return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
- "header %q has an invalid value", lowerName)
+ continue // 丢弃完全为空的占位行
}
if _, dup := normalized[lowerName]; dup {
return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
@@ -224,3 +242,39 @@ func NormalizeHeaderOverrideCredentials(credentials map[string]any) error {
credentials[credKeyHeaderOverrides] = normalized
return nil
}
+
+// normalizeHeaderOverrideEntry 校验并规范化单个覆写条目,保存路径(Normalize,err → 400)
+// 与应用路径(resolveHeaderOverrides,err → 跳过)共用同一套规则,避免两处校验漂移。
+// 名和值均为空表示空占位行,返回 ("", "", nil);空 value 的具名条目合法(模板占位)。
+func normalizeHeaderOverrideEntry(name, value string) (string, string, error) {
+ lowerName := strings.ToLower(strings.TrimSpace(name))
+ value = strings.TrimSpace(value)
+ if lowerName == "" {
+ if value == "" {
+ return "", "", nil
+ }
+ return "", "", infraerrors.New(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "header name must not be empty")
+ }
+ if len(lowerName) > maxHeaderOverrideNameLength {
+ return "", "", infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "header name %q exceeds %d characters", lowerName, maxHeaderOverrideNameLength)
+ }
+ if !httpguts.ValidHeaderFieldName(lowerName) {
+ return "", "", infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "invalid header name %q", lowerName)
+ }
+ if isHeaderOverrideBlockedName(lowerName) {
+ return "", "", infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "header %q is not allowed to be overridden", lowerName)
+ }
+ if len(value) > maxHeaderOverrideValueLength {
+ return "", "", infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "header %q value exceeds %d characters", lowerName, maxHeaderOverrideValueLength)
+ }
+ if !httpguts.ValidHeaderFieldValue(value) {
+ return "", "", infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
+ "header %q has an invalid value", lowerName)
+ }
+ return lowerName, value, nil
+}
diff --git a/backend/internal/service/account_header_override_test.go b/backend/internal/service/account_header_override_test.go
index 56c95b3fcb..c89b5e0587 100644
--- a/backend/internal/service/account_header_override_test.go
+++ b/backend/internal/service/account_header_override_test.go
@@ -108,9 +108,11 @@ func TestGetHeaderOverrides(t *testing.T) {
defensive := headerOverrideTestAccount(PlatformOpenAI, AccountTypeAPIKey, map[string]any{
credKeyHeaderOverrideEnabled: true,
credKeyHeaderOverrides: map[string]any{
- "x-big": oversizedValue,
- "sec-websocket-key": "forged",
- "x-ok": "ok",
+ "x-big": oversizedValue,
+ "sec-websocket-key": "forged",
+ "content-type": "application/json", // 名单扩充前落库的数据也要被拦截
+ "x-claude-code-session-id": "pinned-session",
+ "x-ok": "ok",
},
})
require.Equal(t, map[string]string{"x-ok": "ok"}, defensive.GetHeaderOverrides())
@@ -278,6 +280,8 @@ func TestNormalizeHeaderOverrideCredentials(t *testing.T) {
"Authorization", "x-api-key", "Host", "content-length", "Transfer-Encoding",
"connection", "accept-encoding", "Sec-WebSocket-Key", "session_id",
"conversation_id", "x-codex-turn-state", "chatgpt-account-id",
+ "Content-Type", "Cookie", "x-goog-api-key",
+ "X-Claude-Code-Session-Id", "x-client-request-id",
} {
err := NormalizeHeaderOverrideCredentials(map[string]any{
credKeyHeaderOverrides: map[string]any{name: "v"},
diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go
index e683fc5463..dcaf3a645c 100644
--- a/backend/internal/service/gateway_service.go
+++ b/backend/internal/service/gateway_service.go
@@ -5920,6 +5920,10 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough(
if c != nil && c.Request != nil {
clientBeta = getHeaderRaw(c.Request.Header, "anthropic-beta")
}
+ // 账号覆写了 anthropic-beta 时,覆写值即最终上游值:净化以覆写值为准
+ if beta, ok := account.HeaderOverrideValue("anthropic-beta"); ok {
+ clientBeta = beta
+ }
if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, clientBeta); changed {
body = sanitized
}
@@ -6889,6 +6893,12 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
tokenType, mimicClaudeCode, modelID, clientHeaders, body, effectiveDropSet,
)
+ // 账号覆写了 anthropic-beta 时,覆写值即最终上游值(由下方 ApplyHeaderOverrides 写入):
+ // body 能力净化必须以覆写值为准,否则 header/body 不对称会被上游 400。
+ if beta, ok := account.HeaderOverrideValue("anthropic-beta"); ok {
+ finalBetaHeader, finalBetaShouldSet = beta, true
+ }
+
// 能力维度 body sanitize:与最终 anthropic-beta header 对称
if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, finalBetaHeader); changed {
body = sanitized
@@ -10417,6 +10427,10 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough(
if c != nil && c.Request != nil {
clientBeta = getHeaderRaw(c.Request.Header, "anthropic-beta")
}
+ // 账号覆写了 anthropic-beta 时,覆写值即最终上游值:净化以覆写值为准
+ if beta, ok := account.HeaderOverrideValue("anthropic-beta"); ok {
+ clientBeta = beta
+ }
if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, clientBeta); changed {
body = sanitized
}
@@ -10522,6 +10536,11 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
tokenType, mimicClaudeCode, modelID, clientHeaders, body, ctEffectiveDropSet,
)
+ // 账号覆写了 anthropic-beta 时,覆写值即最终上游值:净化以覆写值为准
+ if beta, ok := account.HeaderOverrideValue("anthropic-beta"); ok {
+ finalBetaHeader, finalBetaShouldSet = beta, true
+ }
+
// 能力维度 body sanitize:与最终 anthropic-beta header 对称
if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, finalBetaHeader); changed {
body = sanitized
diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue
index 7ce016810e..91656b196d 100644
--- a/frontend/src/components/account/BulkEditAccountModal.vue
+++ b/frontend/src/components/account/BulkEditAccountModal.vue
@@ -534,6 +534,10 @@
+
+ {{ t('admin.accounts.headerOverride.bulkReplaceHint') }}
+
+
-
+