use std::fmt;
use std::future::Future;
use std::marker::PhantomData;
use std::pin::Pin;
use std::task::{Context, Poll};
use futures_core::Stream;
use schemars::JsonSchema;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use crate::chat::stream::{StreamBlockType, StreamCollector};
use crate::error::{EXTRACTION_METADATA_KEY, Withheld};
use crate::macros::enum_names;
use crate::provider_data::{ALLAMA_NAMESPACE, DECLINE_EXPLANATION_KEY};
use crate::{
CapabilitySupport, ChatCapability, ChatProvider, ChatRequest, ChatResponse, Error,
FinishReason, Message, ProviderOptions, RequestField, ResponseFormat, Result, StreamEvent,
Subject, ToolChoice, ToolDefinition, Usage, Warning, WarningKind,
};
#[cfg(doc)]
use crate::{UnsupportedPolicy, ValidationCoverage};
mod enforcement;
pub use enforcement::ExtractionPlan;
use enforcement::Target;
const EXTRACTION_TOOL: &str = "submit_structured_output";
pub trait ExtractExt: ChatProvider {
fn extract<'a, T>(
&'a self,
request: &'a ChatRequest,
) -> impl Future<Output = Result<Extracted<T>>> + Send + 'a
where
Self: Sync,
T: JsonSchema + DeserializeOwned + Send + 'a,
{
async move { Extractor::new().extract(self, request).await }
}
fn extract_stream<'a, T>(
&'a self,
request: &'a ChatRequest,
) -> impl Future<Output = Result<ExtractionStream<Self::Stream, T>>> + Send + 'a
where
Self: Sync,
T: JsonSchema + Send + 'a,
{
async move { Extractor::new().extract_stream(self, request).await }
}
}
impl<P: ChatProvider + ?Sized> ExtractExt for P {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ExtractionMode {
#[default]
Auto,
Native,
ForcedTool,
}
enum_names!(ExtractionMode, "extraction mode", {
Auto => "auto",
Native => "native",
ForcedTool => "forced_tool",
});
impl ProviderOptions for ExtractionMode {
const NAMESPACE: &'static str = "allama.extraction";
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Extracted<T> {
pub value: T,
pub response: ChatResponse,
pub metadata: ExtractionMetadata,
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ExtractionMetadata {
pub mode: ExtractionMode,
pub passes: u32,
pub repaired: bool,
#[serde(default)]
pub enforcement: SchemaEnforcement,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub degradations: Vec<Warning>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<Usage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_fallback: Option<AutoFallback>,
#[serde(default, skip_serializing_if = "is_zero")]
pub undeclared_key_rejections: u32,
}
impl ExtractionMetadata {
#[must_use]
pub fn new(mode: ExtractionMode) -> Self {
Self {
mode,
passes: 0,
repaired: false,
enforcement: SchemaEnforcement::NotEnforced,
degradations: Vec::new(),
usage: None,
auto_fallback: None,
undeclared_key_rejections: 0,
}
}
#[must_use]
pub fn from_error(error: &Error) -> Option<Self> {
let record = error.context()?.metadata.get(EXTRACTION_METADATA_KEY)?;
Self::deserialize(record).ok()
}
fn attach_to(&self, mut error: Error) -> Error {
if let Some(context) = error.context_mut()
&& let Ok(record) = serde_json::to_value(self)
{
context
.metadata
.insert(EXTRACTION_METADATA_KEY.to_owned(), record);
}
error
}
fn auto_fallback_for(
&self,
requested: ExtractionMode,
guessed: bool,
error: &Error,
) -> Option<AutoFallback> {
use crate::UnsupportedStage::{BeforeSending, Provider};
let stage = error.unsupported_stage()?;
let refused_here = stage == BeforeSending || (guessed && stage == Provider);
if !self.may_fall_back(requested)
|| error.unsupported_field() != Some(RequestField::ResponseFormat)
|| !refused_here
{
return None;
}
let message = if error.message_origin() == crate::MessageOrigin::Authored {
error.message()
} else {
String::new()
};
Some(AutoFallback::new(RequestField::ResponseFormat, message).with_stage(stage))
}
fn may_fall_back(&self, requested: ExtractionMode) -> bool {
requested == ExtractionMode::Auto
&& self.mode == ExtractionMode::Native
&& self.passes == 0
&& self.auto_fallback.is_none()
}
fn fall_back(&mut self, fallback: AutoFallback) {
self.mode = ExtractionMode::ForcedTool;
self.auto_fallback = Some(fallback);
}
fn record_pass(&mut self, response: &ChatResponse, label: &str) {
self.passes = self.passes.saturating_add(1);
self.record_usage_and_degradations(response, label);
self.enforcement = SchemaEnforcement::from_degradations(&self.degradations);
}
fn record_failed_stream(&mut self, partial: &ChatResponse, label: &str) {
self.record_usage_and_degradations(partial, label);
}
fn record_usage_and_degradations(&mut self, response: &ChatResponse, label: &str) {
if let Some(usage) = &response.usage {
self.usage = Some(self.usage.take().unwrap_or_default() + usage);
}
let field = counted_field(self.mode);
for warning in response
.warnings
.iter()
.filter(|w| field.is_some_and(|field| is_about(w, field)))
{
let warning = enforcement::in_caller_terms(warning, label);
if !self.degradations.contains(&warning) {
self.degradations.push(warning);
}
}
}
}
impl fmt::Debug for ExtractionMetadata {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Self {
mode,
passes,
repaired,
enforcement,
degradations,
usage,
auto_fallback,
undeclared_key_rejections,
} = self;
let mut debug = f.debug_struct("ExtractionMetadata");
debug
.field("mode", mode)
.field("passes", passes)
.field("enforcement", enforcement);
if *repaired {
debug.field("repaired", repaired);
}
if !degradations.is_empty() {
debug.field("degradations", degradations);
}
if let Some(usage) = usage {
debug.field("usage", usage);
}
if let Some(auto_fallback) = auto_fallback {
debug.field("auto_fallback", auto_fallback);
}
if *undeclared_key_rejections > 0 {
debug.field("undeclared_key_rejections", undeclared_key_rejections);
}
debug.finish()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum SchemaEnforcement {
Exact,
Approximate,
#[default]
NotEnforced,
}
enum_names!(SchemaEnforcement, "schema enforcement", {
Exact => "exact",
Approximate => "approximate",
NotEnforced => "not_enforced",
});
impl SchemaEnforcement {
#[must_use]
pub fn satisfies(self, floor: Self) -> bool {
self.rank() >= floor.rank()
}
fn rank(self) -> u8 {
match self {
Self::NotEnforced => 0,
Self::Approximate => 1,
Self::Exact => 2,
}
}
fn from_degradations(warnings: &[Warning]) -> Self {
if warnings.iter().any(|w| w.kind == WarningKind::Unsupported) {
Self::NotEnforced
} else if warnings.is_empty() {
Self::Exact
} else {
Self::Approximate
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AutoFallback {
pub field: RequestField,
pub message: String,
#[serde(default = "before_sending")]
pub stage: crate::UnsupportedStage,
}
fn before_sending() -> crate::UnsupportedStage {
crate::UnsupportedStage::BeforeSending
}
impl AutoFallback {
#[must_use]
pub fn new(field: RequestField, message: impl Into<String>) -> Self {
Self {
field,
message: message.into(),
stage: crate::UnsupportedStage::BeforeSending,
}
}
#[must_use]
pub fn with_stage(mut self, stage: crate::UnsupportedStage) -> Self {
self.stage = stage;
self
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct ExtractFailure {
error: ExtractError,
#[serde(default, skip_serializing_if = "Option::is_none")]
response: Option<ChatResponse>,
metadata: ExtractionMetadata,
}
impl ExtractFailure {
#[must_use]
pub fn new(error: ExtractError, metadata: ExtractionMetadata) -> Self {
Self {
error,
response: None,
metadata,
}
}
#[must_use]
pub fn with_response(mut self, response: ChatResponse) -> Self {
self.response = Some(response);
self
}
#[must_use]
pub fn error(&self) -> &ExtractError {
&self.error
}
#[must_use]
pub fn response(&self) -> Option<&ChatResponse> {
self.response.as_ref()
}
#[must_use]
pub fn metadata(&self) -> &ExtractionMetadata {
&self.metadata
}
#[must_use]
pub fn into_response(self) -> Option<ChatResponse> {
self.response
}
}
impl fmt::Display for ExtractFailure {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.error.fmt(f)
}
}
impl fmt::Debug for ExtractFailure {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut debug = f.debug_struct("ExtractFailure");
debug.field("error", &self.error);
if self.response.is_some() {
debug.field("response", &format_args!("<withheld>"));
}
debug.field("metadata", &self.metadata).finish()
}
}
impl std::error::Error for ExtractFailure {}
impl From<ExtractFailure> for Error {
fn from(failure: ExtractFailure) -> Self {
Error::extract(failure)
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Extractor {
repair: bool,
retries: u32,
allow_undeclared_keys: bool,
min_enforcement: Option<SchemaEnforcement>,
}
impl Extractor {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn repair(mut self, enabled: bool) -> Self {
self.repair = enabled;
self
}
#[must_use]
pub fn retries(mut self, retries: u32) -> Self {
self.retries = retries;
self
}
#[must_use]
pub fn allow_undeclared_keys(mut self, allow: bool) -> Self {
self.allow_undeclared_keys = allow;
self
}
#[must_use]
pub fn min_enforcement(mut self, floor: SchemaEnforcement) -> Self {
self.min_enforcement = Some(floor);
self
}
pub async fn extract<P, T>(&self, provider: &P, request: &ChatRequest) -> Result<Extracted<T>>
where
P: ChatProvider + ?Sized,
T: JsonSchema + DeserializeOwned + Send,
{
let target = Target::new::<T>(provider.provider_name(), &request.model);
let requested = requested_mode(request);
self.check_floor_before_sending(provider, request, &target, false)?;
let (mode, guessed) = select_mode(provider, request);
let mut metadata = ExtractionMetadata::new(mode);
let mut derived = target.derive(request, &metadata)?;
let mut retries_left = self.retries;
loop {
let response = match provider.chat(&derived).await {
Ok(response) => response,
Err(error) => {
let Some(fallback) = metadata.auto_fallback_for(requested, guessed, &error)
else {
return Err(target.pre_send_error(error, &metadata));
};
metadata.fall_back(fallback);
derived = target.derive(request, &metadata)?;
continue;
}
};
metadata.record_pass(&response, &target.label);
target.report_enforcement(&metadata);
if let Some(failure) = self.below_floor(&metadata) {
return Err(failure.with_response(response).into());
}
let mode = metadata.mode;
let parsed = parse_value::<T>(
&response,
mode,
&target.provider,
self.repair,
Some(&target.schema),
self.rejects_undeclared(&metadata),
);
match parsed {
Ok((value, repaired)) => {
metadata.repaired = repaired;
return Ok(Extracted {
value,
response,
metadata,
});
}
Err(rejection) => {
if rejection.undeclared {
metadata.undeclared_key_rejections += 1;
}
if retries_left > 0 && rejection.error.worth_asking_again() {
retries_left -= 1;
ask_again(&mut derived, response, mode, &rejection);
} else {
return Err(ExtractFailure::new(rejection.error, metadata)
.with_response(response)
.into());
}
}
}
}
}
pub fn parse<T: JsonSchema + DeserializeOwned>(
&self,
response: ChatResponse,
mode: ExtractionMode,
) -> Result<Extracted<T>> {
let provider = response.provider.as_deref().unwrap_or("unknown").to_owned();
let metadata = ExtractionMetadata::new(mode);
if mode == ExtractionMode::Auto {
return Err(ExtractFailure::new(
ExtractError::Unsupported { mode, provider },
metadata,
)
.with_response(response)
.into());
}
let model = response.model.clone().unwrap_or_default();
let target = Target::new::<T>(&provider, &model);
let reject_undeclared = self.rejects_undeclared(&metadata);
finish_extraction(response, metadata, &target, self.repair, reject_undeclared)
}
pub async fn extract_stream<P, T>(
&self,
provider: &P,
request: &ChatRequest,
) -> Result<ExtractionStream<P::Stream, T>>
where
P: ChatProvider + ?Sized,
T: JsonSchema,
{
let target = Target::new::<T>(provider.provider_name(), &request.model);
let requested = requested_mode(request);
self.check_floor_before_sending(provider, request, &target, true)?;
let (mode, guessed) = select_mode(provider, request);
let mut metadata = ExtractionMetadata::new(mode);
loop {
let derived = target.derive(request, &metadata)?;
let mut stream = match provider.chat_stream(&derived).await {
Ok(stream) => Box::pin(stream),
Err(error) => {
let Some(fallback) = metadata.auto_fallback_for(requested, guessed, &error)
else {
return Err(target.pre_send_error(error, &metadata));
};
metadata.fall_back(fallback);
continue;
}
};
let mut first = None;
if metadata.may_fall_back(requested) {
let item = std::future::poll_fn(|cx| stream.as_mut().poll_next(cx)).await;
if let Some(Err(error)) = &item
&& let Some(fallback) = metadata.auto_fallback_for(requested, guessed, error)
{
metadata.fall_back(fallback);
continue;
}
first = Some(item);
}
return Ok(ExtractionStream {
inner: stream,
first,
collector: StreamCollector::new(),
partial: String::new(),
tool_index: None,
mode: metadata.mode,
auto_fallback: metadata.auto_fallback,
target,
extractor: *self,
done: false,
_value: PhantomData,
});
}
}
}
pub struct ExtractionStream<S, T> {
inner: Pin<Box<S>>,
first: Option<Option<Result<StreamEvent>>>,
collector: StreamCollector,
partial: String,
tool_index: Option<usize>,
mode: ExtractionMode,
auto_fallback: Option<AutoFallback>,
target: Target,
extractor: Extractor,
done: bool,
_value: PhantomData<fn() -> T>,
}
impl<S, T> ExtractionStream<S, T> {
#[must_use]
pub fn mode(&self) -> ExtractionMode {
self.mode
}
#[must_use]
pub fn partial_output(&self) -> &str {
&self.partial
}
pub fn finish(self) -> Result<Extracted<T>>
where
T: DeserializeOwned,
{
let mut metadata = ExtractionMetadata::new(self.mode);
metadata.auto_fallback = self.auto_fallback;
let mut failed = metadata.clone();
failed.record_failed_stream(self.collector.response_metadata(), &self.target.label);
let response = self
.collector
.finish()
.map_err(|error| failed.attach_to(error))?;
metadata.record_pass(&response, &self.target.label);
self.target.report_enforcement(&metadata);
if let Some(failure) = self.extractor.below_floor(&metadata) {
return Err(failure.with_response(response).into());
}
let reject_undeclared = self.extractor.rejects_undeclared(&metadata);
finish_extraction(
response,
metadata,
&self.target,
self.extractor.repair,
reject_undeclared,
)
}
fn observe(&mut self, event: &StreamEvent) {
match event {
StreamEvent::TextDelta { text, .. } if self.mode == ExtractionMode::Native => {
self.partial.push_str(text);
}
StreamEvent::BlockStart {
index,
block_type: StreamBlockType::ToolCall,
name: Some(name),
..
} if name == EXTRACTION_TOOL && self.tool_index.is_none() => {
self.tool_index = Some(*index);
}
StreamEvent::ToolCallDelta { index, arguments } if self.tool_index == Some(*index) => {
self.partial.push_str(arguments);
}
_ => {}
}
}
}
impl<S, T> fmt::Debug for ExtractionStream<S, T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ExtractionStream")
.field("mode", &self.mode)
.field("collector", &self.collector)
.field("partial_output_len", &self.partial.len())
.field("done", &self.done)
.finish_non_exhaustive()
}
}
impl<S, T> Stream for ExtractionStream<S, T>
where
S: Stream<Item = Result<StreamEvent>>,
{
type Item = Result<StreamEvent>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = &mut *self;
if this.done {
return Poll::Ready(None);
}
let poll = match this.first.take() {
Some(item) => Poll::Ready(item),
None => this.inner.as_mut().poll_next(cx),
};
match &poll {
Poll::Ready(Some(Ok(event))) => {
this.observe(event);
if let Err(error) = this.collector.push_ref(event) {
this.done = true;
this.collector.set_terminal_error(error.clone());
return Poll::Ready(Some(Err(error)));
}
}
Poll::Ready(Some(Err(error))) => {
this.done = true;
this.collector.set_terminal_error(error.clone());
}
Poll::Ready(None) => this.done = true,
Poll::Pending => {}
}
poll
}
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum ExtractError {
#[non_exhaustive]
Unsupported {
mode: ExtractionMode,
provider: String,
},
#[non_exhaustive]
ToolConflict {
provider: String,
},
#[non_exhaustive]
MissingStructuredText {
provider: String,
},
#[non_exhaustive]
MissingToolCall {
provider: String,
},
#[non_exhaustive]
UnexpectedToolCallCount {
provider: String,
count: usize,
},
#[non_exhaustive]
Truncated {
mode: ExtractionMode,
provider: String,
#[serde(default = "output_limit")]
finish_reason: FinishReason,
#[serde(default, skip_serializing_if = "Option::is_none")]
output_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
reasoning_tokens: Option<u64>,
},
#[non_exhaustive]
Refused {
mode: ExtractionMode,
provider: String,
finish_reason: Option<FinishReason>,
text: Option<String>,
source: RefusalSource,
},
#[non_exhaustive]
EnforcementBelow {
required: SchemaEnforcement,
achieved: SchemaEnforcement,
reason: String,
},
#[non_exhaustive]
Parse {
mode: ExtractionMode,
provider: String,
raw: String,
message: String,
path: Option<String>,
},
}
impl ExtractError {
#[must_use]
pub fn raw_output(&self) -> Option<&str> {
match self {
Self::Parse { raw, .. } => Some(raw),
_ => None,
}
}
pub(crate) fn log_message(&self) -> String {
match self {
Self::Parse { mode, provider, .. } => {
format!("{provider}: failed to parse the output using mode {mode:?}")
}
Self::Refused {
provider,
finish_reason,
..
} => refused_without_text(provider, finish_reason.as_ref()),
other => other.to_string(),
}
}
fn worth_asking_again(&self) -> bool {
matches!(
self,
Self::MissingStructuredText { .. }
| Self::MissingToolCall { .. }
| Self::UnexpectedToolCallCount { .. }
| Self::Parse { .. }
)
}
}
impl fmt::Display for ExtractError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Unsupported {
mode: ExtractionMode::Auto,
provider,
} => write!(
f,
"{provider}: the provider reports that the model supports neither structured output nor forced tool calls, so extraction mode Auto has no mode to pick; set ExtractionMode explicitly, or override the capability answers with a capability resolver"
),
Self::Unsupported { mode, provider } => write!(
f,
"{provider}: extraction mode {mode:?} is not supported; set ExtractionMode explicitly or override the capability answers with a capability resolver"
),
Self::ToolConflict { provider } => write!(
f,
"{provider}: extraction cannot be combined with request tools; clear `tools` and `tool_choice`, or start a fresh request from the messages"
),
Self::MissingStructuredText { provider } => {
write!(f, "{provider}: the model did not return structured text")
}
Self::MissingToolCall { provider } => write!(
f,
"{provider}: the model did not call the extraction tool '{EXTRACTION_TOOL}'"
),
Self::UnexpectedToolCallCount { provider, count } => write!(
f,
"{provider}: the model returned {count} extraction tool calls, expected one"
),
Self::Truncated {
mode,
provider,
finish_reason: FinishReason::ContextWindow,
..
} => write!(
f,
"{provider}: the model filled its context window before finishing the structured output (mode {mode:?}): shorten the input, a higher output token limit does not help"
),
Self::Truncated {
mode,
provider,
output_tokens,
reasoning_tokens,
..
} => {
write!(
f,
"{provider}: the model stopped at the output token limit before finishing the structured output (mode {mode:?})"
)?;
if let Some(share) =
crate::usage::reasoning_share(*output_tokens, *reasoning_tokens)
{
write!(
f,
"; {share}: raise max_output_tokens or lower the reasoning effort"
)?;
}
Ok(())
}
Self::Refused {
provider,
text: Some(text),
..
} => write!(f, "{provider}: the model refused: {text}"),
Self::Refused {
provider,
finish_reason,
..
} => f.write_str(&refused_without_text(provider, finish_reason.as_ref())),
Self::EnforcementBelow {
required,
achieved,
reason,
} => write!(
f,
"schema enforcement `{achieved}` is below the required `{required}`: {reason}"
),
Self::Parse {
mode,
provider,
message,
path,
..
} => {
write!(
f,
"{provider}: failed to parse the output using mode {mode:?}: "
)?;
if let Some(path) = path {
write!(f, "at `{path}`: ")?;
}
f.write_str(message)
}
}
}
}
impl fmt::Debug for ExtractError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Unsupported { mode, provider } => f
.debug_struct("Unsupported")
.field("mode", mode)
.field("provider", provider)
.finish(),
Self::ToolConflict { provider } => f
.debug_struct("ToolConflict")
.field("provider", provider)
.finish(),
Self::MissingStructuredText { provider } => f
.debug_struct("MissingStructuredText")
.field("provider", provider)
.finish(),
Self::MissingToolCall { provider } => f
.debug_struct("MissingToolCall")
.field("provider", provider)
.finish(),
Self::UnexpectedToolCallCount { provider, count } => f
.debug_struct("UnexpectedToolCallCount")
.field("provider", provider)
.field("count", count)
.finish(),
Self::Truncated {
mode,
provider,
finish_reason,
output_tokens,
reasoning_tokens,
} => {
let mut debug = f.debug_struct("Truncated");
debug
.field("mode", mode)
.field("provider", provider)
.field("finish_reason", finish_reason);
if let Some(output_tokens) = output_tokens {
debug.field("output_tokens", output_tokens);
}
if let Some(reasoning_tokens) = reasoning_tokens {
debug.field("reasoning_tokens", reasoning_tokens);
}
debug.finish()
}
Self::Refused {
mode,
provider,
finish_reason,
text,
source,
} => {
let mut debug = f.debug_struct("Refused");
debug.field("mode", mode).field("provider", provider);
if let Some(finish_reason) = finish_reason {
debug.field("finish_reason", finish_reason);
}
if let Some(text) = text {
debug.field("text", &Withheld::shown_by_display(text));
}
debug.field("source", source).finish()
}
Self::EnforcementBelow {
required,
achieved,
reason,
} => f
.debug_struct("EnforcementBelow")
.field("required", required)
.field("achieved", achieved)
.field("reason", reason)
.finish(),
Self::Parse {
mode,
provider,
raw,
message,
path,
} => {
let mut debug = f.debug_struct("Parse");
debug
.field("mode", mode)
.field("provider", provider)
.field("raw", &Withheld::new(raw))
.field("message", &Withheld::shown_by_display(message));
if let Some(path) = path {
debug.field("path", &Withheld::shown_by_display(path));
}
debug.finish()
}
}
}
}
impl std::error::Error for ExtractError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum RefusalSource {
Model,
Provider,
None,
}
fn refused_without_text(provider: &str, finish_reason: Option<&FinishReason>) -> String {
match finish_reason {
Some(reason) => format!(
"{provider}: the model refused to produce the structured output (finish reason `{reason}`)"
),
None => format!("{provider}: the model refused to produce the structured output"),
}
}
fn requested_mode(request: &ChatRequest) -> ExtractionMode {
request
.option::<ExtractionMode>()
.copied()
.unwrap_or_default()
}
fn select_mode<P: ChatProvider + ?Sized>(
provider: &P,
request: &ChatRequest,
) -> (ExtractionMode, bool) {
match requested_mode(request) {
ExtractionMode::Auto => {
use CapabilitySupport::{Supported, Unknown, Unsupported};
let answer = |c| provider.chat_capability(&request.model, c);
let native = answer(ChatCapability::StructuredOutput);
let tools = answer(ChatCapability::ToolCalls);
let forced = answer(ChatCapability::ForcedToolChoice);
match (native, tools, forced) {
(Supported, _, _) => (ExtractionMode::Native, false),
(_, Supported, Supported) => (ExtractionMode::ForcedTool, false),
(Unknown, _, _) => (ExtractionMode::Native, true),
(_, Supported | Unknown, Supported | Unknown) => {
(ExtractionMode::ForcedTool, false)
}
(Unsupported, _, _) => (ExtractionMode::Auto, false),
}
}
mode => (mode, false),
}
}
fn derive_request(
request: &ChatRequest,
mode: ExtractionMode,
provider: &str,
name: &str,
schema: serde_json::Value,
) -> std::result::Result<ChatRequest, ExtractError> {
if mode != ExtractionMode::Auto && !request.tools.is_empty() {
return Err(ExtractError::ToolConflict {
provider: provider.to_owned(),
});
}
let mut derived = request.clone();
derived.options.remove::<ExtractionMode>();
derived.options.remove_json(ExtractionMode::NAMESPACE);
match mode {
ExtractionMode::Native => {
derived.response_format = Some(ResponseFormat::json_schema(schema).name(name));
}
ExtractionMode::ForcedTool => {
derived.tools = vec![
ToolDefinition::new(EXTRACTION_TOOL, schema)
.description("Submit extracted structured data")
.strict(true),
];
derived.tool_choice = Some(ToolChoice::Specific {
name: EXTRACTION_TOOL.to_owned(),
});
derived.response_format = None;
}
ExtractionMode::Auto => {
return Err(ExtractError::Unsupported {
mode,
provider: provider.to_owned(),
});
}
}
Ok(derived)
}
fn counted_field(mode: ExtractionMode) -> Option<RequestField> {
match mode {
ExtractionMode::Native => Some(RequestField::ResponseFormat),
ExtractionMode::ForcedTool => Some(RequestField::Tools),
ExtractionMode::Auto => None,
}
}
fn is_about(warning: &Warning, field: RequestField) -> bool {
warning.subject == Subject::Field { field }
}
fn finish_extraction<T: DeserializeOwned>(
response: ChatResponse,
mut metadata: ExtractionMetadata,
target: &Target,
repair: bool,
reject_undeclared: bool,
) -> Result<Extracted<T>> {
let parsed = parse_value::<T>(
&response,
metadata.mode,
&target.provider,
repair,
Some(&target.schema),
reject_undeclared,
);
match parsed {
Ok((value, repaired)) => {
metadata.repaired = repaired;
Ok(Extracted {
value,
response,
metadata,
})
}
Err(rejection) => {
if rejection.undeclared {
metadata.undeclared_key_rejections += 1;
}
Err(ExtractFailure::new(rejection.error, metadata)
.with_response(response)
.into())
}
}
}
struct Rejection {
error: ExtractError,
invalid_json: bool,
undeclared: bool,
}
impl From<ExtractError> for Rejection {
fn from(error: ExtractError) -> Self {
Self {
error,
invalid_json: false,
undeclared: false,
}
}
}
fn is_zero(n: &u32) -> bool {
*n == 0
}
fn parse_value<T: DeserializeOwned>(
response: &ChatResponse,
mode: ExtractionMode,
provider: &str,
repair: bool,
schema: Option<&serde_json::Value>,
reject_undeclared: bool,
) -> std::result::Result<(T, bool), Rejection> {
if let Some(refused) = refusal(response, mode, provider) {
return Err(refused.into());
}
if let Some(finish_reason @ (FinishReason::Length | FinishReason::ContextWindow)) =
&response.finish_reason
{
let usage = response.usage.as_ref();
return Err(ExtractError::Truncated {
mode,
provider: provider.to_owned(),
finish_reason: finish_reason.clone(),
output_tokens: usage.and_then(|usage| usage.output_tokens),
reasoning_tokens: usage.and_then(|usage| usage.reasoning_tokens),
}
.into());
}
let raw = match mode {
ExtractionMode::ForcedTool => {
let calls: Vec<_> = response
.tool_calls()
.filter(|call| call.name == EXTRACTION_TOOL)
.collect();
match calls.as_slice() {
[] => {
return Err(ExtractError::MissingToolCall {
provider: provider.to_owned(),
}
.into());
}
[call] => call.arguments.clone(),
many => {
return Err(ExtractError::UnexpectedToolCallCount {
provider: provider.to_owned(),
count: many.len(),
}
.into());
}
}
}
_ => response
.text()
.filter(|text| !text.trim().is_empty())
.ok_or_else(|| ExtractError::MissingStructuredText {
provider: provider.to_owned(),
})?,
};
let mut failure = match crate::schema::from_str_checked(&raw, schema, reject_undeclared) {
Ok(value) => return Ok((value, false)),
Err(failure) => failure,
};
if repair && let Some(candidate) = repair_candidate(&raw) {
match crate::schema::from_str_checked(candidate, schema, reject_undeclared) {
Ok(value) => return Ok((value, true)),
Err(candidate_failure) if !is_json_syntax_error(&candidate_failure.error) => {
failure = candidate_failure;
}
Err(_) => {}
}
}
let mut message = failure.error.to_string();
if let Some(reason) = response
.finish_reason
.as_ref()
.filter(|reason| !reason.finished_normally())
{
message.push_str(&format!(" (finish reason `{reason}`)"));
}
Err(Rejection {
invalid_json: is_json_syntax_error(&failure.error),
undeclared: failure.undeclared,
error: ExtractError::Parse {
mode,
provider: provider.to_owned(),
message,
path: failure.path,
raw,
},
})
}
fn refusal(response: &ChatResponse, mode: ExtractionMode, provider: &str) -> Option<ExtractError> {
let written = model_refusal(response);
let declined = matches!(
response.finish_reason,
Some(FinishReason::Refusal | FinishReason::ContentFilter)
);
if written.is_none() && !declined {
return None;
}
let (text, source) = match (written, decline_explanation(response)) {
(Some(text), _) => (Some(text.to_owned()), RefusalSource::Model),
(None, Some(explanation)) => (Some(explanation.to_owned()), RefusalSource::Provider),
(None, None) => (None, RefusalSource::None),
};
Some(ExtractError::Refused {
mode,
provider: provider.to_owned(),
finish_reason: response.finish_reason.clone(),
text,
source,
})
}
fn model_refusal(response: &ChatResponse) -> Option<&str> {
response.first_refusal()
}
fn output_limit() -> FinishReason {
FinishReason::Length
}
fn decline_explanation(response: &ChatResponse) -> Option<&str> {
response
.provider_data
.namespaces()
.filter(|(namespace, _)| *namespace != ALLAMA_NAMESPACE)
.find_map(|(_, entries)| entries.get(DECLINE_EXPLANATION_KEY)?.as_str())
.filter(|text| !text.trim().is_empty())
}
fn is_json_syntax_error(error: &serde_json::Error) -> bool {
matches!(
error.classify(),
serde_json::error::Category::Syntax | serde_json::error::Category::Eof
)
}
fn repair_candidate(raw: &str) -> Option<&str> {
let trimmed = raw.trim();
if let Some((_, body)) = trimmed
.strip_prefix("```")
.and_then(|fenced| fenced.split_once('\n'))
{
let body = body.find("```").map_or(body, |end| &body[..end]).trim();
if !body.is_empty() {
return Some(body);
}
}
let start = trimmed.find(['{', '['])?;
let end = trimmed.rfind(['}', ']'])?;
let candidate = trimmed.get(start..=end)?;
(candidate.len() < trimmed.len()).then_some(candidate)
}
fn ask_again(
request: &mut ChatRequest,
response: ChatResponse,
mode: ExtractionMode,
rejection: &Rejection,
) {
let calls: Vec<_> = response.tool_calls().cloned().collect();
let has_content = !response.content.is_empty();
if has_content {
request.push_message(response.into_assistant_message());
}
let problem = match &rejection.error {
ExtractError::Parse { message, .. } if rejection.invalid_json => {
format!("it was not valid JSON: {message}")
}
ExtractError::Parse {
message,
path: Some(path),
..
} => format!("it did not match the schema at `{path}`: {message}"),
ExtractError::Parse { message, .. } => format!("it did not match the schema: {message}"),
ExtractError::MissingStructuredText { .. } => "it contained no text".to_owned(),
ExtractError::MissingToolCall { .. } => {
format!("it did not call the `{EXTRACTION_TOOL}` tool")
}
ExtractError::UnexpectedToolCallCount { count, .. } => {
format!("it called the `{EXTRACTION_TOOL}` tool {count} times instead of once")
}
other => other.to_string(),
};
let forced = mode == ExtractionMode::ForcedTool;
if has_content {
for call in &calls {
let feedback = if forced {
format!(
"The previous call could not be used: {problem}. Call `{EXTRACTION_TOOL}` once with arguments that match its schema."
)
} else {
"This tool call was not run: a structured reply is required.".to_owned()
};
request.push_tool_result(call, feedback);
}
}
if forced && has_content && !calls.is_empty() {
return;
}
request.push_message(Message::user(if forced {
format!(
"The previous reply could not be used: {problem}. Call `{EXTRACTION_TOOL}` once with the extracted data."
)
} else {
format!(
"The previous reply could not be used: {problem}. Reply again with only the JSON value, matching the schema exactly."
)
}));
}
fn schema_name<T>(schema: &serde_json::Value) -> String {
let raw = schema
.get("title")
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
.unwrap_or_else(|| {
std::any::type_name::<T>()
.rsplit("::")
.next()
.unwrap_or("structured_output")
.to_owned()
});
let mut name = String::with_capacity(raw.len());
for ch in raw.chars() {
if ch.is_ascii_alphanumeric() {
name.push(ch.to_ascii_lowercase());
} else if !name.ends_with('_') && !name.is_empty() {
name.push('_');
}
}
let trimmed = name.trim_matches('_');
if trimmed.is_empty() {
"structured_output".into()
} else {
trimmed.into()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mock::{ChatResponseBuilder, MockProvider, MockStreamEvent, MockStreamingProvider};
use crate::{CapabilitySupport, UserContent};
use futures_util::StreamExt;
use serde_json::json;
#[derive(Debug, PartialEq, Deserialize, JsonSchema)]
struct Person {
name: String,
age: u8,
}
fn request() -> ChatRequest {
ChatRequest::new("m").user("Alice is 31")
}
fn alice() -> Person {
Person {
name: "Alice".into(),
age: 31,
}
}
#[tokio::test]
async fn extract_works_against_the_test_provider_in_every_mode() {
#[derive(Debug, PartialEq, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
enum Category {
Billing,
Bug,
}
#[derive(Debug, PartialEq, Deserialize, JsonSchema)]
struct Triage {
category: Category,
urgent: bool,
age: u8,
}
let provider = crate::mock::TestProvider::new();
for mode in [
ExtractionMode::Auto,
ExtractionMode::Native,
ExtractionMode::ForcedTool,
] {
let request = ChatRequest::new("m")
.user("My invoice is wrong")
.with_option(mode);
let extracted = provider
.extract::<Triage>(&request)
.await
.unwrap_or_else(|error| panic!("{mode:?}: {error}"));
assert_eq!(
extracted.value,
Triage {
category: Category::Billing,
urgent: true,
age: 0,
},
"{mode:?}"
);
let expected = match mode {
ExtractionMode::ForcedTool => ExtractionMode::ForcedTool,
_ => ExtractionMode::Native,
};
assert_eq!(extracted.metadata.mode, expected);
}
let mut stream = provider
.extract_stream::<Triage>(&ChatRequest::new("m").user("My invoice is wrong"))
.await
.unwrap();
while stream.next().await.is_some() {}
assert_eq!(stream.finish().unwrap().value.category, Category::Billing);
}
#[tokio::test]
async fn native_mode_uses_json_schema() {
let provider = MockProvider::with_text(r#"{"name":"Alice","age":31}"#)
.with_chat_capability(
ChatCapability::StructuredOutput,
CapabilitySupport::Supported,
);
let extracted = provider.extract::<Person>(&request()).await.unwrap();
assert_eq!(extracted.value, alice());
assert_eq!(
extracted.metadata,
ExtractionMetadata {
mode: ExtractionMode::Native,
passes: 1,
repaired: false,
enforcement: SchemaEnforcement::Exact,
degradations: Vec::new(),
usage: None,
auto_fallback: None,
undeclared_key_rejections: 0,
}
);
let sent = provider.last_request().unwrap();
let Some(ResponseFormat::JsonSchema { name, strict, .. }) = sent.response_format else {
panic!("expected json schema");
};
assert_eq!(name.as_deref(), Some("person"));
assert_eq!(strict, Some(true));
}
#[tokio::test]
async fn auto_picks_forced_tool_without_structured_output() {
let provider =
MockProvider::with_tool_call("c", EXTRACTION_TOOL, json!({"name": "Alice", "age": 31}))
.with_supported_chat_capabilities([
ChatCapability::ToolCalls,
ChatCapability::ForcedToolChoice,
]);
let extracted = provider.extract::<Person>(&request()).await.unwrap();
assert_eq!(extracted.metadata.mode, ExtractionMode::ForcedTool);
let sent = provider.last_request().unwrap();
assert_eq!(
sent.tool_choice,
Some(ToolChoice::Specific {
name: EXTRACTION_TOOL.into()
})
);
assert!(sent.option::<ExtractionMode>().is_none());
}
fn refused(field: RequestField) -> Error {
crate::Degradations::new("demo", crate::UnsupportedPolicy::Warn)
.unsupported(field, "map-typed properties cannot be expressed")
}
fn refusal_record() -> AutoFallback {
AutoFallback::new(
RequestField::ResponseFormat,
"cannot send `response_format`: map-typed properties cannot be expressed",
)
}
fn alice_call() -> serde_json::Value {
json!({"name": "Alice", "age": 31})
}
const NATIVE_AND_TOOLS: [ChatCapability; 2] =
[ChatCapability::StructuredOutput, ChatCapability::ToolCalls];
#[tokio::test]
async fn a_guessed_native_mode_falls_back_on_the_providers_refusal() {
let upstream = || {
Error::unsupported_by_provider(
Some(crate::Subject::Field {
field: RequestField::ResponseFormat,
}),
"HTTP 400: the model does not support `response_format`",
)
.authored()
};
let unknown = MockProvider::build(|b| {
b.error(upstream())
.tool_call("c", EXTRACTION_TOOL, alice_call())
})
.with_chat_capability(ChatCapability::StructuredOutput, CapabilitySupport::Unknown)
.with_chat_capability(ChatCapability::ToolCalls, CapabilitySupport::Unknown)
.with_chat_capability(ChatCapability::ForcedToolChoice, CapabilitySupport::Unknown);
let extracted = unknown.extract::<Person>(&request()).await.unwrap();
assert_eq!(extracted.metadata.mode, ExtractionMode::ForcedTool);
let fallback = extracted.metadata.auto_fallback.unwrap();
assert_eq!(fallback.stage, crate::UnsupportedStage::Provider);
assert_eq!(fallback.field, RequestField::ResponseFormat);
let verified = MockProvider::build(|b| {
b.error(upstream())
.tool_call("c", EXTRACTION_TOOL, alice_call())
})
.with_supported_chat_capabilities(NATIVE_AND_TOOLS);
let err = verified.extract::<Person>(&request()).await.unwrap_err();
assert_eq!(
err.unsupported_stage(),
Some(crate::UnsupportedStage::Provider)
);
assert_eq!(verified.call_count(), 1);
}
#[tokio::test]
async fn auto_retries_a_refused_response_format_in_forced_tool_mode() {
let provider = MockProvider::build(|b| {
b.error(refused(RequestField::ResponseFormat)).tool_call(
"c",
EXTRACTION_TOOL,
alice_call(),
)
})
.with_supported_chat_capabilities(NATIVE_AND_TOOLS);
let extracted = provider.extract::<Person>(&request()).await.unwrap();
assert_eq!(extracted.value, alice());
assert_eq!(
extracted.metadata,
ExtractionMetadata {
mode: ExtractionMode::ForcedTool,
passes: 1,
repaired: false,
enforcement: SchemaEnforcement::Exact,
degradations: Vec::new(),
usage: None,
auto_fallback: Some(refusal_record()),
undeclared_key_rejections: 0,
}
);
let [native, forced] = provider.requests().try_into().unwrap();
assert!(native.response_format.is_some());
assert!(native.tools.is_empty());
assert!(forced.response_format.is_none());
assert_eq!(
forced.tool_choice,
Some(ToolChoice::Specific {
name: EXTRACTION_TOOL.into()
})
);
}
#[tokio::test]
async fn only_a_refused_response_format_falls_back_and_only_from_auto() {
let cases = [
(
request().with_option(ExtractionMode::Native),
refused(RequestField::ResponseFormat),
),
(request(), refused(RequestField::Temperature)),
(request(), Error::unsupported("HTTP 400")),
(request(), rate_limited()),
(
request(),
Error::unsupported_by_provider(
Some(crate::Subject::Field {
field: RequestField::ResponseFormat,
}),
"HTTP 400",
),
),
];
for (request, error) in cases {
let provider = MockProvider::build(|b| {
b.error(error.clone())
.tool_call("c", EXTRACTION_TOOL, alice_call())
})
.with_supported_chat_capabilities(NATIVE_AND_TOOLS);
let err = provider.extract::<Person>(&request).await.unwrap_err();
assert_eq!(err.kind_name(), error.kind_name(), "{err:?}");
assert_eq!(err.unsupported_field(), error.unsupported_field());
let record = ExtractionMetadata::from_error(&err).unwrap();
assert_eq!(record.mode, ExtractionMode::Native);
assert_eq!(record.auto_fallback, None);
assert_eq!(provider.call_count(), 1, "{error:?}");
}
}
#[tokio::test]
async fn a_refusal_after_a_pass_does_not_switch_modes() {
let provider = MockProvider::build(|b| {
b.text("not json")
.error(refused(RequestField::ResponseFormat))
})
.with_supported_chat_capabilities(NATIVE_AND_TOOLS);
let err = Extractor::new()
.retries(1)
.extract::<_, Person>(&provider, &request())
.await
.unwrap_err();
assert_eq!(err.unsupported_field(), Some(RequestField::ResponseFormat));
let record = ExtractionMetadata::from_error(&err).unwrap();
assert_eq!((record.mode, record.passes), (ExtractionMode::Native, 1));
assert_eq!(record.auto_fallback, None);
}
#[tokio::test]
async fn a_refusal_ending_a_fallback_chain_falls_back() {
let chained = refused(RequestField::ResponseFormat).with_fallback_from(rate_limited());
let provider = MockProvider::build(|b| {
b.error(chained)
.tool_call("c", EXTRACTION_TOOL, alice_call())
})
.with_supported_chat_capabilities(NATIVE_AND_TOOLS);
let extracted = provider.extract::<Person>(&request()).await.unwrap();
assert_eq!(extracted.metadata.mode, ExtractionMode::ForcedTool);
assert_eq!(extracted.metadata.auto_fallback, Some(refusal_record()));
}
#[tokio::test]
async fn a_failed_forced_tool_attempt_reports_the_native_refusal_first() {
let provider = MockProvider::build(|b| {
b.error(refused(RequestField::ResponseFormat))
.error(refused(RequestField::Tools))
})
.with_supported_chat_capabilities(NATIVE_AND_TOOLS);
let err = provider.extract::<Person>(&request()).await.unwrap_err();
assert_eq!(err.unsupported_field(), Some(RequestField::ResponseFormat));
assert_eq!(
err.message(),
"`Person` cannot be extracted from `m` with its output constrained: \
native output was refused (cannot send `response_format`: map-typed properties cannot be expressed), \
and forced-tool mode was refused too (cannot send `tools`: map-typed properties cannot be expressed)"
);
assert!(!err.message().contains(EXTRACTION_TOOL));
let record = ExtractionMetadata::from_error(&err).unwrap();
assert_eq!(
(record.mode, record.passes),
(ExtractionMode::ForcedTool, 0)
);
assert_eq!(record.auto_fallback, Some(refusal_record()));
assert_eq!(provider.call_count(), 2);
}
#[tokio::test]
async fn request_tools_conflict_in_every_mode() {
let request = request().tools([ToolDefinition::new("other", json!({"type": "object"}))]);
for mode in [
None,
Some(ExtractionMode::Native),
Some(ExtractionMode::ForcedTool),
] {
for capabilities in [&NATIVE_AND_TOOLS[..], &[ChatCapability::ToolCalls][..]] {
let provider = MockProvider::with_text(r#"{"name":"Alice","age":31}"#)
.with_supported_chat_capabilities(capabilities.iter().copied());
let request = match mode {
Some(mode) => request.clone().with_option(mode),
None => request.clone(),
};
let failure = failure(provider.extract::<Person>(&request).await.unwrap_err());
assert!(
matches!(failure.error(), ExtractError::ToolConflict { .. }),
"{mode:?}: {}",
failure.error()
);
assert!(
failure
.error()
.to_string()
.contains("start a fresh request")
);
assert_eq!(failure.metadata().passes, 0);
assert_eq!(provider.call_count(), 0, "{mode:?}");
}
}
}
#[tokio::test]
async fn a_refusal_without_authored_text_keeps_no_message() {
let mut json = serde_json::to_value(refused(RequestField::ResponseFormat)).unwrap();
json["data"]["context"]
.as_object_mut()
.unwrap()
.remove("message_origin");
let unauthored: Error = serde_json::from_value(json).unwrap();
assert_eq!(unauthored.message_origin(), crate::MessageOrigin::Unknown);
let provider = MockProvider::build(|b| {
b.error(unauthored)
.tool_call("c", EXTRACTION_TOOL, alice_call())
})
.with_supported_chat_capabilities(NATIVE_AND_TOOLS);
let extracted = provider.extract::<Person>(&request()).await.unwrap();
assert_eq!(
extracted.metadata.auto_fallback,
Some(AutoFallback::new(RequestField::ResponseFormat, ""))
);
}
async fn drain<S, T>(stream: &mut ExtractionStream<S, T>) -> usize
where
S: Stream<Item = Result<StreamEvent>>,
{
let mut events = 0;
while let Some(event) = stream.next().await {
event.unwrap();
events += 1;
}
events
}
struct RawStream(std::sync::Mutex<Option<Vec<Result<StreamEvent>>>>);
impl crate::ProviderIdentity for RawStream {
fn provider_name(&self) -> &str {
"raw"
}
fn reads_namespace(&self, namespace: &str) -> bool {
namespace == "raw"
}
}
impl ChatProvider for RawStream {
type Stream = futures_util::stream::Iter<std::vec::IntoIter<Result<StreamEvent>>>;
async fn chat(&self, _: &ChatRequest) -> Result<ChatResponse> {
unreachable!("streams only")
}
async fn chat_stream(&self, _: &ChatRequest) -> Result<Self::Stream> {
let items = self.0.lock().unwrap().take().unwrap_or_default();
Ok(futures_util::stream::iter(items))
}
fn chat_capability(&self, _: &str, _: ChatCapability) -> CapabilitySupport {
CapabilitySupport::Supported
}
}
#[tokio::test]
async fn a_streamed_extraction_ends_after_the_first_error() {
let provider = RawStream(std::sync::Mutex::new(Some(vec![
Ok(StreamEvent::response_start(None, None)),
Err(Error::stream_decode("broken")),
Ok(StreamEvent::text_delta(0, "after")),
])));
let mut stream = provider.extract_stream::<Person>(&request()).await.unwrap();
assert!(matches!(
stream.next().await,
Some(Ok(StreamEvent::ResponseStart { .. }))
));
assert!(matches!(stream.next().await, Some(Err(_))));
assert!(stream.next().await.is_none());
assert!(stream.finish().is_err());
}
#[tokio::test]
async fn streamed_auto_falls_back_on_a_setup_refusal() {
let provider = MockStreamingProvider::build(|b| {
b.setup_error(refused(RequestField::ResponseFormat))
.tool_call("c", EXTRACTION_TOOL, alice_call())
})
.with_chat_capability(
ChatCapability::StructuredOutput,
CapabilitySupport::Supported,
);
let mut stream = provider.extract_stream::<Person>(&request()).await.unwrap();
assert_eq!(stream.mode(), ExtractionMode::ForcedTool);
drain(&mut stream).await;
let extracted = stream.finish().unwrap();
assert_eq!(extracted.value, alice());
assert_eq!(extracted.metadata.mode, ExtractionMode::ForcedTool);
assert_eq!(extracted.metadata.passes, 1);
assert_eq!(extracted.metadata.auto_fallback, Some(refusal_record()));
assert_eq!(provider.call_count(), 2);
assert!(provider.requests()[1].tool_choice.is_some());
}
#[tokio::test]
async fn streamed_auto_falls_back_on_a_refusal_as_the_first_item() {
let provider = MockStreamingProvider::new([
vec![MockStreamEvent::from(refused(RequestField::ResponseFormat))],
MockStreamEvent::tool_call_response("c", EXTRACTION_TOOL, alice_call()),
])
.with_chat_capability(
ChatCapability::StructuredOutput,
CapabilitySupport::Supported,
);
let mut stream = provider.extract_stream::<Person>(&request()).await.unwrap();
assert_eq!(stream.mode(), ExtractionMode::ForcedTool);
drain(&mut stream).await;
assert_eq!(
serde_json::from_str::<serde_json::Value>(stream.partial_output()).unwrap(),
alice_call()
);
let extracted = stream.finish().unwrap();
assert_eq!(extracted.value, alice());
assert_eq!(extracted.metadata.auto_fallback, Some(refusal_record()));
assert_eq!(provider.call_count(), 2);
}
#[tokio::test]
async fn streamed_auto_yields_the_first_item_it_read() {
let provider = MockStreamingProvider::with_text(r#"{"name":"Alice","age":31}"#)
.with_chat_capability(
ChatCapability::StructuredOutput,
CapabilitySupport::Supported,
);
let mut stream = provider.extract_stream::<Person>(&request()).await.unwrap();
let events = drain(&mut stream).await;
let expected = MockStreamEvent::text_response(r#"{"name":"Alice","age":31}"#).len();
assert_eq!(events, expected);
let extracted = stream.finish().unwrap();
assert_eq!(extracted.value, alice());
assert_eq!(extracted.metadata.mode, ExtractionMode::Native);
assert_eq!(extracted.metadata.auto_fallback, None);
let provider = MockStreamingProvider::with_stream([MockStreamEvent::from(refused(
RequestField::Tools,
))])
.with_chat_capability(
ChatCapability::StructuredOutput,
CapabilitySupport::Supported,
);
let mut stream = provider.extract_stream::<Person>(&request()).await.unwrap();
let first = stream.next().await.unwrap().unwrap_err();
assert_eq!(first.unsupported_field(), Some(RequestField::Tools));
assert!(stream.next().await.is_none());
let err = stream.finish().unwrap_err();
assert_eq!(err.unsupported_field(), Some(RequestField::Tools));
assert_eq!(provider.call_count(), 1);
let provider = MockStreamingProvider::with_stream(Vec::<MockStreamEvent>::new())
.with_chat_capability(
ChatCapability::StructuredOutput,
CapabilitySupport::Supported,
);
let mut stream = provider.extract_stream::<Person>(&request()).await.unwrap();
let first = stream.next().await.unwrap().unwrap_err();
assert!(matches!(
first,
Error::Stream {
kind: crate::StreamErrorKind::Incomplete,
..
}
));
assert!(stream.next().await.is_none());
}
#[tokio::test]
async fn streamed_failed_fallback_returns_the_forced_tool_error() {
let provider = MockStreamingProvider::build(|b| {
b.setup_error(refused(RequestField::ResponseFormat))
.setup_error(rate_limited())
})
.with_chat_capability(
ChatCapability::StructuredOutput,
CapabilitySupport::Supported,
);
let Err(err) = provider.extract_stream::<Person>(&request()).await else {
panic!("expected the forced-tool attempt's error");
};
assert!(matches!(err, Error::RateLimited { .. }), "{err:?}");
let record = ExtractionMetadata::from_error(&err).unwrap();
assert_eq!(record.mode, ExtractionMode::ForcedTool);
assert_eq!(record.auto_fallback, Some(refusal_record()));
let provider =
MockStreamingProvider::build(|b| b.setup_error(refused(RequestField::ResponseFormat)));
let request = request().with_option(ExtractionMode::Native);
let Err(err) = provider.extract_stream::<Person>(&request).await else {
panic!("expected the refusal");
};
assert_eq!(err.unsupported_field(), Some(RequestField::ResponseFormat));
assert_eq!(provider.call_count(), 1);
}
#[test]
fn the_auto_fallback_record_round_trips() {
let mut metadata = ExtractionMetadata::new(ExtractionMode::Native);
metadata.fall_back(refusal_record());
let value = serde_json::to_value(&metadata).unwrap();
assert_eq!(value["mode"], "forced_tool");
assert_eq!(value["auto_fallback"]["field"], "response_format");
assert_eq!(
serde_json::from_value::<ExtractionMetadata>(value).unwrap(),
metadata
);
let plain = serde_json::to_value(ExtractionMetadata::new(ExtractionMode::Native)).unwrap();
assert!(plain.get("auto_fallback").is_none(), "{plain}");
}
#[tokio::test]
async fn auto_skips_forced_tool_when_the_model_rejects_it() {
let provider =
MockProvider::with_tool_call("c", EXTRACTION_TOOL, json!({"name": "Alice", "age": 31}))
.with_chat_capability(ChatCapability::ToolCalls, CapabilitySupport::Supported)
.with_chat_capability(
ChatCapability::ForcedToolChoice,
CapabilitySupport::Unsupported,
)
.with_chat_capability(
ChatCapability::StructuredOutput,
CapabilitySupport::Unsupported,
);
let err = provider.extract::<Person>(&request()).await.unwrap_err();
let Error::Extract { failure, .. } = err else {
panic!("expected extract error");
};
assert!(
matches!(failure.error(), ExtractError::Unsupported { .. }),
"{failure:?}"
);
assert!(failure.response().is_none());
assert_eq!(failure.metadata().passes, 0);
assert_eq!(provider.call_count(), 0);
}
#[tokio::test]
async fn explicit_mode_option_and_parse_errors() {
let provider = MockProvider::with_text("not json");
let request = request().with_option(ExtractionMode::Native);
let err = provider.extract::<Person>(&request).await.unwrap_err();
let Error::Extract { failure, .. } = err else {
panic!("expected extract error");
};
assert_eq!(failure.error().raw_output(), Some("not json"));
}
#[tokio::test]
async fn unknown_capabilities_guess_native_output() {
let provider = MockProvider::with_text(r#"{"name":"Alice","age":31}"#);
let extracted = provider.extract::<Person>(&request()).await.unwrap();
assert_eq!(extracted.metadata.mode, ExtractionMode::Native);
assert!(provider.last_request().unwrap().response_format.is_some());
let provider = MockProvider::with_text(r#"{"name":"Alice","age":31}"#)
.with_chat_capability(ChatCapability::ToolCalls, CapabilitySupport::Supported);
let extracted = provider.extract::<Person>(&request()).await.unwrap();
assert_eq!(extracted.metadata.mode, ExtractionMode::Native);
}
#[tokio::test]
async fn without_native_output_unknown_tools_are_guessed() {
let provider =
MockProvider::with_tool_call("c", EXTRACTION_TOOL, json!({"name": "Alice", "age": 31}))
.with_chat_capability(
ChatCapability::StructuredOutput,
CapabilitySupport::Unsupported,
);
let extracted = provider.extract::<Person>(&request()).await.unwrap();
assert_eq!(extracted.metadata.mode, ExtractionMode::ForcedTool);
let provider = MockProvider::empty()
.with_chat_capability(
ChatCapability::StructuredOutput,
CapabilitySupport::Unsupported,
)
.with_chat_capability(ChatCapability::ToolCalls, CapabilitySupport::Unsupported);
let err = provider.extract::<Person>(&request()).await.unwrap_err();
assert!(
matches!(err, Error::Extract { ref failure, .. } if matches!(failure.error(), ExtractError::Unsupported { .. })),
"{err:?}"
);
assert!(err.to_string().contains("capability resolver"), "{err}");
assert_eq!(provider.call_count(), 0);
}
#[tokio::test]
async fn forced_tool_conflicts_with_request_tools() {
let provider = MockProvider::empty();
let request = request()
.with_option(ExtractionMode::ForcedTool)
.tools([ToolDefinition::new("other", json!({"type": "object"}))]);
assert!(provider.extract::<Person>(&request).await.is_err());
}
#[tokio::test]
async fn extraction_through_wrappers() {
let provider = crate::wrappers::RetryingProvider::new(
MockProvider::with_response(
ChatResponseBuilder::new()
.text(r#"{"name":"Bob","age":4}"#)
.build(),
),
crate::wrappers::RetryPolicy::default(),
);
let request = ChatRequest::new("m")
.user("Bob is 4")
.with_option(ExtractionMode::Native);
let extracted: Extracted<Person> =
Extractor::new().extract(&provider, &request).await.unwrap();
assert_eq!(extracted.value.name, "Bob");
}
#[test]
fn repair_candidates() {
assert_eq!(
repair_candidate("```json\n{\"a\": 1}\n```"),
Some("{\"a\": 1}")
);
assert_eq!(repair_candidate("```\n[1]\n```\n"), Some("[1]"));
assert_eq!(
repair_candidate("Sure! Here it is: {\"a\": 1}. Anything else?"),
Some("{\"a\": 1}")
);
assert_eq!(repair_candidate("{\"a\": 1}"), None);
assert_eq!(repair_candidate("no json here"), None);
assert_eq!(repair_candidate("} backwards {"), None);
assert_eq!(
repair_candidate("```json\n{\"a\": 1}\n```\nHope this helps!"),
Some("{\"a\": 1}")
);
assert_eq!(repair_candidate("```{\"a\": 1}```"), Some("{\"a\": 1}"));
}
#[tokio::test]
async fn repair_is_opt_in_and_reported() {
let fenced = "```json\n{\"name\":\"Alice\",\"age\":31}\n```";
let request = request().with_option(ExtractionMode::Native);
let strict = MockProvider::with_text(fenced);
assert!(strict.extract::<Person>(&request).await.is_err());
let provider = MockProvider::with_text(fenced);
let extracted = Extractor::new()
.repair(true)
.extract::<_, Person>(&provider, &request)
.await
.unwrap();
assert_eq!(extracted.value, alice());
assert!(extracted.metadata.repaired);
assert_eq!(provider.call_count(), 1);
}
#[tokio::test]
async fn native_retries_send_the_failure_back() {
let provider = MockProvider::build(|b| {
b.text(r#"{"name":"Alice"}"#)
.text(r#"{"name":"Alice","age":31}"#)
});
let request = request().with_option(ExtractionMode::Native);
let extracted = Extractor::new()
.retries(2)
.extract::<_, Person>(&provider, &request)
.await
.unwrap();
assert_eq!(extracted.value, alice());
assert_eq!(extracted.metadata.passes, 2);
let retry = provider.last_request().unwrap();
assert_eq!(retry.messages.len(), 3);
assert!(retry.messages[1].as_assistant().is_some());
let UserContent::Text(feedback) = &retry.messages[2].as_user().unwrap().content else {
panic!("expected text feedback");
};
assert!(feedback.contains("missing field `age`"), "{feedback}");
}
#[tokio::test]
async fn native_retries_answer_tool_calls_before_asking_again() {
let provider = MockProvider::build(|b| {
b.tool_call("c1", "lookup", json!({"q": "Alice"}))
.text(r#"{"name":"Alice","age":31}"#)
});
let request = request().with_option(ExtractionMode::Native);
let extracted = Extractor::new()
.retries(1)
.extract::<_, Person>(&provider, &request)
.await
.unwrap();
assert_eq!(extracted.value, alice());
let retry = provider.last_request().unwrap();
assert_eq!(retry.messages.len(), 4);
let result = retry.messages[2].as_tool().unwrap();
assert_eq!(result.tool_call_id, "c1");
assert!(!result.is_error);
let feedback = result.content.to_text().unwrap_or_default();
assert!(feedback.contains("was not run"), "{feedback}");
assert!(retry.messages[3].as_user().is_some());
}
#[tokio::test]
async fn forced_tool_retries_answer_the_failed_call() {
let provider = MockProvider::build(|b| {
b.tool_call("c1", EXTRACTION_TOOL, json!({"name": "Alice"}))
.tool_call("c2", EXTRACTION_TOOL, json!({"name": "Alice", "age": 31}))
});
let request = request().with_option(ExtractionMode::ForcedTool);
let extracted = Extractor::new()
.retries(1)
.extract::<_, Person>(&provider, &request)
.await
.unwrap();
assert_eq!(extracted.value, alice());
assert_eq!(extracted.metadata.passes, 2);
let retry = provider.last_request().unwrap();
let result = retry.messages[2].as_tool().unwrap();
assert_eq!(result.tool_call_id, "c1");
assert!(!result.is_error);
let feedback = result.content.to_text().unwrap_or_default();
assert!(feedback.contains("could not be used"), "{feedback}");
assert!(feedback.contains("missing field `age`"), "{feedback}");
assert_eq!(
retry.tool_choice,
Some(ToolChoice::Specific {
name: EXTRACTION_TOOL.into()
})
);
}
#[tokio::test]
async fn a_strict_retry_sends_on_an_endpoint_without_a_tool_error_flag() {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use crate::mock::FunctionProvider;
let calls = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&calls);
let provider = FunctionProvider::new(move |request: &ChatRequest| {
if counter.fetch_add(1, Ordering::SeqCst) == 0 {
return Ok(ChatResponseBuilder::new()
.tool_call("c1", EXTRACTION_TOOL, json!({"name": "Alice"}))
.build());
}
if request
.messages
.iter()
.any(|m| m.as_tool().is_some_and(|t| t.is_error))
{
return Err(Error::unsupported("no tool error flag"));
}
Ok(ChatResponseBuilder::new()
.tool_call("c2", EXTRACTION_TOOL, alice_call())
.build())
})
.with_chat_capability(ChatCapability::ToolCalls, CapabilitySupport::Supported)
.with_chat_capability(
ChatCapability::ForcedToolChoice,
CapabilitySupport::Supported,
);
let request = request()
.with_option(ExtractionMode::ForcedTool)
.unsupported_policy(crate::UnsupportedPolicy::Error);
let extracted = Extractor::new()
.retries(1)
.extract::<_, Person>(&provider, &request)
.await
.unwrap();
assert_eq!(extracted.value, alice());
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn retries_are_bounded_and_skip_provider_errors() {
let provider = MockProvider::build(|b| b.text("nope").text("still nope"));
let request = request().with_option(ExtractionMode::Native);
let err = Extractor::new()
.retries(1)
.extract::<_, Person>(&provider, &request)
.await
.unwrap_err();
assert!(matches!(err, Error::Extract { .. }));
assert_eq!(provider.call_count(), 2);
let failing = MockProvider::build(|b| b.error(Error::auth("bad key")));
let err = Extractor::new()
.retries(3)
.extract::<_, Person>(&failing, &request)
.await
.unwrap_err();
assert!(matches!(err, Error::Auth { .. }));
assert_eq!(failing.call_count(), 1);
}
#[derive(Debug, PartialEq, Deserialize, JsonSchema)]
enum Currency {
Usd,
Eur,
}
#[derive(Debug, PartialEq, Deserialize, JsonSchema)]
struct Price {
currency: Currency,
}
fn retry_feedback(provider: &MockProvider) -> String {
let retry = provider.last_request().unwrap();
let UserContent::Text(feedback) =
&retry.messages.last().unwrap().as_user().unwrap().content
else {
panic!("expected text feedback");
};
feedback.clone()
}
#[tokio::test]
async fn repair_reports_the_candidate_error_when_it_is_json() {
let raw = "Here:\n{\"currency\":\"Gbp\"}";
let provider = MockProvider::build(|b| b.text(raw).text(raw));
let request = request().with_option(ExtractionMode::Native);
let err = Extractor::new()
.repair(true)
.retries(1)
.extract::<_, Price>(&provider, &request)
.await
.unwrap_err();
let failure = failure(err);
let ExtractError::Parse {
message,
raw: reported,
..
} = failure.error()
else {
panic!("expected a parse failure, got {failure:?}");
};
assert!(message.contains("unknown variant `Gbp`"), "{message}");
assert_eq!(reported, raw);
let feedback = retry_feedback(&provider);
assert!(
feedback.contains("it did not match the schema at `currency`: unknown variant `Gbp`"),
"{feedback}"
);
}
#[tokio::test]
async fn repair_keeps_the_raw_error_when_the_candidate_is_not_json() {
let raw = "Here: {\"currency\": Usd}";
let provider = MockProvider::with_text(raw);
let request = request().with_option(ExtractionMode::Native);
let err = Extractor::new()
.repair(true)
.extract::<_, Price>(&provider, &request)
.await
.unwrap_err();
let failure = failure(err);
let ExtractError::Parse {
message,
raw: reported,
..
} = failure.error()
else {
panic!("expected a parse failure, got {failure:?}");
};
let raw_error = serde_json::from_str::<serde_json::Value>(raw)
.unwrap_err()
.to_string();
assert_eq!(message, &raw_error);
assert_eq!(reported, raw);
}
#[tokio::test]
async fn retry_feedback_tells_json_errors_from_schema_errors() {
let request = request().with_option(ExtractionMode::Native);
let trailing_comma = MockProvider::build(|b| {
b.text(r#"{"name":"Alice","age":31,}"#)
.text(r#"{"name":"Alice","age":31}"#)
});
Extractor::new()
.retries(1)
.extract::<_, Person>(&trailing_comma, &request)
.await
.unwrap();
let feedback = retry_feedback(&trailing_comma);
assert!(feedback.contains("it was not valid JSON: "), "{feedback}");
assert!(!feedback.contains("schema:"), "{feedback}");
let wrong_type = MockProvider::build(|b| {
b.text(r#"{"name":"Alice","age":"31"}"#)
.text(r#"{"name":"Alice","age":31}"#)
});
Extractor::new()
.retries(1)
.extract::<_, Person>(&wrong_type, &request)
.await
.unwrap();
let feedback = retry_feedback(&wrong_type);
assert!(
feedback.contains("it did not match the schema at `age`: invalid type"),
"{feedback}"
);
let cut_off = MockProvider::build(|b| {
b.text(r#"{"name":"Alice""#)
.text(r#"{"name":"Alice","age":31}"#)
});
Extractor::new()
.retries(1)
.extract::<_, Person>(&cut_off, &request)
.await
.unwrap();
assert!(retry_feedback(&cut_off).contains("it was not valid JSON: "));
}
#[tokio::test]
async fn forced_tool_retry_feedback_tells_json_errors_from_schema_errors() {
let provider = MockProvider::build(|b| {
b.response(ChatResponse::new(vec![crate::ContentBlock::ToolCall(
crate::ToolCall::new("c1", EXTRACTION_TOOL, r#"{"name":"Alice","#),
)]))
.tool_call("c2", EXTRACTION_TOOL, json!({"name": "Alice", "age": 31}))
});
let request = request().with_option(ExtractionMode::ForcedTool);
Extractor::new()
.retries(1)
.extract::<_, Person>(&provider, &request)
.await
.unwrap();
let retry = provider.last_request().unwrap();
let result = retry.messages[2].as_tool().unwrap();
let feedback = format!("{:?}", result.content);
assert!(feedback.contains("it was not valid JSON: "), "{feedback}");
}
#[derive(Debug, PartialEq, Deserialize, JsonSchema)]
struct Order {
items: Vec<LineItem>,
}
#[derive(Debug, PartialEq, Deserialize, JsonSchema)]
struct LineItem {
name: String,
price: u32,
#[serde(default)]
tags: Vec<String>,
}
#[tokio::test]
async fn parse_failures_name_the_field_path() {
let raw = r#"{"items":[{"name":"a","price":1,"tags":null},{"name":"b","price":"2","tags":null}]}"#;
let fixed = r#"{"items":[{"name":"a","price":1},{"name":"b","price":2}]}"#;
let native = MockProvider::build(|b| b.text(raw).text(fixed));
let request = request().with_option(ExtractionMode::Native);
Extractor::new()
.retries(1)
.extract::<_, Order>(&native, &request)
.await
.unwrap();
let feedback = retry_feedback(&native);
assert!(
feedback.contains(
"it did not match the schema at `items[1].price`: invalid type: string \"2\""
),
"{feedback}"
);
let failure = failure(
MockProvider::with_text(raw)
.extract::<Order>(&request)
.await
.unwrap_err(),
);
let ExtractError::Parse { path, message, .. } = failure.error() else {
panic!("expected a parse failure, got {failure:?}");
};
assert_eq!(path.as_deref(), Some("items[1].price"));
assert_eq!(message, "invalid type: string \"2\", expected u32");
let shown = failure.error().to_string();
assert!(
shown.ends_with(
"using mode Native: at `items[1].price`: invalid type: string \"2\", expected u32"
),
"{shown}"
);
let missing = parse_failure_of::<Order>(r#"{"items":[{"name":"a"}]}"#).await;
assert_eq!(missing.0.as_deref(), Some("items[0]"));
assert!(missing.1.contains("missing field `price`"), "{}", missing.1);
assert_eq!(parse_failure_of::<Order>(r#"{"items":["#).await.0, None);
assert_eq!(parse_failure_of::<Order>(r#""text""#).await.0, None);
}
#[tokio::test]
async fn forced_tool_feedback_names_the_field_path() {
let provider = MockProvider::build(|b| {
b.tool_call("c1", EXTRACTION_TOOL, json!({"name": "Alice", "age": "31"}))
.tool_call("c2", EXTRACTION_TOOL, json!({"name": "Alice", "age": 31}))
});
let request = request().with_option(ExtractionMode::ForcedTool);
Extractor::new()
.retries(1)
.extract::<_, Person>(&provider, &request)
.await
.unwrap();
let retry = provider.last_request().unwrap();
let result = retry.messages[2].as_tool().unwrap();
let feedback = format!("{:?}", result.content);
assert!(
feedback.contains("it did not match the schema at `age`: invalid type"),
"{feedback}"
);
}
#[tokio::test]
async fn repaired_output_reports_the_candidate_path() {
let raw = "Here you go:\n```json\n{\"items\":[{\"name\":\"a\",\"price\":-1}]}\n```";
let (path, message) = parse_failure_with::<Order>(raw, Extractor::new().repair(true)).await;
assert_eq!(path.as_deref(), Some("items[0].price"));
assert!(message.contains("invalid value: integer `-1`"), "{message}");
}
async fn parse_failure_of<T>(raw: &str) -> (Option<String>, String)
where
T: DeserializeOwned + JsonSchema + fmt::Debug + Send + 'static,
{
parse_failure_with::<T>(raw, Extractor::new()).await
}
async fn parse_failure_with<T>(raw: &str, extractor: Extractor) -> (Option<String>, String)
where
T: DeserializeOwned + JsonSchema + fmt::Debug + Send + 'static,
{
let provider = MockProvider::with_text(raw);
let request = request().with_option(ExtractionMode::Native);
let failure = failure(
extractor
.extract::<_, T>(&provider, &request)
.await
.unwrap_err(),
);
let ExtractError::Parse { path, message, .. } = failure.error() else {
panic!("expected a parse failure, got {failure:?}");
};
(path.clone(), message.clone())
}
#[tokio::test]
async fn streamed_native_extraction() {
let provider = MockStreamingProvider::with_text(r#"{"name":"Alice","age":31}"#);
let request = request().with_option(ExtractionMode::Native);
let mut stream = provider.extract_stream::<Person>(&request).await.unwrap();
let mut events = 0;
while let Some(event) = stream.next().await {
event.unwrap();
events += 1;
}
assert!(events > 0);
assert_eq!(stream.partial_output(), r#"{"name":"Alice","age":31}"#);
let extracted = stream.finish().unwrap();
assert_eq!(extracted.value, alice());
assert_eq!(extracted.metadata.mode, ExtractionMode::Native);
assert!(provider.requests()[0].response_format.is_some());
}
#[derive(Debug, PartialEq, Deserialize, JsonSchema)]
struct Profile {
name: String,
#[serde(default)]
tags: Vec<String>,
nickname: Option<String>,
}
const STRICT_NULLS: &str = r#"{"name":"Alice","tags":null,"nickname":null}"#;
fn alice_profile() -> Profile {
Profile {
name: "Alice".into(),
tags: Vec::new(),
nickname: None,
}
}
#[tokio::test]
async fn strict_nulls_are_normalized_in_both_modes() {
let native = MockProvider::with_text(STRICT_NULLS);
let native_request = request().with_option(ExtractionMode::Native);
let extracted = native.extract::<Profile>(&native_request).await.unwrap();
assert_eq!(extracted.value, alice_profile());
assert!(!extracted.metadata.repaired);
let forced = MockProvider::with_tool_call(
"c",
EXTRACTION_TOOL,
serde_json::from_str::<serde_json::Value>(STRICT_NULLS).unwrap(),
);
let forced_request = request().with_option(ExtractionMode::ForcedTool);
let extracted = forced.extract::<Profile>(&forced_request).await.unwrap();
assert_eq!(extracted.value, alice_profile());
let fenced = MockProvider::with_text(format!("```json\n{STRICT_NULLS}\n```"));
let extracted = Extractor::new()
.repair(true)
.extract::<_, Profile>(&fenced, &native_request)
.await
.unwrap();
assert_eq!(extracted.value, alice_profile());
assert!(extracted.metadata.repaired);
}
#[tokio::test]
async fn strict_nulls_are_normalized_in_streamed_extraction() {
let native = MockStreamingProvider::with_text(STRICT_NULLS);
let native_request = request().with_option(ExtractionMode::Native);
let mut stream = native
.extract_stream::<Profile>(&native_request)
.await
.unwrap();
while stream.next().await.is_some() {}
assert_eq!(stream.finish().unwrap().value, alice_profile());
let forced = MockStreamingProvider::with_stream(MockStreamEvent::tool_call_response(
"c",
EXTRACTION_TOOL,
serde_json::from_str::<serde_json::Value>(STRICT_NULLS).unwrap(),
));
let forced_request = request().with_option(ExtractionMode::ForcedTool);
let mut stream = forced
.extract_stream::<Profile>(&forced_request)
.await
.unwrap();
while stream.next().await.is_some() {}
assert_eq!(stream.finish().unwrap().value, alice_profile());
}
#[tokio::test]
async fn parse_failures_keep_the_raw_output_the_model_sent() {
let raw = r#"{"name":null,"tags":null}"#;
let provider = MockProvider::with_text(raw);
let request = request().with_option(ExtractionMode::Native);
let err = provider.extract::<Profile>(&request).await.unwrap_err();
let Error::Extract { failure, .. } = err else {
panic!("expected extract error");
};
assert_eq!(failure.error().raw_output(), Some(raw));
let ExtractError::Parse { message, .. } = failure.error() else {
panic!("expected a parse error");
};
assert!(message.starts_with("invalid type: null"), "{message}");
}
#[tokio::test]
async fn streamed_forced_tool_extraction_tracks_the_tool_arguments() {
let provider = MockStreamingProvider::with_stream(MockStreamEvent::tool_call_response(
"c",
EXTRACTION_TOOL,
json!({"name": "Alice", "age": 31}),
));
let request = request().with_option(ExtractionMode::ForcedTool);
let mut stream = provider.extract_stream::<Person>(&request).await.unwrap();
while stream.next().await.is_some() {}
assert_eq!(
serde_json::from_str::<serde_json::Value>(stream.partial_output()).unwrap(),
json!({"name": "Alice", "age": 31})
);
assert_eq!(stream.finish().unwrap().value, alice());
}
#[tokio::test]
async fn streamed_extraction_reports_stream_failures() {
let provider = MockStreamingProvider::with_stream([
MockStreamEvent::from(StreamEvent::ResponseStart {
id: None,
model: None,
}),
MockStreamEvent::from(StreamEvent::TextDelta {
index: 0,
text: r#"{"name":"#.into(),
}),
MockStreamEvent::from(Error::overloaded("busy")),
]);
let request = request().with_option(ExtractionMode::Native);
let mut stream = provider.extract_stream::<Person>(&request).await.unwrap();
while stream.next().await.is_some() {}
assert_eq!(stream.partial_output(), r#"{"name":"#);
assert!(matches!(stream.finish(), Err(Error::Overloaded { .. })));
}
fn failure(err: Error) -> ExtractFailure {
match err {
Error::Extract { failure, .. } => *failure,
other => panic!("expected an extraction failure, got {other:?}"),
}
}
fn text_with_usage(text: &str, input: u64, output: u64) -> ChatResponse {
ChatResponseBuilder::new()
.text(text)
.usage(input, output)
.build()
}
#[tokio::test]
async fn metadata_sums_usage_over_every_pass() {
let provider = MockProvider::build(|b| {
b.response(text_with_usage(r#"{"name":"Alice"}"#, 10, 4))
.response(text_with_usage(r#"{"name":"Alice","age":31}"#, 20, 6))
});
let request = request().with_option(ExtractionMode::Native);
let extracted = Extractor::new()
.retries(1)
.extract::<_, Person>(&provider, &request)
.await
.unwrap();
assert_eq!(extracted.metadata.passes, 2);
let usage = extracted.metadata.usage.unwrap();
assert_eq!(
(usage.input_tokens, usage.output_tokens),
(Some(30), Some(10))
);
assert_eq!(extracted.response.usage.unwrap().input_tokens, Some(20));
}
#[tokio::test]
async fn failures_carry_the_last_response_and_the_pass_record() {
let provider = MockProvider::build(|b| {
b.response(text_with_usage("nope", 10, 2))
.response(text_with_usage("still nope", 11, 3))
});
let request = request().with_option(ExtractionMode::Native);
let err = Extractor::new()
.retries(1)
.extract::<_, Person>(&provider, &request)
.await
.unwrap_err();
assert!(!err.is_retryable());
let failure = failure(err);
assert!(matches!(failure.error(), ExtractError::Parse { .. }));
assert_eq!(
failure.response().and_then(ChatResponse::text).as_deref(),
Some("still nope")
);
assert_eq!(failure.metadata().mode, ExtractionMode::Native);
assert_eq!(failure.metadata().passes, 2);
assert_eq!(
failure.metadata().usage.as_ref().unwrap().input_tokens,
Some(21)
);
assert_eq!(
failure.into_response().unwrap().text().as_deref(),
Some("still nope")
);
}
#[tokio::test]
async fn truncated_output_fails_before_parsing_and_is_not_retried() {
let provider = MockProvider::build(|b| {
b.response(
ChatResponseBuilder::new()
.text(r#"{"name":"Alice","age":31}"#)
.finish_reason(FinishReason::Length)
.usage(10, 64)
.build(),
)
.text(r#"{"name":"Alice","age":31}"#)
});
let request = request().with_option(ExtractionMode::Native);
let err = Extractor::new()
.repair(true)
.retries(2)
.extract::<_, Person>(&provider, &request)
.await
.unwrap_err();
assert_eq!(provider.call_count(), 1);
let failure = failure(err);
assert!(
matches!(
failure.error(),
ExtractError::Truncated {
mode: ExtractionMode::Native,
..
}
),
"{failure:?}"
);
assert!(failure.to_string().contains("output token limit"));
assert_eq!(failure.metadata().passes, 1);
assert_eq!(
failure.metadata().usage.as_ref().unwrap().output_tokens,
Some(64)
);
assert_eq!(
failure.response().unwrap().finish_reason,
Some(FinishReason::Length)
);
}
#[tokio::test]
async fn a_full_context_window_is_truncation_with_its_own_remedy() {
let provider = MockProvider::build(|b| {
b.response(
ChatResponseBuilder::new()
.text(r#"{"name":"Ali"#)
.finish_reason(FinishReason::ContextWindow)
.usage(190_000, 64)
.build(),
)
.text(r#"{"name":"Alice","age":31}"#)
});
let request = request().with_option(ExtractionMode::Native);
let err = Extractor::new()
.retries(2)
.extract::<_, Person>(&provider, &request)
.await
.unwrap_err();
assert!(!err.is_retryable());
assert_eq!(provider.call_count(), 1);
let failure = failure(err);
assert!(
matches!(
failure.error(),
ExtractError::Truncated {
finish_reason: FinishReason::ContextWindow,
..
}
),
"{failure:?}"
);
let message = failure.to_string();
assert!(message.contains("context window"), "{message}");
assert!(message.contains("shorten the input"), "{message}");
assert!(!message.contains("raise max_output_tokens"), "{message}");
assert_eq!(failure.metadata().passes, 1);
}
#[tokio::test]
async fn truncation_says_how_much_went_to_reasoning_when_reported() {
let truncated = |usage: Usage| {
ChatResponseBuilder::new()
.finish_reason(FinishReason::Length)
.usage_value(usage)
.build()
};
let request = request().with_option(ExtractionMode::Native);
let provider = MockProvider::with_response(truncated(
Usage::new().output_tokens(400).reasoning_tokens(400),
));
let reasoned = failure(provider.extract::<Person>(&request).await.unwrap_err());
assert!(matches!(
reasoned.error(),
ExtractError::Truncated {
output_tokens: Some(400),
reasoning_tokens: Some(400),
..
}
));
assert!(
reasoned.error().to_string().ends_with(
"; 400 of 400 output tokens went to reasoning: raise max_output_tokens or lower the reasoning effort"
),
"{}",
reasoned.error()
);
let provider = MockProvider::with_response(truncated(Usage::new().output_tokens(400)));
let unreported = failure(provider.extract::<Person>(&request).await.unwrap_err());
assert!(!unreported.error().to_string().contains("reasoning"));
}
#[tokio::test]
async fn truncated_forced_tool_arguments_are_reported() {
let provider = MockProvider::with_response(
ChatResponseBuilder::new()
.tool_call("c", EXTRACTION_TOOL, json!({"name": "Alice"}))
.finish_reason(FinishReason::Length)
.build(),
);
let request = request().with_option(ExtractionMode::ForcedTool);
let failure = failure(provider.extract::<Person>(&request).await.unwrap_err());
assert!(matches!(
failure.error(),
ExtractError::Truncated {
mode: ExtractionMode::ForcedTool,
..
}
));
}
fn declined(reason: FinishReason) -> ChatResponseBuilder {
ChatResponseBuilder::new()
.text("I can't help with that.")
.finish_reason(reason)
.usage(10, 6)
}
#[tokio::test]
async fn refusals_fail_before_parsing_and_are_not_retried() {
for reason in [FinishReason::Refusal, FinishReason::ContentFilter] {
let provider = MockProvider::build(|b| {
b.response(declined(reason.clone()).build())
.text(r#"{"name":"Alice","age":31}"#)
});
let request = request().with_option(ExtractionMode::Native);
let err = Extractor::new()
.repair(true)
.retries(2)
.extract::<_, Person>(&provider, &request)
.await
.unwrap_err();
assert_eq!(provider.call_count(), 1, "{reason}");
assert!(!err.is_retryable());
let failure = failure(err);
let ExtractError::Refused {
mode,
finish_reason,
text,
source,
..
} = failure.error()
else {
panic!("a refusal expected: {failure:?}");
};
assert_eq!(*mode, ExtractionMode::Native);
assert_eq!(finish_reason.as_ref(), Some(&reason));
assert_eq!((text, source), (&None, &RefusalSource::None));
assert_eq!(
failure.to_string(),
format!(
"mock: the model refused to produce the structured output (finish reason `{reason}`)"
)
);
assert_eq!(failure.metadata().passes, 1);
assert_eq!(
failure.metadata().usage.as_ref().unwrap().output_tokens,
Some(6)
);
}
}
#[tokio::test]
async fn a_providers_explanation_is_the_refusal_text() {
let mut provider_data = crate::ProviderData::new();
provider_data.insert_raw(
"acme",
DECLINE_EXPLANATION_KEY,
json!("The request touches a restricted topic."),
);
let response = ChatResponseBuilder::new()
.finish_reason(FinishReason::Refusal)
.build()
.provider_data(provider_data);
let provider = MockProvider::with_response(response);
let request = request().with_option(ExtractionMode::ForcedTool);
let failure = failure(provider.extract::<Person>(&request).await.unwrap_err());
assert!(
matches!(
failure.error(),
ExtractError::Refused {
mode: ExtractionMode::ForcedTool,
text: Some(text),
source: RefusalSource::Provider,
..
} if text == "The request touches a restricted topic."
),
"{failure:?}"
);
assert_eq!(
failure.to_string(),
"mock: the model refused: The request touches a restricted topic."
);
let log = Error::from(failure).as_log().to_string();
assert!(!log.contains("restricted"), "{log}");
assert!(log.contains("finish reason `refusal`"), "{log}");
}
#[tokio::test]
async fn a_streamed_refusal_fails_at_finish() {
let provider =
MockStreamingProvider::from_responses([declined(FinishReason::ContentFilter).build()]);
let request = request().with_option(ExtractionMode::Native);
let mut stream = provider.extract_stream::<Person>(&request).await.unwrap();
while stream.next().await.is_some() {}
let failure = failure(stream.finish().unwrap_err());
assert!(
matches!(
failure.error(),
ExtractError::Refused {
finish_reason: Some(FinishReason::ContentFilter),
..
}
),
"{failure:?}"
);
}
#[tokio::test]
async fn odd_finishes_are_named_in_the_parse_message() {
let provider = MockProvider::with_response(
ChatResponseBuilder::new()
.text("{\"name\":")
.finish_reason(FinishReason::Other("malformed_function_call".into()))
.build(),
);
let request = request().with_option(ExtractionMode::Native);
let odd = failure(provider.extract::<Person>(&request).await.unwrap_err());
let ExtractError::Parse { message, .. } = odd.error() else {
panic!("a parse failure expected: {odd:?}");
};
assert!(
message.ends_with("(finish reason `malformed_function_call`)"),
"{message}"
);
let provider = MockProvider::with_text("{\"name\":");
let normal = failure(provider.extract::<Person>(&request).await.unwrap_err());
let ExtractError::Parse { message, .. } = normal.error() else {
panic!("a parse failure expected: {normal:?}");
};
assert!(!message.contains("finish reason"), "{message}");
}
#[tokio::test]
async fn blank_output_is_missing_structured_text() {
for text in ["", " \n\t "] {
let provider = MockProvider::with_text(text);
let request = request().with_option(ExtractionMode::Native);
let failure = failure(provider.extract::<Person>(&request).await.unwrap_err());
assert!(
matches!(failure.error(), ExtractError::MissingStructuredText { .. }),
"{text:?}: {failure:?}"
);
}
}
#[test]
fn refusal_errors_round_trip() {
let error = ExtractError::Refused {
mode: ExtractionMode::Native,
provider: "acme".into(),
finish_reason: Some(FinishReason::ContentFilter),
text: Some("filtered".into()),
source: RefusalSource::Provider,
};
let json = serde_json::to_value(&error).unwrap();
assert_eq!(json["type"], "refused");
assert_eq!(json["source"], "provider");
let back: ExtractError = serde_json::from_value(json).unwrap();
assert_eq!(back.to_string(), "acme: the model refused: filtered");
}
fn rate_limited() -> Error {
Error::rate_limited("HTTP 429")
.authored()
.with_provider("mock")
.with_status(429)
.with_retry_after(std::time::Duration::from_secs(7))
}
#[tokio::test]
async fn provider_errors_on_a_retry_carry_the_earlier_passes() {
let provider = MockProvider::build(|b| {
b.response(text_with_usage(r#"{"name":"Alice"}"#, 500, 50))
.error(rate_limited())
});
let request = request().with_option(ExtractionMode::Native);
let err = Extractor::new()
.retries(1)
.extract::<_, Person>(&provider, &request)
.await
.unwrap_err();
assert!(matches!(err, Error::RateLimited { .. }), "{err:?}");
assert_eq!(err.kind_name(), "rate_limited");
assert!(err.is_retryable());
assert_eq!(err.retry_after(), Some(std::time::Duration::from_secs(7)));
let metadata = ExtractionMetadata::from_error(&err).unwrap();
assert_eq!(metadata.mode, ExtractionMode::Native);
assert_eq!(metadata.passes, 1);
let usage = metadata.usage.unwrap();
assert_eq!(
(usage.input_tokens, usage.output_tokens),
(Some(500), Some(50))
);
}
#[tokio::test]
async fn provider_errors_on_the_first_pass_carry_an_empty_record() {
let provider = MockProvider::build(|b| b.error(rate_limited()));
let request = request().with_option(ExtractionMode::ForcedTool);
let err = provider.extract::<Person>(&request).await.unwrap_err();
assert!(matches!(err, Error::RateLimited { .. }), "{err:?}");
assert_eq!(
ExtractionMetadata::from_error(&err),
Some(ExtractionMetadata::new(ExtractionMode::ForcedTool))
);
}
#[tokio::test]
async fn stream_setup_errors_carry_an_empty_record() {
let provider = MockStreamingProvider::build(|b| b.setup_error(rate_limited()));
let request = request().with_option(ExtractionMode::Native);
let Err(err) = provider.extract_stream::<Person>(&request).await else {
panic!("expected a setup error");
};
assert!(matches!(err, Error::RateLimited { .. }), "{err:?}");
assert_eq!(err.retry_after(), Some(std::time::Duration::from_secs(7)));
assert_eq!(
ExtractionMetadata::from_error(&err),
Some(ExtractionMetadata::new(ExtractionMode::Native))
);
}
#[tokio::test]
async fn stream_errors_returned_by_finish_carry_the_record() {
let provider = MockStreamingProvider::with_stream([
MockStreamEvent::from(StreamEvent::ResponseStart {
id: None,
model: None,
}),
MockStreamEvent::from(Error::overloaded("busy")),
]);
let request = request().with_option(ExtractionMode::Native);
let mut stream = provider.extract_stream::<Person>(&request).await.unwrap();
while stream.next().await.is_some() {}
let err = stream.finish().unwrap_err();
assert!(matches!(err, Error::Overloaded { .. }), "{err:?}");
assert!(err.is_retryable());
assert_eq!(
ExtractionMetadata::from_error(&err),
Some(ExtractionMetadata::new(ExtractionMode::Native))
);
}
fn failing_mid_stream() -> Vec<MockStreamEvent> {
vec![
MockStreamEvent::from(StreamEvent::response_start(None, None)),
MockStreamEvent::from(StreamEvent::warning(strict_dropped(
RequestField::ResponseFormat,
))),
MockStreamEvent::from(StreamEvent::text_delta(0, r#"{"name":"#)),
MockStreamEvent::from(
crate::chat::stream::MetadataBuilder::new()
.with_usage(Usage::new().input_tokens(900).output_tokens(4))
.build(),
),
MockStreamEvent::from(Error::overloaded("busy")),
]
}
#[tokio::test]
async fn a_stream_failing_mid_way_keeps_its_usage_and_degradations() {
let provider = MockStreamingProvider::with_stream(failing_mid_stream());
let request = request().with_option(ExtractionMode::Native);
let mut stream = provider.extract_stream::<Person>(&request).await.unwrap();
while stream.next().await.is_some() {}
let err = stream.finish().unwrap_err();
assert!(matches!(err, Error::Overloaded { .. }), "{err:?}");
let record = ExtractionMetadata::from_error(&err).unwrap();
assert_eq!(record.passes, 0);
assert_eq!(
record.usage.as_ref().and_then(|u| u.input_tokens),
Some(900)
);
assert_eq!(record.usage.as_ref().and_then(|u| u.output_tokens), Some(4));
assert_eq!(
record.degradations,
[strict_dropped(RequestField::ResponseFormat)]
);
assert_eq!(record.enforcement, SchemaEnforcement::NotEnforced);
}
#[tokio::test]
async fn a_stream_ending_early_keeps_its_usage() {
let mut events = failing_mid_stream();
events.pop();
let provider = MockStreamingProvider::with_stream(events);
let request = request().with_option(ExtractionMode::Native);
let mut stream = provider.extract_stream::<Person>(&request).await.unwrap();
while stream.next().await.is_some() {}
let err = stream.finish().unwrap_err();
assert!(matches!(err, Error::Stream { .. }), "{err:?}");
let record = ExtractionMetadata::from_error(&err).unwrap();
assert_eq!(record.passes, 0);
assert_eq!(record.usage.and_then(|u| u.input_tokens), Some(900));
}
#[tokio::test]
async fn a_failed_stream_after_a_fallback_keeps_both_records() {
let mut events = vec![
MockStreamEvent::from(StreamEvent::response_start(None, None)),
MockStreamEvent::from(StreamEvent::tool_call_start(0, "c", EXTRACTION_TOOL)),
MockStreamEvent::from(StreamEvent::tool_call_delta(0, r#"{"name":"#)),
];
events.extend(failing_mid_stream().into_iter().skip(3));
let provider = MockStreamingProvider::build(|b| {
b.setup_error(refused(RequestField::ResponseFormat))
.stream(events)
})
.with_chat_capability(
ChatCapability::StructuredOutput,
CapabilitySupport::Supported,
);
let mut stream = provider.extract_stream::<Person>(&request()).await.unwrap();
while stream.next().await.is_some() {}
let err = stream.finish().unwrap_err();
let record = ExtractionMetadata::from_error(&err).unwrap();
assert_eq!(record.mode, ExtractionMode::ForcedTool);
assert_eq!(record.auto_fallback, Some(refusal_record()));
assert_eq!(record.passes, 0);
assert_eq!(record.usage.and_then(|u| u.input_tokens), Some(900));
}
#[tokio::test]
async fn a_failed_failover_carries_the_record_on_its_own_context() {
let provider = MockProvider::build(|b| {
b.response(text_with_usage("nope", 10, 2))
.error(rate_limited().with_fallback_from(Error::timeout("primary")))
});
let request = request().with_option(ExtractionMode::Native);
let err = Extractor::new()
.retries(1)
.extract::<_, Person>(&provider, &request)
.await
.unwrap_err();
assert!(matches!(err, Error::RateLimited { .. }), "{err:?}");
assert_eq!(ExtractionMetadata::from_error(&err).unwrap().passes, 1);
let primary = err.fallback_from().unwrap();
assert!(matches!(primary, Error::Timeout { .. }), "{primary:?}");
assert!(ExtractionMetadata::from_error(primary).is_none());
}
#[tokio::test]
async fn errors_without_a_record_report_none() {
assert_eq!(ExtractionMetadata::from_error(&rate_limited()), None);
let provider = MockProvider::build(|b| {
b.error(Error::serialization(
serde_json::from_str::<serde_json::Value>("{").unwrap_err(),
))
});
let request = request().with_option(ExtractionMode::Native);
let err = provider.extract::<Person>(&request).await.unwrap_err();
assert!(matches!(err, Error::Serialization { .. }));
assert_eq!(ExtractionMetadata::from_error(&err), None);
}
#[tokio::test]
async fn error_log_keeps_the_extraction_record() {
let provider = MockProvider::build(|b| {
b.response(text_with_usage(r#"{"name":"Alice"}"#, 500, 50))
.error(rate_limited().with_body("secret body"))
});
let request = request().with_option(ExtractionMode::Native);
let mut err = Extractor::new()
.retries(1)
.extract::<_, Person>(&provider, &request)
.await
.unwrap_err();
err.context_mut()
.unwrap()
.metadata
.insert("vendor.detail".into(), json!("may quote input"));
let log = serde_json::to_value(err.as_log()).unwrap();
assert_eq!(log["allama.extraction"]["passes"], 1);
assert_eq!(log["allama.extraction"]["mode"], "native");
assert_eq!(log["allama.extraction"]["usage"]["input_tokens"], 500);
assert!(log.get("vendor.detail").is_none());
assert!(!log.to_string().contains("may quote input"));
assert_eq!(
err.as_log().to_string(),
"kind=rate_limited provider=mock status=429 body_len=11 \
allama.extraction.mode=native allama.extraction.passes=1 \
allama.extraction.input_tokens=500 allama.extraction.output_tokens=50 \
message=\"HTTP 429\""
);
}
#[tokio::test]
async fn missing_text_after_tool_calls_keeps_the_response() {
let provider = MockProvider::with_tool_call("c1", "lookup", json!({"q": "Alice"}))
.with_chat_capability(
ChatCapability::StructuredOutput,
CapabilitySupport::Supported,
);
let failure = failure(provider.extract::<Person>(&request()).await.unwrap_err());
assert!(matches!(
failure.error(),
ExtractError::MissingStructuredText { .. }
));
let calls: Vec<_> = failure.response().unwrap().tool_calls().collect();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "lookup");
}
#[tokio::test]
async fn extract_keeps_sampling_settings() {
let provider = MockProvider::with_text(r#"{"name":"Alice","age":31}"#);
let request = request()
.temperature(0.0)
.seed(7)
.with_option(ExtractionMode::Native);
Extractor::new()
.extract::<_, Person>(&provider, &request)
.await
.unwrap();
let sent = provider.last_request().unwrap();
assert_eq!((sent.temperature, sent.seed), (Some(0.0), Some(7)));
}
#[test]
fn parse_uses_an_existing_response() {
let response = ChatResponseBuilder::new()
.text("Here: {\"name\":\"Alice\",\"age\":31}")
.usage(5, 5)
.build();
let extracted = Extractor::new()
.repair(true)
.parse::<Person>(response.clone(), ExtractionMode::Native)
.unwrap();
assert_eq!(extracted.value, alice());
assert_eq!(extracted.response, response);
assert_eq!(
extracted.metadata,
ExtractionMetadata {
mode: ExtractionMode::Native,
passes: 0,
repaired: true,
enforcement: SchemaEnforcement::NotEnforced,
degradations: Vec::new(),
usage: None,
auto_fallback: None,
undeclared_key_rejections: 0,
}
);
let strict = failure(
Extractor::new()
.parse::<Person>(response.clone(), ExtractionMode::Native)
.unwrap_err(),
);
assert!(matches!(strict.error(), ExtractError::Parse { .. }));
assert_eq!(strict.response(), Some(&response));
let tool = ChatResponseBuilder::new()
.tool_call("c", EXTRACTION_TOOL, json!({"name": "Alice", "age": 31}))
.build();
let extracted = Extractor::new()
.parse::<Person>(tool, ExtractionMode::ForcedTool)
.unwrap();
assert_eq!(extracted.value, alice());
}
#[test]
fn parse_rejects_auto() {
let mut response = ChatResponse::new(vec![crate::ContentBlock::text("{}")]);
response.provider = Some("demo".into());
let failure = failure(
Extractor::new()
.parse::<Person>(response, ExtractionMode::Auto)
.unwrap_err(),
);
assert!(matches!(
failure.error(),
ExtractError::Unsupported { provider, .. } if provider == "demo"
));
assert!(failure.response().is_some());
}
#[tokio::test]
async fn streamed_failures_carry_the_collected_response() {
let provider = MockStreamingProvider::from_responses([text_with_usage("nope", 3, 1)]);
let request = request().with_option(ExtractionMode::Native);
let mut stream = provider.extract_stream::<Person>(&request).await.unwrap();
while stream.next().await.is_some() {}
let failure = failure(stream.finish().unwrap_err());
assert!(matches!(failure.error(), ExtractError::Parse { .. }));
assert_eq!(failure.metadata().passes, 1);
assert_eq!(
failure.metadata().usage.as_ref().unwrap().input_tokens,
Some(3)
);
assert_eq!(
failure.response().and_then(ChatResponse::text).as_deref(),
Some("nope")
);
}
#[tokio::test]
async fn error_log_omits_the_failed_response() {
let provider = MockProvider::with_text("secret model output");
let request = request().with_option(ExtractionMode::Native);
let err = provider.extract::<Person>(&request).await.unwrap_err();
let log = serde_json::to_value(err.as_log()).unwrap();
assert_eq!(log["type"], "extract");
assert!(!log.to_string().contains("secret model output"), "{log}");
assert!(!err.as_log().to_string().contains("secret model output"));
let full = serde_json::to_value(&err).unwrap();
assert!(full.to_string().contains("secret model output"));
let back = failure(serde_json::from_value(full).unwrap());
assert_eq!(
back.response().and_then(ChatResponse::text).as_deref(),
Some("secret model output")
);
assert_eq!(back.metadata().passes, 1);
}
#[tokio::test]
async fn the_synthetic_tool_is_strict() {
let provider =
MockProvider::with_tool_call("c", EXTRACTION_TOOL, json!({"name": "Alice", "age": 31}));
let request = request().with_option(ExtractionMode::ForcedTool);
let extracted = provider.extract::<Person>(&request).await.unwrap();
assert_eq!(extracted.metadata.enforcement, SchemaEnforcement::Exact);
let sent = provider.last_request().unwrap();
let tools = sent.tools;
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].name, EXTRACTION_TOOL);
assert_eq!(tools[0].strict, Some(true));
assert_eq!(sent.parallel_tool_calls, None);
}
#[tokio::test]
async fn the_callers_parallel_tool_calls_is_kept() {
for model in ["gpt-4.1", "ft:gpt-4o-mini-2024-07-18:acme::abc123"] {
for (setting, mode) in [
(None, ExtractionMode::ForcedTool),
(Some(true), ExtractionMode::ForcedTool),
(Some(false), ExtractionMode::ForcedTool),
(Some(true), ExtractionMode::Native),
] {
let provider = if mode == ExtractionMode::Native {
MockProvider::with_text(r#"{"name":"Alice","age":31}"#)
} else {
MockProvider::with_tool_call(
"c",
EXTRACTION_TOOL,
json!({"name": "Alice", "age": 31}),
)
};
let mut request = ChatRequest::new(model)
.user("Alice is 31")
.with_option(mode);
request.parallel_tool_calls = setting;
provider.extract::<Person>(&request).await.unwrap();
assert_eq!(
provider.last_request().unwrap().parallel_tool_calls,
setting,
"{model} {mode:?}"
);
}
}
}
fn strict_dropped(field: RequestField) -> Warning {
Warning::unsupported(field, "strict is not supported; sent without strict")
.with_provider("mock")
}
#[tokio::test]
async fn unsupported_degradations_are_reported_as_not_enforced() {
let provider = MockProvider::with_response(
ChatResponseBuilder::new()
.tool_call("c", EXTRACTION_TOOL, json!({"name": "Alice", "age": 31}))
.warning(strict_dropped(RequestField::Tools))
.warning(Warning::unsupported(RequestField::Seed, "seed dropped"))
.build(),
);
let request = request().with_option(ExtractionMode::ForcedTool);
let extracted = provider.extract::<Person>(&request).await.unwrap();
assert_eq!(extracted.value, alice());
assert_eq!(
extracted.metadata.enforcement,
SchemaEnforcement::NotEnforced
);
assert_eq!(
extracted.metadata.degradations,
[strict_dropped(RequestField::Tools)]
);
assert_eq!(extracted.response.warnings.len(), 2);
}
fn approximated(field: RequestField) -> Warning {
Warning::new(
WarningKind::Approximated,
Subject::Field { field },
"`pattern` was removed",
)
.with_provider("mock")
}
#[tokio::test]
async fn approximated_degradations_are_reported_as_approximate() {
let provider = MockProvider::with_response(
ChatResponseBuilder::new()
.text(r#"{"name":"Alice","age":31}"#)
.warning(approximated(RequestField::ResponseFormat))
.build(),
);
let request = request().with_option(ExtractionMode::Native);
let extracted = provider.extract::<Person>(&request).await.unwrap();
assert_eq!(
extracted.metadata.enforcement,
SchemaEnforcement::Approximate
);
assert_eq!(
extracted.metadata.degradations,
[approximated(RequestField::ResponseFormat)]
);
}
#[tokio::test]
async fn only_the_field_the_mode_relies_on_counts() {
let provider = MockProvider::with_response(
ChatResponseBuilder::new()
.text(r#"{"name":"Alice","age":31}"#)
.warning(strict_dropped(RequestField::Tools))
.build(),
);
let native = request().with_option(ExtractionMode::Native);
let extracted = provider.extract::<Person>(&native).await.unwrap();
assert_eq!(extracted.metadata.enforcement, SchemaEnforcement::Exact);
assert!(extracted.metadata.degradations.is_empty());
assert_eq!(extracted.response.warnings.len(), 1);
let provider = MockProvider::with_response(
ChatResponseBuilder::new()
.tool_call("c", EXTRACTION_TOOL, json!({"name": "Alice", "age": 31}))
.warning(strict_dropped(RequestField::ResponseFormat))
.build(),
);
let request = request().with_option(ExtractionMode::ForcedTool);
let extracted = provider.extract::<Person>(&request).await.unwrap();
assert_eq!(extracted.metadata.enforcement, SchemaEnforcement::Exact);
assert!(extracted.metadata.degradations.is_empty());
}
#[tokio::test]
async fn the_least_enforced_pass_wins() {
let provider = MockProvider::build(|b| {
b.response(
ChatResponseBuilder::new()
.text("not json")
.warning(approximated(RequestField::ResponseFormat))
.build(),
)
.response(
ChatResponseBuilder::new()
.text(r#"{"name":"Alice","age":31}"#)
.warning(strict_dropped(RequestField::ResponseFormat))
.build(),
)
});
let request = request().with_option(ExtractionMode::Native);
let extracted = Extractor::new()
.retries(1)
.extract::<_, Person>(&provider, &request)
.await
.unwrap();
assert_eq!(
extracted.metadata.enforcement,
SchemaEnforcement::NotEnforced
);
assert_eq!(extracted.metadata.degradations.len(), 2);
}
#[test]
fn enforcement_levels_serialize_in_snake_case() {
for (level, name) in [
(SchemaEnforcement::Exact, "exact"),
(SchemaEnforcement::Approximate, "approximate"),
(SchemaEnforcement::NotEnforced, "not_enforced"),
] {
assert_eq!(serde_json::to_value(level).unwrap(), name);
assert_eq!(
serde_json::from_value::<SchemaEnforcement>(json!(name)).unwrap(),
level
);
}
}
#[tokio::test]
async fn degradations_are_collected_once_over_every_pass() {
let degraded = |text: &str| {
ChatResponseBuilder::new()
.text(text)
.warning(strict_dropped(RequestField::ResponseFormat))
.build()
};
let provider = MockProvider::build(|b| {
b.response(degraded(r#"{"name":"Alice"}"#))
.response(degraded(r#"{"name":"Alice","age":31}"#))
});
let request = request().with_option(ExtractionMode::Native);
let extracted = Extractor::new()
.retries(1)
.extract::<_, Person>(&provider, &request)
.await
.unwrap();
assert_eq!(extracted.metadata.passes, 2);
assert_eq!(
extracted.metadata.enforcement,
SchemaEnforcement::NotEnforced
);
assert_eq!(
extracted.metadata.degradations,
[strict_dropped(RequestField::ResponseFormat)]
);
}
#[tokio::test]
async fn extraction_failures_carry_the_degradations() {
let provider = MockProvider::with_response(
ChatResponseBuilder::new()
.text("not json")
.warning(strict_dropped(RequestField::ResponseFormat))
.build(),
);
let request = request().with_option(ExtractionMode::Native);
let failure = failure(provider.extract::<Person>(&request).await.unwrap_err());
assert!(matches!(failure.error(), ExtractError::Parse { .. }));
assert_eq!(
failure.metadata().enforcement,
SchemaEnforcement::NotEnforced
);
assert_eq!(
failure.metadata().degradations,
[strict_dropped(RequestField::ResponseFormat)]
);
let provider = MockProvider::empty()
.with_chat_capability(
ChatCapability::StructuredOutput,
CapabilitySupport::Unsupported,
)
.with_chat_capability(ChatCapability::ToolCalls, CapabilitySupport::Unsupported);
let failure = failure_of(provider.extract::<Person>(&ChatRequest::new("m")).await);
assert_eq!(failure.metadata().passes, 0);
assert_eq!(
failure.metadata().enforcement,
SchemaEnforcement::NotEnforced
);
assert!(failure.metadata().degradations.is_empty());
}
fn failure_of<T: fmt::Debug>(result: Result<T>) -> ExtractFailure {
failure(result.unwrap_err())
}
#[tokio::test]
async fn streamed_extraction_reports_the_streamed_degradations() {
let degraded = |text: &str| {
ChatResponseBuilder::new()
.text(text)
.warning(strict_dropped(RequestField::ResponseFormat))
.build()
};
let request = request().with_option(ExtractionMode::Native);
let provider =
MockStreamingProvider::from_responses([degraded(r#"{"name":"Alice","age":31}"#)]);
let mut stream = provider.extract_stream::<Person>(&request).await.unwrap();
while stream.next().await.is_some() {}
let extracted = stream.finish().unwrap();
assert_eq!(
extracted.metadata.enforcement,
SchemaEnforcement::NotEnforced
);
assert_eq!(
extracted.metadata.degradations,
[strict_dropped(RequestField::ResponseFormat)]
);
let provider = MockStreamingProvider::from_responses([degraded("nope")]);
let mut stream = provider.extract_stream::<Person>(&request).await.unwrap();
while stream.next().await.is_some() {}
let failure = failure(stream.finish().unwrap_err());
assert_eq!(
failure.metadata().enforcement,
SchemaEnforcement::NotEnforced
);
assert_eq!(
failure.metadata().degradations,
[strict_dropped(RequestField::ResponseFormat)]
);
let provider = MockStreamingProvider::with_text(r#"{"name":"Alice","age":31}"#);
let mut stream = provider.extract_stream::<Person>(&request).await.unwrap();
while stream.next().await.is_some() {}
let extracted = stream.finish().unwrap();
assert_eq!(extracted.metadata.enforcement, SchemaEnforcement::Exact);
assert!(extracted.metadata.degradations.is_empty());
}
#[test]
fn parse_normalizes_strict_nulls_and_keeps_the_raw_output() {
let response = ChatResponseBuilder::new().text(STRICT_NULLS).build();
let extracted = Extractor::new()
.parse::<Profile>(response, ExtractionMode::Native)
.unwrap();
assert_eq!(extracted.value, alice_profile());
assert_eq!(
extracted.metadata.enforcement,
SchemaEnforcement::NotEnforced
);
let tool = ChatResponseBuilder::new()
.tool_call(
"c",
EXTRACTION_TOOL,
serde_json::from_str::<serde_json::Value>(STRICT_NULLS).unwrap(),
)
.build();
let extracted = Extractor::new()
.parse::<Profile>(tool, ExtractionMode::ForcedTool)
.unwrap();
assert_eq!(extracted.value, alice_profile());
let raw = r#"{"name":null,"tags":null}"#;
let failure = failure_of(Extractor::new().parse::<Profile>(
ChatResponseBuilder::new().text(raw).build(),
ExtractionMode::Native,
));
assert_eq!(failure.error().raw_output(), Some(raw));
}
fn approximate_text(text: &str) -> ChatResponse {
ChatResponseBuilder::new()
.text(text)
.warning(approximated(RequestField::ResponseFormat))
.usage(10, 5)
.build()
}
#[derive(Debug, PartialEq, serde::Deserialize, schemars::JsonSchema)]
struct Contact {
email: Option<String>,
#[serde(default)]
phone: Option<String>,
}
#[derive(Debug, PartialEq, serde::Deserialize, schemars::JsonSchema)]
struct Customer {
name: String,
contact: Option<Contact>,
}
#[tokio::test]
async fn a_misnamed_key_is_rejected_on_a_pass_that_is_not_exact() {
let provider = MockProvider::with_response(approximate_text(
r#"{"name": "Ada", "contact": {"e_mail": "ada@example.com"}}"#,
));
let request = request().with_option(ExtractionMode::Native);
let failure = failure(provider.extract::<Customer>(&request).await.unwrap_err());
let ExtractError::Parse {
message, path, raw, ..
} = failure.error()
else {
panic!("a parse failure expected: {failure:?}");
};
assert_eq!(path.as_deref(), Some("contact.e_mail"));
assert_eq!(
message,
"unknown field `e_mail`, expected one of `email`, `phone`"
);
assert!(raw.contains("e_mail"));
assert_eq!(failure.metadata().undeclared_key_rejections, 1);
assert_eq!(
failure.metadata().enforcement,
SchemaEnforcement::Approximate
);
}
#[tokio::test]
async fn an_undeclared_key_is_retried_and_counted() {
let provider = MockProvider::build(|b| {
b.response(approximate_text(r#"{"name": "Ada", "e_mail": "x"}"#))
.response(approximate_text(r#"{"name": "Ada", "contact": null}"#))
});
let request = request().with_option(ExtractionMode::Native);
let extracted = Extractor::new()
.retries(1)
.extract::<_, Customer>(&provider, &request)
.await
.unwrap();
assert_eq!(extracted.value.name, "Ada");
assert_eq!(extracted.metadata.passes, 2);
assert_eq!(extracted.metadata.undeclared_key_rejections, 1);
let feedback = retry_feedback(&provider);
assert!(
feedback.contains("it did not match the schema at `e_mail`: unknown field `e_mail`"),
"{feedback}"
);
}
#[tokio::test]
async fn a_key_nested_at_the_wrong_level_is_rejected() {
let provider = MockProvider::with_response(approximate_text(
r#"{"name": "Ada", "email": "ada@example.com"}"#,
));
let request = request().with_option(ExtractionMode::Native);
let failure = failure(provider.extract::<Customer>(&request).await.unwrap_err());
assert!(
matches!(failure.error(), ExtractError::Parse { path: Some(path), .. } if path == "email"),
"{failure:?}"
);
}
#[derive(Debug, PartialEq, serde::Deserialize, schemars::JsonSchema)]
#[serde(untagged)]
enum Reach {
Email { email: Option<String> },
Phone { phone: Option<String> },
}
#[derive(Debug, PartialEq, serde::Deserialize, schemars::JsonSchema)]
struct Lead {
reach: Reach,
}
#[tokio::test]
async fn untagged_enums_with_optional_branches_are_checked_against_every_branch() {
let request = request().with_option(ExtractionMode::Native);
let provider =
MockProvider::with_response(approximate_text(r#"{"reach": {"e_mail": "x"}}"#));
let failure = failure(provider.extract::<Lead>(&request).await.unwrap_err());
assert!(
matches!(failure.error(), ExtractError::Parse { path: Some(path), .. } if path == "reach.e_mail"),
"{failure:?}"
);
let provider =
MockProvider::with_response(approximate_text(r#"{"reach": {"phone": "1"}}"#));
let extracted = provider.extract::<Lead>(&request).await.unwrap();
assert_eq!(extracted.value.reach, Reach::Email { email: None });
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct Tagged {
labels: std::collections::BTreeMap<String, String>,
#[serde(flatten)]
extra: std::collections::BTreeMap<String, serde_json::Value>,
}
#[tokio::test]
async fn maps_and_flattened_maps_take_any_key() {
let provider = MockProvider::with_response(approximate_text(
r#"{"labels": {"any": "x"}, "unlisted": 1}"#,
));
let request = request().with_option(ExtractionMode::Native);
let extracted = provider.extract::<Tagged>(&request).await.unwrap();
assert_eq!(extracted.value.labels["any"], "x");
assert_eq!(extracted.value.extra["unlisted"], 1);
assert_eq!(extracted.metadata.undeclared_key_rejections, 0);
}
#[tokio::test]
async fn undeclared_keys_can_be_allowed_and_exact_passes_are_not_checked() {
let output = r#"{"name": "Ada", "e_mail": "x"}"#;
let request = request().with_option(ExtractionMode::Native);
let provider = MockProvider::with_response(approximate_text(output));
let extracted = Extractor::new()
.allow_undeclared_keys(true)
.extract::<_, Customer>(&provider, &request)
.await
.unwrap();
assert_eq!(extracted.value.name, "Ada");
let provider = MockProvider::with_text(output);
let extracted = provider.extract::<Customer>(&request).await.unwrap();
assert_eq!(extracted.metadata.enforcement, SchemaEnforcement::Exact);
}
#[test]
fn parse_rejects_undeclared_keys_unless_allowed() {
let response = || {
ChatResponse::new(vec![crate::ContentBlock::text(
r#"{"name": "Ada", "x": 1}"#,
)])
};
let err = Extractor::new()
.parse::<Customer>(response(), ExtractionMode::Native)
.unwrap_err();
let failure = failure(err);
assert_eq!(failure.metadata().undeclared_key_rejections, 1);
assert!(
Extractor::new()
.allow_undeclared_keys(true)
.parse::<Customer>(response(), ExtractionMode::Native)
.is_ok()
);
}
#[tokio::test]
async fn streamed_extraction_rejects_undeclared_keys() {
let provider = MockStreamingProvider::from_responses([approximate_text(
r#"{"name": "Ada", "e_mail": "x"}"#,
)]);
let request = request().with_option(ExtractionMode::Native);
let mut stream = provider.extract_stream::<Customer>(&request).await.unwrap();
while stream.next().await.is_some() {}
let failure = failure(stream.finish().unwrap_err());
assert_eq!(failure.metadata().undeclared_key_rejections, 1);
}
struct Validating {
inner: MockProvider,
warnings: Vec<Warning>,
coverage: crate::ValidationCoverage,
}
impl crate::ProviderIdentity for Validating {
fn provider_name(&self) -> &str {
"mock"
}
fn reads_namespace(&self, namespace: &str) -> bool {
namespace == "mock"
}
}
impl ChatProvider for Validating {
type Stream = <MockProvider as ChatProvider>::Stream;
async fn chat(&self, request: &ChatRequest) -> Result<ChatResponse> {
self.inner.chat(request).await
}
async fn chat_stream(&self, request: &ChatRequest) -> Result<Self::Stream> {
self.inner.chat_stream(request).await
}
fn validate_chat_request(&self, _: &ChatRequest) -> Result<crate::ChatValidation> {
let mut validation = crate::Validation::new(self.coverage);
for warning in &self.warnings {
validation = validation.with_warning(warning.clone());
}
Ok(validation)
}
fn chat_capability(&self, model: &str, capability: ChatCapability) -> CapabilitySupport {
self.inner.chat_capability(model, capability)
}
}
fn not_constrained_tool() -> Warning {
Warning::new(
WarningKind::Unsupported,
Subject::Field {
field: RequestField::Tools,
},
"tool `submit_structured_output` is not strict-representable: the subschema at \
`/properties/contact` is a map; it was sent without `strict`",
)
}
#[tokio::test]
async fn degradations_name_the_callers_type() {
let provider = MockProvider::with_response(
ChatResponseBuilder::new()
.tool_call(
"call_1",
EXTRACTION_TOOL,
json!({"name": "Ada", "contact": null}),
)
.warning(not_constrained_tool())
.build(),
);
let request = request().with_option(ExtractionMode::ForcedTool);
let extracted = provider.extract::<Customer>(&request).await.unwrap();
assert_eq!(
extracted.metadata.degradations[0].message,
"the schema of `Customer` is not strict-representable: the subschema at \
`Customer.contact` is a map; it was sent without `strict`"
);
assert_eq!(extracted.response.warnings[0], not_constrained_tool());
}
#[cfg(feature = "warning-events")]
#[test]
fn enforcement_is_a_warning_event_once_per_type_and_model() {
let (_, events) = crate::warning::capture::captured(async {
for _ in 0..2 {
let provider = MockProvider::with_response(approximate_text(
r#"{"name": "Ada", "contact": null}"#,
))
.with_provider_name("enforcement-events");
let request = ChatRequest::new("enforcement-model")
.user("Ada")
.with_option(ExtractionMode::Native);
provider.extract::<Customer>(&request).await.unwrap();
}
});
let ours: Vec<_> = events
.iter()
.filter(|e| e.code.as_deref() == Some("extraction.approximate.Customer"))
.collect();
assert_eq!(ours.len(), 2, "{events:?}");
assert_eq!(ours[0].level, tracing::Level::WARN);
assert_eq!(ours[1].level, tracing::Level::DEBUG);
assert!(
ours[0].message.ends_with(
"; `Customer` is extracted from `enforcement-model` approximately (`native` mode)"
),
"{}",
ours[0].message
);
}
#[tokio::test]
async fn a_floor_fails_before_sending_under_encoding_coverage() {
let provider = Validating {
inner: MockProvider::with_text(r#"{"name": "Ada", "contact": null}"#),
warnings: vec![not_constrained_tool()],
coverage: crate::ValidationCoverage::Encoding,
};
let request = request().with_option(ExtractionMode::ForcedTool);
let err = Extractor::new()
.min_enforcement(SchemaEnforcement::Approximate)
.extract::<_, Customer>(&provider, &request)
.await
.unwrap_err();
assert_eq!(provider.inner.call_count(), 0);
let failure = failure(err);
let ExtractError::EnforcementBelow {
required,
achieved,
reason,
} = failure.error()
else {
panic!("a floor failure expected: {failure:?}");
};
assert_eq!(
(*required, *achieved),
(
SchemaEnforcement::Approximate,
SchemaEnforcement::NotEnforced
)
);
assert!(reason.contains("`Customer.contact` is a map"), "{reason}");
assert!(failure.response().is_none());
assert_eq!(failure.metadata().passes, 0);
let extracted = Extractor::new()
.min_enforcement(SchemaEnforcement::NotEnforced)
.extract::<_, Customer>(&provider, &request)
.await;
assert!(extracted.is_err(), "the mock answers text, not a tool call");
assert_eq!(provider.inner.call_count(), 1);
}
#[tokio::test]
async fn a_floor_is_checked_after_the_pass_under_capabilities_coverage() {
let provider = Validating {
inner: MockProvider::with_response(approximate_text(
r#"{"name": "Ada", "contact": null}"#,
)),
warnings: Vec::new(),
coverage: crate::ValidationCoverage::Capabilities,
};
let request = request().with_option(ExtractionMode::Native);
let err = Extractor::new()
.min_enforcement(SchemaEnforcement::Exact)
.extract::<_, Customer>(&provider, &request)
.await
.unwrap_err();
assert_eq!(provider.inner.call_count(), 1);
let failure = failure(err);
assert!(
matches!(
failure.error(),
ExtractError::EnforcementBelow {
achieved: SchemaEnforcement::Approximate,
..
}
),
"{failure:?}"
);
assert!(failure.response().is_some());
assert_eq!(
failure
.metadata()
.usage
.as_ref()
.and_then(|u| u.output_tokens),
Some(5)
);
assert!(!Error::from(failure).is_retryable());
}
#[tokio::test]
async fn a_floor_applies_to_streams() {
let provider = MockStreamingProvider::from_responses([approximate_text(
r#"{"name": "Ada", "contact": null}"#,
)]);
let request = request().with_option(ExtractionMode::Native);
let mut stream = Extractor::new()
.min_enforcement(SchemaEnforcement::Exact)
.extract_stream::<_, Customer>(&provider, &request)
.await
.unwrap();
while stream.next().await.is_some() {}
let failure = failure(stream.finish().unwrap_err());
assert!(matches!(
failure.error(),
ExtractError::EnforcementBelow { .. }
));
}
#[test]
fn enforcement_levels_are_ordered() {
use SchemaEnforcement::{Approximate, Exact, NotEnforced};
assert!(Exact.satisfies(Exact) && Exact.satisfies(NotEnforced));
assert!(Approximate.satisfies(Approximate) && !Approximate.satisfies(Exact));
assert!(NotEnforced.satisfies(NotEnforced) && !NotEnforced.satisfies(Approximate));
assert_eq!(NotEnforced.to_string(), "not_enforced");
}
#[test]
fn a_plan_reports_mode_enforcement_and_coverage_without_sending() {
let provider = Validating {
inner: MockProvider::with_text("unused")
.with_supported_chat_capabilities(NATIVE_AND_TOOLS),
warnings: vec![approximated(RequestField::ResponseFormat)],
coverage: crate::ValidationCoverage::Encoding,
};
let plan = Extractor::new()
.plan::<Customer>(&provider, &request())
.unwrap();
assert_eq!(plan.mode, ExtractionMode::Native);
assert_eq!(plan.enforcement, SchemaEnforcement::Approximate);
assert_eq!(plan.degradations.len(), 1);
assert_eq!(plan.coverage, crate::ValidationCoverage::Encoding);
assert_eq!(plan.auto_fallback, None);
assert_eq!(provider.inner.call_count(), 0);
let provider = MockProvider::with_error(refused(RequestField::ResponseFormat))
.with_supported_chat_capabilities(NATIVE_AND_TOOLS);
let plan = Extractor::new().plan::<Customer>(&provider, &request());
let plan = plan.unwrap();
assert_eq!(plan.mode, ExtractionMode::Native);
assert_eq!(plan.coverage, crate::ValidationCoverage::Capabilities);
}
#[test]
fn metadata_without_enforcement_fields_deserializes() {
let metadata: ExtractionMetadata =
serde_json::from_value(json!({"mode": "native", "passes": 1, "repaired": false}))
.unwrap();
assert_eq!(metadata.enforcement, SchemaEnforcement::NotEnforced);
assert!(metadata.degradations.is_empty());
let mut degraded = ExtractionMetadata::new(ExtractionMode::ForcedTool);
degraded.record_pass(
&ChatResponseBuilder::new()
.warning(strict_dropped(RequestField::Tools))
.build(),
"Person",
);
let value = serde_json::to_value(°raded).unwrap();
assert_eq!(value["enforcement"], "not_enforced");
assert_eq!(value["degradations"][0]["subject"]["field"], "tools");
assert_eq!(
serde_json::from_value::<ExtractionMetadata>(value).unwrap(),
degraded
);
}
}