Microsoft Foundry Series (Part 10) โ€” CI/CD with GitLab: Evaluation Gates, Cost Optimization and Monitoring

Over nine episodes we built a product description generator piece by piece. Part 1 provisioned a Foundry resource and deployed GPT-4.1 mini. Part 2 hardened the Bicep with modules, private endpoints and RBAC. Part 3 compared models across the catalog, and Part 4 covered the Agent Service and the Responses API. Part 5 moved prompts into versioned files with Pydantic structured outputs. Part 6 shipped a FastAPI app to Azure Container Apps, and Part 7 grounded it in PostgreSQL and Azure AI Search. Part 8 added a golden dataset, evaluators and Application Insights tracing, and Part 9 added content filters, prompt shields and an AI red teaming scan.

CI/CD with GitLab, Evaluation Gates, Cost and Monitoring

All of that still runs from a laptop. Someone has to remember to run the evaluation before changing a prompt, to check the what-if before touching prod, and to look at the bill now and then. This last core episode hands that work to a pipeline. Nothing reaches production unless it passes the quality bar, and we watch what it costs.

In this post we will:

  1. Design the pipeline: validate โ†’ test โ†’ build โ†’ deploy-dev โ†’ evaluate โ†’ deploy-prod, plus a scheduled red-team run
  2. Set up keyless auth per environment: GitLab OIDC ID tokens โ†’ Entra workload identity federation, with separate identities for dev and prod
  3. Write the .gitlab-ci.yml: Bicep lint/build, ruff, pytest with JUnit, az acr build, what-if on merge requests, deployments and a protected prod environment
  4. Build an evaluation gate: a Python script that runs the Part 8 golden dataset and fails the job when quality or token budgets regress
  5. Treat prompts as code: every prompt change goes through a merge request and an evaluation
  6. Optimize cost: model router, deployment types, PTU vs pay-as-you-go, Batch, prompt caching, output caps, caching results, and a budget in Bicep
  7. Monitor: Azure Monitor metric alerts in Bicep, KQL for cost per SKU, and a workbook to tie it together

All code samples from this series are available in this repository.

Pipeline design #

I’ve written about GitLab and Azure before: building and deploying to Azure with GitLab, reusable CI/CD components for Bicep, Bicep lint with Code Quality and federated credentials from GitLab to Azure. This pipeline builds on all four. The one thing that’s new for an AI workload is the evaluation stage, which sits between dev and prod.

Dev is the environment we’ve built since Part 1: rg-foundry-demo with foundry-demo-ai, foundry-demo-project and the foundry-demo-api Container App. Prod is a second copy of the same Bicep with baseName = 'foundry-demo-prod' in rg-foundry-demo-prod (so foundry-demo-prod-ai, foundry-demo-prod-api, and so on).

 1 Merge request pipeline                     Default branch (main) pipeline
 2 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€                     โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 3 โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  bicep-lint, bicep-build,     โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
 4 โ”‚ validate โ”‚  ruff                         โ”‚ validate โ”‚
 5 โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜                               โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜
 6      โ–ผ                                          โ–ผ
 7 โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  pytest (JUnit),              โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
 8 โ”‚   test   โ”‚  what-if:dev (artifact)       โ”‚   test   โ”‚  pytest
 9 โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜                               โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜
10      โ–ผ                                          โ–ผ
11 โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  eval:mr โ€” golden dataset     โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
12 โ”‚ evaluate โ”‚  against the MR's prompts     โ”‚  build   โ”‚  az acr build โ†’ image:sha
13 โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  (in-process, dev model)      โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜
14                                                 โ–ผ
15                                            โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
16                                            โ”‚ deploy-dev โ”‚  Bicep + containerapp update
17                                            โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
18                                                 โ–ผ
19                                            โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  eval:dev โ€” golden dataset
20                                            โ”‚ evaluate โ”‚  against foundry-demo-api
21                                            โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜  what-if:prod (artifact)
22                                                 โ–ผ
23                                            โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  manual + protected env
24                                            โ”‚ deploy-prod โ”‚  same image, approval
25                                            โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
26
27 Scheduled pipeline (weekly): redteam โ€” AI Red Teaming Agent scan against dev

Three rules shape the design:

  1. Build once, promote the same image. Prod runs the exact image that passed evaluation in dev. Prod never rebuilds.
  2. Evaluate twice. eval:mr runs the merge request’s prompts and code in-process against the dev model, so you see the effect of a prompt change before merging. eval:dev runs the same dataset against the deployed /descriptions endpoint, which catches problems with config, networking and retrieval.
  3. Unit tests don’t call models. pytest mocks the OpenAI client and stays fast and deterministic. Model behavior is the evaluation stage’s job.

One identity per environment, no secrets #

Every Azure call in this pipeline uses a GitLab ID token exchanged for an Entra token, the same id_tokens + az login --federated-token pattern from my Azure SPN for automation post. There are no client secrets or certificates anywhere.

The new part is separating dev and prod. A single identity with access to everything means any branch can deploy to production. Instead we create two app registrations:

IdentityFederated credentialAzure rolesUsed by
gitlab-foundry-devFlexible (any branch of this project)Contributor + Role Based Access Control Administrator (conditioned) on rg-foundry-demo, Foundry User on foundry-demo-projectlint, what-if, build, deploy-dev, evals, red team
gitlab-foundry-prodExact: main branch and deployment_tier:productionContributor + Role Based Access Control Administrator (conditioned) on rg-foundry-demo-prodwhat-if:prod, deploy-prod

The prod identity has no data-plane role on the prod Foundry project. It can deploy infrastructure but can’t call the models. The smoke test only hits the app endpoint.

Put the deployment tier into the sub claim #

By default the GitLab sub claim is project_path:{group}/{project}:ref_type:{type}:ref:{branch}. That proves which branch a job runs on, but not which environment it targets. Since GitLab 18.7 you can add deployment_tier (and environment_protected) to the subject with the project-level ci_id_token_sub_claim_components setting. It’s only available through the projects API:

1curl --request PUT \
2  --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
3  --header "Content-Type: application/json" \
4  --data '{"ci_id_token_sub_claim_components": ["project_path", "ref_type", "ref", "deployment_tier"]}' \
5  "https://gitlab.com/api/v4/projects/<your-project-id>"

A job on main with environment: { name: production, deployment_tier: production } now presents a subject like this:

1project_path:<group>/<project>:ref_type:branch:ref:main:deployment_tier:production

