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, ReasoningEncoding};
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;
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` /
72    /// `LOCODE_MODEL`).
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    fn current_auth(&self) -> AuthScheme {
102        self.auth
103            .lock()
104            .unwrap_or_else(PoisonError::into_inner)
105            .clone()
106    }
107
108    /// Swap in a refreshed credential; `false` when no *different* credential
109    /// is available (grok only re-sends on a changed token).
110    fn try_refresh(&self) -> bool {
111        let Some(refresher) = &self.auth_refresh else {
112            return false;
113        };
114        let Some(new_auth) = refresher.refresh() else {
115            return false;
116        };
117        let mut current = self.auth.lock().unwrap_or_else(PoisonError::into_inner);
118        if *current == new_auth {
119            return false;
120        }
121        *current = new_auth;
122        true
123    }
124
125    /// One full retry-wrapped exchange with the current credential.
126    async fn exchange(&self, request: &wire::MessagesRequest) -> Result<Completion, ProviderError> {
127        let auth = self.current_auth();
128        run_with_retry(&self.retry, |_attempt| {
129            client::send_once(&self.http, &self.config, &auth, request)
130        })
131        .await
132    }
133}
134
135#[async_trait]
136impl Provider for AnthropicProvider {
137    #[allow(clippy::unnecessary_literal_bound)] // signature is the trait's
138    fn api_schema(&self) -> &str {
139        "anthropic"
140    }
141
142    async fn complete(&self, request: &ConversationRequest) -> Result<Completion, ProviderError> {
143        // 0. This wire has fixed effort mappings on the Budget encoding — an
144        //    unknown tier has no principled budget; reject clearly pre-send
145        //    (never silently clamp).
146        if let Some(crate::request::ReasoningEffort::Other(tier)) =
147            &request.sampling_args.reasoning_effort
148            && self.config.reasoning_encoding == config::ReasoningEncoding::Budget
149        {
150            return Err(ProviderError::Config(format!(
151                "unknown reasoning-effort tier {tier:?} has no budget mapping on the \
152                 anthropic wire (Budget encoding)"
153            )));
154        }
155
156        // 1. Defensive transcript repair on a clone — a request must never
157        //    leave this crate with a dangling tool_use or duplicate tool_result,
158        //    regardless of who called it (ADR-0004; the engine runs the same
159        //    shared function as the canonical pass).
160        let mut repaired = request.clone();
161        let _ = repair_pairing(&mut repaired.messages);
162
163        // 2. Build the wire request (system hoist, cache markers, thinking).
164        let wire_request = build_request(&repaired, &self.config);
165
166        // 3. Send with transport retry; on a terminal Auth error, refresh once
167        //    and re-run (plan §4.5) — a second Auth failure is terminal.
168        match self.exchange(&wire_request).await {
169            Err(ProviderError::Auth(message)) => {
170                if self.try_refresh() {
171                    self.exchange(&wire_request).await
172                } else {
173                    Err(ProviderError::Auth(message))
174                }
175            }
176            other => other,
177        }
178    }
179}