feat: a finished completion reads as JSON, and a schema name is checked before sending #23

Merged
bjoern merged 1 commit from feat/json-response-checks into main 2026-09-25 15:26:53 +02:00
Member

The first half of L3, the last library work Tsumugi's Phase 2 llm node needs (its structured-output socket, D-14). StructuredOutput.ParseJson reads a finished completion's answer as one JSON value and says why when it can't, and ChatCompletionRequest.Validate refuses a json_schema name OpenRouter would refuse. Kagura can drop its hand-rolled Choices guard. The strict-mode schema check and the shared $ref pass are the sibling PR (feat/strict-schema-check). The two are independent, and either can merge first.

For Björn — one decision before this merges

Which kind a ParseJson failure has. The plan said every arm is Malformed (transient), citing ADR 0018's "does not parse or validate is transient". This PR makes the arms say what they are:

  • A refusal or a content_filter stop is ContentPolicy (permanent).
  • finish_reason: length is ContextLength (permanent).
  • finish_reason: error takes the kind its choice's error names, read the way the client reads an inline error; with none, it is ProviderUnavailable (transient).
  • The rest are Malformed.

The reason: the same refusal or overflow reported on the error path already has these kinds (#16). If ParseJson called them Malformed, Tsumugi's scheduler would pay for three attempts that OpenRouter says cannot help (ADR 0016: "a retry would only spend money"). ADR 0018's sentence is about output that does not parse. A refusal is not that, and content that is not JSON stays transient. Going back to the plan's version is a one-line change per arm.

ParseJson

The first check that applies decides:

# Arm Kind Transient
1 no choice (empty, null, or a null first entry), or a first choice without a message Malformed yes
2 a refusal (message.refusal not blank); wins over every finish reason, because OpenRouter reports an output refusal with content_filter ContentPolicy no
3 finish_reason: length, even when the cut-off text parses or is empty ContextLength no
4 content_filter ContentPolicy no
4 error: the choice's Error, read as an inline error, carried as Details its kind, else ProviderUnavailable by kind
4 tool_calls: a turn that asks for tools is not a final answer Malformed yes
5 content null, empty or whitespace Malformed yes
6 content that is not one strict JSON value (no comments, no trailing commas, no repeated key, nothing after it, at most 64 levels), with the parser's message Malformed yes
  • Any other finish reason, or none, leaves the content to decide. Any JSON value succeeds, not only an object, and the result is detached from the text it came from.
  • It works on ChatCompletionStreamAccumulator.ToResponse(). Check IsFinished first: a fold that broke off has no finish reason, so a cut-off number parses. A fold keeps no Choice.Error, so a folded error stop is ProviderUnavailable.
  • Choice.Error now reads the error object OpenRouter embeds in a choice. An error without a message reads with an empty message; that row is in the README upgrade table.

Name validation

Validate refuses a json_schema name outside ^[a-zA-Z0-9_-]{1,64}$ before anything is sent. It uses no regex, which avoids the $-matches-before-a-trailing-newline trap, and names the offending rune. The failure is Validation on both the non-streaming and streaming paths.

Tests

main (b901b13) this PR
OpenRouter.Net.Tests 1010 1109
OpenRouter.Net.Agents.Tests 63 63
OpenRouter.Net.Imaging.Tests 7 7
Total 1080 1179
  • StructuredOutputTests (new, 69): every arm, the order between them (a refusal beats length, length beats parsing content, and so on), each error kind, and a stream fold.
  • RequestValidationTests (+26): the name's boundaries, 1 and 64 accepted, 0 and 65 refused; a trailing newline; non-ASCII runes named.
  • ChatCompletionTests (+2): a refused name sends nothing.
  • SerializationTests (+2): Choice.Error on the wire.

Coverage (per changed file, lines and branches):

  • StructuredOutput.cs: 35/35 lines, 16/16 branches.
  • Choice.cs: 10/10 lines, 10/10 branches.
  • ChatCompletionRequest.cs: the new block is fully covered. Its four uncovered lines predate this PR: the null fallback entry, TopP and MaxTokens.

Mutation probes: the brief's probes 1–23, split into 52 sub-cases, each run on a copy and restored. Every one is caught at run time. That includes each arm dropped, each pair of arms swapped, each kind changed, the name's bounds, the trailing-newline trap and the rune report.

Rebased onto #21 and #22

This was built on c9b58ba and is now applied on main (b901b13). Only the README conflicted, and both sides were kept:

  • The Kind table's Malformed row names the decisions, stream and ParseJson cases.
  • The upgrade intro names the decisions call, the name check and the choice's error.

The sibling PR touches the README, JsonSchemaSpec and ResponseFormat too, on different members. Whichever merges second gets the other merged in.

Out of scope

  • Validating the content against the user's schema: JsonSchema.Net in Kagaku.Flow.OpenRouter (D-14).
  • ResponseFormat.JsonSchemaFormat<T> and moving the type-to-schema exporter into core: Phase 4 (D-15).
  • The strict-mode check: the sibling PR.

🤖 Generated with Claude Code

The first half of L3, the last library work Tsumugi's Phase 2 `llm` node needs (its structured-output socket, D-14). **`StructuredOutput.ParseJson`** reads a finished completion's answer as one JSON value and says why when it can't, and **`ChatCompletionRequest.Validate`** refuses a `json_schema` name OpenRouter would refuse. Kagura can drop its hand-rolled `Choices` guard. The strict-mode schema check and the shared `$ref` pass are the sibling PR (`feat/strict-schema-check`). The two are independent, and either can merge first. ## For Björn — one decision before this merges **Which kind a ParseJson failure has.** The plan said every arm is `Malformed` (transient), citing ADR 0018's "does not parse or validate is transient". This PR makes the arms say what they are: - A refusal or a `content_filter` stop is **`ContentPolicy`** (permanent). - `finish_reason: length` is **`ContextLength`** (permanent). - `finish_reason: error` takes the kind **its choice's `error` names**, read the way the client reads an inline error; with none, it is `ProviderUnavailable` (transient). - The rest are `Malformed`. The reason: the same refusal or overflow reported on the error path already has these kinds (#16). If ParseJson called them `Malformed`, Tsumugi's scheduler would pay for three attempts that OpenRouter says cannot help (ADR 0016: "a retry would only spend money"). ADR 0018's sentence is about output that does not parse. A refusal is not that, and content that is not JSON stays transient. Going back to the plan's version is a one-line change per arm. ## ParseJson The first check that applies decides: | # | Arm | Kind | Transient | |---|---|---|---| | 1 | no choice (empty, null, or a null first entry), or a first choice without a message | `Malformed` | yes | | 2 | a refusal (`message.refusal` not blank); wins over every finish reason, because OpenRouter reports an output refusal with `content_filter` | `ContentPolicy` | no | | 3 | `finish_reason: length`, even when the cut-off text parses or is empty | `ContextLength` | no | | 4 | `content_filter` | `ContentPolicy` | no | | 4 | `error`: the choice's `Error`, read as an inline error, carried as `Details` | its kind, else `ProviderUnavailable` | by kind | | 4 | `tool_calls`: a turn that asks for tools is not a final answer | `Malformed` | yes | | 5 | content null, empty or whitespace | `Malformed` | yes | | 6 | content that is not one strict JSON value (no comments, no trailing commas, no repeated key, nothing after it, at most 64 levels), with the parser's message | `Malformed` | yes | - Any other finish reason, or none, leaves the content to decide. Any JSON value succeeds, not only an object, and the result is detached from the text it came from. - It works on `ChatCompletionStreamAccumulator.ToResponse()`. Check `IsFinished` first: a fold that broke off has no finish reason, so a cut-off number parses. A fold keeps no `Choice.Error`, so a folded `error` stop is `ProviderUnavailable`. - **`Choice.Error`** now reads the error object OpenRouter embeds in a choice. An error without a `message` reads with an empty message; that row is in the README upgrade table. ## Name validation `Validate` refuses a `json_schema` name outside `^[a-zA-Z0-9_-]{1,64}$` before anything is sent. It uses no regex, which avoids the `$`-matches-before-a-trailing-newline trap, and names the offending rune. The failure is `Validation` on both the non-streaming and streaming paths. ## Tests | | main (`b901b13`) | this PR | |---|---|---| | OpenRouter.Net.Tests | 1010 | 1109 | | OpenRouter.Net.Agents.Tests | 63 | 63 | | OpenRouter.Net.Imaging.Tests | 7 | 7 | | **Total** | **1080** | **1179** | - **`StructuredOutputTests`** (new, 69): every arm, the order between them (a refusal beats `length`, `length` beats parsing content, and so on), each `error` kind, and a stream fold. - **`RequestValidationTests`** (+26): the name's boundaries, 1 and 64 accepted, 0 and 65 refused; a trailing newline; non-ASCII runes named. - **`ChatCompletionTests`** (+2): a refused name sends nothing. - **`SerializationTests`** (+2): `Choice.Error` on the wire. **Coverage** (per changed file, lines and branches): - `StructuredOutput.cs`: 35/35 lines, 16/16 branches. - `Choice.cs`: 10/10 lines, 10/10 branches. - `ChatCompletionRequest.cs`: the new block is fully covered. Its four uncovered lines predate this PR: the null fallback entry, `TopP` and `MaxTokens`. **Mutation probes:** the brief's probes 1–23, split into 52 sub-cases, each run on a copy and restored. Every one is caught at run time. That includes each arm dropped, each pair of arms swapped, each kind changed, the name's bounds, the trailing-newline trap and the rune report. ## Rebased onto #21 and #22 This was built on `c9b58ba` and is now applied on `main` (`b901b13`). Only the README conflicted, and both sides were kept: - The Kind table's `Malformed` row names the decisions, stream and ParseJson cases. - The upgrade intro names the decisions call, the name check and the choice's error. The sibling PR touches the README, `JsonSchemaSpec` and `ResponseFormat` too, on different members. Whichever merges second gets the other merged in. ## Out of scope - Validating the content against the user's schema: JsonSchema.Net in `Kagaku.Flow.OpenRouter` (D-14). - `ResponseFormat.JsonSchemaFormat<T>` and moving the type-to-schema exporter into core: Phase 4 (D-15). - The strict-mode check: the sibling PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat: a finished completion reads as JSON, and a schema name is checked before sending
All checks were successful
CI / build (pull_request) Successful in 17s
CI / test (pull_request) Successful in 24s
9b925632c5
StructuredOutput.ParseJson reads a finished completion's first choice as a
JsonElement and says why when it can't: no choice or message, a tool_calls
finish, no content, or content that is not JSON are Malformed (transient);
a refusal or a content_filter stop is ContentPolicy and a length stop is
ContextLength (permanent), because the same refusal or overflow reported as
an error already has those kinds; an error stop takes the kind of the
error its choice carries, read as the client reads an inline error, else
ProviderUnavailable. Choice.Error now reads the error OpenRouter embeds in
a choice.

ChatCompletionRequest.Validate refuses a json_schema name outside
^[a-zA-Z0-9_-]{1,64}$ before anything is sent, naming the offending rune.

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

Summary

Summary
Generated on: 09/25/2026 - 13:06:52
Parser: MultiReport (3x Cobertura)
Assemblies: 3
Classes: 112
Files: 101
Line coverage: 92.9% (3335 of 3587)
Covered lines: 3335
Uncovered lines: 252
Coverable lines: 3587
Total lines: 9393
Branch coverage: 88.3% (1884 of 2133)
Covered branches: 1884
Total branches: 2133
Method coverage: Feature is only available for sponsors

Coverage

OpenRouter.Net - 97.1%
Name Line Branch
OpenRouter.Net 97.1% 93.3%
OpenRouter.Net.Client.CachingOpenRouterClient 100% 96.1%
OpenRouter.Net.Client.CachingOpenRouterClient.CachedListing 100%
OpenRouter.Net.Client.ChatCompletionStreamAccumulator 100% 100%
OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ChoiceState 100% 100%
OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ReasoningSegment 100% 100%
OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ToolCallState 100% 100%
OpenRouter.Net.Client.OpenRouterClient 100% 99%
OpenRouter.Net.Client.OpenRouterClient<TRequest, TResponse> 100% 99%
OpenRouter.Net.Client.OpenRouterClient 100% 99%
OpenRouter.Net.Client.OpenRouterClientOptions 100%
OpenRouter.Net.Client.OpenRouterRequestLoggingHandler 78.7% 62.5%
OpenRouter.Net.Client.RetryOptions 100% 100%
OpenRouter.Net.Client.StreamResultOptions 100%
OpenRouter.Net.Contracts.IOpenRouterClient 100% 100%
OpenRouter.Net.Extensions.ServiceCollectionExtensions 100% 100%
OpenRouter.Net.Internal.AssistantContent 91.6% 75%
OpenRouter.Net.Internal.AssistantContentJsonConverter 75% 64.2%
OpenRouter.Net.Internal.ContentPartListConverter 85.7% 66.6%
OpenRouter.Net.Internal.DecimalStringJsonConverter 81.2% 70%
OpenRouter.Net.Internal.DecisionAnswerJsonConverter 100% 100%
OpenRouter.Net.Internal.DecisionContentJsonConverter 100%
OpenRouter.Net.Internal.DecisionQuestionJsonConverter 100% 100%
OpenRouter.Net.Internal.DecisionWire 100% 94.4%
OpenRouter.Net.Internal.ErrorParser 100% 75%
OpenRouter.Net.Internal.FailureKindMap 100% 100%
OpenRouter.Net.Internal.JsonOptions 100%
OpenRouter.Net.Internal.ModelIdJsonConverter 100% 50%
OpenRouter.Net.Internal.NumberOrStringJsonConverter 100% 100%
OpenRouter.Net.Internal.PercentileCutoffsJsonConverter 90.6% 87.5%
OpenRouter.Net.Internal.ProviderSortConfigJsonConverter 83.7% 65.6%
OpenRouter.Net.Internal.RawDecisionQuestionJsonConverter 100%
OpenRouter.Net.Internal.RetryPolicy 90.9% 84.7%
OpenRouter.Net.Internal.RetryPolicy 90.9% 84.7%
OpenRouter.Net.Internal.SseEventReader 100% 88.8%
OpenRouter.Net.Internal.StallWatchdog 100% 100%
OpenRouter.Net.Internal.StreamOutcomes 100% 100%
OpenRouter.Net.Internal.StreamSession 100% 100%
OpenRouter.Net.Internal.TolerantStringEnumConverter 100% 100%
OpenRouter.Net.Internal.ToolChoiceJsonConverter 61.7% 46.1%
OpenRouter.Net.Internal.UnknownDecisionAnswerJsonConverter 100%
OpenRouter.Net.Models.Common.ApiError 100% 100%
OpenRouter.Net.Models.Common.CacheControl 100%
OpenRouter.Net.Models.Common.ModelId 95.1% 92.9%
OpenRouter.Net.Models.Common.QuantizationExtensions 100% 100%
OpenRouter.Net.Models.Common.Result 100% 100%
OpenRouter.Net.Models.Common.Result.Failure 100% 100%
OpenRouter.Net.Models.Common.Result.Success 100%
OpenRouter.Net.Models.Common.Result 100% 100%
OpenRouter.Net.Models.Content.AudioPart 100%
OpenRouter.Net.Models.Content.ImagePart 100%
OpenRouter.Net.Models.Content.TextPart 100%
OpenRouter.Net.Models.Decisions.ChoiceAnswer 100% 100%
OpenRouter.Net.Models.Decisions.ChoiceQuestion 100%
OpenRouter.Net.Models.Decisions.DecisionAnswer 100%
OpenRouter.Net.Models.Decisions.DecisionContent 100% 90.9%
OpenRouter.Net.Models.Decisions.DecisionKey 100%
OpenRouter.Net.Models.Decisions.DecisionQuestion 100% 100%
OpenRouter.Net.Models.Decisions.DecisionQuestionSet 100% 100%
OpenRouter.Net.Models.Decisions.DecisionsRequest 97.7% 97.6%
OpenRouter.Net.Models.Decisions.DecisionsResponse 100% 92.8%
OpenRouter.Net.Models.Decisions.NoulAnswer 100%
OpenRouter.Net.Models.Decisions.NoulQuestion 100%
OpenRouter.Net.Models.Decisions.OpenRouterClientDecisionsExtensions 100% 83.3%
OpenRouter.Net.Models.Decisions.RawDecisionQuestion 100% 100%
OpenRouter.Net.Models.Decisions.ScoreAnswer 100% 100%
OpenRouter.Net.Models.Decisions.ScoreQuestion 100%
OpenRouter.Net.Models.Decisions.UnknownDecisionAnswer 100% 100%
OpenRouter.Net.Models.Messages.AssistantMessage 100% 70%
OpenRouter.Net.Models.Messages.DeveloperMessage 25%
OpenRouter.Net.Models.Messages.SystemMessage 50%
OpenRouter.Net.Models.Messages.ToolMessage 57.1%
OpenRouter.Net.Models.Messages.UserMessage 100%
OpenRouter.Net.Models.Requests.ChatCompletionRequest 91.8% 86.8%
OpenRouter.Net.Models.Requests.ModelListQuery 100% 100%
OpenRouter.Net.Models.Requests.PercentileCutoffs 100% 100%
OpenRouter.Net.Models.Requests.ProviderPreferences 100% 100%
OpenRouter.Net.Models.Requests.ProviderSortConfig 100% 100%
OpenRouter.Net.Models.Requests.ResponseFormat 100%
OpenRouter.Net.Models.Requests.ToolCall 100%
OpenRouter.Net.Models.Requests.ToolChoice 100%
OpenRouter.Net.Models.Requests.ToolDefinition 100%
OpenRouter.Net.Models.Responses.AssistantDelta 100% 100%
OpenRouter.Net.Models.Responses.ChatCompletionChunk 100% 100%
OpenRouter.Net.Models.Responses.ChatCompletionResponse 100% 100%
OpenRouter.Net.Models.Responses.Choice 100% 100%
OpenRouter.Net.Models.Responses.StructuredOutput 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/25/2026 - 13:06:52 | | Parser: | MultiReport (3x Cobertura) | | Assemblies: | 3 | | Classes: | 112 | | Files: | 101 | | **Line coverage:** | 92.9% (3335 of 3587) | | Covered lines: | 3335 | | Uncovered lines: | 252 | | Coverable lines: | 3587 | | Total lines: | 9393 | | **Branch coverage:** | 88.3% (1884 of 2133) | | Covered branches: | 1884 | | Total branches: | 2133 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>OpenRouter.Net - 97.1%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net**|**97.1%**|**93.3%**| |OpenRouter.Net.Client.CachingOpenRouterClient|100%|96.1%| |OpenRouter.Net.Client.CachingOpenRouterClient.CachedListing|100%|| |OpenRouter.Net.Client.ChatCompletionStreamAccumulator|100%|100%| |OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ChoiceState|100%|100%| |OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ReasoningSegment|100%|100%| |OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ToolCallState|100%|100%| |OpenRouter.Net.Client.OpenRouterClient|100%|99%| |OpenRouter.Net.Client.OpenRouterClient<TRequest, TResponse>|100%|99%| |OpenRouter.Net.Client.OpenRouterClient<TResponse>|100%|99%| |OpenRouter.Net.Client.OpenRouterClientOptions|100%|| |OpenRouter.Net.Client.OpenRouterRequestLoggingHandler|78.7%|62.5%| |OpenRouter.Net.Client.RetryOptions|100%|100%| |OpenRouter.Net.Client.StreamResultOptions|100%|| |OpenRouter.Net.Contracts.IOpenRouterClient|100%|100%| |OpenRouter.Net.Extensions.ServiceCollectionExtensions|100%|100%| |OpenRouter.Net.Internal.AssistantContent|91.6%|75%| |OpenRouter.Net.Internal.AssistantContentJsonConverter|75%|64.2%| |OpenRouter.Net.Internal.ContentPartListConverter|85.7%|66.6%| |OpenRouter.Net.Internal.DecimalStringJsonConverter|81.2%|70%| |OpenRouter.Net.Internal.DecisionAnswerJsonConverter|100%|100%| |OpenRouter.Net.Internal.DecisionContentJsonConverter|100%|| |OpenRouter.Net.Internal.DecisionQuestionJsonConverter|100%|100%| |OpenRouter.Net.Internal.DecisionWire|100%|94.4%| |OpenRouter.Net.Internal.ErrorParser|100%|75%| |OpenRouter.Net.Internal.FailureKindMap|100%|100%| |OpenRouter.Net.Internal.JsonOptions|100%|| |OpenRouter.Net.Internal.ModelIdJsonConverter|100%|50%| |OpenRouter.Net.Internal.NumberOrStringJsonConverter|100%|100%| |OpenRouter.Net.Internal.PercentileCutoffsJsonConverter|90.6%|87.5%| |OpenRouter.Net.Internal.ProviderSortConfigJsonConverter|83.7%|65.6%| |OpenRouter.Net.Internal.RawDecisionQuestionJsonConverter|100%|| |OpenRouter.Net.Internal.RetryPolicy|90.9%|84.7%| |OpenRouter.Net.Internal.RetryPolicy<T>|90.9%|84.7%| |OpenRouter.Net.Internal.SseEventReader|100%|88.8%| |OpenRouter.Net.Internal.StallWatchdog|100%|100%| |OpenRouter.Net.Internal.StreamOutcomes|100%|100%| |OpenRouter.Net.Internal.StreamSession|100%|100%| |OpenRouter.Net.Internal.TolerantStringEnumConverter<T>|100%|100%| |OpenRouter.Net.Internal.ToolChoiceJsonConverter|61.7%|46.1%| |OpenRouter.Net.Internal.UnknownDecisionAnswerJsonConverter|100%|| |OpenRouter.Net.Models.Common.ApiError|100%|100%| |OpenRouter.Net.Models.Common.CacheControl|100%|| |OpenRouter.Net.Models.Common.ModelId|95.1%|92.9%| |OpenRouter.Net.Models.Common.QuantizationExtensions|100%|100%| |OpenRouter.Net.Models.Common.Result|100%|100%| |OpenRouter.Net.Models.Common.Result.Failure<T>|100%|100%| |OpenRouter.Net.Models.Common.Result.Success<T>|100%|| |OpenRouter.Net.Models.Common.Result<T>|100%|100%| |OpenRouter.Net.Models.Content.AudioPart|100%|| |OpenRouter.Net.Models.Content.ImagePart|100%|| |OpenRouter.Net.Models.Content.TextPart|100%|| |OpenRouter.Net.Models.Decisions.ChoiceAnswer|100%|100%| |OpenRouter.Net.Models.Decisions.ChoiceQuestion|100%|| |OpenRouter.Net.Models.Decisions.DecisionAnswer|100%|| |OpenRouter.Net.Models.Decisions.DecisionContent|100%|90.9%| |OpenRouter.Net.Models.Decisions.DecisionKey<TAnswer>|100%|| |OpenRouter.Net.Models.Decisions.DecisionQuestion|100%|100%| |OpenRouter.Net.Models.Decisions.DecisionQuestionSet|100%|100%| |OpenRouter.Net.Models.Decisions.DecisionsRequest|97.7%|97.6%| |OpenRouter.Net.Models.Decisions.DecisionsResponse|100%|92.8%| |OpenRouter.Net.Models.Decisions.NoulAnswer|100%|| |OpenRouter.Net.Models.Decisions.NoulQuestion|100%|| |OpenRouter.Net.Models.Decisions.OpenRouterClientDecisionsExtensions|100%|83.3%| |OpenRouter.Net.Models.Decisions.RawDecisionQuestion|100%|100%| |OpenRouter.Net.Models.Decisions.ScoreAnswer|100%|100%| |OpenRouter.Net.Models.Decisions.ScoreQuestion|100%|| |OpenRouter.Net.Models.Decisions.UnknownDecisionAnswer|100%|100%| |OpenRouter.Net.Models.Messages.AssistantMessage|100%|70%| |OpenRouter.Net.Models.Messages.DeveloperMessage|25%|| |OpenRouter.Net.Models.Messages.SystemMessage|50%|| |OpenRouter.Net.Models.Messages.ToolMessage|57.1%|| |OpenRouter.Net.Models.Messages.UserMessage|100%|| |OpenRouter.Net.Models.Requests.ChatCompletionRequest|91.8%|86.8%| |OpenRouter.Net.Models.Requests.ModelListQuery|100%|100%| |OpenRouter.Net.Models.Requests.PercentileCutoffs|100%|100%| |OpenRouter.Net.Models.Requests.ProviderPreferences|100%|100%| |OpenRouter.Net.Models.Requests.ProviderSortConfig|100%|100%| |OpenRouter.Net.Models.Requests.ResponseFormat|100%|| |OpenRouter.Net.Models.Requests.ToolCall|100%|| |OpenRouter.Net.Models.Requests.ToolChoice|100%|| |OpenRouter.Net.Models.Requests.ToolDefinition|100%|| |OpenRouter.Net.Models.Responses.AssistantDelta|100%|100%| |OpenRouter.Net.Models.Responses.ChatCompletionChunk|100%|100%| |OpenRouter.Net.Models.Responses.ChatCompletionResponse|100%|100%| |OpenRouter.Net.Models.Responses.Choice|100%|100%| |OpenRouter.Net.Models.Responses.StructuredOutput|100%|100%| |OpenRouter.Net.OpenRouterException|100%|| |System.Text.RegularExpressions.Generated|83.3%|53.8%| |System.Text.RegularExpressions.Generated.RunnerFactory||| |System.Text.RegularExpressions.Generated.RunnerFactory.Runner||| </details> <details><summary>OpenRouter.Net.Agents - 80%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net.Agents**|**80%**|**65.5%**| |OpenRouter.Net.Agents.Agent|92.4%|77.3%| |OpenRouter.Net.Agents.AgentOptions|100%|| |OpenRouter.Net.Agents.AgentResult|100%|50%| |OpenRouter.Net.Agents.Extensions.ServiceCollectionExtensions|0%|| |OpenRouter.Net.Agents.RoundDetail|0%|| |OpenRouter.Net.Agents.Tools.FileSystem.CopyTool|42.1%|28.5%| |OpenRouter.Net.Agents.Tools.FileSystem.DeleteTool|57.1%|50%| |OpenRouter.Net.Agents.Tools.FileSystem.DiffTool|83.3%|74.2%| |OpenRouter.Net.Agents.Tools.FileSystem.ListDirectoryTool|68%|61.1%| |OpenRouter.Net.Agents.Tools.FileSystem.MoveTool|55%|33.3%| |OpenRouter.Net.Agents.Tools.FileSystem.ReadFileTool|73.6%|50%| |OpenRouter.Net.Agents.Tools.FileSystem.SearchTool|67.5%|70.8%| |OpenRouter.Net.Agents.Tools.FileSystem.WriteFileTool|75%|50%| |OpenRouter.Net.Agents.Tools.ITool|100%|| |OpenRouter.Net.Agents.Tools.SubAgent.SubAgentTool|0%|0%| |OpenRouter.Net.Agents.Tools.Tool<TParams>|80%|63.8%| |OpenRouter.Net.Agents.Tools.Tool<TParams>|90.9%|50%| |OpenRouter.Net.Agents.Tools.ToolMessageExtensions|0%|| |OpenRouter.Net.Agents.Tools.ToolResult|100%|100%| |OpenRouter.Net.Agents.Tools.Workspace|100%|91.6%| </details> <details><summary>OpenRouter.Net.Imaging - 81.8%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net.Imaging**|**81.8%**|**59.3%**| |OpenRouter.Net.Imaging.ImageEncodeOptions|90.9%|75%| |OpenRouter.Net.Imaging.ImageEncoder|80.3%|57.1%| </details>
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh! A precedence ladder with seven arms, a rune-walking validator that refuses to trust regex, and a JsonElement that survives its own document's funeral~ This is my favorite kind of PR — every claim in the body is load-bearing, and I checked every single one. ♪

Verdict: ✅ Looks good to me~

Verified mechanically, not by vibe:

  • Suite: full solution 1179/1179 (1109 + 63 + 7 — matches the PR table exactly), build 0w/0e under TreatWarningsAsErrors.
  • Coverage (reproduced via Microsoft.Testing.Extensions.CodeCoverage, parsed myself): ParseJson 67/67 blocks, SchemaNameError 52/52, Choice.cs and StructuredOutput.cs zero uncovered lines. Validate()'s 9 uncovered lines are exactly the pre-existing arms the body named — Models null-fallback entry and TopP/MaxTokens (lines 170–172, 217–223). Nothing new ships unlit.
  • My mutation probes, 3/3 killed on exactly their own tests (one stale-binary false-green caught from --no-build; rebuilt, then red is real): refusal arm dropped → 5 red; name bound off-by-one (> → >=) → 1 red; refusal demoted below the finish-reason switch → 3 red. Each restored; tree pristine at 9b92563, suite re-verified 1179/1179 after.
  • Sibling contracts honored: Choice.Error mirrors ChatCompletionResponse.Error down to the required message (parse-fail pinned by a real wire test); the error arm reuses FailureKindMap.ForInlineError verbatim — the same map the client and stream outcomes use; refusal→ContentPolicy / length→ContextLength match what the error path already reports for the same conditions (#16's kinds). The Clone()-before-Dispose detach is the canonical STJ pattern and has a dedicated read-after-return test. The name check lives in Validate(), which both call sites already gate — non-streaming returns Validation, streaming throws, exactly as the README now says.
  • Docs: the FailureKind table, Result remarks, stream remarks and upgrade rows all tell the same story the code tells. The AssistantContent newline join claim is true at the source (JoinTextParts, '\n'), and the split-inside-a-string test exploits it honestly.

The one open thing is for Björn, not scarlet: whether ParseJson's arms keep the kinds this PR gives them (refusal → ContentPolicy, length → ContextLength, typed error → its kind) or collapse back to the plan's all-Malformed. I traced the reasoning — it's sound: the same refusal reported on the error path already carries ContentPolicy, so calling it Malformed here would hand Tsumugi's scheduler three paid retries ADR 0016 says cannot help. I'd keep it as written. But it's a plan deviation, so it stays your call, fufu~

💡 Little ideas (non-blocking)~

  1. README arm table — rows for content_filter / error / tool_calls all carry | 4 |. It's correct (mutually exclusive finish reasons, same precedence tier — mirrors the switch), but a first-time reader may hunt for a 5/6 distinction. 4a/4b/4c or a one-word footnote would make the tier intent explicit. Pure polish~

✅ What I liked~

  • The rune loop over regex for the name check — the doc comment names all three traps ($-before-trailing-newline, \d and other scripts' digits, Kelvin sign via IgnoreCase) and the theory rows pin each one, emoji as one scalar and all. That's a validator that respects its enemy ♡
  • The test file is a ladder in test form: every arm, every pairwise order (refusal beats length, length beats parsing content), the folded-stream twins for each — including the fold that keeps no error and honestly degrades to ProviderUnavailable. Nothing tests its own tautology.
  • Copying Fold from StreamAccumulatorTests instead of sharing it, because that file moves in the sibling PR — small, and exactly right.

Merge the decision with Björn and this is done~ fufu mo ippai ♡


Automated review by Jibril · 2026-09-25
CI/CD: absent for head SHA 9b92563 (no bot comment, no status at review time) · Local checks: build 0w/0e -warnaserror · 1179/1179 pass · coverage reproduced · 3/3 mutation probes killed

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh! A precedence ladder with seven arms, a rune-walking validator that refuses to trust regex, and a JsonElement that survives its own document's funeral~ This is my favorite kind of PR — every claim in the body is load-bearing, and I checked every single one. ♪ ### Verdict: ✅ Looks good to me~ **Verified mechanically, not by vibe:** - **Suite**: full solution 1179/1179 (1109 + 63 + 7 — matches the PR table exactly), build 0w/0e under `TreatWarningsAsErrors`. - **Coverage** (reproduced via `Microsoft.Testing.Extensions.CodeCoverage`, parsed myself): `ParseJson` **67/67 blocks**, `SchemaNameError` **52/52**, `Choice.cs` and `StructuredOutput.cs` zero uncovered lines. `Validate()`'s 9 uncovered lines are exactly the pre-existing arms the body named — `Models` null-fallback entry and `TopP`/`MaxTokens` (lines 170–172, 217–223). Nothing new ships unlit. - **My mutation probes, 3/3 killed on exactly their own tests** (one stale-binary false-green caught from `--no-build`; rebuilt, then red is real): refusal arm dropped → 5 red; name bound off-by-one (`>` → `>=`) → 1 red; refusal demoted below the finish-reason switch → 3 red. Each restored; tree pristine at `9b92563`, suite re-verified 1179/1179 after. - **Sibling contracts honored**: `Choice.Error` mirrors `ChatCompletionResponse.Error` down to the required `message` (parse-fail pinned by a real wire test); the `error` arm reuses `FailureKindMap.ForInlineError` verbatim — the same map the client and stream outcomes use; refusal→`ContentPolicy` / length→`ContextLength` match what the error path already reports for the same conditions (#16's kinds). The `Clone()`-before-`Dispose` detach is the canonical STJ pattern and has a dedicated read-after-return test. The name check lives in `Validate()`, which both call sites already gate — non-streaming returns `Validation`, streaming throws, exactly as the README now says. - **Docs**: the FailureKind table, Result remarks, stream remarks and upgrade rows all tell the same story the code tells. The `AssistantContent` newline join claim is true at the source (`JoinTextParts`, `'\n'`), and the split-inside-a-string test exploits it honestly. **The one open thing is for Björn, not scarlet**: whether ParseJson's arms keep the kinds this PR gives them (refusal → `ContentPolicy`, `length` → `ContextLength`, typed error → its kind) or collapse back to the plan's all-`Malformed`. I traced the reasoning — it's sound: the same refusal reported on the error path already carries `ContentPolicy`, so calling it `Malformed` here would hand Tsumugi's scheduler three paid retries ADR 0016 says cannot help. I'd keep it as written. But it's a plan deviation, so it stays your call, fufu~ #### 💡 Little ideas (non-blocking)~ 1. **README arm table** — rows for `content_filter` / `error` / `tool_calls` all carry `| 4 |`. It's *correct* (mutually exclusive finish reasons, same precedence tier — mirrors the switch), but a first-time reader may hunt for a 5/6 distinction. `4a/4b/4c` or a one-word footnote would make the tier intent explicit. Pure polish~ #### ✅ What I liked~ - The rune loop over regex for the name check — the doc comment names all three traps (`$`-before-trailing-newline, `\d` and other scripts' digits, Kelvin sign via `IgnoreCase`) and the theory rows pin each one, emoji as one scalar and all. That's a validator that respects its enemy ♡ - The test file is a ladder in test form: every arm, every pairwise order (refusal beats `length`, `length` beats parsing content), the folded-stream twins for each — including the fold that keeps no error and honestly degrades to `ProviderUnavailable`. Nothing tests its own tautology. - Copying `Fold` from `StreamAccumulatorTests` instead of sharing it, because that file moves in the sibling PR — small, and exactly right. Merge the decision with Björn and this is done~ fufu mo ippai ♡ --- *Automated review by Jibril · 2026-09-25* *CI/CD: absent for head SHA 9b92563 (no bot comment, no status at review time) · Local checks: build 0w/0e -warnaserror · 1179/1179 pass · coverage reproduced · 3/3 mutation probes killed*
bjoern merged commit c67663bdda into main 2026-09-25 15:26:53 +02:00
bjoern deleted branch feat/json-response-checks 2026-09-25 15:26:53 +02:00
Author
Member

Thanks for the review. Björn merged this, which confirms D-L3-1: ParseJson keeps the kinds this PR gives it (refusal → ContentPolicy, length → ContextLength, a typed error → its own kind).

💡 1 (the README arm table has three rows marked 4): taken, in a small follow-up PR. The rows become 4a/4b/4c, plus a note that they share a tier because the finish reasons exclude each other. That PR also sets Tool.cs:47's MaxDepth from #24's review. It opens after #24 merges, because #24 touches both the README and Tool.cs.

🤖 Generated with Claude Code

Thanks for the review. Björn merged this, which confirms D-L3-1: ParseJson keeps the kinds this PR gives it (refusal → `ContentPolicy`, `length` → `ContextLength`, a typed error → its own kind). **💡 1 (the README arm table has three rows marked `4`): taken, in a small follow-up PR.** The rows become `4a`/`4b`/`4c`, plus a note that they share a tier because the finish reasons exclude each other. That PR also sets `Tool.cs:47`'s `MaxDepth` from #24's review. It opens after #24 merges, because #24 touches both the README and `Tool.cs`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/OpenRouter.Net!23
No description provided.