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
23 changes: 23 additions & 0 deletions .azure-pipelines/scripts/bicep/test_bicep_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,29 @@ def git(*args):
self.assertIn(f"bicep build --file {SAMPLE_ROOT}/{expected}/main.bicep", calls)
(repo / "calls.txt").unlink()

def test_connection_samples_select_self_contained_fixtures(self):
script = self.pipeline["stages"][2]["jobs"][0]["steps"][1]["inputs"]["inlineScript"]
start = script.index('if [ -n "${{ parameters.samplePath }}" ]; then')
selection = script[start:script.index("deploy_with_location() {")]
selection = selection.replace("${{ parameters.samplePath }}", "")
selection = selection.replace("${{ parameters.validateAll }}", "false")
selection = selection.replace("$(BICEP_ROOT)", SAMPLE_ROOT)
for sample in ("apim-secret-headers", "foundry-model-connection"):
directory = f"{SAMPLE_ROOT}/01-connections/{sample}"
with self.subTest(sample=sample):
result = subprocess.run(
[
"bash", "-c",
"set -euo pipefail\nbase_ref=origin/main\n"
f"git() {{ printf '%s\\n' '{directory}/main.bicep' '{directory}/tests/deploy.bicep'; }}\n"
+ selection
+ '\nprintf "%s\\n" "${deployment_files[@]}"\n',
],
cwd=ROOT, capture_output=True, text=True,
)
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
self.assertEqual(f"{directory}/main.bicepparam\n", result.stdout)

def test_manual_sample_paths_are_normalized_and_contained(self):
bash = shutil.which("bash")
self.assertIsNotNone(bash, "Bash is required for pipeline selection checks")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ Connections enable your AI applications to access tools and objects managed else

This folder provides a set of examples for the most common connection categories.

For a gateway requiring both project identity and a secret subscription-key
header, see [`apim-secret-headers`](./apim-secret-headers/). It creates an
`ApiManagement` model connection and a `CustomKeys` companion without exposing
the header value in model metadata.

## BYOM golden-path variants (non-VNet)

For the end-to-end **bring-your-own-model** golden path, these connections map to the
Expand All @@ -15,10 +20,11 @@ variants in [`../golden-path`](../golden-path/README.md):
| 2. No separate gateway — model in another Foundry / Azure OpenAI account | [`model-gateway`](./model-gateway/) (`samples/parameters-foundryazureai.json`, `parameters-foundryopenai.json`, or `parameters-foundryanthropic.json`) — a **ModelGateway** connection pointed at the other account's inference endpoint |
| 3. Third-party model provider | [`model-gateway`](./model-gateway/) (`samples/parameters-openai.json`) |
| 4. BYOM + BYOG (your own 3P gateway) | [`model-gateway`](./model-gateway/) (custom `targetUrl`) |
| Direct Foundry models with project managed identity | [`foundry-model-connection`](./foundry-model-connection/) (`ApiManagement` category, no separate gateway; optional target-account RBAC) |

> [!IMPORTANT]
> BYOM `<connection>/<model>` resolution works **only** for *gateway-category* connections —
> `ApiManagement` (variant 1) or `ModelGateway` (variants 2–4). A plain
> `ApiManagement` (variant 1 or the direct Foundry PMI sample) or `ModelGateway` (variants 2–4). A plain
> [`connection-azure-openai.bicep`](./connection-azure-openai.bicep) (`AzureOpenAI`) or
> [`connection-foundry.bicep`](./connection-foundry.bicep) (`CognitiveService`) connection is
> **not** resolvable by a prompt agent as `<connection>/<model>` — it fails with
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# APIM connection with project identity and a secret header

Add a model connection to an **existing** Foundry project whose gateway requires
both project-managed-identity authentication and an APIM subscription key.
The key is a secret HTTP header value, not an asymmetric private key.

This sample creates two project-scoped connections using API `2026-07-01`:

| Connection | Authentication | Contents |
|---|---|---|
| `apim-private-header` (configurable) | `ProjectManagedIdentity` | Gateway URL, audience, model metadata, and a reference to the secret header |
| `secret-headers-<deterministic-suffix>` | `CustomKeys` | `credentials.keys.header0` containing the subscription key and `metadata.secretHeadersFor` identifying the parent |

Both use the same target and `isSharedToAll: false`. The model connection's
`metadata.secretHeaders` is a JSON string mapping `Ocp-Apim-Subscription-Key` to
`{ "connection": "<companion-name>", "key": "header0" }`. It contains no secret
value, and `customHeaders` remains `'{}'`. Referencing the companion resource
creates an ARM dependency so the companion is created before its parent.

