use crate::backend::{
AttemptKind, AttemptRecord, ChatMessage, MaterializeAttemptError, MaterializeFailure,
MaterializeInternalOutput, MaterializeReport, RetryDisposition, RunUsage, TokenUsage,
ValidationFailureContext,
};
use crate::error::{ApiErrorKind, RStructorError, Result};
use crate::model::Instructor;
use reqwest::Response;
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use std::time::Duration;
use tokio::time::sleep;
use tracing::{debug, error, info, trace, warn};
/// Default timeout for an entire HTTP request to an LLM provider (5 minutes).
///
/// LLM calls — especially with reasoning models — can legitimately run for
/// several minutes, but a request should never hang forever (reqwest applies
/// no timeout by default). All provider clients use this value unless an
/// explicit timeout is set with the `timeout()` builder method.
///
/// # Example
///
/// ```
/// use rstructor::DEFAULT_REQUEST_TIMEOUT;
/// use std::time::Duration;
///
/// assert_eq!(DEFAULT_REQUEST_TIMEOUT, Duration::from_secs(300));
/// ```
pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(300);
/// Default timeout for establishing a TCP connection to an LLM provider (30 seconds).
///
/// This bounds only the connect phase of a request; the overall request is
/// bounded by [`DEFAULT_REQUEST_TIMEOUT`] (or the timeout set via the client's
/// `timeout()` builder method). A healthy provider endpoint accepts connections
/// well within 30 seconds, so a slower connect almost always indicates a
/// network problem worth surfacing quickly.
///
/// # Example
///
/// ```
/// use rstructor::DEFAULT_CONNECT_TIMEOUT;
/// use std::time::Duration;
///
/// assert_eq!(DEFAULT_CONNECT_TIMEOUT, Duration::from_secs(30));
/// ```
pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
/// Build the reqwest client used by all provider clients.
///
/// Applies the given total request timeout plus the default connect timeout
/// ([`DEFAULT_CONNECT_TIMEOUT`]). Falls back to `reqwest::Client::new()` if the
/// builder fails (which should never happen with these options).
pub fn build_http_client(timeout: Duration) -> reqwest::Client {
reqwest::Client::builder()
.timeout(timeout)
.connect_timeout(DEFAULT_CONNECT_TIMEOUT)
.build()
.unwrap_or_else(|e| {
warn!(
error = %e,
"Failed to build reqwest client with timeout, using default client"
);
reqwest::Client::new()
})
}
/// Prepare a JSON schema for strict mode by recursively adding required fields
/// to all object types in the schema.
///
/// This is required by providers like OpenAI that use strict structured outputs, where
/// every object in the schema (including nested objects and array items) must have:
/// 1. `additionalProperties: false`
/// 2. A `required` array listing all property keys
///
/// Optionality cannot be expressed via `required` under strict mode, so before the
/// `required` array is overwritten with all property keys, every property that was
/// NOT in the original `required` array (i.e. an `Option<T>` field emitted by the
/// derive macro) has its schema rewritten to also admit `null`. Without this, the
/// model would be grammatically forced to fabricate values for optional fields
/// under constrained decoding.
///
/// # Arguments
///
/// * `schema` - The JSON schema to modify
///
/// # Returns
///
/// A new schema Value with strict mode requirements added to all objects
pub fn prepare_strict_schema(schema: &crate::schema::Schema) -> Value {
let mut schema_json = schema.to_json();
add_additional_properties_false(&mut schema_json);
schema_json
}
/// Recursively prepares a JSON schema for strict mode by adding:
/// 1. `additionalProperties: false` to all object types
/// 2. `null` to the schemas of properties absent from the original `required`
/// array (optional fields), so constrained decoding can emit `null` for them
/// 3. `required` array with all property keys (overriding any existing array)
fn add_additional_properties_false(schema: &mut Value) {
if let Some(obj) = schema.as_object_mut() {
// Check if this is an object type schema (the type may already be a
// ["object", "null"] union if a parent marked this schema optional)
let is_object_type = match obj.get("type") {
Some(Value::String(t)) => t == "object",
Some(Value::Array(types)) => types.iter().any(|t| t.as_str() == Some("object")),
_ => false,
};
// Also check if it has properties (even without explicit type: object)
let has_properties = obj.contains_key("properties");
if is_object_type || has_properties {
obj.insert("additionalProperties".to_string(), serde_json::json!(false));
// Capture the ORIGINAL `required` array before it is overwritten below.
// The derive macro emits only truly-required (non-Option) fields here,
// so every property absent from it is optional. If `required` is absent
// entirely, ALL properties are treated as optional.
let original_required: std::collections::HashSet<String> = obj
.get("required")
.and_then(|r| r.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
// OpenAI strict mode requires ALL properties to be listed in `required`.
// Optional properties must therefore admit `null` instead, otherwise the
// model is forced to fabricate values for them.
let mut required_keys: Vec<Value> = Vec::new();
if let Some(properties) = obj.get_mut("properties")
&& let Some(props_obj) = properties.as_object_mut()
{
for (key, prop_schema) in props_obj.iter_mut() {
if !original_required.contains(key.as_str()) {
make_schema_nullable(prop_schema);
}
}
required_keys = props_obj.keys().map(|k| serde_json::json!(k)).collect();
}
if !required_keys.is_empty() {
obj.insert("required".to_string(), Value::Array(required_keys));
}
}
// Recursively process nested schemas
// Process 'properties' object
if let Some(properties) = obj.get_mut("properties")
&& let Some(props_obj) = properties.as_object_mut()
{
for (_key, prop_schema) in props_obj.iter_mut() {
add_additional_properties_false(prop_schema);
}
}
// Process 'items' for arrays
if let Some(items) = obj.get_mut("items") {
add_additional_properties_false(items);
}
// Process 'additionalItems' for arrays
if let Some(additional_items) = obj.get_mut("additionalItems") {
add_additional_properties_false(additional_items);
}
// Process 'allOf' array
if let Some(all_of) = obj.get_mut("allOf")
&& let Some(arr) = all_of.as_array_mut()
{
for item in arr.iter_mut() {
add_additional_properties_false(item);
}
}
// Process 'anyOf' array
if let Some(any_of) = obj.get_mut("anyOf")
&& let Some(arr) = any_of.as_array_mut()
{
for item in arr.iter_mut() {
add_additional_properties_false(item);
}
}
// Process 'oneOf' array
if let Some(one_of) = obj.get_mut("oneOf")
&& let Some(arr) = one_of.as_array_mut()
{
for item in arr.iter_mut() {
add_additional_properties_false(item);
}
}
// Process 'definitions' / '$defs' for reusable schemas
if let Some(definitions) = obj.get_mut("definitions")
&& let Some(defs_obj) = definitions.as_object_mut()
{
for (_key, def_schema) in defs_obj.iter_mut() {
add_additional_properties_false(def_schema);
}
}
if let Some(defs) = obj.get_mut("$defs")
&& let Some(defs_obj) = defs.as_object_mut()
{
for (_key, def_schema) in defs_obj.iter_mut() {
add_additional_properties_false(def_schema);
}
}
// Process 'not' schema
if let Some(not_schema) = obj.get_mut("not") {
add_additional_properties_false(not_schema);
}
// Process 'if', 'then', 'else' schemas
if let Some(if_schema) = obj.get_mut("if") {
add_additional_properties_false(if_schema);
}
if let Some(then_schema) = obj.get_mut("then") {
add_additional_properties_false(then_schema);
}
if let Some(else_schema) = obj.get_mut("else") {
add_additional_properties_false(else_schema);
}
// Process 'patternProperties' object
if let Some(pattern_props) = obj.get_mut("patternProperties")
&& let Some(pattern_obj) = pattern_props.as_object_mut()
{
for (_pattern, pattern_schema) in pattern_obj.iter_mut() {
add_additional_properties_false(pattern_schema);
}
}
// Process 'contains' for arrays
if let Some(contains) = obj.get_mut("contains") {
add_additional_properties_false(contains);
}
// Process 'propertyNames' schema
if let Some(property_names) = obj.get_mut("propertyNames") {
add_additional_properties_false(property_names);
}
}
}
/// Returns true if a schema branch explicitly admits `null` via its `type` keyword.
fn schema_branch_admits_null(branch: &Value) -> bool {
match branch.get("type") {
Some(Value::String(t)) => t == "null",
Some(Value::Array(types)) => types.iter().any(|t| t.as_str() == Some("null")),
_ => false,
}
}
/// Rewrite a property schema so that it also admits `null`.
///
/// Used for optional (`Option<T>`) fields under strict mode: strict structured
/// outputs require every property to be listed in `required`, so optionality is
/// expressed by allowing `null` instead. Handles the schema shapes this crate
/// emits:
///
/// - `"type": "string"` (scalar) becomes `"type": ["string", "null"]`
/// - `"type": [...]` (already a union) gets `"null"` appended if absent
/// - an `"enum"` array gets `null` appended (JSON Schema `enum` constrains
/// values independently of `type`)
/// - `anyOf`/`oneOf` unions get a `{"type": "null"}` branch if none exists
/// - a bare `$ref` (emitted for self-referential structs) is wrapped as
/// `{"anyOf": [{"$ref": ...}, {"type": "null"}]}`
///
/// Schemas without any of these keywords (e.g. `{}`) already admit `null` and
/// are left untouched.
fn make_schema_nullable(schema: &mut Value) {
let Some(obj) = schema.as_object_mut() else {
return;
};
// Bare $ref: type/enum keywords cannot be reliably combined with $ref, so
// wrap the reference in an anyOf union with an explicit null branch.
if obj.contains_key("$ref") {
let original = Value::Object(std::mem::take(obj));
*schema = serde_json::json!({
"anyOf": [original, { "type": "null" }]
});
return;
}
// anyOf/oneOf unions: add a {"type": "null"} branch if no branch admits null.
for key in ["anyOf", "oneOf"] {
if let Some(branches) = obj.get_mut(key).and_then(|v| v.as_array_mut())
&& !branches.iter().any(schema_branch_admits_null)
{
branches.push(serde_json::json!({ "type": "null" }));
}
}
// type keyword: scalar becomes a [type, "null"] union; existing unions get
// "null" appended if absent.
if let Some(type_value) = obj.get_mut("type") {
match type_value {
Value::String(t) => {
if t != "null" {
*type_value = serde_json::json!([t.clone(), "null"]);
}
}
Value::Array(types) if !types.iter().any(|t| t.as_str() == Some("null")) => {
types.push(serde_json::json!("null"));
}
_ => {}
}
}
// enum constrains allowed values independently of type, so null must also be
// listed as an allowed enum value.
if let Some(values) = obj.get_mut("enum").and_then(|e| e.as_array_mut())
&& !values.iter().any(|v| v.is_null())
{
values.push(Value::Null);
}
}
/// Information about adjacently tagged enum transformations for response conversion
#[derive(Debug, Clone)]
pub struct AdjacentlyTaggedEnumInfo {
pub tag_key: String,
pub content_key: String,
pub tag_values: Vec<String>,
}
/// Extract adjacently tagged enum info from a schema (before Gemini transformation)
/// Searches recursively through the schema tree
pub fn extract_adjacently_tagged_info(schema: &Value) -> Option<AdjacentlyTaggedEnumInfo> {
// First check if this level has enum disjunction variants
for key in ["oneOf", "anyOf"] {
if let Some(variants) = schema.get(key).and_then(|v| v.as_array())
&& let Some(info) = extract_adjacently_tagged_info_from_variants(variants)
{
return Some(info);
}
}
// Recursively search in properties
if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
for (_key, prop_schema) in properties {
if let Some(info) = extract_adjacently_tagged_info(prop_schema) {
return Some(info);
}
}
}
// Recursively search in array items
if let Some(items) = schema.get("items")
&& let Some(info) = extract_adjacently_tagged_info(items)
{
return Some(info);
}
// Search in allOf, anyOf, oneOf
for key in &["allOf", "anyOf", "oneOf"] {
if let Some(arr) = schema.get(key).and_then(|v| v.as_array()) {
for item in arr {
if let Some(info) = extract_adjacently_tagged_info(item) {
return Some(info);
}
}
}
}
None
}
fn extract_adjacently_tagged_info_from_variants(
variants: &[Value],
) -> Option<AdjacentlyTaggedEnumInfo> {
let mut tag_key = None;
let mut content_key = None;
let mut tag_values = Vec::new();
for variant in variants {
if let Some((t, c, v)) = detect_adjacently_tagged_variant(variant) {
if tag_key.is_none() {
tag_key = Some(t);
content_key = Some(c);
}
tag_values.push(v);
}
}
if let (Some(tag), Some(content)) = (tag_key, content_key) {
Some(AdjacentlyTaggedEnumInfo {
tag_key: tag,
content_key: content,
tag_values,
})
} else {
None
}
}
/// Transform internally tagged JSON back to adjacently tagged format
pub fn transform_internally_to_adjacently_tagged(
json: &mut Value,
enum_info: &AdjacentlyTaggedEnumInfo,
) {
match json {
Value::Object(obj) => {
// Check if this object is an internally tagged enum instance
if let Some(tag_value) = obj.get(&enum_info.tag_key).and_then(|v| v.as_str())
&& enum_info.tag_values.contains(&tag_value.to_string())
{
// This is an enum instance - extract all fields except the tag
let mut content_fields = serde_json::Map::new();
let mut keys_to_move: Vec<String> = Vec::new();
for (key, _value) in obj.iter() {
if key != &enum_info.tag_key {
keys_to_move.push(key.clone());
}
}
// Move fields to content (unless it's a unit variant with only tag)
if !keys_to_move.is_empty() {
for key in &keys_to_move {
if let Some(value) = obj.remove(key) {
content_fields.insert(key.clone(), value);
}
}
// Add content field
obj.insert(enum_info.content_key.clone(), Value::Object(content_fields));
}
// For unit variants (only tag), don't add content field
return;
}
// Recursively process nested objects and arrays
for value in obj.values_mut() {
transform_internally_to_adjacently_tagged(value, enum_info);
}
}
Value::Array(arr) => {
for item in arr.iter_mut() {
transform_internally_to_adjacently_tagged(item, enum_info);
}
}
_ => {}
}
}
/// Prepare a JSON schema for Gemini by stripping unsupported keywords.
///
/// Gemini's structured outputs API doesn't support certain JSON Schema keywords like
/// `examples` and schema metadata. Supported object constraints, including boolean
/// and schema-valued `additionalProperties`, are preserved.
///
/// # Arguments
///
/// * `schema` - The JSON schema to modify
///
/// # Returns
///
/// A new schema value with unsupported keywords removed, or a local
/// compatibility error when preserving the canonical schema is impossible.
pub fn prepare_gemini_schema(
schema: &crate::schema::Schema,
context: impl Into<String>,
) -> Result<Value> {
let mut schema_json = schema.to_json();
let context = context.into();
strip_gemini_unsupported_keywords(&mut schema_json, &context)?;
Ok(schema_json)
}
/// Recursively removes keywords unsupported by Gemini's structured outputs.
fn strip_gemini_unsupported_keywords(schema: &mut Value, context: &str) -> Result<()> {
resolve_refs_for_gemini(schema, context)?;
strip_gemini_unsupported_keywords_recursive(schema);
Ok(())
}
/// Resolves $ref references by inlining definitions for Gemini compatibility.
///
/// Acyclic local references are losslessly inlined. Recursive references are
/// rejected before any HTTP request because Gemini's schema transport cannot
/// preserve an unbounded cycle, and finite-depth expansion would silently
/// widen the accepted values at the cutoff.
fn resolve_refs_for_gemini(schema: &mut Value, context: &str) -> Result<()> {
let definitions = schema
.get("$defs")
.or_else(|| schema.get("definitions"))
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let mut expanded = schema.clone();
if let Some(object) = expanded.as_object_mut() {
object.remove("$defs");
object.remove("definitions");
}
inline_refs_for_gemini(&mut expanded, &definitions, context, "$", &mut Vec::new())?;
*schema = expanded;
Ok(())
}
fn inline_refs_for_gemini(
schema: &mut Value,
definitions: &serde_json::Map<String, Value>,
context: &str,
path: &str,
active_references: &mut Vec<String>,
) -> Result<()> {
match schema {
Value::Object(object) => {
if let Some(reference) = object
.get("$ref")
.and_then(Value::as_str)
.map(str::to_string)
{
if active_references.contains(&reference) {
return Err(gemini_schema_compatibility_error(
context,
path,
format!(
"recursive reference `{reference}` cannot be represented without \
changing the schema; Gemini compilation does not apply a lossy \
finite-depth expansion"
),
));
}
let Some(mut target) = referenced_definition(&reference, definitions) else {
return Err(gemini_schema_compatibility_error(
context,
path,
format!(
"local reference `{reference}` does not resolve from the schema \
document root"
),
));
};
let mut siblings = std::mem::take(object);
siblings.remove("$ref");
if !siblings.is_empty() {
target = serde_json::json!({
"allOf": [target, Value::Object(siblings)]
});
}
active_references.push(reference);
inline_refs_for_gemini(&mut target, definitions, context, path, active_references)?;
active_references.pop();
*schema = target;
return Ok(());
}
for (key, child) in object {
inline_refs_for_gemini(
child,
definitions,
context,
&append_json_path(path, key),
active_references,
)?;
}
}
Value::Array(array) => {
for (index, child) in array.iter_mut().enumerate() {
inline_refs_for_gemini(
child,
definitions,
context,
&format!("{path}[{index}]"),
active_references,
)?;
}
}
_ => {}
}
Ok(())
}
fn referenced_definition(
reference: &str,
definitions: &serde_json::Map<String, Value>,
) -> Option<Value> {
let pointer = reference
.strip_prefix("#/$defs/")
.or_else(|| reference.strip_prefix("#/definitions/"))?;
let (encoded_key, suffix) = pointer
.split_once('/')
.map_or((pointer, None), |(key, suffix)| (key, Some(suffix)));
let key = encoded_key.replace("~1", "/").replace("~0", "~");
let definition = definitions.get(&key)?;
suffix.map_or_else(
|| Some(definition.clone()),
|suffix| definition.pointer(&format!("/{suffix}")).cloned(),
)
}
fn append_json_path(path: &str, key: &str) -> String {
let mut characters = key.chars();
let is_identifier = matches!(
characters.next(),
Some(first) if first == '_' || first.is_ascii_alphabetic()
) && characters
.all(|character| character == '_' || character.is_ascii_alphanumeric());
if is_identifier {
format!("{path}.{key}")
} else {
let quoted = serde_json::to_string(key).expect("serializing a string cannot fail");
format!("{path}[{quoted}]")
}
}
fn gemini_schema_compatibility_error(context: &str, path: &str, message: String) -> RStructorError {
RStructorError::SchemaCompatibilityError {
provider: "Gemini".into(),
context: context.into(),
path: path.into(),
message: message.into_boxed_str(),
}
}
/// Detects if a oneOf variant looks like an adjacently tagged enum variant.
/// Returns Some((tag_key, content_key, tag_value)) if it matches the pattern.
fn detect_adjacently_tagged_variant(variant: &Value) -> Option<(String, String, String)> {
let obj = variant.as_object()?;
// Must be an object type
if obj.get("type")?.as_str()? != "object" {
return None;
}
let properties = obj.get("properties")?.as_object()?;
let required = obj.get("required")?.as_array()?;
// Must have exactly 2 required fields
if required.len() != 2 {
return None;
}
// Find the tag field (has enum with single value) and content field (is object)
let mut tag_key = None;
let mut tag_value = None;
let mut content_key = None;
for (key, prop) in properties.iter() {
if let Some(prop_obj) = prop.as_object() {
// Check if it's a tag field (has enum with single value)
if let Some(enum_array) = prop_obj.get("enum").and_then(|e| e.as_array())
&& enum_array.len() == 1
&& let Some(val) = enum_array[0].as_str()
{
tag_key = Some(key.clone());
tag_value = Some(val.to_string());
continue;
}
// Check if it's a content field (is object type)
if prop_obj.get("type").and_then(|t| t.as_str()) == Some("object")
&& prop_obj.contains_key("properties")
{
content_key = Some(key.clone());
}
}
}
// Must have found both tag and content
if let (Some(tag), Some(content), Some(value)) = (tag_key, content_key, tag_value) {
Some((tag, content, value))
} else {
None
}
}
/// Transforms adjacently tagged enum variants to internally tagged format for Gemini.
/// This is a workaround for Gemini's limitation with nested content objects.
fn transform_adjacently_tagged_to_internally_tagged(
variant: &Value,
_tag_key: &str,
content_key: &str,
_tag_value: &str,
) -> Value {
let mut obj = variant.as_object().unwrap().clone();
// Clone content properties and required fields before modifying
let content_props_to_add: Vec<(String, Value)>;
let content_required: Vec<Value>;
{
let properties = obj.get("properties").unwrap().as_object().unwrap();
// Get the content object properties
if let Some(content_obj) = properties.get(content_key).and_then(|c| c.as_object()) {
if let Some(content_props) = content_obj.get("properties").and_then(|p| p.as_object()) {
// Collect properties to add
content_props_to_add = content_props
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
} else {
content_props_to_add = Vec::new();
}
// Collect required fields
if let Some(req) = content_obj.get("required").and_then(|r| r.as_array()) {
content_required = req.clone();
} else {
content_required = Vec::new();
}
} else {
content_props_to_add = Vec::new();
content_required = Vec::new();
}
}
// Now modify properties
if let Some(properties) = obj.get_mut("properties").and_then(|p| p.as_object_mut()) {
// Add flattened properties
for (key, value) in content_props_to_add {
properties.insert(key, value);
}
// Remove the content field itself
properties.remove(content_key);
}
// Update required array
if let Some(required_array) = obj.get_mut("required").and_then(|r| r.as_array_mut()) {
// Remove content_key from required
required_array.retain(|v| v.as_str() != Some(content_key));
// Add content's required fields
for field in content_required {
if !required_array.contains(&field) {
required_array.push(field);
}
}
}
// Update description to note the transformation
if let Some(desc) = obj.get("description").and_then(|d| d.as_str()) {
let new_desc = format!("{} (flattened for Gemini compatibility)", desc);
obj.insert("description".to_string(), Value::String(new_desc));
}
Value::Object(obj)
}
fn normalize_adjacently_tagged_variants(variants: &mut Vec<Value>) {
// First, check if this looks like an adjacently tagged enum.
// All variants should have the same tag/content keys.
let mut adjacently_tagged_info: Option<(String, String)> = None;
let mut all_adjacently_tagged = true;
for variant in variants.iter() {
if let Some((tag_key, content_key, _tag_value)) = detect_adjacently_tagged_variant(variant)
{
if let Some((ref existing_tag, ref existing_content)) = adjacently_tagged_info {
if tag_key != *existing_tag || content_key != *existing_content {
all_adjacently_tagged = false;
break;
}
} else {
adjacently_tagged_info = Some((tag_key, content_key));
}
} else {
// Unit variant (only tag, no content) is still okay.
if let Some(variant_obj) = variant.as_object()
&& let Some(props) = variant_obj.get("properties").and_then(|p| p.as_object())
&& props.len() == 1
&& variant_obj
.get("required")
.and_then(|r| r.as_array())
.map(|a| a.len())
== Some(1)
{
continue;
}
all_adjacently_tagged = false;
break;
}
}
if all_adjacently_tagged && adjacently_tagged_info.is_some() {
*variants = variants
.iter()
.map(|variant| {
if let Some((t, c, v)) = detect_adjacently_tagged_variant(variant) {
transform_adjacently_tagged_to_internally_tagged(variant, &t, &c, &v)
} else {
variant.clone()
}
})
.collect();
}
}
/// Internal function that strips unsupported keywords after refs are resolved.
fn strip_gemini_unsupported_keywords_recursive(schema: &mut Value) {
if let Some(obj) = schema.as_object_mut() {
// Remove unsupported keywords
obj.remove("examples");
obj.remove("title");
obj.remove("$schema");
obj.remove("$id");
obj.remove("default");
obj.remove("$defs");
obj.remove("definitions");
debug_assert!(
!obj.contains_key("$ref"),
"all references must be resolved before Gemini keyword stripping"
);
// `x-enum-keys` is a rstructor hint, not part of Gemini's schema dialect.
obj.remove("x-enum-keys");
// Recursively process nested schemas
if let Some(properties) = obj.get_mut("properties")
&& let Some(props_obj) = properties.as_object_mut()
{
for prop_schema in props_obj.values_mut() {
strip_gemini_unsupported_keywords_recursive(prop_schema);
}
}
// Process 'items' for arrays
if let Some(items) = obj.get_mut("items") {
strip_gemini_unsupported_keywords_recursive(items);
}
// Handle tuples (prefixItems) - Gemini doesn't support prefixItems
// Convert to a regular array with oneOf for the item types
if let Some(prefix_items) = obj.remove("prefixItems")
&& let Some(arr) = prefix_items.as_array()
{
// Recursively process each item schema
let mut processed_items: Vec<Value> = arr
.iter()
.map(|item| {
let mut item_clone = item.clone();
strip_gemini_unsupported_keywords_recursive(&mut item_clone);
item_clone
})
.collect();
// Remove duplicates for cleaner schema
processed_items.dedup();
// If all items are the same type, use single items schema
if processed_items.len() == 1 {
obj.insert(
"items".to_string(),
processed_items.into_iter().next().unwrap(),
);
} else {
// Use anyOf for mixed types
obj.insert(
"items".to_string(),
serde_json::json!({
"anyOf": processed_items
}),
);
}
// Remove minItems/maxItems since they're not strictly enforced without prefixItems
obj.remove("minItems");
obj.remove("maxItems");
// Add description about tuple structure
let existing_desc = obj
.get("description")
.and_then(|d| d.as_str())
.map(|s| format!("{}. ", s))
.unwrap_or_default();
let tuple_len = arr.len();
obj.insert(
"description".to_string(),
Value::String(format!(
"{}Fixed-length array (tuple) with {} elements",
existing_desc, tuple_len
)),
);
}
// Process 'allOf' array
if let Some(all_of) = obj.get_mut("allOf")
&& let Some(arr) = all_of.as_array_mut()
{
for item in arr.iter_mut() {
strip_gemini_unsupported_keywords_recursive(item);
}
}
// Process enum disjunction arrays and normalize adjacently tagged variants.
for key in ["anyOf", "oneOf"] {
if let Some(disjunction) = obj.get_mut(key)
&& let Some(variants) = disjunction.as_array_mut()
{
normalize_adjacently_tagged_variants(variants);
for variant in variants.iter_mut() {
strip_gemini_unsupported_keywords_recursive(variant);
}
}
}
// Handle additionalProperties if it's a schema object (for maps) - recurse into it
if let Some(additional) = obj.get_mut("additionalProperties")
&& additional.is_object()
{
strip_gemini_unsupported_keywords_recursive(additional);
}
}
}
/// JSON Schema format specification for structured outputs.
///
/// This struct is used by OpenAI and Grok (and potentially other OpenAI-compatible APIs)
/// for their native structured outputs feature.
#[derive(Debug, Serialize)]
pub struct JsonSchemaFormat {
/// Name of the schema (usually the type name)
pub name: String,
/// Optional description of the schema
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// The JSON schema itself
pub schema: Value,
/// Whether to use strict mode (required for structured outputs)
pub strict: bool,
}
/// Response format for structured outputs (OpenAI-compatible).
#[derive(Debug, Serialize)]
#[serde(tag = "type")]
pub enum ResponseFormat {
/// JSON Schema structured output format
#[serde(rename = "json_schema")]
JsonSchema {
/// The JSON schema specification
json_schema: JsonSchemaFormat,
},
}
impl ResponseFormat {
/// Create a new JSON schema response format for structured outputs.
///
/// # Arguments
///
/// * `name` - The name of the schema (usually the type name)
/// * `schema` - The JSON schema for the expected output
/// * `description` - Optional description of what the output should contain
pub fn json_schema(name: String, schema: Value, description: Option<String>) -> Self {
ResponseFormat::JsonSchema {
json_schema: JsonSchemaFormat {
name,
description,
schema,
strict: true,
},
}
}
}
/// Parse a raw JSON response and validate it against the Instructor trait.
///
/// This function handles:
/// 1. JSON parsing with detailed error messages
/// 2. Custom validation via the Instructor trait
///
/// # Arguments
///
/// * `raw_response` - The raw JSON string from the LLM
///
/// # Returns
///
/// The parsed and validated data, or an error with validation context
pub fn parse_and_validate_response<T>(
raw_response: &str,
) -> std::result::Result<T, (RStructorError, Option<ValidationFailureContext>)>
where
T: Instructor + DeserializeOwned,
{
// Parse the JSON content into our target type
let result: T = match crate::decode::output_from_str(raw_response) {
Ok(parsed) => parsed,
Err(error) => {
let error_msg = error.to_string();
error!(error = %error, "Structured output decoding failed");
return Err((
error,
Some(ValidationFailureContext::new(
error_msg,
raw_response.to_string(),
)),
));
}
};
// Apply any custom validation (business logic beyond schema)
if let Err(e) = result.validate() {
error!(error = ?e, "Custom validation failed");
let error_msg = e.to_string();
return Err((
e,
Some(ValidationFailureContext::new(
error_msg,
raw_response.to_string(),
)),
));
}
Ok(result)
}
/// Helper to create a successful MaterializeInternalOutput from parsed data.
///
/// This is a convenience function that combines parsing, validation, and
/// output construction in one step.
///
/// # Arguments
///
/// * `raw_response` - The raw JSON string from the LLM
/// * `usage` - Optional token usage information
///
/// # Returns
///
/// A MaterializeInternalOutput with the parsed data, or an error
pub fn parse_validate_and_create_output<T>(
raw_response: String,
usage: Option<TokenUsage>,
) -> std::result::Result<MaterializeInternalOutput<T>, MaterializeAttemptError>
where
T: Instructor + DeserializeOwned,
{
match parse_and_validate_response::<T>(&raw_response) {
Ok(result) => {
info!("Successfully generated and validated structured data");
Ok(MaterializeInternalOutput::new(result, raw_response, usage))
}
Err((error, Some(context))) => {
Err(MaterializeAttemptError::semantic(error, context, usage))
}
Err((error, None)) => Err(MaterializeAttemptError::transport_with_usage(error, usage)),
}
}
/// Convert a reqwest error to a RStructorError, handling timeout errors specially.
///
/// Request timeouts become [`RStructorError::Timeout`]. Other transport errors
/// become [`RStructorError::HttpError`]; transient connection failures
/// (connection refused/reset, DNS errors) are classified as retryable by
/// [`RStructorError::is_retryable`], while body/decode errors are not.
pub fn handle_http_error(e: reqwest::Error, provider_name: &str) -> RStructorError {
error!(error = %e, "HTTP request to {} failed", provider_name);
if e.is_timeout() {
RStructorError::Timeout
} else {
RStructorError::HttpError(e)
}
}
/// Classify an error returned while sending a materialization request.
///
/// Reqwest builder errors happen before a request can reach the network, so
/// they are preflight failures rather than provider attempts.
pub(crate) fn materialize_request_error(
error: reqwest::Error,
provider_name: &str,
) -> MaterializeAttemptError {
let is_builder_error = error.is_builder();
let error = handle_http_error(error, provider_name);
if is_builder_error {
MaterializeAttemptError::preflight(error)
} else {
MaterializeAttemptError::transport(error)
}
}
/// Parse retry-after header value to Duration.
fn parse_retry_after(value: &str) -> Option<Duration> {
// Try parsing as seconds (most common)
if let Ok(secs) = value.parse::<u64>() {
return Some(Duration::from_secs(secs));
}
// Could also parse HTTP-date format, but seconds is most common
None
}
/// Classify an API error based on HTTP status code and response body.
fn classify_api_error(
status: reqwest::StatusCode,
error_text: &str,
retry_after: Option<Duration>,
model_hint: Option<&str>,
) -> ApiErrorKind {
let code = status.as_u16();
let error_lower = error_text.to_lowercase();
match code {
// Authentication errors
401 => ApiErrorKind::AuthenticationFailed,
// Permission errors
403 => ApiErrorKind::PermissionDenied,
// Not found - check if it's a model error
404 => {
// Check if the error message mentions "model"
if error_lower.contains("model") {
let model = model_hint
.map(|s| s.to_string())
.or_else(|| extract_model_from_error(&error_lower))
.unwrap_or_else(|| "unknown".to_string());
ApiErrorKind::InvalidModel {
model,
suggestion: suggest_model(&error_lower),
}
} else {
ApiErrorKind::Other {
code,
message: error_text.to_string(),
}
}
}
// Bad request
400 => ApiErrorKind::BadRequest {
details: truncate_message(error_text, 200),
},
// Payload too large
413 => ApiErrorKind::RequestTooLarge,
// Rate limited
429 => ApiErrorKind::RateLimited { retry_after },
// Server errors
500 | 502 => ApiErrorKind::ServerError { code },
// Service unavailable
503 => ApiErrorKind::ServiceUnavailable,
// Gateway/Cloudflare errors
520..=524 => ApiErrorKind::GatewayError { code },
// Other errors
_ => ApiErrorKind::Other {
code,
message: truncate_message(error_text, 500),
},
}
}
/// Extract model name from error message if present.
fn extract_model_from_error(error_text: &str) -> Option<String> {
// Look for quoted model names like 'gpt-4' or "gpt-4"
for quote in ['\'', '"'] {
if let Some(start) = error_text.find(quote) {
let rest = &error_text[start + 1..];
if let Some(end) = rest.find(quote) {
let candidate = &rest[..end];
// Model names typically have alphanumeric chars, dots, or dashes
if candidate.len() > 2
&& candidate
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '.' || c == '_')
{
return Some(candidate.to_string());
}
}
}
}
None
}
/// Suggest an alternative model based on error context.
fn suggest_model(error_text: &str) -> Option<String> {
// Common model name patterns and their suggestions
if error_text.contains("gpt") {
Some("gpt-5.6-sol".to_string())
} else if error_text.contains("claude") || error_text.contains("sonnet") {
Some("claude-opus-5".to_string())
} else if error_text.contains("grok") {
Some("grok-4.5".to_string())
} else if error_text.contains("gemini") {
Some("gemini-3.6-flash".to_string())
} else {
None
}
}
/// Truncate a message to a maximum length.
///
/// Uses `floor_char_boundary` to ensure we don't slice in the middle of a
/// multi-byte UTF-8 character, which would cause a panic.
fn truncate_message(msg: &str, max_len: usize) -> String {
if msg.len() <= max_len {
msg.to_string()
} else {
// Find a valid UTF-8 character boundary at or before max_len
let boundary = msg.floor_char_boundary(max_len);
format!("{}...", &msg[..boundary])
}
}
/// Check HTTP response status and extract error message if unsuccessful.
///
/// This function classifies errors into actionable types (rate limit, auth failure, etc.)
/// and provides user-friendly error messages with suggested actions.
pub async fn check_response_status(response: Response, provider_name: &str) -> Result<Response> {
if !response.status().is_success() {
let status = response.status();
// Extract retry-after header if present
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(parse_retry_after);
let error_text = response.text().await?;
let kind = classify_api_error(status, &error_text, retry_after, None);
error!(
status = %status,
error = %error_text,
kind = %kind,
"{} API returned error response", provider_name
);
return Err(RStructorError::api_error(provider_name, kind));
}
Ok(response)
}
/// Builds the user-role feedback message sent back to the LLM when a response
/// fails schema or custom validation (the re-ask prompt).
///
/// The array guidance deliberately avoids any minimum-count instruction.
/// Telling the model to include "at least N" items induces fabrication: a
/// model that correctly extracted one item (or zero) from the source material
/// would be instructed to invent more. Instead, the prompt requires arrays to
/// contain only entries actually supported by the input — a single-item or
/// empty array is valid when that is all the data supports.
fn validation_retry_feedback(error_message: &str) -> String {
format!(
"Your previous response contained validation errors. Please provide a complete, valid JSON response that includes ALL required fields and follows the schema exactly.\n\nError details:\n{}\n\nPlease fix the issues in your response. Make sure to:\n1. Include ALL required fields exactly as specified in the schema\n2. For enum fields, use EXACTLY one of the allowed values from the description\n3. CRITICAL: For arrays where items.type = 'object':\n - You MUST provide an array of OBJECTS, not strings or primitive values\n - Each object must be a complete JSON object with all its required fields\n4. Arrays must contain ONLY items actually supported by the input: an array may have a single item or be empty if that is what the data supports — NEVER invent entries to pad an array\n5. Verify all nested objects have their complete structure\n6. Follow ALL type specifications (string, number, boolean, array, object)",
error_message
)
}
/// Helper function to execute generation with retry logic using conversation history.
///
/// This function maintains a conversation history across retry attempts, which enables:
/// - **Prompt caching**: Providers like Anthropic and OpenAI can cache the prefix of the
/// conversation, reducing token costs and latency on retries.
/// - **Better error correction**: The model sees its previous (failed) response and the
/// specific error, making it more likely to produce a correct response.
///
/// # How it works
///
/// 1. On first attempt: Sends `[User(prompt)]`
/// 2. On validation failure: Appends `[Assistant(failed_response), User(error_feedback)]`
/// 3. On retry: Sends the full conversation history
///
/// This approach preserves the original prompt exactly, maximizing cache hit rates.
///
/// # Arguments
///
/// * `generate_fn` - Function that takes a conversation history and returns the result plus raw response
/// * `prompt` - The initial user prompt
/// * `max_retries` - Maximum number of retry attempts (None or 0 means no retries)
pub async fn generate_with_retry_with_history<F, Fut, T>(
generate_fn: F,
prompt: &str,
max_retries: Option<usize>,
) -> Result<MaterializeInternalOutput<T>>
where
F: FnMut(Vec<ChatMessage>) -> Fut,
Fut: std::future::Future<
Output = std::result::Result<MaterializeInternalOutput<T>, MaterializeAttemptError>,
>,
{
generate_with_retry_with_initial_messages(
generate_fn,
vec![ChatMessage::user(prompt)],
max_retries,
)
.await
}
/// Helper function to execute generation with retry logic using a custom initial
/// conversation history.
///
/// This is primarily used for multimodal prompts where the initial user message
/// may contain attached media in addition to text.
pub async fn generate_with_retry_with_initial_messages<F, Fut, T>(
generate_fn: F,
initial_messages: Vec<ChatMessage>,
max_retries: Option<usize>,
) -> Result<MaterializeInternalOutput<T>>
where
F: FnMut(Vec<ChatMessage>) -> Fut,
Fut: std::future::Future<
Output = std::result::Result<MaterializeInternalOutput<T>, MaterializeAttemptError>,
>,
{
let successful_run = run_materialize_attempts(generate_fn, initial_messages, max_retries)
.await
.map_err(MaterializeFailure::into_error)?;
Ok(MaterializeInternalOutput::new(
successful_run.report.data,
successful_run.raw_response,
successful_run.report.final_usage,
))
}
/// Execute structured generation and retain the complete retry ledger.
///
/// Unlike [`generate_with_retry_with_history`], exhaustion returns a
/// [`MaterializeFailure`] containing cumulative usage and every failed attempt.
pub async fn generate_with_retry_attempts_with_history<F, Fut, T>(
generate_fn: F,
prompt: &str,
max_retries: Option<usize>,
) -> std::result::Result<MaterializeReport<T>, MaterializeFailure>
where
F: FnMut(Vec<ChatMessage>) -> Fut,
Fut: std::future::Future<
Output = std::result::Result<MaterializeInternalOutput<T>, MaterializeAttemptError>,
>,
{
run_materialize_attempts(generate_fn, vec![ChatMessage::user(prompt)], max_retries)
.await
.map(|success| success.report)
}
/// Execute structured generation from a caller-supplied initial conversation
/// and retain the complete retry ledger.
pub async fn generate_with_retry_attempts_with_initial_messages<F, Fut, T>(
generate_fn: F,
initial_messages: Vec<ChatMessage>,
max_retries: Option<usize>,
) -> std::result::Result<MaterializeReport<T>, MaterializeFailure>
where
F: FnMut(Vec<ChatMessage>) -> Fut,
Fut: std::future::Future<
Output = std::result::Result<MaterializeInternalOutput<T>, MaterializeAttemptError>,
>,
{
run_materialize_attempts(generate_fn, initial_messages, max_retries)
.await
.map(|success| success.report)
}
/// Execute structured generation with media and retain the complete retry ledger.
pub async fn materialize_with_media_and_attempts_with_retry<F, Fut, T>(
generate_fn: F,
prompt: &str,
media: &[crate::backend::client::MediaFile],
max_retries: Option<usize>,
) -> std::result::Result<MaterializeReport<T>, MaterializeFailure>
where
F: FnMut(Vec<ChatMessage>) -> Fut,
Fut: std::future::Future<
Output = std::result::Result<MaterializeInternalOutput<T>, MaterializeAttemptError>,
>,
{
run_materialize_attempts(
generate_fn,
vec![ChatMessage::user_with_media(prompt, media.to_vec())],
max_retries,
)
.await
.map(|success| success.report)
}
struct MaterializeRunSuccess<T> {
report: MaterializeReport<T>,
raw_response: String,
}
async fn run_materialize_attempts<F, Fut, T>(
mut generate_fn: F,
initial_messages: Vec<ChatMessage>,
max_retries: Option<usize>,
) -> std::result::Result<MaterializeRunSuccess<T>, MaterializeFailure>
where
F: FnMut(Vec<ChatMessage>) -> Fut,
Fut: std::future::Future<
Output = std::result::Result<MaterializeInternalOutput<T>, MaterializeAttemptError>,
>,
{
let max_attempts = max_retries.unwrap_or(0).saturating_add(1);
let mut messages = initial_messages;
let mut attempts = Vec::with_capacity(max_attempts.min(16));
let mut cumulative_usage: Option<RunUsage> = None;
trace!(
"Starting structured generation with conversation history: max_attempts={}",
max_attempts
);
for attempt in 0..max_attempts {
// Log attempt information
info!(
attempt = attempt + 1,
total_attempts = max_attempts,
history_len = messages.len(),
"Generation attempt with conversation history"
);
match generate_fn(messages.clone()).await {
Ok(result) => {
let final_usage = result.usage;
if let Some(usage) = final_usage.clone() {
cumulative_usage
.get_or_insert_with(RunUsage::new)
.record(usage);
}
attempts.push(AttemptRecord::succeeded(attempt + 1, final_usage.clone()));
if attempt > 0 {
info!(
attempts_used = attempt + 1,
"Successfully generated after {} retries (with conversation history)",
attempt
);
} else {
debug!("Successfully generated on first attempt");
}
return Ok(MaterializeRunSuccess {
report: MaterializeReport::new(
result.data,
final_usage,
cumulative_usage,
attempts,
),
raw_response: result.raw_response,
});
}
Err(MaterializeAttemptError::Preflight(error)) => {
return Err(MaterializeFailure::new(*error, cumulative_usage, attempts));
}
Err(MaterializeAttemptError::Semantic {
error: attempt_error,
context,
usage,
}) => {
let is_last_attempt = attempt >= max_attempts - 1;
let disposition = if is_last_attempt {
RetryDisposition::BudgetExhausted
} else {
RetryDisposition::Retried
};
if let Some(response_usage) = usage.clone() {
cumulative_usage
.get_or_insert_with(RunUsage::new)
.record(response_usage);
}
attempts.push(AttemptRecord::failed(
attempt + 1,
AttemptKind::Semantic,
&attempt_error,
disposition,
usage,
));
if disposition == RetryDisposition::Retried {
warn!(
attempt = attempt + 1,
error = %context.error_message,
"Validation error in generation attempt"
);
messages.push(ChatMessage::assistant(&context.raw_response));
messages.push(ChatMessage::user(validation_retry_feedback(
&context.error_message,
)));
debug!(
history_len = messages.len(),
"Updated conversation history for retry"
);
sleep(Duration::from_millis(500)).await;
continue;
}
error!(
attempts = max_attempts,
error = %context.error_message,
"Failed after maximum retry attempts with validation errors"
);
return Err(MaterializeFailure::new(
*attempt_error,
cumulative_usage,
attempts,
));
}
Err(MaterializeAttemptError::Transport {
error: attempt_error,
usage,
}) => {
let is_last_attempt = attempt >= max_attempts - 1;
let retryable = attempt_error.is_retryable();
let disposition = if !retryable {
RetryDisposition::NonRetryable
} else if is_last_attempt {
RetryDisposition::BudgetExhausted
} else {
RetryDisposition::Retried
};
if let Some(response_usage) = usage.clone() {
cumulative_usage
.get_or_insert_with(RunUsage::new)
.record(response_usage);
}
attempts.push(AttemptRecord::failed(
attempt + 1,
AttemptKind::Transport,
&attempt_error,
disposition,
usage,
));
if disposition == RetryDisposition::Retried {
let delay = attempt_error
.retry_delay()
.unwrap_or(Duration::from_secs(1));
warn!(
attempt = attempt + 1,
error = ?attempt_error,
delay_ms = delay.as_millis(),
"Retryable API error, waiting before retry"
);
sleep(delay).await;
continue;
}
if is_last_attempt {
error!(
attempts = max_attempts,
error = ?attempt_error,
"Failed after maximum retry attempts"
);
} else {
error!(
error = ?attempt_error,
"Non-retryable error occurred during generation"
);
}
return Err(MaterializeFailure::new(
*attempt_error,
cumulative_usage,
attempts,
));
}
}
}
// This should never be reached due to the returns in the loop
unreachable!()
}
/// Helper for provider implementations of `materialize_with_media`.
///
/// Builds an initial media-bearing user message and runs the shared retry/history flow.
pub async fn materialize_with_media_with_retry<F, Fut, T>(
generate_fn: F,
prompt: &str,
media: &[crate::backend::client::MediaFile],
max_retries: Option<usize>,
) -> Result<T>
where
F: FnMut(Vec<ChatMessage>) -> Fut,
Fut: std::future::Future<
Output = std::result::Result<MaterializeInternalOutput<T>, MaterializeAttemptError>,
>,
{
let initial_messages = vec![ChatMessage::user_with_media(prompt, media.to_vec())];
let output =
generate_with_retry_with_initial_messages(generate_fn, initial_messages, max_retries)
.await?;
Ok(output.data)
}
/// Macro to generate standard builder methods for LLM clients.
///
/// This macro generates `model()`, `temperature()`, `max_tokens()`, and `timeout()` methods
/// that are identical across all LLM client implementations.
#[macro_export]
macro_rules! impl_client_builder_methods {
(
client_type: $client:ty,
config_type: $config:ty,
model_type: $model:ty,
provider_name: $provider:expr
) => {
impl $client {
/// Set the model to use. Accepts either a Model enum variant or a string.
///
/// When a string is provided, it will be converted to a Model enum. If the string
/// matches a known model variant, that variant is used; otherwise, it becomes `Custom(name)`.
/// This allows using any model name, including new models or local LLMs, without needing
/// to update the enum.
#[tracing::instrument(skip(self, model))]
pub fn model<M: Into<$model>>(mut self, model: M) -> Self {
let model = model.into();
tracing::debug!(
previous_model = ?self.config.model,
new_model = ?model,
"Setting {} model", $provider
);
self.config.model = model;
self
}
/// Set the temperature (0.0 to 1.0, lower = more deterministic)
#[tracing::instrument(skip(self))]
pub fn temperature(mut self, temp: f32) -> Self {
tracing::debug!(
previous_temp = self.config.temperature,
new_temp = temp,
"Setting temperature"
);
self.config.temperature = temp;
self
}
/// Set the maximum tokens to generate
#[tracing::instrument(skip(self))]
pub fn max_tokens(mut self, max: u32) -> Self {
tracing::debug!(
previous_max = ?self.config.max_tokens,
new_max = max,
"Setting max_tokens"
);
// Ensure max_tokens is at least 1 to avoid API errors
self.config.max_tokens = Some(max.max(1));
self
}
/// Set the timeout for HTTP requests.
///
/// This sets the total timeout for each HTTP request made by the
/// client (connection + response). The connect phase additionally
/// keeps the default connect timeout of 30 seconds
/// ([`DEFAULT_CONNECT_TIMEOUT`](crate::DEFAULT_CONNECT_TIMEOUT)).
///
/// If not called, requests use the default timeout of 5 minutes
/// ([`DEFAULT_REQUEST_TIMEOUT`](crate::DEFAULT_REQUEST_TIMEOUT)).
///
/// # Arguments
///
/// * `timeout` - Timeout duration (e.g., `Duration::from_secs(30)` for 30 seconds)
#[tracing::instrument(skip(self))]
pub fn timeout(mut self, timeout: std::time::Duration) -> Self {
tracing::debug!(
previous_timeout = ?self.config.timeout,
new_timeout = ?timeout,
"Setting timeout"
);
self.config.timeout = Some(timeout);
// Rebuild reqwest client with the new timeout immediately
self.client = $crate::backend::utils::build_http_client(timeout);
self
}
/// Set the shared materialization retry budget.
///
/// Semantic decode/validation failures re-ask with error feedback.
/// Retryable transport/provider failures retry the same history
/// after their classified delay. Both consume this one budget.
///
/// The default is 3 retries (4 total attempts). Use
/// `.no_retries()` to make exactly one attempt.
///
/// # Arguments
///
/// * `max_retries` - Maximum number of retry attempts (0 = no retries, only single attempt)
///
/// # Examples
///
/// ```no_run
/// # use rstructor::OpenAIClient;
/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
/// let client = OpenAIClient::new("api-key")?
/// .max_retries(5); // Increase to 5 retries (default is 3)
/// # Ok(())
/// # }
/// ```
#[tracing::instrument(skip(self))]
pub fn max_retries(mut self, max_retries: usize) -> Self {
tracing::debug!(
previous_max_retries = ?self.config.max_retries,
new_max_retries = max_retries,
"Setting max_retries"
);
self.config.max_retries = Some(max_retries);
self
}
/// Disable automatic materialization retries.
///
/// By default, the client retries semantic and retryable transport
/// failures up to 3 times. Use this method to stop after the first
/// failed attempt.
///
/// # Examples
///
/// ```no_run
/// # use rstructor::OpenAIClient;
/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
/// let client = OpenAIClient::new("api-key")?
/// .no_retries(); // Stop after the first failed attempt
/// # Ok(())
/// # }
/// ```
#[tracing::instrument(skip(self))]
pub fn no_retries(mut self) -> Self {
tracing::debug!(
previous_max_retries = ?self.config.max_retries,
"Disabling retries"
);
self.config.max_retries = Some(0);
self
}
}
};
}
#[cfg(test)]
mod tests {
use super::*;
type ScriptedAttempts<T> = std::sync::Arc<
std::sync::Mutex<
std::collections::VecDeque<
std::result::Result<MaterializeInternalOutput<T>, MaterializeAttemptError>,
>,
>,
>;
#[test]
fn test_add_additional_properties_simple_object() {
let mut schema = serde_json::json!({
"type": "object",
"properties": {
"name": { "type": "string" }
}
});
add_additional_properties_false(&mut schema);
assert_eq!(schema["additionalProperties"], serde_json::json!(false));
}
#[test]
fn test_add_additional_properties_nested_object() {
let mut schema = serde_json::json!({
"type": "object",
"properties": {
"user": {
"type": "object",
"properties": {
"name": { "type": "string" }
}
}
}
});
add_additional_properties_false(&mut schema);
assert_eq!(schema["additionalProperties"], serde_json::json!(false));
assert_eq!(
schema["properties"]["user"]["additionalProperties"],
serde_json::json!(false)
);
}
#[test]
fn test_add_additional_properties_array_items() {
let mut schema = serde_json::json!({
"type": "object",
"properties": {
"ingredients": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"amount": { "type": "string" }
}
}
}
}
});
add_additional_properties_false(&mut schema);
// Top-level should have additionalProperties: false
assert_eq!(schema["additionalProperties"], serde_json::json!(false));
// Array items object should also have additionalProperties: false
assert_eq!(
schema["properties"]["ingredients"]["items"]["additionalProperties"],
serde_json::json!(false)
);
}
#[test]
fn test_add_additional_properties_deeply_nested() {
let mut schema = serde_json::json!({
"type": "object",
"properties": {
"recipe": {
"type": "object",
"properties": {
"ingredients": {
"type": "array",
"items": {
"type": "object",
"properties": {
"details": {
"type": "object",
"properties": {
"brand": { "type": "string" }
}
}
}
}
}
}
}
}
});
add_additional_properties_false(&mut schema);
// All object levels should have additionalProperties: false
assert_eq!(schema["additionalProperties"], serde_json::json!(false));
assert_eq!(
schema["properties"]["recipe"]["additionalProperties"],
serde_json::json!(false)
);
assert_eq!(
schema["properties"]["recipe"]["properties"]["ingredients"]["items"]["additionalProperties"],
serde_json::json!(false)
);
assert_eq!(
schema["properties"]["recipe"]["properties"]["ingredients"]["items"]["properties"]["details"]
["additionalProperties"],
serde_json::json!(false)
);
}
#[test]
fn test_add_additional_properties_anyof() {
let mut schema = serde_json::json!({
"anyOf": [
{
"type": "object",
"properties": {
"name": { "type": "string" }
}
},
{
"type": "object",
"properties": {
"id": { "type": "number" }
}
}
]
});
add_additional_properties_false(&mut schema);
assert_eq!(
schema["anyOf"][0]["additionalProperties"],
serde_json::json!(false)
);
assert_eq!(
schema["anyOf"][1]["additionalProperties"],
serde_json::json!(false)
);
}
#[test]
fn test_add_additional_properties_definitions() {
let mut schema = serde_json::json!({
"type": "object",
"properties": {
"item": { "$ref": "#/definitions/Item" }
},
"definitions": {
"Item": {
"type": "object",
"properties": {
"name": { "type": "string" }
}
}
}
});
add_additional_properties_false(&mut schema);
assert_eq!(schema["additionalProperties"], serde_json::json!(false));
assert_eq!(
schema["definitions"]["Item"]["additionalProperties"],
serde_json::json!(false)
);
}
#[test]
fn test_add_additional_properties_preserves_existing() {
// If additionalProperties is already set, it should be overwritten
let mut schema = serde_json::json!({
"type": "object",
"properties": {
"name": { "type": "string" }
},
"additionalProperties": true
});
add_additional_properties_false(&mut schema);
assert_eq!(schema["additionalProperties"], serde_json::json!(false));
}
#[test]
fn test_add_additional_properties_no_type() {
// Object with properties but no explicit type should still get additionalProperties: false
let mut schema = serde_json::json!({
"properties": {
"name": { "type": "string" }
}
});
add_additional_properties_false(&mut schema);
assert_eq!(schema["additionalProperties"], serde_json::json!(false));
}
#[test]
fn test_adds_required_array() {
// Schema without required array should get one added with all property keys.
// With no original `required`, ALL properties are treated as optional and
// therefore become nullable.
let mut schema = serde_json::json!({
"type": "object",
"properties": {
"name": { "type": "string" },
"age": { "type": "number" }
}
});
add_additional_properties_false(&mut schema);
let required = schema["required"]
.as_array()
.expect("required should be an array");
assert_eq!(required.len(), 2);
assert!(required.contains(&serde_json::json!("name")));
assert!(required.contains(&serde_json::json!("age")));
// Absent original `required` -> every property admits null.
assert_eq!(
schema["properties"]["name"]["type"],
serde_json::json!(["string", "null"])
);
assert_eq!(
schema["properties"]["age"]["type"],
serde_json::json!(["number", "null"])
);
}
#[test]
fn test_overrides_existing_required_array() {
// Schema with existing required array should be overridden to include all properties
// (OpenAI strict mode requires ALL properties in required, even optional ones).
// Properties absent from the ORIGINAL required array must become nullable so
// the model is not forced to fabricate values for optional fields.
let mut schema = serde_json::json!({
"type": "object",
"properties": {
"name": { "type": "string" },
"age": { "type": "number" }
},
"required": ["name"]
});
add_additional_properties_false(&mut schema);
let required = schema["required"]
.as_array()
.expect("required should be an array");
// Now it should include ALL properties, not just the original
assert_eq!(required.len(), 2);
assert!(required.contains(&serde_json::json!("name")));
assert!(required.contains(&serde_json::json!("age")));
// Originally-required field keeps its scalar type.
assert_eq!(schema["properties"]["name"]["type"], "string");
// Originally-optional field becomes nullable.
assert_eq!(
schema["properties"]["age"]["type"],
serde_json::json!(["number", "null"])
);
}
#[test]
fn test_strict_mode_optional_fields_become_nullable() {
// Derive-shaped schema: only `a` is truly required. After strict
// preparation `b` and `c` must admit null (type AND enum) while `a`
// stays a plain string, and `required` lists every key.
let mut schema = serde_json::json!({
"type": "object",
"properties": {
"a": { "type": "string" },
"b": { "type": "integer" },
"c": { "type": "string", "enum": ["x", "y"] }
},
"required": ["a"]
});
add_additional_properties_false(&mut schema);
// `a` was originally required: untouched scalar type.
assert_eq!(schema["properties"]["a"]["type"], "string");
// `b` was optional: type becomes a ["integer", "null"] union.
assert_eq!(
schema["properties"]["b"]["type"],
serde_json::json!(["integer", "null"])
);
// `c` was optional with an enum: null is added to BOTH type and enum
// (enum constrains values independently of type).
assert_eq!(
schema["properties"]["c"]["type"],
serde_json::json!(["string", "null"])
);
assert_eq!(
schema["properties"]["c"]["enum"],
serde_json::json!(["x", "y", null])
);
// `required` still lists ALL keys, as strict mode demands.
let required = schema["required"]
.as_array()
.expect("required should be an array");
assert_eq!(required.len(), 3);
for key in ["a", "b", "c"] {
assert!(required.contains(&serde_json::json!(key)));
}
}
#[test]
fn test_strict_mode_optional_nested_object_and_array() {
// Optionality is resolved per object level: the outer object's optional
// `meta` (a nested object) and `tags` (an array) become nullable, while
// the nested object applies its OWN original `required` to its fields.
let mut schema = serde_json::json!({
"type": "object",
"properties": {
"title": { "type": "string" },
"meta": {
"type": "object",
"properties": {
"author": { "type": "string" },
"year": { "type": "integer" }
},
"required": ["author"]
},
"tags": {
"type": "array",
"items": {
"type": "object",
"properties": {
"label": { "type": "string" },
"weight": { "type": "number" }
},
"required": ["label"]
}
}
},
"required": ["title"]
});
add_additional_properties_false(&mut schema);
// Required outer field untouched.
assert_eq!(schema["properties"]["title"]["type"], "string");
// Optional nested object becomes nullable but is still fully prepared
// for strict mode (additionalProperties: false, all keys required).
let meta = &schema["properties"]["meta"];
assert_eq!(meta["type"], serde_json::json!(["object", "null"]));
assert_eq!(meta["additionalProperties"], serde_json::json!(false));
let meta_required = meta["required"].as_array().expect("meta required");
assert_eq!(meta_required.len(), 2);
// Inside `meta`, only `year` was optional (per meta's own original
// required array), so `author` stays scalar and `year` admits null.
assert_eq!(meta["properties"]["author"]["type"], "string");
assert_eq!(
meta["properties"]["year"]["type"],
serde_json::json!(["integer", "null"])
);
// Optional array becomes nullable; its item objects are prepared
// independently with their own original required arrays.
let tags = &schema["properties"]["tags"];
assert_eq!(tags["type"], serde_json::json!(["array", "null"]));
let items = &tags["items"];
assert_eq!(items["additionalProperties"], serde_json::json!(false));
assert_eq!(items["properties"]["label"]["type"], "string");
assert_eq!(
items["properties"]["weight"]["type"],
serde_json::json!(["number", "null"])
);
let items_required = items["required"].as_array().expect("items required");
assert_eq!(items_required.len(), 2);
}
#[test]
fn test_strict_mode_optional_type_union_appends_null_once() {
// A property whose type is already a union gets "null" appended; one
// that already admits null is left alone (no duplicate "null").
let mut schema = serde_json::json!({
"type": "object",
"properties": {
"mixed": { "type": ["string", "integer"] },
"already_nullable": { "type": ["string", "null"] }
},
"required": []
});
add_additional_properties_false(&mut schema);
assert_eq!(
schema["properties"]["mixed"]["type"],
serde_json::json!(["string", "integer", "null"])
);
assert_eq!(
schema["properties"]["already_nullable"]["type"],
serde_json::json!(["string", "null"])
);
}
#[test]
fn test_strict_mode_optional_anyof_gets_null_branch() {
// Optional union (anyOf) properties get a {"type": "null"} branch; a
// union that already has one is left unchanged.
let mut schema = serde_json::json!({
"type": "object",
"properties": {
"choice": {
"anyOf": [
{ "type": "string" },
{ "type": "integer" }
]
},
"already_nullable": {
"anyOf": [
{ "type": "string" },
{ "type": "null" }
]
}
},
"required": []
});
add_additional_properties_false(&mut schema);
let choice_any_of = schema["properties"]["choice"]["anyOf"]
.as_array()
.expect("anyOf should be an array");
assert_eq!(choice_any_of.len(), 3);
assert_eq!(choice_any_of[2], serde_json::json!({ "type": "null" }));
let nullable_any_of = schema["properties"]["already_nullable"]["anyOf"]
.as_array()
.expect("anyOf should be an array");
assert_eq!(nullable_any_of.len(), 2, "no duplicate null branch");
}
#[test]
fn test_strict_mode_optional_oneof_gets_null_branch() {
let mut schema = serde_json::json!({
"type": "object",
"properties": {
"variant": {
"oneOf": [
{ "type": "object", "properties": { "a": { "type": "string" } }, "required": ["a"] }
]
}
},
"required": []
});
add_additional_properties_false(&mut schema);
let one_of = schema["properties"]["variant"]["oneOf"]
.as_array()
.expect("oneOf should be an array");
assert_eq!(one_of.len(), 2);
assert_eq!(one_of[1], serde_json::json!({ "type": "null" }));
// The original object branch is still strict-prepared.
assert_eq!(one_of[0]["additionalProperties"], serde_json::json!(false));
}
#[test]
fn test_strict_mode_optional_bare_ref_wrapped_in_anyof() {
// The derive macro emits a bare $ref for self-referential struct fields
// (e.g. `next: Option<Box<Node>>`). An optional $ref is wrapped in an
// anyOf union with an explicit null branch.
let mut schema = serde_json::json!({
"type": "object",
"properties": {
"value": { "type": "integer" },
"next": { "$ref": "#/$defs/Node" }
},
"required": ["value"],
"$defs": {
"Node": {
"type": "object",
"properties": {
"value": { "type": "integer" }
},
"required": ["value"]
}
}
});
add_additional_properties_false(&mut schema);
assert_eq!(
schema["properties"]["next"],
serde_json::json!({
"anyOf": [
{ "$ref": "#/$defs/Node" },
{ "type": "null" }
]
})
);
// Required (non-optional) refs would be left alone; the definition
// itself is still strict-prepared.
assert_eq!(
schema["$defs"]["Node"]["additionalProperties"],
serde_json::json!(false)
);
}
#[test]
fn test_strict_mode_absent_required_treats_all_optional() {
// When `required` is absent entirely, every property is optional and
// must admit null.
let mut schema = serde_json::json!({
"type": "object",
"properties": {
"a": { "type": "string" },
"b": { "type": "boolean" }
}
});
add_additional_properties_false(&mut schema);
assert_eq!(
schema["properties"]["a"]["type"],
serde_json::json!(["string", "null"])
);
assert_eq!(
schema["properties"]["b"]["type"],
serde_json::json!(["boolean", "null"])
);
let required = schema["required"].as_array().expect("required array");
assert_eq!(required.len(), 2);
}
#[test]
fn test_strict_mode_optional_null_passes_serde_into_option() {
// End-to-end sanity: a strict-prepared schema admits null for optional
// fields, and serde deserializes that null into Option::None.
#[derive(serde::Deserialize)]
struct Person {
name: String,
nickname: Option<String>,
}
let mut schema = serde_json::json!({
"type": "object",
"properties": {
"name": { "type": "string" },
"nickname": { "type": "string" }
},
"required": ["name"]
});
add_additional_properties_false(&mut schema);
// The schema now permits null for `nickname`...
assert_eq!(
schema["properties"]["nickname"]["type"],
serde_json::json!(["string", "null"])
);
// ...and serde maps that null to None.
let parsed: Person = serde_json::from_str(r#"{"name": "Ada", "nickname": null}"#).unwrap();
assert_eq!(parsed.name, "Ada");
assert!(parsed.nickname.is_none());
}
#[test]
fn test_adds_required_array_to_nested_objects() {
// Nested objects should also get required arrays
let mut schema = serde_json::json!({
"type": "object",
"properties": {
"steps": {
"type": "array",
"items": {
"type": "object",
"properties": {
"number": { "type": "integer" },
"description": { "type": "string" }
}
}
}
}
});
add_additional_properties_false(&mut schema);
// Top-level should have required
let required = schema["required"]
.as_array()
.expect("required should be an array");
assert!(required.contains(&serde_json::json!("steps")));
// Nested array items should also have required
let nested_required = schema["properties"]["steps"]["items"]["required"]
.as_array()
.expect("nested required should be an array");
assert!(nested_required.contains(&serde_json::json!("number")));
assert!(nested_required.contains(&serde_json::json!("description")));
}
#[test]
fn truncate_message_ascii_within_limit() {
let msg = "Hello, world!";
assert_eq!(truncate_message(msg, 20), "Hello, world!");
}
#[test]
fn truncate_message_ascii_exact_limit() {
let msg = "Hello";
assert_eq!(truncate_message(msg, 5), "Hello");
}
#[test]
fn truncate_message_ascii_exceeds_limit() {
let msg = "Hello, world!";
assert_eq!(truncate_message(msg, 5), "Hello...");
}
#[test]
fn truncate_message_utf8_within_limit() {
let msg = "你好世界"; // 12 bytes (3 bytes per character)
assert_eq!(truncate_message(msg, 20), "你好世界");
}
#[test]
fn truncate_message_utf8_boundary_safe() {
// "你好世界" is 12 bytes total (3 bytes per character)
// Truncating at 5 bytes would fall in the middle of the second character
// floor_char_boundary(5) should return 3 (end of first character)
let msg = "你好世界";
let result = truncate_message(msg, 5);
assert_eq!(result, "你...");
}
#[test]
fn truncate_message_utf8_exact_boundary() {
// Truncating at exactly 6 bytes should include first two characters
let msg = "你好世界";
let result = truncate_message(msg, 6);
assert_eq!(result, "你好...");
}
#[test]
fn truncate_message_emoji() {
// Emojis are typically 4 bytes each
let msg = "🎉🎊🎈";
// Truncating at 5 bytes falls in the middle of second emoji
// floor_char_boundary(5) should return 4 (end of first emoji)
let result = truncate_message(msg, 5);
assert_eq!(result, "🎉...");
}
#[test]
fn truncate_message_mixed_utf8() {
let msg = "Error: 无效的请求";
// "Error: " is 7 bytes, then Chinese characters are 3 bytes each
// Truncating at 10 bytes falls at the boundary after the first Chinese char
// floor_char_boundary(10) should return 10 (end of first Chinese char after "Error: ")
let result = truncate_message(msg, 10);
assert_eq!(result, "Error: 无...");
}
#[test]
fn truncate_message_empty_string() {
let msg = "";
assert_eq!(truncate_message(msg, 10), "");
}
#[test]
fn truncate_message_zero_limit() {
let msg = "Hello";
// floor_char_boundary(0) returns 0, so we get just "..."
assert_eq!(truncate_message(msg, 0), "...");
}
#[test]
fn test_gemini_schema_strips_unsupported_keywords() {
use crate::schema::Schema;
// Create a schema with examples and other unsupported keywords
let schema = Schema::new(serde_json::json!({
"type": "object",
"title": "Movie",
"properties": {
"title": { "type": "string", "description": "Movie title" },
"year": { "type": "integer", "description": "Release year" }
},
"examples": [{
"title": "The Matrix",
"year": 1999
}]
}));
let gemini_schema = prepare_gemini_schema(&schema, "test output").expect("acyclic schema");
// Verify examples is stripped
assert!(
gemini_schema.get("examples").is_none(),
"examples should be stripped from Gemini schema"
);
// Verify title is stripped (Gemini doesn't support it)
assert!(
gemini_schema.get("title").is_none(),
"title should be stripped from Gemini schema"
);
// Verify the basic schema structure is preserved
assert_eq!(gemini_schema["type"], "object");
assert!(gemini_schema["properties"]["title"].is_object());
assert!(gemini_schema["properties"]["year"].is_object());
}
#[test]
fn test_gemini_schema_strips_nested_examples() {
use crate::schema::Schema;
// Create a schema with nested objects that have examples
let schema = Schema::new(serde_json::json!({
"type": "object",
"properties": {
"recipe_name": { "type": "string" },
"ingredients": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"amount": { "type": "number" }
},
"examples": [{
"name": "flour",
"amount": 2.5
}]
}
}
},
"examples": [{
"recipe_name": "Cookies",
"ingredients": []
}]
}));
let gemini_schema = prepare_gemini_schema(&schema, "test output").expect("acyclic schema");
// Verify examples is stripped at root
assert!(
gemini_schema.get("examples").is_none(),
"root examples should be stripped"
);
// Verify examples is stripped from array items (nested object)
assert!(
gemini_schema["properties"]["ingredients"]["items"]
.get("examples")
.is_none(),
"nested examples should be stripped"
);
}
#[test]
fn test_gemini_schema_preserves_closed_object_constraint() {
let mut schema_json = serde_json::json!({
"type": "object",
"properties": {
"name": { "type": "string" }
},
"additionalProperties": false
});
strip_gemini_unsupported_keywords(&mut schema_json, "test output")
.expect("reference-free schema");
assert_eq!(schema_json["additionalProperties"], false);
}
#[test]
fn test_gemini_schema_strips_title_and_schema() {
let mut schema_json = serde_json::json!({
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Movie",
"type": "object",
"properties": {
"name": {
"title": "MovieName",
"type": "string"
}
}
});
strip_gemini_unsupported_keywords(&mut schema_json, "test output")
.expect("reference-free schema");
assert!(
schema_json.get("$schema").is_none(),
"$schema should be stripped"
);
assert!(
schema_json.get("title").is_none(),
"title should be stripped"
);
assert!(
schema_json["properties"]["name"].get("title").is_none(),
"nested title should be stripped"
);
}
#[test]
fn test_extract_adjacently_tagged_info_anyof() {
let schema = serde_json::json!({
"anyOf": [
{
"type": "object",
"properties": {
"status": { "type": "string", "enum": ["Success"] },
"data": {
"type": "object",
"properties": {
"output": { "type": "string" }
},
"required": ["output"]
}
},
"required": ["status", "data"]
},
{
"type": "object",
"properties": {
"status": { "type": "string", "enum": ["Failure"] },
"data": {
"type": "object",
"properties": {
"reason": { "type": "string" }
},
"required": ["reason"]
}
},
"required": ["status", "data"]
}
]
});
let info = extract_adjacently_tagged_info(&schema).expect("should detect anyOf enum info");
assert_eq!(info.tag_key, "status");
assert_eq!(info.content_key, "data");
assert_eq!(info.tag_values.len(), 2);
assert!(info.tag_values.contains(&"Success".to_string()));
assert!(info.tag_values.contains(&"Failure".to_string()));
}
#[test]
fn test_gemini_anyof_adjacently_tagged_variants_are_flattened() {
let mut schema = serde_json::json!({
"anyOf": [
{
"type": "object",
"properties": {
"status": { "type": "string", "enum": ["Success"] },
"data": {
"type": "object",
"properties": {
"output": { "type": "string" }
},
"required": ["output"]
}
},
"required": ["status", "data"],
"description": "Success variant"
},
{
"type": "object",
"properties": {
"status": { "type": "string", "enum": ["Failure"] },
"data": {
"type": "object",
"properties": {
"reason": { "type": "string" }
},
"required": ["reason"]
}
},
"required": ["status", "data"],
"description": "Failure variant"
}
]
});
strip_gemini_unsupported_keywords_recursive(&mut schema);
let first_props = schema["anyOf"][0]["properties"]
.as_object()
.expect("properties should be object");
assert!(first_props.contains_key("status"));
assert!(first_props.contains_key("output"));
assert!(!first_props.contains_key("data"));
let first_required = schema["anyOf"][0]["required"]
.as_array()
.expect("required should be array");
assert!(first_required.contains(&serde_json::json!("status")));
assert!(first_required.contains(&serde_json::json!("output")));
assert!(!first_required.contains(&serde_json::json!("data")));
}
#[test]
fn test_gemini_map_preserves_typed_additional_properties() {
let mut schema = serde_json::json!({
"type": "object",
"properties": {
"counts": {
"type": "object",
"additionalProperties": { "type": "integer" },
"description": "Map using enum keys. Keys: [info, warn, error]",
"x-enum-keys": ["info", "warn", "error"]
}
}
});
strip_gemini_unsupported_keywords_recursive(&mut schema);
let counts = &schema["properties"]["counts"];
assert_eq!(counts["type"], "object");
assert_eq!(counts["additionalProperties"]["type"], "integer");
assert!(counts.get("properties").is_none());
assert!(
counts.get("x-enum-keys").is_none(),
"x-enum-keys should be stripped from final schema"
);
}
#[test]
fn test_gemini_map_description_does_not_change_map_shape() {
let mut schema = serde_json::json!({
"type": "object",
"properties": {
"counts": {
"type": "object",
"additionalProperties": { "type": "integer" },
"description": "Keys: [alpha, beta, gamma]"
}
}
});
strip_gemini_unsupported_keywords_recursive(&mut schema);
let counts = &schema["properties"]["counts"];
assert_eq!(counts["description"], "Keys: [alpha, beta, gamma]");
assert_eq!(counts["additionalProperties"]["type"], "integer");
assert!(counts.get("properties").is_none());
}
#[test]
fn test_gemini_x_enum_keys_stripped_from_non_map_schema() {
let mut schema = serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"x-enum-keys": ["a", "b"]
}
}
});
strip_gemini_unsupported_keywords_recursive(&mut schema);
assert!(
schema["properties"]["name"].get("x-enum-keys").is_none(),
"x-enum-keys should be stripped from non-map schemas"
);
}
#[tokio::test]
async fn test_generate_with_retry_with_initial_messages_preserves_media() {
let initial = vec![ChatMessage::user_with_media(
"describe image",
vec![crate::backend::client::MediaFile::from_bytes(
b"hello-image",
"image/png",
)],
)];
let output = generate_with_retry_with_initial_messages(
|messages: Vec<ChatMessage>| async move {
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].media.len(), 1);
Ok(MaterializeInternalOutput::new(
"ok".to_string(),
"{\"ok\":true}".to_string(),
None,
))
},
initial,
Some(0),
)
.await
.expect("generation should succeed");
assert_eq!(output.data, "ok");
}
#[tokio::test]
async fn test_generate_with_retry_with_initial_messages_adds_feedback_history() {
let initial = vec![ChatMessage::user_with_media(
"describe image",
vec![crate::backend::client::MediaFile::from_bytes(
b"hello-image",
"image/png",
)],
)];
let mut attempts = 0usize;
let output = generate_with_retry_with_initial_messages(
|messages: Vec<ChatMessage>| {
attempts += 1;
async move {
if attempts == 1 {
Err(MaterializeAttemptError::semantic(
RStructorError::ValidationError("schema validation failed".to_string()),
ValidationFailureContext::new(
"missing required field: summary",
"{\"subject\":\"rust\"}",
),
None,
))
} else {
assert_eq!(messages.len(), 3);
assert_eq!(messages[0].media.len(), 1);
assert_eq!(messages[1].role, crate::backend::ChatRole::Assistant);
assert_eq!(messages[2].role, crate::backend::ChatRole::User);
Ok(MaterializeInternalOutput::new(
"ok".to_string(),
"{\"ok\":true}".to_string(),
None,
))
}
}
},
initial,
Some(1),
)
.await
.expect("generation should succeed after retry");
assert_eq!(attempts, 2);
assert_eq!(output.data, "ok");
}
#[tokio::test]
async fn retry_history_preserves_the_system_and_user_prefix_exactly() {
let initial = vec![
ChatMessage::system("Use the fund's reporting policy."),
ChatMessage::user("Summarize today's risk."),
];
let mut attempts = 0usize;
let output = generate_with_retry_with_initial_messages(
|messages: Vec<ChatMessage>| {
attempts += 1;
async move {
assert_eq!(
messages[0].role,
crate::backend::ChatRole::System,
"the stable system prefix must remain first"
);
assert_eq!(messages[0].content, "Use the fund's reporting policy.");
assert_eq!(messages[1].role, crate::backend::ChatRole::User);
assert_eq!(messages[1].content, "Summarize today's risk.");
if attempts == 1 {
Err(MaterializeAttemptError::semantic(
RStructorError::ValidationError("invalid risk status".to_string()),
ValidationFailureContext::new(
"status must be snake_case",
r#"{"status":"Within Limits"}"#,
),
None,
))
} else {
assert_eq!(messages.len(), 4);
assert_eq!(messages[2].role, crate::backend::ChatRole::Assistant);
assert_eq!(messages[3].role, crate::backend::ChatRole::User);
Ok(MaterializeInternalOutput::new(
"within_limits".to_string(),
r#"{"status":"within_limits"}"#.to_string(),
None,
))
}
}
},
initial,
Some(1),
)
.await
.expect("generation should succeed after one correction");
assert_eq!(attempts, 2);
assert_eq!(output.data, "within_limits");
}
#[tokio::test]
async fn test_reask_triggers_on_non_validation_error_variant() {
// A custom validator may return any `RStructorError` variant. As long as a
// `ValidationFailureContext` is present, the reask loop must retry with
// feedback rather than failing immediately. (Previously the loop only
// reasked on the `ValidationError` variant, so a validator returning, e.g.,
// `SchemaError` silently disabled retries.)
let mut attempts = 0usize;
let output = generate_with_retry_with_initial_messages(
|messages: Vec<ChatMessage>| {
attempts += 1;
async move {
if attempts == 1 {
Err(MaterializeAttemptError::semantic(
// NOT a ValidationError on purpose.
RStructorError::SchemaError("custom validator rejected".to_string()),
ValidationFailureContext::new("value out of range", "{\"n\":-1}"),
None,
))
} else {
// Reask appended the failed response + feedback to history.
assert_eq!(messages.len(), 3);
assert_eq!(messages[1].role, crate::backend::ChatRole::Assistant);
assert_eq!(messages[2].role, crate::backend::ChatRole::User);
Ok(MaterializeInternalOutput::new(
"ok".to_string(),
"{\"ok\":true}".to_string(),
None,
))
}
}
},
vec![ChatMessage::user("validate this")],
Some(1),
)
.await
.expect("non-ValidationError validator failure should still reask");
assert_eq!(attempts, 2);
assert_eq!(output.data, "ok");
}
#[test]
fn test_validation_retry_feedback_does_not_induce_array_fabrication() {
let feedback = validation_retry_feedback("missing required field: summary");
// The actual validation error is embedded for the model to act on.
assert!(feedback.contains("missing required field: summary"));
// The prompt previously instructed "Include multiple items (at least
// 2-3) in arrays of objects", which told a model that correctly
// extracted one item (or zero) to fabricate more. No minimum-count
// language may appear.
assert!(!feedback.contains("at least 2"));
assert!(!feedback.contains("2-3"));
assert!(!feedback.contains("multiple items"));
// Instead, arrays must stay faithful to the source material.
assert!(feedback.contains("ONLY items actually supported by the input"));
assert!(feedback.contains("may have a single item or be empty"));
assert!(feedback.contains("NEVER invent entries to pad an array"));
// The rest of the prompt's intent is preserved.
assert!(feedback.contains("ALL required fields"));
assert!(feedback.contains("EXACTLY one of the allowed values"));
assert!(feedback.contains("array of OBJECTS, not strings or primitive values"));
assert!(feedback.contains("type specifications (string, number, boolean, array, object)"));
}
// ===================================================================
// error/retry: classify_api_error status matrix
// ===================================================================
fn status(code: u16) -> reqwest::StatusCode {
reqwest::StatusCode::from_u16(code).expect("valid status code")
}
#[test]
fn classify_api_error_400_bad_request() {
let kind = classify_api_error(status(400), "malformed body", None, None);
match kind {
ApiErrorKind::BadRequest { details } => assert_eq!(details, "malformed body"),
other => panic!("expected BadRequest, got {:?}", other),
}
}
#[test]
fn classify_api_error_401_authentication_failed() {
assert_eq!(
classify_api_error(status(401), "bad key", None, None),
ApiErrorKind::AuthenticationFailed
);
}
#[test]
fn classify_api_error_403_permission_denied() {
assert_eq!(
classify_api_error(status(403), "no access", None, None),
ApiErrorKind::PermissionDenied
);
}
#[test]
fn classify_api_error_413_request_too_large() {
assert_eq!(
classify_api_error(status(413), "too big", None, None),
ApiErrorKind::RequestTooLarge
);
}
#[test]
fn classify_api_error_429_rate_limited_carries_retry_after() {
let kind = classify_api_error(status(429), "slow down", Some(Duration::from_secs(7)), None);
assert_eq!(
kind,
ApiErrorKind::RateLimited {
retry_after: Some(Duration::from_secs(7))
}
);
}
#[test]
fn classify_api_error_500_and_502_server_error_with_code() {
assert_eq!(
classify_api_error(status(500), "boom", None, None),
ApiErrorKind::ServerError { code: 500 }
);
assert_eq!(
classify_api_error(status(502), "bad gateway", None, None),
ApiErrorKind::ServerError { code: 502 }
);
}
#[test]
fn classify_api_error_503_service_unavailable() {
assert_eq!(
classify_api_error(status(503), "down", None, None),
ApiErrorKind::ServiceUnavailable
);
}
#[test]
fn classify_api_error_520_to_524_gateway_error_with_code() {
for code in [520u16, 521, 522, 523, 524] {
assert_eq!(
classify_api_error(status(code), "cf error", None, None),
ApiErrorKind::GatewayError { code },
"code {} should map to GatewayError",
code
);
}
}
#[test]
fn classify_api_error_out_of_range_codes_fall_into_other() {
// 519 and 525 are just outside the 520..=524 gateway band; 418 is a teapot.
for code in [418u16, 519, 525] {
match classify_api_error(status(code), "weird", None, None) {
ApiErrorKind::Other { code: c, message } => {
assert_eq!(c, code);
assert_eq!(message, "weird");
}
other => panic!("code {} expected Other, got {:?}", code, other),
}
}
}
#[test]
fn classify_api_error_404_without_model_is_other() {
match classify_api_error(status(404), "endpoint not found", None, None) {
ApiErrorKind::Other { code, message } => {
assert_eq!(code, 404);
assert_eq!(message, "endpoint not found");
}
other => panic!("expected Other, got {:?}", other),
}
}
#[test]
fn classify_api_error_400_details_truncated_to_203_chars() {
// truncate_message keeps `max_len` bytes (200) then appends "..." -> 203 chars.
let body = "x".repeat(300);
match classify_api_error(status(400), &body, None, None) {
ApiErrorKind::BadRequest { details } => {
assert_eq!(details.len(), 203);
assert!(details.ends_with("..."));
}
other => panic!("expected BadRequest, got {:?}", other),
}
}
#[test]
fn classify_api_error_other_message_truncated_to_503_chars() {
// The catch-all `Other` arm truncates to 500 bytes + "..." -> 503 chars.
let body = "y".repeat(800);
match classify_api_error(status(418), &body, None, None) {
ApiErrorKind::Other { message, .. } => {
assert_eq!(message.len(), 503);
assert!(message.ends_with("..."));
}
other => panic!("expected Other, got {:?}", other),
}
}
// ===================================================================
// error/retry: 404 InvalidModel hint precedence + extract/suggest
// ===================================================================
#[test]
fn classify_api_error_404_model_hint_takes_precedence_over_text() {
// Both a hint and an extractable name are present; the hint wins.
let kind = classify_api_error(
status(404),
"unknown model 'gpt-4o-mini'",
None,
Some("override-model"),
);
match kind {
ApiErrorKind::InvalidModel { model, suggestion } => {
assert_eq!(model, "override-model");
// "gpt" appears in the (lowercased) error text -> gpt suggestion.
assert_eq!(suggestion, Some("gpt-5.6-sol".to_string()));
}
other => panic!("expected InvalidModel, got {:?}", other),
}
}
#[test]
fn classify_api_error_404_extracts_model_from_text_when_no_hint() {
let kind = classify_api_error(
status(404),
"the model \"gemini-2.0-flash\" does not exist",
None,
None,
);
match kind {
ApiErrorKind::InvalidModel { model, suggestion } => {
assert_eq!(model, "gemini-2.0-flash");
assert_eq!(suggestion, Some("gemini-3.6-flash".to_string()));
}
other => panic!("expected InvalidModel, got {:?}", other),
}
}
#[test]
fn classify_api_error_404_unknown_model_falls_back_to_unknown() {
// Mentions "model" so it's InvalidModel, but no hint and no extractable
// quoted token -> "unknown".
let kind = classify_api_error(status(404), "no such model available", None, None);
match kind {
ApiErrorKind::InvalidModel { model, suggestion } => {
assert_eq!(model, "unknown");
assert_eq!(suggestion, None);
}
other => panic!("expected InvalidModel, got {:?}", other),
}
}
#[test]
fn extract_model_from_error_rejects_short_tokens() {
// A quoted token of length <= 2 is rejected (e.g. an empty pair or "ab").
assert_eq!(extract_model_from_error("no model named ''"), None);
assert_eq!(extract_model_from_error("model 'ab' missing"), None);
// Length 3 with valid chars passes.
assert_eq!(
extract_model_from_error("model 'abc' missing"),
Some("abc".to_string())
);
}
#[test]
fn extract_model_from_error_single_quotes_and_invalid_chars() {
assert_eq!(
extract_model_from_error("missing model 'gpt-4o'"),
Some("gpt-4o".to_string())
);
// A quoted token containing spaces is not a valid model name.
assert_eq!(extract_model_from_error("the 'big model' is gone"), None);
// No quotes at all -> None.
assert_eq!(extract_model_from_error("model gpt-4o missing"), None);
}
#[test]
fn suggest_model_matches_provider_keywords() {
assert_eq!(
suggest_model("sonnet is down"),
Some("claude-opus-5".to_string())
);
assert_eq!(
suggest_model("claude unavailable"),
Some("claude-opus-5".to_string())
);
assert_eq!(
suggest_model("gpt is gone"),
Some("gpt-5.6-sol".to_string())
);
assert_eq!(
suggest_model("grok unavailable"),
Some("grok-4.5".to_string())
);
assert_eq!(
suggest_model("gemini missing"),
Some("gemini-3.6-flash".to_string())
);
assert_eq!(suggest_model("totally unknown provider"), None);
}
// ===================================================================
// error/retry: parse_retry_after
// ===================================================================
#[test]
fn parse_retry_after_integer_seconds() {
assert_eq!(parse_retry_after("5"), Some(Duration::from_secs(5)));
}
#[test]
fn parse_retry_after_zero() {
assert_eq!(parse_retry_after("0"), Some(Duration::from_secs(0)));
}
#[test]
fn parse_retry_after_http_date_returns_none() {
// HTTP-date format is not parsed (only integer seconds are supported).
assert_eq!(parse_retry_after("Wed, 21 Oct 2015 07:28:00 GMT"), None);
}
#[test]
fn parse_retry_after_empty_returns_none() {
assert_eq!(parse_retry_after(""), None);
}
#[test]
fn parse_retry_after_negative_returns_none() {
// u64 parse fails for negatives.
assert_eq!(parse_retry_after("-1"), None);
}
#[test]
fn parse_retry_after_float_returns_none() {
// u64 parse fails for floats.
assert_eq!(parse_retry_after("1.5"), None);
}
// ===================================================================
// error/retry: retry_delay / is_retryable matrix
// ===================================================================
#[test]
fn is_retryable_matrix() {
assert!(ApiErrorKind::RateLimited { retry_after: None }.is_retryable());
assert!(ApiErrorKind::ServiceUnavailable.is_retryable());
assert!(ApiErrorKind::GatewayError { code: 521 }.is_retryable());
assert!(ApiErrorKind::ServerError { code: 500 }.is_retryable());
assert!(!ApiErrorKind::AuthenticationFailed.is_retryable());
assert!(!ApiErrorKind::PermissionDenied.is_retryable());
assert!(!ApiErrorKind::RequestTooLarge.is_retryable());
assert!(
!ApiErrorKind::BadRequest {
details: "x".into()
}
.is_retryable()
);
assert!(
!ApiErrorKind::InvalidModel {
model: "m".into(),
suggestion: None
}
.is_retryable()
);
assert!(
!ApiErrorKind::Other {
code: 418,
message: "teapot".into()
}
.is_retryable()
);
assert!(
!ApiErrorKind::UnexpectedResponse {
details: "x".into()
}
.is_retryable()
);
}
#[test]
fn retry_delay_exact_default_durations() {
assert_eq!(
ApiErrorKind::RateLimited { retry_after: None }.retry_delay(),
Some(Duration::from_secs(5))
);
assert_eq!(
ApiErrorKind::RateLimited {
retry_after: Some(Duration::from_secs(30))
}
.retry_delay(),
Some(Duration::from_secs(30))
);
assert_eq!(
ApiErrorKind::ServiceUnavailable.retry_delay(),
Some(Duration::from_secs(2))
);
assert_eq!(
ApiErrorKind::GatewayError { code: 521 }.retry_delay(),
Some(Duration::from_secs(1))
);
assert_eq!(
ApiErrorKind::ServerError { code: 500 }.retry_delay(),
Some(Duration::from_secs(2))
);
}
#[test]
fn retry_delay_none_for_non_retryable() {
assert_eq!(ApiErrorKind::AuthenticationFailed.retry_delay(), None);
assert_eq!(
ApiErrorKind::Other {
code: 418,
message: "teapot".into()
}
.retry_delay(),
None
);
// RStructor-level: ValidationError and Unsupported are never retryable.
assert_eq!(
RStructorError::ValidationError("bad".into()).retry_delay(),
None
);
assert!(!RStructorError::Unsupported("nope".into()).is_retryable());
assert_eq!(
RStructorError::Unsupported("nope".into()).retry_delay(),
None
);
}
// ===================================================================
// HTTP client defaults & transient connection error classification
// ===================================================================
#[tokio::test]
async fn connection_refused_error_is_retryable() {
// Bind to an ephemeral port, then drop the listener so a connection
// to that port is deterministically refused. No external network.
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
let addr = listener.local_addr().expect("local_addr");
drop(listener);
let client = build_http_client(Duration::from_secs(5));
let err = client
.get(format!("http://{addr}/"))
.send()
.await
.expect_err("request to a closed port must fail");
assert!(
err.is_connect(),
"expected a connection error, got: {err:?}"
);
let converted = handle_http_error(err, "Test");
assert!(
matches!(converted, RStructorError::HttpError(_)),
"connection errors should remain HttpError, got: {converted:?}"
);
assert!(
converted.is_retryable(),
"connection-refused errors must be retryable"
);
assert_eq!(converted.retry_delay(), Some(Duration::from_secs(1)));
}
#[tokio::test]
async fn request_timeout_applies_and_maps_to_timeout_error() {
// A listener that accepts connections (kernel backlog) but never
// responds: the TCP handshake completes, no HTTP response arrives,
// so the total request timeout configured on the client must fire.
// This verifies build_http_client actually applies the timeout.
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
let addr = listener.local_addr().expect("local_addr");
let client = build_http_client(Duration::from_millis(200));
let err = client
.get(format!("http://{addr}/"))
.send()
.await
.expect_err("request to an unresponsive server must time out");
assert!(err.is_timeout(), "expected a timeout error, got: {err:?}");
let converted = handle_http_error(err, "Test");
assert_eq!(converted, RStructorError::Timeout);
assert!(converted.is_retryable());
drop(listener);
}
#[test]
fn non_transient_http_error_is_not_retryable() {
// A reqwest error that is neither a timeout nor a connection failure
// (here: an invalid URL at build time) must remain non-retryable.
let err = reqwest::Client::new()
.get("not a valid url")
.build()
.expect_err("invalid URL must fail to build");
assert!(!err.is_connect() && !err.is_timeout());
let converted = handle_http_error(err, "Test");
assert!(matches!(converted, RStructorError::HttpError(_)));
assert!(!converted.is_retryable());
assert_eq!(converted.retry_delay(), None);
}
// ===================================================================
// error/retry: generate_with_retry_with_initial_messages loop behavior
// ===================================================================
#[tokio::test]
async fn retry_loop_exhaustion_returns_last_error() {
// Two failing attempts (Some(1) -> max_attempts == 2). Each returns a
// distinct ValidationError WITH a ValidationFailureContext, so the loop
// reasks and runs again. On the final attempt it still fails, and the
// result must equal the LAST error; the closure is invoked exactly twice.
let mut attempts = 0usize;
let result = generate_with_retry_with_initial_messages::<_, _, String>(
|_messages: Vec<ChatMessage>| {
attempts += 1;
async move {
Err(MaterializeAttemptError::semantic(
RStructorError::ValidationError(format!("error #{}", attempts)),
ValidationFailureContext::new(
format!("context #{}", attempts),
"{\"partial\":true}",
),
None,
))
}
},
vec![ChatMessage::user("hi")],
Some(1),
)
.await;
assert_eq!(attempts, 2);
match result {
Err(RStructorError::ValidationError(msg)) => assert_eq!(msg, "error #2"),
other => panic!("expected last ValidationError, got {:?}", other),
}
}
#[tokio::test]
async fn retry_loop_retryable_then_exhaustion_returns_last_retryable() {
// Both attempts return a retryable RateLimited error with retry_after=0
// (so the sleep is a no-op). Some(1) -> exactly 2 attempts, final result
// is the retryable error from the last attempt.
let mut attempts = 0usize;
let result = generate_with_retry_with_initial_messages::<_, _, String>(
|_messages: Vec<ChatMessage>| {
attempts += 1;
async move {
Err(MaterializeAttemptError::transport(
RStructorError::api_error(
"TestProvider",
ApiErrorKind::RateLimited {
retry_after: Some(Duration::from_secs(0)),
},
),
))
}
},
vec![ChatMessage::user("hi")],
Some(1),
)
.await;
assert_eq!(attempts, 2);
match result {
Err(RStructorError::ApiError { kind, .. }) => assert_eq!(
kind,
ApiErrorKind::RateLimited {
retry_after: Some(Duration::from_secs(0))
}
),
other => panic!("expected RateLimited error, got {:?}", other),
}
}
#[tokio::test]
async fn retry_loop_mixed_retryable_validation_then_success() {
// Some(2) -> max_attempts == 3.
// attempt 0: retryable RateLimited{0} -> NO history change, continue
// attempt 1: ValidationError + context -> appends assistant + user, continue
// attempt 2: Ok -> success
// Asserts attempt count == 3 and that on the final attempt the message
// history is [user, assistant, user] (the 429 added nothing; validation
// appended exactly two messages).
let mut attempts = 0usize;
let output = generate_with_retry_with_initial_messages(
|messages: Vec<ChatMessage>| {
attempts += 1;
async move {
match attempts {
1 => Err(MaterializeAttemptError::transport(
RStructorError::api_error(
"TestProvider",
ApiErrorKind::RateLimited {
retry_after: Some(Duration::from_secs(0)),
},
),
)),
2 => {
// 429 must not have grown the history.
assert_eq!(
messages.len(),
1,
"retryable error should not modify conversation history"
);
assert_eq!(messages[0].role, crate::backend::ChatRole::User);
Err(MaterializeAttemptError::semantic(
RStructorError::ValidationError("missing field".to_string()),
ValidationFailureContext::new(
"missing required field: name",
"{\"partial\":true}",
),
None,
))
}
_ => {
// Validation reask appended exactly two messages.
assert_eq!(messages.len(), 3);
assert_eq!(messages[0].role, crate::backend::ChatRole::User);
assert_eq!(messages[1].role, crate::backend::ChatRole::Assistant);
assert_eq!(messages[1].content, "{\"partial\":true}");
assert_eq!(messages[2].role, crate::backend::ChatRole::User);
Ok(MaterializeInternalOutput::new(
"done".to_string(),
"{\"ok\":true}".to_string(),
None,
))
}
}
}
},
vec![ChatMessage::user("hi")],
Some(2),
)
.await
.expect("third attempt should succeed");
assert_eq!(attempts, 3);
assert_eq!(output.data, "done");
}
// ===================================================================
// strict/gemini: $ref inlining + depth limit + tuple/strict combos
// ===================================================================
#[test]
fn gemini_ref_inlining_resolves_defs() {
let mut schema = serde_json::json!({
"$ref": "#/$defs/Item",
"$defs": {
"Item": {
"type": "object",
"properties": { "name": { "type": "string" } },
"required": ["name"]
}
}
});
strip_gemini_unsupported_keywords(&mut schema, "test output").expect("acyclic reference");
assert_eq!(schema["type"], "object");
assert_eq!(schema["properties"]["name"]["type"], "string");
assert!(schema.get("$ref").is_none(), "$ref should be inlined");
assert!(schema.get("$defs").is_none(), "$defs should be removed");
}
#[test]
fn gemini_ref_inlining_resolves_definitions_variant() {
// The legacy `#/definitions/` prefix is also supported.
let mut schema = serde_json::json!({
"$ref": "#/definitions/Item",
"definitions": {
"Item": {
"type": "object",
"properties": { "id": { "type": "integer" } },
"required": ["id"]
}
}
});
strip_gemini_unsupported_keywords(&mut schema, "test output")
.expect("acyclic legacy reference");
assert_eq!(schema["type"], "object");
assert_eq!(schema["properties"]["id"]["type"], "integer");
assert!(schema.get("$ref").is_none());
assert!(schema.get("definitions").is_none());
}
#[test]
fn gemini_recursive_ref_is_rejected_instead_of_widened() {
let mut schema = serde_json::json!({
"$ref": "#/$defs/Node",
"$defs": {
"Node": {
"type": "object",
"properties": {
"value": { "type": "integer" },
"child": { "$ref": "#/$defs/Node" }
},
"required": ["value"]
}
}
});
let error = strip_gemini_unsupported_keywords(&mut schema, "file tree output")
.expect_err("recursive schemas must not be widened");
assert!(matches!(
error,
RStructorError::SchemaCompatibilityError {
provider,
context,
path,
message,
} if provider.as_ref() == "Gemini"
&& context.as_ref() == "file tree output"
&& path.as_ref() == "$.properties.child"
&& message.contains("finite-depth expansion")
));
}
#[test]
fn gemini_mutual_recursion_is_rejected_instead_of_widened() {
let mut schema = serde_json::json!({
"$ref": "#/$defs/Fund",
"$defs": {
"Fund": {
"type": "object",
"properties": {
"lei": { "type": "string" },
"prime_broker": { "$ref": "#/$defs/PrimeBroker" }
},
"required": ["lei"]
},
"PrimeBroker": {
"type": "object",
"properties": {
"lei": { "type": "string" },
"funds": {
"type": "array",
"items": { "$ref": "#/$defs/Fund" }
}
},
"required": ["lei", "funds"]
}
}
});
let error = strip_gemini_unsupported_keywords(&mut schema, "fund ownership output")
.expect_err("mutual recursion must not be widened");
assert!(matches!(
error,
RStructorError::SchemaCompatibilityError {
provider,
context,
path,
message,
} if provider.as_ref() == "Gemini"
&& context.as_ref() == "fund ownership output"
&& path.as_ref()
== "$.properties.prime_broker.properties.funds.items"
&& message.contains("#/$defs/Fund")
));
}
#[test]
fn gemini_acyclic_refs_decode_json_pointer_escapes() {
let mut schema = serde_json::json!({
"type": "object",
"properties": {
"slash": {"$ref": "#/$defs/a~1b"},
"tilde": {"$ref": "#/$defs/a~0b"}
},
"$defs": {
"a/b": {
"type": "string",
"description": "slash key"
},
"a~b": {
"type": "integer",
"description": "tilde key"
}
}
});
strip_gemini_unsupported_keywords(&mut schema, "escaped definition output")
.expect("escaped acyclic references must resolve");
assert_eq!(schema["properties"]["slash"]["type"], "string");
assert_eq!(schema["properties"]["tilde"]["type"], "integer");
assert!(schema.get("$defs").is_none());
}
#[test]
fn gemini_ref_siblings_remain_conjunctive() {
let mut schema = serde_json::json!({
"$ref": "#/$defs/CounterpartyCode",
"minLength": 5,
"$defs": {
"CounterpartyCode": {
"type": "string",
"minLength": 10
}
}
});
strip_gemini_unsupported_keywords(&mut schema, "counterparty code output")
.expect("acyclic reference with validation sibling");
let all_of = schema["allOf"]
.as_array()
.expect("$ref siblings must compile as a conjunction");
assert_eq!(all_of.len(), 2);
assert!(
all_of.iter().any(|branch| branch["minLength"] == 10),
"the referenced constraint must be preserved: {schema:#}"
);
assert!(
all_of.iter().any(|branch| branch["minLength"] == 5),
"the sibling constraint must be preserved: {schema:#}"
);
}
#[test]
fn gemini_tuple_same_type_collapses_to_single_items() {
// prefixItems with two identical integer schemas -> dedup'd to a single
// `items` schema; prefixItems / minItems / maxItems are removed.
let mut schema = serde_json::json!({
"type": "array",
"prefixItems": [
{ "type": "integer" },
{ "type": "integer" }
],
"minItems": 2,
"maxItems": 2
});
strip_gemini_unsupported_keywords_recursive(&mut schema);
assert_eq!(schema["items"]["type"], "integer");
assert!(schema.get("prefixItems").is_none());
assert!(schema.get("minItems").is_none());
assert!(schema.get("maxItems").is_none());
assert!(
schema["description"]
.as_str()
.unwrap()
.contains("2 elements")
);
}
#[test]
fn gemini_tuple_mixed_types_uses_anyof() {
// prefixItems with three distinct types -> items.anyOf of length 3.
let mut schema = serde_json::json!({
"type": "array",
"prefixItems": [
{ "type": "integer" },
{ "type": "string" },
{ "type": "boolean" }
],
"minItems": 3,
"maxItems": 3
});
strip_gemini_unsupported_keywords_recursive(&mut schema);
let any_of = schema["items"]["anyOf"]
.as_array()
.expect("items.anyOf should be an array");
assert_eq!(any_of.len(), 3);
assert_eq!(any_of[0]["type"], "integer");
assert_eq!(any_of[1]["type"], "string");
assert_eq!(any_of[2]["type"], "boolean");
assert!(schema.get("prefixItems").is_none());
}
#[test]
fn gemini_tuple_dedup_only_adjacent_duplicates() {
// dedup() only removes ADJACENT duplicates, so [int, string, int] keeps
// all three (the two ints are not adjacent).
let mut schema = serde_json::json!({
"type": "array",
"prefixItems": [
{ "type": "integer" },
{ "type": "string" },
{ "type": "integer" }
]
});
strip_gemini_unsupported_keywords_recursive(&mut schema);
let any_of = schema["items"]["anyOf"]
.as_array()
.expect("items.anyOf should be an array");
assert_eq!(any_of.len(), 3);
}
#[test]
fn strict_then_gemini_combination_preserves_closed_objects_and_required() {
// First run strict mode (adds additionalProperties:false + required at
// every object level), then run Gemini cleanup. Gemini now supports the
// boolean constraint, so both compatibility requirements survive.
let mut schema = serde_json::json!({
"type": "object",
"properties": {
"outer": { "type": "string" },
"nested": {
"type": "object",
"properties": {
"inner": { "type": "string" }
}
}
}
});
add_additional_properties_false(&mut schema);
// Strict added additionalProperties:false at both levels.
assert_eq!(schema["additionalProperties"], serde_json::json!(false));
assert_eq!(
schema["properties"]["nested"]["additionalProperties"],
serde_json::json!(false)
);
strip_gemini_unsupported_keywords(&mut schema, "test output")
.expect("reference-free schema");
assert_eq!(schema["additionalProperties"], serde_json::json!(false));
assert_eq!(
schema["properties"]["nested"]["additionalProperties"],
serde_json::json!(false)
);
// required survives at both levels (strict populated it from properties).
let top_required = schema["required"].as_array().expect("top required");
assert!(top_required.contains(&serde_json::json!("outer")));
assert!(top_required.contains(&serde_json::json!("nested")));
let nested_required = schema["properties"]["nested"]["required"]
.as_array()
.expect("nested required");
assert!(nested_required.contains(&serde_json::json!("inner")));
}
#[test]
fn gemini_map_value_with_nested_keywords_is_recleaned() {
// A map (additionalProperties object) whose value schema itself carries
// unsupported keywords (title/examples). The map shape is preserved while
// its value schema is recursively cleaned.
let mut schema = serde_json::json!({
"type": "object",
"additionalProperties": {
"type": "object",
"title": "Inner",
"examples": [{ "a": 1 }],
"properties": {
"a": { "type": "integer" }
}
},
"x-enum-keys": ["alpha"]
});
strip_gemini_unsupported_keywords_recursive(&mut schema);
let value_schema = &schema["additionalProperties"];
assert_eq!(value_schema["type"], "object");
assert!(
value_schema.get("title").is_none(),
"nested title should be re-cleaned"
);
assert!(
value_schema.get("examples").is_none(),
"nested examples should be re-cleaned"
);
assert_eq!(value_schema["properties"]["a"]["type"], "integer");
assert!(schema.get("properties").is_none());
assert!(schema.get("x-enum-keys").is_none());
}
#[test]
fn gemini_strict_empty_properties_no_required_added() {
// Strict mode on an object with empty `properties` adds
// additionalProperties:false but NOT a `required` key (no keys to list).
let mut schema = serde_json::json!({
"type": "object",
"properties": {}
});
add_additional_properties_false(&mut schema);
assert_eq!(schema["additionalProperties"], serde_json::json!(false));
assert!(
schema.get("required").is_none(),
"empty properties must not produce a required array"
);
}
#[test]
fn gemini_strips_allof_member_keywords() {
// title/examples inside allOf members are recursively stripped.
let mut schema = serde_json::json!({
"allOf": [
{
"type": "object",
"title": "Part",
"examples": [{ "x": 1 }],
"properties": { "x": { "type": "integer" } }
}
]
});
strip_gemini_unsupported_keywords_recursive(&mut schema);
let member = &schema["allOf"][0];
assert!(member.get("title").is_none());
assert!(member.get("examples").is_none());
assert_eq!(member["properties"]["x"]["type"], "integer");
}
#[test]
fn gemini_orphan_ref_without_defs_is_rejected() {
let mut schema = serde_json::json!({
"type": "object",
"properties": {
"x": { "$ref": "#/$defs/Missing" }
}
});
let original = schema.clone();
let error = strip_gemini_unsupported_keywords(&mut schema, "orphan ref output")
.expect_err("orphan references must not be stripped");
assert!(matches!(
error,
RStructorError::SchemaCompatibilityError {
provider,
path,
message,
..
} if provider.as_ref() == "Gemini"
&& path.as_ref() == "$.properties.x"
&& message.contains("does not resolve")
));
assert_eq!(
schema, original,
"failed resolution must leave the canonical schema untouched"
);
}
#[test]
fn gemini_strips_default_and_id() {
let mut schema = serde_json::json!({
"type": "object",
"$id": "https://example.com/schema",
"default": { "name": "x" },
"properties": {
"name": { "type": "string", "default": "anon" }
}
});
strip_gemini_unsupported_keywords(&mut schema, "test output")
.expect("reference-free schema");
assert!(schema.get("$id").is_none(), "$id should be stripped");
assert!(
schema.get("default").is_none(),
"default should be stripped"
);
assert!(
schema["properties"]["name"].get("default").is_none(),
"nested default should be stripped"
);
}
#[test]
fn gemini_map_preserves_additional_properties_true() {
let mut schema = serde_json::json!({
"type": "object",
"additionalProperties": true
});
strip_gemini_unsupported_keywords_recursive(&mut schema);
assert_eq!(schema["additionalProperties"], true);
assert!(
schema.get("properties").is_none(),
"dynamic maps should not synthesize placeholder properties"
);
}
// ===================================================================
// enums-tagged: transform_internally_to_adjacently_tagged and friends
// ===================================================================
fn success_failure_info() -> AdjacentlyTaggedEnumInfo {
AdjacentlyTaggedEnumInfo {
tag_key: "status".to_string(),
content_key: "data".to_string(),
tag_values: vec!["Success".to_string(), "Failure".to_string()],
}
}
#[test]
fn transform_internal_to_adjacent_wraps_data_fields() {
// A matching tag value -> non-tag fields are moved under the content key.
let info = success_failure_info();
let mut json = serde_json::json!({
"status": "Success",
"output": "x",
"tokens_used": 3
});
transform_internally_to_adjacently_tagged(&mut json, &info);
assert_eq!(json["status"], "Success");
assert_eq!(json["data"]["output"], "x");
assert_eq!(json["data"]["tokens_used"], 3);
// The original top-level fields were moved, not left behind.
assert!(json.get("output").is_none());
assert!(json.get("tokens_used").is_none());
}
#[test]
fn transform_internal_to_adjacent_unit_variant_unchanged() {
// A tag-only object (matching tag value) gets no `data` key because there
// are no non-tag fields to move.
let info = AdjacentlyTaggedEnumInfo {
tag_key: "status".to_string(),
content_key: "data".to_string(),
tag_values: vec!["Pending".to_string(), "Success".to_string()],
};
let mut json = serde_json::json!({ "status": "Pending" });
transform_internally_to_adjacently_tagged(&mut json, &info);
assert_eq!(json, serde_json::json!({ "status": "Pending" }));
assert!(json.get("data").is_none());
}
#[test]
fn transform_internal_to_adjacent_non_matching_tag_descends() {
// A "status" whose value isn't in tag_values is NOT treated as an enum
// instance; the transform descends into nested values instead.
let info = success_failure_info();
let mut json = serde_json::json!({
"status": "Other",
"inner": { "status": "Success", "output": "y" }
});
transform_internally_to_adjacently_tagged(&mut json, &info);
// Outer untouched (no data key, fields intact).
assert_eq!(json["status"], "Other");
assert!(json.get("data").is_none());
// Inner matching object wrapped.
assert_eq!(json["inner"]["status"], "Success");
assert_eq!(json["inner"]["data"]["output"], "y");
}
#[test]
fn transform_internal_to_adjacent_recurses_into_arrays() {
// Each element of an array of enum instances is wrapped independently.
let info = success_failure_info();
let mut json = serde_json::json!({
"steps": [
{ "status": "Success", "output": "a" },
{ "status": "Failure", "reason": "b" }
]
});
transform_internally_to_adjacently_tagged(&mut json, &info);
assert_eq!(json["steps"][0]["status"], "Success");
assert_eq!(json["steps"][0]["data"]["output"], "a");
assert_eq!(json["steps"][1]["status"], "Failure");
assert_eq!(json["steps"][1]["data"]["reason"], "b");
}
#[test]
fn transform_internal_to_adjacent_leaves_outer_wrapper_untouched() {
// The outer wrapper object is not an enum instance, but its nested enum
// instance is wrapped.
let info = success_failure_info();
let mut json = serde_json::json!({
"wrapper": { "status": "Success", "output": "a" }
});
transform_internally_to_adjacently_tagged(&mut json, &info);
// Outer wrapper key still present, not converted to a `data` block.
assert!(json["wrapper"].is_object());
assert!(json.get("data").is_none());
assert_eq!(json["wrapper"]["status"], "Success");
assert_eq!(json["wrapper"]["data"]["output"], "a");
}
#[test]
fn extract_adjacently_tagged_info_excludes_primitive_content_variants() {
// detect_adjacently_tagged_variant only recognises content fields that are
// OBJECT types with `properties`. A variant whose content is a primitive
// (integer) is therefore NOT detected, so its tag value is excluded.
let schema = serde_json::json!({
"anyOf": [
{
"type": "object",
"properties": {
"kind": { "type": "string", "enum": ["WithObject"] },
"data": {
"type": "object",
"properties": { "n": { "type": "integer" } },
"required": ["n"]
}
},
"required": ["kind", "data"]
},
{
"type": "object",
"properties": {
"kind": { "type": "string", "enum": ["WithPrimitive"] },
"data": { "type": "integer" }
},
"required": ["kind", "data"]
}
]
});
let info =
extract_adjacently_tagged_info(&schema).expect("object variant should be detected");
assert_eq!(info.tag_key, "kind");
assert_eq!(info.content_key, "data");
// Only the object-content variant is captured; the primitive one is dropped.
assert!(info.tag_values.contains(&"WithObject".to_string()));
assert!(
!info.tag_values.contains(&"WithPrimitive".to_string()),
"primitive-content variant's tag value must be excluded"
);
}
#[test]
fn normalize_adjacently_tagged_flattens_struct_and_leaves_unit_unchanged() {
// A mix of an adjacently tagged struct variant and a unit variant
// (single property + single required). The struct variant is flattened
// (content fields hoisted, content key removed); the unit variant is
// left as-is.
let mut variants = vec![
serde_json::json!({
"type": "object",
"properties": {
"kind": { "type": "string", "enum": ["Full"] },
"data": {
"type": "object",
"properties": { "a": { "type": "integer" } },
"required": ["a"]
}
},
"required": ["kind", "data"]
}),
serde_json::json!({
"type": "object",
"properties": {
"kind": { "type": "string", "enum": ["Empty"] }
},
"required": ["kind"]
}),
];
normalize_adjacently_tagged_variants(&mut variants);
// Struct variant: `data` removed, `a` flattened beside the tag.
let full = &variants[0];
assert!(full["properties"].get("data").is_none());
assert_eq!(full["properties"]["a"]["type"], "integer");
assert!(
full["required"]
.as_array()
.unwrap()
.contains(&serde_json::json!("a"))
);
assert!(
!full["required"]
.as_array()
.unwrap()
.contains(&serde_json::json!("data"))
);
// Unit variant untouched.
let empty = &variants[1];
assert_eq!(empty["properties"]["kind"]["enum"][0], "Empty");
assert!(empty["properties"].get("data").is_none());
}
#[test]
fn normalize_adjacently_tagged_bails_out_on_non_tagged_variants() {
// If the variants don't all match the adjacently tagged pattern (and the
// odd one out isn't a valid unit variant), nothing is transformed.
let original = vec![
serde_json::json!({
"type": "object",
"properties": {
"kind": { "type": "string", "enum": ["Full"] },
"data": {
"type": "object",
"properties": { "a": { "type": "integer" } },
"required": ["a"]
}
},
"required": ["kind", "data"]
}),
// Not adjacently tagged: two arbitrary required fields, no enum tag.
serde_json::json!({
"type": "object",
"properties": {
"x": { "type": "string" },
"y": { "type": "integer" }
},
"required": ["x", "y"]
}),
];
let mut variants = original.clone();
normalize_adjacently_tagged_variants(&mut variants);
// No transformation: the first variant still has its `data` content key.
assert_eq!(variants, original);
assert!(variants[0]["properties"].get("data").is_some());
}
// ===================================================================
// provider: ResponseFormat::json_schema serialization
// ===================================================================
#[test]
fn response_format_json_schema_serialization_omits_none_description() {
let rf = ResponseFormat::json_schema(
"Movie".to_string(),
serde_json::json!({ "type": "object" }),
None,
);
let value = serde_json::to_value(&rf).expect("serialize ResponseFormat");
assert_eq!(value["type"], "json_schema");
assert_eq!(value["json_schema"]["name"], "Movie");
assert_eq!(value["json_schema"]["strict"], serde_json::json!(true));
assert_eq!(value["json_schema"]["schema"]["type"], "object");
// description is None -> key omitted entirely.
assert!(
value["json_schema"].get("description").is_none(),
"description should be omitted when None"
);
}
#[test]
fn response_format_json_schema_serialization_includes_some_description() {
let rf = ResponseFormat::json_schema(
"Movie".to_string(),
serde_json::json!({ "type": "object" }),
Some("A movie".to_string()),
);
let value = serde_json::to_value(&rf).expect("serialize ResponseFormat");
assert_eq!(value["json_schema"]["description"], "A movie");
assert_eq!(value["json_schema"]["strict"], serde_json::json!(true));
}
#[tokio::test]
async fn attempt_ledger_preserves_mixed_retry_order_history_and_usage() {
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
let responses: ScriptedAttempts<String> = Arc::new(Mutex::new(VecDeque::from([
Err(MaterializeAttemptError::semantic(
RStructorError::SchemaError("position quantity was textual".into()),
ValidationFailureContext::new(
"position quantity was textual",
r#"{"portfolio_id":"HF-ALPHA-001","positions":[{"symbol":"ESU6","quantity":"short 240"}]}"#,
),
Some(TokenUsage::new("risk-model-v1", 100, 20)),
)),
Err(MaterializeAttemptError::transport(
RStructorError::api_error(
"OpenAI",
ApiErrorKind::RateLimited {
retry_after: Some(Duration::ZERO),
},
),
)),
Ok(MaterializeInternalOutput::new(
"HF-ALPHA-001".to_string(),
r#"{"portfolio_id":"HF-ALPHA-001","positions":[{"symbol":"ESU6","quantity":-240}]}"#
.to_string(),
Some(TokenUsage::new("risk-model-v2", 140, 30)),
)),
])));
let history_lengths = Arc::new(Mutex::new(Vec::new()));
let report = generate_with_retry_attempts_with_history(
{
let responses = Arc::clone(&responses);
let history_lengths = Arc::clone(&history_lengths);
move |messages| {
history_lengths.lock().unwrap().push(messages.len());
let response = responses.lock().unwrap().pop_front().unwrap();
async move { response }
}
},
"reconcile the futures book",
Some(2),
)
.await
.unwrap();
assert_eq!(report.data, "HF-ALPHA-001");
assert_eq!(*history_lengths.lock().unwrap(), vec![1, 3, 3]);
assert_eq!(report.attempts.len(), 3);
assert_eq!(report.attempts[0].kind, AttemptKind::Semantic);
assert_eq!(report.attempts[1].kind, AttemptKind::Transport);
assert_eq!(report.attempts[2].kind, AttemptKind::Semantic);
assert!(matches!(
report.attempts[0].outcome,
crate::AttemptOutcome::Failed {
disposition: crate::RetryDisposition::Retried,
..
}
));
assert!(matches!(
report.attempts[1].outcome,
crate::AttemptOutcome::Failed {
disposition: crate::RetryDisposition::Retried,
..
}
));
assert_eq!(report.attempts[2].outcome, crate::AttemptOutcome::Succeeded);
let usage = report.cumulative_usage.unwrap();
assert_eq!(usage.reported_attempts, 2);
assert_eq!(usage.total_tokens(), 290);
assert_eq!(usage.by_model["risk-model-v1"].total_tokens(), 120);
assert_eq!(usage.by_model["risk-model-v2"].total_tokens(), 170);
}
#[tokio::test]
async fn semantic_exhaustion_preserves_every_attempt_and_exact_final_error() {
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
let responses: ScriptedAttempts<()> = Arc::new(Mutex::new(VecDeque::from([
Err(MaterializeAttemptError::semantic(
RStructorError::ValidationError("gross exposure too high".into()),
ValidationFailureContext::new(
"gross exposure too high",
r#"{"gross_exposure":2.7}"#,
),
Some(TokenUsage::new("risk-model", 80, 10)),
)),
Err(MaterializeAttemptError::semantic(
RStructorError::SchemaError("missing net exposure".into()),
ValidationFailureContext::new("missing net exposure", r#"{"gross_exposure":1.4}"#),
Some(TokenUsage::new("risk-model", 110, 12)),
)),
])));
let failure = generate_with_retry_attempts_with_history(
{
let responses = Arc::clone(&responses);
move |_| {
let response = responses.lock().unwrap().pop_front().unwrap();
async move { response }
}
},
"calculate exposure",
Some(1),
)
.await
.unwrap_err();
assert!(matches!(
failure.error(),
RStructorError::SchemaError(message) if message == "missing net exposure"
));
assert_eq!(failure.attempts.len(), 2);
assert!(matches!(
failure.attempts[0].outcome,
crate::AttemptOutcome::Failed {
disposition: crate::RetryDisposition::Retried,
..
}
));
assert!(matches!(
failure.attempts[1].outcome,
crate::AttemptOutcome::Failed {
disposition: crate::RetryDisposition::BudgetExhausted,
..
}
));
assert_eq!(
failure.cumulative_usage.as_ref().unwrap().total_tokens(),
212
);
}
#[tokio::test]
async fn preflight_failure_records_no_provider_attempt() {
let failure = generate_with_retry_attempts_with_history(
|_| async {
Err::<MaterializeInternalOutput<()>, _>(MaterializeAttemptError::preflight(
RStructorError::SchemaError("unsupported recursive dialect".into()),
))
},
"extract",
Some(3),
)
.await
.unwrap_err();
assert!(failure.attempts.is_empty());
assert!(failure.cumulative_usage.is_none());
assert!(matches!(failure.error(), RStructorError::SchemaError(_)));
}
#[tokio::test]
async fn zero_retry_budget_records_one_unretried_transport_attempt() {
let failure = generate_with_retry_attempts_with_history(
|_| async {
Err::<MaterializeInternalOutput<()>, _>(MaterializeAttemptError::transport(
RStructorError::Timeout,
))
},
"extract",
Some(0),
)
.await
.unwrap_err();
assert_eq!(failure.attempts.len(), 1);
assert_eq!(failure.attempts[0].number, 1);
assert_eq!(failure.attempts[0].kind, AttemptKind::Transport);
assert!(matches!(
failure.attempts[0].outcome,
crate::AttemptOutcome::Failed {
disposition: crate::RetryDisposition::BudgetExhausted,
..
}
));
}
#[tokio::test]
async fn missing_usage_and_non_retryable_transport_stop_are_accounted_conservatively() {
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
let responses: ScriptedAttempts<()> = Arc::new(Mutex::new(VecDeque::from([
Err(MaterializeAttemptError::semantic(
RStructorError::ValidationError("missing strategy id".into()),
ValidationFailureContext::new("missing strategy id", r#"{"nav":125000000}"#),
None,
)),
Err(MaterializeAttemptError::transport_with_usage(
RStructorError::api_error("OpenAI", ApiErrorKind::AuthenticationFailed),
Some(TokenUsage::new("risk-model", 50, 0)),
)),
])));
let failure = generate_with_retry_attempts_with_history(
{
let responses = Arc::clone(&responses);
move |_| {
let response = responses.lock().unwrap().pop_front().unwrap();
async move { response }
}
},
"extract the NAV",
Some(3),
)
.await
.unwrap_err();
assert_eq!(failure.attempts.len(), 2);
assert!(matches!(
failure.attempts[0].outcome,
crate::AttemptOutcome::Failed {
disposition: crate::RetryDisposition::Retried,
..
}
));
assert!(matches!(
failure.attempts[1].outcome,
crate::AttemptOutcome::Failed {
disposition: crate::RetryDisposition::NonRetryable,
..
}
));
let usage = failure.cumulative_usage.unwrap();
assert_eq!(usage.reported_attempts, 1);
assert_eq!(usage.total_tokens(), 50);
assert!(responses.lock().unwrap().is_empty());
}
}