1use super::{LlmClient, Message, StreamEvent, TokenUsage, ToolDefinition};
9use anyhow::{bail, Context, Result};
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use tokio_util::sync::CancellationToken;
13
14mod partial_json;
15use partial_json::parse_partial_json;
16#[cfg(test)]
17use partial_json::try_parse_partial_json;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum StructuredMode {
27 Auto,
29 Strict,
31 Json,
33 Tool,
36 Prompt,
38}
39
40#[derive(Debug, Clone)]
42pub struct StructuredRequest {
43 pub prompt: String,
44 pub system: Option<String>,
45 pub schema: Value,
46 pub schema_name: String,
47 pub schema_description: Option<String>,
48 pub mode: StructuredMode,
49 pub max_repair_attempts: u8,
50}
51
52#[derive(Debug, Clone, Serialize)]
54pub struct StructuredResult {
55 pub object: Value,
56 pub raw_text: Option<String>,
57 pub usage: TokenUsage,
58 pub repair_rounds: u8,
59 pub mode_used: StructuredMode,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum NativeStructuredSupport {
69 None,
71 ForcedTool,
75 JsonSchema,
78}
79
80#[derive(Debug, Clone, PartialEq)]
82pub enum ResponseFormat {
83 JsonObject,
86 JsonSchema { name: String, schema: Value },
89}
90
91#[derive(Debug, Clone, Default, PartialEq)]
99pub struct StructuredDirective {
100 pub force_tool: Option<String>,
102 pub response_format: Option<ResponseFormat>,
104}
105
106pub type PartialObjectCallback = Box<dyn Fn(&Value) + Send>;
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116enum SchemaEnvelope {
117 Direct,
118 Elements,
119 Value,
120}
121
122impl SchemaEnvelope {
123 fn for_schema(schema: &Value) -> Self {
124 if schema_is_object_like(schema) {
125 Self::Direct
126 } else if schema.get("type").and_then(Value::as_str) == Some("array") {
127 Self::Elements
128 } else {
129 Self::Value
130 }
131 }
132
133 fn response_schema(self, schema: &Value) -> Value {
134 match self {
135 Self::Direct => schema.clone(),
136 Self::Elements => serde_json::json!({
137 "type": "object",
138 "required": ["elements"],
139 "additionalProperties": false,
140 "properties": {
141 "elements": schema
142 }
143 }),
144 Self::Value => serde_json::json!({
145 "type": "object",
146 "required": ["value"],
147 "additionalProperties": false,
148 "properties": {
149 "value": schema
150 }
151 }),
152 }
153 }
154
155 fn unwrap_final(self, value: &Value) -> Option<Value> {
156 match self {
157 Self::Direct => Some(value.clone()),
158 Self::Elements => value.get("elements").cloned(),
159 Self::Value => value.get("value").cloned(),
160 }
161 }
162
163 fn project_partial(self, value: &Value, repaired: bool) -> Option<Value> {
164 match self {
165 Self::Direct => Some(value.clone()),
166 Self::Elements => {
167 let mut elements = value.get("elements")?.as_array()?.clone();
168 if repaired && !elements.is_empty() {
172 elements.pop();
173 }
174 Some(Value::Array(elements))
175 }
176 Self::Value => value.get("value").cloned(),
177 }
178 }
179
180 fn instruction(self) -> &'static str {
181 match self {
182 Self::Direct => "",
183 Self::Elements => {
184 "The provider-facing response schema wraps the requested array in an `elements` field. Follow that schema exactly; callers receive the unwrapped array."
185 }
186 Self::Value => {
187 "The provider-facing response schema wraps the requested scalar/enum value in a `value` field. Follow that schema exactly; callers receive the unwrapped value."
188 }
189 }
190 }
191}
192
193fn schema_is_object_like(schema: &Value) -> bool {
194 schema.get("type").and_then(Value::as_str) == Some("object")
195 || schema.get("properties").is_some()
196 || schema.get("required").is_some()
197 || schema.get("additionalProperties").is_some()
198}
199
200pub async fn generate_blocking(
209 client: &dyn LlmClient,
210 req: &StructuredRequest,
211) -> Result<StructuredResult> {
212 let mode = resolve_mode(req.mode, client.native_structured_support());
213 let envelope = SchemaEnvelope::for_schema(&req.schema);
214 let mut messages = build_initial_messages(req, mode);
215 let system = build_system_prompt(req, mode);
216 let tools = build_tools(req, mode);
217 let directive = build_directive(req, mode);
218
219 let mut total_usage = TokenUsage::default();
220 let mut repair_rounds: u8 = 0;
221
222 loop {
223 let resp = client
224 .complete_structured(&messages, Some(&system), &tools, &directive)
225 .await
226 .context("LLM call failed during structured generation")?;
227
228 accumulate_usage(&mut total_usage, &resp.usage);
229
230 let candidates = extract_raw_candidates(&resp.message, mode);
236 let resolution = resolve_structured(&candidates, &req.schema, envelope);
237
238 if let Some((value, raw)) = resolution.valid {
239 return Ok(StructuredResult {
240 object: value,
241 raw_text: Some(raw),
242 usage: total_usage,
243 repair_rounds,
244 mode_used: mode,
245 });
246 }
247
248 if repair_rounds >= req.max_repair_attempts {
249 return Err(match resolution.invalid {
250 Some((_, errors)) => anyhow::anyhow!(
251 "Structured output failed schema validation after {} repair attempts. Errors: {}",
252 repair_rounds,
253 errors.join("; ")
254 ),
255 None => anyhow::anyhow!(
256 "Structured output parsing failed after {} repair attempts: no JSON object found in tool call, text content, or reasoning channel",
257 repair_rounds
258 ),
259 });
260 }
261
262 repair_rounds += 1;
263 let (repair_msg, raw_for_ctx) = match resolution.invalid {
264 Some((raw, errors)) => (build_repair_message(&raw, &errors), raw),
265 None => {
266 let raw = resolution.raw_seen.unwrap_or_default();
267 (build_parse_failure_repair(&raw), raw)
268 }
269 };
270 append_repair_context(
271 &mut messages,
272 &resp.message,
273 &repair_msg,
274 mode,
275 &raw_for_ctx,
276 );
277 }
278}
279
280pub async fn generate_streaming(
293 client: &dyn LlmClient,
294 req: &StructuredRequest,
295 on_partial: PartialObjectCallback,
296) -> Result<StructuredResult> {
297 let mode = resolve_mode(req.mode, client.native_structured_support());
298 let envelope = SchemaEnvelope::for_schema(&req.schema);
299 let mut messages = build_initial_messages(req, mode);
300 let system = build_system_prompt(req, mode);
301 let tools = build_tools(req, mode);
302 let directive = build_directive(req, mode);
303
304 let cancel_token = CancellationToken::new();
305 let mut rx = client
306 .complete_streaming_structured(
307 &messages,
308 Some(&system),
309 &tools,
310 &directive,
311 cancel_token.clone(),
312 )
313 .await
314 .context("LLM streaming call failed during structured generation")?;
315
316 let mut json_buffer = String::new();
317 let mut last_valid_partial: Option<Value> = None;
318 let mut final_response: Option<super::LlmResponse> = None;
319 let mut last_parse_len: usize = 0;
320 let mut complete_candidate: Option<(Value, String, tokio::time::Instant)> = None;
321 const PARSE_THRESHOLD: usize = 8;
323 const DONE_GRACE: std::time::Duration = std::time::Duration::from_millis(250);
328 loop {
329 let event = if let Some((_, _, deadline)) = complete_candidate.as_ref() {
330 tokio::select! {
331 event = rx.recv() => event,
332 _ = tokio::time::sleep_until(*deadline) => {
333 let candidate = complete_candidate
334 .take()
335 .expect("complete streamed candidate exists");
336 let (value, raw_text, _) = candidate;
337 cancel_token.cancel();
338 on_partial(&value);
339 return Ok(StructuredResult {
340 object: value,
341 raw_text: Some(raw_text),
342 usage: TokenUsage::default(),
343 repair_rounds: 0,
344 mode_used: mode,
345 });
346 }
347 }
348 } else {
349 rx.recv().await
350 };
351 let Some(event) = event else {
352 if let Some((value, raw_text, _)) = complete_candidate.take() {
353 cancel_token.cancel();
354 on_partial(&value);
355 return Ok(StructuredResult {
356 object: value,
357 raw_text: Some(raw_text),
358 usage: TokenUsage::default(),
359 repair_rounds: 0,
360 mode_used: mode,
361 });
362 }
363 break;
364 };
365 match event {
366 StreamEvent::ToolUseInputDelta { delta, .. } if mode == StructuredMode::Tool => {
367 if final_response.is_some() {
368 continue;
369 }
370 json_buffer.push_str(&delta);
371 if json_buffer.len() - last_parse_len >= PARSE_THRESHOLD {
372 if let Some(partial) = parse_partial_json(&json_buffer) {
373 if let Some(projected) =
374 envelope.project_partial(&partial.value, partial.repaired)
375 {
376 if last_valid_partial.as_ref() != Some(&projected) {
377 on_partial(&projected);
378 last_valid_partial = Some(projected);
379 }
380 }
381 }
382 last_parse_len = json_buffer.len();
383 }
384 if complete_candidate.is_none() && (delta.contains('}') || delta.contains(']')) {
385 complete_candidate = resolve_structured(
386 std::slice::from_ref(&json_buffer),
387 &req.schema,
388 envelope,
389 )
390 .valid
391 .map(|(value, raw_text)| {
392 (value, raw_text, tokio::time::Instant::now() + DONE_GRACE)
393 });
394 }
395 }
396 StreamEvent::TextDelta(delta) if mode != StructuredMode::Tool => {
397 if final_response.is_some() {
398 continue;
399 }
400 json_buffer.push_str(&delta);
401 if json_buffer.len() - last_parse_len >= PARSE_THRESHOLD {
402 if let Some(json_start) = find_json_start(&json_buffer) {
403 let candidate = &json_buffer[json_start..];
404 if let Some(partial) = parse_partial_json(candidate) {
405 if let Some(projected) =
406 envelope.project_partial(&partial.value, partial.repaired)
407 {
408 if last_valid_partial.as_ref() != Some(&projected) {
409 on_partial(&projected);
410 last_valid_partial = Some(projected);
411 }
412 }
413 }
414 }
415 last_parse_len = json_buffer.len();
416 }
417 if complete_candidate.is_none() && (delta.contains('}') || delta.contains(']')) {
418 complete_candidate = resolve_structured(
419 std::slice::from_ref(&json_buffer),
420 &req.schema,
421 envelope,
422 )
423 .valid
424 .map(|(value, raw_text)| {
425 (value, raw_text, tokio::time::Instant::now() + DONE_GRACE)
426 });
427 }
428 }
429 StreamEvent::Done(resp) => {
430 final_response = Some(resp);
431 break;
432 }
433 _ => {}
434 }
435 }
436
437 let mut resp = final_response.context("Stream ended without Done event")?;
438 let mut total_usage = TokenUsage::default();
439 accumulate_usage(&mut total_usage, &resp.usage);
440 let mut repair_rounds = 0u8;
441 let mut resolution = resolve_structured(
444 &extract_raw_candidates(&resp.message, mode),
445 &req.schema,
446 envelope,
447 );
448 let (value, raw_text) = loop {
449 if let Some(valid) = resolution.valid.take() {
450 break valid;
451 }
452
453 if repair_rounds >= req.max_repair_attempts {
454 return Err(match resolution.invalid {
455 Some((_, errors)) => anyhow::anyhow!(
456 "Streamed structured output failed schema validation after {} repair attempts: {}",
457 repair_rounds,
458 errors.join("; ")
459 ),
460 None => anyhow::anyhow!(
461 "Streamed output produced no parseable JSON object after {} repair attempts (checked tool call, text content, and reasoning channel)",
462 repair_rounds
463 ),
464 });
465 }
466
467 repair_rounds += 1;
468 let (repair_message, raw_for_context) = match resolution.invalid.take() {
469 Some((raw, errors)) => (build_repair_message(&raw, &errors), raw),
470 None => {
471 let raw = resolution.raw_seen.take().unwrap_or_default();
472 (build_parse_failure_repair(&raw), raw)
473 }
474 };
475 append_repair_context(
476 &mut messages,
477 &resp.message,
478 &repair_message,
479 mode,
480 &raw_for_context,
481 );
482 resp = client
483 .complete_structured(&messages, Some(&system), &tools, &directive)
484 .await
485 .context("LLM call failed while repairing streamed structured output")?;
486 accumulate_usage(&mut total_usage, &resp.usage);
487 resolution = resolve_structured(
488 &extract_raw_candidates(&resp.message, mode),
489 &req.schema,
490 envelope,
491 );
492 };
493
494 on_partial(&value);
496
497 Ok(StructuredResult {
498 object: value,
499 raw_text: Some(raw_text),
500 usage: total_usage,
501 repair_rounds,
502 mode_used: mode,
503 })
504}
505
506pub fn extract_json_value(text: &str) -> Result<Value> {
514 let trimmed = text.trim();
515
516 if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
518 if v.is_object() || v.is_array() {
519 return Ok(v);
520 }
521 }
522
523 if let Some(inner) = strip_code_fence(trimmed) {
525 if let Ok(v) = serde_json::from_str::<Value>(inner.trim()) {
526 if v.is_object() || v.is_array() {
527 return Ok(v);
528 }
529 }
530 }
531
532 if let Some(candidate) = find_balanced_json_object(trimmed) {
534 if let Ok(v) = serde_json::from_str::<Value>(candidate) {
535 return Ok(v);
536 }
537 }
538
539 if let Some(candidate) = find_balanced_json_array(trimmed) {
541 if let Ok(v) = serde_json::from_str::<Value>(candidate) {
542 return Ok(v);
543 }
544 }
545
546 bail!("No valid JSON object found in LLM output")
547}
548
549fn strip_code_fence(text: &str) -> Option<&str> {
551 let start_patterns = ["```json\n", "```json\r\n", "```\n", "```\r\n"];
552 for pat in &start_patterns {
553 if let Some(rest) = text.strip_prefix(pat) {
554 if let Some(end) = rest.rfind("```") {
556 return Some(&rest[..end]);
557 }
558 }
559 }
560 if let Some(inner) = text.strip_prefix("```json") {
562 if let Some(end) = inner.rfind("```") {
563 return Some(inner[..end].trim());
564 }
565 }
566 if let Some(inner) = text.strip_prefix("```") {
567 if let Some(end) = inner.rfind("```") {
568 return Some(inner[..end].trim());
569 }
570 }
571 None
572}
573
574fn find_balanced_json_object(text: &str) -> Option<&str> {
576 find_balanced(text, '{', '}')
577}
578
579fn find_balanced_json_array(text: &str) -> Option<&str> {
581 find_balanced(text, '[', ']')
582}
583
584fn find_balanced(text: &str, open: char, close: char) -> Option<&str> {
585 find_balanced_range(text, open, close).map(|(start, end)| &text[start..end])
586}
587
588fn find_balanced_range(text: &str, open: char, close: char) -> Option<(usize, usize)> {
590 let bytes = text.as_bytes();
591 let open_byte = open as u8;
592 let close_byte = close as u8;
593
594 let mut in_string = false;
596 let mut escape_next = false;
597 let mut start = None;
598
599 for (i, &b) in bytes.iter().enumerate() {
600 if escape_next {
601 escape_next = false;
602 continue;
603 }
604 match b {
605 b'\\' if in_string => escape_next = true,
606 b'"' => in_string = !in_string,
607 _ if in_string => {}
608 _ if b == open_byte => {
609 start = Some(i);
610 break;
611 }
612 _ => {}
613 }
614 }
615
616 let start = start?;
617 let mut depth = 0i32;
618 in_string = false;
619 escape_next = false;
620
621 for (i, &b) in bytes[start..].iter().enumerate() {
622 if escape_next {
623 escape_next = false;
624 continue;
625 }
626 match b {
627 b'\\' if in_string => escape_next = true,
628 b'"' => in_string = !in_string,
629 _ if in_string => {}
630 _ if b == open_byte => depth += 1,
631 _ if b == close_byte => {
632 depth -= 1;
633 if depth == 0 {
634 return Some((start, start + i + 1));
635 }
636 }
637 _ => {}
638 }
639 }
640 None
641}
642
643fn find_all_balanced(text: &str, open: char, close: char) -> Vec<String> {
649 let mut out = Vec::new();
650 let mut base = 0usize;
651 while base < text.len() {
652 match find_balanced_range(&text[base..], open, close) {
653 Some((start, end)) => {
654 out.push(text[base + start..base + end].to_string());
655 base += end;
656 }
657 None => break,
658 }
659 }
660 out
661}
662
663fn find_json_start(text: &str) -> Option<usize> {
666 let (search_text, offset) = if let Some(rest) = text.strip_prefix("```json") {
668 (rest, 7)
669 } else if let Some(rest) = text.strip_prefix("```") {
670 (rest, 3)
671 } else {
672 (text, 0)
673 };
674
675 let mut in_string = false;
676 let mut escape_next = false;
677 for (i, &b) in search_text.as_bytes().iter().enumerate() {
678 if escape_next {
679 escape_next = false;
680 continue;
681 }
682 match b {
683 b'\\' if in_string => {
684 escape_next = true;
685 }
686 b'"' => {
687 in_string = !in_string;
688 }
689 b'{' | b'[' if !in_string => {
690 return Some(offset + i);
691 }
692 _ => {}
693 }
694 }
695 None
696}
697
698fn validate_against_schema(value: &Value, schema: &Value) -> Result<(), Vec<String>> {
705 let validator = jsonschema::draft202012::options()
710 .build(schema)
711 .map_err(|error| vec![format!("invalid JSON Schema: {error}")])?;
712 let errors = validator
713 .iter_errors(value)
714 .map(|error| {
715 let path = error.instance_path().to_string();
716 if path.is_empty() {
717 format!("$: {error}")
718 } else {
719 format!("{path}: {error}")
720 }
721 })
722 .collect::<Vec<_>>();
723 if errors.is_empty() {
724 Ok(())
725 } else {
726 Err(errors)
727 }
728}
729
730fn resolve_mode(requested: StructuredMode, support: NativeStructuredSupport) -> StructuredMode {
741 match (requested, support) {
742 (StructuredMode::Prompt, _) => StructuredMode::Prompt,
743 (StructuredMode::Strict, NativeStructuredSupport::JsonSchema) => StructuredMode::Strict,
744 (StructuredMode::Json, NativeStructuredSupport::JsonSchema) => StructuredMode::Json,
745 (StructuredMode::Auto | StructuredMode::Tool, NativeStructuredSupport::JsonSchema) => {
746 StructuredMode::Tool
747 }
748 (
749 StructuredMode::Auto
750 | StructuredMode::Tool
751 | StructuredMode::Strict
752 | StructuredMode::Json,
753 NativeStructuredSupport::ForcedTool,
754 ) => StructuredMode::Tool,
755 (
756 StructuredMode::Auto
757 | StructuredMode::Tool
758 | StructuredMode::Strict
759 | StructuredMode::Json,
760 NativeStructuredSupport::None,
761 ) => StructuredMode::Prompt,
762 }
763}
764
765fn build_directive(req: &StructuredRequest, mode: StructuredMode) -> StructuredDirective {
767 match mode {
768 StructuredMode::Tool => StructuredDirective {
769 force_tool: Some(format!("emit_{}", req.schema_name)),
770 response_format: None,
771 },
772 StructuredMode::Strict => StructuredDirective {
773 force_tool: None,
774 response_format: Some(ResponseFormat::JsonSchema {
775 name: req.schema_name.clone(),
776 schema: SchemaEnvelope::for_schema(&req.schema).response_schema(&req.schema),
777 }),
778 },
779 StructuredMode::Json => StructuredDirective {
780 force_tool: None,
781 response_format: Some(ResponseFormat::JsonObject),
782 },
783 StructuredMode::Auto | StructuredMode::Prompt => StructuredDirective::default(),
784 }
785}
786
787fn build_initial_messages(req: &StructuredRequest, mode: StructuredMode) -> Vec<Message> {
788 let envelope = SchemaEnvelope::for_schema(&req.schema);
789 let response_schema = envelope.response_schema(&req.schema);
790 let envelope_instruction = envelope.instruction();
791 match mode {
792 StructuredMode::Tool => {
793 vec![Message::user(&req.prompt)]
796 }
797 StructuredMode::Prompt | StructuredMode::Json => {
798 let augmented = format!(
802 "{}\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```",
803 req.prompt,
804 envelope_instruction,
805 if envelope_instruction.is_empty() { "" } else { "\n" },
806 serde_json::to_string_pretty(&response_schema).unwrap_or_default()
807 );
808 vec![Message::user(&augmented)]
809 }
810 _ => {
811 vec![Message::user(&req.prompt)]
814 }
815 }
816}
817
818fn build_system_prompt(req: &StructuredRequest, mode: StructuredMode) -> String {
819 let base = req.system.as_deref().unwrap_or("");
820 let envelope_instruction = SchemaEnvelope::for_schema(&req.schema).instruction();
821
822 match mode {
823 StructuredMode::Tool => {
824 format!(
825 "{}{}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.{}{}",
826 base,
827 if base.is_empty() { "" } else { "\n\n" },
828 req.schema_name,
829 if envelope_instruction.is_empty() { "" } else { "\n\n" },
830 envelope_instruction
831 )
832 }
833 StructuredMode::Prompt | StructuredMode::Json => {
834 format!(
835 "{}{}You are a structured data extraction assistant. Always respond with valid JSON only, no markdown fences, no explanation text.{}{}",
836 base,
837 if base.is_empty() { "" } else { "\n\n" },
838 if envelope_instruction.is_empty() { "" } else { "\n\n" },
839 envelope_instruction,
840 )
841 }
842 _ => base.to_string(),
843 }
844}
845
846fn build_tools(req: &StructuredRequest, mode: StructuredMode) -> Vec<ToolDefinition> {
847 match mode {
848 StructuredMode::Tool => {
849 vec![ToolDefinition {
850 name: format!("emit_{}", req.schema_name),
851 description: req
852 .schema_description
853 .clone()
854 .unwrap_or_else(|| format!("Emit a structured {} object", req.schema_name)),
855 parameters: SchemaEnvelope::for_schema(&req.schema).response_schema(&req.schema),
856 }]
857 }
858 _ => vec![],
859 }
860}
861
862struct StructuredResolution {
864 valid: Option<(Value, String)>,
866 invalid: Option<(String, Vec<String>)>,
869 raw_seen: Option<String>,
871}
872
873fn push_candidate(out: &mut Vec<String>, s: String) {
875 let trimmed = s.trim();
876 if !trimmed.is_empty() && !out.iter().any(|c| c == trimmed) {
877 out.push(trimmed.to_string());
878 }
879}
880
881fn extract_raw_candidates(message: &super::Message, mode: StructuredMode) -> Vec<String> {
890 let mut out: Vec<String> = Vec::new();
891 if mode == StructuredMode::Tool {
892 if let Some(call) = message.tool_calls().first() {
893 push_candidate(
894 &mut out,
895 serde_json::to_string(&call.args).unwrap_or_default(),
896 );
897 }
898 }
899 push_candidate(&mut out, message.text());
900 if let Some(reasoning) = message.reasoning_content.as_deref() {
901 push_candidate(&mut out, reasoning.to_string());
902 }
903 out
904}
905
906#[cfg(test)]
909fn extract_all_json_values(text: &str) -> Vec<Value> {
910 extract_json_candidates(text, false)
911}
912
913fn extract_json_candidates(text: &str, include_direct_scalars: bool) -> Vec<Value> {
917 let trimmed = text.trim();
918 let mut values: Vec<Value> = Vec::new();
919 let consider = |candidate: &str, values: &mut Vec<Value>, allow_scalar: bool| {
920 if let Ok(v) = serde_json::from_str::<Value>(candidate.trim()) {
921 if (v.is_object() || v.is_array() || allow_scalar) && !values.contains(&v) {
922 values.push(v);
923 }
924 }
925 };
926 consider(trimmed, &mut values, include_direct_scalars);
927 if let Some(inner) = strip_code_fence(trimmed) {
928 consider(inner, &mut values, include_direct_scalars);
929 }
930 for candidate in find_all_balanced(trimmed, '{', '}') {
931 consider(&candidate, &mut values, false);
932 }
933 for candidate in find_all_balanced(trimmed, '[', ']') {
934 consider(&candidate, &mut values, false);
935 }
936 values
937}
938
939fn resolve_structured(
942 candidates: &[String],
943 schema: &Value,
944 envelope: SchemaEnvelope,
945) -> StructuredResolution {
946 let mut invalid: Option<(String, Vec<String>)> = None;
947 let mut raw_seen: Option<String> = None;
948 let response_schema = envelope.response_schema(schema);
949 for raw in candidates {
950 if raw_seen.is_none() && !raw.trim().is_empty() {
951 raw_seen = Some(raw.clone());
952 }
953 for value in extract_json_candidates(raw, envelope == SchemaEnvelope::Value) {
954 match validate_against_schema(&value, schema) {
955 Ok(()) => {
956 return StructuredResolution {
957 valid: Some((value, raw.clone())),
958 invalid,
959 raw_seen,
960 };
961 }
962 Err(errors) => {
963 if invalid.is_none() {
964 invalid = Some((raw.clone(), errors));
965 }
966 }
967 }
968
969 if envelope != SchemaEnvelope::Direct {
970 match validate_against_schema(&value, &response_schema) {
971 Ok(()) => {
972 if let Some(unwrapped) = envelope.unwrap_final(&value) {
973 match validate_against_schema(&unwrapped, schema) {
974 Ok(()) => {
975 return StructuredResolution {
976 valid: Some((unwrapped, raw.clone())),
977 invalid,
978 raw_seen,
979 };
980 }
981 Err(errors) => {
982 if invalid.is_none() {
983 invalid = Some((raw.clone(), errors));
984 }
985 }
986 }
987 } else if invalid.is_none() {
988 invalid = Some((
989 raw.clone(),
990 vec!["$: response envelope was missing the expected value field"
991 .to_string()],
992 ));
993 }
994 }
995 Err(errors) => {
996 if invalid.is_none() {
997 invalid = Some((raw.clone(), errors));
998 }
999 }
1000 }
1001 }
1002 }
1003 }
1004 StructuredResolution {
1005 valid: None,
1006 invalid,
1007 raw_seen,
1008 }
1009}
1010
1011pub(crate) fn parse_validated_output(text: &str, schema: &Value) -> Option<Value> {
1020 resolve_structured(
1021 &[text.to_string()],
1022 schema,
1023 SchemaEnvelope::for_schema(schema),
1024 )
1025 .valid
1026 .map(|(value, _)| value)
1027}
1028
1029fn truncate_utf8(s: &str, max: usize) -> &str {
1032 if s.len() <= max {
1033 return s;
1034 }
1035 let mut end = max;
1036 while end > 0 && !s.is_char_boundary(end) {
1037 end -= 1;
1038 }
1039 &s[..end]
1040}
1041
1042fn build_parse_failure_repair(raw_text: &str) -> String {
1044 if raw_text.trim().is_empty() {
1045 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();
1046 }
1047 format!(
1048 "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.",
1049 truncate_utf8(raw_text, 2000)
1050 )
1051}
1052
1053fn build_repair_message(raw_text: &str, errors: &[String]) -> String {
1054 let truncated_raw = if raw_text.len() > 2000 {
1056 format!(
1057 "{}...[truncated, {} bytes total]",
1058 truncate_utf8(raw_text, 2000),
1059 raw_text.len()
1060 )
1061 } else {
1062 raw_text.to_string()
1063 };
1064 format!(
1065 "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.",
1066 truncated_raw,
1067 errors.iter().map(|e| format!("- {}", e)).collect::<Vec<_>>().join("\n")
1068 )
1069}
1070
1071fn accumulate_usage(total: &mut TokenUsage, delta: &TokenUsage) {
1072 total.prompt_tokens += delta.prompt_tokens;
1073 total.completion_tokens += delta.completion_tokens;
1074 total.total_tokens += delta.total_tokens;
1075}
1076
1077fn append_repair_context(
1084 messages: &mut Vec<Message>,
1085 assistant_msg: &Message,
1086 repair_text: &str,
1087 mode: StructuredMode,
1088 _raw_text: &str,
1089) {
1090 if mode == StructuredMode::Tool {
1091 messages.push(assistant_msg.clone());
1093 let tool_use_id = assistant_msg
1095 .tool_calls()
1096 .first()
1097 .map(|tc| tc.id.clone())
1098 .unwrap_or_else(|| "unknown".to_string());
1099 messages.push(Message::tool_result(&tool_use_id, repair_text, true));
1101 } else {
1102 messages.push(assistant_msg.clone());
1104 messages.push(Message::user(repair_text));
1105 }
1106}
1107
1108#[cfg(test)]
1113#[path = "structured_tests.rs"]
1114mod structured_tests;