# Azure OpenAI and Azure AI Foundry

Three Azure descriptors, three price namespaces, and one URL shape that needs a deployment name you chose rather than a model id.

Source: https://tokentriage.com/docs/build/providers-azure

---
import Callout from '../../../components/Callout.astro';
import Details from '../../../components/Details.astro';
import NextSteps from '../../../components/NextSteps.astro';

Azure is the family most likely to be misconfigured, for two reasons. Its URL carries a deployment
name you chose rather than a model id, so the path and the body disagree about what is being
called. And "Azure" is not one product: three descriptors ship for it, over two URL shapes and three
price namespaces that no single descriptor could represent.

This page sets up all three. You need the resource host, an API key, and, for the two
deployment-path descriptors, the name of the deployment you created in the portal.

## Which of the three you have

| You are calling | Descriptor | Host looks like | Prices under |
|---|---|---|---|
| a chat or embedding model you deployed in Azure OpenAI | `azure_openai` | `https://<resource>.openai.azure.com` | `azure/`, 216 priced keys |
| Llama, Mistral, DeepSeek or another catalog model in AI Foundry | `azure_ai` | `https://<project>.services.ai.azure.com` | `azure_ai/`, 91 priced keys |
| a legacy instruct deployment on `/completions` | `azure_text` | `https://<resource>.openai.azure.com` | `azure_text/`, 3 priced keys |

Those three figures count keys in the vendored `litellm@2026-07-14#179affb` price file. They are
statistics about a price table rather than claims about what the gateway can reach, and
`tokentriage prices show` reads the same file back to you.

Getting the descriptor wrong does not produce a wrong dollar. It produces no dollar: the model you
sent is not found under the other product's namespace, and the row comes back `model_unknown`. The
[pricing-key page](/docs/build/providers-pricing-keys) covers how to read that.

## Azure OpenAI

The URL is `…/openai/deployments/{deployment}/{operation}?api-version=…`. The descriptor holds that
template; you supply the two variables.

```yaml
upstreams:
  azure:
    provider: azure_openai
    base_url: https://my-resource.openai.azure.com
    api_key_env: AZURE_OPENAI_API_KEY
    params:
      deployment: gpt5-prod
      api_version: "2025-04-01-preview"

tiers:
  - name: frontier
    upstream: azure
    model: gpt-4o
```

```console
$ tokentriage validate --config tokentriage.yaml
mode:      ledger
listen:    127.0.0.1:8787   admin: 127.0.0.1:9090
upstreams: 1
  - azure                  https://my-resource.openai.azure.com
tiers:     1 (cheapest -> most capable)
  0. frontier   -> azure/gpt-4o
rules:     0
OK: configuration is valid
```

The upstream name, the host and the tier in that block are yours, read back from the file above. The
`mode`, `listen` and `admin` lines are the shipped defaults, printed because this configuration sets
none of them.

**Authentication is the `api-key` header**, not a bearer token. That is descriptor data, so
`api_key_env` is all you write. TokenTriage puts 10 of its 95 built-in provider descriptors on that
header scheme, and 3 of the 10 are Azure's. What a test gates is one descriptor's one endpoint
rather than a descriptor as a whole, and the six Azure cells are tabled below with the gate on each.

**Quote the API version.** `2025-04-01-preview` is a string; YAML will read an unquoted
`2025-04-01` as a date and the value will not be what you meant.

<Callout type="key">
The deployment stays fixed while the model moves. A routing decision rewrites the `model` field in
the request body; it does not change the deployment in the path. So one Azure upstream addresses
one deployment, and a second deployment is a second upstream with its own `params.deployment`.
</Callout>

That is the fact that decides how you lay out tiers. If your cheap and frontier models live in two
Azure deployments, you need two upstreams:

```yaml
upstreams:
  azure-mini:
    provider: azure_openai
    base_url: https://my-resource.openai.azure.com
    api_key_env: AZURE_OPENAI_API_KEY
    params: { deployment: gpt5-mini-prod, api_version: "2025-04-01-preview" }
  azure-frontier:
    provider: azure_openai
    base_url: https://my-resource.openai.azure.com
    api_key_env: AZURE_OPENAI_API_KEY
    params: { deployment: gpt5-prod, api_version: "2025-04-01-preview" }
```

### Endpoints

