Merge pull request #7889 from aishangwuji/fix/opencode-zen-routing-and-unsupported-models

fix(opencode): correct Zen qwen3.8-max endpoint and reject unsupported models
This commit is contained in:
Wesley Liddick
2026-10-08 11:32:11 +08:00
committed by GitHub
8 changed files with 220 additions and 1 deletions
@@ -436,6 +436,9 @@ func (s *AccountTestService) testOpenCodeGoAccountConnection(c *gin.Context, acc
testModelID = DefaultOpenCodeGoTestModel
}
testModelID = account.GetMappedModel(testModelID)
if IsOpenCodeUnsupportedModel(testModelID) {
return fmt.Errorf("model %q is not supported on OpenCode standard gateway (gemini models require Google SDK endpoint, jev models require System One endpoint)", testModelID)
}
proto := account.GetAPIProtocol()
switch proto {
case APIProtocolChatCompletions, APIProtocolAnthropic, APIProtocolResponses:
@@ -118,6 +118,9 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions(
// 规则未命中一律兜底 Chat Completions,只有显式 Responses 才走下方转换链。
if account.IsOpenCodeGo() {
mapped := resolveOpenCodeGoMappedModel(account, body, defaultMappedModel)
if IsOpenCodeUnsupportedModel(mapped) {
return nil, writeOpenCodeUnsupportedModelError(c, false, mapped)
}
proto := openCodeGoNativeProtocol(account, mapped)
if proto != APIProtocolResponses {
if isResponsesShape {
@@ -161,6 +161,9 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
if account.IsOpenCodeGo() {
mapped := resolveOpenCodeGoMappedModel(account, body, "")
if IsOpenCodeUnsupportedModel(mapped) {
return nil, writeOpenCodeUnsupportedModelError(c, false, mapped)
}
switch openCodeGoNativeProtocol(account, mapped) {
case APIProtocolAnthropic:
return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, "")
@@ -56,6 +56,9 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
// OpenCode Go:按模型原生协议分流。规则未命中兜底 Chat Completions。
if account.IsOpenCodeGo() {
mapped := resolveOpenCodeGoMappedModel(account, body, defaultMappedModel)
if IsOpenCodeUnsupportedModel(mapped) {
return nil, writeOpenCodeUnsupportedModelError(c, true, mapped)
}
switch openCodeGoNativeProtocol(account, mapped) {
case APIProtocolAnthropic:
return s.forwardAnthropicViaNativeAnthropicEndpoint(ctx, c, account, body, defaultMappedModel)
+37 -1
View File
@@ -6,6 +6,7 @@ import (
"strings"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/gin-gonic/gin"
)
// OpenCode Go 是 OpenCode Zen 的订阅网关:同一 API Key 下按模型分流到
@@ -67,6 +68,39 @@ func normalizeOpenCodeGoModelID(model string) string {
return model
}
// IsOpenCodeUnsupportedModel 判断请求的模型是否属于 OpenCode 专有协议端点模型。
// 官方 Zen 上 gemini-* 仅走 Google SDK 专属端点 /zen/v1/models/<id>,
// jev-* 仅走 SystemOne 专属端点 /zen/v1/systemone,
// 在通用 Chat Completions / Anthropic Messages / Responses 网关端点上不可用。
func IsOpenCodeUnsupportedModel(model string) bool {
normalized := normalizeOpenCodeGoModelID(model)
return strings.HasPrefix(normalized, "gemini-") || strings.HasPrefix(normalized, "jev-")
}
func writeOpenCodeUnsupportedModelError(c *gin.Context, isAnthropic bool, model string) error {
msg := fmt.Sprintf("Model '%s' is not supported on OpenCode standard gateway (gemini models require Google SDK endpoint, jev models require System One endpoint)", model)
if c != nil {
if isAnthropic {
c.JSON(http.StatusBadRequest, gin.H{
"type": "error",
"error": gin.H{
"type": "invalid_request_error",
"message": msg,
},
})
} else {
c.JSON(http.StatusBadRequest, gin.H{
"error": gin.H{
"type": "invalid_request_error",
"code": "model_not_supported",
"message": msg,
},
})
}
}
return fmt.Errorf("opencode unsupported model: %s", model)
}
// OpenCodeGoProtocolRule is one model-pattern → native protocol mapping.
// Pattern is an exact ID or a suffix glob (foo* / *). First match wins.
type OpenCodeGoProtocolRule struct {
@@ -86,13 +120,15 @@ func DefaultOpenCodeGoProtocolRules() []OpenCodeGoProtocolRule {
}
// DefaultOpenCodeZenProtocolRules 对齐 https://opencode.ai/docs/zen/ 端点表:
// GPT/Grok/Muse Spark → Responses,Claude/Qwen → Anthropic,其余 Chat Completions。
// GPT/Grok/Muse Spark → Responses,Claude/Qwen(除 qwen3.8-max) → Anthropic,
// qwen3.8-max 及其余模型 → Chat Completions。
func DefaultOpenCodeZenProtocolRules() []OpenCodeGoProtocolRule {
return []OpenCodeGoProtocolRule{
{Pattern: "grok-*", Protocol: APIProtocolResponses},
{Pattern: "gpt-*", Protocol: APIProtocolResponses},
{Pattern: "muse-spark-*", Protocol: APIProtocolResponses},
{Pattern: "claude-*", Protocol: APIProtocolAnthropic},
{Pattern: "qwen3.8-max", Protocol: APIProtocolChatCompletions},
{Pattern: "qwen*", Protocol: APIProtocolAnthropic},
}
}
@@ -50,8 +50,14 @@ func TestResolveOpenCodeGoUpstreamProtocol(t *testing.T) {
zen := &Account{Platform: PlatformOpenCodeGo, Credentials: map[string]any{"account_mode": AccountModeZen, "api_protocol": APIProtocolAdaptive}}
require.Equal(t, APIProtocolChatCompletions, zen.ResolveOpenCodeGoUpstreamProtocol("minimax-m3"))
require.Equal(t, APIProtocolAnthropic, zen.ResolveOpenCodeGoUpstreamProtocol("claude-opus-4-6"))
require.Equal(t, APIProtocolChatCompletions, zen.ResolveOpenCodeGoUpstreamProtocol("qwen3.8-max"))
require.Equal(t, APIProtocolAnthropic, zen.ResolveOpenCodeGoUpstreamProtocol("qwen3.8-flash"))
require.Equal(t, APIProtocolAnthropic, zen.ResolveOpenCodeGoUpstreamProtocol("qwen3.7-max"))
require.Equal(t, DefaultOpenCodeZenBaseURL, zen.GetOpenAIBaseURL())
goAccount := &Account{Platform: PlatformOpenCodeGo, Credentials: map[string]any{"account_mode": AccountModeGo, "api_protocol": APIProtocolAdaptive}}
require.Equal(t, APIProtocolAnthropic, goAccount.ResolveOpenCodeGoUpstreamProtocol("qwen3.8-max"))
require.Equal(t, "", (&Account{Platform: PlatformKimi}).ResolveOpenCodeGoUpstreamProtocol("glm-5.3"))
}
@@ -235,3 +241,29 @@ func TestDefaultOpenCodeGoModelIDsCoverDocumentedCatalog(t *testing.T) {
require.Contains(t, ids, "glm-5.3")
require.NotEmpty(t, ids)
}
func TestIsOpenCodeUnsupportedModel(t *testing.T) {
t.Parallel()
cases := []struct {
model string
want bool
}{
{"gemini-3.8-flash", true},
{"gemini-3.7-flash", true},
{"opencode/gemini-3.8-flash", true},
{"opencode-go/gemini-3.1-pro", true},
{"jev-1.13", true},
{"jev-1.13-free", true},
{"opencode/jev-1.13", true},
{"qwen3.8-max", false},
{"qwen3.8-flash", false},
{"gpt-5.6-luna", false},
{"grok-4.6", false},
{"minimax-m3", false},
{"glm-5.3", false},
{"", false},
}
for _, tc := range cases {
require.Equal(t, tc.want, IsOpenCodeUnsupportedModel(tc.model), "model=%s", tc.model)
}
}
@@ -0,0 +1,138 @@
package service
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestForwardOpenCodeUnsupportedModels_ChatCompletionsReturns400(t *testing.T) {
gin.SetMode(gin.TestMode)
account := &Account{
ID: 101,
Platform: PlatformOpenCodeGo,
Type: AccountTypeAPIKey,
Credentials: map[string]any{
"api_key": "test-key",
"account_mode": AccountModeZen,
},
}
svc := &OpenAIGatewayService{
cfg: &config.Config{},
}
for _, model := range []string{"gemini-3.8-flash", "jev-1.13", "opencode/gemini-3.7-flash", "opencode-go/jev-1.13-free"} {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
body := []byte(`{"model":"` + model + `","messages":[{"role":"user","content":"hello"}]}`)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
require.Nil(t, result)
require.Error(t, err)
require.Contains(t, err.Error(), "opencode unsupported model")
require.Equal(t, http.StatusBadRequest, rec.Code)
respBody := rec.Body.String()
require.Equal(t, "invalid_request_error", gjson.Get(respBody, "error.type").String())
require.Equal(t, "model_not_supported", gjson.Get(respBody, "error.code").String())
require.Contains(t, gjson.Get(respBody, "error.message").String(), "not supported on OpenCode standard gateway")
}
}
func TestForwardOpenCodeUnsupportedModels_ResponsesReturns400(t *testing.T) {
gin.SetMode(gin.TestMode)
account := &Account{
ID: 102,
Platform: PlatformOpenCodeGo,
Type: AccountTypeAPIKey,
Credentials: map[string]any{
"api_key": "test-key",
"account_mode": AccountModeZen,
},
}
svc := &OpenAIGatewayService{
cfg: &config.Config{},
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
body := []byte(`{"model":"gemini-3.8-flash","input":[{"role":"user","content":"hello"}]}`)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
result, err := svc.Forward(context.Background(), c, account, body)
require.Nil(t, result)
require.Error(t, err)
require.Contains(t, err.Error(), "opencode unsupported model")
require.Equal(t, http.StatusBadRequest, rec.Code)
respBody := rec.Body.String()
require.Equal(t, "invalid_request_error", gjson.Get(respBody, "error.type").String())
require.Equal(t, "model_not_supported", gjson.Get(respBody, "error.code").String())
}
func TestForwardOpenCodeUnsupportedModels_MessagesReturns400(t *testing.T) {
gin.SetMode(gin.TestMode)
account := &Account{
ID: 103,
Platform: PlatformOpenCodeGo,
Type: AccountTypeAPIKey,
Credentials: map[string]any{
"api_key": "test-key",
"account_mode": AccountModeZen,
},
}
svc := &OpenAIGatewayService{
cfg: &config.Config{},
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
body := []byte(`{"model":"gemini-3.8-flash","messages":[{"role":"user","content":"hello"}]}`)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
require.Nil(t, result)
require.Error(t, err)
require.Contains(t, err.Error(), "opencode unsupported model")
require.Equal(t, http.StatusBadRequest, rec.Code)
respBody := rec.Body.String()
require.Equal(t, "invalid_request_error", gjson.Get(respBody, "error.type").String())
require.Contains(t, gjson.Get(respBody, "error.message").String(), "not supported on OpenCode standard gateway")
}
func TestAccountTestService_OpenCodeUnsupportedModelReturnsError(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/test", nil)
account := &Account{
ID: 104,
Platform: PlatformOpenCodeGo,
Type: AccountTypeAPIKey,
}
svc := &AccountTestService{}
err := svc.testOpenCodeGoAccountConnection(c, account, "gemini-3.8-flash", "hi")
require.Error(t, err)
require.Contains(t, err.Error(), "not supported on OpenCode standard gateway")
err = svc.testOpenCodeGoAccountConnection(c, account, "jev-1.13", "hi")
require.Error(t, err)
require.Contains(t, err.Error(), "not supported on OpenCode standard gateway")
}
@@ -304,6 +304,7 @@ export const DEFAULT_OPENCODE_ZEN_PROTOCOL_RULES: OpenCodeGoProtocolRule[] = [
{ pattern: 'gpt-*', protocol: 'responses' },
{ pattern: 'muse-spark-*', protocol: 'responses' },
{ pattern: 'claude-*', protocol: 'anthropic' },
{ pattern: 'qwen3.8-max', protocol: 'chat_completions' },
{ pattern: 'qwen*', protocol: 'anthropic' }
]