[management] Return default rates with each discovered model

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".
This commit is contained in:
mlsmaycon
2026-08-22 14:39:11 +00:00
committed by GitHub
parent 67b1373dfe
commit 09cb67ffd5
5 changed files with 128 additions and 15 deletions

View File

@@ -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

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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:

View File

@@ -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"`
}