diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowResponse.java index 8f0fc631b..2f088f444 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowResponse.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowResponse.java @@ -105,4 +105,19 @@ public class AiWorkflowResponse { + " body or via the X-Stirling-Tool-Report header. May be null for tools" + " that produce only a file.") private JsonNode report; + + @Schema( + description = + "Structured error code when a downstream tool call was blocked (e.g." + + " PAYG_LIMIT_REACHED). Lets the client react — such as opening the" + + " usage-limit modal — instead of only seeing a generic failure. Null" + + " for ordinary outcomes.") + private String errorCode; + + @Schema( + description = + "Whether the team is subscribed, carried from a downstream usage-limit response." + + " Selects which limit modal the client shows (free → subscribe," + + " subscribed → raise cap). Null when the downstream body omitted it.") + private Boolean errorSubscribed; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java index 687c6d1ae..7db5e7a14 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java @@ -36,6 +36,7 @@ import stirling.software.proprietary.policy.model.PolicyRun; import stirling.software.proprietary.policy.model.WaitState; import stirling.software.proprietary.policy.output.PolicyOutputSink; import stirling.software.proprietary.policy.progress.PolicyProgressListener; +import stirling.software.proprietary.service.DownstreamEntitlementError; /** * Runs pipelines asynchronously as tracked jobs. {@link #submit} returns a run id immediately; the @@ -208,11 +209,11 @@ public class PolicyEngine { // failure. We don't interpret the code here (that would couple this module to the saas // billing layer); we just pass it through for the client to map. Other statuses fall // through to the generic failure below. - String code = extractDownstreamErrorCode(e); + String code = DownstreamEntitlementError.extractCode(e); if (code != null) { log.info("Policy run {} blocked by downstream entitlement gate ({})", runId, code); String message = "Usage limit reached"; - run.failWithCode(message, code, extractDownstreamSubscribed(e)); + run.failWithCode(message, code, DownstreamEntitlementError.extractSubscribed(e)); taskManager.setError(runId, message); } else { String message = "Policy run failed: " + e.getMessage(); @@ -302,49 +303,6 @@ public class PolicyEngine { e.getEndpointPath(), e.getReadTimeout().toSeconds()); } - /** Matches the {@code "error":"CODE"} field of a small JSON error body. */ - private static final java.util.regex.Pattern ERROR_CODE_FIELD = - java.util.regex.Pattern.compile("\"error\"\\s*:\\s*\"([^\"]+)\""); - - /** - * Pull the {@code error} sentinel out of a downstream 401/402 JSON body — e.g. the saas - * EntitlementGuard's {@code {"error":"PAYG_LIMIT_REACHED",...}}. Regex (not a JSON parse) on - * purpose: the body is a small, server-controlled shape and this keeps the proprietary module - * free of any billing-layer coupling. Returns null for other statuses or an unmatched body, in - * which case the caller treats it as a generic failure. - */ - private static String extractDownstreamErrorCode(RestClientResponseException e) { - int status = e.getStatusCode().value(); - if (status != 401 && status != 402) { - return null; - } - String body = e.getResponseBodyAsString(); - if (body == null || body.isBlank()) { - return null; - } - java.util.regex.Matcher m = ERROR_CODE_FIELD.matcher(body); - return m.find() ? m.group(1) : null; - } - - /** Matches the {@code "subscribed":true|false} field of a small JSON error body. */ - private static final java.util.regex.Pattern SUBSCRIBED_FIELD = - java.util.regex.Pattern.compile("\"subscribed\"\\s*:\\s*(true|false)"); - - /** - * Pull the {@code subscribed} flag out of a downstream 401/402 JSON body (present on the saas - * {@code PAYG_LIMIT_REACHED} response). Null when absent — the client then defaults to the - * free-limit modal. Regex for the same dependency-free reason as {@link - * #extractDownstreamErrorCode}. - */ - private static Boolean extractDownstreamSubscribed(RestClientResponseException e) { - String body = e.getResponseBodyAsString(); - if (body == null || body.isBlank()) { - return null; - } - java.util.regex.Matcher m = SUBSCRIBED_FIELD.matcher(body); - return m.find() ? Boolean.valueOf(m.group(1)) : null; - } - /** * MDC key {@code UserService.getCurrentUsername()} reads as its async fallback (stamped by the * controller audit aspect on request threads). We reuse it to carry the billing identity onto diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java index 0178e041a..4e6c51531 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java @@ -18,6 +18,7 @@ import org.springframework.http.MediaType; import org.springframework.http.MediaTypeFactory; import org.springframework.stereotype.Service; import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.RestClientResponseException; import org.springframework.web.multipart.MultipartFile; import io.github.pixee.security.Filenames; @@ -385,6 +386,13 @@ public class AiWorkflowService { return new WorkflowState.Terminal( cannotContinue(toolTimeoutMessage(PDF_TO_MARKDOWN_ENDPOINT, e))); } catch (Exception e) { + AiWorkflowResponse limit = paygLimitResponseOrNull(e); + if (limit != null) { + log.info( + "AI markdown conversion blocked by downstream entitlement gate ({})", + limit.getErrorCode()); + return new WorkflowState.Terminal(limit); + } log.error("Failed to convert PDF to Markdown: {}", e.getMessage(), e); return new WorkflowState.Terminal( cannotContinue(toolFailureMessage(PDF_TO_MARKDOWN_ENDPOINT, e))); @@ -470,6 +478,14 @@ public class AiWorkflowService { log.error("Tool {} timed out: {}", endpointPath, e.getMessage()); return new WorkflowState.Terminal(cannotContinue(toolTimeoutMessage(endpointPath, e))); } catch (Exception e) { + AiWorkflowResponse limit = paygLimitResponseOrNull(e); + if (limit != null) { + log.info( + "AI workflow tool {} blocked by downstream entitlement gate ({})", + endpointPath, + limit.getErrorCode()); + return new WorkflowState.Terminal(limit); + } log.error("Failed to execute tool {}: {}", endpointPath, e.getMessage(), e); return new WorkflowState.Terminal(cannotContinue(toolFailureMessage(endpointPath, e))); } @@ -585,6 +601,13 @@ public class AiWorkflowService { log.error("Plan step failed (HTTP {}): {}", e.getStatusCode(), reason); return new WorkflowState.Terminal(cannotContinue(reason)); } catch (Exception e) { + AiWorkflowResponse limit = paygLimitResponseOrNull(e); + if (limit != null) { + log.info( + "AI workflow plan blocked by downstream entitlement gate ({})", + limit.getErrorCode()); + return new WorkflowState.Terminal(limit); + } log.error("Failed to execute plan: {}", e.getMessage(), e); return new WorkflowState.Terminal( cannotContinue("Plan execution failed: " + e.getMessage())); @@ -722,6 +745,33 @@ public class AiWorkflowService { return response; } + /** + * If {@code e} is a downstream usage-limit block — a 401/402 from a tool call carrying the saas + * EntitlementGuard's {@code error} sentinel — build a terminal response that carries the + * structured code (+ {@code subscribed}) through to the client, so it can pop the matching + * usage-limit modal instead of surfacing the raw "tool failed: 402…" text. Returns null for any + * other failure, so the caller falls back to its normal tool-failure handling. + * + *
The agent's tool calls run server-side (loopback HTTP via {@link PolicyExecutor}), so this + * 402 never reaches the frontend's API-client interceptor that pops the modal for direct calls + * — same gap the policy auto-run path bridges in {@code PolicyEngine}. + */ + private AiWorkflowResponse paygLimitResponseOrNull(Throwable e) { + if (!(e instanceof RestClientResponseException rce)) { + return null; + } + String code = DownstreamEntitlementError.extractCode(rce); + if (code == null) { + return null; + } + AiWorkflowResponse response = new AiWorkflowResponse(); + response.setOutcome(AiWorkflowOutcome.CANNOT_CONTINUE); + response.setReason("You've reached your current usage limit."); + response.setErrorCode(code); + response.setErrorSubscribed(DownstreamEntitlementError.extractSubscribed(rce)); + return response; + } + /** * Drive the engine's streaming orchestrator endpoint. Progress events are forwarded to {@code * listener} as they arrive (each one keeps the SSE connection to the frontend alive too). The diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/DownstreamEntitlementError.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/DownstreamEntitlementError.java new file mode 100644 index 000000000..52d9500a5 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/DownstreamEntitlementError.java @@ -0,0 +1,64 @@ +package stirling.software.proprietary.service; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.web.client.RestClientResponseException; + +/** + * Reads the {@code error} sentinel and {@code subscribed} flag out of a downstream 401/402 JSON + * body — e.g. the saas EntitlementGuard's {@code + * {"error":"PAYG_LIMIT_REACHED","subscribed":false}}. + * + *
Server-side run paths (policy auto-run, AI agent workflows) execute tool calls via loopback + * HTTP, so a usage-limit 402 surfaces as a {@link RestClientResponseException} rather than reaching + * the frontend's API-client interceptor. These helpers let those paths pass the structured code + * through to the client, which maps it to the right usage-limit modal instead of showing a generic + * failure. + * + *
Regex (not a JSON parse) on purpose: the body is a small, server-controlled shape and this
+ * keeps the proprietary module free of any billing-layer (saas) coupling.
+ */
+public final class DownstreamEntitlementError {
+
+ private DownstreamEntitlementError() {}
+
+ /** Matches the {@code "error":"CODE"} field of a small JSON error body. */
+ private static final Pattern ERROR_CODE_FIELD =
+ Pattern.compile("\"error\"\\s*:\\s*\"([^\"]+)\"");
+
+ /** Matches the {@code "subscribed":true|false} field of a small JSON error body. */
+ private static final Pattern SUBSCRIBED_FIELD =
+ Pattern.compile("\"subscribed\"\\s*:\\s*(true|false)");
+
+ /**
+ * Pull the {@code error} sentinel out of a downstream 401/402 JSON body. Returns null for other
+ * statuses or an unmatched body, in which case the caller treats it as a generic failure.
+ */
+ public static String extractCode(RestClientResponseException e) {
+ int status = e.getStatusCode().value();
+ if (status != 401 && status != 402) {
+ return null;
+ }
+ String body = e.getResponseBodyAsString();
+ if (body == null || body.isBlank()) {
+ return null;
+ }
+ Matcher m = ERROR_CODE_FIELD.matcher(body);
+ return m.find() ? m.group(1) : null;
+ }
+
+ /**
+ * Pull the {@code subscribed} flag out of the body (present on the saas {@code
+ * PAYG_LIMIT_REACHED}/{@code FEATURE_DEGRADED} responses). Null when absent — the client then
+ * defaults to the free-limit modal.
+ */
+ public static Boolean extractSubscribed(RestClientResponseException e) {
+ String body = e.getResponseBodyAsString();
+ if (body == null || body.isBlank()) {
+ return null;
+ }
+ Matcher m = SUBSCRIBED_FIELD.matcher(body);
+ return m.find() ? Boolean.valueOf(m.group(1)) : null;
+ }
+}
diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml
index c8a1d5d28..7d0e368b2 100644
--- a/frontend/editor/public/locales/en-GB/translation.toml
+++ b/frontend/editor/public/locales/en-GB/translation.toml
@@ -2740,6 +2740,7 @@ need_clarification = "Could you clarify your request?"
not_found = "I couldn't find the requested information."
processing = "Processing ({{outcome}})..."
unsupported_capability = "Unsupported capability: {{capability}}"
+usage_limit_reached = "You've reached your usage limit. Check your plan options to keep going."
[chat.toolsUsed]
summary = "Ran {{count}} tools"
diff --git a/frontend/editor/src/proprietary/components/chat/ChatContext.tsx b/frontend/editor/src/proprietary/components/chat/ChatContext.tsx
index ac3aad972..05577885b 100644
--- a/frontend/editor/src/proprietary/components/chat/ChatContext.tsx
+++ b/frontend/editor/src/proprietary/components/chat/ChatContext.tsx
@@ -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: {
diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts
index 64c177739..27f22b13a 100644
--- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts
+++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts
@@ -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 Also bridges the policy auto-run path: a policy's tool calls run server-side,
- * so their usage-limit 402 never reaches the apiClient interceptor that pops these
- * modals for direct calls. The proprietary auto-run hook broadcasts {@link
- * POLICY_LIMIT_REACHED_EVENT} (with the blocking 402's {@code subscribed} flag)
- * instead; we open the matching modal here.
+ * Also bridges the server-side run paths (policy auto-run, AI agent): their tool
+ * calls run server-side, so a usage-limit 402 never reaches the apiClient interceptor
+ * that pops these modals for direct calls. Those proprietary paths broadcast {@link
+ * PAYG_LIMIT_REACHED_EVENT} (with the blocking 402's {@code subscribed} flag) instead;
+ * we open the matching modal here.
*/
export default function UsageLimitModalHost() {
const [freeOpen, setFreeOpen] = useState(false);
@@ -30,19 +30,19 @@ export default function UsageLimitModalHost() {
useEffect(() => {
const onFree = () => setFreeOpen(true);
const onSpend = () => setSpendOpen(true);
- const onPolicyLimit = (e: Event) => {
- const subscribed = (e as CustomEvent