mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-23 08:51:29 +02:00
Vertex requests reach the router/guardrail with the "@version" suffix already stripped from the URL model by the request parser, but the operator-facing config may carry the raw versioned id the Google console documents (e.g. "claude-opus-4-6@20250514"): - a Vertex provider registered with versioned model ids denied every request as model_not_routable (the Vertex analog of the Bedrock gap fixed in #6773), and - a guardrail allowlist entry with a versioned id never matched, denying the model as model_blocked. Both bit a customer driving Anthropic-on-Vertex through the agent network with unversioned model ids (…/models/claude-opus-4-6:rawPredict at the global location). Introduce a shared llm.NormalizeVertexModel (the same "@" stripping the parser already does) and apply it to a Vertex route's candidate models in routeClaimsModel and to guardrail allowlist entries, so either spelling of the same model matches. Non-Vertex routes and entries without "@" keep exact matching. Also resolve the real model for the Vertex token-count endpoint: …/models/count-tokens:rawPredict carries the literal "count-tokens" pseudo-model in the URL and the actual model in the body (Claude Code sends one such call per request), so any configured allowlist denied all token counting. The parser now reads the body model for that endpoint; an unreadable body keeps the pseudo-model and fails closed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDgMfwgU1Xerfaedu6bQse
472 lines
16 KiB
Go
472 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:]
|
|
}
|
|
model = llm.NormalizeVertexModel(model)
|
|
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 ""
|
|
}
|
|
}
|
|
|
|
// vertexCountTokensModel is the pseudo-model id of the Vertex token-count
|
|
// endpoint (…/models/count-tokens:rawPredict). It is the one Vertex request
|
|
// shape whose real model travels in the JSON body rather than the URL path.
|
|
const vertexCountTokensModel = "count-tokens"
|
|
|
|
// 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)
|
|
|
|
var parser llm.Parser
|
|
if vendor != "" {
|
|
parser, _ = llm.ParserByName(vendor)
|
|
}
|
|
|
|
model := vx.model
|
|
// Token counting carries the literal "count-tokens" pseudo-model in the URL
|
|
// and the real model in the body (Claude Code issues one such call per
|
|
// request). Resolve the body model so the guardrail allowlist and the
|
|
// router evaluate the actual model — otherwise every count-tokens call
|
|
// under an allowlist denies as model_blocked. A body the parser cannot
|
|
// read keeps the pseudo-model, which fails closed downstream.
|
|
if model == vertexCountTokensModel && parser != nil {
|
|
if facts, err := parser.ParseRequest(in.Body); err == nil && facts.Model != "" {
|
|
if bodyModel := llm.NormalizeVertexModel(facts.Model); bodyModel != "" {
|
|
model = bodyModel
|
|
}
|
|
}
|
|
}
|
|
|
|
md := []middleware.KV{}
|
|
if vendor != "" {
|
|
md = append(md, middleware.KV{Key: middleware.KeyLLMProvider, Value: vendor})
|
|
}
|
|
md = append(md, middleware.KV{Key: middleware.KeyLLMModel, Value: model})
|
|
md = append(md, middleware.KV{Key: middleware.KeyLLMStream, Value: strconv.FormatBool(vx.stream)})
|
|
|
|
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
|
|
}
|