Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: CI

on:
push:
branches:
- main
pull_request:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"

- name: Set up uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true

- name: Install Bicep
run: az bicep install

- name: Build Bicep templates
run: |
az bicep build --file infra/main.bicep
az bicep build --file infra/ai-gateway/main.bicep

- name: Run infrastructure tests
run: |
bash tests/test-apim-lifecycle.sh
bash tests/test-ai-gateway-model-registration.sh

- name: Run Python unit tests
run: uv run --frozen python -m unittest discover -s tests -v

- name: Build container image
run: docker build --tag simple-foundry-hosted-agent-python-aigateway:ci .

- name: Run container smoke test
run: python tests/container_smoke.py --image simple-foundry-hosted-agent-python-aigateway:ci
66 changes: 33 additions & 33 deletions IMPLEMENTATION_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,31 +263,33 @@ The root deployment creates three resource groups:
- `foundryagents` for the Foundry project, agent hosting, storage, Container
Registry, and hosted-agent monitoring.
- `foundrymodels` for the separate Foundry model account and deployments.
- `gateway` for AI Gateway, Connector Namespace, Gateway monitoring, model
provider, and model registrations.
- `gateway` for the standalone AI Gateway, Connector Namespace, Gateway
monitoring, model provider, model registrations, and model aliases.

The only cross-group runtime authorization is the AI Gateway system identity's
Foundry User role assignment on the model account. The public Azure
role-definition ID used by the template is
`53ca6127-db72-4b80-b1b0-d745d6d5456d`.

The AI Gateway module uses these preview contracts:
The AI Gateway module uses the live `2025-09-01-preview` contracts:

- `Microsoft.ApiManagement/service@2025-09-01-preview`
- SKU `AIGateway` with capacity `1`
- `Microsoft.ApiManagement/service/workspaces/modelProviders@2025-09-01-preview`
- `Microsoft.ApiManagement/service/workspaces/modelProviders/models@2025-09-01-preview`
- `Microsoft.ApiManagement/service/workspaces/toolServers@2025-09-01-preview`
- `Microsoft.ApiManagement/service/apiKeys@2025-09-01-preview`
- `Microsoft.ApiManagement/aigateways` with SKU `Consumption`
- `Microsoft.ApiManagement/aigateways/workspaces`
- `Microsoft.ApiManagement/aigateways/workspaces/modelProviders`
- `Microsoft.ApiManagement/aigateways/workspaces/modelProviders/models`
- `Microsoft.ApiManagement/aigateways/workspaces/aliases`
- `Microsoft.ApiManagement/aigateways/workspaces/toolServers`
- `Microsoft.ApiManagement/aigateways/workspaces/telemetryExporters`
- `Microsoft.ApiManagement/aigateways/apiKeys`
- `Microsoft.Web/connectorGateways@2026-05-01-preview`

The Connector Namespace uses the same name, resource group, and location as
the APIM AI Gateway service. It is a separate resource from the GitHub MCP
the AI Gateway. It is a separate resource from the GitHub MCP
ToolServer and supports connector-backed scenarios.

The default workspace is implicit for control-plane child resources. The
postprovision hook waits for the workspace child collection before configuring
the GitHub ToolServer.
The predefined `workspaces/default` resource is the parent of every
workspace-scoped child. The postprovision hook waits for the workspace child
collection before configuring the GitHub ToolServer.

The Foundry model provider uses:

Expand All @@ -302,12 +304,17 @@ Each model registration includes:
- `apiFormat: OpenAIChatCompletions`
- `/openai/v1/chat/completions`
- `/openai/v1/responses`
- A token-limit policy derived from deployment capacity
- The live `displayName` wire property
- `deployment.modelName` set to the Foundry deployment name from the final
segment of the deployment resource ID

