Skip to content
↑↓Navigate↵SelectescClose

Azure AI Foundry Connections

Wiring Nexus to an Azure OpenAI deployment hosted in Azure AI Foundry

How an operator wires Nexus to an Azure OpenAI deployment hosted in an Azure AI Foundry resource. The gateway routes POST /v1/chat/completions to the deployment, streaming included, and forwards the OpenAI tool fields; authentication uses the Foundry api-key header. Whether a given deployment honours tools is the deployment’s business — a Foundry-hosted vLLM model such as Phi-4-reasoning rejects tool_choice outright, and the gateway surfaces that upstream error unchanged.

POST /v1/responses is dispatched to the resource’s Responses surface, https://{resource}.openai.azure.com/openai/v1/responses (.azure.us for government), streaming included. That URL carries no api-version query and no deployment path segment: the Responses surface resolves the deployment from the request body, so the gateway sends the mapped deployment name as model. A name the resource does not serve answers 404 DeploymentNotFound. Because no collected source reports this capability per deployment, GET /v1/models reports responses: false for Foundry endpoints. Callers must name the model explicitly rather than relying on catalog discovery.

Two provider ids share this adapter, one per Azure cloud: azure-foundry for commercial Azure and azure-foundry-gov for Azure Government. They are separate providers with separate credentials, separate registry rows, and separate prices. The only behavioural difference is the host the resource name derives: https://{resource}.openai.azure.com for commercial and https://{resource}.openai.azure.us for government. Everything below applies to both except where the cloud is called out.

  1. Registry endpoints first. A credential (platform or BYOK) for the provider routes nothing until a registry endpoint row exists for the model.
  2. Platform connection for Wallet (pay-as-you-go) traffic, or a tenant BYOK connection per project — or both.

Both credential tables carry the same non-secret metadata object, validated at create time:

{
"resource": "contoso-openai",
"domain": "foundry.internal.example",
"api_version": "2024-06-01",
"deployments": { "gpt-5.4-mini": "gpt-5-4-mini-prod" },
"project_endpoint": "https://contoso-openai.services.ai.azure.com/api/projects/my-project",
"discovery_api_version": "2025-05-01"
}
  • resource (required, non-empty string) — the resource label used in the derived origin. Nexus rejects URL delimiters and control characters rather than enforcing Azure’s public resource-name alphabet.
  • domain (optional, non-empty string) — overrides the provider lane’s host suffix for sovereign, private, or air-gapped DNS. When absent, azure-foundry uses openai.azure.com and azure-foundry-gov uses openai.azure.us.
  • api_version (required, non-empty string) — the api-version query parameter sent on every request.
  • deployments (optional object) — maps each canonical model id to an injection-safe deployment name. An absent field is treated as an empty map. A chat request for a model with no mapping fails before any upstream call as connection_not_configured (HTTP 503), leaving the attempt eligible for fallback.
  • project_endpoint (optional, non-empty string) — the AI Foundry project URL, kept with its path because the project name is part of it. Only deployment discovery reads it; inference always goes to the origin, so this does not replace the base URL and a project-based connection stores both. Must be HTTPS without credentials, query, or fragment; loopback HTTP is accepted for local checks.
  • discovery_api_version (optional, non-empty string) — overrides the deployment-listing api-version. This is not the inference version. api_version above is sent on chat completions; a listing api-version belongs to the listing API, and each surface otherwise uses its own pinned default (2025-05-01 for projects, 2023-03-15-preview for the Azure OpenAI data plane, which is the last version that serves a listing at all). Set this when a sovereign or air-gapped resource serves neither default — without it, such a resource is undiscoverable without a rebuild.

Unknown metadata fields are rejected by name. To send inference somewhere other than the resource-derived host — an Azure private endpoint, or an API gateway in front of the resource — set the lane’s base URL rather than a metadata field; see Where a lane’s upstream comes from.

The secret stored with the connection is the Foundry resource’s api-key. It is sent as the api-key header, never as Authorization. Never place credential material in the metadata.

A provider lane represents one upstream environment. Its origin resolves in three steps:

  1. the connection base_url column, a per-scope override;
  2. the lane default from the imported artifact’s catalog[].base_url;
  3. the resource-derived host, https://{resource}.{domain}.

The lane default means a hand-edited artifact defines where its lane lives, without a connection override. A catalog row carrying the placeholder https://unused.invalid (what the generators write) counts as “no lane default”, so step 3 applies. Rows within a lane are expected to agree; the first usable value wins.

