@stackfactor/agent-utils 1.4.3 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,150 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { integration as clientIntegration } from "@stackfactor/client-api";
3
+ import logger from "./logger.js";
4
+ import { getContext } from "./runtimeContext.js";
5
+ /**
6
+ * What an agent used, and how the platform gets to know.
7
+ *
8
+ * An agent says WHAT it used — a model, a unit, an amount — and the platform
9
+ * says what that costs, who is charged and what it was for. Everything that
10
+ * calls a model through this package counts what the call used into the
11
+ * request's {@link UsageLedger}; {@link serve} and {@link runOnce} report the
12
+ * ledger to the platform while the work runs and when it ends, whether it
13
+ * ended well or not. An agent does nothing for this to happen.
14
+ *
15
+ * An agent that calls a provider itself — its own SDK, an HTTP request —
16
+ * counts what that used with {@link recordUsage}.
17
+ */
18
+ /** What usage is counted in; the units the platform has rates for. */
19
+ export const USAGE_UNITS = [
20
+ "inputTokens",
21
+ "outputTokens",
22
+ "imageInputTokens",
23
+ "imageOutputTokens",
24
+ "characters",
25
+ "seconds",
26
+ "credits",
27
+ ];
28
+ /** Entries one report may carry (the platform's limit). */
29
+ const REPORT_ENTRIES_MAX = 100;
30
+ /** How often a report is tried before it is given up on, and the first wait between tries. */
31
+ const REPORT_ATTEMPTS = 3;
32
+ const REPORT_RETRY_BASE_MS = 500;
33
+ /** How often work that is still running reports what it used so far. */
34
+ export const USAGE_REPORT_INTERVAL_MS = 60_000;
35
+ const keyOf = (model, unit) => `${model}\n${unit}`;
36
+ /**
37
+ * What one request used and has not reported yet, summed by model and unit.
38
+ */
39
+ export class UsageLedger {
40
+ pending = new Map();
41
+ /** Adds to what is pending. Anything that is not a positive amount of a known unit is ignored. */
42
+ add(model, unit, amount) {
43
+ if (typeof model !== "string" ||
44
+ model.trim().length === 0 ||
45
+ !USAGE_UNITS.includes(unit) ||
46
+ typeof amount !== "number" ||
47
+ !Number.isFinite(amount) ||
48
+ amount <= 0) {
49
+ return;
50
+ }
51
+ const name = model.trim();
52
+ const key = keyOf(name, unit);
53
+ const entry = this.pending.get(key);
54
+ if (entry)
55
+ entry.amount += amount;
56
+ else
57
+ this.pending.set(key, { model: name, unit, amount });
58
+ }
59
+ /** What is pending, which then no longer is. */
60
+ take() {
61
+ const entries = [...this.pending.values()];
62
+ this.pending = new Map();
63
+ return entries;
64
+ }
65
+ /** True when there is nothing to report. */
66
+ get isEmpty() {
67
+ return this.pending.size === 0;
68
+ }
69
+ }
70
+ /**
71
+ * Counts what the running request used. For an agent that calls a provider
72
+ * itself; what is called through `langChain` is counted already. Outside a
73
+ * request there is nothing to count into, and nothing happens.
74
+ * @param model - The model's id, as its provider names it
75
+ * @param unit - What the amount is counted in
76
+ * @param amount - How much
77
+ */
78
+ export const recordUsage = (model, unit, amount) => {
79
+ getContext()?.usage?.add(model, unit, amount);
80
+ };
81
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
82
+ const defaultReport = (usage, reportId, token) => clientIntegration.reportRunUsage(usage, reportId, token);
83
+ /**
84
+ * Sends one report, again when it fails, under the same name every time: the
85
+ * platform charges a report it has seen only once.
86
+ */
87
+ const send = async (entries, token, report, retryBaseMs) => {
88
+ const reportId = randomUUID();
89
+ for (let attempt = 1;; attempt++) {
90
+ try {
91
+ await report(entries, reportId, token);
92
+ return;
93
+ }
94
+ catch (error) {
95
+ if (attempt >= REPORT_ATTEMPTS)
96
+ throw error;
97
+ await delay(retryBaseMs * 2 ** (attempt - 1));
98
+ }
99
+ }
100
+ };
101
+ /**
102
+ * Reports what the request used and has not reported yet. Never throws, and
103
+ * never changes what the work answers: the work happened, and a platform that
104
+ * cannot be told what it used right now is told by the next report, or not at
105
+ * all — which is logged as an error, with what was used.
106
+ *
107
+ * @param context - The request whose ledger is reported
108
+ * @param options - `report` sends a report (default: the platform's route);
109
+ * `last` says no other report follows, so what cannot be sent is given up on
110
+ * rather than kept for the next one
111
+ */
112
+ export const reportUsage = async (context, options = {}) => {
113
+ const ledger = context?.usage;
114
+ if (!ledger || ledger.isEmpty)
115
+ return;
116
+ const token = context.request?.authToken ?? context.request?.authorization;
117
+ const entries = ledger.take();
118
+ if (!token) {
119
+ logger.log(null, logger.levels.error, `usage was not reported, the request carries no token: ${JSON.stringify(entries)}`);
120
+ return;
121
+ }
122
+ for (let start = 0; start < entries.length; start += REPORT_ENTRIES_MAX) {
123
+ const chunk = entries.slice(start, start + REPORT_ENTRIES_MAX);
124
+ try {
125
+ await send(chunk, token, options.report ?? defaultReport, options.retryBaseMs ?? REPORT_RETRY_BASE_MS);
126
+ }
127
+ catch (error) {
128
+ if (options.last) {
129
+ logger.log(null, logger.levels.error, `usage could not be reported (${error?.message ?? error}): ${JSON.stringify(chunk)}`);
130
+ }
131
+ else {
132
+ for (const entry of chunk)
133
+ ledger.add(entry.model, entry.unit, entry.amount);
134
+ logger.log(null, logger.levels.warn, `usage could not be reported, it is kept for the next report: ${error?.message ?? error}`);
135
+ }
136
+ }
137
+ }
138
+ };
139
+ /**
140
+ * Reports what the request used so far every {@link USAGE_REPORT_INTERVAL_MS}
141
+ * while the work runs, so work that dies has reported most of what it used.
142
+ * @returns Stops it
143
+ */
144
+ export const reportUsageWhileRunning = (context, options = {}) => {
145
+ const timer = setInterval(() => {
146
+ void reportUsage(context, { report: options.report, retryBaseMs: options.retryBaseMs });
147
+ }, options.intervalMs ?? USAGE_REPORT_INTERVAL_MS);
148
+ timer.unref?.();
149
+ return () => clearInterval(timer);
150
+ };
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.4.3",
6
+ "version": "1.7.0",
7
7
  "description": "",
