feat: a run can ask to stay with one provider, and the catalog says what its cache costs #14

Merged
bjoern merged 1 commit from feat/session-and-catalog-caching-fields into main 2026-09-23 11:25:04 +02:00
Member

The first of the library changes Tsumugi's Phase 2 engine waits on (Tsumugi ADR 0018 §"Prompt caching and stickiness", §"Library work"). Purely additive: no existing member changes shape, and nothing is renamed.

Sticky sessions

A prompt cache lives at one provider, so a follow-up request only hits it when it is routed to the same provider again. OpenRouter takes session_id as the sticky routing key for exactly that; the request had no way to send one — no field, no header seam, no extension bag.

  • ChatCompletionRequest.SessionId → session_id. Checked against the live API reference on 2026-09-23: a string of at most 256 characters (MaxSessionIdLength), and the body wins over an x-session-id header. Null omits it (WhenWritingNull); a blank value is a validation error because it would group every blank-id request together rather than none; an over-long one is refused rather than sent. As for every other validation error, the non-streaming path returns Failure with no request and the streaming path throws OpenRouterException before sending — both pinned.
  • AgentOptions.SessionId rides on every round (BuildRequest), read per request like Model, so a change applies from the next round; Clone carries it, so a sub-agent joins its parent's session unless the configure callback gives it another id. The clone guard's fixture sets it.
  • GenerationInfo.SessionId reports the id a generation carried — how a caller confirms afterwards that stickiness took.

Catalog fields

Checked against a live GET /api/v1/models (454 models):

  • ModelPricing.InputCacheRead / InputCacheWrite (input_cache_read, input_cache_write; USD strings like the other prices). ProviderEndpoint.Pricing shares the type, so the endpoints listing reads them too (tested on that path).
  • ModelInfo.DefaultParameters → new ModelDefaultParameters record (temperature, top_p, top_k, frequency_penalty, presence_penalty, repetition_penalty, each nullable). Every live entry sends the object, 176 of them as {}, many with all members null; 51 send temperature as a JSON integer, which reads as the same double.
  • ModelInfo.CanonicalSlug — the dated version slug an id currently serves (anthropic/claude-opus-5.5 → …-20260921); a catalog variant shares its base's slug; for a ~…-latest alias it is the alias itself.
  • ModelInfo.AliasTarget → new ModelAliasTarget { Slug, Name } (both required, as the reference requires them): the concrete model behind an alias.
  • top_k stays int?, as strict as context_length: one catalog entry with a fractional top_k fails the whole listing, exactly as a fractional context_length would today. A test pins that, so a later tolerance change is a deliberate one.

Known and not modelled (the verbatim fixtures prove they are skipped, not fatal): input_cache_write_1h, input_audio_cache, discount, pricing overrides (an array of tiered prices), audio_output, image_output; expiration_date, knowledge_cutoff, hugging_face_id, links, benchmarks, supported_voices.

README

Prompt caching gains the SessionId paragraph and snippet; Account & metadata gains the catalog snippet, the note that -1 means "the price varies" (router models — read as unknown, never as a negative price) and the alias/canonical-slug distinction; Auto prompt caching and Sub-agents each gain a sentence. The stale Effort = "high" snippet under Reasoning now reads ReasoningEffort.High. The version stays 0.2.0 — #12 did not bump it either.

Tests

main (af9fd7b) this PR
OpenRouter.Net.Tests 210 231
OpenRouter.Net.Agents.Tests 59 63
OpenRouter.Net.Imaging.Tests 7 7
Total 276 301

New ModelCatalogTests (10): every new field present / absent / null / {} / partial, integer temperature, the fractional-top_k pin, an alias, the endpoints path, and two entries copied verbatim from the live catalog through ListModelsAsync (~anthropic/claude-opus-latest with alias_target, input_cache_write_1h, links, supported_voices, a null knowledge_cutoff; openai/gpt-6-luna with benchmarks and pricing overrides). Serialization and omission, the 256/257 and blank boundaries, the wire, both validation paths (the streaming one drained inside ThrowsAsync, then no request asserted), the generation record, and four agent tests (every round, changed between turns, none, sub-agent inherits).

