diff --git a/proxy/internal/middleware/builtin/llm_guardrail/middleware.go b/proxy/internal/middleware/builtin/llm_guardrail/middleware.go index 1863aff20..ddf710d24 100644 --- a/proxy/internal/middleware/builtin/llm_guardrail/middleware.go +++ b/proxy/internal/middleware/builtin/llm_guardrail/middleware.go @@ -84,8 +84,9 @@ func (m *Middleware) MutationsSupported() bool { return false } func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middleware.Output, error) { model, modelPresent := lookupMetadata(in.Metadata, middleware.KeyLLMModel) providerID, _ := lookupMetadata(in.Metadata, middleware.KeyLLMResolvedProviderID) + surface, _ := lookupMetadata(in.Metadata, middleware.KeyLLMProvider) - if denial := m.evaluateAllowlist(providerID, model, modelPresent); denial != nil { + if denial := m.evaluateAllowlist(providerID, surface, model, modelPresent); denial != nil { return denial, nil } @@ -114,7 +115,7 @@ func (m *Middleware) Close() error { return nil } // evaluateAllowlist denies when the resolved provider's allowlist rejects the // model; nil means proceed. Scoped to the provider llm_router resolved, so an // unrestricted provider (absent from config) is never caught by another's list. -func (m *Middleware) evaluateAllowlist(providerID, model string, modelPresent bool) *middleware.Output { +func (m *Middleware) evaluateAllowlist(providerID, surface, model string, modelPresent bool) *middleware.Output { if len(m.cfg.ProviderAllowlists) == 0 { return nil } @@ -122,7 +123,7 @@ func (m *Middleware) evaluateAllowlist(providerID, model string, modelPresent bo // if this request targets a restricted provider — fail closed. llm_router // normally stamps the provider first, so this is a defensive guard. if providerID == "" { - return denyModel("", denyCodeModelUnknown, denyMessageModelUnknown, denyReasonModelUnknown) + return denyModel(surface, "", denyCodeModelUnknown, denyMessageModelUnknown, denyReasonModelUnknown) } allowlist, restricted := m.cfg.ProviderAllowlists[providerID] if !restricted { @@ -134,17 +135,17 @@ func (m *Middleware) evaluateAllowlist(providerID, model string, modelPresent bo // model the parser couldn't extract (absent/empty) is denied. This enforces // the allowlist for path-routed providers (Bedrock, Vertex) with no body model. if !modelPresent || normaliseModel(model) == "" { - return denyModel("", denyCodeModelUnknown, denyMessageModelUnknown, denyReasonModelUnknown) + return denyModel(surface, "", denyCodeModelUnknown, denyMessageModelUnknown, denyReasonModelUnknown) } if modelInAllowlist(allowlist, model) { return nil } - return denyModel(model, denyCodeModel, denyMessageModel, denyReasonModel) + return denyModel(surface, model, denyCodeModel, denyMessageModel, denyReasonModel) } // denyModel builds a 403 deny Output for a model-allowlist rejection. model is // included in the details only when non-empty. -func denyModel(model, code, message, reason string) *middleware.Output { +func denyModel(surface, model, code, message, reason string) *middleware.Output { details := map[string]string{} if model != "" { details["model"] = model @@ -156,6 +157,7 @@ func denyModel(model, code, message, reason string) *middleware.Output { Code: code, Message: message, Details: details, + Surface: surface, }, Metadata: []middleware.KV{ {Key: middleware.KeyLLMPolicyDecision, Value: "deny"}, diff --git a/proxy/internal/middleware/builtin/llm_limit_check/middleware.go b/proxy/internal/middleware/builtin/llm_limit_check/middleware.go index c910d57da..1e7edcf42 100644 --- a/proxy/internal/middleware/builtin/llm_limit_check/middleware.go +++ b/proxy/internal/middleware/builtin/llm_limit_check/middleware.go @@ -126,7 +126,7 @@ func (m *Middleware) Invoke(ctx context.Context, in *middleware.Input) (*middlew } if resp.GetDecision() == "deny" { - return denyFromManagement(resp), nil + return denyFromManagement(resp, lookupKV(in.Metadata, middleware.KeyLLMProvider)), nil } return allowFromManagement(resp), nil } @@ -170,7 +170,7 @@ func allowFromManagement(resp *proto.CheckLLMPolicyLimitsResponse) *middleware.O // envelope. The deny code surfaces verbatim through the framework's // fixed JSON template; arbitrary middleware bytes can't reach the // wire. -func denyFromManagement(resp *proto.CheckLLMPolicyLimitsResponse) *middleware.Output { +func denyFromManagement(resp *proto.CheckLLMPolicyLimitsResponse, surface string) *middleware.Output { code := resp.GetDenyCode() if code == "" { code = "llm_policy.cap_exceeded" @@ -185,6 +185,7 @@ func denyFromManagement(resp *proto.CheckLLMPolicyLimitsResponse) *middleware.Ou DenyReason: &middleware.DenyReason{ Code: code, Message: denyMessageForCode(code), + Surface: surface, }, Metadata: []middleware.KV{ {Key: middleware.KeyLLMPolicyDecision, Value: "deny"}, diff --git a/proxy/internal/middleware/builtin/llm_router/middleware.go b/proxy/internal/middleware/builtin/llm_router/middleware.go index 384f3c50b..904e8f1fd 100644 --- a/proxy/internal/middleware/builtin/llm_router/middleware.go +++ b/proxy/internal/middleware/builtin/llm_router/middleware.go @@ -143,23 +143,26 @@ func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middlewar // 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) + // The caller's API dialect, used to mirror a denial in the vendor's own + // error shape so the client can explain it to the user. + surface, _ := lookupMetadata(in.Metadata, middleware.KeyLLMProvider) 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 + if surface == "" { + return denyUnmeterable(surface), nil } route, outcome := m.matchVertex(reqPath, model, in.UserGroups) switch outcome { case matchOutcomeFound: - return m.allowWithRoute(route, in.UserGroups), nil + return m.allowWithRoute(route, surface, in.UserGroups), nil case matchOutcomeUnauthorised: - return denyNoAuthorisedRoute(model), nil + return denyNoAuthorisedRoute(surface, model), nil default: - return denyUnknownModel(model), nil + return denyUnknownModel(surface, model), nil } } @@ -173,15 +176,15 @@ func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middlewar route, outcome := m.matchBedrock(native, model, in.UserGroups) switch outcome { case matchOutcomeFound: - out := m.allowWithRoute(route, in.UserGroups) + out := m.allowWithRoute(route, surface, 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 + return denyNoAuthorisedRoute(surface, model), nil default: - return denyUnknownModel(model), nil + return denyUnknownModel(surface, model), nil } } @@ -194,28 +197,27 @@ func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middlewar route, outcome := m.matchModelless(requestPath(in.URL), in.UserGroups) switch outcome { case matchOutcomeFound: - out := m.allowWithRoute(route, in.UserGroups) + out := m.allowWithRoute(route, surface, in.UserGroups) out.Metadata = append(out.Metadata, middleware.KV{Key: middleware.KeyLLMNonInference, Value: "true"}) return out, 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 + return denyNoAuthorisedRoute(surface, model), nil default: - return denyMissingModel(), nil + return denyMissingModel(surface), nil } } - vendor, _ := lookupMetadata(in.Metadata, middleware.KeyLLMProvider) - route, outcome := m.matchRoute(model, vendor, requestPath(in.URL), in.UserGroups) + route, outcome := m.matchRoute(model, surface, requestPath(in.URL), in.UserGroups) switch outcome { case matchOutcomeFound: - return m.allowWithRoute(route, in.UserGroups), nil + return m.allowWithRoute(route, surface, in.UserGroups), nil case matchOutcomeUnauthorised: - return denyNoAuthorisedRoute(model), nil + return denyNoAuthorisedRoute(surface, model), nil default: - return denyUnknownModel(model), nil + return denyUnknownModel(surface, model), nil } } @@ -621,7 +623,7 @@ func requestPath(raw string) string { // 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 { +func (m *Middleware) allowWithRoute(route ProviderRoute, surface string, userGroups []string) *middleware.Output { rewrite := &middleware.UpstreamRewrite{ Scheme: route.UpstreamScheme, Host: route.UpstreamHost, @@ -643,7 +645,7 @@ func (m *Middleware) allowWithRoute(route ProviderRoute, userGroups []string) *m // request time (cached + auto-refreshed) instead of a static value. bearer, err := m.gcpBearer(route.GCPServiceAccountKeyB64) if err != nil { - return denyUpstreamAuth() + return denyUpstreamAuth(surface) } authValue = bearer } @@ -713,11 +715,12 @@ func (m *Middleware) gcpTokenSource(saKeyB64 string) (oauth2.TokenSource, error) // 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 { +func denyUpstreamAuth(surface string) *middleware.Output { return &middleware.Output{ Decision: middleware.DecisionDeny, DenyStatus: 502, DenyReason: &middleware.DenyReason{ + Surface: surface, Code: denyCodeUpstreamAuth, Message: "could not obtain upstream credential", }, @@ -731,11 +734,12 @@ func denyUpstreamAuth() *middleware.Output { // 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 { +func denyUnmeterable(surface string) *middleware.Output { return &middleware.Output{ Decision: middleware.DecisionDeny, DenyStatus: 403, DenyReason: &middleware.DenyReason{ + Surface: surface, Code: denyCodeUnmeterable, Message: "request publisher is not supported for metering", }, @@ -748,11 +752,12 @@ func denyUnmeterable() *middleware.Output { // denyMissingModel returns the deny envelope for a request whose // envelope has no llm.model metadata. -func denyMissingModel() *middleware.Output { +func denyMissingModel(surface string) *middleware.Output { return &middleware.Output{ Decision: middleware.DecisionDeny, DenyStatus: 403, DenyReason: &middleware.DenyReason{ + Surface: surface, Code: denyCodeNotRoutable, Message: "missing llm.model on request envelope", }, @@ -765,11 +770,12 @@ func denyMissingModel() *middleware.Output { // denyUnknownModel returns the deny envelope for a model that no // configured provider claims. -func denyUnknownModel(model string) *middleware.Output { +func denyUnknownModel(surface, model string) *middleware.Output { return &middleware.Output{ Decision: middleware.DecisionDeny, DenyStatus: 403, DenyReason: &middleware.DenyReason{ + Surface: surface, Code: denyCodeNotRoutable, Message: fmt.Sprintf("no provider configured for model %s", model), Details: map[string]string{"model": model}, @@ -784,11 +790,12 @@ func denyUnknownModel(model string) *middleware.Output { // 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 { +func denyNoAuthorisedRoute(surface, model string) *middleware.Output { return &middleware.Output{ Decision: middleware.DecisionDeny, DenyStatus: 403, DenyReason: &middleware.DenyReason{ + Surface: surface, Code: denyCodeNoAuthorisedRoute, Message: fmt.Sprintf("no policy authorises model %s for the caller's groups", model), Details: map[string]string{"model": model}, diff --git a/proxy/internal/middleware/decision.go b/proxy/internal/middleware/decision.go index 0970bdea4..97dca4af5 100644 --- a/proxy/internal/middleware/decision.go +++ b/proxy/internal/middleware/decision.go @@ -11,11 +11,78 @@ var codeRegex = regexp.MustCompile(`^[a-z][a-z0-9._-]{0,63}$`) // denyResponse is the on-wire shape rendered by RenderDenyResponse. // Keeping this as a typed struct ensures we never leak // middleware-supplied bytes outside known fields. +// +// Type and Error mirror the denial in the vendor's own error shape when +// the request reached a known LLM surface. LLM clients only parse their +// provider's envelope, so without the mirror a budget stop reaches the +// user as an unexplained API error. The NetBird fields stay where they +// were, so the body is a superset and existing consumers are unaffected. type denyResponse struct { Code string `json:"code"` Message string `json:"message,omitempty"` Details map[string]string `json:"details,omitempty"` Middleware string `json:"middleware,omitempty"` + Type string `json:"type,omitempty"` + Error *providerError `json:"error,omitempty"` +} + +// providerError is the nested error object both vendor envelopes carry. +type providerError struct { + Type string `json:"type"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` +} + +// Vendor error types keyed by HTTP status, per each provider's published +// error reference. +const ( + anthropicErrInvalidRequest = "invalid_request_error" + anthropicErrPermission = "permission_error" + anthropicErrRateLimit = "rate_limit_error" + anthropicErrAPI = "api_error" + openAIErrInvalidRequest = "invalid_request_error" + openAIErrRateLimit = "rate_limit_error" +) + +// providerEnvelope returns the vendor-shaped mirror for a denial on the +// given surface, or nil when the surface has no envelope we can speak. +// message is the already-redacted public message. +func providerEnvelope(surface, code, message string, status int) (string, *providerError) { + switch surface { + case "anthropic": + return "error", &providerError{ + Type: anthropicErrorType(status), + Message: message, + } + case "openai": + return "", &providerError{ + Type: openAIErrorType(status), + Message: message, + Code: code, + } + default: + return "", nil + } +} + +func anthropicErrorType(status int) string { + switch status { + case http.StatusForbidden: + return anthropicErrPermission + case http.StatusTooManyRequests: + return anthropicErrRateLimit + case http.StatusBadRequest: + return anthropicErrInvalidRequest + default: + return anthropicErrAPI + } +} + +func openAIErrorType(status int) string { + if status == http.StatusTooManyRequests { + return openAIErrRateLimit + } + return openAIErrInvalidRequest } // RenderDenyResponse writes a structured JSON deny body. Status is @@ -36,6 +103,7 @@ func RenderDenyResponse(w http.ResponseWriter, middlewareID string, reason *Deny Message: truncate(Scan(reason.Message), 256), Middleware: truncate(Scan(middlewareID), 64), } + resp.Type, resp.Error = providerEnvelope(reason.Surface, resp.Code, resp.Message, status) if n := len(reason.Details); n > 0 { resp.Details = make(map[string]string, min(n, 8)) for k, v := range reason.Details { diff --git a/proxy/internal/middleware/decision_test.go b/proxy/internal/middleware/decision_test.go new file mode 100644 index 000000000..cf14c86ff --- /dev/null +++ b/proxy/internal/middleware/decision_test.go @@ -0,0 +1,92 @@ +package middleware + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// decodeDeny renders a denial and returns the parsed body plus the status. +func decodeDeny(t *testing.T, reason *DenyReason, status int) (map[string]any, int) { + t.Helper() + rec := httptest.NewRecorder() + RenderDenyResponse(rec, "llm_limit_check", reason, status) + + var body map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body), "deny body must be valid JSON") + return body, rec.Code +} + +// TestRenderDeny_AnthropicSurfaceMirrorsVendorShape covers a budget stop +// reaching Claude Code. The client only parses the Anthropic envelope, so +// without the mirror the user sees an unexplained API error instead of the +// reason their request was refused. +func TestRenderDeny_AnthropicSurfaceMirrorsVendorShape(t *testing.T) { + body, status := decodeDeny(t, &DenyReason{ + Code: "llm_policy.budget_cap_exceeded", + Message: "LLM policy limit exceeded", + Surface: "anthropic", + }, http.StatusForbidden) + + assert.Equal(t, http.StatusForbidden, status) + assert.Equal(t, "error", body["type"], "Anthropic errors carry type=error at the top level") + + errObj, ok := body["error"].(map[string]any) + require.True(t, ok, "error must be an object") + assert.Equal(t, "permission_error", errObj["type"], "403 maps to permission_error") + assert.Equal(t, "LLM policy limit exceeded", errObj["message"]) + + // The NetBird fields stay put so existing consumers keep working. + assert.Equal(t, "llm_policy.budget_cap_exceeded", body["code"]) + assert.Equal(t, "LLM policy limit exceeded", body["message"]) + assert.Equal(t, "llm_limit_check", body["middleware"]) +} + +// TestRenderDeny_OpenAISurfaceMirrorsVendorShape pins the OpenAI envelope, +// which nests the code and carries no top-level type. +func TestRenderDeny_OpenAISurfaceMirrorsVendorShape(t *testing.T) { + body, _ := decodeDeny(t, &DenyReason{ + Code: "llm_policy.model_blocked", + Message: "model is not in the policy allowlist", + Surface: "openai", + }, http.StatusForbidden) + + assert.NotContains(t, body, "type", "OpenAI errors have no top-level type") + + errObj, ok := body["error"].(map[string]any) + require.True(t, ok, "error must be an object") + assert.Equal(t, "invalid_request_error", errObj["type"]) + assert.Equal(t, "llm_policy.model_blocked", errObj["code"], "the NetBird code rides in the vendor code field") + assert.Equal(t, "model is not in the policy allowlist", errObj["message"]) +} + +// TestRenderDeny_RateLimitStatusMapsToVendorRateLimit pins the mapping a +// client's backoff keys on. +func TestRenderDeny_RateLimitStatusMapsToVendorRateLimit(t *testing.T) { + body, status := decodeDeny(t, &DenyReason{ + Code: "llm_policy.token_cap_exceeded", + Message: "LLM policy limit exceeded", + Surface: "anthropic", + }, http.StatusTooManyRequests) + + assert.Equal(t, http.StatusTooManyRequests, status, "429 must survive the status clamp") + errObj := body["error"].(map[string]any) + assert.Equal(t, "rate_limit_error", errObj["type"]) +} + +// TestRenderDeny_NoSurfaceKeepsLegacyShape guards non-LLM middlewares and +// denials raised before a surface is known. +func TestRenderDeny_NoSurfaceKeepsLegacyShape(t *testing.T) { + body, _ := decodeDeny(t, &DenyReason{ + Code: "llm_policy.model_not_routable", + Message: "no provider configured for model x", + }, http.StatusForbidden) + + assert.NotContains(t, body, "type", "no surface means no vendor mirror") + assert.NotContains(t, body, "error", "no surface means no vendor mirror") + assert.Equal(t, "llm_policy.model_not_routable", body["code"]) +} diff --git a/proxy/internal/middleware/types.go b/proxy/internal/middleware/types.go index 1ed5c9d88..f81892fc8 100644 --- a/proxy/internal/middleware/types.go +++ b/proxy/internal/middleware/types.go @@ -179,6 +179,12 @@ type DenyReason struct { Code string Message string Details map[string]string + // Surface names the LLM API dialect the caller speaks (the + // llm.provider value), so the rendered body can mirror the denial in + // that vendor's error shape alongside the NetBird fields. Empty for + // non-LLM middlewares and for denials raised before a surface was + // resolved; the body then carries the NetBird fields alone. + Surface string } // Output is the value each middleware returns to the dispatcher. The