mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-28 18:41:30 +02:00
338 lines
9.5 KiB
Go
338 lines
9.5 KiB
Go
// Package modeldiscovery fetches and normalizes model catalogs from
|
|
// Agent Network provider endpoints. Discovery always runs on the proxy so
|
|
// it observes the same network path as inference traffic.
|
|
package modeldiscovery
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
"github.com/netbirdio/netbird/proxy/internal/roundtrip"
|
|
)
|
|
|
|
const (
|
|
SourceOpenAIV1Models = "openai_v1_models"
|
|
SourceOllamaAPITags = "ollama_api_tags"
|
|
|
|
defaultTimeout = 5 * time.Second
|
|
maxResponseBytes = 1 << 20 // 1 MiB, after HTTP decompression.
|
|
maxUpstreamURLBytes = 4096
|
|
maxHeaderNameBytes = 256
|
|
maxHeaderValueBytes = 64 << 10
|
|
maxModels = 500
|
|
maxModelIDBytes = 512
|
|
)
|
|
|
|
// Request contains the provider-owned values management resolved from the
|
|
// persisted provider record. Callers must not populate these fields from a
|
|
// dashboard-supplied URL or credential.
|
|
type Request struct {
|
|
UpstreamURL string
|
|
AuthHeaderName string
|
|
AuthHeaderValue string
|
|
SkipTLSVerify bool
|
|
AllowOllamaFallback bool
|
|
}
|
|
|
|
// Model is the deliberately small response surface returned to management.
|
|
// Arbitrary fields supplied by an upstream never cross the control channel.
|
|
type Model struct {
|
|
ID string
|
|
Label string
|
|
}
|
|
|
|
// Result is a normalized model catalog and the endpoint shape that supplied
|
|
// it.
|
|
type Result struct {
|
|
Models []Model
|
|
Source string
|
|
}
|
|
|
|
// Discoverer owns the HTTP client used for provider probes.
|
|
type Discoverer struct {
|
|
client *http.Client
|
|
timeout time.Duration
|
|
bodyLimit int64
|
|
}
|
|
|
|
// New constructs a direct-upstream discoverer. The transport is the same
|
|
// host-network transport family used by Agent Network inference routes.
|
|
func New(logger *log.Logger) *Discoverer {
|
|
return newWithTransport(roundtrip.NewDirectOnly(logger))
|
|
}
|
|
|
|
func newWithTransport(transport http.RoundTripper) *Discoverer {
|
|
return &Discoverer{
|
|
client: &http.Client{
|
|
Transport: transport,
|
|
// Redirects could move a credentialed request away from the
|
|
// persisted provider origin. Discovery never follows them.
|
|
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
},
|
|
timeout: defaultTimeout,
|
|
bodyLimit: maxResponseBytes,
|
|
}
|
|
}
|
|
|
|
// Discover queries the OpenAI-compatible model-list endpoint. Ollama's native
|
|
// tags endpoint is attempted only when explicitly enabled and the primary
|
|
// endpoint reports that the route does not exist.
|
|
func (d *Discoverer) Discover(ctx context.Context, in Request) (Result, error) {
|
|
if d == nil || d.client == nil {
|
|
return Result{}, errors.New("model discovery client is unavailable")
|
|
}
|
|
if err := validateRequest(in); err != nil {
|
|
return Result{}, err
|
|
}
|
|
|
|
timeout := d.timeout
|
|
if timeout <= 0 || timeout > defaultTimeout {
|
|
timeout = defaultTimeout
|
|
}
|
|
probeCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
models, err := d.fetchOpenAIModels(probeCtx, in)
|
|
if err == nil {
|
|
return Result{Models: models, Source: SourceOpenAIV1Models}, nil
|
|
}
|
|
if !in.AllowOllamaFallback || !isMissingEndpoint(err) {
|
|
return Result{}, err
|
|
}
|
|
|
|
models, err = d.fetchOllamaTags(probeCtx, in)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
return Result{Models: models, Source: SourceOllamaAPITags}, nil
|
|
}
|
|
|
|
func validateRequest(in Request) error {
|
|
rawURL := strings.TrimSpace(in.UpstreamURL)
|
|
if rawURL == "" {
|
|
return errors.New("model discovery upstream URL is required")
|
|
}
|
|
if len(rawURL) > maxUpstreamURLBytes {
|
|
return errors.New("model discovery upstream URL is too long")
|
|
}
|
|
if len(in.AuthHeaderName) > maxHeaderNameBytes || len(in.AuthHeaderValue) > maxHeaderValueBytes {
|
|
return errors.New("model discovery authentication header is too large")
|
|
}
|
|
if (in.AuthHeaderName == "") != (in.AuthHeaderValue == "") {
|
|
return errors.New("model discovery authentication header is incomplete")
|
|
}
|
|
if strings.ContainsAny(in.AuthHeaderName, "\r\n") || strings.ContainsAny(in.AuthHeaderValue, "\r\n") {
|
|
return errors.New("model discovery authentication header is invalid")
|
|
}
|
|
if in.AuthHeaderName != "" && !strings.EqualFold(in.AuthHeaderName, "Authorization") {
|
|
return errors.New("model discovery authentication header is unsupported")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (d *Discoverer) fetchOpenAIModels(ctx context.Context, in Request) ([]Model, error) {
|
|
body, err := d.fetch(ctx, in, "v1/models")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var payload struct {
|
|
Data *[]struct {
|
|
ID string `json:"id"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(body, &payload); err != nil || payload.Data == nil {
|
|
return nil, errors.New("upstream returned invalid OpenAI model-list JSON")
|
|
}
|
|
|
|
ids := make([]string, 0, len(*payload.Data))
|
|
for _, model := range *payload.Data {
|
|
ids = append(ids, model.ID)
|
|
}
|
|
return normalize(ids)
|
|
}
|
|
|
|
func (d *Discoverer) fetchOllamaTags(ctx context.Context, in Request) ([]Model, error) {
|
|
body, err := d.fetch(ctx, in, "api/tags")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var payload struct {
|
|
Models *[]struct {
|
|
Name string `json:"name"`
|
|
Model string `json:"model"`
|
|
} `json:"models"`
|
|
}
|
|
if err := json.Unmarshal(body, &payload); err != nil || payload.Models == nil {
|
|
return nil, errors.New("upstream returned invalid Ollama tags JSON")
|
|
}
|
|
|
|
ids := make([]string, 0, len(*payload.Models))
|
|
for _, model := range *payload.Models {
|
|
id := model.Model
|
|
if strings.TrimSpace(id) == "" {
|
|
id = model.Name
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
return normalize(ids)
|
|
}
|
|
|
|
func (d *Discoverer) fetch(ctx context.Context, in Request, endpointPath string) ([]byte, error) {
|
|
endpoint, err := buildEndpointURL(in.UpstreamURL, endpointPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
reqCtx := roundtrip.WithDirectUpstream(ctx)
|
|
if in.SkipTLSVerify {
|
|
reqCtx = roundtrip.WithSkipTLSVerify(reqCtx)
|
|
}
|
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, endpoint.String(), nil)
|
|
if err != nil {
|
|
return nil, errors.New("create model discovery request")
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
if in.AuthHeaderName != "" {
|
|
// Discovery is currently enabled only for Ollama-compatible providers.
|
|
// Canonicalizing the sole catalog-owned credential header keeps the
|
|
// control message from becoming a generic arbitrary-header primitive.
|
|
req.Header.Set("Authorization", in.AuthHeaderValue)
|
|
}
|
|
|
|
resp, err := d.client.Do(req)
|
|
if err != nil {
|
|
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
|
return nil, errors.New("model discovery timed out")
|
|
}
|
|
if errors.Is(ctx.Err(), context.Canceled) {
|
|
return nil, context.Canceled
|
|
}
|
|
// Deliberately omit the underlying error: net/http errors include the
|
|
// internal URL, which should not be reflected through the public API.
|
|
return nil, errors.New("model discovery request failed")
|
|
}
|
|
defer func() {
|
|
_ = resp.Body.Close()
|
|
}()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, &upstreamStatusError{statusCode: resp.StatusCode}
|
|
}
|
|
|
|
limit := d.bodyLimit
|
|
if limit <= 0 || limit > maxResponseBytes {
|
|
limit = maxResponseBytes
|
|
}
|
|
if resp.ContentLength > limit {
|
|
return nil, errors.New("model discovery response is too large")
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
|
|
if err != nil {
|
|
return nil, errors.New("read model discovery response")
|
|
}
|
|
if int64(len(body)) > limit {
|
|
return nil, errors.New("model discovery response is too large")
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
func buildEndpointURL(rawURL, endpointPath string) (*url.URL, error) {
|
|
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
|
if err != nil || parsed.Host == "" || parsed.Hostname() == "" || parsed.Opaque != "" {
|
|
return nil, errors.New("model discovery upstream URL is invalid")
|
|
}
|
|
switch strings.ToLower(parsed.Scheme) {
|
|
case "http":
|
|
parsed.Scheme = "http"
|
|
case "https":
|
|
parsed.Scheme = "https"
|
|
default:
|
|
return nil, errors.New("model discovery upstream URL must use http or https")
|
|
}
|
|
if parsed.User != nil {
|
|
return nil, errors.New("model discovery upstream URL must not contain credentials")
|
|
}
|
|
|
|
// Match Agent Network routing semantics: the static discovery path is
|
|
// appended to any persisted base path. Queries and fragments on a provider
|
|
// URL are not forwarded to inference and are likewise excluded here.
|
|
parsed.Path = "/" + strings.TrimPrefix(path.Join(parsed.Path, endpointPath), "/")
|
|
parsed.RawPath = ""
|
|
parsed.RawQuery = ""
|
|
parsed.ForceQuery = false
|
|
parsed.Fragment = ""
|
|
return parsed, nil
|
|
}
|
|
|
|
func normalize(ids []string) ([]Model, error) {
|
|
if len(ids) > maxModels {
|
|
return nil, errors.New("upstream returned too many models")
|
|
}
|
|
|
|
seen := make(map[string]struct{}, len(ids))
|
|
normalized := make([]string, 0, len(ids))
|
|
for _, raw := range ids {
|
|
id := strings.TrimSpace(raw)
|
|
if !validModelID(id) {
|
|
continue
|
|
}
|
|
if _, ok := seen[id]; ok {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
normalized = append(normalized, id)
|
|
}
|
|
sort.Strings(normalized)
|
|
|
|
models := make([]Model, 0, len(normalized))
|
|
for _, id := range normalized {
|
|
models = append(models, Model{ID: id, Label: id})
|
|
}
|
|
return models, nil
|
|
}
|
|
|
|
func validModelID(id string) bool {
|
|
if id == "" || len(id) > maxModelIDBytes || !utf8.ValidString(id) {
|
|
return false
|
|
}
|
|
for _, r := range id {
|
|
if unicode.IsControl(r) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
type upstreamStatusError struct {
|
|
statusCode int
|
|
}
|
|
|
|
func (e *upstreamStatusError) Error() string {
|
|
return fmt.Sprintf("upstream returned HTTP %d", e.statusCode)
|
|
}
|
|
|
|
func isMissingEndpoint(err error) bool {
|
|
var statusErr *upstreamStatusError
|
|
if !errors.As(err, &statusErr) {
|
|
return false
|
|
}
|
|
return statusErr.statusCode == http.StatusNotFound || statusErr.statusCode == http.StatusMethodNotAllowed
|
|
}
|