diff --git a/.github/workflows/use-visitor-counter.yml b/.github/workflows/use-visitor-counter.yml index 4aa2c96..b865948 100644 --- a/.github/workflows/use-visitor-counter.yml +++ b/.github/workflows/use-visitor-counter.yml @@ -57,38 +57,30 @@ jobs: git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" + # Commit and push logic for PR events (merge, not rebase) - name: Commit and push changes (PR) if: github.event_name == 'pull_request' env: TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | git fetch origin - git checkout -b ${{ github.event.pull_request.head.ref }} origin/${{ github.event.pull_request.head.ref }} - git add "*.md" metrics.json + git checkout ${{ github.head_ref }} + git pull origin ${{ github.head_ref }} || echo "No merge needed" + git add -A git commit -m "Update visitor count" || echo "No changes to commit" git remote set-url origin https://x-access-token:${TOKEN}@github.com/${{ github.repository }} - git pull --rebase origin ${{ github.event.pull_request.head.ref }} || echo "No rebase needed" - git push origin HEAD:${{ github.event.pull_request.head.ref }} + git push origin HEAD:${{ github.head_ref }} + # Commit and push logic for non-PR events (merge, not rebase) - name: Commit and push changes (non-PR) if: github.event_name != 'pull_request' env: TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | git fetch origin - git checkout ${{ github.event.pull_request.head.ref }} || git checkout -b ${{ github.event.pull_request.head.ref }} origin/${{ github.event.pull_request.head.ref }} - git add "*.md" metrics.json + git checkout ${{ github.ref_name }} || git checkout -b ${{ github.ref_name }} origin/${{ github.ref_name }} + git pull origin ${{ github.ref_name }} || echo "No merge needed" + git add -A git commit -m "Update visitor count" || echo "No changes to commit" git remote set-url origin https://x-access-token:${TOKEN}@github.com/${{ github.repository }} - git pull --rebase origin ${{ github.event.pull_request.head.ref }} || echo "No rebase needed" - git push origin HEAD:${{ github.event.pull_request.head.ref }} - - - name: Create Pull Request (non-PR) - if: github.event_name != 'pull_request' - uses: peter-evans/create-pull-request@v6 - with: - token: ${{ secrets.GITHUB_TOKEN }} - branch: update-visitor-count - title: "Update visitor count" - body: "Automated update of visitor count" - base: main + git push origin HEAD:${{ github.ref_name }} diff --git a/README.md b/README.md index 533d51f..ddc1ee8 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Costa Rica [![GitHub](https://img.shields.io/badge/--181717?logo=github&logoColor=ffffff)](https://github.com/) [brown9804](https://github.com/brown9804) -Last updated: 2026-01-12 +Last updated: 2026-01-29 ---------- @@ -153,7 +153,7 @@ graph TD - Configures delegation relationships between Product Manager and specialized agents. - Saves the unique Agent IDs, delegation endpoints, and A2A configuration to the `.env` file. - > E.g `Old UI` + > E.g `Classic UI` image @@ -171,7 +171,7 @@ graph TD - Visit `https://.azurewebsites.net`. - You should see the Zava chat interface with A2A protocol support. - > E.g `Old UI` + > E.g `Classic UI` @@ -187,7 +187,7 @@ graph TD - Core agents: Cora, Interior Design, Inventory, Loyalty, Cart Manager - Product Management Specialist with delegation capabilities - > E.g `Old UI` + > E.g `Classic UI` @@ -202,7 +202,7 @@ graph TD
- Total views -

Refresh Date: 2026-01-12

+ Total views +

Refresh Date: 2026-01-29

diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index 8b4fcc4..56a7a91 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -347,7 +347,7 @@ terraform apply
- Total views -

Refresh Date: 2026-01-12

+ Total views +

Refresh Date: 2026-01-29

