feat: every streamed chunk parses, and every failure says what kind it is #16

Merged
bjoern merged 3 commits from feat/stream-chunks-and-failure-kinds into main 2026-09-23 14:02:27 +02:00
Member

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:

  • This PR: every streamed chunk parses, and every failure carries a FailureKind.
  • Next PR: the accumulator that folds chunks into a finished completion.
  • The one after: the stream call that returns a 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

  • A streamed tool call carried only its index on continuation fragments. The shared ToolCall requires id and function, so the public iterator failed on every multi-fragment tool call. Choice.Delta is now its own AssistantDelta, with ToolCallDelta and FunctionCallDelta where only index is guaranteed. ToolCall and FunctionCall are unchanged apart from two summaries.
  • A chunk without id, model or choices parses. An explicit null counts as missing. This is defence against error-only chunks: the reference marks these fields required, and a comment says so. SystemFingerprint is added.
  • reasoning.server_tool_call is registered as a fourth ReasoningDetail subtype. Before, a stream that used a server tool failed.
  • ApiError.RawCode holds the wire code as sent, number or string. Code is 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.
  • ApiError reads its metadata. ErrorType is the tolerant ApiErrorType (27 values, append-only, exact wire names; an unknown value reads as Unknown). RawErrorType and ProviderCode are computed from metadata, not new wire fields, so existing consumers are untouched.

Every failure says what kind it is

FailureKind has 14 members, listed in the README's Kind table. Result<T>.Failure gains:

  • Kind, an init property with a default, not a positional parameter. Failure(Error, StatusCode, Details), its parameter names and its Deconstruct arity are unchanged. Orihon passes StatusCode: and Details: by name, and that still binds.
  • IsTransient, which is true for RateLimited, ProviderUnavailable, Network, Timeout, Stalled, Interrupted and Malformed.

How the kind is chosen:

  • When unset, it is derived from metadata.error_type first, then the HTTP status, else Unknown. A 403 carrying moderation or guardrail metadata is ContentPolicy; a plain 403 is Authentication. A 408 is ProviderUnavailable.
  • Where the client creates a failure, it sets the kind when there is no status to derive it from:
    • Validation for a refused request, and for a body #15 finds cannot be serialized: it is never sent, so it is permanent.
    • Network, Timeout and Malformed for the transport and parse failures.
    • The error's own type or code for an inline error. An inline error that names neither falls back to ProviderUnavailable, so server_error never reads as the permanent Unknown.
  • IsNetworkFailure is gone. It matched message prefixes; retries now check Kind 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.
  • Stalled and Interrupted are 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 made ListModelsAsync return an empty list, which the caching client then cached for an hour. GetCreditsAsync, GetGenerationAsync, GetKeyAsync and GetModelEndpointsAsync failed with "Response contained no 'data' field." and lost the message. Every parsed body now implements an internal IInlineErrorCarrier, and the error comes back as a failure with its message, code and kind. A listing without data is Malformed, and the two dead "Unhandled result type" arms are gone.

Breaking changes (README → "Upgrading from 0.2")

Choice.Delta changes type. Chunk members are no longer required. There is a fourth ReasoningDetail subtype. ApiError.Code becomes computed, next to the wire RawCode. Failure equality and ToString now include Kind. A missing reasoning type now surfaces as OpenRouterException or Malformed. 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.cs and the README streaming sample gain a Choices.Count guard.

On top of #15

  • Both PRs widened the same two parse catches to NotSupportedException. The comments are merged.
  • #15's "cannot be serialized" failure now carries Kind = Validation. Its non-streaming theory also asserts Kind and !IsTransient.
  • The README sentence that listed 524 and 529 as "not retried by default" now names only 501 and 505, since #15 made 524 and 529 retryable.
  • One new test covers #15's no-base-address arm of the route resolution. That arm sits in a file this PR changes and had no coverage.
  • Also included: the slice-1 review idea (the session-id cap test builds its input from MaxSessionIdLength + 1). It is a separate commit.

Before this merges: one live capture needs your key (Björn)

FailureKind.ContextLength assumes OpenRouter tags a length rejection with metadata.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:

OPENROUTER_API_KEY=… python3 - <<'EOF'
import json, os, urllib.request, urllib.error
long = "word " * 20000   # ~20k tokens against a 4,095-token model; a rejection costs nothing
for stream in (False, True):
    body = json.dumps({"model": "openai/gpt-3.5-turbo-0613", "stream": stream,
                       "messages": [{"role": "user", "content": long}]}).encode()
    req = urllib.request.Request("https://openrouter.ai/api/v1/chat/completions", body,
        {"Authorization": "Bearer " + os.environ["OPENROUTER_API_KEY"], "Content-Type": "application/json"})
    try:
        with urllib.request.urlopen(req) as r: print("stream" if stream else "plain", r.status, r.read().decode()[:4000])
    except urllib.error.HTTPError as e: print("stream" if stream else "plain", e.code, e.read().decode()[:4000])