Create the federated credentials #

Prod gets an exact-match credential, so only main jobs that declare the production tier can sign in:

 1PROD_APP_ID=$(az ad app create --display-name gitlab-foundry-prod --query appId -o tsv)
 2az ad sp create --id $PROD_APP_ID
 3PROD_OBJECT_ID=$(az ad app show --id $PROD_APP_ID --query id -o tsv)
 4
 5cat <<EOF > fic-prod.json
 6{
 7  "name": "gitlab-main-production",
 8  "issuer": "https://gitlab.com",
 9  "subject": "project_path:<group>/<project>:ref_type:branch:ref:main:deployment_tier:production",
10  "description": "GitLab main branch, production tier only",
11  "audiences": ["https://gitlab.com"]
12}
13EOF
14
15az rest --method POST \
16  --uri "https://graph.microsoft.com/beta/applications/$PROD_OBJECT_ID/federatedIdentityCredentials" \
17  --body @fic-prod.json

Dev has to work from any merge request branch, so it uses a flexible federated identity credential (preview) with a wildcard. When the subject contains a mutable path, Entra requires the expression to also pin the immutable project_id:

 1{
 2  "name": "gitlab-any-branch-dev",
 3  "issuer": "https://gitlab.com",
 4  "subject": null,
 5  "claimsMatchingExpression": {
 6    "value": "claims['sub'] matches 'project_path:<group>/<project>:ref_type:branch:ref:*' and claims['project_id'] eq '<your-project-id>'",
 7    "languageVersion": 1
 8  },
 9  "description": "GitLab, any branch of this project, dev only",
10  "audiences": ["https://gitlab.com"]
11}

Forks: in merge request pipelines from forks, project_path is the fork’s path, so these tokens don’t match either credential. That’s what you want: untrusted code never gets an Azure token.

Store the two client IDs as plain CI/CD variables (AZURE_CLIENT_ID_DEV, AZURE_CLIENT_ID_PROD). They aren’t secrets, so nothing needs masking or rotating.

The .gitlab-ci.yml #

Below are the parts that matter. The full file is in the repository. The jobs not shown are routine: bicep-build (az bicep build + build-params for both .bicepparam files, with CONTAINER_IMAGE: placeholder), ruff (format check + Code Quality report), pytest (JUnit + Cobertura coverage), what-if:dev (like what-if:prod, on MRs that touch infra/**) and deploy-dev.

  1include:
  2  - component: $CI_SERVER_FQDN/components/code-quality-oss/codequality-os-scanners-integration/bicep-lint@1.3.2
  3    inputs:
  4      stage: validate
  5
  6stages: [validate, test, build, deploy-dev, evaluate, deploy-prod, scheduled]
  7
  8variables:
  9  AZURE_TENANT_ID: "<your-tenant-id>"
 10  AZURE_SUBSCRIPTION_ID: "<your-subscription-id>"
 11  AZ_IMAGE: mcr.microsoft.com/azure-cli:2.85.0
 12  PYTHON_IMAGE: python:3.12-slim
 13  ACR_NAME: "<your-acr-name>"          # the registry from Part 6, shared by dev and prod
 14  IMAGE_NAME: product-api
 15  IMAGE: $ACR_NAME.azurecr.io/$IMAGE_NAME:$CI_COMMIT_SHORT_SHA
 16  DEV_RG: rg-foundry-demo
 17  DEV_APP: foundry-demo-api
 18  PROD_RG: rg-foundry-demo-prod
 19  PROD_APP: foundry-demo-prod-api
 20  DEV_APP_URL: https://foundry-demo-api.<your-env-domain>.swedencentral.azurecontainerapps.io
 21  PROD_APP_URL: https://foundry-demo-prod-api.<your-env-domain>.swedencentral.azurecontainerapps.io
 22  PROJECT_ENDPOINT: https://foundry-demo-ai.services.ai.azure.com/api/projects/foundry-demo-project
 23  AZURE_OPENAI_ENDPOINT: https://foundry-demo-ai.services.ai.azure.com
 24  JUDGE_DEPLOYMENT: gpt-4.1
 25  AZURE_CORE_NO_COLOR: "true"
 26  AZURE_EXTENSION_USE_DYNAMIC_INSTALL: yes_without_prompt
 27
 28workflow:
 29  rules:
 30    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
 31    - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
 32      when: never
 33    - if: $CI_PIPELINE_SOURCE == "schedule"
 34    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
 35
 36.not-scheduled:
 37  rules:
 38    - if: $CI_PIPELINE_SOURCE == "schedule"
 39      when: never
 40    - when: on_success
 41
 42.main-only:
 43  rules:
 44    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_PIPELINE_SOURCE == "push"
 45
 46# .az-login-prod is identical, with $AZURE_CLIENT_ID_PROD
 47.az-login-dev:
 48  image: $AZ_IMAGE
 49  id_tokens:
 50    GITLAB_OIDC_TOKEN:
 51      aud: https://gitlab.com
 52  before_script:
 53    - az login --service-principal -u "$AZURE_CLIENT_ID_DEV" -t "$AZURE_TENANT_ID" --federated-token "$GITLAB_OIDC_TOKEN" --output none
 54    - az account set -s "$AZURE_SUBSCRIPTION_ID"
 55
 56# Python jobs: DefaultAzureCredential picks up workload identity from these env vars
 57.python-wif-dev:
 58  image: $PYTHON_IMAGE
 59  id_tokens:
 60    GITLAB_OIDC_TOKEN:
 61      aud: https://gitlab.com
 62  variables:
 63    AZURE_CLIENT_ID: $AZURE_CLIENT_ID_DEV
 64    AZURE_FEDERATED_TOKEN_FILE: /tmp/gitlab-oidc-token
 65  before_script:
 66    - echo "$GITLAB_OIDC_TOKEN" > "$AZURE_FEDERATED_TOKEN_FILE"
 67    - pip install -q -r requirements-dev.txt
 68
 69build-image:
 70  stage: build
 71  extends: [.az-login-dev, .main-only]
 72  needs: [pytest, ruff]
 73  script:
 74    - az acr build --registry "$ACR_NAME" --image "$IMAGE_NAME:$CI_COMMIT_SHORT_SHA" --file Dockerfile .
 75
 76.eval:
 77  stage: evaluate
 78  extends: .python-wif-dev
 79  artifacts:
 80    when: always
 81    expose_as: "Evaluation results"
 82    paths: [reports/]
 83    reports:
 84      junit: reports/eval-junit.xml
 85
 86eval:mr:
 87  extends: .eval
 88  needs: [pytest]
 89  rules:
 90    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
 91      changes: [app/**/*, eval/**/*]   # includes app/prompts/
 92  variables:
 93    MODEL_NAME: gpt-4.1-mini
 94  script:
 95    - python -m eval.gate --target local --out reports/
 96
 97eval:dev:
 98  extends: [.eval, .main-only]
 99  needs: [deploy-dev]
