rig_core/providers/anthropic/
client.rs1use http::{HeaderName, HeaderValue};
3
4use super::completion::{ANTHROPIC_VERSION_LATEST, CompletionModel};
5use crate::{
6 client::{
7 self, ApiKey, Capabilities, Capable, DebugExt, Nothing, Provider, ProviderBuilder,
8 ProviderClient,
9 },
10 http_client::{self, HttpClientExt},
11 providers::anthropic::model_listing::AnthropicModelLister,
12};
13
14#[derive(Debug, Default, Clone)]
18pub struct AnthropicExt;
19
20impl Provider for AnthropicExt {
21 type Builder = AnthropicBuilder;
22 const VERIFY_PATH: &'static str = "/v1/models";
23}
24
25impl<H> Capabilities<H> for AnthropicExt {
26 type Completion = Capable<CompletionModel<H>>;
27
28 type Embeddings = Nothing;
29 type Transcription = Nothing;
30 type ModelListing = Capable<AnthropicModelLister<H>>;
31 #[cfg(feature = "image")]
32 type ImageGeneration = Nothing;
33 #[cfg(feature = "audio")]
34 type AudioGeneration = Nothing;
35 type Rerank = Nothing;
36}
37
38#[derive(Debug, Clone)]
39pub struct AnthropicBuilder {
40 pub(crate) anthropic_version: String,
41 pub(crate) anthropic_betas: Vec<String>,
42}
43
44#[derive(Debug, Clone)]
45pub struct AnthropicKey(String);
46
47impl<S> From<S> for AnthropicKey
48where
49 S: Into<String>,
50{
51 fn from(value: S) -> Self {
52 Self(value.into())
53 }
54}
55
56impl ApiKey for AnthropicKey {
57 fn into_header(self) -> Option<http_client::Result<(http::HeaderName, HeaderValue)>> {
58 Some(
59 HeaderValue::from_str(&self.0)
60 .map(|val| (HeaderName::from_static("x-api-key"), val))
61 .map_err(Into::into),
62 )
63 }
64}
65
66pub type Client<H = reqwest::Client> = client::Client<AnthropicExt, H>;
67pub type ClientBuilder<H = crate::markers::Missing> =
68 client::ClientBuilder<AnthropicBuilder, AnthropicKey, H>;
69
70impl Default for AnthropicBuilder {
71 fn default() -> Self {
72 Self {
73 anthropic_version: ANTHROPIC_VERSION_LATEST.into(),
74 anthropic_betas: Vec::new(),
75 }
76 }
77}
78
79impl ProviderBuilder for AnthropicBuilder {
80 type Extension<H>
81 = AnthropicExt
82 where
83 H: HttpClientExt;
84 type ApiKey = AnthropicKey;
85
86 const BASE_URL: &'static str = "https://api.anthropic.com";
87
88 fn build<H>(
89 _builder: &client::ClientBuilder<Self, Self::ApiKey, H>,
90 ) -> http_client::Result<Self::Extension<H>>
91 where
92 H: HttpClientExt,
93 {
94 Ok(AnthropicExt)
95 }
96
97 fn finish<H>(
98 &self,
99 builder: client::ClientBuilder<Self, AnthropicKey, H>,
100 ) -> http_client::Result<client::ClientBuilder<Self, AnthropicKey, H>> {
101 finish_anthropic_builder(self, builder)
102 }
103}
104
105impl DebugExt for AnthropicExt {}
106
107impl ProviderClient for Client {
108 type Input = String;
109 type Error = crate::client::ProviderClientError;
110
111 fn from_env() -> Result<Self, Self::Error>
112 where
113 Self: Sized,
114 {
115 let base_url = crate::client::optional_env_var("ANTHROPIC_BASE_URL")?;
116 let key = crate::client::required_env_var("ANTHROPIC_API_KEY")?;
117
118 let mut builder = Self::builder().api_key(key);
119
120 if let Some(base) = base_url {
121 builder = builder.base_url(&base);
122 }
123
124 builder.build().map_err(Into::into)
125 }
126
127 fn from_val(input: Self::Input) -> Result<Self, Self::Error>
128 where
129 Self: Sized,
130 {
131 Self::builder().api_key(input).build().map_err(Into::into)
132 }
133}
134
135impl<H> ClientBuilder<H> {
153 pub fn anthropic_version(self, anthropic_version: &str) -> Self {
154 self.over_ext(|ext| AnthropicBuilder {
155 anthropic_version: anthropic_version.into(),
156 ..ext
157 })
158 }
159
160 pub fn anthropic_betas(self, anthropic_betas: &[&str]) -> Self {
161 self.over_ext(|mut ext| {
162 ext.anthropic_betas
163 .extend(anthropic_betas.iter().copied().map(String::from));
164
165 ext
166 })
167 }
168
169 pub fn anthropic_beta(self, anthropic_beta: &str) -> Self {
170 self.over_ext(|mut ext| {
171 ext.anthropic_betas.push(anthropic_beta.into());
172
173 ext
174 })
175 }
176}
177
178pub fn normalize_anthropic_base_url(base_url: &str) -> String {
179 let trimmed = base_url.trim_end_matches('/');
180
181 if let Some(stripped) = trimmed.strip_suffix("/v1/messages") {
182 stripped.to_string()
183 } else if let Some(stripped) = trimmed.strip_suffix("/messages") {
184 stripped.to_string()
185 } else if let Some(stripped) = trimmed.strip_suffix("/v1") {
186 stripped.to_string()
187 } else {
188 trimmed.to_string()
189 }
190}
191
192pub fn finish_anthropic_builder<ExtBuilder, H>(
193 ext: &AnthropicBuilder,
194 mut builder: client::ClientBuilder<ExtBuilder, AnthropicKey, H>,
195) -> http_client::Result<client::ClientBuilder<ExtBuilder, AnthropicKey, H>>
196where
197 ExtBuilder: Clone,
198{
199 let normalized_base_url = normalize_anthropic_base_url(builder.get_base_url());
200 builder = builder.base_url(normalized_base_url);
201
202 builder.headers_mut().insert(
203 "anthropic-version",
204 HeaderValue::from_str(&ext.anthropic_version)?,
205 );
206
207 if !ext.anthropic_betas.is_empty() {
208 builder.headers_mut().insert(
209 "anthropic-beta",
210 HeaderValue::from_str(&ext.anthropic_betas.join(","))?,
211 );
212 }
213
214 Ok(builder)
215}
216#[cfg(test)]
217mod tests {
218 use std::sync::Mutex;
219
220 use crate::client::ProviderClient;
221
222 static ENV_LOCK: Mutex<()> = Mutex::new(());
223
224 struct EnvVarGuard {
225 key: &'static str,
226 original: Option<String>,
227 }
228
229 impl EnvVarGuard {
230 fn set(key: &'static str, value: &str) -> Self {
231 let original = std::env::var(key).ok();
232 unsafe { std::env::set_var(key, value) };
235
236 Self { key, original }
237 }
238
239 fn remove(key: &'static str) -> Self {
240 let original = std::env::var(key).ok();
241 unsafe { std::env::remove_var(key) };
244
245 Self { key, original }
246 }
247 }
248
249 impl Drop for EnvVarGuard {
250 fn drop(&mut self) {
251 unsafe {
254 match &self.original {
255 Some(value) => std::env::set_var(self.key, value),
256 None => std::env::remove_var(self.key),
257 }
258 }
259 }
260 }
261
262 #[test]
263 fn test_client_initialization() {
264 let _client =
265 crate::providers::anthropic::Client::new("dummy-key").expect("Client::new() failed");
266 let _client_from_builder = crate::providers::anthropic::Client::builder()
267 .api_key("dummy-key")
268 .build()
269 .expect("Client::builder() failed");
270 }
271
272 #[test]
273 fn from_env_uses_anthropic_base_url() {
274 let _guard = ENV_LOCK.lock().expect("env lock should not be poisoned");
275 let _api_key = EnvVarGuard::set("ANTHROPIC_API_KEY", "dummy-key");
276 let _base_url = EnvVarGuard::set(
277 "ANTHROPIC_BASE_URL",
278 "https://anthropic-compatible.example/v1/messages",
279 );
280
281 let client = crate::providers::anthropic::Client::from_env()
282 .expect("Client::from_env should build with ANTHROPIC_BASE_URL");
283
284 assert_eq!(
285 client.base_url(),
286 "https://anthropic-compatible.example",
287 "from_env should apply ANTHROPIC_BASE_URL and the existing Anthropic base URL normalization"
288 );
289 }
290
291 #[test]
292 fn from_env_uses_default_base_url_when_anthropic_base_url_is_unset() {
293 let _guard = ENV_LOCK.lock().expect("env lock should not be poisoned");
294 let _api_key = EnvVarGuard::set("ANTHROPIC_API_KEY", "dummy-key");
295 let _base_url = EnvVarGuard::remove("ANTHROPIC_BASE_URL");
296
297 let client = crate::providers::anthropic::Client::from_env()
298 .expect("Client::from_env should build without ANTHROPIC_BASE_URL");
299
300 assert_eq!(client.base_url(), "https://api.anthropic.com");
301 }
302}