io_harness/provider/fallback.rs
1//! One provider standing behind another.
2//!
3//! Three providers are implemented and, before 0.11, only ever one was reachable
4//! in a run: every entry point is generic over a single `P: Provider`, and
5//! [`Provider::complete`] returns `impl Future`, so there is no `dyn Provider` to
6//! hold a list behind.
7//!
8//! [`Fallback`] is how fallback happens without changing that. It is itself a
9//! `Provider`, so `run(&contract, &Fallback::new(a, b), &store)` needs no new
10//! entry point, and it nests — `Fallback::new(a, Fallback::new(b, c))` — for three.
11//!
12//! It falls through on a failure another provider might not have and refuses to on
13//! one it would meet too. Retrying a request the server read and rejected is two
14//! failures instead of one, and a wrong API key is not more valid at a different
15//! vendor.
16//!
17//! ## What it does not promise
18//!
19//! That the two providers are equivalent. Falling over swaps the model mid-run, and
20//! the harness cannot see whether the replacement is as capable, as well tuned, or
21//! even the same size. An operator configuring a fallback should expect that a run
22//! which fell over may behave differently from one that did not — which is why the
23//! provider that actually answered is recorded per step rather than inferred from
24//! the run's configuration.
25
26use std::sync::atomic::{AtomicU8, Ordering};
27
28use crate::error::{Error, Result};
29use crate::provider::{CompletionRequest, CompletionResponse, Provider};
30
31/// Try `primary`; on a failure another provider might survive, try `secondary`.
32///
33/// ```no_run
34/// use io_harness::provider::Fallback;
35/// use io_harness::{Anthropic, OpenRouter};
36///
37/// # fn main() -> io_harness::Result<()> {
38/// let provider = Fallback::new(OpenRouter::from_env()?, Anthropic::from_env()?);
39/// # Ok(())
40/// # }
41/// ```
42#[derive(Debug)]
43pub struct Fallback<A, B> {
44 primary: A,
45 secondary: B,
46 /// `name()` returns `&str`, so the combined label has to be owned somewhere.
47 name: String,
48 /// Which one answered last: 0 nobody, 1 primary, 2 secondary. An atomic rather
49 /// than a lock because a `MutexGuard` is not `Send` and `complete`'s future has
50 /// to be. Read by the run loop to record, per step, the provider that actually
51 /// served it — `runs.provider` is one label for a whole run and stops being true
52 /// the moment a run can use two.
53 served: AtomicU8,
54}
55
56impl<A: Provider, B: Provider> Fallback<A, B> {
57 /// `secondary` answers when `primary` fails in a way it might survive.
58 pub fn new(primary: A, secondary: B) -> Self {
59 let name = format!("{} -> {}", primary.name(), secondary.name());
60 Self {
61 primary,
62 secondary,
63 name,
64 served: AtomicU8::new(0),
65 }
66 }
67
68 fn note(&self, who: u8) {
69 self.served.store(who, Ordering::Relaxed);
70 }
71}
72
73/// Whether a different provider is worth trying.
74///
75/// The same question [`ProviderErrorKind::is_retryable`](crate::error::ProviderErrorKind::is_retryable)
76/// answers for a retry, and the same answer: a failure that was about the request or
77/// about the caller's own configuration will happen again at the next vendor, so
78/// falling over just fails twice and takes twice as long doing it.
79fn worth_another_provider(e: &Error) -> bool {
80 matches!(e, Error::Provider { kind, .. } if kind.is_retryable())
81}
82
83// `Sync` on both halves is what makes `complete`'s future `Send`: the combinator
84// holds `&self` across the primary's await, so `&Fallback<A, B>` has to be `Send`,
85// which needs its fields `Sync`. Every real provider is (they hold a `reqwest::Client`
86// and two `String`s), so this costs nothing a caller would notice.
87impl<A: Provider + Sync, B: Provider + Sync> Provider for Fallback<A, B> {
88 /// Both, or neither. Reporting the primary's answer would let an image reach
89 /// a secondary that cannot read it on the one call that matters — the
90 /// fallover — and a fallover already means something has gone wrong. The
91 /// secondary would refuse it (`ensure_media_accepted` runs inside every
92 /// built-in provider too), so the only thing the conjunction changes is
93 /// *when* the caller finds out: before the run, rather than mid-fallover.
94 #[cfg(feature = "media")]
95 fn accepts_images(&self) -> bool {
96 self.primary.accepts_images() && self.secondary.accepts_images()
97 }
98
99 async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse> {
100 match self.primary.complete(request.clone()).await {
101 Ok(response) => {
102 self.note(1);
103 Ok(response)
104 }
105 Err(e) if worth_another_provider(&e) => {
106 tracing::warn!(
107 primary = self.primary.name(),
108 secondary = self.secondary.name(),
109 error = %e,
110 "provider failed; falling over"
111 );
112 let out = self.secondary.complete(request).await;
113 self.note(if out.is_ok() { 2 } else { 0 });
114 out
115 }
116 Err(e) => {
117 // Not worth another provider. The caller gets the primary's error,
118 // unchanged, so the kind they branch on is the real one. Nobody
119 // served this call, and nothing must be able to read that anybody
120 // did — a stale marker is worse than none.
121 self.note(0);
122 Err(e)
123 }
124 }
125 }
126
127 fn name(&self) -> &str {
128 &self.name
129 }
130
131 /// The primary's, for the single-endpoint callers that predate fallback.
132 /// [`endpoints`](Provider::endpoints) is what authorization uses.
133 fn endpoint(&self) -> Option<&str> {
134 self.primary.endpoint()
135 }
136
137 /// BOTH providers' endpoints.
138 ///
139 /// This is the reason `endpoints` exists at all. The 0.8.0 egress policy is
140 /// deny-by-default and a run authorizes its provider's host before its first
141 /// step; a combinator that reported only the primary's host would leave the
142 /// secondary's completely ungoverned, so a fallback would be a way to reach a
143 /// host the policy never saw.
144 fn endpoints(&self) -> Vec<&str> {
145 let mut out = self.primary.endpoints();
146 out.extend(self.secondary.endpoints());
147 out
148 }
149
150 /// The LEAF that answered, not the branch it sits in.
151 ///
152 /// A nested `Fallback::new(a, Fallback::new(b, c))` whose `c` answered must
153 /// record `"c"`, not `"b -> c"`: the row exists because one label for a whole
154 /// run stops being true the moment a run can use two, and naming a sub-chain
155 /// reintroduces exactly that ambiguity one level down.
156 fn last_served(&self) -> Option<String> {
157 match self.served.load(Ordering::Relaxed) {
158 1 => Some(
159 self.primary
160 .last_served()
161 .unwrap_or_else(|| self.primary.name().to_string()),
162 ),
163 2 => Some(
164 self.secondary
165 .last_served()
166 .unwrap_or_else(|| self.secondary.name().to_string()),
167 ),
168 _ => None,
169 }
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176 use crate::error::ProviderErrorKind;
177
178 struct Fixed {
179 label: &'static str,
180 result: fn() -> Result<CompletionResponse>,
181 endpoint: Option<&'static str>,
182 }
183
184 impl Provider for Fixed {
185 async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse> {
186 (self.result)()
187 }
188 fn name(&self) -> &str {
189 self.label
190 }
191 fn endpoint(&self) -> Option<&str> {
192 self.endpoint
193 }
194 }
195
196 fn ok() -> Result<CompletionResponse> {
197 Ok(CompletionResponse {
198 text: Some("hello".into()),
199 ..Default::default()
200 })
201 }
202 fn down() -> Result<CompletionResponse> {
203 Err(Error::provider_status(503, None, "down"))
204 }
205 fn bad_key() -> Result<CompletionResponse> {
206 Err(Error::provider_status(401, None, "bad key"))
207 }
208 fn boom() -> Result<CompletionResponse> {
209 panic!("the secondary must not be called");
210 }
211
212 #[allow(clippy::needless_update)] // `media` is cfg'd out in the default build
213 fn req() -> CompletionRequest {
214 CompletionRequest {
215 system: String::new(),
216 user: "hi".into(),
217 tools: Vec::new(),
218 ..Default::default()
219 }
220 }
221
222 fn p(label: &'static str, result: fn() -> Result<CompletionResponse>) -> Fixed {
223 Fixed {
224 label,
225 result,
226 endpoint: None,
227 }
228 }
229
230 #[tokio::test]
231 async fn a_down_primary_is_answered_by_the_secondary() {
232 let f = Fallback::new(p("first", down), p("second", ok));
233 let out = f.complete(req()).await.unwrap();
234 assert_eq!(out.text.as_deref(), Some("hello"));
235 assert_eq!(f.last_served().as_deref(), Some("second"));
236 assert_eq!(f.name(), "first -> second");
237 }
238
239 #[tokio::test]
240 async fn a_working_primary_is_never_backed_up() {
241 let f = Fallback::new(p("first", ok), p("second", boom));
242 assert!(f.complete(req()).await.is_ok());
243 assert_eq!(f.last_served().as_deref(), Some("first"));
244 }
245
246 #[tokio::test]
247 async fn a_failure_the_secondary_would_share_does_not_fall_over() {
248 // A wrong key is not more valid at a different vendor, and an unacceptable
249 // request is unacceptable twice. `boom` panics if this is got wrong.
250 let f = Fallback::new(p("first", bad_key), p("second", boom));
251 let err = f.complete(req()).await.unwrap_err();
252 let Error::Provider { kind, status, .. } = err else {
253 panic!("expected a provider error, got {err:?}");
254 };
255 assert_eq!(kind, ProviderErrorKind::Auth);
256 assert_eq!(status, Some(401));
257 // Nothing served it, so nothing is claimed to have.
258 assert_eq!(f.last_served(), None);
259 }
260
261 #[tokio::test]
262 async fn both_failing_reports_the_secondarys_error() {
263 let f = Fallback::new(p("first", down), p("second", bad_key));
264 let err = f.complete(req()).await.unwrap_err();
265 let Error::Provider { kind, .. } = err else {
266 panic!("expected a provider error");
267 };
268 // The last thing tried is the thing that failed, which is what a caller
269 // needs to act on — the primary's failure is in the trace and the log.
270 assert_eq!(kind, ProviderErrorKind::Auth);
271 }
272
273 #[tokio::test]
274 async fn three_providers_nest_and_the_leaf_is_what_gets_recorded() {
275 let f = Fallback::new(p("a", down), Fallback::new(p("b", down), p("c", ok)));
276 assert!(f.complete(req()).await.is_ok());
277 assert_eq!(f.name(), "a -> b -> c");
278 // Not "b -> c": the row has to name the provider, not the branch.
279 assert_eq!(f.last_served().as_deref(), Some("c"));
280 }
281
282 #[test]
283 fn authorization_sees_every_endpoint_in_the_chain() {
284 // The security property: a combinator reporting only the primary's host
285 // would let a fallback reach a host the deny-by-default egress policy never
286 // checked.
287 let a = Fixed {
288 label: "a",
289 result: ok,
290 endpoint: Some("https://a.example/v1"),
291 };
292 let b = Fixed {
293 label: "b",
294 result: ok,
295 endpoint: Some("https://b.example/v1"),
296 };
297 let f = Fallback::new(a, b);
298 assert_eq!(
299 f.endpoints(),
300 vec!["https://a.example/v1", "https://b.example/v1"]
301 );
302 // A provider with no endpoint contributes none, rather than a blank.
303 let g = Fallback::new(p("x", ok), p("y", ok));
304 assert!(g.endpoints().is_empty());
305 }
306}