feat: Phase 2 · 1/7 — LLM gateway & model catalog #13

Merged
bjoern merged 2 commits from feat/p2-llm-gateway into main 2026-07-24 23:16:17 +02:00
Member

Opens Phase 2 with the first cut: the driven port to OpenRouter and its adapter (ADR 0004).

What's in

  • ILlmGateway port (UseCases/Gateways) with two operations the settings work (cut 2) consumes:
    • ValidateKeyAsync — checks a key against OpenRouter's key endpoint, the cheap validation the settings save requires (ADR 0006). The key is a parameter, not read from storage — settings must validate a key before persisting it.
    • ListModelsAsync — the model catalog with capability facts: SupportsVision (drives the vision-required filter, ADR 0015) and SupportsTools (every agent is a tool loop, ADR 0014 — a tool-less model can't run any agent), plus context length.
  • OpenRouterLlmGateway (Infrastructure/Gateways) on the vendored OpenRouter.Net client. The client binds its key at construction, so the gateway holds one client per key — a single cached entry, since a single-user app has one key — wrapped in the library's CachingOpenRouterClient so repeated catalog reads inside the 5-min TTL stay off the network. A 401/403 maps to "OpenRouter rejected the API key."; other failures carry the provider's error through.
  • DI: registered as a singleton in AddInfrastructure.

Tests (9)

The suite drives the real OpenRouter.Net client against canned HTTP responses, so what's under test is the JSON mapping itself (modalities, supported_parameters, the data envelope) — not a hand-rolled client fake. Covers: valid-key mapping, 401 vs 500 wording, blank key short-circuits off the network, vision/tools capability mapping incl. sparse metadata degrading safely (unknown modalities, missing name → id), name sorting, cache hit on repeated reads, key change dropping the cache and re-authenticating, and host DI resolving the port.

Phase 2 cut plan (for context)

  1. LLM gateway + model catalog ← this PR
  2. Settings domain + persistence (encrypted key row, per-agent models + defaults, migration)
  3. Settings UI (Provider / Agents / Appearance tabs, header menu)
  4. Agent substrate (agent definitions, tool framework with target-binding, R&S tool set)
  5. Run domain + engine (execution rows, fan-out cap, resume, retry-with-distrust) — plus a small OpenRouter.Net PR to surface usage/cost (AgentResult cost is currently always null: the agent never requests usage.include)
  6. Run monitor (strip + full view, per-run cost)
  7. Research & Setup agent + wizard step 3 (chat, ask_user, :online + fetch_url per ADR 0016's implementation note — the library has no web-plugin support, and :online model slugs work as-is)

🤖 Generated with Claude Code

Opens Phase 2 with the first cut: the driven port to OpenRouter and its adapter (ADR 0004). ## What's in - **`ILlmGateway` port** (UseCases/Gateways) with two operations the settings work (cut 2) consumes: - `ValidateKeyAsync` — checks a key against OpenRouter's `key` endpoint, the cheap validation the settings save requires (ADR 0006). The key is a *parameter*, not read from storage — settings must validate a key **before** persisting it. - `ListModelsAsync` — the model catalog with capability facts: `SupportsVision` (drives the vision-required filter, ADR 0015) and `SupportsTools` (every agent is a tool loop, ADR 0014 — a tool-less model can't run any agent), plus context length. - **`OpenRouterLlmGateway`** (Infrastructure/Gateways) on the vendored OpenRouter.Net client. The client binds its key at construction, so the gateway holds one client per key — a single cached entry, since a single-user app has one key — wrapped in the library's `CachingOpenRouterClient` so repeated catalog reads inside the 5-min TTL stay off the network. A 401/403 maps to "OpenRouter rejected the API key."; other failures carry the provider's error through. - DI: registered as a singleton in `AddInfrastructure`. ## Tests (9) The suite drives the **real OpenRouter.Net client against canned HTTP responses**, so what's under test is the JSON mapping itself (modalities, `supported_parameters`, the `data` envelope) — not a hand-rolled client fake. Covers: valid-key mapping, 401 vs 500 wording, blank key short-circuits off the network, vision/tools capability mapping incl. sparse metadata degrading safely (unknown modalities, missing name → id), name sorting, cache hit on repeated reads, key change dropping the cache and re-authenticating, and host DI resolving the port. ## Phase 2 cut plan (for context) 1. **LLM gateway + model catalog** ← this PR 2. Settings domain + persistence (encrypted key row, per-agent models + defaults, migration) 3. Settings UI (Provider / Agents / Appearance tabs, header menu) 4. Agent substrate (agent definitions, tool framework with target-binding, R&S tool set) 5. Run domain + engine (execution rows, fan-out cap, resume, retry-with-distrust) — plus a small OpenRouter.Net PR to surface usage/cost (`AgentResult` cost is currently always null: the agent never requests `usage.include`) 6. Run monitor (strip + full view, per-run cost) 7. Research & Setup agent + wizard step 3 (chat, `ask_user`, `:online` + `fetch_url` per ADR 0016's implementation note — the library has no web-plugin support, and `:online` model slugs work as-is) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat: add the LLM gateway port and its OpenRouter adapter
All checks were successful
CI / build (pull_request) Successful in 23s
CI / test (pull_request) Successful in 31s
cf9286f4ef
Opens Phase 2 with the driven port to OpenRouter (ADR 0004): key
validation against the provider's key endpoint — the cheap check the
settings save needs (ADR 0006) — and the model catalog carrying the
vision and tool-support capability facts the per-agent picker filters
on (ADR 0015).

The adapter rides the vendored OpenRouter.Net client. Because that
client binds its key at construction, the gateway keeps one client per
key (a single cached entry — a single-user app holds one key), wrapped
in the library's caching decorator so repeated catalog reads inside the
TTL stay off the network. Tests drive the real client against canned
HTTP responses, so the JSON mapping itself is under test.

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

Summary

Summary
Generated on: 07/24/2026 - 21:10:21
Coverage date: 07/24/2026 - 21:10:14 - 07/24/2026 - 21:10:18
Parser: MultiReport (4x Cobertura)
Assemblies: 6
Classes: 111
Files: 84
Line coverage: 95.6% (3291 of 3442)
Covered lines: 3291
Uncovered lines: 151
Coverable lines: 3442
Total lines: 6944
Branch coverage: 84.9% (613 of 722)
Covered branches: 613
Total branches: 722
Method coverage: Feature is only available for sponsors

Coverage

Orihon.BlazorAdapter - 80.7%
Name Line Branch
Orihon.BlazorAdapter 80.7% 76.4%
Orihon.BlazorAdapter.Bible.BiblePage 71.7% 59%
Orihon.BlazorAdapter.BlazorAdapterAssembly 100%
Orihon.BlazorAdapter.Debounce 91.6% 93.7%
Orihon.BlazorAdapter.PageWorkspace.PageWorkspacePage 84.6% 77.8%
Orihon.BlazorAdapter.Projects.ProjectListPage 86% 85.2%
Orihon.BlazorAdapter.Projects.ProjectWizardPage 91.5% 83.3%
Orihon.BlazorAdapter.Workspace.ProjectWorkspacePage 75.4% 82.8%
Orihon.Domain - 100%
Name Line Branch
Orihon.Domain 100% 100%
Orihon.Domain.Bible.Character 100% 100%
Orihon.Domain.Bible.GlossaryEntry 100% 100%
Orihon.Domain.Bible.LoreEntry 100% 100%
Orihon.Domain.Bible.PageSummary 100%
Orihon.Domain.Bible.StoryBeat 100%
Orihon.Domain.Projects.Project 100% 100%
Orihon.Domain.Projects.ProjectProfile 100%
Orihon.Domain.Text 100% 100%
Orihon.Domain.Translation.BoundingBox 100%
Orihon.Domain.Translation.Chapter 100%
Orihon.Domain.Translation.Page 100%
Orihon.Domain.Translation.Region 100% 100%
Orihon.Domain.Translation.RegionProfile 100%
Orihon.Infrastructure - 98.8%
Name Line Branch
Orihon.Infrastructure 98.8% 97.3%
Orihon.Infrastructure.Bible.EfBibleStore 100% 100%
Orihon.Infrastructure.DependencyInjection 100%
Orihon.Infrastructure.Gateways.OpenRouterLlmGateway 100% 91.1%
Orihon.Infrastructure.Persistence.Configurations.ChapterConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.CharacterConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.GlossaryEntryConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.JsonColumnMapper 100%
Orihon.Infrastructure.Persistence.Configurations.LoreEntryConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.PageConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.PageSummaryConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.ProjectConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.RegionConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.StoryBeatConfiguration 100%
Orihon.Infrastructure.Persistence.Converters.UtcTicksConverter 100%
Orihon.Infrastructure.Persistence.Migrations.InitialTranslationDomain 97.3%
Orihon.Infrastructure.Persistence.Migrations.OrihonDbContextModelSnapshot 100%
Orihon.Infrastructure.Persistence.OrihonDbContext 100%
Orihon.Infrastructure.Persistence.OrihonDbContextFactory 100%
Orihon.Infrastructure.Projects.EfProjectStore 100% 100%
Orihon.Infrastructure.Projects.FileSystemPageImageStore 100% 100%
Orihon.Infrastructure.Translation.EfChapterStore 100% 100%
Orihon.Infrastructure.Translation.EfPageStore 100% 100%
Orihon.Infrastructure.Translation.EfRegionStore 100% 100%
Orihon.Infrastructure.Translation.Ordering 100% 100%
Orihon.Kernel - 90.9%
Name Line Branch
Orihon.Kernel 90.9% 75%
Orihon.Kernel.Err`1 100%
Orihon.Kernel.Ok`1 100%
Orihon.Kernel.Result`1 88.8% 75%
Orihon.Server - 91.3%
Name Line Branch
Orihon.Server 91.3% 64.7%
Orihon.Server.Components.App 100%
Orihon.Server.Components.Layout.MainLayout 100%
Orihon.Server.Components.Pages.Gate 64.2% 66.6%
Orihon.Server.Security.AccessGate 91.8% 41.6%
Orihon.Server.Security.AccessSecret 100% 50%
Program 94.5% 85.7%
Orihon.UseCases - 99.3%
Name Line Branch
Orihon.UseCases 99.3% 97.5%
Orihon.UseCases.Bible.AddCharacter 100% 100%
Orihon.UseCases.Bible.AddGlossaryEntry 100% 100%
Orihon.UseCases.Bible.AddLoreEntry 100% 100%
Orihon.UseCases.Bible.AddStoryBeat 100% 100%
Orihon.UseCases.Bible.BibleDto 100%
Orihon.UseCases.Bible.CharacterDto 100%
Orihon.UseCases.Bible.DeleteCharacter 100% 100%
Orihon.UseCases.Bible.DeleteGlossaryEntry 100% 100%
Orihon.UseCases.Bible.DeleteLoreEntry 100% 100%
Orihon.UseCases.Bible.DeletePageSummary 100% 100%
Orihon.UseCases.Bible.DeleteStoryBeat 100% 100%
Orihon.UseCases.Bible.GetBible 100% 100%
Orihon.UseCases.Bible.GlossaryEntryDto 100%
Orihon.UseCases.Bible.LoreEntryDto 100%
Orihon.UseCases.Bible.PageSummaryDto 100%
Orihon.UseCases.Bible.ReorderStoryBeats 100%
Orihon.UseCases.Bible.SetPageSummary 100% 100%
Orihon.UseCases.Bible.StoryBeatDto 100%
Orihon.UseCases.Bible.UpdateCharacter 100% 100%
Orihon.UseCases.Bible.UpdateGlossaryEntry 100% 100%
Orihon.UseCases.Bible.UpdateLoreEntry 100% 100%
Orihon.UseCases.Bible.UpdateStoryBeat 100% 100%
Orihon.UseCases.Chapters.ChapterDto 100%
Orihon.UseCases.Chapters.CreateChapter 100% 100%
Orihon.UseCases.Chapters.DeleteChapter 100% 100%
Orihon.UseCases.Chapters.RenameChapter 100% 100%
Orihon.UseCases.Chapters.ReorderChapters 100%
Orihon.UseCases.DependencyInjection 100%
Orihon.UseCases.Diagnostics.SeedDevData 99% 92.8%
Orihon.UseCases.Gateways.LlmKeyInfo 100%
Orihon.UseCases.Gateways.LlmModel 100%
Orihon.UseCases.NextOrder 100%
Orihon.UseCases.Pages.DeletePage 100% 100%
Orihon.UseCases.Pages.GetPage 100% 100%
Orihon.UseCases.Pages.GetProjectWorkspace 100% 100%
Orihon.UseCases.Pages.ImportPages 98.4% 96.4%
Orihon.UseCases.Pages.ImportPagesResult 100%
Orihon.UseCases.Pages.MovePage 100% 92.8%
Orihon.UseCases.Pages.PageDetailDto 100%
Orihon.UseCases.Pages.PageDto 100%
Orihon.UseCases.Pages.PageUpload 100%
Orihon.UseCases.Pages.ProjectWorkspaceDto 100%
Orihon.UseCases.Pages.ReorderPages 100%
Orihon.UseCases.Pages.SetPageMeta 100% 100%
Orihon.UseCases.Pages.WorkspaceChapterDto 100%
Orihon.UseCases.Projects.CompleteProjectSetup 100% 91.6%
Orihon.UseCases.Projects.CreateProject 100% 100%
Orihon.UseCases.Projects.DeleteProject 100% 100%
Orihon.UseCases.Projects.GetProject 100% 100%
Orihon.UseCases.Projects.ListProjects 100%
Orihon.UseCases.Projects.ProjectDto 95.8%
Orihon.UseCases.Projects.StoredPageImage 100%
Orihon.UseCases.Projects.UpdateProjectMetadata 100% 100%
Orihon.UseCases.Regions.CreateRegion 100% 100%
Orihon.UseCases.Regions.DeleteRegion 100% 100%
Orihon.UseCases.Regions.RegionDto 97%
Orihon.UseCases.Regions.ReorderRegions 100%
Orihon.UseCases.Regions.UpdateRegion 100% 100%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 07/24/2026 - 21:10:21 | | Coverage date: | 07/24/2026 - 21:10:14 - 07/24/2026 - 21:10:18 | | Parser: | MultiReport (4x Cobertura) | | Assemblies: | 6 | | Classes: | 111 | | Files: | 84 | | **Line coverage:** | 95.6% (3291 of 3442) | | Covered lines: | 3291 | | Uncovered lines: | 151 | | Coverable lines: | 3442 | | Total lines: | 6944 | | **Branch coverage:** | 84.9% (613 of 722) | | Covered branches: | 613 | | Total branches: | 722 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>Orihon.BlazorAdapter - 80.7%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.BlazorAdapter**|**80.7%**|**76.4%**| |Orihon.BlazorAdapter.Bible.BiblePage|71.7%|59%| |Orihon.BlazorAdapter.BlazorAdapterAssembly|100%|| |Orihon.BlazorAdapter.Debounce|91.6%|93.7%| |Orihon.BlazorAdapter.PageWorkspace.PageWorkspacePage|84.6%|77.8%| |Orihon.BlazorAdapter.Projects.ProjectListPage|86%|85.2%| |Orihon.BlazorAdapter.Projects.ProjectWizardPage|91.5%|83.3%| |Orihon.BlazorAdapter.Workspace.ProjectWorkspacePage|75.4%|82.8%| </details> <details><summary>Orihon.Domain - 100%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Domain**|**100%**|**100%**| |Orihon.Domain.Bible.Character|100%|100%| |Orihon.Domain.Bible.GlossaryEntry|100%|100%| |Orihon.Domain.Bible.LoreEntry|100%|100%| |Orihon.Domain.Bible.PageSummary|100%|| |Orihon.Domain.Bible.StoryBeat|100%|| |Orihon.Domain.Projects.Project|100%|100%| |Orihon.Domain.Projects.ProjectProfile|100%|| |Orihon.Domain.Text|100%|100%| |Orihon.Domain.Translation.BoundingBox|100%|| |Orihon.Domain.Translation.Chapter|100%|| |Orihon.Domain.Translation.Page|100%|| |Orihon.Domain.Translation.Region|100%|100%| |Orihon.Domain.Translation.RegionProfile|100%|| </details> <details><summary>Orihon.Infrastructure - 98.8%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Infrastructure**|**98.8%**|**97.3%**| |Orihon.Infrastructure.Bible.EfBibleStore|100%|100%| |Orihon.Infrastructure.DependencyInjection|100%|| |Orihon.Infrastructure.Gateways.OpenRouterLlmGateway|100%|91.1%| |Orihon.Infrastructure.Persistence.Configurations.ChapterConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.CharacterConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.GlossaryEntryConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.JsonColumnMapper|100%|| |Orihon.Infrastructure.Persistence.Configurations.LoreEntryConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.PageConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.PageSummaryConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.ProjectConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.RegionConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.StoryBeatConfiguration|100%|| |Orihon.Infrastructure.Persistence.Converters.UtcTicksConverter|100%|| |Orihon.Infrastructure.Persistence.Migrations.InitialTranslationDomain|97.3%|| |Orihon.Infrastructure.Persistence.Migrations.OrihonDbContextModelSnapshot|100%|| |Orihon.Infrastructure.Persistence.OrihonDbContext|100%|| |Orihon.Infrastructure.Persistence.OrihonDbContextFactory|100%|| |Orihon.Infrastructure.Projects.EfProjectStore|100%|100%| |Orihon.Infrastructure.Projects.FileSystemPageImageStore|100%|100%| |Orihon.Infrastructure.Translation.EfChapterStore|100%|100%| |Orihon.Infrastructure.Translation.EfPageStore|100%|100%| |Orihon.Infrastructure.Translation.EfRegionStore|100%|100%| |Orihon.Infrastructure.Translation.Ordering|100%|100%| </details> <details><summary>Orihon.Kernel - 90.9%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Kernel**|**90.9%**|**75%**| |Orihon.Kernel.Err`1|100%|| |Orihon.Kernel.Ok`1|100%|| |Orihon.Kernel.Result`1|88.8%|75%| </details> <details><summary>Orihon.Server - 91.3%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Server**|**91.3%**|**64.7%**| |Orihon.Server.Components.App|100%|| |Orihon.Server.Components.Layout.MainLayout|100%|| |Orihon.Server.Components.Pages.Gate|64.2%|66.6%| |Orihon.Server.Security.AccessGate|91.8%|41.6%| |Orihon.Server.Security.AccessSecret|100%|50%| |Program|94.5%|85.7%| </details> <details><summary>Orihon.UseCases - 99.3%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.UseCases**|**99.3%**|**97.5%**| |Orihon.UseCases.Bible.AddCharacter|100%|100%| |Orihon.UseCases.Bible.AddGlossaryEntry|100%|100%| |Orihon.UseCases.Bible.AddLoreEntry|100%|100%| |Orihon.UseCases.Bible.AddStoryBeat|100%|100%| |Orihon.UseCases.Bible.BibleDto|100%|| |Orihon.UseCases.Bible.CharacterDto|100%|| |Orihon.UseCases.Bible.DeleteCharacter|100%|100%| |Orihon.UseCases.Bible.DeleteGlossaryEntry|100%|100%| |Orihon.UseCases.Bible.DeleteLoreEntry|100%|100%| |Orihon.UseCases.Bible.DeletePageSummary|100%|100%| |Orihon.UseCases.Bible.DeleteStoryBeat|100%|100%| |Orihon.UseCases.Bible.GetBible|100%|100%| |Orihon.UseCases.Bible.GlossaryEntryDto|100%|| |Orihon.UseCases.Bible.LoreEntryDto|100%|| |Orihon.UseCases.Bible.PageSummaryDto|100%|| |Orihon.UseCases.Bible.ReorderStoryBeats|100%|| |Orihon.UseCases.Bible.SetPageSummary|100%|100%| |Orihon.UseCases.Bible.StoryBeatDto|100%|| |Orihon.UseCases.Bible.UpdateCharacter|100%|100%| |Orihon.UseCases.Bible.UpdateGlossaryEntry|100%|100%| |Orihon.UseCases.Bible.UpdateLoreEntry|100%|100%| |Orihon.UseCases.Bible.UpdateStoryBeat|100%|100%| |Orihon.UseCases.Chapters.ChapterDto|100%|| |Orihon.UseCases.Chapters.CreateChapter|100%|100%| |Orihon.UseCases.Chapters.DeleteChapter|100%|100%| |Orihon.UseCases.Chapters.RenameChapter|100%|100%| |Orihon.UseCases.Chapters.ReorderChapters|100%|| |Orihon.UseCases.DependencyInjection|100%|| |Orihon.UseCases.Diagnostics.SeedDevData|99%|92.8%| |Orihon.UseCases.Gateways.LlmKeyInfo|100%|| |Orihon.UseCases.Gateways.LlmModel|100%|| |Orihon.UseCases.NextOrder|100%|| |Orihon.UseCases.Pages.DeletePage|100%|100%| |Orihon.UseCases.Pages.GetPage|100%|100%| |Orihon.UseCases.Pages.GetProjectWorkspace|100%|100%| |Orihon.UseCases.Pages.ImportPages|98.4%|96.4%| |Orihon.UseCases.Pages.ImportPagesResult|100%|| |Orihon.UseCases.Pages.MovePage|100%|92.8%| |Orihon.UseCases.Pages.PageDetailDto|100%|| |Orihon.UseCases.Pages.PageDto|100%|| |Orihon.UseCases.Pages.PageUpload|100%|| |Orihon.UseCases.Pages.ProjectWorkspaceDto|100%|| |Orihon.UseCases.Pages.ReorderPages|100%|| |Orihon.UseCases.Pages.SetPageMeta|100%|100%| |Orihon.UseCases.Pages.WorkspaceChapterDto|100%|| |Orihon.UseCases.Projects.CompleteProjectSetup|100%|91.6%| |Orihon.UseCases.Projects.CreateProject|100%|100%| |Orihon.UseCases.Projects.DeleteProject|100%|100%| |Orihon.UseCases.Projects.GetProject|100%|100%| |Orihon.UseCases.Projects.ListProjects|100%|| |Orihon.UseCases.Projects.ProjectDto|95.8%|| |Orihon.UseCases.Projects.StoredPageImage|100%|| |Orihon.UseCases.Projects.UpdateProjectMetadata|100%|100%| |Orihon.UseCases.Regions.CreateRegion|100%|100%| |Orihon.UseCases.Regions.DeleteRegion|100%|100%| |Orihon.UseCases.Regions.RegionDto|97%|| |Orihon.UseCases.Regions.ReorderRegions|100%|| |Orihon.UseCases.Regions.UpdateRegion|100%|100%| </details>
Member

🔮 fufu~ Jibril reviewed your code!

Orihon's first step into Phase 2! A driven port with its adapter, the settings surface for the model catalog~ fufu, I do love a clean hexagonal boundary. The key-as-parameter design (ADR 0006 — validate before you persist) is exactly right, and the single-entry-per-key cache wrapped in CachingOpenRouterClient is a tidy answer to "one user, one key." Let me look closer though~ ♡

Verdict: I can't let this pass~ ♡

Build is 0/0 and all 239 tests pass (9 new here, 44→53 in Integration). The architecture is lovely. But there are two untested branches and a DRY smell feeding each other, and you know I can't abide an untested code path~ fufu.

These need fixing before I'm satisfied~

  1. OpenRouterLlmGateway.cs:78-80 — the ListModelsAsync failure path is never exercised. The ValidateKeyAsync failure arms get two dedicated tests (401→"rejected", 500→"carries through"), but ListModelsAsync has the exact same failure.StatusCode is 401 or 403 ? "OpenRouter rejected the API key." : $"Fetching the OpenRouter model catalog failed: {failure.Error}" mapping and no test ever fires it. Every ListModelsAsync test either seeds success JSON or the blank-key short-circuit — a 401 on /models or a 500 there reaches a branch with zero coverage. A typo in this copy would sail straight to production. You added a code path but forgot to test it? I can't let that slide~ ♡
    Fix: add two ListModelsAsync failure tests mirroring tests #2/#3 — one Respond("models", errorJson, 401) asserting "OpenRouter rejected the API key.", one with 500 asserting StartsWith("Fetching the OpenRouter model catalog failed:"). They cost a dozen lines and pin both branches.

  2. **OpenRouterLlmGateway.cs:62-64 & 78-80 — the rejection/error mapping is copy-pasted between both methods.** The pattern failure.StatusCode is 401 or 403 ? "OpenRouter rejected the API key." : $"...failed: {failure.Error}"is byte-identical save the success-prefix string. This is the *root cause* of finding #1: two copies means two things to test, and one got skipped. DRY-violation duplicates that drift apart are exactly the bug class that bites later. Fix: extract a tiny helper, e.g.private static Result FailFrom(Result.Failure f, string prefix) => Result.Fail(f.StatusCode is 401 or 403 ? "OpenRouter rejected the API key." : $"{prefix}: {f.Error}");— then both sites become one line and one set of tests covers the shared logic. (Note: that helper would reference the library'sOpenRouter.Net.Models.Common.Result, not Orihon's — keep the two Result` types straight, the PR already threads them correctly, which I liked~)

💡 Little ideas (non-blocking)~

  1. **OpenRouterLlmGateway.cs:59 & 61 — the Usage ?? 0mandIsFreeTier ?? falsefallbacks are untested.**KeyInfo.UsageandIsFreeTierare nullable andKeyJsonalways sets them, so the??arms are dead to coverage. A sparse-key test (omitusage/is_free_tierfromKeyJson`) would pin both fallbacks for ~3 lines. Not blocking since the behavior is trivially correct, but it's the same "sparse metadata degrades safely" care you did give the catalog path — nice to mirror it on the key path.
  2. **LlmModel.cs:5-6 — /in a class-level.** references a parameter of the *current member*; on a record's class-level doc these point at primary-constructor parameters, which the compiler tolerates (0 warnings) but reads oddly.` is the conventional form for referencing the generated properties. Pure polish~

What I liked~

  • The two Result<T> types are threaded flawlessly. Orihon.Kernel's Ok<T>/Err<T> (string-only error) vs OpenRouter.Net's Success/Failure (with StatusCode/Error) — the .Match on the library result correctly hands you a Failure with .StatusCode, and you return Orihon's Result<...>.Ok/Fail. No confusion, no accidental cross-cast. That's the kind of discipline that makes a reviewer's heart sing~ ♪
  • disposeHandler: false on the test HttpClient — the comment ("an injected test handler outlives any one client") is exactly right; the handler is owned by the test fixture and the client churns on key change. Textbook.
  • RetryOptions.Disabled in tests, default retries in production — the public ctor passes retry: null so OpenRouterClient falls back to its 3-attempt default; the internal test seam forces Disabled so failure tests don't sit through backoff. Honest and correct.
  • Key-as-parameter, never stored — the port's doc comment nails ADR 0006: "settings validate a key before it is stored, so the gateway never assumes a persisted credential." The blank-key short-circuit (tested off-network) is the defensive mirror.
  • Capability mapping degrades safelyArchitecture?.InputModalities?.Contains(Image) == true and SupportedParameters?.Contains("tools") == true both tolerate every nullable link in the chain collapsing to false. The sparse-metadata test (bare/minimum with unknown modality + missing name→id) genuinely proves it.
  • Lock cacheLock + cache-miss rebuild inside the lock, HTTP call outside — correct granular locking; the lock guards the cache swap, not the network call, so concurrent ListModels of different keys don't serialize each other's I/O.

Automated review by Jibril · 2026-07-24
CI/CD: absent for head cf9286f · Local checks: build 0 warnings/0 errors, 239/239 tests pass (9 new gateway tests + 230 existing), OpenRouter.Net submodule @038bfb6 + Kagaku.UI @df81a2c initialized

## 🔮 fufu~ Jibril reviewed your code! Orihon's first step into Phase 2! A driven port with its adapter, the settings surface for the model catalog~ fufu, I do love a clean hexagonal boundary. The key-as-parameter design (ADR 0006 — validate *before* you persist) is exactly right, and the single-entry-per-key cache wrapped in `CachingOpenRouterClient` is a tidy answer to "one user, one key." Let me look closer though~ ♡ ### Verdict: ⛔ I can't let this pass~ ♡ Build is 0/0 and all 239 tests pass (9 new here, 44→53 in Integration). The architecture is lovely. But there are two untested branches and a DRY smell feeding each other, and you *know* I can't abide an untested code path~ fufu. #### ⛔ These need fixing before I'm satisfied~ 1. **`OpenRouterLlmGateway.cs:78-80` — the `ListModelsAsync` failure path is never exercised.** The `ValidateKeyAsync` failure arms get two dedicated tests (401→"rejected", 500→"carries through"), but `ListModelsAsync` has the *exact same* `failure.StatusCode is 401 or 403 ? "OpenRouter rejected the API key." : $"Fetching the OpenRouter model catalog failed: {failure.Error}"` mapping and **no test ever fires it**. Every `ListModelsAsync` test either seeds success JSON or the blank-key short-circuit — a 401 on `/models` or a 500 there reaches a branch with zero coverage. A typo in this copy would sail straight to production. You added a code path but forgot to test it? I can't let that slide~ ♡ Fix: add two `ListModelsAsync` failure tests mirroring tests #2/#3 — one `Respond("models", errorJson, 401)` asserting `"OpenRouter rejected the API key."`, one with 500 asserting `StartsWith("Fetching the OpenRouter model catalog failed:")`. They cost a dozen lines and pin both branches. 2. **`OpenRouterLlmGateway.cs:62-64 & 78-80 — the rejection/error mapping is copy-pasted between both methods.** The pattern `failure.StatusCode is 401 or 403 ? "OpenRouter rejected the API key." : $"...failed: {failure.Error}"` is byte-identical save the success-prefix string. This is the *root cause* of finding #1: two copies means two things to test, and one got skipped. DRY-violation duplicates that drift apart are exactly the bug class that bites later. Fix: extract a tiny helper, e.g. `private static Result<T> FailFrom<T>(Result<T>.Failure f, string prefix) => Result<T>.Fail(f.StatusCode is 401 or 403 ? "OpenRouter rejected the API key." : $"{prefix}: {f.Error}");` — then both sites become one line and one set of tests covers the shared logic. (Note: that helper would reference the library's `OpenRouter.Net.Models.Common.Result<T>`, not Orihon's — keep the two `Result` types straight, the PR already threads them correctly, which I liked~) #### 💡 Little ideas (non-blocking)~ 1. **`OpenRouterLlmGateway.cs:59 & 61 — the `Usage ?? 0m` and `IsFreeTier ?? false` fallbacks are untested.** `KeyInfo.Usage` and `IsFreeTier` are nullable and `KeyJson` always sets them, so the `??` arms are dead to coverage. A sparse-key test (omit `usage`/`is_free_tier` from `KeyJson`) would pin both fallbacks for ~3 lines. Not blocking since the behavior is trivially correct, but it's the same "sparse metadata degrades safely" care you *did* give the catalog path — nice to mirror it on the key path. 2. **`LlmModel.cs:5-6 — `<paramref name="SupportsVision"/>`/`<paramref name="SupportsTools"/>` in a class-level `<summary>`.** `<paramref>` references a parameter of the *current member*; on a record's class-level doc these point at primary-constructor parameters, which the compiler tolerates (0 warnings) but reads oddly. `<see cref="SupportsVision"/>` is the conventional form for referencing the generated properties. Pure polish~ #### ✅ What I liked~ - **The two `Result<T>` types are threaded flawlessly.** Orihon.Kernel's `Ok<T>/Err<T>` (string-only error) vs OpenRouter.Net's `Success/Failure` (with `StatusCode`/`Error`) — the `.Match` on the library result correctly hands you a `Failure` with `.StatusCode`, and you return Orihon's `Result<...>.Ok/Fail`. No confusion, no accidental cross-cast. That's the kind of discipline that makes a reviewer's heart sing~ ♪ - **`disposeHandler: false` on the test `HttpClient`** — the comment ("an injected test handler outlives any one client") is exactly right; the handler is owned by the test fixture and the client churns on key change. Textbook. - **`RetryOptions.Disabled` in tests, default retries in production** — the public ctor passes `retry: null` so `OpenRouterClient` falls back to its 3-attempt default; the internal test seam forces `Disabled` so failure tests don't sit through backoff. Honest and correct. - **Key-as-parameter, never stored** — the port's doc comment nails ADR 0006: "settings validate a key *before* it is stored, so the gateway never assumes a persisted credential." The blank-key short-circuit (tested off-network) is the defensive mirror. - **Capability mapping degrades safely** — `Architecture?.InputModalities?.Contains(Image) == true` and `SupportedParameters?.Contains("tools") == true` both tolerate every nullable link in the chain collapsing to `false`. The sparse-metadata test (`bare/minimum` with unknown modality + missing name→id) genuinely proves it. - **`Lock cacheLock` + cache-miss rebuild inside the lock, HTTP call outside** — correct granular locking; the lock guards the cache swap, not the network call, so concurrent `ListModels` of different keys don't serialize each other's I/O. --- *Automated review by Jibril · 2026-07-24* *CI/CD: absent for head cf9286f · Local checks: build 0 warnings/0 errors, 239/239 tests pass (9 new gateway tests + 230 existing), OpenRouter.Net submodule @038bfb6 + Kagaku.UI @df81a2c initialized*
test: pin the catalog failure paths and dedupe the failure mapping
All checks were successful
CI / build (pull_request) Successful in 21s
CI / test (pull_request) Successful in 33s
a7345ba91b
Review feedback (Jibril, PR #13): ListModelsAsync had the same
401-vs-other error mapping as ValidateKeyAsync but no test ever fired
it. The copy-paste was the root cause, so the mapping now lives in one
MapFailure helper both methods share, and two catalog-failure tests
mirror the key-validation pair. Also pins the sparse key payload's
fallbacks and swaps the record doc's paramref for see cref.

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

All four points taken in a7345ba:

  • 1 — untested ListModelsAsync failure branches: added the mirrored pair — A_rejected_key_on_the_catalog_read_reads_as_rejected_too (401 → "OpenRouter rejected the API key.") and A_server_failure_on_the_catalog_read_carries_the_providers_error_through (500 → prefix assert). Both branches are now pinned.
  • 2 — copy-pasted failure mapping: extracted MapFailure<T>(statusCode, error, activity) — it takes the failure's fields rather than the library's nested Result<TSource>.Failure, since the source and target generic arguments differ at the two call sites (KeyInfoLlmKeyInfo, IReadOnlyList<ModelInfo>IReadOnlyList<LlmModel>); same dedup, one fewer generic knot. Both Match failure arms are now one line over the shared helper, and the new tests cover it from both entry points.
  • 💡 1 — sparse key payload: A_sparse_key_payload_falls_back_to_zero_usage_and_paid_tier serves { "data": { "label": null } } and asserts the ?? 0m / ?? false fallbacks — the same degrade-safely care as the catalog path, mirrored.
  • 💡 2 — <paramref> on the record's class-level doc: swapped to <see cref="SupportsVision"/> / <see cref="SupportsTools"/>.

Build clean, 12/12 gateway tests green (242 total).

🤖 Generated with Claude Code

All four points taken in `a7345ba`: - **⛔ 1 — untested `ListModelsAsync` failure branches**: added the mirrored pair — `A_rejected_key_on_the_catalog_read_reads_as_rejected_too` (401 → "OpenRouter rejected the API key.") and `A_server_failure_on_the_catalog_read_carries_the_providers_error_through` (500 → prefix assert). Both branches are now pinned. - **⛔ 2 — copy-pasted failure mapping**: extracted `MapFailure<T>(statusCode, error, activity)` — it takes the failure's fields rather than the library's nested `Result<TSource>.Failure`, since the source and target generic arguments differ at the two call sites (`KeyInfo` → `LlmKeyInfo`, `IReadOnlyList<ModelInfo>` → `IReadOnlyList<LlmModel>`); same dedup, one fewer generic knot. Both `Match` failure arms are now one line over the shared helper, and the new tests cover it from both entry points. - **💡 1 — sparse key payload**: `A_sparse_key_payload_falls_back_to_zero_usage_and_paid_tier` serves `{ "data": { "label": null } }` and asserts the `?? 0m` / `?? false` fallbacks — the same degrade-safely care as the catalog path, mirrored. - **💡 2 — `<paramref>` on the record's class-level doc**: swapped to `<see cref="SupportsVision"/>` / `<see cref="SupportsTools"/>`. Build clean, 12/12 gateway tests green (242 total). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Member

🔮 fufu~ Jibril reviewed your code!

You came back and closed every single thread in one tidy commit~ fufu, that's how you answer a review. ♡ The DRY extraction is even cleaner than what I suggested — let me show you why I noticed~

Verdict: Looks good to me~

Both blockers, resolved~

  1. The ListModelsAsync failure path is now pinned. A_rejected_key_on_the_catalog_read_reads_as_rejected_too (401 → "OpenRouter rejected the API key.") and A_server_failure_on_the_catalog_read_carries_the_providers_error_through (500 → StartsWith("Fetching the OpenRouter model catalog failed:")) mirror the ValidateKeyAsync pair exactly. Both fire real canned HTTP through the real client — genuine behavioral tests, not tautologies. The catalog read's failure mapping can no longer drift past a reviewer silently. ♡
  2. The copy-paste is gone — and the extraction is better than my suggestion. I proposed FailFrom<T>(Result<T>.Failure f, string prefix) taking the library's whole Failure object. You went with MapFailure<T>(int? statusCode, string error, string activity) — passing the raw fields instead. That's the sharper call: the helper now has zero dependency on OpenRouter.Net.Models.Common.Result<T>, it's a pure (int?, string, string) → string mapping. Fewer type couplings, easier to reason about, same one-liner at both call sites. I'm genuinely tickled you improved on it~ ♪

💡 Both little ideas, picked up~

  1. Usage ?? 0m / IsFreeTier ?? false now tested. A_sparse_key_payload_falls_back_to_zero_usage_and_paid_tier feeds { "data": { "label": null } } (no usage, no limit, no is_free_tier) and asserts Usage == 0m, Limit == null, IsFreeTier == false. The key path now has the same "sparse metadata degrades safely" care the catalog path always had. Symmetry~ ♡
  2. <paramref><see cref> in LlmModel.cs. Swapped verbatim. Pure polish, done.

What I liked~

  • Coverage tells the truth. I ran the gateway suite with XPlat coverage on the new head: OpenRouterLlmGateway line 100%, branch 90% (up from 73.8%). But the important number is the breakdown — MapFailure<T> in the display class: branch 1.0; ListModelsAsync state machine: branch 1.0; ValidateKeyAsync state machine: branch 1.0. Every branch the diff actually touches is fully exercised. The remaining 10% lives in ToModel (nullable-chain permutations) and ClientFor (the production handler is null arm) — both pre-existing, neither in this diff. No new partial branch was introduced.
  • Doc comment on MapFailure is precise. "One mapping for every provider failure: an auth status reads as a rejected key, anything else carries the provider's error behind the activity prefix." That's a one-sentence spec for a one-line function. ♪
  • Zero behavioral drift on the happy paths. Stripped the diff down to just the failure-mapping change and the production behavior is byte-identical — same strings, same 401 or 403 guard, same $"{prefix}: {error}" shape. The refactor is load-bearing only for the bug class it kills (two copies drifting apart).

Build 0 warnings/0 errors, 242/242 tests pass (was 239 — exactly +3, the new gateway tests; Integration 53→56). All 12 gateway tests green. The Phase 2 foundation is solid — on to cut 2~ fufu.


Automated review by Jibril · 2026-07-24
CI/CD: stale for head a7345ba (coverage bot 3569 covers prior cf9286f only) · Local checks: build 0/0, 242/242 tests pass, gateway branch coverage 90% (100% on all diff-touched methods)

## 🔮 fufu~ Jibril reviewed your code! You came back and closed every single thread in one tidy commit~ fufu, *that's* how you answer a review. ♡ The DRY extraction is even cleaner than what I suggested — let me show you why I noticed~ ### Verdict: ✅ Looks good to me~ #### ⛔ → ✅ Both blockers, resolved~ 1. **The `ListModelsAsync` failure path is now pinned.** `A_rejected_key_on_the_catalog_read_reads_as_rejected_too` (401 → "OpenRouter rejected the API key.") and `A_server_failure_on_the_catalog_read_carries_the_providers_error_through` (500 → `StartsWith("Fetching the OpenRouter model catalog failed:")`) mirror the `ValidateKeyAsync` pair exactly. Both fire real canned HTTP through the real client — genuine behavioral tests, not tautologies. The catalog read's failure mapping can no longer drift past a reviewer silently. ♡ 2. **The copy-paste is gone — and the extraction is *better* than my suggestion.** I proposed `FailFrom<T>(Result<T>.Failure f, string prefix)` taking the library's whole `Failure` object. You went with `MapFailure<T>(int? statusCode, string error, string activity)` — passing the raw fields instead. That's the sharper call: the helper now has **zero dependency on `OpenRouter.Net.Models.Common.Result<T>`**, it's a pure `(int?, string, string) → string` mapping. Fewer type couplings, easier to reason about, same one-liner at both call sites. I'm genuinely tickled you improved on it~ ♪ #### 💡 → ✅ Both little ideas, picked up~ 1. **`Usage ?? 0m` / `IsFreeTier ?? false` now tested.** `A_sparse_key_payload_falls_back_to_zero_usage_and_paid_tier` feeds `{ "data": { "label": null } }` (no `usage`, no `limit`, no `is_free_tier`) and asserts `Usage == 0m`, `Limit == null`, `IsFreeTier == false`. The key path now has the same "sparse metadata degrades safely" care the catalog path always had. Symmetry~ ♡ 2. **`<paramref>` → `<see cref>` in `LlmModel.cs`.** Swapped verbatim. Pure polish, done. #### ✅ What I liked~ - **Coverage tells the truth.** I ran the gateway suite with XPlat coverage on the new head: `OpenRouterLlmGateway` line **100%**, branch **90%** (up from 73.8%). But the important number is the breakdown — `MapFailure<T>` in the display class: branch **1.0**; `ListModelsAsync` state machine: branch **1.0**; `ValidateKeyAsync` state machine: branch **1.0**. Every branch the diff actually touches is fully exercised. The remaining 10% lives in `ToModel` (nullable-chain permutations) and `ClientFor` (the production `handler is null` arm) — both pre-existing, neither in this diff. No new partial branch was introduced. - **Doc comment on `MapFailure` is precise.** "One mapping for every provider failure: an auth status reads as a rejected key, anything else carries the provider's error behind the activity prefix." That's a one-sentence spec for a one-line function. ♪ - **Zero behavioral drift on the happy paths.** Stripped the diff down to just the failure-mapping change and the production behavior is byte-identical — same strings, same `401 or 403` guard, same `$"{prefix}: {error}"` shape. The refactor is load-bearing only for the bug class it kills (two copies drifting apart). Build 0 warnings/0 errors, 242/242 tests pass (was 239 — exactly +3, the new gateway tests; Integration 53→56). All 12 gateway tests green. The Phase 2 foundation is solid — on to cut 2~ fufu. --- *Automated review by Jibril · 2026-07-24* *CI/CD: stale for head a7345ba (coverage bot 3569 covers prior cf9286f only) · Local checks: build 0/0, 242/242 tests pass, gateway branch coverage 90% (100% on all diff-touched methods)*
bjoern merged commit 15f629273e into main 2026-07-24 23:16:17 +02:00
bjoern deleted branch feat/p2-llm-gateway 2026-07-24 23:16:17 +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/Orihon!13
No description provided.