The endpoint vocabulary registers 17 kinds. `azure_openai` declares three of them: chat, embeddings
and the models listing. Azure sells image and audio surfaces as well, and no Azure descriptor
declares those. Image generation is declared by 7 descriptors and image editing by 3; audio
transcription by 4, speech by 3 and translation by 1. None of those declarers is an Azure
descriptor, so calls to those surfaces take the passthrough path: forwarded and ledgered, without a
cost figure. The count of descriptors declaring
each kind is generated, and the generator prints a zero rather than dropping the row, so a
registered but undeclared kind is a stated fact instead of a hole in a table.

Four of the 17 kinds are recognition-only across every descriptor, not only on Azure: `files`,
`fine_tuning`, `vector_stores` and `realtime`. A request to one of those is scoped by virtual key,
matchable by governance limits and cache rules, and stamped on the ledger row, which is worth
having. What it is not is a proxy for file content or for lifecycle state, so Azure OpenAI's own
file and fine-tuning surfaces sit outside what this gateway models. The
[endpoints page](/docs/build/providers-endpoints) carries the whole vocabulary.

### What a test gates, and what it does not

The generated matrix declares 186 provider-by-endpoint cells and prints an evidence level against
each one, so you can tell a gated cell from a declared one. These are the six Azure cells:

| Cell | Evidence | Gate you can run |
|---|---|---|
| `azure_openai` chat | row-stamp | `TestAzureCostGoldenDP2` |
| `azure_openai` embeddings | endpoint-stamp | `TestAzureEmbeddingsCost` |
| `azure_openai` models | declared (no e2e gate) | none |
| `azure_ai` chat | row-stamp | `TestAzureAIFoundry` |
| `azure_ai` embeddings | declared (no e2e gate) | none |
| `azure_text` completions | declared (no e2e gate) | none |

A row-stamp means a test drives a request through the descriptor against a mock shaped like that
provider and asserts the ledger row's own provider and endpoint stamps. An endpoint-stamp is weaker:
the test pins the endpoint stamp and identifies one descriptor without asserting the provider stamp,
and the matrix marks it as such. `declared (no e2e gate)` means the descriptor declares the endpoint
and the engine will serve it, with no harness test driving that cell; the matrix prints those rather
than hiding them and calls them "not a support claim". Those 186 are declared cells, so the total is
not a verified count and should not be read as one.

## Legacy instruct deployments

`azure_text` is the third descriptor and the one fewest readers need. It is derived from the Azure
OpenAI one, so the `api-key` header, the required `api-version` query and the inbound handling
cannot drift apart from it. What differs is the path,
`/openai/deployments/{deployment}/completions`, the single endpoint kind it declares, and the price
namespace it binds.

```yaml
upstreams:
  azure-instruct:
    provider: azure_text
    base_url: https://my-resource.openai.azure.com
    api_key_env: AZURE_OPENAI_API_KEY
    params:
      deployment: gpt35-instruct-prod
      api_version: "2024-10-21"

tiers:
  - name: legacy
    upstream: azure-instruct
    model: gpt-35-turbo-instruct
```

TokenTriage declares the `completions` kind on 3 of its 95 built-in provider descriptors:
`azure_text`, `openai` and `text_completion_openai`. No harness test drives any of those three
cells, so all three read `declared (no e2e gate)` in the matrix, and `azure_text` is one of the 10
descriptors carrying no gated cell at all. Read this section as a shape the engine will serve rather
than as a tested path. The capability claim on the descriptor is narrowed to match the endpoint:
tools, tool choice, response format, vision parts and reasoning effort are all absent from the
legacy request schema, so the descriptor claims none of them.

All 3 keys under `azure_text/` are `gpt-35-turbo-instruct` variants, and Microsoft's model
retirement schedule, read on 2026-08-23 while the descriptor was written, lists no instruct row at
all. Absence from a schedule is not a retirement notice, so nothing here says the deployment is gone
and nothing says it is still available; the descriptor carries a dated note to look again on
2026-11-01. The `/completions` path itself stays in Azure's stable data-plane specification either
way.

## Azure AI Foundry

Foundry uses one endpoint for every model in its catalog, so there is no deployment in the path,
only an API version.

