Files
SavitarCandClaude Opus 5.5 e2939cd8fa 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>
2026-10-09 10:11:05 +08:00

386 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"fmt"
"net/http"
"slices"
"strings"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/gin-gonic/gin"
)
// OpenCode Go 是 OpenCode Zen 的订阅网关:同一 API Key 下按模型分流到
// Responses / Chat Completions / Anthropic Messages 三种原生端点,
// 额度窗口为 rolling(5h) / weekly / monthly。
const (
openCodeGoUsagePath = "/usage"
// DefaultOpenCodeGoTestModel is the admin connection-test fallback when
// the UI does not pick a model. glm-5.3 is a Chat Completions catalog ID.
DefaultOpenCodeGoTestModel = "glm-5.3"
protocolRulesCredentialKey = "protocol_rules"
maxProtocolRules = 64
maxProtocolRulePatternLength = 128
)
// DefaultOpenCodeGoModelIDs 是官方文档当前公开的模型 ID 目录,
// 供 /v1/models 在尚未同步上游列表时回退,以及账号白名单预填。
func DefaultOpenCodeGoModelIDs() []string {
return []string{
"grok-4.7",
"grok-4.6",
"gpt-5.6-luna",
"glm-5.3-flash",
"glm-5.3",
"glm-5.2",
"glm-5.1",
"kimi-k3",
"kimi-k2.7-code",
"kimi-k2.6",
"longcat-2.0",
"deepseek-v4-pro",
"deepseek-v4-flash",
"deepseek-v4-flash-vision-exp",
"mimo-v2.5",
"mimo-v2.5-pro",
"minimax-m3",
"minimax-m2.7",
"minimax-m2.5",
"muse-spark-1.3-contributor",
"muse-spark-1.2-contributor",
"qwen3.8-max",
"qwen3.8-flash",
"qwen3.7-max",
"qwen3.7-plus",
"qwen3.6-plus",
"hy4-preview",
"hy3",
"omen-alpha",
}
}
func normalizeOpenCodeGoModelID(model string) string {
model = strings.ToLower(strings.TrimSpace(model))
for _, prefix := range []string{"opencode-go/", "opencode_go/", "opencode/"} {
model = strings.TrimPrefix(model, prefix)
}
return model
}
// IsOpenCodeUnsupportedModel 判断请求的模型是否属于 OpenCode 专有协议端点模型。
// 官方 Zen 上 gemini-* 仅走 Google SDK 专属端点 /zen/v1/models/<id>,
// jev-* 仅走 SystemOne 专属端点 /zen/v1/systemone,
// 在通用 Chat Completions / Anthropic Messages / Responses 网关端点上不可用。
func IsOpenCodeUnsupportedModel(model string) bool {
normalized := normalizeOpenCodeGoModelID(model)
return strings.HasPrefix(normalized, "gemini-") || strings.HasPrefix(normalized, "jev-")
}
func writeOpenCodeUnsupportedModelError(c *gin.Context, isAnthropic bool, model string) error {
msg := fmt.Sprintf("Model '%s' is not supported on OpenCode standard gateway (gemini models require Google SDK endpoint, jev models require System One endpoint)", model)
if c != nil {
if isAnthropic {
c.JSON(http.StatusBadRequest, gin.H{
"type": "error",
"error": gin.H{
"type": "invalid_request_error",
"message": msg,
},
})
} else {
c.JSON(http.StatusBadRequest, gin.H{
"error": gin.H{
"type": "invalid_request_error",
"code": "model_not_supported",
"message": msg,
},
})
}
}
return fmt.Errorf("opencode unsupported model: %s", model)
}
// ProtocolRule is one model-pattern → native protocol mapping.
// Pattern is an exact ID or a suffix glob (foo* / *). First match wins.
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.
func DefaultOpenCodeGoProtocolRules() []ProtocolRule {
return []ProtocolRule{
{Pattern: "grok-*", Protocol: APIProtocolResponses},
{Pattern: "gpt-*", Protocol: APIProtocolResponses},
{Pattern: "muse-spark-*", Protocol: APIProtocolResponses},
{Pattern: "minimax-*", Protocol: APIProtocolAnthropic},
{Pattern: "qwen*", Protocol: APIProtocolAnthropic},
}
}
// DefaultOpenCodeZenProtocolRules 对齐 https://opencode.ai/docs/zen/ 端点表:
// GPT/Grok/Muse Spark → Responses,Claude/Qwen(除 qwen3.8-max) → Anthropic,
// qwen3.8-max 及其余模型 → Chat Completions。
func DefaultOpenCodeZenProtocolRules() []ProtocolRule {
return []ProtocolRule{
{Pattern: "grok-*", Protocol: APIProtocolResponses},
{Pattern: "gpt-*", Protocol: APIProtocolResponses},
{Pattern: "muse-spark-*", Protocol: APIProtocolResponses},
{Pattern: "claude-*", Protocol: APIProtocolAnthropic},
{Pattern: "qwen3.8-max", Protocol: APIProtocolChatCompletions},
{Pattern: "qwen*", Protocol: APIProtocolAnthropic},
}
}
func isNativeUpstreamProtocol(protocol string) bool {
switch protocol {
case APIProtocolChatCompletions, APIProtocolAnthropic, APIProtocolResponses:
return true
default:
return false
}
}
func protocolRulePatternMatches(pattern, model string) bool {
pattern = strings.ToLower(strings.TrimSpace(pattern))
model = normalizeOpenCodeGoModelID(model)
if pattern == "" || model == "" {
return false
}
if pattern == "*" {
return true
}
if strings.HasSuffix(pattern, "*") {
return strings.HasPrefix(model, strings.TrimSuffix(pattern, "*"))
}
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 normalizeProtocolSet(rule.Protocol, rule.Protocols), true
}
}
return nil, false
}
func (a *Account) configuredProtocolRules() ([]ProtocolRule, bool) {
if a == nil || a.Credentials == nil {
return nil, false
}
raw, ok := a.Credentials[protocolRulesCredentialKey]
if !ok || raw == nil {
return nil, false
}
rules, err := parseProtocolRules(raw)
if err != nil {
return nil, false
}
return rules, true
}
func parseProtocolRules(raw any) ([]ProtocolRule, error) {
items, err := protocolRuleItems(raw)
if err != nil {
return nil, err
}
if len(items) > maxProtocolRules {
return nil, fmt.Errorf("protocol_rules supports at most %d entries", maxProtocolRules)
}
rules := make([]ProtocolRule, 0, len(items))
for i, item := range items {
entry, ok := item.(map[string]any)
if !ok {
return nil, fmt.Errorf("protocol_rules[%d] must be an object", i)
}
pattern, _ := entry["pattern"].(string)
protocol, _ := entry["protocol"].(string)
pattern, err := normalizeProtocolRulePattern(pattern)
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)
}
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:
return items, nil
case []map[string]any:
out := make([]any, 0, len(items))
for _, item := range items {
out = append(out, item)
}
return out, nil
default:
return nil, fmt.Errorf("protocol_rules must be an array")
}
}
func normalizeProtocolRulePattern(pattern string) (string, error) {
pattern = strings.ToLower(strings.TrimSpace(pattern))
if pattern == "" {
return "", fmt.Errorf("pattern is required")
}
if len(pattern) > maxProtocolRulePatternLength {
return "", fmt.Errorf("pattern is too long")
}
if strings.ContainsAny(pattern, " \t") {
return "", fmt.Errorf("pattern must not contain whitespace")
}
star := strings.Count(pattern, "*")
if star > 1 || (star == 1 && !strings.HasSuffix(pattern, "*")) {
return "", fmt.Errorf("pattern may use a single trailing * wildcard")
}
return pattern, nil
}
// NormalizeProtocolRulesCredentials 校验并原地规范化 credentials.protocol_rules。
// 未携带该字段时为 no-op,使旧账号继续使用内置默认表。
func NormalizeProtocolRulesCredentials(credentials map[string]any) error {
if credentials == nil {
return nil
}
raw, ok := credentials[protocolRulesCredentialKey]
if !ok || raw == nil {
return nil
}
rules, err := parseProtocolRules(raw)
if err != nil {
return infraerrors.New(http.StatusBadRequest, "INVALID_OPENCODE_GO_PROTOCOL_RULES", err.Error())
}
encoded := make([]any, 0, len(rules))
for _, rule := range rules {
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
}
func (a *Account) IsOpenCodeGo() bool {
return a != nil && a.Platform == PlatformOpenCodeGo
}
// GetOpenCodeAccountMode 返回 OpenCode 账号类型。未设置时按 Go 处理,兼容已有账号。
func (a *Account) GetOpenCodeAccountMode() string {
if a == nil || !a.IsOpenCodeGo() {
return ""
}
if strings.TrimSpace(a.GetCredential("account_mode")) == AccountModeZen {
return AccountModeZen
}
return AccountModeGo
}
func (a *Account) IsOpenCodeZen() bool {
return a.GetOpenCodeAccountMode() == AccountModeZen
}
func (a *Account) IsOpenCodeGoPlan() bool {
return a.GetOpenCodeAccountMode() == AccountModeGo
}
func (a *Account) IsMultiProtocolAPIKey() bool {
return a != nil && IsMultiProtocolAPIKeyProvider(a.Platform)
}
// openCodeGoQuotaURL 根据 base_url 解析 OpenCode Go 额度端点。
// /zen/go/v1(Chat 协议默认,DefaultOpenCodeGoBaseURL)与 /zen/go
// (Anthropic 协议默认,DefaultOpenCodeGoAnthropicBaseURL)两种 base 统一
// 剥掉尾部 /v1 后拼回 /v1/usage(实测 /zen/go/usage → 404),协议切换不
// 影响额度探测端点。与 kimiQuotaURL 同一惯例。
func openCodeGoQuotaURL(baseURL string) string {
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
if base == "" {
base = DefaultOpenCodeGoBaseURL
}
base = strings.TrimSuffix(base, "/v1")
return base + "/v1" + openCodeGoUsagePath
}