EOF
  • If error_type is there, the fixture gets replaced with the captured body and nothing else changes.
  • If it is not, the kind is 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

main (ec76623) this PR
OpenRouter.Net.Tests 254 469
OpenRouter.Net.Agents.Tests 63 63
OpenRouter.Net.Imaging.Tests 7 7
Total 324 539

Test files:

  • New: StreamChunkParsingTests, ApiErrorTests, FailureKindTests (every ApiErrorType, 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).
  • Extended: the retry, streaming, reasoning, enum-converter, result and base-URL tests.
  • Fixtures: OpenRouter's documented mid-stream error chunk and the spec's guardrail 403 are verbatim in OpenRouterSamples.

Coverage (dotnet test --coverage --coverage-output-format cobertura, per file, best over the three reports):

  • Every changed or new source line is hit, and every branch is taken.
  • The one partial is 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 Kind positional fails to compile the deconstruction test. Among them:

  • dropping each FromErrorType arm and each status arm
  • swapping error type and status precedence
  • the 403 moderation check
  • the inline fallback
  • dropping each kind at every failure site
  • IsTransient membership
  • the retry check
  • the envelope error check
  • a listing without data
  • Rewrap dropping status, details or message
  • the delta's reasoning and refusal wire names
  • optional arguments on a tool-call fragment
  • the User-Agent version

Docs name the next two PRs' behaviour ahead of time

AssistantDelta and FailureKind summaries mention the accumulator and the stall timeout in <c>, not cref, so nothing points at a type that does not exist yet. The accumulator PR turns them into crefs. The third PR deletes the README's "no call returns them yet" sentence and adds Stalled/Interrupted rows to the Kind table.

Out of scope

  • The accumulator.
  • The Result-returning stream call.
  • The stall watchdog.
  • Retrying a stream before its first chunk.
  • Reading on after an inline error chunk. OpenRouter documents that the stream ends there.

