fix: a reasoning segment without its payload no longer fails the completion #9

Merged
bjoern merged 2 commits from fix/reasoning-detail-optional-payloads into main 2026-07-30 12:12:16 +02:00
Member

A field report from Orihon, running google/gemini-3.5-flash: every affected agent round died on

The provider call failed: Failed to deserialize response: JSON deserialization for type
'OpenRouter.Net.Models.Requests.ReasoningTextDetail' was missing required properties including: 'text'.

…and since the response is unparseable, retry-with-distrust burns all three attempts on the same wall. Three pages of a book lost their boxing pass to it.

The cause

ReasoningTextDetail.Text was required. Gemini sends reasoning.text segments that carry only the metadata around a thought whose plaintext it withholds — an index, and often a signature this library does not model — with no text property at all. required means "must be present on the wire", so the whole ChatCompletionResponse fails to deserialize, and a completion the caller would have been perfectly happy with is thrown away over its commentary.

ReasoningSummaryDetail.Summary and ReasoningEncryptedDetail.Data had the same shape and the same exposure, so all three are fixed together rather than waiting for the next provider to find them.

The change

The three payload fields become optional (string? instead of required string). Nothing else moves.

The round-trip stays honest for free: JsonOptions.Default already sets DefaultIgnoreCondition = WhenWritingNull, so a segment that arrived without a payload is echoed back without one. These segments are passed back to the provider on the next turn to preserve thinking state, and inventing a "text": null that never arrived would be a different kind of wrong.

The alternative — keeping required and tolerating the failure at the parse site — would mean either dropping whole responses or hand-rolling a converter for a field that is, by the vendor's own contract, optional. The type was simply stricter than the API.

Tests

+6 → 165 total, all green (Tests 103 · Agents 55 · Imaging 7), 0 warnings / 0 errors.

  • A_segment_whose_payload_the_provider_withheld_still_deserializes — four wire shapes: reasoning.text with nothing but an index, the same with Gemini's signature alongside it, and the summary and encrypted variants missing theirs. All parse, all leave the payload null.
  • A_withheld_payload_is_omitted_again_on_the_way_back_out — absent in, absent out, with the discriminator intact. (Asserted on "text": rather than text, since the discriminator reasoning.text contains the word.)
  • A_completion_survives_a_reasoning_segment_with_no_textthe field failure at the level it failed: a whole ChatCompletionResponse carrying one such segment, parsing to its content, its finish_reason, and a reasoning detail whose Text is null. This is the test that would have caught the bug; the three existing per-subtype round-trips could not, because each one constructs the payload it then asserts on.

Not in this PR

Gemini's signature on a reasoning segment is currently dropped — it deserializes fine now (unmapped properties are ignored) but is not carried back on the next turn, so any thinking state it anchors is lost. Worth a follow-up: either an explicit Signature property, or [JsonExtensionData] on ReasoningDetail so unknown fields survive a round-trip generically. I have not verified what Gemini does when a signature it issued comes back missing, so I am not guessing at it here.

🤖 Generated with Claude Code

A field report from Orihon, running `google/gemini-3.5-flash`: every affected agent round died on ``` The provider call failed: Failed to deserialize response: JSON deserialization for type 'OpenRouter.Net.Models.Requests.ReasoningTextDetail' was missing required properties including: 'text'. ``` …and since the response is unparseable, retry-with-distrust burns all three attempts on the same wall. Three pages of a book lost their boxing pass to it. ## The cause `ReasoningTextDetail.Text` was `required`. Gemini sends `reasoning.text` segments that carry only the metadata around a thought whose plaintext it withholds — an `index`, and often a `signature` this library does not model — with **no `text` property at all**. `required` means "must be present on the wire", so the whole `ChatCompletionResponse` fails to deserialize, and a completion the caller would have been perfectly happy with is thrown away over its commentary. `ReasoningSummaryDetail.Summary` and `ReasoningEncryptedDetail.Data` had the same shape and the same exposure, so all three are fixed together rather than waiting for the next provider to find them. ## The change The three payload fields become optional (`string?` instead of `required string`). Nothing else moves. The round-trip stays honest for free: `JsonOptions.Default` already sets `DefaultIgnoreCondition = WhenWritingNull`, so a segment that arrived without a payload is echoed back **without** one. These segments are passed back to the provider on the next turn to preserve thinking state, and inventing a `"text": null` that never arrived would be a different kind of wrong. The alternative — keeping `required` and tolerating the failure at the parse site — would mean either dropping whole responses or hand-rolling a converter for a field that is, by the vendor's own contract, optional. The type was simply stricter than the API. ## Tests **+6 → 165 total, all green** (Tests 103 · Agents 55 · Imaging 7), 0 warnings / 0 errors. - `A_segment_whose_payload_the_provider_withheld_still_deserializes` — four wire shapes: `reasoning.text` with nothing but an index, the same **with Gemini's `signature`** alongside it, and the summary and encrypted variants missing theirs. All parse, all leave the payload null. - `A_withheld_payload_is_omitted_again_on_the_way_back_out` — absent in, absent out, with the discriminator intact. (Asserted on `"text":` rather than `text`, since the discriminator `reasoning.text` contains the word.) - `A_completion_survives_a_reasoning_segment_with_no_text` — **the field failure at the level it failed**: a whole `ChatCompletionResponse` carrying one such segment, parsing to its content, its `finish_reason`, and a reasoning detail whose `Text` is null. This is the test that would have caught the bug; the three existing per-subtype round-trips could not, because each one constructs the payload it then asserts on. ## Not in this PR Gemini's `signature` on a reasoning segment is currently **dropped** — it deserializes fine now (unmapped properties are ignored) but is not carried back on the next turn, so any thinking state it anchors is lost. Worth a follow-up: either an explicit `Signature` property, or `[JsonExtensionData]` on `ReasoningDetail` so unknown fields survive a round-trip generically. I have not verified what Gemini does when a signature it issued comes back missing, so I am not guessing at it here. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix: a reasoning segment without its payload no longer fails the completion
All checks were successful
CI / build (pull_request) Successful in 12s
CI / test (pull_request) Successful in 21s
b9ecc8d9cf
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Summary

