fix: wire bodies fail cleanly, a bare base URL keeps its last segment, 524/529 retry, and a message sends its role once #15

Merged
bjoern merged 2 commits from fix/wire-robustness into main 2026-09-23 13:14:40 +02:00
Member

Four reported bugs, plus a fifth found while verifying them. Each was reproduced before being fixed, and each has a regression test that failed on main. There are two commits: the four reported fixes first, then the role fix on its own.

1. An out-of-order discriminator escaped Result<T>

A reasoning_details segment whose type was not its first key threw NotSupportedException, and CreateChatCompletionAsync threw instead of returning a result. System.Text.Json only looks for a polymorphic discriminator in first position by default, and NotSupportedException is not a JsonException. Streaming had the same hole: the raw exception came out instead of an OpenRouterException.

  • AllowOutOfOrderMetadataProperties = true on JsonOptions.Default fixes the ordering for all three discriminated types: Message (on role), ContentPart and ReasoningDetail (on type). Bodies are fully buffered before parsing, so the lookahead costs nothing. I checked that ContentPart behaves the same when it goes through the AssistantContent converter.
  • A segment with no discriminator at all still can't be typed. Both parse sites now catch NotSupportedException alongside JsonException: non-streaming returns Failed to deserialize response, streaming throws OpenRouterException("Failed to parse SSE chunk…").

2. A request body that can't be serialized was reported as a network error and retried

PostAsJsonAsync serializes lazily inside the transport, which wraps any failure there in HttpRequestException. Reproduced against a real SocketsHttpHandler, not the mock:

body before after
default(JsonElement) in ProviderFields 3 connections, Network error: An error occurred while sending the request. 0 connections, Failed to serialize request: Operation is not valid due to the current state of the object.
tool Parameters from a disposed JsonDocument 3 connections, Network error: Error while copying content to a stream. 0 connections, Failed to serialize request: Cannot access a disposed object.
Temperature = double.NaN same network-error path Failed to serialize request: …. NaN gets past range validation because every comparison with it is false

The body is now serialized once, before the retry loop. If that fails, nothing is sent and nothing is retried. Streaming throws the same message as an OpenRouterException, matching how it already reports validation errors.

One wire change to note: each attempt now sends a ByteArrayContent with a Content-Length instead of a chunked JsonContent. The header is still application/json; charset=utf-8 (checked on a raw socket capture). Retries now reuse the same bytes instead of serializing again.

3. A base URL without a trailing slash was documented as required but never enforced

Without the slash, URI resolution replaced the last segment: https://openrouter.ai/api/v1 + chat/completions resolved to https://openrouter.ai/api/chat/completions. The client now resolves every route against its base address as if that address ended in /.

I put this in the client rather than normalizing OpenRouterClientOptions for two reasons: it also covers callers who construct OpenRouterClient with their own HttpClient, and it never modifies their instance. Changing BaseAddress throws once a client has sent a request. The BaseUrl doc now says the slash is optional.

Checked live: ListModelsAsync through AddOpenRouter with BaseUrl = https://openrouter.ai/api/v1 returns the catalog (454 models). Tests cover the DI path, a caller-built HttpClient, and every route (models, generation with a query string, endpoints with a : variant, streaming) against a base with a port and a percent-encoded segment.

4. 524 and 529 weren't retried

openapi.json lists both on /chat/completions and /generation:

  • 524: "Infrastructure Timeout - Provider request timed out at edge network", example message "Request timed out. Please try again later."
  • 529: "Provider Overloaded - Provider is temporarily overloaded"

Both are now in the default RetryableStatusCodes. That also covers an inline HTTP 200 error that carries either code. README and XML docs are updated.

5. Every message sent its role twice

A raw socket capture while verifying #2 showed every outgoing message as {"role":"user","Role":"user",…}. Message.Role has [JsonIgnore], but System.Text.Json does not inherit the attribute from an abstract property to its overrides, and none of the five overrides repeated it. OpenRouter tolerated the extra field. A caller serializing our messages under a camelCase policy (ASP.NET's web defaults, for example) got nothing at all: Role became a second role, collided with the discriminator, and serialization threw InvalidOperationException.

  • Each override now carries [JsonIgnore]. The base's doc explains why, and no longer claims Role drives dispatch: the discriminator comes from [JsonDerivedType].
  • The existing tests looked role up by name, which finds it either way. The new test checks the full list of property names for all five subtypes.
  • Messages stored by an earlier version, "Role" included, still deserialize (tested).

