diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 97ebd9ed35..f954cce572 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -1226,7 +1226,7 @@ func (h *AccountHandler) Update(c *gin.Context) { // 网关会按"现状即证据"默认走 Responses。 func (h *AccountHandler) scheduleOpenAIResponsesProbe(account *service.Account) { if account == nil || account.Type != service.AccountTypeAPIKey || - (account.Platform != service.PlatformOpenAI && !service.IsCNProvider(account.Platform)) { + (account.Platform != service.PlatformOpenAI && !account.RoutesProtocolByInbound()) { return } if h.accountTestService == nil { diff --git a/backend/internal/service/anthropic_apikey_auth.go b/backend/internal/service/anthropic_apikey_auth.go index 8bd8c4ba8f..a7f3963071 100644 --- a/backend/internal/service/anthropic_apikey_auth.go +++ b/backend/internal/service/anthropic_apikey_auth.go @@ -21,7 +21,7 @@ func (a *Account) GetAnthropicAPIKeyAuthScheme() string { if a == nil || a.Type != AccountTypeAPIKey { return AnthropicAPIKeyAuthSchemeXAPIKey } - if a.Platform != PlatformAnthropic && !a.IsCNProvider() { + if a.Platform != PlatformAnthropic && !a.RoutesProtocolByInbound() { return AnthropicAPIKeyAuthSchemeXAPIKey } diff --git a/backend/internal/service/openai_apikey_responses_probe.go b/backend/internal/service/openai_apikey_responses_probe.go index d942afab08..e7dc1cb201 100644 --- a/backend/internal/service/openai_apikey_responses_probe.go +++ b/backend/internal/service/openai_apikey_responses_probe.go @@ -130,8 +130,8 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte if account.Type != AccountTypeAPIKey { return } - if account.IsCNProvider() { - // 国产 OpenAI 兼容上游默认仅支持 /v1/chat/completions。直接落标 false + if account.RoutesProtocolByInbound() { + // 按入站协议分流的供应商(国产厂商等)默认仅支持 /v1/chat/completions。直接落标 false // 走 Chat Completions 直转,跳过网络探测。 // 例外:deepseek / kimi 的固定 responses 和 adaptive 账号使用官方原生 // Responses 端点,落标 force_responses;其余协议显式重置为 auto,避免 diff --git a/backend/internal/service/openai_gateway_count_tokens.go b/backend/internal/service/openai_gateway_count_tokens.go index 9ece2430c8..9579f2054f 100644 --- a/backend/internal/service/openai_gateway_count_tokens.go +++ b/backend/internal/service/openai_gateway_count_tokens.go @@ -150,7 +150,7 @@ func prepareNativeOpenAIInputTokensCountRequest(body []byte, account *Account) ( } func shouldEstimateOpenAIInputTokensLocally(account *Account) bool { - if account == nil || account.IsGrok() || account.IsCNProvider() || account.Type == AccountTypeUpstream { + if account == nil || account.IsGrok() || account.RoutesProtocolByInbound() || account.Type == AccountTypeUpstream { return true } if account.Type != AccountTypeAPIKey { @@ -264,14 +264,14 @@ func (s *OpenAIGatewayService) ForwardCountTokensAsAnthropic( return fmt.Errorf("count_tokens: missing account") } - // 国产供应商与 OpenCode(全部协议,含 anthropic):一律本地估算,不发上游请求。 + // 多协议 API Key 供应商(国产厂商与聚合平台,全部协议,含 anthropic):一律本地估算,不发上游请求。 // 依据(2026-08 核实):三家的 Anthropic 兼容层均未提供 // /v1/messages/count_tokens——DeepSeek 官方 anthropic_api 文档无此端点 // (且注明 anthropic-version 头被忽略),聚合网关 OpenModel 明确标注 // count_tokens 为 "Anthropic only",Kimi/智谱亦无任何文档承诺。转发上游 // 只会常态 404,且错误还会流入账号处置逻辑误伤整账号调度;Claude Code // 高频调用此端点,本地 tiktoken 估算是与 Grok 一致的既有方案。 - if account.IsCNProvider() || account.IsOpenCodeGo() { + if account.IsMultiProtocolAPIKey() { estimated, err := estimateAnthropicCountTokensLocally(body) if err != nil { writeAnthropicCountTokensError(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body") diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 3825507b85..3cf10c5c73 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -1351,9 +1351,10 @@ func shouldForwardOpenAIResponsesViaRawChatCompletions(account *Account) bool { // Grok/GPT/Muse into Chat Completions. return false } - if account.IsCNProvider() { - // CN 的显式协议配置优先于异步探针 Extra;adaptive 仅 DeepSeek / Kimi - // 有原生 Responses,GLM 回退 Chat Completions。 + if account.RoutesProtocolByInbound() { + // 按入站协议分流的供应商(国产厂商等):显式协议配置优先于异步探针 + // Extra;adaptive 仅在供应商有原生 Responses 端点时直转,否则回退 + // Chat Completions(如 GLM)。 switch account.GetAPIProtocol() { case APIProtocolChatCompletions: return true diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 824d2b94dd..c0803a4c51 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -930,7 +930,7 @@ func groupMediaPricingLooksIncomplete(group *Group) bool { // 运营者的修复手段是配置账号级 model_mapping(映射到已定价的 CN 模型)或 // 分组/渠道显式定价。 func (s *OpenAIGatewayService) filterCNProviderBillingModelCandidates(ctx context.Context, account *Account, apiKey *APIKey, candidates []string) []string { - if account == nil || (!account.IsCNProvider() && !account.IsOpenCodeGo()) { + if account == nil || !account.IsMultiProtocolAPIKey() { return candidates } out := make([]string, 0, len(candidates)) diff --git a/backend/internal/service/platform_list_test.go b/backend/internal/service/platform_list_test.go index 09cc70b090..c06f60fcf8 100644 --- a/backend/internal/service/platform_list_test.go +++ b/backend/internal/service/platform_list_test.go @@ -116,3 +116,59 @@ func TestProviderProfilesUseOpenAIGateway(t *testing.T) { require.Equal(t, domain.PlatformGatewayOpenAI, spec.Gateway, platform) } } + +// 转发、探测与计费中"多协议供应商"类判断改为查 profile:现有平台上与改动前的 +// 国产厂商 / 国产厂商 ∪ OpenCode 判断逐一相同。 +func TestProviderProfileAccountPredicatesMatchLegacy(t *testing.T) { + for _, platform := range platformProbeValues { + account := &Account{Platform: platform, Type: AccountTypeAPIKey} + require.Equal(t, legacyIsCNProvider(platform), account.RoutesProtocolByInbound(), platform) + require.Equal(t, legacyIsCNProvider(platform) || platform == PlatformOpenCodeGo, account.IsMultiProtocolAPIKey(), platform) + } + var nilAccount *Account + require.False(t, nilAccount.RoutesProtocolByInbound()) +} + +// 仅在 providerProfiles 中登记的新供应商按分流方式自动获得对应行为:按入站协议 +// 分流的与国产厂商一致,按模型分流的与 OpenCode 一致。 +func TestNewlyRegisteredProviderInheritsRoutingBehaviour(t *testing.T) { + const byInbound, byModel = "test_inbound_provider", "test_model_provider" + providerProfiles[byInbound] = &ProviderProfile{ + Platform: byInbound, DefaultMode: AccountModePayG, Routing: ProviderRoutingByInbound, + Modes: map[string]ProviderEndpoints{AccountModePayG: {BaseURLs: map[string]string{ + APIProtocolChatCompletions: "https://inbound.example.com/v1", + APIProtocolAnthropic: "https://inbound.example.com/anthropic", + }}}, + } + providerProfiles[byModel] = &ProviderProfile{ + Platform: byModel, DefaultMode: AccountModePayG, Routing: ProviderRoutingByModel, + Modes: map[string]ProviderEndpoints{AccountModePayG: {BaseURLs: map[string]string{ + APIProtocolChatCompletions: "https://model.example.com/v1", + }}}, + } + t.Cleanup(func() { + delete(providerProfiles, byInbound) + delete(providerProfiles, byModel) + }) + + inbound := &Account{Platform: byInbound, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "api_key": "sk-test", "api_protocol": APIProtocolAdaptive, + "base_url": "https://inbound.example.com/v1", + }} + require.True(t, inbound.IsMultiProtocolAPIKey()) + require.True(t, inbound.RoutesProtocolByInbound()) + // 无原生 Responses 端点的 adaptive 账号回退 Chat Completions(与 GLM 相同)。 + require.True(t, shouldForwardOpenAIResponsesViaRawChatCompletions(inbound)) + require.True(t, shouldEstimateOpenAIInputTokensLocally(inbound)) + require.Equal(t, "https://inbound.example.com/v1", upstreamModelRegistryBaseURL(inbound)) + + model := &Account{Platform: byModel, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "api_key": "sk-test", "api_protocol": APIProtocolAdaptive, + "base_url": "https://model.example.com/v1", + }} + require.True(t, model.IsMultiProtocolAPIKey()) + require.False(t, model.RoutesProtocolByInbound()) + // 按模型分流:protocol_rules 决定协议,不按 Responses 能力标记回退(与 OpenCode 相同)。 + require.False(t, shouldForwardOpenAIResponsesViaRawChatCompletions(model)) + require.Equal(t, "https://model.example.com/v1", upstreamModelRegistryBaseURL(model)) +} diff --git a/backend/internal/service/provider_profile.go b/backend/internal/service/provider_profile.go index 111cf968fb..c8ad645132 100644 --- a/backend/internal/service/provider_profile.go +++ b/backend/internal/service/provider_profile.go @@ -204,3 +204,12 @@ func (a *Account) routesByModel() bool { profile := a.providerProfile() return profile != nil && profile.Routing == ProviderRoutingByModel } + +// RoutesProtocolByInbound 报告账号是否为按入站协议分流的多协议 API Key 供应商 +// (单一厂商型,目前即国产厂商)。这类账号共享"显式协议优先、adaptive 选同协议 +// 原生端点"的转发、Responses 能力标记与本地 token 估算逻辑;按模型分流的聚合 +// 平台由 protocol_rules 决定协议,走 routesByModel 分支。 +func (a *Account) RoutesProtocolByInbound() bool { + profile := a.providerProfile() + return profile != nil && profile.Routing != ProviderRoutingByModel +} diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 6438efc3e2..6846c9a5d1 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -1003,7 +1003,7 @@ func (s *RateLimitService) handle403(ctx context.Context, account *Account, upst // 国产供应商与 openai 同口径:HTML 403(CDN/代理拦截页)不构成账号失效证据, // 且 403 在 failover 状态集里会被逐账号重放——直接 SetError 会让一个坏请求/ // 一层坏代理连环永久禁用整组账号。走 HTML 豁免 + N 次累计 + 临时冷却。 - if account.Platform == PlatformOpenAI || IsCNProvider(account.Platform) || account.IsOpenCodeGo() { + if account.Platform == PlatformOpenAI || account.IsMultiProtocolAPIKey() { return s.handleOpenAI403(ctx, account, upstreamMsg, responseBody) } // 非 Antigravity 平台:保持原有行为 diff --git a/backend/internal/service/upstream_billing_probe.go b/backend/internal/service/upstream_billing_probe.go index a53fca5597..85c4122647 100644 --- a/backend/internal/service/upstream_billing_probe.go +++ b/backend/internal/service/upstream_billing_probe.go @@ -627,7 +627,7 @@ func (s *UpstreamBillingProbeService) probeLoadedAccount(ctx context.Context, ac return s.persistProbeFailure(ctx, account, intervalMinutes, now, 0, "missing_api_key", 0) } baseURL := account.GetCredential("base_url") - if account.IsCNProvider() && account.IsAdaptiveAPIProtocol() { + if account.RoutesProtocolByInbound() && account.IsAdaptiveAPIProtocol() { baseURL = account.GetCNProtocolBaseURL(APIProtocolChatCompletions) } if account.Platform == PlatformOpenAI { diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index 7596dea131..3c5eb2fcd2 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -625,7 +625,7 @@ func upstreamModelRegistryBaseURL(account *Account) string { return "" } switch { - case account.IsOpenAI() || account.IsCNProvider() || account.IsOpenCodeGo(): + case account.IsOpenAI() || account.IsMultiProtocolAPIKey(): return account.GetOpenAIFormatBaseURL() case account.IsGrok(): return account.GetGrokBaseURL() @@ -792,9 +792,8 @@ func (s *AccountTestService) buildUpstreamModelsRequest(ctx context.Context, acc return s.buildAntigravityAPIKeyModelsRequest(ctx, account) case account.IsGrok(): return s.buildGrokUpstreamModelsRequest(ctx, account) - case account.IsOpenAI() || account.IsCNProvider() || account.IsOpenCodeGo(): - // 国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)与 OpenCode Go - // 复用 OpenAI /v1/models 探测。 + case account.IsOpenAI() || account.IsMultiProtocolAPIKey(): + // 多协议 API Key 供应商(国产厂商与聚合平台)复用 OpenAI /v1/models 探测。 return s.buildOpenAIUpstreamModelsRequest(ctx, account) case account.IsGemini(): return s.buildGeminiUpstreamModelsRequest(ctx, account)