fix(summarization): entitlement-gate the summarize provider chain — stop the anon 401 flood + 3x fan-out (#4913)#4915
Conversation
…top the anon 401 flood + 3x fan-out (#4913) #4675/#4687 premium-gated summarize-article's LLM spend server-side but the client kept dispatching for every principal: anon dashboards fanned out up to 3 doomed RPCs per attempt (ollama->groq->openrouter through the same gated endpoint) before landing on the browser-T5 fallback they'd get anyway — console/Sentry 401 noise on every anonymous /desktop session. Third instance of the server-gate-without-client-sweep class (#4865 classify-event, #4902 health). Same fix shape as #4869: - src/services/summarize-gate.ts — pure session gate (probe + timed 15-min suppression, fail-open on probe errors so a gating bug never silences Pro summaries). Separate state from classify-gate: a summarize 403 must not silence classification. - tryApiProvider consults the gate before any dispatch (single choke point — covers runApiChain and the beta fire-and-forget call); probe wired to panel-gating's hasPremiumAccess at module init. - Server 403 -> suppress the whole chain for a window (entitlement drift must not recreate the flood at news-refresh rate). 401 does NOT suppress: post-gate it only occurs on the signed-in boot-window race, and benching a Pro user 15min over a transient race is worse. - translateText stays ungated (mode=translate via plain newsClient is the server-allowed anon path). Free users keep summaries (browser T5) — this removes only the wasted requests. Tests: pure-gate suite + source-grep wiring pins (gate before RPC, 403 suppression, single call site, translate ungated); AGENTS.md service count + stats.json regen for the new module. Fixes #4913 Claude-Session: https://claude.ai/code/session_01YKrVoDp8TfEKLYXo83kPFV
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
Greptile SummaryThis PR adds a client-side entitlement gate (
Confidence Score: 5/5Safe to merge — the gate is a pure additive client-side guard that fails open, so no paying user can be silenced by a gating bug; the server remains the authoritative enforcement point. The new gate module is zero-import, fully unit-tested with correct boundary conditions, and deliberately isolated from classify-gate state. The single choke-point placement inside tryApiProvider covers every dispatch path including the BETA fire-and-forget. The 403 suppression is correctly positioned before the rethrow so the circuit breaker still records the failure. The translate path is correctly left ungated. No logic errors or edge-case gaps were found. No files require special attention. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[generateSummaryInternal called] --> B{skipCloudProviders?}
B -- yes --> BT[tryBrowserT5]
B -- no --> C[runApiChain: ollama to groq to openrouter]
C --> D[tryApiProvider for each provider]
D --> E{isFeatureAvailable?}
E -- no --> SKIP[return null]
E -- yes --> F{canAttemptServerSummarization?}
F -- false --> NULL1[return null next provider]
F -- true --> G[premiumNewsClient.summarizeArticle RPC]
G --> H{response OK?}
H -- 403 ApiError --> SUP[suppressServerSummarization 15 min then rethrow]
SUP --> NULL2[tryApiProvider returns null]
H -- other error --> NULL3[return null]
H -- success --> RES[return SummarizationResult]
NULL1 --> NXT{more providers?}
NULL2 --> NXT
NULL3 --> NXT
NXT -- yes --> D
NXT -- no --> BT
RES --> DONE[return API result]
BT --> BT2{T5 available?}
BT2 -- yes --> DONE2[return browser result]
BT2 -- no --> FAIL[trackLLMFailure return null]
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A[generateSummaryInternal called] --> B{skipCloudProviders?}
B -- yes --> BT[tryBrowserT5]
B -- no --> C[runApiChain: ollama to groq to openrouter]
C --> D[tryApiProvider for each provider]
D --> E{isFeatureAvailable?}
E -- no --> SKIP[return null]
E -- yes --> F{canAttemptServerSummarization?}
F -- false --> NULL1[return null next provider]
F -- true --> G[premiumNewsClient.summarizeArticle RPC]
G --> H{response OK?}
H -- 403 ApiError --> SUP[suppressServerSummarization 15 min then rethrow]
SUP --> NULL2[tryApiProvider returns null]
H -- other error --> NULL3[return null]
H -- success --> RES[return SummarizationResult]
NULL1 --> NXT{more providers?}
NULL2 --> NXT
NULL3 --> NXT
NXT -- yes --> D
NXT -- no --> BT
RES --> DONE[return API result]
BT --> BT2{T5 available?}
BT2 -- yes --> DONE2[return browser result]
BT2 -- no --> FAIL[trackLLMFailure return null]
Reviews (1): Last reviewed commit: "fix(summarization): entitlement-gate the..." | Re-trigger Greptile |
…ulled the news RPC client into the main static graph The instanceof check imported ApiError as a VALUE from the generated news client, turning the previously type-only import into a static edge and failing the eager-chunk budget (rpc-client-news-v1 must stay lazy). Use the existing getRpcErrorStatusCode duck-type helper instead — same one threat-classifier uses for the same reason. Wiring pin updated to the new shape. Verified against a fresh VITE_VARIANT=full build: dashboard-eager-chunks 154/154. Claude-Session: https://claude.ai/code/session_01YKrVoDp8TfEKLYXo83kPFV
…by-design chains (#5377) Both chain-exhausted sites (BETA + normal mode) warned 'All providers failed' even when nothing was attempted — the entitlement gate (#4913/#4915) had declined every server dispatch and browser T5 was unavailable, which is the designed anonymous path. The outage-shaped warning is indistinguishable from a real provider failure in console triage and cost the #5377 investigation two wrong hypotheses. Route both sites through logChainOutcome(): when lastAttemptedProvider is still 'none' (tryApiProvider marks attempts only after the feature + entitlement gates; tryBrowserT5 only after the mlWorker availability check) log at debug with an accurate 'skipped: no eligible provider' message; reserve warn for chains where a provider was genuinely attempted and failed. Note: #5377's part 1 (the enqueue gate — zero anon summarize dispatches) already landed in #4915 via configureSummarizeGate(hasPremiumAccess) and is pinned by tests/summarize-entitlement-gate; this closes the remaining acceptance item. Tests: extended tests/summarize-entitlement-gate.test.mts — the fail sites must route through logChainOutcome (no unconditional string-literal 'All providers failed' warn), debug-before-warn branch on 'none', and attempt marking must stay behind the gates so 'none' means declined-by-design. 21/21 green; RED (1 fail) against the pre-fix source. tsc clean. Co-Authored-By: Claude Fable 5 <[email protected]>
…by-design chains (#5377) (#5605) * fix(summarization): stop logging 'All providers failed' for declined-by-design chains (#5377) Both chain-exhausted sites (BETA + normal mode) warned 'All providers failed' even when nothing was attempted — the entitlement gate (#4913/#4915) had declined every server dispatch and browser T5 was unavailable, which is the designed anonymous path. The outage-shaped warning is indistinguishable from a real provider failure in console triage and cost the #5377 investigation two wrong hypotheses. Route both sites through logChainOutcome(): when lastAttemptedProvider is still 'none' (tryApiProvider marks attempts only after the feature + entitlement gates; tryBrowserT5 only after the mlWorker availability check) log at debug with an accurate 'skipped: no eligible provider' message; reserve warn for chains where a provider was genuinely attempted and failed. Note: #5377's part 1 (the enqueue gate — zero anon summarize dispatches) already landed in #4915 via configureSummarizeGate(hasPremiumAccess) and is pinned by tests/summarize-entitlement-gate; this closes the remaining acceptance item. Tests: extended tests/summarize-entitlement-gate.test.mts — the fail sites must route through logChainOutcome (no unconditional string-literal 'All providers failed' warn), debug-before-warn branch on 'none', and attempt marking must stay behind the gates so 'none' means declined-by-design. 21/21 green; RED (1 fail) against the pre-fix source. tsc clean. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(summarization): isolate provider attempt tracking * docs: update service module count * fix(review): make the #5377 outcome classifier correct and its guards mutation-proof Code review of #5605 found the outcome signal wrong in both directions and all three new regression guards passing with the bug restored (proven by execution). Addresses every finding. Classification (#1, #5, #8) — `lastAttemptedProvider === 'none'` conflated causes: - too quiet: canAttemptServerSummarization() denies both an anon/free principal AND an entitled one inside the 403/429 cooldown, so a paying user's live entitlement outage was demoted to console.debug for up to 15 min (24 h via Retry-After). trackLLMFailure is a no-op and no captureConsoleIntegration exists, so that was the only signal — the #5600 shape. summarize-gate now exports isServerSummarizationSuppressed() and the chain records which denial it hit. - too loud: CircuitBreaker.execute returns its default WITHOUT running the callback while on cooldown (breaker defaults maxFailures=2, cooldown=5min), so a chain that contacted nobody still reported "All providers failed". Marking now happens INSIDE the breaker callback, and a short-circuit is recorded as its own cause. - the quiet path no longer claims "using designed fallback"; reaching it means browser T5 never ran and callers render an error/unavailable state. Guards (#2, #3, #6) — each of these was green with the bug restored: - indexOf matched the gate name inside a COMMENT, so moving the mark above the gate passed. Source assertions now strip comments; more importantly the mark lives inside the breaker callback, making the ordering structural. - the locality assertion ran against the whole file, so hoisting the state to module scope passed. Now scoped to generateSummary with a creation-count pin and a broadened module-global check. - the raw-warn regex only matched quoted literals while this file's idiom is a template literal. Now delimiter-agnostic. - slice anchors are asserted to resolve; a renamed anchor made slice(a, -1) silently widen to end-of-file. The decision surface moved into a pure classifier covered by an executed truth table rather than grep. Verified: all three original bypasses now fail (32/32 green, 31/32 with each mutation applied); typecheck and biome clean. Claude-Session: https://claude.ai/code/session_018XDm48Kzuv1GQe4PR1qimE * chore(docs): regenerate stats after rebase onto main Claude-Session: https://claude.ai/code/session_018XDm48Kzuv1GQe4PR1qimE --------- Co-authored-by: Claude <[email protected]> Co-authored-by: Elie Habib <[email protected]>
…by-design chains (koala73#5377) (koala73#5605) * fix(summarization): stop logging 'All providers failed' for declined-by-design chains (koala73#5377) Both chain-exhausted sites (BETA + normal mode) warned 'All providers failed' even when nothing was attempted — the entitlement gate (koala73#4913/koala73#4915) had declined every server dispatch and browser T5 was unavailable, which is the designed anonymous path. The outage-shaped warning is indistinguishable from a real provider failure in console triage and cost the koala73#5377 investigation two wrong hypotheses. Route both sites through logChainOutcome(): when lastAttemptedProvider is still 'none' (tryApiProvider marks attempts only after the feature + entitlement gates; tryBrowserT5 only after the mlWorker availability check) log at debug with an accurate 'skipped: no eligible provider' message; reserve warn for chains where a provider was genuinely attempted and failed. Note: koala73#5377's part 1 (the enqueue gate — zero anon summarize dispatches) already landed in koala73#4915 via configureSummarizeGate(hasPremiumAccess) and is pinned by tests/summarize-entitlement-gate; this closes the remaining acceptance item. Tests: extended tests/summarize-entitlement-gate.test.mts — the fail sites must route through logChainOutcome (no unconditional string-literal 'All providers failed' warn), debug-before-warn branch on 'none', and attempt marking must stay behind the gates so 'none' means declined-by-design. 21/21 green; RED (1 fail) against the pre-fix source. tsc clean. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(summarization): isolate provider attempt tracking * docs: update service module count * fix(review): make the koala73#5377 outcome classifier correct and its guards mutation-proof Code review of koala73#5605 found the outcome signal wrong in both directions and all three new regression guards passing with the bug restored (proven by execution). Addresses every finding. Classification (koala73#1, koala73#5, koala73#8) — `lastAttemptedProvider === 'none'` conflated causes: - too quiet: canAttemptServerSummarization() denies both an anon/free principal AND an entitled one inside the 403/429 cooldown, so a paying user's live entitlement outage was demoted to console.debug for up to 15 min (24 h via Retry-After). trackLLMFailure is a no-op and no captureConsoleIntegration exists, so that was the only signal — the koala73#5600 shape. summarize-gate now exports isServerSummarizationSuppressed() and the chain records which denial it hit. - too loud: CircuitBreaker.execute returns its default WITHOUT running the callback while on cooldown (breaker defaults maxFailures=2, cooldown=5min), so a chain that contacted nobody still reported "All providers failed". Marking now happens INSIDE the breaker callback, and a short-circuit is recorded as its own cause. - the quiet path no longer claims "using designed fallback"; reaching it means browser T5 never ran and callers render an error/unavailable state. Guards (koala73#2, koala73#3, koala73#6) — each of these was green with the bug restored: - indexOf matched the gate name inside a COMMENT, so moving the mark above the gate passed. Source assertions now strip comments; more importantly the mark lives inside the breaker callback, making the ordering structural. - the locality assertion ran against the whole file, so hoisting the state to module scope passed. Now scoped to generateSummary with a creation-count pin and a broadened module-global check. - the raw-warn regex only matched quoted literals while this file's idiom is a template literal. Now delimiter-agnostic. - slice anchors are asserted to resolve; a renamed anchor made slice(a, -1) silently widen to end-of-file. The decision surface moved into a pure classifier covered by an executed truth table rather than grep. Verified: all three original bypasses now fail (32/32 green, 31/32 with each mutation applied); typecheck and biome clean. Claude-Session: https://claude.ai/code/session_018XDm48Kzuv1GQe4PR1qimE * chore(docs): regenerate stats after rebase onto main Claude-Session: https://claude.ai/code/session_018XDm48Kzuv1GQe4PR1qimE --------- Co-authored-by: Claude <[email protected]> Co-authored-by: Elie Habib <[email protected]>
Fixes #4913. Third instance of the server-gate-without-client-sweep flood class (#4865 classify-event → fixed by #4869; #4902 health → fixed by #4905). Found live on the anonymous /desktop console while verifying #4907.
Problem
#4675 → PR #4687 premium-gated
/api/news/v1/summarize-article's LLM spend server-side, butsrc/services/summarization.tskept dispatching for every principal viapremiumNewsClient(premiumFetchis auth injection, not an entitlement gate). Anonymous dashboards fanned out up to 3 doomed RPCs per summarize attempt (ollama → groq → openrouter through the same gated endpoint) — repeating 401 console/Sentry noise — before landing on the browser-T5 fallback they'd get anyway.Fix (the proven #4869 shape)
src/services/summarize-gate.ts— pure zero-import session gate: entitlement probe + 15-min timed suppression, fail-OPEN on probe errors (a client gating bug must never silence Pro summaries; the server still gates). Deliberately separate state fromclassify-gate.ts— a summarize 403 must not silence classification.tryApiProviderconsults the gate before any dispatch, coveringrunApiChainand the beta fire-and-forget call; probe wired topanel-gating.hasPremiumAccess()(dual-signal) at module init.premium-fetch.tsboot-window race), and benching a Pro user 15 min over a transient race is worse than one logged retry.translateTextstays ungated —mode='translate'via the plainnewsClientis the server-allowed anon path (requiresPremium = mode !== 'translate').Product behavior
No visible change for any tier. Free/anon users keep their summaries (browser T5, the designed anon path since #4687) — this removes only the wasted network round-trips and the console/Sentry noise. Pro users are unaffected (probe true → dispatch as today).
Verification
tests/summarize-entitlement-gate.test.mts: 9 pure-gate tests (probe deny/allow/fail-open, suppression window + expiry + probe-after, cross-gate isolation from classify-gate) + 5 source-grep wiring pins (gate before RPC, 403 suppression, probe wiring, exactly onepremiumNewsClient.summarizeArticlecall site so a new dispatch path can't bypass the gate, translate-path ungated). 14/14 pass; classify sibling suite 11/11 still green.npm run typecheckclean;docs-stats --checkgreen (AGENTS.md service count 195→196 + stats.json regen for the new module); tiered pre-push gate green.https://claude.ai/code/session_01YKrVoDp8TfEKLYXo83kPFV