| @@ -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 { | Ok(ModelResponse { | ||||
| text, | text, | ||||
| @@ -224,6 +246,16 @@ pub async fn send_stream(client: &Client, request: &ModelRequest) -> Result<Mode | |||||
| } | } | ||||
| if text.is_empty() { | 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"); | bail!("Anthropic stream completed without producing any content"); | ||||
| } | } | ||||
| @@ -322,6 +354,34 @@ async fn write_error_debug( | |||||
| Ok(()) | 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 { | fn debug_request(request: &ModelRequest, url: &str, body: Value) -> HttpDebugRequest { | ||||
| HttpDebugRequest { | HttpDebugRequest { | ||||
| method: "POST".to_string(), | method: "POST".to_string(), | ||||
| @@ -762,6 +822,75 @@ mod tests { | |||||
| assert!(raw.contains("\"text\":\"hi \"")); | 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 { | fn anthropic_request(base_url: String) -> ModelRequest { | ||||
| ModelRequest { | ModelRequest { | ||||
| base_url, | 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 { | Ok(ModelResponse { | ||||
| text, | text, | ||||
| @@ -200,6 +226,16 @@ pub async fn send_stream(client: &Client, request: &ModelRequest) -> Result<Mode | |||||
| } | } | ||||
| if text.is_empty() { | 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"); | bail!("Google stream completed without producing any content"); | ||||
| } | } | ||||
| @@ -296,6 +332,34 @@ async fn write_error_debug( | |||||
| Ok(()) | 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 { | fn debug_request(request: &ModelRequest, url: &str, body: Value) -> HttpDebugRequest { | ||||
| HttpDebugRequest { | HttpDebugRequest { | ||||
| method: "POST".to_string(), | 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 | .choices | ||||
| .into_iter() | .into_iter() | ||||
| .next() | .next() | ||||
| .and_then(|choice| choice.message.content) | .and_then(|choice| choice.message.content) | ||||
| .filter(|content| !content.is_empty()) | .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 { | Ok(ModelResponse { | ||||
| text, | text, | ||||
| @@ -204,6 +230,16 @@ pub async fn send_stream(client: &Client, request: &ModelRequest) -> Result<Mode | |||||
| } | } | ||||
| if text.is_empty() { | 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"); | bail!("OpenAI stream completed without producing any content"); | ||||
| } | } | ||||
| @@ -303,6 +339,34 @@ async fn write_error_debug( | |||||
| Ok(()) | 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 { | fn debug_request(request: &ModelRequest, url: &str, body: Value) -> HttpDebugRequest { | ||||
| HttpDebugRequest { | HttpDebugRequest { | ||||
| method: "POST".to_string(), | method: "POST".to_string(), | ||||