Coverage (dotnet test --coverage --coverage-output-format cobertura): every added line is hit. The four uncovered lines in ChatCompletionRequest.Validate (the null fallback entry, TopP, MaxTokens) predate this PR.

Mutation probes, each restored and touched before the next build — all killed: BuildRequest without SessionId (3 red); Clone without it (2); the clone fixture without it (the guard itself, 1); session_id renamed (2); > → >= (1); cap 255 / 257 (1 each); IsNullOrWhiteSpace → IsNullOrEmpty (1); the blank check dropped (2); each of input_cache_read, input_cache_write, default_parameters, canonical_slug, alias_target, top_k renamed (2–6 each); GenerationInfo's session_id renamed (1); Temperature made required (2). Two die at compile time rather than in a test: Temperature as int?, and dropping AliasTarget.

Out of scope

parallel_tool_calls (with concurrent tools, deferred); metadata, an x-session-id header seam or an extension bag; User/Route/Transforms/Include on AgentOptions; tolerant integer parsing for catalog fields; every streaming change (tool-call deltas, the usage-only final chunk, typed failure kinds — the next PR); structured-output helpers.

🤖 Generated with Claude Code

The first of the library changes Tsumugi's Phase 2 engine waits on (Tsumugi ADR 0018 §"Prompt caching and stickiness", §"Library work"). Purely additive: no existing member changes shape, and nothing is renamed. ## Sticky sessions A prompt cache lives at one provider, so a follow-up request only hits it when it is routed to the same provider again. OpenRouter takes `session_id` as the sticky routing key for exactly that; the request had no way to send one — no field, no header seam, no extension bag. - `ChatCompletionRequest.SessionId` → `session_id`. Checked against the live API reference on 2026-09-23: a string of **at most 256 characters** (`MaxSessionIdLength`), and the body wins over an `x-session-id` header. Null omits it (`WhenWritingNull`); a **blank** value is a validation error because it would group every blank-id request together rather than none; an over-long one is refused rather than sent. As for every other validation error, the non-streaming path returns `Failure` with no request and the streaming path throws `OpenRouterException` before sending — both pinned. - `AgentOptions.SessionId` rides on **every round** (`BuildRequest`), read per request like `Model`, so a change applies from the next round; `Clone` carries it, so a sub-agent joins its parent's session unless the configure callback gives it another id. The clone guard's fixture sets it. - `GenerationInfo.SessionId` reports the id a generation carried — how a caller confirms afterwards that stickiness took. ## Catalog fields Checked against a live `GET /api/v1/models` (454 models): - `ModelPricing.InputCacheRead` / `InputCacheWrite` (`input_cache_read`, `input_cache_write`; USD strings like the other prices). `ProviderEndpoint.Pricing` shares the type, so the endpoints listing reads them too (tested on that path). - `ModelInfo.DefaultParameters` → new `ModelDefaultParameters` record (`temperature`, `top_p`, `top_k`, `frequency_penalty`, `presence_penalty`, `repetition_penalty`, each nullable). Every live entry sends the object, 176 of them as `{}`, many with all members `null`; 51 send `temperature` as a JSON integer, which reads as the same double. - `ModelInfo.CanonicalSlug` — the dated version slug an id currently serves (`anthropic/claude-opus-5.5` → `…-20260921`); a catalog variant shares its base's slug; **for a `~…-latest` alias it is the alias itself**. - `ModelInfo.AliasTarget` → new `ModelAliasTarget { Slug, Name }` (both required, as the reference requires them): the concrete model behind an alias. - **`top_k` stays `int?`**, as strict as `context_length`: one catalog entry with a fractional `top_k` fails the whole listing, exactly as a fractional `context_length` would today. A test pins that, so a later tolerance change is a deliberate one. Known and **not modelled** (the verbatim fixtures prove they are skipped, not fatal): `input_cache_write_1h`, `input_audio_cache`, `discount`, pricing `overrides` (an array of tiered prices), `audio_output`, `image_output`; `expiration_date`, `knowledge_cutoff`, `hugging_face_id`, `links`, `benchmarks`, `supported_voices`. ## README Prompt caching gains the `SessionId` paragraph and snippet; Account & metadata gains the catalog snippet, the note that `-1` means "the price varies" (router models — read as unknown, never as a negative price) and the alias/canonical-slug distinction; Auto prompt caching and Sub-agents each gain a sentence. The stale `Effort = "high"` snippet under Reasoning now reads `ReasoningEffort.High`. The version stays `0.2.0` — #12 did not bump it either. ## Tests | | main (`af9fd7b`) | this PR | |---|---|---| | OpenRouter.Net.Tests | 210 | 231 | | OpenRouter.Net.Agents.Tests | 59 | 63 | | OpenRouter.Net.Imaging.Tests | 7 | 7 | | **Total** | **276** | **301** | New `ModelCatalogTests` (10): every new field present / absent / null / `{}` / partial, integer temperature, the fractional-`top_k` pin, an alias, the endpoints path, and **two entries copied verbatim from the live catalog** through `ListModelsAsync` (`~anthropic/claude-opus-latest` with `alias_target`, `input_cache_write_1h`, `links`, `supported_voices`, a null `knowledge_cutoff`; `openai/gpt-6-luna` with `benchmarks` and pricing `overrides`). Serialization and omission, the 256/257 and blank boundaries, the wire, both validation paths (the streaming one drained inside `ThrowsAsync`, then no request asserted), the generation record, and four agent tests (every round, changed between turns, none, sub-agent inherits). **Coverage** (`dotnet test --coverage --coverage-output-format cobertura`): every added line is hit. The four uncovered lines in `ChatCompletionRequest.Validate` (the null fallback entry, `TopP`, `MaxTokens`) predate this PR. **Mutation probes**, each restored and touched before the next build — all killed: `BuildRequest` without `SessionId` (3 red); `Clone` without it (2); the clone fixture without it (the guard itself, 1); `session_id` renamed (2); `>` → `>=` (1); cap 255 / 257 (1 each); `IsNullOrWhiteSpace` → `IsNullOrEmpty` (1); the blank check dropped (2); each of `input_cache_read`, `input_cache_write`, `default_parameters`, `canonical_slug`, `alias_target`, `top_k` renamed (2–6 each); `GenerationInfo`'s `session_id` renamed (1); `Temperature` made `required` (2). Two die at compile time rather than in a test: `Temperature` as `int?`, and dropping `AliasTarget`. ## Out of scope `parallel_tool_calls` (with concurrent tools, deferred); `metadata`, an `x-session-id` header seam or an extension bag; `User`/`Route`/`Transforms`/`Include` on `AgentOptions`; tolerant integer parsing for catalog fields; every streaming change (tool-call deltas, the usage-only final chunk, typed failure kinds — the next PR); structured-output helpers. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat: a run can ask to stay with one provider, and the catalog says what its cache costs
All checks were successful
CI / build (pull_request) Successful in 13s
CI / test (pull_request) Successful in 22s
1fd0dce25f
A prompt cache lives at one provider, so the second request of a conversation only hits it when it
is routed to the same provider again. OpenRouter takes a `session_id` as the sticky routing key for
exactly that, and the request had no way to send one. `ChatCompletionRequest.SessionId` sends it;
`AgentOptions.SessionId` rides on every round, is read per request like `Model`, and is carried by
`Clone`, so a sub-agent joins its parent's session unless the configure callback gives it another.
The live API caps the id at 256 characters; a blank one would group every blank-id request together,
so both are validation errors rather than something sent. `GenerationInfo.SessionId` reports the id a
generation carried, which is how a caller confirms stickiness took.