🤖 Generated with Claude Code

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: - **This PR:** every streamed chunk parses, and every failure carries a `FailureKind`. - **Next PR:** the accumulator that folds chunks into a finished completion. - **The one after:** the stream call that returns a `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 - **A streamed tool call carried only its `index` on continuation fragments.** The shared `ToolCall` requires `id` and `function`, so the public iterator failed on every multi-fragment tool call. `Choice.Delta` is now its own `AssistantDelta`, with `ToolCallDelta` and `FunctionCallDelta` where only `index` is guaranteed. `ToolCall` and `FunctionCall` are unchanged apart from two summaries. - **A chunk without `id`, `model` or `choices` parses.** An explicit `null` counts as missing. This is defence against error-only chunks: the reference marks these fields required, and a comment says so. `SystemFingerprint` is added. - **`reasoning.server_tool_call` is registered** as a fourth `ReasoningDetail` subtype. Before, a stream that used a server tool failed. - **`ApiError.RawCode` holds the wire `code`** as sent, number or string. `Code` is 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. - **`ApiError` reads its metadata.** `ErrorType` is the tolerant `ApiErrorType` (27 values, append-only, exact wire names; an unknown value reads as `Unknown`). `RawErrorType` and `ProviderCode` are computed from `metadata`, not new wire fields, so existing consumers are untouched. ## Every failure says what kind it is `FailureKind` has 14 members, listed in the README's Kind table. `Result<T>.Failure` gains: - `Kind`, an **init property with a default**, not a positional parameter. `Failure(Error, StatusCode, Details)`, its parameter names and its `Deconstruct` arity are unchanged. Orihon passes `StatusCode:` and `Details:` by name, and that still binds. - `IsTransient`, which is true for RateLimited, ProviderUnavailable, Network, Timeout, Stalled, Interrupted and Malformed. How the kind is chosen: - **When unset, it is derived** from `metadata.error_type` first, then the HTTP status, else `Unknown`. A 403 carrying moderation or guardrail metadata is `ContentPolicy`; a plain 403 is `Authentication`. A 408 is `ProviderUnavailable`. - **Where the client creates a failure, it sets the kind** when there is no status to derive it from: - `Validation` for a refused request, and for a body #15 finds cannot be serialized: it is never sent, so it is permanent. - `Network`, `Timeout` and `Malformed` for the transport and parse failures. - The error's own type or code for an inline error. An inline error that names neither falls back to `ProviderUnavailable`, so `server_error` never reads as the permanent `Unknown`. - **`IsNetworkFailure` is gone.** It matched message prefixes; retries now check `Kind 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. - `Stalled` and `Interrupted` are 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 made `ListModelsAsync` return an empty list, which the caching client then cached for an hour. `GetCreditsAsync`, `GetGenerationAsync`, `GetKeyAsync` and `GetModelEndpointsAsync` failed with "Response contained no 'data' field." and lost the message. Every parsed body now implements an internal `IInlineErrorCarrier`, and the error comes back as a failure with its message, code and kind. A listing without `data` is `Malformed`, and the two dead `"Unhandled result type"` arms are gone. ## Breaking changes (README → "Upgrading from 0.2") `Choice.Delta` changes type. Chunk members are no longer `required`. There is a fourth `ReasoningDetail` subtype. `ApiError.Code` becomes computed, next to the wire `RawCode`. `Failure` equality and `ToString` now include `Kind`. A missing reasoning `type` now surfaces as `OpenRouterException` or `Malformed`. 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.cs` and the README streaming sample gain a `Choices.Count` guard. ## On top of #15 - Both PRs widened the same two parse catches to `NotSupportedException`. The comments are merged. - #15's "cannot be serialized" failure now carries `Kind = Validation`. Its non-streaming theory also asserts `Kind` and `!IsTransient`. - The README sentence that listed 524 and 529 as "not retried by default" now names only 501 and 505, since #15 made 524 and 529 retryable. - One new test covers #15's no-base-address arm of the route resolution. That arm sits in a file this PR changes and had no coverage. - Also included: the slice-1 review idea (the session-id cap test builds its input from `MaxSessionIdLength + 1`). It is a separate commit. ## Before this merges: one live capture needs your key (Björn) `FailureKind.ContextLength` assumes OpenRouter tags a length rejection with `metadata.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: ```bash OPENROUTER_API_KEY=… python3 - <<'EOF' import json, os, urllib.request, urllib.error long = "word " * 20000 # ~20k tokens against a 4,095-token model; a rejection costs nothing for stream in (False, True): body = json.dumps({"model": "openai/gpt-3.5-turbo-0613", "stream": stream, "messages": [{"role": "user", "content": long}]}).encode() req = urllib.request.Request("https://openrouter.ai/api/v1/chat/completions", body, {"Authorization": "Bearer " + os.environ["OPENROUTER_API_KEY"], "Content-Type": "application/json"}) try: with urllib.request.urlopen(req) as r: print("stream" if stream else "plain", r.status, r.read().decode()[:4000]) except urllib.error.HTTPError as e: print("stream" if stream else "plain", e.code, e.read().decode()[:4000]) EOF ``` - **If `error_type` is there,** the fixture gets replaced with the captured body and nothing else changes. - **If it is not,** the kind is `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 | | main (`ec76623`) | this PR | |---|---|---| | OpenRouter.Net.Tests | 254 | 469 | | OpenRouter.Net.Agents.Tests | 63 | 63 | | OpenRouter.Net.Imaging.Tests | 7 | 7 | | **Total** | **324** | **539** | Test files: - **New:** `StreamChunkParsingTests`, `ApiErrorTests`, `FailureKindTests` (every `ApiErrorType`, 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). - **Extended:** the retry, streaming, reasoning, enum-converter, result and base-URL tests. - **Fixtures:** OpenRouter's documented mid-stream error chunk and the spec's guardrail 403 are verbatim in `OpenRouterSamples`. **Coverage** (`dotnet test --coverage --coverage-output-format cobertura`, per file, best over the three reports): - Every changed or new source line is hit, and every branch is taken. - The one partial is `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 `Kind` positional fails to compile the deconstruction test. Among them: - dropping each `FromErrorType` arm and each status arm - swapping error type and status precedence - the 403 moderation check - the inline fallback - dropping each kind at every failure site - `IsTransient` membership - the retry check - the envelope error check - a listing without `data` - `Rewrap` dropping status, details or message - the delta's reasoning and refusal wire names - optional `arguments` on a tool-call fragment - the User-Agent version ## Docs name the next two PRs' behaviour ahead of time `AssistantDelta` and `FailureKind` summaries mention the accumulator and the stall timeout in `<c>`, not `cref`, so nothing points at a type that does not exist yet. The accumulator PR turns them into `cref`s. The third PR deletes the README's "no call returns them yet" sentence and adds `Stalled`/`Interrupted` rows to the Kind table. ## Out of scope - The accumulator. - The `Result`-returning stream call. - The stall watchdog. - Retrying a stream before its first chunk. - Reading on after an inline error chunk. OpenRouter documents that the stream ends there. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Review feedback (Jibril, PR #14): the one-past-the-maximum input is
MaxSessionIdLength + 1 rather than a literal, so a cap change moves the
whole test; the message still pins 256 and 257.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A streamed tool call carries only its index on continuation fragments,
which the shared ToolCall refused; a chunk's delta is now its own
AssistantDelta with ToolCallDelta/FunctionCallDelta, and a chunk without
id, model or choices parses. ApiError keeps a string code (RawCode) beside
the numeric one and reads error_type and provider_code from its metadata.
Result<T>.Failure gains Kind (derived from error_type, then status, or set
where the failure is made) and IsTransient; Failure's positional shape and
Deconstruct are unchanged. An error envelope on a 200 fails every data
call instead of reading as an empty success. Version 0.3.0: Choice.Delta
changes type, listed in the README's upgrade table.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
test: a client without a base address leaves the route for HttpClient to refuse
All checks were successful
CI / build (pull_request) Successful in 14s
CI / test (pull_request) Successful in 22s
3b3550b329
Covers the no-base-address arm #15 added to the route resolution, which
this PR's changes to the client put in its coverage report.

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

