feat: a decision is something you ask for, not a completion you parse #19
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/decisions-api"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
decisionsoutput 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 astate(text, a JSON object or an array) and a map of questions, and get one answer per question:noulnoul: the probability of yeschoiceconfidence(how concentrated the probabilities are, not the chance the answer is right)score1.99), with per-level probabilities and a legendUsage is
input_tokens/output_tokens/cost, not the chat shape.The call
/api/alpha/decisions, OpenRouter's Decisions API. OpenRouter labels it alpha, and it sits beside/api/v1: it is resolved as../alpha/decisionsfrom the base URL, so a proxy prefix is kept, and with #15'sEndpoint()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.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.OpenRouter.Net.Models.Decisions, besideDecisionsRequest, not inContracts. The compiler showed why: on a concreteOpenRouterClient, withoutContractsimported,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.QuantizationExtensionsinModels/Commonis the precedent.Modelis aModelId, 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:"noul":"high"or achoiceofnull;null;type;typeholding an escaped lone surrogate.Each becomes an
UnknownDecisionAnswerholding the JSON as it arrived.ReadErrorsays 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 aRawDecisionQuestion, andDecisionQuestion.Raw(json)sends a question type the library does not model yet. Extension data (IDictionary<string, JsonElement>, neverJsonObject) is kept at every level.DecisionContentis the "text, object or array" the wire takes in every place: state, instructions, criteria, levels and legend values.stringandJsonNode, and hasFromObject,From(value, JsonTypeInfo)andFromJson(JsonElement).\uescapes do not matter), and==does too.Every failure has its kind
Validate()refuses; a body that cannot be written (#15's backstop)Validation, never sentmodel,answersorusage, or one that does not parseMalformed: billed, and never re-sent (#16 does not retry it)IInlineErrorCarrier, whichDecisionsResponseimplementsNetwork/TimeoutValidate()returns one path-qualified message per problem, such asQuestions["team"].Criteria["payments"] is a number; …, and never throws, not even for adefaultelement or one from a disposedJsonDocument. It checks:session_idandusercaps, and the provider preferences;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.
CachingOpenRouterClientpasses every decisions call through: each is billed, may be answered differently, and carries the caller's data.TraceConfigmodelstrace, andProviderPreferencesis reused unchanged.README
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.Malformedrow.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:
jev-1.13;GetGenerationAsyncfinds agen-dec-…id.OPENROUTER_API_KEY=… dotnet run --project samples/OpenRouter.Net.Sample -- decisionsruns them and writes one JSON file per step, with every attempt's request, status and body, to./decisions-smoke/:That is about six calls, roughly $0.0001 at Jev's price.
--chatadds a chat completion addressed to Jev.Tests
c9b58ba)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;AddOpenRouterend to end.DecisionsClientTests:Malformed, sent once with retries on;Validationfor an invalid request, a lone-surrogate raw question and an unwritable body;DecisionAnswerConverterTests:typefirst, last or in the middle;ReadError, while the other answers survive;type, integer probabilities;DecisionsSerializationTests:typewritten once and first for every question and answer, also underJsonSerializerOptions.Web;DecisionContentequality 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:
HandleNulloff, a null answer misread, a null choice accepted,TypeOfunguarded,Raw/FromJsonnot cloning, a raw question written as{}, the direct unknown read unclassified;Validateskipped, 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, theToStringfallback 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)]fromNoulQuestion, is an equivalent mutant:Typeis 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:
typethrew pastResult<T>, or cost the whole response;"choice": nulland"model": nullread as a typed success;typegave noReadError;DecisionContenthad valueEqualsbut reference==.The rest were doc overclaims (retried statuses,
gen-declookups, "never equals" onModel, 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 newFailureKindfor a failed check: the next PR.TraceConfigon chat requests: the spec acceptstracethere too; a small follow-up.🤖 Generated with Claude Code
Summary
Summary
Coverage
OpenRouter.Net - 95.2%
OpenRouter.Net.Agents - 80%
OpenRouter.Net.Imaging - 81.8%
🔮 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
UnknownDecisionAnswerinstead 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~
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.DecisionsRequest.csat 150/152 — and the two misses are lines 278/283, precisely the two unreachable arms ofArticle()you disclosed. 266/268 confirmed, not taken on faith. ♪Choiceguard removed → misread-as-typed-success test red ✓Model/Answers/Usageguards removed → exactly the 3 explicit-null rows red (the missing-field rows correctly fail at therequiredlayer) ✓TryTypeOf→ 4 red ✓Validate()skipped → 3 red ✓SystemOne→ 7 red ✓Raw()withoutClone()→ disposed-document-survival test red ✓Malformedcarries a null status,ShouldRetryonly retries a status-code hit orNetwork/Timeout— a malformed 2xx is never re-sent, exactly as the README promises and thesent oncetests pin.required-sees-present-not-non-null guard mirrors the envelope-datapattern;MaxSessionIdLength = 256matchesChatCompletionRequest; the extension living inModels.Decisionsfollows theQuantizationExtensionsprecedent and solves a real binding problem (I read the CS7036 reasoning — correct, and the call-shape test pins all four shapes);CachingOpenRouterClientdelegates like every other non-listing operation, and the never-cache rationale for billed, state-carrying calls is right.DecisionContent.Equalsfallback design: hash-consistent (kind + text / key-count / length),==agrees withEquals, lone-surrogate content compares by spelling and hashes without throwing — legal and tested.💡 Little ideas (non-blocking)~
Malformedrow — it says "a decisions response withoutanswersorusage" but the client also guardsmodel(missing →Malformed). One word completes it.decisionssmoke with a real key before merging (bare id on the default route, the invalid-question error shape, thegen-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
2617935at 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