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 match schema_root_kind(schema, schema, &mut Vec::new(), 0) {
125 Some(SchemaRootKind::Object) => Self::Direct,
126 Some(SchemaRootKind::Array) => Self::Elements,
127 Some(SchemaRootKind::Other) | None => Self::Value,
128 }
129 }
130
131 fn response_schema(self, schema: &Value) -> Value {
132 match self {
133 Self::Direct => schema.clone(),
134 Self::Elements => wrap_response_schema("elements", schema),
135 Self::Value => wrap_response_schema("value", schema),
136 }
137 }
138
139 fn unwrap_final(self, value: &Value) -> Option<Value> {
140 match self {
141 Self::Direct => Some(value.clone()),
142 Self::Elements => value.get("elements").cloned(),
143 Self::Value => value.get("value").cloned(),
144 }
145 }
146
147 fn project_partial(self, value: &Value, repaired: bool) -> Option<Value> {
148 match self {
149 Self::Direct => Some(value.clone()),
150 Self::Elements => {
151 let mut elements = value.get("elements")?.as_array()?.clone();
152 if repaired && !elements.is_empty() {
156 elements.pop();
157 }
158 Some(Value::Array(elements))
159 }
160 Self::Value => value.get("value").cloned(),
161 }
162 }
163
164 fn instruction(self) -> &'static str {
165 match self {
166 Self::Direct => "",
167 Self::Elements => {
168 "The provider-facing response schema wraps the requested array in an `elements` field. Follow that schema exactly; callers receive the unwrapped array."
169 }
170 Self::Value => {
171 "The provider-facing response schema wraps the requested scalar/enum value in a `value` field. Follow that schema exactly; callers receive the unwrapped value."
172 }
173 }
174 }
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178enum SchemaRootKind {
179 Object,
180 Array,
181 Other,
182}
183
184fn schema_root_kind(
185 schema: &Value,
186 root: &Value,
187 active_refs: &mut Vec<String>,
188 depth: usize,
189) -> Option<SchemaRootKind> {
190 if depth > 64 {
191 return None;
192 }
193 let object = schema.as_object()?;
194
195 if let Some(kind) = object.get("type").and_then(schema_type_kind) {
196 return Some(kind);
197 }
198 if let Some(value) = object.get("const") {
199 return Some(value_kind(value));
200 }
201 if let Some(values) = object.get("enum").and_then(Value::as_array) {
202 if let Some(kind) = common_value_kind(values) {
203 return Some(kind);
204 }
205 }
206 if let Some(reference) = object.get("$ref").and_then(Value::as_str) {
207 if let Some(pointer) = reference.strip_prefix('#') {
208 if !active_refs.iter().any(|active| active == reference) {
209 if let Some(target) = root.pointer(pointer) {
210 active_refs.push(reference.to_string());
211 let kind = schema_root_kind(target, root, active_refs, depth + 1);
212 active_refs.pop();
213 if kind.is_some() {
214 return kind;
215 }
216 }
217 }
218 }
219 }
220 if let Some(all_of) = object.get("allOf").and_then(Value::as_array) {
221 if let Some(kind) = all_of
222 .iter()
223 .find_map(|branch| schema_root_kind(branch, root, active_refs, depth + 1))
224 {
225 return Some(kind);
226 }
227 }
228 for keyword in ["anyOf", "oneOf"] {
229 if let Some(branches) = object.get(keyword).and_then(Value::as_array) {
230 let kinds = branches
231 .iter()
232 .map(|branch| schema_root_kind(branch, root, active_refs, depth + 1))
233 .collect::<Option<Vec<_>>>();
234 if let Some(kinds) = kinds {
235 if let Some(first) = kinds.first().copied() {
236 if kinds.iter().all(|kind| *kind == first) {
237 return Some(first);
238 }
239 }
240 }
241 }
242 }
243
244 if ["properties", "required", "additionalProperties"]
247 .iter()
248 .any(|keyword| object.contains_key(*keyword))
249 {
250 return Some(SchemaRootKind::Object);
251 }
252 None
253}
254
255fn schema_type_kind(value: &Value) -> Option<SchemaRootKind> {
256 match value {
257 Value::String(value) => Some(type_name_kind(value)),
258 Value::Array(values) if values.len() == 1 => values[0].as_str().map(type_name_kind),
259 _ => None,
260 }
261}
262
263fn type_name_kind(value: &str) -> SchemaRootKind {
264 match value {
265 "object" => SchemaRootKind::Object,
266 "array" => SchemaRootKind::Array,
267 _ => SchemaRootKind::Other,
268 }
269}
270
271fn value_kind(value: &Value) -> SchemaRootKind {
272 match value {
273 Value::Object(_) => SchemaRootKind::Object,
274 Value::Array(_) => SchemaRootKind::Array,
275 _ => SchemaRootKind::Other,
276 }
277}
278
279fn common_value_kind(values: &[Value]) -> Option<SchemaRootKind> {
280 let first = values.first().map(value_kind)?;
281 values
282 .iter()
283 .all(|value| value_kind(value) == first)
284 .then_some(first)
285}
286
287fn wrap_response_schema(field: &str, schema: &Value) -> Value {
288 let mut embedded = schema.clone();
289 let mut wrapper = serde_json::json!({
290 "type": "object",
291 "required": [field],
292 "additionalProperties": false,
293 "properties": {}
294 });
295
296 if let Some(embedded_object) = embedded.as_object_mut() {
300 for keyword in ["$defs", "definitions"] {
301 if let Some(definitions) = embedded_object.remove(keyword) {
302 wrapper[keyword] = definitions;
303 }
304 }
305 }
306 wrapper["properties"][field] = embedded;
307 wrapper
308}
309
310pub async fn generate_blocking(
319 client: &dyn LlmClient,
320 req: &StructuredRequest,
321) -> Result<StructuredResult> {
322 let mode = resolve_mode(req.mode, client.native_structured_support());
323 let envelope = SchemaEnvelope::for_schema(&req.schema);
324 let mut messages = build_initial_messages(req, mode);
325 let system = build_system_prompt(req, mode);
326 let tools = build_tools(req, mode);
327 let directive = build_directive(req, mode);
328
329 let mut total_usage = TokenUsage::default();
330 let mut repair_rounds: u8 = 0;
331
332 loop {
333 let resp = client
334 .complete_structured(&messages, Some(&system), &tools, &directive)
335 .await
336 .context("LLM call failed during structured generation")?;
337
338 accumulate_usage(&mut total_usage, &resp.usage);
339
340 let candidates = extract_raw_candidates(&resp.message, mode);
346 let resolution = resolve_structured(&candidates, &req.schema, envelope);
347
348 if let Some((value, raw)) = resolution.valid {
349 return Ok(StructuredResult {
350 object: value,
351 raw_text: Some(raw),
352 usage: total_usage,
353 repair_rounds,
354 mode_used: mode,
355 });
356 }
357
358 if repair_rounds >= req.max_repair_attempts {
359 return Err(match resolution.invalid {
360 Some((_, errors)) => anyhow::anyhow!(
361 "Structured output failed schema validation after {} repair attempts. Errors: {}",
362 repair_rounds,
363 errors.join("; ")
364 ),
365 None => anyhow::anyhow!(
366 "Structured output parsing failed after {} repair attempts: no JSON object found in tool call, text content, or reasoning channel",
367 repair_rounds
368 ),
369 });
370 }
371
372 repair_rounds += 1;
373 let (repair_msg, raw_for_ctx) = match resolution.invalid {
374 Some((raw, errors)) => (build_repair_message(&raw, &errors), raw),
375 None => {
376 let raw = resolution.raw_seen.unwrap_or_default();
377 (build_parse_failure_repair(&raw), raw)
378 }
379 };
380 append_repair_context(
381 &mut messages,
382 &resp.message,
383 &repair_msg,
384 mode,
385 &raw_for_ctx,
386 );
387 }
388}
389
390pub async fn generate_streaming(
403 client: &dyn LlmClient,
404 req: &StructuredRequest,
405 on_partial: PartialObjectCallback,
406) -> Result<StructuredResult> {
407 let mode = resolve_mode(req.mode, client.native_structured_support());
408 let envelope = SchemaEnvelope::for_schema(&req.schema);
409 let mut messages = build_initial_messages(req, mode);
410 let system = build_system_prompt(req, mode);
411 let tools = build_tools(req, mode);
412 let directive = build_directive(req, mode);
413
414 let cancel_token = CancellationToken::new();
415 let mut rx = client
416 .complete_streaming_structured(
417 &messages,
418 Some(&system),
419 &tools,
420 &directive,
421 cancel_token.clone(),
422 )
423 .await
424 .context("LLM streaming call failed during structured generation")?;
425
426 let mut json_buffer = String::new();
427 let mut last_valid_partial: Option<Value> = None;
428 let mut final_response: Option<super::LlmResponse> = None;
429 let mut last_parse_len: usize = 0;
430 let mut complete_candidate: Option<(Value, String, tokio::time::Instant)> = None;
431 const PARSE_THRESHOLD: usize = 8;
433 const DONE_GRACE: std::time::Duration = std::time::Duration::from_millis(250);
438 loop {
439 let event = if let Some((_, _, deadline)) = complete_candidate.as_ref() {
440 tokio::select! {
441 event = rx.recv() => event,
442 _ = tokio::time::sleep_until(*deadline) => {
443 let candidate = complete_candidate
444 .take()
445 .expect("complete streamed candidate exists");
446 let (value, raw_text, _) = candidate;
447 cancel_token.cancel();
448 on_partial(&value);
449 return Ok(StructuredResult {
450 object: value,
451 raw_text: Some(raw_text),
452 usage: TokenUsage::default(),
453 repair_rounds: 0,
454 mode_used: mode,
455 });
456 }
457 }
458 } else {
459 rx.recv().await
460 };
461 let Some(event) = event else {
462 if let Some((value, raw_text, _)) = complete_candidate.take() {
463 cancel_token.cancel();
464 on_partial(&value);
465 return Ok(StructuredResult {
466 object: value,
467 raw_text: Some(raw_text),
468 usage: TokenUsage::default(),
469 repair_rounds: 0,
470 mode_used: mode,
471 });
472 }
473 break;
474 };
475 match event {
476 StreamEvent::ToolUseInputDelta { delta, .. } if mode == StructuredMode::Tool => {
477 if final_response.is_some() {
478 continue;
479 }
480 json_buffer.push_str(&delta);
481 if json_buffer.len() - last_parse_len >= PARSE_THRESHOLD {
482 if let Some(partial) = parse_partial_json(&json_buffer) {
483 if let Some(projected) =
484 envelope.project_partial(&partial.value, partial.repaired)
485 {
486 if last_valid_partial.as_ref() != Some(&projected) {
487 on_partial(&projected);
488 last_valid_partial = Some(projected);
489 }
490 }
491 }
492 last_parse_len = json_buffer.len();
493 }
494 if complete_candidate.is_none() && (delta.contains('}') || delta.contains(']')) {
495 complete_candidate = resolve_structured(
496 std::slice::from_ref(&json_buffer),
497 &req.schema,
498 envelope,
499 )
500 .valid
501 .map(|(value, raw_text)| {
502 (value, raw_text, tokio::time::Instant::now() + DONE_GRACE)
503 });
504 }
505 }
506 StreamEvent::TextDelta(delta) if mode != StructuredMode::Tool => {
507 if final_response.is_some() {
508 continue;
509 }
510 json_buffer.push_str(&delta);
511 if json_buffer.len() - last_parse_len >= PARSE_THRESHOLD {
512 if let Some(json_start) = find_json_start(&json_buffer) {
513 let candidate = &json_buffer[json_start..];
514 if let Some(partial) = parse_partial_json(candidate) {
515 if let Some(projected) =
516 envelope.project_partial(&partial.value, partial.repaired)
517 {
518 if last_valid_partial.as_ref() != Some(&projected) {
519 on_partial(&projected);
520 last_valid_partial = Some(projected);
521 }
522 }
523 }
524 }
525 last_parse_len = json_buffer.len();
526 }
527 if complete_candidate.is_none() && (delta.contains('}') || delta.contains(']')) {
528 complete_candidate = resolve_structured(
529 std::slice::from_ref(&json_buffer),
530 &req.schema,
531 envelope,
532 )
533 .valid
534 .map(|(value, raw_text)| {
535 (value, raw_text, tokio::time::Instant::now() + DONE_GRACE)
536 });
537 }
538 }
539 StreamEvent::Done(resp) => {
540 final_response = Some(resp);
541 break;
542 }
543 _ => {}
544 }
545 }
546
547 let mut resp = final_response.context("Stream ended without Done event")?;
548 let mut total_usage = TokenUsage::default();
549 accumulate_usage(&mut total_usage, &resp.usage);
550 let mut repair_rounds = 0u8;
551 let mut resolution = resolve_structured(
554 &extract_raw_candidates(&resp.message, mode),
555 &req.schema,
556 envelope,
557 );
558 let (value, raw_text) = loop {
559 if let Some(valid) = resolution.valid.take() {
560 break valid;
561 }
562
563 if repair_rounds >= req.max_repair_attempts {
564 return Err(match resolution.invalid {
565 Some((_, errors)) => anyhow::anyhow!(
566 "Streamed structured output failed schema validation after {} repair attempts: {}",
567 repair_rounds,
568 errors.join("; ")
569 ),
570 None => anyhow::anyhow!(
571 "Streamed output produced no parseable JSON object after {} repair attempts (checked tool call, text content, and reasoning channel)",
572 repair_rounds
573 ),
574 });
575 }
576
577 repair_rounds += 1;
578 let (repair_message, raw_for_context) = match resolution.invalid.take() {
579 Some((raw, errors)) => (build_repair_message(&raw, &errors), raw),
580 None => {
581 let raw = resolution.raw_seen.take().unwrap_or_default();
582 (build_parse_failure_repair(&raw), raw)
583 }
584 };
585 append_repair_context(
586 &mut messages,
587 &resp.message,
588 &repair_message,
589 mode,
590 &raw_for_context,
591 );
592 resp = client
593 .complete_structured(&messages, Some(&system), &tools, &directive)
594 .await
595 .context("LLM call failed while repairing streamed structured output")?;
596 accumulate_usage(&mut total_usage, &resp.usage);
597 resolution = resolve_structured(
598 &extract_raw_candidates(&resp.message, mode),
599 &req.schema,
600 envelope,
601 );
602 };
603
604 on_partial(&value);
606
607 Ok(StructuredResult {
608 object: value,
609 raw_text: Some(raw_text),
610 usage: total_usage,
611 repair_rounds,
612 mode_used: mode,
613 })
614}
615
616pub fn extract_json_value(text: &str) -> Result<Value> {
624 let trimmed = text.trim();
625
626 if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
628 if v.is_object() || v.is_array() {
629 return Ok(v);
630 }
631 }
632
633 if let Some(inner) = strip_code_fence(trimmed) {
635 if let Ok(v) = serde_json::from_str::<Value>(inner.trim()) {
636 if v.is_object() || v.is_array() {
637 return Ok(v);
638 }
639 }
640 }
641
642 if let Some(candidate) = find_balanced_json_object(trimmed) {
644 if let Ok(v) = serde_json::from_str::<Value>(candidate) {
645 return Ok(v);
646 }
647 }
648
649 if let Some(candidate) = find_balanced_json_array(trimmed) {
651 if let Ok(v) = serde_json::from_str::<Value>(candidate) {
652 return Ok(v);
653 }
654 }
655
656 bail!("No valid JSON object found in LLM output")
657}
658
659fn strip_code_fence(text: &str) -> Option<&str> {
661 let start_patterns = ["```json\n", "```json\r\n", "```\n", "```\r\n"];
662 for pat in &start_patterns {
663 if let Some(rest) = text.strip_prefix(pat) {
664 if let Some(end) = rest.rfind("```") {
666 return Some(&rest[..end]);
667 }
668 }
669 }
670 if let Some(inner) = text.strip_prefix("```json") {
672 if let Some(end) = inner.rfind("```") {
673 return Some(inner[..end].trim());
674 }
675 }
676 if let Some(inner) = text.strip_prefix("```") {
677 if let Some(end) = inner.rfind("```") {
678 return Some(inner[..end].trim());
679 }
680 }
681 None
682}
683
684fn find_balanced_json_object(text: &str) -> Option<&str> {
686 find_balanced(text, '{', '}')
687}
688
689fn find_balanced_json_array(text: &str) -> Option<&str> {
691 find_balanced(text, '[', ']')
692}
693
694fn find_balanced(text: &str, open: char, close: char) -> Option<&str> {
695 find_balanced_range(text, open, close).map(|(start, end)| &text[start..end])
696}
697
698fn find_balanced_range(text: &str, open: char, close: char) -> Option<(usize, usize)> {
700 let bytes = text.as_bytes();
701 let open_byte = open as u8;
702 let close_byte = close as u8;
703
704 let mut in_string = false;
706 let mut escape_next = false;
707 let mut start = None;
708
709 for (i, &b) in bytes.iter().enumerate() {
710 if escape_next {
711 escape_next = false;
712 continue;
713 }
714 match b {
715 b'\\' if in_string => escape_next = true,
716 b'"' => in_string = !in_string,
717 _ if in_string => {}
718 _ if b == open_byte => {
719 start = Some(i);
720 break;
721 }
722 _ => {}
723 }
724 }
725
726 let start = start?;
727 let mut depth = 0i32;
728 in_string = false;
729 escape_next = false;
730
731 for (i, &b) in bytes[start..].iter().enumerate() {
732 if escape_next {
733 escape_next = false;
734 continue;
735 }
736 match b {
737 b'\\' if in_string => escape_next = true,
738 b'"' => in_string = !in_string,
739 _ if in_string => {}
740 _ if b == open_byte => depth += 1,
741 _ if b == close_byte => {
742 depth -= 1;
743 if depth == 0 {
744 return Some((start, start + i + 1));
745 }
746 }
747 _ => {}
748 }
749 }
750 None
751}
752
753fn find_all_balanced(text: &str, open: char, close: char) -> Vec<String> {
759 let mut out = Vec::new();
760 let mut base = 0usize;
761 while base < text.len() {
762 match find_balanced_range(&text[base..], open, close) {
763 Some((start, end)) => {
764 out.push(text[base + start..base + end].to_string());
765 base += end;
766 }
767 None => break,
768 }
769 }
770 out
771}
772
773fn find_json_start(text: &str) -> Option<usize> {
776 let (search_text, offset) = if let Some(rest) = text.strip_prefix("```json") {
778 (rest, 7)
779 } else if let Some(rest) = text.strip_prefix("```") {
780 (rest, 3)
781 } else {
782 (text, 0)
783 };
784
785 let mut in_string = false;
786 let mut escape_next = false;
787 for (i, &b) in search_text.as_bytes().iter().enumerate() {
788 if escape_next {
789 escape_next = false;
790 continue;
791 }
792 match b {
793 b'\\' if in_string => {
794 escape_next = true;
795 }
796 b'"' => {
797 in_string = !in_string;
798 }
799 b'{' | b'[' if !in_string => {
800 return Some(offset + i);
801 }
802 _ => {}
803 }
804 }
805 None
806}
807
808fn validate_against_schema(value: &Value, schema: &Value) -> Result<(), Vec<String>> {
815 let validator = jsonschema::draft202012::options()
820 .build(schema)
821 .map_err(|error| vec![format!("invalid JSON Schema: {error}")])?;
822 let errors = validator
823 .iter_errors(value)
824 .map(|error| {
825 let path = error.instance_path().to_string();
826 if path.is_empty() {
827 format!("$: {error}")
828 } else {
829 format!("{path}: {error}")
830 }
831 })
832 .collect::<Vec<_>>();
833 if errors.is_empty() {
834 Ok(())
835 } else {
836 Err(errors)
837 }
838}
839
840fn resolve_mode(requested: StructuredMode, support: NativeStructuredSupport) -> StructuredMode {
851 match (requested, support) {
852 (StructuredMode::Prompt, _) => StructuredMode::Prompt,
853 (StructuredMode::Strict, NativeStructuredSupport::JsonSchema) => StructuredMode::Strict,
854 (StructuredMode::Json, NativeStructuredSupport::JsonSchema) => StructuredMode::Json,
855 (StructuredMode::Auto | StructuredMode::Tool, NativeStructuredSupport::JsonSchema) => {
856 StructuredMode::Tool
857 }
858 (
859 StructuredMode::Auto
860 | StructuredMode::Tool
861 | StructuredMode::Strict
862 | StructuredMode::Json,
863 NativeStructuredSupport::ForcedTool,
864 ) => StructuredMode::Tool,
865 (
866 StructuredMode::Auto
867 | StructuredMode::Tool
868 | StructuredMode::Strict
869 | StructuredMode::Json,
870 NativeStructuredSupport::None,
871 ) => StructuredMode::Prompt,
872 }
873}
874
875fn build_directive(req: &StructuredRequest, mode: StructuredMode) -> StructuredDirective {
877 match mode {
878 StructuredMode::Tool => StructuredDirective {
879 force_tool: Some(format!("emit_{}", req.schema_name)),
880 response_format: None,
881 },
882 StructuredMode::Strict => StructuredDirective {
883 force_tool: None,
884 response_format: Some(ResponseFormat::JsonSchema {
885 name: req.schema_name.clone(),
886 schema: SchemaEnvelope::for_schema(&req.schema).response_schema(&req.schema),
887 }),
888 },
889 StructuredMode::Json => StructuredDirective {
890 force_tool: None,
891 response_format: Some(ResponseFormat::JsonObject),
892 },
893 StructuredMode::Auto | StructuredMode::Prompt => StructuredDirective::default(),
894 }
895}
896
897fn build_initial_messages(req: &StructuredRequest, mode: StructuredMode) -> Vec<Message> {
898 let envelope = SchemaEnvelope::for_schema(&req.schema);
899 let response_schema = envelope.response_schema(&req.schema);
900 let envelope_instruction = envelope.instruction();
901 match mode {
902 StructuredMode::Tool => {
903 vec![Message::user(&req.prompt)]
906 }
907 StructuredMode::Prompt | StructuredMode::Json => {
908 let augmented = format!(
912 "{}\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```",
913 req.prompt,
914 envelope_instruction,
915 if envelope_instruction.is_empty() { "" } else { "\n" },
916 serde_json::to_string_pretty(&response_schema).unwrap_or_default()
917 );
918 vec![Message::user(&augmented)]
919 }
920 _ => {
921 vec![Message::user(&req.prompt)]
924 }
925 }
926}
927
928fn build_system_prompt(req: &StructuredRequest, mode: StructuredMode) -> String {
929 let base = req.system.as_deref().unwrap_or("");
930 let envelope_instruction = SchemaEnvelope::for_schema(&req.schema).instruction();
931
932 match mode {
933 StructuredMode::Tool => {
934 format!(
935 "{}{}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.{}{}",
936 base,
937 if base.is_empty() { "" } else { "\n\n" },
938 req.schema_name,
939 if envelope_instruction.is_empty() { "" } else { "\n\n" },
940 envelope_instruction
941 )
942 }
943 StructuredMode::Prompt | StructuredMode::Json => {
944 format!(
945 "{}{}You are a structured data extraction assistant. Always respond with valid JSON only, no markdown fences, no explanation text.{}{}",
946 base,
947 if base.is_empty() { "" } else { "\n\n" },
948 if envelope_instruction.is_empty() { "" } else { "\n\n" },
949 envelope_instruction,
950 )
951 }
952 _ => base.to_string(),
953 }
954}
955
956fn build_tools(req: &StructuredRequest, mode: StructuredMode) -> Vec<ToolDefinition> {
957 match mode {
958 StructuredMode::Tool => {
959 vec![ToolDefinition {
960 name: format!("emit_{}", req.schema_name),
961 description: req
962 .schema_description
963 .clone()
964 .unwrap_or_else(|| format!("Emit a structured {} object", req.schema_name)),
965 parameters: SchemaEnvelope::for_schema(&req.schema).response_schema(&req.schema),
966 }]
967 }
968 _ => vec![],
969 }
970}
971
972struct StructuredResolution {
974 valid: Option<(Value, String)>,
976 invalid: Option<(String, Vec<String>)>,
979 raw_seen: Option<String>,
981}
982
983fn push_candidate(out: &mut Vec<String>, s: String) {
985 let trimmed = s.trim();
986 if !trimmed.is_empty() && !out.iter().any(|c| c == trimmed) {
987 out.push(trimmed.to_string());
988 }
989}
990
991fn extract_raw_candidates(message: &super::Message, mode: StructuredMode) -> Vec<String> {
1000 let mut out: Vec<String> = Vec::new();
1001 if mode == StructuredMode::Tool {
1002 if let Some(call) = message.tool_calls().first() {
1003 push_candidate(
1004 &mut out,
1005 serde_json::to_string(&call.args).unwrap_or_default(),
1006 );
1007 }
1008 }
1009 push_candidate(&mut out, message.text());
1010 if let Some(reasoning) = message.reasoning_content.as_deref() {
1011 push_candidate(&mut out, reasoning.to_string());
1012 }
1013 out
1014}
1015
1016#[cfg(test)]
1019fn extract_all_json_values(text: &str) -> Vec<Value> {
1020 extract_json_candidates(text, false)
1021}
1022
1023fn extract_json_candidates(text: &str, include_direct_scalars: bool) -> Vec<Value> {
1027 let trimmed = text.trim();
1028 let mut values: Vec<Value> = Vec::new();
1029 let consider = |candidate: &str, values: &mut Vec<Value>, allow_scalar: bool| {
1030 if let Ok(v) = serde_json::from_str::<Value>(candidate.trim()) {
1031 if (v.is_object() || v.is_array() || allow_scalar) && !values.contains(&v) {
1032 values.push(v);
1033 }
1034 }
1035 };
1036 consider(trimmed, &mut values, include_direct_scalars);
1037 if let Some(inner) = strip_code_fence(trimmed) {
1038 consider(inner, &mut values, include_direct_scalars);
1039 }
1040 for candidate in find_all_balanced(trimmed, '{', '}') {
1041 consider(&candidate, &mut values, false);
1042 }
1043 for candidate in find_all_balanced(trimmed, '[', ']') {
1044 consider(&candidate, &mut values, false);
1045 }
1046 values
1047}
1048
1049fn resolve_structured(
1052 candidates: &[String],
1053 schema: &Value,
1054 envelope: SchemaEnvelope,
1055) -> StructuredResolution {
1056 let mut invalid: Option<(String, Vec<String>)> = None;
1057 let mut raw_seen: Option<String> = None;
1058 let response_schema = envelope.response_schema(schema);
1059 for raw in candidates {
1060 if raw_seen.is_none() && !raw.trim().is_empty() {
1061 raw_seen = Some(raw.clone());
1062 }
1063 for value in extract_json_candidates(raw, envelope == SchemaEnvelope::Value) {
1064 match validate_against_schema(&value, schema) {
1065 Ok(()) => {
1066 return StructuredResolution {
1067 valid: Some((value, raw.clone())),
1068 invalid,
1069 raw_seen,
1070 };
1071 }
1072 Err(errors) => {
1073 if invalid.is_none() {
1074 invalid = Some((raw.clone(), errors));
1075 }
1076 }
1077 }
1078
1079 if envelope != SchemaEnvelope::Direct {
1080 match validate_against_schema(&value, &response_schema) {
1081 Ok(()) => {
1082 if let Some(unwrapped) = envelope.unwrap_final(&value) {
1083 match validate_against_schema(&unwrapped, schema) {
1084 Ok(()) => {
1085 return StructuredResolution {
1086 valid: Some((unwrapped, raw.clone())),
1087 invalid,
1088 raw_seen,
1089 };
1090 }
1091 Err(errors) => {
1092 if invalid.is_none() {
1093 invalid = Some((raw.clone(), errors));
1094 }
1095 }
1096 }
1097 } else if invalid.is_none() {
1098 invalid = Some((
1099 raw.clone(),
1100 vec!["$: response envelope was missing the expected value field"
1101 .to_string()],
1102 ));
1103 }
1104 }
1105 Err(errors) => {
1106 if invalid.is_none() {
1107 invalid = Some((raw.clone(), errors));
1108 }
1109 }
1110 }
1111 }
1112 }
1113 }
1114 StructuredResolution {
1115 valid: None,
1116 invalid,
1117 raw_seen,
1118 }
1119}
1120
1121pub(crate) fn parse_validated_output(text: &str, schema: &Value) -> Option<Value> {
1130 resolve_structured(
1131 &[text.to_string()],
1132 schema,
1133 SchemaEnvelope::for_schema(schema),
1134 )
1135 .valid
1136 .map(|(value, _)| value)
1137}
1138
1139fn truncate_utf8(s: &str, max: usize) -> &str {
1142 if s.len() <= max {
1143 return s;
1144 }
1145 let mut end = max;
1146 while end > 0 && !s.is_char_boundary(end) {
1147 end -= 1;
1148 }
1149 &s[..end]
1150}
1151
1152fn build_parse_failure_repair(raw_text: &str) -> String {
1154 if raw_text.trim().is_empty() {
1155 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();
1156 }
1157 format!(
1158 "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.",
1159 truncate_utf8(raw_text, 2000)
1160 )
1161}
1162
1163fn build_repair_message(raw_text: &str, errors: &[String]) -> String {
1164 let truncated_raw = if raw_text.len() > 2000 {
1166 format!(
1167 "{}...[truncated, {} bytes total]",
1168 truncate_utf8(raw_text, 2000),
1169 raw_text.len()
1170 )
1171 } else {
1172 raw_text.to_string()
1173 };
1174 format!(
1175 "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.",
1176 truncated_raw,
1177 errors.iter().map(|e| format!("- {}", e)).collect::<Vec<_>>().join("\n")
1178 )
1179}
1180
1181fn accumulate_usage(total: &mut TokenUsage, delta: &TokenUsage) {
1182 total.prompt_tokens += delta.prompt_tokens;
1183 total.completion_tokens += delta.completion_tokens;
1184 total.total_tokens += delta.total_tokens;
1185}
1186
1187fn append_repair_context(
1194 messages: &mut Vec<Message>,
1195 assistant_msg: &Message,
1196 repair_text: &str,
1197 mode: StructuredMode,
1198 _raw_text: &str,
1199) {
1200 if mode == StructuredMode::Tool {
1201 messages.push(assistant_msg.clone());
1203 let tool_use_id = assistant_msg
1205 .tool_calls()
1206 .first()
1207 .map(|tc| tc.id.clone())
1208 .unwrap_or_else(|| "unknown".to_string());
1209 messages.push(Message::tool_result(&tool_use_id, repair_text, true));
1211 } else {
1212 messages.push(assistant_msg.clone());
1214 messages.push(Message::user(repair_text));
1215 }
1216}
1217
1218#[cfg(test)]
1223#[path = "structured_tests.rs"]
1224mod structured_tests;