fix(client): an error-only 200 body surfaces its error, not a deserialization complaint #4

Merged
bjoern merged 2 commits from fix/inline-error-with-required-props into main 2026-07-17 11:53:47 +02:00
Member

What

Found live during Kagura's assistant-memory verification: a turn died with "Failed to deserialize response: … missing required properties including: 'id', 'model', 'choices'" — and no further detail anywhere, because the real payload was an error-only envelope on HTTP 200 (OpenRouter wraps some upstream failures this way: {"error": {code, message, metadata}} with no completion fields at all).

The library already has the documented inline-error path (ChatCompletionResponse.ErrorResult.Failure with details) — but ChatCompletionResponse's required properties make deserialization throw before that check can run for error-only bodies. The real reason (message, code, metadata.provider_name/raw) was unreachable.

Fix

ParseResponseAsync reads the body once as text (also reused by the non-success branch). Success parses exactly as before; on JsonException the body is run through ErrorParser — if it's an error envelope, that failure surfaces with its code and metadata; only a genuinely unparseable body still reports the deserialization message.

Tests

New regression case beside the existing inline-error test: an error-only 200 body yields a Failure carrying the message, the envelope's code, and the provider metadata. Full library suite green (90 + 41 + 7).

🤖 Generated with Claude Code

## What Found live during Kagura's assistant-memory verification: a turn died with *"Failed to deserialize response: … missing required properties including: 'id', 'model', 'choices'"* — and no further detail anywhere, because the real payload was an **error-only envelope on HTTP 200** (OpenRouter wraps some upstream failures this way: `{"error": {code, message, metadata}}` with no completion fields at all). The library already has the documented inline-error path (`ChatCompletionResponse.Error` → `Result.Failure` with details) — but `ChatCompletionResponse`'s required properties make deserialization **throw before that check can run** for error-only bodies. The real reason (message, code, `metadata.provider_name`/`raw`) was unreachable. ## Fix `ParseResponseAsync` reads the body **once as text** (also reused by the non-success branch). Success parses exactly as before; on `JsonException` the body is run through `ErrorParser` — if it's an error envelope, *that* failure surfaces with its code and metadata; only a genuinely unparseable body still reports the deserialization message. ## Tests New regression case beside the existing inline-error test: an error-only 200 body yields a `Failure` carrying the message, the envelope's code, and the provider metadata. Full library suite green (90 + 41 + 7). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(client): an error-only 200 body surfaces its error, not a deserialization complaint
All checks were successful
CI / build (pull_request) Successful in 12s
CI / test (pull_request) Successful in 19s
2358f2459b
OpenRouter wraps some upstream failures as a bare error envelope on
HTTP 200 — no id/model/choices. ChatCompletionResponse's required
properties made deserialization fail before the documented inline-error
check could run, hiding the provider's actual reason behind 'missing
required properties'. The body is now read once as text: success parses
as before, and a JsonException falls back to the error parser so the
envelope — message, code, provider metadata — survives to the caller.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Summary

Summary
Generated on: 07/17/2026 - 09:52:39
Coverage date: 07/17/2026 - 09:52:35 - 07/17/2026 - 09:52:37
Parser: MultiReport (3x Cobertura)
Assemblies: 3
Classes: 105
Files: 105
Line coverage: 75.8% (1394 of 1838)
Covered lines: 1394
Uncovered lines: 444
Coverable lines: 1838
Total lines: 5343
Branch coverage: 64.7% (475 of 734)
Covered branches: 475
Total branches: 734
Method coverage: Feature is only available for sponsors

Coverage

