| @@ -76,16 +76,38 @@ pub async fn send(client: &Client, request: &ModelRequest) -> Result<ModelRespon | |||
| ); | |||
| } | |||
| let parsed: AnthropicResponse = | |||
| serde_json::from_str(&body).context("failed to parse Anthropic response JSON")?; | |||
| let text = parsed | |||
| .content | |||
| .into_iter() | |||
| .find_map(|block| match block { | |||
| AnthropicContentBlock::Text { text } if !text.is_empty() => 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<Mode | |||
| } | |||
| if text.is_empty() { | |||
| write_response_request_error_debug( | |||
| request, | |||
| &debug_url, | |||
| request_body, | |||
| status_code, | |||
| &response_headers, | |||
| &raw_stream, | |||
| "Anthropic stream completed without producing any content".to_string(), | |||
| ) | |||
| .await?; | |||
| bail!("Anthropic stream completed without producing any content"); | |||
| } | |||
| @@ -322,6 +354,34 @@ async fn write_error_debug( | |||
| Ok(()) | |||
| } | |||
| async fn write_response_request_error_debug( | |||
| request: &ModelRequest, | |||
| url: &str, | |||
| request_body: Value, | |||
| status: u16, | |||
| response_headers: &reqwest::header::HeaderMap, | |||
| response_body: &str, | |||
| error: String, | |||
| ) -> 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::<Result<Vec<_>, _>>() | |||
| .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, | |||
| @@ -75,9 +75,35 @@ pub async fn send(client: &Client, request: &ModelRequest) -> Result<ModelRespon | |||
| ); | |||
| } | |||
| let parsed: GoogleResponse = | |||
| serde_json::from_str(&body).context("failed to parse Google response JSON")?; | |||
| let text = response_text(parsed).context("Google response missing non-thought text part")?; | |||
| let parsed: GoogleResponse = 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 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<Mode | |||
| } | |||
| if text.is_empty() { | |||
| write_response_request_error_debug( | |||
| request, | |||
| &debug_url, | |||
| request_body, | |||
| status_code, | |||
| &response_headers, | |||
| &raw_stream, | |||
| "Google stream completed without producing any content".to_string(), | |||
| ) | |||
| .await?; | |||
| bail!("Google stream completed without producing any content"); | |||
| } | |||
| @@ -296,6 +332,34 @@ async fn write_error_debug( | |||
| Ok(()) | |||
| } | |||
| async fn write_response_request_error_debug( | |||
| request: &ModelRequest, | |||
| url: &str, | |||
| request_body: Value, | |||
| status: u16, | |||
| response_headers: &reqwest::header::HeaderMap, | |||
| response_body: &str, | |||
| error: String, | |||
| ) -> 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(), | |||
| @@ -75,15 +75,41 @@ pub async fn send(client: &Client, request: &ModelRequest) -> Result<ModelRespon | |||
| ); | |||
| } | |||
| let parsed: OpenAiResponse = | |||
| serde_json::from_str(&body).context("failed to parse OpenAI response JSON")?; | |||
| let text = parsed | |||
| let parsed: OpenAiResponse = 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 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<Mode | |||
| } | |||
| if text.is_empty() { | |||
| write_response_request_error_debug( | |||
| request, | |||
| &debug_url, | |||
| request_body, | |||
| status_code, | |||
| &response_headers, | |||
| &raw_stream, | |||
| "OpenAI stream completed without producing any content".to_string(), | |||
| ) | |||
| .await?; | |||
| bail!("OpenAI stream completed without producing any content"); | |||
| } | |||
| @@ -303,6 +339,34 @@ async fn write_error_debug( | |||
| Ok(()) | |||
| } | |||
| async fn write_response_request_error_debug( | |||
| request: &ModelRequest, | |||
| url: &str, | |||
| request_body: Value, | |||
| status: u16, | |||
| response_headers: &reqwest::header::HeaderMap, | |||
| response_body: &str, | |||
| error: String, | |||
| ) -> 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(), | |||