mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-01 11:09:15 +02:00
* [management,proxy] Agent network: per-account LLM gateway (policy, metering, multi-provider) (#6555) * [agent-network] Shared proto, OpenAPI schema, and generated types * [agent-network] Management: store, manager, synthesizer, policy engine, provider catalog, HTTP/gRPC API Adds the account-scoped agent-network module: provider/policy/budget CRUD and store, the reverse-proxy service synthesizer, policy selection + limit enforcement, the provider catalog (incl. Vertex AI and AWS Bedrock entries), and the management HTTP + proxy gRPC surfaces. * [management] Fix agent-network proxy-peer fan-out on affected-peer recompute The affected-peers resolver loaded only persisted reverse-proxy services, but agent-network services are synthesized on demand and never persisted. As a result the embedded proxy peer was never folded into the affected set when a client's group changed, so the proxy received no network-map update for a newly authorised client and rejected its handshake until a full resync (restart). loadProxyServices now merges the synthesized agent-network services (injected via a registration hook to avoid an import cycle), so proxy peers learn newly authorised clients immediately. * [proxy] Reverse-proxy middleware framework, chain, and request plumbing The per-target middleware chain (slots, dispatcher, mutation gate, metadata merger), body capture, access-log terminal sink, and the proxy wiring that builds + runs chains for synthesized agent-network services. * [proxy] LLM parsers, pricing, and builtin middlewares (OpenAI, Anthropic, Vertex AI, AWS Bedrock) Request/response parsers and SSE/event-stream metering, the embedded pricing table, and the builtin middleware set: request parser, router, policy limit-check/record, cost meter, guardrail, identity inject, response parser. Includes the path-routed providers — Google Vertex AI (keyfile:: service-account OAuth minting) and AWS Bedrock (bearer auth, invoke/converse/streaming, optional /bedrock prefix) — plus the Models allowlist and unmeterable-publisher deny. * [proxy] IPv6 in-place apply and TCP accept-loop hardening on netstack listeners * [agent-network] End-to-end test suite, module docs, and deployment preset * [agent-network] Fix codespell typos and exclude false positives - labelgen word pool: vermillion -> vermilion, racoon -> raccoon. - codespell ignore list: add flate (Go compress/flate package), recordin (a test-local identifier), and unparseable (a valid alternative spelling used consistently across identifiers + a metadata-value constant). * [management] Set LastSeen on injected proxy peer in realstack test (MySQL strict-mode) The injected embedded proxy peer had a PeerStatus with a zero LastSeen, which serializes to '0000-00-00' and is rejected by MySQL in strict mode (SQLite tolerates it). Set LastSeen to a valid time so SaveAccount succeeds on both engines. * [agent-network] Remove e2e shell-script suite from this branch The end-to-end shell scripts under scripts/e2e/ are maintained in a separate testing suite and are not part of this change set. * [agent-network] Polish module docs: remove internal review scaffolding, fix links, verify diagrams Strip PR-review framing, commit references, absolute paths, and stale internal references from the agent-network module docs; fix broken relative links; verify all diagrams against the current architecture. Remove the internal AI-reviewer prompt file. * [management] Refine session expiration handling to support 3-state encoding for SSO deadlines * [agent-network] Relocate agentnetwork package to internals/modules Move management/server/agentnetwork (and its catalog/, labelgen/, types/ subpackages) to management/internals/modules/agentnetwork, alongside the reverse-proxy module, and rewrite all importers. Pure relocation: package names, the synthesizer + affectedpeers registration hook, and store access (shared store.Store) are unchanged, so no import cycle is introduced (affectedpeers still depends only on the agentnetwork/types leaf). * [agent-network] Co-locate HTTP handlers in the module (RegisterEndpoints) Move the agent-network HTTP handlers from server/http/handlers/agentnetwork into the module at internals/modules/agentnetwork/handlers (package handlers) and rename the entrypoint AddEndpoints -> RegisterEndpoints, matching the reverse-proxy module convention. Wiring in http/handler.go updated accordingly. * Update getting started to point to rc when agent network enabled * Add a reference to a commercial license * Fix docs localhost link * Fix docs localhost link * Add private services domain note * [management] Add agent-network telemetry metrics (#6561) Surface agent-network adoption and usage in the self-hosted metrics worker: distinct accounts, providers, policies, budget rules, accounts with log collection enabled, and aggregated input/output tokens plus cost. Tokens and cost are summed from agent_network_request_usage (the always-written per-request ledger) so the figures are accurate regardless of the log-collection toggle and carry no double-counting. All values come from a handful of indexed aggregate queries run only on the worker's periodic tick. Adds store.AgentNetworkMetrics with GetAgentNetworkMetrics on the Store interface, the SqlStore implementation, and a zero-valued FileStore stub. * Update NetBird server and proxy image versions to 0.74.0-rc.2 * [management,proxy] Reduce agent-network cognitive complexity (#6566) Address the SonarCloud quality-gate findings in new agent-network code by extracting focused helpers. No behavior change. - synthesizer.go: split buildIdentityInjectConfigJSON into per-shape rule builders; extract mergeGuardrail from mergeGuardrails to cut nesting depth. - llm_identity_inject: extract injectionEmitsAnything validation predicate from New. - llm_response_parser/streaming.go: extract applyOpenAIStreamUsage and applyAnthropicStreamUsage (via a named anthropicStreamUsage type) and simplify the OpenAI scanner loop. - reverseproxy.go: decompose ServeHTTP into serveRouteError, buildTargetContext, serveDirect, serveWithChain, captureRequestForChain, serveDeny, newResponseWriter, observeResponse, and forwardUpstream, preserving the defer ordering so response observation still reads the captured writer before it is released. * [management] Move agent-network access-log ingest into the agentnetwork module (#6568) The agent-network access-log ingest path (metaKey wire contract, flatten, usage derivation, and the dual-write of the usage ledger + settings-gated full row) lived in the reverseproxy accesslogs manager, even though the agentnetwork module already owns the rest of that domain — types, read (ListAccessLogs / GetUsageOverview), the budget-counter writes, and retention cleanup. Move it next to the rest: a stateless agentnetwork.IngestAccessLog(ctx, store, entry) that the reverseproxy SaveAccessLog delegates to when the entry is agent-network. Removes the agentNetworkTypes import from the reverseproxy manager. No behavior change; the write/read table separation is unchanged. Adds real-store coverage for the disable->enable log-collection toggle (usage ledger always written, full row gated) plus the metadata parse and group-dedup helpers, which previously had no dedicated tests. * Add session view support in the access log * [management,proxy] Container-based agent-network e2e harness (#6577) * [e2e] Add container-based agent-network e2e harness (Pillar 1) Introduce a self-contained, OIDC-free e2e harness that stands up NetBird in containers, so suites no longer depend on the hand-maintained Tilt stack or a real IdP. - harness brings up the combined server (management + signal + relay + STUN + embedded IdP) in a single container built from combined/Dockerfile.multistage, and mints an admin PAT through the unauthenticated /api/setup bootstrap (NB_SETUP_PAT_ENABLED). API access goes through the existing shared/management/client/rest typed client. - the image is built via the docker CLI (BuildKit) so the Dockerfile's cache mounts are honored; testcontainers then runs the tagged image. - everything is behind the `e2e` build tag so normal builds and unit tests never pull in testcontainers. Adds BuildKit cache mounts to combined/Dockerfile.multistage so source changes recompile incrementally rather than from scratch. Pillar 1 proven by TestCombinedBootstrap: server builds, boots, mints a PAT, and the PAT authenticates a real management API call. * [e2e] Add management-side agent-network scenarios (Pillar 2) Port the API-driven agent-network scenarios from the bash suites to Go, sharing one combined server per package run (TestMain) with each test owning its resource cleanup. Drives the /api/agent-network/* endpoints through the shared REST client's NewRequest primitive with the generated api types. Scenarios: - provider lifecycle (create/get/list/delete + 404 after delete) - provider validation (missing api_key, unknown catalog id → 4xx) - settings collection-toggle round-trip with cluster/subdomain immutability - policy window floor (reject <60s enabled limit, accept at 60s) - consumption read endpoint returns an array All deterministic and dependency-free (dummy provider keys; no upstream calls), so they run headless in CI. * [e2e] Add live chat-through-proxy scenario (Pillar 3) Stand up the full agent-network data path in containers and drive a real chat-completion through the gateway: - harness: a shared docker network (combined server reachable by alias), a proxy container built from the published reverse-proxy image (NB_PROXY_PRIVATE, NB_PROXY_ALLOW_INSECURE, NB_RELAY_TRANSPORT=ws to match the combined server's WS-multiplexed relay) with a generated self-signed wildcard cert, and a netbird client container that joins via a setup key. - the combined image, proxy image, and client image default to the published rc.2 releases (overridable via NB_E2E_*_IMAGE; a bare local tag is built from source instead). Geolocation download is disabled so the server starts without external fetches. - one shared domain is used for the management exposed address, the proxy domain, and the agent-network cluster; the proxy token is minted via the server CLI (global) to match the manual install. TestChatCompletionThroughProxy provisions provider+policy+group+setup key, runs proxy+client, drives an OpenAI chat-completion through the tunnel, and asserts a 200 plus the ingested access-log row. Requires OPENAI_TOKEN (skips otherwise). The provider must be created with enabled=true explicitly — the create default is false despite the API doc. * [e2e] Run the live chat scenario across a provider matrix Replace the single-provider chat test with a data-driven matrix that runs the same scenario through every provider whose credentials are present in the environment (keys/URLs sourced from ~/.llm-keys locally, Actions secrets in CI): - OpenAI (chat), Anthropic (messages), Vercel, OpenRouter, Cloudflare (OpenAI-compatible gateways), and Bedrock (path-routed, bearer, via the messages shape) — covering both wire shapes and the gateway routing. - all providers are created enabled with a unique model string so the proxy's connect-time snapshot carries them all and model->provider routing is unambiguous (provider toggles after connect don't reconcile to a connected proxy). - the client supports both wire shapes (/v1/chat/completions and /v1/messages); Cloudflare gets the openai provider segment appended to its gateway URL. Each provider must return 200 through the tunnel and produce an ingested access-log row. Vertex is intentionally excluded from the uniform matrix: it needs a bespoke rawPredict request shape rather than the shared chat/messages path, so it warrants a dedicated scenario. * [ci] Add manual workflow for the agent-network e2e suite The e2e suite (build tag `e2e`) stands up the combined server + proxy + client in Docker and drives live chat-completions, so it is slow and needs provider credentials. Gate it out of normal CI (it already is, via the build tag) and run it on demand via workflow_dispatch. Provider scenarios skip when their secret is unset, so it degrades gracefully. * [e2e] Add Vertex to the provider matrix; run e2e on ubuntu-latest Vertex (Anthropic-on-Vertex) doesn't share the chat/messages wire shapes: the model travels in a rawPredict path and the proxy mints the service account's OAuth token. Add a Vertex client method that posts /v1/projects/<project>/locations/<region>/publishers/anthropic/models/<model>:rawPredict with the Vertex anthropic_version body, and wire it into the matrix as a path-routed provider (created without a models array). It is keyed off GOOGLE_VERTEX_SA_BASE64 + GOOGLE_VERTEX_PROJECT (region defaults to "global", model to a pinned claude snapshot, both overridable). Also bump the e2e workflow runner to ubuntu-latest and add the Vertex secrets. * Add docker/docker and docker/go-connections as direct dependencies in go.mod * [ci] Trigger agent-network e2e workflow on push to main and pull requests * [e2e] Fix proxy cert permission denied on Linux CI runners The proxy bind-mounts a temp dir of self-signed certs. MkdirTemp creates it 0700 and the key was 0600, which Docker Desktop on macOS ignores but a non-root proxy container on Linux runners cannot traverse/read, so the cert watcher failed with "open /certs/tls.crt: permission denied" and the container exited. Widen the cert dir to 0755 and write the throwaway key 0644 so the proxy uid can read the bind-mounted material. * [e2e] Build images from source by default instead of pulling rc.2 The agent-network code under test lives in this branch, so the e2e should exercise it rather than a frozen published release. Flip the harness default: combined/proxy/client are now built from their in-repo Dockerfiles (combined/Dockerfile.multistage, proxy/Dockerfile.multistage, e2e/harness/Dockerfile.client) under local tags. Pulling a published image stays available by setting NB_E2E_*_IMAGE to a registry reference. Builds now go through buildx --load so the Dockerfile cache mounts are honored and the result is loaded for testcontainers. The CI workflow adds a container-driver builder and a local layer cache (NB_E2E_BUILDX_CACHE) persisted via actions/cache, which caches the base/apt/dep-download layers across runs. The Go compile still re-runs each time, as BuildKit mount caches cannot be exported to the GitHub cache. * [e2e] Cover real providers in lifecycle + assert real consumption metering - TestProviderLifecycle now runs per available real provider (create → get → list → delete → 404) instead of a single dummy provider, exercising each catalog's create and field round-trip. Create is offline, so it stays fast and burns no provider quota; falls back to a synthetic OpenAI provider when no keys are set. - TestProvidersMatrix attaches a token limit (high caps, 60s window) to its policy, which switches on usage metering, and asserts consumption rows are recorded with positive token counts after the live traffic. Consumption is account-scoped (keyed by source group / user and window, not per provider), so the assertion is aggregate. - TestProviderValidation gains invalid-upstream and blank-name cases. Create validation is uniform across catalogs (no per-provider required-field rules), so per-provider rejection cases would be redundant. * [e2e] Assert session id propagates per provider Each matrix request now sends a unique session id as the universal x-session-id header and asserts it round-trips into that provider's access-log row. This guards the session-grouping contract end to end for every provider (header extraction runs in llm_request_parser ahead of the parser-specific body extraction, so it is provider-agnostic). * [e2e] Drop accidentally committed sync-phases dashboard netbird-sync-phases.json was swept into the Pillar 1 commit by a broad git add; it belongs to the unrelated sync-phases metrics work, not this e2e harness. Remove it from the branch so the PR diff is scoped to the e2e changes. * [e2e] Revert accidentally committed sync-phase ingest spec The netbird_sync_phase measurement spec in metrics ingest was swept into the Pillar 1 commit; it belongs to the unrelated sync-phases metrics work, not this e2e harness. Its emission side never landed here, so the spec was orphaned anyway. Restore ingest/main.go to its origin/main state. * Fix golint issues * Fix sonar * Add access log session test * Fix access log tests --------- Co-authored-by: braginini <bangvalo@gmail.com> Co-authored-by: Zoltan Papp <zoltan.pmail@gmail.com>
794 lines
29 KiB
Go
794 lines
29 KiB
Go
// Package llm_router implements the SlotOnRequest middleware that
|
|
// routes a request to an upstream LLM provider based on the model name
|
|
// emitted upstream by llm_request_parser. The router rewrites the
|
|
// request's outbound target (scheme + host), strips known LLM-vendor
|
|
// auth headers, and injects the per-provider auth header from the
|
|
// matched route. Unknown or unconfigured models deny with a 403 and
|
|
// the canonical llm_policy.model_not_routable code.
|
|
package llm_router
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/oauth2"
|
|
"golang.org/x/oauth2/google"
|
|
|
|
"github.com/netbirdio/netbird/proxy/internal/middleware"
|
|
)
|
|
|
|
// gcpScope is the OAuth2 scope minted for Vertex AI service-account auth.
|
|
const gcpScope = "https://www.googleapis.com/auth/cloud-platform"
|
|
|
|
// gcpTokenTimeout bounds each GCP token mint/refresh HTTP call so a slow or
|
|
// unreachable token endpoint can't block the request indefinitely.
|
|
const gcpTokenTimeout = 10 * time.Second
|
|
|
|
// ID is the registry key for this middleware.
|
|
const ID = "llm_router"
|
|
|
|
// Version is reported via Middleware.Version().
|
|
const Version = "1.0.0"
|
|
|
|
const (
|
|
denyCodeNotRoutable = "llm_policy.model_not_routable"
|
|
denyReasonNotRoutable = "model_not_routable"
|
|
denyCodeNoAuthorisedRoute = "llm_policy.no_authorised_provider"
|
|
denyReasonNoAuthorisedRoute = "no_authorised_provider"
|
|
//nolint:gosec // deny code label, not a credential
|
|
denyCodeUpstreamAuth = "llm_policy.upstream_auth_failed"
|
|
denyCodeUnmeterable = "llm_policy.unmeterable_publisher"
|
|
denyReasonUnmeterable = "unmeterable_publisher"
|
|
)
|
|
|
|
// strippedAuthHeaders is the closed list of vendor authentication
|
|
// credentials the router clears before injecting the provider-specific
|
|
// credential. Strictly auth headers — vendor-specific metadata
|
|
// (anthropic-version, openai-organization, openai-project, etc.) is
|
|
// NOT stripped because the client SDK sets those and the upstream
|
|
// requires them (e.g. Anthropic returns 400 without
|
|
// anthropic-version). Each entry is canonicalised by Go's
|
|
// http.Header.Del/Set, so listing the canonical shapes here is
|
|
// sufficient.
|
|
var strippedAuthHeaders = []string{
|
|
"Authorization", // OpenAI, OpenAI-compatible, most vendors, Bedrock bearer
|
|
"Proxy-Authorization", // upstream proxy auth (defense-in-depth)
|
|
"x-api-key", // Anthropic
|
|
"api-key", // Azure OpenAI
|
|
"X-Amz-Date", // AWS SigV4 — strip client-supplied AWS signing material
|
|
"X-Amz-Security-Token",
|
|
"X-Amz-Content-Sha256",
|
|
}
|
|
|
|
// Middleware routes requests to upstream LLM providers based on the
|
|
// llm.model metadata emitted by llm_request_parser.
|
|
type Middleware struct {
|
|
cfg Config
|
|
// tokenSrc caches one auto-refreshing OAuth2 TokenSource per GCP
|
|
// service-account key (keyed by a hash of the key material), so Vertex
|
|
// token minting happens once and refreshes are amortised across requests.
|
|
tokenMu sync.Mutex
|
|
tokenSrc map[string]oauth2.TokenSource
|
|
}
|
|
|
|
// New constructs a Middleware with the supplied configuration. Empty
|
|
// or nil Providers slice yields a router that denies every request as
|
|
// not-routable.
|
|
func New(cfg Config) *Middleware {
|
|
return &Middleware{cfg: cfg, tokenSrc: map[string]oauth2.TokenSource{}}
|
|
}
|
|
|
|
// ID returns the registry identifier.
|
|
func (m *Middleware) ID() string { return ID }
|
|
|
|
// Version returns the implementation version.
|
|
func (m *Middleware) Version() string { return Version }
|
|
|
|
// Slot reports the chain slot the middleware lives in.
|
|
func (m *Middleware) Slot() middleware.Slot { return middleware.SlotOnRequest }
|
|
|
|
// AcceptedContentTypes returns nil because the router only consults
|
|
// the metadata emitted by llm_request_parser.
|
|
func (m *Middleware) AcceptedContentTypes() []string { return nil }
|
|
|
|
// MetadataKeys is the closed set of metadata keys this middleware may
|
|
// emit. The accumulator drops anything outside this allowlist.
|
|
func (m *Middleware) MetadataKeys() []string {
|
|
return []string{
|
|
middleware.KeyLLMResolvedProviderID,
|
|
middleware.KeyLLMAuthorisingGroups,
|
|
middleware.KeyLLMPolicyDecision,
|
|
middleware.KeyLLMPolicyReason,
|
|
}
|
|
}
|
|
|
|
// MutationsSupported reports that the middleware emits header and
|
|
// upstream-rewrite mutations.
|
|
func (m *Middleware) MutationsSupported() bool { return true }
|
|
|
|
// Close releases resources owned by the middleware. The router is
|
|
// stateless, so this is a no-op.
|
|
func (m *Middleware) Close() error { return nil }
|
|
|
|
// matchOutcome captures why matchRoute returned what it did so the
|
|
// caller can distinguish "no provider knows this model" from "providers
|
|
// know it but none authorise this peer's groups".
|
|
type matchOutcome int
|
|
|
|
const (
|
|
matchOutcomeFound matchOutcome = iota
|
|
matchOutcomeUnknownModel
|
|
matchOutcomeUnauthorised
|
|
)
|
|
|
|
// Invoke resolves the model to a provider authorised for the caller's
|
|
// groups, strips known vendor auth headers, and injects the route's
|
|
// auth header. Unknown models deny with model_not_routable; models
|
|
// known to a provider that no policy authorises for the caller deny
|
|
// with no_authorised_provider.
|
|
func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middleware.Output, error) {
|
|
// Vertex AI carries the model in the URL path, not the body, and is
|
|
// selected by path rather than by the model/vendor table. Route it before
|
|
// the model lookup so a model the parser extracted from the path can't be
|
|
// claimed by a same-vendor direct provider (e.g. claude-* on api.anthropic.com).
|
|
reqPath := requestPath(in.URL)
|
|
if isVertexPath(reqPath) {
|
|
model, _ := lookupMetadata(in.Metadata, middleware.KeyLLMModel)
|
|
// The request parser emits no llm.provider for a Vertex publisher it
|
|
// can't parse (e.g. google/gemini). Forwarding such a request would
|
|
// bypass token/budget metering, so deny it rather than serve it
|
|
// unmetered.
|
|
if vendor, _ := lookupMetadata(in.Metadata, middleware.KeyLLMProvider); vendor == "" {
|
|
return denyUnmeterable(), nil
|
|
}
|
|
route, outcome := m.matchVertex(reqPath, model, in.UserGroups)
|
|
switch outcome {
|
|
case matchOutcomeFound:
|
|
return m.allowWithRoute(route, in.UserGroups), nil
|
|
case matchOutcomeUnauthorised:
|
|
return denyNoAuthorisedRoute(model), nil
|
|
default:
|
|
return denyUnknownModel(model), nil
|
|
}
|
|
}
|
|
|
|
// Bedrock likewise carries the model in the URL path (/model/{id}/{action}),
|
|
// optionally behind a "/bedrock" gateway-namespace prefix. Route it by path
|
|
// before the model lookup; when the prefix is present, strip it from the
|
|
// forwarded path so the real Bedrock endpoint receives its native path.
|
|
if isBedrockPath(reqPath) {
|
|
model, _ := lookupMetadata(in.Metadata, middleware.KeyLLMModel)
|
|
native, hadPrefix := splitBedrockNamespace(reqPath)
|
|
route, outcome := m.matchBedrock(native, model, in.UserGroups)
|
|
switch outcome {
|
|
case matchOutcomeFound:
|
|
out := m.allowWithRoute(route, in.UserGroups)
|
|
if hadPrefix && out.Mutations != nil && out.Mutations.RewriteUpstream != nil {
|
|
out.Mutations.RewriteUpstream.StripPathPrefix = bedrockNamespacePrefix
|
|
}
|
|
return out, nil
|
|
case matchOutcomeUnauthorised:
|
|
return denyNoAuthorisedRoute(model), nil
|
|
default:
|
|
return denyUnknownModel(model), nil
|
|
}
|
|
}
|
|
|
|
model, ok := lookupMetadata(in.Metadata, middleware.KeyLLMModel)
|
|
if !ok || model == "" {
|
|
// Non-inference endpoints (model listing) carry no model but still
|
|
// need rewriting from the synth placeholder to a real upstream;
|
|
// clients such as Codex call GET /v1/models at startup to enumerate
|
|
// availability and read a 403 as "model unavailable".
|
|
route, outcome := m.matchModelless(requestPath(in.URL), in.UserGroups)
|
|
switch outcome {
|
|
case matchOutcomeFound:
|
|
return m.allowWithRoute(route, in.UserGroups), nil
|
|
case matchOutcomeUnauthorised:
|
|
// A recognised model-less endpoint exists but no provider
|
|
// authorises the caller — deny as an authorisation failure
|
|
// rather than masking it as a missing model.
|
|
return denyNoAuthorisedRoute(model), nil
|
|
default:
|
|
return denyMissingModel(), nil
|
|
}
|
|
}
|
|
|
|
vendor, _ := lookupMetadata(in.Metadata, middleware.KeyLLMProvider)
|
|
route, outcome := m.matchRoute(model, vendor, requestPath(in.URL), in.UserGroups)
|
|
switch outcome {
|
|
case matchOutcomeFound:
|
|
return m.allowWithRoute(route, in.UserGroups), nil
|
|
case matchOutcomeUnauthorised:
|
|
return denyNoAuthorisedRoute(model), nil
|
|
default:
|
|
return denyUnknownModel(model), nil
|
|
}
|
|
}
|
|
|
|
// matchRoute returns the ProviderRoute that should serve the given
|
|
// model + request path for a caller in the given user-groups. Selection
|
|
// is:
|
|
//
|
|
// 1. Filter the configured providers to those whose Models list
|
|
// contains the model.
|
|
// 2. Filter the model-matched candidates to those whose
|
|
// AllowedGroupIDs intersect the caller's UserGroups. A route with
|
|
// no AllowedGroupIDs is the catch-all: it stays in the list. If
|
|
// the model was known but no candidate is authorised for this
|
|
// peer, return matchOutcomeUnauthorised so the caller can emit
|
|
// the dedicated no_authorised_provider deny code.
|
|
// 3. Vendor precedence: when the request carries a detected vendor
|
|
// (llm.provider) and at least one candidate is the same vendor,
|
|
// drop the rest — a vendor-tagged request must never cross to
|
|
// another vendor's route (e.g. an Anthropic call landing on an
|
|
// OpenAI-compatible gateway that also claims the model).
|
|
// 4. Model precedence over path: a route that explicitly lists the
|
|
// model beats a catch-all (empty Models) gateway.
|
|
// 5. Disambiguate the survivors by URL path prefix: longest
|
|
// UpstreamPath that prefix-matches the request path wins; an empty
|
|
// UpstreamPath is the catchall. If none prefix-matches, fall back
|
|
// to declaration order so the model stays routable.
|
|
func (m *Middleware) matchRoute(model, vendor, reqPath string, userGroups []string) (ProviderRoute, matchOutcome) {
|
|
var modelMatched []ProviderRoute
|
|
for _, route := range m.cfg.Providers {
|
|
if routeClaimsModel(route, model) {
|
|
modelMatched = append(modelMatched, route)
|
|
}
|
|
}
|
|
if len(modelMatched) == 0 {
|
|
return ProviderRoute{}, matchOutcomeUnknownModel
|
|
}
|
|
|
|
// Vendor pinning runs BEFORE the group filter so a request the parser
|
|
// tagged with a vendor can never cross to another vendor's route — not
|
|
// even an authorised one. Narrow to same-vendor routes when any
|
|
// model-matched route declares that vendor; setups with no vendor tag on
|
|
// any route fall through unchanged. After narrowing, if no same-vendor
|
|
// route authorises the caller, that's matchOutcomeUnauthorised (no
|
|
// cross-vendor fallback).
|
|
if vendor != "" {
|
|
if vendorMatched := matchingVendor(modelMatched, vendor); len(vendorMatched) > 0 {
|
|
modelMatched = vendorMatched
|
|
}
|
|
}
|
|
|
|
var candidates []ProviderRoute
|
|
for _, route := range modelMatched {
|
|
if routeAuthorisesGroups(route, userGroups) {
|
|
candidates = append(candidates, route)
|
|
}
|
|
}
|
|
if len(candidates) == 0 {
|
|
return ProviderRoute{}, matchOutcomeUnauthorised
|
|
}
|
|
|
|
// Model routing takes precedence over path. A route that explicitly
|
|
// lists the model must beat a catch-all (empty Models) gateway that
|
|
// claims every model — otherwise an Anthropic request can fall through
|
|
// to an OpenAI-compatible gateway declared earlier. Only when no
|
|
// candidate explicitly claims the model do the catch-alls compete, and
|
|
// the path-prefix tiebreak applies within whichever tier wins.
|
|
if explicit := explicitlyClaiming(candidates, model); len(explicit) > 0 {
|
|
candidates = explicit
|
|
}
|
|
if len(candidates) == 1 {
|
|
return candidates[0], matchOutcomeFound
|
|
}
|
|
|
|
best := candidates[0]
|
|
bestLen := -1
|
|
for _, c := range candidates {
|
|
if !pathPrefixMatches(c.UpstreamPath, reqPath) {
|
|
continue
|
|
}
|
|
if len(c.UpstreamPath) > bestLen {
|
|
best = c
|
|
bestLen = len(c.UpstreamPath)
|
|
}
|
|
}
|
|
return best, matchOutcomeFound
|
|
}
|
|
|
|
// isModelLessPath reports whether reqPath is a known OpenAI-shaped
|
|
// non-inference endpoint that legitimately carries no model in its
|
|
// request (the model-listing endpoints). These must route to an upstream
|
|
// rather than deny, so model enumeration works end to end.
|
|
func isModelLessPath(reqPath string) bool {
|
|
return reqPath == "/v1/models" || strings.HasPrefix(reqPath, "/v1/models/")
|
|
}
|
|
|
|
// isVertexPath reports whether reqPath is a Google Vertex AI publisher
|
|
// endpoint: /v1/projects/{project}/locations/{region}/publishers/{publisher}/
|
|
// models/{model}:{action}. The model + vendor live in the path, so these
|
|
// requests are routed by path to the Vertex provider rather than by model.
|
|
func isVertexPath(reqPath string) bool {
|
|
return strings.HasPrefix(reqPath, "/v1/projects/") &&
|
|
strings.Contains(reqPath, "/publishers/") &&
|
|
strings.Contains(reqPath, "/models/")
|
|
}
|
|
|
|
// bedrockNamespacePrefix is an optional gateway-namespace prefix some clients
|
|
// place before the native Bedrock path to disambiguate it from other providers
|
|
// that also use "/model/...". It is stripped before forwarding upstream.
|
|
const bedrockNamespacePrefix = "/bedrock"
|
|
|
|
// splitBedrockNamespace removes an optional "/bedrock" namespace prefix,
|
|
// returning the native Bedrock path and whether the prefix was present.
|
|
func splitBedrockNamespace(reqPath string) (string, bool) {
|
|
if strings.HasPrefix(reqPath, bedrockNamespacePrefix+"/") {
|
|
return strings.TrimPrefix(reqPath, bedrockNamespacePrefix), true
|
|
}
|
|
return reqPath, false
|
|
}
|
|
|
|
// isBedrockPath reports whether reqPath is an AWS Bedrock runtime model
|
|
// endpoint: /model/{modelId}/{action} where action is invoke,
|
|
// invoke-with-response-stream, converse, or converse-stream — optionally behind
|
|
// a "/bedrock" gateway-namespace prefix. The model lives in the path, so these
|
|
// requests are routed by path to the Bedrock provider.
|
|
func isBedrockPath(reqPath string) bool {
|
|
native, _ := splitBedrockNamespace(reqPath)
|
|
if !strings.HasPrefix(native, "/model/") {
|
|
return false
|
|
}
|
|
return strings.HasSuffix(native, "/invoke") ||
|
|
strings.HasSuffix(native, "/invoke-with-response-stream") ||
|
|
strings.HasSuffix(native, "/converse") ||
|
|
strings.HasSuffix(native, "/converse-stream")
|
|
}
|
|
|
|
// matchVertex selects the Vertex provider authorised for the caller's groups
|
|
// and claiming the requested model.
|
|
func (m *Middleware) matchVertex(reqPath, model string, userGroups []string) (ProviderRoute, matchOutcome) {
|
|
return m.matchPathRoute(reqPath, model, userGroups, func(r ProviderRoute) bool { return r.Vertex })
|
|
}
|
|
|
|
// matchBedrock selects the Bedrock provider authorised for the caller's groups
|
|
// and claiming the requested model.
|
|
func (m *Middleware) matchBedrock(reqPath, model string, userGroups []string) (ProviderRoute, matchOutcome) {
|
|
return m.matchPathRoute(reqPath, model, userGroups, func(r ProviderRoute) bool { return r.Bedrock })
|
|
}
|
|
|
|
// matchPathRoute selects a path-routed provider (Vertex/Bedrock). These carry
|
|
// the model in the URL, so the model/vendor table is bypassed — but the route's
|
|
// configured Models allowlist is still enforced (empty Models = catch-all) so a
|
|
// provider credential can't be used for models the operator didn't authorise.
|
|
// Returns matchOutcomeUnauthorised when no style route authorises the caller's
|
|
// groups, matchOutcomeUnknownModel when an authorised route exists but none
|
|
// claims the model (or no style route exists at all), else the chosen route
|
|
// (longest UpstreamPath prefix-match wins among multiple).
|
|
func (m *Middleware) matchPathRoute(reqPath, model string, userGroups []string, isStyle func(ProviderRoute) bool) (ProviderRoute, matchOutcome) {
|
|
var styled []ProviderRoute
|
|
for _, route := range m.cfg.Providers {
|
|
if isStyle(route) {
|
|
styled = append(styled, route)
|
|
}
|
|
}
|
|
if len(styled) == 0 {
|
|
return ProviderRoute{}, matchOutcomeUnknownModel
|
|
}
|
|
|
|
var authorised []ProviderRoute
|
|
for _, route := range styled {
|
|
if routeAuthorisesGroups(route, userGroups) {
|
|
authorised = append(authorised, route)
|
|
}
|
|
}
|
|
if len(authorised) == 0 {
|
|
return ProviderRoute{}, matchOutcomeUnauthorised
|
|
}
|
|
|
|
var candidates []ProviderRoute
|
|
for _, route := range authorised {
|
|
if routeClaimsModel(route, model) {
|
|
candidates = append(candidates, route)
|
|
}
|
|
}
|
|
if len(candidates) == 0 {
|
|
return ProviderRoute{}, matchOutcomeUnknownModel
|
|
}
|
|
if len(candidates) == 1 {
|
|
return candidates[0], matchOutcomeFound
|
|
}
|
|
|
|
best := candidates[0]
|
|
bestLen := -1
|
|
for _, c := range candidates {
|
|
if !pathPrefixMatches(c.UpstreamPath, reqPath) {
|
|
continue
|
|
}
|
|
if len(c.UpstreamPath) > bestLen {
|
|
best = c
|
|
bestLen = len(c.UpstreamPath)
|
|
}
|
|
}
|
|
return best, matchOutcomeFound
|
|
}
|
|
|
|
// matchModelless selects a route for a non-inference, model-less request.
|
|
// It mirrors matchRoute's group-authorisation filter and path-prefix
|
|
// tiebreak but skips the per-model filter, since any provider the caller's
|
|
// groups authorise can serve a model-listing request. Returns
|
|
// matchOutcomeFound with the chosen route (single authorised provider wins
|
|
// outright; multiple fall to the longest UpstreamPath prefix-match, then
|
|
// declaration order), matchOutcomeUnauthorised when no provider authorises
|
|
// the caller, or matchOutcomeUnknownModel when the path isn't a recognised
|
|
// model-less endpoint.
|
|
func (m *Middleware) matchModelless(reqPath string, userGroups []string) (ProviderRoute, matchOutcome) {
|
|
if !isModelLessPath(reqPath) {
|
|
return ProviderRoute{}, matchOutcomeUnknownModel
|
|
}
|
|
var candidates []ProviderRoute
|
|
for _, route := range m.cfg.Providers {
|
|
// Vertex/Bedrock are path-routed and don't serve OpenAI-style
|
|
// model-listing endpoints; including them here could rewrite a
|
|
// GET /v1/models to an upstream that 404s it.
|
|
if route.Vertex || route.Bedrock {
|
|
continue
|
|
}
|
|
if routeAuthorisesGroups(route, userGroups) {
|
|
candidates = append(candidates, route)
|
|
}
|
|
}
|
|
if len(candidates) == 0 {
|
|
return ProviderRoute{}, matchOutcomeUnauthorised
|
|
}
|
|
if len(candidates) == 1 {
|
|
return candidates[0], matchOutcomeFound
|
|
}
|
|
|
|
best := candidates[0]
|
|
bestLen := -1
|
|
for _, c := range candidates {
|
|
if !pathPrefixMatches(c.UpstreamPath, reqPath) {
|
|
continue
|
|
}
|
|
if len(c.UpstreamPath) > bestLen {
|
|
best = c
|
|
bestLen = len(c.UpstreamPath)
|
|
}
|
|
}
|
|
return best, matchOutcomeFound
|
|
}
|
|
|
|
// routeAuthorisesGroups reports whether the route's AllowedGroupIDs
|
|
// intersect the caller's userGroups. A route with empty AllowedGroupIDs
|
|
// is unreachable: the synthesiser only emits routes bound to at least
|
|
// one enabled policy, so an empty list signals a misconfiguration that
|
|
// must not be allowed to fall through.
|
|
func routeAuthorisesGroups(r ProviderRoute, userGroups []string) bool {
|
|
for _, ug := range userGroups {
|
|
for _, ag := range r.AllowedGroupIDs {
|
|
if ug == ag {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// authorisingGroupsCSV returns the sorted, deduplicated comma-separated
|
|
// intersection of routeGroups and userGroups — i.e. the groups that
|
|
// actually authorise the resolved route for this caller. Returns the
|
|
// empty string when the intersection is empty (shouldn't happen on the
|
|
// allow path, but defensive).
|
|
func authorisingGroupsCSV(routeGroups, userGroups []string) string {
|
|
if len(routeGroups) == 0 || len(userGroups) == 0 {
|
|
return ""
|
|
}
|
|
allowed := make(map[string]struct{}, len(routeGroups))
|
|
for _, g := range routeGroups {
|
|
allowed[g] = struct{}{}
|
|
}
|
|
seen := make(map[string]struct{}, len(userGroups))
|
|
out := make([]string, 0, len(userGroups))
|
|
for _, ug := range userGroups {
|
|
if _, ok := allowed[ug]; !ok {
|
|
continue
|
|
}
|
|
if _, dup := seen[ug]; dup {
|
|
continue
|
|
}
|
|
seen[ug] = struct{}{}
|
|
out = append(out, ug)
|
|
}
|
|
if len(out) == 0 {
|
|
return ""
|
|
}
|
|
sort.Strings(out)
|
|
return strings.Join(out, ",")
|
|
}
|
|
|
|
// matchingVendor returns the subset of routes whose Vendor equals the
|
|
// request's detected vendor. Routes with an empty Vendor never match — an
|
|
// untagged route can't be asserted to speak the request's surface, so it
|
|
// stays out of the vendor-filtered set (but remains eligible via the
|
|
// fall-through when no route matches the vendor at all).
|
|
func matchingVendor(routes []ProviderRoute, vendor string) []ProviderRoute {
|
|
var out []ProviderRoute
|
|
for _, r := range routes {
|
|
if r.Vendor == vendor {
|
|
out = append(out, r)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// explicitlyClaiming returns the subset of routes whose Models list
|
|
// names the model exactly. Catch-all routes (empty Models) are excluded,
|
|
// so callers can prefer a provider that genuinely declares the model over
|
|
// a gateway that claims everything.
|
|
func explicitlyClaiming(routes []ProviderRoute, model string) []ProviderRoute {
|
|
var out []ProviderRoute
|
|
for _, r := range routes {
|
|
for _, candidate := range r.Models {
|
|
if candidate == model {
|
|
out = append(out, r)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// routeClaimsModel reports whether the route's Models list contains
|
|
// the given model identifier. An empty Models list is treated as
|
|
// "claim every model" — used by gateway-style providers (LiteLLM,
|
|
// custom OpenAI-compatible endpoints) that proxy an open-ended set of
|
|
// upstream models the operator can't enumerate in NetBird's provider
|
|
// config.
|
|
func routeClaimsModel(route ProviderRoute, model string) bool {
|
|
if len(route.Models) == 0 {
|
|
return true
|
|
}
|
|
for _, candidate := range route.Models {
|
|
if candidate == model {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// pathPrefixMatches reports whether upstreamPath matches reqPath on a path-
|
|
// segment boundary: an exact match, or reqPath continuing after
|
|
// upstreamPath at a "/" separator. This avoids a sibling base like
|
|
// "/openai" spuriously matching "/openai-test". An empty (or "/")
|
|
// upstreamPath always matches (catchall).
|
|
func pathPrefixMatches(upstreamPath, reqPath string) bool {
|
|
if upstreamPath == "" || upstreamPath == "/" {
|
|
return true
|
|
}
|
|
upstreamPath = strings.TrimRight(upstreamPath, "/")
|
|
return reqPath == upstreamPath || strings.HasPrefix(reqPath, upstreamPath+"/")
|
|
}
|
|
|
|
// requestPath extracts the path component from an Input.URL string
|
|
// (which is r.URL.String() — typically "/path?query"). Returns the
|
|
// raw input on parse failure so the prefix check can still operate on
|
|
// the unparsed value.
|
|
func requestPath(raw string) string {
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil {
|
|
return raw
|
|
}
|
|
return parsed.Path
|
|
}
|
|
|
|
// allowWithRoute builds the Output for a successful route match. The
|
|
// returned Mutations carry the upstream rewrite plus — riding on it —
|
|
// the StripHeaders list and the AuthHeader to inject.
|
|
//
|
|
// The strip + inject MUST go through UpstreamRewrite (not HeadersAdd /
|
|
// HeadersRemove) because the framework's mutation gate runs every
|
|
// header change through a denylist that blocks Authorization,
|
|
// Cookie, etc. — exactly the headers the router is replacing. The
|
|
// proxy's upstream-build path applies AuthHeader / StripHeaders
|
|
// directly, bypassing the denylist by virtue of being a trusted
|
|
// proxy operation rather than an arbitrary middleware mutation.
|
|
//
|
|
// Emits the authorising-groups intersection alongside the resolved
|
|
// provider id so identity-stamping middlewares (llm_identity_inject)
|
|
// tag the request with ONLY the groups that authorised this specific
|
|
// route — not every group the peer happens to be in.
|
|
func (m *Middleware) allowWithRoute(route ProviderRoute, userGroups []string) *middleware.Output {
|
|
rewrite := &middleware.UpstreamRewrite{
|
|
Scheme: route.UpstreamScheme,
|
|
Host: route.UpstreamHost,
|
|
// UpstreamPath is the path component the operator pasted on
|
|
// the provider record (e.g. "/v1/{account}/{gateway}/compat"
|
|
// for Cloudflare AI Gateway). Carrying it on the rewrite so
|
|
// the proxy's URL composer joins it with the agent's request
|
|
// path — without this, the operator's configured upstream
|
|
// path is silently dropped and the gateway returns a 4xx for
|
|
// the malformed URL. Empty value leaves the original
|
|
// target's path untouched.
|
|
Path: route.UpstreamPath,
|
|
StripHeaders: append([]string(nil), strippedAuthHeaders...),
|
|
}
|
|
authValue := route.AuthHeaderValue
|
|
if route.GCPServiceAccountKeyB64 != "" {
|
|
// Mint a short-lived OAuth2 token from the service-account key at
|
|
// request time (cached + auto-refreshed) instead of a static value.
|
|
bearer, err := m.gcpBearer(route.GCPServiceAccountKeyB64)
|
|
if err != nil {
|
|
return denyUpstreamAuth()
|
|
}
|
|
authValue = bearer
|
|
}
|
|
if route.AuthHeaderName != "" && authValue != "" {
|
|
rewrite.AuthHeader = &middleware.AuthHeader{
|
|
Name: route.AuthHeaderName,
|
|
Value: authValue,
|
|
}
|
|
}
|
|
return &middleware.Output{
|
|
Decision: middleware.DecisionAllow,
|
|
Mutations: &middleware.Mutations{RewriteUpstream: rewrite},
|
|
Metadata: []middleware.KV{
|
|
{Key: middleware.KeyLLMResolvedProviderID, Value: route.ID},
|
|
{Key: middleware.KeyLLMAuthorisingGroups, Value: authorisingGroupsCSV(route.AllowedGroupIDs, userGroups)},
|
|
{Key: middleware.KeyLLMPolicyDecision, Value: "allow"},
|
|
},
|
|
}
|
|
}
|
|
|
|
// gcpBearer returns a "Bearer <token>" value minted from a base64-encoded GCP
|
|
// service-account key, using a cached, auto-refreshing token source.
|
|
func (m *Middleware) gcpBearer(saKeyB64 string) (string, error) {
|
|
ts, err := m.gcpTokenSource(saKeyB64)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
tok, err := ts.Token()
|
|
if err != nil {
|
|
return "", fmt.Errorf("mint gcp token: %w", err)
|
|
}
|
|
return "Bearer " + tok.AccessToken, nil
|
|
}
|
|
|
|
// gcpTokenSource returns the cached TokenSource for the given service-account
|
|
// key, building it (decode base64 → parse JSON → cloud-platform scope) on first
|
|
// use. The returned source caches the token and refreshes it before expiry.
|
|
func (m *Middleware) gcpTokenSource(saKeyB64 string) (oauth2.TokenSource, error) {
|
|
sum := sha256.Sum256([]byte(saKeyB64))
|
|
key := hex.EncodeToString(sum[:])
|
|
|
|
m.tokenMu.Lock()
|
|
defer m.tokenMu.Unlock()
|
|
if m.tokenSrc == nil {
|
|
m.tokenSrc = map[string]oauth2.TokenSource{}
|
|
}
|
|
if ts, ok := m.tokenSrc[key]; ok {
|
|
return ts, nil
|
|
}
|
|
jsonKey, err := base64.StdEncoding.DecodeString(strings.TrimSpace(saKeyB64))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode gcp service-account key: %w", err)
|
|
}
|
|
conf, err := google.JWTConfigFromJSON(jsonKey, gcpScope)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse gcp service-account key: %w", err)
|
|
}
|
|
// Bound mint/refresh with a timeout HTTP client so a slow token endpoint
|
|
// can't hang the request. The oauth2 library uses this client for the
|
|
// lifetime of the (auto-refreshing) source.
|
|
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, &http.Client{Timeout: gcpTokenTimeout})
|
|
ts := conf.TokenSource(ctx)
|
|
m.tokenSrc[key] = ts
|
|
return ts, nil
|
|
}
|
|
|
|
// denyUpstreamAuth is returned when the router cannot obtain the upstream
|
|
// credential (e.g. a malformed service-account key or an unreachable token
|
|
// endpoint). It surfaces as a 502 — an upstream problem, not a policy denial.
|
|
func denyUpstreamAuth() *middleware.Output {
|
|
return &middleware.Output{
|
|
Decision: middleware.DecisionDeny,
|
|
DenyStatus: 502,
|
|
DenyReason: &middleware.DenyReason{
|
|
Code: denyCodeUpstreamAuth,
|
|
Message: "could not obtain upstream credential",
|
|
},
|
|
Metadata: []middleware.KV{
|
|
{Key: middleware.KeyLLMPolicyDecision, Value: "deny"},
|
|
{Key: middleware.KeyLLMPolicyReason, Value: "upstream_auth_failed"},
|
|
},
|
|
}
|
|
}
|
|
|
|
// denyUnmeterable returns the deny envelope for a path-routed request whose
|
|
// publisher has no parser surface, so its usage can't be metered. Serving it
|
|
// would bypass token/budget caps, so it is rejected with a 403.
|
|
func denyUnmeterable() *middleware.Output {
|
|
return &middleware.Output{
|
|
Decision: middleware.DecisionDeny,
|
|
DenyStatus: 403,
|
|
DenyReason: &middleware.DenyReason{
|
|
Code: denyCodeUnmeterable,
|
|
Message: "request publisher is not supported for metering",
|
|
},
|
|
Metadata: []middleware.KV{
|
|
{Key: middleware.KeyLLMPolicyDecision, Value: "deny"},
|
|
{Key: middleware.KeyLLMPolicyReason, Value: denyReasonUnmeterable},
|
|
},
|
|
}
|
|
}
|
|
|
|
// denyMissingModel returns the deny envelope for a request whose
|
|
// envelope has no llm.model metadata.
|
|
func denyMissingModel() *middleware.Output {
|
|
return &middleware.Output{
|
|
Decision: middleware.DecisionDeny,
|
|
DenyStatus: 403,
|
|
DenyReason: &middleware.DenyReason{
|
|
Code: denyCodeNotRoutable,
|
|
Message: "missing llm.model on request envelope",
|
|
},
|
|
Metadata: []middleware.KV{
|
|
{Key: middleware.KeyLLMPolicyDecision, Value: "deny"},
|
|
{Key: middleware.KeyLLMPolicyReason, Value: denyReasonNotRoutable},
|
|
},
|
|
}
|
|
}
|
|
|
|
// denyUnknownModel returns the deny envelope for a model that no
|
|
// configured provider claims.
|
|
func denyUnknownModel(model string) *middleware.Output {
|
|
return &middleware.Output{
|
|
Decision: middleware.DecisionDeny,
|
|
DenyStatus: 403,
|
|
DenyReason: &middleware.DenyReason{
|
|
Code: denyCodeNotRoutable,
|
|
Message: fmt.Sprintf("no provider configured for model %s", model),
|
|
Details: map[string]string{"model": model},
|
|
},
|
|
Metadata: []middleware.KV{
|
|
{Key: middleware.KeyLLMPolicyDecision, Value: "deny"},
|
|
{Key: middleware.KeyLLMPolicyReason, Value: denyReasonNotRoutable},
|
|
},
|
|
}
|
|
}
|
|
|
|
// denyNoAuthorisedRoute returns the deny envelope for a model that one
|
|
// or more providers claim, but where no policy authorises the caller's
|
|
// groups for any of those providers.
|
|
func denyNoAuthorisedRoute(model string) *middleware.Output {
|
|
return &middleware.Output{
|
|
Decision: middleware.DecisionDeny,
|
|
DenyStatus: 403,
|
|
DenyReason: &middleware.DenyReason{
|
|
Code: denyCodeNoAuthorisedRoute,
|
|
Message: fmt.Sprintf("no policy authorises model %s for the caller's groups", model),
|
|
Details: map[string]string{"model": model},
|
|
},
|
|
Metadata: []middleware.KV{
|
|
{Key: middleware.KeyLLMPolicyDecision, Value: "deny"},
|
|
{Key: middleware.KeyLLMPolicyReason, Value: denyReasonNoAuthorisedRoute},
|
|
},
|
|
}
|
|
}
|
|
|
|
// lookupMetadata returns the value for key plus a presence flag so
|
|
// callers can distinguish absent from empty.
|
|
func lookupMetadata(meta []middleware.KV, key string) (string, bool) {
|
|
for _, kv := range meta {
|
|
if kv.Key == key {
|
|
return kv.Value, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|