diff --git a/examples/README.md b/examples/README.md index e748cf5a..dfaa1bbb 100644 --- a/examples/README.md +++ b/examples/README.md @@ -7,6 +7,7 @@ This directory contains runnable examples for Agent Control. Each example has it | Example | Summary | Docs | |:--------|:--------|:-----| | Agent Control Demo | End-to-end workflow: create controls, run a controlled agent, update controls dynamically. | https://docs.agentcontrol.dev/examples/agent-control-demo | +| Azure AI Foundry (LangGraph) | Customer support agent with runtime guardrails on Azure AI Foundry Hosted Agents. | https://docs.agentcontrol.dev/examples/azure-foundry-langgraph | | CrewAI | Combine Agent Control security controls with CrewAI guardrails for customer support. | https://docs.agentcontrol.dev/examples/crewai | | Google ADK Plugin | Recommended packaged ADK integration using `AgentControlPlugin` for model and tool guardrails. | https://docs.agentcontrol.dev/examples/google-adk-plugin | | Google ADK Callbacks | Lower-level ADK lifecycle hook integration for manual model and tool guardrails. | https://docs.agentcontrol.dev/examples/google-adk-callbacks | diff --git a/examples/azure_foundry_langgraph/.dockerignore b/examples/azure_foundry_langgraph/.dockerignore new file mode 100644 index 00000000..8fa9dc9b --- /dev/null +++ b/examples/azure_foundry_langgraph/.dockerignore @@ -0,0 +1,10 @@ +.venv/ +__pycache__/ +.env +.azure/ +infra/ +*.pyc +.git/ +.dockerignore +README.md +azure.yaml diff --git a/examples/azure_foundry_langgraph/.env.example b/examples/azure_foundry_langgraph/.env.example new file mode 100644 index 00000000..819bb14d --- /dev/null +++ b/examples/azure_foundry_langgraph/.env.example @@ -0,0 +1,11 @@ +# --- Agent App --- +AGENT_NAME=customer-support-agent +AGENT_CONTROL_URL=http://localhost:8000 +POLICY_REFRESH_INTERVAL_SECONDS=2 + +# Azure AI Foundry +AZURE_AI_PROJECT_ENDPOINT=https://.cognitiveservices.azure.com +MODEL_DEPLOYMENT_NAME=gpt-4.1-mini + +# Optional - leave empty for demo (no auth) +AGENT_CONTROL_API_KEY= diff --git a/examples/azure_foundry_langgraph/DEMO_SCRIPT.md b/examples/azure_foundry_langgraph/DEMO_SCRIPT.md new file mode 100644 index 00000000..1e4cb2e2 --- /dev/null +++ b/examples/azure_foundry_langgraph/DEMO_SCRIPT.md @@ -0,0 +1,70 @@ +# Demo Script + +Open the Foundry Agent Playground and Agent Control UI side by side. All controls should be **disabled** to start. **Start a new chat for each step.** + +## Demo 1: PII Protection + +### Unprotected + +``` +Share customer details for jane@example.com +``` +Leaks everything: SSN (123-45-6789), phone, DOB, billing address, credit card. + +### Enable control + +Enable `block-pii` in the Agent Control UI. **New chat:** + +``` +Share customer details for jane@example.com +``` +**Blocked.** The SSN pattern is caught at the tool output. + +### Toggle + +**Disable** `block-pii`. **New chat**, same prompt - leaks SSN again. **Re-enable**, new chat - blocked. + +## Demo 2: Refund Limits + +### Unprotected + +**New chat:** +``` +Process a refund of $50 for order ORD-1001 +``` +Approved. + +**New chat:** +``` +Process a refund of $150 for order ORD-1001 +``` +Also approved - no guardrails. + +### Enable control + +Enable `max-refund-amount` in the Agent Control UI. **New chat:** + +``` +Process a refund of $50 for order ORD-1003 +``` +Approved - under $100. + +**New chat:** +``` +Process a refund of $150 for order ORD-1001 +``` +**Blocked.** The JSON evaluator checks `refund_amount > 100`. You can change the max threshold in the UI. + +### Toggle + +**Disable** `max-refund-amount`. **New chat**, $150 refund goes through. **Re-enable**, new chat - blocked again. + +## Controls reference + +| Control | Steps | Stage | Evaluator | What it catches | +|---------|-------|-------|-----------|-----------------| +| `block-pii` | `lookup_customer`, `llm_call` | post | regex | SSN pattern `\d{3}-\d{2}-\d{4}` | +| `max-refund-amount` | `process_refund` | post | json | `refund_amount` max 100 | +| `block-internal-data` | `get_order_internal` | post | regex | internal notes, margins, fraud flags | +| `block-prompt-injection` | `llm_call` | pre | regex | injection phrases | +| `block-competitor-discuss` | `llm_call` | pre | regex | competitor comparisons | diff --git a/examples/azure_foundry_langgraph/Dockerfile b/examples/azure_foundry_langgraph/Dockerfile new file mode 100644 index 00000000..ae2f23ac --- /dev/null +++ b/examples/azure_foundry_langgraph/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY . agent/ +WORKDIR /app/agent + +RUN pip install --upgrade pip && pip install --no-cache-dir -r requirements.txt + +EXPOSE 8088 +CMD ["python", "hosted_app.py"] diff --git a/examples/azure_foundry_langgraph/README.md b/examples/azure_foundry_langgraph/README.md new file mode 100644 index 00000000..a3eb1510 --- /dev/null +++ b/examples/azure_foundry_langgraph/README.md @@ -0,0 +1,221 @@ +# Agent Control on Azure AI Foundry (LangGraph) + +A customer support agent running on [Azure AI Foundry Hosted Agents](https://learn.microsoft.com/en-us/azure/foundry/agents/concepts/hosted-agents), governed by Agent Control at runtime. + +Demonstrates: +- **Runtime guardrails** - toggle controls on/off from the UI without redeploying the agent +- **Step-specific controls** - different policies for different tools and the LLM itself +- **Pre and post evaluation** - block dangerous inputs before the LLM sees them, block sensitive outputs before the user sees them + +## Architecture + +``` +User --> Azure AI Foundry Hosted Agent (port 8088) + | + +--> @control() decorator on every tool + LLM call + | | + | +--> Agent Control Server (separate deployment) + | + +--> LangGraph StateGraph + | + +--> Azure OpenAI (gpt-4.1-mini) + +--> Tools (4 total: 2 safe, 2 sensitive) +``` + +## Tools + +| Tool | Returns | Controlled? | +|------|---------|-------------| +| `get_order_status` | Shipping status, items, ETA, tracking | No server control (safe data) | +| `get_order_internal` | Payment info, margins, internal notes, fraud flags | `block-internal-data` (post) | +| `lookup_customer` | Name, email, membership, recent orders | No server control (safe data) | +| `lookup_customer_pii` | Phone, DOB, address, credit card, risk score | `block-customer-pii` (post) | + +The LLM call itself (`llm_call`) is also wrapped with `@control()`: +- `block-prompt-injection` (pre) - blocks adversarial inputs +- `block-competitor-discuss` (pre) - blocks competitor comparisons (business policy) + +## Prerequisites + +- Python 3.12+ +- Docker +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) (`az`) +- [Azure Developer CLI](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/install-azd) (`azd`) with the agents extension +- An Azure subscription with permissions to create resources (Owner or User Access Administrator on the resource group) + +## Setup + +### 1. Start the Agent Control server + +For local development, run Agent Control locally: + +```bash +curl -L https://raw.githubusercontent.com/agentcontrol/agent-control/refs/heads/main/docker-compose.yml \ + | docker compose -f - up -d +``` + +Verify: `curl http://localhost:8000/health` + +For production or demo, deploy Agent Control to an Azure VM (or any host with Docker): + +```bash +# Create a VM +az group create --name my-demo-rg --location eastus +az vm create --resource-group my-demo-rg --name agent-control-vm \ + --image Ubuntu2204 --size Standard_B2s \ + --admin-username azureuser --generate-ssh-keys +az vm open-port --resource-group my-demo-rg --name agent-control-vm --port 8000 + +# SSH in and deploy +ssh azureuser@ +sudo apt update && sudo apt install -y docker.io docker-compose-v2 +curl -L https://raw.githubusercontent.com/agentcontrol/agent-control/refs/heads/main/docker-compose.yml \ + | docker compose -f - up -d +``` + +### 2. Install dependencies + +```bash +python3 -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +``` + +### 3. Configure environment + +```bash +cp .env.example .env +``` + +Edit `.env`: +- `AGENT_CONTROL_URL` - your Agent Control server URL (e.g., `http://localhost:8000` or `http://:8000`) +- `MODEL_DEPLOYMENT_NAME` - your Azure OpenAI model deployment name +- `AZURE_AI_PROJECT_ENDPOINT` - your Foundry project endpoint (only needed for local testing with Azure model) + +### 4. Seed controls + +```bash +python seed_controls.py +``` + +This registers the agent and creates 4 controls (all disabled by default): +- `block-prompt-injection` - `llm_call` pre stage +- `block-internal-data` - `get_order_internal` post stage +- `block-customer-pii` - `lookup_customer_pii` post stage +- `block-competitor-discuss` - `llm_call` pre stage + +### 5. Test locally + +```bash +python local_test.py +``` + +Enable/disable controls in the Agent Control UI and re-run to see different behavior. + +### 6. Deploy to Azure AI Foundry + +#### Install the azd agents extension + +```bash +azd extension install azure.ai.agents +``` + +#### Initialize azd + +From this example directory: + +```bash +azd auth login +azd init -t Azure-Samples/azd-ai-starter-basic -e my-agent-env +``` + +When prompted: +- "Continue initializing?" - Yes +- "Overwrite existing files?" - Keep existing files + +#### Register the agent + +```bash +azd ai agent init -m agent.yaml +``` + +This reads `agent.yaml`, resolves model deployments, and adds the agent as a service in `azure.yaml`. + +#### Provision Azure resources + +```bash +azd provision +``` + +This creates (if they don't already exist): +- Azure AI Services account + Foundry project +- Azure Container Registry (ACR) +- Capability host for Hosted Agents +- Application Insights + Log Analytics +- Model deployment (gpt-4.1-mini) + +> **Note:** You need Owner or User Access Administrator role on the resource group for the RBAC role assignments in the Bicep template. + +#### Deploy the agent + +```bash +azd deploy CustomerSupportAgentLG +``` + +This builds the Docker image remotely in ACR and deploys it as a Hosted Agent. The output includes the playground URL and agent endpoint. + +#### If deploying to an existing Foundry project + +If you already provisioned resources and want to deploy from a fresh checkout, set the required azd environment variables manually: + +```bash +azd env new my-agent-env +azd env set AZURE_RESOURCE_GROUP "" +azd env set AZURE_LOCATION "" +azd env set AZURE_SUBSCRIPTION_ID "" +azd env set AZURE_AI_ACCOUNT_NAME "" +azd env set AZURE_AI_PROJECT_NAME "" +azd env set AZURE_AI_PROJECT_ID "" +azd env set AZURE_AI_PROJECT_ENDPOINT "" +azd env set AZURE_OPENAI_ENDPOINT "" +azd env set AZURE_CONTAINER_REGISTRY_ENDPOINT "" +azd env set ENABLE_HOSTED_AGENTS "true" + +azd deploy CustomerSupportAgentLG +``` + +> **Tip:** The `AZURE_AI_PROJECT_ID` is the full ARM resource ID, e.g., +> `/subscriptions/.../resourceGroups/.../providers/Microsoft.CognitiveServices/accounts//projects/` + +#### Important notes + +- The Dockerfile must include `pip install --upgrade pip` to avoid packaging version errors during remote builds +- Add a `.dockerignore` to exclude `.venv/`, `.env`, `infra/`, `.azure/` from the Docker build context +- Hosted Agents require `linux/amd64` containers - azd handles this via `remoteBuild: true` +- After resetting the Agent Control DB, you must redeploy the agent (so `agent_control.init()` runs fresh) +- The SDK refreshes controls every 5 seconds (`POLICY_REFRESH_INTERVAL_SECONDS=5`) - after toggling a control in the UI, wait a few seconds before testing + +## Demo Flow + +1. Start with all controls **disabled** - show the unprotected agent leaking internal notes and PII +2. Enable controls one by one in the Agent Control UI - each blocks a different category of risk +3. Toggle controls on/off in real-time - same agent, same code, different behavior + +See [DEMO_SCRIPT.md](DEMO_SCRIPT.md) for the full step-by-step demo script with prompts, expected results, and talking points. + +## File Overview + +| File | Purpose | +|------|---------| +| `tools.py` | 4 tools, each decorated with `@control()` | +| `graph.py` | LangGraph StateGraph with `@control()` on the LLM call | +| `agent_control_setup.py` | `agent_control.init()` bootstrap + health check | +| `model.py` | Azure OpenAI chat model via `langchain-azure-ai` | +| `settings.py` | pydantic-settings configuration | +| `seed_controls.py` | Creates the 4 demo controls on the server | +| `local_test.py` | Local integration test (no Azure model needed) | +| `hosted_app.py` | `from_langgraph()` entrypoint for Foundry | +| `Dockerfile` | Container for Foundry Hosted Agents (port 8088) | +| `.dockerignore` | Excludes .venv, .env, infra from container build | +| `agent.yaml` | Foundry Hosted Agent definition | +| `requirements.txt` | Python dependencies | +| `.env.example` | Environment variable template | diff --git a/examples/azure_foundry_langgraph/agent.yaml b/examples/azure_foundry_langgraph/agent.yaml new file mode 100644 index 00000000..7bb8a61b --- /dev/null +++ b/examples/azure_foundry_langgraph/agent.yaml @@ -0,0 +1,25 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml + +kind: hosted +name: CustomerSupportAgentLG +description: Customer support agent with Agent Control runtime guardrails on Azure AI Foundry +protocols: + - protocol: responses + version: v1 +environment_variables: + - name: AZURE_OPENAI_ENDPOINT + value: ${AZURE_OPENAI_ENDPOINT} + - name: OPENAI_API_VERSION + value: 2025-03-01-preview + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: gpt-4.1-mini + - name: AZURE_AI_PROJECT_ENDPOINT + value: ${AZURE_AI_PROJECT_ENDPOINT} + - name: AGENT_CONTROL_URL + value: ${AGENT_CONTROL_URL} + - name: AGENT_CONTROL_API_KEY + value: ${AGENT_CONTROL_API_KEY} + - name: AGENT_NAME + value: customer-support-agent + - name: POLICY_REFRESH_INTERVAL_SECONDS + value: "2" diff --git a/examples/azure_foundry_langgraph/agent_control_setup.py b/examples/azure_foundry_langgraph/agent_control_setup.py new file mode 100644 index 00000000..9f696645 --- /dev/null +++ b/examples/azure_foundry_langgraph/agent_control_setup.py @@ -0,0 +1,34 @@ +import httpx + +import agent_control + +from settings import settings + + +def check_server_health() -> None: + """Fail fast if the Agent Control server is unreachable.""" + url = f"{settings.agent_control_url}/health" + try: + resp = httpx.get(url, timeout=5) + resp.raise_for_status() + except httpx.HTTPError as exc: + raise RuntimeError( + f"Agent Control server not reachable at {url}: {exc}" + ) from exc + + +def bootstrap_agent_control() -> None: + """Initialize the Agent Control SDK and verify server connectivity.""" + check_server_health() + + init_kwargs = { + "agent_name": settings.agent_name, + "agent_description": "Customer support agent with Agent Control runtime guardrails", + "server_url": settings.agent_control_url, + "observability_enabled": True, + "policy_refresh_interval_seconds": settings.policy_refresh_interval_seconds, + } + if settings.agent_control_api_key: + init_kwargs["api_key"] = settings.agent_control_api_key + + agent_control.init(**init_kwargs) diff --git a/examples/azure_foundry_langgraph/azure.yaml b/examples/azure_foundry_langgraph/azure.yaml new file mode 100644 index 00000000..3a059277 --- /dev/null +++ b/examples/azure_foundry_langgraph/azure.yaml @@ -0,0 +1,32 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json + +requiredVersions: + extensions: + azure.ai.agents: '>=0.1.0-preview' +name: ai-foundry-starter-basic +services: + CustomerSupportAgentLG: + project: . + host: azure.ai.agent + language: docker + docker: + remoteBuild: true + config: + container: + resources: + cpu: "0.25" + memory: 0.5Gi + scale: + maxReplicas: 1 + deployments: + - model: + format: OpenAI + name: gpt-4.1-mini + version: "2025-04-14" + name: gpt-4.1-mini + sku: + capacity: 10 + name: GlobalStandard +infra: + provider: bicep + path: ./infra diff --git a/examples/azure_foundry_langgraph/graph.py b/examples/azure_foundry_langgraph/graph.py new file mode 100644 index 00000000..827dcea8 --- /dev/null +++ b/examples/azure_foundry_langgraph/graph.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from typing import Annotated + +from agent_control import control +from langchain_core.messages import BaseMessage, SystemMessage +from langgraph.graph import START, StateGraph +from langgraph.graph.message import add_messages +from langgraph.prebuilt import ToolNode, tools_condition +from typing_extensions import TypedDict + +from model import create_chat_model +from tools import ALL_TOOLS + +SYSTEM_PROMPT = ( + "You are a helpful customer service assistant. " + "You have access to order tracking, customer profiles, and internal systems. " + "Use the appropriate tool for each question:\n" + "- get_order_status: shipping status, items, delivery estimate\n" + "- get_order_internal: payment details, internal notes, fraud flags\n" + "- lookup_customer: full customer profile including SSN, phone, DOB, address\n" + "- process_refund: process a refund (takes order_id and amount)\n" + "Always use tools to answer questions. Be concise and helpful." +) + + +class AgentState(TypedDict): + messages: Annotated[list[BaseMessage], add_messages] + + +# Read-only after build_graph(); safe for concurrent access. +_llm_instance = None + + +@control(step_name="llm_call") +async def _invoke_llm(user_input: str, _messages: list[BaseMessage] | None = None) -> str: + """Controlled LLM call - Agent Control evaluates input (pre) and output (post).""" + messages = [SystemMessage(content=SYSTEM_PROMPT)] + (_messages or []) + return await _llm_instance.ainvoke(messages) + + +def build_graph(): + global _llm_instance + _llm_instance = create_chat_model().bind_tools(ALL_TOOLS) + + async def call_model(state: AgentState): + user_msg = state["messages"][-1] + user_text = str(user_msg.content) if hasattr(user_msg, "content") else "" + response = await _invoke_llm(user_input=user_text, _messages=state["messages"]) + return {"messages": [response]} + + builder = StateGraph(AgentState) + builder.add_node("llm", call_model) + builder.add_node("tools", ToolNode(ALL_TOOLS)) + builder.add_edge(START, "llm") + builder.add_conditional_edges("llm", tools_condition) + builder.add_edge("tools", "llm") + return builder.compile() diff --git a/examples/azure_foundry_langgraph/hosted_app.py b/examples/azure_foundry_langgraph/hosted_app.py new file mode 100644 index 00000000..eb65eac0 --- /dev/null +++ b/examples/azure_foundry_langgraph/hosted_app.py @@ -0,0 +1,15 @@ +"""Entrypoint for Azure AI Foundry Hosted Agents. + +Wraps the LangGraph agent with the Foundry adapter and serves on port 8088. +""" + +from agent_control_setup import bootstrap_agent_control +from graph import build_graph +from azure.ai.agentserver.langgraph import from_langgraph + +bootstrap_agent_control() +agent = build_graph() +adapter = from_langgraph(agent) + +if __name__ == "__main__": + adapter.run() diff --git a/examples/azure_foundry_langgraph/infra/abbreviations.json b/examples/azure_foundry_langgraph/infra/abbreviations.json new file mode 100644 index 00000000..00cef3fc --- /dev/null +++ b/examples/azure_foundry_langgraph/infra/abbreviations.json @@ -0,0 +1,137 @@ +{ + "aiFoundryAccounts": "aif", + "analysisServicesServers": "as", + "apiManagementService": "apim-", + "appConfigurationStores": "appcs-", + "appManagedEnvironments": "cae-", + "appContainerApps": "ca-", + "authorizationPolicyDefinitions": "policy-", + "automationAutomationAccounts": "aa-", + "blueprintBlueprints": "bp-", + "blueprintBlueprintsArtifacts": "bpa-", + "cacheRedis": "redis-", + "cdnProfiles": "cdnp-", + "cdnProfilesEndpoints": "cdne-", + "cognitiveServicesAccounts": "cog-", + "cognitiveServicesFormRecognizer": "cog-fr-", + "cognitiveServicesTextAnalytics": "cog-ta-", + "computeAvailabilitySets": "avail-", + "computeCloudServices": "cld-", + "computeDiskEncryptionSets": "des", + "computeDisks": "disk", + "computeDisksOs": "osdisk", + "computeGalleries": "gal", + "computeSnapshots": "snap-", + "computeVirtualMachines": "vm", + "computeVirtualMachineScaleSets": "vmss-", + "containerInstanceContainerGroups": "ci", + "containerRegistryRegistries": "cr", + "containerServiceManagedClusters": "aks-", + "databricksWorkspaces": "dbw-", + "dataFactoryFactories": "adf-", + "dataLakeAnalyticsAccounts": "dla", + "dataLakeStoreAccounts": "dls", + "dataMigrationServices": "dms-", + "dBforMySQLServers": "mysql-", + "dBforPostgreSQLServers": "psql-", + "devicesIotHubs": "iot-", + "devicesProvisioningServices": "provs-", + "devicesProvisioningServicesCertificates": "pcert-", + "documentDBDatabaseAccounts": "cosmos-", + "documentDBMongoDatabaseAccounts": "cosmon-", + "eventGridDomains": "evgd-", + "eventGridDomainsTopics": "evgt-", + "eventGridEventSubscriptions": "evgs-", + "eventHubNamespaces": "evhns-", + "eventHubNamespacesEventHubs": "evh-", + "hdInsightClustersHadoop": "hadoop-", + "hdInsightClustersHbase": "hbase-", + "hdInsightClustersKafka": "kafka-", + "hdInsightClustersMl": "mls-", + "hdInsightClustersSpark": "spark-", + "hdInsightClustersStorm": "storm-", + "hybridComputeMachines": "arcs-", + "insightsActionGroups": "ag-", + "insightsComponents": "appi-", + "keyVaultVaults": "kv-", + "kubernetesConnectedClusters": "arck", + "kustoClusters": "dec", + "kustoClustersDatabases": "dedb", + "logicIntegrationAccounts": "ia-", + "logicWorkflows": "logic-", + "machineLearningServicesWorkspaces": "mlw-", + "managedIdentityUserAssignedIdentities": "id-", + "managementManagementGroups": "mg-", + "migrateAssessmentProjects": "migr-", + "networkApplicationGateways": "agw-", + "networkApplicationSecurityGroups": "asg-", + "networkAzureFirewalls": "afw-", + "networkBastionHosts": "bas-", + "networkConnections": "con-", + "networkDnsZones": "dnsz-", + "networkExpressRouteCircuits": "erc-", + "networkFirewallPolicies": "afwp-", + "networkFirewallPoliciesWebApplication": "waf", + "networkFirewallPoliciesRuleGroups": "wafrg", + "networkFrontDoors": "fd-", + "networkFrontdoorWebApplicationFirewallPolicies": "fdfp-", + "networkLoadBalancersExternal": "lbe-", + "networkLoadBalancersInternal": "lbi-", + "networkLoadBalancersInboundNatRules": "rule-", + "networkLocalNetworkGateways": "lgw-", + "networkNatGateways": "ng-", + "networkNetworkInterfaces": "nic-", + "networkNetworkSecurityGroups": "nsg-", + "networkNetworkSecurityGroupsSecurityRules": "nsgsr-", + "networkNetworkWatchers": "nw-", + "networkPrivateDnsZones": "pdnsz-", + "networkPrivateLinkServices": "pl-", + "networkPublicIPAddresses": "pip-", + "networkPublicIPPrefixes": "ippre-", + "networkRouteFilters": "rf-", + "networkRouteTables": "rt-", + "networkRouteTablesRoutes": "udr-", + "networkTrafficManagerProfiles": "traf-", + "networkVirtualNetworkGateways": "vgw-", + "networkVirtualNetworks": "vnet-", + "networkVirtualNetworksSubnets": "snet-", + "networkVirtualNetworksVirtualNetworkPeerings": "peer-", + "networkVirtualWans": "vwan-", + "networkVpnGateways": "vpng-", + "networkVpnGatewaysVpnConnections": "vcn-", + "networkVpnGatewaysVpnSites": "vst-", + "notificationHubsNamespaces": "ntfns-", + "notificationHubsNamespacesNotificationHubs": "ntf-", + "operationalInsightsWorkspaces": "log-", + "portalDashboards": "dash-", + "powerBIDedicatedCapacities": "pbi-", + "purviewAccounts": "pview-", + "recoveryServicesVaults": "rsv-", + "resourcesResourceGroups": "rg-", + "searchSearchServices": "srch-", + "serviceBusNamespaces": "sb-", + "serviceBusNamespacesQueues": "sbq-", + "serviceBusNamespacesTopics": "sbt-", + "serviceEndPointPolicies": "se-", + "serviceFabricClusters": "sf-", + "signalRServiceSignalR": "sigr", + "sqlManagedInstances": "sqlmi-", + "sqlServers": "sql-", + "sqlServersDataWarehouse": "sqldw-", + "sqlServersDatabases": "sqldb-", + "sqlServersDatabasesStretch": "sqlstrdb-", + "storageStorageAccounts": "st", + "storageStorageAccountsVm": "stvm", + "storSimpleManagers": "ssimp", + "streamAnalyticsCluster": "asa-", + "synapseWorkspaces": "syn", + "synapseWorkspacesAnalyticsWorkspaces": "synw", + "synapseWorkspacesSqlPoolsDedicated": "syndp", + "synapseWorkspacesSqlPoolsSpark": "synsp", + "timeSeriesInsightsEnvironments": "tsi-", + "webServerFarms": "plan-", + "webSitesAppService": "app-", + "webSitesAppServiceEnvironment": "ase-", + "webSitesFunctions": "func-", + "webStaticSites": "stapp-" +} diff --git a/examples/azure_foundry_langgraph/infra/core/ai/acr-role-assignment.bicep b/examples/azure_foundry_langgraph/infra/core/ai/acr-role-assignment.bicep new file mode 100644 index 00000000..3e0c2b21 --- /dev/null +++ b/examples/azure_foundry_langgraph/infra/core/ai/acr-role-assignment.bicep @@ -0,0 +1,27 @@ +targetScope = 'resourceGroup' + +@description('Name of the existing container registry') +param acrName string + +@description('Principal ID to grant AcrPull role') +param principalId string + +@description('Full resource ID of the ACR (for generating unique GUID)') +param acrResourceId string + +// Reference the existing ACR in this resource group +resource acr 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = { + name: acrName +} + +// Grant AcrPull role to the AI project's managed identity +resource acrPullRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: acr + name: guid(acrResourceId, principalId, '7f951dda-4ed3-4680-a7ca-43fe172d538d') + properties: { + principalId: principalId + principalType: 'ServicePrincipal' + // AcrPull role + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + } +} diff --git a/examples/azure_foundry_langgraph/infra/core/ai/ai-project.bicep b/examples/azure_foundry_langgraph/infra/core/ai/ai-project.bicep new file mode 100644 index 00000000..b38126f0 --- /dev/null +++ b/examples/azure_foundry_langgraph/infra/core/ai/ai-project.bicep @@ -0,0 +1,435 @@ +targetScope = 'resourceGroup' + +@description('Tags that will be applied to all resources') +param tags object = {} + +@description('Main location for the resources') +param location string + +var resourceToken = uniqueString(subscription().id, resourceGroup().id, location) + +@description('Name of the project') +param aiFoundryProjectName string + +param deployments deploymentsType + +@description('Id of the user or app to assign application roles') +param principalId string + +@description('Principal type of user or app') +param principalType string + +@description('Optional. Name of an existing AI Services account in the current resource group. If not provided, a new one will be created.') +param existingAiAccountName string = '' + +@description('List of connections to provision') +param connections array = [] + +@description('Also provision dependent resources and connect to the project') +param additionalDependentResources dependentResourcesType + +@description('Enable monitoring via appinsights and log analytics') +param enableMonitoring bool = true + +@description('Enable hosted agent deployment') +param enableHostedAgents bool = false + +@description('Optional. Existing container registry resource ID. If provided, a connection will be created to this ACR instead of creating a new one.') +param existingContainerRegistryResourceId string = '' + +@description('Optional. Existing container registry login server (e.g., myregistry.azurecr.io). Required if existingContainerRegistryResourceId is provided.') +param existingContainerRegistryEndpoint string = '' + +@description('Optional. Name of an existing ACR connection on the Foundry project. If provided, no new ACR or connection will be created.') +param existingAcrConnectionName string = '' + +@description('Optional. Existing Application Insights connection string. If provided, a connection will be created but no new App Insights resource.') +param existingApplicationInsightsConnectionString string = '' + +@description('Optional. Existing Application Insights resource ID. Used for connection metadata when providing an existing App Insights.') +param existingApplicationInsightsResourceId string = '' + +@description('Optional. Name of an existing Application Insights connection on the Foundry project. If provided, no new App Insights or connection will be created.') +param existingAppInsightsConnectionName string = '' + +// Load abbreviations +var abbrs = loadJsonContent('../../abbreviations.json') + +// Determine which resources to create based on connections +var hasStorageConnection = length(filter(additionalDependentResources, conn => conn.resource == 'storage')) > 0 +var hasAcrConnection = length(filter(additionalDependentResources, conn => conn.resource == 'registry')) > 0 +var hasExistingAcr = !empty(existingContainerRegistryResourceId) +var hasExistingAcrConnection = !empty(existingAcrConnectionName) +var hasExistingAppInsightsConnection = !empty(existingAppInsightsConnectionName) +var hasExistingAppInsightsConnectionString = !empty(existingApplicationInsightsConnectionString) +// Only create new App Insights resources if monitoring enabled and no existing connection/connection string +var shouldCreateAppInsights = enableMonitoring && !hasExistingAppInsightsConnection && !hasExistingAppInsightsConnectionString +var hasSearchConnection = length(filter(additionalDependentResources, conn => conn.resource == 'azure_ai_search')) > 0 +var hasBingConnection = length(filter(additionalDependentResources, conn => conn.resource == 'bing_grounding')) > 0 +var hasBingCustomConnection = length(filter(additionalDependentResources, conn => conn.resource == 'bing_custom_grounding')) > 0 + +// Extract connection names from ai.yaml for each resource type +var storageConnectionName = hasStorageConnection ? filter(additionalDependentResources, conn => conn.resource == 'storage')[0].connectionName : '' +var acrConnectionName = hasAcrConnection ? filter(additionalDependentResources, conn => conn.resource == 'registry')[0].connectionName : '' +var searchConnectionName = hasSearchConnection ? filter(additionalDependentResources, conn => conn.resource == 'azure_ai_search')[0].connectionName : '' +var bingConnectionName = hasBingConnection ? filter(additionalDependentResources, conn => conn.resource == 'bing_grounding')[0].connectionName : '' +var bingCustomConnectionName = hasBingCustomConnection ? filter(additionalDependentResources, conn => conn.resource == 'bing_custom_grounding')[0].connectionName : '' + +// Enable monitoring via Log Analytics and Application Insights +module logAnalytics '../monitor/loganalytics.bicep' = if (shouldCreateAppInsights) { + name: 'logAnalytics' + params: { + location: location + tags: tags + name: 'logs-${resourceToken}' + } +} + +module applicationInsights '../monitor/applicationinsights.bicep' = if (shouldCreateAppInsights) { + name: 'applicationInsights' + params: { + location: location + tags: tags + name: 'appi-${resourceToken}' + logAnalyticsWorkspaceId: logAnalytics.outputs.id + } +} + +// Always create a new AI Account for now (simplified approach) +// TODO: Add support for existing accounts in a future version +resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' = { + name: !empty(existingAiAccountName) ? existingAiAccountName : 'ai-account-${resourceToken}' + location: location + tags: tags + sku: { + name: 'S0' + } + kind: 'AIServices' + identity: { + type: 'SystemAssigned' + } + properties: { + allowProjectManagement: true + customSubDomainName: !empty(existingAiAccountName) ? existingAiAccountName : 'ai-account-${resourceToken}' + networkAcls: { + defaultAction: 'Allow' + virtualNetworkRules: [] + ipRules: [] + } + publicNetworkAccess: 'Enabled' + disableLocalAuth: true + } + + @batchSize(1) + resource seqDeployments 'deployments' = [ + for dep in (deployments??[]): { + name: dep.name + properties: { + model: dep.model + } + sku: dep.sku + } + ] + + resource project 'projects' = { + name: aiFoundryProjectName + location: location + identity: { + type: 'SystemAssigned' + } + properties: { + description: '${aiFoundryProjectName} Project' + displayName: '${aiFoundryProjectName}Project' + } + dependsOn: [ + seqDeployments + ] + } + + resource aiFoundryAccountCapabilityHost 'capabilityHosts@2025-10-01-preview' = if (enableHostedAgents) { + name: 'agents' + properties: { + capabilityHostKind: 'Agents' + // IMPORTANT: this is required to enable hosted agents deployment + // if no BYO Net is provided + enablePublicHostingEnvironment: true + } + } +} + + +// Create connection towards appinsights - only if we created a new App Insights resource +resource appInsightConnection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = if (shouldCreateAppInsights) { + parent: aiAccount::project + name: 'appi-connection' + properties: { + category: 'AppInsights' + target: applicationInsights.outputs.id + authType: 'ApiKey' + isSharedToAll: true + credentials: { + key: applicationInsights.outputs.connectionString + } + metadata: { + ApiType: 'Azure' + ResourceId: applicationInsights.outputs.id + } + } +} + +// Create connection to existing App Insights - if user provided connection string but no existing connection +resource existingAppInsightConnection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = if (enableMonitoring && hasExistingAppInsightsConnectionString && !hasExistingAppInsightsConnection && !empty(existingApplicationInsightsResourceId)) { + parent: aiAccount::project + name: 'appi-connection' + properties: { + category: 'AppInsights' + target: existingApplicationInsightsResourceId + authType: 'ApiKey' + isSharedToAll: true + credentials: { + key: existingApplicationInsightsConnectionString + } + metadata: { + ApiType: 'Azure' + ResourceId: existingApplicationInsightsResourceId + } + } +} + +// Create additional connections from ai.yaml configuration +module aiConnections './connection.bicep' = [for (connection, index) in connections: { + name: 'connection-${connection.name}' + params: { + aiServicesAccountName: aiAccount.name + aiProjectName: aiAccount::project.name + connectionConfig: { + name: connection.name + category: connection.category + target: connection.target + authType: connection.authType + } + apiKey: '' // API keys should be provided via secure parameters or Key Vault + } +}] + +resource localUserAiDeveloperRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: resourceGroup() + name: guid(subscription().id, resourceGroup().id, principalId, '64702f94-c441-49e6-a78b-ef80e0188fee') + properties: { + principalId: principalId + principalType: principalType + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', '64702f94-c441-49e6-a78b-ef80e0188fee') + } +} + +resource localUserCognitiveServicesUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: resourceGroup() + name: guid(subscription().id, resourceGroup().id, principalId, 'a97b65f3-24c7-4388-baec-2e87135dc908') + properties: { + principalId: principalId + principalType: principalType + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', 'a97b65f3-24c7-4388-baec-2e87135dc908') + } +} + +resource projectCognitiveServicesUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: aiAccount + name: guid(subscription().id, resourceGroup().id, aiAccount::project.name, '53ca6127-db72-4b80-b1b0-d745d6d5456d') + properties: { + principalId: aiAccount::project.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', '53ca6127-db72-4b80-b1b0-d745d6d5456d') + } +} + + +// All connections are now created directly within their respective resource modules +// using the centralized ./connection.bicep module + +// Storage module - deploy if storage connection is defined in ai.yaml +module storage '../storage/storage.bicep' = if (hasStorageConnection) { + name: 'storage' + params: { + location: location + tags: tags + resourceName: 'st${resourceToken}' + connectionName: storageConnectionName + principalId: principalId + principalType: principalType + aiServicesAccountName: aiAccount.name + aiProjectName: aiAccount::project.name + } +} + +// Azure Container Registry module - deploy if ACR connection is defined in ai.yaml +module acr '../host/acr.bicep' = if (hasAcrConnection) { + name: 'acr' + params: { + location: location + tags: tags + resourceName: '${abbrs.containerRegistryRegistries}${resourceToken}' + connectionName: acrConnectionName + principalId: principalId + principalType: principalType + aiServicesAccountName: aiAccount.name + aiProjectName: aiAccount::project.name + } +} + +// Connection for existing ACR - create if user provided an existing ACR resource ID but no existing connection +module existingAcrConnection './connection.bicep' = if (hasExistingAcr && !hasExistingAcrConnection) { + name: 'existing-acr-connection' + params: { + aiServicesAccountName: aiAccount.name + aiProjectName: aiAccount::project.name + connectionConfig: { + name: 'acr-connection' + category: 'ContainerRegistry' + target: existingContainerRegistryEndpoint + authType: 'ManagedIdentity' + credentials: { + clientId: aiAccount::project.identity.principalId + resourceId: existingContainerRegistryResourceId + } + isSharedToAll: true + metadata: { + ResourceId: existingContainerRegistryResourceId + } + } + } +} + +// Extract resource group name from the existing ACR resource ID +// Resource ID format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ContainerRegistry/registries/{name} +var existingAcrResourceGroup = hasExistingAcr ? split(existingContainerRegistryResourceId, '/')[4] : '' +var existingAcrName = hasExistingAcr ? last(split(existingContainerRegistryResourceId, '/')) : '' + +// Grant AcrPull role to the AI project's managed identity on the existing ACR +// This allows the hosted agents to pull images from the user-provided registry +// Note: User must have permission to assign roles on the existing ACR (Owner or User Access Administrator) +// Using a module allows scoping to a different resource group if the ACR isn't in the same RG +// Skip if connection already exists (role assignment should already be in place) +module existingAcrRoleAssignment './acr-role-assignment.bicep' = if (hasExistingAcr && !hasExistingAcrConnection) { + name: 'existing-acr-role-assignment' + scope: resourceGroup(existingAcrResourceGroup) + params: { + acrName: existingAcrName + acrResourceId: existingContainerRegistryResourceId + principalId: aiAccount::project.identity.principalId + } +} + +// Bing Search grounding module - deploy if Bing connection is defined in ai.yaml or parameter is enabled +module bingGrounding '../search/bing_grounding.bicep' = if (hasBingConnection) { + name: 'bing-grounding' + params: { + tags: tags + resourceName: 'bing-${resourceToken}' + connectionName: bingConnectionName + aiServicesAccountName: aiAccount.name + aiProjectName: aiAccount::project.name + } +} + +// Bing Custom Search grounding module - deploy if custom Bing connection is defined in ai.yaml or parameter is enabled +module bingCustomGrounding '../search/bing_custom_grounding.bicep' = if (hasBingCustomConnection) { + name: 'bing-custom-grounding' + params: { + tags: tags + resourceName: 'bingcustom-${resourceToken}' + connectionName: bingCustomConnectionName + aiServicesAccountName: aiAccount.name + aiProjectName: aiAccount::project.name + } +} + +// Azure AI Search module - deploy if search connection is defined in ai.yaml +module azureAiSearch '../search/azure_ai_search.bicep' = if (hasSearchConnection) { + name: 'azure-ai-search' + params: { + tags: tags + resourceName: 'search-${resourceToken}' + connectionName: searchConnectionName + storageAccountResourceId: hasStorageConnection ? storage!.outputs.storageAccountId : '' + containerName: 'knowledge' + aiServicesAccountName: aiAccount.name + aiProjectName: aiAccount::project.name + principalId: principalId + principalType: principalType + location: location + } +} + +// Outputs +output AZURE_AI_PROJECT_ENDPOINT string = aiAccount::project.properties.endpoints['AI Foundry API'] +output AZURE_OPENAI_ENDPOINT string = aiAccount.properties.endpoints['OpenAI Language Model Instance API'] +output aiServicesEndpoint string = aiAccount.properties.endpoint +output accountId string = aiAccount.id +output projectId string = aiAccount::project.id +output aiServicesAccountName string = aiAccount.name +output aiServicesProjectName string = aiAccount::project.name +output aiServicesPrincipalId string = aiAccount.identity.principalId +output projectName string = aiAccount::project.name +output APPLICATIONINSIGHTS_CONNECTION_STRING string = shouldCreateAppInsights ? applicationInsights.outputs.connectionString : (hasExistingAppInsightsConnectionString ? existingApplicationInsightsConnectionString : '') +output APPLICATIONINSIGHTS_RESOURCE_ID string = shouldCreateAppInsights ? applicationInsights.outputs.id : (hasExistingAppInsightsConnectionString ? existingApplicationInsightsResourceId : '') + +// Grouped dependent resources outputs +output dependentResources object = { + registry: { + name: hasAcrConnection ? acr!.outputs.containerRegistryName : '' + loginServer: hasAcrConnection ? acr!.outputs.containerRegistryLoginServer : ((hasExistingAcr || hasExistingAcrConnection) ? existingContainerRegistryEndpoint : '') + connectionName: hasAcrConnection ? acr!.outputs.containerRegistryConnectionName : (hasExistingAcrConnection ? existingAcrConnectionName : (hasExistingAcr ? 'acr-connection' : '')) + } + bing_grounding: { + name: (hasBingConnection) ? bingGrounding!.outputs.bingGroundingName : '' + connectionName: (hasBingConnection) ? bingGrounding!.outputs.bingGroundingConnectionName : '' + connectionId: (hasBingConnection) ? bingGrounding!.outputs.bingGroundingConnectionId : '' + } + bing_custom_grounding: { + name: (hasBingCustomConnection) ? bingCustomGrounding!.outputs.bingCustomGroundingName : '' + connectionName: (hasBingCustomConnection) ? bingCustomGrounding!.outputs.bingCustomGroundingConnectionName : '' + connectionId: (hasBingCustomConnection) ? bingCustomGrounding!.outputs.bingCustomGroundingConnectionId : '' + } + search: { + serviceName: hasSearchConnection ? azureAiSearch!.outputs.searchServiceName : '' + connectionName: hasSearchConnection ? azureAiSearch!.outputs.searchConnectionName : '' + } + storage: { + accountName: hasStorageConnection ? storage!.outputs.storageAccountName : '' + connectionName: hasStorageConnection ? storage!.outputs.storageConnectionName : '' + } +} + +type deploymentsType = { + @description('Specify the name of cognitive service account deployment.') + name: string + + @description('Required. Properties of Cognitive Services account deployment model.') + model: { + @description('Required. The name of Cognitive Services account deployment model.') + name: string + + @description('Required. The format of Cognitive Services account deployment model.') + format: string + + @description('Required. The version of Cognitive Services account deployment model.') + version: string + } + + @description('The resource model definition representing SKU.') + sku: { + @description('Required. The name of the resource model definition representing SKU.') + name: string + + @description('The capacity of the resource model definition representing SKU.') + capacity: int + } +}[]? + +type dependentResourcesType = { + @description('The type of dependent resource to create') + resource: 'storage' | 'registry' | 'azure_ai_search' | 'bing_grounding' | 'bing_custom_grounding' + + @description('The connection name for this resource') + connectionName: string +}[] diff --git a/examples/azure_foundry_langgraph/infra/core/ai/connection.bicep b/examples/azure_foundry_langgraph/infra/core/ai/connection.bicep new file mode 100644 index 00000000..c7d79a5c --- /dev/null +++ b/examples/azure_foundry_langgraph/infra/core/ai/connection.bicep @@ -0,0 +1,68 @@ +targetScope = 'resourceGroup' + +@description('AI Services account name') +param aiServicesAccountName string + +@description('AI project name') +param aiProjectName string + +// Connection configuration type definition +type ConnectionConfig = { + @description('Name of the connection') + name: string + + @description('Category of the connection (e.g., ContainerRegistry, AzureStorageAccount, CognitiveSearch)') + category: string + + @description('Target endpoint or URL for the connection') + target: string + + @description('Authentication type') + authType: 'AAD' | 'AccessKey' | 'AccountKey' | 'ApiKey' | 'CustomKeys' | 'ManagedIdentity' | 'None' | 'OAuth2' | 'PAT' | 'SAS' | 'ServicePrincipal' | 'UsernamePassword' + + @description('Whether the connection is shared to all users (optional, defaults to true)') + isSharedToAll: bool? + + @description('Credentials for non-ApiKey authentication types (optional)') + credentials: object? + + @description('Additional metadata for the connection (optional)') + metadata: object? +} + +@description('Connection configuration') +param connectionConfig ConnectionConfig + +@secure() +@description('API key for ApiKey based connections (optional)') +param apiKey string = '' + + +// Get reference to the AI Services account and project +resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + name: aiServicesAccountName + + resource project 'projects' existing = { + name: aiProjectName + } +} + +// Create the connection +resource connection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = { + parent: aiAccount::project + name: connectionConfig.name + properties: { + category: connectionConfig.category + target: connectionConfig.target + authType: connectionConfig.authType + isSharedToAll: connectionConfig.?isSharedToAll ?? true + credentials: connectionConfig.authType == 'ApiKey' ? { + key: apiKey + } : connectionConfig.?credentials + metadata: connectionConfig.?metadata + } +} + +// Outputs +output connectionName string = connection.name +output connectionId string = connection.id diff --git a/examples/azure_foundry_langgraph/infra/core/host/acr.bicep b/examples/azure_foundry_langgraph/infra/core/host/acr.bicep new file mode 100644 index 00000000..5e4acaa0 --- /dev/null +++ b/examples/azure_foundry_langgraph/infra/core/host/acr.bicep @@ -0,0 +1,87 @@ +targetScope = 'resourceGroup' + +@description('The location used for all deployed resources') +param location string = resourceGroup().location + +@description('Tags that will be applied to all resources') +param tags object = {} + +@description('Resource name for the container registry') +param resourceName string + +@description('Id of the user or app to assign application roles') +param principalId string + +@description('Principal type of user or app') +param principalType string + +@description('AI Services account name for the project parent') +param aiServicesAccountName string = '' + +@description('AI project name for creating the connection') +param aiProjectName string = '' + +@description('Name for the AI Foundry ACR connection') +param connectionName string = 'acr-connection' + +// Get reference to the AI Services account and project to access their managed identities +resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: aiServicesAccountName + + resource aiProject 'projects' existing = { + name: aiProjectName + } +} + +// Create the Container Registry +module containerRegistry 'br/public:avm/res/container-registry/registry:0.1.1' = { + name: 'registry' + params: { + name: resourceName + location: location + tags: tags + publicNetworkAccess: 'Enabled' + roleAssignments:[ + { + principalId: principalId + principalType: principalType + roleDefinitionIdOrName: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + } + // TODO SEPARATELY + { + // the foundry project itself can pull from the ACR + principalId: aiAccount::aiProject.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + } + ] + } +} + +// Create the ACR connection using the centralized connection module +module acrConnection '../ai/connection.bicep' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: 'acr-connection-creation' + params: { + aiServicesAccountName: aiServicesAccountName + aiProjectName: aiProjectName + connectionConfig: { + name: connectionName + category: 'ContainerRegistry' + target: containerRegistry.outputs.loginServer + authType: 'ManagedIdentity' + credentials: { + clientId: aiAccount::aiProject.identity.principalId + resourceId: containerRegistry.outputs.resourceId + } + isSharedToAll: true + metadata: { + ResourceId: containerRegistry.outputs.resourceId + } + } + } +} + +output containerRegistryName string = containerRegistry.outputs.name +output containerRegistryLoginServer string = containerRegistry.outputs.loginServer +output containerRegistryResourceId string = containerRegistry.outputs.resourceId +output containerRegistryConnectionName string = acrConnection.outputs.connectionName diff --git a/examples/azure_foundry_langgraph/infra/core/monitor/applicationinsights-dashboard.bicep b/examples/azure_foundry_langgraph/infra/core/monitor/applicationinsights-dashboard.bicep new file mode 100644 index 00000000..f3e0952b --- /dev/null +++ b/examples/azure_foundry_langgraph/infra/core/monitor/applicationinsights-dashboard.bicep @@ -0,0 +1,1236 @@ +metadata description = 'Creates a dashboard for an Application Insights instance.' +param name string +param applicationInsightsName string +param location string = resourceGroup().location +param tags object = {} + +// 2020-09-01-preview because that is the latest valid version +resource applicationInsightsDashboard 'Microsoft.Portal/dashboards@2020-09-01-preview' = { + name: name + location: location + tags: tags + properties: { + lenses: [ + { + order: 0 + parts: [ + { + position: { + x: 0 + y: 0 + colSpan: 2 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'id' + value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + { + name: 'Version' + value: '1.0' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/AspNetOverviewPinnedPart' + asset: { + idInputName: 'id' + type: 'ApplicationInsights' + } + defaultMenuItemId: 'overview' + } + } + { + position: { + x: 2 + y: 0 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'Version' + value: '1.0' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/ProactiveDetectionAsyncPart' + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + defaultMenuItemId: 'ProactiveDetection' + } + } + { + position: { + x: 3 + y: 0 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'ResourceId' + value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/QuickPulseButtonSmallPart' + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + } + } + { + position: { + x: 4 + y: 0 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'TimeContext' + value: { + durationMs: 86400000 + endTime: null + createdTime: '2018-05-04T01:20:33.345Z' + isInitialTime: true + grain: 1 + useDashboardTimeRange: false + } + } + { + name: 'Version' + value: '1.0' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/AvailabilityNavButtonPart' + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + } + } + { + position: { + x: 5 + y: 0 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'TimeContext' + value: { + durationMs: 86400000 + endTime: null + createdTime: '2018-05-08T18:47:35.237Z' + isInitialTime: true + grain: 1 + useDashboardTimeRange: false + } + } + { + name: 'ConfigurationId' + value: '78ce933e-e864-4b05-a27b-71fd55a6afad' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/AppMapButtonPart' + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + } + } + { + position: { + x: 0 + y: 1 + colSpan: 3 + rowSpan: 1 + } + metadata: { + inputs: [] + type: 'Extension/HubsExtension/PartType/MarkdownPart' + settings: { + content: { + settings: { + content: '# Usage' + title: '' + subtitle: '' + } + } + } + } + } + { + position: { + x: 3 + y: 1 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'TimeContext' + value: { + durationMs: 86400000 + endTime: null + createdTime: '2018-05-04T01:22:35.782Z' + isInitialTime: true + grain: 1 + useDashboardTimeRange: false + } + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/UsageUsersOverviewPart' + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + } + } + { + position: { + x: 4 + y: 1 + colSpan: 3 + rowSpan: 1 + } + metadata: { + inputs: [] + type: 'Extension/HubsExtension/PartType/MarkdownPart' + settings: { + content: { + settings: { + content: '# Reliability' + title: '' + subtitle: '' + } + } + } + } + } + { + position: { + x: 7 + y: 1 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ResourceId' + value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + { + name: 'DataModel' + value: { + version: '1.0.0' + timeContext: { + durationMs: 86400000 + createdTime: '2018-05-04T23:42:40.072Z' + isInitialTime: false + grain: 1 + useDashboardTimeRange: false + } + } + isOptional: true + } + { + name: 'ConfigurationId' + value: '8a02f7bf-ac0f-40e1-afe9-f0e72cfee77f' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/CuratedBladeFailuresPinnedPart' + isAdapter: true + asset: { + idInputName: 'ResourceId' + type: 'ApplicationInsights' + } + defaultMenuItemId: 'failures' + } + } + { + position: { + x: 8 + y: 1 + colSpan: 3 + rowSpan: 1 + } + metadata: { + inputs: [] + type: 'Extension/HubsExtension/PartType/MarkdownPart' + settings: { + content: { + settings: { + content: '# Responsiveness\r\n' + title: '' + subtitle: '' + } + } + } + } + } + { + position: { + x: 11 + y: 1 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ResourceId' + value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + { + name: 'DataModel' + value: { + version: '1.0.0' + timeContext: { + durationMs: 86400000 + createdTime: '2018-05-04T23:43:37.804Z' + isInitialTime: false + grain: 1 + useDashboardTimeRange: false + } + } + isOptional: true + } + { + name: 'ConfigurationId' + value: '2a8ede4f-2bee-4b9c-aed9-2db0e8a01865' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/CuratedBladePerformancePinnedPart' + isAdapter: true + asset: { + idInputName: 'ResourceId' + type: 'ApplicationInsights' + } + defaultMenuItemId: 'performance' + } + } + { + position: { + x: 12 + y: 1 + colSpan: 3 + rowSpan: 1 + } + metadata: { + inputs: [] + type: 'Extension/HubsExtension/PartType/MarkdownPart' + settings: { + content: { + settings: { + content: '# Browser' + title: '' + subtitle: '' + } + } + } + } + } + { + position: { + x: 15 + y: 1 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'MetricsExplorerJsonDefinitionId' + value: 'BrowserPerformanceTimelineMetrics' + } + { + name: 'TimeContext' + value: { + durationMs: 86400000 + createdTime: '2018-05-08T12:16:27.534Z' + isInitialTime: false + grain: 1 + useDashboardTimeRange: false + } + } + { + name: 'CurrentFilter' + value: { + eventTypes: [ + 4 + 1 + 3 + 5 + 2 + 6 + 13 + ] + typeFacets: {} + isPermissive: false + } + } + { + name: 'id' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'Version' + value: '1.0' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/MetricsExplorerBladePinnedPart' + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + defaultMenuItemId: 'browser' + } + } + { + position: { + x: 0 + y: 2 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'sessions/count' + aggregationType: 5 + namespace: 'microsoft.insights/components/kusto' + metricVisualization: { + displayName: 'Sessions' + color: '#47BDF5' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'users/count' + aggregationType: 5 + namespace: 'microsoft.insights/components/kusto' + metricVisualization: { + displayName: 'Users' + color: '#7E58FF' + } + } + ] + title: 'Unique sessions and users' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + openBladeOnClick: { + openBlade: true + destinationBlade: { + extensionName: 'HubsExtension' + bladeName: 'ResourceMenuBlade' + parameters: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + menuid: 'segmentationUsers' + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 4 + y: 2 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'requests/failed' + aggregationType: 7 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Failed requests' + color: '#EC008C' + } + } + ] + title: 'Failed requests' + visualization: { + chartType: 3 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + openBladeOnClick: { + openBlade: true + destinationBlade: { + extensionName: 'HubsExtension' + bladeName: 'ResourceMenuBlade' + parameters: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + menuid: 'failures' + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 8 + y: 2 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'requests/duration' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Server response time' + color: '#00BCF2' + } + } + ] + title: 'Server response time' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + openBladeOnClick: { + openBlade: true + destinationBlade: { + extensionName: 'HubsExtension' + bladeName: 'ResourceMenuBlade' + parameters: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + menuid: 'performance' + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 12 + y: 2 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'browserTimings/networkDuration' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Page load network connect time' + color: '#7E58FF' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'browserTimings/processingDuration' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Client processing time' + color: '#44F1C8' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'browserTimings/sendDuration' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Send request time' + color: '#EB9371' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'browserTimings/receiveDuration' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Receiving response time' + color: '#0672F1' + } + } + ] + title: 'Average page load time breakdown' + visualization: { + chartType: 3 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 0 + y: 5 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'availabilityResults/availabilityPercentage' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Availability' + color: '#47BDF5' + } + } + ] + title: 'Average availability' + visualization: { + chartType: 3 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + openBladeOnClick: { + openBlade: true + destinationBlade: { + extensionName: 'HubsExtension' + bladeName: 'ResourceMenuBlade' + parameters: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + menuid: 'availability' + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 4 + y: 5 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'exceptions/server' + aggregationType: 7 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Server exceptions' + color: '#47BDF5' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'dependencies/failed' + aggregationType: 7 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Dependency failures' + color: '#7E58FF' + } + } + ] + title: 'Server exceptions and Dependency failures' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 8 + y: 5 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'performanceCounters/processorCpuPercentage' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Processor time' + color: '#47BDF5' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'performanceCounters/processCpuPercentage' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Process CPU' + color: '#7E58FF' + } + } + ] + title: 'Average processor and process CPU utilization' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 12 + y: 5 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'exceptions/browser' + aggregationType: 7 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Browser exceptions' + color: '#47BDF5' + } + } + ] + title: 'Browser exceptions' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 0 + y: 8 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'availabilityResults/count' + aggregationType: 7 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Availability test results count' + color: '#47BDF5' + } + } + ] + title: 'Availability test results count' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 4 + y: 8 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'performanceCounters/processIOBytesPerSecond' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Process IO rate' + color: '#47BDF5' + } + } + ] + title: 'Average process I/O rate' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 8 + y: 8 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'performanceCounters/memoryAvailableBytes' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Available memory' + color: '#47BDF5' + } + } + ] + title: 'Average available memory' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + ] + } + ] + } +} + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = { + name: applicationInsightsName +} diff --git a/examples/azure_foundry_langgraph/infra/core/monitor/applicationinsights.bicep b/examples/azure_foundry_langgraph/infra/core/monitor/applicationinsights.bicep new file mode 100644 index 00000000..f8c1e8ad --- /dev/null +++ b/examples/azure_foundry_langgraph/infra/core/monitor/applicationinsights.bicep @@ -0,0 +1,31 @@ +metadata description = 'Creates an Application Insights instance based on an existing Log Analytics workspace.' +param name string +param dashboardName string = '' +param location string = resourceGroup().location +param tags object = {} +param logAnalyticsWorkspaceId string + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { + name: name + location: location + tags: tags + kind: 'web' + properties: { + Application_Type: 'web' + WorkspaceResourceId: logAnalyticsWorkspaceId + } +} + +module applicationInsightsDashboard 'applicationinsights-dashboard.bicep' = if (!empty(dashboardName)) { + name: 'application-insights-dashboard' + params: { + name: dashboardName + location: location + applicationInsightsName: applicationInsights.name + } +} + +output connectionString string = applicationInsights.properties.ConnectionString +output id string = applicationInsights.id +output instrumentationKey string = applicationInsights.properties.InstrumentationKey +output name string = applicationInsights.name diff --git a/examples/azure_foundry_langgraph/infra/core/monitor/loganalytics.bicep b/examples/azure_foundry_langgraph/infra/core/monitor/loganalytics.bicep new file mode 100644 index 00000000..bf87f546 --- /dev/null +++ b/examples/azure_foundry_langgraph/infra/core/monitor/loganalytics.bicep @@ -0,0 +1,22 @@ +metadata description = 'Creates a Log Analytics workspace.' +param name string +param location string = resourceGroup().location +param tags object = {} + +resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2021-12-01-preview' = { + name: name + location: location + tags: tags + properties: any({ + retentionInDays: 30 + features: { + searchVersion: 1 + } + sku: { + name: 'PerGB2018' + } + }) +} + +output id string = logAnalytics.id +output name string = logAnalytics.name diff --git a/examples/azure_foundry_langgraph/infra/core/search/azure_ai_search.bicep b/examples/azure_foundry_langgraph/infra/core/search/azure_ai_search.bicep new file mode 100644 index 00000000..ba6e9bdf --- /dev/null +++ b/examples/azure_foundry_langgraph/infra/core/search/azure_ai_search.bicep @@ -0,0 +1,211 @@ +targetScope = 'resourceGroup' + +@description('Tags that will be applied to all resources') +param tags object = {} + +@description('Azure Search resource name') +param resourceName string + +@description('Azure Search SKU name') +param azureSearchSkuName string = 'basic' + +@description('Azure storage account resource ID') +param storageAccountResourceId string + +@description('container name') +param containerName string = 'knowledgebase' + +@description('AI Services account name for the project parent') +param aiServicesAccountName string = '' + +@description('AI project name for creating the connection') +param aiProjectName string = '' + +@description('Id of the user or app to assign application roles') +param principalId string + +@description('Principal type of user or app') +param principalType string + +@description('Name for the AI Foundry search connection') +param connectionName string = 'azure-ai-search-connection' + +@description('Location for all resources') +param location string = resourceGroup().location + +// Get reference to the AI Services account and project to access their managed identities +resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: aiServicesAccountName + + resource aiProject 'projects' existing = { + name: aiProjectName + } +} + +// Azure Search Service +resource searchService 'Microsoft.Search/searchServices@2024-06-01-preview' = { + name: resourceName + location: location + tags: tags + sku: { + name: azureSearchSkuName + } + identity: { + type: 'SystemAssigned' + } + properties: { + replicaCount: 1 + partitionCount: 1 + hostingMode: 'default' + authOptions: { + aadOrApiKey: { + aadAuthFailureMode: 'http401WithBearerChallenge' + } + } + disableLocalAuth: false + encryptionWithCmk: { + enforcement: 'Unspecified' + } + publicNetworkAccess: 'enabled' + } +} + +// Reference to existing Storage Account +resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' existing = { + name: last(split(storageAccountResourceId, '/')) +} + +// Reference to existing Blob Service +resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-05-01' existing = { + parent: storageAccount + name: 'default' +} + +// Storage Container (create if it doesn't exist) +resource storageContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = { + parent: blobService + name: containerName + properties: { + publicAccess: 'None' + } +} + +// RBAC Assignments + +// Search needs to read from Storage +resource searchToStorageRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storageAccount.id, searchService.id, 'Storage Blob Data Reader', uniqueString(deployment().name)) + scope: storageAccount + properties: { + // GOOD + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '2a2b9908-6ea1-4ae2-8e65-a410df84e7d1') // Storage Blob Data Reader + principalId: searchService.identity.principalId + principalType: 'ServicePrincipal' + } +} + +// Search needs OpenAI access (AI Services account) +resource searchToAIServicesRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(aiServicesAccountName)) { + name: guid(aiServicesAccountName, searchService.id, 'Cognitive Services OpenAI User', uniqueString(deployment().name)) + properties: { + // GOOD + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd') // Cognitive Services OpenAI User + principalId: searchService.identity.principalId + principalType: 'ServicePrincipal' + } +} + +// AI Project needs Search access - Service Contributor +resource aiServicesToSearchServiceRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: guid(searchService.id, aiServicesAccountName, aiProjectName, 'Search Service Contributor', uniqueString(deployment().name)) + scope: searchService + properties: { + // GOOD + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7ca78c08-252a-4471-8644-bb5ff32d4ba0') // Search Service Contributor + principalId: aiAccount::aiProject.identity.principalId + principalType: 'ServicePrincipal' + } +} + +// AI Project needs Search access - Index Data Contributor +resource aiServicesToSearchDataRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: guid(searchService.id, aiServicesAccountName, aiProjectName, 'Search Index Data Contributor', uniqueString(deployment().name)) + scope: searchService + properties: { + // GOOD + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '8ebe5a00-799e-43f5-93ac-243d3dce84a7') // Search Index Data Contributor + principalId: aiAccount::aiProject.identity.principalId + principalType: 'ServicePrincipal' + } +} + +// User permissions - Search Index Data Contributor +resource userToSearchRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(searchService.id, principalId, 'Search Index Data Contributor', uniqueString(deployment().name)) + scope: searchService + properties: { + // GOOD + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '8ebe5a00-799e-43f5-93ac-243d3dce84a7') // Search Index Data Contributor + principalId: principalId + principalType: principalType + } +} + +// // User permissions - Storage Blob Data Contributor +// resource userToStorageRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { +// name: guid(storageAccount.id, principalId, 'Storage Blob Data Contributor', uniqueString(deployment().name)) +// scope: storageAccount +// properties: { +// roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe') // Storage Blob Data Contributor +// principalId: principalId +// principalType: principalType +// } +// } + +// // Project needs Search access - Index Data Contributor +// resource projectToSearchRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { +// name: guid(searchService.id, aiProjectName, 'Search Index Data Contributor', uniqueString(deployment().name)) +// scope: searchService +// properties: { +// roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '8ebe5a00-799e-43f5-93ac-243d3dce84a7') // Search Index Data Contributor +// principalId: aiAccountPrincipalId // Using AI account principal ID as project identity +// principalType: 'ServicePrincipal' +// } +// } + +// Create the AI Search connection using the centralized connection module +module aiSearchConnection '../ai/connection.bicep' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: 'ai-search-connection-creation' + params: { + aiServicesAccountName: aiServicesAccountName + aiProjectName: aiProjectName + connectionConfig: { + name: connectionName + category: 'CognitiveSearch' + target: 'https://${searchService.name}.search.windows.net' + authType: 'AAD' + isSharedToAll: true + metadata: { + ApiVersion: '2024-07-01' + ResourceId: searchService.id + ApiType: 'Azure' + type: 'azure_ai_search' + } + } + } + dependsOn: [ + aiServicesToSearchDataRoleAssignment + ] +} + +// Outputs +output searchServiceName string = searchService.name +output searchServiceId string = searchService.id +output searchServicePrincipalId string = searchService.identity.principalId +output storageAccountName string = storageAccount.name +output storageAccountId string = storageAccount.id +output containerName string = storageContainer.name +output storageAccountPrincipalId string = storageAccount.identity.principalId +output searchConnectionName string = (!empty(aiServicesAccountName) && !empty(aiProjectName)) ? aiSearchConnection!.outputs.connectionName : '' +output searchConnectionId string = (!empty(aiServicesAccountName) && !empty(aiProjectName)) ? aiSearchConnection!.outputs.connectionId : '' + diff --git a/examples/azure_foundry_langgraph/infra/core/search/bing_custom_grounding.bicep b/examples/azure_foundry_langgraph/infra/core/search/bing_custom_grounding.bicep new file mode 100644 index 00000000..997095fa --- /dev/null +++ b/examples/azure_foundry_langgraph/infra/core/search/bing_custom_grounding.bicep @@ -0,0 +1,82 @@ +targetScope = 'resourceGroup' + +@description('Tags that will be applied to all resources') +param tags object = {} + +@description('Bing custom grounding resource name') +param resourceName string + +@description('AI Services account name for the project parent') +param aiServicesAccountName string = '' + +@description('AI project name for creating the connection') +param aiProjectName string = '' + +@description('Name for the AI Foundry Bing Custom Search connection') +param connectionName string = 'bing-custom-grounding-connection' + +// Get reference to the AI Services account and project to access their managed identities +resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: aiServicesAccountName + + resource aiProject 'projects' existing = { + name: aiProjectName + } +} + +// Bing Search resource for grounding capability +resource bingCustomSearch 'Microsoft.Bing/accounts@2020-06-10' = { + name: resourceName + location: 'global' + tags: tags + sku: { + name: 'G1' + } + properties: { + statisticsEnabled: false + } + kind: 'Bing.CustomGrounding' +} + +// Role assignment to allow AI project to use Bing Search +resource bingCustomSearchRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + scope: bingCustomSearch + name: guid(subscription().id, resourceGroup().id, 'bing-search-role', aiServicesAccountName, aiProjectName) + properties: { + principalId: aiAccount::aiProject.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', 'a97b65f3-24c7-4388-baec-2e87135dc908') // Cognitive Services User + } +} + +// Create the Bing Custom Search connection using the centralized connection module +module aiSearchConnection '../ai/connection.bicep' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: 'bing-custom-search-connection-creation' + params: { + aiServicesAccountName: aiServicesAccountName + aiProjectName: aiProjectName + connectionConfig: { + name: connectionName + category: 'GroundingWithCustomSearch' + target: bingCustomSearch.properties.endpoint + authType: 'ApiKey' + isSharedToAll: true + metadata: { + Location: 'global' + ResourceId: bingCustomSearch.id + ApiType: 'Azure' + type: 'bing_custom_search' + } + } + apiKey: bingCustomSearch.listKeys().key1 + } + dependsOn: [ + bingCustomSearchRoleAssignment + ] +} + +// Outputs +output bingCustomGroundingName string = bingCustomSearch.name +output bingCustomGroundingConnectionName string = aiSearchConnection.outputs.connectionName +output bingCustomGroundingResourceId string = bingCustomSearch.id +output bingCustomGroundingConnectionId string = aiSearchConnection.outputs.connectionId diff --git a/examples/azure_foundry_langgraph/infra/core/search/bing_grounding.bicep b/examples/azure_foundry_langgraph/infra/core/search/bing_grounding.bicep new file mode 100644 index 00000000..1a7b8db8 --- /dev/null +++ b/examples/azure_foundry_langgraph/infra/core/search/bing_grounding.bicep @@ -0,0 +1,81 @@ +targetScope = 'resourceGroup' + +@description('Tags that will be applied to all resources') +param tags object = {} + +@description('Bing grounding resource name') +param resourceName string + +@description('AI Services account name for the project parent') +param aiServicesAccountName string = '' + +@description('AI project name for creating the connection') +param aiProjectName string = '' + +@description('Name for the AI Foundry Bing Search connection') +param connectionName string = 'bing-grounding-connection' + +// Get reference to the AI Services account and project to access their managed identities +resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: aiServicesAccountName + + resource aiProject 'projects' existing = { + name: aiProjectName + } +} + +// Bing Search resource for grounding capability +resource bingSearch 'Microsoft.Bing/accounts@2020-06-10' = { + name: resourceName + location: 'global' + tags: tags + sku: { + name: 'G1' + } + properties: { + statisticsEnabled: false + } + kind: 'Bing.Grounding' +} + +// Role assignment to allow AI project to use Bing Search +resource bingSearchRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + scope: bingSearch + name: guid(subscription().id, resourceGroup().id, 'bing-search-role', aiServicesAccountName, aiProjectName) + properties: { + principalId: aiAccount::aiProject.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', 'a97b65f3-24c7-4388-baec-2e87135dc908') // Cognitive Services User + } +} + +// Create the Bing Search connection using the centralized connection module +module bingSearchConnection '../ai/connection.bicep' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: 'bing-search-connection-creation' + params: { + aiServicesAccountName: aiServicesAccountName + aiProjectName: aiProjectName + connectionConfig: { + name: connectionName + category: 'GroundingWithBingSearch' + target: bingSearch.properties.endpoint + authType: 'ApiKey' + isSharedToAll: true + metadata: { + Location: 'global' + ResourceId: bingSearch.id + ApiType: 'Azure' + type: 'bing_grounding' + } + } + apiKey: bingSearch.listKeys().key1 + } + dependsOn: [ + bingSearchRoleAssignment + ] +} + +output bingGroundingName string = bingSearch.name +output bingGroundingConnectionName string = bingSearchConnection.outputs.connectionName +output bingGroundingResourceId string = bingSearch.id +output bingGroundingConnectionId string = bingSearchConnection.outputs.connectionId diff --git a/examples/azure_foundry_langgraph/infra/core/storage/storage.bicep b/examples/azure_foundry_langgraph/infra/core/storage/storage.bicep new file mode 100644 index 00000000..6bad1d15 --- /dev/null +++ b/examples/azure_foundry_langgraph/infra/core/storage/storage.bicep @@ -0,0 +1,113 @@ +targetScope = 'resourceGroup' + +@description('The location used for all deployed resources') +param location string = resourceGroup().location + +@description('Tags that will be applied to all resources') +param tags object = {} + +@description('Storage account resource name') +param resourceName string + +@description('Id of the user or app to assign application roles') +param principalId string + +@description('Principal type of user or app') +param principalType string + +@description('AI Services account name for the project parent') +param aiServicesAccountName string = '' + +@description('AI project name for creating the connection') +param aiProjectName string = '' + +@description('Name for the AI Foundry storage connection') +param connectionName string = 'storage-connection' + +// Storage Account for the AI Services account +resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' = { + name: resourceName + location: location + tags: tags + sku: { + name: 'Standard_LRS' + } + kind: 'StorageV2' + identity: { + type: 'SystemAssigned' + } + properties: { + supportsHttpsTrafficOnly: true + allowBlobPublicAccess: false + minimumTlsVersion: 'TLS1_2' + accessTier: 'Hot' + encryption: { + services: { + blob: { + enabled: true + } + file: { + enabled: true + } + } + keySource: 'Microsoft.Storage' + } + } +} + +// Get reference to the AI Services account and project to access their managed identities +resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: aiServicesAccountName + + resource aiProject 'projects' existing = { + name: aiProjectName + } +} + +// Role assignment for AI Services to access the storage account +resource storageRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: guid(storageAccount.id, aiAccount.id, 'ai-storage-contributor') + scope: storageAccount + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe') // Storage Blob Data Contributor + principalId: aiAccount::aiProject.identity.principalId + principalType: 'ServicePrincipal' + } +} + +// User permissions - Storage Blob Data Contributor +resource userStorageRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storageAccount.id, principalId, 'Storage Blob Data Contributor') + scope: storageAccount + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe') // Storage Blob Data Contributor + principalId: principalId + principalType: principalType + } +} + +// Create the storage connection using the centralized connection module +module storageConnection '../ai/connection.bicep' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: 'storage-connection-creation' + params: { + aiServicesAccountName: aiServicesAccountName + aiProjectName: aiProjectName + connectionConfig: { + name: connectionName + category: 'AzureStorageAccount' + target: storageAccount.properties.primaryEndpoints.blob + authType: 'AAD' + isSharedToAll: true + metadata: { + ApiType: 'Azure' + ResourceId: storageAccount.id + location: storageAccount.location + } + } + } +} + +output storageAccountName string = storageAccount.name +output storageAccountId string = storageAccount.id +output storageAccountPrincipalId string = storageAccount.identity.principalId +output storageConnectionName string = storageConnection.outputs.connectionName diff --git a/examples/azure_foundry_langgraph/infra/main.bicep b/examples/azure_foundry_langgraph/infra/main.bicep new file mode 100644 index 00000000..42557a3a --- /dev/null +++ b/examples/azure_foundry_langgraph/infra/main.bicep @@ -0,0 +1,188 @@ +targetScope = 'subscription' +// targetScope = 'resourceGroup' + +@minLength(1) +@maxLength(64) +@description('Name of the environment that can be used as part of naming resource convention') +param environmentName string + +@minLength(1) +@maxLength(90) +@description('Name of the resource group to use or create') +param resourceGroupName string = 'rg-${environmentName}' + +// Restricted locations to match list from +// https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/responses?tabs=python-key#region-availability +@minLength(1) +@description('Primary location for all resources') +@allowed([ + 'australiaeast' + 'brazilsouth' + 'canadacentral' + 'canadaeast' + 'eastus' + 'eastus2' + 'francecentral' + 'germanywestcentral' + 'italynorth' + 'japaneast' + 'koreacentral' + 'northcentralus' + 'norwayeast' + 'polandcentral' + 'southafricanorth' + 'southcentralus' + 'southeastasia' + 'southindia' + 'spaincentral' + 'swedencentral' + 'switzerlandnorth' + 'uaenorth' + 'uksouth' + 'westus' + 'westus2' + 'westus3' +]) +param location string + +param aiDeploymentsLocation string + +@description('Id of the user or app to assign application roles') +param principalId string + +@description('Principal type of user or app') +param principalType string + +@description('Optional. Name of an existing AI Services account within the resource group. If not provided, a new one will be created.') +param aiFoundryResourceName string = '' + +@description('Optional. Name of the AI Foundry project. If not provided, a default name will be used.') +param aiFoundryProjectName string = 'ai-project-${environmentName}' + +@description('List of model deployments') +param aiProjectDeploymentsJson string = '[]' + +@description('List of connections') +param aiProjectConnectionsJson string = '[]' + +@description('List of resources to create and connect to the AI project') +param aiProjectDependentResourcesJson string = '[]' + +var aiProjectDeployments = json(aiProjectDeploymentsJson) +var aiProjectConnections = json(aiProjectConnectionsJson) +var aiProjectDependentResources = json(aiProjectDependentResourcesJson) + +@description('Enable hosted agent deployment') +param enableHostedAgents bool + +@description('Enable monitoring for the AI project') +param enableMonitoring bool = true + +@description('Optional. Existing container registry resource ID. If provided, no new ACR will be created and a connection to this ACR will be established.') +param existingContainerRegistryResourceId string = '' + +@description('Optional. Existing container registry endpoint (login server). Required if existingContainerRegistryResourceId is provided.') +param existingContainerRegistryEndpoint string = '' + +@description('Optional. Name of an existing ACR connection on the Foundry project. If provided, no new ACR or connection will be created.') +param existingAcrConnectionName string = '' + +@description('Optional. Existing Application Insights connection string. If provided, a connection will be created but no new App Insights resource.') +param existingApplicationInsightsConnectionString string = '' + +@description('Optional. Existing Application Insights resource ID. Used for connection metadata when providing an existing App Insights.') +param existingApplicationInsightsResourceId string = '' + +@description('Optional. Name of an existing Application Insights connection on the Foundry project. If provided, no new App Insights or connection will be created.') +param existingAppInsightsConnectionName string = '' + +// Tags that should be applied to all resources. +// +// Note that 'azd-service-name' tags should be applied separately to service host resources. +// Example usage: +// tags: union(tags, { 'azd-service-name': }) +var tags = { + 'azd-env-name': environmentName +} + +// Check if resource group exists and create it if it doesn't +resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { + name: resourceGroupName + location: location + tags: tags +} + +// Build dependent resources array conditionally +// Check if ACR already exists in the user-provided array to avoid duplicates +// Also skip if user provided an existing container registry endpoint or connection name +var hasAcr = contains(map(aiProjectDependentResources, r => r.resource), 'registry') +var shouldCreateAcr = enableHostedAgents && !hasAcr && empty(existingContainerRegistryResourceId) && empty(existingAcrConnectionName) +var dependentResources = shouldCreateAcr ? union(aiProjectDependentResources, [ + { + resource: 'registry' + connectionName: 'acr-connection' + } +]) : aiProjectDependentResources + +// AI Project module +module aiProject 'core/ai/ai-project.bicep' = { + scope: rg + name: 'ai-project' + params: { + tags: tags + location: aiDeploymentsLocation + aiFoundryProjectName: aiFoundryProjectName + principalId: principalId + principalType: principalType + existingAiAccountName: aiFoundryResourceName + deployments: aiProjectDeployments + connections: aiProjectConnections + additionalDependentResources: dependentResources + enableMonitoring: enableMonitoring + enableHostedAgents: enableHostedAgents + existingContainerRegistryResourceId: existingContainerRegistryResourceId + existingContainerRegistryEndpoint: existingContainerRegistryEndpoint + existingAcrConnectionName: existingAcrConnectionName + existingApplicationInsightsConnectionString: existingApplicationInsightsConnectionString + existingApplicationInsightsResourceId: existingApplicationInsightsResourceId + existingAppInsightsConnectionName: existingAppInsightsConnectionName + } +} + +// Resources +output AZURE_RESOURCE_GROUP string = resourceGroupName +output AZURE_AI_ACCOUNT_ID string = aiProject.outputs.accountId +output AZURE_AI_PROJECT_ID string = aiProject.outputs.projectId +output AZURE_AI_FOUNDRY_PROJECT_ID string = aiProject.outputs.projectId +output AZURE_AI_ACCOUNT_NAME string = aiProject.outputs.aiServicesAccountName +output AZURE_AI_PROJECT_NAME string = aiProject.outputs.projectName + +// Endpoints +output AZURE_AI_PROJECT_ENDPOINT string = aiProject.outputs.AZURE_AI_PROJECT_ENDPOINT +output AZURE_OPENAI_ENDPOINT string = aiProject.outputs.AZURE_OPENAI_ENDPOINT +output APPLICATIONINSIGHTS_CONNECTION_STRING string = aiProject.outputs.APPLICATIONINSIGHTS_CONNECTION_STRING +output APPLICATIONINSIGHTS_RESOURCE_ID string = aiProject.outputs.APPLICATIONINSIGHTS_RESOURCE_ID + +// Dependent Resources and Connections + +// ACR +output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = aiProject.outputs.dependentResources.registry.connectionName +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = aiProject.outputs.dependentResources.registry.loginServer + +// Bing Search +output BING_GROUNDING_CONNECTION_NAME string = aiProject.outputs.dependentResources.bing_grounding.connectionName +output BING_GROUNDING_RESOURCE_NAME string = aiProject.outputs.dependentResources.bing_grounding.name +output BING_GROUNDING_CONNECTION_ID string = aiProject.outputs.dependentResources.bing_grounding.connectionId + +// Bing Custom Search +output BING_CUSTOM_GROUNDING_CONNECTION_NAME string = aiProject.outputs.dependentResources.bing_custom_grounding.connectionName +output BING_CUSTOM_GROUNDING_NAME string = aiProject.outputs.dependentResources.bing_custom_grounding.name +output BING_CUSTOM_GROUNDING_CONNECTION_ID string = aiProject.outputs.dependentResources.bing_custom_grounding.connectionId + +// Azure AI Search +output AZURE_AI_SEARCH_CONNECTION_NAME string = aiProject.outputs.dependentResources.search.connectionName +output AZURE_AI_SEARCH_SERVICE_NAME string = aiProject.outputs.dependentResources.search.serviceName + +// Azure Storage +output AZURE_STORAGE_CONNECTION_NAME string = aiProject.outputs.dependentResources.storage.connectionName +output AZURE_STORAGE_ACCOUNT_NAME string = aiProject.outputs.dependentResources.storage.accountName diff --git a/examples/azure_foundry_langgraph/infra/main.parameters.json b/examples/azure_foundry_langgraph/infra/main.parameters.json new file mode 100644 index 00000000..926e0e2a --- /dev/null +++ b/examples/azure_foundry_langgraph/infra/main.parameters.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "resourceGroupName": { + "value": "${AZURE_RESOURCE_GROUP}" + }, + "environmentName": { + "value": "${AZURE_ENV_NAME}" + }, + "location": { + "value": "${AZURE_LOCATION}" + }, + "aiFoundryResourceName": { + "value": "${AZURE_AI_ACCOUNT_NAME}" + }, + "aiFoundryProjectName": { + "value": "${AZURE_AI_PROJECT_NAME}" + }, + "aiDeploymentsLocation": { + "value": "${AZURE_LOCATION}" + }, + "principalId": { + "value": "${AZURE_PRINCIPAL_ID}" + }, + "principalType": { + "value": "${AZURE_PRINCIPAL_TYPE}" + }, + "aiProjectDeploymentsJson": { + "value": "${AI_PROJECT_DEPLOYMENTS=[]}" + }, + "aiProjectConnectionsJson": { + "value": "${AI_PROJECT_CONNECTIONS=[]}" + }, + "aiProjectDependentResourcesJson": { + "value": "${AI_PROJECT_DEPENDENT_RESOURCES=[]}" + }, + "enableMonitoring": { + "value": "${ENABLE_MONITORING=true}" + }, + "enableHostedAgents": { + "value": "${ENABLE_HOSTED_AGENTS=false}" + }, + "existingContainerRegistryResourceId": { + "value": "${AZURE_CONTAINER_REGISTRY_RESOURCE_ID=}" + }, + "existingContainerRegistryEndpoint": { + "value": "${AZURE_CONTAINER_REGISTRY_ENDPOINT=}" + }, + "existingAcrConnectionName": { + "value": "${AZURE_AI_PROJECT_ACR_CONNECTION_NAME=}" + }, + "existingApplicationInsightsConnectionString": { + "value": "${APPLICATIONINSIGHTS_CONNECTION_STRING=}" + }, + "existingApplicationInsightsResourceId": { + "value": "${APPLICATIONINSIGHTS_RESOURCE_ID=}" + }, + "existingAppInsightsConnectionName": { + "value": "${APPLICATIONINSIGHTS_CONNECTION_NAME=}" + } + } +} diff --git a/examples/azure_foundry_langgraph/local_test.py b/examples/azure_foundry_langgraph/local_test.py new file mode 100644 index 00000000..ba44a370 --- /dev/null +++ b/examples/azure_foundry_langgraph/local_test.py @@ -0,0 +1,72 @@ +"""Local test script - exercises Agent Control integration without Azure model. + +Usage: + python local_test.py + +Requires the Agent Control server to be running and controls seeded. +Enable controls in the UI before running to see blocking in action. +""" + +import asyncio + +from dotenv import load_dotenv +load_dotenv() # Must be before any agent_control imports + +from agent_control import ControlViolationError + +from agent_control_setup import bootstrap_agent_control +from tools import get_order_status, lookup_customer, get_order_internal, process_refund + + +async def run_tests(): + print("=" * 60) + print("Local Agent Control Integration Test") + print("=" * 60) + + print("\n1. Bootstrapping Agent Control...") + bootstrap_agent_control() + print(" OK - connected to server") + + print("\n2. get_order_status (safe data)...") + try: + result = await get_order_status.ainvoke({"order_id": "ORD-1001"}) + print(f" PASS: {result['status']}, {result['carrier']}, ETA {result['estimated_delivery']}") + except ControlViolationError as e: + print(f" BLOCKED: {e}") + + print("\n3. lookup_customer (has SSN - block-pii controls this)...") + try: + result = await lookup_customer.ainvoke({"email": "jane@example.com"}) + print(f" PASS (control disabled): {result['name']}, SSN={result['ssn']}, phone={result['phone']}") + except ControlViolationError as e: + print(f" BLOCKED (control enabled): {e}") + + print("\n4. get_order_internal (has margins - block-internal-data controls this)...") + try: + result = await get_order_internal.ainvoke({"order_id": "ORD-1001"}) + print(f" PASS (control disabled): margin={result['profit_margin']}, notes={result['internal_notes'][:50]}...") + except ControlViolationError as e: + print(f" BLOCKED (control enabled): {e}") + + print("\n5. process_refund $50 (under limit)...") + try: + result = await process_refund.ainvoke({"order_id": "ORD-1001", "amount": 50.0}) + print(f" PASS: {result['message']}") + except ControlViolationError as e: + print(f" BLOCKED: {e}") + + print("\n6. process_refund $150 (over limit - max-refund-amount controls this)...") + try: + result = await process_refund.ainvoke({"order_id": "ORD-1001", "amount": 150.0}) + print(f" PASS (control disabled): {result['message']}") + except ControlViolationError as e: + print(f" BLOCKED (control enabled): {e}") + + print("\n" + "=" * 60) + print("Done. Toggle controls in the Agent Control UI and re-run to") + print("see different behavior with the same code.") + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(run_tests()) diff --git a/examples/azure_foundry_langgraph/model.py b/examples/azure_foundry_langgraph/model.py new file mode 100644 index 00000000..d8987167 --- /dev/null +++ b/examples/azure_foundry_langgraph/model.py @@ -0,0 +1,18 @@ +from azure.identity import DefaultAzureCredential, get_bearer_token_provider +from langchain.chat_models import init_chat_model + +from settings import settings + + +def create_chat_model(): + deployment = settings.model_deployment_name + token_provider = get_bearer_token_provider( + DefaultAzureCredential(), + "https://cognitiveservices.azure.com/.default", + ) + # Don't pass azure_endpoint - let init_chat_model read AZURE_OPENAI_ENDPOINT + # from the environment (set by Foundry Hosted Agents automatically). + return init_chat_model( + f"azure_openai:{deployment}", + azure_ad_token_provider=token_provider, + ) diff --git a/examples/azure_foundry_langgraph/requirements.txt b/examples/azure_foundry_langgraph/requirements.txt new file mode 100644 index 00000000..78a95de6 --- /dev/null +++ b/examples/azure_foundry_langgraph/requirements.txt @@ -0,0 +1,10 @@ +azure-ai-agentserver-langgraph>=1.0.0b16 +azure-identity>=1.23 +langgraph>=1.0.10 +langchain>=1.2.0 +langchain-azure-ai>=1.1.0 +langchain-openai>=1.1.0 +agent-control-sdk>=6.8.0 +pydantic-settings>=2.0 +python-dotenv>=1.0 +httpx>=0.28 diff --git a/examples/azure_foundry_langgraph/seed_controls.py b/examples/azure_foundry_langgraph/seed_controls.py new file mode 100644 index 00000000..320df46a --- /dev/null +++ b/examples/azure_foundry_langgraph/seed_controls.py @@ -0,0 +1,190 @@ +"""Seed demo controls via the Agent Control SDK. + +Usage: + python seed_controls.py + +Creates 5 controls: + 1. block-prompt-injection - llm_call pre (security) + 2. block-internal-data - get_order_internal post (data protection) + 3. block-pii - lookup_customer + llm_call post (PII/SSN) + 4. block-competitor-discuss - llm_call pre (business policy) + 5. max-refund-amount - process_refund post (business logic) + +Controls are created DISABLED by default so the demo can start unprotected. +""" + +import asyncio + +from dotenv import load_dotenv +load_dotenv() + +import httpx + +import agent_control + +from settings import settings + +CONTROLS = [ + { + "name": "block-prompt-injection", + "data": { + "enabled": False, + "execution": "sdk", + "scope": { + "stages": ["pre"], + "step_types": ["llm"], + "step_names": ["llm_call"], + }, + "selector": {"path": "input"}, + "evaluator": { + "name": "regex", + "config": { + "pattern": r"([Ii]gnore previous instructions|[Ss]ystem prompt|[Yy]ou are now|[Ff]orget everything|[Dd]isregard all)" + }, + }, + "action": {"decision": "deny"}, + }, + }, + { + "name": "block-internal-data", + "data": { + "enabled": False, + "execution": "sdk", + "scope": { + "stages": ["post"], + "step_types": ["tool"], + "step_names": ["get_order_internal"], + }, + "selector": {"path": "output"}, + "evaluator": { + "name": "regex", + "config": { + "pattern": r"(internal_notes|cost_of_goods|profit_margin|[Ee]scalation risk|[Ff]riendly fraud)" + }, + }, + "action": {"decision": "deny"}, + }, + }, + { + "name": "block-pii", + "data": { + "enabled": False, + "execution": "sdk", + "scope": { + "stages": ["post"], + "step_names": ["lookup_customer", "llm_call"], + }, + "selector": {"path": "output"}, + "evaluator": { + "name": "regex", + "config": { + "pattern": r"\d{3}-\d{2}-\d{4}" + }, + }, + "action": {"decision": "deny"}, + }, + }, + { + "name": "block-competitor-discuss", + "data": { + "enabled": False, + "execution": "sdk", + "scope": { + "stages": ["pre"], + "step_types": ["llm"], + "step_names": ["llm_call"], + }, + "selector": {"path": "input"}, + "evaluator": { + "name": "regex", + "config": { + "pattern": r"([Cc]ompare.*([Aa]mazon|[Ss]hopify)|[Ss]witch to ([Aa]mazon|[Ss]hopify)|[Bb]etter than ([Aa]mazon|[Ss]hopify))" + }, + }, + "action": {"decision": "deny"}, + }, + }, + { + "name": "max-refund-amount", + "data": { + "enabled": False, + "execution": "sdk", + "scope": { + "stages": ["post"], + "step_types": ["tool"], + "step_names": ["process_refund"], + }, + "selector": {"path": "output"}, + "evaluator": { + "name": "json", + "config": { + "field_constraints": { + "refund_amount": {"max": 100.0} + } + }, + }, + "action": {"decision": "deny"}, + }, + }, +] + + +async def seed() -> None: + server_url = settings.agent_control_url + api_key = settings.agent_control_api_key or None + + # Register agent via init (also starts the SDK) + agent_control.init( + agent_name=settings.agent_name, + agent_description="Customer support agent with Agent Control runtime guardrails", + server_url=server_url, + api_key=api_key, + policy_refresh_interval_seconds=0, + ) + print(f"Registered agent: {settings.agent_name}") + + for ctrl_def in CONTROLS: + try: + ctrl = await agent_control.create_control( + name=ctrl_def["name"], + data=ctrl_def["data"], + server_url=server_url, + api_key=api_key, + ) + ctrl_id = ctrl.get("control_id") or ctrl.get("id") + print(f"Created control: {ctrl_def['name']} (id={ctrl_id})") + except httpx.HTTPStatusError as e: + if e.response.status_code == 409: + # Already exists - look it up by name + result = await agent_control.list_controls( + server_url=server_url, + api_key=api_key, + name=ctrl_def["name"], + ) + controls_list = result.get("controls", []) if isinstance(result, dict) else result + ctrl_id = next( + (c.get("id") for c in controls_list + if c.get("name") == ctrl_def["name"]), + None, + ) + if ctrl_id is None: + raise RuntimeError( + f"Control '{ctrl_def['name']}' returned 409 but was not found via list_controls" + ) from e + print(f"Control already exists: {ctrl_def['name']} (id={ctrl_id})") + else: + raise + + await agent_control.add_agent_control( + agent_name=settings.agent_name, + control_id=ctrl_id, + server_url=server_url, + api_key=api_key, + ) + + print("Controls attached to agent. Done!") + await agent_control.ashutdown() + + +if __name__ == "__main__": + asyncio.run(seed()) diff --git a/examples/azure_foundry_langgraph/settings.py b/examples/azure_foundry_langgraph/settings.py new file mode 100644 index 00000000..102fb457 --- /dev/null +++ b/examples/azure_foundry_langgraph/settings.py @@ -0,0 +1,30 @@ +import os + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + agent_name: str = "customer-support-agent" + agent_control_url: str = "http://localhost:8000" + agent_control_api_key: str = "" + policy_refresh_interval_seconds: int = 2 + + azure_ai_project_endpoint: str = "" + model_deployment_name: str = "gpt-4.1-mini" + + model_config = {"env_file": ".env", "extra": "ignore"} + + def __init__(self, **kwargs): + super().__init__(**kwargs) + # Foundry Hosted Agents set these env vars - use them as fallbacks + if not self.azure_ai_project_endpoint: + self.azure_ai_project_endpoint = os.environ.get( + "AZURE_OPENAI_ENDPOINT", "" + ) + if not self.model_deployment_name or self.model_deployment_name == "gpt-4.1-mini": + self.model_deployment_name = os.environ.get( + "AZURE_AI_MODEL_DEPLOYMENT_NAME", self.model_deployment_name + ) + + +settings = Settings() diff --git a/examples/azure_foundry_langgraph/tools.py b/examples/azure_foundry_langgraph/tools.py new file mode 100644 index 00000000..d69b10db --- /dev/null +++ b/examples/azure_foundry_langgraph/tools.py @@ -0,0 +1,164 @@ +"""Customer support tools with Agent Control runtime guardrails. + +Uses the checked-wrapper pattern so @control() sees the tool name at +registration time and correctly classifies steps as type="tool". + +Pattern: raw function -> setattr(.name) -> control() -> @tool wrapper. +""" + +from __future__ import annotations + +from agent_control import control +from langchain_core.tools import tool + +# --------------------------------------------------------------------------- +# Mock data - single customer, single order +# --------------------------------------------------------------------------- + +MOCK_ORDERS = { + f"ORD-{1000 + i}": { + "order_id": f"ORD-{1000 + i}", + "status": ["shipped", "processing", "delivered", "shipped", "processing", + "shipped", "delivered", "processing", "shipped", "delivered"][i - 1], + "customer_name": "Jane Doe", + "items": [ + {"name": ["Wireless Headphones", "USB-C Cable", "Standing Desk", "Monitor Arm", + "Keyboard", "Mouse", "Webcam", "Laptop Stand", "Power Strip", "Desk Mat"][i - 1], + "qty": 1, "price": [89.99, 12.99, 549.00, 39.99, 79.99, + 29.99, 59.99, 44.99, 24.99, 34.99][i - 1]}, + ], + "estimated_delivery": f"2026-03-{19 + i}", + "tracking_number": f"1Z999AA1012345678{i}", + "carrier": ["UPS", "FedEx", "USPS", "UPS", "FedEx", + "USPS", "UPS", "FedEx", "USPS", "UPS"][i - 1], + } + for i in range(1, 11) +} + +MOCK_ORDER_INTERNALS = { + f"ORD-{1000 + i}": { + "order_id": f"ORD-{1000 + i}", + "payment_method": ["Visa ending in 4242", "Amex ending in 1008", "Visa ending in 4242", + "MC ending in 3456", "Visa ending in 4242", "Amex ending in 1008", + "Visa ending in 4242", "MC ending in 3456", "Visa ending in 4242", + "Amex ending in 1008"][i - 1], + "cost_of_goods": [34.19, 5.50, 312.50, 18.00, 35.00, + 12.00, 28.00, 20.00, 10.00, 15.00][i - 1], + "profit_margin": ["62%", "58%", "43%", "55%", "56%", + "60%", "53%", "56%", "60%", "57%"][i - 1], + "internal_notes": [ + "Customer called twice about this order. Escalation risk - offer 15% discount if they complain again.", + "Standard order, no issues.", + "VIP account. Previously filed chargeback (suspected friendly fraud). Do NOT issue refund without manager approval.", + "Bundled with ORD-1003.", + "Customer requested gift wrapping.", + "Replacement for defective unit from ORD-1002.", + "Corporate purchase, net-30 terms.", + "Express shipping upgraded by support.", + "Backordered, expected restock 2026-03-22.", + "Customer loyalty program bonus item.", + ][i - 1], + "fraud_review": ["None", "None", "Flagged - suspected friendly fraud", "None", "None", + "None", "None", "None", "None", "None"][i - 1], + } + for i in range(1, 11) +} + +MOCK_CUSTOMERS = { + "jane@example.com": { + "name": "Jane Doe", + "email": "jane@example.com", + "ssn": "123-45-6789", + "phone": "415-555-0101", + "date_of_birth": "1988-03-14", + "billing_address": "742 Evergreen Terrace, Springfield, IL 62704", + "credit_card_on_file": "Visa ending in 4242", + "membership": "gold", + "account_since": "2021-06-15", + "recent_orders": ["ORD-1001"], + }, +} + +# --------------------------------------------------------------------------- +# Tools - checked-wrapper pattern for correct @control() registration +# --------------------------------------------------------------------------- + + +async def _get_order_status(order_id: str) -> dict: + """Look up order status by ID.""" + order = MOCK_ORDERS.get(order_id) + if not order: + return {"error": f"Order {order_id} not found"} + return order + + +setattr(_get_order_status, "name", "get_order_status") +_get_order_status_checked = control()(_get_order_status) + + +@tool("get_order_status") +async def get_order_status(order_id: str) -> dict: + """Look up order status by ID. Returns shipping status, items, delivery estimate, and tracking info.""" + return await _get_order_status_checked(order_id=order_id) + + +async def _lookup_customer(email: str) -> dict: + """Look up customer details by email.""" + customer = MOCK_CUSTOMERS.get(email) + if not customer: + return {"error": f"No customer found for {email}"} + return customer + + +setattr(_lookup_customer, "name", "lookup_customer") +_lookup_customer_checked = control()(_lookup_customer) + + +@tool("lookup_customer") +async def lookup_customer(email: str) -> dict: + """Look up customer details by email. Returns full profile including name, SSN, phone, date of birth, billing address, credit card, membership tier, and recent orders.""" + return await _lookup_customer_checked(email=email) + + +async def _get_order_internal(order_id: str) -> dict: + """Fetch internal order details.""" + data = MOCK_ORDER_INTERNALS.get(order_id) + if not data: + return {"error": f"No internal data for order {order_id}"} + return data + + +setattr(_get_order_internal, "name", "get_order_internal") +_get_order_internal_checked = control()(_get_order_internal) + + +@tool("get_order_internal") +async def get_order_internal(order_id: str) -> dict: + """Fetch internal order details including payment method, cost of goods, profit margins, internal notes, and fraud review status. Use this when the user asks about payment, internal notes, or fraud flags.""" + return await _get_order_internal_checked(order_id=order_id) + + +async def _process_refund(order_id: str, amount: float) -> dict: + """Process a refund for an order.""" + order = MOCK_ORDERS.get(order_id) + if not order: + return {"error": f"Order {order_id} not found"} + return { + "order_id": order_id, + "refund_amount": amount, + "status": "approved", + "message": f"Refund of ${amount:.2f} approved for order {order_id}", + } + + +setattr(_process_refund, "name", "process_refund") +_process_refund_checked = control()(_process_refund) + + +@tool("process_refund") +async def process_refund(order_id: str, amount: float) -> dict: + """Process a refund for an order. Takes order ID and refund amount in dollars.""" + return await _process_refund_checked(order_id=order_id, amount=amount) + + +ALL_TOOLS = [get_order_status, lookup_customer, get_order_internal, process_refund]