Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions crates/aisix-guardrails/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,26 @@ shapes, both carrying the same bounded per-kind failure tag and neither
allowed to carry matched content (#153): an explicitly fail-open row
emits `Bypass`, a fail-closed row emits `Block { unavailable: Some(tag) }`.

**Carry that tag all the way to the caller.** A fail-closed availability
block and a content block are the same 422 with the same `error.type`, so
the tag is the only thing that separates "your policy fired" from "your
guardrail is broken" — drop it and an operator debugs a policy that is
fine while their traffic is refused. Every block site in `aisix-proxy`
therefore builds its message through `error::guardrail_block_message` /
`guardrail_block_error` and passes the verdict's `unavailable` through;
the tag also lands on `error.code = "guardrail_unavailable"`, the audit
hit's `blocked_unavailable`, and the histogram's `error_type`. A refusal
the proxy raises on a guardrail's behalf (a hold-back cap, a failed mask
splice) carries one too — see `error::TAG_*`.

**Give each failure cause its own tag.** Tags are what a dashboard shows,
so collapsing distinct operator mistakes into one catch-all costs the
operator the diagnosis: `custom_unknown_action` (a word we do not know)
and `custom_no_verdict` (no decision at all) need different fixes, and
neither is `custom_script_error` (their service is down). And a verdict
we cannot read is always a FAILURE, never an Allow — reading silence as
consent is the open door `fail_open: false` exists to close.

**`enforcement_mode: monitor` is unconditional.** A monitored row never
blocks, for any reason — not a content match, not a provider outage, not
a failure policy. Do not add an exception: the value of the mode is that
Expand Down
219 changes: 194 additions & 25 deletions crates/aisix-guardrails/src/custom.rs

Large diffs are not rendered by default.

