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}