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 generate_blocking_with_cancellation(client, req, CancellationToken::new()).await
334}
335
336pub async fn generate_blocking_with_cancellation(
343 client: &dyn LlmClient,
344 req: &StructuredRequest,
345 cancellation: CancellationToken,
346) -> Result<StructuredResult> {
347 let mode = resolve_mode(req.mode, client.native_structured_support());
348 let envelope = SchemaEnvelope::for_schema(&req.schema);
349 let mut messages = build_initial_messages(req, mode);
350 let system = build_system_prompt(req, mode);
351 let tools = build_tools(req, mode);
352 let directive = build_directive(req, mode);
353
354 let mut total_usage = TokenUsage::default();
355 let mut repair_rounds: u8 = 0;
356
357 loop {
358 let resp = tokio::select! {
359 biased;
360 _ = cancellation.cancelled() => bail!("Operation cancelled by user"),
361 response = client.complete_structured(&messages, Some(&system), &tools, &directive) => response,
362 }
363 .context("LLM call failed during structured generation")?;
364
365 accumulate_usage(&mut total_usage, &resp.usage);
366
367 let candidates = extract_raw_candidates(&resp.message, mode);
373 let resolution = resolve_structured(&candidates, &req.schema, envelope);
374
375 if let Some((value, raw)) = resolution.valid {
376 return Ok(StructuredResult {
377 object: value,
378 raw_text: Some(raw),
379 usage: total_usage,
380 repair_rounds,
381 mode_used: mode,
382 });
383 }
384
385 if repair_rounds >= req.max_repair_attempts {
386 return Err(match resolution.invalid {
387 Some((_, errors)) => anyhow::anyhow!(
388 "Structured output failed schema validation after {} repair attempts. Errors: {}",
389 repair_rounds,
390 errors.join("; ")
391 ),
392 None => anyhow::anyhow!(
393 "Structured output parsing failed after {} repair attempts: no JSON object found in tool call, text content, or reasoning channel",
394 repair_rounds
395 ),
396 });
397 }
398
399 repair_rounds += 1;
400 let (repair_msg, raw_for_ctx) = match resolution.invalid {
401 Some((raw, errors)) => (build_repair_message(&raw, &errors), raw),
402 None => {
403 let raw = resolution.raw_seen.unwrap_or_default();
404 (build_parse_failure_repair(&raw), raw)
405 }
406 };
407 append_repair_context(
408 &mut messages,
409 &resp.message,
410 &repair_msg,
411 mode,
412 &raw_for_ctx,
413 );
414 }
415}
416
417pub async fn generate_streaming(
430 client: &dyn LlmClient,
431 req: &StructuredRequest,
432 on_partial: PartialObjectCallback,
433) -> Result<StructuredResult> {
434 generate_streaming_with_cancellation(client, req, on_partial, CancellationToken::new()).await
435}
436
437pub async fn generate_streaming_with_cancellation(
444 client: &dyn LlmClient,
445 req: &StructuredRequest,
446 on_partial: PartialObjectCallback,
447 cancellation: CancellationToken,
448) -> Result<StructuredResult> {
449 let mode = resolve_mode(req.mode, client.native_structured_support());
450 let envelope = SchemaEnvelope::for_schema(&req.schema);
451 let mut messages = build_initial_messages(req, mode);
452 let system = build_system_prompt(req, mode);
453 let tools = build_tools(req, mode);
454 let directive = build_directive(req, mode);
455
456 let provider_cancellation = cancellation.child_token();
457 let mut rx = tokio::select! {
458 biased;
459 _ = cancellation.cancelled() => bail!("Operation cancelled by user"),
460 response = client.complete_streaming_structured(
461 &messages,
462 Some(&system),
463 &tools,
464 &directive,
465 provider_cancellation.clone(),
466 ) => response,
467 }
468 .context("LLM streaming call failed during structured generation")?;
469
470 let mut json_buffer = String::new();
471 let mut last_valid_partial: Option<Value> = None;
472 let mut final_response: Option<super::LlmResponse> = None;
473 let mut last_parse_len: usize = 0;
474 let mut complete_candidate: Option<(Value, String, tokio::time::Instant)> = None;
475 const PARSE_THRESHOLD: usize = 8;
477 const DONE_GRACE: std::time::Duration = std::time::Duration::from_millis(250);
482 loop {
483 let event = if let Some((_, _, deadline)) = complete_candidate.as_ref() {
484 tokio::select! {
485 biased;
486 _ = cancellation.cancelled() => bail!("Operation cancelled by user"),
487 event = rx.recv() => event,
488 _ = tokio::time::sleep_until(*deadline) => {
489 let Some(candidate) = complete_candidate.take() else {
490 continue;
491 };
492 let (value, raw_text, _) = candidate;
493 provider_cancellation.cancel();
494 on_partial(&value);
495 return Ok(StructuredResult {
496 object: value,
497 raw_text: Some(raw_text),
498 usage: TokenUsage::default(),
499 repair_rounds: 0,
500 mode_used: mode,
501 });
502 }
503 }
504 } else {
505 tokio::select! {
506 biased;
507 _ = cancellation.cancelled() => bail!("Operation cancelled by user"),
508 event = rx.recv() => event,
509 }
510 };
511 let Some(event) = event else {
512 if let Some((value, raw_text, _)) = complete_candidate.take() {
513 provider_cancellation.cancel();
514 on_partial(&value);
515 return Ok(StructuredResult {
516 object: value,
517 raw_text: Some(raw_text),
518 usage: TokenUsage::default(),
519 repair_rounds: 0,
520 mode_used: mode,
521 });
522 }
523 break;
524 };
525 match event {
526 StreamEvent::ToolUseInputDelta { delta, .. } if mode == StructuredMode::Tool => {
527 if final_response.is_some() {
528 continue;
529 }
530 json_buffer.push_str(&delta);
531 if json_buffer.len() - last_parse_len >= PARSE_THRESHOLD {
532 if let Some(partial) = parse_partial_json(&json_buffer) {
533 if let Some(projected) =
534 envelope.project_partial(&partial.value, partial.repaired)
535 {
536 if last_valid_partial.as_ref() != Some(&projected) {
537 on_partial(&projected);
538 last_valid_partial = Some(projected);
539 }
540 }
541 }
542 last_parse_len = json_buffer.len();
543 }
544 if complete_candidate.is_none() && (delta.contains('}') || delta.contains(']')) {
545 complete_candidate = resolve_structured(
546 std::slice::from_ref(&json_buffer),
547 &req.schema,
548 envelope,
549 )
550 .valid
551 .map(|(value, raw_text)| {
552 (value, raw_text, tokio::time::Instant::now() + DONE_GRACE)
553 });
554 }
555 }
556 StreamEvent::TextDelta(delta) if mode != StructuredMode::Tool => {
557 if final_response.is_some() {
558 continue;
559 }
560 json_buffer.push_str(&delta);
561 if json_buffer.len() - last_parse_len >= PARSE_THRESHOLD {
562 if let Some(json_start) = find_json_start(&json_buffer) {
563 let candidate = &json_buffer[json_start..];
564 if let Some(partial) = parse_partial_json(candidate) {
565 if let Some(projected) =
566 envelope.project_partial(&partial.value, partial.repaired)
567 {
568 if last_valid_partial.as_ref() != Some(&projected) {
569 on_partial(&projected);
570 last_valid_partial = Some(projected);
571 }
572 }
573 }
574 }
575 last_parse_len = json_buffer.len();
576 }
577 if complete_candidate.is_none() && (delta.contains('}') || delta.contains(']')) {
578 complete_candidate = resolve_structured(
579 std::slice::from_ref(&json_buffer),
580 &req.schema,
581 envelope,
582 )
583 .valid
584 .map(|(value, raw_text)| {
585 (value, raw_text, tokio::time::Instant::now() + DONE_GRACE)
586 });
587 }
588 }
589 StreamEvent::Done(resp) => {
590 final_response = Some(resp);
591 break;
592 }
593 _ => {}
594 }
595 }
596
597 provider_cancellation.cancel();
598 let mut resp = final_response.context("Stream ended without Done event")?;
599 let mut total_usage = TokenUsage::default();
600 accumulate_usage(&mut total_usage, &resp.usage);
601 let mut repair_rounds = 0u8;
602 let mut resolution = resolve_structured(
605 &extract_raw_candidates(&resp.message, mode),
606 &req.schema,
607 envelope,
608 );
609 let (value, raw_text) = loop {
610 if let Some(valid) = resolution.valid.take() {
611 break valid;
612 }
613
614 if repair_rounds >= req.max_repair_attempts {
615 return Err(match resolution.invalid {
616 Some((_, errors)) => anyhow::anyhow!(
617 "Streamed structured output failed schema validation after {} repair attempts: {}",
618 repair_rounds,
619 errors.join("; ")
620 ),
621 None => anyhow::anyhow!(
622 "Streamed output produced no parseable JSON object after {} repair attempts (checked tool call, text content, and reasoning channel)",
623 repair_rounds
624 ),
625 });
626 }
627
628 repair_rounds += 1;
629 let (repair_message, raw_for_context) = match resolution.invalid.take() {
630 Some((raw, errors)) => (build_repair_message(&raw, &errors), raw),
631 None => {
632 let raw = resolution.raw_seen.take().unwrap_or_default();
633 (build_parse_failure_repair(&raw), raw)
634 }
635 };
636 append_repair_context(
637 &mut messages,
638 &resp.message,
639 &repair_message,
640 mode,
641 &raw_for_context,
642 );
643 resp = tokio::select! {
644 biased;
645 _ = cancellation.cancelled() => bail!("Operation cancelled by user"),
646 response = client.complete_structured(&messages, Some(&system), &tools, &directive) => response,
647 }
648 .context("LLM call failed while repairing streamed structured output")?;
649 accumulate_usage(&mut total_usage, &resp.usage);
650 resolution = resolve_structured(
651 &extract_raw_candidates(&resp.message, mode),
652 &req.schema,
653 envelope,
654 );
655 };
656
657 on_partial(&value);
659
660 Ok(StructuredResult {
661 object: value,
662 raw_text: Some(raw_text),
663 usage: total_usage,
664 repair_rounds,
665 mode_used: mode,
666 })
667}
668
669pub fn extract_json_value(text: &str) -> Result<Value> {
677 let trimmed = text.trim();
678
679 if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
681 if v.is_object() || v.is_array() {
682 return Ok(v);
683 }
684 }
685
686 if let Some(inner) = strip_code_fence(trimmed) {
688 if let Ok(v) = serde_json::from_str::<Value>(inner.trim()) {
689 if v.is_object() || v.is_array() {
690 return Ok(v);
691 }
692 }
693 }
694
695 if let Some(candidate) = find_balanced_json_object(trimmed) {
697 if let Ok(v) = serde_json::from_str::<Value>(candidate) {
698 return Ok(v);
699 }
700 }
701
702 if let Some(candidate) = find_balanced_json_array(trimmed) {
704 if let Ok(v) = serde_json::from_str::<Value>(candidate) {
705 return Ok(v);
706 }
707 }
708
709 bail!("No valid JSON object found in LLM output")
710}
711
712fn strip_code_fence(text: &str) -> Option<&str> {
714 let start_patterns = ["```json\n", "```json\r\n", "```\n", "```\r\n"];
715 for pat in &start_patterns {
716 if let Some(rest) = text.strip_prefix(pat) {
717 if let Some(end) = rest.rfind("```") {
719 return Some(&rest[..end]);
720 }
721 }
722 }
723 if let Some(inner) = text.strip_prefix("```json") {
725 if let Some(end) = inner.rfind("```") {
726 return Some(inner[..end].trim());
727 }
728 }
729 if let Some(inner) = text.strip_prefix("```") {
730 if let Some(end) = inner.rfind("```") {
731 return Some(inner[..end].trim());
732 }
733 }
734 None
735}
736
737fn find_balanced_json_object(text: &str) -> Option<&str> {
739 find_balanced(text, '{', '}')
740}
741
742fn find_balanced_json_array(text: &str) -> Option<&str> {
744 find_balanced(text, '[', ']')
745}
746
747fn find_balanced(text: &str, open: char, close: char) -> Option<&str> {
748 find_balanced_range(text, open, close).map(|(start, end)| &text[start..end])
749}
750
751fn find_balanced_range(text: &str, open: char, close: char) -> Option<(usize, usize)> {
753 let bytes = text.as_bytes();
754 let open_byte = open as u8;
755 let close_byte = close as u8;
756
757 let mut in_string = false;
759 let mut escape_next = false;
760 let mut start = None;
761
762 for (i, &b) in bytes.iter().enumerate() {
763 if escape_next {
764 escape_next = false;
765 continue;
766 }
767 match b {
768 b'\\' if in_string => escape_next = true,
769 b'"' => in_string = !in_string,
770 _ if in_string => {}
771 _ if b == open_byte => {
772 start = Some(i);
773 break;
774 }
775 _ => {}
776 }
777 }
778
779 let start = start?;
780 let mut depth = 0i32;
781 in_string = false;
782 escape_next = false;
783
784 for (i, &b) in bytes[start..].iter().enumerate() {
785 if escape_next {
786 escape_next = false;
787 continue;
788 }
789 match b {
790 b'\\' if in_string => escape_next = true,
791 b'"' => in_string = !in_string,
792 _ if in_string => {}
793 _ if b == open_byte => depth += 1,
794 _ if b == close_byte => {
795 depth -= 1;
796 if depth == 0 {
797 return Some((start, start + i + 1));
798 }
799 }
800 _ => {}
801 }
802 }
803 None
804}
805
806fn find_all_balanced(text: &str, open: char, close: char) -> Vec<String> {
812 let mut out = Vec::new();
813 let mut base = 0usize;
814 while base < text.len() {
815 match find_balanced_range(&text[base..], open, close) {
816 Some((start, end)) => {
817 out.push(text[base + start..base + end].to_string());
818 base += end;
819 }
820 None => break,
821 }
822 }
823 out
824}
825
826fn find_json_start(text: &str) -> Option<usize> {
829 let (search_text, offset) = if let Some(rest) = text.strip_prefix("```json") {
831 (rest, 7)
832 } else if let Some(rest) = text.strip_prefix("```") {
833 (rest, 3)
834 } else {
835 (text, 0)
836 };
837
838 let mut in_string = false;
839 let mut escape_next = false;
840 for (i, &b) in search_text.as_bytes().iter().enumerate() {
841 if escape_next {
842 escape_next = false;
843 continue;
844 }
845 match b {
846 b'\\' if in_string => {
847 escape_next = true;
848 }
849 b'"' => {
850 in_string = !in_string;
851 }
852 b'{' | b'[' if !in_string => {
853 return Some(offset + i);
854 }
855 _ => {}
856 }
857 }
858 None
859}
860
861fn validate_against_schema(value: &Value, schema: &Value) -> Result<(), Vec<String>> {
868 let validator = jsonschema::draft202012::options()
873 .build(schema)
874 .map_err(|error| vec![format!("invalid JSON Schema: {error}")])?;
875 let errors = validator
876 .iter_errors(value)
877 .map(|error| {
878 let path = error.instance_path().to_string();
879 if path.is_empty() {
880 format!("$: {error}")
881 } else {
882 format!("{path}: {error}")
883 }
884 })
885 .collect::<Vec<_>>();
886 if errors.is_empty() {
887 Ok(())
888 } else {
889 Err(errors)
890 }
891}
892
893pub fn is_complete_streamed_value(raw: &str, response_schema: &Value) -> bool {
903 serde_json::from_str::<Value>(raw)
904 .ok()
905 .is_some_and(|value| validate_against_schema(&value, response_schema).is_ok())
906}
907
908fn resolve_mode(requested: StructuredMode, support: NativeStructuredSupport) -> StructuredMode {
919 match (requested, support) {
920 (StructuredMode::Prompt, _) => StructuredMode::Prompt,
921 (StructuredMode::Strict, NativeStructuredSupport::JsonSchema) => StructuredMode::Strict,
922 (StructuredMode::Json, NativeStructuredSupport::JsonSchema) => StructuredMode::Json,
923 (StructuredMode::Auto | StructuredMode::Tool, NativeStructuredSupport::JsonSchema) => {
924 StructuredMode::Tool
925 }
926 (
927 StructuredMode::Auto
928 | StructuredMode::Tool
929 | StructuredMode::Strict
930 | StructuredMode::Json,
931 NativeStructuredSupport::ForcedTool,
932 ) => StructuredMode::Tool,
933 (
934 StructuredMode::Auto
935 | StructuredMode::Tool
936 | StructuredMode::Strict
937 | StructuredMode::Json,
938 NativeStructuredSupport::JsonObject,
939 ) => StructuredMode::Json,
940 (
941 StructuredMode::Auto
942 | StructuredMode::Tool
943 | StructuredMode::Strict
944 | StructuredMode::Json,
945 NativeStructuredSupport::None,
946 ) => StructuredMode::Prompt,
947 }
948}
949
950fn build_directive(req: &StructuredRequest, mode: StructuredMode) -> StructuredDirective {
952 let response_schema = SchemaEnvelope::for_schema(&req.schema).response_schema(&req.schema);
953 let mut directive = match mode {
954 StructuredMode::Tool => StructuredDirective {
955 force_tool: Some(format!("emit_{}", req.schema_name)),
956 response_format: None,
957 validation_schema: None,
958 },
959 StructuredMode::Strict => StructuredDirective {
960 force_tool: None,
961 response_format: Some(ResponseFormat::JsonSchema {
962 name: req.schema_name.clone(),
963 schema: response_schema.clone(),
964 }),
965 validation_schema: None,
966 },
967 StructuredMode::Json => StructuredDirective {
968 force_tool: None,
969 response_format: Some(ResponseFormat::JsonObject),
970 validation_schema: None,
971 },
972 StructuredMode::Auto | StructuredMode::Prompt => StructuredDirective::default(),
973 };
974 directive.validation_schema = Some(response_schema);
975 directive
976}
977
978fn build_initial_messages(req: &StructuredRequest, mode: StructuredMode) -> Vec<Message> {
979 let envelope = SchemaEnvelope::for_schema(&req.schema);
980 let response_schema = envelope.response_schema(&req.schema);
981 let envelope_instruction = envelope.instruction();
982 match mode {
983 StructuredMode::Tool => {
984 vec![Message::user(&req.prompt)]
987 }
988 StructuredMode::Prompt | StructuredMode::Json => {
989 let augmented = format!(
993 "{}\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```",
994 req.prompt,
995 envelope_instruction,
996 if envelope_instruction.is_empty() { "" } else { "\n" },
997 serde_json::to_string_pretty(&response_schema).unwrap_or_default()
998 );
999 vec![Message::user(&augmented)]
1000 }
1001 _ => {
1002 vec![Message::user(&req.prompt)]
1005 }
1006 }
1007}
1008
1009fn build_system_prompt(req: &StructuredRequest, mode: StructuredMode) -> String {
1010 let base = req.system.as_deref().unwrap_or("");
1011 let envelope_instruction = SchemaEnvelope::for_schema(&req.schema).instruction();
1012
1013 match mode {
1014 StructuredMode::Tool => {
1015 format!(
1016 "{}{}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.{}{}",
1017 base,
1018 if base.is_empty() { "" } else { "\n\n" },
1019 req.schema_name,
1020 if envelope_instruction.is_empty() { "" } else { "\n\n" },
1021 envelope_instruction
1022 )
1023 }
1024 StructuredMode::Prompt | StructuredMode::Json => {
1025 format!(
1026 "{}{}You are a structured data extraction assistant. Always respond with valid JSON only, no markdown fences, no explanation text.{}{}",
1027 base,
1028 if base.is_empty() { "" } else { "\n\n" },
1029 if envelope_instruction.is_empty() { "" } else { "\n\n" },
1030 envelope_instruction,
1031 )
1032 }
1033 _ => base.to_string(),
1034 }
1035}
1036
1037fn build_tools(req: &StructuredRequest, mode: StructuredMode) -> Vec<ToolDefinition> {
1038 match mode {
1039 StructuredMode::Tool => {
1040 vec![ToolDefinition {
1041 name: format!("emit_{}", req.schema_name),
1042 description: req
1043 .schema_description
1044 .clone()
1045 .unwrap_or_else(|| format!("Emit a structured {} object", req.schema_name)),
1046 parameters: SchemaEnvelope::for_schema(&req.schema).response_schema(&req.schema),
1047 }]
1048 }
1049 _ => vec![],
1050 }
1051}
1052
1053struct StructuredResolution {
1055 valid: Option<(Value, String)>,
1057 invalid: Option<(String, Vec<String>)>,
1060 raw_seen: Option<String>,
1062}
1063
1064fn push_candidate(out: &mut Vec<String>, s: String) {
1066 let trimmed = s.trim();
1067 if !trimmed.is_empty() && !out.iter().any(|c| c == trimmed) {
1068 out.push(trimmed.to_string());
1069 }
1070}
1071
1072fn extract_raw_candidates(message: &super::Message, mode: StructuredMode) -> Vec<String> {
1081 let mut out: Vec<String> = Vec::new();
1082 if mode == StructuredMode::Tool {
1083 if let Some(call) = message.tool_calls().first() {
1084 push_candidate(
1085 &mut out,
1086 serde_json::to_string(&call.args).unwrap_or_default(),
1087 );
1088 }
1089 }
1090 push_candidate(&mut out, message.text());
1091 if let Some(reasoning) = message.reasoning_content.as_deref() {
1092 push_candidate(&mut out, reasoning.to_string());
1093 }
1094 out
1095}
1096
1097#[cfg(test)]
1100fn extract_all_json_values(text: &str) -> Vec<Value> {
1101 extract_json_candidates(text, false)
1102}
1103
1104fn extract_json_candidates(text: &str, include_direct_scalars: bool) -> Vec<Value> {
1108 let trimmed = text.trim();
1109 let mut values: Vec<Value> = Vec::new();
1110 let consider = |candidate: &str, values: &mut Vec<Value>, allow_scalar: bool| {
1111 if let Ok(v) = serde_json::from_str::<Value>(candidate.trim()) {
1112 if (v.is_object() || v.is_array() || allow_scalar) && !values.contains(&v) {
1113 values.push(v);
1114 }
1115 }
1116 };
1117 consider(trimmed, &mut values, include_direct_scalars);
1118 if let Some(inner) = strip_code_fence(trimmed) {
1119 consider(inner, &mut values, include_direct_scalars);
1120 }
1121 for candidate in find_all_balanced(trimmed, '{', '}') {
1122 consider(&candidate, &mut values, false);
1123 }
1124 for candidate in find_all_balanced(trimmed, '[', ']') {
1125 consider(&candidate, &mut values, false);
1126 }
1127 values
1128}
1129
1130fn resolve_structured(
1133 candidates: &[String],
1134 schema: &Value,
1135 envelope: SchemaEnvelope,
1136) -> StructuredResolution {
1137 let mut invalid: Option<(String, Vec<String>)> = None;
1138 let mut raw_seen: Option<String> = None;
1139 let response_schema = envelope.response_schema(schema);
1140 for raw in candidates {
1141 if raw_seen.is_none() && !raw.trim().is_empty() {
1142 raw_seen = Some(raw.clone());
1143 }
1144 for value in extract_json_candidates(raw, envelope == SchemaEnvelope::Value) {
1145 match validate_against_schema(&value, schema) {
1146 Ok(()) => {
1147 return StructuredResolution {
1148 valid: Some((value, raw.clone())),
1149 invalid,
1150 raw_seen,
1151 };
1152 }
1153 Err(errors) => {
1154 if invalid.is_none() {
1155 invalid = Some((raw.clone(), errors));
1156 }
1157 }
1158 }
1159
1160 if envelope != SchemaEnvelope::Direct {
1161 match validate_against_schema(&value, &response_schema) {
1162 Ok(()) => {
1163 if let Some(unwrapped) = envelope.unwrap_final(&value) {
1164 match validate_against_schema(&unwrapped, schema) {
1165 Ok(()) => {
1166 return StructuredResolution {
1167 valid: Some((unwrapped, raw.clone())),
1168 invalid,
1169 raw_seen,
1170 };
1171 }
1172 Err(errors) => {
1173 if invalid.is_none() {
1174 invalid = Some((raw.clone(), errors));
1175 }
1176 }
1177 }
1178 } else if invalid.is_none() {
1179 invalid = Some((
1180 raw.clone(),
1181 vec!["$: response envelope was missing the expected value field"
1182 .to_string()],
1183 ));
1184 }
1185 }
1186 Err(errors) => {
1187 if invalid.is_none() {
1188 invalid = Some((raw.clone(), errors));
1189 }
1190 }
1191 }
1192 }
1193 }
1194 }
1195 StructuredResolution {
1196 valid: None,
1197 invalid,
1198 raw_seen,
1199 }
1200}
1201
1202pub(crate) fn parse_validated_output(text: &str, schema: &Value) -> Option<Value> {
1211 resolve_structured(
1212 &[text.to_string()],
1213 schema,
1214 SchemaEnvelope::for_schema(schema),
1215 )
1216 .valid
1217 .map(|(value, _)| value)
1218}
1219
1220fn truncate_utf8(s: &str, max: usize) -> &str {
1223 if s.len() <= max {
1224 return s;
1225 }
1226 let mut end = max;
1227 while end > 0 && !s.is_char_boundary(end) {
1228 end -= 1;
1229 }
1230 &s[..end]
1231}
1232
1233fn build_parse_failure_repair(raw_text: &str) -> String {
1235 if raw_text.trim().is_empty() {
1236 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();
1237 }
1238 format!(
1239 "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.",
1240 truncate_utf8(raw_text, 2000)
1241 )
1242}
1243
1244fn build_repair_message(raw_text: &str, errors: &[String]) -> String {
1245 let truncated_raw = if raw_text.len() > 2000 {
1247 format!(
1248 "{}...[truncated, {} bytes total]",
1249 truncate_utf8(raw_text, 2000),
1250 raw_text.len()
1251 )
1252 } else {
1253 raw_text.to_string()
1254 };
1255 format!(
1256 "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.",
1257 truncated_raw,
1258 errors.iter().map(|e| format!("- {}", e)).collect::<Vec<_>>().join("\n")
1259 )
1260}
1261
1262fn accumulate_usage(total: &mut TokenUsage, delta: &TokenUsage) {
1263 total.prompt_tokens += delta.prompt_tokens;
1264 total.completion_tokens += delta.completion_tokens;
1265 total.total_tokens += delta.total_tokens;
1266}
1267
1268fn append_repair_context(
1275 messages: &mut Vec<Message>,
1276 assistant_msg: &Message,
1277 repair_text: &str,
1278 mode: StructuredMode,
1279 _raw_text: &str,
1280) {
1281 if mode == StructuredMode::Tool {
1282 messages.push(assistant_msg.clone());
1284 let tool_use_id = assistant_msg
1286 .tool_calls()
1287 .first()
1288 .map(|tc| tc.id.clone())
1289 .unwrap_or_else(|| "unknown".to_string());
1290 messages.push(Message::tool_result(&tool_use_id, repair_text, true));
1292 } else {
1293 messages.push(assistant_msg.clone());
1295 messages.push(Message::user(repair_text));
1296 }
1297}
1298
1299#[cfg(test)]
1304#[path = "structured_tests.rs"]
1305mod structured_tests;