From 5e88d3f87afd6debab57e149bae754a3c46dcb75 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Sun, 23 Aug 2026 20:21:25 +0200 Subject: [PATCH] [management] Offer a provider's live model list in the config form (#7246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [management] Offer a provider's live model list in the config form Adds POST /api/agent-network/catalog/providers/models, which asks a vendor which models an operator's own credential can actually reach, so the provider form can offer a live list instead of only the compiled-in catalog. The catalog goes stale, and it cannot see an account: which OpenAI models an org is entitled to, which Bedrock inference profiles an account and region hold, which Vertex models a project has enabled. The endpoints, auth headers and response shapes come from probing the live APIs (#7244); each vendor invented its own envelope and none can be guessed from the request. Bedrock shaped the design: its listing lives on the control plane while inference must go to the runtime host, so Discovery carries its own host rather than reusing the record's upstream, and profile ids are taken verbatim because the region prefix is what AWS requires at invoke time. A caller supplies either the key they are typing or the id of a saved record whose stored credential is reused — never both, since accepting both would run an arbitrary credential under the identity of a record the caller may only be permitted to read. Gated on Create rather than Read, because this spends the operator's credential against a third party. Management has not made outbound calls on an operator's behalf before and it holds a credential for every provider, so every resolved address must be public — covering loopback, RFC1918, the cloud metadata address and NetBird's own 100.64/10 range — and redirects are not followed, since a redirect moves the request to a host the check never saw. The vendor is authoritative for the id; the catalog stays authoritative for pricing. A discovered model the shipped table cannot price returns pricing_known: false so the operator must set rates rather than being registered at a silent zero. --- .../modules/agentnetwork/catalog/catalog.go | 100 +++- .../handlers/model_discovery_handler_test.go | 178 +++++++ .../handlers/providers_handler.go | 95 ++++ .../internals/modules/agentnetwork/manager.go | 48 ++ .../agentnetwork/modeldiscovery/discovery.go | 469 +++++++++++++++++ .../modeldiscovery/discovery_test.go | 496 ++++++++++++++++++ .../agentnetwork/modeldiscovery/parse.go | 134 +++++ shared/management/http/api/openapi.yml | 114 ++++ shared/management/http/api/types.gen.go | 51 ++ 9 files changed, 1681 insertions(+), 4 deletions(-) create mode 100644 management/internals/modules/agentnetwork/handlers/model_discovery_handler_test.go create mode 100644 management/internals/modules/agentnetwork/modeldiscovery/discovery.go create mode 100644 management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go create mode 100644 management/internals/modules/agentnetwork/modeldiscovery/parse.go diff --git a/management/internals/modules/agentnetwork/catalog/catalog.go b/management/internals/modules/agentnetwork/catalog/catalog.go index c534f9a85..3c7b995e5 100644 --- a/management/internals/modules/agentnetwork/catalog/catalog.go +++ b/management/internals/modules/agentnetwork/catalog/catalog.go @@ -113,8 +113,61 @@ type Provider struct { // upstream provider + credentials on Portkey's hosted side). ExtraHeaders []ExtraHeader Models []Model + // Discovery, when non-nil, describes how to ask this vendor which + // models the operator's own credential can actually reach, so the + // provider form can offer a live list instead of only the hand-curated + // Models above. Nil for entries with no listing endpoint (gateways + // vary too much) — those keep free-text entry. + Discovery *Discovery } +// ListingShape names the response envelope a vendor returns its model +// listing in. Every vendor invented its own, and none of them can be +// guessed from the request, so the catalog states it. +type ListingShape string + +const ( + // ShapeOpenAIData is {"data":[{"id":…}]} — OpenAI, and Anthropic, which + // adopted the same envelope. + ShapeOpenAIData ListingShape = "openai_data" + // ShapeBedrockInferenceProfiles is + // {"inferenceProfileSummaries":[{"inferenceProfileId":…}]}. The ids carry + // the region prefix that makes them invocable, which is exactly what an + // operator cannot reconstruct by hand. + ShapeBedrockInferenceProfiles ListingShape = "bedrock_inference_profiles" + // ShapeVertexPublisherModels is {"publisherModels":[{"name":…}]}, where + // name is a resource path and the invocable id is its last segment joined + // to a separate versionId field. + ShapeVertexPublisherModels ListingShape = "vertex_publisher_models" +) + +// Discovery describes one vendor's model-listing endpoint. +// +// Host is deliberately separate from the provider record's upstream URL: +// Bedrock serves listings from the control plane (bedrock.) while +// inference must go to the runtime host (bedrock-runtime.), so the +// two cannot be the same value. Empty Host means "use the record's own +// upstream", which is right for every vendor that serves both from one host. +// +// The regionPlaceholder in Host is substituted from the provider record's +// region. Deriving the discovery host from the catalog rather than accepting +// one from the caller is also what keeps this from being an open proxy: the +// only hosts management will dial are the ones written here. +type Discovery struct { + Host string + Path string + Query string + Shape ListingShape + // Headers are static headers the vendor requires beyond the credential + // (Anthropic versions its API through one and rejects a request without + // it). The auth header itself comes from AuthHeaderName/Template. + Headers map[string]string +} + +// RegionPlaceholder is replaced in Discovery.Host by the provider record's +// configured region. +const RegionPlaceholder = "" + // ExtraHeader names a single optional per-provider routing/config // header. Catalog declares N of these per provider type; the operator // fills any subset on the provider record (see Provider.ExtraValues). @@ -245,8 +298,12 @@ var providers = []Provider{ AuthHeaderTemplate: "Bearer ${API_KEY}", DefaultContentType: "application/json", BrandColor: "#10A37F", - ParserID: "openai", - PricingSurfaces: []string{"openai"}, + Discovery: &Discovery{ + Path: "/v1/models", + Shape: ShapeOpenAIData, + }, + ParserID: "openai", + PricingSurfaces: []string{"openai"}, // Pricing + context windows cross-checked against LiteLLM's // model_prices_and_context_window.json. Notable corrections from // earlier values: o4-mini repriced from $4/$16 to $1.10/$4.40 @@ -284,8 +341,18 @@ var providers = []Provider{ AuthHeaderTemplate: "${API_KEY}", DefaultContentType: "application/json", BrandColor: "#D97757", - ParserID: "anthropic", - PricingSurfaces: []string{"anthropic"}, + Discovery: &Discovery{ + Path: "/v1/models", + // The default page is short and a picker wants the whole + // catalogue in one call. + Query: "limit=1000", + Shape: ShapeOpenAIData, + // Anthropic versions its API through a header and refuses a + // request that omits it, listing included. + Headers: map[string]string{"anthropic-version": "2023-06-01"}, + }, + ParserID: "anthropic", + PricingSurfaces: []string{"anthropic"}, // Per Anthropic's current model lineup. Pricing in USD per 1k // tokens. Context windows: 4.6+ family is 1M; Haiku 4.5 stays at // 200K. claude-3-7-sonnet and claude-3-5-haiku retired @@ -345,6 +412,22 @@ var providers = []Provider{ AuthHeaderTemplate: "Bearer ${API_KEY}", DefaultContentType: "application/json", BrandColor: "#FF9900", + // Listings come from the CONTROL PLANE, not the runtime host in + // DefaultHost above: ListInferenceProfiles is not an operation + // bedrock-runtime implements, and answers + // there. Inference has to go to the runtime host, so the two hosts + // genuinely differ and Discovery.Host carries the difference. + // + // Inference profiles rather than foundation models because the profile + // id is the invocable one: it carries the region prefix (eu., us., + // global.) that AWS requires and that cannot be derived from the + // configured region — an eu-central-1 account legitimately holds + // global.* profiles. + Discovery: &Discovery{ + Host: "bedrock." + RegionPlaceholder + ".amazonaws.com", + Path: "/inference-profiles", + Shape: ShapeBedrockInferenceProfiles, + }, // ParserID stays empty (path-style dispatch via IsBedrockPathStyle); // the request parser meters these under the "bedrock" surface. PricingSurfaces: []string{"bedrock"}, @@ -395,6 +478,15 @@ var providers = []Provider{ AuthHeaderTemplate: "Bearer ${API_KEY}", DefaultContentType: "application/json", BrandColor: "#4285F4", + // Only the v1beta1 publisher listing answers: the v1 form and the + // project-scoped form under BOTH versions return 404. That means the + // list is publisher-global — it cannot say which models this project + // has enabled — so it is offered as a suggestion beside the catalog + // rather than replacing it. See the discovery e2e for the probes. + Discovery: &Discovery{ + Path: "/v1beta1/publishers/anthropic/models", + Shape: ShapeVertexPublisherModels, + }, // ParserID stays empty (path-style dispatch via IsVertexPathStyle); // Anthropic-on-Vertex requests are metered under the "anthropic" // surface with the bare, unversioned model id. diff --git a/management/internals/modules/agentnetwork/handlers/model_discovery_handler_test.go b/management/internals/modules/agentnetwork/handlers/model_discovery_handler_test.go new file mode 100644 index 000000000..389c2ae50 --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/model_discovery_handler_test.go @@ -0,0 +1,178 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/modeldiscovery" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/shared/auth" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// discoveryManagerStub records what the handler asked for and returns a canned +// answer. The Manager interface is embedded rather than implemented: only the +// one method is reachable from this handler, and a call to any other should +// fail loudly rather than silently return a zero value. +type discoveryManagerStub struct { + agentnetwork.Manager + + gotReq modeldiscovery.Request + gotRecordID string + models []modeldiscovery.Model + err error +} + +func (s *discoveryManagerStub) DiscoverProviderModels( + _ context.Context, _, _ string, req modeldiscovery.Request, recordID string, +) ([]modeldiscovery.Model, error) { + s.gotReq = req + s.gotRecordID = recordID + return s.models, s.err +} + +// postDiscovery drives the handler with an authenticated request. +func postDiscovery(t *testing.T, stub *discoveryManagerStub, body string) *httptest.ResponseRecorder { + t.Helper() + h := &handler{manager: stub} + + req := httptest.NewRequest(http.MethodPost, "/agent-network/catalog/providers/models", strings.NewReader(body)) + req = req.WithContext(nbcontext.SetUserAuthInContext(req.Context(), auth.UserAuth{ + AccountId: "acc-1", + UserId: "user-1", + })) + + rec := httptest.NewRecorder() + h.discoverProviderModels(rec, req) + return rec +} + +func TestDiscoverModelsReturnsTheVendorList(t *testing.T) { + stub := &discoveryManagerStub{models: []modeldiscovery.Model{ + {ID: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", Label: "EU Claude Haiku 4.5", PricingKnown: true}, + {ID: "global.cohere.embed-v4:0", Label: "Global Cohere Embed v4"}, + // A vendor that supplies no display name at all. Bedrock does for + // every profile, but the OpenAI listing carries none. + {ID: "gpt-4o-mini", PricingKnown: true}, + }} + + rec := postDiscovery(t, stub, `{ + "catalog_provider_id":"bedrock_api", + "upstream_url":"https://bedrock-runtime.eu-central-1.amazonaws.com", + "api_key":"aws-bearer" + }`) + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + var out api.AgentNetworkModelDiscoveryResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out)) + require.Len(t, out.Models, 3) + + assert.Equal(t, "eu.anthropic.claude-haiku-4-5-20251001-v1:0", out.Models[0].Id) + assert.True(t, out.Models[0].PricingKnown) + // An unpriced model must say so rather than arriving indistinguishable + // from a priced one: registering it silently would meter at zero. + assert.False(t, out.Models[1].PricingKnown) + + require.NotNil(t, out.Models[0].Label, "the vendor supplied a display name") + assert.Equal(t, "EU Claude Haiku 4.5", *out.Models[0].Label) + // A vendor that supplies no name must omit the key rather than send an + // empty string: the dashboard falls back to the id on absence, and would + // render a blank row for "". + assert.Nil(t, out.Models[2].Label, "an absent label must not serialize") + assert.NotContains(t, rec.Body.String(), `"label":""`) + + assert.Equal(t, "bedrock_api", stub.gotReq.CatalogID) + assert.Equal(t, "aws-bearer", stub.gotReq.APIKey) + // The upstream is what the region is read back out of for Bedrock, so + // losing it here would break discovery for every regional provider. + assert.Equal(t, "https://bedrock-runtime.eu-central-1.amazonaws.com", stub.gotReq.UpstreamURL) + assert.Empty(t, stub.gotRecordID) +} + +func TestDiscoverModelsUsesAStoredRecordWithoutAKey(t *testing.T) { + stub := &discoveryManagerStub{} + + rec := postDiscovery(t, stub, `{"catalog_provider_id":"openai_api","provider_id":"prov-42"}`) + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + // The dashboard refreshes a saved provider's list without ever holding + // the credential, so the record id has to reach the manager. + assert.Equal(t, "prov-42", stub.gotRecordID) + assert.Empty(t, stub.gotReq.APIKey) +} + +// TestDiscoverModelsRefusesMixedCredentials covers the case where a caller +// names a saved provider AND supplies a key. Accepting it would run an +// arbitrary credential under the identity of a record the caller may only be +// permitted to read. +func TestDiscoverModelsRefusesMixedCredentials(t *testing.T) { + stub := &discoveryManagerStub{} + + rec := postDiscovery(t, stub, `{ + "catalog_provider_id":"openai_api", + "provider_id":"prov-42", + "api_key":"sk-attacker" + }`) + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Empty(t, stub.gotRecordID, "the request must be refused before it reaches the manager") +} + +// TestDiscoverModelsReportsNoDiscoveryDistinctly matters because the caller +// falls back to the catalog's own model list on this outcome. Collapsing it +// into a generic 500 would turn "this provider has no listing endpoint" into +// "something went wrong", and the form would show an error instead of a list. +func TestDiscoverModelsReportsNoDiscoveryDistinctly(t *testing.T) { + stub := &discoveryManagerStub{err: modeldiscovery.ErrNoDiscovery} + + rec := postDiscovery(t, stub, `{"catalog_provider_id":"litellm_proxy","upstream_url":"https://gw.example.com","api_key":"sk"}`) + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code) +} + +// TestDiscoverModelsTrimsTheCatalogID pins that the id the emptiness check +// accepts is the id the manager receives. A padded value that clears the check +// but reaches the catalog untrimmed misses the lookup, and the operator is told +// their provider does not exist. +func TestDiscoverModelsTrimsTheCatalogID(t *testing.T) { + stub := &discoveryManagerStub{} + + rec := postDiscovery(t, stub, `{"catalog_provider_id":" openai_api ","api_key":"sk"}`) + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Equal(t, "openai_api", stub.gotReq.CatalogID) +} + +// TestDiscoverModelsReportsCallerInputAsBadRequest covers the other half of the +// error mapping. These failures are all reachable from a well-formed request +// with a bad field value, so answering 500 both misinforms the operator and +// puts their typo into the server's error rate. +func TestDiscoverModelsReportsCallerInputAsBadRequest(t *testing.T) { + stub := &discoveryManagerStub{ + err: fmt.Errorf("%w: unknown catalog provider %q", modeldiscovery.ErrInvalidRequest, "nope"), + } + + rec := postDiscovery(t, stub, `{"catalog_provider_id":"nope","api_key":"sk"}`) + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "unknown catalog provider") +} + +func TestDiscoverModelsRejectsMalformedRequests(t *testing.T) { + for name, body := range map[string]string{ + "not json": `{`, + "no catalog provider": `{"api_key":"sk"}`, + "blank catalog provider": `{"catalog_provider_id":" ","api_key":"sk"}`, + } { + t.Run(name, func(t *testing.T) { + stub := &discoveryManagerStub{} + rec := postDiscovery(t, stub, body) + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) + } +} diff --git a/management/internals/modules/agentnetwork/handlers/providers_handler.go b/management/internals/modules/agentnetwork/handlers/providers_handler.go index 0d8a44ca3..645d1da61 100644 --- a/management/internals/modules/agentnetwork/handlers/providers_handler.go +++ b/management/internals/modules/agentnetwork/handlers/providers_handler.go @@ -7,6 +7,7 @@ package handlers import ( "encoding/json" + "errors" "math" "net/http" "net/url" @@ -16,6 +17,7 @@ import ( "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/modeldiscovery" "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing" "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" nbcontext "github.com/netbirdio/netbird/management/server/context" @@ -32,6 +34,7 @@ type handler struct { func RegisterEndpoints(manager agentnetwork.Manager, router *mux.Router) { h := &handler{manager: manager} router.HandleFunc("/agent-network/catalog/providers", h.getCatalogProviders).Methods("GET", "OPTIONS") + router.HandleFunc("/agent-network/catalog/providers/models", h.discoverProviderModels).Methods("POST", "OPTIONS") router.HandleFunc("/agent-network/providers", h.getAllProviders).Methods("GET", "OPTIONS") router.HandleFunc("/agent-network/providers", h.createProvider).Methods("POST", "OPTIONS") router.HandleFunc("/agent-network/providers/{providerId}", h.getProvider).Methods("GET", "OPTIONS") @@ -61,6 +64,98 @@ func (h *handler) getCatalogProviders(w http.ResponseWriter, r *http.Request) { util.WriteJSONObject(r.Context(), w, out) } +// discoverProviderModels asks the vendor which models the operator's own +// credential can reach, so the provider form can offer a live list rather than +// only the static catalog. +func (h *handler) discoverProviderModels(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + var body api.AgentNetworkModelDiscoveryRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + util.WriteErrorResponse("invalid json", http.StatusBadRequest, w) + return + } + // Trimmed once and carried, not trimmed for the emptiness test and then + // discarded: a padded " openai_api " would clear the check here and miss + // the catalog lookup, reporting the provider as unknown. + catalogID := strings.TrimSpace(body.CatalogProviderId) + if catalogID == "" { + util.WriteErrorResponse("catalog_provider_id is required", http.StatusBadRequest, w) + return + } + + recordID := strValue(body.ProviderId) + req := modeldiscovery.Request{ + CatalogID: catalogID, + UpstreamURL: strValue(body.UpstreamUrl), + APIKey: strValue(body.ApiKey), + } + // One source of credential or the other, never a mix: taking a key from + // the request while addressing a saved record would let a caller run an + // arbitrary credential against a provider they can only read. + if recordID != "" && req.APIKey != "" { + util.WriteErrorResponse("provide either provider_id or api_key, not both", http.StatusBadRequest, w) + return + } + + models, err := h.manager.DiscoverProviderModels(r.Context(), userAuth.AccountId, userAuth.UserId, req, recordID) + if err != nil { + // A provider with no listing endpoint is a fact about the catalog + // entry, not a failure: the caller falls back to the catalog's own + // models, so it must be able to tell the two apart. + if errors.Is(err, modeldiscovery.ErrNoDiscovery) { + util.WriteErrorResponse(err.Error(), http.StatusUnprocessableEntity, w) + return + } + // An unknown provider, an unusable upstream, a missing region or a + // missing key are all things the caller sent, reachable from a + // well-formed request. Reporting them as 500 tells the operator the + // server broke and buries genuine faults in the error rate. + if errors.Is(err, modeldiscovery.ErrInvalidRequest) { + util.WriteErrorResponse(err.Error(), http.StatusBadRequest, w) + return + } + util.WriteError(r.Context(), err, w) + return + } + + out := api.AgentNetworkModelDiscoveryResponse{Models: make([]api.AgentNetworkDiscoveredModel, 0, len(models))} + for _, m := range models { + 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 + } + out.Models = append(out.Models, entry) + } + util.WriteJSONObject(r.Context(), w, out) +} + +// strValue reads an optional string field, treating absent as empty. +func strValue(v *string) string { + if v == nil { + return "" + } + return strings.TrimSpace(*v) +} + // applyDefaultPricing overwrites the catalog response's model rates with // the LIVE default pricing table, which may differ from the compiled-in // catalog rates when the operator provides a defaults_llm_pricing.yaml. diff --git a/management/internals/modules/agentnetwork/manager.go b/management/internals/modules/agentnetwork/manager.go index 379672989..41789195e 100644 --- a/management/internals/modules/agentnetwork/manager.go +++ b/management/internals/modules/agentnetwork/manager.go @@ -13,6 +13,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/labelgen" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/modeldiscovery" "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey" @@ -50,6 +51,7 @@ type Manager interface { CreateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error) UpdateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error) DeleteProvider(ctx context.Context, accountID, userID, providerID string) error + DiscoverProviderModels(ctx context.Context, accountID, userID string, req modeldiscovery.Request, recordID string) ([]modeldiscovery.Model, error) GetAllPolicies(ctx context.Context, accountID, userID string) ([]*types.Policy, error) GetPolicy(ctx context.Context, accountID, userID, policyID string) (*types.Policy, error) @@ -123,6 +125,15 @@ type managerImpl struct { permissionsManager permissions.Manager proxyController proxy.Controller + // modelDiscovery queries vendors for the models a credential can reach. + // A field rather than a package call so tests can drive it without + // reaching the network. + // + // One instance serves every request for the process's lifetime, so its + // fields must stay read-only after construction: lazy initialisation + // inside Fetch or httpClient would race across request goroutines. + modelDiscovery *modeldiscovery.Client + // reconcileCache holds the last set of synthesised proxy mappings // per account, each paired with the proxy that served it, so a change // of serving proxy can be diffed without re-deriving it. @@ -151,6 +162,7 @@ func NewManager( accountManager: accountManager, permissionsManager: permissionsManager, proxyController: proxyController, + modelDiscovery: &modeldiscovery.Client{}, reconcileCache: make(map[string]map[string]syntheticMapping), labelRng: rand.New(rand.NewSource(time.Now().UnixNano())), } @@ -170,6 +182,38 @@ func (m *managerImpl) GetProvider(ctx context.Context, accountID, userID, provid return m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, providerID) } +// DiscoverProviderModels asks the vendor which models a credential can reach. +// +// recordID, when set, names an existing provider whose stored credential and +// upstream are used instead of the ones in req — so the dashboard can refresh +// the list without ever holding the key. +// +// Gated on Create rather than Read: this spends the operator's credential +// against a third party, which is not something a read-only role should be +// able to make the server do. That one check also covers reading the stored +// record — Create is strictly stronger than Read here, and the lookup is +// scoped to accountID, so another account's record is never reachable. +func (m *managerImpl) DiscoverProviderModels(ctx context.Context, accountID, userID string, req modeldiscovery.Request, recordID string) ([]modeldiscovery.Model, error) { + if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Create); err != nil { + return nil, err + } + + if recordID != "" { + record, err := m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, recordID) + if err != nil { + return nil, err + } + // The catalog id comes from the stored record too: letting the caller + // name a different one would run a provider's credential against + // whichever vendor endpoint they picked. + req.CatalogID = record.ProviderID + req.UpstreamURL = record.UpstreamURL + req.APIKey = record.APIKey + } + + return m.modelDiscovery.Fetch(ctx, req) +} + // CreateProvider persists a new provider for the account. Providers have no // settings side effects: the account's endpoint is bootstrapped separately and // explicitly via CreateSettings, and every provider in the account routes @@ -1017,6 +1061,10 @@ func (*mockManager) GetAllProviders(_ context.Context, _, _ string) ([]*types.Pr return []*types.Provider{}, nil } +func (*mockManager) DiscoverProviderModels(_ context.Context, _, _ string, _ modeldiscovery.Request, _ string) ([]modeldiscovery.Model, error) { + return nil, nil +} + func (*mockManager) GetProvider(_ context.Context, _, _, _ string) (*types.Provider, error) { return &types.Provider{}, nil } diff --git a/management/internals/modules/agentnetwork/modeldiscovery/discovery.go b/management/internals/modules/agentnetwork/modeldiscovery/discovery.go new file mode 100644 index 000000000..37401820c --- /dev/null +++ b/management/internals/modules/agentnetwork/modeldiscovery/discovery.go @@ -0,0 +1,469 @@ +// Package modeldiscovery asks a vendor which models an operator's own +// credential can reach, so the provider form can offer a live list instead of +// only the catalog's hand-curated one. +// +// The catalog cannot know two things that matter. It goes stale — its entries +// carry comments tracking which models a vendor retired on which date — and it +// cannot see an account: which OpenAI models an org is entitled to, which +// Bedrock inference profiles a given account and region hold, which Vertex +// models a project has enabled. Those are exactly the facts an operator needs +// when filling in a provider record, and only the vendor has them. +// +// The vendor is authoritative for the model ID. The catalog remains +// authoritative for pricing, and a discovered model the catalog cannot price +// is reported as such rather than silently registered at a rate of zero. +package modeldiscovery + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "strings" + "syscall" + "time" + + "golang.org/x/oauth2/google" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing" +) + +const ( + // fetchTimeout bounds one vendor call end to end. A listing is a single + // small GET; anything slower is a vendor problem and the operator is + // waiting on a form. + fetchTimeout = 8 * time.Second + // maxListingBytes bounds the response we will buffer. The largest real + // listing observed is Bedrock's foundation-model catalogue at ~70KB, so + // this is a wide margin over anything legitimate. + maxListingBytes = 2 << 20 + // gcpScope matches the scope llm_router mints Vertex tokens under, so a + // credential that works for discovery works for inference too. + gcpScope = "https://www.googleapis.com/auth/cloud-platform" + // vertexKeyfilePrefix marks an api_key that is a base64 service-account + // JSON key rather than a bearer token. + vertexKeyfilePrefix = "keyfile::" +) + +// ErrNoDiscovery is returned for a catalog entry that declares no listing +// endpoint. Gateways vary too much to have one, and the caller should fall +// back to the catalog list plus free-text entry rather than treating this as +// a failure. +var ErrNoDiscovery = errors.New("provider has no model-discovery endpoint") + +// ErrInvalidRequest marks a discovery failure caused by the caller's own input +// rather than by the vendor or by this server. Every one of these is reachable +// from a well-formed request carrying a bad field value, so the handler owes +// the caller a 400 — a 500 would both misinform them and bury real server +// faults in the error rate. +var ErrInvalidRequest = errors.New("invalid discovery request") + +// Model is one discovered model. +type Model struct { + // ID is the identifier to register on the provider record, in the form the + // vendor issues it. For Bedrock that is the region-prefixed inference + // profile id, which is the only form AWS accepts at invoke time. + ID string + // Label is the vendor's display name where it supplies one. + Label string + // PricingKnown reports whether the shipped pricing table can price this + // 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. +type Request struct { + // CatalogID selects the catalog entry, which supplies the endpoint, the + // auth header and the response shape. The caller never supplies those. + CatalogID string + // UpstreamURL is the provider record's configured upstream. It is used + // only when the catalog entry declares no discovery host of its own. + UpstreamURL string + // Region substitutes the catalog host's placeholder. + Region string + // APIKey is the operator's credential, exactly as stored on the record. + APIKey string +} + +// Client fetches model listings. The zero value is usable; Resolver and +// HTTPClient exist so tests can drive it against a local server. +type Client struct { + HTTPClient *http.Client + // Resolver looks up the host for the SSRF check. Nil uses the default. + Resolver *net.Resolver + // AllowPrivateHosts disables the private-address guard. Only tests set it: + // their server is on loopback, which is precisely what the guard blocks. + AllowPrivateHosts bool +} + +// Fetch returns the models the credential can reach. +func (c *Client) Fetch(ctx context.Context, req Request) ([]Model, error) { + entry, ok := catalog.Lookup(req.CatalogID) + if !ok { + return nil, fmt.Errorf("%w: unknown catalog provider %q", ErrInvalidRequest, req.CatalogID) + } + if entry.Discovery == nil { + return nil, ErrNoDiscovery + } + + endpoint, err := c.discoveryURL(entry, req) + if err != nil { + return nil, err + } + + ctx, cancel := context.WithTimeout(ctx, fetchTimeout) + defer cancel() + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("build discovery request: %w", err) + } + if err := applyAuth(httpReq, entry, req.APIKey); err != nil { + return nil, err + } + for name, value := range entry.Discovery.Headers { + httpReq.Header.Set(name, value) + } + httpReq.Header.Set("Accept", "application/json") + + resp, err := c.httpClient().Do(httpReq) + if err != nil { + return nil, fmt.Errorf("reach %s: %w", entry.Name, err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxListingBytes)) + if err != nil { + return nil, fmt.Errorf("read %s listing: %w", entry.Name, err) + } + if resp.StatusCode != http.StatusOK { + // Surface the vendor's own status. An operator whose key lacks a scope + // needs to see 403 rather than a generic failure. + return nil, fmt.Errorf("%s returned %d for its model listing", entry.Name, resp.StatusCode) + } + + ids, err := parseListing(entry.Discovery.Shape, body) + if err != nil { + return nil, err + } + return decorate(entry, ids), nil +} + +// discoveryURL builds the listing URL and refuses one that does not point at a +// public host. +// +// The path, query and (for Bedrock) the host all come from the catalog rather +// than from the caller, so the only operator-controlled part is the host of an +// entry whose listing lives on its own upstream. That still has to be checked: +// management holds credentials for every provider, and an upstream pointed at +// an internal address would turn this endpoint into a probe of the management +// server's own network. +func (c *Client) discoveryURL(entry catalog.Provider, req Request) (string, error) { + host := entry.Discovery.Host + if host == "" { + parsed, err := url.Parse(strings.TrimSpace(req.UpstreamURL)) + if err != nil || parsed.Host == "" { + return "", fmt.Errorf("%w: provider upstream %q is not a usable URL", ErrInvalidRequest, req.UpstreamURL) + } + host = parsed.Host + } + if strings.Contains(host, catalog.RegionPlaceholder) { + region := strings.TrimSpace(req.Region) + if region == "" { + // A provider record carries no region field: the region lives + // inside the upstream host the operator already configured, so + // read it back out rather than asking them for it twice. + region = regionFromUpstream(entry, req.UpstreamURL) + } + if region == "" { + return "", fmt.Errorf("%w: %s discovery needs a region, and none could be read from the provider upstream", + ErrInvalidRequest, entry.Name) + } + host = strings.ReplaceAll(host, catalog.RegionPlaceholder, region) + } + + target := &url.URL{Scheme: "https", Host: host, Path: entry.Discovery.Path, RawQuery: entry.Discovery.Query} + if err := c.checkPublicHost(target.Hostname()); err != nil { + return "", err + } + return target.String(), nil +} + +// regionFromUpstream recovers the region an operator embedded in the provider +// upstream, by matching it against the catalog's own host template. Bedrock's +// template is "bedrock-runtime..amazonaws.com" and Vertex's is +// "-aiplatform.googleapis.com", so the region is whatever sits between +// the fixed halves. Returns empty when the upstream does not match the +// template, which is the case for a custom or proxied endpoint. +func regionFromUpstream(entry catalog.Provider, upstreamURL string) string { + prefix, suffix, found := strings.Cut(entry.DefaultHost, catalog.RegionPlaceholder) + if !found { + return "" + } + parsed, err := url.Parse(strings.TrimSpace(upstreamURL)) + if err != nil { + return "" + } + host := parsed.Hostname() + if host == "" { + // A bare host with no scheme parses as a path, not a host. + host = strings.TrimSpace(upstreamURL) + } + // The two halves must not overlap. "bedrock-runtime.amazonaws.com" carries + // both of Bedrock's — it is the regionless endpoint — and satisfies both + // checks above while leaving nothing between them, so slicing it would + // panic on an inverted range rather than report "no region here". + if !strings.HasPrefix(host, prefix) || !strings.HasSuffix(host, suffix) || + len(host) < len(prefix)+len(suffix) { + return "" + } + region := host[len(prefix) : len(host)-len(suffix)] + if region == "" || strings.Contains(region, ".") { + return "" + } + return region +} + +// checkPublicHost refuses hosts that resolve to an address the management +// server should never be asked to reach on an operator's behalf. +func (c *Client) checkPublicHost(host string) error { + if c.AllowPrivateHosts { + return nil + } + if host == "" { + return errors.New("discovery host is empty") + } + resolver := c.Resolver + if resolver == nil { + resolver = net.DefaultResolver + } + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + addrs, err := resolver.LookupNetIP(ctx, "ip", host) + if err != nil { + return fmt.Errorf("resolve discovery host %q: %w", host, err) + } + // Every address must be public: a name that resolves to one public and one + // loopback address is still a way to reach loopback. + for _, addr := range addrs { + if !isPublic(addr) { + return fmt.Errorf("%w: discovery host %q resolves to a non-public address", ErrInvalidRequest, host) + } + } + return nil +} + +// isPublic reports whether an address is one we are willing to dial. +func isPublic(addr netip.Addr) bool { + addr = addr.Unmap() + switch { + case !addr.IsValid(), + addr.IsLoopback(), + addr.IsPrivate(), + addr.IsLinkLocalUnicast(), + addr.IsLinkLocalMulticast(), + addr.IsInterfaceLocalMulticast(), + addr.IsMulticast(), + addr.IsUnspecified(): + return false + } + // 100.64.0.0/10 (carrier NAT) is where NetBird's own overlay addresses + // live, so it is emphatically not somewhere to send a provider credential. + if addr.Is4() { + b := addr.As4() + if b[0] == 100 && b[1] >= 64 && b[1] <= 127 { + return false + } + } + return true +} + +// applyAuth sets the credential header the catalog entry declares. A Vertex +// service-account key is exchanged for an OAuth token first, the same way the +// proxy does at request time. +func applyAuth(req *http.Request, entry catalog.Provider, apiKey string) error { + key := strings.TrimSpace(apiKey) + if key == "" { + return fmt.Errorf("%w: %s discovery needs an API key", ErrInvalidRequest, entry.Name) + } + if rest, ok := strings.CutPrefix(key, vertexKeyfilePrefix); ok { + token, err := mintGCPToken(req.Context(), rest) + if err != nil { + return err + } + key = token + } + name := entry.AuthHeaderName + if name == "" { + name = "Authorization" + } + template := entry.AuthHeaderTemplate + if template == "" { + template = "${API_KEY}" + } + req.Header.Set(name, strings.ReplaceAll(template, "${API_KEY}", key)) + return nil +} + +// mintGCPToken exchanges a base64 service-account key for an access token. +func mintGCPToken(ctx context.Context, saKeyB64 string) (string, error) { + jsonKey, err := base64.StdEncoding.DecodeString(strings.TrimSpace(saKeyB64)) + if err != nil { + return "", fmt.Errorf("decode service-account key: %w", err) + } + conf, err := google.JWTConfigFromJSON(jsonKey, gcpScope) + if err != nil { + return "", fmt.Errorf("parse service-account key: %w", err) + } + tok, err := conf.TokenSource(ctx).Token() + if err != nil { + return "", fmt.Errorf("mint gcp token: %w", err) + } + return tok.AccessToken, nil +} + +// 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 { + out := make([]Model, 0, len(ids)) + seen := make(map[string]struct{}, len(ids)) + for _, listed := range ids { + if listed.id == "" { + continue + } + if _, dup := seen[listed.id]; dup { + continue + } + seen[listed.id] = struct{}{} + + // 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 +} + +// refuseRedirect is the redirect policy every discovery request runs under. A +// redirect is a way to move the request to a host checkPublicHost never saw, +// so none are followed. +func refuseRedirect(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse +} + +func (c *Client) httpClient() *http.Client { + if c.HTTPClient != nil { + if c.HTTPClient.CheckRedirect != nil { + return c.HTTPClient + } + // An injected client that states no policy still gets ours: the + // no-redirect guarantee should not depend on the caller remembering it. + // + // Copied rather than assigned into: one Client is shared by every + // request for the process's lifetime, so writing to its fields here + // would race across request goroutines. The copy shares the Transport, + // which is safe for concurrent use by design. + clone := *c.HTTPClient + clone.CheckRedirect = refuseRedirect + return &clone + } + transport := guardedTransport + if c.AllowPrivateHosts { + transport = http.DefaultTransport + } + return &http.Client{ + Timeout: fetchTimeout, + Transport: transport, + CheckRedirect: refuseRedirect, + } +} + +// guardedTransport dials only addresses isPublic accepts. +// +// checkPublicHost resolves the host itself, and the transport then resolves it +// again when it dials — two lookups of a name whose owner chooses the answers. +// A record that returns a public address to the first and 127.0.0.1 to the +// second passes the guard and reaches loopback anyway, which is the whole of +// DNS rebinding. Re-checking at the socket closes that window: whatever the +// second lookup returned is what Control is handed, and an address the guard +// refuses never gets connected. +// +// Shared package-wide rather than built per Fetch so connections and their +// pool survive between calls; the guard holds no state. +var guardedTransport = newGuardedTransport() + +func newGuardedTransport() http.RoundTripper { + base, ok := http.DefaultTransport.(*http.Transport) + if !ok { + // Something replaced the default transport. Fall back to it rather + // than dropping its behaviour, and rely on checkPublicHost alone. + return http.DefaultTransport + } + // Cloned so proxy settings, TLS defaults and timeouts come from the + // standard transport rather than being restated here. + transport := base.Clone() + dialer := &net.Dialer{ + Timeout: fetchTimeout, + KeepAlive: 30 * time.Second, + Control: func(_, address string, _ syscall.RawConn) error { + return guardDialAddress(address) + }, + } + transport.DialContext = dialer.DialContext + return transport +} + +// guardDialAddress refuses a resolved socket address the discovery client has +// no business connecting to. Control hands it over post-resolution and +// pre-connect, once per address the dialer tries, so a name with several A +// records is checked at each one. +func guardDialAddress(address string) error { + host, _, err := net.SplitHostPort(address) + if err != nil { + return fmt.Errorf("discovery dial address %q is unreadable", address) + } + addr, err := netip.ParseAddr(host) + if err != nil { + // Control is documented to receive a resolved address; anything else + // is a state we cannot vet, so it does not get dialled. + return fmt.Errorf("discovery dial address %q is not an IP", host) + } + if !isPublic(addr) { + return fmt.Errorf("discovery refused to dial non-public address %s", addr) + } + return nil +} diff --git a/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go b/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go new file mode 100644 index 000000000..fba2c97d1 --- /dev/null +++ b/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go @@ -0,0 +1,496 @@ +package modeldiscovery + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "net/netip" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "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 +// request it was given, so a test can assert on the URL and headers the client +// built without a network round trip. +type stubTransport struct { + status int + body string + got *http.Request +} + +func (s *stubTransport) RoundTrip(req *http.Request) (*http.Response, error) { + s.got = req + status := s.status + if status == 0 { + status = http.StatusOK + } + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(strings.NewReader(s.body)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + Request: req, + }, nil +} + +// newStubClient returns a client that never leaves the process. The host guard +// is disabled because it would otherwise resolve the vendor's real name, which +// would make these tests depend on DNS. +func newStubClient(status int, body string) (*Client, *stubTransport) { + tr := &stubTransport{status: status, body: body} + return &Client{ + HTTPClient: &http.Client{Transport: tr}, + AllowPrivateHosts: true, + }, tr +} + +// The payloads below are trimmed from what the vendors actually returned in +// the discovery e2e, rather than invented, so a parser that only works against +// an idealised shape fails here. + +const openAIListing = `{"object":"list","data":[ + {"id":"gpt-4o-mini","object":"model","created":1721172741,"owned_by":"system"}, + {"id":"gpt-4o","object":"model","created":1715367049,"owned_by":"system"} +]}` + +const anthropicListing = `{"data":[ + {"type":"model","id":"claude-haiku-4-5-20251001","display_name":"Claude Haiku 4.5"}, + {"type":"model","id":"claude-sonnet-4-6","display_name":"Claude Sonnet 4.6"} +],"has_more":false}` + +const bedrockListing = `{"inferenceProfileSummaries":[ + {"inferenceProfileId":"eu.anthropic.claude-haiku-4-5-20251001-v1:0", + "inferenceProfileName":"EU Anthropic Claude Haiku 4.5","status":"ACTIVE","type":"SYSTEM_DEFINED"}, + {"inferenceProfileId":"global.cohere.embed-v4:0", + "inferenceProfileName":"Global Cohere Embed v4","status":"ACTIVE","type":"SYSTEM_DEFINED"}, + {"inferenceProfileId":"eu.meta.llama3-2-1b-instruct-v1:0", + "inferenceProfileName":"EU Meta Llama 3.2 1B","status":"INACTIVE","type":"SYSTEM_DEFINED"} +]}` + +const vertexListing = `{"publisherModels":[ + {"name":"publishers/anthropic/models/claude-3-opus","versionId":"20240229","launchStage":"GA"}, + {"name":"publishers/anthropic/models/claude-sonnet-4-5","versionId":"20250929","launchStage":"GA"} +]}` + +func TestFetchOpenAIListing(t *testing.T) { + cl, tr := 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) + + assert.Equal(t, "https://api.openai.com/v1/models", tr.got.URL.String()) + assert.Equal(t, "Bearer sk-test", tr.got.Header.Get("Authorization"), + "the credential must be injected through the catalog's auth template") + assert.Equal(t, []string{"gpt-4o-mini", "gpt-4o"}, ids(models)) + for _, m := range models { + assert.True(t, m.PricingKnown, "both models are in the shipped catalog: %s", m.ID) + } +} + +func TestFetchAnthropicSendsTheVersionHeader(t *testing.T) { + cl, tr := newStubClient(http.StatusOK, anthropicListing) + + models, err := cl.Fetch(context.Background(), Request{ + CatalogID: "anthropic_api", + UpstreamURL: "https://api.anthropic.com", + APIKey: "sk-ant-test", + }) + require.NoError(t, err) + + // Anthropic rejects a request without the version header, so a listing + // that reached us at all proves it was sent — but assert it, because the + // failure mode otherwise only shows up against the live API. + assert.Equal(t, "2023-06-01", tr.got.Header.Get("anthropic-version")) + assert.Equal(t, "sk-ant-test", tr.got.Header.Get("x-api-key"), + "Anthropic takes a bare key under its own header, not a Bearer token") + assert.Equal(t, "limit=1000", tr.got.URL.RawQuery) + + assert.Equal(t, []string{"claude-haiku-4-5-20251001", "claude-sonnet-4-6"}, ids(models)) + assert.Equal(t, "Claude Haiku 4.5", models[0].Label) +} + +func TestFetchBedrockUsesTheControlPlaneAndKeepsWireIDs(t *testing.T) { + cl, tr := newStubClient(http.StatusOK, bedrockListing) + + models, err := cl.Fetch(context.Background(), Request{ + CatalogID: "bedrock_api", + // The record's upstream is the RUNTIME host, which does not serve + // listings. The catalog's own discovery host must win over it. + UpstreamURL: "https://bedrock-runtime.eu-central-1.amazonaws.com", + Region: "eu-central-1", + APIKey: "aws-bearer", + }) + require.NoError(t, err) + + assert.Equal(t, "https://bedrock.eu-central-1.amazonaws.com/inference-profiles", + tr.got.URL.String(), "listings come from the control plane, not the runtime host") + + // Region-prefixed ids verbatim: the prefix is what makes them invocable + // and it cannot be reconstructed — global.* alongside eu.* is exactly the + // case that defeats deriving it from the configured region. + assert.Equal(t, []string{ + "eu.anthropic.claude-haiku-4-5-20251001-v1:0", + "global.cohere.embed-v4:0", + }, ids(models), "an INACTIVE profile must not be offered") + + assert.True(t, models[0].PricingKnown, + "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) { + cl, _ := newStubClient(http.StatusOK, vertexListing) + + models, err := cl.Fetch(context.Background(), Request{ + CatalogID: "vertex_ai_api", + UpstreamURL: "https://us-east5-aiplatform.googleapis.com", + Region: "us-east5", + APIKey: "ya29.test-token", + }) + require.NoError(t, err) + + // Vertex addresses a model as "@" on rawPredict, and splits + // those across two fields in the listing. + assert.Equal(t, []string{"claude-3-opus@20240229", "claude-sonnet-4-5@20250929"}, ids(models)) + assert.Equal(t, "claude-3-opus", models[0].Label) +} + +func TestFetchSurfacesTheVendorStatus(t *testing.T) { + cl, _ := newStubClient(http.StatusForbidden, `{"error":{"message":"no access"}}`) + + _, err := cl.Fetch(context.Background(), Request{ + CatalogID: "openai_api", + UpstreamURL: "https://api.openai.com", + APIKey: "sk-test", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "403", + "an operator whose key lacks access needs to see which status the vendor returned") +} + +func TestFetchRejectsAProviderWithoutDiscovery(t *testing.T) { + cl, _ := newStubClient(http.StatusOK, openAIListing) + + _, err := cl.Fetch(context.Background(), Request{ + CatalogID: "litellm_proxy", + UpstreamURL: "https://gateway.example.com", + APIKey: "sk-test", + }) + assert.ErrorIs(t, err, ErrNoDiscovery, + "a gateway with no listing endpoint must be distinguishable from a failure, so the caller can fall back") +} + +func TestFetchRequiresACredential(t *testing.T) { + cl, _ := newStubClient(http.StatusOK, openAIListing) + + _, err := cl.Fetch(context.Background(), Request{ + CatalogID: "openai_api", + UpstreamURL: "https://api.openai.com", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "API key") +} + +func TestDiscoveryURLNeedsARegionWhenTheHostTemplatesOne(t *testing.T) { + cl, _ := newStubClient(http.StatusOK, bedrockListing) + + // An upstream that matches no catalog template — a proxy in front of + // Bedrock, say — leaves nothing to read the region from. Refusing beats + // guessing: an unsubstituted placeholder would dial a host that does not + // exist, and a guessed region would dial the wrong account's endpoint. + _, err := cl.Fetch(context.Background(), Request{ + CatalogID: "bedrock_api", + UpstreamURL: "https://bedrock.internal-proxy.example.com", + APIKey: "aws-bearer", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "region") +} + +// TestHostGuardRejectsNonPublicAddresses is the SSRF guard. Management holds a +// credential for every provider, so an upstream pointed at an internal address +// would turn discovery into a way to probe — and hand a token to — the +// management server's own network. +func TestHostGuardRejectsNonPublicAddresses(t *testing.T) { + for _, tc := range []struct { + name string + addr string + want bool + }{ + {"loopback v4", "127.0.0.1", false}, + {"loopback v6", "::1", false}, + {"private 10/8", "10.0.0.5", false}, + {"private 172.16/12", "172.16.4.1", false}, + {"private 192.168/16", "192.168.1.1", false}, + {"link-local", "169.254.169.254", false}, // cloud metadata + {"unspecified", "0.0.0.0", false}, + {"multicast", "224.0.0.1", false}, + {"netbird overlay 100.64/10", "100.90.1.2", false}, + {"v4-mapped loopback", "::ffff:127.0.0.1", false}, + {"public v4", "1.1.1.1", true}, + {"public v6", "2606:4700:4700::1111", true}, + {"just outside CGNAT", "100.128.0.1", true}, + } { + t.Run(tc.name, func(t *testing.T) { + addr, err := netip.ParseAddr(tc.addr) + require.NoError(t, err) + assert.Equal(t, tc.want, isPublic(addr)) + }) + } +} + +func TestHostGuardResolvesAndRejectsLocalhost(t *testing.T) { + cl := &Client{} + err := cl.checkPublicHost("localhost") + require.Error(t, err, "a name resolving to loopback must be refused, not just a literal address") + assert.Contains(t, err.Error(), "non-public") +} + +// TestRedirectsAreNotFollowed covers a gap the other tests leave open: they all +// inject an HTTPClient, which bypasses httpClient() and therefore the redirect +// policy entirely. The policy is a security control — a 302 moves the request +// to a host checkPublicHost never resolved — so it needs a test that goes +// through the constructor the manager actually uses. +func TestRedirectsAreNotFollowed(t *testing.T) { + var hits int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + http.Redirect(w, r, "http://169.254.169.254/latest/meta-data/", http.StatusFound) + })) + t.Cleanup(srv.Close) + + for name, cl := range map[string]*Client{ + // The production shape: no injected client at all. + "default client": {AllowPrivateHosts: true}, + // An injected client that states no policy must inherit ours rather + // than silently chasing the redirect. + "injected client with no policy": { + AllowPrivateHosts: true, + HTTPClient: &http.Client{}, + }, + } { + t.Run(name, func(t *testing.T) { + hits = 0 + req, err := http.NewRequest(http.MethodGet, srv.URL, nil) + require.NoError(t, err) + + resp, err := cl.httpClient().Do(req) + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + + assert.Equal(t, http.StatusFound, resp.StatusCode, + "the redirect must be surfaced, not followed to an unchecked host") + assert.Equal(t, 1, hits, "exactly one request must leave the client") + }) + } +} + +// TestInjectedClientKeepsItsOwnRedirectPolicy pins that the default above is a +// default, not an override, and that supplying it does not mutate the caller's +// client — one Client is shared across every request, so a write here would +// race. +func TestInjectedClientKeepsItsOwnRedirectPolicy(t *testing.T) { + own := func(*http.Request, []*http.Request) error { return nil } + injected := &http.Client{CheckRedirect: own} + cl := &Client{HTTPClient: injected} + + assert.Same(t, injected, cl.httpClient(), + "a client that states a policy must be handed back untouched") + + bare := &http.Client{} + cl = &Client{HTTPClient: bare} + require.NotSame(t, bare, cl.httpClient(), "the policy must be applied to a copy") + assert.Nil(t, bare.CheckRedirect, "the caller's client must not be written to") +} + +// TestDialGuardRejectsRebindingToANonPublicAddress covers the window between +// the two DNS lookups. checkPublicHost resolves the host, then the transport +// resolves it again to dial; a name whose owner answers the first with a public +// address and the second with 127.0.0.1 would otherwise pass the guard and +// still reach loopback. The dial-time check sees whatever the second lookup +// actually returned. +func TestDialGuardRejectsRebindingToANonPublicAddress(t *testing.T) { + for _, tc := range []struct { + name string + address string + wantErr string + }{ + {"loopback", "127.0.0.1:443", "non-public"}, + {"cloud metadata", "169.254.169.254:80", "non-public"}, + {"rfc1918", "10.1.2.3:443", "non-public"}, + {"netbird overlay", "100.90.1.2:443", "non-public"}, + {"loopback v6", "[::1]:443", "non-public"}, + {"unresolved name", "evil.example.com:443", "not an IP"}, + {"no port", "1.1.1.1", "unreadable"}, + } { + t.Run(tc.name, func(t *testing.T) { + err := guardDialAddress(tc.address) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + }) + } + + assert.NoError(t, guardDialAddress("1.1.1.1:443"), "a public address must still be dialled") + assert.NoError(t, guardDialAddress("[2606:4700:4700::1111]:443")) +} + +// TestDialGuardIsInstalledOnTheDefaultClient pins the wiring rather than the +// guard: a correct guard nothing calls protects nothing. +func TestDialGuardIsInstalledOnTheDefaultClient(t *testing.T) { + cl := &Client{} + transport, ok := cl.httpClient().Transport.(*http.Transport) + require.True(t, ok, "the default discovery client must carry the guarded transport") + require.NotNil(t, transport.DialContext, "the guarded transport must dial through the guard") + + _, err := transport.DialContext(context.Background(), "tcp", "127.0.0.1:9") + require.Error(t, err, "the guard must refuse loopback even when the caller dials it directly") + assert.Contains(t, err.Error(), "non-public") + + // Tests point the client at a loopback server on purpose, so the opt-out + // has to reach the dialer too. + relaxed := &Client{AllowPrivateHosts: true} + assert.Equal(t, http.DefaultTransport, relaxed.httpClient().Transport) +} + +// TestCallerInputFailuresAreMarkedInvalid keeps the handler's 400 mapping +// honest: it branches on this sentinel, so an unmarked caller-input failure +// silently becomes a 500. +func TestCallerInputFailuresAreMarkedInvalid(t *testing.T) { + for _, tc := range []struct { + name string + req Request + }{ + {"unknown provider", Request{CatalogID: "not_a_provider", APIKey: "k"}}, + {"unusable upstream", Request{CatalogID: "openai_api", UpstreamURL: "://", APIKey: "k"}}, + {"missing api key", Request{CatalogID: "openai_api", UpstreamURL: "https://api.openai.com"}}, + {"no region to read", Request{ + CatalogID: "bedrock_api", + UpstreamURL: "https://bedrock-runtime.amazonaws.com", + APIKey: "aws-bearer", + }}, + } { + t.Run(tc.name, func(t *testing.T) { + cl, _ := newStubClient(http.StatusOK, openAIListing) + _, err := cl.Fetch(context.Background(), tc.req) + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidRequest) + }) + } +} + +// TestEveryDiscoveryEntryHasAParser keeps the catalog and the parser table from +// drifting: adding a Discovery block with a shape nothing parses would fail +// only at runtime, in front of an operator. +func TestEveryDiscoveryEntryHasAParser(t *testing.T) { + for _, entry := range catalog.All() { + if entry.Discovery == nil { + continue + } + t.Run(entry.ID, func(t *testing.T) { + assert.NotEmpty(t, entry.Discovery.Path, "a discovery entry needs a path") + _, err := parseListing(entry.Discovery.Shape, []byte(`{}`)) + assert.NoError(t, err, "shape %q has no parser", entry.Discovery.Shape) + }) + } +} + +func ids(models []Model) []string { + out := make([]string, 0, len(models)) + for _, m := range models { + out = append(out, m.ID) + } + return out +} + +// TestRegionIsReadBackFromTheUpstream covers the reason the API takes no +// region field: a provider record has none, and the operator already encoded +// it in the upstream host when they configured inference. +func TestRegionIsReadBackFromTheUpstream(t *testing.T) { + cl, tr := newStubClient(http.StatusOK, bedrockListing) + + _, err := cl.Fetch(context.Background(), Request{ + CatalogID: "bedrock_api", + UpstreamURL: "https://bedrock-runtime.us-west-2.amazonaws.com", + APIKey: "aws-bearer", + }) + require.NoError(t, err) + assert.Equal(t, "bedrock.us-west-2.amazonaws.com", tr.got.URL.Host) +} + +func TestRegionFromUpstream(t *testing.T) { + bedrock, ok := catalog.Lookup("bedrock_api") + require.True(t, ok) + vertex, ok := catalog.Lookup("vertex_ai_api") + require.True(t, ok) + + for _, tc := range []struct { + name string + entry catalog.Provider + upstream string + want string + }{ + {"bedrock runtime host", bedrock, "https://bedrock-runtime.eu-central-1.amazonaws.com", "eu-central-1"}, + {"bedrock without scheme", bedrock, "bedrock-runtime.ap-south-1.amazonaws.com", "ap-south-1"}, + {"vertex regional host", vertex, "https://us-east5-aiplatform.googleapis.com", "us-east5"}, + // A proxied or self-hosted upstream matches no template, and guessing + // a region from it would build a URL pointing somewhere arbitrary. + {"unrelated upstream", bedrock, "https://llm.internal.example.com", ""}, + {"vertex global host has no region segment", vertex, "https://aiplatform.googleapis.com", ""}, + // Bedrock's regionless endpoint carries both halves of the template at + // once, with nothing between them. It has to read as "no region here" + // rather than as an inverted slice range. + {"bedrock regionless endpoint", bedrock, "https://bedrock-runtime.amazonaws.com", ""}, + {"bedrock regionless without scheme", bedrock, "bedrock-runtime.amazonaws.com", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, regionFromUpstream(tc.entry, tc.upstream)) + }) + } +} diff --git a/management/internals/modules/agentnetwork/modeldiscovery/parse.go b/management/internals/modules/agentnetwork/modeldiscovery/parse.go new file mode 100644 index 000000000..83048cb8a --- /dev/null +++ b/management/internals/modules/agentnetwork/modeldiscovery/parse.go @@ -0,0 +1,134 @@ +package modeldiscovery + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog" + sharedllm "github.com/netbirdio/netbird/shared/llm" +) + +// listedModel is one entry lifted out of a vendor listing before the catalog +// is consulted about it. +type listedModel struct { + id string + label string +} + +// parseListing extracts model ids from a vendor listing. Each vendor invented +// its own envelope, and the shape is declared by the catalog rather than +// sniffed, so a vendor that changes shape fails loudly instead of silently +// returning nothing. +func parseListing(shape catalog.ListingShape, body []byte) ([]listedModel, error) { + switch shape { + case catalog.ShapeOpenAIData: + return parseOpenAIData(body) + case catalog.ShapeBedrockInferenceProfiles: + return parseBedrockInferenceProfiles(body) + case catalog.ShapeVertexPublisherModels: + return parseVertexPublisherModels(body) + default: + return nil, fmt.Errorf("no parser for listing shape %q", shape) + } +} + +// parseOpenAIData reads {"data":[{"id":…}]}, which OpenAI defined and +// Anthropic adopted. Anthropic additionally supplies display_name. +func parseOpenAIData(body []byte) ([]listedModel, error) { + var doc struct { + Data []struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + } `json:"data"` + } + if err := json.Unmarshal(body, &doc); err != nil { + return nil, fmt.Errorf("decode model listing: %w", err) + } + out := make([]listedModel, 0, len(doc.Data)) + for _, entry := range doc.Data { + out = append(out, listedModel{id: entry.ID, label: entry.DisplayName}) + } + return out, nil +} + +// parseBedrockInferenceProfiles reads +// {"inferenceProfileSummaries":[{"inferenceProfileId":…}]}. +// +// The profile id is taken verbatim because its region prefix (eu., us., +// global.) is what makes it invocable, and it is not derivable from the +// configured region — an account in one region legitimately holds global.* +// profiles alongside its regional ones. +// +// Only ACTIVE profiles are offered: AWS reports others, and registering one +// would produce a model that routes inside NetBird and fails at AWS. +func parseBedrockInferenceProfiles(body []byte) ([]listedModel, error) { + var doc struct { + Summaries []struct { + ID string `json:"inferenceProfileId"` + Name string `json:"inferenceProfileName"` + Status string `json:"status"` + } `json:"inferenceProfileSummaries"` + } + if err := json.Unmarshal(body, &doc); err != nil { + return nil, fmt.Errorf("decode inference-profile listing: %w", err) + } + out := make([]listedModel, 0, len(doc.Summaries)) + for _, entry := range doc.Summaries { + if entry.Status != "" && !strings.EqualFold(entry.Status, "ACTIVE") { + continue + } + out = append(out, listedModel{id: entry.ID, label: entry.Name}) + } + return out, nil +} + +// parseVertexPublisherModels reads {"publisherModels":[{"name":…}]}, where +// name is a resource path ("publishers/anthropic/models/claude-3-opus") and +// the version lives in a separate field. +// +// Vertex addresses a model as "@" on the rawPredict path, so the +// two are joined here: reporting the bare name would hand the operator an id +// that looks usable and is not. +func parseVertexPublisherModels(body []byte) ([]listedModel, error) { + var doc struct { + Models []struct { + Name string `json:"name"` + VersionID string `json:"versionId"` + } `json:"publisherModels"` + } + if err := json.Unmarshal(body, &doc); err != nil { + return nil, fmt.Errorf("decode publisher-model listing: %w", err) + } + out := make([]listedModel, 0, len(doc.Models)) + for _, entry := range doc.Models { + id := entry.Name + if slash := strings.LastIndex(id, "/"); slash >= 0 { + id = id[slash+1:] + } + if id == "" { + continue + } + label := id + if entry.VersionID != "" { + id += "@" + entry.VersionID + } + out = append(out, listedModel{id: id, label: label}) + } + return out, nil +} + +// normalizeForPricing maps a vendor's wire id onto the key the catalog prices +// it under. It mirrors the synthesiser's normalizePricingModelID: the two must +// agree, or a model reported here as priced would meter at the default rate +// instead of the operator's. +func normalizeForPricing(catalogProviderID, modelID string) string { + switch { + case catalog.IsBedrockPathStyle(catalogProviderID): + return sharedllm.NormalizeBedrockModel(modelID) + case catalog.IsVertexPathStyle(catalogProviderID): + return sharedllm.NormalizeVertexModel(modelID) + default: + return modelID + } +} diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index bfceadeef..3ab5a2e42 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -5335,6 +5335,84 @@ components: - input_per_1k - output_per_1k - context_window + AgentNetworkModelDiscoveryRequest: + type: object + properties: + catalog_provider_id: + type: string + description: Catalog provider to query (AgentNetworkCatalogProvider.id). Determines the listing endpoint, the auth header and the response shape. + example: "bedrock_api" + upstream_url: + type: string + description: | + The upstream being configured. Used to reach vendors that serve their listing from the same host as inference, and to read back the region for those whose host embeds one. Ignored when provider_id is supplied. + example: "https://bedrock-runtime.eu-central-1.amazonaws.com" + api_key: + type: string + description: Credential to query the vendor with, for a provider that has not been saved yet. Mutually exclusive with provider_id. + example: "sk-..." + provider_id: + type: string + description: Existing Agent Network provider record whose stored credential and upstream should be used. Lets the form refresh the list without the client holding the key. + example: "ch8i4ug6lnn4g9hqv7m0" + required: + - catalog_provider_id + AgentNetworkModelDiscoveryResponse: + type: object + properties: + models: + type: array + description: Models the credential can reach, in the order the vendor returned them. + items: + $ref: '#/components/schemas/AgentNetworkDiscoveredModel' + required: + - models + AgentNetworkDiscoveredModel: + type: object + properties: + id: + type: string + description: | + 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. + example: "eu.anthropic.claude-haiku-4-5-20251001-v1:0" + label: + type: string + description: Vendor-supplied display name, where the vendor supplies one. + 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 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: @@ -14004,6 +14082,42 @@ paths: "$ref": "#/components/responses/forbidden" '500': "$ref": "#/components/responses/internal_error" + /api/agent-network/catalog/providers/models: + post: + summary: Discover the models a provider credential can reach + description: | + Asks the vendor which models the supplied credential can actually use, so the provider form can offer a live list instead of only the static catalog. The endpoint, auth header and response shape are taken from the catalog entry, never from the request. + + Supply either an api_key together with the upstream_url being configured (before the provider is saved), or a provider_id of an existing record to reuse its stored credential. + + Returns 422 for a catalog provider that has no listing endpoint (most gateways); the caller should fall back to the catalog's own model list. A model whose price the shipped table does not know is returned with pricing_known false, and the operator must set rates for it. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkModelDiscoveryRequest' + responses: + '200': + description: The models the credential can reach + content: + application/json: + schema: + $ref: '#/components/schemas/AgentNetworkModelDiscoveryResponse' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '422': + "$ref": "#/components/responses/validation_failed_simple" + '500': + "$ref": "#/components/responses/internal_error" /api/agent-network/providers: get: summary: List all Agent Network Providers diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 04e04a24f..db5b2e18e 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -2120,6 +2120,33 @@ type AgentNetworkConsumption struct { // AgentNetworkConsumptionDimensionKind Whether this row counts a single end user or a single source group across every member. 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"` + + // 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"` +} + // AgentNetworkGuardrail defines model for AgentNetworkGuardrail. type AgentNetworkGuardrail struct { // Checks Guardrail check parameters. Each entry has an `enabled` flag plus per-check configuration; disabled entries are inert. @@ -2167,6 +2194,27 @@ type AgentNetworkGuardrailRequest struct { Name string `json:"name"` } +// AgentNetworkModelDiscoveryRequest defines model for AgentNetworkModelDiscoveryRequest. +type AgentNetworkModelDiscoveryRequest struct { + // ApiKey Credential to query the vendor with, for a provider that has not been saved yet. Mutually exclusive with provider_id. + ApiKey *string `json:"api_key,omitempty"` + + // CatalogProviderId Catalog provider to query (AgentNetworkCatalogProvider.id). Determines the listing endpoint, the auth header and the response shape. + CatalogProviderId string `json:"catalog_provider_id"` + + // ProviderId Existing Agent Network provider record whose stored credential and upstream should be used. Lets the form refresh the list without the client holding the key. + ProviderId *string `json:"provider_id,omitempty"` + + // UpstreamUrl The upstream being configured. Used to reach vendors that serve their listing from the same host as inference, and to read back the region for those whose host embeds one. Ignored when provider_id is supplied. + UpstreamUrl *string `json:"upstream_url,omitempty"` +} + +// AgentNetworkModelDiscoveryResponse defines model for AgentNetworkModelDiscoveryResponse. +type AgentNetworkModelDiscoveryResponse struct { + // Models Models the credential can reach, in the order the vendor returned them. + Models []AgentNetworkDiscoveredModel `json:"models"` +} + // AgentNetworkPolicy defines model for AgentNetworkPolicy. type AgentNetworkPolicy struct { // CreatedAt Timestamp when the policy was created. @@ -6179,6 +6227,9 @@ type PostApiAgentNetworkBudgetRulesJSONRequestBody = AgentNetworkBudgetRuleReque // PutApiAgentNetworkBudgetRulesRuleIdJSONRequestBody defines body for PutApiAgentNetworkBudgetRulesRuleId for application/json ContentType. type PutApiAgentNetworkBudgetRulesRuleIdJSONRequestBody = AgentNetworkBudgetRuleRequest +// PostApiAgentNetworkCatalogProvidersModelsJSONRequestBody defines body for PostApiAgentNetworkCatalogProvidersModels for application/json ContentType. +type PostApiAgentNetworkCatalogProvidersModelsJSONRequestBody = AgentNetworkModelDiscoveryRequest + // PostApiAgentNetworkGuardrailsJSONRequestBody defines body for PostApiAgentNetworkGuardrails for application/json ContentType. type PostApiAgentNetworkGuardrailsJSONRequestBody = AgentNetworkGuardrailRequest