11 changes: 8 additions & 3 deletions crates/aisix-obs/src/metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,9 +240,14 @@ pub const M_AUTH_DECISIONS_TOTAL: &str = "aisix_auth_decisions_total";
/// - `phase`: `input` / `output`.
/// - `result`: `allowed` / `blocked` / `masked` / `bypassed` (remote
/// failure + fail-open) / `would_block` / `would_mask` (monitor mode).
/// - `error_type`: bounded failure tag (e.g. `lakera_timeout`) when
/// `result="bypassed"`, else `none`. Fail-closed failures surface as
/// `blocked` (the timeout budget shows up in the latency distribution).
/// - `error_type`: bounded failure tag (e.g. `lakera_timeout`,
/// `custom_unknown_action`) whenever the guardrail could not EVALUATE
/// the content, else `none`. It is populated on `result="bypassed"`
/// (fail-open) and equally on the `result="blocked"` a fail-CLOSED row
/// produces for the same cause (AISIX-Cloud#1365) — `result` stays
/// `blocked` there so a shipped alert on it keeps counting, and
/// `error_type != "none"` is what separates "this content violated a
/// policy" from "this guardrail is broken or its provider is down".
///
/// The `_count` series doubles as a per-guardrail execution counter, so
/// there is no separate `aisix_guardrail_requests_total` (LiteLLM's
Expand Down
9 changes: 5 additions & 4 deletions crates/aisix-proxy/src/attempt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ pub(crate) fn attempt_error_from_proxy(err: &ProxyError) -> (String, String) {
pub(crate) fn attempt_reached_upstream(err: &ProxyError) -> bool {
match err {
ProxyError::Bridge(be) => be.reached_upstream(),
ProxyError::ContentFiltered(_) => true,
ProxyError::ContentFiltered { .. } => true,
ProxyError::MissingAuth
| ProxyError::MissingRouteAuthHeader(_)
| ProxyError::InvalidApiKey
Expand Down Expand Up @@ -400,9 +400,10 @@ mod tests {

// Only the output hook can fire inside a dispatch call, so the
// provider had already answered — the attempt did reach it.
assert!(attempt_reached_upstream(&ProxyError::ContentFiltered(
"blocked by response guardrail".into()
)));
assert!(attempt_reached_upstream(&ProxyError::ContentFiltered {
message: "blocked by response guardrail".into(),
unavailable: None,
}));
// Gateway-side refusals never contacted anyone.
assert!(!attempt_reached_upstream(&ProxyError::ModelNotFound(
"nope".into()
Expand Down
23 changes: 14 additions & 9 deletions crates/aisix-proxy/src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -933,7 +933,7 @@ async fn multipart_dispatch(
if let aisix_guardrails::GuardrailVerdict::Block {
reason,
guardrail_name,
..
unavailable,
} = verdict
{
// Per #153 the matched-pattern detail stays in ops logs only.
Expand All @@ -943,8 +943,10 @@ async fn multipart_dispatch(
reason = %reason,
"guardrail blocked audio request (prompt field)",
);
return Err(ProxyError::ContentFiltered(
crate::error::guardrail_block_message("request", guardrail_name.as_deref()),
return Err(crate::error::guardrail_block_error(
"request",
guardrail_name.as_deref(),
unavailable.as_deref(),
));
}
}
Expand Down Expand Up @@ -1432,7 +1434,7 @@ async fn multipart_dispatch(
if let aisix_guardrails::GuardrailVerdict::Block {
reason,
guardrail_name,
..
unavailable,
} = verdict
{
// Per #153 the matched-pattern detail stays in ops logs only.
Expand All @@ -1444,10 +1446,11 @@ async fn multipart_dispatch(
);
return Ok(AudioDispatchSuccess {
usage_handled_by_stream: false,
response: ProxyError::ContentFiltered(crate::error::guardrail_block_message(
response: crate::error::guardrail_block_error(
"response",
guardrail_name.as_deref(),
))
unavailable.as_deref(),
)
.into_response(),
model_name,
provider: provider_label,
Expand Down Expand Up @@ -1617,7 +1620,7 @@ async fn speech_dispatch(
if let aisix_guardrails::GuardrailVerdict::Block {
reason,
guardrail_name,
..
unavailable,
} = verdict
{
// Per #153 the matched-pattern detail stays in ops logs only.
Expand All @@ -1627,8 +1630,10 @@ async fn speech_dispatch(
reason = %reason,
"guardrail blocked /v1/audio/speech request",
);
return Err(ProxyError::ContentFiltered(
crate::error::guardrail_block_message("request", guardrail_name.as_deref()),
return Err(crate::error::guardrail_block_error(
"request",
guardrail_name.as_deref(),
unavailable.as_deref(),
));
}
}
Expand Down
53 changes: 30 additions & 23 deletions crates/aisix-proxy/src/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ pub async fn chat_completions(
// budget / rate-limit / bridge error after that point still
// records which model the request targeted. ContentFiltered
// (guardrail) sets `guardrail_blocked` for the Blocked tab.
let guardrail_blocked = matches!(err, ProxyError::ContentFiltered(_));
let guardrail_blocked = matches!(err, ProxyError::ContentFiltered { .. });
let model_id_str = resolved_model_id.as_deref().unwrap_or("");
// AISIX-Cloud#1013: failed requests carry the (post-mask)
// request body so a 4xx/5xx can be triaged from the log alone.
Expand Down Expand Up @@ -1319,7 +1319,7 @@ async fn dispatch(
GuardrailVerdict::Block {
reason,
guardrail_name,
..
unavailable,
} => {
// The verdict's `reason` carries matched-pattern detail
// (e.g. `"input blocked by literal \"forbidden-token\""`).
Expand All @@ -1345,8 +1345,10 @@ async fn dispatch(
reason = %reason,
"guardrail blocked request"
);
return Err(with_model(ProxyError::ContentFiltered(
crate::error::guardrail_block_message("request", guardrail_name.as_deref()),
return Err(with_model(crate::error::guardrail_block_error(
"request",
guardrail_name.as_deref(),
unavailable.as_deref(),
)));
}
GuardrailVerdict::Bypass { reason } => {
Expand Down Expand Up @@ -2433,19 +2435,18 @@ async fn dispatch(
GuardrailVerdict::Block {
reason,
guardrail_name,
..
unavailable,
} => {
tracing::warn!(
guardrail_hook = "output",
model = %req.model,
reason = %reason,
"guardrail blocked cached response",
);
return Err(with_model(ProxyError::ContentFiltered(
crate::error::guardrail_block_message(
"response",
guardrail_name.as_deref(),
),
return Err(with_model(crate::error::guardrail_block_error(
"response",
guardrail_name.as_deref(),
unavailable.as_deref(),
)));
}
GuardrailVerdict::Bypass { reason } => {
Expand Down Expand Up @@ -3020,7 +3021,7 @@ async fn dispatch(
GuardrailVerdict::Block {
reason,
guardrail_name,
..
unavailable,
} => {
// Output filter fires AFTER the upstream call, so the
// provider has already billed for these tokens. Surface
Expand Down Expand Up @@ -3068,10 +3069,11 @@ async fn dispatch(
return Err(DispatchFailure::new(
Some(model_id.clone()),
Some(charge),
ProxyError::ContentFiltered(crate::error::guardrail_block_message(
crate::error::guardrail_block_error(
"response",
guardrail_name.as_deref(),
)),
unavailable.as_deref(),
),
)
.with_routing(routing));
}
Expand Down Expand Up @@ -4066,7 +4068,7 @@ async fn dispatch_ensemble(
GuardrailVerdict::Block {
reason,
guardrail_name,
..
unavailable,
} => {
tracing::warn!(
guardrail_hook = "output",
Expand All @@ -4087,10 +4089,11 @@ async fn dispatch_ensemble(
return Err(DispatchFailure::new(
Some(model_id.to_string()),
None,
ProxyError::ContentFiltered(crate::error::guardrail_block_message(
crate::error::guardrail_block_error(
"response",
guardrail_name.as_deref(),
)),
unavailable.as_deref(),
),
));
}
GuardrailVerdict::Bypass { reason } => {
Expand Down Expand Up @@ -5290,7 +5293,7 @@ where
aisix_guardrails::GuardrailVerdict::Block {
reason,
guardrail_name,
..
unavailable,
} => {
tracing::warn!(
guardrail_hook = "output",
Expand All @@ -5307,7 +5310,7 @@ where
&crate::error::guardrail_block_message(
"response",
guardrail_name.as_deref(),
),
unavailable.as_deref()),
),
),
);
Expand Down Expand Up @@ -5374,7 +5377,11 @@ where
yield Ok::<_, Infallible>(
Event::default().event("error").data(error_frame_payload(
"content_filter",
"response blocked by content policy",
&crate::error::guardrail_block_message(
"response",
None,
Some(crate::error::TAG_OUTPUT_BUFFER_EXCEEDED),
),
)),
);
break;
Expand Down Expand Up @@ -5498,7 +5505,7 @@ where
aisix_guardrails::GuardrailVerdict::Block {
reason,
guardrail_name,
..
unavailable,
} => {
tracing::warn!(
guardrail_hook = "output",
Expand All @@ -5514,7 +5521,7 @@ where
&crate::error::guardrail_block_message(
"response",
guardrail_name.as_deref(),
),
unavailable.as_deref()),
)),
);
true
Expand Down Expand Up @@ -5586,7 +5593,7 @@ where
aisix_guardrails::GuardrailVerdict::Block {
reason,
guardrail_name,
..
unavailable,
} => {
// Mirror the non-streaming path's #153
// redaction contract: the wire-level message
Expand All @@ -5610,7 +5617,7 @@ where
&crate::error::guardrail_block_message(
"response",
guardrail_name.as_deref(),
),
unavailable.as_deref()),
)),
);
}
Expand Down
19 changes: 10 additions & 9 deletions crates/aisix-proxy/src/completions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ async fn dispatch(
if let aisix_guardrails::GuardrailVerdict::Block {
reason,
guardrail_name,
..
unavailable,
} = verdict
{
// Per #153 the matched-pattern detail stays in ops logs only.
Expand All @@ -360,8 +360,10 @@ async fn dispatch(
reason = %reason,
"guardrail blocked /v1/completions request",
);
return Err(ProxyError::ContentFiltered(
crate::error::guardrail_block_message("request", guardrail_name.as_deref()),
return Err(crate::error::guardrail_block_error(
"request",
guardrail_name.as_deref(),
unavailable.as_deref(),
));
}
}
Expand Down Expand Up @@ -518,7 +520,7 @@ async fn dispatch(
if let aisix_guardrails::GuardrailVerdict::Block {
reason,
guardrail_name,
..
unavailable,
} = verdict
{
// Per #153 the matched-pattern detail stays in ops logs only.
Expand All @@ -535,11 +537,10 @@ async fn dispatch(
// under-report spend the customer was charged for. Same
// output analog as responses.rs #543 / chat.rs UpstreamCharge.
return Ok(CompletionDispatchSuccess {
response: ProxyError::ContentFiltered(
crate::error::guardrail_block_message(
"response",
guardrail_name.as_deref(),
),
response: crate::error::guardrail_block_error(
"response",
guardrail_name.as_deref(),
unavailable.as_deref(),
)
.into_response(),
provider: provider_label,
Expand Down
8 changes: 5 additions & 3 deletions crates/aisix-proxy/src/embeddings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ async fn dispatch(
if let aisix_guardrails::GuardrailVerdict::Block {
reason,
guardrail_name,
..
unavailable,
} = verdict
{
// Per #153 keep the matched-pattern detail in ops logs only; the
Expand All @@ -373,8 +373,10 @@ async fn dispatch(
reason = %reason,
"guardrail blocked /v1/embeddings request",
);
return Err(ProxyError::ContentFiltered(
crate::error::guardrail_block_message("request", guardrail_name.as_deref()),
return Err(crate::error::guardrail_block_error(
"request",
guardrail_name.as_deref(),
unavailable.as_deref(),
));
}
}
Expand Down
Loading