The deployment name, not the backing catalog model name, is the OpenAI `model`
value sent through AI Gateway.
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`.

## Regions and model defaults

Expand Down Expand Up @@ -336,19 +343,18 @@ The AI Gateway preview region allowlist is:

The model defaults are:

| Purpose | Gateway deployment | Backing model | Version | Capacity |
| 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 |

If `gpt-5.6-sol` is unavailable, set `modelName=gpt-5.5` and
`modelVersion=2026-04-24`.

Global Standard capacity maps to the Gateway token limit at 1,000 tokens per
minute per capacity unit. Capacity 20 therefore registers 20,000 tokens per
minute for the full deployment. Mini capacity 200 accommodates requests from
the GitHub Copilot app, also called ghapp, when built-in tool definitions make
the prompt substantially larger.
Global Standard deployment capacity is provisioned on the Foundry model
account. Mini capacity 200 accommodates requests from the GitHub Copilot app,
also called ghapp, when built-in tool definitions make the prompt substantially
larger.

The agent caps each response at 2,048 output tokens. Azure quota evaluation
uses an estimate based on the prompt and requested maximum output, so this cap
Expand All @@ -371,17 +377,17 @@ Foundry fallback.

## AI Gateway deletion lifecycle

APIM deletion can include a live service phase, a soft-deleted service phase,
and an identity cleanup interval. Name availability alone does not prove that
identity cleanup has completed.
AI Gateway deletion has a live resource phase and an identity cleanup interval.
The standalone `aigateways` resource does not use the APIM `service` deleted
services endpoint. Name availability alone does not prove that identity cleanup
has completed.

`azure.yaml` invokes the lifecycle scripts in two paths:

- Preprovision preserves a succeeded Gateway and removes only a terminal
failed Gateway that matches the current azd environment ownership markers.
- Postdown completes the explicit teardown, waits for the live resource to
disappear, purges the soft-deleted APIM service, and waits for identity
cleanup.
disappear, and waits for identity cleanup.

The scripts use bounded exponential polling. Defaults are:

Expand All @@ -401,12 +407,6 @@ The nonsecret `.azure/<environment>/apim-lifecycle.state` marker contains only
environment and resource identity, location, lifecycle state, time, and a
deployment or resource failure identifier.

Relevant public documentation:

- [Azure API Management soft-delete](https://learn.microsoft.com/azure/api-management/soft-delete)
- [Deleted Services - Get By Name](https://learn.microsoft.com/rest/api/apimanagement/deleted-services/get-by-name?view=rest-apimanagement-2024-05-01)
- [Deleted Services - Purge](https://learn.microsoft.com/rest/api/apimanagement/deleted-services/purge?view=rest-apimanagement-2024-05-01)

## Hosted-agent image and monitoring

The hosted agent uses `language: docker` and `docker.remoteBuild: true`. The
Expand Down
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,31 @@ Or use the console:
uv run chat.py
```

Run the zero-auth container smoke test after building the image:

```bash
docker build -t daily-repo-digest .
python tests/container_smoke.py --image daily-repo-digest
```

With an existing Azure login and selected azd environment, verify the deployed
agent without redeploying, or optionally deploy first:

```bash
bash tests/test-azd-smoke.sh
bash tests/test-azd-smoke.sh --deploy
# Or provision and deploy first:
bash tests/test-azd-smoke.sh --up
```

## Deploy

```bash
azd up
azd ai agent invoke "Create a concise daily repo digest for microsoft/agent-framework."
```

If Azure leaves a failed AI Gateway after an activation or managed-identity conflict, rerun `azd up`. The preprovision hook deletes only the terminal-Failed `AIGateway` tagged for the current azd environment, purges its APIM soft-delete record, and waits for identity cleanup. `azd down` performs the same bounded purge and settle process in its postdown hook. If the bound expires, follow the exact retry guidance printed by the hook rather than relying on name availability alone.
If Azure leaves a failed AI Gateway after an activation or managed-identity conflict, rerun `azd up`. The preprovision hook deletes only the terminal-Failed `Microsoft.ApiManagement/aigateways` resource tagged for the current azd environment and waits for identity cleanup. `azd down` performs the same bounded settle process in its postdown hook. If the bound expires, follow the exact retry guidance printed by the hook rather than relying on name availability alone.

Run the scheduled digest immediately:

