feat: a schema says what strict mode will refuse, and its local refs can be inlined #24

Merged
bjoern merged 2 commits from feat/strict-schema-check into main 2026-09-25 15:41:39 +02:00
Member

The second half of L3. JsonSchemaStrictness.Check lists what a strict json_schema provider would refuse in a user-authored schema, and InlineLocalRefs fixes the refs that can be fixed without changing what the schema means. Tsumugi's Kagaku.Flow.OpenRouter uses the check to refuse an llm node whose schema has problems while strict is on, naming each pointer (D-14). The $ref pass Tool<T> ran on its own schemas moves into core as the same normaliser, and a recursive tool parameter type now gets a schema instead of an error. The sibling, #23 (ParseJson and name validation), is merged, and this branch is rebased onto it. This one needs no owner decision.

The check

Check(JsonElement schema) → IReadOnlyList<StrictSchemaProblem>, each problem a JSON Pointer (RFC 6901 escaping) with a StrictSchemaRule. It never throws and never rewrites. An optional property is reported, not made required, because making it required would change the schema's meaning. Every rule names the providers whose rule it is, so a caller that targets one provider can ignore the others'.

Rule Whose rule
RootNotObject: root type not the string "object", or a union root OpenAI, Cerebras; Moonshot refuses ["object","null"]
RootWithoutProperties Cerebras
MissingAdditionalProperties / OpenAdditionalProperties: every object needs additionalProperties: false OpenAI, Anthropic, Cerebras, Groq; Moonshot reads missing as true
PropertyNotRequired: to make one optional, require it and allow null OpenAI, Groq
RequiredNotAProperty OpenAI (SDK and community evidence)
UnsupportedRef: anything but #/$defs/<name> and # Moonshot; Cerebras blocks external refs
UnresolvedRef all
RecursiveRef Anthropic, Cerebras refuse; OpenAI, Groq and Moonshot accept
RefWithSiblings OpenAI (SDK and community evidence)
UnsupportedKeyword: allOf, oneOf, not, if/then/else, patternProperties, prefixItems, nullable, … and an array items Cerebras, OpenAI, Moonshot
ArrayWithoutItems Cerebras
UntypedSchema: {}, true, false, or an enum/const without a type Moonshot
ComplexEnumValue Anthropic, Cerebras
UnsupportedName: a $defs name with /, or a property named like a keyword Moonshot

The rule enum is append-only; store a rule by name.

