use crate::llm::trace::{emit_agent_event, AgentTraceEvent};
use crate::stdlib::json_stream::{JsonStreamStatus, StreamSchemaValidator};
use crate::value::VmDictExt;
use crate::value::{VmError, VmValue};
pub(crate) use crate::value::SchemaStreamAbort;
use super::options::LlmRequestPayload;
pub(crate) struct StreamSchemaWatch {
validator: StreamSchemaValidator,
provider: String,
model: String,
chunks_consumed: usize,
fired: bool,
}
impl StreamSchemaWatch {
pub(crate) fn from_payload(payload: &LlmRequestPayload) -> Option<Self> {
if !payload.schema_stream_abort {
return None;
}
let schema = payload.output_schema.as_ref()?;
match StreamSchemaValidator::from_json_schema(schema) {
Ok(validator) => Some(Self {
validator,
provider: payload.provider.clone(),
model: payload.model.clone(),
chunks_consumed: 0,
fired: false,
}),
Err(err) => {
crate::events::log_warn(
"llm",
&format!(
"schema_stream_abort: failed to canonicalize output_schema, \
continuing without mid-stream validation: {err}"
),
);
None
}
}
}
pub(crate) fn observe(&mut self, delta: &str) -> Option<SchemaStreamAbort> {
if self.fired || delta.is_empty() {
return None;
}
self.chunks_consumed += 1;
if let JsonStreamStatus::Invalid {
reason_kind,
reason,
path,
} = self.validator.feed(delta)
{
let abort = SchemaStreamAbort {
provider: self.provider.clone(),
model: self.model.clone(),
reason_kind: *reason_kind,
reason: reason.clone(),
path: path.clone(),
chunks_consumed: self.chunks_consumed,
};
self.fired = true;
emit_agent_event(AgentTraceEvent::SchemaStreamAborted {
provider: abort.provider.clone(),
model: abort.model.clone(),
reason_kind: abort.reason_kind.as_str().to_string(),
reason: abort.reason.clone(),
path: abort.path.clone(),
chunks_consumed: abort.chunks_consumed,
});
if let Some(metrics) = crate::active_metrics_registry() {
metrics.record_schema_stream_aborted(&abort.provider, &abort.model);
}
return Some(abort);
}
None
}
}
impl SchemaStreamAbort {
pub(crate) fn into_vm_error(self) -> VmError {
VmError::SchemaStreamAbort(Box::new(self))
}
}
pub(crate) fn parse_schema_stream_abort(err: &VmError) -> Option<SchemaStreamAbort> {
err.schema_stream_abort().cloned()
}
pub(crate) fn aborted_result_value(abort: &SchemaStreamAbort) -> VmValue {
let mut meta = std::collections::BTreeMap::new();
meta.put_str("reason_kind", abort.reason_kind.as_str());
meta.put_str("reason", abort.reason.as_str());
meta.put_str("path", abort.path.as_str());
meta.insert(
"chunks_consumed".to_string(),
VmValue::Int(abort.chunks_consumed as i64),
);
meta.put_str("provider", abort.provider.as_str());
meta.put_str("model", abort.model.as_str());
let mut dict = std::collections::BTreeMap::new();
dict.put_str("text", "");
dict.put_str("model", abort.model.as_str());
dict.put_str("provider", abort.provider.as_str());
dict.insert("input_tokens".to_string(), VmValue::Int(0));
dict.insert("output_tokens".to_string(), VmValue::Int(0));
dict.insert("data".to_string(), VmValue::Nil);
dict.insert("schema_stream_aborted".to_string(), VmValue::dict(meta));
VmValue::dict(dict)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::value::ErrorCategory;
#[test]
fn parses_round_trip_message() {
let original = SchemaStreamAbort {
provider: "openai".to_string(),
model: "gpt-test".to_string(),
reason_kind: crate::value::SchemaValidationReasonKind::WrongType,
reason: "expected type 'int', got JSON string".to_string(),
path: "$.age".to_string(),
chunks_consumed: 3,
};
let err = original.clone().into_vm_error();
let parsed = parse_schema_stream_abort(&err).expect("parses");
assert_eq!(parsed.provider, original.provider);
assert_eq!(parsed.model, original.model);
assert_eq!(parsed.reason, original.reason);
assert_eq!(parsed.path, original.path);
assert_eq!(parsed.chunks_consumed, original.chunks_consumed);
}
#[test]
fn non_abort_error_is_none() {
let err = VmError::CategorizedError {
message: "something else".to_string(),
category: ErrorCategory::Timeout,
};
assert!(parse_schema_stream_abort(&err).is_none());
}
}