Skip to main content

axon/
backend_resolution.rs

1//! §Fase 36.b — the Backend Resolution Contract (D1).
2//!
3//! axon resolves the execution backend of any flow — behind an
4//! `axonendpoint` route or a `/v1/execute` call — through ONE
5//! deterministic, total, published precedence ladder. The first rung
6//! that yields a usable concrete backend wins:
7//!
8//!   1. **request-explicit** — a concrete backend named on the request
9//!   2. **endpoint-declared** — the `axonendpoint backend:` field
10//!   3. **server-default** — `axon serve --backend` / `AXON_DEFAULT_BACKEND`
11//!   4. **environment-available `auto`** — the registry-ranked list if
12//!      non-empty, else the canonical providers whose API key is
13//!      present in the environment, in canonical priority order
14//!   5. **honest failure** — `Err(NoBackendAvailable)`; the ladder
15//!      NEVER falls through to `stub`
16//!
17//! A rung carrying `"auto"` (or empty) is *transparent* — it does not
18//! fire, it falls through to the next rung. `stub` is reachable ONLY
19//! by an explicit rung-1/2/3 value of `"stub"` — never from the `auto`
20//! rungs (D5: `stub` is filtered out of the registry / env lists here,
21//! so auto-resolution can never land on it).
22//!
23//! This module is **pure** — no I/O, no `std::env`, no clock. The
24//! environment scan (which keys are present) and the registry scoring
25//! are computed by the caller and passed in; that keeps the contract
26//! exhaustively unit-testable and deterministic. The shared corpus in
27//! the §36.l tests pins it.
28
29/// Is `name` an explicit, concrete backend choice — i.e. a rung that
30/// should FIRE rather than fall through? Empty and `"auto"` are
31/// transparent (they mean "resolve normally").
32pub fn is_explicit_backend(name: &str) -> bool {
33    !name.is_empty() && name != "auto"
34}
35
36/// Which rung of the precedence ladder resolved the backend.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum BackendResolutionReason {
39    /// Rung 1 — a concrete backend named on the request.
40    RequestExplicit,
41    /// Rung 2 — the `axonendpoint backend:` declaration.
42    EndpointDeclared,
43    /// Rung 3 — the server-wide default.
44    ServerDefault,
45    /// Rung 4a — the top of the operator-tuned `backend_registry` scores.
46    RegistryRanked,
47    /// Rung 4b — the first canonical provider with an API key in the env.
48    EnvironmentAvailable,
49}
50
51impl BackendResolutionReason {
52    /// Stable wire/observability slug (D8). The closed catalog.
53    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/// A resolved backend + the rung that chose it.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct BackendResolution {
69    pub backend: String,
70    pub reason: BackendResolutionReason,
71}
72
73/// The honest-failure outcome (D5) — every ladder rung was empty and
74/// `stub` was not explicitly requested.
75#[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/// The inputs to one backend resolution — all pre-computed by the
97/// caller so this contract stays pure.
98#[derive(Debug, Clone, Default)]
99pub struct BackendResolutionInputs {
100    /// Rung 1 — a backend named on the request. `None`, empty, or
101    /// `"auto"` = no request preference.
102    pub request_backend: Option<String>,
103    /// Rung 2 — the `axonendpoint backend:` declaration, if any.
104    pub endpoint_backend: Option<String>,
105    /// Rung 3 — the server-wide default, if configured.
106    pub server_default: Option<String>,
107    /// Rung 4a — registry-scored backends, best-first (from
108    /// `compute_backend_scores`). `stub` entries are ignored.
109    pub registry_ranked: Vec<String>,
110    /// Rung 4b — canonical providers with an API key present in the
111    /// environment, in canonical priority order. `stub` is never here.
112    pub env_available: Vec<String>,
113}
114
115/// §D1 — resolve the execution backend by the precedence ladder.
116///
117/// Total and deterministic: the same inputs always produce the same
118/// result. `stub` is returned ONLY when an explicit rung-1/2/3 value
119/// is literally `"stub"`; the `auto` rungs skip every `"stub"` entry,
120/// so auto-resolution can never silently degrade to the no-op (D5).
121pub fn resolve_backend(
122    inputs: &BackendResolutionInputs,
123) -> Result<BackendResolution, NoBackendAvailable> {
124    // Rungs 1–3 — an explicit, concrete declaration fires immediately.
125    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    // Rung 4 — `auto` resolution. The registry's operator-tuned
141    // ranking wins when populated; else the environment-available
142    // providers. An auto rung fires ONLY on a usable CONCRETE backend
143    // — `is_explicit_backend` (non-empty, not `"auto"`) AND not
144    // `"stub"`. So the transparent tokens and the no-op are skipped:
145    // auto-resolution can never land on `stub` (D5), nor on an empty
146    // / `"auto"` entry should one slip into the caller's list. The
147    // resolver stays total — it never returns a non-backend.
148    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    // Rung 5 — honest failure. No silent stub.
165    Err(NoBackendAvailable)
166}
167
168// ── Tests ───────────────────────────────────────────────────────────
169
170#[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()); // transparent
202        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        // Every rung empty — D5: honest failure, not a silent stub.
252        assert_eq!(resolve_backend(&inputs()), Err(NoBackendAvailable));
253    }
254
255    #[test]
256    fn auto_rungs_never_land_on_stub() {
257        // §D5 — even if `stub` somehow appears in the registry / env
258        // lists, the `auto` rungs skip it. A stub entry alone with no
259        // real backend is an honest failure.
260        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        // …but a real backend behind a stub entry still resolves.
266        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        // An operator who explicitly asks for stub gets it — D5 forbids
273        // SILENT stub, not explicit opt-in.
274        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}