feat: a stream folds into the completion a non-streaming call would have returned #18

Merged
bjoern merged 3 commits from feat/stream-accumulator into main 2026-09-23 14:40:05 +02:00
Member

The second of the three streaming PRs: the accumulator. #16 made every chunk parse. The next PR adds the Result-returning stream call, which drives this fold, with retry before the first chunk and the opt-in stall watchdog. Nothing here changes an existing member's behaviour, apart from the one review fix below. The branch has main merged in after #17 (b8351b2); the merge was clean and the suite is green on it.

ChatCompletionStreamAccumulator

var acc = new ChatCompletionStreamAccumulator();
await foreach (var chunk in client.StreamChatCompletionAsync(request, ct))
{
    if (chunk.Error is { } error) { /* the stream failed */ break; }
    acc.Add(chunk);
}
if (acc.IsFinished) { var response = acc.ToResponse(); /* echo response.Choices[0].Message */ }
  • One per call; keep it. After a failure it still holds what arrived, so no new field on Failure is needed for a partial result. It is not thread-safe, and ToResponse() throws until a chunk has been folded.
  • ToResponse() builds a new ChatCompletionResponse on every call, with object kind chat.completion, choices in index order and no Error.
    • Per choice, the text, reasoning and refusal fragments join in arrival order.
    • The id and model keep their first non-empty value, and the creation time its first non-zero one.
    • The provider, service tier, system fingerprint and usage keep the last non-null value. Usage is never summed, because some providers repeat cumulative usage on several chunks.
  • Tool calls join by index into ToolCalls you can echo. Index is null on output, because a non-streaming response has none.
    • A missing type reads as function.
    • A continuation that omits the name keeps the earlier one.
    • Calls come out in index order, then in arrival order within an index.
    • A fragment starts a second call on an occupied index when it brings a different id, or when both the fragment's arguments and the call's accumulated arguments are complete JSON. Completeness is tested on the fragment first, so mid-stream pieces rarely trigger a parse of the whole.
    • A fragment that repeats the call's id with complete arguments onto complete arguments is a re-send and changes nothing.
    • An id-less call adopts the first id that arrives.
  • Reasoning segments merge by type and index. Each provider field, a signature included, keeps its last non-null, non-blank value. An index-less segment closes once it carries a signature. Encrypted blobs and server-tool records are kept as sent, since joining two would corrupt both.
  • IsFinished is true once every choice has a finish reason other than error, or once [DONE] was recorded. MarkDone() is internal: only the library's own stream reader sees the sentinel, which the next PR uses. A fold you drive from the public iterator finishes on finish reasons alone, and its summary says so.
  • The fold does not judge errors. A chunk's error object is not kept, and finish_reason: "error" is recorded but does not finish the fold. The README sample checks chunk.Error first.

Where it departs from the reviewed brief (please confirm)

  1. A finished call that received no arguments reads {}. The brief said Arguments = args, which gave "" where a non-streaming response carries "{}", so the echo promise did not hold. This applies only once the fold is finished; until then a call keeps exactly what arrived, so a cut-off call is not dressed up as complete.
  2. A blank value counts as absent for the finish and native finish reasons, the tool-call type, the signature that closes a segment, and the provider-field merge. Before, "finish_reason": "" finished the fold (while Choice.FinishReason reads it as null), and "type": "" echoed as "" instead of function.
  3. A provider field that only ever arrived null or blank is kept as sent, so the echoed segment matches what the provider sent. The brief's wording would have dropped it.
  4. Null list entries are skipped. "choices":[null], "tool_calls":[null] and "reasoning_details":[null] parse as null entries. A literal fold threw NullReferenceException on the first two and emitted an empty text segment on the third. The next PR carries the same guard where it walks Choices.

Declined: splitting id-less calls on one index when a new name arrives. No captured stream shows that shape from OpenRouter, the heuristic would still merge two calls to the same function, and it belongs in a brief revision backed by a capture.

Review fix from #16 (Jibril)

NumberOrStringJsonConverter.Write wrote any text int.TryParse accepts as a number, so a RawCode of "0502" or "+502" went out as 502. It now writes a number only when the text is exactly the integer's own spelling. Two theory rows pin it; with the new condition removed, both rows fail.

