返回 CodeWhale
fleet_preflight.rs
根目录 / crates / workflow / src / 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
28 use serde::{Deserialize, Serialize};
29 use thiserror::Error;
30
31 use crate::fleet_exact::FrozenRoute;
32 use 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")]
37 pub 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
46 impl 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)]
68 pub 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
75 impl 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)]
123 pub 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
152 impl 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)]
198 pub struct RoutePreflight {
199 workers: Vec<PreflightedRoute>,
200 router: Option<PreflightedRoute>,
201 }
202
203 impl 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)]
249 pub 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)]
292 mod 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 }
415
415 lines RUST