You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
AI: status, subscriber gating and daily-quota surfacing #10
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.
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.
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.
Activity
Adron commentedon Sep 16, 2026
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.
quotais not the same object on both endpoints.GET /api/ai/status→{usedToday, dailyLimit, remaining}.POST /api/ai/suggest→{usedToday, dailyLimit}with noremaining(verified twice). SoAiQuota.Remainingisint?, and a post-suggest quota chip cannot read.Remaining— bindRemainingOrComputedinstead. #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
providerfield on/generateis audit-ledger-only and does not select anything, and theopenai/geminientries indefaultModelsare dormant and unreachable. Don't surface a provider choice.3.
crossPosthas six fields, not two —crossPostToBluesky,selectedMastodonIds,crossPostToLinkedIn,selectedLinkedInTargets,crossPostToTwitter,linkedInLinkAsFirstComment. BothcrossPostandscheduleImmediatelyaremessage_series-only (relevant to #12).4. A sixth
422code exists that the epic didn't list:refused. Treat it likeinvalid_ai_output. Modelled and marked retryable.5. Quota accounting is asymmetric, and it shapes the UI.
Input-validation
422s cost nothing (usedTodaystayed 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 missingpowered_documentsource ref (#15), and the non-persistable-artifact rule (#11) before calling. And no auto-retry — a retry burns another unit.Also:
context.spacingMinutesis accepted but ignored (series rows always land 4 minutes apart), so it's deliberately not exposed.Note on the exception type
The
codeis surfaced on a newAiApiException, not onInterlinedApiException— the latter issealedand was being rewritten in #130. Folding them together is flagged as a follow-up in the code. CatchAiApiExceptionin these UI issues.Unverified
POST /api/ai/generatewas never called (it persists content to a shared test account). Its envelope is contract-transcribed, flagged unverified in doc comments, and keeps the rawcreatedelement for read-after-write. Whichever of #12–#15 lands first should verify it against a real account and tighten the model.Adron commentedon Sep 16, 2026
Implemented in #173 — work continues in the PR from here.
The shared
AiAvailabilityServicefetchesGET /api/ai/statusonce 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 oneIsAiAvailablegate — 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.AiQuotaChipis the "45 of 50 left today" surface as a one-tag drop-in bound toRemainingOrComputed, andAiNotice/AiNoticeBarkeepquota_exceededandrate_limitedvisually 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.modeis not cheap-rejected — it reaches the model and bills a unit asinvalid_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 oneRunAiAsyncthat never auto-retries.