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