use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::strict_schema::strict_json_schema;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct StructuredOutput {
pub name: String,
pub schema: Value,
#[serde(default = "default_strict")]
pub strict: bool,
}
fn default_strict() -> bool {
true
}
impl StructuredOutput {
pub fn new(name: impl Into<String>, schema: Value) -> Self {
Self {
name: name.into(),
schema,
strict: true,
}
}
#[must_use]
pub fn lenient(mut self) -> Self {
self.strict = false;
self
}
pub fn openai_response_format(&self) -> Value {
let schema = if self.strict {
strict_json_schema(&self.schema)
} else {
self.schema.clone()
};
json!({
"type": "json_schema",
"json_schema": {
"name": self.name,
"strict": self.strict,
"schema": schema,
}
})
}
pub fn anthropic_output_config(&self) -> Value {
json!({
"format": {
"type": "json_schema",
"schema": self.schema,
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn schema() -> Value {
json!({
"type": "object",
"properties": { "verdict": { "type": "string" } },
"required": ["verdict"],
"additionalProperties": false,
})
}
#[test]
fn openai_envelope_carries_name_strict_and_schema() {
let v = StructuredOutput::new("review_output", schema()).openai_response_format();
assert_eq!(v["type"], "json_schema");
assert_eq!(v["json_schema"]["name"], "review_output");
assert_eq!(v["json_schema"]["strict"], true);
assert_eq!(v["json_schema"]["schema"]["type"], "object");
}
#[test]
fn openai_envelope_normalizes_a_strict_schema() {
let loose = json!({
"type": "object",
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"kind": {"type": "string"},
"severity": {"type": "string"}
},
"required": ["kind"]
}
}
}
});
let directive = StructuredOutput::new("period_findings", loose.clone());
let sent = directive.openai_response_format();
let items = &sent["json_schema"]["schema"]["properties"]["findings"]["items"];
assert_eq!(items["additionalProperties"], json!(false), "{sent}");
assert_eq!(items["required"], json!(["kind", "severity"]), "{sent}");
assert_eq!(
sent["json_schema"]["schema"]["additionalProperties"],
json!(false),
"{sent}"
);
assert_eq!(
directive.schema, loose,
"the caller's schema must not change"
);
}
#[test]
fn openai_envelope_leaves_a_lenient_schema_verbatim() {
let loose = json!({
"type": "object",
"properties": {"a": {"type": "string"}, "b": {"type": "string"}},
"required": ["a"]
});
let sent = StructuredOutput::new("x", loose.clone())
.lenient()
.openai_response_format();
assert_eq!(sent["json_schema"]["schema"], loose, "{sent}");
}
#[test]
fn anthropic_envelope_carries_only_the_schema() {
let v = StructuredOutput::new("review_output", schema()).anthropic_output_config();
assert_eq!(v["format"]["type"], "json_schema");
assert_eq!(v["format"]["schema"]["type"], "object");
assert!(v["format"].get("name").is_none(), "{v}");
assert!(v["format"].get("strict").is_none(), "{v}");
}
#[test]
fn strict_defaults_true_and_round_trips() {
let parsed: StructuredOutput =
serde_json::from_value(json!({ "name": "x", "schema": { "type": "object" } }))
.expect("deserialise");
assert!(parsed.strict);
let round: StructuredOutput =
serde_json::from_value(serde_json::to_value(&parsed).expect("serialise"))
.expect("re-deserialise");
assert_eq!(round, parsed);
}
#[test]
fn lenient_clears_strict() {
let s = StructuredOutput::new("x", schema()).lenient();
assert!(!s.strict);
assert_eq!(s.openai_response_format()["json_schema"]["strict"], false);
}
}