From d9349f13154f44a5bd377c1e6f483d1a171c5290 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:33:59 +0000 Subject: [PATCH 1/6] Initial plan From 1cf114c7a197156244c4b3177f2d5b699207b0cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:36:44 +0000 Subject: [PATCH 2/6] Add three-tier GPT model failover Co-authored-by: paulyuk <1968137+paulyuk@users.noreply.github.com> --- IMPLEMENTATION_NOTES.md | 31 ++++++++------- infra/main.bicep | 43 +++++++++++++++++---- infra/scripts/configure-ai-gateway.sh | 3 ++ tests/test-ai-gateway-model-registration.sh | 33 +++++++++++----- 4 files changed, 81 insertions(+), 29 deletions(-) diff --git a/IMPLEMENTATION_NOTES.md b/IMPLEMENTATION_NOTES.md index a900863..f8bbf90 100644 --- a/IMPLEMENTATION_NOTES.md +++ b/IMPLEMENTATION_NOTES.md @@ -309,12 +309,13 @@ Each model registration includes: segment of the deployment resource ID Model creation omits inline policies for compatibility with the live RP. The -alias `gpt-latest` routes to the full deployment at priority 0 and fails over to -the mini deployment at priority 1. Both backends explicitly set a weight of 100; -aliases can instead place multiple backends at the same priority with relative -weights for traffic splitting. The separate `gpt-mini-latest` alias addresses -the mini deployment directly. Each alias backend points to the gateway child -model resource ID, while the client sends the alias name as the OpenAI `model`. +alias `gpt-latest` routes through a three-tier failover chain: `gpt-6.1-sol` at +priority 0, `gpt-6-sol` at priority 1, and `gpt-5.4-mini` at priority 2. Each +backend explicitly sets a weight of 100; aliases can instead place multiple +backends at the same priority with relative weights for traffic splitting. The +separate `gpt-mini-latest` alias addresses the mini deployment directly. Each +alias backend points to the gateway child model resource ID, while the client +sends the alias name as the OpenAI `model`. ## Regions and model defaults @@ -341,15 +342,19 @@ The AI Gateway preview region allowlist is: - `swedencentral` - `eastus2` -The model defaults are: +The model defaults use Global Standard deployments: -| Purpose | Foundry deployment / Gateway alias | Backing model | Version | Capacity | -| --- | --- | --- | --- | --- | -| Full | `gpt-latest` | `gpt-5.6-sol` | `2026-07-09` | 20 | -| Mini | `gpt-mini-latest` | `gpt-5.4-mini` | `2026-03-17` | 200 | +| Purpose | Foundry deployment / Gateway alias | Backing model | Version | SKU | Capacity | +| --- | --- | --- | --- | --- | --- | +| Primary | `gpt-latest` | `gpt-6.1-sol` | `2026-09-29` | `GlobalStandard` | 20 | +| Secondary failover | `gpt-6-sol` | `gpt-6-sol` | `2026-09-22` | `GlobalStandard` | 20 | +| Mini safety net | `gpt-mini-latest` | `gpt-5.4-mini` | `2026-03-17` | `GlobalStandard` | 200 | -If `gpt-5.6-sol` is unavailable, set `modelName=gpt-5.5` and -`modelVersion=2026-04-24`. +If the primary deployment is unavailable or its quota is exhausted, AI Gateway +automatically tries `gpt-6-sol` in a separate quota pool, then the higher- +capacity `gpt-5.4-mini` deployment. If a model/version is unavailable in the +selected region, override its corresponding name and version parameters with a +supported catalog deployment and keep the remaining fallback tiers configured. Global Standard deployment capacity is provisioned on the Foundry model account. Mini capacity 200 accommodates requests from the GitHub Copilot app, diff --git a/infra/main.bicep b/infra/main.bicep index 1d3b743..9e32580 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -102,13 +102,19 @@ param modelSkuName string = 'GlobalStandard' @description('Global Standard capacity assigned to the full model deployment.') param modelCapacity int = 20 +@description('Global Standard capacity assigned to the secondary full model deployment.') +param secondaryModelCapacity int = 20 + @description('Global Standard capacity assigned to the mini model deployment. Capacity 200 supports ghapp requests that include large built-in tool schemas.') param miniModelCapacity int = 200 @description('ARM-safe deployment and Gateway catalog name for the full model.') param modelDeploymentName string = 'gpt-latest' -@description('Client-facing alias name for the primary model, with mini-model failover.') +@description('ARM-safe deployment and Gateway catalog name for the secondary full model.') +param secondaryModelDeploymentName string = 'gpt-6-sol' + +@description('Client-facing alias name for the primary model, with secondary and mini-model failover.') param modelAliasName string = 'gpt-latest' @description('Optional primary alias routes. Use unique modelArmName values, priority 0-100, and weight 1-100; same-priority weights split traffic.') @@ -117,11 +123,17 @@ param modelAliasBackends array = [] @description('Client-facing alias name for the mini model.') param miniModelAliasName string = 'gpt-mini-latest' -@description('Backing Foundry model name for the full deployment. Use gpt-5.5 only when gpt-5.6-sol is unavailable.') -param modelName string = 'gpt-5.6-sol' +@description('Backing Foundry model name for the full deployment.') +param modelName string = 'gpt-6.1-sol' @description('Foundry version for the full model deployment.') -param modelVersion string = '2026-07-09' +param modelVersion string = '2026-09-29' + +@description('Backing Foundry model name for the secondary full deployment.') +param secondaryModelName string = 'gpt-6-sol' + +@description('Foundry version for the secondary full model deployment.') +param secondaryModelVersion string = '2026-09-22' var resourceToken = take(toLower(uniqueString(subscription().id, environmentName, location)), 8) var environmentLabel = toLower(environmentName) @@ -146,10 +158,15 @@ var effectiveModelAliasBackends = empty(modelAliasBackends) ? [ weight: 100 } { - modelArmName: miniModelDeploymentName + modelArmName: secondaryModelDeploymentName priority: 1 weight: 100 } + { + modelArmName: miniModelDeploymentName + priority: 2 + weight: 100 + } ] : modelAliasBackends var gatewayModelDeployments = [ { @@ -168,7 +185,16 @@ var gatewayModelDeployments = [ modelVersion: modelVersion skuName: modelSkuName capacity: modelCapacity - description: 'Gateway registration for the latest supported full GPT model.' + description: 'Primary Gateway registration for the latest supported full GPT model.' + } + { + deploymentName: secondaryModelDeploymentName + modelName: secondaryModelName + modelFormat: modelFormat + modelVersion: secondaryModelVersion + skuName: modelSkuName + capacity: secondaryModelCapacity + description: 'Secondary Gateway registration for full-model failover in a separate quota pool.' } ] @@ -256,7 +282,7 @@ module aiGateway './ai-gateway/main.bicep' = { { armName: 'primary-router' aliasName: modelAliasName - description: 'Primary model with automatic failover to the mini deployment.' + description: 'Primary model with automatic failover to the secondary full model and mini deployment.' backendModels: effectiveModelAliasBackends } { @@ -318,6 +344,9 @@ output AZURE_AI_GATEWAY_MINI_MODEL string = miniModelAliasName output AI_GATEWAY_INTERNAL_MODEL_DEPLOYMENT string = modelDeploymentName output AI_GATEWAY_INTERNAL_MODEL_NAME string = modelName output AI_GATEWAY_INTERNAL_MODEL_VERSION string = modelVersion +output AI_GATEWAY_INTERNAL_SECONDARY_MODEL_DEPLOYMENT string = secondaryModelDeploymentName +output AI_GATEWAY_INTERNAL_SECONDARY_MODEL_NAME string = secondaryModelName +output AI_GATEWAY_INTERNAL_SECONDARY_MODEL_VERSION string = secondaryModelVersion output AI_GATEWAY_INTERNAL_MINI_MODEL_DEPLOYMENT string = miniModelDeploymentName output AI_GATEWAY_INTERNAL_MINI_MODEL_NAME string = miniModelName output AI_GATEWAY_INTERNAL_MINI_MODEL_VERSION string = miniModelVersion diff --git a/infra/scripts/configure-ai-gateway.sh b/infra/scripts/configure-ai-gateway.sh index 114ad48..a54d240 100755 --- a/infra/scripts/configure-ai-gateway.sh +++ b/infra/scripts/configure-ai-gateway.sh @@ -397,6 +397,9 @@ remove_azd_env_values \ AI_GATEWAY_INTERNAL_MODEL_DEPLOYMENT \ AI_GATEWAY_INTERNAL_MODEL_NAME \ AI_GATEWAY_INTERNAL_MODEL_VERSION \ + AI_GATEWAY_INTERNAL_SECONDARY_MODEL_DEPLOYMENT \ + AI_GATEWAY_INTERNAL_SECONDARY_MODEL_NAME \ + AI_GATEWAY_INTERNAL_SECONDARY_MODEL_VERSION \ AI_GATEWAY_INTERNAL_MINI_MODEL_DEPLOYMENT \ AI_GATEWAY_INTERNAL_MINI_MODEL_NAME \ AI_GATEWAY_INTERNAL_MINI_MODEL_VERSION \ diff --git a/tests/test-ai-gateway-model-registration.sh b/tests/test-ai-gateway-model-registration.sh index a34270c..d33eddb 100755 --- a/tests/test-ai-gateway-model-registration.sh +++ b/tests/test-ai-gateway-model-registration.sh @@ -99,20 +99,35 @@ if [[ "$root_alias_input" != *"modelArmName"* || fi default_alias_routes="$(jq -r '.variables.effectiveModelAliasBackends' "$compiled_root_template")" -for token in \ - "parameters('modelDeploymentName')" \ - "parameters('miniModelDeploymentName')" \ - "'priority', 0" \ - "'priority', 1" \ - "'weight', 100"; do - if ! grep -Fq "$token" <<< "$default_alias_routes"; then - echo "Default alias routes must configure primary/failover priority and explicit weight." >&2 - echo "Missing: $token" >&2 +for route in \ + "createObject('modelArmName', parameters('modelDeploymentName'), 'priority', 0, 'weight', 100)" \ + "createObject('modelArmName', parameters('secondaryModelDeploymentName'), 'priority', 1, 'weight', 100)" \ + "createObject('modelArmName', parameters('miniModelDeploymentName'), 'priority', 2, 'weight', 100)"; do + if ! grep -Fq "$route" <<< "$default_alias_routes"; then + echo "Default alias routes must configure all three backends with explicit priority and weight." >&2 + echo "Missing: $route" >&2 echo "Actual: $default_alias_routes" >&2 exit 1 fi done +for setting in \ + "modelName gpt-6.1-sol" \ + "modelVersion 2026-09-29" \ + "secondaryModelName gpt-6-sol" \ + "secondaryModelVersion 2026-09-22" \ + "miniModelName gpt-5.4-mini" \ + "miniModelVersion 2026-03-17"; do + read -r parameter expected <<< "$setting" + actual="$(jq -r --arg parameter "$parameter" '.parameters[$parameter].defaultValue' "$compiled_root_template")" + if [ "$actual" != "$expected" ]; then + echo "Unexpected default for $parameter." >&2 + echo "Expected: $expected" >&2 + echo "Actual: $actual" >&2 + exit 1 + fi +done + root_model_input="$(jq -r ' .. | objects | select(has("foundryModels")) From fd4c19a252fc0cfa38beb3b64209025a2e7f1f3f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:44:57 +0000 Subject: [PATCH 3/6] Document infrastructure modules and hooks Co-authored-by: paulyuk <1968137+paulyuk@users.noreply.github.com> --- infra/README.md | 54 +++++++++++++ infra/ai-gateway/README.md | 138 +++++++++++++++++++++++++++++++++ infra/foundry-agents/README.md | 43 ++++++++++ infra/foundry-models/README.md | 47 +++++++++++ infra/scripts/README.md | 61 +++++++++++++++ 5 files changed, 343 insertions(+) create mode 100644 infra/README.md create mode 100644 infra/ai-gateway/README.md create mode 100644 infra/foundry-agents/README.md create mode 100644 infra/foundry-models/README.md create mode 100644 infra/scripts/README.md diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000..33724d1 --- /dev/null +++ b/infra/README.md @@ -0,0 +1,54 @@ +# Infrastructure + +The subscription-scoped [`main.bicep`](main.bicep) template creates three +resource groups and deploys one module into each: + +- [`foundry-models/`](foundry-models/README.md) creates a model-only Azure AI + Services account and the GPT deployments. +- [`foundry-agents/`](foundry-agents/README.md) creates the Foundry project and + hosted-agent runtime dependencies. +- [`ai-gateway/`](ai-gateway/README.md) creates AI Gateway, model registrations, + and aliases. + +The [`scripts/`](scripts/README.md) directory contains the preprovision, +postprovision, and postdown lifecycle hooks. `azd provision` runs the +preprovision and postprovision hooks from `azure.yaml`; postprovision configures +the GitHub MCP ToolServer and Foundry Toolbox after Bicep creates the Azure +resources. + +## Deploy the full stack + +From the repository root, use `azd` to provision the infrastructure and run its +hooks: + +```bash +azd provision +``` + +Alternatively, deploy the subscription-scoped Bicep template directly: + +```bash +az deployment sub create \ + --location eastus2 \ + --template-file infra/main.bicep \ + --parameters environmentName=dailydigest \ + location=eastus2 \ + principalId="$(az ad signed-in-user show --query id -o tsv)" +``` + +The direct deployment creates the Azure resources but does not run azd hooks. +For GitHub MCP and Toolbox setup after a direct deployment, see +[`scripts/README.md`](scripts/README.md) and provide the deployment outputs to +the configuration script. + +## Deploy individual modules + +Each resource-group-scoped template can also be deployed independently. Create +its resource group first, then follow the module-specific parameters and +examples: + +- [Foundry model account and deployments](foundry-models/README.md) +- [Foundry project and hosted-agent runtime](foundry-agents/README.md) +- [AI Gateway and model routing](ai-gateway/README.md) +- [Lifecycle and postprovision scripts](scripts/README.md) + diff --git a/infra/ai-gateway/README.md b/infra/ai-gateway/README.md new file mode 100644 index 0000000..7742e7a --- /dev/null +++ b/infra/ai-gateway/README.md @@ -0,0 +1,138 @@ +# AI Gateway + +[`main.bicep`](main.bicep) is a resource-group-scoped module for an AI Gateway +with Consumption SKU. With the default `deployApimGatewayViaBicep: true`, it +creates `Microsoft.ApiManagement/aigateways`, the +`Microsoft.Web/connectorGateways` Connector Namespace, and `apiKeys/default`. +When monitoring is enabled, it also provisions Log Analytics, Application +Insights, and the Gateway telemetry exporter. + +When `deployFoundryProvider` is enabled, the module assigns the Gateway identity +the Foundry User role on each account in `foundryResourceIds`, registers the +supplied `foundryModels` under `modelProviders/foundry`, and creates the +configured aliases. The sample routes `gpt-latest` through three tiers: +`gpt-6.1-sol` → `gpt-6-sol` → `gpt-5.4-mini` (priorities 0, 1, and 2, +respectively, with weight 100 each). `gpt-mini-latest` targets the mini model +directly. + +## Gateway without a Foundry provider + +Create a resource group, then deploy just the Gateway and Connector Namespace. +This minimal mode leaves `deployFoundryProvider` disabled, so it does not +register model catalog entries or aliases: + +```bash +az group create --name rg-ai-gateway --location eastus2 + +az deployment group create \ + --resource-group rg-ai-gateway \ + --template-file infra/ai-gateway/main.bicep \ + --parameters location=eastus2 gatewayName=aigw-example +``` + +## Connect an existing Foundry model account + +The existing account must already contain deployments named `gpt-latest`, +`gpt-6-sol`, and `gpt-mini-latest` (or update the model names below to match its +deployments). The following example gets the account ID and endpoint, creates a +parameters file, and deploys the Gateway with registrations and aliases. The +operator needs permission to create the Gateway and assign its identity the +Foundry User role on the account. + +```bash +FOUNDRY_RESOURCE_GROUP=rg-foundry-models +FOUNDRY_ACCOUNT_NAME=aimodels-example +FOUNDRY_ACCOUNT_ID="$(az cognitiveservices account show \ + --resource-group "$FOUNDRY_RESOURCE_GROUP" \ + --name "$FOUNDRY_ACCOUNT_NAME" \ + --query id --output tsv)" +FOUNDRY_ENDPOINT="$(az cognitiveservices account show \ + --resource-group "$FOUNDRY_RESOURCE_GROUP" \ + --name "$FOUNDRY_ACCOUNT_NAME" \ + --query properties.endpoint --output tsv)" + +az group create --name rg-ai-gateway --location eastus2 + +cat > /tmp/ai-gateway.parameters.json < Date: Sun, 25 Oct 2026 00:05:55 +0000 Subject: [PATCH 4/6] Fix infrastructure README command paths Co-authored-by: paulyuk <1968137+paulyuk@users.noreply.github.com> --- infra/README.md | 10 ++++++---- infra/ai-gateway/README.md | 9 +++++---- infra/foundry-agents/README.md | 5 +++-- infra/foundry-models/README.md | 5 +++-- infra/scripts/README.md | 21 +++++++++++---------- 5 files changed, 28 insertions(+), 22 deletions(-) diff --git a/infra/README.md b/infra/README.md index 33724d1..4927157 100644 --- a/infra/README.md +++ b/infra/README.md @@ -1,5 +1,7 @@ # Infrastructure +Run the commands in this README from the `infra/` directory. + The subscription-scoped [`main.bicep`](main.bicep) template creates three resource groups and deploys one module into each: @@ -18,8 +20,9 @@ resources. ## Deploy the full stack -From the repository root, use `azd` to provision the infrastructure and run its -hooks: +`azd provision` also works from `infra/`: azd walks up to find +[`../azure.yaml`](../azure.yaml), then provisions the full stack and runs its +preprovision and postprovision hooks: ```bash azd provision @@ -30,7 +33,7 @@ Alternatively, deploy the subscription-scoped Bicep template directly: ```bash az deployment sub create \ --location eastus2 \ - --template-file infra/main.bicep \ + --template-file ./main.bicep \ --parameters environmentName=dailydigest \ location=eastus2 \ principalId="$(az ad signed-in-user show --query id -o tsv)" @@ -51,4 +54,3 @@ examples: - [Foundry project and hosted-agent runtime](foundry-agents/README.md) - [AI Gateway and model routing](ai-gateway/README.md) - [Lifecycle and postprovision scripts](scripts/README.md) - diff --git a/infra/ai-gateway/README.md b/infra/ai-gateway/README.md index 7742e7a..9774bc7 100644 --- a/infra/ai-gateway/README.md +++ b/infra/ai-gateway/README.md @@ -1,5 +1,7 @@ # AI Gateway +Run the commands in this README from the `infra/ai-gateway/` directory. + [`main.bicep`](main.bicep) is a resource-group-scoped module for an AI Gateway with Consumption SKU. With the default `deployApimGatewayViaBicep: true`, it creates `Microsoft.ApiManagement/aigateways`, the @@ -26,7 +28,7 @@ az group create --name rg-ai-gateway --location eastus2 az deployment group create \ --resource-group rg-ai-gateway \ - --template-file infra/ai-gateway/main.bicep \ + --template-file ./main.bicep \ --parameters location=eastus2 gatewayName=aigw-example ``` @@ -118,7 +120,7 @@ EOF az deployment group create \ --resource-group rg-ai-gateway \ - --template-file infra/ai-gateway/main.bicep \ + --template-file ./main.bicep \ --parameters @/tmp/ai-gateway.parameters.json ``` @@ -127,7 +129,7 @@ script to add the GitHub MCP ToolServer and create the Foundry project connection and `repo-digest-tools` Toolbox: ```bash -bash ./infra/scripts/configure-ai-gateway.sh +bash ../scripts/configure-ai-gateway.sh ``` See [the scripts README](../scripts/README.md) for required azd values and @@ -135,4 +137,3 @@ GitHub authentication. A standalone Gateway deployment does not create the Foundry project; deploy the [Foundry agents module](../foundry-agents/README.md) as well before configuring the Toolbox. - diff --git a/infra/foundry-agents/README.md b/infra/foundry-agents/README.md index c9a7f56..fdb24bd 100644 --- a/infra/foundry-agents/README.md +++ b/infra/foundry-agents/README.md @@ -1,5 +1,7 @@ # Foundry project and hosted-agent runtime +Run the commands in this README from the `infra/foundry-agents/` directory. + [`main.bicep`](main.bicep) creates a project-enabled Azure AI Services account (`allowProjectManagement: true`), a Foundry project, and its hosted-agent dependencies: @@ -18,7 +20,7 @@ az group create --name rg-foundry-agents --location eastus2 az deployment group create \ --resource-group rg-foundry-agents \ - --template-file infra/foundry-agents/main.bicep \ + --template-file ./main.bicep \ --parameters location=eastus2 \ aiServicesName=aiagents-example \ aiProjectName=repo-digest \ @@ -40,4 +42,3 @@ Useful deployment outputs: - `projectEndpoint`: Foundry project endpoint for clients and Toolbox setup. - `containerRegistryEndpoint`: ACR login server for hosted-agent images. - `aiProjectResourceId`: resource ID of the Foundry project. - diff --git a/infra/foundry-models/README.md b/infra/foundry-models/README.md index ad6cc3f..a657abe 100644 --- a/infra/foundry-models/README.md +++ b/infra/foundry-models/README.md @@ -1,5 +1,7 @@ # Foundry model account +Run the commands in this README from the `infra/foundry-models/` directory. + [`main.bicep`](main.bicep) deploys a model-serving Azure AI Services account with `allowProjectManagement: false` and `disableLocalAuth: true`. It creates the model deployments passed through the required `modelDeployments` array and @@ -23,7 +25,7 @@ az group create --name rg-foundry-models --location eastus2 az deployment group create \ --resource-group rg-foundry-models \ - --template-file infra/foundry-models/main.bicep \ + --template-file ./main.bicep \ --parameters location=eastus2 \ aiServicesName=aimodels-example \ principalId="$(az ad signed-in-user show --query id -o tsv)" \ @@ -44,4 +46,3 @@ Useful deployment outputs: - `aiServicesEndpoint`: endpoint used by the Gateway Foundry provider. - `modelDeploymentIds`: resource IDs for the deployments, in the same order as the `modelDeployments` input. - diff --git a/infra/scripts/README.md b/infra/scripts/README.md index 7db05aa..d04e414 100644 --- a/infra/scripts/README.md +++ b/infra/scripts/README.md @@ -3,7 +3,7 @@ The scripts provide azd lifecycle hooks for safely recovering the AI Gateway and finishing setup that requires a local GitHub credential. `azure.yaml` invokes the matching PowerShell scripts on Windows and Bash scripts on POSIX. -Run commands below from the repository root. +Run the commands in this README from the `infra/scripts/` directory. ## Gateway lifecycle @@ -21,12 +21,13 @@ modes: Standalone usage: ```bash -bash ./infra/scripts/manage-ai-gateway-lifecycle.sh prepare -bash ./infra/scripts/manage-ai-gateway-lifecycle.sh cleanup +bash ./manage-ai-gateway-lifecycle.sh prepare +bash ./manage-ai-gateway-lifecycle.sh cleanup ``` -On Windows, use `./infra/scripts/manage-ai-gateway-lifecycle.ps1 prepare` or -`cleanup`. Polling intervals and the overall bound can be adjusted with +On Windows, use `./manage-ai-gateway-lifecycle.ps1 prepare` or +`./manage-ai-gateway-lifecycle.ps1 cleanup`. Polling intervals and the overall +bound can be adjusted with `APIM_LIFECYCLE_POLL_INITIAL_SECONDS`, `APIM_LIFECYCLE_POLL_MAX_SECONDS`, `APIM_LIFECYCLE_IDENTITY_SETTLE_SECONDS`, and @@ -45,17 +46,17 @@ On Windows, use `./infra/scripts/manage-ai-gateway-lifecycle.ps1 prepare` or `toolServers/github`, creates the `aigw-github` project connection, and creates the Foundry Toolbox `repo-digest-tools`. -`azd provision` runs these hooks automatically. To run the postprovision step -manually after deploying the resources: +`azd provision` runs these hooks automatically; azd finds the repository's +`../../azure.yaml` when invoked from `infra/scripts/`. To run the postprovision +step manually after deploying the resources: ```bash -bash ./infra/scripts/configure-ai-gateway.sh +bash ./configure-ai-gateway.sh ``` -Use `./infra/scripts/configure-ai-gateway.ps1` on Windows. The configuration +Use `./configure-ai-gateway.ps1` on Windows. The configuration step needs Azure CLI and azd access to the deployment, a GitHub CLI login (`gh auth login`) or `GH_TOKEN`, and the provisioned Gateway, Foundry project, and endpoint settings in the azd environment. The script verifies the Gateway model route before saving the runtime key and creating the Toolbox connection. It does not print or store the GitHub credential in Bicep outputs. - From ccf3d892c9737c74c322159b302e5e1beb8cceff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:15:41 +0000 Subject: [PATCH 5/6] Explain hub-and-spoke README architecture Co-authored-by: paulyuk <1968137+paulyuk@users.noreply.github.com> --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c42c9e4..5c9cffd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Daily Repo Digest with AI Gateway -This sample runs a Microsoft Agent Framework repo-digest agent in Foundry Hosted Agents. Its model calls use AI Gateway, and Foundry Toolbox routes read-only GitHub MCP calls through AI Gateway. +This sample demonstrates a **hub-and-spoke governance architecture** for Foundry Hosted Agents using **AI Gateway** as the central hub. The Microsoft Agent Framework repo-digest agent runs in an isolated Foundry project spoke (`infra/foundry-agents/`) with zero model deployments and no direct credentials to backing models or external MCP servers. All model inference (`gpt-latest` three-tier failover across `gpt-6.1-sol` → `gpt-6-sol` → `gpt-5.4-mini`, plus `gpt-mini-latest`) and read-only GitHub MCP tool calls (via Foundry Toolbox) are routed **exclusively through AI Gateway (`infra/ai-gateway/`)** — there is no other access path from the agent to the model account (`infra/foundry-models/`) or to GitHub MCP. ## Prerequisites @@ -114,4 +114,8 @@ The default is `microsoft/agent-framework`. The included scheduled routine keeps **Start with [`repo_digest_agent.py`](repo_digest_agent.py).** It connects the Agent Framework agent directly to the AI Gateway model route and uses Foundry Toolbox for the AI Gateway GitHub MCP route. [`github_mcp_middleware.py`](github_mcp_middleware.py) keeps GitHub results bounded, and [`main.py`](main.py) hosts the agent in Foundry. +- **Hub (`infra/ai-gateway/`)**: Standalone AI Gateway (`Microsoft.ApiManagement/aigateways`) that enforces central routing, token rate limits, failover (`gpt-latest`), and MCP tool brokering (`toolServers/github`). Only AI Gateway's managed identity receives cross-resource runtime access to the model account through the configured Foundry User RBAC assignment. Only AI Gateway holds the GitHub MCP token. +- **Agent Spoke (`infra/foundry-agents/`)**: Hosts the Foundry project, ACR, Storage, and Hosted Agent runtime. Its project account has no model deployments and no RBAC assignment on the separate model account, ensuring the agent cannot bypass AI Gateway to call models or GitHub MCP directly. +- **Model Spoke (`infra/foundry-models/`)**: Dedicated model-only Azure AI Services account (`allowProjectManagement: false`, `disableLocalAuth: true`) hosting `gpt-6.1-sol`, `gpt-6-sol`, and `gpt-5.4-mini`, accessible to the agent only through AI Gateway. + See [`IMPLEMENTATION_NOTES.md`](IMPLEMENTATION_NOTES.md), including the [GitHub credential boundary](IMPLEMENTATION_NOTES.md#github-credential-boundary) and [how to tighten the GitHub credential to least privilege](IMPLEMENTATION_NOTES.md#tighten-the-github-credential-to-least-privilege), for architecture, authentication, AI Gateway contracts, security boundaries, configuration, monitoring, and deployment details. From fb9ca5e464732cda66900e14f1b228a1db369d92 Mon Sep 17 00:00:00 2001 From: Paul Yuknewicz Date: Sun, 25 Oct 2026 12:49:08 -0700 Subject: [PATCH 6/6] Auto-refresh .env on postprovision and reload dotenv per request locally Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1b3ab80e-64db-4e12-8e50-8fe293ced64b --- README.md | 6 +++--- infra/scripts/configure-ai-gateway.ps1 | 2 ++ infra/scripts/configure-ai-gateway.sh | 2 ++ main.py | 8 +++++++- tests/test_agent_runtime.py | 16 +++++++++++++++- 5 files changed, 29 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5c9cffd..4b9493c 100644 --- a/README.md +++ b/README.md @@ -39,14 +39,14 @@ follow **[Set up the GitHub credential](IMPLEMENTATION_NOTES.md#tighten-the-gith ## Run locally -Create the local `.env` from the selected azd environment: +`azd up` and `azd provision` automatically generate `.env` from the active azd environment. You can also refresh `.env` manually at any time: ```bash # macOS or Linux -./scripts/create-dev-env.sh +./scripts/create-dev-env.sh --force # Windows -pwsh ./scripts/create-dev-env.ps1 +pwsh ./scripts/create-dev-env.ps1 -Force ``` Start the agent: diff --git a/infra/scripts/configure-ai-gateway.ps1 b/infra/scripts/configure-ai-gateway.ps1 index 9abbe78..0d53f64 100644 --- a/infra/scripts/configure-ai-gateway.ps1 +++ b/infra/scripts/configure-ai-gateway.ps1 @@ -429,4 +429,6 @@ Remove-AzdEnvValues @( "FOUNDRY_API_KEY" ) +& (Join-Path $repoRoot "scripts/create-dev-env.ps1") -Force + Write-Host "AI Gateway setup complete. Bicep owns Azure resources; this hook injects the GitHub credential, connects Foundry Toolbox to AI Gateway, and saves the runtime key." diff --git a/infra/scripts/configure-ai-gateway.sh b/infra/scripts/configure-ai-gateway.sh index a54d240..4916d5b 100755 --- a/infra/scripts/configure-ai-gateway.sh +++ b/infra/scripts/configure-ai-gateway.sh @@ -424,4 +424,6 @@ remove_azd_env_values \ GITHUB_TOKEN \ FOUNDRY_API_KEY +"$REPO_ROOT/scripts/create-dev-env.sh" --force + echo "AI Gateway setup complete. Bicep owns Azure resources; this hook injects the GitHub credential, connects Foundry Toolbox to AI Gateway, and saves the runtime key." diff --git a/main.py b/main.py index 980ea69..c758836 100644 --- a/main.py +++ b/main.py @@ -14,8 +14,14 @@ from repo_digest_agent import build_agent +def build_runtime_agent(): + if not os.environ.get("FOUNDRY_HOSTING_ENVIRONMENT"): + load_dotenv(override=True) + return build_agent() + + def main() -> None: - ResponsesHostServer(agent=build_agent, history_source="agent_server").run() + ResponsesHostServer(agent=build_runtime_agent, history_source="agent_server").run() if __name__ == "__main__": diff --git a/tests/test_agent_runtime.py b/tests/test_agent_runtime.py index 7aa3573..a9b528e 100644 --- a/tests/test_agent_runtime.py +++ b/tests/test_agent_runtime.py @@ -52,9 +52,23 @@ def test_host_server_receives_agent_factory_and_history_source(self) -> None: with patch.object(main, "ResponsesHostServer", return_value=server) as host_server: main.main() - host_server.assert_called_once_with(agent=main.build_agent, history_source="agent_server") + host_server.assert_called_once_with(agent=main.build_runtime_agent, history_source="agent_server") server.run.assert_called_once_with() + def test_build_runtime_agent_reloads_dotenv_locally(self) -> None: + sentinel = object() + with ( + patch.dict(os.environ, {}, clear=False), + patch.object(main, "load_dotenv") as reload_dotenv, + patch.object(main, "build_agent", return_value=sentinel) as build_agent, + ): + os.environ.pop("FOUNDRY_HOSTING_ENVIRONMENT", None) + result = main.build_runtime_agent() + + self.assertIs(result, sentinel) + reload_dotenv.assert_called_once_with(override=True) + build_agent.assert_called_once_with() + class _UnsupportedListen: async def __aenter__(self) -> None: