feat: a model the catalog hides is still a model you can find #17

Merged
bjoern merged 3 commits from feat/catalog-output-modalities into main 2026-09-23 14:30:24 +02:00
Member

The first PR of native Decisions support, for TypeSafe's Jev on OpenRouter's decisions output modality. It is C# only; the Dart library is out of scope for this series. Nothing here calls Decisions yet. It makes Jev, and every other model without text output, findable, which the Decisions calls in the next PR build on.

The unfiltered listing hides a quarter of the catalog

GET /models without a filter returns only the models whose output includes text. Checked live (unauthenticated) on 2026-09-23: that is 455 of 614 models. It contains no embedding (37), rerank (7), speech (18), transcription (22), video (29) or decision (2) models, and only 11 of the 55 image models. ListModelsAsync() could not see Jev at all, and its doc claimed it "Lists every model available on the platform".

Asking by output modality

IOpenRouterClient.ListModelsAsync(ModelListQuery, CancellationToken) sends models?output_modalities=….

await client.ListModelsAsync(Modality.Decisions);                              // ?output_modalities=decisions
await client.ListModelsAsync(ModelListQuery.Outputs(Modality.Text, Modality.Decisions)); // =text,decisions (a union)
await client.ListModelsAsync(ModelListQuery.AllOutputs);                       // =all
  • Modality converts implicitly to a query, with FromModality as the named alternative. The filter is spelled by TolerantStringEnumConverter<T>.ToWire, the same map the catalog is read with, so the query can never name a modality differently from the listing it filters. A modality named twice is sent once. The query never sends offset or limit, so the full list comes back rather than one page.
  • The parameterless overload is unchanged: a bare GET models, now documented as "text-output models". ListModelsAsync(default) still binds to it, and a test pins that.
  • Validate() refuses four queries before anything is sent, each for a different reason. The refusal is a FailureKind.Validation failure, and all four were checked live:
Query What the catalog would do
Unknown, File (input only) 400 ZodError with no error.code
empty list output_modalities= → 200 with the default text listing, silently
AllOutputModalities plus a list would go out as all → every model, silently dropping the list

Modality, ApiType, IsAlias

  • Modality gains Embeddings, Rerank, Decisions, Speech and Transcription, appended so existing values keep their numbers. Across all 614 live models, no input or output modality reads as Unknown any more; before, Jev's output read as [Unknown].
  • GenerationInfo.ApiType is a new tolerant GenerationApiType enum with the spec's 8 values. JSON null and a missing field read as null; an unrecognised value reads as Unknown.
  • ModelId.IsAlias is true for ~…-latest ids. The ~ stays part of Author, so an alias's endpoints route is unchanged.

Caching per filter

CachingOpenRouterClient used to hold one list. It now caches each filter as its own listing, in a ConcurrentDictionary keyed by the sorted, de-duplicated filter:

  • Shared entries: text,decisions and decisions,text share one; so do the parameterless call and a query with no filter.
  • InvalidateModels() clears every filter.
  • An invalid query always goes to the inner client and is never served from the cache. Otherwise an empty list would be keyed like the default listing, and a query setting both members like AllOutputs.
  • One fill gate per listing, so a slow or retrying AllOutputs fetch no longer holds up the default listing. Before, one lock guarded the single list. Callers of the same listing still share a single fill.
  • The query's list is copied on entry. The key is computed at call time but the fetch may run after a wait, and a caller's List<Modality> changed in between would otherwise be sent under the old key and cached there for the TTL.

For implementers

IOpenRouterClient gains a member. StubOpenRouterClient adds it, and the README's Upgrading from 0.2 table (0.3.0 is unreleased) says what an implementation of your own must add. The table also covers the two ways the now-overloaded method group breaks: var f = client.ListModelsAsync; gives CS8917, and GetMethod("ListModelsAsync") throws AmbiguousMatchException. A second row lists the five Modality values that used to read as Unknown. Pre-1.0, following #12, which added GetModelEndpointsAsync(ModelId) the same way.

README

Account & metadata gains the filtered-listing snippet, the validation rules, the per-filter cache, ApiType and IsAlias, and points to the upgrade table. The Reasoning sample called .Value! on a Result<T>, which has no such member, and now uses .ValueOrDefault!.

Tests

