Skip to content
Draft
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
43 changes: 38 additions & 5 deletions IMPLEMENTATION_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ Functions application.
- `infra/foundry-agents/main.bicep` creates the agent project, storage,
Container Registry, monitoring, connections, and RBAC.
- `infra/ai-gateway/main.bicep` creates AI Gateway, its Connector Namespace,
model provider, model registrations, runtime key, and monitoring.
model provider, model registrations, runtime key, and monitoring, and invokes
`infra/foundry-agents/github-connection.bicep` in the agents resource group.
- `chat.py` is a local console client for the Responses endpoint.

The scheduled routine is named `daily-repo-digest`. It runs at 9 AM in the
Expand Down Expand Up @@ -56,7 +57,7 @@ flowchart LR
Model requests use
`<gateway>/default/models/openai/v1/chat/completions` or the corresponding
Responses route. Tool requests use
`<gateway>/default/toolservers/github/mcp`.
`<gateway>/default/mcp/github/mcp`.

The agent does not call a Foundry model endpoint directly. This keeps model
traffic behind AI Gateway policies. `FoundryChatClient` is intentionally not
Expand All @@ -71,9 +72,11 @@ Agent -> Foundry Toolbox -> AI Gateway -> GitHub MCP
```

`toolbox.yaml` declares one MCP source backed by the `aigw-github` project
connection. The postprovision hook stores the AI Gateway `Api-Key` in that
connection. `FoundryToolbox` authenticates the agent-to-Toolbox request with
Entra identity.
connection. Bicep creates that `RemoteTool` / `CustomKeys` connection and stores
the generated AI Gateway `Api-Key` in it. The postprovision hook creates
`repo-digest-tools` through the Foundry data-plane API using that connection;
it no longer runs `azd ai connection create`. `FoundryToolbox` authenticates
the agent-to-Toolbox request with Entra identity.

The GitHub source sets `require_approval: "never"` because all three exposed
operations are read-only and the scheduled routine cannot pause for approval.
Expand Down Expand Up @@ -291,6 +294,36 @@ 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 gateway module receives the agents account and project names from the
Foundry module outputs, so it waits for project provisioning. Its nested
connection module targets the agents resource group and depends on the gateway
and `apiKeys/default`. The connection target is
`<gateway>/default/mcp/github/mcp`, with `isSharedToAll: true` and
`metadata.type: custom_MCP`. Stable resource names and incremental deployments
allow repeated `azd up` runs to update the same connection.

The preview key contract used by the deployment is
`runtimeApiKey.listSecrets().key` at `2025-09-01-preview`. Bicep compiles this
created-resource call to ARM `listSecrets(resourceId(...), apiVersion).key`
and adds the key resource dependency. The value crosses the module boundary
only through an `@secure()` parameter (`securestring` in ARM), never an output.
The deploying identity needs permission to list the gateway key and write
project connections in the agents resource group. Do not log resolved
deployment parameters or run the hooks with shell tracing/debug logging.

Both postprovision hooks still retrieve the key with `listSecrets` (preferring
`key`, with legacy response-field and `listValues` compatibility fallbacks) and
save `AZURE_AI_GATEWAY_API_KEY` to the local azd environment for
`create-dev-env.sh`. They also inject the GitHub credential, check model-route
readiness, and create the data-plane Toolbox if absent. Failed-resource
recovery remains in the lifecycle scripts.

Compilation and regression tests verify the ARM expression, secure parameter,
dependencies, target, and absence of secret outputs. A fresh and repeated
`azd up` against the live preview service are still required to validate its
response contract and Toolbox attachment end to end; compilation alone cannot
verify a service response.

The Foundry model provider uses:

- `kind: Foundry`
Expand Down
43 changes: 43 additions & 0 deletions infra/ai-gateway/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# AI Gateway provisioning

The subscription entry point, `../main.bicep`, deploys this module into the
gateway resource group after the Foundry project and model resources are ready.
This module creates the gateway and `apiKeys/default`, then deploys
`../foundry-agents/github-connection.bicep` into the agents resource group.

The `aigw-github` project connection uses
`Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview`,
category `RemoteTool`, authentication `CustomKeys`, and target
`https://<gateway-host>/default/mcp/github/mcp`. It is shared with the project
and has `metadata.type: custom_MCP`.

The gateway's `2025-09-01-preview` key is read with
`runtimeApiKey.listSecrets().key` and passed only through the connection
module's `@secure()` `gatewayApiKey` parameter. ARM waits for key creation
before evaluating the nested deployment. No deployment output contains the
secret. The deployment identity needs key-listing permission in the gateway
resource group and connection-write permission in the agents resource group.
Stable names and incremental deployment keep repeat provisioning idempotent.

