Series: Decision APIs in .NET, part 6 of 10. Previous: Part 5, native clients in C#. Next: Part 7, local models and Polish evaluation.
The serializer from Part 5 passes its tests, the Azure resource answers a ping, and the first real request still fails. Nothing is wrong with the JSON. Someone pasted the Foundry project URL where the resource endpoint belongs, or put the model name where the deployment name belongs, or requested a token for the wrong audience. Each value looked plausible, and each produced a well-formed request the service could not accept.
Azure now offers two decision endpoints with two different contracts: Microsoft-Decision-1 behind a System One-style operation in Microsoft Foundry, and the Azure OpenAI Decisions API behind the OpenAI v1 surface. Both can feed the same typed answers from Part 3. Their endpoints, serializers, token audiences and roles are not interchangeable. This part makes every one of those identifiers explicit in C#, checks the wiring locally, and is precise about what a local check cannot prove.
- A decision request uses five identifiers: resource root, operation path, deployment name, underlying model and token scope. Each has exactly one place in the request.
- Bind the surface to its operation, serializer and audience in one profile, so a System One body can never be sent to the OpenAI path, and validate the root before any credential is touched.
- Acquire the token inside the same deadline as the HTTP call, from one explicit credential per environment. In production that is the intended managed identity, not a probing chain.
- Deploying, inferring and acting are three permissions. A local test proves the wiring, not that the identity is authorised or the model is good.
Two Azure surfaces, two contracts
The two endpoints arrived within two weeks of each other and are easy to conflate. They are separate products with separate milestones, not a preview and a GA of the same thing.
| Microsoft-Decision-1 | Azure OpenAI Decisions | |
|---|---|---|
| Status | Public preview, announced 9 October 2026 | Generally available, launched 22 October 2026 |
| Model | Microsoft-Decision-1, version 1 | gpt-6-luna (the only supported model) |
| Operation on the resource | providers/microsoft/v1/systemone | openai/v1/decisions, no api-version |
| Wire format | "Similar to" System One: state, questions keyed by name; noul, choice, score | OpenAI Decisions: input, questions array, per-question refusal |
Request model | Deployment name | Deployment name |
| Entra token scope in the docs | https://cognitiveservices.azure.com/.default | https://ai.azure.com/.default |
| Deployment types | GlobalStandard; DataZoneStandard in selected regions | Where gpt-6-luna is available; East US 2 excluded from the rollout |
| Launch price, per 1M input tokens | $0.042 (output not charged) | $0.10, cached $0.01 (output not charged) |
Two lines in that table deserve a second look. Microsoft describes the Decision-1 request as similar to a System One API, not identical, and documents three question kinds. Use the Part 5 System One adapter, but test against the Foundry deployment rather than assuming every TypeSafe feature carries over. And the token scopes differ by page: the Decision-1 guide requests a Cognitive Services token, while the Foundry keyless-authentication guide and the Azure OpenAI v1 guide use https://ai.azure.com/.default. Follow the page for the surface you call, and treat a real call with the intended identity as the only proof.
Five identifiers, five places in the request
Most configuration mistakes are an identifier in the wrong slot. Where does each one actually go?
Each identifier has one place in the exchange, and none of them substitutes for another
- Five configuration values, five places in one HTTP exchange with a Microsoft-Decision-1 deployment. The client builds every line on the right from the profile on the left.
- The resource root selects the Azure resource and nothing else. A Foundry project URL such as
/api/projects/triageis not a root; appending an operation to it builds a different path. - The operation selects the API on that resource. It decides the wire format, so the System One path and the OpenAI path cannot share a serializer.
- The request body names the deployment, the alias you chose when deploying, not the model. A wrong deployment name is a 422 in Microsoft's troubleshooting table.
- The response names the underlying model,
microsoft-decision-1. The client must accept that it differs from the deployment name and record it for audit. - The token scope sets the audience of the bearer token. It is never a URL the request is posted to, and a Resource Manager token is not a data-plane token.
- The same five places for Azure OpenAI Decisions: another host, the v1 path without
api-version, deploymenttriage-luna, modelgpt-6-lunaand theai.azure.comaudience.
RES and the deployment names are placeholders. Paths and scopes follow the Microsoft Learn pages for each surface.
Deploy through the documented surface
For Microsoft-Decision-1 the documented portal route is your Foundry project, then Model catalog, Microsoft-Decision-1 and Deploy. Copy the resource endpoint and the deployment name from the deployment page. The equivalent CLI call, for an existing resource, is:
az cognitiveservices account deployment create \
--name RESOURCE_NAME \
--resource-group RESOURCE_GROUP \
--deployment-name triage-ms \
--model-name "Microsoft-Decision-1" \
--model-format Microsoft \
--model-version "1" \
--sku-name GlobalStandard \
--sku-capacity 1
That command changes Azure state, needs the Cognitive Services Contributor role or equivalent, and is shown here as an instruction, not as something this article ran. GlobalStandard may process inference in any supported region; for locality requirements choose DataZoneStandard where it is offered.
For Azure OpenAI Decisions, deploy gpt-6-luna on an Azure OpenAI resource in a region where it is available, and choose a deployment name such as triage-luna. The Decisions operation lives on the v1 surface, so there is no dated api-version query to carry around.
Bind the profile so wrong combinations cannot be configured
The cheapest defence is to make mismatches unrepresentable. The profile below takes three inputs, the root, the deployment name and the surface, and derives the other three from the surface. Nobody can configure the System One operation with the OpenAI serializer, or the right path with the wrong audience.
public enum DecisionSurface { MicrosoftDecision1, AzureOpenAiDecisions }
public sealed record AzureProfile
{
public Uri Root { get; }
public string Deployment { get; }
public DecisionSurface Surface { get; }
public AzureProfile(Uri root, string deployment, DecisionSurface surface)
{
if (!root.IsAbsoluteUri || root.AbsolutePath != "/" || root.Query != "" ||
root.Fragment != "" || root.UserInfo != "")
throw new ArgumentException("Root must be the resource endpoint, with no path, query or credentials.");
if (root.Scheme != Uri.UriSchemeHttps && !root.IsLoopback)
throw new ArgumentException("Remote endpoints must use HTTPS.");
if (string.IsNullOrWhiteSpace(deployment))
throw new ArgumentException("Deployment name is required.");
(Root, Deployment, Surface) = (root, deployment, surface);
}
public string Operation => Surface switch
{
DecisionSurface.MicrosoftDecision1 => "providers/microsoft/v1/systemone",
_ => "openai/v1/decisions", // v1 API: no api-version query
};
public string TokenScope => Surface switch
{
DecisionSurface.MicrosoftDecision1 => "https://cognitiveservices.azure.com/.default",
_ => "https://ai.azure.com/.default",
};
public INativeWire Wire => Surface switch
{
DecisionSurface.MicrosoftDecision1 => new SystemOneWire(Operation),
_ => new OpenAiDecisionsWire(Operation),
};
}
Validation runs in the constructor, before a credential or a socket is involved. In the lab it rejected a project URL, an already-suffixed /openai/v1/ root, a root carrying ?api-version=, plain HTTP to a remote host, credentials embedded in the URL and an empty deployment name. Each of those would otherwise surface much later as a 404 or a 401 that points at the wrong field.
Authentication is a DelegatingHandler in front of the Part 5 transport. It asks the credential for a token on every request, with the request's own cancellation token, and attaches it as a bearer header. It never stores the token in configuration or logs.
public sealed class BearerTokenHandler(TokenCredential credential, string scope) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
AccessToken token = await credential.GetTokenAsync(
new TokenRequestContext([scope]), cancellationToken); // cached inside real credentials
request.Headers.Authorization = new("Bearer", token.Token);
return await base.SendAsync(request, cancellationToken);
}
}
public sealed class AzureDecisionClient(AzureProfile profile, TokenCredential credential,
HttpMessageHandler transport, TimeSpan deadline)
{
private readonly NativeClient client = new(
new HttpClient(new BearerTokenHandler(credential, profile.TokenScope) { InnerHandler = transport })
{ Timeout = Timeout.InfiniteTimeSpan },
profile.Root, profile.Wire, deadline);
public async Task<NativeResult> SendAsync(DecisionTask task, string input, CancellationToken caller)
{
try
{
return await client.SendAsync(task, profile.Deployment, input, caller);
}
catch (Exception e) when (e is AuthenticationFailedException or CredentialUnavailableException)
{
return new(Outcome.Authentication, Error: e.GetType().Name); // no request was sent
}
}
}
Asking for a token per request is not wasteful. The Azure Identity credentials keep acquired tokens in an in-memory cache, which is exactly why the guidance is to create one credential instance and reuse it. A new DefaultAzureCredential per call throws that cache away and, under load, can draw throttling from Microsoft Entra ID.
Credential acquisition shares the deadline
Part 5 gave each call one budget covering headers, body and parse. Where does getting a token fit? If the credential is slow, for example a managed identity endpoint that is slow to answer, should the decision call start a fresh full-length deadline afterwards?
The token is acquired inside the call's budget, so a slow credential becomes a Deadline
- The app calls the decision client with its own token. Before anything is sent, the handler asks the credential for a token for the profile's scope, under the same linked budget.
- The credential returns an access token. The handler attaches it to this request only; the token is never written to configuration, logs or the result.
- The request goes to the deployment with the bearer header, the deployment name in
modeland the System One body. - The response names the underlying model. The Part 3 validator accepts the answers and the client returns
Ok. - On the next call, a real Azure Identity credential answers from its cache. That is why one long-lived credential instance matters.
- In the lab a credential that needs 800 ms meets a 300 ms budget. It sees the cancellation, no HTTP request is sent, and the caller gets
Deadline.
Steps 1 to 4 and 6 were executed with a test credential and an in-memory handler. Step 5 describes the caching documented for Azure Identity credentials.
The consequence is a design choice to make explicitly. Either the budget includes credential acquisition, as here, and a slow identity endpoint shows up as Deadline; or the application warms the credential at startup and accepts that the first request after a long idle may be slower. What it should not do is start a new inference deadline after an unbounded token wait without saying so.
Choose the credential deterministically
Azure's own guidance is direct: use deterministic credentials in production. DefaultAzureCredential walks a chain of sources, and a changed environment variable or a developer tool on a build agent can silently select a different identity. Pick one credential per environment and name it in code:
public static class Credentials
{
// Local development: one explicit developer credential, not a probing chain.
public static TokenCredential Developer() => new AzureCliCredential();
// Deployed workload: the intended user-assigned identity, nothing else.
public static TokenCredential Workload(string clientId) =>
new ManagedIdentityCredential(ManagedIdentityId.FromUserAssignedClientId(clientId));
}
Both factories compile against Azure.Identity 1.21.0; the lab never calls them, because it must not sign in or touch an account. If a team prefers to keep DefaultAzureCredential for convenience, Azure.Identity supports narrowing it with the AZURE_TOKEN_CREDENTIALS environment variable, for example to ManagedIdentityCredential in production.
Both surfaces also accept an API key in an api-key header. For a bounded prototype that can be reasonable. If you choose it, keep the key in the existing secret store, never in the endpoint URL, and do not let a key setting and a bearer setting coexist in one profile. Keyless authentication earns its extra setup when you want access controlled by role assignments and no long-lived application secret to rotate.
Permission to deploy is not permission to infer, or to act
Creating a deployment is a management operation; calling it is a data-plane operation. They are usually performed by different principals and need different roles.
| Activity | Who | Role in the documentation |
|---|---|---|
| Deploy Microsoft-Decision-1 | Platform engineer or pipeline | Cognitive Services Contributor |
| Call a Foundry model with Entra ID | The workload identity | Cognitive Services User, assigned at the Foundry resource scope |
| Call Azure OpenAI v1 with Entra ID | The workload identity | Cognitive Services OpenAI User |
| Assign those roles | Administrator | Owner or User Access Administrator |
Two operational details cause most "it works for me" reports. A familiar role on the wrong resource authorises nothing, and Microsoft notes that role assignments can take up to five minutes to propagate. Neither is visible in a local test, and decoding a token on your machine does not tell you whether the service will accept it.
There is a third permission Azure does not decide at all. A managed identity that may call the decision model says nothing about whether the support operator may draft a reply, arrange a return or issue a refund. Keep that check in application code, as in Part 1, and do not let successful service authentication turn into business authority. The wider pattern of separate deployment, runtime and operator identities is in three identities in one Azure service.
Read the status, then the field it implicates
When a call fails, the status narrows the search to a few fields, not to one. Microsoft's troubleshooting table for Decision-1 is a good map; the slider below follows it.
A status narrows the search to a few fields, never to exactly one
- 400: the question type or shape is invalid. Check
state,questions,typeandcriteria; an OpenAI-shaped body sent to the systemone path fails here. - 401: the credential is missing, invalid or expired, or the token was issued for the wrong audience. Credential acquisition succeeding locally does not rule this out.
- 403: the identity is known but not allowed. Check the role and the scope it was assigned at. If the resource restricts networks, confirm the caller's network is allowed too.
- 404: the resource endpoint or the API path is wrong. A project URL used as the root, or a doubled
/openai/v1/, lands here. - 422: the deployment name or a question definition is not supported. The model name in place of the deployment name is the classic cause.
- 429: the deployment's rate limit was exceeded. This is the only status in the set where a retry with back-off is the right next step.
Status meanings from the Microsoft-Decision-1 troubleshooting table on Microsoft Learn. Network access is an extra check for restricted resources, not a cause listed in that table. Outcomes are the Part 5 client's classification.
Keep the status and the bounded error detail with the outcome, and keep the identifiers that produced the request: root, operation, deployment and scope (never the token). With those five values in the log line, most of these failures are a one-minute fix instead of an afternoon of guessing.
What a local run establishes, and what it cannot
The lab wires the Part 5 client to both profiles with a test TokenCredential and an in-memory HttpMessageHandler that records every request and returns authored bodies. There is no network listener and no cloud request. On .NET 10 with Azure.Identity 1.21.0 it printed, in part:
PASS MicrosoftDecision1: url https://contoso-triage.services.ai.azure.com/providers/microsoft/v1/systemone
PASS MicrosoftDecision1: body model "model": "triage-ms"
PASS MicrosoftDecision1: outcome Ok, response model microsoft-decision-1
PASS MicrosoftDecision1: scope https://cognitiveservices.azure.com/.default
PASS AzureOpenAiDecisions: url https://contoso-triage.openai.azure.com/openai/v1/decisions
PASS AzureOpenAiDecisions: body shape input + questions array
PASS AzureOpenAiDecisions: scope https://ai.azure.com/.default
PASS profile project URL rejected: Root must be the resource endpoint, with no path, query or credentials.
PASS profile dated api-version rejected: Root must be the resource endpoint, with no path, query or credentials.
PASS slow credential inside budget Deadline after 300 ms, credential saw cancellation: True, requests sent: 0
PASS credential failure Authentication (AuthenticationFailedException), requests sent: 0
PASS status 422 Configuration
PASS token never in report
31 passed, 0 failed; network listeners: 0; cloud requests: 0
That is useful evidence about the client: the right URL, the right body, the right audience, the token attached and never printed, cancellation reaching the credential. It certifies nothing about Azure. The remaining acceptance is short and specific: with an authorised deployment and the intended workload identity, send the frozen task through the selected operation, keep a redacted request and response, and check that the normalised answers pass the Part 3 validator. Only then is it worth comparing model answers on labelled tickets, which is where Part 4's thresholds and Part 7's evaluation take over.
Worked example: a configuration that fails four ways
Plausible values in the wrong slots
{
"Endpoint": "https://contoso-triage.services.ai.azure.com/api/projects/triage",
"Model": "Microsoft-Decision-1",
"ApiVersion": "2024-10-21",
"TokenScope": "https://management.azure.com/.default",
"Credential": "DefaultAzureCredential"
}
A project URL as root (404), the model name as deployment (422), a dated version on a surface that has none, a Resource Manager audience (401), and a credential chain that may pick a different identity on the next build agent.
Three inputs, everything else derived
{
"Root": "https://contoso-triage.services.ai.azure.com/",
"Deployment": "triage-ms",
"Surface": "MicrosoftDecision1",
"ManagedIdentityClientId": "<client id of the workload identity>"
}
Operation, serializer and audience follow from Surface. The root is validated at startup, the credential is one named identity, and the deployment name is the one on the deployment page.
Pitfalls
- Treating the two surfaces as versions of one API. Different operation, different wire format, different audience in the docs.
- Deriving the scope from the endpoint. Stripping a path from a URL does not produce an audience. Configure it per surface.
- A new credential per request. It defeats token caching and can trigger Entra throttling.
- Unbounded token waits. Credential acquisition belongs inside the call's deadline, or explicitly before it.
- Assuming a role works the moment it is assigned. Allow for propagation, and check the scope it was assigned at.
- Adding private networking to fix a 404. Network rules decide who can reach the resource. They never fix a wrong path, deployment or serializer.
- Logging the request for diagnosis. Log the five identifiers and the status, never the authorisation header or key.
Which surface for which job
| If you need | Prefer | Because |
|---|---|---|
| Per-question refusals, image input, the official .NET SDK | Azure OpenAI Decisions | Documented refusal answer, up to 128 images, DecisionsClient in OpenAI 2.15.0 |
| Lowest input price for text-only routing, data-zone processing | Microsoft-Decision-1 | $0.042 per 1M input tokens at launch, DataZoneStandard where offered |
| A GA service for production commitments | Azure OpenAI Decisions | Decision-1 is in public preview |
| Both, behind one routing policy | Two profiles, one contract | The Part 3 records and validator are shared; only the profile changes |
Checklist
- Each deployment has a profile: root, deployment name and surface; operation, serializer and scope are derived.
- The root is the resource endpoint, validated at startup: no path, query, fragment or embedded credentials, HTTPS only.
- The request
modelis the deployment name, and the responsemodelis recorded, not compared. - One credential instance per process, explicit per environment: developer credential locally, the named managed identity in production.
- Token acquisition runs inside the call's budget, and the token never reaches logs or results.
- Deploy, infer and act are separate permissions held by separate principals.
- A live acceptance call with the intended identity is planned and recorded before model quality is evaluated.
Series: Previous: Part 5, native Decision API clients in C#. Next: Part 7, local models and Polish evaluation.
Further reading
- Deploy and use Microsoft-Decision-1 (Microsoft Learn): operation path, deployment, authentication and troubleshooting.
- Use the Azure OpenAI Decisions API (Microsoft Learn): endpoint, limits, C# examples with
DecisionsClient. - Azure OpenAI v1 API lifecycle (Microsoft Learn): the v1 surface without
api-version, and its token scope. - Configure keyless authentication with Microsoft Entra ID (Microsoft Learn): Cognitive Services User and propagation.
- Authentication best practices with the Azure Identity library for .NET: deterministic credentials and credential reuse.
- Introducing Microsoft-Decision-1 in Microsoft Foundry (9 October 2026) and Azure OpenAI Decisions API generally available (23 October 2026).




