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/// A streamed first attempt may be followed by bounded non-streaming repair
290/// calls when `max_repair_attempts` is non-zero. Repair calls publish only the
291/// final corrected object, avoiding a second misleading partial stream.
292pub async fn generate_streaming(
293    client: &dyn LlmClient,
294    req: &StructuredRequest,
295    on_partial: PartialObjectCallback,
296) -> Result<StructuredResult> {
297    let mode = resolve_mode(req.mode, client.native_structured_support());
298    let envelope = SchemaEnvelope::for_schema(&req.schema);
299    let mut messages = build_initial_messages(req, mode);
300    let system = build_system_prompt(req, mode);
301    let tools = build_tools(req, mode);
302    let directive = build_directive(req, mode);
303
304    let cancel_token = CancellationToken::new();
305    let mut rx = client
306        .complete_streaming_structured(
307            &messages,
308            Some(&system),
309            &tools,
310            &directive,
311            cancel_token.clone(),
312        )
313        .await
314        .context("LLM streaming call failed during structured generation")?;
315
316    let mut json_buffer = String::new();
317    let mut last_valid_partial: Option<Value> = None;
318    let mut final_response: Option<super::LlmResponse> = None;
319    let mut last_parse_len: usize = 0;
320    let mut complete_candidate: Option<(Value, String, tokio::time::Instant)> = None;
321    // Minimum bytes of new data before attempting a partial parse (reduces CPU)
322    const PARSE_THRESHOLD: usize = 8;
323    // Well-behaved providers send Done immediately after the complete object.
324    // A short grace preserves their final usage metadata while preventing an
325    // otherwise valid result from hanging on a compatible endpoint that never
326    // terminates its stream.
327    const DONE_GRACE: std::time::Duration = std::time::Duration::from_millis(250);
328    loop {
329        let event = if let Some((_, _, deadline)) = complete_candidate.as_ref() {
330            tokio::select! {
331                event = rx.recv() => event,
332                _ = tokio::time::sleep_until(*deadline) => {
333                    let candidate = complete_candidate
334                        .take()
335                        .expect("complete streamed candidate exists");
336                    let (value, raw_text, _) = candidate;
337                    cancel_token.cancel();
338                    on_partial(&value);
339                    return Ok(StructuredResult {
340                        object: value,
341                        raw_text: Some(raw_text),
342                        usage: TokenUsage::default(),
343                        repair_rounds: 0,
344                        mode_used: mode,
345                    });
346                }
347            }
348        } else {
349            rx.recv().await
350        };
351        let Some(event) = event else {
352            if let Some((value, raw_text, _)) = complete_candidate.take() {
353                cancel_token.cancel();
354                on_partial(&value);
355                return Ok(StructuredResult {
356                    object: value,
357                    raw_text: Some(raw_text),
358                    usage: TokenUsage::default(),
359                    repair_rounds: 0,
360                    mode_used: mode,
361                });
362            }
363            break;
364        };
365        match event {
366            StreamEvent::ToolUseInputDelta { delta, .. } if mode == StructuredMode::Tool => {
367                if final_response.is_some() {
368                    continue;
369                }
370                json_buffer.push_str(&delta);
371                if json_buffer.len() - last_parse_len >= PARSE_THRESHOLD {
372                    if let Some(partial) = parse_partial_json(&json_buffer) {
373                        if let Some(projected) =
374                            envelope.project_partial(&partial.value, partial.repaired)
375                        {
376                            if last_valid_partial.as_ref() != Some(&projected) {
377                                on_partial(&projected);
378                                last_valid_partial = Some(projected);
379                            }
380                        }
381                    }
382                    last_parse_len = json_buffer.len();
383                }
384                if complete_candidate.is_none() && (delta.contains('}') || delta.contains(']')) {
385                    complete_candidate = resolve_structured(
386                        std::slice::from_ref(&json_buffer),
387                        &req.schema,
388                        envelope,
389                    )
390                    .valid
391                    .map(|(value, raw_text)| {
392                        (value, raw_text, tokio::time::Instant::now() + DONE_GRACE)
393                    });
394                }
395            }
396            StreamEvent::TextDelta(delta) if mode != StructuredMode::Tool => {
397                if final_response.is_some() {
398                    continue;
399                }
400                json_buffer.push_str(&delta);
401                if json_buffer.len() - last_parse_len >= PARSE_THRESHOLD {
402                    if let Some(json_start) = find_json_start(&json_buffer) {
403                        let candidate = &json_buffer[json_start..];
404                        if let Some(partial) = parse_partial_json(candidate) {
405                            if let Some(projected) =
406                                envelope.project_partial(&partial.value, partial.repaired)
407                            {
408                                if last_valid_partial.as_ref() != Some(&projected) {
409                                    on_partial(&projected);
410                                    last_valid_partial = Some(projected);
411                                }
412                            }
413                        }
414                    }
415                    last_parse_len = json_buffer.len();
416                }
417                if complete_candidate.is_none() && (delta.contains('}') || delta.contains(']')) {
418                    complete_candidate = resolve_structured(
419                        std::slice::from_ref(&json_buffer),
420                        &req.schema,
421                        envelope,
422                    )
423                    .valid
424                    .map(|(value, raw_text)| {
425                        (value, raw_text, tokio::time::Instant::now() + DONE_GRACE)
426                    });
427                }
428            }
429            StreamEvent::Done(resp) => {
430                final_response = Some(resp);
431                break;
432            }
433            _ => {}
434        }
435    }
436
437    let mut resp = final_response.context("Stream ended without Done event")?;
438    let mut total_usage = TokenUsage::default();
439    accumulate_usage(&mut total_usage, &resp.usage);
440    let mut repair_rounds = 0u8;
441    // Same multi-source resolution as the blocking path: the final message may carry
442    // the object in the tool call, the text content, or the reasoning channel.
443    let mut resolution = resolve_structured(
444        &extract_raw_candidates(&resp.message, mode),
445        &req.schema,
446        envelope,
447    );
448    let (value, raw_text) = loop {
449        if let Some(valid) = resolution.valid.take() {
450            break valid;
451        }
452
453        if repair_rounds >= req.max_repair_attempts {
454            return Err(match resolution.invalid {
455                Some((_, errors)) => anyhow::anyhow!(
456                    "Streamed structured output failed schema validation after {} repair attempts: {}",
457                    repair_rounds,
458                    errors.join("; ")
459                ),
460                None => anyhow::anyhow!(
461                    "Streamed output produced no parseable JSON object after {} repair attempts (checked tool call, text content, and reasoning channel)",
462                    repair_rounds
463                ),
464            });
465        }
466
467        repair_rounds += 1;
468        let (repair_message, raw_for_context) = match resolution.invalid.take() {
469            Some((raw, errors)) => (build_repair_message(&raw, &errors), raw),
470            None => {
471                let raw = resolution.raw_seen.take().unwrap_or_default();
472                (build_parse_failure_repair(&raw), raw)
473            }
474        };
475        append_repair_context(
476            &mut messages,
477            &resp.message,
478            &repair_message,
479            mode,
480            &raw_for_context,
481        );
482        resp = client
483            .complete_structured(&messages, Some(&system), &tools, &directive)
484            .await
485            .context("LLM call failed while repairing streamed structured output")?;
486        accumulate_usage(&mut total_usage, &resp.usage);
487        resolution = resolve_structured(
488            &extract_raw_candidates(&resp.message, mode),
489            &req.schema,
490            envelope,
491        );
492    };
493
494    // Emit final complete object
495    on_partial(&value);
496
497    Ok(StructuredResult {
498        object: value,
499        raw_text: Some(raw_text),
500        usage: total_usage,
501        repair_rounds,
502        mode_used: mode,
503    })
504}
505
506// ---------------------------------------------------------------------------
507// JSON extraction and parsing
508// ---------------------------------------------------------------------------
509
510/// Extract a JSON value from potentially dirty LLM output.
511///
512/// Handles: raw JSON, markdown code fences, leading/trailing prose.
513pub fn extract_json_value(text: &str) -> Result<Value> {
514    let trimmed = text.trim();
515
516    // 1. Direct parse
517    if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
518        if v.is_object() || v.is_array() {
519            return Ok(v);
520        }
521    }
522
523    // 2. Strip markdown code fence
524    if let Some(inner) = strip_code_fence(trimmed) {
525        if let Ok(v) = serde_json::from_str::<Value>(inner.trim()) {
526            if v.is_object() || v.is_array() {
527                return Ok(v);
528            }
529        }
530    }
531
532    // 3. Find balanced JSON substring (first { to matching })
533    if let Some(candidate) = find_balanced_json_object(trimmed) {
534        if let Ok(v) = serde_json::from_str::<Value>(candidate) {
535            return Ok(v);
536        }
537    }
538
539    // 4. Try array
540    if let Some(candidate) = find_balanced_json_array(trimmed) {
541        if let Ok(v) = serde_json::from_str::<Value>(candidate) {
542            return Ok(v);
543        }
544    }
545
546    bail!("No valid JSON object found in LLM output")
547}
548
549/// Strip ```json ... ``` or ``` ... ``` fences.
550fn strip_code_fence(text: &str) -> Option<&str> {
551    let start_patterns = ["```json\n", "```json\r\n", "```\n", "```\r\n"];
552    for pat in &start_patterns {
553        if let Some(rest) = text.strip_prefix(pat) {
554            // Find closing fence
555            if let Some(end) = rest.rfind("```") {
556                return Some(&rest[..end]);
557            }
558        }
559    }
560    // Also handle inline: ```json{...}```
561    if let Some(inner) = text.strip_prefix("```json") {
562        if let Some(end) = inner.rfind("```") {
563            return Some(inner[..end].trim());
564        }
565    }
566    if let Some(inner) = text.strip_prefix("```") {
567        if let Some(end) = inner.rfind("```") {
568            return Some(inner[..end].trim());
569        }
570    }
571    None
572}
573
574/// Find the first balanced `{...}` substring using bracket counting.
575fn find_balanced_json_object(text: &str) -> Option<&str> {
576    find_balanced(text, '{', '}')
577}
578
579/// Find the first balanced `[...]` substring.
580fn find_balanced_json_array(text: &str) -> Option<&str> {
581    find_balanced(text, '[', ']')
582}
583
584fn find_balanced(text: &str, open: char, close: char) -> Option<&str> {
585    find_balanced_range(text, open, close).map(|(start, end)| &text[start..end])
586}
587
588/// Byte range `[start, end)` of the first balanced `open..close` substring (quote-aware).
589fn find_balanced_range(text: &str, open: char, close: char) -> Option<(usize, usize)> {
590    let bytes = text.as_bytes();
591    let open_byte = open as u8;
592    let close_byte = close as u8;
593
594    // Find the first unquoted occurrence of `open`
595    let mut in_string = false;
596    let mut escape_next = false;
597    let mut start = None;
598
599    for (i, &b) in bytes.iter().enumerate() {
600        if escape_next {
601            escape_next = false;
602            continue;
603        }
604        match b {
605            b'\\' if in_string => escape_next = true,
606            b'"' => in_string = !in_string,
607            _ if in_string => {}
608            _ if b == open_byte => {
609                start = Some(i);
610                break;
611            }
612            _ => {}
613        }
614    }
615
616    let start = start?;
617    let mut depth = 0i32;
618    in_string = false;
619    escape_next = false;
620
621    for (i, &b) in bytes[start..].iter().enumerate() {
622        if escape_next {
623            escape_next = false;
624            continue;
625        }
626        match b {
627            b'\\' if in_string => escape_next = true,
628            b'"' => in_string = !in_string,
629            _ if in_string => {}
630            _ if b == open_byte => depth += 1,
631            _ if b == close_byte => {
632                depth -= 1;
633                if depth == 0 {
634                    return Some((start, start + i + 1));
635                }
636            }
637            _ => {}
638        }
639    }
640    None
641}
642
643/// Every top-level balanced `open..close` substring, in document order.
644///
645/// Reasoning traces often contain several objects (worked examples, partial drafts)
646/// before the final answer, so callers validate each against the schema and keep the
647/// one that fits rather than blindly trusting the first `{...}`.
648fn find_all_balanced(text: &str, open: char, close: char) -> Vec<String> {
649    let mut out = Vec::new();
650    let mut base = 0usize;
651    while base < text.len() {
652        match find_balanced_range(&text[base..], open, close) {
653            Some((start, end)) => {
654                out.push(text[base + start..base + end].to_string());
655                base += end;
656            }
657            None => break,
658        }
659    }
660    out
661}
662
663/// Find the byte offset where JSON content starts in a text stream.
664/// Skips leading prose/whitespace to find `{` or `[` that isn't inside a string.
665fn find_json_start(text: &str) -> Option<usize> {
666    // Skip past code fence markers if present
667    let (search_text, offset) = if let Some(rest) = text.strip_prefix("```json") {
668        (rest, 7)
669    } else if let Some(rest) = text.strip_prefix("```") {
670        (rest, 3)
671    } else {
672        (text, 0)
673    };
674
675    let mut in_string = false;
676    let mut escape_next = false;
677    for (i, &b) in search_text.as_bytes().iter().enumerate() {
678        if escape_next {
679            escape_next = false;
680            continue;
681        }
682        match b {
683            b'\\' if in_string => {
684                escape_next = true;
685            }
686            b'"' => {
687                in_string = !in_string;
688            }
689            b'{' | b'[' if !in_string => {
690                return Some(offset + i);
691            }
692            _ => {}
693        }
694    }
695    None
696}
697
698// ---------------------------------------------------------------------------
699// Schema validation
700// ---------------------------------------------------------------------------
701
702/// Validate a JSON value against a JSON Schema.
703/// Returns Ok(()) on success, or a list of human-readable error strings.
704fn validate_against_schema(value: &Value, schema: &Value) -> Result<(), Vec<String>> {
705    // Structured-output schemas are host/model input, so compilation is kept
706    // entirely in-memory: the dependency is built without HTTP/file resolvers.
707    // Local `$ref` / `$defs`, composition keywords, conditional schemas, and
708    // exact `oneOf` semantics are handled by the standards-compliant validator.
709    let validator = jsonschema::draft202012::options()
710        .build(schema)
711        .map_err(|error| vec![format!("invalid JSON Schema: {error}")])?;
712    let errors = validator
713        .iter_errors(value)
714        .map(|error| {
715            let path = error.instance_path().to_string();
716            if path.is_empty() {
717                format!("$: {error}")
718            } else {
719                format!("{path}: {error}")
720            }
721        })
722        .collect::<Vec<_>>();
723    if errors.is_empty() {
724        Ok(())
725    } else {
726        Err(errors)
727    }
728}
729
730// ---------------------------------------------------------------------------
731// Message/prompt construction helpers
732// ---------------------------------------------------------------------------
733
734/// Resolve the requested mode against the provider's native capability.
735///
736/// Prefer native enforcement only when the client explicitly reports support.
737/// Unknown OpenAI-compatible endpoints can hang when sent `tool_choice` or
738/// `response_format`, so unsupported requests degrade to prompt+schema parsing
739/// instead of optimistic native parameters.
740fn resolve_mode(requested: StructuredMode, support: NativeStructuredSupport) -> StructuredMode {
741    match (requested, support) {
742        (StructuredMode::Prompt, _) => StructuredMode::Prompt,
743        (StructuredMode::Strict, NativeStructuredSupport::JsonSchema) => StructuredMode::Strict,
744        (StructuredMode::Json, NativeStructuredSupport::JsonSchema) => StructuredMode::Json,
745        (StructuredMode::Auto | StructuredMode::Tool, NativeStructuredSupport::JsonSchema) => {
746            StructuredMode::Tool
747        }
748        (
749            StructuredMode::Auto
750            | StructuredMode::Tool
751            | StructuredMode::Strict
752            | StructuredMode::Json,
753            NativeStructuredSupport::ForcedTool,
754        ) => StructuredMode::Tool,
755        (
756            StructuredMode::Auto
757            | StructuredMode::Tool
758            | StructuredMode::Strict
759            | StructuredMode::Json,
760            NativeStructuredSupport::None,
761        ) => StructuredMode::Prompt,
762    }
763}
764
765/// Build the provider directive for an already-resolved mode.
766fn build_directive(req: &StructuredRequest, mode: StructuredMode) -> StructuredDirective {
767    match mode {
768        StructuredMode::Tool => StructuredDirective {
769            force_tool: Some(format!("emit_{}", req.schema_name)),
770            response_format: None,
771        },
772        StructuredMode::Strict => StructuredDirective {
773            force_tool: None,
774            response_format: Some(ResponseFormat::JsonSchema {
775                name: req.schema_name.clone(),
776                schema: SchemaEnvelope::for_schema(&req.schema).response_schema(&req.schema),
777            }),
778        },
779        StructuredMode::Json => StructuredDirective {
780            force_tool: None,
781            response_format: Some(ResponseFormat::JsonObject),
782        },
783        StructuredMode::Auto | StructuredMode::Prompt => StructuredDirective::default(),
784    }
785}
786
787fn build_initial_messages(req: &StructuredRequest, mode: StructuredMode) -> Vec<Message> {
788    let envelope = SchemaEnvelope::for_schema(&req.schema);
789    let response_schema = envelope.response_schema(&req.schema);
790    let envelope_instruction = envelope.instruction();
791    match mode {
792        StructuredMode::Tool => {
793            // For tool mode, the prompt is the user message; the LLM will respond
794            // with a tool call whose input is the structured object.
795            vec![Message::user(&req.prompt)]
796        }
797        StructuredMode::Prompt | StructuredMode::Json => {
798            // Prompt mode and json_object mode both need the schema in the prompt:
799            // json_object only guarantees *syntactic* validity, so the model still
800            // has to be told the shape it should produce.
801            let augmented = format!(
802                "{}\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```",
803                req.prompt,
804                envelope_instruction,
805                if envelope_instruction.is_empty() { "" } else { "\n" },
806                serde_json::to_string_pretty(&response_schema).unwrap_or_default()
807            );
808            vec![Message::user(&augmented)]
809        }
810        _ => {
811            // Strict mode: the schema constraint is enforced by the provider via
812            // response_format.json_schema, so the user message is just the prompt.
813            vec![Message::user(&req.prompt)]
814        }
815    }
816}
817
818fn build_system_prompt(req: &StructuredRequest, mode: StructuredMode) -> String {
819    let base = req.system.as_deref().unwrap_or("");
820    let envelope_instruction = SchemaEnvelope::for_schema(&req.schema).instruction();
821
822    match mode {
823        StructuredMode::Tool => {
824            format!(
825                "{}{}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.{}{}",
826                base,
827                if base.is_empty() { "" } else { "\n\n" },
828                req.schema_name,
829                if envelope_instruction.is_empty() { "" } else { "\n\n" },
830                envelope_instruction
831            )
832        }
833        StructuredMode::Prompt | StructuredMode::Json => {
834            format!(
835                "{}{}You are a structured data extraction assistant. Always respond with valid JSON only, no markdown fences, no explanation text.{}{}",
836                base,
837                if base.is_empty() { "" } else { "\n\n" },
838                if envelope_instruction.is_empty() { "" } else { "\n\n" },
839                envelope_instruction,
840            )
841        }
842        _ => base.to_string(),
843    }
844}
845
846fn build_tools(req: &StructuredRequest, mode: StructuredMode) -> Vec<ToolDefinition> {
847    match mode {
848        StructuredMode::Tool => {
849            vec![ToolDefinition {
850                name: format!("emit_{}", req.schema_name),
851                description: req
852                    .schema_description
853                    .clone()
854                    .unwrap_or_else(|| format!("Emit a structured {} object", req.schema_name)),
855                parameters: SchemaEnvelope::for_schema(&req.schema).response_schema(&req.schema),
856            }]
857        }
858        _ => vec![],
859    }
860}
861
862/// Outcome of mining a response for the structured object across all candidate sources.
863struct StructuredResolution {
864    /// A schema-valid object plus the raw source string it came from.
865    valid: Option<(Value, String)>,
866    /// First parseable-but-schema-invalid object source + its validation errors,
867    /// used to build a targeted repair prompt.
868    invalid: Option<(String, Vec<String>)>,
869    /// First non-empty raw candidate, shown verbatim in a parse-failure repair prompt.
870    raw_seen: Option<String>,
871}
872
873/// Append `s` to `out` if it is non-empty and not already present (trimmed, deduped).
874fn push_candidate(out: &mut Vec<String>, s: String) {
875    let trimmed = s.trim();
876    if !trimmed.is_empty() && !out.iter().any(|c| c == trimmed) {
877        out.push(trimmed.to_string());
878    }
879}
880
881/// Ordered raw strings to mine for the structured object, most authoritative first:
882/// tool-call arguments, then text content, then the reasoning channel.
883///
884/// The reasoning fallback is the crux of the cross-model fix: reasoning models
885/// (GLM/zhipu, DeepSeek-R1, kimi…) frequently emit the final object inside
886/// `reasoning` with `content` empty and no tool call. Earlier extraction only looked
887/// at the tool call / text, so those models yielded an empty string and the whole
888/// generate_object failed even though a perfectly good object was produced.
889fn extract_raw_candidates(message: &super::Message, mode: StructuredMode) -> Vec<String> {
890    let mut out: Vec<String> = Vec::new();
891    if mode == StructuredMode::Tool {
892        if let Some(call) = message.tool_calls().first() {
893            push_candidate(
894                &mut out,
895                serde_json::to_string(&call.args).unwrap_or_default(),
896            );
897        }
898    }
899    push_candidate(&mut out, message.text());
900    if let Some(reasoning) = message.reasoning_content.as_deref() {
901        push_candidate(&mut out, reasoning.to_string());
902    }
903    out
904}
905
906/// Every JSON object/array value mineable from possibly-dirty text, in document order
907/// (direct parse, code fences, then all balanced `{...}` / `[...]`). Deduped.
908#[cfg(test)]
909fn extract_all_json_values(text: &str) -> Vec<Value> {
910    extract_json_candidates(text, false)
911}
912
913/// Every JSON value mineable from possibly-dirty text for schema-aware structured
914/// resolution. When `include_direct_scalars` is true, direct raw/fenced scalar JSON
915/// is retained so top-level scalar schemas can recover non-enveloped model output.
916fn extract_json_candidates(text: &str, include_direct_scalars: bool) -> Vec<Value> {
917    let trimmed = text.trim();
918    let mut values: Vec<Value> = Vec::new();
919    let consider = |candidate: &str, values: &mut Vec<Value>, allow_scalar: bool| {
920        if let Ok(v) = serde_json::from_str::<Value>(candidate.trim()) {
921            if (v.is_object() || v.is_array() || allow_scalar) && !values.contains(&v) {
922                values.push(v);
923            }
924        }
925    };
926    consider(trimmed, &mut values, include_direct_scalars);
927    if let Some(inner) = strip_code_fence(trimmed) {
928        consider(inner, &mut values, include_direct_scalars);
929    }
930    for candidate in find_all_balanced(trimmed, '{', '}') {
931        consider(&candidate, &mut values, false);
932    }
933    for candidate in find_all_balanced(trimmed, '[', ']') {
934        consider(&candidate, &mut values, false);
935    }
936    values
937}
938
939/// Try every raw candidate × every JSON value it yields against the schema; return the
940/// first schema-valid value, else the best parseable-but-invalid value (for repair).
941fn resolve_structured(
942    candidates: &[String],
943    schema: &Value,
944    envelope: SchemaEnvelope,
945) -> StructuredResolution {
946    let mut invalid: Option<(String, Vec<String>)> = None;
947    let mut raw_seen: Option<String> = None;
948    let response_schema = envelope.response_schema(schema);
949    for raw in candidates {
950        if raw_seen.is_none() && !raw.trim().is_empty() {
951            raw_seen = Some(raw.clone());
952        }
953        for value in extract_json_candidates(raw, envelope == SchemaEnvelope::Value) {
954            match validate_against_schema(&value, schema) {
955                Ok(()) => {
956                    return StructuredResolution {
957                        valid: Some((value, raw.clone())),
958                        invalid,
959                        raw_seen,
960                    };
961                }
962                Err(errors) => {
963                    if invalid.is_none() {
964                        invalid = Some((raw.clone(), errors));
965                    }
966                }
967            }
968
969            if envelope != SchemaEnvelope::Direct {
970                match validate_against_schema(&value, &response_schema) {
971                    Ok(()) => {
972                        if let Some(unwrapped) = envelope.unwrap_final(&value) {
973                            match validate_against_schema(&unwrapped, schema) {
974                                Ok(()) => {
975                                    return StructuredResolution {
976                                        valid: Some((unwrapped, raw.clone())),
977                                        invalid,
978                                        raw_seen,
979                                    };
980                                }
981                                Err(errors) => {
982                                    if invalid.is_none() {
983                                        invalid = Some((raw.clone(), errors));
984                                    }
985                                }
986                            }
987                        } else if invalid.is_none() {
988                            invalid = Some((
989                                raw.clone(),
990                                vec!["$: response envelope was missing the expected value field"
991                                    .to_string()],
992                            ));
993                        }
994                    }
995                    Err(errors) => {
996                        if invalid.is_none() {
997                            invalid = Some((raw.clone(), errors));
998                        }
999                    }
1000                }
1001            }
1002        }
1003    }
1004    StructuredResolution {
1005        valid: None,
1006        invalid,
1007        raw_seen,
1008    }
1009}
1010
1011/// Extract the first JSON value from possibly dirty model text that validates
1012/// against `schema`.
1013///
1014/// This is the local fast path for callers that already asked an agent to
1015/// produce structured output. It accepts direct JSON, fenced JSON, and a
1016/// balanced JSON value embedded in prose, while preserving the same schema
1017/// and envelope semantics used by [`generate_blocking`]. Callers can fall back
1018/// to an LLM repair pass only when this returns `None`.
1019pub(crate) fn parse_validated_output(text: &str, schema: &Value) -> Option<Value> {
1020    resolve_structured(
1021        &[text.to_string()],
1022        schema,
1023        SchemaEnvelope::for_schema(schema),
1024    )
1025    .valid
1026    .map(|(value, _)| value)
1027}
1028
1029/// UTF-8-safe truncation to at most `max` bytes (never splits a multibyte char —
1030/// repair prompts echo arbitrary model output, including CJK).
1031fn truncate_utf8(s: &str, max: usize) -> &str {
1032    if s.len() <= max {
1033        return s;
1034    }
1035    let mut end = max;
1036    while end > 0 && !s.is_char_boundary(end) {
1037        end -= 1;
1038    }
1039    &s[..end]
1040}
1041
1042/// Repair prompt for when nothing parseable was produced at all.
1043fn build_parse_failure_repair(raw_text: &str) -> String {
1044    if raw_text.trim().is_empty() {
1045        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();
1046    }
1047    format!(
1048        "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.",
1049        truncate_utf8(raw_text, 2000)
1050    )
1051}
1052
1053fn build_repair_message(raw_text: &str, errors: &[String]) -> String {
1054    // Truncate raw output in repair message to avoid blowing context
1055    let truncated_raw = if raw_text.len() > 2000 {
1056        format!(
1057            "{}...[truncated, {} bytes total]",
1058            truncate_utf8(raw_text, 2000),
1059            raw_text.len()
1060        )
1061    } else {
1062        raw_text.to_string()
1063    };
1064    format!(
1065        "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.",
1066        truncated_raw,
1067        errors.iter().map(|e| format!("- {}", e)).collect::<Vec<_>>().join("\n")
1068    )
1069}
1070
1071fn accumulate_usage(total: &mut TokenUsage, delta: &TokenUsage) {
1072    total.prompt_tokens += delta.prompt_tokens;
1073    total.completion_tokens += delta.completion_tokens;
1074    total.total_tokens += delta.total_tokens;
1075}
1076
1077/// Append repair context to the message history, respecting conversation structure.
1078///
1079/// In tool mode, the LLM returned a tool_use block. The correct follow-up is:
1080///   assistant (tool_use) → user (tool_result with error) → assistant (retry)
1081/// In text modes, it's simply:
1082///   assistant (text) → user (repair request) → assistant (retry)
1083fn append_repair_context(
1084    messages: &mut Vec<Message>,
1085    assistant_msg: &Message,
1086    repair_text: &str,
1087    mode: StructuredMode,
1088    _raw_text: &str,
1089) {
1090    if mode == StructuredMode::Tool {
1091        // Push the original assistant message (with tool_use block intact)
1092        messages.push(assistant_msg.clone());
1093        // Find the tool_use ID to construct a proper tool_result
1094        let tool_use_id = assistant_msg
1095            .tool_calls()
1096            .first()
1097            .map(|tc| tc.id.clone())
1098            .unwrap_or_else(|| "unknown".to_string());
1099        // Return the error as a tool_result so the conversation stays valid
1100        messages.push(Message::tool_result(&tool_use_id, repair_text, true));
1101    } else {
1102        // Text modes: push assistant text then user repair request
1103        messages.push(assistant_msg.clone());
1104        messages.push(Message::user(repair_text));
1105    }
1106}
1107
1108// ---------------------------------------------------------------------------
1109// Tests
1110// ---------------------------------------------------------------------------
1111
1112#[cfg(test)]
1113#[path = "structured_tests.rs"]
1114mod structured_tests;