locode_provider/provider.rs
1//! The `Provider` trait (one wire schema per impl) and its error taxonomy (ADR-0007).
2
3use std::time::Duration;
4
5use async_trait::async_trait;
6use thiserror::Error;
7
8use crate::completion::{Completion, CompletionDelta};
9use crate::request::ConversationRequest;
10
11/// A model-sampling wire: builds a request, calls the model, normalizes the response.
12///
13/// **One impl per wire schema** — not per gateway. A `Provider` identifies a
14/// request/response *protocol shape* (Anthropic Messages, OpenAI Chat, OpenAI
15/// Responses, or the `mock` double); a gateway/endpoint (OpenRouter, Bedrock, a
16/// proxy, a local model) is *configuration* (`base_url` + auth + headers) pointed at
17/// one of these schemas, never a separate impl. This is Grok Build's split
18/// (`ApiBackend` schema vs an un-enumerated `base_url`/auth) and what ADR-0007's
19/// per-model `{ base_url, api_backend, extra_headers }` record encodes.
20#[async_trait]
21pub trait Provider: Send + Sync {
22 /// The wire-schema id this provider speaks — e.g. `"anthropic"`, `"mock"`.
23 ///
24 /// This is the *protocol shape*, not a gateway. Stamped into the report's
25 /// `provider` field.
26 fn api_schema(&self) -> &str;
27
28 /// Sample one completion for `request`.
29 ///
30 /// # Errors
31 /// Returns [`ProviderError`]; use [`ProviderError::retryable`] to classify
32 /// retry-vs-terminal. The transport-tier backoff lives in the wire; the
33 /// bounded loop-level resample lives in the engine.
34 async fn complete(&self, request: &ConversationRequest) -> Result<Completion, ProviderError>;
35
36 /// Sample one completion, emitting [`CompletionDelta`]s to `on_delta` as they
37 /// arrive, and returning the **same final [`Completion`]** as [`Self::complete`]
38 /// (ADR-0021). Deltas are a display-only side channel; the returned
39 /// `Completion` is what the engine appends and dispatches from.
40 ///
41 /// **Default** (this impl): non-streaming wires call [`Self::complete`] and emit one
42 /// synthetic text delta, so the seam works for every provider (`mock`, and any
43 /// wire that hasn't implemented SSE). SSE wires override this to stream live.
44 ///
45 /// # Errors
46 /// Returns [`ProviderError`], same taxonomy as [`Self::complete`].
47 async fn stream(
48 &self,
49 request: &ConversationRequest,
50 on_delta: &mut (dyn FnMut(CompletionDelta) + Send),
51 ) -> Result<Completion, ProviderError> {
52 let completion = self.complete(request).await?;
53 if let Some(text) = completion.text() {
54 on_delta(CompletionDelta::Text(text));
55 }
56 Ok(completion)
57 }
58}
59
60/// Why a model call failed (ADR-0007).
61///
62/// An **exhaustive** taxonomy (not `#[non_exhaustive]`): like Grok's `SamplingError`
63/// and Codex's `CodexErr`, [`ProviderError::retryable`] matches every variant with
64/// no wildcard, so a new error cannot be added without classifying it. Distinct
65/// terminal variants (context overflow, quota, auth) follow Codex; the general
66/// [`ProviderError::Api`] is the escape hatch for unclassified HTTP statuses.
67#[derive(Debug, Error)]
68pub enum ProviderError {
69 /// A transport/network failure (connection reset, DNS, TLS). Retryable.
70 #[error("transport error: {0}")]
71 Transport(String),
72 /// Rate limited (HTTP 429). Surfaced, not silently hammered; a bounded retry
73 /// honors `retry_after` when present.
74 #[error("rate limited")]
75 RateLimited {
76 /// The server-requested delay before retrying, if given (`Retry-After`).
77 retry_after: Option<Duration>,
78 },
79 /// A general API error carrying the HTTP status; retryable iff 5xx/overloaded.
80 #[error("api error (status {status}): {message}")]
81 Api {
82 /// The HTTP status code.
83 status: u16,
84 /// The error message from the response body.
85 message: String,
86 },
87 /// The request exceeded the model's context window. Terminal.
88 #[error("context window exceeded")]
89 ContextOverflow,
90 /// The account's quota/usage limit is exhausted. Terminal.
91 #[error("quota exceeded")]
92 Quota,
93 /// Authentication failed (e.g. 401 after a refresh attempt). Terminal here;
94 /// refresh-and-retry-once is the wire's job before surfacing this.
95 #[error("authentication error: {0}")]
96 Auth(String),
97 /// The response could not be parsed into a [`Completion`]. Terminal.
98 #[error("failed to decode provider response: {0}")]
99 Decode(String),
100 /// The request configuration is invalid for this wire (e.g. an unknown
101 /// reasoning-effort tier on a fixed-mapping wire). Terminal, pre-send.
102 #[error("invalid configuration: {0}")]
103 Config(String),
104}
105
106impl ProviderError {
107 /// Whether the loop/transport should retry this error (vs treat it as terminal).
108 ///
109 /// `Api` is retryable for any 5xx plus 408/409 — Claude Code's retryable
110 /// set (Task-12 plan §4.6); everything 4xx-terminal stays terminal.
111 #[must_use]
112 pub fn retryable(&self) -> bool {
113 match self {
114 ProviderError::Transport(_) | ProviderError::RateLimited { .. } => true,
115 ProviderError::Api { status, .. } => {
116 matches!(status, 408 | 409 | 500..=599)
117 }
118 ProviderError::ContextOverflow
119 | ProviderError::Quota
120 | ProviderError::Auth(_)
121 | ProviderError::Decode(_)
122 | ProviderError::Config(_) => false,
123 }
124 }
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130 use crate::completion::StopReason;
131 use crate::request::{CacheHint, SamplingArgs};
132 use locode_protocol::{ContentBlock, Usage};
133
134 /// A minimal provider that implements only `complete`, exercising the default
135 /// `stream` fallback (non-streaming wires emit one synthetic text delta).
136 struct TextOnly(String);
137
138 #[async_trait]
139 impl Provider for TextOnly {
140 #[allow(clippy::unnecessary_literal_bound)]
141 fn api_schema(&self) -> &str {
142 "text-only"
143 }
144 async fn complete(
145 &self,
146 _request: &ConversationRequest,
147 ) -> Result<Completion, ProviderError> {
148 Ok(Completion {
149 content: vec![ContentBlock::Text {
150 text: self.0.clone(),
151 }],
152 usage: Usage::default(),
153 stop: StopReason::EndTurn,
154 })
155 }
156 }
157
158 fn req() -> ConversationRequest {
159 ConversationRequest {
160 messages: vec![],
161 tools: vec![],
162 sampling_args: SamplingArgs::default(),
163 cache_hint: CacheHint::default(),
164 }
165 }
166
167 #[tokio::test]
168 async fn default_stream_emits_one_text_delta_and_same_completion() {
169 let provider = TextOnly("hello there".into());
170 let mut deltas = Vec::new();
171 let completion = provider
172 .stream(&req(), &mut |d| deltas.push(d))
173 .await
174 .expect("default stream ok");
175 assert_eq!(deltas, vec![CompletionDelta::Text("hello there".into())]);
176 assert_eq!(completion.text().as_deref(), Some("hello there"));
177 }
178
179 #[tokio::test]
180 async fn default_stream_with_empty_text_emits_nothing() {
181 let provider = TextOnly(String::new());
182 let mut deltas = Vec::new();
183 provider
184 .stream(&req(), &mut |d| deltas.push(d))
185 .await
186 .expect("ok");
187 assert!(
188 deltas.is_empty(),
189 "no text → no synthetic delta: {deltas:?}"
190 );
191 }
192}