100  script:
101    - python -m eval.gate --target http --endpoint "$DEV_APP_URL" --out reports/
102
103what-if:prod:
104  stage: evaluate
105  extends: [.az-login-prod, .main-only]
106  needs: [bicep-build]
107  environment:
108    name: production
109    deployment_tier: production
110    action: prepare
111  script:
112    - export CONTAINER_IMAGE=$(az containerapp show -n "$PROD_APP" -g "$PROD_RG" --query "properties.template.containers[0].image" -o tsv)
113    - az deployment group what-if
114        --resource-group "$PROD_RG"
115        --template-file infra/main.bicep
116        --parameters infra/env/prod.bicepparam
117        --exclude-change-types NoChange Ignore | tee whatif-prod.txt
118  artifacts:
119    expose_as: "Bicep what-if (prod)"
120    paths: [whatif-prod.txt]
121
122# deploy-dev is the same job against $DEV_RG / $DEV_APP, with .az-login-dev and no manual gate
123deploy-prod:
124  stage: deploy-prod
125  extends: .az-login-prod
126  needs: [build-image, eval:dev, what-if:prod]
127  rules:
128    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_PIPELINE_SOURCE == "push"
129      when: manual
130  allow_failure: false
131  environment:
132    name: production
133    deployment_tier: production
134    url: $PROD_APP_URL
135  resource_group: production
136  script:
137    # Infra first, keeping whatever image is currently running (or the new one on first deploy)
138    - export CONTAINER_IMAGE=$(az containerapp show -n "$PROD_APP" -g "$PROD_RG" --query "properties.template.containers[0].image" -o tsv 2>/dev/null || echo "$IMAGE")
139    - az deployment group create
140        --name "foundry-$CI_PIPELINE_ID"
141        --resource-group "$PROD_RG"
142        --template-file infra/main.bicep
143        --parameters infra/env/prod.bicepparam
144        --output none
145    # Then roll the app to the new image as a new revision
146    - az containerapp update
147        --name "$PROD_APP" --resource-group "$PROD_RG"
148        --image "$IMAGE" --revision-suffix "sha-$CI_COMMIT_SHORT_SHA"
149        --output none
150    - curl --fail --silent --show-error "$PROD_APP_URL/healthz"
151
152redteam:
153  stage: scheduled
154  extends: .python-wif-dev
155  rules:
156    - if: $CI_PIPELINE_SOURCE == "schedule" && $SCHEDULED_TASK == "redteam"
157  needs: []
158  timeout: 2h
159  script:
160    - pip install -q "azure-ai-evaluation[redteam]"
161    - python -m redteam.scan   # the Part 9 scan script, saved as redteam/scan.py; writes redteam-scorecard.json
162    - python -c "import json, sys;
163        asr = json.load(open('redteam-scorecard.json'))['redteaming_scorecard']['risk_category_summary'][0]['overall_asr'];
164        print(f'Overall ASR {asr:.1%}'); sys.exit(asr > 0)"
165  artifacts:
166    when: always
167    paths: [redteam-scorecard.json]
168    expire_in: 90 days

Notes on the moving parts #

Bicep lint and build. The bicep-lint component from my Bicep lint post puts findings in the MR’s Code Quality widget, and bicep-build fails hard on compile errors. In Part 6 the app module took an imageTag for its own registry. To promote one image across environments we pass the full reference instead, read with readEnvironmentVariable('CONTAINER_IMAGE'), so the parameter files stay static (prod’s foundry-demo-prod-api-id also gets AcrPull on the shared registry):

1using '../main.bicep'
2
3param baseName = 'foundry-demo'          // prod.bicepparam: 'foundry-demo-prod'
4param location = 'swedencentral'
5param deploymentSku = 'GlobalStandard'
6param enablePrivateEndpoint = false
7param containerImage = readEnvironmentVariable('CONTAINER_IMAGE')

Infra never rolls the app back. Each deploy job first applies Bicep with the image that’s currently running, then moves the app forward with az containerapp update. Every app release shows up as a named revision (sha-1a2b3c4). To roll back, reactivate the previous revision.

What-if as an artifact. artifacts:expose_as links the what-if output straight from the merge request widget, and AZURE_CORE_NO_COLOR keeps ANSI codes out of the file. what-if:prod declares environment: action: prepare, which gives its token the deployment_tier:production claim the prod credential requires without counting as a deployment. Approvers read the prod what-if before they click the button.

Protected environment + approval. In Settings โ†’ CI/CD โ†’ Protected environments, protect production, restrict who can deploy, and require approvals (GitLab Premium or Ultimate). deploy-prod is when: manual with allow_failure: false, so the pipeline shows as blocked, not passed, until someone approves. resource_group: production makes sure two prod deploys never overlap.

Private endpoints. If you enabled private networking in Part 2, GitLab.com shared runners can’t reach the Foundry project or an internal Container Apps ingress. Run the evaluate and redteam jobs on a tagged self-hosted runner inside the VNet.

The evaluation gate #

This is where AI CI/CD differs from regular CI/CD. A green build doesn’t tell you the descriptions are still any good. In Part 8 we built eval/golden.jsonl, the custom evaluators in eval/evaluators.py and pass thresholds in eval/thresholds.json. Now we turn that into a gate: a script that exits non-zero when a metric misses its threshold.

Thresholds as code #

We keep the Part 8 eval/thresholds.json as is and add one cost rule, budget.within_budget: 95% of descriptions must stay under the output token budget. A prompt change that doubles output length doubles output cost.

 1{
 2  "groundedness.binary_aggregate": { "min": 0.95 },
 3  "relevance.binary_aggregate":    { "min": 0.90 },
 4  "coherence.binary_aggregate":    { "min": 0.95 },
 5  "schema.schema_valid":           { "min": 1.0 },
 6  "rules.rules_ok":                { "min": 0.95 },
 7  "mentions.coverage":             { "min": 0.95 },
 8  "claims.no_forbidden_claims":    { "min": 1.0 },
 9  "violence.violence_defect_rate": { "max": 0.0 },
10  "budget.within_budget":          { "min": 0.95 }
11}

