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(&messages, Some(&system), &tools, &directive, cancel_token)
307 .await
308 .context("LLM streaming call failed during structured generation")?;
309
310 let mut json_buffer = String::new();
311 let mut last_valid_partial: Option<Value> = None;
312 let mut final_response: Option<super::LlmResponse> = None;
313 let mut last_parse_len: usize = 0;
314 const PARSE_THRESHOLD: usize = 8;
316
317 while let Some(event) = rx.recv().await {
318 match event {
319 StreamEvent::ToolUseInputDelta { delta, .. } if mode == StructuredMode::Tool => {
320 if final_response.is_some() {
321 continue;
322 }
323 json_buffer.push_str(&delta);
324 if json_buffer.len() - last_parse_len >= PARSE_THRESHOLD {
325 if let Some(partial) = parse_partial_json(&json_buffer) {
326 if let Some(projected) =
327 envelope.project_partial(&partial.value, partial.repaired)
328 {
329 if last_valid_partial.as_ref() != Some(&projected) {
330 on_partial(&projected);
331 last_valid_partial = Some(projected);
332 }
333 }
334 }
335 last_parse_len = json_buffer.len();
336 }
337 }
338 StreamEvent::TextDelta(delta) if mode != StructuredMode::Tool => {
339 if final_response.is_some() {
340 continue;
341 }
342 json_buffer.push_str(&delta);
343 if json_buffer.len() - last_parse_len >= PARSE_THRESHOLD {
344 if let Some(json_start) = find_json_start(&json_buffer) {
345 let candidate = &json_buffer[json_start..];
346 if let Some(partial) = parse_partial_json(candidate) {
347 if let Some(projected) =
348 envelope.project_partial(&partial.value, partial.repaired)
349 {
350 if last_valid_partial.as_ref() != Some(&projected) {
351 on_partial(&projected);
352 last_valid_partial = Some(projected);
353 }
354 }
355 }
356 }
357 last_parse_len = json_buffer.len();
358 }
359 }
360 StreamEvent::Done(resp) => {
361 final_response = Some(resp);
362 }
363 _ => {}
364 }
365 }
366
367 let mut resp = final_response.context("Stream ended without Done event")?;
368 let mut total_usage = TokenUsage::default();
369 accumulate_usage(&mut total_usage, &resp.usage);
370 let mut repair_rounds = 0u8;
371 let mut resolution = resolve_structured(
374 &extract_raw_candidates(&resp.message, mode),
375 &req.schema,
376 envelope,
377 );
378 let (value, raw_text) = loop {
379 if let Some(valid) = resolution.valid.take() {
380 break valid;
381 }
382
383 if repair_rounds >= req.max_repair_attempts {
384 return Err(match resolution.invalid {
385 Some((_, errors)) => anyhow::anyhow!(
386 "Streamed structured output failed schema validation after {} repair attempts: {}",
387 repair_rounds,
388 errors.join("; ")
389 ),
390 None => anyhow::anyhow!(
391 "Streamed output produced no parseable JSON object after {} repair attempts (checked tool call, text content, and reasoning channel)",
392 repair_rounds
393 ),
394 });
395 }
396
397 repair_rounds += 1;
398 let (repair_message, raw_for_context) = match resolution.invalid.take() {
399 Some((raw, errors)) => (build_repair_message(&raw, &errors), raw),
400 None => {
401 let raw = resolution.raw_seen.take().unwrap_or_default();
402 (build_parse_failure_repair(&raw), raw)
403 }
404 };
405 append_repair_context(
406 &mut messages,
407 &resp.message,
408 &repair_message,
409 mode,
410 &raw_for_context,
411 );
412 resp = client
413 .complete_structured(&messages, Some(&system), &tools, &directive)
414 .await
415 .context("LLM call failed while repairing streamed structured output")?;
416 accumulate_usage(&mut total_usage, &resp.usage);
417 resolution = resolve_structured(
418 &extract_raw_candidates(&resp.message, mode),
419 &req.schema,
420 envelope,
421 );
422 };
423
424 on_partial(&value);
426
427 Ok(StructuredResult {
428 object: value,
429 raw_text: Some(raw_text),
430 usage: total_usage,
431 repair_rounds,
432 mode_used: mode,
433 })
434}
435
436pub fn extract_json_value(text: &str) -> Result<Value> {
444 let trimmed = text.trim();
445
446 if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
448 if v.is_object() || v.is_array() {
449 return Ok(v);
450 }
451 }
452
453 if let Some(inner) = strip_code_fence(trimmed) {
455 if let Ok(v) = serde_json::from_str::<Value>(inner.trim()) {
456 if v.is_object() || v.is_array() {
457 return Ok(v);
458 }
459 }
460 }
461
462 if let Some(candidate) = find_balanced_json_object(trimmed) {
464 if let Ok(v) = serde_json::from_str::<Value>(candidate) {
465 return Ok(v);
466 }
467 }
468
469 if let Some(candidate) = find_balanced_json_array(trimmed) {
471 if let Ok(v) = serde_json::from_str::<Value>(candidate) {
472 return Ok(v);
473 }
474 }
475
476 bail!("No valid JSON object found in LLM output")
477}
478
479fn strip_code_fence(text: &str) -> Option<&str> {
481 let start_patterns = ["```json\n", "```json\r\n", "```\n", "```\r\n"];
482 for pat in &start_patterns {
483 if let Some(rest) = text.strip_prefix(pat) {
484 if let Some(end) = rest.rfind("```") {
486 return Some(&rest[..end]);
487 }
488 }
489 }
490 if let Some(inner) = text.strip_prefix("```json") {
492 if let Some(end) = inner.rfind("```") {
493 return Some(inner[..end].trim());
494 }
495 }
496 if let Some(inner) = text.strip_prefix("```") {
497 if let Some(end) = inner.rfind("```") {
498 return Some(inner[..end].trim());
499 }
500 }
501 None
502}
503
504fn find_balanced_json_object(text: &str) -> Option<&str> {
506 find_balanced(text, '{', '}')
507}
508
509fn find_balanced_json_array(text: &str) -> Option<&str> {
511 find_balanced(text, '[', ']')
512}
513
514fn find_balanced(text: &str, open: char, close: char) -> Option<&str> {
515 find_balanced_range(text, open, close).map(|(start, end)| &text[start..end])
516}
517
518fn find_balanced_range(text: &str, open: char, close: char) -> Option<(usize, usize)> {
520 let bytes = text.as_bytes();
521 let open_byte = open as u8;
522 let close_byte = close as u8;
523
524 let mut in_string = false;
526 let mut escape_next = false;
527 let mut start = None;
528
529 for (i, &b) in bytes.iter().enumerate() {
530 if escape_next {
531 escape_next = false;
532 continue;
533 }
534 match b {
535 b'\\' if in_string => escape_next = true,
536 b'"' => in_string = !in_string,
537 _ if in_string => {}
538 _ if b == open_byte => {
539 start = Some(i);
540 break;
541 }
542 _ => {}
543 }
544 }
545
546 let start = start?;
547 let mut depth = 0i32;
548 in_string = false;
549 escape_next = false;
550
551 for (i, &b) in bytes[start..].iter().enumerate() {
552 if escape_next {
553 escape_next = false;
554 continue;
555 }
556 match b {
557 b'\\' if in_string => escape_next = true,
558 b'"' => in_string = !in_string,
559 _ if in_string => {}
560 _ if b == open_byte => depth += 1,
561 _ if b == close_byte => {
562 depth -= 1;
563 if depth == 0 {
564 return Some((start, start + i + 1));
565 }
566 }
567 _ => {}
568 }
569 }
570 None
571}
572
573fn find_all_balanced(text: &str, open: char, close: char) -> Vec<String> {
579 let mut out = Vec::new();
580 let mut base = 0usize;
581 while base < text.len() {
582 match find_balanced_range(&text[base..], open, close) {
583 Some((start, end)) => {
584 out.push(text[base + start..base + end].to_string());
585 base += end;
586 }
587 None => break,
588 }
589 }
590 out
591}
592
593fn find_json_start(text: &str) -> Option<usize> {
596 let (search_text, offset) = if let Some(rest) = text.strip_prefix("```json") {
598 (rest, 7)
599 } else if let Some(rest) = text.strip_prefix("```") {
600 (rest, 3)
601 } else {
602 (text, 0)
603 };
604
605 let mut in_string = false;
606 let mut escape_next = false;
607 for (i, &b) in search_text.as_bytes().iter().enumerate() {
608 if escape_next {
609 escape_next = false;
610 continue;
611 }
612 match b {
613 b'\\' if in_string => {
614 escape_next = true;
615 }
616 b'"' => {
617 in_string = !in_string;
618 }
619 b'{' | b'[' if !in_string => {
620 return Some(offset + i);
621 }
622 _ => {}
623 }
624 }
625 None
626}
627
628fn validate_against_schema(value: &Value, schema: &Value) -> Result<(), Vec<String>> {
635 let validator = jsonschema::draft202012::options()
640 .build(schema)
641 .map_err(|error| vec![format!("invalid JSON Schema: {error}")])?;
642 let errors = validator
643 .iter_errors(value)
644 .map(|error| {
645 let path = error.instance_path().to_string();
646 if path.is_empty() {
647 format!("$: {error}")
648 } else {
649 format!("{path}: {error}")
650 }
651 })
652 .collect::<Vec<_>>();
653 if errors.is_empty() {
654 Ok(())
655 } else {
656 Err(errors)
657 }
658}
659
660fn resolve_mode(requested: StructuredMode, support: NativeStructuredSupport) -> StructuredMode {
671 match (requested, support) {
672 (StructuredMode::Prompt, _) => StructuredMode::Prompt,
673 (StructuredMode::Strict, NativeStructuredSupport::JsonSchema) => StructuredMode::Strict,
674 (StructuredMode::Json, NativeStructuredSupport::JsonSchema) => StructuredMode::Json,
675 (StructuredMode::Auto | StructuredMode::Tool, NativeStructuredSupport::JsonSchema) => {
676 StructuredMode::Tool
677 }
678 (
679 StructuredMode::Auto
680 | StructuredMode::Tool
681 | StructuredMode::Strict
682 | StructuredMode::Json,
683 NativeStructuredSupport::ForcedTool,
684 ) => StructuredMode::Tool,
685 (
686 StructuredMode::Auto
687 | StructuredMode::Tool
688 | StructuredMode::Strict
689 | StructuredMode::Json,
690 NativeStructuredSupport::None,
691 ) => StructuredMode::Prompt,
692 }
693}
694
695fn build_directive(req: &StructuredRequest, mode: StructuredMode) -> StructuredDirective {
697 match mode {
698 StructuredMode::Tool => StructuredDirective {
699 force_tool: Some(format!("emit_{}", req.schema_name)),
700 response_format: None,
701 },
702 StructuredMode::Strict => StructuredDirective {
703 force_tool: None,
704 response_format: Some(ResponseFormat::JsonSchema {
705 name: req.schema_name.clone(),
706 schema: SchemaEnvelope::for_schema(&req.schema).response_schema(&req.schema),
707 }),
708 },
709 StructuredMode::Json => StructuredDirective {
710 force_tool: None,
711 response_format: Some(ResponseFormat::JsonObject),
712 },
713 StructuredMode::Auto | StructuredMode::Prompt => StructuredDirective::default(),
714 }
715}
716
717fn build_initial_messages(req: &StructuredRequest, mode: StructuredMode) -> Vec<Message> {
718 let envelope = SchemaEnvelope::for_schema(&req.schema);
719 let response_schema = envelope.response_schema(&req.schema);
720 let envelope_instruction = envelope.instruction();
721 match mode {
722 StructuredMode::Tool => {
723 vec![Message::user(&req.prompt)]
726 }
727 StructuredMode::Prompt | StructuredMode::Json => {
728 let augmented = format!(
732 "{}\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```",
733 req.prompt,
734 envelope_instruction,
735 if envelope_instruction.is_empty() { "" } else { "\n" },
736 serde_json::to_string_pretty(&response_schema).unwrap_or_default()
737 );
738 vec![Message::user(&augmented)]
739 }
740 _ => {
741 vec![Message::user(&req.prompt)]
744 }
745 }
746}
747
748fn build_system_prompt(req: &StructuredRequest, mode: StructuredMode) -> String {
749 let base = req.system.as_deref().unwrap_or("");
750 let envelope_instruction = SchemaEnvelope::for_schema(&req.schema).instruction();
751
752 match mode {
753 StructuredMode::Tool => {
754 format!(
755 "{}{}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.{}{}",
756 base,
757 if base.is_empty() { "" } else { "\n\n" },
758 req.schema_name,
759 if envelope_instruction.is_empty() { "" } else { "\n\n" },
760 envelope_instruction
761 )
762 }
763 StructuredMode::Prompt | StructuredMode::Json => {
764 format!(
765 "{}{}You are a structured data extraction assistant. Always respond with valid JSON only, no markdown fences, no explanation text.{}{}",
766 base,
767 if base.is_empty() { "" } else { "\n\n" },
768 if envelope_instruction.is_empty() { "" } else { "\n\n" },
769 envelope_instruction,
770 )
771 }
772 _ => base.to_string(),
773 }
774}
775
776fn build_tools(req: &StructuredRequest, mode: StructuredMode) -> Vec<ToolDefinition> {
777 match mode {
778 StructuredMode::Tool => {
779 vec![ToolDefinition {
780 name: format!("emit_{}", req.schema_name),
781 description: req
782 .schema_description
783 .clone()
784 .unwrap_or_else(|| format!("Emit a structured {} object", req.schema_name)),
785 parameters: SchemaEnvelope::for_schema(&req.schema).response_schema(&req.schema),
786 }]
787 }
788 _ => vec![],
789 }
790}
791
792struct StructuredResolution {
794 valid: Option<(Value, String)>,
796 invalid: Option<(String, Vec<String>)>,
799 raw_seen: Option<String>,
801}
802
803fn push_candidate(out: &mut Vec<String>, s: String) {
805 let trimmed = s.trim();
806 if !trimmed.is_empty() && !out.iter().any(|c| c == trimmed) {
807 out.push(trimmed.to_string());
808 }
809}
810
811fn extract_raw_candidates(message: &super::Message, mode: StructuredMode) -> Vec<String> {
820 let mut out: Vec<String> = Vec::new();
821 if mode == StructuredMode::Tool {
822 if let Some(call) = message.tool_calls().first() {
823 push_candidate(
824 &mut out,
825 serde_json::to_string(&call.args).unwrap_or_default(),
826 );
827 }
828 }
829 push_candidate(&mut out, message.text());
830 if let Some(reasoning) = message.reasoning_content.as_deref() {
831 push_candidate(&mut out, reasoning.to_string());
832 }
833 out
834}
835
836#[cfg(test)]
839fn extract_all_json_values(text: &str) -> Vec<Value> {
840 extract_json_candidates(text, false)
841}
842
843fn extract_json_candidates(text: &str, include_direct_scalars: bool) -> Vec<Value> {
847 let trimmed = text.trim();
848 let mut values: Vec<Value> = Vec::new();
849 let consider = |candidate: &str, values: &mut Vec<Value>, allow_scalar: bool| {
850 if let Ok(v) = serde_json::from_str::<Value>(candidate.trim()) {
851 if (v.is_object() || v.is_array() || allow_scalar) && !values.contains(&v) {
852 values.push(v);
853 }
854 }
855 };
856 consider(trimmed, &mut values, include_direct_scalars);
857 if let Some(inner) = strip_code_fence(trimmed) {
858 consider(inner, &mut values, include_direct_scalars);
859 }
860 for candidate in find_all_balanced(trimmed, '{', '}') {
861 consider(&candidate, &mut values, false);
862 }
863 for candidate in find_all_balanced(trimmed, '[', ']') {
864 consider(&candidate, &mut values, false);
865 }
866 values
867}
868
869fn resolve_structured(
872 candidates: &[String],
873 schema: &Value,
874 envelope: SchemaEnvelope,
875) -> StructuredResolution {
876 let mut invalid: Option<(String, Vec<String>)> = None;
877 let mut raw_seen: Option<String> = None;
878 let response_schema = envelope.response_schema(schema);
879 for raw in candidates {
880 if raw_seen.is_none() && !raw.trim().is_empty() {
881 raw_seen = Some(raw.clone());
882 }
883 for value in extract_json_candidates(raw, envelope == SchemaEnvelope::Value) {
884 match validate_against_schema(&value, schema) {
885 Ok(()) => {
886 return StructuredResolution {
887 valid: Some((value, raw.clone())),
888 invalid,
889 raw_seen,
890 };
891 }
892 Err(errors) => {
893 if invalid.is_none() {
894 invalid = Some((raw.clone(), errors));
895 }
896 }
897 }
898
899 if envelope != SchemaEnvelope::Direct {
900 match validate_against_schema(&value, &response_schema) {
901 Ok(()) => {
902 if let Some(unwrapped) = envelope.unwrap_final(&value) {
903 match validate_against_schema(&unwrapped, schema) {
904 Ok(()) => {
905 return StructuredResolution {
906 valid: Some((unwrapped, raw.clone())),
907 invalid,
908 raw_seen,
909 };
910 }
911 Err(errors) => {
912 if invalid.is_none() {
913 invalid = Some((raw.clone(), errors));
914 }
915 }
916 }
917 } else if invalid.is_none() {
918 invalid = Some((
919 raw.clone(),
920 vec!["$: response envelope was missing the expected value field"
921 .to_string()],
922 ));
923 }
924 }
925 Err(errors) => {
926 if invalid.is_none() {
927 invalid = Some((raw.clone(), errors));
928 }
929 }
930 }
931 }
932 }
933 }
934 StructuredResolution {
935 valid: None,
936 invalid,
937 raw_seen,
938 }
939}
940
941pub(crate) fn parse_validated_output(text: &str, schema: &Value) -> Option<Value> {
950 resolve_structured(
951 &[text.to_string()],
952 schema,
953 SchemaEnvelope::for_schema(schema),
954 )
955 .valid
956 .map(|(value, _)| value)
957}
958
959fn truncate_utf8(s: &str, max: usize) -> &str {
962 if s.len() <= max {
963 return s;
964 }
965 let mut end = max;
966 while end > 0 && !s.is_char_boundary(end) {
967 end -= 1;
968 }
969 &s[..end]
970}
971
972fn build_parse_failure_repair(raw_text: &str) -> String {
974 if raw_text.trim().is_empty() {
975 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();
976 }
977 format!(
978 "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.",
979 truncate_utf8(raw_text, 2000)
980 )
981}
982
983fn build_repair_message(raw_text: &str, errors: &[String]) -> String {
984 let truncated_raw = if raw_text.len() > 2000 {
986 format!(
987 "{}...[truncated, {} bytes total]",
988 truncate_utf8(raw_text, 2000),
989 raw_text.len()
990 )
991 } else {
992 raw_text.to_string()
993 };
994 format!(
995 "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.",
996 truncated_raw,
997 errors.iter().map(|e| format!("- {}", e)).collect::<Vec<_>>().join("\n")
998 )
999}
1000
1001fn accumulate_usage(total: &mut TokenUsage, delta: &TokenUsage) {
1002 total.prompt_tokens += delta.prompt_tokens;
1003 total.completion_tokens += delta.completion_tokens;
1004 total.total_tokens += delta.total_tokens;
1005}
1006
1007fn append_repair_context(
1014 messages: &mut Vec<Message>,
1015 assistant_msg: &Message,
1016 repair_text: &str,
1017 mode: StructuredMode,
1018 _raw_text: &str,
1019) {
1020 if mode == StructuredMode::Tool {
1021 messages.push(assistant_msg.clone());
1023 let tool_use_id = assistant_msg
1025 .tool_calls()
1026 .first()
1027 .map(|tc| tc.id.clone())
1028 .unwrap_or_else(|| "unknown".to_string());
1029 messages.push(Message::tool_result(&tool_use_id, repair_text, true));
1031 } else {
1032 messages.push(assistant_msg.clone());
1034 messages.push(Message::user(repair_text));
1035 }
1036}
1037
1038#[cfg(test)]
1043#[path = "structured_tests.rs"]
1044mod structured_tests;