Unify metering APIs

This commit is contained in:
James Brunton
2026-09-01 15:49:17 +01:00
parent d12be8ea53
commit 4003d78892
11 changed files with 32 additions and 195 deletions
@@ -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<Void> meterAutomationRun(
@RequestBody(required = false) AutomationMeterRequest body,
HttpServletRequest request) {
@@ -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.
*
* <p>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<FileSize> inputs);
}
@@ -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);
}
@@ -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<ClassificationRunBiller> biller;
public ClassificationMeterController(ObjectProvider<ClassificationRunBiller> 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<Void> 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<String> labels) {}
}
@@ -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));
}
}
@@ -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.
}
@@ -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", {
@@ -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 {
@@ -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);
@@ -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.
});
@@ -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.
});
}