1use 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
46const RESPOND_TOOL_NAME: &str = "respond";
48
49#[derive(Debug, Clone, Copy)]
51pub struct StructuredConfig {
52 pub max_retries: u32,
58}
59
60impl Default for StructuredConfig {
61 fn default() -> Self {
62 Self { max_retries: 2 }
63 }
64}
65
66#[derive(Debug, Clone)]
68pub struct StructuredOutput {
69 pub value: serde_json::Value,
71 pub response: ChatResponse,
74 pub retries: u32,
77}
78
79#[derive(Debug, thiserror::Error)]
84pub enum StructuredOutputError {
85 #[error("structured output requested without a response_format on the request")]
88 MissingResponseFormat,
89
90 #[error("invalid output JSON schema: {0}")]
92 InvalidSchema(String),
93
94 #[error("output JSON schema is incompatible with OpenAI strict mode: {0}")]
97 IncompatibleSchema(String),
98
99 #[error("model produced no structured output to validate")]
102 NoStructuredOutput,
103
104 #[error("provider returned a non-success outcome: {0}")]
107 ProviderOutcome(String),
108
109 #[error(
113 "structured output failed schema validation after {attempts} attempt(s); last errors: {errors}"
114 )]
115 RetriesExhausted {
116 attempts: u32,
118 errors: String,
120 last_value: Option<serde_json::Value>,
122 },
123
124 #[error(transparent)]
126 Transport(#[from] anyhow::Error),
127}
128
129pub 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 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 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 _ => {
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 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#[derive(Debug, Clone)]
247pub enum StructuredStreamUpdate {
248 Partial(serde_json::Value),
254 Final(StructuredOutput),
257}
258
259pub type StructuredStream<'a> =
261 Pin<Box<dyn Stream<Item = Result<StructuredStreamUpdate, StructuredOutputError>> + Send + 'a>>;
262
263pub 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 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 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
395enum StreamAttemptItem {
398 Partial(serde_json::Value),
399 Complete(ChatResponse),
400 Failed(StructuredOutputError),
401}
402
403fn 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
454fn 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
482fn 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
492fn 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
503fn 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
521fn partial_from_buffer(buffer: &str) -> Option<serde_json::Value> {
527 let trimmed = buffer.trim_start();
528 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
543fn 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
596fn 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#[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
654fn 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 }
687
688fn 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
710fn 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
721fn strip_code_fence(text: &str) -> &str {
723 let Some(rest) = text.strip_prefix("```") else {
724 return text;
725 };
726 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
732fn 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 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 #[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 #[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 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 #[tokio::test]
1081 async fn mismatch_then_retry_succeeds() -> Result<()> {
1082 let provider = ScriptedProvider::new(
1083 "openai",
1084 StructuredOutputSupport::Native,
1085 vec![
1086 success(text_block(r#"{"name": "Ada", "age": "old"}"#)),
1088 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 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 success(respond_tool_block(serde_json::json!({"name": "x"}))),
1124 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 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 #[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 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 #[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 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 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 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 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 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 assert!(request.thinking.is_some());
1560 }
1561}