Skip to main content

a3s_code_core/llm/
structured.rs

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