mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-08 07:51:28 +02:00
proxy: model-specific deny message for model-allowlist rejections
denyFromManagement returned the generic "LLM policy limit exceeded" for every deny code. Map llm_policy.model_blocked / model_unknown to the same model-specific messages the local guardrail uses, keeping other codes generic so internal quota detail never leaks. Document the fail-open trade-off: the proxy backstop carries the union of restricting policies' models, so per-group narrowing is lost during a CheckLLMPolicyLimits outage.
This commit is contained in:
@@ -175,7 +175,7 @@ func denyFromManagement(resp *proto.CheckLLMPolicyLimitsResponse) *middleware.Ou
|
||||
DenyStatus: 403,
|
||||
DenyReason: &middleware.DenyReason{
|
||||
Code: code,
|
||||
Message: "LLM policy limit exceeded",
|
||||
Message: denyMessageForCode(code),
|
||||
},
|
||||
Metadata: []middleware.KV{
|
||||
{Key: middleware.KeyLLMPolicyDecision, Value: "deny"},
|
||||
@@ -184,6 +184,21 @@ func denyFromManagement(resp *proto.CheckLLMPolicyLimitsResponse) *middleware.Ou
|
||||
}
|
||||
}
|
||||
|
||||
// denyMessageForCode maps a management deny code to a public message.
|
||||
// Model-allowlist rejections get a model-specific message matching the
|
||||
// local guardrail; everything else keeps the generic quota wording. The
|
||||
// message stays generic so it never leaks internal quota detail.
|
||||
func denyMessageForCode(code string) string {
|
||||
switch code {
|
||||
case "llm_policy.model_blocked":
|
||||
return "model is not in the policy allowlist"
|
||||
case "llm_policy.model_unknown":
|
||||
return "request model could not be determined for the policy allowlist"
|
||||
default:
|
||||
return "LLM policy limit exceeded"
|
||||
}
|
||||
}
|
||||
|
||||
// lookupKV returns the value associated with key, or the empty
|
||||
// string when absent.
|
||||
func lookupKV(kvs []middleware.KV, key string) string {
|
||||
|
||||
@@ -115,6 +115,34 @@ func TestInvoke_DenyConvertsToProxyDeny(t *testing.T) {
|
||||
assert.NotContains(t, out.DenyReason.Message, "1000", "internal cap numbers must not reach the caller")
|
||||
}
|
||||
|
||||
// TestInvoke_ModelBlockedDenyMessage proves a model-allowlist rejection
|
||||
// gets a model-specific public message rather than the generic quota
|
||||
// wording, so a blocked model reads consistently with the local guardrail.
|
||||
func TestInvoke_ModelBlockedDenyMessage(t *testing.T) {
|
||||
mgmt := &fakeMgmt{
|
||||
checkResp: &proto.CheckLLMPolicyLimitsResponse{
|
||||
Decision: "deny",
|
||||
DenyCode: "llm_policy.model_blocked",
|
||||
},
|
||||
}
|
||||
m := New(mgmt, nil)
|
||||
|
||||
out := runInvoke(t, m, &middleware.Input{
|
||||
AccountID: "acc-1",
|
||||
UserGroups: []string{"grp-engineers"},
|
||||
Metadata: []middleware.KV{
|
||||
{Key: middleware.KeyLLMResolvedProviderID, Value: "prov-1"},
|
||||
{Key: middleware.KeyLLMModel, Value: "blocked-model"},
|
||||
},
|
||||
})
|
||||
|
||||
assert.Equal(t, middleware.DecisionDeny, out.Decision)
|
||||
require.NotNil(t, out.DenyReason, "deny envelope must carry a reason payload")
|
||||
assert.Equal(t, "llm_policy.model_blocked", out.DenyReason.Code, "canonical deny code surfaces to the caller")
|
||||
assert.Equal(t, "model is not in the policy allowlist", out.DenyReason.Message,
|
||||
"model denials must use a model-specific message, matching the local guardrail")
|
||||
}
|
||||
|
||||
// TestInvoke_NoMgmtClientPassesThrough proves the partial-wiring
|
||||
// safety: a middleware constructed without a management client
|
||||
// allows every request without attribution. This makes a half-set-up
|
||||
|
||||
Reference in New Issue
Block a user