mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-16 19:49:56 +00:00
* [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.
454 lines
16 KiB
Go
454 lines
16 KiB
Go
// Package llm_request_parser implements the SlotOnRequest middleware
|
|
// that detects the LLM provider from the request URL, parses the JSON
|
|
// request body for model and streaming flags, and extracts the user
|
|
// prompt text. Emitted metadata feeds downstream middlewares (guardrail,
|
|
// cost meter) and the access-log terminal sink.
|
|
package llm_request_parser
|
|
|
|
import (
|
|
"context"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/netbirdio/netbird/proxy/internal/llm"
|
|
"github.com/netbirdio/netbird/proxy/internal/middleware"
|
|
"github.com/netbirdio/netbird/proxy/internal/middleware/builtin"
|
|
"github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_guardrail"
|
|
)
|
|
|
|
// ID is the registry key for this middleware.
|
|
const ID = "llm_request_parser"
|
|
|
|
// Version is reported via Middleware.Version().
|
|
const Version = "1.0.0"
|
|
|
|
// maxPromptBytes caps llm.request_prompt_raw at a size that fits within
|
|
// MaxMetadataValueBytes with headroom. Truncation is rune-safe.
|
|
const maxPromptBytes = 3500
|
|
|
|
// middlewareImpl is the concrete implementation. providerID, when set,
|
|
// names the parser to use directly (bypasses URL sniffing). It is empty
|
|
// for non-agent-network targets, which fall back to DetectParser on the
|
|
// request path.
|
|
type middlewareImpl struct {
|
|
providerID string
|
|
redactPii bool
|
|
capturePrompt bool
|
|
}
|
|
|
|
// ID returns the registry identifier.
|
|
func (middlewareImpl) ID() string { return ID }
|
|
|
|
// Version returns the implementation version.
|
|
func (middlewareImpl) Version() string { return Version }
|
|
|
|
// Slot reports the request slot.
|
|
func (middlewareImpl) Slot() middleware.Slot { return middleware.SlotOnRequest }
|
|
|
|
// AcceptedContentTypes restricts body inspection to JSON.
|
|
func (middlewareImpl) AcceptedContentTypes() []string {
|
|
return []string{"application/json"}
|
|
}
|
|
|
|
// MetadataKeys lists the closed allowlist of keys this middleware emits.
|
|
func (middlewareImpl) MetadataKeys() []string {
|
|
return []string{
|
|
middleware.KeyLLMProvider,
|
|
middleware.KeyLLMModel,
|
|
middleware.KeyLLMStream,
|
|
middleware.KeyLLMRequestPromptRaw,
|
|
middleware.KeyLLMCaptureTruncated,
|
|
middleware.KeyLLMSessionID,
|
|
}
|
|
}
|
|
|
|
// MutationsSupported reports that this middleware never mutates.
|
|
func (middlewareImpl) MutationsSupported() bool { return false }
|
|
|
|
// Close is a no-op; the middleware is stateless.
|
|
func (middlewareImpl) Close() error { return nil }
|
|
|
|
// Invoke detects the LLM provider, parses request facts, and emits
|
|
// metadata. Always returns DecisionAllow; never errors. Provider
|
|
// selection prefers the configured providerID (synthesiser-stamped on
|
|
// agent-network targets) so requests routed to a custom upstream URL
|
|
// still resolve. Falls back to URL sniffing when no providerID is set.
|
|
func (m middlewareImpl) Invoke(_ context.Context, in *middleware.Input) (*middleware.Output, error) {
|
|
out := &middleware.Output{Decision: middleware.DecisionAllow}
|
|
if in == nil {
|
|
return out, nil
|
|
}
|
|
|
|
// Google Vertex AI carries the model + publisher (vendor) in the URL path,
|
|
// not the body, so it needs a dedicated extraction path.
|
|
if vx, okv := parseVertexPath(extractPath(in.URL)); okv {
|
|
return m.invokeVertex(in, vx), nil
|
|
}
|
|
|
|
// AWS Bedrock likewise carries the model in the URL path (/model/{id}/{action}).
|
|
if br, okb := parseBedrockPath(extractPath(in.URL)); okb {
|
|
return m.invokeBedrock(in, br), nil
|
|
}
|
|
|
|
parser, ok := llm.ParserByName(m.providerID)
|
|
if !ok {
|
|
parser, ok = llm.DetectParser(extractPath(in.URL))
|
|
}
|
|
if !ok {
|
|
return out, nil
|
|
}
|
|
|
|
md := []middleware.KV{
|
|
{Key: middleware.KeyLLMProvider, Value: parser.ProviderName()},
|
|
}
|
|
|
|
// Session id is an opaque grouping identifier, not prompt content, so
|
|
// it's emitted regardless of the prompt-collection toggle — session
|
|
// grouping must work even when prompt capture is off. Prefer a header
|
|
// (Codex sends the session as an HTTP header, and headers survive an
|
|
// oversized request whose body capture was bypassed) and resolve it
|
|
// before ParseRequest so a malformed body still keeps the header id.
|
|
sessionID := sessionIDFromHeaders(in.Headers)
|
|
if sessionID == "" {
|
|
sessionID = parser.ExtractSessionID(in.Body)
|
|
}
|
|
appendSessionID := func(md []middleware.KV) []middleware.KV {
|
|
if sessionID != "" {
|
|
return append(md, middleware.KV{Key: middleware.KeyLLMSessionID, Value: sessionID})
|
|
}
|
|
return md
|
|
}
|
|
|
|
facts, err := parser.ParseRequest(in.Body)
|
|
if err != nil {
|
|
if logger := builtin.Context().Logger; logger != nil {
|
|
logger.Debugf("llm_request_parser: parse request body: %v", err)
|
|
}
|
|
md = appendSessionID(md)
|
|
md = appendCaptureTruncated(md, false, in.BodyTruncated)
|
|
out.Metadata = md
|
|
return out, nil
|
|
}
|
|
|
|
if facts.Model != "" {
|
|
md = append(md, middleware.KV{Key: middleware.KeyLLMModel, Value: facts.Model})
|
|
}
|
|
md = append(md, middleware.KV{Key: middleware.KeyLLMStream, Value: strconv.FormatBool(facts.Stream)})
|
|
md = appendSessionID(md)
|
|
|
|
prompt, promptTruncated := truncatePrompt(parser.ExtractPrompt(in.Body))
|
|
if prompt != "" && m.capturePrompt {
|
|
if m.redactPii {
|
|
// Apply redaction BEFORE the value lands in the metadata bag, so
|
|
// the access-log row never carries raw emails / SSNs / phones.
|
|
// The downstream llm_guardrail middleware reads this key to
|
|
// produce llm.request_prompt; RedactPII is idempotent so its
|
|
// second pass is a no-op. Redaction can grow the text, so
|
|
// re-truncate to keep the value within the metadata cap.
|
|
prompt = llm_guardrail.RedactPII(prompt)
|
|
var redactedTruncated bool
|
|
prompt, redactedTruncated = truncatePrompt(prompt)
|
|
promptTruncated = promptTruncated || redactedTruncated
|
|
}
|
|
md = append(md, middleware.KV{Key: middleware.KeyLLMRequestPromptRaw, Value: prompt})
|
|
}
|
|
|
|
md = appendCaptureTruncated(md, promptTruncated, in.BodyTruncated)
|
|
out.Metadata = md
|
|
return out, nil
|
|
}
|
|
|
|
// sessionIDHeaders are request header names that may carry a client
|
|
// session identifier, checked in order, case-insensitively. Matching is
|
|
// against Go's canonical header form, so use the hyphenated names the
|
|
// clients actually send: "x-claude-code-session-id" (Claude Code),
|
|
// "session-id" (OpenAI Codex — confirmed on the wire as "Session-Id"),
|
|
// and "x-session-id" as a generic convention.
|
|
var sessionIDHeaders = []string{"x-claude-code-session-id", "session-id", "x-session-id"}
|
|
|
|
// sessionIDFromHeaders returns the first non-empty value among the known
|
|
// session header names, or "" when none is present. Headers arrive in
|
|
// canonical form, so the match is case-insensitive.
|
|
func sessionIDFromHeaders(headers []middleware.KV) string {
|
|
for _, want := range sessionIDHeaders {
|
|
for _, kv := range headers {
|
|
if strings.EqualFold(kv.Key, want) && kv.Value != "" {
|
|
return kv.Value
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// appendCaptureTruncated stamps the capture_truncated marker reflecting
|
|
// either prompt-side truncation or upstream body truncation.
|
|
func appendCaptureTruncated(md []middleware.KV, promptTruncated, bodyTruncated bool) []middleware.KV {
|
|
value := "false"
|
|
if promptTruncated || bodyTruncated {
|
|
value = "true"
|
|
}
|
|
return append(md, middleware.KV{Key: middleware.KeyLLMCaptureTruncated, Value: value})
|
|
}
|
|
|
|
// truncatePrompt clamps a prompt string to maxPromptBytes on a UTF-8
|
|
// rune boundary. Returns the clamped string and whether truncation
|
|
// occurred.
|
|
func truncatePrompt(s string) (string, bool) {
|
|
if len(s) <= maxPromptBytes {
|
|
return s, false
|
|
}
|
|
cut := maxPromptBytes
|
|
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
|
cut--
|
|
}
|
|
return s[:cut], true
|
|
}
|
|
|
|
// extractPath returns the path component of a URL that may be absolute
|
|
// or already a path. Parse errors fall back to the raw input.
|
|
func extractPath(raw string) string {
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
u, err := url.Parse(raw)
|
|
if err != nil || u.Path == "" {
|
|
return raw
|
|
}
|
|
return u.Path
|
|
}
|
|
|
|
// vertexRequest is the model + vendor extracted from a Vertex AI publisher
|
|
// path (the model is in the URL, not the body).
|
|
type vertexRequest struct {
|
|
publisher string
|
|
model string
|
|
stream bool
|
|
}
|
|
|
|
// parseVertexPath extracts the publisher, model, and streaming flag from a
|
|
// Vertex publisher endpoint:
|
|
//
|
|
// /v1/projects/{project}/locations/{region}/publishers/{publisher}/models/{model}:{action}
|
|
//
|
|
// The model's "@version" suffix is stripped so it matches catalog/pricing.
|
|
func parseVertexPath(reqPath string) (vertexRequest, bool) {
|
|
const pubSep, modSep = "/publishers/", "/models/"
|
|
if !strings.HasPrefix(reqPath, "/v1/projects/") {
|
|
return vertexRequest{}, false
|
|
}
|
|
pubIdx := strings.Index(reqPath, pubSep)
|
|
modIdx := strings.Index(reqPath, modSep)
|
|
if pubIdx < 0 || modIdx <= pubIdx {
|
|
return vertexRequest{}, false
|
|
}
|
|
publisher := reqPath[pubIdx+len(pubSep) : modIdx]
|
|
rest := reqPath[modIdx+len(modSep):] // {model}:{action}
|
|
if publisher == "" || rest == "" {
|
|
return vertexRequest{}, false
|
|
}
|
|
model, action := rest, ""
|
|
if c := strings.LastIndex(rest, ":"); c >= 0 {
|
|
model, action = rest[:c], rest[c+1:]
|
|
}
|
|
if at := strings.Index(model, "@"); at >= 0 {
|
|
model = model[:at]
|
|
}
|
|
if model == "" {
|
|
return vertexRequest{}, false
|
|
}
|
|
return vertexRequest{publisher: publisher, model: model, stream: strings.HasPrefix(action, "stream")}, true
|
|
}
|
|
|
|
// vertexPublisherVendor maps a Vertex publisher to the parser surface its
|
|
// requests/responses speak. Empty for publishers without a parser yet
|
|
// (e.g. google/gemini) — the request still routes, but isn't metered.
|
|
func vertexPublisherVendor(publisher string) string {
|
|
switch strings.ToLower(publisher) {
|
|
case "anthropic":
|
|
return "anthropic"
|
|
case "openai":
|
|
return "openai"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// invokeVertex emits the model/vendor/session/prompt for a Vertex publisher
|
|
// request, using the publisher's parser to read the (vendor-native) body.
|
|
func (m middlewareImpl) invokeVertex(in *middleware.Input, vx vertexRequest) *middleware.Output {
|
|
out := &middleware.Output{Decision: middleware.DecisionAllow}
|
|
vendor := vertexPublisherVendor(vx.publisher)
|
|
|
|
md := []middleware.KV{}
|
|
if vendor != "" {
|
|
md = append(md, middleware.KV{Key: middleware.KeyLLMProvider, Value: vendor})
|
|
}
|
|
md = append(md, middleware.KV{Key: middleware.KeyLLMModel, Value: vx.model})
|
|
md = append(md, middleware.KV{Key: middleware.KeyLLMStream, Value: strconv.FormatBool(vx.stream)})
|
|
|
|
var parser llm.Parser
|
|
if vendor != "" {
|
|
parser, _ = llm.ParserByName(vendor)
|
|
}
|
|
|
|
sessionID := sessionIDFromHeaders(in.Headers)
|
|
if sessionID == "" && parser != nil {
|
|
sessionID = parser.ExtractSessionID(in.Body)
|
|
}
|
|
if sessionID != "" {
|
|
md = append(md, middleware.KV{Key: middleware.KeyLLMSessionID, Value: sessionID})
|
|
}
|
|
|
|
promptTruncated := false
|
|
if parser != nil && m.capturePrompt {
|
|
var prompt string
|
|
prompt, promptTruncated = truncatePrompt(parser.ExtractPrompt(in.Body))
|
|
if prompt != "" {
|
|
if m.redactPii {
|
|
prompt = llm_guardrail.RedactPII(prompt)
|
|
var rt bool
|
|
prompt, rt = truncatePrompt(prompt)
|
|
promptTruncated = promptTruncated || rt
|
|
}
|
|
md = append(md, middleware.KV{Key: middleware.KeyLLMRequestPromptRaw, Value: prompt})
|
|
}
|
|
}
|
|
md = appendCaptureTruncated(md, promptTruncated, in.BodyTruncated)
|
|
out.Metadata = md
|
|
return out
|
|
}
|
|
|
|
// bedrockRequest is the model + streaming flag extracted from an AWS Bedrock
|
|
// model path. The InvokeModel vs Converse distinction is recovered downstream
|
|
// from the response body shape, so only the streaming flag is carried here.
|
|
type bedrockRequest struct {
|
|
model string
|
|
stream bool
|
|
}
|
|
|
|
// bedrockNamespacePrefix is an optional gateway-namespace prefix some clients
|
|
// put before the native Bedrock path to disambiguate it from other providers
|
|
// that also use "/model/...".
|
|
const bedrockNamespacePrefix = "/bedrock"
|
|
|
|
// trimBedrockNamespace removes an optional "/bedrock" namespace prefix, leaving
|
|
// the native Bedrock path ("/model/...").
|
|
func trimBedrockNamespace(reqPath string) string {
|
|
if strings.HasPrefix(reqPath, bedrockNamespacePrefix+"/") {
|
|
return strings.TrimPrefix(reqPath, bedrockNamespacePrefix)
|
|
}
|
|
return reqPath
|
|
}
|
|
|
|
// bedrockRegionPrefixes are the cross-region inference-profile prefixes that
|
|
// front a Bedrock model id (e.g. "eu.anthropic.claude-...").
|
|
var bedrockRegionPrefixes = []string{"us.", "eu.", "apac.", "global."}
|
|
|
|
// bedrockVersionSuffix matches the trailing "-vN[:N]" or "-YYYYMMDD-vN[:N]"
|
|
// version/throughput suffix of a Bedrock model id.
|
|
var bedrockVersionSuffix = regexp.MustCompile(`-(\d{8}-)?v\d+(:\d+)?$`)
|
|
|
|
// parseBedrockPath extracts the model and streaming/converse flags from an AWS
|
|
// Bedrock runtime model endpoint:
|
|
//
|
|
// /model/{modelId}/{action}
|
|
//
|
|
// action ∈ {invoke, invoke-with-response-stream, converse, converse-stream}.
|
|
// The modelId may be URL-encoded and may carry a cross-region inference-profile
|
|
// prefix and a version suffix; normalizeBedrockModel strips both so the model
|
|
// matches catalog pricing.
|
|
func parseBedrockPath(reqPath string) (bedrockRequest, bool) {
|
|
reqPath = trimBedrockNamespace(reqPath)
|
|
const prefix = "/model/"
|
|
if !strings.HasPrefix(reqPath, prefix) {
|
|
return bedrockRequest{}, false
|
|
}
|
|
rest := reqPath[len(prefix):]
|
|
slash := strings.LastIndex(rest, "/")
|
|
if slash <= 0 || slash == len(rest)-1 {
|
|
return bedrockRequest{}, false
|
|
}
|
|
rawModel, action := rest[:slash], rest[slash+1:]
|
|
if decoded, err := url.PathUnescape(rawModel); err == nil {
|
|
rawModel = decoded
|
|
}
|
|
model := normalizeBedrockModel(rawModel)
|
|
if model == "" {
|
|
return bedrockRequest{}, false
|
|
}
|
|
switch action {
|
|
case "invoke", "converse":
|
|
return bedrockRequest{model: model}, true
|
|
case "invoke-with-response-stream", "converse-stream":
|
|
return bedrockRequest{model: model, stream: true}, true
|
|
default:
|
|
return bedrockRequest{}, false
|
|
}
|
|
}
|
|
|
|
// normalizeBedrockModel strips an ARN wrapper, a cross-region inference-profile
|
|
// prefix, and the version/throughput suffix from a Bedrock model id so it
|
|
// matches the catalog/pricing key, e.g.
|
|
// "eu.anthropic.claude-sonnet-4-5-20250929-v1:0" -> "anthropic.claude-sonnet-4-5"
|
|
// and "arn:aws:bedrock:eu-central-1:123:inference-profile/eu.anthropic.claude-sonnet-4-5-20250929-v1:0"
|
|
// -> "anthropic.claude-sonnet-4-5".
|
|
func normalizeBedrockModel(modelID string) string {
|
|
m := modelID
|
|
// A full ARN (inference-profile / provisioned-throughput / foundation-model)
|
|
// carries the model id in its last path segment.
|
|
if strings.HasPrefix(m, "arn:") {
|
|
if i := strings.LastIndex(m, "/"); i >= 0 {
|
|
m = m[i+1:]
|
|
}
|
|
}
|
|
for _, p := range bedrockRegionPrefixes {
|
|
if strings.HasPrefix(m, p) {
|
|
m = m[len(p):]
|
|
break
|
|
}
|
|
}
|
|
return bedrockVersionSuffix.ReplaceAllString(m, "")
|
|
}
|
|
|
|
// invokeBedrock emits the model/provider/session/prompt for an AWS Bedrock
|
|
// request. Bedrock is metered under the dedicated "bedrock" parser, which reads
|
|
// both the InvokeModel and Converse response shapes.
|
|
func (m middlewareImpl) invokeBedrock(in *middleware.Input, br bedrockRequest) *middleware.Output {
|
|
out := &middleware.Output{Decision: middleware.DecisionAllow}
|
|
md := []middleware.KV{
|
|
{Key: middleware.KeyLLMProvider, Value: llm.ProviderNameBedrock},
|
|
{Key: middleware.KeyLLMModel, Value: br.model},
|
|
{Key: middleware.KeyLLMStream, Value: strconv.FormatBool(br.stream)},
|
|
}
|
|
|
|
parser, _ := llm.ParserByName(llm.ProviderNameBedrock)
|
|
sessionID := sessionIDFromHeaders(in.Headers)
|
|
if sessionID == "" && parser != nil {
|
|
sessionID = parser.ExtractSessionID(in.Body)
|
|
}
|
|
if sessionID != "" {
|
|
md = append(md, middleware.KV{Key: middleware.KeyLLMSessionID, Value: sessionID})
|
|
}
|
|
|
|
promptTruncated := false
|
|
if parser != nil && m.capturePrompt {
|
|
var prompt string
|
|
prompt, promptTruncated = truncatePrompt(parser.ExtractPrompt(in.Body))
|
|
if prompt != "" {
|
|
if m.redactPii {
|
|
prompt = llm_guardrail.RedactPII(prompt)
|
|
var rt bool
|
|
prompt, rt = truncatePrompt(prompt)
|
|
promptTruncated = promptTruncated || rt
|
|
}
|
|
md = append(md, middleware.KV{Key: middleware.KeyLLMRequestPromptRaw, Value: prompt})
|
|
}
|
|
}
|
|
md = appendCaptureTruncated(md, promptTruncated, in.BodyTruncated)
|
|
out.Metadata = md
|
|
return out
|
|
}
|