mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Keep the internal platform ID opencode_go. Product surface is OpenCode with account_mode zen|go, model-aware routing across Chat Completions, Responses, and Anthropic Messages, and X-OpenCode-Session derived from prompt_cache_key / metadata.user_id so prompt cache can hit. Unmatched models fall back to /v1/chat/completions. Related to #6742.
331 lines
9.9 KiB
Go
331 lines
9.9 KiB
Go
package service
|
||
|
||
import (
|
||
"fmt"
|
||
"net/http"
|
||
"strings"
|
||
|
||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||
)
|
||
|
||
// 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.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
|
||
}
|
||
|
||
// 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 → Anthropic,其余 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: "qwen*", Protocol: APIProtocolAnthropic},
|
||
}
|
||
}
|
||
|
||
func defaultOpenCodeProtocolRules(mode string) []OpenCodeGoProtocolRule {
|
||
if mode == AccountModeZen {
|
||
return DefaultOpenCodeZenProtocolRules()
|
||
}
|
||
return DefaultOpenCodeGoProtocolRules()
|
||
}
|
||
|
||
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) openCodeDefaultChatBaseURL() string {
|
||
if a.IsOpenCodeZen() {
|
||
return DefaultOpenCodeZenBaseURL
|
||
}
|
||
return DefaultOpenCodeGoBaseURL
|
||
}
|
||
|
||
func (a *Account) openCodeDefaultAnthropicBaseURL() string {
|
||
if a.IsOpenCodeZen() {
|
||
return DefaultOpenCodeZenAnthropicBaseURL
|
||
}
|
||
return DefaultOpenCodeGoAnthropicBaseURL
|
||
}
|
||
|
||
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()))
|
||
}
|
||
}
|
||
|
||
func openCodeGoQuotaURL(baseURL string) string {
|
||
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||
if base == "" {
|
||
base = DefaultOpenCodeGoBaseURL
|
||
}
|
||
return base + openCodeGoUsagePath
|
||
}
|