feat(platform): 接入 Command Code 平台

转发:
- 平台清单与 provider profile 新增 command_code:按模型分流,Chat
  Completions / Responses 基址 https://api.commandcode.ai/provider/v1,
  Anthropic 基址 https://api.commandcode.ai/provider;内置规则 claude-* →
  Anthropic、gpt-* → Responses(也支持 Chat Completions)。
- 原生 Anthropic 端点的版本感知拼接改按分流方式判断,转发与连接测试共用
  (Anthropic 基址带 /v1 时连接测试不再误报为配置错误)。

按模型的协议集合(模型支持入站协议时直通,免去协议转换):
- 分流规则可带协议集合 protocols(首项为首选协议):入站协议在集合中时同
  协议直通,否则走首选协议;只有一个协议时与原规则相同,OpenCode 行为不变。
- profile 新增 model_catalog:Command Code 的 /models 为每个模型给出
  supported_endpoints。官方默认地址的目录按地址全进程共享,自定义上游按账号
  隔离;目录请求的 UA 收敛与账号请求头覆写与转发一致。目录缓存 30 分钟、后台
  刷新,失败退避 5 分钟;从未加载成功时首批请求最多等 2 秒,冷启动也按目录
  分流。账号规则未命中的模型按目录取协议集合;目录不可用时回落内置规则(账号
  未配置规则时)或 Chat Completions。
- 三个入站入口共用的 resolveUpstreamProtocol 增加目录参数。
- 前端分流规则编辑器可为每条规则勾选"也支持"的协议;提供模型目录的平台,
  兜底行改为说明按上游模型列表选协议。

连接测试:
- 按模型分流的平台共用一条测试路径(协议与网关同一判定,含模型目录;默认
  测试模型取自 profile),不再落入 Claude 测试路径。
- 首个分支由"国产厂商"改为"按入站协议分流";adaptive 测试跳过 profile 未
  提供的 Anthropic 端点。现有国产厂商行为不变。
- 连接测试是 OpenCode 专属分流入口的最后一个调用方,改走统一判定后删除
  ResolveOpenCodeGoUpstreamProtocol、openCodeGoNativeProtocol,以及此前就只剩
  测试使用的 OpenCodeGoModelProtocol;路由矩阵的旧行为基准改为测试内独立复述。

额度与余额(/alpha/whoami、billing/credits、billing/subscriptions、
usage/summary):
- 额度探测写入 5 小时 / 每周窗口(限额套餐)与还原出的月度窗口,同时写入
  积分余额;余额探测只取积分。仅对官方主机上的 API Key 账号生效。
- 订阅周期或本周期用量取数失败时保留上一次的月度快照,不当作没有月度窗口
  清掉;窗口尚未开始(没有重置时间)时清掉上一个窗口的重置时间。
- whoami 取不到时探测整体失败,不按个人账户继续(组织账号会查到成员的个人
  钱包,并据此按余额停调)。
- 阈值停调与定时检测纳入 Command Code;有充值积分时上游跳过窗口检查,窗口
  快照不参与停调与冷却;积分低于余额阈值时临时停调。
- 上游计费探测的官方域名加入 commandcode.ai,不把 Key 发往官方主机上并不
  存在的中转计费接口。

上游错误(依据 commandcode.ai/docs/troubleshooting/errors,按文案分类,
402 / 403 / 429 同样处理):
- 5 小时 / 每周窗口用满:带原因的临时停调,到文案中的 "Resets in …",解析
  不出时回落额度快照;充值积分不受窗口限制,额度 / 余额探测发现充值积分后
  只解除这一类停调。
- 积分耗尽(含 usage_exceeded):可恢复的临时停调,积分恢复后由周期检测
  解除,不再永久置为 error。
- 组织花费上限:文案指明模型("set for <模型>")且请求模型已知时只写模型级
  限流;成员总额度或作用域不明时整个账号冷却,最长一小时。
- 普通频率限制走默认 429 逻辑,不再冷却到窗口重置点。

前端:
- 账号列表显示额度窗口与积分余额,阈值设置增加 command_code。单元格可见性
  改为按账号判定,与后端一致:只对官方主机上的 API Key 账号显示,自定义中转
  显示占位符,不再每次打开列表都探测出一行报错。
- 使用官网的官方标志(圆角方块内镂空 ⌘);配色为中性灰(品牌黑色已由
  Grok 使用)。

