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