use super::{LlmClient, Message, StreamEvent, TokenUsage, ToolDefinition};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio_util::sync::CancellationToken;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StructuredMode {
Auto,
Strict,
Json,
Tool,
Prompt,
}
#[derive(Debug, Clone)]
pub struct StructuredRequest {
pub prompt: String,
pub system: Option<String>,
pub schema: Value,
pub schema_name: String,
pub schema_description: Option<String>,
pub mode: StructuredMode,
pub max_repair_attempts: u8,
}
#[derive(Debug, Clone, Serialize)]
pub struct StructuredResult {
pub object: Value,
pub raw_text: Option<String>,
pub usage: TokenUsage,
pub repair_rounds: u8,
pub mode_used: StructuredMode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NativeStructuredSupport {
None,
ForcedTool,
JsonSchema,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ResponseFormat {
JsonObject,
JsonSchema { name: String, schema: Value },
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct StructuredDirective {
pub force_tool: Option<String>,
pub response_format: Option<ResponseFormat>,
}
pub type PartialObjectCallback = Box<dyn Fn(&Value) + Send>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SchemaEnvelope {
Direct,
Elements,
Value,
}
impl SchemaEnvelope {
fn for_schema(schema: &Value) -> Self {
if schema_is_object_like(schema) {
Self::Direct
} else if schema.get("type").and_then(Value::as_str) == Some("array") {
Self::Elements
} else {
Self::Value
}
}
fn response_schema(self, schema: &Value) -> Value {
match self {
Self::Direct => schema.clone(),
Self::Elements => serde_json::json!({
"type": "object",
"required": ["elements"],
"additionalProperties": false,
"properties": {
"elements": schema
}
}),
Self::Value => serde_json::json!({
"type": "object",
"required": ["value"],
"additionalProperties": false,
"properties": {
"value": schema
}
}),
}
}
fn unwrap_final(self, value: &Value) -> Option<Value> {
match self {
Self::Direct => Some(value.clone()),
Self::Elements => value.get("elements").cloned(),
Self::Value => value.get("value").cloned(),
}
}
fn project_partial(self, value: &Value, repaired: bool) -> Option<Value> {
match self {
Self::Direct => Some(value.clone()),
Self::Elements => {
let mut elements = value.get("elements")?.as_array()?.clone();
if repaired && !elements.is_empty() {
elements.pop();
}
Some(Value::Array(elements))
}
Self::Value => value.get("value").cloned(),
}
}
fn instruction(self) -> &'static str {
match self {
Self::Direct => "",
Self::Elements => {
"The provider-facing response schema wraps the requested array in an `elements` field. Follow that schema exactly; callers receive the unwrapped array."
}
Self::Value => {
"The provider-facing response schema wraps the requested scalar/enum value in a `value` field. Follow that schema exactly; callers receive the unwrapped value."
}
}
}
}
fn schema_is_object_like(schema: &Value) -> bool {
schema.get("type").and_then(Value::as_str) == Some("object")
|| schema.get("properties").is_some()
|| schema.get("required").is_some()
|| schema.get("additionalProperties").is_some()
}
pub async fn generate_blocking(
client: &dyn LlmClient,
req: &StructuredRequest,
) -> Result<StructuredResult> {
let mode = resolve_mode(req.mode, client.native_structured_support());
let envelope = SchemaEnvelope::for_schema(&req.schema);
let mut messages = build_initial_messages(req, mode);
let system = build_system_prompt(req, mode);
let tools = build_tools(req, mode);
let directive = build_directive(req, mode);
let mut total_usage = TokenUsage::default();
let mut repair_rounds: u8 = 0;
loop {
let resp = client
.complete_structured(&messages, Some(&system), &tools, &directive)
.await
.context("LLM call failed during structured generation")?;
accumulate_usage(&mut total_usage, &resp.usage);
let candidates = extract_raw_candidates(&resp.message, mode);
let resolution = resolve_structured(&candidates, &req.schema, envelope);
if let Some((value, raw)) = resolution.valid {
return Ok(StructuredResult {
object: value,
raw_text: Some(raw),
usage: total_usage,
repair_rounds,
mode_used: mode,
});
}
if repair_rounds >= req.max_repair_attempts {
return Err(match resolution.invalid {
Some((_, errors)) => anyhow::anyhow!(
"Structured output failed schema validation after {} repair attempts. Errors: {}",
repair_rounds,
errors.join("; ")
),
None => anyhow::anyhow!(
"Structured output parsing failed after {} repair attempts: no JSON object found in tool call, text content, or reasoning channel",
repair_rounds
),
});
}
repair_rounds += 1;
let (repair_msg, raw_for_ctx) = match resolution.invalid {
Some((raw, errors)) => (build_repair_message(&raw, &errors), raw),
None => {
let raw = resolution.raw_seen.unwrap_or_default();
(build_parse_failure_repair(&raw), raw)
}
};
append_repair_context(
&mut messages,
&resp.message,
&repair_msg,
mode,
&raw_for_ctx,
);
}
}
pub async fn generate_streaming(
client: &dyn LlmClient,
req: &StructuredRequest,
on_partial: PartialObjectCallback,
) -> Result<StructuredResult> {
let mode = resolve_mode(req.mode, client.native_structured_support());
let envelope = SchemaEnvelope::for_schema(&req.schema);
let messages = build_initial_messages(req, mode);
let system = build_system_prompt(req, mode);
let tools = build_tools(req, mode);
let directive = build_directive(req, mode);
let cancel_token = CancellationToken::new();
let mut rx = client
.complete_streaming_structured(&messages, Some(&system), &tools, &directive, cancel_token)
.await
.context("LLM streaming call failed during structured generation")?;
let mut json_buffer = String::new();
let mut last_valid_partial: Option<Value> = None;
let mut final_response: Option<super::LlmResponse> = None;
let mut last_parse_len: usize = 0;
const PARSE_THRESHOLD: usize = 8;
while let Some(event) = rx.recv().await {
match event {
StreamEvent::ToolUseInputDelta(delta) if mode == StructuredMode::Tool => {
if final_response.is_some() {
continue;
}
json_buffer.push_str(&delta);
if json_buffer.len() - last_parse_len >= PARSE_THRESHOLD {
if let Some(partial) = parse_partial_json(&json_buffer) {
if let Some(projected) =
envelope.project_partial(&partial.value, partial.repaired)
{
if last_valid_partial.as_ref() != Some(&projected) {
on_partial(&projected);
last_valid_partial = Some(projected);
}
}
}
last_parse_len = json_buffer.len();
}
}
StreamEvent::TextDelta(delta) if mode != StructuredMode::Tool => {
if final_response.is_some() {
continue;
}
json_buffer.push_str(&delta);
if json_buffer.len() - last_parse_len >= PARSE_THRESHOLD {
if let Some(json_start) = find_json_start(&json_buffer) {
let candidate = &json_buffer[json_start..];
if let Some(partial) = parse_partial_json(candidate) {
if let Some(projected) =
envelope.project_partial(&partial.value, partial.repaired)
{
if last_valid_partial.as_ref() != Some(&projected) {
on_partial(&projected);
last_valid_partial = Some(projected);
}
}
}
}
last_parse_len = json_buffer.len();
}
}
StreamEvent::Done(resp) => {
final_response = Some(resp);
}
_ => {}
}
}
let resp = final_response.context("Stream ended without Done event")?;
let candidates = extract_raw_candidates(&resp.message, mode);
let resolution = resolve_structured(&candidates, &req.schema, envelope);
let (value, raw_text) = match resolution.valid {
Some(vr) => vr,
None => {
return Err(match resolution.invalid {
Some((_, errors)) => anyhow::anyhow!(
"Streamed structured output failed schema validation: {}",
errors.join("; ")
),
None => anyhow::anyhow!(
"Streamed output produced no parseable JSON object (checked tool call, text content, and reasoning channel)"
),
});
}
};
on_partial(&value);
Ok(StructuredResult {
object: value,
raw_text: Some(raw_text),
usage: resp.usage,
repair_rounds: 0,
mode_used: mode,
})
}
pub fn extract_json_value(text: &str) -> Result<Value> {
let trimmed = text.trim();
if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
if v.is_object() || v.is_array() {
return Ok(v);
}
}
if let Some(inner) = strip_code_fence(trimmed) {
if let Ok(v) = serde_json::from_str::<Value>(inner.trim()) {
if v.is_object() || v.is_array() {
return Ok(v);
}
}
}
if let Some(candidate) = find_balanced_json_object(trimmed) {
if let Ok(v) = serde_json::from_str::<Value>(candidate) {
return Ok(v);
}
}
if let Some(candidate) = find_balanced_json_array(trimmed) {
if let Ok(v) = serde_json::from_str::<Value>(candidate) {
return Ok(v);
}
}
bail!("No valid JSON object found in LLM output")
}
fn strip_code_fence(text: &str) -> Option<&str> {
let start_patterns = ["```json\n", "```json\r\n", "```\n", "```\r\n"];
for pat in &start_patterns {
if let Some(rest) = text.strip_prefix(pat) {
if let Some(end) = rest.rfind("```") {
return Some(&rest[..end]);
}
}
}
if let Some(inner) = text.strip_prefix("```json") {
if let Some(end) = inner.rfind("```") {
return Some(inner[..end].trim());
}
}
if let Some(inner) = text.strip_prefix("```") {
if let Some(end) = inner.rfind("```") {
return Some(inner[..end].trim());
}
}
None
}
fn find_balanced_json_object(text: &str) -> Option<&str> {
find_balanced(text, '{', '}')
}
fn find_balanced_json_array(text: &str) -> Option<&str> {
find_balanced(text, '[', ']')
}
fn find_balanced(text: &str, open: char, close: char) -> Option<&str> {
find_balanced_range(text, open, close).map(|(start, end)| &text[start..end])
}
fn find_balanced_range(text: &str, open: char, close: char) -> Option<(usize, usize)> {
let bytes = text.as_bytes();
let open_byte = open as u8;
let close_byte = close as u8;
let mut in_string = false;
let mut escape_next = false;
let mut start = None;
for (i, &b) in bytes.iter().enumerate() {
if escape_next {
escape_next = false;
continue;
}
match b {
b'\\' if in_string => escape_next = true,
b'"' => in_string = !in_string,
_ if in_string => {}
_ if b == open_byte => {
start = Some(i);
break;
}
_ => {}
}
}
let start = start?;
let mut depth = 0i32;
in_string = false;
escape_next = false;
for (i, &b) in bytes[start..].iter().enumerate() {
if escape_next {
escape_next = false;
continue;
}
match b {
b'\\' if in_string => escape_next = true,
b'"' => in_string = !in_string,
_ if in_string => {}
_ if b == open_byte => depth += 1,
_ if b == close_byte => {
depth -= 1;
if depth == 0 {
return Some((start, start + i + 1));
}
}
_ => {}
}
}
None
}
fn find_all_balanced(text: &str, open: char, close: char) -> Vec<String> {
let mut out = Vec::new();
let mut base = 0usize;
while base < text.len() {
match find_balanced_range(&text[base..], open, close) {
Some((start, end)) => {
out.push(text[base + start..base + end].to_string());
base += end;
}
None => break,
}
}
out
}
fn find_json_start(text: &str) -> Option<usize> {
let (search_text, offset) = if let Some(rest) = text.strip_prefix("```json") {
(rest, 7)
} else if let Some(rest) = text.strip_prefix("```") {
(rest, 3)
} else {
(text, 0)
};
let mut in_string = false;
let mut escape_next = false;
for (i, &b) in search_text.as_bytes().iter().enumerate() {
if escape_next {
escape_next = false;
continue;
}
match b {
b'\\' if in_string => {
escape_next = true;
}
b'"' => {
in_string = !in_string;
}
b'{' | b'[' if !in_string => {
return Some(offset + i);
}
_ => {}
}
}
None
}
#[cfg(test)]
fn try_parse_partial_json(text: &str) -> Option<Value> {
parse_partial_json(text).map(|parsed| parsed.value)
}
#[derive(Debug, Clone, PartialEq)]
struct PartialJsonValue {
value: Value,
repaired: bool,
}
fn parse_partial_json(text: &str) -> Option<PartialJsonValue> {
let trimmed = text.trim();
if trimmed.is_empty() {
return None;
}
if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
if v.is_object() || v.is_array() {
return Some(PartialJsonValue {
value: v,
repaired: false,
});
}
}
let repaired = fix_partial_json(trimmed);
if repaired.trim().is_empty() || repaired == trimmed {
return None;
}
serde_json::from_str::<Value>(&repaired)
.ok()
.filter(|v| v.is_object() || v.is_array())
.map(|value| PartialJsonValue {
value,
repaired: true,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PartialJsonState {
Root,
Finish,
InsideString,
InsideStringEscape,
InsideStringUnicodeEscape,
InsideLiteral,
InsideNumber,
InsideObjectStart,
InsideObjectKey,
InsideObjectAfterKey,
InsideObjectBeforeValue,
InsideObjectAfterValue,
InsideObjectAfterComma,
InsideArrayStart,
InsideArrayAfterValue,
InsideArrayAfterComma,
}
fn is_json_hex_digit(ch: char) -> bool {
ch.is_ascii_hexdigit()
}
fn process_partial_value_start(
ch: char,
end: usize,
swap_state: PartialJsonState,
stack: &mut Vec<PartialJsonState>,
last_valid_end: &mut usize,
literal_start: &mut Option<usize>,
start: usize,
) {
match ch {
'"' => {
*last_valid_end = end;
stack.pop();
stack.push(swap_state);
stack.push(PartialJsonState::InsideString);
}
'f' | 't' | 'n' => {
*last_valid_end = end;
*literal_start = Some(start);
stack.pop();
stack.push(swap_state);
stack.push(PartialJsonState::InsideLiteral);
}
'-' => {
stack.pop();
stack.push(swap_state);
stack.push(PartialJsonState::InsideNumber);
}
'0'..='9' => {
*last_valid_end = end;
stack.pop();
stack.push(swap_state);
stack.push(PartialJsonState::InsideNumber);
}
'{' => {
*last_valid_end = end;
stack.pop();
stack.push(swap_state);
stack.push(PartialJsonState::InsideObjectStart);
}
'[' => {
*last_valid_end = end;
stack.pop();
stack.push(swap_state);
stack.push(PartialJsonState::InsideArrayStart);
}
_ => {}
}
}
fn process_after_partial_object_value(
ch: char,
end: usize,
stack: &mut Vec<PartialJsonState>,
last_valid_end: &mut usize,
) {
match ch {
',' => {
stack.pop();
stack.push(PartialJsonState::InsideObjectAfterComma);
}
'}' => {
*last_valid_end = end;
stack.pop();
}
_ => {}
}
}
fn process_after_partial_array_value(
ch: char,
end: usize,
stack: &mut Vec<PartialJsonState>,
last_valid_end: &mut usize,
) {
match ch {
',' => {
stack.pop();
stack.push(PartialJsonState::InsideArrayAfterComma);
}
']' => {
*last_valid_end = end;
stack.pop();
}
_ => {}
}
}
fn fix_partial_json(input: &str) -> String {
use PartialJsonState::*;
let mut stack = vec![Root];
let mut last_valid_end = 0usize;
let mut literal_start: Option<usize> = None;
let mut unicode_escape_digits = 0usize;
for (start, ch) in input.char_indices() {
let end = start + ch.len_utf8();
let current_state = *stack.last().unwrap_or(&Finish);
match current_state {
Root => {
process_partial_value_start(
ch,
end,
Finish,
&mut stack,
&mut last_valid_end,
&mut literal_start,
start,
);
}
InsideObjectStart => match ch {
'"' => {
stack.pop();
stack.push(InsideObjectKey);
}
'}' => {
last_valid_end = end;
stack.pop();
}
_ => {}
},
InsideObjectAfterComma => {
if ch == '"' {
stack.pop();
stack.push(InsideObjectKey);
}
}
InsideObjectKey => {
if ch == '"' {
stack.pop();
stack.push(InsideObjectAfterKey);
}
}
InsideObjectAfterKey => {
if ch == ':' {
stack.pop();
stack.push(InsideObjectBeforeValue);
}
}
InsideObjectBeforeValue => {
process_partial_value_start(
ch,
end,
InsideObjectAfterValue,
&mut stack,
&mut last_valid_end,
&mut literal_start,
start,
);
}
InsideObjectAfterValue => {
process_after_partial_object_value(ch, end, &mut stack, &mut last_valid_end);
}
InsideString => match ch {
'"' => {
stack.pop();
last_valid_end = end;
}
'\\' => {
stack.push(InsideStringEscape);
}
_ => {
last_valid_end = end;
}
},
InsideArrayStart => match ch {
']' => {
last_valid_end = end;
stack.pop();
}
_ => {
last_valid_end = end;
process_partial_value_start(
ch,
end,
InsideArrayAfterValue,
&mut stack,
&mut last_valid_end,
&mut literal_start,
start,
);
}
},
InsideArrayAfterValue => match ch {
',' => {
stack.pop();
stack.push(InsideArrayAfterComma);
}
']' => {
last_valid_end = end;
stack.pop();
}
_ => {
last_valid_end = end;
}
},
InsideArrayAfterComma => {
process_partial_value_start(
ch,
end,
InsideArrayAfterValue,
&mut stack,
&mut last_valid_end,
&mut literal_start,
start,
);
}
InsideStringEscape => {
stack.pop();
if ch == 'u' {
unicode_escape_digits = 0;
stack.push(InsideStringUnicodeEscape);
} else {
last_valid_end = end;
}
}
InsideStringUnicodeEscape => {
if is_json_hex_digit(ch) {
unicode_escape_digits += 1;
if unicode_escape_digits == 4 {
stack.pop();
last_valid_end = end;
}
}
}
InsideNumber => match ch {
'0'..='9' => {
last_valid_end = end;
}
'e' | 'E' | '-' | '.' => {}
',' => {
stack.pop();
if stack.last() == Some(&InsideArrayAfterValue) {
process_after_partial_array_value(ch, end, &mut stack, &mut last_valid_end);
}
if stack.last() == Some(&InsideObjectAfterValue) {
process_after_partial_object_value(
ch,
end,
&mut stack,
&mut last_valid_end,
);
}
}
'}' => {
stack.pop();
if stack.last() == Some(&InsideObjectAfterValue) {
process_after_partial_object_value(
ch,
end,
&mut stack,
&mut last_valid_end,
);
}
}
']' => {
stack.pop();
if stack.last() == Some(&InsideArrayAfterValue) {
process_after_partial_array_value(ch, end, &mut stack, &mut last_valid_end);
}
}
_ => {
stack.pop();
}
},
InsideLiteral => {
let partial_literal = literal_start
.and_then(|s| input.get(s..end))
.unwrap_or_default();
if !("false".starts_with(partial_literal)
|| "true".starts_with(partial_literal)
|| "null".starts_with(partial_literal))
{
stack.pop();
if stack.last() == Some(&InsideObjectAfterValue) {
process_after_partial_object_value(
ch,
end,
&mut stack,
&mut last_valid_end,
);
} else if stack.last() == Some(&InsideArrayAfterValue) {
process_after_partial_array_value(ch, end, &mut stack, &mut last_valid_end);
}
} else {
last_valid_end = end;
}
}
Finish => {}
}
}
let mut result = input[..last_valid_end].to_string();
for state in stack.iter().rev() {
match state {
InsideString => result.push('"'),
InsideObjectKey
| InsideObjectAfterKey
| InsideObjectAfterComma
| InsideObjectStart
| InsideObjectBeforeValue
| InsideObjectAfterValue => result.push('}'),
InsideArrayStart | InsideArrayAfterComma | InsideArrayAfterValue => result.push(']'),
InsideLiteral => {
let partial_literal = literal_start
.and_then(|s| input.get(s..input.len()))
.unwrap_or_default();
if "true".starts_with(partial_literal) {
result.push_str(&"true"[partial_literal.len()..]);
} else if "false".starts_with(partial_literal) {
result.push_str(&"false"[partial_literal.len()..]);
} else if "null".starts_with(partial_literal) {
result.push_str(&"null"[partial_literal.len()..]);
}
}
_ => {}
}
}
result
}
fn validate_against_schema(value: &Value, schema: &Value) -> Result<(), Vec<String>> {
let errors = basic_schema_validate(value, schema, "");
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
fn basic_schema_validate(value: &Value, schema: &Value, path: &str) -> Vec<String> {
let mut errors = Vec::new();
if schema.get("$ref").is_some() {
return errors;
}
if let Some(any_of) = schema
.get("anyOf")
.or_else(|| schema.get("oneOf"))
.and_then(|v| v.as_array())
{
let matched = any_of
.iter()
.any(|sub| basic_schema_validate(value, sub, path).is_empty());
if !matched {
errors.push(format!(
"{}: value does not match any variant in anyOf/oneOf",
path_or_root(path),
));
}
return errors;
}
if let Some(enum_values) = schema.get("enum").and_then(|v| v.as_array()) {
if !enum_values.contains(value) {
errors.push(format!(
"{}: value {:?} not in enum {:?}",
path_or_root(path),
value,
enum_values
));
}
return errors;
}
if let Some(const_val) = schema.get("const") {
if value != const_val {
errors.push(format!(
"{}: expected const {:?}, got {:?}",
path_or_root(path),
const_val,
value
));
}
return errors;
}
if let Some(type_val) = schema.get("type") {
let type_ok = if let Some(type_str) = type_val.as_str() {
check_type(value, type_str)
} else if let Some(type_arr) = type_val.as_array() {
type_arr
.iter()
.filter_map(|t| t.as_str())
.any(|t| check_type(value, t))
} else {
true
};
if !type_ok {
errors.push(format!(
"{}: expected type {:?}, got {:?}",
path_or_root(path),
type_val,
value_type_name(value)
));
return errors;
}
}
if let Some(obj) = value.as_object() {
if let Some(properties) = schema.get("properties").and_then(|v| v.as_object()) {
for (key, prop_schema) in properties {
if let Some(child_value) = obj.get(key) {
let child_path = if path.is_empty() {
format!(".{}", key)
} else {
format!("{}.{}", path, key)
};
errors.extend(basic_schema_validate(child_value, prop_schema, &child_path));
}
}
}
if let Some(required) = schema.get("required").and_then(|v| v.as_array()) {
for req_field in required {
if let Some(field_name) = req_field.as_str() {
if !obj.contains_key(field_name) {
errors.push(format!(
"{}: missing required field '{}'",
path_or_root(path),
field_name
));
}
}
}
}
if schema.get("additionalProperties") == Some(&Value::Bool(false)) {
if let Some(properties) = schema.get("properties").and_then(|v| v.as_object()) {
for key in obj.keys() {
if !properties.contains_key(key) {
errors.push(format!(
"{}: unexpected additional property '{}'",
path_or_root(path),
key
));
}
}
}
}
}
if let Some(arr) = value.as_array() {
if let Some(items_schema) = schema.get("items") {
for (i, item) in arr.iter().enumerate() {
let child_path = format!("{}[{}]", path, i);
errors.extend(basic_schema_validate(item, items_schema, &child_path));
}
}
if let Some(min) = schema.get("minItems").and_then(|v| v.as_u64()) {
if (arr.len() as u64) < min {
errors.push(format!(
"{}: array has {} items, minimum is {}",
path_or_root(path),
arr.len(),
min
));
}
}
if let Some(max) = schema.get("maxItems").and_then(|v| v.as_u64()) {
if (arr.len() as u64) > max {
errors.push(format!(
"{}: array has {} items, maximum is {}",
path_or_root(path),
arr.len(),
max
));
}
}
}
if let Some(s) = value.as_str() {
if let Some(min_len) = schema.get("minLength").and_then(|v| v.as_u64()) {
if (s.chars().count() as u64) < min_len {
errors.push(format!(
"{}: string length {} < minLength {}",
path_or_root(path),
s.chars().count(),
min_len
));
}
}
if let Some(max_len) = schema.get("maxLength").and_then(|v| v.as_u64()) {
if (s.chars().count() as u64) > max_len {
errors.push(format!(
"{}: string length {} > maxLength {}",
path_or_root(path),
s.chars().count(),
max_len
));
}
}
if let Some(pattern) = schema.get("pattern").and_then(|v| v.as_str()) {
if let Ok(re) = regex::Regex::new(pattern) {
if !re.is_match(s) {
errors.push(format!(
"{}: string does not match pattern '{}'",
path_or_root(path),
pattern
));
}
}
}
}
if let Some(n) = value.as_f64() {
if let Some(min) = schema.get("minimum").and_then(|v| v.as_f64()) {
if n < min {
errors.push(format!(
"{}: value {} < minimum {}",
path_or_root(path),
n,
min
));
}
}
if let Some(max) = schema.get("maximum").and_then(|v| v.as_f64()) {
if n > max {
errors.push(format!(
"{}: value {} > maximum {}",
path_or_root(path),
n,
max
));
}
}
if let Some(exc_min) = schema.get("exclusiveMinimum").and_then(|v| v.as_f64()) {
if n <= exc_min {
errors.push(format!(
"{}: value {} <= exclusiveMinimum {}",
path_or_root(path),
n,
exc_min
));
}
}
if let Some(exc_max) = schema.get("exclusiveMaximum").and_then(|v| v.as_f64()) {
if n >= exc_max {
errors.push(format!(
"{}: value {} >= exclusiveMaximum {}",
path_or_root(path),
n,
exc_max
));
}
}
}
errors
}
fn check_type(value: &Value, type_str: &str) -> bool {
match type_str {
"object" => value.is_object(),
"array" => value.is_array(),
"string" => value.is_string(),
"number" => value.is_number(),
"integer" => {
value.is_i64()
|| value.is_u64()
|| value
.as_f64()
.map(|f| f.fract() == 0.0 && f.is_finite())
.unwrap_or(false)
}
"boolean" => value.is_boolean(),
"null" => value.is_null(),
_ => true,
}
}
fn path_or_root(path: &str) -> &str {
if path.is_empty() {
"$"
} else {
path
}
}
fn value_type_name(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
fn resolve_mode(requested: StructuredMode, support: NativeStructuredSupport) -> StructuredMode {
match (requested, support) {
(StructuredMode::Prompt, _) => StructuredMode::Prompt,
(StructuredMode::Strict, NativeStructuredSupport::JsonSchema) => StructuredMode::Strict,
(StructuredMode::Json, NativeStructuredSupport::JsonSchema) => StructuredMode::Json,
(StructuredMode::Auto | StructuredMode::Tool, NativeStructuredSupport::JsonSchema) => {
StructuredMode::Tool
}
(
StructuredMode::Auto
| StructuredMode::Tool
| StructuredMode::Strict
| StructuredMode::Json,
NativeStructuredSupport::ForcedTool,
) => StructuredMode::Tool,
(
StructuredMode::Auto
| StructuredMode::Tool
| StructuredMode::Strict
| StructuredMode::Json,
NativeStructuredSupport::None,
) => StructuredMode::Prompt,
}
}
fn build_directive(req: &StructuredRequest, mode: StructuredMode) -> StructuredDirective {
match mode {
StructuredMode::Tool => StructuredDirective {
force_tool: Some(format!("emit_{}", req.schema_name)),
response_format: None,
},
StructuredMode::Strict => StructuredDirective {
force_tool: None,
response_format: Some(ResponseFormat::JsonSchema {
name: req.schema_name.clone(),
schema: SchemaEnvelope::for_schema(&req.schema).response_schema(&req.schema),
}),
},
StructuredMode::Json => StructuredDirective {
force_tool: None,
response_format: Some(ResponseFormat::JsonObject),
},
StructuredMode::Auto | StructuredMode::Prompt => StructuredDirective::default(),
}
}
fn build_initial_messages(req: &StructuredRequest, mode: StructuredMode) -> Vec<Message> {
let envelope = SchemaEnvelope::for_schema(&req.schema);
let response_schema = envelope.response_schema(&req.schema);
let envelope_instruction = envelope.instruction();
match mode {
StructuredMode::Tool => {
vec![Message::user(&req.prompt)]
}
StructuredMode::Prompt | StructuredMode::Json => {
let augmented = format!(
"{}\n\n{}{}\n\nYou MUST respond with ONLY a valid JSON object (no markdown, no explanation) that conforms to this JSON Schema:\n\n```json\n{}\n```",
req.prompt,
envelope_instruction,
if envelope_instruction.is_empty() { "" } else { "\n" },
serde_json::to_string_pretty(&response_schema).unwrap_or_default()
);
vec![Message::user(&augmented)]
}
_ => {
vec![Message::user(&req.prompt)]
}
}
}
fn build_system_prompt(req: &StructuredRequest, mode: StructuredMode) -> String {
let base = req.system.as_deref().unwrap_or("");
let envelope_instruction = SchemaEnvelope::for_schema(&req.schema).instruction();
match mode {
StructuredMode::Tool => {
format!(
"{}{}You MUST respond by calling the `emit_{}` tool exactly once with a valid argument matching the schema. Do not output any text outside the tool call.{}{}",
base,
if base.is_empty() { "" } else { "\n\n" },
req.schema_name,
if envelope_instruction.is_empty() { "" } else { "\n\n" },
envelope_instruction
)
}
StructuredMode::Prompt | StructuredMode::Json => {
format!(
"{}{}You are a structured data extraction assistant. Always respond with valid JSON only, no markdown fences, no explanation text.{}{}",
base,
if base.is_empty() { "" } else { "\n\n" },
if envelope_instruction.is_empty() { "" } else { "\n\n" },
envelope_instruction,
)
}
_ => base.to_string(),
}
}
fn build_tools(req: &StructuredRequest, mode: StructuredMode) -> Vec<ToolDefinition> {
match mode {
StructuredMode::Tool => {
vec![ToolDefinition {
name: format!("emit_{}", req.schema_name),
description: req
.schema_description
.clone()
.unwrap_or_else(|| format!("Emit a structured {} object", req.schema_name)),
parameters: SchemaEnvelope::for_schema(&req.schema).response_schema(&req.schema),
}]
}
_ => vec![],
}
}
struct StructuredResolution {
valid: Option<(Value, String)>,
invalid: Option<(String, Vec<String>)>,
raw_seen: Option<String>,
}
fn push_candidate(out: &mut Vec<String>, s: String) {
let trimmed = s.trim();
if !trimmed.is_empty() && !out.iter().any(|c| c == trimmed) {
out.push(trimmed.to_string());
}
}
fn extract_raw_candidates(message: &super::Message, mode: StructuredMode) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
if mode == StructuredMode::Tool {
if let Some(call) = message.tool_calls().first() {
push_candidate(
&mut out,
serde_json::to_string(&call.args).unwrap_or_default(),
);
}
}
push_candidate(&mut out, message.text());
if let Some(reasoning) = message.reasoning_content.as_deref() {
push_candidate(&mut out, reasoning.to_string());
}
out
}
#[cfg(test)]
fn extract_all_json_values(text: &str) -> Vec<Value> {
extract_json_candidates(text, false)
}
fn extract_json_candidates(text: &str, include_direct_scalars: bool) -> Vec<Value> {
let trimmed = text.trim();
let mut values: Vec<Value> = Vec::new();
let consider = |candidate: &str, values: &mut Vec<Value>, allow_scalar: bool| {
if let Ok(v) = serde_json::from_str::<Value>(candidate.trim()) {
if (v.is_object() || v.is_array() || allow_scalar) && !values.contains(&v) {
values.push(v);
}
}
};
consider(trimmed, &mut values, include_direct_scalars);
if let Some(inner) = strip_code_fence(trimmed) {
consider(inner, &mut values, include_direct_scalars);
}
for candidate in find_all_balanced(trimmed, '{', '}') {
consider(&candidate, &mut values, false);
}
for candidate in find_all_balanced(trimmed, '[', ']') {
consider(&candidate, &mut values, false);
}
values
}
fn resolve_structured(
candidates: &[String],
schema: &Value,
envelope: SchemaEnvelope,
) -> StructuredResolution {
let mut invalid: Option<(String, Vec<String>)> = None;
let mut raw_seen: Option<String> = None;
let response_schema = envelope.response_schema(schema);
for raw in candidates {
if raw_seen.is_none() && !raw.trim().is_empty() {
raw_seen = Some(raw.clone());
}
for value in extract_json_candidates(raw, envelope == SchemaEnvelope::Value) {
match validate_against_schema(&value, schema) {
Ok(()) => {
return StructuredResolution {
valid: Some((value, raw.clone())),
invalid,
raw_seen,
};
}
Err(errors) => {
if invalid.is_none() {
invalid = Some((raw.clone(), errors));
}
}
}
if envelope != SchemaEnvelope::Direct {
match validate_against_schema(&value, &response_schema) {
Ok(()) => {
if let Some(unwrapped) = envelope.unwrap_final(&value) {
match validate_against_schema(&unwrapped, schema) {
Ok(()) => {
return StructuredResolution {
valid: Some((unwrapped, raw.clone())),
invalid,
raw_seen,
};
}
Err(errors) => {
if invalid.is_none() {
invalid = Some((raw.clone(), errors));
}
}
}
} else if invalid.is_none() {
invalid = Some((
raw.clone(),
vec!["$: response envelope was missing the expected value field"
.to_string()],
));
}
}
Err(errors) => {
if invalid.is_none() {
invalid = Some((raw.clone(), errors));
}
}
}
}
}
}
StructuredResolution {
valid: None,
invalid,
raw_seen,
}
}
fn truncate_utf8(s: &str, max: usize) -> &str {
if s.len() <= max {
return s;
}
let mut end = max;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
fn build_parse_failure_repair(raw_text: &str) -> String {
if raw_text.trim().is_empty() {
return "Your previous response contained no JSON. Respond with ONLY a single valid JSON object that matches the schema — no prose, no markdown, no analysis, and put the object in your reply content (not in a thinking/reasoning aside).".to_string();
}
format!(
"Your previous output could not be parsed as a JSON object:\n\n{}\n\nReturn ONLY a single valid JSON object matching the schema — no prose, no markdown.",
truncate_utf8(raw_text, 2000)
)
}
fn build_repair_message(raw_text: &str, errors: &[String]) -> String {
let truncated_raw = if raw_text.len() > 2000 {
format!(
"{}...[truncated, {} bytes total]",
truncate_utf8(raw_text, 2000),
raw_text.len()
)
} else {
raw_text.to_string()
};
format!(
"Your previous output failed schema validation:\n\n{}\n\nValidation errors:\n{}\n\nPlease return ONLY a corrected JSON object that fixes these errors. No explanation, no markdown.",
truncated_raw,
errors.iter().map(|e| format!("- {}", e)).collect::<Vec<_>>().join("\n")
)
}
fn accumulate_usage(total: &mut TokenUsage, delta: &TokenUsage) {
total.prompt_tokens += delta.prompt_tokens;
total.completion_tokens += delta.completion_tokens;
total.total_tokens += delta.total_tokens;
}
fn append_repair_context(
messages: &mut Vec<Message>,
assistant_msg: &Message,
repair_text: &str,
mode: StructuredMode,
_raw_text: &str,
) {
if mode == StructuredMode::Tool {
messages.push(assistant_msg.clone());
let tool_use_id = assistant_msg
.tool_calls()
.first()
.map(|tc| tc.id.clone())
.unwrap_or_else(|| "unknown".to_string());
messages.push(Message::tool_result(&tool_use_id, repair_text, true));
} else {
messages.push(assistant_msg.clone());
messages.push(Message::user(repair_text));
}
}
#[cfg(test)]
#[path = "structured_tests.rs"]
mod structured_tests;