mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-01 15:02:58 +08:00
feat(grok): 支持账号级自定义上游地址与请求头覆写
将账号级请求头覆写从 anthropic/openai 的 api_key 账号扩展到 Grok 的 api_key 与 oauth 账号,并放开 Grok OAuth 账号的自定义上游地址(仅作用于 转发端点,授权与 token 刷新链路不变)。 后端: - IsHeaderOverrideEligible 扩展到 Grok(api_key+oauth),禁止名单新增 x-grok-conv-id(逐请求会话路由头)。 - 所有 Grok 上游请求路径接线 ApplyHeaderOverrides(Responses/Chat 桥/ 媒体/配额探测/billing 探测/连通性测试),统一置于内置默认头之后。 - GetGrokBaseURL/GetGrokMediaBaseURL 放开 OAuth 自定义地址:官方地址 视同未定制回落官方网关,仅显式第三方 host 改发转发流量。 - 非官方 host 允许任意 path 前缀,官方 host 仍强制 /v1。 - OAuth base_url 校验按 host 判定官方/自定义,自定义 host 恒受运营方 URL 策略约束,不受 XAI_ALLOW_UNSAFE_URL_OVERRIDES 调试开关放宽。 - 给 GrokQuotaService 注入 config,使配额/billing 探测与转发共用同一 URL 策略。 前端: - Edit 模态为 Grok OAuth 账号新增「自定义上游地址」开关。 - 请求头表单新增 JSON 快速导入与按 JSON 一键复制(复用 useClipboard, 兼容非安全上下文 HTTP 页面)。 - 门控改为平台×类型判定,Bulk 批量 base_url 增加格式校验,zh/en 文案同步。
This commit is contained in:
@@ -183,7 +183,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream)
|
||||
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository)
|
||||
grokQuotaFetcher := service.NewGrokQuotaFetcher()
|
||||
grokQuotaService := service.ProvideGrokQuotaService(accountRepository, proxyRepository, grokTokenProvider, httpUpstream, usageLogRepository)
|
||||
grokQuotaService := service.ProvideGrokQuotaService(accountRepository, proxyRepository, grokTokenProvider, httpUpstream, configConfig, usageLogRepository)
|
||||
openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory, openAIGatewayService)
|
||||
usageCache := service.NewUsageCache()
|
||||
accountUsageService := service.ProvideAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, grokQuotaService, openAIQuotaService, usageCache, identityCache, tlsFingerprintProfileService, openAIGatewayService)
|
||||
|
||||
@@ -113,7 +113,7 @@ func TestGrokOAuthHandlerQueryQuotaProbesUpstream(t *testing.T) {
|
||||
},
|
||||
}}
|
||||
upstream := &grokQuotaHandlerUpstream{}
|
||||
quotaService := service.NewGrokQuotaService(repo, nil, service.NewGrokTokenProvider(repo, nil), upstream)
|
||||
quotaService := service.NewGrokQuotaService(repo, nil, service.NewGrokTokenProvider(repo, nil), upstream, nil)
|
||||
handler := NewGrokOAuthHandler(nil, nil, quotaService, nil)
|
||||
|
||||
router := gin.New()
|
||||
@@ -151,7 +151,7 @@ func TestGrokOAuthHandlerResetQuotaReturnsUnsupported(t *testing.T) {
|
||||
Platform: service.PlatformGrok,
|
||||
Type: service.AccountTypeOAuth,
|
||||
}}
|
||||
quotaService := service.NewGrokQuotaService(repo, nil, nil, nil)
|
||||
quotaService := service.NewGrokQuotaService(repo, nil, nil, nil, nil)
|
||||
handler := NewGrokOAuthHandler(nil, nil, quotaService, nil)
|
||||
|
||||
router := gin.New()
|
||||
|
||||
@@ -94,6 +94,21 @@ func BuildBillingURL(formatCredits bool) string {
|
||||
return base + BillingMonthlyPath
|
||||
}
|
||||
|
||||
// BuildBillingURLWithValidator builds the weekly or monthly billing URL against
|
||||
// the caller-resolved base URL, applying the caller's outbound URL trust policy
|
||||
// first. Accounts forwarding through a custom upstream keep their billing
|
||||
// probes on the same upstream.
|
||||
func BuildBillingURLWithValidator(baseURL string, formatCredits bool, validator BaseURLValidator) (string, error) {
|
||||
validatedBaseURL, err := validatedBaseURLWithValidator(baseURL, validator)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid base url: %w", err)
|
||||
}
|
||||
if formatCredits {
|
||||
return validatedBaseURL + BillingWeeklyPath, nil
|
||||
}
|
||||
return validatedBaseURL + BillingMonthlyPath, nil
|
||||
}
|
||||
|
||||
// ApplyCLIBillingHeaders sets Authorization + CLI identity headers for billing GETs.
|
||||
func ApplyCLIBillingHeaders(req *http.Request, accessToken string) {
|
||||
if req == nil {
|
||||
|
||||
@@ -14,6 +14,20 @@ func TestBuildBillingURL(t *testing.T) {
|
||||
require.Equal(t, "https://cli-chat-proxy.grok.com/v1/billing", BuildBillingURL(false))
|
||||
}
|
||||
|
||||
func TestBuildBillingURLWithValidator(t *testing.T) {
|
||||
t.Parallel()
|
||||
weeklyURL, err := BuildBillingURLWithValidator(DefaultCLIBaseURL, true, ValidateTrustedBaseURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://cli-chat-proxy.grok.com/v1/billing?format=credits", weeklyURL)
|
||||
|
||||
monthlyURL, err := BuildBillingURLWithValidator("https://relay.example.test/v1", false, ValidateBaseURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://relay.example.test/v1/billing", monthlyURL)
|
||||
|
||||
_, err = BuildBillingURLWithValidator("https://relay.example.test/v1", true, ValidateTrustedBaseURL)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestApplyCLIBillingHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
req, err := http.NewRequest(http.MethodGet, BuildBillingURL(true), nil)
|
||||
|
||||
@@ -298,6 +298,12 @@ func ValidateTrustedBaseURL(raw string) (string, error) {
|
||||
return normalizeKnownBaseURLPath(normalized)
|
||||
}
|
||||
|
||||
// normalizeKnownBaseURLPath 规范化 base URL 的 path 部分:
|
||||
// - 官方主机固定使用 /v1 前缀(空 path 自动补齐,其余 path 拒绝);
|
||||
// - 其他主机保留管理员配置的任意 path 前缀(第三方转发地址常见
|
||||
// /xxx/v1 之类的路由前缀),空 path 仍按惯例补 /v1。
|
||||
//
|
||||
// 所有主机统一禁止 userinfo/query/fragment,并去除尾部斜杠。
|
||||
func normalizeKnownBaseURLPath(raw string) (string, error) {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
@@ -318,7 +324,7 @@ func normalizeKnownBaseURLPath(raw string) (string, error) {
|
||||
parsed.RawPath = ""
|
||||
return strings.TrimRight(parsed.String(), "/"), nil
|
||||
}
|
||||
if path != "/v1" {
|
||||
if path != "/v1" && IsOfficialBaseURLHost(parsed.Hostname()) {
|
||||
return "", fmt.Errorf("base URL path must be /v1")
|
||||
}
|
||||
parsed.Path = path
|
||||
@@ -326,6 +332,32 @@ func normalizeKnownBaseURLPath(raw string) (string, error) {
|
||||
return strings.TrimRight(parsed.String(), "/"), nil
|
||||
}
|
||||
|
||||
// IsOfficialBaseURLHost 报告 host 是否属于官方 API / CLI 网关主机。
|
||||
func IsOfficialBaseURLHost(host string) bool {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
for _, allowed := range baseURLAllowedHosts {
|
||||
if host == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsOfficialBaseURL 报告 raw 是否指向官方主机(api.x.ai 或 CLI 网关),
|
||||
// 容忍存量凭证中的历史变体(大小写、显式 443 端口、百分号编码 path 等)。
|
||||
// 无法解析的值一并视为官方,调用方据此回落默认端点而不是把流量发往未定义目标。
|
||||
func IsOfficialBaseURL(raw string) bool {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return true
|
||||
}
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return true
|
||||
}
|
||||
return IsOfficialBaseURLHost(parsed.Hostname())
|
||||
}
|
||||
|
||||
func AllowUnsafeURLOverrides() bool {
|
||||
return envBool(EnvAllowUnsafeURLOverrides)
|
||||
}
|
||||
|
||||
@@ -166,6 +166,57 @@ func TestValidateBaseURLAllowsPublicThirdPartyGrokAPI(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestValidateBaseURLPathPrefixPolicy(t *testing.T) {
|
||||
// 非官方主机保留管理员配置的任意 path 前缀。
|
||||
prefixed, err := ValidateBaseURL("https://relay.example.test/xai/v1/")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://relay.example.test/xai/v1", prefixed)
|
||||
|
||||
deepPrefixed, err := ValidateBaseURL("https://relay.example.test/tenant-a/proxy")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://relay.example.test/tenant-a/proxy", deepPrefixed)
|
||||
|
||||
// 空 path 仍按惯例补 /v1,保持既有配置兼容。
|
||||
rootOnly, err := ValidateBaseURL("https://relay.example.test")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://relay.example.test/v1", rootOnly)
|
||||
|
||||
// 官方主机固定 /v1 前缀。
|
||||
_, err = ValidateBaseURL("https://api.x.ai/xai/v1")
|
||||
require.Error(t, err)
|
||||
_, err = ValidateBaseURL("https://cli-chat-proxy.grok.com/other")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestIsOfficialBaseURL(t *testing.T) {
|
||||
official := []string{
|
||||
"",
|
||||
" ",
|
||||
DefaultBaseURL,
|
||||
DefaultCLIBaseURL,
|
||||
"https://api.x.ai",
|
||||
"HTTPS://API.X.AI:443/",
|
||||
"https://api.x.ai:0443/v1",
|
||||
"https://api.x.ai/%76%31",
|
||||
"https://api.x.ai:8443/v1",
|
||||
"HTTPS://CLI-CHAT-PROXY.GROK.COM:443/%76%31/",
|
||||
"::invalid::url", // 无法解析的值按官方处理,回落默认端点
|
||||
}
|
||||
for _, raw := range official {
|
||||
require.True(t, IsOfficialBaseURL(raw), "expected official: %q", raw)
|
||||
}
|
||||
|
||||
custom := []string{
|
||||
"https://relay.example.test/v1",
|
||||
"https://relay.example.test/xai/v1",
|
||||
"http://relay.example.test/v1",
|
||||
"https://grok.com.evil.example.test/v1",
|
||||
}
|
||||
for _, raw := range custom {
|
||||
require.False(t, IsOfficialBaseURL(raw), "expected custom: %q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBaseURLsRejectEmptyQueryDelimiter(t *testing.T) {
|
||||
_, err := ValidateBaseURL("https://grok.example.test/v1?")
|
||||
require.Error(t, err)
|
||||
|
||||
@@ -1263,17 +1263,25 @@ func (a *Account) GetOpenAIRefreshToken() string {
|
||||
// GetGrokBaseURL selects the upstream used by Grok text and Responses traffic.
|
||||
// Grok media traffic has a different transport contract and must use
|
||||
// GetGrokMediaBaseURL instead.
|
||||
//
|
||||
// The stored base_url only rewrites forwarding endpoints. Credential lifecycle
|
||||
// traffic (OAuth authorization and token refresh) always uses the official
|
||||
// auth endpoints regardless of this value.
|
||||
func (a *Account) GetGrokBaseURL() string {
|
||||
if !a.IsGrok() {
|
||||
return ""
|
||||
}
|
||||
baseURL := strings.TrimSpace(a.GetCredential("base_url"))
|
||||
if a.IsGrokOAuth() {
|
||||
// OAuth bearer credentials are subscription credentials and may only be
|
||||
// sent to the supported CLI gateway. Stored base_url values and unsafe
|
||||
// development overrides apply exclusively to API-key accounts.
|
||||
return xai.DefaultCLIBaseURL
|
||||
// Subscription traffic defaults to the supported CLI gateway. Stored
|
||||
// official-host values (written by credential creation/refresh, or
|
||||
// legacy variants) mean "not customized"; only an explicit custom-host
|
||||
// forwarding address redirects traffic.
|
||||
if baseURL == "" || xai.IsOfficialBaseURL(baseURL) {
|
||||
return xai.DefaultCLIBaseURL
|
||||
}
|
||||
return baseURL
|
||||
}
|
||||
baseURL := a.GetCredential("base_url")
|
||||
if baseURL != "" {
|
||||
return baseURL
|
||||
}
|
||||
@@ -1281,17 +1289,12 @@ func (a *Account) GetGrokBaseURL() string {
|
||||
}
|
||||
|
||||
// GetGrokMediaBaseURL selects the upstream used by Grok Imagine APIs.
|
||||
//
|
||||
// OAuth media credentials have the same trust boundary as OAuth text traffic:
|
||||
// they are pinned to the supported CLI gateway even for large request bodies.
|
||||
// API-key accounts retain their configured public/custom upstream behavior.
|
||||
// It currently resolves the same way as text traffic; the separate accessor
|
||||
// preserves the media/text distinction at call sites.
|
||||
func (a *Account) GetGrokMediaBaseURL() string {
|
||||
if !a.IsGrok() {
|
||||
return ""
|
||||
}
|
||||
if a.IsGrokOAuth() {
|
||||
return xai.DefaultCLIBaseURL
|
||||
}
|
||||
return a.GetGrokBaseURL()
|
||||
}
|
||||
|
||||
|
||||
@@ -266,7 +266,7 @@ func TestGetGrokBaseURLUsesSubscriptionProxyForOAuth(t *testing.T) {
|
||||
expected: xai.DefaultCLIBaseURL,
|
||||
},
|
||||
{
|
||||
name: "oauth explicit custom base_url stays pinned to CLI proxy by default",
|
||||
name: "oauth explicit custom base_url redirects forwarding traffic",
|
||||
account: Account{
|
||||
Type: AccountTypeOAuth,
|
||||
Platform: PlatformGrok,
|
||||
@@ -274,7 +274,18 @@ func TestGetGrokBaseURLUsesSubscriptionProxyForOAuth(t *testing.T) {
|
||||
"base_url": "https://custom.example.com/v1",
|
||||
},
|
||||
},
|
||||
expected: xai.DefaultCLIBaseURL,
|
||||
expected: "https://custom.example.com/v1",
|
||||
},
|
||||
{
|
||||
name: "oauth custom base_url with path prefix redirects forwarding traffic",
|
||||
account: Account{
|
||||
Type: AccountTypeOAuth,
|
||||
Platform: PlatformGrok,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "https://relay.example.com/xai/v1",
|
||||
},
|
||||
},
|
||||
expected: "https://relay.example.com/xai/v1",
|
||||
},
|
||||
{
|
||||
name: "API key without base_url uses official credit-backed API",
|
||||
@@ -294,7 +305,7 @@ func TestGetGrokBaseURLUsesSubscriptionProxyForOAuth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGrokBaseURLPinsOAuthWhenUnsafeOverridesEnabled(t *testing.T) {
|
||||
func TestGetGrokBaseURLHonorsOAuthCustomRegardlessOfUnsafeOverrides(t *testing.T) {
|
||||
t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true")
|
||||
account := Account{
|
||||
Type: AccountTypeOAuth,
|
||||
@@ -304,7 +315,7 @@ func TestGetGrokBaseURLPinsOAuthWhenUnsafeOverridesEnabled(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
require.Equal(t, xai.DefaultCLIBaseURL, account.GetGrokBaseURL())
|
||||
require.Equal(t, "https://custom.example.com/v1", account.GetGrokBaseURL())
|
||||
}
|
||||
|
||||
func TestGetGrokMediaBaseURLPinsOAuthMediaToCLIProxy(t *testing.T) {
|
||||
@@ -356,7 +367,7 @@ func TestGetGrokMediaBaseURLPinsOAuthMediaToCLIProxy(t *testing.T) {
|
||||
expected: xai.DefaultCLIBaseURL,
|
||||
},
|
||||
{
|
||||
name: "oauth untrusted custom base_url is pinned to CLI proxy",
|
||||
name: "oauth custom base_url redirects media traffic",
|
||||
account: Account{
|
||||
Type: AccountTypeOAuth,
|
||||
Platform: PlatformGrok,
|
||||
@@ -364,7 +375,7 @@ func TestGetGrokMediaBaseURLPinsOAuthMediaToCLIProxy(t *testing.T) {
|
||||
"base_url": "https://custom.example.com/v1",
|
||||
},
|
||||
},
|
||||
expected: xai.DefaultCLIBaseURL,
|
||||
expected: "https://custom.example.com/v1",
|
||||
},
|
||||
{
|
||||
name: "API key retains its configured media API",
|
||||
@@ -395,7 +406,7 @@ func TestGetGrokMediaBaseURLPinsOAuthMediaToCLIProxy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGrokMediaBaseURLPinsOAuthWhenUnsafeOverridesEnabled(t *testing.T) {
|
||||
func TestGetGrokMediaBaseURLHonorsOAuthCustomRegardlessOfUnsafeOverrides(t *testing.T) {
|
||||
t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true")
|
||||
account := Account{
|
||||
Type: AccountTypeOAuth,
|
||||
@@ -405,5 +416,5 @@ func TestGetGrokMediaBaseURLPinsOAuthWhenUnsafeOverridesEnabled(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
require.Equal(t, xai.DefaultCLIBaseURL, account.GetGrokMediaBaseURL())
|
||||
require.Equal(t, "https://custom.example.com/v1", account.GetGrokMediaBaseURL())
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import (
|
||||
"golang.org/x/net/http/httpguts"
|
||||
)
|
||||
|
||||
// 请求头覆写(header override):仅对 Anthropic / OpenAI 平台的 api_key 账号生效。
|
||||
// 请求头覆写(header override):对 Anthropic / OpenAI 平台的 api_key 账号
|
||||
// 以及 Grok 平台的 api_key / oauth 账号生效。
|
||||
// 管理员在账号上配置一组 header name -> value,转发到上游前用配置值覆盖同名请求头
|
||||
// (匹配不区分大小写);value 为空的条目视为"未填写",不参与覆盖。
|
||||
const (
|
||||
@@ -28,7 +29,8 @@ const (
|
||||
// - authorization/x-api-key/cookie 等:上游认证头由账号凭据统一注入,禁止通过覆写篡改或重新引入;
|
||||
// - accept-encoding:强制压缩会破坏网关对上游流式响应(SSE/usage)的解析;
|
||||
// - sec-websocket-*:WebSocket 握手头由拨号器管理(OpenAI WS 模式);
|
||||
// - session_id/x-claude-code-session-id 等:逐请求会话隔离头,固定值会造成会话串扰。
|
||||
// - session_id/x-claude-code-session-id/x-grok-conv-id 等:逐请求会话隔离头,
|
||||
// 固定值会造成会话串扰。
|
||||
var headerOverrideBlockedNames = map[string]struct{}{
|
||||
"host": {},
|
||||
"content-length": {},
|
||||
@@ -59,6 +61,7 @@ var headerOverrideBlockedNames = map[string]struct{}{
|
||||
"chatgpt-account-id": {},
|
||||
"x-claude-code-session-id": {},
|
||||
"x-client-request-id": {},
|
||||
"x-grok-conv-id": {},
|
||||
}
|
||||
|
||||
func isHeaderOverrideBlockedName(lowerName string) bool {
|
||||
@@ -67,12 +70,20 @@ func isHeaderOverrideBlockedName(lowerName string) bool {
|
||||
}
|
||||
|
||||
// IsHeaderOverrideEligible 报告账号类型是否支持请求头覆写。
|
||||
// 目前仅开放 Anthropic / OpenAI 两个平台的 api_key 账号。
|
||||
// Anthropic / OpenAI 仅开放 api_key 账号;Grok 额外开放 oauth 账号——
|
||||
// 订阅流量改发自定义转发地址时,通常需要补充中间层要求的准入头。
|
||||
func (a *Account) IsHeaderOverrideEligible() bool {
|
||||
if a == nil || a.Type != AccountTypeAPIKey {
|
||||
if a == nil {
|
||||
return false
|
||||
}
|
||||
switch a.Platform {
|
||||
case PlatformAnthropic, PlatformOpenAI:
|
||||
return a.Type == AccountTypeAPIKey
|
||||
case PlatformGrok:
|
||||
return a.Type == AccountTypeAPIKey || a.Type == AccountTypeOAuth
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return a.Platform == PlatformAnthropic || a.Platform == PlatformOpenAI
|
||||
}
|
||||
|
||||
// IsHeaderOverrideEnabled 报告账号是否启用了请求头覆写。
|
||||
|
||||
@@ -30,7 +30,8 @@ func TestIsHeaderOverrideEligible(t *testing.T) {
|
||||
{"anthropic oauth", PlatformAnthropic, AccountTypeOAuth, false},
|
||||
{"openai oauth", PlatformOpenAI, AccountTypeOAuth, false},
|
||||
{"gemini apikey", PlatformGemini, AccountTypeAPIKey, false},
|
||||
{"grok apikey", PlatformGrok, AccountTypeAPIKey, false},
|
||||
{"grok apikey", PlatformGrok, AccountTypeAPIKey, true},
|
||||
{"grok oauth", PlatformGrok, AccountTypeOAuth, true},
|
||||
{"antigravity apikey", PlatformAntigravity, AccountTypeAPIKey, false},
|
||||
{"anthropic bedrock", PlatformAnthropic, AccountTypeBedrock, false},
|
||||
}
|
||||
|
||||
@@ -758,6 +758,8 @@ func (s *AccountTestService) testGrokAccountConnection(c *gin.Context, account *
|
||||
if account.IsGrokOAuth() {
|
||||
applyGrokCLIHeaders(req.Header)
|
||||
}
|
||||
// 连通性测试与真实转发保持同一套账号级请求头覆写。
|
||||
account.ApplyHeaderOverrides(req.Header)
|
||||
|
||||
proxyURL := ""
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
|
||||
@@ -329,6 +329,8 @@ func (s *OpenAIGatewayService) ForwardGrokMedia(
|
||||
}
|
||||
upstreamReq.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
// 账号级请求头覆写最后应用,配置值优先于内置默认头。
|
||||
account.ApplyHeaderOverrides(upstreamReq.Header)
|
||||
|
||||
proxyURL := ""
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
|
||||
"golang.org/x/sync/singleflight"
|
||||
@@ -52,6 +53,7 @@ type GrokQuotaService struct {
|
||||
tokenProvider *GrokTokenProvider
|
||||
httpUpstream HTTPUpstream
|
||||
usageLogRepo UsageLogRepository
|
||||
cfg *config.Config
|
||||
probeFlight singleflight.Group
|
||||
}
|
||||
|
||||
@@ -60,6 +62,7 @@ func NewGrokQuotaService(
|
||||
proxyRepo ProxyRepository,
|
||||
tokenProvider *GrokTokenProvider,
|
||||
httpUpstream HTTPUpstream,
|
||||
cfg *config.Config,
|
||||
usageLogRepos ...UsageLogRepository,
|
||||
) *GrokQuotaService {
|
||||
var usageLogRepo UsageLogRepository
|
||||
@@ -72,6 +75,7 @@ func NewGrokQuotaService(
|
||||
tokenProvider: tokenProvider,
|
||||
httpUpstream: httpUpstream,
|
||||
usageLogRepo: usageLogRepo,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +139,7 @@ func (s *GrokQuotaService) probeUsage(ctx context.Context, accountID int64) (*Gr
|
||||
if err != nil {
|
||||
return nil, infraerrors.Newf(http.StatusBadRequest, "GROK_QUOTA_PROBE_BODY_ERROR", "failed to build probe body: %v", err)
|
||||
}
|
||||
targetURL, err := buildGrokResponsesURL(account, nil)
|
||||
targetURL, err := buildGrokResponsesURL(account, s.cfg)
|
||||
if err != nil {
|
||||
return nil, infraerrors.Newf(http.StatusBadRequest, "GROK_QUOTA_BASE_URL_INVALID", "invalid Grok base_url: %v", err)
|
||||
}
|
||||
@@ -152,6 +156,8 @@ func (s *GrokQuotaService) probeUsage(ctx context.Context, accountID int64) (*Gr
|
||||
if account.IsGrokOAuth() {
|
||||
applyGrokCLIHeaders(req.Header)
|
||||
}
|
||||
// 探测请求与真实转发保持同一套账号级请求头覆写,避免探测通过但转发失败。
|
||||
account.ApplyHeaderOverrides(req.Header)
|
||||
|
||||
resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, maxInt(account.Concurrency, 1))
|
||||
if err != nil {
|
||||
@@ -306,11 +312,17 @@ func (s *GrokQuotaService) fetchBilling(
|
||||
proxyURL string,
|
||||
weekly bool,
|
||||
) (*xai.BillingSummary, int, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, xai.BuildBillingURL(weekly), nil)
|
||||
billingURL, err := buildGrokBillingURL(account, s.cfg, weekly)
|
||||
if err != nil {
|
||||
return nil, 0, infraerrors.Newf(http.StatusBadRequest, "GROK_QUOTA_BASE_URL_INVALID", "invalid Grok base_url: %v", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, billingURL, nil)
|
||||
if err != nil {
|
||||
return nil, 0, infraerrors.Newf(http.StatusInternalServerError, "GROK_QUOTA_PROBE_REQUEST_BUILD_FAILED", "failed to build billing request: %v", err)
|
||||
}
|
||||
xai.ApplyCLIBillingHeaders(req, token)
|
||||
// billing 探测与真实转发保持同一套账号级请求头覆写。
|
||||
account.ApplyHeaderOverrides(req.Header)
|
||||
resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, maxInt(account.Concurrency, 2))
|
||||
if err != nil {
|
||||
return nil, 0, infraerrors.Newf(http.StatusBadGateway, "GROK_QUOTA_PROBE_REQUEST_FAILED", "billing request failed: %v", err)
|
||||
|
||||
@@ -227,7 +227,7 @@ func TestGrokQuotaServiceProbeUsageStoresHeaders(t *testing.T) {
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(`{"id":"resp_probe"}`)),
|
||||
}}
|
||||
svc := NewGrokQuotaService(repo, nil, NewGrokTokenProvider(repo, nil), upstream)
|
||||
svc := NewGrokQuotaService(repo, nil, NewGrokTokenProvider(repo, nil), upstream, nil)
|
||||
|
||||
result, err := svc.ProbeUsage(context.Background(), 42)
|
||||
require.NoError(t, err)
|
||||
@@ -269,7 +269,7 @@ func TestGrokQuotaServiceProbeUsageIgnoresAccountGrokMapping(t *testing.T) {
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(strings.NewReader(`{"id":"resp_probe"}`)),
|
||||
}}
|
||||
svc := NewGrokQuotaService(repo, nil, NewGrokTokenProvider(repo, nil), upstream)
|
||||
svc := NewGrokQuotaService(repo, nil, NewGrokTokenProvider(repo, nil), upstream, nil)
|
||||
|
||||
result, err := svc.ProbeUsage(context.Background(), 47)
|
||||
require.NoError(t, err)
|
||||
@@ -292,7 +292,7 @@ func TestGrokQuotaServiceProbeUsageReportsProbeModelOnUpstreamError(t *testing.T
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(strings.NewReader(`{"code":"invalid-argument","error":"Model not found"}`)),
|
||||
}}
|
||||
svc := NewGrokQuotaService(repo, nil, NewGrokTokenProvider(repo, nil), upstream)
|
||||
svc := NewGrokQuotaService(repo, nil, NewGrokTokenProvider(repo, nil), upstream, nil)
|
||||
|
||||
_, err := svc.ProbeUsage(context.Background(), 48)
|
||||
require.Error(t, err)
|
||||
@@ -320,6 +320,7 @@ func TestGrokQuotaServiceProbeUsageRedactsUpstreamErrorBodyFromErrorAndLogs(t *t
|
||||
nil,
|
||||
NewGrokTokenProvider(repo, nil),
|
||||
upstream,
|
||||
nil,
|
||||
)
|
||||
|
||||
var logs bytes.Buffer
|
||||
@@ -365,7 +366,7 @@ func TestGrokQuotaServiceProbeUsageLoadsProxyWhenAccountEdgeMissing(t *testing.T
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(strings.NewReader(`{"id":"resp_probe"}`)),
|
||||
}}
|
||||
svc := NewGrokQuotaService(repo, proxyRepo, NewGrokTokenProvider(repo, nil), upstream)
|
||||
svc := NewGrokQuotaService(repo, proxyRepo, NewGrokTokenProvider(repo, nil), upstream, nil)
|
||||
|
||||
_, err := svc.ProbeUsage(context.Background(), 46)
|
||||
require.NoError(t, err)
|
||||
@@ -392,7 +393,7 @@ func TestGrokQuotaServiceProbeUsageStoresNoHeadersState(t *testing.T) {
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(strings.NewReader(`{"id":"resp_probe"}`)),
|
||||
}}
|
||||
svc := NewGrokQuotaService(repo, nil, NewGrokTokenProvider(repo, nil), upstream)
|
||||
svc := NewGrokQuotaService(repo, nil, NewGrokTokenProvider(repo, nil), upstream, nil)
|
||||
|
||||
result, err := svc.ProbeUsage(context.Background(), 45)
|
||||
require.NoError(t, err)
|
||||
@@ -427,7 +428,7 @@ func TestGrokQuotaServiceProbeUsageReturnsRateLimitedSnapshot(t *testing.T) {
|
||||
Header: http.Header{"Retry-After": []string{"45"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":{"message":"rate limited"}}`)),
|
||||
}}
|
||||
svc := NewGrokQuotaService(repo, nil, NewGrokTokenProvider(repo, nil), upstream)
|
||||
svc := NewGrokQuotaService(repo, nil, NewGrokTokenProvider(repo, nil), upstream, nil)
|
||||
|
||||
result, err := svc.ProbeUsage(context.Background(), 43)
|
||||
require.NoError(t, err)
|
||||
@@ -450,7 +451,7 @@ func TestGrokQuotaServiceQueryQuotaFreeFallsBackToGrok45(t *testing.T) {
|
||||
}}
|
||||
upstream := &grokHybridUpstream{}
|
||||
usageRepo := &grokQuotaUsageLogRepo{stats: &usagestats.AccountStats{Tokens: 1_000_000}}
|
||||
svc := NewGrokQuotaService(repo, nil, NewGrokTokenProvider(repo, nil), upstream, usageRepo)
|
||||
svc := NewGrokQuotaService(repo, nil, NewGrokTokenProvider(repo, nil), upstream, nil, usageRepo)
|
||||
|
||||
result, err := svc.QueryQuota(context.Background(), account.ID)
|
||||
require.NoError(t, err)
|
||||
@@ -492,7 +493,7 @@ func TestGrokQuotaServiceQueryQuotaPaidBillingSkipsActiveProbe(t *testing.T) {
|
||||
usagePercent := 25.0
|
||||
upstream := &grokHybridUpstream{weeklyUsagePercent: &usagePercent}
|
||||
usageRepo := &grokQuotaUsageLogRepo{stats: &usagestats.AccountStats{Tokens: 1_000_000}}
|
||||
svc := NewGrokQuotaService(repo, nil, NewGrokTokenProvider(repo, nil), upstream, usageRepo)
|
||||
svc := NewGrokQuotaService(repo, nil, NewGrokTokenProvider(repo, nil), upstream, nil, usageRepo)
|
||||
|
||||
result, err := svc.QueryQuota(context.Background(), account.ID)
|
||||
require.NoError(t, err)
|
||||
@@ -519,7 +520,7 @@ func TestGrokQuotaServiceQueryQuotaCustomPaidMonthlyLimitSkipsActiveProbe(t *tes
|
||||
}}
|
||||
monthlyLimit := 25_000.0
|
||||
upstream := &grokHybridUpstream{monthlyLimitCents: &monthlyLimit}
|
||||
svc := NewGrokQuotaService(repo, nil, NewGrokTokenProvider(repo, nil), upstream)
|
||||
svc := NewGrokQuotaService(repo, nil, NewGrokTokenProvider(repo, nil), upstream, nil)
|
||||
|
||||
result, err := svc.QueryQuota(context.Background(), account.ID)
|
||||
require.NoError(t, err)
|
||||
@@ -652,7 +653,7 @@ func TestAccountUsageServiceGrokRefreshUsesBillingOnly(t *testing.T) {
|
||||
}}
|
||||
upstream := &grokHybridUpstream{}
|
||||
usageRepo := &grokQuotaUsageLogRepo{stats: &usagestats.AccountStats{Tokens: 750_000}}
|
||||
quotaService := NewGrokQuotaService(repo, nil, NewGrokTokenProvider(repo, nil), upstream, usageRepo)
|
||||
quotaService := NewGrokQuotaService(repo, nil, NewGrokTokenProvider(repo, nil), upstream, nil, usageRepo)
|
||||
usageService := &AccountUsageService{
|
||||
grokQuotaFetcher: NewGrokQuotaFetcher(),
|
||||
grokQuotaService: quotaService,
|
||||
@@ -688,7 +689,7 @@ func TestGrokQuotaServiceProbeFlightsDeduplicateBillingAndSeparateActive(t *test
|
||||
billingStarted := make(chan struct{})
|
||||
billingRelease := make(chan struct{})
|
||||
upstream := &grokHybridUpstream{billingStarted: billingStarted, billingRelease: billingRelease}
|
||||
svc := NewGrokQuotaService(repo, nil, NewGrokTokenProvider(repo, nil), upstream)
|
||||
svc := NewGrokQuotaService(repo, nil, NewGrokTokenProvider(repo, nil), upstream, nil)
|
||||
|
||||
type probeOutcome struct {
|
||||
result *GrokQuotaProbeResult
|
||||
@@ -745,7 +746,7 @@ func TestGrokQuotaServiceBilling429DoesNotPauseModelScheduling(t *testing.T) {
|
||||
billingStatus: http.StatusTooManyRequests,
|
||||
billingHeaders: http.Header{"Retry-After": []string{"45"}},
|
||||
}
|
||||
svc := NewGrokQuotaService(repo, nil, NewGrokTokenProvider(repo, nil), upstream)
|
||||
svc := NewGrokQuotaService(repo, nil, NewGrokTokenProvider(repo, nil), upstream, nil)
|
||||
|
||||
result, err := svc.ProbeBilling(context.Background(), account.ID)
|
||||
|
||||
@@ -765,7 +766,7 @@ func TestGrokQuotaServiceQueryQuotaFree429PersistsLimitAndKeepsBilling(t *testin
|
||||
activeStatus: http.StatusTooManyRequests,
|
||||
activeHeaders: http.Header{"Retry-After": []string{"45"}},
|
||||
}
|
||||
svc := NewGrokQuotaService(repo, nil, NewGrokTokenProvider(repo, nil), upstream)
|
||||
svc := NewGrokQuotaService(repo, nil, NewGrokTokenProvider(repo, nil), upstream, nil)
|
||||
|
||||
result, err := svc.QueryQuota(context.Background(), account.ID)
|
||||
require.NoError(t, err)
|
||||
@@ -791,7 +792,7 @@ func TestGrokQuotaServiceResetQuotaUnsupported(t *testing.T) {
|
||||
accountsByID: map[int64]*Account{44: account},
|
||||
},
|
||||
}
|
||||
svc := NewGrokQuotaService(repo, nil, nil, nil)
|
||||
svc := NewGrokQuotaService(repo, nil, nil, nil, nil)
|
||||
|
||||
_, err := svc.ResetQuota(context.Background(), 44)
|
||||
require.Error(t, err)
|
||||
|
||||
@@ -15,30 +15,48 @@ func grokBaseURLValidator(account *Account, cfg *config.Config) (xai.BaseURLVali
|
||||
}
|
||||
switch account.Type {
|
||||
case AccountTypeOAuth:
|
||||
// Subscription credentials are never governed by the operator's API-key
|
||||
// URL policy. They stay pinned to the supported CLI gateway.
|
||||
return redactedGrokBaseURLValidator(xai.ValidateTrustedBaseURL), nil
|
||||
case AccountTypeAPIKey:
|
||||
if cfg == nil {
|
||||
return redactedGrokBaseURLValidator(xai.ValidateBaseURL), nil
|
||||
}
|
||||
if !cfg.Security.URLAllowlist.Enabled {
|
||||
return redactedGrokBaseURLValidator(func(raw string) (string, error) {
|
||||
return urlvalidator.ValidateURLFormat(raw, cfg.Security.URLAllowlist.AllowInsecureHTTP)
|
||||
}), nil
|
||||
}
|
||||
// Official gateway hosts are always trusted and always usable, even when
|
||||
// the operator enables a restrictive URL allowlist. A custom forwarding
|
||||
// host is vetted by the same operator policy as API-key accounts.
|
||||
//
|
||||
// The official-vs-custom decision is made on the host, not via
|
||||
// ValidateTrustedBaseURL: that validator relaxes to accept-any under the
|
||||
// XAI_ALLOW_UNSAFE_URL_OVERRIDES debug switch, which must never let an
|
||||
// OAuth bearer token reach an arbitrary custom host.
|
||||
policyValidator := grokOperatorPolicyValidator(cfg)
|
||||
return redactedGrokBaseURLValidator(func(raw string) (string, error) {
|
||||
return urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
|
||||
AllowedHosts: cfg.Security.URLAllowlist.UpstreamHosts,
|
||||
RequireAllowlist: true,
|
||||
AllowPrivate: cfg.Security.URLAllowlist.AllowPrivateHosts,
|
||||
})
|
||||
if xai.IsOfficialBaseURL(raw) {
|
||||
return xai.ValidateTrustedBaseURL(raw)
|
||||
}
|
||||
return policyValidator(raw)
|
||||
}), nil
|
||||
case AccountTypeAPIKey:
|
||||
return redactedGrokBaseURLValidator(grokOperatorPolicyValidator(cfg)), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported grok account type: %s", account.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// grokOperatorPolicyValidator 按全局出站 URL 安全策略校验自定义 base_url:
|
||||
// 白名单开启时强制 UpstreamHosts;关闭时仅做格式校验(HTTP 允许与否跟随配置)。
|
||||
func grokOperatorPolicyValidator(cfg *config.Config) xai.BaseURLValidator {
|
||||
if cfg == nil {
|
||||
return xai.ValidateBaseURL
|
||||
}
|
||||
if !cfg.Security.URLAllowlist.Enabled {
|
||||
return func(raw string) (string, error) {
|
||||
return urlvalidator.ValidateURLFormat(raw, cfg.Security.URLAllowlist.AllowInsecureHTTP)
|
||||
}
|
||||
}
|
||||
return func(raw string) (string, error) {
|
||||
return urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
|
||||
AllowedHosts: cfg.Security.URLAllowlist.UpstreamHosts,
|
||||
RequireAllowlist: true,
|
||||
AllowPrivate: cfg.Security.URLAllowlist.AllowPrivateHosts,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func redactedGrokBaseURLValidator(validator xai.BaseURLValidator) xai.BaseURLValidator {
|
||||
return func(raw string) (string, error) {
|
||||
validated, err := validator(raw)
|
||||
@@ -65,6 +83,16 @@ func buildGrokChatCompletionsURL(account *Account, cfg *config.Config) (string,
|
||||
return xai.BuildChatCompletionsURLWithValidator(account.GetGrokBaseURL(), validator)
|
||||
}
|
||||
|
||||
// buildGrokBillingURL 解析 billing 探测端点:跟随账号的转发 base_url,
|
||||
// 未定制的账号仍指向官方 CLI 网关。
|
||||
func buildGrokBillingURL(account *Account, cfg *config.Config, weekly bool) (string, error) {
|
||||
validator, err := grokBaseURLValidator(account, cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return xai.BuildBillingURLWithValidator(account.GetGrokBaseURL(), weekly, validator)
|
||||
}
|
||||
|
||||
func buildGrokMediaURL(account *Account, cfg *config.Config, endpoint GrokMediaEndpoint, requestID string) (string, error) {
|
||||
validator, err := grokBaseURLValidator(account, cfg)
|
||||
if err != nil {
|
||||
|
||||
@@ -104,19 +104,182 @@ func TestGrokAPIKeyURLPolicyRedactsMalformedConfiguredURL(t *testing.T) {
|
||||
require.NotContains(t, err.Error(), "secret")
|
||||
}
|
||||
|
||||
func TestGrokOAuthURLPolicyIgnoresAPIKeyOverrides(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "http://attacker.example.test/v1",
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Security.URLAllowlist.Enabled = false
|
||||
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
|
||||
func TestGrokOAuthURLPolicy(t *testing.T) {
|
||||
t.Run("default CLI gateway always allowed under restrictive allowlist", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{},
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Security.URLAllowlist.Enabled = true
|
||||
cfg.Security.URLAllowlist.UpstreamHosts = []string{"other.example.test"}
|
||||
|
||||
target, err := buildGrokResponsesURL(account, cfg)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, xai.DefaultCLIBaseURL+"/responses", target)
|
||||
target, err := buildGrokResponsesURL(account, cfg)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, xai.DefaultCLIBaseURL+"/responses", target)
|
||||
})
|
||||
|
||||
t.Run("stored official-host variant stays on CLI gateway", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "HTTPS://API.X.AI:443/",
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
|
||||
target, err := buildGrokResponsesURL(account, cfg)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, xai.DefaultCLIBaseURL+"/responses", target)
|
||||
})
|
||||
|
||||
t.Run("custom forwarding address follows operator policy", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "https://relay.example.test/v1",
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Security.URLAllowlist.Enabled = false
|
||||
|
||||
target, err := buildGrokResponsesURL(account, cfg)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://relay.example.test/v1/responses", target)
|
||||
})
|
||||
|
||||
t.Run("custom path prefix is preserved", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "https://relay.example.test/xai/v1",
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
|
||||
target, err := buildGrokResponsesURL(account, cfg)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://relay.example.test/xai/v1/responses", target)
|
||||
})
|
||||
|
||||
t.Run("custom forwarding address rejected by allowlist", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "https://relay.example.test/v1",
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Security.URLAllowlist.Enabled = true
|
||||
cfg.Security.URLAllowlist.UpstreamHosts = []string{"other.example.test"}
|
||||
|
||||
_, err := buildGrokResponsesURL(account, cfg)
|
||||
require.EqualError(t, err, "invalid base url: base URL rejected by URL security policy")
|
||||
})
|
||||
|
||||
t.Run("insecure HTTP custom address requires operator opt-in", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "http://relay.example.test/v1",
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Security.URLAllowlist.Enabled = false
|
||||
cfg.Security.URLAllowlist.AllowInsecureHTTP = false
|
||||
|
||||
_, err := buildGrokResponsesURL(account, cfg)
|
||||
require.EqualError(t, err, "invalid base url: base URL rejected by URL security policy")
|
||||
|
||||
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
|
||||
target, err := buildGrokResponsesURL(account, cfg)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "http://relay.example.test/v1/responses", target)
|
||||
})
|
||||
|
||||
t.Run("unsafe override switch does not relax the operator allowlist for custom hosts", func(t *testing.T) {
|
||||
// XAI_ALLOW_UNSAFE_URL_OVERRIDES relaxes the trusted-host validator to
|
||||
// accept-any; a custom OAuth forwarding host must still be governed by
|
||||
// the operator allowlist so the bearer token cannot reach arbitrary hosts.
|
||||
t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true")
|
||||
cfg := &config.Config{}
|
||||
cfg.Security.URLAllowlist.Enabled = true
|
||||
cfg.Security.URLAllowlist.UpstreamHosts = []string{"cli-chat-proxy.grok.com"}
|
||||
|
||||
custom := &Account{
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "http://10.0.0.1/v1",
|
||||
},
|
||||
}
|
||||
_, err := buildGrokResponsesURL(custom, cfg)
|
||||
require.EqualError(t, err, "invalid base url: base URL rejected by URL security policy")
|
||||
|
||||
// The official gateway still resolves even under the restrictive allowlist.
|
||||
official := &Account{
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{},
|
||||
}
|
||||
target, err := buildGrokResponsesURL(official, cfg)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, xai.DefaultCLIBaseURL+"/responses", target)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGrokBillingURLFollowsAccountBaseURL(t *testing.T) {
|
||||
t.Run("oauth default stays on CLI gateway", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{},
|
||||
}
|
||||
|
||||
weeklyURL, err := buildGrokBillingURL(account, nil, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, xai.DefaultCLIBaseURL+"/billing?format=credits", weeklyURL)
|
||||
|
||||
monthlyURL, err := buildGrokBillingURL(account, nil, false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, xai.DefaultCLIBaseURL+"/billing", monthlyURL)
|
||||
})
|
||||
|
||||
t.Run("oauth custom forwarding address carries billing probes", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "https://relay.example.test/v1",
|
||||
},
|
||||
}
|
||||
|
||||
weeklyURL, err := buildGrokBillingURL(account, nil, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://relay.example.test/v1/billing?format=credits", weeklyURL)
|
||||
})
|
||||
|
||||
t.Run("billing probe honors the operator allowlist like forwarding", func(t *testing.T) {
|
||||
// Probe paths must share the forwarding URL policy so a custom host the
|
||||
// allowlist rejects cannot receive the OAuth bearer via a billing probe.
|
||||
account := &Account{
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "https://relay.example.test/v1",
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Security.URLAllowlist.Enabled = true
|
||||
cfg.Security.URLAllowlist.UpstreamHosts = []string{"cli-chat-proxy.grok.com"}
|
||||
|
||||
_, err := buildGrokBillingURL(account, cfg, true)
|
||||
require.EqualError(t, err, "invalid base url: base URL rejected by URL security policy")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -195,14 +195,15 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest(
|
||||
upstreamReq.Header.Set("user-agent", userAgent)
|
||||
}
|
||||
|
||||
// 账号级请求头覆写(仅 openai api_key 账号启用时生效)
|
||||
account.ApplyHeaderOverrides(upstreamReq.Header)
|
||||
if account.Platform == PlatformGrok {
|
||||
if account.IsGrokOAuth() {
|
||||
applyGrokCLIHeaders(upstreamReq.Header)
|
||||
}
|
||||
applyGrokCacheHeaders(upstreamReq.Header, grokCacheIdentity)
|
||||
}
|
||||
// 账号级请求头覆写:放在所有内置默认头(含 Grok CLI 身份头)之后应用,
|
||||
// 使配置值获得除共享传输层强制头之外的最高优先级。
|
||||
account.ApplyHeaderOverrides(upstreamReq.Header)
|
||||
|
||||
proxyURL := ""
|
||||
if account.Proxy != nil {
|
||||
|
||||
@@ -769,6 +769,9 @@ func buildGrokResponsesRequest(ctx context.Context, c *gin.Context, account *Acc
|
||||
req.Header.Set("OpenAI-Beta", v)
|
||||
}
|
||||
}
|
||||
// 账号级请求头覆写最后应用,使配置值优先于上面的内置默认头;
|
||||
// 打到官方 CLI 网关时身份头仍由共享传输层最终强制。
|
||||
account.ApplyHeaderOverrides(req.Header)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -259,7 +259,7 @@ func TestBuildGrokResponsesRequestUsesAccountBaseURLAndBearerToken(t *testing.T)
|
||||
req, err := buildGrokResponsesRequest(context.Background(), nil, account, []byte(`{"model":"grok-4.3"}`), "access-token", "isolated-cache-id", nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.MethodPost, req.Method)
|
||||
require.Equal(t, xai.DefaultCLIBaseURL+"/responses", req.URL.String())
|
||||
require.Equal(t, "https://xai.test/v1/responses", req.URL.String())
|
||||
require.Equal(t, "Bearer access-token", req.Header.Get("Authorization"))
|
||||
require.Equal(t, "application/json", req.Header.Get("Content-Type"))
|
||||
require.Contains(t, req.Header.Get("Accept"), "text/event-stream")
|
||||
@@ -288,14 +288,14 @@ func TestBuildGrokResponsesRequestAllowsPublicAPIKeyBaseURLByDefault(t *testing.
|
||||
require.NotEqual(t, grokUpstreamUserAgent, req.Header.Get("User-Agent"))
|
||||
}
|
||||
|
||||
func TestBuildGrokResponsesRequestPinsOAuthCustomBaseURLByDefault(t *testing.T) {
|
||||
func TestBuildGrokResponsesRequestPinsOAuthOfficialVariantBaseURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
account := &Account{
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "https://xai.test/v1",
|
||||
"base_url": "HTTPS://API.X.AI:443/",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -304,6 +304,58 @@ func TestBuildGrokResponsesRequestPinsOAuthCustomBaseURLByDefault(t *testing.T)
|
||||
require.Equal(t, xai.DefaultCLIBaseURL+"/responses", req.URL.String())
|
||||
}
|
||||
|
||||
func TestBuildGrokResponsesRequestAppliesHeaderOverridesLast(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
account := &Account{
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "https://relay.example.test/v1",
|
||||
"header_override_enabled": true,
|
||||
"header_overrides": map[string]any{
|
||||
"User-Agent": "relay-client/2.0",
|
||||
"X-Grok-Client-Version": "9.9.9",
|
||||
"X-Relay-Token": "relay-secret",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req, err := buildGrokResponsesRequest(context.Background(), nil, account, []byte(`{"model":"grok-4.3"}`), "access-token", "conv-1", nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://relay.example.test/v1/responses", req.URL.String())
|
||||
// 覆写值优先于内置 CLI 身份头。名字不在 wire casing 映射中的覆写头
|
||||
// 以小写键直写(HTTP/2 线上语义),需按写入形态断言。
|
||||
require.Equal(t, "relay-client/2.0", req.Header.Get("User-Agent"))
|
||||
require.Equal(t, []string{"9.9.9"}, req.Header["x-grok-client-version"])
|
||||
require.Empty(t, req.Header.Get("X-Grok-Client-Version"))
|
||||
require.Equal(t, []string{"relay-secret"}, req.Header["x-relay-token"])
|
||||
// 会话路由头与认证头不受覆写影响。
|
||||
require.Equal(t, "conv-1", req.Header.Get(grokConversationIDHeader))
|
||||
require.Equal(t, "Bearer access-token", req.Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
func TestBuildGrokResponsesRequestIgnoresBlockedHeaderOverrides(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
account := &Account{
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"header_override_enabled": true,
|
||||
"header_overrides": map[string]any{
|
||||
"Authorization": "Bearer stolen",
|
||||
"x-grok-conv-id": "pinned-conversation",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req, err := buildGrokResponsesRequest(context.Background(), nil, account, []byte(`{"model":"grok-4.3"}`), "api-key", "conv-2", nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Bearer api-key", req.Header.Get("Authorization"))
|
||||
require.Equal(t, "conv-2", req.Header.Get(grokConversationIDHeader))
|
||||
}
|
||||
|
||||
func TestGrokMediaGenerationGateCoversImagesAndVideo(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -2129,4 +2181,3 @@ func TestIsGrokImageGenerationModel(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -199,9 +199,10 @@ func ProvideGrokQuotaService(
|
||||
proxyRepo ProxyRepository,
|
||||
tokenProvider *GrokTokenProvider,
|
||||
httpUpstream HTTPUpstream,
|
||||
cfg *config.Config,
|
||||
usageLogRepo UsageLogRepository,
|
||||
) *GrokQuotaService {
|
||||
return NewGrokQuotaService(accountRepo, proxyRepo, tokenProvider, httpUpstream, usageLogRepo)
|
||||
return NewGrokQuotaService(accountRepo, proxyRepo, tokenProvider, httpUpstream, cfg, usageLogRepo)
|
||||
}
|
||||
|
||||
// ProvideGeminiTokenProvider creates GeminiTokenProvider with OAuthRefreshAPI injection
|
||||
|
||||
@@ -589,14 +589,19 @@
|
||||
{{ t('admin.accounts.headerOverride.addRow') }}
|
||||
</button>
|
||||
|
||||
<div v-if="headerOverrideTemplatePlatform" class="flex flex-wrap gap-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-if="headerOverrideTemplatePlatform"
|
||||
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>
|
||||
<HeaderOverrideJsonTools
|
||||
:rows="headerOverrideRows"
|
||||
@update:rows="headerOverrideRows = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
@@ -1272,10 +1277,11 @@ import {
|
||||
buildModelMappingObject as buildModelMappingPayload,
|
||||
getPresetMappingsByPlatform
|
||||
} from '@/composables/useModelWhitelist'
|
||||
import HeaderOverrideJsonTools from '@/components/account/HeaderOverrideJsonTools.vue'
|
||||
import {
|
||||
buildHeaderOverridesObject,
|
||||
getHeaderOverrideTemplate,
|
||||
isHeaderOverridePlatform,
|
||||
isHeaderOverrideCapable,
|
||||
validateHeaderOverrideRows,
|
||||
HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY,
|
||||
HEADER_OVERRIDES_CREDENTIAL_KEY,
|
||||
@@ -1351,12 +1357,15 @@ 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')
|
||||
targetSelectedPlatforms.value.every(p =>
|
||||
targetSelectedTypes.value.every(ty => isHeaderOverrideCapable(p, ty))
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1859,6 +1868,16 @@ const handleSubmit = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// base_url 现在也会作用于 Grok OAuth 订阅账号的转发端点;坏值会让请求期
|
||||
// 校验失败、账号请求全挂,因此保存前强制格式校验(与单账号编辑一致)。
|
||||
if (enableBaseUrl.value) {
|
||||
const trimmedBaseUrl = baseUrl.value.trim()
|
||||
if (trimmedBaseUrl && !/^https?:\/\//i.test(trimmedBaseUrl)) {
|
||||
appStore.showError(t('admin.accounts.grokCustomBaseUrl.invalid'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (enableHeaderOverride.value && headerOverrideEnabled.value) {
|
||||
// 批量保存对 header_overrides 是整键替换:开启但没有任何有效行会把所选账号的
|
||||
// 既有覆写配置静默清空,必须显式拦截(清空请走关闭开关的路径,有专门提示)
|
||||
|
||||
@@ -1498,7 +1498,7 @@
|
||||
|
||||
<!-- Header Override Section (anthropic/openai apikey only) -->
|
||||
<div
|
||||
v-if="isHeaderOverridePlatform(form.platform)"
|
||||
v-if="isHeaderOverrideCapable(form.platform, 'apikey')"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
@@ -1592,6 +1592,10 @@
|
||||
>
|
||||
+ {{ t('admin.accounts.headerOverride.fillTemplate') }}
|
||||
</button>
|
||||
<HeaderOverrideJsonTools
|
||||
:rows="headerOverrideRows"
|
||||
@update:rows="headerOverrideRows = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
@@ -3494,12 +3498,13 @@ import ProxyAdBanner from '@/components/common/ProxyAdBanner.vue'
|
||||
import GroupSelector from '@/components/common/GroupSelector.vue'
|
||||
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
|
||||
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
|
||||
import HeaderOverrideJsonTools from '@/components/account/HeaderOverrideJsonTools.vue'
|
||||
import {
|
||||
applyAntigravityProjectID,
|
||||
applyHeaderOverride,
|
||||
applyInterceptWarmup,
|
||||
getHeaderOverrideTemplate,
|
||||
isHeaderOverridePlatform,
|
||||
isHeaderOverrideCapable,
|
||||
validateHeaderOverrideRows,
|
||||
type HeaderOverrideRow
|
||||
} from '@/components/account/credentialsBuilder'
|
||||
@@ -5019,8 +5024,8 @@ const handleSubmit = async () => {
|
||||
credentials.custom_error_codes = [...selectedErrorCodes.value]
|
||||
}
|
||||
|
||||
// Add header override if enabled (anthropic/openai apikey only)
|
||||
if (isHeaderOverridePlatform(form.platform)) {
|
||||
// Add header override if enabled (anthropic/openai/grok apikey)
|
||||
if (isHeaderOverrideCapable(form.platform, 'apikey')) {
|
||||
if (headerOverrideEnabled.value) {
|
||||
const headerError = validateHeaderOverrideRows(headerOverrideRows.value)
|
||||
if (headerError) {
|
||||
|
||||
@@ -421,110 +421,151 @@
|
||||
</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>
|
||||
|
||||
<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') }}
|
||||
<!-- Grok OAuth Custom Upstream URL (仅改写转发端点,OAuth 授权/刷新不受影响) -->
|
||||
<div
|
||||
v-if="account.platform === 'grok' && account.type === 'oauth'"
|
||||
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.grokCustomBaseUrl.title') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.grokCustomBaseUrl.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="grok-custom-base-url-toggle"
|
||||
@click="grokOAuthCustomBaseUrlEnabled = !grokOAuthCustomBaseUrlEnabled"
|
||||
: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',
|
||||
grokOAuthCustomBaseUrlEnabled ? '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',
|
||||
grokOAuthCustomBaseUrlEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="grokOAuthCustomBaseUrlEnabled">
|
||||
<input
|
||||
v-model="grokOAuthBaseUrl"
|
||||
type="text"
|
||||
class="input"
|
||||
data-testid="grok-custom-base-url-input"
|
||||
:placeholder="t('admin.accounts.grokCustomBaseUrl.placeholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header Override Section (anthropic/openai apikey + grok apikey/oauth) -->
|
||||
<div v-if="headerOverrideCapable" 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>
|
||||
<HeaderOverrideJsonTools
|
||||
:rows="headerOverrideRows"
|
||||
@update:rows="headerOverrideRows = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.headerOverride.emptyValueHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI/Grok OAuth Model Mapping (OAuth 类型没有 apikey 容器,需要独立的模型映射区域) -->
|
||||
@@ -2589,6 +2630,7 @@ import ProxyAdBanner from '@/components/common/ProxyAdBanner.vue'
|
||||
import GroupSelector from '@/components/common/GroupSelector.vue'
|
||||
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
|
||||
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
|
||||
import HeaderOverrideJsonTools from '@/components/account/HeaderOverrideJsonTools.vue'
|
||||
import {
|
||||
applyAntigravityProjectID,
|
||||
applyHeaderOverride,
|
||||
@@ -2597,7 +2639,8 @@ import {
|
||||
buildPlanTypeOptions,
|
||||
readPlanType,
|
||||
getHeaderOverrideTemplate,
|
||||
isHeaderOverridePlatform,
|
||||
isCustomGrokBaseUrl,
|
||||
isHeaderOverrideCapable,
|
||||
splitHeaderOverridesObject,
|
||||
validateHeaderOverrideRows,
|
||||
HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY,
|
||||
@@ -2737,6 +2780,14 @@ const customErrorCodeInput = ref<number | null>(null)
|
||||
const headerOverrideEnabled = ref(false)
|
||||
const headerOverrideRows = ref<HeaderOverrideRow[]>([])
|
||||
|
||||
const headerOverrideCapable = computed(
|
||||
() => !!props.account && isHeaderOverrideCapable(props.account.platform, props.account.type)
|
||||
)
|
||||
|
||||
// Grok OAuth 自定义上游地址(仅转发端点;OAuth 授权/令牌刷新不受影响)
|
||||
const grokOAuthCustomBaseUrlEnabled = ref(false)
|
||||
const grokOAuthBaseUrl = ref('')
|
||||
|
||||
const addHeaderOverrideRow = () => {
|
||||
headerOverrideRows.value.push({ name: '', value: '' })
|
||||
}
|
||||
@@ -3397,9 +3448,27 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
|
||||
loadTempUnschedRules(credentials)
|
||||
|
||||
// Reset header override state (loaded below only for apikey accounts)
|
||||
// Load header override state (anthropic/openai apikey + grok apikey/oauth)
|
||||
headerOverrideEnabled.value = false
|
||||
headerOverrideRows.value = []
|
||||
if (newAccount.credentials && isHeaderOverrideCapable(newAccount.platform, newAccount.type)) {
|
||||
const overrideCreds = newAccount.credentials as Record<string, unknown>
|
||||
headerOverrideEnabled.value = overrideCreds[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY] === true
|
||||
headerOverrideRows.value = splitHeaderOverridesObject(
|
||||
overrideCreds[HEADER_OVERRIDES_CREDENTIAL_KEY]
|
||||
)
|
||||
}
|
||||
|
||||
// Load Grok OAuth custom upstream URL state(存储的官方地址视同未定制)
|
||||
grokOAuthCustomBaseUrlEnabled.value = false
|
||||
grokOAuthBaseUrl.value = ''
|
||||
if (newAccount.platform === 'grok' && newAccount.type === 'oauth' && newAccount.credentials) {
|
||||
const grokCreds = newAccount.credentials as Record<string, unknown>
|
||||
if (isCustomGrokBaseUrl(grokCreds.base_url)) {
|
||||
grokOAuthCustomBaseUrlEnabled.value = true
|
||||
grokOAuthBaseUrl.value = (grokCreds.base_url as string).trim()
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize API Key fields for apikey type
|
||||
if (newAccount.type === 'apikey' && newAccount.credentials) {
|
||||
@@ -3433,13 +3502,6 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
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'
|
||||
@@ -4077,8 +4139,8 @@ const handleSubmit = async () => {
|
||||
delete newCredentials.custom_error_codes
|
||||
}
|
||||
|
||||
// Add header override if enabled (anthropic/openai apikey only)
|
||||
if (isHeaderOverridePlatform(props.account.platform)) {
|
||||
// Add header override if enabled (anthropic/openai/grok apikey)
|
||||
if (isHeaderOverrideCapable(props.account.platform, 'apikey')) {
|
||||
if (headerOverrideEnabled.value) {
|
||||
const headerError = validateHeaderOverrideRows(headerOverrideRows.value)
|
||||
if (headerError) {
|
||||
@@ -4253,6 +4315,41 @@ const handleSubmit = async () => {
|
||||
updatePayload.credentials = newCredentials
|
||||
}
|
||||
|
||||
// Grok OAuth: 自定义上游地址 + 请求头覆写。base_url 仅改写转发端点,
|
||||
// OAuth 授权与令牌刷新链路不读取该值;关闭开关即恢复默认官方网关。
|
||||
if (props.account.platform === 'grok' && props.account.type === 'oauth') {
|
||||
const currentCredentials =
|
||||
(updatePayload.credentials as Record<string, unknown>) ||
|
||||
((props.account.credentials as Record<string, unknown>) || {})
|
||||
const newCredentials: Record<string, unknown> = { ...currentCredentials }
|
||||
|
||||
if (grokOAuthCustomBaseUrlEnabled.value) {
|
||||
const trimmedBaseUrl = grokOAuthBaseUrl.value.trim()
|
||||
if (!trimmedBaseUrl) {
|
||||
appStore.showError(t('admin.accounts.grokCustomBaseUrl.required'))
|
||||
return
|
||||
}
|
||||
if (!/^https?:\/\//i.test(trimmedBaseUrl)) {
|
||||
appStore.showError(t('admin.accounts.grokCustomBaseUrl.invalid'))
|
||||
return
|
||||
}
|
||||
newCredentials.base_url = trimmedBaseUrl
|
||||
} else {
|
||||
delete newCredentials.base_url
|
||||
}
|
||||
|
||||
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')
|
||||
|
||||
updatePayload.credentials = newCredentials
|
||||
}
|
||||
|
||||
// OpenAI: 手动覆盖订阅档位 plan_type(Plus/Pro/Free)。仅 OAuth 非影子账号:
|
||||
// 影子账号凭据由母账号管理(且后端会 sanitize),setup-token 无订阅调度语义。
|
||||
if (props.account.platform === 'openai' && props.account.type === 'oauth' && !isSparkShadow.value) {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<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="toggleImportPanel"
|
||||
>
|
||||
{{ t('admin.accounts.headerOverride.importJson') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-primary-50 px-3 py-1 text-xs text-primary-700 transition-colors hover:bg-primary-100 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-primary-900/30 dark:text-primary-400 dark:hover:bg-primary-900/50"
|
||||
:disabled="!hasNamedRows"
|
||||
@click="copyAsJson"
|
||||
>
|
||||
{{ t('admin.accounts.headerOverride.copyJson') }}
|
||||
</button>
|
||||
|
||||
<div v-if="showImportPanel" class="w-full space-y-2">
|
||||
<textarea
|
||||
v-model="importText"
|
||||
rows="5"
|
||||
class="input font-mono text-xs"
|
||||
:placeholder="t('admin.accounts.headerOverride.importJsonPlaceholder')"
|
||||
></textarea>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-primary-600 px-3 py-1 text-xs text-white transition-colors hover:bg-primary-700"
|
||||
@click="applyImport"
|
||||
>
|
||||
{{ t('admin.accounts.headerOverride.importJsonApply') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-gray-100 px-3 py-1 text-xs text-gray-600 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500"
|
||||
@click="closeImportPanel"
|
||||
>
|
||||
{{ t('admin.accounts.headerOverride.importJsonCancel') }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.headerOverride.importJsonHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import {
|
||||
parseHeaderOverridesJson,
|
||||
serializeHeaderOverrideRows,
|
||||
type HeaderOverrideRow
|
||||
} from './credentialsBuilder'
|
||||
|
||||
const props = defineProps<{
|
||||
rows: HeaderOverrideRow[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:rows', rows: HeaderOverrideRow[]): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
const showImportPanel = ref(false)
|
||||
const importText = ref('')
|
||||
|
||||
const hasNamedRows = computed(() => props.rows.some((row) => row.name.trim()))
|
||||
|
||||
const toggleImportPanel = () => {
|
||||
showImportPanel.value = !showImportPanel.value
|
||||
}
|
||||
|
||||
const closeImportPanel = () => {
|
||||
showImportPanel.value = false
|
||||
importText.value = ''
|
||||
}
|
||||
|
||||
const applyImport = () => {
|
||||
const rows = parseHeaderOverridesJson(importText.value)
|
||||
if (rows === null) {
|
||||
appStore.showError(t('admin.accounts.headerOverride.importJsonInvalid'))
|
||||
return
|
||||
}
|
||||
emit('update:rows', rows)
|
||||
closeImportPanel()
|
||||
}
|
||||
|
||||
const copyAsJson = () => {
|
||||
void copyToClipboard(serializeHeaderOverrideRows(props.rows))
|
||||
}
|
||||
</script>
|
||||
@@ -10,9 +10,12 @@ import {
|
||||
buildHeaderOverridesObject,
|
||||
buildPlanTypeOptions,
|
||||
getHeaderOverrideTemplate,
|
||||
isHeaderOverridePlatform,
|
||||
isCustomGrokBaseUrl,
|
||||
isHeaderOverrideCapable,
|
||||
parseHeaderOverridesJson,
|
||||
planTypeDisplayLabel,
|
||||
readPlanType,
|
||||
serializeHeaderOverrideRows,
|
||||
splitHeaderOverridesObject,
|
||||
validateHeaderOverrideRows
|
||||
} from '../credentialsBuilder'
|
||||
@@ -95,14 +98,97 @@ describe('applyAntigravityProjectID', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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('isHeaderOverrideCapable', () => {
|
||||
it('anthropic/openai only support apikey accounts', () => {
|
||||
expect(isHeaderOverrideCapable('anthropic', 'apikey')).toBe(true)
|
||||
expect(isHeaderOverrideCapable('openai', 'apikey')).toBe(true)
|
||||
expect(isHeaderOverrideCapable('anthropic', 'oauth')).toBe(false)
|
||||
expect(isHeaderOverrideCapable('openai', 'oauth')).toBe(false)
|
||||
})
|
||||
|
||||
it('grok supports both apikey and oauth accounts', () => {
|
||||
expect(isHeaderOverrideCapable('grok', 'apikey')).toBe(true)
|
||||
expect(isHeaderOverrideCapable('grok', 'oauth')).toBe(true)
|
||||
expect(isHeaderOverrideCapable('grok', 'bedrock')).toBe(false)
|
||||
})
|
||||
|
||||
it('other platforms are not supported', () => {
|
||||
expect(isHeaderOverrideCapable('gemini', 'apikey')).toBe(false)
|
||||
expect(isHeaderOverrideCapable('antigravity', 'apikey')).toBe(false)
|
||||
expect(isHeaderOverrideCapable('', 'apikey')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseHeaderOverridesJson', () => {
|
||||
it('parses a flat object and normalizes values to trimmed strings', () => {
|
||||
expect(
|
||||
parseHeaderOverridesJson('{"User-Agent": " my-client/1.0 ", "x-num": 3, "x-flag": true}')
|
||||
).toEqual([
|
||||
{ name: 'User-Agent', value: 'my-client/1.0' },
|
||||
{ name: 'x-flag', value: 'true' },
|
||||
{ name: 'x-num', value: '3' }
|
||||
])
|
||||
})
|
||||
|
||||
it('drops entries with blank names', () => {
|
||||
expect(parseHeaderOverridesJson('{" ": "v", "x-app": "cli"}')).toEqual([
|
||||
{ name: 'x-app', value: 'cli' }
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects invalid JSON, arrays, primitives and nested values', () => {
|
||||
expect(parseHeaderOverridesJson('not json')).toBeNull()
|
||||
expect(parseHeaderOverridesJson('[1,2]')).toBeNull()
|
||||
expect(parseHeaderOverridesJson('"str"')).toBeNull()
|
||||
expect(parseHeaderOverridesJson('null')).toBeNull()
|
||||
expect(parseHeaderOverridesJson('{"a": {"b": 1}}')).toBeNull()
|
||||
expect(parseHeaderOverridesJson('{"a": null}')).toBeNull()
|
||||
})
|
||||
|
||||
it('parses an empty object to an empty row list', () => {
|
||||
expect(parseHeaderOverridesJson('{}')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('serializeHeaderOverrideRows', () => {
|
||||
it('serializes named rows and skips empty placeholder rows', () => {
|
||||
const text = serializeHeaderOverrideRows([
|
||||
{ name: ' user-agent ', value: ' my-client/1.0 ' },
|
||||
{ name: '', value: 'ignored' },
|
||||
{ name: 'x-app', value: '' }
|
||||
])
|
||||
expect(JSON.parse(text)).toEqual({ 'user-agent': 'my-client/1.0', 'x-app': '' })
|
||||
})
|
||||
|
||||
it('round-trips with parseHeaderOverridesJson', () => {
|
||||
const rows = [
|
||||
{ name: 'a-header', value: '1' },
|
||||
{ name: 'b-header', value: '2' }
|
||||
]
|
||||
expect(parseHeaderOverridesJson(serializeHeaderOverrideRows(rows))).toEqual(rows)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isCustomGrokBaseUrl', () => {
|
||||
it('treats official hosts and their variants as not customized', () => {
|
||||
expect(isCustomGrokBaseUrl('https://api.x.ai/v1')).toBe(false)
|
||||
expect(isCustomGrokBaseUrl('https://cli-chat-proxy.grok.com/v1')).toBe(false)
|
||||
expect(isCustomGrokBaseUrl('HTTPS://API.X.AI:443/')).toBe(false)
|
||||
expect(isCustomGrokBaseUrl('https://api.x.ai:8443/v1')).toBe(false)
|
||||
})
|
||||
|
||||
it('treats empty, non-string and unparseable values as not customized', () => {
|
||||
expect(isCustomGrokBaseUrl('')).toBe(false)
|
||||
expect(isCustomGrokBaseUrl(' ')).toBe(false)
|
||||
expect(isCustomGrokBaseUrl(undefined)).toBe(false)
|
||||
expect(isCustomGrokBaseUrl(42)).toBe(false)
|
||||
expect(isCustomGrokBaseUrl('not a url')).toBe(false)
|
||||
})
|
||||
|
||||
it('treats third-party hosts as customized', () => {
|
||||
expect(isCustomGrokBaseUrl('https://relay.example.com/v1')).toBe(true)
|
||||
expect(isCustomGrokBaseUrl('https://relay.example.com/xai/v1')).toBe(true)
|
||||
expect(isCustomGrokBaseUrl('http://relay.example.com/v1')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -200,6 +286,16 @@ describe('getHeaderOverrideTemplate', () => {
|
||||
expect(names).toContain('openai-beta')
|
||||
expect(validateHeaderOverrideRows(rows)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns Grok forwarding headers with empty values for grok', () => {
|
||||
const rows = getHeaderOverrideTemplate('grok')
|
||||
expect(rows.every((r) => r.value === '')).toBe(true)
|
||||
const names = rows.map((r) => r.name)
|
||||
expect(names).toContain('user-agent')
|
||||
expect(names).toContain('x-xai-token-auth')
|
||||
expect(names).toContain('x-grok-client-version')
|
||||
expect(validateHeaderOverrideRows(rows)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyHeaderOverride', () => {
|
||||
|
||||
@@ -25,7 +25,7 @@ export function applyAntigravityProjectID(
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 请求头覆写(仅 anthropic/openai 平台的 api_key 账号) ==========
|
||||
// ========== 请求头覆写(anthropic/openai 的 api_key 账号 + grok 的 api_key/oauth 账号) ==========
|
||||
|
||||
export const HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY = 'header_override_enabled'
|
||||
export const HEADER_OVERRIDES_CREDENTIAL_KEY = 'header_overrides'
|
||||
@@ -35,9 +35,15 @@ export interface HeaderOverrideRow {
|
||||
value: string
|
||||
}
|
||||
|
||||
/** 请求头覆写支持的平台(与后端 IsHeaderOverrideEligible 保持一致) */
|
||||
export function isHeaderOverridePlatform(platform: string): boolean {
|
||||
return platform === 'anthropic' || platform === 'openai'
|
||||
/** 请求头覆写资格(与后端 IsHeaderOverrideEligible 保持一致) */
|
||||
export function isHeaderOverrideCapable(platform: string, type: string): boolean {
|
||||
if (platform === 'anthropic' || platform === 'openai') {
|
||||
return type === 'apikey'
|
||||
}
|
||||
if (platform === 'grok') {
|
||||
return type === 'apikey' || type === 'oauth'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** 禁止覆写的请求头(与后端 headerOverrideBlockedNames 保持一致) */
|
||||
@@ -70,7 +76,8 @@ const HEADER_OVERRIDE_BLOCKED_NAMES = new Set([
|
||||
'x-codex-turn-metadata',
|
||||
'chatgpt-account-id',
|
||||
'x-claude-code-session-id',
|
||||
'x-client-request-id'
|
||||
'x-client-request-id',
|
||||
'x-grok-conv-id'
|
||||
])
|
||||
|
||||
/** RFC 7230 token:合法的 HTTP header 名称字符集 */
|
||||
@@ -107,9 +114,16 @@ const OPENAI_HEADER_OVERRIDE_TEMPLATE = [
|
||||
'accept-language'
|
||||
]
|
||||
|
||||
/** 模板:Grok 转发常用请求头(第三方转发网关通常需要的身份/准入头,值留空由管理员填写) */
|
||||
const GROK_HEADER_OVERRIDE_TEMPLATE = ['user-agent', 'x-xai-token-auth', 'x-grok-client-version']
|
||||
|
||||
export function getHeaderOverrideTemplate(platform: string): HeaderOverrideRow[] {
|
||||
const names =
|
||||
platform === 'openai' ? OPENAI_HEADER_OVERRIDE_TEMPLATE : ANTHROPIC_HEADER_OVERRIDE_TEMPLATE
|
||||
platform === 'openai'
|
||||
? OPENAI_HEADER_OVERRIDE_TEMPLATE
|
||||
: platform === 'grok'
|
||||
? GROK_HEADER_OVERRIDE_TEMPLATE
|
||||
: ANTHROPIC_HEADER_OVERRIDE_TEMPLATE
|
||||
return names.map((name) => ({ name, value: '' }))
|
||||
}
|
||||
|
||||
@@ -183,6 +197,68 @@ export function splitHeaderOverridesObject(record: unknown): HeaderOverrideRow[]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析粘贴的 JSON 文本为请求头覆写行。
|
||||
* 仅接受扁平 JSON 对象;值允许 string/number/boolean(统一转字符串),
|
||||
* 其余类型或非对象输入返回 null 表示格式非法。键为空白的条目直接丢弃。
|
||||
*/
|
||||
export function parseHeaderOverridesJson(text: string): HeaderOverrideRow[] | null {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
||||
const rows: HeaderOverrideRow[] = []
|
||||
for (const [rawName, rawValue] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
const name = rawName.trim()
|
||||
if (!name) continue
|
||||
if (
|
||||
typeof rawValue !== 'string' &&
|
||||
typeof rawValue !== 'number' &&
|
||||
typeof rawValue !== 'boolean'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
rows.push({ name, value: String(rawValue).trim() })
|
||||
}
|
||||
return rows.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
/** 请求头覆写行 → 便于迁移/备份的 JSON 文本(跳过名称为空的占位行) */
|
||||
export function serializeHeaderOverrideRows(rows: HeaderOverrideRow[]): string {
|
||||
const record: Record<string, string> = {}
|
||||
for (const row of rows) {
|
||||
const name = row.name.trim()
|
||||
if (!name) continue
|
||||
record[name] = row.value.trim()
|
||||
}
|
||||
return JSON.stringify(record, null, 2)
|
||||
}
|
||||
|
||||
// ========== Grok 自定义转发地址(base_url 仅改写转发端点,凭证生命周期不受影响) ==========
|
||||
|
||||
const GROK_OFFICIAL_BASE_URL_HOSTS = new Set(['api.x.ai', 'cli-chat-proxy.grok.com'])
|
||||
|
||||
/**
|
||||
* 判断 Grok 账号存储的 base_url 是否为自定义转发地址。
|
||||
* 官方主机的任意变体与无法解析的值均视为"未定制"(与后端 IsOfficialBaseURL 对齐),
|
||||
* 用于 OAuth 账号编辑时决定"自定义上游地址"开关的初始状态。
|
||||
*/
|
||||
export function isCustomGrokBaseUrl(value: unknown): boolean {
|
||||
if (typeof value !== 'string') return false
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return false
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(trimmed)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return !GROK_OFFICIAL_BASE_URL_HOSTS.has(parsed.hostname.toLowerCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* 将请求头覆写写入 credentials。
|
||||
* create 模式:关闭时不写入任何字段;edit 模式:关闭时删除字段(全量替换语义)。
|
||||
|
||||
@@ -322,7 +322,7 @@ export default {
|
||||
selectionInfo:
|
||||
'{count} account(s) selected. Only checked or filled fields will be updated; others stay unchanged.',
|
||||
baseUrlPlaceholder: 'https://api.anthropic.com or https://api.openai.com',
|
||||
baseUrlNotice: 'Applies to API Key accounts only; leave empty to keep existing value',
|
||||
baseUrlNotice: 'Applies to API Key accounts and the forwarding endpoint of Grok OAuth accounts; leave empty to keep existing value',
|
||||
submit: 'Update Accounts',
|
||||
updating: 'Updating...',
|
||||
success: 'Updated {count} account(s)',
|
||||
@@ -579,6 +579,13 @@ export default {
|
||||
valuePlaceholder: 'Override value (leave empty to skip)',
|
||||
addRow: 'Add Header',
|
||||
fillTemplate: 'Fill Template',
|
||||
importJson: 'Import JSON',
|
||||
importJsonPlaceholder: '{"user-agent": "my-client/1.0", "x-relay-token": "..."}',
|
||||
importJsonApply: 'Parse & Fill',
|
||||
importJsonCancel: 'Cancel',
|
||||
importJsonHint: 'Paste a flat JSON object (header name → value). Parsing replaces the current rows.',
|
||||
importJsonInvalid: 'Invalid JSON: expected a flat object of header name → string value',
|
||||
copyJson: 'Copy as JSON',
|
||||
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.',
|
||||
@@ -589,6 +596,13 @@ export default {
|
||||
invalidValue: 'Invalid header value (control characters are not allowed; max length 8192)',
|
||||
tooManyEntries: 'Too many header override entries (max 64)'
|
||||
},
|
||||
grokCustomBaseUrl: {
|
||||
title: 'Custom Upstream URL',
|
||||
hint: 'When enabled, account traffic (chat/media/probes) is forwarded to the specified address. OAuth authorization and token refresh are unaffected and stay on the official endpoints.',
|
||||
placeholder: 'https://relay.example.com/v1',
|
||||
required: 'An address is required when Custom Upstream URL is enabled',
|
||||
invalid: 'Invalid upstream address (must be a full http(s):// URL)'
|
||||
},
|
||||
autoPauseOnExpired: 'Auto Pause On Expired',
|
||||
autoPauseOnExpiredDesc: 'When enabled, the account will auto pause scheduling after it expires',
|
||||
autoPause5hThreshold: '5h Usage Threshold (%)',
|
||||
|
||||
@@ -425,7 +425,7 @@ export default {
|
||||
title: '批量编辑账号',
|
||||
selectionInfo: '已选择 {count} 个账号。只更新您勾选或填写的字段,未勾选的字段保持不变。',
|
||||
baseUrlPlaceholder: 'https://api.anthropic.com 或 https://api.openai.com',
|
||||
baseUrlNotice: '仅适用于 API Key 账号,留空则不修改',
|
||||
baseUrlNotice: '适用于 API Key 账号及 Grok OAuth 账号的转发端点,留空则不修改',
|
||||
submit: '批量更新',
|
||||
updating: '更新中...',
|
||||
success: '成功更新 {count} 个账号',
|
||||
@@ -672,6 +672,13 @@ export default {
|
||||
valuePlaceholder: '覆写值(留空表示不覆写)',
|
||||
addRow: '添加请求头',
|
||||
fillTemplate: '填入模板',
|
||||
importJson: 'JSON 导入',
|
||||
importJsonPlaceholder: '{"user-agent": "my-client/1.0", "x-relay-token": "..."}',
|
||||
importJsonApply: '解析并填入',
|
||||
importJsonCancel: '取消',
|
||||
importJsonHint: '粘贴扁平 JSON 对象(请求头名 → 值),解析后将整体替换当前列表。',
|
||||
importJsonInvalid: 'JSON 格式不正确:需要"请求头名 → 字符串值"的扁平对象',
|
||||
copyJson: '复制为 JSON',
|
||||
emptyValueHint: '值留空的行不会参与覆盖,仅作为待填写的占位。',
|
||||
bulkDisableHint: '保存后将关闭所选账号的请求头覆写并清空已有配置。',
|
||||
bulkReplaceHint: '保存后将用下方配置整体替换所选账号已有的请求头覆写配置。',
|
||||
@@ -682,6 +689,13 @@ export default {
|
||||
invalidValue: '请求头值不合法(不允许控制字符,长度不超过 8192)',
|
||||
tooManyEntries: '请求头覆写条目过多(最多 64 条)'
|
||||
},
|
||||
grokCustomBaseUrl: {
|
||||
title: '自定义上游地址',
|
||||
hint: '开启后账号流量(对话/媒体/探测)改发指定地址;OAuth 授权与令牌刷新不受影响,仍走官方端点。',
|
||||
placeholder: 'https://relay.example.com/v1',
|
||||
required: '开启自定义上游地址后必须填写地址',
|
||||
invalid: '上游地址格式不正确(需为 http(s):// 开头的完整地址)'
|
||||
},
|
||||
autoPauseOnExpired: '过期自动暂停调度',
|
||||
autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度',
|
||||
autoPause5hThreshold: '5h 用量阈值(%)',
|
||||
|
||||
Reference in New Issue
Block a user