feat(analyze): structured LLM failure diagnostics + SSE reload on failure

Failure markers now carry finish_reason and usage tokens (prompt /
completion / reasoning) pulled from the chat-completions response, so
the case-page "Technische Details" block can show why an analysis
truncated — e.g. gpt-oss-120b hitting max_completion_tokens with the
chain-of-thought before producing any visible answer.

The worker also emits a new CaseEventKind::AnalysisFailed after writing
the marker, so subscribed browsers reload immediately instead of leaving
the page on the "Wird analysiert …" placeholder.
This commit is contained in:
2026-05-05 11:01:29 +02:00
parent a142725a8a
commit 44e93f08ee
8 changed files with 574 additions and 59 deletions
+128 -14
View File
@@ -39,6 +39,75 @@ pub struct FailureMarker {
/// the field deserialize to `None`.
#[serde(default)]
pub backend_id: Option<String>,
/// Structured diagnostics — populated for LLM failures where the
/// provider response carries `finish_reason`/`usage`. Old markers
/// without the field deserialize to `None` (no migration needed).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub details: Option<FailureDetails>,
}
/// Structured failure diagnostics. All fields optional so the same struct
/// works for empty-content failures (everything populated) and for HTTP /
/// transport errors (only `model_id` known).
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct FailureDetails {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub finish_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub completion_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_completion_tokens: Option<u32>,
}
impl FailureDetails {
pub fn new() -> Self {
Self::default()
}
pub fn with_model_id(mut self, v: impl Into<String>) -> Self {
self.model_id = Some(v.into());
self
}
pub fn with_finish_reason(mut self, v: impl Into<String>) -> Self {
self.finish_reason = Some(v.into());
self
}
pub fn with_prompt_tokens(mut self, v: u32) -> Self {
self.prompt_tokens = Some(v);
self
}
pub fn with_completion_tokens(mut self, v: u32) -> Self {
self.completion_tokens = Some(v);
self
}
pub fn with_reasoning_tokens(mut self, v: u32) -> Self {
self.reasoning_tokens = Some(v);
self
}
pub fn with_max_completion_tokens(mut self, v: u32) -> Self {
self.max_completion_tokens = Some(v);
self
}
fn is_empty(&self) -> bool {
self.model_id.is_none()
&& self.finish_reason.is_none()
&& self.prompt_tokens.is_none()
&& self.completion_tokens.is_none()
&& self.reasoning_tokens.is_none()
&& self.max_completion_tokens.is_none()
}
}
/// Outcome of the pre-condition check. `Skip` carries a static string so
@@ -181,31 +250,74 @@ pub async fn try_enqueue_all_for_user(
}
}
/// Persist a failure marker atomically. Called by the worker after a
/// terminal failure. `last_recording_mtime` must equal the value read
/// from the `AnalysisInput` so `evaluate_case` can later detect an
/// unchanged input.
pub async fn write_failure_marker(
case_dir: &Path,
last_recording_mtime: &str,
reason: &str,
backend_id: Option<&str>,
) -> std::io::Result<()> {
/// Builder for a failure marker write. Use `failure_marker(...)` to start.
///
/// Fluent so the worker's call sites stay readable when carrying many
/// optional diagnostic fields (`finish_reason`, `usage`, `model_id`).
/// Consume the builder with `.write().await`.
pub struct FailureMarkerWriter<'a> {
case_dir: &'a Path,
last_recording_mtime: &'a str,
reason: String,
backend_id: Option<String>,
details: FailureDetails,
}
impl<'a> FailureMarkerWriter<'a> {
pub fn with_backend_id(mut self, id: impl Into<String>) -> Self {
self.backend_id = Some(id.into());
self
}
pub fn with_details(mut self, details: FailureDetails) -> Self {
self.details = details;
self
}
/// Persist atomically: write to `<path>.tmp`, fsync, rename.
/// `last_recording_mtime` must equal the value read from the
/// `AnalysisInput` so `evaluate_case` can later detect an unchanged
/// input.
pub async fn write(self) -> std::io::Result<()> {
let marker = FailureMarker {
last_recording_mtime: last_recording_mtime.to_owned(),
reason: reason.to_owned(),
last_recording_mtime: self.last_recording_mtime.to_owned(),
reason: self.reason,
failed_at: doctate_common::now_rfc3339(),
backend_id: backend_id.map(|s| s.to_owned()),
backend_id: self.backend_id,
details: if self.details.is_empty() {
None
} else {
Some(self.details)
},
};
let bytes = serde_json::to_vec_pretty(&marker)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let path = case_dir.join(FAILURE_MARKER_FILE);
let path = self.case_dir.join(FAILURE_MARKER_FILE);
let tmp = path.with_extension("json.tmp");
let mut f = tokio::fs::File::create(&tmp).await?;
f.write_all(&bytes).await?;
f.sync_all().await?;
drop(f);
tokio::fs::rename(&tmp, &path).await
}
}
/// Start a failure-marker write. The minimum a caller must supply is the
/// case directory, the input mtime (so `evaluate_case` can later detect
/// "input changed since failure"), and a human-readable reason. Backend
/// id and structured details are added fluently.
pub fn failure_marker<'a>(
case_dir: &'a Path,
last_recording_mtime: &'a str,
reason: impl Into<String>,
) -> FailureMarkerWriter<'a> {
FailureMarkerWriter {
case_dir,
last_recording_mtime,
reason: reason.into(),
backend_id: None,
details: FailureDetails::default(),
}
}
/// Best-effort delete. Called by the worker after a successful run so
@@ -444,6 +556,7 @@ mod tests {
reason: "test".into(),
failed_at: "2026-01-01T10-05-00Z".into(),
backend_id: None,
details: None,
};
fs::write(
dir.path().join(FAILURE_MARKER_FILE),
@@ -474,6 +587,7 @@ mod tests {
reason: "test".into(),
failed_at: "2025-12-31T23-00-00Z".into(),
backend_id: None,
details: None,
};
fs::write(
dir.path().join(FAILURE_MARKER_FILE),
+138 -9
View File
@@ -3,7 +3,7 @@ use std::time::Duration;
use doctate_common::join_url;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::debug;
use tracing::{debug, warn};
use super::backend::LlmBackend;
@@ -13,12 +13,61 @@ use super::backend::LlmBackend;
/// **Security:** `Display` and `Debug` MUST NOT contain the HTTP response body.
/// Provider error bodies can echo back details that include the API key or
/// other sensitive context (e.g. `{"error": "invalid api_key sk-…"}`). Only
/// the status and a short category string are safe to log.
/// the status and structured numeric/enum fields (token counts, finish_reason)
/// are safe to log.
#[derive(Debug)]
pub enum LlmError {
Http(reqwest::Error),
Status { status: u16 },
Status {
status: u16,
},
Parse(&'static str),
/// Response was 200 OK but `choices[0].message.content` was null/missing.
/// The structured fields below come from the same response and let the
/// caller distinguish "model truncated at token cap" (`finish_reason=length`)
/// from "content filter rejected" or other null-content modes.
EmptyContent {
finish_reason: Option<String>,
prompt_tokens: Option<u32>,
completion_tokens: Option<u32>,
reasoning_tokens: Option<u32>,
max_completion_tokens: u32,
},
}
/// Diagnostic detail extracted from an `EmptyContent` error. The worker
/// copies these fields into the persisted `FailureDetails` so the UI's
/// "Technische Details" block can show finish_reason and token counts.
#[derive(Debug, Clone)]
pub struct EmptyContentDetails<'a> {
pub finish_reason: Option<&'a str>,
pub prompt_tokens: Option<u32>,
pub completion_tokens: Option<u32>,
pub reasoning_tokens: Option<u32>,
pub max_completion_tokens: u32,
}
impl LlmError {
/// `Some` iff this error is `EmptyContent`, exposing the diagnostic
/// fields the worker needs to populate the failure-marker UI.
pub fn empty_content_details(&self) -> Option<EmptyContentDetails<'_>> {
match self {
Self::EmptyContent {
finish_reason,
prompt_tokens,
completion_tokens,
reasoning_tokens,
max_completion_tokens,
} => Some(EmptyContentDetails {
finish_reason: finish_reason.as_deref(),
prompt_tokens: *prompt_tokens,
completion_tokens: *completion_tokens,
reasoning_tokens: *reasoning_tokens,
max_completion_tokens: *max_completion_tokens,
}),
_ => None,
}
}
}
impl std::fmt::Display for LlmError {
@@ -27,6 +76,30 @@ impl std::fmt::Display for LlmError {
Self::Http(e) => write!(f, "llm http error: {e}"),
Self::Status { status } => write!(f, "llm returned status {status}"),
Self::Parse(msg) => write!(f, "llm response parse error: {msg}"),
Self::EmptyContent {
finish_reason,
prompt_tokens,
completion_tokens,
reasoning_tokens,
max_completion_tokens,
} => {
let fr = finish_reason.as_deref().unwrap_or("unknown");
write!(
f,
"llm returned no content (finish_reason={fr}, completion_tokens={ct}/{cap}",
ct = completion_tokens
.map(|n| n.to_string())
.unwrap_or_else(|| "?".into()),
cap = max_completion_tokens,
)?;
if let Some(rt) = reasoning_tokens {
write!(f, ", reasoning_tokens={rt}")?;
}
if let Some(pt) = prompt_tokens {
write!(f, ", prompt_tokens={pt}")?;
}
write!(f, ")")
}
}
}
}
@@ -57,11 +130,15 @@ struct Message<'a> {
#[derive(Deserialize)]
struct ChatResponse {
choices: Vec<Choice>,
#[serde(default)]
usage: Option<Usage>,
}
#[derive(Deserialize)]
struct Choice {
message: ResponseMessage,
#[serde(default)]
finish_reason: Option<String>,
}
#[derive(Deserialize)]
@@ -69,6 +146,22 @@ struct ResponseMessage {
content: Option<String>,
}
#[derive(Deserialize, Default)]
struct Usage {
#[serde(default)]
prompt_tokens: Option<u32>,
#[serde(default)]
completion_tokens: Option<u32>,
#[serde(default)]
completion_tokens_details: Option<CompletionTokensDetails>,
}
#[derive(Deserialize, Default)]
struct CompletionTokensDetails {
#[serde(default)]
reasoning_tokens: Option<u32>,
}
#[derive(Deserialize)]
struct DocumentEnvelope {
document: String,
@@ -157,12 +250,48 @@ pub async fn chat_once(
}
let parsed: ChatResponse = response.json().await.map_err(LlmError::Http)?;
let content = parsed
.choices
.into_iter()
.next()
.and_then(|c| c.message.content)
.ok_or(LlmError::Parse("missing choices[0].message.content"))?;
let usage = parsed.usage;
let first = parsed.choices.into_iter().next();
let (finish_reason, content) = match first {
Some(c) => (c.finish_reason, c.message.content),
None => (None, None),
};
let content = match content {
Some(s) => s,
None => {
// Pull diagnostic fields out of the response so the caller can
// surface them in logs and the failure-marker UI. With
// reasoning models (gpt-oss, o1) the most common cause is
// `finish_reason=length`: the CoT consumed the budget before
// the answer started. Without these fields the failure used
// to read "missing choices[0].message.content" — true but
// useless.
let prompt_tokens = usage.as_ref().and_then(|u| u.prompt_tokens);
let completion_tokens = usage.as_ref().and_then(|u| u.completion_tokens);
let reasoning_tokens = usage
.as_ref()
.and_then(|u| u.completion_tokens_details.as_ref())
.and_then(|d| d.reasoning_tokens);
warn!(
model = %backend.model_id,
backend = %backend.id,
finish_reason = ?finish_reason,
prompt_tokens = ?prompt_tokens,
completion_tokens = ?completion_tokens,
reasoning_tokens = ?reasoning_tokens,
max_completion_tokens = backend.max_completion_tokens,
"llm returned no content"
);
return Err(LlmError::EmptyContent {
finish_reason,
prompt_tokens,
completion_tokens,
reasoning_tokens,
max_completion_tokens: backend.max_completion_tokens,
});
}
};
let text = if backend.use_json_schema {
let env: DocumentEnvelope = serde_json::from_str(&content)
+1
View File
@@ -112,6 +112,7 @@ mod tests {
reason: "test".into(),
failed_at: "2026-05-03T00:00:00Z".into(),
backend_id: None,
details: None,
};
fs::write(
case_dir.join(FAILURE_MARKER_FILE),
+51 -11
View File
@@ -4,6 +4,7 @@ use std::sync::Arc;
use tokio::io::AsyncWriteExt;
use tracing::{error, info, warn};
use super::auto_trigger::FailureDetails;
use super::backend::{default_backend, find_backend};
use super::{
ANALYSIS_INPUT_FILE, AnalysisInput, AnalyzeReceiver, DOCUMENT_FILE, auto_trigger, llm, prompt,
@@ -66,13 +67,14 @@ async fn process(
let stub = b"_Keine verwertbaren Aufnahmen (alle Aufnahmen still)._\n";
if let Err(e) = write_atomic(&document_path, stub).await {
error!(path = %document_path.display(), error = %e, "writing stub document failed");
let _ = auto_trigger::write_failure_marker(
let _ = auto_trigger::failure_marker(
case_dir,
&input.last_recording_mtime,
&format!("stub write: {e}"),
None,
format!("stub write: {e}"),
)
.write()
.await;
emit_analysis_failed(events_tx, case_dir);
return;
}
if let Err(e) = tokio::fs::remove_file(&input_path).await {
@@ -111,13 +113,32 @@ async fn process(
// Do not log the response body — LlmError::Display is already
// redacted, but reinforce the rule here for future readers.
error!(case = %case_dir.display(), backend_id = %backend.id, error = %e, "llm call failed");
let _ = auto_trigger::write_failure_marker(
let mut details = FailureDetails::new().with_model_id(&backend.model_id);
if let Some(d) = e.empty_content_details() {
details = details.with_max_completion_tokens(d.max_completion_tokens);
if let Some(fr) = d.finish_reason {
details = details.with_finish_reason(fr);
}
if let Some(v) = d.prompt_tokens {
details = details.with_prompt_tokens(v);
}
if let Some(v) = d.completion_tokens {
details = details.with_completion_tokens(v);
}
if let Some(v) = d.reasoning_tokens {
details = details.with_reasoning_tokens(v);
}
}
let _ = auto_trigger::failure_marker(
case_dir,
&input.last_recording_mtime,
&format!("llm: {e}"),
Some(&backend.id),
format!("llm: {e}"),
)
.with_backend_id(&backend.id)
.with_details(details)
.write()
.await;
emit_analysis_failed(events_tx, case_dir);
return;
}
};
@@ -139,25 +160,31 @@ async fn process(
backend_id = %backend.id,
"llm returned empty document — silent deletion guard triggered"
);
let _ = auto_trigger::write_failure_marker(
let _ = auto_trigger::failure_marker(
case_dir,
&input.last_recording_mtime,
"llm returned empty document",
Some(&backend.id),
)
.with_backend_id(&backend.id)
.with_details(FailureDetails::new().with_model_id(&backend.model_id))
.write()
.await;
emit_analysis_failed(events_tx, case_dir);
return;
}
if let Err(e) = write_atomic(&document_path, document.as_bytes()).await {
error!(path = %document_path.display(), error = %e, "writing document failed");
let _ = auto_trigger::write_failure_marker(
let _ = auto_trigger::failure_marker(
case_dir,
&input.last_recording_mtime,
&format!("write document: {e}"),
Some(&backend.id),
format!("write document: {e}"),
)
.with_backend_id(&backend.id)
.with_details(FailureDetails::new().with_model_id(&backend.model_id))
.write()
.await;
emit_analysis_failed(events_tx, case_dir);
return;
}
@@ -183,6 +210,19 @@ async fn process(
);
}
/// Notify subscribed browsers that an analysis attempt failed. Called
/// after the failure marker is on disk so a debounced reload sees the
/// fresh banner immediately instead of leaving the page on the
/// "Wird analysiert …" placeholder.
fn emit_analysis_failed(events_tx: &EventSender, case_dir: &Path) {
events::emit(
events_tx,
events::user_slug_of(case_dir),
events::case_id_of(case_dir),
CaseEventKind::AnalysisFailed,
);
}
/// Write `bytes` to `path` atomically: write to `<path>.tmp`, fsync, rename.
/// A crash between write and rename leaves no half-written target file — the
/// state machine simply sees the job as still pending and retries.
+1
View File
@@ -30,6 +30,7 @@ pub enum CaseEventKind {
OnelinerUpdated,
AnalysisQueued,
DocumentReady,
AnalysisFailed,
CaseClosed,
CaseReopened,
CasePurged,
+30 -7
View File
@@ -399,6 +399,21 @@ struct FailureBanner {
/// the marker's `backend_id`). Empty if the marker predates the
/// per-backend tracking or the id is no longer in the catalog.
backend_label: String,
/// Provider-side model identifier (e.g. `openai/gpt-oss-120b`).
/// Empty when the marker predates structured details.
model_id: String,
/// `finish_reason` from the chat-completions response. Empty when not
/// applicable (HTTP/transport errors don't produce one). Typical
/// values: `length` (token cap hit), `content_filter`, `stop`.
finish_reason: String,
/// Token counts from the response's `usage` block. `None` when the
/// provider did not return usage or the failure was pre-response.
prompt_tokens: Option<u32>,
completion_tokens: Option<u32>,
reasoning_tokens: Option<u32>,
/// `max_completion_tokens` value the call was made with. Pairs with
/// `completion_tokens` to show "X / Y" in the UI.
max_completion_tokens: Option<u32>,
}
/// Flags derived from filesystem state + config.
@@ -438,17 +453,25 @@ async fn compute_flags(
// analysis would otherwise occupy the same UI slot. Skips the FS read on
// the success hot-path (document already on disk).
let analysis_failed = if !has_document && !analyzing {
auto_trigger::read_failure_marker(case_dir)
.await
.map(|m| FailureBanner {
reason: m.reason,
failed_at: m.failed_at,
backend_label: m
auto_trigger::read_failure_marker(case_dir).await.map(|m| {
let backend_label = m
.backend_id
.as_deref()
.and_then(crate::analyze::backend::find_backend)
.map(|b| b.label.clone())
.unwrap_or_default(),
.unwrap_or_default();
let details = m.details.unwrap_or_default();
FailureBanner {
reason: m.reason,
failed_at: m.failed_at,
backend_label,
model_id: details.model_id.unwrap_or_default(),
finish_reason: details.finish_reason.unwrap_or_default(),
prompt_tokens: details.prompt_tokens,
completion_tokens: details.completion_tokens,
reasoning_tokens: details.reasoning_tokens,
max_completion_tokens: details.max_completion_tokens,
}
})
} else {
None
+49 -4
View File
@@ -201,6 +201,26 @@
font-size: 0.85em;
color: #4a1014;
}
.failure-banner dl.failure-details {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.2em 0.8em;
margin: 0.4em 0 0.6em 0;
padding: 0.5em 0.8em;
background: #fff;
border: 1px solid #f5c6cb;
border-radius: 3px;
font-size: 0.85em;
color: #4a1014;
}
.failure-banner dl.failure-details dt {
font-weight: 600;
color: #6b1f25;
}
.failure-banner dl.failure-details dd {
margin: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.failure-banner form {
margin: 0.8em 0 0 0;
}
@@ -609,10 +629,7 @@
</script>
{% when None %} {% match analysis_failed %} {% when Some with (failure) %}
<div class="failure-banner" role="alert">
<strong>
Analyse{% if !failure.backend_label.is_empty() %} mit {{ failure.backend_label }}{% endif %} fehlgeschlagen.
</strong>
Bitte erneut versuchen.
<strong>Analyse fehlgeschlagen.</strong>
<span class="failure-time"
>Zuletzt versucht:
<time datetime="{{ failure.failed_at }}"
@@ -621,6 +638,34 @@
>
<details>
<summary>Technische Details</summary>
<dl class="failure-details">
{% if !failure.backend_label.is_empty() %}
<dt>Backend</dt>
<dd>{{ failure.backend_label }}</dd>
{% endif %}
{% if !failure.model_id.is_empty() %}
<dt>Modell</dt>
<dd><code>{{ failure.model_id }}</code></dd>
{% endif %}
{% if !failure.finish_reason.is_empty() %}
<dt>finish_reason</dt>
<dd><code>{{ failure.finish_reason }}</code></dd>
{% endif %}
{% match failure.completion_tokens %}{% when Some with (ct) %}
<dt>Completion-Tokens</dt>
<dd>
{{ ct }}{% match failure.max_completion_tokens %}{% when Some with (cap) %} / {{ cap }}{% when None %}{% endmatch %}
</dd>
{% when None %}{% endmatch %}
{% match failure.reasoning_tokens %}{% when Some with (rt) %}
<dt>davon Reasoning</dt>
<dd>{{ rt }}</dd>
{% when None %}{% endmatch %}
{% match failure.prompt_tokens %}{% when Some with (pt) %}
<dt>Prompt-Tokens</dt>
<dd>{{ pt }}</dd>
{% when None %}{% endmatch %}
</dl>
<pre class="failure-reason">{{ failure.reason }}</pre>
</details>
<form
+162
View File
@@ -0,0 +1,162 @@
//! Regression test for the diagnostic surface around an empty-content
//! LLM response.
//!
//! Pre-fix behaviour: when the provider returned `200 OK` with
//! `choices[0].message.content == null` (typical for reasoning models that
//! exhausted `max_completion_tokens` on the chain-of-thought), the worker
//! wrote a failure marker that read `llm: missing choices[0].message.content`
//! — true but useless. Diagnostic fields the provider sent in the same
//! response (`finish_reason`, `usage.completion_tokens`, reasoning_tokens)
//! were dropped on the floor.
//!
//! This test pins the new `LlmError::EmptyContent` variant to the parsed
//! response fields so the failure-marker UI can render structured details.
use doctate_server::analyze::backend::LlmBackend;
use doctate_server::analyze::llm::{LlmError, chat_once};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn test_backend(uri: String) -> LlmBackend {
LlmBackend {
id: "test_backend".into(),
label: "Test".into(),
url: uri,
api_key: "test-key".into(),
requires_api_key: true,
model_id: "openai/gpt-oss-120b".into(),
temperature: 0.2,
top_p: None,
max_completion_tokens: 16384,
reasoning_effort: Some("high".into()),
use_json_schema: false,
system_prompt: "system".into(),
format_instruction: None,
timeout_seconds: 30,
}
}
#[tokio::test]
async fn empty_content_with_full_usage_surfaces_finish_reason_and_tokens() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"choices": [{
"message": { "content": null },
"finish_reason": "length"
}],
"usage": {
"prompt_tokens": 1287,
"completion_tokens": 16384,
"completion_tokens_details": {
"reasoning_tokens": 14102
}
}
})))
.mount(&server)
.await;
let backend = test_backend(server.uri());
let client = reqwest::Client::new();
let err = chat_once(&client, &backend, "user content")
.await
.expect_err("must fail when content is null");
let d = err
.empty_content_details()
.expect("must classify as EmptyContent");
assert_eq!(d.finish_reason, Some("length"));
assert_eq!(d.prompt_tokens, Some(1287));
assert_eq!(d.completion_tokens, Some(16384));
assert_eq!(d.reasoning_tokens, Some(14102));
assert_eq!(d.max_completion_tokens, 16384);
// Display string must mention finish_reason and the X/Y token ratio so
// the failure-marker `reason` field already carries a hint even when
// structured details are not yet rendered.
let s = err.to_string();
assert!(s.contains("finish_reason=length"), "got: {s}");
assert!(s.contains("16384/16384"), "got: {s}");
assert!(s.contains("reasoning_tokens=14102"), "got: {s}");
}
#[tokio::test]
async fn empty_content_without_usage_degrades_gracefully() {
// Some OpenAI-compatible providers omit `usage` entirely on null-content
// responses. The parser must still classify this as EmptyContent and not
// crash on the missing field.
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"choices": [{
"message": { "content": null },
"finish_reason": "stop"
}]
})))
.mount(&server)
.await;
let backend = test_backend(server.uri());
let client = reqwest::Client::new();
let err = chat_once(&client, &backend, "user content")
.await
.expect_err("must fail when content is null");
let d = err
.empty_content_details()
.expect("must classify as EmptyContent even without usage");
assert_eq!(d.finish_reason, Some("stop"));
assert_eq!(d.prompt_tokens, None);
assert_eq!(d.completion_tokens, None);
assert_eq!(d.reasoning_tokens, None);
assert_eq!(d.max_completion_tokens, 16384);
}
#[tokio::test]
async fn populated_content_returns_ok() {
// Sanity check: the new code path must not regress the happy path.
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"choices": [{
"message": { "content": "Hallo Welt." },
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5
}
})))
.mount(&server)
.await;
let backend = test_backend(server.uri());
let client = reqwest::Client::new();
let text = chat_once(&client, &backend, "user content")
.await
.expect("must succeed when content is populated");
assert_eq!(text, "Hallo Welt.");
}
#[tokio::test]
async fn http_error_does_not_carry_empty_content_details() {
// Status errors are a different LlmError variant — `empty_content_details`
// must not falsely classify them as EmptyContent.
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(503).set_body_string("upstream down"))
.mount(&server)
.await;
let backend = test_backend(server.uri());
let client = reqwest::Client::new();
let err = chat_once(&client, &backend, "user content")
.await
.expect_err("must fail on 503");
assert!(matches!(err, LlmError::Status { status: 503 }));
assert!(err.empty_content_details().is_none());
}