Skip to main content

codewhale_workflow/
fleet_preflight.rs

1//! Worker route **preflight**: everything about a route that must be true and
2//! frozen *before* a Workflow starts, and certainly before any Router is
3//! asked anything.
4//!
5//! An exact Fleet's promise is that the saved provider/model is the one that
6//! runs. That promise is only worth something if it is *checked* at the point
7//! the run is admitted, not discovered at the first API call:
8//!
9//! - **Provider identity** — the exact configured provider key and its kind.
10//! - **Canonical wire model** — the model string that will actually be placed
11//!   on the request. Receipt and child spawn must use *this* value, not the
12//!   file's spelling of it, or the receipt describes a request nobody made.
13//! - **Credential / readiness** — decided **locally**, from configuration. No
14//!   live probe: a preflight that hits the network would spend money and leak
15//!   the fact of the run before the operator's gates have even been evaluated.
16//!   Keyless local providers (`vllm`, `ollama`, `sglang`, …) are
17//!   [`CredentialReadiness::KeylessLocal`] and are perfectly valid.
18//! - **Endpoint identity** — a non-secret label for *where* the request goes,
19//!   so two members pointed at different deployments of the same model id are
20//!   distinguishable on a receipt. Never a full URL with credentials in it.
21//! - **Reasoning capability** — what the route can truthfully express, derived
22//!   once here so a later launch cannot invent one.
23//!
24//! Everything in this module is a plain value with no clock, no filesystem, and
25//! no network. The host supplies the facts; this crate defines their shape and
26//! the invariants over them.
27
28use serde::{Deserialize, Serialize};
29use thiserror::Error;
30
31use crate::fleet_exact::FrozenRoute;
32use crate::fleet_reasoning::ReasoningCapability;
33
34/// Whether a route can be called at all, decided from local configuration.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case", tag = "kind")]
37pub enum CredentialReadiness {
38    /// A credential for this provider is configured on this machine.
39    Configured,
40    /// The provider is a local, keyless endpoint. Valid, and not a downgrade.
41    KeylessLocal,
42    /// No credential is configured. The route cannot run.
43    Missing { detail: String },
44}
45
46impl CredentialReadiness {
47    #[must_use]
48    pub const fn is_ready(&self) -> bool {
49        matches!(self, Self::Configured | Self::KeylessLocal)
50    }
51
52    #[must_use]
53    pub const fn as_str(&self) -> &'static str {
54        match self {
55            Self::Configured => "configured",
56            Self::KeylessLocal => "keyless_local",
57            Self::Missing { .. } => "missing",
58        }
59    }
60}
61
62/// A non-secret identity for the endpoint a route talks to.
63///
64/// Deliberately **not** a base URL: a configured base URL can carry a token in
65/// its path or query, and receipts are durable. Host plus a coarse path label is
66/// enough to tell two deployments apart, which is the only thing this is for.
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68pub struct EndpointIdentity {
69    /// Host (and port, when non-default), lowercased. Never credentials.
70    pub host: String,
71    /// Whether the endpoint resolves to loopback / a private address.
72    pub local: bool,
73}
74
75impl EndpointIdentity {
76    /// Build an endpoint identity from a base URL, keeping only the host.
77    ///
78    /// Parsing is deliberately minimal and dependency-free: strip the scheme,
79    /// drop anything before an `@` (which is exactly where a credential would
80    /// live), then keep the authority up to the first `/`.
81    #[must_use]
82    pub fn from_base_url(base_url: &str) -> Self {
83        let without_scheme = base_url
84            .trim()
85            .split_once("://")
86            .map_or(base_url.trim(), |(_, rest)| rest);
87        let authority = without_scheme
88            .split(['/', '?', '#'])
89            .next()
90            .unwrap_or_default();
91        // `user:password@host` — everything before the `@` is a credential.
92        let host = authority
93            .rsplit_once('@')
94            .map_or(authority, |(_, host)| host)
95            .to_ascii_lowercase();
96        let bare = host.split(':').next().unwrap_or(&host);
97        let local = bare == "localhost"
98            || bare == "127.0.0.1"
99            || bare == "::1"
100            || bare.starts_with("192.168.")
101            || bare.starts_with("10.")
102            || bare.ends_with(".local");
103        Self { host, local }
104    }
105
106    /// The compact receipt form.
107    #[must_use]
108    pub fn label(&self) -> String {
109        if self.local {
110            format!("{} (local)", self.host)
111        } else {
112            self.host.clone()
113        }
114    }
115}
116
117/// One worker's route, fully preflighted and frozen.
118///
119/// Constructed once, at Workflow start. A launch reads it; nothing rewrites
120/// it. The `wire_model` here is the single source of truth for both the
121/// receipt and the child spawn.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct PreflightedRoute {
124    /// The member this route belongs to.
125    pub member_id: String,
126    /// Canonical runtime provider identity recorded on receipts.
127    pub provider_id: String,
128    /// Runtime configuration key used to rebuild this route when compatibility
129    /// migration canonicalized [`Self::provider_id`] for receipts.
130    ///
131    /// Absent for ordinary routes. The released Ollama Cloud shape is the
132    /// current use: a saved `ollama` route is reported canonically as
133    /// `ollama-cloud`, while client construction must still read the exact
134    /// legacy table and credential slot. This value is non-secret.
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub provider_config_id: Option<String>,
137    /// Provider kind (`zai`, `openai`, `deepseek`, `vllm`, …).
138    pub provider_kind: String,
139    /// The model id exactly as saved in the Fleet file.
140    pub declared_model: String,
141    /// The canonical model string that will be placed on the wire. Receipt and
142    /// child spawn both use this.
143    pub wire_model: String,
144    /// Where the request goes.
145    pub endpoint: EndpointIdentity,
146    /// Locally decided readiness. Never a live probe.
147    pub credential: CredentialReadiness,
148    /// What the route can truthfully express about reasoning.
149    pub capability: ReasoningCapability,
150}
151
152impl PreflightedRoute {
153    /// Provider key the host must scope when rebuilding this frozen route.
154    #[must_use]
155    pub fn provider_config_id(&self) -> &str {
156        self.provider_config_id
157            .as_deref()
158            .unwrap_or(&self.provider_id)
159    }
160
161    /// The frozen provider/model pair, in canonical wire form.
162    ///
163    /// This is what a receipt records and the provider identity the child
164    /// ultimately runs. [`Self::provider_config_id`] may retain an older
165    /// configuration selector solely so rebuilding that identity reads the
166    /// correct table and credential slot.
167    #[must_use]
168    pub fn frozen(&self) -> FrozenRoute {
169        FrozenRoute {
170            provider: self.provider_id.clone(),
171            model: self.wire_model.clone(),
172        }
173    }
174
175    /// Whether the declared model string differed from the canonical wire form.
176    /// Recorded rather than hidden: `glm-5` resolving to `glm-5-20260101` is a
177    /// fact the operator should be able to see on a receipt.
178    #[must_use]
179    pub fn model_canonicalized(&self) -> bool {
180        self.declared_model != self.wire_model
181    }
182
183    /// Fail if this route is not runnable. Called at Workflow start.
184    pub fn require_ready(&self) -> Result<(), PreflightError> {
185        match &self.credential {
186            CredentialReadiness::Configured | CredentialReadiness::KeylessLocal => Ok(()),
187            CredentialReadiness::Missing { detail } => Err(PreflightError::CredentialMissing {
188                member: self.member_id.clone(),
189                provider: self.provider_id.clone(),
190                detail: detail.clone(),
191            }),
192        }
193    }
194}
195
196/// A frozen preflight for every worker in a Workflow, plus the Router.
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
198pub struct RoutePreflight {
199    workers: Vec<PreflightedRoute>,
200    router: Option<PreflightedRoute>,
201}
202
203impl RoutePreflight {
204    #[must_use]
205    pub fn new(workers: Vec<PreflightedRoute>, router: Option<PreflightedRoute>) -> Self {
206        Self { workers, router }
207    }
208
209    #[must_use]
210    pub fn workers(&self) -> &[PreflightedRoute] {
211        &self.workers
212    }
213
214    #[must_use]
215    pub fn router(&self) -> Option<&PreflightedRoute> {
216        self.router.as_ref()
217    }
218
219    /// The frozen route for one member id.
220    #[must_use]
221    pub fn worker(&self, member_id: &str) -> Option<&PreflightedRoute> {
222        let key = member_id.trim().to_ascii_lowercase();
223        self.workers.iter().find(|route| route.member_id == key)
224    }
225
226    /// Fail unless every worker route is runnable.
227    ///
228    /// Called before a Workflow is allowed to start, so a member with no
229    /// credential is a startup error rather than a first-task surprise.
230    pub fn require_all_ready(&self) -> Result<(), PreflightError> {
231        for route in &self.workers {
232            route.require_ready()?;
233        }
234        Ok(())
235    }
236
237    /// Whether any worker route talks to a different provider than the Router.
238    /// This is what a receipt discloses as cross-provider inference.
239    #[must_use]
240    pub fn crosses_providers(&self, member_id: &str) -> bool {
241        match (self.worker(member_id), self.router()) {
242            (Some(worker), Some(router)) => worker.provider_id != router.provider_id,
243            _ => false,
244        }
245    }
246}
247
248#[derive(Debug, Clone, PartialEq, Eq, Error)]
249pub enum PreflightError {
250    #[error(
251        "fleet member `{member}` is pinned to provider `{provider}`, which does not resolve to a \
252         configured provider: {detail}"
253    )]
254    ProviderUnresolved {
255        member: String,
256        provider: String,
257        detail: String,
258    },
259    #[error(
260        "fleet member `{member}` is pinned to model `{model}` on provider `{provider}`, which is \
261         not a valid route: {detail}"
262    )]
263    ModelUnresolved {
264        member: String,
265        provider: String,
266        model: String,
267        detail: String,
268    },
269    #[error(
270        "fleet member `{member}` cannot run: provider `{provider}` has no credential configured \
271         on this machine ({detail}). This is decided locally — no provider was contacted. Keyless \
272         local providers do not need one."
273    )]
274    CredentialMissing {
275        member: String,
276        provider: String,
277        detail: String,
278    },
279    #[error(
280        "cannot determine what reasoning control provider `{provider}` actually expresses for \
281         model `{model}`: {detail}. An exact fleet fails closed here rather than claiming a \
282         capability it did not verify."
283    )]
284    CapabilityUnknown {
285        provider: String,
286        model: String,
287        detail: String,
288    },
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    fn route(member: &str, provider: &str, wire: &str) -> PreflightedRoute {
296        PreflightedRoute {
297            member_id: member.to_string(),
298            provider_id: provider.to_string(),
299            provider_config_id: None,
300            provider_kind: provider.to_string(),
301            declared_model: wire.to_string(),
302            wire_model: wire.to_string(),
303            endpoint: EndpointIdentity::from_base_url("https://api.z.ai/api/paas/v4"),
304            credential: CredentialReadiness::Configured,
305            capability: ReasoningCapability::tiered(),
306        }
307    }
308
309    #[test]
310    fn an_endpoint_identity_keeps_the_host_and_drops_credentials() {
311        let identity =
312            EndpointIdentity::from_base_url("https://user:sk-secret@api.z.ai/api/paas/v4");
313        assert_eq!(identity.host, "api.z.ai");
314        assert!(!identity.local);
315        assert!(!identity.label().contains("sk-secret"));
316        assert!(!identity.label().contains('/'));
317    }
318
319    #[test]
320    fn loopback_and_private_endpoints_are_marked_local() {
321        for url in [
322            "http://127.0.0.1:8000/v1",
323            "http://localhost:11434",
324            "http://192.168.1.20:8000/v1",
325            "http://box.local/v1",
326        ] {
327            let identity = EndpointIdentity::from_base_url(url);
328            assert!(identity.local, "{url} must be local");
329            assert!(identity.label().ends_with("(local)"));
330        }
331        assert!(!EndpointIdentity::from_base_url("https://api.openai.com/v1").local);
332    }
333
334    /// The receipt and the child spawn must not be able to disagree, so there
335    /// is exactly one canonical wire model and both read it.
336    #[test]
337    fn the_frozen_route_uses_the_canonical_wire_model() {
338        let mut preflighted = route("implementer", "zai", "glm-5");
339        preflighted.wire_model = "glm-5-20260101".to_string();
340
341        assert_eq!(preflighted.frozen().model, "glm-5-20260101");
342        assert_eq!(preflighted.frozen().provider, "zai");
343        assert!(preflighted.model_canonicalized());
344        assert_eq!(preflighted.declared_model, "glm-5");
345    }
346
347    /// Keyless local providers are first-class: readiness is about whether the
348    /// route can run, not about whether a key exists.
349    #[test]
350    fn keyless_local_providers_are_ready() {
351        let mut local = route("worker", "vllm", "qwen3");
352        local.credential = CredentialReadiness::KeylessLocal;
353        local.endpoint = EndpointIdentity::from_base_url("http://127.0.0.1:8000/v1");
354
355        assert!(local.credential.is_ready());
356        local.require_ready().expect("keyless local is valid");
357        assert_eq!(local.credential.as_str(), "keyless_local");
358    }
359
360    #[test]
361    fn a_missing_credential_fails_the_workflow_locally() {
362        let mut route = route("implementer", "zai", "glm-5");
363        route.credential = CredentialReadiness::Missing {
364            detail: "no ZAI_API_KEY".to_string(),
365        };
366
367        let preflight = RoutePreflight::new(vec![route], None);
368        let err = preflight
369            .require_all_ready()
370            .expect_err("a member with no credential must not start");
371        assert!(matches!(err, PreflightError::CredentialMissing { .. }));
372        let message = err.to_string();
373        assert!(
374            message.contains("decided locally"),
375            "the error must say no provider was contacted: {message}"
376        );
377    }
378
379    #[test]
380    fn cross_provider_inference_is_detectable_from_the_preflight() {
381        let preflight = RoutePreflight::new(
382            vec![route("implementer", "zai", "glm-5")],
383            Some(route("router", "openai", "gpt-5.6-luna")),
384        );
385        assert!(preflight.crosses_providers("implementer"));
386
387        let same = RoutePreflight::new(
388            vec![route("implementer", "zai", "glm-5")],
389            Some(route("router", "zai", "glm-5-turbo")),
390        );
391        assert!(!same.crosses_providers("implementer"));
392
393        // With no router, nothing crosses.
394        let none = RoutePreflight::new(vec![route("implementer", "zai", "glm-5")], None);
395        assert!(!none.crosses_providers("implementer"));
396    }
397
398    #[test]
399    fn a_preflight_serializes_without_secrets_or_paths() {
400        let preflight = RoutePreflight::new(
401            vec![route("implementer", "zai", "glm-5")],
402            Some(route("router", "openai", "gpt-5.6-luna")),
403        );
404        let json = serde_json::to_string(&preflight).expect("serialize");
405        let lowered = json.to_ascii_lowercase();
406        for forbidden in [
407            "api_key", "secret", "bearer", "base_url", "/users/", "https://",
408        ] {
409            assert!(!lowered.contains(forbidden), "{forbidden} in {json}");
410        }
411        let back: RoutePreflight = serde_json::from_str(&json).expect("round-trip");
412        assert_eq!(back, preflight);
413    }
414}