diff --git a/src/a2a/status_automation.ps1 b/src/a2a/status_automation.ps1 index 55089e8..59fe6fc 100644 --- a/src/a2a/status_automation.ps1 +++ b/src/a2a/status_automation.ps1 @@ -11,7 +11,7 @@ if () { # Check automation endpoint try { - = Invoke-RestMethod -Uri "https://zava-4f64ebdc-app.azurewebsites.net/a2a/automation/status" -TimeoutSec 5 + = Invoke-RestMethod -Uri "https://zava-63f59c9f-app.azurewebsites.net/a2a/automation/status" -TimeoutSec 5 Write-Host "Automation Status: " } catch { Write-Host "Automation endpoint not accessible" diff --git a/src/app/agents/agent_processor.py b/src/app/agents/agent_processor.py index ef1f131..e8c4558 100644 --- a/src/app/agents/agent_processor.py +++ b/src/app/agents/agent_processor.py @@ -8,6 +8,7 @@ try: from azure.ai.projects import AIProjectClient # type: ignore from azure.identity import DefaultAzureCredential # type: ignore + from services.azure_auth import get_default_credential, get_inference_credential # type: ignore _REMOTE_AVAILABLE = True except Exception: _REMOTE_AVAILABLE = False @@ -112,11 +113,32 @@ def __init__(self, agent_id: str, project_endpoint: str = None): project_endpoint: Optional project endpoint (reads from env if not provided) """ self.agent_id = agent_id - self.project_endpoint = project_endpoint or os.environ.get("AZURE_AI_AGENT_ENDPOINT") - - if not self.project_endpoint or not _REMOTE_AVAILABLE: + + raw_endpoint = ( + project_endpoint + or os.environ.get("AZURE_AI_AGENT_ENDPOINT") + or os.environ.get("AZURE_AI_PROJECT_ENDPOINT") + or os.environ.get("AZURE_AI_FOUNDRY_ENDPOINT") + ) + if not raw_endpoint or not _REMOTE_AVAILABLE: raise ValueError("Remote agent support unavailable (endpoint or SDK missing)") - self.client = AIProjectClient(endpoint=self.project_endpoint, credential=DefaultAzureCredential()) + + # The Azure AI Projects SDK expects: https://.services.ai.azure.com/api/projects/ + project_name = os.environ.get("AZURE_AI_PROJECT_NAME") + normalized = raw_endpoint.replace("cognitiveservices.azure.com", "services.ai.azure.com") + + if "/api/projects/" in normalized: + # Already a full project endpoint + full_project_endpoint = normalized.rstrip("/") + elif project_name: + base_endpoint = normalized.split("/api/")[0].rstrip("/") + full_project_endpoint = f"{base_endpoint}/api/projects/{project_name}" + else: + # Best-effort fallback (may still work if the caller provided a full endpoint) + full_project_endpoint = normalized.rstrip("/") + + self.project_endpoint = full_project_endpoint + self.client = AIProjectClient(endpoint=self.project_endpoint, credential=get_default_credential()) def run_conversation_with_text_stream( self, diff --git a/src/app/agents/deploy_real_agents.py b/src/app/agents/deploy_real_agents.py index 03a50c2..706c53e 100644 --- a/src/app/agents/deploy_real_agents.py +++ b/src/app/agents/deploy_real_agents.py @@ -6,29 +6,123 @@ import sys import json import hashlib +from typing import Optional from azure.ai.projects import AIProjectClient -from azure.identity import DefaultAzureCredential -from azure.core.credentials import AzureKeyCredential from dotenv import load_dotenv -load_dotenv() + +_dotenv_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".env")) +if os.path.exists(_dotenv_path): + load_dotenv(dotenv_path=_dotenv_path) +else: + # Fall back to default search behavior for local/dev environments. + load_dotenv() + +def get_env(name: str, default: Optional[str] = None) -> Optional[str]: + """Read an environment variable with APPSETTING_ fallback.""" + value = os.getenv(name) + if value: + return value + prefixed = os.getenv(f"APPSETTING_{name}") + if prefixed: + return prefixed + return default + # Debug environment variables -print(f"DEBUG: AZURE_SUBSCRIPTION_ID={os.getenv('AZURE_SUBSCRIPTION_ID')}") -print(f"DEBUG: AZURE_RESOURCE_GROUP={os.getenv('AZURE_RESOURCE_GROUP')}") -print(f"DEBUG: AZURE_AI_PROJECT_NAME={os.getenv('AZURE_AI_PROJECT_NAME')}") -print(f"DEBUG: AZURE_LOCATION={os.getenv('AZURE_LOCATION')}") +print(f"DEBUG: AZURE_SUBSCRIPTION_ID={get_env('AZURE_SUBSCRIPTION_ID')}") +print(f"DEBUG: AZURE_RESOURCE_GROUP={get_env('AZURE_RESOURCE_GROUP')}") +print(f"DEBUG: AZURE_AI_PROJECT_NAME={get_env('AZURE_AI_PROJECT_NAME')}") +print(f"DEBUG: AZURE_LOCATION={get_env('AZURE_LOCATION')}") def _hash_instructions(text: str) -> str: return hashlib.sha256(text.encode("utf-8")).hexdigest() + +def _resolve_model_name(model: str) -> str: + """Resolve model name to the exact Azure AI Foundry deployment name.""" + model_map = { + "model_router": "model-router", + "gpt_4o": "gpt-4o", + "gpt_4o_mini": "gpt-4o-mini", + "text_embedding_3_small": "text-embedding-3-small", + "model-router": "model-router", + "gpt-4o": "gpt-4o", + "gpt-4o-mini": "gpt-4o-mini", + "text-embedding-3-small": "text-embedding-3-small", + } + resolved = model_map.get(model, model) + if resolved == model and "_" in model: + resolved = model.replace("_", "-") + return resolved + + +def _sanitize_agent_name(name: str) -> str: + """Sanitize agent name for API constraints (lowercase, hyphens, <=63 chars).""" + import re + + name = name.replace(" ", "-") + name = re.sub(r"[^a-zA-Z0-9-]", "", name) + name = re.sub(r"-+", "-", name) + name = name[:63] + name = name.strip("-") + return name.lower() + + +def _create_agent(project_client: AIProjectClient, *, model: str, name: str, instructions: str): + """Create an agent with SDK-version fallback support.""" + agents = project_client.agents + + if hasattr(agents, "create_agent"): + return agents.create_agent(model=model, name=name, instructions=instructions) + + if hasattr(agents, "create"): + # Try a simple kwargs signature first + try: + return agents.create(model=model, name=name, instructions=instructions) + except TypeError: + pass + + # Fall back to SDK model definitions + try: + from azure.ai.projects.models import PromptAgentDefinition + + agent_def = PromptAgentDefinition(model=model, name=name, instructions=instructions) + return agents.create(agent_def) + except Exception: + pass + + try: + from azure.ai.projects.models import AgentDefinition + + agent_def = AgentDefinition(model=model, name=name, instructions=instructions) + return agents.create(agent_def) + except Exception: + pass + + # Last resort: pass a dict payload + return agents.create({"model": model, "name": name, "instructions": instructions}) + + if hasattr(agents, "create_prompt_agent"): + return agents.create_prompt_agent(model=model, name=name, instructions=instructions) + + raise AttributeError("No supported agent creation method found in Azure AI Projects SDK") + + +from services.azure_auth import get_default_credential + def deploy_agents(): """Deploy or update agents idempotently, emitting structured JSON for Terraform.""" - project_endpoint = os.getenv("AZURE_AI_PROJECT_ENDPOINT") or os.getenv("AZURE_AI_FOUNDRY_ENDPOINT") + project_endpoint = get_env("AZURE_AI_PROJECT_ENDPOINT") or get_env("AZURE_AI_FOUNDRY_ENDPOINT") if not project_endpoint: - print("ERROR: AZURE_AI_PROJECT_ENDPOINT / AZURE_AI_FOUNDRY_ENDPOINT not configured") - sys.exit(0) # Do not hard fail; allow Terraform run to proceed + foundry_name = get_env("AZURE_AI_FOUNDRY_NAME") + project_name = get_env("AZURE_AI_PROJECT_NAME") + if foundry_name and project_name: + project_endpoint = f"https://{foundry_name}.services.ai.azure.com/api/projects/{project_name}" + else: + print("ERROR: AZURE_AI_PROJECT_ENDPOINT / AZURE_AI_FOUNDRY_ENDPOINT not configured") + sys.exit(0) # Do not hard fail; allow Terraform run to proceed print("=" * 70) print("Idempotent Multi-Agent Provisioning - Azure AI Foundry") @@ -37,18 +131,32 @@ def deploy_agents(): print() # Try to construct connection string if available - project_connection_string = os.getenv("AZURE_AI_PROJECT_CONNECTION_STRING") + project_connection_string = get_env("AZURE_AI_PROJECT_CONNECTION_STRING") if not project_connection_string: - sub_id = os.getenv("AZURE_SUBSCRIPTION_ID") - rg = os.getenv("AZURE_RESOURCE_GROUP") - project_name = os.getenv("AZURE_AI_PROJECT_NAME") - location = os.getenv("AZURE_LOCATION") + sub_id = get_env("AZURE_SUBSCRIPTION_ID") + rg = get_env("AZURE_RESOURCE_GROUP") + project_name = get_env("AZURE_AI_PROJECT_NAME") + location = get_env("AZURE_LOCATION") if sub_id and rg and project_name and location: project_connection_string = f"{location}.api.azureml.ms;subscription_id={sub_id};resource_group={rg};project_name={project_name}" print(f"Constructed connection string: {project_connection_string}") # Agent config definitions + model_deployment = ( + get_env("AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME") + or get_env("AZURE_OPENAI_CHAT_DEPLOYMENT") + or get_env("MODEL_DEPLOYMENT_NAME") + or "model-router" + ) + model_deployment = _resolve_model_name(model_deployment) + + agent_model_map = {} + try: + agent_model_map = json.loads(get_env("AGENT_MODEL_MAP", "{}") or "{}") + except Exception: + agent_model_map = {} + agents_config = [ { "name": "Cora - Shopping Assistant", @@ -58,7 +166,7 @@ def deploy_agents(): "Your role is to help customers find products, answer questions about inventory, provide recommendations, and assist with general shopping needs. " "Be friendly, professional, and informative. Keep answers concise and helpful." ), - "model": "gpt-4o-mini" + "model": agent_model_map.get("cora", model_deployment) }, { "name": "Interior Design Specialist", @@ -67,7 +175,7 @@ def deploy_agents(): "You are an expert interior designer at Zava. Help customers with color schemes, room layout, product combinations, and style advice (modern, rustic, minimalist, etc.). " "Provide creative, practical advice with specific product recommendations when possible." ), - "model": "gpt-4o-mini" + "model": agent_model_map.get("interior_designer", model_deployment) }, { "name": "Inventory Manager", @@ -76,7 +184,7 @@ def deploy_agents(): "You are the inventory specialist at Zava. Help customers check product availability, provide stock levels, suggest alternatives if items are out of stock, and estimate restock timelines. " "Be factual and helpful about inventory status." ), - "model": "gpt-4o-mini" + "model": agent_model_map.get("inventory_agent", model_deployment) }, { "name": "Customer Loyalty Specialist", @@ -85,7 +193,7 @@ def deploy_agents(): "You are the customer loyalty and rewards specialist at Zava. Help customers understand their loyalty tier and benefits, calculate applicable discounts, learn about rewards programs, and maximize their savings. " "Be enthusiastic about helping customers save money." ), - "model": "gpt-4o-mini" + "model": agent_model_map.get("customer_loyalty", model_deployment) }, { "name": "Cart Management Assistant", @@ -94,7 +202,7 @@ def deploy_agents(): "You are the shopping cart assistant at Zava. Help customers add items to their cart, remove items, review cart contents, and proceed to checkout. " "Be efficient and confirm all cart operations clearly." ), - "model": "gpt-4o-mini" + "model": agent_model_map.get("cart_manager", model_deployment) }, { "name": "Product Management Specialist", @@ -109,7 +217,7 @@ def deploy_agents(): "Always provide accurate product information with specific names, prices, and availability. " "Use A2A protocol patterns to ensure seamless handoffs to appropriate specialists." ), - "model": "gpt-4o-mini" + "model": agent_model_map.get("product_management", model_deployment) } ] @@ -132,13 +240,13 @@ def deploy_agents(): try: print("Initializing Azure AI Project Client...") - # Use DefaultAzureCredential for authentication - credential = DefaultAzureCredential() + # Use managed identity when available (AZURE_CLIENT_ID), otherwise fall back + credential = get_default_credential() # Get required environment variables - sub_id = os.getenv("AZURE_SUBSCRIPTION_ID") - rg = os.getenv("AZURE_RESOURCE_GROUP") - project_name = os.getenv("AZURE_AI_PROJECT_NAME") + sub_id = get_env("AZURE_SUBSCRIPTION_ID") + rg = get_env("AZURE_RESOURCE_GROUP") + project_name = get_env("AZURE_AI_PROJECT_NAME") if not all([sub_id, rg, project_name]): raise ValueError("Missing required environment variables: AZURE_SUBSCRIPTION_ID, AZURE_RESOURCE_GROUP, AZURE_AI_PROJECT_NAME") @@ -163,20 +271,29 @@ def deploy_agents(): print(f"Resource Group: {rg}") print(f"Project Name: {project_name}") - # Initialize AIProjectClient with endpoint and credential - # The SDK requires the full project endpoint - project_client = AIProjectClient( - endpoint=full_project_endpoint, - credential=credential - ) + # Initialize AIProjectClient with connection string when supported (newer SDKs), + # otherwise fall back to the project endpoint. + if project_connection_string and hasattr(AIProjectClient, "from_connection_string"): + project_client = AIProjectClient.from_connection_string( + credential=credential, + conn_str=project_connection_string, + ) + else: + # The SDK requires the full project endpoint + project_client = AIProjectClient(endpoint=full_project_endpoint, credential=credential) print("Successfully initialized AIProjectClient") print("Fetching existing agents...") existing_agents = {} try: - agent_list = list(project_client.agents.list_agents()) + if hasattr(project_client.agents, "list"): + agent_list = list(project_client.agents.list()) + else: + agent_list = list(project_client.agents.list_agents()) existing_agents = {a.name: a for a in agent_list} + for a in agent_list: + existing_agents[_sanitize_agent_name(a.name)] = a print(f"Found {len(existing_agents)} existing agent(s)") except Exception as list_err: print(f"Could not list existing agents (may be first run): {list_err}") @@ -195,6 +312,7 @@ def deploy_agents(): name = cfg["name"] env_var = cfg["env_var"] instr = cfg["instructions"] + sanitized_name = _sanitize_agent_name(name) instr_hash = _hash_instructions(instr) prior_hash = prior_state.get(env_var, {}).get("hash") @@ -207,37 +325,46 @@ def deploy_agents(): continue # Idempotent logic - check if agent already exists - if name in existing_agents: - agent_obj = existing_agents[name] - agent_id = getattr(agent_obj, "id", None) or getattr(agent_obj, "agentId", f"unknown-{env_var}") + existing_agent = existing_agents.get(name) or existing_agents.get(sanitized_name) + if existing_agent: + agent_obj = existing_agent + agent_id = ( + getattr(agent_obj, "id", None) + or getattr(agent_obj, "agent_id", None) + or getattr(agent_obj, "agentId", None) + or f"unknown-{env_var}" + ) # Attempt update if instructions changed if prior_hash and prior_hash != instr_hash: - print(f"[{env_var}] Updating agent (instructions changed): {name}") + print(f"[{env_var}] Recreating agent (instructions changed): {name}") try: - # Try native update if available try: - project_client.agents.update_agent(agent_id=agent_id, instructions=instr) - statuses[env_var] = "updated" - print(f"[{env_var}] Successfully updated: {agent_id}") + if hasattr(project_client.agents, "delete_agent"): + project_client.agents.delete_agent(agent_id=agent_id) + else: + project_client.agents.delete(agent_id) except Exception: - # Fallback recreate strategy - print(f"[{env_var}] Update not supported, recreating...") - try: - project_client.agents.delete_agent(agent_id) - except Exception: - pass - new_agent = project_client.agents.create_agent( - model=cfg["model"], - name=name, - instructions=instr - ) - agent_id = new_agent.id - statuses[env_var] = "recreated" - print(f"[{env_var}] Successfully recreated: {agent_id}") + pass + + new_agent = _create_agent( + project_client, + model=_resolve_model_name(cfg["model"]), + name=sanitized_name, + instructions=instr, + ) + agent_id = ( + getattr(new_agent, "id", None) + or getattr(new_agent, "agent_id", None) + or getattr(new_agent, "agentId", None) + or agent_id + ) + statuses[env_var] = "recreated" + print(f"[{env_var}] Successfully recreated: {agent_id}") except Exception as ue: - print(f"[{env_var}] Failed to update {name}: {ue}") + print(f"[{env_var}] Failed to recreate {name}: {ue}") statuses[env_var] = "existing-no-update" + deployed_agents[env_var] = agent_id else: print(f"[{env_var}] Reusing existing agent: {name} ({agent_id})") @@ -248,12 +375,18 @@ def deploy_agents(): # Create new agent print(f"[{env_var}] Creating new agent: {name}") try: - agent = project_client.agents.create_agent( - model=cfg["model"], - name=name, - instructions=instr + agent = _create_agent( + project_client, + model=_resolve_model_name(cfg["model"]), + name=sanitized_name, + instructions=instr, + ) + agent_id = ( + getattr(agent, "id", None) + or getattr(agent, "agent_id", None) + or getattr(agent, "agentId", None) + or f"unknown-{env_var}" ) - agent_id = agent.id deployed_agents[env_var] = agent_id statuses[env_var] = "created" print(f"[{env_var}] SUCCESS - Created agent: {agent_id}") @@ -284,39 +417,35 @@ def deploy_agents(): except Exception as se: print(f"WARNING: Failed to write state file: {se}") - # Update .env with real agent IDs (early propagation) - env_path = os.path.join(os.path.dirname(__file__), '..', '..', '.env') + # Update src/.env with real agent IDs (early propagation) + # NOTE: Terraform generates ../src/.env (workspace-relative), not ../src/app/.env. + env_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..', '.env')) if os.path.exists(env_path): try: + import re + with open(env_path, 'r', encoding='utf-8') as f: content = f.read() - - # Replace each agent ID + + # Normalize Agents endpoint domains (cognitiveservices -> services.ai) + content = content.replace("cognitiveservices.azure.com", "services.ai.azure.com") + + # Replace or append each agent ID for var, aid in deployed_agents.items(): - # Use regex to replace the value after the = sign - import re pattern = rf'^{re.escape(var)}=.*$' replacement = f'{var}={aid}' - content = re.sub(pattern, replacement, content, flags=re.MULTILINE) - - # Also fix the project endpoint domain in .env - if "cognitiveservices.azure.com" in content: - print("Fixing endpoint domains in .env...") - content = content.replace( - "AZURE_AI_PROJECT_ENDPOINT=https://aif-", - "# AZURE_AI_PROJECT_ENDPOINT=https://aif-" # Comment out old - ) - # Add corrected endpoint after the Azure AI Foundry section - if "# Azure AI Foundry Configuration" in content: - content = content.replace( - "# Azure AI Foundry Configuration\n", - "# Azure AI Foundry Configuration\n# Note: Agents API uses .services.ai.azure.com domain\n" - ) - + if re.search(pattern, content, flags=re.MULTILINE): + content = re.sub(pattern, replacement, content, flags=re.MULTILINE) + else: + # Append if the key is missing (supports older .env templates) + if not content.endswith("\n"): + content += "\n" + content += f"{replacement}\n" + with open(env_path, 'w', encoding='utf-8') as f: f.write(content) - - print(f"[{env_var}] Updated .env with agent IDs: {env_path}") + + print(f"Updated .env with agent IDs: {env_path}") print("Agent IDs written:") for var, aid in deployed_agents.items(): print(f" {var}: {aid}") @@ -325,7 +454,7 @@ def deploy_agents(): import traceback traceback.print_exc() else: - print("INFO: .env file not found for agent ID propagation") + print(f"INFO: .env file not found for agent ID propagation: {env_path}") print("\n" + "=" * 70) print("DEPLOYMENT SUMMARY") diff --git a/src/app/agents/local_agent_processor.py b/src/app/agents/local_agent_processor.py index 69f5cde..a54e592 100644 --- a/src/app/agents/local_agent_processor.py +++ b/src/app/agents/local_agent_processor.py @@ -3,6 +3,9 @@ from typing import List, Dict, Any, Generator from azure.ai.inference import ChatCompletionsClient from azure.core.credentials import AzureKeyCredential +from azure.identity import DefaultAzureCredential + +from services.azure_auth import get_default_credential, get_inference_credential try: from app.agents.agents_config import AGENT_INSTRUCTIONS @@ -33,26 +36,49 @@ def __init__(self, agent_id: str, domain: str): self.domain = domain # Initialize GPT client (shared across all agents) - endpoint = os.getenv("gpt_endpoint", "") - api_key = os.getenv("gpt_api_key", "") - deployment = os.getenv("gpt_deployment", "gpt-4o-mini") - - # Convert endpoint to Foundry format if needed - if endpoint: + endpoint = ( + os.getenv("gpt_endpoint") + or os.getenv("AZURE_OPENAI_ENDPOINT") + or os.getenv("AZURE_AI_FOUNDRY_ENDPOINT") + or "" + ) + api_key = ( + os.getenv("gpt_api_key") + or os.getenv("AZURE_OPENAI_API_KEY") + or os.getenv("AZURE_AI_FOUNDRY_API_KEY") + or "" + ) + deployment = ( + os.getenv("gpt_deployment") + or os.getenv("AZURE_OPENAI_CHAT_DEPLOYMENT") + or os.getenv("AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME") + or "gpt-4o-mini" + ) + + self.use_gpt = False + self.client = None + self.model = deployment + + if endpoint and deployment: + # Convert endpoint to Foundry format if needed foundry_endpoint = endpoint.replace('.cognitiveservices.', '.services.ai.') if '.services.azure.com' in foundry_endpoint and '.services.ai.azure.com' not in foundry_endpoint: foundry_endpoint = foundry_endpoint.replace('.services.azure.com', '.services.ai.azure.com') if not foundry_endpoint.endswith('/models'): foundry_endpoint = f"{foundry_endpoint.rstrip('/')}/models" - - self.use_gpt = bool(endpoint and api_key) - if self.use_gpt: + try: - self.client = ChatCompletionsClient( - endpoint=foundry_endpoint, - credential=AzureKeyCredential(api_key) - ) - self.model = deployment + # Prefer key auth if present; otherwise use token-based auth (Managed Identity in cloud). + if api_key: + credential = AzureKeyCredential(api_key) + else: + credential = get_inference_credential( + api_key=None, + default_credential=get_default_credential(), + endpoint=foundry_endpoint, + ) + self.client = ChatCompletionsClient(endpoint=foundry_endpoint, credential=credential) + self.use_gpt = True except Exception: self.use_gpt = False diff --git a/src/app/agents/quick_verify.py b/src/app/agents/quick_verify.py index 7021663..23bd0f3 100644 --- a/src/app/agents/quick_verify.py +++ b/src/app/agents/quick_verify.py @@ -4,6 +4,7 @@ """ import os import json +from pathlib import Path from azure.ai.projects import AIProjectClient from azure.identity import DefaultAzureCredential from dotenv import load_dotenv @@ -48,12 +49,20 @@ def verify_agents(): print(f"Endpoint (full): {full_project_endpoint}") print() - # Read expected agents from state file - state_path = os.path.join(os.path.dirname(__file__), "agents_state.json") - if not os.path.exists(state_path): - print(f"ERROR: agents_state.json not found at: {state_path}") + # Read expected agents from state file. + # deploy_real_agents.py writes to terraform-infrastructure/.terraform/agents_state.json + # but older versions of this script expected a local agents_state.json. + candidates = [ + Path(__file__).resolve().parent / "agents_state.json", + Path(__file__).resolve().parents[3] / "terraform-infrastructure" / ".terraform" / "agents_state.json", + ] + state_path = next((p for p in candidates if p.exists()), None) + if not state_path: + print("ERROR: No agents state file found. Tried:") + for p in candidates: + print(f" - {p}") return False - + with open(state_path, 'r', encoding='utf-8') as f: expected_agents = json.load(f) @@ -73,7 +82,7 @@ def verify_agents(): ) print("Fetching agents from Azure AI Foundry...") - agents_list = list(project_client.agents.list_agents()) + agents_list = list(project_client.agents.list()) print(f"\nFound {len(agents_list)} agent(s) in Azure AI Foundry:") diff --git a/src/app/tools/singleAgentExample.py b/src/app/tools/singleAgentExample.py index d670212..d072ddb 100644 --- a/src/app/tools/singleAgentExample.py +++ b/src/app/tools/singleAgentExample.py @@ -2,8 +2,11 @@ import time from azure.ai.inference import ChatCompletionsClient from azure.core.credentials import AzureKeyCredential +from azure.identity import DefaultAzureCredential from dotenv import load_dotenv +from services.azure_auth import get_default_credential, get_inference_credential + # Load environment variables (Azure endpoint, deployment, keys, etc.) load_dotenv() @@ -12,7 +15,6 @@ os.getenv("gpt_endpoint") or os.getenv("AZURE_OPENAI_ENDPOINT") or os.getenv("AZURE_AI_FOUNDRY_ENDPOINT") - or os.getenv("AZURE_AI_PROJECT_ENDPOINT") ) api_key = ( os.getenv("gpt_api_key") @@ -32,14 +34,14 @@ def get_client(): """Lazily initialize and return the MSFT Foundry client""" global client if client is None: - # Graceful fallback if endpoint or key missing - if not endpoint or not api_key: + # Graceful fallback if endpoint is missing + if not endpoint: # Provide a stub-like response by using dummy client pattern # Instead of raising, log and use lightweight shim that returns explanatory text class _Shim: def complete(self, *_, **__): class _Resp: - choices = [type("_C", (), {"message": type("_M", (), {"content": "Configuration error: missing endpoint or api key. Please ensure terraform apply populated .env with gpt_endpoint and gpt_api_key."})()})] + choices = [type("_C", (), {"message": type("_M", (), {"content": "Configuration error: missing endpoint. Please ensure terraform apply populated .env with gpt_endpoint."})()})] return _Resp() return _Shim() @@ -48,7 +50,15 @@ class _Resp: foundry_endpoint = foundry_endpoint.replace('.services.azure.com', '.services.ai.azure.com') if not foundry_endpoint.endswith('/models'): foundry_endpoint = f"{foundry_endpoint.rstrip('/')}/models" - client = ChatCompletionsClient(endpoint=foundry_endpoint, credential=AzureKeyCredential(api_key)) + if api_key: + credential = AzureKeyCredential(api_key) + else: + credential = get_inference_credential( + api_key=None, + default_credential=get_default_credential(), + endpoint=foundry_endpoint, + ) + client = ChatCompletionsClient(endpoint=foundry_endpoint, credential=credential) return client def generate_response(text_input): diff --git a/src/chat_app_multi_agent.py b/src/chat_app_multi_agent.py index 1c96f47..b09a4d5 100644 --- a/src/chat_app_multi_agent.py +++ b/src/chat_app_multi_agent.py @@ -92,14 +92,22 @@ def _flatten_response_json(response_json: Dict[str, Any]) -> str: return final or '(No response)' +def _get_env_any(*names: str) -> str | None: + for name in names: + value = os.getenv(name) + if value: + return value + return None + + def get_agent_processor(domain: str): """Return a processor (remote if available, else local) for the domain.""" agent_id_map = { - "interior_design": os.getenv("interior_designer"), - "inventory": os.getenv("inventory_agent"), - "customer_loyalty": os.getenv("customer_loyalty"), - "cart_management": os.getenv("cart_manager"), - "cora": os.getenv("cora") + "interior_design": _get_env_any("interior_designer", "AGENT_INTERIOR_DESIGNER_ID"), + "inventory": _get_env_any("inventory_agent", "AGENT_INVENTORY_AGENT_ID"), + "customer_loyalty": _get_env_any("customer_loyalty", "AGENT_CUSTOMER_LOYALTY_ID"), + "cart_management": _get_env_any("cart_manager", "AGENT_CART_MANAGER_ID"), + "cora": _get_env_any("cora", "AGENT_CORA_ID") } agent_id = agent_id_map.get(domain) diff --git a/src/requirements.txt b/src/requirements.txt index d2a01c5..7142491 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -5,7 +5,7 @@ azure-cosmos==4.9.0 azure-identity==1.19.0 azure-search-documents==11.6.0 azure-ai-inference==1.0.0b6 -azure-ai-projects==1.0.0b5 +azure-ai-projects>=2.0.0b1 azure-storage-blob==12.19.0 openai==1.54.0 fastapi==0.115.0 diff --git a/src/services/azure_auth.py b/src/services/azure_auth.py new file mode 100644 index 0000000..5bc00f2 --- /dev/null +++ b/src/services/azure_auth.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Sequence + +from azure.core.credentials import AccessToken, TokenCredential +from azure.identity import DefaultAzureCredential, ManagedIdentityCredential + + +COGNITIVE_SERVICES_SCOPE = "https://cognitiveservices.azure.com/.default" +AI_FOUNDRY_SCOPE = "https://ai.azure.com/.default" + + +@dataclass(frozen=True) +class FixedScopeTokenCredential(TokenCredential): + """Wraps a TokenCredential and forces a fixed scope. + + Some Azure SDK clients derive the scope from the endpoint and can request an + unexpected audience for Azure AI Foundry model inference endpoints. + + Azure AI (Cognitive Services) expects tokens for: + https://cognitiveservices.azure.com/.default + + This wrapper ensures we always request the correct audience. + """ + + inner: TokenCredential + scope: str = COGNITIVE_SERVICES_SCOPE + + def get_token(self, *scopes: str, **kwargs) -> AccessToken: # type: ignore[override] + return self.inner.get_token(self.scope, **kwargs) + + +def get_inference_credential( + api_key: str | None, + default_credential: TokenCredential, + endpoint: str | None = None, +) -> TokenCredential: + """Return a credential appropriate for Azure AI Inference. + + - If an API key is present, callers should still use AzureKeyCredential. + (This helper only returns TokenCredential for MI/AAD flows.) + - For MI/AAD flows, return a TokenCredential that always requests the + Cognitive Services audience. + """ + + # api_key is ignored here; we only wrap token credentials. + scope = COGNITIVE_SERVICES_SCOPE + if endpoint and "services.ai.azure.com" in endpoint: + scope = AI_FOUNDRY_SCOPE + return FixedScopeTokenCredential(default_credential, scope=scope) + + +def get_default_credential() -> TokenCredential: + """Return a TokenCredential that prefers managed identity in Azure. + + Falls back to DefaultAzureCredential if managed identity is unavailable + (e.g., local runs during terraform apply). + """ + + client_id = os.getenv("AZURE_CLIENT_ID") or os.getenv("MANAGED_IDENTITY_CLIENT_ID") + if client_id: + mi = ManagedIdentityCredential(client_id=client_id) + try: + # Validate MI availability to avoid local failures. + mi.get_token(COGNITIVE_SERVICES_SCOPE) + return mi + except Exception: + return DefaultAzureCredential() + return DefaultAzureCredential() diff --git a/src/services/handoff_service.py b/src/services/handoff_service.py index 78ba259..4393e2f 100644 --- a/src/services/handoff_service.py +++ b/src/services/handoff_service.py @@ -8,8 +8,11 @@ from pydantic import BaseModel from azure.ai.inference import ChatCompletionsClient from azure.core.credentials import AzureKeyCredential +from azure.identity import DefaultAzureCredential from dotenv import load_dotenv +from services.azure_auth import get_default_credential, get_inference_credential + load_dotenv() @@ -34,12 +37,25 @@ class HandoffService: def __init__(self): """Initialize the handoff service with GPT client""" - endpoint = os.getenv("gpt_endpoint") - api_key = os.getenv("gpt_api_key") - deployment = os.getenv("gpt_deployment") - - if not all([endpoint, api_key, deployment]): - raise ValueError("Missing GPT configuration in environment") + endpoint = ( + os.getenv("gpt_endpoint") + or os.getenv("AZURE_OPENAI_ENDPOINT") + or os.getenv("AZURE_AI_FOUNDRY_ENDPOINT") + ) + api_key = ( + os.getenv("gpt_api_key") + or os.getenv("AZURE_OPENAI_API_KEY") + or os.getenv("AZURE_AI_FOUNDRY_API_KEY") + ) + deployment = ( + os.getenv("gpt_deployment") + or os.getenv("AZURE_OPENAI_CHAT_DEPLOYMENT") + or os.getenv("AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME") + ) + + # Endpoint + deployment are required. API key is optional when using Managed Identity/AAD. + if not all([endpoint, deployment]): + raise ValueError("Missing GPT configuration in environment (endpoint and deployment required)") # Convert endpoint to Azure AI Foundry format foundry_endpoint = endpoint.replace('.cognitiveservices.', '.services.ai.') @@ -48,10 +64,15 @@ def __init__(self): if not foundry_endpoint.endswith('/models'): foundry_endpoint = f"{foundry_endpoint.rstrip('/')}/models" - self.client = ChatCompletionsClient( - endpoint=foundry_endpoint, - credential=AzureKeyCredential(api_key) - ) + if api_key: + credential = AzureKeyCredential(api_key) + else: + credential = get_inference_credential( + api_key=None, + default_credential=get_default_credential(), + endpoint=foundry_endpoint, + ) + self.client = ChatCompletionsClient(endpoint=foundry_endpoint, credential=credential) self.deployment = deployment def classify_intent( diff --git a/terraform-infrastructure/README.md b/terraform-infrastructure/README.md index d4eda35..1459b9f 100644 --- a/terraform-infrastructure/README.md +++ b/terraform-infrastructure/README.md @@ -119,7 +119,7 @@ graph TD;
- Total views -

Refresh Date: 2026-01-12

+ Total views +

Refresh Date: 2026-01-29

diff --git a/terraform-infrastructure/main.tf b/terraform-infrastructure/main.tf index 106e1bc..014dd84 100644 --- a/terraform-infrastructure/main.tf +++ b/terraform-infrastructure/main.tf @@ -41,6 +41,9 @@ locals { ) : fileexists("../src/${f}") ? filesha256("../src/${f}") : "" ])) product_catalog_hash = fileexists("../src/data/updated_product_catalog(in).csv") ? filesha256("../src/data/updated_product_catalog(in).csv") : "missing" + + deploy_to_appservice = var.deployment_target == "appservice" + deploy_to_container_apps = var.deployment_target == "containerapps" } resource "azurerm_cosmosdb_account" "cosmos" { @@ -124,6 +127,19 @@ resource "azapi_resource" "ai_foundry" { }) } +# Ensure allowProjectManagement is applied (some older API versions ignore it during create). +# This PATCH uses a newer api-version that supports the property and updates the existing account in place. +resource "azapi_update_resource" "ai_foundry_enable_project_mgmt" { + type = "Microsoft.CognitiveServices/accounts@2025-06-01" + resource_id = azapi_resource.ai_foundry.id + + body = jsonencode({ + properties = { + allowProjectManagement = true + } + }) +} + resource "azapi_resource" "ai_project" { type = "Microsoft.CognitiveServices/accounts/projects@2025-06-01" name = local.ai_project_name @@ -132,7 +148,7 @@ resource "azapi_resource" "ai_project" { schema_validation_enabled = false identity { type = "SystemAssigned" } body = jsonencode({ properties = {} }) - depends_on = [azapi_resource.ai_foundry] + depends_on = [azapi_update_resource.ai_foundry_enable_project_mgmt] } # === Real Multi-Agent Creation (ochartarotr) === @@ -276,7 +292,274 @@ resource "azurerm_container_registry" "acr" { ] } +# Container Apps environment (alternative to App Service) +resource "azurerm_container_app_environment" "app_env" { + count = local.deploy_to_container_apps ? 1 : 0 + name = "${var.name_prefix}-${local.suffix}-cae" + location = var.location + resource_group_name = azurerm_resource_group.rg.name + log_analytics_workspace_id = azurerm_log_analytics_workspace.law.id +} + +resource "azurerm_user_assigned_identity" "containerapp_identity" { + count = local.deploy_to_container_apps ? 1 : 0 + name = "${var.name_prefix}-${local.suffix}-ca-id" + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location +} + +# Container Apps deployment (uses ACR image) +resource "azurerm_container_app" "app" { + count = local.deploy_to_container_apps ? 1 : 0 + name = "${var.name_prefix}-${local.suffix}-ca" + resource_group_name = azurerm_resource_group.rg.name + container_app_environment_id = azurerm_container_app_environment.app_env[0].id + revision_mode = "Single" + + identity { + type = "UserAssigned" + identity_ids = [azurerm_user_assigned_identity.containerapp_identity[0].id] + } + + template { + container { + name = "zava-chat-app" + image = "${azurerm_container_registry.acr.login_server}/zava-chat-app:latest" + cpu = 1 + memory = "2Gi" + + env { + name = "WEBSITES_PORT" + value = "8000" + } + + env { + name = "USE_MULTI_AGENT" + value = var.enable_multi_agent ? "true" : "false" + } + + env { + name = "AZURE_AI_FOUNDRY_ENDPOINT" + value = "https://${local.ai_foundry_name}.cognitiveservices.azure.com/" + } + env { + name = "AZURE_AI_PROJECT_NAME" + value = local.ai_project_name + } + env { + name = "AZURE_AI_PROJECT_ENDPOINT" + value = "https://${local.ai_foundry_name}.services.ai.azure.com/api/projects/${local.ai_project_name}" + } + env { + name = "AZURE_AI_AGENT_ENDPOINT" + value = "https://${local.ai_foundry_name}.services.ai.azure.com/api/projects/${local.ai_project_name}" + } + env { + name = "AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME" + value = var.chat_model_deployment + } + + env { + name = "AZURE_OPENAI_CHAT_DEPLOYMENT" + value = var.chat_model_deployment + } + env { + name = "AZURE_OPENAI_EMBEDDING_DEPLOYMENT" + value = "text-embedding-3-small" + } + env { + name = "AZURE_OPENAI_ENDPOINT" + value = "https://${local.ai_foundry_name}.cognitiveservices.azure.com/" + } + env { + name = "AZURE_OPENAI_API_VERSION" + value = "2024-02-01" + } + + env { + name = "gpt_endpoint" + value = "https://${local.ai_foundry_name}.cognitiveservices.azure.com/" + } + env { + name = "gpt_deployment" + value = var.chat_model_deployment + } + env { + name = "gpt_api_version" + value = "2024-12-01-preview" + } + + env { + name = "COSMOS_DB_ENDPOINT" + value = azurerm_cosmosdb_account.cosmos.endpoint + } + env { + name = "COSMOS_DB_NAME" + value = local.cosmos_db_name + } + env { + name = "COSMOS_DB_CONTAINER_NAME" + value = "product_catalog" + } + env { + name = "COSMOS_SKIP_IF_EXISTS" + value = "true" + } + + env { + name = "SEARCH_SERVICE_ENDPOINT" + value = "https://${local.search_service_name}.search.windows.net" + } + env { + name = "SEARCH_INDEX_NAME" + value = "products-index" + } + + env { + name = "STORAGE_ACCOUNT_NAME" + value = local.storage_account + } + + env { + name = "AZURE_SUBSCRIPTION_ID" + value = data.azurerm_client_config.current.subscription_id + } + env { + name = "AZURE_RESOURCE_GROUP" + value = azurerm_resource_group.rg.name + } + env { + name = "AZURE_LOCATION" + value = var.location + } + + env { + name = "AZURE_CLIENT_ID" + value = azurerm_user_assigned_identity.containerapp_identity[0].client_id + } + + env { + name = "CUSTOMER_ID" + value = "CUST001" + } + + # Secrets (Key Vault references) + env { + name = "SEARCH_SERVICE_KEY" + secret_name = "search-service-key" + } + env { + name = "STORAGE_CONNECTION_STRING" + secret_name = "storage-connection-string" + } + + # Cosmos auth: use AAD when local auth disabled + dynamic "env" { + for_each = var.enable_cosmos_local_auth ? [1] : [] + content { + name = "COSMOS_DB_KEY" + secret_name = "cosmos-primary-key" + } + } + dynamic "env" { + for_each = var.enable_cosmos_local_auth ? [] : [1] + content { + name = "COSMOS_DB_KEY" + value = "AAD_AUTH" + } + } + + # Agent IDs (non-secret) - set explicitly to avoid secret set drift + env { + name = "AGENT_CORA_ID" + value = data.external.agents_state.result["agent_cora_id"] + } + env { + name = "AGENT_INTERIOR_DESIGNER_ID" + value = data.external.agents_state.result["agent_interior_designer_id"] + } + env { + name = "AGENT_INVENTORY_AGENT_ID" + value = data.external.agents_state.result["agent_inventory_agent_id"] + } + env { + name = "AGENT_CUSTOMER_LOYALTY_ID" + value = data.external.agents_state.result["agent_customer_loyalty_id"] + } + env { + name = "AGENT_CART_MANAGER_ID" + value = data.external.agents_state.result["agent_cart_manager_id"] + } + env { + name = "cora" + value = data.external.agents_state.result["agent_cora_id"] + } + env { + name = "interior_designer" + value = data.external.agents_state.result["agent_interior_designer_id"] + } + env { + name = "inventory_agent" + value = data.external.agents_state.result["agent_inventory_agent_id"] + } + env { + name = "customer_loyalty" + value = data.external.agents_state.result["agent_customer_loyalty_id"] + } + env { + name = "cart_manager" + value = data.external.agents_state.result["agent_cart_manager_id"] + } + } + } + + ingress { + external_enabled = true + target_port = 8000 + transport = "auto" + traffic_weight { + percentage = 100 + latest_revision = true + } + } + + registry { + server = azurerm_container_registry.acr.login_server + identity = azurerm_user_assigned_identity.containerapp_identity[0].id + } + + secret { + name = "search-service-key" + key_vault_secret_id = "${azurerm_key_vault.kv.vault_uri}secrets/search-admin-key" + identity = azurerm_user_assigned_identity.containerapp_identity[0].id + } + secret { + name = "storage-connection-string" + key_vault_secret_id = "${azurerm_key_vault.kv.vault_uri}secrets/storage-connection-string" + identity = azurerm_user_assigned_identity.containerapp_identity[0].id + } + dynamic "secret" { + for_each = var.enable_cosmos_local_auth ? [1] : [] + content { + name = "cosmos-primary-key" + key_vault_secret_id = "${azurerm_key_vault.kv.vault_uri}secrets/cosmos-primary-key" + identity = azurerm_user_assigned_identity.containerapp_identity[0].id + } + } + + depends_on = [ + azurerm_container_registry.acr, + azurerm_user_assigned_identity.containerapp_identity, + azurerm_role_assignment.kv_secrets_user_containerapp, + azurerm_role_assignment.containerapp_acr_pull, + null_resource.docker_image_build, + null_resource.set_kv_secrets, + null_resource.set_agent_kv_secrets + ] +} + resource "azurerm_container_registry_webhook" "webhook" { + count = local.deploy_to_appservice ? 1 : 0 name = "${local.registry_name}webhook" resource_group_name = azurerm_resource_group.rg.name registry_name = azurerm_container_registry.acr.name @@ -408,18 +691,20 @@ resource "null_resource" "docker_image_build" { } resource "azurerm_service_plan" "appserviceplan" { + count = local.deploy_to_appservice ? 1 : 0 name = local.app_service_plan resource_group_name = azurerm_resource_group.rg.name location = var.location os_type = "Linux" - sku_name = "B1" # Basic tier to avoid Standard VM quota issues + sku_name = var.app_service_sku } resource "azurerm_linux_web_app" "app" { + count = local.deploy_to_appservice ? 1 : 0 name = local.web_app_name resource_group_name = azurerm_resource_group.rg.name location = var.location - service_plan_id = azurerm_service_plan.appserviceplan.id + service_plan_id = azurerm_service_plan.appserviceplan[0].id https_only = true identity { @@ -449,19 +734,18 @@ resource "azurerm_linux_web_app" "app" { # GPT Configuration (using managed identity) gpt_endpoint = "https://${local.ai_foundry_name}.cognitiveservices.azure.com/" - gpt_deployment = "gpt-4o-mini" + gpt_deployment = var.chat_model_deployment gpt_api_version = "2024-12-01-preview" # MSFT Foundry Configuration (using managed identity) AZURE_AI_FOUNDRY_ENDPOINT = "https://${local.ai_foundry_name}.cognitiveservices.azure.com/" AZURE_AI_PROJECT_NAME = local.ai_project_name - AZURE_AI_PROJECT_ENDPOINT = "https://${local.ai_foundry_name}.cognitiveservices.azure.com/" - AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME = "gpt-4o-mini" + AZURE_AI_PROJECT_ENDPOINT = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault.kv.vault_uri}secrets/agent-endpoint)" + AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME = var.chat_model_deployment # MSFT Foundry OpenAI Configuration (using managed identity) - AZURE_OPENAI_CHAT_DEPLOYMENT = "gpt-4o-mini" + AZURE_OPENAI_CHAT_DEPLOYMENT = var.chat_model_deployment AZURE_OPENAI_EMBEDDING_DEPLOYMENT = "text-embedding-3-small" - AZURE_OPENAI_IMAGE_DEPLOYMENT = "dall-e-3" AZURE_OPENAI_ENDPOINT = "https://${local.ai_foundry_name}.cognitiveservices.azure.com/" AZURE_OPENAI_API_VERSION = "2024-02-01" @@ -478,6 +762,11 @@ resource "azurerm_linux_web_app" "app" { AGENT_INVENTORY_AGENT_ID = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault.kv.vault_uri}secrets/agent-inventory-agent-id)" AGENT_CUSTOMER_LOYALTY_ID = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault.kv.vault_uri}secrets/agent-customer-loyalty-id)" AGENT_CART_MANAGER_ID = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault.kv.vault_uri}secrets/agent-cart-manager-id)" + cora = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault.kv.vault_uri}secrets/agent-cora-id)" + interior_designer = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault.kv.vault_uri}secrets/agent-interior-designer-id)" + inventory_agent = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault.kv.vault_uri}secrets/agent-inventory-agent-id)" + customer_loyalty = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault.kv.vault_uri}secrets/agent-customer-loyalty-id)" + cart_manager = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault.kv.vault_uri}secrets/agent-cart-manager-id)" CUSTOMER_ID = "CUST001" } @@ -490,9 +779,10 @@ resource "azurerm_linux_web_app" "app" { # Grant AcrPull role to Web App managed identity so it can pull private images without admin credentials resource "azurerm_role_assignment" "webapp_acr_pull" { + count = local.deploy_to_appservice ? 1 : 0 scope = azurerm_container_registry.acr.id role_definition_name = "AcrPull" - principal_id = azurerm_linux_web_app.app.identity[0].principal_id + principal_id = azurerm_linux_web_app.app[0].identity[0].principal_id depends_on = [ azurerm_linux_web_app.app, azurerm_container_registry.acr @@ -507,45 +797,75 @@ resource "azurerm_key_vault" "kv" { tenant_id = data.azurerm_client_config.current.tenant_id sku_name = "standard" soft_delete_retention_days = 7 - purge_protection_enabled = false - enable_rbac_authorization = false + purge_protection_enabled = true + enable_rbac_authorization = true + public_network_access_enabled = true - access_policy { - tenant_id = data.azurerm_client_config.current.tenant_id - object_id = local.principal_id - secret_permissions = ["Get", "List", "Set", "Delete", "Purge", "Recover"] + network_acls { + default_action = "Allow" + bypass = "AzureServices" } tags = { purpose = "multi-agent-ai-secrets" } } +# Ensure Key Vault public access is enabled before data-plane secret reads +resource "null_resource" "enable_kv_public_access" { + depends_on = [azurerm_key_vault.kv] + + provisioner "local-exec" { + command = <<-EOT + Write-Host "Ensuring Key Vault public access is enabled..." + az keyvault update ` + --name "${azurerm_key_vault.kv.name}" ` + --resource-group "${azurerm_resource_group.rg.name}" ` + --public-network-access Enabled ` + --default-action Allow ` + --bypass AzureServices | Out-Null + EOT + interpreter = ["PowerShell", "-Command"] + working_dir = path.module + } + + triggers = { + always_run = timestamp() + } +} + # Data source to retrieve the web app identity after it's created/updated data "azurerm_linux_web_app" "app_identity" { - name = azurerm_linux_web_app.app.name + count = local.deploy_to_appservice ? 1 : 0 + name = azurerm_linux_web_app.app[0].name resource_group_name = azurerm_resource_group.rg.name depends_on = [azurerm_linux_web_app.app] } -# Access policy for Web App managed identity to read secrets -resource "azurerm_key_vault_access_policy" "app_policy" { - key_vault_id = azurerm_key_vault.kv.id - tenant_id = data.azurerm_client_config.current.tenant_id - object_id = data.azurerm_linux_web_app.app_identity.identity[0].principal_id - secret_permissions = ["Get"] - depends_on = [azurerm_linux_web_app.app] +# RBAC role assignments for Key Vault access +resource "azurerm_role_assignment" "kv_secrets_officer_user" { + scope = azurerm_key_vault.kv.id + role_definition_name = "Key Vault Secrets Officer" + principal_id = local.principal_id } -# Populate Key Vault secrets (Cosmos key, Search key, Storage connection) -# Key Vault Secrets as Terraform resources (provides version for references) -# Note: AI Foundry now uses managed identity instead of keys +resource "azurerm_role_assignment" "kv_secrets_user_containerapp" { + count = local.deploy_to_container_apps ? 1 : 0 + scope = azurerm_key_vault.kv.id + role_definition_name = "Key Vault Secrets User" + principal_id = azurerm_user_assigned_identity.containerapp_identity[0].principal_id + depends_on = [azurerm_user_assigned_identity.containerapp_identity] +} -resource "azurerm_key_vault_secret" "search_admin_key" { - name = "search-admin-key" - value = jsondecode(data.azapi_resource_action.search_admin_keys[0].output).primaryKey - key_vault_id = azurerm_key_vault.kv.id - depends_on = [azurerm_key_vault.kv] +resource "azurerm_role_assignment" "kv_secrets_user_webapp" { + count = local.deploy_to_appservice ? 1 : 0 + scope = azurerm_key_vault.kv.id + role_definition_name = "Key Vault Secrets User" + principal_id = data.azurerm_linux_web_app.app_identity[0].identity[0].principal_id + depends_on = [data.azurerm_linux_web_app.app_identity] } +# Populate Key Vault secrets via CLI to avoid data-plane read failures +# Note: AI Foundry now uses managed identity instead of keys + # Fetch storage keys unconditionally data "azapi_resource_action" "storage_keys_unconditional" { type = "Microsoft.Storage/storageAccounts@2023-01-01" @@ -556,19 +876,34 @@ data "azapi_resource_action" "storage_keys_unconditional" { depends_on = [azapi_resource.storage] } -resource "azurerm_key_vault_secret" "storage_connection_string" { - name = "storage-connection-string" - value = "DefaultEndpointsProtocol=https;AccountName=${local.storage_account};AccountKey=${jsondecode(data.azapi_resource_action.storage_keys_unconditional.output).keys[0].value};EndpointSuffix=core.windows.net" - key_vault_id = azurerm_key_vault.kv.id - depends_on = [azurerm_key_vault.kv, data.azapi_resource_action.storage_keys_unconditional] -} +resource "null_resource" "set_kv_secrets" { + depends_on = [ + azurerm_key_vault.kv, + azurerm_role_assignment.kv_secrets_officer_user, + null_resource.enable_kv_public_access, + data.azapi_resource_action.search_admin_keys, + data.azapi_resource_action.storage_keys_unconditional, + data.azapi_resource_action.cosmos_keys + ] + + provisioner "local-exec" { + command = <<-EOT + $kv = "${azurerm_key_vault.kv.name}" + Write-Host "Setting Key Vault secrets (search/storage/cosmos/agent-endpoint)..." + az keyvault secret set --vault-name $kv --name "search-admin-key" --value "${jsondecode(data.azapi_resource_action.search_admin_keys[0].output).primaryKey}" | Out-Null + az keyvault secret set --vault-name $kv --name "storage-connection-string" --value "DefaultEndpointsProtocol=https;AccountName=${local.storage_account};AccountKey=${jsondecode(data.azapi_resource_action.storage_keys_unconditional.output).keys[0].value};EndpointSuffix=core.windows.net" | Out-Null + if (${var.enable_cosmos_local_auth ? "$true" : "$false"}) { + az keyvault secret set --vault-name $kv --name "cosmos-primary-key" --value "${jsondecode(data.azapi_resource_action.cosmos_keys[0].output).primaryMasterKey}" | Out-Null + } + az keyvault secret set --vault-name $kv --name "agent-endpoint" --value "https://${local.ai_foundry_name}.services.ai.azure.com/api/projects/${local.ai_project_name}" | Out-Null + EOT + interpreter = ["PowerShell", "-Command"] + working_dir = path.module + } -resource "azurerm_key_vault_secret" "cosmos_primary_key" { - count = var.enable_cosmos_local_auth ? 1 : 0 - name = "cosmos-primary-key" - value = jsondecode(data.azapi_resource_action.cosmos_keys[0].output).primaryMasterKey - key_vault_id = azurerm_key_vault.kv.id - depends_on = [azurerm_key_vault.kv] + triggers = { + always_run = timestamp() + } } # External data source for agents state @@ -577,56 +912,41 @@ data "external" "agents_state" { depends_on = [null_resource.deploy_multi_agents] } -# Store agent IDs in Key Vault -resource "azurerm_key_vault_secret" "agent_cora_id" { - name = "agent-cora-id" - value = data.external.agents_state.result["agent_cora_id"] - key_vault_id = azurerm_key_vault.kv.id - depends_on = [azurerm_key_vault.kv, data.external.agents_state] -} - -resource "azurerm_key_vault_secret" "agent_interior_designer_id" { - name = "agent-interior-designer-id" - value = data.external.agents_state.result["agent_interior_designer_id"] - key_vault_id = azurerm_key_vault.kv.id - depends_on = [azurerm_key_vault.kv, data.external.agents_state] -} - -resource "azurerm_key_vault_secret" "agent_inventory_agent_id" { - name = "agent-inventory-agent-id" - value = data.external.agents_state.result["agent_inventory_agent_id"] - key_vault_id = azurerm_key_vault.kv.id - depends_on = [azurerm_key_vault.kv, data.external.agents_state] -} - -resource "azurerm_key_vault_secret" "agent_customer_loyalty_id" { - name = "agent-customer-loyalty-id" - value = data.external.agents_state.result["agent_customer_loyalty_id"] - key_vault_id = azurerm_key_vault.kv.id - depends_on = [azurerm_key_vault.kv, data.external.agents_state] -} +# Store agent IDs in Key Vault via CLI +resource "null_resource" "set_agent_kv_secrets" { + depends_on = [ + azurerm_key_vault.kv, + azurerm_role_assignment.kv_secrets_officer_user, + null_resource.enable_kv_public_access, + data.external.agents_state + ] -resource "azurerm_key_vault_secret" "agent_cart_manager_id" { - name = "agent-cart-manager-id" - value = data.external.agents_state.result["agent_cart_manager_id"] - key_vault_id = azurerm_key_vault.kv.id - depends_on = [azurerm_key_vault.kv, data.external.agents_state] -} + provisioner "local-exec" { + command = <<-EOT + $kv = "${azurerm_key_vault.kv.name}" + Write-Host "Setting Key Vault agent ID secrets..." + az keyvault secret set --vault-name $kv --name "agent-cora-id" --value "${data.external.agents_state.result["agent_cora_id"]}" | Out-Null + az keyvault secret set --vault-name $kv --name "agent-interior-designer-id" --value "${data.external.agents_state.result["agent_interior_designer_id"]}" | Out-Null + az keyvault secret set --vault-name $kv --name "agent-inventory-agent-id" --value "${data.external.agents_state.result["agent_inventory_agent_id"]}" | Out-Null + az keyvault secret set --vault-name $kv --name "agent-customer-loyalty-id" --value "${data.external.agents_state.result["agent_customer_loyalty_id"]}" | Out-Null + az keyvault secret set --vault-name $kv --name "agent-cart-manager-id" --value "${data.external.agents_state.result["agent_cart_manager_id"]}" | Out-Null + EOT + interpreter = ["PowerShell", "-Command"] + working_dir = path.module + } -# Store agent endpoint in Key Vault (transformed to services.ai.azure.com) -resource "azurerm_key_vault_secret" "agent_endpoint" { - name = "agent-endpoint" - value = "https://${local.ai_foundry_name}.services.ai.azure.com/models" - key_vault_id = azurerm_key_vault.kv.id - depends_on = [azurerm_key_vault.kv] + triggers = { + always_run = timestamp() + } } # App Service Plan autoscale resource "azurerm_monitor_autoscale_setting" "appservice_autoscale" { + count = local.deploy_to_appservice ? 1 : 0 name = "${var.name_prefix}-${local.suffix}-asp-autoscale" resource_group_name = azurerm_resource_group.rg.name location = var.location - target_resource_id = azurerm_service_plan.appserviceplan.id + target_resource_id = azurerm_service_plan.appserviceplan[0].id profile { name = "default" @@ -639,7 +959,7 @@ resource "azurerm_monitor_autoscale_setting" "appservice_autoscale" { rule { metric_trigger { metric_name = "CpuPercentage" - metric_resource_id = azurerm_service_plan.appserviceplan.id + metric_resource_id = azurerm_service_plan.appserviceplan[0].id time_grain = "PT1M" statistic = "Average" time_window = "PT5M" @@ -658,7 +978,7 @@ resource "azurerm_monitor_autoscale_setting" "appservice_autoscale" { rule { metric_trigger { metric_name = "CpuPercentage" - metric_resource_id = azurerm_service_plan.appserviceplan.id + metric_resource_id = azurerm_service_plan.appserviceplan[0].id time_grain = "PT1M" statistic = "Average" time_window = "PT10M" @@ -688,9 +1008,10 @@ resource "azurerm_monitor_autoscale_setting" "appservice_autoscale" { # Alerts: App Service 5xx & CPU, Cosmos 429 throttles resource "azurerm_monitor_metric_alert" "app_5xx" { + count = local.deploy_to_appservice ? 1 : 0 name = "${var.name_prefix}-${local.suffix}-app-5xx-alert" resource_group_name = azurerm_resource_group.rg.name - scopes = [azurerm_linux_web_app.app.id] + scopes = [azurerm_linux_web_app.app[0].id] description = "Alert on high 5xx responses" severity = 2 frequency = "PT5M" @@ -705,9 +1026,10 @@ resource "azurerm_monitor_metric_alert" "app_5xx" { } resource "azurerm_monitor_metric_alert" "app_cpu" { + count = local.deploy_to_appservice ? 1 : 0 name = "${var.name_prefix}-${local.suffix}-app-cpu-alert" resource_group_name = azurerm_resource_group.rg.name - scopes = [azurerm_service_plan.appserviceplan.id] + scopes = [azurerm_service_plan.appserviceplan[0].id] description = "Alert on high CPU" severity = 3 frequency = "PT5M" @@ -740,6 +1062,7 @@ resource "azurerm_monitor_metric_alert" "cosmos_throttle" { # Portal Dashboard aggregating key metrics resource "azurerm_portal_dashboard" "observability" { + count = local.deploy_to_appservice ? 1 : 0 name = "${var.name_prefix}-${local.suffix}-dashboard" resource_group_name = azurerm_resource_group.rg.name location = var.location @@ -755,7 +1078,7 @@ resource "azurerm_portal_dashboard" "observability" { metadata = { inputs = [ { name = "resourceType", value = "microsoft.web/sites" }, - { name = "resource", value = azurerm_linux_web_app.app.id }, + { name = "resource", value = azurerm_linux_web_app.app[0].id }, { name = "chartSettings", value = jsonencode({ version = "Workspace" }) } ] type = "Extension/HubsExtension/PartType/MonitorChartPart" @@ -764,7 +1087,7 @@ resource "azurerm_portal_dashboard" "observability" { version = "1.0.0" chart = { title = "App Service Requests" - metrics = [{ resourceMetadata = { id = azurerm_linux_web_app.app.id }, name = "Requests", aggregationType = "Total" }] + metrics = [{ resourceMetadata = { id = azurerm_linux_web_app.app[0].id }, name = "Requests", aggregationType = "Total" }] timespan = { duration = "PT1H" } visualization = { chartType = "Line" } } @@ -777,7 +1100,7 @@ resource "azurerm_portal_dashboard" "observability" { metadata = { inputs = [ { name = "resourceType", value = "microsoft.web/serverfarms" }, - { name = "resource", value = azurerm_service_plan.appserviceplan.id } + { name = "resource", value = azurerm_service_plan.appserviceplan[0].id } ] type = "Extension/HubsExtension/PartType/MonitorChartPart" settings = { @@ -785,7 +1108,7 @@ resource "azurerm_portal_dashboard" "observability" { version = "1.0.0" chart = { title = "CPU Percentage" - metrics = [{ resourceMetadata = { id = azurerm_service_plan.appserviceplan.id }, name = "CpuPercentage", aggregationType = "Average" }] + metrics = [{ resourceMetadata = { id = azurerm_service_plan.appserviceplan[0].id }, name = "CpuPercentage", aggregationType = "Average" }] timespan = { duration = "PT1H" } } } @@ -862,6 +1185,22 @@ resource "azapi_resource" "cosmos_user_data_contributor" { }) } +# Assign Cosmos DB Data Contributor role to Container App managed identity +resource "azapi_resource" "containerapp_cosmos_data_contributor" { + count = local.deploy_to_container_apps ? 1 : 0 + type = "Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2023-04-15" + name = md5("${azurerm_cosmosdb_account.cosmos.id}-${azurerm_user_assigned_identity.containerapp_identity[0].principal_id}-${local.cosmos_db_data_contributor_role_id}") + parent_id = azurerm_cosmosdb_account.cosmos.id + body = jsonencode({ + properties = { + roleDefinitionId = "${azurerm_cosmosdb_account.cosmos.id}/sqlRoleDefinitions/${local.cosmos_db_data_contributor_role_id}" + principalId = azurerm_user_assigned_identity.containerapp_identity[0].principal_id + scope = azurerm_cosmosdb_account.cosmos.id + } + }) + depends_on = [azurerm_container_app.app] +} + # Role assignments for Search managed identity resource "azurerm_role_assignment" "search_cosmos_account_reader" { scope = azurerm_cosmosdb_account.cosmos.id @@ -920,21 +1259,51 @@ resource "azurerm_role_assignment" "search_project_contributor" { # Role assignments for Web App managed identity to access AI Foundry resource "azurerm_role_assignment" "webapp_foundry_openai_user" { + count = local.deploy_to_appservice ? 1 : 0 scope = azapi_resource.ai_foundry.id role_definition_id = "/subscriptions/${data.azurerm_client_config.current.subscription_id}/providers/Microsoft.Authorization/roleDefinitions/${local.cognitive_openai_user_role_id}" - principal_id = data.azurerm_linux_web_app.app_identity.identity[0].principal_id + principal_id = data.azurerm_linux_web_app.app_identity[0].identity[0].principal_id principal_type = "ServicePrincipal" depends_on = [azurerm_linux_web_app.app] } resource "azurerm_role_assignment" "webapp_project_openai_user" { + count = local.deploy_to_appservice ? 1 : 0 scope = azapi_resource.ai_project.id role_definition_id = "/subscriptions/${data.azurerm_client_config.current.subscription_id}/providers/Microsoft.Authorization/roleDefinitions/${local.cognitive_openai_user_role_id}" - principal_id = data.azurerm_linux_web_app.app_identity.identity[0].principal_id + principal_id = data.azurerm_linux_web_app.app_identity[0].identity[0].principal_id principal_type = "ServicePrincipal" depends_on = [azurerm_linux_web_app.app] } +# Role assignments for Container App managed identity to access AI Foundry +resource "azurerm_role_assignment" "containerapp_foundry_openai_user" { + count = local.deploy_to_container_apps ? 1 : 0 + scope = azapi_resource.ai_foundry.id + role_definition_id = "/subscriptions/${data.azurerm_client_config.current.subscription_id}/providers/Microsoft.Authorization/roleDefinitions/${local.cognitive_openai_user_role_id}" + principal_id = azurerm_user_assigned_identity.containerapp_identity[0].principal_id + principal_type = "ServicePrincipal" + depends_on = [azurerm_container_app.app] +} + +resource "azurerm_role_assignment" "containerapp_project_openai_user" { + count = local.deploy_to_container_apps ? 1 : 0 + scope = azapi_resource.ai_project.id + role_definition_id = "/subscriptions/${data.azurerm_client_config.current.subscription_id}/providers/Microsoft.Authorization/roleDefinitions/${local.cognitive_openai_user_role_id}" + principal_id = azurerm_user_assigned_identity.containerapp_identity[0].principal_id + principal_type = "ServicePrincipal" + depends_on = [azurerm_container_app.app] +} + +# Grant AcrPull role to Container App managed identity for ACR pulls +resource "azurerm_role_assignment" "containerapp_acr_pull" { + count = local.deploy_to_container_apps ? 1 : 0 + scope = azurerm_container_registry.acr.id + role_definition_name = "AcrPull" + principal_id = azurerm_user_assigned_identity.containerapp_identity[0].principal_id + depends_on = [azurerm_user_assigned_identity.containerapp_identity, azurerm_container_registry.acr] +} + # Storage account permissions for MSFT Foundry project resource "azurerm_role_assignment" "storage_blob_data_contributor_user" { scope = azapi_resource.storage.id @@ -970,23 +1339,43 @@ resource "null_resource" "ai_model_deployments" { Start-Sleep -Seconds 30 try { - # Create gpt-4o-mini deployment - Write-Host "Creating gpt-4o-mini deployment..." + # Create model-router deployment (single chat deployment) + Write-Host "Creating model-router deployment..." az cognitiveservices account deployment create ` --resource-group "${azurerm_resource_group.rg.name}" ` --name "${local.ai_foundry_name}" ` - --deployment-name "gpt-4o-mini" ` - --model-name "gpt-4o-mini" ` - --model-version "2024-07-18" ` + --deployment-name "model-router" ` + --model-name "model-router" ` + --model-version "2025-11-18" ` --model-format "OpenAI" ` --sku-capacity 10 ` --sku-name "GlobalStandard" if ($LASTEXITCODE -eq 0) { - Write-Host "gpt-4o-mini deployment created successfully" + Write-Host "model-router deployment created successfully" } else { - Write-Host "gpt-4o-mini deployment may already exist or failed to create" - } # Create text-embedding-3-small deployment + Write-Host "model-router deployment may already exist or failed to create" + } + + # Create gpt-4o-mini deployment (fallback chat model) + Write-Host "Creating gpt-4o-mini deployment..." + az cognitiveservices account deployment create ` + --resource-group "${azurerm_resource_group.rg.name}" ` + --name "${local.ai_foundry_name}" ` + --deployment-name "gpt-4o-mini" ` + --model-name "gpt-4o-mini" ` + --model-version "2024-07-18" ` + --model-format "OpenAI" ` + --sku-capacity 10 ` + --sku-name "GlobalStandard" + + if ($LASTEXITCODE -eq 0) { + Write-Host "gpt-4o-mini deployment created successfully" + } else { + Write-Host "gpt-4o-mini deployment may already exist or failed to create" + } + + # Create text-embedding-3-small deployment Write-Host "Creating text-embedding-3-small deployment..." az cognitiveservices account deployment create ` --resource-group "${azurerm_resource_group.rg.name}" ` @@ -1004,56 +1393,6 @@ resource "null_resource" "ai_model_deployments" { Write-Host "text-embedding-3-small deployment may already exist or failed to create" } - # Create dall-e-3 deployment for image generation - Write-Host "Creating dall-e-3 deployment..." - try { - az cognitiveservices account deployment create ` - --resource-group "${azurerm_resource_group.rg.name}" ` - --name "${local.ai_foundry_name}" ` - --deployment-name "dall-e-3" ` - --model-name "dall-e-3" ` - --model-version "3.0" ` - --model-format "OpenAI" ` - --sku-capacity 1 ` - --sku-name "Standard" - - if ($LASTEXITCODE -eq 0) { - Write-Host "dall-e-3 deployment created successfully" - $dalleAvailable = $true - } else { - Write-Host "dall-e-3 model not available in this region/tier, skipping" - $dalleAvailable = $false - } - } catch { - Write-Host "dall-e-3 model not supported in this region, skipping" - $dalleAvailable = $false - } - - # Create phi-4 deployment - Write-Host "Creating phi-4 deployment..." - try { - az cognitiveservices account deployment create ` - --resource-group "${azurerm_resource_group.rg.name}" ` - --name "${local.ai_foundry_name}" ` - --deployment-name "phi-4" ` - --model-name "phi-4" ` - --model-version "1" ` - --model-format "OpenAI" ` - --sku-capacity 5 ` - --sku-name "GlobalStandard" - - if ($LASTEXITCODE -eq 0) { - Write-Host "phi-4 deployment created successfully" - $phi4Available = $true - } else { - Write-Host "phi-4 model not available in this region/tier, skipping" - $phi4Available = $false - } - } catch { - Write-Host "phi-4 model not supported, skipping" - $phi4Available = $false - } - # List all deployments to verify Write-Host "`nCurrent model deployments:" az cognitiveservices account deployment list ` @@ -1122,7 +1461,7 @@ resource "azapi_resource" "storage_connection" { depends_on = [ azapi_resource.storage, - azapi_resource.ai_foundry + azapi_update_resource.ai_foundry_enable_project_mgmt ] body = jsonencode({ @@ -1152,7 +1491,7 @@ resource "azapi_resource" "app_insights_connection" { depends_on = [ azurerm_application_insights.appinsights, - azapi_resource.ai_foundry + azapi_update_resource.ai_foundry_enable_project_mgmt ] body = jsonencode({ @@ -1182,7 +1521,7 @@ resource "azapi_resource" "search_connection" { depends_on = [ azurerm_search_service.search, - azapi_resource.ai_foundry + azapi_update_resource.ai_foundry_enable_project_mgmt ] body = jsonencode({ @@ -1213,7 +1552,7 @@ resource "azapi_resource" "cosmos_connection" { depends_on = [ azurerm_cosmosdb_account.cosmos, - azapi_resource.ai_foundry + azapi_update_resource.ai_foundry_enable_project_mgmt ] body = jsonencode({ @@ -1312,103 +1651,40 @@ resource "null_resource" "create_env_file" { # For OpenAI models, use the cognitive services endpoint $openAiEndpoint = $rawAiFoundryEndpoint # For Agents API, use the corrected services.ai.azure.com domain - $agentsEndpoint = $rawAiFoundryEndpoint -replace "cognitiveservices\.azure\.com", "services.ai.azure.com" + $agentsEndpointBase = $rawAiFoundryEndpoint -replace "cognitiveservices\.azure\.com", "services.ai.azure.com" + $agentsEndpointBase = $agentsEndpointBase.TrimEnd("/") + $agentsProjectEndpoint = "$agentsEndpointBase/api/projects/${local.ai_project_name}" Write-Host "OpenAI Endpoint: $openAiEndpoint" - Write-Host "Agents API Endpoint: $agentsEndpoint" + Write-Host "Agents API Endpoint: $agentsProjectEndpoint" # Fetch secrets from Key Vault for local dev (avoid embedding in Terraform state) $kv = "${azurerm_key_vault.kv.name}" $aiFoundryKey = az keyvault secret show --vault-name $kv --name ai-foundry-key --query value -o tsv $searchKey = az keyvault secret show --vault-name $kv --name search-admin-key --query value -o tsv - if (${var.enable_cosmos_local_auth}) { + if (${var.enable_cosmos_local_auth ? "$true" : "$false"}) { $cosmosKey = az keyvault secret show --vault-name $kv --name cosmos-primary-key --query value -o tsv } else { $cosmosKey = "AAD_AUTH" } $storageConnectionString = az keyvault secret show --vault-name $kv --name storage-connection-string --query value -o tsv # Create .env file content - if ($phi4Available) { - $envContent = @" -# Azure AI Foundry Configuration -AZURE_AI_FOUNDRY_ENDPOINT=$openAiEndpoint -AZURE_AI_FOUNDRY_API_KEY=$aiFoundryKey -AZURE_AI_PROJECT_NAME=${local.ai_project_name} -AZURE_AI_AGENT_ENDPOINT=$agentsEndpoint - -# Azure OpenAI Model Deployments -AZURE_OPENAI_CHAT_DEPLOYMENT=gpt-4o-mini -AZURE_OPENAI_EMBEDDING_DEPLOYMENT=text-embedding-3-small -AZURE_OPENAI_PHI_DEPLOYMENT=phi-4 -AZURE_OPENAI_IMAGE_DEPLOYMENT=dall-e-3 -AZURE_OPENAI_ENDPOINT=$openAiEndpoint -AZURE_OPENAI_API_KEY=$aiFoundryKey -AZURE_OPENAI_API_VERSION=2024-02-01 - -# GPT Model Configuration (for single-agent chat) -gpt_endpoint=$openAiEndpoint -gpt_deployment=gpt-4o-mini -gpt_api_key=$aiFoundryKey -gpt_api_version=2024-02-01 - -# Azure Cosmos DB Configuration -COSMOS_DB_ENDPOINT=${azurerm_cosmosdb_account.cosmos.endpoint} -COSMOS_DB_KEY=$cosmosKey -COSMOS_DB_NAME=${local.cosmos_db_name} -COSMOS_DB_CONTAINER_NAME=product_catalog -COSMOS_SKIP_IF_EXISTS=true -COSMOS_FORCE_INGEST=false - -# Azure AI Search Configuration -SEARCH_SERVICE_ENDPOINT=https://${local.search_service_name}.search.windows.net -SEARCH_SERVICE_KEY=$searchKey -SEARCH_INDEX_NAME=products-index - -# Azure Storage Configuration -STORAGE_ACCOUNT_NAME=${local.storage_account} -STORAGE_CONNECTION_STRING=$storageConnectionString - -# Azure Application Insights -APPLICATION_INSIGHTS_CONNECTION_STRING=${azurerm_application_insights.appinsights.connection_string} - -# Azure Resource Information -AZURE_SUBSCRIPTION_ID=${data.azurerm_client_config.current.subscription_id} -AZURE_RESOURCE_GROUP=${azurerm_resource_group.rg.name} -AZURE_LOCATION=${var.location} - -# Multi-Agent Configuration -USE_MULTI_AGENT=true -AZURE_AI_PROJECT_ENDPOINT=$agentsEndpoint -AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME=gpt-4o-mini - -# Agent IDs (will be updated by deploy_real_agents.py after creation) -cora=asst_local_cora -interior_designer=asst_local_interior_design -inventory_agent=asst_local_inventory -customer_loyalty=asst_local_customer_loyalty -cart_manager=asst_local_cart_manager - -# Customer Configuration -CUSTOMER_ID=CUST001 -"@ - } else { - $envContent = @" + $envContent = @" # Azure AI Foundry Configuration AZURE_AI_FOUNDRY_ENDPOINT=$openAiEndpoint AZURE_AI_FOUNDRY_API_KEY=$aiFoundryKey AZURE_AI_PROJECT_NAME=${local.ai_project_name} -AZURE_AI_AGENT_ENDPOINT=$aiFoundryEndpoint +AZURE_AI_AGENT_ENDPOINT=$agentsProjectEndpoint # Azure OpenAI Model Deployments -AZURE_OPENAI_CHAT_DEPLOYMENT=gpt-4o-mini + AZURE_OPENAI_CHAT_DEPLOYMENT=${var.chat_model_deployment} AZURE_OPENAI_EMBEDDING_DEPLOYMENT=text-embedding-3-small -AZURE_OPENAI_IMAGE_DEPLOYMENT=dall-e-3 AZURE_OPENAI_ENDPOINT=$openAiEndpoint AZURE_OPENAI_API_KEY=$aiFoundryKey AZURE_OPENAI_API_VERSION=2024-02-01 # GPT Model Configuration (for single-agent chat) gpt_endpoint=$openAiEndpoint -gpt_deployment=gpt-4o-mini + gpt_deployment=${var.chat_model_deployment} gpt_api_key=$aiFoundryKey gpt_api_version=2024-02-01 @@ -1439,8 +1715,8 @@ AZURE_LOCATION=${var.location} # Multi-Agent Configuration USE_MULTI_AGENT=true -AZURE_AI_PROJECT_ENDPOINT=$aiFoundryEndpoint -AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME=gpt-4o-mini +AZURE_AI_PROJECT_ENDPOINT=$agentsProjectEndpoint +AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME=${var.chat_model_deployment} # Local Pseudo Agent IDs (no remote provisioning required) cora=asst_local_cora @@ -1452,18 +1728,13 @@ cart_manager=asst_local_cart_manager # Customer Configuration CUSTOMER_ID=CUST001 "@ - } # Write .env file $envContent | Out-File -FilePath "../src/.env" -Encoding UTF8 Write-Host ".env file created successfully at ../src/.env" Write-Host "Environment variables configured for:" - if ($phi4Available) { - Write-Host " - Models: gpt-4o-mini, text-embedding-3-small, phi-4" - } else { - Write-Host " - Models: gpt-4o-mini, text-embedding-3-small (phi-4 not available)" - } + Write-Host " - Models: ${var.chat_model_deployment}, text-embedding-3-small" Write-Host " - MSFT Foundry: ${local.ai_foundry_name}" Write-Host " - Azure AI Project: ${local.ai_project_name}" Write-Host " - Cosmos DB: ${local.cosmos_account_name}" @@ -1714,7 +1985,7 @@ resource "null_resource" "deploy_multi_agents" { } Write-Host "Installing required Azure SDK packages..." - & $pythonCmd -m pip install -q azure-ai-projects azure-identity python-dotenv + & $pythonCmd -m pip install -q --pre 'azure-ai-projects>=2.0.0b1' azure-identity python-dotenv if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: Failed to install required packages" @@ -1726,15 +1997,21 @@ resource "null_resource" "deploy_multi_agents" { Write-Host "" # Set up environment for agent deployment with corrected endpoint - $rawEndpoint = "${azapi_resource.ai_foundry.output}" | ConvertFrom-Json | Select-Object -ExpandProperty properties | Select-Object -ExpandProperty endpoint - # Fix domain for agents API - $agentEndpoint = $rawEndpoint -replace "cognitiveservices\.azure\.com", "services.ai.azure.com" + $rawEndpoint = az cognitiveservices account show ` + --resource-group "${azurerm_resource_group.rg.name}" ` + --name "${local.ai_foundry_name}" ` + --query "properties.endpoint" ` + --output tsv + # Fix domain for agents API and attach project path + $agentEndpointBase = $rawEndpoint -replace "cognitiveservices\.azure\.com", "services.ai.azure.com" + $agentEndpoint = "$agentEndpointBase/api/projects/${local.ai_project_name}" $env:AZURE_AI_PROJECT_ENDPOINT = $agentEndpoint Write-Host "Using Agents API endpoint: $agentEndpoint" # Deploy agents using Python script Write-Host "Deploying 6 agents to MSFT Foundry..." $agentScriptPath = Join-Path (Split-Path $PWD.Path -Parent) "src\app\agents\deploy_real_agents.py" + $env:PYTHONPATH = Join-Path (Split-Path $PWD.Path -Parent) "src" if (!(Test-Path $agentScriptPath)) { Write-Host "ERROR: Agent deployment script not found: $agentScriptPath" @@ -1759,7 +2036,7 @@ resource "null_resource" "deploy_multi_agents" { $envPath = Join-Path (Split-Path $PWD.Path -Parent) "src/.env" if (Test-Path $envPath) { Write-Host "Updating Web App app settings with real agent IDs..." - $agentVars = @("cora","interior_designer","inventory_agent","customer_loyalty","cart_manager") + $agentVars = @("cora","interior_designer","inventory_agent","customer_loyalty","cart_manager","product_management") $settingsArgs = @() foreach ($var in $agentVars) { $line = Select-String -Path $envPath -Pattern "^$var=" -ErrorAction SilentlyContinue | Select-Object -First 1 @@ -1772,16 +2049,18 @@ resource "null_resource" "deploy_multi_agents" { } } if ($settingsArgs.Count -gt 0) { - # Ensure image deployment variable present - $settingsArgs += "AZURE_OPENAI_IMAGE_DEPLOYMENT=dall-e-3" - az webapp config appsettings set ` - --resource-group ${azurerm_resource_group.rg.name} ` - --name ${local.web_app_name} ` - --settings $settingsArgs | Out-Null - Write-Host "[OK] Web App app settings updated with real agent IDs" - Write-Host "Restarting Web App to apply settings..." - az webapp restart --resource-group ${azurerm_resource_group.rg.name} --name ${local.web_app_name} | Out-Null - Write-Host "[OK] Web App restarted" + if ("${var.deployment_target}" -eq "appservice") { + az webapp config appsettings set ` + --resource-group ${azurerm_resource_group.rg.name} ` + --name ${local.web_app_name} ` + --settings $settingsArgs | Out-Null + Write-Host "[OK] Web App app settings updated with real agent IDs" + Write-Host "Restarting Web App to apply settings..." + az webapp restart --resource-group ${azurerm_resource_group.rg.name} --name ${local.web_app_name} | Out-Null + Write-Host "[OK] Web App restarted" + } else { + Write-Host "[INFO] Skipping Web App updates (deployment_target=containerapps)" + } } else { Write-Host "No real agent IDs found to update (still using local simulation)." } @@ -1793,9 +2072,13 @@ resource "null_resource" "deploy_multi_agents" { Write-Host "" Write-Host "Docker image already built by standalone resource." Write-Host "" - Write-Host "Restarting Web App to ensure latest configuration..." - az webapp restart --resource-group ${azurerm_resource_group.rg.name} --name ${local.web_app_name} | Out-Null - Write-Host "[OK] Web App restarted" + if ("${var.deployment_target}" -eq "appservice") { + Write-Host "Restarting Web App to ensure latest configuration..." + az webapp restart --resource-group ${azurerm_resource_group.rg.name} --name ${local.web_app_name} | Out-Null + Write-Host "[OK] Web App restarted" + } else { + Write-Host "[INFO] Skipping Web App restart (deployment_target=containerapps)" + } Write-Host "" Write-Host "Multi-agent deployment complete!" EOT @@ -1874,7 +2157,7 @@ resource "null_resource" "verify_real_agents" { # Install Docker Desktop if not present and deploy chat app using containers resource "null_resource" "deploy_chat_app" { - count = var.enable_data_pipeline ? 1 : 0 + count = (var.enable_data_pipeline && local.deploy_to_appservice) ? 1 : 0 depends_on = [ null_resource.verify_single_agent_app, @@ -1975,7 +2258,7 @@ resource "null_resource" "deploy_chat_app" { --settings ` WEBSITES_PORT=8000 ` gpt_endpoint="$aiFoundryEndpoint" ` - gpt_deployment="gpt-4o-mini" ` + gpt_deployment="${var.chat_model_deployment}" ` gpt_api_key="$aiFoundryKey" ` gpt_api_version="2024-12-01-preview" | Out-Null @@ -2010,7 +2293,7 @@ resource "null_resource" "deploy_chat_app" { Write-Host "" Write-Host " [OK] Container: ${local.registry_name}.azurecr.io/zava-chat-app:latest" Write-Host " [OK] SDK: azure-ai-inference (MSFT Foundry)" - Write-Host " [OK] Model: gpt-4o-mini" + Write-Host " [OK] Model: ${var.chat_model_deployment}" Write-Host " [OK] Endpoint: .services.ai.azure.com/models (auto-converted)" Write-Host "" Write-Host " Note: App may take 1-2 minutes to fully initialize on first start" @@ -2038,7 +2321,7 @@ resource "null_resource" "deploy_chat_app" { # Remote multi-agent verification (runs after deployment). Hits /agents endpoint. resource "null_resource" "verify_multi_agent_remote" { - count = var.enable_multi_agent ? 1 : 0 + count = (var.enable_multi_agent && local.deploy_to_appservice) ? 1 : 0 depends_on = [ null_resource.deploy_multi_agents, @@ -2085,7 +2368,7 @@ resource "null_resource" "verify_multi_agent_remote" { } triggers = { - web_app_id = azurerm_linux_web_app.app.id + web_app_id = azurerm_linux_web_app.app[0].id docker_hash = local.dockerfile_hash agents_code = filesha256("../src/chat_app_multi_agent.py") } @@ -2405,7 +2688,7 @@ try { # A2A Monitoring Integration with Azure resource "azurerm_monitor_action_group" "a2a_alerts" { - count = (var.enable_a2a_automation && var.enable_monitoring_dashboards) ? 1 : 0 + count = (var.enable_a2a_automation && var.enable_monitoring_dashboards && local.deploy_to_appservice) ? 1 : 0 name = "${local.web_app_name}-a2a-alerts" resource_group_name = azurerm_resource_group.rg.name @@ -2422,11 +2705,11 @@ resource "azurerm_monitor_action_group" "a2a_alerts" { # A2A System Health Alert resource "azurerm_monitor_metric_alert" "a2a_system_health" { - count = (var.enable_a2a_automation && var.enable_monitoring_dashboards) ? 1 : 0 + count = (var.enable_a2a_automation && var.enable_monitoring_dashboards && local.deploy_to_appservice) ? 1 : 0 name = "${local.web_app_name}-a2a-health" resource_group_name = azurerm_resource_group.rg.name - scopes = [azurerm_linux_web_app.app.id] + scopes = [azurerm_linux_web_app.app[0].id] description = "Alert when A2A automation system health degrades" severity = 2 frequency = "PT1M" @@ -2449,11 +2732,11 @@ resource "azurerm_monitor_metric_alert" "a2a_system_health" { # A2A Performance Alert resource "azurerm_monitor_metric_alert" "a2a_performance" { - count = (var.enable_a2a_automation && var.enable_monitoring_dashboards) ? 1 : 0 + count = (var.enable_a2a_automation && var.enable_monitoring_dashboards && local.deploy_to_appservice) ? 1 : 0 name = "${local.web_app_name}-a2a-performance" resource_group_name = azurerm_resource_group.rg.name - scopes = [azurerm_linux_web_app.app.id] + scopes = [azurerm_linux_web_app.app[0].id] description = "Alert when A2A system response time exceeds threshold" severity = 3 frequency = "PT1M" @@ -2476,10 +2759,11 @@ resource "azurerm_monitor_metric_alert" "a2a_performance" { # Post-deploy automated fix to ensure Web App starts successfully resource "null_resource" "post_deploy_health" { + count = local.deploy_to_appservice ? 1 : 0 depends_on = [ azurerm_linux_web_app.app, azurerm_role_assignment.webapp_acr_pull, - azurerm_key_vault_access_policy.app_policy, + azurerm_role_assignment.kv_secrets_user_webapp, null_resource.deploy_a2a_automation ] diff --git a/terraform-infrastructure/outputs.tf b/terraform-infrastructure/outputs.tf index f6dac68..61761a0 100644 --- a/terraform-infrastructure/outputs.tf +++ b/terraform-infrastructure/outputs.tf @@ -19,13 +19,13 @@ output "container_registry_name" { } output "application_name" { - value = azurerm_linux_web_app.app.name - description = "App Service name" + value = var.deployment_target == "appservice" ? azurerm_linux_web_app.app[0].name : azurerm_container_app.app[0].name + description = "Application name" } output "application_url" { - value = azurerm_linux_web_app.app.default_hostname - description = "Primary host name for the App Service" + value = var.deployment_target == "appservice" ? azurerm_linux_web_app.app[0].default_hostname : azurerm_container_app.app[0].ingress[0].fqdn + description = "Primary host name for the application" } output "ai_foundry_name" { @@ -54,6 +54,102 @@ output "application_insights_connection_string" { sensitive = true } +locals { + appservice_instructions = var.deployment_target == "appservice" ? trimspace(<<-EOT + + ============================================================================ + ZAVA AI SHOPPING ASSISTANT - DEPLOYMENT COMPLETE + ============================================================================ + + AZURE WEB APP: + - App Name: ${azurerm_linux_web_app.app[0].name} + - URL: https://${azurerm_linux_web_app.app[0].default_hostname} + - Health Check: https://${azurerm_linux_web_app.app[0].default_hostname}/health + + LOCAL TESTING: + - Primary URL: https://${azurerm_linux_web_app.app[0].default_hostname} + - For Local Development: http://127.0.0.1:8000 + - To run locally: + cd ../src + venv\Scripts\Activate.ps1 + uvicorn chat_app:app --host 0.0.0.0 --port 8000 + + A2A AUTOMATION FRAMEWORK: + - Enabled: ${var.enable_a2a_automation} + - Azure Web App Integration: https://${azurerm_linux_web_app.app[0].default_hostname}/a2a + - Status: https://${azurerm_linux_web_app.app[0].default_hostname}/a2a/automation/status + - Metrics: https://${azurerm_linux_web_app.app[0].default_hostname}/a2a/automation/metrics + - Health: https://${azurerm_linux_web_app.app[0].default_hostname}/a2a/automation/health + - Testing: https://${azurerm_linux_web_app.app[0].default_hostname}/a2a/automation/test/run + + A2A AUTOMATION FEATURES: + ๐Ÿค– Automated Process Management + ๐Ÿš€ Continuous Deployment Pipeline + ๐Ÿงช Continuous Testing: ${var.enable_continuous_testing} + ๐Ÿ“Š Monitoring Dashboards: ${var.enable_monitoring_dashboards} + ๐Ÿ”ง Self-healing Capabilities + + TO START A2A AUTOMATION: + cd ../src/a2a + .\start_automation.ps1 + + TO CHECK A2A STATUS: + .\status_automation.ps1 + + TEST PROMPTS: + - "What colors of paint do you have available?" + - "Tell me about lattices" + - "Where can I find your store?" + - "Do you have history books?" (tests scope limits) + + AZURE RESOURCES: + - Resource Group: ${azurerm_resource_group.rg.name} + - AI Foundry: ${local.ai_foundry_name} + - Cosmos DB: ${local.cosmos_account_name} + - Search Service: ${local.search_service_name} + - Container Registry: ${local.registry_name} + + ============================================================================ + + EOT + ) : "" + + containerapps_instructions = var.deployment_target == "containerapps" ? trimspace(<<-EOT + + ============================================================================ + ZAVA AI SHOPPING ASSISTANT - DEPLOYMENT COMPLETE + ============================================================================ + + AZURE CONTAINER APPS: + - App Name: ${azurerm_container_app.app[0].name} + - URL: https://${azurerm_container_app.app[0].ingress[0].fqdn} + - Health Check: https://${azurerm_container_app.app[0].ingress[0].fqdn}/health + + LOCAL TESTING: + - Primary URL: https://${azurerm_container_app.app[0].ingress[0].fqdn} + - For Local Development: http://127.0.0.1:8000 + - To run locally: + cd ../src + venv\Scripts\Activate.ps1 + uvicorn chat_app:app --host 0.0.0.0 --port 8000 + + A2A AUTOMATION FRAMEWORK: + - Enabled: ${var.enable_a2a_automation} + - Base URL: https://${azurerm_container_app.app[0].ingress[0].fqdn} + + AZURE RESOURCES: + - Resource Group: ${azurerm_resource_group.rg.name} + - AI Foundry: ${local.ai_foundry_name} + - Cosmos DB: ${local.cosmos_account_name} + - Search Service: ${local.search_service_name} + - Container Registry: ${local.registry_name} + + ============================================================================ + + EOT + ) : "" +} + output "cosmos_db_name" { value = local.cosmos_db_name description = "Cosmos DB database name" @@ -116,10 +212,10 @@ output "key_vault_uri" { output "deployed_models" { value = var.enable_ai_automation ? [ - "gpt-4o-mini", + "model-router", "text-embedding-3-small" ] : [] - description = "List of AI models actually deployed (phi-4 not available in this region)" + description = "List of AI models actually deployed" } output "env_file_location" { @@ -128,73 +224,17 @@ output "env_file_location" { } output "chat_application_url" { - value = "https://${azurerm_linux_web_app.app.default_hostname}" + value = var.deployment_target == "appservice" ? "https://${azurerm_linux_web_app.app[0].default_hostname}" : "https://${azurerm_container_app.app[0].ingress[0].fqdn}" description = "URL to access the Zava AI Shopping Assistant chat application" } output "chat_application_health" { - value = "https://${azurerm_linux_web_app.app.default_hostname}/health" + value = var.deployment_target == "appservice" ? "https://${azurerm_linux_web_app.app[0].default_hostname}/health" : "https://${azurerm_container_app.app[0].ingress[0].fqdn}/health" description = "Health check endpoint for the chat application" } output "application_instructions" { - value = <<-EOT - - ============================================================================ - ZAVA AI SHOPPING ASSISTANT - DEPLOYMENT COMPLETE - ============================================================================ - - AZURE WEB APP: - - App Name: ${azurerm_linux_web_app.app.name} - - URL: https://${azurerm_linux_web_app.app.default_hostname} - - Health Check: https://${azurerm_linux_web_app.app.default_hostname}/health - - LOCAL TESTING: - - Primary URL: https://${azurerm_linux_web_app.app.default_hostname} - - For Local Development: http://127.0.0.1:8000 - - To run locally: - cd ../src - venv\Scripts\Activate.ps1 - uvicorn chat_app:app --host 0.0.0.0 --port 8000 - - A2A AUTOMATION FRAMEWORK: - - Enabled: ${var.enable_a2a_automation} - - Azure Web App Integration: https://${azurerm_linux_web_app.app.default_hostname}/a2a - - Status: https://${azurerm_linux_web_app.app.default_hostname}/a2a/automation/status - - Metrics: https://${azurerm_linux_web_app.app.default_hostname}/a2a/automation/metrics - - Health: https://${azurerm_linux_web_app.app.default_hostname}/a2a/automation/health - - Testing: https://${azurerm_linux_web_app.app.default_hostname}/a2a/automation/test/run - - A2A AUTOMATION FEATURES: - ๐Ÿค– Automated Process Management - ๐Ÿš€ Continuous Deployment Pipeline - ๐Ÿงช Continuous Testing: ${var.enable_continuous_testing} - ๐Ÿ“Š Monitoring Dashboards: ${var.enable_monitoring_dashboards} - ๐Ÿ”ง Self-healing Capabilities - - TO START A2A AUTOMATION: - cd ../src/a2a - .\start_automation.ps1 - - TO CHECK A2A STATUS: - .\status_automation.ps1 - - TEST PROMPTS: - - "What colors of paint do you have available?" - - "Tell me about lattices" - - "Where can I find your store?" - - "Do you have history books?" (tests scope limits) - - AZURE RESOURCES: - - Resource Group: ${azurerm_resource_group.rg.name} - - AI Foundry: ${local.ai_foundry_name} - - Cosmos DB: ${local.cosmos_account_name} - - Search Service: ${local.search_service_name} - - Container Registry: ${local.registry_name} - - ============================================================================ - - EOT + value = var.deployment_target == "appservice" ? local.appservice_instructions : local.containerapps_instructions description = "Deployment summary and usage instructions including A2A automation" } @@ -211,14 +251,23 @@ output "a2a_automation_port" { output "a2a_automation_endpoints" { description = "A2A automation endpoints" - value = var.enable_a2a_automation ? { - status = "https://${azurerm_linux_web_app.app.default_hostname}/a2a/automation/status" - metrics = "https://${azurerm_linux_web_app.app.default_hostname}/a2a/automation/metrics" - health = "https://${azurerm_linux_web_app.app.default_hostname}/a2a/automation/health" - testing = "https://${azurerm_linux_web_app.app.default_hostname}/a2a/automation/test/run" - deployment = "https://${azurerm_linux_web_app.app.default_hostname}/a2a/automation/deploy/trigger" - performance = "https://${azurerm_linux_web_app.app.default_hostname}/a2a/automation/performance" - } : {} + value = var.enable_a2a_automation ? ( + var.deployment_target == "appservice" ? { + status = "https://${azurerm_linux_web_app.app[0].default_hostname}/a2a/automation/status" + metrics = "https://${azurerm_linux_web_app.app[0].default_hostname}/a2a/automation/metrics" + health = "https://${azurerm_linux_web_app.app[0].default_hostname}/a2a/automation/health" + testing = "https://${azurerm_linux_web_app.app[0].default_hostname}/a2a/automation/test/run" + deployment = "https://${azurerm_linux_web_app.app[0].default_hostname}/a2a/automation/deploy/trigger" + performance = "https://${azurerm_linux_web_app.app[0].default_hostname}/a2a/automation/performance" + } : { + status = "https://${azurerm_container_app.app[0].ingress[0].fqdn}/a2a/automation/status" + metrics = "https://${azurerm_container_app.app[0].ingress[0].fqdn}/a2a/automation/metrics" + health = "https://${azurerm_container_app.app[0].ingress[0].fqdn}/a2a/automation/health" + testing = "https://${azurerm_container_app.app[0].ingress[0].fqdn}/a2a/automation/test/run" + deployment = "https://${azurerm_container_app.app[0].ingress[0].fqdn}/a2a/automation/deploy/trigger" + performance = "https://${azurerm_container_app.app[0].ingress[0].fqdn}/a2a/automation/performance" + } + ) : {} } output "monitoring_dashboards_enabled" { @@ -236,8 +285,8 @@ output "deployment_summary" { description = "Summary of all deployed components" value = { web_application = { - url = "https://${azurerm_linux_web_app.app.default_hostname}" - health_check = "https://${azurerm_linux_web_app.app.default_hostname}/health" + url = var.deployment_target == "appservice" ? "https://${azurerm_linux_web_app.app[0].default_hostname}" : "https://${azurerm_container_app.app[0].ingress[0].fqdn}" + health_check = var.deployment_target == "appservice" ? "https://${azurerm_linux_web_app.app[0].default_hostname}/health" : "https://${azurerm_container_app.app[0].ingress[0].fqdn}/health" } ai_services = { foundry_endpoint = "https://${local.ai_foundry_name}.cognitiveservices.azure.com/" @@ -250,9 +299,9 @@ output "deployment_summary" { monitoring = var.enable_monitoring_dashboards testing = var.enable_continuous_testing endpoints = var.enable_a2a_automation ? { - status = "https://${azurerm_linux_web_app.app.default_hostname}/a2a/automation/status" - metrics = "https://${azurerm_linux_web_app.app.default_hostname}/a2a/automation/metrics" - health = "https://${azurerm_linux_web_app.app.default_hostname}/a2a/automation/health" + status = var.deployment_target == "appservice" ? "https://${azurerm_linux_web_app.app[0].default_hostname}/a2a/automation/status" : "https://${azurerm_container_app.app[0].ingress[0].fqdn}/a2a/automation/status" + metrics = var.deployment_target == "appservice" ? "https://${azurerm_linux_web_app.app[0].default_hostname}/a2a/automation/metrics" : "https://${azurerm_container_app.app[0].ingress[0].fqdn}/a2a/automation/metrics" + health = var.deployment_target == "appservice" ? "https://${azurerm_linux_web_app.app[0].default_hostname}/a2a/automation/health" : "https://${azurerm_container_app.app[0].ingress[0].fqdn}/a2a/automation/health" } : null } data_services = { diff --git a/terraform-infrastructure/terraform.tfvars b/terraform-infrastructure/terraform.tfvars index 4e575e4..e1dfd76 100644 --- a/terraform-infrastructure/terraform.tfvars +++ b/terraform-infrastructure/terraform.tfvars @@ -1,7 +1,13 @@ -resource_group_name = "RG-AI-retailbrwn" -location = "westus3" +resource_group_name = "RG-AI-Retail-DemoX9" +location = "eastus2" name_prefix = "zava" +# App Service Plan SKU (change if quota blocks this tier) +app_service_sku = "P0v3" + +# Deployment target (appservice|containerapps) +deployment_target = "containerapps" + # Enable multi-agent architecture enable_multi_agent = true diff --git a/terraform-infrastructure/variables.tf b/terraform-infrastructure/variables.tf index 64c0bbc..3c55d94 100644 --- a/terraform-infrastructure/variables.tf +++ b/terraform-infrastructure/variables.tf @@ -81,3 +81,21 @@ variable "automation_storage_path" { default = "./automation_data" } +variable "app_service_sku" { + type = string + description = "App Service Plan SKU (e.g., B1, S1, P0v3). Must support Linux custom containers." + default = "S1" +} + +variable "deployment_target" { + type = string + description = "Deployment target: 'appservice' or 'containerapps'" + default = "containerapps" +} + +variable "chat_model_deployment" { + type = string + description = "Chat model deployment name for agents and chat (e.g., model-router or gpt-4o-mini)" + default = "gpt-4o-mini" +} +