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
57 changes: 57 additions & 0 deletions crates/aisix-core/src/models/passthrough_route.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -379,6 +379,30 @@ pub fn passthrough_route_coupling() -> Value {
}
}
},
// Cross-mode leftovers are configuration errors, not ignored
// fields: a companion outside its mode is never consulted, so a
// row carrying one is rejected rather than half-honored. The CP
// enforces the identical rule on create and patch.
{
"title": "auth_header_name only in header_key mode",
"if": {
"anyOf": [
{ "title": "auth_mode omitted (defaults to gateway_key)", "not": { "required": ["auth_mode"] } },
{ "title": "auth_mode: gateway_key or anonymous", "properties": { "auth_mode": { "enum": ["gateway_key", "anonymous"] } }, "required": ["auth_mode"] }
]
},
"then": { "not": { "required": ["auth_header_name"] } }
},
{
"title": "anonymous_key_id only in anonymous mode",
"if": {
"anyOf": [
{ "title": "auth_mode omitted (defaults to gateway_key)", "not": { "required": ["auth_mode"] } },
{ "title": "auth_mode: gateway_key or header_key", "properties": { "auth_mode": { "enum": ["gateway_key", "header_key"] } }, "required": ["auth_mode"] }
]
},
"then": { "not": { "required": ["anonymous_key_id"] } }
},
// credential_mode couplings: inject needs a real ProviderKey id; a
// forward_client route carrying one is a configuration error, not
// an ignored field.
Expand DownExpand Up@@ -516,6 +540,39 @@ mod coupling_tests {
assert!(validate_passthrough_route(&doc).is_err());
}

#[test]
fn cross_mode_leftover_companions_are_rejected() {
// A companion outside its mode is never consulted at runtime, so
// both validators refuse the row instead of half-honoring it.
// auth_header_name without header_key (auth_mode absent = gateway_key).
let mut doc = base();
doc["auth_header_name"] = json!("x-aisix-api-key");
assert!(validate_passthrough_route(&doc).is_err());
assert!(validate_passthrough_route_lenient(&doc).is_err());
// anonymous_key_id on an explicit header_key route.
let doc = json!({
"name": "r", "path_prefix": "/p",
"target_url": "https://u.example", "provider_key_id": "pk",
"auth_mode": "header_key", "auth_header_name": "x-aisix-api-key",
"anonymous_key_id": "ak-1"
});
assert!(validate_passthrough_route(&doc).is_err());
// auth_header_name on an anonymous route.
let doc = json!({
"name": "r", "path_prefix": "/p",
"target_url": "https://u.example", "provider_key_id": "pk",
"auth_mode": "anonymous", "anonymous_key_id": "ak-1",
"source_cidrs": ["10.0.0.0/8"],
"auth_header_name": "x-aisix-api-key"
});
assert!(validate_passthrough_route(&doc).is_err());
// source_cidrs is deliberately NOT mode-coupled: an extra IP
// allowlist is honored on every mode.
let mut doc = base();
doc["source_cidrs"] = json!(["10.0.0.0/8"]);
assert!(validate_passthrough_route(&doc).is_ok());
}

