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
113 changes: 94 additions & 19 deletions crates/switchyard-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,7 @@ async fn anthropic_count_tokens(
) -> Response {
let body = match llm_json_body(body) {
Ok(body) => body,
Err(message) => return invalid_body_error(message),
Err(message) => return anthropic_error_response(invalid_body_error(message)),
};
let (algorithm, request) = match resolve_route(
&state,
Expand All @@ -484,12 +484,12 @@ async fn anthropic_count_tokens(
WireFormat::AnthropicMessages,
) {
Ok(resolved) => resolved,
Err(response) => return response,
Err(response) => return anthropic_error_response(response),
};
match algorithm.count_tokens(request).await {
anthropic_error_response(match algorithm.count_tokens(request).await {
Ok(payload) => (StatusCode::OK, Json(payload)).into_response(),
Err(error) => count_tokens_error(error),
}
})
}

/// Map a [`count_tokens`](Algorithm::count_tokens) failure to an HTTP response:
Expand Down Expand Up @@ -567,6 +567,7 @@ async fn handle_endpoint_inner(
}
Err(message) => invalid_body_error(message),
};
let response = render_error_response(response, wire_format);
metrics::record_client_response(response.status().as_u16());
request_log.emit(&response);
response
Expand Down Expand Up @@ -805,7 +806,7 @@ fn algorithm_error(error: LibsyError) -> Response {
),
LlmClientError::UpstreamHttp { status, body } => error_response(
StatusCode::from_u16(*status).unwrap_or(StatusCode::BAD_GATEWAY),
body,
upstream_error_message(body),
"upstream_error",
"upstream_error",
),
Expand Down Expand Up @@ -834,6 +835,93 @@ fn algorithm_error(error: LibsyError) -> Response {
}
}

// Provider errors are often JSON documents; expose their message without
// embedding the entire document as an escaped string in our error envelope.
fn upstream_error_message(body: &str) -> String {
serde_json::from_str::<Value>(body)
.ok()
.and_then(|body| {
body.pointer("/error/message")
.and_then(Value::as_str)
.map(str::to_string)
})
.unwrap_or_else(|| body.to_string())
}

// Error metadata retained until the client-facing endpoint selects an envelope.
#[derive(Clone)]
struct ApiError {
status: StatusCode,
message: String,
error_type: &'static str,
code: &'static str,
}

impl ApiError {
fn new(
status: StatusCode,
message: impl Into<String>,
error_type: &'static str,
code: &'static str,
) -> Self {
Self {
status,
message: message.into(),
error_type,
code,
}
}

fn into_response(self, wire_format: WireFormat) -> Response {
let body = match wire_format {
WireFormat::AnthropicMessages => json!({
"type": "error",
"error": {
"type": anthropic_error_type(self.status),
"message": self.message.clone(),
}
}),
WireFormat::OpenAiChat | WireFormat::OpenAiResponses => json!({
"error": {
"message": self.message.clone(),
"type": self.error_type,
"code": self.code,
}
}),
};
let mut response = (self.status, Json(body)).into_response();
response
.extensions_mut()
.insert(RequestLogError(self.message.clone()));
response.extensions_mut().insert(self);
response
}
}

fn render_error_response(response: Response, wire_format: WireFormat) -> Response {
let Some(error) = response.extensions().get::<ApiError>().cloned() else {
return response;
};
error.into_response(wire_format)
}

fn anthropic_error_response(response: Response) -> Response {
render_error_response(response, WireFormat::AnthropicMessages)
}

fn anthropic_error_type(status: StatusCode) -> &'static str {
match status {
StatusCode::BAD_REQUEST => "invalid_request_error",
StatusCode::UNAUTHORIZED => "authentication_error",
StatusCode::FORBIDDEN => "permission_error",
StatusCode::NOT_FOUND => "not_found_error",
StatusCode::PAYLOAD_TOO_LARGE => "request_too_large",
StatusCode::TOO_MANY_REQUESTS => "rate_limit_error",
status if status.as_u16() == 529 => "overloaded_error",
_ => "api_error",
}
}