Summary

Summary
Generated on: 09/23/2026 - 11:41:19
Parser: MultiReport (3x Cobertura)
Assemblies: 3
Classes: 78
Files: 71
Line coverage: 88% (2002 of 2274)
Covered lines: 2002
Uncovered lines: 272
Coverable lines: 2274
Total lines: 5845
Branch coverage: 80.3% (996 of 1239)
Covered branches: 996
Total branches: 1239
Method coverage: Feature is only available for sponsors

Coverage

OpenRouter.Net - 92.9%
Name Line Branch
OpenRouter.Net 92.9% 87.1%
OpenRouter.Net.Client.CachingOpenRouterClient 73.3% 81.2%
OpenRouter.Net.Client.OpenRouterClient 100% 98%
OpenRouter.Net.Client.OpenRouterClient<TRequest, TResponse> 100% 98%
OpenRouter.Net.Client.OpenRouterClient 100% 98%
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 80% 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.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 - 11:41:19 | | Parser: | MultiReport (3x Cobertura) | | Assemblies: | 3 | | Classes: | 78 | | Files: | 71 | | **Line coverage:** | 88% (2002 of 2274) | | Covered lines: | 2002 | | Uncovered lines: | 272 | | Coverable lines: | 2274 | | Total lines: | 5845 | | **Branch coverage:** | 80.3% (996 of 1239) | | Covered branches: | 996 | | Total branches: | 1239 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>OpenRouter.Net - 92.9%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net**|**92.9%**|**87.1%**| |OpenRouter.Net.Client.CachingOpenRouterClient|73.3%|81.2%| |OpenRouter.Net.Client.OpenRouterClient|100%|98%| |OpenRouter.Net.Client.OpenRouterClient<TRequest, TResponse>|100%|98%| |OpenRouter.Net.Client.OpenRouterClient<TResponse>|100%|98%| |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|80%|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.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! Every failure whispers what it is and every chunk consents to be parsed~ This is the kind of PR I want to pet. A FailureKind derived from error_type first and status second, an AssistantDelta that finally admits continuations only carry an index, 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)

  • Local suite reproduced exactly: build clean, 539/539 (469 + 63 + 7) — matches your PR table to the digit.
  • Mutation probes 9/9 killed on my bench: precedence dropped → 27 red; the 403-guardrail arm removed → 4 red; inline fallback dropped → 3 red; retry check loosened to Network-only → 1 red (A_timed_out_request_is_retried catches it — delicious); envelope inline-check disabled → 8 red; Rewrap dropping Kind → 7 red; the User-Agent version literal → 1 red; the delta's reasoning wire name → 1 red; ApiError.Code's getter neutered → 8 red. The Kind-positional pin is a compile-time kill as you said. Every restore verified by rebuild + full green + pristine tree.
  • Contracts traced in full: AssistantDelta's Content/Parts envelope mirrors AssistantMessage byte-for-byte in shape; FromWireName is exact-wire-only — the Enum.TryParse hole ("1" → ContextLengthExceeded, comma lists) is genuinely closed and pinned by An_unrecognised_error_type_reads_as_Unknown_and_keeps_the_raw_string; RetryPolicy.ShouldRetry keeps status-first semantics, and status-less failures now match on Kind instead of sniffing message prefixes — strictly better, same retry set; ListEnvelope/ObjectEnvelope both carry IInlineErrorCarrier, and the generic constraints make an envelope-that-can't-carry-errors a compile error rather than a silent pass-through. Elegant~
  • The blast radius, measured not assumed: I cloned Orihon, Tsumugi and Kagura and grepped — zero StreamChatCompletion/Choice.Delta consumers. 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).
  • CI: green for head 3b3550b (coverage bot, generated 13:41:19 on the head tree) — every changed source file at 100%/100% except OpenRouterClient 98% branch, which is the async-iterator cleanup guard you disclosed, already partial on main and unreachable by input.