For a direct Foundry connection without a gateway or secret header, see
[`foundry-model-connection`](../foundry-model-connection/). For provisioning an
APIM gateway, see [`public-byom-apim`](../public-byom-apim/).

## Prerequisites

- An existing Foundry account and project with managed identity. Deploy into
that account's resource group using an identity with permission to write
both connections.
- An existing, reachable APIM gateway with a model API accepting the project's
identity token and the subscription-key header. Its Entra validation policy
must accept the supplied audience and project identity.
- Existing backend deployment and gateway-to-backend authentication/RBAC.
This template does **not** create gateway policies, model deployments, role
assignments, or network infrastructure.
- Secret-header support enabled in the consuming Foundry environment. Creating
these ARM resources alone does not enable runtime or flighted UI support
(`admin_connected_secret_headers`).
- Azure CLI and Bicep, and API `2026-07-01` availability in the target environment.

Bicep 0.41.2 reports `BCP081` because its bundled type definitions do not include
the `2026-07-01` connection API. Compilation still succeeds, but cannot validate
that API's resource properties. This version matches the exported connection
example; deployment and runtime verification are still required.

The endpoint in the parameter file is an AzureCloud placeholder. Supply the
actual gateway URL and audience for your environment; no cloud host is inferred.
The gateway application audience is independent of the subscription key and is
not necessarily the Cognitive Services audience.

## Deploy

Copy `samples/parameters.json` to an untracked local file and replace the
placeholders. Keep `subscriptionKey` as an ARM Key Vault reference rather than
placing the key in a committed file or command line. The vault must allow ARM
template deployment (`enabledForTemplateDeployment`), and the deploying identity
must have `Microsoft.KeyVault/vaults/deploy/action` on it. This template does not
change vault settings or permissions.

```bash
az login
az account set --subscription "<source-subscription>"
az bicep build --file main.bicep --outfile azuredeploy.json

az deployment group create \
--resource-group "<source-resource-group>" \
--name apim-secret-headers \
--template-file main.bicep \
--parameters @parameters.local.json
```

`subscriptionKey` is a required `@secure()` parameter with no default. It flows
only into the companion's `credentials.keys.header0`; it is not emitted in
outputs or model metadata. Do not print connection secrets, enable verbose
payload logging, or commit populated parameter files. Normal ARM exports/GETs
can omit credentials: an exported companion without `credentials.keys` is not
a complete redeployable secret configuration.

The sample uses a static model list. Set `modelDeploymentName` to the gateway's
deployment name and `modelName`/`modelVersion` to its actual catalog metadata.
The endpoint is deployment-path based (`deploymentInPath: 'true'`), matching
the APIM example; it is not the `/openai/v1` body-model configuration used by
the direct Foundry sample.

## Verify and use

Verify that both connections exist, have matching targets and project scope,
and that the parent references the companion's exact name and `header0`.
Do not retrieve the secret just to verify the relationship.

Use the existing prompt-agent Responses script with your project endpoint:

```bash
python ../public-byom-apim/samples/create-agent.py \
--endpoint "<project-endpoint>" \
--model "apim-private-header/<gateway-deployment-name>" \
--agent-name apim-secret-header-sample-agent
```

See the [shared agent instructions](../public-byom-apim/samples/create-agent.py)
for Python dependencies. Invocation incurs model usage charges and creates
an agent version and conversation. A successful response verifies runtime
resolution; Bicep compilation or ARM deployment alone does not.

If authentication fails, check **both** the gateway's Entra policy/audience and
its subscription-key requirement. A correct PMI token does not substitute for
a required key, and a correct key does not substitute for a valid identity.

## CI deployment fixture

The root `main.bicepparam` selects `tests/deploy.bicep` for the repository's
ephemeral deployment check. It creates a disposable account and project, then
deploys the **actual** `main.bicep` with a non-secret fixture key and a reserved
`.invalid` gateway hostname. It verifies ARM creation of the two connections,
not APIM authentication or inference; no real gateway or model is deployed.
The pipeline owns resource-group cleanup. Do not use this parameter file for
an existing project: follow the deployment instructions above instead.

## Updates and cleanup

The companion name is deterministic for the project and parent name. Redeploying
with a new key updates that companion in place, affecting the existing parent
immediately, even if a subsequent parent update fails. This minimal template
does not implement the UX's fresh-companion replacement behavior. For isolated
testing or replacement, deploy with a new parent connection name, verify it,
and retire the old pair only after checking consumers.

