use std::sync::Arc;
use agent_framework_azure::TokenCredential;
use agent_framework_core::client::{ChatClient, ChatStream};
use agent_framework_core::error::{Error, Result};
use agent_framework_core::types::{
ChatOptions, ChatResponse, ChatResponseUpdate, Content, Message, Role, UsageContent,
};
use futures::stream::{self, StreamExt};
use serde_json::Value;
use crate::convert;
pub const DEFAULT_PATH: &str = "/v1/messages";
pub const ANTHROPIC_FOUNDRY_VERSION: &str = "foundry-2025-01-01";
pub const DEFAULT_SCOPE: &str = "https://cognitiveservices.azure.com/.default";
const DEFAULT_MAX_TOKENS: u32 = 1024;
fn classify_foundry_error(status: u16, message: impl Into<String>) -> Error {
let message = message.into();
match status {
401 | 403 => Error::service_invalid_auth(message),
400 => Error::service_invalid_request(message),
_ => Error::service_status(status, message, None),
}
}
#[derive(Clone)]
pub struct AnthropicFoundryClient {
inner: Arc<Inner>,
}
#[derive(Clone)]
struct Inner {
http: reqwest::Client,
base_url: String,
path: String,
model: String,
credential: Arc<dyn TokenCredential>,
scope: String,
anthropic_version: String,
max_tokens: u32,
}
impl std::fmt::Debug for AnthropicFoundryClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AnthropicFoundryClient")
.field("base_url", &self.inner.base_url)
.field("path", &self.inner.path)
.field("model", &self.inner.model)
.field("scope", &self.inner.scope)
.field("anthropic_version", &self.inner.anthropic_version)
.field("max_tokens", &self.inner.max_tokens)
.finish_non_exhaustive()
}
}
impl AnthropicFoundryClient {
pub fn with_token_credential(
base_url: impl Into<String>,
model: impl Into<String>,
credential: Arc<dyn TokenCredential>,
) -> Self {
Self {
inner: Arc::new(Inner {
http: reqwest::Client::new(),
base_url: base_url.into(),
path: DEFAULT_PATH.to_string(),
model: model.into(),
credential,
scope: DEFAULT_SCOPE.to_string(),
anthropic_version: ANTHROPIC_FOUNDRY_VERSION.to_string(),
max_tokens: DEFAULT_MAX_TOKENS,
}),
}
}
pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
Arc::make_mut(&mut self.inner).scope = scope.into();
self
}
pub fn with_path(mut self, path: impl Into<String>) -> Self {
Arc::make_mut(&mut self.inner).path = path.into();
self
}
pub fn with_anthropic_version(mut self, anthropic_version: impl Into<String>) -> Self {
Arc::make_mut(&mut self.inner).anthropic_version = anthropic_version.into();
self
}
pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
Arc::make_mut(&mut self.inner).max_tokens = max_tokens;
self
}
pub fn model(&self) -> &str {
&self.inner.model
}
fn url(&self) -> String {
format!(
"{}{}",
self.inner.base_url.trim_end_matches('/'),
self.inner.path
)
}
async fn bearer_token(&self) -> Result<String> {
self.inner
.credential
.get_token_for_scope(&self.inner.scope)
.await
}
async fn send(&self, body: &Value) -> Result<reqwest::Response> {
let token = self.bearer_token().await?;
let resp = self
.inner
.http
.post(self.url())
.bearer_auth(token)
.json(body)
.send()
.await
.map_err(|e| Error::service(format!("request failed: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(classify_foundry_error(
status.as_u16(),
format!("Azure AI Foundry error {status}: {text}"),
));
}
Ok(resp)
}
}
#[async_trait::async_trait]
impl ChatClient for AnthropicFoundryClient {
async fn get_response(
&self,
messages: Vec<Message>,
options: ChatOptions,
) -> Result<ChatResponse> {
let max_tokens = options.max_tokens.unwrap_or(self.inner.max_tokens);
let body = convert::build_cloud_request(
&messages,
&options,
max_tokens,
false,
&self.inner.anthropic_version,
);
let resp = self.send(&body).await?;
let value: Value = resp
.json()
.await
.map_err(|e| Error::service(format!("invalid response json: {e}")))?;
Ok(convert::parse_response(&value))
}
async fn get_streaming_response(
&self,
messages: Vec<Message>,
options: ChatOptions,
) -> Result<ChatStream> {
let response = self.get_response(messages, options).await?;
let mut contents: Vec<Content> = response
.messages
.iter()
.flat_map(|m| m.contents.iter().cloned())
.collect();
if let Some(usage) = response.usage_details.clone() {
contents.push(Content::Usage(UsageContent { details: usage }));
}
let update = ChatResponseUpdate {
contents,
role: Some(Role::assistant()),
response_id: response.response_id.clone(),
model: response.model.clone(),
finish_reason: response.finish_reason.clone(),
..Default::default()
};
Ok(stream::once(async move { Ok(update) }).boxed())
}
fn model(&self) -> Option<&str> {
Some(&self.inner.model)
}
}
#[cfg(test)]
mod tests {
use super::*;
use agent_framework_azure::StaticTokenCredential;
fn client() -> AnthropicFoundryClient {
AnthropicFoundryClient::with_token_credential(
"https://my-foundry-resource.services.ai.azure.com",
"claude-sonnet-4-5",
Arc::new(StaticTokenCredential::new("my-jwt-token")),
)
}
#[test]
fn url_is_base_url_plus_default_path() {
assert_eq!(
client().url(),
"https://my-foundry-resource.services.ai.azure.com/v1/messages"
);
}
#[test]
fn url_trims_trailing_slash_on_base_url_before_appending_path() {
let c = AnthropicFoundryClient::with_token_credential(
"https://my-foundry-resource.services.ai.azure.com/",
"claude-sonnet-4-5",
Arc::new(StaticTokenCredential::new("tok")),
);
assert_eq!(
c.url(),
"https://my-foundry-resource.services.ai.azure.com/v1/messages"
);
}
#[test]
fn with_path_overrides_default_path() {
let c = client().with_path("/anthropic/v1/messages");
assert_eq!(
c.url(),
"https://my-foundry-resource.services.ai.azure.com/anthropic/v1/messages"
);
}
#[tokio::test]
async fn bearer_token_is_attached_from_credential() {
let c = client();
assert_eq!(c.bearer_token().await.unwrap(), "my-jwt-token");
}
#[test]
fn with_scope_overrides_default_scope() {
let c = client().with_scope("https://example.com/.default");
assert_eq!(c.inner.scope, "https://example.com/.default");
}
#[test]
fn request_body_has_configured_anthropic_version_and_no_model_key() {
let c = client().with_anthropic_version("2024-99-99");
let body = convert::build_cloud_request(
&[Message::user("hi")],
&ChatOptions::new(),
1024,
false,
&c.inner.anthropic_version,
);
assert_eq!(body["anthropic_version"], serde_json::json!("2024-99-99"));
assert!(body.get("model").is_none());
}
#[test]
fn default_anthropic_version_matches_const() {
let c = client();
assert_eq!(c.inner.anthropic_version, ANTHROPIC_FOUNDRY_VERSION);
}
#[test]
fn model_accessor() {
let c = client();
assert_eq!(c.model(), "claude-sonnet-4-5");
assert_eq!(ChatClient::model(&c), Some("claude-sonnet-4-5"));
}
}