use serde_json::Value as JsonValue;
use crate::llm::{execute_llm_call, extract_llm_options, llm_error_message};
use crate::value::{VmError, VmValue};
use super::backend::{DecisionTransportError, RefusalReason};
const MAX_EVALUATION_OUTPUT_TOKENS: i64 = 2048;
pub struct StructuredResponse {
pub data: JsonValue,
pub served_model: Option<String>,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub physical_attempts: u32,
}
pub async fn one_structured_call(
provider: &str,
model: &str,
effort: &str,
prompt: &str,
system: &str,
schema: &JsonValue,
) -> Result<StructuredResponse, DecisionTransportError> {
let (extracted, options) = prepare(provider, model, effort, prompt, system, schema)?;
let response = Box::pin(execute_llm_call(None, extracted, Some(options), None, None))
.await
.map_err(|error| classify(&error))?;
read_response(&response)
}
fn prepare(
provider: &str,
model: &str,
effort: &str,
prompt: &str,
system: &str,
schema: &JsonValue,
) -> Result<(crate::llm::api::LlmCallOptions, crate::value::DictMap), DecisionTransportError> {
let options = crate::schema::json_to_vm_value(&serde_json::json!({
"provider": provider,
"model": model,
"temperature": 0.0,
"effort": effort,
"max_tokens": MAX_EVALUATION_OUTPUT_TOKENS,
"output": {"schema": schema, "strict": true, "validation": "error"},
"schema_retries": 0,
}));
let extracted = extract_llm_options(&[
VmValue::string(prompt),
VmValue::string(system),
options.clone(),
])
.map_err(|error| unsupported_or_failed(&error))?;
let options_dict =
options
.as_dict()
.cloned()
.ok_or_else(|| DecisionTransportError::TransportFailed {
diagnostic: "evaluation options are not a record".into(),
})?;
Ok((extracted, options_dict))
}
fn read_response(response: &VmValue) -> Result<StructuredResponse, DecisionTransportError> {
let fields = response
.as_dict()
.ok_or_else(|| DecisionTransportError::Refused {
reason: RefusalReason::SchemaInvalid,
diagnostic: "evaluation response is not a record".into(),
})?;
let data = fields
.get("data")
.map(crate::llm::helpers::vm_value_to_json)
.ok_or_else(|| DecisionTransportError::Refused {
reason: RefusalReason::SchemaInvalid,
diagnostic: "evaluation response carried no validated data".into(),
})?;
let usage = fields.get("usage").and_then(VmValue::as_dict);
let tokens = |key: &str| {
usage
.and_then(|usage| usage.get(key))
.and_then(VmValue::as_int)
.and_then(|count| u64::try_from(count).ok())
};
Ok(StructuredResponse {
data,
served_model: fields
.get("model")
.map(|model| model.as_str_cow().into_owned()),
input_tokens: tokens("input_tokens"),
output_tokens: tokens("output_tokens"),
physical_attempts: 1,
})
}
fn unsupported_or_failed(error: &VmError) -> DecisionTransportError {
DecisionTransportError::UnsupportedOptions {
diagnostic: llm_error_message(error),
}
}
fn classify(error: &VmError) -> DecisionTransportError {
let message = llm_error_message(error);
let fields = match error {
VmError::Thrown(VmValue::Dict(fields)) => Some(fields.clone()),
_ => None,
};
let field = |key: &str| {
fields
.as_ref()
.and_then(|fields| fields.get(key))
.map(|value| value.as_str_cow().into_owned())
};
let status = fields
.as_ref()
.and_then(|fields| fields.get("status"))
.and_then(VmValue::as_int);
let reason = field("reason").unwrap_or_default();
let category = field("category").unwrap_or_default();
match (status, reason.as_str(), category.as_str()) {
(Some(429), _, _) | (_, "rate_limit", _) | (_, _, "rate_limit") => {
DecisionTransportError::RateLimited {
retry_after_ms: fields
.as_ref()
.and_then(|fields| fields.get("retry_after_ms"))
.and_then(VmValue::as_int)
.and_then(|ms| u64::try_from(ms).ok()),
}
}
(Some(529) | Some(503), _, _) | (_, "overloaded", _) => DecisionTransportError::Overloaded,
(Some(400), _, _) if message.contains("max_tokens_exceeded") => {
DecisionTransportError::StateTooLarge {
provider_reason: message,
limit_tokens: None,
}
}
(_, _, "schema_validation") => DecisionTransportError::Refused {
reason: RefusalReason::SchemaInvalid,
diagnostic: message,
},
(_, "content_filter", _) | (_, "refusal", _) => DecisionTransportError::Refused {
reason: RefusalReason::ProviderRefusal,
diagnostic: message,
},
(_, "max_tokens", _) | (_, "length", _) => DecisionTransportError::Refused {
reason: RefusalReason::OutputTruncated,
diagnostic: message,
},
(_, _, "budget_exceeded") => DecisionTransportError::TransportFailed {
diagnostic: message,
},
_ if message.contains("max_tokens_exceeded") => DecisionTransportError::StateTooLarge {
provider_reason: message,
limit_tokens: None,
},
_ => DecisionTransportError::TransportFailed {
diagnostic: message,
},
}
}