mirror of
https://github.com/Darkatse/TauriTavern.git
synced 2026-10-19 15:45:12 +00:00
feat(diagnostics): log upstream body preview when chat completion JSON parse fails
When an upstream chat-completion provider returns 2xx with a body the client
cannot decode as JSON (HTML challenge pages, plain-text proxy errors, empty
bodies, SSE on a non-stream endpoint, etc.), the failure used to silently bubble
up as `Failed to parse generation JSON` with no log of what was actually
received, leaving operators without any signal to diagnose CDN/proxy issues.
Introduce a shared body-preview helper in
`infrastructure/apis/http_chat_completion_repository/body_preview.rs`:
* `log_upstream_body_parse_failure(provider, operation, status, content_type,
body, error)` emits a structured `tracing::error!` event with a UTF-8 lossy
preview of the first 512 bytes plus the full body length, so logs stay bounded
even when upstream pours out megabytes of HTML.
* `read_upstream_json_body(provider, operation, response)` is the async wrapper
every HTTP provider call site uses: it reads the body once, attempts JSON
decode, calls the logger on failure, and surfaces a more diagnostic error
string (`Failed to parse {operation} JSON from {provider} (status {n})`).
All twelve `response.json::<Value>().await.map_err(...)` call sites across the
seven provider modules (`openai`, `claude`, `cohere`, `makersuite`,
`gemini_interactions`, `vertexai`, `openai_responses`) now route through the
helper. The OpenAI Responses WebSocket message parser
(`parse_ws_event`) also calls the logger before returning so non-JSON frames
emit the same diagnostic.
Error classification is intentionally unchanged in this commit (still
`DomainError::InternalError`); the follow-up will reclassify it as transient.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7acd1b7635
commit
fdc2cea60d
9 changed files with 141 additions and 33 deletions
|
|
@ -0,0 +1,114 @@
|
|||
use std::fmt::Display;
|
||||
|
||||
use reqwest::Response;
|
||||
use reqwest::StatusCode;
|
||||
use reqwest::header::CONTENT_TYPE;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::domain::errors::DomainError;
|
||||
|
||||
/// Maximum number of bytes from a non-JSON upstream body to capture in the
|
||||
/// diagnostic log. Anything larger is truncated; the full `body_len` is still
|
||||
/// emitted so log readers know how much was elided.
|
||||
pub(super) const BODY_PREVIEW_BYTES: usize = 512;
|
||||
|
||||
/// Emit a structured `error!` event describing why a chat-completion upstream
|
||||
/// body could not be parsed as JSON. Always truncates the body to
|
||||
/// [`BODY_PREVIEW_BYTES`] so we don't blow up the log pipeline on huge HTML
|
||||
/// challenges, plain-text errors, or runaway bodies.
|
||||
pub(super) fn log_upstream_body_parse_failure(
|
||||
provider_name: &str,
|
||||
operation: &str,
|
||||
status: StatusCode,
|
||||
content_type: &str,
|
||||
body: &[u8],
|
||||
error: &impl Display,
|
||||
) {
|
||||
let body_len = body.len();
|
||||
let body_preview = body_preview_string(body);
|
||||
|
||||
tracing::error!(
|
||||
provider = provider_name,
|
||||
operation = operation,
|
||||
status = status.as_u16(),
|
||||
content_type = content_type,
|
||||
body_len,
|
||||
body_preview = %body_preview,
|
||||
error = %error,
|
||||
"upstream returned non-JSON body for chat completion",
|
||||
);
|
||||
}
|
||||
|
||||
/// Lossy UTF-8 preview of the first [`BODY_PREVIEW_BYTES`] of a body.
|
||||
pub(super) fn body_preview_string(body: &[u8]) -> String {
|
||||
let preview_bytes = &body[..body.len().min(BODY_PREVIEW_BYTES)];
|
||||
String::from_utf8_lossy(preview_bytes).into_owned()
|
||||
}
|
||||
|
||||
/// Read an upstream HTTP response body and parse it as JSON, logging a
|
||||
/// detailed diagnostic event on failure. Caller is responsible for ensuring
|
||||
/// the response status was 2xx before invoking this helper.
|
||||
pub(super) async fn read_upstream_json_body(
|
||||
provider_name: &str,
|
||||
operation: &str,
|
||||
response: Response,
|
||||
) -> Result<Value, DomainError> {
|
||||
let status = response.status();
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let body = response.bytes().await.map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to read {operation} body from {provider_name} (status {}): {error}",
|
||||
status.as_u16()
|
||||
))
|
||||
})?;
|
||||
|
||||
match serde_json::from_slice::<Value>(&body) {
|
||||
Ok(value) => Ok(value),
|
||||
Err(error) => {
|
||||
log_upstream_body_parse_failure(
|
||||
provider_name,
|
||||
operation,
|
||||
status,
|
||||
&content_type,
|
||||
&body,
|
||||
&error,
|
||||
);
|
||||
Err(DomainError::InternalError(format!(
|
||||
"Failed to parse {operation} JSON from {provider_name} (status {}): {error}",
|
||||
status.as_u16()
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn body_preview_string_truncates_to_first_512_bytes() {
|
||||
let body = vec![b'a'; BODY_PREVIEW_BYTES + 64];
|
||||
let preview = body_preview_string(&body);
|
||||
assert_eq!(preview.len(), BODY_PREVIEW_BYTES);
|
||||
assert!(preview.chars().all(|character| character == 'a'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_preview_string_returns_short_body_verbatim() {
|
||||
let body = b"<html>error</html>";
|
||||
assert_eq!(body_preview_string(body), "<html>error</html>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_preview_string_replaces_invalid_utf8_lossily() {
|
||||
let body = vec![0xff, 0xfe, b'O', b'K'];
|
||||
let preview = body_preview_string(&body);
|
||||
assert!(preview.contains("OK"));
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ use crate::domain::repositories::chat_completion_repository::{
|
|||
};
|
||||
|
||||
use super::HttpChatCompletionRepository;
|
||||
use super::body_preview::read_upstream_json_body;
|
||||
use super::normalizers;
|
||||
|
||||
const ANTHROPIC_VERSION: &str = "2023-06-01";
|
||||
|
|
@ -47,9 +48,7 @@ pub(super) async fn list_models(
|
|||
.await);
|
||||
}
|
||||
|
||||
response.json::<Value>().await.map_err(|error| {
|
||||
DomainError::InternalError(format!("Failed to parse models JSON: {error}"))
|
||||
})
|
||||
read_upstream_json_body("Claude", "list_models", response).await
|
||||
}
|
||||
|
||||
pub(super) async fn generate(
|
||||
|
|
@ -91,9 +90,7 @@ pub(super) async fn generate(
|
|||
.await);
|
||||
}
|
||||
|
||||
let body = response.json::<Value>().await.map_err(|error| {
|
||||
DomainError::InternalError(format!("Failed to parse generation JSON: {error}"))
|
||||
})?;
|
||||
let body = read_upstream_json_body(provider_name, "generate", response).await?;
|
||||
|
||||
if super::payload_contains_cache_control(payload) {
|
||||
let model = payload.get("model").and_then(Value::as_str);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use crate::domain::repositories::chat_completion_repository::{
|
|||
};
|
||||
|
||||
use super::HttpChatCompletionRepository;
|
||||
use super::body_preview::read_upstream_json_body;
|
||||
|
||||
pub(super) async fn list_models(
|
||||
repository: &HttpChatCompletionRepository,
|
||||
|
|
@ -32,9 +33,7 @@ pub(super) async fn list_models(
|
|||
.await);
|
||||
}
|
||||
|
||||
let body = response.json::<Value>().await.map_err(|error| {
|
||||
DomainError::InternalError(format!("Failed to parse models JSON: {error}"))
|
||||
})?;
|
||||
let body = read_upstream_json_body("Cohere", "list_models", response).await?;
|
||||
|
||||
Ok(json!({ "data": normalize_models(&body) }))
|
||||
}
|
||||
|
|
@ -71,9 +70,7 @@ pub(super) async fn generate(
|
|||
.await);
|
||||
}
|
||||
|
||||
response.json::<Value>().await.map_err(|error| {
|
||||
DomainError::InternalError(format!("Failed to parse generation JSON: {error}"))
|
||||
})
|
||||
read_upstream_json_body("Cohere", "generate", response).await
|
||||
}
|
||||
|
||||
pub(super) async fn generate_stream(
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ use crate::domain::repositories::chat_completion_repository::{
|
|||
};
|
||||
|
||||
use super::HttpChatCompletionRepository;
|
||||
use super::body_preview::read_upstream_json_body;
|
||||
use super::normalizers;
|
||||
|
||||
const GEMINI_API_VERSION: &str = "v1beta";
|
||||
|
|
@ -387,9 +388,7 @@ pub(super) async fn generate(
|
|||
.await);
|
||||
}
|
||||
|
||||
let body = response.json::<Value>().await.map_err(|error| {
|
||||
DomainError::InternalError(format!("Failed to parse generation JSON: {error}"))
|
||||
})?;
|
||||
let body = read_upstream_json_body(provider_name, "generate", response).await?;
|
||||
|
||||
Ok(normalizers::normalize_gemini_interactions_response(body))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use crate::domain::repositories::chat_completion_repository::{
|
|||
};
|
||||
|
||||
use super::HttpChatCompletionRepository;
|
||||
use super::body_preview::read_upstream_json_body;
|
||||
use super::normalizers;
|
||||
|
||||
const GEMINI_API_VERSION: &str = "v1beta";
|
||||
|
|
@ -36,9 +37,7 @@ pub(super) async fn list_models(
|
|||
.await);
|
||||
}
|
||||
|
||||
let body = response.json::<Value>().await.map_err(|error| {
|
||||
DomainError::InternalError(format!("Failed to parse models JSON: {error}"))
|
||||
})?;
|
||||
let body = read_upstream_json_body("Google Gemini", "list_models", response).await?;
|
||||
|
||||
let models = body
|
||||
.get("models")
|
||||
|
|
@ -120,9 +119,7 @@ pub(super) async fn generate(
|
|||
.await);
|
||||
}
|
||||
|
||||
let body = response.json::<Value>().await.map_err(|error| {
|
||||
DomainError::InternalError(format!("Failed to parse generation JSON: {error}"))
|
||||
})?;
|
||||
let body = read_upstream_json_body("Google Gemini", "generate", response).await?;
|
||||
|
||||
Ok(normalizers::normalize_gemini_response(body))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ use crate::domain::repositories::chat_completion_repository::{
|
|||
};
|
||||
use crate::infrastructure::http_client_pool::{HttpClientPool, HttpClientProfile};
|
||||
|
||||
mod body_preview;
|
||||
mod claude;
|
||||
mod cohere;
|
||||
mod gemini_interactions;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use crate::domain::repositories::chat_completion_repository::{
|
|||
};
|
||||
|
||||
use super::HttpChatCompletionRepository;
|
||||
use super::body_preview::read_upstream_json_body;
|
||||
|
||||
pub(super) async fn list_models(
|
||||
repository: &HttpChatCompletionRepository,
|
||||
|
|
@ -42,9 +43,7 @@ pub(super) async fn list_models_with_path(
|
|||
.await);
|
||||
}
|
||||
|
||||
response.json::<Value>().await.map_err(|error| {
|
||||
DomainError::InternalError(format!("Failed to parse models JSON: {error}"))
|
||||
})
|
||||
read_upstream_json_body(provider_name, "list_models", response).await
|
||||
}
|
||||
|
||||
pub(super) async fn generate(
|
||||
|
|
@ -79,9 +78,7 @@ pub(super) async fn generate(
|
|||
.await);
|
||||
}
|
||||
|
||||
let body = response.json::<Value>().await.map_err(|error| {
|
||||
DomainError::InternalError(format!("Failed to parse generation JSON: {error}"))
|
||||
})?;
|
||||
let body = read_upstream_json_body(provider_name, "generate", response).await?;
|
||||
|
||||
if super::payload_contains_cache_control(payload) {
|
||||
let model = payload.get("model").and_then(Value::as_str);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ use crate::domain::repositories::chat_completion_repository::{
|
|||
};
|
||||
|
||||
use super::HttpChatCompletionRepository;
|
||||
use super::body_preview::{log_upstream_body_parse_failure, read_upstream_json_body};
|
||||
use super::normalizers;
|
||||
|
||||
type ResponsesWsStream = tokio_tungstenite::WebSocketStream<reqwest::Upgraded>;
|
||||
|
|
@ -463,9 +464,7 @@ async fn generate_http(
|
|||
.await);
|
||||
}
|
||||
|
||||
let body = response.json::<Value>().await.map_err(|error| {
|
||||
DomainError::InternalError(format!("Failed to parse generation JSON: {error}"))
|
||||
})?;
|
||||
let body = read_upstream_json_body(provider_name, "generate", response).await?;
|
||||
|
||||
Ok(normalizers::normalize_openai_responses_response(body))
|
||||
}
|
||||
|
|
@ -1090,6 +1089,14 @@ fn response_from_ws_payload(payload: &[u8]) -> Result<Option<Value>, DomainError
|
|||
|
||||
fn parse_ws_event(payload: &[u8]) -> Result<Value, DomainError> {
|
||||
serde_json::from_slice(payload).map_err(|error| {
|
||||
log_upstream_body_parse_failure(
|
||||
"OpenAI Responses",
|
||||
"generate_stream_ws",
|
||||
StatusCode::SWITCHING_PROTOCOLS,
|
||||
"application/json",
|
||||
payload,
|
||||
&error,
|
||||
);
|
||||
DomainError::InternalError(format!(
|
||||
"OpenAI Responses WebSocket event is not valid JSON: {error}"
|
||||
))
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use crate::domain::repositories::chat_completion_repository::{
|
|||
};
|
||||
|
||||
use super::HttpChatCompletionRepository;
|
||||
use super::body_preview::read_upstream_json_body;
|
||||
use super::normalizers;
|
||||
|
||||
const PROVIDER_NAME: &str = "Google Vertex AI";
|
||||
|
|
@ -73,9 +74,7 @@ pub(super) async fn generate(
|
|||
.await);
|
||||
}
|
||||
|
||||
let body = response.json::<Value>().await.map_err(|error| {
|
||||
DomainError::InternalError(format!("Failed to parse generation JSON: {error}"))
|
||||
})?;
|
||||
let body = read_upstream_json_body(PROVIDER_NAME, "generate", response).await?;
|
||||
|
||||
Ok(normalizers::normalize_gemini_response(body))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue