mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-21 14:19:18 +08:00
feat: apikey 账号支持请求头覆写(Anthropic/OpenAI)
- 账号 credentials 新增 header_override_enabled / header_overrides, 仅对 anthropic/openai 平台的 api_key 账号生效 - 转发前对同名请求头做大小写不敏感覆盖(EqualFold 全量删除后按 wire casing 写入单值),值为空的条目视为占位不参与覆盖 - 覆盖全部出站路径:/v1/messages(标准+透传)、count_tokens、 /v1/responses、chat completions、embeddings、images、全部 WS 模式、 账号测试/探测、上游模型列表 - 创建/编辑/批量更新统一校验:RFC 7230 名称格式、去重、长度/条目上限、 24 个禁止覆写头(认证/连接控制/accept-encoding/sec-websocket-*/ 会话隔离头),应用时二次防御过滤 - 前端三个账号弹窗新增开关+键值行编辑器+模板按钮(Claude Code CLI / Codex CLI 标准头,值为空),本地校验与后端规则对齐,i18n 中英文
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -231,6 +231,9 @@ func (s *OpenAIGatewayService) buildInputTokensUpstreamRequest(
|
||||
}
|
||||
}
|
||||
|
||||
// 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
|
||||
account.ApplyHeaderOverrides(req.Header)
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -486,6 +486,125 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header Override (anthropic/openai apikey only) -->
|
||||
<div v-if="allHeaderOverrideCapable" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 pr-4">
|
||||
<label
|
||||
id="bulk-edit-header-override-label"
|
||||
class="input-label mb-0"
|
||||
for="bulk-edit-header-override-enabled"
|
||||
>
|
||||
{{ t('admin.accounts.headerOverride.title') }}
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.headerOverride.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
v-model="enableHeaderOverride"
|
||||
id="bulk-edit-header-override-enabled"
|
||||
type="checkbox"
|
||||
aria-controls="bulk-edit-header-override-body"
|
||||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="enableHeaderOverride" id="bulk-edit-header-override-body" class="mt-3 space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
headerOverrideEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
@click="headerOverrideEnabled = !headerOverrideEnabled"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
headerOverrideEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div v-if="headerOverrideEnabled" class="space-y-3">
|
||||
<div class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
|
||||
<p class="text-xs text-blue-700 dark:text-blue-400">
|
||||
<Icon name="exclamationCircle" size="sm" class="mr-1 inline" :stroke-width="2" />
|
||||
{{ t('admin.accounts.headerOverride.info') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="headerOverrideRows.length > 0" class="space-y-2">
|
||||
<div
|
||||
v-for="(row, index) in headerOverrideRows"
|
||||
:key="getHeaderOverrideRowKey(row)"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
v-model="row.name"
|
||||
type="text"
|
||||
class="input flex-1"
|
||||
:placeholder="t('admin.accounts.headerOverride.namePlaceholder')"
|
||||
/>
|
||||
<input
|
||||
v-model="row.value"
|
||||
type="text"
|
||||
class="input flex-1"
|
||||
:placeholder="t('admin.accounts.headerOverride.valuePlaceholder')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
@click="removeHeaderOverrideRow(index)"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
|
||||
@click="addHeaderOverrideRow"
|
||||
>
|
||||
<svg class="mr-1 inline h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.headerOverride.addRow') }}
|
||||
</button>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-primary-50 px-3 py-1 text-xs text-primary-700 transition-colors hover:bg-primary-100 dark:bg-primary-900/30 dark:text-primary-400 dark:hover:bg-primary-900/50"
|
||||
@click="fillHeaderOverrideTemplate"
|
||||
>
|
||||
+ {{ t('admin.accounts.headerOverride.fillTemplate') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.headerOverride.emptyValueHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<p v-else class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.headerOverride.bulkDisableHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Proxy -->
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
@@ -1149,6 +1268,16 @@ import {
|
||||
buildModelMappingObject as buildModelMappingPayload,
|
||||
getPresetMappingsByPlatform
|
||||
} from '@/composables/useModelWhitelist'
|
||||
import {
|
||||
buildHeaderOverridesObject,
|
||||
getHeaderOverrideTemplate,
|
||||
isHeaderOverridePlatform,
|
||||
validateHeaderOverrideRows,
|
||||
HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY,
|
||||
HEADER_OVERRIDES_CREDENTIAL_KEY,
|
||||
type HeaderOverrideRow
|
||||
} from '@/components/account/credentialsBuilder'
|
||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||
import {
|
||||
OPENAI_WS_MODE_CTX_POOL,
|
||||
OPENAI_WS_MODE_OFF,
|
||||
@@ -1217,6 +1346,16 @@ const allOpenAIAPIKey = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
// 是否全部为 anthropic/openai 平台的 apikey 账号(请求头覆写仅在此条件下显示)
|
||||
const allHeaderOverrideCapable = computed(() => {
|
||||
return (
|
||||
targetSelectedPlatforms.value.length > 0 &&
|
||||
targetSelectedPlatforms.value.every(p => isHeaderOverridePlatform(p)) &&
|
||||
targetSelectedTypes.value.length > 0 &&
|
||||
targetSelectedTypes.value.every(t => t === 'apikey')
|
||||
)
|
||||
})
|
||||
|
||||
// 是否全部为 Anthropic OAuth/SetupToken(RPM 配置仅在此条件下显示)
|
||||
const allAnthropicOAuthOrSetupToken = computed(() => {
|
||||
return (
|
||||
@@ -1253,6 +1392,7 @@ const enableBaseUrl = ref(false)
|
||||
const enableModelRestriction = ref(false)
|
||||
const enableCustomErrorCodes = ref(false)
|
||||
const enableInterceptWarmup = ref(false)
|
||||
const enableHeaderOverride = ref(false)
|
||||
const enableProxy = ref(false)
|
||||
const enableConcurrency = ref(false)
|
||||
const enableLoadFactor = ref(false)
|
||||
@@ -1281,6 +1421,36 @@ const modelMappings = ref<ModelMapping[]>([])
|
||||
const selectedErrorCodes = ref<number[]>([])
|
||||
const customErrorCodeInput = ref<number | null>(null)
|
||||
const interceptWarmupRequests = ref(false)
|
||||
const headerOverrideEnabled = ref(false)
|
||||
const headerOverrideRows = ref<HeaderOverrideRow[]>([])
|
||||
const getHeaderOverrideRowKey = createStableObjectKeyResolver<HeaderOverrideRow>('bulk-header-override-row')
|
||||
|
||||
const addHeaderOverrideRow = () => {
|
||||
headerOverrideRows.value.push({ name: '', value: '' })
|
||||
}
|
||||
|
||||
const removeHeaderOverrideRow = (index: number) => {
|
||||
headerOverrideRows.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// 模板按钮:填入标准客户端请求头名称(值留空),跳过已存在的同名行。
|
||||
// 目标全为 openai 时用 Codex 模板,否则用 Claude Code 模板。
|
||||
const fillHeaderOverrideTemplate = () => {
|
||||
const platform =
|
||||
targetSelectedPlatforms.value.length === 1 && targetSelectedPlatforms.value[0] === 'openai'
|
||||
? 'openai'
|
||||
: 'anthropic'
|
||||
const existing = new Set(
|
||||
headerOverrideRows.value.map((row) => row.name.trim().toLowerCase()).filter(Boolean)
|
||||
)
|
||||
const rows = headerOverrideRows.value.filter((row) => row.name.trim() || row.value.trim())
|
||||
for (const row of getHeaderOverrideTemplate(platform)) {
|
||||
if (!existing.has(row.name)) {
|
||||
rows.push(row)
|
||||
}
|
||||
}
|
||||
headerOverrideRows.value = rows
|
||||
}
|
||||
const proxyId = ref<number | null>(null)
|
||||
const concurrency = ref(1)
|
||||
const loadFactor = ref<number | null>(null)
|
||||
@@ -1523,6 +1693,15 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
|
||||
credentialsChanged = true
|
||||
}
|
||||
|
||||
if (enableHeaderOverride.value) {
|
||||
// 后端使用 JSONB || merge 语义:关闭时显式写入 false + 空对象以清除旧配置
|
||||
credentials[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY] = headerOverrideEnabled.value
|
||||
credentials[HEADER_OVERRIDES_CREDENTIAL_KEY] = headerOverrideEnabled.value
|
||||
? buildHeaderOverridesObject(headerOverrideRows.value)
|
||||
: {}
|
||||
credentialsChanged = true
|
||||
}
|
||||
|
||||
if (enableOpenAIWSMode.value) {
|
||||
const extra = ensureExtra()
|
||||
extra.openai_oauth_responses_websockets_v2_mode = openaiOAuthResponsesWebSocketV2Mode.value
|
||||
@@ -1651,6 +1830,7 @@ const handleSubmit = async () => {
|
||||
enableModelRestriction.value ||
|
||||
enableCustomErrorCodes.value ||
|
||||
enableInterceptWarmup.value ||
|
||||
enableHeaderOverride.value ||
|
||||
enableProxy.value ||
|
||||
enableConcurrency.value ||
|
||||
enableLoadFactor.value ||
|
||||
@@ -1672,6 +1852,14 @@ const handleSubmit = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (enableHeaderOverride.value && headerOverrideEnabled.value) {
|
||||
const headerError = validateHeaderOverrideRows(headerOverrideRows.value)
|
||||
if (headerError) {
|
||||
appStore.showError(t(`admin.accounts.headerOverride.${headerError}`))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const built = buildUpdatePayload()
|
||||
if (!built) {
|
||||
appStore.showError(t('admin.accounts.bulkEdit.noFieldsSelected'))
|
||||
@@ -1753,6 +1941,7 @@ watch(
|
||||
enableModelRestriction.value = false
|
||||
enableCustomErrorCodes.value = false
|
||||
enableInterceptWarmup.value = false
|
||||
enableHeaderOverride.value = false
|
||||
enableProxy.value = false
|
||||
enableConcurrency.value = false
|
||||
enableLoadFactor.value = false
|
||||
@@ -1778,6 +1967,8 @@ watch(
|
||||
selectedErrorCodes.value = []
|
||||
customErrorCodeInput.value = null
|
||||
interceptWarmupRequests.value = false
|
||||
headerOverrideEnabled.value = false
|
||||
headerOverrideRows.value = []
|
||||
proxyId.value = null
|
||||
concurrency.value = 1
|
||||
loadFactor.value = null
|
||||
|
||||
@@ -1468,6 +1468,110 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header Override Section (anthropic/openai apikey only) -->
|
||||
<div
|
||||
v-if="isHeaderOverridePlatform(form.platform)"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.headerOverride.title') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.headerOverride.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="headerOverrideEnabled = !headerOverrideEnabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
headerOverrideEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
headerOverrideEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="headerOverrideEnabled" class="space-y-3">
|
||||
<div class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
|
||||
<p class="text-xs text-blue-700 dark:text-blue-400">
|
||||
<Icon name="exclamationCircle" size="sm" class="mr-1 inline" :stroke-width="2" />
|
||||
{{ t('admin.accounts.headerOverride.info') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="headerOverrideRows.length > 0" class="space-y-2">
|
||||
<div
|
||||
v-for="(row, index) in headerOverrideRows"
|
||||
:key="getHeaderOverrideRowKey(row)"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
v-model="row.name"
|
||||
type="text"
|
||||
class="input flex-1"
|
||||
:placeholder="t('admin.accounts.headerOverride.namePlaceholder')"
|
||||
/>
|
||||
<input
|
||||
v-model="row.value"
|
||||
type="text"
|
||||
class="input flex-1"
|
||||
:placeholder="t('admin.accounts.headerOverride.valuePlaceholder')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeHeaderOverrideRow(index)"
|
||||
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="addHeaderOverrideRow"
|
||||
class="w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg class="mr-1 inline h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.headerOverride.addRow') }}
|
||||
</button>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="fillHeaderOverrideTemplate"
|
||||
class="rounded-lg bg-primary-50 px-3 py-1 text-xs text-primary-700 transition-colors hover:bg-primary-100 dark:bg-primary-900/30 dark:text-primary-400 dark:hover:bg-primary-900/50"
|
||||
>
|
||||
+ {{ t('admin.accounts.headerOverride.fillTemplate') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.headerOverride.emptyValueHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Bedrock credentials (only for Anthropic Bedrock type) -->
|
||||
@@ -3328,7 +3432,12 @@ import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.
|
||||
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
|
||||
import {
|
||||
applyAntigravityProjectID,
|
||||
applyInterceptWarmup
|
||||
applyHeaderOverride,
|
||||
applyInterceptWarmup,
|
||||
getHeaderOverrideTemplate,
|
||||
isHeaderOverridePlatform,
|
||||
validateHeaderOverrideRows,
|
||||
type HeaderOverrideRow
|
||||
} from '@/components/account/credentialsBuilder'
|
||||
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
|
||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||
@@ -3512,6 +3621,30 @@ function parsePoolModeRetryStatusCodes(input: string): number[] {
|
||||
const customErrorCodesEnabled = ref(false)
|
||||
const selectedErrorCodes = ref<number[]>([])
|
||||
const customErrorCodeInput = ref<number | null>(null)
|
||||
const headerOverrideEnabled = ref(false)
|
||||
const headerOverrideRows = ref<HeaderOverrideRow[]>([])
|
||||
|
||||
const addHeaderOverrideRow = () => {
|
||||
headerOverrideRows.value.push({ name: '', value: '' })
|
||||
}
|
||||
|
||||
const removeHeaderOverrideRow = (index: number) => {
|
||||
headerOverrideRows.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// 模板按钮:填入标准客户端请求头名称(值留空),跳过已存在的同名行
|
||||
const fillHeaderOverrideTemplate = () => {
|
||||
const existing = new Set(
|
||||
headerOverrideRows.value.map((row) => row.name.trim().toLowerCase()).filter(Boolean)
|
||||
)
|
||||
const rows = headerOverrideRows.value.filter((row) => row.name.trim() || row.value.trim())
|
||||
for (const row of getHeaderOverrideTemplate(form.platform)) {
|
||||
if (!existing.has(row.name)) {
|
||||
rows.push(row)
|
||||
}
|
||||
}
|
||||
headerOverrideRows.value = rows
|
||||
}
|
||||
const interceptWarmupRequests = ref(false)
|
||||
const autoPauseOnExpired = ref(true)
|
||||
const openaiPassthroughEnabled = ref(false)
|
||||
@@ -3569,6 +3702,7 @@ const vertexServiceAccountDragActive = ref(false)
|
||||
const tempUnschedEnabled = ref(false)
|
||||
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
|
||||
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-model-mapping')
|
||||
const getHeaderOverrideRowKey = createStableObjectKeyResolver<HeaderOverrideRow>('create-header-override-row')
|
||||
const getOpenAICompactModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-openai-compact-model-mapping')
|
||||
const getAntigravityModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-antigravity-model-mapping')
|
||||
const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>('create-temp-unsched-rule')
|
||||
@@ -4359,6 +4493,8 @@ const resetForm = () => {
|
||||
customErrorCodesEnabled.value = false
|
||||
selectedErrorCodes.value = []
|
||||
customErrorCodeInput.value = null
|
||||
headerOverrideEnabled.value = false
|
||||
headerOverrideRows.value = []
|
||||
interceptWarmupRequests.value = false
|
||||
autoPauseOnExpired.value = true
|
||||
openaiPassthroughEnabled.value = false
|
||||
@@ -4789,6 +4925,18 @@ const handleSubmit = async () => {
|
||||
credentials.custom_error_codes = [...selectedErrorCodes.value]
|
||||
}
|
||||
|
||||
// Add header override if enabled (anthropic/openai apikey only)
|
||||
if (isHeaderOverridePlatform(form.platform)) {
|
||||
if (headerOverrideEnabled.value) {
|
||||
const headerError = validateHeaderOverrideRows(headerOverrideRows.value)
|
||||
if (headerError) {
|
||||
appStore.showError(t(`admin.accounts.headerOverride.${headerError}`))
|
||||
return
|
||||
}
|
||||
}
|
||||
applyHeaderOverride(credentials, headerOverrideEnabled.value, headerOverrideRows.value, 'create')
|
||||
}
|
||||
|
||||
applyInterceptWarmup(credentials, interceptWarmupRequests.value, 'create')
|
||||
if (!applyTempUnschedConfig(credentials)) {
|
||||
return
|
||||
|
||||
@@ -417,6 +417,110 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header Override Section (anthropic/openai apikey only) -->
|
||||
<div
|
||||
v-if="isHeaderOverridePlatform(account.platform)"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.headerOverride.title') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.headerOverride.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="headerOverrideEnabled = !headerOverrideEnabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
headerOverrideEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
headerOverrideEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="headerOverrideEnabled" class="space-y-3">
|
||||
<div class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
|
||||
<p class="text-xs text-blue-700 dark:text-blue-400">
|
||||
<Icon name="exclamationCircle" size="sm" class="mr-1 inline" :stroke-width="2" />
|
||||
{{ t('admin.accounts.headerOverride.info') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="headerOverrideRows.length > 0" class="space-y-2">
|
||||
<div
|
||||
v-for="(row, index) in headerOverrideRows"
|
||||
:key="getHeaderOverrideRowKey(row)"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
v-model="row.name"
|
||||
type="text"
|
||||
class="input flex-1"
|
||||
:placeholder="t('admin.accounts.headerOverride.namePlaceholder')"
|
||||
/>
|
||||
<input
|
||||
v-model="row.value"
|
||||
type="text"
|
||||
class="input flex-1"
|
||||
:placeholder="t('admin.accounts.headerOverride.valuePlaceholder')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeHeaderOverrideRow(index)"
|
||||
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="addHeaderOverrideRow"
|
||||
class="w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg class="mr-1 inline h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.headerOverride.addRow') }}
|
||||
</button>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="fillHeaderOverrideTemplate"
|
||||
class="rounded-lg bg-primary-50 px-3 py-1 text-xs text-primary-700 transition-colors hover:bg-primary-100 dark:bg-primary-900/30 dark:text-primary-400 dark:hover:bg-primary-900/50"
|
||||
>
|
||||
+ {{ t('admin.accounts.headerOverride.fillTemplate') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.headerOverride.emptyValueHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- OpenAI/Grok OAuth Model Mapping (OAuth 类型没有 apikey 容器,需要独立的模型映射区域) -->
|
||||
@@ -2433,7 +2537,15 @@ import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.
|
||||
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
|
||||
import {
|
||||
applyAntigravityProjectID,
|
||||
applyInterceptWarmup
|
||||
applyHeaderOverride,
|
||||
applyInterceptWarmup,
|
||||
getHeaderOverrideTemplate,
|
||||
isHeaderOverridePlatform,
|
||||
splitHeaderOverridesObject,
|
||||
validateHeaderOverrideRows,
|
||||
HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY,
|
||||
HEADER_OVERRIDES_CREDENTIAL_KEY,
|
||||
type HeaderOverrideRow
|
||||
} from '@/components/account/credentialsBuilder'
|
||||
import { formatDateTime, formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
|
||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||
@@ -2564,6 +2676,30 @@ function formatPoolModeRetryStatusCodes(value: unknown): string {
|
||||
const customErrorCodesEnabled = ref(false)
|
||||
const selectedErrorCodes = ref<number[]>([])
|
||||
const customErrorCodeInput = ref<number | null>(null)
|
||||
const headerOverrideEnabled = ref(false)
|
||||
const headerOverrideRows = ref<HeaderOverrideRow[]>([])
|
||||
|
||||
const addHeaderOverrideRow = () => {
|
||||
headerOverrideRows.value.push({ name: '', value: '' })
|
||||
}
|
||||
|
||||
const removeHeaderOverrideRow = (index: number) => {
|
||||
headerOverrideRows.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// 模板按钮:填入标准客户端请求头名称(值留空),跳过已存在的同名行
|
||||
const fillHeaderOverrideTemplate = () => {
|
||||
const existing = new Set(
|
||||
headerOverrideRows.value.map((row) => row.name.trim().toLowerCase()).filter(Boolean)
|
||||
)
|
||||
const rows = headerOverrideRows.value.filter((row) => row.name.trim() || row.value.trim())
|
||||
for (const row of getHeaderOverrideTemplate(props.account?.platform || '')) {
|
||||
if (!existing.has(row.name)) {
|
||||
rows.push(row)
|
||||
}
|
||||
}
|
||||
headerOverrideRows.value = rows
|
||||
}
|
||||
const interceptWarmupRequests = ref(false)
|
||||
const autoPauseOnExpired = ref(false)
|
||||
const autoPause5hThreshold = ref<number | null>(null)
|
||||
@@ -2580,6 +2716,7 @@ const isSyncingAntigravityUpstream = ref(false)
|
||||
const tempUnschedEnabled = ref(false)
|
||||
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
|
||||
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-model-mapping')
|
||||
const getHeaderOverrideRowKey = createStableObjectKeyResolver<HeaderOverrideRow>('edit-header-override-row')
|
||||
const getOpenAICompactModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-openai-compact-model-mapping')
|
||||
const getAntigravityModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-antigravity-model-mapping')
|
||||
const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>('edit-temp-unsched-rule')
|
||||
@@ -3186,6 +3323,10 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
|
||||
loadTempUnschedRules(credentials)
|
||||
|
||||
// Reset header override state (loaded below only for apikey accounts)
|
||||
headerOverrideEnabled.value = false
|
||||
headerOverrideRows.value = []
|
||||
|
||||
// Initialize API Key fields for apikey type
|
||||
if (newAccount.type === 'apikey' && newAccount.credentials) {
|
||||
const credentials = newAccount.credentials as Record<string, unknown>
|
||||
@@ -3215,6 +3356,14 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
} else {
|
||||
selectedErrorCodes.value = []
|
||||
}
|
||||
|
||||
// Load header override (anthropic/openai apikey only)
|
||||
headerOverrideEnabled.value =
|
||||
isHeaderOverridePlatform(newAccount.platform) &&
|
||||
credentials[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY] === true
|
||||
headerOverrideRows.value = splitHeaderOverridesObject(
|
||||
credentials[HEADER_OVERRIDES_CREDENTIAL_KEY]
|
||||
)
|
||||
} else if (newAccount.type === 'bedrock' && newAccount.credentials) {
|
||||
const bedrockCreds = newAccount.credentials as Record<string, unknown>
|
||||
const authMode = (bedrockCreds.auth_mode as string) || 'sigv4'
|
||||
@@ -3850,6 +3999,18 @@ const handleSubmit = async () => {
|
||||
delete newCredentials.custom_error_codes
|
||||
}
|
||||
|
||||
// Add header override if enabled (anthropic/openai apikey only)
|
||||
if (isHeaderOverridePlatform(props.account.platform)) {
|
||||
if (headerOverrideEnabled.value) {
|
||||
const headerError = validateHeaderOverrideRows(headerOverrideRows.value)
|
||||
if (headerError) {
|
||||
appStore.showError(t(`admin.accounts.headerOverride.${headerError}`))
|
||||
return
|
||||
}
|
||||
}
|
||||
applyHeaderOverride(newCredentials, headerOverrideEnabled.value, headerOverrideRows.value, 'edit')
|
||||
}
|
||||
|
||||
// Add intercept warmup requests setting
|
||||
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
|
||||
if (!applyTempUnschedConfig(newCredentials)) {
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY,
|
||||
HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY,
|
||||
HEADER_OVERRIDES_CREDENTIAL_KEY,
|
||||
applyAntigravityProjectID,
|
||||
applyInterceptWarmup
|
||||
applyHeaderOverride,
|
||||
applyInterceptWarmup,
|
||||
buildHeaderOverridesObject,
|
||||
getHeaderOverrideTemplate,
|
||||
isHeaderOverridePlatform,
|
||||
splitHeaderOverridesObject,
|
||||
validateHeaderOverrideRows
|
||||
} from '../credentialsBuilder'
|
||||
|
||||
describe('applyInterceptWarmup', () => {
|
||||
@@ -82,3 +90,185 @@ describe('applyAntigravityProjectID', () => {
|
||||
expect(creds[ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY]).toBe('configured-project')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isHeaderOverridePlatform', () => {
|
||||
it('only anthropic and openai are supported', () => {
|
||||
expect(isHeaderOverridePlatform('anthropic')).toBe(true)
|
||||
expect(isHeaderOverridePlatform('openai')).toBe(true)
|
||||
expect(isHeaderOverridePlatform('gemini')).toBe(false)
|
||||
expect(isHeaderOverridePlatform('grok')).toBe(false)
|
||||
expect(isHeaderOverridePlatform('antigravity')).toBe(false)
|
||||
expect(isHeaderOverridePlatform('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateHeaderOverrideRows', () => {
|
||||
it('accepts valid rows and empty placeholder rows', () => {
|
||||
expect(
|
||||
validateHeaderOverrideRows([
|
||||
{ name: 'user-agent', value: 'my-agent/1.0' },
|
||||
{ name: 'x-app', value: '' },
|
||||
{ name: '', value: '' }
|
||||
])
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects empty name with non-empty value', () => {
|
||||
expect(validateHeaderOverrideRows([{ name: '', value: 'v' }])).toBe('invalidName')
|
||||
})
|
||||
|
||||
it('rejects invalid header names', () => {
|
||||
expect(validateHeaderOverrideRows([{ name: 'bad name', value: '' }])).toBe('invalidName')
|
||||
expect(validateHeaderOverrideRows([{ name: 'bad:name', value: '' }])).toBe('invalidName')
|
||||
expect(validateHeaderOverrideRows([{ name: '名称', value: '' }])).toBe('invalidName')
|
||||
})
|
||||
|
||||
it('rejects blocked header names case-insensitively', () => {
|
||||
expect(validateHeaderOverrideRows([{ name: 'Authorization', value: '' }])).toBe('blockedName')
|
||||
expect(validateHeaderOverrideRows([{ name: 'X-Api-Key', value: '' }])).toBe('blockedName')
|
||||
expect(validateHeaderOverrideRows([{ name: 'host', value: '' }])).toBe('blockedName')
|
||||
expect(validateHeaderOverrideRows([{ name: 'Content-Length', value: '' }])).toBe('blockedName')
|
||||
})
|
||||
|
||||
it('rejects duplicate names case-insensitively', () => {
|
||||
expect(
|
||||
validateHeaderOverrideRows([
|
||||
{ name: 'User-Agent', value: 'a' },
|
||||
{ name: 'user-agent', value: 'b' }
|
||||
])
|
||||
).toBe('duplicateName')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildHeaderOverridesObject / splitHeaderOverridesObject', () => {
|
||||
it('lowercases names, trims values and drops empty-name rows', () => {
|
||||
expect(
|
||||
buildHeaderOverridesObject([
|
||||
{ name: ' User-Agent ', value: ' my-agent ' },
|
||||
{ name: 'X-App', value: '' },
|
||||
{ name: '', value: 'ignored' }
|
||||
])
|
||||
).toEqual({ 'user-agent': 'my-agent', 'x-app': '' })
|
||||
})
|
||||
|
||||
it('splits an object into sorted rows and ignores non-string values', () => {
|
||||
expect(
|
||||
splitHeaderOverridesObject({ 'x-app': 'cli', 'user-agent': 'ua', bogus: 42 })
|
||||
).toEqual([
|
||||
{ name: 'user-agent', value: 'ua' },
|
||||
{ name: 'x-app', value: 'cli' }
|
||||
])
|
||||
expect(splitHeaderOverridesObject(null)).toEqual([])
|
||||
expect(splitHeaderOverridesObject(['a'])).toEqual([])
|
||||
expect(splitHeaderOverridesObject('str')).toEqual([])
|
||||
})
|
||||
|
||||
it('roundtrips through build and split', () => {
|
||||
const rows = [
|
||||
{ name: 'user-agent', value: 'ua' },
|
||||
{ name: 'x-app', value: 'cli' }
|
||||
]
|
||||
expect(splitHeaderOverridesObject(buildHeaderOverridesObject(rows))).toEqual(rows)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getHeaderOverrideTemplate', () => {
|
||||
it('returns Claude Code CLI headers with empty values for anthropic', () => {
|
||||
const rows = getHeaderOverrideTemplate('anthropic')
|
||||
expect(rows.every((r) => r.value === '')).toBe(true)
|
||||
const names = rows.map((r) => r.name)
|
||||
expect(names).toContain('user-agent')
|
||||
expect(names).toContain('x-app')
|
||||
expect(names).toContain('anthropic-beta')
|
||||
expect(names).toContain('x-stainless-lang')
|
||||
expect(validateHeaderOverrideRows(rows)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns Codex CLI headers with empty values for openai', () => {
|
||||
const rows = getHeaderOverrideTemplate('openai')
|
||||
expect(rows.every((r) => r.value === '')).toBe(true)
|
||||
const names = rows.map((r) => r.name)
|
||||
expect(names).toContain('user-agent')
|
||||
expect(names).toContain('originator')
|
||||
expect(names).toContain('openai-beta')
|
||||
expect(validateHeaderOverrideRows(rows)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyHeaderOverride', () => {
|
||||
it('create + enabled: writes enabled flag and overrides object', () => {
|
||||
const creds: Record<string, unknown> = { api_key: 'sk' }
|
||||
applyHeaderOverride(creds, true, [{ name: 'User-Agent', value: 'ua' }], 'create')
|
||||
expect(creds[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY]).toBe(true)
|
||||
expect(creds[HEADER_OVERRIDES_CREDENTIAL_KEY]).toEqual({ 'user-agent': 'ua' })
|
||||
})
|
||||
|
||||
it('create + disabled: does not add fields', () => {
|
||||
const creds: Record<string, unknown> = { api_key: 'sk' }
|
||||
applyHeaderOverride(creds, false, [{ name: 'user-agent', value: 'ua' }], 'create')
|
||||
expect(HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY in creds).toBe(false)
|
||||
expect(HEADER_OVERRIDES_CREDENTIAL_KEY in creds).toBe(false)
|
||||
})
|
||||
|
||||
it('edit + disabled: deletes existing fields', () => {
|
||||
const creds: Record<string, unknown> = {
|
||||
api_key: 'sk',
|
||||
[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY]: true,
|
||||
[HEADER_OVERRIDES_CREDENTIAL_KEY]: { 'user-agent': 'ua' }
|
||||
}
|
||||
applyHeaderOverride(creds, false, [], 'edit')
|
||||
expect(HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY in creds).toBe(false)
|
||||
expect(HEADER_OVERRIDES_CREDENTIAL_KEY in creds).toBe(false)
|
||||
expect(creds.api_key).toBe('sk')
|
||||
})
|
||||
|
||||
it('edit + enabled: replaces overrides object wholesale', () => {
|
||||
const creds: Record<string, unknown> = {
|
||||
[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY]: true,
|
||||
[HEADER_OVERRIDES_CREDENTIAL_KEY]: { 'x-old': 'old' }
|
||||
}
|
||||
applyHeaderOverride(creds, true, [{ name: 'x-new', value: 'new' }], 'edit')
|
||||
expect(creds[HEADER_OVERRIDES_CREDENTIAL_KEY]).toEqual({ 'x-new': 'new' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateHeaderOverrideRows value/entry limits', () => {
|
||||
it('rejects websocket handshake headers', () => {
|
||||
expect(validateHeaderOverrideRows([{ name: 'Sec-WebSocket-Key', value: '' }])).toBe(
|
||||
'blockedName'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects control characters in values', () => {
|
||||
expect(validateHeaderOverrideRows([{ name: 'x-app', value: 'a\x0bb' }])).toBe('invalidValue')
|
||||
})
|
||||
|
||||
it('rejects oversized values', () => {
|
||||
expect(validateHeaderOverrideRows([{ name: 'x-app', value: 'a'.repeat(8193) }])).toBe(
|
||||
'invalidValue'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects too many entries', () => {
|
||||
const rows = Array.from({ length: 65 }, (_, i) => ({ name: `x-h-${i}`, value: 'v' }))
|
||||
expect(validateHeaderOverrideRows(rows)).toBe('tooManyEntries')
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateHeaderOverrideRows session isolation headers', () => {
|
||||
it('rejects per-request session headers', () => {
|
||||
expect(validateHeaderOverrideRows([{ name: 'session_id', value: '' }])).toBe('blockedName')
|
||||
expect(validateHeaderOverrideRows([{ name: 'Conversation_ID', value: '' }])).toBe('blockedName')
|
||||
expect(validateHeaderOverrideRows([{ name: 'x-codex-turn-state', value: '' }])).toBe(
|
||||
'blockedName'
|
||||
)
|
||||
})
|
||||
|
||||
it('allows tab inside value', () => {
|
||||
expect(validateHeaderOverrideRows([{ name: 'x-app', value: 'a\tb' }])).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects oversized names', () => {
|
||||
expect(validateHeaderOverrideRows([{ name: 'x'.repeat(201), value: 'v' }])).toBe('invalidName')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,3 +24,170 @@ export function applyAntigravityProjectID(
|
||||
delete credentials[ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY]
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 请求头覆写(仅 anthropic/openai 平台的 api_key 账号) ==========
|
||||
|
||||
export const HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY = 'header_override_enabled'
|
||||
export const HEADER_OVERRIDES_CREDENTIAL_KEY = 'header_overrides'
|
||||
|
||||
export interface HeaderOverrideRow {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/** 请求头覆写支持的平台(与后端 IsHeaderOverrideEligible 保持一致) */
|
||||
export function isHeaderOverridePlatform(platform: string): boolean {
|
||||
return platform === 'anthropic' || platform === 'openai'
|
||||
}
|
||||
|
||||
/** 禁止覆写的请求头(与后端 headerOverrideBlockedNames 保持一致) */
|
||||
const HEADER_OVERRIDE_BLOCKED_NAMES = new Set([
|
||||
'host',
|
||||
'content-length',
|
||||
'transfer-encoding',
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'proxy-authenticate',
|
||||
'proxy-authorization',
|
||||
'proxy-connection',
|
||||
'te',
|
||||
'trailer',
|
||||
'upgrade',
|
||||
'authorization',
|
||||
'x-api-key',
|
||||
'accept-encoding',
|
||||
'sec-websocket-key',
|
||||
'sec-websocket-version',
|
||||
'sec-websocket-extensions',
|
||||
'sec-websocket-protocol',
|
||||
'sec-websocket-accept',
|
||||
'session_id',
|
||||
'conversation_id',
|
||||
'x-codex-turn-state',
|
||||
'x-codex-turn-metadata',
|
||||
'chatgpt-account-id'
|
||||
])
|
||||
|
||||
/** RFC 7230 token:合法的 HTTP header 名称字符集 */
|
||||
const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
|
||||
|
||||
export function isValidHeaderOverrideName(name: string): boolean {
|
||||
return HEADER_NAME_PATTERN.test(name)
|
||||
}
|
||||
|
||||
export function isBlockedHeaderOverrideName(name: string): boolean {
|
||||
return HEADER_OVERRIDE_BLOCKED_NAMES.has(name.trim().toLowerCase())
|
||||
}
|
||||
|
||||
/** 模板:Claude Code CLI 标准客户端请求头(值留空由管理员填写) */
|
||||
const ANTHROPIC_HEADER_OVERRIDE_TEMPLATE = [
|
||||
'user-agent',
|
||||
'x-app',
|
||||
'anthropic-beta',
|
||||
'anthropic-version',
|
||||
'anthropic-dangerous-direct-browser-access',
|
||||
'x-stainless-lang',
|
||||
'x-stainless-package-version',
|
||||
'x-stainless-os',
|
||||
'x-stainless-arch',
|
||||
'x-stainless-runtime',
|
||||
'x-stainless-runtime-version',
|
||||
'x-stainless-retry-count',
|
||||
'x-stainless-timeout'
|
||||
]
|
||||
|
||||
/** 模板:Codex CLI 标准客户端请求头(值留空由管理员填写) */
|
||||
const OPENAI_HEADER_OVERRIDE_TEMPLATE = [
|
||||
'user-agent',
|
||||
'originator',
|
||||
'openai-beta',
|
||||
'version',
|
||||
'accept',
|
||||
'accept-language'
|
||||
]
|
||||
|
||||
export function getHeaderOverrideTemplate(platform: string): HeaderOverrideRow[] {
|
||||
const names =
|
||||
platform === 'openai' ? OPENAI_HEADER_OVERRIDE_TEMPLATE : ANTHROPIC_HEADER_OVERRIDE_TEMPLATE
|
||||
return names.map((name) => ({ name, value: '' }))
|
||||
}
|
||||
|
||||
/** 与后端 maxHeaderOverride* 常量保持一致 */
|
||||
const HEADER_OVERRIDE_MAX_ENTRIES = 64
|
||||
const HEADER_OVERRIDE_MAX_NAME_LENGTH = 200
|
||||
const HEADER_OVERRIDE_MAX_VALUE_LENGTH = 8192
|
||||
|
||||
/** header value 不允许包含控制字符(与后端 httpguts.ValidHeaderFieldValue 对齐) */
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const HEADER_VALUE_INVALID_PATTERN = /[\x00-\x08\x0a-\x1f\x7f]/
|
||||
|
||||
/**
|
||||
* 校验请求头覆写行,返回首个错误的 i18n key(无错误返回 null)。
|
||||
* 名称为空但值非空 → invalidName;名称非法 → invalidName;
|
||||
* 禁止覆写 → blockedName;大小写不敏感重名 → duplicateName;
|
||||
* 值含控制字符或超长 → invalidValue;条目过多 → tooManyEntries。
|
||||
*/
|
||||
export function validateHeaderOverrideRows(
|
||||
rows: HeaderOverrideRow[]
|
||||
): 'invalidName' | 'blockedName' | 'duplicateName' | 'invalidValue' | 'tooManyEntries' | null {
|
||||
const seen = new Set<string>()
|
||||
for (const row of rows) {
|
||||
const name = row.name.trim()
|
||||
const value = row.value.trim()
|
||||
if (!name) {
|
||||
if (value) return 'invalidName'
|
||||
continue
|
||||
}
|
||||
if (!isValidHeaderOverrideName(name) || name.length > HEADER_OVERRIDE_MAX_NAME_LENGTH) {
|
||||
return 'invalidName'
|
||||
}
|
||||
const lower = name.toLowerCase()
|
||||
if (HEADER_OVERRIDE_BLOCKED_NAMES.has(lower)) return 'blockedName'
|
||||
if (seen.has(lower)) return 'duplicateName'
|
||||
if (HEADER_VALUE_INVALID_PATTERN.test(value) || value.length > HEADER_OVERRIDE_MAX_VALUE_LENGTH) {
|
||||
return 'invalidValue'
|
||||
}
|
||||
seen.add(lower)
|
||||
}
|
||||
if (seen.size > HEADER_OVERRIDE_MAX_ENTRIES) return 'tooManyEntries'
|
||||
return null
|
||||
}
|
||||
|
||||
/** 行数组 → credentials 存储对象(名称小写化,丢弃空行) */
|
||||
export function buildHeaderOverridesObject(rows: HeaderOverrideRow[]): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
for (const row of rows) {
|
||||
const name = row.name.trim().toLowerCase()
|
||||
if (!name) continue
|
||||
result[name] = row.value.trim()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** credentials 存储对象 → 行数组(按名称排序保证稳定展示) */
|
||||
export function splitHeaderOverridesObject(record: unknown): HeaderOverrideRow[] {
|
||||
if (!record || typeof record !== 'object' || Array.isArray(record)) return []
|
||||
return Object.entries(record as Record<string, unknown>)
|
||||
.filter(([, value]) => typeof value === 'string')
|
||||
.map(([name, value]) => ({ name, value: value as string }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
/**
|
||||
* 将请求头覆写写入 credentials。
|
||||
* create 模式:关闭时不写入任何字段;edit 模式:关闭时删除字段(全量替换语义)。
|
||||
*/
|
||||
export function applyHeaderOverride(
|
||||
credentials: Record<string, unknown>,
|
||||
enabled: boolean,
|
||||
rows: HeaderOverrideRow[],
|
||||
mode: 'create' | 'edit'
|
||||
): void {
|
||||
if (enabled) {
|
||||
credentials[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY] = true
|
||||
credentials[HEADER_OVERRIDES_CREDENTIAL_KEY] = buildHeaderOverridesObject(rows)
|
||||
} else if (mode === 'edit') {
|
||||
delete credentials[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY]
|
||||
delete credentials[HEADER_OVERRIDES_CREDENTIAL_KEY]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (%)',
|
||||
|
||||
@@ -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 用量阈值(%)',
|
||||
|
||||
Reference in New Issue
Block a user