agent_framework_anthropic/foundry.rs
1//! [`AnthropicFoundryClient`]: Anthropic (Claude) models hosted on
2//! [Azure AI Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/),
3//! spoken over a Foundry Anthropic deployment's Messages-shaped endpoint
4//! (`POST {base_url}{path}`, defaulting to `{base_url}/v1/messages`).
5//!
6//! Like [`crate::bedrock::AnthropicBedrockClient`] and
7//! [`crate::vertex::AnthropicVertexClient`], this is a *transport*, not a new
8//! wire format: the request body is the same Anthropic Messages API shape
9//! [`AnthropicClient`](crate::AnthropicClient) speaks directly, built via
10//! [`crate::convert::build_cloud_request`]. Authentication is Microsoft Entra
11//! ID (`Authorization: Bearer <token>`), via the
12//! [`agent_framework_azure::TokenCredential`] abstraction that crate's own
13//! Azure OpenAI clients use — any of its real credential chains
14//! (`ManagedIdentityCredential`, `ClientSecretCredential`,
15//! `AzureCliCredential`, `ChainedTokenCredential`, …) work here unmodified.
16//!
17//! ## What's *not* stably documented
18//!
19//! Unlike Bedrock's `InvokeModel`/`anthropic_version` pairing or Vertex AI's
20//! `rawPredict`/`anthropic_version` pairing, Azure AI Foundry's Anthropic
21//! integration does not (as of this writing) have a single, stable, publicly
22//! documented route path or `anthropic_version` tag the way the other two
23//! clouds do — Foundry deployments vary in how they expose partner models.
24//! Both are therefore **overridable defaults**, not hardcoded assumptions:
25//!
26//! * the path suffix defaults to [`DEFAULT_PATH`] (`/v1/messages`) and can be
27//! changed per deployment via [`with_path`](AnthropicFoundryClient::with_path);
28//! * the `anthropic_version` body field defaults to
29//! [`ANTHROPIC_FOUNDRY_VERSION`] and can be changed via
30//! [`with_anthropic_version`](AnthropicFoundryClient::with_anthropic_version).
31//!
32//! Callers should confirm both against their specific Foundry deployment's
33//! documentation before relying on the defaults in production.
34//!
35//! ```no_run
36//! use std::sync::Arc;
37//! use agent_framework_azure::StaticTokenCredential;
38//! use agent_framework_anthropic::foundry::AnthropicFoundryClient;
39//! use agent_framework_core::prelude::*;
40//!
41//! # async fn demo() -> Result<()> {
42//! let credential = Arc::new(StaticTokenCredential::new("eyJ0eXAi..."));
43//! let client = AnthropicFoundryClient::with_token_credential(
44//! "https://my-foundry-resource.services.ai.azure.com",
45//! "claude-sonnet-4-5",
46//! credential,
47//! );
48//! let agent = Agent::builder(client)
49//! .instructions("You are concise.")
50//! .build();
51//! let reply = agent.run_once("Say hi").await?;
52//! println!("{}", reply.text());
53//! # Ok(())
54//! # }
55//! ```
56
57use std::sync::Arc;
58
59use agent_framework_azure::TokenCredential;
60use agent_framework_core::client::{ChatClient, ChatStream};
61use agent_framework_core::error::{Error, Result};
62use agent_framework_core::types::{
63 ChatOptions, ChatResponse, ChatResponseUpdate, Content, Message, Role, UsageContent,
64};
65use futures::stream::{self, StreamExt};
66use serde_json::Value;
67
68use crate::convert;
69
70/// Default path suffix appended to `base_url`. Overridable via
71/// [`AnthropicFoundryClient::with_path`] — see the [module docs](self) for
72/// why this isn't assumed to be stable across Foundry deployments.
73pub const DEFAULT_PATH: &str = "/v1/messages";
74
75/// Default `anthropic_version` sent in the request body. This is **not**
76/// drawn from stable, publicly documented Azure AI Foundry API reference the
77/// way [`crate::bedrock::ANTHROPIC_BEDROCK_VERSION`] and
78/// [`crate::vertex::ANTHROPIC_VERTEX_VERSION`] are — see the [module
79/// docs](self). Overridable via
80/// [`AnthropicFoundryClient::with_anthropic_version`].
81pub const ANTHROPIC_FOUNDRY_VERSION: &str = "foundry-2025-01-01";
82
83/// Default Entra ID scope requested for the bearer token, matching the scope
84/// [`agent_framework_azure::AzureOpenAIClient`]'s own documentation uses for
85/// Azure AI resources. Overridable via
86/// [`AnthropicFoundryClient::with_scope`] if a specific Foundry deployment
87/// requires a different audience.
88pub const DEFAULT_SCOPE: &str = "https://cognitiveservices.azure.com/.default";
89
90/// `max_tokens` is required by the Anthropic Messages API; used whenever
91/// neither `ChatOptions::max_tokens` nor a client-level override is set.
92/// Matches [`crate::AnthropicClient`]'s default.
93const DEFAULT_MAX_TOKENS: u32 = 1024;
94
95/// Classify a non-success Foundry HTTP response into a granular [`Error`].
96/// Status-only, matching [`crate::bedrock`]/[`crate::vertex`]: `401`/`403` ->
97/// invalid auth, `400` -> invalid request, everything else (notably `429`
98/// and `5xx`) stays a generic, retry-layer-visible [`Error::ServiceStatus`].
99fn classify_foundry_error(status: u16, message: impl Into<String>) -> Error {
100 let message = message.into();
101 match status {
102 401 | 403 => Error::service_invalid_auth(message),
103 400 => Error::service_invalid_request(message),
104 _ => Error::service_status(status, message, None),
105 }
106}
107
108/// An Anthropic Messages API transport for Claude models hosted on Azure AI
109/// Foundry (`POST {base_url}{path}`).
110///
111/// See the [module docs](self) for how this relates to
112/// [`AnthropicClient`](crate::AnthropicClient) and for the caveats around the
113/// default path/`anthropic_version`.
114#[derive(Clone)]
115pub struct AnthropicFoundryClient {
116 inner: Arc<Inner>,
117}
118
119#[derive(Clone)]
120struct Inner {
121 http: reqwest::Client,
122 base_url: String,
123 path: String,
124 model: String,
125 credential: Arc<dyn TokenCredential>,
126 scope: String,
127 anthropic_version: String,
128 max_tokens: u32,
129}
130
131impl std::fmt::Debug for AnthropicFoundryClient {
132 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133 f.debug_struct("AnthropicFoundryClient")
134 .field("base_url", &self.inner.base_url)
135 .field("path", &self.inner.path)
136 .field("model", &self.inner.model)
137 .field("scope", &self.inner.scope)
138 .field("anthropic_version", &self.inner.anthropic_version)
139 .field("max_tokens", &self.inner.max_tokens)
140 .finish_non_exhaustive()
141 }
142}
143
144impl AnthropicFoundryClient {
145 /// Create a client authenticating via a [`TokenCredential`] (Microsoft
146 /// Entra ID) against a Foundry Anthropic deployment's `base_url`.
147 pub fn with_token_credential(
148 base_url: impl Into<String>,
149 model: impl Into<String>,
150 credential: Arc<dyn TokenCredential>,
151 ) -> Self {
152 Self {
153 inner: Arc::new(Inner {
154 http: reqwest::Client::new(),
155 base_url: base_url.into(),
156 path: DEFAULT_PATH.to_string(),
157 model: model.into(),
158 credential,
159 scope: DEFAULT_SCOPE.to_string(),
160 anthropic_version: ANTHROPIC_FOUNDRY_VERSION.to_string(),
161 max_tokens: DEFAULT_MAX_TOKENS,
162 }),
163 }
164 }
165
166 /// Override the Entra ID scope requested for the bearer token (default
167 /// [`DEFAULT_SCOPE`]).
168 pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
169 Arc::make_mut(&mut self.inner).scope = scope.into();
170 self
171 }
172
173 /// Override the path suffix appended to `base_url` (default
174 /// [`DEFAULT_PATH`]). See the [module docs](self) for why this may need
175 /// adjusting per deployment.
176 pub fn with_path(mut self, path: impl Into<String>) -> Self {
177 Arc::make_mut(&mut self.inner).path = path.into();
178 self
179 }
180
181 /// Override the `anthropic_version` sent in the request body (default
182 /// [`ANTHROPIC_FOUNDRY_VERSION`]). See the [module docs](self) for why
183 /// this may need adjusting per deployment.
184 pub fn with_anthropic_version(mut self, anthropic_version: impl Into<String>) -> Self {
185 Arc::make_mut(&mut self.inner).anthropic_version = anthropic_version.into();
186 self
187 }
188
189 /// Override the default `max_tokens` sent when `ChatOptions::max_tokens`
190 /// is unset (the Anthropic Messages API requires this field).
191 pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
192 Arc::make_mut(&mut self.inner).max_tokens = max_tokens;
193 self
194 }
195
196 /// The default model id.
197 pub fn model(&self) -> &str {
198 &self.inner.model
199 }
200
201 /// The full request URL (`{base_url}{path}`).
202 fn url(&self) -> String {
203 format!(
204 "{}{}",
205 self.inner.base_url.trim_end_matches('/'),
206 self.inner.path
207 )
208 }
209
210 /// Fetch the bearer token for the configured scope. Split out from
211 /// [`send`](Self::send) so header attachment is unit-testable without an
212 /// HTTP round trip.
213 async fn bearer_token(&self) -> Result<String> {
214 self.inner
215 .credential
216 .get_token_for_scope(&self.inner.scope)
217 .await
218 }
219
220 async fn send(&self, body: &Value) -> Result<reqwest::Response> {
221 let token = self.bearer_token().await?;
222 let resp = self
223 .inner
224 .http
225 .post(self.url())
226 .bearer_auth(token)
227 .json(body)
228 .send()
229 .await
230 .map_err(|e| Error::service(format!("request failed: {e}")))?;
231 if !resp.status().is_success() {
232 let status = resp.status();
233 let text = resp.text().await.unwrap_or_default();
234 return Err(classify_foundry_error(
235 status.as_u16(),
236 format!("Azure AI Foundry error {status}: {text}"),
237 ));
238 }
239 Ok(resp)
240 }
241}
242
243#[async_trait::async_trait]
244impl ChatClient for AnthropicFoundryClient {
245 async fn get_response(
246 &self,
247 messages: Vec<Message>,
248 options: ChatOptions,
249 ) -> Result<ChatResponse> {
250 // Unlike Bedrock/Vertex, a Foundry deployment's `base_url` is itself
251 // model-scoped (it names one specific Anthropic deployment), so
252 // there is no URL slot to route a per-request `ChatOptions::model`
253 // override into; the configured `model` is descriptive only (see
254 // `model()`/`Debug`).
255 let max_tokens = options.max_tokens.unwrap_or(self.inner.max_tokens);
256 let body = convert::build_cloud_request(
257 &messages,
258 &options,
259 max_tokens,
260 false,
261 &self.inner.anthropic_version,
262 );
263 let resp = self.send(&body).await?;
264 let value: Value = resp
265 .json()
266 .await
267 .map_err(|e| Error::service(format!("invalid response json: {e}")))?;
268 Ok(convert::parse_response(&value))
269 }
270
271 /// Get a streaming response.
272 ///
273 /// Whether (and how) a given Foundry Anthropic deployment streams is one
274 /// more thing this crate can't assume stably across deployments (see the
275 /// [module docs](self)), so this calls the same non-streaming endpoint
276 /// [`ChatClient::get_response`] does and adapts the complete
277 /// [`ChatResponse`] into a single [`ChatResponseUpdate`] — the same
278 /// tactic [`crate::bedrock::AnthropicBedrockClient::get_streaming_response`]
279 /// and [`crate::vertex::AnthropicVertexClient::get_streaming_response`]
280 /// use. Callers driving this client through
281 /// [`ChatResponse::from_updates`](agent_framework_core::types::ChatResponse::from_updates)
282 /// still get a correct aggregated result; they just don't see partial
283 /// text arrive incrementally.
284 async fn get_streaming_response(
285 &self,
286 messages: Vec<Message>,
287 options: ChatOptions,
288 ) -> Result<ChatStream> {
289 let response = self.get_response(messages, options).await?;
290
291 let mut contents: Vec<Content> = response
292 .messages
293 .iter()
294 .flat_map(|m| m.contents.iter().cloned())
295 .collect();
296 if let Some(usage) = response.usage_details.clone() {
297 contents.push(Content::Usage(UsageContent { details: usage }));
298 }
299
300 let update = ChatResponseUpdate {
301 contents,
302 role: Some(Role::assistant()),
303 response_id: response.response_id.clone(),
304 model: response.model.clone(),
305 finish_reason: response.finish_reason.clone(),
306 ..Default::default()
307 };
308 Ok(stream::once(async move { Ok(update) }).boxed())
309 }
310
311 fn model(&self) -> Option<&str> {
312 Some(&self.inner.model)
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use agent_framework_azure::StaticTokenCredential;
320
321 fn client() -> AnthropicFoundryClient {
322 AnthropicFoundryClient::with_token_credential(
323 "https://my-foundry-resource.services.ai.azure.com",
324 "claude-sonnet-4-5",
325 Arc::new(StaticTokenCredential::new("my-jwt-token")),
326 )
327 }
328
329 #[test]
330 fn url_is_base_url_plus_default_path() {
331 assert_eq!(
332 client().url(),
333 "https://my-foundry-resource.services.ai.azure.com/v1/messages"
334 );
335 }
336
337 #[test]
338 fn url_trims_trailing_slash_on_base_url_before_appending_path() {
339 let c = AnthropicFoundryClient::with_token_credential(
340 "https://my-foundry-resource.services.ai.azure.com/",
341 "claude-sonnet-4-5",
342 Arc::new(StaticTokenCredential::new("tok")),
343 );
344 assert_eq!(
345 c.url(),
346 "https://my-foundry-resource.services.ai.azure.com/v1/messages"
347 );
348 }
349
350 #[test]
351 fn with_path_overrides_default_path() {
352 let c = client().with_path("/anthropic/v1/messages");
353 assert_eq!(
354 c.url(),
355 "https://my-foundry-resource.services.ai.azure.com/anthropic/v1/messages"
356 );
357 }
358
359 #[tokio::test]
360 async fn bearer_token_is_attached_from_credential() {
361 let c = client();
362 assert_eq!(c.bearer_token().await.unwrap(), "my-jwt-token");
363 }
364
365 #[test]
366 fn with_scope_overrides_default_scope() {
367 let c = client().with_scope("https://example.com/.default");
368 assert_eq!(c.inner.scope, "https://example.com/.default");
369 }
370
371 #[test]
372 fn request_body_has_configured_anthropic_version_and_no_model_key() {
373 let c = client().with_anthropic_version("2024-99-99");
374 let body = convert::build_cloud_request(
375 &[Message::user("hi")],
376 &ChatOptions::new(),
377 1024,
378 false,
379 &c.inner.anthropic_version,
380 );
381 assert_eq!(body["anthropic_version"], serde_json::json!("2024-99-99"));
382 assert!(body.get("model").is_none());
383 }
384
385 #[test]
386 fn default_anthropic_version_matches_const() {
387 let c = client();
388 assert_eq!(c.inner.anthropic_version, ANTHROPIC_FOUNDRY_VERSION);
389 }
390
391 #[test]
392 fn model_accessor() {
393 let c = client();
394 assert_eq!(c.model(), "claude-sonnet-4-5");
395 assert_eq!(ChatClient::model(&c), Some("claude-sonnet-4-5"));
396 }
397}