|
| 1 | +# app_config.py |
| 2 | +import os |
| 3 | +import logging |
| 4 | +from typing import Optional, List, Dict, Any |
| 5 | +from dotenv import load_dotenv |
| 6 | +from azure.identity import DefaultAzureCredential, ClientSecretCredential |
| 7 | +from azure.cosmos.aio import CosmosClient |
| 8 | +from azure.ai.projects.aio import AIProjectClient |
| 9 | +from semantic_kernel.kernel import Kernel |
| 10 | +from semantic_kernel.contents import ChatHistory |
| 11 | +from semantic_kernel.agents.azure_ai.azure_ai_agent import AzureAIAgent |
| 12 | +from semantic_kernel.functions import KernelFunction |
| 13 | + |
| 14 | +# Load environment variables from .env file |
| 15 | +load_dotenv() |
| 16 | + |
| 17 | + |
| 18 | +class AppConfig: |
| 19 | + """Application configuration class that loads settings from environment variables.""" |
| 20 | + |
| 21 | + def __init__(self): |
| 22 | + """Initialize the application configuration with environment variables.""" |
| 23 | + # Azure authentication settings |
| 24 | + self.AZURE_TENANT_ID = self._get_optional("AZURE_TENANT_ID") |
| 25 | + self.AZURE_CLIENT_ID = self._get_optional("AZURE_CLIENT_ID") |
| 26 | + self.AZURE_CLIENT_SECRET = self._get_optional("AZURE_CLIENT_SECRET") |
| 27 | + |
| 28 | + # CosmosDB settings |
| 29 | + self.COSMOSDB_ENDPOINT = self._get_optional("COSMOSDB_ENDPOINT") |
| 30 | + self.COSMOSDB_DATABASE = self._get_optional("COSMOSDB_DATABASE") |
| 31 | + self.COSMOSDB_CONTAINER = self._get_optional("COSMOSDB_CONTAINER") |
| 32 | + |
| 33 | + # Azure OpenAI settings |
| 34 | + self.AZURE_OPENAI_DEPLOYMENT_NAME = self._get_required( |
| 35 | + "AZURE_OPENAI_DEPLOYMENT_NAME", "gpt-4o" |
| 36 | + ) |
| 37 | + self.AZURE_OPENAI_API_VERSION = self._get_required( |
| 38 | + "AZURE_OPENAI_API_VERSION", "2024-11-20" |
| 39 | + ) |
| 40 | + self.AZURE_OPENAI_ENDPOINT = self._get_required("AZURE_OPENAI_ENDPOINT") |
| 41 | + self.AZURE_OPENAI_SCOPES = [ |
| 42 | + f"{self._get_optional('AZURE_OPENAI_SCOPE', 'https://cognitiveservices.azure.com/.default')}" |
| 43 | + ] |
| 44 | + |
| 45 | + # Frontend settings |
| 46 | + self.FRONTEND_SITE_NAME = self._get_optional( |
| 47 | + "FRONTEND_SITE_NAME", "http://127.0.0.1:3000" |
| 48 | + ) |
| 49 | + |
| 50 | + # Azure AI settings |
| 51 | + self.AZURE_AI_SUBSCRIPTION_ID = self._get_required("AZURE_AI_SUBSCRIPTION_ID") |
| 52 | + self.AZURE_AI_RESOURCE_GROUP = self._get_required("AZURE_AI_RESOURCE_GROUP") |
| 53 | + self.AZURE_AI_PROJECT_NAME = self._get_required("AZURE_AI_PROJECT_NAME") |
| 54 | + self.AZURE_AI_AGENT_PROJECT_CONNECTION_STRING = self._get_required( |
| 55 | + "AZURE_AI_AGENT_PROJECT_CONNECTION_STRING" |
| 56 | + ) |
| 57 | + |
| 58 | + # Cached clients and resources |
| 59 | + self._azure_credentials = None |
| 60 | + self._cosmos_client = None |
| 61 | + self._cosmos_database = None |
| 62 | + self._ai_project_client = None |
| 63 | + |
| 64 | + def _get_required(self, name: str, default: Optional[str] = None) -> str: |
| 65 | + """Get a required configuration value from environment variables. |
| 66 | +
|
| 67 | + Args: |
| 68 | + name: The name of the environment variable |
| 69 | + default: Optional default value if not found |
| 70 | +
|
| 71 | + Returns: |
| 72 | + The value of the environment variable or default if provided |
| 73 | +
|
| 74 | + Raises: |
| 75 | + ValueError: If the environment variable is not found and no default is provided |
| 76 | + """ |
| 77 | + if name in os.environ: |
| 78 | + return os.environ[name] |
| 79 | + if default is not None: |
| 80 | + logging.warning( |
| 81 | + "Environment variable %s not found, using default value", name |
| 82 | + ) |
| 83 | + return default |
| 84 | + raise ValueError( |
| 85 | + f"Environment variable {name} not found and no default provided" |
| 86 | + ) |
| 87 | + |
| 88 | + def _get_optional(self, name: str, default: str = "") -> str: |
| 89 | + """Get an optional configuration value from environment variables. |
| 90 | +
|
| 91 | + Args: |
| 92 | + name: The name of the environment variable |
| 93 | + default: Default value if not found (default: "") |
| 94 | +
|
| 95 | + Returns: |
| 96 | + The value of the environment variable or the default value |
| 97 | + """ |
| 98 | + if name in os.environ: |
| 99 | + return os.environ[name] |
| 100 | + return default |
| 101 | + |
| 102 | + def _get_bool(self, name: str) -> bool: |
| 103 | + """Get a boolean configuration value from environment variables. |
| 104 | +
|
| 105 | + Args: |
| 106 | + name: The name of the environment variable |
| 107 | +
|
| 108 | + Returns: |
| 109 | + True if the environment variable exists and is set to 'true' or '1', False otherwise |
| 110 | + """ |
| 111 | + return name in os.environ and os.environ[name].lower() in ["true", "1"] |
| 112 | + |
| 113 | + def get_azure_credentials(self): |
| 114 | + """Get Azure credentials using DefaultAzureCredential. |
| 115 | +
|
| 116 | + Returns: |
| 117 | + DefaultAzureCredential instance for Azure authentication |
| 118 | + """ |
| 119 | + # Cache the credentials object |
| 120 | + if self._azure_credentials is not None: |
| 121 | + return self._azure_credentials |
| 122 | + |
| 123 | + try: |
| 124 | + self._azure_credentials = DefaultAzureCredential() |
| 125 | + return self._azure_credentials |
| 126 | + except Exception as exc: |
| 127 | + logging.warning("Failed to create DefaultAzureCredential: %s", exc) |
| 128 | + return None |
| 129 | + |
| 130 | + def get_cosmos_database_client(self): |
| 131 | + """Get a Cosmos DB client for the configured database. |
| 132 | +
|
| 133 | + Returns: |
| 134 | + A Cosmos DB database client |
| 135 | + """ |
| 136 | + try: |
| 137 | + if self._cosmos_client is None: |
| 138 | + self._cosmos_client = CosmosClient( |
| 139 | + self.COSMOSDB_ENDPOINT, credential=self.get_azure_credentials() |
| 140 | + ) |
| 141 | + |
| 142 | + if self._cosmos_database is None: |
| 143 | + self._cosmos_database = self._cosmos_client.get_database_client( |
| 144 | + self.COSMOSDB_DATABASE |
| 145 | + ) |
| 146 | + |
| 147 | + return self._cosmos_database |
| 148 | + except Exception as exc: |
| 149 | + logging.error( |
| 150 | + "Failed to create CosmosDB client: %s. CosmosDB is required for this application.", |
| 151 | + exc, |
| 152 | + ) |
| 153 | + raise |
| 154 | + |
| 155 | + def create_kernel(self): |
| 156 | + """Creates a new Semantic Kernel instance. |
| 157 | +
|
| 158 | + Returns: |
| 159 | + A new Semantic Kernel instance |
| 160 | + """ |
| 161 | + # Create a new kernel instance without manually configuring OpenAI services |
| 162 | + # The agents will be created using Azure AI Agent Project pattern instead |
| 163 | + kernel = Kernel() |
| 164 | + return kernel |
| 165 | + |
| 166 | + def get_ai_project_client(self): |
| 167 | + """Create and return an AIProjectClient for Azure AI Foundry using from_connection_string. |
| 168 | +
|
| 169 | + Returns: |
| 170 | + An AIProjectClient instance |
| 171 | + """ |
| 172 | + if self._ai_project_client is not None: |
| 173 | + return self._ai_project_client |
| 174 | + |
| 175 | + try: |
| 176 | + credential = self.get_azure_credentials() |
| 177 | + if credential is None: |
| 178 | + raise RuntimeError( |
| 179 | + "Unable to acquire Azure credentials; ensure DefaultAzureCredential is configured" |
| 180 | + ) |
| 181 | + |
| 182 | + connection_string = self.AZURE_AI_AGENT_PROJECT_CONNECTION_STRING |
| 183 | + self._ai_project_client = AIProjectClient.from_connection_string( |
| 184 | + credential=credential, conn_str=connection_string |
| 185 | + ) |
| 186 | + logging.info("Successfully created AIProjectClient using connection string") |
| 187 | + return self._ai_project_client |
| 188 | + except Exception as exc: |
| 189 | + logging.error("Failed to create AIProjectClient: %s", exc) |
| 190 | + raise |
| 191 | + |
| 192 | + async def create_azure_ai_agent( |
| 193 | + self, |
| 194 | + kernel: Kernel, |
| 195 | + agent_name: str, |
| 196 | + instructions: str, |
| 197 | + tools: Optional[List[KernelFunction]] = None, |
| 198 | + response_format=None, |
| 199 | + temperature: float = 0.0, |
| 200 | + ): |
| 201 | + """ |
| 202 | + Creates a new Azure AI Agent with the specified name and instructions using AIProjectClient. |
| 203 | + If an agent with the given name (assistant_id) already exists, it tries to retrieve it first. |
| 204 | +
|
| 205 | + Args: |
| 206 | + kernel: The Semantic Kernel instance |
| 207 | + agent_name: The name of the agent (will be used as assistant_id) |
| 208 | + instructions: The system message / instructions for the agent |
| 209 | + agent_type: The type of agent (defaults to "assistant") |
| 210 | + tools: Optional tool definitions for the agent |
| 211 | + tool_resources: Optional tool resources required by the tools |
| 212 | + response_format: Optional response format to control structured output |
| 213 | + temperature: The temperature setting for the agent (defaults to 0.0) |
| 214 | +
|
| 215 | + Returns: |
| 216 | + A new AzureAIAgent instance |
| 217 | + """ |
| 218 | + try: |
| 219 | + # Get the AIProjectClient |
| 220 | + project_client = self.get_ai_project_client() |
| 221 | + |
| 222 | + # First try to get an existing agent with this name as assistant_id |
| 223 | + try: |
| 224 | + logging.info(f"Trying to retrieve existing agent with ID: {agent_name}") |
| 225 | + existing_definition = await project_client.agents.get_agent(agent_name) |
| 226 | + logging.info(f"Found existing agent with ID: {agent_name}") |
| 227 | + |
| 228 | + # Create the agent instance directly with project_client and existing definition |
| 229 | + agent = AzureAIAgent( |
| 230 | + client=project_client, |
| 231 | + definition=existing_definition, |
| 232 | + kernel=kernel, |
| 233 | + plugins=tools, |
| 234 | + ) |
| 235 | + |
| 236 | + logging.info( |
| 237 | + f"Successfully loaded existing Azure AI Agent for {agent_name}" |
| 238 | + ) |
| 239 | + return agent |
| 240 | + except Exception as e: |
| 241 | + # The Azure AI Projects SDK throws an exception when the agent doesn't exist |
| 242 | + # (not returning None), so we catch it and proceed to create a new agent |
| 243 | + if "ResourceNotFound" in str(e) or "404" in str(e): |
| 244 | + logging.info( |
| 245 | + f"Agent with ID {agent_name} not found. Will create a new one." |
| 246 | + ) |
| 247 | + else: |
| 248 | + # Log unexpected errors but still try to create a new agent |
| 249 | + logging.warning( |
| 250 | + f"Unexpected error while retrieving agent {agent_name}: {str(e)}. Attempting to create new agent." |
| 251 | + ) |
| 252 | + |
| 253 | + # Create the agent using the project client with the agent_name as both name and assistantId |
| 254 | + agent_definition = await project_client.agents.create_agent( |
| 255 | + model=self.AZURE_OPENAI_DEPLOYMENT_NAME, |
| 256 | + name=agent_name, |
| 257 | + instructions=instructions, |
| 258 | + temperature=temperature, |
| 259 | + response_format=response_format, |
| 260 | + ) |
| 261 | + |
| 262 | + # Create the agent instance directly with project_client and definition |
| 263 | + agent = AzureAIAgent( |
| 264 | + client=project_client, |
| 265 | + definition=agent_definition, |
| 266 | + kernel=kernel, |
| 267 | + plugins=tools, |
| 268 | + ) |
| 269 | + |
| 270 | + return agent |
| 271 | + except Exception as exc: |
| 272 | + logging.error("Failed to create Azure AI Agent: %s", exc) |
| 273 | + raise |
| 274 | + |
| 275 | + |
| 276 | +# Create a global instance of AppConfig |
| 277 | +config = AppConfig() |
0 commit comments