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