diff --git a/embeddings/src/error.rs b/embeddings/src/error.rs index 6a74939f..459f0079 100644 --- a/embeddings/src/error.rs +++ b/embeddings/src/error.rs @@ -28,6 +28,7 @@ pub enum LibError { HuggingFaceTokenInvalid, RemoteHttpError { status: u16, + message: Option, }, OnnxModelEvalFailed, } @@ -91,8 +92,12 @@ impl std::fmt::Display for LibError { LibError::HuggingFaceTokenInvalid => { write!(f, "Invalid or expired HuggingFace token") } - LibError::RemoteHttpError { status } => { - write!(f, "HTTP error from remote model: status code {}", status) + LibError::RemoteHttpError { status, message } => { + if let Some(msg) = message { + write!(f, "HTTP error from remote model: status code {}: {}", status, msg) + } else { + write!(f, "HTTP error from remote model: status code {}", status) + } } LibError::OnnxModelEvalFailed => write!(f, "Failed to evaluate ONNX model"), } diff --git a/embeddings/src/model/jina.rs b/embeddings/src/model/jina.rs index d09067d5..de503ff5 100644 --- a/embeddings/src/model/jina.rs +++ b/embeddings/src/model/jina.rs @@ -112,6 +112,15 @@ impl JinaModel { impl TextModel for JinaModel { fn predict(&self, texts: &[&str]) -> Result>, Box> { + // Pre-validate: filter out empty texts and check we have at least one non-empty input + let non_empty_count = texts.iter().filter(|t| !t.trim().is_empty()).count(); + if non_empty_count == 0 { + return Err(Box::new(LibError::RemoteHttpError { + status: 400, + message: Some("all input texts are empty - at least one non-empty text is required for embeddings".to_string()), + })); + } + let url = self .api_url .as_deref() @@ -136,10 +145,30 @@ impl TextModel for JinaModel { let response_text = response.text().map_err(|_| LibError::RemoteHttpError { status: status_code, + message: None, })?; + // Helper to extract error message from JSON response + let extract_error_message = |text: &str| -> Option { + serde_json::from_str::(text) + .ok() + .and_then(|body| { + // Try standard error.message format first + if let Some(msg) = body.get("error")?.get("message")?.as_str() { + return Some(msg.to_string()); + } + // Try Jina's detail format + if let Some(detail) = body.get("detail")?.as_str() { + return Some(detail.to_string()); + } + None + }) + }; + // Check HTTP status code first if !status.is_success() { + let error_message = extract_error_message(&response_text); + // Try to parse JSON error response for more details if let Ok(response_body) = serde_json::from_str::(&response_text) { if let Some(error) = response_body.get("error") { @@ -166,6 +195,7 @@ impl TextModel for JinaModel { } _ => LibError::RemoteHttpError { status: status_code, + message: error_message, }, }; @@ -199,6 +229,7 @@ impl TextModel for JinaModel { 429 => LibError::RemoteRequestSendFailed, _ => LibError::RemoteHttpError { status: status_code, + message: error_message, }, } }; @@ -217,6 +248,7 @@ impl TextModel for JinaModel { 429 => LibError::RemoteRequestSendFailed, _ => LibError::RemoteHttpError { status: status_code, + message: error_message, }, }; return Err(Box::new(lib_error)); @@ -225,6 +257,7 @@ impl TextModel for JinaModel { let response_body: serde_json::Value = serde_json::from_str(&response_text).map_err(|_| LibError::RemoteHttpError { status: status_code, + message: Some("failed to parse successful response as JSON".to_string()), })?; // Check if there's an error in the response (shouldn't happen if status was success, but check anyway) @@ -247,6 +280,7 @@ impl TextModel for JinaModel { "rate_limit_exceeded" | "quota_exceeded" => LibError::RemoteRequestSendFailed, _ => LibError::RemoteHttpError { status: status_code, + message: error.get("message").and_then(|m| m.as_str()).map(|s| s.to_string()), }, }; @@ -271,6 +305,7 @@ impl TextModel for JinaModel { } else { LibError::RemoteHttpError { status: status_code, + message: Some(detail_str.to_string()), } }; return Err(Box::new(lib_error)); @@ -295,6 +330,7 @@ impl TextModel for JinaModel { if embeddings.is_empty() { return Err(Box::new(LibError::RemoteHttpError { status: status_code, + message: Some("API returned no embeddings in response".to_string()), })); } @@ -306,6 +342,7 @@ impl TextModel for JinaModel { if embedding.is_empty() { return Err(Box::new(LibError::RemoteHttpError { status: status_code, + message: Some("API returned an empty embedding vector".to_string()), })); } if embedding.len() != inferred_dim { diff --git a/embeddings/src/model/openai.rs b/embeddings/src/model/openai.rs index 3a7922f3..22722114 100644 --- a/embeddings/src/model/openai.rs +++ b/embeddings/src/model/openai.rs @@ -94,6 +94,15 @@ impl OpenAIModel { impl TextModel for OpenAIModel { fn predict(&self, texts: &[&str]) -> Result>, Box> { + // Pre-validate: filter out empty texts and check we have at least one non-empty input + let non_empty_count = texts.iter().filter(|t| !t.trim().is_empty()).count(); + if non_empty_count == 0 { + return Err(Box::new(LibError::RemoteHttpError { + status: 400, + message: Some("all input texts are empty - at least one non-empty text is required for embeddings".to_string()), + })); + } + let url = self .api_url .as_deref() @@ -118,10 +127,20 @@ impl TextModel for OpenAIModel { let response_text = response.text().map_err(|_| LibError::RemoteHttpError { status: status_code, + message: None, })?; + // Helper to extract error message from JSON response + let extract_error_message = |text: &str| -> Option { + serde_json::from_str::(text) + .ok() + .and_then(|body| body.get("error")?.get("message")?.as_str().map(|s| s.to_string())) + }; + // Check HTTP status code first if !status.is_success() { + let error_message = extract_error_message(&response_text); + // Try to parse JSON error response for more details if let Ok(response_body) = serde_json::from_str::(&response_text) { if let Some(error) = response_body.get("error") { @@ -143,6 +162,7 @@ impl TextModel for OpenAIModel { } _ => LibError::RemoteHttpError { status: status_code, + message: error_message, }, }; @@ -160,6 +180,7 @@ impl TextModel for OpenAIModel { 429 => LibError::RemoteRequestSendFailed, _ => LibError::RemoteHttpError { status: status_code, + message: error_message, }, }; return Err(Box::new(lib_error)); @@ -169,12 +190,14 @@ impl TextModel for OpenAIModel { let response_body: serde_json::Value = serde_json::from_str(&response_text).map_err(|_| LibError::RemoteHttpError { status: status_code, + message: Some("failed to parse successful response as JSON".to_string()), })?; let data_array = response_body["data"].as_array(); if data_array.is_none() { return Err(Box::new(LibError::RemoteHttpError { status: status_code, + message: Some("response missing 'data' array".to_string()), })); } @@ -196,6 +219,7 @@ impl TextModel for OpenAIModel { if embeddings.is_empty() { return Err(Box::new(LibError::RemoteHttpError { status: status_code, + message: Some("API returned no embeddings in response".to_string()), })); } diff --git a/embeddings/src/model/voyage.rs b/embeddings/src/model/voyage.rs index b56fd57e..cbf69462 100644 --- a/embeddings/src/model/voyage.rs +++ b/embeddings/src/model/voyage.rs @@ -99,6 +99,15 @@ impl VoyageModel { impl TextModel for VoyageModel { fn predict(&self, texts: &[&str]) -> Result>, Box> { + // Pre-validate: filter out empty texts and check we have at least one non-empty input + let non_empty_count = texts.iter().filter(|t| !t.trim().is_empty()).count(); + if non_empty_count == 0 { + return Err(Box::new(LibError::RemoteHttpError { + status: 400, + message: Some("all input texts are empty - at least one non-empty text is required for embeddings".to_string()), + })); + } + let url = self .api_url .as_deref() @@ -123,10 +132,20 @@ impl TextModel for VoyageModel { let response_text = response.text().map_err(|_| LibError::RemoteHttpError { status: status_code, + message: None, })?; + // Helper to extract error message from JSON response + let extract_error_message = |text: &str| -> Option { + serde_json::from_str::(text) + .ok() + .and_then(|body| body.get("error")?.get("message")?.as_str().map(|s| s.to_string())) + }; + // Check HTTP status code first if !status.is_success() { + let error_message = extract_error_message(&response_text); + // Try to parse JSON error response for more details if let Ok(response_body) = serde_json::from_str::(&response_text) { if let Some(error) = response_body.get("error") { @@ -152,6 +171,7 @@ impl TextModel for VoyageModel { } _ => LibError::RemoteHttpError { status: status_code, + message: error_message, }, }; @@ -169,6 +189,7 @@ impl TextModel for VoyageModel { 429 => LibError::RemoteRequestSendFailed, _ => LibError::RemoteHttpError { status: status_code, + message: error_message, }, }; return Err(Box::new(lib_error)); @@ -178,11 +199,13 @@ impl TextModel for VoyageModel { let response_body: serde_json::Value = serde_json::from_str(&response_text).map_err(|_| LibError::RemoteHttpError { status: status_code, + message: Some("failed to parse successful response as JSON".to_string()), })?; if response_body.get("data").is_none() { return Err(Box::new(LibError::RemoteHttpError { status: status_code, + message: Some("response missing 'data' field".to_string()), })); } @@ -191,6 +214,7 @@ impl TextModel for VoyageModel { if data_array.is_none() { return Err(Box::new(LibError::RemoteHttpError { status: status_code, + message: Some("response 'data' is not an array".to_string()), })); } @@ -212,6 +236,7 @@ impl TextModel for VoyageModel { if embeddings.is_empty() { return Err(Box::new(LibError::RemoteHttpError { status: status_code, + message: Some("API returned no embeddings in response".to_string()), })); } @@ -223,6 +248,7 @@ impl TextModel for VoyageModel { if embedding.is_empty() { return Err(Box::new(LibError::RemoteHttpError { status: status_code, + message: Some("API returned an empty embedding vector".to_string()), })); } if embedding.len() != inferred_dim {