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(
292 client: &dyn LlmClient,
293 req: &StructuredRequest,
294 on_partial: PartialObjectCallback,
295) -> Result<StructuredResult> {
296 let mode = resolve_mode(req.mode, client.native_structured_support());
297 let envelope = SchemaEnvelope::for_schema(&req.schema);
298 let messages = build_initial_messages(req, mode);
299 let system = build_system_prompt(req, mode);
300 let tools = build_tools(req, mode);
301 let directive = build_directive(req, mode);
302
303 let cancel_token = CancellationToken::new();
304 let mut rx = client
305 .complete_streaming_structured(&messages, Some(&system), &tools, &directive, cancel_token)
306 .await
307 .context("LLM streaming call failed during structured generation")?;
308
309 let mut json_buffer = String::new();
310 let mut last_valid_partial: Option<Value> = None;
311 let mut final_response: Option<super::LlmResponse> = None;
312 let mut last_parse_len: usize = 0;
313 const PARSE_THRESHOLD: usize = 8;
315
316 while let Some(event) = rx.recv().await {
317 match event {
318 StreamEvent::ToolUseInputDelta { delta, .. } if mode == StructuredMode::Tool => {
319 if final_response.is_some() {
320 continue;
321 }
322 json_buffer.push_str(&delta);
323 if json_buffer.len() - last_parse_len >= PARSE_THRESHOLD {
324 if let Some(partial) = parse_partial_json(&json_buffer) {
325 if let Some(projected) =
326 envelope.project_partial(&partial.value, partial.repaired)
327 {
328 if last_valid_partial.as_ref() != Some(&projected) {
329 on_partial(&projected);
330 last_valid_partial = Some(projected);
331 }
332 }
333 }
334 last_parse_len = json_buffer.len();
335 }
336 }
337 StreamEvent::TextDelta(delta) if mode != StructuredMode::Tool => {
338 if final_response.is_some() {
339 continue;
340 }
341 json_buffer.push_str(&delta);
342 if json_buffer.len() - last_parse_len >= PARSE_THRESHOLD {
343 if let Some(json_start) = find_json_start(&json_buffer) {
344 let candidate = &json_buffer[json_start..];
345 if let Some(partial) = parse_partial_json(candidate) {
346 if let Some(projected) =
347 envelope.project_partial(&partial.value, partial.repaired)
348 {
349 if last_valid_partial.as_ref() != Some(&projected) {
350 on_partial(&projected);
351 last_valid_partial = Some(projected);
352 }
353 }
354 }
355 }
356 last_parse_len = json_buffer.len();
357 }
358 }
359 StreamEvent::Done(resp) => {
360 final_response = Some(resp);
361 }
362 _ => {}
363 }
364 }
365
366 let resp = final_response.context("Stream ended without Done event")?;
367 let candidates = extract_raw_candidates(&resp.message, mode);
370 let resolution = resolve_structured(&candidates, &req.schema, envelope);
371 let (value, raw_text) = match resolution.valid {
372 Some(vr) => vr,
373 None => {
374 return Err(match resolution.invalid {
375 Some((_, errors)) => anyhow::anyhow!(
376 "Streamed structured output failed schema validation: {}",
377 errors.join("; ")
378 ),
379 None => anyhow::anyhow!(
380 "Streamed output produced no parseable JSON object (checked tool call, text content, and reasoning channel)"
381 ),
382 });
383 }
384 };
385
386 on_partial(&value);
388
389 Ok(StructuredResult {
390 object: value,
391 raw_text: Some(raw_text),
392 usage: resp.usage,
393 repair_rounds: 0,
394 mode_used: mode,
395 })
396}
397
398pub fn extract_json_value(text: &str) -> Result<Value> {
406 let trimmed = text.trim();
407
408 if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
410 if v.is_object() || v.is_array() {
411 return Ok(v);
412 }
413 }
414
415 if let Some(inner) = strip_code_fence(trimmed) {
417 if let Ok(v) = serde_json::from_str::<Value>(inner.trim()) {
418 if v.is_object() || v.is_array() {
419 return Ok(v);
420 }
421 }
422 }
423
424 if let Some(candidate) = find_balanced_json_object(trimmed) {
426 if let Ok(v) = serde_json::from_str::<Value>(candidate) {
427 return Ok(v);
428 }
429 }
430
431 if let Some(candidate) = find_balanced_json_array(trimmed) {
433 if let Ok(v) = serde_json::from_str::<Value>(candidate) {
434 return Ok(v);
435 }
436 }
437
438 bail!("No valid JSON object found in LLM output")
439}
440
441fn strip_code_fence(text: &str) -> Option<&str> {
443 let start_patterns = ["```json\n", "```json\r\n", "```\n", "```\r\n"];
444 for pat in &start_patterns {
445 if let Some(rest) = text.strip_prefix(pat) {
446 if let Some(end) = rest.rfind("```") {
448 return Some(&rest[..end]);
449 }
450 }
451 }
452 if let Some(inner) = text.strip_prefix("```json") {
454 if let Some(end) = inner.rfind("```") {
455 return Some(inner[..end].trim());
456 }
457 }
458 if let Some(inner) = text.strip_prefix("```") {
459 if let Some(end) = inner.rfind("```") {
460 return Some(inner[..end].trim());
461 }
462 }
463 None
464}
465
466fn find_balanced_json_object(text: &str) -> Option<&str> {
468 find_balanced(text, '{', '}')
469}
470
471fn find_balanced_json_array(text: &str) -> Option<&str> {
473 find_balanced(text, '[', ']')
474}
475
476fn find_balanced(text: &str, open: char, close: char) -> Option<&str> {
477 find_balanced_range(text, open, close).map(|(start, end)| &text[start..end])
478}
479
480fn find_balanced_range(text: &str, open: char, close: char) -> Option<(usize, usize)> {
482 let bytes = text.as_bytes();
483 let open_byte = open as u8;
484 let close_byte = close as u8;
485
486 let mut in_string = false;
488 let mut escape_next = false;
489 let mut start = None;
490
491 for (i, &b) in bytes.iter().enumerate() {
492 if escape_next {
493 escape_next = false;
494 continue;
495 }
496 match b {
497 b'\\' if in_string => escape_next = true,
498 b'"' => in_string = !in_string,
499 _ if in_string => {}
500 _ if b == open_byte => {
501 start = Some(i);
502 break;
503 }
504 _ => {}
505 }
506 }
507
508 let start = start?;
509 let mut depth = 0i32;
510 in_string = false;
511 escape_next = false;
512
513 for (i, &b) in bytes[start..].iter().enumerate() {
514 if escape_next {
515 escape_next = false;
516 continue;
517 }
518 match b {
519 b'\\' if in_string => escape_next = true,
520 b'"' => in_string = !in_string,
521 _ if in_string => {}
522 _ if b == open_byte => depth += 1,
523 _ if b == close_byte => {
524 depth -= 1;
525 if depth == 0 {
526 return Some((start, start + i + 1));
527 }
528 }
529 _ => {}
530 }
531 }
532 None
533}
534
535fn find_all_balanced(text: &str, open: char, close: char) -> Vec<String> {
541 let mut out = Vec::new();
542 let mut base = 0usize;
543 while base < text.len() {
544 match find_balanced_range(&text[base..], open, close) {
545 Some((start, end)) => {
546 out.push(text[base + start..base + end].to_string());
547 base += end;
548 }
549 None => break,
550 }
551 }
552 out
553}
554
555fn find_json_start(text: &str) -> Option<usize> {
558 let (search_text, offset) = if let Some(rest) = text.strip_prefix("```json") {
560 (rest, 7)
561 } else if let Some(rest) = text.strip_prefix("```") {
562 (rest, 3)
563 } else {
564 (text, 0)
565 };
566
567 let mut in_string = false;
568 let mut escape_next = false;
569 for (i, &b) in search_text.as_bytes().iter().enumerate() {
570 if escape_next {
571 escape_next = false;
572 continue;
573 }
574 match b {
575 b'\\' if in_string => {
576 escape_next = true;
577 }
578 b'"' => {
579 in_string = !in_string;
580 }
581 b'{' | b'[' if !in_string => {
582 return Some(offset + i);
583 }
584 _ => {}
585 }
586 }
587 None
588}
589
590fn validate_against_schema(value: &Value, schema: &Value) -> Result<(), Vec<String>> {
597 let validator = jsonschema::draft202012::options()
602 .build(schema)
603 .map_err(|error| vec![format!("invalid JSON Schema: {error}")])?;
604 let errors = validator
605 .iter_errors(value)
606 .map(|error| {
607 let path = error.instance_path().to_string();
608 if path.is_empty() {
609 format!("$: {error}")
610 } else {
611 format!("{path}: {error}")
612 }
613 })
614 .collect::<Vec<_>>();
615 if errors.is_empty() {
616 Ok(())
617 } else {
618 Err(errors)
619 }
620}
621
622fn resolve_mode(requested: StructuredMode, support: NativeStructuredSupport) -> StructuredMode {
633 match (requested, support) {
634 (StructuredMode::Prompt, _) => StructuredMode::Prompt,
635 (StructuredMode::Strict, NativeStructuredSupport::JsonSchema) => StructuredMode::Strict,
636 (StructuredMode::Json, NativeStructuredSupport::JsonSchema) => StructuredMode::Json,
637 (StructuredMode::Auto | StructuredMode::Tool, NativeStructuredSupport::JsonSchema) => {
638 StructuredMode::Tool
639 }
640 (
641 StructuredMode::Auto
642 | StructuredMode::Tool
643 | StructuredMode::Strict
644 | StructuredMode::Json,
645 NativeStructuredSupport::ForcedTool,
646 ) => StructuredMode::Tool,
647 (
648 StructuredMode::Auto
649 | StructuredMode::Tool
650 | StructuredMode::Strict
651 | StructuredMode::Json,
652 NativeStructuredSupport::None,
653 ) => StructuredMode::Prompt,
654 }
655}
656
657fn build_directive(req: &StructuredRequest, mode: StructuredMode) -> StructuredDirective {
659 match mode {
660 StructuredMode::Tool => StructuredDirective {
661 force_tool: Some(format!("emit_{}", req.schema_name)),
662 response_format: None,
663 },
664 StructuredMode::Strict => StructuredDirective {
665 force_tool: None,
666 response_format: Some(ResponseFormat::JsonSchema {
667 name: req.schema_name.clone(),
668 schema: SchemaEnvelope::for_schema(&req.schema).response_schema(&req.schema),
669 }),
670 },
671 StructuredMode::Json => StructuredDirective {
672 force_tool: None,
673 response_format: Some(ResponseFormat::JsonObject),
674 },
675 StructuredMode::Auto | StructuredMode::Prompt => StructuredDirective::default(),
676 }
677}
678
679fn build_initial_messages(req: &StructuredRequest, mode: StructuredMode) -> Vec<Message> {
680 let envelope = SchemaEnvelope::for_schema(&req.schema);
681 let response_schema = envelope.response_schema(&req.schema);
682 let envelope_instruction = envelope.instruction();
683 match mode {
684 StructuredMode::Tool => {
685 vec![Message::user(&req.prompt)]
688 }
689 StructuredMode::Prompt | StructuredMode::Json => {
690 let augmented = format!(
694 "{}\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```",
695 req.prompt,
696 envelope_instruction,
697 if envelope_instruction.is_empty() { "" } else { "\n" },
698 serde_json::to_string_pretty(&response_schema).unwrap_or_default()
699 );
700 vec![Message::user(&augmented)]
701 }
702 _ => {
703 vec![Message::user(&req.prompt)]
706 }
707 }
708}
709
710fn build_system_prompt(req: &StructuredRequest, mode: StructuredMode) -> String {
711 let base = req.system.as_deref().unwrap_or("");
712 let envelope_instruction = SchemaEnvelope::for_schema(&req.schema).instruction();
713
714 match mode {
715 StructuredMode::Tool => {
716 format!(
717 "{}{}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.{}{}",
718 base,
719 if base.is_empty() { "" } else { "\n\n" },
720 req.schema_name,
721 if envelope_instruction.is_empty() { "" } else { "\n\n" },
722 envelope_instruction
723 )
724 }
725 StructuredMode::Prompt | StructuredMode::Json => {
726 format!(
727 "{}{}You are a structured data extraction assistant. Always respond with valid JSON only, no markdown fences, no explanation text.{}{}",
728 base,
729 if base.is_empty() { "" } else { "\n\n" },
730 if envelope_instruction.is_empty() { "" } else { "\n\n" },
731 envelope_instruction,
732 )
733 }
734 _ => base.to_string(),
735 }
736}
737
738fn build_tools(req: &StructuredRequest, mode: StructuredMode) -> Vec<ToolDefinition> {
739 match mode {
740 StructuredMode::Tool => {
741 vec![ToolDefinition {
742 name: format!("emit_{}", req.schema_name),
743 description: req
744 .schema_description
745 .clone()
746 .unwrap_or_else(|| format!("Emit a structured {} object", req.schema_name)),
747 parameters: SchemaEnvelope::for_schema(&req.schema).response_schema(&req.schema),
748 }]
749 }
750 _ => vec![],
751 }
752}
753
754struct StructuredResolution {
756 valid: Option<(Value, String)>,
758 invalid: Option<(String, Vec<String>)>,
761 raw_seen: Option<String>,
763}
764
765fn push_candidate(out: &mut Vec<String>, s: String) {
767 let trimmed = s.trim();
768 if !trimmed.is_empty() && !out.iter().any(|c| c == trimmed) {
769 out.push(trimmed.to_string());
770 }
771}
772
773fn extract_raw_candidates(message: &super::Message, mode: StructuredMode) -> Vec<String> {
782 let mut out: Vec<String> = Vec::new();
783 if mode == StructuredMode::Tool {
784 if let Some(call) = message.tool_calls().first() {
785 push_candidate(
786 &mut out,
787 serde_json::to_string(&call.args).unwrap_or_default(),
788 );
789 }
790 }
791 push_candidate(&mut out, message.text());
792 if let Some(reasoning) = message.reasoning_content.as_deref() {
793 push_candidate(&mut out, reasoning.to_string());
794 }
795 out
796}
797
798#[cfg(test)]
801fn extract_all_json_values(text: &str) -> Vec<Value> {
802 extract_json_candidates(text, false)
803}
804
805fn extract_json_candidates(text: &str, include_direct_scalars: bool) -> Vec<Value> {
809 let trimmed = text.trim();
810 let mut values: Vec<Value> = Vec::new();
811 let consider = |candidate: &str, values: &mut Vec<Value>, allow_scalar: bool| {
812 if let Ok(v) = serde_json::from_str::<Value>(candidate.trim()) {
813 if (v.is_object() || v.is_array() || allow_scalar) && !values.contains(&v) {
814 values.push(v);
815 }
816 }
817 };
818 consider(trimmed, &mut values, include_direct_scalars);
819 if let Some(inner) = strip_code_fence(trimmed) {
820 consider(inner, &mut values, include_direct_scalars);
821 }
822 for candidate in find_all_balanced(trimmed, '{', '}') {
823 consider(&candidate, &mut values, false);
824 }
825 for candidate in find_all_balanced(trimmed, '[', ']') {
826 consider(&candidate, &mut values, false);
827 }
828 values
829}
830
831fn resolve_structured(
834 candidates: &[String],
835 schema: &Value,
836 envelope: SchemaEnvelope,
837) -> StructuredResolution {
838 let mut invalid: Option<(String, Vec<String>)> = None;
839 let mut raw_seen: Option<String> = None;
840 let response_schema = envelope.response_schema(schema);
841 for raw in candidates {
842 if raw_seen.is_none() && !raw.trim().is_empty() {
843 raw_seen = Some(raw.clone());
844 }
845 for value in extract_json_candidates(raw, envelope == SchemaEnvelope::Value) {
846 match validate_against_schema(&value, schema) {
847 Ok(()) => {
848 return StructuredResolution {
849 valid: Some((value, raw.clone())),
850 invalid,
851 raw_seen,
852 };
853 }
854 Err(errors) => {
855 if invalid.is_none() {
856 invalid = Some((raw.clone(), errors));
857 }
858 }
859 }
860
861 if envelope != SchemaEnvelope::Direct {
862 match validate_against_schema(&value, &response_schema) {
863 Ok(()) => {
864 if let Some(unwrapped) = envelope.unwrap_final(&value) {
865 match validate_against_schema(&unwrapped, schema) {
866 Ok(()) => {
867 return StructuredResolution {
868 valid: Some((unwrapped, raw.clone())),
869 invalid,
870 raw_seen,
871 };
872 }
873 Err(errors) => {
874 if invalid.is_none() {
875 invalid = Some((raw.clone(), errors));
876 }
877 }
878 }
879 } else if invalid.is_none() {
880 invalid = Some((
881 raw.clone(),
882 vec!["$: response envelope was missing the expected value field"
883 .to_string()],
884 ));
885 }
886 }
887 Err(errors) => {
888 if invalid.is_none() {
889 invalid = Some((raw.clone(), errors));
890 }
891 }
892 }
893 }
894 }
895 }
896 StructuredResolution {
897 valid: None,
898 invalid,
899 raw_seen,
900 }
901}
902
903fn truncate_utf8(s: &str, max: usize) -> &str {
906 if s.len() <= max {
907 return s;
908 }
909 let mut end = max;
910 while end > 0 && !s.is_char_boundary(end) {
911 end -= 1;
912 }
913 &s[..end]
914}
915
916fn build_parse_failure_repair(raw_text: &str) -> String {
918 if raw_text.trim().is_empty() {
919 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();
920 }
921 format!(
922 "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.",
923 truncate_utf8(raw_text, 2000)
924 )
925}
926
927fn build_repair_message(raw_text: &str, errors: &[String]) -> String {
928 let truncated_raw = if raw_text.len() > 2000 {
930 format!(
931 "{}...[truncated, {} bytes total]",
932 truncate_utf8(raw_text, 2000),
933 raw_text.len()
934 )
935 } else {
936 raw_text.to_string()
937 };
938 format!(
939 "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.",
940 truncated_raw,
941 errors.iter().map(|e| format!("- {}", e)).collect::<Vec<_>>().join("\n")
942 )
943}
944
945fn accumulate_usage(total: &mut TokenUsage, delta: &TokenUsage) {
946 total.prompt_tokens += delta.prompt_tokens;
947 total.completion_tokens += delta.completion_tokens;
948 total.total_tokens += delta.total_tokens;
949}
950
951fn append_repair_context(
958 messages: &mut Vec<Message>,
959 assistant_msg: &Message,
960 repair_text: &str,
961 mode: StructuredMode,
962 _raw_text: &str,
963) {
964 if mode == StructuredMode::Tool {
965 messages.push(assistant_msg.clone());
967 let tool_use_id = assistant_msg
969 .tool_calls()
970 .first()
971 .map(|tc| tc.id.clone())
972 .unwrap_or_else(|| "unknown".to_string());
973 messages.push(Message::tool_result(&tool_use_id, repair_text, true));
975 } else {
976 messages.push(assistant_msg.clone());
978 messages.push(Message::user(repair_text));
979 }
980}
981
982#[cfg(test)]
987#[path = "structured_tests.rs"]
988mod structured_tests;