A second environment for the same backend is a second lane, not an override: import an artifact whose name mints the lane and whose provider selects this adapter, for example {"name": "azure-foundry-gov-sovereign", "provider": "azure-foundry-gov"}. That gives the environment its own credential, prices, and import history.

The connection override remains for the cases a lane cannot express — one org behind its own egress proxy, or a loopback origin for offline checks. Because it silently outranks the catalog, credentials platform-keys set and get print a note on stderr when the two differ.

The adapter requires an origin with no path, query, or fragment. Non-loopback HTTP is refused; loopback HTTP is accepted so a connection can point at a local upstream for offline checks. --base-url is a column, not a metadata field, and a base_url key inside metadata is rejected.

Azure publishes no machine-readable Foundry catalog or price list, so Foundry endpoints come from two operator CSVs — a catalog CSV (one row per model) and a rates CSV (one row per priced meter) — built into one artifact and imported like any other provider.

When the resource is reachable, foundry-csv writes both files from its live deployment listing rather than having you type them:

Terminal window
just registry-foundry-csv dist/foundry --prices \
--context-length 128000 --max-output-tokens 16384

It reads AZURE_FOUNDRY_COMMERCIAL_PROJECT_ENDPOINT (preferred, since it reports publisher and capabilities), falling back to AZURE_FOUNDRY_COMMERCIAL_OPENAI_ENDPOINT, with AZURE_FOUNDRY_COMMERCIAL_API_KEY as the credential; flags override all three. The government resource uses AZURE_FOUNDRY_GOV_PROJECT_ENDPOINT and AZURE_FOUNDRY_GOV_API_KEY. Each catalog row gets a canonical model id derived from the deployment’s base model name (Mistral-Large-3 becomes mistral-large-3), provider_model_id set to the base model name exactly as Azure reports it so deployment discovery can map it later, and capability flags taken from the listing. A deployment advertising nothing the adapter dispatches is emitted disabled and non-routable. Two deployments of one base model stop the run, because a registry endpoint is one row per model — keep one and map the other in connection metadata by hand.

The listing describes deployments, not the models behind them, so it reports nothing about whether a model reasons, which modalities it accepts, or its token limits. Those four columns are read from the published Azure OpenAI models page and matched to each deployment’s base model by name. A model the page describes carries its own limits, so --context-length and --max-output-tokens apply only to the rows it does not describe.

Only published wording is recognized — Reasoning, Text and image processing, and the handful of sibling phrases the page uses. A model the page describes in wording the collector does not know, or does not describe at all, keeps the values the collector writes on its own (reasoning=false, text in and text out), and the capability summary counts those rows as making that claim without evidence, so a row asserting a capability without evidence is always reported rather than silent. An embedding row keeps embedding as its output modality and no output-token limit whatever the page’s prose says, because those two are what make it an embedding row.

This matters beyond the Foundry rows themselves. reasoning and the modality sets live on the model, shared by every provider serving it, and an import is hard-rejected when it would change them for a model another provider already serves. A Foundry artifact that asserted reasoning=false for a model imported from OpenAI as a reasoning model could not be imported at all.

Each run writes what it read to model-catalog.json beside the CSVs. Copy that file to a network that cannot reach the page and pass it as --model-catalog; the run then reaches only the Foundry resource, and the file records the URL and capture time the facts came from:

Terminal window
just registry-foundry-csv dist/foundry \
--model-catalog model-catalog.json \
--context-length 128000 --max-output-tokens 16384

A page this run cannot reach is a warning, not a failure: the CSV pair is still written, with those columns as the collector would have left them and the capability summary saying so. A hand-authored JSON array of the same rows is accepted too, which is the way to cover a model the page omits.

The listing says which endpoint a deployment serves and nothing about whether it honours tools/tool_choice or stream, which the adapter forwards unchanged. Those two are properties of the deployment, and deployments of one resource genuinely differ: DeepSeek-V3.2 answers a tool call, while the vLLM-hosted Phi-4-reasoning refuses any tool_choice but none with a 400. Neither the listing nor the model name can be read for the answer, so the collector asks each dispatchable deployment directly, at the same URL the adapter posts to:

  1. a plain one-token chat completion, as the baseline;
  2. the same completion carrying one throwaway function tool and tool_choice: auto;
  3. the same completion with stream: true.

