Skip to main content

systemprompt_models/wire/
defect.rs

1//! Detects an upstream buffered body that carries no turn at all.
2//!
3//! Every per-wire buffered parser is total: it deserializes what it
4//! recognises and defaults the rest. That is the right behaviour for a reply
5//! that is merely sparse, and the wrong behaviour for a reply that is empty,
6//! because the parser then manufactures a well-formed canonical response with
7//! no content, no usage and no stop reason. Relayed to a client that reads as
8//! a successful turn in which the model said nothing, and the audit row
9//! records it as completed with zero tokens.
10//!
11//! [`buffered_body_defect`] runs before the parser and separates the two
12//! cases. A body is defective when it is not a JSON object at all, when it
13//! carries a provider `error` object despite the 2xx status, or when it has
14//! neither a non-empty content array nor a usage object. A legitimate empty
15//! turn -- one that stopped immediately but still reports usage -- has usage
16//! and is left alone.
17//!
18//! Copyright (c) systemprompt.io — Business Source License 1.1.
19//! See <https://systemprompt.io> for licensing details.
20
21// JSON: protocol boundary — the check reads an arbitrary provider wire body
22// before any typed parse has been attempted.
23use serde_json::Value;
24
25/// Why the body cannot be parsed into a turn.
26///
27/// `NotAnObject` is a JSON array or scalar where the contract requires an
28/// object, `UpstreamErrorObject` is a provider error delivered with a success
29/// status, and `NoTurn` is an object with neither content nor usage.
30///
31/// `Display` is the operator-facing sentence; the raw body excerpt is attached
32/// by the caller, which is the layer that still holds the bytes.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum BodyDefect {
35    NotAnObject,
36    UpstreamErrorObject(String),
37    NoTurn,
38}
39
40impl std::fmt::Display for BodyDefect {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            Self::NotAnObject => write!(f, "upstream body is not a JSON object"),
44            Self::UpstreamErrorObject(message) => {
45                write!(f, "upstream returned an error object: {message}")
46            },
47            Self::NoTurn => write!(f, "upstream body carried no content and no usage"),
48        }
49    }
50}
51
52#[must_use]
53pub fn buffered_body_defect(
54    value: &Value,
55    content_field: &str,
56    usage_field: &str,
57) -> Option<BodyDefect> {
58    let Some(object) = value.as_object() else {
59        return Some(BodyDefect::NotAnObject);
60    };
61    if let Some(error) = object.get("error")
62        && !error.is_null()
63    {
64        return Some(BodyDefect::UpstreamErrorObject(error_message(error)));
65    }
66    let has_content = object
67        .get(content_field)
68        .is_some_and(|c| c.as_array().is_some_and(|a| !a.is_empty()));
69    let has_usage = object.get(usage_field).is_some_and(Value::is_object);
70    (!has_content && !has_usage).then_some(BodyDefect::NoTurn)
71}
72
73// Why: Providers return `error` as either an object with `message` or a bare
74// string.
75fn error_message(error: &Value) -> String {
76    error
77        .get("message")
78        .and_then(Value::as_str)
79        .or_else(|| error.as_str())
80        .map_or_else(|| error.to_string(), ToOwned::to_owned)
81}