The POSIX and PowerShell postprovision hooks no longer create the connection.
They still register the GitHub ToolServer using the local `GH_TOKEN` or GitHub
CLI credential, verify model-route readiness, save `AZURE_AI_GATEWAY_API_KEY`
to the local azd environment for development, and create the
`repo-digest-tools` Toolbox through the Foundry data-plane API.
`toolbox.yaml` references `project_connection_id: aigw-github`.
Failed-resource recovery remains in the lifecycle hooks.

Do not enable shell tracing or debug logging when handling keys. Local
environment files are private deployment state, not source configuration.

From the repository root, validate the compiled contract with:

```bash
bash tests/test-ai-gateway-model-registration.sh
bash tests/test-apim-lifecycle.sh
```

Live verification requires Azure credentials: run `azd up` on a fresh
environment, confirm Toolbox resolves `aigw-github`, and repeat `azd up` to
check idempotency. The focused tests verify generated ARM, not live preview
responses.
18 changes: 17 additions & 1 deletion infra/ai-gateway/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ param foundryResourceIds array = []
param foundryManagedIdentityResource string = 'https://cognitiveservices.azure.com/'
param foundryModels array = []
param modelAliasesConfig array = []
param foundryAgentsResourceGroupName string = resourceGroup().name
param foundryAgentsAccountName string = ''
param foundryAgentsProjectName string = ''
@secure()
param toolServerConfigs object = {}

Expand Down Expand Up @@ -187,6 +190,19 @@ resource runtimeApiKey 'Microsoft.ApiManagement/aigateways/apiKeys@2025-09-01-pr
}
}

var runtimeHostname = deployApimGatewayViaBicep ? (aiGateway!.properties.?frontend.?defaultHostname ?? replace(aiGateway!.properties.?gatewayUrl ?? '', 'https://', '')) : ''

module githubConnection '../foundry-agents/github-connection.bicep' = if (deployApimGatewayViaBicep && !empty(foundryAgentsProjectName)) {
name: 'github-connection-${uniqueString(gatewayName, foundryAgentsProjectName)}'
scope: resourceGroup(foundryAgentsResourceGroupName)
params: {
aiServicesName: foundryAgentsAccountName
aiProjectName: foundryAgentsProjectName
gatewayUrl: 'https://${runtimeHostname}'
gatewayApiKey: runtimeApiKey!.listSecrets().key
}
}

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

output apimGatewayId string = resourceId('Microsoft.ApiManagement/aigateways', gatewayName)
Expand All @@ -196,7 +212,7 @@ 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 ? (aiGateway!.properties.?frontend.?defaultHostname ?? replace(aiGateway!.properties.?gatewayUrl ?? '', 'https://', '')) : ''
output runtimeHostname string = runtimeHostname
output runtimeApiKeyId string = deployApimGatewayViaBicep ? runtimeApiKey.id : ''
output gatewayPrincipalId string = deployApimGatewayViaBicep ? aiGateway!.identity.principalId : ''
output foundryUserRoleAssignmentName string = deployFoundryProvider && deployApimGatewayViaBicep && !empty(foundryResourceIds) ? foundryUserRoleAssignments[0]!.outputs.roleAssignmentName : ''
Expand Down
37 changes: 37 additions & 0 deletions infra/foundry-agents/github-connection.bicep
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
targetScope = 'resourceGroup'

param aiServicesName string
param aiProjectName string
param gatewayUrl string
@secure()
param gatewayApiKey string

var normalizedGatewayUrl = endsWith(gatewayUrl, '/') ? take(gatewayUrl, length(gatewayUrl) - 1) : gatewayUrl

resource aiServices 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = {
name: aiServicesName
}

resource aiProject 'Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview' existing = {
parent: aiServices
name: aiProjectName
}

