mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-27 01:51:30 +02:00
The provider form accepted anything and found out later. A typo in the upstream, a key pasted a character short, an AWS access key in a field that wants a Bedrock API key — all saved cleanly, then surfaced minutes later as a failed request or an empty model picker, with nothing pointing back at the record that caused it. CreateProvider now spends the credential once against the vendor's own model listing, and UpdateProvider does the same when the upstream or the key changed — only then, so renames, model rows and price edits neither wait on a vendor nor fail because one is having a bad day. Both run before the store write, so a rejected rotation leaves the working key exactly where it was. The check reuses the discovery Fetch rather than a lighter status probe. It exercises the path the model picker will take, so a URL answering 200 with a login page fails here instead of passing a status check and producing an empty picker later. Failures that mean "we cannot ask" are not failures: a gateway with no listing endpoint, a Bedrock record behind a proxy where no control-plane host can be derived, and a self-hosted endpoint on a private network that the proxy reaches through the tunnel but management cannot. None of those are evidence the record is wrong, and refusing them would make this a lockout. Discovery failures are typed for it. The message an operator sees carries no status code and never echoes their URL — WriteError lowercases it, and paths are case-sensitive, so an echoed URL would come back altered and describe something they did not type. The vendor's status is logged instead.
135 lines
4.5 KiB
Go
135 lines
4.5 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("%w: decode model listing: %w", ErrUnparseableListing, 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("%w: decode inference-profile listing: %w", ErrUnparseableListing, 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("%w: decode publisher-model listing: %w", ErrUnparseableListing, 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
|
|
}
|
|
}
|