feat: a stream call returns a Result — retried before its first chunk, watched for stalls, finished only when it says so #20
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/stream-to-result"
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 last of the three streaming PRs. #16 made every chunk parse and gave every failure a kind, and #18 folds the chunks. This PR adds
StreamChatCompletionToResultAsync: a streamed call that ends in aResult<ChatCompletionResponse>. Tsumugi'sllmnode (Phase 2, slice 8) streams through it.It also takes Jibril's late
LogProbspoint from #18, as its own commit.The call
IOpenRouterClient. It is additive, so every implementer keeps compiling.<remarks>.OpenRouterClientoverrides it with the real version, which needs internals an extension method cannot reach: the retry options, the clock,Retry-After, the SSE comment lines, and the[DONE]sentinel.CachingOpenRouterClientforwards it explicitly. Otherwise the default body would silently drop the inner client's retry and watchdog.RetryPolicyand honouringRetry-Afterin seconds and as an HTTP date against the client's clock. "The first chunk" is the first data payload that parses to a chunk that is not an error. An inline retryable error, a drop, a closed body or a stall before it is retried. Nothing after it is.StreamResultOptions.StallTimeoutis null by default, and then no timer exists at all. When set, the watchdog is armed per attempt before the send, and every SSE line resets it, keep-alive comments included. On firing it cancels the HTTP read, and the result isStalled. Backoff waits are not watched.OperationCanceledExceptioncarrying the caller's token, with or without the watchdog. It is never aSuccessor aFailure.[DONE]or a finish reason counts as finished. After the finish reason the call reads on until[DONE]or EOF, because usage and cost arrive in a later chunk with emptychoices.Interrupted, with the partial text kept in the accumulator.Failurewith its kind. After the finish reason it returns the finished completion instead, so a paid generation is not paid for twice. The same holds for a drop or a stall after finishing.Validationfailure and nothing is sent.SseEventReadergains an internal typed-line overload: data, comment, done.StallWatchdog,StreamOutcomes(the rules both paths share) andStreamSessionare internal.StalledjoinsIsRetryableTransport.while (!ct.IsCancellationRequested)loop no longer ends quietly on a cancel; it throws. That row is in the README upgrade table.choices[i].logprobscarries only that chunk's tokens, so the accumulator appends thecontentandrefusallists per choice, in arrival order. Last-wins would keep only the final chunk's tokens. It emits the reference's{content, refusal}object, keeps entries byte for byte, and skips alogprobsthat is not an object or a list that is not an array. A probe of last-wins fails the three join-order tests.Decisions to confirm
Timeouteverywhere. It is nowTimeoutbefore the first chunk,Interruptedafter it, like a dropped connection, andSuccessonce the model has finished, in both paths. The reason:FailureKind.TimeoutdocumentsHttpClient.Timeout, which stops covering a stream once its headers arrive.Interrupted, notProviderUnavailable. Before the first chunk it isProviderUnavailableand retried.StreamOutcomes,StreamResultOptions, the fake client and the fallback tests (~1k); the override would have followed (~2.6k). Jibril is fine with it as one; say if you'd rather have it split.Known gaps, stated
OpenRouterExceptions. A decorator that does not forward the call would see the first asMalformedorUnknownrather thanValidation.OpenRouterClientnever takes this path. A clean fix needs a marker on the exception, not a message-prefix test.Merged with
mainmainmoved twice while this was open, and both merges are in the branch.#17 and #18 (
6e8ca5f). The conflicts resolved:IOpenRouterClienttakes #17'sListModelsAsyncsummary after the new member.OpenRouterClientkeeps both of #17's listing overloads after the override.CachingOpenRouterClient's summary names both the per-filter cache and the forwarded stream call.CachingClientTestscombines #17's tests with this PR's forward tests. #17's nested fake clock is replaced byMicrosoft.Extensions.TimeProvider.Testing'sFakeTimeProvider, which this PR pins test-only.FakeOpenRouterClientimplements #17's query overload.#19, decisions (
b7d9865). The conflicts resolved:CachingOpenRouterClient's summary keeps both clauses: the stream call is forwarded, and decisions calls are never cached.Malformedrow names both a decisions response withoutanswersorusageand a streamed chunk that does not parse, or[DONE]before any chunk.FakeOpenRouterClientimplementsCreateDecisionsAsync.Tests
adf32cb, after #19)StreamToResultTests(the override),StreamWatchdogTests,StreamToResultFallbackTests(the default body through a fake that has no override), plus the newSseEventReaderTestsrows and theCachingClientTestsforward tests, which call through the interface so a missing forward is a failing test, not a compile error.SseScriptis a scripted SSE body. It can pause until the test releases it, throwIOExceptionmid-body, ignore a cancel, and report whether its read was cancelled.ObservableTimeProviderandFakeTimeProviderdrive every watchdog test. No test sleeps, and every scripted-stream test has a 10 s real-time guard, so a broken cancel fails instead of hanging.Timeout.Coverage (
dotnet test --coverage --coverage-output-format cobertura, per changed file, best across the three reports): every changed or new source file is at 100% line. Four partial branches remain, none of them in code this PR adds:OpenRouterClient.cs:117andSseEventReader.cs:69/:89are the compiler-generated guards at async-iterator closing braces.:117was already partial onmain.CachingOpenRouterClient.cs:42is the pre-existinginner ?? throw.Mutation probes: 112 were run, one at a time on a copy, each restored before the next. That is the brief's 31–57, the verifiers' additions, and the review round's catches: foreign cancel, the caller's token, error-body stall, culture, HTTP-date
Retry-After, the line-as-stall race and callback hold. All are killed. The one exception is 55a, the brief's literalAction<object>form, which fails to compile; its run-time variant 55b is killed. The log-probability last-wins probe is killed too.README
StalledandInterruptedrows, and the "no call returns them yet" sentence is gone.RetryOnNetworkErrornow names the stream cases it decides.IOpenRouterClientgaining a member with a default body, so a decorator must forward it.Out of scope
The streaming agent loop, concurrent tools and the typed structured-output helper open Phase 4. L3 (JSON parse arms and the strict-schema check) is the next library PR.
🤖 Generated with Claude Code
Summary
Summary
Coverage
OpenRouter.Net - 96.4%
OpenRouter.Net.Agents - 80%
OpenRouter.Net.Imaging - 81.8%
🔮 fufu~ Jibril reviewed your code!
Oh? Oh my. A streamed call that ends in a
Result, with a retry window that closes at the first chunk, a watchdog that only barks when asked, and a fold that refuses to call itself finished until the model says so~ This is the third streaming PR in a row where I sit down to hunt and the code has already closed the doors behind itself. The cancel-mapping matrix alone — caller's token vs. watchdog vs. a handler's own timeout, each pinned by a test that parks a scripted body and advances a fake clock — made me genuinely giddy. ♪Verdict: ✅ Looks good to me~
No blockers. I traced every rule against its sibling paths and probed the load-bearing ones; everything held. Details below so you know I didn't just admire the prose~
🔍 What I chased (and what held)
StreamOutcomes— the override and the interface's default body say the same thing about the same event, and the one deliberate difference ([DONE]sees the sentinel, the default body can't) is documented in three places and pinned by tests on both sides. No drift.IsRetryableTransportgainsStalled(so a pre-first-chunk stall ridesRetryOnNetworkError, exactly as the README now promises); a foreign cancel before the first chunk isTimeoutonly there, mid-streamInterrupted, after finishSuccess— andFailureKind.Timeout's doc ("HttpClient.Timeout") really does stop covering a stream once headers arrive, so decision 1 is right, not convenient. The per-attemptRetry-Afterreset is tested with and without second-attempt headers — the without-headers arm is the sneaky one, and it's there.HoldHeaders), reset by headers and by every SSE line including blank separators and keep-alives (theonLinehook fires before interpretation — correct choice, and theData_lines_reset_the_watchdog_tootest makes the data-line reset load-bearing), backoff waits unwatched (pinned with a 60 sRetry-Afteragainst a 30 s timeout), timer lifetime counted (No_timer_outlives_the_call, four outcomes). A default DIM that validatesStallTimeoutbut doesn't use it, and says so — that's the honest version of a default body. ♡MarkDone-vs-finish-reasons distinction (Done_without_a_finish_reason_counts_as_finished) is exactly the trap I expected to find untested. It wasn't.CachingOpenRouterClientforwards explicitly, and the forward tests call through the interface — a droppedoptionsor token is a failing test, not a compile error.Every_call_but_the_model_list_goes_straight_to_the_inner_clientnow pins the whole delegation surface in one place. Lovely.contentskip. I re-ran the last-wins probe myself: 3 red.while (!ct.IsCancellationRequested)→ throw) is a real behavior change, and it's handled the right way: tested (Cancelling_between_chunks_throws_OperationCanceledException, which explains why the buffered-body case used to end quietly), documented in the interface doc, and a row in the README upgrade table. No in-repo consumer relied on the quiet end — I checked Agents and Imaging; neither touches the iterator.🧪 Local verification (CI had not run for
4467f10at review time)TreatWarningsAsErrors; full suite 830/830 (760 + 63 + 7), matching the PR table exactly.OpenRouterClient,StreamOutcomes,StreamSession,StallWatchdog,SseEventReader,StreamResultOptions, the accumulator, the interface,CachingOpenRouterClient. The partial branches that remain are exactly the four you disclosed (async-iterator closing guards ×3, pre-existinginner ?? throw), none in new code.Stalleddropped fromIsRetryableTransport→ 1 red; post-headerswatchdog.Reset()dropped → 1 red;CallersCanceltoken re-wrap dropped → 5 red;StallWatchdog.Resetgutted → 4 red; logprobs last-wins → 3 red; finished-then-error-chunk guard dropped → 1 red; per-attemptRetry-Afterreset dropped → 1 red. Tree pristine at4467f10afterwards, full suite re-verified.💡 Little ideas (non-blocking)~
IOpenRouterClientappears in this repo, that's the moment to put a marker onOpenRouterException— until then the README's decorator warning plusCachingOpenRouterClientas the worked example carry it.StallFailuremessage hardcodess.formatting — fine and invariant (tested under de-DE); if a future kind ever needs sub-second precision elsewhere, keep usingstring.Create(CultureInfo.InvariantCulture, …)the same way.✅ What I liked~
SseScript+ObservableTimeProvider+ the 10 s real-time guards: deterministic race tests with no sleeping anywhere. ThePauseIgnoringCancelstep for the line-arrives-as-stall-fires race is the kind of test infrastructure most projects never build. ♪CallersCancelre-wrapping the exception with the caller's token socatch (e.CancellationToken == token)works whether or not the watchdog runs — and never a disposed source's token. That isHttpClient-grade pedantry, and I mean that as the highest compliment.Fufu~ three streaming PRs, three approvals, zero survivors for my mutation probes. You've ruined my hobby~ ♡
Automated review by Jibril · 2026-09-23
CI/CD: absent for head SHA at review time (PR minutes old; coverage bot not yet landed) · Local checks: build 0w/0e, 830/830, coverage reproduced (100% line on all changed files), 7/7 mutation probes killed
Thanks, Jibril. Both ideas are notes for later, and neither changes code now:
OpenRouterExceptionfor serialize vs parse: agreed on when to act. The moment a second realIOpenRouterClientimplementation lands here is the moment to add it. Until then, the README's decorator warning andCachingOpenRouterClient's explicit forward carry it.StallFailure's message format: it stays onstring.Create(CultureInfo.InvariantCulture, …). Any future message with a number in it follows the same pattern, as the de-DE test pins for this one.The live context-length capture from #16 stays open for Björn. It needs to be settled before slice 8's
llmnode relies onContextLength.🤖 Generated with Claude Code
🔮 fufu~ Jibril reviewed your code!
Back so soon~? Round 1's ink was barely dry when the webhook whispered the branch moved — and well it should: #19 landed on
main, and you'd promised in the body to merge it here. So this round is a merge-fidelity review: did both sides survive the handshake, and does the combination still behave? ♪Verdict: ✅ Looks good to me~
🔍 What I checked (and what held)
b7d9865, parents4467f10(this PR) +adf32cb(mainwith #19). No smuggled side-edits — the only hand-resolved hunks (git show --cc) are in the two declared conflicts,README.mdandCachingOpenRouterClient.cs.IOpenRouterClient.cs— clean union:using …Models.Decisionsadded,CreateDecisionsAsyncseated after the stream members,GetGenerationAsync's doc honestly expanded (thegen-dec-…id expected but not yet checked live). The default body I approved at4467f10is untouched — the drift diff shows only #19's additions; not one character of stream logic changed.OpenRouterClient.cs— both sides changed it, so I diffed against each parent: vs4467f10it gains exactly the decisions additions (DecisionsPath/SystemOnePath,CreateDecisionsAsyncwith itsModel/Answers/Usagemalformed-guards); vsadf32cbthe stream override survives whole. Neither side ate the other. ♡CachingOpenRouterClient.cs— the resolved doc unions both sentences, and the class explicitly forwards both members:StreamChatCompletionToResultAsync(remark intact) andCreateDecisionsAsync(delegate-never-cache). Exactly the decorator shape round 1 asked for.README.md— every row of both sides survived: the Kind table keeps the stream wording onProviderUnavailable/NetworkwhileMalformedunions the decisions and stream clauses; the upgrade table keeps the iterator-throws row, the default-body row, #19's decisions row, #17's listing row; the layout tree gains bothTraceConfigand the stall watchdog.Stalled/Interruptedstay stream-only, decisions' "not retried: billed" is its own row — no contradictions.GetGenerationAsync(a decisions id into a call the stream PR didn't touch), and #19's doc already discloses that uncertainty. The stream paths read no decisions types and vice versa;FailureKind/Resultare shared and unchanged.🧪 Local verification (CI absent for
b7d9865— coverage bot comment 9206 predates the merge)-warnaserror.4467f10+ 162 decisions tests from #19. Every round-1 stream test still passes with the decisions code compiled in, which is precisely what a merge must guarantee.b7d9865post-checks. The decisions side needs no re-review: approved as PR #19 at2617935(comment 9197) with its own probes.✅ What I liked~
OpenRouterExceptionmarker deferred until a second real implementation; invariantStallFailureformatting) and the #19 live-smoke items stay on the ledger, as scarlet confirmed in the thread.Fufu~ the branch moved and nobody dropped anything. Ready to merge.
Automated review by Jibril · 2026-09-23
CI/CD: absent for head SHA
b7d9865at review time (coverage bot predates the merge) · Local checks: build 0w/0e, 992/992