8
8
  "main": "dist/cjs/index.js",
9
9
  "module": "dist/esm/index.js",
@@ -14,7 +14,7 @@
14
14
  "scripts": {
15
15
  "clean": "rm -rf dist",
16
16
  "build": "npm run clean && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json && echo '{\"type\":\"module\"}' > dist/esm/package.json",
17
- "test": "npm run build && node --test \"test/**/*.test.js\"",
17
+ "test": "npm run build && node --test test/*.test.js",
18
18
  "work:start": "bash scripts/work-start.sh",
19
19
  "work:commit": "bash scripts/work-commit.sh",
20
20
  "work:release": "bash scripts/release.sh",
@@ -52,10 +52,11 @@
52
52
  "@grpc/grpc-js": "^1.14.4",
53
53
  "@grpc/proto-loader": "^0.7.15",
54
54
  "@langchain/anthropic": "^1.3.25",
55
+ "@langchain/core": "^1.2.9",
55
56
  "@langchain/google-genai": "^2.1.26",
56
57
  "@langchain/openai": "^1.3.0",
57
58
  "@opentelemetry/api": "^1.9.0",
58
- "@stackfactor/client-api": "^1.2.1",
59
+ "@stackfactor/client-api": "^1.6.0",
59
60
  "langchain": "^1.2.35",
60
61
  "mongoose": "^9.3.1",
61
62
  "openai": "^6.32.0",
@@ -63,7 +64,11 @@
63
64
  "zod": "^4.3.6"
64
65
  },
65
66
  "devDependencies": {
66
- "@langchain/core": "^1.2.9",
67
67
  "typescript": "^5.9.3"
68
+ },
69
+ "overrides": {
70
+ "mongodb": {
71
+ "gcp-metadata": ">=7.0.1"
72
+ }
68
73
  }
69
74
  }