From b65275235fc1bca21aae40f45c8668fdfa27073b Mon Sep 17 00:00:00 2001
From: shaw
Date: Mon, 30 Mar 2026 08:50:12 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20Anthropic=20oauth/setup-token=E8=B4=A6?=
=?UTF-8?q?=E5=8F=B7=E6=94=AF=E6=8C=81=E8=87=AA=E5=AE=9A=E4=B9=89=E8=BD=AC?=
=?UTF-8?q?=E5=8F=91URL?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/internal/handler/dto/mappers.go | 8 +++
backend/internal/handler/dto/types.go | 4 ++
backend/internal/service/account.go | 22 ++++++++
backend/internal/service/gateway_service.go | 46 ++++++++++++++--
.../components/account/CreateAccountModal.vue | 51 ++++++++++++++++++
.../components/account/EditAccountModal.vue | 54 +++++++++++++++++++
frontend/src/i18n/locales/en.ts | 5 ++
frontend/src/i18n/locales/zh.ts | 5 ++
frontend/src/types/index.ts | 4 ++
9 files changed, 195 insertions(+), 4 deletions(-)
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