1use futures::StreamExt;
19
20use crate::{Chunk, CompletionRequest, DynProvider, JsonSchema, Message, ToolChoice, ToolSpec};
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum ProbeOutcome {
25 Supported,
27 Unsupported,
31 Errored,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct PreflightReport {
39 pub native_tool_calling: ProbeOutcome,
41 pub structured_output: ProbeOutcome,
43 pub notes: Vec<String>,
45}
46
47impl PreflightReport {
48 #[must_use]
50 pub fn ok(&self) -> bool {
51 self.native_tool_calling == ProbeOutcome::Supported
52 && self.structured_output == ProbeOutcome::Supported
53 }
54
55 #[must_use]
59 pub fn has_unsupported(&self) -> bool {
60 self.native_tool_calling == ProbeOutcome::Unsupported
61 || self.structured_output == ProbeOutcome::Unsupported
62 }
63}
64
65fn probe_schema() -> serde_json::Value {
69 serde_json::json!({
70 "type": "object",
71 "properties": { "ok": { "type": "boolean" } },
72 "required": ["ok"],
73 "additionalProperties": false,
74 })
75}
76
77pub async fn preflight(provider: &DynProvider, model: &str) -> PreflightReport {
82 let mut notes = Vec::new();
83 let native_tool_calling = match check_tool_call(provider, model).await {
84 Ok(true) => ProbeOutcome::Supported,
85 Ok(false) => {
86 notes.push(
87 "tool-call probe: no tool call emitted under tool_choice=required".to_owned(),
88 );
89 ProbeOutcome::Unsupported
90 }
91 Err(e) => {
92 notes.push(format!("tool-call probe errored: {e}"));
93 ProbeOutcome::Errored
94 }
95 };
96 let structured_output = match check_structured_output(provider, model).await {
97 Ok(true) => ProbeOutcome::Supported,
98 Ok(false) => {
99 notes.push("structured-output probe: reply did not conform to the schema".to_owned());
100 ProbeOutcome::Unsupported
101 }
102 Err(e) => {
103 notes.push(format!("structured-output probe errored: {e}"));
104 ProbeOutcome::Errored
105 }
106 };
107 PreflightReport {
108 native_tool_calling,
109 structured_output,
110 notes,
111 }
112}
113
114async fn check_tool_call(provider: &DynProvider, model: &str) -> Result<bool, String> {
116 let mut req = CompletionRequest::new(model);
117 req.max_tokens = Some(256);
118 req.tools = vec![ToolSpec::new(
119 "preflight_probe",
120 "A connectivity probe. Call it with ok=true.",
121 probe_schema(),
122 )];
123 req.tool_choice = ToolChoice::Required;
124 req.messages = vec![Message::user(
125 "Call the preflight_probe tool with ok set to true.",
126 )];
127
128 let mut stream = provider.complete(req).await.map_err(|e| e.to_string())?;
129 while let Some(item) = stream.next().await {
130 let chunk = item.map_err(|e| e.to_string())?;
131 if matches!(chunk, Chunk::ToolCallStart { .. }) {
132 return Ok(true);
133 }
134 }
135 Ok(false)
136}
137
138async fn check_structured_output(provider: &DynProvider, model: &str) -> Result<bool, String> {
150 let mut req = CompletionRequest::new(model);
151 req.max_tokens = Some(256);
152 req.response_format = Some(JsonSchema(probe_schema()));
153 req.messages = vec![Message::user(
154 "Reply with a JSON object that sets \"ok\" to true.",
155 )];
156
157 let mut stream = provider.complete(req).await.map_err(|e| e.to_string())?;
158 let mut text = String::new();
159 while let Some(item) = stream.next().await {
160 if let Chunk::TextDelta(t) = item.map_err(|e| e.to_string())? {
161 text.push_str(&t);
162 }
163 }
164 Ok(json_matches_probe(&text))
165}
166
167fn json_matches_probe(text: &str) -> bool {
176 let trimmed = strip_code_fence(text.trim());
177 let Ok(serde_json::Value::Object(map)) = serde_json::from_str::<serde_json::Value>(trimmed)
178 else {
179 return false;
180 };
181 map.len() == 1 && matches!(map.get("ok"), Some(serde_json::Value::Bool(_)))
182}
183
184#[must_use]
189pub fn strip_code_fence(s: &str) -> &str {
190 let s = s
191 .strip_prefix("```json")
192 .or_else(|| s.strip_prefix("```"))
193 .unwrap_or(s);
194 s.trim().trim_end_matches("```").trim()
195}
196
197#[cfg(test)]
198mod tests {
199 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
200 use super::*;
201 use crate::{StopReason, Usage, error::DummyError, into_dyn};
202 use futures::stream;
203
204 #[test]
205 fn json_matches_probe_accepts_plain_and_fenced() {
206 assert!(json_matches_probe(r#"{"ok": true}"#));
207 assert!(json_matches_probe("```json\n{\"ok\": false}\n```"));
208 assert!(json_matches_probe("```\n{\"ok\": true}\n```"));
209 }
210
211 #[test]
212 fn json_matches_probe_rejects_prose_and_wrong_shape() {
213 assert!(!json_matches_probe("The sky is blue."));
214 assert!(!json_matches_probe(r#"{"status": "fine"}"#));
215 assert!(!json_matches_probe(""));
216 assert!(!json_matches_probe(r#"{"ok": true, "extra": 1}"#));
220 assert!(!json_matches_probe(r#"{"ok": "yes"}"#));
221 }
222
223 struct CapableProvider;
225 #[async_trait::async_trait]
226 impl crate::LlmProvider for CapableProvider {
227 type Error = DummyError;
228 async fn complete(
229 &self,
230 req: CompletionRequest,
231 ) -> Result<futures::stream::BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error>
232 {
233 let chunks = if req.tool_choice == ToolChoice::Required {
234 vec![
235 Ok(Chunk::tool_call_start("c1", "preflight_probe")),
236 Ok(Chunk::tool_call_args_delta("c1", "{\"ok\":true}")),
237 Ok(Chunk::tool_call_end("c1")),
238 Ok(Chunk::Stop(StopReason::ToolUse)),
239 ]
240 } else {
241 vec![
242 Ok(Chunk::text_delta("{\"ok\": true}")),
243 Ok(Chunk::Usage(Usage {
244 input_tokens: 1,
245 output_tokens: 1,
246 ..Default::default()
247 })),
248 Ok(Chunk::Stop(StopReason::EndTurn)),
249 ]
250 };
251 Ok(stream::iter(chunks).boxed())
252 }
253 }
254
255 struct DegradedProvider;
257 #[async_trait::async_trait]
258 impl crate::LlmProvider for DegradedProvider {
259 type Error = DummyError;
260 async fn complete(
261 &self,
262 _req: CompletionRequest,
263 ) -> Result<futures::stream::BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error>
264 {
265 Ok(stream::iter(vec![
266 Ok(Chunk::text_delta("The sky is blue.")),
267 Ok(Chunk::Stop(StopReason::EndTurn)),
268 ])
269 .boxed())
270 }
271 }
272
273 struct ErroringProvider;
275 #[async_trait::async_trait]
276 impl crate::LlmProvider for ErroringProvider {
277 type Error = DummyError;
278 async fn complete(
279 &self,
280 _req: CompletionRequest,
281 ) -> Result<futures::stream::BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error>
282 {
283 Err(DummyError::Other("connection refused".to_owned()))
284 }
285 }
286
287 #[tokio::test]
288 async fn preflight_passes_a_capable_backend() {
289 let p = into_dyn(CapableProvider);
290 let report = preflight(&*p, "m").await;
291 assert!(report.ok(), "{report:?}");
292 assert!(!report.has_unsupported());
293 }
294
295 #[tokio::test]
296 async fn preflight_flags_a_degraded_backend_as_unsupported() {
297 let p = into_dyn(DegradedProvider);
298 let report = preflight(&*p, "m").await;
299 assert!(!report.ok());
300 assert!(
301 report.has_unsupported(),
302 "degraded backend is a capability verdict"
303 );
304 assert_eq!(report.native_tool_calling, ProbeOutcome::Unsupported);
305 assert_eq!(report.structured_output, ProbeOutcome::Unsupported);
306 }
307
308 #[tokio::test]
309 async fn preflight_marks_transport_failure_errored_not_unsupported() {
310 let p = into_dyn(ErroringProvider);
314 let report = preflight(&*p, "m").await;
315 assert!(!report.ok());
316 assert!(
317 !report.has_unsupported(),
318 "transport error is not 'unsupported'"
319 );
320 assert_eq!(report.native_tool_calling, ProbeOutcome::Errored);
321 assert_eq!(report.structured_output, ProbeOutcome::Errored);
322 }
323}