axon/
backend_resolution.rs1pub fn is_explicit_backend(name: &str) -> bool {
33 !name.is_empty() && name != "auto"
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum BackendResolutionReason {
39 RequestExplicit,
41 EndpointDeclared,
43 ServerDefault,
45 RegistryRanked,
47 EnvironmentAvailable,
49}
50
51impl BackendResolutionReason {
52 pub fn as_slug(self) -> &'static str {
54 match self {
55 BackendResolutionReason::RequestExplicit => "request_explicit",
56 BackendResolutionReason::EndpointDeclared => "endpoint_declared",
57 BackendResolutionReason::ServerDefault => "server_default",
58 BackendResolutionReason::RegistryRanked => "registry_ranked",
59 BackendResolutionReason::EnvironmentAvailable => {
60 "environment_available"
61 }
62 }
63 }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct BackendResolution {
69 pub backend: String,
70 pub reason: BackendResolutionReason,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct NoBackendAvailable;
77
78impl std::fmt::Display for NoBackendAvailable {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 write!(
81 f,
82 "no execution backend available — axon will not silently \
83 run the no-op `stub`. Fix one of: declare `backend:` on \
84 the axonendpoint; set a provider API key in the server \
85 environment (ANTHROPIC_API_KEY / OPENAI_API_KEY / \
86 GEMINI_API_KEY / KIMI_API_KEY / GLM_API_KEY / \
87 OPENROUTER_API_KEY / OLLAMA_API_KEY); pass `--backend \
88 <name>` to `axon serve`; or request `backend=stub` \
89 explicitly to opt into the no-op."
90 )
91 }
92}
93
94impl std::error::Error for NoBackendAvailable {}
95
96#[derive(Debug, Clone, Default)]
99pub struct BackendResolutionInputs {
100 pub request_backend: Option<String>,
103 pub endpoint_backend: Option<String>,
105 pub server_default: Option<String>,
107 pub registry_ranked: Vec<String>,
110 pub env_available: Vec<String>,
113}
114
115pub fn resolve_backend(
122 inputs: &BackendResolutionInputs,
123) -> Result<BackendResolution, NoBackendAvailable> {
124 for (slot, reason) in [
126 (&inputs.request_backend, BackendResolutionReason::RequestExplicit),
127 (&inputs.endpoint_backend, BackendResolutionReason::EndpointDeclared),
128 (&inputs.server_default, BackendResolutionReason::ServerDefault),
129 ] {
130 if let Some(name) = slot {
131 if is_explicit_backend(name) {
132 return Ok(BackendResolution {
133 backend: name.clone(),
134 reason,
135 });
136 }
137 }
138 }
139
140 let is_usable_auto = |b: &&String| {
149 is_explicit_backend(b.as_str()) && b.as_str() != "stub"
150 };
151 if let Some(top) = inputs.registry_ranked.iter().find(is_usable_auto) {
152 return Ok(BackendResolution {
153 backend: top.clone(),
154 reason: BackendResolutionReason::RegistryRanked,
155 });
156 }
157 if let Some(top) = inputs.env_available.iter().find(is_usable_auto) {
158 return Ok(BackendResolution {
159 backend: top.clone(),
160 reason: BackendResolutionReason::EnvironmentAvailable,
161 });
162 }
163
164 Err(NoBackendAvailable)
166}
167
168#[cfg(test)]
171mod tests {
172 use super::*;
173
174 fn inputs() -> BackendResolutionInputs {
175 BackendResolutionInputs::default()
176 }
177
178 #[test]
179 fn is_explicit_rejects_auto_and_empty() {
180 assert!(!is_explicit_backend(""));
181 assert!(!is_explicit_backend("auto"));
182 assert!(is_explicit_backend("gemini"));
183 assert!(is_explicit_backend("stub"));
184 }
185
186 #[test]
187 fn request_explicit_wins_over_everything() {
188 let mut i = inputs();
189 i.request_backend = Some("kimi".into());
190 i.endpoint_backend = Some("gemini".into());
191 i.server_default = Some("anthropic".into());
192 i.registry_ranked = vec!["openai".into()];
193 let r = resolve_backend(&i).unwrap();
194 assert_eq!(r.backend, "kimi");
195 assert_eq!(r.reason, BackendResolutionReason::RequestExplicit);
196 }
197
198 #[test]
199 fn endpoint_declared_wins_when_request_is_auto() {
200 let mut i = inputs();
201 i.request_backend = Some("auto".into()); i.endpoint_backend = Some("gemini".into());
203 i.server_default = Some("anthropic".into());
204 let r = resolve_backend(&i).unwrap();
205 assert_eq!(r.backend, "gemini");
206 assert_eq!(r.reason, BackendResolutionReason::EndpointDeclared);
207 }
208
209 #[test]
210 fn server_default_wins_when_request_and_endpoint_absent() {
211 let mut i = inputs();
212 i.server_default = Some("anthropic".into());
213 i.env_available = vec!["gemini".into()];
214 let r = resolve_backend(&i).unwrap();
215 assert_eq!(r.backend, "anthropic");
216 assert_eq!(r.reason, BackendResolutionReason::ServerDefault);
217 }
218
219 #[test]
220 fn registry_ranked_wins_over_env_in_auto_mode() {
221 let mut i = inputs();
222 i.registry_ranked = vec!["openai".into(), "kimi".into()];
223 i.env_available = vec!["gemini".into()];
224 let r = resolve_backend(&i).unwrap();
225 assert_eq!(r.backend, "openai");
226 assert_eq!(r.reason, BackendResolutionReason::RegistryRanked);
227 }
228
229 #[test]
230 fn environment_available_resolves_when_registry_empty() {
231 let mut i = inputs();
232 i.env_available = vec!["gemini".into(), "anthropic".into()];
233 let r = resolve_backend(&i).unwrap();
234 assert_eq!(r.backend, "gemini");
235 assert_eq!(r.reason, BackendResolutionReason::EnvironmentAvailable);
236 }
237
238 #[test]
239 fn empty_and_auto_slots_are_transparent() {
240 let mut i = inputs();
241 i.request_backend = Some(String::new());
242 i.endpoint_backend = Some("auto".into());
243 i.server_default = Some("kimi".into());
244 let r = resolve_backend(&i).unwrap();
245 assert_eq!(r.backend, "kimi");
246 assert_eq!(r.reason, BackendResolutionReason::ServerDefault);
247 }
248
249 #[test]
250 fn no_backend_anywhere_is_honest_failure_never_stub() {
251 assert_eq!(resolve_backend(&inputs()), Err(NoBackendAvailable));
253 }
254
255 #[test]
256 fn auto_rungs_never_land_on_stub() {
257 let mut i = inputs();
261 i.registry_ranked = vec!["stub".into()];
262 i.env_available = vec!["stub".into()];
263 assert_eq!(resolve_backend(&i), Err(NoBackendAvailable));
264
265 i.registry_ranked = vec!["stub".into(), "gemini".into()];
267 assert_eq!(resolve_backend(&i).unwrap().backend, "gemini");
268 }
269
270 #[test]
271 fn stub_is_reachable_only_by_an_explicit_rung() {
272 let mut i = inputs();
275 i.request_backend = Some("stub".into());
276 let r = resolve_backend(&i).unwrap();
277 assert_eq!(r.backend, "stub");
278 assert_eq!(r.reason, BackendResolutionReason::RequestExplicit);
279 }
280
281 #[test]
282 fn resolution_is_deterministic() {
283 let mut i = inputs();
284 i.endpoint_backend = Some("gemini".into());
285 i.env_available = vec!["anthropic".into(), "kimi".into()];
286 let a = resolve_backend(&i).unwrap();
287 let b = resolve_backend(&i).unwrap();
288 assert_eq!(a, b);
289 }
290
291 #[test]
292 fn reason_slugs_are_the_closed_catalog() {
293 for (reason, slug) in [
294 (BackendResolutionReason::RequestExplicit, "request_explicit"),
295 (BackendResolutionReason::EndpointDeclared, "endpoint_declared"),
296 (BackendResolutionReason::ServerDefault, "server_default"),
297 (BackendResolutionReason::RegistryRanked, "registry_ranked"),
298 (
299 BackendResolutionReason::EnvironmentAvailable,
300 "environment_available",
301 ),
302 ] {
303 assert_eq!(reason.as_slug(), slug);
304 }
305 }
306
307 #[test]
308 fn honest_failure_message_names_the_fixes() {
309 let msg = NoBackendAvailable.to_string();
310 assert!(msg.contains("backend:"));
311 assert!(msg.contains("ANTHROPIC_API_KEY"));
312 assert!(msg.contains("--backend"));
313 assert!(msg.contains("stub"));
314 }
315}