mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
## Problem We're getting 402s when an AI **agent** (chat) run hits the free allowance / spending cap, but the frontend handles them poorly and never pops the usage-limit modal. The agent runs its tool calls **server-side** (loopback HTTP via `PolicyExecutor`), so the 402 never reaches the `apiClient` interceptor that pops the modal for direct calls. It was caught by the generic tool-failure handler and flattened into a `CANNOT_CONTINUE` reason string (`"The /api/v1/… tool failed: 402…"`), streamed as a `result` event, and rendered as a scary chat bubble. This is the same gap the policy auto-run path bridges (#6626) — one layer up. ## Fix **Backend** (`proprietary`) - `AiWorkflowResponse` gains `errorCode` + `errorSubscribed`. - `AiWorkflowService` detects a downstream 401/402 entitlement sentinel in its three tool-exec catch sites (`onToolCall`, `runPlan`, `onConvertMarkdown`) and surfaces the structured code (+ `subscribed`) on the terminal response instead of the raw failure text. - Factored the 401/402 body extraction `PolicyEngine` already had into a shared `DownstreamEntitlementError` util so the two server-side paths can't drift. **Frontend** - New `usageLimitBridge` (`PAYG_LIMIT_REACHED_EVENT` + `dispatchPaygLimitReached`) generalises the previously policy-only bridge. Proprietary can't import the saas modal API (layering), so server-side limit hits broadcast a window event the saas `UsageLimitModalHost` opens the modal from. Migrated the policy path onto it. - `ChatContext` fires the matching modal (free → subscribe, subscribed → raise cap) on the limit result **and** on a direct 402, replacing the raw reason with a brief friendly line (`chat.responses.usage_limit_reached`). No Python engine changes — the charge/402 happens on the Java tool endpoint that Java itself calls. ## Test plan - [x] `:proprietary:compileJava` + `spotlessCheck` clean - [x] `AiWorkflowServiceTest` + `PolicyEngineTest` green - [x] eslint, proprietary + saas typechecks clean - [ ] Manual: drive an agent run over the limit → brief line in chat + the right modal (free vs cap) > Note: proprietary test compilation is currently blocked on the pre-existing `InitialSecuritySetupTest` 6-arg ctor break (unrelated, tracked separately); verified locally by temporarily patching it.
36 lines
1.5 KiB
TypeScript
36 lines
1.5 KiB
TypeScript
/**
|
|
* Bridge for usage-limit (PAYG 402) signals raised by server-side runs — the policy auto-run path
|
|
* and the AI agent — neither of which flows through the apiClient interceptor that pops the
|
|
* usage-limit modal for direct calls. Their tool calls execute server-side, so the blocking 402
|
|
* never reaches the browser's HTTP client.
|
|
*
|
|
* Proprietary code can't import the saas modal API (layering: proprietary ↛ saas), so it broadcasts
|
|
* this window event instead; the saas-layer UsageLimitModalHost listens and opens the matching
|
|
* modal (free → "subscribe", subscribed → "raise cap"). Kept here (proprietary) so both layers
|
|
* share one name.
|
|
*/
|
|
export const PAYG_LIMIT_REACHED_EVENT = "payg:limitReached";
|
|
|
|
/** Detail carried on {@link PAYG_LIMIT_REACHED_EVENT}. */
|
|
export interface PaygLimitReachedDetail {
|
|
/**
|
|
* Whether the blocked team was subscribed (over its spending cap) vs un-subscribed (free
|
|
* allowance spent), from the blocking 402. The listener uses it to choose the spend-cap vs
|
|
* free-limit modal. Null when unknown → treat as free-limit.
|
|
*/
|
|
subscribed: boolean | null;
|
|
}
|
|
|
|
/** Fire {@link PAYG_LIMIT_REACHED_EVENT}. No-op outside a browser (tests / SSR). */
|
|
export function dispatchPaygLimitReached(subscribed: boolean | null): void {
|
|
try {
|
|
window.dispatchEvent(
|
|
new CustomEvent<PaygLimitReachedDetail>(PAYG_LIMIT_REACHED_EVENT, {
|
|
detail: { subscribed },
|
|
}),
|
|
);
|
|
} catch {
|
|
// non-browser env (tests / SSR) — no-op.
|
|
}
|
|
}
|