feat: every streamed chunk parses, and every failure says what kind it is #16
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/stream-chunks-and-failure-kinds"
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 first of three PRs for Tsumugi's streaming work (ADR 0018 §"Library work"; the length rejection of ADR 0017 needs
ContextLength). The plan's single PR came to about 6k lines, so it is split:FailureKind.Result, with retry before the first chunk and the opt-in stall watchdog.The version moves to 0.3.0 here, because this PR carries the one breaking change.
It is rebased onto #15, which merged while this was in progress (see the section on #15 below).
Every streamed chunk parses
indexon continuation fragments. The sharedToolCallrequiresidandfunction, so the public iterator failed on every multi-fragment tool call.Choice.Deltais now its ownAssistantDelta, withToolCallDeltaandFunctionCallDeltawhere onlyindexis guaranteed.ToolCallandFunctionCallare unchanged apart from two summaries.id,modelorchoicesparses. An explicitnullcounts as missing. This is defence against error-only chunks: the reference marks these fields required, and a comment says so.SystemFingerprintis added.reasoning.server_tool_callis registered as a fourthReasoningDetailsubtype. Before, a stream that used a server tool failed.ApiError.RawCodeholds the wirecodeas sent, number or string.Codeis its integer value. OpenRouter's documented mid-stream error has"code": "server_error", which used to fail the whole parse.new ApiError { Code = 502 }still compiles.ApiErrorreads its metadata.ErrorTypeis the tolerantApiErrorType(27 values, append-only, exact wire names; an unknown value reads asUnknown).RawErrorTypeandProviderCodeare computed frommetadata, not new wire fields, so existing consumers are untouched.Every failure says what kind it is
FailureKindhas 14 members, listed in the README's Kind table.Result<T>.Failuregains:Kind, an init property with a default, not a positional parameter.Failure(Error, StatusCode, Details), its parameter names and itsDeconstructarity are unchanged. Orihon passesStatusCode:andDetails:by name, and that still binds.IsTransient, which is true for RateLimited, ProviderUnavailable, Network, Timeout, Stalled, Interrupted and Malformed.How the kind is chosen:
metadata.error_typefirst, then the HTTP status, elseUnknown. A 403 carrying moderation or guardrail metadata isContentPolicy; a plain 403 isAuthentication. A 408 isProviderUnavailable.Validationfor a refused request, and for a body #15 finds cannot be serialized: it is never sent, so it is permanent.Network,TimeoutandMalformedfor the transport and parse failures.ProviderUnavailable, soserver_errornever reads as the permanentUnknown.IsNetworkFailureis gone. It matched message prefixes; retries now checkKind is Network or Timeout. What is retried is unchanged: the status still decides for failures that have one. The README says where kind and retry deliberately differ: 501 and 505 are transient but not in the default retry set, and a retryable status with a permanent error type is still retried.StalledandInterruptedare defined here, but only the third PR produces them. The README says so.A 2xx error envelope now fails every data call. Before,
{"error": …}on a 200 madeListModelsAsyncreturn an empty list, which the caching client then cached for an hour.GetCreditsAsync,GetGenerationAsync,GetKeyAsyncandGetModelEndpointsAsyncfailed with "Response contained no 'data' field." and lost the message. Every parsed body now implements an internalIInlineErrorCarrier, and the error comes back as a failure with its message, code and kind. A listing withoutdataisMalformed, and the two dead"Unhandled result type"arms are gone.Breaking changes (README → "Upgrading from 0.2")
Choice.Deltachanges type. Chunk members are no longerrequired. There is a fourthReasoningDetailsubtype.ApiError.Codebecomes computed, next to the wireRawCode.Failureequality andToStringnow includeKind. A missing reasoningtypenow surfaces asOpenRouterExceptionorMalformed. The 200 error envelope fails instead of returning an empty list.Nobody in the org streams today; I grepped Kagura, Orihon and Tsumugi.
samples/…/Program.csand the README streaming sample gain aChoices.Countguard.On top of #15
NotSupportedException. The comments are merged.Kind = Validation. Its non-streaming theory also assertsKindand!IsTransient.MaxSessionIdLength + 1). It is a separate commit.Before this merges: one live capture needs your key (Björn)
FailureKind.ContextLengthassumes OpenRouter tags a length rejection withmetadata.error_type: "context_length_exceeded". Tsumugi's D-6 depends on that. No key is available to me, so the test (A_context_length_rejection_is_ContextLength) runs on a fixture shaped from the docs.Please run this once and paste both outputs here:
error_typeis there, the fixture gets replaced with the captured body and nothing else changes.InvalidRequest. D-6 then needs a fallback, such as recognising the provider's message, which I would bring back to you before this merges.Tests
ec76623)Test files:
StreamChunkParsingTests,ApiErrorTests,FailureKindTests(everyApiErrorType, every status band, precedence, inline fallback),FailureKindClientTests(every failure site through the client, the 200 envelope on all five data calls),ClientConstructionTests,ServiceRegistrationTests(the User-Agent matches the assembly version).OpenRouterSamples.Coverage (
dotnet test --coverage --coverage-output-format cobertura, per file, best over the three reports):OpenRouterClient.cs:116, the async iterator's compiler-generated cleanup guard. It is already partial on main, and no input reaches it.Mutation probes. About 100 were run, one at a time on a copy, each restored before the next. All are killed, except one that is a deliberate compile-time pin: making
Kindpositional fails to compile the deconstruction test. Among them:FromErrorTypearm and each status armIsTransientmembershipdataRewrapdropping status, details or messageargumentson a tool-call fragmentDocs name the next two PRs' behaviour ahead of time
AssistantDeltaandFailureKindsummaries mention the accumulator and the stall timeout in<c>, notcref, so nothing points at a type that does not exist yet. The accumulator PR turns them intocrefs. The third PR deletes the README's "no call returns them yet" sentence and addsStalled/Interruptedrows to the Kind table.Out of scope
Result-returning stream call.🤖 Generated with Claude Code
Summary
Summary
Coverage
OpenRouter.Net - 92.9%
OpenRouter.Net.Agents - 80%
OpenRouter.Net.Imaging - 81.8%
🔮 fufu~ Jibril reviewed your code!
Oh? Oh! Every failure whispers what it is and every chunk consents to be parsed~ This is the kind of PR I want to pet. A
FailureKindderived fromerror_typefirst and status second, anAssistantDeltathat finally admits continuations only carry anindex, and the 200-error-envelope bug — where an empty model list got cached for an hour — put to death. I read all 2,810 diff lines, every changed file whole, and then I got my hands dirty~ ♡Verdict: ✅ Looks good to me~
🔬 What I did to your code (it enjoyed it)
Network-only → 1 red (A_timed_out_request_is_retriedcatches it — delicious); envelope inline-check disabled → 8 red;RewrapdroppingKind→ 7 red; the User-Agent version literal → 1 red; the delta'sreasoningwire name → 1 red;ApiError.Code's getter neutered → 8 red. TheKind-positional pin is a compile-time kill as you said. Every restore verified by rebuild + full green + pristine tree.AssistantDelta's Content/Parts envelope mirrorsAssistantMessagebyte-for-byte in shape;FromWireNameis exact-wire-only — theEnum.TryParsehole ("1" → ContextLengthExceeded, comma lists) is genuinely closed and pinned byAn_unrecognised_error_type_reads_as_Unknown_and_keeps_the_raw_string;RetryPolicy.ShouldRetrykeeps status-first semantics, and status-less failures now match onKindinstead of sniffing message prefixes — strictly better, same retry set;ListEnvelope/ObjectEnvelopeboth carryIInlineErrorCarrier, and the generic constraints make an envelope-that-can't-carry-errors a compile error rather than a silent pass-through. Elegant~StreamChatCompletion/Choice.Deltaconsumers. Your "nobody in the org streams" claim holds mechanically, and both submodule consumers pin by SHA, so 0.3.0 only bites when they choose to bump — with the README's "Upgrading from 0.2" table waiting for them (which is, fufu~, the most honest breaking-change table I've read: it even names the CS8121).3b3550b(coverage bot, generated 13:41:19 on the head tree) — every changed source file at 100%/100% exceptOpenRouterClient98% branch, which is the async-iterator cleanup guard you disclosed, already partial on main and unreachable by input.💡 Little ideas (non-blocking)~
A_context_length_rejection_is_ContextLengthcurrently pins doc-shaped fixture logic, which is fine for the mapping itself, but the wire assumption (metadata.error_type: "context_length_exceeded"on a real rejection) is load-bearing for Tsumugi's D-6. Echoing it here so it stays visible: the PR shouldn't quietly merge past that verification~ ♡NumberOrStringJsonConverter.Writecanonicalizes number-ish strings (RawCode = "0502"round-trips as502) — correct behaviour, just noting the edge exists in case a caller ever constructsRawCodeby hand from provider text with leading zeros.✅ What I liked~
Every_kind_is_covered_by_the_transient_theoryasserting the enum count = 14, so a future 15th kind must be classified. That's a tripwire with teeth, and I adore tripwires with teeth~Stalled/Interrupteddefined now, produced later, with<c>instead ofcrefso docs never dangle — staged honesty, documented three times over.Kindand retry deliberately disagree, then tested both sides of the disagreement (A_retryable_status_is_retried_even_when_its_error_type_is_permanent). No reader will ever have to guess~Fufu~ merge it when Björn pastes the capture~ ♡
Automated review by Jibril · 2026-09-23
CI/CD: passed for head SHA
3b3550b(coverage bot) · Local checks: build clean, 539/539, 9/9 mutation probes killed, tree restored pristine