diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index e39b36d3..0b5448af 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -268,6 +268,14 @@ func AccountFromServiceShallow(a *service.Account) *Account { target := a.GetCacheTTLOverrideTarget() out.CacheTTLOverrideTarget = &target } + // 自定义 Base URL 中继转发 + if a.IsCustomBaseURLEnabled() { + enabled := true + out.CustomBaseURLEnabled = &enabled + if customURL := a.GetCustomBaseURL(); customURL != "" { + out.CustomBaseURL = &customURL + } + } } // 提取账号配额限制(apikey / bedrock 类型有效) diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index aa419d6b..8af6990e 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -198,6 +198,10 @@ type Account struct { CacheTTLOverrideEnabled *bool `json:"cache_ttl_override_enabled,omitempty"` CacheTTLOverrideTarget *string `json:"cache_ttl_override_target,omitempty"` + // 自定义 Base URL 中继转发(仅 Anthropic OAuth/SetupToken 账号有效) + CustomBaseURLEnabled *bool `json:"custom_base_url_enabled,omitempty"` + CustomBaseURL *string `json:"custom_base_url,omitempty"` + // API Key 账号配额限制 QuotaLimit *float64 `json:"quota_limit,omitempty"` QuotaUsed *float64 `json:"quota_used,omitempty"` diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 741e33e8..a1449ffd 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1229,6 +1229,28 @@ func (a *Account) IsSessionIDMaskingEnabled() bool { return false } +// IsCustomBaseURLEnabled 检查是否启用自定义 base URL 中继转发 +// 仅适用于 Anthropic OAuth/SetupToken 类型账号 +func (a *Account) IsCustomBaseURLEnabled() bool { + if !a.IsAnthropicOAuthOrSetupToken() { + return false + } + if a.Extra == nil { + return false + } + if v, ok := a.Extra["custom_base_url_enabled"]; ok { + if enabled, ok := v.(bool); ok { + return enabled + } + } + return false +} + +// GetCustomBaseURL 返回自定义中继服务的 base URL +func (a *Account) GetCustomBaseURL() string { + return a.GetExtraString("custom_base_url") +} + // IsCacheTTLOverrideEnabled 检查是否启用缓存 TTL 强制替换 // 仅适用于 Anthropic OAuth/SetupToken 类型账号 // 启用后将所有 cache creation tokens 归入指定的 TTL 类型(5m 或 1h) diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 5b7a97b0..44214b65 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -12,6 +12,7 @@ import ( "log/slog" mathrand "math/rand" "net/http" + "net/url" "os" "path/filepath" "regexp" @@ -4150,10 +4151,12 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A return nil, err } - // 获取代理URL + // 获取代理URL(自定义 base URL 模式下,proxy 通过 buildCustomRelayURL 作为查询参数传递) proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { - proxyURL = account.Proxy.URL() + if !account.IsCustomBaseURLEnabled() || account.GetCustomBaseURL() == "" { + proxyURL = account.Proxy.URL() + } } // 解析 TLS 指纹 profile(同一请求生命周期内不变,避免重试循环中重复解析) @@ -5628,6 +5631,16 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex } targetURL = validatedURL + "/v1/messages?beta=true" } + } else if account.IsCustomBaseURLEnabled() { + customURL := account.GetCustomBaseURL() + if customURL == "" { + return nil, fmt.Errorf("custom_base_url is enabled but not configured for account %d", account.ID) + } + validatedURL, err := s.validateUpstreamBaseURL(customURL) + if err != nil { + return nil, err + } + targetURL = s.buildCustomRelayURL(validatedURL, "/v1/messages", account) } clientHeaders := http.Header{} @@ -8063,10 +8076,12 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, return err } - // 获取代理URL + // 获取代理URL(自定义 base URL 模式下,proxy 通过 buildCustomRelayURL 作为查询参数传递) proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { - proxyURL = account.Proxy.URL() + if !account.IsCustomBaseURLEnabled() || account.GetCustomBaseURL() == "" { + proxyURL = account.Proxy.URL() + } } // 发送请求 @@ -8345,6 +8360,16 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con } targetURL = validatedURL + "/v1/messages/count_tokens?beta=true" } + } else if account.IsCustomBaseURLEnabled() { + customURL := account.GetCustomBaseURL() + if customURL == "" { + return nil, fmt.Errorf("custom_base_url is enabled but not configured for account %d", account.ID) + } + validatedURL, err := s.validateUpstreamBaseURL(customURL) + if err != nil { + return nil, err + } + targetURL = s.buildCustomRelayURL(validatedURL, "/v1/messages/count_tokens", account) } clientHeaders := http.Header{} @@ -8471,6 +8496,19 @@ func (s *GatewayService) countTokensError(c *gin.Context, status int, errType, m }) } +// buildCustomRelayURL 构建自定义中继转发 URL +// 在 path 后附加 beta=true 和可选的 proxy 查询参数 +func (s *GatewayService) buildCustomRelayURL(baseURL, path string, account *Account) string { + u := strings.TrimRight(baseURL, "/") + path + "?beta=true" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL := account.Proxy.URL() + if proxyURL != "" { + u += "&proxy=" + url.QueryEscape(proxyURL) + } + } + return u +} + func (s *GatewayService) validateUpstreamBaseURL(raw string) (string, error) { if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled { normalized, err := urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP) diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 806b57db..7ffa453f 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -2245,6 +2245,41 @@

+ + +
+
+
+ +

+ {{ t('admin.accounts.quotaControl.customBaseUrl.hint') }} +

