pub mod chat;
mod client;
pub mod config;
pub mod embed;
pub mod error;
pub mod image_generation;
pub mod models;
#[cfg(test)]
mod policy_tests;
pub mod rerank;
pub use chat::{AzureAIChatHandler, AzureAIChatUtils};
pub use config::{AzureAIConfig, AzureAIEndpointType};
pub use embed::{AzureAIEmbeddingHandler, AzureAIEmbeddingUtils};
pub use error::AzureAIErrorMapper;
pub use image_generation::AzureAIImageHandler;
pub use models::{AzureAIModelRegistry, AzureAIModelSpec, AzureAIModelType, get_azure_ai_registry};
pub use rerank::{AzureAIRerankHandler, AzureAIRerankUtils, RerankRequest, RerankResponse};
use futures::Stream;
use reqwest::Method;
use serde_json::Value;
use std::collections::HashMap;
use std::pin::Pin;
use crate::core::providers::unified_provider::ProviderError;
use crate::core::traits::error_mapper::trait_def::ErrorMapper;
use crate::core::traits::provider::llm_provider::trait_definition::LLMProvider;
use crate::core::types::{
chat::ChatRequest,
context::RequestContext,
embedding::EmbeddingRequest,
health::HealthStatus,
image::ImageGenerationRequest,
model::ModelInfo,
model::ProviderCapability,
responses::{ChatChunk, ChatResponse, EmbeddingResponse, ImageGenerationResponse},
};
use client::AzureAIClient;
#[rustfmt::skip]
const CHAT_PARAMS: &[&str] = &["temperature", "max_tokens", "max_completion_tokens", "top_p", "frequency_penalty", "presence_penalty", "stop"];
#[rustfmt::skip]
const PHI_4_CHAT_PARAMS: &[&str] = &["temperature", "max_tokens", "top_p", "frequency_penalty", "presence_penalty", "stop"];
#[rustfmt::skip]
const CHAT_STREAM_PARAMS: &[&str] = &["temperature", "max_tokens", "max_completion_tokens", "top_p", "frequency_penalty", "presence_penalty", "stop", "stream"];
#[rustfmt::skip]
const CHAT_TOOL_PARAMS: &[&str] = &["temperature", "max_tokens", "max_completion_tokens", "top_p", "frequency_penalty", "presence_penalty", "stop", "tools", "tool_choice", "functions", "function_call"];
#[rustfmt::skip]
const CHAT_TOOL_STREAM_PARAMS: &[&str] = &["temperature", "max_tokens", "max_completion_tokens", "top_p", "frequency_penalty", "presence_penalty", "stop", "tools", "tool_choice", "functions", "function_call", "stream"];
#[derive(Debug, Clone)]
pub struct AzureAIProvider {
client: AzureAIClient,
chat_handler: AzureAIChatHandler,
embedding_handler: AzureAIEmbeddingHandler,
image_handler: AzureAIImageHandler,
rerank_handler: AzureAIRerankHandler,
model_registry: &'static AzureAIModelRegistry,
pub(crate) model_identity:
Option<crate::core::providers::model_identity::DeploymentProviderBinding>,
}
impl AzureAIProvider {
pub fn new(config: AzureAIConfig) -> Result<Self, ProviderError> {
let client = AzureAIClient::new(config)?;
let chat_handler = AzureAIChatHandler::from_client(client.clone())
.map_err(|e| ProviderError::configuration("azure_ai", e.to_string()))?;
let embedding_handler = AzureAIEmbeddingHandler::from_client(client.clone())
.map_err(|e| ProviderError::configuration("azure_ai", e.to_string()))?;
let image_handler = AzureAIImageHandler::from_client(client.clone())
.map_err(|e| ProviderError::configuration("azure_ai", e.to_string()))?;
let rerank_handler = AzureAIRerankHandler::from_client(client.clone())
.map_err(|e| ProviderError::configuration("azure_ai", e.to_string()))?;
let model_registry = get_azure_ai_registry();
Ok(Self {
client,
chat_handler,
embedding_handler,
image_handler,
rerank_handler,
model_registry,
model_identity: None,
})
}
pub fn get_config(&self) -> &AzureAIConfig {
self.client.get_config()
}
pub fn from_env() -> Result<Self, ProviderError> {
let config = AzureAIConfig::from_env();
Self::new(config)
}
pub fn with_api_key(
api_key: impl Into<String>,
api_base: impl Into<String>,
) -> Result<Self, ProviderError> {
let mut config = AzureAIConfig::new("azure_ai");
config.base.api_key = Some(api_key.into());
config.base.api_base = Some(api_base.into());
Self::new(config)
}
pub fn get_chat_handler(&self) -> &AzureAIChatHandler {
&self.chat_handler
}
pub fn get_embedding_handler(&self) -> &AzureAIEmbeddingHandler {
&self.embedding_handler
}
pub fn get_image_handler(&self) -> &AzureAIImageHandler {
&self.image_handler
}
pub fn get_rerank_handler(&self) -> &AzureAIRerankHandler {
&self.rerank_handler
}
pub fn get_model_registry(&self) -> &AzureAIModelRegistry {
self.model_registry
}
fn ensure_params_supported<'a>(
&self,
model: &str,
params: impl IntoIterator<Item = &'a str>,
) -> Result<(), ProviderError> {
let supported = self.get_supported_openai_params(model);
if let Some(param) = params.into_iter().find(|param| !supported.contains(param)) {
return Err(ProviderError::invalid_request(
"azure_ai",
format!("parameter '{param}' is not supported for model '{model}'"),
));
}
Ok(())
}
fn ensure_chat_request_params_supported(
&self,
request: &ChatRequest,
streaming: bool,
) -> Result<(), ProviderError> {
let params = [
request.temperature.as_ref().map(|_| "temperature"),
request.max_tokens.as_ref().map(|_| "max_tokens"),
request
.max_completion_tokens
.as_ref()
.map(|_| "max_completion_tokens"),
request.top_p.as_ref().map(|_| "top_p"),
request
.frequency_penalty
.as_ref()
.map(|_| "frequency_penalty"),
request
.presence_penalty
.as_ref()
.map(|_| "presence_penalty"),
request.stop.as_ref().map(|_| "stop"),
(request.stream || streaming).then_some("stream"),
request.tools.as_ref().map(|_| "tools"),
request.tool_choice.as_ref().map(|_| "tool_choice"),
request.functions.as_ref().map(|_| "functions"),
request.function_call.as_ref().map(|_| "function_call"),
];
self.ensure_params_supported(&request.model, params.into_iter().flatten())
}
}
impl LLMProvider for AzureAIProvider {
fn name(&self) -> &'static str {
"azure_ai"
}
fn capabilities(&self) -> &'static [ProviderCapability] {
&[
ProviderCapability::ChatCompletion,
ProviderCapability::ChatCompletionStream,
ProviderCapability::Embeddings,
ProviderCapability::ImageGeneration,
ProviderCapability::ToolCalling,
ProviderCapability::FunctionCalling,
]
}
fn models(&self) -> &[ModelInfo] {
static MODELS: std::sync::OnceLock<Vec<ModelInfo>> = std::sync::OnceLock::new();
MODELS.get_or_init(|| self.model_registry.to_model_infos())
}
fn supports_model(&self, model: &str) -> bool {
!model.trim().is_empty()
}
fn get_supported_openai_params(&self, model: &str) -> &'static [&'static str] {
let azure_ai_features = |model_id| {
self.model_registry
.get_model(model_id)
.filter(|spec| {
spec.capabilities
.contains(&ProviderCapability::ChatCompletion)
})
.map(|spec| {
(
spec.supports_function_calling,
spec.supports_streaming,
spec.id == "Phi-4",
)
})
};
let features = match self.model_identity.as_ref() {
Some(binding) => {
let identity = binding.identity();
match (
identity.capability_catalog_provider(),
identity.capability_catalog_model(),
) {
(Some("openai"), Some(model_id)) => {
crate::core::providers::openai::models::get_openai_registry()
.get_model_spec(model_id)
.filter(|spec| {
spec.model_info
.capabilities
.contains(&ProviderCapability::ChatCompletion)
})
.map(|spec| {
let capabilities = &spec.model_info.capabilities;
(
capabilities.contains(&ProviderCapability::ToolCalling),
capabilities
.contains(&ProviderCapability::ChatCompletionStream),
false,
)
})
}
(Some("azure_ai"), Some(model_id)) => identity
.catalog_capabilities()
.filter(|caps| caps.contains(&ProviderCapability::ChatCompletion))
.map(|caps| {
(
caps.contains(&ProviderCapability::ToolCalling),
caps.contains(&ProviderCapability::ChatCompletionStream),
model_id == "Phi-4",
)
}),
_ => None,
}
}
None => azure_ai_features(model),
};
match features {
Some((_, _, true)) => PHI_4_CHAT_PARAMS,
Some((false, false, false)) => CHAT_PARAMS,
Some((false, true, false)) => CHAT_STREAM_PARAMS,
Some((true, false, false)) => CHAT_TOOL_PARAMS,
Some((true, true, false)) => CHAT_TOOL_STREAM_PARAMS,
None => &[],
}
}
async fn map_openai_params(
&self,
params: HashMap<String, Value>,
model: &str,
) -> Result<HashMap<String, Value>, ProviderError> {
self.ensure_params_supported(model, params.keys().map(String::as_str))?;
Ok(params)
}
async fn transform_request(
&self,
request: ChatRequest,
_context: RequestContext,
) -> Result<Value, ProviderError> {
self.ensure_chat_request_params_supported(&request, false)?;
AzureAIChatUtils::transform_request(&request)
}
async fn transform_response(
&self,
raw_response: &[u8],
model: &str,
_request_id: &str,
) -> Result<ChatResponse, ProviderError> {
let response_json: Value = serde_json::from_slice(raw_response)
.map_err(|e| ProviderError::response_parsing("azure_ai", e.to_string()))?;
AzureAIChatUtils::transform_response(response_json, model)
}
fn get_error_mapper(&self) -> Box<dyn ErrorMapper<ProviderError>> {
Box::new(AzureAIErrorMapper)
}
async fn chat_completion(
&self,
request: ChatRequest,
context: RequestContext,
) -> Result<ChatResponse, ProviderError> {
self.ensure_chat_request_params_supported(&request, false)?;
self.chat_handler
.create_chat_completion(request, context)
.await
}
async fn chat_completion_stream(
&self,
request: ChatRequest,
context: RequestContext,
) -> Result<Pin<Box<dyn Stream<Item = Result<ChatChunk, ProviderError>> + Send>>, ProviderError>
{
self.ensure_chat_request_params_supported(&request, true)?;
let stream = self
.chat_handler
.create_chat_completion_stream(request, context)
.await?;
Ok(Box::pin(stream))
}
async fn embeddings(
&self,
request: EmbeddingRequest,
context: RequestContext,
) -> Result<EmbeddingResponse, ProviderError> {
self.embedding_handler.embedding(request, context).await
}
async fn image_generation(
&self,
request: ImageGenerationRequest,
context: RequestContext,
) -> Result<ImageGenerationResponse, ProviderError> {
self.image_handler.generate_image(request, context).await
}
async fn health_check(&self) -> HealthStatus {
if self.client.get_config().validate().is_err() {
return HealthStatus::Unhealthy;
}
let url = match self.client.get_config().build_endpoint_url("models") {
Ok(url) => url,
Err(_) => return HealthStatus::Unhealthy,
};
let request = match self.client.request(Method::GET, &url) {
Ok(request) => request,
Err(_) => return HealthStatus::Unhealthy,
};
match request.send().await {
Ok(response) if response.status().is_success() => HealthStatus::Healthy,
Ok(_) => HealthStatus::Degraded,
Err(_) => HealthStatus::Unhealthy,
}
}
async fn calculate_cost(
&self,
model: &str,
input_tokens: u32,
output_tokens: u32,
) -> Result<f64, ProviderError> {
crate::core::providers::model_identity::calculate_managed_provider_cost(
&crate::core::providers::Provider::AzureAI(self.clone()),
model,
input_tokens,
output_tokens,
)
.unwrap_or_else(|| {
Err(ProviderError::configuration(
"pricing",
"Azure AI pricing authority dispatch is unavailable",
))
})
}
}
pub struct AzureAIProviderFactory;
impl AzureAIProviderFactory {
pub fn create_default() -> Result<AzureAIProvider, ProviderError> {
let config = AzureAIConfig::new("azure_ai");
AzureAIProvider::new(config)
}
pub fn create_with_config(config: AzureAIConfig) -> Result<AzureAIProvider, ProviderError> {
AzureAIProvider::new(config)
}
pub fn create_from_env() -> Result<AzureAIProvider, ProviderError> {
let config = AzureAIConfig::from_env();
AzureAIProvider::new(config)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::types::{chat::ChatMessage, message::MessageContent, message::MessageRole};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
fn create_test_config() -> AzureAIConfig {
let mut config = AzureAIConfig::new("azure_ai");
config.base.api_key = Some("test_api_key".to_string());
config.base.api_base = Some("https://test.ai.azure.com".to_string());
config
}
async fn read_http_headers(socket: &mut TcpStream) -> std::io::Result<()> {
let mut request = Vec::new();
let mut buffer = [0_u8; 1024];
loop {
let bytes_read = socket.read(&mut buffer).await?;
if bytes_read == 0 {
return Ok(());
}
request.extend_from_slice(&buffer[..bytes_read]);
if request.windows(4).any(|window| window == b"\r\n\r\n") {
return Ok(());
}
}
}
async fn health_response_base_url(status: &str) -> std::io::Result<String> {
let listener = TcpListener::bind(("127.0.0.1", 0)).await?;
let addr = listener.local_addr()?;
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}"
);
tokio::spawn(async move {
let Ok((mut socket, _)) = listener.accept().await else {
return;
};
if read_http_headers(&mut socket).await.is_err() {
return;
}
if let Err(err) = socket.write_all(response.as_bytes()).await {
eprintln!("test server failed to write response: {err}");
}
});
Ok(format!("http://{addr}"))
}
#[tokio::test]
async fn test_provider_factory() {
let _result = AzureAIProviderFactory::create_default();
}
#[tokio::test]
async fn test_provider_creation() {
let config = AzureAIConfig::new("azure_ai");
let _result = AzureAIProvider::new(config);
}
#[test]
fn test_provider_creation_with_valid_config() {
let config = create_test_config();
let provider = AzureAIProvider::new(config);
assert!(provider.is_ok());
}
#[test]
fn test_provider_creation_missing_api_key() {
let mut config = AzureAIConfig::new("azure_ai");
config.base.api_base = Some("https://test.ai.azure.com".to_string());
let provider = AzureAIProvider::new(config);
assert!(provider.is_err());
}
#[test]
fn test_provider_with_api_key() {
let provider = AzureAIProvider::with_api_key("test_key", "https://test.ai.azure.com");
assert!(provider.is_ok());
}
#[test]
fn test_factory_create_with_config() {
let config = create_test_config();
let provider = AzureAIProviderFactory::create_with_config(config);
assert!(provider.is_ok());
}
#[test]
fn test_provider_name() {
let config = create_test_config();
let provider = AzureAIProvider::new(config).unwrap();
assert_eq!(provider.name(), "azure_ai");
}
#[test]
fn test_provider_capabilities() {
let config = create_test_config();
let provider = AzureAIProvider::new(config).unwrap();
let caps = provider.capabilities();
assert!(caps.contains(&ProviderCapability::ChatCompletion));
assert!(caps.contains(&ProviderCapability::ChatCompletionStream));
assert!(caps.contains(&ProviderCapability::Embeddings));
assert!(caps.contains(&ProviderCapability::ImageGeneration));
assert!(caps.contains(&ProviderCapability::ToolCalling));
assert!(caps.contains(&ProviderCapability::FunctionCalling));
}
#[test]
fn test_provider_models_not_empty() {
let config = create_test_config();
let provider = AzureAIProvider::new(config).unwrap();
assert!(!provider.models().is_empty());
}
#[test]
fn test_supports_dynamic_deployment_names() {
let config = create_test_config();
let provider = match AzureAIProvider::new(config) {
Ok(provider) => provider,
Err(error) => panic!("provider should be created: {error}"),
};
assert!(provider.supports_model("customer-gpt4o-prod"));
assert!(!provider.supports_model(" "));
}
#[test]
fn test_get_config() {
let config = create_test_config();
let provider = AzureAIProvider::new(config.clone()).unwrap();
let retrieved_config = provider.get_config();
assert_eq!(retrieved_config.base.api_key, config.base.api_key);
assert_eq!(retrieved_config.base.api_base, config.base.api_base);
}
#[test]
fn test_get_handlers() {
let config = create_test_config();
let provider = AzureAIProvider::new(config).unwrap();
let _chat = provider.get_chat_handler();
let _embed = provider.get_embedding_handler();
let _image = provider.get_image_handler();
let _rerank = provider.get_rerank_handler();
let _registry = provider.get_model_registry();
}
#[test]
fn test_model_capabilities() {
let registry = get_azure_ai_registry();
assert!(registry.supports_capability("gpt-4o", &ProviderCapability::ChatCompletion));
assert!(
registry.supports_capability("text-embedding-3-large", &ProviderCapability::Embeddings)
);
assert!(!registry.supports_capability("dall-e-3", &ProviderCapability::ChatCompletion));
}
#[test]
fn test_model_registry_gpt_models() {
let registry = get_azure_ai_registry();
assert!(registry.get_model("gpt-4o").is_some());
assert!(registry.get_model("gpt-4").is_none());
assert!(registry.get_model("gpt-35-turbo").is_some());
}
#[test]
fn test_model_registry_embedding_models() {
let registry = get_azure_ai_registry();
assert!(registry.get_model("text-embedding-3-large").is_some());
assert!(registry.get_model("text-embedding-3-small").is_some());
}
#[tokio::test]
async fn test_transform_request_basic() {
let config = create_test_config();
let provider = AzureAIProvider::new(config).unwrap();
let request = ChatRequest {
model: "gpt-4o".to_string(),
messages: vec![ChatMessage {
role: MessageRole::User,
content: Some(MessageContent::Text("Hello".to_string())),
..Default::default()
}],
..Default::default()
};
let context = RequestContext::default();
let result = provider.transform_request(request, context).await;
assert!(result.is_ok());
let transformed = result.unwrap();
assert!(transformed["messages"].is_array());
}
#[tokio::test]
async fn test_transform_request_with_temperature() {
let config = create_test_config();
let provider = AzureAIProvider::new(config).unwrap();
let request = ChatRequest {
model: "gpt-4o".to_string(),
messages: vec![ChatMessage {
role: MessageRole::User,
content: Some(MessageContent::Text("Hello".to_string())),
..Default::default()
}],
temperature: Some(0.7),
..Default::default()
};
let context = RequestContext::default();
let result = provider.transform_request(request, context).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_health_check_success_requires_endpoint_success() {
let mut config = create_test_config();
config.base.api_base = Some(match health_response_base_url("200 OK").await {
Ok(url) => url,
Err(error) => panic!("test server should start: {error}"),
});
config.base.endpoint_access = crate::core::net::ProviderEndpointAccess::PrivateNetwork;
let provider = match AzureAIProvider::new(config) {
Ok(provider) => provider,
Err(error) => panic!("provider should be created: {error}"),
};
let status = provider.health_check().await;
assert_eq!(status, HealthStatus::Healthy);
}
#[tokio::test]
async fn test_health_check_degrades_on_endpoint_failure() {
let mut config = create_test_config();
config.base.api_base = Some(
match health_response_base_url("500 Internal Server Error").await {
Ok(url) => url,
Err(error) => panic!("test server should start: {error}"),
},
);
config.base.endpoint_access = crate::core::net::ProviderEndpointAccess::PrivateNetwork;
let provider = match AzureAIProvider::new(config) {
Ok(provider) => provider,
Err(error) => panic!("provider should be created: {error}"),
};
let status = provider.health_check().await;
assert_eq!(status, HealthStatus::Degraded);
}
#[tokio::test]
async fn test_calculate_cost_known_model() {
let config = create_test_config();
let provider = AzureAIProvider::new(config).unwrap();
let cost = provider.calculate_cost("gpt-4o", 1000, 500).await;
let _ = cost;
}
#[tokio::test]
async fn test_calculate_cost_unknown_model() {
let config = create_test_config();
let provider = AzureAIProvider::new(config).unwrap();
let cost = provider
.calculate_cost("unknown-model-xyz", 1000, 500)
.await;
assert!(cost.is_err());
}
#[test]
fn test_get_error_mapper() {
let config = create_test_config();
let provider = AzureAIProvider::new(config).unwrap();
let _mapper = provider.get_error_mapper();
}
#[test]
fn test_provider_clone() {
let config = create_test_config();
let provider = AzureAIProvider::new(config).unwrap();
let cloned = provider.clone();
assert_eq!(provider.name(), cloned.name());
assert_eq!(provider.capabilities().len(), cloned.capabilities().len());
}
#[test]
fn test_provider_debug() {
let config = create_test_config();
let provider = AzureAIProvider::new(config).unwrap();
let debug_str = format!("{:?}", provider);
assert!(debug_str.contains("AzureAIProvider"));
}
#[test]
fn test_model_registry_to_model_infos() {
let registry = get_azure_ai_registry();
let infos = registry.to_model_infos();
assert!(!infos.is_empty());
for info in &infos {
assert!(!info.id.is_empty());
assert!(!info.provider.is_empty());
}
}
#[test]
fn test_model_registry_get_all_models() {
let registry = get_azure_ai_registry();
let models = registry.get_all_models();
assert!(!models.is_empty());
}
}