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
11 changes: 11 additions & 0 deletions crates/aisix-obs/src/usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,17 @@ pub struct UsageEvent {
pub cost_usd: f64,

/// True when a guardrail rejected the request (input or output).
///
/// cp-api indexes this and the dashboard's Logs "Guardrail blocks"
/// view is the exact predicate `guardrail_blocked = true`, so it is
/// the ONLY thing that puts a refusal in front of an operator — a
/// refused request whose event leaves the field at its `false`
/// default is still in the unfiltered feed, which makes the empty
/// Blocked view read as "no guardrail activity" rather than as a
/// missing row (AISIX-Cloud#1428). Every emitter on a failure path
/// must therefore set it from `ProxyError::is_guardrail_block`,
/// including a stream refused after its 200 head went out: there the
/// status stays 200 and this bool is the whole record of the block.
pub guardrail_blocked: bool,

/// Set when at least one remote-API guardrail (today: kind=bedrock)
Expand Down
14 changes: 14 additions & 0 deletions crates/aisix-proxy/src/a2a.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,9 @@ async fn dispatch(
Duration::ZERO,
trace.as_ref(),
/* dispatched */ false,
// A quota refusal is not a guardrail decision.
/* guardrail_blocked */
false,
);
return response;
}
Expand Down Expand Up @@ -370,6 +373,7 @@ async fn dispatch(
latency,
trace.as_ref(),
/* dispatched */ true,
/* guardrail_blocked */ false,
);
axum::Json(response_value).into_response()
}
Expand All @@ -387,6 +391,7 @@ async fn dispatch(
latency,
trace.as_ref(),
/* dispatched */ true,
/* guardrail_blocked */ false,
);
a2a_error_response(rpc_id, status, &err.to_string())
}
Expand Down Expand Up @@ -457,6 +462,7 @@ impl Drop for StreamUsageOnDrop {
self.started.elapsed(),
self.trace.as_ref(),
/* dispatched */ true,
/* guardrail_blocked */ false,
);
}
}
Expand Down Expand Up @@ -501,6 +507,7 @@ async fn dispatch_stream(
started.elapsed(),
trace.as_ref(),
/* dispatched */ true,
/* guardrail_blocked */ false,
);
return a2a_error_response(rpc_id, status, &err.to_string());
}
Expand Down Expand Up @@ -781,6 +788,8 @@ async fn guardrail_block_response(
Duration::ZERO,
trace,
/* dispatched */ false,
// This IS the guardrail refusal.
/* guardrail_blocked */ true,
);
Some(response)
}
Expand Down Expand Up @@ -833,6 +842,10 @@ fn emit_a2a_usage(
// Whether the call reached the upstream agent — false for a quota
// rejection, which refuses before any upstream contact.
dispatched: bool,
// Whether a guardrail refused the call. `/a2a` emits exactly one event
// per call, so this row is the only place a refusal can appear to the
// dashboard's "Guardrail blocks" view (AISIX-Cloud#1428).
guardrail_blocked: bool,
) {
// No model resolves on this endpoint, so the estimator falls back to its
// default encoding — the same thing it does for any non-OpenAI model.
Expand Down Expand Up @@ -873,6 +886,7 @@ fn emit_a2a_usage(
.ttfb
.map(|d| d.as_millis().min(u32::MAX as u128) as u32)
.unwrap_or_default(),
guardrail_blocked,
..Default::default()
};
crate::usage_attr::apply_jwt_identity(&mut event, auth.jwt.as_ref());
Expand Down
3 changes: 3 additions & 0 deletions crates/aisix-proxy/src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ pub async fn transcriptions(
&api_key_id,
status,
err.kind(),
err.is_guardrail_block(),
&client,
crate::usage_attr::enforced_hits(&audit),
);
Expand Down Expand Up @@ -400,6 +401,7 @@ pub async fn translations(
&api_key_id,
status,
err.kind(),
err.is_guardrail_block(),
&client,
crate::usage_attr::enforced_hits(&audit),
);
Expand Down Expand Up @@ -568,6 +570,7 @@ pub async fn speech(
&api_key_id,
status,
err.kind(),
err.is_guardrail_block(),
&client,
crate::usage_attr::enforced_hits(&audit),
);
Expand Down
7 changes: 5 additions & 2 deletions crates/aisix-proxy/src/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -459,8 +459,11 @@ pub async fn chat_completions(
// `req.model` resolves against the snapshot, so a guardrail /
// 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 { .. });
// (guardrail) sets `guardrail_blocked` for the Blocked tab —
// through the shared predicate every handler now reads, so the
// family cannot answer this question two different ways
// (AISIX-Cloud#1428).
let guardrail_blocked = err.is_guardrail_block();
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
1 change: 1 addition & 0 deletions crates/aisix-proxy/src/completions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ pub async fn completions(
&api_key_id,
status,
err.kind(),
err.is_guardrail_block(),
&client,
crate::usage_attr::enforced_hits(&audit),
);
Expand Down
1 change: 1 addition & 0 deletions crates/aisix-proxy/src/embeddings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ pub async fn embeddings(
&api_key_id,
status,
err.kind(),
err.is_guardrail_block(),
&client,
crate::usage_attr::enforced_hits(&audit),
);
Expand Down
22 changes: 22 additions & 0 deletions crates/aisix-proxy/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,28 @@ impl ProxyError {
}
}

/// Whether this failure IS a guardrail refusal — the request was
/// stopped by the guardrail machinery rather than by an upstream, a
/// quota, a credential or a malformed body.
///
/// Drives `UsageEvent::guardrail_blocked`, which is the indexed column
/// the dashboard's "Guardrail blocks" view and the
/// `guardrail_blocked=true` usage query filter on. Every failure-path
/// emitter reads it from here rather than re-deriving the match, so a
/// new handler cannot join the family with the flag silently left at
/// its `false` default (AISIX-Cloud#1428).
///
/// A fail-closed refusal (`unavailable: Some(_)` — the guardrail could
/// not evaluate the request and its row refuses what it cannot check)
/// counts too: the guardrail machinery is still what stopped the
/// request, and hiding it from the Blocked view would leave an operator
/// with a 422 that nothing accounts for. Which of the two it was stays
/// legible on `guardrail_enforced_hits.action`
/// (`blocked` vs `blocked_unavailable`).
pub(crate) fn is_guardrail_block(&self) -> bool {
matches!(self, ProxyError::ContentFiltered { .. })
}

/// Seconds the client should wait before retrying. Only present for
/// rate-limit-style rejections so the proxy can emit a `Retry-After`
/// header.
Expand Down
Loading