use std::borrow::Cow;
use schemars::JsonSchema;
use serde_json::Value;
use super::{
AutoFallback, EXTRACTION_TOOL, ExtractError, ExtractFailure, ExtractionMetadata,
ExtractionMode, Extractor, SchemaEnforcement, counted_field, derive_request, is_about,
requested_mode, schema_name, select_mode,
};
use crate::{
ChatProvider, ChatRequest, Error, Message, RequestField, Result, ValidationCoverage, Warning,
WarningKind,
};
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct ExtractionPlan {
pub mode: ExtractionMode,
pub enforcement: SchemaEnforcement,
pub degradations: Vec<Warning>,
pub auto_fallback: Option<AutoFallback>,
pub coverage: ValidationCoverage,
}
impl Extractor {
pub fn plan<T: JsonSchema>(
&self,
provider: &(impl ChatProvider + ?Sized),
request: &ChatRequest,
) -> Result<ExtractionPlan> {
Target::new::<T>(provider.provider_name(), &request.model).plan(provider, request, false)
}
pub(super) fn check_floor_before_sending<P: ChatProvider + ?Sized>(
&self,
provider: &P,
request: &ChatRequest,
target: &Target,
stream: bool,
) -> Result<()> {
let Some(floor) = self.min_enforcement else {
return Ok(());
};
let plan = target.plan(provider, request, stream)?;
if plan.coverage != ValidationCoverage::Encoding || plan.enforcement.satisfies(floor) {
return Ok(());
}
let mut metadata = ExtractionMetadata::new(plan.mode);
metadata.auto_fallback = plan.auto_fallback;
Err(ExtractFailure::new(
ExtractError::EnforcementBelow {
required: floor,
achieved: plan.enforcement,
reason: reason(&plan.degradations),
},
metadata,
)
.into())
}
pub(super) fn below_floor(&self, metadata: &ExtractionMetadata) -> Option<ExtractFailure> {
let floor = self.min_enforcement?;
(!metadata.enforcement.satisfies(floor)).then(|| {
ExtractFailure::new(
ExtractError::EnforcementBelow {
required: floor,
achieved: metadata.enforcement,
reason: reason(&metadata.degradations),
},
metadata.clone(),
)
})
}
pub(super) fn rejects_undeclared(&self, metadata: &ExtractionMetadata) -> bool {
!self.allow_undeclared_keys && metadata.enforcement != SchemaEnforcement::Exact
}
}
fn reason(degradations: &[Warning]) -> String {
if degradations.is_empty() {
return "no pass was made".to_owned();
}
degradations
.iter()
.map(|w| w.message.as_str())
.collect::<Vec<_>>()
.join("; ")
}
fn limitations(degradations: &[Warning]) -> String {
if degradations.is_empty() {
return "no pass was made".to_owned();
}
degradations
.iter()
.map(|w| {
w.message
.split_once("; ")
.map_or(w.message.as_str(), |(l, _)| l)
})
.collect::<Vec<_>>()
.join(", and ")
}
#[derive(Debug, Clone)]
pub(super) struct Target {
pub(super) provider: String,
pub(super) model: String,
pub(super) label: String,
pub(super) name: String,
pub(super) schema: Value,
}
impl Target {
pub(super) fn new<T: JsonSchema>(provider: &str, model: &str) -> Self {
let schema = crate::schema::schema_for::<T>();
let label = schema
.get("title")
.and_then(Value::as_str)
.map(str::to_owned)
.unwrap_or_else(|| short_type_name(std::any::type_name::<T>()));
Self {
provider: provider.to_owned(),
model: model.to_owned(),
label,
name: schema_name::<T>(&schema),
schema,
}
}
pub(super) fn derive(
&self,
request: &ChatRequest,
metadata: &ExtractionMetadata,
) -> Result<ChatRequest> {
derive_request(
request,
metadata.mode,
&self.provider,
&self.name,
self.schema.clone(),
)
.map_err(|error| ExtractFailure::new(error, metadata.clone()).into())
}
pub(super) fn pre_send_error(&self, error: Error, metadata: &ExtractionMetadata) -> Error {
if error.unsupported_stage() != Some(crate::UnsupportedStage::BeforeSending) {
return metadata.attach_to(error);
}
match &metadata.auto_fallback {
Some(fallback) if metadata.passes == 0 => {
let forced = if error.message_origin() == crate::MessageOrigin::Authored {
format!(
"was refused too ({})",
refusal_in_caller_terms(&error.message(), &self.label)
)
} else {
"was refused too".to_owned()
};
metadata.attach_to(self.auto_refused(fallback, &forced))
}
_ => metadata.attach_to(self.in_caller_terms(error)),
}
}
fn in_caller_terms(&self, mut error: Error) -> Error {
if let Error::Unsupported { message, .. } = &mut error {
*message = refusal_in_caller_terms(message, &self.label);
}
error
}
fn auto_refused(&self, fallback: &AutoFallback, forced: &str) -> Error {
let native = if fallback.message.is_empty() {
String::new()
} else {
format!(
" ({})",
refusal_in_caller_terms(&fallback.message, &self.label)
)
};
Error::unsupported_before_sending(
format!(
"`{}` cannot be extracted from `{}` with its output constrained: native output was refused{native}, and forced-tool mode {forced}",
self.label, self.model
),
Some(crate::Subject::Field {
field: RequestField::ResponseFormat,
}),
)
.authored()
.with_provider(self.provider.clone())
}
pub(super) fn report_enforcement(&self, metadata: &ExtractionMetadata) {
let (kind, how) = match metadata.enforcement {
SchemaEnforcement::Exact => return,
SchemaEnforcement::Approximate => (WarningKind::Approximated, "approximately"),
_ => (WarningKind::Unsupported, "without constraining the output"),
};
let Some(field) = counted_field(metadata.mode) else {
return;
};
let mut warning = Warning::new(
kind,
crate::Subject::Field { field },
format!(
"{}; `{}` is extracted from `{}` {how} (`{}` mode)",
limitations(&metadata.degradations),
self.label,
self.model,
metadata.mode,
),
)
.with_provider(self.provider.clone());
warning.code = Some(Cow::Owned(format!(
"extraction.{}.{}",
metadata.enforcement, self.label
)));
crate::warning::emit(&self.provider, &self.model, &mut warning);
}
pub(super) fn plan<P: ChatProvider + ?Sized>(
&self,
provider: &P,
request: &ChatRequest,
stream: bool,
) -> Result<ExtractionPlan> {
let requested = requested_mode(request);
let (mode, guessed) = select_mode(provider, request);
let mut metadata = ExtractionMetadata::new(mode);
let mut request = request.clone();
request.system.clear();
request.messages = vec![Message::user("placeholder")];
loop {
let derived = self.derive(&request, &metadata)?;
let validation = if stream {
provider.validate_chat_stream_request(&derived)
} else {
provider.validate_chat_request(&derived)
};
match validation {
Ok(validation) => {
let field = counted_field(metadata.mode);
let mut degradations: Vec<Warning> = Vec::new();
for warning in validation
.warnings()
.iter()
.filter(|w| field.is_some_and(|field| is_about(w, field)))
{
let warning = in_caller_terms(warning, &self.label);
if !degradations.contains(&warning) {
degradations.push(warning);
}
}
return Ok(ExtractionPlan {
mode: metadata.mode,
enforcement: SchemaEnforcement::from_degradations(°radations),
degradations,
auto_fallback: metadata.auto_fallback,
coverage: validation.coverage(),
});
}
Err(error) => {
let Some(fallback) = metadata.auto_fallback_for(requested, guessed, &error)
else {
return Err(self.pre_send_error(error, &metadata));
};
metadata.fall_back(fallback);
}
}
}
}
}
pub(super) fn in_caller_terms(warning: &Warning, label: &str) -> Warning {
let mut warning = warning.clone();
warning.message = message_in_caller_terms(&warning.message, label);
warning
}
fn refusal_in_caller_terms(message: &str, label: &str) -> String {
message_in_caller_terms(message, label)
}
fn message_in_caller_terms(message: &str, label: &str) -> String {
let schema = format!("the schema of `{label}`");
let message = message
.replace(&format!("tool `{EXTRACTION_TOOL}`"), &schema)
.replace("the response schema", &schema)
.replace(&format!("`{EXTRACTION_TOOL}`"), &format!("`{label}`"));
let mut out = String::with_capacity(message.len());
let mut rest = message.as_str();
while let Some(start) = rest.find("`/") {
let Some(len) = rest[start + 1..].find('`') else {
break;
};
let pointer = &rest[start + 1..start + 1 + len];
out.push_str(&rest[..start]);
match field_path(label, pointer) {
Some(path) => out.push_str(&format!("`{path}`")),
None => out.push_str(&format!("`{pointer}`")),
}
rest = &rest[start + len + 2..];
}
out.push_str(rest);
out
}
fn field_path(label: &str, pointer: &str) -> Option<String> {
let mut base = label.to_owned();
let mut path = String::new();
let mut segments = pointer
.split('/')
.skip(1)
.filter(|s| !s.is_empty())
.map(|s| s.replace("~1", "/").replace("~0", "~"));
while let Some(segment) = segments.next() {
match segment.as_str() {
"properties" => path.push_str(&format!(".{}", segments.next()?)),
"items" | "additionalItems" => path.push_str("[]"),
"prefixItems" => path.push_str(&format!("[{}]", segments.next()?)),
"additionalProperties" => path.push_str("[..]"),
"$defs" | "definitions" => {
base = segments.next()?;
path.clear();
}
"anyOf" | "oneOf" | "allOf" => {
segments.next()?;
}
_ => return None,
}
}
Some(format!("{base}{path}"))
}
fn short_type_name(name: &str) -> String {
let outer = name.split('<').next().unwrap_or(name);
let short = outer.rsplit("::").next().unwrap_or(outer);
match name.find('<') {
Some(at) => format!("{short}{}", &name[at..]),
None => short.to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn messages_name_the_callers_type() {
assert_eq!(
message_in_caller_terms(
"tool `submit_structured_output` is not strict-representable: the subschema at \
`/properties/attributes` is a map; it was sent without `strict`",
"Order"
),
"the schema of `Order` is not strict-representable: the subschema at \
`Order.attributes` is a map; it was sent without `strict`"
);
assert_eq!(
refusal_in_caller_terms(
"cannot send `response_format`: the response schema is not strict-representable: \
the subschema at `/$defs/Item/properties/tags/items` uses `not`",
"Order"
),
"cannot send `response_format`: the schema of `Order` is not strict-representable: \
the subschema at `Item.tags[]` uses `not`"
);
assert_eq!(
message_in_caller_terms("at `/patternProperties/x` and `/", "T"),
"at `/patternProperties/x` and `/"
);
assert_eq!(field_path("T", "/"), Some("T".into()));
assert_eq!(
field_path("T", "/properties/a~1b/anyOf/0/properties/c"),
Some("T.a/b.c".into())
);
}
#[test]
fn short_type_names_keep_generics() {
assert_eq!(short_type_name("my::Person"), "Person");
assert_eq!(short_type_name("a::Wrapped<b::Inner>"), "Wrapped<b::Inner>");
}
}