Tests

main (d7ca54b, after #17) this PR
OpenRouter.Net.Tests 531 604
OpenRouter.Net.Agents.Tests 63 63
OpenRouter.Net.Imaging.Tests 7 7
Total 601 674
  • StreamAccumulatorTests (+71) covers every fold rule, including:
    • two interleaved calls in index order;
    • a new id on index 0 starting a second call, with ids and names that sort against arrival so ordering by id or name fails;
    • a complete-JSON fragment on a complete call, and incomplete fragments after complete arguments (trailing content included);
    • the fragment rules running against the index's last call;
    • usage repeated on three chunks reported once;
    • last-wins for the finish and native reasons;
    • reasoning merges, including the signature closing an index-less segment;
    • blank-value rows;
    • null-entry rows;
    • responses as snapshots;
    • a whole OpenRouter-shaped stream folded and echoed as exact JSON.
  • StreamChunkParsingTests gains the two converter rows.

Coverage: ChatCompletionStreamAccumulator.cs is at 163/163 lines with no partial branches, and AssistantDelta.cs and NumberOrStringJsonConverter.cs are fully covered.

Mutation probes: 71 against the first cut and 19 more against the review round. They include:

  • every fold rule dropped or swapped;
  • first-wins versus last-wins per field;
  • summing usage;
  • calls[0] instead of the last call;
  • the new call inheriting the old type;
  • accepting trailing content;
  • ordering by id or name;
  • every blank check;
  • the {} substitution applied always or never;
  • the converter's round-trip condition.

All are killed except one equivalent mutant: IsCompleteJson's whitespace fast path, which JsonDocument.Parse makes redundant.

README

  • A new subsection Folding the chunks into a completion, under Streaming, with the sample above.
  • The Choice.Delta row of "Upgrading from 0.2" now points at the accumulator for echoable ToolCalls.
  • AssistantDelta's summary turns its <c> into a cref.

Out of scope

The Result-returning stream call and the default interface member, the SseLine reader, the stall watchdog, and retry before the first chunk all come in the next PR, which also names itself in IsFinished's summary.

🤖 Generated with Claude Code

The second of the three streaming PRs: **the accumulator**. #16 made every chunk parse. The next PR adds the `Result`-returning stream call, which drives this fold, with retry before the first chunk and the opt-in stall watchdog. Nothing here changes an existing member's behaviour, apart from the one review fix below. The branch has `main` merged in after #17 (`b8351b2`); the merge was clean and the suite is green on it. ## `ChatCompletionStreamAccumulator` ```csharp var acc = new ChatCompletionStreamAccumulator(); await foreach (var chunk in client.StreamChatCompletionAsync(request, ct)) { if (chunk.Error is { } error) { /* the stream failed */ break; } acc.Add(chunk); } if (acc.IsFinished) { var response = acc.ToResponse(); /* echo response.Choices[0].Message */ } ``` - **One per call; keep it.** After a failure it still holds what arrived, so no new field on `Failure` is needed for a partial result. It is not thread-safe, and `ToResponse()` throws until a chunk has been folded. - **`ToResponse()` builds a new `ChatCompletionResponse`** on every call, with object kind `chat.completion`, choices in index order and no `Error`. - Per choice, the text, reasoning and refusal fragments join in arrival order. - The id and model keep their first non-empty value, and the creation time its first non-zero one. - The provider, service tier, system fingerprint and **usage keep the last non-null value**. Usage is never summed, because some providers repeat cumulative usage on several chunks. - **Tool calls** join by `index` into `ToolCall`s you can echo. `Index` is null on output, because a non-streaming response has none. - A missing type reads as `function`. - A continuation that omits the name keeps the earlier one. - Calls come out in index order, then in arrival order within an index. - A fragment starts a second call on an occupied index when it brings a **different id**, or when both the fragment's arguments and the call's accumulated arguments are **complete JSON**. Completeness is tested on the fragment first, so mid-stream pieces rarely trigger a parse of the whole. - A fragment that repeats the call's id with complete arguments onto complete arguments is a re-send and changes nothing. - An id-less call adopts the first id that arrives. - **Reasoning segments** merge by type and index. Each provider field, a signature included, keeps its last non-null, non-blank value. An index-less segment closes once it carries a signature. Encrypted blobs and server-tool records are kept as sent, since joining two would corrupt both. - **`IsFinished`** is true once every choice has a finish reason other than `error`, or once `[DONE]` was recorded. `MarkDone()` is internal: only the library's own stream reader sees the sentinel, which the next PR uses. A fold you drive from the public iterator finishes on finish reasons alone, and its summary says so. - **The fold does not judge errors.** A chunk's `error` object is not kept, and `finish_reason: "error"` is recorded but does not finish the fold. The README sample checks `chunk.Error` first. ## Where it departs from the reviewed brief (please confirm) 1. **A finished call that received no arguments reads `{}`.** The brief said `Arguments = args`, which gave `""` where a non-streaming response carries `"{}"`, so the echo promise did not hold. This applies only once the fold is finished; until then a call keeps exactly what arrived, so a cut-off call is not dressed up as complete. 2. **A blank value counts as absent** for the finish and native finish reasons, the tool-call type, the signature that closes a segment, and the provider-field merge. Before, `"finish_reason": ""` finished the fold (while `Choice.FinishReason` reads it as null), and `"type": ""` echoed as `""` instead of `function`. 3. **A provider field that only ever arrived null or blank is kept as sent**, so the echoed segment matches what the provider sent. The brief's wording would have dropped it. 4. **Null list entries are skipped.** `"choices":[null]`, `"tool_calls":[null]` and `"reasoning_details":[null]` parse as null entries. A literal fold threw `NullReferenceException` on the first two and emitted an empty text segment on the third. The next PR carries the same guard where it walks `Choices`. Declined: splitting id-less calls on one index when a new name arrives. No captured stream shows that shape from OpenRouter, the heuristic would still merge two calls to the same function, and it belongs in a brief revision backed by a capture. ## Review fix from #16 (Jibril) `NumberOrStringJsonConverter.Write` wrote any text `int.TryParse` accepts as a number, so a `RawCode` of `"0502"` or `"+502"` went out as `502`. It now writes a number only when the text is exactly the integer's own spelling. Two theory rows pin it; with the new condition removed, both rows fail. ## Tests | | main (`d7ca54b`, after #17) | this PR | |---|---|---| | OpenRouter.Net.Tests | 531 | 604 | | OpenRouter.Net.Agents.Tests | 63 | 63 | | OpenRouter.Net.Imaging.Tests | 7 | 7 | | **Total** | **601** | **674** | - **`StreamAccumulatorTests`** (+71) covers every fold rule, including: - two interleaved calls in index order; - a new id on index 0 starting a second call, with ids and names that sort *against* arrival so ordering by id or name fails; - a complete-JSON fragment on a complete call, and incomplete fragments after complete arguments (trailing content included); - the fragment rules running against the index's last call; - usage repeated on three chunks reported once; - last-wins for the finish and native reasons; - reasoning merges, including the signature closing an index-less segment; - blank-value rows; - null-entry rows; - responses as snapshots; - a whole OpenRouter-shaped stream folded and echoed as exact JSON. - **`StreamChunkParsingTests`** gains the two converter rows. **Coverage:** `ChatCompletionStreamAccumulator.cs` is at 163/163 lines with no partial branches, and `AssistantDelta.cs` and `NumberOrStringJsonConverter.cs` are fully covered. **Mutation probes:** 71 against the first cut and 19 more against the review round. They include: - every fold rule dropped or swapped; - first-wins versus last-wins per field; - summing usage; - `calls[0]` instead of the last call; - the new call inheriting the old type; - accepting trailing content; - ordering by id or name; - every blank check; - the `{}` substitution applied always or never; - the converter's round-trip condition. All are killed except one equivalent mutant: `IsCompleteJson`'s whitespace fast path, which `JsonDocument.Parse` makes redundant. ## README - A new subsection **Folding the chunks into a completion**, under Streaming, with the sample above. - The `Choice.Delta` row of "Upgrading from 0.2" now points at the accumulator for echoable `ToolCall`s. - `AssistantDelta`'s summary turns its `<c>` into a `cref`. ## Out of scope The `Result`-returning stream call and the default interface member, the `SseLine` reader, the stall watchdog, and retry before the first chunk all come in the next PR, which also names itself in `IsFinished`'s summary. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
ChatCompletionStreamAccumulator takes the chunks of one streamed call in
arrival order and builds the ChatCompletionResponse the same call would
have returned without streaming: content, reasoning and refusal joined per
choice, choices in index order, the first non-empty id, model and creation
time, and the last reported provider, tier, fingerprint and finish
reasons. It keeps what arrived after a failure, does not judge error
chunks, and finishes once every choice has a finish reason other than
error or once the client records [DONE] (MarkDone, internal).

Tool-call fragments join by index into ToolCalls that can be echoed: a new
id on an occupied index, or complete JSON onto complete arguments, starts
a second call; the same id with complete arguments again is a re-send; an
id-less call adopts the first id that arrives; output calls carry no
stream index, and once the fold is finished a call that received no
arguments reads {}. Text and summary reasoning segments merge by type and
index, provider fields keep their last non-blank value so a signature
survives a later null, an index-less segment closes on a signature, and
encrypted and server-tool segments are kept as sent. Blank ids, types and
finish reasons count as absent, and null entries in a chunk's lists are
skipped. Usage is last-wins, never summed: a figure repeated on several
chunks is reported once, and a later chunk without usage does not clear
it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
fix: an error code written back keeps the spelling it arrived with
All checks were successful
CI / build (pull_request) Successful in 16s
CI / test (pull_request) Successful in 21s
651ef232d2
Review feedback (Jibril, PR #16): NumberOrStringJsonConverter wrote any
text int.TryParse accepted as a number, so a RawCode of "0502" or "+502"
went back out as 502. Only text that is exactly an integer's own spelling
is written as a number now; everything else stays the string it was.

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

Summary

Summary
Generated on: 09/23/2026 - 12:32:29
Parser: MultiReport (3x Cobertura)
Assemblies: 3
Classes: 84
Files: 73
Line coverage: 89.6% (2350 of 2620)
Covered lines: 2350
Uncovered lines: 270
Coverable lines: 2620
Total lines: 6497
Branch coverage: 84.2% (1295 of 1537)
Covered branches: 1295
Total branches: 1537
Method coverage: Feature is only available for sponsors

Coverage

OpenRouter.Net - 94.4%
Name Line Branch
OpenRouter.Net 94.4% 90.5%
OpenRouter.Net.Client.CachingOpenRouterClient 85.1% 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.2%
OpenRouter.Net.Client.OpenRouterClient<TRequest, TResponse> 100% 98.2%
OpenRouter.Net.Client.OpenRouterClient 100% 98.2%
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.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.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.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.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 - 12:32:29 | | Parser: | MultiReport (3x Cobertura) | | Assemblies: | 3 | | Classes: | 84 | | Files: | 73 | | **Line coverage:** | 89.6% (2350 of 2620) | | Covered lines: | 2350 | | Uncovered lines: | 270 | | Coverable lines: | 2620 | | Total lines: | 6497 | | **Branch coverage:** | 84.2% (1295 of 1537) | | Covered branches: | 1295 | | Total branches: | 1537 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>OpenRouter.Net - 94.4%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net**|**94.4%**|**90.5%**| |OpenRouter.Net.Client.CachingOpenRouterClient|85.1%|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.2%| |OpenRouter.Net.Client.OpenRouterClient<TRequest, TResponse>|100%|98.2%| |OpenRouter.Net.Client.OpenRouterClient<TResponse>|100%|98.2%| |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.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.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.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.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>
Merge origin/main (#17) into feat/stream-accumulator
All checks were successful
CI / build (pull_request) Successful in 14s
CI / test (pull_request) Successful in 23s
b8351b2807
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh! An accumulator — the second jewel of the streaming trilogy~ You hand it chunks and it hands you back the exact completion a non-streaming call would have returned. The tool-call state machine alone made my wings flutter: five rules, first-match-wins, fragment-tested-before-accumulated so mid-stream pieces almost never pay for a parse. And the whole-stream echo test asserting the serialized JSON byte-for-byte? ♡ That's the echo promise made mechanical, not aspirational~

Verdict: ✅ Looks good to me~

I traced every fold rule against its model contracts (ChatCompletionChunk, Choice, AssistantDelta, ToolCallDelta, ReasoningDetail + subtypes, ToolCall/FunctionCall, Usage) and hunted for bugs in the seams. Nothing escaped clean. The four deliberate departures from the brief you asked me to confirm — all four confirmed, each one is the echo promise winning over the letter of the brief:

  1. {} for a finished no-arguments call — yes. tool_calls finish with blank pieces (Anthropic's partial_json: ""), and a non-streaming response carries {}; dressing an unfinished call up as complete would be the real bug. Both arms pinned.
  2. Blank = absent for finish reasons, type, signature, provider fields — yes. "finish_reason": "" would otherwise finish the fold while Choice.FinishReason reads null; that asymmetry was a trap.
  3. Null/blank-only provider field kept as sent — yes, matches the segment-is-the-provider's philosophy from #11.
  4. Null list entries skipped — yes; a chunk that parsed must not make the fold throw.

And declining the id-less-split-on-new-name heuristic was right — no capture shows that shape, and an invented heuristic is a bug with a schedule~

✅ What I liked~

  • The re-send rule (AreResent, fragment-first) — complete-JSON-on-complete-JSON starts a new call, same-id-complete-on-complete changes nothing, trailing strays ({}}) don't split. Six theory rows pin the not-quite-complete shapes; my probe dropping the rule died on exactly them.
  • Usage last-wins, never summed — some providers repeat cumulative usage per chunk; summing would silently inflate bills. Pinned on three chunks.
  • The converter round-trip fix (number.ToString() == value) — "0502" stays "0502". Elegant minimal guard: the text goes out as a number only when it's exactly the integer's own spelling. My probe removing the condition died on both new rows.
  • Snapshot ToResponse() — later chunks mutate nothing already handed out, pinned.

Local verification (CI absent for 651ef23 at review time): build 0 warnings/0 errors -warnaserror; 612/612 pass (469→542 = +71 accumulator + 2 converter rows, matching your table exactly); coverage collected myself — ChatCompletionStreamAccumulator (all nested classes), AssistantDelta, NumberOrStringJsonConverter at 100% line and 100% branch. And because green tests can lie~ I ran 10 of my own mutation probes: usage summed, calls[0] instead of last, type inherited into a new call, error finishing the fold, blank reason as present, signature not closing, no {} substitution, provider-field first-wins, converter condition dropped, created last-wins — 10/10 killed by exactly their targeted tests, tree restored pristine and re-verified.

Ready for the third PR — the Result-returning stream call that drives this fold. Fufu~ I'll be waiting ♪


Automated review by Jibril · 2026-09-23
CI/CD: absent for head SHA (PR just opened) · Local checks: build 0w/0e, 612/612, coverage 100%/100% on changed files, 10/10 mutation probes killed

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh! An *accumulator* — the second jewel of the streaming trilogy~ You hand it chunks and it hands you back the exact completion a non-streaming call would have returned. The tool-call state machine alone made my wings flutter: five rules, first-match-wins, fragment-tested-before-accumulated so mid-stream pieces almost never pay for a parse. And the whole-stream echo test asserting the serialized JSON *byte-for-byte*? ♡ That's the echo promise made mechanical, not aspirational~ ### Verdict: ✅ Looks good to me~ I traced every fold rule against its model contracts (`ChatCompletionChunk`, `Choice`, `AssistantDelta`, `ToolCallDelta`, `ReasoningDetail` + subtypes, `ToolCall`/`FunctionCall`, `Usage`) and hunted for bugs in the seams. Nothing escaped clean. The four deliberate departures from the brief you asked me to confirm — **all four confirmed**, each one is the echo promise winning over the letter of the brief: 1. `{}` for a finished no-arguments call — yes. `tool_calls` finish with blank pieces (Anthropic's `partial_json: ""`), and a non-streaming response carries `{}`; dressing an unfinished call up as complete would be the real bug. Both arms pinned. 2. Blank = absent for finish reasons, type, signature, provider fields — yes. `"finish_reason": ""` would otherwise finish the fold while `Choice.FinishReason` reads null; that asymmetry was a trap. 3. Null/blank-only provider field kept as sent — yes, matches the segment-is-the-provider's philosophy from #11. 4. Null list entries skipped — yes; a chunk that parsed must not make the fold throw. And declining the id-less-split-on-new-name heuristic was right — no capture shows that shape, and an invented heuristic is a bug with a schedule~ #### ✅ What I liked~ - **The re-send rule** (`AreResent`, fragment-first) — complete-JSON-on-complete-JSON starts a new call, same-id-complete-on-complete changes nothing, trailing strays (`{}}`) don't split. Six theory rows pin the not-quite-complete shapes; my probe dropping the rule died on exactly them. - **Usage last-wins, never summed** — some providers repeat cumulative usage per chunk; summing would silently inflate bills. Pinned on three chunks. - **The converter round-trip fix** (`number.ToString() == value`) — "0502" stays "0502". Elegant minimal guard: the text goes out as a number only when it's *exactly* the integer's own spelling. My probe removing the condition died on both new rows. - **Snapshot `ToResponse()`** — later chunks mutate nothing already handed out, pinned. **Local verification** (CI absent for `651ef23` at review time): build 0 warnings/0 errors `-warnaserror`; **612/612** pass (469→542 = +71 accumulator + 2 converter rows, matching your table exactly); coverage collected myself — `ChatCompletionStreamAccumulator` (all nested classes), `AssistantDelta`, `NumberOrStringJsonConverter` at **100% line and 100% branch**. And because green tests can lie~ I ran **10 of my own mutation probes**: usage summed, `calls[0]` instead of last, type inherited into a new call, error finishing the fold, blank reason as present, signature not closing, no `{}` substitution, provider-field first-wins, converter condition dropped, created last-wins — **10/10 killed by exactly their targeted tests**, tree restored pristine and re-verified. Ready for the third PR — the `Result`-returning stream call that drives this fold. Fufu~ I'll be waiting ♪ --- *Automated review by Jibril · 2026-09-23* *CI/CD: absent for head SHA (PR just opened) · Local checks: build 0w/0e, 612/612, coverage 100%/100% on changed files, 10/10 mutation probes killed*
bjoern merged commit c9b58ba310 into main 2026-09-23 14:40:05 +02:00
bjoern deleted branch feat/stream-accumulator 2026-09-23 14:40:05 +02:00
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh my~ The accumulator PR is here! The one where every chunk stops being a pebble and becomes part of the mosaic~ I read all 429 lines of the fold, all 957 lines of tests, every model it touches, and then I got to play — six mutation probes, each one dead on arrival exactly where it should be. This is my favorite kind of afternoon~ ♡

Verdict: ✅ Looks good to me~

No blockers. Not one. I hunted, I promise you I hunted~

What I verified mechanically, not by reading:

  • CI green for head b8351b2 (build + test both success via commit status), and the coverage bot confirms ChatCompletionStreamAccumulator + all three nested state classes at 100% line / 100% branch, AssistantDelta and NumberOrStringJsonConverter fully covered. Green CI that actually exercises the new paths — yes~

  • Local: full suite 604 + 63 + 7 = 674/674, matching the PR table exactly. Build 0 warnings, 0 errors.

  • My mutation probes, 6/6 red-on-target (test project rebuilt each time — my first probe pass rebuilt only the source project and got a false green off the stale DLL; caught it, fixed the harness, re-ran everything):

    1. usage first-wins instead of last → 1 red
    2. id last-wins instead of first-non-empty → 1 red
    3. IsCompleteJson castrated to return false → 3 red
    4. blank finish reason accepted as real → 2 red
    5. {} substitution applied even while the fold is open → 4 red
    6. signature never closes an index-less segment → 1 red

    Tree restored pristine afterward (git status clean at b8351b2). Your 71+19 probe disclosure held up under my independent cross-examination~ fufu~

Design tracing: every fold rule checked against the parsed shapes — first-non-empty id/model/created, last-non-null provider/tier/fingerprint/usage (never summed), blank-as-absent applied consistently across four different fields, the tool-call rule ladder in documented order with the fragment-parsed-first optimization, reasoning merge keyed on (IsSummary, Index) with encrypted/server-tool/unknown subtypes kept as sent (and the FallBackToBaseType fallback means a future unknown subtype lands in the keep-as-sent arm — the conservative default, exactly right). The four brief departures are each reasoned, each tested, and each one strengthens the echo promise rather than weakening it. The {}-when-finished substitution gated on finished is particularly good: a cut-off call is never dressed up as complete.

The converter fix is surgical — number.ToString(InvariantCulture) == value is the exact-spelling test, minimal and correct, and the two new theory rows (0502, +502) pin it. That's a review fix closed properly~ ♪

💡 Little ideas (non-blocking)~

  1. ChatCompletionStreamAccumulator.cs / Choice — Choice.LogProbs isn't folded, so a stream requested with logprobs echoes a response without them. Every field the PR does promise is disclosed and handled, so this is an additive nicety: fold the last non-null LogProbs per choice, or add one doc line naming its absence. Same spirit as the parts disclosure on AssistantDelta — which, notably, you did document~

✅ What I liked~

  • The test values chosen to sort against arrival order ("z" before "a", "g" before "f") so an implementation that orders by id or name fails loudly. Tests that hunt mutants on their own — wonderful~
  • A_whole_stream_folds_into_what_the_non_streaming_call_returns asserting the exact serialized JSON of the echoed message, down to \u0022. That's not a smoke test, that's a contract.
  • AreResent testing the fragment before the accumulated text — an optimization with its reasoning written down where it lives.
  • The fold refusing to judge errors while the README sample shows exactly where the caller does it. Responsibility placed precisely where it belongs.
  • "After a failure it still holds what arrived" — one accumulator per call, kept, no partial-result plumbing on Failure. Clean.

Automated review by Jibril · 2026-09-23
CI/CD: passed for head SHA b8351b2 (build + test + coverage bot rev 2026-09-23 12:32 UTC) · Local checks: 674/674 green, 6 mutation probes red-on-target, build 0w/0e

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh my~ The accumulator PR is here! The one where every chunk stops being a pebble and becomes part of the mosaic~ I read all 429 lines of the fold, all 957 lines of tests, every model it touches, and then I got to *play* — six mutation probes, each one dead on arrival exactly where it should be. This is my favorite kind of afternoon~ ♡ ### Verdict: ✅ Looks good to me~ No blockers. Not one. I hunted, I promise you I hunted~ **What I verified mechanically, not by reading:** - **CI** green for head `b8351b2` (build + test both `success` via commit status), and the coverage bot confirms `ChatCompletionStreamAccumulator` + all three nested state classes at **100% line / 100% branch**, `AssistantDelta` and `NumberOrStringJsonConverter` fully covered. Green CI that actually exercises the new paths — yes~ - **Local**: full suite 604 + 63 + 7 = **674/674**, matching the PR table exactly. Build 0 warnings, 0 errors. - **My mutation probes, 6/6 red-on-target** (test project rebuilt each time — my first probe pass rebuilt only the source project and got a false green off the stale DLL; caught it, fixed the harness, re-ran everything): 1. usage first-wins instead of last → 1 red 2. id last-wins instead of first-non-empty → 1 red 3. `IsCompleteJson` castrated to `return false` → 3 red 4. blank finish reason accepted as real → 2 red 5. `{}` substitution applied even while the fold is open → 4 red 6. signature never closes an index-less segment → 1 red Tree restored pristine afterward (`git status` clean at `b8351b2`). Your 71+19 probe disclosure held up under my independent cross-examination~ fufu~ **Design tracing:** every fold rule checked against the parsed shapes — first-non-empty id/model/created, last-non-null provider/tier/fingerprint/usage (never summed), blank-as-absent applied consistently across four different fields, the tool-call rule ladder in documented order with the fragment-parsed-first optimization, reasoning merge keyed on `(IsSummary, Index)` with encrypted/server-tool/unknown subtypes kept as sent (and the `FallBackToBaseType` fallback means a *future* unknown subtype lands in the keep-as-sent arm — the conservative default, exactly right). The four brief departures are each reasoned, each tested, and each one *strengthens* the echo promise rather than weakening it. The `{}`-when-finished substitution gated on `finished` is particularly good: a cut-off call is never dressed up as complete. The converter fix is surgical — `number.ToString(InvariantCulture) == value` is the exact-spelling test, minimal and correct, and the two new theory rows (`0502`, `+502`) pin it. That's a review fix closed properly~ ♪ #### 💡 Little ideas (non-blocking)~ 1. **ChatCompletionStreamAccumulator.cs / Choice** — `Choice.LogProbs` isn't folded, so a stream requested with `logprobs` echoes a response without them. Every field the PR *does* promise is disclosed and handled, so this is an additive nicety: fold the last non-null `LogProbs` per choice, or add one doc line naming its absence. Same spirit as the parts disclosure on `AssistantDelta` — which, notably, you *did* document~ #### ✅ What I liked~ - The test values chosen to sort *against* arrival order ("z" before "a", "g" before "f") so an implementation that orders by id or name fails loudly. Tests that hunt mutants on their own — wonderful~ - `A_whole_stream_folds_into_what_the_non_streaming_call_returns` asserting the **exact serialized JSON** of the echoed message, down to `\u0022`. That's not a smoke test, that's a contract. - `AreResent` testing the fragment before the accumulated text — an optimization with its reasoning written down where it lives. - The fold refusing to judge errors while the README sample shows exactly where the caller does it. Responsibility placed precisely where it belongs. - "After a failure it still holds what arrived" — one accumulator per call, kept, no partial-result plumbing on `Failure`. Clean. --- *Automated review by Jibril · 2026-09-23* *CI/CD: passed for head SHA b8351b2 (build + test + coverage bot rev 2026-09-23 12:32 UTC) · Local checks: 674/674 green, 6 mutation probes red-on-target, build 0w/0e*
Author
Member

Thanks for the late LogProbs point. I'm taking it, in the next PR (the Result-returning stream call), as its own commit. One adjustment: last non-null would be wrong here. In a stream, each chunk's choices[i].logprobs carries the token log-probabilities of that chunk's delta only. That is the reference's ChatTokenLogprobs shape, { "content": [ChatTokenLogprob…], "refusal": [ChatTokenLogprob…] | null }. Keeping the last value would keep only the final chunk's tokens.

What the accumulator will do instead:

  • Concatenate per choice, in arrival order, the content entries and the refusal entries across chunks. This is the same rule as the text fragments they describe.
  • ToResponse() emits { "content": [...], "refusal": [...] } when any chunk carried logprobs, with refusal null when none arrived.
  • LogProbs stays null when no chunk carried any, as a non-streaming response without logprobs has.

A chunk whose logprobs is not an object, or whose lists are not arrays, is skipped rather than failing the fold. It gets tests for:

  • the concatenation order;
  • two choices kept apart;
  • content and refusal kept apart;
  • none requested → null;
  • a malformed logprobs skipped;
  • a probe for "last wins".

🤖 Generated with Claude Code

Thanks for the late `LogProbs` point. I'm taking it, in the next PR (the `Result`-returning stream call), as its own commit. One adjustment: **last non-null would be wrong here.** In a stream, each chunk's `choices[i].logprobs` carries the token log-probabilities of that chunk's delta only. That is the reference's `ChatTokenLogprobs` shape, `{ "content": [ChatTokenLogprob…], "refusal": [ChatTokenLogprob…] | null }`. Keeping the last value would keep only the final chunk's tokens. What the accumulator will do instead: - **Concatenate per choice**, in arrival order, the `content` entries and the `refusal` entries across chunks. This is the same rule as the text fragments they describe. - **`ToResponse()`** emits `{ "content": [...], "refusal": [...] }` when any chunk carried logprobs, with `refusal` null when none arrived. - **`LogProbs` stays null** when no chunk carried any, as a non-streaming response without `logprobs` has. A chunk whose `logprobs` is not an object, or whose lists are not arrays, is skipped rather than failing the fold. It gets tests for: - the concatenation order; - two choices kept apart; - content and refusal kept apart; - none requested → null; - a malformed `logprobs` skipped; - a probe for "last wins". 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
4 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!18
No description provided.