After the fix: {"role":"system","content":"s"},{"role":"user","content":[…]},{"role":"assistant","content":"a"}.

Test harness

MockHttpMessageHandler now writes each request body out the way a real transport does, and wraps failures in HttpRequestException the same way. Before, it never read the body, which is how bug 2 went unnoticed. No existing test changed behaviour.

Verification

  • dotnet build: 0 warnings (warnings-as-errors)
  • dotnet test: 324/324 passing (301 before, plus 23 new)

Not in this PR

  • An unknown reasoning type (e.g. a future reasoning.image) still fails the whole completion with Failed to deserialize response. It's a Result failure now, not an escaping exception. Tolerating it would take a hand-written converter and an "unknown segment" subtype.
  • Dart parity: openrouter_dart's default retryableStatusCodes also lacks 524/529.

🤖 Generated with Claude Code

Four reported bugs, plus a fifth found while verifying them. Each was reproduced before being fixed, and each has a regression test that failed on `main`. There are two commits: the four reported fixes first, then the role fix on its own. ## 1. An out-of-order discriminator escaped `Result<T>` A `reasoning_details` segment whose `type` was not its first key threw `NotSupportedException`, and `CreateChatCompletionAsync` threw instead of returning a result. System.Text.Json only looks for a polymorphic discriminator in first position by default, and `NotSupportedException` is not a `JsonException`. Streaming had the same hole: the raw exception came out instead of an `OpenRouterException`. - `AllowOutOfOrderMetadataProperties = true` on `JsonOptions.Default` fixes the ordering for all three discriminated types: `Message` (on `role`), `ContentPart` and `ReasoningDetail` (on `type`). Bodies are fully buffered before parsing, so the lookahead costs nothing. I checked that `ContentPart` behaves the same when it goes through the `AssistantContent` converter. - A segment with **no** discriminator at all still can't be typed. Both parse sites now catch `NotSupportedException` alongside `JsonException`: non-streaming returns `Failed to deserialize response`, streaming throws `OpenRouterException("Failed to parse SSE chunk…")`. ## 2. A request body that can't be serialized was reported as a network error and retried `PostAsJsonAsync` serializes lazily inside the transport, which wraps any failure there in `HttpRequestException`. Reproduced against a real `SocketsHttpHandler`, not the mock: | body | before | after | |---|---|---| | `default(JsonElement)` in `ProviderFields` | 3 connections, `Network error: An error occurred while sending the request.` | 0 connections, `Failed to serialize request: Operation is not valid due to the current state of the object.` | | tool `Parameters` from a disposed `JsonDocument` | 3 connections, `Network error: Error while copying content to a stream.` | 0 connections, `Failed to serialize request: Cannot access a disposed object.` | | `Temperature = double.NaN` | same network-error path | `Failed to serialize request: …`. NaN gets past range validation because every comparison with it is false | The body is now serialized once, before the retry loop. If that fails, nothing is sent and nothing is retried. Streaming throws the same message as an `OpenRouterException`, matching how it already reports validation errors. **One wire change to note:** each attempt now sends a `ByteArrayContent` with a `Content-Length` instead of a chunked `JsonContent`. The header is still `application/json; charset=utf-8` (checked on a raw socket capture). Retries now reuse the same bytes instead of serializing again. ## 3. A base URL without a trailing slash was documented as required but never enforced Without the slash, URI resolution replaced the last segment: `https://openrouter.ai/api/v1` + `chat/completions` resolved to `https://openrouter.ai/api/chat/completions`. The client now resolves every route against its base address as if that address ended in `/`. I put this in the client rather than normalizing `OpenRouterClientOptions` for two reasons: it also covers callers who construct `OpenRouterClient` with their own `HttpClient`, and it never modifies their instance. Changing `BaseAddress` throws once a client has sent a request. The `BaseUrl` doc now says the slash is optional. Checked live: `ListModelsAsync` through `AddOpenRouter` with `BaseUrl = https://openrouter.ai/api/v1` returns the catalog (454 models). Tests cover the DI path, a caller-built `HttpClient`, and every route (models, generation with a query string, endpoints with a `:` variant, streaming) against a base with a port and a percent-encoded segment. ## 4. 524 and 529 weren't retried `openapi.json` lists both on `/chat/completions` and `/generation`: - `524`: "Infrastructure Timeout - Provider request timed out at edge network", example message "Request timed out. Please try again later." - `529`: "Provider Overloaded - Provider is temporarily overloaded" Both are now in the default `RetryableStatusCodes`. That also covers an inline HTTP 200 error that carries either code. README and XML docs are updated. ## 5. Every message sent its role twice A raw socket capture while verifying #2 showed every outgoing message as `{"role":"user","Role":"user",…}`. `Message.Role` has `[JsonIgnore]`, but System.Text.Json does not inherit the attribute from an abstract property to its overrides, and none of the five overrides repeated it. OpenRouter tolerated the extra field. A caller serializing our messages under a camelCase policy (ASP.NET's web defaults, for example) got nothing at all: `Role` became a second `role`, collided with the discriminator, and serialization threw `InvalidOperationException`. - Each override now carries `[JsonIgnore]`. The base's doc explains why, and no longer claims `Role` drives dispatch: the discriminator comes from `[JsonDerivedType]`. - The existing tests looked `role` up by name, which finds it either way. The new test checks the full list of property names for all five subtypes. - Messages stored by an earlier version, `"Role"` included, still deserialize (tested). After the fix: `{"role":"system","content":"s"},{"role":"user","content":[…]},{"role":"assistant","content":"a"}`. ## Test harness `MockHttpMessageHandler` now writes each request body out the way a real transport does, and wraps failures in `HttpRequestException` the same way. Before, it never read the body, which is how bug 2 went unnoticed. No existing test changed behaviour. ## Verification - `dotnet build`: 0 warnings (warnings-as-errors) - `dotnet test`: 324/324 passing (301 before, plus 23 new) ## Not in this PR - **An unknown reasoning `type`** (e.g. a future `reasoning.image`) still fails the whole completion with `Failed to deserialize response`. It's a Result failure now, not an escaping exception. Tolerating it would take a hand-written converter and an "unknown segment" subtype. - **Dart parity:** `openrouter_dart`'s default `retryableStatusCodes` also lacks 524/529. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix: bodies that can't be read or written fail cleanly, a bare base URL keeps its last segment, and 524/529 retry
All checks were successful
CI / build (pull_request) Successful in 13s
CI / test (pull_request) Successful in 22s
49e350a7af
A reasoning segment whose `type` was not its first key threw NotSupportedException: by default
System.Text.Json looks for a polymorphic discriminator only in first position. That is not a
JsonException, so it escaped `Result<T>` from `CreateChatCompletionAsync` and escaped the streaming
path's OpenRouterException wrapping too. `AllowOutOfOrderMetadataProperties` on the shared options
fixes the ordering for all three discriminated types (Message on `role`, ContentPart and
ReasoningDetail on `type`); every body is fully buffered first, so the lookahead costs nothing. A
segment with no discriminator at all still cannot be typed, so both parse sites now catch
NotSupportedException beside JsonException and report it the way they report any malformed body.

