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
[](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`
@@ -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
-

-
Refresh Date: 2026-01-12
+

+
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
-

-
Refresh Date: 2026-01-12
+

+
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;
-

-
Refresh Date: 2026-01-12
+

+
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"
+}
+