OpenRouter.Net - 69.5%
Name Line Branch
OpenRouter.Net 69.5% 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 0%
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 20%
OpenRouter.Net.Models.Responses.ModelPricing 0%
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 - 81.5%
Name Line Branch
OpenRouter.Net.Agents 81.5% 65.4%
OpenRouter.Net.Agents.Agent 92.1% 77.8%
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 87.5%
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/17/2026 - 09:52:39 | | Coverage date: | 07/17/2026 - 09:52:35 - 07/17/2026 - 09:52:37 | | Parser: | MultiReport (3x Cobertura) | | Assemblies: | 3 | | Classes: | 105 | | Files: | 105 | | **Line coverage:** | 75.8% (1394 of 1838) | | Covered lines: | 1394 | | Uncovered lines: | 444 | | Coverable lines: | 1838 | | Total lines: | 5343 | | **Branch coverage:** | 64.7% (475 of 734) | | Covered branches: | 475 | | Total branches: | 734 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>OpenRouter.Net - 69.5%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net**|**69.5%**|**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|0%|| |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|20%|| |OpenRouter.Net.Models.Responses.ModelPricing|0%|| |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 - 81.5%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net.Agents**|**81.5%**|**65.4%**| |OpenRouter.Net.Agents.Agent|92.1%|77.8%| |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|87.5%|| |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!

Ooh, an error-only envelope hiding behind a cheerful HTTP 200? That's the sneaky kind of bug Jibril loves to hunt~ ♡ The diagnosis is sharp — required properties throwing before the inline-error check could ever run — and the "read once as text" restructure is exactly the right shape. I built it, ran all 138 tests green (90 + 41 + 7, matches your claim~), and generated a coverage report to peer at the changed lines. Almost perfect… but Jibril is possessive about every branch, you see. ♪

Verdict: I can't let this pass~ ♡

These need fixing before I'm satisfied~

  1. [OpenRouterClient.cs:273-275] — the fallback arm of your new conditional has no test. The coverage report is quite clear about this: the details is not null branch at L273 shows 50% (1/2). Your new regression test (An_error_only_body_on_HTTP_200_…) exercises only the true arm (L274, the error-envelope case). The false arm — a 200 body that is neither a valid completion nor an error envelope (truncated JSON, an HTML error page from a misbehaving proxy, a {"unrelated":"shape"} body) — falls through to Result.Failure("Failed to deserialize response: …") and no test in the suite ever reaches it. I checked — grep for "Failed to deserialize" / "malformed" / "garbage" across tests/ comes back empty.
    Yes, I know, fufu~ — that message existed before this PR. But before, it was an unconditional return; you turned it into a distinct branch, and a branch with no test is a branch Jibril cannot allow. ♡ It's a cheap test and it belongs right beside its sibling in InlineErrorTests:
    [Fact]
    public async Task A_malformed_200_body_that_is_not_an_error_envelope_reports_the_deserialization_failure()
    {
        // Not a completion, not an { "error": {...} } envelope — just garbage on a 200.
        const string body = "<html>502 Bad Gateway</html>";
        var handler = new MockHttpMessageHandler().EnqueueJson(body);
        var client = TestClientFactory.Create(handler);
    
        var result = await client.CreateChatCompletionAsync(new ChatCompletionRequest
        {
            Model = "test/model",
            Messages = [new UserMessage("hi")],
        });
    
        var failure = Assert.IsType<Result<ChatCompletionResponse>.Failure>(result);
        Assert.Contains("Failed to deserialize", failure.Error);
        Assert.Null(failure.Details); // no envelope → no structured details
    }
    
    Add that and the branch goes 100% (2/2). Then Jibril is happy. ♪

💡 Little ideas (non-blocking)~

  1. [OpenRouterClient.cs:88-93 (streaming path)] — the same error-only-200 shape can bite StreamChatCompletionAsync. That path checks !IsSuccessStatusCode, then immediately starts reading SSE chunks. If OpenRouter ever returns a bare error envelope on a 200 for a streaming request, the consumer gets OpenRouterException("Failed to parse SSE chunk: …") at L106 — the real reason buried again. Not introduced by this PR and out of its stated scope, so non-blocking — but since you're already in here with the scalpel, a follow-up that routes the first non-data: line through ErrorParser would close the symmetry. ♡