#[test]
fn credential_bearing_header_slots_are_rejected() {
for field in ["auth_header_name", "identity_header"] {
Expand Down
60 changes: 60 additions & 0 deletions crates/aisix-obs/src/otlp_http_sink.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -743,6 +743,16 @@ fn build_otlp_span(record: &SinkRecord, exporter_name: &str) -> Value {
&event.client_user_agent,
));
}
// End-user identity a passthrough route's `identity_header` extracted —
// the per-employee attribution of the forward-proxy scenario. Not gated
// on the protocol so a future handler that learns to populate it exports
// it without touching this sink.
if !event.client_identity.is_empty() {
attributes.push(attr_string_capped(
"aisix.client_identity",
&event.client_identity,
));
}
// JWT identity attribution (AISIX-Cloud#564): who the request ran as
// when it authenticated with a JWT — the identity behind the (possibly
// shared) api_key_id.
Expand DownExpand Up@@ -802,6 +812,14 @@ fn build_otlp_span(record: &SinkRecord, exporter_name: &str) -> Value {
attributes.push(attr_string("aisix.mcp.server_name", &event.mcp_server_name));
}
}
"passthrough" => {
if !event.passthrough_route_name.is_empty() {
attributes.push(attr_string(
"aisix.passthrough.route_name",
&event.passthrough_route_name,
));
}
}
_ => {}
}
// Opt-in captured content (#519 B.2) — present ONLY on a record built by
Expand DownExpand Up@@ -841,6 +859,7 @@ fn operation_name(event: &UsageEvent) -> &'static str {
match event.inbound_protocol.as_str() {
"a2a" => "invoke_agent",
"mcp" => "execute_tool",
"passthrough" => "passthrough",
_ => "chat",
}
}
Expand All@@ -867,6 +886,9 @@ fn span_name(event: &UsageEvent) -> String {
let target = match event.inbound_protocol.as_str() {
"a2a" => &event.a2a_agent_name,
"mcp" => &event.mcp_tool_name,
// A route name is a registered resource like an agent name, so it is
// operator-bounded — but it still passes the length cap below.
"passthrough" => &event.passthrough_route_name,
_ => return "chat.completions".to_string(),
};
if target.is_empty() || target.len() > MAX_SPAN_NAME_TARGET {
Expand DownExpand Up@@ -1535,6 +1557,44 @@ mod tests {
assert_eq!(string_at("aisix.a2a.task_state"), "working");
}

#[test]
fn a_passthrough_relay_exports_route_and_identity_not_a_chat_span() {
// The passthrough handler emits usage events with
// `inbound_protocol = "passthrough"`; without the explicit branch a
// Copilot relay exported as a bare `chat.completions` span with the
// route and the device-injected end-user identity recorded nowhere.
let mut ev = sample_event();
ev.inbound_protocol = "passthrough".into();
ev.passthrough_route_name = "copilot-chat".into();
ev.client_identity = "alice@example.com".into();

let body = build_otlp_traces_payload(&ev, "test-exp");
let span = &body["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
assert_eq!(span["name"], "passthrough copilot-chat");
let attrs = span["attributes"].as_array().unwrap();
let find = |k: &str| attrs.iter().find(|a| a["key"] == k);
let string_at = |k: &str| find(k).unwrap()["value"]["stringValue"].clone();
assert_eq!(string_at("gen_ai.operation.name"), "passthrough");
assert_eq!(string_at("aisix.passthrough.route_name"), "copilot-chat");
assert_eq!(string_at("aisix.client_identity"), "alice@example.com");
}

#[test]
fn client_identity_exports_without_a_protocol_gate() {
// The identity attribute must not depend on the passthrough branch:
// any handler that populates the field gets it exported.
let mut ev = sample_event();
ev.client_identity = "bob@example.com".into();
let body = build_otlp_traces_payload(&ev, "test-exp");
let span = &body["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
let attrs = span["attributes"].as_array().unwrap();
let id = attrs
.iter()
.find(|a| a["key"] == "aisix.client_identity")
.unwrap();
assert_eq!(id["value"]["stringValue"], "bob@example.com");
}

#[test]
fn a_streamed_a2a_call_carries_its_event_count() {
// A unary call has no count, and exporting a zero would read as "the
Expand Down
1 change: 1 addition & 0 deletions crates/aisix-proxy/src/attempt.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -291,6 +291,7 @@ pub(crate) fn attempt_reached_upstream(err: &ProxyError) -> bool {
ProxyError::Bridge(be) => be.reached_upstream(),
ProxyError::ContentFiltered(_) => true,
ProxyError::MissingAuth
| ProxyError::MissingRouteAuthHeader(_)
| ProxyError::InvalidApiKey
| ProxyError::ApiKeyExpired
| ProxyError::ApiKeyDisabled
Expand Down
13 changes: 11 additions & 2 deletions crates/aisix-proxy/src/error.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -166,6 +166,13 @@ impl ErrorEnvelope {
pub enum ProxyError {
#[error("missing or malformed Authorization header")]
MissingAuth,
/// A `header_key` passthrough route saw no gateway key in its configured
/// header. Named separately from [`ProxyError::MissingAuth`] because on
/// these routes `Authorization` deliberately carries the caller's own
/// upstream credential — telling the integrator to fix `Authorization`
/// points at exactly the wrong header.
#[error("missing or malformed {0} header (this route's gateway API key header)")]
MissingRouteAuthHeader(String),
#[error("invalid API key")]
InvalidApiKey,
/// The presented key exists but its `expires_at` deadline has
Expand DownExpand Up@@ -336,7 +343,8 @@ pub(crate) fn guardrail_block_message(side: &str, guardrail_name: Option<&str>)
impl ProxyError {
pub fn status(&self) -> StatusCode {
match self {
ProxyError::MissingAuth
ProxyError::MissingRouteAuthHeader(_)
| ProxyError::MissingAuth
| ProxyError::InvalidApiKey
| ProxyError::ApiKeyExpired
| ProxyError::ApiKeyDisabled
Expand DownExpand Up@@ -369,7 +377,8 @@ impl ProxyError {

pub fn kind(&self) -> &'static str {
match self {
ProxyError::MissingAuth
ProxyError::MissingRouteAuthHeader(_)
| ProxyError::MissingAuth
| ProxyError::InvalidApiKey
| ProxyError::ApiKeyExpired
| ProxyError::ApiKeyDisabled
Expand Down
17 changes: 12 additions & 5 deletions crates/aisix-proxy/src/passthrough_route.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -352,10 +352,7 @@ pub async fn entry(
status,
elapsed,
);
crate::usage_attr::emit_error_usage_event(
&state,
&snapshot,
"passthrough_route",
let mut event = crate::usage_attr::build_error_usage_event(
"passthrough",
&request_id,
"",
Expand All@@ -364,6 +361,16 @@ pub async fn entry(
error.kind(),
&client,
);
// The route matched before the pipeline failed, so a rejected
// request still attributes to it — an operator triaging 401s
// per route needs the name on the event, not just in the log.
event.passthrough_route_name = route_name.clone();
crate::usage_attr::emit_prepared_usage_event(
&state,
&snapshot,
"passthrough_route",
event,
);
error.into_response()
}
}
Expand DownExpand Up@@ -917,7 +924,7 @@ async fn authenticate(
.and_then(|v| v.to_str().ok())
.map(|v| v.strip_prefix("Bearer ").unwrap_or(v).trim().to_string())
.filter(|v| !v.is_empty())
.ok_or(ProxyError::MissingAuth)?;
.ok_or_else(|| ProxyError::MissingRouteAuthHeader(name.to_string()))?;
crate::auth::authenticate_token(state, &token, ctx).await
}
PassthroughAuthMode::Anonymous => {
Expand Down
33 changes: 33 additions & 0 deletions crates/aisix-proxy/src/usage_attr.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -354,6 +354,30 @@ pub(crate) fn emit_error_usage_event(
error_class: &str,
client: &ClientContext,
) {
let event = build_error_usage_event(
inbound_protocol,
request_id,
requested_model,
api_key_id,
status_code,
error_class,
client,
);
emit_prepared_usage_event(state, snap, label, event);
}

/// The [`emit_error_usage_event`] event without the emission, for a caller
/// that attributes handler-specific fields (e.g. the passthrough route name)
/// before handing it to [`emit_prepared_usage_event`].
pub(crate) fn build_error_usage_event(
inbound_protocol: &'static str,
request_id: &str,
requested_model: &str,
api_key_id: &str,
status_code: u16,
error_class: &str,
client: &ClientContext,
) -> UsageEvent {
let mut event = UsageEvent {
request_id: request_id.to_string(),
occurred_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
Expand All@@ -367,6 +391,15 @@ pub(crate) fn emit_error_usage_event(
..Default::default()
};
apply_jwt_identity(&mut event, client.jwt.as_ref());
event
}

pub(crate) fn emit_prepared_usage_event(
state: &ProxyState,
snap: &AisixSnapshot,
label: &'static str,
event: UsageEvent,
) {
state.usage_sink.try_emit(label, event.clone());
let exporters = live_exporters(state, snap);
state
Expand Down
72 changes: 72 additions & 0 deletions schemas/resources/passthrough_route.schema.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -240,6 +240,78 @@
]
}
},
{
"if": {
"anyOf": [
{
"not": {
"required": [
"auth_mode"
]
},
"title": "auth_mode omitted (defaults to gateway_key)"
},
{
"properties": {
"auth_mode": {
"enum": [
"gateway_key",
"anonymous"
]
}
},
"required": [
"auth_mode"
],
"title": "auth_mode: gateway_key or anonymous"
}
]
},
"then": {
"not": {
"required": [
"auth_header_name"
]
}
},
"title": "auth_header_name only in header_key mode"
},
{
"if": {
"anyOf": [
{
"not": {
"required": [
"auth_mode"
]
},
"title": "auth_mode omitted (defaults to gateway_key)"
},
{
"properties": {
"auth_mode": {
"enum": [
"gateway_key",
"header_key"
]
}
},
"required": [
"auth_mode"
],
"title": "auth_mode: gateway_key or header_key"
}
]
},
"then": {
"not": {
"required": [
"anonymous_key_id"
]
}
},
"title": "anonymous_key_id only in anonymous mode"
},
{
"if": {
"anyOf": [
Expand Down
19 changes: 19 additions & 0 deletions tests/e2e/src/cases/passthrough-route-e2e.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -281,6 +281,25 @@ describe("passthrough-route e2e: explicit routes, BYO credentials, 410 tombstone
// …and the gateway's side-channel headers did not.
expect(hit.headers["x-aisix-api-key"]).toBeUndefined();
expect(hit.headers["x-aisix-user"]).toBeUndefined();

// Missing gateway key: the 401 must point at the route's configured
// header, not `Authorization` — on this route Authorization carries the
// caller's own upstream credential and is exactly the wrong thing to fix.
const noKey = await harnessRequest(`${app!.proxyUrl}/v1/chat/completions`, {
method: "POST",
headers: {
host: "ai-upstream.example.com",
authorization: "Bearer employee-official-token",
"content-type": "application/json",
},
body: JSON.stringify({ model: "gpt-4o", messages: [] }),
});
expect(noKey.statusCode).toBe(401);
const noKeyBody = (await noKey.body.json()) as {
error?: { message?: string };
};
expect(noKeyBody.error?.message).toContain("x-aisix-api-key");
expect(noKeyBody.error?.message).not.toContain("Authorization");
});

test("anonymous route binds the configured principal behind source_cidrs", async (ctx) => {
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(passthrough): OTLP route/identity attribution, route on rejected events, header_key 401 names its header by jarvis9443 · Pull Request #983 · api7/aisix · GitHub
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
57 changes: 57 additions & 0 deletions crates/aisix-core/src/models/passthrough_route.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -379,6 +379,30 @@ pub fn passthrough_route_coupling() -> Value {
}
}
},
// Cross-mode leftovers are configuration errors, not ignored
// fields: a companion outside its mode is never consulted, so a
// row carrying one is rejected rather than half-honored. The CP
// enforces the identical rule on create and patch.
{
"title": "auth_header_name only in header_key mode",
"if": {
"anyOf": [
{ "title": "auth_mode omitted (defaults to gateway_key)", "not": { "required": ["auth_mode"] } },
{ "title": "auth_mode: gateway_key or anonymous", "properties": { "auth_mode": { "enum": ["gateway_key", "anonymous"] } }, "required": ["auth_mode"] }
]
},
"then": { "not": { "required": ["auth_header_name"] } }
},
{
"title": "anonymous_key_id only in anonymous mode",
"if": {
"anyOf": [
{ "title": "auth_mode omitted (defaults to gateway_key)", "not": { "required": ["auth_mode"] } },
{ "title": "auth_mode: gateway_key or header_key", "properties": { "auth_mode": { "enum": ["gateway_key", "header_key"] } }, "required": ["auth_mode"] }
]
},
"then": { "not": { "required": ["anonymous_key_id"] } }
},
// credential_mode couplings: inject needs a real ProviderKey id; a
// forward_client route carrying one is a configuration error, not
// an ignored field.
Expand DownExpand Up@@ -516,6 +540,39 @@ mod coupling_tests {
assert!(validate_passthrough_route(&doc).is_err());
}

#[test]
fn cross_mode_leftover_companions_are_rejected() {
// A companion outside its mode is never consulted at runtime, so
// both validators refuse the row instead of half-honoring it.
// auth_header_name without header_key (auth_mode absent = gateway_key).
let mut doc = base();
doc["auth_header_name"] = json!("x-aisix-api-key");
assert!(validate_passthrough_route(&doc).is_err());
assert!(validate_passthrough_route_lenient(&doc).is_err());
// anonymous_key_id on an explicit header_key route.
let doc = json!({
"name": "r", "path_prefix": "/p",
"target_url": "https://u.example", "provider_key_id": "pk",
"auth_mode": "header_key", "auth_header_name": "x-aisix-api-key",
"anonymous_key_id": "ak-1"
});
assert!(validate_passthrough_route(&doc).is_err());
// auth_header_name on an anonymous route.
let doc = json!({
"name": "r", "path_prefix": "/p",
"target_url": "https://u.example", "provider_key_id": "pk",
"auth_mode": "anonymous", "anonymous_key_id": "ak-1",
"source_cidrs": ["10.0.0.0/8"],
"auth_header_name": "x-aisix-api-key"
});
assert!(validate_passthrough_route(&doc).is_err());
// source_cidrs is deliberately NOT mode-coupled: an extra IP
// allowlist is honored on every mode.
let mut doc = base();
doc["source_cidrs"] = json!(["10.0.0.0/8"]);
assert!(validate_passthrough_route(&doc).is_ok());
}

#[test]
fn credential_bearing_header_slots_are_rejected() {
for field in ["auth_header_name", "identity_header"] {
Expand Down
60 changes: 60 additions & 0 deletions crates/aisix-obs/src/otlp_http_sink.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -743,6 +743,16 @@ fn build_otlp_span(record: &SinkRecord, exporter_name: &str) -> Value {
&event.client_user_agent,
));
}
// End-user identity a passthrough route's `identity_header` extracted —
// the per-employee attribution of the forward-proxy scenario. Not gated
// on the protocol so a future handler that learns to populate it exports
// it without touching this sink.
if !event.client_identity.is_empty() {
attributes.push(attr_string_capped(
"aisix.client_identity",
&event.client_identity,
));
}
// JWT identity attribution (AISIX-Cloud#564): who the request ran as
// when it authenticated with a JWT — the identity behind the (possibly
// shared) api_key_id.
Expand DownExpand Up@@ -802,6 +812,14 @@ fn build_otlp_span(record: &SinkRecord, exporter_name: &str) -> Value {
attributes.push(attr_string("aisix.mcp.server_name", &event.mcp_server_name));
}
}
"passthrough" => {
if !event.passthrough_route_name.is_empty() {
attributes.push(attr_string(
"aisix.passthrough.route_name",
&event.passthrough_route_name,
));
}
}
_ => {}
}
// Opt-in captured content (#519 B.2) — present ONLY on a record built by
Expand DownExpand Up@@ -841,6 +859,7 @@ fn operation_name(event: &UsageEvent) -> &'static str {
match event.inbound_protocol.as_str() {
"a2a" => "invoke_agent",
"mcp" => "execute_tool",
"passthrough" => "passthrough",
_ => "chat",
}
}
Expand All@@ -867,6 +886,9 @@ fn span_name(event: &UsageEvent) -> String {
let target = match event.inbound_protocol.as_str() {
"a2a" => &event.a2a_agent_name,
"mcp" => &event.mcp_tool_name,
// A route name is a registered resource like an agent name, so it is
// operator-bounded — but it still passes the length cap below.
"passthrough" => &event.passthrough_route_name,
_ => return "chat.completions".to_string(),
};
if target.is_empty() || target.len() > MAX_SPAN_NAME_TARGET {
Expand DownExpand Up@@ -1535,6 +1557,44 @@ mod tests {
assert_eq!(string_at("aisix.a2a.task_state"), "working");
}

#[test]
fn a_passthrough_relay_exports_route_and_identity_not_a_chat_span() {
// The passthrough handler emits usage events with
// `inbound_protocol = "passthrough"`; without the explicit branch a
// Copilot relay exported as a bare `chat.completions` span with the
// route and the device-injected end-user identity recorded nowhere.
let mut ev = sample_event();
ev.inbound_protocol = "passthrough".into();
ev.passthrough_route_name = "copilot-chat".into();
ev.client_identity = "alice@example.com".into();

let body = build_otlp_traces_payload(&ev, "test-exp");
let span = &body["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
assert_eq!(span["name"], "passthrough copilot-chat");
let attrs = span["attributes"].as_array().unwrap();
let find = |k: &str| attrs.iter().find(|a| a["key"] == k);
let string_at = |k: &str| find(k).unwrap()["value"]["stringValue"].clone();
assert_eq!(string_at("gen_ai.operation.name"), "passthrough");
assert_eq!(string_at("aisix.passthrough.route_name"), "copilot-chat");
assert_eq!(string_at("aisix.client_identity"), "alice@example.com");
}

#[test]
fn client_identity_exports_without_a_protocol_gate() {
// The identity attribute must not depend on the passthrough branch:
// any handler that populates the field gets it exported.
let mut ev = sample_event();
ev.client_identity = "bob@example.com".into();
let body = build_otlp_traces_payload(&ev, "test-exp");
let span = &body["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
let attrs = span["attributes"].as_array().unwrap();
let id = attrs
.iter()
.find(|a| a["key"] == "aisix.client_identity")
.unwrap();
assert_eq!(id["value"]["stringValue"], "bob@example.com");
}

#[test]
fn a_streamed_a2a_call_carries_its_event_count() {
// A unary call has no count, and exporting a zero would read as "the
Expand Down
1 change: 1 addition & 0 deletions crates/aisix-proxy/src/attempt.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -291,6 +291,7 @@ pub(crate) fn attempt_reached_upstream(err: &ProxyError) -> bool {
ProxyError::Bridge(be) => be.reached_upstream(),
ProxyError::ContentFiltered(_) => true,
ProxyError::MissingAuth
| ProxyError::MissingRouteAuthHeader(_)
| ProxyError::InvalidApiKey
| ProxyError::ApiKeyExpired
| ProxyError::ApiKeyDisabled
Expand Down
13 changes: 11 additions & 2 deletions crates/aisix-proxy/src/error.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -166,6 +166,13 @@ impl ErrorEnvelope {
pub enum ProxyError {
#[error("missing or malformed Authorization header")]
MissingAuth,
/// A `header_key` passthrough route saw no gateway key in its configured
/// header. Named separately from [`ProxyError::MissingAuth`] because on
/// these routes `Authorization` deliberately carries the caller's own
/// upstream credential — telling the integrator to fix `Authorization`
/// points at exactly the wrong header.
#[error("missing or malformed {0} header (this route's gateway API key header)")]
MissingRouteAuthHeader(String),
#[error("invalid API key")]
InvalidApiKey,
/// The presented key exists but its `expires_at` deadline has
Expand DownExpand Up@@ -336,7 +343,8 @@ pub(crate) fn guardrail_block_message(side: &str, guardrail_name: Option<&str>)
impl ProxyError {
pub fn status(&self) -> StatusCode {
match self {
ProxyError::MissingAuth
ProxyError::MissingRouteAuthHeader(_)
| ProxyError::MissingAuth
| ProxyError::InvalidApiKey
| ProxyError::ApiKeyExpired
| ProxyError::ApiKeyDisabled
Expand DownExpand Up@@ -369,7 +377,8 @@ impl ProxyError {

pub fn kind(&self) -> &'static str {
match self {
ProxyError::MissingAuth
ProxyError::MissingRouteAuthHeader(_)
| ProxyError::MissingAuth
| ProxyError::InvalidApiKey
| ProxyError::ApiKeyExpired
| ProxyError::ApiKeyDisabled
Expand Down
17 changes: 12 additions & 5 deletions crates/aisix-proxy/src/passthrough_route.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -352,10 +352,7 @@ pub async fn entry(
status,
elapsed,
);
crate::usage_attr::emit_error_usage_event(
&state,
&snapshot,
"passthrough_route",
let mut event = crate::usage_attr::build_error_usage_event(
"passthrough",
&request_id,
"",
Expand All@@ -364,6 +361,16 @@ pub async fn entry(
error.kind(),
&client,
);
// The route matched before the pipeline failed, so a rejected
// request still attributes to it — an operator triaging 401s
// per route needs the name on the event, not just in the log.
event.passthrough_route_name = route_name.clone();
crate::usage_attr::emit_prepared_usage_event(
&state,
&snapshot,
"passthrough_route",
event,
);
error.into_response()
}
}
Expand DownExpand Up@@ -917,7 +924,7 @@ async fn authenticate(
.and_then(|v| v.to_str().ok())
.map(|v| v.strip_prefix("Bearer ").unwrap_or(v).trim().to_string())
.filter(|v| !v.is_empty())
.ok_or(ProxyError::MissingAuth)?;
.ok_or_else(|| ProxyError::MissingRouteAuthHeader(name.to_string()))?;
crate::auth::authenticate_token(state, &token, ctx).await
}
PassthroughAuthMode::Anonymous => {
Expand Down
33 changes: 33 additions & 0 deletions crates/aisix-proxy/src/usage_attr.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -354,6 +354,30 @@ pub(crate) fn emit_error_usage_event(
error_class: &str,
client: &ClientContext,
) {
let event = build_error_usage_event(
inbound_protocol,
request_id,
requested_model,
api_key_id,
status_code,
error_class,
client,
);
emit_prepared_usage_event(state, snap, label, event);
}

/// The [`emit_error_usage_event`] event without the emission, for a caller
/// that attributes handler-specific fields (e.g. the passthrough route name)
/// before handing it to [`emit_prepared_usage_event`].
pub(crate) fn build_error_usage_event(
inbound_protocol: &'static str,
request_id: &str,
requested_model: &str,
api_key_id: &str,
status_code: u16,
error_class: &str,
client: &ClientContext,
) -> UsageEvent {
let mut event = UsageEvent {
request_id: request_id.to_string(),
occurred_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
Expand All@@ -367,6 +391,15 @@ pub(crate) fn emit_error_usage_event(
..Default::default()
};
apply_jwt_identity(&mut event, client.jwt.as_ref());
event
}

pub(crate) fn emit_prepared_usage_event(
state: &ProxyState,
snap: &AisixSnapshot,
label: &'static str,
event: UsageEvent,
) {
state.usage_sink.try_emit(label, event.clone());
let exporters = live_exporters(state, snap);
state
Expand Down
72 changes: 72 additions & 0 deletions schemas/resources/passthrough_route.schema.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -240,6 +240,78 @@
]
}
},
{
"if": {
"anyOf": [
{
"not": {
"required": [
"auth_mode"
]
},
"title": "auth_mode omitted (defaults to gateway_key)"
},
{
"properties": {
"auth_mode": {
"enum": [
"gateway_key",
"anonymous"
]
}
},
"required": [
"auth_mode"
],
"title": "auth_mode: gateway_key or anonymous"
}
]
},
"then": {
"not": {
"required": [
"auth_header_name"
]
}
},
"title": "auth_header_name only in header_key mode"
},
{
"if": {
"anyOf": [
{
"not": {
"required": [
"auth_mode"
]
},
"title": "auth_mode omitted (defaults to gateway_key)"
},
{
"properties": {
"auth_mode": {
"enum": [
"gateway_key",
"header_key"
]
}
},
"required": [
"auth_mode"
],
"title": "auth_mode: gateway_key or header_key"
}
]
},
"then": {
"not": {
"required": [
"anonymous_key_id"
]
}
},
"title": "anonymous_key_id only in anonymous mode"
},
{
"if": {
"anyOf": [
Expand Down
19 changes: 19 additions & 0 deletions tests/e2e/src/cases/passthrough-route-e2e.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -281,6 +281,25 @@ describe("passthrough-route e2e: explicit routes, BYO credentials, 410 tombstone
// …and the gateway's side-channel headers did not.
expect(hit.headers["x-aisix-api-key"]).toBeUndefined();
expect(hit.headers["x-aisix-user"]).toBeUndefined();

// Missing gateway key: the 401 must point at the route's configured
// header, not `Authorization` — on this route Authorization carries the
// caller's own upstream credential and is exactly the wrong thing to fix.
const noKey = await harnessRequest(`${app!.proxyUrl}/v1/chat/completions`, {
method: "POST",
headers: {
host: "ai-upstream.example.com",
authorization: "Bearer employee-official-token",
"content-type": "application/json",
},
body: JSON.stringify({ model: "gpt-4o", messages: [] }),
});
expect(noKey.statusCode).toBe(401);
const noKeyBody = (await noKey.body.json()) as {
error?: { message?: string };
};
expect(noKeyBody.error?.message).toContain("x-aisix-api-key");
expect(noKeyBody.error?.message).not.toContain("Authorization");
});

test("anonymous route binds the configured principal behind source_cidrs", async (ctx) => {
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(passthrough): OTLP route/identity attribution, route on rejected events, header_key 401 names its header by jarvis9443 · Pull Request #983 · api7/aisix · GitHub
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
57 changes: 57 additions & 0 deletions crates/aisix-core/src/models/passthrough_route.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -379,6 +379,30 @@ pub fn passthrough_route_coupling() -> Value {
}
}
},
// Cross-mode leftovers are configuration errors, not ignored
// fields: a companion outside its mode is never consulted, so a
// row carrying one is rejected rather than half-honored. The CP
// enforces the identical rule on create and patch.
{
"title": "auth_header_name only in header_key mode",
"if": {
"anyOf": [
{ "title": "auth_mode omitted (defaults to gateway_key)", "not": { "required": ["auth_mode"] } },
{ "title": "auth_mode: gateway_key or anonymous", "properties": { "auth_mode": { "enum": ["gateway_key", "anonymous"] } }, "required": ["auth_mode"] }
]
},
"then": { "not": { "required": ["auth_header_name"] } }
},
{
"title": "anonymous_key_id only in anonymous mode",
"if": {
"anyOf": [
{ "title": "auth_mode omitted (defaults to gateway_key)", "not": { "required": ["auth_mode"] } },
{ "title": "auth_mode: gateway_key or header_key", "properties": { "auth_mode": { "enum": ["gateway_key", "header_key"] } }, "required": ["auth_mode"] }
]
},
"then": { "not": { "required": ["anonymous_key_id"] } }
},
// credential_mode couplings: inject needs a real ProviderKey id; a
// forward_client route carrying one is a configuration error, not
// an ignored field.
Expand DownExpand Up@@ -516,6 +540,39 @@ mod coupling_tests {
assert!(validate_passthrough_route(&doc).is_err());
}

#[test]
fn cross_mode_leftover_companions_are_rejected() {
// A companion outside its mode is never consulted at runtime, so
// both validators refuse the row instead of half-honoring it.
// auth_header_name without header_key (auth_mode absent = gateway_key).
let mut doc = base();
doc["auth_header_name"] = json!("x-aisix-api-key");
assert!(validate_passthrough_route(&doc).is_err());
assert!(validate_passthrough_route_lenient(&doc).is_err());
// anonymous_key_id on an explicit header_key route.
let doc = json!({
"name": "r", "path_prefix": "/p",
"target_url": "https://u.example", "provider_key_id": "pk",
"auth_mode": "header_key", "auth_header_name": "x-aisix-api-key",
"anonymous_key_id": "ak-1"
});
assert!(validate_passthrough_route(&doc).is_err());
// auth_header_name on an anonymous route.
let doc = json!({
"name": "r", "path_prefix": "/p",
"target_url": "https://u.example", "provider_key_id": "pk",
"auth_mode": "anonymous", "anonymous_key_id": "ak-1",
"source_cidrs": ["10.0.0.0/8"],
"auth_header_name": "x-aisix-api-key"
});
assert!(validate_passthrough_route(&doc).is_err());
// source_cidrs is deliberately NOT mode-coupled: an extra IP
// allowlist is honored on every mode.
let mut doc = base();
doc["source_cidrs"] = json!(["10.0.0.0/8"]);
assert!(validate_passthrough_route(&doc).is_ok());
}

#[test]
fn credential_bearing_header_slots_are_rejected() {
for field in ["auth_header_name", "identity_header"] {
Expand Down
60 changes: 60 additions & 0 deletions crates/aisix-obs/src/otlp_http_sink.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -743,6 +743,16 @@ fn build_otlp_span(record: &SinkRecord, exporter_name: &str) -> Value {
&event.client_user_agent,
));
}
// End-user identity a passthrough route's `identity_header` extracted —
// the per-employee attribution of the forward-proxy scenario. Not gated
// on the protocol so a future handler that learns to populate it exports
// it without touching this sink.
if !event.client_identity.is_empty() {
attributes.push(attr_string_capped(
"aisix.client_identity",
&event.client_identity,
));
}
// JWT identity attribution (AISIX-Cloud#564): who the request ran as
// when it authenticated with a JWT — the identity behind the (possibly
// shared) api_key_id.
Expand DownExpand Up@@ -802,6 +812,14 @@ fn build_otlp_span(record: &SinkRecord, exporter_name: &str) -> Value {
attributes.push(attr_string("aisix.mcp.server_name", &event.mcp_server_name));
}
}
"passthrough" => {
if !event.passthrough_route_name.is_empty() {
attributes.push(attr_string(
"aisix.passthrough.route_name",
&event.passthrough_route_name,
));
}
}
_ => {}
}
// Opt-in captured content (#519 B.2) — present ONLY on a record built by
Expand DownExpand Up@@ -841,6 +859,7 @@ fn operation_name(event: &UsageEvent) -> &'static str {
match event.inbound_protocol.as_str() {
"a2a" => "invoke_agent",
"mcp" => "execute_tool",
"passthrough" => "passthrough",
_ => "chat",
}
}
Expand All@@ -867,6 +886,9 @@ fn span_name(event: &UsageEvent) -> String {
let target = match event.inbound_protocol.as_str() {
"a2a" => &event.a2a_agent_name,
"mcp" => &event.mcp_tool_name,
// A route name is a registered resource like an agent name, so it is
// operator-bounded — but it still passes the length cap below.
"passthrough" => &event.passthrough_route_name,
_ => return "chat.completions".to_string(),
};
if target.is_empty() || target.len() > MAX_SPAN_NAME_TARGET {
Expand DownExpand Up@@ -1535,6 +1557,44 @@ mod tests {
assert_eq!(string_at("aisix.a2a.task_state"), "working");
}

#[test]
fn a_passthrough_relay_exports_route_and_identity_not_a_chat_span() {
// The passthrough handler emits usage events with
// `inbound_protocol = "passthrough"`; without the explicit branch a
// Copilot relay exported as a bare `chat.completions` span with the
// route and the device-injected end-user identity recorded nowhere.
let mut ev = sample_event();
ev.inbound_protocol = "passthrough".into();
ev.passthrough_route_name = "copilot-chat".into();
ev.client_identity = "alice@example.com".into();

let body = build_otlp_traces_payload(&ev, "test-exp");
let span = &body["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
assert_eq!(span["name"], "passthrough copilot-chat");
let attrs = span["attributes"].as_array().unwrap();
let find = |k: &str| attrs.iter().find(|a| a["key"] == k);
let string_at = |k: &str| find(k).unwrap()["value"]["stringValue"].clone();
assert_eq!(string_at("gen_ai.operation.name"), "passthrough");
assert_eq!(string_at("aisix.passthrough.route_name"), "copilot-chat");
assert_eq!(string_at("aisix.client_identity"), "alice@example.com");
}

#[test]
fn client_identity_exports_without_a_protocol_gate() {
// The identity attribute must not depend on the passthrough branch:
// any handler that populates the field gets it exported.
let mut ev = sample_event();
ev.client_identity = "bob@example.com".into();
let body = build_otlp_traces_payload(&ev, "test-exp");
let span = &body["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
let attrs = span["attributes"].as_array().unwrap();
let id = attrs
.iter()
.find(|a| a["key"] == "aisix.client_identity")
.unwrap();
assert_eq!(id["value"]["stringValue"], "bob@example.com");
}

#[test]
fn a_streamed_a2a_call_carries_its_event_count() {
// A unary call has no count, and exporting a zero would read as "the
Expand Down
1 change: 1 addition & 0 deletions crates/aisix-proxy/src/attempt.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -291,6 +291,7 @@ pub(crate) fn attempt_reached_upstream(err: &ProxyError) -> bool {
ProxyError::Bridge(be) => be.reached_upstream(),
ProxyError::ContentFiltered(_) => true,
ProxyError::MissingAuth
| ProxyError::MissingRouteAuthHeader(_)
| ProxyError::InvalidApiKey
| ProxyError::ApiKeyExpired
| ProxyError::ApiKeyDisabled
Expand Down
13 changes: 11 additions & 2 deletions crates/aisix-proxy/src/error.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -166,6 +166,13 @@ impl ErrorEnvelope {
pub enum ProxyError {
#[error("missing or malformed Authorization header")]
MissingAuth,
/// A `header_key` passthrough route saw no gateway key in its configured
/// header. Named separately from [`ProxyError::MissingAuth`] because on
/// these routes `Authorization` deliberately carries the caller's own
/// upstream credential — telling the integrator to fix `Authorization`
/// points at exactly the wrong header.
#[error("missing or malformed {0} header (this route's gateway API key header)")]
MissingRouteAuthHeader(String),
#[error("invalid API key")]
InvalidApiKey,
/// The presented key exists but its `expires_at` deadline has
Expand DownExpand Up@@ -336,7 +343,8 @@ pub(crate) fn guardrail_block_message(side: &str, guardrail_name: Option<&str>)
impl ProxyError {
pub fn status(&self) -> StatusCode {
match self {
ProxyError::MissingAuth
ProxyError::MissingRouteAuthHeader(_)
| ProxyError::MissingAuth
| ProxyError::InvalidApiKey
| ProxyError::ApiKeyExpired
| ProxyError::ApiKeyDisabled
Expand DownExpand Up@@ -369,7 +377,8 @@ impl ProxyError {

pub fn kind(&self) -> &'static str {
match self {
ProxyError::MissingAuth
ProxyError::MissingRouteAuthHeader(_)
| ProxyError::MissingAuth
| ProxyError::InvalidApiKey
| ProxyError::ApiKeyExpired
| ProxyError::ApiKeyDisabled
Expand Down
17 changes: 12 additions & 5 deletions crates/aisix-proxy/src/passthrough_route.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -352,10 +352,7 @@ pub async fn entry(
status,
elapsed,
);
crate::usage_attr::emit_error_usage_event(
&state,
&snapshot,
"passthrough_route",
let mut event = crate::usage_attr::build_error_usage_event(
"passthrough",
&request_id,
"",
Expand All@@ -364,6 +361,16 @@ pub async fn entry(
error.kind(),
&client,
);
// The route matched before the pipeline failed, so a rejected
// request still attributes to it — an operator triaging 401s
// per route needs the name on the event, not just in the log.
event.passthrough_route_name = route_name.clone();
crate::usage_attr::emit_prepared_usage_event(
&state,
&snapshot,
"passthrough_route",
event,
);
error.into_response()
}
}
Expand DownExpand Up@@ -917,7 +924,7 @@ async fn authenticate(
.and_then(|v| v.to_str().ok())
.map(|v| v.strip_prefix("Bearer ").unwrap_or(v).trim().to_string())
.filter(|v| !v.is_empty())
.ok_or(ProxyError::MissingAuth)?;
.ok_or_else(|| ProxyError::MissingRouteAuthHeader(name.to_string()))?;
crate::auth::authenticate_token(state, &token, ctx).await
}
PassthroughAuthMode::Anonymous => {
Expand Down
33 changes: 33 additions & 0 deletions crates/aisix-proxy/src/usage_attr.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -354,6 +354,30 @@ pub(crate) fn emit_error_usage_event(
error_class: &str,
client: &ClientContext,
) {
let event = build_error_usage_event(
inbound_protocol,
request_id,
requested_model,
api_key_id,
status_code,
error_class,
client,
);
emit_prepared_usage_event(state, snap, label, event);
}

/// The [`emit_error_usage_event`] event without the emission, for a caller
/// that attributes handler-specific fields (e.g. the passthrough route name)
/// before handing it to [`emit_prepared_usage_event`].
pub(crate) fn build_error_usage_event(
inbound_protocol: &'static str,
request_id: &str,
requested_model: &str,
api_key_id: &str,
status_code: u16,
error_class: &str,
client: &ClientContext,
) -> UsageEvent {
let mut event = UsageEvent {
request_id: request_id.to_string(),
occurred_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
Expand All@@ -367,6 +391,15 @@ pub(crate) fn emit_error_usage_event(
..Default::default()
};
apply_jwt_identity(&mut event, client.jwt.as_ref());
event
}

pub(crate) fn emit_prepared_usage_event(
state: &ProxyState,
snap: &AisixSnapshot,
label: &'static str,
event: UsageEvent,
) {
state.usage_sink.try_emit(label, event.clone());
let exporters = live_exporters(state, snap);
state
Expand Down
72 changes: 72 additions & 0 deletions schemas/resources/passthrough_route.schema.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -240,6 +240,78 @@
]
}
},
{
"if": {
"anyOf": [
{
"not": {
"required": [
"auth_mode"
]
},
"title": "auth_mode omitted (defaults to gateway_key)"
},
{
"properties": {
"auth_mode": {
"enum": [
"gateway_key",
"anonymous"
]
}
},
"required": [
"auth_mode"
],
"title": "auth_mode: gateway_key or anonymous"
}
]
},
"then": {
"not": {
"required": [
"auth_header_name"
]
}
},
"title": "auth_header_name only in header_key mode"
},
{
"if": {
"anyOf": [
{
"not": {
"required": [
"auth_mode"
]
},
"title": "auth_mode omitted (defaults to gateway_key)"
},
{
"properties": {
"auth_mode": {
"enum": [
"gateway_key",
"header_key"
]
}
},
"required": [
"auth_mode"
],
"title": "auth_mode: gateway_key or header_key"
}
]
},
"then": {
"not": {
"required": [
"anonymous_key_id"
]
}
},
"title": "anonymous_key_id only in anonymous mode"
},
{
"if": {
"anyOf": [
Expand Down
19 changes: 19 additions & 0 deletions tests/e2e/src/cases/passthrough-route-e2e.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -281,6 +281,25 @@ describe("passthrough-route e2e: explicit routes, BYO credentials, 410 tombstone
// …and the gateway's side-channel headers did not.
expect(hit.headers["x-aisix-api-key"]).toBeUndefined();
expect(hit.headers["x-aisix-user"]).toBeUndefined();

// Missing gateway key: the 401 must point at the route's configured
// header, not `Authorization` — on this route Authorization carries the
// caller's own upstream credential and is exactly the wrong thing to fix.
const noKey = await harnessRequest(`${app!.proxyUrl}/v1/chat/completions`, {
method: "POST",
headers: {
host: "ai-upstream.example.com",
authorization: "Bearer employee-official-token",
"content-type": "application/json",
},
body: JSON.stringify({ model: "gpt-4o", messages: [] }),
});
expect(noKey.statusCode).toBe(401);
const noKeyBody = (await noKey.body.json()) as {
error?: { message?: string };
};
expect(noKeyBody.error?.message).toContain("x-aisix-api-key");
expect(noKeyBody.error?.message).not.toContain("Authorization");
});

test("anonymous route binds the configured principal behind source_cidrs", async (ctx) => {
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(passthrough): OTLP route/identity attribution, route on rejected events, header_key 401 names its header by jarvis9443 · Pull Request #983 · api7/aisix · GitHub
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
57 changes: 57 additions & 0 deletions crates/aisix-core/src/models/passthrough_route.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -379,6 +379,30 @@ pub fn passthrough_route_coupling() -> Value {
}
}
},
// Cross-mode leftovers are configuration errors, not ignored
// fields: a companion outside its mode is never consulted, so a
// row carrying one is rejected rather than half-honored. The CP
// enforces the identical rule on create and patch.
{
"title": "auth_header_name only in header_key mode",
"if": {
"anyOf": [
{ "title": "auth_mode omitted (defaults to gateway_key)", "not": { "required": ["auth_mode"] } },
{ "title": "auth_mode: gateway_key or anonymous", "properties": { "auth_mode": { "enum": ["gateway_key", "anonymous"] } }, "required": ["auth_mode"] }
]
},
"then": { "not": { "required": ["auth_header_name"] } }
},
{
"title": "anonymous_key_id only in anonymous mode",
"if": {
"anyOf": [
{ "title": "auth_mode omitted (defaults to gateway_key)", "not": { "required": ["auth_mode"] } },
{ "title": "auth_mode: gateway_key or header_key", "properties": { "auth_mode": { "enum": ["gateway_key", "header_key"] } }, "required": ["auth_mode"] }
]
},
"then": { "not": { "required": ["anonymous_key_id"] } }
},
// credential_mode couplings: inject needs a real ProviderKey id; a
// forward_client route carrying one is a configuration error, not
// an ignored field.
Expand DownExpand Up@@ -516,6 +540,39 @@ mod coupling_tests {
assert!(validate_passthrough_route(&doc).is_err());
}

#[test]
fn cross_mode_leftover_companions_are_rejected() {
// A companion outside its mode is never consulted at runtime, so
// both validators refuse the row instead of half-honoring it.
// auth_header_name without header_key (auth_mode absent = gateway_key).
let mut doc = base();
doc["auth_header_name"] = json!("x-aisix-api-key");
assert!(validate_passthrough_route(&doc).is_err());
assert!(validate_passthrough_route_lenient(&doc).is_err());
// anonymous_key_id on an explicit header_key route.
let doc = json!({
"name": "r", "path_prefix": "/p",
"target_url": "https://u.example", "provider_key_id": "pk",
"auth_mode": "header_key", "auth_header_name": "x-aisix-api-key",
"anonymous_key_id": "ak-1"
});
assert!(validate_passthrough_route(&doc).is_err());
// auth_header_name on an anonymous route.
let doc = json!({
"name": "r", "path_prefix": "/p",
"target_url": "https://u.example", "provider_key_id": "pk",
"auth_mode": "anonymous", "anonymous_key_id": "ak-1",
"source_cidrs": ["10.0.0.0/8"],
"auth_header_name": "x-aisix-api-key"
});
assert!(validate_passthrough_route(&doc).is_err());
// source_cidrs is deliberately NOT mode-coupled: an extra IP
// allowlist is honored on every mode.
let mut doc = base();
doc["source_cidrs"] = json!(["10.0.0.0/8"]);
assert!(validate_passthrough_route(&doc).is_ok());
}

#[test]
fn credential_bearing_header_slots_are_rejected() {
for field in ["auth_header_name", "identity_header"] {
Expand Down
60 changes: 60 additions & 0 deletions crates/aisix-obs/src/otlp_http_sink.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -743,6 +743,16 @@ fn build_otlp_span(record: &SinkRecord, exporter_name: &str) -> Value {
&event.client_user_agent,
));
}
// End-user identity a passthrough route's `identity_header` extracted —
// the per-employee attribution of the forward-proxy scenario. Not gated
// on the protocol so a future handler that learns to populate it exports
// it without touching this sink.
if !event.client_identity.is_empty() {
attributes.push(attr_string_capped(
"aisix.client_identity",
&event.client_identity,
));
}
// JWT identity attribution (AISIX-Cloud#564): who the request ran as
// when it authenticated with a JWT — the identity behind the (possibly
// shared) api_key_id.
Expand DownExpand Up@@ -802,6 +812,14 @@ fn build_otlp_span(record: &SinkRecord, exporter_name: &str) -> Value {
attributes.push(attr_string("aisix.mcp.server_name", &event.mcp_server_name));
}
}
"passthrough" => {
if !event.passthrough_route_name.is_empty() {
attributes.push(attr_string(
"aisix.passthrough.route_name",
&event.passthrough_route_name,
));
}
}
_ => {}
}
// Opt-in captured content (#519 B.2) — present ONLY on a record built by
Expand DownExpand Up@@ -841,6 +859,7 @@ fn operation_name(event: &UsageEvent) -> &'static str {
match event.inbound_protocol.as_str() {
"a2a" => "invoke_agent",
"mcp" => "execute_tool",
"passthrough" => "passthrough",
_ => "chat",
}
}
Expand All@@ -867,6 +886,9 @@ fn span_name(event: &UsageEvent) -> String {
let target = match event.inbound_protocol.as_str() {
"a2a" => &event.a2a_agent_name,
"mcp" => &event.mcp_tool_name,
// A route name is a registered resource like an agent name, so it is
// operator-bounded — but it still passes the length cap below.
"passthrough" => &event.passthrough_route_name,
_ => return "chat.completions".to_string(),
};
if target.is_empty() || target.len() > MAX_SPAN_NAME_TARGET {
Expand DownExpand Up@@ -1535,6 +1557,44 @@ mod tests {
assert_eq!(string_at("aisix.a2a.task_state"), "working");
}

#[test]
fn a_passthrough_relay_exports_route_and_identity_not_a_chat_span() {
// The passthrough handler emits usage events with
// `inbound_protocol = "passthrough"`; without the explicit branch a
// Copilot relay exported as a bare `chat.completions` span with the
// route and the device-injected end-user identity recorded nowhere.
let mut ev = sample_event();
ev.inbound_protocol = "passthrough".into();
ev.passthrough_route_name = "copilot-chat".into();
ev.client_identity = "alice@example.com".into();

let body = build_otlp_traces_payload(&ev, "test-exp");
let span = &body["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
assert_eq!(span["name"], "passthrough copilot-chat");
let attrs = span["attributes"].as_array().unwrap();
let find = |k: &str| attrs.iter().find(|a| a["key"] == k);
let string_at = |k: &str| find(k).unwrap()["value"]["stringValue"].clone();
assert_eq!(string_at("gen_ai.operation.name"), "passthrough");
assert_eq!(string_at("aisix.passthrough.route_name"), "copilot-chat");
assert_eq!(string_at("aisix.client_identity"), "alice@example.com");
}

#[test]
fn client_identity_exports_without_a_protocol_gate() {
// The identity attribute must not depend on the passthrough branch:
// any handler that populates the field gets it exported.
let mut ev = sample_event();
ev.client_identity = "bob@example.com".into();
let body = build_otlp_traces_payload(&ev, "test-exp");
let span = &body["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
let attrs = span["attributes"].as_array().unwrap();
let id = attrs
.iter()
.find(|a| a["key"] == "aisix.client_identity")
.unwrap();
assert_eq!(id["value"]["stringValue"], "bob@example.com");
}

#[test]
fn a_streamed_a2a_call_carries_its_event_count() {
// A unary call has no count, and exporting a zero would read as "the
Expand Down
1 change: 1 addition & 0 deletions crates/aisix-proxy/src/attempt.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -291,6 +291,7 @@ pub(crate) fn attempt_reached_upstream(err: &ProxyError) -> bool {
ProxyError::Bridge(be) => be.reached_upstream(),
ProxyError::ContentFiltered(_) => true,
ProxyError::MissingAuth
| ProxyError::MissingRouteAuthHeader(_)
| ProxyError::InvalidApiKey
| ProxyError::ApiKeyExpired
| ProxyError::ApiKeyDisabled
Expand Down
13 changes: 11 additions & 2 deletions crates/aisix-proxy/src/error.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -166,6 +166,13 @@ impl ErrorEnvelope {
pub enum ProxyError {
#[error("missing or malformed Authorization header")]
MissingAuth,
/// A `header_key` passthrough route saw no gateway key in its configured
/// header. Named separately from [`ProxyError::MissingAuth`] because on
/// these routes `Authorization` deliberately carries the caller's own
/// upstream credential — telling the integrator to fix `Authorization`
/// points at exactly the wrong header.
#[error("missing or malformed {0} header (this route's gateway API key header)")]
MissingRouteAuthHeader(String),
#[error("invalid API key")]
InvalidApiKey,
/// The presented key exists but its `expires_at` deadline has
Expand DownExpand Up@@ -336,7 +343,8 @@ pub(crate) fn guardrail_block_message(side: &str, guardrail_name: Option<&str>)
impl ProxyError {
pub fn status(&self) -> StatusCode {
match self {
ProxyError::MissingAuth
ProxyError::MissingRouteAuthHeader(_)
| ProxyError::MissingAuth
| ProxyError::InvalidApiKey
| ProxyError::ApiKeyExpired
| ProxyError::ApiKeyDisabled
Expand DownExpand Up@@ -369,7 +377,8 @@ impl ProxyError {

pub fn kind(&self) -> &'static str {
match self {
ProxyError::MissingAuth
ProxyError::MissingRouteAuthHeader(_)
| ProxyError::MissingAuth
| ProxyError::InvalidApiKey
| ProxyError::ApiKeyExpired
| ProxyError::ApiKeyDisabled
Expand Down
17 changes: 12 additions & 5 deletions crates/aisix-proxy/src/passthrough_route.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -352,10 +352,7 @@ pub async fn entry(
status,
elapsed,
);
crate::usage_attr::emit_error_usage_event(
&state,
&snapshot,
"passthrough_route",
let mut event = crate::usage_attr::build_error_usage_event(
"passthrough",
&request_id,
"",
Expand All@@ -364,6 +361,16 @@ pub async fn entry(
error.kind(),
&client,
);
// The route matched before the pipeline failed, so a rejected
// request still attributes to it — an operator triaging 401s
// per route needs the name on the event, not just in the log.
event.passthrough_route_name = route_name.clone();
crate::usage_attr::emit_prepared_usage_event(
&state,
&snapshot,
"passthrough_route",
event,
);
error.into_response()
}
}
Expand DownExpand Up@@ -917,7 +924,7 @@ async fn authenticate(
.and_then(|v| v.to_str().ok())
.map(|v| v.strip_prefix("Bearer ").unwrap_or(v).trim().to_string())
.filter(|v| !v.is_empty())
.ok_or(ProxyError::MissingAuth)?;
.ok_or_else(|| ProxyError::MissingRouteAuthHeader(name.to_string()))?;
crate::auth::authenticate_token(state, &token, ctx).await
}
PassthroughAuthMode::Anonymous => {
Expand Down
33 changes: 33 additions & 0 deletions crates/aisix-proxy/src/usage_attr.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -354,6 +354,30 @@ pub(crate) fn emit_error_usage_event(
error_class: &str,
client: &ClientContext,
) {
let event = build_error_usage_event(
inbound_protocol,
request_id,
requested_model,
api_key_id,
status_code,
error_class,
client,
);
emit_prepared_usage_event(state, snap, label, event);
}

/// The [`emit_error_usage_event`] event without the emission, for a caller
/// that attributes handler-specific fields (e.g. the passthrough route name)
/// before handing it to [`emit_prepared_usage_event`].
pub(crate) fn build_error_usage_event(
inbound_protocol: &'static str,
request_id: &str,
requested_model: &str,
api_key_id: &str,
status_code: u16,
error_class: &str,
client: &ClientContext,
) -> UsageEvent {
let mut event = UsageEvent {
request_id: request_id.to_string(),
occurred_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
Expand All@@ -367,6 +391,15 @@ pub(crate) fn emit_error_usage_event(
..Default::default()
};
apply_jwt_identity(&mut event, client.jwt.as_ref());
event
}

pub(crate) fn emit_prepared_usage_event(
state: &ProxyState,
snap: &AisixSnapshot,
label: &'static str,
event: UsageEvent,
) {
state.usage_sink.try_emit(label, event.clone());
let exporters = live_exporters(state, snap);
state
Expand Down
72 changes: 72 additions & 0 deletions schemas/resources/passthrough_route.schema.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -240,6 +240,78 @@
]
}
},
{
"if": {
"anyOf": [
{
"not": {
"required": [
"auth_mode"
]
},
"title": "auth_mode omitted (defaults to gateway_key)"
},
{
"properties": {
"auth_mode": {
"enum": [
"gateway_key",
"anonymous"
]
}
},
"required": [
"auth_mode"
],
"title": "auth_mode: gateway_key or anonymous"
}
]
},
"then": {
"not": {
"required": [
"auth_header_name"
]
}
},
"title": "auth_header_name only in header_key mode"
},
{
"if": {
"anyOf": [
{
"not": {
"required": [
"auth_mode"
]
},
"title": "auth_mode omitted (defaults to gateway_key)"
},
{
"properties": {
"auth_mode": {
"enum": [
"gateway_key",
"header_key"
]
}
},
"required": [
"auth_mode"
],
"title": "auth_mode: gateway_key or header_key"
}
]
},
"then": {
"not": {
"required": [
"anonymous_key_id"
]
}
},
"title": "anonymous_key_id only in anonymous mode"
},
{
"if": {
"anyOf": [
Expand Down
19 changes: 19 additions & 0 deletions tests/e2e/src/cases/passthrough-route-e2e.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -281,6 +281,25 @@ describe("passthrough-route e2e: explicit routes, BYO credentials, 410 tombstone
// …and the gateway's side-channel headers did not.
expect(hit.headers["x-aisix-api-key"]).toBeUndefined();
expect(hit.headers["x-aisix-user"]).toBeUndefined();

// Missing gateway key: the 401 must point at the route's configured
// header, not `Authorization` — on this route Authorization carries the
// caller's own upstream credential and is exactly the wrong thing to fix.
const noKey = await harnessRequest(`${app!.proxyUrl}/v1/chat/completions`, {
method: "POST",
headers: {
host: "ai-upstream.example.com",
authorization: "Bearer employee-official-token",
"content-type": "application/json",
},
body: JSON.stringify({ model: "gpt-4o", messages: [] }),
});
expect(noKey.statusCode).toBe(401);
const noKeyBody = (await noKey.body.json()) as {
error?: { message?: string };
};
expect(noKeyBody.error?.message).toContain("x-aisix-api-key");
expect(noKeyBody.error?.message).not.toContain("Authorization");
});

test("anonymous route binds the configured principal behind source_cidrs", async (ctx) => {
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(passthrough): OTLP route/identity attribution, route on rejected events, header_key 401 names its header by jarvis9443 · Pull Request #983 · api7/aisix · GitHub
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
57 changes: 57 additions & 0 deletions crates/aisix-core/src/models/passthrough_route.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -379,6 +379,30 @@ pub fn passthrough_route_coupling() -> Value {
}
}
},
// Cross-mode leftovers are configuration errors, not ignored
// fields: a companion outside its mode is never consulted, so a
// row carrying one is rejected rather than half-honored. The CP
// enforces the identical rule on create and patch.
{
"title": "auth_header_name only in header_key mode",
"if": {
"anyOf": [
{ "title": "auth_mode omitted (defaults to gateway_key)", "not": { "required": ["auth_mode"] } },
{ "title": "auth_mode: gateway_key or anonymous", "properties": { "auth_mode": { "enum": ["gateway_key", "anonymous"] } }, "required": ["auth_mode"] }
]
},
"then": { "not": { "required": ["auth_header_name"] } }
},
{
"title": "anonymous_key_id only in anonymous mode",
"if": {
"anyOf": [
{ "title": "auth_mode omitted (defaults to gateway_key)", "not": { "required": ["auth_mode"] } },
{ "title": "auth_mode: gateway_key or header_key", "properties": { "auth_mode": { "enum": ["gateway_key", "header_key"] } }, "required": ["auth_mode"] }
]
},
"then": { "not": { "required": ["anonymous_key_id"] } }
},
// credential_mode couplings: inject needs a real ProviderKey id; a
// forward_client route carrying one is a configuration error, not
// an ignored field.
Expand DownExpand Up@@ -516,6 +540,39 @@ mod coupling_tests {
assert!(validate_passthrough_route(&doc).is_err());
}

#[test]
fn cross_mode_leftover_companions_are_rejected() {
// A companion outside its mode is never consulted at runtime, so
// both validators refuse the row instead of half-honoring it.
// auth_header_name without header_key (auth_mode absent = gateway_key).
let mut doc = base();
doc["auth_header_name"] = json!("x-aisix-api-key");
assert!(validate_passthrough_route(&doc).is_err());
assert!(validate_passthrough_route_lenient(&doc).is_err());
// anonymous_key_id on an explicit header_key route.
let doc = json!({
"name": "r", "path_prefix": "/p",
"target_url": "https://u.example", "provider_key_id": "pk",
"auth_mode": "header_key", "auth_header_name": "x-aisix-api-key",
"anonymous_key_id": "ak-1"
});
assert!(validate_passthrough_route(&doc).is_err());
// auth_header_name on an anonymous route.
let doc = json!({
"name": "r", "path_prefix": "/p",
"target_url": "https://u.example", "provider_key_id": "pk",
"auth_mode": "anonymous", "anonymous_key_id": "ak-1",
"source_cidrs": ["10.0.0.0/8"],
"auth_header_name": "x-aisix-api-key"
});
assert!(validate_passthrough_route(&doc).is_err());
// source_cidrs is deliberately NOT mode-coupled: an extra IP
// allowlist is honored on every mode.
let mut doc = base();
doc["source_cidrs"] = json!(["10.0.0.0/8"]);
assert!(validate_passthrough_route(&doc).is_ok());
}

#[test]
fn credential_bearing_header_slots_are_rejected() {
for field in ["auth_header_name", "identity_header"] {
Expand Down
60 changes: 60 additions & 0 deletions crates/aisix-obs/src/otlp_http_sink.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -743,6 +743,16 @@ fn build_otlp_span(record: &SinkRecord, exporter_name: &str) -> Value {
&event.client_user_agent,
));
}
// End-user identity a passthrough route's `identity_header` extracted —
// the per-employee attribution of the forward-proxy scenario. Not gated
// on the protocol so a future handler that learns to populate it exports
// it without touching this sink.
if !event.client_identity.is_empty() {
attributes.push(attr_string_capped(
"aisix.client_identity",
&event.client_identity,
));
}
// JWT identity attribution (AISIX-Cloud#564): who the request ran as
// when it authenticated with a JWT — the identity behind the (possibly
// shared) api_key_id.
Expand DownExpand Up@@ -802,6 +812,14 @@ fn build_otlp_span(record: &SinkRecord, exporter_name: &str) -> Value {
attributes.push(attr_string("aisix.mcp.server_name", &event.mcp_server_name));
}
}
"passthrough" => {
if !event.passthrough_route_name.is_empty() {
attributes.push(attr_string(
"aisix.passthrough.route_name",
&event.passthrough_route_name,
));
}
}
_ => {}
}
// Opt-in captured content (#519 B.2) — present ONLY on a record built by
Expand DownExpand Up@@ -841,6 +859,7 @@ fn operation_name(event: &UsageEvent) -> &'static str {
match event.inbound_protocol.as_str() {
"a2a" => "invoke_agent",
"mcp" => "execute_tool",
"passthrough" => "passthrough",
_ => "chat",
}
}
Expand All@@ -867,6 +886,9 @@ fn span_name(event: &UsageEvent) -> String {
let target = match event.inbound_protocol.as_str() {
"a2a" => &event.a2a_agent_name,
"mcp" => &event.mcp_tool_name,
// A route name is a registered resource like an agent name, so it is
// operator-bounded — but it still passes the length cap below.
"passthrough" => &event.passthrough_route_name,
_ => return "chat.completions".to_string(),
};
if target.is_empty() || target.len() > MAX_SPAN_NAME_TARGET {
Expand DownExpand Up@@ -1535,6 +1557,44 @@ mod tests {
assert_eq!(string_at("aisix.a2a.task_state"), "working");
}

#[test]
fn a_passthrough_relay_exports_route_and_identity_not_a_chat_span() {
// The passthrough handler emits usage events with
// `inbound_protocol = "passthrough"`; without the explicit branch a
// Copilot relay exported as a bare `chat.completions` span with the
// route and the device-injected end-user identity recorded nowhere.
let mut ev = sample_event();
ev.inbound_protocol = "passthrough".into();
ev.passthrough_route_name = "copilot-chat".into();
ev.client_identity = "alice@example.com".into();

let body = build_otlp_traces_payload(&ev, "test-exp");
let span = &body["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
assert_eq!(span["name"], "passthrough copilot-chat");
let attrs = span["attributes"].as_array().unwrap();
let find = |k: &str| attrs.iter().find(|a| a["key"] == k);
let string_at = |k: &str| find(k).unwrap()["value"]["stringValue"].clone();
assert_eq!(string_at("gen_ai.operation.name"), "passthrough");
assert_eq!(string_at("aisix.passthrough.route_name"), "copilot-chat");
assert_eq!(string_at("aisix.client_identity"), "alice@example.com");
}

#[test]
fn client_identity_exports_without_a_protocol_gate() {
// The identity attribute must not depend on the passthrough branch:
// any handler that populates the field gets it exported.
let mut ev = sample_event();
ev.client_identity = "bob@example.com".into();
let body = build_otlp_traces_payload(&ev, "test-exp");
let span = &body["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
let attrs = span["attributes"].as_array().unwrap();
let id = attrs
.iter()
.find(|a| a["key"] == "aisix.client_identity")
.unwrap();
assert_eq!(id["value"]["stringValue"], "bob@example.com");
}

#[test]
fn a_streamed_a2a_call_carries_its_event_count() {
// A unary call has no count, and exporting a zero would read as "the
Expand Down
1 change: 1 addition & 0 deletions crates/aisix-proxy/src/attempt.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -291,6 +291,7 @@ pub(crate) fn attempt_reached_upstream(err: &ProxyError) -> bool {
ProxyError::Bridge(be) => be.reached_upstream(),
ProxyError::ContentFiltered(_) => true,
ProxyError::MissingAuth
| ProxyError::MissingRouteAuthHeader(_)
| ProxyError::InvalidApiKey
| ProxyError::ApiKeyExpired
| ProxyError::ApiKeyDisabled
Expand Down
13 changes: 11 additions & 2 deletions crates/aisix-proxy/src/error.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -166,6 +166,13 @@ impl ErrorEnvelope {
pub enum ProxyError {
#[error("missing or malformed Authorization header")]
MissingAuth,
/// A `header_key` passthrough route saw no gateway key in its configured
/// header. Named separately from [`ProxyError::MissingAuth`] because on
/// these routes `Authorization` deliberately carries the caller's own
/// upstream credential — telling the integrator to fix `Authorization`
/// points at exactly the wrong header.
#[error("missing or malformed {0} header (this route's gateway API key header)")]
MissingRouteAuthHeader(String),
#[error("invalid API key")]
InvalidApiKey,
/// The presented key exists but its `expires_at` deadline has
Expand DownExpand Up@@ -336,7 +343,8 @@ pub(crate) fn guardrail_block_message(side: &str, guardrail_name: Option<&str>)
impl ProxyError {
pub fn status(&self) -> StatusCode {
match self {
ProxyError::MissingAuth
ProxyError::MissingRouteAuthHeader(_)
| ProxyError::MissingAuth
| ProxyError::InvalidApiKey
| ProxyError::ApiKeyExpired
| ProxyError::ApiKeyDisabled
Expand DownExpand Up@@ -369,7 +377,8 @@ impl ProxyError {

pub fn kind(&self) -> &'static str {
match self {
ProxyError::MissingAuth
ProxyError::MissingRouteAuthHeader(_)
| ProxyError::MissingAuth
| ProxyError::InvalidApiKey
| ProxyError::ApiKeyExpired
| ProxyError::ApiKeyDisabled
Expand Down
17 changes: 12 additions & 5 deletions crates/aisix-proxy/src/passthrough_route.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -352,10 +352,7 @@ pub async fn entry(
status,
elapsed,
);
crate::usage_attr::emit_error_usage_event(
&state,
&snapshot,
"passthrough_route",
let mut event = crate::usage_attr::build_error_usage_event(
"passthrough",
&request_id,
"",
Expand All@@ -364,6 +361,16 @@ pub async fn entry(
error.kind(),
&client,
);
// The route matched before the pipeline failed, so a rejected
// request still attributes to it — an operator triaging 401s
// per route needs the name on the event, not just in the log.
event.passthrough_route_name = route_name.clone();
crate::usage_attr::emit_prepared_usage_event(
&state,
&snapshot,
"passthrough_route",
event,
);
error.into_response()
}
}
Expand DownExpand Up@@ -917,7 +924,7 @@ async fn authenticate(
.and_then(|v| v.to_str().ok())
.map(|v| v.strip_prefix("Bearer ").unwrap_or(v).trim().to_string())
.filter(|v| !v.is_empty())
.ok_or(ProxyError::MissingAuth)?;
.ok_or_else(|| ProxyError::MissingRouteAuthHeader(name.to_string()))?;
crate::auth::authenticate_token(state, &token, ctx).await
}
PassthroughAuthMode::Anonymous => {
Expand Down
33 changes: 33 additions & 0 deletions crates/aisix-proxy/src/usage_attr.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -354,6 +354,30 @@ pub(crate) fn emit_error_usage_event(
error_class: &str,
client: &ClientContext,
) {
let event = build_error_usage_event(
inbound_protocol,
request_id,
requested_model,
api_key_id,
status_code,
error_class,
client,
);
emit_prepared_usage_event(state, snap, label, event);
}

/// The [`emit_error_usage_event`] event without the emission, for a caller
/// that attributes handler-specific fields (e.g. the passthrough route name)
/// before handing it to [`emit_prepared_usage_event`].
pub(crate) fn build_error_usage_event(
inbound_protocol: &'static str,
request_id: &str,
requested_model: &str,
api_key_id: &str,
status_code: u16,
error_class: &str,
client: &ClientContext,
) -> UsageEvent {
let mut event = UsageEvent {
request_id: request_id.to_string(),
occurred_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
Expand All@@ -367,6 +391,15 @@ pub(crate) fn emit_error_usage_event(
..Default::default()
};
apply_jwt_identity(&mut event, client.jwt.as_ref());
event
}

pub(crate) fn emit_prepared_usage_event(
state: &ProxyState,
snap: &AisixSnapshot,
label: &'static str,
event: UsageEvent,
) {
state.usage_sink.try_emit(label, event.clone());
let exporters = live_exporters(state, snap);
state
Expand Down
72 changes: 72 additions & 0 deletions schemas/resources/passthrough_route.schema.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -240,6 +240,78 @@
]
}
},
{
"if": {
"anyOf": [
{
"not": {
"required": [
"auth_mode"
]
},
"title": "auth_mode omitted (defaults to gateway_key)"
},
{
"properties": {
"auth_mode": {
"enum": [
"gateway_key",
"anonymous"
]
}
},
"required": [
"auth_mode"
],
"title": "auth_mode: gateway_key or anonymous"
}
]
},
"then": {
"not": {
"required": [
"auth_header_name"
]
}
},
"title": "auth_header_name only in header_key mode"
},
{
"if": {
"anyOf": [
{
"not": {
"required": [
"auth_mode"
]
},
"title": "auth_mode omitted (defaults to gateway_key)"
},
{
"properties": {
"auth_mode": {
"enum": [
"gateway_key",
"header_key"
]
}
},
"required": [
"auth_mode"
],
"title": "auth_mode: gateway_key or header_key"
}
]
},
"then": {
"not": {
"required": [
"anonymous_key_id"
]
}
},
"title": "anonymous_key_id only in anonymous mode"
},
{
"if": {
"anyOf": [
Expand Down
19 changes: 19 additions & 0 deletions tests/e2e/src/cases/passthrough-route-e2e.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -281,6 +281,25 @@ describe("passthrough-route e2e: explicit routes, BYO credentials, 410 tombstone
// …and the gateway's side-channel headers did not.
expect(hit.headers["x-aisix-api-key"]).toBeUndefined();
expect(hit.headers["x-aisix-user"]).toBeUndefined();

// Missing gateway key: the 401 must point at the route's configured
// header, not `Authorization` — on this route Authorization carries the
// caller's own upstream credential and is exactly the wrong thing to fix.
const noKey = await harnessRequest(`${app!.proxyUrl}/v1/chat/completions`, {
method: "POST",
headers: {
host: "ai-upstream.example.com",
authorization: "Bearer employee-official-token",
"content-type": "application/json",
},
body: JSON.stringify({ model: "gpt-4o", messages: [] }),
});
expect(noKey.statusCode).toBe(401);
const noKeyBody = (await noKey.body.json()) as {
error?: { message?: string };
};
expect(noKeyBody.error?.message).toContain("x-aisix-api-key");
expect(noKeyBody.error?.message).not.toContain("Authorization");
});

test("anonymous route binds the configured principal behind source_cidrs", async (ctx) => {
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(passthrough): OTLP route/identity attribution, route on rejected events, header_key 401 names its header by jarvis9443 · Pull Request #983 · api7/aisix · GitHub
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
57 changes: 57 additions & 0 deletions crates/aisix-core/src/models/passthrough_route.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -379,6 +379,30 @@ pub fn passthrough_route_coupling() -> Value {
}
}
},
// Cross-mode leftovers are configuration errors, not ignored
// fields: a companion outside its mode is never consulted, so a
// row carrying one is rejected rather than half-honored. The CP
// enforces the identical rule on create and patch.
{
"title": "auth_header_name only in header_key mode",
"if": {
"anyOf": [
{ "title": "auth_mode omitted (defaults to gateway_key)", "not": { "required": ["auth_mode"] } },
{ "title": "auth_mode: gateway_key or anonymous", "properties": { "auth_mode": { "enum": ["gateway_key", "anonymous"] } }, "required": ["auth_mode"] }
]
},
"then": { "not": { "required": ["auth_header_name"] } }
},
{
"title": "anonymous_key_id only in anonymous mode",
"if": {
"anyOf": [
{ "title": "auth_mode omitted (defaults to gateway_key)", "not": { "required": ["auth_mode"] } },
{ "title": "auth_mode: gateway_key or header_key", "properties": { "auth_mode": { "enum": ["gateway_key", "header_key"] } }, "required": ["auth_mode"] }
]
},
"then": { "not": { "required": ["anonymous_key_id"] } }
},
// credential_mode couplings: inject needs a real ProviderKey id; a
// forward_client route carrying one is a configuration error, not
// an ignored field.
Expand DownExpand Up@@ -516,6 +540,39 @@ mod coupling_tests {
assert!(validate_passthrough_route(&doc).is_err());
}

#[test]
fn cross_mode_leftover_companions_are_rejected() {
// A companion outside its mode is never consulted at runtime, so
// both validators refuse the row instead of half-honoring it.
// auth_header_name without header_key (auth_mode absent = gateway_key).
let mut doc = base();
doc["auth_header_name"] = json!("x-aisix-api-key");
assert!(validate_passthrough_route(&doc).is_err());
assert!(validate_passthrough_route_lenient(&doc).is_err());
// anonymous_key_id on an explicit header_key route.
let doc = json!({
"name": "r", "path_prefix": "/p",
"target_url": "https://u.example", "provider_key_id": "pk",
"auth_mode": "header_key", "auth_header_name": "x-aisix-api-key",
"anonymous_key_id": "ak-1"
});
assert!(validate_passthrough_route(&doc).is_err());
// auth_header_name on an anonymous route.
let doc = json!({
"name": "r", "path_prefix": "/p",
"target_url": "https://u.example", "provider_key_id": "pk",
"auth_mode": "anonymous", "anonymous_key_id": "ak-1",
"source_cidrs": ["10.0.0.0/8"],
"auth_header_name": "x-aisix-api-key"
});
assert!(validate_passthrough_route(&doc).is_err());
// source_cidrs is deliberately NOT mode-coupled: an extra IP
// allowlist is honored on every mode.
let mut doc = base();
doc["source_cidrs"] = json!(["10.0.0.0/8"]);
assert!(validate_passthrough_route(&doc).is_ok());
}

#[test]
fn credential_bearing_header_slots_are_rejected() {
for field in ["auth_header_name", "identity_header"] {
Expand Down
60 changes: 60 additions & 0 deletions crates/aisix-obs/src/otlp_http_sink.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -743,6 +743,16 @@ fn build_otlp_span(record: &SinkRecord, exporter_name: &str) -> Value {
&event.client_user_agent,
));
}
// End-user identity a passthrough route's `identity_header` extracted —
// the per-employee attribution of the forward-proxy scenario. Not gated
// on the protocol so a future handler that learns to populate it exports
// it without touching this sink.
if !event.client_identity.is_empty() {
attributes.push(attr_string_capped(
"aisix.client_identity",
&event.client_identity,
));
}
// JWT identity attribution (AISIX-Cloud#564): who the request ran as
// when it authenticated with a JWT — the identity behind the (possibly
// shared) api_key_id.
Expand DownExpand Up@@ -802,6 +812,14 @@ fn build_otlp_span(record: &SinkRecord, exporter_name: &str) -> Value {
attributes.push(attr_string("aisix.mcp.server_name", &event.mcp_server_name));
}
}
"passthrough" => {
if !event.passthrough_route_name.is_empty() {
attributes.push(attr_string(
"aisix.passthrough.route_name",
&event.passthrough_route_name,
));
}
}
_ => {}
}
// Opt-in captured content (#519 B.2) — present ONLY on a record built by
Expand DownExpand Up@@ -841,6 +859,7 @@ fn operation_name(event: &UsageEvent) -> &'static str {
match event.inbound_protocol.as_str() {
"a2a" => "invoke_agent",
"mcp" => "execute_tool",
"passthrough" => "passthrough",
_ => "chat",
}
}
Expand All@@ -867,6 +886,9 @@ fn span_name(event: &UsageEvent) -> String {
let target = match event.inbound_protocol.as_str() {
"a2a" => &event.a2a_agent_name,
"mcp" => &event.mcp_tool_name,
// A route name is a registered resource like an agent name, so it is
// operator-bounded — but it still passes the length cap below.
"passthrough" => &event.passthrough_route_name,
_ => return "chat.completions".to_string(),
};
if target.is_empty() || target.len() > MAX_SPAN_NAME_TARGET {
Expand DownExpand Up@@ -1535,6 +1557,44 @@ mod tests {
assert_eq!(string_at("aisix.a2a.task_state"), "working");
}

#[test]
fn a_passthrough_relay_exports_route_and_identity_not_a_chat_span() {
// The passthrough handler emits usage events with
// `inbound_protocol = "passthrough"`; without the explicit branch a
// Copilot relay exported as a bare `chat.completions` span with the
// route and the device-injected end-user identity recorded nowhere.
let mut ev = sample_event();
ev.inbound_protocol = "passthrough".into();
ev.passthrough_route_name = "copilot-chat".into();
ev.client_identity = "alice@example.com".into();

let body = build_otlp_traces_payload(&ev, "test-exp");
let span = &body["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
assert_eq!(span["name"], "passthrough copilot-chat");
let attrs = span["attributes"].as_array().unwrap();
let find = |k: &str| attrs.iter().find(|a| a["key"] == k);
let string_at = |k: &str| find(k).unwrap()["value"]["stringValue"].clone();
assert_eq!(string_at("gen_ai.operation.name"), "passthrough");
assert_eq!(string_at("aisix.passthrough.route_name"), "copilot-chat");
assert_eq!(string_at("aisix.client_identity"), "alice@example.com");
}

#[test]
fn client_identity_exports_without_a_protocol_gate() {
// The identity attribute must not depend on the passthrough branch:
// any handler that populates the field gets it exported.
let mut ev = sample_event();
ev.client_identity = "bob@example.com".into();
let body = build_otlp_traces_payload(&ev, "test-exp");
let span = &body["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
let attrs = span["attributes"].as_array().unwrap();
let id = attrs
.iter()
.find(|a| a["key"] == "aisix.client_identity")
.unwrap();
assert_eq!(id["value"]["stringValue"], "bob@example.com");
}

#[test]
fn a_streamed_a2a_call_carries_its_event_count() {
// A unary call has no count, and exporting a zero would read as "the
Expand Down
1 change: 1 addition & 0 deletions crates/aisix-proxy/src/attempt.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -291,6 +291,7 @@ pub(crate) fn attempt_reached_upstream(err: &ProxyError) -> bool {
ProxyError::Bridge(be) => be.reached_upstream(),
ProxyError::ContentFiltered(_) => true,
ProxyError::MissingAuth
| ProxyError::MissingRouteAuthHeader(_)
| ProxyError::InvalidApiKey
| ProxyError::ApiKeyExpired
| ProxyError::ApiKeyDisabled
Expand Down
13 changes: 11 additions & 2 deletions crates/aisix-proxy/src/error.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -166,6 +166,13 @@ impl ErrorEnvelope {
pub enum ProxyError {
#[error("missing or malformed Authorization header")]
MissingAuth,
/// A `header_key` passthrough route saw no gateway key in its configured
/// header. Named separately from [`ProxyError::MissingAuth`] because on
/// these routes `Authorization` deliberately carries the caller's own
/// upstream credential — telling the integrator to fix `Authorization`
/// points at exactly the wrong header.
#[error("missing or malformed {0} header (this route's gateway API key header)")]
MissingRouteAuthHeader(String),
#[error("invalid API key")]
InvalidApiKey,
/// The presented key exists but its `expires_at` deadline has
Expand DownExpand Up@@ -336,7 +343,8 @@ pub(crate) fn guardrail_block_message(side: &str, guardrail_name: Option<&str>)
impl ProxyError {
pub fn status(&self) -> StatusCode {
match self {
ProxyError::MissingAuth
ProxyError::MissingRouteAuthHeader(_)
| ProxyError::MissingAuth
| ProxyError::InvalidApiKey
| ProxyError::ApiKeyExpired
| ProxyError::ApiKeyDisabled
Expand DownExpand Up@@ -369,7 +377,8 @@ impl ProxyError {

pub fn kind(&self) -> &'static str {
match self {
ProxyError::MissingAuth
ProxyError::MissingRouteAuthHeader(_)
| ProxyError::MissingAuth
| ProxyError::InvalidApiKey
| ProxyError::ApiKeyExpired
| ProxyError::ApiKeyDisabled
Expand Down
17 changes: 12 additions & 5 deletions crates/aisix-proxy/src/passthrough_route.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -352,10 +352,7 @@ pub async fn entry(
status,
elapsed,
);
crate::usage_attr::emit_error_usage_event(
&state,
&snapshot,
"passthrough_route",
let mut event = crate::usage_attr::build_error_usage_event(
"passthrough",
&request_id,
"",
Expand All@@ -364,6 +361,16 @@ pub async fn entry(
error.kind(),
&client,
);
// The route matched before the pipeline failed, so a rejected
// request still attributes to it — an operator triaging 401s
// per route needs the name on the event, not just in the log.
event.passthrough_route_name = route_name.clone();
crate::usage_attr::emit_prepared_usage_event(
&state,
&snapshot,
"passthrough_route",
event,
);
error.into_response()
}
}
Expand DownExpand Up@@ -917,7 +924,7 @@ async fn authenticate(
.and_then(|v| v.to_str().ok())
.map(|v| v.strip_prefix("Bearer ").unwrap_or(v).trim().to_string())
.filter(|v| !v.is_empty())
.ok_or(ProxyError::MissingAuth)?;
.ok_or_else(|| ProxyError::MissingRouteAuthHeader(name.to_string()))?;
crate::auth::authenticate_token(state, &token, ctx).await
}
PassthroughAuthMode::Anonymous => {
Expand Down
33 changes: 33 additions & 0 deletions crates/aisix-proxy/src/usage_attr.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -354,6 +354,30 @@ pub(crate) fn emit_error_usage_event(
error_class: &str,
client: &ClientContext,
) {
let event = build_error_usage_event(
inbound_protocol,
request_id,
requested_model,
api_key_id,
status_code,
error_class,
client,
);
emit_prepared_usage_event(state, snap, label, event);
}

/// The [`emit_error_usage_event`] event without the emission, for a caller
/// that attributes handler-specific fields (e.g. the passthrough route name)
/// before handing it to [`emit_prepared_usage_event`].
pub(crate) fn build_error_usage_event(
inbound_protocol: &'static str,
request_id: &str,
requested_model: &str,
api_key_id: &str,
status_code: u16,
error_class: &str,
client: &ClientContext,
) -> UsageEvent {
let mut event = UsageEvent {
request_id: request_id.to_string(),
occurred_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
Expand All@@ -367,6 +391,15 @@ pub(crate) fn emit_error_usage_event(
..Default::default()
};
apply_jwt_identity(&mut event, client.jwt.as_ref());
event
}

pub(crate) fn emit_prepared_usage_event(
state: &ProxyState,
snap: &AisixSnapshot,
label: &'static str,
event: UsageEvent,
) {
state.usage_sink.try_emit(label, event.clone());
let exporters = live_exporters(state, snap);
state
Expand Down
72 changes: 72 additions & 0 deletions schemas/resources/passthrough_route.schema.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -240,6 +240,78 @@
]
}
},
{
"if": {
"anyOf": [
{
"not": {
"required": [
"auth_mode"
]
},
"title": "auth_mode omitted (defaults to gateway_key)"
},
{
"properties": {
"auth_mode": {
"enum": [
"gateway_key",
"anonymous"
]
}
},
"required": [
"auth_mode"
],
"title": "auth_mode: gateway_key or anonymous"
}
]
},
"then": {
"not": {
"required": [
"auth_header_name"
]
}
},
"title": "auth_header_name only in header_key mode"
},
{
"if": {
"anyOf": [
{
"not": {
"required": [
"auth_mode"
]
},
"title": "auth_mode omitted (defaults to gateway_key)"
},
{
"properties": {
"auth_mode": {
"enum": [
"gateway_key",
"header_key"
]
}
},
"required": [
"auth_mode"
],
"title": "auth_mode: gateway_key or header_key"
}
]
},
"then": {
"not": {
"required": [
"anonymous_key_id"
]
}
},
"title": "anonymous_key_id only in anonymous mode"
},
{
"if": {
"anyOf": [
Expand Down
19 changes: 19 additions & 0 deletions tests/e2e/src/cases/passthrough-route-e2e.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -281,6 +281,25 @@ describe("passthrough-route e2e: explicit routes, BYO credentials, 410 tombstone
// …and the gateway's side-channel headers did not.
expect(hit.headers["x-aisix-api-key"]).toBeUndefined();
expect(hit.headers["x-aisix-user"]).toBeUndefined();

// Missing gateway key: the 401 must point at the route's configured
// header, not `Authorization` — on this route Authorization carries the
// caller's own upstream credential and is exactly the wrong thing to fix.
const noKey = await harnessRequest(`${app!.proxyUrl}/v1/chat/completions`, {
method: "POST",
headers: {
host: "ai-upstream.example.com",
authorization: "Bearer employee-official-token",
"content-type": "application/json",
},
body: JSON.stringify({ model: "gpt-4o", messages: [] }),
});
expect(noKey.statusCode).toBe(401);
const noKeyBody = (await noKey.body.json()) as {
error?: { message?: string };
};
expect(noKeyBody.error?.message).toContain("x-aisix-api-key");
expect(noKeyBody.error?.message).not.toContain("Authorization");
});

test("anonymous route binds the configured principal behind source_cidrs", async (ctx) => {
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(passthrough): OTLP route/identity attribution, route on rejected events, header_key 401 names its header by jarvis9443 · Pull Request #983 · api7/aisix · GitHub
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
57 changes: 57 additions & 0 deletions crates/aisix-core/src/models/passthrough_route.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -379,6 +379,30 @@ pub fn passthrough_route_coupling() -> Value {
}
}
},
// Cross-mode leftovers are configuration errors, not ignored
// fields: a companion outside its mode is never consulted, so a
// row carrying one is rejected rather than half-honored. The CP
// enforces the identical rule on create and patch.
{
"title": "auth_header_name only in header_key mode",
"if": {
"anyOf": [
{ "title": "auth_mode omitted (defaults to gateway_key)", "not": { "required": ["auth_mode"] } },
{ "title": "auth_mode: gateway_key or anonymous", "properties": { "auth_mode": { "enum": ["gateway_key", "anonymous"] } }, "required": ["auth_mode"] }
]
},
"then": { "not": { "required": ["auth_header_name"] } }
},
{
"title": "anonymous_key_id only in anonymous mode",
"if": {
"anyOf": [
{ "title": "auth_mode omitted (defaults to gateway_key)", "not": { "required": ["auth_mode"] } },
{ "title": "auth_mode: gateway_key or header_key", "properties": { "auth_mode": { "enum": ["gateway_key", "header_key"] } }, "required": ["auth_mode"] }
]
},
"then": { "not": { "required": ["anonymous_key_id"] } }
},
// credential_mode couplings: inject needs a real ProviderKey id; a
// forward_client route carrying one is a configuration error, not
// an ignored field.
Expand DownExpand Up@@ -516,6 +540,39 @@ mod coupling_tests {
assert!(validate_passthrough_route(&doc).is_err());
}

#[test]
fn cross_mode_leftover_companions_are_rejected() {
// A companion outside its mode is never consulted at runtime, so
// both validators refuse the row instead of half-honoring it.
// auth_header_name without header_key (auth_mode absent = gateway_key).
let mut doc = base();
doc["auth_header_name"] = json!("x-aisix-api-key");
assert!(validate_passthrough_route(&doc).is_err());
assert!(validate_passthrough_route_lenient(&doc).is_err());
// anonymous_key_id on an explicit header_key route.
let doc = json!({
"name": "r", "path_prefix": "/p",
"target_url": "https://u.example", "provider_key_id": "pk",
"auth_mode": "header_key", "auth_header_name": "x-aisix-api-key",
"anonymous_key_id": "ak-1"
});
assert!(validate_passthrough_route(&doc).is_err());
// auth_header_name on an anonymous route.
let doc = json!({
"name": "r", "path_prefix": "/p",
"target_url": "https://u.example", "provider_key_id": "pk",
"auth_mode": "anonymous", "anonymous_key_id": "ak-1",
"source_cidrs": ["10.0.0.0/8"],
"auth_header_name": "x-aisix-api-key"
});
assert!(validate_passthrough_route(&doc).is_err());
// source_cidrs is deliberately NOT mode-coupled: an extra IP
// allowlist is honored on every mode.
let mut doc = base();
doc["source_cidrs"] = json!(["10.0.0.0/8"]);
assert!(validate_passthrough_route(&doc).is_ok());
}

#[test]
fn credential_bearing_header_slots_are_rejected() {
for field in ["auth_header_name", "identity_header"] {
Expand Down
60 changes: 60 additions & 0 deletions crates/aisix-obs/src/otlp_http_sink.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -743,6 +743,16 @@ fn build_otlp_span(record: &SinkRecord, exporter_name: &str) -> Value {
&event.client_user_agent,
));
}
// End-user identity a passthrough route's `identity_header` extracted —
// the per-employee attribution of the forward-proxy scenario. Not gated
// on the protocol so a future handler that learns to populate it exports
// it without touching this sink.
if !event.client_identity.is_empty() {
attributes.push(attr_string_capped(
"aisix.client_identity",
&event.client_identity,
));
}
// JWT identity attribution (AISIX-Cloud#564): who the request ran as
// when it authenticated with a JWT — the identity behind the (possibly
// shared) api_key_id.
Expand DownExpand Up@@ -802,6 +812,14 @@ fn build_otlp_span(record: &SinkRecord, exporter_name: &str) -> Value {
attributes.push(attr_string("aisix.mcp.server_name", &event.mcp_server_name));
}
}
"passthrough" => {
if !event.passthrough_route_name.is_empty() {
attributes.push(attr_string(
"aisix.passthrough.route_name",
&event.passthrough_route_name,
));
}
}
_ => {}
}
// Opt-in captured content (#519 B.2) — present ONLY on a record built by
Expand DownExpand Up@@ -841,6 +859,7 @@ fn operation_name(event: &UsageEvent) -> &'static str {
match event.inbound_protocol.as_str() {
"a2a" => "invoke_agent",
"mcp" => "execute_tool",
"passthrough" => "passthrough",
_ => "chat",
}
}
Expand All@@ -867,6 +886,9 @@ fn span_name(event: &UsageEvent) -> String {
let target = match event.inbound_protocol.as_str() {
"a2a" => &event.a2a_agent_name,
"mcp" => &event.mcp_tool_name,
// A route name is a registered resource like an agent name, so it is
// operator-bounded — but it still passes the length cap below.
"passthrough" => &event.passthrough_route_name,
_ => return "chat.completions".to_string(),
};
if target.is_empty() || target.len() > MAX_SPAN_NAME_TARGET {
Expand DownExpand Up@@ -1535,6 +1557,44 @@ mod tests {
assert_eq!(string_at("aisix.a2a.task_state"), "working");
}

#[test]
fn a_passthrough_relay_exports_route_and_identity_not_a_chat_span() {
// The passthrough handler emits usage events with
// `inbound_protocol = "passthrough"`; without the explicit branch a
// Copilot relay exported as a bare `chat.completions` span with the
// route and the device-injected end-user identity recorded nowhere.
let mut ev = sample_event();
ev.inbound_protocol = "passthrough".into();
ev.passthrough_route_name = "copilot-chat".into();
ev.client_identity = "alice@example.com".into();

let body = build_otlp_traces_payload(&ev, "test-exp");
let span = &body["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
assert_eq!(span["name"], "passthrough copilot-chat");
let attrs = span["attributes"].as_array().unwrap();
let find = |k: &str| attrs.iter().find(|a| a["key"] == k);
let string_at = |k: &str| find(k).unwrap()["value"]["stringValue"].clone();
assert_eq!(string_at("gen_ai.operation.name"), "passthrough");
assert_eq!(string_at("aisix.passthrough.route_name"), "copilot-chat");
assert_eq!(string_at("aisix.client_identity"), "alice@example.com");
}

#[test]
fn client_identity_exports_without_a_protocol_gate() {
// The identity attribute must not depend on the passthrough branch:
// any handler that populates the field gets it exported.
let mut ev = sample_event();
ev.client_identity = "bob@example.com".into();
let body = build_otlp_traces_payload(&ev, "test-exp");
let span = &body["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
let attrs = span["attributes"].as_array().unwrap();
let id = attrs
.iter()
.find(|a| a["key"] == "aisix.client_identity")
.unwrap();
assert_eq!(id["value"]["stringValue"], "bob@example.com");
}

#[test]
fn a_streamed_a2a_call_carries_its_event_count() {
// A unary call has no count, and exporting a zero would read as "the
Expand Down
1 change: 1 addition & 0 deletions crates/aisix-proxy/src/attempt.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -291,6 +291,7 @@ pub(crate) fn attempt_reached_upstream(err: &ProxyError) -> bool {
ProxyError::Bridge(be) => be.reached_upstream(),
ProxyError::ContentFiltered(_) => true,
ProxyError::MissingAuth
| ProxyError::MissingRouteAuthHeader(_)
| ProxyError::InvalidApiKey
| ProxyError::ApiKeyExpired
| ProxyError::ApiKeyDisabled
Expand Down
13 changes: 11 additions & 2 deletions crates/aisix-proxy/src/error.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -166,6 +166,13 @@ impl ErrorEnvelope {
pub enum ProxyError {
#[error("missing or malformed Authorization header")]
MissingAuth,
/// A `header_key` passthrough route saw no gateway key in its configured
/// header. Named separately from [`ProxyError::MissingAuth`] because on
/// these routes `Authorization` deliberately carries the caller's own
/// upstream credential — telling the integrator to fix `Authorization`
/// points at exactly the wrong header.
#[error("missing or malformed {0} header (this route's gateway API key header)")]
MissingRouteAuthHeader(String),
#[error("invalid API key")]
InvalidApiKey,
/// The presented key exists but its `expires_at` deadline has
Expand DownExpand Up@@ -336,7 +343,8 @@ pub(crate) fn guardrail_block_message(side: &str, guardrail_name: Option<&str>)
impl ProxyError {
pub fn status(&self) -> StatusCode {
match self {
ProxyError::MissingAuth
ProxyError::MissingRouteAuthHeader(_)
| ProxyError::MissingAuth
| ProxyError::InvalidApiKey
| ProxyError::ApiKeyExpired
| ProxyError::ApiKeyDisabled
Expand DownExpand Up@@ -369,7 +377,8 @@ impl ProxyError {

pub fn kind(&self) -> &'static str {
match self {
ProxyError::MissingAuth
ProxyError::MissingRouteAuthHeader(_)
| ProxyError::MissingAuth
| ProxyError::InvalidApiKey
| ProxyError::ApiKeyExpired
| ProxyError::ApiKeyDisabled
Expand Down
17 changes: 12 additions & 5 deletions crates/aisix-proxy/src/passthrough_route.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -352,10 +352,7 @@ pub async fn entry(
status,
elapsed,
);
crate::usage_attr::emit_error_usage_event(
&state,
&snapshot,
"passthrough_route",
let mut event = crate::usage_attr::build_error_usage_event(
"passthrough",
&request_id,
"",
Expand All@@ -364,6 +361,16 @@ pub async fn entry(
error.kind(),
&client,
);
// The route matched before the pipeline failed, so a rejected
// request still attributes to it — an operator triaging 401s
// per route needs the name on the event, not just in the log.
event.passthrough_route_name = route_name.clone();
crate::usage_attr::emit_prepared_usage_event(
&state,
&snapshot,
"passthrough_route",
event,
);
error.into_response()
}
}
Expand DownExpand Up@@ -917,7 +924,7 @@ async fn authenticate(
.and_then(|v| v.to_str().ok())
.map(|v| v.strip_prefix("Bearer ").unwrap_or(v).trim().to_string())
.filter(|v| !v.is_empty())
.ok_or(ProxyError::MissingAuth)?;
.ok_or_else(|| ProxyError::MissingRouteAuthHeader(name.to_string()))?;
crate::auth::authenticate_token(state, &token, ctx).await
}
PassthroughAuthMode::Anonymous => {
Expand Down
33 changes: 33 additions & 0 deletions crates/aisix-proxy/src/usage_attr.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -354,6 +354,30 @@ pub(crate) fn emit_error_usage_event(
error_class: &str,
client: &ClientContext,
) {
let event = build_error_usage_event(
inbound_protocol,
request_id,
requested_model,
api_key_id,
status_code,
error_class,
client,
);
emit_prepared_usage_event(state, snap, label, event);
}

/// The [`emit_error_usage_event`] event without the emission, for a caller
/// that attributes handler-specific fields (e.g. the passthrough route name)
/// before handing it to [`emit_prepared_usage_event`].
pub(crate) fn build_error_usage_event(
inbound_protocol: &'static str,
request_id: &str,
requested_model: &str,
api_key_id: &str,
status_code: u16,
error_class: &str,
client: &ClientContext,
) -> UsageEvent {
let mut event = UsageEvent {
request_id: request_id.to_string(),
occurred_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
Expand All@@ -367,6 +391,15 @@ pub(crate) fn emit_error_usage_event(
..Default::default()
};
apply_jwt_identity(&mut event, client.jwt.as_ref());
event
}

pub(crate) fn emit_prepared_usage_event(
state: &ProxyState,
snap: &AisixSnapshot,
label: &'static str,
event: UsageEvent,
) {
state.usage_sink.try_emit(label, event.clone());
let exporters = live_exporters(state, snap);
state
Expand Down
72 changes: 72 additions & 0 deletions schemas/resources/passthrough_route.schema.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -240,6 +240,78 @@
]
}
},
{
"if": {
"anyOf": [
{
"not": {
"required": [
"auth_mode"
]
},
"title": "auth_mode omitted (defaults to gateway_key)"
},
{
"properties": {
"auth_mode": {
"enum": [
"gateway_key",
"anonymous"
]
}
},
"required": [
"auth_mode"
],
"title": "auth_mode: gateway_key or anonymous"
}
]
},
"then": {
"not": {
"required": [
"auth_header_name"
]
}
},
"title": "auth_header_name only in header_key mode"
},
{
"if": {
"anyOf": [
{
"not": {
"required": [
"auth_mode"
]
},
"title": "auth_mode omitted (defaults to gateway_key)"
},
{
"properties": {
"auth_mode": {
"enum": [
"gateway_key",
"header_key"
]
}
},
"required": [
"auth_mode"
],
"title": "auth_mode: gateway_key or header_key"
}
]
},
"then": {
"not": {
"required": [
"anonymous_key_id"
]
}
},
"title": "anonymous_key_id only in anonymous mode"
},
{
"if": {
"anyOf": [
Expand Down
19 changes: 19 additions & 0 deletions tests/e2e/src/cases/passthrough-route-e2e.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -281,6 +281,25 @@ describe("passthrough-route e2e: explicit routes, BYO credentials, 410 tombstone
// …and the gateway's side-channel headers did not.
expect(hit.headers["x-aisix-api-key"]).toBeUndefined();
expect(hit.headers["x-aisix-user"]).toBeUndefined();

// Missing gateway key: the 401 must point at the route's configured
// header, not `Authorization` — on this route Authorization carries the
// caller's own upstream credential and is exactly the wrong thing to fix.
const noKey = await harnessRequest(`${app!.proxyUrl}/v1/chat/completions`, {
method: "POST",
headers: {
host: "ai-upstream.example.com",
authorization: "Bearer employee-official-token",
"content-type": "application/json",
},
body: JSON.stringify({ model: "gpt-4o", messages: [] }),
});
expect(noKey.statusCode).toBe(401);
const noKeyBody = (await noKey.body.json()) as {
error?: { message?: string };
};
expect(noKeyBody.error?.message).toContain("x-aisix-api-key");
expect(noKeyBody.error?.message).not.toContain("Authorization");
});

test("anonymous route binds the configured principal behind source_cidrs", async (ctx) => {
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(passthrough): OTLP route/identity attribution, route on rejected events, header_key 401 names its header by jarvis9443 · Pull Request #983 · api7/aisix · GitHub
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
57 changes: 57 additions & 0 deletions crates/aisix-core/src/models/passthrough_route.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -379,6 +379,30 @@ pub fn passthrough_route_coupling() -> Value {
}
}
},
// Cross-mode leftovers are configuration errors, not ignored
// fields: a companion outside its mode is never consulted, so a
// row carrying one is rejected rather than half-honored. The CP
// enforces the identical rule on create and patch.
{
"title": "auth_header_name only in header_key mode",
"if": {
"anyOf": [
{ "title": "auth_mode omitted (defaults to gateway_key)", "not": { "required": ["auth_mode"] } },
{ "title": "auth_mode: gateway_key or anonymous", "properties": { "auth_mode": { "enum": ["gateway_key", "anonymous"] } }, "required": ["auth_mode"] }
]
},
"then": { "not": { "required": ["auth_header_name"] } }
},
{
"title": "anonymous_key_id only in anonymous mode",
"if": {
"anyOf": [
{ "title": "auth_mode omitted (defaults to gateway_key)", "not": { "required": ["auth_mode"] } },
{ "title": "auth_mode: gateway_key or header_key", "properties": { "auth_mode": { "enum": ["gateway_key", "header_key"] } }, "required": ["auth_mode"] }
]
},
"then": { "not": { "required": ["anonymous_key_id"] } }
},
// credential_mode couplings: inject needs a real ProviderKey id; a
// forward_client route carrying one is a configuration error, not
// an ignored field.
Expand DownExpand Up@@ -516,6 +540,39 @@ mod coupling_tests {
assert!(validate_passthrough_route(&doc).is_err());
}

#[test]
fn cross_mode_leftover_companions_are_rejected() {
// A companion outside its mode is never consulted at runtime, so
// both validators refuse the row instead of half-honoring it.
// auth_header_name without header_key (auth_mode absent = gateway_key).
let mut doc = base();
doc["auth_header_name"] = json!("x-aisix-api-key");
assert!(validate_passthrough_route(&doc).is_err());
assert!(validate_passthrough_route_lenient(&doc).is_err());
// anonymous_key_id on an explicit header_key route.
let doc = json!({
"name": "r", "path_prefix": "/p",
"target_url": "https://u.example", "provider_key_id": "pk",
"auth_mode": "header_key", "auth_header_name": "x-aisix-api-key",
"anonymous_key_id": "ak-1"
});
assert!(validate_passthrough_route(&doc).is_err());
// auth_header_name on an anonymous route.
let doc = json!({
"name": "r", "path_prefix": "/p",
"target_url": "https://u.example", "provider_key_id": "pk",
"auth_mode": "anonymous", "anonymous_key_id": "ak-1",
"source_cidrs": ["10.0.0.0/8"],
"auth_header_name": "x-aisix-api-key"
});
assert!(validate_passthrough_route(&doc).is_err());
// source_cidrs is deliberately NOT mode-coupled: an extra IP
// allowlist is honored on every mode.
let mut doc = base();
doc["source_cidrs"] = json!(["10.0.0.0/8"]);
assert!(validate_passthrough_route(&doc).is_ok());
}

#[test]
fn credential_bearing_header_slots_are_rejected() {
for field in ["auth_header_name", "identity_header"] {
Expand Down
60 changes: 60 additions & 0 deletions crates/aisix-obs/src/otlp_http_sink.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -743,6 +743,16 @@ fn build_otlp_span(record: &SinkRecord, exporter_name: &str) -> Value {
&event.client_user_agent,
));
}
// End-user identity a passthrough route's `identity_header` extracted —
// the per-employee attribution of the forward-proxy scenario. Not gated
// on the protocol so a future handler that learns to populate it exports
// it without touching this sink.
if !event.client_identity.is_empty() {
attributes.push(attr_string_capped(
"aisix.client_identity",
&event.client_identity,
));
}
// JWT identity attribution (AISIX-Cloud#564): who the request ran as
// when it authenticated with a JWT — the identity behind the (possibly
// shared) api_key_id.
Expand DownExpand Up@@ -802,6 +812,14 @@ fn build_otlp_span(record: &SinkRecord, exporter_name: &str) -> Value {
attributes.push(attr_string("aisix.mcp.server_name", &event.mcp_server_name));
}
}
"passthrough" => {
if !event.passthrough_route_name.is_empty() {
attributes.push(attr_string(
"aisix.passthrough.route_name",
&event.passthrough_route_name,
));
}
}
_ => {}
}
// Opt-in captured content (#519 B.2) — present ONLY on a record built by
Expand DownExpand Up@@ -841,6 +859,7 @@ fn operation_name(event: &UsageEvent) -> &'static str {
match event.inbound_protocol.as_str() {
"a2a" => "invoke_agent",
"mcp" => "execute_tool",
"passthrough" => "passthrough",
_ => "chat",
}
}
Expand All@@ -867,6 +886,9 @@ fn span_name(event: &UsageEvent) -> String {
let target = match event.inbound_protocol.as_str() {
"a2a" => &event.a2a_agent_name,
"mcp" => &event.mcp_tool_name,
// A route name is a registered resource like an agent name, so it is
// operator-bounded — but it still passes the length cap below.
"passthrough" => &event.passthrough_route_name,
_ => return "chat.completions".to_string(),
};
if target.is_empty() || target.len() > MAX_SPAN_NAME_TARGET {
Expand DownExpand Up@@ -1535,6 +1557,44 @@ mod tests {
assert_eq!(string_at("aisix.a2a.task_state"), "working");
}

#[test]
fn a_passthrough_relay_exports_route_and_identity_not_a_chat_span() {
// The passthrough handler emits usage events with
// `inbound_protocol = "passthrough"`; without the explicit branch a
// Copilot relay exported as a bare `chat.completions` span with the
// route and the device-injected end-user identity recorded nowhere.
let mut ev = sample_event();
ev.inbound_protocol = "passthrough".into();
ev.passthrough_route_name = "copilot-chat".into();
ev.client_identity = "alice@example.com".into();

let body = build_otlp_traces_payload(&ev, "test-exp");
let span = &body["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
assert_eq!(span["name"], "passthrough copilot-chat");
let attrs = span["attributes"].as_array().unwrap();
let find = |k: &str| attrs.iter().find(|a| a["key"] == k);
let string_at = |k: &str| find(k).unwrap()["value"]["stringValue"].clone();
assert_eq!(string_at("gen_ai.operation.name"), "passthrough");
assert_eq!(string_at("aisix.passthrough.route_name"), "copilot-chat");
assert_eq!(string_at("aisix.client_identity"), "alice@example.com");
}

#[test]
fn client_identity_exports_without_a_protocol_gate() {
// The identity attribute must not depend on the passthrough branch:
// any handler that populates the field gets it exported.
let mut ev = sample_event();
ev.client_identity = "bob@example.com".into();
let body = build_otlp_traces_payload(&ev, "test-exp");
let span = &body["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
let attrs = span["attributes"].as_array().unwrap();
let id = attrs
.iter()
.find(|a| a["key"] == "aisix.client_identity")
.unwrap();
assert_eq!(id["value"]["stringValue"], "bob@example.com");
}

#[test]
fn a_streamed_a2a_call_carries_its_event_count() {
// A unary call has no count, and exporting a zero would read as "the
Expand Down
1 change: 1 addition & 0 deletions crates/aisix-proxy/src/attempt.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -291,6 +291,7 @@ pub(crate) fn attempt_reached_upstream(err: &ProxyError) -> bool {
ProxyError::Bridge(be) => be.reached_upstream(),
ProxyError::ContentFiltered(_) => true,
ProxyError::MissingAuth
| ProxyError::MissingRouteAuthHeader(_)
| ProxyError::InvalidApiKey
| ProxyError::ApiKeyExpired
| ProxyError::ApiKeyDisabled
Expand Down
13 changes: 11 additions & 2 deletions crates/aisix-proxy/src/error.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -166,6 +166,13 @@ impl ErrorEnvelope {
pub enum ProxyError {
#[error("missing or malformed Authorization header")]
MissingAuth,
/// A `header_key` passthrough route saw no gateway key in its configured
/// header. Named separately from [`ProxyError::MissingAuth`] because on
/// these routes `Authorization` deliberately carries the caller's own
/// upstream credential — telling the integrator to fix `Authorization`
/// points at exactly the wrong header.
#[error("missing or malformed {0} header (this route's gateway API key header)")]
MissingRouteAuthHeader(String),
#[error("invalid API key")]
InvalidApiKey,
/// The presented key exists but its `expires_at` deadline has
Expand DownExpand Up@@ -336,7 +343,8 @@ pub(crate) fn guardrail_block_message(side: &str, guardrail_name: Option<&str>)
impl ProxyError {
pub fn status(&self) -> StatusCode {
match self {
ProxyError::MissingAuth
ProxyError::MissingRouteAuthHeader(_)
| ProxyError::MissingAuth
| ProxyError::InvalidApiKey
| ProxyError::ApiKeyExpired
| ProxyError::ApiKeyDisabled
Expand DownExpand Up@@ -369,7 +377,8 @@ impl ProxyError {

pub fn kind(&self) -> &'static str {
match self {
ProxyError::MissingAuth
ProxyError::MissingRouteAuthHeader(_)
| ProxyError::MissingAuth
| ProxyError::InvalidApiKey
| ProxyError::ApiKeyExpired
| ProxyError::ApiKeyDisabled
Expand Down
17 changes: 12 additions & 5 deletions crates/aisix-proxy/src/passthrough_route.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -352,10 +352,7 @@ pub async fn entry(
status,
elapsed,
);
crate::usage_attr::emit_error_usage_event(
&state,
&snapshot,
"passthrough_route",
let mut event = crate::usage_attr::build_error_usage_event(
"passthrough",
&request_id,
"",
Expand All@@ -364,6 +361,16 @@ pub async fn entry(
error.kind(),
&client,
);
// The route matched before the pipeline failed, so a rejected
// request still attributes to it — an operator triaging 401s
// per route needs the name on the event, not just in the log.
event.passthrough_route_name = route_name.clone();
crate::usage_attr::emit_prepared_usage_event(
&state,
&snapshot,
"passthrough_route",
event,
);
error.into_response()
}
}
Expand DownExpand Up@@ -917,7 +924,7 @@ async fn authenticate(
.and_then(|v| v.to_str().ok())
.map(|v| v.strip_prefix("Bearer ").unwrap_or(v).trim().to_string())
.filter(|v| !v.is_empty())
.ok_or(ProxyError::MissingAuth)?;
.ok_or_else(|| ProxyError::MissingRouteAuthHeader(name.to_string()))?;
crate::auth::authenticate_token(state, &token, ctx).await
}
PassthroughAuthMode::Anonymous => {
Expand Down
33 changes: 33 additions & 0 deletions crates/aisix-proxy/src/usage_attr.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -354,6 +354,30 @@ pub(crate) fn emit_error_usage_event(
error_class: &str,
client: &ClientContext,
) {
let event = build_error_usage_event(
inbound_protocol,
request_id,
requested_model,
api_key_id,
status_code,
error_class,
client,
);
emit_prepared_usage_event(state, snap, label, event);
}

/// The [`emit_error_usage_event`] event without the emission, for a caller
/// that attributes handler-specific fields (e.g. the passthrough route name)
/// before handing it to [`emit_prepared_usage_event`].
pub(crate) fn build_error_usage_event(
inbound_protocol: &'static str,
request_id: &str,
requested_model: &str,
api_key_id: &str,
status_code: u16,
error_class: &str,
client: &ClientContext,
) -> UsageEvent {
let mut event = UsageEvent {
request_id: request_id.to_string(),
occurred_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
Expand All@@ -367,6 +391,15 @@ pub(crate) fn emit_error_usage_event(
..Default::default()
};
apply_jwt_identity(&mut event, client.jwt.as_ref());
event
}

pub(crate) fn emit_prepared_usage_event(
state: &ProxyState,
snap: &AisixSnapshot,
label: &'static str,
event: UsageEvent,
) {
state.usage_sink.try_emit(label, event.clone());
let exporters = live_exporters(state, snap);
state
Expand Down
72 changes: 72 additions & 0 deletions schemas/resources/passthrough_route.schema.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -240,6 +240,78 @@
]
}
},
{
"if": {
"anyOf": [
{
"not": {
"required": [
"auth_mode"
]
},
"title": "auth_mode omitted (defaults to gateway_key)"
},
{
"properties": {
"auth_mode": {
"enum": [
"gateway_key",
"anonymous"
]
}
},
"required": [
"auth_mode"
],
"title": "auth_mode: gateway_key or anonymous"
}
]
},
"then": {
"not": {
"required": [
"auth_header_name"
]
}
},
"title": "auth_header_name only in header_key mode"
},
{
"if": {
"anyOf": [
{
"not": {
"required": [
"auth_mode"
]
},
"title": "auth_mode omitted (defaults to gateway_key)"
},
{
"properties": {
"auth_mode": {
"enum": [
"gateway_key",
"header_key"
]
}
},
"required": [
"auth_mode"
],
"title": "auth_mode: gateway_key or header_key"
}
]
},
"then": {
"not": {
"required": [
"anonymous_key_id"
]
}
},
"title": "anonymous_key_id only in anonymous mode"
},
{
"if": {
"anyOf": [
Expand Down
19 changes: 19 additions & 0 deletions tests/e2e/src/cases/passthrough-route-e2e.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -281,6 +281,25 @@ describe("passthrough-route e2e: explicit routes, BYO credentials, 410 tombstone
// …and the gateway's side-channel headers did not.
expect(hit.headers["x-aisix-api-key"]).toBeUndefined();
expect(hit.headers["x-aisix-user"]).toBeUndefined();

// Missing gateway key: the 401 must point at the route's configured
// header, not `Authorization` — on this route Authorization carries the
// caller's own upstream credential and is exactly the wrong thing to fix.
const noKey = await harnessRequest(`${app!.proxyUrl}/v1/chat/completions`, {
method: "POST",
headers: {
host: "ai-upstream.example.com",
authorization: "Bearer employee-official-token",
"content-type": "application/json",
},
body: JSON.stringify({ model: "gpt-4o", messages: [] }),
});
expect(noKey.statusCode).toBe(401);
const noKeyBody = (await noKey.body.json()) as {
error?: { message?: string };
};
expect(noKeyBody.error?.message).toContain("x-aisix-api-key");
expect(noKeyBody.error?.message).not.toContain("Authorization");
});

test("anonymous route binds the configured principal behind source_cidrs", async (ctx) => {
Expand Down