mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 19:10:47 +02:00
fix(payg): fire the usage-limit modal when an AI agent run hits the limit (#6638)
## 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.
This commit is contained in:
@@ -12,6 +12,7 @@ import { useAllFiles, useFileActions } from "@app/contexts/FileContext";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { getApiBaseUrl } from "@app/services/apiClientConfig";
|
||||
import { getAuthHeaders } from "@app/services/apiClientSetup";
|
||||
import { dispatchPaygLimitReached } from "@app/services/usageLimitBridge";
|
||||
import { createChildStub } from "@app/contexts/file/fileActions";
|
||||
import {
|
||||
createNewStirlingFileStub,
|
||||
@@ -179,6 +180,25 @@ interface AiWorkflowResponse {
|
||||
fileId?: string;
|
||||
fileName?: string;
|
||||
contentType?: string;
|
||||
/**
|
||||
* Structured error code when a tool call inside the workflow was blocked (e.g.
|
||||
* PAYG_LIMIT_REACHED / FEATURE_DEGRADED). Present instead of a raw failure reason so the
|
||||
* client can pop the usage-limit modal. See {@link isPaygLimitCode}.
|
||||
*/
|
||||
errorCode?: string;
|
||||
/** From the blocking 402: true → over spending cap, false/absent → free allowance spent. */
|
||||
errorSubscribed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage-limit sentinels the agent can surface (matching the saas EntitlementGuard / apiClient
|
||||
* interceptor). When one of these is the result's errorCode, we open the usage-limit modal rather
|
||||
* than render the failure as chat text.
|
||||
*/
|
||||
const PAYG_LIMIT_CODES = new Set(["PAYG_LIMIT_REACHED", "FEATURE_DEGRADED"]);
|
||||
|
||||
function isPaygLimitCode(code: string | null | undefined): boolean {
|
||||
return code != null && PAYG_LIMIT_CODES.has(code);
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
@@ -512,18 +532,46 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
if (!response.ok) {
|
||||
let detail: string | undefined;
|
||||
let limitHandled = false;
|
||||
try {
|
||||
const body = await response.json();
|
||||
detail =
|
||||
body?.message ??
|
||||
body?.detail ??
|
||||
body?.error ??
|
||||
(Array.isArray(body?.errors)
|
||||
? body.errors[0]?.message
|
||||
: undefined);
|
||||
const code = typeof body?.error === "string" ? body.error : null;
|
||||
// A 402 carrying a usage-limit sentinel means the agent call itself was gated.
|
||||
// Fire the usage-limit modal (free → subscribe, subscribed → raise cap) and show a
|
||||
// brief line below — not a generic "engine failed" error.
|
||||
if (response.status === 402 && isPaygLimitCode(code)) {
|
||||
dispatchPaygLimitReached(
|
||||
typeof body?.subscribed === "boolean" ? body.subscribed : null,
|
||||
);
|
||||
limitHandled = true;
|
||||
} else {
|
||||
detail =
|
||||
body?.message ??
|
||||
body?.detail ??
|
||||
body?.error ??
|
||||
(Array.isArray(body?.errors)
|
||||
? body.errors[0]?.message
|
||||
: undefined);
|
||||
}
|
||||
} catch {
|
||||
// non-JSON body — ignore
|
||||
}
|
||||
if (limitHandled) {
|
||||
dispatch({ type: "SET_PROGRESS", progress: null });
|
||||
dispatch({
|
||||
type: "ADD_MESSAGE",
|
||||
message: {
|
||||
id: generateId(),
|
||||
role: ChatRole.ASSISTANT,
|
||||
content: t(
|
||||
"chat.responses.usage_limit_reached",
|
||||
"You've reached your usage limit. Check your plan options to keep going.",
|
||||
),
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
detail ?? `AI engine request failed: ${response.status}`,
|
||||
);
|
||||
@@ -553,7 +601,20 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
onResult: (data) => {
|
||||
receivedResult = true;
|
||||
dispatch({ type: "SET_PROGRESS", progress: null });
|
||||
const replyContent = formatWorkflowResponse(data, t);
|
||||
// The agent's tool calls run server-side, so a usage-limit 402 surfaces here on the
|
||||
// result (not via the apiClient interceptor that pops the modal for direct calls).
|
||||
// Fire the matching modal and replace the raw "tool failed: 402…" reason with a
|
||||
// brief, non-alarming line.
|
||||
const isLimit = isPaygLimitCode(data.errorCode);
|
||||
if (isLimit) {
|
||||
dispatchPaygLimitReached(data.errorSubscribed ?? null);
|
||||
}
|
||||
const replyContent = isLimit
|
||||
? t(
|
||||
"chat.responses.usage_limit_reached",
|
||||
"You've reached your usage limit. Check your plan options to keep going.",
|
||||
)
|
||||
: formatWorkflowResponse(data, t);
|
||||
dispatch({
|
||||
type: "ADD_MESSAGE",
|
||||
message: {
|
||||
|
||||
@@ -27,9 +27,8 @@ import {
|
||||
import type {
|
||||
PolicyRunStatus,
|
||||
PolicyRunView,
|
||||
PolicyLimitReachedDetail,
|
||||
} from "@app/services/policyPipeline";
|
||||
import { POLICY_LIMIT_REACHED_EVENT } from "@app/services/policyPipeline";
|
||||
import { dispatchPaygLimitReached } from "@app/services/usageLimitBridge";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers";
|
||||
import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext";
|
||||
@@ -87,15 +86,7 @@ export function usePolicyAutoRun(): void {
|
||||
if (code !== "PAYG_LIMIT_REACHED" && code !== "FEATURE_DEGRADED") return;
|
||||
if (firedLimitModal.current.has(view.runId)) return;
|
||||
firedLimitModal.current.add(view.runId);
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<PolicyLimitReachedDetail>(POLICY_LIMIT_REACHED_EVENT, {
|
||||
detail: { subscribed: view.errorSubscribed ?? null },
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// non-browser env (tests / SSR) — no-op.
|
||||
}
|
||||
dispatchPaygLimitReached(view.errorSubscribed ?? null);
|
||||
}, []);
|
||||
|
||||
// Dispatch: for each active policy × each session file not yet run, fire a run.
|
||||
|
||||
Reference in New Issue
Block a user