From daaec94ea9b3ba7752e3543ee87a4f6e1dce0dbc Mon Sep 17 00:00:00 2001 From: Marketen Date: Thu, 15 Oct 2026 09:10:32 +0200 Subject: [PATCH] fix: let the provider decide which chat options a model supports The Gateway rejected streaming, tools, parallel tool calls and structured output with 422 unsupported_feature whenever the catalog flag for the model was off, before contacting the provider. Stale flags therefore blocked requests the provider accepts: in the 2026-10-14 production catalog, structured output on minimax/minimax-m2.7 and four private/ models, and parallel tool calls on private/glm-5.3. Chat options now go to the provider unchanged. Endpoint routing, privacy policy, credit checks and the SystemOne no-streaming rule still apply. SupportsStreamForEndpoint is removed; nothing uses it any more. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +++- .../application/services/capabilities_test.go | 24 +++++++++++++++++++ .../application/services/generate_service.go | 18 ++++---------- pkg/domain/domain_test.go | 13 ---------- pkg/domain/model.go | 13 ---------- 5 files changed, 33 insertions(+), 40 deletions(-) create mode 100644 apps/gateway/internal/application/services/capabilities_test.go diff --git a/README.md b/README.md index b1c509d..26b43ff 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,10 @@ print(response.choices[0].message.content) Supported capabilities depend on the selected model and can include streaming, tool calls, parallel tool calls, structured outputs and reasoning. The current capabilities for each model are shown in the -[model catalog](https://nexus.dappnode.com/models). +[model catalog](https://nexus.dappnode.com/models). They describe each model +and are not checked against chat requests: the selected provider validates chat +options, so a stale catalog flag cannot reject a request the provider would +accept. Endpoint routing, privacy policy and credit checks still apply. ## Privacy and usage controls diff --git a/apps/gateway/internal/application/services/capabilities_test.go b/apps/gateway/internal/application/services/capabilities_test.go new file mode 100644 index 0000000..661f7ad --- /dev/null +++ b/apps/gateway/internal/application/services/capabilities_test.go @@ -0,0 +1,24 @@ +package services + +import ( + "testing" + + "github.com/dappnode/dappnode-nexus-gateway/pkg/domain" +) + +func TestStaleCapabilityFlagsDoNotRejectChatOptions(t *testing.T) { + parallel := true + model := domain.PublicModel{PublicModelID: "fixture", SupportsChatCompletions: true} + req := domain.GenerateRequest{Stream: true, Tools: []domain.ToolDefinition{{}}, ParallelToolCalls: ¶llel, StructuredOutput: true} + if err := (&GenerateService{}).validateRequest(domain.EndpointChatCompletions, req, model); err != nil { + t.Fatal(err) + } +} + +func TestSystemOneStillRejectsStreaming(t *testing.T) { + model := domain.PublicModel{PublicModelID: "jev", ProviderConfig: domain.ProviderConfig{ProviderName: "typesafe"}} + err := (&GenerateService{}).validateRequest(domain.EndpointSystemOne, domain.GenerateRequest{Stream: true}, model) + if gwErr, ok := err.(*domain.GatewayError); !ok || gwErr.Code != domain.ErrCodeUnsupportedFeature { + t.Fatalf("SystemOne stream = %v", err) + } +} diff --git a/apps/gateway/internal/application/services/generate_service.go b/apps/gateway/internal/application/services/generate_service.go index 6be0ca9..53dd598 100644 --- a/apps/gateway/internal/application/services/generate_service.go +++ b/apps/gateway/internal/application/services/generate_service.go @@ -187,22 +187,14 @@ func (s *GenerateService) validateRequest(endpoint string, req domain.GenerateRe return domain.ErrUnsupportedEndpoint(model.PublicModelID, endpoint) } - if req.Stream && !model.SupportsStreamForEndpoint(endpoint) { + // Catalog capability flags describe the catalog; they are not a request + // schema. Chat options go to the provider unchanged and the provider + // decides, so a stale flag cannot reject a request the provider accepts. + // SystemOne has no streaming wire protocol in Nexus. + if endpoint == domain.EndpointSystemOne && req.Stream { return domain.ErrUnsupportedFeature("streaming for " + endpoint) } - if len(req.Tools) > 0 && !model.SupportsTools { - return domain.ErrUnsupportedFeature("tools") - } - - if req.ParallelToolCalls != nil && *req.ParallelToolCalls && !model.SupportsParallelToolCalls { - return domain.ErrUnsupportedFeature("parallel_tool_calls") - } - - if req.StructuredOutput && !model.SupportsStructuredOutput { - return domain.ErrUnsupportedFeature("structured_output") - } - if strings.EqualFold(model.ProviderConfig.ProviderName, "doubleword") && req.ServiceTier != nil { return domain.ErrInvalidField("service_tier is fixed by the selected Doubleword model") } diff --git a/pkg/domain/domain_test.go b/pkg/domain/domain_test.go index 1479622..99db7b7 100644 --- a/pkg/domain/domain_test.go +++ b/pkg/domain/domain_test.go @@ -77,16 +77,3 @@ func TestPublicModelSupportsEndpoint(t *testing.T) { t.Error("should not support unknown") } } - -func TestPublicModelSupportsStreamForEndpoint(t *testing.T) { - m := domain.PublicModel{ - SupportsChatCompletionsStream: true, - } - if !m.SupportsStreamForEndpoint(domain.EndpointChatCompletions) { - t.Error("should support chat_completions stream") - } - m.SupportsChatCompletionsStream = false - if m.SupportsStreamForEndpoint(domain.EndpointChatCompletions) { - t.Error("should not support chat_completions stream when disabled") - } -} diff --git a/pkg/domain/model.go b/pkg/domain/model.go index 2b079b1..6600a77 100644 --- a/pkg/domain/model.go +++ b/pkg/domain/model.go @@ -78,16 +78,3 @@ func (m PublicModel) SupportsEndpoint(endpoint string) bool { return false } } - -// SupportsStreamForEndpoint checks if the model supports streaming for the given endpoint. -func (m PublicModel) SupportsStreamForEndpoint(endpoint string) bool { - if m.ProviderConfig.ProviderName == "typesafe" { - return false - } - switch endpoint { - case EndpointChatCompletions: - return m.SupportsChatCompletionsStream - default: - return false - } -}