diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 4da30d610b..cea4dfea48 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -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: diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index a94d570078..7ffd20916f 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -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 { diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 10c525550a..fffca9af22 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -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, "") diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index efe17c30ae..a45dd5e565 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -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) diff --git a/backend/internal/service/opencode_go.go b/backend/internal/service/opencode_go.go index 1364179b01..68a5437664 100644 --- a/backend/internal/service/opencode_go.go +++ b/backend/internal/service/opencode_go.go @@ -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/, +// 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}, } } diff --git a/backend/internal/service/opencode_go_test.go b/backend/internal/service/opencode_go_test.go index 796487c952..2583d30ac1 100644 --- a/backend/internal/service/opencode_go_test.go +++ b/backend/internal/service/opencode_go_test.go @@ -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) + } +} diff --git a/backend/internal/service/opencode_unsupported_models_test.go b/backend/internal/service/opencode_unsupported_models_test.go new file mode 100644 index 0000000000..56894ab308 --- /dev/null +++ b/backend/internal/service/opencode_unsupported_models_test.go @@ -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") +} diff --git a/frontend/src/components/account/credentialsBuilder.ts b/frontend/src/components/account/credentialsBuilder.ts index c85ef5267a..43c487c927 100644 --- a/frontend/src/components/account/credentialsBuilder.ts +++ b/frontend/src/components/account/credentialsBuilder.ts @@ -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' } ]