A column is written true when the deployment answered the request carrying the field, and false only when it refused that request after accepting the baseline — so a false is always a refusal the resource issued, never an inference from the endpoint kind or the model name. Anything else, including a rate limit, a transport failure, or a baseline that did not succeed, leaves both columns blank and the row non-routable, and names the deployment and the upstream’s own words on exit. Every probe result is printed with the run.

The probe sends AZURE_FOUNDRY_API_VERSION (default 2024-06-01) as its api-version; --inference-api-version overrides it. --no-capability-probe sends no completion at all and leaves both columns and the routable flag for the operator, at the cost of an unimportable pair until they are filled in.

Review every capability column before importing a generated pair. from-csv validates the shape of a row, never the truth of it, and it reads a blank boolean cell as false — so a column asserts something whether or not a source supplied it. The two ways of getting one wrong fail asymmetrically:

  • Understated, the capability disappears in silence. The gateway advertises it as absent in GET /v1/models, every client that reads the catalog stops sending the field, and the feature is gone with no error raised anywhere.
  • Overstated, every request that uses it fails at call time. The deployment rejects it upstream and the caller receives a provider error it cannot act on.

For a run with the probe on, foundry-csv stands behind four columns: chat and embeddings are the endpoint the listing says the deployment serves, and tools and streaming are the deployment’s own answers. responses, images, and moderations are false as a rule about the adapter, which dispatches chat and nothing else.

What remains unproven, and is asserted anyway, is: reasoning, which is false on every row; the input and output modalities, which are text-in/text-out (embedding-out for an embedding row); and context_length and max_output_tokens, which are whatever --context-length and --max-output-tokens were given, repeated across every row, because no Foundry surface reports a per-model limit. With --no-capability-probe, or for any deployment whose probe settled nothing, tools and streaming join that set: both columns are blank, which is to say false, and the row is emitted non-routable so it cannot serve traffic on an unexamined claim.

Every run ends by printing which columns fall into which of those groups. Read that block before importing.

--prices additionally reads the Azure pricing pages for the publishers involved and fills the rates CSV, but only where the published model name and the deployment’s SKU scope match exactly — GlobalStandard prices against the page’s Global row, and a near name such as Mistral-Large-3 against Mistral Medium 3.5 is left blank and reported instead of guessed. Prices vary by region, so the region defaults to the one the resource reports for itself and --region overrides it. Every filled price is printed with the row, section, region, and page it came from; pricing-evidence.json lands beside the CSVs with every price observed for every region and the digest of each page as parsed. Provisioned, batch, priority, and fine-tuning columns are ignored, since billing pay-as-you-go traffic at one of those rates would be wrong for every request.

The tool leaves blank whatever it cannot prove and names it on exit: prices it could not match, token limits unless you passed them, reasoning and non-text input modalities, which neither the listing nor the probe reports, and tools/streaming for any deployment whose probe settled nothing. from-csv then refuses an incomplete pair, quoting the file, line, and column.

When some deployments price cleanly and others do not, from-csv --skip-unpriced imports the ones priced for both the input and output token meters and names every deployment it dropped, so one unmatched name does not hold back the rest. A dropped deployment is absent from the registry rather than present at a guessed rate; price it by hand in the rates CSV and re-run without the flag to add it.

On an isolated network, copy the starter files from tools/registry-generator/templates/ instead and fill them in by hand; every column must be present and unknown columns are rejected by name and line.

Catalog rows for this provider: chat is the only endpoint the adapter dispatches, so leave responses false. streaming and tools describe the deployment rather than the adapter, which forwards both; foundry-csv fills them from its probe, and a hand-written row must set them from what the deployment actually accepts. base_url is required by the registry schema and the adapter reads it as the lane default, below a connection override and above the composed resource host. Write https://unused.invalid when the lane declares no default; the adapter treats that value as unset. A real host here redirects every request on the lane that has no connection override.

Rate rows are priced in USD per million tokens for token meters (and per request or call otherwise), exactly as Azure publishes them. Context bands are extra rows split on min_context/max_context, and the last band for a meter must be unbounded.

model,tier,meter,modality,min_context,max_context,cache_ttl,tool_type,price,effective_from,effective_to
gpt-5.4-mini,standard,input_token,text,0,,,,0.15,,
gpt-5.4-mini,standard,cache_read_token,text,0,,,,0.075,,
gpt-5.4-mini,standard,output_token,text,0,,,,0.60,,

