Skip to main content

entelix_core/ir/
structured.rs

1//! `ResponseFormat` — vendor-agnostic structured-output IR.
2//!
3//! Per: enters IR because OpenAI Chat / OpenAI
4//! Responses / Gemini all natively support a JSON-Schema-shaped
5//! response constraint. Anthropic does not natively, so codecs
6//! synthesize a tool-use shim and emit
7//! [`crate::ir::ModelWarning::LossyEncode`].
8//!
9//! ## Validation discipline
10//!
11//! [`JsonSchemaSpec::new`] performs a minimal sanity check at
12//! construction (non-empty name; schema must be a JSON object).
13//! Full JSON Schema validation is deferred to the codec encode
14//! path where it has access to the vendor's validation rules
15//! (some vendors require strict mode, draft 2020-12, etc.). Per
16//!: callers receive an `Err` at
17//! construction for the obvious failures, not at first-call time.
18
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21
22use crate::error::{Error, Result};
23
24/// JSON Schema specification — a (name, schema) pair carried
25/// through the IR and routed to vendor-canonical structured-output
26/// channels.
27///
28/// Construct via [`Self::new`] (validates inputs) or via
29/// `serde_json::from_str` (deserialization is unchecked — the
30/// codec validates at encode time).
31#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
32pub struct JsonSchemaSpec {
33    /// Caller-chosen identifier for the schema. Surfaces in OTel
34    /// span attributes (`gen_ai.response_format.name`) and in the
35    /// vendor wire format where applicable (OpenAI requires this).
36    pub name: String,
37    /// JSON Schema document. Must be a JSON object at the top
38    /// level (per JSON Schema spec); [`Self::new`] rejects other
39    /// shapes.
40    pub schema: Value,
41}
42
43impl JsonSchemaSpec {
44    /// Validated constructor. Returns [`Error::Config`] when:
45    /// - `name` is empty after trimming, or
46    /// - `schema` is not a JSON object at the top level.
47    pub fn new(name: impl Into<String>, schema: Value) -> Result<Self> {
48        let name = name.into();
49        if name.trim().is_empty() {
50            return Err(Error::config("JsonSchemaSpec: name must be non-empty"));
51        }
52        if !schema.is_object() {
53            return Err(Error::config(
54                "JsonSchemaSpec: schema must be a JSON object at the top level",
55            ));
56        }
57        Ok(Self { name, schema })
58    }
59}
60
61/// How the codec should ask the model to honour the schema.
62///
63/// Industry consensus (LangChain 1.0 `ProviderStrategy`/
64/// `ToolStrategy`, pydantic-ai 1.90 `NativeOutput`/`ToolOutput`/
65/// `PromptedOutput`/`TextOutput`, BAML SAP, Vercel AI SDK 5
66/// `generateObject`, Instructor's mode flag) converges on three
67/// dispatch shapes plus an automatic picker:
68///
69/// - `Native` — vendor-native structured output channel (OpenAI
70///   `text.format = json_schema`, Gemini `responseJsonSchema`,
71///   Anthropic `output_config.format = json_schema`). Strictest;
72///   the vendor itself rejects malformed responses.
73/// - `Tool` — single forced tool call whose input schema is the
74///   target schema. Mature on every vendor (the tool-call
75///   surface predates native structured output by a year+);
76///   slightly less efficient because the model emits a tool_use
77///   block instead of plain assistant text.
78/// - `Prompted` — schema injected into the system prompt; the
79///   reply is parsed best-effort. Last resort for vendors with
80///   neither native nor tool support, and for "I want a typed
81///   answer but the model is non-reasoning" flows. Deferred to
82///   1.1 — `complete_typed` rejects this strategy at runtime
83///   today.
84/// - `Auto` — codec picks per-vendor at codec-construction time
85///   (NOT per request — per-request resolution would let the same
86///   logical request resolve differently across replays, breaking
87///   the SessionGraph event log's deterministic-replay guarantee).
88///   The picked strategy is what `Codec::auto_output_strategy(model)`
89///   returns.
90#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
92#[non_exhaustive]
93pub enum OutputStrategy {
94    /// Codec picks per-vendor at codec-construction time. Default.
95    #[default]
96    Auto,
97    /// Vendor-native structured output channel.
98    Native,
99    /// Forced single tool call carrying the target schema.
100    Tool,
101    /// Schema injected into the system prompt (1.1 — currently
102    /// rejected at encode time).
103    Prompted,
104}
105
106/// Structured-output directive attached to a [`ModelRequest`](crate::ir::ModelRequest).
107///
108/// `strict` requests the vendor's strict-mode interpretation when
109/// available (OpenAI). Codecs that cannot enforce strict mode
110/// natively emit a `LossyEncode` warning.
111///
112/// `strategy` selects the dispatch shape (vendor-native channel /
113/// forced tool call / prompted). `Auto` (the default) lets each
114/// codec pick its preferred shape per `auto_output_strategy(model)`
115/// at codec-construction time — see [`OutputStrategy`].
116#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
117pub struct ResponseFormat {
118    /// Schema the response must conform to.
119    pub json_schema: JsonSchemaSpec,
120    /// Request strict-mode validation. Defaults to `true` —
121    /// callers explicitly opt out with `false` when they want
122    /// best-effort schema adherence (some Anthropic shim flows).
123    #[serde(default = "ResponseFormat::default_strict")]
124    pub strict: bool,
125    /// Dispatch shape — vendor-native, forced-tool, or prompted.
126    /// Defaults to [`OutputStrategy::Auto`] which lets the codec
127    /// pick per-vendor at construction time.
128    #[serde(default)]
129    pub strategy: OutputStrategy,
130}
131
132impl ResponseFormat {
133    /// Build a strict response format from the supplied schema.
134    /// `strategy` defaults to `Auto`; chain
135    /// [`Self::with_strategy`] to override.
136    pub fn strict(schema: JsonSchemaSpec) -> Self {
137        Self {
138            json_schema: schema,
139            strict: true,
140            strategy: OutputStrategy::Auto,
141        }
142    }
143
144    /// Build a best-effort response format (no strict-mode
145    /// validation requested). `strategy` defaults to `Auto`.
146    pub fn best_effort(schema: JsonSchemaSpec) -> Self {
147        Self {
148            json_schema: schema,
149            strict: false,
150            strategy: OutputStrategy::Auto,
151        }
152    }
153
154    /// Override the dispatch [`OutputStrategy`]. `Auto` means
155    /// "codec picks at construction time"; explicit `Native` /
156    /// `Tool` / `Prompted` overrides per-codec defaulting.
157    #[must_use]
158    pub const fn with_strategy(mut self, strategy: OutputStrategy) -> Self {
159        self.strategy = strategy;
160        self
161    }
162
163    /// Validate the schema against the strict-mode constraints
164    /// shared across `OpenAI` (Chat + Responses) and Anthropic
165    /// native structured outputs. Returns the offending
166    /// field path on failure so codecs can attach an actionable
167    /// `LossyEncode` warning.
168    ///
169    /// Constraints checked:
170    /// - every object schema declares `additionalProperties: false`
171    /// - every object schema's `required` list contains *every*
172    ///   property defined in `properties` (`OpenAI` strict-mode
173    ///   requirement)
174    ///
175    /// The check is a no-op when `self.strict == false`.
176    pub fn strict_preflight(&self) -> std::result::Result<(), StrictSchemaError> {
177        if !self.strict {
178            return Ok(());
179        }
180        check_strict(&self.json_schema.schema, "$")
181    }
182
183    const fn default_strict() -> bool {
184        true
185    }
186}
187
188/// Reason a strict-mode `JsonSchemaSpec` did not meet the
189/// vendor-shared constraints checked by
190/// [`ResponseFormat::strict_preflight`].
191#[derive(Debug, Clone, Eq, PartialEq, thiserror::Error)]
192#[non_exhaustive]
193pub enum StrictSchemaError {
194    /// An object schema is missing `additionalProperties: false`,
195    /// or carries a non-`false` value.
196    #[error("strict-mode schema requires `additionalProperties: false` at {path}")]
197    AdditionalPropertiesNotFalse {
198        /// Dotted path into the schema (`$.properties.user`).
199        path: String,
200    },
201    /// An object schema's `required` array does not include every
202    /// property defined under `properties` — `OpenAI` strict mode
203    /// rejects partial-required object schemas.
204    #[error("strict-mode schema at {path} declares properties not in `required`: {}", .missing.join(", "))]
205    RequiredMissingProperties {
206        /// Dotted path into the schema.
207        path: String,
208        /// Properties declared but not required.
209        missing: Vec<String>,
210    },
211}
212
213fn check_strict(schema: &Value, path: &str) -> std::result::Result<(), StrictSchemaError> {
214    // Only object schemas carry the constraint. Other shapes
215    // (string, number, array) pass through unchecked.
216    let Some(obj) = schema.as_object() else {
217        return Ok(());
218    };
219    let kind = obj.get("type").and_then(Value::as_str);
220
221    if kind == Some("object") {
222        match obj.get("additionalProperties") {
223            Some(Value::Bool(false)) => {}
224            _ => {
225                return Err(StrictSchemaError::AdditionalPropertiesNotFalse {
226                    path: path.to_owned(),
227                });
228            }
229        }
230        if let Some(Value::Object(properties)) = obj.get("properties") {
231            let required: std::collections::BTreeSet<&str> = obj
232                .get("required")
233                .and_then(Value::as_array)
234                .map(|arr| arr.iter().filter_map(Value::as_str).collect())
235                .unwrap_or_default();
236            let missing: Vec<String> = properties
237                .keys()
238                .filter(|k| !required.contains(k.as_str()))
239                .cloned()
240                .collect();
241            if !missing.is_empty() {
242                return Err(StrictSchemaError::RequiredMissingProperties {
243                    path: path.to_owned(),
244                    missing,
245                });
246            }
247            // Recurse into each property schema.
248            for (name, sub) in properties {
249                check_strict(sub, &format!("{path}.properties.{name}"))?;
250            }
251        }
252    } else if kind == Some("array")
253        && let Some(items) = obj.get("items")
254    {
255        check_strict(items, &format!("{path}.items"))?;
256    }
257    // Recurse into composition keywords (anyOf / allOf / oneOf).
258    for keyword in ["anyOf", "allOf", "oneOf"] {
259        if let Some(Value::Array(arr)) = obj.get(keyword) {
260            for (i, sub) in arr.iter().enumerate() {
261                check_strict(sub, &format!("{path}.{keyword}[{i}]"))?;
262            }
263        }
264    }
265    Ok(())
266}
267
268#[cfg(test)]
269#[allow(clippy::unwrap_used)]
270mod tests {
271    use serde_json::json;
272
273    use super::*;
274
275    #[test]
276    fn new_rejects_empty_name() {
277        let err = JsonSchemaSpec::new("", json!({"type": "object"})).unwrap_err();
278        assert!(format!("{err}").contains("name must be non-empty"));
279    }
280
281    #[test]
282    fn new_rejects_whitespace_only_name() {
283        let err = JsonSchemaSpec::new("   ", json!({"type": "object"})).unwrap_err();
284        assert!(format!("{err}").contains("name must be non-empty"));
285    }
286
287    #[test]
288    fn new_rejects_non_object_schema() {
289        let err = JsonSchemaSpec::new("user", json!("not an object")).unwrap_err();
290        assert!(format!("{err}").contains("must be a JSON object"));
291        let err2 = JsonSchemaSpec::new("user", json!([1, 2, 3])).unwrap_err();
292        assert!(format!("{err2}").contains("must be a JSON object"));
293    }
294
295    #[test]
296    fn new_accepts_valid_object_schema() {
297        let spec = JsonSchemaSpec::new(
298            "user",
299            json!({
300                "type": "object",
301                "properties": {"name": {"type": "string"}},
302                "required": ["name"],
303            }),
304        )
305        .unwrap();
306        assert_eq!(spec.name, "user");
307        assert!(spec.schema.is_object());
308    }
309
310    #[test]
311    fn strict_constructor_sets_strict_flag() {
312        let spec = JsonSchemaSpec::new("user", json!({"type": "object"})).unwrap();
313        let format = ResponseFormat::strict(spec);
314        assert!(format.strict);
315    }
316
317    #[test]
318    fn best_effort_constructor_clears_strict_flag() {
319        let spec = JsonSchemaSpec::new("user", json!({"type": "object"})).unwrap();
320        let format = ResponseFormat::best_effort(spec);
321        assert!(!format.strict);
322    }
323
324    #[test]
325    fn round_trips_via_serde() {
326        let spec = JsonSchemaSpec::new("user", json!({"type": "object"})).unwrap();
327        let format = ResponseFormat::strict(spec);
328        let json = serde_json::to_string(&format).unwrap();
329        let back: ResponseFormat = serde_json::from_str(&json).unwrap();
330        assert_eq!(format, back);
331    }
332}