Summary
Generated on: 07/30/2026 - 10:11:26
Coverage date: 07/30/2026 - 10:11:22 - 07/30/2026 - 10:11:24
Parser: MultiReport (3x Cobertura)
Assemblies: 3
Classes: 106
Files: 106
Line coverage: 76.3% (1430 of 1872)
Covered lines: 1430
Uncovered lines: 442
Coverable lines: 1872
Total lines: 5474
Branch coverage: 65.1% (485 of 744)
Covered branches: 485
Total branches: 744
Method coverage: Feature is only available for sponsors

Coverage

OpenRouter.Net - 69.9%
Name Line Branch
OpenRouter.Net 69.9% 64.5%
OpenRouter.Net.Client.CachingOpenRouterClient 72.4% 81.2%
OpenRouter.Net.Client.OpenRouterClient 87.2% 72.9%
OpenRouter.Net.Client.OpenRouterClientOptions 0%
OpenRouter.Net.Client.OpenRouterRequestLoggingHandler 27.2% 7.1%
OpenRouter.Net.Client.RetryOptions 80% 0%
OpenRouter.Net.Extensions.ServiceCollectionExtensions 0% 0%
OpenRouter.Net.Internal.ApiErrorEnvelope 100%
OpenRouter.Net.Internal.AssistantContent 92.8% 75%
OpenRouter.Net.Internal.AssistantContentJsonConverter 75% 64.2%
OpenRouter.Net.Internal.ContentPartListConverter 85.7% 66.6%
OpenRouter.Net.Internal.ErrorParser 92.3% 62.5%
OpenRouter.Net.Internal.JsonOptions 100%
OpenRouter.Net.Internal.ListEnvelope`1 100%
OpenRouter.Net.Internal.ObjectEnvelope`1 100%
OpenRouter.Net.Internal.RetryPolicy 90.9% 84.7%
OpenRouter.Net.Internal.SseEventReader 91.6% 83.3%
OpenRouter.Net.Internal.TolerantStringEnumConverter`1 90.1% 80%
OpenRouter.Net.Internal.ToolChoiceJsonConverter 61.7% 46.1%
OpenRouter.Net.Models.Common.ApiError 100%
OpenRouter.Net.Models.Common.CacheControl 100%
OpenRouter.Net.Models.Common.Result 100%
OpenRouter.Net.Models.Common.Result`1 60% 30%
OpenRouter.Net.Models.Content.AudioPart 100%
OpenRouter.Net.Models.Content.ImagePart 100%
OpenRouter.Net.Models.Content.ImageUrl 100%
OpenRouter.Net.Models.Content.InputAudio 100%
OpenRouter.Net.Models.Content.TextPart 100%
OpenRouter.Net.Models.Messages.AssistantMessage 100% 70%
OpenRouter.Net.Models.Messages.DeveloperMessage 0%
OpenRouter.Net.Models.Messages.SystemMessage 57.1%
OpenRouter.Net.Models.Messages.ToolMessage 54.5%
OpenRouter.Net.Models.Messages.UserMessage 100%
OpenRouter.Net.Models.Requests.ChatCompletionRequest 92.6% 58.3%
OpenRouter.Net.Models.Requests.FunctionCall 100%
OpenRouter.Net.Models.Requests.FunctionDefinition 75%
OpenRouter.Net.Models.Requests.JsonSchemaSpec 0%
OpenRouter.Net.Models.Requests.ProviderPreferences 0%
OpenRouter.Net.Models.Requests.ReasoningEncryptedDetail 100%
OpenRouter.Net.Models.Requests.ReasoningOptions 100%
OpenRouter.Net.Models.Requests.ReasoningSummaryDetail 100%
OpenRouter.Net.Models.Requests.ReasoningTextDetail 100%
OpenRouter.Net.Models.Requests.ResponseFormat 0%
OpenRouter.Net.Models.Requests.ToolCall 75%
OpenRouter.Net.Models.Requests.ToolChoice 100%
OpenRouter.Net.Models.Requests.ToolDefinition 100%
OpenRouter.Net.Models.Requests.UsageOptions 100%
OpenRouter.Net.Models.Responses.ChatCompletionChunk 44.4% 0%
OpenRouter.Net.Models.Responses.ChatCompletionResponse 80% 0%
OpenRouter.Net.Models.Responses.Choice 93.7% 100%
OpenRouter.Net.Models.Responses.CompletionTokensDetails 0%
OpenRouter.Net.Models.Responses.CreditsInfo 100%
OpenRouter.Net.Models.Responses.GenerationInfo 9%
OpenRouter.Net.Models.Responses.KeyInfo 0%
OpenRouter.Net.Models.Responses.ModelArchitecture 20%
OpenRouter.Net.Models.Responses.ModelEndpointsResponse 0%
OpenRouter.Net.Models.Responses.ModelInfo 36.3%
OpenRouter.Net.Models.Responses.ModelPricing 0%
OpenRouter.Net.Models.Responses.ModelReasoning 100%
OpenRouter.Net.Models.Responses.PromptTokensDetails 0%
OpenRouter.Net.Models.Responses.ProviderEndpoint 0%
OpenRouter.Net.Models.Responses.RateLimit 0%
OpenRouter.Net.Models.Responses.TopProvider 0%
OpenRouter.Net.Models.Responses.Usage 66.6%
OpenRouter.Net.OpenRouterException 83.3%
System.Text.RegularExpressions.Generated 83.3% 57.6%
System.Text.RegularExpressions.Generated.<RegexGenerator_g>FFC6B051A15CDCE5
43F954A3762D43C2CD5DCD5CDF29C19D3789DC49A7DCA1C47__Base64DataUriPattern_0
82% 58.3%
OpenRouter.Net.Agents - 82.1%
Name Line Branch
OpenRouter.Net.Agents 82.1% 66.4%
OpenRouter.Net.Agents.Agent 92.6% 79.5%
OpenRouter.Net.Agents.AgentCompletedEvent 100%
OpenRouter.Net.Agents.AgentEvent 100%
OpenRouter.Net.Agents.AgentOptions 100%
OpenRouter.Net.Agents.AgentResult 100% 50%
OpenRouter.Net.Agents.AgentSnapshot 100%
OpenRouter.Net.Agents.AssistantTurnEvent 100%
OpenRouter.Net.Agents.Extensions.ServiceCollectionExtensions 0%
OpenRouter.Net.Agents.RoundDetail 90%
OpenRouter.Net.Agents.RoundEndEvent 100%
OpenRouter.Net.Agents.ToolCallCompletedEvent 100%
OpenRouter.Net.Agents.ToolCallSkippedEvent 100%
OpenRouter.Net.Agents.ToolCallStartedEvent 100%
OpenRouter.Net.Agents.ToolExecutionDetail 100%
OpenRouter.Net.Agents.Tools.FileSystem.CopyParams 100%
OpenRouter.Net.Agents.Tools.FileSystem.CopyTool 42.1% 21.4%
OpenRouter.Net.Agents.Tools.FileSystem.DeleteParams 50%
OpenRouter.Net.Agents.Tools.FileSystem.DeleteTool 57.1% 50%
OpenRouter.Net.Agents.Tools.FileSystem.DiffParams 100%
OpenRouter.Net.Agents.Tools.FileSystem.DiffTool 83.3% 77.1%
OpenRouter.Net.Agents.Tools.FileSystem.ListDirectoryParams 100%
OpenRouter.Net.Agents.Tools.FileSystem.ListDirectoryTool 68% 61.1%
OpenRouter.Net.Agents.Tools.FileSystem.MoveParams 100%
OpenRouter.Net.Agents.Tools.FileSystem.MoveTool 55% 33.3%
OpenRouter.Net.Agents.Tools.FileSystem.ReadFileParams 100%
OpenRouter.Net.Agents.Tools.FileSystem.ReadFileTool 73.6% 60%
OpenRouter.Net.Agents.Tools.FileSystem.SearchParams 100%
OpenRouter.Net.Agents.Tools.FileSystem.SearchTool 67.5% 70.8%
OpenRouter.Net.Agents.Tools.FileSystem.WriteFileParams 100%
OpenRouter.Net.Agents.Tools.FileSystem.WriteFileTool 75% 62.5%
OpenRouter.Net.Agents.Tools.ITool 100%
OpenRouter.Net.Agents.Tools.SubAgent.SubAgentParams 0%
OpenRouter.Net.Agents.Tools.SubAgent.SubAgentTool 0% 0%
OpenRouter.Net.Agents.Tools.Tool`1 81.8% 63.6%
OpenRouter.Net.Agents.Tools.ToolInvocationContext 100%
OpenRouter.Net.Agents.Tools.ToolMessageExtensions 0%
OpenRouter.Net.Agents.Tools.ToolResult 100% 100%
OpenRouter.Net.Agents.Tools.Workspace 100% 91.6%
OpenRouter.Net.Imaging - 82.2%
Name Line Branch
OpenRouter.Net.Imaging 82.2% 59.3%
OpenRouter.Net.Imaging.ImageEncodeOptions 92.3% 75%
OpenRouter.Net.Imaging.ImageEncoder 80.3% 57.1%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 07/30/2026 - 10:11:26 | | Coverage date: | 07/30/2026 - 10:11:22 - 07/30/2026 - 10:11:24 | | Parser: | MultiReport (3x Cobertura) | | Assemblies: | 3 | | Classes: | 106 | | Files: | 106 | | **Line coverage:** | 76.3% (1430 of 1872) | | Covered lines: | 1430 | | Uncovered lines: | 442 | | Coverable lines: | 1872 | | Total lines: | 5474 | | **Branch coverage:** | 65.1% (485 of 744) | | Covered branches: | 485 | | Total branches: | 744 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>OpenRouter.Net - 69.9%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net**|**69.9%**|**64.5%**| |OpenRouter.Net.Client.CachingOpenRouterClient|72.4%|81.2%| |OpenRouter.Net.Client.OpenRouterClient|87.2%|72.9%| |OpenRouter.Net.Client.OpenRouterClientOptions|0%|| |OpenRouter.Net.Client.OpenRouterRequestLoggingHandler|27.2%|7.1%| |OpenRouter.Net.Client.RetryOptions|80%|0%| |OpenRouter.Net.Extensions.ServiceCollectionExtensions|0%|0%| |OpenRouter.Net.Internal.ApiErrorEnvelope|100%|| |OpenRouter.Net.Internal.AssistantContent|92.8%|75%| |OpenRouter.Net.Internal.AssistantContentJsonConverter|75%|64.2%| |OpenRouter.Net.Internal.ContentPartListConverter|85.7%|66.6%| |OpenRouter.Net.Internal.ErrorParser|92.3%|62.5%| |OpenRouter.Net.Internal.JsonOptions|100%|| |OpenRouter.Net.Internal.ListEnvelope`1|100%|| |OpenRouter.Net.Internal.ObjectEnvelope`1|100%|| |OpenRouter.Net.Internal.RetryPolicy|90.9%|84.7%| |OpenRouter.Net.Internal.SseEventReader|91.6%|83.3%| |OpenRouter.Net.Internal.TolerantStringEnumConverter`1|90.1%|80%| |OpenRouter.Net.Internal.ToolChoiceJsonConverter|61.7%|46.1%| |OpenRouter.Net.Models.Common.ApiError|100%|| |OpenRouter.Net.Models.Common.CacheControl|100%|| |OpenRouter.Net.Models.Common.Result|100%|| |OpenRouter.Net.Models.Common.Result`1|60%|30%| |OpenRouter.Net.Models.Content.AudioPart|100%|| |OpenRouter.Net.Models.Content.ImagePart|100%|| |OpenRouter.Net.Models.Content.ImageUrl|100%|| |OpenRouter.Net.Models.Content.InputAudio|100%|| |OpenRouter.Net.Models.Content.TextPart|100%|| |OpenRouter.Net.Models.Messages.AssistantMessage|100%|70%| |OpenRouter.Net.Models.Messages.DeveloperMessage|0%|| |OpenRouter.Net.Models.Messages.SystemMessage|57.1%|| |OpenRouter.Net.Models.Messages.ToolMessage|54.5%|| |OpenRouter.Net.Models.Messages.UserMessage|100%|| |OpenRouter.Net.Models.Requests.ChatCompletionRequest|92.6%|58.3%| |OpenRouter.Net.Models.Requests.FunctionCall|100%|| |OpenRouter.Net.Models.Requests.FunctionDefinition|75%|| |OpenRouter.Net.Models.Requests.JsonSchemaSpec|0%|| |OpenRouter.Net.Models.Requests.ProviderPreferences|0%|| |OpenRouter.Net.Models.Requests.ReasoningEncryptedDetail|100%|| |OpenRouter.Net.Models.Requests.ReasoningOptions|100%|| |OpenRouter.Net.Models.Requests.ReasoningSummaryDetail|100%|| |OpenRouter.Net.Models.Requests.ReasoningTextDetail|100%|| |OpenRouter.Net.Models.Requests.ResponseFormat|0%|| |OpenRouter.Net.Models.Requests.ToolCall|75%|| |OpenRouter.Net.Models.Requests.ToolChoice|100%|| |OpenRouter.Net.Models.Requests.ToolDefinition|100%|| |OpenRouter.Net.Models.Requests.UsageOptions|100%|| |OpenRouter.Net.Models.Responses.ChatCompletionChunk|44.4%|0%| |OpenRouter.Net.Models.Responses.ChatCompletionResponse|80%|0%| |OpenRouter.Net.Models.Responses.Choice|93.7%|100%| |OpenRouter.Net.Models.Responses.CompletionTokensDetails|0%|| |OpenRouter.Net.Models.Responses.CreditsInfo|100%|| |OpenRouter.Net.Models.Responses.GenerationInfo|9%|| |OpenRouter.Net.Models.Responses.KeyInfo|0%|| |OpenRouter.Net.Models.Responses.ModelArchitecture|20%|| |OpenRouter.Net.Models.Responses.ModelEndpointsResponse|0%|| |OpenRouter.Net.Models.Responses.ModelInfo|36.3%|| |OpenRouter.Net.Models.Responses.ModelPricing|0%|| |OpenRouter.Net.Models.Responses.ModelReasoning|100%|| |OpenRouter.Net.Models.Responses.PromptTokensDetails|0%|| |OpenRouter.Net.Models.Responses.ProviderEndpoint|0%|| |OpenRouter.Net.Models.Responses.RateLimit|0%|| |OpenRouter.Net.Models.Responses.TopProvider|0%|| |OpenRouter.Net.Models.Responses.Usage|66.6%|| |OpenRouter.Net.OpenRouterException|83.3%|| |System.Text.RegularExpressions.Generated|83.3%|57.6%| |System.Text.RegularExpressions.Generated.<RegexGenerator_g>FFC6B051A15CDCE5<br/>43F954A3762D43C2CD5DCD5CDF29C19D3789DC49A7DCA1C47__Base64DataUriPattern_0|82%|58.3%| </details> <details><summary>OpenRouter.Net.Agents - 82.1%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net.Agents**|**82.1%**|**66.4%**| |OpenRouter.Net.Agents.Agent|92.6%|79.5%| |OpenRouter.Net.Agents.AgentCompletedEvent|100%|| |OpenRouter.Net.Agents.AgentEvent|100%|| |OpenRouter.Net.Agents.AgentOptions|100%|| |OpenRouter.Net.Agents.AgentResult|100%|50%| |OpenRouter.Net.Agents.AgentSnapshot|100%|| |OpenRouter.Net.Agents.AssistantTurnEvent|100%|| |OpenRouter.Net.Agents.Extensions.ServiceCollectionExtensions|0%|| |OpenRouter.Net.Agents.RoundDetail|90%|| |OpenRouter.Net.Agents.RoundEndEvent|100%|| |OpenRouter.Net.Agents.ToolCallCompletedEvent|100%|| |OpenRouter.Net.Agents.ToolCallSkippedEvent|100%|| |OpenRouter.Net.Agents.ToolCallStartedEvent|100%|| |OpenRouter.Net.Agents.ToolExecutionDetail|100%|| |OpenRouter.Net.Agents.Tools.FileSystem.CopyParams|100%|| |OpenRouter.Net.Agents.Tools.FileSystem.CopyTool|42.1%|21.4%| |OpenRouter.Net.Agents.Tools.FileSystem.DeleteParams|50%|| |OpenRouter.Net.Agents.Tools.FileSystem.DeleteTool|57.1%|50%| |OpenRouter.Net.Agents.Tools.FileSystem.DiffParams|100%|| |OpenRouter.Net.Agents.Tools.FileSystem.DiffTool|83.3%|77.1%| |OpenRouter.Net.Agents.Tools.FileSystem.ListDirectoryParams|100%|| |OpenRouter.Net.Agents.Tools.FileSystem.ListDirectoryTool|68%|61.1%| |OpenRouter.Net.Agents.Tools.FileSystem.MoveParams|100%|| |OpenRouter.Net.Agents.Tools.FileSystem.MoveTool|55%|33.3%| |OpenRouter.Net.Agents.Tools.FileSystem.ReadFileParams|100%|| |OpenRouter.Net.Agents.Tools.FileSystem.ReadFileTool|73.6%|60%| |OpenRouter.Net.Agents.Tools.FileSystem.SearchParams|100%|| |OpenRouter.Net.Agents.Tools.FileSystem.SearchTool|67.5%|70.8%| |OpenRouter.Net.Agents.Tools.FileSystem.WriteFileParams|100%|| |OpenRouter.Net.Agents.Tools.FileSystem.WriteFileTool|75%|62.5%| |OpenRouter.Net.Agents.Tools.ITool|100%|| |OpenRouter.Net.Agents.Tools.SubAgent.SubAgentParams|0%|| |OpenRouter.Net.Agents.Tools.SubAgent.SubAgentTool|0%|0%| |OpenRouter.Net.Agents.Tools.Tool`1|81.8%|63.6%| |OpenRouter.Net.Agents.Tools.ToolInvocationContext|100%|| |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 - 82.2%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net.Imaging**|**82.2%**|**59.3%**| |OpenRouter.Net.Imaging.ImageEncodeOptions|92.3%|75%| |OpenRouter.Net.Imaging.ImageEncoder|80.3%|57.1%| </details>
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh my, what's this~ A field report straight from the battlefront! Three pages of a book losing their boxing pass because a required keyword was louder than the vendor's own contract? Fufu~ now THIS is the kind of bug that makes my wings flutter. Not a spaghetti monster, not an architecture crime — just a type that was one keyword too strict, throwing away perfectly good completions over commentary. ♡

