Series: Decision APIs in .NET, part 5 of 10. Previous: Part 4, thresholds from the cost of each error. Next: Part 6, two Azure decision endpoints.
The routing task from Part 3 works against one provider. Someone points it at a second one by changing the base URL, and the first request comes back 422. They fix the payload, and the next surprise is quieter: a response whose confidence field is missing deserialises happily, with a confidence of 0. A week later, a load balancer sends response headers and then stalls, and a request that "has a 100-second timeout" keeps waiting long after those 100 seconds.
None of these are model problems. They are client problems, and they sit in three places: the wire shape of each native protocol, the lifetime of an HTTP response after its headers arrive, and the way failure is reported to the code that has to decide what to do next. This part builds a small C# client for TypeSafe's System One protocol and OpenAI's Decisions API, runs it against a real loopback socket, and compares it with the official OpenAI .NET SDK.
- System One keys questions and answers by name in JSON objects. OpenAI Decisions uses arrays, returns answers in question order, and makes
nameoptional. Map by meaning, never by position you did not check. - Parse strictly: no defaults for missing fields, no duplicate keys (.NET 10 can reject them for you), and labels and legends checked against the rubric you sent.
HttpCompletionOption.ResponseHeadersReadendsHttpClient.Timeoutat the headers. Own one deadline that spans the body and the parse.- Report a cancelled caller, an expired deadline, a broken stream, an invalid body and a refusal as different outcomes, because each one leads to a different next step.
The same three questions travel in two different shapes
The task asks three questions about ticket T-1042: which team should investigate first (a choice over technical, billing, returns and other), whether device, firmware and symptom are all supplied (a predicate), and how severe the service impact is (a three-level score). These are the request bodies the client actually sent, shortened only in the question text.
// POST https://api.typesafe.ai/v1/systemone
{
"model": "jev-latest",
"state": "Aurora M4, firmware 2.8; telemetry stopped after the update. ...",
"questions": {
"department": { "type": "choice", "instructions": "Which team should investigate first?",
"criteria": { "technical": "Device fault or telemetry problem needing diagnosis",
"billing": "...", "returns": "...", "other": "..." } },
"enough_evidence": { "type": "noul", "instructions": "Are device, firmware and symptom all supplied?" },
"urgency": { "type": "score", "instructions": "Rate the supported service impact.",
"criteria": ["No interruption", "Intermittent with workaround", "Continuous without workaround"] }
}
}
// POST https://api.openai.com/v1/decisions
{
"model": "gpt-6-luna",
"input": "Aurora M4, firmware 2.8; telemetry stopped after the update. ...",
"questions": [
{ "name": "department", "type": "choice", "instructions": "Which team should investigate first?",
"choices": [ { "value": "technical", "description": "Device fault or telemetry problem needing diagnosis" },
{ "value": "billing", "description": "..." }, "..." ] },
{ "name": "enough_evidence", "type": "predicate", "instructions": "Are device, firmware and symptom all supplied?" },
{ "name": "urgency", "type": "score", "instructions": "Rate the supported service impact.",
"levels": [ { "label": "No interruption" }, { "label": "Intermittent with workaround" },
{ "label": "Continuous without workaround" } ] }
]
}
The semantics match and almost every detail differs. The differences that matter to a client:
| System One | OpenAI Decisions | |
|---|---|---|
| Content field | state (string, object or array) | input (string or message items) |
| Questions | Object keyed by name | Array; name optional, 1 to 400 questions |
| Predicate | noul, answer field noul | predicate, answer field probability |
| Choice options | criteria object, 1 to 255 | choices array of {value, description}, 2 to 255; values may be booleans |
| Score levels | criteria array, 1 to 10 | levels array of {label, description}, 2 to 10 |
| Answers | Object keyed by question name | Array in question order |
| Distributions | Objects: {"technical": 0.93}, {"0": 0.2}, plus a score legend | Arrays: {value, probability}, {value, label, probability} |
| Refusal | No refusal variant in the published schema | Per question: {"type": "refusal", "name": ...} |
Both protocols return a model field that may differ from the alias you sent; TypeSafe's schema says so explicitly. Microsoft-Decision-1 in Foundry uses a providers/microsoft/v1/systemone path and a request "similar to" System One, and documents only the noul, choice and score kinds. Treat it as its own profile that shares this serializer, not as a promise of full compatibility. Part 6 configures it.
Translate at the edge, then validate the meaning
The client has three jobs and keeps them apart. A wire adapter turns the versioned task into a native request and a native response into the answer records from Part 3. The transport owns the HTTP exchange. The validator from Part 3 then checks the normalised answers against the questions that were asked. What does each adapter have to get right?
Two wire shapes map to one set of answer records, except where a protocol cannot say it
- Each adapter reads its own container: an object keyed by question name for System One, an array in question order for OpenAI. Both feed the same C# records.
- A predicate is
noulon one side andprobabilityon the other. Neither carries a confidence field, so the adapter must not invent one. - Choice distributions arrive as an object or as an array of entries. OpenAI values may be booleans, distinct from the strings "true" and "false"; this task accepts strings only.
- Score levels come back as string keys with a
legend, or as entries with a label. The adapter rejects a level whose label or legend differs from the rubric it sent. - OpenAI can refuse one question and answer the rest. System One's schema has no refusal variant, so a refusal-shaped System One answer is an invalid response, not a partial decision.
Field names from the TypeSafe OpenAPI schema and the OpenAI Decisions API reference. Answer values are authored test bodies.
Four rules fall out of the comparison, and each one is a test in the lab below.
- Identity before value. For System One the map key is the identity; an answer for a name you did not ask is rejected. For OpenAI the position is the identity and the name, when present, must agree with it. A response that puts the
urgencyanswer first, namedurgency, is rejected rather than silently read as the department. - No defaults. A missing
confidenceis an invalid body. A naive DTO withdouble Confidencedeserialises the same body to a confidence of 0, which downstream code cannot tell from a real zero. - Check the rubric that came back. System One returns a
legendthat maps each level to its criterion; OpenAI returns a label with each level. If either disagrees with what was sent, the provider and the application are not talking about the same scale. - Keep the protocol's vocabulary. Accepting a boolean choice value is a capability this task does not need, so the adapter rejects it explicitly. That is a documented limit of this client, not a claim about the API.
Duplicate JSON properties deserve their own line. {"noul": 0.94, "noul": 0.06} is syntactically valid JSON, and by default both JsonDocument and JsonSerializer accept it and keep the last value: in the lab, a naive read returned 0.06. Since .NET 10, JsonDocumentOptions.AllowDuplicateProperties = false (and the same option on JsonSerializerOptions) makes the parser throw instead. Targets older than .NET 10 can take the option from the System.Text.Json 10 NuGet package, or walk the document and reject repeated names themselves.
Headers arrive before the body, and the timeout stops at the headers
To bound memory, a client reads the status and Content-Length before it buffers anything, which means HttpCompletionOption.ResponseHeadersRead. The documentation is explicit about the side effect: with that option, HttpClient.Timeout applies only up to the end of the headers. Reading the content afterwards is not covered. What happens when the server sends headers and then nothing?
The headers arrive in time, the body never does, and only our own deadline notices
- The caller passes its own token. The client links it with a 300 ms budget and sends the request through an
HttpClientwhoseTimeoutis infinite. - Status and headers arrive quickly, so
SendAsynccompletes. WithResponseHeadersRead, this is whereHttpClient.Timeoutwould have stopped counting. - The server sends 30 of the 400 promised bytes and stops.
ReadAsyncwaits; nothing in the HTTP stack has a reason to give up. - The linked budget cancels the read at 300 ms. Because the caller's own token is still live, the client reports
Deadline: no valid assessment arrived in time. - If the caller cancels first, the same read throws, but now the client rethrows. A user who closed the page is not a provider outage and should not trigger a retry.
Both paths were executed against a TCP listener on 127.0.0.1 that withholds the body. Timings are the configured budgets, not provider latency.
The fix is not a bigger timeout. It is one owner for the whole operation: a linked CancellationTokenSource created per call, passed to SendAsync, ReadAsStreamAsync and every ReadAsync, with the HttpClient's own timeout set to infinite so two clocks cannot disagree. Parsing is synchronous and takes no token, so the client checks the budget just before parsing and bounds the work with a byte limit and a nesting depth instead. For the wider pattern of keeping timeout, cancellation and commit apart, see a timeout is not cancellation.
The client: one budget, bounded reads, strict parse
NativeClient.SendAsync turns every way a call can end into one typed outcome
public async Task<NativeResult> SendAsync(DecisionTask task,
string deployment, string input, CancellationToken caller)
{
caller.ThrowIfCancellationRequested();
var json = wire.Payload(task, deployment, input).ToJsonString();
using var budget = CancellationTokenSource.CreateLinkedTokenSource(caller);
budget.CancelAfter(deadline); // one deadline: headers, body, parse
try
{
using var request = new HttpRequestMessage(HttpMethod.Post,
new Uri(root, wire.Path))
{ Content = new StringContent(json, Encoding.UTF8, "application/json") };
using var response = await http.SendAsync(request,
HttpCompletionOption.ResponseHeadersRead, budget.Token);
if (!response.IsSuccessStatusCode) return Classify(response.StatusCode);
if (response.Content.Headers.ContentLength > maxBytes)
return Invalid("declared body exceeds bound");
await using var stream =
await response.Content.ReadAsStreamAsync(budget.Token);
using var buffer = new MemoryStream();
byte[] block = new byte[4096]; int read;
while ((read = await stream.ReadAsync(block, budget.Token)) > 0)
{
if (buffer.Length + read > maxBytes)
return Invalid("body exceeds bound");
buffer.Write(block, 0, read);
}
budget.Token.ThrowIfCancellationRequested();
using var document = JsonDocument.Parse(
buffer.GetBuffer().AsMemory(0, (int)buffer.Length), Strict);
var answers = wire.Read(document.RootElement, task);
if (DecisionValidator.Validate(task, answers) is { } error)
return Invalid(error);
var outcome = answers.Any(a => a is RefusalAnswer)
? Outcome.Refused : Outcome.Ok;
return new(outcome, answers, 200, Raw: document.RootElement.Clone());
}
catch (OperationCanceledException) when (caller.IsCancellationRequested)
{ throw; } // the caller left: not a provider fault
catch (OperationCanceledException) when (budget.IsCancellationRequested)
{ return new(Outcome.Deadline, Error: "operation deadline expired"); }
catch (Exception e) when (e is HttpRequestException or IOException)
{ return new(Outcome.Transport, Error: e.GetType().Name); }
catch (Exception e) when (e is WireException or JsonException
or InvalidOperationException or KeyNotFoundException
or ArgumentException or FormatException)
{ return Invalid(e.Message); }
}
- A pre-cancelled caller never reaches the network. Then one linked budget is created for this call: the caller's token plus the operation deadline.
ResponseHeadersReadlets the client look at the status before buffering. From here on, only the budget token bounds the wait.- A non-success status is classified without reading the body. A declared length above the limit is rejected before a single body byte is buffered.
- Chunked responses have no
Content-Length, so the loop counts the bytes actually read. In the lab a 16 KB chunked body is rejected at the 4 KB limit. - The strict options reject duplicate keys and deep nesting. The adapter maps names and kinds; the Part 3 validator checks sums, maxima and the score mean.
- A refusal keeps the other answers but marks the call
Refused.Clone()keeps the raw body valid after the document is disposed. - The order of the catch blocks is the policy. If both tokens are cancelled, the caller wins; only a live caller with an expired budget yields
Deadline.
The HttpClient comes from a factory that disables redirects and sets the timeout to infinite. With redirects off, a moved endpoint surfaces as an HttpFailure with its status instead of being followed silently; following a 302 would even turn the POST into a GET. Both Strict options are static: new() { AllowDuplicateProperties = false, MaxDepth = 16 }.
public static HttpClient CreateHttpClient() => new(new SocketsHttpHandler
{
AllowAutoRedirect = false, // a moved endpoint is a failure, not a hop
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
})
{ Timeout = Timeout.InfiniteTimeSpan }; // the client's budget is the only clock
Classify failures so the next decision is possible
An empty route is the worst possible failure report: the caller cannot tell a wrong credential from a refusal from a stalled proxy. The client returns one of ten outcomes, and the categories are deliberately coarse, because a status code alone does not diagnose a cause.
| Observation | Outcome | What the caller can do next |
|---|---|---|
| 401 (Part 6 maps a failed token acquisition here too) | Authentication | Repair the credential or its audience; do not retry blindly |
| 403 | Authorization | Check the identity, role and resource scope |
| 400, 404, 422 | Configuration | Check path, deployment name and question shape |
| 408, 429, 5xx | Temporary | A retry may help, under one owner's attempt budget |
| Any other status, including 302 | HttpFailure | Keep the status; nothing was answered |
| Budget expired, caller still waiting | Deadline | Route to the fallback; the assessment is unknown |
| Connection or stream broke | Transport | Assessment unknown; a retry may help |
| Body malformed, oversized or inconsistent | InvalidResponse | Fix the mapping or investigate the provider |
| OpenAI refused a question | Refused | Keep the other answers; do not automate this request |
| Caller cancelled | exception rethrown | Nothing: the caller has gone |
TypeSafe's schema distinguishes 400 (application validation, with a detail) from 422 (request validation, with a list of loc, msg and type). Microsoft's troubleshooting table for Decision-1 maps 404 to a wrong endpoint or path and 422 to an unsupported deployment name or question definition. Both point at configuration, which is why they share an outcome; keep the status and the bounded error detail for the operator, and never log the request's authorisation header.
The client itself does no retries, and in the lab every scenario issued exactly one request. A decision call is an assessment, so retrying it is usually harmless; the action it leads to may not be. If retries are added, one layer owns the attempt count and the total deadline, and an Authentication or InvalidResponse outcome is not retried at all.
Test the stream and the parser against a real socket
Mocking HttpMessageHandler tests the parser but not the stream: it cannot withhold a body, send chunks or break a connection halfway. The lab starts a TcpListener on an ephemeral loopback port, writes raw HTTP/1.1 responses per scenario, records every request, and stops in finally. Run on .NET 10, every scenario produced the expected outcome:
PASS s1-ok Ok: department=technical 0.93; enough_evidence=0.94; urgency=1.1
PASS oa-ok Ok: department=technical 0.93; enough_evidence=0.94; urgency=1.1
PASS same normalised decision
PASS raw kept after dispose response model jev-1.14.0, request model jev-latest
PASS oa-misordered InvalidResponse (answer 0 is named 'urgency', expected 'department')
PASS oa-refusal Refused: department=technical 0.93; enough_evidence=refused; urgency=1.1
PASS s1-refusal InvalidResponse (answer type 'refusal' is not valid for 'enough_evidence')
PASS oa-label InvalidResponse (level 1 carries label 'Continuous without workaround')
PASS s1-bad-mean InvalidResponse (urgency: score 0.5 is not the weighted mean 1.1)
PASS s1-duplicate InvalidResponse (Duplicate property 'noul' encountered during deserialization.)
PASS status-302 HttpFailure 302
PASS chunked-big InvalidResponse (body exceeds bound)
PASS truncated Transport (HttpIOException)
PASS stall-body Deadline (operation deadline expired) 300 ms
PASS caller cancels during body TaskCanceledException
PASS pre-cancelled caller OperationCanceledException, 0 requests sent
30 passed, 0 failed
The full run has 30 checks, including reordered System One answers, unnamed OpenAI answers, a boolean choice value, a legend mismatch, a missing confidence, malformed JSON, seven status classes, a declared oversized body and stalled headers. The response bodies are authored to the documented shapes; no hosted provider was called. That boundary matters: the lab proves that the transport and adapters behave as specified, not that a deployment exists, that a credential works, or that a model answers the routing question well.
Or use the OpenAI SDK, knowing what it changes
For OpenAI Decisions there is now an official option. Version 2.15.0 of the OpenAI NuGet package adds DecisionsClient, marked experimental (OPENAI001), with typed questions and answers. Pointed at the same loopback server, it parsed the same body:
#pragma warning disable OPENAI001
var client = new DecisionsClient(new ApiKeyCredential(key),
new DecisionsClientOptions { Endpoint = endpoint });
var options = new DecisionCreationOptions(
[new DecisionsUserMessageItem([new DecisionsInputTextContentPart(ticketText)])],
"gpt-6-luna",
[
new DecisionsChoiceQuestion(
[new DecisionsChoice(new DecisionsChoiceValue("technical")), /* ... */],
[new DecisionsInputTextInstructionsPart("Which team should investigate first?")])
{ Name = "department" },
new DecisionsPredicateQuestion(
[new DecisionsInputTextInstructionsPart("Are device, firmware and symptom all supplied?")])
{ Name = "enough_evidence" },
]);
DecisionResource decision = (await client.CreateDecisionAsync(options, ct)).Value;
foreach (DecisionsAnswer answer in decision.Answers)
{
switch (answer)
{
case DecisionsChoiceAnswer c: /* c.Choice.StringValue, c.Probabilities[i].Probability */ break;
case DecisionsPredicateAnswer p: /* p.Probability */ break;
case DecisionsRefusalAnswer r: /* r.Name */ break;
}
}
Three things changed, and the lab made each of them visible. The probabilities are float: the SDK's 0.93 widens to the double 0.9300000071525574, harmless under a 0.002 sum tolerance and fatal to an exact-equality test. The SDK sends input as message items and instructions as content parts, a richer form the API also accepts. And its pipeline retries: against a server returning 503, one CreateDecisionAsync call produced four requests before throwing ClientResultException. That is reasonable behaviour, but if your application also retries, the attempts multiply. Set the pipeline's retry policy and network timeout deliberately, and still map the result into the Part 3 records so the validator runs on SDK output too.
Worked example: a naive client and what it hides
Deserialize into a DTO and hope
var response = await http.PostAsJsonAsync(url, payload, ct);
var dto = await response.Content
.ReadFromJsonAsync<NaiveResponse>(ct); // DTO with double fields
var dept = dto!.Answers["department"];
if (dept.Confidence < 0.6) Review(ticket); // 0 when the field is missing
var evidence = dto.Answers["enough_evidence"].Noul; // last duplicate wins
In the lab, the missing-confidence body yielded a confidence of 0 and the duplicate-key body yielded 0.06 instead of 0.94. Neither raised anything. The status is never checked, a refusal-shaped answer becomes zeros, and the body is bounded only by the 2 GB default buffer limit.
Typed outcomes, then a policy
var result = await client.SendAsync(task, deployment, ticketText, ct);
switch (result.Outcome)
{
case Outcome.Ok:
Route(ticket, result.Answers); break;
case Outcome.Refused:
case Outcome.Deadline or Outcome.Transport or Outcome.Temporary:
HoldForOperator(ticket, result.Outcome); break;
default: // Authentication, Authorization, Configuration, InvalidResponse ...
Alert(result.Outcome, result.Status, result.Error);
HoldForOperator(ticket, result.Outcome); break;
}
The ticket always lands somewhere visible, and the reason travels with it. Configuration and parsing defects page a person instead of masquerading as low-confidence routing.
Pitfalls
- Reading
answers[0]as the first question you care about. OpenAI returns answers in question order, but match the name when it is present; System One has no order at all. - Treating Microsoft-Decision-1 as full System One. Same path family, a documented subset of kinds, and "similar" in Microsoft's own words.
- Two clocks.
HttpClient.Timeoutplus a token deadline gives two different failure shapes for one condition. Keep one owner. - Catching every
OperationCanceledExceptionas a provider failure. It turns users who left into outages and retries. - Trusting
Content-Lengthas the only bound. Chunked bodies have none; count what you read. - Renormalising a bad distribution. A sum of 0.7 means something is missing; rescaling hides which.
- Stacking retries. SDK pipeline retries times application retries times gateway retries is a retry storm with extra steps.
Custom client or SDK?
| Situation | Prefer | Watch |
|---|---|---|
| OpenAI Decisions only, standard deployment | DecisionsClient (OpenAI 2.15.0+) | Experimental API, float values, default retries; still map into your own records |
| System One or Microsoft-Decision-1 | A small adapter on HttpClient like the one here | Map keys, legend, no refusal kind |
| Several providers behind one routing policy | One contract, one adapter per protocol, one transport | Capability differences: refusal, boolean choices, image input |
| Strict latency budget per request | Your own linked budget, retries off in the pipeline | Credential acquisition inside the same budget (Part 6) |
Checklist
- Each protocol has its own adapter, and both produce the Part 3 answer records.
- Answers are matched by name (System One) or by position checked against the name (OpenAI).
- Missing fields, unknown kinds, legend or label mismatches and duplicate keys are invalid responses.
- One linked budget covers headers, body and parse;
HttpClient.Timeoutis infinite. - Body size is bounded by declared length and by bytes actually read.
- Caller cancellation is rethrown; an expired budget is
Deadline. - Exactly one layer owns retries, including the SDK pipeline's.
- Tests run against a real socket that can stall, chunk and break.
Series: Previous: Part 4, thresholds from the cost of each error. Next: Part 6, two Azure decision endpoints, one workflow.
Further reading
- OpenAI: create a decision (API reference): question and answer shapes, limits, refusals.
- TypeSafe: System One API and its OpenAPI schema: question kinds, answer union, error shapes.
- Microsoft Learn: Microsoft-Decision-1: the systemone path, supported kinds and troubleshooting statuses.
- HttpCompletionOption (.NET API): where
HttpClient.Timeoutstops withResponseHeadersRead. - AllowDuplicateProperties on JsonDocumentOptions (.NET 10): rejecting duplicate keys.
- Use a JSON DOM (.NET docs):
JsonDocumentlifetime andJsonElement.Clone. - openai-dotnet changelog:
DecisionsClientin 2.15.0.




