use futures::StreamExt;
use crate::{Chunk, CompletionRequest, DynProvider, JsonSchema, Message, ToolChoice, ToolSpec};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProbeOutcome {
Supported,
Unsupported,
Errored,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreflightReport {
pub native_tool_calling: ProbeOutcome,
pub structured_output: ProbeOutcome,
pub notes: Vec<String>,
}
impl PreflightReport {
#[must_use]
pub fn ok(&self) -> bool {
self.native_tool_calling == ProbeOutcome::Supported
&& self.structured_output == ProbeOutcome::Supported
}
#[must_use]
pub fn has_unsupported(&self) -> bool {
self.native_tool_calling == ProbeOutcome::Unsupported
|| self.structured_output == ProbeOutcome::Unsupported
}
}
fn probe_schema() -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": { "ok": { "type": "boolean" } },
"required": ["ok"],
"additionalProperties": false,
})
}
pub async fn preflight(provider: &DynProvider, model: &str) -> PreflightReport {
let mut notes = Vec::new();
let native_tool_calling = match check_tool_call(provider, model).await {
Ok(true) => ProbeOutcome::Supported,
Ok(false) => {
notes.push(
"tool-call probe: no tool call emitted under tool_choice=required".to_owned(),
);
ProbeOutcome::Unsupported
}
Err(e) => {
notes.push(format!("tool-call probe errored: {e}"));
ProbeOutcome::Errored
}
};
let structured_output = match check_structured_output(provider, model).await {
Ok(true) => ProbeOutcome::Supported,
Ok(false) => {
notes.push("structured-output probe: reply did not conform to the schema".to_owned());
ProbeOutcome::Unsupported
}
Err(e) => {
notes.push(format!("structured-output probe errored: {e}"));
ProbeOutcome::Errored
}
};
PreflightReport {
native_tool_calling,
structured_output,
notes,
}
}
async fn check_tool_call(provider: &DynProvider, model: &str) -> Result<bool, String> {
let mut req = CompletionRequest::new(model);
req.max_tokens = Some(256);
req.tools = vec![ToolSpec::new(
"preflight_probe",
"A connectivity probe. Call it with ok=true.",
probe_schema(),
)];
req.tool_choice = ToolChoice::Required;
req.messages = vec![Message::user(
"Call the preflight_probe tool with ok set to true.",
)];
let mut stream = provider.complete(req).await.map_err(|e| e.to_string())?;
while let Some(item) = stream.next().await {
let chunk = item.map_err(|e| e.to_string())?;
if matches!(chunk, Chunk::ToolCallStart { .. }) {
return Ok(true);
}
}
Ok(false)
}
async fn check_structured_output(provider: &DynProvider, model: &str) -> Result<bool, String> {
let mut req = CompletionRequest::new(model);
req.max_tokens = Some(256);
req.response_format = Some(JsonSchema(probe_schema()));
req.messages = vec![Message::user(
"Reply with a JSON object that sets \"ok\" to true.",
)];
let mut stream = provider.complete(req).await.map_err(|e| e.to_string())?;
let mut text = String::new();
while let Some(item) = stream.next().await {
if let Chunk::TextDelta(t) = item.map_err(|e| e.to_string())? {
text.push_str(&t);
}
}
Ok(json_matches_probe(&text))
}
fn json_matches_probe(text: &str) -> bool {
let trimmed = strip_code_fence(text.trim());
let Ok(serde_json::Value::Object(map)) = serde_json::from_str::<serde_json::Value>(trimmed)
else {
return false;
};
map.len() == 1 && matches!(map.get("ok"), Some(serde_json::Value::Bool(_)))
}
#[must_use]
pub fn strip_code_fence(s: &str) -> &str {
let s = s
.strip_prefix("```json")
.or_else(|| s.strip_prefix("```"))
.unwrap_or(s);
s.trim().trim_end_matches("```").trim()
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
use crate::{StopReason, Usage, error::DummyError, into_dyn};
use futures::stream;
#[test]
fn json_matches_probe_accepts_plain_and_fenced() {
assert!(json_matches_probe(r#"{"ok": true}"#));
assert!(json_matches_probe("```json\n{\"ok\": false}\n```"));
assert!(json_matches_probe("```\n{\"ok\": true}\n```"));
}
#[test]
fn json_matches_probe_rejects_prose_and_wrong_shape() {
assert!(!json_matches_probe("The sky is blue."));
assert!(!json_matches_probe(r#"{"status": "fine"}"#));
assert!(!json_matches_probe(""));
assert!(!json_matches_probe(r#"{"ok": true, "extra": 1}"#));
assert!(!json_matches_probe(r#"{"ok": "yes"}"#));
}
struct CapableProvider;
#[async_trait::async_trait]
impl crate::LlmProvider for CapableProvider {
type Error = DummyError;
async fn complete(
&self,
req: CompletionRequest,
) -> Result<futures::stream::BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error>
{
let chunks = if req.tool_choice == ToolChoice::Required {
vec![
Ok(Chunk::tool_call_start("c1", "preflight_probe")),
Ok(Chunk::tool_call_args_delta("c1", "{\"ok\":true}")),
Ok(Chunk::tool_call_end("c1")),
Ok(Chunk::Stop(StopReason::ToolUse)),
]
} else {
vec![
Ok(Chunk::text_delta("{\"ok\": true}")),
Ok(Chunk::Usage(Usage {
input_tokens: 1,
output_tokens: 1,
..Default::default()
})),
Ok(Chunk::Stop(StopReason::EndTurn)),
]
};
Ok(stream::iter(chunks).boxed())
}
}
struct DegradedProvider;
#[async_trait::async_trait]
impl crate::LlmProvider for DegradedProvider {
type Error = DummyError;
async fn complete(
&self,
_req: CompletionRequest,
) -> Result<futures::stream::BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error>
{
Ok(stream::iter(vec![
Ok(Chunk::text_delta("The sky is blue.")),
Ok(Chunk::Stop(StopReason::EndTurn)),
])
.boxed())
}
}
struct ErroringProvider;
#[async_trait::async_trait]
impl crate::LlmProvider for ErroringProvider {
type Error = DummyError;
async fn complete(
&self,
_req: CompletionRequest,
) -> Result<futures::stream::BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error>
{
Err(DummyError::Other("connection refused".to_owned()))
}
}
#[tokio::test]
async fn preflight_passes_a_capable_backend() {
let p = into_dyn(CapableProvider);
let report = preflight(&*p, "m").await;
assert!(report.ok(), "{report:?}");
assert!(!report.has_unsupported());
}
#[tokio::test]
async fn preflight_flags_a_degraded_backend_as_unsupported() {
let p = into_dyn(DegradedProvider);
let report = preflight(&*p, "m").await;
assert!(!report.ok());
assert!(
report.has_unsupported(),
"degraded backend is a capability verdict"
);
assert_eq!(report.native_tool_calling, ProbeOutcome::Unsupported);
assert_eq!(report.structured_output, ProbeOutcome::Unsupported);
}
#[tokio::test]
async fn preflight_marks_transport_failure_errored_not_unsupported() {
let p = into_dyn(ErroringProvider);
let report = preflight(&*p, "m").await;
assert!(!report.ok());
assert!(
!report.has_unsupported(),
"transport error is not 'unsupported'"
);
assert_eq!(report.native_tool_calling, ProbeOutcome::Errored);
assert_eq!(report.structured_output, ProbeOutcome::Errored);
}
}