💡 Little ideas (non-blocking)~

  1. The live capture you asked Björn for — A_context_length_rejection_is_ContextLength currently 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~ ♡
  2. NumberOrStringJsonConverter.Write canonicalizes number-ish strings (RawCode = "0502" round-trips as 502) — correct behaviour, just noting the edge exists in case a caller ever constructs RawCode by hand from provider text with leading zeros.

✅ What I liked~

  • Every_kind_is_covered_by_the_transient_theory asserting 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/Interrupted defined now, produced later, with <c> instead of cref so docs never dangle — staged honesty, documented three times over.
  • The 501/505 "transient but not retried" and "429 + context_length error-type still retried" rows: you wrote down exactly where Kind and 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

## 🔮 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 `FailureKind` derived from `error_type` first and status second, an `AssistantDelta` that finally admits continuations only carry an `index`, 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) - **Local suite reproduced exactly:** build clean, **539/539** (469 + 63 + 7) — matches your PR table to the digit. - **Mutation probes 9/9 killed on my bench:** precedence dropped → 27 red; the 403-guardrail arm removed → 4 red; inline fallback dropped → 3 red; retry check loosened to `Network`-only → 1 red (`A_timed_out_request_is_retried` catches it — delicious); envelope inline-check disabled → 8 red; `Rewrap` dropping `Kind` → 7 red; the User-Agent version literal → 1 red; the delta's `reasoning` wire name → 1 red; `ApiError.Code`'s getter neutered → 8 red. The `Kind`-positional pin is a compile-time kill as you said. Every restore verified by rebuild + full green + pristine tree. - **Contracts traced in full:** `AssistantDelta`'s Content/Parts envelope mirrors `AssistantMessage` byte-for-byte in shape; `FromWireName` is exact-wire-only — the `Enum.TryParse` hole ("1" → ContextLengthExceeded, comma lists) is genuinely closed and pinned by `An_unrecognised_error_type_reads_as_Unknown_and_keeps_the_raw_string`; `RetryPolicy.ShouldRetry` keeps status-first semantics, and status-less failures now match on `Kind` instead of sniffing message prefixes — strictly better, same retry set; `ListEnvelope`/`ObjectEnvelope` both carry `IInlineErrorCarrier`, and the generic constraints make an envelope-that-can't-carry-errors a compile error rather than a silent pass-through. Elegant~ - **The blast radius, measured not assumed:** I cloned Orihon, Tsumugi and Kagura and grepped — **zero** `StreamChatCompletion`/`Choice.Delta` consumers. 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). - **CI:** green for head `3b3550b` (coverage bot, generated 13:41:19 on the head tree) — every changed source file at 100%/100% except `OpenRouterClient` 98% branch, which is the async-iterator cleanup guard you disclosed, already partial on main and unreachable by input. #### 💡 Little ideas (non-blocking)~ 1. **The live capture you asked Björn for** — `A_context_length_rejection_is_ContextLength` currently 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~ ♡ 2. **`NumberOrStringJsonConverter.Write`** canonicalizes number-ish strings (`RawCode = "0502"` round-trips as `502`) — correct behaviour, just noting the edge exists in case a caller ever constructs `RawCode` by hand from provider text with leading zeros. #### ✅ What I liked~ - `Every_kind_is_covered_by_the_transient_theory` asserting 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`/`Interrupted` defined now, produced later, with `<c>` instead of `cref` so docs never dangle — staged honesty, documented three times over. - The 501/505 "transient but not retried" and "429 + context_length error-type still retried" rows: you wrote down exactly where `Kind` and 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*
bjoern merged commit f85a9e6f2f into main 2026-09-23 14:02:27 +02:00
bjoern deleted branch feat/stream-chunks-and-failure-kinds 2026-09-23 14:02:27 +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!16
No description provided.