Skip to main content

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;
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
37/// Why a model call failed (ADR-0007).
38///
39/// An **exhaustive** taxonomy (not `#[non_exhaustive]`): like Grok's `SamplingError`
40/// and Codex's `CodexErr`, [`ProviderError::retryable`] matches every variant with
41/// no wildcard, so a new error cannot be added without classifying it. Distinct
42/// terminal variants (context overflow, quota, auth) follow Codex; the general
43/// [`ProviderError::Api`] is the escape hatch for unclassified HTTP statuses.
44#[derive(Debug, Error)]
45pub enum ProviderError {
46    /// A transport/network failure (connection reset, DNS, TLS). Retryable.
47    #[error("transport error: {0}")]
48    Transport(String),
49    /// Rate limited (HTTP 429). Surfaced, not silently hammered; a bounded retry
50    /// honors `retry_after` when present.
51    #[error("rate limited")]
52    RateLimited {
53        /// The server-requested delay before retrying, if given (`Retry-After`).
54        retry_after: Option<Duration>,
55    },
56    /// A general API error carrying the HTTP status; retryable iff 5xx/overloaded.
57    #[error("api error (status {status}): {message}")]
58    Api {
59        /// The HTTP status code.
60        status: u16,
61        /// The error message from the response body.
62        message: String,
63    },
64    /// The request exceeded the model's context window. Terminal.
65    #[error("context window exceeded")]
66    ContextOverflow,
67    /// The account's quota/usage limit is exhausted. Terminal.
68    #[error("quota exceeded")]
69    Quota,
70    /// Authentication failed (e.g. 401 after a refresh attempt). Terminal here;
71    /// refresh-and-retry-once is the wire's job before surfacing this.
72    #[error("authentication error: {0}")]
73    Auth(String),
74    /// The response could not be parsed into a [`Completion`]. Terminal.
75    #[error("failed to decode provider response: {0}")]
76    Decode(String),
77    /// The request configuration is invalid for this wire (e.g. an unknown
78    /// reasoning-effort tier on a fixed-mapping wire). Terminal, pre-send.
79    #[error("invalid configuration: {0}")]
80    Config(String),
81}
82
83impl ProviderError {
84    /// Whether the loop/transport should retry this error (vs treat it as terminal).
85    ///
86    /// `Api` is retryable for any 5xx plus 408/409 โ€” Claude Code's retryable
87    /// set (Task-12 plan ยง4.6); everything 4xx-terminal stays terminal.
88    #[must_use]
89    pub fn retryable(&self) -> bool {
90        match self {
91            ProviderError::Transport(_) | ProviderError::RateLimited { .. } => true,
92            ProviderError::Api { status, .. } => {
93                matches!(status, 408 | 409 | 500..=599)
94            }
95            ProviderError::ContextOverflow
96            | ProviderError::Quota
97            | ProviderError::Auth(_)
98            | ProviderError::Decode(_)
99            | ProviderError::Config(_) => false,
100        }
101    }
102}