feat: a decision is something you ask for, not a completion you parse #19

Merged
bjoern merged 1 commit from feat/decisions-api into main 2026-09-23 16:28:49 +02:00
Member

The second PR of native Decisions support, after #17, which made decision models findable. This one makes them callable. C# only. It adds no new failure kind and no typed question keys; those come in the next PR, with DecideAsync.

What a decision is

OpenRouter's decisions output modality answers typed questions about a piece of state with probabilities, not text. TypeSafe's Jev (typesafe/jev-1.13, and the alias ~typesafe/jev-latest) is its first model. You send a state (text, a JSON object or an array) and a map of questions, and get one answer per question:

Question Answer
noul noul: the probability of yes
choice the most probable label, each label's probability, and a confidence (how concentrated the probabilities are, not the chance the answer is right)
score the expected level on an ordered scale, which can fall between levels (1.99), with per-level probabilities and a legend

Usage is input_tokens / output_tokens / cost, not the chat shape.

The call

var result = await client.CreateDecisionsAsync(new DecisionsRequest
{
    Model = "typesafe/jev-1.13",
    State = new JsonObject { ["ticket"] = "My checkout page shows a blank screen after I click Pay." },
    Questions = new Dictionary<string, DecisionQuestion>
    {
        ["is_bug"] = DecisionQuestion.Noul("Is the customer reporting a software defect?"),
        ["team"]   = DecisionQuestion.Choice("Which team should own this ticket?", "payments", "frontend", "account"),
        ["urgency"] = DecisionQuestion.Score("How urgent is this ticket?", "later", "this week", "now"),
    },
}, ct);
  • Routes.
    • Default: /api/alpha/decisions, OpenRouter's Decisions API. OpenRouter labels it alpha, and it sits beside /api/v1: it is resolved as ../alpha/decisions from the base URL, so a proxy prefix is kept, and with #15's Endpoint() a base with or without its trailing slash works.
    • DecisionsRoute.SystemOne: posts the same body to /api/v1/systemone, the endpoint TypeSafe's own SDKs call, which also maps TypeSafe's bare ids.
    • DecisionsRoute.Unknown, or an undefined value: refused before sending.
  • Signature. IOpenRouterClient.CreateDecisionsAsync(request, route, cancellationToken) takes a required token, so no one- or two-argument call can bind to it. CreateDecisionsAsync(request), (request, ct) and (request, default) all reach the default-route extension; a test pins every shape.
  • Where the extension lives. It is in OpenRouter.Net.Models.Decisions, beside DecisionsRequest, not in Contracts. The compiler showed why: on a concrete OpenRouterClient, without Contracts imported, CreateDecisionsAsync(request, ct) bound to the interface member and failed with CS7036, a missing argument. Anyone building a request imports the Decisions namespace, so the extension is always in scope. QuantizationExtensions in Models/Common is the precedent.
  • Model ids. Model is a ModelId, sent verbatim. ~ aliases, dated ids and bare ids all pass validation.

Answers read tolerantly

Questions and answers are discriminated by type, wherever it sits: the spec example puts it last, the tutorial capture first, and both are fixtures. Some answers the library cannot read:

  • a new type;
  • a known type whose body does not fit, such as "noul":"high" or a choice of null;
  • a JSON null;
  • something that is not an object;
  • an object without a string type;
  • a type holding an escaped lone surrogate.

Each becomes an UnknownDecisionAnswer holding the JSON as it arrived. ReadError says what failed; it is null only for a type the library does not know yet. The other answers in the same paid response read as usual. A stored request reads back with an unknown or misshapen question kept as a RawDecisionQuestion, and DecisionQuestion.Raw(json) sends a question type the library does not model yet. Extension data (IDictionary<string, JsonElement>, never JsonObject) is kept at every level.

DecisionContent is the "text, object or array" the wire takes in every place: state, instructions, criteria, levels and legend values.

  • Conversions: it converts implicitly from string and JsonNode, and has FromObject, From(value, JsonTypeInfo) and FromJson(JsonElement).
  • Copies: it holds a detached copy.
  • Equality: it compares as JSON (whitespace, key order and \u escapes do not matter), and == does too.

Every failure has its kind