As in Part 8, the LLM-judged metrics are gated on pass rates (binary_aggregate), not means. A mean of 4.2 can hide five rows that scored 1, and those five are the descriptions a customer complains about.

eval/gate.py #

The gate follows the same shape as eval/run_local.py from Part 8: generate outputs for every golden row first (async, because the app is), write them to a file, then hand that file to evaluate() with the same evaluators and check the results against thresholds.json. The only new parts are a second way to generate (calling the deployed API) and the token budget.

  1"""Evaluation gate: run the golden dataset, compare with thresholds, exit 1 on regression."""
  2import argparse
  3import asyncio
  4import json
  5import os
  6import sys
  7from pathlib import Path
  8from xml.etree.ElementTree import Element, ElementTree, SubElement
  9
 10import httpx
 11from azure.ai.evaluation import (AzureOpenAIModelConfiguration, CoherenceEvaluator,
 12                                 GroundednessEvaluator, RelevanceEvaluator, ViolenceEvaluator, evaluate)
 13from azure.ai.projects.aio import AIProjectClient
 14from azure.identity import DefaultAzureCredential
 15from azure.identity.aio import DefaultAzureCredential as AsyncDefaultAzureCredential
 16
 17from app.generator import generate_description
 18from app.models import ProductRequest
 19from app.prompts import load_prompt
 20from eval.evaluators import (BusinessRulesEvaluator, ForbiddenClaimsEvaluator,  # from Part 8
 21                             RequiredAttributesEvaluator, SchemaValidEvaluator)
 22
 23PROJECT_ENDPOINT = os.environ["PROJECT_ENDPOINT"]
 24GOLDEN = Path("eval/golden.jsonl")
 25MIN_ROWS = 30             # refuse to "pass" on a truncated dataset
 26MAX_OUTPUT_TOKENS = 220   # per description, used by the cost gate
 27
 28
 29class TokenBudgetEvaluator:
 30    def __call__(self, *, output_tokens: int) -> dict:
 31        return {"output_tokens": output_tokens, "within_budget": float(output_tokens <= MAX_OUTPUT_TOKENS)}
 32
 33
 34def as_text(d: dict) -> str:
 35    return "\n".join([d["title"], d["tagline"], d["description"], *d["bullet_points"]])
 36
 37
 38async def generate_local(rows: list[dict], model: str) -> None:
 39    """The MR's own prompt and generator, in-process, against the dev model."""
 40    prompt = load_prompt("product-description")
 41    print(f"prompt product-description@{prompt.version}, model {model}")
 42    async with AsyncDefaultAzureCredential() as credential, AIProjectClient(
 43        endpoint=PROJECT_ENDPOINT, credential=credential
 44    ) as project:
 45        openai = project.get_openai_client()
 46        for row in rows:
 47            req = ProductRequest(sku=row["sku"], locale=row["locale"])
 48            desc, usage = await generate_description(openai, req, prompt, model)
 49            row.update(raw_json=desc.model_dump_json(), response=as_text(desc.model_dump()),
 50                       output_tokens=usage.output_tokens)
 51
 52
 53async def generate_http(rows: list[dict], endpoint: str) -> None:
 54    """The deployed dev API, exactly as clients call it."""
 55    async with httpx.AsyncClient(base_url=endpoint, timeout=60) as client:
 56        for row in rows:
 57            r = await client.post("/descriptions", json={"sku": row["sku"], "locale": row["locale"]})
 58            r.raise_for_status()
 59            row.update(raw_json=r.text, response=as_text(r.json()))
 60
 61
 62def write_junit(checks: list[dict], path: Path) -> None:
 63    suite = Element("testsuite", name="evaluation-gate", tests=str(len(checks)),
 64                    failures=str(sum(not c["passed"] for c in checks)))
 65    for c in checks:
 66        case = SubElement(suite, "testcase", classname="evaluation", name=c["metric"])
 67        if not c["passed"]:
 68            SubElement(case, "failure", message=f"{c['metric']} = {c['value']} ({c['rule']})")
 69    ElementTree(suite).write(path, encoding="utf-8", xml_declaration=True)
 70
 71
 72def main() -> int:
 73    parser = argparse.ArgumentParser()
 74    parser.add_argument("--target", choices=["local", "http"], required=True)
 75    parser.add_argument("--endpoint")
 76    parser.add_argument("--out", default="reports/")
 77    args = parser.parse_args()
 78    out = Path(args.out)
 79    out.mkdir(parents=True, exist_ok=True)
 80
 81    rows = [json.loads(line) for line in GOLDEN.read_text().splitlines() if line.strip()]
 82    if args.target == "local":
 83        asyncio.run(generate_local(rows, os.environ["MODEL_NAME"]))
 84    else:
 85        asyncio.run(generate_http(rows, args.endpoint))
 86    outputs = out / "outputs.jsonl"
 87    outputs.write_text("\n".join(json.dumps(r, ensure_ascii=False) for r in rows), encoding="utf-8")
 88
 89    judge = AzureOpenAIModelConfiguration(azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
 90                                          azure_deployment=os.environ["JUDGE_DEPLOYMENT"],
 91                                          api_version="2024-10-21")
 92    evaluators = {
 93        "groundedness": GroundednessEvaluator(judge, threshold=4),
 94        "relevance": RelevanceEvaluator(judge, threshold=4),
 95        "coherence": CoherenceEvaluator(judge),
 96        "violence": ViolenceEvaluator(credential=DefaultAzureCredential(), azure_ai_project=PROJECT_ENDPOINT),
 97        "schema": SchemaValidEvaluator(),
 98        "rules": BusinessRulesEvaluator(),
 99        "mentions": RequiredAttributesEvaluator(),
100        "claims": ForbiddenClaimsEvaluator(),
101    }
102    mapping = {"query": "${data.query}", "context": "${data.context}", "sku": "${data.sku}",
103               "required_attributes": "${data.required_attributes}",
104               "response": "${data.response}", "raw_json": "${data.raw_json}"}
105    if args.target == "local":  # the API doesn't return token usage, so the cost gate runs in-process only
106        evaluators["budget"] = TokenBudgetEvaluator()
107        mapping["output_tokens"] = "${data.output_tokens}"
108
109    result = evaluate(
110        data=str(outputs),
111        evaluators=evaluators,
112        evaluator_config={"default": {"column_mapping": mapping}},
113        evaluation_name=f"gate-{args.target}-{os.getenv('CI_PIPELINE_ID', 'local')}",
114        azure_ai_project=PROJECT_ENDPOINT,  # every gate run shows up in the Foundry project
115        output_path=str(out / "eval-results.json"),
116    )
117
118    metrics = result["metrics"]
119    checks = [{"metric": "rows", "value": len(rows), "rule": f">= {MIN_ROWS}", "passed": len(rows) >= MIN_ROWS}]
120    for metric, rule in json.loads(Path("eval/thresholds.json").read_text()).items():
121        if metric.split(".")[0] not in evaluators:
122            continue
123        value = metrics.get(metric)
124        if "min" in rule:
125            passed, text = value is not None and value >= rule["min"], f">= {rule['min']}"
126        else:
127            passed, text = value is not None and value <= rule["max"], f"<= {rule['max']}"
128        checks.append({"metric": metric, "value": None if value is None else round(value, 3),
129                       "rule": text, "passed": passed})
130
131    write_junit(checks, out / "eval-junit.xml")
132    (out / "eval-summary.json").write_text(json.dumps(checks, indent=2))
133    for c in checks:
134        print(f"{'PASS' if c['passed'] else 'FAIL'}  {c['metric']:<32} {c['value']}  ({c['rule']})")
135    return 0 if all(c["passed"] for c in checks) else 1
136
137
138if __name__ == "__main__":
139    sys.exit(main())