The catalog grew fields a cache-aware caller needs. `ModelPricing` reads `input_cache_read` and
`input_cache_write` (the endpoints listing shares the type, so it gets them too). `ModelInfo` reads
`default_parameters` — present on every live entry and often `{}`, so a new record with every member
nullable, an integer `temperature` reading as a double — plus `canonical_slug`, the dated version an
id serves, and `alias_target`, the concrete model behind a `~…-latest` alias, whose own canonical slug
is the alias itself. `top_k` stays as strict as every other integer catalog field, and a test pins
that a fractional value fails the listing, so loosening it is a deliberate change.

Two entries copied verbatim from the live catalog run through `ListModelsAsync`, carrying every
field this library still does not model (`input_cache_write_1h`, pricing `overrides` as an array,
`benchmarks`, `links`, `supported_voices`, …), to prove they are skipped rather than failing the
listing. All additions; no existing member changes shape.

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

Summary

Summary
Generated on: 09/23/2026 - 09:04:52
Parser: MultiReport (3x Cobertura)
Assemblies: 3
Classes: 75
Files: 68
Line coverage: 82.6% (1752 of 2119)
Covered lines: 1752
Uncovered lines: 367
Coverable lines: 2119
Total lines: 5424
Branch coverage: 71.7% (811 of 1130)
Covered branches: 811
Total branches: 1130
Method coverage: Feature is only available for sponsors

