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 /// Streams from whichever provider serves, as [`complete`](Provider::complete)
128 /// answers from whichever provider serves.
129 ///
130 /// Forwarded rather than left to the default, which would delegate to
131 /// `complete` and collapse a real stream into one delta on the one path a
132 /// caller is most likely to be watching. A fallover mid-stream means the
133 /// primary's deltas were already emitted and the secondary's answer arrives
134 /// whole — the caller sees the primary's partial text followed by the
135 /// secondary's complete text, which is why a delta is provisional until the
136 /// completion returns.
137 async fn complete_streaming(
138 &self,
139 request: CompletionRequest,
140 on_token: &(dyn Fn(&str) + Send + Sync),
141 ) -> Result<CompletionResponse> {
142 match self
143 .primary
144 .complete_streaming(request.clone(), on_token)
145 .await
146 {
147 Ok(response) => {
148 self.note(1);
149 Ok(response)
150 }
151 Err(e) if worth_another_provider(&e) => {
152 tracing::warn!(
153 primary = self.primary.name(),
154 secondary = self.secondary.name(),
155 error = %e,
156 "provider failed mid-stream; falling over"
157 );
158 let out = self.secondary.complete_streaming(request, on_token).await;
159 self.note(if out.is_ok() { 2 } else { 0 });
160 out
161 }
162 Err(e) => {
163 self.note(0);
164 Err(e)
165 }
166 }
167 }
168
169 fn name(&self) -> &str {
170 &self.name
171 }
172
173 /// The primary's, for the single-endpoint callers that predate fallback.
174 /// [`endpoints`](Provider::endpoints) is what authorization uses.
175 fn endpoint(&self) -> Option<&str> {
176 self.primary.endpoint()
177 }
178
179 /// BOTH providers' endpoints.
180 ///
181 /// This is the reason `endpoints` exists at all. The 0.8.0 egress policy is
182 /// deny-by-default and a run authorizes its provider's host before its first
183 /// step; a combinator that reported only the primary's host would leave the
184 /// secondary's completely ungoverned, so a fallback would be a way to reach a
185 /// host the policy never saw.
186 fn endpoints(&self) -> Vec<&str> {
187 let mut out = self.primary.endpoints();
188 out.extend(self.secondary.endpoints());
189 out
190 }
191
192 /// The LEAF that answered, not the branch it sits in.
193 ///
194 /// A nested `Fallback::new(a, Fallback::new(b, c))` whose `c` answered must
195 /// record `"c"`, not `"b -> c"`: the row exists because one label for a whole
196 /// run stops being true the moment a run can use two, and naming a sub-chain
197 /// reintroduces exactly that ambiguity one level down.
198 fn last_served(&self) -> Option<String> {
199 match self.served.load(Ordering::Relaxed) {
200 1 => Some(
201 self.primary
202 .last_served()
203 .unwrap_or_else(|| self.primary.name().to_string()),
204 ),
205 2 => Some(
206 self.secondary
207 .last_served()
208 .unwrap_or_else(|| self.secondary.name().to_string()),
209 ),
210 _ => None,
211 }
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use crate::error::ProviderErrorKind;
219
220 struct Fixed {
221 label: &'static str,
222 result: fn() -> Result<CompletionResponse>,
223 endpoint: Option<&'static str>,
224 }
225
226 impl Provider for Fixed {
227 async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse> {
228 (self.result)()
229 }
230 fn name(&self) -> &str {
231 self.label
232 }
233 fn endpoint(&self) -> Option<&str> {
234 self.endpoint
235 }
236 }
237
238 fn ok() -> Result<CompletionResponse> {
239 Ok(CompletionResponse {
240 text: Some("hello".into()),
241 ..Default::default()
242 })
243 }
244 fn down() -> Result<CompletionResponse> {
245 Err(Error::provider_status(503, None, "down"))
246 }
247 fn bad_key() -> Result<CompletionResponse> {
248 Err(Error::provider_status(401, None, "bad key"))
249 }
250 fn boom() -> Result<CompletionResponse> {
251 panic!("the secondary must not be called");
252 }
253
254 #[allow(clippy::needless_update)] // `media` is cfg'd out in the default build
255 fn req() -> CompletionRequest {
256 CompletionRequest {
257 system: String::new(),
258 user: "hi".into(),
259 tools: Vec::new(),
260 ..Default::default()
261 }
262 }
263
264 fn p(label: &'static str, result: fn() -> Result<CompletionResponse>) -> Fixed {
265 Fixed {
266 label,
267 result,
268 endpoint: None,
269 }
270 }
271
272 #[tokio::test]
273 async fn a_down_primary_is_answered_by_the_secondary() {
274 let f = Fallback::new(p("first", down), p("second", ok));
275 let out = f.complete(req()).await.unwrap();
276 assert_eq!(out.text.as_deref(), Some("hello"));
277 assert_eq!(f.last_served().as_deref(), Some("second"));
278 assert_eq!(f.name(), "first -> second");
279 }
280
281 #[tokio::test]
282 async fn a_working_primary_is_never_backed_up() {
283 let f = Fallback::new(p("first", ok), p("second", boom));
284 assert!(f.complete(req()).await.is_ok());
285 assert_eq!(f.last_served().as_deref(), Some("first"));
286 }
287
288 #[tokio::test]
289 async fn a_failure_the_secondary_would_share_does_not_fall_over() {
290 // A wrong key is not more valid at a different vendor, and an unacceptable
291 // request is unacceptable twice. `boom` panics if this is got wrong.
292 let f = Fallback::new(p("first", bad_key), p("second", boom));
293 let err = f.complete(req()).await.unwrap_err();
294 let Error::Provider { kind, status, .. } = err else {
295 panic!("expected a provider error, got {err:?}");
296 };
297 assert_eq!(kind, ProviderErrorKind::Auth);
298 assert_eq!(status, Some(401));
299 // Nothing served it, so nothing is claimed to have.
300 assert_eq!(f.last_served(), None);
301 }
302
303 #[tokio::test]
304 async fn both_failing_reports_the_secondarys_error() {
305 let f = Fallback::new(p("first", down), p("second", bad_key));
306 let err = f.complete(req()).await.unwrap_err();
307 let Error::Provider { kind, .. } = err else {
308 panic!("expected a provider error");
309 };
310 // The last thing tried is the thing that failed, which is what a caller
311 // needs to act on — the primary's failure is in the trace and the log.
312 assert_eq!(kind, ProviderErrorKind::Auth);
313 }
314
315 #[tokio::test]
316 async fn three_providers_nest_and_the_leaf_is_what_gets_recorded() {
317 let f = Fallback::new(p("a", down), Fallback::new(p("b", down), p("c", ok)));
318 assert!(f.complete(req()).await.is_ok());
319 assert_eq!(f.name(), "a -> b -> c");
320 // Not "b -> c": the row has to name the provider, not the branch.
321 assert_eq!(f.last_served().as_deref(), Some("c"));
322 }
323
324 #[test]
325 fn authorization_sees_every_endpoint_in_the_chain() {
326 // The security property: a combinator reporting only the primary's host
327 // would let a fallback reach a host the deny-by-default egress policy never
328 // checked.
329 let a = Fixed {
330 label: "a",
331 result: ok,
332 endpoint: Some("https://a.example/v1"),
333 };
334 let b = Fixed {
335 label: "b",
336 result: ok,
337 endpoint: Some("https://b.example/v1"),
338 };
339 let f = Fallback::new(a, b);
340 assert_eq!(
341 f.endpoints(),
342 vec!["https://a.example/v1", "https://b.example/v1"]
343 );
344 // A provider with no endpoint contributes none, rather than a blank.
345 let g = Fallback::new(p("x", ok), p("y", ok));
346 assert!(g.endpoints().is_empty());
347 }
348}