Skip to main content

polyc_llm/
preflight.rs

1//! Startup preflight: verify a backend actually delivers native tool calling and
2//! schema-constrained structured output, against the live endpoint.
3//!
4//! A self-hosted OpenAI-compatible server only delivers these if its serving
5//! runtime is configured for them (a tool-call parser + a grammar/constrained
6//! decoding backend). When it isn't, tool calls arrive as plain text and the
7//! response schema is ignored — and the harness's tool loop silently no-ops.
8//! This module turns that silent degradation into an explicit, logged (or fatal)
9//! signal by actually exercising both capabilities once at startup.
10//!
11//! Each probe yields a tri-state [`ProbeOutcome`]: `Supported` (verified),
12//! `Unsupported` (the backend ran but didn't honor the constraint), or `Errored`
13//! (the call itself failed — transport, cold start, timeout). The distinction
14//! matters at the call site: a definitive `Unsupported` is a capability verdict
15//! worth failing startup over, whereas an `Errored` probe is "couldn't verify"
16//! and must not crash-loop a backend that is merely warming up.
17
18use futures::StreamExt;
19
20use crate::{Chunk, CompletionRequest, DynProvider, JsonSchema, Message, ToolChoice, ToolSpec};
21
22/// The result of one capability probe.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum ProbeOutcome {
25    /// The backend ran the probe and honored the constraint.
26    Supported,
27    /// The backend ran the probe but did not honor the constraint (no tool call
28    /// emitted under `tool_choice = Required`, or output that violated the schema).
29    /// A definitive capability verdict.
30    Unsupported,
31    /// The probe call itself failed (transport error, cold start, timeout). NOT a
32    /// capability verdict — the backend may be fine once warm.
33    Errored,
34}
35
36/// What the live endpoint actually did when probed.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct PreflightReport {
39    /// Outcome of the forced-tool-call probe.
40    pub native_tool_calling: ProbeOutcome,
41    /// Outcome of the schema-constrained structured-output probe.
42    pub structured_output: ProbeOutcome,
43    /// Human-readable notes (parse failures, transport errors) for logging.
44    pub notes: Vec<String>,
45}
46
47impl PreflightReport {
48    /// Both capabilities verified — the backend is a full peer.
49    #[must_use]
50    pub fn ok(&self) -> bool {
51        self.native_tool_calling == ProbeOutcome::Supported
52            && self.structured_output == ProbeOutcome::Supported
53    }
54
55    /// A probe gave a definitive *negative* capability verdict (ran, didn't honor
56    /// the constraint). This — not a transport error — is what justifies failing
57    /// startup under a strict policy.
58    #[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
65/// The schema used by both probes: a one-field object the smallest model can
66/// satisfy, with `additionalProperties: false` so a grammar backend has
67/// something to enforce.
68fn 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
77/// Probe a backend's real capabilities with two live calls.
78///
79/// One forces a tool call; the other requests structured output and checks the
80/// reply against the full probe schema. Never panics.
81pub 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
114/// Force a tool call and report whether the backend emitted one.
115async 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
138/// Request structured output and report whether the reply conforms to the schema.
139///
140/// Checks the reply against the FULL probe schema (exact shape — not merely "has
141/// the key"), so a backend that returns prose, extra keys, or a wrong-typed field
142/// fails. This verifies the observable property callers actually depend on:
143/// schema-conforming structured output when requested. It is deliberately a
144/// black-box check — distinguishing true token-level grammar enforcement from a
145/// model that simply complied is not reliably observable from outside (real
146/// backends vary, and a prose-pulling "adversarial" prompt false-fails endpoints
147/// that serve structured output fine in practice), so the probe asserts
148/// conformance-on-request rather than enforcement-against-any-prompt.
149async 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
167/// True if `text` (possibly wrapped in a markdown code fence) is a JSON object
168/// that *conforms to the probe schema*: exactly one key `ok`, with a boolean
169/// value, and nothing else.
170///
171/// Checking the full shape — not merely that `ok` is present — is what makes this
172/// a test of schema-*constrained* decoding: a server that ignored
173/// `additionalProperties: false` (extra keys), the `ok` type, or returned prose
174/// fails, exactly as it should. Lenient on a surrounding markdown fence only.
175fn 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/// Strip a leading/trailing ```` ```json ```` … ```` ``` ```` fence if present.
185///
186/// Public so callers parsing a JSON-in-a-fence model reply (e.g. e2e tests) share
187/// one fence-handling implementation instead of re-deriving it.
188#[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        // Schema-constrained: extra keys (additionalProperties) and a wrong-typed
217        // `ok` must fail — a server that merely returned JSON-with-`ok` is not
218        // proof of constrained decoding.
219        assert!(!json_matches_probe(r#"{"ok": true, "extra": 1}"#));
220        assert!(!json_matches_probe(r#"{"ok": "yes"}"#));
221    }
222
223    /// Fully capable: emits a tool call and schema-conforming JSON.
224    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    /// Ran fine but honored neither constraint: no tool call, prose reply.
256    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    /// Unreachable: every call fails before streaming (transport/cold start).
274    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        // A cold/unreachable backend must NOT read as a definitive capability
311        // verdict — otherwise strict mode crash-loops a backend that's merely
312        // warming up.
313        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}