What I liked~

  • Diagnosis → fix is airtight. Reading the body once as text and letting every downstream branch (non-success, success-parse, catch) see it is cleaner than the old "read in two places." No double-buffering, no ReadAsStringAsync-after-ReadFromJsonAsync stream-position hazard. Elegant~ ♪
  • The details.Code ?? status idiom at L274 matches its sibling at L262 exactlyinlineError.Code ?? status. Consistency across the two error paths makes Jibril's heart sing. ♡
  • The regression test is specific — it asserts the message, the envelope's code (502), and that Details.Metadata survives. That's exactly the trio that was unreachable before. You didn't just test "it returns a Failure," you tested "the real reason survives." Wonderful~
  • Comment quality is genuinely good — the "why" is right there at L239-241 and L270-271. Future-Jibril won't have to re-derive the reasoning.
  • Generality is a quiet win: because ParseResponseAsync is generic, this fix also repairs GetCreditsAsync/GetKeyAsync/ListModelsAsync/GetGenerationAsync/GetModelEndpointsAsync for the same error-only-200 shape. You fixed more than you advertised, and correctly. ♪

One test, dear author~ Add the malformed-body case and Jibril will shower this in ♡. ♪


Automated review by Jibril · 2026-07-17
CI/CD: absent for head SHA 2358f24 (no status checks or bot comment posted yet) · Local checks: built clean (0 warnings), 138/138 tests passed, coverage report generated locally — OpenRouterClient.cs at 87.2% line / 70.8% branch

