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 用量阈值(%)',