io_harness/provider/mod.rs
1//! The provider layer — provider-agnostic by design.
2//!
3//! No vendor type appears in these public types. A [`Provider`] takes a
4//! [`CompletionRequest`] and returns a [`CompletionResponse`]; OpenRouter,
5//! Anthropic, and OpenAI are implementation details behind the trait.
6
7pub mod anthropic;
8pub mod openai;
9pub(crate) mod openai_wire;
10pub mod openrouter;
11
12pub mod fallback;
13pub mod record;
14pub mod replay;
15pub use anthropic::Anthropic;
16pub use fallback::Fallback;
17pub use openai::OpenAi;
18pub use openrouter::OpenRouter;
19pub use record::Record;
20pub use replay::Replay;
21
22use futures_util::StreamExt;
23
24use crate::error::{Error, Result};
25
26/// Turn a non-success response into a typed [`Error::Provider`], preserving the
27/// status and the server's `Retry-After`.
28///
29/// Every provider funnels through here rather than inspecting `status()` itself:
30/// the three did it identically before this existed, and one place is what stops
31/// them from drifting into disagreeing about what a 429 means.
32pub(crate) async fn ensure_success(resp: reqwest::Response) -> Result<reqwest::Response> {
33 let status = resp.status();
34 if status.is_success() {
35 return Ok(resp);
36 }
37 // Read the header before the body: `text()` consumes the response.
38 let retry_after = crate::net::retry_after(resp.headers());
39 let detail = resp.text().await.unwrap_or_default();
40 let detail = detail.trim();
41 Err(Error::provider_status(
42 status.as_u16(),
43 retry_after,
44 if detail.is_empty() {
45 status.canonical_reason().unwrap_or("no detail").to_string()
46 } else {
47 detail.to_string()
48 },
49 ))
50}
51
52/// Reject a response that parsed to nothing at all.
53///
54/// A stream that completed but yielded no text, no tool call and no usage is a
55/// failure the loop cannot see: [`CompletionResponse::default`] reads exactly
56/// like "the model chose not to call a tool", so a truncated or garbled transfer
57/// ends the run as if the model had decided to stop. Naming it
58/// [`crate::error::ProviderErrorKind::Malformed`] makes it retryable instead of
59/// invisible.
60///
61/// A response with text and no tool call is *not* this: that is a model that
62/// answered without calling anything, and it keeps its meaning exactly.
63pub(crate) fn ensure_parsed(response: CompletionResponse) -> Result<CompletionResponse> {
64 if response.text.is_none() && response.tool_calls.is_empty() && response.usage.is_none() {
65 return Err(Error::provider_malformed(
66 "the response stream yielded no text, no tool call and no usage",
67 ));
68 }
69 Ok(response)
70}
71
72/// Read an SSE byte stream line by line, handing each `data:` payload (the text
73/// after `data:`) to `ingest`. `ingest` returns `true` to stop early on a
74/// provider's terminal event. Shared by every provider so the transport lives
75/// in one place; each provider supplies its own JSON accumulation.
76pub(crate) async fn read_sse<F>(resp: reqwest::Response, mut ingest: F) -> Result<()>
77where
78 F: FnMut(&str) -> bool,
79{
80 let mut stream = resp.bytes_stream();
81 let mut buf = String::new();
82 while let Some(chunk) = stream.next().await {
83 // A mid-stream byte error is Transport; a stream that stalls past the
84 // client's deadline is Timeout. `From<reqwest::Error>` decides both.
85 let chunk = chunk?;
86 buf.push_str(&String::from_utf8_lossy(&chunk));
87 while let Some(nl) = buf.find('\n') {
88 let line = buf[..nl].trim_end_matches('\r').to_string();
89 buf.drain(..=nl);
90 let Some(data) = line.strip_prefix("data:") else {
91 continue;
92 };
93 let data = data.trim();
94 if data.is_empty() {
95 continue;
96 }
97 if ingest(data) {
98 return Ok(());
99 }
100 }
101 }
102 Ok(())
103}
104
105/// A tool the model may call, described in a vendor-neutral shape.
106///
107/// ```
108/// use io_harness::ToolSpec;
109///
110/// // One description, whichever provider runs: the crate translates this into
111/// // Anthropic's `input_schema` or OpenAI's `function.parameters` at the wire
112/// // boundary, so a tool is not re-described per vendor.
113/// let spec = ToolSpec {
114/// name: "lookup_order".into(),
115/// // The model reads this to decide whether to call it, so it is a sentence
116/// // about when to use the tool, not a restatement of the name.
117/// description: "Look up an order by its id. Use when the user names an order.".into(),
118/// parameters: serde_json::json!({
119/// "type": "object",
120/// "properties": { "order_id": { "type": "string" } },
121/// "required": ["order_id"]
122/// }),
123/// };
124///
125/// // The arguments a model then sends arrive shaped by this schema — see
126/// // `Tool::invoke`, which receives them as a `serde_json::Value`.
127/// assert_eq!(spec.parameters["required"][0], "order_id");
128/// ```
129#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
130pub struct ToolSpec {
131 /// Tool name the model calls.
132 pub name: String,
133 /// What the tool does, for the model.
134 pub description: String,
135 /// JSON Schema of the arguments object.
136 pub parameters: serde_json::Value,
137}
138
139/// An image handed to the model alongside the task.
140///
141/// The bytes are held already base64-encoded, because that is the form every
142/// provider's wire format wants: Anthropic takes base64 in an image content
143/// block, and the OpenAI-shaped bodies take it inside a `data:` URL. Encoding
144/// once where the file is read, rather than once per provider, also keeps the
145/// replay key — which is the whole serialized request (`Replay::key`) — a
146/// string rather than a JSON array with one number per byte.
147///
148/// Images only. Video is not on the roadmap: the Anthropic Messages API and the
149/// OpenAI Chat Completions API accept no video at all, and OpenRouter, the only
150/// one of the three with a `video_url` part, states support varies by model and
151/// offers no way to ask which. Audio is likewise absent.
152///
153/// ```
154/// use io_harness::Media;
155///
156/// # fn main() -> io_harness::Result<()> {
157/// // In a real caller these bytes come from `std::fs::read`; the type is inferred
158/// // from the path rather than trusted from a client, so an `.exe` renamed to
159/// // `.png` is still refused by the vendor and never by a guess made here.
160/// let bytes = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
161/// let media_type = Media::media_type_for("screenshot.png").expect("an image extension");
162/// let image = Media::image(media_type, &bytes)?;
163///
164/// // Attach it to a request, or record what was sent: `byte_len` is the decoded
165/// // size the request-wide bound is counted in, and `digest` answers "is this the
166/// // same image as last step" in the trace.
167/// assert_eq!(image.byte_len(), bytes.len());
168/// assert_eq!(image.digest().len(), 16);
169///
170/// // A type outside the set every provider documents is refused here, rather than
171/// // costing a step and coming back as an HTTP 400 that reads like a transport
172/// // failure.
173/// assert!(Media::image("image/tiff", &bytes).is_err());
174/// # Ok(())
175/// # }
176/// ```
177#[cfg(feature = "media")]
178#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
179pub struct Media {
180 /// IANA media type. One of `image/jpeg`, `image/png`, `image/gif` or
181 /// `image/webp` — the intersection all three providers document.
182 pub media_type: String,
183 /// Standard base64 of the image bytes: no `data:` prefix and no line breaks.
184 /// Construct through [`Media::image`] rather than filling this in by hand.
185 pub base64: String,
186}
187
188/// The image media types all three providers document accepting. A type outside
189/// this set is refused at construction rather than sent for a vendor to reject
190/// with a 400 that costs a step and reads like a transport failure.
191///
192/// ```
193/// use io_harness::{Media, IMAGE_MEDIA_TYPES};
194///
195/// // Screen an upload before it reaches a run, so the user is told at the point
196/// // they can fix it rather than one step and one billed request later.
197/// fn accept(upload_type: &str) -> Result<(), String> {
198/// if IMAGE_MEDIA_TYPES.contains(&upload_type) {
199/// return Ok(());
200/// }
201/// Err(format!("{upload_type} is not one of {}", IMAGE_MEDIA_TYPES.join(", ")))
202/// }
203///
204/// assert!(accept("image/png").is_ok());
205/// assert!(accept("image/tiff").is_err());
206///
207/// // The set is the same one `Media::image` enforces — reading it is how a caller
208/// // agrees with the constructor instead of maintaining a second list.
209/// for media_type in IMAGE_MEDIA_TYPES {
210/// assert!(Media::image(media_type, b"not really an image").is_ok());
211/// }
212/// ```
213#[cfg(feature = "media")]
214pub const IMAGE_MEDIA_TYPES: [&str; 4] = ["image/jpeg", "image/png", "image/gif", "image/webp"];
215
216/// The largest single image, in decoded bytes.
217///
218/// Anthropic documents 5MB per image; OpenAI allows a larger total payload. The
219/// smaller of the two is the honest bound for a provider-agnostic crate: an
220/// image that would be refused by one vendor and accepted by another is worse
221/// than one refused here, because the refusal there costs a step and arrives as
222/// an HTTP 400 that reads like a transport failure.
223#[cfg(feature = "media")]
224pub const MAX_IMAGE_BYTES: usize = 5 * 1024 * 1024;
225
226/// The largest total of all images on one request, in decoded bytes.
227///
228/// Exists because the per-image bound does not compose: sixteen images each
229/// under the single-image limit is a request no budget anticipated. This is the
230/// bound the run loop enforces when it attaches the caller's images and whatever
231/// the agent has just looked at.
232#[cfg(feature = "media")]
233pub const MAX_REQUEST_IMAGE_BYTES: usize = 20 * 1024 * 1024;
234
235#[cfg(feature = "media")]
236impl Media {
237 /// Encode image bytes for the provider boundary.
238 ///
239 /// Fails when `media_type` is not one of [`IMAGE_MEDIA_TYPES`]. The bytes
240 /// themselves are not parsed: whether they are a valid PNG is the vendor's
241 /// judgement, and guessing here would mean adding an image decoder to the
242 /// default path of a crate that has one only behind the barcode feature.
243 pub fn image(media_type: impl Into<String>, bytes: &[u8]) -> Result<Self> {
244 use base64::Engine as _;
245 let media_type = media_type.into();
246 if !IMAGE_MEDIA_TYPES.contains(&media_type.as_str()) {
247 return Err(Error::Config(format!(
248 "unsupported image media type {media_type:?}: expected one of {}",
249 IMAGE_MEDIA_TYPES.join(", ")
250 )));
251 }
252 if bytes.len() > MAX_IMAGE_BYTES {
253 return Err(Error::Config(format!(
254 "image is {} bytes, over the {MAX_IMAGE_BYTES}-byte per-image bound; \
255 resize it before attaching rather than sending it truncated",
256 bytes.len()
257 )));
258 }
259 Ok(Self {
260 media_type,
261 base64: base64::engine::general_purpose::STANDARD.encode(bytes),
262 })
263 }
264
265 /// The media type inferred from a path's extension, for the built-in that
266 /// takes a path from the model. `None` when the extension is not an image
267 /// type every provider accepts — which the caller reports as a refusal the
268 /// model can act on, rather than sending bytes no vendor will read.
269 pub fn media_type_for(path: &str) -> Option<&'static str> {
270 let ext = path.rsplit('.').next()?.to_ascii_lowercase();
271 Some(match ext.as_str() {
272 "jpg" | "jpeg" => "image/jpeg",
273 "png" => "image/png",
274 "gif" => "image/gif",
275 "webp" => "image/webp",
276 _ => return None,
277 })
278 }
279
280 /// Decoded byte length, for the size bound and the trace.
281 ///
282 /// Derived from the encoded length rather than by decoding: base64 is four
283 /// characters per three bytes, less the padding.
284 pub fn byte_len(&self) -> usize {
285 let pad = self.base64.bytes().rev().take_while(|b| *b == b'=').count();
286 // Saturating because this is reachable from a trust boundary: an MCP
287 // server hands over its own base64, and a stub payload like `"="` would
288 // otherwise underflow and panic in a debug build. A wrong size on
289 // malformed input is a wrong size; a panic ends the run.
290 (self.base64.len() / 4 * 3).saturating_sub(pad)
291 }
292
293 /// A short digest of the encoded image, for the trace.
294 ///
295 /// Deliberately not cryptographic — it answers "is this the same image the
296 /// last step sent?" and nothing else, and it uses the standard library so
297 /// that recording what was sent costs no dependency. Do not treat a match as
298 /// proof of identity.
299 pub fn digest(&self) -> String {
300 use std::hash::{Hash as _, Hasher as _};
301 let mut h = std::collections::hash_map::DefaultHasher::new();
302 self.media_type.hash(&mut h);
303 self.base64.hash(&mut h);
304 format!("{:016x}", h.finish())
305 }
306}
307
308/// A request for one model completion.
309///
310/// Construct with `..Default::default()` for forward compatibility — fields are
311/// added in minor releases (`media` in 0.15.0). An exhaustive struct literal
312/// will not survive the next one.
313///
314/// ```no_run
315/// use io_harness::{CompletionRequest, OpenRouter, Provider, ToolSpec};
316///
317/// # async fn demo() -> io_harness::Result<()> {
318/// let request = CompletionRequest {
319/// system: "You answer with one sentence.".into(),
320/// user: "Which crate builds the workspace?".into(),
321/// // Offering a tool here is what allows the model to reply with a
322/// // `ToolCall` instead of text; an empty `tools` guarantees prose.
323/// tools: vec![ToolSpec {
324/// name: "grep".into(),
325/// description: "Search the workspace for a pattern.".into(),
326/// parameters: serde_json::json!({
327/// "type": "object",
328/// "properties": { "pattern": { "type": "string" } },
329/// "required": ["pattern"]
330/// }),
331/// }],
332/// // Never an exhaustive literal: `media` appeared in 0.15.0 and the next
333/// // field will appear the same way, in a minor.
334/// ..Default::default()
335/// };
336///
337/// let response = OpenRouter::from_env()?.complete(request).await?;
338/// # let _ = response;
339/// # Ok(())
340/// # }
341/// ```
342#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
343pub struct CompletionRequest {
344 /// System instructions.
345 pub system: String,
346 /// The user turn.
347 pub user: String,
348 /// Tools the model may call.
349 pub tools: Vec<ToolSpec>,
350 /// Images the model should see alongside `user`.
351 ///
352 /// A provider that does not accept images refuses a request carrying any,
353 /// before the body is built and before anything is spent — see
354 /// `ensure_media_accepted`. Media is never silently dropped: a run that
355 /// paid for an answer about an image the model never received is the failure
356 /// this field exists to make impossible.
357 #[cfg(feature = "media")]
358 #[serde(default, skip_serializing_if = "Vec::is_empty")]
359 pub media: Vec<Media>,
360}
361
362/// Refuse a request carrying media that `provider` does not accept.
363///
364/// Called by the run loop before every completion, so the boundary covers an
365/// out-of-tree [`Provider`] as well as the three built in — and called again
366/// inside each built-in provider, so reaching one directly cannot bypass it.
367///
368/// This is an [`Error::Config`] rather than a provider error because nothing
369/// went wrong on the wire: a text-only provider was paired with an image, which
370/// is a decision the caller made and can fix.
371#[cfg(feature = "media")]
372pub(crate) fn ensure_media_accepted(
373 name: &str,
374 accepts: bool,
375 request: &CompletionRequest,
376) -> Result<()> {
377 if request.media.is_empty() || accepts {
378 return Ok(());
379 }
380 Err(Error::Config(format!(
381 "provider {name:?} does not accept image input, and the request carries {} image(s); \
382 no request was sent",
383 request.media.len()
384 )))
385}
386
387/// A tool call the model decided to make.
388///
389/// ```
390/// use io_harness::ToolCall;
391///
392/// // What a `Tool` implementation is handed, and what the run loop dispatches on.
393/// let call = ToolCall {
394/// name: "write_file".into(),
395/// arguments: serde_json::json!({ "path": "NOTES.md", "content": "# Notes" }),
396/// };
397///
398/// // `arguments` is whatever the model produced, not something the crate
399/// // validated: read it defensively. A missing field is a tool result the model
400/// // can correct, where an unwrap would be a panic that ends the run.
401/// let Some(path) = call.arguments.get("path").and_then(|v| v.as_str()) else {
402/// panic!("reply with an error string here, do not unwrap in a real tool");
403/// };
404/// assert_eq!(path, "NOTES.md");
405/// assert_eq!(call.arguments.get("mode").and_then(|v| v.as_str()), None);
406/// ```
407#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
408pub struct ToolCall {
409 /// Tool name.
410 pub name: String,
411 /// Parsed arguments object.
412 pub arguments: serde_json::Value,
413}
414
415/// Token usage for one completion, in a vendor-neutral shape. Used to enforce
416/// the cost budget and to record spend in the trace.
417///
418/// ```
419/// use io_harness::{Containment, Draw, Ledger, Usage};
420///
421/// let ledger = Ledger::new(&Containment::new(4, 2, 1, 10_000));
422/// let usage = Usage { prompt_tokens: 4_000, completion_tokens: 500, total_tokens: 4_500 };
423///
424/// // `total_tokens` is the figure the budget is enforced in: the provider's own
425/// // total, taken as reported rather than re-derived from the other two.
426/// assert_eq!(ledger.draw_tokens(usage.total_tokens), Draw::Ok);
427/// assert_eq!(ledger.remaining_tokens(), 5_500);
428///
429/// // A provider that reports nothing gives `None`, not a zero: an unknown cost
430/// // and a free step are different facts, and the loop treats them differently.
431/// let unknown: Option<Usage> = None;
432/// assert_eq!(unknown.map(|u| u.total_tokens), None);
433/// ```
434#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
435pub struct Usage {
436 /// Tokens in the prompt.
437 pub prompt_tokens: u64,
438 /// Tokens the model generated.
439 pub completion_tokens: u64,
440 /// Total tokens billed for this completion.
441 pub total_tokens: u64,
442}
443
444/// One model completion.
445///
446/// Construct with `..Default::default()` for forward compatibility — fields are
447/// added in minor releases (e.g. `usage` in 0.2.0).
448///
449/// ```
450/// use io_harness::{CompletionResponse, ToolCall, Usage};
451///
452/// let response = CompletionResponse {
453/// tool_calls: vec![ToolCall {
454/// name: "write_file".into(),
455/// arguments: serde_json::json!({ "path": "NOTES.md" }),
456/// }],
457/// usage: Some(Usage { prompt_tokens: 1_200, completion_tokens: 80, total_tokens: 1_280 }),
458/// ..Default::default()
459/// };
460///
461/// // The branch a run loop takes: tool calls first, in the order the model made
462/// // them, and free text only when there are none. A model may return both, and
463/// // reading `text` first would drop the work it asked for.
464/// if let Some(call) = response.tool_calls.first() {
465/// assert_eq!(call.name, "write_file");
466/// } else {
467/// println!("the model answered instead of acting: {:?}", response.text);
468/// }
469///
470/// // Usage is what the step's budget draw is made from; `None` means the provider
471/// // said nothing, which is why the field is an `Option` rather than a zero.
472/// assert_eq!(response.usage.map(|u| u.total_tokens), Some(1_280));
473/// ```
474#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
475pub struct CompletionResponse {
476 /// Any free text the model returned.
477 pub text: Option<String>,
478 /// Tool calls the model requested, in order.
479 pub tool_calls: Vec<ToolCall>,
480 /// Token usage, when the provider reports it. `None` if unknown.
481 pub usage: Option<Usage>,
482}
483
484/// Anything that can turn a [`CompletionRequest`] into a [`CompletionResponse`].
485///
486/// Implemented by [`OpenRouter`], [`Anthropic`], and [`OpenAi`]; tests supply
487/// their own to run the loop offline. Selecting a provider is just constructing
488/// a different implementer and handing it to [`crate::run`] — no vendor type
489/// appears in the task contract.
490///
491/// ```
492/// use io_harness::{CompletionRequest, CompletionResponse, Provider, Result, Usage};
493///
494/// // A provider that answers from a script: the whole run loop — tools, policy,
495/// // checkpoints, verification — driven with no key, no socket, and no spend.
496/// struct Scripted {
497/// replies: std::sync::Mutex<std::vec::IntoIter<CompletionResponse>>,
498/// }
499///
500/// impl Provider for Scripted {
501/// async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse> {
502/// Ok(self.replies.lock().unwrap().next().unwrap_or_default())
503/// }
504///
505/// // Recorded per step in the trace, so an audit says who answered.
506/// fn name(&self) -> &str {
507/// "scripted"
508/// }
509///
510/// // `endpoint` is left at its `None` default: this one opens no connection,
511/// // so the egress policy has nothing to authorize. A provider that does dial
512/// // must report its URL here, or it is reaching the network unchecked.
513/// }
514///
515/// let provider = Scripted {
516/// replies: std::sync::Mutex::new(vec![CompletionResponse {
517/// text: Some("done".into()),
518/// usage: Some(Usage { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 }),
519/// ..Default::default()
520/// }]
521/// .into_iter()),
522/// };
523/// assert_eq!(provider.name(), "scripted");
524/// assert_eq!(provider.endpoint(), None);
525/// ```
526pub trait Provider {
527 /// Perform one completion.
528 fn complete(
529 &self,
530 request: CompletionRequest,
531 ) -> impl std::future::Future<Output = Result<CompletionResponse>> + Send;
532
533 /// A short label recorded in the run's trace so an audit shows which
534 /// provider ran. Defaults to `"provider"` so existing implementers keep
535 /// compiling; the built-in providers override it.
536 fn name(&self) -> &str {
537 "provider"
538 }
539
540 /// Whether this provider's model accepts image input.
541 ///
542 /// Defaults to `false`, and the default is the point: an implementation
543 /// written before 0.15.0 keeps compiling *and* inherits a refusal rather
544 /// than a silent drop. A run that spent money on a confident answer about an
545 /// image the model never received is the failure this governs, and it is
546 /// invisible from the outside — the response looks exactly like success.
547 ///
548 /// The three built-in providers override it to `true`. Whether the specific
549 /// *model* configured behind them accepts images is the vendor's business
550 /// and not knowable here; this reports what the API accepts.
551 #[cfg(feature = "media")]
552 fn accepts_images(&self) -> bool {
553 false
554 }
555
556 /// The URL this provider dials, if it dials one.
557 ///
558 /// The run authorizes this against the policy's [`crate::Act::Net`] rules
559 /// before the first completion, and contributes its host as the named
560 /// `provider` layer so a network-deny base can still reach its model.
561 ///
562 /// Defaults to `None`, which means "opens no connection" — the honest answer
563 /// for the mock providers tests drive the loop with, and what keeps every
564 /// existing implementer compiling. A `None` provider is not exempt from the
565 /// boundary; it simply has no connection for the boundary to govern.
566 fn endpoint(&self) -> Option<&str> {
567 None
568 }
569
570 /// Every host this provider may dial, for the 0.8.0 egress policy to authorize
571 /// before the run's first step.
572 ///
573 /// Defaults to whatever [`endpoint`](Provider::endpoint) reports, so an existing
574 /// implementation needs no change. A combinator that can reach more than one
575 /// host — [`Fallback`] — overrides it, because reporting only the first would
576 /// leave the rest ungoverned by a policy that is deny-by-default everywhere
577 /// else.
578 fn endpoints(&self) -> Vec<&str> {
579 self.endpoint().into_iter().collect()
580 }
581
582 /// The provider that actually answered the last call, when this is a combinator
583 /// and the answer is not obvious from configuration.
584 ///
585 /// `None` for a plain provider: [`name`](Provider::name) already says who it
586 /// was. [`Fallback`] returns whichever of its two served, so the run loop can
587 /// record it per step rather than recording one label for the whole run.
588 fn last_served(&self) -> Option<String> {
589 None
590 }
591}
592
593/// Every provider failure, against a real socket.
594///
595/// These live here rather than in `tests/` because the endpoint override the
596/// fixtures need is crate-internal — the public API pins each provider to its
597/// vendor's URL, and a test that mocked the error instead of serving it would not
598/// exercise the status parsing, the header parsing, or the deadline.
599#[cfg(test)]
600mod failures {
601 use std::io::{Read, Write};
602 use std::net::TcpListener;
603 use std::time::Duration;
604
605 use super::*;
606 use crate::error::ProviderErrorKind as Kind;
607 use crate::net::{http_date, unix_now};
608
609 /// A local HTTP server that answers every connection with one canned raw
610 /// response, then closes.
611 fn serve(response: String) -> String {
612 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
613 let url = format!("http://{}/v1", listener.local_addr().unwrap());
614 std::thread::spawn(move || {
615 for stream in listener.incoming() {
616 let Ok(mut stream) = stream else { break };
617 drain_request(&mut stream);
618 let _ = stream.write_all(response.as_bytes());
619 let _ = stream.flush();
620 }
621 });
622 url
623 }
624
625 /// Read the request head and its body, so the client is never answered before
626 /// its own write has been consumed — an unread body can surface as a reset
627 /// instead of the status under test.
628 fn drain_request(stream: &mut std::net::TcpStream) {
629 let mut seen = Vec::new();
630 let mut byte = [0u8; 1];
631 while stream.read(&mut byte).unwrap_or(0) == 1 {
632 seen.push(byte[0]);
633 if seen.ends_with(b"\r\n\r\n") {
634 break;
635 }
636 }
637 let head = String::from_utf8_lossy(&seen).to_ascii_lowercase();
638 let len: usize = head
639 .lines()
640 .find_map(|l| l.strip_prefix("content-length:"))
641 .and_then(|v| v.trim().parse().ok())
642 .unwrap_or(0);
643 let mut body = vec![0u8; len];
644 let _ = stream.read_exact(&mut body);
645 }
646
647 /// A status response with `extra` header lines (each already `\r\n`-free).
648 fn status_response(status: &str, extra: &[&str]) -> String {
649 let body = "{\"error\":\"nope\"}";
650 let mut head = format!("HTTP/1.1 {status}\r\nContent-Length: {}\r\n", body.len());
651 for line in extra {
652 head.push_str(line);
653 head.push_str("\r\n");
654 }
655 format!("{head}\r\n{body}")
656 }
657
658 /// A 200 whose body is `events`, delimited by the close rather than a length —
659 /// what a real streamed response looks like.
660 fn stream_response(events: &str) -> String {
661 format!("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n{events}")
662 }
663
664 #[allow(clippy::needless_update)] // `media` is cfg'd out in the default build
665 fn request() -> CompletionRequest {
666 CompletionRequest {
667 system: "s".into(),
668 user: "u".into(),
669 tools: Vec::new(),
670 ..Default::default()
671 }
672 }
673
674 /// The kind and status of a failed call, so no assertion reads the rendered
675 /// string.
676 fn failure(result: Result<CompletionResponse>) -> (Kind, Option<u16>, Option<Duration>) {
677 match result {
678 Err(Error::Provider {
679 kind,
680 status,
681 retry_after,
682 ..
683 }) => (kind, status, retry_after),
684 other => panic!("expected a provider error, got {other:?}"),
685 }
686 }
687
688 /// A provider pointed at `url`, one second of patience.
689 fn openrouter(url: &str) -> OpenRouter {
690 OpenRouter::at(url, Duration::from_secs(1))
691 }
692
693 #[tokio::test]
694 async fn a_refused_connection_is_transport() {
695 // Bind then drop: nothing is listening on that port now.
696 let dead = TcpListener::bind("127.0.0.1:0").unwrap();
697 let url = format!("http://{}/v1", dead.local_addr().unwrap());
698 drop(dead);
699
700 let (kind, status, _) = failure(openrouter(&url).complete(request()).await);
701 // `Transport` on unix, where the stack refuses immediately. On Windows a
702 // connect to a closed local port is retransmitted rather than refused, so
703 // the client's own deadline fires first and the kind is `Timeout`. Both mean
704 // the request never reached a model and both are retryable, which is the
705 // property that matters; which of the two the OS reports is not ours to
706 // decide, and asserting one made the Windows leg red for a platform
707 // difference rather than a defect.
708 assert!(
709 matches!(kind, Kind::Transport | Kind::Timeout),
710 "a connection that never opened must be Transport or Timeout, got {kind:?}"
711 );
712 assert!(
713 kind.is_retryable(),
714 "a connection that never opened is worth another attempt"
715 );
716 assert_eq!(
717 status, None,
718 "a connection that never happened has no status"
719 );
720 assert!(kind.is_retryable());
721 }
722
723 /// F3 — a socket that accepts and then never writes ends as `Timeout`, not as
724 /// a run that hangs forever.
725 #[tokio::test]
726 async fn a_server_that_accepts_and_never_answers_ends_as_a_timeout() {
727 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
728 let url = format!("http://{}/v1", listener.local_addr().unwrap());
729 // Hold the accepted connection open and write nothing to it.
730 std::thread::spawn(move || {
731 let held: Vec<_> = listener.incoming().filter_map(|s| s.ok()).collect();
732 std::thread::sleep(Duration::from_secs(30));
733 drop(held);
734 });
735
736 let started = std::time::Instant::now();
737 let (kind, status, _) = failure(
738 OpenRouter::at(&url, Duration::from_millis(300))
739 .complete(request())
740 .await,
741 );
742 assert_eq!(kind, Kind::Timeout);
743 assert_eq!(status, None);
744 assert!(kind.is_retryable());
745 assert!(
746 started.elapsed() < Duration::from_secs(10),
747 "the deadline, not the server, ended the call"
748 );
749 }
750
751 #[tokio::test]
752 async fn a_rate_limit_without_a_retry_after_is_rate_limited_and_carries_no_wait() {
753 let url = serve(status_response("429 Too Many Requests", &[]));
754 let (kind, status, retry_after) = failure(openrouter(&url).complete(request()).await);
755 assert_eq!(kind, Kind::RateLimited);
756 assert_eq!(status, Some(429));
757 assert_eq!(retry_after, None);
758 }
759
760 #[tokio::test]
761 async fn a_rate_limit_with_delta_seconds_keeps_the_wait_the_server_asked_for() {
762 let url = serve(status_response(
763 "429 Too Many Requests",
764 &["Retry-After: 11"],
765 ));
766 let (kind, status, retry_after) = failure(openrouter(&url).complete(request()).await);
767 assert_eq!(kind, Kind::RateLimited);
768 assert_eq!(status, Some(429));
769 assert_eq!(retry_after, Some(Duration::from_secs(11)));
770 }
771
772 #[tokio::test]
773 async fn a_rate_limit_with_an_http_date_keeps_the_wait_until_that_date() {
774 let header = format!("Retry-After: {}", http_date(unix_now() + 45));
775 let url = serve(status_response("429 Too Many Requests", &[&header]));
776 let (kind, status, retry_after) = failure(openrouter(&url).complete(request()).await);
777 assert_eq!(kind, Kind::RateLimited);
778 assert_eq!(status, Some(429));
779 let waited = retry_after.expect("the date is a wait");
780 assert!(
781 waited > Duration::from_secs(40) && waited <= Duration::from_secs(45),
782 "{waited:?}"
783 );
784 }
785
786 #[tokio::test]
787 async fn a_server_error_is_server_and_retryable() {
788 let url = serve(status_response("503 Service Unavailable", &[]));
789 let (kind, status, _) = failure(openrouter(&url).complete(request()).await);
790 assert_eq!(kind, Kind::Server);
791 assert_eq!(status, Some(503));
792 assert!(kind.is_retryable());
793 }
794
795 #[tokio::test]
796 async fn a_rejected_key_is_auth_and_not_retryable() {
797 let url = serve(status_response("401 Unauthorized", &[]));
798 let (kind, status, _) = failure(openrouter(&url).complete(request()).await);
799 assert_eq!(kind, Kind::Auth);
800 assert_eq!(status, Some(401));
801 assert!(!kind.is_retryable(), "a wrong key stays wrong");
802 }
803
804 #[tokio::test]
805 async fn a_bad_request_is_request_and_not_retryable() {
806 let url = serve(status_response("400 Bad Request", &[]));
807 let (kind, status, _) = failure(openrouter(&url).complete(request()).await);
808 assert_eq!(kind, Kind::Request);
809 assert_eq!(status, Some(400));
810 assert!(!kind.is_retryable(), "the same request fails the same way");
811 }
812
813 #[tokio::test]
814 async fn a_stream_that_parses_to_nothing_is_malformed_not_an_empty_answer() {
815 let url = serve(stream_response(
816 "data: not json at all\n\ndata: {\"unterminated\n\n",
817 ));
818 let (kind, status, _) = failure(openrouter(&url).complete(request()).await);
819 assert_eq!(kind, Kind::Malformed);
820 assert_eq!(status, None, "the status was fine; the body was not");
821 assert!(kind.is_retryable(), "re-asking is cheap");
822 }
823
824 /// The other half of the pair: a stream that parses and simply contains no
825 /// tool call keeps its meaning. Collapsing these two in either direction is
826 /// the regression this test exists to catch.
827 #[tokio::test]
828 async fn a_stream_with_text_and_no_tool_call_stays_a_quiet_success() {
829 let url = serve(stream_response(
830 "data: {\"choices\":[{\"delta\":{\"content\":\"done\"}}]}\n\ndata: [DONE]\n\n",
831 ));
832 let out = openrouter(&url).complete(request()).await.unwrap();
833 assert_eq!(out.text.as_deref(), Some("done"));
834 assert!(out.tool_calls.is_empty());
835 }
836
837 /// Usage alone is enough to have parsed: a provider that streams only its
838 /// usage summary reported something, so it is not malformed.
839 #[tokio::test]
840 async fn a_stream_carrying_only_usage_is_not_malformed() {
841 let url = serve(stream_response(
842 "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":0,\"total_tokens\":3}}\n\ndata: [DONE]\n\n",
843 ));
844 let out = openrouter(&url).complete(request()).await.unwrap();
845 assert_eq!(out.usage.unwrap().total_tokens, 3);
846 }
847
848 #[tokio::test]
849 async fn anthropics_own_stream_shape_is_held_to_the_same_two_meanings() {
850 let empty = serve(stream_response("data: {\"type\":\"whatever\"}\n\n"));
851 let (kind, _, _) = failure(
852 Anthropic::at(&empty, Duration::from_secs(1))
853 .complete(request())
854 .await,
855 );
856 assert_eq!(kind, Kind::Malformed);
857
858 let quiet = serve(stream_response(
859 "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}\n\ndata: {\"type\":\"message_stop\"}\n\n",
860 ));
861 let out = Anthropic::at(&quiet, Duration::from_secs(1))
862 .complete(request())
863 .await
864 .unwrap();
865 assert_eq!(out.text.as_deref(), Some("hi"));
866 assert!(out.tool_calls.is_empty());
867 }
868
869 /// The three providers were byte-identical here before the taxonomy existed
870 /// and must stay so: one status, one kind, whoever asked.
871 #[tokio::test]
872 async fn all_three_providers_map_a_status_to_the_same_kind() {
873 for (status, want) in [
874 ("429 Too Many Requests", Kind::RateLimited),
875 ("503 Service Unavailable", Kind::Server),
876 ("403 Forbidden", Kind::Auth),
877 ("404 Not Found", Kind::Request),
878 ] {
879 let url = serve(status_response(status, &["Retry-After: 3"]));
880 let code: u16 = status[..3].parse().unwrap();
881 let timeout = Duration::from_secs(1);
882
883 let seen = [
884 failure(OpenRouter::at(&url, timeout).complete(request()).await),
885 failure(OpenAi::at(&url, timeout).complete(request()).await),
886 failure(Anthropic::at(&url, timeout).complete(request()).await),
887 ];
888 for observed in &seen {
889 assert_eq!(
890 *observed,
891 (want, Some(code), Some(Duration::from_secs(3))),
892 "{status}"
893 );
894 }
895 }
896 }
897}
898
899/// The media boundary: what may be constructed, and who may receive it.
900#[cfg(all(test, feature = "media"))]
901mod media_tests {
902 use super::*;
903
904 /// A one-pixel PNG. Small enough to inline, real enough that the encoding
905 /// under test is encoding an image rather than a string.
906 const PNG: &[u8] = &[
907 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
908 0x52,
909 ];
910
911 struct Blind;
912 impl Provider for Blind {
913 async fn complete(&self, _r: CompletionRequest) -> Result<CompletionResponse> {
914 unreachable!("the refusal must happen before the request is sent")
915 }
916 fn name(&self) -> &str {
917 "blind"
918 }
919 // Note: no `accepts_images` override. Inheriting the default is the
920 // point — a provider written before 0.15.0 refuses rather than drops.
921 }
922
923 struct Seeing;
924 impl Provider for Seeing {
925 async fn complete(&self, _r: CompletionRequest) -> Result<CompletionResponse> {
926 Ok(CompletionResponse::default())
927 }
928 fn name(&self) -> &str {
929 "seeing"
930 }
931 fn accepts_images(&self) -> bool {
932 true
933 }
934 }
935
936 #[allow(clippy::needless_update)] // `media` is cfg'd out in the default build
937 fn with_image() -> CompletionRequest {
938 CompletionRequest {
939 user: "what is in this picture".into(),
940 media: vec![Media::image("image/png", PNG).unwrap()],
941 ..Default::default()
942 }
943 }
944
945 #[test]
946 fn an_unsupported_media_type_is_refused_at_construction() {
947 let err = Media::image("image/tiff", PNG).unwrap_err();
948 assert!(
949 matches!(&err, Error::Config(m) if m.contains("image/tiff")),
950 "{err:?}"
951 );
952 }
953
954 #[test]
955 fn every_documented_image_type_is_accepted() {
956 // The negative control for the test above: the refusal is about the type
957 // being outside the set, not about construction failing in general.
958 for t in IMAGE_MEDIA_TYPES {
959 assert!(Media::image(t, PNG).is_ok(), "{t} should be constructible");
960 }
961 }
962
963 #[test]
964 fn a_provider_that_does_not_accept_images_refuses_before_the_request_is_sent() {
965 // `Blind::complete` is `unreachable!`, so reaching the provider at all
966 // fails this test rather than passing it quietly.
967 let err =
968 ensure_media_accepted("blind", Blind.accepts_images(), &with_image()).unwrap_err();
969 let Error::Config(message) = &err else {
970 panic!("expected a configuration error, got {err:?}");
971 };
972 assert!(message.contains("does not accept image input"), "{message}");
973 assert!(
974 message.contains("no request was sent"),
975 "the caller must be told nothing was spent: {message}"
976 );
977 }
978
979 #[test]
980 fn a_provider_that_accepts_images_is_not_refused() {
981 // The negative control. Without it the test above would pass against an
982 // implementation that refused every request carrying media.
983 assert!(ensure_media_accepted("seeing", Seeing.accepts_images(), &with_image()).is_ok());
984 }
985
986 #[test]
987 fn a_text_only_request_is_never_refused_even_by_a_blind_provider() {
988 #[allow(clippy::needless_update)] // `media` is cfg'd out in the default build
989 let text_only = CompletionRequest {
990 user: "no picture here".into(),
991 ..Default::default()
992 };
993 assert!(ensure_media_accepted("blind", Blind.accepts_images(), &text_only).is_ok());
994 }
995
996 #[test]
997 fn byte_len_reports_the_decoded_size_not_the_encoded_one() {
998 let m = Media::image("image/png", PNG).unwrap();
999 assert_eq!(m.byte_len(), PNG.len());
1000 assert!(m.base64.len() > PNG.len(), "base64 grows the payload");
1001 }
1002
1003 #[test]
1004 fn the_digest_distinguishes_images_and_is_stable() {
1005 let a = Media::image("image/png", PNG).unwrap();
1006 let b = Media::image("image/png", PNG).unwrap();
1007 let c = Media::image("image/png", &[0xff, 0x00]).unwrap();
1008 assert_eq!(a.digest(), b.digest(), "same bytes, same digest");
1009 assert_ne!(a.digest(), c.digest(), "different bytes, different digest");
1010 assert_eq!(a.digest().len(), 16);
1011 }
1012
1013 #[test]
1014 fn a_path_maps_to_the_media_type_its_extension_names() {
1015 assert_eq!(Media::media_type_for("shot.PNG"), Some("image/png"));
1016 assert_eq!(Media::media_type_for("a/b/photo.jpeg"), Some("image/jpeg"));
1017 assert_eq!(Media::media_type_for("scan.webp"), Some("image/webp"));
1018 // Not an image, and not guessed at: a `.pdf` is a document and a
1019 // `.mp4` is video, which this release does not carry at all.
1020 assert_eq!(Media::media_type_for("report.pdf"), None);
1021 assert_eq!(Media::media_type_for("clip.mp4"), None);
1022 assert_eq!(Media::media_type_for("noextension"), None);
1023 }
1024}