From 4003d7889258f0d63759d86102dd7d1a3d631d19 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Tue, 1 Sep 2026 15:49:17 +0100 Subject: [PATCH] Unify metering APIs --- .../automation/AutomationMeterController.java | 16 ++-- .../automation/AutomationRunBiller.java | 17 ++-- .../ClassificationRunBiller.java | 8 -- .../ClassificationMeterController.java | 81 ------------------- .../charge/SaasClassificationRunBiller.java | 49 ----------- .../src/core/services/automationMeter.ts | 4 +- .../classification-heuristic-upload.spec.ts | 2 +- .../useClientSideClassification.test.tsx | 4 +- .../policies/useClientSideClassification.ts | 15 ++-- .../proprietary/services/automationMeter.ts | 4 +- .../services/classificationMeter.ts | 27 ------- 11 files changed, 32 insertions(+), 195 deletions(-) delete mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/classification/ClassificationRunBiller.java delete mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/ClassificationMeterController.java delete mode 100644 app/saas/src/main/java/stirling/software/saas/payg/charge/SaasClassificationRunBiller.java delete mode 100644 frontend/editor/src/proprietary/services/classificationMeter.ts diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/automation/AutomationMeterController.java b/app/proprietary/src/main/java/stirling/software/proprietary/automation/AutomationMeterController.java index e60b58128f..b37ee44d16 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/automation/AutomationMeterController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/automation/AutomationMeterController.java @@ -21,14 +21,15 @@ import stirling.software.proprietary.audit.AuditContext; import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize; /** - * Meters + audits a client-side Automate run so a browser-run workflow bills like the equivalent - * server-side policy. Side-effect only; does no processing itself. The frontend dispatches this - * once, after the run completes. + * Meters + audits a client-side automation run so a browser-run automation bills like the + * equivalent server-side policy. The meter endpoint for every automation that executes in + * the browser (rather than through the billing interceptors). Side-effect only; does no + * processing itself. */ @Slf4j @Hidden @RestController -@RequestMapping("/api/v1/automate") +@RequestMapping("/api/v1/automation") public class AutomationMeterController { /** Cap on input documents accepted per call, guarding against a hostile client payload. */ @@ -45,10 +46,11 @@ public class AutomationMeterController { @PostMapping("/meter") @Operation( - summary = "Meter a client-side Automate run", + summary = "Meter a client-side automation run", description = - "Records billing + audit for an automation performed in the browser. Does no" - + " processing itself. Dispatched by the frontend, not for direct use.") + "Records billing + audit for an automation performed in the browser (the" + + " Automate tool or the local classification pass). Does no processing" + + " itself. Dispatched by the frontend, not for direct use.") public ResponseEntity meterAutomationRun( @RequestBody(required = false) AutomationMeterRequest body, HttpServletRequest request) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/automation/AutomationRunBiller.java b/app/proprietary/src/main/java/stirling/software/proprietary/automation/AutomationRunBiller.java index 4258dd23eb..3641d04942 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/automation/AutomationRunBiller.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/automation/AutomationRunBiller.java @@ -5,19 +5,14 @@ import java.util.List; import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize; /** - * Meters one client-side Automate run. The Automate tool runs its steps in the browser (calling - * each tool's normal endpoint), so no automation sub-step reaches the billing interceptors - this - * biller is how that run is charged instead. SaaS and a linked self-hosted instance each provide an - * implementation; other flavors have no bean and the run is recorded for audit but not charged. - * - *

Charged on the input document set's doc-units, once per run, so an Automate workflow costs the - * same as the equivalent server-side policy over the same inputs (see {@link - * stirling.software.proprietary.billing.DocumentUnitCalculator}). + * Meters one client-side automation run - the Automate tool or the local classification pass. Both + * run their work in the browser (calling each tool's normal endpoint), so no automation sub-step + * reaches the billing interceptors; this biller is how those runs are charged instead. SaaS and a + * linked self-hosted instance each provide an implementation; other flavors have no bean and the + * run is recorded for audit but not charged. */ public interface AutomationRunBiller { - /** - * Charge one Automate run over {@code inputs} (page/byte facts of the original input files). - */ + /** Charge one run over {@code inputs} (page/byte facts of the original input files). */ void recordAutomationRun(List inputs); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/ClassificationRunBiller.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/ClassificationRunBiller.java deleted file mode 100644 index dd0467c9ca..0000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/classification/ClassificationRunBiller.java +++ /dev/null @@ -1,8 +0,0 @@ -package stirling.software.proprietary.classification; - -/** Meters a client-side classification run; SaaS charges PAYG, other flavors have no bean. */ -public interface ClassificationRunBiller { - - /** Charge one classification policy run covering {@code documentCount} documents. */ - void recordClassificationRun(int documentCount); -} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/ClassificationMeterController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/ClassificationMeterController.java deleted file mode 100644 index dc978648b6..0000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/ClassificationMeterController.java +++ /dev/null @@ -1,81 +0,0 @@ -package stirling.software.proprietary.policy.controller; - -import java.util.List; - -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import io.swagger.v3.oas.annotations.Hidden; -import io.swagger.v3.oas.annotations.Operation; - -import jakarta.servlet.http.HttpServletRequest; - -import lombok.extern.slf4j.Slf4j; - -import stirling.software.proprietary.audit.AuditContext; -import stirling.software.proprietary.classification.ClassificationRunBiller; - -/** - * Meters + audits a client-side (non-AI) classification run so both classify paths bill - * identically. Side-effect only; does no classification itself. - */ -@Slf4j -@Hidden -@RestController -@RequestMapping("/api/v1/policies") -public class ClassificationMeterController { - - /** Audit step label mirrors the AI classify tool so both paths read alike in the trail. */ - private static final String CLASSIFY_STEP = "/api/v1/ai/tools/classify-and-label"; - - /** Client-supplied count cap: the frontend meters one document per call. */ - private static final int MAX_DOCUMENTS = 10_000; - - private final ObjectProvider biller; - - public ClassificationMeterController(ObjectProvider biller) { - this.biller = biller; - } - - @PostMapping("/classify/meter") - @Operation( - summary = "Meter a client-side classification run", - description = - "Records billing + audit for a non-AI classification performed in the browser." - + " Does no classification itself. Dispatched by the frontend, not for" - + " direct use.") - public ResponseEntity meterClassification( - @RequestBody(required = false) ClassifyMeterRequest body, HttpServletRequest request) { - int documents = body != null && body.documentCount() != null ? body.documentCount() : 1; - if (documents < 1) documents = 1; - if (documents > MAX_DOCUMENTS) documents = MAX_DOCUMENTS; - String policyName = - body != null && body.policyName() != null && !body.policyName().isBlank() - ? body.policyName() - : "Classification"; - - // Stamp the run so ControllerAuditAspect records it as a policy run, like the AI path. - request.setAttribute(AuditContext.REQ_ATTR_POLICY_NAME, policyName); - request.setAttribute(AuditContext.REQ_ATTR_POLICY_STEPS, List.of(CLASSIFY_STEP)); - - ClassificationRunBiller runBiller = biller.getIfAvailable(); - if (runBiller != null) { - try { - runBiller.recordClassificationRun(documents); - } catch (RuntimeException e) { - log.warn( - "[classify meter] billing failed; classification proceeds unbilled: {}", - e.getMessage()); - } - } - return ResponseEntity.accepted().build(); - } - - /** Frontend payload: documents classified, plus the policy name for the audit label. */ - public record ClassifyMeterRequest( - String policyName, Integer documentCount, List labels) {} -} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/charge/SaasClassificationRunBiller.java b/app/saas/src/main/java/stirling/software/saas/payg/charge/SaasClassificationRunBiller.java deleted file mode 100644 index a4536d6c23..0000000000 --- a/app/saas/src/main/java/stirling/software/saas/payg/charge/SaasClassificationRunBiller.java +++ /dev/null @@ -1,49 +0,0 @@ -package stirling.software.saas.payg.charge; - -import org.springframework.context.annotation.Profile; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; - -import lombok.RequiredArgsConstructor; - -import stirling.software.proprietary.classification.ClassificationRunBiller; -import stirling.software.proprietary.security.database.repository.UserRepository; -import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; -import stirling.software.proprietary.security.model.User; -import stirling.software.saas.payg.model.BillingCategory; -import stirling.software.saas.payg.model.JobSource; -import stirling.software.saas.payg.model.ProcessType; -import stirling.software.saas.util.AuthenticationUtils; - -/** - * Charges one PAYG unit per document as an AUTOMATION job, matching what a server-side classify - * policy step bills. - */ -@Component -@Profile("saas") -@RequiredArgsConstructor -public class SaasClassificationRunBiller implements ClassificationRunBiller { - - private final UserRepository userRepository; - private final JobChargeService jobChargeService; - - @Override - public void recordClassificationRun(int documentCount) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - User user = AuthenticationUtils.getCurrentUser(auth, userRepository); - if (user == null || user.getTeam() == null) { - return; - } - JobSource source = - auth instanceof ApiKeyAuthenticationToken ? JobSource.API : JobSource.WEB; - ChargeContext ctx = - new ChargeContext( - user.getId(), - user.getTeam().getId(), - source, - ProcessType.AUTOMATION, - BillingCategory.AUTOMATION); - jobChargeService.chargeStandalone(ctx, Math.max(1, documentCount)); - } -} diff --git a/frontend/editor/src/core/services/automationMeter.ts b/frontend/editor/src/core/services/automationMeter.ts index 629e6e07e9..59f86adcb9 100644 --- a/frontend/editor/src/core/services/automationMeter.ts +++ b/frontend/editor/src/core/services/automationMeter.ts @@ -1,4 +1,4 @@ -// Records a completed in-browser Automate run for billing/audit. +// Records a completed in-browser automation run. /** One input document's page count (0 for non-PDF / unknown) and byte size. */ export interface AutomationMeterInput { @@ -12,7 +12,7 @@ export interface AutomationMeterPayload { inputs: AutomationMeterInput[]; } -/** Meter a completed Automate run. Fire-and-forget; never awaited, never throws. */ +/** Meter a completed automation run. Fire-and-forget; never awaited, never throws. */ export function meterAutomationRun(_payload: AutomationMeterPayload): void { // No billing layer in the core build. } diff --git a/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts b/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts index 856ddd58f5..c5a4de919b 100644 --- a/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts @@ -43,7 +43,7 @@ test("a 10-file upload wave classifies every file into its group", async ({ await page.route("**/api/v1/policies", (route) => route.fulfill({ json: [SEEDED_POLICY] }), ); - await page.route("**/api/v1/policies/classify/meter", (route) => + await page.route("**/api/v1/automation/meter", (route) => route.fulfill({ status: 202, body: "" }), ); await page.goto("/editor", { diff --git a/frontend/editor/src/proprietary/components/policies/useClientSideClassification.test.tsx b/frontend/editor/src/proprietary/components/policies/useClientSideClassification.test.tsx index 2149161f59..86db46949c 100644 --- a/frontend/editor/src/proprietary/components/policies/useClientSideClassification.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/useClientSideClassification.test.tsx @@ -73,8 +73,8 @@ vi.mock("@app/services/fileStorage", () => ({ vi.mock("@app/services/heuristic/heuristicClassification", () => ({ classifyFileHeuristically: (file: File) => mocks.classify(file), })); -vi.mock("@app/services/classificationMeter", () => ({ - meterClassificationRun: (payload: unknown) => mocks.meter(payload), +vi.mock("@app/services/automationMeter", () => ({ + meterAutomationRun: (payload: unknown) => mocks.meter(payload), })); import { diff --git a/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts b/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts index 0ae9238b12..252b9a7780 100644 --- a/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts +++ b/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts @@ -10,7 +10,7 @@ import { useClassificationEnabled } from "@app/hooks/useClassificationEnabled"; import { scheduleIdle } from "@app/utils/scheduleIdle"; import { usePolicies } from "@app/hooks/usePolicies"; import { classifyFileHeuristically } from "@app/services/heuristic/heuristicClassification"; -import { meterClassificationRun } from "@app/services/classificationMeter"; +import { meterAutomationRun } from "@app/services/automationMeter"; import { isDispatched, markDispatched, @@ -28,6 +28,9 @@ import { CLASSIFICATION_CATEGORY_ID } from "@app/data/classificationPolicy"; * would tell the auto-run the policy had already run and kill the escalation entirely. */ export const LOCAL_METER_CATEGORY = `${CLASSIFICATION_CATEGORY_ID}:local-meter`; +/** Audit step label for a metered classify run; mirrors the AI classify tool so both paths read + * alike in the trail. */ +const CLASSIFY_STEP = "/api/v1/ai/tools/classify-and-label"; /** Files classified per idle pass, so a large library drains over several ticks. */ const CLASSIFY_BATCH = 3; /** How long to wait for an upload's bytes to land in IndexedDB (20 × 250ms ≈ 5s). @@ -108,6 +111,7 @@ export function useClientSideClassification(): void { stub.id, stub.name, stub.size ?? 0, + stub.processedFile?.totalPages ?? 0, ); // Bytes never landed (file removed mid-wait): leave undelivered so a // reload (or new version) retries; the claim stops churn this session. @@ -149,6 +153,7 @@ async function classifyStub( fileId: FileId, fileName: string, fileSize: number, + pageCount: number, ): Promise<{ labels: string[]; confidence: HeuristicConfidence } | null> { let file: StirlingFile | null = null; for (let i = 0; i < FILE_WAIT_TRIES; i++) { @@ -202,10 +207,10 @@ async function classifyStub( // Meter on the first classification only; a healing re-run of an undelivered // result (already dispatched) is not a new billable run. if (!alreadyMetered) { - meterClassificationRun({ - policyName: "Classification", - documentCount: 1, - labels, + meterAutomationRun({ + automationName: "Classification", + operations: [CLASSIFY_STEP], + inputs: [{ pages: pageCount, bytes: fileSize }], }); } markDispatched(LOCAL_METER_CATEGORY, fileId); diff --git a/frontend/editor/src/proprietary/services/automationMeter.ts b/frontend/editor/src/proprietary/services/automationMeter.ts index 938c0c7111..e2139e524c 100644 --- a/frontend/editor/src/proprietary/services/automationMeter.ts +++ b/frontend/editor/src/proprietary/services/automationMeter.ts @@ -1,4 +1,4 @@ -// Meters an in-browser Automate run for billing/audit. +// Meters an in-browser automation run. import apiClient from "@app/services/apiClient"; import { type AutomationMeterPayload } from "@core/services/automationMeter"; @@ -10,7 +10,7 @@ export { export function meterAutomationRun(payload: AutomationMeterPayload): void { void apiClient - .post(`/api/v1/automate/meter`, payload, { suppressErrorToast: true }) + .post(`/api/v1/automation/meter`, payload, { suppressErrorToast: true }) .catch(() => { // Best-effort billing; the automation already succeeded in the browser. }); diff --git a/frontend/editor/src/proprietary/services/classificationMeter.ts b/frontend/editor/src/proprietary/services/classificationMeter.ts deleted file mode 100644 index 036b7ef242..0000000000 --- a/frontend/editor/src/proprietary/services/classificationMeter.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Meters an in-browser (non-AI) classification run for billing/audit parity with -// the server-side classify path. Fire-and-forget; failures never block the user. - -import apiClient from "@app/services/apiClient"; -import { getPolicyOutputBaseUrl } from "@app/services/policyOutputBaseUrl"; -import { resolvePolicyRunTarget } from "@app/services/policyApi"; - -interface ClassifyMeterPayload { - /** Policy name for the audit-trail label; defaults to "Classification" server-side. */ - policyName?: string; - /** Documents covered by this meter call (defaults to 1 server-side). */ - documentCount?: number; - /** Resolved labels, carried for the audit record. */ - labels?: string[]; -} - -/** Meter a completed client-side classification. Does not throw and is not awaited by callers. */ -export function meterClassificationRun(payload: ClassifyMeterPayload): void { - const base = getPolicyOutputBaseUrl(resolvePolicyRunTarget()); - void apiClient - .post(`${base}/api/v1/policies/classify/meter`, payload, { - suppressErrorToast: true, - }) - .catch(() => { - // Best-effort billing; the classification already succeeded in the browser. - }); -}