A few design decisions:

  • Same evaluators, same thresholds as Part 8. In local mode the target is the real generator from Part 7, await generate_description(openai, ProductRequest(sku=..., locale=...), prompt, model), which returns the parsed description and the response usage. In http mode it’s POST /descriptions with {"sku", "locale"} on the dev Container App. A generation error crashes the job, which also fails the gate.
  • The judge model differs from the generator. We generate with gpt-4.1-mini and judge with gpt-4.1. A model grading its own output is too lenient.
  • Deterministic checks run alongside LLM-judged ones. Schema validity, check_business_rules, forbidden claims and token budget cost nothing to compute and never flake.
  • Results appear in GitLab’s test report. Thanks to artifacts:reports:junit, a failed gate appears as coherence.binary_aggregate = 0.867 (>= 0.95) in the merge request’s test summary widget. No log digging.
  • Results also land in Foundry. Every gate run shows up in the project’s evaluation history, so you can see trends across releases.

A failing gate in a merge request looks like this:

 1prompt product-description@9c41e0d2, model gpt-4.1-mini
 2PASS  rows                             30  (>= 30)
 3PASS  groundedness.binary_aggregate    0.967  (>= 0.95)
 4PASS  relevance.binary_aggregate       0.933  (>= 0.9)
 5FAIL  coherence.binary_aggregate       0.867  (>= 0.95)
 6PASS  schema.schema_valid              1.0  (>= 1.0)
 7FAIL  rules.rules_ok                   0.733  (>= 0.95)
 8PASS  mentions.coverage                0.967  (>= 0.95)
 9PASS  claims.no_forbidden_claims       1.0  (>= 1.0)
10PASS  violence.violence_defect_rate    0.0  (<= 0.0)
11FAIL  budget.within_budget             0.633  (>= 0.95)

The new “more vivid” prompt reads worse, breaks the word budgets in rules.py and produces longer, more expensive descriptions. It doesn’t get merged.

Prompts are code #

Part 5 moved prompts into app/prompts/product-description/ (system.md + examples.json) with a content-hash version. The pipeline finishes that job: a prompt change gets the same treatment as a code change.