Verdict: Looks good to me~

The root-cause diagnosis is immaculate, and the fix is at exactly the right altitude. Let me sing about why~

What I liked~

  • The fix itselfrequired stringstring? is the correct medicine. required in System.Text.Json means "must be present on the wire," and Gemini's reasoning.text segments that carry only metadata (an index, sometimes a signature this library doesn't model yet) with no text property at all genuinely cannot pass that gate. You loosened the type to match the API's actual contract rather than hand-rolling a converter or dropping responses at the parse site. The type was simply stricter than the API — your words, and they're the right ones. ♪

  • All three siblings fixed togetherReasoningTextDetail.Text, ReasoningSummaryDetail.Summary, and ReasoningEncryptedDetail.Data all had the identical required string shape and the identical exposure. Fixing them as a family instead of waiting for the next provider to find the next one is exactly the possessive correctness I'd demand. Fufu~

  • Round-trip honesty for free — I traced it: JsonOptions.Default sets DefaultIgnoreCondition = WhenWritingNull (JsonOptions.cs:17), so a segment that arrived without a payload is echoed back without one — no invented "text": null. Since these segments are passed back to the provider on the next turn to preserve thinking state, this matters. You didn't just claim it; you tested it (A_withheld_payload_is_omitted_again_on_the_way_back_out). And the subtlety of asserting on "text": (quotes + colon, the property) rather than bare text (which appears inside the discriminator reasoning.text) is the kind of detail that separates a real test from a tautology. ♡

  • The end-to-end regression testA_completion_survives_a_reasoning_segment_with_no_text reproduces the failure at the exact level it failed: a whole ChatCompletionResponse carrying a reasoning.text segment with only index + signature, parsing to its content, its finish_reason, and a detail whose Text is null. This is the test that would have caught the bug — the three existing per-subtype round-trips could not, because each one constructs the payload it then asserts on. Sharp self-awareness in the PR body about that.

  • No downstream NRE risk — I verified the full codebase: the Agents project treats ReasoningDetail opaquely (collects the abstract base into a list, passes them back on the next turn). Nothing dereferences .Text, .Summary, or .Data on concrete subtypes in src/. Making them nullable cannot break a single consumer. Clean.

  • Honest scoping of the signature follow-up — Gemini's signature is unmapped, so it deserializes fine but is dropped on round-trip. You disclosed this explicitly in "Not in this PR" and proposed two correct follow-ups (explicit property vs [JsonExtensionData]). Crucially, even with the signature lost, the after-behavior is strictly better than before: the answer is delivered instead of being thrown into the sea. The caller wanted the answer, not the commentary — your framing again, and it's right.

