mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
Unify metering APIs
This commit is contained in:
+9
-7
@@ -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) {
|
||||
|
||||
+6
-11
@@ -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);
|
||||
}
|
||||
|
||||
-8
@@ -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);
|
||||
}
|
||||
-81
@@ -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) {}
|
||||
}
|
||||
-49
@@ -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", {
|
||||
|
||||
+2
-2
@@ -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.
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user