mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
fix(opencode): correct Zen qwen3.8-max endpoint and reject unsupported models
- Route qwen3.8-max to Chat Completions on OpenCode Zen mode instead of Messages - Fast-reject unsupported model families (gemini-*, jev-*) with HTTP 400 on OpenCode gateway to avoid upstream 404 failovers and strike accumulation - Sync frontend DEFAULT_OPENCODE_ZEN_PROTOCOL_RULES with backend
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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' }
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user