Once the pair is complete, build the artifact, validate it offline, then import it:

Terminal window
just registry-from-csv azure-foundry \
dist/foundry/catalog.csv dist/foundry/rates.csv dist/foundry/azure-foundry.json
just registry-import dist/foundry/azure-foundry.json --check # no database
just registry-import dist/foundry/azure-foundry.json # plan, confirm, apply

Keep the CSVs as the checked-in source of truth for the provider: the artifact is the provider’s complete registry state, so re-importing without a model retires that endpoint. Re-running with corrected prices activates a new rate card on the same effective interval, which the plan flags for approval.

Azure serves canonical models other providers also serve, and a canonical model row is shared across providers while endpoints are per-provider. The catalog CSV’s model-level fields (display name, family, release date, description, modalities) therefore rewrite the row every provider’s endpoints read: the plan shows that as a replace on the models dataset, so match the existing values unless you mean to change them for everyone. A reasoning or modality change to a model another provider already serves is refused outright (shared_model_capability_change), because that capability cannot differ per endpoint.

Seed azure-foundry-gov rows the same way, with --provider azure-foundry-gov and that cloud’s own prices. The two clouds are separate providers, so a government row never inherits a commercial price and retiring one cloud’s rows leaves the other’s untouched.

Terminal window
printf '%s' "$FOUNDRY_API_KEY" | nexus credentials platform-keys set \
--provider azure-foundry \
--metadata '{"resource":"contoso-openai","api_version":"2024-06-01","deployments":{"gpt-5.4-mini":"gpt-5-4-mini-prod"}}' \
--secret-stdin

Scope flags (--workspace, --org, --project) narrow the key as with any platform key. platform-keys list and get print the metadata so you can see which resource and deployments a key serves. platform-keys validate checks that the secret decrypts; no live upstream probe exists for this provider, so a --live run reports healthy (no live probe) on decryptable keys.

Against the dev stack, the whole path — discover, price, import, route, meter — runs in five commands. Postgres and the KMS key live inside the compose network, so the import and the credential go through the cli service while generation and offline validation run on the host:

Terminal window
just registry-foundry-csv dist/foundry --prices # writes catalog.csv, rates.csv, pricing-evidence.json
# fill in the blanks the tool reported, then:
just registry-from-csv azure-foundry dist/foundry/catalog.csv dist/foundry/rates.csv dist/foundry/azure-foundry.json
just registry-import dist/foundry/azure-foundry.json --check
COMPOSE="docker compose --env-file .env -f deploy/compose/docker-compose.yml -f deploy/compose/docker-compose.dev.yml"
$COMPOSE --profile cli run --rm -i cli cargo run -q -p nexus-cli -- \
registry import /workspace/dist/foundry/azure-foundry.json --yes --format table
printf '%s' "$AZURE_FOUNDRY_COMMERCIAL_API_KEY" | $COMPOSE --profile cli run --rm -i cli cargo run -q -p nexus-cli -- \
credentials platform-keys set --provider azure-foundry --secret-stdin \
--metadata '{"resource":"my-resource","api_version":"2024-10-21","deployments":{"deepseek-v3.2":"DeepSeek-V3.2"}}'

The gateway serves /v1/models from a periodically refreshed registry snapshot, so a freshly imported model appears within that interval rather than instantly. Every enabled, priced endpoint in that snapshot is listed, whatever keys the caller’s project holds — catalog-only rows stay invisible and a request for one fails as unknown_model, which is what keeps an unpriced model from ever being billed.

Then route real traffic and confirm the metering used the imported rate card:

Terminal window
curl -sS "$NEXUS_BASE_URL/chat/completions" \
-H "authorization: Bearer $NEXUS_API_KEY" -H 'content-type: application/json' \
-d '{"model":"deepseek-v3.2","messages":[{"role":"user","content":"Reply with: ok"}],"max_tokens":24}'
$COMPOSE exec -T clickhouse clickhouse-client -q "
SELECT model, is_stream, billing_status, prompt_tokens, completion_tokens, total_cost_usd
FROM vortex.requests WHERE provider = 'azure-foundry' ORDER BY request_id"

billing_status = priced with total_cost_usd equal to tokens times the imported per-token price is the signal that the artifact, the connection, and the meter agree. Streamed requests are metered the same way.

Azure exposes deployment listings on two different surfaces, and a resource serves one of them, not both:

