mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +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:
+15
@@ -105,4 +105,19 @@ public class AiWorkflowResponse {
|
|||||||
+ " body or via the X-Stirling-Tool-Report header. May be null for tools"
|
+ " body or via the X-Stirling-Tool-Report header. May be null for tools"
|
||||||
+ " that produce only a file.")
|
+ " that produce only a file.")
|
||||||
private JsonNode report;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-45
@@ -36,6 +36,7 @@ import stirling.software.proprietary.policy.model.PolicyRun;
|
|||||||
import stirling.software.proprietary.policy.model.WaitState;
|
import stirling.software.proprietary.policy.model.WaitState;
|
||||||
import stirling.software.proprietary.policy.output.PolicyOutputSink;
|
import stirling.software.proprietary.policy.output.PolicyOutputSink;
|
||||||
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
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
|
* 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
|
// 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
|
// billing layer); we just pass it through for the client to map. Other statuses fall
|
||||||
// through to the generic failure below.
|
// through to the generic failure below.
|
||||||
String code = extractDownstreamErrorCode(e);
|
String code = DownstreamEntitlementError.extractCode(e);
|
||||||
if (code != null) {
|
if (code != null) {
|
||||||
log.info("Policy run {} blocked by downstream entitlement gate ({})", runId, code);
|
log.info("Policy run {} blocked by downstream entitlement gate ({})", runId, code);
|
||||||
String message = "Usage limit reached";
|
String message = "Usage limit reached";
|
||||||
run.failWithCode(message, code, extractDownstreamSubscribed(e));
|
run.failWithCode(message, code, DownstreamEntitlementError.extractSubscribed(e));
|
||||||
taskManager.setError(runId, message);
|
taskManager.setError(runId, message);
|
||||||
} else {
|
} else {
|
||||||
String message = "Policy run failed: " + e.getMessage();
|
String message = "Policy run failed: " + e.getMessage();
|
||||||
@@ -302,49 +303,6 @@ public class PolicyEngine {
|
|||||||
e.getEndpointPath(), e.getReadTimeout().toSeconds());
|
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
|
* 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
|
* controller audit aspect on request threads). We reuse it to carry the billing identity onto
|
||||||
|
|||||||
+50
@@ -18,6 +18,7 @@ import org.springframework.http.MediaType;
|
|||||||
import org.springframework.http.MediaTypeFactory;
|
import org.springframework.http.MediaTypeFactory;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.client.HttpServerErrorException;
|
import org.springframework.web.client.HttpServerErrorException;
|
||||||
|
import org.springframework.web.client.RestClientResponseException;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import io.github.pixee.security.Filenames;
|
import io.github.pixee.security.Filenames;
|
||||||
@@ -385,6 +386,13 @@ public class AiWorkflowService {
|
|||||||
return new WorkflowState.Terminal(
|
return new WorkflowState.Terminal(
|
||||||
cannotContinue(toolTimeoutMessage(PDF_TO_MARKDOWN_ENDPOINT, e)));
|
cannotContinue(toolTimeoutMessage(PDF_TO_MARKDOWN_ENDPOINT, e)));
|
||||||
} catch (Exception 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);
|
log.error("Failed to convert PDF to Markdown: {}", e.getMessage(), e);
|
||||||
return new WorkflowState.Terminal(
|
return new WorkflowState.Terminal(
|
||||||
cannotContinue(toolFailureMessage(PDF_TO_MARKDOWN_ENDPOINT, e)));
|
cannotContinue(toolFailureMessage(PDF_TO_MARKDOWN_ENDPOINT, e)));
|
||||||
@@ -470,6 +478,14 @@ public class AiWorkflowService {
|
|||||||
log.error("Tool {} timed out: {}", endpointPath, e.getMessage());
|
log.error("Tool {} timed out: {}", endpointPath, e.getMessage());
|
||||||
return new WorkflowState.Terminal(cannotContinue(toolTimeoutMessage(endpointPath, e)));
|
return new WorkflowState.Terminal(cannotContinue(toolTimeoutMessage(endpointPath, e)));
|
||||||
} catch (Exception 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);
|
log.error("Failed to execute tool {}: {}", endpointPath, e.getMessage(), e);
|
||||||
return new WorkflowState.Terminal(cannotContinue(toolFailureMessage(endpointPath, 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);
|
log.error("Plan step failed (HTTP {}): {}", e.getStatusCode(), reason);
|
||||||
return new WorkflowState.Terminal(cannotContinue(reason));
|
return new WorkflowState.Terminal(cannotContinue(reason));
|
||||||
} catch (Exception e) {
|
} 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);
|
log.error("Failed to execute plan: {}", e.getMessage(), e);
|
||||||
return new WorkflowState.Terminal(
|
return new WorkflowState.Terminal(
|
||||||
cannotContinue("Plan execution failed: " + e.getMessage()));
|
cannotContinue("Plan execution failed: " + e.getMessage()));
|
||||||
@@ -722,6 +745,33 @@ public class AiWorkflowService {
|
|||||||
return response;
|
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.
|
||||||
|
*
|
||||||
|
* <p>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
|
* 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
|
* listener} as they arrive (each one keeps the SSE connection to the frontend alive too). The
|
||||||
|
|||||||
+64
@@ -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}}.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2740,6 +2740,7 @@ need_clarification = "Could you clarify your request?"
|
|||||||
not_found = "I couldn't find the requested information."
|
not_found = "I couldn't find the requested information."
|
||||||
processing = "Processing ({{outcome}})..."
|
processing = "Processing ({{outcome}})..."
|
||||||
unsupported_capability = "Unsupported capability: {{capability}}"
|
unsupported_capability = "Unsupported capability: {{capability}}"
|
||||||
|
usage_limit_reached = "You've reached your usage limit. Check your plan options to keep going."
|
||||||
|
|
||||||
[chat.toolsUsed]
|
[chat.toolsUsed]
|
||||||
summary = "Ran {{count}} tools"
|
summary = "Ran {{count}} tools"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useAllFiles, useFileActions } from "@app/contexts/FileContext";
|
|||||||
import apiClient from "@app/services/apiClient";
|
import apiClient from "@app/services/apiClient";
|
||||||
import { getApiBaseUrl } from "@app/services/apiClientConfig";
|
import { getApiBaseUrl } from "@app/services/apiClientConfig";
|
||||||
import { getAuthHeaders } from "@app/services/apiClientSetup";
|
import { getAuthHeaders } from "@app/services/apiClientSetup";
|
||||||
|
import { dispatchPaygLimitReached } from "@app/services/usageLimitBridge";
|
||||||
import { createChildStub } from "@app/contexts/file/fileActions";
|
import { createChildStub } from "@app/contexts/file/fileActions";
|
||||||
import {
|
import {
|
||||||
createNewStirlingFileStub,
|
createNewStirlingFileStub,
|
||||||
@@ -179,6 +180,25 @@ interface AiWorkflowResponse {
|
|||||||
fileId?: string;
|
fileId?: string;
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
contentType?: 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 {
|
interface ChatState {
|
||||||
@@ -512,8 +532,19 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let detail: string | undefined;
|
let detail: string | undefined;
|
||||||
|
let limitHandled = false;
|
||||||
try {
|
try {
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
|
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 =
|
detail =
|
||||||
body?.message ??
|
body?.message ??
|
||||||
body?.detail ??
|
body?.detail ??
|
||||||
@@ -521,9 +552,26 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
(Array.isArray(body?.errors)
|
(Array.isArray(body?.errors)
|
||||||
? body.errors[0]?.message
|
? body.errors[0]?.message
|
||||||
: undefined);
|
: undefined);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// non-JSON body — ignore
|
// 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(
|
throw new Error(
|
||||||
detail ?? `AI engine request failed: ${response.status}`,
|
detail ?? `AI engine request failed: ${response.status}`,
|
||||||
);
|
);
|
||||||
@@ -553,7 +601,20 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
onResult: (data) => {
|
onResult: (data) => {
|
||||||
receivedResult = true;
|
receivedResult = true;
|
||||||
dispatch({ type: "SET_PROGRESS", progress: null });
|
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({
|
dispatch({
|
||||||
type: "ADD_MESSAGE",
|
type: "ADD_MESSAGE",
|
||||||
message: {
|
message: {
|
||||||
|
|||||||
@@ -27,9 +27,8 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
PolicyRunStatus,
|
PolicyRunStatus,
|
||||||
PolicyRunView,
|
PolicyRunView,
|
||||||
PolicyLimitReachedDetail,
|
|
||||||
} from "@app/services/policyPipeline";
|
} 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 type { FileId } from "@app/types/file";
|
||||||
import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers";
|
import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers";
|
||||||
import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext";
|
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 (code !== "PAYG_LIMIT_REACHED" && code !== "FEATURE_DEGRADED") return;
|
||||||
if (firedLimitModal.current.has(view.runId)) return;
|
if (firedLimitModal.current.has(view.runId)) return;
|
||||||
firedLimitModal.current.add(view.runId);
|
firedLimitModal.current.add(view.runId);
|
||||||
try {
|
dispatchPaygLimitReached(view.errorSubscribed ?? null);
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent<PolicyLimitReachedDetail>(POLICY_LIMIT_REACHED_EVENT, {
|
|
||||||
detail: { subscribed: view.errorSubscribed ?? null },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// non-browser env (tests / SSR) — no-op.
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Dispatch: for each active policy × each session file not yet run, fire a run.
|
// Dispatch: for each active policy × each session file not yet run, fire a run.
|
||||||
|
|||||||
@@ -56,24 +56,6 @@ export interface BackendPolicy {
|
|||||||
output: BackendOutputSpec;
|
output: BackendOutputSpec;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Window event fired when a policy run is blocked by a usage limit (its tool call got a 402
|
|
||||||
* entitlement sentinel). Dispatched from the proprietary auto-run poll; a saas-layer listener
|
|
||||||
* reads the wallet and opens the matching usage-limit modal. Kept here (proprietary) so both
|
|
||||||
* layers share one name — proprietary can't import the saas modal API directly.
|
|
||||||
*/
|
|
||||||
export const POLICY_LIMIT_REACHED_EVENT = "payg:policyLimitReached";
|
|
||||||
|
|
||||||
/** Detail carried on {@link POLICY_LIMIT_REACHED_EVENT}. */
|
|
||||||
export interface PolicyLimitReachedDetail {
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lifecycle states of a backend run (mirrors PolicyRunStatus). */
|
/** Lifecycle states of a backend run (mirrors PolicyRunStatus). */
|
||||||
export type PolicyRunStatus =
|
export type PolicyRunStatus =
|
||||||
| "PENDING"
|
| "PENDING"
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,9 @@ import {
|
|||||||
SPEND_CAP_MODAL_EVENT,
|
SPEND_CAP_MODAL_EVENT,
|
||||||
} from "@app/components/usageLimitModals";
|
} from "@app/components/usageLimitModals";
|
||||||
import {
|
import {
|
||||||
POLICY_LIMIT_REACHED_EVENT,
|
PAYG_LIMIT_REACHED_EVENT,
|
||||||
type PolicyLimitReachedDetail,
|
type PaygLimitReachedDetail,
|
||||||
} from "@app/services/policyPipeline";
|
} from "@app/services/usageLimitBridge";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Always-mounted host for the usage-limit warning modals. Mount once (in
|
* Always-mounted host for the usage-limit warning modals. Mount once (in
|
||||||
@@ -17,11 +17,11 @@ import {
|
|||||||
* only while open, so it reads the wallet (and animates in) on open rather
|
* only while open, so it reads the wallet (and animates in) on open rather
|
||||||
* than on app load.
|
* than on app load.
|
||||||
*
|
*
|
||||||
* <p>Also bridges the policy auto-run path: a policy's tool calls run server-side,
|
* <p>Also bridges the server-side run paths (policy auto-run, AI agent): their tool
|
||||||
* so their usage-limit 402 never reaches the apiClient interceptor that pops these
|
* calls run server-side, so a usage-limit 402 never reaches the apiClient interceptor
|
||||||
* modals for direct calls. The proprietary auto-run hook broadcasts {@link
|
* that pops these modals for direct calls. Those proprietary paths broadcast {@link
|
||||||
* POLICY_LIMIT_REACHED_EVENT} (with the blocking 402's {@code subscribed} flag)
|
* PAYG_LIMIT_REACHED_EVENT} (with the blocking 402's {@code subscribed} flag) instead;
|
||||||
* instead; we open the matching modal here.
|
* we open the matching modal here.
|
||||||
*/
|
*/
|
||||||
export default function UsageLimitModalHost() {
|
export default function UsageLimitModalHost() {
|
||||||
const [freeOpen, setFreeOpen] = useState(false);
|
const [freeOpen, setFreeOpen] = useState(false);
|
||||||
@@ -30,19 +30,19 @@ export default function UsageLimitModalHost() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onFree = () => setFreeOpen(true);
|
const onFree = () => setFreeOpen(true);
|
||||||
const onSpend = () => setSpendOpen(true);
|
const onSpend = () => setSpendOpen(true);
|
||||||
const onPolicyLimit = (e: Event) => {
|
const onServerLimit = (e: Event) => {
|
||||||
const subscribed = (e as CustomEvent<PolicyLimitReachedDetail>).detail
|
const subscribed = (e as CustomEvent<PaygLimitReachedDetail>).detail
|
||||||
?.subscribed;
|
?.subscribed;
|
||||||
if (subscribed) setSpendOpen(true);
|
if (subscribed) setSpendOpen(true);
|
||||||
else setFreeOpen(true);
|
else setFreeOpen(true);
|
||||||
};
|
};
|
||||||
window.addEventListener(FREE_LIMIT_MODAL_EVENT, onFree);
|
window.addEventListener(FREE_LIMIT_MODAL_EVENT, onFree);
|
||||||
window.addEventListener(SPEND_CAP_MODAL_EVENT, onSpend);
|
window.addEventListener(SPEND_CAP_MODAL_EVENT, onSpend);
|
||||||
window.addEventListener(POLICY_LIMIT_REACHED_EVENT, onPolicyLimit);
|
window.addEventListener(PAYG_LIMIT_REACHED_EVENT, onServerLimit);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener(FREE_LIMIT_MODAL_EVENT, onFree);
|
window.removeEventListener(FREE_LIMIT_MODAL_EVENT, onFree);
|
||||||
window.removeEventListener(SPEND_CAP_MODAL_EVENT, onSpend);
|
window.removeEventListener(SPEND_CAP_MODAL_EVENT, onSpend);
|
||||||
window.removeEventListener(POLICY_LIMIT_REACHED_EVENT, onPolicyLimit);
|
window.removeEventListener(PAYG_LIMIT_REACHED_EVENT, onServerLimit);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user