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