fn server_error(message: impl Into<String>) -> Response {
error_response(
StatusCode::INTERNAL_SERVER_ERROR,
Expand All @@ -858,20 +946,7 @@ fn error_response(
error_type: &'static str,
code: &'static str,
) -> Response {
let message = message.into();
let mut response = (
status,
Json(json!({
"error": {
"message": message.clone(),
"type": error_type,
"code": code,
}
})),
)
.into_response();
response.extensions_mut().insert(RequestLogError(message));
response
ApiError::new(status, message, error_type, code).into_response(WireFormat::OpenAiChat)
}

async fn models(State(state): State<ServerState>) -> Json<Value> {
Expand Down
89 changes: 80 additions & 9 deletions crates/switchyard-server/tests/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ async fn upstream_chat(
)
.into_response();
}
if body["messages"][0]["content"] == "auth-fail" {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": {"message": "upstream authentication failed"}})),
)
.into_response();
}

let model = body["model"].as_str().unwrap_or("unknown").to_string();
if body["stream"].as_bool() == Some(true) {
Expand Down Expand Up @@ -1040,8 +1047,14 @@ targets = ["weak"]
// The route's picked target is OpenAI, so count_tokens (Anthropic-only) is
// unsupported for it.
assert_eq!(
response.json()?["error"]["code"],
"count_tokens_unsupported"
response.json()?,
json!({
"type": "error",
"error": {
"type": "invalid_request_error",
"message": "no target supports count_tokens (needs an Anthropic upstream)"
}
})
);
Ok(())
}
Expand Down Expand Up @@ -1476,7 +1489,7 @@ async fn streaming_error_records_error_without_usage_or_latency() -> TestResult
}

#[tokio::test]
async fn request_and_upstream_errors_use_the_canonical_envelope() -> TestResult {
async fn request_and_upstream_errors_use_the_inbound_wire_format() -> TestResult {
let (_upstream, app) = test_app(&[(ROUTE_MODEL, &["model/a"])]).await?;

let unknown = send(
Expand Down Expand Up @@ -1505,17 +1518,75 @@ async fn request_and_upstream_errors_use_the_canonical_envelope() -> TestResult
"invalid_request_error"
);

let upstream_error = send(
let upstream_cases = [
(
"/v1/chat/completions",
json!({
"model": ROUTE_MODEL,
"messages": [{"role": "user", "content": "auth-fail"}]
}),
json!({
"error": {
"message": "upstream authentication failed",
"type": "upstream_error",
"code": "upstream_error"
}
}),
),
(
"/v1/responses",
json!({"model": ROUTE_MODEL, "input": "auth-fail"}),
json!({
"error": {
"message": "upstream authentication failed",
"type": "upstream_error",
"code": "upstream_error"
}
}),
),
(
"/v1/messages",
json!({
"model": ROUTE_MODEL,
"max_tokens": 64,
"messages": [{"role": "user", "content": "auth-fail"}]
}),
json!({
"type": "error",
"error": {
"type": "authentication_error",
"message": "upstream authentication failed"
}
}),
),
];
for (path, body, expected) in upstream_cases {
let response = send(&app, "POST", path, Some(body)).await?;
assert_eq!(response.status, StatusCode::UNAUTHORIZED, "{path}");
assert_eq!(response.json()?, expected, "{path}");
}

let anthropic_unknown = send(
&app,
"POST",
"/v1/chat/completions",
"/v1/messages",
Some(json!({
"model": ROUTE_MODEL,
"messages": [{"role": "user", "content": "fail"}]
"model": "other",
"max_tokens": 64,
"messages": [{"role": "user", "content": "hi"}]
})),
)
.await?;
assert_eq!(upstream_error.status, StatusCode::IM_A_TEAPOT);
assert_eq!(upstream_error.json()?["error"]["code"], "upstream_error");
assert_eq!(anthropic_unknown.status, StatusCode::NOT_FOUND);
assert_eq!(
anthropic_unknown.json()?,
json!({
"type": "error",
"error": {
"type": "not_found_error",
"message": "No route registered for model other"
}
})
);
Ok(())
}
Loading