测试基准中原先用作"仅服务端登记"示例的 command_code 改为虚构的 acme_router;
新增的前端测试加入 CI 前端关键测试列表。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SavitarC
2026-10-09 10:11:05 +08:00
co-authored by Claude Opus 5.5
parent 7ffa38a8ae
commit e2939cd8fa
64 changed files with 3166 additions and 343 deletions
+2
View File
@@ -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 \
+4 -1
View File
@@ -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」两种接入方式。
+1
View File
@@ -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 {
+6 -2
View File
@@ -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: "",
}
)
@@ -19,5 +19,6 @@ func TestAllPlatformsIncludesEveryConcretePlatform(t *testing.T) {
"minimax",
"opencode_go",
"typesafe",
"command_code",
}, AllPlatforms())
}
+2 -2
View File
@@ -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,
+8 -1
View File
@@ -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 ""
}
@@ -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,键形如
// <provider>_5h_used_percent / <provider>_weekly_reset_at)。payg 账号无此快照,
// cnProviderThresholdCandidates 读取国产供应商 Coding Plan、OpenCode Go 与 Command Code
// 账号的 5h / weekly(及月度)窗口用量快照(由 CNProviderQuotaService 写入 account.Extra,
// 键形如 <provider>_5h_used_percent / <provider>_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 {
@@ -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")
@@ -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 ""
}
@@ -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 {
@@ -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)
@@ -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")
}
@@ -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")
}
+27
View File
@@ -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
}
@@ -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))
}
@@ -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
}
@@ -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)
}
}
@@ -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))
}
+14 -7
View File
@@ -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 标识。
@@ -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))
}
@@ -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"))
}
}
@@ -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, "")
@@ -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 用。
@@ -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 语义。
@@ -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 {
+77 -37
View File
@@ -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 统一
+39 -40
View File
@@ -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) {
@@ -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")
}
+16 -1
View File
@@ -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
}
+10 -7
View File
@@ -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())
+28 -3
View File
@@ -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。
@@ -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
@@ -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
}
+13 -3
View File
@@ -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
@@ -1045,6 +1045,7 @@ var upstreamBillingProbeOfficialAPIDomains = []string{
"deepseek.com",
"opencode.ai",
"typesafe.ai",
"commandcode.ai",
}
func upstreamBillingProbeTargetIsOfficialAPI(baseURL string) bool {
@@ -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 返回参与协议分流的模型名:仅按模型分流的账号需要,按账号
@@ -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":
@@ -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())
@@ -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,用于断言归一化默认值 */
+3 -1
View File
@@ -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 两条)。 */
+3 -1
View File
@@ -43,11 +43,12 @@ export type SchedulingThresholdPlatformType =
| "zhipu"
| "minimax"
| "opencode_go"
| "command_code"
export type AccountSchedulingThresholdsMap = Record<SchedulingThresholdPlatformType, number>
// 与后端 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(
@@ -436,8 +436,9 @@
</div>
</template>
<!-- CN providers (Kimi / Zhipu / DeepSeek): coding-plan quota or payg balance -->
<template v-else-if="account.platform === 'kimi' || account.platform === 'zhipu' || account.platform === 'deepseek' || account.platform === 'minimax' || account.platform === 'opencode_go'">
<!-- Multi-protocol API-key providers (CN vendors, OpenCode, Command Code):
coding-plan / subscription quota windows or balance -->
<template v-else-if="isMultiProtocolApiKeyPlatform(account.platform)">
<!-- 挂在 CN 平台下的 Ollama Cloud 账号(资格由后端下发 eligible):用量由
Ollama 用量窗口负责。这类账号不是国产厂商订阅,CN 的额度/余额探测端点由
base_url 衍生,对 ollama.com 会被后端出站 URL 白名单拒绝,渲染出来只会
@@ -689,7 +690,11 @@ import GrokQuotaProbeCell from './GrokQuotaProbeCell.vue'
import CNProviderQuotaCell from './CNProviderQuotaCell.vue'
import CNProviderBalanceCell from './CNProviderBalanceCell.vue'
import OllamaCloudUsageCell from './OllamaCloudUsageCell.vue'
import { cnQuotaCellVisible as cnQuotaCellVisibleFn, cnBalanceCellVisible as cnBalanceCellVisibleFn } from './credentialsBuilder'
import {
cnQuotaCellVisible as cnQuotaCellVisibleFn,
cnBalanceCellVisible as cnBalanceCellVisibleFn,
isMultiProtocolApiKeyPlatform
} from './credentialsBuilder'
import OpenCodeGoUsageCell from './OpenCodeGoUsageCell.vue'
// Module-level cache shared across all AccountUsageCell instances
@@ -752,15 +757,9 @@ let visibilityObserver: IntersectionObserver | null = null
const showUsageWindows = computed(() => {
// Gemini: we can always compute local usage windows from DB logs (simulated quotas).
if (props.account.platform === 'gemini') return true
// CN providers: apikey 账号也有滚动用量窗口(coding plan)或余额(payg),
// 多协议 API Key 供应商:apikey 账号也有滚动用量窗口(coding plan / 订阅)或余额,
// 由 CNProviderQuotaCell / CNProviderBalanceCell 自行探测与展示。
if (
props.account.platform === 'kimi' ||
props.account.platform === 'zhipu' ||
props.account.platform === 'deepseek' ||
props.account.platform === 'minimax' ||
props.account.platform === 'opencode_go'
) {
if (isMultiProtocolApiKeyPlatform(props.account.platform)) {
return true
}
return props.account.type === 'oauth' || props.account.type === 'setup-token'
@@ -787,12 +786,8 @@ const shouldFetchUsage = computed(() => {
// CN 供应商子单元格可见性(与 CNProviderQuotaCell / CNProviderBalanceCell 共用
// credentialsBuilder 的单一实现):都不可见时显示 `-` 占位符。
const cnAccountMode = computed(() => {
const mode = props.account.credentials?.account_mode
return typeof mode === 'string' ? mode : ''
})
const cnQuotaCellVisible = computed(() => cnQuotaCellVisibleFn(props.account.platform, cnAccountMode.value))
const cnBalanceCellVisible = computed(() => cnBalanceCellVisibleFn(props.account.platform, cnAccountMode.value))
const cnQuotaCellVisible = computed(() => cnQuotaCellVisibleFn(props.account))
const cnBalanceCellVisible = computed(() => cnBalanceCellVisibleFn(props.account))
const isBatchManaged = computed(() => typeof props.requestBatchedUsage === 'function')
@@ -71,13 +71,8 @@ const props = defineProps<{
const { t } = useI18n()
const readMode = (): string => {
const mode = props.account.credentials?.account_mode
return typeof mode === 'string' ? mode : ''
}
// 仅 kimi / deepseek payg 账号有公开余额端点(智谱 payg 无)。
const visible = computed(() => cnBalanceCellVisible(props.account.platform, readMode()))
const visible = computed(() => cnBalanceCellVisible(props.account))
const loading = ref(false)
const error = ref<string | null>(null)
@@ -74,12 +74,7 @@ const props = defineProps<{
const { t } = useI18n()
const readMode = (): string => {
const mode = props.account.credentials?.account_mode
return typeof mode === 'string' ? mode : ''
}
const visible = computed(() => cnQuotaCellVisible(props.account.platform, readMode()))
const visible = computed(() => cnQuotaCellVisible(props.account))
const loading = ref(false)
const error = ref<string | null>(null)
@@ -12,35 +12,55 @@
</div>
<p class="input-hint mb-2">{{ t('admin.accounts.opencodeGo.protocolRules.hint') }}</p>
<div v-if="rows.length > 0" class="mb-2 space-y-2">
<div
v-for="(row, index) in rows"
:key="getRowKey(row)"
class="flex items-center gap-2"
>
<input
v-model="row.pattern"
type="text"
class="input flex-1 font-mono text-sm"
:placeholder="t('admin.accounts.opencodeGo.protocolRules.patternPlaceholder')"
:data-testid="`opencode-go-protocol-pattern-${index}`"
/>
<select
v-model="row.protocol"
class="input w-44 shrink-0"
:data-testid="`opencode-go-protocol-select-${index}`"
<div v-for="(row, index) in rows" :key="getRowKey(row)">
<div class="flex items-center gap-2">
<input
v-model="row.pattern"
type="text"
class="input flex-1 font-mono text-sm"
:placeholder="t('admin.accounts.opencodeGo.protocolRules.patternPlaceholder')"
:data-testid="`opencode-go-protocol-pattern-${index}`"
/>
<select
:value="row.protocol"
class="input w-44 shrink-0"
:data-testid="`opencode-go-protocol-select-${index}`"
@change="setPrimaryProtocol(row, ($event.target as HTMLSelectElement).value as CnNativeApiProtocol)"
>
<option v-for="option in protocolOptions" :key="option.value" :value="option.value">
{{ t(`admin.accounts.cnProviders.apiProtocol.${option.labelKey}`) }}
</option>
</select>
<button
type="button"
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
:aria-label="t('admin.accounts.opencodeGo.protocolRules.remove')"
@click="removeRow(index)"
>
<Icon name="trash" size="sm" />
</button>
</div>
<div
v-if="protocolOptions.length > 1"
class="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400"
:title="t('admin.accounts.opencodeGo.protocolRules.alsoSupportsHint')"
>
<option v-for="option in protocolOptions" :key="option.value" :value="option.value">
<span>{{ t('admin.accounts.opencodeGo.protocolRules.alsoSupports') }}</span>
<button
v-for="option in protocolOptions.filter(item => item.value !== row.protocol)"
:key="option.value"
type="button"
class="rounded-full border px-2 py-0.5 transition-colors"
:class="hasExtraProtocol(row, option.value)
? 'border-primary-300 bg-primary-50 text-primary-700 dark:border-primary-700 dark:bg-primary-900/30 dark:text-primary-300'
: 'border-gray-200 text-gray-500 hover:border-gray-300 dark:border-dark-600 dark:text-gray-400 dark:hover:border-dark-500'"
:aria-pressed="hasExtraProtocol(row, option.value)"
:data-testid="`opencode-go-protocol-extra-${index}-${option.value}`"
@click="toggleExtraProtocol(row, option.value)"
>
{{ t(`admin.accounts.cnProviders.apiProtocol.${option.labelKey}`) }}
</option>
</select>
<button
type="button"
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
:aria-label="t('admin.accounts.opencodeGo.protocolRules.remove')"
@click="removeRow(index)"
>
<Icon name="trash" size="sm" />
</button>
</button>
</div>
</div>
</div>
<div
@@ -48,7 +68,7 @@
data-testid="opencode-go-protocol-fallback"
>
<span class="flex-1 font-mono">*</span>
<span>{{ t('admin.accounts.opencodeGo.protocolRules.fallback') }}</span>
<span>{{ t(`admin.accounts.opencodeGo.protocolRules.${hasModelCatalog ? 'catalogFallback' : 'fallback'}`) }}</span>
</div>
<button
type="button"
@@ -69,6 +89,7 @@ import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
import {
cloneOpenCodeGoProtocolRules,
defaultProviderProtocolRules,
providerHasModelCatalog,
providerNativeProtocols,
type CnNativeApiProtocol,
type OpenCodeGoProtocolRule
@@ -109,11 +130,47 @@ const PROTOCOL_ORDER: Array<{ value: CnNativeApiProtocol; labelKey: string }> =
// 仅列出该接入模式提供原生端点的协议;已有规则使用的协议保留,避免编辑时丢值。
const protocolOptions = computed(() => {
const supported = new Set<CnNativeApiProtocol>(providerNativeProtocols(props.platform, props.plan))
for (const row of props.rows) supported.add(row.protocol)
for (const row of props.rows) {
supported.add(row.protocol)
for (const protocol of row.extraProtocols ?? []) supported.add(protocol)
}
const options = PROTOCOL_ORDER.filter(option => supported.has(option.value))
return options.length > 0 ? options : PROTOCOL_ORDER
})
const hasModelCatalog = computed(() => providerHasModelCatalog(props.platform))
// 规则的协议集合:首选协议 + 也支持的协议;入站协议在集合中时同协议直通。
const hasExtraProtocol = (row: OpenCodeGoProtocolRule, protocol: CnNativeApiProtocol) =>
row.extraProtocols?.includes(protocol) ?? false
const setExtraProtocols = (row: OpenCodeGoProtocolRule, extra: CnNativeApiProtocol[]) => {
if (extra.length > 0) row.extraProtocols = extra
else delete row.extraProtocols
}
const toggleExtraProtocol = (row: OpenCodeGoProtocolRule, protocol: CnNativeApiProtocol) => {
const extra = row.extraProtocols ?? []
setExtraProtocols(
row,
extra.includes(protocol)
? extra.filter(item => item !== protocol)
: PROTOCOL_ORDER.map(option => option.value).filter(value => value === protocol || extra.includes(value))
)
}
// 切换首选协议:新协议原本就在“也支持”中时只调换顺序(原首选转入“也支持”),
// 否则替换原首选协议。
const setPrimaryProtocol = (row: OpenCodeGoProtocolRule, protocol: CnNativeApiProtocol) => {
const previous = row.protocol
if (protocol === previous) return
const extra = row.extraProtocols ?? []
row.protocol = protocol
if (!extra.includes(protocol)) return
const set = new Set<CnNativeApiProtocol>([...extra.filter(item => item !== protocol), previous])
setExtraProtocols(row, PROTOCOL_ORDER.map(option => option.value).filter(value => set.has(value)))
}
const restoreDefaults = () => {
emit('update:rows', cloneOpenCodeGoProtocolRules(defaultProviderProtocolRules(props.platform, props.plan)))
}
@@ -342,6 +342,48 @@ describe('AccountUsageCell', () => {
expect(wrapper.text()).not.toContain('-')
})
it('Command Code 账号渲染额度与积分余额单元格', async () => {
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 9103,
platform: 'command_code',
type: 'apikey',
credentials: { api_key: 'user_test_key', account_mode: 'payg' }
})
},
global: {
stubs: { ...cnUsageCellStubs, UsageProgressBar: true, AccountQuotaInfo: true }
}
})
await flushPromises()
expect(wrapper.find('[data-test="cn-quota-cell"]').exists()).toBe(true)
expect(wrapper.find('[data-test="cn-balance-cell"]').exists()).toBe(true)
expect(wrapper.text()).not.toContain('-')
})
it('自定义中转的 Command Code 账号没有可查的用量接口,显示占位符', async () => {
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 9105,
platform: 'command_code',
type: 'apikey',
credentials: { api_key: 'user_test_key', account_mode: 'payg', base_url: 'https://relay.example.com/v1' }
})
},
global: {
stubs: { ...cnUsageCellStubs, UsageProgressBar: true, AccountQuotaInfo: true }
}
})
await flushPromises()
expect(wrapper.find('div[title="admin.accounts.cnProviders.noBalanceEndpoint"]').exists()).toBe(true)
})
it('Antigravity 图片用量会聚合新旧 image 模型', async () => {
getUsage.mockResolvedValue({
antigravity_quota: {
@@ -514,8 +514,8 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
platforms: [
...BUILTIN_PLATFORM_CATALOG.platforms,
{
id: 'command_code',
display_name: 'Command Code',
id: 'acme_router',
display_name: 'Acme Router',
gateway: 'openai',
cn_provider: false,
multi_protocol: {
@@ -525,16 +525,16 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
{
mode: 'standard',
base_urls: {
chat_completions: 'https://api.commandcode.ai/provider/v1',
anthropic: 'https://api.commandcode.ai/provider',
chat_completions: 'https://api.acme-router.example/provider/v1',
anthropic: 'https://api.acme-router.example/provider',
},
protocol_rules: [{ pattern: 'claude-*', protocol: 'anthropic' }],
},
{
mode: 'team',
base_urls: {
chat_completions: 'https://team.commandcode.ai/provider/v1',
anthropic: 'https://team.commandcode.ai/provider',
chat_completions: 'https://team.acme-router.example/provider/v1',
anthropic: 'https://team.acme-router.example/provider',
},
protocol_rules: [{ pattern: 'sonnet-*', protocol: 'anthropic' }],
},
@@ -553,7 +553,7 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
},
},
],
composite_precedence: [...BUILTIN_PLATFORM_CATALOG.composite_precedence, 'command_code', 'cline_pass'],
composite_precedence: [...BUILTIN_PLATFORM_CATALOG.composite_precedence, 'acme_router', 'cline_pass'],
}
beforeEach(() => {
@@ -566,7 +566,7 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
it('creates a by-model provider account from its profile defaults', async () => {
const wrapper = mountModal()
await wrapper.get('[data-testid="platform-button-command_code"]').trigger('click')
await wrapper.get('[data-testid="platform-button-acme_router"]').trigger('click')
await wrapper.get('form#create-account-form input[type="text"]').setValue('cc')
await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-cc')
@@ -575,16 +575,16 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
expect(createAccountMock).toHaveBeenCalledTimes(1)
const payload = createAccountMock.mock.calls[0]?.[0]
expect(payload?.platform).toBe('command_code')
expect(payload?.platform).toBe('acme_router')
expect(payload?.type).toBe('apikey')
expect(payload?.credentials).toMatchObject({
api_key: 'sk-cc',
account_mode: 'standard',
api_protocol: 'adaptive',
base_url: 'https://api.commandcode.ai/provider/v1',
base_url: 'https://api.acme-router.example/provider/v1',
api_base_urls: {
chat_completions: 'https://api.commandcode.ai/provider/v1',
anthropic: 'https://api.commandcode.ai/provider',
chat_completions: 'https://api.acme-router.example/provider/v1',
anthropic: 'https://api.acme-router.example/provider',
},
protocol_rules: [{ pattern: 'claude-*', protocol: 'anthropic' }],
})
@@ -596,7 +596,7 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
it('switches endpoints and default rules with the provider mode', async () => {
const wrapper = mountModal()
await wrapper.get('[data-testid="platform-button-command_code"]').trigger('click')
await wrapper.get('[data-testid="platform-button-acme_router"]').trigger('click')
await wrapper.get('[data-testid="generic-account-mode"]').findAll('button')[1].trigger('click')
await wrapper.get('form#create-account-form input[type="text"]').setValue('cc-team')
await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-cc')
@@ -606,10 +606,10 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
expect(createAccountMock.mock.calls[0]?.[0]?.credentials).toMatchObject({
account_mode: 'team',
base_url: 'https://team.commandcode.ai/provider/v1',
base_url: 'https://team.acme-router.example/provider/v1',
api_base_urls: {
chat_completions: 'https://team.commandcode.ai/provider/v1',
anthropic: 'https://team.commandcode.ai/provider',
chat_completions: 'https://team.acme-router.example/provider/v1',
anthropic: 'https://team.acme-router.example/provider',
},
protocol_rules: [{ pattern: 'sonnet-*', protocol: 'anthropic' }],
})
@@ -638,7 +638,7 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
it('falls back to the Kimi default mode after a server-only provider', async () => {
const wrapper = mountModal()
await wrapper.get('[data-testid="platform-button-command_code"]').trigger('click')
await wrapper.get('[data-testid="platform-button-acme_router"]').trigger('click')
await selectButtonByText(wrapper, 'Kimi')
await wrapper.get('form#create-account-form input[type="text"]').setValue('kimi')
await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-kimi')
@@ -549,8 +549,8 @@ describe('EditAccountModal', () => {
platforms: [
...BUILTIN_PLATFORM_CATALOG.platforms,
{
id: 'command_code',
display_name: 'Command Code',
id: 'acme_router',
display_name: 'Acme Router',
gateway: 'openai',
cn_provider: false,
multi_protocol: {
@@ -560,16 +560,16 @@ describe('EditAccountModal', () => {
{
mode: 'standard',
base_urls: {
chat_completions: 'https://api.commandcode.ai/provider/v1',
anthropic: 'https://api.commandcode.ai/provider'
chat_completions: 'https://api.acme-router.example/provider/v1',
anthropic: 'https://api.acme-router.example/provider'
},
protocol_rules: [{ pattern: 'claude-*', protocol: 'anthropic' }]
},
{
mode: 'team',
base_urls: {
chat_completions: 'https://team.commandcode.ai/provider/v1',
anthropic: 'https://team.commandcode.ai/provider'
chat_completions: 'https://team.acme-router.example/provider/v1',
anthropic: 'https://team.acme-router.example/provider'
},
protocol_rules: [{ pattern: 'sonnet-*', protocol: 'anthropic' }]
}
@@ -577,7 +577,7 @@ describe('EditAccountModal', () => {
}
}
],
composite_precedence: [...BUILTIN_PLATFORM_CATALOG.composite_precedence, 'command_code']
composite_precedence: [...BUILTIN_PLATFORM_CATALOG.composite_precedence, 'acme_router']
})
checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false })
})
@@ -588,7 +588,7 @@ describe('EditAccountModal', () => {
function commandCodeAccount() {
const account = buildAccount()
account.platform = 'command_code'
account.platform = 'acme_router'
account.credentials = {
api_key: 'sk-cc',
account_mode: 'standard',
@@ -0,0 +1,63 @@
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import OpenCodeGoProtocolRulesEditor from '../OpenCodeGoProtocolRulesEditor.vue'
import type { OpenCodeGoProtocolRule } from '../credentialsBuilder'
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({ t: (key: string) => key })
}
})
function mountEditor(rows: OpenCodeGoProtocolRule[], platform = 'command_code', plan = 'payg') {
return mount(OpenCodeGoProtocolRulesEditor, {
props: { rows, platform, plan },
global: { stubs: { Icon: true } }
})
}
describe('OpenCodeGoProtocolRulesEditor protocol sets', () => {
it('toggles additional protocols in protocol order', async () => {
const rows: OpenCodeGoProtocolRule[] = [{ pattern: 'gpt-*', protocol: 'anthropic' }]
const wrapper = mountEditor(rows)
expect(wrapper.find('[data-testid="opencode-go-protocol-extra-0-anthropic"]').exists()).toBe(false)
const responses = wrapper.get('[data-testid="opencode-go-protocol-extra-0-responses"]')
expect(responses.attributes('aria-pressed')).toBe('false')
await responses.trigger('click')
await wrapper.get('[data-testid="opencode-go-protocol-extra-0-chat_completions"]').trigger('click')
expect(rows[0].extraProtocols).toEqual(['chat_completions', 'responses'])
expect(wrapper.get('[data-testid="opencode-go-protocol-extra-0-responses"]').attributes('aria-pressed')).toBe('true')
await wrapper.get('[data-testid="opencode-go-protocol-extra-0-responses"]').trigger('click')
await wrapper.get('[data-testid="opencode-go-protocol-extra-0-chat_completions"]').trigger('click')
expect(rows[0]).toEqual({ pattern: 'gpt-*', protocol: 'anthropic' })
})
it('swaps the preferred protocol within the set and replaces it otherwise', async () => {
const rows: OpenCodeGoProtocolRule[] = [
{ pattern: 'gpt-*', protocol: 'responses', extraProtocols: ['chat_completions'] },
{ pattern: 'grok-*', protocol: 'chat_completions' }
]
const wrapper = mountEditor(rows)
await wrapper.get('[data-testid="opencode-go-protocol-select-0"]').setValue('chat_completions')
expect(rows[0]).toEqual({ pattern: 'gpt-*', protocol: 'chat_completions', extraProtocols: ['responses'] })
await wrapper.get('[data-testid="opencode-go-protocol-select-0"]').setValue('anthropic')
expect(rows[0]).toEqual({ pattern: 'gpt-*', protocol: 'anthropic', extraProtocols: ['responses'] })
await wrapper.get('[data-testid="opencode-go-protocol-select-1"]').setValue('responses')
expect(rows[1]).toEqual({ pattern: 'grok-*', protocol: 'responses' })
})
it('describes the model-list fallback only for providers with a model catalog', () => {
expect(mountEditor([]).get('[data-testid="opencode-go-protocol-fallback"]').text())
.toContain('admin.accounts.opencodeGo.protocolRules.catalogFallback')
expect(mountEditor([], 'opencode_go', 'go').get('[data-testid="opencode-go-protocol-fallback"]').text())
.toContain('admin.accounts.opencodeGo.protocolRules.fallback')
})
})
@@ -13,6 +13,8 @@ import {
OPENCODE_GO_BASE_URL,
OPENCODE_ZEN_ANTHROPIC_BASE_URL,
OPENCODE_ZEN_BASE_URL,
cnBalanceCellVisible,
cnQuotaCellVisible,
cnSupportsNativeResponses,
defaultCNAdaptiveBaseUrls,
defaultCNBaseUrl,
@@ -21,13 +23,14 @@ import {
isHeaderOverrideCapable,
isMultiProtocolApiKeyPlatform,
providerAccountModes,
providerHasModelCatalog,
providerNativeProtocols,
providerRoutesByModel,
resolveProviderAccountMode,
type CnApiProtocol
} from '../credentialsBuilder'
// ===== 改为读取平台清单之前的实现,作为等价基准 =====
// ===== 改为读取平台清单之前的实现,作为等价基准(补入之后内置登记的 Command Code) =====
const legacyOpenCode = {
goBase: 'https://opencode.ai/zen/go/v1',
@@ -53,15 +56,15 @@ const legacyZenRules = [
]
function legacyIsMultiProtocol(platform: string): boolean {
return ['kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go'].includes(platform)
return ['kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'command_code'].includes(platform)
}
function legacySupportsResponses(platform: string): boolean {
return ['deepseek', 'kimi', 'minimax', 'opencode_go'].includes(platform)
return ['deepseek', 'kimi', 'minimax', 'opencode_go', 'command_code'].includes(platform)
}
function legacyHeaderOverride(platform: string, type: string): boolean {
if (['anthropic', 'openai', 'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go'].includes(platform)) {
if (['anthropic', 'openai', 'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'command_code'].includes(platform)) {
return type === 'apikey'
}
if (platform === 'grok') return type === 'apikey' || type === 'oauth'
@@ -185,13 +188,50 @@ describe('credentialsBuilder derives multi-protocol data from the platform catal
})
})
describe('credentialsBuilder built-in Command Code provider', () => {
it('uses the Command Code endpoints and routes Claude / GPT by model', () => {
expect(isMultiProtocolApiKeyPlatform('command_code')).toBe(true)
expect(providerRoutesByModel('command_code')).toBe(true)
expect(providerAccountModes('command_code')).toEqual(['payg'])
expect(defaultCNAdaptiveBaseUrls('command_code', 'payg')).toEqual({
chat_completions: 'https://api.commandcode.ai/provider/v1',
anthropic: 'https://api.commandcode.ai/provider',
responses: 'https://api.commandcode.ai/provider/v1'
})
expect(defaultProviderProtocolRules('command_code', 'payg')).toEqual([
{ pattern: 'claude-*', protocol: 'anthropic' },
{ pattern: 'gpt-*', protocol: 'responses', extraProtocols: ['chat_completions'] }
])
expect(providerHasModelCatalog('command_code')).toBe(true)
expect(providerHasModelCatalog('opencode_go')).toBe(false)
})
it('shows both the quota windows and the credit balance for official-host API keys only', () => {
const account = (credentials: Record<string, unknown>, type = 'apikey') => ({ platform: 'command_code', type, credentials })
expect(cnQuotaCellVisible(account({ account_mode: 'payg' }))).toBe(true)
expect(cnBalanceCellVisible(account({ account_mode: 'payg' }))).toBe(true)
expect(cnQuotaCellVisible(account({ base_url: 'https://api.commandcode.ai/provider/v1' }))).toBe(true)
// 自定义中转与非 https 端点:后端不查官方用量接口,单元格不显示。
const relay = account({ api_protocol: 'chat_completions', base_url: 'https://relay.example.com/v1' })
expect(cnQuotaCellVisible(relay)).toBe(false)
expect(cnBalanceCellVisible(relay)).toBe(false)
expect(cnQuotaCellVisible(account({ base_url: 'http://api.commandcode.ai/provider/v1' }))).toBe(false)
// 按模型分流的账号默认 adaptive,看 Chat Completions 分协议端点。
const adaptiveRelay = account({ api_base_urls: { chat_completions: 'https://relay.example.com/v1' } })
expect(cnQuotaCellVisible(adaptiveRelay)).toBe(false)
expect(cnBalanceCellVisible(account({ account_mode: 'payg' }, 'oauth'))).toBe(false)
expect(cnQuotaCellVisible({ platform: 'deepseek', type: 'apikey', credentials: { account_mode: 'payg' } })).toBe(false)
expect(cnBalanceCellVisible({ platform: 'zhipu', type: 'apikey', credentials: { account_mode: 'payg' } })).toBe(false)
})
})
describe('credentialsBuilder picks up newly registered providers from the catalog', () => {
const serverCatalog: PlatformCatalog = {
platforms: [
...BUILTIN_PLATFORM_CATALOG.platforms,
{
id: 'command_code',
display_name: 'Command Code',
id: 'acme_router',
display_name: 'Acme Router',
gateway: 'openai',
cn_provider: false,
multi_protocol: {
@@ -201,8 +241,8 @@ describe('credentialsBuilder picks up newly registered providers from the catalo
{
mode: 'standard',
base_urls: {
chat_completions: 'https://api.commandcode.ai/provider/v1',
anthropic: 'https://api.commandcode.ai/provider'
chat_completions: 'https://api.acme-router.example/provider/v1',
anthropic: 'https://api.acme-router.example/provider'
},
protocol_rules: [
{ pattern: 'claude-*', protocol: 'anthropic' },
@@ -213,29 +253,29 @@ describe('credentialsBuilder picks up newly registered providers from the catalo
}
}
],
composite_precedence: [...BUILTIN_PLATFORM_CATALOG.composite_precedence, 'command_code']
composite_precedence: [...BUILTIN_PLATFORM_CATALOG.composite_precedence, 'acme_router']
}
it('treats the new provider as a multi-protocol API-key platform', () => {
expect(isMultiProtocolApiKeyPlatform('command_code')).toBe(false)
expect(isMultiProtocolApiKeyPlatform('acme_router')).toBe(false)
setPlatformCatalog(serverCatalog)
expect(isMultiProtocolApiKeyPlatform('command_code')).toBe(true)
expect(isHeaderOverrideCapable('command_code', 'apikey')).toBe(true)
expect(isHeaderOverrideCapable('command_code', 'oauth')).toBe(false)
expect(cnSupportsNativeResponses('command_code')).toBe(false)
expect(providerNativeProtocols('command_code')).toEqual(['chat_completions', 'anthropic'])
expect(providerRoutesByModel('command_code')).toBe(true)
expect(resolveProviderAccountMode('command_code', 'payg')).toBe('standard')
expect(defaultCNBaseUrl('command_code', 'standard', 'anthropic')).toBe('https://api.commandcode.ai/provider')
expect(defaultCNBaseUrl('command_code', 'standard', 'responses')).toBe('https://api.commandcode.ai/provider/v1')
expect(defaultCNAdaptiveBaseUrls('command_code', 'standard')).toEqual({
chat_completions: 'https://api.commandcode.ai/provider/v1',
anthropic: 'https://api.commandcode.ai/provider',
expect(isMultiProtocolApiKeyPlatform('acme_router')).toBe(true)
expect(isHeaderOverrideCapable('acme_router', 'apikey')).toBe(true)
expect(isHeaderOverrideCapable('acme_router', 'oauth')).toBe(false)
expect(cnSupportsNativeResponses('acme_router')).toBe(false)
expect(providerNativeProtocols('acme_router')).toEqual(['chat_completions', 'anthropic'])
expect(providerRoutesByModel('acme_router')).toBe(true)
expect(resolveProviderAccountMode('acme_router', 'payg')).toBe('standard')
expect(defaultCNBaseUrl('acme_router', 'standard', 'anthropic')).toBe('https://api.acme-router.example/provider')
expect(defaultCNBaseUrl('acme_router', 'standard', 'responses')).toBe('https://api.acme-router.example/provider/v1')
expect(defaultCNAdaptiveBaseUrls('acme_router', 'standard')).toEqual({
chat_completions: 'https://api.acme-router.example/provider/v1',
anthropic: 'https://api.acme-router.example/provider',
responses: ''
})
// 非法协议的规则被丢弃。
expect(defaultProviderProtocolRules('command_code', 'standard')).toEqual([
expect(defaultProviderProtocolRules('acme_router', 'standard')).toEqual([
{ pattern: 'claude-*', protocol: 'anthropic' }
])
})
@@ -23,6 +23,7 @@ import {
parseHeaderOverridesJson,
parseOpenCodeGoProtocolRules,
planTypeDisplayLabel,
protocolRuleSet,
readPlanType,
serializeHeaderOverrideRows,
splitHeaderOverridesObject,
@@ -122,9 +123,10 @@ describe('openCodeGo protocol rules', () => {
expect(defaultCNBaseUrl('opencode_go', 'go', 'anthropic')).toBe('https://opencode.ai/zen/go')
expect(defaultOpenCodeProtocolRules('zen').some(rule => rule.pattern === 'claude-*')).toBe(true)
expect(defaultOpenCodeProtocolRules('go').some(rule => rule.pattern === 'minimax-*')).toBe(true)
expect(cnQuotaCellVisible('opencode_go', 'zen')).toBe(false)
expect(cnQuotaCellVisible('opencode_go', 'go')).toBe(true)
expect(cnQuotaCellVisible('opencode_go', '')).toBe(true)
const openCodeAccount = (account_mode?: string) => ({ platform: 'opencode_go', type: 'apikey', credentials: { account_mode } })
expect(cnQuotaCellVisible(openCodeAccount('zen'))).toBe(false)
expect(cnQuotaCellVisible(openCodeAccount('go'))).toBe(true)
expect(cnQuotaCellVisible(openCodeAccount())).toBe(true)
})
it('parses stored rules and skips invalid entries', () => {
@@ -143,6 +145,31 @@ describe('openCodeGo protocol rules', () => {
])
})
it('round-trips protocol sets with the preferred protocol first', () => {
const parsed = parseOpenCodeGoProtocolRules([
{ pattern: 'gpt-*', protocol: 'responses', protocols: ['responses', 'chat_completions', 'adaptive', 'chat_completions'] },
{ pattern: 'glm-*', protocols: ['anthropic', 'chat_completions'] },
{ pattern: 'kimi-*', protocol: 'chat_completions', protocols: ['chat_completions'] }
])
expect(parsed).toEqual([
{ pattern: 'gpt-*', protocol: 'responses', extraProtocols: ['chat_completions'] },
{ pattern: 'glm-*', protocol: 'anthropic', extraProtocols: ['chat_completions'] },
{ pattern: 'kimi-*', protocol: 'chat_completions' }
])
expect(protocolRuleSet(parsed![0])).toEqual(['responses', 'chat_completions'])
const cloned = cloneOpenCodeGoProtocolRules(parsed!)
expect(cloned).toEqual(parsed)
expect(cloned[0].extraProtocols).not.toBe(parsed![0].extraProtocols)
const credentials: Record<string, unknown> = {}
applyOpenCodeGoProtocolRules(credentials, [{ pattern: ' GPT-* ', protocol: 'responses', extraProtocols: ['chat_completions', 'responses'] }, ...parsed!.slice(2)], 'create')
expect(credentials[OPENCODE_GO_PROTOCOL_RULES_KEY]).toEqual([
{ pattern: 'gpt-*', protocol: 'responses', protocols: ['responses', 'chat_completions'] },
{ pattern: 'kimi-*', protocol: 'chat_completions' }
])
})
it('writes lowercase patterns on create and keeps an empty list on edit', () => {
const created: Record<string, unknown> = {}
applyOpenCodeGoProtocolRules(created, cloneOpenCodeGoProtocolRules(), 'create')
@@ -296,6 +296,11 @@ export function providerRoutesByModel(platform: string): boolean {
return getProviderProfile(platform)?.routing === 'by_model'
}
/** 上游模型目录给出每个模型支持的协议;未命中分流规则的模型据此选协议。 */
export function providerHasModelCatalog(platform: string): boolean {
return getProviderProfile(platform)?.model_catalog === true
}
/** 供应商的接入模式,默认模式在前。 */
export function providerAccountModes(platform: string): string[] {
return getProviderProfile(platform)?.modes.map(item => item.mode) ?? []
@@ -327,17 +332,40 @@ export const OPENCODE_GO_PROTOCOL_RULES_KEY = 'protocol_rules'
export interface OpenCodeGoProtocolRule {
pattern: string
/** 首选协议:入站协议不在模型支持的协议中时使用。 */
protocol: CnNativeApiProtocol
/** 模型同时支持的其他协议(不含 protocol);入站协议在其中时同协议直通,免去协议转换。 */
extraProtocols?: CnNativeApiProtocol[]
}
function isNativeOpenCodeGoProtocol(value: unknown): value is CnNativeApiProtocol {
return value === 'chat_completions' || value === 'anthropic' || value === 'responses'
}
/** 从 protocols 列表中取出首选协议以外的原生协议(去重、保持顺序)。 */
function extraRuleProtocols(protocol: CnNativeApiProtocol, protocols: unknown): CnNativeApiProtocol[] {
if (!Array.isArray(protocols)) return []
const extra: CnNativeApiProtocol[] = []
for (const item of protocols) {
if (isNativeOpenCodeGoProtocol(item) && item !== protocol && !extra.includes(item)) extra.push(item)
}
return extra
}
function buildRule(pattern: string, protocol: CnNativeApiProtocol, protocols?: unknown): OpenCodeGoProtocolRule {
const extra = extraRuleProtocols(protocol, protocols)
return extra.length > 0 ? { pattern, protocol, extraProtocols: extra } : { pattern, protocol }
}
/** 规则支持的协议集合,首项为首选协议。 */
export function protocolRuleSet(rule: OpenCodeGoProtocolRule): CnNativeApiProtocol[] {
return [rule.protocol, ...extraRuleProtocols(rule.protocol, rule.extraProtocols)]
}
function normalizeProfileRules(rules: ProviderProtocolRule[] | undefined): OpenCodeGoProtocolRule[] {
return (rules ?? [])
.filter(rule => rule.pattern && isNativeOpenCodeGoProtocol(rule.protocol))
.map(rule => ({ pattern: rule.pattern, protocol: rule.protocol as CnNativeApiProtocol }))
.map(rule => buildRule(rule.pattern, rule.protocol as CnNativeApiProtocol, rule.protocols))
}
function builtinProtocolRules(platform: string, mode: string): OpenCodeGoProtocolRule[] {
@@ -364,7 +392,7 @@ export function defaultOpenCodeProtocolRules(mode: string = 'go'): OpenCodeGoPro
export function cloneOpenCodeGoProtocolRules(
rules: OpenCodeGoProtocolRule[] = DEFAULT_OPENCODE_GO_PROTOCOL_RULES
): OpenCodeGoProtocolRule[] {
return rules.map(rule => ({ pattern: rule.pattern, protocol: rule.protocol }))
return rules.map(rule => buildRule(rule.pattern, rule.protocol, rule.extraProtocols))
}
export function parseOpenCodeGoProtocolRules(raw: unknown): OpenCodeGoProtocolRule[] | null {
@@ -376,9 +404,12 @@ export function parseOpenCodeGoProtocolRules(raw: unknown): OpenCodeGoProtocolRu
const pattern = typeof (item as { pattern?: unknown }).pattern === 'string'
? (item as { pattern: string }).pattern.trim()
: ''
const protocol = (item as { protocol?: unknown }).protocol
const protocols = (item as { protocols?: unknown }).protocols
let protocol = (item as { protocol?: unknown }).protocol
// 与后端一致:只给了 protocols 时首项即首选协议。
if ((protocol == null || protocol === '') && Array.isArray(protocols)) protocol = protocols[0]
if (!pattern || !isNativeOpenCodeGoProtocol(protocol)) continue
rules.push({ pattern, protocol })
rules.push(buildRule(pattern, protocol, protocols))
}
return rules
}
@@ -389,11 +420,17 @@ export function applyOpenCodeGoProtocolRules(
mode: 'create' | 'edit'
): void {
const serialized = rules
.map(rule => ({
pattern: rule.pattern.trim().toLowerCase(),
protocol: rule.protocol
}))
.filter(rule => rule.pattern.length > 0 && isNativeOpenCodeGoProtocol(rule.protocol))
.filter(rule => isNativeOpenCodeGoProtocol(rule.protocol))
.map(rule => {
const entry: { pattern: string; protocol: CnNativeApiProtocol; protocols?: CnNativeApiProtocol[] } = {
pattern: rule.pattern.trim().toLowerCase(),
protocol: rule.protocol
}
const protocols = protocolRuleSet(rule)
if (protocols.length > 1) entry.protocols = protocols
return entry
})
.filter(rule => rule.pattern.length > 0)
if (serialized.length > 0 || mode === 'edit') {
credentials[OPENCODE_GO_PROTOCOL_RULES_KEY] = serialized
}
@@ -483,13 +520,73 @@ export function defaultCNAdaptiveBaseUrls(
// CNProviderQuotaCell / CNProviderBalanceCell 与 AccountUsageCell 的占位符判定
// 共用,避免多处复制条件后一处改另一处漏改。
export function cnQuotaCellVisible(platform: string, accountMode: string): boolean {
if (platform === 'opencode_go') return accountMode !== 'zen'
return (platform === 'kimi' || platform === 'zhipu' || platform === 'minimax') && accountMode === 'coding'
/** 用量单元格可见性判定所需的账号字段。 */
export interface CNUsageCellAccount {
platform: string
type: string
credentials?: Record<string, unknown>
}
export function cnBalanceCellVisible(platform: string, accountMode: string): boolean {
return (platform === 'kimi' || platform === 'deepseek') && accountMode !== 'coding'
// Command Code 的积分与用量接口只查官方主机上的 API Key 账号(与后端
// commandCodeUsageSupported 一致):自定义中转的 Key 不发往官方接口,后端直接拒绝
// 探测,单元格只会显示一行报错。
const OFFICIAL_ACCOUNT_API_PLATFORMS = ['command_code']
function cnUsageCellAccountMode(account: CNUsageCellAccount): string {
const mode = account.credentials?.account_mode
return typeof mode === 'string' ? mode : ''
}
/** 与后端 Account.GetAPIProtocol 一致:按模型分流的供应商未指定原生协议时为 adaptive。 */
function accountUsesAdaptiveProtocol(platform: string, mode: string, protocol: unknown): boolean {
if (protocol === 'adaptive') return true
if (protocol === 'chat_completions' || protocol === 'anthropic') return false
if (protocol === 'responses' && cnSupportsNativeResponses(platform, mode)) return false
return providerRoutesByModel(platform)
}
/**
* 账号实际使用的 Chat Completions 端点(与后端 Account.GetOpenAIBaseURL 一致):adaptive
* 账号优先分协议端点,其次 base_url,都没填时为平台清单的默认端点。
*/
function accountChatBaseUrl(account: CNUsageCellAccount, mode: string): string {
const credentials = account.credentials
const baseUrls = credentials?.api_base_urls
if (baseUrls && typeof baseUrls === 'object' && accountUsesAdaptiveProtocol(account.platform, mode, credentials?.api_protocol)) {
const chat = (baseUrls as Record<string, unknown>).chat_completions
if (typeof chat === 'string' && chat.trim()) return chat.trim()
}
const baseUrl = credentials?.base_url
if (typeof baseUrl === 'string' && baseUrl.trim()) return baseUrl.trim()
return defaultCNBaseUrl(account.platform, mode)
}
/** 官方主机即平台清单默认端点的主机,且必须是 https。 */
function officialAccountAPISupported(account: CNUsageCellAccount, mode: string): boolean {
if (account.type !== 'apikey') return false
try {
const target = new URL(accountChatBaseUrl(account, mode))
const official = new URL(defaultCNBaseUrl(account.platform, mode))
return target.protocol === 'https:' && target.hostname === official.hostname
} catch {
return false
}
}
export function cnQuotaCellVisible(account: CNUsageCellAccount): boolean {
const { platform } = account
const mode = cnUsageCellAccountMode(account)
if (platform === 'opencode_go') return mode !== 'zen'
// Command Code:订阅套餐窗口与积分同源,一次探测同时刷新两者。
if (OFFICIAL_ACCOUNT_API_PLATFORMS.includes(platform)) return officialAccountAPISupported(account, mode)
return (platform === 'kimi' || platform === 'zhipu' || platform === 'minimax') && mode === 'coding'
}
export function cnBalanceCellVisible(account: CNUsageCellAccount): boolean {
const { platform } = account
const mode = cnUsageCellAccountMode(account)
if (OFFICIAL_ACCOUNT_API_PLATFORMS.includes(platform)) return officialAccountAPISupported(account, mode)
return (platform === 'kimi' || platform === 'deepseek') && mode !== 'coding'
}
/**
@@ -101,12 +101,12 @@ describe('UserPlatformQuotaModal', () => {
expect(apiMocks.getPlatformQuotas).toHaveBeenCalledWith(99)
})
it('renders all eleven supported platforms with empty limits', async () => {
it('renders all twelve supported platforms with empty limits', async () => {
const w = await mountAndOpen()
const rows = w.findAll('tbody tr')
expect(rows.map(row => row.find('td').text())).toEqual([
'anthropic', 'openai', 'gemini', 'antigravity', 'grok',
'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe',
'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe', 'command_code',
])
for (const row of rows) {
const inputs = row.findAll('input[type=number]')
@@ -116,11 +116,11 @@ describe('UserPlatformQuotaModal', () => {
w.unmount()
})
it.each(['kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe'] as const)(
it.each(['kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe', 'command_code'] as const)(
'saves edits to %s without erasing existing platform limits', async (platform) => {
const existing: PlatformQuotaUpdateItem[] = [
{ platform: 'openai', daily_limit_usd: 10, weekly_limit_usd: 20, monthly_limit_usd: 100 },
...(['kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe'] as const).map(p => ({
...(['kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe', 'command_code'] as const).map(p => ({
platform: p, daily_limit_usd: 0, weekly_limit_usd: null, monthly_limit_usd: 50,
})),
]
@@ -137,7 +137,7 @@ describe('UserPlatformQuotaModal', () => {
: item)
expect(apiMocks.updatePlatformQuotas).toHaveBeenCalledTimes(1)
expect(apiMocks.updatePlatformQuotas).toHaveBeenCalledWith(99, expect.arrayContaining(expected))
expect(apiMocks.updatePlatformQuotas.mock.calls[0][1]).toHaveLength(11)
expect(apiMocks.updatePlatformQuotas.mock.calls[0][1]).toHaveLength(12)
expect(w.emitted('success')).toHaveLength(1)
w.unmount()
},
@@ -152,13 +152,13 @@ describe('UserPlatformQuotaModal', () => {
})
const w = await mountAndOpen()
const inputs = w.findAll('input[type=number]')
// 11 platforms × 3 windows = 33 inputs
expect(inputs.length).toBe(33)
// 12 platforms × 3 windows = 36 inputs
expect(inputs.length).toBe(36)
// 第一个 input 是 anthropic.daily = 10
expect((inputs[0].element as HTMLInputElement).value).toBe('10')
})
it('保存提交完整 11 platform payload', async () => {
it('保存提交完整 12 platform payload', async () => {
apiMocks.getPlatformQuotas.mockResolvedValueOnce({
platform_quotas: [
{ platform: 'openai', daily_limit_usd: null, weekly_limit_usd: 20, monthly_limit_usd: null,
@@ -175,7 +175,7 @@ describe('UserPlatformQuotaModal', () => {
expect(apiMocks.updatePlatformQuotas).toHaveBeenCalledTimes(1)
const [uid, payload] = apiMocks.updatePlatformQuotas.mock.calls[0]
expect(uid).toBe(99)
expect(payload).toHaveLength(11) // 11 platforms always submitted
expect(payload).toHaveLength(12) // 12 platforms always submitted
const openai = payload.find((p: any) => p.platform === 'openai')
expect(openai.weekly_limit_usd).toBe(20)
})
@@ -240,7 +240,7 @@ describe('UserPlatformQuotaModal', () => {
it('未配置限额的平台重置按钮禁用并提示不可用', async () => {
const w = await mountAndOpen()
const resetBtns = w.findAll('button').filter((b) => b.text() === '↻')
expect(resetBtns.length).toBe(33) // 11 平台 × 3 窗口
expect(resetBtns.length).toBe(36) // 12 平台 × 3 窗口
for (const b of resetBtns) {
expect((b.element as HTMLButtonElement).disabled).toBe(true)
expect(b.attributes('title')).toBe('admin.users.platformQuota.reset.unavailable')
@@ -54,6 +54,17 @@
d="M23.748 4.651c-.254-.124-.364.113-.512.233-.051.04-.094.09-.137.137-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.155-.708-.311-.955-.65-.172-.24-.219-.509-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.094.172.187.129.323-.082.28-.18.553-.266.833-.055.179-.137.218-.328.14a5.5 5.5 0 0 1-1.737-1.179c-.857-.828-1.631-1.743-2.597-2.46a12 12 0 0 0-.689-.47c-.985-.957.13-1.743.387-1.836.27-.098.094-.433-.778-.428-.872.003-1.67.295-2.687.685a3 3 0 0 1-.465.136a9.6 9.6 0 0 0-2.883-.101c-1.885.21-3.39 1.1-4.497 2.622C.082 8.776-.231 10.854.152 13.02c.403 2.284 1.568 4.175 3.36 5.653 1.857 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.132-.284 4.994-1.86.47.234.962.328 1.78.398.629.058 1.235-.031 1.705-.129.735-.155.684-.836.418-.961-2.155-1.004-1.682-.595-2.112-.926 1.095-1.295 2.768-3.598 3.284-6.733.05-.346.115-.834.108-1.114-.004-.171.035-.238.23-.257a4.2 4.2 0 0 0 1.545-.475c1.397-.763 1.96-2.016 2.093-3.517.02-.23-.004-.467-.247-.588M11.58 18.168c-2.088-1.642-3.101-2.183-3.52-2.16-.39.024-.32.472-.234.763.09.288.207.487.371.74.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.168-1.361-.801-2.5-1.86-3.301-3.306-.775-1.393-1.225-2.888-1.299-4.482-.02-.385.094-.522.477-.592a4.7 4.7 0 0 1 1.53-.038c2.131.311 3.946 1.264 5.467 2.774.868.86 1.525 1.887 2.202 2.89.72 1.066 1.494 2.082 2.48 2.915.348.291.626.513.892.677-.802.09-2.14.109-3.055-.615zm1.001-6.44a.306.306 0 0 1 .415-.287a.3.3 0 0 1 .113.074a.3.3 0 0 1 .086.214c0 .17-.136.307-.308.307a.303.303 0 0 1-.306-.307m3.11 1.596c-.2.081-.4.151-.591.16a1.25 1.25 0 0 1-.798-.254c-.274-.23-.47-.358-.551-.758a1.7 1.7 0 0 1 .015-.588c.07-.327-.007-.537-.238-.727-.188-.156-.426-.199-.689-.199a.6.6 0 0 1-.254-.078a.253.253 0 0 1-.114-.358a1 1 0 0 1 .192-.21c.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.392.451.462.576.685.915.176.264.336.536.446.848.066.194-.02.353-.25.45"
/>
</svg>
<!-- Command Code official mark (⌘ cut out of a rounded square), from commandcode.ai/favicon/2024/safari-pinned-tab.svg -->
<svg v-else-if="platform === 'command_code'" :class="sizeClass" viewBox="0 0 700 700" fill="currentColor">
<g transform="translate(0,700) scale(0.1,-0.1)">
<path d="M2305 6994 c-371 -13 -682 -39 -893 -74 -598 -103 -963 -350 -1172 -795 -126 -267 -186 -576 -222 -1130 -19 -287 -18 -2669 0 -2950 53 -794 172 -1175 464 -1481 286 -298 672 -437 1367 -489 616 -47 2694 -46 3267 0 685 56 1056 186 1339 470 289 289 424 685 475 1395 22 310 32 1055 27 1915 -6 868 -13 1102 -42 1417 -55 589 -188 950 -448 1216 -305 311 -678 435 -1487 493 -141 10 -2428 21 -2675 13z m33 -1350 c322 -66 580 -324 646 -646 12 -57 16 -136 16 -303 l0 -225 474 0 474 0 5 243 c4 201 8 255 25 318 67 248 222 437 447 545 138 67 195 79 370 79 143 -1 154 -2 245 -33 279 -96 476 -307 552 -587 29 -111 29 -297 -1 -410 -69 -261 -263 -478 -511 -572 -113 -43 -194 -53 -431 -53 l-219 0 0 -474 0 -474 238 -5 c253 -5 310 -14 432 -64 243 -99 438 -327 495 -576 47 -209 17 -419 -88 -607 -57 -102 -205 -250 -307 -307 -433 -242 -960 -71 -1169 379 -63 134 -74 200 -79 466 l-4 232 -474 0 -474 0 0 -219 c0 -149 -5 -243 -14 -294 -60 -312 -299 -565 -611 -649 -112 -30 -298 -30 -410 0 -519 139 -776 708 -534 1185 106 210 301 365 538 429 63 17 117 21 319 25 l242 5 0 474 0 474 -225 0 c-252 0 -329 11 -452 62 -485 201 -664 796 -372 1232 116 174 310 306 514 349 93 20 248 21 343 1z" />
<path d="M2080 5174 c-187 -50 -302 -241 -256 -425 31 -119 118 -216 231 -256 42 -15 84 -18 260 -18 l210 0 0 210 c0 176 -3 218 -18 260 -39 111 -136 200 -252 230 -72 18 -103 18 -175 -1z" />
<path d="M4705 5176 c-75 -19 -125 -49 -178 -105 -86 -92 -91 -112 -95 -373 l-3 -228 185 0 c264 0 337 20 429 119 74 79 92 127 92 241 0 83 -3 102 -27 150 -74 150 -249 236 -403 196z" />
<path d="M3000 3525 l0 -475 475 0 475 0 0 475 0 475 -475 0 -475 0 0 -475z" />
<path d="M2051 2550 c-59 -22 -68 -27 -129 -84 -178 -167 -127 -463 98 -574 48 -24 67 -27 150 -27 114 0 162 18 241 92 99 92 119 165 119 428 l0 185 -212 0 c-184 -1 -220 -3 -267 -20z" />
<path d="M4432 2348 l3 -224 33 -66 c38 -77 92 -130 171 -167 48 -22 70 -26 146 -26 82 0 97 3 157 33 77 38 130 92 167 171 22 47 26 70 26 146 0 76 -4 99 -26 146 -37 79 -90 133 -167 171 l-66 33 -224 3 -223 3 3 -223z" />
</g>
</svg>
<!-- Composite group icon -->
<svg v-else-if="platform === 'composite'" :class="sizeClass" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="6" cy="12" r="3" />
@@ -1335,6 +1335,7 @@ function generateRoutedCodexFiles(
minimax: 'MiniMax',
opencode_go: 'OpenCode',
typesafe: 'TypeSafe / Jev',
command_code: 'Command Code',
composite: 'Composite'
}
const label = labels[platform]
@@ -13,6 +13,7 @@ import { platformLabel } from '@/utils/platformColors'
import { normalizePlatformQuotasMap, sanitizePlatformQuotasMap } from '@/api/admin/settings'
import { platformQuotaPlatforms } from '@/api/admin/users'
// 改为读取平台清单之前的平台字面量,加上之后内置登记的 Command Code。
const concretePlatforms = [
'anthropic',
'openai',
@@ -24,7 +25,8 @@ const concretePlatforms = [
'deepseek',
'minimax',
'opencode_go',
'typesafe'
'typesafe',
'command_code'
]
describe('platform option catalogs', () => {
@@ -44,9 +46,9 @@ describe('platform catalog with a newly registered platform', () => {
const withNewProvider = {
platforms: [
...BUILTIN_PLATFORM_CATALOG.platforms,
{ id: 'command_code', display_name: 'Command Code', gateway: 'openai', cn_provider: false }
{ id: 'acme_router', display_name: 'Acme Router', gateway: 'openai', cn_provider: false }
],
composite_precedence: [...BUILTIN_PLATFORM_CATALOG.composite_precedence, 'command_code']
composite_precedence: [...BUILTIN_PLATFORM_CATALOG.composite_precedence, 'acme_router']
}
afterEach(() => {
@@ -57,11 +59,11 @@ describe('platform catalog with a newly registered platform', () => {
expect(listPlatformIds()).toEqual(concretePlatforms)
expect(compositePrecedencePlatformIds()).toEqual([
'anthropic', 'gemini', 'openai', 'antigravity', 'grok',
'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe'
'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe', 'command_code'
])
expect(CONCRETE_PLATFORM_OPTIONS.map((option) => option.label)).toEqual([
'Anthropic', 'OpenAI', 'Gemini', 'Antigravity', 'Grok',
'Kimi', 'Zhipu GLM', 'DeepSeek', 'MiniMax', 'OpenCode', 'TypeSafe / Jev'
'Kimi', 'Zhipu GLM', 'DeepSeek', 'MiniMax', 'OpenCode', 'TypeSafe / Jev', 'Command Code'
])
expect(platformLabel('zhipu')).toBe('Zhipu GLM')
expect(platformLabel('composite')).toBe('Composite')
@@ -70,22 +72,22 @@ describe('platform catalog with a newly registered platform', () => {
it('adds newly registered platforms to options, labels and quota lists reactively', () => {
const optionValues = computed(() => CONCRETE_PLATFORM_OPTIONS.map((option) => option.value))
expect(optionValues.value).not.toContain('command_code')
expect(optionValues.value).not.toContain('acme_router')
setPlatformCatalog(withNewProvider)
expect(optionValues.value).toEqual([...concretePlatforms, 'command_code'])
expect(optionValues.value).toEqual([...concretePlatforms, 'acme_router'])
expect(GROUP_PLATFORM_OPTIONS.map((option) => option.value)).toEqual([
...concretePlatforms,
'command_code',
'acme_router',
'composite'
])
expect(platformLabel('command_code')).toBe('Command Code')
expect(platformDisplayName('command_code')).toBe('Command Code')
expect(platformLabel('acme_router')).toBe('Acme Router')
expect(platformDisplayName('acme_router')).toBe('Acme Router')
expect(platformLabel('unregistered')).toBe('unregistered')
expect(platformQuotaPlatforms()).toContain('command_code')
expect(Object.keys(normalizePlatformQuotasMap())).toEqual([...concretePlatforms, 'command_code'])
expect(sanitizePlatformQuotasMap({ command_code: { daily: 5, weekly: -1, monthly: null } }).command_code).toEqual({
expect(platformQuotaPlatforms()).toContain('acme_router')
expect(Object.keys(normalizePlatformQuotasMap())).toEqual([...concretePlatforms, 'acme_router'])
expect(sanitizePlatformQuotasMap({ acme_router: { daily: 5, weekly: -1, monthly: null } }).acme_router).toEqual({
daily: 5,
weekly: null,
monthly: null
@@ -221,6 +221,41 @@
"gateway": "anthropic",
"cn_provider": false,
"litellm_provider": "typesafe"
},
{
"id": "command_code",
"display_name": "Command Code",
"gateway": "openai",
"cn_provider": false,
"multi_protocol": {
"default_mode": "payg",
"routing": "by_model",
"model_catalog": true,
"modes": [
{
"mode": "payg",
"base_urls": {
"anthropic": "https://api.commandcode.ai/provider",
"chat_completions": "https://api.commandcode.ai/provider/v1",
"responses": "https://api.commandcode.ai/provider/v1"
},
"protocol_rules": [
{
"pattern": "claude-*",
"protocol": "anthropic"
},
{
"pattern": "gpt-*",
"protocol": "responses",
"protocols": [
"responses",
"chat_completions"
]
}
]
}
]
}
}
],
"composite_precedence": [
@@ -234,6 +269,7 @@
"deepseek",
"minimax",
"opencode_go",
"typesafe"
"typesafe",
"command_code"
]
}
@@ -15,7 +15,10 @@ export type ProviderNativeProtocol = 'chat_completions' | 'anthropic' | 'respons
export interface ProviderProtocolRule {
pattern: string
/** 首选协议;入站协议不在 protocols 中时使用。 */
protocol: string
/** 模型支持的协议集合(首项即 protocol);入站协议在其中时同协议直通。仅一个协议时省略。 */
protocols?: string[]
}
export interface ProviderModeInfo {
@@ -29,6 +32,8 @@ export interface ProviderProfileInfo {
default_mode: string
routing: ProviderRouting
responses_path?: string
/** 上游 /models 为每个模型给出 supported_endpoints,按模型分流时未命中规则的模型据此选协议。 */
model_catalog?: boolean
/** 默认模式在前。 */
modes: ProviderModeInfo[]
}
@@ -175,12 +175,15 @@ export default {
},
protocolRules: {
title: 'Model protocol routing',
hint: 'In adaptive mode, each model is sent to a native upstream protocol. Use an exact ID or a trailing * glob (e.g. grok-*, qwen*). The first matching rule wins; unmatched models use Chat Completions.',
hint: 'In adaptive mode, each model is sent to a native upstream protocol. Use an exact ID or a trailing * glob (e.g. grok-*, qwen*). The first matching rule wins. If the inbound protocol is one the model also supports, the request passes through on that protocol without conversion; otherwise the selected protocol is used.',
patternPlaceholder: 'grok-* or deepseek-v4-flash',
add: 'Add rule',
remove: 'Remove rule',
restoreDefaults: 'Restore defaults',
alsoSupports: 'Also supports',
alsoSupportsHint: 'Requests arriving on one of these protocols are passed through unchanged, avoiding protocol conversion',
fallback: 'Unmatched models → Chat Completions (/v1/chat/completions)',
catalogFallback: 'Unmatched models → protocols from the upstream model list (supported_endpoints in /models); Chat Completions when unavailable',
},
title: 'OpenCode Go usage',
panelHint: 'Usage windows reported by the upstream OpenCode Go account. Refreshed on demand or automatically when enabled.',
@@ -378,12 +378,15 @@ export default {
},
protocolRules: {
title: '模型协议分流',
hint: '自适应模式下按模型匹配上游协议。支持精确 ID 或末尾 * 通配(如 grok-*、qwen*);自上而下第一条命中生效;未命中走 Chat Completions。',
hint: '自适应模式下按模型匹配上游协议。支持精确 ID 或末尾 * 通配(如 grok-*、qwen*);自上而下第一条命中生效。入站协议是模型也支持的协议时同协议直通、不做转换,否则走所选协议。',
patternPlaceholder: 'grok-* 或 deepseek-v4-flash',
add: '添加规则',
remove: '删除规则',
restoreDefaults: '恢复默认',
alsoSupports: '也支持',
alsoSupportsHint: '以这些协议进来的请求同协议直通,免去协议转换',
fallback: '未命中以上规则 → Chat Completions(/v1/chat/completions)',
catalogFallback: '未命中以上规则 → 按上游模型列表(/models 的 supported_endpoints)选协议;列表不可用时走 Chat Completions',
},
title: 'OpenCode Go 用量',
panelHint: '上游 OpenCode Go 账号上报的用量窗口。可手动刷新,或开启自动刷新。',
+1 -1
View File
@@ -923,7 +923,7 @@ export interface UpdateGroupRequest {
// ==================== Account & Proxy Types ====================
/** 前端内置专属界面(图标、配色、表单等)的平台。 */
export type KnownAccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'grok' | 'kimi' | 'zhipu' | 'deepseek' | 'minimax' | 'opencode_go' | 'typesafe'
export type KnownAccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'grok' | 'kimi' | 'zhipu' | 'deepseek' | 'minimax' | 'opencode_go' | 'typesafe' | 'command_code'
/**
* 账号平台:内置平台,或后端平台清单中新登记的平台(任意字符串)。
* `string & {}` 保留内置平台的字面量补全。
+2 -1
View File
@@ -17,7 +17,8 @@ const PROVIDER_BY_PLATFORM: Record<GroupPlatform, KeyGroupProvider> = {
antigravity: 'other',
composite: 'other',
opencode_go: 'other',
typesafe: 'other'
typesafe: 'other',
command_code: 'other'
}
export function getKeyGroupProvider(platform: GroupPlatform): KeyGroupProvider {
+15
View File
@@ -20,6 +20,7 @@ export type Platform =
| 'minimax'
| 'opencode_go'
| 'typesafe'
| 'command_code'
| 'composite'
// ── Badge (bg + text + border, for inline badges with border) ───────
@@ -35,6 +36,7 @@ const BADGE: Record<Platform, string> = {
minimax: 'bg-rose-500/10 text-rose-600 border-rose-500/30 dark:text-rose-400',
opencode_go: 'bg-amber-500/10 text-amber-700 border-amber-500/30 dark:text-amber-300',
typesafe: 'bg-sky-500/10 text-sky-700 border-sky-500/30 dark:text-sky-300',
command_code: 'bg-neutral-500/10 text-neutral-700 border-neutral-500/30 dark:text-neutral-300',
composite: 'bg-cyan-500/10 text-cyan-700 border-cyan-500/30 dark:text-cyan-300',
}
const BADGE_DEFAULT = 'bg-slate-500/10 text-slate-600 border-slate-500/30 dark:text-slate-400'
@@ -52,6 +54,7 @@ const BADGE_LIGHT: Record<Platform, string> = {
minimax: 'bg-rose-500/10 text-rose-600 dark:bg-rose-500/10 dark:text-rose-300',
opencode_go: 'bg-amber-500/10 text-amber-700 dark:bg-amber-500/10 dark:text-amber-300',
typesafe: 'bg-sky-500/10 text-sky-700 dark:bg-sky-500/10 dark:text-sky-300',
command_code: 'bg-neutral-500/10 text-neutral-700 dark:bg-neutral-500/10 dark:text-neutral-300',
composite: 'bg-cyan-500/10 text-cyan-700 dark:bg-cyan-500/10 dark:text-cyan-300',
}
@@ -68,6 +71,7 @@ const BORDER: Record<Platform, string> = {
minimax: 'border-rose-500/20 dark:border-rose-500/20',
opencode_go: 'border-amber-500/20 dark:border-amber-500/20',
typesafe: 'border-sky-500/20 dark:border-sky-500/20',
command_code: 'border-neutral-500/20 dark:border-neutral-500/20',
composite: 'border-cyan-500/20 dark:border-cyan-500/20',
}
const BORDER_DEFAULT = 'border-gray-200 dark:border-dark-700'
@@ -85,6 +89,7 @@ const BORDER_STRONG: Record<Platform, string> = {
minimax: 'border-rose-500/35 dark:border-rose-500/30',
opencode_go: 'border-amber-500/35 dark:border-amber-500/30',
typesafe: 'border-sky-500/35 dark:border-sky-500/30',
command_code: 'border-neutral-500/35 dark:border-neutral-500/30',
composite: 'border-cyan-500/35 dark:border-cyan-500/30',
}
const BORDER_STRONG_DEFAULT = 'border-gray-300 dark:border-dark-600'
@@ -103,6 +108,7 @@ const ACCENT: Record<Platform, string> = {
minimax: '#f43f5e', // rose-500
opencode_go: '#f59e0b', // amber-500
typesafe: '#0ea5e9', // sky-500
command_code: '#737373', // neutral-500
composite: '#06b6d4', // cyan-500
}
const ACCENT_DEFAULT = '#14b8a6' // primary-500 (teal)
@@ -120,6 +126,7 @@ const ACCENT_BAR: Record<Platform, string> = {
minimax: 'bg-gradient-to-r from-rose-400 to-rose-500',
opencode_go: 'bg-gradient-to-r from-amber-400 to-amber-500',
typesafe: 'bg-gradient-to-r from-sky-400 to-sky-500',
command_code: 'bg-gradient-to-r from-neutral-400 to-neutral-500',
composite: 'bg-gradient-to-r from-slate-500 to-cyan-500',
}
const ACCENT_BAR_DEFAULT = 'bg-gradient-to-r from-primary-400 to-primary-500'
@@ -137,6 +144,7 @@ const TEXT: Record<Platform, string> = {
minimax: 'text-rose-600 dark:text-rose-400',
opencode_go: 'text-amber-700 dark:text-amber-300',
typesafe: 'text-sky-700 dark:text-sky-300',
command_code: 'text-neutral-700 dark:text-neutral-300',
composite: 'text-cyan-700 dark:text-cyan-300',
}
const TEXT_DEFAULT = 'text-primary-600 dark:text-primary-400'
@@ -154,6 +162,7 @@ const ICON: Record<Platform, string> = {
minimax: 'text-rose-500 dark:text-rose-400',
opencode_go: 'text-amber-500 dark:text-amber-300',
typesafe: 'text-sky-500 dark:text-sky-300',
command_code: 'text-neutral-500 dark:text-neutral-300',
composite: 'text-cyan-600 dark:text-cyan-300',
}
const ICON_DEFAULT = 'text-primary-500 dark:text-primary-400'
@@ -171,6 +180,7 @@ const BUTTON: Record<Platform, string> = {
minimax: 'bg-rose-500 text-white hover:bg-rose-600 active:bg-rose-700 dark:bg-rose-500/80 dark:hover:bg-rose-500',
opencode_go: 'bg-amber-500 text-white hover:bg-amber-600 active:bg-amber-700 dark:bg-amber-500/80 dark:hover:bg-amber-500',
typesafe: 'bg-sky-600 text-white hover:bg-sky-700 active:bg-sky-800 dark:bg-sky-600/80 dark:hover:bg-sky-600',
command_code: 'bg-neutral-500 text-white hover:bg-neutral-600 active:bg-neutral-700 dark:bg-neutral-500/80 dark:hover:bg-neutral-500',
composite: 'bg-cyan-700 text-white hover:bg-cyan-800 active:bg-cyan-900 dark:bg-cyan-600 dark:hover:bg-cyan-500',
}
const BUTTON_DEFAULT = 'bg-primary-500 text-white hover:bg-primary-600 dark:bg-primary-600 dark:hover:bg-primary-500'
@@ -188,6 +198,7 @@ const DISCOUNT: Record<Platform, string> = {
minimax: 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300',
opencode_go: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
typesafe: 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300',
command_code: 'bg-neutral-100 text-neutral-800 dark:bg-neutral-900/40 dark:text-neutral-300',
composite: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-300',
}
const DISCOUNT_DEFAULT = 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
@@ -205,6 +216,7 @@ const GRADIENT: Record<Platform, string> = {
minimax: 'from-rose-500 to-rose-600',
opencode_go: 'from-amber-500 to-amber-600',
typesafe: 'from-sky-500 to-sky-600',
command_code: 'from-neutral-500 to-neutral-600',
composite: 'from-slate-600 to-cyan-600',
}
const GRADIENT_DEFAULT = 'from-primary-500 to-primary-600'
@@ -222,6 +234,7 @@ const GRADIENT_TEXT: Record<Platform, string> = {
minimax: 'text-rose-100',
opencode_go: 'text-amber-100',
typesafe: 'text-sky-100',
command_code: 'text-neutral-100',
composite: 'text-cyan-100',
}
const GRADIENT_TEXT_DEFAULT = 'text-primary-100'
@@ -238,6 +251,7 @@ const GRADIENT_SUBTEXT: Record<Platform, string> = {
minimax: 'text-rose-200',
opencode_go: 'text-amber-200',
typesafe: 'text-sky-200',
command_code: 'text-neutral-200',
composite: 'text-cyan-200',
}
const GRADIENT_SUBTEXT_DEFAULT = 'text-primary-200'
@@ -257,6 +271,7 @@ function isPlatform(p: string): p is Platform {
p === 'minimax' ||
p === 'opencode_go' ||
p === 'typesafe' ||
p === 'command_code' ||
p === 'composite'
)
}