Files
sub2api/backend/internal/service/opencode_go.go
T
SavitarCandClaude Opus 5.5 c063a4016e refactor(provider): 以 provider profile 收敛多协议供应商默认端点表
多协议 API Key 供应商(kimi/zhipu/deepseek/minimax/opencode_go)的默认端点
此前在 account.go 的 GetOpenAIBaseURL / defaultCNProtocolBaseURL /
GetAnthropicProtocolBaseURL / GetOpenAIFormatBaseURL 中按平台 switch 重复了
四遍,Responses 能力与 OpenCode 内置分流表也各自散落。

新增 provider_profile.go 作为单一数据来源:每个供应商登记各接入模式的
Chat/Anthropic 默认基址、内置 protocol_rules 与原生 Responses 能力;
上述函数、SupportsNativeCNResponses、IsMultiProtocolAPIKeyProvider 与
defaultOpenCodeProtocolRules 改为查表。纯重构,行为不变:新增测试以旧
switch 逻辑为基准,逐一核对平台 × account_mode × 协议的全部组合。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:17:41 +08:00

358 lines
11 KiB
Go
Raw 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"
"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"
openCodeGoProtocolRulesKey = "protocol_rules"
maxOpenCodeGoProtocolRules = 64
maxOpenCodeGoProtocolPatternLength = 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)
}
// OpenCodeGoProtocolRule is one model-pattern → native protocol mapping.
// Pattern is an exact ID or a suffix glob (foo* / *). First match wins.
type OpenCodeGoProtocolRule struct {
Pattern string `json:"pattern"`
Protocol string `json:"protocol"`
}
// DefaultOpenCodeGoProtocolRules is the built-in adaptive routing table for Go.
func DefaultOpenCodeGoProtocolRules() []OpenCodeGoProtocolRule {
return []OpenCodeGoProtocolRule{
{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() []OpenCodeGoProtocolRule {
return []OpenCodeGoProtocolRule{
{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},
}
}
// defaultOpenCodeProtocolRules 从 provider profile 取该接入模式的内置分流表。
func defaultOpenCodeProtocolRules(mode string) []OpenCodeGoProtocolRule {
return LookupProviderProfile(PlatformOpenCodeGo).Endpoints(mode).ProtocolRules
}
func isNativeOpenCodeGoProtocol(protocol string) bool {
switch protocol {
case APIProtocolChatCompletions, APIProtocolAnthropic, APIProtocolResponses:
return true
default:
return false
}
}
func openCodeGoPatternMatches(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
}
func matchOpenCodeGoProtocolRules(model string, rules []OpenCodeGoProtocolRule) string {
for _, rule := range rules {
if !isNativeOpenCodeGoProtocol(rule.Protocol) {
continue
}
if openCodeGoPatternMatches(rule.Pattern, model) {
return rule.Protocol
}
}
return APIProtocolChatCompletions
}
// OpenCodeGoModelProtocol 返回内置默认表下模型对应的原生上游协议。
// 未命中时为 Chat Completions。账号自定义 protocol_rules 见
// ResolveOpenCodeGoUpstreamProtocol。
func OpenCodeGoModelProtocol(model string) string {
return matchOpenCodeGoProtocolRules(model, DefaultOpenCodeGoProtocolRules())
}
func (a *Account) openCodeGoProtocolRules() ([]OpenCodeGoProtocolRule, bool) {
if a == nil || a.Credentials == nil {
return nil, false
}
raw, ok := a.Credentials[openCodeGoProtocolRulesKey]
if !ok || raw == nil {
return nil, false
}
rules, err := parseOpenCodeGoProtocolRules(raw)
if err != nil {
return nil, false
}
return rules, true
}
func parseOpenCodeGoProtocolRules(raw any) ([]OpenCodeGoProtocolRule, error) {
items, err := openCodeGoProtocolRuleItems(raw)
if err != nil {
return nil, err
}
if len(items) > maxOpenCodeGoProtocolRules {
return nil, fmt.Errorf("protocol_rules supports at most %d entries", maxOpenCodeGoProtocolRules)
}
rules := make([]OpenCodeGoProtocolRule, 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 := normalizeOpenCodeGoProtocolPattern(pattern)
if err != nil {
return nil, fmt.Errorf("protocol_rules[%d]: %w", i, err)
}
protocol = strings.TrimSpace(protocol)
if !isNativeOpenCodeGoProtocol(protocol) {
return nil, fmt.Errorf("protocol_rules[%d]: protocol must be chat_completions, anthropic, or responses", i)
}
rules = append(rules, OpenCodeGoProtocolRule{Pattern: pattern, Protocol: protocol})
}
return rules, nil
}
func openCodeGoProtocolRuleItems(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 normalizeOpenCodeGoProtocolPattern(pattern string) (string, error) {
pattern = strings.ToLower(strings.TrimSpace(pattern))
if pattern == "" {
return "", fmt.Errorf("pattern is required")
}
if len(pattern) > maxOpenCodeGoProtocolPatternLength {
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
}
// NormalizeOpenCodeGoProtocolRulesCredentials 校验并原地规范化 credentials.protocol_rules。
// 未携带该字段时为 no-op,使旧账号继续使用内置默认表。
func NormalizeOpenCodeGoProtocolRulesCredentials(credentials map[string]any) error {
if credentials == nil {
return nil
}
raw, ok := credentials[openCodeGoProtocolRulesKey]
if !ok || raw == nil {
return nil
}
rules, err := parseOpenCodeGoProtocolRules(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 {
encoded = append(encoded, map[string]any{
"pattern": rule.Pattern,
"protocol": rule.Protocol,
})
}
credentials[openCodeGoProtocolRulesKey] = 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)
}
// 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。
func (a *Account) ResolveOpenCodeGoUpstreamProtocol(model string) string {
if a == nil || !a.IsOpenCodeGo() {
return ""
}
switch a.GetAPIProtocol() {
case APIProtocolChatCompletions, APIProtocolAnthropic, APIProtocolResponses:
return a.GetAPIProtocol()
default:
if rules, present := a.openCodeGoProtocolRules(); present {
return matchOpenCodeGoProtocolRules(model, rules)
}
return matchOpenCodeGoProtocolRules(model, defaultOpenCodeProtocolRules(a.GetOpenCodeAccountMode()))
}
}
// 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
}