Skip to main content

locode_provider/anthropic/
mod.rs

1//! The Anthropic Messages wire — the one live `Provider` (Task 12, ADR-0007).
2//!
3//! Converts the provider-neutral [`ConversationRequest`]
4//! into a Messages request, sends it (non-streaming), and parses the response back
5//! into a [`Completion`] — preserving tool-use ids verbatim,
6//! thinking blocks *with* signatures, and usage. Owns the transport-tier retry.
7//!
8//! Design: `tasks/plans/task-12-anthropic-wire.md` (+ §9 addendum) and the
9//! ADR-0007 amendment (2026-07-18). Wire structs are ported from Grok Build's
10//! `messages.rs`; the conversion logic lives in [`build`] and `parse`.
11
12pub mod build;
13mod client;
14pub mod config;
15pub mod error;
16pub mod parse;
17pub mod retry;
18pub mod wire;
19
20use std::sync::{Arc, Mutex, PoisonError};
21
22use async_trait::async_trait;
23
24pub use build::{build_request, count_cache_controls, normalize_input_schema};
25pub use config::{ApiBackend, AuthScheme, DeveloperRendering, ModelConfig};
26pub use error::{HttpFailure, classify, parse_retry_after};
27pub use parse::response_to_completion;
28pub use retry::{RetryPolicy, backoff, run_with_retry};
29
30use crate::completion::{Completion, CompletionDelta};
31use crate::provider::{Provider, ProviderError};
32use crate::repair::repair_pairing;
33use crate::request::ConversationRequest;
34
35/// Re-resolve the credential after a 401/403 (plan §4.5).
36///
37/// Returns `Some(new)` iff a **different** credential was obtained — grok's
38/// `refresh_after_unauthorized` only fires on a changed token. v0 is static-key,
39/// so the default provider carries no refresher and a 401 is terminal
40/// immediately; this seam is what a future OAuth flow plugs into.
41pub trait AuthRefresh: Send + Sync {
42    /// Produce a fresh credential, or `None` if nothing new is available.
43    fn refresh(&self) -> Option<AuthScheme>;
44}
45
46/// The live Anthropic Messages `Provider` (non-streaming).
47pub struct AnthropicProvider {
48    http: reqwest::Client,
49    config: ModelConfig,
50    retry: RetryPolicy,
51    /// Current credential; swapped by the 401 refresh-once path.
52    auth: Mutex<AuthScheme>,
53    auth_refresh: Option<Arc<dyn AuthRefresh>>,
54}
55
56impl AnthropicProvider {
57    /// Build a provider from a resolved [`ModelConfig`].
58    ///
59    /// # Errors
60    /// [`ProviderError::Transport`] when the HTTP client cannot be constructed.
61    pub fn new(config: ModelConfig) -> Result<Self, ProviderError> {
62        Ok(Self {
63            http: crate::http::build_http_client()?,
64            auth: Mutex::new(config.auth.clone()),
65            config,
66            retry: RetryPolicy::default(),
67            auth_refresh: None,
68        })
69    }
70
71    /// Build from the environment (`LOCODE_API_KEY` / `LOCODE_BASE_URL`); the
72    /// model comes from `--model`/settings, not env (ADR-0024 §1.4).
73    ///
74    /// # Errors
75    /// [`ProviderError::Auth`] when the key is missing;
76    /// [`ProviderError::Transport`] when the HTTP client cannot be constructed.
77    pub fn from_env() -> Result<Self, ProviderError> {
78        Self::new(ModelConfig::from_env()?)
79    }
80
81    /// Override the transport retry policy.
82    #[must_use]
83    pub fn with_retry_policy(mut self, retry: RetryPolicy) -> Self {
84        self.retry = retry;
85        self
86    }
87
88    /// Install a credential refresher for the 401 refresh-once path.
89    #[must_use]
90    pub fn with_auth_refresh(mut self, refresh: Arc<dyn AuthRefresh>) -> Self {
91        self.auth_refresh = Some(refresh);
92        self
93    }
94
95    /// The active config (read-only view).
96    #[must_use]
97    pub fn config(&self) -> &ModelConfig {
98        &self.config
99    }
100
101    /// Mutable config access (pre-run tweaks — e.g. the settings `model`
102    /// override, ADR-0024; mirrors the Responses provider's accessor).
103    pub fn config_mut(&mut self) -> &mut ModelConfig {
104        &mut self.config
105    }
106
107    fn current_auth(&self) -> AuthScheme {
108        self.auth
109            .lock()
110            .unwrap_or_else(PoisonError::into_inner)
111            .clone()
112    }
113
114    /// Swap in a refreshed credential; `false` when no *different* credential
115    /// is available (grok only re-sends on a changed token).
116    fn try_refresh(&self) -> bool {
117        let Some(refresher) = &self.auth_refresh else {
118            return false;
119        };
120        let Some(new_auth) = refresher.refresh() else {
121            return false;
122        };
123        let mut current = self.auth.lock().unwrap_or_else(PoisonError::into_inner);
124        if *current == new_auth {
125            return false;
126        }
127        *current = new_auth;
128        true
129    }
130
131    /// One full retry-wrapped exchange with the current credential.
132    async fn exchange(&self, request: &wire::MessagesRequest) -> Result<Completion, ProviderError> {
133        let auth = self.current_auth();
134        run_with_retry(&self.retry, |_attempt| {
135            client::send_once(&self.http, &self.config, &auth, request)
136        })
137        .await
138    }
139}
140
141#[async_trait]
142impl Provider for AnthropicProvider {
143    #[allow(clippy::unnecessary_literal_bound)] // signature is the trait's
144    fn api_schema(&self) -> &str {
145        "anthropic"
146    }
147
148    async fn complete(&self, request: &ConversationRequest) -> Result<Completion, ProviderError> {
149        // 1. Defensive transcript repair on a clone — a request must never
150        //    leave this crate with a dangling tool_use or duplicate tool_result,
151        //    regardless of who called it (ADR-0004; the engine runs the same
152        //    shared function as the canonical pass).
153        let mut repaired = request.clone();
154        let _ = repair_pairing(&mut repaired.messages);
155
156        // 2. Build the wire request (system hoist, cache markers, thinking).
157        let wire_request = build_request(&repaired, &self.config);
158
159        // 3. Send with transport retry; on a terminal Auth error, refresh once
160        //    and re-run (plan §4.5) — a second Auth failure is terminal.
161        match self.exchange(&wire_request).await {
162            Err(ProviderError::Auth(message)) => {
163                if self.try_refresh() {
164                    self.exchange(&wire_request).await
165                } else {
166                    Err(ProviderError::Auth(message))
167                }
168            }
169            other => other,
170        }
171    }
172
173    async fn stream(
174        &self,
175        request: &ConversationRequest,
176        on_delta: &mut (dyn FnMut(CompletionDelta) + Send),
177    ) -> Result<Completion, ProviderError> {
178        // Same pre-send validation + repair + build as `complete`, but streaming.
179        let mut repaired = request.clone();
180        let _ = repair_pairing(&mut repaired.messages);
181        let mut wire_request = build_request(&repaired, &self.config);
182        wire_request.stream = Some(true);
183
184        // Single-attempt streaming send: a retryable failure surfaces to the
185        // engine's loop-level resample (which re-runs the stream). We still
186        // refresh once on a terminal Auth error, mirroring `complete` — an auth
187        // failure lands at the HTTP status check, before any delta is emitted, so
188        // re-borrowing `on_delta` for the retry is sound (borrows don't overlap).
189        let first = client::send_once_streaming(
190            &self.http,
191            &self.config,
192            &self.current_auth(),
193            &wire_request,
194            on_delta,
195        )
196        .await;
197        match first {
198            Ok(completion) => Ok(completion),
199            Err(failure)
200                if matches!(failure.error, ProviderError::Auth(_)) && self.try_refresh() =>
201            {
202                client::send_once_streaming(
203                    &self.http,
204                    &self.config,
205                    &self.current_auth(),
206                    &wire_request,
207                    on_delta,
208                )
209                .await
210                .map_err(|f| f.error)
211            }
212            Err(failure) => Err(failure.error),
213        }
214    }
215}