Architecture at a glance
The plain-English version of this page is Why FoundryGate?; the narrative walkthrough with the Entra sync and quota-resolution detail is the System Overview.
The whole system in one picture: a developer’s coding tool talks to one gate, the gate decides yes or no before anything reaches a model, and a separate app — never on the request path — decides who gets a key and how big their budget is.
Read it top to bottom. Every box links to the reference page for that part.
FoundryGate architecture — generated from infra/ and src/
FoundryGate architecture, generated from infrastructure and source code. Developer CLIs (Claude Code sending x-api-key, Codex CLI sending api-key) call the API Management gateway. The gateway has 2 front doors: /anthropic and /openai/v1. Each developer key belongs to one of 3 tier products: Standard, 5M tokens per month, 20K tokens per minute, permitting gpt, haiku, sonnet; Power, 20M tokens per month, 40K tokens per minute, permitting gpt, haiku, sonnet; Unlimited, no monthly cap, 100K tokens per minute, permitting gpt, haiku, opus, sonnet. Requests route through backend pools to Azure AI Foundry accounts in eastus2 (primary, 4 model deployments) and swedencentral (pool-member, 3 model deployments). Out of the request path, the control plane runs the Blazor UI, the API on Container Apps, Azure SQL, Key Vault and 3 timer functions: EntraSyncFunction daily at 02:00 UTC, MonthlyQuotaResetFunction daily at 00:01 UTC, UsageSyncFunction every 15 minutes.
Front doors — one per model provider
/anthropicfoundrygate-anthropic/openai/v1foundrygate-openaifg-require-product — a key with no tier product is refused Tier products — the developer's key belongs to exactly one
llm-token-limit + the alias allowlist, counted against the developer's own subscription
Retry-After · going too fast 403 · monthly budget spent, or model not on the tier Backend pools — where an allowed request is sent
foundry-anthropic-poolfoundry-openai (primary account)foundryRegions deployControlPlane is true
infra/main.bicep — an arrow means "consumes that
module's outputs"
- monitoring no dependencies
modules/monitoring.bicep - foundry per region no dependencies
modules/foundry.bicep - apim ← monitoring
modules/apim.bicep - foundryRbac per region ← apim, foundry
modules/foundry-rbac.bicep - gateway ← apim, foundry, foundryRbac
modules/ai-gateway.bicep - controlPlane if deployControlPlane ← apim, foundry, gateway, monitoring
modules/control-plane.bicep - swaPreviewRole if deployControlPlane && deploySwaPreviewRole ← controlPlane
modules/swa-preview-role.bicep
The control plane module fans out into 9 more: identities, keyVault, registry, storage, sql, staticWebApp, rbac, containerApp, functionApp.
And the .NET side — 7 shipping projects, in reference order (each row may only reference the rows above it)
- FoundryGate.Domain references nothing
- FoundryGate.Data → Domain FoundryGate.Web → Domain
- FoundryGate.Cli → Data, Domain FoundryGate.Core → Data, Domain
- FoundryGate.Api → Core, Data, Domain FoundryGate.Functions → Core, Data, Domain
infra/ and src/ at build time · commit c996615 · generator Why this picture can’t go stale
Everything above — the regions, the tier numbers, the model deployment names, the
Function schedules, the module dependency arrows — is read out of the repository at build
time by docs-site/scripts/generate-architecture.mjs
and written to docs-site/src/generated/architecture.json. The page renders that file and
nothing else.
| Where the picture comes from | What it reads |
|---|---|
infra/main.bicep | foundryRegions, quotaTiers, productModelAliases, pooledModelDeployments, primaryOnlyModelDeployments, the module declarations and their deployControlPlane conditions, and the fg-role tags |
infra/modules/*.bicep | the module dependency edges (which module consumes which module’s outputs), the gateway’s front-door paths and backend pool names, the control plane’s own sub-modules |
src/*/*.csproj | the .NET ProjectReference graph |
src/FoundryGate.Functions/** | every [TimerTrigger] and its real NCRONTAB schedule |
src/FoundryGate.Api/** | the controller route prefixes |
The generator runs as prebuild, so npm run build always regenerates it, and CI
regenerates it too and fails the build on git diff --exit-code docs-site/src/generated.
Changing a region or a tier without updating the diagram is not something a pull request
can quietly do. A Mermaid twin of the module graph is emitted alongside the JSON as
architecture.mmd for anything that wants the graph as data.
The three things worth taking away
- Enforcement happens on the request, at the gate. The
llm-token-limitpolicy in each tier product decides429(too fast) or403(monthly budget spent, or model not on this tier), counted against the developer’s own APIM subscription. No database is consulted. - The model allowlist is the alias map. A tier permits exactly the aliases listed on
its card — anything else comes back
403 model_not_permitted, with the deployments rotating underneath the alias without a policy redeploy. - The control plane can be absent.
deployControlPlane=false— the default — gives you the gateway and the models on their own, which is what a fork should stand up first. The dashed boxes in the module graph are the parts that condition on it.
Live evidence
Responses captured from a real deployed gateway — 429s, 403s, header measurements — are
recorded in validation/
in the repository, one file per gateway cycle.