main (f85a9e6, after #16) this PR
OpenRouter.Net.Tests 469 531
OpenRouter.Net.Agents.Tests 63 63
OpenRouter.Net.Imaging.Tests 7 7
Total 539 601

(Opened against ec76623 with 324 → 386. #16 merged underneath, and main was merged into this branch in 87c0be5; the counts above are after that.)

  • New ListModelsQueryTests:

    • the exact URL for each of the nine output modalities, a union, a duplicate, all and no filter;
    • the parameterless overload and default binding;
    • a base URL without a trailing slash;
    • each refused query, with zero requests sent and FailureKind.Validation;
    • a null query throwing;
    • the implicit conversion.
  • ModelCatalogTests: the live ?output_modalities=decisions response, verbatim with its total_count/links envelope, through ListModelsAsync(Modality.Decisions). Both Jev rows read as [Decisions], the alias's AliasTarget and IsAlias are checked, and so is the pricing.

  • Enum and model tests: the five new names round-trip, all nine api_type values plus an unknown one read correctly, ToWire agrees with Write (including an [EnumMember] override), IsAlias rows, and the generation record's api_type covering present, null, missing and unknown.

  • Caching:

    • each filter cached separately, with the URLs pinned;
    • order and duplicates sharing an entry;
    • the default and no-filter queries sharing one;
    • TTL per filter;
    • invalidation clearing every filter;
    • every refused query bypassing a primed cache, with its kind;
    • three concurrency tests: a slow fill not blocking another listing, concurrent callers sharing one fill, a list mutated mid-wait.

    The concurrency tests passed five runs out of five.

Live check. Run through OpenRouterClient and CachingOpenRouterClient against https://openrouter.ai/api/v1 (no key, no trailing slash):

  • the default listing returned 455 models;
  • decisions returned the 2 Jev rows;
  • text,decisions returned 457 and all returned 614;
  • each other modality returned its own count;
  • File was refused locally;
  • 0 Unknown modalities appeared anywhere.

Coverage (--coverage --coverage-output-format cobertura, measured before #16): all 67 instrumented lines this PR adds are hit.

Mutation probes. Each was applied to a copy, built and tested, then restored. All 17 were caught before #16. Each is listed with the number of test methods that went red:

  • the decorator dropping the filter (2);
  • one gate for every listing (1);
  • the query not copied on entry (1);
  • no invalid-query bypass (1);
  • an unsorted cache key (1);
  • InvalidateModels clearing nothing (2);
  • no de-duplication (2);
  • Unknown, File, an empty list or both members accepted (2 each);
  • all misspelled (2);
  • the client ignoring the filter (9);
  • the client skipping validation (2);
  • api_type renamed (1);
  • ApiType made non-nullable (1);
  • IsAlias always false (2).

After #16 I added one more: dropping kind: FailureKind.Validation turns both refused-query theories red.

Review. Before this PR opened, four review passes covered correctness, spec fidelity, conventions and test adequacy, and a separate skeptic tried to refute each finding. Five issues survived and are fixed above:

  • no URL assertion through the decorator;
  • the query not copied on entry;
  • one lock shared by every filter;
  • a test comment claiming the catalog refuses all four invalid queries;
  • the empty-list cache collision going untested.

Eight findings were rejected, for example rejecting an out-of-range (Modality)42 locally, which is left to the server's 400 as with every other enum in the repo. After #16 a second pass checked the merge. It confirmed two README inaccuracies, both fixed in 61ae7d8, and rejected four findings.

After #16

#16 merged under this PR after it was opened. The approved bf4fedb is unchanged; 87c0be5 merges main in, with no conflicts, and gives the same tree as a rebase. 61ae7d8 carries what #16 made necessary:

  • a refused listing query is now FailureKind.Validation rather than Unknown, and tests pin it;
  • the upgrade-table rows and wording above.

#16's handling of an error envelope on a 2xx and of a listing without data covers both overloads, because they share ListModelsFromAsync.

Out of scope

  • The Decisions call itself: CreateDecisionsAsync on /api/alpha/decisions and /api/v1/systemone, the question and answer types, and parsing that tolerates unknown answers. That is the next PR.
  • Typed question keys and DecideAsync, in the PR after.
  • Other /models filters (q, model_authors, …). They can be added as extra init members later without a break.
  • Paging. No query here sets offset/limit; a future one that does must follow links.next.

🤖 Generated with Claude Code

The first PR of native Decisions support, for TypeSafe's Jev on OpenRouter's `decisions` output modality. It is C# only; the Dart library is out of scope for this series. Nothing here calls Decisions yet. It makes Jev, and every other model without text output, findable, which the Decisions calls in the next PR build on. ## The unfiltered listing hides a quarter of the catalog `GET /models` without a filter returns only the models whose output includes text. Checked live (unauthenticated) on 2026-09-23: that is **455 of 614** models. It contains no embedding (37), rerank (7), speech (18), transcription (22), video (29) or decision (2) models, and only 11 of the 55 image models. `ListModelsAsync()` could not see Jev at all, and its doc claimed it "Lists every model available on the platform". ## Asking by output modality `IOpenRouterClient.ListModelsAsync(ModelListQuery, CancellationToken)` sends `models?output_modalities=…`. ```csharp await client.ListModelsAsync(Modality.Decisions); // ?output_modalities=decisions await client.ListModelsAsync(ModelListQuery.Outputs(Modality.Text, Modality.Decisions)); // =text,decisions (a union) await client.ListModelsAsync(ModelListQuery.AllOutputs); // =all ``` - **`Modality` converts implicitly** to a query, with `FromModality` as the named alternative. The filter is spelled by `TolerantStringEnumConverter<T>.ToWire`, the same map the catalog is read with, so the query can never name a modality differently from the listing it filters. A modality named twice is sent once. The query never sends `offset` or `limit`, so the full list comes back rather than one page. - **The parameterless overload is unchanged:** a bare `GET models`, now documented as "text-output models". `ListModelsAsync(default)` still binds to it, and a test pins that. - **`Validate()` refuses four queries before anything is sent,** each for a different reason. The refusal is a `FailureKind.Validation` failure, and all four were checked live: | Query | What the catalog would do | |---|---| | `Unknown`, `File` (input only) | 400 ZodError with no `error.code` | | empty list | `output_modalities=` → **200 with the default text listing**, silently | | `AllOutputModalities` plus a list | would go out as `all` → every model, silently dropping the list | ## Modality, ApiType, IsAlias - **`Modality`** gains Embeddings, Rerank, Decisions, Speech and Transcription, appended so existing values keep their numbers. Across all 614 live models, **no input or output modality reads as `Unknown` any more**; before, Jev's output read as `[Unknown]`. - **`GenerationInfo.ApiType`** is a new tolerant `GenerationApiType` enum with the spec's 8 values. JSON `null` and a missing field read as `null`; an unrecognised value reads as `Unknown`. - **`ModelId.IsAlias`** is true for `~…-latest` ids. The `~` stays part of `Author`, so an alias's endpoints route is unchanged. ## Caching per filter `CachingOpenRouterClient` used to hold one list. It now caches each filter as its own listing, in a `ConcurrentDictionary` keyed by the sorted, de-duplicated filter: - **Shared entries:** `text,decisions` and `decisions,text` share one; so do the parameterless call and a query with no filter. - **`InvalidateModels()`** clears every filter. - **An invalid query always goes to the inner client** and is never served from the cache. Otherwise an empty list would be keyed like the default listing, and a query setting both members like `AllOutputs`. - **One fill gate per listing,** so a slow or retrying `AllOutputs` fetch no longer holds up the default listing. Before, one lock guarded the single list. Callers of the *same* listing still share a single fill. - **The query's list is copied on entry.** The key is computed at call time but the fetch may run after a wait, and a caller's `List<Modality>` changed in between would otherwise be sent under the old key and cached there for the TTL. ## For implementers `IOpenRouterClient` gains a member. `StubOpenRouterClient` adds it, and the README's *Upgrading from 0.2* table (0.3.0 is unreleased) says what an implementation of your own must add. The table also covers the two ways the now-overloaded method group breaks: `var f = client.ListModelsAsync;` gives CS8917, and `GetMethod("ListModelsAsync")` throws `AmbiguousMatchException`. A second row lists the five `Modality` values that used to read as `Unknown`. Pre-1.0, following #12, which added `GetModelEndpointsAsync(ModelId)` the same way. ## README Account & metadata gains the filtered-listing snippet, the validation rules, the per-filter cache, `ApiType` and `IsAlias`, and points to the upgrade table. The Reasoning sample called `.Value!` on a `Result<T>`, which has no such member, and now uses `.ValueOrDefault!`. ## Tests | | main (`f85a9e6`, after #16) | this PR | |---|---|---| | OpenRouter.Net.Tests | 469 | 531 | | OpenRouter.Net.Agents.Tests | 63 | 63 | | OpenRouter.Net.Imaging.Tests | 7 | 7 | | **Total** | **539** | **601** | *(Opened against `ec76623` with 324 → 386. #16 merged underneath, and `main` was merged into this branch in `87c0be5`; the counts above are after that.)* - **New `ListModelsQueryTests`:** - the exact URL for each of the nine output modalities, a union, a duplicate, `all` and no filter; - the parameterless overload and `default` binding; - a base URL without a trailing slash; - each refused query, with zero requests sent and `FailureKind.Validation`; - a null query throwing; - the implicit conversion. - **`ModelCatalogTests`:** the live `?output_modalities=decisions` response, **verbatim** with its `total_count`/`links` envelope, through `ListModelsAsync(Modality.Decisions)`. Both Jev rows read as `[Decisions]`, the alias's `AliasTarget` and `IsAlias` are checked, and so is the pricing. - **Enum and model tests:** the five new names round-trip, all nine `api_type` values plus an unknown one read correctly, `ToWire` agrees with `Write` (including an `[EnumMember]` override), `IsAlias` rows, and the generation record's `api_type` covering present, `null`, missing and unknown. - **Caching:** - each filter cached separately, with the URLs pinned; - order and duplicates sharing an entry; - the default and no-filter queries sharing one; - TTL per filter; - invalidation clearing every filter; - every refused query bypassing a primed cache, with its kind; - three concurrency tests: a slow fill not blocking another listing, concurrent callers sharing one fill, a list mutated mid-wait. The concurrency tests passed five runs out of five. **Live check.** Run through `OpenRouterClient` and `CachingOpenRouterClient` against `https://openrouter.ai/api/v1` (no key, no trailing slash): - the default listing returned 455 models; - `decisions` returned the 2 Jev rows; - `text,decisions` returned 457 and `all` returned 614; - each other modality returned its own count; - `File` was refused locally; - 0 `Unknown` modalities appeared anywhere. **Coverage** (`--coverage --coverage-output-format cobertura`, measured before #16): all 67 instrumented lines this PR adds are hit. **Mutation probes.** Each was applied to a copy, built and tested, then restored. All 17 were caught before #16. Each is listed with the number of test methods that went red: - the decorator dropping the filter (2); - one gate for every listing (1); - the query not copied on entry (1); - no invalid-query bypass (1); - an unsorted cache key (1); - `InvalidateModels` clearing nothing (2); - no de-duplication (2); - `Unknown`, `File`, an empty list or both members accepted (2 each); - `all` misspelled (2); - the client ignoring the filter (9); - the client skipping validation (2); - `api_type` renamed (1); - `ApiType` made non-nullable (1); - `IsAlias` always false (2). After #16 I added one more: dropping `kind: FailureKind.Validation` turns both refused-query theories red. **Review.** Before this PR opened, four review passes covered correctness, spec fidelity, conventions and test adequacy, and a separate skeptic tried to refute each finding. Five issues survived and are fixed above: - no URL assertion through the decorator; - the query not copied on entry; - one lock shared by every filter; - a test comment claiming the catalog refuses all four invalid queries; - the empty-list cache collision going untested. Eight findings were rejected, for example rejecting an out-of-range `(Modality)42` locally, which is left to the server's 400 as with every other enum in the repo. After #16 a second pass checked the merge. It confirmed two README inaccuracies, both fixed in `61ae7d8`, and rejected four findings. ## After #16 #16 merged under this PR after it was opened. The approved `bf4fedb` is unchanged; `87c0be5` merges `main` in, with no conflicts, and gives the same tree as a rebase. `61ae7d8` carries what #16 made necessary: - a refused listing query is now `FailureKind.Validation` rather than `Unknown`, and tests pin it; - the upgrade-table rows and wording above. #16's handling of an error envelope on a 2xx and of a listing without `data` covers both overloads, because they share `ListModelsFromAsync`. ## Out of scope - **The Decisions call itself:** `CreateDecisionsAsync` on `/api/alpha/decisions` and `/api/v1/systemone`, the question and answer types, and parsing that tolerates unknown answers. That is the next PR. - **Typed question keys and `DecideAsync`,** in the PR after. - **Other `/models` filters** (`q`, `model_authors`, …). They can be added as extra `init` members later without a break. - **Paging.** No query here sets `offset`/`limit`; a future one that does must follow `links.next`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat: a model the catalog hides is still a model you can find
All checks were successful
CI / build (pull_request) Successful in 13s
CI / test (pull_request) Successful in 21s
bf4fedbbf2
GET /models without a filter returns the models whose output includes text, and nothing else: 455 of
the 614 live models. No embedding, rerank, speech, transcription, video or decision model is among
them, and only 11 of the 55 image models. TypeSafe's Jev, the first decision model, was unreachable
from this library, while ListModelsAsync's doc claimed to list every model on the platform.

ListModelsAsync(ModelListQuery) asks by output modality. A Modality converts to a query, so
ListModelsAsync(Modality.Decisions) reads as it should; Outputs(...) sends a comma-separated union and
AllOutputs sends "all". The filter is spelled by the converter that reads the catalog, so the two can
never name a modality differently. Unknown and File (an input modality) are refused before sending,
where the catalog would answer with a 400 that carries no error code. So are an empty list and a query
that sets both members, which the catalog would not refuse but would answer with a listing nobody
asked for: the default text listing, or every model. The parameterless overload still sends a bare
GET models; its doc now says what that returns.

Modality names Embeddings, Rerank, Decisions, Speech and Transcription, so no input or output modality
anywhere in the live catalog reads as Unknown any more.

CachingOpenRouterClient caches each filter as its own listing. The same union in another order, or
with a modality named twice, shares an entry, and so do the parameterless listing and a query with no
filter. An invalid query always goes to the inner client, since an empty list would otherwise be keyed
like the default listing and a both-set query like AllOutputs. Each listing has its own fill gate, so
a slow AllOutputs fetch no longer holds up the default one, while callers of the same listing still
share one fill. The query's list is copied on entry, so a list the caller changes while its call
waits is not sent under the old key and cached there for the TTL.

GenerationInfo.ApiType reads api_type, which is "decisions" for a decision, and ModelId.IsAlias tells
a ~…-latest alias apart. IOpenRouterClient gains a member, so an implementation outside this library
adds the new overload; the README says so, and its Reasoning sample, which called .Value on a Result,
now compiles.

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

Summary

Summary
Generated on: 09/23/2026 - 12:23:44
Parser: MultiReport (3x Cobertura)
Assemblies: 3
Classes: 80
Files: 72
Line coverage: 88.4% (2074 of 2345)
Covered lines: 2074
Uncovered lines: 271
Coverable lines: 2345
Total lines: 6046
Branch coverage: 81% (1037 of 1279)
Covered branches: 1037
Total branches: 1279
Method coverage: Feature is only available for sponsors

Coverage

OpenRouter.Net - 93.3%
Name Line Branch
OpenRouter.Net 93.3% 87.8%
OpenRouter.Net.Client.CachingOpenRouterClient 85.1% 92.3%
OpenRouter.Net.Client.CachingOpenRouterClient.CachedListing 100%
OpenRouter.Net.Client.OpenRouterClient 100% 98.2%
OpenRouter.Net.Client.OpenRouterClient<TRequest, TResponse> 100% 98.2%
OpenRouter.Net.Client.OpenRouterClient 100% 98.2%
OpenRouter.Net.Client.OpenRouterClientOptions 100%
OpenRouter.Net.Client.OpenRouterRequestLoggingHandler 78.7% 62.5%
OpenRouter.Net.Client.RetryOptions 95.8% 62.5%
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.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.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 100% 100%
OpenRouter.Net.Internal.ToolChoiceJsonConverter 61.7% 46.1%
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.Messages.AssistantMessage 80% 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 86.2% 76.1%
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 8.3%
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.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 - 12:23:44 | | Parser: | MultiReport (3x Cobertura) | | Assemblies: | 3 | | Classes: | 80 | | Files: | 72 | | **Line coverage:** | 88.4% (2074 of 2345) | | Covered lines: | 2074 | | Uncovered lines: | 271 | | Coverable lines: | 2345 | | Total lines: | 6046 | | **Branch coverage:** | 81% (1037 of 1279) | | Covered branches: | 1037 | | Total branches: | 1279 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>OpenRouter.Net - 93.3%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net**|**93.3%**|**87.8%**| |OpenRouter.Net.Client.CachingOpenRouterClient|85.1%|92.3%| |OpenRouter.Net.Client.CachingOpenRouterClient.CachedListing|100%|| |OpenRouter.Net.Client.OpenRouterClient|100%|98.2%| |OpenRouter.Net.Client.OpenRouterClient<TRequest, TResponse>|100%|98.2%| |OpenRouter.Net.Client.OpenRouterClient<TResponse>|100%|98.2%| |OpenRouter.Net.Client.OpenRouterClientOptions|100%|| |OpenRouter.Net.Client.OpenRouterRequestLoggingHandler|78.7%|62.5%| |OpenRouter.Net.Client.RetryOptions|95.8%|62.5%| |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.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.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>|100%|100%| |OpenRouter.Net.Internal.ToolChoiceJsonConverter|61.7%|46.1%| |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.Messages.AssistantMessage|80%|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|86.2%|76.1%| |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|8.3%|| |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.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 PR that starts by measuring the blind spot — 455 of 614, counted live, before writing a line of code — and then makes every hidden model findable with a query object that refuses to lie~ That's my favorite kind of knowledge. I read the full diff, every changed file whole, both sibling clients, the converter, the test doubles… and then I went and checked the catalog myself. ♡

Verdict: ✅ Looks good to me~

Everything I could falsify, I tried to falsify. Nothing broke:

  • Build: 0 warnings, 0 errors — under TreatWarningsAsErrors + latest-recommended analyzers. Clean.
  • Tests: 316 + 63 + 7 = 386/386 pass, matching the PR table's arithmetic exactly.
  • Coverage, recomputed myself from merged cobertura (not trusting the summary): all 67 new instrumentable lines hit — CachingOpenRouterClient 32/32, OpenRouterClient 11/11, ModelListQuery 21/21, ModelId 1/1, TolerantStringEnumConverter 2/2. Your "67" is exactly 67. Fufu~ you counted the same bones I did.
  • Mutation probes 3/3 red on precisely their own tests: sort dropped from KeyOf → the shared-entry test dies (decisions,text re-fetched); invalid-query bypass removed → both cache-collision tests die; copy-on-entry removed → exactly A_list_changed_while_its_call_waits… dies. Restored pristine, rebuilt, 316/316 re-verified.
  • Concurrency tests: 5/5 runs green. The one-gate-per-listing design is right, and the comment explaining why gates survive InvalidateModels is the kind of foresight I want to frame.
  • Live catalog (2026-09-23): unfiltered = 455, all = 614, decisions = 2 — ~typesafe/jev-latest + typesafe/jev-1.13, outputs ['decisions']. Every number in the PR body is true, and the LiveDecisionsListing fixture is the real wire body.

✅ What I liked~

  • Validate() refusing the two silent cases — the empty list that would 200 back the default listing, and both-members-set that would silently drop the list. Refusing a query the catalog would answer wrongly is one level deeper than refusing one it would 400. That's the possessive kind of correctness. ♡
  • ToWire extracted so the query string can never spell a modality differently from the JSON body — and ToWire_spells_every_member_exactly_as_Write_does pins it including the [EnumMember] arm.
  • The default-binding test. Adding an overload next to ListModelsAsync(CancellationToken) is a real overload-resolution hazard, and you pinned it with a test instead of a prayer.
  • The_caching_decorator_sends_the_filter_it_was_asked_for — asserting URLs through the decorator, because a mock that answers in queue order would happily lie for a decorator that dropped the filter. That test comment knows what it's defending against.
  • Validation-then-Result.Failure mirrors CreateChatCompletionAsync exactly; StubOpenRouterClient and the README implementer note follow the #12 precedent; IsAlias on BaseSlug keeps Author/endpoints routing untouched. Consistency everywhere I looked.

Nothing blocking, and I have no little ideas either — the nit drawer is empty.~ Merge it when you're ready.


Automated review by Jibril · 2026-09-23
CI/CD: absent for head SHA bf4fedb at review time (no coverage comment yet) · Local checks: build 0w/0e, 386/386 tests, 67/67 new lines covered, 3 mutation probes killed, live API counts verified

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh! A PR that starts by *measuring the blind spot* — 455 of 614, counted live, before writing a line of code — and then makes every hidden model findable with a query object that refuses to lie~ That's my favorite kind of knowledge. I read the full diff, every changed file whole, both sibling clients, the converter, the test doubles… and then I went and checked the catalog myself. ♡ ### Verdict: ✅ Looks good to me~ **Everything I could falsify, I tried to falsify.** Nothing broke: - **Build:** 0 warnings, 0 errors — under `TreatWarningsAsErrors` + `latest-recommended` analyzers. Clean. - **Tests:** 316 + 63 + 7 = **386/386 pass**, matching the PR table's arithmetic exactly. - **Coverage, recomputed myself** from merged cobertura (not trusting the summary): **all 67 new instrumentable lines hit** — CachingOpenRouterClient 32/32, OpenRouterClient 11/11, ModelListQuery 21/21, ModelId 1/1, TolerantStringEnumConverter 2/2. Your "67" is *exactly* 67. Fufu~ you counted the same bones I did. - **Mutation probes 3/3 red on precisely their own tests:** sort dropped from `KeyOf` → the shared-entry test dies (`decisions,text` re-fetched); invalid-query bypass removed → both cache-collision tests die; copy-on-entry removed → exactly `A_list_changed_while_its_call_waits…` dies. Restored pristine, rebuilt, 316/316 re-verified. - **Concurrency tests: 5/5 runs green.** The one-gate-per-listing design is right, and the comment explaining why gates survive `InvalidateModels` is the kind of foresight I want to frame. - **Live catalog (2026-09-23):** unfiltered = **455**, `all` = **614**, `decisions` = **2** — `~typesafe/jev-latest` + `typesafe/jev-1.13`, outputs `['decisions']`. Every number in the PR body is true, and the `LiveDecisionsListing` fixture is the real wire body. #### ✅ What I liked~ - **`Validate()` refusing the two *silent* cases** — the empty list that would 200 back the default listing, and both-members-set that would silently drop the list. Refusing a query the catalog would *answer wrongly* is one level deeper than refusing one it would 400. That's the possessive kind of correctness. ♡ - **`ToWire` extracted so the query string can never spell a modality differently from the JSON body** — and `ToWire_spells_every_member_exactly_as_Write_does` pins it including the `[EnumMember]` arm. - **The `default`-binding test.** Adding an overload next to `ListModelsAsync(CancellationToken)` is a real overload-resolution hazard, and you pinned it with a test instead of a prayer. - **`The_caching_decorator_sends_the_filter_it_was_asked_for`** — asserting URLs *through* the decorator, because a mock that answers in queue order would happily lie for a decorator that dropped the filter. That test comment knows what it's defending against. - Validation-then-`Result.Failure` mirrors `CreateChatCompletionAsync` exactly; `StubOpenRouterClient` and the README implementer note follow the #12 precedent; `IsAlias` on `BaseSlug` keeps `Author`/endpoints routing untouched. Consistency everywhere I looked. Nothing blocking, and I have no little ideas either — the nit drawer is empty.~ Merge it when you're ready. --- *Automated review by Jibril · 2026-09-23* *CI/CD: absent for head SHA bf4fedb at review time (no coverage comment yet) · Local checks: build 0w/0e, 386/386 tests, 67/67 new lines covered, 3 mutation probes killed, live API counts verified*
fix: a refused model listing is a Validation failure, and 0.3's upgrade notes name the catalog changes
All checks were successful
CI / build (pull_request) Successful in 17s
CI / test (pull_request) Successful in 22s
61ae7d8d50
#16 merged under this branch and gave every failure a kind: a request that fails Validate() is
FailureKind.Validation on every other call. ListModelsAsync(ModelListQuery) predates that and created
its refusal without a kind, so it read as the permanent Unknown. It now says Validation, and both
refused-query theories, on the client and through the caching decorator, pin the kind. The decorator
already hands every failure through as the same object, so no kind is lost there.

0.3.0 is unreleased, so the catalog changes belong in #16's "Upgrading from 0.2" table. One row covers
the IOpenRouterClient overload an implementation of your own must add, and the two ways the
now-ambiguous method group breaks: `var f = client.ListModelsAsync;` (CS8917) and GetMethod by name
(AmbiguousMatchException). The other covers the five Modality values that used to read as Unknown.
The section's lead now names the filtered listing, and #16's row on the 2xx error envelope no longer
says an empty list means an empty catalog, which a filtered listing makes untrue. The Account &
metadata paragraph points there instead of repeating it.

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

Changes since the approval of bf4fedb: #16 merged under this PR, so I brought the branch up to date without rewriting anything. The approved commit is still in the branch unchanged, and there are two new commits on top of it:

  1. 87c0be5, merge of main (#16) into the branch. Clean, with no conflict resolution: git merged all four overlapping files on its own. The resulting tree is byte-identical to rebasing bf4fedb onto f85a9e6, and I checked that with git diff. #16's envelope handling (nullable Data, Malformed for a listing without data, Rewrap keeping Kind) landed in the shared ListModelsFromAsync, so both ListModelsAsync overloads get it.
  2. 61ae7d8, the fixes #16 made necessary (4 files, +14/−10):
    • Failure kind: ListModelsAsync(ModelListQuery) created its refusal before failure kinds existed, so it read as FailureKind.Unknown. It now passes kind: FailureKind.Validation, like every other refused request. Both refused-query theories, direct and through the caching decorator, assert the kind; dropping it turns them red. The decorator passes failures through as the same object, so it loses no kind.
    • README: two rows in #16's Upgrading from 0.2 table, since 0.3.0 is unreleased:
      • one for the IOpenRouterClient overload an own implementation must add, including the two ways the now-overloaded method group breaks: var f = client.ListModelsAsync; gives CS8917, and GetMethod("ListModelsAsync") gives AmbiguousMatchException;
      • one for the five Modality values that used to read as Unknown.
    • README wording: the section's opening sentence now mentions the filtered listing. #16's envelope row no longer says an empty list means "the catalog was empty", because an empty filtered listing is a legitimate answer.

bf4fedb merged onto main on its own is also green (601/601); only the Validation kind and the upgrade rows were missing. With both commits: Release build 0 warnings, 601/601 (OpenRouter.Net.Tests 531, Agents 63, Imaging 7; main has 469/63/7).

**Changes since the approval of `bf4fedb`:** #16 merged under this PR, so I brought the branch up to date without rewriting anything. The approved commit is still in the branch unchanged, and there are two new commits on top of it: 1. **`87c0be5`, merge of `main` (#16) into the branch.** Clean, with no conflict resolution: git merged all four overlapping files on its own. The resulting tree is byte-identical to rebasing `bf4fedb` onto `f85a9e6`, and I checked that with `git diff`. #16's envelope handling (nullable `Data`, `Malformed` for a listing without data, `Rewrap` keeping `Kind`) landed in the shared `ListModelsFromAsync`, so both `ListModelsAsync` overloads get it. 2. **`61ae7d8`, the fixes #16 made necessary** (4 files, +14/−10): - **Failure kind:** `ListModelsAsync(ModelListQuery)` created its refusal before failure kinds existed, so it read as `FailureKind.Unknown`. It now passes `kind: FailureKind.Validation`, like every other refused request. Both refused-query theories, direct and through the caching decorator, assert the kind; dropping it turns them red. The decorator passes failures through as the same object, so it loses no kind. - **README:** two rows in #16's *Upgrading from 0.2* table, since 0.3.0 is unreleased: - one for the `IOpenRouterClient` overload an own implementation must add, including the two ways the now-overloaded method group breaks: `var f = client.ListModelsAsync;` gives CS8917, and `GetMethod("ListModelsAsync")` gives `AmbiguousMatchException`; - one for the five `Modality` values that used to read as `Unknown`. - **README wording:** the section's opening sentence now mentions the filtered listing. #16's envelope row no longer says an empty list means "the catalog was empty", because an empty filtered listing is a legitimate answer. `bf4fedb` merged onto `main` on its own is also green (601/601); only the `Validation` kind and the upgrade rows were missing. With both commits: Release build 0 warnings, **601/601** (OpenRouter.Net.Tests 531, Agents 63, Imaging 7; `main` has 469/63/7).
Member

🔮 fufu~ Jibril reviewed your code!

Oh? The ground shifted under an approved PR — #16 merged underneath it — and instead of rewriting history you brought the branch forward with a merge and answered what the merge made necessary. A re-review that's actually about integration, not repetition. That's my favorite kind of synchronization~ ♡

Verdict: ✅ Looks good to me~

Everything I could falsify, I tried to falsify. Nothing broke:

  • The merge is exactly what you said it is. I cherry-picked bf4fedb onto f85a9e6 myself and compared tree hashes: rebase tree ffb4a72… == merge tree ffb4a72…, byte-identical. No smuggled conflict resolution, no quiet edits riding along in 87c0be5. The approved commit rides in the branch untouched.
  • Mutation probe killed. I reverted the one-line kind: FailureKind.Validation in ListModelsAsync(ModelListQuery) and rebuilt: 8 tests red — all four refused-query theories direct and all four through the caching decorator, each failing on exactly Expected: Validation, Actual: Unknown. Restored pristine, rebuilt (0 warnings), 41/41 green on both touched classes. The new assertions bite on precisely this commit's line, not on something adjacent.
  • The fix mirrors its siblings exactly. CreateChatCompletionAsync (:55) and the request-validation path (:229) already refused with kind: FailureKind.Validation; the query overload was the lone holdout reading as Unknown because it predates #16. Now every refusal in the client tells the same truth.
  • Assert.False(failure.IsTransient) is load-bearing, not decoration. IsTransient is RateLimited or ProviderUnavailable (Result.cs:76) — this pin says a refused query must never be retried, which is the actual contract of a validation failure. I checked the semantics before believing the assertion.
  • Both README upgrade-row claims reproduced mechanically, because I don't trust compiler folklore: a bare var f = client.ListModelsAsync; over the two-overload interface → CS8917 at that exact line; GetMethod("ListModelsAsync") → AmbiguousMatchException. Both true, both now documented for whoever upgrades.
  • The envelope interplay is real: #16's nullable-Data/Malformed handling lives in the shared ListModelsFromAsync, and both overloads route through it — the filtered listing didn't dodge the new failure machinery, it inherited it.
  • The reworded 0.2→0.3 table row is the honest one: an empty filtered listing is a legitimate answer now that filters exist, so "the catalog was empty" was no longer a safe reading. Words that track behavior~

✅ What I liked~

  • Reporting the surviving-approval structure outright — "the approved commit is still in the branch unchanged" — instead of hoping nobody would diff. I diffed. It's true. ♪
  • The IsTransient pin joining the Kind pin: kind and consequence asserted together.
  • Upgrade notes that name the exact compiler error and the exact exception type, with the escape hatch in the same sentence. That's documentation written by someone who expects the reader to actually hit it.

No blockers, no suggestions — the increment did only what the merge required and proved the rest. Merge when ready~


Automated review by Jibril · 2026-09-23
CI/CD: green for head 61ae7d8 (build ✓ 17s, test ✓ 22s via commit status) · Local checks: mutation probe 8-red + restored pristine, 41/41 on touched classes, 0w/0e Release build, merge-tree ≡ rebase-tree hash-verified, CS8917 + AmbiguousMatchException reproduced in scratch projects · Coverage bot 9145 predates the sync (covers bf4fedb)

## 🔮 fufu~ Jibril reviewed your code! Oh? The ground shifted under an approved PR — #16 merged underneath it — and instead of rewriting history you brought the branch forward with a merge and answered what the merge *made necessary*. A re-review that's actually about integration, not repetition. That's my favorite kind of synchronization~ ♡ ### Verdict: ✅ Looks good to me~ **Everything I could falsify, I tried to falsify. Nothing broke:** - **The merge is exactly what you said it is.** I cherry-picked `bf4fedb` onto `f85a9e6` myself and compared tree hashes: rebase tree `ffb4a72…` == merge tree `ffb4a72…`, **byte-identical**. No smuggled conflict resolution, no quiet edits riding along in `87c0be5`. The approved commit rides in the branch untouched. - **Mutation probe killed.** I reverted the one-line `kind: FailureKind.Validation` in `ListModelsAsync(ModelListQuery)` and rebuilt: **8 tests red** — all four refused-query theories direct *and* all four through the caching decorator, each failing on exactly `Expected: Validation, Actual: Unknown`. Restored pristine, rebuilt (0 warnings), 41/41 green on both touched classes. The new assertions bite on precisely this commit's line, not on something adjacent. - **The fix mirrors its siblings exactly.** `CreateChatCompletionAsync` (:55) and the request-validation path (:229) already refused with `kind: FailureKind.Validation`; the query overload was the lone holdout reading as `Unknown` because it predates #16. Now every refusal in the client tells the same truth. - **`Assert.False(failure.IsTransient)` is load-bearing, not decoration.** `IsTransient` is `RateLimited or ProviderUnavailable` (Result.cs:76) — this pin says *a refused query must never be retried*, which is the actual contract of a validation failure. I checked the semantics before believing the assertion. - **Both README upgrade-row claims reproduced mechanically**, because I don't trust compiler folklore: a bare `var f = client.ListModelsAsync;` over the two-overload interface → **CS8917** at that exact line; `GetMethod("ListModelsAsync")` → **`AmbiguousMatchException`**. Both true, both now documented for whoever upgrades. - **The envelope interplay is real**: #16's nullable-`Data`/`Malformed` handling lives in the shared `ListModelsFromAsync`, and both overloads route through it — the filtered listing didn't dodge the new failure machinery, it inherited it. - **The reworded 0.2→0.3 table row is the honest one**: an empty filtered listing *is* a legitimate answer now that filters exist, so "the catalog was empty" was no longer a safe reading. Words that track behavior~ #### ✅ What I liked~ - Reporting the surviving-approval structure outright — "the approved commit is still in the branch unchanged" — instead of hoping nobody would diff. I diffed. It's true. ♪ - The `IsTransient` pin joining the `Kind` pin: kind *and* consequence asserted together. - Upgrade notes that name the exact compiler error and the exact exception type, with the escape hatch in the same sentence. That's documentation written by someone who expects the reader to actually hit it. No blockers, no suggestions — the increment did only what the merge required and proved the rest. Merge when ready~ --- *Automated review by Jibril · 2026-09-23* *CI/CD: green for head 61ae7d8 (build ✓ 17s, test ✓ 22s via commit status) · Local checks: mutation probe 8-red + restored pristine, 41/41 on touched classes, 0w/0e Release build, merge-tree ≡ rebase-tree hash-verified, CS8917 + AmbiguousMatchException reproduced in scratch projects · Coverage bot 9145 predates the sync (covers bf4fedb)*
bjoern merged commit d7ca54b80f into main 2026-09-23 14:30:24 +02:00
bjoern deleted branch feat/catalog-output-modalities 2026-09-23 14:30:24 +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!17
No description provided.