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
14mod partial_json;
15use partial_json::parse_partial_json;
16#[cfg(test)]
17use partial_json::try_parse_partial_json;
18
19// ---------------------------------------------------------------------------
20// Public types
21// ---------------------------------------------------------------------------
22
23/// Mode selection for structured output generation.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum StructuredMode {
27    /// Auto-select best mode based on provider capabilities.
28    Auto,
29    /// OpenAI native strict JSON schema (response_format.type = json_schema).
30    Strict,
31    /// OpenAI json_object mode (guarantees valid JSON, not schema-conformant).
32    Json,
33    /// Use tool-calling: inject a synthetic tool whose parameters IS the schema.
34    /// Works on all providers that support tool use (Anthropic, OpenAI, etc).
35    Tool,
36    /// Prompt-only: append schema instructions to the prompt. Least reliable.
37    Prompt,
38}
39
40/// Request specification for structured object generation.
41#[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/// Result of a successful structured generation.
53#[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/// Provider-native structured-output capability.
63///
64/// Each [`LlmClient`] reports this so the structured engine can request the
65/// strongest enforcement the provider actually supports. Defaults to
66/// [`NativeStructuredSupport::None`] for clients that don't override it.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum NativeStructuredSupport {
69    /// No native enforcement — rely on prompt instructions + lenient extraction.
70    None,
71    /// Can force a specific tool call (Anthropic `tool_choice`, OpenAI function
72    /// `tool_choice`). Guarantees the model emits the structured tool call
73    /// instead of free-form prose.
74    ForcedTool,
75    /// Supports OpenAI-style `response_format` (`json_object` and
76    /// `json_schema` + `strict`) in addition to forced tool calls.
77    JsonSchema,
78}
79
80/// A native `response_format` request for OpenAI-compatible providers.
81#[derive(Debug, Clone, PartialEq)]
82pub enum ResponseFormat {
83    /// `{"type":"json_object"}` — guarantees syntactically valid JSON, but not
84    /// schema conformance.
85    JsonObject,
86    /// `{"type":"json_schema","json_schema":{name,schema,strict:true}}` —
87    /// parser-enforced schema conformance.
88    JsonSchema { name: String, schema: Value },
89}
90
91/// Instruction telling a provider how to enforce structured output for a call.
92///
93/// Carries the union of intents; each provider honors what it supports and
94/// ignores the rest (e.g. Anthropic has no `response_format`, so it only acts
95/// on `force_tool`). The default (`force_tool: None, response_format: None`)
96/// reproduces an ordinary completion, which is why the trait's default
97/// `complete_structured` impl is behavior-preserving.
98#[derive(Debug, Clone, Default, PartialEq)]
99pub struct StructuredDirective {
100    /// Force the model to call exactly this tool (provider `tool_choice`).
101    pub force_tool: Option<String>,
102    /// Request a provider-native `response_format` (OpenAI-compatible only).
103    pub response_format: Option<ResponseFormat>,
104}
105
106/// Callback for streaming partial object snapshots.
107pub type PartialObjectCallback = Box<dyn Fn(&Value) + Send>;
108
109/// Provider-facing schema envelope.
110///
111/// Function/tool parameters are most reliable when the top-level schema is an
112/// object. Inspired by Vercel AI SDK's `Output.array` / `Output.choice`
113/// wrappers, A3S sends top-level arrays and scalar schemas inside a small object
114/// envelope, then unwraps the validated value before returning it to callers.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116enum SchemaEnvelope {
117    Direct,
118    Elements,
119    Value,
120}
121
122impl SchemaEnvelope {
123    fn for_schema(schema: &Value) -> Self {
124        match schema_root_kind(schema, schema, &mut Vec::new(), 0) {
125            Some(SchemaRootKind::Object) => Self::Direct,
126            Some(SchemaRootKind::Array) => Self::Elements,
127            Some(SchemaRootKind::Other) | None => Self::Value,
128        }
129    }
130
131    fn response_schema(self, schema: &Value) -> Value {
132        match self {
133            Self::Direct => schema.clone(),
134            Self::Elements => wrap_response_schema("elements", schema),
135            Self::Value => wrap_response_schema("value", schema),
136        }
137    }
138
139    fn unwrap_final(self, value: &Value) -> Option<Value> {
140        match self {
141            Self::Direct => Some(value.clone()),
142            Self::Elements => value.get("elements").cloned(),
143            Self::Value => value.get("value").cloned(),
144        }
145    }
146
147    fn project_partial(self, value: &Value, repaired: bool) -> Option<Value> {
148        match self {
149            Self::Direct => Some(value.clone()),
150            Self::Elements => {
151                let mut elements = value.get("elements")?.as_array()?.clone();
152                // A repaired parse may include a synthetic last element that was
153                // closed only so the partial JSON can parse. Match Vercel's
154                // array streaming behavior: publish only completed elements.
155                if repaired && !elements.is_empty() {
156                    elements.pop();
157                }
158                Some(Value::Array(elements))
159            }
160            Self::Value => value.get("value").cloned(),
161        }
162    }
163
164    fn instruction(self) -> &'static str {
165        match self {
166            Self::Direct => "",
167            Self::Elements => {
168                "The provider-facing response schema wraps the requested array in an `elements` field. Follow that schema exactly; callers receive the unwrapped array."
169            }
170            Self::Value => {
171                "The provider-facing response schema wraps the requested scalar/enum value in a `value` field. Follow that schema exactly; callers receive the unwrapped value."
172            }
173        }
174    }
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178enum SchemaRootKind {
179    Object,
180    Array,
181    Other,
182}
183
184fn schema_root_kind(
185    schema: &Value,
186    root: &Value,
187    active_refs: &mut Vec<String>,
188    depth: usize,
189) -> Option<SchemaRootKind> {
190    if depth > 64 {
191        return None;
192    }
193    let object = schema.as_object()?;
194
195    if let Some(kind) = object.get("type").and_then(schema_type_kind) {
196        return Some(kind);
197    }
198    if let Some(value) = object.get("const") {
199        return Some(value_kind(value));
200    }
201    if let Some(values) = object.get("enum").and_then(Value::as_array) {
202        if let Some(kind) = common_value_kind(values) {
203            return Some(kind);
204        }
205    }
206    if let Some(reference) = object.get("$ref").and_then(Value::as_str) {
207        if let Some(pointer) = reference.strip_prefix('#') {
208            if !active_refs.iter().any(|active| active == reference) {
209                if let Some(target) = root.pointer(pointer) {
210                    active_refs.push(reference.to_string());
211                    let kind = schema_root_kind(target, root, active_refs, depth + 1);
212                    active_refs.pop();
213                    if kind.is_some() {
214                        return kind;
215                    }
216                }
217            }
218        }
219    }
220    if let Some(all_of) = object.get("allOf").and_then(Value::as_array) {
221        if let Some(kind) = all_of
222            .iter()
223            .find_map(|branch| schema_root_kind(branch, root, active_refs, depth + 1))
224        {
225            return Some(kind);
226        }
227    }
228    for keyword in ["anyOf", "oneOf"] {
229        if let Some(branches) = object.get(keyword).and_then(Value::as_array) {
230            let kinds = branches
231                .iter()
232                .map(|branch| schema_root_kind(branch, root, active_refs, depth + 1))
233                .collect::<Option<Vec<_>>>();
234            if let Some(kinds) = kinds {
235                if let Some(first) = kinds.first().copied() {
236                    if kinds.iter().all(|kind| *kind == first) {
237                        return Some(first);
238                    }
239                }
240            }
241        }
242    }
243
244    // Preserve the established object-schema behavior for schemas that rely
245    // on object-only keywords without an explicit `type`.
246    if ["properties", "required", "additionalProperties"]
247        .iter()
248        .any(|keyword| object.contains_key(*keyword))
249    {
250        return Some(SchemaRootKind::Object);
251    }
252    None
253}
254
255fn schema_type_kind(value: &Value) -> Option<SchemaRootKind> {
256    match value {
257        Value::String(value) => Some(type_name_kind(value)),
258        Value::Array(values) if values.len() == 1 => values[0].as_str().map(type_name_kind),
259        _ => None,
260    }
261}
262
263fn type_name_kind(value: &str) -> SchemaRootKind {
264    match value {
265        "object" => SchemaRootKind::Object,
266        "array" => SchemaRootKind::Array,
267        _ => SchemaRootKind::Other,
268    }
269}
270
271fn value_kind(value: &Value) -> SchemaRootKind {
272    match value {
273        Value::Object(_) => SchemaRootKind::Object,
274        Value::Array(_) => SchemaRootKind::Array,
275        _ => SchemaRootKind::Other,
276    }
277}
278
279fn common_value_kind(values: &[Value]) -> Option<SchemaRootKind> {
280    let first = values.first().map(value_kind)?;
281    values
282        .iter()
283        .all(|value| value_kind(value) == first)
284        .then_some(first)
285}
286
287fn wrap_response_schema(field: &str, schema: &Value) -> Value {
288    let mut embedded = schema.clone();
289    let mut wrapper = serde_json::json!({
290        "type": "object",
291        "required": [field],
292        "additionalProperties": false,
293        "properties": {}
294    });
295
296    // A local reference such as `#/$defs/item` resolves from the provider-
297    // facing document root. Hoist root definitions when the requested schema
298    // must be wrapped so those references retain their original meaning.
299    if let Some(embedded_object) = embedded.as_object_mut() {
300        for keyword in ["$defs", "definitions"] {
301            if let Some(definitions) = embedded_object.remove(keyword) {
302                wrapper[keyword] = definitions;
303            }
304        }
305    }
306    wrapper["properties"][field] = embedded;
307    wrapper
308}
309
310// ---------------------------------------------------------------------------
311// Core generation: blocking (non-streaming)
312// ---------------------------------------------------------------------------
313
314/// Generate a structured JSON object using the given LLM client.
315///
316/// Selects the best mode based on `req.mode`, calls the LLM, validates against
317/// the schema, and retries with repair prompts if validation fails.
318pub async fn generate_blocking(
319    client: &dyn LlmClient,
320    req: &StructuredRequest,
321) -> Result<StructuredResult> {
322    let mode = resolve_mode(req.mode, client.native_structured_support());
323    let envelope = SchemaEnvelope::for_schema(&req.schema);
324    let mut messages = build_initial_messages(req, mode);
325    let system = build_system_prompt(req, mode);
326    let tools = build_tools(req, mode);
327    let directive = build_directive(req, mode);
328
329    let mut total_usage = TokenUsage::default();
330    let mut repair_rounds: u8 = 0;
331
332    loop {
333        let resp = client
334            .complete_structured(&messages, Some(&system), &tools, &directive)
335            .await
336            .context("LLM call failed during structured generation")?;
337
338        accumulate_usage(&mut total_usage, &resp.usage);
339
340        // Mine the object from every place a model might have parked it (tool call,
341        // text content, AND the reasoning channel), trying each balanced JSON
342        // candidate against the schema. Reasoning models routinely leave `content`
343        // empty and emit the object inside `reasoning`, so without the reasoning
344        // fallback generate_object failed with "no structured output" across models.
345        let candidates = extract_raw_candidates(&resp.message, mode);
346        let resolution = resolve_structured(&candidates, &req.schema, envelope);
347
348        if let Some((value, raw)) = resolution.valid {
349            return Ok(StructuredResult {
350                object: value,
351                raw_text: Some(raw),
352                usage: total_usage,
353                repair_rounds,
354                mode_used: mode,
355            });
356        }
357
358        if repair_rounds >= req.max_repair_attempts {
359            return Err(match resolution.invalid {
360                Some((_, errors)) => anyhow::anyhow!(
361                    "Structured output failed schema validation after {} repair attempts. Errors: {}",
362                    repair_rounds,
363                    errors.join("; ")
364                ),
365                None => anyhow::anyhow!(
366                    "Structured output parsing failed after {} repair attempts: no JSON object found in tool call, text content, or reasoning channel",
367                    repair_rounds
368                ),
369            });
370        }
371
372        repair_rounds += 1;
373        let (repair_msg, raw_for_ctx) = match resolution.invalid {
374            Some((raw, errors)) => (build_repair_message(&raw, &errors), raw),
375            None => {
376                let raw = resolution.raw_seen.unwrap_or_default();
377                (build_parse_failure_repair(&raw), raw)
378            }
379        };
380        append_repair_context(
381            &mut messages,
382            &resp.message,
383            &repair_msg,
384            mode,
385            &raw_for_ctx,
386        );
387    }
388}
389
390// ---------------------------------------------------------------------------
391// Core generation: streaming
392// ---------------------------------------------------------------------------
393
394/// Generate a structured JSON object with streaming partial updates.
395///
396/// Calls `on_partial` with progressively more complete partial objects as tokens
397/// arrive. Returns the final validated object.
398///
399/// A streamed first attempt may be followed by bounded non-streaming repair
400/// calls when `max_repair_attempts` is non-zero. Repair calls publish only the
401/// final corrected object, avoiding a second misleading partial stream.
402pub async fn generate_streaming(
403    client: &dyn LlmClient,
404    req: &StructuredRequest,
405    on_partial: PartialObjectCallback,
406) -> Result<StructuredResult> {
407    let mode = resolve_mode(req.mode, client.native_structured_support());
408    let envelope = SchemaEnvelope::for_schema(&req.schema);
409    let mut messages = build_initial_messages(req, mode);
410    let system = build_system_prompt(req, mode);
411    let tools = build_tools(req, mode);
412    let directive = build_directive(req, mode);
413
414    let cancel_token = CancellationToken::new();
415    let mut rx = client
416        .complete_streaming_structured(
417            &messages,
418            Some(&system),
419            &tools,
420            &directive,
421            cancel_token.clone(),
422        )
423        .await
424        .context("LLM streaming call failed during structured generation")?;
425
426    let mut json_buffer = String::new();
427    let mut last_valid_partial: Option<Value> = None;
428    let mut final_response: Option<super::LlmResponse> = None;
429    let mut last_parse_len: usize = 0;
430    let mut complete_candidate: Option<(Value, String, tokio::time::Instant)> = None;
431    // Minimum bytes of new data before attempting a partial parse (reduces CPU)
432    const PARSE_THRESHOLD: usize = 8;
433    // Well-behaved providers send Done immediately after the complete object.
434    // A short grace preserves their final usage metadata while preventing an
435    // otherwise valid result from hanging on a compatible endpoint that never
436    // terminates its stream.
437    const DONE_GRACE: std::time::Duration = std::time::Duration::from_millis(250);
438    loop {
439        let event = if let Some((_, _, deadline)) = complete_candidate.as_ref() {
440            tokio::select! {
441                event = rx.recv() => event,
442                _ = tokio::time::sleep_until(*deadline) => {
443                    let candidate = complete_candidate
444                        .take()
445                        .expect("complete streamed candidate exists");
446                    let (value, raw_text, _) = candidate;
447                    cancel_token.cancel();
448                    on_partial(&value);
449                    return Ok(StructuredResult {
450                        object: value,
451                        raw_text: Some(raw_text),
452                        usage: TokenUsage::default(),
453                        repair_rounds: 0,
454                        mode_used: mode,
455                    });
456                }
457            }
458        } else {
459            rx.recv().await
460        };
461        let Some(event) = event else {
462            if let Some((value, raw_text, _)) = complete_candidate.take() {
463                cancel_token.cancel();
464                on_partial(&value);
465                return Ok(StructuredResult {
466                    object: value,
467                    raw_text: Some(raw_text),
468                    usage: TokenUsage::default(),
469                    repair_rounds: 0,
470                    mode_used: mode,
471                });
472            }
473            break;
474        };
475        match event {
476            StreamEvent::ToolUseInputDelta { delta, .. } if mode == StructuredMode::Tool => {
477                if final_response.is_some() {
478                    continue;
479                }
480                json_buffer.push_str(&delta);
481                if json_buffer.len() - last_parse_len >= PARSE_THRESHOLD {
482                    if let Some(partial) = parse_partial_json(&json_buffer) {
483                        if let Some(projected) =
484                            envelope.project_partial(&partial.value, partial.repaired)
485                        {
486                            if last_valid_partial.as_ref() != Some(&projected) {
487                                on_partial(&projected);
488                                last_valid_partial = Some(projected);
489                            }
490                        }
491                    }
492                    last_parse_len = json_buffer.len();
493                }
494                if complete_candidate.is_none() && (delta.contains('}') || delta.contains(']')) {
495                    complete_candidate = resolve_structured(
496                        std::slice::from_ref(&json_buffer),
497                        &req.schema,
498                        envelope,
499                    )
500                    .valid
501                    .map(|(value, raw_text)| {
502                        (value, raw_text, tokio::time::Instant::now() + DONE_GRACE)
503                    });
504                }
505            }
506            StreamEvent::TextDelta(delta) if mode != StructuredMode::Tool => {
507                if final_response.is_some() {
508                    continue;
509                }
510                json_buffer.push_str(&delta);
511                if json_buffer.len() - last_parse_len >= PARSE_THRESHOLD {
512                    if let Some(json_start) = find_json_start(&json_buffer) {
513                        let candidate = &json_buffer[json_start..];
514                        if let Some(partial) = parse_partial_json(candidate) {
515                            if let Some(projected) =
516                                envelope.project_partial(&partial.value, partial.repaired)
517                            {
518                                if last_valid_partial.as_ref() != Some(&projected) {
519                                    on_partial(&projected);
520                                    last_valid_partial = Some(projected);
521                                }
522                            }
523                        }
524                    }
525                    last_parse_len = json_buffer.len();
526                }
527                if complete_candidate.is_none() && (delta.contains('}') || delta.contains(']')) {
528                    complete_candidate = resolve_structured(
529                        std::slice::from_ref(&json_buffer),
530                        &req.schema,
531                        envelope,
532                    )
533                    .valid
534                    .map(|(value, raw_text)| {
535                        (value, raw_text, tokio::time::Instant::now() + DONE_GRACE)
536                    });
537                }
538            }
539            StreamEvent::Done(resp) => {
540                final_response = Some(resp);
541                break;
542            }
543            _ => {}
544        }
545    }
546
547    let mut resp = final_response.context("Stream ended without Done event")?;
548    let mut total_usage = TokenUsage::default();
549    accumulate_usage(&mut total_usage, &resp.usage);
550    let mut repair_rounds = 0u8;
551    // Same multi-source resolution as the blocking path: the final message may carry
552    // the object in the tool call, the text content, or the reasoning channel.
553    let mut resolution = resolve_structured(
554        &extract_raw_candidates(&resp.message, mode),
555        &req.schema,
556        envelope,
557    );
558    let (value, raw_text) = loop {
559        if let Some(valid) = resolution.valid.take() {
560            break valid;
561        }
562
563        if repair_rounds >= req.max_repair_attempts {
564            return Err(match resolution.invalid {
565                Some((_, errors)) => anyhow::anyhow!(
566                    "Streamed structured output failed schema validation after {} repair attempts: {}",
567                    repair_rounds,
568                    errors.join("; ")
569                ),
570                None => anyhow::anyhow!(
571                    "Streamed output produced no parseable JSON object after {} repair attempts (checked tool call, text content, and reasoning channel)",
572                    repair_rounds
573                ),
574            });
575        }
576
577        repair_rounds += 1;
578        let (repair_message, raw_for_context) = match resolution.invalid.take() {
579            Some((raw, errors)) => (build_repair_message(&raw, &errors), raw),
580            None => {
581                let raw = resolution.raw_seen.take().unwrap_or_default();
582                (build_parse_failure_repair(&raw), raw)
583            }
584        };
585        append_repair_context(
586            &mut messages,
587            &resp.message,
588            &repair_message,
589            mode,
590            &raw_for_context,
591        );
592        resp = client
593            .complete_structured(&messages, Some(&system), &tools, &directive)
594            .await
595            .context("LLM call failed while repairing streamed structured output")?;
596        accumulate_usage(&mut total_usage, &resp.usage);
597        resolution = resolve_structured(
598            &extract_raw_candidates(&resp.message, mode),
599            &req.schema,
600            envelope,
601        );
602    };
603
604    // Emit final complete object
605    on_partial(&value);
606
607    Ok(StructuredResult {
608        object: value,
609        raw_text: Some(raw_text),
610        usage: total_usage,
611        repair_rounds,
612        mode_used: mode,
613    })
614}
615
616// ---------------------------------------------------------------------------
617// JSON extraction and parsing
618// ---------------------------------------------------------------------------
619
620/// Extract a JSON value from potentially dirty LLM output.
621///
622/// Handles: raw JSON, markdown code fences, leading/trailing prose.
623pub fn extract_json_value(text: &str) -> Result<Value> {
624    let trimmed = text.trim();
625
626    // 1. Direct parse
627    if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
628        if v.is_object() || v.is_array() {
629            return Ok(v);
630        }
631    }
632
633    // 2. Strip markdown code fence
634    if let Some(inner) = strip_code_fence(trimmed) {
635        if let Ok(v) = serde_json::from_str::<Value>(inner.trim()) {
636            if v.is_object() || v.is_array() {
637                return Ok(v);
638            }
639        }
640    }
641
642    // 3. Find balanced JSON substring (first { to matching })
643    if let Some(candidate) = find_balanced_json_object(trimmed) {
644        if let Ok(v) = serde_json::from_str::<Value>(candidate) {
645            return Ok(v);
646        }
647    }
648
649    // 4. Try array
650    if let Some(candidate) = find_balanced_json_array(trimmed) {
651        if let Ok(v) = serde_json::from_str::<Value>(candidate) {
652            return Ok(v);
653        }
654    }
655
656    bail!("No valid JSON object found in LLM output")
657}
658
659/// Strip ```json ... ``` or ``` ... ``` fences.
660fn strip_code_fence(text: &str) -> Option<&str> {
661    let start_patterns = ["```json\n", "```json\r\n", "```\n", "```\r\n"];
662    for pat in &start_patterns {
663        if let Some(rest) = text.strip_prefix(pat) {
664            // Find closing fence
665            if let Some(end) = rest.rfind("```") {
666                return Some(&rest[..end]);
667            }
668        }
669    }
670    // Also handle inline: ```json{...}```
671    if let Some(inner) = text.strip_prefix("```json") {
672        if let Some(end) = inner.rfind("```") {
673            return Some(inner[..end].trim());
674        }
675    }
676    if let Some(inner) = text.strip_prefix("```") {
677        if let Some(end) = inner.rfind("```") {
678            return Some(inner[..end].trim());
679        }
680    }
681    None
682}
683
684/// Find the first balanced `{...}` substring using bracket counting.
685fn find_balanced_json_object(text: &str) -> Option<&str> {
686    find_balanced(text, '{', '}')
687}
688
689/// Find the first balanced `[...]` substring.
690fn find_balanced_json_array(text: &str) -> Option<&str> {
691    find_balanced(text, '[', ']')
692}
693
694fn find_balanced(text: &str, open: char, close: char) -> Option<&str> {
695    find_balanced_range(text, open, close).map(|(start, end)| &text[start..end])
696}
697
698/// Byte range `[start, end)` of the first balanced `open..close` substring (quote-aware).
699fn find_balanced_range(text: &str, open: char, close: char) -> Option<(usize, usize)> {
700    let bytes = text.as_bytes();
701    let open_byte = open as u8;
702    let close_byte = close as u8;
703
704    // Find the first unquoted occurrence of `open`
705    let mut in_string = false;
706    let mut escape_next = false;
707    let mut start = None;
708
709    for (i, &b) in bytes.iter().enumerate() {
710        if escape_next {
711            escape_next = false;
712            continue;
713        }
714        match b {
715            b'\\' if in_string => escape_next = true,
716            b'"' => in_string = !in_string,
717            _ if in_string => {}
718            _ if b == open_byte => {
719                start = Some(i);
720                break;
721            }
722            _ => {}
723        }
724    }
725
726    let start = start?;
727    let mut depth = 0i32;
728    in_string = false;
729    escape_next = false;
730
731    for (i, &b) in bytes[start..].iter().enumerate() {
732        if escape_next {
733            escape_next = false;
734            continue;
735        }
736        match b {
737            b'\\' if in_string => escape_next = true,
738            b'"' => in_string = !in_string,
739            _ if in_string => {}
740            _ if b == open_byte => depth += 1,
741            _ if b == close_byte => {
742                depth -= 1;
743                if depth == 0 {
744                    return Some((start, start + i + 1));
745                }
746            }
747            _ => {}
748        }
749    }
750    None
751}
752
753/// Every top-level balanced `open..close` substring, in document order.
754///
755/// Reasoning traces often contain several objects (worked examples, partial drafts)
756/// before the final answer, so callers validate each against the schema and keep the
757/// one that fits rather than blindly trusting the first `{...}`.
758fn find_all_balanced(text: &str, open: char, close: char) -> Vec<String> {
759    let mut out = Vec::new();
760    let mut base = 0usize;
761    while base < text.len() {
762        match find_balanced_range(&text[base..], open, close) {
763            Some((start, end)) => {
764                out.push(text[base + start..base + end].to_string());
765                base += end;
766            }
767            None => break,
768        }
769    }
770    out
771}
772
773/// Find the byte offset where JSON content starts in a text stream.
774/// Skips leading prose/whitespace to find `{` or `[` that isn't inside a string.
775fn find_json_start(text: &str) -> Option<usize> {
776    // Skip past code fence markers if present
777    let (search_text, offset) = if let Some(rest) = text.strip_prefix("```json") {
778        (rest, 7)
779    } else if let Some(rest) = text.strip_prefix("```") {
780        (rest, 3)
781    } else {
782        (text, 0)
783    };
784
785    let mut in_string = false;
786    let mut escape_next = false;
787    for (i, &b) in search_text.as_bytes().iter().enumerate() {
788        if escape_next {
789            escape_next = false;
790            continue;
791        }
792        match b {
793            b'\\' if in_string => {
794                escape_next = true;
795            }
796            b'"' => {
797                in_string = !in_string;
798            }
799            b'{' | b'[' if !in_string => {
800                return Some(offset + i);
801            }
802            _ => {}
803        }
804    }
805    None
806}
807
808// ---------------------------------------------------------------------------
809// Schema validation
810// ---------------------------------------------------------------------------
811
812/// Validate a JSON value against a JSON Schema.
813/// Returns Ok(()) on success, or a list of human-readable error strings.
814fn validate_against_schema(value: &Value, schema: &Value) -> Result<(), Vec<String>> {
815    // Structured-output schemas are host/model input, so compilation is kept
816    // entirely in-memory: the dependency is built without HTTP/file resolvers.
817    // Local `$ref` / `$defs`, composition keywords, conditional schemas, and
818    // exact `oneOf` semantics are handled by the standards-compliant validator.
819    let validator = jsonschema::draft202012::options()
820        .build(schema)
821        .map_err(|error| vec![format!("invalid JSON Schema: {error}")])?;
822    let errors = validator
823        .iter_errors(value)
824        .map(|error| {
825            let path = error.instance_path().to_string();
826            if path.is_empty() {
827                format!("$: {error}")
828            } else {
829                format!("{path}: {error}")
830            }
831        })
832        .collect::<Vec<_>>();
833    if errors.is_empty() {
834        Ok(())
835    } else {
836        Err(errors)
837    }
838}
839
840// ---------------------------------------------------------------------------
841// Message/prompt construction helpers
842// ---------------------------------------------------------------------------
843
844/// Resolve the requested mode against the provider's native capability.
845///
846/// Prefer native enforcement only when the client explicitly reports support.
847/// Unknown OpenAI-compatible endpoints can hang when sent `tool_choice` or
848/// `response_format`, so unsupported requests degrade to prompt+schema parsing
849/// instead of optimistic native parameters.
850fn resolve_mode(requested: StructuredMode, support: NativeStructuredSupport) -> StructuredMode {
851    match (requested, support) {
852        (StructuredMode::Prompt, _) => StructuredMode::Prompt,
853        (StructuredMode::Strict, NativeStructuredSupport::JsonSchema) => StructuredMode::Strict,
854        (StructuredMode::Json, NativeStructuredSupport::JsonSchema) => StructuredMode::Json,
855        (StructuredMode::Auto | StructuredMode::Tool, NativeStructuredSupport::JsonSchema) => {
856            StructuredMode::Tool
857        }
858        (
859            StructuredMode::Auto
860            | StructuredMode::Tool
861            | StructuredMode::Strict
862            | StructuredMode::Json,
863            NativeStructuredSupport::ForcedTool,
864        ) => StructuredMode::Tool,
865        (
866            StructuredMode::Auto
867            | StructuredMode::Tool
868            | StructuredMode::Strict
869            | StructuredMode::Json,
870            NativeStructuredSupport::None,
871        ) => StructuredMode::Prompt,
872    }
873}
874
875/// Build the provider directive for an already-resolved mode.
876fn build_directive(req: &StructuredRequest, mode: StructuredMode) -> StructuredDirective {
877    match mode {
878        StructuredMode::Tool => StructuredDirective {
879            force_tool: Some(format!("emit_{}", req.schema_name)),
880            response_format: None,
881        },
882        StructuredMode::Strict => StructuredDirective {
883            force_tool: None,
884            response_format: Some(ResponseFormat::JsonSchema {
885                name: req.schema_name.clone(),
886                schema: SchemaEnvelope::for_schema(&req.schema).response_schema(&req.schema),
887            }),
888        },
889        StructuredMode::Json => StructuredDirective {
890            force_tool: None,
891            response_format: Some(ResponseFormat::JsonObject),
892        },
893        StructuredMode::Auto | StructuredMode::Prompt => StructuredDirective::default(),
894    }
895}
896
897fn build_initial_messages(req: &StructuredRequest, mode: StructuredMode) -> Vec<Message> {
898    let envelope = SchemaEnvelope::for_schema(&req.schema);
899    let response_schema = envelope.response_schema(&req.schema);
900    let envelope_instruction = envelope.instruction();
901    match mode {
902        StructuredMode::Tool => {
903            // For tool mode, the prompt is the user message; the LLM will respond
904            // with a tool call whose input is the structured object.
905            vec![Message::user(&req.prompt)]
906        }
907        StructuredMode::Prompt | StructuredMode::Json => {
908            // Prompt mode and json_object mode both need the schema in the prompt:
909            // json_object only guarantees *syntactic* validity, so the model still
910            // has to be told the shape it should produce.
911            let augmented = format!(
912                "{}\n\n{}{}\n\nYou MUST respond with ONLY a valid JSON object (no markdown, no explanation) that conforms to this JSON Schema:\n\n```json\n{}\n```",
913                req.prompt,
914                envelope_instruction,
915                if envelope_instruction.is_empty() { "" } else { "\n" },
916                serde_json::to_string_pretty(&response_schema).unwrap_or_default()
917            );
918            vec![Message::user(&augmented)]
919        }
920        _ => {
921            // Strict mode: the schema constraint is enforced by the provider via
922            // response_format.json_schema, so the user message is just the prompt.
923            vec![Message::user(&req.prompt)]
924        }
925    }
926}
927
928fn build_system_prompt(req: &StructuredRequest, mode: StructuredMode) -> String {
929    let base = req.system.as_deref().unwrap_or("");
930    let envelope_instruction = SchemaEnvelope::for_schema(&req.schema).instruction();
931
932    match mode {
933        StructuredMode::Tool => {
934            format!(
935                "{}{}You MUST respond by calling the `emit_{}` tool exactly once with a valid argument matching the schema. Do not output any text outside the tool call.{}{}",
936                base,
937                if base.is_empty() { "" } else { "\n\n" },
938                req.schema_name,
939                if envelope_instruction.is_empty() { "" } else { "\n\n" },
940                envelope_instruction
941            )
942        }
943        StructuredMode::Prompt | StructuredMode::Json => {
944            format!(
945                "{}{}You are a structured data extraction assistant. Always respond with valid JSON only, no markdown fences, no explanation text.{}{}",
946                base,
947                if base.is_empty() { "" } else { "\n\n" },
948                if envelope_instruction.is_empty() { "" } else { "\n\n" },
949                envelope_instruction,
950            )
951        }
952        _ => base.to_string(),
953    }
954}
955
956fn build_tools(req: &StructuredRequest, mode: StructuredMode) -> Vec<ToolDefinition> {
957    match mode {
958        StructuredMode::Tool => {
959            vec![ToolDefinition {
960                name: format!("emit_{}", req.schema_name),
961                description: req
962                    .schema_description
963                    .clone()
964                    .unwrap_or_else(|| format!("Emit a structured {} object", req.schema_name)),
965                parameters: SchemaEnvelope::for_schema(&req.schema).response_schema(&req.schema),
966            }]
967        }
968        _ => vec![],
969    }
970}
971
972/// Outcome of mining a response for the structured object across all candidate sources.
973struct StructuredResolution {
974    /// A schema-valid object plus the raw source string it came from.
975    valid: Option<(Value, String)>,
976    /// First parseable-but-schema-invalid object source + its validation errors,
977    /// used to build a targeted repair prompt.
978    invalid: Option<(String, Vec<String>)>,
979    /// First non-empty raw candidate, shown verbatim in a parse-failure repair prompt.
980    raw_seen: Option<String>,
981}
982
983/// Append `s` to `out` if it is non-empty and not already present (trimmed, deduped).
984fn push_candidate(out: &mut Vec<String>, s: String) {
985    let trimmed = s.trim();
986    if !trimmed.is_empty() && !out.iter().any(|c| c == trimmed) {
987        out.push(trimmed.to_string());
988    }
989}
990
991/// Ordered raw strings to mine for the structured object, most authoritative first:
992/// tool-call arguments, then text content, then the reasoning channel.
993///
994/// The reasoning fallback is the crux of the cross-model fix: reasoning models
995/// (GLM/zhipu, DeepSeek-R1, kimi…) frequently emit the final object inside
996/// `reasoning` with `content` empty and no tool call. Earlier extraction only looked
997/// at the tool call / text, so those models yielded an empty string and the whole
998/// generate_object failed even though a perfectly good object was produced.
999fn extract_raw_candidates(message: &super::Message, mode: StructuredMode) -> Vec<String> {
1000    let mut out: Vec<String> = Vec::new();
1001    if mode == StructuredMode::Tool {
1002        if let Some(call) = message.tool_calls().first() {
1003            push_candidate(
1004                &mut out,
1005                serde_json::to_string(&call.args).unwrap_or_default(),
1006            );
1007        }
1008    }
1009    push_candidate(&mut out, message.text());
1010    if let Some(reasoning) = message.reasoning_content.as_deref() {
1011        push_candidate(&mut out, reasoning.to_string());
1012    }
1013    out
1014}
1015
1016/// Every JSON object/array value mineable from possibly-dirty text, in document order
1017/// (direct parse, code fences, then all balanced `{...}` / `[...]`). Deduped.
1018#[cfg(test)]
1019fn extract_all_json_values(text: &str) -> Vec<Value> {
1020    extract_json_candidates(text, false)
1021}
1022
1023/// Every JSON value mineable from possibly-dirty text for schema-aware structured
1024/// resolution. When `include_direct_scalars` is true, direct raw/fenced scalar JSON
1025/// is retained so top-level scalar schemas can recover non-enveloped model output.
1026fn extract_json_candidates(text: &str, include_direct_scalars: bool) -> Vec<Value> {
1027    let trimmed = text.trim();
1028    let mut values: Vec<Value> = Vec::new();
1029    let consider = |candidate: &str, values: &mut Vec<Value>, allow_scalar: bool| {
1030        if let Ok(v) = serde_json::from_str::<Value>(candidate.trim()) {
1031            if (v.is_object() || v.is_array() || allow_scalar) && !values.contains(&v) {
1032                values.push(v);
1033            }
1034        }
1035    };
1036    consider(trimmed, &mut values, include_direct_scalars);
1037    if let Some(inner) = strip_code_fence(trimmed) {
1038        consider(inner, &mut values, include_direct_scalars);
1039    }
1040    for candidate in find_all_balanced(trimmed, '{', '}') {
1041        consider(&candidate, &mut values, false);
1042    }
1043    for candidate in find_all_balanced(trimmed, '[', ']') {
1044        consider(&candidate, &mut values, false);
1045    }
1046    values
1047}
1048
1049/// Try every raw candidate × every JSON value it yields against the schema; return the
1050/// first schema-valid value, else the best parseable-but-invalid value (for repair).
1051fn resolve_structured(
1052    candidates: &[String],
1053    schema: &Value,
1054    envelope: SchemaEnvelope,
1055) -> StructuredResolution {
1056    let mut invalid: Option<(String, Vec<String>)> = None;
1057    let mut raw_seen: Option<String> = None;
1058    let response_schema = envelope.response_schema(schema);
1059    for raw in candidates {
1060        if raw_seen.is_none() && !raw.trim().is_empty() {
1061            raw_seen = Some(raw.clone());
1062        }
1063        for value in extract_json_candidates(raw, envelope == SchemaEnvelope::Value) {
1064            match validate_against_schema(&value, schema) {
1065                Ok(()) => {
1066                    return StructuredResolution {
1067                        valid: Some((value, raw.clone())),
1068                        invalid,
1069                        raw_seen,
1070                    };
1071                }
1072                Err(errors) => {
1073                    if invalid.is_none() {
1074                        invalid = Some((raw.clone(), errors));
1075                    }
1076                }
1077            }
1078
1079            if envelope != SchemaEnvelope::Direct {
1080                match validate_against_schema(&value, &response_schema) {
1081                    Ok(()) => {
1082                        if let Some(unwrapped) = envelope.unwrap_final(&value) {
1083                            match validate_against_schema(&unwrapped, schema) {
1084                                Ok(()) => {
1085                                    return StructuredResolution {
1086                                        valid: Some((unwrapped, raw.clone())),
1087                                        invalid,
1088                                        raw_seen,
1089                                    };
1090                                }
1091                                Err(errors) => {
1092                                    if invalid.is_none() {
1093                                        invalid = Some((raw.clone(), errors));
1094                                    }
1095                                }
1096                            }
1097                        } else if invalid.is_none() {
1098                            invalid = Some((
1099                                raw.clone(),
1100                                vec!["$: response envelope was missing the expected value field"
1101                                    .to_string()],
1102                            ));
1103                        }
1104                    }
1105                    Err(errors) => {
1106                        if invalid.is_none() {
1107                            invalid = Some((raw.clone(), errors));
1108                        }
1109                    }
1110                }
1111            }
1112        }
1113    }
1114    StructuredResolution {
1115        valid: None,
1116        invalid,
1117        raw_seen,
1118    }
1119}
1120
1121/// Extract the first JSON value from possibly dirty model text that validates
1122/// against `schema`.
1123///
1124/// This is the local fast path for callers that already asked an agent to
1125/// produce structured output. It accepts direct JSON, fenced JSON, and a
1126/// balanced JSON value embedded in prose, while preserving the same schema
1127/// and envelope semantics used by [`generate_blocking`]. Callers can fall back
1128/// to an LLM repair pass only when this returns `None`.
1129pub(crate) fn parse_validated_output(text: &str, schema: &Value) -> Option<Value> {
1130    resolve_structured(
1131        &[text.to_string()],
1132        schema,
1133        SchemaEnvelope::for_schema(schema),
1134    )
1135    .valid
1136    .map(|(value, _)| value)
1137}
1138
1139/// UTF-8-safe truncation to at most `max` bytes (never splits a multibyte char —
1140/// repair prompts echo arbitrary model output, including CJK).
1141fn truncate_utf8(s: &str, max: usize) -> &str {
1142    if s.len() <= max {
1143        return s;
1144    }
1145    let mut end = max;
1146    while end > 0 && !s.is_char_boundary(end) {
1147        end -= 1;
1148    }
1149    &s[..end]
1150}
1151
1152/// Repair prompt for when nothing parseable was produced at all.
1153fn build_parse_failure_repair(raw_text: &str) -> String {
1154    if raw_text.trim().is_empty() {
1155        return "Your previous response contained no JSON. Respond with ONLY a single valid JSON object that matches the schema — no prose, no markdown, no analysis, and put the object in your reply content (not in a thinking/reasoning aside).".to_string();
1156    }
1157    format!(
1158        "Your previous output could not be parsed as a JSON object:\n\n{}\n\nReturn ONLY a single valid JSON object matching the schema — no prose, no markdown.",
1159        truncate_utf8(raw_text, 2000)
1160    )
1161}
1162
1163fn build_repair_message(raw_text: &str, errors: &[String]) -> String {
1164    // Truncate raw output in repair message to avoid blowing context
1165    let truncated_raw = if raw_text.len() > 2000 {
1166        format!(
1167            "{}...[truncated, {} bytes total]",
1168            truncate_utf8(raw_text, 2000),
1169            raw_text.len()
1170        )
1171    } else {
1172        raw_text.to_string()
1173    };
1174    format!(
1175        "Your previous output failed schema validation:\n\n{}\n\nValidation errors:\n{}\n\nPlease return ONLY a corrected JSON object that fixes these errors. No explanation, no markdown.",
1176        truncated_raw,
1177        errors.iter().map(|e| format!("- {}", e)).collect::<Vec<_>>().join("\n")
1178    )
1179}
1180
1181fn accumulate_usage(total: &mut TokenUsage, delta: &TokenUsage) {
1182    total.prompt_tokens += delta.prompt_tokens;
1183    total.completion_tokens += delta.completion_tokens;
1184    total.total_tokens += delta.total_tokens;
1185}
1186
1187/// Append repair context to the message history, respecting conversation structure.
1188///
1189/// In tool mode, the LLM returned a tool_use block. The correct follow-up is:
1190///   assistant (tool_use) → user (tool_result with error) → assistant (retry)
1191/// In text modes, it's simply:
1192///   assistant (text) → user (repair request) → assistant (retry)
1193fn append_repair_context(
1194    messages: &mut Vec<Message>,
1195    assistant_msg: &Message,
1196    repair_text: &str,
1197    mode: StructuredMode,
1198    _raw_text: &str,
1199) {
1200    if mode == StructuredMode::Tool {
1201        // Push the original assistant message (with tool_use block intact)
1202        messages.push(assistant_msg.clone());
1203        // Find the tool_use ID to construct a proper tool_result
1204        let tool_use_id = assistant_msg
1205            .tool_calls()
1206            .first()
1207            .map(|tc| tc.id.clone())
1208            .unwrap_or_else(|| "unknown".to_string());
1209        // Return the error as a tool_result so the conversation stays valid
1210        messages.push(Message::tool_result(&tool_use_id, repair_text, true));
1211    } else {
1212        // Text modes: push assistant text then user repair request
1213        messages.push(assistant_msg.clone());
1214        messages.push(Message::user(repair_text));
1215    }
1216}
1217
1218// ---------------------------------------------------------------------------
1219// Tests
1220// ---------------------------------------------------------------------------
1221
1222#[cfg(test)]
1223#[path = "structured_tests.rs"]
1224mod structured_tests;