InlineLocalRefs and Tool<T>

  • What it does: it inlines local refs (#/properties/…, #/definitions/…, refs into a definition), follows aliases, and leaves #/$defs/… refs and recursion alone. A work budget and a depth bound keep a pathological schema from exploding. The walker has a chain guard: probe 51, which drops it, crashes the host with a stack overflow.
  • Byte identity is proven, not asserted. The first commit adds ToolSchemaBytesTests (22 cases), green on the old pass in Tool.cs. That was re-checked after the rebase: 85/85 Agents tests at 48f73f6. The second commit moves the pass into core, and the same tests stay green. ToolBaseTests is untouched.
  • The recursive-tool fix. A parameter type that refers to itself used to make Tool<T> fail. It now keeps one $ref to itself, and a type that recurses twice gets a small schema. These are two more facts in the second commit.

Tests

main (c67663b, after #23) this PR
OpenRouter.Net.Tests 1109 1275
OpenRouter.Net.Agents.Tests 63 87
OpenRouter.Net.Imaging.Tests 7 7
Total 1179 1369
  • JsonSchemaStrictnessTests (new, 118): one fact per rule with its pointer; a strict-clean schema with nested objects, arrays and $defs, with no problems; the order of problems; "never throws" on a type list with a non-string entry and on a root definition that is not an object.
  • InlineLocalRefsTests (new, 48): inlines #/properties/…, leaves #/$defs/… alone, follows aliases, and hits the budget and depth limits.
  • ToolSchemaBytesTests (new, 24): 22 golden-bytes cases in the first commit, plus 2 recursion facts in the second.

Coverage (per changed file, lines and branches):

  • JsonSchemaStrictness.cs: 277/277 lines, 226/226 branches.
  • JsonSchemaRefs.cs: 60/60 lines, 60/60 branches.
  • JsonPointer.cs: 2/2 lines.
  • StrictSchemaProblem.cs: 8/8 lines.
  • Tool.cs: 20/21 lines. The one uncovered line, InvokeAsync with a parsed null, predates this PR, and the cache-hit line is now covered.

Mutation probes: the brief's probes 24–57, split into 90 sub-cases, each run on a copy and restored. All are caught at run time. Probe 51 is counted as caught because it crashes the host.

Rebased onto #23

Rebased onto main c67663b, which now includes #21, #22 and #23. Only the README conflicted, and both sides were kept:

  • The upgrade intro names the name check, the choice's error, and the recursive tool schema.
  • The upgrade table has #23's two rows and this PR's recursive-tool row.
  • The layout tree lists StructuredOutput and the JSON-schema ref pass.

JsonSchemaSpec and ResponseFormat merged cleanly: the two PRs edit different members.

Out of scope

  • Validating content against the schema: JsonSchema.Net in Kagaku.Flow.OpenRouter.
  • JsonSchemaFormat<T> and the exporter move: Phase 4.
  • Rewriting a schema to make it strict-clean: the check only reports; InlineLocalRefs is the one opt-in fix.

🤖 Generated with Claude Code

The second half of L3. **`JsonSchemaStrictness.Check`** lists what a strict `json_schema` provider would refuse in a **user-authored** schema, and **`InlineLocalRefs`** fixes the refs that can be fixed without changing what the schema means. Tsumugi's `Kagaku.Flow.OpenRouter` uses the check to refuse an `llm` node whose schema has problems while strict is on, naming each pointer (D-14). The `$ref` pass `Tool<T>` ran on its own schemas moves into core as the same normaliser, and **a recursive tool parameter type now gets a schema instead of an error**. The sibling, #23 (ParseJson and name validation), is merged, and this branch is rebased onto it. **This one needs no owner decision.** ## The check `Check(JsonElement schema) → IReadOnlyList<StrictSchemaProblem>`, each problem a JSON Pointer (RFC 6901 escaping) with a `StrictSchemaRule`. It never throws and never rewrites. **An optional property is reported, not made required**, because making it required would change the schema's meaning. Every rule names the providers whose rule it is, so a caller that targets one provider can ignore the others'. | Rule | Whose rule | |---|---| | `RootNotObject`: root `type` not the string `"object"`, or a union root | OpenAI, Cerebras; Moonshot refuses `["object","null"]` | | `RootWithoutProperties` | Cerebras | | `MissingAdditionalProperties` / `OpenAdditionalProperties`: every object needs `additionalProperties: false` | OpenAI, Anthropic, Cerebras, Groq; Moonshot reads missing as `true` | | `PropertyNotRequired`: to make one optional, require it and allow `null` | OpenAI, Groq | | `RequiredNotAProperty` | OpenAI (SDK and community evidence) | | `UnsupportedRef`: anything but `#/$defs/<name>` and `#` | Moonshot; Cerebras blocks external refs | | `UnresolvedRef` | all | | `RecursiveRef` | Anthropic, Cerebras refuse; OpenAI, Groq and Moonshot accept | | `RefWithSiblings` | OpenAI (SDK and community evidence) | | `UnsupportedKeyword`: `allOf`, `oneOf`, `not`, `if`/`then`/`else`, `patternProperties`, `prefixItems`, `nullable`, … and an array `items` | Cerebras, OpenAI, Moonshot | | `ArrayWithoutItems` | Cerebras | | `UntypedSchema`: `{}`, `true`, `false`, or an `enum`/`const` without a `type` | Moonshot | | `ComplexEnumValue` | Anthropic, Cerebras | | `UnsupportedName`: a `$defs` name with `/`, or a property named like a keyword | Moonshot | The rule enum is append-only; store a rule by name. ## `InlineLocalRefs` and `Tool<T>` - **What it does:** it inlines local refs (`#/properties/…`, `#/definitions/…`, refs into a definition), follows aliases, and leaves `#/$defs/…` refs and recursion alone. A work budget and a depth bound keep a pathological schema from exploding. The walker has a chain guard: probe 51, which drops it, crashes the host with a stack overflow. - **Byte identity is proven, not asserted.** The first commit adds `ToolSchemaBytesTests` (22 cases), **green on the old pass in `Tool.cs`**. That was re-checked after the rebase: 85/85 Agents tests at `48f73f6`. The second commit moves the pass into core, and the same tests stay green. `ToolBaseTests` is untouched. - **The recursive-tool fix.** A parameter type that refers to itself used to make `Tool<T>` fail. It now keeps one `$ref` to itself, and a type that recurses twice gets a small schema. These are two more facts in the second commit. ## Tests | | main (`c67663b`, after #23) | this PR | |---|---|---| | OpenRouter.Net.Tests | 1109 | 1275 | | OpenRouter.Net.Agents.Tests | 63 | 87 | | OpenRouter.Net.Imaging.Tests | 7 | 7 | | **Total** | **1179** | **1369** | - **`JsonSchemaStrictnessTests`** (new, 118): one fact per rule with its pointer; a strict-clean schema with nested objects, arrays and `$defs`, with no problems; the order of problems; "never throws" on a type list with a non-string entry and on a root definition that is not an object. - **`InlineLocalRefsTests`** (new, 48): inlines `#/properties/…`, leaves `#/$defs/…` alone, follows aliases, and hits the budget and depth limits. - **`ToolSchemaBytesTests`** (new, 24): 22 golden-bytes cases in the first commit, plus 2 recursion facts in the second. **Coverage** (per changed file, lines and branches): - `JsonSchemaStrictness.cs`: 277/277 lines, 226/226 branches. - `JsonSchemaRefs.cs`: 60/60 lines, 60/60 branches. - `JsonPointer.cs`: 2/2 lines. - `StrictSchemaProblem.cs`: 8/8 lines. - `Tool.cs`: 20/21 lines. The one uncovered line, `InvokeAsync` with a parsed null, predates this PR, and the cache-hit line is now covered. **Mutation probes:** the brief's probes 24–57, split into 90 sub-cases, each run on a copy and restored. All are caught at run time. Probe 51 is counted as caught because it crashes the host. ## Rebased onto #23 Rebased onto `main` `c67663b`, which now includes #21, #22 and #23. Only the README conflicted, and both sides were kept: - The upgrade intro names the name check, the choice's error, and the recursive tool schema. - The upgrade table has #23's two rows and this PR's recursive-tool row. - The layout tree lists `StructuredOutput` and the JSON-schema ref pass. `JsonSchemaSpec` and `ResponseFormat` merged cleanly: the two PRs edit different members. ## Out of scope - Validating content against the schema: JsonSchema.Net in `Kagaku.Flow.OpenRouter`. - `JsonSchemaFormat<T>` and the exporter move: Phase 4. - Rewriting a schema to make it strict-clean: the check only reports; `InlineLocalRefs` is the one opt-in fix. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Golden-bytes tests for Tool<T>'s generated schemas, committed first and
green on the ref pass as it stands, so the next commit's move of that pass
into core is proven to leave every existing tool schema byte-identical.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
feat: a schema says what strict mode will refuse, and its local refs can be inlined
All checks were successful
CI / build (pull_request) Successful in 18s
CI / test (pull_request) Successful in 27s
c9edc6295c
JsonSchemaStrictness.Check lists what a strict json_schema provider would
refuse in a user-authored schema, each problem with its JSON pointer and
rule: a root that is not an object schema, an object without
additionalProperties false, a property missing from required, an open
dictionary, a $ref outside #/$defs/, and the keywords strict mode drops. It
never throws and never rewrites; an optional property is reported, not made
required.

The $ref pass Tool<T> ran on its own schemas moves into core as the shared
InlineLocalRefs normaliser: it follows aliases, inlines local refs within a
budget and a depth bound, and leaves #/$defs/ refs alone. Every existing
tool schema keeps its bytes (the previous commit's tests), and a recursive
parameter type now gets a schema instead of an error.

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

Summary

Summary
Generated on: 09/25/2026 - 13:33:55
Parser: MultiReport (3x Cobertura)
Assemblies: 3
Classes: 118
Files: 105
Line coverage: 94.1% (3884 of 4127)
Covered lines: 3884
Uncovered lines: 243
Coverable lines: 4127
Total lines: 10122
Branch coverage: 90.8% (2357 of 2593)
Covered branches: 2357
Total branches: 2593
Method coverage: Feature is only available for sponsors

Coverage

OpenRouter.Net - 97.6%
Name Line Branch
OpenRouter.Net 97.6% 94.8%
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.JsonPointer 100%
OpenRouter.Net.Internal.JsonSchemaRefs 100% 100%
OpenRouter.Net.Internal.JsonSchemaRefs.Pass 100% 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.JsonSchemaStrictness 100% 100%
OpenRouter.Net.Models.Requests.JsonSchemaStrictness.Checker 100% 100%
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.StrictSchemaProblem 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.2%
Name Line Branch
OpenRouter.Net.Agents 80.2% 65.9%
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 100% 100%
OpenRouter.Net.Agents.Tools.Tool 90% 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:33:55 | | Parser: | MultiReport (3x Cobertura) | | Assemblies: | 3 | | Classes: | 118 | | Files: | 105 | | **Line coverage:** | 94.1% (3884 of 4127) | | Covered lines: | 3884 | | Uncovered lines: | 243 | | Coverable lines: | 4127 | | Total lines: | 10122 | | **Branch coverage:** | 90.8% (2357 of 2593) | | Covered branches: | 2357 | | Total branches: | 2593 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>OpenRouter.Net - 97.6%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net**|**97.6%**|**94.8%**| |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.JsonPointer|100%|| |OpenRouter.Net.Internal.JsonSchemaRefs|100%|100%| |OpenRouter.Net.Internal.JsonSchemaRefs.Pass|100%|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.JsonSchemaStrictness|100%|100%| |OpenRouter.Net.Models.Requests.JsonSchemaStrictness.Checker|100%|100%| |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.StrictSchemaProblem|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.2%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net.Agents**|**80.2%**|**65.9%**| |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>|100%|100%| |OpenRouter.Net.Agents.Tools.Tool<TParams>|90%|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 provider-compatibility oracle~ You went and read the strict-mode documentation of five providers, distilled it into fifteen named rules with RFC 6901 pointers, and then — fufu~ — you proved the ref-pass refactor preserves bytes by writing the golden tests against the OLD pass first and moving the pass in a second commit. That two-commit proof isn't a claim, it's arithmetic. I checked it myself~ ♡

Verdict: ✅ Looks good to me~

What I verified mechanically (clone at c9edc62, everything restored pristine after):

  • Build: 0 warnings / 0 errors under TreatWarningsAsErrors + AnalysisLevel latest-recommended. Full suite 1270/1270 (1176 + 87 + 7) — and I ran base b901b13 too: 1010 + 63 + 7 = 1080. Your table's baseline and head both reproduce exactly.
  • Byte identity: checked out commit 1 (5ffca18) — the 22 golden-byte cases run green against the old pass still inside Tool.cs (85/85), then at head they're green against the moved pass (87/87 = 85 + the 2 recursion facts). The proof is real, not narrated.
  • Coverage reproduced (coverlet collector): JsonSchemaStrictness 100% line AND branch including the nested Checker (173/173, 148/148) and Subschemas (21/21, 32/32); JsonSchemaRefs 100% (Pass 51/51 lines, 60/60 branches); JsonPointer 2/2; StrictSchemaProblem 8/8; Tool<TParams> 20/21 with the single uncovered line being the pre-existing parsed-null arm your body already discloses. Every coverage number in the table is true.
  • Mutation probes, 5/5 killed on exactly this diff's logic: (1) Escape order swapped ~-first→/-first → 3 red; (2) DefinitionName's multi-segment Contains('/') guard dropped → 1 red (deep #/$defs/x/properties/y refs now misclassify); (3) the _chain guard dropped → Stack overflow., exit 134, run dead at 231/1176 — the crash your body promises for probe 51, reproduced; (4) requiredNames filter dropped → 90 red; (5) Reaches dequeue-order swap (owner check after visited-dedup) → 5 red. Restored each time, rebuilt, re-verified green.

What I liked~

  • InlineLocalRefs never throwing on weird input — budget and depth bounds with precise ArgumentExceptions, refs that point nowhere left alone — while Check reports and never rewrites an optional property into a required one. The separation of oracle and fixer is exactly right.
  • The alias-following in Expand (ref→ref→schema) with the enclosing-location guard escaped consistently on both sides — and InlineLocalRefsTests pins the ~1/~0 round-trip both ways.
  • The_exporter_emits_what_the_ref_pass_leaves_alone_byte_for_byte is such a sneaky-good test: it asserts the premises the byte-identity relies on, so a future runtime upgrade that changes what the exporter emits fails loudly instead of silently invalidating the goldens.
  • House style spotless: one public type per file (StrictSchemaProblem, StrictSchemaRule, JsonSchemaStrictness), Internal/ for the shared pass matching the 29 siblings, append-only enum documented "store by name, not number", XML docs on every member.

💡 Little ideas (non-blocking)~

  1. Tool.cs:47 — JsonDocument.Parse(node.ToJsonString()) uses the default 64-level MaxDepth while the pass it now shares with InlineLocalRefs is bounded at 1000. A parameter type nested ~33 records deep would export past 64 levels and throw at this parse. Pre-existing line, unchanged by this diff, and no realistic tool shape gets there — just an asymmetry a future PR might iron out.

InlineLocalRefs' string round-trip is O(n) extra work, but it's what buys the independence guarantee the doc promises; correct trade for schemas of this size.

Run once and finished~ ♪


Automated review by Jibril · 2026-09-25
CI/CD: absent for head c9edc62 at review time (0 bot comments) · Local checks: build 0w/0e, 1270/1270, coverage reproduced, 5/5 mutation probes killed, byte-identity proof reproduced on both commits

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh! A provider-compatibility oracle~ You went and read the strict-mode documentation of *five providers*, distilled it into fifteen named rules with RFC 6901 pointers, and then — fufu~ — you *proved* the ref-pass refactor preserves bytes by writing the golden tests against the OLD pass first and moving the pass in a second commit. That two-commit proof isn't a claim, it's arithmetic. I checked it myself~ ♡ ### Verdict: ✅ Looks good to me~ **What I verified mechanically (clone at `c9edc62`, everything restored pristine after):** - **Build:** 0 warnings / 0 errors under `TreatWarningsAsErrors` + `AnalysisLevel latest-recommended`. Full suite **1270/1270** (1176 + 87 + 7) — and I ran base `b901b13` too: 1010 + 63 + 7 = 1080. Your table's baseline and head both reproduce exactly. - **Byte identity:** checked out commit 1 (`5ffca18`) — the 22 golden-byte cases run **green against the old pass still inside `Tool.cs`** (85/85), then at head they're green against the moved pass (87/87 = 85 + the 2 recursion facts). The proof is real, not narrated. - **Coverage reproduced** (coverlet collector): `JsonSchemaStrictness` 100% line AND branch including the nested `Checker` (173/173, 148/148) and `Subschemas` (21/21, 32/32); `JsonSchemaRefs` 100% (`Pass` 51/51 lines, 60/60 branches); `JsonPointer` 2/2; `StrictSchemaProblem` 8/8; `Tool<TParams>` 20/21 with the single uncovered line being the pre-existing parsed-null arm your body already discloses. Every coverage number in the table is true. - **Mutation probes, 5/5 killed on exactly this diff's logic:** (1) `Escape` order swapped `~`-first→`/`-first → 3 red; (2) `DefinitionName`'s multi-segment `Contains('/')` guard dropped → 1 red (deep `#/$defs/x/properties/y` refs now misclassify); (3) **the `_chain` guard dropped → `Stack overflow.`, exit 134, run dead at 231/1176** — the crash your body promises for probe 51, reproduced; (4) `requiredNames` filter dropped → 90 red; (5) `Reaches` dequeue-order swap (owner check after visited-dedup) → 5 red. Restored each time, rebuilt, re-verified green. **What I liked~** - `InlineLocalRefs` never throwing on weird input — budget and depth bounds with precise `ArgumentException`s, refs that point nowhere left alone — while `Check` reports and *never rewrites an optional property into a required one*. The separation of oracle and fixer is exactly right. - The alias-following in `Expand` (ref→ref→schema) with the enclosing-location guard escaped consistently on both sides — and `InlineLocalRefsTests` pins the `~1`/`~0` round-trip both ways. - `The_exporter_emits_what_the_ref_pass_leaves_alone_byte_for_byte` is such a sneaky-good test: it asserts the *premises* the byte-identity relies on, so a future runtime upgrade that changes what the exporter emits fails loudly instead of silently invalidating the goldens. - House style spotless: one public type per file (`StrictSchemaProblem`, `StrictSchemaRule`, `JsonSchemaStrictness`), `Internal/` for the shared pass matching the 29 siblings, append-only enum documented "store by name, not number", XML docs on every member. #### 💡 Little ideas (non-blocking)~ 1. **Tool.cs:47** — `JsonDocument.Parse(node.ToJsonString())` uses the default 64-level `MaxDepth` while the pass it now shares with `InlineLocalRefs` is bounded at 1000. A parameter type nested ~33 records deep would export past 64 levels and throw at this parse. Pre-existing line, unchanged by this diff, and no realistic tool shape gets there — just an asymmetry a future PR might iron out. `InlineLocalRefs`' string round-trip is O(n) extra work, but it's what buys the independence guarantee the doc promises; correct trade for schemas of this size. Run once and finished~ ♪ --- *Automated review by Jibril · 2026-09-25* *CI/CD: absent for head `c9edc62` at review time (0 bot comments) · Local checks: build 0w/0e, 1270/1270, coverage reproduced, 5/5 mutation probes killed, byte-identity proof reproduced on both commits*
bjoern force-pushed feat/strict-schema-check from c9edc6295c
All checks were successful
CI / build (pull_request) Successful in 18s
CI / test (pull_request) Successful in 27s
to af6326e3ea
All checks were successful
CI / build (pull_request) Successful in 17s
CI / test (pull_request) Successful in 24s
2026-09-25 15:33:14 +02:00
Compare
Author
Member

Thanks for the review. Since you reviewed c9edc62, the branch has been rebased onto main c67663b (#23 merged), and the head is now af6326e. Only the README conflicted, and both sides were kept. Checks after the rebase:

  • Build: 0 warnings.
  • Tests: 1275 + 87 + 7 = 1369.
  • Commit 1 (48f73f6) still passes on the old pass (85/85 Agents tests), so the byte-identity proof still holds after the rebase.

💡 1 (Tool.cs:47, the parse uses the default MaxDepth of 64 but the pass allows 1000): not taking it in this PR. The line predates this PR, and no real tool shape nests 33 records deep. Changing it now would put an unreviewed edit on an approved diff. I've logged it as a library follow-up. When it's fixed, JsonDocumentOptions { MaxDepth } should match the pass's depth bound, and a test should pin a type nested deeper than 64 levels.

🤖 Generated with Claude Code

Thanks for the review. Since you reviewed `c9edc62`, the branch has been rebased onto `main` `c67663b` (#23 merged), and the head is now **`af6326e`**. Only the README conflicted, and both sides were kept. Checks after the rebase: - Build: 0 warnings. - Tests: 1275 + 87 + 7 = **1369**. - Commit 1 (`48f73f6`) still passes on the old pass (85/85 Agents tests), so the byte-identity proof still holds after the rebase. **💡 1 (Tool.cs:47, the parse uses the default `MaxDepth` of 64 but the pass allows 1000): not taking it in this PR.** The line predates this PR, and no real tool shape nests 33 records deep. Changing it now would put an unreviewed edit on an approved diff. I've logged it as a library follow-up. When it's fixed, `JsonDocumentOptions { MaxDepth }` should match the pass's depth bound, and a test should pin a type nested deeper than 64 levels. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Member

🔮 fufu~ Jibril reviewed your code!

A rebase round~ You slid both commits cleanly onto main c67663b now that #23 is in, and I came back to make sure nothing hid in the shuffle. Oh? You even rewrote the body's test table for the new baseline — 1369, not the old 1270. Honest numbers, fufu~ ♡

Verdict: ✅ Looks good to me~

What I verified mechanically on this round's increment (c9edc62 → af6326e, clone pristine at head):

  • No smuggled code. I reconstructed the rebase independently: the round-1 code patch applied onto c67663b (3-way, the only conflict being ResponseFormat.cs, exactly as your body says) produces a tree whose diff against af6326e is empty for all 11 code files. The entire rebase delta is README.md (+65/−5) — the "both sides were kept" merge. range-diff agrees: commit 1 replayed byte-identical (=), commit 2 differs in README hunks only. Round 1's verification (build, 1270/1270, 100% line+branch coverage on every new file, 5/5 mutation probes killed, byte-identity proof across the pass move) therefore transfers to this head's code in full.
  • The README merge reads true. #23's two upgrade rows (name check, choice error) and your recursive-tool row sit in one table without contradiction; the FailureKind table now carries the ParseJson provenance; the layout tree lists StructuredOutput and the ref pass beside your additions. I read every merged section — no half-resolved sentence anywhere.
  • Build + suite at the real head: 0 warnings / 0 errors under TreatWarningsAsErrors, and 1369/1369 — your table exactly (1275 + 87 + 7), which is base 1179 plus your 118 + 48 + 24.
  • CI: the coverage bot (9459) ran on the merge ref — whose src/ tree is byte-identical to this head's per the proof above — and it agrees with my round-1 reproduction: JsonSchemaStrictness 100% line and branch (Checker included), JsonSchemaRefs 100%/100%, Tool<TParams> 100%/100%, StrictSchemaProblem 8/8, JsonPointer 2/2. The one uncovered Tool line is the pre-existing parsed-null arm your body discloses.

💡 Little ideas (non-blocking)~

  1. The Tool.cs JsonDocument.Parse depth asymmetry from round 1 (default 64 vs the pass's 1000) survives the rebase unchanged — still pre-existing, still yours to iron out whenever a future PR touches that file~

Fufu~ a rebase this boring is the highest compliment I can give. Ready to merge~ ♪


Automated review by Jibril · 2026-09-25
CI/CD: coverage bot 9459 on the merge ref (src tree byte-identical to head af6326e) · Local checks: build 0w/0e, 1369/1369 at head, rebase delta proven README-only by tree reconstruction

## 🔮 fufu~ Jibril reviewed your code! A rebase round~ You slid both commits cleanly onto `main` `c67663b` now that #23 is in, and I came back to make sure nothing hid in the shuffle. Oh? You even rewrote the body's test table for the new baseline — 1369, not the old 1270. Honest numbers, fufu~ ♡ ### Verdict: ✅ Looks good to me~ **What I verified mechanically on this round's increment (`c9edc62` → `af6326e`, clone pristine at head):** - **No smuggled code.** I reconstructed the rebase independently: the round-1 code patch applied onto `c67663b` (3-way, the only conflict being `ResponseFormat.cs`, exactly as your body says) produces a tree whose diff against `af6326e` is **empty for all 11 code files**. The entire rebase delta is README.md (+65/−5) — the "both sides were kept" merge. `range-diff` agrees: commit 1 replayed byte-identical (`=`), commit 2 differs in README hunks only. Round 1's verification (build, 1270/1270, 100% line+branch coverage on every new file, 5/5 mutation probes killed, byte-identity proof across the pass move) therefore transfers to this head's code in full. - **The README merge reads true.** #23's two upgrade rows (name check, choice error) and your recursive-tool row sit in one table without contradiction; the FailureKind table now carries the ParseJson provenance; the layout tree lists `StructuredOutput` and the ref pass beside your additions. I read every merged section — no half-resolved sentence anywhere. - **Build + suite at the real head:** 0 warnings / 0 errors under `TreatWarningsAsErrors`, and **1369/1369** — your table exactly (1275 + 87 + 7), which is base 1179 plus your 118 + 48 + 24. - **CI:** the coverage bot (9459) ran on the merge ref — whose `src/` tree is byte-identical to this head's per the proof above — and it agrees with my round-1 reproduction: `JsonSchemaStrictness` 100% line and branch (Checker included), `JsonSchemaRefs` 100%/100%, `Tool<TParams>` 100%/100%, `StrictSchemaProblem` 8/8, `JsonPointer` 2/2. The one uncovered `Tool` line is the pre-existing parsed-null arm your body discloses. #### 💡 Little ideas (non-blocking)~ 1. The `Tool.cs` `JsonDocument.Parse` depth asymmetry from round 1 (default 64 vs the pass's 1000) survives the rebase unchanged — still pre-existing, still yours to iron out whenever a future PR touches that file~ Fufu~ a rebase this boring is the highest compliment I can give. Ready to merge~ ♪ --- *Automated review by Jibril · 2026-09-25* *CI/CD: coverage bot 9459 on the merge ref (src tree byte-identical to head `af6326e`) · Local checks: build 0w/0e, 1369/1369 at head, rebase delta proven README-only by tree reconstruction*
bjoern merged commit a35670faae into main 2026-09-25 15:41:39 +02:00
bjoern deleted branch feat/strict-schema-check 2026-09-25 15:41:40 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
4 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

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