mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-20 00:18:55 +00:00
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:
co-authored by
Claude Opus 5.5
parent
7ffa38a8ae
commit
e2939cd8fa
@@ -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 \
|
||||
|
||||
@@ -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」两种接入方式。
|
||||
|
||||
@@ -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,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())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 统一
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,用于断言归一化默认值 */
|
||||
|
||||
@@ -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 两条)。 */
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
+63
-23
@@ -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 账号上报的用量窗口。可手动刷新,或开启自动刷新。',
|
||||
|
||||
@@ -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 & {}` 保留内置平台的字面量补全。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user