resource githubConnection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = {
parent: aiProject
name: 'aigw-github'
properties: {
category: 'RemoteTool'
authType: 'CustomKeys'
target: '${normalizedGatewayUrl}/default/mcp/github/mcp'
credentials: {
keys: {
'Api-Key': gatewayApiKey
}
}
isSharedToAll: true
metadata: {
type: 'custom_MCP'
}
}
}
3 changes: 3 additions & 0 deletions infra/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,9 @@ module aiGateway './ai-gateway/main.bicep' = {
location: location
gatewayLocation: aiGatewayLocation
gatewayName: effectiveAiGatewayName
foundryAgentsResourceGroupName: foundryAgentsRg.name
foundryAgentsAccountName: foundryAgents.outputs.aiServicesName
foundryAgentsProjectName: foundryAgents.outputs.aiProjectName
tags: tags
enableMonitoring: true
deployApimGatewayViaBicep: true
Expand Down
14 changes: 2 additions & 12 deletions infra/scripts/configure-ai-gateway.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ $foundryUserRoleId = "53ca6127-db72-4b80-b1b0-d745d6d5456d"
$defaultRepository = "microsoft/agent-framework"
$githubMcpServer = "https://api.githubcopilot.com/mcp/"
$githubMcpTools = "list_pull_requests,list_issues,actions_list"
$toolboxConnectionName = "aigw-github"
$toolboxName = "repo-digest-tools"
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "../..")).Path

