feat(settings): OpenRouter assistant settings — fetched, filterable model pickers #113
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/openrouter-settings"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The first code slice of Phase 4 (AI collaboration), following ADR 0031 (#111). A new Assistant tab in settings, storing what the agent needs before any agent code exists.
Reworked per @bjoern's review — no hand-copied model slugs. Both models are picked from a filterable combobox populated from OpenRouter's live catalog.
What it does
/modelsendpoint (loads even with no key set). Vision-capable models carry a vision badge; the describer offers only vision-capable models (its whole job is to see images) plus a(none)choice. A stored slug still shows even before the catalog resolves it or if a model is delisted.Shape
OpenRouter.Netsubmodule (ADR 0004) + Infrastructure reference; anIOpenRouterGateway(mirroringINovelAiGateway) lists the catalog and derives the vision flag fromModelInfo.Architecture.InputModalities.Combobox<TItem>inKagura.UI, extracted from the proven tag-authoring typeahead: filter, keyboard nav, loading/empty/error states, per-item trailing slot, blur-reverts-to-selection (only list values win). This is the design-system's long-planned Combobox — its row is now ✓.SettingKeysgainsopenrouter.token / .model / .describerModel;GetOpenRouterSettings(key presence + slugs),SaveOpenRouterKey(secret),SaveOpenRouterModel(primary/describer),ListOpenRouterModels.A review-found Razor gotcha is fixed along the way: a string parameter bound as
LoadError="State.X"passes the literal string — it needsLoadError="@State.X".Tests
Full suite 1326 green. New coverage: the
Comboboxcomponent (filter, pointer + keyboard select, blur-revert, loading/error, trailing slot); the gateway's catalog mapping, vision detection, and error-not-throw path (stubbed HTTP); the reworked section (fetched list, vision badges, describer filtering, slug persistence, key encrypted at rest, never journaled).Verification
Browser-verified against the real public OpenRouter catalog: filtering "claude sonnet" returned actual models (including Claude Sonnet 5), each vision-badged, and selecting one saved the exact OpenRouter slug (
~anthropic/claude-sonnet-latest— their real id for that alias). Dev data + screenshots cleaned up.Note: the ADR-0031 build-order note that had deferred the model list is on the #111 branch; I'll tidy it there so the docs stay consistent once both land.
🤖 Generated with Claude Code
Summary
Summary
Coverage
Kagura.BlazorAdapter - 89%
Kagura.Domain - 94.2%
D04ADFED3A21D401C2764A1D17367E35BEB556CBB3B4B0B74__NonSlugChars_0
Kagura.Infrastructure - 98.4%
n
on
Kagura.Kernel - 90%
Kagura.Server - 84.6%
Kagura.UI - 94.8%
Kagura.UseCases - 95.9%
OpenRouter.Net - 16.5%
11780951F121D31931A9902EBCB3C836981FE5704E53C5056__Base64DataUriPattern_0
I disagree with the concept of leaving the model auto suggestion out of the setting. The library should in theory provide and endpoint for loading all models. And for the tags, we already have a proven combobox with filtering by a string. Furthermore, selecting a free form model is bad cause it means the user has to go to openrouter, manually find the model, copy the string and then insert it here. That is undesirable.
To me the obvious choise is to support it (and the blind mode option) just here.
🔮 fufu~ Jibril reviewed your code!
A new settings tab~ The first code slice of Phase 4! Jibril read the full diff, then read the NovelAI sibling section (
NovelAiSettingsSection,NovelAiSettingsState,SaveNovelAiToken,SaveNovelAiModel,GetNovelAiSettings) to compare. Fufu~ you mirrored it so faithfully it made me smile~ ♪Verdict: ✅ Looks good to me~
✅ What I liked~
Line-for-line mirror of the NovelAI flow.
SaveOpenRouterKey↔SaveNovelAiToken(same blank-refusal guard, sameSetSecretAsync, sameTrim()).GetOpenRouterSettings↔GetNovelAiSettings(sameHasSecretAsync+ plain reads, key never rides along).SaveOpenRouterModel↔SaveNovelAiModel(same plainSetAsync+Trim()). The key difference — no gateway model validation, because OpenRouter has hundreds of free-form slugs — is correctly justified. ✅The Fluxor slice follows the NovelAI section's shape precisely:
[FeatureState]record, action records, pure reducers, and an effects class with broadcatch (Exception)dispatchingOpenRouterSettingsFailed. The comment explains why (matching the sibling's shape so a future live-check can't strand a spinner) — good documentation of a deliberate choice. ✅The debounced model-save in
OpenRouterSettingsSection.razoris correct. Each field owns its ownCancellationTokenSource; the closure() => new SaveOpenRouterModelRequested(_model)reads_modelat dispatch time (after the delay), so it always saves the latest value.Dispose()cancels and disposes both debounce timers. Clean. ✅The
_syncedonce-only adoption —if (State.Value.Loaded && _synced == default)adopts the stored slugs exactly once, then local fields drive the inputs. Correct for the debounced-text-field pattern. ✅Comprehensive tests: key encrypted at rest + view reports only presence, blank-key refused, slug round-trip with trim + empty defaults, never journaled (ADR 0014), bUnit section render/save, and the settings-tab list updated. The integration test that asserts the raw DB row doesn't contain the plaintext key is exactly the right security check. Jibril approves of that
Assert.DoesNotContain(Key, raw.Value)~ ♡SettingKeysfollows the established naming convention (openrouter.token/model/describerModel), and theforumicon for the tab is a nice touch. ✅💡 Little ideas (non-blocking)~
SaveOpenRouterModel.SaveAsyncalways returnsOk— unlikeSaveNovelAiModelwhich can fail on unsupported models, the free-form slug save can't fail (the store write is the only operation, and exceptions are caught upstream in the effect). This is correct for the design, just noting theResult<Unit>return type is technically alwaysOkfrom this use case. Not a bug — theResultshape matches the sibling for consistency, and a future validation layer could use it.No bugs, no logic errors, no missing tests. The three settings keys stand alone exactly as the PR body claims — no mechanical dependency on #111. Jibril is satisfied~ ♪
Automated review by Jibril · 2026-07-13
CI/CD: passed for head SHA
529370a(96.0% line coverage, 1314 tests) · Local checks: skipped (CI green)You're right — hand-copying a slug from the OpenRouter site is bad UX, and the deferral was the wrong call. Reworking this PR to do it properly here:
IOpenRouterClient.ListModelsAsync()(the/modelsendpoint is public, so the catalog loads even before a key is set), andModelInfo.Architecture.InputModalitiestells us which models accept image input, so I can badge/filter vision-capable models for the describer.OpenRouter.Netsubmodule (ADR 0004) + anIOpenRouterGateway(mirroringINovelAiGateway) that lists models, and extracting the reusableComboboxcomponent the design-system already anticipates (its flagship listed use is literally "OpenRouter model list with vision badge"), modeled on the proven typeahead inTagAuthoring.Text fields are gone. Pushing the rework to this branch.
feat(settings): OpenRouter (AI assistant) key + model settingsto feat(settings): OpenRouter assistant settings — fetched, filterable model pickers🔮 fufu~ Jibril reviewed your code!
Oh? Oh! The rework is here~! Jibril read every line of the full diff — the Combobox component, the gateway, the state/effects, the Razor section, and all four test files. Then she read the NovelAI sibling (
NovelAiGateway,NovelAiSettingsState,NovelAiSettingsEffects) side by side to compare. Fufu~ you took @bjoern's feedback and ran with it beautifully~ ♪Verdict: ⛔ I can't let this pass just yet~ ♡
⛔ These need fixing before I'm satisfied~
OpenRouterSettingsSectionTests.cs/OpenRouterSettingsEffects— the describer model save flow through the UI is completely untested.Coverage confirms it:
SaveOpenRouterDescriberModelRequested= 0%,OpenRouterSettingsEffects= 50% line / 25% branch. The sibling testChoosing_a_model_saves_its_slugopens the primary picker, clicks Claude, and asserts the slug persisted — ✅. But no test does the same for the describer picker. The testThe_describer_picker_offers_only_vision_models_plus_noneopens the describer dropdown and checks the filtering, but never clicks anything — so theSelectedChanged→SaveOpenRouterDescriberModelRequested(m.Id)→OnSaveDescriberAsync→ExecuteDescriberAsyncchain is never exercised through the UI. The integration test covers the use case directly, but the Fluxor binding (dispatch → effect → result handling) for the describer is dark.Fufu~ you wouldn't leave a brand-new dispatch path untested, would you? A typo in the
SelectedChangedlambda, a wrong action type, a swapped key — nothing would catch it~ ♡Fix: Add a test mirroring
Choosing_a_model_saves_its_slugfor the describer:And ideally one for selecting
(none)to verify it clears the setting (saves"").✅ What I liked~
The
Combobox<TItem>is a delight. Filter, keyboard nav (ArrowUp/Down/Enter/Escape), blur-revert-to-selection, loading/empty/error states, per-item trailing slot for the vision badge — it's the full design-system component, cleanly extracted. TheOnParametersSetmirror-when-closed + typed-query-when-open split is exactly right. The CSS floating menu withz-index: 20is correct. Jibril is genuinely impressed~ ♪The gateway is a faithful mirror.
OpenRouterGatewayfollowsNovelAiGateway's build-per-call pattern (avoiding the library's key-validating DI path), uses the sameIHttpClientFactory+ named client shape, mapsResult<T>with the same switch, and catches broadly to returnResult.Failinstead of throwing. Theusing Result = ...alias to disambiguate from the library'sResult<T>matches the sibling'susingalias trick. The vision detection fromModelInfo.Architecture?.InputModalities?.Contains(Modality.Image)is correct and well-documented. ✅The
(none)sentinel for the describer is elegant.private static readonly OpenRouterModelOption None = new("", "(none)", false)prepended toDescriberItems— selecting it dispatchesSaveOpenRouterDescriberModelRequested("")which trims to empty and clears the setting. TheDescribercomputed property correctly falls throughResolve ?? Placeholder ?? Noneso the box defaults to(none)when nothing is set. Clean. ✅The delisted-model placeholder is thoughtful.
Placeholder(slug)creates a stand-in carrying the raw slug so the box shows it even before the catalog loads or if the model was delisted. The testA_stored_slug_shows_even_before_the_catalog_resolves_itcovers this. ✅The Razor
@prefix fix.LoadError="@State.Value.ModelsError"— correct! The PR description calls out the found gotcha whereLoadError="State.Value.ModelsError"(no@) passes the literal string. Both comboboxes use the@prefix correctly. ✅The Fluxor slice mirrors NovelAI precisely. Same
[FeatureState]record shape, same optimistic reducers, same broad-catch effects. The separation ofModelsError(shown in-picker) fromError(shown as section banner) is correct — a catalog failure doesn't strand the key field. ✅Automated review by Jibril · 2026-07-13
CI/CD: coverage comment present for head SHA
4ef628d(92.8% overall, 1326 tests) · Local checks: skipped (CI green)Good catch — the describer's dispatch→effect→save chain was genuinely dark (only its filtering was tested, never a selection). Fixed in
fd35259:Choosing_a_describer_model_saves_its_slug— opens the describer picker, clicks GPT-4o mini, assertsopenrouter.describerModelpersisted.Choosing_none_clears_the_describer— starts with a describer set (so(none)is a real change, not a no-op the!IsSelectedguard would swallow), clicks(none), asserts the setting saved as"".Both exercise
SaveOpenRouterDescriberModelRequested → OnSaveDescriberAsync → ExecuteDescriberAsyncend to end. Full suite green.