Merge pull request #3759 from Wei-Shaw/feat/account-header-override

feat: apikey 账号支持请求头覆写(Anthropic/OpenAI)
This commit is contained in:
Wesley Liddick
2026-07-06 20:47:33 +08:00
committed by GitHub
22 changed files with 1659 additions and 3 deletions
+8
View File
@@ -70,6 +70,14 @@ type Account struct {
modelMappingCacheRawPtr uintptr modelMappingCacheRawPtr uintptr
modelMappingCacheRawLen int modelMappingCacheRawLen int
modelMappingCacheRawSig uint64 modelMappingCacheRawSig uint64
// header_overrides 热路径缓存(非持久化字段,同 model_mapping 缓存先例)
headerOverrideCache map[string]string
headerOverrideCacheReady bool
headerOverrideCacheCredentialsPtr uintptr
headerOverrideCacheRawPtr uintptr
headerOverrideCacheRawLen int
headerOverrideCacheRawSig uint64
} }
type OpenAIEndpointCapability string type OpenAIEndpointCapability string
@@ -0,0 +1,280 @@
package service
import (
"net/http"
"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 覆写不生效或产生冲突;
// - content-type:承载报文框架信息(multipart boundary 为每请求随机值),静态覆写必然与 body 不匹配;
// - authorization/x-api-key/cookie 等:上游认证头由账号凭据统一注入,禁止通过覆写篡改或重新引入;
// - accept-encoding:强制压缩会破坏网关对上游流式响应(SSE/usage)的解析;
// - sec-websocket-*:WebSocket 握手头由拨号器管理(OpenAI WS 模式);
// - session_id/x-claude-code-session-id 等:逐请求会话隔离头,固定值会造成会话串扰。
var headerOverrideBlockedNames = map[string]struct{}{
"host": {},
"content-length": {},
"content-type": {},
"transfer-encoding": {},
"connection": {},
"keep-alive": {},
"proxy-authenticate": {},
"proxy-authorization": {},
"proxy-connection": {},
"te": {},
"trailer": {},
"upgrade": {},
"authorization": {},
"x-api-key": {},
"x-goog-api-key": {},
"cookie": {},
"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": {},
"x-claude-code-session-id": {},
"x-client-request-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 名会被跳过。
// 结果带热路径缓存(同 GetModelMapping 先例):同一 credentials 映射在
// 一次请求 / 一条 WS 会话内的多次调用只做一次解析与校验。
func (a *Account) GetHeaderOverrides() map[string]string {
if !a.IsHeaderOverrideEnabled() {
return nil
}
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, value, err := normalizeHeaderOverrideEntry(name, value)
if err != nil || lowerName == "" || value == "" {
continue
}
result[lowerName] = value
}
if len(result) == 0 {
return nil
}
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 写入,避免产生重复头。
// 账号未启用或不符合条件时为 no-op,可安全地在 OAuth/api_key 共用的构建器中调用。
func (a *Account) ApplyHeaderOverrides(h http.Header) {
if h == nil {
return
}
overrides := a.GetHeaderOverrides()
if len(overrides) == 0 {
return
}
// 覆写名两两不同(大小写不敏感)且各自只操作同名键,应用顺序不影响结果。
// 全量 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{value}
}
}
// 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, value, err := normalizeHeaderOverrideEntry(name, value)
if err != nil {
return err
}
if lowerName == "" {
continue // 丢弃完全为空的占位行
}
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
}
// 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
}
@@ -0,0 +1,339 @@
//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",
"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())
}
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",
"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"},
})
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)
})
}
@@ -295,6 +295,9 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
setAnthropicAPIKeyAuthHeader(req.Header, account, authToken) setAnthropicAPIKeyAuthHeader(req.Header, account, authToken)
} }
// 账号级请求头覆写:测试请求与真实转发保持一致的最终头
account.ApplyHeaderOverrides(req.Header)
// Get proxy URL // Get proxy URL
proxyURL := "" proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil { if account.ProxyID != nil && account.Proxy != nil {
@@ -603,6 +606,9 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount) setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount)
} }
// 账号级请求头覆写:测试请求与真实转发保持一致的最终头
credentialAccount.ApplyHeaderOverrides(req.Header)
// Get proxy URL // Get proxy URL
proxyURL := "" proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil { 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("Accept", "text/event-stream")
req.Header.Set("Authorization", "Bearer "+authToken) req.Header.Set("Authorization", "Bearer "+authToken)
// 账号级请求头覆写:测试请求与真实转发保持一致的最终头
account.ApplyHeaderOverrides(req.Header)
proxyURL := "" proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil { if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL() proxyURL = account.Proxy.URL()
@@ -848,6 +857,9 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account
setOpenAIChatGPTAccountHeaders(req.Header, account) setOpenAIChatGPTAccountHeaders(req.Header, account)
} }
// 账号级请求头覆写:测试请求与真实转发保持一致的最终头
account.ApplyHeaderOverrides(req.Header)
proxyURL := "" proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil { if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL() 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("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+authToken) req.Header.Set("Authorization", "Bearer "+authToken)
// 账号级请求头覆写:测试请求与真实转发保持一致的最终头
account.ApplyHeaderOverrides(req.Header)
proxyURL := "" proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil { if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL() proxyURL = account.Proxy.URL()
+14
View File
@@ -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{ account := &Account{
Name: input.Name, Name: input.Name,
Notes: normalizeAccountNotes(input.Notes), Notes: normalizeAccountNotes(input.Notes),
@@ -2821,6 +2826,10 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
// 敏感子键采用"incoming 没提供就保留"的合并语义:前端响应已脱敏, // 敏感子键采用"incoming 没提供就保留"的合并语义:前端响应已脱敏,
// 全对象 PUT 编辑时不会再带回 token,避免覆盖时清空已有凭证。 // 全对象 PUT 编辑时不会再带回 token,避免覆盖时清空已有凭证。
account.Credentials = MergePreservingSensitiveCreds(account.Credentials, input.Credentials) account.Credentials = MergePreservingSensitiveCreds(account.Credentials, input.Credentials)
// 校验并规范化请求头覆写配置(header 名小写化、格式检查)
if err := NormalizeHeaderOverrideCredentials(account.Credentials); err != nil {
return nil, err
}
} }
// Extra 使用 map:需要区分“未提供(nil)”与“显式清空({})”。 // Extra 使用 map:需要区分“未提供(nil)”与“显式清空({})”。
// 关闭配额限制时前端会删除 quota_* 键并提交 extra:{},此时也必须落库。 // 关闭配额限制时前端会删除 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. // Prepare bulk updates for columns and JSONB fields.
repoUpdates := AccountBulkUpdate{ repoUpdates := AccountBulkUpdate{
Credentials: input.Credentials, Credentials: input.Credentials,
@@ -5920,6 +5920,10 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough(
if c != nil && c.Request != nil { if c != nil && c.Request != nil {
clientBeta = getHeaderRaw(c.Request.Header, "anthropic-beta") 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 { if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, clientBeta); changed {
body = sanitized body = sanitized
} }
@@ -5956,6 +5960,9 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough(
setHeaderRaw(req.Header, "anthropic-version", "2023-06-01") setHeaderRaw(req.Header, "anthropic-version", "2023-06-01")
} }
// 账号级请求头覆写(最终生效,覆盖上面所有来源的同名头)
account.ApplyHeaderOverrides(req.Header)
return req, body, nil return req, body, nil
} }
@@ -6886,6 +6893,12 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
tokenType, mimicClaudeCode, modelID, clientHeaders, body, effectiveDropSet, 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 对称 // 能力维度 body sanitize:与最终 anthropic-beta header 对称
if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, finalBetaHeader); changed { if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, finalBetaHeader); changed {
body = sanitized body = sanitized
@@ -6959,6 +6972,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 对比 === // === DEBUG: 打印上游转发请求(headers + body 摘要),与 CLIENT_ORIGINAL 对比 ===
s.debugLogGatewaySnapshot("UPSTREAM_FORWARD", req.Header, body, map[string]string{ s.debugLogGatewaySnapshot("UPSTREAM_FORWARD", req.Header, body, map[string]string{
"url": req.URL.String(), "url": req.URL.String(),
@@ -10410,6 +10427,10 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough(
if c != nil && c.Request != nil { if c != nil && c.Request != nil {
clientBeta = getHeaderRaw(c.Request.Header, "anthropic-beta") 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 { if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, clientBeta); changed {
body = sanitized body = sanitized
} }
@@ -10445,6 +10466,9 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough(
req.Header.Set("anthropic-version", "2023-06-01") req.Header.Set("anthropic-version", "2023-06-01")
} }
// 账号级请求头覆写(最终生效,覆盖上面所有来源的同名头)
account.ApplyHeaderOverrides(req.Header)
return req, nil return req, nil
} }
@@ -10512,6 +10536,11 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
tokenType, mimicClaudeCode, modelID, clientHeaders, body, ctEffectiveDropSet, 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 对称 // 能力维度 body sanitize:与最终 anthropic-beta header 对称
if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, finalBetaHeader); changed { if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, finalBetaHeader); changed {
body = sanitized body = sanitized
@@ -10578,6 +10607,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" { if c != nil && tokenType == "oauth" {
c.Set(claudeMimicDebugInfoKey, buildClaudeMimicDebugLine(req, body, account, tokenType, mimicClaudeCode)) c.Set(claudeMimicDebugInfoKey, buildClaudeMimicDebugLine(req, body, account, tokenType, mimicClaudeCode))
} }
@@ -149,6 +149,9 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte
req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
// 账号级请求头覆写:能力探测与真实转发保持一致的最终头
account.ApplyHeaderOverrides(req.Header)
proxyURL := "" proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil { if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL() proxyURL = account.Proxy.URL()
@@ -82,6 +82,9 @@ func (s *OpenAIGatewayService) ForwardEmbeddings(
upstreamReq.Header.Set("user-agent", customUA) upstreamReq.Header.Set("user-agent", customUA)
} }
// 账号级请求头覆写(仅 openai api_key 账号启用时生效)
account.ApplyHeaderOverrides(upstreamReq.Header)
proxyURL := "" proxyURL := ""
if account.Proxy != nil { if account.Proxy != nil {
proxyURL = account.Proxy.URL() proxyURL = account.Proxy.URL()
@@ -166,6 +166,9 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions(
upstreamReq.Header.Set("user-agent", "sub2api-grok/1.0") upstreamReq.Header.Set("user-agent", "sub2api-grok/1.0")
} }
// 账号级请求头覆写(仅 openai api_key 账号启用时生效)
account.ApplyHeaderOverrides(upstreamReq.Header)
// 6. Send request // 6. Send request
proxyURL := "" proxyURL := ""
if account.Proxy != nil { if account.Proxy != nil {
@@ -231,6 +231,9 @@ func (s *OpenAIGatewayService) buildInputTokensUpstreamRequest(
} }
} }
// 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
account.ApplyHeaderOverrides(req.Header)
return req, nil return req, nil
} }
@@ -138,6 +138,9 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions(
upstreamReq.Header.Set("user-agent", customUA) upstreamReq.Header.Set("user-agent", customUA)
} }
// 账号级请求头覆写(仅 openai api_key 账号启用时生效)
account.ApplyHeaderOverrides(upstreamReq.Header)
proxyURL := "" proxyURL := ""
if account.Proxy != nil { if account.Proxy != nil {
proxyURL = account.Proxy.URL() proxyURL = account.Proxy.URL()
@@ -3783,6 +3783,9 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
req.Header.Set("content-type", "application/json") req.Header.Set("content-type", "application/json")
} }
// 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
account.ApplyHeaderOverrides(req.Header)
return req, nil return req, nil
} }
@@ -4568,6 +4571,9 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
req.Header.Set("content-type", "application/json") req.Header.Set("content-type", "application/json")
} }
// 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
account.ApplyHeaderOverrides(req.Header)
return req, nil return req, nil
} }
@@ -760,6 +760,8 @@ func (s *OpenAIGatewayService) buildOpenAIImagesRequest(
if strings.TrimSpace(contentType) != "" { if strings.TrimSpace(contentType) != "" {
req.Header.Set("Content-Type", contentType) req.Header.Set("Content-Type", contentType)
} }
// 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
account.ApplyHeaderOverrides(req.Header)
return req, nil return req, nil
} }
@@ -1183,6 +1183,10 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders(
headers.Set("user-agent", codexCLIUserAgent) headers.Set("user-agent", codexCLIUserAgent)
} }
// 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)。
// 覆盖所有 WS 模式(ctx_pool/dedicated/passthrough)的握手头。
account.ApplyHeaderOverrides(headers)
return headers, sessionResolution, nil return headers, sessionResolution, nil
} }
@@ -208,6 +208,8 @@ func (s *AccountTestService) buildAnthropicUpstreamModelsRequest(ctx context.Con
} else { } else {
setAnthropicAPIKeyAuthHeader(req.Header, account, apiKeyAuthToken) setAnthropicAPIKeyAuthHeader(req.Header, account, apiKeyAuthToken)
} }
// 账号级请求头覆写:模型列表探测与真实转发保持一致的最终头
account.ApplyHeaderOverrides(req.Header)
return req, nil return req, nil
} }
@@ -277,6 +279,8 @@ func (s *AccountTestService) buildOpenAIUpstreamModelsRequest(ctx context.Contex
} }
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Authorization", "Bearer "+apiKey)
// 账号级请求头覆写:模型列表探测与真实转发保持一致的最终头
account.ApplyHeaderOverrides(req.Header)
return req, nil return req, nil
} }
@@ -486,6 +486,129 @@
</div> </div>
</div> </div>
<!-- Header Override (anthropic/openai apikey only) -->
<div v-if="allHeaderOverrideCapable" class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="flex items-center justify-between">
<div class="flex-1 pr-4">
<label
id="bulk-edit-header-override-label"
class="input-label mb-0"
for="bulk-edit-header-override-enabled"
>
{{ t('admin.accounts.headerOverride.title') }}
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.hint') }}
</p>
</div>
<input
v-model="enableHeaderOverride"
id="bulk-edit-header-override-enabled"
type="checkbox"
aria-controls="bulk-edit-header-override-body"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</div>
<div v-if="enableHeaderOverride" id="bulk-edit-header-override-body" class="mt-3 space-y-3">
<button
type="button"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
headerOverrideEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
@click="headerOverrideEnabled = !headerOverrideEnabled"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
headerOverrideEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
<div v-if="headerOverrideEnabled" class="space-y-3">
<div class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
<p class="text-xs text-blue-700 dark:text-blue-400">
<Icon name="exclamationCircle" size="sm" class="mr-1 inline" :stroke-width="2" />
{{ t('admin.accounts.headerOverride.info') }}
</p>
</div>
<p class="text-xs text-amber-600 dark:text-amber-400">
{{ t('admin.accounts.headerOverride.bulkReplaceHint') }}
</p>
<div v-if="headerOverrideRows.length > 0" class="space-y-2">
<div
v-for="(row, index) in headerOverrideRows"
:key="getHeaderOverrideRowKey(row)"
class="flex items-center gap-2"
>
<input
v-model="row.name"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.headerOverride.namePlaceholder')"
/>
<input
v-model="row.value"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.headerOverride.valuePlaceholder')"
/>
<button
type="button"
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
@click="removeHeaderOverrideRow(index)"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
<button
type="button"
class="w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
@click="addHeaderOverrideRow"
>
<svg class="mr-1 inline h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
{{ t('admin.accounts.headerOverride.addRow') }}
</button>
<div v-if="headerOverrideTemplatePlatform" class="flex flex-wrap gap-2">
<button
type="button"
class="rounded-lg bg-primary-50 px-3 py-1 text-xs text-primary-700 transition-colors hover:bg-primary-100 dark:bg-primary-900/30 dark:text-primary-400 dark:hover:bg-primary-900/50"
@click="fillHeaderOverrideTemplate"
>
+ {{ t('admin.accounts.headerOverride.fillTemplate') }}
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.emptyValueHint') }}
</p>
</div>
<p v-else class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.bulkDisableHint') }}
</p>
</div>
</div>
<!-- Proxy --> <!-- Proxy -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
@@ -1149,6 +1272,16 @@ import {
buildModelMappingObject as buildModelMappingPayload, buildModelMappingObject as buildModelMappingPayload,
getPresetMappingsByPlatform getPresetMappingsByPlatform
} from '@/composables/useModelWhitelist' } 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 { import {
OPENAI_WS_MODE_CTX_POOL, OPENAI_WS_MODE_CTX_POOL,
OPENAI_WS_MODE_OFF, OPENAI_WS_MODE_OFF,
@@ -1217,6 +1350,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 配置仅在此条件下显示) // 是否全部为 Anthropic OAuth/SetupToken(RPM 配置仅在此条件下显示)
const allAnthropicOAuthOrSetupToken = computed(() => { const allAnthropicOAuthOrSetupToken = computed(() => {
return ( return (
@@ -1253,6 +1396,7 @@ const enableBaseUrl = ref(false)
const enableModelRestriction = ref(false) const enableModelRestriction = ref(false)
const enableCustomErrorCodes = ref(false) const enableCustomErrorCodes = ref(false)
const enableInterceptWarmup = ref(false) const enableInterceptWarmup = ref(false)
const enableHeaderOverride = ref(false)
const enableProxy = ref(false) const enableProxy = ref(false)
const enableConcurrency = ref(false) const enableConcurrency = ref(false)
const enableLoadFactor = ref(false) const enableLoadFactor = ref(false)
@@ -1281,6 +1425,39 @@ const modelMappings = ref<ModelMapping[]>([])
const selectedErrorCodes = ref<number[]>([]) const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null) const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false) const interceptWarmupRequests = ref(false)
const headerOverrideEnabled = ref(false)
const headerOverrideRows = ref<HeaderOverrideRow[]>([])
const getHeaderOverrideRowKey = createStableObjectKeyResolver<HeaderOverrideRow>('bulk-header-override-row')
const addHeaderOverrideRow = () => {
headerOverrideRows.value.push({ name: '', value: '' })
}
const removeHeaderOverrideRow = (index: number) => {
headerOverrideRows.value.splice(index, 1)
}
// 模板仅在所选账号平台唯一时可用:混合 anthropic+openai 选择无法确定用哪套模板,
// 误填会把另一平台的专有头写进所有所选账号
const headerOverrideTemplatePlatform = computed(() => {
return targetSelectedPlatforms.value.length === 1 ? targetSelectedPlatforms.value[0] : null
})
// 模板按钮:填入所选平台的标准客户端请求头名称(值留空),跳过已存在的同名行
const fillHeaderOverrideTemplate = () => {
const platform = headerOverrideTemplatePlatform.value
if (!platform) return
const existing = new Set(
headerOverrideRows.value.map((row) => row.name.trim().toLowerCase()).filter(Boolean)
)
const rows = headerOverrideRows.value.filter((row) => row.name.trim() || row.value.trim())
for (const row of getHeaderOverrideTemplate(platform)) {
if (!existing.has(row.name)) {
rows.push(row)
}
}
headerOverrideRows.value = rows
}
const proxyId = ref<number | null>(null) const proxyId = ref<number | null>(null)
const concurrency = ref(1) const concurrency = ref(1)
const loadFactor = ref<number | null>(null) const loadFactor = ref<number | null>(null)
@@ -1523,6 +1700,15 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
credentialsChanged = true 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) { if (enableOpenAIWSMode.value) {
const extra = ensureExtra() const extra = ensureExtra()
extra.openai_oauth_responses_websockets_v2_mode = openaiOAuthResponsesWebSocketV2Mode.value extra.openai_oauth_responses_websockets_v2_mode = openaiOAuthResponsesWebSocketV2Mode.value
@@ -1651,6 +1837,7 @@ const handleSubmit = async () => {
enableModelRestriction.value || enableModelRestriction.value ||
enableCustomErrorCodes.value || enableCustomErrorCodes.value ||
enableInterceptWarmup.value || enableInterceptWarmup.value ||
enableHeaderOverride.value ||
enableProxy.value || enableProxy.value ||
enableConcurrency.value || enableConcurrency.value ||
enableLoadFactor.value || enableLoadFactor.value ||
@@ -1672,6 +1859,20 @@ const handleSubmit = async () => {
return return
} }
if (enableHeaderOverride.value && headerOverrideEnabled.value) {
// 批量保存对 header_overrides 是整键替换:开启但没有任何有效行会把所选账号的
// 既有覆写配置静默清空,必须显式拦截(清空请走关闭开关的路径,有专门提示)
if (!headerOverrideRows.value.some((row) => row.name.trim())) {
appStore.showError(t('admin.accounts.headerOverride.bulkEmptyRows'))
return
}
const headerError = validateHeaderOverrideRows(headerOverrideRows.value)
if (headerError) {
appStore.showError(t(`admin.accounts.headerOverride.${headerError}`))
return
}
}
const built = buildUpdatePayload() const built = buildUpdatePayload()
if (!built) { if (!built) {
appStore.showError(t('admin.accounts.bulkEdit.noFieldsSelected')) appStore.showError(t('admin.accounts.bulkEdit.noFieldsSelected'))
@@ -1753,6 +1954,7 @@ watch(
enableModelRestriction.value = false enableModelRestriction.value = false
enableCustomErrorCodes.value = false enableCustomErrorCodes.value = false
enableInterceptWarmup.value = false enableInterceptWarmup.value = false
enableHeaderOverride.value = false
enableProxy.value = false enableProxy.value = false
enableConcurrency.value = false enableConcurrency.value = false
enableLoadFactor.value = false enableLoadFactor.value = false
@@ -1778,6 +1980,8 @@ watch(
selectedErrorCodes.value = [] selectedErrorCodes.value = []
customErrorCodeInput.value = null customErrorCodeInput.value = null
interceptWarmupRequests.value = false interceptWarmupRequests.value = false
headerOverrideEnabled.value = false
headerOverrideRows.value = []
proxyId.value = null proxyId.value = null
concurrency.value = 1 concurrency.value = 1
loadFactor.value = null loadFactor.value = null
@@ -1468,6 +1468,110 @@
</div> </div>
</div> </div>
<!-- Header Override Section (anthropic/openai apikey only) -->
<div
v-if="isHeaderOverridePlatform(form.platform)"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="mb-3 flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.headerOverride.title') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.hint') }}
</p>
</div>
<button
type="button"
@click="headerOverrideEnabled = !headerOverrideEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
headerOverrideEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
headerOverrideEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="headerOverrideEnabled" class="space-y-3">
<div class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
<p class="text-xs text-blue-700 dark:text-blue-400">
<Icon name="exclamationCircle" size="sm" class="mr-1 inline" :stroke-width="2" />
{{ t('admin.accounts.headerOverride.info') }}
</p>
</div>
<div v-if="headerOverrideRows.length > 0" class="space-y-2">
<div
v-for="(row, index) in headerOverrideRows"
:key="getHeaderOverrideRowKey(row)"
class="flex items-center gap-2"
>
<input
v-model="row.name"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.headerOverride.namePlaceholder')"
/>
<input
v-model="row.value"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.headerOverride.valuePlaceholder')"
/>
<button
type="button"
@click="removeHeaderOverrideRow(index)"
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
<button
type="button"
@click="addHeaderOverrideRow"
class="w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
>
<svg class="mr-1 inline h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
{{ t('admin.accounts.headerOverride.addRow') }}
</button>
<div class="flex flex-wrap gap-2">
<button
type="button"
@click="fillHeaderOverrideTemplate"
class="rounded-lg bg-primary-50 px-3 py-1 text-xs text-primary-700 transition-colors hover:bg-primary-100 dark:bg-primary-900/30 dark:text-primary-400 dark:hover:bg-primary-900/50"
>
+ {{ t('admin.accounts.headerOverride.fillTemplate') }}
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.emptyValueHint') }}
</p>
</div>
</div>
</div> </div>
<!-- Bedrock credentials (only for Anthropic Bedrock type) --> <!-- Bedrock credentials (only for Anthropic Bedrock type) -->
@@ -3328,7 +3432,12 @@ import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue' import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
import { import {
applyAntigravityProjectID, applyAntigravityProjectID,
applyInterceptWarmup applyHeaderOverride,
applyInterceptWarmup,
getHeaderOverrideTemplate,
isHeaderOverridePlatform,
validateHeaderOverrideRows,
type HeaderOverrideRow
} from '@/components/account/credentialsBuilder' } from '@/components/account/credentialsBuilder'
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format' import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
@@ -3512,6 +3621,30 @@ function parsePoolModeRetryStatusCodes(input: string): number[] {
const customErrorCodesEnabled = ref(false) const customErrorCodesEnabled = ref(false)
const selectedErrorCodes = ref<number[]>([]) const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null) const customErrorCodeInput = ref<number | null>(null)
const headerOverrideEnabled = ref(false)
const headerOverrideRows = ref<HeaderOverrideRow[]>([])
const addHeaderOverrideRow = () => {
headerOverrideRows.value.push({ name: '', value: '' })
}
const removeHeaderOverrideRow = (index: number) => {
headerOverrideRows.value.splice(index, 1)
}
// 模板按钮:填入标准客户端请求头名称(值留空),跳过已存在的同名行
const fillHeaderOverrideTemplate = () => {
const existing = new Set(
headerOverrideRows.value.map((row) => row.name.trim().toLowerCase()).filter(Boolean)
)
const rows = headerOverrideRows.value.filter((row) => row.name.trim() || row.value.trim())
for (const row of getHeaderOverrideTemplate(form.platform)) {
if (!existing.has(row.name)) {
rows.push(row)
}
}
headerOverrideRows.value = rows
}
const interceptWarmupRequests = ref(false) const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(true) const autoPauseOnExpired = ref(true)
const openaiPassthroughEnabled = ref(false) const openaiPassthroughEnabled = ref(false)
@@ -3569,6 +3702,7 @@ const vertexServiceAccountDragActive = ref(false)
const tempUnschedEnabled = ref(false) const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([]) const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-model-mapping') const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-model-mapping')
const getHeaderOverrideRowKey = createStableObjectKeyResolver<HeaderOverrideRow>('create-header-override-row')
const getOpenAICompactModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-openai-compact-model-mapping') const getOpenAICompactModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-openai-compact-model-mapping')
const getAntigravityModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-antigravity-model-mapping') const getAntigravityModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-antigravity-model-mapping')
const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>('create-temp-unsched-rule') const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>('create-temp-unsched-rule')
@@ -3970,6 +4104,10 @@ watch(
anthropicAPIKeyAuthScheme.value = 'x_api_key' anthropicAPIKeyAuthScheme.value = 'x_api_key'
webSearchEmulationMode.value = 'default' webSearchEmulationMode.value = 'default'
} }
// 请求头覆写为平台相关配置(模板/常用头集合不同),切换平台时清空,
// 避免上一平台的模板行被提交到新平台账号
headerOverrideEnabled.value = false
headerOverrideRows.value = []
// Reset OAuth states // Reset OAuth states
oauth.resetState() oauth.resetState()
openaiOAuth.resetState() openaiOAuth.resetState()
@@ -4359,6 +4497,8 @@ const resetForm = () => {
customErrorCodesEnabled.value = false customErrorCodesEnabled.value = false
selectedErrorCodes.value = [] selectedErrorCodes.value = []
customErrorCodeInput.value = null customErrorCodeInput.value = null
headerOverrideEnabled.value = false
headerOverrideRows.value = []
interceptWarmupRequests.value = false interceptWarmupRequests.value = false
autoPauseOnExpired.value = true autoPauseOnExpired.value = true
openaiPassthroughEnabled.value = false openaiPassthroughEnabled.value = false
@@ -4789,6 +4929,18 @@ const handleSubmit = async () => {
credentials.custom_error_codes = [...selectedErrorCodes.value] 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') applyInterceptWarmup(credentials, interceptWarmupRequests.value, 'create')
if (!applyTempUnschedConfig(credentials)) { if (!applyTempUnschedConfig(credentials)) {
return return
@@ -417,6 +417,110 @@
</div> </div>
</div> </div>
<!-- Header Override Section (anthropic/openai apikey only) -->
<div
v-if="isHeaderOverridePlatform(account.platform)"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="mb-3 flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.headerOverride.title') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.hint') }}
</p>
</div>
<button
type="button"
@click="headerOverrideEnabled = !headerOverrideEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
headerOverrideEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
headerOverrideEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="headerOverrideEnabled" class="space-y-3">
<div class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
<p class="text-xs text-blue-700 dark:text-blue-400">
<Icon name="exclamationCircle" size="sm" class="mr-1 inline" :stroke-width="2" />
{{ t('admin.accounts.headerOverride.info') }}
</p>
</div>
<div v-if="headerOverrideRows.length > 0" class="space-y-2">
<div
v-for="(row, index) in headerOverrideRows"
:key="getHeaderOverrideRowKey(row)"
class="flex items-center gap-2"
>
<input
v-model="row.name"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.headerOverride.namePlaceholder')"
/>
<input
v-model="row.value"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.headerOverride.valuePlaceholder')"
/>
<button
type="button"
@click="removeHeaderOverrideRow(index)"
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
<button
type="button"
@click="addHeaderOverrideRow"
class="w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
>
<svg class="mr-1 inline h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
{{ t('admin.accounts.headerOverride.addRow') }}
</button>
<div class="flex flex-wrap gap-2">
<button
type="button"
@click="fillHeaderOverrideTemplate"
class="rounded-lg bg-primary-50 px-3 py-1 text-xs text-primary-700 transition-colors hover:bg-primary-100 dark:bg-primary-900/30 dark:text-primary-400 dark:hover:bg-primary-900/50"
>
+ {{ t('admin.accounts.headerOverride.fillTemplate') }}
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.headerOverride.emptyValueHint') }}
</p>
</div>
</div>
</div> </div>
<!-- OpenAI/Grok OAuth Model Mapping (OAuth 类型没有 apikey 容器,需要独立的模型映射区域) --> <!-- OpenAI/Grok OAuth Model Mapping (OAuth 类型没有 apikey 容器,需要独立的模型映射区域) -->
@@ -2433,7 +2537,15 @@ import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue' import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
import { import {
applyAntigravityProjectID, applyAntigravityProjectID,
applyInterceptWarmup applyHeaderOverride,
applyInterceptWarmup,
getHeaderOverrideTemplate,
isHeaderOverridePlatform,
splitHeaderOverridesObject,
validateHeaderOverrideRows,
HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY,
HEADER_OVERRIDES_CREDENTIAL_KEY,
type HeaderOverrideRow
} from '@/components/account/credentialsBuilder' } from '@/components/account/credentialsBuilder'
import { formatDateTime, formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format' import { formatDateTime, formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
@@ -2564,6 +2676,30 @@ function formatPoolModeRetryStatusCodes(value: unknown): string {
const customErrorCodesEnabled = ref(false) const customErrorCodesEnabled = ref(false)
const selectedErrorCodes = ref<number[]>([]) const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null) const customErrorCodeInput = ref<number | null>(null)
const headerOverrideEnabled = ref(false)
const headerOverrideRows = ref<HeaderOverrideRow[]>([])
const addHeaderOverrideRow = () => {
headerOverrideRows.value.push({ name: '', value: '' })
}
const removeHeaderOverrideRow = (index: number) => {
headerOverrideRows.value.splice(index, 1)
}
// 模板按钮:填入标准客户端请求头名称(值留空),跳过已存在的同名行
const fillHeaderOverrideTemplate = () => {
const existing = new Set(
headerOverrideRows.value.map((row) => row.name.trim().toLowerCase()).filter(Boolean)
)
const rows = headerOverrideRows.value.filter((row) => row.name.trim() || row.value.trim())
for (const row of getHeaderOverrideTemplate(props.account?.platform || '')) {
if (!existing.has(row.name)) {
rows.push(row)
}
}
headerOverrideRows.value = rows
}
const interceptWarmupRequests = ref(false) const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(false) const autoPauseOnExpired = ref(false)
const autoPause5hThreshold = ref<number | null>(null) const autoPause5hThreshold = ref<number | null>(null)
@@ -2580,6 +2716,7 @@ const isSyncingAntigravityUpstream = ref(false)
const tempUnschedEnabled = ref(false) const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([]) const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-model-mapping') const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-model-mapping')
const getHeaderOverrideRowKey = createStableObjectKeyResolver<HeaderOverrideRow>('edit-header-override-row')
const getOpenAICompactModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-openai-compact-model-mapping') const getOpenAICompactModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-openai-compact-model-mapping')
const getAntigravityModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-antigravity-model-mapping') const getAntigravityModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-antigravity-model-mapping')
const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>('edit-temp-unsched-rule') const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>('edit-temp-unsched-rule')
@@ -3186,6 +3323,10 @@ const syncFormFromAccount = (newAccount: Account | null) => {
loadTempUnschedRules(credentials) loadTempUnschedRules(credentials)
// Reset header override state (loaded below only for apikey accounts)
headerOverrideEnabled.value = false
headerOverrideRows.value = []
// Initialize API Key fields for apikey type // Initialize API Key fields for apikey type
if (newAccount.type === 'apikey' && newAccount.credentials) { if (newAccount.type === 'apikey' && newAccount.credentials) {
const credentials = newAccount.credentials as Record<string, unknown> const credentials = newAccount.credentials as Record<string, unknown>
@@ -3215,6 +3356,14 @@ const syncFormFromAccount = (newAccount: Account | null) => {
} else { } else {
selectedErrorCodes.value = [] 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) { } else if (newAccount.type === 'bedrock' && newAccount.credentials) {
const bedrockCreds = newAccount.credentials as Record<string, unknown> const bedrockCreds = newAccount.credentials as Record<string, unknown>
const authMode = (bedrockCreds.auth_mode as string) || 'sigv4' const authMode = (bedrockCreds.auth_mode as string) || 'sigv4'
@@ -3850,6 +3999,18 @@ const handleSubmit = async () => {
delete newCredentials.custom_error_codes 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 // Add intercept warmup requests setting
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit') applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
if (!applyTempUnschedConfig(newCredentials)) { if (!applyTempUnschedConfig(newCredentials)) {
@@ -1,8 +1,16 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { import {
ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY, ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY,
HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY,
HEADER_OVERRIDES_CREDENTIAL_KEY,
applyAntigravityProjectID, applyAntigravityProjectID,
applyInterceptWarmup applyHeaderOverride,
applyInterceptWarmup,
buildHeaderOverridesObject,
getHeaderOverrideTemplate,
isHeaderOverridePlatform,
splitHeaderOverridesObject,
validateHeaderOverrideRows
} from '../credentialsBuilder' } from '../credentialsBuilder'
describe('applyInterceptWarmup', () => { describe('applyInterceptWarmup', () => {
@@ -82,3 +90,202 @@ describe('applyAntigravityProjectID', () => {
expect(creds[ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY]).toBe('configured-project') 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')
expect(validateHeaderOverrideRows([{ name: 'Content-Type', value: '' }])).toBe('blockedName')
expect(validateHeaderOverrideRows([{ name: 'Cookie', value: '' }])).toBe('blockedName')
expect(validateHeaderOverrideRows([{ name: 'x-goog-api-key', value: '' }])).toBe('blockedName')
})
it('rejects duplicate names case-insensitively', () => {
expect(
validateHeaderOverrideRows([
{ name: 'User-Agent', value: 'a' },
{ name: 'user-agent', value: 'b' }
])
).toBe('duplicateName')
})
})
describe('buildHeaderOverridesObject / splitHeaderOverridesObject', () => {
it('lowercases names, trims values and drops empty-name rows', () => {
expect(
buildHeaderOverridesObject([
{ name: ' User-Agent ', value: ' my-agent ' },
{ name: 'X-App', value: '' },
{ name: '', value: 'ignored' }
])
).toEqual({ 'user-agent': 'my-agent', 'x-app': '' })
})
it('splits an object into sorted rows and ignores non-string values', () => {
expect(
splitHeaderOverridesObject({ 'x-app': 'cli', 'user-agent': 'ua', bogus: 42 })
).toEqual([
{ name: 'user-agent', value: 'ua' },
{ name: 'x-app', value: 'cli' }
])
expect(splitHeaderOverridesObject(null)).toEqual([])
expect(splitHeaderOverridesObject(['a'])).toEqual([])
expect(splitHeaderOverridesObject('str')).toEqual([])
})
it('roundtrips through build and split', () => {
const rows = [
{ name: 'user-agent', value: 'ua' },
{ name: 'x-app', value: 'cli' }
]
expect(splitHeaderOverridesObject(buildHeaderOverridesObject(rows))).toEqual(rows)
})
})
describe('getHeaderOverrideTemplate', () => {
it('returns Claude Code CLI headers with empty values for anthropic', () => {
const rows = getHeaderOverrideTemplate('anthropic')
expect(rows.every((r) => r.value === '')).toBe(true)
const names = rows.map((r) => r.name)
expect(names).toContain('user-agent')
expect(names).toContain('x-app')
expect(names).toContain('anthropic-beta')
expect(names).toContain('x-stainless-lang')
expect(validateHeaderOverrideRows(rows)).toBeNull()
})
it('returns Codex CLI headers with empty values for openai', () => {
const rows = getHeaderOverrideTemplate('openai')
expect(rows.every((r) => r.value === '')).toBe(true)
const names = rows.map((r) => r.name)
expect(names).toContain('user-agent')
expect(names).toContain('originator')
expect(names).toContain('openai-beta')
expect(validateHeaderOverrideRows(rows)).toBeNull()
})
})
describe('applyHeaderOverride', () => {
it('create + enabled: writes enabled flag and overrides object', () => {
const creds: Record<string, unknown> = { api_key: 'sk' }
applyHeaderOverride(creds, true, [{ name: 'User-Agent', value: 'ua' }], 'create')
expect(creds[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY]).toBe(true)
expect(creds[HEADER_OVERRIDES_CREDENTIAL_KEY]).toEqual({ 'user-agent': 'ua' })
})
it('create + disabled: does not add fields', () => {
const creds: Record<string, unknown> = { api_key: 'sk' }
applyHeaderOverride(creds, false, [{ name: 'user-agent', value: 'ua' }], 'create')
expect(HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY in creds).toBe(false)
expect(HEADER_OVERRIDES_CREDENTIAL_KEY in creds).toBe(false)
})
it('edit + disabled: deletes existing fields', () => {
const creds: Record<string, unknown> = {
api_key: 'sk',
[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY]: true,
[HEADER_OVERRIDES_CREDENTIAL_KEY]: { 'user-agent': 'ua' }
}
applyHeaderOverride(creds, false, [], 'edit')
expect(HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY in creds).toBe(false)
expect(HEADER_OVERRIDES_CREDENTIAL_KEY in creds).toBe(false)
expect(creds.api_key).toBe('sk')
})
it('edit + enabled: replaces overrides object wholesale', () => {
const creds: Record<string, unknown> = {
[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY]: true,
[HEADER_OVERRIDES_CREDENTIAL_KEY]: { 'x-old': 'old' }
}
applyHeaderOverride(creds, true, [{ name: 'x-new', value: 'new' }], 'edit')
expect(creds[HEADER_OVERRIDES_CREDENTIAL_KEY]).toEqual({ 'x-new': 'new' })
})
})
describe('validateHeaderOverrideRows value/entry limits', () => {
it('rejects websocket handshake headers', () => {
expect(validateHeaderOverrideRows([{ name: 'Sec-WebSocket-Key', value: '' }])).toBe(
'blockedName'
)
})
it('rejects control characters in values', () => {
expect(validateHeaderOverrideRows([{ name: 'x-app', value: 'a\x0bb' }])).toBe('invalidValue')
})
it('rejects oversized values', () => {
expect(validateHeaderOverrideRows([{ name: 'x-app', value: 'a'.repeat(8193) }])).toBe(
'invalidValue'
)
})
it('measures value length in UTF-8 bytes to match backend', () => {
// 3000 个 CJK 字符 = 3000 UTF-16 code units,但 9000 UTF-8 字节 > 8192
expect(validateHeaderOverrideRows([{ name: 'x-app', value: '测'.repeat(3000) }])).toBe(
'invalidValue'
)
expect(validateHeaderOverrideRows([{ name: 'x-app', value: '测'.repeat(2000) }])).toBeNull()
})
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'
)
expect(validateHeaderOverrideRows([{ name: 'X-Claude-Code-Session-Id', value: '' }])).toBe(
'blockedName'
)
expect(validateHeaderOverrideRows([{ name: 'x-client-request-id', value: '' }])).toBe(
'blockedName'
)
})
it('allows tab inside value', () => {
expect(validateHeaderOverrideRows([{ name: 'x-app', value: 'a\tb' }])).toBeNull()
})
it('rejects oversized names', () => {
expect(validateHeaderOverrideRows([{ name: 'x'.repeat(201), value: 'v' }])).toBe('invalidName')
})
})
@@ -24,3 +24,180 @@ export function applyAntigravityProjectID(
delete credentials[ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY] 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',
'content-type',
'transfer-encoding',
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'proxy-connection',
'te',
'trailer',
'upgrade',
'authorization',
'x-api-key',
'x-goog-api-key',
'cookie',
'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',
'x-claude-code-session-id',
'x-client-request-id'
])
/** RFC 7230 token:合法的 HTTP header 名称字符集 */
const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
function isValidHeaderOverrideName(name: string): boolean {
return HEADER_NAME_PATTERN.test(name)
}
/** 模板: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]/
/** 长度限制按 UTF-8 字节计(与后端 Go len() 对齐,避免多字节值前端放行后端 400) */
const HEADER_TEXT_ENCODER = new TextEncoder()
function utf8ByteLength(value: string): number {
return HEADER_TEXT_ENCODER.encode(value).length
}
/**
* 校验请求头覆写行,返回首个错误的 i18n key(无错误返回 null)。
* 名称为空但值非空 → invalidName;名称非法 → invalidName;
* 禁止覆写 → blockedName;大小写不敏感重名 → duplicateName;
* 值含控制字符或超长 → invalidValue;条目过多 → tooManyEntries。
*/
export function validateHeaderOverrideRows(
rows: HeaderOverrideRow[]
): 'invalidName' | 'blockedName' | 'duplicateName' | 'invalidValue' | 'tooManyEntries' | null {
const seen = new Set<string>()
for (const row of rows) {
const name = row.name.trim()
const value = row.value.trim()
if (!name) {
if (value) return 'invalidName'
continue
}
if (!isValidHeaderOverrideName(name) || name.length > HEADER_OVERRIDE_MAX_NAME_LENGTH) {
return 'invalidName'
}
const lower = name.toLowerCase()
if (HEADER_OVERRIDE_BLOCKED_NAMES.has(lower)) return 'blockedName'
if (seen.has(lower)) return 'duplicateName'
if (
HEADER_VALUE_INVALID_PATTERN.test(value) ||
utf8ByteLength(value) > HEADER_OVERRIDE_MAX_VALUE_LENGTH
) {
return 'invalidValue'
}
seen.add(lower)
}
if (seen.size > HEADER_OVERRIDE_MAX_ENTRIES) return 'tooManyEntries'
return null
}
/** 行数组 → credentials 存储对象(名称小写化,丢弃空行) */
export function buildHeaderOverridesObject(rows: HeaderOverrideRow[]): Record<string, string> {
const result: Record<string, string> = {}
for (const row of rows) {
const name = row.name.trim().toLowerCase()
if (!name) continue
result[name] = row.value.trim()
}
return result
}
/** credentials 存储对象 → 行数组(按名称排序保证稳定展示) */
export function splitHeaderOverridesObject(record: unknown): HeaderOverrideRow[] {
if (!record || typeof record !== 'object' || Array.isArray(record)) return []
return Object.entries(record as Record<string, unknown>)
.filter(([, value]) => typeof value === 'string')
.map(([name, value]) => ({ name, value: value as string }))
.sort((a, b) => a.name.localeCompare(b.name))
}
/**
* 将请求头覆写写入 credentials。
* create 模式:关闭时不写入任何字段;edit 模式:关闭时删除字段(全量替换语义)。
*/
export function applyHeaderOverride(
credentials: Record<string, unknown>,
enabled: boolean,
rows: HeaderOverrideRow[],
mode: 'create' | 'edit'
): void {
if (enabled) {
credentials[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY] = true
credentials[HEADER_OVERRIDES_CREDENTIAL_KEY] = buildHeaderOverridesObject(rows)
} else if (mode === 'edit') {
delete credentials[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY]
delete credentials[HEADER_OVERRIDES_CREDENTIAL_KEY]
}
}
+18
View File
@@ -3674,6 +3674,24 @@ export default {
interceptWarmupRequests: 'Intercept Warmup Requests', interceptWarmupRequests: 'Intercept Warmup Requests',
interceptWarmupRequestsDesc: interceptWarmupRequestsDesc:
'When enabled, warmup requests like title generation will return mock responses without consuming upstream tokens', '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.',
bulkReplaceHint: 'Saving will replace the existing header override configuration on all selected accounts with the rows below.',
bulkEmptyRows: 'Add at least one header row before saving, or turn the toggle off to clear existing configuration.',
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', autoPauseOnExpired: 'Auto Pause On Expired',
autoPauseOnExpiredDesc: 'When enabled, the account will auto pause scheduling after it expires', autoPauseOnExpiredDesc: 'When enabled, the account will auto pause scheduling after it expires',
autoPause5hThreshold: '5h Usage Threshold (%)', autoPause5hThreshold: '5h Usage Threshold (%)',
+18
View File
@@ -3842,6 +3842,24 @@ export default {
errorCodeExists: '该错误码已被选中', errorCodeExists: '该错误码已被选中',
interceptWarmupRequests: '拦截预热请求', interceptWarmupRequests: '拦截预热请求',
interceptWarmupRequestsDesc: '启用后,标题生成等预热请求将返回 mock 响应,不消耗上游 token', interceptWarmupRequestsDesc: '启用后,标题生成等预热请求将返回 mock 响应,不消耗上游 token',
headerOverride: {
title: '请求头覆写',
hint: '转发时用配置值覆盖同名请求头(不区分大小写)',
info: '仅对本账号的出站请求生效:配置的请求头会在转发前覆盖客户端/网关生成的同名头。认证头(authorization、x-api-key)与连接控制头不允许覆写。',
namePlaceholder: '请求头名称(如 user-agent)',
valuePlaceholder: '覆写值(留空表示不覆写)',
addRow: '添加请求头',
fillTemplate: '填入模板',
emptyValueHint: '值留空的行不会参与覆盖,仅作为待填写的占位。',
bulkDisableHint: '保存后将关闭所选账号的请求头覆写并清空已有配置。',
bulkReplaceHint: '保存后将用下方配置整体替换所选账号已有的请求头覆写配置。',
bulkEmptyRows: '请至少添加一行请求头再保存;如需清空已有配置,请关闭上方开关。',
invalidName: '请求头名称格式不正确(仅允许字母、数字和 !#$%&\'*+-.^_`|~ 字符)',
blockedName: '该请求头不允许覆写(认证头与连接控制头由系统管理)',
duplicateName: '存在重复的请求头名称(匹配不区分大小写)',
invalidValue: '请求头值不合法(不允许控制字符,长度不超过 8192)',
tooManyEntries: '请求头覆写条目过多(最多 64 条)'
},
autoPauseOnExpired: '过期自动暂停调度', autoPauseOnExpired: '过期自动暂停调度',
autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度', autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度',
autoPause5hThreshold: '5h 用量阈值(%)', autoPause5hThreshold: '5h 用量阈值(%)',