mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-25 09:01:29 +02:00
[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.
135 lines
4.4 KiB
Go
135 lines
4.4 KiB
Go
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 "<id>@<version>" 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
|
|
}
|
|
}
|