refactor(platform): 多协议供应商的转发与探测判断改查 profile

平台清单之外,后端还有几处用"国产厂商"或"国产厂商 ∪ OpenCode"表示
多协议供应商,新登记的供应商会被漏掉(例如上游模型同步直接报不支持)。

- 适用于全部多协议供应商的判断改用 IsMultiProtocolAPIKey:上游模型同步与
  模型目录基址、Anthropic count_tokens 本地估算、HTML 403 豁免与累计计数、Claude 型号
  计费候选过滤;
- 新增 RoutesProtocolByInbound(profile 分流方式为按入站协议),替换原来
  只针对国产厂商、实际描述"按入站协议分流"的判断:Responses 直转回退、
  Responses 能力标记(含创建 / 更新后的异步探测调度)、上游计费探测的
  adaptive 基址、Anthropic API Key 鉴权方式、OpenAI 输入 token 本地估算。
  按模型分流的供应商(OpenCode)仍走 routesByModel 分支。

依赖各家额度 / 余额接口的逻辑(Coding Plan 窗口、余额不足冷却等)仍按平台
显式判断。现有平台上两组判断与改动前逐一相同(测试覆盖全部平台与非法值);
另测试临时登记的新供应商按分流方式分别获得国产厂商与 OpenCode 的行为。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SavitarC
2026-10-09 10:10:33 +08:00
co-authored by Claude Opus 5.5
parent eefa7dd9dc
commit c556aee8fd
11 changed files with 82 additions and 17 deletions
@@ -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 {
@@ -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
}
@@ -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,避免
@@ -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")
@@ -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
@@ -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))
@@ -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))
}
@@ -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
}
@@ -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 平台:保持原有行为
@@ -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 {
+3 -4
View File
@@ -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)