```yaml
upstreams:
  foundry:
    provider: azure_ai
    base_url: https://my-project.services.ai.azure.com
    api_key_env: AZURE_AI_API_KEY
    params:
      api_version: "2024-05-01-preview"

tiers:
  - name: cheap
    upstream: foundry
    model: deepseek-r1
```

Requests go to `/models/chat/completions` on the OpenAI wire, with the `api-key` header. The
descriptor declares chat and embeddings.

The price difference between the two products is the argument for keeping them apart. In the
committed snapshot, `deepseek-r1` through Foundry resolves at $1.35 per million input tokens and
$5.40 per million output; the same model bought direct from DeepSeek resolves at $0.55 and $2.19.
Both are correct figures for what you bought, out of one price file.

## Reaching Azure from a client that is not OpenAI-shaped

All three Azure descriptors ride the `openai` wire driver, so an Anthropic-format client reaches an
Azure upstream through translation, streaming and not. The grid of which client format reaches which
upstream dialect is drawn once, on the
[dialect matrix](/docs/build/providers-dialect-matrix); what a translated row records about the
fields that could not cross is on
[translation loss](/docs/build/providers-translation-loss).

## Checking it worked

Point a client at the proxy, send one request, and read `cost.pricing_key` on the row. It should
begin `azure/`, `azure_ai/` or `azure_text/` according to which product you configured.

You can check a rate first without sending anything:

```console
$ tokentriage prices show azure/gpt-4o
model:      azure/gpt-4o
resolved:   azure/azure/gpt-4o
provider:   azure
snapshot:   litellm@2026-07-14#179affb
source:     litellm  fetched 2026-07-14  generator pricegen v2
upstream_sha256: 179affb29103fed8b02a83e22e93209c2c65a393cbf9dd785dbaec0f8748d6f7

RATE         USD / TOKEN          USD / 1M TOKENS
input        2.5e-06              $2.5000
output       1e-05                $10.0000
cache_read   1.25e-06             $1.2500
cache_write  (fallback = input)   $2.5000
reasoning    (fallback = output)  $10.0000
```

Every line there comes from the price file compiled into the binary, so your copy prints the same
figures for as long as you run the same build. The snapshot id and the digest are printed to tell
you which build that was.

<Details summary="Why the doubled azure/ in the resolved key">

The vendored catalog namespaces each provider's models by the provider prefix, and Azure's own
published model ids already carry `azure/`. So the stored key is `azure/azure/gpt-4o`, and two
different lookups reach it.

The proxy scopes its search to the descriptor's catalog prefix, which is what keeps an Azure request
from picking up OpenAI's direct rate: a model absent from the `azure/` namespace resolves
`model_unknown` rather than borrowing a neighbour. `prices show` takes no prefix argument, so it
matches your argument against the tail of every key in the file and prefers the shortest hit. That
is why `azure/gpt-4o` finds the doubled key, and why the `azure_text` rows want their stored key in
full: `prices show azure_text/azure/gpt-35-turbo-instruct` resolves, and
`azure_text/gpt-35-turbo-instruct` does not.

</Details>

## What can go wrong

| Symptom | Cause | Fix |
|---|---|---|
| `"deployment_name" is not a template variable of provider "azure_openai" (allowed: api_version, deployment)` | wrong key name | the message names both allowed keys |
| `requires template variable "api_version" (missing)` | a `params:` block missing one | both are required for `azure_openai` and for `azure_text` |
| provider returns 404 | the deployment name does not exist on that resource | check the portal; the path carries it |
| row is `model_unknown` | the model is not in this product's namespace | check which of the three descriptors you configured |
| API version reads as a date | unquoted YAML | quote it |

A validation failure prints twice. The command returns the error and the binary prints it again on
its way out, so a doubled message is the tool talking rather than two separate faults.

<NextSteps items={[
  {title: "Which pricing key your model lands under", href: "/docs/build/providers-pricing-keys", description: "Why azure/, azure_ai/ and azure_text/ cannot borrow each other's rates, and how to read an unpriced row."},
  {title: "Amazon Bedrock", href: "/docs/build/providers-bedrock", description: "The other cloud family, where a region replaces the deployment name and moves the rate as well as the host."},
  {title: "Authentication schemes", href: "/docs/build/providers-auth", description: "What the api-key header shares with the five other schemes a descriptor can declare, and what auth health will tell you."}
]} />
