From b548f61d58c2b028857eaa4271d34b623b52503a Mon Sep 17 00:00:00 2001 From: Marketen Date: Thu, 15 Oct 2026 16:32:48 +0200 Subject: [PATCH 1/2] feat: publish operator-stated model capabilities in /v1/models /v1/models now includes each model's capabilities, the operator's record from Metering, unchanged: models.dev field names such as modalities (image input), tool_call, structured_output, reasoning and reasoning_options (effort levels). A missing field means not stated, and an empty record is not published. features stays, now derived from capabilities, so existing clients see the same values. Gateway's model no longer carries the five capability flags. With a Metering that does not send capabilities yet, they are rebuilt from the flags it still sends. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +- .../adapters/http/dto/models_response.go | 13 +++-- .../adapters/http/handlers/handlers_test.go | 26 ++++----- .../adapters/http/handlers/models_handler.go | 50 ++++++++++++----- .../http/handlers/models_handler_test.go | 28 ++++++++++ .../http/handlers/stream_usage_repro_test.go | 2 +- .../adapters/metering/runtime_client.go | 55 ++++++++++++------- .../adapters/metering/runtime_client_test.go | 21 +++++++ .../services/generate_service_test.go | 39 ++++++------- .../application/services/proxy_test.go | 6 +- pkg/domain/model.go | 52 +++++++++--------- 11 files changed, 195 insertions(+), 102 deletions(-) create mode 100644 apps/gateway/internal/adapters/http/handlers/models_handler_test.go diff --git a/README.md b/README.md index 0f89ba7..7153955 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). They describe each model +[model catalog](https://nexus.dappnode.com/models). `GET /v1/models` lists +them per model in `capabilities`, using models.dev field names (`modalities`, +`tool_call`, `structured_output`, `reasoning`, `reasoning_options`, …); a +missing field means not stated, and `features` is an older summary. 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. diff --git a/apps/gateway/internal/adapters/http/dto/models_response.go b/apps/gateway/internal/adapters/http/dto/models_response.go index 2251fa2..9ab3b76 100644 --- a/apps/gateway/internal/adapters/http/dto/models_response.go +++ b/apps/gateway/internal/adapters/http/dto/models_response.go @@ -1,5 +1,7 @@ package dto +import "encoding/json" + // ModelsResponse is the response envelope for GET /v1/models. type ModelsResponse struct { Object string `json:"object"` @@ -27,8 +29,11 @@ type ModelData struct { OutputPricePer1MTokensUSD *float64 `json:"output_price_per_1m_tokens_usd,omitempty"` CacheReadPricePer1MTokensUSD *float64 `json:"cache_read_price_per_1m_tokens_usd,omitempty"` CacheWritePricePer1MTokensUSD *float64 `json:"cache_write_price_per_1m_tokens_usd,omitempty"` - Features []string `json:"features"` - Endpoints []string `json:"endpoints"` - ProofMode string `json:"proof_mode"` - ProofsEnabled bool `json:"proofs_enabled"` + // Capabilities is what the operator states the model accepts, using + // models.dev field names. Features is its older summary. + Capabilities json.RawMessage `json:"capabilities,omitempty"` + Features []string `json:"features"` + Endpoints []string `json:"endpoints"` + ProofMode string `json:"proof_mode"` + ProofsEnabled bool `json:"proofs_enabled"` } diff --git a/apps/gateway/internal/adapters/http/handlers/handlers_test.go b/apps/gateway/internal/adapters/http/handlers/handlers_test.go index d1a7650..9b4b7c7 100644 --- a/apps/gateway/internal/adapters/http/handlers/handlers_test.go +++ b/apps/gateway/internal/adapters/http/handlers/handlers_test.go @@ -110,20 +110,18 @@ func TestModelsHandler_ListsModels(t *testing.T) { catalog := &mockModelCatalog{ models: []domain.PublicModel{ { - PublicModelID: "openai/gpt-4.1-mini", - DisplayName: "GPT-4.1 Mini", - Description: &desc, - ProviderConfig: domain.ProviderConfig{ProviderName: "openai"}, - Active: true, - SupportsChatCompletions: true, - SupportsChatCompletionsStream: true, - SupportsTools: true, - SupportsStructuredOutput: true, - MaxContextWindow: 128000, - MaxOutputTokens: 16000, - Currency: "EUR", - InputPricePerMillion: decimal.NewFromFloat(0.4), - OutputPricePerMillion: decimal.NewFromFloat(1.6), + PublicModelID: "openai/gpt-4.1-mini", + DisplayName: "GPT-4.1 Mini", + Description: &desc, + ProviderConfig: domain.ProviderConfig{ProviderName: "openai"}, + Active: true, + SupportsChatCompletions: true, + Capabilities: json.RawMessage(`{"streaming":true,"tool_call":true,"structured_output":true}`), + MaxContextWindow: 128000, + MaxOutputTokens: 16000, + Currency: "EUR", + InputPricePerMillion: decimal.NewFromFloat(0.4), + OutputPricePerMillion: decimal.NewFromFloat(1.6), }, { PublicModelID: "anthropic/claude-sonnet-4", diff --git a/apps/gateway/internal/adapters/http/handlers/models_handler.go b/apps/gateway/internal/adapters/http/handlers/models_handler.go index f3d44aa..883aa9b 100644 --- a/apps/gateway/internal/adapters/http/handlers/models_handler.go +++ b/apps/gateway/internal/adapters/http/handlers/models_handler.go @@ -1,6 +1,7 @@ package handlers import ( + "encoding/json" "net/http" "strings" @@ -72,6 +73,7 @@ func toPublicModelData(m domain.PublicModel, eurToUSDRate float64) dto.ModelData OutputPricePer1MTokensUSD: priceToUSDPtr(m.Currency, m.OutputPricePerMillion, eurToUSDRate), CacheReadPricePer1MTokensUSD: pricePtrToUSDPtr(m.Currency, m.CacheReadPricePerMillion, eurToUSDRate), CacheWritePricePer1MTokensUSD: pricePtrToUSDPtr(m.Currency, m.CacheWritePricePerMillion, eurToUSDRate), + Capabilities: publishedCapabilities(m), Features: modelFeatures(m), Endpoints: modelEndpoints(m), ProofMode: proofMode, @@ -98,21 +100,29 @@ func modelFeatures(m domain.PublicModel) []string { if m.SupportsEndpoint(domain.EndpointSystemOne) { return []string{"structured-outputs"} } - features := make([]string, 0, 5) - if m.SupportsChatCompletionsStream { - features = append(features, "streaming") - } - if m.SupportsTools { - features = append(features, "function-calling") - } - if m.SupportsParallelToolCalls { - features = append(features, "parallel-tool-calls") - } - if m.SupportsStructuredOutput { - features = append(features, "structured-outputs") - } - if m.SupportsReasoning { - features = append(features, "reasoning") + // features is the older summary of capabilities, kept for existing clients. + var stated struct { + Streaming bool `json:"streaming"` + ToolCall bool `json:"tool_call"` + ParallelToolCalls bool `json:"parallel_tool_calls"` + StructuredOutput bool `json:"structured_output"` + Reasoning bool `json:"reasoning"` + } + _ = json.Unmarshal(m.Capabilities, &stated) + features := make([]string, 0, 6) + for _, f := range []struct { + on bool + name string + }{ + {stated.Streaming, "streaming"}, + {stated.ToolCall, "function-calling"}, + {stated.ParallelToolCalls, "parallel-tool-calls"}, + {stated.StructuredOutput, "structured-outputs"}, + {stated.Reasoning, "reasoning"}, + } { + if f.on { + features = append(features, f.name) + } } switch m.EffectiveProofMode() { case domain.ProofModeTinfoilAttestedTransport: @@ -121,6 +131,16 @@ func modelFeatures(m domain.PublicModel) []string { return features } +// publishedCapabilities is the operator's record, unchanged, or nothing when +// the operator stated nothing. +func publishedCapabilities(m domain.PublicModel) json.RawMessage { + var object map[string]json.RawMessage + if json.Unmarshal(m.Capabilities, &object) != nil || len(object) == 0 { + return nil + } + return m.Capabilities +} + func modelEndpoints(m domain.PublicModel) []string { endpoints := make([]string, 0, 1) if m.SupportsEndpoint(domain.EndpointSystemOne) { diff --git a/apps/gateway/internal/adapters/http/handlers/models_handler_test.go b/apps/gateway/internal/adapters/http/handlers/models_handler_test.go new file mode 100644 index 0000000..34ea308 --- /dev/null +++ b/apps/gateway/internal/adapters/http/handlers/models_handler_test.go @@ -0,0 +1,28 @@ +package handlers + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/dappnode/dappnode-nexus-gateway/pkg/domain" +) + +// The operator's record is published unchanged, including fields this Gateway +// does not know yet; features stays as its older summary. +func TestModelsHandler_PublishesCapabilitiesUnchanged(t *testing.T) { + stored := `{"modalities":{"input":["text","image"],"output":["text"]},"tool_call":true,"reasoning":true,` + + `"reasoning_options":[{"type":"effort","values":["low","high","max"],"default":"high"}],"audio_output":false}` + data := toPublicModelData(domain.PublicModel{PublicModelID: "vision", SupportsChatCompletions: true, Capabilities: json.RawMessage(stored)}, 1) + if string(data.Capabilities) != stored { + t.Fatalf("capabilities = %s", data.Capabilities) + } + if strings.Join(data.Features, ",") != "function-calling,reasoning" { + t.Fatalf("features = %v", data.Features) + } + empty := toPublicModelData(domain.PublicModel{PublicModelID: "unknown", SupportsChatCompletions: true, Capabilities: json.RawMessage(`{}`)}, 1) + body, _ := json.Marshal(empty) + if strings.Contains(string(body), `"capabilities"`) { + t.Fatalf("published capabilities nobody stated: %s", body) + } +} diff --git a/apps/gateway/internal/adapters/http/handlers/stream_usage_repro_test.go b/apps/gateway/internal/adapters/http/handlers/stream_usage_repro_test.go index 3ce5df0..a0cfb02 100644 --- a/apps/gateway/internal/adapters/http/handlers/stream_usage_repro_test.go +++ b/apps/gateway/internal/adapters/http/handlers/stream_usage_repro_test.go @@ -107,7 +107,7 @@ func TestStreamingUsageRetention(t *testing.T) { model := domain.PublicModel{ PublicModelID: "local-repro", ProviderModelID: "local-repro", UpstreamModelName: "local-repro", Active: true, - SupportsChatCompletions: true, SupportsChatCompletionsStream: true, SupportsTools: true, + SupportsChatCompletions: true, Capabilities: json.RawMessage(`{"streaming":true,"tool_call":true}`), ProviderConfig: domain.ProviderConfig{ ProviderName: "novita", BaseURL: upstream.URL, APIKeySecretRef: "NEXUS_STREAM_REPRO_PROVIDER_KEY", diff --git a/apps/gateway/internal/adapters/metering/runtime_client.go b/apps/gateway/internal/adapters/metering/runtime_client.go index 9a3124f..3fd359b 100644 --- a/apps/gateway/internal/adapters/metering/runtime_client.go +++ b/apps/gateway/internal/adapters/metering/runtime_client.go @@ -322,29 +322,41 @@ func jsonObject(raw json.RawMessage) bool { return json.Unmarshal(raw, &object) == nil && object != nil } +// capabilities is the operator's record from Metering. A Metering that does not +// send one yet still sends the flags it replaced, so they are used instead. +func (m runtimePublicModel) capabilities() json.RawMessage { + if jsonObject(m.Capabilities) { + return m.Capabilities + } + legacy, _ := json.Marshal(map[string]bool{ + "streaming": *m.SupportsChatCompletionsStream, + "tool_call": *m.SupportsTools, + "parallel_tool_calls": *m.SupportsParallelToolCalls, + "structured_output": *m.SupportsStructuredOutput, + "reasoning": *m.SupportsReasoning, + }) + return legacy +} + func (m runtimePublicModel) toDomain() domain.PublicModel { model := domain.PublicModel{ - ID: m.ID, - PublicModelID: m.PublicModelID, - DisplayName: m.DisplayName, - Description: m.Description, - ProviderModelID: m.ProviderModelID, - UpstreamModelName: m.UpstreamModelName, - ServiceTier: m.ServiceTier, - ProviderConfig: m.ProviderConfig.toDomain(), - SupportsChatCompletions: *m.SupportsChatCompletions, - SupportsChatCompletionsStream: *m.SupportsChatCompletionsStream, - SupportsTools: *m.SupportsTools, - SupportsParallelToolCalls: *m.SupportsParallelToolCalls, - SupportsStructuredOutput: *m.SupportsStructuredOutput, - SupportsReasoning: *m.SupportsReasoning, - ProofMode: m.ProofMode, - MaxContextWindow: m.MaxContextWindow, - MaxOutputTokens: m.MaxOutputTokens, - InputPricePerMillion: priceFromMicrocents(*m.InputPricePer1MTokensMicrocents), - OutputPricePerMillion: priceFromMicrocents(*m.OutputPricePer1MTokensMicrocents), - Currency: m.Currency, - Active: m.Active, + ID: m.ID, + PublicModelID: m.PublicModelID, + DisplayName: m.DisplayName, + Description: m.Description, + ProviderModelID: m.ProviderModelID, + UpstreamModelName: m.UpstreamModelName, + ServiceTier: m.ServiceTier, + ProviderConfig: m.ProviderConfig.toDomain(), + SupportsChatCompletions: *m.SupportsChatCompletions, + Capabilities: m.capabilities(), + ProofMode: m.ProofMode, + MaxContextWindow: m.MaxContextWindow, + MaxOutputTokens: m.MaxOutputTokens, + InputPricePerMillion: priceFromMicrocents(*m.InputPricePer1MTokensMicrocents), + OutputPricePerMillion: priceFromMicrocents(*m.OutputPricePer1MTokensMicrocents), + Currency: m.Currency, + Active: m.Active, } if m.Fallback != nil { model.Fallback = &domain.ProviderTarget{ @@ -429,6 +441,7 @@ type runtimePublicModel struct { SupportsParallelToolCalls *bool `json:"supports_parallel_tool_calls"` SupportsStructuredOutput *bool `json:"supports_structured_output"` SupportsReasoning *bool `json:"supports_reasoning"` + Capabilities json.RawMessage `json:"capabilities,omitempty"` ProofMode string `json:"proof_mode"` MaxContextWindow int `json:"max_context_window"` MaxOutputTokens int `json:"max_output_tokens"` diff --git a/apps/gateway/internal/adapters/metering/runtime_client_test.go b/apps/gateway/internal/adapters/metering/runtime_client_test.go index fe01d0b..2ad1ca5 100644 --- a/apps/gateway/internal/adapters/metering/runtime_client_test.go +++ b/apps/gateway/internal/adapters/metering/runtime_client_test.go @@ -579,3 +579,24 @@ func stringPointer(value string) *string { func int64Pointer(value int64) *int64 { return &value } + +// The operator's record is used as sent; a Metering that does not send one yet +// still sends the flags it replaced. +func TestRuntimePublicModelCapabilities(t *testing.T) { + model := validRuntimePublicModel("openai/gpt-test") + model.Capabilities = json.RawMessage(`{"modalities":{"input":["text","image"]},"tool_call":true}`) + if got := string(model.toDomain().Capabilities); got != `{"modalities":{"input":["text","image"]},"tool_call":true}` { + t.Fatalf("capabilities = %s", got) + } + model.Capabilities = nil + var legacy map[string]bool + if err := json.Unmarshal(model.toDomain().Capabilities, &legacy); err != nil { + t.Fatal(err) + } + want := map[string]bool{"streaming": true, "tool_call": false, "parallel_tool_calls": false, "structured_output": false, "reasoning": false} + for key, value := range want { + if got, ok := legacy[key]; !ok || got != value { + t.Fatalf("capabilities from flags = %v", legacy) + } + } +} diff --git a/apps/gateway/internal/application/services/generate_service_test.go b/apps/gateway/internal/application/services/generate_service_test.go index f98f360..35de009 100644 --- a/apps/gateway/internal/application/services/generate_service_test.go +++ b/apps/gateway/internal/application/services/generate_service_test.go @@ -2,6 +2,7 @@ package services import ( "context" + "encoding/json" "io" "strings" "testing" @@ -309,15 +310,15 @@ func newFallbackGenerateService(meter *stubUsageMeter, primary, fallback *stubPr APIKey: domain.APIKey{ID: "key1", Active: true}, }}, &stubModelCatalog{model: domain.PublicModel{ - PublicModelID: "model", - ProviderModelID: "primary-model", - UpstreamModelName: "primary-upstream", - ProviderConfig: domain.ProviderConfig{ProviderName: "primary"}, - Fallback: &domain.ProviderTarget{ProviderModelID: "fallback-model", UpstreamModelName: "fallback-upstream", ServiceTier: &serviceTier, ProviderConfig: domain.ProviderConfig{ProviderName: "fallback"}}, - SupportsChatCompletions: true, - SupportsChatCompletionsStream: true, - MaxContextWindow: 1000, - MaxOutputTokens: 100, + PublicModelID: "model", + ProviderModelID: "primary-model", + UpstreamModelName: "primary-upstream", + ProviderConfig: domain.ProviderConfig{ProviderName: "primary"}, + Fallback: &domain.ProviderTarget{ProviderModelID: "fallback-model", UpstreamModelName: "fallback-upstream", ServiceTier: &serviceTier, ProviderConfig: domain.ProviderConfig{ProviderName: "fallback"}}, + SupportsChatCompletions: true, + Capabilities: json.RawMessage(`{"streaming":true}`), + MaxContextWindow: 1000, + MaxOutputTokens: 100, }}, nil, &stubProviderRegistry{providers: map[string]ports.Provider{ @@ -456,16 +457,16 @@ func TestProxy_RoutesExplicitRouterToSelectedModel(t *testing.T) { Reason: reason, }} selected := domain.PublicModel{ - PublicModelID: "minimax/minimax-m2.7", - DisplayName: "MiniMax M2.7", - ProviderModelID: "novita-minimax-m2.7", - ProviderConfig: domain.ProviderConfig{ProviderName: "novita"}, - SupportsChatCompletions: true, - SupportsChatCompletionsStream: true, - MaxContextWindow: 1000, - MaxOutputTokens: 100, - InputPricePerMillion: decimal.NewFromFloat(0.03), - OutputPricePerMillion: decimal.NewFromFloat(0.12), + PublicModelID: "minimax/minimax-m2.7", + DisplayName: "MiniMax M2.7", + ProviderModelID: "novita-minimax-m2.7", + ProviderConfig: domain.ProviderConfig{ProviderName: "novita"}, + SupportsChatCompletions: true, + Capabilities: json.RawMessage(`{"streaming":true}`), + MaxContextWindow: 1000, + MaxOutputTokens: 100, + InputPricePerMillion: decimal.NewFromFloat(0.03), + OutputPricePerMillion: decimal.NewFromFloat(0.12), } svc := NewGenerateService( &stubAuthService{authCtx: domain.AuthContext{ diff --git a/apps/gateway/internal/application/services/proxy_test.go b/apps/gateway/internal/application/services/proxy_test.go index 44853a0..3793732 100644 --- a/apps/gateway/internal/application/services/proxy_test.go +++ b/apps/gateway/internal/application/services/proxy_test.go @@ -2,6 +2,7 @@ package services import ( "context" + "encoding/json" "io" "strings" "testing" @@ -33,8 +34,9 @@ func proxyService(meter *resultMeter, pii string, providers map[string]ports.Pro model := domain.PublicModel{ PublicModelID: "zai-org/glm-5.3-flash", ProviderModelID: "pm", UpstreamModelName: "glm-5-3-flash", ProviderConfig: domain.ProviderConfig{ProviderName: "primary"}, - SupportsChatCompletions: true, SupportsChatCompletionsStream: true, SupportsTools: true, SupportsParallelToolCalls: true, - MaxContextWindow: 1000, MaxOutputTokens: 100, + SupportsChatCompletions: true, + Capabilities: json.RawMessage(`{"streaming":true,"tool_call":true,"parallel_tool_calls":true}`), + MaxContextWindow: 1000, MaxOutputTokens: 100, } if fallback { model.Fallback = &domain.ProviderTarget{ProviderModelID: "fm", UpstreamModelName: "glm-fallback", ProviderConfig: domain.ProviderConfig{ProviderName: "fallback"}} diff --git a/pkg/domain/model.go b/pkg/domain/model.go index 6600a77..483e1bf 100644 --- a/pkg/domain/model.go +++ b/pkg/domain/model.go @@ -1,6 +1,10 @@ package domain -import "github.com/shopspring/decimal" +import ( + "encoding/json" + + "github.com/shopspring/decimal" +) const ( ProofModeNone = "none" @@ -27,30 +31,28 @@ type ProviderTarget struct { // PublicModel is the fully-resolved model exposed by the gateway. type PublicModel struct { - ID string - PublicModelID string - DisplayName string - Description *string - ProviderModelID string - UpstreamModelName string - ServiceTier *string - ProviderConfig ProviderConfig - Fallback *ProviderTarget - SupportsChatCompletions bool - SupportsChatCompletionsStream bool - SupportsTools bool - SupportsParallelToolCalls bool - SupportsStructuredOutput bool - SupportsReasoning bool - ProofMode string - MaxContextWindow int - MaxOutputTokens int - InputPricePerMillion decimal.Decimal - OutputPricePerMillion decimal.Decimal - CacheReadPricePerMillion *decimal.Decimal - CacheWritePricePerMillion *decimal.Decimal - Currency string - Active bool + ID string + PublicModelID string + DisplayName string + Description *string + ProviderModelID string + UpstreamModelName string + ServiceTier *string + ProviderConfig ProviderConfig + Fallback *ProviderTarget + SupportsChatCompletions bool // Routing: whether chat is served. + // Capabilities is the operator's description of the model, a JSON object + // published unchanged in /v1/models. Gateway never rejects requests by it. + Capabilities json.RawMessage + ProofMode string + MaxContextWindow int + MaxOutputTokens int + InputPricePerMillion decimal.Decimal + OutputPricePerMillion decimal.Decimal + CacheReadPricePerMillion *decimal.Decimal + CacheWritePricePerMillion *decimal.Decimal + Currency string + Active bool } func (m PublicModel) EffectiveProofMode() string { From 689d47071ea44595f42e463216ed20869b8db3e1 Mon Sep 17 00:00:00 2001 From: Marketen Date: Thu, 15 Oct 2026 16:45:03 +0200 Subject: [PATCH 2/2] feat: capabilities replaces features in /v1/models /v1/models no longer has a features list. What it said is in capabilities (streaming, tool_call, parallel_tool_calls, structured_output, reasoning), kind (router) and proof_mode (Tinfoil attested transport). Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- .../adapters/http/dto/models_response.go | 3 +- .../adapters/http/handlers/handlers_test.go | 43 ++++++++----------- .../adapters/http/handlers/models_handler.go | 37 ---------------- .../http/handlers/models_handler_test.go | 6 +-- 5 files changed, 22 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 7153955..74b54d2 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ capabilities for each model are shown in the [model catalog](https://nexus.dappnode.com/models). `GET /v1/models` lists them per model in `capabilities`, using models.dev field names (`modalities`, `tool_call`, `structured_output`, `reasoning`, `reasoning_options`, …); a -missing field means not stated, and `features` is an older summary. They describe each model +missing field means not stated. 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. diff --git a/apps/gateway/internal/adapters/http/dto/models_response.go b/apps/gateway/internal/adapters/http/dto/models_response.go index 9ab3b76..af2ff09 100644 --- a/apps/gateway/internal/adapters/http/dto/models_response.go +++ b/apps/gateway/internal/adapters/http/dto/models_response.go @@ -30,9 +30,8 @@ type ModelData struct { CacheReadPricePer1MTokensUSD *float64 `json:"cache_read_price_per_1m_tokens_usd,omitempty"` CacheWritePricePer1MTokensUSD *float64 `json:"cache_write_price_per_1m_tokens_usd,omitempty"` // Capabilities is what the operator states the model accepts, using - // models.dev field names. Features is its older summary. + // models.dev field names. A missing field means not stated. Capabilities json.RawMessage `json:"capabilities,omitempty"` - Features []string `json:"features"` Endpoints []string `json:"endpoints"` ProofMode string `json:"proof_mode"` ProofsEnabled bool `json:"proofs_enabled"` diff --git a/apps/gateway/internal/adapters/http/handlers/handlers_test.go b/apps/gateway/internal/adapters/http/handlers/handlers_test.go index 9b4b7c7..642877f 100644 --- a/apps/gateway/internal/adapters/http/handlers/handlers_test.go +++ b/apps/gateway/internal/adapters/http/handlers/handlers_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "reflect" "strings" "testing" @@ -152,20 +153,20 @@ func TestModelsHandler_ListsModels(t *testing.T) { var body struct { Object string `json:"object"` Data []struct { - ID string `json:"id"` - Object string `json:"object"` - OwnedBy string `json:"owned_by"` - DisplayName string `json:"display_name"` - Description *string `json:"description,omitempty"` - ContextSize int `json:"context_size"` - MaxOutputTokens int `json:"max_output_tokens"` - Currency string `json:"currency"` - InputPricePer1MTokensCents int64 `json:"input_price_per_1m_tokens_cents"` - OutputPricePer1MTokensCents int64 `json:"output_price_per_1m_tokens_cents"` - InputPricePer1MTokensUSD float64 `json:"input_price_per_1m_tokens_usd"` - OutputPricePer1MTokensUSD float64 `json:"output_price_per_1m_tokens_usd"` - Features []string `json:"features"` - Endpoints []string `json:"endpoints"` + ID string `json:"id"` + Object string `json:"object"` + OwnedBy string `json:"owned_by"` + DisplayName string `json:"display_name"` + Description *string `json:"description,omitempty"` + ContextSize int `json:"context_size"` + MaxOutputTokens int `json:"max_output_tokens"` + Currency string `json:"currency"` + InputPricePer1MTokensCents int64 `json:"input_price_per_1m_tokens_cents"` + OutputPricePer1MTokensCents int64 `json:"output_price_per_1m_tokens_cents"` + InputPricePer1MTokensUSD float64 `json:"input_price_per_1m_tokens_usd"` + OutputPricePer1MTokensUSD float64 `json:"output_price_per_1m_tokens_usd"` + Capabilities map[string]bool `json:"capabilities"` + Endpoints []string `json:"endpoints"` } `json:"data"` } json.NewDecoder(resp.Body).Decode(&body) @@ -210,18 +211,8 @@ func TestModelsHandler_ListsModels(t *testing.T) { if first.OutputPricePer1MTokensUSD != 1.728 { t.Errorf("data[0].output_price_usd = %v, want 1.728", first.OutputPricePer1MTokensUSD) } - wantFeatures := map[string]bool{ - "streaming": true, - "function-calling": true, - "structured-outputs": true, - } - if len(first.Features) != len(wantFeatures) { - t.Errorf("data[0].features = %v, want %v", first.Features, wantFeatures) - } - for _, f := range first.Features { - if !wantFeatures[f] { - t.Errorf("unexpected feature %q in data[0].features", f) - } + if want := map[string]bool{"streaming": true, "tool_call": true, "structured_output": true}; !reflect.DeepEqual(first.Capabilities, want) { + t.Errorf("data[0].capabilities = %v, want %v", first.Capabilities, want) } if len(first.Endpoints) != 1 || first.Endpoints[0] != "chat/completions" { t.Errorf("data[0].endpoints = %v, want [chat/completions]", first.Endpoints) diff --git a/apps/gateway/internal/adapters/http/handlers/models_handler.go b/apps/gateway/internal/adapters/http/handlers/models_handler.go index 883aa9b..5b3a533 100644 --- a/apps/gateway/internal/adapters/http/handlers/models_handler.go +++ b/apps/gateway/internal/adapters/http/handlers/models_handler.go @@ -74,7 +74,6 @@ func toPublicModelData(m domain.PublicModel, eurToUSDRate float64) dto.ModelData CacheReadPricePer1MTokensUSD: pricePtrToUSDPtr(m.Currency, m.CacheReadPricePerMillion, eurToUSDRate), CacheWritePricePer1MTokensUSD: pricePtrToUSDPtr(m.Currency, m.CacheWritePricePerMillion, eurToUSDRate), Capabilities: publishedCapabilities(m), - Features: modelFeatures(m), Endpoints: modelEndpoints(m), ProofMode: proofMode, ProofsEnabled: domain.ProofModeEnabled(proofMode), @@ -90,47 +89,11 @@ func toRouterData(r domain.RouterEntry) dto.ModelData { Created: r.CreatedAt.Unix(), DisplayName: r.DisplayName, Description: r.Description, - Features: []string{"routing"}, Endpoints: []string{"chat/completions"}, ProofMode: domain.ProofModeNone, } } -func modelFeatures(m domain.PublicModel) []string { - if m.SupportsEndpoint(domain.EndpointSystemOne) { - return []string{"structured-outputs"} - } - // features is the older summary of capabilities, kept for existing clients. - var stated struct { - Streaming bool `json:"streaming"` - ToolCall bool `json:"tool_call"` - ParallelToolCalls bool `json:"parallel_tool_calls"` - StructuredOutput bool `json:"structured_output"` - Reasoning bool `json:"reasoning"` - } - _ = json.Unmarshal(m.Capabilities, &stated) - features := make([]string, 0, 6) - for _, f := range []struct { - on bool - name string - }{ - {stated.Streaming, "streaming"}, - {stated.ToolCall, "function-calling"}, - {stated.ParallelToolCalls, "parallel-tool-calls"}, - {stated.StructuredOutput, "structured-outputs"}, - {stated.Reasoning, "reasoning"}, - } { - if f.on { - features = append(features, f.name) - } - } - switch m.EffectiveProofMode() { - case domain.ProofModeTinfoilAttestedTransport: - features = append(features, "tinfoil-attested-transport") - } - return features -} - // publishedCapabilities is the operator's record, unchanged, or nothing when // the operator stated nothing. func publishedCapabilities(m domain.PublicModel) json.RawMessage { diff --git a/apps/gateway/internal/adapters/http/handlers/models_handler_test.go b/apps/gateway/internal/adapters/http/handlers/models_handler_test.go index 34ea308..b0ceae7 100644 --- a/apps/gateway/internal/adapters/http/handlers/models_handler_test.go +++ b/apps/gateway/internal/adapters/http/handlers/models_handler_test.go @@ -9,7 +9,7 @@ import ( ) // The operator's record is published unchanged, including fields this Gateway -// does not know yet; features stays as its older summary. +// does not know yet. There is no separate features list any more. func TestModelsHandler_PublishesCapabilitiesUnchanged(t *testing.T) { stored := `{"modalities":{"input":["text","image"],"output":["text"]},"tool_call":true,"reasoning":true,` + `"reasoning_options":[{"type":"effort","values":["low","high","max"],"default":"high"}],"audio_output":false}` @@ -17,8 +17,8 @@ func TestModelsHandler_PublishesCapabilitiesUnchanged(t *testing.T) { if string(data.Capabilities) != stored { t.Fatalf("capabilities = %s", data.Capabilities) } - if strings.Join(data.Features, ",") != "function-calling,reasoning" { - t.Fatalf("features = %v", data.Features) + if body, _ := json.Marshal(data); strings.Contains(string(body), `"features"`) { + t.Fatalf("published the removed features list: %s", body) } empty := toPublicModelData(domain.PublicModel{PublicModelID: "unknown", SupportsChatCompletions: true, Capabilities: json.RawMessage(`{}`)}, 1) body, _ := json.Marshal(empty)