Skip to main content

agent_sdk_providers/
structured.rs

1//! Schema-validated structured output.
2//!
3//! This module implements a provider-agnostic runner that constrains a model's
4//! final answer to a JSON Schema, validates the output, and bounded-re-prompts
5//! on mismatch before failing with a typed error.
6//!
7//! # How it works
8//!
9//! 1. The caller supplies a [`ChatRequest`] whose
10//!    [`response_format`](agent_sdk_foundation::llm::ChatRequest::response_format) is
11//!    set, plus a [`StructuredConfig`] bounding the retries.
12//! 2. The runner inspects the provider's
13//!    [`structured_output_support`](crate::LlmProvider::structured_output_support):
14//!    - [`Native`](crate::StructuredOutputSupport::Native) — the provider
15//!      already mapped `response_format` onto its wire request (`OpenAI` JSON
16//!      mode, Gemini `responseSchema`). The structured value is parsed from the
17//!      assistant's text output.
18//!    - [`ToolForcing`](crate::StructuredOutputSupport::ToolForcing) — the
19//!      runner injects a single forced "respond" tool whose `input_schema` is
20//!      the output schema (the Anthropic fallback) and reads the structured
21//!      value from that tool call's input.
22//! 3. The candidate value is validated against the schema with `jsonschema`.
23//!    On success it is returned. On failure the runner appends the model's
24//!    output plus a corrective user message describing the validation errors
25//!    and retries, up to [`StructuredConfig::max_retries`] times. Exhausting
26//!    the budget yields [`StructuredOutputError::RetriesExhausted`].
27//!
28//! This mirrors the Claude SDK's `output_format` +
29//! `error_max_structured_output_retries` behaviour.
30
31use std::pin::Pin;
32
33use agent_sdk_foundation::llm::{
34    ChatOutcome, ChatRequest, ChatResponse, ContentBlock, Message, ResponseFormat, Tool,
35    ToolChoice, Usage,
36};
37use agent_sdk_foundation::types::ToolTier;
38use futures::{Stream, StreamExt};
39
40use crate::provider::{LlmProvider, StructuredOutputSupport};
41use crate::streaming::{StreamAccumulator, StreamDelta, StreamErrorKind};
42
43#[cfg(feature = "openai")]
44use crate::impls::openai_schema::normalized_strict_schema;
45
46/// The forced tool name used for the tool-forcing fallback (Anthropic).
47const RESPOND_TOOL_NAME: &str = "respond";
48
49/// Bounds for the structured-output re-prompt loop.
50#[derive(Debug, Clone, Copy)]
51pub struct StructuredConfig {
52    /// Maximum number of *re-prompts* after the first attempt. A value of `2`
53    /// means up to three model calls total (1 initial + 2 retries) before the
54    /// runner gives up with [`StructuredOutputError::RetriesExhausted`].
55    ///
56    /// Mirrors the Claude SDK `error_max_structured_output_retries`.
57    pub max_retries: u32,
58}
59
60impl Default for StructuredConfig {
61    fn default() -> Self {
62        Self { max_retries: 2 }
63    }
64}
65
66/// A successfully validated structured output and the response that produced it.
67#[derive(Debug, Clone)]
68pub struct StructuredOutput {
69    /// The validated JSON value, guaranteed to satisfy the requested schema.
70    pub value: serde_json::Value,
71    /// The full provider response that produced [`value`](Self::value), so
72    /// callers can still read usage, stop reason, and any leading text.
73    pub response: ChatResponse,
74    /// Number of re-prompts performed before the value validated (0 when the
75    /// first attempt already satisfied the schema).
76    pub retries: u32,
77}
78
79/// Errors from the structured-output runner.
80///
81/// These are *typed* terminal outcomes — the runner never panics on a model
82/// that fails to produce schema-valid output.
83#[derive(Debug, thiserror::Error)]
84pub enum StructuredOutputError {
85    /// The request did not carry a
86    /// [`response_format`](agent_sdk_foundation::llm::ChatRequest::response_format).
87    #[error("structured output requested without a response_format on the request")]
88    MissingResponseFormat,
89
90    /// The schema supplied in the response format is not a valid JSON Schema.
91    #[error("invalid output JSON schema: {0}")]
92    InvalidSchema(String),
93
94    /// The schema is valid JSON Schema, but `OpenAI` strict mode cannot preserve
95    /// its dynamic-property semantics.
96    #[error("output JSON schema is incompatible with OpenAI strict mode: {0}")]
97    IncompatibleSchema(String),
98
99    /// The model produced no extractable structured value (no JSON text /
100    /// no forced tool call).
101    #[error("model produced no structured output to validate")]
102    NoStructuredOutput,
103
104    /// The provider returned a non-success outcome (rate limit, server error,
105    /// invalid request).
106    #[error("provider returned a non-success outcome: {0}")]
107    ProviderOutcome(String),
108
109    /// The re-prompt budget was exhausted and the latest output still failed
110    /// schema validation. Carries the final validation errors and the last
111    /// candidate value for diagnostics.
112    #[error(
113        "structured output failed schema validation after {attempts} attempt(s); last errors: {errors}"
114    )]
115    RetriesExhausted {
116        /// Total number of model calls made (initial + retries).
117        attempts: u32,
118        /// Human-readable concatenation of the final validation errors.
119        errors: String,
120        /// The last candidate value the model produced, if any.
121        last_value: Option<serde_json::Value>,
122    },
123
124    /// A transport-level error bubbled up from the provider.
125    #[error(transparent)]
126    Transport(#[from] anyhow::Error),
127}
128
129/// Run a bounded, schema-validated structured-output exchange against `provider`.
130///
131/// The `request`'s
132/// [`response_format`](agent_sdk_foundation::llm::ChatRequest::response_format) must
133/// be set; on success the returned [`StructuredOutput::value`] is guaranteed to
134/// satisfy that schema.
135///
136/// # Errors
137///
138/// Returns a [`StructuredOutputError`] when the request is missing a response
139/// format, the schema is invalid, the provider errors, or the model fails to
140/// produce schema-valid output within [`StructuredConfig::max_retries`].
141pub async fn run_structured(
142    provider: &dyn LlmProvider,
143    mut request: ChatRequest,
144    config: StructuredConfig,
145) -> Result<StructuredOutput, StructuredOutputError> {
146    let response_format = request
147        .response_format
148        .clone()
149        .ok_or(StructuredOutputError::MissingResponseFormat)?;
150
151    // Compile the validator once; reuse it across every retry.
152    let validator = validator_for_provider(provider, &response_format)?;
153
154    let support = provider.structured_output_support();
155    if matches!(support, StructuredOutputSupport::ToolForcing) {
156        apply_tool_forcing(&mut request, &response_format);
157    }
158
159    let max_attempts = config.max_retries.saturating_add(1);
160    let mut last_value: Option<serde_json::Value> = None;
161    let mut last_errors = String::new();
162
163    for attempt in 0..max_attempts {
164        // Clone only when a retry may still follow; on the final attempt move the
165        // request in (no deep clone of the message history + attachments).
166        let attempt_request = if attempt + 1 == max_attempts {
167            std::mem::replace(&mut request, ChatRequest::new(String::new(), Vec::new()))
168        } else {
169            request.clone()
170        };
171        let outcome = provider.chat(attempt_request).await?;
172        let response = match outcome {
173            ChatOutcome::Success(response) => response,
174            ChatOutcome::RateLimited(_) => {
175                return Err(StructuredOutputError::ProviderOutcome(
176                    "rate limited".to_owned(),
177                ));
178            }
179            ChatOutcome::InvalidRequest(msg) => {
180                return Err(StructuredOutputError::ProviderOutcome(format!(
181                    "invalid request: {msg}"
182                )));
183            }
184            ChatOutcome::ServerError(msg) => {
185                return Err(StructuredOutputError::ProviderOutcome(format!(
186                    "server error: {msg}"
187                )));
188            }
189            // `ChatOutcome` is `#[non_exhaustive]`; an unrecognized outcome is
190            // surfaced as a provider failure rather than silently retried.
191            _ => {
192                return Err(StructuredOutputError::ProviderOutcome(
193                    "unrecognized provider outcome".to_owned(),
194                ));
195            }
196        };
197
198        let candidate = extract_candidate(&response, support);
199        let Some(value) = candidate else {
200            // No structured value at all. On the final attempt this is a hard
201            // failure; otherwise re-prompt asking for the structured answer.
202            if attempt + 1 >= max_attempts {
203                return Err(StructuredOutputError::NoStructuredOutput);
204            }
205            append_correction(
206                &mut request,
207                &response,
208                support,
209                "Your previous reply did not contain a structured answer. \
210                 Respond with a single JSON value that satisfies the requested schema.",
211            );
212            "missing structured output".clone_into(&mut last_errors);
213            continue;
214        };
215
216        let errors = collect_schema_errors(&validator, &value);
217
218        if errors.is_empty() {
219            return Ok(StructuredOutput {
220                value,
221                response,
222                retries: attempt,
223            });
224        }
225
226        last_errors = errors.join("; ");
227        last_value = Some(value);
228
229        if attempt + 1 < max_attempts {
230            let correction = format!(
231                "Your previous JSON output did not satisfy the schema. \
232                 Fix these validation errors and resend the full JSON value: {last_errors}"
233            );
234            append_correction(&mut request, &response, support, &correction);
235        }
236    }
237
238    Err(StructuredOutputError::RetriesExhausted {
239        attempts: max_attempts,
240        errors: last_errors,
241        last_value,
242    })
243}
244
245/// An incremental update emitted by [`run_structured_stream`].
246#[derive(Debug, Clone)]
247pub enum StructuredStreamUpdate {
248    /// A best-effort, not-yet-validated object parsed from the partial
249    /// response as it streams in. Successive `Partial`s grow toward the final
250    /// value; a consumer can render them as a live preview. Because the
251    /// underlying JSON is incomplete, a partial may contain truncated string
252    /// values and is never schema-validated.
253    Partial(serde_json::Value),
254    /// The final, schema-validated structured output. Exactly one `Final` is
255    /// emitted on success and it is always the last item in the stream.
256    Final(StructuredOutput),
257}
258
259/// A stream of [`StructuredStreamUpdate`]s produced by [`run_structured_stream`].
260pub type StructuredStream<'a> =
261    Pin<Box<dyn Stream<Item = Result<StructuredStreamUpdate, StructuredOutputError>> + Send + 'a>>;
262
263/// Streaming counterpart to [`run_structured`].
264///
265/// Drives the same bounded, schema-validated exchange, but streams the first
266/// attempt so callers see the structured object build incrementally
267/// ([`StructuredStreamUpdate::Partial`]) before the validated
268/// [`StructuredStreamUpdate::Final`] arrives. If the first (streamed) attempt
269/// fails schema validation, the bounded re-prompt loop continues
270/// non-streaming, reusing the exact retry machinery of [`run_structured`].
271///
272/// This is additive: existing [`run_structured`] callers are unaffected.
273///
274/// # Errors
275///
276/// The stream yields a single [`StructuredOutputError`] (and then ends) when
277/// the request lacks a response format, the schema is invalid, the provider
278/// errors, or the model never produces schema-valid output within
279/// [`StructuredConfig::max_retries`].
280pub fn run_structured_stream(
281    provider: &dyn LlmProvider,
282    request: ChatRequest,
283    config: StructuredConfig,
284) -> StructuredStream<'_> {
285    Box::pin(async_stream::stream! {
286        let mut request = request;
287        let Some(response_format) = request.response_format.clone() else {
288            yield Err(StructuredOutputError::MissingResponseFormat);
289            return;
290        };
291        let validator = match validator_for_provider(provider, &response_format) {
292            Ok(validator) => validator,
293            Err(error) => {
294                yield Err(error);
295                return;
296            }
297        };
298
299        let support = provider.structured_output_support();
300        if matches!(support, StructuredOutputSupport::ToolForcing) {
301            apply_tool_forcing(&mut request, &response_format);
302        }
303
304        let max_attempts = config.max_retries.saturating_add(1);
305        let model = provider.model().to_owned();
306        let mut last_value: Option<serde_json::Value> = None;
307        let mut last_errors = String::new();
308
309        for attempt in 0..max_attempts {
310            // The first attempt streams (emitting partials); retries reuse the
311            // non-streaming path so the re-prompt machinery is shared verbatim.
312            let response = if attempt == 0 {
313                let mut attempt_stream =
314                    Box::pin(stream_first_attempt(provider, request.clone(), support, model.clone()));
315                let mut completed: Option<ChatResponse> = None;
316                while let Some(item) = attempt_stream.next().await {
317                    match item {
318                        StreamAttemptItem::Partial(value) => {
319                            yield Ok(StructuredStreamUpdate::Partial(value));
320                        }
321                        StreamAttemptItem::Complete(response) => completed = Some(response),
322                        StreamAttemptItem::Failed(error) => {
323                            yield Err(error);
324                            return;
325                        }
326                    }
327                }
328                // `stream_first_attempt` always terminates with `Complete` or
329                // `Failed`; the `None` arm is an unreachable safety net.
330                match completed {
331                    Some(response) => response,
332                    None => return,
333                }
334            } else {
335                match provider.chat(request.clone()).await {
336                    Ok(ChatOutcome::Success(response)) => response,
337                    Ok(other) => {
338                        yield Err(non_success_outcome_error(&other));
339                        return;
340                    }
341                    Err(e) => {
342                        yield Err(StructuredOutputError::Transport(e));
343                        return;
344                    }
345                }
346            };
347
348            let Some(value) = extract_candidate(&response, support) else {
349                if attempt + 1 >= max_attempts {
350                    yield Err(StructuredOutputError::NoStructuredOutput);
351                    return;
352                }
353                append_correction(
354                    &mut request,
355                    &response,
356                    support,
357                    "Your previous reply did not contain a structured answer. \
358                     Respond with a single JSON value that satisfies the requested schema.",
359                );
360                "missing structured output".clone_into(&mut last_errors);
361                continue;
362            };
363
364            let errors = collect_schema_errors(&validator, &value);
365
366            if errors.is_empty() {
367                yield Ok(StructuredStreamUpdate::Final(StructuredOutput {
368                    value,
369                    response,
370                    retries: attempt,
371                }));
372                return;
373            }
374
375            last_errors = errors.join("; ");
376            last_value = Some(value);
377
378            if attempt + 1 < max_attempts {
379                let correction = format!(
380                    "Your previous JSON output did not satisfy the schema. \
381                     Fix these validation errors and resend the full JSON value: {last_errors}"
382                );
383                append_correction(&mut request, &response, support, &correction);
384            }
385        }
386
387        yield Err(StructuredOutputError::RetriesExhausted {
388            attempts: max_attempts,
389            errors: last_errors,
390            last_value,
391        });
392    })
393}
394
395/// An item produced by [`stream_first_attempt`]: an incremental partial, the
396/// terminal accumulated response, or a typed failure.
397enum StreamAttemptItem {
398    Partial(serde_json::Value),
399    Complete(ChatResponse),
400    Failed(StructuredOutputError),
401}
402
403/// Stream the first structured-output attempt, emitting de-duplicated partial
404/// objects as the response builds and finishing with the accumulated response
405/// (or a typed failure). Factored out of [`run_structured_stream`] so the
406/// orchestration loop stays small.
407fn stream_first_attempt(
408    provider: &dyn LlmProvider,
409    request: ChatRequest,
410    support: StructuredOutputSupport,
411    model: String,
412) -> impl Stream<Item = StreamAttemptItem> + Send + '_ {
413    async_stream::stream! {
414        let mut accumulator = StreamAccumulator::new();
415        let mut partial_buf = String::new();
416        let mut respond_tool_ids: std::collections::HashSet<String> =
417            std::collections::HashSet::new();
418        let mut last_partial: Option<serde_json::Value> = None;
419        let mut stream_error: Option<(String, StreamErrorKind)> = None;
420
421        let mut stream = provider.chat_stream(request);
422        while let Some(item) = stream.next().await {
423            let delta = match item {
424                Ok(delta) => delta,
425                Err(e) => {
426                    yield StreamAttemptItem::Failed(StructuredOutputError::Transport(e));
427                    return;
428                }
429            };
430
431            accumulate_partial_buffer(&delta, support, &mut partial_buf, &mut respond_tool_ids);
432            if let StreamDelta::Error { message, kind } = &delta {
433                stream_error = Some((message.clone(), *kind));
434            }
435            accumulator.apply(&delta);
436
437            if let Some(value) = partial_from_buffer(&partial_buf)
438                && last_partial.as_ref() != Some(&value)
439            {
440                last_partial = Some(value.clone());
441                yield StreamAttemptItem::Partial(value);
442            }
443        }
444
445        if let Some((message, kind)) = stream_error {
446            yield StreamAttemptItem::Failed(stream_error_to_outcome(&message, kind));
447            return;
448        }
449
450        yield StreamAttemptItem::Complete(build_streamed_response(accumulator, model));
451    }
452}
453
454/// Append the relevant part of a streamed delta to the running partial buffer
455/// so [`partial_from_buffer`] can re-parse it. For native providers this is the
456/// model's text output; for tool-forcing it is the forced `respond` tool's
457/// input JSON.
458fn accumulate_partial_buffer(
459    delta: &StreamDelta,
460    support: StructuredOutputSupport,
461    buffer: &mut String,
462    respond_tool_ids: &mut std::collections::HashSet<String>,
463) {
464    match (support, delta) {
465        (StructuredOutputSupport::Native, StreamDelta::TextDelta { delta, .. }) => {
466            buffer.push_str(delta);
467        }
468        (StructuredOutputSupport::ToolForcing, StreamDelta::ToolUseStart { id, name, .. })
469            if name == RESPOND_TOOL_NAME =>
470        {
471            respond_tool_ids.insert(id.clone());
472        }
473        (StructuredOutputSupport::ToolForcing, StreamDelta::ToolInputDelta { id, delta, .. })
474            if respond_tool_ids.contains(id) =>
475        {
476            buffer.push_str(delta);
477        }
478        _ => {}
479    }
480}
481
482/// Map a recorded streaming error into the structured-output error surface.
483fn stream_error_to_outcome(message: &str, kind: StreamErrorKind) -> StructuredOutputError {
484    let label = match kind {
485        StreamErrorKind::RateLimited => "rate limited".to_owned(),
486        StreamErrorKind::InvalidRequest => format!("invalid request: {message}"),
487        _ => format!("server error: {message}"),
488    };
489    StructuredOutputError::ProviderOutcome(label)
490}
491
492/// Map a non-success [`ChatOutcome`] from a retry attempt into a typed error.
493fn non_success_outcome_error(outcome: &ChatOutcome) -> StructuredOutputError {
494    let label = match outcome {
495        ChatOutcome::RateLimited(_) => "rate limited".to_owned(),
496        ChatOutcome::InvalidRequest(msg) => format!("invalid request: {msg}"),
497        ChatOutcome::ServerError(msg) => format!("server error: {msg}"),
498        _ => "unrecognized provider outcome".to_owned(),
499    };
500    StructuredOutputError::ProviderOutcome(label)
501}
502
503/// Materialize a [`ChatResponse`] from a fully-consumed stream accumulator.
504fn build_streamed_response(mut accumulator: StreamAccumulator, model: String) -> ChatResponse {
505    let usage = accumulator.take_usage().unwrap_or(Usage {
506        input_tokens: 0,
507        output_tokens: 0,
508        cached_input_tokens: 0,
509        cache_creation_input_tokens: 0,
510    });
511    let stop_reason = accumulator.take_stop_reason();
512    ChatResponse {
513        id: String::new(),
514        content: accumulator.into_content_blocks(),
515        model,
516        stop_reason,
517        usage,
518    }
519}
520
521/// Best-effort parse of a partial JSON object/array from an in-flight buffer.
522///
523/// Returns the repaired value only when the buffer (after closing any open
524/// containers) parses to an object or array; otherwise `None` so the caller
525/// simply waits for more data.
526fn partial_from_buffer(buffer: &str) -> Option<serde_json::Value> {
527    let trimmed = buffer.trim_start();
528    // Tolerate a streamed leading ```json fence.
529    let body = trimmed
530        .strip_prefix("```")
531        .and_then(|rest| rest.split_once('\n').map(|(_, body)| body))
532        .unwrap_or(trimmed)
533        .trim();
534    if body.is_empty() {
535        return None;
536    }
537    let repaired = repair_partial_json(body);
538    serde_json::from_str::<serde_json::Value>(&repaired)
539        .ok()
540        .filter(|value| value.is_object() || value.is_array())
541}
542
543/// Close any open strings/containers in a partial JSON fragment so it parses.
544///
545/// Truncated string *values* are kept (closed with `"`); a dangling separator
546/// (`,`) is dropped and a dangling key (`"k":`) is completed with `null`. The
547/// result is not guaranteed to parse (e.g. a half-typed key), in which case the
548/// caller discards it.
549fn repair_partial_json(buffer: &str) -> String {
550    let mut in_string = false;
551    let mut escape = false;
552    let mut stack: Vec<char> = Vec::new();
553
554    for ch in buffer.chars() {
555        if in_string {
556            if escape {
557                escape = false;
558            } else if ch == '\\' {
559                escape = true;
560            } else if ch == '"' {
561                in_string = false;
562            }
563            continue;
564        }
565        match ch {
566            '"' => in_string = true,
567            '{' => stack.push('}'),
568            '[' => stack.push(']'),
569            '}' | ']' => {
570                stack.pop();
571            }
572            _ => {}
573        }
574    }
575
576    let mut out = buffer.to_owned();
577    if escape {
578        out.pop();
579    }
580    if in_string {
581        out.push('"');
582    }
583    out.truncate(out.trim_end().len());
584    if out.ends_with(',') {
585        out.pop();
586        out.truncate(out.trim_end().len());
587    } else if out.ends_with(':') {
588        out.push_str(" null");
589    }
590    for closer in stack.iter().rev() {
591        out.push(*closer);
592    }
593    out
594}
595
596/// Collect human-readable schema-validation errors for a candidate value
597/// (empty when it satisfies the schema).
598fn collect_schema_errors(
599    validator: &jsonschema::Validator,
600    value: &serde_json::Value,
601) -> Vec<String> {
602    validator
603        .iter_errors(value)
604        .map(|error| format!("at `{}`: {error}", error.instance_path()))
605        .collect()
606}
607
608/// Return the schema against which the structured runner should validate a
609/// provider response.
610///
611/// `OpenAI` strict mode does not use the caller's schema verbatim: it closes
612/// objects, requires all properties, and represents caller-optional properties
613/// as nullable. The provider sends that normalized schema on the wire, so the
614/// local validator must use the same one or it can reject a response `OpenAI`
615/// correctly produced (for example, an optional field emitted as `null`).
616#[cfg(feature = "openai")]
617fn validation_schema_for_provider(
618    provider: &dyn LlmProvider,
619    response_format: &ResponseFormat,
620) -> Result<serde_json::Value, StructuredOutputError> {
621    if response_format.strict
622        && matches!(
623            provider.structured_output_support(),
624            StructuredOutputSupport::Native
625        )
626        && matches!(provider.provider(), "openai" | "openai-responses")
627    {
628        return normalized_strict_schema(&response_format.schema)
629            .map_err(|error| StructuredOutputError::IncompatibleSchema(error.to_string()));
630    }
631
632    Ok(response_format.schema.clone())
633}
634
635#[cfg(feature = "openai")]
636fn validator_for_provider(
637    provider: &dyn LlmProvider,
638    response_format: &ResponseFormat,
639) -> Result<jsonschema::Validator, StructuredOutputError> {
640    let validation_schema = validation_schema_for_provider(provider, response_format)?;
641    jsonschema::validator_for(&validation_schema)
642        .map_err(|error| StructuredOutputError::InvalidSchema(error.to_string()))
643}
644
645#[cfg(not(feature = "openai"))]
646fn validator_for_provider(
647    _provider: &dyn LlmProvider,
648    response_format: &ResponseFormat,
649) -> Result<jsonschema::Validator, StructuredOutputError> {
650    jsonschema::validator_for(&response_format.schema)
651        .map_err(|error| StructuredOutputError::InvalidSchema(error.to_string()))
652}
653
654/// Inject the forced "respond" tool for providers without native JSON mode.
655fn apply_tool_forcing(request: &mut ChatRequest, response_format: &ResponseFormat) {
656    let respond_tool = Tool {
657        name: RESPOND_TOOL_NAME.to_owned(),
658        description: format!(
659            "Return the final answer as structured data named `{}`. \
660             You MUST call this tool exactly once with arguments matching the schema.",
661            response_format.name
662        ),
663        input_schema: response_format.schema.clone(),
664        display_name: "Structured response".to_owned(),
665        tier: ToolTier::Observe,
666    };
667
668    match request.tools {
669        Some(ref mut tools) => {
670            tools.retain(|t| t.name != RESPOND_TOOL_NAME);
671            tools.push(respond_tool);
672        }
673        None => request.tools = Some(vec![respond_tool]),
674    }
675    request.tool_choice = Some(ToolChoice::Tool(RESPOND_TOOL_NAME.to_owned()));
676    // Forcing a specific tool is incompatible with extended thinking on
677    // Anthropic-family models (the API 400s when `thinking` is active alongside
678    // a `tool_choice` that names a tool). Clearing `request.thinking` here is
679    // *not* sufficient on its own: the Claude providers fall back to their
680    // provider-configured thinking default when the request field is `None`
681    // (see `resolve_thinking_config`), which would resurrect thinking on the
682    // wire. The authoritative guard lives at the wire boundary — the Claude
683    // request builders drop thinking whenever `tool_choice` names a tool (see
684    // `provider::thinking_for_forced_tool`) — so we do not touch
685    // `request.thinking` here and instead rely on that single source of truth.
686}
687
688/// Pull the candidate structured value out of a response according to how the
689/// provider satisfied the request.
690fn extract_candidate(
691    response: &ChatResponse,
692    support: StructuredOutputSupport,
693) -> Option<serde_json::Value> {
694    match support {
695        StructuredOutputSupport::ToolForcing => {
696            response.content.iter().find_map(|block| match block {
697                ContentBlock::ToolUse { name, input, .. } if name == RESPOND_TOOL_NAME => {
698                    Some(input.clone())
699                }
700                _ => None,
701            })
702        }
703        StructuredOutputSupport::Native => {
704            let text = response.first_text()?;
705            parse_json_text(text)
706        }
707    }
708}
709
710/// Parse a JSON value from model text output.
711///
712/// Native JSON mode returns a bare JSON document, but models occasionally wrap
713/// it in a fenced code block, so this strips a leading/trailing markdown fence
714/// before parsing.
715fn parse_json_text(text: &str) -> Option<serde_json::Value> {
716    let trimmed = text.trim();
717    let unfenced = strip_code_fence(trimmed);
718    serde_json::from_str(unfenced).ok()
719}
720
721/// Strip a surrounding ```` ```json ... ``` ```` (or plain ```` ``` ````) fence.
722fn strip_code_fence(text: &str) -> &str {
723    let Some(rest) = text.strip_prefix("```") else {
724        return text;
725    };
726    // Drop an optional language tag on the opening fence line.
727    let rest = rest.split_once('\n').map_or(rest, |(_, body)| body);
728    rest.strip_suffix("```")
729        .map_or(text, |inner| inner.trim_end_matches('`').trim())
730}
731
732/// Append the assistant's previous output plus a corrective user message so the
733/// next attempt sees the validation feedback.
734///
735/// For the tool-forcing path (Anthropic), the assistant turn carries a forced
736/// `respond` `ContentBlock::ToolUse`. The Anthropic Messages API rejects any
737/// conversation where a `tool_use` is not immediately followed by a matching
738/// `tool_result` in the next user message, so the correction is delivered as a
739/// `ToolResult` for that tool-use id (carrying the validation errors) rather than
740/// as plain user text — otherwise the very first re-prompt 400s. When no forced
741/// tool call is present (or for native providers) the correction is plain text.
742fn append_correction(
743    request: &mut ChatRequest,
744    previous: &ChatResponse,
745    support: StructuredOutputSupport,
746    correction: &str,
747) {
748    request
749        .messages
750        .push(Message::assistant_with_content(previous.content.clone()));
751
752    let respond_tool_use_id = if matches!(support, StructuredOutputSupport::ToolForcing) {
753        previous.content.iter().find_map(|block| match block {
754            ContentBlock::ToolUse { id, name, .. } if name == RESPOND_TOOL_NAME => Some(id.clone()),
755            _ => None,
756        })
757    } else {
758        None
759    };
760
761    match respond_tool_use_id {
762        Some(tool_use_id) => {
763            request
764                .messages
765                .push(Message::tool_result(tool_use_id, correction, true));
766        }
767        None => request.messages.push(Message::user(correction)),
768    }
769}
770
771#[cfg(test)]
772mod tests {
773    use super::*;
774
775    use std::sync::Mutex;
776    use std::sync::atomic::{AtomicUsize, Ordering};
777
778    use agent_sdk_foundation::llm::{StopReason, Usage};
779    use anyhow::Result;
780    use async_trait::async_trait;
781
782    use crate::streaming::StreamBox;
783
784    /// A scripted provider: replays a fixed queue of [`ChatOutcome`]s and
785    /// reports a configurable [`StructuredOutputSupport`]. It also records every
786    /// request it receives so tests can assert on the re-prompt history and on
787    /// the tool-forcing injection.
788    struct ScriptedProvider {
789        provider_name: &'static str,
790        model: String,
791        support: StructuredOutputSupport,
792        outcomes: Mutex<std::collections::VecDeque<ChatOutcome>>,
793        seen_requests: Mutex<Vec<ChatRequest>>,
794        calls: AtomicUsize,
795    }
796
797    impl ScriptedProvider {
798        fn new(
799            provider_name: &'static str,
800            support: StructuredOutputSupport,
801            outcomes: Vec<ChatOutcome>,
802        ) -> Self {
803            Self {
804                provider_name,
805                model: "scripted-model".to_owned(),
806                support,
807                outcomes: Mutex::new(outcomes.into()),
808                seen_requests: Mutex::new(Vec::new()),
809                calls: AtomicUsize::new(0),
810            }
811        }
812
813        fn call_count(&self) -> usize {
814            self.calls.load(Ordering::SeqCst)
815        }
816    }
817
818    #[async_trait]
819    impl LlmProvider for ScriptedProvider {
820        async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome> {
821            self.calls.fetch_add(1, Ordering::SeqCst);
822            self.seen_requests
823                .lock()
824                .expect("seen_requests lock")
825                .push(request);
826            let outcome = self
827                .outcomes
828                .lock()
829                .expect("outcomes lock")
830                .pop_front()
831                .expect("ScriptedProvider: ran out of scripted outcomes");
832            Ok(outcome)
833        }
834
835        fn chat_stream(&self, _request: ChatRequest) -> StreamBox<'_> {
836            Box::pin(async_stream::stream! {
837                yield Err(anyhow::anyhow!("streaming not used in structured tests"));
838            })
839        }
840
841        fn model(&self) -> &str {
842            &self.model
843        }
844
845        fn provider(&self) -> &'static str {
846            self.provider_name
847        }
848
849        fn structured_output_support(&self) -> StructuredOutputSupport {
850            self.support
851        }
852    }
853
854    fn person_schema() -> serde_json::Value {
855        serde_json::json!({
856            "type": "object",
857            "properties": {
858                "name": { "type": "string" },
859                "age": { "type": "integer", "minimum": 0 }
860            },
861            "required": ["name", "age"],
862            "additionalProperties": false
863        })
864    }
865
866    fn request_with_format() -> ChatRequest {
867        ChatRequest {
868            system: String::new(),
869            messages: vec![Message::user("Describe a person.")],
870            tools: None,
871            max_tokens: 256,
872            max_tokens_explicit: true,
873            session_id: None,
874            cached_content: None,
875            thinking: None,
876            tool_choice: None,
877            response_format: Some(ResponseFormat::new("person", person_schema())),
878            cache: None,
879        }
880    }
881
882    fn request_with_optional_property_format() -> ChatRequest {
883        let mut request = request_with_format();
884        request.response_format = Some(ResponseFormat::new(
885            "optional-person",
886            serde_json::json!({
887                "type": "object",
888                "properties": {
889                    "name": {"type": "string"},
890                    "nickname": {"type": "string"}
891                },
892                "required": ["name"]
893            }),
894        ));
895        request
896    }
897
898    fn success(content: Vec<ContentBlock>) -> ChatOutcome {
899        ChatOutcome::Success(ChatResponse {
900            id: "resp".to_owned(),
901            content,
902            model: "scripted-model".to_owned(),
903            stop_reason: Some(StopReason::EndTurn),
904            usage: Usage {
905                input_tokens: 1,
906                output_tokens: 1,
907                cached_input_tokens: 0,
908                cache_creation_input_tokens: 0,
909            },
910        })
911    }
912
913    fn text_block(text: &str) -> Vec<ContentBlock> {
914        vec![ContentBlock::Text {
915            text: text.to_owned(),
916        }]
917    }
918
919    fn respond_tool_block(input: serde_json::Value) -> Vec<ContentBlock> {
920        vec![ContentBlock::ToolUse {
921            id: "call_1".to_owned(),
922            name: RESPOND_TOOL_NAME.to_owned(),
923            input,
924            thought_signature: None,
925        }]
926    }
927
928    // ── Happy path: native (OpenAI / Gemini) ──────────────────────────
929
930    #[tokio::test]
931    async fn native_happy_path_validates_json_text() -> Result<()> {
932        let provider = ScriptedProvider::new(
933            "openai",
934            StructuredOutputSupport::Native,
935            vec![success(text_block(r#"{"name": "Ada", "age": 36}"#))],
936        );
937
938        let out = run_structured(
939            &provider,
940            request_with_format(),
941            StructuredConfig::default(),
942        )
943        .await?;
944
945        assert_eq!(out.value["name"], "Ada");
946        assert_eq!(out.value["age"], 36);
947        assert_eq!(out.retries, 0);
948        assert_eq!(provider.call_count(), 1);
949        Ok(())
950    }
951
952    #[tokio::test]
953    async fn native_happy_path_strips_markdown_fence() -> Result<()> {
954        let provider = ScriptedProvider::new(
955            "gemini",
956            StructuredOutputSupport::Native,
957            vec![success(text_block(
958                "```json\n{\"name\": \"Grace\", \"age\": 45}\n```",
959            ))],
960        );
961
962        let out = run_structured(
963            &provider,
964            request_with_format(),
965            StructuredConfig::default(),
966        )
967        .await?;
968
969        assert_eq!(out.value["name"], "Grace");
970        Ok(())
971    }
972
973    #[cfg(feature = "openai")]
974    #[tokio::test]
975    async fn native_openai_validates_against_the_normalized_strict_schema() -> Result<()> {
976        for provider_name in ["openai", "openai-responses"] {
977            let provider = ScriptedProvider::new(
978                provider_name,
979                StructuredOutputSupport::Native,
980                vec![success(text_block(r#"{"name": "Ada", "nickname": null}"#))],
981            );
982
983            let output = run_structured(
984                &provider,
985                request_with_optional_property_format(),
986                StructuredConfig::default(),
987            )
988            .await?;
989
990            assert_eq!(output.value["nickname"], serde_json::Value::Null);
991            assert_eq!(provider.call_count(), 1);
992        }
993        Ok(())
994    }
995
996    #[tokio::test]
997    async fn native_non_openai_uses_the_caller_schema() -> Result<()> {
998        let provider = ScriptedProvider::new(
999            "gemini",
1000            StructuredOutputSupport::Native,
1001            vec![success(text_block(r#"{"name": "Ada"}"#))],
1002        );
1003
1004        let output = run_structured(
1005            &provider,
1006            request_with_optional_property_format(),
1007            StructuredConfig::default(),
1008        )
1009        .await?;
1010
1011        assert_eq!(output.value["name"], "Ada");
1012        assert!(output.value.get("nickname").is_none());
1013        Ok(())
1014    }
1015
1016    #[cfg(feature = "openai")]
1017    #[tokio::test]
1018    async fn native_openai_rejects_dynamic_property_schemas_before_calling_provider() {
1019        let provider = ScriptedProvider::new("openai", StructuredOutputSupport::Native, Vec::new());
1020        let mut request = request_with_format();
1021        request.response_format = Some(ResponseFormat::new(
1022            "dynamic-properties",
1023            serde_json::json!({
1024                "type": "object",
1025                "properties": {"fixed": {"type": "string"}},
1026                "additionalProperties": {"type": "string"}
1027            }),
1028        ));
1029
1030        let result = run_structured(&provider, request, StructuredConfig::default()).await;
1031
1032        assert!(matches!(
1033            result,
1034            Err(StructuredOutputError::IncompatibleSchema(_))
1035        ));
1036        assert_eq!(provider.call_count(), 0);
1037    }
1038
1039    // ── Happy path: tool-forcing fallback (Anthropic) ─────────────────
1040
1041    #[tokio::test]
1042    async fn tool_forcing_happy_path_reads_tool_input() -> Result<()> {
1043        let provider = ScriptedProvider::new(
1044            "anthropic",
1045            StructuredOutputSupport::ToolForcing,
1046            vec![success(respond_tool_block(
1047                serde_json::json!({"name": "Linus", "age": 54}),
1048            ))],
1049        );
1050
1051        let out = run_structured(
1052            &provider,
1053            request_with_format(),
1054            StructuredConfig::default(),
1055        )
1056        .await?;
1057
1058        assert_eq!(out.value["name"], "Linus");
1059        assert_eq!(out.retries, 0);
1060
1061        // The runner must have injected the forced respond tool.
1062        let (has_respond_tool, forces_respond) = {
1063            let seen = provider.seen_requests.lock().expect("seen lock");
1064            let tools = seen[0].tools.as_ref().expect("tools injected");
1065            (
1066                tools.iter().any(|t| t.name == RESPOND_TOOL_NAME),
1067                matches!(
1068                    seen[0].tool_choice,
1069                    Some(ToolChoice::Tool(ref n)) if n == RESPOND_TOOL_NAME
1070                ),
1071            )
1072        };
1073        assert!(has_respond_tool);
1074        assert!(forces_respond);
1075        Ok(())
1076    }
1077
1078    // ── Mismatch → retry → success ────────────────────────────────────
1079
1080    #[tokio::test]
1081    async fn mismatch_then_retry_succeeds() -> Result<()> {
1082        let provider = ScriptedProvider::new(
1083            "openai",
1084            StructuredOutputSupport::Native,
1085            vec![
1086                // First attempt: `age` is a string, violating the schema.
1087                success(text_block(r#"{"name": "Ada", "age": "old"}"#)),
1088                // Retry: corrected.
1089                success(text_block(r#"{"name": "Ada", "age": 36}"#)),
1090            ],
1091        );
1092
1093        let out = run_structured(
1094            &provider,
1095            request_with_format(),
1096            StructuredConfig { max_retries: 2 },
1097        )
1098        .await?;
1099
1100        assert_eq!(out.value["age"], 36);
1101        assert_eq!(out.retries, 1);
1102        assert_eq!(provider.call_count(), 2);
1103
1104        // The corrective re-prompt must have appended the prior answer + a
1105        // user correction message.
1106        let grew = {
1107            let seen = provider.seen_requests.lock().expect("seen lock");
1108            seen[1].messages.len() > seen[0].messages.len()
1109        };
1110        assert!(grew);
1111        Ok(())
1112    }
1113
1114    #[tokio::test]
1115    async fn tool_forcing_retry_appends_tool_result_for_forced_tool_use() -> Result<()> {
1116        use agent_sdk_foundation::llm::Content;
1117
1118        let provider = ScriptedProvider::new(
1119            "anthropic",
1120            StructuredOutputSupport::ToolForcing,
1121            vec![
1122                // First respond: invalid (missing required `age`).
1123                success(respond_tool_block(serde_json::json!({"name": "x"}))),
1124                // Retry: valid.
1125                success(respond_tool_block(
1126                    serde_json::json!({"name": "x", "age": 1}),
1127                )),
1128            ],
1129        );
1130
1131        let out = run_structured(
1132            &provider,
1133            request_with_format(),
1134            StructuredConfig { max_retries: 1 },
1135        )
1136        .await?;
1137        assert_eq!(out.retries, 1);
1138
1139        // The retry request must be a valid Anthropic conversation: the appended
1140        // assistant `respond` tool_use must be answered by a user tool_result with
1141        // a matching tool_use_id — not a bare user text message (which 400s).
1142        let seen = provider.seen_requests.lock().expect("seen lock");
1143        let retry = &seen[1];
1144
1145        let assistant_tool_use_id = retry
1146            .messages
1147            .iter()
1148            .find_map(|m| match &m.content {
1149                Content::Blocks(blocks) => blocks.iter().find_map(|b| match b {
1150                    ContentBlock::ToolUse { id, name, .. } if name == RESPOND_TOOL_NAME => {
1151                        Some(id.clone())
1152                    }
1153                    _ => None,
1154                }),
1155                Content::Text(_) => None,
1156            })
1157            .expect("assistant respond tool_use present in retry");
1158
1159        let has_matching_result = retry.messages.iter().any(|m| match &m.content {
1160            Content::Blocks(blocks) => blocks.iter().any(|b| {
1161                matches!(
1162                    b,
1163                    ContentBlock::ToolResult { tool_use_id, .. }
1164                        if *tool_use_id == assistant_tool_use_id
1165                )
1166            }),
1167            Content::Text(_) => false,
1168        });
1169        drop(seen);
1170        assert!(
1171            has_matching_result,
1172            "retry must carry a tool_result for the forced tool_use id"
1173        );
1174        Ok(())
1175    }
1176
1177    // ── Retry exhaustion → typed error ────────────────────────────────
1178
1179    #[tokio::test]
1180    async fn retry_exhaustion_yields_typed_error() -> Result<()> {
1181        let provider = ScriptedProvider::new(
1182            "anthropic",
1183            StructuredOutputSupport::ToolForcing,
1184            vec![
1185                success(respond_tool_block(serde_json::json!({"name": "x"}))),
1186                success(respond_tool_block(serde_json::json!({"name": "y"}))),
1187                success(respond_tool_block(serde_json::json!({"name": "z"}))),
1188            ],
1189        );
1190
1191        let err = run_structured(
1192            &provider,
1193            request_with_format(),
1194            StructuredConfig { max_retries: 2 },
1195        )
1196        .await
1197        .expect_err("schema never satisfied");
1198
1199        match err {
1200            StructuredOutputError::RetriesExhausted {
1201                attempts,
1202                last_value,
1203                ..
1204            } => {
1205                assert_eq!(attempts, 3, "1 initial + 2 retries");
1206                assert_eq!(
1207                    last_value.as_ref().and_then(|v| v["name"].as_str()),
1208                    Some("z")
1209                );
1210            }
1211            other => panic!("expected RetriesExhausted, got {other:?}"),
1212        }
1213        // initial + 2 retries == 3 calls.
1214        assert_eq!(provider.call_count(), 3);
1215        Ok(())
1216    }
1217
1218    #[tokio::test]
1219    async fn zero_retries_fails_after_single_attempt() -> Result<()> {
1220        let provider = ScriptedProvider::new(
1221            "openai",
1222            StructuredOutputSupport::Native,
1223            vec![success(text_block(r#"{"name": "Ada"}"#))],
1224        );
1225
1226        let err = run_structured(
1227            &provider,
1228            request_with_format(),
1229            StructuredConfig { max_retries: 0 },
1230        )
1231        .await
1232        .expect_err("missing required `age`");
1233
1234        assert!(matches!(
1235            err,
1236            StructuredOutputError::RetriesExhausted { attempts: 1, .. }
1237        ));
1238        assert_eq!(provider.call_count(), 1);
1239        Ok(())
1240    }
1241
1242    // ── Error surfaces ────────────────────────────────────────────────
1243
1244    #[tokio::test]
1245    async fn missing_response_format_is_typed_error() {
1246        let provider = ScriptedProvider::new(
1247            "openai",
1248            StructuredOutputSupport::Native,
1249            vec![success(text_block("{}"))],
1250        );
1251        let mut req = request_with_format();
1252        req.response_format = None;
1253
1254        let err = run_structured(&provider, req, StructuredConfig::default())
1255            .await
1256            .expect_err("no response format");
1257        assert!(matches!(err, StructuredOutputError::MissingResponseFormat));
1258    }
1259
1260    #[tokio::test]
1261    async fn invalid_schema_is_typed_error() {
1262        let provider = ScriptedProvider::new(
1263            "openai",
1264            StructuredOutputSupport::Native,
1265            vec![success(text_block("{}"))],
1266        );
1267        let mut req = request_with_format();
1268        // `type` must be a string/array, not a number — an invalid schema.
1269        req.response_format = Some(ResponseFormat::new("bad", serde_json::json!({"type": 123})));
1270
1271        let err = run_structured(&provider, req, StructuredConfig::default())
1272            .await
1273            .expect_err("invalid schema");
1274        assert!(matches!(err, StructuredOutputError::InvalidSchema(_)));
1275    }
1276
1277    #[tokio::test]
1278    async fn provider_rate_limit_surfaces_as_typed_error() {
1279        let provider = ScriptedProvider::new(
1280            "openai",
1281            StructuredOutputSupport::Native,
1282            vec![ChatOutcome::RateLimited(None)],
1283        );
1284
1285        let err = run_structured(
1286            &provider,
1287            request_with_format(),
1288            StructuredConfig::default(),
1289        )
1290        .await
1291        .expect_err("rate limited");
1292        assert!(matches!(err, StructuredOutputError::ProviderOutcome(_)));
1293    }
1294
1295    #[tokio::test]
1296    async fn no_structured_output_on_final_attempt_errors() {
1297        // Native provider returns non-JSON prose every time.
1298        let provider = ScriptedProvider::new(
1299            "openai",
1300            StructuredOutputSupport::Native,
1301            vec![
1302                success(text_block("I cannot do that.")),
1303                success(text_block("Still prose, sorry.")),
1304            ],
1305        );
1306
1307        let err = run_structured(
1308            &provider,
1309            request_with_format(),
1310            StructuredConfig { max_retries: 1 },
1311        )
1312        .await
1313        .expect_err("never produced JSON");
1314        assert!(matches!(err, StructuredOutputError::NoStructuredOutput));
1315        assert_eq!(provider.call_count(), 2);
1316    }
1317
1318    // ── Streaming structured output ───────────────────────────────────
1319
1320    /// A provider that serves a fixed list of streaming deltas from
1321    /// `chat_stream`. The non-streaming `chat` is only exercised on retries
1322    /// (none of the streaming tests below need it).
1323    struct StreamingProvider {
1324        provider_name: &'static str,
1325        model: String,
1326        support: StructuredOutputSupport,
1327        deltas: Mutex<Vec<StreamDelta>>,
1328    }
1329
1330    impl StreamingProvider {
1331        fn new(
1332            provider_name: &'static str,
1333            support: StructuredOutputSupport,
1334            deltas: Vec<StreamDelta>,
1335        ) -> Self {
1336            Self {
1337                provider_name,
1338                model: "scripted-model".to_owned(),
1339                support,
1340                deltas: Mutex::new(deltas),
1341            }
1342        }
1343    }
1344
1345    #[async_trait]
1346    impl LlmProvider for StreamingProvider {
1347        async fn chat(&self, _request: ChatRequest) -> Result<ChatOutcome> {
1348            Ok(ChatOutcome::ServerError("chat() not used".to_owned()))
1349        }
1350
1351        fn chat_stream(&self, _request: ChatRequest) -> StreamBox<'_> {
1352            let deltas = self.deltas.lock().map(|d| d.clone()).unwrap_or_default();
1353            Box::pin(async_stream::stream! {
1354                for delta in deltas {
1355                    yield Ok(delta);
1356                }
1357            })
1358        }
1359
1360        fn model(&self) -> &str {
1361            &self.model
1362        }
1363
1364        fn provider(&self) -> &'static str {
1365            self.provider_name
1366        }
1367
1368        fn structured_output_support(&self) -> StructuredOutputSupport {
1369            self.support
1370        }
1371    }
1372
1373    async fn drive_stream(
1374        mut stream: StructuredStream<'_>,
1375    ) -> Result<(Vec<serde_json::Value>, Option<StructuredOutput>)> {
1376        let mut partials = Vec::new();
1377        let mut final_out = None;
1378        while let Some(update) = stream.next().await {
1379            match update? {
1380                StructuredStreamUpdate::Partial(value) => partials.push(value),
1381                StructuredStreamUpdate::Final(out) => final_out = Some(out),
1382            }
1383        }
1384        Ok((partials, final_out))
1385    }
1386
1387    #[tokio::test]
1388    async fn streaming_native_emits_partials_then_validated_final() -> Result<()> {
1389        let provider = StreamingProvider::new(
1390            "openai",
1391            StructuredOutputSupport::Native,
1392            vec![
1393                StreamDelta::TextDelta {
1394                    delta: r#"{"name": "Ada""#.to_owned(),
1395                    block_index: 0,
1396                },
1397                StreamDelta::TextDelta {
1398                    delta: r#", "age": 36}"#.to_owned(),
1399                    block_index: 0,
1400                },
1401                StreamDelta::Done {
1402                    stop_reason: Some(StopReason::EndTurn),
1403                },
1404            ],
1405        );
1406
1407        let stream = run_structured_stream(
1408            &provider,
1409            request_with_format(),
1410            StructuredConfig::default(),
1411        );
1412        let (partials, final_out) = drive_stream(stream).await?;
1413
1414        assert!(!partials.is_empty(), "expected at least one partial");
1415        // The first partial sees only the name before the age streamed in.
1416        assert_eq!(partials[0]["name"], "Ada");
1417        let final_out = final_out.expect("a validated final value");
1418        assert_eq!(final_out.value["name"], "Ada");
1419        assert_eq!(final_out.value["age"], 36);
1420        assert_eq!(final_out.retries, 0);
1421        Ok(())
1422    }
1423
1424    #[cfg(feature = "openai")]
1425    #[tokio::test]
1426    async fn streaming_native_openai_uses_the_normalized_strict_schema() -> Result<()> {
1427        let provider = StreamingProvider::new(
1428            "openai-responses",
1429            StructuredOutputSupport::Native,
1430            vec![
1431                StreamDelta::TextDelta {
1432                    delta: r#"{"name": "Ada", "nickname": null}"#.to_owned(),
1433                    block_index: 0,
1434                },
1435                StreamDelta::Done {
1436                    stop_reason: Some(StopReason::EndTurn),
1437                },
1438            ],
1439        );
1440
1441        let stream = run_structured_stream(
1442            &provider,
1443            request_with_optional_property_format(),
1444            StructuredConfig::default(),
1445        );
1446        let (_, final_output) = drive_stream(stream).await?;
1447        let final_output = final_output.ok_or_else(|| anyhow::anyhow!("missing final output"))?;
1448
1449        assert_eq!(final_output.value["nickname"], serde_json::Value::Null);
1450        Ok(())
1451    }
1452
1453    #[tokio::test]
1454    async fn streaming_tool_forcing_reads_partial_tool_input() -> Result<()> {
1455        let provider = StreamingProvider::new(
1456            "anthropic",
1457            StructuredOutputSupport::ToolForcing,
1458            vec![
1459                StreamDelta::ToolUseStart {
1460                    id: "call_1".to_owned(),
1461                    name: RESPOND_TOOL_NAME.to_owned(),
1462                    block_index: 0,
1463                    thought_signature: None,
1464                },
1465                StreamDelta::ToolInputDelta {
1466                    id: "call_1".to_owned(),
1467                    delta: r#"{"name": "Linus""#.to_owned(),
1468                    block_index: 0,
1469                },
1470                StreamDelta::ToolInputDelta {
1471                    id: "call_1".to_owned(),
1472                    delta: r#", "age": 54}"#.to_owned(),
1473                    block_index: 0,
1474                },
1475                StreamDelta::Done {
1476                    stop_reason: Some(StopReason::ToolUse),
1477                },
1478            ],
1479        );
1480
1481        let stream = run_structured_stream(
1482            &provider,
1483            request_with_format(),
1484            StructuredConfig::default(),
1485        );
1486        let (partials, final_out) = drive_stream(stream).await?;
1487
1488        assert_eq!(partials[0]["name"], "Linus");
1489        let final_out = final_out.expect("a validated final value");
1490        assert_eq!(final_out.value["age"], 54);
1491        Ok(())
1492    }
1493
1494    #[tokio::test]
1495    async fn streaming_missing_response_format_errors() {
1496        let provider =
1497            StreamingProvider::new("openai", StructuredOutputSupport::Native, Vec::new());
1498        let mut req = request_with_format();
1499        req.response_format = None;
1500
1501        let mut stream = run_structured_stream(&provider, req, StructuredConfig::default());
1502        let first = stream.next().await.expect("one item");
1503        assert!(matches!(
1504            first,
1505            Err(StructuredOutputError::MissingResponseFormat)
1506        ));
1507    }
1508
1509    #[test]
1510    fn partial_from_buffer_repairs_incomplete_json() {
1511        assert_eq!(
1512            partial_from_buffer(r#"{"name": "Ada""#).map(|v| v["name"].clone()),
1513            Some(serde_json::json!("Ada"))
1514        );
1515        assert_eq!(
1516            partial_from_buffer(r#"{"a": 1,"#),
1517            Some(serde_json::json!({"a": 1}))
1518        );
1519        assert_eq!(
1520            partial_from_buffer(r#"{"a":"#),
1521            Some(serde_json::json!({"a": null}))
1522        );
1523        assert!(partial_from_buffer("").is_none());
1524        assert!(partial_from_buffer("not json").is_none());
1525    }
1526
1527    #[test]
1528    fn apply_tool_forcing_forces_the_respond_tool() {
1529        // `apply_tool_forcing` injects the `respond` tool and forces it via
1530        // `tool_choice`. It intentionally leaves `request.thinking` untouched:
1531        // clearing it here would be resurrected by `resolve_thinking_config`'s
1532        // fallback to the provider-configured default, so the actual
1533        // thinking-incompatible-with-forced-tool guard lives at the Claude wire
1534        // boundary (see `provider::thinking_for_forced_tool` and the
1535        // `forced_tool_drops_configured_thinking_on_the_wire` regression in the
1536        // Anthropic provider). This test locks in the forcing contract.
1537        let mut request = request_with_format();
1538        request.thinking = Some(agent_sdk_foundation::llm::ThinkingConfig::new(10_000));
1539        let response_format = request
1540            .response_format
1541            .clone()
1542            .expect("request_with_format sets a response format");
1543
1544        apply_tool_forcing(&mut request, &response_format);
1545
1546        assert!(matches!(
1547            request.tool_choice,
1548            Some(ToolChoice::Tool(ref name)) if name == RESPOND_TOOL_NAME
1549        ));
1550        assert!(
1551            request
1552                .tools
1553                .as_ref()
1554                .is_some_and(|tools| tools.iter().any(|t| t.name == RESPOND_TOOL_NAME)),
1555            "the respond tool must be present"
1556        );
1557        // Thinking is deliberately not mutated here; the wire-boundary guard
1558        // drops it. See the module-level comment in `apply_tool_forcing`.
1559        assert!(request.thinking.is_some());
1560    }
1561}