Configuration Reference
SystemConfiguration keys
These are stored in the SystemConfiguration database table and editable by admins at /config. All values are strings.
| Key | Default | Description |
|---|---|---|
DefaultMonthlyTokenQuota | "5000000" | Per-user monthly token budget when no user or group override applies. Must equal a configured tier cap (5000000 = the Standard tier as shipped) — see Gateway quota tiers. A value that matches no tier is enforced at the next tier up and flagged isGatewayCapped on every allocation it produces. |
ApimResourceId | "" | ARM resource ID of the APIM instance (/subscriptions/.../Microsoft.ApiManagement/service/...). Informational — the key service addresses APIM through the Gateway__* environment variables below, not this value. |
FoundryResourceId | "" | ARM resource ID of the Azure AI Foundry account. Superseded for deployment management by the Gateway__* environment variables below (the gateway runs one account per region, and infra sets them, so nothing has to be typed here); kept as seeded reference data. |
EntraGroupSyncEnabled | "false" | Set to "true" to enable automatic Entra group member sync. When false, group sync must be triggered manually from the admin UI. |
ResetDayOfMonth | "1" | Day of month the monthly quota reset acts on, 1–28 (no month is short of a 28th). The reset Function wakes daily at 00:01 UTC and runs on the first tick on or after this day that finds the month not yet reset — so a tick lost to an outage is picked up the next day instead of costing the month its reset, and a change here takes effect the next day with no redeploy. A value outside the range falls back to 1 with a warning rather than never resetting. Note this moves FoundryGate’s allocation rows only — the gateway’s own token-quota window is a UTC calendar month and always resets on the 1st, whatever this says. |
RateCard | "[]" | What a token costs, as a JSON array of { "modelPrefix", "inputPerMillion", "outputPerMillion" } objects. Drives the estimated cost figures on /dashboard and /quota — see Cost estimates. Empty by default: until you price your own tokens, no cost is shown anywhere. |
Blank values ("") are deliberate placeholders. Nothing breaks while they are blank: everything the
gateway is actually addressed by comes from the Gateway__* environment variables below, which infra
sets automatically.
Cost estimates (RateCard)
Azure cannot answer “what did this developer cost?”. Claude bills as a single aggregate Marketplace CCU meter, so Cost Management cannot break spend down per deployment, per subscription or per user. FoundryGate therefore computes it from your own prices:
[ { "modelPrefix": "*", "inputPerMillion": 3, "outputPerMillion": 15 }, { "modelPrefix": "claude-opus", "inputPerMillion": 15, "outputPerMillion": 75 }]Prices are per million tokens, in whatever currency you priced them in — FoundryGate never renders a
currency symbol, because it does not know which one you meant. Only the * entry is read today;
nothing matches on modelPrefix yet, so the named entries above are validated and then ignored until
#213 lands the per-model token split that can
use them. They are part of the stored shape now so that a fork does not have to rewrite its row later.
Each modelPrefix may appear at most once, and each price must be between 0 and 1,000,000 — the
upper bound is a guardrail, not a business rule: no model costs a million per million tokens, and an
unbounded price overflows the arithmetic behind every cost figure. PUT /config/RateCard answers
400 with the rule in the message, and stores the card re-serialized so whitespace differences do
not leave two versions of the same rates. Editing it also drops the cached copy immediately, so a
corrected price is live on the next read rather than up to 30 seconds later.
The formula, and its caveat. For an allocation:
estimatedCost = tokensUsed / 1_000_000 × blendedRatePerMillionblendedRatePerMillion = mean(inputPerMillion, outputPerMillion) of the "*" entryQuotaAllocation.TokensUsed is a single total: it carries no
prompt/completion split, so the two prices cannot be applied separately, and no per-model split, so
the per-model entries are validated and stored but nothing reads them yet
(#213 stores both splits — the reconciliation
KQL already selects PromptTokens/CompletionTokens and sees ModelName on every row, so the data
is discarded on the way in rather than missing at the source). If you know your real prompt/completion
mix, express it by setting both of the * entry’s prices to your own blended number.
Three reasons every figure derived from this is labelled an estimate, and stays labelled:
- one blended rate, because there is no prompt/completion split;
- one rate for every model, because there is no per-model split;
- the token totals are a floor — interrupted streams undercount (#84) and cache-read/creation token weighting at the gateway is unverified (#88).
With no * entry — the shipped default — every cost field is null and nothing renders one. That is
deliberate: a zero would read as “free”. Cost is a reporting figure and nothing else; the gateway
enforces tokens, never dollars.
System-managed keys
These rows are seeded like the others and listed by GET /config, but FoundryGate writes them, not
you. PUT /config/{key} answers 409 naming the reason, and the /config editor disables the
field — the row carries isReadOnly: true on the read, so the page never has to discover the refusal
by attempting an edit, and never keeps a list of its own that could drift from the API’s
(#172).
| Key | Default | Written by |
|---|---|---|
LastUserSyncDate | "" | POST /users/sync, as an ISO-8601 instant, in the same unit of work as the run’s users.synced audit row |
LastUserSyncResult | "" | The same run, as the JSON of its UserSyncResult (camelCase — the same shape the endpoint returns) |
Both are blank until a fork has run a sync, and a fork whose last run predates
#171 stays blank: nothing backfills a record
that was never kept. Only the most recent run is kept — the audit log has the full history. Read them
together from GET /users/sync/last, which is what /users/sync shows on a cold load.
Retired keys
Three keys were seeded by earlier versions and are gone as of
#164 /
#123. Nothing read them, and an “edit” was a
silent no-op — a configuration editor that accepts a value the system never consults is worse than
no row at all. The next db seed-reference after upgrading deletes them (the CLI reports the
deletions in its -N count); GET /config no longer lists them and PUT /config/{key} answers
404. No fork-authored key is ever deleted — the seeder only removes keys FoundryGate itself names.
| Retired key | Read instead |
|---|---|
ApimGatewayUrl | Gateway__ApimGatewayUrl, set on the container by infra from the APIM module’s own output, so the address shown on /me can never drift from the gateway that was deployed |
ApimProductId | The per-tier APIM products (quotaTiers in infra/main.bicep, emitted to both hosts as Gateway__Tiers__*; GET /quota/tiers lists them) — a subscription is issued against the product for the user’s tier |
EntraTenantId | AzureAd__TenantId. Graph is called as the API’s own identity, so the tenant is implied by the credential |
Gateway quota tiers (Gateway__Tiers__*)
A developer’s monthly budget is a gateway tier. APIM’s token-quota is a per-product
literal, so the only budgets the gateway can enforce are the configured tier products — and
therefore the only monthly token quotas the control plane accepts are a tier’s exact cap or
unlimited. Both hosts read the table from the Gateway:Tiers section, and on a deployed host infra
sets it: Gateway__Tiers__{i}__ProductId / __DisplayName / __MonthlyTokenQuota, projected by
infra/modules/control-plane.bicep from the same quotaTiers parameter that creates the APIM
products and renders their llm-token-limit policies. One source, so the caps the control plane
validates against and the caps the gateway enforces cannot disagree. Locally there is no gateway to
emit anything, so each host carries the table in its own appsettings.local.json; a predeployment
test (GatewayOptionsTiersTests) fails if those drift from the bicep defaults. Tiers lives on the
same Gateway section as the Gateway__* addressing below, but unlike the addressing it is never
optional — either host refuses to start without at least one tier.
| ProductId | DisplayName | MonthlyTokenQuota | Meaning |
|---|---|---|---|
standard | Standard | 5000000 | Everyday agent usage. |
power | Power | 20000000 | Heavy agentic workloads. |
unlimited | Unlimited | 0 | No gateway-enforced monthly budget (0 = unlimited, as in bicep). |
Validated at startup (fail-fast): at least one tier, product ids ⊆ GatewayTiers.All, no
duplicates, every cap in range, exactly one unlimited tier, and at least one finite tier.
Adding or changing a tier starts, and for a deployed fork usually ends, with quotaTiers in
infra/main.bicep: it creates the APIM product, renders its llm-token-limit policy, and is
projected onto both hosts as Gateway__Tiers__* — so overriding the parameter needs no second edit
and cannot leave the control plane accepting budgets the gateway will not enforce. Two things still
travel with it: a new product id must be added to GatewayTiers.All in Domain, and the local
copies in both hosts’ appsettings.local.json should be kept in step so a local run behaves like a
deployed one. Predeployment tests cover all three, so getting this wrong fails the build rather than
a deployment.
Existing quotas that no longer match any tier are not rejected on read — they are enforced at the
next tier up and flagged isGatewayCapped until an admin corrects them.
Environment variables / appsettings
The complete set for both control-plane hosts, grouped by who gets what: shared, gateway addressing,
API-only, Functions-only. infra/modules/control-plane.bicep sets them as Container App environment
variables and Function App application settings; the Bicep is the source of truth for the names, and
the Infrastructure Reference covers the resources behind
the values. None of them is a secret: SQL uses Entra authentication, so even the connection string is
plain configuration. Secrets, when one is ever needed, are set out of band in Key Vault and referenced
from appsettings as @KeyVault(SecretName).
Everything in the first two sections is set on both hosts from one shared block in the Bicep, so
the API and the Functions host can never be told about different gateways
(#108). A predeployment test,
GatewayInfraBindingTests, reads that Bicep, binds a real GatewayOptions from the names it finds and
checks by reflection that each one arrived — so it fails if a Gateway__* name drifts, if infra sets
one nothing binds, or if a new Gateway member turns up with no declared source.
Shared by both hosts
| Key | Description |
|---|---|
AZURE_CLIENT_ID | Client id of that host’s user-assigned managed identity — selects it in the token credential chain |
Azure__KeyVaultUrl | Key Vault URI for @KeyVault() reference resolution; absent locally, which skips resolution |
ConnectionStrings__FoundryGate | Azure SQL connection string, Authentication=Active Directory Default (no password) |
OpenTelemetry__Enabled / OpenTelemetry__ConnectionString | OpenTelemetry → Azure Monitor; off in appsettings.local.json and for a local func start |
Entra__Enabled | false by default. Turns on Microsoft Graph-backed directory sync — the API’s POST /users/sync and POST /groups/sync-entra, and the Functions host’s nightly EntraSyncFunction. There is no client secret: Graph is called as each host’s own identity — its user-assigned managed identity in Azure, your Azure CLI login locally — which must hold the Graph application roles Application.Read.All, User.Read.All and GroupMember.ReadBasic.All (see the Infrastructure Reference). While false, the sync endpoints return 503 and the nightly job logs once at Information and does nothing |
Entra__ApplicationClientId | The app registration whose service principal carries the user assignments that define who is a FoundryGate developer. Infra sets it on both hosts from the same value as AzureAd__ClientId; it lives on this section because the Functions host binds no AzureAd section at all. The API defaults it from AzureAd__ClientId when it is blank, so an older deployment keeps working |
Entra__ServicePrincipalObjectId | Optional. Object id of the enterprise application whose user assignments define who is a FoundryGate user. Defaults to the service principal of Entra__ApplicationClientId, resolved once at first use; set it only if developers are assigned to a different enterprise application |
Entra__GraphBaseUrl | Optional. Defaults to https://graph.microsoft.com/v1.0; sovereign-cloud forks override it (the token scope {host}/.default is derived from it) |
Gateway addressing (Gateway__*, shared by both hosts)
Set on both hosts so nothing has to type an ARM resource id into SystemConfiguration. Both hosts
bind the whole section rather than a per-host subset that would drift; the Read by column says who
actually consumes each value.
| Key | Read by | Description |
|---|---|---|
Gateway__SubscriptionId | API | Subscription the gateway resource group lives in — the ARM scope shared by APIM and Foundry |
Gateway__ResourceGroup | API | rg-foundrygate-{env} |
Gateway__ApimName | API | APIM service name (short name, not the ARM id) — subscription create/rotate/delete and alias named-value edits. ApimName without the subscription/resource group fails startup; with all three absent the /keys/* routes answer 503 locally (and a qa/prod host refuses to start) |
Gateway__ApimGatewayUrl | API | The gateway’s public origin, handed to developers as cliConfig.gatewayBaseUrl on GET /users/me. Comes from the APIM module’s own output, so it cannot drift from the gateway that was deployed |
Gateway__FoundryAccountNames__{i} | API | Foundry account names in pool order (index 0 = primary) — the API manages model deployments in exactly these accounts and no others. Account names without a subscription/resource group fail startup; the whole section may be absent locally, and /foundry/* then answers 503 |
Gateway__KeyEncryptionKeyUri | API | Versionless URI of the Key Vault RSA key (fg-apim-key-encryption) that wraps stored APIM subscription keys (RSA-OAEP-256). The API resolves the key’s current version per wrap (5-minute cache) and pins each stored value to the version that wrapped it, so a Key Vault key rotation needs no redeploy or re-encryption. Required when KeyProtection__Provider is KeyVault |
Gateway__LogAnalyticsWorkspaceId | Functions | The workspace GUID — what the Log Analytics query API calls the “workspace id”, not the ARM resource id. The only gateway value the usage reconciliation job needs; the host refuses to start if given anything that is not a GUID. Absent, the job logs a warning each pass and does nothing, which is right for a fork that has not enabled the gateway’s GenAI diagnostic setting |
Gateway__LogAnalyticsWorkspaceResourceId | (nothing yet) | ARM resource id of the same workspace, for QueryResourceAsync and anything management-plane. Bound so nothing has to reconstruct it |
Gateway__ModelAliases__{i}__Tier / __Alias / __DeploymentName / __Provider | API | The gateway’s model alias map, one indexed row per tier + alias — infra flattens productModelAliases from infra/main.bicep onto both hosts, so the map that becomes gateway policy and the map GET /users/me hands developers are the same object. Tier is a quotaTiers product id, Provider is anthropic or openai (case-insensitive). A tier the gateway does not create, a blank member, or the same tier+alias twice fails startup. Absent (a fork on an older deploy, or local) → cliConfig.modelAliases is [] and developers take model names from the CLI setup page |
Gateway__Tiers__{i}__ProductId / __DisplayName / __MonthlyTokenQuota | API + Functions | The quota tier table, one indexed row per tier — infra projects quotaTiers from infra/main.bicep onto both hosts, so the parameter that creates the APIM products is the one the control plane validates budgets against. MonthlyTokenQuota is a whole number of tokens, 0 meaning unlimited (bicep’s own convention). Required: neither host starts without at least one tier, exactly one of them unlimited. Absent only on a local host, which reads Gateway:Tiers from appsettings.local.json instead. See Gateway quota tiers above |
API only
| Key | Description |
|---|---|
ASPNETCORE_ENVIRONMENT | qa or prod (lowercase; local is docker-only) |
AzureAd__Instance | Entra cloud instance (https://login.microsoftonline.com/ in the public cloud) |
AzureAd__TenantId | Entra tenant ID for bearer token validation |
AzureAd__ClientId | Entra App Registration client ID |
AzureAd__Audience | Token audience (usually api://{clientId}). The API accepts both {clientId} and api://{clientId} whatever this is set to — the app registration requests v2 access tokens, whose aud is the bare client id, and pinning validation to the api:// form alone rejected every real token (#102) |
Cors__AllowedOrigins__0 | The Static Web App origin allowed to call /api/v1 |
KeyProtection__Provider | KeyVault (default; cloud) or DataProtection (machine-local key ring — accepted in the local environment only; the API refuses to start with it anywhere else) |
Security__RateLimits__Reveal__PermitLimit / __WindowSeconds | The POST /keys/me/reveal cap, per caller oid. Defaults to 5 per 60 seconds — the values that shipped as constants (#181). Validated at startup (1–10000 permits, 1–3600 seconds) |
Security__RateLimits__Rotate__PermitLimit / __WindowSeconds | The POST /keys/me/rotate cap. Defaults to 3 per 60 seconds — tighter than reveal, because a rotation is a write the gateway feels and breaks whatever the developer has configured |
Security__RevealAnomaly__Threshold / __WindowMinutes | The reveal anomaly signal (#180): the reveal that pushes one key past 10 reveals inside a rolling 60 minutes logs a Warning and writes a key.reveal-anomaly audit row, once per window. The request still succeeds — a limiter catches a fast drain, this catches a patient one. No on/off switch; set the threshold unreachably high to silence it |
The Functions host has no AzureAd, Cors or KeyProtection section at all: nothing there serves a
request, so there is no token to validate, and its jobs never read or write a developer’s key. (It
does bind Entra__* — the nightly directory sync runs there — which is why
Entra__ApplicationClientId is on the shared list rather than read from AzureAd__ClientId.)
Functions only
| Key | Default | Description |
|---|---|---|
AZURE_FUNCTIONS_ENVIRONMENT / DOTNET_ENVIRONMENT | — | qa or prod — the Functions host reads the first, the isolated worker’s generic host the second |
AzureWebJobsStorage__accountName / AzureWebJobsStorage__credential=managedidentity / AzureWebJobsStorage__clientId | — | Identity-based host storage. There is no storage connection string: shared-key access is disabled on the account |
APPLICATIONINSIGHTS_CONNECTION_STRING | — | Functions host telemetry |
Storage__AccountName | (unset) | Storage account for the scheduled jobs’ distributed locks — one blob lease per job (quota-monthly-reset, entra-directory-sync), so the two never block each other. Left empty, the host uses the account it already knows from AzureWebJobsStorage__accountName, so no extra configuration is needed in Azure |
Storage__ConnectionString | (unset) | Local Azurite only (UseDevelopmentStorage=true). A cloud connection string is refused at startup — the storage account does not accept shared keys |
Storage__LockContainerName | foundrygate-locks | Blob container holding the lock blobs; created on demand |
Storage__LockLeaseSeconds | 60 | How long a job’s lease is held. It expires by itself, so a crashed run never needs unlocking |
Locally, copy src/FoundryGate.Functions/local.settings.template.json to
local.settings.json (gitignored) and point ConnectionStrings__FoundryGate at the
docker SQL container.