A request body that could not be serialized was reported as `Network error` and retried.
`PostAsJsonAsync` serializes lazily inside the transport, which wraps any failure there in
HttpRequestException. Reproduced against a real SocketsHttpHandler: a `default(JsonElement)` in
`ProviderFields` or a tool schema from a disposed JsonDocument opened three connections and came back
as "Error while copying content to a stream." The body is now serialized once, before the retry loop.
If that fails, the call returns `Failed to serialize request: …` (streaming throws it as an
OpenRouterException) and nothing is sent. A NaN sampling parameter takes the same path: it passes range
validation because every comparison with NaN is false. Each attempt now sends the same bytes with a
Content-Length instead of re-serializing a chunked body.

`OpenRouterClientOptions.BaseUrl` documented its trailing slash as required and never checked it, and
without one, URI resolution silently replaced the last segment: `…/api/v1` + `chat/completions` became
`…/api/chat/completions`. The client now resolves every route against its base address as if that
address ended in `/`. Doing it in the client rather than the options also covers a caller who builds
the HttpClient themselves, and it leaves their instance unmodified. Checked live: `ListModelsAsync`
through `AddOpenRouter` with `https://openrouter.ai/api/v1` returns the catalog.

openapi.json lists 524 (edge timeout) and 529 (provider overloaded) on /chat/completions and
/generation, both described as temporary. They are now in the default `RetryableStatusCodes`, which
also covers an inline HTTP 200 error carrying either code.

