diff --git a/src/openhuman/embeddings/openai.rs b/src/openhuman/embeddings/openai.rs index 1d74e49a52..1b7eb99359 100644 --- a/src/openhuman/embeddings/openai.rs +++ b/src/openhuman/embeddings/openai.rs @@ -129,7 +129,11 @@ impl EmbeddingProvider for OpenAiEmbedding { "[openai] embed error: status={status}, body={text}" ); let message = format!("Embedding API error {status}: {text}"); - crate::core::observability::report_error( + // Route through the expected-error classifier so user-state + // conditions (budget exhausted / insufficient credits, missing + // API key, transient upstream HTTP) are demoted to info/warn + // breadcrumbs instead of spawning Sentry error events. + crate::core::observability::report_error_or_expected( message.as_str(), "embeddings", "openai_embed", diff --git a/src/openhuman/embeddings/openai_tests.rs b/src/openhuman/embeddings/openai_tests.rs index 527bdb9fc6..06c41a4e69 100644 --- a/src/openhuman/embeddings/openai_tests.rs +++ b/src/openhuman/embeddings/openai_tests.rs @@ -224,6 +224,32 @@ async fn embed_server_error() { assert!(msg.contains("rate limited"), "body: {msg}"); } +#[tokio::test] +async fn embed_budget_exhausted_400_still_errors() { + // OPENHUMAN-TAURI-JM: the backend returns HTTP 400 with a budget-exhausted + // body when the user is out of credits. The provider must still surface + // an `Err` to the caller (so the calling pipeline can short-circuit), but + // the diagnostic emit site must route through `report_error_or_expected` + // so the message is classified as `BudgetExhausted` and demoted rather + // than spawning a Sentry error event for every embed call. + let app = Router::new().route( + "/v1/embeddings", + post(|| async { + ( + StatusCode::BAD_REQUEST, + r#"{"success":false,"error":"Budget exceeded — add credits to continue"}"#, + ) + }), + ); + let url = start_mock(app).await; + let p = OpenAiEmbedding::new(&url, "k", "m", 1); + + let err = p.embed(&["hi"]).await.unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("400"), "status: {msg}"); + assert!(msg.contains("Budget exceeded"), "body: {msg}"); +} + #[tokio::test] async fn embed_missing_data_field() { let app = Router::new().route(