mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
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:
@@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user