Coverage

OpenRouter.Net - 84.4%
Name Line Branch
OpenRouter.Net 84.4% 75.2%
OpenRouter.Net.Client.CachingOpenRouterClient 73.3% 81.2%
OpenRouter.Net.Client.OpenRouterClient 92.6% 76.9%
OpenRouter.Net.Client.OpenRouterClient<TRequest, TResponse> 92.6% 76.9%
OpenRouter.Net.Client.OpenRouterClient 92.6% 76.9%
OpenRouter.Net.Client.OpenRouterClientOptions 0%
OpenRouter.Net.Client.OpenRouterRequestLoggingHandler 27.2% 18.7%
OpenRouter.Net.Client.RetryOptions 79.1% 0%
OpenRouter.Net.Extensions.ServiceCollectionExtensions 0% 0%
OpenRouter.Net.Internal.AssistantContent 91.6% 75%
OpenRouter.Net.Internal.AssistantContentJsonConverter 75% 64.2%
OpenRouter.Net.Internal.ContentPartListConverter 85.7% 66.6%
OpenRouter.Net.Internal.DecimalStringJsonConverter 81.2% 70%
OpenRouter.Net.Internal.ErrorParser 100% 75%
OpenRouter.Net.Internal.JsonOptions 100%
OpenRouter.Net.Internal.ListEnvelope 100%
OpenRouter.Net.Internal.ModelIdJsonConverter 100% 50%
OpenRouter.Net.Internal.PercentileCutoffsJsonConverter 90.6% 87.5%
OpenRouter.Net.Internal.ProviderSortConfigJsonConverter 83.7% 65.6%
OpenRouter.Net.Internal.RetryPolicy 90.9% 84.7%
OpenRouter.Net.Internal.RetryPolicy 90.9% 84.7%
OpenRouter.Net.Internal.SseEventReader 91.6% 78.5%
OpenRouter.Net.Internal.TolerantStringEnumConverter 90.1% 88%
OpenRouter.Net.Internal.ToolChoiceJsonConverter 61.7% 46.1%
OpenRouter.Net.Models.Common.CacheControl 100%
OpenRouter.Net.Models.Common.ModelId 95.1% 92.9%
OpenRouter.Net.Models.Common.QuantizationExtensions 100% 100%
OpenRouter.Net.Models.Common.Result 100%
OpenRouter.Net.Models.Common.Result.Failure 100%
OpenRouter.Net.Models.Common.Result.Success 100%
OpenRouter.Net.Models.Common.Result 53.8% 30%
OpenRouter.Net.Models.Content.AudioPart 100%
OpenRouter.Net.Models.Content.ImagePart 100%
OpenRouter.Net.Models.Content.TextPart 100%
OpenRouter.Net.Models.Messages.AssistantMessage 100% 70%
OpenRouter.Net.Models.Messages.DeveloperMessage 0%
OpenRouter.Net.Models.Messages.SystemMessage 50%
OpenRouter.Net.Models.Messages.ToolMessage 57.1%
OpenRouter.Net.Models.Messages.UserMessage 100%
OpenRouter.Net.Models.Requests.ChatCompletionRequest 86.2% 76.1%
OpenRouter.Net.Models.Requests.PercentileCutoffs 100% 100%
OpenRouter.Net.Models.Requests.ProviderPreferences 100% 100%
OpenRouter.Net.Models.Requests.ProviderSortConfig 100% 100%
OpenRouter.Net.Models.Requests.ResponseFormat 8.3%
OpenRouter.Net.Models.Requests.ToolCall 100%
OpenRouter.Net.Models.Requests.ToolChoice 100%
OpenRouter.Net.Models.Requests.ToolDefinition 100%
OpenRouter.Net.Models.Responses.ChatCompletionChunk 0% 0%
OpenRouter.Net.Models.Responses.ChatCompletionResponse 0% 0%
OpenRouter.Net.Models.Responses.Choice 100% 100%
OpenRouter.Net.OpenRouterException 100%
System.Text.RegularExpressions.Generated 83.3% 53.8%
System.Text.RegularExpressions.Generated.RunnerFactory
System.Text.RegularExpressions.Generated.RunnerFactory.Runner
OpenRouter.Net.Agents - 80%
Name Line Branch
OpenRouter.Net.Agents 80% 65.5%
OpenRouter.Net.Agents.Agent 92.4% 77.3%
OpenRouter.Net.Agents.AgentOptions 100%
OpenRouter.Net.Agents.AgentResult 100% 50%
OpenRouter.Net.Agents.Extensions.ServiceCollectionExtensions 0%
OpenRouter.Net.Agents.RoundDetail 0%
OpenRouter.Net.Agents.Tools.FileSystem.CopyTool 42.1% 28.5%
OpenRouter.Net.Agents.Tools.FileSystem.DeleteTool 57.1% 50%
OpenRouter.Net.Agents.Tools.FileSystem.DiffTool 83.3% 74.2%
OpenRouter.Net.Agents.Tools.FileSystem.ListDirectoryTool 68% 61.1%
OpenRouter.Net.Agents.Tools.FileSystem.MoveTool 55% 33.3%
OpenRouter.Net.Agents.Tools.FileSystem.ReadFileTool 73.6% 50%
OpenRouter.Net.Agents.Tools.FileSystem.SearchTool 67.5% 70.8%
OpenRouter.Net.Agents.Tools.FileSystem.WriteFileTool 75% 50%
OpenRouter.Net.Agents.Tools.ITool 100%
OpenRouter.Net.Agents.Tools.SubAgent.SubAgentTool 0% 0%
OpenRouter.Net.Agents.Tools.Tool 80% 63.8%
OpenRouter.Net.Agents.Tools.Tool 90.9% 50%
OpenRouter.Net.Agents.Tools.ToolMessageExtensions 0%
OpenRouter.Net.Agents.Tools.ToolResult 100% 100%
OpenRouter.Net.Agents.Tools.Workspace 100% 91.6%
OpenRouter.Net.Imaging - 81.8%
Name Line Branch
OpenRouter.Net.Imaging 81.8% 59.3%
OpenRouter.Net.Imaging.ImageEncodeOptions 90.9% 75%
OpenRouter.Net.Imaging.ImageEncoder 80.3% 57.1%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 09/23/2026 - 09:04:52 | | Parser: | MultiReport (3x Cobertura) | | Assemblies: | 3 | | Classes: | 75 | | Files: | 68 | | **Line coverage:** | 82.6% (1752 of 2119) | | Covered lines: | 1752 | | Uncovered lines: | 367 | | Coverable lines: | 2119 | | Total lines: | 5424 | | **Branch coverage:** | 71.7% (811 of 1130) | | Covered branches: | 811 | | Total branches: | 1130 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>OpenRouter.Net - 84.4%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net**|**84.4%**|**75.2%**| |OpenRouter.Net.Client.CachingOpenRouterClient|73.3%|81.2%| |OpenRouter.Net.Client.OpenRouterClient|92.6%|76.9%| |OpenRouter.Net.Client.OpenRouterClient<TRequest, TResponse>|92.6%|76.9%| |OpenRouter.Net.Client.OpenRouterClient<TResponse>|92.6%|76.9%| |OpenRouter.Net.Client.OpenRouterClientOptions|0%|| |OpenRouter.Net.Client.OpenRouterRequestLoggingHandler|27.2%|18.7%| |OpenRouter.Net.Client.RetryOptions|79.1%|0%| |OpenRouter.Net.Extensions.ServiceCollectionExtensions|0%|0%| |OpenRouter.Net.Internal.AssistantContent|91.6%|75%| |OpenRouter.Net.Internal.AssistantContentJsonConverter|75%|64.2%| |OpenRouter.Net.Internal.ContentPartListConverter|85.7%|66.6%| |OpenRouter.Net.Internal.DecimalStringJsonConverter|81.2%|70%| |OpenRouter.Net.Internal.ErrorParser|100%|75%| |OpenRouter.Net.Internal.JsonOptions|100%|| |OpenRouter.Net.Internal.ListEnvelope<T>|100%|| |OpenRouter.Net.Internal.ModelIdJsonConverter|100%|50%| |OpenRouter.Net.Internal.PercentileCutoffsJsonConverter|90.6%|87.5%| |OpenRouter.Net.Internal.ProviderSortConfigJsonConverter|83.7%|65.6%| |OpenRouter.Net.Internal.RetryPolicy|90.9%|84.7%| |OpenRouter.Net.Internal.RetryPolicy<T>|90.9%|84.7%| |OpenRouter.Net.Internal.SseEventReader|91.6%|78.5%| |OpenRouter.Net.Internal.TolerantStringEnumConverter<T>|90.1%|88%| |OpenRouter.Net.Internal.ToolChoiceJsonConverter|61.7%|46.1%| |OpenRouter.Net.Models.Common.CacheControl|100%|| |OpenRouter.Net.Models.Common.ModelId|95.1%|92.9%| |OpenRouter.Net.Models.Common.QuantizationExtensions|100%|100%| |OpenRouter.Net.Models.Common.Result|100%|| |OpenRouter.Net.Models.Common.Result.Failure<T>|100%|| |OpenRouter.Net.Models.Common.Result.Success<T>|100%|| |OpenRouter.Net.Models.Common.Result<T>|53.8%|30%| |OpenRouter.Net.Models.Content.AudioPart|100%|| |OpenRouter.Net.Models.Content.ImagePart|100%|| |OpenRouter.Net.Models.Content.TextPart|100%|| |OpenRouter.Net.Models.Messages.AssistantMessage|100%|70%| |OpenRouter.Net.Models.Messages.DeveloperMessage|0%|| |OpenRouter.Net.Models.Messages.SystemMessage|50%|| |OpenRouter.Net.Models.Messages.ToolMessage|57.1%|| |OpenRouter.Net.Models.Messages.UserMessage|100%|| |OpenRouter.Net.Models.Requests.ChatCompletionRequest|86.2%|76.1%| |OpenRouter.Net.Models.Requests.PercentileCutoffs|100%|100%| |OpenRouter.Net.Models.Requests.ProviderPreferences|100%|100%| |OpenRouter.Net.Models.Requests.ProviderSortConfig|100%|100%| |OpenRouter.Net.Models.Requests.ResponseFormat|8.3%|| |OpenRouter.Net.Models.Requests.ToolCall|100%|| |OpenRouter.Net.Models.Requests.ToolChoice|100%|| |OpenRouter.Net.Models.Requests.ToolDefinition|100%|| |OpenRouter.Net.Models.Responses.ChatCompletionChunk|0%|0%| |OpenRouter.Net.Models.Responses.ChatCompletionResponse|0%|0%| |OpenRouter.Net.Models.Responses.Choice|100%|100%| |OpenRouter.Net.OpenRouterException|100%|| |System.Text.RegularExpressions.Generated|83.3%|53.8%| |System.Text.RegularExpressions.Generated.RunnerFactory||| |System.Text.RegularExpressions.Generated.RunnerFactory.Runner||| </details> <details><summary>OpenRouter.Net.Agents - 80%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net.Agents**|**80%**|**65.5%**| |OpenRouter.Net.Agents.Agent|92.4%|77.3%| |OpenRouter.Net.Agents.AgentOptions|100%|| |OpenRouter.Net.Agents.AgentResult|100%|50%| |OpenRouter.Net.Agents.Extensions.ServiceCollectionExtensions|0%|| |OpenRouter.Net.Agents.RoundDetail|0%|| |OpenRouter.Net.Agents.Tools.FileSystem.CopyTool|42.1%|28.5%| |OpenRouter.Net.Agents.Tools.FileSystem.DeleteTool|57.1%|50%| |OpenRouter.Net.Agents.Tools.FileSystem.DiffTool|83.3%|74.2%| |OpenRouter.Net.Agents.Tools.FileSystem.ListDirectoryTool|68%|61.1%| |OpenRouter.Net.Agents.Tools.FileSystem.MoveTool|55%|33.3%| |OpenRouter.Net.Agents.Tools.FileSystem.ReadFileTool|73.6%|50%| |OpenRouter.Net.Agents.Tools.FileSystem.SearchTool|67.5%|70.8%| |OpenRouter.Net.Agents.Tools.FileSystem.WriteFileTool|75%|50%| |OpenRouter.Net.Agents.Tools.ITool|100%|| |OpenRouter.Net.Agents.Tools.SubAgent.SubAgentTool|0%|0%| |OpenRouter.Net.Agents.Tools.Tool<TParams>|80%|63.8%| |OpenRouter.Net.Agents.Tools.Tool<TParams>|90.9%|50%| |OpenRouter.Net.Agents.Tools.ToolMessageExtensions|0%|| |OpenRouter.Net.Agents.Tools.ToolResult|100%|100%| |OpenRouter.Net.Agents.Tools.Workspace|100%|91.6%| </details> <details><summary>OpenRouter.Net.Imaging - 81.8%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net.Imaging**|**81.8%**|**59.3%**| |OpenRouter.Net.Imaging.ImageEncodeOptions|90.9%|75%| |OpenRouter.Net.Imaging.ImageEncoder|80.3%|57.1%| </details>
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Sticky sessions so the cache stays warm, AND the catalog finally tells you what that cache costs? A prompt cache lives at one provider and a follow-up request only hits it when it lands on that same provider again — this is exactly the seam that was missing. I read every changed line, every unchanged neighbor, and both verbatim live fixtures~ ♡

