use crate::api::ApiClient;
use crate::message::Message;
pub trait StructuredOutput: Sized + Send + 'static {
fn name() -> &'static str;
fn schema() -> serde_json::Value;
fn from_value(v: serde_json::Value) -> Result<Self, StructuredError>
where
Self: serde::de::DeserializeOwned,
{
serde_json::from_value(v).map_err(StructuredError::Deserialize)
}
}
#[derive(Debug, Clone)]
pub struct ResponseFormat {
pub name: String,
pub schema: serde_json::Value,
pub strict: bool,
}
impl ResponseFormat {
#[must_use]
pub fn from_type<T: StructuredOutput>() -> Self {
Self {
name: T::name().to_string(),
schema: T::schema(),
strict: true,
}
}
#[must_use]
pub fn new(name: impl Into<String>, schema: serde_json::Value) -> Self {
Self {
name: name.into(),
schema,
strict: true,
}
}
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub enum ToolConstraint {
#[default]
None,
Strict,
#[cfg(feature = "grammar")]
Grammar(std::sync::Arc<dyn crate::provider::grammar::ToolGrammarProvider>),
}
#[derive(Debug, Clone, Default)]
pub struct RequestOptions {
pub response_format: Option<ResponseFormat>,
pub tool_constraint: ToolConstraint,
}
impl RequestOptions {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_response_format(mut self, rf: ResponseFormat) -> Self {
self.response_format = Some(rf);
self
}
#[must_use]
pub fn with_tool_constraint(mut self, c: ToolConstraint) -> Self {
self.tool_constraint = c;
self
}
}
#[derive(Debug, thiserror::Error)]
pub enum StructuredError {
#[error("structured output did not match the expected schema: {0}")]
Deserialize(#[from] serde_json::Error),
#[error("API error during structured output request: {0}")]
Api(crate::api::error::ApiError),
}
pub(crate) fn parse_json_lenient(text: &str) -> Option<serde_json::Value> {
if let Ok(v) = serde_json::from_str(text) {
return Some(v);
}
extract_json_substring(text)
}
pub(crate) fn extract_json_substring(text: &str) -> Option<serde_json::Value> {
let bytes = text.as_bytes();
let mut start = None;
let mut depth: i32 = 0;
let mut close = b'\0';
let mut in_string = false;
let mut escaped = false;
for (i, &byte) in bytes.iter().enumerate() {
if in_string {
if escaped {
escaped = false;
} else if byte == b'\\' {
escaped = true;
} else if byte == b'"' {
in_string = false;
}
continue;
}
match byte {
b'"' => {
in_string = true;
}
b'{' | b'[' => {
if start.is_none() {
start = Some(i);
close = if byte == b'{' { b'}' } else { b']' };
}
depth = depth.saturating_add(1);
}
b'}' | b']' => {
if let Some(s) = start {
if byte == close {
depth = depth.saturating_sub(1);
if depth == 0 {
let slice = text.get(s..=i).unwrap_or(text);
if let Ok(v) = serde_json::from_str(slice) {
return Some(v);
}
start = None;
}
} else {
start = None;
depth = 0;
}
}
}
_ => {}
}
}
None
}
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
pub(crate) fn tighten_json_schema(schema: &serde_json::Value) -> serde_json::Value {
let mut out = schema.clone();
tighten_in_place(&mut out);
out
}
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
fn tighten_in_place(schema: &mut serde_json::Value) {
let Some(obj) = schema.as_object_mut() else {
return;
};
if let Some(properties) = obj
.get_mut("properties")
.and_then(serde_json::Value::as_object_mut)
{
for child in properties.values_mut() {
tighten_in_place(child);
}
}
if let Some(items) = obj.get_mut("items") {
tighten_in_place(items);
}
for key in ["allOf", "anyOf", "oneOf"] {
if let Some(arr) = obj.get_mut(key).and_then(serde_json::Value::as_array_mut) {
for child in arr {
tighten_in_place(child);
}
}
}
for key in ["$defs", "definitions"] {
if let Some(defs) = obj.get_mut(key).and_then(serde_json::Value::as_object_mut) {
for child in defs.values_mut() {
tighten_in_place(child);
}
}
}
let is_object = obj
.get("type")
.and_then(serde_json::Value::as_str)
.is_some_and(|t| t == "object");
if !is_object {
return;
}
obj.insert(
"additionalProperties".to_string(),
serde_json::Value::Bool(false),
);
let property_keys: Vec<String> = obj
.get("properties")
.and_then(serde_json::Value::as_object)
.map(|props| props.keys().cloned().collect())
.unwrap_or_default();
obj.insert(
"required".to_string(),
serde_json::Value::Array(
property_keys
.into_iter()
.map(serde_json::Value::String)
.collect(),
),
);
}
pub async fn request_structured<T: StructuredOutput + serde::de::DeserializeOwned>(
client: &dyn ApiClient,
messages: Vec<Message>,
system: Option<String>,
) -> Result<T, StructuredError> {
let opts = RequestOptions::new().with_response_format(ResponseFormat::from_type::<T>());
let request = crate::api::StreamRequest {
messages,
system,
tools: None,
};
let response = client
.create_message_with_options(&request, opts)
.await
.map_err(StructuredError::Api)?;
let value = client.extract_structured(&response.message);
T::from_value(value)
}
#[cfg(test)]
mod tests {
use super::*;
use std::future::Future;
use std::pin::Pin;
#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq)]
struct Action {
tool: String,
args: serde_json::Value,
}
impl StructuredOutput for Action {
fn name() -> &'static str {
"action"
}
fn schema() -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"tool": { "type": "string" },
"args": {}
},
"required": ["tool", "args"],
"additionalProperties": false
})
}
}
fn fixture_action() -> serde_json::Value {
serde_json::json!({
"tool": "write",
"args": { "path": "/tmp/test.txt" }
})
}
#[test]
fn structured_output_round_trip() {
let v = fixture_action();
let action: Action = Action::from_value(v).expect("should deserialize");
assert_eq!(action.tool, "write");
assert_eq!(action.args, serde_json::json!({ "path": "/tmp/test.txt" }));
}
#[test]
fn response_format_from_type() {
let rf = ResponseFormat::from_type::<Action>();
assert_eq!(rf.name, "action");
assert_eq!(rf.schema, Action::schema());
assert!(rf.strict);
}
#[test]
fn request_options_builder() {
let opts = RequestOptions::new();
assert!(opts.response_format.is_none());
let rf = ResponseFormat::from_type::<Action>();
let opts = RequestOptions::new().with_response_format(rf);
assert!(opts.response_format.is_some());
assert_eq!(opts.response_format.as_ref().unwrap().name, "action");
}
#[cfg(any(feature = "openai", feature = "gemini"))]
#[cfg(any(feature = "openai", feature = "gemini"))]
#[test]
fn parse_json_lenient_plain_json() {
let v = parse_json_lenient(r#"{"a": 1}"#).unwrap();
assert_eq!(v["a"], 1);
}
#[cfg(any(feature = "openai", feature = "gemini"))]
#[cfg(any(feature = "openai", feature = "gemini"))]
#[test]
fn parse_json_lenient_with_prefix() {
let v = parse_json_lenient(r#"Here is the JSON: {"a": 1}"#).unwrap();
assert_eq!(v["a"], 1);
}
#[cfg(any(feature = "openai", feature = "gemini"))]
#[cfg(any(feature = "openai", feature = "gemini"))]
#[test]
fn parse_json_lenient_markdown_fences() {
let v = parse_json_lenient("```json\n{\"a\": 1}\n```").unwrap();
assert_eq!(v["a"], 1);
}
#[cfg(any(feature = "openai", feature = "gemini"))]
#[cfg(any(feature = "openai", feature = "gemini"))]
#[test]
fn parse_json_lenient_array() {
let v = parse_json_lenient(r#"prefix [1, 2, 3] suffix"#).unwrap();
assert_eq!(v[0], 1);
}
#[cfg(any(feature = "openai", feature = "gemini"))]
#[cfg(any(feature = "openai", feature = "gemini"))]
#[test]
fn parse_json_lenient_no_json() {
let result = parse_json_lenient("just prose, nothing here");
assert!(result.is_none());
}
#[test]
fn structured_error_displays() {
let json_err = serde_json::from_str::<serde_json::Value>("bad").unwrap_err();
let err = StructuredError::Deserialize(json_err);
assert!(err.to_string().contains("schema"));
}
#[cfg(any(feature = "openai", feature = "gemini"))]
#[cfg(any(feature = "openai", feature = "gemini"))]
#[test]
fn parse_json_lenient_brace_inside_string() {
let v = parse_json_lenient(r#"prefix {"a": "}"} suffix"#).unwrap();
assert_eq!(v["a"], "}");
}
#[cfg(any(feature = "openai", feature = "gemini"))]
#[cfg(any(feature = "openai", feature = "gemini"))]
#[test]
fn parse_json_lenient_bracket_inside_string() {
let v = parse_json_lenient(r#"before {"x": "]"} after"#).unwrap();
assert_eq!(v["x"], "]");
}
#[cfg(any(feature = "openai", feature = "gemini"))]
#[cfg(any(feature = "openai", feature = "gemini"))]
#[test]
fn parse_json_lenient_escaped_quote_in_string() {
let v = parse_json_lenient(r#"here {"a": "he said \"hi\""} there"#).unwrap();
assert_eq!(v["a"], "he said \"hi\"");
}
#[cfg(any(feature = "openai", feature = "gemini"))]
#[cfg(any(feature = "openai", feature = "gemini"))]
#[test]
fn parse_json_lenient_nested_objects_in_prose() {
let v = parse_json_lenient(r#"result: {"outer": {"inner": 42}}"#).unwrap();
assert_eq!(v["outer"]["inner"], 42);
}
#[cfg(any(feature = "openai", feature = "gemini"))]
#[cfg(any(feature = "openai", feature = "gemini"))]
#[test]
fn parse_json_lenient_mismatched_delimiter_then_valid() {
let v = parse_json_lenient(r#"{oops] then {"a":1}"#).unwrap();
assert_eq!(v["a"], 1);
}
struct PlainMockClient;
impl crate::api::ApiClient for PlainMockClient {
fn model(&self) -> String {
"test".to_string()
}
fn stream_messages(
&self,
_request: &crate::api::StreamRequest,
) -> Pin<
Box<
dyn futures::Stream<
Item = Result<crate::stream::StreamEvent, crate::api::error::ApiError>,
> + Send
+ 'static,
>,
> {
Box::pin(futures::stream::empty())
}
fn create_message(
&self,
_request: &crate::api::StreamRequest,
) -> Pin<
Box<
dyn Future<
Output = Result<
crate::api::NonStreamingResponse,
crate::api::error::ApiError,
>,
> + Send
+ '_,
>,
> {
Box::pin(async {
Ok(crate::api::NonStreamingResponse {
message: crate::message::Message::assistant(""),
stop_reason: crate::stream::StreamStopReason::EndTurn,
usage: Some(crate::stream::Usage::default()),
})
})
}
}
#[tokio::test]
async fn default_client_rejects_response_format() {
let client = PlainMockClient;
let opts =
RequestOptions::new().with_response_format(ResponseFormat::from_type::<Action>());
let request = crate::api::StreamRequest::new(vec![]);
let result = client.create_message_with_options(&request, opts).await;
assert!(
result.is_err(),
"client without structured-output support should reject response_format"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("does not support structured output"),
"error should explain why: {err_msg}"
);
}
#[tokio::test]
async fn default_client_delegates_empty_options() {
let client = PlainMockClient;
let opts = RequestOptions::new();
let request = crate::api::StreamRequest::new(vec![]);
let result = client.create_message_with_options(&request, opts).await;
assert!(result.is_ok(), "empty options should delegate normally");
}
struct StructuredMockClient;
impl crate::api::ApiClient for StructuredMockClient {
fn model(&self) -> String {
"test".to_string()
}
fn stream_messages(
&self,
_request: &crate::api::StreamRequest,
) -> Pin<
Box<
dyn futures::Stream<
Item = Result<crate::stream::StreamEvent, crate::api::error::ApiError>,
> + Send
+ 'static,
>,
> {
Box::pin(futures::stream::empty())
}
fn create_message(
&self,
_request: &crate::api::StreamRequest,
) -> Pin<
Box<
dyn Future<
Output = Result<
crate::api::NonStreamingResponse,
crate::api::error::ApiError,
>,
> + Send
+ '_,
>,
> {
Box::pin(async {
Ok(crate::api::NonStreamingResponse {
message: crate::message::Message::assistant(""),
stop_reason: crate::stream::StreamStopReason::EndTurn,
usage: Some(crate::stream::Usage::default()),
})
})
}
fn create_message_with_options(
&self,
_request: &crate::api::StreamRequest,
_options: RequestOptions,
) -> Pin<
Box<
dyn Future<
Output = Result<
crate::api::NonStreamingResponse,
crate::api::error::ApiError,
>,
> + Send
+ '_,
>,
> {
Box::pin(async {
Ok(crate::api::NonStreamingResponse {
message: crate::message::Message::assistant(
r#"{"tool": "write", "args": {"path": "/test"}}"#,
),
stop_reason: crate::stream::StreamStopReason::EndTurn,
usage: Some(crate::stream::Usage::default()),
})
})
}
}
#[tokio::test]
async fn request_structured_end_to_end() {
let client = StructuredMockClient;
let action: Action = request_structured(&client, vec![], None)
.await
.expect("should succeed");
assert_eq!(action.tool, "write");
}
struct ProseMockClient;
impl crate::api::ApiClient for ProseMockClient {
fn model(&self) -> String {
"test".to_string()
}
fn stream_messages(
&self,
_request: &crate::api::StreamRequest,
) -> Pin<
Box<
dyn futures::Stream<
Item = Result<crate::stream::StreamEvent, crate::api::error::ApiError>,
> + Send
+ 'static,
>,
> {
Box::pin(futures::stream::empty())
}
fn create_message(
&self,
_request: &crate::api::StreamRequest,
) -> Pin<
Box<
dyn Future<
Output = Result<
crate::api::NonStreamingResponse,
crate::api::error::ApiError,
>,
> + Send
+ '_,
>,
> {
Box::pin(async {
Ok(crate::api::NonStreamingResponse {
message: crate::message::Message::assistant(""),
stop_reason: crate::stream::StreamStopReason::EndTurn,
usage: Some(crate::stream::Usage::default()),
})
})
}
fn create_message_with_options(
&self,
_request: &crate::api::StreamRequest,
_options: RequestOptions,
) -> Pin<
Box<
dyn Future<
Output = Result<
crate::api::NonStreamingResponse,
crate::api::error::ApiError,
>,
> + Send
+ '_,
>,
> {
Box::pin(async {
Ok(crate::api::NonStreamingResponse {
message: crate::message::Message::assistant("I cannot produce that."),
stop_reason: crate::stream::StreamStopReason::EndTurn,
usage: Some(crate::stream::Usage::default()),
})
})
}
}
#[tokio::test]
async fn request_structured_prose_returns_deserialize_error() {
let client = ProseMockClient;
let err = request_structured::<Action>(&client, vec![], None)
.await
.expect_err("should fail");
assert!(matches!(err, StructuredError::Deserialize(_)));
}
#[test]
fn tool_constraint_default_is_none() {
assert!(matches!(ToolConstraint::default(), ToolConstraint::None));
assert!(matches!(
RequestOptions::default().tool_constraint,
ToolConstraint::None
));
}
#[test]
fn request_options_tool_constraint_builder() {
let opts = RequestOptions::new().with_tool_constraint(ToolConstraint::Strict);
assert!(matches!(opts.tool_constraint, ToolConstraint::Strict));
let rf = ResponseFormat::from_type::<Action>();
let opts = RequestOptions::new()
.with_response_format(rf)
.with_tool_constraint(ToolConstraint::Strict);
assert!(opts.response_format.is_some());
assert!(matches!(opts.tool_constraint, ToolConstraint::Strict));
}
#[test]
fn tool_constraint_clone_compiles() {
let opts = RequestOptions::new().with_tool_constraint(ToolConstraint::Strict);
let cloned = opts.clone();
assert!(matches!(opts.tool_constraint, ToolConstraint::Strict));
assert!(matches!(cloned.tool_constraint, ToolConstraint::Strict));
}
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[test]
fn tighten_sets_additional_properties_false() {
let schema = serde_json::json!({
"type": "object",
"properties": {"a": {"type": "string"}}
});
let tightened = tighten_json_schema(&schema);
assert_eq!(tightened["additionalProperties"], false);
}
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[test]
fn tighten_enumerates_required() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"a": {"type": "string"},
"b": {"type": "number"}
}
});
let tightened = tighten_json_schema(&schema);
let required = tightened["required"].as_array().unwrap();
assert_eq!(required.len(), 2);
let keys: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
assert!(keys.contains(&"a"));
assert!(keys.contains(&"b"));
}
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[test]
fn tighten_recurses_into_nested_objects() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"inner": {
"type": "object",
"properties": {"x": {"type": "string"}}
}
}
});
let tightened = tighten_json_schema(&schema);
assert_eq!(
tightened["properties"]["inner"]["additionalProperties"],
false
);
let inner_required = tightened["properties"]["inner"]["required"]
.as_array()
.unwrap();
assert_eq!(inner_required.len(), 1);
assert_eq!(inner_required[0], "x");
}
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[test]
fn tighten_preserves_non_object_schemas() {
let schema = serde_json::json!({"type": "string"});
let tightened = tighten_json_schema(&schema);
assert_eq!(tightened, schema);
assert!(tightened.get("additionalProperties").is_none());
assert!(tightened.get("required").is_none());
}
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[test]
fn tighten_idempotent_on_already_strict() {
let schema = serde_json::json!({
"type": "object",
"additionalProperties": false,
"properties": {"a": {"type": "string"}},
"required": ["a"]
});
let once = tighten_json_schema(&schema);
let twice = tighten_json_schema(&once);
assert_eq!(once, twice);
}
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[test]
fn tighten_object_without_properties() {
let schema = serde_json::json!({"type": "object"});
let tightened = tighten_json_schema(&schema);
assert_eq!(tightened["additionalProperties"], false);
assert_eq!(tightened["required"].as_array().unwrap().len(), 0);
}
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[test]
fn tighten_recurses_into_local_defs() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"filter": {"$ref": "#/$defs/Filter"}
},
"$defs": {
"Filter": {
"type": "object",
"properties": {
"lang": {"type": "string"},
"limit": {"type": "number"}
}
}
}
});
let tightened = tighten_json_schema(&schema);
assert_eq!(tightened["additionalProperties"], false);
assert_eq!(tightened["required"], serde_json::json!(["filter"]));
let filter = &tightened["$defs"]["Filter"];
assert_eq!(filter["additionalProperties"], false);
let required = filter["required"].as_array().unwrap();
assert_eq!(required.len(), 2);
let keys: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
assert!(keys.contains(&"lang"));
assert!(keys.contains(&"limit"));
assert_eq!(tightened["properties"]["filter"]["$ref"], "#/$defs/Filter");
}
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[cfg(any(
feature = "anthropic",
feature = "grammar",
feature = "openai",
feature = "gemini"
))]
#[test]
fn tighten_recurses_into_legacy_definitions() {
let schema = serde_json::json!({
"type": "object",
"properties": {"x": {"$ref": "#/definitions/X"}},
"definitions": {
"X": {
"type": "object",
"properties": {"a": {"type": "string"}}
}
}
});
let tightened = tighten_json_schema(&schema);
let def = &tightened["definitions"]["X"];
assert_eq!(def["additionalProperties"], false);
assert_eq!(def["required"].as_array().unwrap().len(), 1);
}
}