1use serde::{Deserialize, Serialize};
29use thiserror::Error;
30
31use crate::fleet_exact::FrozenRoute;
32use crate::fleet_reasoning::ReasoningCapability;
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case", tag = "kind")]
37pub enum CredentialReadiness {
38 Configured,
40 KeylessLocal,
42 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68pub struct EndpointIdentity {
69 pub host: String,
71 pub local: bool,
73}
74
75impl EndpointIdentity {
76 #[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 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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct PreflightedRoute {
124 pub member_id: String,
126 pub provider_id: String,
128 pub provider_kind: String,
130 pub declared_model: String,
132 pub wire_model: String,
135 pub endpoint: EndpointIdentity,
137 pub credential: CredentialReadiness,
139 pub capability: ReasoningCapability,
141}
142
143impl PreflightedRoute {
144 #[must_use]
149 pub fn frozen(&self) -> FrozenRoute {
150 FrozenRoute {
151 provider: self.provider_id.clone(),
152 model: self.wire_model.clone(),
153 }
154 }
155
156 #[must_use]
160 pub fn model_canonicalized(&self) -> bool {
161 self.declared_model != self.wire_model
162 }
163
164 pub fn require_ready(&self) -> Result<(), PreflightError> {
166 match &self.credential {
167 CredentialReadiness::Configured | CredentialReadiness::KeylessLocal => Ok(()),
168 CredentialReadiness::Missing { detail } => Err(PreflightError::CredentialMissing {
169 member: self.member_id.clone(),
170 provider: self.provider_id.clone(),
171 detail: detail.clone(),
172 }),
173 }
174 }
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
179pub struct RoutePreflight {
180 workers: Vec<PreflightedRoute>,
181 router: Option<PreflightedRoute>,
182}
183
184impl RoutePreflight {
185 #[must_use]
186 pub fn new(workers: Vec<PreflightedRoute>, router: Option<PreflightedRoute>) -> Self {
187 Self { workers, router }
188 }
189
190 #[must_use]
191 pub fn workers(&self) -> &[PreflightedRoute] {
192 &self.workers
193 }
194
195 #[must_use]
196 pub fn router(&self) -> Option<&PreflightedRoute> {
197 self.router.as_ref()
198 }
199
200 #[must_use]
202 pub fn worker(&self, member_id: &str) -> Option<&PreflightedRoute> {
203 let key = member_id.trim().to_ascii_lowercase();
204 self.workers.iter().find(|route| route.member_id == key)
205 }
206
207 pub fn require_all_ready(&self) -> Result<(), PreflightError> {
212 for route in &self.workers {
213 route.require_ready()?;
214 }
215 Ok(())
216 }
217
218 #[must_use]
221 pub fn crosses_providers(&self, member_id: &str) -> bool {
222 match (self.worker(member_id), self.router()) {
223 (Some(worker), Some(router)) => worker.provider_id != router.provider_id,
224 _ => false,
225 }
226 }
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Error)]
230pub enum PreflightError {
231 #[error(
232 "fleet member `{member}` is pinned to provider `{provider}`, which does not resolve to a \
233 configured provider: {detail}"
234 )]
235 ProviderUnresolved {
236 member: String,
237 provider: String,
238 detail: String,
239 },
240 #[error(
241 "fleet member `{member}` is pinned to model `{model}` on provider `{provider}`, which is \
242 not a valid route: {detail}"
243 )]
244 ModelUnresolved {
245 member: String,
246 provider: String,
247 model: String,
248 detail: String,
249 },
250 #[error(
251 "fleet member `{member}` cannot run: provider `{provider}` has no credential configured \
252 on this machine ({detail}). This is decided locally — no provider was contacted. Keyless \
253 local providers do not need one."
254 )]
255 CredentialMissing {
256 member: String,
257 provider: String,
258 detail: String,
259 },
260 #[error(
261 "cannot determine what reasoning control provider `{provider}` actually expresses for \
262 model `{model}`: {detail}. An exact fleet fails closed here rather than claiming a \
263 capability it did not verify."
264 )]
265 CapabilityUnknown {
266 provider: String,
267 model: String,
268 detail: String,
269 },
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 fn route(member: &str, provider: &str, wire: &str) -> PreflightedRoute {
277 PreflightedRoute {
278 member_id: member.to_string(),
279 provider_id: provider.to_string(),
280 provider_kind: provider.to_string(),
281 declared_model: wire.to_string(),
282 wire_model: wire.to_string(),
283 endpoint: EndpointIdentity::from_base_url("https://api.z.ai/api/paas/v4"),
284 credential: CredentialReadiness::Configured,
285 capability: ReasoningCapability::tiered(),
286 }
287 }
288
289 #[test]
290 fn an_endpoint_identity_keeps_the_host_and_drops_credentials() {
291 let identity =
292 EndpointIdentity::from_base_url("https://user:sk-secret@api.z.ai/api/paas/v4");
293 assert_eq!(identity.host, "api.z.ai");
294 assert!(!identity.local);
295 assert!(!identity.label().contains("sk-secret"));
296 assert!(!identity.label().contains('/'));
297 }
298
299 #[test]
300 fn loopback_and_private_endpoints_are_marked_local() {
301 for url in [
302 "http://127.0.0.1:8000/v1",
303 "http://localhost:11434",
304 "http://192.168.1.20:8000/v1",
305 "http://box.local/v1",
306 ] {
307 let identity = EndpointIdentity::from_base_url(url);
308 assert!(identity.local, "{url} must be local");
309 assert!(identity.label().ends_with("(local)"));
310 }
311 assert!(!EndpointIdentity::from_base_url("https://api.openai.com/v1").local);
312 }
313
314 #[test]
317 fn the_frozen_route_uses_the_canonical_wire_model() {
318 let mut preflighted = route("implementer", "zai", "glm-5");
319 preflighted.wire_model = "glm-5-20260101".to_string();
320
321 assert_eq!(preflighted.frozen().model, "glm-5-20260101");
322 assert_eq!(preflighted.frozen().provider, "zai");
323 assert!(preflighted.model_canonicalized());
324 assert_eq!(preflighted.declared_model, "glm-5");
325 }
326
327 #[test]
330 fn keyless_local_providers_are_ready() {
331 let mut local = route("worker", "vllm", "qwen3");
332 local.credential = CredentialReadiness::KeylessLocal;
333 local.endpoint = EndpointIdentity::from_base_url("http://127.0.0.1:8000/v1");
334
335 assert!(local.credential.is_ready());
336 local.require_ready().expect("keyless local is valid");
337 assert_eq!(local.credential.as_str(), "keyless_local");
338 }
339
340 #[test]
341 fn a_missing_credential_fails_the_workflow_locally() {
342 let mut route = route("implementer", "zai", "glm-5");
343 route.credential = CredentialReadiness::Missing {
344 detail: "no ZAI_API_KEY".to_string(),
345 };
346
347 let preflight = RoutePreflight::new(vec![route], None);
348 let err = preflight
349 .require_all_ready()
350 .expect_err("a member with no credential must not start");
351 assert!(matches!(err, PreflightError::CredentialMissing { .. }));
352 let message = err.to_string();
353 assert!(
354 message.contains("decided locally"),
355 "the error must say no provider was contacted: {message}"
356 );
357 }
358
359 #[test]
360 fn cross_provider_inference_is_detectable_from_the_preflight() {
361 let preflight = RoutePreflight::new(
362 vec![route("implementer", "zai", "glm-5")],
363 Some(route("router", "openai", "gpt-5.6-luna")),
364 );
365 assert!(preflight.crosses_providers("implementer"));
366
367 let same = RoutePreflight::new(
368 vec![route("implementer", "zai", "glm-5")],
369 Some(route("router", "zai", "glm-5-turbo")),
370 );
371 assert!(!same.crosses_providers("implementer"));
372
373 let none = RoutePreflight::new(vec![route("implementer", "zai", "glm-5")], None);
375 assert!(!none.crosses_providers("implementer"));
376 }
377
378 #[test]
379 fn a_preflight_serializes_without_secrets_or_paths() {
380 let preflight = RoutePreflight::new(
381 vec![route("implementer", "zai", "glm-5")],
382 Some(route("router", "openai", "gpt-5.6-luna")),
383 );
384 let json = serde_json::to_string(&preflight).expect("serialize");
385 let lowered = json.to_ascii_lowercase();
386 for forbidden in [
387 "api_key", "secret", "bearer", "base_url", "/users/", "https://",
388 ] {
389 assert!(!lowered.contains(forbidden), "{forbidden} in {json}");
390 }
391 let back: RoutePreflight = serde_json::from_str(&json).expect("round-trip");
392 assert_eq!(back, preflight);
393 }
394}