Delete the model connection before its companion, and only after ensuring no
other connection references it. Outputs include both resource IDs for scoped
cleanup, never the key. Separately remove any test agents/conversations you
created. Do not delete the existing project, gateway, Key Vault secret, or
resource group. Deleting a deployment record does not delete its resources.
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"metadata": {
"_generator": {
"name": "bicep",
"version": "0.48.1.52935",
"templateHash": "9763819720263725144"
}
},
"parameters": {
"accountName": {
"type": "string",
"metadata": {
"description": "Existing Foundry account in THIS deployment resource group."
}
},
"projectName": {
"type": "string",
"metadata": {
"description": "Existing project under accountName. The project must have a managed identity."
}
},
"targetUrl": {
"type": "string",
"metadata": {
"description": "Existing APIM model API base URL, without a trailing slash. Use the actual endpoint for your cloud."
}
},
"audience": {
"type": "string",
"metadata": {
"description": "Entra token audience accepted by the gateway, for example its api:// application ID URI. This is separate from the subscription key."
}
},
"modelDeploymentName": {
"type": "string",
"metadata": {
"description": "Existing deployment name exposed by the gateway."
}
},
"modelName": {
"type": "string",
"metadata": {
"description": "Underlying model name, which may differ from the deployment name."
}
},
"modelVersion": {
"type": "string",
"metadata": {
"description": "Underlying model version exposed by the gateway."
}
},
"connectionName": {
"type": "string",
"defaultValue": "apim-private-header",
"metadata": {
"description": "Model connection name. Agents reference <connectionName>/<modelDeploymentName>."
}
},
"headerName": {
"type": "string",
"defaultValue": "Ocp-Apim-Subscription-Key",
"metadata": {
"description": "Header whose value is stored as a secret in the companion connection."
}
},
"subscriptionKey": {
"type": "securestring",
"minLength": 1,
"metadata": {
"description": "Gateway subscription key. Supply securely, preferably through an ARM Key Vault parameter reference. Never place the value in metadata or outputs."
}
},
"inferenceAPIVersion": {
"type": "string",
"defaultValue": "2025-04-01-preview",
"metadata": {
"description": "Inference API version forwarded through the gateway."
}
}
},
"variables": {
"companionName": "[format('secret-headers-{0}', uniqueString(resourceId('Microsoft.CognitiveServices/accounts/projects', parameters('accountName'), parameters('projectName')), parameters('connectionName')))]"
},
"resources": [
{
"type": "Microsoft.CognitiveServices/accounts/projects/connections",
"apiVersion": "2026-07-01",
"name": "[format('{0}/{1}/{2}', parameters('accountName'), parameters('projectName'), variables('companionName'))]",
"properties": {
"category": "CustomKeys",
"authType": "CustomKeys",
"target": "[parameters('targetUrl')]",
"isSharedToAll": false,
"credentials": {
"keys": {
"header0": "[parameters('subscriptionKey')]"
}
},
"metadata": {
"secretHeadersFor": "[parameters('connectionName')]"
}
}
},
{
"type": "Microsoft.CognitiveServices/accounts/projects/connections",
"apiVersion": "2026-07-01",
"name": "[format('{0}/{1}/{2}', parameters('accountName'), parameters('projectName'), parameters('connectionName'))]",
"properties": {
"category": "ApiManagement",
"authType": "ProjectManagedIdentity",
"audience": "[parameters('audience')]",
"target": "[parameters('targetUrl')]",
"isSharedToAll": false,
"credentials": {},
"metadata": {
"deploymentInPath": "true",
"inferenceAPIVersion": "[parameters('inferenceAPIVersion')]",
"customHeaders": "{}",
"models": "[string(createArray(createObject('name', parameters('modelDeploymentName'), 'properties', createObject('model', createObject('name', parameters('modelName'), 'version', parameters('modelVersion'), 'format', 'OpenAI')))))]",
"secretHeaders": "[string(createObject(format('{0}', parameters('headerName')), createObject('connection', variables('companionName'), 'key', 'header0')))]"
}
},
"dependsOn": [
"[resourceId('Microsoft.CognitiveServices/accounts/projects/connections', parameters('accountName'), parameters('projectName'), variables('companionName'))]"
]
}
],
"outputs": {
"connectionId": {
"type": "string",
"value": "[resourceId('Microsoft.CognitiveServices/accounts/projects/connections', parameters('accountName'), parameters('projectName'), parameters('connectionName'))]"
},
"connectionName": {
"type": "string",
"value": "[parameters('connectionName')]"
},
"secretHeaderConnectionId": {
"type": "string",
"value": "[resourceId('Microsoft.CognitiveServices/accounts/projects/connections', parameters('accountName'), parameters('projectName'), variables('companionName'))]"
},
"modelReference": {
"type": "string",
"value": "[format('{0}/{1}', parameters('connectionName'), parameters('modelDeploymentName'))]"
}
}
}
Loading
Loading