fix(xai): forward structured stream-error details to the client

Surface the upstream error message, code, and type from response.failed/error stream events through the client-visible error pipeline, matching the reference translation baseline and restoring fatal-vs-retryable classification and retry pacing for downstream Codex clients. Messages are scrubbed of account fingerprints (UUIDs) and control characters before exposure; unstructured failures keep the sanitized generic error, and non-2xx HTTP transport errors keep their existing rewrap semantics.

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
zyycn
2026-07-26 19:30:19 +08:00
co-authored by Claude
parent e1b7cda8b4
commit 2e0bee6096
2 changed files with 114 additions and 7 deletions
@@ -7,7 +7,9 @@ use gateway_core::accounting::{
CurrencyCode, Decimal, Money, ProviderReportedCost, Usage,
};
use gateway_core::engine::UpstreamSendState;
use gateway_core::error::{ProviderError, ProviderErrorKind, SafeUpstreamValue};
use gateway_core::error::{
ClientVisibleUpstreamError, ProviderError, ProviderErrorKind, SafeUpstreamValue,
};
use gateway_core::event::{
ContentItem, ContentKind, FinishReason, GatewayEvent, ProtocolWireEvent, ProviderEvent,
ReasoningDelta, ResponseMeta, TextDelta, ToolCallDelta,
@@ -781,11 +783,7 @@ fn incomplete_finish_reason(response: &Value) -> FinishReason {
}
fn upstream_event_error(value: &Value) -> ProviderError {
let code = value
.pointer("/error/code")
.or_else(|| value.pointer("/response/error/code"))
.or_else(|| value.get("code"))
.and_then(Value::as_str);
let code = upstream_error_field(value, "code");
let kind = match code {
Some("invalid_request" | "invalid_prompt") => ProviderErrorKind::InvalidRequest,
Some("unsupported" | "unsupported_feature") => ProviderErrorKind::Unsupported,
@@ -800,9 +798,75 @@ fn upstream_event_error(value: &Value) -> ProviderError {
if let Some(code) = code.and_then(|code| SafeUpstreamValue::new(code.to_owned()).ok()) {
error = error.with_upstream_code(code);
}
// 结构化 message/code/type 供原客户端展示与重试分类;message 先脱去
// 账号指纹(上游限流文案内嵌 team UUID),非结构化正文不透出。
if let Some(message) = upstream_error_field(value, "message")
&& let Ok(client_error) = ClientVisibleUpstreamError::new(
scrub_account_fingerprints(message),
code.map(str::to_owned),
upstream_error_field(value, "type").map(str::to_owned),
)
{
error = error.with_client_visible_upstream_error(client_error);
}
error
}
fn upstream_error_field<'a>(value: &'a Value, field: &str) -> Option<&'a str> {
value
.pointer(&format!("/error/{field}"))
.or_else(|| value.pointer(&format!("/response/error/{field}")))
.or_else(|| value.get(field))
.and_then(Value::as_str)
}
const UUID_TEXT_LEN: usize = 36;
/// 把 message 中的 UUID 替换为占位符,控制字符归一为空格,
/// 使文本满足客户端可见错误的约束且不携带可定位账号的标识。
fn scrub_account_fingerprints(message: &str) -> String {
let bytes = message.as_bytes();
let mut scrubbed = String::with_capacity(message.len());
let mut index = 0;
while index < message.len() {
if uuid_at(bytes, index) {
scrubbed.push_str("[redacted]");
index += UUID_TEXT_LEN;
continue;
}
let character = message[index..].chars().next().expect("char boundary");
scrubbed.push(if character.is_control() {
' '
} else {
character
});
index += character.len_utf8();
}
scrubbed
}
fn uuid_at(bytes: &[u8], index: usize) -> bool {
let Some(candidate) = bytes.get(index..index + UUID_TEXT_LEN) else {
return false;
};
if index > 0 && bytes[index - 1].is_ascii_alphanumeric() {
return false;
}
if bytes
.get(index + UUID_TEXT_LEN)
.is_some_and(u8::is_ascii_alphanumeric)
{
return false;
}
candidate.iter().enumerate().all(|(offset, byte)| {
if matches!(offset, 8 | 13 | 18 | 23) {
*byte == b'-'
} else {
byte.is_ascii_hexdigit()
}
})
}
fn protocol_error(_error: impl std::fmt::Debug) -> ProviderError {
protocol_error_marker()
}
@@ -709,7 +709,7 @@ fn decoder_should_classify_failed_event_without_retaining_body() {
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n\n",
"event: response.failed\n",
"data: {\"type\":\"response.failed\",\"error\":{\"code\":\"rate_limit_exceeded\",\"message\":\"secret\"}}\n\n",
"data: {\"type\":\"response.failed\",\"error\":{\"code\":\"rate_limit_exceeded\",\"type\":\"server_error\",\"message\":\"secret\"}}\n\n",
);
let error = GrokCanonicalDecoder::new("fallback")
.push(body.as_bytes())
@@ -723,5 +723,48 @@ fn decoder_should_classify_failed_event_without_retaining_body() {
error.upstream_code().map(|code| code.as_str()),
Some("rate_limit_exceeded")
);
let visible = error
.client_visible_upstream_error()
.expect("structured upstream message is client-visible");
assert_eq!(visible.message(), "secret");
assert_eq!(visible.code(), Some("rate_limit_exceeded"));
assert_eq!(visible.error_type(), Some("server_error"));
assert!(!format!("{error:?}").contains("secret"));
}
#[test]
fn decoder_should_scrub_account_fingerprints_from_failure_messages() {
let body = concat!(
"event: error\n",
"data: {\"type\":\"error\",\"code\":\"rate_limit_exceeded\",\"message\":\"Too many requests for team 00000000-0000-0000-0000-000000000013 and model grok-4.\\nRequests per Second (actual/limit): 2/2\"}\n\n",
);
let error = GrokCanonicalDecoder::new("fallback")
.push(body.as_bytes())
.expect_err("error event must surface");
let visible = error
.client_visible_upstream_error()
.expect("structured upstream message is client-visible");
assert_eq!(
visible.message(),
"Too many requests for team [redacted] and model grok-4. Requests per Second (actual/limit): 2/2"
);
assert!(!format!("{error:?}").contains("00000000-0000"));
}
#[test]
fn decoder_should_omit_client_details_for_unstructured_failures() {
let body = concat!(
"event: response.failed\n",
"data: {\"type\":\"response.failed\",\"error\":{\"code\":\"internal_error\"}}\n\n",
);
let error = GrokCanonicalDecoder::new("fallback")
.push(body.as_bytes())
.expect_err("failed response must surface");
assert!(error.client_visible_upstream_error().is_none());
assert_eq!(
error.upstream_code().map(|code| code.as_str()),
Some("internal_error")
);
}