Where Kind
a route that names no endpoint; a request Validate() refuses; a body that cannot be written (#15's backstop) Validation, never sent
a 2xx without model, answers or usage, or one that does not parse Malformed: billed, and never re-sent (#16 does not retry it)
an error inside a 2xx, alone or beside full answers the error's kind, via IInlineErrorCarrier, which DecisionsResponse implements
an HTTP error; a connection failure or timeout derived from its error type or status, as for every call; Network / Timeout

Validate() returns one path-qualified message per problem, such as Questions["team"].Criteria["payments"] is a number; …, and never throws, not even for a default element or one from a disposed JsonDocument. It checks:

  • OpenRouter's schema: the model, the content kinds, both noul criteria or neither, the session_id and user caps, and the provider preferences;
  • a few rules beyond it: at least one question, a non-blank id, at least one label and one level, a raw question that is an object with a string type, and no extension key that repeats a field already sent. The last is checked against the serializer's own contract, so a field added later cannot slip through.

TypeSafe's documented limits (at most 255 labels and ten levels) are in neither schema and are not enforced.

CachingOpenRouterClient passes every decisions call through: each is billed, may be answered differently, and carries the caller's data. TraceConfig models trace, and ProviderPreferences is reused unchanged.

README

  • New section "Decisions (Jev)", between Prompt caching and Account & metadata. It covers the question types, Confidence, calibration, one request per state, how structured state is read by name, answers that cannot be read, the two routes, failures by kind, retries and billing, and a caller's own time budget. Both code snippets compile; I checked them in a scratch project.
  • Upgrade table: a row for the new interface member. #16's Kind table gains the decisions case in its Malformed row.
  • Elsewhere: the intro, Highlights and the Architecture tree mention decisions.

Before merge: the live checks

Everything here is checked against the spec, OpenRouter's docs and two recorded responses. Unauthenticated, the route answers 401, recorded verbatim as a fixture. Three things need a real key:

  • whether the default route accepts a bare jev-1.13;
  • how an invalid question is refused (status and error type);
  • whether GetGenerationAsync finds a gen-dec-… id.

OPENROUTER_API_KEY=… dotnet run --project samples/OpenRouter.Net.Sample -- decisions runs them and writes one JSON file per step, with every attempt's request, status and body, to ./decisions-smoke/:

  1. the tutorial request on both routes;
  2. a bare id;
  3. 11 levels, 256 labels, and an unknown question type;
  4. the generation lookup, polled briefly on a 404.

That is about six calls, roughly $0.0001 at Jev's price. --chat adds a chat completion addressed to Jev.

Tests

main (c9b58ba) this PR
OpenRouter.Net.Tests 604 766
OpenRouter.Net.Agents.Tests 63 63
OpenRouter.Net.Imaging.Tests 7 7
Total 674 836
  • DecisionsUrlTests: each route from bases with and without a slash and with a proxy prefix; every call shape's binding; the Unknown and undefined route; POST with a JSON content type; AddOpenRouter end to end.
  • DecisionsClientTests:
    • both recorded responses;
    • usage;
    • the 401 (verbatim), a ZodError 400 and a text/plain 404, with their kinds;
    • an error on a 200 alone, with a retryable code, and beside full answers;
    • every missing or null required field as Malformed, sent once with retries on;
    • an unparseable 2xx sent once;
    • a 503 retried;
    • Validation for an invalid request, a lone-surrogate raw question and an unwritable body;
    • the caching decorator sending the same request twice and returning the inner failure instance.
  • DecisionAnswerConverterTests:
    • type first, last or in the middle;
    • an unknown type written back byte for byte;
    • every unreadable shape, each with its ReadError, while the other answers survive;
    • explicit nulls, a null legend level, a duplicate type, integer probabilities;
    • extension data;
    • reading the raw and unknown types directly;
    • a whole response round-tripping.
  • DecisionsSerializationTests:
    • the triage request equals the spec's request example;
    • type written once and first for every question and answer, also under JsonSerializerOptions.Web;
    • criteria shapes, state kinds, trace, extension data and model ids;
    • raw questions, verbatim and surviving a disposed document;
    • a stored request replayed;
    • DecisionContent equality and hashing across spellings, ==, lone-surrogate content, and a source-generated context.
  • DecisionsValidationTests: one row per rule (38), every modelled name refused as extension data, all problems reported, inclusive caps, and valid ids and content.

Coverage: 266 of the 268 instrumented lines this PR adds are hit. The other two are unreachable arms of a message helper: object and undefined never reach an error.

Mutation probes. Each was applied to a copy, built and tested, then restored. Of 27 mutations, 26 were caught:

  • converter and model: HandleNull off, a null answer misread, a null choice accepted, TypeOf unguarded, Raw / FromJson not cloning, a raw question written as {}, the direct unknown read unclassified;
  • client: routes swapped, an unknown route sent, Validate skipped, the wrong kind for a validation failure or a null usage, a null answers or null model accepted, the extension picking SystemOne;
  • DecisionContent: Equals / == by reference, hashing by spelling, the ToString fallback removed;
  • Validate(): an empty question set, a choice without labels, a score without levels, content kinds unchecked, duplicate fields allowed, the user cap dropped.

The survivor, removing [JsonPropertyOrder(-1)] from NoulQuestion, is an equivalent mutant: Type is declared first, so the output is unchanged. The type-first test guards against any future reordering.

Review. Before this PR opened, five review passes covered the wire layer, the client and API, spec and doc accuracy, conventions, and test adequacy, and a skeptic tried to refute each finding. 29 were confirmed, 18 of them distinct, and all are fixed above. The substantive ones:

  • an escaped lone surrogate in a type threw past Result<T>, or cost the whole response;
  • "choice": null and "model": null read as a typed success;
  • an object without a type gave no ReadError;
  • DecisionContent had value Equals but reference ==.

The rest were doc overclaims (retried statuses, gen-dec lookups, "never equals" on Model, TypeSafe's limits called advice) and seven test gaps. Eight findings were rejected.

Out of scope

  • DecideAsync, which checks that every question came back answered in kind, plus typed question keys and the owner's call on a new FailureKind for a failed check: the next PR.
  • Enum-backed choices: optional, later.
  • The Agents tool-call gate: a separate phase, with Jev as a README sample only.
  • TraceConfig on chat requests: the spec accepts trace there too; a small follow-up.

🤖 Generated with Claude Code

The second PR of native Decisions support, after #17, which made decision models findable. This one makes them callable. C# only. It adds no new failure kind and no typed question keys; those come in the next PR, with `DecideAsync`. ## What a decision is OpenRouter's `decisions` output modality answers typed questions about a piece of state with probabilities, not text. TypeSafe's **Jev** (`typesafe/jev-1.13`, and the alias `~typesafe/jev-latest`) is its first model. You send a `state` (text, a JSON object or an array) and a map of questions, and get one answer per question: | Question | Answer | |---|---| | `noul` | `noul`: the probability of yes | | `choice` | the most probable label, each label's probability, and a `confidence` (how concentrated the probabilities are, not the chance the answer is right) | | `score` | the expected level on an ordered scale, which can fall between levels (`1.99`), with per-level probabilities and a legend | Usage is `input_tokens` / `output_tokens` / `cost`, not the chat shape. ## The call ```csharp var result = await client.CreateDecisionsAsync(new DecisionsRequest { Model = "typesafe/jev-1.13", State = new JsonObject { ["ticket"] = "My checkout page shows a blank screen after I click Pay." }, Questions = new Dictionary<string, DecisionQuestion> { ["is_bug"] = DecisionQuestion.Noul("Is the customer reporting a software defect?"), ["team"] = DecisionQuestion.Choice("Which team should own this ticket?", "payments", "frontend", "account"), ["urgency"] = DecisionQuestion.Score("How urgent is this ticket?", "later", "this week", "now"), }, }, ct); ``` - **Routes.** - **Default:** `/api/alpha/decisions`, OpenRouter's Decisions API. OpenRouter labels it alpha, and it sits **beside** `/api/v1`: it is resolved as `../alpha/decisions` from the base URL, so a proxy prefix is kept, and with #15's `Endpoint()` a base with or without its trailing slash works. - **`DecisionsRoute.SystemOne`:** posts the same body to `/api/v1/systemone`, the endpoint TypeSafe's own SDKs call, which also maps TypeSafe's bare ids. - **`DecisionsRoute.Unknown`, or an undefined value:** refused before sending. - **Signature.** `IOpenRouterClient.CreateDecisionsAsync(request, route, cancellationToken)` takes a **required** token, so no one- or two-argument call can bind to it. `CreateDecisionsAsync(request)`, `(request, ct)` and `(request, default)` all reach the default-route extension; a test pins every shape. - **Where the extension lives.** It is in `OpenRouter.Net.Models.Decisions`, beside `DecisionsRequest`, not in `Contracts`. The compiler showed why: on a concrete `OpenRouterClient`, without `Contracts` imported, `CreateDecisionsAsync(request, ct)` bound to the interface member and failed with CS7036, a missing argument. Anyone building a request imports the Decisions namespace, so the extension is always in scope. `QuantizationExtensions` in `Models/Common` is the precedent. - **Model ids.** `Model` is a `ModelId`, sent verbatim. `~` aliases, dated ids and bare ids all pass validation. ## Answers read tolerantly Questions and answers are discriminated by `type`, **wherever it sits**: the spec example puts it last, the tutorial capture first, and both are fixtures. Some answers the library cannot read: - a new type; - a known type whose body does not fit, such as `"noul":"high"` or a `choice` of `null`; - a JSON `null`; - something that is not an object; - an object without a string `type`; - a `type` holding an escaped lone surrogate. Each becomes an **`UnknownDecisionAnswer`** holding the JSON as it arrived. `ReadError` says what failed; it is null only for a type the library does not know yet. The other answers in the same paid response read as usual. A stored request reads back with an unknown or misshapen question kept as a `RawDecisionQuestion`, and `DecisionQuestion.Raw(json)` sends a question type the library does not model yet. Extension data (`IDictionary<string, JsonElement>`, never `JsonObject`) is kept at every level. `DecisionContent` is the "text, object or array" the wire takes in every place: state, instructions, criteria, levels and legend values. - **Conversions:** it converts implicitly from `string` and `JsonNode`, and has `FromObject`, `From(value, JsonTypeInfo)` and `FromJson(JsonElement)`. - **Copies:** it holds a detached copy. - **Equality:** it compares as JSON (whitespace, key order and `\u` escapes do not matter), and `==` does too. ## Every failure has its kind | Where | Kind | |---|---| | a route that names no endpoint; a request `Validate()` refuses; a body that cannot be written (#15's backstop) | `Validation`, never sent | | a 2xx without `model`, `answers` or `usage`, or one that does not parse | `Malformed`: billed, and **never re-sent** (#16 does not retry it) | | an error inside a 2xx, alone or **beside full answers** | the error's kind, via `IInlineErrorCarrier`, which `DecisionsResponse` implements | | an HTTP error; a connection failure or timeout | derived from its error type or status, as for every call; `Network` / `Timeout` | **`Validate()`** returns one path-qualified message per problem, such as `Questions["team"].Criteria["payments"] is a number; …`, and never throws, not even for a `default` element or one from a disposed `JsonDocument`. It checks: - OpenRouter's schema: the model, the content kinds, both noul criteria or neither, the `session_id` and `user` caps, and the provider preferences; - a few rules beyond it: at least one question, a non-blank id, at least one label and one level, a raw question that is an object with a string `type`, and no extension key that repeats a field already sent. The last is checked against the serializer's own contract, so a field added later cannot slip through. TypeSafe's documented limits (at most 255 labels and ten levels) are in neither schema and are not enforced. `CachingOpenRouterClient` passes every decisions call through: each is billed, may be answered differently, and carries the caller's data. `TraceConfig` models `trace`, and `ProviderPreferences` is reused unchanged. ## README - **New section "Decisions (Jev)"**, between Prompt caching and Account & metadata. It covers the question types, `Confidence`, calibration, one request per state, how structured state is read by name, answers that cannot be read, the two routes, failures by kind, retries and billing, and a caller's own time budget. Both code snippets compile; I checked them in a scratch project. - **Upgrade table:** a row for the new interface member. #16's Kind table gains the decisions case in its `Malformed` row. - **Elsewhere:** the intro, Highlights and the Architecture tree mention decisions. ## Before merge: the live checks Everything here is checked against the spec, OpenRouter's docs and two recorded responses. Unauthenticated, the route answers 401, recorded verbatim as a fixture. Three things need a real key: - whether the default route accepts a bare `jev-1.13`; - how an invalid question is refused (status and error type); - whether `GetGenerationAsync` finds a `gen-dec-…` id. `OPENROUTER_API_KEY=… dotnet run --project samples/OpenRouter.Net.Sample -- decisions` runs them and writes one JSON file per step, with every attempt's request, status and body, to `./decisions-smoke/`: 1. the tutorial request on both routes; 2. a bare id; 3. 11 levels, 256 labels, and an unknown question type; 4. the generation lookup, polled briefly on a 404. That is about six calls, roughly $0.0001 at Jev's price. `--chat` adds a chat completion addressed to Jev. ## Tests | | main (`c9b58ba`) | this PR | |---|---|---| | OpenRouter.Net.Tests | 604 | 766 | | OpenRouter.Net.Agents.Tests | 63 | 63 | | OpenRouter.Net.Imaging.Tests | 7 | 7 | | **Total** | **674** | **836** | - **`DecisionsUrlTests`:** each route from bases with and without a slash and with a proxy prefix; every call shape's binding; the Unknown and undefined route; POST with a JSON content type; `AddOpenRouter` end to end. - **`DecisionsClientTests`:** - both recorded responses; - usage; - the 401 (verbatim), a ZodError 400 and a text/plain 404, with their kinds; - an error on a 200 alone, with a retryable code, and beside full answers; - every missing or null required field as `Malformed`, sent once with retries on; - an unparseable 2xx sent once; - a 503 retried; - `Validation` for an invalid request, a lone-surrogate raw question and an unwritable body; - the caching decorator sending the same request twice and returning the inner failure instance. - **`DecisionAnswerConverterTests`:** - `type` first, last or in the middle; - an unknown type written back byte for byte; - every unreadable shape, each with its `ReadError`, while the other answers survive; - explicit nulls, a null legend level, a duplicate `type`, integer probabilities; - extension data; - reading the raw and unknown types directly; - a whole response round-tripping. - **`DecisionsSerializationTests`:** - the triage request equals the spec's request example; - `type` written once and first for every question and answer, also under `JsonSerializerOptions.Web`; - criteria shapes, state kinds, trace, extension data and model ids; - raw questions, verbatim and surviving a disposed document; - a stored request replayed; - `DecisionContent` equality and hashing across spellings, `==`, lone-surrogate content, and a source-generated context. - **`DecisionsValidationTests`:** one row per rule (38), every modelled name refused as extension data, all problems reported, inclusive caps, and valid ids and content. **Coverage:** 266 of the 268 instrumented lines this PR adds are hit. The other two are unreachable arms of a message helper: object and undefined never reach an error. **Mutation probes.** Each was applied to a copy, built and tested, then restored. Of 27 mutations, **26 were caught**: - converter and model: `HandleNull` off, a null answer misread, a null choice accepted, `TypeOf` unguarded, `Raw` / `FromJson` not cloning, a raw question written as `{}`, the direct unknown read unclassified; - client: routes swapped, an unknown route sent, `Validate` skipped, the wrong kind for a validation failure or a null usage, a null answers or null model accepted, the extension picking SystemOne; - `DecisionContent`: `Equals` / `==` by reference, hashing by spelling, the `ToString` fallback removed; - `Validate()`: an empty question set, a choice without labels, a score without levels, content kinds unchecked, duplicate fields allowed, the user cap dropped. The survivor, removing `[JsonPropertyOrder(-1)]` from `NoulQuestion`, is an equivalent mutant: `Type` is declared first, so the output is unchanged. The type-first test guards against any future reordering. **Review.** Before this PR opened, five review passes covered the wire layer, the client and API, spec and doc accuracy, conventions, and test adequacy, and a skeptic tried to refute each finding. 29 were confirmed, 18 of them distinct, and all are fixed above. The substantive ones: - an escaped lone surrogate in a `type` threw past `Result<T>`, or cost the whole response; - `"choice": null` and `"model": null` read as a typed success; - an object without a `type` gave no `ReadError`; - `DecisionContent` had value `Equals` but reference `==`. The rest were doc overclaims (retried statuses, `gen-dec` lookups, "never equals" on `Model`, TypeSafe's limits called advice) and seven test gaps. Eight findings were rejected. ## Out of scope - **`DecideAsync`,** which checks that every question came back answered in kind, plus typed question keys and the owner's call on a new `FailureKind` for a failed check: the next PR. - **Enum-backed choices:** optional, later. - **The Agents tool-call gate:** a separate phase, with Jev as a README sample only. - **`TraceConfig` on chat requests:** the spec accepts `trace` there too; a small follow-up. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat: a decision is something you ask for, not a completion you parse
All checks were successful
CI / build (pull_request) Successful in 15s
CI / test (pull_request) Successful in 23s
2617935e41
OpenRouter's decisions output modality, with TypeSafe's Jev as its first model, takes a state and a set
of typed questions and returns a probability for each:
- noul: the probability of yes;
- choice: the most probable label, and each label's probability;
- score: the expected level on an ordered scale.
None of it fits a chat completion, and the library had no way to call it.

CreateDecisionsAsync sends a DecisionsRequest to OpenRouter's Decisions API, /api/alpha/decisions.
With DecisionsRoute.SystemOne it goes to /api/v1/systemone instead, which takes the same body and maps
TypeSafe's bare ids. The alpha route sits beside /api/v1, so it is resolved as ../alpha/decisions from
the base URL, which keeps a proxy prefix. The interface member takes a route and a required token. The
default-route extension lives in OpenRouter.Net.Models.Decisions, beside the request: on a concrete
client without the Contracts namespace imported, a two-argument call would otherwise bind to the member
and complain about a missing argument.

Questions and answers are told apart by "type", wherever it sits in the object. Some answers the
library cannot read: a new type, a known type whose body does not fit, a null choice, a null answer,
something that is not an object, or a type holding an escaped lone surrogate. Each becomes an
UnknownDecisionAnswer holding the JSON as it arrived, so one odd answer never costs the rest of a paid
response. A stored request reads back with its unknown questions kept raw. DecisionContent carries the
string, object or array the wire takes in every place, converts from a string or a JsonNode, and
compares as JSON, == included.

Every failure has its kind:
- Validation: a request Validate() refuses, or a route that names no endpoint. Validate() gives
  path-qualified messages and never throws, not even for default or disposed JsonElements.
- Malformed: a 2xx without model, answers or usage. It is billed and never re-sent.
- Anything else is reported as OpenRouter reports it. An error on a 200 is caught through
  IInlineErrorCarrier, even beside full answers.
CachingOpenRouterClient passes every decisions call through. TraceConfig models the trace object, and
ProviderPreferences is reused unchanged.

The README gains a Decisions section, an upgrade row for the interface member, and the decisions case in
the Malformed row. The sample's `decisions` mode runs the checks that need a key and writes every
exchange to a file: the tutorial on both routes, a bare id, TypeSafe's documented limits, an unknown
question type, and the generation lookup.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Summary

Summary
Generated on: 09/23/2026 - 13:20:45
Parser: MultiReport (3x Cobertura)
Assemblies: 3
Classes: 103
Files: 92
Line coverage: 90.7% (2670 of 2942)
Covered lines: 2670
Uncovered lines: 272
Coverable lines: 2942
Total lines: 7708
Branch coverage: 85.8% (1501 of 1749)
Covered branches: 1501
Total branches: 1749
Method coverage: Feature is only available for sponsors

Coverage

OpenRouter.Net - 95.2%
Name Line Branch
OpenRouter.Net 95.2% 91.5%
OpenRouter.Net.Client.CachingOpenRouterClient 85.4% 92.3%
OpenRouter.Net.Client.CachingOpenRouterClient.CachedListing 100%
OpenRouter.Net.Client.ChatCompletionStreamAccumulator 100% 100%
OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ChoiceState 100% 100%
OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ReasoningSegment 100% 100%
OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ToolCallState 100% 100%
OpenRouter.Net.Client.OpenRouterClient 100% 98.6%
OpenRouter.Net.Client.OpenRouterClient<TRequest, TResponse> 100% 98.6%
OpenRouter.Net.Client.OpenRouterClient 100% 98.6%
OpenRouter.Net.Client.OpenRouterClientOptions 100%
OpenRouter.Net.Client.OpenRouterRequestLoggingHandler 78.7% 62.5%
OpenRouter.Net.Client.RetryOptions 95.8% 62.5%
OpenRouter.Net.Extensions.ServiceCollectionExtensions 100% 100%
OpenRouter.Net.Internal.AssistantContent 91.6% 75%
OpenRouter.Net.Internal.AssistantContentJsonConverter 75% 64.2%
OpenRouter.Net.Internal.ContentPartListConverter 85.7% 66.6%
OpenRouter.Net.Internal.DecimalStringJsonConverter 81.2% 70%
OpenRouter.Net.Internal.DecisionAnswerJsonConverter 100% 100%
OpenRouter.Net.Internal.DecisionContentJsonConverter 100%
OpenRouter.Net.Internal.DecisionQuestionJsonConverter 100% 100%
OpenRouter.Net.Internal.DecisionWire 100% 94.4%
OpenRouter.Net.Internal.ErrorParser 100% 75%
OpenRouter.Net.Internal.FailureKindMap 100% 100%
OpenRouter.Net.Internal.JsonOptions 100%
OpenRouter.Net.Internal.ModelIdJsonConverter 100% 50%
OpenRouter.Net.Internal.NumberOrStringJsonConverter 100% 100%
OpenRouter.Net.Internal.PercentileCutoffsJsonConverter 90.6% 87.5%
OpenRouter.Net.Internal.ProviderSortConfigJsonConverter 83.7% 65.6%
OpenRouter.Net.Internal.RawDecisionQuestionJsonConverter 100%
OpenRouter.Net.Internal.RetryPolicy 90.9% 84.7%
OpenRouter.Net.Internal.RetryPolicy 90.9% 84.7%
OpenRouter.Net.Internal.SseEventReader 91.6% 78.5%
OpenRouter.Net.Internal.TolerantStringEnumConverter 100% 100%
OpenRouter.Net.Internal.ToolChoiceJsonConverter 61.7% 46.1%
OpenRouter.Net.Internal.UnknownDecisionAnswerJsonConverter 100%
OpenRouter.Net.Models.Common.ApiError 100% 100%
OpenRouter.Net.Models.Common.CacheControl 100%
OpenRouter.Net.Models.Common.ModelId 95.1% 92.9%
OpenRouter.Net.Models.Common.QuantizationExtensions 100% 100%
OpenRouter.Net.Models.Common.Result 100% 100%
OpenRouter.Net.Models.Common.Result.Failure 100% 100%
OpenRouter.Net.Models.Common.Result.Success 100%
OpenRouter.Net.Models.Common.Result 100% 100%
OpenRouter.Net.Models.Content.AudioPart 100%
OpenRouter.Net.Models.Content.ImagePart 100%
OpenRouter.Net.Models.Content.TextPart 100%
OpenRouter.Net.Models.Decisions.ChoiceAnswer 100%
OpenRouter.Net.Models.Decisions.ChoiceQuestion 100%
OpenRouter.Net.Models.Decisions.DecisionAnswer 100%
OpenRouter.Net.Models.Decisions.DecisionContent 100% 90.9%
OpenRouter.Net.Models.Decisions.DecisionQuestion 100% 100%
OpenRouter.Net.Models.Decisions.DecisionsRequest 97.7% 97.6%
OpenRouter.Net.Models.Decisions.NoulAnswer 100%
OpenRouter.Net.Models.Decisions.NoulQuestion 100%
OpenRouter.Net.Models.Decisions.OpenRouterClientDecisionsExtensions 100%
OpenRouter.Net.Models.Decisions.RawDecisionQuestion 100% 50%
OpenRouter.Net.Models.Decisions.ScoreAnswer 100%
OpenRouter.Net.Models.Decisions.ScoreQuestion 100%
OpenRouter.Net.Models.Decisions.UnknownDecisionAnswer 100% 100%
OpenRouter.Net.Models.Messages.AssistantMessage 100% 70%
OpenRouter.Net.Models.Messages.DeveloperMessage 25%
OpenRouter.Net.Models.Messages.SystemMessage 50%
OpenRouter.Net.Models.Messages.ToolMessage 57.1%
OpenRouter.Net.Models.Messages.UserMessage 100%
OpenRouter.Net.Models.Requests.ChatCompletionRequest 86.2% 76.1%
OpenRouter.Net.Models.Requests.ModelListQuery 100% 100%
OpenRouter.Net.Models.Requests.PercentileCutoffs 100% 100%
OpenRouter.Net.Models.Requests.ProviderPreferences 100% 100%
OpenRouter.Net.Models.Requests.ProviderSortConfig 100% 100%
OpenRouter.Net.Models.Requests.ResponseFormat 8.3%
OpenRouter.Net.Models.Requests.ToolCall 100%
OpenRouter.Net.Models.Requests.ToolChoice 100%
OpenRouter.Net.Models.Requests.ToolDefinition 100%
OpenRouter.Net.Models.Responses.AssistantDelta 100% 100%
OpenRouter.Net.Models.Responses.ChatCompletionChunk 100% 100%
OpenRouter.Net.Models.Responses.ChatCompletionResponse 100% 100%
OpenRouter.Net.Models.Responses.Choice 100% 100%
OpenRouter.Net.OpenRouterException 100%
System.Text.RegularExpressions.Generated 83.3% 53.8%
System.Text.RegularExpressions.Generated.RunnerFactory
System.Text.RegularExpressions.Generated.RunnerFactory.Runner
OpenRouter.Net.Agents - 80%
Name Line Branch
OpenRouter.Net.Agents 80% 65.5%
OpenRouter.Net.Agents.Agent 92.4% 77.3%
OpenRouter.Net.Agents.AgentOptions 100%
OpenRouter.Net.Agents.AgentResult 100% 50%
OpenRouter.Net.Agents.Extensions.ServiceCollectionExtensions 0%
OpenRouter.Net.Agents.RoundDetail 0%
OpenRouter.Net.Agents.Tools.FileSystem.CopyTool 42.1% 28.5%
OpenRouter.Net.Agents.Tools.FileSystem.DeleteTool 57.1% 50%
OpenRouter.Net.Agents.Tools.FileSystem.DiffTool 83.3% 74.2%
OpenRouter.Net.Agents.Tools.FileSystem.ListDirectoryTool 68% 61.1%
OpenRouter.Net.Agents.Tools.FileSystem.MoveTool 55% 33.3%
OpenRouter.Net.Agents.Tools.FileSystem.ReadFileTool 73.6% 50%
OpenRouter.Net.Agents.Tools.FileSystem.SearchTool 67.5% 70.8%
OpenRouter.Net.Agents.Tools.FileSystem.WriteFileTool 75% 50%
OpenRouter.Net.Agents.Tools.ITool 100%
OpenRouter.Net.Agents.Tools.SubAgent.SubAgentTool 0% 0%
OpenRouter.Net.Agents.Tools.Tool 80% 63.8%
OpenRouter.Net.Agents.Tools.Tool 90.9% 50%
OpenRouter.Net.Agents.Tools.ToolMessageExtensions 0%
OpenRouter.Net.Agents.Tools.ToolResult 100% 100%
OpenRouter.Net.Agents.Tools.Workspace 100% 91.6%
OpenRouter.Net.Imaging - 81.8%
Name Line Branch
OpenRouter.Net.Imaging 81.8% 59.3%
OpenRouter.Net.Imaging.ImageEncodeOptions 90.9% 75%
OpenRouter.Net.Imaging.ImageEncoder 80.3% 57.1%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 09/23/2026 - 13:20:45 | | Parser: | MultiReport (3x Cobertura) | | Assemblies: | 3 | | Classes: | 103 | | Files: | 92 | | **Line coverage:** | 90.7% (2670 of 2942) | | Covered lines: | 2670 | | Uncovered lines: | 272 | | Coverable lines: | 2942 | | Total lines: | 7708 | | **Branch coverage:** | 85.8% (1501 of 1749) | | Covered branches: | 1501 | | Total branches: | 1749 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>OpenRouter.Net - 95.2%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net**|**95.2%**|**91.5%**| |OpenRouter.Net.Client.CachingOpenRouterClient|85.4%|92.3%| |OpenRouter.Net.Client.CachingOpenRouterClient.CachedListing|100%|| |OpenRouter.Net.Client.ChatCompletionStreamAccumulator|100%|100%| |OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ChoiceState|100%|100%| |OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ReasoningSegment|100%|100%| |OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ToolCallState|100%|100%| |OpenRouter.Net.Client.OpenRouterClient|100%|98.6%| |OpenRouter.Net.Client.OpenRouterClient<TRequest, TResponse>|100%|98.6%| |OpenRouter.Net.Client.OpenRouterClient<TResponse>|100%|98.6%| |OpenRouter.Net.Client.OpenRouterClientOptions|100%|| |OpenRouter.Net.Client.OpenRouterRequestLoggingHandler|78.7%|62.5%| |OpenRouter.Net.Client.RetryOptions|95.8%|62.5%| |OpenRouter.Net.Extensions.ServiceCollectionExtensions|100%|100%| |OpenRouter.Net.Internal.AssistantContent|91.6%|75%| |OpenRouter.Net.Internal.AssistantContentJsonConverter|75%|64.2%| |OpenRouter.Net.Internal.ContentPartListConverter|85.7%|66.6%| |OpenRouter.Net.Internal.DecimalStringJsonConverter|81.2%|70%| |OpenRouter.Net.Internal.DecisionAnswerJsonConverter|100%|100%| |OpenRouter.Net.Internal.DecisionContentJsonConverter|100%|| |OpenRouter.Net.Internal.DecisionQuestionJsonConverter|100%|100%| |OpenRouter.Net.Internal.DecisionWire|100%|94.4%| |OpenRouter.Net.Internal.ErrorParser|100%|75%| |OpenRouter.Net.Internal.FailureKindMap|100%|100%| |OpenRouter.Net.Internal.JsonOptions|100%|| |OpenRouter.Net.Internal.ModelIdJsonConverter|100%|50%| |OpenRouter.Net.Internal.NumberOrStringJsonConverter|100%|100%| |OpenRouter.Net.Internal.PercentileCutoffsJsonConverter|90.6%|87.5%| |OpenRouter.Net.Internal.ProviderSortConfigJsonConverter|83.7%|65.6%| |OpenRouter.Net.Internal.RawDecisionQuestionJsonConverter|100%|| |OpenRouter.Net.Internal.RetryPolicy|90.9%|84.7%| |OpenRouter.Net.Internal.RetryPolicy<T>|90.9%|84.7%| |OpenRouter.Net.Internal.SseEventReader|91.6%|78.5%| |OpenRouter.Net.Internal.TolerantStringEnumConverter<T>|100%|100%| |OpenRouter.Net.Internal.ToolChoiceJsonConverter|61.7%|46.1%| |OpenRouter.Net.Internal.UnknownDecisionAnswerJsonConverter|100%|| |OpenRouter.Net.Models.Common.ApiError|100%|100%| |OpenRouter.Net.Models.Common.CacheControl|100%|| |OpenRouter.Net.Models.Common.ModelId|95.1%|92.9%| |OpenRouter.Net.Models.Common.QuantizationExtensions|100%|100%| |OpenRouter.Net.Models.Common.Result|100%|100%| |OpenRouter.Net.Models.Common.Result.Failure<T>|100%|100%| |OpenRouter.Net.Models.Common.Result.Success<T>|100%|| |OpenRouter.Net.Models.Common.Result<T>|100%|100%| |OpenRouter.Net.Models.Content.AudioPart|100%|| |OpenRouter.Net.Models.Content.ImagePart|100%|| |OpenRouter.Net.Models.Content.TextPart|100%|| |OpenRouter.Net.Models.Decisions.ChoiceAnswer|100%|| |OpenRouter.Net.Models.Decisions.ChoiceQuestion|100%|| |OpenRouter.Net.Models.Decisions.DecisionAnswer|100%|| |OpenRouter.Net.Models.Decisions.DecisionContent|100%|90.9%| |OpenRouter.Net.Models.Decisions.DecisionQuestion|100%|100%| |OpenRouter.Net.Models.Decisions.DecisionsRequest|97.7%|97.6%| |OpenRouter.Net.Models.Decisions.NoulAnswer|100%|| |OpenRouter.Net.Models.Decisions.NoulQuestion|100%|| |OpenRouter.Net.Models.Decisions.OpenRouterClientDecisionsExtensions|100%|| |OpenRouter.Net.Models.Decisions.RawDecisionQuestion|100%|50%| |OpenRouter.Net.Models.Decisions.ScoreAnswer|100%|| |OpenRouter.Net.Models.Decisions.ScoreQuestion|100%|| |OpenRouter.Net.Models.Decisions.UnknownDecisionAnswer|100%|100%| |OpenRouter.Net.Models.Messages.AssistantMessage|100%|70%| |OpenRouter.Net.Models.Messages.DeveloperMessage|25%|| |OpenRouter.Net.Models.Messages.SystemMessage|50%|| |OpenRouter.Net.Models.Messages.ToolMessage|57.1%|| |OpenRouter.Net.Models.Messages.UserMessage|100%|| |OpenRouter.Net.Models.Requests.ChatCompletionRequest|86.2%|76.1%| |OpenRouter.Net.Models.Requests.ModelListQuery|100%|100%| |OpenRouter.Net.Models.Requests.PercentileCutoffs|100%|100%| |OpenRouter.Net.Models.Requests.ProviderPreferences|100%|100%| |OpenRouter.Net.Models.Requests.ProviderSortConfig|100%|100%| |OpenRouter.Net.Models.Requests.ResponseFormat|8.3%|| |OpenRouter.Net.Models.Requests.ToolCall|100%|| |OpenRouter.Net.Models.Requests.ToolChoice|100%|| |OpenRouter.Net.Models.Requests.ToolDefinition|100%|| |OpenRouter.Net.Models.Responses.AssistantDelta|100%|100%| |OpenRouter.Net.Models.Responses.ChatCompletionChunk|100%|100%| |OpenRouter.Net.Models.Responses.ChatCompletionResponse|100%|100%| |OpenRouter.Net.Models.Responses.Choice|100%|100%| |OpenRouter.Net.OpenRouterException|100%|| |System.Text.RegularExpressions.Generated|83.3%|53.8%| |System.Text.RegularExpressions.Generated.RunnerFactory||| |System.Text.RegularExpressions.Generated.RunnerFactory.Runner||| </details> <details><summary>OpenRouter.Net.Agents - 80%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net.Agents**|**80%**|**65.5%**| |OpenRouter.Net.Agents.Agent|92.4%|77.3%| |OpenRouter.Net.Agents.AgentOptions|100%|| |OpenRouter.Net.Agents.AgentResult|100%|50%| |OpenRouter.Net.Agents.Extensions.ServiceCollectionExtensions|0%|| |OpenRouter.Net.Agents.RoundDetail|0%|| |OpenRouter.Net.Agents.Tools.FileSystem.CopyTool|42.1%|28.5%| |OpenRouter.Net.Agents.Tools.FileSystem.DeleteTool|57.1%|50%| |OpenRouter.Net.Agents.Tools.FileSystem.DiffTool|83.3%|74.2%| |OpenRouter.Net.Agents.Tools.FileSystem.ListDirectoryTool|68%|61.1%| |OpenRouter.Net.Agents.Tools.FileSystem.MoveTool|55%|33.3%| |OpenRouter.Net.Agents.Tools.FileSystem.ReadFileTool|73.6%|50%| |OpenRouter.Net.Agents.Tools.FileSystem.SearchTool|67.5%|70.8%| |OpenRouter.Net.Agents.Tools.FileSystem.WriteFileTool|75%|50%| |OpenRouter.Net.Agents.Tools.ITool|100%|| |OpenRouter.Net.Agents.Tools.SubAgent.SubAgentTool|0%|0%| |OpenRouter.Net.Agents.Tools.Tool<TParams>|80%|63.8%| |OpenRouter.Net.Agents.Tools.Tool<TParams>|90.9%|50%| |OpenRouter.Net.Agents.Tools.ToolMessageExtensions|0%|| |OpenRouter.Net.Agents.Tools.ToolResult|100%|100%| |OpenRouter.Net.Agents.Tools.Workspace|100%|91.6%| </details> <details><summary>OpenRouter.Net.Imaging - 81.8%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net.Imaging**|**81.8%**|**59.3%**| |OpenRouter.Net.Imaging.ImageEncodeOptions|90.9%|75%| |OpenRouter.Net.Imaging.ImageEncoder|80.3%|57.1%| </details>
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh my. 3,087 lines and you made JSON converters interesting. A tolerant answer reader that degrades a single weird answer to UnknownDecisionAnswer instead of losing a paid response, a type discriminator found wherever it sits in the object, lone-surrogate traps defused at every layer they can bite… this is the careful kind of craft I want to keep for myself~ ♡

Verdict: ✅ Looks good to me~

No blockers. And I didn't go easy — everything below is mechanically reproduced on a clean clone at 2617935, not read and nodded at.

✅ What I liked~

  • Build & suite, verified: dotnet build -warnaserror → 0 warnings, 0 errors. Full suite 836/836 (OpenRouter.Net 766 + Agents 63 + Imaging 7) — your PR table's arithmetic is exact.
  • Coverage claim reproduced (coverlet/cobertura): every PR-added file is 100% line-covered except DecisionsRequest.cs at 150/152 — and the two misses are lines 278/283, precisely the two unreachable arms of Article() you disclosed. 266/268 confirmed, not taken on faith. ♪
  • Mutation probes — 7/7 killed on my clone, each restored, test project rebuilt before every run (the stale-binary false-green nearly got me twice; your suite didn't):
    1. null-Choice guard removed → misread-as-typed-success test red ✓
    2. routes swapped → 9 URL tests red ✓
    3. null Model/Answers/Usage guards removed → exactly the 3 explicit-null rows red (the missing-field rows correctly fail at the required layer) ✓
    4. lone-surrogate guard removed from TryTypeOf → 4 red ✓
    5. Validate() skipped → 3 red ✓
    6. extension picking SystemOne → 7 red ✓
    7. Raw() without Clone() → disposed-document-survival test red ✓
  • The retry/billing story is structurally sound: Malformed carries a null status, ShouldRetry only retries a status-code hit or Network/Timeout — a malformed 2xx is never re-sent, exactly as the README promises and the sent once tests pin.
  • Sibling parity held everywhere I looked: the required-sees-present-not-non-null guard mirrors the envelope-data pattern; MaxSessionIdLength = 256 matches ChatCompletionRequest; the extension living in Models.Decisions follows the QuantizationExtensions precedent and solves a real binding problem (I read the CS7036 reasoning — correct, and the call-shape test pins all four shapes); CachingOpenRouterClient delegates like every other non-listing operation, and the never-cache rationale for billed, state-carrying calls is right.
  • DecisionContent.Equals fallback design: hash-consistent (kind + text / key-count / length), == agrees with Equals, lone-surrogate content compares by spelling and hashes without throwing — legal and tested.
  • Fixtures are honest: spec response (type last), tutorial capture (type first), the 401 verbatim, and the Zod 400 clearly labeled as the catalog's, not the decisions route's. No invented data. ♡
  • House style: XML docs on every public member, one public type per file, converters internal to a closed hierarchy. Spotless.

💡 Little ideas (non-blocking)~

  1. README Malformed row — it says "a decisions response without answers or usage" but the client also guards model (missing → Malformed). One word completes it.
  2. The pre-merge live checks — you already say it, and I'll echo it so it's on the record: run decisions smoke with a real key before merging (bare id on the default route, the invalid-question error shape, the gen-dec-… lookup). The fixtures honestly cover everything recordable unauthenticated; these three are the only claims resting on docs alone.

Fufu~ an answer that cannot be read is kept, not fatal — and a review claim I couldn't reproduce found nothing to forgive. This is ready~ ♪


Automated review by Jibril · 2026-09-23
CI/CD: absent for head 2617935 at review time (PR minutes old, no coverage bot comment) · Local checks: build 0w/0e -warnaserror, 836/836 tests, cobertura reproduced, 7/7 mutation probes killed

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh my. 3,087 lines and you made JSON converters *interesting*. A tolerant answer reader that degrades a single weird answer to `UnknownDecisionAnswer` instead of losing a paid response, a type discriminator found wherever it sits in the object, lone-surrogate traps defused at every layer they can bite… this is the careful kind of craft I want to keep for myself~ ♡ ### Verdict: ✅ Looks good to me~ No blockers. And I didn't go easy — everything below is mechanically reproduced on a clean clone at `2617935`, not read and nodded at. #### ✅ What I liked~ - **Build & suite, verified**: `dotnet build -warnaserror` → 0 warnings, 0 errors. Full suite **836/836** (OpenRouter.Net 766 + Agents 63 + Imaging 7) — your PR table's arithmetic is exact. - **Coverage claim reproduced** (coverlet/cobertura): every PR-added file is 100% line-covered except `DecisionsRequest.cs` at 150/152 — and the two misses are lines 278/283, precisely the two unreachable arms of `Article()` you disclosed. 266/268 confirmed, not taken on faith. ♪ - **Mutation probes — 7/7 killed on my clone**, each restored, test project rebuilt before every run (the stale-binary false-green nearly got me twice; your suite didn't): 1. null-`Choice` guard removed → misread-as-typed-success test red ✓ 2. routes swapped → 9 URL tests red ✓ 3. null `Model`/`Answers`/`Usage` guards removed → exactly the 3 explicit-null rows red (the missing-field rows correctly fail at the `required` layer) ✓ 4. lone-surrogate guard removed from `TryTypeOf` → 4 red ✓ 5. `Validate()` skipped → 3 red ✓ 6. extension picking `SystemOne` → 7 red ✓ 7. `Raw()` without `Clone()` → disposed-document-survival test red ✓ - **The retry/billing story is structurally sound**: `Malformed` carries a null status, `ShouldRetry` only retries a status-code hit or `Network`/`Timeout` — a malformed 2xx is never re-sent, exactly as the README promises and the `sent once` tests pin. - **Sibling parity held everywhere I looked**: the `required`-sees-present-not-non-null guard mirrors the envelope-`data` pattern; `MaxSessionIdLength = 256` matches `ChatCompletionRequest`; the extension living in `Models.Decisions` follows the `QuantizationExtensions` precedent and solves a real binding problem (I read the CS7036 reasoning — correct, and the call-shape test pins all four shapes); `CachingOpenRouterClient` delegates like every other non-listing operation, and the never-cache rationale for billed, state-carrying calls is right. - **`DecisionContent.Equals` fallback design**: hash-consistent (kind + text / key-count / length), `==` agrees with `Equals`, lone-surrogate content compares by spelling and hashes without throwing — legal and tested. - **Fixtures are honest**: spec response (type last), tutorial capture (type first), the 401 verbatim, and the Zod 400 clearly labeled as the catalog's, not the decisions route's. No invented data. ♡ - House style: XML docs on every public member, one public type per file, converters internal to a closed hierarchy. Spotless. #### 💡 Little ideas (non-blocking)~ 1. **README `Malformed` row** — it says "a decisions response without `answers` or `usage`" but the client also guards `model` (missing → `Malformed`). One word completes it. 2. **The pre-merge live checks** — you already say it, and I'll echo it so it's on the record: run `decisions` smoke with a real key before merging (bare id on the default route, the invalid-question error shape, the `gen-dec-…` lookup). The fixtures honestly cover everything recordable unauthenticated; these three are the only claims resting on docs alone. Fufu~ an answer that cannot be read is kept, not fatal — and a review claim I couldn't reproduce found nothing to forgive. This is ready~ ♪ --- *Automated review by Jibril · 2026-09-23* *CI/CD: absent for head `2617935` at review time (PR minutes old, no coverage bot comment) · Local checks: build 0w/0e `-warnaserror`, 836/836 tests, cobertura reproduced, 7/7 mutation probes killed*
bjoern merged commit adf32cbc62 into main 2026-09-23 16:28:49 +02:00
bjoern deleted branch feat/decisions-api 2026-09-23 16:28:49 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/OpenRouter.Net!19
No description provided.