Skip to content

AI: status fetch, subscriber gate and daily-quota surfacing - #173

Merged
Adron merged 2 commits into
mainfrom
issue-10-ai-gating
Sep 24, 2026
Merged

Adron merged 2 commits into
mainfrom
issue-10-ai-gating

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Stack

Third of four stacked PRs on the AI epic. Each is based on the one above it, not on main:

PR Branch Base
#137 (open) issue-9-ai-service main
this issue-10-ai-gating issue-9-ai-service
next issue-14-powered-templates issue-10-ai-gating
next issue-15-powered-document issue-14-powered-templates

Review/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 — fetches GET /api/ai/status once per session, single-flighted so several AI panels constructed in the same frame share one request, and exposes the one IsAiAvailable gate: subscriber && providers.Count > 0.

  • Unknown is false. A status fetch that fails leaves AI hidden rather than guessing "yes" — guessing wrong means showing a subscriber-only control to a free account, which AI: status, subscriber gating and daily-quota surfacing #10 forbids outright.
  • providers: [] → hidden (a call would answer 409 no_provider_configured; there is nothing a user could do about it).
  • The cache is per-session, not per-process: it resets on SessionService.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 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 via IsQuotaExhausted/QuotaLabel and 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 from AiQuota.RemainingOrComputed, never .Remaining — per #9's correction and re-verified here, /status sends remaining and /suggest does 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_exceeded and rate_limited get distinct amber treatments with their own short labels (DAILY LIMIT / SLOW DOWN) instead of collapsing into one red error, and rate_limited honours Retry-After in its copy. Nothing in the path is a MessageBox, Popup or adorner — the control is a bound Border and two TextBlocks, so it structurally cannot block the dispatcher.

AiNotice also carries a Spent flag, because this API's quota accounting is asymmetric: invalid_ai_output/refused/provider_error reached the model and cost a unit, so the copy tells the user to re-word rather than re-press.

ViewModels/AiPanelViewModelBase.cs — RunAiAsync is the single funnel all /api/ai/* traffic goes through. That's what makes three easy-to-forget rules structural:

  1. It never retries. A failed call that reached the model already spent one of the 50, so an auto-retry silently doubles the bill.
  2. It refuses to start when the gate is closed, so a stale binding can't fire a call that would answer 403/409.
  3. It reconciles the chip: folds the echoed quota in on success, and calls MarkQuotaExhausted() on a 429 quota_exceeded — so the chip never disagrees with the message next to it. A 401 resets the gate.

It catches AiApiException (not the sealed InterlinedApiException being rewritten in #130), plus HttpRequestException and a catch-all, so no AI failure can escape into async void.

/suggest calls made and what came back

None on this PR — #10 needs no artifact. Only free GET /api/ai/status and the zero-cost 422 probes 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:

An unrecognized context.mode is not cheap-rejected. {"mode":"not_a_mode"} on powered_document falls through to the model and comes back 422 invalid_ai_output — billed (usedToday 2 → 3).

Every real validation failure was free (usedToday unchanged across four), with server prose specific enough to surface verbatim rather than replace with generic copy:

probe response cost
from_list + non-owned listId 422 {"error":"List not found.","code":"invalid_input"} 0
from_list + no listId 422 {"error":"A list must be selected.","code":"invalid_input"} 0
research_url + ftp:// 422 {"error":"Only http(s) URLs are supported.","code":"invalid_input"} 0
from_article + non-owned documentId 422 {"error":"Document not found.","code":"invalid_input"} 0
mode: "not_a_mode" 422 {"error":"Model did not return valid JSON.","code":"invalid_ai_output"} 1

GET /api/ai/status re-verified byte-for-byte against what #137 recorded, including that defaultModels still carries dormant openai/gemini entries (no provider picker is built — AI is Anthropic-only app-wide).

Left unverified

  • subscriber: false and providers: [] 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.
  • 429 was never triggered — neither quota_exceeded (would need 45 more units) nor rate_limited (would need 15 calls in 60s, ~15 units). The Retry-After parsing is AI: add InterlinedApiClient.Ai.cs service + artifact models #137's, untouched here.
  • POST /api/ai/generate was 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.
  • No AI control is hosted on this branch, because nothing on it offers an AI action yet. The first consumers are the next two PRs in the stack, both of which gate every affordance on IsAiAvailable and drop in AiQuotaChip + AiNoticeBar.

Builds clean in both -c Debug and -c Release.

Closes #10

🤖 Generated with Claude Code

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>
@Adron
Adron merged commit 8272e6e into main Sep 24, 2026
Adron added a commit that referenced this pull request Sep 24, 2026
…erter

fix(build): duplicate InverseBoolToVisibilityConverter after #161 + #173
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AI: status, subscriber gating and daily-quota surfacing

1 participant