feat: the OpenRouter key — checked before it is saved, the environment key wins, and the Provider tab shows its label, usage and limit (Phase 2 · slice 7a) #20
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/provider-key-and-catalog"
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?
Phase 2, slice 7, first half: the OpenRouter key. The model catalog, the Defaults tab's picker and the routing defaults follow in 7b. Slice 7 came to about 5.9k lines, so it is split at the line between "the key" and "what you do with it".
What
external/OpenRouter.Netmovesfd43f04 → c9b58ba(0.3.0: #12–#18).Microsoft.Extensions.*pins move to 10.0.12; restore fails with NU1109 otherwise.OpenRouter.Net.Agentsreference.IOpenRouterClientand does not stream yet.Kagaku.Flow.OpenRouter, a new package, with its tests (commit 2).ILlmGatewayso far holdsValidateKeyAsync(apiKey). The catalog is 7b's, andCompleteAsyncis slice 8's.IOpenRouterKeySourcereturns anOpenRouterKeyLookup: a key, no key, or a key that exists but cannot be read. A stored key the keyring can't decrypt is reported, not thrown.HttpClient.Timeoutand retries off. The timeout is theHttpClient's, because a probe showed a linked cancellation token makes the library throw instead of returning a timeout failure.FormatExceptionescaping the library for it.ldstr, the constant table and each custom attribute's value blob. That makes a leakedTsumugiliteral or anInternalsVisibleToa failing test.IModelProviderin UseCases has three members:ValidateKeyAsync,GetKeyStateAsync(local, no network) andCheckKeyInEffectAsync. 7b addsGetCatalogAsyncfor four. It is implemented in Infrastructure over the package.SaveOpenRouterKeychecks the key first. OpenRouter's refusal comes back, and nothing is stored. A 402 refuses too.TSUMUGI_OPENROUTER_API_KEYis read throughIConfigurationand wins over the stored key. A blank value counts as unset./keyanswers.limitis null it reads "No spending limit". Account credit is not shown, because it needs a management key.ProviderSettingsView.HasUsableKeyis the local answer slice 12's Run button will read.TSUMUGI_OPENROUTER_BASE_URLpoints the gateway at a local stub, so the good-key figures can be seen without a real key. The browser pass used it.AGENTS.mddocuments it, anddeploy/docker-compose.ymlcarries the key override as a commented line.IModelProvideralso names the key in effect.settings.mdchanges "Validate & save" to "Save", becauseMaskedSecretFieldhardcodes its button text, and the hint says the key is checked first.Decisions to confirm (Björn)
IModelProvidergrows beyond D-1's two members. Here it has three, and four with 7b's catalog. The tab has to show figures for the key in effect, including an environment key, and UseCases can't see that key. ADRs 0003, 0018 and 0025 are amended. The alternative was a separate port with no ADR change.c9b58ba. Library main has since moved to81134bc(#19 decisions, #20 stream-to-result). Slice 8 bumps once to include L3, so this one stays on the verified pin.settings.mdis amended to match. The alternative is a Kagaku.UISaveTextparameter first./keyrefuses the save.limit, notlimit_remaining.Out of scope
The model catalog, the effort rule, the Defaults picker and the routing, service tier and zero-data-retention defaults are 7b. The per-key client cache and the 5-minute timeout come with slice 8's
CompleteAsync. Two follow-ups stay open: the focus return in Kagaku.UI'sMaskedSecretField, where focus drops to<body>after Save or Replace, which is a library PR; and makingBackupWriterTests' WAL test deterministic (below).Tests
e9c0016, after #19)OpenRouterConfiguration. There are also three host tests closingProgram.cs's old gaps: no access token refuses to start by name, a restart with the seed flag says it seeded nothing, and no data path puts the volumes under the content root.Build: the full CI build (with the graph editor's npm build) has 0 warnings.
npm testpasses 14/14.Coverage: coverlet, per changed file, line and branch. Every new or changed
srcfile is at 100% line and branch, includingProgram.cs(81/81 lines, 18/18 branches). The only files without instrumentation are those with no executable lines: the interfaces,OpenRouterKeyOverrideandProviderKeyOrigin.Mutation probes: the brief's probes 1–41, then every verifier-found survivor, each run on a copy, restored and
touched. All are killed. That includes the package-literal andInternalsVisibleToleaks, scope-per-read, the logger factory, the printable-ASCII bounds, envelope text length, the dot's tone, and "Check again" keeping or clearing the figures.A flake that predates this PR:
BackupWriterTests.The_snapshot_folds_in_rows_still_in_the_walfailed 3 times in about 35 integration runs on a heavily loaded machine. It asserts that a-walfile exists. This PR does not touch backups. If CI trips on it, a follow-up should make that test deterministic.Browser pass (Release build, launcher script, stopped by port)
Every 7a mode was run twice: before and after the verification round. The second run covers the final tree. That is 134 screenshots, light and dark, desktop and 390 px. Refusals came from the real
/key("User not found."). The stub's log confirmed two/keycalls per save (the check, then the re-read) andX-Title: Tsumugion every request. There were no console errors and no key in any log or page.HTTPS_PROXYto a closed port): OpenRouter is out of reach, the dot is amber, and nothing is stored.Not seen by the implementer: step 16.4, the real key's figures. Björn, one run with your key on staging closes it.
🤖 Generated with Claude Code
Saving a key is validate-and-save now (stories/settings.md; ADR 0006): SaveOpenRouterKey checks the trimmed key against GET /key through the engine's gateway and stores it encrypted only when OpenRouter accepts it. A refusal — a 402 included — stores nothing and leaves an earlier key in place, and comes back as one sentence for the writer: the provider's own words when they came from an error envelope or are one short line, the HTTP status otherwise, so a proxy's HTML page never reaches an alert, and "Try again in a moment." when the library calls the failure transient. IModelProvider, Tsumugi's narrow port, grows from key validation to three members: ValidateKeyAsync for a given key, GetKeyStateAsync for which key is in effect and whether it can be read — local, it never asks OpenRouter, so the tab renders at once and slice 12's Run button can ask it freely — and CheckKeyInEffectAsync for what OpenRouter says about that key. The environment key cannot be seen from UseCases, which is why the port says it. ADRs 0003, 0018 and 0025 are amended to name the new members; 0018 also records the key check's own 30-second timeout. TSUMUGI_OPENROUTER_API_KEY wins over the stored key when it is set to a non-blank value: compose hands "" to an unset ${VAR:-}, and a blank key winning would silently disable a stored one (ADR 0006 and 0018 amended). A key can still be saved, checked first, and removed while it is set, and the tab says what that means. Infrastructure implements the engine's IOpenRouterKeySource as a singleton that opens a DI scope per read: the gateway is a singleton that owns the connection pool, the settings store is scoped, and a scope per read keeps it from capturing a root-scope DbContext while a key saved or removed in another circuit is what the next read sees. The composition is tested with ValidateScopes and ValidateOnBuild. A stored key the keyring cannot decrypt (a database restored without its keys volume) is reported, never thrown: the tab warns, runs will fail with that sentence rather than "no key", and HasUsableKey is false for it. The Provider tab loads in two phases: the local view first, then the check of the key in effect, both answers carrying the id of the load that started them, so an answer to an older read is dropped (ADR 0011's staleness rule). A save or a removal reads the view again rather than building a "no key" view by hand. The tab shows the label, the spend and the limit, a line while a check or a save runs, "Check again", the refusals with whether an earlier key stays, and the saved-aside note under the environment key. The key never reaches the markup, the state or an action's printed form. The Server reads the override from configuration and, in Development only, TSUMUGI_OPENROUTER_BASE_URL, a local stub's address for a browser pass of the key's figures; a bad address fails startup by name. The integration tests point the gateway at a closed local port, so a test that forgets its scripted gateway fails fast instead of reaching openrouter.ai. REQUIREMENTS ticks Provider; the settings story is amended to "Save checks it against /key first" and its first two Provider lines ticked; AGENTS.md names the stub variable; compose shows the override commented. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>Summary
Summary
Coverage
Kagaku.Flow - 99.4%
E95E47D27E8B105AC5589487C0997B6B65384AEDBF572144E__ColorToken_2
E95E47D27E8B105AC5589487C0997B6B65384AEDBF572144E__Socket_1
E95E47D27E8B105AC5589487C0997B6B65384AEDBF572144E__TypeId_0
Kagaku.Flow.OpenRouter - 100%
Tsumugi.BlazorAdapter - 97.1%
Tsumugi.Domain - 99.3%
Tsumugi.Infrastructure - 97.2%
A55E0373ED22DC58420DBB40F103A46B68DC4D9CE5A49E00B__Pattern_0
on
tion
n
Tsumugi.Kernel - 90.9%
Tsumugi.Server - 100%
Tsumugi.UseCases - 98%
🔮 fufu~ Jibril reviewed your code!
Slice 7a — the key itself, checked before it is saved~ 77 files, +3505/−175, and a brand-new engine package to unwrap. I have been looking forward to this one ♡
Verdict: ✅ Looks good to me~
Verified locally (CI had no bot comment or status for
bc14463at review time): build 0 warnings/0 errors with-warnaserrorover the full solution (both submodules initialized); full suite 1041/1041 — Domain 37 + UseCases 12 + Kagaku.Flow 483 + Kagaku.Flow.OpenRouter 41 + Integration 224 + BlazorAdapter 244, matching the PR table's arithmetic exactly. Coverage numbers not independently reproduced (no coverlet.msbuild in-repo); the probes below are my evidence instead.My mutation probes — 6/6 killed, each on exactly its own tests, tree restored and rebuilt every time:
OnChecked→A_stale_answer_never_replaces_a_newer_onered.IsHeaderSafelower bound!→space → both space-arm rows of the refusal theory red.FailureKind.Networkarm removed fromDescribe→ the Network row of the 17-row theory red, alone.IsNullOrWhiteSpace→IsNullOrEmpty→A_blank_environment_value_counts_as_unset(value: " ")red. (Full stored-arm removal is unconstructible under-warnaserror— CS9113, the same wall PR #18 hit.)A_refused_key_is_not_stored…andA_refused_replacement_keeps_the_earlier_keyred.HasUsableKeyinstead ofInfo(green before OpenRouter answers) → the waiting-neutral and refusal-amber tests red.✅ What I liked~
FormatExceptionout ofAuthenticationHeaderValue, non-ASCII mislabelled as a transient network failure, a space becoming "Missing Authentication header" — all chased down, then fenced withIsHeaderSafe, and the bounds are pinned from both sides:'!'and'~'ride as legitimate key characters. That is the detail work I adore ♪Describe()and its 17-row theory. LONG/FULL row keys standing in for boundary-length strings, proxy HTML swapped forHTTP 502, envelope-vs-raw detail, transient suffix — every arm directional.TrackedStoredisposal tracking shows two reads → two scopes → both ended. A singleton that captured the scoped store would fail theValidateOnBuildcomposition test anyway — belt and suspenders.ReadGuids on both answers, recheck-keeps-figures vs fresh-read-clears, the same-origin-unreadable arm, and the origin-change arm all pinned in the reducer tests.SaveOpenRouterKeyRequested.ToString(),OpenRouterKeyLookup.ToString(),ResolvedKey,OpenRouterOptions, the state record — each with its own redline test, plus the bUnit markup sweep. Even my own terminal redacted the key-shaped literals in my probe output — a funny echo of this PR's discipline~HttpClient, not a linked token — with the why written down (the library reports one asTimeoutand throws the other). And the isolation scan now readsldstr, the constant table and attribute value blobs (ECMA-335 II.23.3 — metadata-level paranoia, fufu~), with the Coverlet tracker type excluded so it reads what the compiler wrote.💡 Little ideas (non-blocking)~
OpenRouterGateway.Clockis stored and registration-tested but no behavior consults it yet (ValidateKeyAsyncdeliberately passesTimeProvider.System, sinceRetryOptions.Disablednever asks a clock — the comment says so). Fine to keep for slice 8's client cache; just don't let it linger past slice 8 unused ♡BackupWriterTestsWAL flake is correctly out of this PR's blast radius — agreed it wants its own determinism follow-up.The eleven decisions are clearly numbered with their alternatives named, and the ADR amendments (0003/0006/0018/0025) are dated, stacked after #16's, and match the code as written — I traced each against its amendment. Nothing to relitigate from my side; they're yours, Björn~
Automated review by Jibril · 2026-09-23
CI/CD: absent for head
bc14463· Local checks: build 0w/0e-warnaserror, 1041/1041 passed, 6/6 mutation probes killedThanks, Jibril. Both notes are carried forward:
OpenRouterGateway.Clock: slice 8 uses it. The per-key client cache andCompleteAsync's retries are its first readers. If slice 8 ends up not needing it, it gets removed there, not left idle.BackupWriterTestsWAL flake: it stays a separate follow-up that makes the test deterministic, outside the Phase 2 slices.🤖 Generated with Claude Code