diff --git a/Makefile b/Makefile index 6895d5f85c..55d9b73537 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,8 @@ FRONTEND_CRITICAL_VITEST := \ src/components/account/__tests__/credentialsBuilder.platformCatalog.spec.ts \ src/components/account/__tests__/CreateAccountModal.spec.ts \ src/components/account/__tests__/EditAccountModal.spec.ts \ + src/components/account/__tests__/credentialsBuilder.spec.ts \ + src/components/account/__tests__/OpenCodeGoProtocolRulesEditor.spec.ts \ src/components/keys/__tests__/BulkEditKeysModal.spec.ts \ src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts \ src/views/user/__tests__/KeysView.spec.ts \ diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go index 95d65c91e4..3ec7bacf07 100644 --- a/backend/internal/domain/constants.go +++ b/backend/internal/domain/constants.go @@ -32,7 +32,10 @@ const ( // PlatformOpenCodeGo 是 OpenCode 平台(账号类型 Zen 按量 / Go 订阅)。 // 值保持 opencode_go 以兼容已落库的账号、分组、配额与 Composite 路由。 PlatformOpenCodeGo = "opencode_go" - PlatformComposite = "composite" + // PlatformCommandCode 是 Command Code Provider API(多模型聚合,积分计费, + // 订阅套餐另有 5 小时 / 每周滚动窗口)。 + PlatformCommandCode = "command_code" + PlatformComposite = "composite" ) // Account mode constants 区分国产供应商的「按量付费(余额)」与「Coding Plan」两种接入方式。 diff --git a/backend/internal/domain/platforms.go b/backend/internal/domain/platforms.go index f84801ad6b..ec7497c600 100644 --- a/backend/internal/domain/platforms.go +++ b/backend/internal/domain/platforms.go @@ -46,6 +46,7 @@ var platformList = []PlatformSpec{ {ID: PlatformMiniMax, DisplayName: "MiniMax", Gateway: PlatformGatewayOpenAI, CNProvider: true, LiteLLMProvider: "minimax"}, {ID: PlatformOpenCodeGo, DisplayName: "OpenCode", Gateway: PlatformGatewayOpenAI, LiteLLMProvider: "opencode-go"}, {ID: PlatformTypeSafe, DisplayName: "TypeSafe / Jev", Gateway: PlatformGatewayAnthropic, LiteLLMProvider: "typesafe"}, + {ID: PlatformCommandCode, DisplayName: "Command Code", Gateway: PlatformGatewayOpenAI}, } var platformIndex = func() map[string]int { diff --git a/backend/internal/domain/platforms_test.go b/backend/internal/domain/platforms_test.go index 327fc1c315..0d075b1331 100644 --- a/backend/internal/domain/platforms_test.go +++ b/backend/internal/domain/platforms_test.go @@ -6,25 +6,29 @@ import ( "github.com/stretchr/testify/require" ) -// 重构前各处手写的平台列表,作为平台清单派生结果的等价基准。 +// 重构前各处手写的平台列表,作为平台清单派生结果的等价基准;重构后新登记的平台 +// (Command Code)按同类平台(OpenCode)的位置补入。 var ( legacyDisplayOrder = []string{ PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe, + PlatformCommandCode, } legacyCompositePrecedence = []string{ PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe, + PlatformCommandCode, } legacyOpenAIGateway = []string{ PlatformOpenAI, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, + PlatformCommandCode, } legacyCNProviders = []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} legacyLiteLLMByPlatf = map[string]string{ PlatformAnthropic: "anthropic", PlatformOpenAI: "openai", PlatformGemini: "gemini", PlatformAntigravity: "anthropic", PlatformGrok: "xai", PlatformKimi: "moonshot", PlatformZhipu: "zhipu", PlatformDeepseek: "deepseek", PlatformMiniMax: "minimax", - PlatformOpenCodeGo: "opencode-go", PlatformTypeSafe: "typesafe", + PlatformOpenCodeGo: "opencode-go", PlatformTypeSafe: "typesafe", PlatformCommandCode: "", } ) diff --git a/backend/internal/model/error_passthrough_rule_test.go b/backend/internal/model/error_passthrough_rule_test.go index 17e6fdcbf2..10f9679854 100644 --- a/backend/internal/model/error_passthrough_rule_test.go +++ b/backend/internal/model/error_passthrough_rule_test.go @@ -19,5 +19,6 @@ func TestAllPlatformsIncludesEveryConcretePlatform(t *testing.T) { "minimax", "opencode_go", "typesafe", + "command_code", }, AllPlatforms()) } diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 95010937c7..60b817983f 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -863,7 +863,7 @@ func TestAPIContracts(t *testing.T) { "force_email_on_third_party_signup": false, "default_concurrency": 5, "default_balance": 1.25, - "default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"opencode_go":{"daily":null,"weekly":null,"monthly":null},"typesafe":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}}, + "default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"command_code":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"opencode_go":{"daily":null,"weekly":null,"monthly":null},"typesafe":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}}, "auth_source_default_email_platform_quotas": null, "auth_source_default_github_platform_quotas": null, "auth_source_default_google_platform_quotas": null, @@ -1189,7 +1189,7 @@ func TestAPIContracts(t *testing.T) { "purchase_subscription_url": "", "table_default_page_size": 20, "table_page_size_options": [10, 20, 50], - "default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"opencode_go":{"daily":null,"weekly":null,"monthly":null},"typesafe":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}}, + "default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"command_code":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"opencode_go":{"daily":null,"weekly":null,"monthly":null},"typesafe":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}}, "auth_source_default_email_platform_quotas": null, "auth_source_default_github_platform_quotas": null, "auth_source_default_google_platform_quotas": null, diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 04b609e9e5..cee477b22f 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1547,7 +1547,8 @@ func (a *Account) GetCNAPIKey() string { return a.GetCredential("api_key") } -// GetCodingPlanProvider 根据 base_url 识别 Coding Plan 供应商(kimi / zhipu / minimax), +// GetCodingPlanProvider 根据 base_url 识别 Coding Plan 供应商(kimi / zhipu / minimax; +// OpenCode Go 订阅与官方主机上的 Command Code 账号按平台识别), // 用于路由到对应的额度查询端点。非 coding 模式或无法识别时返回空串。 // 只认官方域名:自定义中转不得把第三方 Key 发往厂商官方额度端点。 func (a *Account) GetCodingPlanProvider() string { @@ -1557,6 +1558,12 @@ func (a *Account) GetCodingPlanProvider() string { if a.IsOpenCodeGoPlan() { return PlatformOpenCodeGo } + if a.IsCommandCode() { + if a.commandCodeUsageSupported() { + return PlatformCommandCode + } + return "" + } if a.GetAccountMode() != AccountModeCoding { return "" } diff --git a/backend/internal/service/account_scheduling_threshold_eval.go b/backend/internal/service/account_scheduling_threshold_eval.go index 0e1a40934c..951629658e 100644 --- a/backend/internal/service/account_scheduling_threshold_eval.go +++ b/backend/internal/service/account_scheduling_threshold_eval.go @@ -58,7 +58,7 @@ func EvaluateAccountSchedulingThreshold(account *Account, thresholds map[string] winner = pickLatestResetSchedulingCandidate(anthropicThresholdCandidates(account), threshold, now) case PlatformGrok: winner = pickLatestResetSchedulingCandidate(grokThresholdCandidates(account), threshold, now) - case PlatformKimi, PlatformZhipu, PlatformMiniMax, PlatformOpenCodeGo: + case PlatformKimi, PlatformZhipu, PlatformMiniMax, PlatformOpenCodeGo, PlatformCommandCode: winner = pickLatestResetSchedulingCandidate(cnProviderThresholdCandidates(account, decision.Platform), threshold, now) default: return decision @@ -353,25 +353,33 @@ func grokThresholdCandidates(account *Account) []*accountSchedulingThresholdCand } } -// cnProviderThresholdCandidates 读取国产供应商 Coding Plan 账号的 5h / weekly 滚动窗口 -// 用量快照(由 CNProviderQuotaService 写入 account.Extra,键形如 -// _5h_used_percent / _weekly_reset_at)。payg 账号无此快照, +// cnProviderThresholdCandidates 读取国产供应商 Coding Plan、OpenCode Go 与 Command Code +// 账号的 5h / weekly(及月度)窗口用量快照(由 CNProviderQuotaService 写入 account.Extra, +// 键形如 _5h_used_percent / _weekly_reset_at)。payg 账号无此快照, // 候选为空 → 不触发阈值停调(余额型走余额检测)。与 openai 的快照驱动停调一致: // 仅当用量超阈值且窗口尚未重置时才停调。 func cnProviderThresholdCandidates(account *Account, provider string) []*accountSchedulingThresholdCandidate { if account == nil || len(account.Extra) == 0 { return nil } + if provider == PlatformCommandCode && !commandCodeWindowsBinding(account.Extra) { + return nil + } candidates := []*accountSchedulingThresholdCandidate{ cnThresholdCandidate(account.Extra, provider, "5h"), cnThresholdCandidate(account.Extra, provider, "weekly"), } - if provider == PlatformOpenCodeGo { + if cnQuotaHasMonthlyWindow(provider) { candidates = append(candidates, cnThresholdCandidate(account.Extra, provider, "monthly")) } return candidates } +// cnQuotaHasMonthlyWindow 报告供应商的额度快照是否含月度窗口(OpenCode Go、Command Code)。 +func cnQuotaHasMonthlyWindow(provider string) bool { + return provider == PlatformOpenCodeGo || provider == PlatformCommandCode +} + func cnThresholdCandidate(extra map[string]any, provider, window string) *accountSchedulingThresholdCandidate { var usedKey, resetKey string switch window { diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 3b84bd2690..e6d1f77fbe 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -385,7 +385,8 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int } // Route to platform-specific test method - if account.IsCNProvider() { + // 按入站协议分流的多协议供应商(国产厂商等):按账号协议选测试路径。 + if account.RoutesProtocolByInbound() { switch account.GetAPIProtocol() { case APIProtocolAdaptive: return s.testCNProviderAdaptiveConnection(c, account, modelID, prompt) @@ -414,8 +415,9 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int return s.routeAntigravityTest(c, account, modelID, prompt) } - if account.IsOpenCodeGo() { - return s.testOpenCodeGoAccountConnection(c, account, modelID, prompt) + // 按模型分流的多模型聚合平台(OpenCode、Command Code 等)。 + if account.routesByModel() { + return s.testModelRoutedAccountConnection(c, account, modelID, prompt) } if account.IsTypeSafe() { @@ -425,39 +427,44 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int return s.testClaudeAccountConnection(c, account, modelID) } -// testOpenCodeGoAccountConnection probes the native endpoint for the selected -// model. Adaptive accounts (the default) follow OpenCodeGoModelProtocol: +// testModelRoutedAccountConnection probes the native endpoint for the selected +// model on providers that route by model (see ProviderRoutingByModel). Adaptive +// accounts (the default) follow the provider's protocol rules, e.g. OpenCode Go: // grok/gpt/muse-spark → Responses, minimax/qwen → Anthropic, everything else // (including deepseek-v4-flash) → Chat Completions. A pinned api_protocol // overrides that catalog. Falling through to the generic Claude tester used // credentials.base_url + /v1/messages?beta=true, which 404s as HTML on // https://opencode.ai/zen/go/v1/v1/messages. -func (s *AccountTestService) testOpenCodeGoAccountConnection(c *gin.Context, account *Account, modelID string, prompt string) error { +func (s *AccountTestService) testModelRoutedAccountConnection(c *gin.Context, account *Account, modelID string, prompt string) error { testModelID := strings.TrimSpace(modelID) if testModelID == "" { - testModelID = DefaultOpenCodeGoTestModel + if profile := account.providerProfile(); profile != nil { + testModelID = profile.DefaultTestModel + } + } + if testModelID == "" { + testModelID = openai.DefaultTestModel } testModelID = account.GetMappedModel(testModelID) - if IsOpenCodeUnsupportedModel(testModelID) { + if account.IsOpenCodeGo() && IsOpenCodeUnsupportedModel(testModelID) { return fmt.Errorf("model %q is not supported on OpenCode standard gateway (gemini models require Google SDK endpoint, jev models require System One endpoint)", testModelID) } - proto := account.GetAPIProtocol() - switch proto { - case APIProtocolChatCompletions, APIProtocolAnthropic, APIProtocolResponses: - default: - proto = openCodeGoNativeProtocol(account, testModelID) + // 与网关同一判定(含上游模型目录);测试没有入站协议,取模型的首选协议。 + protocol := account.resolveModelRoutedProtocol(testModelID) + if s.openaiGatewayService != nil { + protocol = s.openaiGatewayService.resolveUpstreamProtocolFor(account, "", testModelID) } - switch proto { + switch protocol { case APIProtocolAnthropic: return s.testCNProviderAnthropicConnection(c, account, testModelID) case APIProtocolResponses: - return s.testOpenCodeGoResponsesConnection(c, account, testModelID) + return s.testModelRoutedResponsesConnection(c, account, testModelID) default: return s.testCNProviderChatCompletionsConnection(c, account, testModelID, prompt) } } -func (s *AccountTestService) testOpenCodeGoResponsesConnection(c *gin.Context, account *Account, testModelID string) error { +func (s *AccountTestService) testModelRoutedResponsesConnection(c *gin.Context, account *Account, testModelID string) error { authToken := strings.TrimSpace(account.GetOpenAIProtocolAPIKey()) if authToken == "" { return s.sendErrorAndEnd(c, "No API key available") diff --git a/backend/internal/service/account_test_service_cn_adaptive.go b/backend/internal/service/account_test_service_cn_adaptive.go index f56560d53d..83d03371d2 100644 --- a/backend/internal/service/account_test_service_cn_adaptive.go +++ b/backend/internal/service/account_test_service_cn_adaptive.go @@ -18,14 +18,21 @@ import ( const accountTestSuppressCompletionContextKey = "account_test_suppress_completion" // testCNProviderAdaptiveConnection verifies every native endpoint used by an -// adaptive CN-provider account. Zhipu uses Chat Completions plus Anthropic; -// DeepSeek and Kimi additionally use their native Responses endpoints. +// adaptive account of a provider that routes by inbound protocol. Zhipu uses +// Chat Completions plus Anthropic; DeepSeek and Kimi additionally use their +// native Responses endpoints. Endpoints the provider profile does not offer are +// skipped. func (s *AccountTestService) testCNProviderAdaptiveConnection(c *gin.Context, account *Account, modelID string, prompt string) error { - testModelID := strings.TrimSpace(modelID) - if testModelID == "" { - testModelID = openai.DefaultTestModel + requestedModelID := strings.TrimSpace(modelID) + if requestedModelID == "" { + if profile := account.providerProfile(); profile != nil { + requestedModelID = profile.DefaultTestModel + } } - testModelID = account.GetMappedModel(testModelID) + if requestedModelID == "" { + requestedModelID = openai.DefaultTestModel + } + testModelID := account.GetMappedModel(requestedModelID) authToken := strings.TrimSpace(account.GetOpenAIProtocolAPIKey()) if authToken == "" { @@ -36,12 +43,14 @@ func (s *AccountTestService) testCNProviderAdaptiveConnection(c *gin.Context, ac // completion events until every native adaptive endpoint has passed. c.Set(accountTestSuppressCompletionContextKey, true) defer c.Set(accountTestSuppressCompletionContextKey, false) - if err := s.testCNProviderChatCompletionsConnection(c, account, modelID, prompt); err != nil { + if err := s.testCNProviderChatCompletionsConnection(c, account, requestedModelID, prompt); err != nil { return err } - if err := s.testCNProviderAdaptiveAnthropicConnection(c, account, testModelID, authToken); err != nil { - return err + if account.providerSupportsProtocol(APIProtocolAnthropic) { + if err := s.testCNProviderAdaptiveAnthropicConnection(c, account, testModelID, authToken); err != nil { + return err + } } if account.SupportsNativeCNResponses() { @@ -237,10 +246,10 @@ func (s *AccountTestService) testCNProviderAnthropicConnection(c *gin.Context, a if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid Anthropic base URL: %s", err.Error())) } - if hint := cnAnthropicBaseURLMisconfigHint(baseURL); hint != "" { + if hint := cnAnthropicBaseURLMisconfigHint(baseURL, account.routesByModel()); hint != "" { return s.sendErrorAndEnd(c, hint) } - apiURL := strings.TrimRight(baseURL, "/") + "/v1/messages" + apiURL := nativeAnthropicMessagesURL(account, baseURL) c.Writer.Header().Set("Content-Type", "text/event-stream") c.Writer.Header().Set("Cache-Control", "no-cache") @@ -295,7 +304,9 @@ func (s *AccountTestService) testCNProviderAnthropicConnection(c *gin.Context, a // endpoint (paas path, version segment, or chat/completions / responses // suffix). The naive {base}/v1/messages join would 404 (e.g. // .../api/paas/v4/v1/messages) with no hint about the actual misconfiguration. -func cnAnthropicBaseURLMisconfigHint(baseURL string) string { +// versionAware is set for providers whose join is version-aware (see +// nativeAnthropicMessagesURL), where a trailing version segment is valid. +func cnAnthropicBaseURLMisconfigHint(baseURL string, versionAware bool) string { parsed, err := url.Parse(strings.TrimSpace(baseURL)) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return "" @@ -307,7 +318,7 @@ func cnAnthropicBaseURLMisconfigHint(baseURL string) string { openAICompatShaped := strings.Contains(path, "/paas/") || strings.HasSuffix(path, "/chat/completions") || strings.HasSuffix(path, "/responses") || - openAIBaseURLHasVersionSuffix(path) + (!versionAware && openAIBaseURLHasVersionSuffix(path)) if !openAICompatShaped { return "" } diff --git a/backend/internal/service/channel_service_test.go b/backend/internal/service/channel_service_test.go index 62ba8e0b9a..0ecf1e8564 100644 --- a/backend/internal/service/channel_service_test.go +++ b/backend/internal/service/channel_service_test.go @@ -2127,7 +2127,7 @@ func TestMatchingPlatforms(t *testing.T) { {"anthropic returns itself", PlatformAnthropic, []string{PlatformAnthropic}}, {"gemini returns itself", PlatformGemini, []string{PlatformGemini}}, {"openai returns itself", PlatformOpenAI, []string{PlatformOpenAI}}, - {"composite returns concrete platforms", PlatformComposite, []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe}}, + {"composite returns concrete platforms", PlatformComposite, []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe, PlatformCommandCode}}, } for _, tt := range tests { diff --git a/backend/internal/service/cn_provider_balance_check_service.go b/backend/internal/service/cn_provider_balance_check_service.go index 691f8fd33c..975de23345 100644 --- a/backend/internal/service/cn_provider_balance_check_service.go +++ b/backend/internal/service/cn_provider_balance_check_service.go @@ -27,6 +27,7 @@ const cnQuotaProbeConcurrency = 4 // 克隆自 AccountExpiryService 的 Start/Stop/runOnce + ticker 骨架。 // 余额探测仅覆盖有公开余额端点的 kimi / deepseek;智谱无余额端点,仅靠响应式 429/402。 // 额度探测覆盖 kimi / zhipu 的 coding plan 账号(deepseek 无 coding 套餐)。 +// Command Code 账号一次探测同时刷新窗口快照与积分余额,积分低于阈值时同样临时停调。 type CNProviderBalanceCheckService struct { accountRepo AccountRepository balanceService *CNProviderBalanceService @@ -154,7 +155,8 @@ func (s *CNProviderBalanceCheckService) runOnce() { } // 预算按工作量放大:4 并发 × 15s/批 + payg 每账号 5s,下限 30s 上限 300s。 - batches := (len(quotaTargets) + cnQuotaProbeConcurrency - 1) / cnQuotaProbeConcurrency + commandCodeTargets := s.collectCommandCodeTargets() + batches := (len(quotaTargets) + len(commandCodeTargets) + cnQuotaProbeConcurrency - 1) / cnQuotaProbeConcurrency timeout := 30*time.Second + time.Duration(batches)*15*time.Second + time.Duration(len(paygTargets))*5*time.Second if timeout > 300*time.Second { timeout = 300 * time.Second @@ -188,11 +190,76 @@ func (s *CNProviderBalanceCheckService) runOnce() { wg.Wait() } + if len(commandCodeTargets) > 0 { + sem := make(chan struct{}, cnQuotaProbeConcurrency) + var wg sync.WaitGroup + var mu sync.Mutex + for _, target := range commandCodeTargets { + wg.Add(1) + go func(account *Account) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + switch s.checkCommandCode(ctx, account, threshold) { + case cnBalancePaused: + mu.Lock() + paused++ + mu.Unlock() + case cnBalanceCleared: + mu.Lock() + cleared++ + mu.Unlock() + } + }(target) + } + wg.Wait() + } + if paused > 0 || cleared > 0 { log.Printf("[CNBalance] paused=%d cleared=%d (threshold=%.2f)", paused, cleared, threshold) } } +// collectCommandCodeTargets 选出官方主机上激活的 Command Code 账号:一次探测同时刷新 +// 窗口快照(阈值停调读取)与积分余额。 +func (s *CNProviderBalanceCheckService) collectCommandCodeTargets() []*Account { + if s.quotaService == nil { + return nil + } + accounts, err := s.accountRepo.ListByPlatform(context.Background(), PlatformCommandCode) + if err != nil { + log.Printf("[CNBalance] list %s accounts failed: %v", PlatformCommandCode, err) + return nil + } + targets := make([]*Account, 0, len(accounts)) + for i := range accounts { + account := &accounts[i] + if account.IsActive() && account.commandCodeUsageSupported() { + targets = append(targets, account) + } + } + return targets +} + +// checkCommandCode 探测 Command Code 窗口与积分;手动可调度的账号再按积分余额停调 / 恢复。 +func (s *CNProviderBalanceCheckService) checkCommandCode(ctx context.Context, account *Account, threshold float64) cnBalanceCheckOutcome { + result, err := s.quotaService.QueryUsage(ctx, account.ID) + if err != nil { + log.Printf("[CNBalance] command code usage account %d failed: %v", account.ID, err) + return cnBalanceNoChange + } + if result == nil || !result.Success || result.Balance == nil { + if result != nil && result.Error != "" { + log.Printf("[CNBalance] command code usage account %d error: %s", account.ID, result.Error) + } + return cnBalanceNoChange + } + if !account.Schedulable { + return cnBalanceNoChange + } + return s.applyBalanceResult(ctx, account, result.Balance, threshold) +} + // probeQuota 探测单个 coding plan 账号的滚动窗口用量并落 extra 快照。 // 不在此处做停调/恢复决策:调度阈值评估读取快照统一判定(含暂停账号的续停)。 func (s *CNProviderBalanceCheckService) probeQuota(ctx context.Context, accountID int64, platform string) { @@ -224,7 +291,11 @@ func (s *CNProviderBalanceCheckService) checkOne(ctx context.Context, account *A if err != nil || result == nil || !result.Success { return cnBalanceNoChange } + return s.applyBalanceResult(ctx, account, result, threshold) +} +// applyBalanceResult 按余额探测结果停调或恢复账号。 +func (s *CNProviderBalanceCheckService) applyBalanceResult(ctx context.Context, account *Account, result *CNProviderBalanceResult, threshold float64) cnBalanceCheckOutcome { // 双币种(deepseek CNY+USD)任一币种余额达标即可继续调度;仅当全部低于 // 阈值(或不可用)才停调。 low := !result.Available || allCNBalancesBelowThreshold(result, threshold) diff --git a/backend/internal/service/cn_provider_balance_service.go b/backend/internal/service/cn_provider_balance_service.go index c474683077..1bac2ef3d9 100644 --- a/backend/internal/service/cn_provider_balance_service.go +++ b/backend/internal/service/cn_provider_balance_service.go @@ -123,6 +123,9 @@ func (s *CNProviderBalanceService) QueryBalanceForAccount(ctx context.Context, a } func (s *CNProviderBalanceService) queryBalanceForAccount(ctx context.Context, account *Account) (*CNProviderBalanceResult, error) { + if account.IsCommandCode() { + return s.queryCommandCodeBalance(ctx, account) + } provider := account.Platform if provider != PlatformKimi && provider != PlatformDeepseek { return nil, infraerrors.New(http.StatusBadRequest, "CN_BALANCE_NO_ENDPOINT", "account provider has no balance endpoint") @@ -219,22 +222,7 @@ func (s *CNProviderBalanceService) queryBalanceForAccount(ctx context.Context, a result.Available = available result.Success = true - balanceUpdates := make([]any, 0, len(entries)) - for _, entry := range entries { - balanceUpdates = append(balanceUpdates, map[string]any{ - "currency": entry.Currency, - "balance": entry.Balance, - }) - } - updates := map[string]any{ - cnExtraKey(provider, cnBalanceExtraSuffixBalance): result.Balance, - cnExtraKey(provider, cnBalanceExtraSuffixCurrency): result.Currency, - cnExtraKey(provider, cnBalanceExtraSuffixAvailable): available, - cnExtraKey(provider, cnBalanceExtraSuffixUpdated): now.Format(time.RFC3339), - cnExtraKey(provider, cnBalanceExtraSuffixBalances): balanceUpdates, - // 余额探测成功即清除响应式 402/429 写下的 balance_low 标记。 - cnExtraKey(provider, cnBalanceExtraSuffixLow): false, - } + updates := cnBalanceExtraUpdates(provider, result, now) if err := s.accountRepo.UpdateExtra(ctx, account.ID, updates); err != nil { slog.Warn("cn_balance_persist_failed", "account_id", account.ID, "provider", provider, "error", err) } else { @@ -243,6 +231,26 @@ func (s *CNProviderBalanceService) queryBalanceForAccount(ctx context.Context, a return result, nil } +// cnBalanceExtraUpdates 构造 provider 维度的余额快照更新。 +func cnBalanceExtraUpdates(provider string, result *CNProviderBalanceResult, now time.Time) map[string]any { + balanceUpdates := make([]any, 0, len(result.Balances)) + for _, entry := range result.Balances { + balanceUpdates = append(balanceUpdates, map[string]any{ + "currency": entry.Currency, + "balance": entry.Balance, + }) + } + return map[string]any{ + cnExtraKey(provider, cnBalanceExtraSuffixBalance): result.Balance, + cnExtraKey(provider, cnBalanceExtraSuffixCurrency): result.Currency, + cnExtraKey(provider, cnBalanceExtraSuffixAvailable): result.Available, + cnExtraKey(provider, cnBalanceExtraSuffixUpdated): now.Format(time.RFC3339), + cnExtraKey(provider, cnBalanceExtraSuffixBalances): balanceUpdates, + // 余额探测成功即清除响应式 402/429 写下的 balance_low 标记。 + cnExtraKey(provider, cnBalanceExtraSuffixLow): false, + } +} + // loadPayGAccount 加载 payg 模式的国产供应商账号(余额仅对 payg 有意义;coding 走额度)。 func (s *CNProviderBalanceService) loadPayGAccount(ctx context.Context, accountID int64) (*Account, error) { account, err := s.accountRepo.GetByID(ctx, accountID) @@ -261,6 +269,13 @@ func validatePayGAccount(account *Account) error { if account == nil { return infraerrors.New(http.StatusNotFound, "CN_BALANCE_ACCOUNT_NOT_FOUND", "account not found") } + // Command Code 积分余额与套餐窗口同源,只查官方主机。 + if account.IsCommandCode() { + if !account.commandCodeUsageSupported() { + return infraerrors.New(http.StatusBadRequest, "CN_BALANCE_NO_ENDPOINT", "command code balance is only available for api key accounts on the official host") + } + return nil + } if !account.IsCNProvider() { return infraerrors.New(http.StatusBadRequest, "CN_BALANCE_INVALID_PLATFORM", "account is not a CN provider account") } diff --git a/backend/internal/service/cn_provider_quota_service.go b/backend/internal/service/cn_provider_quota_service.go index 48fe0a6803..38c290db16 100644 --- a/backend/internal/service/cn_provider_quota_service.go +++ b/backend/internal/service/cn_provider_quota_service.go @@ -62,9 +62,13 @@ type CNProviderQuotaProbeResult struct { FetchedAt int64 `json:"fetched_at"` Persisted bool `json:"persisted"` Error string `json:"error,omitempty"` + + // Balance 为同一次探测得到的余额(Command Code 积分与窗口同源),其余供应商为空。 + Balance *CNProviderBalanceResult `json:"balance,omitempty"` } -// CNProviderQuotaService 探测 Kimi / Zhipu Coding Plan 的滚动窗口用量。 +// CNProviderQuotaService 探测 Kimi / Zhipu / MiniMax Coding Plan、OpenCode Go 与 +// Command Code 的滚动窗口用量。 type CNProviderQuotaService struct { accountRepo AccountRepository proxyRepo ProxyRepository @@ -131,6 +135,9 @@ func (s *CNProviderQuotaService) QueryUsageForAccount(ctx context.Context, accou func (s *CNProviderQuotaService) queryUsageForAccount(ctx context.Context, account *Account) (*CNProviderQuotaProbeResult, error) { provider := account.GetCodingPlanProvider() + if provider == PlatformCommandCode { + return s.queryCommandCodeUsage(ctx, account) + } if provider != PlatformKimi && provider != PlatformZhipu && provider != PlatformMiniMax && provider != PlatformOpenCodeGo { return nil, infraerrors.New(http.StatusBadRequest, "CN_QUOTA_NOT_CODING_PLAN", "account is not a kimi/zhipu/minimax coding plan or opencode go account") } @@ -290,6 +297,12 @@ func validateCodingPlanAccount(account *Account) error { if account.IsOpenCodeGoPlan() { return nil } + if account.IsCommandCode() { + if !account.commandCodeUsageSupported() { + return infraerrors.New(http.StatusBadRequest, "CN_QUOTA_NOT_CODING_PLAN", "command code usage is only available for api key accounts on the official host") + } + return nil + } if account.IsOpenCodeGo() { return infraerrors.New(http.StatusBadRequest, "CN_QUOTA_NOT_CODING_PLAN", "opencode zen accounts have no subscription quota window") } diff --git a/backend/internal/service/command_code.go b/backend/internal/service/command_code.go new file mode 100644 index 0000000000..41b9d49cf3 --- /dev/null +++ b/backend/internal/service/command_code.go @@ -0,0 +1,27 @@ +package service + +// Command Code Provider API(https://commandcode.ai/docs/provider):同一 API Key 下 +// 按模型分流到三种原生端点,基址 https://api.commandcode.ai/provider/v1: +// - Claude 系模型只在 /messages(Anthropic Messages)提供,发往其他端点返回 400; +// - OpenAI 与开源模型提供 /chat/completions,多数也提供 /responses。 +// 每个模型支持的端点由 /models 的 supported_endpoints 给出(公开接口),网关据此在 +// 模型支持入站协议时同协议直通(见 model_protocol_catalog.go);目录就绪前使用下面的 +// 内置规则。模型名可带厂商前缀(如 deepseek/deepseek-v4-flash),原样发往上游。 + +// DefaultCommandCodeTestModel 是管理端连接测试未指定模型时使用的模型(Chat Completions)。 +const DefaultCommandCodeTestModel = "deepseek/deepseek-v4-flash" + +// DefaultCommandCodeProtocolRules 是 Command Code 的内置分流表:Claude 只走 Anthropic; +// GPT 同时支持 Responses 与 Chat Completions(入站同协议直通,其余入站首选 Responses); +// 其余模型只保证 Chat Completions,是否支持 Responses 以上游模型目录为准。 +func DefaultCommandCodeProtocolRules() []ProtocolRule { + return []ProtocolRule{ + {Pattern: "claude-*", Protocol: APIProtocolAnthropic}, + {Pattern: "gpt-*", Protocol: APIProtocolResponses, Protocols: []string{APIProtocolResponses, APIProtocolChatCompletions}}, + } +} + +// IsCommandCode 报告账号是否为 Command Code 平台账号。 +func (a *Account) IsCommandCode() bool { + return a != nil && a.Platform == PlatformCommandCode +} diff --git a/backend/internal/service/command_code_test.go b/backend/internal/service/command_code_test.go new file mode 100644 index 0000000000..fc198b8ed9 --- /dev/null +++ b/backend/internal/service/command_code_test.go @@ -0,0 +1,176 @@ +//go:build unit + +package service + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// commandCodeTestAccount 只带 api_key:端点与分流规则全部来自 provider profile。 +func commandCodeTestAccount(id int64) *Account { + return &Account{ + ID: id, + Name: "cc", + Platform: PlatformCommandCode, + Type: AccountTypeAPIKey, + Status: StatusActive, + Concurrency: 1, + Credentials: map[string]any{"api_key": "user_test_key"}, + } +} + +func TestCommandCodeProfileRoutesByModel(t *testing.T) { + account := commandCodeTestAccount(1) + require.True(t, account.IsMultiProtocolAPIKey()) + require.True(t, account.routesByModel()) + require.False(t, account.RoutesProtocolByInbound()) + require.Equal(t, APIProtocolAdaptive, account.GetAPIProtocol()) + require.Equal(t, DefaultCommandCodeBaseURL, account.GetOpenAIBaseURL()) + require.Equal(t, DefaultCommandCodeAnthropicBaseURL, account.GetCNProtocolBaseURL(APIProtocolAnthropic)) + require.Equal(t, DefaultCommandCodeBaseURL, account.GetCNProtocolBaseURL(APIProtocolResponses)) + + // 未取到模型目录时按内置规则:Claude 只走 Anthropic;GPT 支持 Responses 与 Chat + // Completions(同协议直通,Anthropic 入站走首选 Responses);其余只保证 Chat Completions。 + for _, inbound := range []string{APIProtocolChatCompletions, APIProtocolResponses, APIProtocolAnthropic} { + require.Equal(t, APIProtocolAnthropic, resolveUpstreamProtocol(account, inbound, "claude-sonnet-4-6", nil), inbound) + require.Equal(t, APIProtocolChatCompletions, resolveUpstreamProtocol(account, inbound, "deepseek/deepseek-v4-flash", nil), inbound) + } + require.Equal(t, APIProtocolResponses, resolveUpstreamProtocol(account, APIProtocolResponses, "gpt-5.5", nil)) + require.Equal(t, APIProtocolChatCompletions, resolveUpstreamProtocol(account, APIProtocolChatCompletions, "gpt-5.5", nil)) + require.Equal(t, APIProtocolResponses, resolveUpstreamProtocol(account, APIProtocolAnthropic, "gpt-5.5", nil)) + + // 显式协议优先于内置规则。 + account.Credentials["api_protocol"] = APIProtocolChatCompletions + require.Equal(t, APIProtocolChatCompletions, resolveUpstreamProtocol(account, APIProtocolAnthropic, "claude-sonnet-4-6", nil)) +} + +func TestCommandCodeNativeAnthropicTargetURL(t *testing.T) { + svc := &OpenAIGatewayService{} + targetURL, err := svc.nativeAnthropicTargetURL(commandCodeTestAccount(2)) + require.NoError(t, err) + require.Equal(t, "https://api.commandcode.ai/provider/v1/messages", targetURL) +} + +func TestAccountTestService_CommandCodeClaudeUsesAnthropicMessages(t *testing.T) { + account := commandCodeTestAccount(501) + svc, upstream := adaptiveCNAccountTestService(account, adaptiveCNAnthropicTestResponse()) + c, recorder := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "claude-sonnet-4-6", "hi", AccountTestModeDefault) + + require.NoError(t, err) + require.Len(t, upstream.requests, 1) + require.Equal(t, "https://api.commandcode.ai/provider/v1/messages", upstream.requests[0].URL.String()) + require.Equal(t, "user_test_key", upstream.requests[0].Header.Get("x-api-key")) + require.Contains(t, recorder.Body.String(), `"type":"test_complete"`) +} + +// 连接测试与转发共用端点拼接:按模型分流的平台,Anthropic 基址带 /v1 也合法。 +func TestAccountTestService_CommandCodeAnthropicBaseWithVersion(t *testing.T) { + account := commandCodeTestAccount(504) + account.Credentials["api_base_urls"] = map[string]any{APIProtocolAnthropic: "https://api.commandcode.ai/provider/v1"} + svc, upstream := adaptiveCNAccountTestService(account, adaptiveCNAnthropicTestResponse()) + c, recorder := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "claude-sonnet-4-6", "hi", AccountTestModeDefault) + + require.NoError(t, err) + require.Len(t, upstream.requests, 1) + require.Equal(t, "https://api.commandcode.ai/provider/v1/messages", upstream.requests[0].URL.String()) + require.Contains(t, recorder.Body.String(), `"type":"test_complete"`) + + target, err := (&OpenAIGatewayService{cfg: rawChatCompletionsTestConfig()}).nativeAnthropicTargetURL(account) + require.NoError(t, err) + require.Equal(t, upstream.requests[0].URL.String(), target) +} + +// 连接测试与网关同一判定:模型目录决定协议时,测试也按目录选择端点。 +func TestAccountTestService_CommandCodeUsesModelCatalog(t *testing.T) { + base := fmt.Sprintf("http://cc-test-%d.example/provider/v1", time.Now().UnixNano()) + account := commandCodeTestAccount(505) + account.Credentials["api_base_urls"] = map[string]any{ + APIProtocolChatCompletions: base, + APIProtocolResponses: base, + } + url := buildOpenAIModelsURL(base) + upstreamModelProtocols.store(modelProtocolCatalogKey(account, base, url), + map[string][]string{"vendor/responses-only": {APIProtocolResponses}}, nil, time.Now()) + svc, upstream := adaptiveCNAccountTestService(account, adaptiveCNResponsesTestResponse()) + svc.openaiGatewayService = &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + c, recorder := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "vendor/responses-only", "hi", AccountTestModeDefault) + + require.NoError(t, err) + require.Len(t, upstream.requests, 1) + require.Equal(t, base+"/responses", upstream.requests[0].URL.String()) + require.Contains(t, recorder.Body.String(), `"type":"test_complete"`) +} + +func TestAccountTestService_CommandCodeGPTUsesResponses(t *testing.T) { + account := commandCodeTestAccount(502) + svc, upstream := adaptiveCNAccountTestService(account, adaptiveCNResponsesTestResponse()) + c, recorder := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "gpt-5.5", "hi", AccountTestModeDefault) + + require.NoError(t, err) + require.Len(t, upstream.requests, 1) + require.Equal(t, "https://api.commandcode.ai/provider/v1/responses", upstream.requests[0].URL.String()) + require.Equal(t, "Bearer user_test_key", upstream.requests[0].Header.Get("Authorization")) + require.Contains(t, recorder.Body.String(), `"type":"test_complete"`) +} + +func TestAccountTestService_CommandCodeEmptyModelUsesProfileDefault(t *testing.T) { + account := commandCodeTestAccount(503) + svc, upstream := adaptiveCNAccountTestService(account, adaptiveCNChatTestResponse()) + c, recorder := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "", "hi", AccountTestModeDefault) + + require.NoError(t, err) + require.Len(t, upstream.requests, 1) + require.Equal(t, "https://api.commandcode.ai/provider/v1/chat/completions", upstream.requests[0].URL.String()) + require.Equal(t, DefaultCommandCodeTestModel, gjson.GetBytes(upstream.lastBody, "model").String()) + require.Contains(t, recorder.Body.String(), `"type":"test_complete"`) +} + +// 仅在 profile 登记、按入站协议分流的新供应商:adaptive 测试走原生端点探测, +// 跳过 profile 未提供的 Anthropic 端点,不再落入 Claude 测试路径。 +func TestAccountTestService_ByInboundProviderSkipsMissingNativeEndpoints(t *testing.T) { + const platform = "test_inbound_only_chat" + providerProfiles[platform] = &ProviderProfile{ + Platform: platform, DefaultMode: AccountModePayG, Routing: ProviderRoutingByInbound, + DefaultTestModel: "inbound-test-model", + Modes: map[string]ProviderEndpoints{AccountModePayG: {BaseURLs: map[string]string{ + APIProtocolChatCompletions: "https://inbound.example.com/v1", + }}}, + } + t.Cleanup(func() { delete(providerProfiles, platform) }) + + account := &Account{ + ID: 601, Name: "inbound", Platform: platform, Type: AccountTypeAPIKey, Status: StatusActive, Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-inbound", "api_protocol": APIProtocolAdaptive}, + } + svc, upstream := adaptiveCNAccountTestService(account, adaptiveCNChatTestResponse()) + c, recorder := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "", "hi", AccountTestModeDefault) + + require.NoError(t, err) + require.Len(t, upstream.requests, 1) + require.Equal(t, "https://inbound.example.com/v1/chat/completions", upstream.requests[0].URL.String()) + require.Equal(t, "inbound-test-model", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Contains(t, recorder.Body.String(), `"type":"test_complete"`) +} + +// 官方主机不做上游计费探测:中转站的计费接口在官方 API 上不存在,只会把 Key 发往官方主机。 +func TestCommandCodeOfficialHostSkipsUpstreamBillingProbe(t *testing.T) { + require.True(t, upstreamBillingProbeTargetIsOfficialAPI(DefaultCommandCodeBaseURL)) + require.True(t, upstreamBillingProbeTargetIsOfficialAPI(DefaultCommandCodeAnthropicBaseURL)) +} diff --git a/backend/internal/service/command_code_usage.go b/backend/internal/service/command_code_usage.go new file mode 100644 index 0000000000..4781b4c903 --- /dev/null +++ b/backend/internal/service/command_code_usage.go @@ -0,0 +1,511 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "math" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +// Command Code 的积分与用量窗口来自 /alpha 下四个只读接口(官方 CLI /usage 同源, +// 社区插件实测复核),全部使用账号 API Key(Bearer): +// +// GET /alpha/whoami -> {user, org};个人账户 org 为 null +// GET /alpha/billing/credits[?orgId=] -> {credits:{monthlyCredits, purchasedCredits, freeCredits}, +// windowLimits:{limited, fiveHour, weekly}} +// GET /alpha/billing/subscriptions[?orgId=] -> {data:{planId, currentPeriodStart, currentPeriodEnd}} +// GET /alpha/usage/summary[?orgId=][&since=] -> {totalCost, totalMonthlyCredits} +// +// 积分单位为美元。windowLimits 与 credits 平级;fiveHour / weekly 的 used、cap 为积分, +// resetAt 为 epoch 毫秒;limited=false(按量计费)时没有滚动窗口。月度窗口上游只给 +// 剩余量(credits.monthlyCredits),上限按「本周期已用 + 剩余」还原。个人账户不能带 +// orgId 参数(空值返回 400),只有组织账户才带。 +// +// 订阅套餐的滚动窗口只限制套餐内积分:有充值积分时请求跳过窗口检查,此时窗口快照 +// 不参与阈值停调与 429 冷却(见 commandCodeWindowsBinding),已有的窗口冷却在探测到 +// 充值积分后解除(见 clearCommandCodeWindowCooldown)。 + +const ( + commandCodeAPIBase = "https://api.commandcode.ai" + + // commandCodeExtraSuffixPurchasedCredits 记录充值积分,供阈值停调与 429 冷却判断 + // 滚动窗口是否生效。 + commandCodeExtraSuffixPurchasedCredits = "purchased_credits" +) + +var errCommandCodeInvalidResponse = errors.New("invalid command code usage response") + +// commandCodePlanNames 与官方 CLI 的 planId 展示名一致;未收录的原样展示。 +var commandCodePlanNames = map[string]string{ + "individual-go": "Go", + "individual-goat": "GOAT", + "individual-pro": "Pro", + "individual-pro-v1": "Pro", + "individual-provider": "Provider", + "individual-max": "Max", + "individual-ultra": "Ultra", + "teams-pro": "Teams Pro", +} + +// commandCodeUsageSupported 报告账号能否查询 Command Code 用量:API Key 账号且推理 +// 端点指向官方主机。自定义中转的 Key 不发往官方用量接口。 +func (a *Account) commandCodeUsageSupported() bool { + return a.IsCommandCode() && a.Type == AccountTypeAPIKey && isOfficialCommandCodeHost(a.GetOpenAIBaseURL()) +} + +// commandCodeNumber 兼容数字与数字字符串。 +type commandCodeNumber float64 + +func (n *commandCodeNumber) UnmarshalJSON(data []byte) error { + data = bytes.TrimSpace(data) + if len(data) == 0 || bytes.Equal(data, []byte("null")) { + *n = 0 + return nil + } + if data[0] == '"' { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + v, err := strconv.ParseFloat(strings.TrimSpace(s), 64) + if err != nil { + return err + } + *n = commandCodeNumber(v) + return nil + } + var v float64 + if err := json.Unmarshal(data, &v); err != nil { + return err + } + *n = commandCodeNumber(v) + return nil +} + +type commandCodeWindow struct { + Used commandCodeNumber `json:"used"` + Cap commandCodeNumber `json:"cap"` + ResetAt commandCodeNumber `json:"resetAt"` +} + +type commandCodeCredits struct { + MonthlyCredits commandCodeNumber `json:"monthlyCredits"` + PurchasedCredits commandCodeNumber `json:"purchasedCredits"` + FreeCredits commandCodeNumber `json:"freeCredits"` +} + +type commandCodeCreditsPayload struct { + Credits *commandCodeCredits `json:"credits"` + WindowLimits struct { + Limited bool `json:"limited"` + FiveHour *commandCodeWindow `json:"fiveHour"` + Weekly *commandCodeWindow `json:"weekly"` + } `json:"windowLimits"` +} + +type commandCodeSubscriptionPayload struct { + Data struct { + PlanID string `json:"planId"` + CurrentPeriodStart string `json:"currentPeriodStart"` + CurrentPeriodEnd string `json:"currentPeriodEnd"` + } `json:"data"` +} + +type commandCodeSummaryPayload struct { + TotalCost *commandCodeNumber `json:"totalCost"` + TotalMonthlyCredits *commandCodeNumber `json:"totalMonthlyCredits"` +} + +// commandCodeUsage 是一次取数的汇总;subscription / summary 取数失败时为 nil,并置 +// periodFetchFailed:此时无从判断月度窗口是否存在,不能当作没有月度窗口。 +type commandCodeUsage struct { + credits commandCodeCreditsPayload + subscription *commandCodeSubscriptionPayload + summary *commandCodeSummaryPayload + periodFetchFailed bool +} + +// commandCodeHTTPError 是 /alpha 接口的非 2xx 响应。 +type commandCodeHTTPError struct { + status int + body string +} + +func (e *commandCodeHTTPError) Error() string { + return fmt.Sprintf("HTTP %d: %s", e.status, e.body) +} + +func (e *commandCodeHTTPError) unauthorized() bool { + return e.status == http.StatusUnauthorized || e.status == http.StatusForbidden +} + +func isCommandCodeAuthError(err error) bool { + var httpErr *commandCodeHTTPError + return errors.As(err, &httpErr) && httpErr.unauthorized() +} + +type commandCodeUsageClient struct { + cfg *config.Config + httpUpstream HTTPUpstream + account *Account + apiKey string + proxyURL string +} + +// get 请求一个 /alpha 接口并解码 JSON。 +func (c *commandCodeUsageClient) get(ctx context.Context, path string, query url.Values, out any) error { + target := commandCodeAPIBase + path + if len(query) > 0 { + target += "?" + query.Encode() + } + validatedURL, err := cnValidateProbeURL(c.cfg, target) + if err != nil { + return infraerrors.New(http.StatusForbidden, "CN_QUOTA_URL_REJECTED", err.Error()) + } + callCtx, cancel := context.WithTimeout(ctx, cnQuotaUpstreamTimeout) + defer cancel() + req, err := http.NewRequestWithContext(callCtx, http.MethodGet, validatedURL, nil) + if err != nil { + return infraerrors.Newf(http.StatusInternalServerError, "CN_QUOTA_REQUEST_BUILD_FAILED", "build request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Accept", "application/json") + c.account.ApplyHeaderOverrides(req.Header) + + resp, err := c.httpUpstream.Do(req, c.proxyURL, c.account.ID, maxInt(c.account.Concurrency, 1)) + if err != nil { + return fmt.Errorf("upstream request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + body, _ := io.ReadAll(io.LimitReader(resp.Body, cnQuotaMaxBodyBytes)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return &commandCodeHTTPError{status: resp.StatusCode, body: truncate(strings.TrimSpace(string(body)), 240)} + } + if err := json.Unmarshal(body, out); err != nil { + return fmt.Errorf("%w: %s: %v", errCommandCodeInvalidResponse, path, err) + } + return nil +} + +// fetch 取积分与窗口;withPeriod 时再取订阅周期与本周期用量以还原月度窗口。 +// whoami 用来取 orgId,任何失败都整体返回:无从判断是否组织账户时,按个人账户查会把 +// 组织账号查成成员的个人钱包,并据此按余额停调。 +func (c *commandCodeUsageClient) fetch(ctx context.Context, withPeriod bool) (*commandCodeUsage, error) { + var whoami struct { + Org *struct { + ID string `json:"id"` + } `json:"org"` + } + query := url.Values{} + if err := c.get(ctx, "/alpha/whoami", nil, &whoami); err != nil { + return nil, err + } + if whoami.Org != nil && strings.TrimSpace(whoami.Org.ID) != "" { + query.Set("orgId", strings.TrimSpace(whoami.Org.ID)) + } + + usage := &commandCodeUsage{} + if err := c.get(ctx, "/alpha/billing/credits", query, &usage.credits); err != nil { + return nil, err + } + if usage.credits.Credits == nil { + return nil, fmt.Errorf("%w: credits missing", errCommandCodeInvalidResponse) + } + if !withPeriod { + return usage, nil + } + + var subscription commandCodeSubscriptionPayload + if err := c.get(ctx, "/alpha/billing/subscriptions", query, &subscription); err != nil { + if isCommandCodeAuthError(err) { + return nil, err + } + slog.Warn("command_code_subscription_failed", "account_id", c.account.ID, "error", err) + usage.periodFetchFailed = true + return usage, nil + } + usage.subscription = &subscription + periodStart := strings.TrimSpace(subscription.Data.CurrentPeriodStart) + if periodStart == "" { + return usage, nil + } + summaryQuery := url.Values{} + for key, values := range query { + summaryQuery[key] = values + } + summaryQuery.Set("since", periodStart) + var summary commandCodeSummaryPayload + if err := c.get(ctx, "/alpha/usage/summary", summaryQuery, &summary); err != nil { + if isCommandCodeAuthError(err) { + return nil, err + } + slog.Warn("command_code_usage_summary_failed", "account_id", c.account.ID, "error", err) + usage.periodFetchFailed = true + return usage, nil + } + usage.summary = &summary + return usage, nil +} + +func isApplicationError(err error) bool { + var appErr *infraerrors.ApplicationError + return errors.As(err, &appErr) +} + +func commandCodePercent(used, limit float64) float64 { + percent := used / limit * 100 + if percent < 0 { + percent = 0 + } + return math.Round(percent*100) / 100 +} + +// tiers 返回 5h / weekly(限额套餐)与月度窗口。 +func (u *commandCodeUsage) tiers() []CNQuotaTier { + var tiers []CNQuotaTier + limits := u.credits.WindowLimits + if limits.Limited { + for _, item := range []struct { + window string + limit *commandCodeWindow + }{ + {window: "5h", limit: limits.FiveHour}, + {window: "weekly", limit: limits.Weekly}, + } { + if item.limit == nil || item.limit.Cap <= 0 { + continue + } + tiers = append(tiers, CNQuotaTier{ + Window: item.window, + UsedPercent: commandCodePercent(float64(item.limit.Used), float64(item.limit.Cap)), + ResetAt: cnMillisToRFC3339(int64(item.limit.ResetAt)), + }) + } + } + if u.summary != nil && u.subscription != nil { + used := 0.0 + switch { + case u.summary.TotalMonthlyCredits != nil: + used = float64(*u.summary.TotalMonthlyCredits) + case u.summary.TotalCost != nil: + used = float64(*u.summary.TotalCost) + } + if limit := used + float64(u.credits.Credits.MonthlyCredits); limit > 0 { + tiers = append(tiers, CNQuotaTier{ + Window: "monthly", + UsedPercent: commandCodePercent(used, limit), + ResetAt: cnNormalizeResetTime(u.subscription.Data.CurrentPeriodEnd), + }) + } + } + return tiers +} + +func (u *commandCodeUsage) planName() string { + if u.subscription == nil { + return "" + } + planID := strings.TrimSpace(u.subscription.Data.PlanID) + if name, ok := commandCodePlanNames[planID]; ok { + return name + } + return planID +} + +// balance 返回可用积分合计(套餐剩余 + 充值 + 赠送,美元)。 +func (u *commandCodeUsage) balance(now time.Time) *CNProviderBalanceResult { + credits := u.credits.Credits + total := float64(credits.MonthlyCredits + credits.PurchasedCredits + credits.FreeCredits) + total = math.Round(total*10000) / 10000 + return &CNProviderBalanceResult{ + Provider: PlatformCommandCode, + Success: true, + Balance: total, + Currency: "USD", + Balances: []CNProviderBalanceEntry{{Currency: "USD", Balance: total}}, + Available: true, + StatusCode: http.StatusOK, + FetchedAt: now.Unix(), + } +} + +// clearCommandCodeWindowCooldown 在探测到充值积分时解除窗口用满的临时停调:充值积分 +// 不受滚动窗口限制,上游已允许继续请求。只清除 commandCodeUsageLimitReason 前缀的停调, +// 积分不足、花费上限、管理员规则等其他原因的停调不受影响。 +func clearCommandCodeWindowCooldown(ctx context.Context, repo AccountRepository, account *Account, usage *commandCodeUsage, now time.Time) bool { + if usage == nil || usage.credits.Credits == nil || usage.credits.Credits.PurchasedCredits <= 0 { + return false + } + if account.TempUnschedulableUntil == nil || !now.Before(*account.TempUnschedulableUntil) || + !strings.HasPrefix(account.TempUnschedulableReason, commandCodeUsageLimitReason) { + return false + } + if err := repo.ClearTempUnschedulable(ctx, account.ID); err != nil { + slog.Warn("command_code_usage_limit_clear_failed", "account_id", account.ID, "error", err) + return false + } + slog.Info("command_code_usage_limit_cleared", "account_id", account.ID, + "purchased_credits", float64(usage.credits.Credits.PurchasedCredits)) + return true +} + +// commandCodeWindowsBinding 报告账号快照中的滚动 / 月度窗口是否限制调度: +// 有充值积分时上游跳过窗口检查,窗口用满也不会被拒绝。 +func commandCodeWindowsBinding(extra map[string]any) bool { + purchased, ok := cnParseF64(extra[cnExtraKey(PlatformCommandCode, commandCodeExtraSuffixPurchasedCredits)]) + return !ok || purchased <= 0 +} + +// commandCodeUsageExtraUpdates 构造窗口 + 余额快照。缺席的窗口写 nil,避免套餐 +// 变化后沿用旧快照;窗口尚未开始时没有重置时间,同样清掉上一个窗口的值;月度窗口 +// 取数失败时保留旧快照;传 nil tiers 时只写余额。 +func commandCodeUsageExtraUpdates(usage *commandCodeUsage, tiers []CNQuotaTier, balance *CNProviderBalanceResult, now time.Time, withTiers bool) map[string]any { + updates := cnBalanceExtraUpdates(PlatformCommandCode, balance, now) + updates[cnExtraKey(PlatformCommandCode, commandCodeExtraSuffixPurchasedCredits)] = float64(usage.credits.Credits.PurchasedCredits) + if !withTiers { + return updates + } + for key, value := range cnQuotaExtraUpdates(PlatformCommandCode, tiers, now) { + updates[key] = value + } + present := make(map[string]CNQuotaTier, len(tiers)) + for _, tier := range tiers { + present[tier.Window] = tier + } + for _, window := range []struct { + name, used, reset string + }{ + {"5h", cnExtraSuffix5hUsed, cnExtraSuffix5hReset}, + {"weekly", cnExtraSuffixWeeklyUsed, cnExtraSuffixWeeklyReset}, + {"monthly", cnExtraSuffixMonthlyUsed, cnExtraSuffixMonthlyReset}, + } { + tier, ok := present[window.name] + if !ok && window.name == "monthly" && usage.periodFetchFailed { + continue + } + if !ok { + updates[cnExtraKey(PlatformCommandCode, window.used)] = nil + } + if tier.ResetAt == "" { + updates[cnExtraKey(PlatformCommandCode, window.reset)] = nil + } + } + return updates +} + +func newCommandCodeUsageClient(cfg *config.Config, httpUpstream HTTPUpstream, account *Account, proxyURL string) *commandCodeUsageClient { + return &commandCodeUsageClient{ + cfg: cfg, + httpUpstream: httpUpstream, + account: account, + apiKey: strings.TrimSpace(account.GetCNAPIKey()), + proxyURL: proxyURL, + } +} + +// commandCodeFetchFailure 把取数错误转换为探测失败文案;ok=false 表示应作为错误返回。 +func commandCodeFetchFailure(err error) (status int, message string, ok bool) { + var httpErr *commandCodeHTTPError + switch { + case errors.As(err, &httpErr) && httpErr.unauthorized(): + return httpErr.status, fmt.Sprintf("Authentication failed (HTTP %d)", httpErr.status), true + case errors.As(err, &httpErr): + return httpErr.status, fmt.Sprintf("API error (HTTP %d): %s", httpErr.status, httpErr.body), true + case errors.Is(err, errCommandCodeInvalidResponse): + return http.StatusOK, "Invalid usage response", true + default: + return 0, "", false + } +} + +// queryCommandCodeUsage 查询 Command Code 积分与窗口,落窗口与余额快照。 +func (s *CNProviderQuotaService) queryCommandCodeUsage(ctx context.Context, account *Account) (*CNProviderQuotaProbeResult, error) { + client := newCommandCodeUsageClient(s.cfg, s.httpUpstream, account, s.resolveProxyURL(ctx, account)) + if client.apiKey == "" { + return nil, infraerrors.New(http.StatusBadRequest, "CN_QUOTA_NO_APIKEY", "account api_key is empty") + } + now := time.Now().UTC() + result := &CNProviderQuotaProbeResult{ + Provider: PlatformCommandCode, + Source: "command_code_usage", + FetchedAt: now.Unix(), + } + usage, err := client.fetch(ctx, true) + if err != nil { + status, message, ok := commandCodeFetchFailure(err) + if !ok { + if isApplicationError(err) { + return nil, err + } + return nil, infraerrors.Newf(http.StatusBadGateway, "CN_QUOTA_REQUEST_FAILED", "%v", err) + } + result.StatusCode = status + result.Error = message + return result, nil + } + + tiers := usage.tiers() + balance := usage.balance(now) + result.StatusCode = http.StatusOK + result.Tiers = tiers + result.PlanLevel = usage.planName() + result.Balance = balance + result.Success = true + result.CredentialValid = true + + updates := commandCodeUsageExtraUpdates(usage, tiers, balance, now, true) + if err := s.accountRepo.UpdateExtra(ctx, account.ID, updates); err != nil { + slog.Warn("cn_quota_persist_failed", "account_id", account.ID, "provider", PlatformCommandCode, "error", err) + } else { + result.Persisted = true + balance.Persisted = true + } + clearCommandCodeWindowCooldown(ctx, s.accountRepo, account, usage, now) + return result, nil +} + +// queryCommandCodeBalance 只查积分余额(whoami + credits),落余额快照。 +func (s *CNProviderBalanceService) queryCommandCodeBalance(ctx context.Context, account *Account) (*CNProviderBalanceResult, error) { + client := newCommandCodeUsageClient(s.cfg, s.httpUpstream, account, s.resolveProxyURL(ctx, account)) + if client.apiKey == "" { + return nil, infraerrors.New(http.StatusBadRequest, "CN_BALANCE_NO_APIKEY", "account api_key is empty") + } + now := time.Now().UTC() + usage, err := client.fetch(ctx, false) + if err != nil { + status, message, ok := commandCodeFetchFailure(err) + if !ok { + if isApplicationError(err) { + return nil, err + } + return nil, infraerrors.Newf(http.StatusBadGateway, "CN_BALANCE_REQUEST_FAILED", "%v", err) + } + return &CNProviderBalanceResult{ + Provider: PlatformCommandCode, + StatusCode: status, + FetchedAt: now.Unix(), + Available: true, + Error: message, + }, nil + } + result := usage.balance(now) + if err := s.accountRepo.UpdateExtra(ctx, account.ID, commandCodeUsageExtraUpdates(usage, nil, result, now, false)); err != nil { + slog.Warn("cn_balance_persist_failed", "account_id", account.ID, "provider", PlatformCommandCode, "error", err) + } else { + result.Persisted = true + } + clearCommandCodeWindowCooldown(ctx, s.accountRepo, account, usage, now) + return result, nil +} diff --git a/backend/internal/service/command_code_usage_test.go b/backend/internal/service/command_code_usage_test.go new file mode 100644 index 0000000000..dc76c27a99 --- /dev/null +++ b/backend/internal/service/command_code_usage_test.go @@ -0,0 +1,691 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/stretchr/testify/require" +) + +// commandCodeAlphaUpstream 按路径返回 /alpha 接口响应,并记录请求。 +type commandCodeAlphaUpstream struct { + mu sync.Mutex + responses map[string]commandCodeAlphaResponse + requests []*http.Request +} + +type commandCodeAlphaResponse struct { + status int + body string +} + +func (u *commandCodeAlphaUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + u.mu.Lock() + u.requests = append(u.requests, req) + u.mu.Unlock() + resp, ok := u.responses[req.URL.Path] + if !ok { + resp = commandCodeAlphaResponse{status: http.StatusNotFound, body: `{"error":"not found"}`} + } + return &http.Response{ + StatusCode: resp.status, + Body: io.NopCloser(strings.NewReader(resp.body)), + Header: make(http.Header), + Request: req, + }, nil +} + +func (u *commandCodeAlphaUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, concurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) { + return u.Do(req, proxyURL, accountID, concurrency) +} + +func (u *commandCodeAlphaUpstream) request(path string) *http.Request { + for _, req := range u.requests { + if req.URL.Path == path { + return req + } + } + return nil +} + +const ( + commandCodeTestCredits = `{ + "credits": {"belowThreshold": false, "monthlyCredits": "34.9", "purchasedCredits": 0, "freeCredits": 0.1}, + "windowLimits": { + "limited": true, + "fiveHour": {"used": 1.5, "cap": 14, "resetAt": 1790000000000}, + "weekly": {"used": 35, "cap": 35, "resetAt": 1790400000000} + } + }` + commandCodeTestSubscription = `{"data": {"planId": "individual-goat", "status": "active", + "currentPeriodStart": "2026-09-01T00:00:00Z", "currentPeriodEnd": "2026-10-01T00:00:00Z"}}` + commandCodeTestSummary = `{"totalCost": 40, "totalMonthlyCredits": 35.1}` +) + +func commandCodeUsageAccount() *Account { + account := commandCodeTestAccount(77) + account.Concurrency = 1 + return account +} + +func newCommandCodeAlphaUpstream(whoami string) *commandCodeAlphaUpstream { + return &commandCodeAlphaUpstream{responses: map[string]commandCodeAlphaResponse{ + "/alpha/whoami": {status: http.StatusOK, body: whoami}, + "/alpha/billing/credits": {status: http.StatusOK, body: commandCodeTestCredits}, + "/alpha/billing/subscriptions": {status: http.StatusOK, body: commandCodeTestSubscription}, + "/alpha/usage/summary": {status: http.StatusOK, body: commandCodeTestSummary}, + }} +} + +func TestCommandCodeQuotaProbeParsesWindowsAndBalance(t *testing.T) { + account := commandCodeUsageAccount() + upstream := newCommandCodeAlphaUpstream(`{"user": {"userName": "alice"}, "org": null}`) + repo := &cnBalanceProbeRepo{account: account} + svc := NewCNProviderQuotaService(repo, nil, upstream, &config.Config{}) + + result, err := svc.QueryUsage(context.Background(), account.ID) + + require.NoError(t, err) + require.True(t, result.Success) + require.True(t, result.Persisted) + require.Equal(t, PlatformCommandCode, result.Provider) + require.Equal(t, "GOAT", result.PlanLevel) + require.Equal(t, []CNQuotaTier{ + {Window: "5h", UsedPercent: 10.71, ResetAt: time.UnixMilli(1790000000000).UTC().Format(time.RFC3339)}, + {Window: "weekly", UsedPercent: 100, ResetAt: time.UnixMilli(1790400000000).UTC().Format(time.RFC3339)}, + {Window: "monthly", UsedPercent: 50.14, ResetAt: "2026-10-01T00:00:00Z"}, + }, result.Tiers) + require.NotNil(t, result.Balance) + require.InDelta(t, 35.0, result.Balance.Balance, 1e-9) + require.Equal(t, "USD", result.Balance.Currency) + + // 个人账户不带 orgId;本周期用量从订阅周期起点统计。 + for _, req := range upstream.requests { + require.Equal(t, "Bearer user_test_key", req.Header.Get("Authorization")) + require.Equal(t, "api.commandcode.ai", req.URL.Host) + require.False(t, req.URL.Query().Has("orgId"), req.URL.String()) + } + require.Equal(t, "2026-09-01T00:00:00Z", upstream.request("/alpha/usage/summary").URL.Query().Get("since")) + + require.Len(t, repo.extraWrites, 1) + extra := repo.extraWrites[0] + require.Equal(t, 10.71, extra["command_code_5h_used_percent"]) + require.Equal(t, 100.0, extra["command_code_weekly_used_percent"]) + require.Equal(t, 50.14, extra["command_code_monthly_used_percent"]) + require.Equal(t, "2026-10-01T00:00:00Z", extra["command_code_monthly_reset_at"]) + require.InDelta(t, 35.0, extra["command_code_balance"], 1e-9) + require.Equal(t, "USD", extra["command_code_balance_currency"]) + require.Equal(t, 0.0, extra["command_code_purchased_credits"]) + require.Equal(t, false, extra["command_code_balance_low"]) +} + +func TestCommandCodeQuotaProbePassesOrgID(t *testing.T) { + account := commandCodeUsageAccount() + upstream := newCommandCodeAlphaUpstream(`{"user": {"userName": "alice"}, "org": {"id": "9a0f5d0e-6d2c-4f36-9c55-0b8b0c0d1e2f", "login": "team"}}`) + svc := NewCNProviderQuotaService(&cnBalanceProbeRepo{account: account}, nil, upstream, &config.Config{}) + + _, err := svc.QueryUsage(context.Background(), account.ID) + + require.NoError(t, err) + for _, path := range []string{"/alpha/billing/credits", "/alpha/billing/subscriptions", "/alpha/usage/summary"} { + require.Equal(t, "9a0f5d0e-6d2c-4f36-9c55-0b8b0c0d1e2f", upstream.request(path).URL.Query().Get("orgId"), path) + } +} + +func TestCommandCodeQuotaProbeUnlimitedPlanClearsRollingWindows(t *testing.T) { + account := commandCodeUsageAccount() + upstream := newCommandCodeAlphaUpstream(`{"org": null}`) + upstream.responses["/alpha/billing/credits"] = commandCodeAlphaResponse{status: http.StatusOK, body: `{ + "credits": {"monthlyCredits": 10, "purchasedCredits": 25, "freeCredits": 0}, + "windowLimits": {"limited": false} + }`} + upstream.responses["/alpha/usage/summary"] = commandCodeAlphaResponse{status: http.StatusOK, body: `{"totalCost": 5}`} + repo := &cnBalanceProbeRepo{account: account} + svc := NewCNProviderQuotaService(repo, nil, upstream, &config.Config{}) + + result, err := svc.QueryUsage(context.Background(), account.ID) + + require.NoError(t, err) + require.Equal(t, []CNQuotaTier{{Window: "monthly", UsedPercent: 33.33, ResetAt: "2026-10-01T00:00:00Z"}}, result.Tiers) + extra := repo.extraWrites[0] + for _, key := range []string{"command_code_5h_used_percent", "command_code_5h_reset_at", "command_code_weekly_used_percent", "command_code_weekly_reset_at"} { + value, ok := extra[key] + require.True(t, ok, key) + require.Nil(t, value, key) + } + require.Equal(t, 25.0, extra["command_code_purchased_credits"]) + require.InDelta(t, 35.0, extra["command_code_balance"], 1e-9) +} + +// 窗口尚未开始(resetAt 为空)时清掉上一个窗口的重置时间,不与新的用量配对。 +func TestCommandCodeQuotaProbeClearsResetOfUnstartedWindow(t *testing.T) { + account := commandCodeUsageAccount() + upstream := newCommandCodeAlphaUpstream(`{"org": null}`) + upstream.responses["/alpha/billing/credits"] = commandCodeAlphaResponse{status: http.StatusOK, body: `{ + "credits": {"monthlyCredits": 34.9, "purchasedCredits": 0, "freeCredits": 0}, + "windowLimits": { + "limited": true, + "fiveHour": {"used": 0, "cap": 14, "resetAt": null}, + "weekly": {"used": 35, "cap": 35, "resetAt": 1790400000000} + } + }`} + repo := &cnBalanceProbeRepo{account: account} + + _, err := NewCNProviderQuotaService(repo, nil, upstream, &config.Config{}).QueryUsage(context.Background(), account.ID) + + require.NoError(t, err) + extra := repo.extraWrites[0] + require.Equal(t, 0.0, extra["command_code_5h_used_percent"]) + value, written := extra["command_code_5h_reset_at"] + require.True(t, written) + require.Nil(t, value) + require.Equal(t, time.UnixMilli(1790400000000).UTC().Format(time.RFC3339), extra["command_code_weekly_reset_at"]) + require.Equal(t, "2026-10-01T00:00:00Z", extra["command_code_monthly_reset_at"]) +} + +func TestCommandCodeQuotaProbeAuthFailureDoesNotPersist(t *testing.T) { + account := commandCodeUsageAccount() + upstream := newCommandCodeAlphaUpstream(`{}`) + upstream.responses["/alpha/whoami"] = commandCodeAlphaResponse{status: http.StatusUnauthorized, body: `{"error":"unauthorized"}`} + repo := &cnBalanceProbeRepo{account: account} + svc := NewCNProviderQuotaService(repo, nil, upstream, &config.Config{}) + + result, err := svc.QueryUsage(context.Background(), account.ID) + + require.NoError(t, err) + require.False(t, result.Success) + require.Equal(t, http.StatusUnauthorized, result.StatusCode) + require.Equal(t, "Authentication failed (HTTP 401)", result.Error) + require.Empty(t, repo.extraWrites) + require.Len(t, upstream.requests, 1) +} + +// 订阅周期或本周期用量取数失败:滚动窗口照常更新,月度快照保留上一次的值(无从判断 +// 月度窗口是否还在);订阅确实没有周期时才清掉月度快照。 +// whoami 失败(非鉴权错误)时无从判断是否组织账户:探测整体失败,不按个人账户去查 +// credits,也不写快照。 +func TestCommandCodeProbeFailsWhenWhoamiFails(t *testing.T) { + for _, whoami := range []commandCodeAlphaResponse{ + {status: http.StatusBadGateway, body: `bad gateway`}, + {status: http.StatusOK, body: `not json`}, + } { + account := commandCodeUsageAccount() + upstream := newCommandCodeAlphaUpstream(`{}`) + upstream.responses["/alpha/whoami"] = whoami + repo := &cnBalanceProbeRepo{account: account} + + result, err := NewCNProviderQuotaService(repo, nil, upstream, &config.Config{}).QueryUsage(context.Background(), account.ID) + require.NoError(t, err, whoami.body) + require.False(t, result.Success, whoami.body) + require.NotEmpty(t, result.Error, whoami.body) + + balance, err := NewCNProviderBalanceService(repo, nil, upstream, &config.Config{}).QueryBalance(context.Background(), account.ID) + require.NoError(t, err, whoami.body) + require.False(t, balance.Success, whoami.body) + + require.Nil(t, upstream.request("/alpha/billing/credits"), whoami.body) + require.Empty(t, repo.extraWrites, whoami.body) + } + + // 请求本身失败(网络错误)同样整体失败。 + account := commandCodeUsageAccount() + repo := &cnBalanceProbeRepo{account: account} + _, err := NewCNProviderQuotaService(repo, nil, failingCommandCodeUpstream{}, &config.Config{}).QueryUsage(context.Background(), account.ID) + require.Error(t, err) + require.Empty(t, repo.extraWrites) +} + +// failingCommandCodeUpstream 让每个请求都在网络层失败。 +type failingCommandCodeUpstream struct{} + +func (failingCommandCodeUpstream) Do(*http.Request, string, int64, int) (*http.Response, error) { + return nil, errors.New("dial tcp: i/o timeout") +} + +func (u failingCommandCodeUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, concurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) { + return u.Do(req, proxyURL, accountID, concurrency) +} + +func TestCommandCodeQuotaProbeKeepsWindowsWhenPeriodEndpointsFail(t *testing.T) { + for _, tc := range []struct { + name string + path string + response commandCodeAlphaResponse + planLevel string + keepMonthly bool + }{ + {"subscriptions failed", "/alpha/billing/subscriptions", commandCodeAlphaResponse{status: http.StatusInternalServerError, body: `oops`}, "", true}, + {"summary failed", "/alpha/usage/summary", commandCodeAlphaResponse{status: http.StatusBadGateway, body: `oops`}, "GOAT", true}, + {"no subscription period", "/alpha/billing/subscriptions", commandCodeAlphaResponse{status: http.StatusOK, body: `{"data":{}}`}, "", false}, + } { + account := commandCodeUsageAccount() + upstream := newCommandCodeAlphaUpstream(`{"org": null}`) + upstream.responses[tc.path] = tc.response + repo := &cnBalanceProbeRepo{account: account} + + result, err := NewCNProviderQuotaService(repo, nil, upstream, &config.Config{}).QueryUsage(context.Background(), account.ID) + + require.NoError(t, err, tc.name) + require.True(t, result.Success, tc.name) + require.Len(t, result.Tiers, 2, tc.name) + require.Equal(t, tc.planLevel, result.PlanLevel, tc.name) + if tc.path == "/alpha/billing/subscriptions" { + require.Nil(t, upstream.request("/alpha/usage/summary"), tc.name) + } + extra := repo.extraWrites[0] + require.Equal(t, 10.71, extra["command_code_5h_used_percent"], tc.name) + for _, key := range []string{"command_code_monthly_used_percent", "command_code_monthly_reset_at"} { + value, written := extra[key] + require.Equal(t, !tc.keepMonthly, written, tc.name+": "+key) + require.Nil(t, value, tc.name+": "+key) + } + } +} + +func TestCommandCodeUsageRequiresOfficialHost(t *testing.T) { + account := commandCodeUsageAccount() + require.Equal(t, PlatformCommandCode, account.GetCodingPlanProvider()) + require.NoError(t, validateCodingPlanAccount(account)) + require.NoError(t, validatePayGAccount(account)) + + account.Credentials["base_url"] = "https://relay.example.com/v1" + account.Credentials["api_protocol"] = APIProtocolChatCompletions + require.Empty(t, account.GetCodingPlanProvider()) + require.Error(t, validateCodingPlanAccount(account)) + require.Error(t, validatePayGAccount(account)) +} + +func TestCommandCodeBalanceProbeFetchesCreditsOnly(t *testing.T) { + account := commandCodeUsageAccount() + upstream := newCommandCodeAlphaUpstream(`{"org": null}`) + repo := &cnBalanceProbeRepo{account: account} + svc := NewCNProviderBalanceService(repo, nil, upstream, &config.Config{}) + + result, err := svc.QueryBalance(context.Background(), account.ID) + + require.NoError(t, err) + require.True(t, result.Success) + require.True(t, result.Persisted) + require.InDelta(t, 35.0, result.Balance, 1e-9) + require.Len(t, upstream.requests, 2) + require.Len(t, repo.extraWrites, 1) + _, hasWindow := repo.extraWrites[0]["command_code_5h_used_percent"] + require.False(t, hasWindow) + require.InDelta(t, 35.0, repo.extraWrites[0]["command_code_balance"], 1e-9) +} + +func TestCommandCodeWindowsIgnoredWithPurchasedCredits(t *testing.T) { + future := time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339) + account := commandCodeUsageAccount() + account.Extra = map[string]any{ + "command_code_5h_used_percent": 100.0, + "command_code_5h_reset_at": future, + "command_code_purchased_credits": 0.0, + } + require.NotEmpty(t, cnProviderThresholdCandidates(account, PlatformCommandCode)) + require.NotNil(t, cnProviderQuotaSnapshotReset(account, time.Now())) + + account.Extra["command_code_purchased_credits"] = 12.5 + require.Empty(t, cnProviderThresholdCandidates(account, PlatformCommandCode)) + require.Nil(t, cnProviderQuotaSnapshotReset(account, time.Now())) +} + +// commandCodeCheckRepo 记录周期检测对 Command Code 账号的停调。 +type commandCodeCheckRepo struct { + AccountRepository + accounts []Account + mu sync.Mutex + paused []int64 +} + +func (r *commandCodeCheckRepo) ListByPlatform(_ context.Context, platform string) ([]Account, error) { + if platform == PlatformCommandCode { + return r.accounts, nil + } + return nil, nil +} + +func (r *commandCodeCheckRepo) SetTempUnschedulable(_ context.Context, id int64, _ time.Time, _ string) error { + r.mu.Lock() + defer r.mu.Unlock() + r.paused = append(r.paused, id) + return nil +} + +type commandCodeBalanceProber struct { + mu sync.Mutex + probed []int64 + balance float64 +} + +func (p *commandCodeBalanceProber) QueryUsage(_ context.Context, accountID int64) (*CNProviderQuotaProbeResult, error) { + p.mu.Lock() + defer p.mu.Unlock() + p.probed = append(p.probed, accountID) + return &CNProviderQuotaProbeResult{ + Success: true, + Balance: &CNProviderBalanceResult{Success: true, Balance: p.balance, Currency: "USD", Available: true}, + }, nil +} + +func TestCNProviderBalanceCheckRunOnceProbesCommandCode(t *testing.T) { + official := *commandCodeUsageAccount() + official.ID, official.Schedulable = 1, true + manualOff := *commandCodeUsageAccount() + manualOff.ID, manualOff.Schedulable = 2, false + relay := *commandCodeTestAccount(3) + relay.Schedulable = true + relay.Credentials = map[string]any{"api_key": "k", "api_protocol": APIProtocolChatCompletions, "base_url": "https://relay.example.com/v1"} + inactive := *commandCodeUsageAccount() + inactive.ID, inactive.Status = 4, StatusDisabled + + repo := &commandCodeCheckRepo{accounts: []Account{official, manualOff, relay, inactive}} + prober := &commandCodeBalanceProber{balance: 0.2} + cfg := &config.Config{} + cfg.Gateway.CNProviders.BalanceThreshold = 0.5 + svc := &CNProviderBalanceCheckService{accountRepo: repo, quotaService: prober, cfg: cfg} + + svc.runOnce() + + require.ElementsMatch(t, []int64{1, 2}, prober.probed) + require.Equal(t, []int64{1}, repo.paused) +} + +// 积分不足(402)是可恢复状态:临时停调并标记 balance_low,不能永久置 error。 +func TestHandleUpstreamError_CommandCode402PausesInsteadOfError(t *testing.T) { + repo := &rateLimitAccountRepoStub{} + service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + account := commandCodeUsageAccount() + + shouldDisable := service.HandleUpstreamError(context.Background(), account, http.StatusPaymentRequired, + http.Header{}, []byte(`{"error":{"message":"Insufficient credits"}}`)) + + require.True(t, shouldDisable) + require.Equal(t, 0, repo.setErrorCalls) + require.Equal(t, 1, repo.tempCalls) + require.Contains(t, repo.lastTempReason, cnBalanceLowReasonPrefix) + require.Equal(t, true, repo.lastExtraUpdates["command_code_balance_low"]) +} + +func TestHandleUpstreamError_CommandCode429InsufficientBalancePauses(t *testing.T) { + repo := &rateLimitAccountRepoStub{} + service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + account := commandCodeUsageAccount() + + service.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, + http.Header{}, []byte(`{"error":{"type":"insufficient_credits","message":"no credits left"}}`)) + + require.Equal(t, 0, repo.setErrorCalls) + require.Equal(t, 1, repo.tempCalls) + require.Contains(t, repo.lastTempReason, cnBalanceLowReasonPrefix) + require.Equal(t, 0, repo.rateLimitedCalls) +} + +// commandCodeRateLimitRepo 记录 Command Code 响应式处理写入的临时停调与模型级限流。 +type commandCodeRateLimitRepo struct { + rateLimitAccountRepoStub + tempUntil time.Time + modelLimits map[string]time.Time + reasons map[string]string +} + +func (r *commandCodeRateLimitRepo) SetTempUnschedulable(ctx context.Context, id int64, until time.Time, reason string) error { + r.tempUntil = until + return r.rateLimitAccountRepoStub.SetTempUnschedulable(ctx, id, until, reason) +} + +func (r *commandCodeRateLimitRepo) SetModelRateLimit(_ context.Context, _ int64, scope string, resetAt time.Time, reason ...string) error { + if r.modelLimits == nil { + r.modelLimits = map[string]time.Time{} + r.reasons = map[string]string{} + } + r.modelLimits[scope] = resetAt + if len(reason) > 0 { + r.reasons[scope] = reason[0] + } + return nil +} + +// 窗口用满:按文案中的重置时间写带原因的临时停调(充值后可据原因解除),不依赖额度快照。 +func TestHandleUpstreamError_CommandCodeWindowLimitCoolsDownToMessageReset(t *testing.T) { + for _, status := range []int{http.StatusTooManyRequests, http.StatusForbidden, http.StatusPaymentRequired} { + repo := &commandCodeRateLimitRepo{} + service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + account := commandCodeUsageAccount() + + shouldDisable := service.HandleUpstreamError(context.Background(), account, status, http.Header{}, + []byte(`{"error":{"type":"usage_exceeded","message":"You've reached your 5-hour usage limit. Resets in 2h 41m (3:00 PM)."}}`)) + + require.Equal(t, status != http.StatusTooManyRequests, shouldDisable, status) + require.Equal(t, 0, repo.setErrorCalls, status) + require.Equal(t, 0, repo.rateLimitedCalls, status) + require.Equal(t, 1, repo.tempCalls, status) + require.True(t, strings.HasPrefix(repo.lastTempReason, commandCodeUsageLimitReason+": "), repo.lastTempReason) + expected := time.Now().Add(2*time.Hour + 41*time.Minute) + require.WithinDuration(t, expected, repo.tempUntil, time.Minute, status) + } +} + +// 文案没有重置时间时回落额度快照中最早的窗口重置点。 +func TestHandleUpstreamError_CommandCodeWindowLimitFallsBackToSnapshot(t *testing.T) { + repo := &commandCodeRateLimitRepo{} + service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + account := commandCodeUsageAccount() + account.Extra = map[string]any{ + "command_code_5h_used_percent": 100.0, + "command_code_5h_reset_at": time.Now().Add(90 * time.Minute).UTC().Format(time.RFC3339), + "command_code_purchased_credits": 0.0, + } + + service.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, http.Header{}, + []byte(`{"error":{"message":"You've reached your weekly usage limit."}}`)) + + require.Equal(t, 1, repo.tempCalls) + require.WithinDuration(t, time.Now().Add(90*time.Minute), repo.tempUntil, time.Minute) +} + +// 普通频率限制很快恢复:即使快照里有窗口重置点,也不冷却到窗口重置。 +func TestHandleUpstreamError_CommandCodeRateLimitUsesDefaultCooldown(t *testing.T) { + repo := &commandCodeRateLimitRepo{} + service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + account := commandCodeUsageAccount() + account.Extra = map[string]any{ + "command_code_5h_used_percent": 97.0, + "command_code_5h_reset_at": time.Now().Add(4 * time.Hour).UTC().Format(time.RFC3339), + "command_code_purchased_credits": 0.0, + } + + service.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, http.Header{}, + []byte(`{"error":{"type":"rate_limit","message":"Rate limit exceeded. Please wait a moment and try again."}}`)) + + require.Equal(t, 0, repo.setErrorCalls) + require.Equal(t, 0, repo.tempCalls) + if repo.rateLimitedCalls > 0 { + require.True(t, repo.lastRateLimitedAt.Before(time.Now().Add(30*time.Minute)), "must not cool down to the 5h window reset") + } +} + +const ( + commandCodeSpendLimitBody = `{"error":{"type":"usage_exceeded","message":"You've reached the $10.00/mo spending limit your organization set for GLM-5. It resets in 3 days."}}` + commandCodeMemberSpendLimitBody = `{"error":{"type":"usage_exceeded","message":"You've reached the $50.00/mo spending limit your organization set. It resets in 12 days."}}` +) + +// 组织为某个模型设的花费上限只限当前模型:写模型级限流到文案中的重置时间,不停整个账号。 +func TestHandleUpstreamError_CommandCodeSpendLimitIsModelScoped(t *testing.T) { + repo := &commandCodeRateLimitRepo{} + service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + account := commandCodeUsageAccount() + ctx := withTempUnschedulableModel(context.Background(), []string{"glm-5"}) + + shouldDisable := service.HandleUpstreamError(ctx, account, http.StatusForbidden, http.Header{}, []byte(commandCodeSpendLimitBody)) + + require.False(t, shouldDisable) + require.Equal(t, 0, repo.setErrorCalls) + require.Equal(t, 0, repo.tempCalls) + require.Equal(t, 0, repo.rateLimitedCalls) + require.Contains(t, repo.modelLimits, "glm-5") + require.WithinDuration(t, time.Now().Add(72*time.Hour), repo.modelLimits["glm-5"], time.Minute) + require.Equal(t, commandCodeSpendLimitReason, repo.reasons["glm-5"]) +} + +// 成员总额度耗尽:即使请求模型已知也按账号冷却,但最长一小时(管理员可能调高上限)。 +func TestHandleUpstreamError_CommandCodeMemberSpendLimitIsAccountScoped(t *testing.T) { + repo := &commandCodeRateLimitRepo{} + service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + account := commandCodeUsageAccount() + ctx := withTempUnschedulableModel(context.Background(), []string{"glm-5"}) + + shouldDisable := service.HandleUpstreamError(ctx, account, http.StatusForbidden, http.Header{}, []byte(commandCodeMemberSpendLimitBody)) + + require.True(t, shouldDisable) + require.Empty(t, repo.modelLimits) + require.Equal(t, 0, repo.setErrorCalls) + require.Equal(t, 1, repo.tempCalls) + require.Equal(t, commandCodeSpendLimitReason, repo.lastTempReason) + require.WithinDuration(t, time.Now().Add(commandCodeAccountSpendLimitRecheck), repo.tempUntil, time.Minute) + + // 文案中的重置时间早于一小时时以文案为准。 + soon := &commandCodeRateLimitRepo{} + NewRateLimitService(soon, nil, &config.Config{}, nil, nil).HandleUpstreamError(ctx, account, http.StatusTooManyRequests, http.Header{}, + []byte(`{"error":{"message":"You've reached the $50.00/mo spending limit your organization set. It resets in 20 minutes."}}`)) + require.WithinDuration(t, time.Now().Add(20*time.Minute), soon.tempUntil, time.Minute) +} + +// 不知道模型时按账号冷却(最长一小时),不停账号数天,也不进入 403 累计禁用。 +func TestHandleUpstreamError_CommandCodeSpendLimitWithoutModelShortCooldown(t *testing.T) { + repo := &commandCodeRateLimitRepo{} + service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + account := commandCodeUsageAccount() + + for i := 0; i < 5; i++ { + service.HandleUpstreamError(context.Background(), account, http.StatusForbidden, http.Header{}, []byte(commandCodeSpendLimitBody)) + } + + require.Equal(t, 0, repo.setErrorCalls) + require.Empty(t, repo.modelLimits) + require.Equal(t, 5, repo.tempCalls) + require.False(t, repo.tempUntil.After(time.Now().Add(commandCodeAccountSpendLimitRecheck))) +} + +func TestCommandCodeSpendLimitScope(t *testing.T) { + require.True(t, commandCodeSpendLimitIsModelScoped([]byte(commandCodeSpendLimitBody))) + require.True(t, commandCodeSpendLimitIsModelScoped([]byte(`spending limit your organization set for gpt-5.5. It resets in 2 days.`))) + require.False(t, commandCodeSpendLimitIsModelScoped([]byte(commandCodeMemberSpendLimitBody))) + require.False(t, commandCodeSpendLimitIsModelScoped([]byte(`You've reached the $50.00/mo spending limit your organization set for you.`))) + require.False(t, commandCodeSpendLimitIsModelScoped([]byte(`spending limit reached`))) +} + +// commandCodeStatefulRepo 把停调写回同一个账号对象,用于验证完整的状态转换。 +type commandCodeStatefulRepo struct { + AccountRepository + account *Account + clearCalls int +} + +func (r *commandCodeStatefulRepo) GetByID(_ context.Context, _ int64) (*Account, error) { + clone := *r.account + return &clone, nil +} + +func (r *commandCodeStatefulRepo) UpdateExtra(_ context.Context, _ int64, _ map[string]any) error { + return nil +} + +func (r *commandCodeStatefulRepo) SetTempUnschedulable(_ context.Context, _ int64, until time.Time, reason string) error { + r.account.TempUnschedulableUntil = &until + r.account.TempUnschedulableReason = reason + return nil +} + +func (r *commandCodeStatefulRepo) ClearTempUnschedulable(_ context.Context, _ int64) error { + r.clearCalls++ + r.account.TempUnschedulableUntil = nil + r.account.TempUnschedulableReason = "" + return nil +} + +func commandCodeCreditsBody(purchased float64) string { + return fmt.Sprintf(`{"credits":{"monthlyCredits":0,"purchasedCredits":%g,"freeCredits":0}, + "windowLimits":{"limited":true,"fiveHour":{"used":14,"cap":14,"resetAt":1790000000000}}}`, purchased) +} + +// 窗口用满进入冷却 → 充值 → 下一次额度探测发现充值积分 → 恢复可调度。 +func TestCommandCodeWindowCooldownClearedAfterTopUp(t *testing.T) { + account := commandCodeUsageAccount() + account.Schedulable = true + repo := &commandCodeStatefulRepo{account: account} + rateLimit := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + + rateLimit.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, http.Header{}, + []byte(`{"error":{"message":"You've reached your 5-hour usage limit. Resets in 3h 10m."}}`)) + require.False(t, account.IsSchedulable()) + + // 未充值:探测不解除冷却。 + upstream := newCommandCodeAlphaUpstream(`{"org": null}`) + upstream.responses["/alpha/billing/credits"] = commandCodeAlphaResponse{status: http.StatusOK, body: commandCodeCreditsBody(0)} + quota := NewCNProviderQuotaService(repo, nil, upstream, &config.Config{}) + _, err := quota.QueryUsage(context.Background(), account.ID) + require.NoError(t, err) + require.Equal(t, 0, repo.clearCalls) + require.False(t, account.IsSchedulable()) + + // 充值后:额度探测与余额探测都能解除窗口冷却。 + upstream.responses["/alpha/billing/credits"] = commandCodeAlphaResponse{status: http.StatusOK, body: commandCodeCreditsBody(20)} + _, err = quota.QueryUsage(context.Background(), account.ID) + require.NoError(t, err) + require.Equal(t, 1, repo.clearCalls) + require.True(t, account.IsSchedulable()) + + rateLimit.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, http.Header{}, + []byte(`{"error":{"message":"You've reached your weekly usage limit. Resets in 2 days."}}`)) + require.False(t, account.IsSchedulable()) + _, err = NewCNProviderBalanceService(repo, nil, upstream, &config.Config{}).QueryBalance(context.Background(), account.ID) + require.NoError(t, err) + require.True(t, account.IsSchedulable()) +} + +// 充值积分只解除窗口冷却:积分不足、花费上限等其他原因的停调保持不变。 +func TestCommandCodeTopUpKeepsOtherCooldowns(t *testing.T) { + for _, reason := range []string{cnBalanceLowReason("no credits"), commandCodeSpendLimitReason, "manual rule"} { + until := time.Now().Add(time.Hour) + account := commandCodeUsageAccount() + account.Schedulable = true + account.TempUnschedulableUntil = &until + account.TempUnschedulableReason = reason + repo := &commandCodeStatefulRepo{account: account} + upstream := newCommandCodeAlphaUpstream(`{"org": null}`) + upstream.responses["/alpha/billing/credits"] = commandCodeAlphaResponse{status: http.StatusOK, body: commandCodeCreditsBody(20)} + + _, err := NewCNProviderQuotaService(repo, nil, upstream, &config.Config{}).QueryUsage(context.Background(), account.ID) + + require.NoError(t, err) + require.Equal(t, 0, repo.clearCalls, reason) + require.False(t, account.IsSchedulable(), reason) + } +} + +func TestClassifyCommandCodeUsageError(t *testing.T) { + cases := map[string]commandCodeUsageErrorKind{ + `{"error":{"message":"You've reached your 5-hour usage limit. Resets in 2h 41m (3:00 PM)."}}`: commandCodeUsageWindowLimit, + `{"error":{"message":"You've reached your weekly usage limit."}}`: commandCodeUsageWindowLimit, + commandCodeSpendLimitBody: commandCodeUsageSpendLimit, + `{"error":{"type":"usage_exceeded","message":"Monthly credits spent."}}`: commandCodeUsageCreditsExhausted, + `{"error":{"message":"Insufficient credits"}}`: commandCodeUsageCreditsExhausted, + `{"error":{"type":"rate_limit","message":"Rate limit exceeded. Please wait a moment."}}`: commandCodeUsageErrorNone, + `{"error":{"message":"forbidden"}}`: commandCodeUsageErrorNone, + } + for body, want := range cases { + require.Equal(t, want, classifyCommandCodeUsageError([]byte(body)), body) + } +} diff --git a/backend/internal/service/composite_platform_test.go b/backend/internal/service/composite_platform_test.go index 4b11366a29..aac637b50d 100644 --- a/backend/internal/service/composite_platform_test.go +++ b/backend/internal/service/composite_platform_test.go @@ -218,13 +218,13 @@ func TestCompositeGroupSchedulerHasAllCanonicalPlatformBuckets(t *testing.T) { platforms = append(platforms, platform) } require.ElementsMatch(t, - []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe}, + []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe, PlatformCommandCode}, platforms, ) } func TestCompositeConcretePlatformsIncludeCNProviders(t *testing.T) { - for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe} { + for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe, PlatformCommandCode} { require.True(t, isConcreteRequestPlatform(platform)) require.True(t, canCopyAccountsFromGroupPlatform(PlatformComposite, platform)) } diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index c6fc312753..9b751aac5b 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -44,13 +44,14 @@ const ( PlatformAntigravity = domain.PlatformAntigravity PlatformGrok = domain.PlatformGrok // 国产 OpenAI 兼容供应商(与 grok 一样经 OpenAI 网关转发)。 - PlatformKimi = domain.PlatformKimi - PlatformZhipu = domain.PlatformZhipu - PlatformDeepseek = domain.PlatformDeepseek - PlatformMiniMax = domain.PlatformMiniMax - PlatformTypeSafe = domain.PlatformTypeSafe - PlatformOpenCodeGo = domain.PlatformOpenCodeGo - PlatformComposite = domain.PlatformComposite + PlatformKimi = domain.PlatformKimi + PlatformZhipu = domain.PlatformZhipu + PlatformDeepseek = domain.PlatformDeepseek + PlatformMiniMax = domain.PlatformMiniMax + PlatformOpenCodeGo = domain.PlatformOpenCodeGo + PlatformTypeSafe = domain.PlatformTypeSafe + PlatformCommandCode = domain.PlatformCommandCode + PlatformComposite = domain.PlatformComposite // PlatformKiro is retained for unsupported-platform threshold tests and legacy // account rows. Scheduling-threshold evaluation never pauses kiro accounts. PlatformKiro = "kiro" @@ -86,6 +87,8 @@ const ( DefaultOpenCodeGoBaseURL = "https://opencode.ai/zen/go/v1" // OpenCode Zen:按量付费网关,模型列表为 /zen/v1/models。 DefaultOpenCodeZenBaseURL = "https://opencode.ai/zen/v1" + // Command Code Provider API:Chat Completions / Responses / models 共用 /provider/v1 基址。 + DefaultCommandCodeBaseURL = "https://api.commandcode.ai/provider/v1" ) // 国产供应商 Anthropic 协议端点的默认 base_url(上游路径为 {base}/v1/messages)。 @@ -99,6 +102,8 @@ const ( // OpenCode Go Anthropic 基址不含 /v1:nativeAnthropicTargetURL 会再拼 /v1/messages。 DefaultOpenCodeGoAnthropicBaseURL = "https://opencode.ai/zen/go" DefaultOpenCodeZenAnthropicBaseURL = "https://opencode.ai/zen" + // Command Code 的 Anthropic 端点为 /provider/v1/messages(Claude 系模型只在此端点提供)。 + DefaultCommandCodeAnthropicBaseURL = "https://api.commandcode.ai/provider" ) // IsCNProvider 报告 platform 是否为国产 OpenAI 兼容供应商(kimi/zhipu/deepseek/minimax), @@ -126,6 +131,7 @@ var AllowedQuotaPlatforms = domain.ConcretePlatformIDs() // AllowedSchedulingThresholdPlatforms 是允许设置账号自动停调阈值的平台列表。 // openai/anthropic/grok 有原生用量窗口;kimi/zhipu/minimax 的 Coding Plan 同样暴露 // 5h/weekly 滚动窗口,纳入阈值评估。deepseek 为余额型,走余额检测而非阈值。 +// OpenCode Go 与 Command Code 的订阅套餐另有月度窗口。 var AllowedSchedulingThresholdPlatforms = []string{ PlatformOpenAI, PlatformAnthropic, @@ -134,6 +140,7 @@ var AllowedSchedulingThresholdPlatforms = []string{ PlatformZhipu, PlatformMiniMax, PlatformOpenCodeGo, + PlatformCommandCode, } // IsAllowedQuotaPlatform 报告 s 是否为合法的 quota platform 标识。 diff --git a/backend/internal/service/model_protocol_catalog.go b/backend/internal/service/model_protocol_catalog.go new file mode 100644 index 0000000000..a9c9bb8404 --- /dev/null +++ b/backend/internal/service/model_protocol_catalog.go @@ -0,0 +1,263 @@ +package service + +import ( + "context" + "fmt" + "io" + "log/slog" + "net/http" + "slices" + "strings" + "sync" + "time" + + "github.com/tidwall/gjson" +) + +// 上游模型目录中的协议能力。部分聚合平台(Command Code)在 {Chat Completions 基址}/models +// 的每个模型上给出 supported_endpoints,例如: +// +// {"id":"deepseek/deepseek-v4-flash","supported_endpoints":["/chat/completions","/responses"]} +// {"id":"claude-sonnet-4-6","supported_endpoints":["/messages"]} +// +// 按模型分流时据此判断模型是否支持入站协议:支持就同协议直通,省去一次协议转换 +// (见 modelProtocolSet)。 +// +// 缓存范围:官方默认地址的目录与账号无关,按地址全进程共享;自定义上游的目录可能随 +// Key、租户或请求头不同,按账号隔离(见 modelProtocolCatalogKey)。 +// +// 刷新在后台进行:过期时继续使用旧目录。目录从未加载成功时,首批请求最多等待 +// modelProtocolCatalogFirstLoadWait,让冷启动也按目录分流,而不是先走一次协议转换; +// 超时或失败则回落内置规则,失败退避期内不再等待。 + +const ( + modelProtocolCatalogTTL = 30 * time.Minute + modelProtocolCatalogRetryBackoff = 5 * time.Minute + modelProtocolCatalogTimeout = 15 * time.Second + modelProtocolCatalogFirstLoadWait = 2 * time.Second + modelProtocolCatalogMaxBodyBytes = 2 << 20 +) + +type modelProtocolCatalogEntry struct { + // models 以小写模型 ID 为键,值为上游声明的协议(保持上游顺序)。 + models map[string][]string + fetchedAt time.Time + retryAt time.Time + refreshing bool + // done 在进行中的刷新结束时关闭。 + done chan struct{} +} + +type modelProtocolCatalog struct { + mu sync.Mutex + entries map[string]*modelProtocolCatalogEntry + // firstLoadWait 覆盖 modelProtocolCatalogFirstLoadWait(测试用)。 + firstLoadWait time.Duration +} + +// upstreamModelProtocols 是全进程共享的模型协议目录。 +var upstreamModelProtocols modelProtocolCatalog + +func (c *modelProtocolCatalog) entryLocked(key string) *modelProtocolCatalogEntry { + if c.entries == nil { + c.entries = make(map[string]*modelProtocolCatalogEntry) + } + entry := c.entries[key] + if entry == nil { + entry = &modelProtocolCatalogEntry{} + c.entries[key] = entry + } + return entry +} + +// lookup 返回 key 目录中 model 支持的协议;目录尚未就绪或模型不在目录中时返回 nil。 +// 目录缺失或过期、且不在刷新中也不在失败退避期时,异步调用 refresh(由其调用 store); +// 目录从未加载成功且刷新进行中时,最多等待 firstLoadWait。 +func (c *modelProtocolCatalog) lookup(key, model string, now time.Time, refresh func()) []string { + c.mu.Lock() + defer c.mu.Unlock() + entry := c.entryLocked(key) + stale := entry.models == nil || now.Sub(entry.fetchedAt) >= modelProtocolCatalogTTL + if stale && !entry.refreshing && !now.Before(entry.retryAt) && refresh != nil { + entry.refreshing = true + entry.done = make(chan struct{}) + go refresh() + } + if entry.models == nil && entry.refreshing { + wait := c.firstLoadWait + if wait <= 0 { + wait = modelProtocolCatalogFirstLoadWait + } + done := entry.done + c.mu.Unlock() + timer := time.NewTimer(wait) + select { + case <-done: + case <-timer.C: + } + timer.Stop() + c.mu.Lock() + } + if entry.models == nil { + return nil + } + return entry.models[strings.ToLower(strings.TrimSpace(model))] +} + +// store 写入一次刷新的结果;失败时保留旧目录并进入退避。 +func (c *modelProtocolCatalog) store(key string, models map[string][]string, err error, now time.Time) { + c.mu.Lock() + defer c.mu.Unlock() + entry := c.entryLocked(key) + entry.refreshing = false + if entry.done != nil { + close(entry.done) + entry.done = nil + } + if err != nil { + entry.retryAt = now.Add(modelProtocolCatalogRetryBackoff) + return + } + entry.models = models + entry.fetchedAt = now + entry.retryAt = time.Time{} +} + +// modelEndpointProtocol 把 supported_endpoints 中的端点路径映射为上游协议。 +func modelEndpointProtocol(endpoint string) string { + endpoint = strings.ToLower(strings.TrimRight(strings.TrimSpace(endpoint), "/")) + switch { + case strings.HasSuffix(endpoint, "/chat/completions"): + return APIProtocolChatCompletions + case strings.HasSuffix(endpoint, "/responses"): + return APIProtocolResponses + case strings.HasSuffix(endpoint, "/messages"): + return APIProtocolAnthropic + default: + return "" + } +} + +// parseModelProtocolCatalog 解析 /models 响应;没有任何模型带可识别的 supported_endpoints +// 时返回错误(上游不提供该字段,或是中转站的普通 /models)。 +func parseModelProtocolCatalog(body []byte) (map[string][]string, error) { + entries, err := extractUpstreamModelRawEntries(body) + if err != nil { + return nil, err + } + models := make(map[string][]string) + for _, raw := range entries { + id := strings.ToLower(strings.TrimSpace(gjson.GetBytes(raw, "id").String())) + if id == "" { + continue + } + var protocols []string + for _, endpoint := range gjson.GetBytes(raw, "supported_endpoints").Array() { + protocol := modelEndpointProtocol(endpoint.String()) + if protocol != "" && !slices.Contains(protocols, protocol) { + protocols = append(protocols, protocol) + } + } + if len(protocols) > 0 { + models[id] = protocols + } + } + if len(models) == 0 { + return nil, fmt.Errorf("model list has no supported_endpoints") + } + return models, nil +} + +// modelCatalogProtocols 返回上游模型目录中 model 支持的协议;平台不提供目录、账号固定了 +// 上游协议或目录尚未就绪时返回 nil。 +func (s *OpenAIGatewayService) modelCatalogProtocols(account *Account, model string) []string { + if s == nil || s.httpUpstream == nil || strings.TrimSpace(model) == "" || !account.routesByModel() { + return nil + } + if profile := account.providerProfile(); profile == nil || !profile.ModelCatalog { + return nil + } + if account.GetAPIProtocol() != APIProtocolAdaptive { + return nil + } + base := strings.TrimSpace(account.GetCNProtocolBaseURL(APIProtocolChatCompletions)) + if base == "" { + return nil + } + url := buildOpenAIModelsURL(base) + key := modelProtocolCatalogKey(account, base, url) + headers := modelProtocolCatalogHeaders(account, url) + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + accountID, concurrency := account.ID, account.Concurrency + return upstreamModelProtocols.lookup(key, model, time.Now(), func() { + ctx, cancel := context.WithTimeout(context.Background(), modelProtocolCatalogTimeout) + defer cancel() + models, err := s.fetchModelProtocolCatalog(ctx, url, headers, proxyURL, accountID, concurrency) + if err != nil { + slog.Warn("model_protocol_catalog_refresh_failed", "account_id", accountID, "url", url, "error", err) + } else { + slog.Info("model_protocol_catalog_refreshed", "account_id", accountID, "url", url, "models", len(models)) + } + upstreamModelProtocols.store(key, models, err, time.Now()) + }) +} + +// modelProtocolCatalogKey 返回目录的缓存键:账号使用平台官方默认地址时按地址共享, +// 自定义上游按账号隔离,避免一个账号的目录或鉴权失败影响同地址的其他账号。 +func modelProtocolCatalogKey(account *Account, base, url string) string { + official := account.providerProfile().DefaultBaseURL(account.GetCredential("account_mode"), APIProtocolChatCompletions) + if official != "" && strings.EqualFold(strings.TrimRight(base, "/"), strings.TrimRight(official, "/")) { + return url + } + return fmt.Sprintf("%s#account=%d", url, account.ID) +} + +// modelProtocolCatalogHeaders 构造目录请求头,UA 收敛与账号请求头覆写与转发一致 +// (见 buildUpstreamRequest)。在请求时构造,后台刷新不再读取账号。 +func modelProtocolCatalogHeaders(account *Account, url string) http.Header { + headers := http.Header{} + headers.Set("Accept", "application/json") + if apiKey := strings.TrimSpace(account.GetOpenAIProtocolAPIKey()); apiKey != "" { + headers.Set("Authorization", "Bearer "+apiKey) + } + applyOpenCodeUpstreamUserAgent(account, url, headers) + account.ApplyHeaderOverrides(headers) + return headers +} + +func (s *OpenAIGatewayService) fetchModelProtocolCatalog(ctx context.Context, url string, headers http.Header, proxyURL string, accountID int64, concurrency int) (map[string][]string, error) { + validatedURL, err := s.validateUpstreamBaseURL(url) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(WithHTTPUpstreamRedirectsDisabled(ctx), http.MethodGet, validatedURL, nil) + if err != nil { + return nil, err + } + req.Header = headers.Clone() + resp, err := s.httpUpstream.Do(req, proxyURL, accountID, maxInt(concurrency, 1)) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("model list returned HTTP %d", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, modelProtocolCatalogMaxBodyBytes+1)) + if err != nil { + return nil, err + } + if len(body) > modelProtocolCatalogMaxBodyBytes { + return nil, fmt.Errorf("model list exceeds %d bytes", modelProtocolCatalogMaxBodyBytes) + } + return parseModelProtocolCatalog(body) +} + +// resolveUpstreamProtocolFor 是网关入口使用的 resolveUpstreamProtocol:按模型分流且平台 +// 提供模型目录时,带上目录中该模型支持的协议。 +func (s *OpenAIGatewayService) resolveUpstreamProtocolFor(account *Account, inbound, model string) string { + return resolveUpstreamProtocol(account, inbound, model, s.modelCatalogProtocols(account, model)) +} diff --git a/backend/internal/service/model_protocol_catalog_test.go b/backend/internal/service/model_protocol_catalog_test.go new file mode 100644 index 0000000000..536e71c0dd --- /dev/null +++ b/backend/internal/service/model_protocol_catalog_test.go @@ -0,0 +1,268 @@ +//go:build unit + +package service + +import ( + "errors" + "fmt" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// 取自 https://api.commandcode.ai/provider/v1/models 的片段(2026-09-26)。 +const commandCodeModelsSample = `{"object":"list","data":[ + {"id":"claude-sonnet-4-6","supported_endpoints":["/messages"]}, + {"id":"gpt-5.5","supported_endpoints":["/chat/completions","/responses"]}, + {"id":"deepseek/deepseek-v4-flash","supported_endpoints":["/chat/completions","/responses"]}, + {"id":"deepseek/deepseek-v4-flash-fast","supported_endpoints":["/chat/completions"]}, + {"id":"zai-org/GLM-5.3","supported_endpoints":["/chat/completions","/responses"]}, + {"id":"typesafe/jev","supported_endpoints":["/systemone"]}, + {"id":"no-endpoints"} +]}` + +func TestParseModelProtocolCatalog(t *testing.T) { + models, err := parseModelProtocolCatalog([]byte(commandCodeModelsSample)) + require.NoError(t, err) + require.Equal(t, map[string][]string{ + "claude-sonnet-4-6": {APIProtocolAnthropic}, + "gpt-5.5": {APIProtocolChatCompletions, APIProtocolResponses}, + "deepseek/deepseek-v4-flash": {APIProtocolChatCompletions, APIProtocolResponses}, + "deepseek/deepseek-v4-flash-fast": {APIProtocolChatCompletions}, + "zai-org/glm-5.3": {APIProtocolChatCompletions, APIProtocolResponses}, + }, models) + + _, err = parseModelProtocolCatalog([]byte(`{"data":[{"id":"gpt-5"},{"id":"glm-5"}]}`)) + require.Error(t, err, "a plain /models without supported_endpoints is not a protocol catalog") +} + +func TestModelProtocolCatalogLookupRefreshesInBackground(t *testing.T) { + catalog := modelProtocolCatalog{firstLoadWait: 20 * time.Millisecond} + const url = "https://catalog.example/v1/models" + now := time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC) + var refreshes atomic.Int32 + started := make(chan struct{}, 4) + refresh := func() { refreshes.Add(1); started <- struct{}{} } + + // 未就绪且刷新迟迟不返回:等待上限后返回 nil;刷新期间不重复触发。 + require.Nil(t, catalog.lookup(url, "gpt-5.5", now, refresh)) + <-started + require.Nil(t, catalog.lookup(url, "gpt-5.5", now, refresh)) + require.EqualValues(t, 1, refreshes.Load()) + + models, err := parseModelProtocolCatalog([]byte(commandCodeModelsSample)) + require.NoError(t, err) + catalog.store(url, models, nil, now) + require.Equal(t, []string{APIProtocolChatCompletions, APIProtocolResponses}, catalog.lookup(url, "ZAI-ORG/GLM-5.3", now, refresh)) + require.Nil(t, catalog.lookup(url, "unknown-model", now, refresh)) + require.EqualValues(t, 1, refreshes.Load()) + + // 过期后继续返回旧目录,同时后台刷新;失败时保留旧目录并退避。 + later := now.Add(modelProtocolCatalogTTL) + require.NotNil(t, catalog.lookup(url, "gpt-5.5", later, refresh)) + <-started + require.EqualValues(t, 2, refreshes.Load()) + catalog.store(url, nil, errors.New("boom"), later) + require.NotNil(t, catalog.lookup(url, "gpt-5.5", later.Add(time.Minute), refresh)) + require.EqualValues(t, 2, refreshes.Load(), "failure backoff suppresses refresh") + require.NotNil(t, catalog.lookup(url, "gpt-5.5", later.Add(modelProtocolCatalogRetryBackoff), refresh)) + <-started + require.EqualValues(t, 3, refreshes.Load()) +} + +// 冷启动:目录从未加载时首批请求等待进行中的刷新,按目录分流;刷新失败则立即回落, +// 退避期内不再等待。 +func TestModelProtocolCatalogFirstLoadWaitsForRefresh(t *testing.T) { + const key = "https://catalog.example/v1/models" + now := time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC) + models, err := parseModelProtocolCatalog([]byte(commandCodeModelsSample)) + require.NoError(t, err) + + loaded := modelProtocolCatalog{firstLoadWait: 5 * time.Second} + require.Equal(t, []string{APIProtocolChatCompletions, APIProtocolResponses}, loaded.lookup(key, "gpt-5.5", now, func() { + time.Sleep(10 * time.Millisecond) + loaded.store(key, models, nil, now) + })) + + failing := modelProtocolCatalog{firstLoadWait: 5 * time.Second} + var refreshes atomic.Int32 + refresh := func() { + refreshes.Add(1) + failing.store(key, nil, errors.New("boom"), now) + } + start := time.Now() + require.Nil(t, failing.lookup(key, "gpt-5.5", now, refresh)) + require.Nil(t, failing.lookup(key, "gpt-5.5", now.Add(time.Minute), refresh)) + require.Less(t, time.Since(start), time.Second, "a failed first load must not hold requests") + require.EqualValues(t, 1, refreshes.Load()) +} + +// 缓存范围与请求头:官方默认地址按地址共享,自定义上游按账号隔离;请求头与转发一致。 +func TestModelProtocolCatalogKeyAndHeaders(t *testing.T) { + official := commandCodeTestAccount(21) + base := official.GetCNProtocolBaseURL(APIProtocolChatCompletions) + url := buildOpenAIModelsURL(base) + require.Equal(t, "https://api.commandcode.ai/provider/v1/models", url) + require.Equal(t, url, modelProtocolCatalogKey(official, base, url)) + require.Equal(t, url, modelProtocolCatalogKey(commandCodeTestAccount(22), base+"/", url)) + + headers := modelProtocolCatalogHeaders(official, url) + require.Equal(t, "Bearer user_test_key", headers.Get("Authorization")) + require.Equal(t, CodexCanonicalUserAgent(), headers.Get("User-Agent"), "official host gets the canonical UA like forwarding") + + custom := commandCodeTestAccount(23) + custom.Credentials["api_base_urls"] = map[string]any{APIProtocolChatCompletions: "https://relay.example/v1"} + custom.Credentials["header_override_enabled"] = true + custom.Credentials["header_overrides"] = map[string]any{"X-Tenant": "t-1"} + customBase := custom.GetCNProtocolBaseURL(APIProtocolChatCompletions) + customURL := buildOpenAIModelsURL(customBase) + require.Equal(t, customURL+"#account=23", modelProtocolCatalogKey(custom, customBase, customURL)) + headers = modelProtocolCatalogHeaders(custom, customURL) + require.Equal(t, "t-1", getHeaderRaw(headers, "x-tenant")) + require.Empty(t, headers.Get("User-Agent")) +} + +func TestCommandCodeModelProtocolSetUsesCatalog(t *testing.T) { + account := commandCodeTestAccount(11) + both := []string{APIProtocolChatCompletions, APIProtocolResponses} + + // 目录声明两种协议:入站同协议直通;Anthropic 入站走首选(内置规则没有命中时为 Chat Completions)。 + require.Equal(t, APIProtocolResponses, resolveUpstreamProtocol(account, APIProtocolResponses, "deepseek/deepseek-v4-flash", both)) + require.Equal(t, APIProtocolChatCompletions, resolveUpstreamProtocol(account, APIProtocolChatCompletions, "deepseek/deepseek-v4-flash", both)) + require.Equal(t, APIProtocolChatCompletions, resolveUpstreamProtocol(account, APIProtocolAnthropic, "deepseek/deepseek-v4-flash", both)) + // GPT 的首选沿用内置规则(Responses)。 + require.Equal(t, APIProtocolResponses, resolveUpstreamProtocol(account, APIProtocolAnthropic, "gpt-5.5", both)) + // 目录只声明 Chat Completions:Responses 入站也转换到 Chat Completions。 + require.Equal(t, APIProtocolChatCompletions, resolveUpstreamProtocol(account, APIProtocolResponses, "deepseek/deepseek-v4-flash-fast", []string{APIProtocolChatCompletions})) + require.Equal(t, APIProtocolAnthropic, resolveUpstreamProtocol(account, APIProtocolResponses, "claude-sonnet-4-6", []string{APIProtocolAnthropic})) + + // 账号规则命中时优先于目录;已配置但未命中时仍使用目录。 + account.Credentials["protocol_rules"] = []any{map[string]any{"pattern": "deepseek/*", "protocol": APIProtocolChatCompletions}} + require.Equal(t, APIProtocolChatCompletions, resolveUpstreamProtocol(account, APIProtocolResponses, "deepseek/deepseek-v4-flash", both)) + require.Equal(t, APIProtocolResponses, resolveUpstreamProtocol(account, APIProtocolResponses, "zai-org/glm-5.3", both)) + + // 显式协议优先于一切。 + account.Credentials["api_protocol"] = APIProtocolChatCompletions + require.Equal(t, APIProtocolChatCompletions, resolveUpstreamProtocol(account, APIProtocolResponses, "zai-org/glm-5.3", both)) +} + +func TestProtocolRulesWithProtocolSets(t *testing.T) { + credentials := map[string]any{"protocol_rules": []any{ + map[string]any{"pattern": " GPT-* ", "protocol": APIProtocolResponses, "protocols": []any{APIProtocolChatCompletions, APIProtocolResponses, APIProtocolChatCompletions}}, + map[string]any{"pattern": "qwen*", "protocols": []any{APIProtocolAnthropic}}, + map[string]any{"pattern": "glm-*", "protocol": APIProtocolChatCompletions}, + }} + require.NoError(t, NormalizeProtocolRulesCredentials(credentials)) + require.Equal(t, []any{ + map[string]any{"pattern": "gpt-*", "protocol": APIProtocolResponses, "protocols": []any{APIProtocolResponses, APIProtocolChatCompletions}}, + map[string]any{"pattern": "qwen*", "protocol": APIProtocolAnthropic}, + map[string]any{"pattern": "glm-*", "protocol": APIProtocolChatCompletions}, + }, credentials["protocol_rules"]) + + bad := map[string]any{"protocol_rules": []any{map[string]any{"pattern": "gpt-*", "protocols": []any{"responses", "bogus"}}}} + require.Error(t, NormalizeProtocolRulesCredentials(bad)) + + // 单一协议规则(OpenCode)不受影响:与入站无关。 + openCode := &Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "k"}} + for _, inbound := range []string{APIProtocolChatCompletions, APIProtocolResponses, APIProtocolAnthropic} { + require.Equal(t, APIProtocolResponses, resolveUpstreamProtocol(openCode, inbound, "gpt-5.5", nil), inbound) + require.Equal(t, APIProtocolChatCompletions, resolveUpstreamProtocol(openCode, inbound, "glm-5.3", nil), inbound) + } +} + +// 经真实入口验证:目录声明模型支持入站协议时原样直通,不做协议转换。 +func TestCommandCodeGatewayPassesThroughCatalogProtocols(t *testing.T) { + type observation struct { + url string + body []byte + } + forward := func(t *testing.T, ingress routingMatrixIngress, model string, catalog map[string][]string) observation { + t.Helper() + base := fmt.Sprintf("http://cc-%s-%d.example", strings.ReplaceAll(t.Name(), "/", "-"), time.Now().UnixNano()) + account := commandCodeTestAccount(12) + account.Credentials["api_base_urls"] = map[string]any{ + APIProtocolChatCompletions: base + "/provider/v1", + APIProtocolResponses: base + "/provider/v1", + APIProtocolAnthropic: base + "/provider", + } + url := buildOpenAIModelsURL(base + "/provider/v1") + key := modelProtocolCatalogKey(account, base+"/provider/v1", url) + if catalog != nil { + upstreamModelProtocols.store(key, catalog, nil, time.Now()) + } else { + // 目录不可用(退避中):不触发刷新,回落内置规则。 + upstreamModelProtocols.store(key, nil, errors.New("unavailable"), time.Now()) + } + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + body := routingMatrixCase{ingress: ingress, model: model}.body() + _ = ingress.forward(svc, adaptiveProtocolTestContext(ingress.path, body), account, body) + require.NotEmpty(t, upstream.requests) + return observation{url: upstream.requests[len(upstream.requests)-1].URL.String(), body: upstream.lastBody} + } + ingresses := map[string]routingMatrixIngress{} + for _, ingress := range routingMatrixIngresses() { + ingresses[ingress.name] = ingress + } + catalog := map[string][]string{ + "deepseek/deepseek-v4-flash": {APIProtocolChatCompletions, APIProtocolResponses}, + "deepseek/deepseek-v4-flash-fast": {APIProtocolChatCompletions}, + "gpt-5.5": {APIProtocolChatCompletions, APIProtocolResponses}, + } + + obs := forward(t, ingresses["responses"], "deepseek/deepseek-v4-flash", catalog) + require.True(t, strings.HasSuffix(obs.url, "/provider/v1/responses"), obs.url) + require.True(t, gjson.GetBytes(obs.body, "input").Exists(), "Responses body passes through unconverted") + + obs = forward(t, ingresses["responses"], "deepseek/deepseek-v4-flash", nil) + require.True(t, strings.HasSuffix(obs.url, "/provider/v1/chat/completions"), obs.url) + require.True(t, gjson.GetBytes(obs.body, "messages").Exists()) + + obs = forward(t, ingresses["responses"], "deepseek/deepseek-v4-flash-fast", catalog) + require.True(t, strings.HasSuffix(obs.url, "/provider/v1/chat/completions"), obs.url) + + obs = forward(t, ingresses["chat"], "gpt-5.5", catalog) + require.True(t, strings.HasSuffix(obs.url, "/provider/v1/chat/completions"), obs.url) + require.True(t, gjson.GetBytes(obs.body, "messages").Exists(), "Chat body passes through unconverted") +} + +// 目录缺失时网关拉取目录,首个请求即按目录分流;自定义上游按账号各自拉取,并带上 +// 账号的请求头覆写。 +func TestCommandCodeGatewayFetchesModelCatalog(t *testing.T) { + base := fmt.Sprintf("http://cc-fetch-%d.example/provider/v1", time.Now().UnixNano()) + newAccount := func(id int64) *Account { + account := commandCodeTestAccount(id) + account.Credentials["api_base_urls"] = map[string]any{ + APIProtocolChatCompletions: base, + APIProtocolResponses: base, + APIProtocolAnthropic: strings.TrimSuffix(base, "/v1"), + } + account.Credentials["header_override_enabled"] = true + account.Credentials["header_overrides"] = map[string]any{"X-Tenant": fmt.Sprintf("t-%d", id)} + return account + } + upstream := &commandCodeAlphaUpstream{responses: map[string]commandCodeAlphaResponse{ + "/provider/v1/models": {status: 200, body: commandCodeModelsSample}, + }} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + both := []string{APIProtocolChatCompletions, APIProtocolResponses} + + first, second := newAccount(13), newAccount(14) + require.Equal(t, both, svc.modelCatalogProtocols(first, "deepseek/deepseek-v4-flash")) + require.Equal(t, both, svc.modelCatalogProtocols(first, "deepseek/deepseek-v4-flash")) + require.Equal(t, both, svc.modelCatalogProtocols(second, "deepseek/deepseek-v4-flash")) + + upstream.mu.Lock() + defer upstream.mu.Unlock() + require.Len(t, upstream.requests, 2, "one fetch per account on a custom upstream") + for i, account := range []*Account{first, second} { + req := upstream.requests[i] + require.Equal(t, "/provider/v1/models", req.URL.Path) + require.Equal(t, "Bearer user_test_key", req.Header.Get("Authorization")) + require.Equal(t, fmt.Sprintf("t-%d", account.ID), getHeaderRaw(req.Header, "x-tenant")) + } +} diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 66c2471ae7..d6dc47be65 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -121,14 +121,14 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( // 维持原样把请求体交给所选路径。 convertResponsesShape := isResponsesShape && (account.routesByModel() || account.IsAdaptiveAPIProtocol()) - // 上游协议统一由 resolveUpstreamProtocol 判定。Anthropic 分流必须先于 + // 上游协议统一由 resolveUpstreamProtocol 判定(按模型分流时带上游模型目录)。Anthropic 分流必须先于 // ShouldUseResponsesAPI:Anthropic 协议账号经 probe 落标 // openai_responses_supported=false,否则会命中 CC 直转。 routingModel := upstreamRoutingModel(account, body, defaultMappedModel) if account.IsOpenCodeGo() && IsOpenCodeUnsupportedModel(routingModel) { return nil, writeOpenCodeUnsupportedModelError(c, false, routingModel) } - switch resolveUpstreamProtocol(account, inbound, routingModel) { + switch s.resolveUpstreamProtocolFor(account, inbound, routingModel) { case APIProtocolAnthropic: if convertResponsesShape { return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, "") diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 3cf10c5c73..492880ae1e 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -177,13 +177,13 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco originalModel = reqModel } - // 上游协议统一由 resolveUpstreamProtocol 判定。OpenAI API Key 账号只会落到 + // 上游协议统一由 resolveUpstreamProtocol 判定(按模型分流时带上游模型目录)。OpenAI API Key 账号只会落到 // Responses / Chat Completions,上面的归一化对两条路径都生效。 routingModel := upstreamRoutingModel(account, body, "") if account.IsOpenCodeGo() && IsOpenCodeUnsupportedModel(routingModel) { return nil, writeOpenCodeUnsupportedModelError(c, false, routingModel) } - switch resolveUpstreamProtocol(account, APIProtocolResponses, routingModel) { + switch s.resolveUpstreamProtocolFor(account, APIProtocolResponses, routingModel) { case APIProtocolAnthropic: // Responses 客户端 × Anthropic 上游:转成 Anthropic 请求走原生端点。不能落到 // raw-CC 分支——其 URL 构造会把 anthropic base 当 CC base 用。 diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 9594a79a73..6448ba6bb3 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -53,14 +53,14 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( return nil, err } - // 上游协议统一由 resolveUpstreamProtocol 判定。Anthropic 分流必须先于 + // 上游协议统一由 resolveUpstreamProtocol 判定(按模型分流时带上游模型目录)。Anthropic 分流必须先于 // ShouldUseResponsesAPI:Anthropic 协议账号经 probe 落标 // openai_responses_supported=false,否则会命中 CC 直转。 routingModel := upstreamRoutingModel(account, body, defaultMappedModel) if account.IsOpenCodeGo() && IsOpenCodeUnsupportedModel(routingModel) { return nil, writeOpenCodeUnsupportedModelError(c, true, routingModel) } - switch resolveUpstreamProtocol(account, APIProtocolAnthropic, routingModel) { + switch s.resolveUpstreamProtocolFor(account, APIProtocolAnthropic, routingModel) { case APIProtocolAnthropic: // 上游为供应商原生 Anthropic 端点:/v1/messages 零转换直通(仅模型名映射 + // 少量 body 清洗),完整保留 thinking / tool_use / cache 语义。 diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native.go b/backend/internal/service/openai_gateway_messages_anthropic_native.go index eb061c9750..12a3344068 100644 --- a/backend/internal/service/openai_gateway_messages_anthropic_native.go +++ b/backend/internal/service/openai_gateway_messages_anthropic_native.go @@ -133,11 +133,17 @@ func (s *OpenAIGatewayService) nativeAnthropicTargetURL(account *Account) (strin if err != nil { return "", fmt.Errorf("invalid base_url: %w", err) } - if account.IsOpenCodeGo() { - // OpenCode Go 的 Chat Completions base 带 /v1;用版本感知拼接避免 /v1/v1/messages。 - return buildOpenAIEndpointURL(validatedURL, "/v1/messages"), nil + return nativeAnthropicMessagesURL(account, validatedURL), nil +} + +// nativeAnthropicMessagesURL 由已校验的 Anthropic 协议基址拼出 messages 端点,转发与 +// 连接测试共用。按模型分流的聚合平台(OpenCode、Command Code 等)的基址可能沿用带 /v1 +// 的 Chat Completions 基址,用版本感知拼接避免 /v1/v1/messages;其余供应商朴素拼接。 +func nativeAnthropicMessagesURL(account *Account, validatedBaseURL string) string { + if account.routesByModel() { + return buildOpenAIEndpointURL(validatedBaseURL, "/v1/messages") } - return strings.TrimRight(validatedURL, "/") + "/v1/messages", nil + return strings.TrimRight(validatedBaseURL, "/") + "/v1/messages" } func resolveMappedUpstreamModel(account *Account, body []byte, defaultMappedModel string) string { diff --git a/backend/internal/service/opencode_go.go b/backend/internal/service/opencode_go.go index d052451a56..248ced9589 100644 --- a/backend/internal/service/opencode_go.go +++ b/backend/internal/service/opencode_go.go @@ -3,6 +3,7 @@ package service import ( "fmt" "net/http" + "slices" "strings" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" @@ -106,6 +107,21 @@ func writeOpenCodeUnsupportedModelError(c *gin.Context, isAnthropic bool, model type ProtocolRule struct { Pattern string `json:"pattern"` Protocol string `json:"protocol"` + // Protocols 是命中模型支持的全部原生协议,首项即 Protocol(首选);为空表示只支持 + // Protocol。入站协议在其中时同协议直通,否则走首选协议(见 resolveModelRoutedProtocolFor)。 + Protocols []string `json:"protocols,omitempty"` +} + +// normalizeProtocolSet 返回以 preferred 开头、去重且只含原生协议的协议集合。 +func normalizeProtocolSet(preferred string, protocols []string) []string { + set := []string{preferred} + for _, protocol := range protocols { + if !isNativeUpstreamProtocol(protocol) || slices.Contains(set, protocol) { + continue + } + set = append(set, protocol) + } + return set } // DefaultOpenCodeGoProtocolRules is the built-in adaptive routing table for Go. @@ -157,23 +173,25 @@ func protocolRulePatternMatches(pattern, model string) bool { return model == pattern } +// matchProtocolRules 返回首条命中规则的首选协议;未命中时为 Chat Completions。 func matchProtocolRules(model string, rules []ProtocolRule) string { + if set, ok := matchProtocolRuleSet(model, rules); ok { + return set[0] + } + return APIProtocolChatCompletions +} + +// matchProtocolRuleSet 返回首条命中规则支持的协议集合(首项为首选);未命中时 ok=false。 +func matchProtocolRuleSet(model string, rules []ProtocolRule) ([]string, bool) { for _, rule := range rules { if !isNativeUpstreamProtocol(rule.Protocol) { continue } if protocolRulePatternMatches(rule.Pattern, model) { - return rule.Protocol + return normalizeProtocolSet(rule.Protocol, rule.Protocols), true } } - return APIProtocolChatCompletions -} - -// OpenCodeGoModelProtocol 返回内置默认表下模型对应的原生上游协议。 -// 未命中时为 Chat Completions。账号自定义 protocol_rules 见 -// ResolveOpenCodeGoUpstreamProtocol。 -func OpenCodeGoModelProtocol(model string) string { - return matchProtocolRules(model, DefaultOpenCodeGoProtocolRules()) + return nil, false } func (a *Account) configuredProtocolRules() ([]ProtocolRule, bool) { @@ -211,15 +229,54 @@ func parseProtocolRules(raw any) ([]ProtocolRule, error) { if err != nil { return nil, fmt.Errorf("protocol_rules[%d]: %w", i, err) } + protocols, err := protocolRuleProtocols(entry["protocols"]) + if err != nil { + return nil, fmt.Errorf("protocol_rules[%d]: %w", i, err) + } protocol = strings.TrimSpace(protocol) + if protocol == "" && len(protocols) > 0 { + protocol = protocols[0] + } if !isNativeUpstreamProtocol(protocol) { return nil, fmt.Errorf("protocol_rules[%d]: protocol must be chat_completions, anthropic, or responses", i) } - rules = append(rules, ProtocolRule{Pattern: pattern, Protocol: protocol}) + rule := ProtocolRule{Pattern: pattern, Protocol: protocol} + if set := normalizeProtocolSet(protocol, protocols); len(set) > 1 { + rule.Protocols = set + } + rules = append(rules, rule) } return rules, nil } +// protocolRuleProtocols 解析规则的 protocols 列表(可缺省);其中每一项都须为原生协议。 +func protocolRuleProtocols(raw any) ([]string, error) { + if raw == nil { + return nil, nil + } + var items []any + switch values := raw.(type) { + case []any: + items = values + case []string: + for _, value := range values { + items = append(items, value) + } + default: + return nil, fmt.Errorf("protocols must be an array") + } + out := make([]string, 0, len(items)) + for _, item := range items { + protocol, _ := item.(string) + protocol = strings.TrimSpace(protocol) + if !isNativeUpstreamProtocol(protocol) { + return nil, fmt.Errorf("protocols must only contain chat_completions, anthropic, or responses") + } + out = append(out, protocol) + } + return out, nil +} + func protocolRuleItems(raw any) ([]any, error) { switch items := raw.(type) { case []any: @@ -269,10 +326,18 @@ func NormalizeProtocolRulesCredentials(credentials map[string]any) error { } encoded := make([]any, 0, len(rules)) for _, rule := range rules { - encoded = append(encoded, map[string]any{ + item := map[string]any{ "pattern": rule.Pattern, "protocol": rule.Protocol, - }) + } + if len(rule.Protocols) > 1 { + protocols := make([]any, 0, len(rule.Protocols)) + for _, protocol := range rule.Protocols { + protocols = append(protocols, protocol) + } + item["protocols"] = protocols + } + encoded = append(encoded, item) } credentials[protocolRulesCredentialKey] = encoded return nil @@ -305,31 +370,6 @@ func (a *Account) IsMultiProtocolAPIKey() bool { return a != nil && IsMultiProtocolAPIKeyProvider(a.Platform) } -// openCodeGoNativeProtocol 返回 OpenCode Go 实际上游协议。 -// 规则未命中、空值或未知协议一律兜底 Chat Completions,避免落入 Responses 转换链。 -func openCodeGoNativeProtocol(account *Account, model string) string { - if account == nil { - return APIProtocolChatCompletions - } - switch proto := account.ResolveOpenCodeGoUpstreamProtocol(model); proto { - case APIProtocolAnthropic, APIProtocolResponses: - return proto - default: - return APIProtocolChatCompletions - } -} - -// ResolveOpenCodeGoUpstreamProtocol 按账号协议配置与模型规则决定上游协议。 -// 显式 pinned 协议优先;adaptive(默认)先走 credentials.protocol_rules, -// 未配置时回落内置默认表;已配置但未命中则走 Chat Completions。 -// 规则见 resolveModelRoutedProtocol。 -func (a *Account) ResolveOpenCodeGoUpstreamProtocol(model string) string { - if a == nil || !a.IsOpenCodeGo() { - return "" - } - return a.resolveModelRoutedProtocol(model) -} - // openCodeGoQuotaURL 根据 base_url 解析 OpenCode Go 额度端点。 // /zen/go/v1(Chat 协议默认,DefaultOpenCodeGoBaseURL)与 /zen/go // (Anthropic 协议默认,DefaultOpenCodeGoAnthropicBaseURL)两种 base 统一 diff --git a/backend/internal/service/opencode_go_test.go b/backend/internal/service/opencode_go_test.go index a8dbce1003..c3bbc2558a 100644 --- a/backend/internal/service/opencode_go_test.go +++ b/backend/internal/service/opencode_go_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestOpenCodeGoModelProtocol(t *testing.T) { +func TestOpenCodeGoDefaultProtocolRules(t *testing.T) { t.Parallel() cases := []struct { model string @@ -28,40 +28,40 @@ func TestOpenCodeGoModelProtocol(t *testing.T) { {"unknown-model", APIProtocolChatCompletions}, } for _, tc := range cases { - require.Equal(t, tc.want, OpenCodeGoModelProtocol(tc.model), "model=%s", tc.model) + require.Equal(t, tc.want, matchProtocolRules(tc.model, DefaultOpenCodeGoProtocolRules()), "model=%s", tc.model) } } -func TestResolveOpenCodeGoUpstreamProtocol(t *testing.T) { +func TestOpenCodeGoModelRoutedProtocol(t *testing.T) { t.Parallel() adaptive := &Account{Platform: PlatformOpenCodeGo, Credentials: map[string]any{"api_protocol": APIProtocolAdaptive}} - require.Equal(t, APIProtocolAnthropic, adaptive.ResolveOpenCodeGoUpstreamProtocol("minimax-m3")) - require.Equal(t, APIProtocolResponses, adaptive.ResolveOpenCodeGoUpstreamProtocol("grok-4.6")) - require.Equal(t, APIProtocolChatCompletions, adaptive.ResolveOpenCodeGoUpstreamProtocol("glm-5.3")) + require.Equal(t, APIProtocolAnthropic, adaptive.resolveModelRoutedProtocol("minimax-m3")) + require.Equal(t, APIProtocolResponses, adaptive.resolveModelRoutedProtocol("grok-4.6")) + require.Equal(t, APIProtocolChatCompletions, adaptive.resolveModelRoutedProtocol("glm-5.3")) pinned := &Account{Platform: PlatformOpenCodeGo, Credentials: map[string]any{"api_protocol": APIProtocolChatCompletions}} - require.Equal(t, APIProtocolChatCompletions, pinned.ResolveOpenCodeGoUpstreamProtocol("minimax-m3")) + require.Equal(t, APIProtocolChatCompletions, pinned.resolveModelRoutedProtocol("minimax-m3")) empty := &Account{Platform: PlatformOpenCodeGo} - require.Equal(t, APIProtocolResponses, empty.ResolveOpenCodeGoUpstreamProtocol("gpt-5.6-luna")) + require.Equal(t, APIProtocolResponses, empty.resolveModelRoutedProtocol("gpt-5.6-luna")) require.Equal(t, AccountModeGo, empty.GetOpenCodeAccountMode()) require.True(t, empty.IsOpenCodeGoPlan()) zen := &Account{Platform: PlatformOpenCodeGo, Credentials: map[string]any{"account_mode": AccountModeZen, "api_protocol": APIProtocolAdaptive}} - require.Equal(t, APIProtocolChatCompletions, zen.ResolveOpenCodeGoUpstreamProtocol("minimax-m3")) - require.Equal(t, APIProtocolAnthropic, zen.ResolveOpenCodeGoUpstreamProtocol("claude-opus-4-6")) - require.Equal(t, APIProtocolChatCompletions, zen.ResolveOpenCodeGoUpstreamProtocol("qwen3.8-max")) - require.Equal(t, APIProtocolAnthropic, zen.ResolveOpenCodeGoUpstreamProtocol("qwen3.8-flash")) - require.Equal(t, APIProtocolAnthropic, zen.ResolveOpenCodeGoUpstreamProtocol("qwen3.7-max")) + require.Equal(t, APIProtocolChatCompletions, zen.resolveModelRoutedProtocol("minimax-m3")) + require.Equal(t, APIProtocolAnthropic, zen.resolveModelRoutedProtocol("claude-opus-4-6")) + require.Equal(t, APIProtocolChatCompletions, zen.resolveModelRoutedProtocol("qwen3.8-max")) + require.Equal(t, APIProtocolAnthropic, zen.resolveModelRoutedProtocol("qwen3.8-flash")) + require.Equal(t, APIProtocolAnthropic, zen.resolveModelRoutedProtocol("qwen3.7-max")) require.Equal(t, DefaultOpenCodeZenBaseURL, zen.GetOpenAIBaseURL()) goAccount := &Account{Platform: PlatformOpenCodeGo, Credentials: map[string]any{"account_mode": AccountModeGo, "api_protocol": APIProtocolAdaptive}} - require.Equal(t, APIProtocolAnthropic, goAccount.ResolveOpenCodeGoUpstreamProtocol("qwen3.8-max")) + require.Equal(t, APIProtocolAnthropic, goAccount.resolveModelRoutedProtocol("qwen3.8-max")) - require.Equal(t, "", (&Account{Platform: PlatformKimi}).ResolveOpenCodeGoUpstreamProtocol("glm-5.3")) + require.False(t, (&Account{Platform: PlatformKimi}).routesByModel()) } -func TestResolveOpenCodeGoUpstreamProtocolUsesAccountRules(t *testing.T) { +func TestOpenCodeGoModelRoutedProtocolUsesAccountRules(t *testing.T) { t.Parallel() account := &Account{ Platform: PlatformOpenCodeGo, @@ -74,14 +74,14 @@ func TestResolveOpenCodeGoUpstreamProtocolUsesAccountRules(t *testing.T) { }, }, } - require.Equal(t, APIProtocolChatCompletions, account.ResolveOpenCodeGoUpstreamProtocol("grok-4.6")) - require.Equal(t, APIProtocolResponses, account.ResolveOpenCodeGoUpstreamProtocol("deepseek-v4-flash")) - require.Equal(t, APIProtocolAnthropic, account.ResolveOpenCodeGoUpstreamProtocol("qwen3.8-max")) - require.Equal(t, APIProtocolChatCompletions, account.ResolveOpenCodeGoUpstreamProtocol("glm-5.3")) - require.Equal(t, APIProtocolChatCompletions, account.ResolveOpenCodeGoUpstreamProtocol("minimax-m3")) + require.Equal(t, APIProtocolChatCompletions, account.resolveModelRoutedProtocol("grok-4.6")) + require.Equal(t, APIProtocolResponses, account.resolveModelRoutedProtocol("deepseek-v4-flash")) + require.Equal(t, APIProtocolAnthropic, account.resolveModelRoutedProtocol("qwen3.8-max")) + require.Equal(t, APIProtocolChatCompletions, account.resolveModelRoutedProtocol("glm-5.3")) + require.Equal(t, APIProtocolChatCompletions, account.resolveModelRoutedProtocol("minimax-m3")) } -func TestResolveOpenCodeGoUpstreamProtocolEmptyRulesAreAllChatCompletions(t *testing.T) { +func TestOpenCodeGoModelRoutedProtocolEmptyRulesAreAllChatCompletions(t *testing.T) { t.Parallel() account := &Account{ Platform: PlatformOpenCodeGo, @@ -89,11 +89,11 @@ func TestResolveOpenCodeGoUpstreamProtocolEmptyRulesAreAllChatCompletions(t *tes protocolRulesCredentialKey: []any{}, }, } - require.Equal(t, APIProtocolChatCompletions, account.ResolveOpenCodeGoUpstreamProtocol("grok-4.6")) - require.Equal(t, APIProtocolChatCompletions, account.ResolveOpenCodeGoUpstreamProtocol("minimax-m3")) + require.Equal(t, APIProtocolChatCompletions, account.resolveModelRoutedProtocol("grok-4.6")) + require.Equal(t, APIProtocolChatCompletions, account.resolveModelRoutedProtocol("minimax-m3")) } -func TestResolveOpenCodeGoUpstreamProtocolFirstMatchWins(t *testing.T) { +func TestOpenCodeGoModelRoutedProtocolFirstMatchWins(t *testing.T) { t.Parallel() account := &Account{ Platform: PlatformOpenCodeGo, @@ -104,11 +104,11 @@ func TestResolveOpenCodeGoUpstreamProtocolFirstMatchWins(t *testing.T) { }, }, } - require.Equal(t, APIProtocolChatCompletions, account.ResolveOpenCodeGoUpstreamProtocol("gpt-5.6-luna")) - require.Equal(t, APIProtocolResponses, account.ResolveOpenCodeGoUpstreamProtocol("gpt-5.4")) + require.Equal(t, APIProtocolChatCompletions, account.resolveModelRoutedProtocol("gpt-5.6-luna")) + require.Equal(t, APIProtocolResponses, account.resolveModelRoutedProtocol("gpt-5.4")) } -func TestOpenCodeGoNativeProtocolUnmatchedFallsBackToChatCompletions(t *testing.T) { +func TestOpenCodeGoUnmatchedFallsBackToChatCompletions(t *testing.T) { t.Parallel() withRules := &Account{ Platform: PlatformOpenCodeGo, @@ -121,17 +121,16 @@ func TestOpenCodeGoNativeProtocolUnmatchedFallsBackToChatCompletions(t *testing. }, }, } - require.Equal(t, APIProtocolChatCompletions, openCodeGoNativeProtocol(withRules, "deepseek-v4-flash")) - require.Equal(t, APIProtocolChatCompletions, openCodeGoNativeProtocol(withRules, "glm-5.3")) - require.Equal(t, APIProtocolChatCompletions, openCodeGoNativeProtocol(withRules, "omen-alpha")) - require.Equal(t, APIProtocolChatCompletions, openCodeGoNativeProtocol(withRules, "kimi-k3")) - require.Equal(t, APIProtocolChatCompletions, openCodeGoNativeProtocol(withRules, "unknown-model")) - require.Equal(t, APIProtocolResponses, openCodeGoNativeProtocol(withRules, "grok-4.6")) - require.Equal(t, APIProtocolAnthropic, openCodeGoNativeProtocol(withRules, "qwen3.8-flash")) + require.Equal(t, APIProtocolChatCompletions, withRules.resolveModelRoutedProtocol("deepseek-v4-flash")) + require.Equal(t, APIProtocolChatCompletions, withRules.resolveModelRoutedProtocol("glm-5.3")) + require.Equal(t, APIProtocolChatCompletions, withRules.resolveModelRoutedProtocol("omen-alpha")) + require.Equal(t, APIProtocolChatCompletions, withRules.resolveModelRoutedProtocol("kimi-k3")) + require.Equal(t, APIProtocolChatCompletions, withRules.resolveModelRoutedProtocol("unknown-model")) + require.Equal(t, APIProtocolResponses, withRules.resolveModelRoutedProtocol("grok-4.6")) + require.Equal(t, APIProtocolAnthropic, withRules.resolveModelRoutedProtocol("qwen3.8-flash")) defaults := &Account{Platform: PlatformOpenCodeGo} - require.Equal(t, APIProtocolChatCompletions, openCodeGoNativeProtocol(defaults, "deepseek-v4-flash")) - require.Equal(t, APIProtocolChatCompletions, openCodeGoNativeProtocol(nil, "grok-4.6")) + require.Equal(t, APIProtocolChatCompletions, defaults.resolveModelRoutedProtocol("deepseek-v4-flash")) } func TestParseOpenCodeGoProtocolRulesAcceptsTypedMaps(t *testing.T) { @@ -160,9 +159,9 @@ func TestShouldForwardOpenAIResponsesViaRawChatCompletions_OpenCodeGoIgnoresProb }, } require.False(t, shouldForwardOpenAIResponsesViaRawChatCompletions(account)) - require.Equal(t, APIProtocolResponses, account.ResolveOpenCodeGoUpstreamProtocol("grok-4.6")) - require.Equal(t, APIProtocolResponses, account.ResolveOpenCodeGoUpstreamProtocol("gpt-5.6-luna")) - require.Equal(t, APIProtocolAnthropic, account.ResolveOpenCodeGoUpstreamProtocol("qwen3.8-flash")) + require.Equal(t, APIProtocolResponses, account.resolveModelRoutedProtocol("grok-4.6")) + require.Equal(t, APIProtocolResponses, account.resolveModelRoutedProtocol("gpt-5.6-luna")) + require.Equal(t, APIProtocolAnthropic, account.resolveModelRoutedProtocol("qwen3.8-flash")) } func TestStampOpenAIResponsesUpstreamEndpoint(t *testing.T) { diff --git a/backend/internal/service/opencode_unsupported_models_test.go b/backend/internal/service/opencode_unsupported_models_test.go index 56894ab308..d6d6518cb4 100644 --- a/backend/internal/service/opencode_unsupported_models_test.go +++ b/backend/internal/service/opencode_unsupported_models_test.go @@ -128,11 +128,11 @@ func TestAccountTestService_OpenCodeUnsupportedModelReturnsError(t *testing.T) { } svc := &AccountTestService{} - err := svc.testOpenCodeGoAccountConnection(c, account, "gemini-3.8-flash", "hi") + err := svc.testModelRoutedAccountConnection(c, account, "gemini-3.8-flash", "hi") require.Error(t, err) require.Contains(t, err.Error(), "not supported on OpenCode standard gateway") - err = svc.testOpenCodeGoAccountConnection(c, account, "jev-1.13", "hi") + err = svc.testModelRoutedAccountConnection(c, account, "jev-1.13", "hi") require.Error(t, err) require.Contains(t, err.Error(), "not supported on OpenCode standard gateway") } diff --git a/backend/internal/service/platform_catalog.go b/backend/internal/service/platform_catalog.go index ddd0dd7166..7ef15368c5 100644 --- a/backend/internal/service/platform_catalog.go +++ b/backend/internal/service/platform_catalog.go @@ -28,6 +28,8 @@ type ProviderProfileInfo struct { DefaultMode string `json:"default_mode"` Routing ProviderRouting `json:"routing"` ResponsesPath string `json:"responses_path,omitempty"` + // ModelCatalog 表示模型支持的协议由上游 /models 自动获取(账号规则仍优先)。 + ModelCatalog bool `json:"model_catalog,omitempty"` // Modes 以默认模式开头,其余按模式名排序。 Modes []ProviderModeInfo `json:"modes"` } @@ -77,6 +79,7 @@ func providerProfileInfo(profile *ProviderProfile) *ProviderProfileInfo { DefaultMode: profile.DefaultMode, Routing: routing, ResponsesPath: profile.ResponsesPath, + ModelCatalog: profile.ModelCatalog, Modes: make([]ProviderModeInfo, 0, len(modes)), } for _, mode := range modes { @@ -88,8 +91,20 @@ func providerProfileInfo(profile *ProviderProfile) *ProviderProfileInfo { info.Modes = append(info.Modes, ProviderModeInfo{ Mode: mode, BaseURLs: baseURLs, - ProtocolRules: append([]ProtocolRule(nil), endpoints.ProtocolRules...), + ProtocolRules: cloneProtocolRules(endpoints.ProtocolRules), }) } return info } + +func cloneProtocolRules(rules []ProtocolRule) []ProtocolRule { + if rules == nil { + return nil + } + out := make([]ProtocolRule, len(rules)) + for i, rule := range rules { + out[i] = rule + out[i].Protocols = append([]string(nil), rule.Protocols...) + } + return out +} diff --git a/backend/internal/service/platform_list_test.go b/backend/internal/service/platform_list_test.go index c06f60fcf8..4612150ab6 100644 --- a/backend/internal/service/platform_list_test.go +++ b/backend/internal/service/platform_list_test.go @@ -9,18 +9,21 @@ import ( "github.com/stretchr/testify/require" ) -// 以下为重构前各处手写的平台列表 / switch,作为平台清单派生结果的等价基准。 +// 以下为重构前各处手写的平台列表 / switch,作为平台清单派生结果的等价基准; +// 重构后新登记的平台(Command Code)按同类平台(OpenCode)的位置补入。 var ( legacyAllPlatforms = []string{ PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe, + PlatformCommandCode, } legacySchedulerSnapshotPlatforms = []string{ PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe, + PlatformCommandCode, } legacyCompositeMatchingPlatforms = legacySchedulerSnapshotPlatforms - legacyMultiProtocolProviders = []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo} + legacyMultiProtocolProviders = []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformCommandCode} platformProbeValues = append(append([]string{}, legacyAllPlatforms...), PlatformComposite, "", "moonshot", "Kimi", "openai ", "glm", "bogus") platformProbeAccountTypes = []string{AccountTypeAPIKey, AccountTypeOAuth, AccountTypeSetupToken, AccountTypeUpstream, ""} ) @@ -34,12 +37,12 @@ func legacyIsCNProvider(platform string) bool { } func legacyIsOpenAICompatible(platform string) bool { - return platform == PlatformOpenAI || platform == PlatformGrok || legacyIsCNProvider(platform) || platform == PlatformOpenCodeGo + return platform == PlatformOpenAI || platform == PlatformGrok || legacyIsCNProvider(platform) || platform == PlatformOpenCodeGo || platform == PlatformCommandCode } func legacyNormalizeOpenAICompatiblePlatform(platform string) string { switch platform { - case PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo: + case PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformCommandCode: return platform } return PlatformOpenAI @@ -52,7 +55,7 @@ func legacyIsUpstreamBillingProbeIdentity(platform, accountType string) bool { switch platform { case PlatformOpenAI, PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, - PlatformTypeSafe: + PlatformTypeSafe, PlatformCommandCode: return true } return false @@ -60,7 +63,7 @@ func legacyIsUpstreamBillingProbeIdentity(platform, accountType string) bool { func legacyIsHeaderOverrideEligible(platform, accountType string) bool { switch platform { - case PlatformAnthropic, PlatformOpenAI, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo: + case PlatformAnthropic, PlatformOpenAI, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformCommandCode: return accountType == AccountTypeAPIKey case PlatformGrok: return accountType == AccountTypeAPIKey || accountType == AccountTypeOAuth @@ -123,7 +126,7 @@ 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) + require.Equal(t, legacyIsCNProvider(platform) || platform == PlatformOpenCodeGo || platform == PlatformCommandCode, account.IsMultiProtocolAPIKey(), platform) } var nilAccount *Account require.False(t, nilAccount.RoutesProtocolByInbound()) diff --git a/backend/internal/service/provider_profile.go b/backend/internal/service/provider_profile.go index c8ad645132..04e9c74f83 100644 --- a/backend/internal/service/provider_profile.go +++ b/backend/internal/service/provider_profile.go @@ -39,6 +39,12 @@ type ProviderProfile struct { // ResponsesPath 覆盖 Responses 端点路径(按 buildOpenAIEndpointURL 的版本感知 // 规则拼接),空值为 /v1/responses。对自定义 base_url 的账号同样生效。 ResponsesPath string + // DefaultTestModel 是管理端连接测试未指定模型时使用的模型;空值时按各协议的 + // 通用默认模型。 + DefaultTestModel string + // ModelCatalog 表示上游 {Chat Completions 基址}/models 为每个模型给出 + // supported_endpoints;按模型分流时据此判断模型支持哪些协议(见 model_protocol_catalog.go)。 + ModelCatalog bool } // Endpoints 返回指定接入模式的端点;未知模式回落 DefaultMode。 @@ -147,9 +153,10 @@ var providerProfiles = map[string]*ProviderProfile{ }, }, PlatformOpenCodeGo: { - Platform: PlatformOpenCodeGo, - DefaultMode: AccountModeGo, - Routing: ProviderRoutingByModel, + Platform: PlatformOpenCodeGo, + DefaultMode: AccountModeGo, + Routing: ProviderRoutingByModel, + DefaultTestModel: DefaultOpenCodeGoTestModel, Modes: map[string]ProviderEndpoints{ AccountModeGo: { BaseURLs: map[string]string{ @@ -169,6 +176,24 @@ var providerProfiles = map[string]*ProviderProfile{ }, }, }, + PlatformCommandCode: { + Platform: PlatformCommandCode, + DefaultMode: AccountModePayG, + Routing: ProviderRoutingByModel, + DefaultTestModel: DefaultCommandCodeTestModel, + ModelCatalog: true, + // 订阅套餐与 Provider 按量计费共用同一个 API Key 与端点,只有一个接入模式。 + Modes: map[string]ProviderEndpoints{ + AccountModePayG: { + BaseURLs: map[string]string{ + APIProtocolChatCompletions: DefaultCommandCodeBaseURL, + APIProtocolResponses: DefaultCommandCodeBaseURL, + APIProtocolAnthropic: DefaultCommandCodeAnthropicBaseURL, + }, + ProtocolRules: DefaultCommandCodeProtocolRules(), + }, + }, + }, } // LookupProviderProfile 返回 platform 对应的多协议供应商 profile;非此类平台返回 nil。 diff --git a/backend/internal/service/ratelimit_cn_providers.go b/backend/internal/service/ratelimit_cn_providers.go index bb222a9f9b..b25e2c39a7 100644 --- a/backend/internal/service/ratelimit_cn_providers.go +++ b/backend/internal/service/ratelimit_cn_providers.go @@ -175,12 +175,20 @@ func cnProviderQuotaSnapshotReset(account *Account, now time.Time) *time.Time { if account == nil || len(account.Extra) == 0 { return nil } - if !account.IsOpenCodeGo() && (!account.IsCNProvider() || !account.IsCodingPlan()) { + switch { + case account.IsOpenCodeGo(): + case account.IsCommandCode(): + // 有充值积分时上游跳过窗口检查,429 与窗口无关。 + if !commandCodeWindowsBinding(account.Extra) { + return nil + } + case account.IsCNProvider() && account.IsCodingPlan(): + default: return nil } provider := account.Platform suffixes := []string{cnExtraSuffix5hReset, cnExtraSuffixWeeklyReset} - if account.IsOpenCodeGo() { + if cnQuotaHasMonthlyWindow(provider) { suffixes = append(suffixes, cnExtraSuffixMonthlyReset) } var earliest *time.Time diff --git a/backend/internal/service/ratelimit_command_code.go b/backend/internal/service/ratelimit_command_code.go new file mode 100644 index 0000000000..4b8d95d01d --- /dev/null +++ b/backend/internal/service/ratelimit_command_code.go @@ -0,0 +1,174 @@ +package service + +import ( + "context" + "log/slog" + "net/http" + "regexp" + "strings" + "time" +) + +// Command Code 用量超限的响应式处理,依据官方错误说明 +// (https://commandcode.ai/docs/troubleshooting/errors): +// +// - Rate limit:短时间请求过多,很快恢复,走默认 429 逻辑; +// - Usage exceeded,三种来源: +// 1. 5 小时 / 每周滚动窗口用满,文案如 +// "You've reached your 5-hour usage limit. Resets in 2h 41m (3:00 PM)"; +// 2. 月度积分耗尽; +// 3. 组织为成员设的花费上限,分成员总额度与成员某个模型的额度两种,后者文案如 +// "You've reached the $10.00/mo spending limit your organization set for GLM-5. It resets in 3 days." +// +// 文档没有给出状态码与响应体结构,因此按文案分类,402 / 403 / 429 同样处理; +// 页面路径中的 usage_exceeded 可能是错误码,一并识别。未识别的错误交回默认逻辑。 +// +// 窗口用满写带原因的临时停调(而非 SetRateLimited):充值积分不受滚动窗口限制, +// 额度探测发现充值积分后只清除这一类停调(见 queryCommandCodeUsage)。 + +type commandCodeUsageErrorKind int + +const ( + commandCodeUsageErrorNone commandCodeUsageErrorKind = iota + // commandCodeUsageWindowLimit 滚动窗口用满:冷却到窗口重置。 + commandCodeUsageWindowLimit + // commandCodeUsageCreditsExhausted 积分耗尽:可恢复停调,积分恢复后由周期检测解除。 + commandCodeUsageCreditsExhausted + // commandCodeUsageSpendLimit 组织花费上限:模型级只限当前模型,成员总额度按账号冷却。 + commandCodeUsageSpendLimit +) + +const ( + // commandCodeUsageLimitReason 是窗口用满临时停调的原因前缀,充值后据此只清除这一类停调。 + commandCodeUsageLimitReason = "command_code_usage_limit" + commandCodeSpendLimitReason = "command_code_spend_limit" + // 从文案解析出的重置时长上限:窗口最长为每周,花费上限最长按月。 + commandCodeMaxWindowCooldown = 8 * 24 * time.Hour + commandCodeMaxSpendLimitCooldown = 32 * 24 * time.Hour + // commandCodeAccountSpendLimitRecheck 是账号级花费上限(或作用域不明)的最长冷却: + // 管理员可能随时调高上限,到期后由下一次请求重新确认。 + commandCodeAccountSpendLimitRecheck = time.Hour +) + +// commandCodeModelSpendLimitPattern 匹配组织为成员某个模型设的花费上限 +// ("... spending limit your organization set for GLM-5.");成员总额度没有 "for <模型>"。 +var commandCodeModelSpendLimitPattern = regexp.MustCompile(`(?i)spending limit your organization set for\s+([^.\s][^.]*)`) + +// commandCodeSpendLimitIsModelScoped 报告花费上限是否只针对单个模型。 +func commandCodeSpendLimitIsModelScoped(body []byte) bool { + match := commandCodeModelSpendLimitPattern.FindSubmatch(body) + if match == nil { + return false + } + target := strings.ToLower(strings.TrimSpace(string(match[1]))) + return target != "" && !strings.HasPrefix(target, "you") +} + +func classifyCommandCodeUsageError(body []byte) commandCodeUsageErrorKind { + text := strings.ToLower(string(body)) + switch { + case strings.Contains(text, "spending limit"): + return commandCodeUsageSpendLimit + case strings.Contains(text, "usage limit"): + return commandCodeUsageWindowLimit + case cnProviderResponseIndicatesInsufficientBalance(body), + strings.Contains(text, "insufficient credit"), + strings.Contains(text, "out of credits"), + strings.Contains(text, "usage_exceeded"), + strings.Contains(text, "usage exceeded"): + return commandCodeUsageCreditsExhausted + default: + return commandCodeUsageErrorNone + } +} + +// commandCodeResetAt 解析文案中的 "Resets in 2h 41m" / "resets in 3 days";无法解析 +// 或超过 maxWait 时返回 nil。 +func commandCodeResetAt(body []byte, now time.Time, maxWait time.Duration) *time.Time { + after := parseOpenCodeGoUsageLimitResetDuration(extractUpstreamErrorMessage(body)) + if after <= 0 { + after = parseOpenCodeGoUsageLimitResetDuration(string(body)) + } + if after <= 0 || after > maxWait { + return nil + } + until := now.Add(after) + return &until +} + +// handleCommandCodeUsageError 处理已识别的 Command Code 用量超限;handled=false 时由 +// 调用方继续走默认逻辑。shouldDisable 与同类状态码的既有口径一致:429 不置位; +// 只限单个模型时也不置位,避免调用方把整个账号拉黑。 +func (s *RateLimitService) handleCommandCodeUsageError( + ctx context.Context, + account *Account, + statusCode int, + responseBody []byte, + upstreamMsg string, +) (handled, shouldDisable bool) { + accountWide := statusCode != http.StatusTooManyRequests + now := time.Now() + switch classifyCommandCodeUsageError(responseBody) { + case commandCodeUsageWindowLimit: + until := commandCodeResetAt(responseBody, now, commandCodeMaxWindowCooldown) + if until == nil { + until = cnProviderQuotaSnapshotReset(account, now) + } + if until == nil { + return false, false + } + reason := commandCodeUsageLimitReason + if msg := strings.TrimSpace(upstreamMsg); msg != "" { + reason += ": " + msg + } + s.notifyAccountSchedulingBlocked(account, *until, commandCodeUsageLimitReason) + if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, *until, reason); err != nil { + slog.Warn("command_code_usage_limit_set_failed", "account_id", account.ID, "error", err) + return false, false + } + slog.Info("command_code_usage_limit", "account_id", account.ID, "status_code", statusCode, "reset_at", until.UTC()) + return true, accountWide + case commandCodeUsageCreditsExhausted: + s.handleCNProviderInsufficientBalance(ctx, account, upstreamMsg) + return true, accountWide + case commandCodeUsageSpendLimit: + return true, s.handleCommandCodeSpendLimit(ctx, account, statusCode, responseBody, now) + default: + return false, false + } +} + +// handleCommandCodeSpendLimit 处理组织花费上限:文案指明模型且请求模型已知时只限该 +// 模型,冷却到文案中的重置时间;成员总额度或作用域不明时整个账号冷却,但最长 +// commandCodeAccountSpendLimitRecheck,避免管理员调高上限后账号仍停用数天,也不会 +// 因累积 403 被永久禁用。 +func (s *RateLimitService) handleCommandCodeSpendLimit(ctx context.Context, account *Account, statusCode int, responseBody []byte, now time.Time) (shouldDisable bool) { + reset := commandCodeResetAt(responseBody, now, commandCodeMaxSpendLimitCooldown) + modelKey := modelRateLimitKeyForUpstreamModelNotFound(ctx, account, tempUnschedulableModel(ctx, nil)) + if modelKey != "" && commandCodeSpendLimitIsModelScoped(responseBody) { + until := reset + if until == nil { + fallback, ok := s.get429FallbackCooldown(ctx, account) + if !ok || fallback <= 0 { + fallback = time.Duration(defaultRateLimit429CooldownSeconds) * time.Second + } + next := now.Add(fallback) + until = &next + } + if err := s.accountRepo.SetModelRateLimit(ctx, account.ID, modelKey, *until, commandCodeSpendLimitReason); err != nil { + slog.Warn("command_code_spend_limit_set_failed", "account_id", account.ID, "model", modelKey, "error", err) + } + slog.Info("command_code_spend_limit", "account_id", account.ID, "model", modelKey, "status_code", statusCode, "reset_at", until.UTC()) + return false + } + until := now.Add(commandCodeAccountSpendLimitRecheck) + if reset != nil && reset.Before(until) { + until = *reset + } + s.notifyAccountSchedulingBlocked(account, until, commandCodeSpendLimitReason) + if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, until, commandCodeSpendLimitReason); err != nil { + slog.Warn("command_code_spend_limit_set_failed", "account_id", account.ID, "error", err) + } + slog.Info("command_code_spend_limit", "account_id", account.ID, "status_code", statusCode, "until", until.UTC()) + return statusCode != http.StatusTooManyRequests +} diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 6846c9a5d1..488c8042d5 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -399,6 +399,15 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc upstreamMsg = truncateForLog([]byte(upstreamMsg), 512) } + // Command Code 用量超限按文案区分、与状态码无关(见 ratelimit_command_code.go); + // 未识别的错误(含普通频率限制)继续走下面的默认逻辑。 + if account.IsCommandCode() && (statusCode == http.StatusPaymentRequired || + statusCode == http.StatusForbidden || statusCode == http.StatusTooManyRequests) { + if handled, disable := s.handleCommandCodeUsageError(ctx, account, statusCode, responseBody, upstreamMsg); handled { + return disable + } + } + switch statusCode { case 400: // "organization has been disabled" → 永久禁用 @@ -515,9 +524,10 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc shouldDisable = true } case 402: - // 国产供应商:余额不足是可恢复状态(充值/检测恢复后由周期任务自动解除), - // 不能走 handleAuthError 永久置 status=error。改为可恢复的临时停调。 - if account.IsCNProvider() || account.IsOpenCodeZen() { + // 国产供应商 / OpenCode Zen / Command Code:余额(积分)不足是可恢复状态 + // (充值/检测恢复后由周期任务自动解除),不能走 handleAuthError 永久置 + // status=error。改为可恢复的临时停调。 + if account.IsCNProvider() || account.IsOpenCodeZen() || account.IsCommandCode() { s.handleCNProviderInsufficientBalance(ctx, account, upstreamMsg) shouldDisable = true break diff --git a/backend/internal/service/upstream_billing_probe.go b/backend/internal/service/upstream_billing_probe.go index 85c4122647..0529322c90 100644 --- a/backend/internal/service/upstream_billing_probe.go +++ b/backend/internal/service/upstream_billing_probe.go @@ -1045,6 +1045,7 @@ var upstreamBillingProbeOfficialAPIDomains = []string{ "deepseek.com", "opencode.ai", "typesafe.ai", + "commandcode.ai", } func upstreamBillingProbeTargetIsOfficialAPI(baseURL string) bool { diff --git a/backend/internal/service/upstream_protocol_routing.go b/backend/internal/service/upstream_protocol_routing.go index c0c6df333c..dff4cb9905 100644 --- a/backend/internal/service/upstream_protocol_routing.go +++ b/backend/internal/service/upstream_protocol_routing.go @@ -3,6 +3,7 @@ package service import ( "encoding/json" "fmt" + "slices" "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" ) @@ -14,10 +15,12 @@ import ( // // inbound 为入站协议;/v1/chat/completions 收到 Responses 形状请求体时按 // responses 计。model 仅按模型分流的账号使用,取 upstreamRoutingModel 的结果。 +// catalog 为上游模型目录给出的该模型支持的协议(见 model_protocol_catalog.go),未知时为 nil。 // // 优先级: -// 1. 按模型分流的供应商(多模型聚合平台,如 OpenCode):显式 api_protocol → -// 账号 protocol_rules → profile 内置规则 → Chat Completions,与入站协议无关。 +// 1. 按模型分流的供应商(多模型聚合平台,如 OpenCode、Command Code):显式 +// api_protocol 直接生效;否则先得到该模型支持的协议集合(见 modelProtocolSet), +// 入站协议在集合中时同协议直通,不在时走集合首项(首选协议)。 // 2. adaptive(按入站协议分流):供应商提供入站协议的原生端点则走同协议, // 否则回落 Chat Completions。 // 3. 显式 anthropic 协议 → Anthropic。 @@ -26,9 +29,9 @@ import ( // 5. 其余 → Responses。 // // Grok 在 Responses / Chat Completions 入口有专属链路,须在调用本函数前分流。 -func resolveUpstreamProtocol(account *Account, inbound, model string) string { +func resolveUpstreamProtocol(account *Account, inbound, model string, catalog []string) string { if account.routesByModel() { - return account.resolveModelRoutedProtocol(model) + return account.resolveModelRoutedProtocolFor(model, inbound, catalog) } if account.IsAdaptiveAPIProtocol() { if account.providerSupportsProtocol(inbound) { @@ -45,20 +48,64 @@ func resolveUpstreamProtocol(account *Account, inbound, model string) string { return APIProtocolResponses } -// resolveModelRoutedProtocol 按模型名解析按模型分流账号的上游协议。 -// 显式 pinned 协议优先;adaptive 先走 credentials.protocol_rules,未配置时回落 -// profile 对应接入模式的内置规则;规则未命中或结果未知一律 Chat Completions, -// 避免落入 Responses 转换链。 +// resolveModelRoutedProtocol 返回按模型分流账号对该模型的首选上游协议(不考虑入站 +// 协议与上游模型目录),供连接测试等没有入站协议的场景使用。 func (a *Account) resolveModelRoutedProtocol(model string) string { + return a.resolveModelRoutedProtocolFor(model, "", nil) +} + +// resolveModelRoutedProtocolFor 按模型名解析按模型分流账号的上游协议:显式 pinned +// 协议直接生效;否则取该模型支持的协议集合,入站协议在其中时同协议直通(省去一次 +// 协议转换),不在时走首选协议。 +func (a *Account) resolveModelRoutedProtocolFor(model, inbound string, catalog []string) string { switch proto := a.GetAPIProtocol(); proto { case APIProtocolChatCompletions, APIProtocolAnthropic, APIProtocolResponses: return proto } - rules, present := a.configuredProtocolRules() - if !present { - rules = a.providerProfile().Endpoints(a.GetCredential("account_mode")).ProtocolRules + set := a.modelProtocolSet(model, catalog) + if slices.Contains(set, inbound) { + return inbound } - return matchProtocolRules(model, rules) + return set[0] +} + +// modelProtocolSet 返回模型支持的上游协议集合(首项为首选),来源依次为: +// 1. 账号 protocol_rules 中命中的规则(管理员显式配置); +// 2. 上游模型目录(catalog):首选沿用内置规则对该模型的首选(在集合内时),否则取目录首项; +// 3. profile 内置规则——仅在账号未配置 protocol_rules 时,已配置但未命中视为有意不走内置规则; +// 4. Chat Completions,避免落入 Responses 转换链。 +func (a *Account) modelProtocolSet(model string, catalog []string) []string { + rules, configured := a.configuredProtocolRules() + if configured { + if set, ok := matchProtocolRuleSet(model, rules); ok { + return set + } + } + profileRules := a.providerProfile().Endpoints(a.GetCredential("account_mode")).ProtocolRules + if set := a.supportedCatalogProtocols(catalog); len(set) > 0 { + preferred := matchProtocolRules(model, profileRules) + if slices.Contains(set, preferred) { + return normalizeProtocolSet(preferred, set) + } + return set + } + if !configured { + if set, ok := matchProtocolRuleSet(model, profileRules); ok { + return set + } + } + return []string{APIProtocolChatCompletions} +} + +// supportedCatalogProtocols 过滤出账号接入模式确有原生端点的目录协议。 +func (a *Account) supportedCatalogProtocols(catalog []string) []string { + out := make([]string, 0, len(catalog)) + for _, protocol := range catalog { + if isNativeUpstreamProtocol(protocol) && a.providerSupportsProtocol(protocol) && !slices.Contains(out, protocol) { + out = append(out, protocol) + } + } + return out } // upstreamRoutingModel 返回参与协议分流的模型名:仅按模型分流的账号需要,按账号 diff --git a/backend/internal/service/upstream_protocol_routing_matrix_test.go b/backend/internal/service/upstream_protocol_routing_matrix_test.go index fc3a5165a1..36e505d532 100644 --- a/backend/internal/service/upstream_protocol_routing_matrix_test.go +++ b/backend/internal/service/upstream_protocol_routing_matrix_test.go @@ -239,13 +239,29 @@ func observeRouting(tc routingMatrixCase) (obs routingObservation) { return obs } +// legacyOpenCodeProtocol 复述统一判定前 OpenCode 的分流(原 openCodeGoNativeProtocol): +// 显式协议 → 账号 protocol_rules(已配置但未命中为 Chat Completions)→ 接入模式的内置规则表。 +func legacyOpenCodeProtocol(account *Account, model string) string { + switch proto := account.GetAPIProtocol(); proto { + case APIProtocolChatCompletions, APIProtocolAnthropic, APIProtocolResponses: + return proto + } + if rules, configured := account.configuredProtocolRules(); configured { + return matchProtocolRules(model, rules) + } + if account.IsOpenCodeZen() { + return matchProtocolRules(model, DefaultOpenCodeZenProtocolRules()) + } + return matchProtocolRules(model, DefaultOpenCodeGoProtocolRules()) +} + // legacyUpstreamRouting 复述统一判定前三个入口各自的分流顺序,返回预期的上游协议 // 以及 Chat Completions 上游是否先把 Responses 形状请求体转换成 messages。 func legacyUpstreamRouting(account *Account, ingress routingMatrixIngress, model string) (string, bool) { openCode := account.IsOpenCodeGo() var openCodeProto string if openCode { - openCodeProto = openCodeGoNativeProtocol(account, model) + openCodeProto = legacyOpenCodeProtocol(account, model) } switch ingress.name { case "responses": diff --git a/backend/internal/service/upstream_protocol_routing_test.go b/backend/internal/service/upstream_protocol_routing_test.go index 2266c1cbf7..1572a7551a 100644 --- a/backend/internal/service/upstream_protocol_routing_test.go +++ b/backend/internal/service/upstream_protocol_routing_test.go @@ -95,17 +95,18 @@ func TestResolveUpstreamProtocol(t *testing.T) { {"openai oauth ignores probe", openAIWithProbe(AccountTypeOAuth, false), cc, "", responses}, } for _, tc := range cases { - require.Equal(t, tc.want, resolveUpstreamProtocol(tc.account, tc.inbound, tc.model), tc.name) + require.Equal(t, tc.want, resolveUpstreamProtocol(tc.account, tc.inbound, tc.model, nil), tc.name) } } func TestRoutesByModelOnlyForAggregators(t *testing.T) { t.Parallel() + aggregators := map[string]bool{PlatformOpenCodeGo: true, PlatformCommandCode: true} for platform, profile := range providerProfiles { account := &Account{Platform: platform} - require.Equal(t, platform == PlatformOpenCodeGo, account.routesByModel(), platform) - require.Equal(t, account.IsOpenCodeGo(), profile.Routing == ProviderRoutingByModel, platform) + require.Equal(t, aggregators[platform], account.routesByModel(), platform) + require.Equal(t, aggregators[platform], profile.Routing == ProviderRoutingByModel, platform) } require.False(t, (&Account{Platform: PlatformOpenAI}).routesByModel()) require.False(t, (*Account)(nil).routesByModel()) diff --git a/frontend/src/api/__tests__/settings.authSourceDefaults.spec.ts b/frontend/src/api/__tests__/settings.authSourceDefaults.spec.ts index 7da7225e71..6ec179bb11 100644 --- a/frontend/src/api/__tests__/settings.authSourceDefaults.spec.ts +++ b/frontend/src/api/__tests__/settings.authSourceDefaults.spec.ts @@ -13,7 +13,7 @@ import { listPlatformIds } from "@/constants/platformCatalog"; /** 与后端 AllowedQuotaPlatforms 一致的全部具体平台(平台清单)。 */ const quotaPlatforms = [ "anthropic", "openai", "gemini", "antigravity", "grok", - "kimi", "zhipu", "deepseek", "minimax", "opencode_go", "typesafe", + "kimi", "zhipu", "deepseek", "minimax", "opencode_go", "typesafe", "command_code", ]; /** 全部平台全 null 的 map,用于断言归一化默认值 */ diff --git a/frontend/src/api/admin/cnProviders.ts b/frontend/src/api/admin/cnProviders.ts index 7a4d3f18c5..ee0c7df0db 100644 --- a/frontend/src/api/admin/cnProviders.ts +++ b/frontend/src/api/admin/cnProviders.ts @@ -12,7 +12,7 @@ export interface CNQuotaTier { reset_at?: string } -/** Coding Plan 额度探测结果(kimi / zhipu),对齐后端 CNProviderQuotaProbeResult。 */ +/** 额度探测结果(Coding Plan / OpenCode Go / Command Code),对齐后端 CNProviderQuotaProbeResult。 */ export interface CNProviderQuotaProbeResult { provider: string source?: string @@ -24,6 +24,8 @@ export interface CNProviderQuotaProbeResult { fetched_at: number persisted: boolean error?: string + /** 同一次探测得到的余额(Command Code 积分与窗口同源),其余供应商为空。 */ + balance?: CNProviderBalanceResult } /** 单币种余额明细(deepseek 双币种账号含 CNY + USD 两条)。 */ diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 4e0fbf240b..c760d45f18 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -43,11 +43,12 @@ export type SchedulingThresholdPlatformType = | "zhipu" | "minimax" | "opencode_go" + | "command_code" export type AccountSchedulingThresholdsMap = Record // 与后端 AllowedSchedulingThresholdPlatforms 保持一致(deepseek 为余额型, -// 走余额检测而非用量阈值;minimax Coding/Token Plan 与 OpenCode GO 有滚动窗口)。 +// 走余额检测而非用量阈值;minimax Coding/Token Plan、OpenCode GO 与 Command Code 有滚动窗口)。 export const SCHEDULING_THRESHOLD_PLATFORMS: SchedulingThresholdPlatformType[] = [ "openai", "anthropic", @@ -56,6 +57,7 @@ export const SCHEDULING_THRESHOLD_PLATFORMS: SchedulingThresholdPlatformType[] = "zhipu", "minimax", "opencode_go", + "command_code", ] export function normalizeAccountSchedulingThresholdsMap( diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index eb47cf8074..fd5b9177bc 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -436,8 +436,9 @@ - -