AI: status fetch, subscriber gate and daily-quota surfacing - #173
Merged
Merged
Conversation
Adds the shared plumbing every AI affordance in the app hangs off, so the gating rule lives in one place instead of being re-derived per feature. `AiAvailabilityService` fetches GET /api/ai/status once per session (single-flighted, so panels constructed in the same frame share one request), caches it, and exposes the one `IsAiAvailable` gate: subscriber AND providers non-empty. Unknown is false — a status fetch that fails leaves AI hidden rather than guessing "yes" and showing a subscriber-only control to a free account. The cache is per-session, not per-process: it resets on CurrentUser change, and a generation counter makes an in-flight GET discard its own result so a response for the previous account can't repopulate the cache after a sign-out. The gate deliberately excludes quota, unlike AiStatus.CanUseAi. A subscriber who has spent today's 50 should still see the controls with an in-place "out until tomorrow" line, not watch them disappear — so exhaustion is surfaced through IsQuotaExhausted/QuotaLabel instead of hiding anything. `AiQuotaChip` is the "45 of 50 left today" line #10 asks for wherever an AI action is offered — one XAML tag, no attributes, no host code-behind, and it collapses itself when the gate is closed. It binds a label derived from `AiQuota.RemainingOrComputed`, never `.Remaining`: /api/ai/status sends `remaining` but /api/ai/suggest does not (re-verified live), so after a suggestion the raw field is null and only the computed value is correct. `AiNotice` + `AiNoticeBar` render failures in place. quota_exceeded and rate_limited get distinct amber treatments with their own labels rather than collapsing into one red error, and nothing in the path is a MessageBox, Popup or adorner — the messages are a bound Border and two TextBlocks, so they structurally cannot block the dispatcher. `AiPanelViewModelBase.RunAiAsync` is the single funnel all /api/ai/* traffic goes through, which is what makes three rules structural rather than aspirational: it never retries (a failed call that reached the model already spent a unit, so an auto-retry silently doubles the bill), it refuses to start when the gate is closed (a stale binding can't spend anything), and it folds the echoed quota back into the service on success while marking the allowance gone on a 429 — so the chip never disagrees with the message beside it. Live re-verification (test account, deviceLabel issue-10-15-probe): GET /api/ai/status returns {"subscriber":true,"providers":["anthropic"], "defaultModels":{anthropic,openai,gemini},"quota":{usedToday,dailyLimit, remaining}} exactly as #9 recorded. Four input-validation 422s cost zero quota (usedToday unchanged across all four) with precise server prose worth surfacing verbatim — "List not found.", "A list must be selected.", "Only http(s) URLs are supported.", "Document not found." One probe cost a unit and is worth recording: an unrecognized context.mode is NOT cheap-rejected — it falls through to the model and comes back 422 invalid_ai_output, billed. The four-value enum already prevents that, but it confirms pre-flight has to be exhaustive, not best-effort. No AI control is hosted on this branch — nothing on it offers an AI action yet. The first consumers are the next two PRs in the stack (#14 Powered Templates, #15 Powered Document), both of which route every affordance through `IsAiAvailable` and drop in `AiQuotaChip` + `AiNoticeBar`. Closes #10 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stack
Third of four stacked PRs on the AI epic. Each is based on the one above it, not on
main:issue-9-ai-servicemainissue-10-ai-gatingissue-9-ai-serviceissue-14-powered-templatesissue-10-ai-gatingissue-15-powered-documentissue-14-powered-templatesReview/merge in that order. This PR's diff is 8 new files and zero modified files — it touches nothing that exists.
What this adds
Services/AiAvailabilityService.cs— fetchesGET /api/ai/statusonce per session, single-flighted so several AI panels constructed in the same frame share one request, and exposes the oneIsAiAvailablegate:subscriber && providers.Count > 0.providers: []→ hidden (a call would answer409 no_provider_configured; there is nothing a user could do about it).SessionService.CurrentUserchange, and a generation counter makes an in-flight GET discard its own result, so a response for the previous account can't repopulate the cache after a sign-out.The gate deliberately excludes quota, unlike the existing
AiStatus.CanUseAi. A subscriber who has spent today's 50 should still see the AI controls with an in-place "out until tomorrow" line — not watch them vanish. So exhaustion is surfaced viaIsQuotaExhausted/QuotaLabeland hides nothing.Views/AiQuotaChip.xaml— the "45 of 50 left today" line, as one XAML tag with no attributes and no host code-behind. It collapses itself when the gate is closed, so a host never needs its own visibility rule for it. It binds a label derived fromAiQuota.RemainingOrComputed, never.Remaining— per #9's correction and re-verified here,/statussendsremainingand/suggestdoes not, so after a suggestion the raw field is null and only the computed value is right.ViewModels/AiNotice.cs+Views/AiNoticeBar.xaml— failures rendered in place.quota_exceededandrate_limitedget distinct amber treatments with their own short labels (DAILY LIMIT/SLOW DOWN) instead of collapsing into one red error, andrate_limitedhonoursRetry-Afterin its copy. Nothing in the path is aMessageBox,Popupor adorner — the control is a boundBorderand twoTextBlocks, so it structurally cannot block the dispatcher.AiNoticealso carries aSpentflag, because this API's quota accounting is asymmetric:invalid_ai_output/refused/provider_errorreached the model and cost a unit, so the copy tells the user to re-word rather than re-press.ViewModels/AiPanelViewModelBase.cs—RunAiAsyncis the single funnel all/api/ai/*traffic goes through. That's what makes three easy-to-forget rules structural:403/409.MarkQuotaExhausted()on a429 quota_exceeded— so the chip never disagrees with the message next to it. A401resets the gate.It catches
AiApiException(not the sealedInterlinedApiExceptionbeing rewritten in #130), plusHttpRequestExceptionand a catch-all, so no AI failure can escape intoasync void./suggestcalls made and what came backNone on this PR — #10 needs no artifact. Only free
GET /api/ai/statusand the zero-cost422probes below. (The two paid artifact-shape probes are in #14 and #15.)Quota units spent by this PR: 1, and it's worth recording why, because it contradicts an assumption:
Every real validation failure was free (
usedTodayunchanged across four), with server prose specific enough to surface verbatim rather than replace with generic copy:from_list+ non-ownedlistId422 {"error":"List not found.","code":"invalid_input"}from_list+ nolistId422 {"error":"A list must be selected.","code":"invalid_input"}research_url+ftp://422 {"error":"Only http(s) URLs are supported.","code":"invalid_input"}from_article+ non-owneddocumentId422 {"error":"Document not found.","code":"invalid_input"}mode: "not_a_mode"422 {"error":"Model did not return valid JSON.","code":"invalid_ai_output"}GET /api/ai/statusre-verified byte-for-byte against what #137 recorded, including thatdefaultModelsstill carries dormantopenai/geminientries (no provider picker is built — AI is Anthropic-only app-wide).Left unverified
subscriber: falseandproviders: []were never observed. The test account is a subscriber on a server with the key configured, so both hide-everything paths are reasoned from the contract, not seen. They're the default-false branches of one boolean, which is the cheapest possible thing to get right, but they are not live-proven.429was never triggered — neitherquota_exceeded(would need 45 more units) norrate_limited(would need 15 calls in 60s, ~15 units). TheRetry-Afterparsing is AI: add InterlinedApiClient.Ai.cs service + artifact models #137's, untouched here.POST /api/ai/generatewas not called, per the standing rule — it persists to a shared account. Its envelope is still AI: add InterlinedApiClient.Ai.cs service + artifact models #137's contract transcription.IsAiAvailableand drop inAiQuotaChip+AiNoticeBar.Builds clean in both
-c Debugand-c Release.Closes #10
🤖 Generated with Claude Code