The test mock now writes each request body out the way a real transport does and wraps failures in
HttpRequestException the same way. Before, it never read the body, which is how the second bug went
unnoticed.

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

Summary

Summary
Generated on: 09/23/2026 - 10:53:56
Parser: MultiReport (3x Cobertura)
Assemblies: 3
Classes: 75
Files: 68
Line coverage: 84.6% (1843 of 2178)
Covered lines: 1843
Uncovered lines: 335
Coverable lines: 2178
Total lines: 5532
Branch coverage: 73.2% (836 of 1142)
Covered branches: 836
Total branches: 1142
Method coverage: Feature is only available for sponsors

Coverage

OpenRouter.Net - 87.6%
Name Line Branch
OpenRouter.Net 87.6% 77.2%
OpenRouter.Net.Client.CachingOpenRouterClient 73.3% 81.2%
OpenRouter.Net.Client.OpenRouterClient 94.1% 76.7%
OpenRouter.Net.Client.OpenRouterClient<TRequest, TResponse> 94.1% 76.7%
OpenRouter.Net.Client.OpenRouterClient 94.1% 76.7%
OpenRouter.Net.Client.OpenRouterClientOptions 100%
OpenRouter.Net.Client.OpenRouterRequestLoggingHandler 27.2% 18.7%
OpenRouter.Net.Client.RetryOptions 91.6% 50%
OpenRouter.Net.Extensions.ServiceCollectionExtensions 82.1% 85.7%
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.JsonOptions 100%
OpenRouter.Net.Internal.ListEnvelope 100%
OpenRouter.Net.Internal.ModelIdJsonConverter 100% 50%
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 90.1% 88%
OpenRouter.Net.Internal.ToolChoiceJsonConverter 61.7% 46.1%
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%
OpenRouter.Net.Models.Common.Result.Failure 100%
OpenRouter.Net.Models.Common.Result.Success 100%
OpenRouter.Net.Models.Common.Result 53.8% 30%
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.ChatCompletionChunk 0% 0%
OpenRouter.Net.Models.Responses.ChatCompletionResponse 0% 0%
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 - 10:53:56 | | Parser: | MultiReport (3x Cobertura) | | Assemblies: | 3 | | Classes: | 75 | | Files: | 68 | | **Line coverage:** | 84.6% (1843 of 2178) | | Covered lines: | 1843 | | Uncovered lines: | 335 | | Coverable lines: | 2178 | | Total lines: | 5532 | | **Branch coverage:** | 73.2% (836 of 1142) | | Covered branches: | 836 | | Total branches: | 1142 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>OpenRouter.Net - 87.6%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net**|**87.6%**|**77.2%**| |OpenRouter.Net.Client.CachingOpenRouterClient|73.3%|81.2%| |OpenRouter.Net.Client.OpenRouterClient|94.1%|76.7%| |OpenRouter.Net.Client.OpenRouterClient<TRequest, TResponse>|94.1%|76.7%| |OpenRouter.Net.Client.OpenRouterClient<TResponse>|94.1%|76.7%| |OpenRouter.Net.Client.OpenRouterClientOptions|100%|| |OpenRouter.Net.Client.OpenRouterRequestLoggingHandler|27.2%|18.7%| |OpenRouter.Net.Client.RetryOptions|91.6%|50%| |OpenRouter.Net.Extensions.ServiceCollectionExtensions|82.1%|85.7%| |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.JsonOptions|100%|| |OpenRouter.Net.Internal.ListEnvelope<T>|100%|| |OpenRouter.Net.Internal.ModelIdJsonConverter|100%|50%| |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>|90.1%|88%| |OpenRouter.Net.Internal.ToolChoiceJsonConverter|61.7%|46.1%| |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%|| |OpenRouter.Net.Models.Common.Result.Failure<T>|100%|| |OpenRouter.Net.Models.Common.Result.Success<T>|100%|| |OpenRouter.Net.Models.Common.Result<T>|53.8%|30%| |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.ChatCompletionChunk|0%|0%| |OpenRouter.Net.Models.Responses.ChatCompletionResponse|0%|0%| |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>
fix: a message sends its role once
All checks were successful
CI / build (pull_request) Successful in 13s
CI / test (pull_request) Successful in 25s
e139ddfe04
Every outgoing message carried a second role field: `{"role":"user","Role":"user",…}`. `Message.Role`
has `[JsonIgnore]`, but System.Text.Json does not inherit the attribute from an abstract property to
its overrides, and none of the five overrides repeated it. So each subtype serialized its enum as
`"Role"` beside the discriminator. OpenRouter tolerated it. A caller serializing messages under a
camelCase policy (ASP.NET's web defaults, for example) got nothing at all: `Role` became a second
`role`, collided with the discriminator, and System.Text.Json refused the type with
InvalidOperationException.

Each override now carries `[JsonIgnore]`. The base's doc says why, and no longer claims the property
drives dispatch (the discriminator comes from `[JsonDerivedType]`). The existing tests looked `role`
up by name, which finds it either way, so the new test checks the full list of property names for all
five subtypes. Messages stored by an earlier version, `"Role"` included, still deserialize.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
scarlet changed title from fix: bodies that can't be read or written fail cleanly, a bare base URL keeps its last segment, and 524/529 retry to fix: wire bodies fail cleanly, a bare base URL keeps its last segment, 524/529 retry, and a message sends its role once 2026-09-23 12:53:32 +02:00
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh my~ Four bugs, four reproductions, four regression suites, and a test harness honest enough to confess how it let bug #2 slip past it. This is the kind of PR I want to sink my teeth into — so I did. Six mutation probes deep~ ♡

Verdict: ✅ Looks good to me~

⛔ These need fixing before I'm satisfied~

None. Fufu~ you knew I'd go looking, didn't you? I brought knives and found cake.

💡 Little ideas (non-blocking)~

  1. OpenRouterClient.cs:262 — the return route arm of Endpoint() (no BaseAddress set) is the one new branch no test exercises; my coverage run shows line 262 at zero hits. It's a behavior-preserving passthrough (HttpClient then complains about the relative URI, exactly as on main), and the comment says so — but a one-line test pinning "caller error, not library crash" would keep that contract from drifting. Purely a nicety ♪

✅ What I liked~

  • Every fix is load-bearing and I proved it. Six mutations, each red on exactly its own tests, nothing else: AllowOutOfOrderMetadataProperties removed → 7 out-of-order pins red; 524/529 dropped from the default set → exactly the 3 retry tests red; serialize-once reverted to lazy → exactly the 3 serialization tests red (and the hardened mock catches it — beautiful); naive base resolution restored → exactly the 3 BaseUrl tests red; each NotSupportedException catch dropped separately → exactly its own test red. No tautologies anywhere~
  • The mock hardening is the real star. A mock that never reads the body was why bug #2 survived; LoadIntoBufferAsync + HttpRequestException wrapping makes the mock tell the truth about transport-shaped failures. Tests that test the harness's honesty — fufu~ ♡
  • Sibling tracing holds. JsonOptions.Default is shared by all three discriminated types (Message on role, ContentPart and ReasoningDetail on type), and the hand-written AssistantContentJsonConverter delegates to IReadOnlyList<ContentPart> with those same options — so the out-of-order fix genuinely reaches the converter path too.
  • The NaN claim is true and subtle: Temperature is < 0 or > 2 — both comparisons false for NaN, so it sails past Validate. I read the guard with my own eyes.
  • 524/529 verified against the live openapi.json — both listed on /chat/completions (and my probe proves the inline-529-on-200 path retries through the status-code set, not just the HTTP status).
  • The wire change is disclosed, not smuggled: ByteArrayContent with Content-Length instead of chunked JsonContent, fresh content per attempt over the same bytes (the per-attempt JsonBody(json) in the retry lambda is exactly right — HttpContent must never be reused).
  • The "Not in this PR" section naming the stray "Role" (pre-existing, every-request blast radius — right call to defer), the unknown-reasoning-type tolerance, and the Dart parity gap. Disciplined scoping is love~
  • Local: build 0w/0e under warnings-as-errors, 321/321 matching the PR body exactly; clone left pristine at 49e350a after all probes.

Merge it before I change my mind about sharing~ fufufu ♡


Automated review by Jibril · 2026-09-23
CI/CD: passed for head SHA 49e350a (build + test, run #34) · Local checks: 321/321, 6/6 mutation probes red-on-target, coverage re-run on changed file

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh my~ Four bugs, four reproductions, four regression suites, and a test harness honest enough to confess how it let bug #2 slip past it. This is the kind of PR I want to sink my teeth into — so I did. Six mutation probes deep~ ♡ ### Verdict: ✅ Looks good to me~ #### ⛔ These need fixing before I'm satisfied~ None. Fufu~ you knew I'd go looking, didn't you? I brought knives and found cake. #### 💡 Little ideas (non-blocking)~ 1. **OpenRouterClient.cs:262** — the `return route` arm of `Endpoint()` (no `BaseAddress` set) is the one new branch no test exercises; my coverage run shows line 262 at zero hits. It's a behavior-preserving passthrough (HttpClient then complains about the relative URI, exactly as on `main`), and the comment says so — but a one-line test pinning "caller error, not library crash" would keep that contract from drifting. Purely a nicety ♪ #### ✅ What I liked~ - **Every fix is load-bearing and I proved it.** Six mutations, each red on exactly its own tests, nothing else: `AllowOutOfOrderMetadataProperties` removed → 7 out-of-order pins red; 524/529 dropped from the default set → exactly the 3 retry tests red; serialize-once reverted to lazy → exactly the 3 serialization tests red (and the hardened mock catches it — beautiful); naive base resolution restored → exactly the 3 BaseUrl tests red; each `NotSupportedException` catch dropped separately → exactly its own test red. No tautologies anywhere~ - **The mock hardening is the real star.** A mock that never reads the body was *why* bug #2 survived; `LoadIntoBufferAsync` + `HttpRequestException` wrapping makes the mock tell the truth about transport-shaped failures. Tests that test the harness's honesty — fufu~ ♡ - **Sibling tracing holds.** `JsonOptions.Default` is shared by all three discriminated types (Message on `role`, ContentPart and ReasoningDetail on `type`), and the hand-written `AssistantContentJsonConverter` delegates to `IReadOnlyList<ContentPart>` with those same options — so the out-of-order fix genuinely reaches the converter path too. - **The NaN claim is true and subtle**: `Temperature is < 0 or > 2` — both comparisons false for NaN, so it sails past `Validate`. I read the guard with my own eyes. - **524/529 verified against the live `openapi.json`** — both listed on `/chat/completions` (and my probe proves the inline-529-on-200 path retries through the status-code set, not just the HTTP status). - **The wire change is disclosed, not smuggled**: `ByteArrayContent` with `Content-Length` instead of chunked `JsonContent`, fresh content per attempt over the same bytes (the per-attempt `JsonBody(json)` in the retry lambda is exactly right — `HttpContent` must never be reused). - **The "Not in this PR" section** naming the stray `"Role"` (pre-existing, every-request blast radius — right call to defer), the unknown-reasoning-type tolerance, and the Dart parity gap. Disciplined scoping is love~ - Local: build 0w/0e under warnings-as-errors, **321/321** matching the PR body exactly; clone left pristine at `49e350a` after all probes. Merge it before I change my mind about sharing~ fufufu ♡ --- *Automated review by Jibril · 2026-09-23* *CI/CD: passed for head SHA `49e350a` (build + test, run #34) · Local checks: 321/321, 6/6 mutation probes red-on-target, coverage re-run on changed file*
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh! Five bugs, each reproduced before fixing, each pinned by a regression test that failed on main, and a test harness honest enough to confess it was never reading request bodies~ This is exactly how wire-robustness work should be done. I read the full diff, every changed file whole, and traced all five fixes against their siblings. Then I got possessive and probed them. ♡

Verdict: ✅ Looks good to me~

Mutation probes — 6/6 red on exactly their own tests (each restored, rebuilt, and the tree re-verified pristine at e139ddf):

  1. 524/529 removed from RetryableStatusCodes → 3 red (the new Theory's two arms + the inline-HTTP-200 error test). The inline path matters and is genuinely covered: ParseResponseAsync propagates inlineError.Code into Failure.StatusCode, so ShouldRetry sees the 529 either way. Traced, not assumed~
  2. AllowOutOfOrderMetadataProperties deleted → 7 red. And I checked the siblings: AssistantContentJsonConverter and ContentPartListConverter both delegate to STJ's polymorphic reader, so Message/ContentPart/ReasoningDetail all benefit equally — no converter holds a first-key assumption.
  3. [JsonIgnore] stripped from just AssistantMessage.Role → 2 red. Non-inheritance of JsonIgnore over abstract→override properties is a genuinely sneaky little trap, and asserting on the full list of property names (a name lookup finds role either way) is the only kind of test that could catch it. Fufu~, well played.
  4. Endpoint() reverted to plain new Uri(baseAddress, route) → 3 red. The all-routes test (models, generation with query string, endpoints with : variant, streaming, port + percent-encoded segment) is thorough, and resolving in the client rather than normalizing the options so caller-built HttpClients are covered too — that's the right layer.
  5. Pre-serialization moved back inside the retry loop (PostAsJsonAsync again) → 3 red (all three RequestSerializationCases). The mock now doing LoadIntoBufferAsync is what makes these honest — a mock that never reads the body hides exactly this bug class, as you said.
  6. Both NotSupportedException catches dropped → 2 red (no-discriminator failure + streamed variant). No type-less segment escapes Result<T> anymore.

Numbers, verified not trusted: full solution 324/324 locally (301 + exactly 23 new cases — I counted: 4 + 2 + 6 + 3 + 5 + 3), matching the PR body's arithmetic precisely. Line coverage on OpenRouterClient at head: 68/70 — the only untested new line is the null-BaseAddress fallback in Endpoint() (see ideas). CI green for e139ddf (build ✓ 12:53:30, test ✓ 12:53:58), coverage bot regenerated for this head.

💡 Little ideas (non-blocking)~

  1. OpenRouterClient.cs:260 — the BaseAddress is null arm of Endpoint() is the one new line no test exercises. Its behavior matches the old code exactly (relative URI passes through and HttpClient reports what's missing), and DI always sets a base address — purely a pin-it-when-convenient, not a hole with teeth.
  2. Dart parity — already disclosed in the PR body, but let it be written here too: openrouter_dart's default retryableStatusCodes still lacks 524/529. Same bug, other body~ ♪

✅ What I liked~

  • Bodies serialized once, before the retry loop. Fail fast with Failed to serialize request: …, zero connections, zero retries — and retries reusing the same bytes instead of re-serializing is a small free performance win on top of the correctness fix.
  • The Message.Role doc rewrite. The old doc claimed Role drove dispatch; the new one explains the discriminator comes from [JsonDerivedType] and documents the non-inheritance trap for the next person. Docs that teach the bug they just fixed are my favorite kind~
  • Back-compat test for the "Role"-bearing persisted messages — most PRs fix the wire and forget the conversations already on disk. You didn't. ♡
  • The NaN case. Realizing NaN slips past every range comparison and then building a dedicated serialization case for it — that's the knowledge-obsessed attention to detail I live for~

Five bugs, five honest reproductions, twenty-three directional pins, and a mock that finally tells the truth. Nothing left for me to be scary about~ Ready to merge. ♡


Automated review by Jibril · 2026-09-23
CI/CD: passed for head SHA e139ddf (build + test) · Local checks: full suite 324/324, coverage parsed, 6/6 mutation probes red-on-target, tree pristine at e139ddf

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh! Five bugs, each reproduced before fixing, each pinned by a regression test that failed on `main`, and a test harness honest enough to confess it was never reading request bodies~ This is exactly how wire-robustness work should be done. I read the full diff, every changed file whole, and traced all five fixes against their siblings. Then I got *possessive* and probed them. ♡ ### Verdict: ✅ Looks good to me~ **Mutation probes — 6/6 red on exactly their own tests** (each restored, rebuilt, and the tree re-verified pristine at `e139ddf`): 1. **524/529** removed from `RetryableStatusCodes` → **3 red** (the new Theory's two arms + the inline-HTTP-200 error test). The inline path matters and is genuinely covered: `ParseResponseAsync` propagates `inlineError.Code` into `Failure.StatusCode`, so `ShouldRetry` sees the 529 either way. Traced, not assumed~ 2. **`AllowOutOfOrderMetadataProperties`** deleted → **7 red**. And I checked the siblings: `AssistantContentJsonConverter` and `ContentPartListConverter` both delegate to STJ's polymorphic reader, so `Message`/`ContentPart`/`ReasoningDetail` all benefit equally — no converter holds a first-key assumption. 3. **`[JsonIgnore]`** stripped from just `AssistantMessage.Role` → **2 red**. Non-inheritance of `JsonIgnore` over abstract→override properties is a genuinely sneaky little trap, and asserting on the full list of property names (a name lookup finds `role` either way) is the only kind of test that could catch it. Fufu~, well played. 4. **`Endpoint()`** reverted to plain `new Uri(baseAddress, route)` → **3 red**. The all-routes test (models, generation with query string, endpoints with `:` variant, streaming, port + percent-encoded segment) is thorough, and resolving in the client rather than normalizing the options so caller-built `HttpClient`s are covered too — that's the right layer. 5. **Pre-serialization** moved back inside the retry loop (`PostAsJsonAsync` again) → **3 red** (all three `RequestSerializationCases`). The mock now doing `LoadIntoBufferAsync` is what makes these honest — a mock that never reads the body hides exactly this bug class, as you said. 6. **Both `NotSupportedException` catches** dropped → **2 red** (no-discriminator failure + streamed variant). No type-less segment escapes `Result<T>` anymore. **Numbers, verified not trusted**: full solution **324/324** locally (301 + exactly 23 new cases — I counted: 4 + 2 + 6 + 3 + 5 + 3), matching the PR body's arithmetic precisely. Line coverage on `OpenRouterClient` at head: 68/70 — the only untested *new* line is the null-`BaseAddress` fallback in `Endpoint()` (see ideas). CI green for `e139ddf` (build ✓ 12:53:30, test ✓ 12:53:58), coverage bot regenerated for this head. #### 💡 Little ideas (non-blocking)~ 1. **OpenRouterClient.cs:260** — the `BaseAddress is null` arm of `Endpoint()` is the one new line no test exercises. Its behavior matches the old code exactly (relative URI passes through and `HttpClient` reports what's missing), and DI always sets a base address — purely a pin-it-when-convenient, not a hole with teeth. 2. **Dart parity** — already disclosed in the PR body, but let it be written here too: `openrouter_dart`'s default `retryableStatusCodes` still lacks 524/529. Same bug, other body~ ♪ #### ✅ What I liked~ - **Bodies serialized once, before the retry loop.** Fail fast with `Failed to serialize request: …`, zero connections, zero retries — and retries reusing the same bytes instead of re-serializing is a small free performance win on top of the correctness fix. - **The `Message.Role` doc rewrite.** The old doc *claimed* `Role` drove dispatch; the new one explains the discriminator comes from `[JsonDerivedType]` and documents the non-inheritance trap for the next person. Docs that teach the bug they just fixed are my favorite kind~ - **Back-compat test for the `"Role"`-bearing persisted messages** — most PRs fix the wire and forget the conversations already on disk. You didn't. ♡ - **The `NaN` case.** Realizing NaN slips past every range comparison and then building a dedicated serialization case for it — that's the knowledge-obsessed attention to detail I live for~ Five bugs, five honest reproductions, twenty-three directional pins, and a mock that finally tells the truth. Nothing left for me to be scary about~ Ready to merge. ♡ --- *Automated review by Jibril · 2026-09-23* *CI/CD: passed for head SHA e139ddf (build + test) · Local checks: full suite 324/324, coverage parsed, 6/6 mutation probes red-on-target, tree pristine at e139ddf*
bjoern merged commit ec7662342b into main 2026-09-23 13:14:40 +02:00
bjoern deleted branch fix/wire-robustness 2026-09-23 13:14:40 +02:00
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!15
No description provided.