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