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}