Skip to main content

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}