Expand Down
3 changes: 2 additions & 1 deletion github_mcp_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,11 @@ def _normalize_github_arguments(
workflow_filter = arguments.get("workflow_runs_filter")
normalized_filter = dict(workflow_filter) if isinstance(workflow_filter, Mapping) else {}
normalized_filter["status"] = "completed"
arguments.pop("per_page", None)
arguments.update(
method="list_workflow_runs",
workflow_runs_filter=normalized_filter,
per_page=100,
perPage=100,
page=1,
)

Expand Down
72 changes: 47 additions & 25 deletions infra/ai-gateway/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ param enableMonitoring bool = false
param deployApimGatewayViaBicep bool = true
param appInsightsName string = '${gatewayName}-insights'
param logAnalyticsWorkspaceName string = take('${gatewayName}-logs', 63)
param publisherEmail string = 'noreply@example.com'
param publisherName string = 'AI Gateway'
param deployFoundryProvider bool = false
param foundryProviderName string = 'foundry'
param foundryProviderDisplayName string = 'Foundry'
Expand All @@ -18,6 +16,7 @@ param foundryEndpoint string = ''
param foundryResourceIds array = []
param foundryManagedIdentityResource string = 'https://cognitiveservices.azure.com/'
param foundryModels array = []
param modelAliasesConfig array = []
@secure()
param toolServerConfigs object = {}

Expand All @@ -28,21 +27,18 @@ var logAnalyticsApiVersion = '2023-09-01'
var telemetryWorkspaceName = 'default'
var telemetryExporterName = 'appinsights'
var normalizedFoundryEndpoint = endsWith(foundryEndpoint, '/') ? foundryEndpoint : '${foundryEndpoint}/'
resource aiGateway 'Microsoft.ApiManagement/service@2025-09-01-preview' = if (deployApimGatewayViaBicep) {
#disable-next-line BCP081
resource aiGateway 'Microsoft.ApiManagement/aigateways@2025-09-01-preview' = if (deployApimGatewayViaBicep) {
name: gatewayName
location: gatewayLocation
tags: tags
identity: {
type: 'SystemAssigned'
}
sku: {
name: 'AIGateway'
capacity: 1
}
properties: {
publisherEmail: publisherEmail
publisherName: publisherName
name: 'Consumption'
}
properties: {}
}

resource connectorNamespace 'Microsoft.Web/connectorGateways@2026-05-01-preview' = if (deployApimGatewayViaBicep) {
Expand All @@ -54,7 +50,8 @@ resource connectorNamespace 'Microsoft.Web/connectorGateways@2026-05-01-preview'
]
}

resource defaultWorkspace 'Microsoft.ApiManagement/service/workspaces@2025-09-01-preview' existing = if (deployApimGatewayViaBicep) {
#disable-next-line BCP081
resource defaultWorkspace 'Microsoft.ApiManagement/aigateways/workspaces@2025-09-01-preview' existing = if (deployApimGatewayViaBicep) {
parent: aiGateway
name: telemetryWorkspaceName
}
Expand Down Expand Up @@ -95,7 +92,8 @@ resource appInsights 'Microsoft.Insights/components@2020-02-02' = if (enableMoni
}
}

resource telemetryExporter 'Microsoft.ApiManagement/service/workspaces/telemetryExporters@2025-09-01-preview' = if (enableMonitoring && deployApimGatewayViaBicep) {
#disable-next-line BCP081
resource telemetryExporter 'Microsoft.ApiManagement/aigateways/workspaces/telemetryExporters@2025-09-01-preview' = if (enableMonitoring && deployApimGatewayViaBicep) {
parent: defaultWorkspace
name: telemetryExporterName
properties: {
Expand All @@ -108,7 +106,8 @@ resource telemetryExporter 'Microsoft.ApiManagement/service/workspaces/telemetry
}
}

resource foundryProvider 'Microsoft.ApiManagement/service/workspaces/modelProviders@2025-09-01-preview' = if (deployFoundryProvider && deployApimGatewayViaBicep) {
#disable-next-line BCP081
resource foundryProvider 'Microsoft.ApiManagement/aigateways/workspaces/modelProviders@2025-09-01-preview' = if (deployFoundryProvider && deployApimGatewayViaBicep) {
parent: defaultWorkspace
name: foundryProviderName
dependsOn: [
Expand All @@ -131,7 +130,8 @@ resource foundryProvider 'Microsoft.ApiManagement/service/workspaces/modelProvid
}
}

resource foundryCatalogModels 'Microsoft.ApiManagement/service/workspaces/modelProviders/models@2025-09-01-preview' = [for model in foundryModels: if (deployFoundryProvider && deployApimGatewayViaBicep) {
#disable-next-line BCP081
resource foundryCatalogModels 'Microsoft.ApiManagement/aigateways/workspaces/modelProviders/models@2025-09-01-preview' = [for model in foundryModels: if (deployFoundryProvider && deployApimGatewayViaBicep) {
parent: foundryProvider
name: model.armName
properties: {
Expand All @@ -144,42 +144,64 @@ resource foundryCatalogModels 'Microsoft.ApiManagement/service/workspaces/modelP
modelName: last(split(model.resourceId, '/'))
modelVersion: model.?modelVersion ?? null
}
policies: model.?policies ?? [
{
type: 'tokenLimit'
period: 'minute'
count: model.?tokenLimit ?? 10000
counterKey: 'Identity'
}
]
}
}]

resource toolServers 'Microsoft.ApiManagement/service/workspaces/toolServers@2025-09-01-preview' = [for config in items(toolServerConfigs): if (deployApimGatewayViaBicep) {
#disable-next-line BCP081
resource modelAliases 'Microsoft.ApiManagement/aigateways/workspaces/aliases@2025-09-01-preview' = [for alias in modelAliasesConfig: if (deployFoundryProvider && deployApimGatewayViaBicep) {
parent: defaultWorkspace
name: alias.armName
dependsOn: [
foundryCatalogModels
]
properties: {
aliasName: alias.aliasName
description: alias.?description ?? null
backendModels: [for target in alias.backendModels: {
resourceId: resourceId(
'Microsoft.ApiManagement/aigateways/workspaces/modelProviders/models',
gatewayName,
telemetryWorkspaceName,
foundryProviderName,
target.modelArmName
)
priority: target.priority
weight: target.weight
}]
}
}]

#disable-next-line BCP081
resource toolServers 'Microsoft.ApiManagement/aigateways/workspaces/toolServers@2025-09-01-preview' = [for config in items(toolServerConfigs): if (deployApimGatewayViaBicep) {
parent: defaultWorkspace
name: config.key
properties: config.value
}]

resource runtimeApiKey 'Microsoft.ApiManagement/service/apiKeys@2025-09-01-preview' = if (deployApimGatewayViaBicep) {
#disable-next-line BCP081
resource runtimeApiKey 'Microsoft.ApiManagement/aigateways/apiKeys@2025-09-01-preview' = if (deployApimGatewayViaBicep) {
parent: aiGateway
name: 'default'
properties: {
displayName: 'Default hosted agent key'
}
}

output apimGatewayId string = resourceId('Microsoft.ApiManagement/service', gatewayName)
var modelAliasResourceIds = [for alias in modelAliasesConfig: resourceId('Microsoft.ApiManagement/aigateways/workspaces/aliases', gatewayName, telemetryWorkspaceName, alias.armName)]

output apimGatewayId string = resourceId('Microsoft.ApiManagement/aigateways', gatewayName)
output connectorNamespaceId string = deployApimGatewayViaBicep ? connectorNamespace.id : ''
output appInsightsId string = enableMonitoring ? appInsights.id : ''
output appInsightsName string = enableMonitoring ? appInsights.name : ''
output logAnalyticsWorkspaceId string = enableMonitoring ? logAnalyticsWorkspace.id : ''
output logAnalyticsWorkspaceName string = enableMonitoring ? logAnalyticsWorkspace.name : ''
output telemetryExporterId string = enableMonitoring && deployApimGatewayViaBicep ? telemetryExporter.id : ''
output runtimeHostname string = deployApimGatewayViaBicep ? replace(aiGateway!.properties.gatewayUrl, 'https://', '') : ''
output runtimeHostname string = deployApimGatewayViaBicep ? (aiGateway!.properties.?frontend.?defaultHostname ?? replace(aiGateway!.properties.?gatewayUrl ?? '', 'https://', '')) : ''
output runtimeApiKeyId string = deployApimGatewayViaBicep ? runtimeApiKey.id : ''
output gatewayPrincipalId string = deployApimGatewayViaBicep ? aiGateway!.identity.principalId : ''
output foundryUserRoleAssignmentName string = deployFoundryProvider && deployApimGatewayViaBicep && !empty(foundryResourceIds) ? foundryUserRoleAssignments[0]!.outputs.roleAssignmentName : ''
output modelAliasIds array = deployFoundryProvider && deployApimGatewayViaBicep ? modelAliasResourceIds : []
output defaultModelAliasName string = !empty(modelAliasesConfig) ? modelAliasesConfig[0].aliasName : ''
output gatewayLocation string = gatewayLocation
output apiVersions object = {
aiGateway: aiGatewayApiVersion
Expand Down
Loading
Loading