Merge pull request #6747 from DuskLin/feat/opencode-go-platform

feat(opencode): 增加 OpenCode 平台支持(Zen / GO)
This commit is contained in:
Wesley Liddick
2026-09-11 16:42:05 +08:00
committed by GitHub
108 changed files with 2543 additions and 195 deletions
+1
View File
@@ -2048,6 +2048,7 @@ func setDefaults() {
"open.bigmodel.cn",
"api.minimaxi.com", // MiniMax CN quota + inference
"api.minimax.io", // MiniMax intl; frozen allowlists must add this host to use the intl site
"opencode.ai",
"generativelanguage.googleapis.com",
"cloudcode-pa.googleapis.com",
"*.openai.azure.com",
@@ -19,7 +19,8 @@ import "time"
// - "7d" 7 天窗口(Claude/Codex)
// - "7d-sonnet" Claude 7 天 Sonnet 独立额度
// - "7d-fable" Claude 7 天 Fable 独立额度
// - "weekly" 周窗口(Kimi/Zhipu coding plan)
// - "weekly" 周窗口(Kimi/Zhipu coding plan / OpenCode Go)
// - "monthly" 月窗口(OpenCode Go)
// - "daily" 日窗口(Gemini 日配额 / Grok 日请求)
// - "30d" 30 天窗口(Grok 月度)
// - "total" 无窗口语义的总量额度(Antigravity per-model 等)
+10 -5
View File
@@ -24,11 +24,14 @@ const (
PlatformAntigravity = "antigravity"
PlatformGrok = "grok"
// 国产 OpenAI 兼容供应商(经 OpenAI 网关转发,按 Chat Completions 协议)。
PlatformKimi = "kimi" // Kimi (月之暗面 / Moonshot)
PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel)
PlatformDeepseek = "deepseek" // DeepSeek
PlatformMiniMax = "minimax" // MiniMax (M 系列)
PlatformComposite = "composite"
PlatformKimi = "kimi" // Kimi (月之暗面 / Moonshot)
PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel)
PlatformDeepseek = "deepseek" // DeepSeek
PlatformMiniMax = "minimax" // MiniMax (M 系列)
// PlatformOpenCodeGo 是 OpenCode 平台(账号类型 Zen 按量 / Go 订阅)。
// 值保持 opencode_go 以兼容已落库的分组、配额与 Composite 路由 CHECK。
PlatformOpenCodeGo = "opencode_go"
PlatformComposite = "composite"
)
// Account mode constants 区分国产供应商的「按量付费(余额)」与「Coding Plan」两种接入方式。
@@ -36,6 +39,8 @@ const (
const (
AccountModePayG = "payg" // 按量付费:消耗余额,做余额检测冷却
AccountModeCoding = "coding" // Coding Plan:滚动用量窗口冷却(5h / weekly)
AccountModeZen = "zen" // OpenCode Zen:按量付费,https://opencode.ai/zen/v1
AccountModeGo = "go" // OpenCode Go:订阅额度窗口,https://opencode.ai/zen/go/v1
)
// API protocol constants 国产供应商的上游 API 协议维度。存储于
@@ -644,6 +644,7 @@ var platformToLiteLLMProvider = map[string]string{
service.PlatformZhipu: "zhipu",
service.PlatformDeepseek: "deepseek",
service.PlatformMiniMax: "minimax",
service.PlatformOpenCodeGo: "opencode-go",
}
// SyncPricingModels 返回 LiteLLM 定价目录中指定平台的最新模型列表
@@ -40,7 +40,7 @@ func NewChannelMonitorHandler(monitorService *service.ChannelMonitorService) *Ch
type channelMonitorCreateRequest struct {
Name string `json:"name" binding:"required,max=100"`
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax"`
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax opencode_go"`
APIMode string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
Endpoint string `json:"endpoint" binding:"omitempty,max=500"`
APIKey string `json:"api_key" binding:"omitempty,max=2000"`
@@ -64,7 +64,7 @@ type channelMonitorCreateRequest struct {
type channelMonitorUpdateRequest struct {
Name *string `json:"name" binding:"omitempty,max=100"`
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax"`
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax opencode_go"`
APIMode *string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
Endpoint *string `json:"endpoint" binding:"omitempty,max=500"`
APIKey *string `json:"api_key" binding:"omitempty,max=2000"`
@@ -184,7 +184,7 @@ func sanitizeUpdateGroupRequestForSimpleMode(req *UpdateGroupRequest) {
type CreateGroupRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax composite"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax opencode_go composite"`
RateMultiplier float64 `json:"rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
@@ -258,7 +258,7 @@ type CreateGroupRequest struct {
type UpdateGroupRequest struct {
Name string `json:"name"`
Description *string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax composite"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax opencode_go composite"`
RateMultiplier *float64 `json:"rate_multiplier"`
IsExclusive *bool `json:"is_exclusive"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
@@ -332,7 +332,7 @@ type UpdateGroupRequest struct {
type CompositeRouteRequest struct {
PublicModel string `json:"public_model" binding:"required"`
MatchType string `json:"match_type" binding:"omitempty,oneof=exact prefix"`
TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax"`
TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax opencode_go"`
UpstreamModel string `json:"upstream_model"`
Endpoint string `json:"endpoint" binding:"omitempty,oneof=any messages count_tokens responses chat_completions embeddings images gemini"`
Priority int `json:"priority"`
@@ -12,9 +12,9 @@ import (
"github.com/stretchr/testify/require"
)
// 回归分组平台枚举:kimi/zhipu/deepseek 必须能通过 Create/Update 的 binding 校验
// (历史 bug:调度/路由链路已支持 CN 平台分组,但 oneof 白名单漏加三平台,导致
// 平台分组无法创建、CN 账号"无可用分组");非法值仍须被拒。
// 回归分组平台枚举:kimi/zhipu/deepseek/opencode_go 必须能通过 Create/Update 的
// binding 校验(历史 bug:调度/路由链路已支持这些平台,但 oneof 白名单漏加,
// 导致平台分组无法创建、账号"无可用分组");非法值仍须被拒。
func bindGroupPlatformJSON(t *testing.T, target any, body string) error {
t.Helper()
gin.SetMode(gin.TestMode)
@@ -27,7 +27,7 @@ func bindGroupPlatformJSON(t *testing.T, target any, body string) error {
func TestGroupPlatformBinding_AllowedPlatforms(t *testing.T) {
allowed := []string{
"anthropic", "openai", "gemini", "antigravity", "grok",
"kimi", "zhipu", "deepseek", "minimax", "composite",
"kimi", "zhipu", "deepseek", "minimax", "opencode_go", "composite",
}
for _, platform := range allowed {
t.Run("create_"+platform, func(t *testing.T) {
@@ -72,10 +72,15 @@ func TestGroupPlatformBinding_RejectsInvalidPlatforms(t *testing.T) {
}
func TestCompositeRouteTargetPlatform_AllowsCNProviders(t *testing.T) {
for _, platform := range []string{"kimi", "zhipu", "deepseek", "minimax"} {
for _, platform := range []string{"kimi", "zhipu", "deepseek", "minimax", "opencode_go"} {
var req CompositeRouteRequest
body := fmt.Sprintf(`{"public_model":"m","target_platform":%q}`, platform)
require.NoError(t, bindGroupPlatformJSON(t, &req, body))
require.Equal(t, platform, req.TargetPlatform)
}
}
func TestCompositeRouteTargetPlatform_RejectsComposite(t *testing.T) {
var req CompositeRouteRequest
require.Error(t, bindGroupPlatformJSON(t, &req, `{"public_model":"m","target_platform":"composite"}`))
}
+1 -1
View File
@@ -315,7 +315,7 @@ func GetUpstreamEndpoint(c *gin.Context, platform string) string {
// OpenAI 转发服务维护独立的运行时端点上下文,覆盖普通入站推导。
// 这对 force_chat_completions 的错误路径尤为重要:此时可能没有
// ForwardResult,不能把入站 /v1/responses 误报成上游端点。
if platform == service.PlatformOpenAI || platform == service.PlatformGrok || service.IsCNProvider(platform) {
if platform == service.PlatformOpenAI || platform == service.PlatformGrok || service.IsMultiProtocolAPIKeyProvider(platform) {
if endpoint := service.GetActualOpenAIUpstreamEndpoint(c); endpoint != "" {
return endpoint
}
+12
View File
@@ -233,6 +233,18 @@ func TestResolveOpenAIUpstreamEndpointPrefersForwardResult(t *testing.T) {
result: &service.OpenAIForwardResult{},
want: EndpointResponses,
},
{
name: "opencode go conversion result reports responses",
account: &service.Account{Platform: service.PlatformOpenCodeGo, Type: service.AccountTypeAPIKey},
result: &service.OpenAIForwardResult{UpstreamEndpoint: EndpointResponses},
want: EndpointResponses,
},
{
name: "opencode go empty result without runtime stays inbound",
account: &service.Account{Platform: service.PlatformOpenCodeGo, Type: service.AccountTypeAPIKey},
result: &service.OpenAIForwardResult{},
want: EndpointChatCompletions,
},
}
for _, tt := range tests {
+5 -3
View File
@@ -1269,12 +1269,12 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *
seen := make(map[string]struct{})
models := make([]string, 0)
schedulablePlatforms := h.gatewayService.GetSchedulablePlatforms(ctx, groupID)
for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax} {
for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo} {
platformModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform)
if len(platformModels) == 0 {
// CN 供应商没有静态默认模型列表(defaultModelIDsForPlatform 的
// default 分支是 Claude 列表),composite 下只暴露账号映射键。
if _, ok := schedulablePlatforms[platform]; ok && !service.IsCNProvider(platform) {
if _, ok := schedulablePlatforms[platform]; ok && !service.IsMultiProtocolAPIKeyProvider(platform) {
platformModels = defaultModelIDsForPlatform(platform)
}
}
@@ -1451,10 +1451,12 @@ func defaultModelIDsForPlatform(platform string) []string {
return claude.DefaultModelIDs()
case service.PlatformGrok:
return xai.DefaultModelIDs()
case service.PlatformOpenCodeGo:
return service.DefaultOpenCodeGoModelIDs()
case service.PlatformComposite:
ids := make([]string, 0)
seen := make(map[string]struct{})
for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax} {
for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo} {
for _, id := range defaultModelIDsForPlatform(concretePlatform) {
if _, ok := seen[id]; ok {
continue
@@ -18,7 +18,7 @@ import (
func TestAllowOpenAICompatibleMessagesDispatch_CNProvidersExempt(t *testing.T) {
require.True(t, allowOpenAICompatibleMessagesDispatch(nil, nil), "无 key 保持放行")
for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformGrok} {
for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo, service.PlatformGrok} {
apiKey := &service.APIKey{Group: &service.Group{Platform: platform, AllowMessagesDispatch: false}}
require.True(t, allowOpenAICompatibleMessagesDispatch(nil, apiKey),
"%s 分组必须豁免 allow_messages_dispatch 闸门", platform)
@@ -74,4 +74,11 @@ func TestResolveOpenAIMessagesDispatchMappedModel_CompositeCNTargetsSkipGroupMap
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(c, apiKey, "claude-sonnet-4-5-20250929"), "model=%s", model)
}
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/v1/messages", nil)
c.Request = c.Request.WithContext(service.WithResolvedTargetPlatform(c.Request.Context(), service.PlatformOpenCodeGo))
apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}}
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(c, apiKey, "claude-sonnet-4-5-20250929"),
"composite → opencode_go 不得注入 openai 默认 gpt-5.x 映射")
}
@@ -198,11 +198,11 @@ func resolveOpenAIMessagesDispatchMappedModel(c *gin.Context, apiKey *service.AP
if apiKey == nil || apiKey.Group == nil {
return ""
}
// composite 解析到 grok/CN 目标时调度级映射不适用(Group 级映射的 gpt-5.x
// 默认值是 openai 专属,发给这些上游必错),模型改写交给账号级 model_mapping。
// composite 解析到 grok/CN/OpenCode 目标时调度级映射不适用(Group 级映射的
// gpt-5.x 默认值是 openai 专属,发给这些上游必错),模型改写交给账号级 model_mapping。
if apiKey.Group.Platform == service.PlatformComposite && c != nil && c.Request != nil {
if platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok &&
(platform == service.PlatformGrok || service.IsCNProvider(platform)) {
(platform == service.PlatformGrok || service.IsMultiProtocolAPIKeyProvider(platform)) {
return ""
}
}
@@ -305,14 +305,14 @@ func allowOpenAICompatibleMessagesDispatch(c *gin.Context, apiKey *service.APIKe
// 协议账号原生直通 Claude Code),无需 allow_messages_dispatch 开关授权——
// 该开关对非 openai/composite 平台恒被 sanitizeGroupMessagesDispatchFields 置 false,
// 若不豁免,CN 分组将永远 403。
if service.IsCNProvider(apiKey.Group.Platform) {
if service.IsMultiProtocolAPIKeyProvider(apiKey.Group.Platform) {
return true
}
// composite 分组解析到 grok/CN 目标时与对应独立分组同语义豁免;
// composite 分组解析到 grok/CN/OpenCode Go 目标时与对应独立分组同语义豁免;
// 解析到 openai 目标则受 composite 分组自身的可配置开关控制。
if apiKey.Group.Platform == service.PlatformComposite && c != nil && c.Request != nil {
if platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok &&
(platform == service.PlatformGrok || service.IsCNProvider(platform)) {
(platform == service.PlatformGrok || service.IsMultiProtocolAPIKeyProvider(platform)) {
return true
}
}
@@ -322,7 +322,8 @@ func allowOpenAICompatibleMessagesDispatch(c *gin.Context, apiKey *service.APIKe
func openAICompatibleTextTargetAllowed(c *gin.Context, apiKey *service.APIKey, model string) bool {
return compositeTargetPlatformAllowed(c, apiKey, model,
service.PlatformOpenAI, service.PlatformGrok,
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax)
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek,
service.PlatformMiniMax, service.PlatformOpenCodeGo)
}
// isResponsesWebSocketCompositePlatform 限定 composite 分组在 Responses WebSocket
@@ -45,6 +45,7 @@ const (
PlatformZhipu = domain.PlatformZhipu
PlatformDeepseek = domain.PlatformDeepseek
PlatformMiniMax = domain.PlatformMiniMax
PlatformOpenCodeGo = domain.PlatformOpenCodeGo
)
// AllPlatforms 返回所有支持的平台列表
@@ -59,6 +60,7 @@ func AllPlatforms() []string {
PlatformZhipu,
PlatformDeepseek,
PlatformMiniMax,
PlatformOpenCodeGo,
}
}
@@ -17,5 +17,6 @@ func TestAllPlatformsIncludesEveryConcretePlatform(t *testing.T) {
"zhipu",
"deepseek",
"minimax",
"opencode_go",
}, AllPlatforms())
}
+2 -2
View File
@@ -863,7 +863,7 @@ func TestAPIContracts(t *testing.T) {
"force_email_on_third_party_signup": false,
"default_concurrency": 5,
"default_balance": 1.25,
"default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}},
"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},"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,
@@ -1182,7 +1182,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},"zhipu":{"daily":null,"weekly":null,"monthly":null}},
"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},"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,
+3 -2
View File
@@ -48,7 +48,8 @@ func RegisterGatewayRoutes(
isOpenAIResponsesCompatibleGatewayPlatform := func(c *gin.Context) bool {
switch getGroupPlatform(c) {
case service.PlatformOpenAI, service.PlatformGrok,
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax:
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek,
service.PlatformMiniMax, service.PlatformOpenCodeGo:
// 国产 OpenAI 兼容供应商与 openai/grok 一样经 OpenAI 网关转发。
return true
default:
@@ -57,7 +58,7 @@ func RegisterGatewayRoutes(
}
countTokensHandler := func(c *gin.Context) {
switch getGroupPlatform(c) {
case service.PlatformOpenAI, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax:
case service.PlatformOpenAI, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo:
h.OpenAIGateway.CountTokens(c)
case service.PlatformGrok:
h.OpenAIGateway.GrokCountTokens(c)
+35 -15
View File
@@ -296,9 +296,9 @@ func (a *Account) IsCNProvider() bool {
// IsOpenAICompatible 报告账号是否走 OpenAI 网关(OpenAI 协议族)。
// openai/grok 原生走 OpenAI 网关;国产供应商同为 OpenAI Chat Completions
// 兼容上游,也经 OpenAI 网关转发。
// 兼容上游,也经 OpenAI 网关转发。OpenCode 同样经 OpenAI 网关按模型分流。
func (a *Account) IsOpenAICompatible() bool {
return a != nil && (a.Platform == PlatformOpenAI || a.Platform == PlatformGrok || a.IsCNProvider())
return a != nil && (a.Platform == PlatformOpenAI || a.Platform == PlatformGrok || a.IsCNProvider() || a.IsOpenCodeGo())
}
func (a *Account) GeminiOAuthType() string {
@@ -1343,13 +1343,13 @@ func (a *Account) IsOpenAIApiKey() bool {
}
// GetOpenAIBaseURL 解析 OpenAI 协议族账号的上游 base_url。
// 适用 openai 与国产 OpenAI 兼容供应商(kimi/zhipu/deepseek);grok 走 GetGrokBaseURL,
// 此处对 grok 返回 "" 以保持原有行为。
// 适用 openai、国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)与 OpenCode Go;
// grok 走 GetGrokBaseURL,此处对 grok 返回 "" 以保持原有行为。
func (a *Account) GetOpenAIBaseURL() string {
if !a.IsOpenAI() && !a.IsCNProvider() {
if !a.IsOpenAI() && !a.IsCNProvider() && !a.IsOpenCodeGo() {
return ""
}
if a.IsCNProvider() && a.IsAdaptiveAPIProtocol() {
if a.IsMultiProtocolAPIKey() && a.IsAdaptiveAPIProtocol() {
if baseURLs, ok := a.Credentials["api_base_urls"].(map[string]any); ok {
if baseURL, ok := baseURLs[APIProtocolChatCompletions].(string); ok && strings.TrimSpace(baseURL) != "" {
return strings.TrimSpace(baseURL)
@@ -1377,6 +1377,8 @@ func (a *Account) GetOpenAIBaseURL() string {
return DefaultDeepseekBaseURL
case PlatformMiniMax:
return DefaultMiniMaxBaseURL
case PlatformOpenCodeGo:
return a.openCodeDefaultChatBaseURL()
default:
return "https://api.openai.com"
}
@@ -1405,7 +1407,7 @@ func (a *Account) IsCodingPlan() bool {
// (与既有行为完全一致)。responses 协议仅 deepseek / kimi / minimax 支持(官方原生
// Responses 端点,适配 Codex);zhipu 无此端点。
func (a *Account) GetAPIProtocol() string {
if a == nil || !a.IsCNProvider() {
if a == nil || !a.IsMultiProtocolAPIKey() {
return APIProtocolChatCompletions
}
switch strings.TrimSpace(a.GetCredential("api_protocol")) {
@@ -1420,6 +1422,9 @@ func (a *Account) GetAPIProtocol() string {
case APIProtocolChatCompletions:
return APIProtocolChatCompletions
}
if a.IsOpenCodeGo() {
return APIProtocolAdaptive
}
return APIProtocolChatCompletions
}
@@ -1431,7 +1436,7 @@ func (a *Account) SupportsNativeCNResponses() bool {
return false
}
switch a.Platform {
case PlatformDeepseek, PlatformKimi, PlatformMiniMax:
case PlatformDeepseek, PlatformKimi, PlatformMiniMax, PlatformOpenCodeGo:
return true
default:
return false
@@ -1461,7 +1466,7 @@ func (a *Account) IsAdaptiveAPIProtocol() bool {
// adaptive 账号优先使用 api_base_urls 中的分协议地址,缺失时按平台和
// account_mode 使用官方默认端点。base_url 继续作为 Chat Completions 地址兼容旧字段。
func (a *Account) GetCNProtocolBaseURL(protocol string) string {
if a == nil || !a.IsCNProvider() {
if a == nil || !a.IsMultiProtocolAPIKey() {
return ""
}
if a.IsAdaptiveAPIProtocol() {
@@ -1494,6 +1499,8 @@ func (a *Account) defaultCNProtocolBaseURL(protocol string) string {
return DefaultDeepseekAnthropicBaseURL
case PlatformMiniMax:
return DefaultMiniMaxAnthropicBaseURL
case PlatformOpenCodeGo:
return a.openCodeDefaultAnthropicBaseURL()
}
case APIProtocolChatCompletions, APIProtocolResponses:
switch a.Platform {
@@ -1511,6 +1518,8 @@ func (a *Account) defaultCNProtocolBaseURL(protocol string) string {
return DefaultDeepseekBaseURL
case PlatformMiniMax:
return DefaultMiniMaxBaseURL
case PlatformOpenCodeGo:
return a.openCodeDefaultChatBaseURL()
}
}
return ""
@@ -1549,6 +1558,8 @@ func (a *Account) GetAnthropicProtocolBaseURL() string {
return DefaultDeepseekAnthropicBaseURL
case PlatformMiniMax:
return DefaultMiniMaxAnthropicBaseURL
case PlatformOpenCodeGo:
return a.openCodeDefaultAnthropicBaseURL()
default:
return ""
}
@@ -1578,6 +1589,8 @@ func (a *Account) GetOpenAIFormatBaseURL() string {
return DefaultDeepseekBaseURL
case PlatformMiniMax:
return DefaultMiniMaxBaseURL
case PlatformOpenCodeGo:
return a.openCodeDefaultChatBaseURL()
default:
return a.GetOpenAIBaseURL()
}
@@ -1586,7 +1599,7 @@ func (a *Account) GetOpenAIFormatBaseURL() string {
// GetCNAPIKey 返回国产 OpenAI 兼容供应商账号的 api_key 凭据(kimi/zhipu/deepseek)。
// 与 openai 的 GetOpenAIApiKey 区分:后者仅对 openai 平台返回。
func (a *Account) GetCNAPIKey() string {
if a == nil || !a.IsCNProvider() {
if a == nil || !a.IsMultiProtocolAPIKey() {
return ""
}
return a.GetCredential("api_key")
@@ -1596,7 +1609,13 @@ func (a *Account) GetCNAPIKey() string {
// 用于路由到对应的额度查询端点。非 coding 模式或无法识别时返回空串。
// 只认官方域名:自定义中转不得把第三方 Key 发往厂商官方额度端点。
func (a *Account) GetCodingPlanProvider() string {
if a == nil || a.GetAccountMode() != AccountModeCoding {
if a == nil {
return ""
}
if a.IsOpenCodeGoPlan() {
return PlatformOpenCodeGo
}
if a.GetAccountMode() != AccountModeCoding {
return ""
}
baseURL := strings.ToLower(a.GetOpenAIBaseURL())
@@ -1725,14 +1744,15 @@ func (a *Account) GetOpenAIApiKey() string {
}
// GetOpenAIProtocolAPIKey 返回 OpenAI 协议族 APIKey 账号的密钥。
// 覆盖 openai 原生账号与国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)账号,
// 供转发鉴权、模型列表同步等协议族共用路径使用。注意 IsOpenAIApiKey 语义上
// 仅指 openai 平台账号,调度倍率/WS 能力门控继续以其为准,不受本方法影响。
// 覆盖 openai 原生账号、国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)
// 以及 OpenCode Go 账号,供转发鉴权、模型列表同步等协议族共用路径使用。
// 注意 IsOpenAIApiKey 语义上仅指 openai 平台账号,调度倍率/WS 能力门控
// 继续以其为准,不受本方法影响。
func (a *Account) GetOpenAIProtocolAPIKey() string {
if a == nil {
return ""
}
if a.IsCNProvider() {
if a.IsMultiProtocolAPIKey() {
if a.Type != AccountTypeAPIKey {
return ""
}
@@ -78,7 +78,7 @@ func (a *Account) IsHeaderOverrideEligible() bool {
return false
}
switch a.Platform {
case PlatformAnthropic, PlatformOpenAI, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
case PlatformAnthropic, PlatformOpenAI, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo:
return a.Type == AccountTypeAPIKey
case PlatformGrok:
return a.Type == AccountTypeAPIKey || a.Type == AccountTypeOAuth
@@ -30,11 +30,13 @@ func TestIsHeaderOverrideEligible(t *testing.T) {
{"kimi apikey", PlatformKimi, AccountTypeAPIKey, true},
{"zhipu apikey", PlatformZhipu, AccountTypeAPIKey, true},
{"deepseek apikey", PlatformDeepseek, AccountTypeAPIKey, true},
{"opencode go apikey", PlatformOpenCodeGo, AccountTypeAPIKey, true},
{"anthropic oauth", PlatformAnthropic, AccountTypeOAuth, false},
{"openai oauth", PlatformOpenAI, AccountTypeOAuth, false},
{"kimi oauth", PlatformKimi, AccountTypeOAuth, false},
{"zhipu oauth", PlatformZhipu, AccountTypeOAuth, false},
{"deepseek oauth", PlatformDeepseek, AccountTypeOAuth, false},
{"opencode go oauth", PlatformOpenCodeGo, AccountTypeOAuth, false},
{"gemini apikey", PlatformGemini, AccountTypeAPIKey, false},
{"grok apikey", PlatformGrok, AccountTypeAPIKey, true},
{"grok oauth", PlatformGrok, AccountTypeOAuth, true},
@@ -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:
case PlatformKimi, PlatformZhipu, PlatformMiniMax, PlatformOpenCodeGo:
winner = pickLatestResetSchedulingCandidate(cnProviderThresholdCandidates(account, decision.Platform), threshold, now)
default:
return decision
@@ -356,10 +356,14 @@ func cnProviderThresholdCandidates(account *Account, provider string) []*account
if account == nil || len(account.Extra) == 0 {
return nil
}
return []*accountSchedulingThresholdCandidate{
candidates := []*accountSchedulingThresholdCandidate{
cnThresholdCandidate(account.Extra, provider, "5h"),
cnThresholdCandidate(account.Extra, provider, "weekly"),
}
if provider == PlatformOpenCodeGo {
candidates = append(candidates, cnThresholdCandidate(account.Extra, provider, "monthly"))
}
return candidates
}
func cnThresholdCandidate(extra map[string]any, provider, window string) *accountSchedulingThresholdCandidate {
@@ -371,6 +375,9 @@ func cnThresholdCandidate(extra map[string]any, provider, window string) *accoun
case "weekly":
usedKey = cnExtraKey(provider, cnExtraSuffixWeeklyUsed)
resetKey = cnExtraKey(provider, cnExtraSuffixWeeklyReset)
case "monthly":
usedKey = cnExtraKey(provider, cnExtraSuffixMonthlyUsed)
resetKey = cnExtraKey(provider, cnExtraSuffixMonthlyReset)
default:
return nil
}
+2 -2
View File
@@ -515,8 +515,8 @@ func (s *AccountService) TestCredentials(ctx context.Context, id int64) error {
case PlatformGrok:
// Grok OAuth credentials are validated via token exchange/refresh and request-path probes.
return nil
case PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
// 国产 OpenAI 兼容供应商:凭证为 API Key,实际可用性经余额/额度探测与转发路径验证。
case PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo:
// 国产 OpenAI 兼容供应商与 OpenCode:凭证为 API Key,实际可用性经余额/额度探测与转发路径验证。
return nil
default:
return fmt.Errorf("unsupported platform: %s", account.Platform)
@@ -385,9 +385,56 @@ 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)
}
return s.testClaudeAccountConnection(c, account, modelID)
}
// testOpenCodeGoAccountConnection probes the native endpoint for the selected
// model. Adaptive accounts (the default) follow OpenCodeGoModelProtocol:
// 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 {
testModelID := strings.TrimSpace(modelID)
if testModelID == "" {
testModelID = DefaultOpenCodeGoTestModel
}
testModelID = account.GetMappedModel(testModelID)
proto := account.GetAPIProtocol()
switch proto {
case APIProtocolChatCompletions, APIProtocolAnthropic, APIProtocolResponses:
default:
proto = openCodeGoNativeProtocol(account, testModelID)
}
switch proto {
case APIProtocolAnthropic:
return s.testCNProviderAnthropicConnection(c, account, testModelID)
case APIProtocolResponses:
return s.testOpenCodeGoResponsesConnection(c, account, testModelID)
default:
return s.testCNProviderChatCompletionsConnection(c, account, testModelID, prompt)
}
}
func (s *AccountTestService) testOpenCodeGoResponsesConnection(c *gin.Context, account *Account, testModelID string) error {
authToken := strings.TrimSpace(account.GetOpenAIProtocolAPIKey())
if authToken == "" {
return s.sendErrorAndEnd(c, "No API key available")
}
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Header().Set("X-Accel-Buffering", "no")
c.Writer.Flush()
s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID})
return s.testCNProviderAdaptiveResponsesConnection(c, account, testModelID, authToken)
}
func (s *AccountTestService) testCNProviderChatCompletionsConnection(c *gin.Context, account *Account, modelID string, prompt string) error {
testModelID := strings.TrimSpace(modelID)
if testModelID == "" {
@@ -2058,6 +2105,7 @@ func (s *AccountTestService) testOpenAIChatCompletionsConnection(
// 账号级请求头覆写:测试请求与真实转发保持一致的最终头
account.ApplyHeaderOverrides(req.Header)
applyOpenCodeSessionHeader(c, account, apiURL, req.Header, payloadBytes)
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
@@ -85,6 +85,7 @@ func (s *AccountTestService) testCNProviderAdaptiveAnthropicConnection(c *gin.Co
// base_url 强制 Bearer,其余保持 extra/default 行为。
setAnthropicAPIKeyAuthHeader(req.Header, account, authToken, account.GetCNProtocolBaseURL(APIProtocolAnthropic))
account.ApplyHeaderOverrides(req.Header)
applyOpenCodeSessionHeader(c, account, apiURL, req.Header, payloadBytes)
resp, err := s.doCNProviderAdaptiveRequest(req, account)
if err != nil {
@@ -178,6 +179,7 @@ func (s *AccountTestService) testCNProviderAdaptiveResponsesConnection(c *gin.Co
req.Header.Set("Authorization", "Bearer "+authToken)
applyOpenAICodexProbeHeaders(req.Header)
account.ApplyHeaderOverrides(req.Header)
applyOpenCodeSessionHeader(c, account, apiURL, req.Header, payloadBytes)
resp, err := s.doCNProviderAdaptiveRequest(req, account)
if err != nil {
@@ -266,6 +268,7 @@ func (s *AccountTestService) testCNProviderAnthropicConnection(c *gin.Context, a
// extra/default 行为。
setAnthropicAPIKeyAuthHeader(req.Header, account, authToken, account.GetAnthropicProtocolBaseURL())
account.ApplyHeaderOverrides(req.Header)
applyOpenCodeSessionHeader(c, account, apiURL, req.Header, payloadBytes)
resp, err := s.doCNProviderAdaptiveRequest(req, account)
if err != nil {
@@ -0,0 +1,124 @@
//go:build unit
package service
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func openCodeGoTestAccount(id int64) *Account {
return &Account{
ID: id,
Name: "oc",
Platform: PlatformOpenCodeGo,
Type: AccountTypeAPIKey,
Status: StatusActive,
Concurrency: 1,
Credentials: map[string]any{
"api_key": "sk-opencode-go-test",
"api_protocol": APIProtocolAdaptive,
"base_url": "https://opencode.ai/zen/go/v1",
"api_base_urls": map[string]any{
APIProtocolChatCompletions: "https://opencode.ai/zen/go/v1",
APIProtocolAnthropic: "https://opencode.ai/zen/go",
APIProtocolResponses: "https://opencode.ai/zen/go/v1",
},
},
}
}
func TestAccountTestService_OpenCodeGoDeepSeekFlashUsesChatCompletions(t *testing.T) {
account := openCodeGoTestAccount(401)
svc, upstream := adaptiveCNAccountTestService(account, adaptiveCNChatTestResponse())
c, recorder := newTestContext()
err := svc.TestAccountConnection(c, account.ID, "deepseek-v4-flash", "hi", AccountTestModeDefault)
require.NoError(t, err)
require.Len(t, upstream.requests, 1)
require.Equal(t, "https://opencode.ai/zen/go/v1/chat/completions", upstream.requests[0].URL.String())
require.Equal(t, "Bearer sk-opencode-go-test", upstream.requests[0].Header.Get("Authorization"))
require.NotEmpty(t, upstream.requests[0].Header.Get("X-OpenCode-Session"))
require.Contains(t, recorder.Body.String(), `"type":"test_complete"`)
require.NotContains(t, upstream.requests[0].URL.Path, "/messages")
}
func TestAccountTestService_OpenCodeGoGrokUsesResponses(t *testing.T) {
account := openCodeGoTestAccount(402)
svc, upstream := adaptiveCNAccountTestService(account, adaptiveCNResponsesTestResponse())
c, recorder := newTestContext()
err := svc.TestAccountConnection(c, account.ID, "grok-4.6", "hi", AccountTestModeDefault)
require.NoError(t, err)
require.Len(t, upstream.requests, 1)
require.Equal(t, "https://opencode.ai/zen/go/v1/responses", upstream.requests[0].URL.String())
require.Equal(t, "Bearer sk-opencode-go-test", upstream.requests[0].Header.Get("Authorization"))
require.NotEmpty(t, upstream.requests[0].Header.Get("X-OpenCode-Session"))
require.Contains(t, recorder.Body.String(), `"type":"test_complete"`)
}
func TestAccountTestService_OpenCodeGoMiniMaxUsesAnthropicMessages(t *testing.T) {
account := openCodeGoTestAccount(403)
svc, upstream := adaptiveCNAccountTestService(account, adaptiveCNAnthropicTestResponse())
c, recorder := newTestContext()
err := svc.TestAccountConnection(c, account.ID, "minimax-m3", "hi", AccountTestModeDefault)
require.NoError(t, err)
require.Len(t, upstream.requests, 1)
req := upstream.requests[0]
require.Equal(t, "https://opencode.ai/zen/go/v1/messages", req.URL.String())
require.Empty(t, req.URL.RawQuery)
require.Equal(t, "sk-opencode-go-test", req.Header.Get("x-api-key"))
require.NotEmpty(t, req.Header.Get("X-OpenCode-Session"))
require.Contains(t, recorder.Body.String(), `"type":"test_complete"`)
}
func TestAccountTestService_OpenCodeGoPinnedChatProtocolIgnoresCatalog(t *testing.T) {
account := openCodeGoTestAccount(404)
account.Credentials["api_protocol"] = APIProtocolChatCompletions
svc, upstream := adaptiveCNAccountTestService(account, adaptiveCNChatTestResponse())
c, _ := newTestContext()
err := svc.TestAccountConnection(c, account.ID, "grok-4.6", "hi", AccountTestModeDefault)
require.NoError(t, err)
require.Len(t, upstream.requests, 1)
require.Equal(t, "https://opencode.ai/zen/go/v1/chat/completions", upstream.requests[0].URL.String())
}
func TestAccountTestService_OpenCodeGoAppliesModelMappingOnResponses(t *testing.T) {
account := openCodeGoTestAccount(406)
account.Credentials["model_mapping"] = map[string]any{
"opencode/muse-spark-1.3-contributior-free": "muse-spark-1.3-contributior-free",
}
svc, upstream := adaptiveCNAccountTestService(account, adaptiveCNResponsesTestResponse())
c, recorder := newTestContext()
err := svc.TestAccountConnection(c, account.ID, "opencode/muse-spark-1.3-contributior-free", "hi", AccountTestModeDefault)
require.NoError(t, err)
require.Len(t, upstream.requests, 1)
require.Equal(t, "https://opencode.ai/zen/go/v1/responses", upstream.requests[0].URL.String())
require.Equal(t, "muse-spark-1.3-contributior-free", gjson.GetBytes(upstream.lastBody, "model").String())
require.Contains(t, recorder.Body.String(), `"model":"muse-spark-1.3-contributior-free"`)
require.NotContains(t, string(upstream.lastBody), "opencode/muse-spark-1.3-contributior-free")
}
func TestAccountTestService_OpenCodeGoEmptyModelDefaultsToChatCatalogID(t *testing.T) {
account := openCodeGoTestAccount(405)
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://opencode.ai/zen/go/v1/chat/completions", upstream.requests[0].URL.String())
require.True(t, strings.Contains(recorder.Body.String(), DefaultOpenCodeGoTestModel))
}
+12
View File
@@ -323,6 +323,9 @@ func (s *adminServiceImpl) DuplicateAccount(ctx context.Context, id int64, actor
if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil {
return nil, err
}
if err := NormalizeOpenCodeGoProtocolRulesCredentials(input.Credentials); err != nil {
return nil, err
}
duplicate, err := buildAccountForCreate(input, accountExtra)
if err != nil {
return nil, err
@@ -514,6 +517,9 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou
if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil {
return nil, err
}
if err := NormalizeOpenCodeGoProtocolRulesCredentials(input.Credentials); err != nil {
return nil, err
}
// Never persist ephemeral SSO/password secrets after OAuth conversion.
input.Credentials = SanitizeStoredCredentials(input.Platform, input.Credentials)
@@ -640,6 +646,9 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
if err := NormalizeHeaderOverrideCredentials(account.Credentials); err != nil {
return nil, err
}
if err := NormalizeOpenCodeGoProtocolRulesCredentials(account.Credentials); err != nil {
return nil, err
}
// Strip SSO/password residue that must never sit next to OAuth tokens.
account.Credentials = SanitizeStoredCredentials(account.Platform, account.Credentials)
}
@@ -1065,6 +1074,9 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil {
return nil, err
}
if err := NormalizeOpenCodeGoProtocolRulesCredentials(input.Credentials); err != nil {
return nil, err
}
// Bulk may mix platforms; always drop ephemeral SSO/password keys (cookie
// only when platform is known Grok — empty platform still strips password/*).
if input.Credentials != nil {
+3 -1
View File
@@ -294,6 +294,8 @@ func defaultModelsListCandidateIDs(platform string) []string {
return ids
case PlatformGrok:
return xai.DefaultModelIDs()
case PlatformOpenCodeGo:
return DefaultOpenCodeGoModelIDs()
case PlatformComposite:
return compositeDefaultModelsListCandidateIDs()
default:
@@ -314,7 +316,7 @@ func defaultAllowImageGenerationForPlatform(platform string) bool {
func compositeDefaultModelsListCandidateIDs() []string {
seen := make(map[string]struct{})
ids := make([]string, 0)
for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} {
for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo} {
for _, id := range defaultModelsListCandidateIDs(platform) {
if _, ok := seen[id]; ok {
continue
@@ -221,6 +221,8 @@ func (f *ChannelMonitorQuotaFetcher) fetchUncached(ctx context.Context, accountI
return f.fetchCNQuota(ctx, account, now)
}
return f.fetchCNBalance(ctx, account, now)
case domain.PlatformOpenCodeGo:
return f.fetchCNQuota(ctx, account, now)
default:
return f.fetchUsage(ctx, account, now)
}
@@ -215,6 +215,8 @@ func normalizeMonitorPrimaryModel(provider, checkMode, model string) string {
// - gemini/grok/antigravity:本地统计/值通道降级,不会永久 error,放行
func monitorAccountQuotaCapability(account *Account) error {
switch account.Platform {
case PlatformOpenCodeGo:
return nil
case PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
if account.IsCodingPlan() {
if p := account.GetCodingPlanProvider(); p != PlatformKimi && p != PlatformZhipu && p != PlatformMiniMax {
+1 -1
View File
@@ -367,7 +367,7 @@ func isPlatformPricingMatch(groupPlatform, pricingPlatform string) bool {
// fallback used before a request target has been resolved.
func matchingPlatforms(groupPlatform string) []string {
if groupPlatform == PlatformComposite {
return []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax}
return []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo}
}
return []string{groupPlatform}
}
@@ -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}},
{"composite returns concrete platforms", PlatformComposite, []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo}},
}
for _, tt := range tests {
@@ -35,6 +35,8 @@ const (
cnExtraSuffix5hReset = "5h_reset_at"
cnExtraSuffixWeeklyUsed = "weekly_used_percent"
cnExtraSuffixWeeklyReset = "weekly_reset_at"
cnExtraSuffixMonthlyUsed = "monthly_used_percent"
cnExtraSuffixMonthlyReset = "monthly_reset_at"
cnExtraSuffixUsageUpdated = "usage_updated_at"
)
@@ -43,7 +45,7 @@ func cnExtraKey(provider, suffix string) string { return provider + "_" + suffix
// CNQuotaTier 表示一个滚动用量窗口档位(5h / weekly)。
type CNQuotaTier struct {
Window string `json:"window"` // "5h" | "weekly"
Window string `json:"window"` // "5h" | "weekly" | "monthly"
UsedPercent float64 `json:"used_percent"` // 已用百分比(0-100+,不做裁剪)
ResetAt string `json:"reset_at,omitempty"` // RFC3339,空表示无重置时间
}
@@ -129,8 +131,8 @@ func (s *CNProviderQuotaService) QueryUsageForAccount(ctx context.Context, accou
func (s *CNProviderQuotaService) queryUsageForAccount(ctx context.Context, account *Account) (*CNProviderQuotaProbeResult, error) {
provider := account.GetCodingPlanProvider()
if provider != PlatformKimi && provider != PlatformZhipu && provider != PlatformMiniMax {
return nil, infraerrors.New(http.StatusBadRequest, "CN_QUOTA_NOT_CODING_PLAN", "account is not a kimi/zhipu/minimax coding plan 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")
}
apiKey := strings.TrimSpace(account.GetCNAPIKey())
@@ -148,6 +150,9 @@ func (s *CNProviderQuotaService) queryUsageForAccount(ctx context.Context, accou
case PlatformKimi:
targetURL = kimiQuotaURL(baseURL)
authHeader = "Bearer " + apiKey
case PlatformOpenCodeGo:
targetURL = openCodeGoQuotaURL(baseURL)
authHeader = "Bearer " + apiKey
case PlatformZhipu:
targetURL = zhipuQuotaURL(baseURL)
authHeader = apiKey // 智谱额度端点鉴权不加 Bearer 前缀
@@ -234,6 +239,9 @@ func (s *CNProviderQuotaService) queryUsageForAccount(ctx context.Context, accou
switch provider {
case PlatformKimi:
tiers = parseKimiUsageTiers(bodyBytes)
case PlatformOpenCodeGo:
tiers = parseOpenCodeGoUsageTiers(bodyBytes)
result.PlanLevel = "OpenCode Go"
case PlatformZhipu:
tiers = parseZhipuTokenTiers(gjson.GetBytes(bodyBytes, "data"))
result.PlanLevel = strings.TrimSpace(gjson.GetBytes(bodyBytes, "data.level").String())
@@ -279,6 +287,12 @@ func validateCodingPlanAccount(account *Account) error {
if account == nil {
return infraerrors.New(http.StatusNotFound, "CN_QUOTA_ACCOUNT_NOT_FOUND", "account not found")
}
if account.IsOpenCodeGoPlan() {
return nil
}
if account.IsOpenCodeGo() {
return infraerrors.New(http.StatusBadRequest, "CN_QUOTA_NOT_CODING_PLAN", "opencode zen accounts have no subscription quota window")
}
if !account.IsCNProvider() {
return infraerrors.New(http.StatusBadRequest, "CN_QUOTA_INVALID_PLATFORM", "account is not a CN provider account")
}
@@ -614,11 +628,61 @@ func cnQuotaExtraUpdates(provider string, tiers []CNQuotaTier, now time.Time) ma
if t.ResetAt != "" {
updates[cnExtraKey(provider, cnExtraSuffixWeeklyReset)] = t.ResetAt
}
case "monthly":
updates[cnExtraKey(provider, cnExtraSuffixMonthlyUsed)] = t.UsedPercent
if t.ResetAt != "" {
updates[cnExtraKey(provider, cnExtraSuffixMonthlyReset)] = t.ResetAt
}
}
}
return updates
}
// parseOpenCodeGoUsageTiers 解析 OpenCode Go GET /usage 响应。
//
// 结构对齐 cc-switch extractor:
//
// { "usage": { "rolling": {percent, resetsAt}, "weekly": {...}, "monthly": {...} } }
//
// percent 为已用百分比(0-100);rolling 映射为 5h 窗口。
func parseOpenCodeGoUsageTiers(body []byte) []CNQuotaTier {
usage := gjson.GetBytes(body, "usage")
if !usage.Exists() {
return nil
}
var tiers []CNQuotaTier
for _, item := range []struct {
key string
window string
}{
{key: "rolling", window: "5h"},
{key: "weekly", window: "weekly"},
{key: "monthly", window: "monthly"},
} {
node := usage.Get(item.key)
if !node.Exists() {
continue
}
percentNode := node.Get("percent")
if !percentNode.Exists() {
continue
}
used, ok := cnParseF64(percentNode.Value())
if !ok {
continue
}
if used < 0 {
used = 0
}
tiers = append(tiers, CNQuotaTier{
Window: item.window,
UsedPercent: used,
ResetAt: cnNormalizeResetTime(node.Get("resetsAt").Value()),
})
}
return tiers
}
// cnParseF64 把 JSON 数值或字符串解析为 float64(兼容 "100" 与 100)。
func cnParseF64(raw any) (float64, bool) {
switch v := raw.(type) {
@@ -177,6 +177,34 @@ func TestCNQuotaExtraUpdates(t *testing.T) {
require.Equal(t, now.Format(time.RFC3339), updates["kimi_usage_updated_at"])
}
func TestParseOpenCodeGoUsageTiers(t *testing.T) {
t.Parallel()
body := []byte(`{
"usage": {
"rolling": {"percent": 12.5, "resetsAt": "2026-09-07T12:00:00Z"},
"weekly": {"percent": 40, "resetsAt": "2026-09-10T00:00:00Z"},
"monthly": {"percent": 22.2, "resetsAt": "2026-10-01T00:00:00Z"}
}
}`)
tiers := parseOpenCodeGoUsageTiers(body)
require.Len(t, tiers, 3)
require.Equal(t, "5h", tiers[0].Window)
require.Equal(t, 12.5, tiers[0].UsedPercent)
require.Equal(t, "2026-09-07T12:00:00Z", tiers[0].ResetAt)
require.Equal(t, "weekly", tiers[1].Window)
require.Equal(t, 40.0, tiers[1].UsedPercent)
require.Equal(t, "monthly", tiers[2].Window)
require.InDelta(t, 22.2, tiers[2].UsedPercent, 0.001)
updates := cnQuotaExtraUpdates(PlatformOpenCodeGo, tiers, time.Date(2026, 9, 7, 0, 0, 0, 0, time.UTC))
require.Equal(t, 12.5, updates["opencode_go_5h_used_percent"])
require.Equal(t, 40.0, updates["opencode_go_weekly_used_percent"])
require.Equal(t, 22.2, updates["opencode_go_monthly_used_percent"])
require.Equal(t, "2026-10-01T00:00:00Z", updates["opencode_go_monthly_reset_at"])
require.Empty(t, parseOpenCodeGoUsageTiers([]byte(`{"ok":true}`)))
}
// TestCNProviderResponseIndicatesInsufficientBalance 覆盖中英文余额不足文案与否定用例。
func TestCNProviderResponseIndicatesInsufficientBalance(t *testing.T) {
t.Parallel()
@@ -502,6 +530,7 @@ func TestNormalizeOpenAICompatiblePlatform_SchedulerExactMatch(t *testing.T) {
require.Equal(t, PlatformKimi, NormalizeOpenAICompatiblePlatform(PlatformKimi))
require.Equal(t, PlatformZhipu, NormalizeOpenAICompatiblePlatform(PlatformZhipu))
require.Equal(t, PlatformDeepseek, NormalizeOpenAICompatiblePlatform(PlatformDeepseek))
require.Equal(t, PlatformOpenCodeGo, NormalizeOpenAICompatiblePlatform(PlatformOpenCodeGo))
// 其他平台(含空、anthropic、未知)一律归一为 openai。
require.Equal(t, PlatformOpenAI, NormalizeOpenAICompatiblePlatform(""))
require.Equal(t, PlatformOpenAI, NormalizeOpenAICompatiblePlatform(PlatformAnthropic))
@@ -536,6 +565,14 @@ func TestGetOpenAIProtocolAPIKey_CNProviders(t *testing.T) {
Credentials: map[string]any{"api_key": "sk-openai"},
}
require.Equal(t, "sk-openai", openai.GetOpenAIProtocolAPIKey())
openCodeGo := &Account{
Platform: PlatformOpenCodeGo,
Type: AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "sk-opencode-go"},
}
require.Equal(t, "sk-opencode-go", openCodeGo.GetOpenAIProtocolAPIKey())
require.False(t, openCodeGo.IsOpenAIApiKey())
}
// TestBuildUpstreamModelsRequest_CNProviders 验证“同步上游支持的模型”对国产供应商可用:
@@ -557,6 +594,8 @@ func TestBuildUpstreamModelsRequest_CNProviders(t *testing.T) {
{"deepseek", PlatformDeepseek, "", "https://api.deepseek.com/v1/models"},
{"minimax default", PlatformMiniMax, "", "https://api.minimaxi.com/v1/models"},
{"minimax coding", PlatformMiniMax, AccountModeCoding, "https://api.minimaxi.com/v1/models"},
{"opencode go", PlatformOpenCodeGo, AccountModeGo, "https://opencode.ai/zen/go/v1/models"},
{"opencode zen", PlatformOpenCodeGo, AccountModeZen, "https://opencode.ai/zen/v1/models"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -601,6 +640,8 @@ func TestGetAPIProtocol(t *testing.T) {
require.Equal(t, APIProtocolChatCompletions, mk(PlatformZhipu, APIProtocolResponses).GetAPIProtocol(), "zhipu 无 responses 端点")
require.Equal(t, APIProtocolChatCompletions, mk(PlatformKimi, "bogus").GetAPIProtocol(), "非法值回退默认")
require.Equal(t, APIProtocolChatCompletions, (&Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}).GetAPIProtocol(), "非 CN 供应商恒为默认")
require.Equal(t, APIProtocolAdaptive, mk(PlatformOpenCodeGo, "").GetAPIProtocol(), "opencode go 默认 adaptive")
require.Equal(t, APIProtocolResponses, mk(PlatformOpenCodeGo, APIProtocolResponses).GetAPIProtocol())
}
func TestSupportsNativeCNResponses(t *testing.T) {
@@ -609,6 +650,7 @@ func TestSupportsNativeCNResponses(t *testing.T) {
require.True(t, (&Account{Platform: PlatformKimi}).SupportsNativeCNResponses())
require.True(t, (&Account{Platform: PlatformKimi, Credentials: map[string]any{"account_mode": AccountModeCoding}}).SupportsNativeCNResponses())
require.True(t, (&Account{Platform: PlatformMiniMax}).SupportsNativeCNResponses())
require.True(t, (&Account{Platform: PlatformOpenCodeGo}).SupportsNativeCNResponses())
require.False(t, (&Account{Platform: PlatformZhipu}).SupportsNativeCNResponses())
require.False(t, (&Account{Platform: PlatformOpenAI}).SupportsNativeCNResponses())
}
@@ -631,6 +673,8 @@ func TestAdaptiveProtocolBaseURLs(t *testing.T) {
{"deepseek", PlatformDeepseek, AccountModePayG, DefaultDeepseekBaseURL, DefaultDeepseekAnthropicBaseURL, DefaultDeepseekBaseURL},
{"minimax payg", PlatformMiniMax, AccountModePayG, DefaultMiniMaxBaseURL, DefaultMiniMaxAnthropicBaseURL, DefaultMiniMaxBaseURL},
{"minimax coding", PlatformMiniMax, AccountModeCoding, DefaultMiniMaxBaseURL, DefaultMiniMaxAnthropicBaseURL, DefaultMiniMaxBaseURL},
{"opencode go", PlatformOpenCodeGo, AccountModeGo, DefaultOpenCodeGoBaseURL, DefaultOpenCodeGoAnthropicBaseURL, DefaultOpenCodeGoBaseURL},
{"opencode zen", PlatformOpenCodeGo, AccountModeZen, DefaultOpenCodeZenBaseURL, DefaultOpenCodeZenAnthropicBaseURL, DefaultOpenCodeZenBaseURL},
}
for _, tc := range cases {
@@ -202,7 +202,7 @@ func (s *GatewayService) resolveCompositeRouteDecision(ctx context.Context, grou
func isConcreteRequestPlatform(platform string) bool {
switch platform {
case PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok,
PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo:
return true
default:
return false
@@ -216,13 +216,13 @@ func TestCompositeGroupSchedulerHasAllCanonicalPlatformBuckets(t *testing.T) {
platforms = append(platforms, platform)
}
require.ElementsMatch(t,
[]string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax},
[]string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo},
platforms,
)
}
func TestCompositeConcretePlatformsIncludeCNProviders(t *testing.T) {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo} {
require.True(t, isConcreteRequestPlatform(platform))
require.True(t, canCopyAccountsFromGroupPlatform(PlatformComposite, platform))
}
+28 -5
View File
@@ -44,11 +44,12 @@ const (
PlatformAntigravity = domain.PlatformAntigravity
PlatformGrok = domain.PlatformGrok
// 国产 OpenAI 兼容供应商(与 grok 一样经 OpenAI 网关转发)。
PlatformKimi = domain.PlatformKimi
PlatformZhipu = domain.PlatformZhipu
PlatformDeepseek = domain.PlatformDeepseek
PlatformMiniMax = domain.PlatformMiniMax
PlatformComposite = domain.PlatformComposite
PlatformKimi = domain.PlatformKimi
PlatformZhipu = domain.PlatformZhipu
PlatformDeepseek = domain.PlatformDeepseek
PlatformMiniMax = domain.PlatformMiniMax
PlatformOpenCodeGo = domain.PlatformOpenCodeGo
PlatformComposite = domain.PlatformComposite
// PlatformKiro is retained for unsupported-platform threshold tests and legacy
// account rows. Scheduling-threshold evaluation never pauses kiro accounts.
PlatformKiro = "kiro"
@@ -58,6 +59,8 @@ const (
const (
AccountModePayG = domain.AccountModePayG
AccountModeCoding = domain.AccountModeCoding
AccountModeZen = domain.AccountModeZen
AccountModeGo = domain.AccountModeGo
)
// 上游 API 协议(国产供应商):决定转发端点与格式,与接入模式正交。
@@ -78,6 +81,10 @@ const (
DefaultDeepseekBaseURL = "https://api.deepseek.com"
// MiniMax 按量付费与 Coding/Token Plan 共用推理域名,靠 API Key 区分套餐。
DefaultMiniMaxBaseURL = "https://api.minimaxi.com/v1"
// OpenCode Go:Chat Completions / Responses / models 共用 /v1 基址。
DefaultOpenCodeGoBaseURL = "https://opencode.ai/zen/go/v1"
// OpenCode Zen:按量付费网关,模型列表为 /zen/v1/models。
DefaultOpenCodeZenBaseURL = "https://opencode.ai/zen/v1"
)
// 国产供应商 Anthropic 协议端点的默认 base_url(上游路径为 {base}/v1/messages)。
@@ -88,6 +95,9 @@ const (
DefaultZhipuAnthropicBaseURL = "https://open.bigmodel.cn/api/anthropic"
DefaultDeepseekAnthropicBaseURL = "https://api.deepseek.com/anthropic"
DefaultMiniMaxAnthropicBaseURL = "https://api.minimaxi.com/anthropic"
// OpenCode Go Anthropic 基址不含 /v1:nativeAnthropicTargetURL 会再拼 /v1/messages。
DefaultOpenCodeGoAnthropicBaseURL = "https://opencode.ai/zen/go"
DefaultOpenCodeZenAnthropicBaseURL = "https://opencode.ai/zen"
)
// IsCNProvider 报告 platform 是否为国产 OpenAI 兼容供应商(kimi/zhipu/deepseek/minimax)。
@@ -100,6 +110,17 @@ func IsCNProvider(platform string) bool {
}
}
// IsOpenCodeGo 报告 platform 是否为 OpenCode Go 订阅网关。
func IsOpenCodeGo(platform string) bool {
return platform == PlatformOpenCodeGo
}
// IsMultiProtocolAPIKeyProvider 报告 platform 是否为多协议 API Key 网关
// (国产供应商 + OpenCode):走 OpenAI 网关、支持 adaptive 协议分流。
func IsMultiProtocolAPIKeyProvider(platform string) bool {
return IsCNProvider(platform) || platform == PlatformOpenCodeGo
}
// AllowedQuotaPlatforms 是允许设置 user × platform quota 的平台列表(单一权威来源)。
// ent/schema/user_platform_quota.go 的 Validate 函数独立维护(构建期约束),
// 若新增平台需同步修改该 schema。
@@ -113,6 +134,7 @@ var AllowedQuotaPlatforms = []string{
PlatformZhipu,
PlatformDeepseek,
PlatformMiniMax,
PlatformOpenCodeGo,
}
// AllowedSchedulingThresholdPlatforms 是允许设置账号自动停调阈值的平台列表。
@@ -125,6 +147,7 @@ var AllowedSchedulingThresholdPlatforms = []string{
PlatformKimi,
PlatformZhipu,
PlatformMiniMax,
PlatformOpenCodeGo,
}
// IsAllowedQuotaPlatform 报告 s 是否为合法的 quota platform 标识。
@@ -223,7 +223,7 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest(
// 账号级请求头覆写:放在所有内置默认头(含 Grok CLI 身份头)之后应用,
// 使配置值获得除共享传输层强制头之外的最高优先级。
account.ApplyHeaderOverrides(upstreamReq.Header)
applyOpenCodeSessionHeader(c, account, targetURL, upstreamReq.Header)
applyOpenCodeSessionHeader(c, account, targetURL, upstreamReq.Header, body)
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
@@ -71,6 +71,7 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions(
defaultMappedModel string,
compatPromptCacheTenantIsolated bool,
) (*OpenAIForwardResult, error) {
rememberOpenCodeInboundBody(c, body)
beginUpstreamResponseModelObservation(c)
ClearActualOpenAIUpstreamEndpoint(c)
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
@@ -113,10 +114,44 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions(
// accounts never forward the body unchanged to a Chat Completions endpoint.
isResponsesShape := !gjson.GetBytes(body, "messages").Exists() && gjson.GetBytes(body, "input").Exists()
// OpenCode Go:按模型原生协议分流(与 inbound 协议正交)。
// 规则未命中一律兜底 Chat Completions,只有显式 Responses 才走下方转换链。
if account.IsOpenCodeGo() {
mapped := resolveOpenCodeGoMappedModel(account, body, defaultMappedModel)
proto := openCodeGoNativeProtocol(account, mapped)
if proto != APIProtocolResponses {
if isResponsesShape {
if proto == APIProtocolAnthropic {
return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, "")
}
var responsesReq apicompat.ResponsesRequest
if err := json.Unmarshal(body, &responsesReq); err != nil {
return nil, fmt.Errorf("parse responses-shaped chat completions request: %w", err)
}
chatReq, err := apicompat.ResponsesToChatCompletionsRequestWithOptions(
&responsesReq,
&apicompat.ResponsesToChatOptions{ReasoningContentByID: s.reasoningContentByID},
)
if err != nil {
return nil, fmt.Errorf("convert responses-shaped chat completions request: %w", err)
}
chatBody, err := json.Marshal(chatReq)
if err != nil {
return nil, fmt.Errorf("marshal converted chat completions request: %w", err)
}
return s.forwardAsRawChatCompletions(ctx, c, account, chatBody, defaultMappedModel)
}
if proto == APIProtocolAnthropic {
return s.forwardChatCompletionsViaNativeAnthropic(ctx, c, account, body, defaultMappedModel)
}
return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel)
}
}
// 自适应账号的标准 Chat Completions 入站使用供应商原生 CC 端点。
// Responses 形状下,DeepSeek / Kimi 继续走下方原生 Responses 链;GLM
// 没有 Responses 端点,先转换成 Chat Completions 再直转。
if account.IsAdaptiveAPIProtocol() {
if account.IsAdaptiveAPIProtocol() && !account.IsOpenCodeGo() {
if !isResponsesShape {
return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel)
}
@@ -154,6 +189,7 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions(
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel)
}
SetActualOpenAIUpstreamEndpoint(c, openAIResponsesUpstreamEndpoint)
startTime := time.Now()
@@ -376,6 +412,7 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions(
return s.forwardAsChatCompletions(markAgentIdentityTaskRecoveryTried(ctx), c, account, body, promptCacheKey, defaultMappedModel, compatPromptCacheTenantIsolated)
}
if account.Type == AccountTypeAPIKey &&
!account.IsOpenCodeGo() &&
openai_compat.ResolveResponsesSupport(account.Extra) == openai_compat.ResponsesSupportUnknown &&
!isResponsesEndpointSupportedByStatus(resp.StatusCode) {
logger.L().Info("openai chat_completions: /responses unsupported, falling back to raw chat completions",
@@ -399,6 +436,7 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions(
} else {
result, handleErr = s.handleChatBufferedStreamingResponse(resp, c, account, originalModel, billingModel, upstreamModel, startTime)
}
stampOpenAIResponsesUpstreamEndpoint(c, result)
// cyber_policy:标记已设、error 已按 Chat Completions 格式发给客户端。丢弃 result、
// 返回哨兵,使 handler 落入 tokens=0 免费用量行(对齐 /v1/responses),不计费、不 failover。
@@ -111,7 +111,7 @@ func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic(
}
upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, reqStream)
upstreamReq, _, err := s.buildNativeAnthropicUpstreamRequest(upstreamCtx, c, account, anthropicBody, apiKey, targetURL)
upstreamReq, _, err := s.buildNativeAnthropicUpstreamRequest(upstreamCtx, c, account, anthropicBody, apiKey, targetURL, body)
releaseUpstreamCtx()
if err != nil {
return nil, fmt.Errorf("build upstream request: %w", err)
@@ -24,7 +24,7 @@ import (
)
func TestResolveMessagesDispatchModel_CNProvidersNoDispatchMapping(t *testing.T) {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo} {
g := &Group{Platform: platform}
require.Empty(t, g.ResolveMessagesDispatchModel("claude-sonnet-4-5"),
"CN 分组(%s)不得返回调度级映射模型(openai 默认值会发给 CN 上游)", platform)
@@ -57,6 +57,12 @@ func TestFilterCNProviderBillingModelCandidates(t *testing.T) {
require.Equal(t, []string{"claude-sonnet-4-5", "gpt-5.4"}, passthrough)
require.Nil(t, svc.filterCNProviderBillingModelCandidates(context.Background(), nil, apiKey, nil))
openCodeAccount := &Account{ID: 3, Platform: PlatformOpenCodeGo}
openCodeFiltered := svc.filterCNProviderBillingModelCandidates(context.Background(), openCodeAccount, apiKey,
[]string{"claude-sonnet-4-5", "muse-spark-1.3-contributor-free"})
require.Equal(t, []string{"muse-spark-1.3-contributor-free"}, openCodeFiltered,
"OpenCode 无显式定价时不得按 Claude 原价计费 claude-*")
}
func TestCalculateOpenAIRecordUsageCost_EmptyCandidatesIsPricingUnavailable(t *testing.T) {
@@ -264,14 +264,14 @@ func (s *OpenAIGatewayService) ForwardCountTokensAsAnthropic(
return fmt.Errorf("count_tokens: missing account")
}
// 国产供应商(全部协议,含 anthropic):一律本地估算,不发上游请求。
// 国产供应商与 OpenCode(全部协议,含 anthropic):一律本地估算,不发上游请求。
// 依据(2026-08 核实):三家的 Anthropic 兼容层均未提供
// /v1/messages/count_tokens——DeepSeek 官方 anthropic_api 文档无此端点
// (且注明 anthropic-version 头被忽略),聚合网关 OpenModel 明确标注
// count_tokens 为 "Anthropic only",Kimi/智谱亦无任何文档承诺。转发上游
// 只会常态 404,且错误还会流入账号处置逻辑误伤整账号调度;Claude Code
// 高频调用此端点,本地 tiktoken 估算是与 Grok 一致的既有方案。
if account.IsCNProvider() {
if account.IsCNProvider() || account.IsOpenCodeGo() {
estimated, err := estimateAnthropicCountTokensLocally(body)
if err != nil {
writeAnthropicCountTokensError(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body")
@@ -150,6 +150,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
}
originalBody := body
rememberOpenCodeInboundBody(c, originalBody)
requestView := newOpenAIRequestView(body)
reqModel, reqStream, promptCacheKey := requestView.Model, requestView.Stream, requestView.PromptCacheKey
originalModel := reqModel
@@ -158,6 +159,18 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
return s.forwardGrokResponses(ctx, c, account, body, originalModel, reqStream, startTime)
}
if account.IsOpenCodeGo() {
mapped := resolveOpenCodeGoMappedModel(account, body, "")
switch openCodeGoNativeProtocol(account, mapped) {
case APIProtocolAnthropic:
return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, "")
case APIProtocolResponses:
break
default:
return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body)
}
}
// CN 供应商 anthropic 协议账号:/v1/responses 入站是交叉协议组合
// (Responses 客户端 × Anthropic 上游),转成 Anthropic 请求走原生端点。
// 不能落到下面的 raw-CC 分支——其 URL 构造会把 anthropic base 当 CC base 用。
@@ -185,6 +198,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body)
}
SetActualOpenAIUpstreamEndpoint(c, openAIResponsesUpstreamEndpoint)
if account.IsOpenAI() && (account.IsOpenAIApiKey() || account.IsOpenAIOAuthLike()) {
normalizedReasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningContentReplay(body)
if reasoningErr != nil {
@@ -1322,6 +1336,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
if searchCount > 0 && account != nil && account.IsGrok() {
forwardResult.SearchCount = searchCount
}
stampOpenAIResponsesUpstreamEndpoint(c, forwardResult)
return forwardResult, nil
}
}
@@ -1330,6 +1345,11 @@ func shouldForwardOpenAIResponsesViaRawChatCompletions(account *Account) bool {
if account == nil || account.Type != AccountTypeAPIKey {
return false
}
if account.IsOpenCodeGo() {
// Model protocol_rules are the authority. Probe Extra must not collapse
// Grok/GPT/Muse into Chat Completions.
return false
}
if account.IsCNProvider() {
// CN 的显式协议配置优先于异步探针 Extra;adaptive 仅 DeepSeek / Kimi
// 有原生 Responses,GLM 回退 Chat Completions。
@@ -1490,7 +1510,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
// 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
account.ApplyHeaderOverrides(req.Header)
applyOpenCodeSessionHeader(c, account, targetURL, req.Header)
applyOpenCodeSessionHeader(c, account, targetURL, req.Header, body, openCodeSessionHintBody(promptCacheKey))
// x-codex-beta-features:按真实 Codex 的会话级行为补注(在账号级覆写之后,
// 保证不被覆盖丢失)。
applyOpenAICodexBetaFeatures(c, account, req.Header)
@@ -33,6 +33,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
promptCacheKey string,
defaultMappedModel string,
) (*OpenAIForwardResult, error) {
rememberOpenCodeInboundBody(c, body)
beginUpstreamResponseModelObservation(c)
ClearActualOpenAIUpstreamEndpoint(c)
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
@@ -43,12 +44,23 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
return nil, err
}
// 入口分流(国产供应商 Anthropic 协议):上游为供应商原生 Anthropic 端点时,
// /v1/messages 请求零转换直通(仅模型名映射 + 少量 body 清洗),完整保留
// thinking / tool_use / cache 语义,适配 Claude Code 等原生客户端。
// 必须先于 ShouldUseResponsesAPI 分流:Anthropic 协议账号经 probe 落标
// openai_responses_supported=false,会先命中下方的 CC 直转分支。
if account.IsAnthropicProtocol() || account.IsAdaptiveAPIProtocol() {
// OpenCode Go:按模型原生协议分流。规则未命中兜底 Chat Completions。
if account.IsOpenCodeGo() {
mapped := resolveOpenCodeGoMappedModel(account, body, defaultMappedModel)
switch openCodeGoNativeProtocol(account, mapped) {
case APIProtocolAnthropic:
return s.forwardAnthropicViaNativeAnthropicEndpoint(ctx, c, account, body, defaultMappedModel)
case APIProtocolResponses:
break
default:
return s.forwardAnthropicViaRawChatCompletions(ctx, c, account, body, defaultMappedModel)
}
} else if account.IsAnthropicProtocol() || account.IsAdaptiveAPIProtocol() {
// 入口分流(国产供应商 Anthropic 协议):上游为供应商原生 Anthropic 端点时,
// /v1/messages 请求零转换直通(仅模型名映射 + 少量 body 清洗),完整保留
// thinking / tool_use / cache 语义,适配 Claude Code 等原生客户端。
// 必须先于 ShouldUseResponsesAPI 分流:Anthropic 协议账号经 probe 落标
// openai_responses_supported=false,会先命中下方的 CC 直转分支。
return s.forwardAnthropicViaNativeAnthropicEndpoint(ctx, c, account, body, defaultMappedModel)
}
@@ -57,6 +69,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
return s.forwardAnthropicViaRawChatCompletions(ctx, c, account, body, defaultMappedModel)
}
SetActualOpenAIUpstreamEndpoint(c, openAIResponsesUpstreamEndpoint)
startTime := time.Now()
@@ -530,6 +543,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
notifyOpenAIAutoReset(*account.ParentAccountID)
}
stampOpenAIResponsesUpstreamEndpoint(c, result)
return result, handleErr
}
@@ -133,9 +133,19 @@ 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 strings.TrimRight(validatedURL, "/") + "/v1/messages", nil
}
func resolveOpenCodeGoMappedModel(account *Account, body []byte, defaultMappedModel string) string {
original := strings.TrimSpace(gjson.GetBytes(body, "model").String())
billing := resolveOpenAIForwardModel(account, original, defaultMappedModel)
return normalizeOpenAIModelForUpstream(account, billing)
}
func (s *OpenAIGatewayService) buildNativeAnthropicUpstreamRequest(
ctx context.Context,
c *gin.Context,
@@ -143,6 +153,7 @@ func (s *OpenAIGatewayService) buildNativeAnthropicUpstreamRequest(
body []byte,
apiKey string,
targetURL string,
sessionBodies ...[]byte,
) (*http.Request, []byte, error) {
// 能力维度 body sanitize:与 Anthropic 平台 passthrough 相同,按 beta
// header 决定是否保留 body 中的 beta 能力字段,避免客户端"body 带字段但
@@ -199,6 +210,8 @@ func (s *OpenAIGatewayService) buildNativeAnthropicUpstreamRequest(
// 账号级请求头覆写(最终生效,覆盖上面所有来源的同名头)
account.ApplyHeaderOverrides(req.Header)
payloads := append([][]byte{body}, sessionBodies...)
applyOpenCodeSessionHeader(c, account, targetURL, req.Header, payloads...)
return req, body, nil
}
@@ -0,0 +1,124 @@
//go:build unit
package service
import (
"context"
"io"
"net/http"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func openCodeMappedTestAccount() *Account {
return &Account{
ID: 801,
Name: "oc",
Platform: PlatformOpenCodeGo,
Type: AccountTypeAPIKey,
Concurrency: 1,
Credentials: map[string]any{
"api_key": "sk-opencode",
"account_mode": AccountModeZen,
"api_protocol": APIProtocolAdaptive,
"base_url": "https://opencode.ai/zen/v1",
"api_base_urls": map[string]any{
APIProtocolChatCompletions: "https://opencode.ai/zen/v1",
APIProtocolAnthropic: "https://opencode.ai/zen",
APIProtocolResponses: "https://opencode.ai/zen/v1",
},
"model_mapping": map[string]any{
"opencode/muse-spark-1.3-contributior-free": "muse-spark-1.3-contributior-free",
"opencode/claude-sonnet-4": "claude-sonnet-4",
"opencode/glm-5.3": "glm-5.3",
},
},
}
}
func TestOpenCodeGatewayAppliesMappedModelOnAllIngresses(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
path string
body string
wantURL string
wantModel string
wantNotModel string
forward func(*OpenAIGatewayService, *gin.Context, *Account, []byte) error
upstream *http.Response
}{
{
name: "responses muse-spark",
path: "/v1/responses",
body: `{"model":"opencode/muse-spark-1.3-contributior-free","stream":false,"input":"hi"}`,
wantURL: "https://opencode.ai/zen/v1/responses",
wantModel: "muse-spark-1.3-contributior-free",
wantNotModel: "opencode/muse-spark-1.3-contributior-free",
upstream: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-oc-responses"}},
Body: io.NopCloser(strings.NewReader(
`{"id":"resp_oc","object":"response","model":"muse-spark-1.3-contributior-free","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":2,"output_tokens":1,"total_tokens":3}}`,
)),
},
forward: func(svc *OpenAIGatewayService, c *gin.Context, account *Account, body []byte) error {
SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
_, err := svc.Forward(context.Background(), c, account, body)
return err
},
},
{
name: "chat completions glm",
path: "/v1/chat/completions",
body: `{"model":"opencode/glm-5.3","stream":false,"messages":[{"role":"user","content":"hi"}]}`,
wantURL: "https://opencode.ai/zen/v1/chat/completions",
wantModel: "glm-5.3",
wantNotModel: "opencode/glm-5.3",
upstream: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-oc-cc"}},
Body: io.NopCloser(strings.NewReader(
`{"id":"chatcmpl_oc","object":"chat.completion","model":"glm-5.3","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}`,
)),
},
forward: func(svc *OpenAIGatewayService, c *gin.Context, account *Account, body []byte) error {
_, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
return err
},
},
{
name: "messages claude",
path: "/v1/messages",
body: `{"model":"opencode/claude-sonnet-4","max_tokens":32,"stream":false,"messages":[{"role":"user","content":"hi"}]}`,
wantURL: "https://opencode.ai/zen/v1/messages",
wantModel: "claude-sonnet-4",
wantNotModel: "opencode/claude-sonnet-4",
upstream: nativeAnthropicBufferedResponse(),
forward: func(svc *OpenAIGatewayService, c *gin.Context, account *Account, body []byte) error {
_, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
return err
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
upstream := &httpUpstreamRecorder{resp: tt.upstream}
svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
c := adaptiveProtocolTestContext(tt.path, []byte(tt.body))
err := tt.forward(svc, c, openCodeMappedTestAccount(), []byte(tt.body))
require.NoError(t, err)
require.NotNil(t, upstream.lastReq)
require.Equal(t, tt.wantURL, upstream.lastReq.URL.String())
require.Equal(t, tt.wantModel, gjson.GetBytes(upstream.lastBody, "model").String())
require.NotContains(t, string(upstream.lastBody), tt.wantNotModel)
})
}
}
@@ -721,7 +721,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
// 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
account.ApplyHeaderOverrides(req.Header)
applyOpenCodeSessionHeader(c, account, targetURL, req.Header)
applyOpenCodeSessionHeader(c, account, targetURL, req.Header, body)
// x-codex-beta-features:按真实 Codex 的会话级行为补注(在账号级覆写之后,
// 保证不被覆盖丢失)。
applyOpenAICodexBetaFeatures(c, account, req.Header)
@@ -116,7 +116,7 @@ func (s *OpenAIGatewayService) forwardResponsesViaNativeAnthropic(
}
upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, reqStream)
upstreamReq, _, err := s.buildNativeAnthropicUpstreamRequest(upstreamCtx, c, account, anthropicBody, apiKey, targetURL)
upstreamReq, _, err := s.buildNativeAnthropicUpstreamRequest(upstreamCtx, c, account, anthropicBody, apiKey, targetURL, body)
releaseUpstreamCtx()
if err != nil {
return nil, fmt.Errorf("build upstream request: %w", err)
@@ -292,7 +292,7 @@ func (s *OpenAIGatewayService) SelectAccountForTokenCount(
// handler 调度入口仍需导出,保持导出名。)
func NormalizeOpenAICompatiblePlatform(platform string) string {
switch platform {
case PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
case PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo:
return platform
default:
return PlatformOpenAI
@@ -314,6 +314,21 @@ func (r *OpenAIForwardResult) SucceededForScheduling() bool {
}
}
const openAIResponsesUpstreamEndpoint = "/v1/responses"
// stampOpenAIResponsesUpstreamEndpoint records that this attempt hit the
// Responses API. OpenCode Go / CN accounts cannot derive that from inbound
// path (DeriveUpstreamEndpoint falls back to the client URL).
func stampOpenAIResponsesUpstreamEndpoint(c *gin.Context, result *OpenAIForwardResult) {
SetActualOpenAIUpstreamEndpoint(c, openAIResponsesUpstreamEndpoint)
if result == nil {
return
}
if strings.TrimSpace(result.UpstreamEndpoint) == "" {
result.UpstreamEndpoint = openAIResponsesUpstreamEndpoint
}
}
// SetActualOpenAIUpstreamEndpoint records the endpoint selected by the current
// forwarding attempt. It covers error paths where no OpenAIForwardResult is
// available for usage and operations logging.
@@ -918,7 +918,7 @@ func groupMediaPricingLooksIncomplete(group *Group) bool {
// 运营者的修复手段是配置账号级 model_mapping(映射到已定价的 CN 模型)或
// 分组/渠道显式定价。
func (s *OpenAIGatewayService) filterCNProviderBillingModelCandidates(ctx context.Context, account *Account, apiKey *APIKey, candidates []string) []string {
if account == nil || !account.IsCNProvider() {
if account == nil || (!account.IsCNProvider() && !account.IsOpenCodeGo()) {
return candidates
}
out := make([]string, 0, len(candidates))
@@ -79,10 +79,10 @@ func (g *Group) ResolveMessagesDispatchModel(requestedModel string) string {
return xai.ModelMappingWithOptions(opts)["claude-*"]
}
// 国产供应商分组:调度级模型映射不适用(其配置被 sanitize 置空,且下方的
// gpt-5.x 默认值是 openai 专属,发给 CN 上游必错)。模型改写完全交给账号级
// model_mapping;anthropic 协议上游本身接受 claude-* 模型名。
if IsCNProvider(g.Platform) {
// 国产供应商 / OpenCode 分组:调度级模型映射不适用(其配置被 sanitize 置空,
// 且下方的 gpt-5.x 默认值是 openai 专属,发给这些上游必错)。模型改写完全
// 交给账号级 model_mapping;Anthropic 协议上游本身接受 claude-* 模型名。
if IsMultiProtocolAPIKeyProvider(g.Platform) {
return ""
}
@@ -3,28 +3,61 @@ package service
import (
"net/http"
"net/url"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/tidwall/gjson"
)
const openCodeSessionHeader = "X-OpenCode-Session"
const (
openCodeSessionHeader = "X-OpenCode-Session"
openCodeInboundBodyContextKey = "opencode_inbound_body"
)
// applyOpenCodeSessionHeader forwards the caller-owned conversation identifier
// only to OpenCode's official API origin. The caller applies this after account
// header overrides so a per-conversation value cannot be replaced by a fixed
// account-wide override.
func applyOpenCodeSessionHeader(c *gin.Context, account *Account, targetURL string, headers http.Header) {
if c == nil || c.Request == nil || account == nil || account.Type != AccountTypeAPIKey || headers == nil {
// rememberOpenCodeInboundBody keeps the client request body so protocol
// conversion (Responses↔Anthropic↔Chat Completions) can still recover
// prompt_cache_key / metadata.user_id after those fields are dropped.
func rememberOpenCodeInboundBody(c *gin.Context, body []byte) {
if c == nil || len(body) == 0 {
return
}
c.Set(openCodeInboundBodyContextKey, body)
}
func openCodeInboundBodies(c *gin.Context) [][]byte {
if c == nil {
return nil
}
raw, ok := c.Get(openCodeInboundBodyContextKey)
if !ok {
return nil
}
body, ok := raw.([]byte)
if !ok || len(body) == 0 {
return nil
}
return [][]byte{body}
}
// applyOpenCodeSessionHeader sets x-opencode-session on outbound inference
// requests. OpenCode Go requires a per-conversation value from 2026-09-05
// (MissingSessionID). Prefer caller headers, then the documented body session
// fields (OpenAI prompt_cache_key / Anthropic metadata.user_id), then any
// already applied account override. A generated UUID is last-resort only for
// probes and clients that omit every stable identifier — a new UUID each turn
// would miss upstream prompt cache.
func applyOpenCodeSessionHeader(c *gin.Context, account *Account, targetURL string, headers http.Header, bodies ...[]byte) {
if account == nil || account.Type != AccountTypeAPIKey || headers == nil {
return
}
if !shouldSendOpenCodeSessionHeader(account, targetURL) {
return
}
parsed, err := url.Parse(targetURL)
if err != nil || !strings.EqualFold(parsed.Scheme, "https") || !strings.EqualFold(parsed.Hostname(), "opencode.ai") {
return
}
sessionID := strings.TrimSpace(c.GetHeader(openCodeSessionHeader))
payloads := append(openCodeInboundBodies(c), bodies...)
sessionID := resolveOpenCodeSessionID(c, headers, shouldGenerateOpenCodeSession(account, targetURL), payloads...)
if sessionID == "" {
return
}
@@ -35,3 +68,105 @@ func applyOpenCodeSessionHeader(c *gin.Context, account *Account, targetURL stri
}
headers.Set(openCodeSessionHeader, sessionID)
}
func shouldSendOpenCodeSessionHeader(account *Account, targetURL string) bool {
if account != nil && account.IsOpenCodeGoPlan() {
return true
}
return isOfficialOpenCodeHost(targetURL)
}
func shouldGenerateOpenCodeSession(account *Account, targetURL string) bool {
if account != nil && account.IsOpenCodeGoPlan() {
return true
}
parsed, err := url.Parse(targetURL)
if err != nil {
return false
}
return strings.EqualFold(parsed.Scheme, "https") &&
strings.EqualFold(parsed.Hostname(), "opencode.ai") &&
strings.Contains(parsed.Path, "/zen/go")
}
func isOfficialOpenCodeHost(targetURL string) bool {
parsed, err := url.Parse(targetURL)
if err != nil {
return false
}
return strings.EqualFold(parsed.Scheme, "https") && strings.EqualFold(parsed.Hostname(), "opencode.ai")
}
func resolveOpenCodeSessionID(c *gin.Context, headers http.Header, generate bool, bodies ...[]byte) string {
if c != nil && c.Request != nil {
if sessionID := sanitizeSessionID(c.GetHeader(openCodeSessionHeader)); sessionID != "" {
return sessionID
}
if sessionID := sanitizeSessionID(explicitOpenAIHeaderSessionID(c)); sessionID != "" {
return sessionID
}
if sessionID := sanitizeSessionID(ClaudeCodeSessionIDFromHeader(c)); sessionID != "" {
return sessionID
}
}
for _, body := range bodies {
if sessionID := sanitizeSessionID(openCodeSessionIDFromPayload(body)); sessionID != "" {
return sessionID
}
}
if sessionID := sanitizeSessionID(existingOpenCodeSessionHeader(headers)); sessionID != "" {
return sessionID
}
if generate {
return uuid.NewString()
}
return ""
}
// openCodeSessionIDFromPayload reads the stable conversation id from documented
// request-body fields. Kimi Code / OpenAI clients send prompt_cache_key on
// Chat Completions and Responses; Anthropic clients send metadata.user_id on
// Messages. Neither field changes model behavior.
func openCodeSessionIDFromPayload(body []byte) string {
if len(body) == 0 {
return ""
}
view := openAIRequestPayloadView(body)
if sessionID := strings.TrimSpace(view.Get("prompt_cache_key").String()); sessionID != "" {
return sessionID
}
return openCodeSessionIDFromMetadataUserID(view.Get("metadata.user_id").String())
}
func openCodeSessionIDFromMetadataUserID(userID string) string {
userID = strings.TrimSpace(userID)
if userID == "" {
return ""
}
if strings.HasPrefix(userID, "{") {
if sessionID := strings.TrimSpace(gjson.Get(userID, "session_id").String()); sessionID != "" {
return sessionID
}
}
return userID
}
func openCodeSessionHintBody(promptCacheKey string) []byte {
key := strings.TrimSpace(promptCacheKey)
if key == "" {
return nil
}
return []byte(`{"prompt_cache_key":` + strconv.Quote(key) + `}`)
}
func existingOpenCodeSessionHeader(headers http.Header) string {
if headers == nil {
return ""
}
for key, values := range headers {
if strings.EqualFold(key, openCodeSessionHeader) && len(values) > 0 {
return values[0]
}
}
return ""
}
@@ -92,7 +92,13 @@ func TestApplyOpenCodeSessionHeaderTrustBoundary(t *testing.T) {
incoming: "conversation-123",
},
{
name: "missing caller value",
name: "missing caller value generates session on go endpoint",
account: openCodeSessionTestAccount("https://opencode.ai/zen/go/v1"),
targetURL: "https://opencode.ai/zen/go/v1/responses",
want: "<generated>",
},
{
name: "zen endpoint does not invent a session",
account: openCodeSessionTestAccount("https://opencode.ai/zen/v1"),
targetURL: "https://opencode.ai/zen/v1/responses",
},
@@ -108,11 +114,116 @@ func TestApplyOpenCodeSessionHeaderTrustBoundary(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
headers := make(http.Header)
applyOpenCodeSessionHeader(newOpenCodeSessionTestContext(t, tt.incoming), tt.account, tt.targetURL, headers)
require.Equal(t, tt.want, headers.Get(openCodeSessionHeader))
got := headers.Get(openCodeSessionHeader)
if tt.want == "<generated>" {
require.NotEmpty(t, got)
return
}
require.Equal(t, tt.want, got)
})
}
}
func TestApplyOpenCodeSessionHeaderOpenCodeGoAlwaysSetsSession(t *testing.T) {
account := &Account{
ID: 4,
Platform: PlatformOpenCodeGo,
Type: AccountTypeAPIKey,
Credentials: map[string]any{
"base_url": "https://relay.example.com/v1",
},
}
headers := make(http.Header)
applyOpenCodeSessionHeader(newOpenCodeSessionTestContext(t, ""), account, "https://relay.example.com/v1/chat/completions", headers)
require.NotEmpty(t, headers.Get(openCodeSessionHeader))
}
func TestApplyOpenCodeSessionHeaderMapsCallerSessionID(t *testing.T) {
account := &Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey}
c := newOpenCodeSessionTestContext(t, "")
c.Request.Header.Set("session_id", "conv-from-client")
headers := make(http.Header)
applyOpenCodeSessionHeader(c, account, "https://opencode.ai/zen/go/v1/chat/completions", headers)
require.Equal(t, "conv-from-client", headers.Get(openCodeSessionHeader))
}
func TestApplyOpenCodeSessionHeaderRejectsControlCharsInPromptCacheKey(t *testing.T) {
account := &Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey}
body := []byte("{\"model\":\"glm-5.3\",\"prompt_cache_key\":\"a\\nb\",\"input\":\"hello\"}")
headers := make(http.Header)
applyOpenCodeSessionHeader(newOpenCodeSessionTestContext(t, ""), account, "https://opencode.ai/zen/go/v1/responses", headers, body)
got := headers.Get(openCodeSessionHeader)
require.NotEmpty(t, got)
require.NotContains(t, got, "\n")
require.NotEqual(t, "a\nb", got)
}
func TestApplyOpenCodeSessionHeaderUsesPromptCacheKeyInsteadOfRandomUUID(t *testing.T) {
account := &Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey}
body := []byte(`{"model":"grok-4.6","prompt_cache_key":"kimi-session-42","input":"hello"}`)
headers := make(http.Header)
applyOpenCodeSessionHeader(newOpenCodeSessionTestContext(t, ""), account, "https://opencode.ai/zen/go/v1/responses", headers, body)
require.Equal(t, "kimi-session-42", headers.Get(openCodeSessionHeader))
headers2 := make(http.Header)
laterTurn := []byte(`{"model":"grok-4.6","prompt_cache_key":"kimi-session-42","input":"follow up"}`)
applyOpenCodeSessionHeader(newOpenCodeSessionTestContext(t, ""), account, "https://opencode.ai/zen/go/v1/responses", headers2, laterTurn)
require.Equal(t, headers.Get(openCodeSessionHeader), headers2.Get(openCodeSessionHeader))
}
func TestApplyOpenCodeSessionHeaderUsesAnthropicMetadataUserID(t *testing.T) {
account := &Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey}
body := []byte(`{"model":"minimax-m3","metadata":{"user_id":"coding-agent-session"},"messages":[{"role":"user","content":"hi"}]}`)
headers := make(http.Header)
applyOpenCodeSessionHeader(newOpenCodeSessionTestContext(t, ""), account, "https://opencode.ai/zen/go/v1/messages", headers, body)
require.Equal(t, "coding-agent-session", headers.Get(openCodeSessionHeader))
}
func TestApplyOpenCodeSessionHeaderUnwrapsClaudeCodeMetadataSessionJSON(t *testing.T) {
account := &Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey}
body := []byte(`{"model":"claude-sonnet-4","metadata":{"user_id":"{\"session_id\":\"meta-session-xyz\"}"},"messages":[]}`)
headers := make(http.Header)
applyOpenCodeSessionHeader(newOpenCodeSessionTestContext(t, ""), account, "https://opencode.ai/zen/go/v1/messages", headers, body)
require.Equal(t, "meta-session-xyz", headers.Get(openCodeSessionHeader))
}
func TestApplyOpenCodeSessionHeaderBodyBeatsGeneratedUUIDAndLosesToCallerHeader(t *testing.T) {
account := &Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey}
body := []byte(`{"prompt_cache_key":"from-body"}`)
headers := make(http.Header)
applyOpenCodeSessionHeader(newOpenCodeSessionTestContext(t, "from-header"), account, "https://opencode.ai/zen/go/v1/responses", headers, body)
require.Equal(t, "from-header", headers.Get(openCodeSessionHeader))
converted := []byte(`{"model":"minimax-m3","messages":[]}`)
headers2 := make(http.Header)
applyOpenCodeSessionHeader(newOpenCodeSessionTestContext(t, ""), account, "https://opencode.ai/zen/go/v1/messages", headers2, converted, body)
require.Equal(t, "from-body", headers2.Get(openCodeSessionHeader))
}
func TestApplyOpenCodeSessionHeaderUsesRememberedInboundBodyAfterConversion(t *testing.T) {
account := &Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey}
c := newOpenCodeSessionTestContext(t, "")
rememberOpenCodeInboundBody(c, []byte(`{"model":"gpt-5","prompt_cache_key":"inbound-responses-session","input":"hello"}`))
headers := make(http.Header)
convertedCC := []byte(`{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"hello"}]}`)
applyOpenCodeSessionHeader(c, account, "https://opencode.ai/zen/go/v1/chat/completions", headers, convertedCC)
require.Equal(t, "inbound-responses-session", headers.Get(openCodeSessionHeader))
c2 := newOpenCodeSessionTestContext(t, "")
rememberOpenCodeInboundBody(c2, []byte(`{"model":"grok-4.6","metadata":{"user_id":"inbound-messages-session"},"messages":[{"role":"user","content":"hi"}]}`))
headers2 := make(http.Header)
convertedResponses := []byte(`{"model":"grok-4.6","input":"hi"}`)
applyOpenCodeSessionHeader(c2, account, "https://opencode.ai/zen/go/v1/responses", headers2, convertedResponses)
require.Equal(t, "inbound-messages-session", headers2.Get(openCodeSessionHeader))
}
func TestOpenCodeSessionIDFromPayloadIgnoresEmptyBody(t *testing.T) {
require.Empty(t, openCodeSessionIDFromPayload(nil))
require.Empty(t, openCodeSessionIDFromPayload([]byte(`{"model":"gpt-5"}`)))
}
func TestOpenCodeSessionForwardedByResponsesBuildersAfterAccountOverride(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := openCodeSessionTestService()
@@ -147,6 +258,24 @@ func TestOpenCodeSessionForwardedByResponsesBuildersAfterAccountOverride(t *test
}
}
func TestOpenCodeSessionForwardedFromPromptCacheKeyWithoutCallerHeader(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := openCodeSessionTestService()
account := &Account{
ID: 1,
Platform: PlatformOpenCodeGo,
Type: AccountTypeAPIKey,
Credentials: map[string]any{
"base_url": "https://opencode.ai/zen/go/v1",
},
}
c := newOpenCodeSessionTestContext(t, "")
body := []byte(`{"model":"gpt-5","prompt_cache_key":"stable-cache-key","input":"hello"}`)
req, err := svc.buildUpstreamRequest(context.Background(), c, account, body, "token", false, "", false)
require.NoError(t, err)
requireSingleOpenCodeSessionHeader(t, req.Header, "stable-cache-key")
}
func TestOpenCodeSessionMissingCallerValueKeepsExistingOverrideBehavior(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := openCodeSessionTestService()
+330
View File
@@ -0,0 +1,330 @@
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
}
@@ -0,0 +1,220 @@
package service
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestOpenCodeGoModelProtocol(t *testing.T) {
t.Parallel()
cases := []struct {
model string
want string
}{
{"grok-4.6", APIProtocolResponses},
{"opencode-go/gpt-5.6-luna", APIProtocolResponses},
{"muse-spark-1.3-contributor", APIProtocolResponses},
{"minimax-m3", APIProtocolAnthropic},
{"qwen3.8-max", APIProtocolAnthropic},
{"qwen3.7-plus", APIProtocolAnthropic},
{"glm-5.3", APIProtocolChatCompletions},
{"kimi-k3", APIProtocolChatCompletions},
{"deepseek-v4-pro", APIProtocolChatCompletions},
{"mimo-v2.5-pro", APIProtocolChatCompletions},
{"hy4-preview", APIProtocolChatCompletions},
{"omen-alpha", APIProtocolChatCompletions},
{"", APIProtocolChatCompletions},
{"unknown-model", APIProtocolChatCompletions},
}
for _, tc := range cases {
require.Equal(t, tc.want, OpenCodeGoModelProtocol(tc.model), "model=%s", tc.model)
}
}
func TestResolveOpenCodeGoUpstreamProtocol(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"))
pinned := &Account{Platform: PlatformOpenCodeGo, Credentials: map[string]any{"api_protocol": APIProtocolChatCompletions}}
require.Equal(t, APIProtocolChatCompletions, pinned.ResolveOpenCodeGoUpstreamProtocol("minimax-m3"))
empty := &Account{Platform: PlatformOpenCodeGo}
require.Equal(t, APIProtocolResponses, empty.ResolveOpenCodeGoUpstreamProtocol("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, DefaultOpenCodeZenBaseURL, zen.GetOpenAIBaseURL())
require.Equal(t, "", (&Account{Platform: PlatformKimi}).ResolveOpenCodeGoUpstreamProtocol("glm-5.3"))
}
func TestResolveOpenCodeGoUpstreamProtocolUsesAccountRules(t *testing.T) {
t.Parallel()
account := &Account{
Platform: PlatformOpenCodeGo,
Credentials: map[string]any{
"api_protocol": APIProtocolAdaptive,
openCodeGoProtocolRulesKey: []any{
map[string]any{"pattern": "grok-*", "protocol": APIProtocolChatCompletions},
map[string]any{"pattern": "deepseek-v4-flash", "protocol": APIProtocolResponses},
map[string]any{"pattern": "qwen*", "protocol": APIProtocolAnthropic},
},
},
}
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"))
}
func TestResolveOpenCodeGoUpstreamProtocolEmptyRulesAreAllChatCompletions(t *testing.T) {
t.Parallel()
account := &Account{
Platform: PlatformOpenCodeGo,
Credentials: map[string]any{
openCodeGoProtocolRulesKey: []any{},
},
}
require.Equal(t, APIProtocolChatCompletions, account.ResolveOpenCodeGoUpstreamProtocol("grok-4.6"))
require.Equal(t, APIProtocolChatCompletions, account.ResolveOpenCodeGoUpstreamProtocol("minimax-m3"))
}
func TestResolveOpenCodeGoUpstreamProtocolFirstMatchWins(t *testing.T) {
t.Parallel()
account := &Account{
Platform: PlatformOpenCodeGo,
Credentials: map[string]any{
openCodeGoProtocolRulesKey: []any{
map[string]any{"pattern": "gpt-5.6-luna", "protocol": APIProtocolChatCompletions},
map[string]any{"pattern": "gpt-*", "protocol": APIProtocolResponses},
},
},
}
require.Equal(t, APIProtocolChatCompletions, account.ResolveOpenCodeGoUpstreamProtocol("gpt-5.6-luna"))
require.Equal(t, APIProtocolResponses, account.ResolveOpenCodeGoUpstreamProtocol("gpt-5.4"))
}
func TestOpenCodeGoNativeProtocolUnmatchedFallsBackToChatCompletions(t *testing.T) {
t.Parallel()
withRules := &Account{
Platform: PlatformOpenCodeGo,
Credentials: map[string]any{
"api_protocol": APIProtocolAdaptive,
openCodeGoProtocolRulesKey: []any{
map[string]any{"pattern": "grok-*", "protocol": APIProtocolResponses},
map[string]any{"pattern": "gpt-*", "protocol": APIProtocolResponses},
map[string]any{"pattern": "qwen*", "protocol": APIProtocolAnthropic},
},
},
}
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"))
defaults := &Account{Platform: PlatformOpenCodeGo}
require.Equal(t, APIProtocolChatCompletions, openCodeGoNativeProtocol(defaults, "deepseek-v4-flash"))
require.Equal(t, APIProtocolChatCompletions, openCodeGoNativeProtocol(nil, "grok-4.6"))
}
func TestParseOpenCodeGoProtocolRulesAcceptsTypedMaps(t *testing.T) {
t.Parallel()
rules, err := parseOpenCodeGoProtocolRules([]map[string]any{
{"pattern": "grok-*", "protocol": APIProtocolResponses},
{"pattern": "qwen*", "protocol": APIProtocolAnthropic},
})
require.NoError(t, err)
require.Equal(t, []OpenCodeGoProtocolRule{
{Pattern: "grok-*", Protocol: APIProtocolResponses},
{Pattern: "qwen*", Protocol: APIProtocolAnthropic},
}, rules)
}
func TestShouldForwardOpenAIResponsesViaRawChatCompletions_OpenCodeGoIgnoresProbe(t *testing.T) {
t.Parallel()
account := &Account{
Platform: PlatformOpenCodeGo,
Type: AccountTypeAPIKey,
Credentials: map[string]any{
"api_protocol": APIProtocolAdaptive,
},
Extra: map[string]any{
"openai_responses_supported": false,
},
}
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"))
}
func TestStampOpenAIResponsesUpstreamEndpoint(t *testing.T) {
t.Parallel()
result := &OpenAIForwardResult{}
stampOpenAIResponsesUpstreamEndpoint(nil, result)
require.Equal(t, "/v1/responses", result.UpstreamEndpoint)
existing := &OpenAIForwardResult{UpstreamEndpoint: "/v1/messages"}
stampOpenAIResponsesUpstreamEndpoint(nil, existing)
require.Equal(t, "/v1/messages", existing.UpstreamEndpoint)
}
func TestNormalizeOpenCodeGoProtocolRulesCredentials(t *testing.T) {
t.Parallel()
require.NoError(t, NormalizeOpenCodeGoProtocolRulesCredentials(nil))
require.NoError(t, NormalizeOpenCodeGoProtocolRulesCredentials(map[string]any{}))
creds := map[string]any{
openCodeGoProtocolRulesKey: []any{
map[string]any{"pattern": " Grok-* ", "protocol": APIProtocolResponses},
},
}
require.NoError(t, NormalizeOpenCodeGoProtocolRulesCredentials(creds))
rules, ok := creds[openCodeGoProtocolRulesKey].([]any)
require.True(t, ok)
require.NotEmpty(t, rules)
entry, ok := rules[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "grok-*", entry["pattern"])
err := NormalizeOpenCodeGoProtocolRulesCredentials(map[string]any{
openCodeGoProtocolRulesKey: []any{
map[string]any{"pattern": "*grok", "protocol": APIProtocolResponses},
},
})
require.Error(t, err)
err = NormalizeOpenCodeGoProtocolRulesCredentials(map[string]any{
openCodeGoProtocolRulesKey: []any{
map[string]any{"pattern": "grok-*", "protocol": "adaptive"},
},
})
require.Error(t, err)
}
func TestOpenCodeGoQuotaURL(t *testing.T) {
t.Parallel()
require.Equal(t, "https://opencode.ai/zen/go/v1/usage", openCodeGoQuotaURL(""))
require.Equal(t, "https://opencode.ai/zen/go/v1/usage", openCodeGoQuotaURL(DefaultOpenCodeGoBaseURL+"/"))
require.Equal(t, "https://custom.example/v1/usage", openCodeGoQuotaURL("https://custom.example/v1"))
}
func TestDefaultOpenCodeGoModelIDsCoverDocumentedCatalog(t *testing.T) {
t.Parallel()
ids := DefaultOpenCodeGoModelIDs()
require.Contains(t, ids, "grok-4.6")
require.Contains(t, ids, "minimax-m3")
require.Contains(t, ids, "glm-5.3")
require.NotEmpty(t, ids)
}
@@ -127,12 +127,19 @@ func (s *RateLimitService) cnBalanceCooldownDuration() time.Duration {
// 周期额度探测刷新快照后阈值评估会再次停调到正确的时间点。
// 无快照或均已过期返回 nil。
func cnProviderQuotaSnapshotReset(account *Account, now time.Time) *time.Time {
if account == nil || !account.IsCNProvider() || !account.IsCodingPlan() || len(account.Extra) == 0 {
if account == nil || len(account.Extra) == 0 {
return nil
}
if !account.IsOpenCodeGo() && (!account.IsCNProvider() || !account.IsCodingPlan()) {
return nil
}
provider := account.Platform
suffixes := []string{cnExtraSuffix5hReset, cnExtraSuffixWeeklyReset}
if account.IsOpenCodeGo() {
suffixes = append(suffixes, cnExtraSuffixMonthlyReset)
}
var earliest *time.Time
for _, suffix := range []string{cnExtraSuffix5hReset, cnExtraSuffixWeeklyReset} {
for _, suffix := range suffixes {
t := parseSchedulingResetAt(account.Extra[cnExtraKey(provider, suffix)])
if t == nil || !t.After(now) {
continue
@@ -152,6 +159,36 @@ func (s *RateLimitService) applyCNProviderReactive429(
headers http.Header,
responseBody []byte,
) bool {
if account.IsOpenCodeGo() {
if until := cnProviderQuotaSnapshotReset(account, time.Now()); until != nil {
s.notifyAccountSchedulingBlocked(account, *until, "429")
if err := s.accountRepo.SetRateLimited(ctx, account.ID, *until); err != nil {
slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err)
return true
}
slog.Info("opencode_go_rate_limited",
"account_id", account.ID,
"platform", account.Platform,
"reset_at", *until,
)
return true
}
if resetAt := parseOpenAIRateLimitResetTime(responseBody); resetAt != nil {
resetTime := time.Unix(*resetAt, 0)
s.notifyAccountSchedulingBlocked(account, resetTime, "429")
if err := s.accountRepo.SetRateLimited(ctx, account.ID, resetTime); err != nil {
slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err)
return true
}
slog.Info("opencode_go_rate_limited",
"account_id", account.ID,
"platform", account.Platform,
"reset_at", resetTime,
)
return true
}
return false
}
if !account.IsCNProvider() {
return false
}
@@ -512,7 +512,7 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc
case 402:
// 国产供应商:余额不足是可恢复状态(充值/检测恢复后由周期任务自动解除),
// 不能走 handleAuthError 永久置 status=error。改为可恢复的临时停调。
if account.IsCNProvider() {
if account.IsCNProvider() || account.IsOpenCodeZen() {
s.handleCNProviderInsufficientBalance(ctx, account, upstreamMsg)
shouldDisable = true
break
@@ -990,7 +990,7 @@ func (s *RateLimitService) handle403(ctx context.Context, account *Account, upst
// 国产供应商与 openai 同口径:HTML 403(CDN/代理拦截页)不构成账号失效证据,
// 且 403 在 failover 状态集里会被逐账号重放——直接 SetError 会让一个坏请求/
// 一层坏代理连环永久禁用整组账号。走 HTML 豁免 + N 次累计 + 临时冷却。
if account.Platform == PlatformOpenAI || IsCNProvider(account.Platform) {
if account.Platform == PlatformOpenAI || IsCNProvider(account.Platform) || account.IsOpenCodeGo() {
return s.handleOpenAI403(ctx, account, upstreamMsg, responseBody)
}
// 非 Antigravity 平台:保持原有行为
@@ -1154,7 +1154,7 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head
}
// 国产供应商(kimi/zhipu/deepseek)的 429 走专用可恢复路径:余额不足 → 临时停调,
// Coding Plan 窗口耗尽 → 冷却到快照重置点。未命中则继续默认 429 逻辑。
if account.IsCNProvider() {
if account.IsCNProvider() || account.IsOpenCodeGo() {
if s.applyCNProviderReactive429(ctx, account, headers, responseBody) {
return
}
@@ -115,7 +115,7 @@ func TestSchedulerBulkAccountEventScopesOpenAIRebuildToFreshPlatform(t *testing.
}
func TestSchedulerBulkAccountEventScopesCNRebuildToFreshPlatform(t *testing.T) {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} {
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo} {
t.Run(platform, func(t *testing.T) {
cache := newBulkEventSnapshotCache()
repo := newBulkEventAccountRepo(&Account{ID: 1, Platform: platform, GroupIDs: []int64{12}})
@@ -609,7 +609,7 @@ func (s *SchedulerSnapshotService) handleBulkAccountEvent(ctx context.Context, p
}
accountGroupIDs := s.normalizeGroupIDs(account.GroupIDs)
switch account.Platform {
case PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
case PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo:
addPlatformGroups(account.Platform, accountGroupIDs)
case PlatformAntigravity:
// 批量更新可能刚关闭 mixed_scheduling,仍需清理两个兼容平台的旧快照。
@@ -824,8 +824,8 @@ func (s *SchedulerSnapshotService) rebuildByAccount(ctx context.Context, account
return s.rebuildBuckets(ctx, buckets, reason)
}
func schedulerSnapshotPlatforms() [9]string {
return [9]string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax}
func schedulerSnapshotPlatforms() [10]string {
return [10]string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo}
}
// 生命周期辅助函数有意排除 group0;full rebuild 构造 group0 canonical 集时必须显式调用 canonical helper。
@@ -1013,7 +1013,7 @@ func IsUpstreamBillingProbeIdentity(platform, accountType string) bool {
}
switch platform {
case PlatformOpenAI, PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok,
PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo:
return true
default:
return false
@@ -1051,6 +1051,7 @@ var upstreamBillingProbeOfficialAPIDomains = []string{
"kimi.com",
"bigmodel.cn",
"deepseek.com",
"opencode.ai",
}
func upstreamBillingProbeTargetIsOfficialAPI(baseURL string) bool {
@@ -16,7 +16,7 @@ import (
func TestUpstreamBillingProbeIdentityCoversAllAPIKeyPlatforms(t *testing.T) {
for _, platform := range []string{
PlatformOpenAI, PlatformGrok, PlatformAnthropic, PlatformGemini, PlatformAntigravity,
PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax,
PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo,
} {
require.True(t, IsUpstreamBillingProbeIdentity(platform, AccountTypeAPIKey), platform)
require.True(t, isUpstreamBillingProbeAccount(&Account{Platform: platform, Type: AccountTypeAPIKey}), platform)
@@ -172,6 +172,8 @@ func TestUpstreamBillingProbeOfficialAPIBaseURLIsUnsupportedWithoutRequest(t *te
{PlatformZhipu, "https://open.bigmodel.cn/api/anthropic"},
{PlatformDeepseek, "https://api.deepseek.com"},
{PlatformDeepseek, "https://api.deepseek.com/anthropic"},
{PlatformOpenCodeGo, "https://opencode.ai/zen/go/v1"},
{PlatformOpenCodeGo, "https://opencode.ai/zen/go"},
}
for i, tc := range cases {
account := &Account{
@@ -213,6 +215,8 @@ func TestUpstreamBillingProbeOfficialAPIHostMatchingIsNormalized(t *testing.T) {
require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://api.kimi.com/coding/v1"))
require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://open.bigmodel.cn/api/anthropic"))
require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://api.deepseek.com/anthropic"))
require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://opencode.ai/zen/go/v1"))
require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://opencode.ai/zen/go"))
// 相似但不同的注册域不拦:中转完全可能叫 *-x.ai 之外的任何名字。
require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://relay.example/v1"))
require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://notx.ai"))
+6 -3
View File
@@ -617,7 +617,7 @@ func upstreamModelRegistryBaseURL(account *Account) string {
return ""
}
switch {
case account.IsOpenAI() || account.IsCNProvider():
case account.IsOpenAI() || account.IsCNProvider() || account.IsOpenCodeGo():
return account.GetOpenAIFormatBaseURL()
case account.IsGrok():
return account.GetGrokBaseURL()
@@ -677,6 +677,8 @@ func matchModelsDevProviderByKnownHost(registry map[string]modelsDevProvider, ac
switch host {
case "api.openai.com", "chatgpt.com":
providerID = "openai"
case "opencode.ai":
providerID = "opencode-go"
default:
return modelsDevProvider{}, false
}
@@ -782,8 +784,9 @@ func (s *AccountTestService) buildUpstreamModelsRequest(ctx context.Context, acc
return s.buildAntigravityAPIKeyModelsRequest(ctx, account)
case account.IsGrok():
return s.buildGrokUpstreamModelsRequest(ctx, account)
case account.IsOpenAI() || account.IsCNProvider():
// 国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)复用 OpenAI /v1/models 探测。
case account.IsOpenAI() || account.IsCNProvider() || account.IsOpenCodeGo():
// 国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)与 OpenCode Go
// 复用 OpenAI /v1/models 探测。
return s.buildOpenAIUpstreamModelsRequest(ctx, account)
case account.IsGemini():
return s.buildGeminiUpstreamModelsRequest(ctx, account)