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