Expand Down Expand Up @@ -72,7 +71,7 @@ function Invoke-AzRestPutJson($Uri, $Body) {
function Get-GatewayApiKeyValue($GatewayResourceId) {
$listSecretsUri = "https://management.azure.com${GatewayResourceId}/apiKeys/default/listSecrets?api-version=$aiGatewayApiVersion"
try {
$value = az rest --method post --uri $listSecretsUri --body "{}" --query "primaryKey || properties.primaryKey || primaryValue || properties.primaryValue" -o tsv 2>$null
$value = az rest --method post --uri $listSecretsUri --body "{}" --query "key || primaryKey || properties.primaryKey || primaryValue || properties.primaryValue" -o tsv 2>$null
if (-not [string]::IsNullOrWhiteSpace($value)) { return $value.Trim() }
} catch {
Write-Verbose "The current backend does not expose listSecrets; trying listValues."
Expand Down Expand Up @@ -369,16 +368,7 @@ azd env set GITHUB_REPOSITORY $githubRepository
azd env set AZURE_AI_GATEWAY_API_KEY $gatewayApiKey | Out-Null
azd env set TOOLBOX_NAME $toolboxName

Write-Host "Connecting Foundry Toolbox to the AI Gateway GitHub ToolServer."
azd ai connection create $toolboxConnectionName `
--kind remote-tool `
--target ($gatewayUrl.TrimEnd("/") + "/default/toolservers/github/mcp") `
--auth-type custom-keys `
--custom-key "Api-Key=$gatewayApiKey" `
--force `
--no-prompt `
--project-endpoint $projectEndpoint `
-o json | Out-Null
Write-Host "Connecting Foundry Toolbox using the Bicep-provisioned aigw-github connection."

$toolboxExists = $true
try {
Expand Down
14 changes: 2 additions & 12 deletions infra/scripts/configure-ai-gateway.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ FOUNDRY_USER_ROLE_ID="53ca6127-db72-4b80-b1b0-d745d6d5456d"
DEFAULT_REPOSITORY="microsoft/agent-framework"
GITHUB_MCP_SERVER="https://api.githubcopilot.com/mcp/"
GITHUB_MCP_TOOLS="list_pull_requests,list_issues,actions_list"
TOOLBOX_CONNECTION_NAME="aigw-github"
TOOLBOX_NAME="repo-digest-tools"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"

Expand Down Expand Up @@ -58,7 +57,7 @@ get_gateway_api_key_value() {
--method post \
--uri "https://management.azure.com${gateway_resource_id}/apiKeys/default/listSecrets?api-version=${AI_GATEWAY_API_VERSION}" \
--body '{}' \
--query "primaryKey || properties.primaryKey || primaryValue || properties.primaryValue" \
--query "key || primaryKey || properties.primaryKey || primaryValue || properties.primaryValue" \
-o tsv 2>/dev/null)" && [ -n "$value" ]; then
printf '%s' "$value"
return 0
Expand Down Expand Up @@ -368,16 +367,7 @@ azd env set GITHUB_REPOSITORY "$github_repository"
azd env set AZURE_AI_GATEWAY_API_KEY "$gateway_api_key" >/dev/null
azd env set TOOLBOX_NAME "$TOOLBOX_NAME"

echo "Connecting Foundry Toolbox to the AI Gateway GitHub ToolServer."
azd ai connection create "$TOOLBOX_CONNECTION_NAME" \
--kind remote-tool \
--target "${gateway_url%/}/default/toolservers/github/mcp" \
--auth-type custom-keys \
--custom-key "Api-Key=${gateway_api_key}" \
--force \
--no-prompt \
--project-endpoint "$project_endpoint" \
-o json >/dev/null
echo "Connecting Foundry Toolbox using the Bicep-provisioned aigw-github connection."

if ! toolbox_json="$(azd ai toolbox show "$TOOLBOX_NAME" \
--no-prompt \
Expand Down
75 changes: 75 additions & 0 deletions tests/test-ai-gateway-model-registration.sh
Original file line number Diff line number Diff line change
Expand Up @@ -139,4 +139,79 @@ if [ "$mini_capacity" != "200" ]; then
exit 1
fi

if ! jq -e '
.resources[]
| select(.type == "Microsoft.Resources/deployments" and .properties.parameters.gatewayApiKey != null)
| . as $module
| .properties.template as $template
| .properties.parameters.gatewayApiKey.value as $key
| ($module.resourceGroup == "[parameters('\''foundryAgentsResourceGroupName'\'')]")
and ($module.properties.mode == "Incremental")
and ($module.condition | contains("deployApimGatewayViaBicep") and contains("foundryAgentsProjectName"))
and ($template.parameters.gatewayApiKey.type == "securestring")
and ($key == "[listSecrets(resourceId('\''Microsoft.ApiManagement/aigateways/apiKeys'\'', parameters('\''gatewayName'\''), '\''default'\''), '\''2025-09-01-preview'\'').key]")
and ($module.dependsOn | any(contains("Microsoft.ApiManagement/aigateways/apiKeys")))
and ($template.resources | length == 1)
and ($template.resources[0] |
.type == "Microsoft.CognitiveServices/accounts/projects/connections"
and .apiVersion == "2025-04-01-preview"
and (.name | contains("aigw-github"))
and .properties.category == "RemoteTool"
and .properties.authType == "CustomKeys"
and .properties.target == "[format('\''{0}/default/mcp/github/mcp'\'', variables('\''normalizedGatewayUrl'\''))]"
and .properties.credentials.keys["Api-Key"] == "[parameters('\''gatewayApiKey'\'')]"
and .properties.isSharedToAll == true
and .properties.metadata.type == "custom_MCP")
' "$compiled_template" >/dev/null; then
echo "GitHub connection must use a secure key parameter, the MCP route, and depend on the generated key." >&2
exit 1
fi

if ! jq -e '
.resources[]
| select(.properties.parameters.foundryAgentsProjectName != null)
| (.properties.parameters.foundryAgentsResourceGroupName.value == "[variables('\''effectiveFoundryAgentsResourceGroupName'\'')]")
and (.properties.parameters.foundryAgentsAccountName.value | contains("outputs.aiServicesName.value"))
and (.properties.parameters.foundryAgentsProjectName.value | contains("outputs.aiProjectName.value"))
and (.dependsOn | any(contains("foundry-agents-")))
' "$compiled_root_template" >/dev/null; then
echo "Gateway connection provisioning must wait for the Foundry agents project in its own resource group." >&2
exit 1
fi

if ! jq -e '
[.. | objects | .outputs? // {} | to_entries[]
| select((.value.value | tostring | test("listSecrets|listValues|parameters\\('\''gatewayApiKey'\''\\)"))
or (.key | test("(?i)^(gatewayApiKey|AI_GATEWAY_API_KEY|AZURE_AI_GATEWAY_API_KEY)$")))]
| length == 0
' "$compiled_root_template" >/dev/null; then
echo "Gateway API key values must never be emitted in deployment outputs." >&2
exit 1
fi

for hook in "$repo_root/infra/scripts/configure-ai-gateway.sh" "$repo_root/infra/scripts/configure-ai-gateway.ps1"; do
if grep -Eq 'azd[[:space:]]+ai[[:space:]]+connection[[:space:]]+create|--custom-key' "$hook"; then
echo "Postprovision must not create the Bicep-owned connection or pass its key to a connection command." >&2
exit 1
fi
for token in \
'/apiKeys/default/listSecrets?' \
'--query "key || primaryKey' \
'azd env set AZURE_AI_GATEWAY_API_KEY' \
'toolServers/github' \
'GH_TOKEN' \
'azd ai toolbox show' \
'azd ai toolbox create'; do
if ! grep -Fq -- "$token" "$hook"; then
echo "Postprovision must preserve local key retrieval, GitHub registration, and Toolbox setup: $token" >&2
exit 1
fi
done
done

if ! grep -Fq 'project_connection_id: aigw-github' "$repo_root/toolbox.yaml"; then
echo "Toolbox must attach the Bicep-provisioned aigw-github connection." >&2
exit 1
fi

echo "AI Gateway model registration test passed."