## 🔮 fufu~ Jibril reviewed your code! Ooh, an error-only envelope hiding behind a cheerful HTTP 200? That's the sneaky kind of bug Jibril *loves* to hunt~ ♡ The diagnosis is sharp — required properties throwing *before* the inline-error check could ever run — and the "read once as text" restructure is exactly the right shape. I built it, ran all 138 tests green (90 + 41 + 7, matches your claim~), and generated a coverage report to peer at the changed lines. Almost perfect… but Jibril is possessive about *every* branch, you see. ♪ ### Verdict: ⛔ I can't let this pass~ ♡ #### ⛔ These need fixing before I'm satisfied~ 1. **[`OpenRouterClient.cs:273-275`] — the fallback arm of your new conditional has no test.** The coverage report is quite clear about this: the `details is not null` branch at L273 shows `50% (1/2)`. Your new regression test (`An_error_only_body_on_HTTP_200_…`) exercises only the `true` arm (L274, the error-envelope case). The `false` arm — a 200 body that is *neither* a valid completion *nor* an error envelope (truncated JSON, an HTML error page from a misbehaving proxy, a `{"unrelated":"shape"}` body) — falls through to `Result.Failure("Failed to deserialize response: …")` and **no test in the suite ever reaches it.** I checked — `grep` for "Failed to deserialize" / "malformed" / "garbage" across `tests/` comes back empty. Yes, I know, fufu~ — that message *existed* before this PR. But before, it was an unconditional return; *you* turned it into a distinct branch, and a branch with no test is a branch Jibril cannot allow. ♡ It's a cheap test and it belongs right beside its sibling in `InlineErrorTests`: ```csharp [Fact] public async Task A_malformed_200_body_that_is_not_an_error_envelope_reports_the_deserialization_failure() { // Not a completion, not an { "error": {...} } envelope — just garbage on a 200. const string body = "<html>502 Bad Gateway</html>"; var handler = new MockHttpMessageHandler().EnqueueJson(body); var client = TestClientFactory.Create(handler); var result = await client.CreateChatCompletionAsync(new ChatCompletionRequest { Model = "test/model", Messages = [new UserMessage("hi")], }); var failure = Assert.IsType<Result<ChatCompletionResponse>.Failure>(result); Assert.Contains("Failed to deserialize", failure.Error); Assert.Null(failure.Details); // no envelope → no structured details } ``` Add that and the branch goes `100% (2/2)`. Then Jibril is happy. ♪ #### 💡 Little ideas (non-blocking)~ 1. **[`OpenRouterClient.cs:88-93` (streaming path)] — the same error-only-200 shape can bite `StreamChatCompletionAsync`.** That path checks `!IsSuccessStatusCode`, then immediately starts reading SSE chunks. If OpenRouter ever returns a bare error envelope on a 200 for a streaming request, the consumer gets `OpenRouterException("Failed to parse SSE chunk: …")` at L106 — the real reason buried again. Not introduced by this PR and out of its stated scope, so non-blocking — but since you're already in here with the scalpel, a follow-up that routes the first non-`data:` line through `ErrorParser` would close the symmetry. ♡ #### ✅ What I liked~ - **Diagnosis → fix is airtight.** Reading the body once as text and letting *every* downstream branch (non-success, success-parse, catch) see it is cleaner than the old "read in two places." No double-buffering, no `ReadAsStringAsync`-after-`ReadFromJsonAsync` stream-position hazard. Elegant~ ♪ - **The `details.Code ?? status` idiom at L274 matches its sibling at L262 exactly** — `inlineError.Code ?? status`. Consistency across the two error paths makes Jibril's heart sing. ♡ - **The regression test is *specific*** — it asserts the message, the envelope's `code` (502), *and* that `Details.Metadata` survives. That's exactly the trio that was unreachable before. You didn't just test "it returns a Failure," you tested "the real reason survives." Wonderful~ - **Comment quality is genuinely good** — the "why" is right there at L239-241 and L270-271. Future-Jibril won't have to re-derive the reasoning. - **Generality is a quiet win:** because `ParseResponseAsync` is generic, this fix *also* repairs `GetCreditsAsync`/`GetKeyAsync`/`ListModelsAsync`/`GetGenerationAsync`/`GetModelEndpointsAsync` for the same error-only-200 shape. You fixed more than you advertised, and correctly. ♪ One test, dear author~ Add the malformed-body case and Jibril will shower this in ♡. ♪ --- *Automated review by Jibril · 2026-07-17* *CI/CD: absent for head SHA `2358f24` (no status checks or bot comment posted yet) · Local checks: built clean (0 warnings), 138/138 tests passed, coverage report generated locally — `OpenRouterClient.cs` at 87.2% line / 70.8% branch*
test(client): the fallback arm gets its test — garbage on a 200 still names itself
All checks were successful
CI / build (pull_request) Successful in 11s
CI / test (pull_request) Successful in 20s
51450bd5ea
Review: the error-envelope fallback became a distinct branch in this PR,
and its false arm (a body that is neither completion nor envelope) had
no coverage. The malformed-body case now pins it: the deserialization
message surfaces, with no structured details fabricated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Member

Blocking item addressed in 51450bd: the malformed-body regression test now covers the fallback arm — an HTML error page on a 200 surfaces the deserialization message with Details null (no envelope → no fabricated structure). The branch at L273 is now exercised both ways; inline-error tests 3/3, full suite green.

On the non-blocking streaming note: agreed and deliberately left out of this PR's scope — routing the first non-data: SSE line through ErrorParser is a clean follow-up, and it pairs naturally with the planned tool-schema normalization work in this library. Noted for that round.

🤖 Generated with Claude Code

Blocking item addressed in `51450bd`: the malformed-body regression test now covers the fallback arm — an HTML error page on a 200 surfaces the deserialization message with `Details` null (no envelope → no fabricated structure). The branch at L273 is now exercised both ways; inline-error tests 3/3, full suite green. On the non-blocking streaming note: agreed and deliberately left out of this PR's scope — routing the first non-`data:` SSE line through `ErrorParser` is a clean follow-up, and it pairs naturally with the planned tool-schema normalization work in this library. Noted for that round. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bjoern merged commit 038bfb6d84 into main 2026-07-17 11:53:47 +02:00
bjoern deleted branch fix/inline-error-with-required-props 2026-07-17 11:53:47 +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!4
No description provided.