Skip to main content

a3s_code_core/llm/
structured.rs

1//! Structured object generation from LLM output.
2//!
3//! Provides reliable JSON object generation with schema validation, automatic
4//! repair, and streaming partial object support. Works across all providers by
5//! selecting the best available mode (strict JSON schema, json_mode, tool-call,
6//! or prompt-only).
7
8use 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
14// ---------------------------------------------------------------------------
15// Public types
16// ---------------------------------------------------------------------------
17
18/// Mode selection for structured output generation.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum StructuredMode {
22    /// Auto-select best mode based on provider capabilities.
23    Auto,
24    /// OpenAI native strict JSON schema (response_format.type = json_schema).
25    Strict,
26    /// OpenAI json_object mode (guarantees valid JSON, not schema-conformant).
27    Json,
28    /// Use tool-calling: inject a synthetic tool whose parameters IS the schema.
29    /// Works on all providers that support tool use (Anthropic, OpenAI, etc).
30    Tool,
31    /// Prompt-only: append schema instructions to the prompt. Least reliable.
32    Prompt,
33}
34
35/// Request specification for structured object generation.
36#[derive(Debug, Clone)]
37pub struct StructuredRequest {
38    pub prompt: String,
39    pub system: Option<String>,
40    pub schema: Value,
41    pub schema_name: String,
42    pub schema_description: Option<String>,
43    pub mode: StructuredMode,
44    pub max_repair_attempts: u8,
45}
46
47/// Result of a successful structured generation.
48#[derive(Debug, Clone, Serialize)]
49pub struct StructuredResult {
50    pub object: Value,
51    pub raw_text: Option<String>,
52    pub usage: TokenUsage,
53    pub repair_rounds: u8,
54    pub mode_used: StructuredMode,
55}
56
57/// Callback for streaming partial object snapshots.
58pub type PartialObjectCallback = Box<dyn Fn(&Value) + Send>;
59
60// ---------------------------------------------------------------------------
61// Core generation: blocking (non-streaming)
62// ---------------------------------------------------------------------------
63
64/// Generate a structured JSON object using the given LLM client.
65///
66/// Selects the best mode based on `req.mode`, calls the LLM, validates against
67/// the schema, and retries with repair prompts if validation fails.
68pub async fn generate_blocking(
69    client: &dyn LlmClient,
70    req: &StructuredRequest,
71) -> Result<StructuredResult> {
72    let mode = req.mode;
73    let mut messages = build_initial_messages(req, mode);
74    let system = build_system_prompt(req, mode);
75    let tools = build_tools(req, mode);
76
77    let mut total_usage = TokenUsage::default();
78    let mut repair_rounds: u8 = 0;
79
80    loop {
81        let resp = client
82            .complete(&messages, Some(&system), &tools)
83            .await
84            .context("LLM call failed during structured generation")?;
85
86        accumulate_usage(&mut total_usage, &resp.usage);
87
88        // Mine the object from every place a model might have parked it (tool call,
89        // text content, AND the reasoning channel), trying each balanced JSON
90        // candidate against the schema. Reasoning models routinely leave `content`
91        // empty and emit the object inside `reasoning`, so without the reasoning
92        // fallback generate_object failed with "no structured output" across models.
93        let candidates = extract_raw_candidates(&resp.message, mode);
94        let resolution = resolve_structured(&candidates, &req.schema);
95
96        if let Some((value, raw)) = resolution.valid {
97            return Ok(StructuredResult {
98                object: value,
99                raw_text: Some(raw),
100                usage: total_usage,
101                repair_rounds,
102                mode_used: mode,
103            });
104        }
105
106        if repair_rounds >= req.max_repair_attempts {
107            return Err(match resolution.invalid {
108                Some((_, errors)) => anyhow::anyhow!(
109                    "Structured output failed schema validation after {} repair attempts. Errors: {}",
110                    repair_rounds,
111                    errors.join("; ")
112                ),
113                None => anyhow::anyhow!(
114                    "Structured output parsing failed after {} repair attempts: no JSON object found in tool call, text content, or reasoning channel",
115                    repair_rounds
116                ),
117            });
118        }
119
120        repair_rounds += 1;
121        let (repair_msg, raw_for_ctx) = match resolution.invalid {
122            Some((raw, errors)) => (build_repair_message(&raw, &errors), raw),
123            None => {
124                let raw = resolution.raw_seen.unwrap_or_default();
125                (build_parse_failure_repair(&raw), raw)
126            }
127        };
128        append_repair_context(
129            &mut messages,
130            &resp.message,
131            &repair_msg,
132            mode,
133            &raw_for_ctx,
134        );
135    }
136}
137
138// ---------------------------------------------------------------------------
139// Core generation: streaming
140// ---------------------------------------------------------------------------
141
142/// Generate a structured JSON object with streaming partial updates.
143///
144/// Calls `on_partial` with progressively more complete partial objects as tokens
145/// arrive. Returns the final validated object.
146///
147/// In streaming mode, `max_repair_attempts` defaults to 0 because a repair
148/// would reset the partial object stream (confusing for consumers).
149pub async fn generate_streaming(
150    client: &dyn LlmClient,
151    req: &StructuredRequest,
152    on_partial: PartialObjectCallback,
153) -> Result<StructuredResult> {
154    let mode = req.mode;
155    let messages = build_initial_messages(req, mode);
156    let system = build_system_prompt(req, mode);
157    let tools = build_tools(req, mode);
158
159    let cancel_token = CancellationToken::new();
160    let mut rx = client
161        .complete_streaming(&messages, Some(&system), &tools, cancel_token)
162        .await
163        .context("LLM streaming call failed during structured generation")?;
164
165    let mut json_buffer = String::new();
166    let mut last_valid_partial: Option<Value> = None;
167    let mut final_response: Option<super::LlmResponse> = None;
168    let mut last_parse_len: usize = 0;
169    // Minimum bytes of new data before attempting a partial parse (reduces CPU)
170    const PARSE_THRESHOLD: usize = 8;
171
172    while let Some(event) = rx.recv().await {
173        match event {
174            StreamEvent::ToolUseInputDelta(delta) if mode == StructuredMode::Tool => {
175                if final_response.is_some() {
176                    continue;
177                }
178                json_buffer.push_str(&delta);
179                if json_buffer.len() - last_parse_len >= PARSE_THRESHOLD {
180                    if let Some(partial) = try_parse_partial_json(&json_buffer) {
181                        if last_valid_partial.as_ref() != Some(&partial) {
182                            on_partial(&partial);
183                            last_valid_partial = Some(partial);
184                        }
185                    }
186                    last_parse_len = json_buffer.len();
187                }
188            }
189            StreamEvent::TextDelta(delta) if mode != StructuredMode::Tool => {
190                if final_response.is_some() {
191                    continue;
192                }
193                json_buffer.push_str(&delta);
194                if json_buffer.len() - last_parse_len >= PARSE_THRESHOLD {
195                    if let Some(json_start) = find_json_start(&json_buffer) {
196                        let candidate = &json_buffer[json_start..];
197                        if let Some(partial) = try_parse_partial_json(candidate) {
198                            if last_valid_partial.as_ref() != Some(&partial) {
199                                on_partial(&partial);
200                                last_valid_partial = Some(partial);
201                            }
202                        }
203                    }
204                    last_parse_len = json_buffer.len();
205                }
206            }
207            StreamEvent::Done(resp) => {
208                final_response = Some(resp);
209            }
210            _ => {}
211        }
212    }
213
214    let resp = final_response.context("Stream ended without Done event")?;
215    // Same multi-source resolution as the blocking path: the final message may carry
216    // the object in the tool call, the text content, or the reasoning channel.
217    let candidates = extract_raw_candidates(&resp.message, mode);
218    let resolution = resolve_structured(&candidates, &req.schema);
219    let (value, raw_text) = match resolution.valid {
220        Some(vr) => vr,
221        None => {
222            return Err(match resolution.invalid {
223                Some((_, errors)) => anyhow::anyhow!(
224                    "Streamed structured output failed schema validation: {}",
225                    errors.join("; ")
226                ),
227                None => anyhow::anyhow!(
228                    "Streamed output produced no parseable JSON object (checked tool call, text content, and reasoning channel)"
229                ),
230            });
231        }
232    };
233
234    // Emit final complete object
235    on_partial(&value);
236
237    Ok(StructuredResult {
238        object: value,
239        raw_text: Some(raw_text),
240        usage: resp.usage,
241        repair_rounds: 0,
242        mode_used: mode,
243    })
244}
245
246// ---------------------------------------------------------------------------
247// JSON extraction and parsing
248// ---------------------------------------------------------------------------
249
250/// Extract a JSON value from potentially dirty LLM output.
251///
252/// Handles: raw JSON, markdown code fences, leading/trailing prose.
253pub fn extract_json_value(text: &str) -> Result<Value> {
254    let trimmed = text.trim();
255
256    // 1. Direct parse
257    if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
258        if v.is_object() || v.is_array() {
259            return Ok(v);
260        }
261    }
262
263    // 2. Strip markdown code fence
264    if let Some(inner) = strip_code_fence(trimmed) {
265        if let Ok(v) = serde_json::from_str::<Value>(inner.trim()) {
266            if v.is_object() || v.is_array() {
267                return Ok(v);
268            }
269        }
270    }
271
272    // 3. Find balanced JSON substring (first { to matching })
273    if let Some(candidate) = find_balanced_json_object(trimmed) {
274        if let Ok(v) = serde_json::from_str::<Value>(candidate) {
275            return Ok(v);
276        }
277    }
278
279    // 4. Try array
280    if let Some(candidate) = find_balanced_json_array(trimmed) {
281        if let Ok(v) = serde_json::from_str::<Value>(candidate) {
282            return Ok(v);
283        }
284    }
285
286    bail!("No valid JSON object found in LLM output")
287}
288
289/// Strip ```json ... ``` or ``` ... ``` fences.
290fn strip_code_fence(text: &str) -> Option<&str> {
291    let start_patterns = ["```json\n", "```json\r\n", "```\n", "```\r\n"];
292    for pat in &start_patterns {
293        if let Some(rest) = text.strip_prefix(pat) {
294            // Find closing fence
295            if let Some(end) = rest.rfind("```") {
296                return Some(&rest[..end]);
297            }
298        }
299    }
300    // Also handle inline: ```json{...}```
301    if let Some(inner) = text.strip_prefix("```json") {
302        if let Some(end) = inner.rfind("```") {
303            return Some(inner[..end].trim());
304        }
305    }
306    if let Some(inner) = text.strip_prefix("```") {
307        if let Some(end) = inner.rfind("```") {
308            return Some(inner[..end].trim());
309        }
310    }
311    None
312}
313
314/// Find the first balanced `{...}` substring using bracket counting.
315fn find_balanced_json_object(text: &str) -> Option<&str> {
316    find_balanced(text, '{', '}')
317}
318
319/// Find the first balanced `[...]` substring.
320fn find_balanced_json_array(text: &str) -> Option<&str> {
321    find_balanced(text, '[', ']')
322}
323
324fn find_balanced(text: &str, open: char, close: char) -> Option<&str> {
325    find_balanced_range(text, open, close).map(|(start, end)| &text[start..end])
326}
327
328/// Byte range `[start, end)` of the first balanced `open..close` substring (quote-aware).
329fn find_balanced_range(text: &str, open: char, close: char) -> Option<(usize, usize)> {
330    let bytes = text.as_bytes();
331    let open_byte = open as u8;
332    let close_byte = close as u8;
333
334    // Find the first unquoted occurrence of `open`
335    let mut in_string = false;
336    let mut escape_next = false;
337    let mut start = None;
338
339    for (i, &b) in bytes.iter().enumerate() {
340        if escape_next {
341            escape_next = false;
342            continue;
343        }
344        match b {
345            b'\\' if in_string => escape_next = true,
346            b'"' => in_string = !in_string,
347            _ if in_string => {}
348            _ if b == open_byte => {
349                start = Some(i);
350                break;
351            }
352            _ => {}
353        }
354    }
355
356    let start = start?;
357    let mut depth = 0i32;
358    in_string = false;
359    escape_next = false;
360
361    for (i, &b) in bytes[start..].iter().enumerate() {
362        if escape_next {
363            escape_next = false;
364            continue;
365        }
366        match b {
367            b'\\' if in_string => escape_next = true,
368            b'"' => in_string = !in_string,
369            _ if in_string => {}
370            _ if b == open_byte => depth += 1,
371            _ if b == close_byte => {
372                depth -= 1;
373                if depth == 0 {
374                    return Some((start, start + i + 1));
375                }
376            }
377            _ => {}
378        }
379    }
380    None
381}
382
383/// Every top-level balanced `open..close` substring, in document order.
384///
385/// Reasoning traces often contain several objects (worked examples, partial drafts)
386/// before the final answer, so callers validate each against the schema and keep the
387/// one that fits rather than blindly trusting the first `{...}`.
388fn find_all_balanced(text: &str, open: char, close: char) -> Vec<String> {
389    let mut out = Vec::new();
390    let mut base = 0usize;
391    while base < text.len() {
392        match find_balanced_range(&text[base..], open, close) {
393            Some((start, end)) => {
394                out.push(text[base + start..base + end].to_string());
395                base += end;
396            }
397            None => break,
398        }
399    }
400    out
401}
402
403/// Find the byte offset where JSON content starts in a text stream.
404/// Skips leading prose/whitespace to find `{` or `[` that isn't inside a string.
405fn find_json_start(text: &str) -> Option<usize> {
406    // Skip past code fence markers if present
407    let (search_text, offset) = if let Some(rest) = text.strip_prefix("```json") {
408        (rest, 7)
409    } else if let Some(rest) = text.strip_prefix("```") {
410        (rest, 3)
411    } else {
412        (text, 0)
413    };
414
415    let mut in_string = false;
416    let mut escape_next = false;
417    for (i, &b) in search_text.as_bytes().iter().enumerate() {
418        if escape_next {
419            escape_next = false;
420            continue;
421        }
422        match b {
423            b'\\' if in_string => {
424                escape_next = true;
425            }
426            b'"' => {
427                in_string = !in_string;
428            }
429            b'{' | b'[' if !in_string => {
430                return Some(offset + i);
431            }
432            _ => {}
433        }
434    }
435    None
436}
437
438// ---------------------------------------------------------------------------
439// Partial JSON parsing (for streaming)
440// ---------------------------------------------------------------------------
441
442/// Attempt to parse a potentially incomplete JSON string into the most complete
443/// valid partial object possible.
444///
445/// Strategy: try parsing as-is first. If that fails, progressively close open
446/// braces/brackets and try again. This handles the common case where the LLM
447/// has output `{"name": "foo", "items": [1, 2` — we close it to get a partial.
448fn try_parse_partial_json(text: &str) -> Option<Value> {
449    let trimmed = text.trim();
450    if trimmed.is_empty() {
451        return None;
452    }
453
454    // Fast path: already valid
455    if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
456        if v.is_object() || v.is_array() {
457            return Some(v);
458        }
459    }
460
461    // Count unclosed brackets/braces (respecting strings)
462    let mut closers = Vec::new();
463    let mut in_string = false;
464    let mut escape_next = false;
465    // Track if we're mid-value (after a colon or comma, before the value is complete)
466    let mut last_significant: Option<u8> = None;
467
468    for &b in trimmed.as_bytes() {
469        if escape_next {
470            escape_next = false;
471            continue;
472        }
473        match b {
474            b'\\' if in_string => {
475                escape_next = true;
476            }
477            b'"' => {
478                in_string = !in_string;
479                if !in_string {
480                    last_significant = Some(b'"');
481                }
482            }
483            _ if in_string => {}
484            b'{' => {
485                closers.push(b'}');
486                last_significant = Some(b'{');
487            }
488            b'[' => {
489                closers.push(b']');
490                last_significant = Some(b'[');
491            }
492            b'}' | b']' => {
493                closers.pop();
494                last_significant = Some(b);
495            }
496            b':' | b',' => {
497                last_significant = Some(b);
498            }
499            b if !b.is_ascii_whitespace() => {
500                last_significant = Some(b);
501            }
502            _ => {}
503        }
504    }
505
506    if closers.is_empty() {
507        return None; // Already balanced but didn't parse — genuinely invalid
508    }
509
510    // Pre-allocate repair buffer: original + at most 6 extra chars (null + closers)
511    let mut repaired = String::with_capacity(trimmed.len() + closers.len() + 6);
512    repaired.push_str(trimmed);
513
514    if in_string {
515        repaired.push('"');
516        last_significant = Some(b'"');
517    }
518
519    // If last significant char suggests an incomplete key or value, handle it
520    if let Some(last) = last_significant {
521        if last == b':' {
522            // Key with no value yet — add null
523            repaired.push_str("null");
524        } else if last == b',' {
525            // Trailing comma — some parsers choke on this, trim it
526            if let Some(pos) = repaired.rfind(',') {
527                repaired.truncate(pos);
528            }
529        }
530    }
531
532    // Close all open brackets/braces
533    for &closer in closers.iter().rev() {
534        repaired.push(closer as char);
535    }
536
537    serde_json::from_str::<Value>(&repaired)
538        .ok()
539        .filter(|v| v.is_object() || v.is_array())
540}
541
542// ---------------------------------------------------------------------------
543// Schema validation
544// ---------------------------------------------------------------------------
545
546/// Validate a JSON value against a JSON Schema.
547/// Returns Ok(()) on success, or a list of human-readable error strings.
548fn validate_against_schema(value: &Value, schema: &Value) -> Result<(), Vec<String>> {
549    // We do a basic recursive validation here. For production, consider using
550    // the `jsonschema` crate, but to avoid adding a heavy dependency we implement
551    // the subset of JSON Schema that matters for structured output.
552    let errors = basic_schema_validate(value, schema, "");
553    if errors.is_empty() {
554        Ok(())
555    } else {
556        Err(errors)
557    }
558}
559
560/// Basic JSON Schema validator covering the most common constraints.
561fn basic_schema_validate(value: &Value, schema: &Value, path: &str) -> Vec<String> {
562    let mut errors = Vec::new();
563
564    // Handle $ref — not supported in basic validator, skip
565    if schema.get("$ref").is_some() {
566        return errors;
567    }
568
569    // Handle anyOf / oneOf: value must match at least one sub-schema
570    if let Some(any_of) = schema
571        .get("anyOf")
572        .or_else(|| schema.get("oneOf"))
573        .and_then(|v| v.as_array())
574    {
575        let matched = any_of
576            .iter()
577            .any(|sub| basic_schema_validate(value, sub, path).is_empty());
578        if !matched {
579            errors.push(format!(
580                "{}: value does not match any variant in anyOf/oneOf",
581                path_or_root(path),
582            ));
583        }
584        return errors;
585    }
586
587    // Handle enum
588    if let Some(enum_values) = schema.get("enum").and_then(|v| v.as_array()) {
589        if !enum_values.contains(value) {
590            errors.push(format!(
591                "{}: value {:?} not in enum {:?}",
592                path_or_root(path),
593                value,
594                enum_values
595            ));
596        }
597        return errors;
598    }
599
600    // Handle const
601    if let Some(const_val) = schema.get("const") {
602        if value != const_val {
603            errors.push(format!(
604                "{}: expected const {:?}, got {:?}",
605                path_or_root(path),
606                const_val,
607                value
608            ));
609        }
610        return errors;
611    }
612
613    // Type checking (supports nullable via type array: ["string", "null"])
614    if let Some(type_val) = schema.get("type") {
615        let type_ok = if let Some(type_str) = type_val.as_str() {
616            check_type(value, type_str)
617        } else if let Some(type_arr) = type_val.as_array() {
618            type_arr
619                .iter()
620                .filter_map(|t| t.as_str())
621                .any(|t| check_type(value, t))
622        } else {
623            true
624        };
625        if !type_ok {
626            errors.push(format!(
627                "{}: expected type {:?}, got {:?}",
628                path_or_root(path),
629                type_val,
630                value_type_name(value)
631            ));
632            return errors;
633        }
634    }
635
636    // Object validation
637    if let Some(obj) = value.as_object() {
638        if let Some(properties) = schema.get("properties").and_then(|v| v.as_object()) {
639            for (key, prop_schema) in properties {
640                if let Some(child_value) = obj.get(key) {
641                    let child_path = if path.is_empty() {
642                        format!(".{}", key)
643                    } else {
644                        format!("{}.{}", path, key)
645                    };
646                    errors.extend(basic_schema_validate(child_value, prop_schema, &child_path));
647                }
648            }
649        }
650
651        if let Some(required) = schema.get("required").and_then(|v| v.as_array()) {
652            for req_field in required {
653                if let Some(field_name) = req_field.as_str() {
654                    if !obj.contains_key(field_name) {
655                        errors.push(format!(
656                            "{}: missing required field '{}'",
657                            path_or_root(path),
658                            field_name
659                        ));
660                    }
661                }
662            }
663        }
664
665        // additionalProperties: false
666        if schema.get("additionalProperties") == Some(&Value::Bool(false)) {
667            if let Some(properties) = schema.get("properties").and_then(|v| v.as_object()) {
668                for key in obj.keys() {
669                    if !properties.contains_key(key) {
670                        errors.push(format!(
671                            "{}: unexpected additional property '{}'",
672                            path_or_root(path),
673                            key
674                        ));
675                    }
676                }
677            }
678        }
679    }
680
681    // Array validation
682    if let Some(arr) = value.as_array() {
683        if let Some(items_schema) = schema.get("items") {
684            for (i, item) in arr.iter().enumerate() {
685                let child_path = format!("{}[{}]", path, i);
686                errors.extend(basic_schema_validate(item, items_schema, &child_path));
687            }
688        }
689        if let Some(min) = schema.get("minItems").and_then(|v| v.as_u64()) {
690            if (arr.len() as u64) < min {
691                errors.push(format!(
692                    "{}: array has {} items, minimum is {}",
693                    path_or_root(path),
694                    arr.len(),
695                    min
696                ));
697            }
698        }
699        if let Some(max) = schema.get("maxItems").and_then(|v| v.as_u64()) {
700            if (arr.len() as u64) > max {
701                errors.push(format!(
702                    "{}: array has {} items, maximum is {}",
703                    path_or_root(path),
704                    arr.len(),
705                    max
706                ));
707            }
708        }
709    }
710
711    // String validation
712    if let Some(s) = value.as_str() {
713        if let Some(min_len) = schema.get("minLength").and_then(|v| v.as_u64()) {
714            if (s.chars().count() as u64) < min_len {
715                errors.push(format!(
716                    "{}: string length {} < minLength {}",
717                    path_or_root(path),
718                    s.chars().count(),
719                    min_len
720                ));
721            }
722        }
723        if let Some(max_len) = schema.get("maxLength").and_then(|v| v.as_u64()) {
724            if (s.chars().count() as u64) > max_len {
725                errors.push(format!(
726                    "{}: string length {} > maxLength {}",
727                    path_or_root(path),
728                    s.chars().count(),
729                    max_len
730                ));
731            }
732        }
733        if let Some(pattern) = schema.get("pattern").and_then(|v| v.as_str()) {
734            if let Ok(re) = regex::Regex::new(pattern) {
735                if !re.is_match(s) {
736                    errors.push(format!(
737                        "{}: string does not match pattern '{}'",
738                        path_or_root(path),
739                        pattern
740                    ));
741                }
742            }
743        }
744    }
745
746    // Number validation
747    if let Some(n) = value.as_f64() {
748        if let Some(min) = schema.get("minimum").and_then(|v| v.as_f64()) {
749            if n < min {
750                errors.push(format!(
751                    "{}: value {} < minimum {}",
752                    path_or_root(path),
753                    n,
754                    min
755                ));
756            }
757        }
758        if let Some(max) = schema.get("maximum").and_then(|v| v.as_f64()) {
759            if n > max {
760                errors.push(format!(
761                    "{}: value {} > maximum {}",
762                    path_or_root(path),
763                    n,
764                    max
765                ));
766            }
767        }
768        if let Some(exc_min) = schema.get("exclusiveMinimum").and_then(|v| v.as_f64()) {
769            if n <= exc_min {
770                errors.push(format!(
771                    "{}: value {} <= exclusiveMinimum {}",
772                    path_or_root(path),
773                    n,
774                    exc_min
775                ));
776            }
777        }
778        if let Some(exc_max) = schema.get("exclusiveMaximum").and_then(|v| v.as_f64()) {
779            if n >= exc_max {
780                errors.push(format!(
781                    "{}: value {} >= exclusiveMaximum {}",
782                    path_or_root(path),
783                    n,
784                    exc_max
785                ));
786            }
787        }
788    }
789
790    errors
791}
792
793fn check_type(value: &Value, type_str: &str) -> bool {
794    match type_str {
795        "object" => value.is_object(),
796        "array" => value.is_array(),
797        "string" => value.is_string(),
798        "number" => value.is_number(),
799        "integer" => {
800            value.is_i64()
801                || value.is_u64()
802                || value
803                    .as_f64()
804                    .map(|f| f.fract() == 0.0 && f.is_finite())
805                    .unwrap_or(false)
806        }
807        "boolean" => value.is_boolean(),
808        "null" => value.is_null(),
809        _ => true,
810    }
811}
812
813fn path_or_root(path: &str) -> &str {
814    if path.is_empty() {
815        "$"
816    } else {
817        path
818    }
819}
820
821fn value_type_name(value: &Value) -> &'static str {
822    match value {
823        Value::Null => "null",
824        Value::Bool(_) => "boolean",
825        Value::Number(_) => "number",
826        Value::String(_) => "string",
827        Value::Array(_) => "array",
828        Value::Object(_) => "object",
829    }
830}
831
832// ---------------------------------------------------------------------------
833// Message/prompt construction helpers
834// ---------------------------------------------------------------------------
835
836fn build_initial_messages(req: &StructuredRequest, mode: StructuredMode) -> Vec<Message> {
837    match mode {
838        StructuredMode::Tool => {
839            // For tool mode, the prompt is the user message; the LLM will respond
840            // with a tool call whose input is the structured object.
841            vec![Message::user(&req.prompt)]
842        }
843        StructuredMode::Prompt => {
844            // Append schema instructions to the user prompt
845            let augmented = format!(
846                "{}\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```",
847                req.prompt,
848                serde_json::to_string_pretty(&req.schema).unwrap_or_default()
849            );
850            vec![Message::user(&augmented)]
851        }
852        _ => {
853            // Strict/Json modes: the schema constraint is enforced by the provider,
854            // so the user message is just the prompt.
855            vec![Message::user(&req.prompt)]
856        }
857    }
858}
859
860fn build_system_prompt(req: &StructuredRequest, mode: StructuredMode) -> String {
861    let base = req.system.as_deref().unwrap_or("");
862
863    match mode {
864        StructuredMode::Tool => {
865            format!(
866                "{}{}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.",
867                base,
868                if base.is_empty() { "" } else { "\n\n" },
869                req.schema_name
870            )
871        }
872        StructuredMode::Prompt => {
873            format!(
874                "{}{}You are a structured data extraction assistant. Always respond with valid JSON only, no markdown fences, no explanation text.",
875                base,
876                if base.is_empty() { "" } else { "\n\n" },
877            )
878        }
879        _ => base.to_string(),
880    }
881}
882
883fn build_tools(req: &StructuredRequest, mode: StructuredMode) -> Vec<ToolDefinition> {
884    match mode {
885        StructuredMode::Tool => {
886            vec![ToolDefinition {
887                name: format!("emit_{}", req.schema_name),
888                description: req
889                    .schema_description
890                    .clone()
891                    .unwrap_or_else(|| format!("Emit a structured {} object", req.schema_name)),
892                parameters: req.schema.clone(),
893            }]
894        }
895        _ => vec![],
896    }
897}
898
899/// Outcome of mining a response for the structured object across all candidate sources.
900struct StructuredResolution {
901    /// A schema-valid object plus the raw source string it came from.
902    valid: Option<(Value, String)>,
903    /// First parseable-but-schema-invalid object source + its validation errors,
904    /// used to build a targeted repair prompt.
905    invalid: Option<(String, Vec<String>)>,
906    /// First non-empty raw candidate, shown verbatim in a parse-failure repair prompt.
907    raw_seen: Option<String>,
908}
909
910/// Append `s` to `out` if it is non-empty and not already present (trimmed, deduped).
911fn push_candidate(out: &mut Vec<String>, s: String) {
912    let trimmed = s.trim();
913    if !trimmed.is_empty() && !out.iter().any(|c| c == trimmed) {
914        out.push(trimmed.to_string());
915    }
916}
917
918/// Ordered raw strings to mine for the structured object, most authoritative first:
919/// tool-call arguments, then text content, then the reasoning channel.
920///
921/// The reasoning fallback is the crux of the cross-model fix: reasoning models
922/// (GLM/zhipu, DeepSeek-R1, kimi…) frequently emit the final object inside
923/// `reasoning` with `content` empty and no tool call. Earlier extraction only looked
924/// at the tool call / text, so those models yielded an empty string and the whole
925/// generate_object failed even though a perfectly good object was produced.
926fn extract_raw_candidates(message: &super::Message, mode: StructuredMode) -> Vec<String> {
927    let mut out: Vec<String> = Vec::new();
928    if mode == StructuredMode::Tool {
929        if let Some(call) = message.tool_calls().first() {
930            push_candidate(
931                &mut out,
932                serde_json::to_string(&call.args).unwrap_or_default(),
933            );
934        }
935    }
936    push_candidate(&mut out, message.text());
937    if let Some(reasoning) = message.reasoning_content.as_deref() {
938        push_candidate(&mut out, reasoning.to_string());
939    }
940    out
941}
942
943/// Every JSON object/array value mineable from possibly-dirty text, in document order
944/// (direct parse, code fences, then all balanced `{...}` / `[...]`). Deduped.
945fn extract_all_json_values(text: &str) -> Vec<Value> {
946    let trimmed = text.trim();
947    let mut values: Vec<Value> = Vec::new();
948    let consider = |candidate: &str, values: &mut Vec<Value>| {
949        if let Ok(v) = serde_json::from_str::<Value>(candidate.trim()) {
950            if (v.is_object() || v.is_array()) && !values.contains(&v) {
951                values.push(v);
952            }
953        }
954    };
955    consider(trimmed, &mut values);
956    if let Some(inner) = strip_code_fence(trimmed) {
957        consider(inner, &mut values);
958    }
959    for candidate in find_all_balanced(trimmed, '{', '}') {
960        consider(&candidate, &mut values);
961    }
962    for candidate in find_all_balanced(trimmed, '[', ']') {
963        consider(&candidate, &mut values);
964    }
965    values
966}
967
968/// Try every raw candidate × every JSON value it yields against the schema; return the
969/// first schema-valid object, else the best parseable-but-invalid object (for repair).
970fn resolve_structured(candidates: &[String], schema: &Value) -> StructuredResolution {
971    let mut invalid: Option<(String, Vec<String>)> = None;
972    let mut raw_seen: Option<String> = None;
973    for raw in candidates {
974        if raw_seen.is_none() && !raw.trim().is_empty() {
975            raw_seen = Some(raw.clone());
976        }
977        for value in extract_all_json_values(raw) {
978            match validate_against_schema(&value, schema) {
979                Ok(()) => {
980                    return StructuredResolution {
981                        valid: Some((value, raw.clone())),
982                        invalid,
983                        raw_seen,
984                    };
985                }
986                Err(errors) => {
987                    if invalid.is_none() {
988                        invalid = Some((raw.clone(), errors));
989                    }
990                }
991            }
992        }
993    }
994    StructuredResolution {
995        valid: None,
996        invalid,
997        raw_seen,
998    }
999}
1000
1001/// UTF-8-safe truncation to at most `max` bytes (never splits a multibyte char —
1002/// repair prompts echo arbitrary model output, including CJK).
1003fn truncate_utf8(s: &str, max: usize) -> &str {
1004    if s.len() <= max {
1005        return s;
1006    }
1007    let mut end = max;
1008    while end > 0 && !s.is_char_boundary(end) {
1009        end -= 1;
1010    }
1011    &s[..end]
1012}
1013
1014/// Repair prompt for when nothing parseable was produced at all.
1015fn build_parse_failure_repair(raw_text: &str) -> String {
1016    if raw_text.trim().is_empty() {
1017        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();
1018    }
1019    format!(
1020        "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.",
1021        truncate_utf8(raw_text, 2000)
1022    )
1023}
1024
1025fn build_repair_message(raw_text: &str, errors: &[String]) -> String {
1026    // Truncate raw output in repair message to avoid blowing context
1027    let truncated_raw = if raw_text.len() > 2000 {
1028        format!(
1029            "{}...[truncated, {} bytes total]",
1030            truncate_utf8(raw_text, 2000),
1031            raw_text.len()
1032        )
1033    } else {
1034        raw_text.to_string()
1035    };
1036    format!(
1037        "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.",
1038        truncated_raw,
1039        errors.iter().map(|e| format!("- {}", e)).collect::<Vec<_>>().join("\n")
1040    )
1041}
1042
1043fn accumulate_usage(total: &mut TokenUsage, delta: &TokenUsage) {
1044    total.prompt_tokens += delta.prompt_tokens;
1045    total.completion_tokens += delta.completion_tokens;
1046    total.total_tokens += delta.total_tokens;
1047}
1048
1049/// Append repair context to the message history, respecting conversation structure.
1050///
1051/// In tool mode, the LLM returned a tool_use block. The correct follow-up is:
1052///   assistant (tool_use) → user (tool_result with error) → assistant (retry)
1053/// In text modes, it's simply:
1054///   assistant (text) → user (repair request) → assistant (retry)
1055fn append_repair_context(
1056    messages: &mut Vec<Message>,
1057    assistant_msg: &Message,
1058    repair_text: &str,
1059    mode: StructuredMode,
1060    _raw_text: &str,
1061) {
1062    if mode == StructuredMode::Tool {
1063        // Push the original assistant message (with tool_use block intact)
1064        messages.push(assistant_msg.clone());
1065        // Find the tool_use ID to construct a proper tool_result
1066        let tool_use_id = assistant_msg
1067            .tool_calls()
1068            .first()
1069            .map(|tc| tc.id.clone())
1070            .unwrap_or_else(|| "unknown".to_string());
1071        // Return the error as a tool_result so the conversation stays valid
1072        messages.push(Message::tool_result(&tool_use_id, repair_text, true));
1073    } else {
1074        // Text modes: push assistant text then user repair request
1075        messages.push(assistant_msg.clone());
1076        messages.push(Message::user(repair_text));
1077    }
1078}
1079
1080// ---------------------------------------------------------------------------
1081// Tests
1082// ---------------------------------------------------------------------------
1083
1084#[cfg(test)]
1085#[path = "structured_tests.rs"]
1086mod structured_tests;