From 09cb67ffd52e5a1ace62fe523fe47a01f93d5bbf Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Sat, 22 Aug 2026 14:39:11 +0000 Subject: [PATCH] [management] Return default rates with each discovered model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The endpoint reported pricing_known and then made the operator find the price themselves. The dashboard has nothing to prefill a model row with, so a discovered model either arrived at zero — silently metering every request against it as free — or had to be priced by hand against a table NetBird already ships. Each model now carries the rates it would actually be billed at. Rates come from the live default pricing table rather than the compiled-in catalog, because that is the table the synthesiser ships to the proxy: an operator running a defaults_llm_pricing.yaml would otherwise be shown one price in the form and charged another. It is the same lookup the catalog endpoint prefills from, so a model reached by either route prices identically — pinned by TestDiscoveredRatesMatchTheCatalogEndpoint, since the two are separate call paths that would otherwise drift. pricing_known now derives from that same lookup instead of a second pass over the compiled catalog, so "we can price this" and "here is the price" can no longer disagree. input_per_1k and output_per_1k are required and sent even at zero: an unpriced model is offered at zero and flagged rather than withheld — the vendor says the credential can reach it, and hiding it would hide a model the operator genuinely has. The cache rates stay absent when unset, matching the catalog response, because a zero there reads as "free" rather than "not applicable". --- .../handlers/providers_handler.go | 15 ++++++- .../agentnetwork/modeldiscovery/discovery.go | 45 ++++++++++++++----- .../modeldiscovery/discovery_test.go | 37 +++++++++++++++ shared/management/http/api/openapi.yml | 29 +++++++++++- shared/management/http/api/types.gen.go | 17 ++++++- 5 files changed, 128 insertions(+), 15 deletions(-) diff --git a/management/internals/modules/agentnetwork/handlers/providers_handler.go b/management/internals/modules/agentnetwork/handlers/providers_handler.go index d76885f2a..645d1da61 100644 --- a/management/internals/modules/agentnetwork/handlers/providers_handler.go +++ b/management/internals/modules/agentnetwork/handlers/providers_handler.go @@ -125,7 +125,20 @@ func (h *handler) discoverProviderModels(w http.ResponseWriter, r *http.Request) out := api.AgentNetworkModelDiscoveryResponse{Models: make([]api.AgentNetworkDiscoveredModel, 0, len(models))} for _, m := range models { - entry := api.AgentNetworkDiscoveredModel{Id: m.ID, PricingKnown: m.PricingKnown} + entry := api.AgentNetworkDiscoveredModel{ + Id: m.ID, + PricingKnown: m.PricingKnown, + // Sent even when zero: the form prefills every discovered model as + // an editable row, and an unpriced one is shown at zero and flagged + // rather than left out. + InputPer1k: m.InputPer1k, + OutputPer1k: m.OutputPer1k, + // Cache rates stay absent when unset, matching the catalog + // response — a zero would read as "free", not "not applicable". + CachedInputPer1k: positiveRatePtr(m.CachedInputPer1k), + CacheReadPer1k: positiveRatePtr(m.CacheReadPer1k), + CacheCreationPer1k: positiveRatePtr(m.CacheCreationPer1k), + } if m.Label != "" { label := m.Label entry.Label = &label diff --git a/management/internals/modules/agentnetwork/modeldiscovery/discovery.go b/management/internals/modules/agentnetwork/modeldiscovery/discovery.go index 129c351ae..37401820c 100644 --- a/management/internals/modules/agentnetwork/modeldiscovery/discovery.go +++ b/management/internals/modules/agentnetwork/modeldiscovery/discovery.go @@ -31,6 +31,7 @@ import ( "golang.org/x/oauth2/google" "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing" ) const ( @@ -75,6 +76,17 @@ type Model struct { // model. False means the operator must set rates, or the request would // meter at zero. PricingKnown bool + // The rates below are the defaults for this model, taken from the same + // table the proxy bills with, so the form prefills exactly what a request + // would cost. All zero when PricingKnown is false — an unpriced model is + // offered at zero and flagged, rather than withheld: the vendor says the + // credential can reach it, and refusing to show it would hide a model the + // operator genuinely has. + InputPer1k float64 + OutputPer1k float64 + CachedInputPer1k float64 + CacheReadPer1k float64 + CacheCreationPer1k float64 } // Request identifies which vendor to ask and with what credential. @@ -329,14 +341,15 @@ func mintGCPToken(ctx context.Context, saKeyB64 string) (string, error) { return tok.AccessToken, nil } -// decorate turns raw vendor ids into the models the caller renders, marking -// each with whether the shipped pricing table can price it. +// decorate turns raw vendor ids into the models the caller renders, attaching +// the rates the request would actually be billed at. +// +// Rates come from the live default pricing table rather than the compiled-in +// catalog, because that is the table the synthesiser ships to the proxy: an +// operator running a defaults_llm_pricing.yaml would otherwise be shown one +// price in the form and charged another. It is also the same lookup the catalog +// endpoint prefills from, so a model reached by either route prices identically. func decorate(entry catalog.Provider, ids []listedModel) []Model { - priced := make(map[string]struct{}, len(entry.Models)) - for _, m := range entry.Models { - priced[m.ID] = struct{}{} - } - out := make([]Model, 0, len(ids)) seen := make(map[string]struct{}, len(ids)) for _, listed := range ids { @@ -348,11 +361,19 @@ func decorate(entry catalog.Provider, ids []listedModel) []Model { } seen[listed.id] = struct{}{} - // The catalog keys pricing by the normalised id while the vendor - // issues the wire form, so normalise before asking whether we can - // price it — otherwise every Bedrock profile would report unpriced. - _, known := priced[normalizeForPricing(entry.ID, listed.id)] - out = append(out, Model{ID: listed.id, Label: listed.label, PricingKnown: known}) + // The table keys pricing by the normalised id while the vendor issues + // the wire form, so normalise before looking it up — otherwise every + // Bedrock profile would report unpriced. + model := Model{ID: listed.id, Label: listed.label} + if rate, known := pricing.LookupDefault(entry.PricingSurfaces, normalizeForPricing(entry.ID, listed.id)); known { + model.PricingKnown = true + model.InputPer1k = rate.InputPer1k + model.OutputPer1k = rate.OutputPer1k + model.CachedInputPer1k = rate.CachedInputPer1k + model.CacheReadPer1k = rate.CacheReadPer1k + model.CacheCreationPer1k = rate.CacheCreationPer1k + } + out = append(out, model) } return out } diff --git a/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go b/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go index 80167849f..fba2c97d1 100644 --- a/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go +++ b/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/require" "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing" ) // stubTransport answers every request with one canned response and records the @@ -146,6 +147,42 @@ func TestFetchBedrockUsesTheControlPlaneAndKeepsWireIDs(t *testing.T) { "the catalog prices anthropic.claude-haiku-4-5, which this id normalises to") assert.False(t, models[1].PricingKnown, "cohere embed is not in the shipped Bedrock catalog, so the operator must price it") + + // The rates travel with the model, so the form can prefill an editable row + // rather than making the operator look every price up by hand. + assert.Positive(t, models[0].InputPer1k, "a priced model must carry its input rate") + assert.Positive(t, models[0].OutputPer1k, "a priced model must carry its output rate") + // An unpriced model is offered at zero and flagged, not withheld: the + // vendor says the credential can reach it. + assert.Zero(t, models[1].InputPer1k) + assert.Zero(t, models[1].OutputPer1k) +} + +// TestDiscoveredRatesMatchTheCatalogEndpoint pins the two prefill paths to one +// table. The provider form fills a model row either from the catalog response +// or from a discovery response, and an operator who switches between them must +// not see the price change — both must equal what the proxy will bill. +func TestDiscoveredRatesMatchTheCatalogEndpoint(t *testing.T) { + cl, _ := newStubClient(http.StatusOK, openAIListing) + + models, err := cl.Fetch(context.Background(), Request{ + CatalogID: "openai_api", + UpstreamURL: "https://api.openai.com", + APIKey: "sk-test", + }) + require.NoError(t, err) + require.NotEmpty(t, models) + + entry, ok := catalog.Lookup("openai_api") + require.True(t, ok) + + for _, m := range models { + want, known := pricing.LookupDefault(entry.PricingSurfaces, m.ID) + require.True(t, known, "%s should be priced by the default table", m.ID) + assert.Equal(t, want.InputPer1k, m.InputPer1k, "input rate for %s", m.ID) + assert.Equal(t, want.OutputPer1k, m.OutputPer1k, "output rate for %s", m.ID) + assert.Equal(t, want.CachedInputPer1k, m.CachedInputPer1k, "cached-input rate for %s", m.ID) + } } func TestFetchVertexJoinsNameAndVersion(t *testing.T) { diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index a4c6b6b03..3ab5a2e42 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -5381,11 +5381,38 @@ components: example: "EU Anthropic Claude Haiku 4.5" pricing_known: type: boolean - description: Whether NetBird's shipped pricing table can price this model. When false the operator must set input/output rates, or requests to it would record a cost of zero. + description: Whether NetBird's shipped pricing table can price this model. When false the rates below are all zero and the operator must set them, or requests to this model would record a cost of zero. example: true + input_per_1k: + type: number + format: double + description: Default input token price per 1k tokens, in USD, from the same table the proxy bills with. Zero when pricing_known is false. + example: 0.005 + output_per_1k: + type: number + format: double + description: Default output token price per 1k tokens, in USD. Zero when pricing_known is false. + example: 0.015 + cached_input_per_1k: + type: number + format: double + description: OpenAI-shape cache rate — default cost per 1k cached prompt tokens (a subset of input tokens), in USD. Absent when the model has no cached-input discount. + example: 0.000075 + cache_read_per_1k: + type: number + format: double + description: Anthropic-shape cache rate — default cost per 1k cache-read tokens (additive to input tokens), in USD. Absent when the model has no cache-read rate. + example: 0.0003 + cache_creation_per_1k: + type: number + format: double + description: Anthropic-shape cache rate — default cost per 1k cache-creation tokens (additive to input tokens), in USD. Absent when the model has no cache-creation rate. + example: 0.00375 required: - id - pricing_known + - input_per_1k + - output_per_1k AgentNetworkCatalogProvider: type: object properties: diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 3aad96eb0..db5b2e18e 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -2122,13 +2122,28 @@ type AgentNetworkConsumptionDimensionKind string // AgentNetworkDiscoveredModel defines model for AgentNetworkDiscoveredModel. type AgentNetworkDiscoveredModel struct { + // CacheCreationPer1k Anthropic-shape cache rate — default cost per 1k cache-creation tokens (additive to input tokens), in USD. Absent when the model has no cache-creation rate. + CacheCreationPer1k *float64 `json:"cache_creation_per_1k,omitempty"` + + // CacheReadPer1k Anthropic-shape cache rate — default cost per 1k cache-read tokens (additive to input tokens), in USD. Absent when the model has no cache-read rate. + CacheReadPer1k *float64 `json:"cache_read_per_1k,omitempty"` + + // CachedInputPer1k OpenAI-shape cache rate — default cost per 1k cached prompt tokens (a subset of input tokens), in USD. Absent when the model has no cached-input discount. + CachedInputPer1k *float64 `json:"cached_input_per_1k,omitempty"` + // Id Identifier to register on the provider record, in the form the vendor issues it. For Bedrock this is the region-prefixed inference-profile id, which is the only form AWS accepts at invoke time. Id string `json:"id"` + // InputPer1k Default input token price per 1k tokens, in USD, from the same table the proxy bills with. Zero when pricing_known is false. + InputPer1k float64 `json:"input_per_1k"` + // Label Vendor-supplied display name, where the vendor supplies one. Label *string `json:"label,omitempty"` - // PricingKnown Whether NetBird's shipped pricing table can price this model. When false the operator must set input/output rates, or requests to it would record a cost of zero. + // OutputPer1k Default output token price per 1k tokens, in USD. Zero when pricing_known is false. + OutputPer1k float64 `json:"output_per_1k"` + + // PricingKnown Whether NetBird's shipped pricing table can price this model. When false the rates below are all zero and the operator must set them, or requests to this model would record a cost of zero. PricingKnown bool `json:"pricing_known"` }