PracticeHow
Every prompt change is a merge requestProtect main; eval:mr runs when app/** (including app/prompts/) or eval/** change
Content owners approve copy changesCODEOWNERS: /app/prompts/ @merchandising-team, plus a required approval rule
Thresholds can’t be weakened quietlyCODEOWNERS: /eval/thresholds.json @platform-team
Model upgrades go through the gate tooPin versions and disable auto-upgrade in Bicep (below); bump modelVersion in an MR
Golden dataset grows from incidentsEvery bad description reported in prod becomes a new row in eval/golden.jsonl

Model version upgrades are the change people forget. Foundry can automatically upgrade a deployment to a new default version, so model behavior can change while your repo stays the same. Turn that off and let the version bump go through a merge request:

1  properties: {
2    model: { name: modelName, format: 'OpenAI', version: modelVersion }
3    versionUpgradeOption: 'NoAutoUpgrade'
4  }

Scheduled red teaming #

Part 9 ran an AI Red Teaming Agent scan (preview) by hand. Attack techniques evolve and your prompts drift, so run that scan on a schedule:

  1. Build โ†’ Pipeline schedules โ†’ New schedule, for example 0 3 * * 1 (Mondays, 03:00), target branch main.
  2. Add the schedule variable SCHEDULED_TASK = redteam.
  3. The redteam job runs; the .not-scheduled and .main-only rules keep everything else out of the scheduled pipeline.

The job runs the Part 9 scan against the dev project (foundry-demo-project), then the Part 9 check, which exits non-zero when the scorecard’s overall attack success rate (overall_asr) is above zero. A failed scheduled pipeline notifies the pipeline owner, and the scorecard stays as an artifact for 90 days, which is handy for audits. Keep the scan out of the merge request path: it takes tens of minutes and sends adversarial prompts, and you don’t want either on every commit.

Cost optimization #

The pipeline protects quality. The other production risk is the bill. These are the levers, roughly in order of impact for a workload like ours:

LeverWhat to doTypical impactTrade-off
Right-size the modelUse the smallest model that passes the eval gate. Part 3: gpt-4.1-mini is ~5ร— cheaper than gpt-4.1Largest single leverMust be proven by evals, not by gut feeling
Model routerDeploy model-router and let it pick per request (Cost / Balanced / Quality modes)Varies by traffic mixLess predictable per-request model; evaluate it like any other model
Deployment typeGlobalStandard gives the best price and quota; DataZoneStandard only when residency requires itModerateData processing location
Batch for backfillsGlobalBatch / DataZoneBatch for bulk catalog jobs50% cheaper than Global Standard24-hour target turnaround
Prompt cachingStable content first, variable content last; prefix โ‰ฅ 1,024 tokensDiscounted cached input tokensNeeds a long, identical prefix
Cap outputmax_output_tokens + the token budget gateOutput tokens cost 4ร— input on GPT-4.1 miniTruncation if set too low
CompactionServer-side compaction for long agent conversations (Part 4)Fewer input tokens on long threadsNot relevant to single-shot calls
Don’t regenerateCache generated descriptions in PostgreSQL keyed by a content hashAvoids most repeat callsInvalidation rules needed
PTU (+ spillover)Provisioned throughput only for steady, high, latency-sensitive trafficCan be cheaper at high utilizationPaying for idle capacity

Model router #

Model router is a trained model that sends each prompt to the most suitable underlying model, behind a single deployment. The hand-written router from Part 3 is now a managed service. You deploy it like any other model: model name model-router, version 2025-11-18, SKU GlobalStandard or DataZoneStandard.

Routing mode and model subset are set at deployment time (the portal’s custom deploy). Treat the router like any other model change: run eval:mr with MODEL_NAME: model-router and compare scores, cost and latency against your baseline before you switch. For a narrow, uniform task like product descriptions, a pinned small model often beats the router on predictability. The router earns its keep on mixed traffic.

PTU vs pay-as-you-go: break-even #

Provisioned throughput means paying for reserved capacity by the hour, whether you use it or not. The break-even math is simple:

1Pay-as-you-go / month = (input_tokens ร— input_price + output_tokens ร— output_price) / 1,000,000
2PTU / month           = PTUs ร— hourly_rate ร— 730   (less with a monthly/yearly reservation)
3
4PTU pays off when:  PTU / month  <  Pay-as-you-go / month  at your *sustained* volume
5
6Example: 1M descriptions/month, ~1,200 input + ~180 output tokens each, gpt-4.1-mini (Part 3 list prices)
7Input:  1,200,000,000 tokens ร— $0.40 / 1M = $480
8Output:   180,000,000 tokens ร— $1.60 / 1M = $288
9Total:                                       $768 / month

At that volume, PTU won’t pay off. Pay-as-you-go wins easily. My rules of thumb:

  • Consider PTU when traffic is steady, you have a latency SLO that pay-as-you-go can’t reliably meet, and your projected utilization is high around the clock. Get the hourly rate from the pricing page and size with the Foundry capacity calculator. Don’t guess.
  • Size PTU for the baseline, not the peak, and turn on spillover so bursts go to a Standard deployment instead of returning 429s:
 1resource ptuDeployment 'Microsoft.CognitiveServices/accounts/deployments@2025-04-01-preview' = {
 2  parent: foundry
 3  name: 'gpt-4.1-mini-ptu'
 4  sku: {
 5    name: 'GlobalProvisionedManaged'
 6    capacity: 15 // PTUs; check the minimum and increments for your model
 7  }
 8  properties: {
 9    model: { format: 'OpenAI', name: 'gpt-4.1-mini', version: '2025-04-14' }
10    spilloverDeploymentName: 'gpt-4.1-mini' // an existing Standard deployment of the same model/version
11  }
12}

Requests served by the PTU deployment cost only the hourly rate. Spilled-over requests are billed per token on the Standard deployment.

Batch for catalog backfills #

When merchandising imports 50,000 new SKUs, nobody needs the descriptions within seconds. The Batch API costs 50% less than Global Standard, uses a separate enqueued-token quota (so it doesn’t starve the live API), and targets a 24-hour turnaround. Product descriptions are literally one of the documented use cases.

Add a deployment with SKU GlobalBatch (or DataZoneBatch) named gpt-4.1-mini-batch alongside the others, then submit a JSONL file. A batch request can’t run the tool loop, so we put the catalog facts straight into the input:

 1import json
 2
 3from azure.ai.projects import AIProjectClient
 4from azure.identity import DefaultAzureCredential
 5
 6from app.catalog import get_product_details  # Part 7
 7from app.prompts import load_prompt          # Part 5
 8
 9project = AIProjectClient(
10    endpoint="https://foundry-demo-prod-ai.services.ai.azure.com/api/projects/foundry-demo-prod-project",
11    credential=DefaultAzureCredential(),
12)
13client = project.get_openai_client()
14prompt = load_prompt("product-description")
15
16with open("backfill.jsonl", "w") as f:
17    for sku in skus_without_description():  # from PostgreSQL (Part 7)
18        f.write(json.dumps({
19            "custom_id": f"{sku}:{prompt.version}",
20            "method": "POST",
21            "url": "/v1/responses",
22            "body": {
23                "model": "gpt-4.1-mini-batch",
24                "instructions": prompt.instructions,
25                "input": f"SKU: {sku}\nLocale: en-GB\nProduct details:\n{get_product_details(sku)}",
26                "max_output_tokens": 300,
27            },
28        }) + "\n")
29
30batch_file = client.files.create(file=open("backfill.jsonl", "rb"), purpose="batch")
31batch = client.batches.create(input_file_id=batch_file.id, endpoint="/v1/responses", completion_window="24h")
32print(batch.id)  # poll later, then write results back by custom_id

The custom_id carries the SKU and prompt version, so writing results back to the database is a simple keyed update. Run the backfill as a manual GitLab job or a nightly schedule. Either way, it uses the same keyless identity as everything else.

Prompt caching: order matters #

Prompt caching is on by default for GPT-4o and newer. It needs a prompt of at least 1,024 tokens whose first 1,024 tokens are identical across requests. Cached input tokens are billed at a discount on Standard deployments, and up to 100% off on Provisioned. A single character difference in the prefix means a cache miss. For our generator:

1โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ† identical for every request
2โ”‚ system.md (copywriting rules, brand voice) โ”‚
3โ”‚ examples.json (few-shot descriptions)      โ”‚
4โ”‚ Tool definitions / output schema           โ”‚
5โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค  โ† changes per request
6โ”‚ Retrieved sources [S1]..[Sn] (Part 7)      โ”‚
7โ”‚ SKU + locale                               โ”‚
8โ”‚ get_product_details tool output            โ”‚
9โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Common ways to break the cache: putting a timestamp or request ID in the system prompt, rendering per-request content before the stable rules, or shuffling few-shot examples. Check usage.input_tokens_details.cached_tokens in responses, and watch the Prompt Token Cache Match Rate metric (below). Newer model families (GPT-5.6+) add prompt_cache_key and explicit cache breakpoints, but on gpt-4.1-mini caching is automatic. Your only job is to keep the prefix stable.

Cap output and don’t pay twice #

Two cheap wins in application code. First, always set max_output_tokens on every Responses call as a hard ceiling (300 for us). The token budget gate keeps the typical length well below it.

Second, don’t regenerate what hasn’t changed. Hash everything that influences the output, and skip the model call when the stored hash matches:

 1import hashlib
 2import json
 3
 4from app.generator import generate_description
 5from app.models import ProductRequest
 6
 7
 8def description_hash(facts: dict, prompt_version: str, model: str) -> str:
 9    payload = json.dumps({"f": facts, "p": prompt_version, "m": model}, sort_keys=True)
10    return hashlib.sha256(payload.encode()).hexdigest()
11
12
13async def get_or_generate(openai, product, prompt, model: str):
14    h = description_hash(product.facts, prompt.version, model)
15    if product.description_hash == h:
16        return product.description  # cache hit: zero tokens
17    description, _usage = await generate_description(
18        openai, ProductRequest(sku=product.sku, locale=product.locale), prompt, model
19    )
20    await save_description(product.sku, description, h)  # UPDATE products SET description=..., description_hash=...
21    return description

Including prompt.version and model in the hash means a merged prompt change correctly invalidates old descriptions. Combined with Batch, re-describing the whole catalog after a prompt update becomes an overnight job at half price.

Budget in Bicep #

Finally, a safety net. A Cost Management budget on each environment’s resource group, deployed with the rest of the infrastructure:

 1@description('Monthly budget in the billing currency')
 2param monthlyBudget int = 200
 3
 4@description('First day of a month, e.g. 2026-09-01. Keep it fixed after the first deployment.')
 5param budgetStartDate string
 6
 7param alertEmail string
 8
 9resource budget 'Microsoft.Consumption/budgets@2023-05-01' = {
10  name: 'budget-${baseName}'
11  properties: {
12    category: 'Cost'
13    amount: monthlyBudget
14    timeGrain: 'Monthly'
15    timePeriod: { startDate: budgetStartDate }
16    notifications: {
17      actual80: { enabled: true, operator: 'GreaterThanOrEqualTo', threshold: 80, thresholdType: 'Actual', contactEmails: [alertEmail] }
18      forecast100: { enabled: true, operator: 'GreaterThan', threshold: 100, thresholdType: 'Forecasted', contactEmails: [alertEmail] }
19    }
20  }
21}

Budgets alert, they don’t stop spending. Cost data also lags by hours. For fast feedback, rely on the token metrics in the next section. Use the budget to catch the slow leaks.

Monitoring #

Part 8 wired up Application Insights tracing. For production we add platform metrics with alerts (is the service healthy and within capacity?) and KQL over traces (what does each feature or SKU cost?).

The metrics that matter #

These come from the supported metrics for Microsoft.CognitiveServices/accounts:

Metric (display name)REST API nameUse it forSplit by
Azure OpenAI RequestsAzureOpenAIRequestsThrottling (429s), error rates, spilloverStatusCode, ModelDeploymentName, IsSpillover
Time to ResponseAzureOpenAITimeToResponseLatency as users experience itModelDeploymentName
Processed Inference TokensTokenTransactionTotal token consumption (โ‰ˆ cost driver)ModelDeploymentName
Processed Prompt Tokens / Generated Completion TokensProcessedPromptTokens / GeneratedTokensInput vs output splitModelDeploymentName
Prompt Token Cache Match RateAzureOpenAIContextTokensCacheMatchRateIs prompt caching working?ModelDeploymentName
Provisioned UtilizationProvisionedUtilizationPTU headroom (โ‰ฅ 100% โ†’ 429)ModelDeploymentName
Foundry Model Estimated Cost (preview)FoundryModelEstimatedCostNear-real-time estimated USD (not an invoice)ModelDeploymentName, ProjectId

Metric alerts in Bicep #

A modules/monitoring.bicep that joins the module structure from Part 2:

 1param foundryName string // foundry-demo-ai / foundry-demo-prod-ai
 2param alertEmail string
 3
 4@description('Hourly token ceiling before someone gets paged')
 5param hourlyTokenThreshold int = 2000000
 6
 7resource foundry 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = {
 8  name: foundryName
 9}
10
11resource actionGroup 'Microsoft.Insights/actionGroups@2023-01-01' = {
12  name: 'ag-${foundryName}'
13  location: 'global'
14  properties: {
15    groupShortName: 'foundry'
16    enabled: true
17    emailReceivers: [{ name: 'platform-team', emailAddress: alertEmail, useCommonAlertSchema: true }]
18  }
19}
20
21var alerts = [
22  { name: 'throttling-429', severity: 2, metricName: 'AzureOpenAIRequests', timeAggregation: 'Total', threshold: 10, windowSize: 'PT15M', dimensions: [{ name: 'StatusCode', operator: 'Include', values: ['429'] }] }
23  { name: 'latency-time-to-response', severity: 3, metricName: 'AzureOpenAITimeToResponse', timeAggregation: 'Average', threshold: 5000, windowSize: 'PT15M', dimensions: [] }
24  { name: 'token-consumption', severity: 2, metricName: 'TokenTransaction', timeAggregation: 'Total', threshold: hourlyTokenThreshold, windowSize: 'PT1H', dimensions: [] }
25]
26
27resource metricAlerts 'Microsoft.Insights/metricAlerts@2018-03-01' = [
28  for alert in alerts: {
29    name: '${foundryName}-${alert.name}'
30    location: 'global'
31    properties: {
32      severity: alert.severity
33      enabled: true
34      scopes: [foundry.id]
35      evaluationFrequency: 'PT5M'
36      windowSize: alert.windowSize
37      criteria: {
38        'odata.type': 'Microsoft.Azure.Monitor.SingleResourceMultipleMetricCriteria'
39        allOf: [{
40          name: alert.name
41          criterionType: 'StaticThresholdCriterion'
42          metricNamespace: 'Microsoft.CognitiveServices/accounts'
43          metricName: alert.metricName
44          dimensions: alert.dimensions
45          operator: 'GreaterThan'
46          threshold: alert.threshold
47          timeAggregation: alert.timeAggregation
48        }]
49      }
50      actions: [{ actionGroupId: actionGroup.id }]
51    }
52  }
53]

Since alerts live in Bicep, the pipeline deploys them, what-if shows threshold changes in the merge request, and dev and prod can’t drift apart. Start with static thresholds, watch them for a couple of weeks, then tune. An alert that fires every day gets ignored.

KQL: cost per SKU from traces #

Metrics tell you how many tokens. Traces tell you what for. In Part 8 the custom generate_description span carries product.sku and app.prompt_version, and the GenAI instrumentation records gen_ai.usage.input_tokens and gen_ai.usage.output_tokens on every model call in the same operation. Part 8’s “which products are expensive?” query becomes a dollar estimate with a price table:

let prices = datatable(model: string, input_per_m: real, output_per_m: real)
[
    "gpt-4.1-mini", 0.40, 1.60,
    "gpt-4.1",      2.00, 8.00
];
let generations = dependencies
    | where timestamp > ago(7d) and name == "generate_description"
    | project operation_Id,
              sku = tostring(customDimensions["product.sku"]),
              prompt_version = tostring(customDimensions["app.prompt_version"]);
dependencies
| where timestamp > ago(7d)
| where tostring(customDimensions["gen_ai.operation.name"]) == "chat"
| project operation_Id,
          model = tostring(customDimensions["gen_ai.request.model"]),
          input_tokens = toint(customDimensions["gen_ai.usage.input_tokens"]),
          output_tokens = toint(customDimensions["gen_ai.usage.output_tokens"])
| join kind=inner generations on operation_Id
| lookup kind=leftouter prices on model
| extend cost_usd = input_tokens / 1e6 * input_per_m + output_tokens / 1e6 * output_per_m
| summarize generations = dcount(operation_Id),
            input_tokens = sum(input_tokens),
            output_tokens = sum(output_tokens),
            est_cost_usd = round(sum(cost_usd), 4)
  by sku, prompt_version
| top 20 by est_cost_usd desc

gen_ai.request.model holds the deployment name, so name deployments after models (as we have throughout the series) or map them in the prices table. Keep the prices current from the pricing page. This is an estimate for spotting outliers, not an invoice. The SKUs at the top are usually the ones being regenerated over and over (the hash cache fixes that) or the ones with huge catalog entries (trim the input). Splitting by prompt_version also shows what a merged prompt change did to the bill.

Dashboards and workbooks #

Put it all in one Azure Monitor workbook per environment, so the morning check takes one page: requests split by StatusCode (429s highlighted), Time to Response, input vs output tokens per deployment, cache match rate, the top-20 SKUs by estimated cost from the KQL above, and a link to the eval gate history in the Foundry project and to Cost Management for budget burn.

Build the workbook in the portal, export its JSON, and commit it as a Microsoft.Insights/workbooks resource in Bicep. The pipeline then deploys the dashboard like any other infrastructure.

Clean up #

1az group delete --name rg-foundry-demo --yes --no-wait
2az group delete --name rg-foundry-demo-prod --yes --no-wait
3
4# Remove the CI identities
5az ad app delete --id $(az ad app list --display-name gitlab-foundry-dev --query "[0].appId" -o tsv)
6az ad app delete --id $(az ad app list --display-name gitlab-foundry-prod --query "[0].appId" -o tsv)

Also delete the pipeline schedule in GitLab, or the weekly red-team job will fail against a resource group that no longer exists.

Series recap #

Ten episodes, one product description generator, from a single API call to a gated, monitored production service:

PartTopicWhat it delivered
Part 1Getting startedFoundry resource + project + GPT-4.1 mini in Bicep; first Responses API call with DefaultAzureCredential
Part 2Bicep deep diveModules, private endpoints, Foundry RBAC roles, deployment types and region selection
Part 3Model catalogGPT-4.1 vs GPT-5 vs open-weight models, pricing, multi-model deployments and routing
Part 4Foundry servicesAgent Service, Responses API, built-in tools, memory and compaction
Part 5Prompts & structured outputsVersioned prompt files and Pydantic-validated JSON output
Part 6FastAPI on Container AppsDocker image in ACR, user-assigned managed identity, Bicep for the app
Part 7Grounding / RAGProduct catalog in PostgreSQL, retrieval with Azure AI Search
Part 8Evaluations & tracingGolden dataset with thresholds, azure-ai-evaluation, Application Insights + KQL
Part 9Content safetyContent filters / RAI policy in Bicep, prompt shields, AI Red Teaming Agent scan
Part 10CI/CD, cost & monitoringGitLab pipeline with OIDC per environment, evaluation gates, cost levers, budget, alerts and KQL

Key takeaways #

  1. Evaluation is the AI-specific stage. Put a golden-dataset gate between dev and prod, and fail the pipeline on regressions just like on failing tests.
  2. Gate on pass rates, plus deterministic checks. Schema validity, business rules, forbidden claims and token budgets are cheap, stable, and catch real regressions.
  3. Prompts, thresholds and model versions are code. Merge requests, CODEOWNERS approvals and NoAutoUpgrade stop any of them from changing quietly.
  4. Keyless everywhere, one identity per environment. GitLab ID tokens + Entra federation, with deployment_tier in the sub claim, keep prod credentials away from feature branches.
  5. Build once, promote the same image. Bicep keeps the running image; az containerapp update moves the app forward as a named, reversible revision.
  6. The biggest cost lever is the model you pick. After that: Batch for bulk work (50% off), a stable prefix for prompt caching, output caps, and not regenerating unchanged content. PTU only for steady, latency-critical traffic, and always with spillover.
  7. Alert on 429s, latency and tokens in Bicep. Attribute cost with KQL. Budgets are the last safety net, not the first signal.

What’s next? #

That wraps up the core series. In the bonus episode we go beyond a single model call: hosted agents and multi-agent workflows with Microsoft Agent Framework. We’ll split the generator into cooperating agents (researcher, copywriter, reviewer), run them as a hosted agent in Foundry, and plug the result into the same pipeline and evaluation gate we built today.

Full series outline #

#TopicPublished
1Getting started โ€” Provision with Bicep, deploy GPT, generate descriptions21 May 2026
2Bicep deep dive โ€” networking, RBAC, deployment types, region selection1 Jun 2026
3Foundry model catalog โ€” comparing GPT-4.1, GPT-5, open-weight models and when to use what15 Jun 2026
4Foundry services overview โ€” agents, Responses API, tools, memory and real-world use cases27 Jun 2026
5Prompt engineering and structured outputs โ€” schema-validated product descriptions3 Aug 2026
6Building the Python API โ€” FastAPI on Azure Container Apps with managed identity17 Aug 2026
7Grounding with your data โ€” product catalog in PostgreSQL and RAG with Azure AI Search2 Sep 2026
8Evaluations and tracing โ€” measuring quality with Foundry evaluators and Application Insights17 Sep 2026
9Content safety, prompt shields and AI red teaming1 Oct 2026
10CI/CD with GitLab โ€” evaluation gates, cost optimization and monitoring (this post)17 Oct 2026
BonusHosted agents and multi-agent workflows with Microsoft Agent Framework1 Nov 2026

Stay tuned!

comments powered by Disqus