From c351ca882f917133db5c25e2d1c37df024bda517 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Wed, 19 Aug 2026 09:52:56 +0000 Subject: [PATCH] [management] Expose live model discovery on the provider API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds POST /api/agent-network/catalog/providers/models, so the provider form can offer the models an operator's own credential can reach instead of only the compiled-in catalog. A caller names a catalog provider and supplies either the key they are typing (the record does not exist yet) or the id of a saved record whose stored credential should be reused — which lets the dashboard refresh a list without ever holding the key. The two are mutually exclusive: accepting both would run an arbitrary credential under the identity of a record the caller may only be permitted to read. When a record id is given, the catalog id and upstream come from the record too, so the credential cannot be aimed at a different vendor's endpoint. 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. A provider with no listing endpoint answers 422 rather than 500: the caller falls back to the catalog's own models on that outcome, so it has to be distinguishable from a failure. The region is read back out of the configured upstream by matching it against the catalog's host template, since a provider record has no region field and the operator already encoded one when they set up inference. An upstream matching no template is refused rather than guessed at — a wrong region would dial another account's endpoint. --- .../handlers/model_discovery_handler_test.go | 137 ++++++++++++++++++ .../handlers/providers_handler.go | 70 +++++++++ .../internals/modules/agentnetwork/manager.go | 43 ++++++ .../agentnetwork/modeldiscovery/discovery.go | 38 ++++- .../modeldiscovery/discovery_test.go | 50 ++++++- shared/management/http/api/openapi.yml | 87 +++++++++++ shared/management/http/api/types.gen.go | 36 +++++ 7 files changed, 457 insertions(+), 4 deletions(-) create mode 100644 management/internals/modules/agentnetwork/handlers/model_discovery_handler_test.go 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..df53612c9 --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/model_discovery_handler_test.go @@ -0,0 +1,137 @@ +package handlers + +import ( + "context" + "encoding/json" + "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"}, + }} + + 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, 2) + + 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) + + assert.Equal(t, "bedrock_api", stub.gotReq.CatalogID) + assert.Equal(t, "aws-bearer", stub.gotReq.APIKey) + 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) +} + +func TestDiscoverModelsRejectsMalformedRequests(t *testing.T) { + for name, body := range map[string]string{ + "not json": `{`, + "no catalog provider": `{"api_key":"sk"}`, + "blank catalog provide": `{"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..54b317442 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,73 @@ 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 + } + if strings.TrimSpace(body.CatalogProviderId) == "" { + util.WriteErrorResponse("catalog_provider_id is required", http.StatusBadRequest, w) + return + } + + recordID := strValue(body.ProviderId) + req := modeldiscovery.Request{ + CatalogID: body.CatalogProviderId, + 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 + } + 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} + 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..c380a8d57 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,11 @@ 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. + 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 +158,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 +178,37 @@ 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. Reading a stored credential is a read +// of that provider, and is permission-checked as one. +// +// 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. +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 +1056,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 index b57f1c61d..7fdef7c6f 100644 --- a/management/internals/modules/agentnetwork/modeldiscovery/discovery.go +++ b/management/internals/modules/agentnetwork/modeldiscovery/discovery.go @@ -168,7 +168,13 @@ func (c *Client) discoveryURL(entry catalog.Provider, req Request) (string, erro if strings.Contains(host, catalog.RegionPlaceholder) { region := strings.TrimSpace(req.Region) if region == "" { - return "", fmt.Errorf("%s discovery needs a region", entry.Name) + // 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("%s discovery needs a region, and none could be read from the provider upstream", entry.Name) } host = strings.ReplaceAll(host, catalog.RegionPlaceholder, region) } @@ -180,6 +186,36 @@ func (c *Client) discoveryURL(entry catalog.Provider, req Request) (string, erro 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) + } + if !strings.HasPrefix(host, prefix) || !strings.HasSuffix(host, 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 { diff --git a/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go b/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go index fbb58821d..23827bbf6 100644 --- a/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go +++ b/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go @@ -203,14 +203,17 @@ func TestFetchRequiresACredential(t *testing.T) { 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-runtime.eu-central-1.amazonaws.com", + UpstreamURL: "https://bedrock.internal-proxy.example.com", APIKey: "aws-bearer", }) require.Error(t, err) - assert.Contains(t, err.Error(), "region", - "an unsubstituted placeholder would dial a host that does not exist") + assert.Contains(t, err.Error(), "region") } // TestHostGuardRejectsNonPublicAddresses is the SSRF guard. Management holds a @@ -275,3 +278,44 @@ func ids(models []Model) []string { } 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", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, regionFromUpstream(tc.entry, tc.upstream)) + }) + } +} diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index bfceadeef..a4c6b6b03 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -5335,6 +5335,57 @@ 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 operator must set input/output rates, or requests to it would record a cost of zero. + example: true + required: + - id + - pricing_known AgentNetworkCatalogProvider: type: object properties: @@ -14004,6 +14055,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..3aad96eb0 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -2120,6 +2120,18 @@ 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 { + // 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"` + + // 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. + 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 +2179,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 +6212,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