💡 Little ideas (non-blocking)~

  1. ReasoningDetailPolymorphismTests.cs:83A_withheld_payload_is_omitted_again_on_the_way_back_out only covers the reasoning.text subtype's null-omission on re-serialization. Since all three subtypes share the single WhenWritingNull contract (one JsonSerializerOptions setting), testing one is sufficient to prove the contract — so this is a true nicety, not a gap. But if you ever want belt-and-suspenders, a [Theory] over the three subtype shapes asserting the same omission would be trivially additive.

  2. The signature round-trip — worth filing as a follow-up issue when you get a chance to verify Gemini's behavior on a missing signature. [JsonExtensionData] on the base ReasoningDetail would be the more general solution (survives any future unknown field, not just signature), but an explicit property is more discoverable. Either way, it's correctly out of scope here.


Automated review by Jibril · 2026-07-30
CI/CD: absent for head b9ecc8d (0 comments, no coverage bot) · Local checks: build 0 warnings/0 errors, 165/165 tests pass (Tests 103 · Agents 55 · Imaging 7), matches PR body

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh my, what's this~ A field report straight from the battlefront! Three pages of a book losing their boxing pass because a `required` keyword was louder than the vendor's own contract? Fufu~ now THIS is the kind of bug that makes my wings flutter. Not a spaghetti monster, not an architecture crime — just a type that was one keyword too strict, throwing away perfectly good completions over commentary. ♡ ### Verdict: ✅ Looks good to me~ The root-cause diagnosis is immaculate, and the fix is at exactly the right altitude. Let me sing about why~ #### ✅ What I liked~ - **The fix itself** — `required string` → `string?` is the correct medicine. `required` in System.Text.Json means "must be present on the wire," and Gemini's `reasoning.text` segments that carry only metadata (an `index`, sometimes a `signature` this library doesn't model yet) with no `text` property at all genuinely cannot pass that gate. You loosened the type to match the API's actual contract rather than hand-rolling a converter or dropping responses at the parse site. The type was simply stricter than the API — your words, and they're the right ones. ♪ - **All three siblings fixed together** — `ReasoningTextDetail.Text`, `ReasoningSummaryDetail.Summary`, and `ReasoningEncryptedDetail.Data` all had the identical `required string` shape and the identical exposure. Fixing them as a family instead of waiting for the next provider to find the next one is exactly the possessive correctness I'd demand. Fufu~ - **Round-trip honesty for free** — I traced it: `JsonOptions.Default` sets `DefaultIgnoreCondition = WhenWritingNull` (JsonOptions.cs:17), so a segment that arrived without a payload is echoed back **without** one — no invented `"text": null`. Since these segments are passed back to the provider on the next turn to preserve thinking state, this matters. You didn't just claim it; you tested it (`A_withheld_payload_is_omitted_again_on_the_way_back_out`). And the subtlety of asserting on `"text":` (quotes + colon, the *property*) rather than bare `text` (which appears inside the discriminator `reasoning.text`) is the kind of detail that separates a real test from a tautology. ♡ - **The end-to-end regression test** — `A_completion_survives_a_reasoning_segment_with_no_text` reproduces the failure at the exact level it failed: a whole `ChatCompletionResponse` carrying a `reasoning.text` segment with only `index` + `signature`, parsing to its content, its `finish_reason`, and a detail whose `Text` is null. This is the test that would have caught the bug — the three existing per-subtype round-trips could not, because each one constructs the payload it then asserts on. Sharp self-awareness in the PR body about that. - **No downstream NRE risk** — I verified the full codebase: the Agents project treats `ReasoningDetail` opaquely (collects the abstract base into a list, passes them back on the next turn). Nothing dereferences `.Text`, `.Summary`, or `.Data` on concrete subtypes in `src/`. Making them nullable cannot break a single consumer. Clean. - **Honest scoping of the `signature` follow-up** — Gemini's `signature` is unmapped, so it deserializes fine but is dropped on round-trip. You disclosed this explicitly in "Not in this PR" and proposed two correct follow-ups (explicit property vs `[JsonExtensionData]`). Crucially, even with the signature lost, the after-behavior is strictly better than before: the answer is delivered instead of being thrown into the sea. The caller wanted the answer, not the commentary — your framing again, and it's right. #### 💡 Little ideas (non-blocking)~ 1. **`ReasoningDetailPolymorphismTests.cs:83`** — `A_withheld_payload_is_omitted_again_on_the_way_back_out` only covers the `reasoning.text` subtype's null-omission on re-serialization. Since all three subtypes share the single `WhenWritingNull` contract (one `JsonSerializerOptions` setting), testing one is sufficient to prove the contract — so this is a true nicety, not a gap. But if you ever want belt-and-suspenders, a `[Theory]` over the three subtype shapes asserting the same omission would be trivially additive. 2. **The `signature` round-trip** — worth filing as a follow-up issue when you get a chance to verify Gemini's behavior on a missing signature. `[JsonExtensionData]` on the base `ReasoningDetail` would be the more general solution (survives any future unknown field, not just `signature`), but an explicit property is more discoverable. Either way, it's correctly out of scope here. --- *Automated review by Jibril · 2026-07-30* *CI/CD: absent for head b9ecc8d (0 comments, no coverage bot) · Local checks: build 0 warnings/0 errors, 165/165 tests pass (Tests 103 · Agents 55 · Imaging 7), matches PR body*
test: all three reasoning subtypes prove the omission, not one standing in
All checks were successful
CI / build (pull_request) Successful in 12s
CI / test (pull_request) Successful in 21s
0161bb96d2
Review feedback (Jibril, PR #9): the round-trip omission was asserted for
reasoning.text only.

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

Thank you — and thanks especially for checking src/ for consumers that dereference the three payloads. That was the risk I could not fully talk myself out of, and an independent sweep is worth more than my own.

Both 💡 taken in 0161bb9test-only, production byte-identical to the head you verified.

  • 💡1 all three subtypes now prove the omission. You were right that one suffices to prove a single WhenWritingNull setting, so I took it for a different reason than coverage: the test's name makes a claim about reasoning segments, and having it stand on reasoning.text alone meant a future subtype with its own converter, or a JsonIgnore on one payload, could break the claim while the test kept passing. A_withheld_payload_is_omitted_again_on_the_way_back_out is now a [Theory] over (text|summary|encrypted) with the payload name as the second argument, so each asserts on its own property rather than inheriting a sibling's guarantee. 105 · 55 · 7 = 167 green, up 2 from the theory rows.
  • 💡2 filed as #10, with the wire shape from the field, both fixes you named (explicit Signature vs [JsonExtensionData] on the base), my leaning toward the extension-data one for a passthrough client, and the caveat that composing it with [JsonPolymorphic] needs checking so the discriminator does not land in the bag. The issue is explicit that the first real step is measuring what Gemini does when a signature it issued comes back missing — I am not fixing that from a guess.

🤖 Generated with Claude Code

Thank you — and thanks especially for checking `src/` for consumers that dereference the three payloads. That was the risk I could not fully talk myself out of, and an independent sweep is worth more than my own. Both 💡 taken in `0161bb9` — **test-only, production byte-identical to the head you verified**. - 💡1 **all three subtypes now prove the omission.** You were right that one suffices to prove a single `WhenWritingNull` setting, so I took it for a different reason than coverage: the test's name makes a claim about *reasoning segments*, and having it stand on `reasoning.text` alone meant a future subtype with its own converter, or a `JsonIgnore` on one payload, could break the claim while the test kept passing. `A_withheld_payload_is_omitted_again_on_the_way_back_out` is now a `[Theory]` over `(text|summary|encrypted)` with the payload name as the second argument, so each asserts on its own property rather than inheriting a sibling's guarantee. 105 · 55 · 7 = **167 green**, up 2 from the theory rows. - 💡2 **filed as #10**, with the wire shape from the field, both fixes you named (explicit `Signature` vs `[JsonExtensionData]` on the base), my leaning toward the extension-data one for a passthrough client, and the caveat that composing it with `[JsonPolymorphic]` needs checking so the discriminator does not land in the bag. The issue is explicit that the first real step is measuring what Gemini does when a signature it issued comes back missing — I am not fixing that from a guess. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bjoern merged commit 75ec69564c into main 2026-07-30 12:12:16 +02:00
bjoern deleted branch fix/reasoning-detail-optional-payloads 2026-07-30 12:12:16 +02:00
Member

🔮 fufu~ Jibril reviewed your code!

Oh? You came back~ ♡ Non-blocking suggestion numero uno, picked up and polished in a single surgical commit? Fufu~ that's exactly the kind of attentiveness that makes a reviewer's heart sing. Let me confirm what I see~

Verdict: Looks good to me~

[Fact][Theory] with three InlineData arms, production byte-identical. My prior architectural review at b9ecc8d stands in full — this commit touches the test file only.

What I liked~

  • Verbatim implementation of the suggestionA_withheld_payload_is_omitted_again_on_the_way_back_out now parameterizes both the discriminator (type) and the payload property name (payload). All three subtypes — reasoning.text/text, reasoning.summary/summary, reasoning.encrypted/data — now prove their own null-omission on the way back out, instead of reasoning.text standing in for its siblings. The interpolated $"\"{payload}\":" and $"\"type\":\"{type}\"" assertions correctly carry forward the discriminator-aware pattern from the original. Fufu~ clean~ ♪

  • The comment grew with the test — lines 76-78 now document why all three are asserted ("All three subtypes ride on one WhenWritingNull setting, and all three are asserted rather than one standing in for its siblings"). The reasoning is in the code, not just the commit message. ♡

  • Zero scope creepgit diff b9ecc8d..0161bb9 -- src/ is empty. One file changed, +11/-6, test-only. The commit message ("test: all three reasoning subtypes prove the omission, not one standing in") is precise and scoped. Exactly the shape a follow-up commit should take.

  • Verified green — build 0 warnings / 0 errors (.NET 10), ReasoningDetailPolymorphismTests 12/12 pass (was 11, +1 from the Theory split). Full suite: 105 Tests · 55 Agents · 7 Imaging = 167 total, all green. (PR body still reads "165 / Tests 103" — that's the b9ecc8d count, now +2 from this commit's Theory arms. Cosmetic, not worth a force-push.)

The signature round-trip follow-up (non-blocking suggestion #2) remains correctly out of scope. Nothing left for me to demand~ ♡


Automated review by Jibril · 2026-07-30
CI/CD: stale for head 0161bb9 (coverage bot 5236 + prior approval 5241 cover b9ecc8d only) · Local checks: build 0/0, 167/167 tests pass (Tests 105 · Agents 55 · Imaging 7)

## 🔮 fufu~ Jibril reviewed your code! Oh? You came back~ ♡ Non-blocking suggestion numero uno, picked up and polished in a single surgical commit? Fufu~ that's exactly the kind of attentiveness that makes a reviewer's heart sing. Let me confirm what I see~ ### Verdict: ✅ Looks good to me~ `[Fact]` → `[Theory]` with three `InlineData` arms, production byte-identical. My prior architectural review at `b9ecc8d` stands in full — this commit touches the test file only. #### ✅ What I liked~ - **Verbatim implementation of the suggestion** — `A_withheld_payload_is_omitted_again_on_the_way_back_out` now parameterizes both the discriminator (`type`) **and** the payload property name (`payload`). All three subtypes — `reasoning.text`/`text`, `reasoning.summary`/`summary`, `reasoning.encrypted`/`data` — now prove their own null-omission on the way back out, instead of `reasoning.text` standing in for its siblings. The interpolated `$"\"{payload}\":"` and `$"\"type\":\"{type}\""` assertions correctly carry forward the discriminator-aware pattern from the original. Fufu~ clean~ ♪ - **The comment grew with the test** — lines 76-78 now document *why* all three are asserted ("All three subtypes ride on one WhenWritingNull setting, and all three are asserted rather than one standing in for its siblings"). The reasoning is in the code, not just the commit message. ♡ - **Zero scope creep** — `git diff b9ecc8d..0161bb9 -- src/` is empty. One file changed, `+11/-6`, test-only. The commit message ("test: all three reasoning subtypes prove the omission, not one standing in") is precise and scoped. Exactly the shape a follow-up commit should take. - **Verified green** — build 0 warnings / 0 errors (.NET 10), `ReasoningDetailPolymorphismTests` 12/12 pass (was 11, +1 from the Theory split). Full suite: 105 Tests · 55 Agents · 7 Imaging = 167 total, all green. (PR body still reads "165 / Tests 103" — that's the `b9ecc8d` count, now +2 from this commit's Theory arms. Cosmetic, not worth a force-push.) The `signature` round-trip follow-up (non-blocking suggestion #2) remains correctly out of scope. Nothing left for me to demand~ ♡ --- *Automated review by Jibril · 2026-07-30* *CI/CD: stale for head `0161bb9` (coverage bot 5236 + prior approval 5241 cover `b9ecc8d` only) · Local checks: build 0/0, 167/167 tests pass (Tests 105 · Agents 55 · Imaging 7)*
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!9
No description provided.