Skip to content

AI: status, subscriber gating and daily-quota surfacing #10

Description

@Adron

AI controls must hide themselves rather than fail, matching the web app's documented behaviour.

Rules from the product docs

  • providers: [] → the site has no ANTHROPIC_API_KEY; hide all AI controls (calls would 409 no_provider_configured).
  • subscriber: false → free account; do not show AI controls at all.
  • Quota is 50 actions/day per account; a preview and its confirmed action each count.
  • 429 quota_exceeded → tell the user to retry tomorrow; 429 rate_limited → honor Retry-After.

Acceptance criteria

  • GET /api/ai/status is fetched once per session (and on demand) and cached on a shared service.
  • A single IsAiAvailable gate (subscriber AND providers non-empty) drives visibility of every AI affordance.
  • Remaining quota is visible wherever an AI action is offered (e.g. 42 of 50 left today).
  • quota_exceeded and rate_limited render as distinct, non-blocking in-place messages — not a generic error toast, and never a modal that blocks the dispatcher.
  • No AI control is ever shown to a free account.

Activity

  1. added
    parityWeb/API feature-parity work
    P0Must have for a credible parity claim
    on Sep 15, 2026
  2. Adron commented on Sep 16, 2026

    @Adron
    MemberAuthor

    Live-verified corrections from the service layer (#9 / PR #137) — read before building this

    Five things contradict what the AI epic and these issues assume.

    1. quota is not the same object on both endpoints.
    GET /api/ai/status → {usedToday, dailyLimit, remaining}. POST /api/ai/suggest → {usedToday, dailyLimit} with no remaining (verified twice). So AiQuota.Remaining is int?, and a post-suggest quota chip cannot read .Remaining — bind RemainingOrComputed instead. #10's "remaining quota is visible wherever an AI action is offered" has to account for that.

    2. There is no provider picker to build.
    AI is Anthropic-only, app-wide; per-user provider keys were removed server-side 2026-09-05. The provider field on /generate is audit-ledger-only and does not select anything, and the openai/gemini entries in defaultModels are dormant and unreachable. Don't surface a provider choice.

    3. crossPost has six fields, not two — crossPostToBluesky, selectedMastodonIds, crossPostToLinkedIn, selectedLinkedInTargets, crossPostToTwitter, linkedInLinkAsFirstComment. Both crossPost and scheduleImmediately are message_series-only (relevant to #12).

    4. A sixth 422 code exists that the epic didn't list: refused. Treat it like invalid_ai_output. Modelled and marked retryable.

    5. Quota accounting is asymmetric, and it shapes the UI.
    Input-validation 422s cost nothing (usedToday stayed 0 across two), but any call that reaches the model costs a unit — including failures. So client-side pre-flight is load-bearing, not politeness: enforce the word caps, the 10-word series gate (#12/#13), a missing powered_document source ref (#15), and the non-persistable-artifact rule (#11) before calling. And no auto-retry — a retry burns another unit.

    Also: context.spacingMinutes is accepted but ignored (series rows always land 4 minutes apart), so it's deliberately not exposed.

    Note on the exception type

    The code is surfaced on a new AiApiException, not on InterlinedApiException — the latter is sealed and was being rewritten in #130. Folding them together is flagged as a follow-up in the code. Catch AiApiException in these UI issues.

    Unverified

    POST /api/ai/generate was never called (it persists content to a shared test account). Its envelope is contract-transcribed, flagged unverified in doc comments, and keeps the raw created element for read-after-write. Whichever of #12–#15 lands first should verify it against a real account and tighten the model.

  3. Adron commented on Sep 16, 2026

    @Adron
    MemberAuthor

    Implemented in #173 — work continues in the PR from here.

    The shared AiAvailabilityService fetches GET /api/ai/status once per session (single-flighted, reset on account change with a generation guard so a stale response can't reopen the gate for a signed-out user) and exposes the one IsAiAvailable gate — subscriber AND providers non-empty, with unknown counting as false so a failed status fetch hides AI rather than guessing. The gate deliberately excludes quota: a subscriber who has spent today's 50 keeps the controls and gets an in-place amber notice, since hiding them would read as a bug. AiQuotaChip is the "45 of 50 left today" surface as a one-tag drop-in bound to RemainingOrComputed, and AiNotice/AiNoticeBar keep quota_exceeded and rate_limited visually distinct from each other and from a generic red failure, with no dialog anywhere in the path.

    One live finding is worth carrying forward: an unrecognized context.mode is not cheap-rejected — it reaches the model and bills a unit as invalid_ai_output — while every genuine input-validation 422 costs nothing. Client-side pre-flight therefore has to be exhaustive rather than best-effort, which is why all AI traffic funnels through one RunAiAsync that never auto-retries.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P0Must have for a credible parity claimarea:aiArea: aiparityWeb/API feature-parity work

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions