From b5d83f948becef76bb5900ed67cd8f423172527c Mon Sep 17 00:00:00 2001 From: orangels Date: Sat, 9 May 2026 18:42:37 +0800 Subject: [PATCH] fix: log empty successful responses as request errors --- src/protocols/anthropic.rs | 149 ++++++++++++++++++++++++++++++++++--- src/protocols/google.rs | 70 ++++++++++++++++- src/protocols/openai.rs | 72 +++++++++++++++++- 3 files changed, 274 insertions(+), 17 deletions(-) diff --git a/src/protocols/anthropic.rs b/src/protocols/anthropic.rs index 3dc1ae2..3175a35 100644 --- a/src/protocols/anthropic.rs +++ b/src/protocols/anthropic.rs @@ -76,16 +76,38 @@ pub async fn send(client: &Client, request: &ModelRequest) -> Result Some(text), - _ => None, - }) - .context("Anthropic response missing first text content block")?; + let parsed: AnthropicResponse = match serde_json::from_str(&body) { + Ok(parsed) => parsed, + Err(error) => { + write_response_request_error_debug( + request, + &debug_url, + request_body, + status_code, + &response_headers, + &body, + format!("failed to parse Anthropic response JSON: {error}"), + ) + .await?; + return Err(error).context("failed to parse Anthropic response JSON"); + } + }; + let Some(text) = parsed.content.into_iter().find_map(|block| match block { + AnthropicContentBlock::Text { text } if !text.is_empty() => Some(text), + _ => None, + }) else { + write_response_request_error_debug( + request, + &debug_url, + request_body, + status_code, + &response_headers, + &body, + "Anthropic response missing first text content block".to_string(), + ) + .await?; + bail!("Anthropic response missing first text content block"); + }; Ok(ModelResponse { text, @@ -224,6 +246,16 @@ pub async fn send_stream(client: &Client, request: &ModelRequest) -> Result Result<()> { + if let Some(raw_debug) = &request.raw_debug { + raw_debug + .write_http_error( + "anthropic-request-error", + debug_request(request, url, request_body), + HttpDebugResponse { + status: Some(status), + headers: response_headers_for_debug(response_headers), + body: response_body.to_string(), + error_kind: Some("request_error".to_string()), + error: Some(error), + }, + ) + .await + .context("failed to write Anthropic raw debug request error response")?; + } + Ok(()) +} + fn debug_request(request: &ModelRequest, url: &str, body: Value) -> HttpDebugRequest { HttpDebugRequest { method: "POST".to_string(), @@ -762,6 +822,75 @@ mod tests { assert!(raw.contains("\"text\":\"hi \"")); } + #[tokio::test] + async fn empty_stream_request_error_debug_records_request_and_raw_stream() { + let server = MockServer::start().await; + let temp_dir = tempfile::tempdir().expect("create temp dir"); + Mock::given(method("POST")) + .and(path("/v1/messages")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string( + "event: message_start\n\ + data: {\"type\":\"message_start\"}\n\n\ + event: message_stop\n\ + data: {\"type\":\"message_stop\"}\n\n", + ), + ) + .mount(&server) + .await; + + let request = ModelRequest { + base_url: server.uri(), + api_token: "anthropic-secret-token".to_string(), + model: "claude-test".to_string(), + prompt: "prompt before empty stream".to_string(), + temperature: 0.0, + max_tokens: 1024, + stream: true, + raw_debug: Some( + RawDebugConfig::new( + temp_dir.path().to_path_buf(), + "anthropic-claude-test".to_string(), + ) + .with_success_raw(false), + ), + thinking: None, + }; + + let error = super::send_stream(&Client::new(), &request) + .await + .expect_err("empty stream should fail"); + assert!( + error + .to_string() + .contains("completed without producing any content") + ); + + let debug_files = std::fs::read_dir(temp_dir.path()) + .expect("read debug dir") + .collect::, _>>() + .expect("debug entries"); + assert_eq!(debug_files.len(), 1); + let raw = std::fs::read_to_string(debug_files[0].path()).expect("read raw debug file"); + let debug: serde_json::Value = serde_json::from_str(&raw).expect("debug json"); + + assert_eq!( + debug["request"]["body"]["messages"][0]["content"], + "prompt before empty stream" + ); + assert_eq!(debug["response"]["status"], 200); + assert_eq!(debug["response"]["error_kind"], "request_error"); + assert!( + debug["response"]["body"] + .as_str() + .expect("response body") + .contains("event: message_stop") + ); + assert!(!raw.contains("anthropic-secret-token")); + } + fn anthropic_request(base_url: String) -> ModelRequest { ModelRequest { base_url, diff --git a/src/protocols/google.rs b/src/protocols/google.rs index cc90f00..0b892cc 100644 --- a/src/protocols/google.rs +++ b/src/protocols/google.rs @@ -75,9 +75,35 @@ pub async fn send(client: &Client, request: &ModelRequest) -> Result parsed, + Err(error) => { + write_response_request_error_debug( + request, + &debug_url, + request_body, + status_code, + &response_headers, + &body, + format!("failed to parse Google response JSON: {error}"), + ) + .await?; + return Err(error).context("failed to parse Google response JSON"); + } + }; + let Some(text) = response_text(parsed) else { + write_response_request_error_debug( + request, + &debug_url, + request_body, + status_code, + &response_headers, + &body, + "Google response missing non-thought text part".to_string(), + ) + .await?; + bail!("Google response missing non-thought text part"); + }; Ok(ModelResponse { text, @@ -200,6 +226,16 @@ pub async fn send_stream(client: &Client, request: &ModelRequest) -> Result Result<()> { + if let Some(raw_debug) = &request.raw_debug { + raw_debug + .write_http_error( + "google-request-error", + debug_request(request, url, request_body), + HttpDebugResponse { + status: Some(status), + headers: response_headers_for_debug(response_headers), + body: response_body.to_string(), + error_kind: Some("request_error".to_string()), + error: Some(error), + }, + ) + .await + .context("failed to write Google raw debug request error response")?; + } + Ok(()) +} + fn debug_request(request: &ModelRequest, url: &str, body: Value) -> HttpDebugRequest { HttpDebugRequest { method: "POST".to_string(), diff --git a/src/protocols/openai.rs b/src/protocols/openai.rs index 6e7f77f..850b6e4 100644 --- a/src/protocols/openai.rs +++ b/src/protocols/openai.rs @@ -75,15 +75,41 @@ pub async fn send(client: &Client, request: &ModelRequest) -> Result parsed, + Err(error) => { + write_response_request_error_debug( + request, + &debug_url, + request_body, + status_code, + &response_headers, + &body, + format!("failed to parse OpenAI response JSON: {error}"), + ) + .await?; + return Err(error).context("failed to parse OpenAI response JSON"); + } + }; + let Some(text) = parsed .choices .into_iter() .next() .and_then(|choice| choice.message.content) .filter(|content| !content.is_empty()) - .context("OpenAI response missing choices[0].message.content")?; + else { + write_response_request_error_debug( + request, + &debug_url, + request_body, + status_code, + &response_headers, + &body, + "OpenAI response missing choices[0].message.content".to_string(), + ) + .await?; + bail!("OpenAI response missing choices[0].message.content"); + }; Ok(ModelResponse { text, @@ -204,6 +230,16 @@ pub async fn send_stream(client: &Client, request: &ModelRequest) -> Result Result<()> { + if let Some(raw_debug) = &request.raw_debug { + raw_debug + .write_http_error( + "openai-request-error", + debug_request(request, url, request_body), + HttpDebugResponse { + status: Some(status), + headers: response_headers_for_debug(response_headers), + body: response_body.to_string(), + error_kind: Some("request_error".to_string()), + error: Some(error), + }, + ) + .await + .context("failed to write OpenAI raw debug request error response")?; + } + Ok(()) +} + fn debug_request(request: &ModelRequest, url: &str, body: Value) -> HttpDebugRequest { HttpDebugRequest { method: "POST".to_string(),