Native Decision API Clients in C#: Wire Shapes and Stalled Bodies

Build one C# client for System One and OpenAI Decisions that maps answers by meaning, parses strictly, owns one deadline past the headers and reports every failure as a distinct outcome.

Hand-drawn green and brass housing with an open socket, two different connector plugs routed into it, a pencil on a stand and a sheet of branching lines.

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 name optional. 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.ResponseHeadersRead ends HttpClient.Timeout at 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 OneOpenAI Decisions
Content fieldstate (string, object or array)input (string or message items)
QuestionsObject keyed by nameArray; name optional, 1 to 400 questions
Predicatenoul, answer field noulpredicate, answer field probability
Choice optionscriteria object, 1 to 255choices array of {value, description}, 2 to 255; values may be booleans
Score levelscriteria array, 1 to 10levels array of {label, description}, 2 to 10
AnswersObject keyed by question nameArray in question order
DistributionsObjects: {"technical": 0.93}, {"0": 0.2}, plus a score legendArrays: {value, probability}, {value, label, probability}
RefusalNo refusal variant in the published schemaPer 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?

Data flow

Two wire shapes map to one set of answer records, except where a protocol cannot say it

System One answers: { "name": ... } C# answer Part 3 contract OpenAI Decisions answers: [ in order ] "noul": 0.94 type noul, no confidence PredicateAnswer Probability 0.94 "probability": 0.94 type predicate, no confidence {"technical": 0.93, ...} probabilities keyed by name ChoiceAnswer technical, 0.93 {"value": "technical", ...} value may also be a boolean "legend": {"1": ...} legend must match the rubric ScoreAnswer Score 1.10, levels 0-2 {"value": 1, "label": ...} label must match level 1 no refusal variant so it is InvalidResponse RefusalAnswer Outcome.Refused {"type": "refusal"} other answers still arrive
  1. 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.
  2. A predicate is noul on one side and probability on the other. Neither carries a confidence field, so the adapter must not invent one.
  3. 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.
  4. 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.
  5. 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 urgency answer first, named urgency, is rejected rather than silently read as the department.
  • No defaults. A missing confidence is an invalid body. A naive DTO with double Confidence deserialises 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 legend that 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?

Sequence

The headers arrive in time, the body never does, and only our own deadline notices

  1. The caller passes its own token. The client links it with a 300 ms budget and sends the request through an HttpClient whose Timeout is infinite.
  2. Status and headers arrive quickly, so SendAsync completes. With ResponseHeadersRead, this is where HttpClient.Timeout would have stopped counting.
  3. The server sends 30 of the 400 promised bytes and stops. ReadAsync waits; nothing in the HTTP stack has a reason to give up.
  4. 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.
  5. 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

Code walkthrough

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); }
}
  1. 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.
  2. ResponseHeadersRead lets the client look at the status before buffering. From here on, only the budget token bounds the wait.
  3. 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.
  4. 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.
  5. 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.
  6. A refusal keeps the other answers but marks the call Refused. Clone() keeps the raw body valid after the document is disposed.
  7. 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.

ObservationOutcomeWhat the caller can do next
401 (Part 6 maps a failed token acquisition here too)AuthenticationRepair the credential or its audience; do not retry blindly
403AuthorizationCheck the identity, role and resource scope
400, 404, 422ConfigurationCheck path, deployment name and question shape
408, 429, 5xxTemporaryA retry may help, under one owner's attempt budget
Any other status, including 302HttpFailureKeep the status; nothing was answered
Budget expired, caller still waitingDeadlineRoute to the fallback; the assessment is unknown
Connection or stream brokeTransportAssessment unknown; a retry may help
Body malformed, oversized or inconsistentInvalidResponseFix the mapping or investigate the provider
OpenAI refused a questionRefusedKeep the other answers; do not automate this request
Caller cancelledexception rethrownNothing: 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.Timeout plus a token deadline gives two different failure shapes for one condition. Keep one owner.
  • Catching every OperationCanceledException as a provider failure. It turns users who left into outages and retries.
  • Trusting Content-Length as 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?

SituationPreferWatch
OpenAI Decisions only, standard deploymentDecisionsClient (OpenAI 2.15.0+)Experimental API, float values, default retries; still map into your own records
System One or Microsoft-Decision-1A small adapter on HttpClient like the one hereMap keys, legend, no refusal kind
Several providers behind one routing policyOne contract, one adapter per protocol, one transportCapability differences: refusal, boolean choices, image input
Strict latency budget per requestYour own linked budget, retries off in the pipelineCredential 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.Timeout is 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

Written by

Marcin Borecki

Software architect and entrepreneur, Microsoft Regional Director since 2014, with CloudExpert since 2011. I help teams design, measure and ship .NET, Azure, data and AI systems that hold up in production.

Facing this problem in your own system?

I run focused investigations, architecture reviews and hands-on workshops for engineering teams.

See how I can help