Surface URL Listing api-version
AI Foundry projects {project_endpoint}/deployments 2025-05-01
Azure OpenAI data plane {origin}/openai/deployments 2023-03-15-preview

Discovery tries the projects surface first when the connection carries a project_endpoint, then falls back to the data plane. When neither answers, the error names every URL it tried and what each returned, because a bare 404 cannot distinguish the wrong surface from a bad project endpoint or a key without access.

Set discovery_api_version in the metadata when a resource serves neither pinned listing version. The data-plane origin follows the same three-step resolution as inference, so a lane that declares its upstream in the artifact lists deployments from the host it actually talks to. The projects surface is addressed solely by project_endpoint, which is a full URL and therefore already the operator’s to choose.

Each listing api-version above is fixed and belongs to the listing API. It is unrelated to the connection’s api_version, which is the inference version used for chat completions. The data-plane version in particular is pinned to the last one that serves a listing at all: later versions dropped it and answer 404.

An AI Foundry project resource therefore needs project_endpoint in its metadata, keeping the path because the project name is part of it. A plain Azure OpenAI resource needs nothing extra — it is reached through the fallback. Note that project_endpoint does not replace the connection’s base URL: inference is dispatched to the origin, so a project-based connection with a private endpoint stores both.

Discovery lists the deployments currently exposed by one Foundry connection, compares them with the connection’s deployments map, and classifies each upstream deployment:

  • mapped — the deployment is already present in the connection metadata, under any identifier the listing reports for it.
  • mappable — the deployment’s underlying model matches an enabled azure-foundry registry endpoint by provider model id or canonical model id.
  • unsupported — no enabled registry endpoint can serve that upstream model.

Report-only discovery does not update connection metadata and never writes registry rows:

Terminal window
nexus credentials platform-keys discover ppk_...
nexus credentials provider-keys discover my-org/default/azure-foundry/primary

Apply mode merges mappable deployments into the connection metadata, prunes mappings whose deployment no longer exists upstream, validates the resulting metadata, and records a connection.discovery_applied audit event with counts only.

Two safeguards matter here, because apply rewrites the whole map:

  • A listing that succeeds with zero deployments is refused rather than applied. An empty page is indistinguishable from a listing the key cannot see — an RBAC-filtered surface, a proxy shim, or the wrong surface for the resource — and committing it would erase every mapping. The error says so and points at report-only mode. A connection with no mappings yet is still applied, since it has nothing to lose.
  • A mapping matches a deployment under any identifier the surface reports. Surfaces differ: an ARM-style listing puts a fully-qualified resource path in id and the short name in name. Both are matched, the short name is what gets stored (only it is addressable as a deployment URL segment), and a mapping previously stored under a resource path is rewritten to the short name on the next apply. On the dev stack just bootstrap-registry runs this step itself, after the imports, for the platform connection it seeds:
Terminal window
nexus credentials platform-keys discover ppk_... --apply
nexus credentials provider-keys discover my-org/default/azure-foundry/primary --apply

The control-plane REST surface exposes the same operation for BYOK provider connections:

Terminal window
curl -X POST "$CONTROL_PLANE/api/provider-keys/pk_.../discover" \
-H "authorization: Bearer $TOKEN" \
-H "content-type: application/json" \
-d '{"apply": true}'

For local checks against a mock upstream, set the connection’s base URL to a loopback origin such as http://127.0.0.1:18080. Non-loopback HTTP origins are rejected; production Foundry origins should use HTTPS.

Via the console: Settings → Provider keys → Register provider key, choose Azure AI Foundry, and fill in the resource, API version, and deployment map. Via REST:

Terminal window
curl -X POST "$CONTROL_PLANE/api/provider-keys" \
-H 'content-type: application/json' \
-d '{
"org_id": "org_...",
"project_id": "proj_...",
"provider": "azure-foundry",
"name": "primary",
"secret": "<foundry api-key>",
"metadata": {
"resource": "contoso-openai",
"api_version": "2024-06-01",
"deployments": {"gpt-5.4-mini": "gpt-5-4-mini-prod"}
}
}'

The gateway resolves the primary connection; other names are stored but nothing routes to them until a selector targets them (the console flags such connections as Not routed).

A chat completion pinned to azure-foundry/gpt-5.4-mini posts to:

{origin}/openai/deployments/{deployment}/chat/completions?api-version={api_version}

with the connection secret in the api-key header. The response is OpenAI-shaped; token usage and operator-pinned cost are recorded on the request analytics row like any other provider.