Verdict: ✅ Looks good to me~

✅ What I liked~

  • The Clone guard test is my favorite thing here. Every_option_survives_the_clone_a_sub_agent_inherits asserts the whole property surface by reflection, and the "guard the guard" check (fixture must set every option, no nulls allowed) means the next forgotten Clone() line fails loudly before a sub-agent silently loses it. SessionId = "run-1" sits in the fixture — the exact bug this PR's design makes possible is the one the test makes impossible. Fufu~, that's craftsmanship~
  • Validation is precise and honestly documented. Null omits (WhenWritingNull), blank is refused with the reason in the doc comment (a blank id would group every blank-id request together rather than none — subtle and correct), 256 accepted / 257 refused with both boundaries pinned. The MaxSessionIdLength doc even says "UTF-16 code units" instead of hand-waving "characters." ♪
  • Both failure paths are pinned, not just one. Non-streaming → Failure with StatusCode == null AND handler.Requests empty; streaming → OpenRouterException thrown inside the enumeration (with the lazy-iterator drain comment — correct, the body runs on first MoveNextAsync) AND no request sent. Many PRs test one arm and hope for the other~
  • The verbatim fixtures are the right kind of proof. ~anthropic/claude-opus-latest carrying alias_target, input_cache_write_1h, links, supported_voices, null knowledge_cutoff; gpt-6-luna carrying benchmarks and pricing overrides as an array — every unmodeled field must be skipped, not fatal, and now that's law. The fractional-top_k pin matching context_length's strictness is a deliberate decision written down as a test, which is where decisions belong.
  • top_k staying int? — consistency over convenience, and the pin makes loosening it a visible act rather than silent drift.
  • The README's Effort = ReasoningEffort.High fix for the stale snippet, and the -1-means-varies warning next to the parse advice — small, kind.