+
+ +
+
+ +
+
@@ -3095,6 +3130,8 @@ const tlsFingerprintProfiles = ref<{ id: number; name: string }[]>([]) const sessionIdMaskingEnabled = ref(false) const cacheTTLOverrideEnabled = ref(false) const cacheTTLOverrideTarget = ref('5m') +const customBaseUrlEnabled = ref(false) +const customBaseUrl = ref('') // Gemini tier selection (used as fallback when auto-detection is unavailable/fails) const geminiTierGoogleOne = ref<'google_one_free' | 'google_ai_pro' | 'google_ai_ultra'>('google_one_free') @@ -3765,6 +3802,8 @@ const resetForm = () => { sessionIdMaskingEnabled.value = false cacheTTLOverrideEnabled.value = false cacheTTLOverrideTarget.value = '5m' + customBaseUrlEnabled.value = false + customBaseUrl.value = '' allowOverages.value = false antigravityAccountType.value = 'oauth' upstreamBaseUrl.value = '' @@ -4856,6 +4895,12 @@ const handleAnthropicExchange = async (authCode: string) => { extra.cache_ttl_override_target = cacheTTLOverrideTarget.value } + // Add custom base URL settings + if (customBaseUrlEnabled.value && customBaseUrl.value.trim()) { + extra.custom_base_url_enabled = true + extra.custom_base_url = customBaseUrl.value.trim() + } + const credentials: Record = { ...tokenInfo } applyInterceptWarmup(credentials, interceptWarmupRequests.value, 'create') await createAccountAndFinish(form.platform, addMethod.value as AccountType, credentials, extra) @@ -4974,6 +5019,12 @@ const handleCookieAuth = async (sessionKey: string) => { extra.cache_ttl_override_target = cacheTTLOverrideTarget.value } + // Add custom base URL settings + if (customBaseUrlEnabled.value && customBaseUrl.value.trim()) { + extra.custom_base_url_enabled = true + extra.custom_base_url = customBaseUrl.value.trim() + } + const accountName = keys.length > 1 ? `${form.name} #${i + 1}` : form.name const credentials: Record = { ...tokenInfo } diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index da6c9715..607e7a69 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1580,6 +1580,41 @@

+ + +
+
+
+ +

+ {{ t('admin.accounts.quotaControl.customBaseUrl.hint') }} +

+
+ +
+
+ +
+
@@ -1854,6 +1889,8 @@ const tlsFingerprintProfiles = ref<{ id: number; name: string }[]>([]) const sessionIdMaskingEnabled = ref(false) const cacheTTLOverrideEnabled = ref(false) const cacheTTLOverrideTarget = ref('5m') +const customBaseUrlEnabled = ref(false) +const customBaseUrl = ref('') // OpenAI 自动透传开关(OAuth/API Key) const openaiPassthroughEnabled = ref(false) @@ -2482,6 +2519,8 @@ function loadQuotaControlSettings(account: Account) { sessionIdMaskingEnabled.value = false cacheTTLOverrideEnabled.value = false cacheTTLOverrideTarget.value = '5m' + customBaseUrlEnabled.value = false + customBaseUrl.value = '' // Only applies to Anthropic OAuth/SetupToken accounts if (account.platform !== 'anthropic' || (account.type !== 'oauth' && account.type !== 'setup-token')) { @@ -2528,6 +2567,12 @@ function loadQuotaControlSettings(account: Account) { cacheTTLOverrideEnabled.value = true cacheTTLOverrideTarget.value = account.cache_ttl_override_target || '5m' } + + // Load custom base URL setting + if (account.custom_base_url_enabled === true) { + customBaseUrlEnabled.value = true + customBaseUrl.value = account.custom_base_url || '' + } } function formatTempUnschedKeywords(value: unknown) { @@ -2980,6 +3025,15 @@ const handleSubmit = async () => { delete newExtra.cache_ttl_override_target } + // Custom base URL relay setting + if (customBaseUrlEnabled.value && customBaseUrl.value.trim()) { + newExtra.custom_base_url_enabled = true + newExtra.custom_base_url = customBaseUrl.value.trim() + } else { + delete newExtra.custom_base_url_enabled + delete newExtra.custom_base_url + } + updatePayload.extra = newExtra } diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 07a0e634..d1f55e58 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -2318,6 +2318,11 @@ export default { target: 'Target TTL', targetHint: 'Select the TTL tier for billing' }, + customBaseUrl: { + label: 'Custom Relay URL', + hint: 'Forward requests to a custom relay service. Proxy URL will be passed as a query parameter.', + urlHint: 'Relay service URL (e.g., https://relay.example.com)', + }, clientAffinity: { label: 'Client Affinity Scheduling', hint: 'When enabled, new sessions prefer accounts previously used by this client to reduce account switching' diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index a6b6e8b5..55634bd8 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -2462,6 +2462,11 @@ export default { target: '目标 TTL', targetHint: '选择计费使用的 TTL 类型' }, + customBaseUrl: { + label: '自定义转发地址', + hint: '启用后将请求转发到自定义中继服务,代理地址将作为 URL 参数传递给中继服务', + urlHint: '中继服务地址(如 https://relay.example.com)', + }, clientAffinity: { label: '客户端亲和调度', hint: '启用后,新会话会优先调度到该客户端之前使用过的账号,避免频繁切换账号' diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 8ab48216..f9425ad0 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -734,6 +734,10 @@ export interface Account { cache_ttl_override_enabled?: boolean | null cache_ttl_override_target?: string | null + // 自定义 Base URL 中继转发(仅 Anthropic OAuth/SetupToken 账号有效) + custom_base_url_enabled?: boolean | null + custom_base_url?: string | null + // 客户端亲和调度(仅 Anthropic/Antigravity 平台有效) // 启用后新会话会优先调度到客户端之前使用过的账号 client_affinity_enabled?: boolean | null