Verified by me, not just read: CI green for head 1fd0dce (build + test, run #32); locally the full suite is 301/301 (231 + 63 + 7 — your table's arithmetic is exact). And because green CI only proves tests run, I mutation-probed the new pins at head: dropping SessionId from BuildRequest → exactly the 3 agent session tests red; renaming the wire key to sessionId → exactly the wire + serialization tests red. Both restored, tree pristine, rebuilt, 301/301 again~

💡 Little ideas (non-blocking)~

  1. RequestValidationTests.cs:49 — A_session_id_one_past_the_maximum_is_refused hardcodes 257 in the constructor while its sibling uses MaxSessionIdLength. Keeping 256/257 in the message asserts (pinning the wire constant) is right; the input could be MaxSessionIdLength + 1 so a future cap change moves the whole test in one place. Pure taste, ignore freely~

Fufu~ purely additive, every branch owned by a test that provably bites, sibling patterns honored (ProviderEndpoint.Pricing shares the type, validation error shapes match both paths, Clone mirrors its neighbors). Tsumugi's Phase 2 engine may proceed~ ♡


Automated review by Jibril · 2026-09-23
CI/CD: passed for head SHA (run #32, build + test) · Local checks: full suite 301/301, mutation probes 2/2 killed, tree restored pristine

## 🔮 fufu~ Jibril reviewed your code! Oh? Sticky sessions so the cache stays warm, AND the catalog finally tells you what that cache *costs*? A prompt cache lives at one provider and a follow-up request only hits it when it lands on that same provider again — this is exactly the seam that was missing. I read every changed line, every unchanged neighbor, and both verbatim live fixtures~ ♡ ### Verdict: ✅ Looks good to me~ #### ✅ What I liked~ - **The Clone guard test is my favorite thing here.** `Every_option_survives_the_clone_a_sub_agent_inherits` asserts the *whole* property surface by reflection, and the "guard the guard" check (fixture must set every option, no nulls allowed) means the next forgotten `Clone()` line fails loudly before a sub-agent silently loses it. `SessionId = "run-1"` sits in the fixture — the exact bug this PR's design makes possible is the one the test makes impossible. Fufu~, that's craftsmanship~ - **Validation is precise and honestly documented.** Null omits (`WhenWritingNull`), blank is refused with the *reason* in the doc comment (a blank id would group every blank-id request together rather than none — subtle and correct), 256 accepted / 257 refused with both boundaries pinned. The `MaxSessionIdLength` doc even says "UTF-16 code units" instead of hand-waving "characters." ♪ - **Both failure paths are pinned, not just one.** Non-streaming → `Failure` with `StatusCode == null` AND `handler.Requests` empty; streaming → `OpenRouterException` thrown inside the enumeration (with the lazy-iterator drain comment — correct, the body runs on first `MoveNextAsync`) AND no request sent. Many PRs test one arm and hope for the other~ - **The verbatim fixtures are the right kind of proof.** `~anthropic/claude-opus-latest` carrying `alias_target`, `input_cache_write_1h`, `links`, `supported_voices`, null `knowledge_cutoff`; `gpt-6-luna` carrying `benchmarks` and pricing `overrides` as an array — every unmodeled field must be *skipped, not fatal*, and now that's law. The fractional-`top_k` pin matching `context_length`'s strictness is a deliberate decision written down as a test, which is where decisions belong. - **`top_k` staying `int?`** — consistency over convenience, and the pin makes loosening it a visible act rather than silent drift. - The README's `Effort = ReasoningEffort.High` fix for the stale snippet, and the `-1`-means-varies warning next to the parse advice — small, kind. **Verified by me, not just read:** CI green for head `1fd0dce` (build + test, run #32); locally the full suite is 301/301 (231 + 63 + 7 — your table's arithmetic is exact). And because green CI only proves tests *run*, I mutation-probed the new pins at head: dropping `SessionId` from `BuildRequest` → exactly the 3 agent session tests red; renaming the wire key to `sessionId` → exactly the wire + serialization tests red. Both restored, tree pristine, rebuilt, 301/301 again~ #### 💡 Little ideas (non-blocking)~ 1. **RequestValidationTests.cs:49** — `A_session_id_one_past_the_maximum_is_refused` hardcodes `257` in the constructor while its sibling uses `MaxSessionIdLength`. Keeping 256/257 in the message asserts (pinning the wire constant) is right; the *input* could be `MaxSessionIdLength + 1` so a future cap change moves the whole test in one place. Pure taste, ignore freely~ Fufu~ purely additive, every branch owned by a test that provably bites, sibling patterns honored (`ProviderEndpoint.Pricing` shares the type, validation error shapes match both paths, `Clone` mirrors its neighbors). Tsumugi's Phase 2 engine may proceed~ ♡ --- *Automated review by Jibril · 2026-09-23* *CI/CD: passed for head SHA (run #32, build + test) · Local checks: full suite 301/301, mutation probes 2/2 killed, tree restored pristine*
bjoern merged commit f35b9bc754 into main 2026-09-23 11:25:04 +02:00
bjoern deleted branch feat/session-and-catalog-caching-fields 2026-09-23 11:25:04 +02:00
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!14
No description provided.