@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.
@@ -1,4 +1,5 @@
1
1
  import * as grpc from "@grpc/grpc-js";
2
+ import { type ReportUsage } from "./usage.js";
2
3
  /**
3
4
  * The agent entry point. Reads `params` / `config` / `onProgress` from globals
4
5
  * (populated per-request by {@link serve}) and returns the structured result.
@@ -22,7 +23,94 @@ export interface ServeOptions {
22
23
  host?: string;
23
24
  /** Override the auth check (e.g. in tests). Defaults to {@link defaultAuthenticate}. */
24
25
  authenticate?: Authenticator;
26
+ /** Run-once mode only: override the envelope fetch (e.g. in tests). */
27
+ fetchEnvelope?: (batchJobId: string, token: string) => Promise<any>;
28
+ /** Run-once mode only: override the run lifecycle (e.g. in tests). */
29
+ lifecycle?: RunLifecycle;
30
+ /** Run-once mode only: override process exit (e.g. in tests). */
31
+ exit?: (code: number) => void;
32
+ /** Override how what the work used is reported to the platform (e.g. in tests). */
33
+ reportUsage?: ReportUsage;
34
+ /** Override how often running work reports what it used so far (e.g. in tests). */
35
+ usageReportIntervalMs?: number;
25
36
  }
37
+ /**
38
+ * How an execution reports its own run to the platform: it claims the run
39
+ * before doing the work, says it is alive while it does, and settles it with
40
+ * the outcome. Every call carries the execution's AGENT token, which the
41
+ * platform minted for this run and which names it — which run is never the
42
+ * execution's to assert.
43
+ */
44
+ export interface RunLifecycle {
45
+ claim: (token: string) => Promise<{
46
+ claimed: boolean;
47
+ }>;
48
+ heartbeat: (token: string) => Promise<void>;
49
+ settle: (status: number, outcome: unknown, token: string) => Promise<{
50
+ settled: boolean;
51
+ }>;
52
+ }
53
+ /**
54
+ * Environment a Cloud Run JOB execution is started with by the platform
55
+ * (stackfactor-batch-jobs, agentJobExecutions.ts): references only — the
56
+ * event record, the integration, the tenant, and a token minted for this
57
+ * execution alone. Their presence is what switches {@link serve} into run-once
58
+ * mode; the payload and the tenant's configuration are fetched, never passed.
59
+ */
60
+ export declare const EXECUTION_ENV: {
61
+ readonly BATCH_JOB_ID: "SF_EXECUTION_BATCH_JOB_ID";
62
+ readonly INTEGRATION_ID: "SF_EXECUTION_INTEGRATION_ID";
63
+ readonly TENANT_ID: "SF_EXECUTION_TENANT_ID";
64
+ readonly TOKEN: "SF_EXECUTION_TOKEN";
65
+ };
66
+ /** True when this process was started as a job execution rather than a service. */
67
+ export declare const isRunOnceExecution: (env?: NodeJS.ProcessEnv) => boolean;
68
+ /**
69
+ * True for the one kind of run that is ALLOWED to carry no StackFactor token: a
70
+ * delivery to a webhook declared `requireToken: false`. There the platform
71
+ * authenticates nobody and passes the provider's request to the integration as
72
+ * it arrived, so there is no token to validate and no session to build — the
73
+ * handler verifies its provider itself, and brings its own API key (from its
74
+ * configuration) if it has to act on the platform.
75
+ *
76
+ * This does not open the agent up. The Execute RPC is reachable only by callers
77
+ * holding Cloud Run's invoker role — the backend and the batch worker — so a
78
+ * request with no token means the platform CHOSE to send none, and it only ever
79
+ * does that for such a webhook. A token that IS present is always validated,
80
+ * whatever the event, and every other event still requires one.
81
+ */
82
+ export declare const isAnonymousWebhookRun: (event: any, request: any) => boolean;
83
+ /**
84
+ * True for the runs that are ALLOWED to carry no StackFactor token, because
85
+ * the platform starts them with no identity on purpose:
86
+ * - a delivery to a `requireToken: false` webhook (isAnonymousWebhookRun);
87
+ * - a scheduled run: the clock is nobody.
88
+ * Both run with `session: null`. Since backend-core's decision record §11 the
89
+ * platform starts both with a token of their own (the integration's managed
90
+ * account in the tenant), so against a current platform neither is ever
91
+ * tokenless and this answers false; it stays for a platform that predates
92
+ * that, and can go once none does. The reasoning of isAnonymousWebhookRun holds
93
+ * for both: only the platform can reach Execute or start an execution, so no
94
+ * token means the platform chose to send none. A token that IS present is
95
+ * always validated, and every other event still requires one.
96
+ */
97
+ export declare const isAnonymousRun: (event: any, request: any) => boolean;
98
+ /**
99
+ * Run-once mode: the same agent, started as one Cloud Run JOB execution for
100
+ * one platform event (`runAs: job`). Fetches the execution's envelope from the
101
+ * backend with the execution's token — exactly what a service call would have
102
+ * received in its Execute request — binds the same {@link RequestContext} the
103
+ * gRPC path binds, runs the handler once, and exits: 0 on success, 1 on any
104
+ * failure so Cloud Run retries the task. SIGTERM (Cloud Run cancelling the
105
+ * execution or its task timeout) aborts the in-flight work.
106
+ *
107
+ * The execution reports its own run: it claims it before doing the work (an
108
+ * execution that loses the claim exits 0 without repeating it), says it is
109
+ * alive while it runs, and settles it with the outcome. The handler is
110
+ * written once and does not know which runtime it is on, nor that any of this
111
+ * is happening.
112
+ */
113
+ export declare const runOnce: (main: AgentMain, options?: ServeOptions) => Promise<void>;
26
114
  /**
27
115
  * Start a gRPC server that exposes `main` as the fleet-standard `Agent.Execute`
28
116
  * RPC. Per request it binds a {@link RequestContext} (so the agent's globals are
@@ -1 +1 @@
1
- {"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../../src/serve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,IAAI,MAAM,eAAe,CAAC;AA+PtC;;;;;GAKG;AACH,MAAM,MAAM,SAAS,GAAG,CAAC,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC;AAE/D;;;;GAIG;AACH,MAAM,MAAM,aAAa,GAAG,CAC1B,OAAO,EAAE,GAAG,KACT,OAAO,CAAC;IAAE,IAAI,EAAE,GAAG,CAAC;IAAC,MAAM,EAAE,GAAG,CAAA;CAAE,CAAC,CAAC;AAEzC,MAAM,WAAW,YAAY;IAC3B,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,2CAA2C;IAC3C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wFAAwF;IACxF,YAAY,CAAC,EAAE,aAAa,CAAC;CAC9B;AAyHD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,KAAK,GAChB,MAAM,SAAS,EACf,UAAS,YAAiB,KACzB,IAAI,CAAC,MA8MP,CAAC"}
1
+ {"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../../src/serve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,IAAI,MAAM,eAAe,CAAC;AAWtC,OAAO,EAIL,KAAK,WAAW,EACjB,MAAM,YAAY,CAAC;AAwPpB;;;;;GAKG;AACH,MAAM,MAAM,SAAS,GAAG,CAAC,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC;AAE/D;;;;GAIG;AACH,MAAM,MAAM,aAAa,GAAG,CAC1B,OAAO,EAAE,GAAG,KACT,OAAO,CAAC;IAAE,IAAI,EAAE,GAAG,CAAC;IAAC,MAAM,EAAE,GAAG,CAAA;CAAE,CAAC,CAAC;AAEzC,MAAM,WAAW,YAAY;IAC3B,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,2CAA2C;IAC3C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wFAAwF;IACxF,YAAY,CAAC,EAAE,aAAa,CAAC;IAC7B,uEAAuE;IACvE,aAAa,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC;IACpE,sEAAsE;IACtE,SAAS,CAAC,EAAE,YAAY,CAAC;IACzB,iEAAiE;IACjE,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC9B,mFAAmF;IACnF,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,mFAAmF;IACnF,qBAAqB,CAAC,EAAE,MAAM,CAAC;CAChC;AAED;;;;;;GAMG;AACH,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;IACxD,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C,MAAM,EAAE,CACN,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,MAAM,KACV,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;CACpC;AAKD;;;;;;GAMG;AACH,eAAO,MAAM,aAAa;;;;;CAKhB,CAAC;AAEX,mFAAmF;AACnF,eAAO,MAAM,kBAAkB,GAAI,MAAK,MAAM,CAAC,UAAwB,KAAG,OACJ,CAAC;AAKvE;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,qBAAqB,GAAI,OAAO,GAAG,EAAE,SAAS,GAAG,KAAG,OAEhB,CAAC;AAKlD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,cAAc,GAAI,OAAO,GAAG,EAAE,SAAS,GAAG,KAAG,OAGN,CAAC;AA4JrD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,OAAO,GAClB,MAAM,SAAS,EACf,UAAS,YAAiB,KACzB,OAAO,CAAC,IAAI,CA8Id,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,KAAK,GAChB,MAAM,SAAS,EACf,UAAS,YAAiB,KACzB,IAAI,CAAC,MAyOP,CAAC"}
package/dist/esm/serve.js CHANGED
@@ -2,10 +2,11 @@ import * as grpc from "@grpc/grpc-js";
2
2
  import { existsSync } from "node:fs";
3
3
  import { resolve, relative, isAbsolute } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
- import { session as clientSession } from "@stackfactor/client-api";
5
+ import { integration as clientIntegration, session as clientSession, } from "@stackfactor/client-api";
6
6
  import logger from "./logger.js";
7
7
  import { loadAgentService } from "./agentProto.js";
8
8
  import { runWithContext, installGlobals } from "./runtimeContext.js";
9
+ import { UsageLedger, reportUsage, reportUsageWhileRunning, } from "./usage.js";
9
10
  // Genuine dynamic `import()` that survives TypeScript's CJS downlevelling (which
10
11
  // would otherwise turn `import()` into `require()` and break file:// URLs and
11
12
  // ESM check modules). Built once; see the call site in runSelfCheck.
@@ -195,6 +196,60 @@ const runSelfCheck = async (checkCode, config, session) => {
195
196
  clearTimeout(timer);
196
197
  }
197
198
  };
199
+ /** How often a running execution says it is still alive. */
200
+ const HEARTBEAT_MS = 30_000;
201
+ /**
202
+ * Environment a Cloud Run JOB execution is started with by the platform
203
+ * (stackfactor-batch-jobs, agentJobExecutions.ts): references only — the
204
+ * event record, the integration, the tenant, and a token minted for this
205
+ * execution alone. Their presence is what switches {@link serve} into run-once
206
+ * mode; the payload and the tenant's configuration are fetched, never passed.
207
+ */
208
+ export const EXECUTION_ENV = {
209
+ BATCH_JOB_ID: "SF_EXECUTION_BATCH_JOB_ID",
210
+ INTEGRATION_ID: "SF_EXECUTION_INTEGRATION_ID",
211
+ TENANT_ID: "SF_EXECUTION_TENANT_ID",
212
+ TOKEN: "SF_EXECUTION_TOKEN",
213
+ };
214
+ /** True when this process was started as a job execution rather than a service. */
215
+ export const isRunOnceExecution = (env = process.env) => Boolean(env[EXECUTION_ENV.BATCH_JOB_ID] && env[EXECUTION_ENV.TOKEN]);
216
+ /** `ExecuteRequest.event` of an inbound webhook delivery (backend-core EVENT_TYPE.WEBHOOK_RECEIVED). */
217
+ const WEBHOOK_RECEIVED = 350;
218
+ /**
219
+ * True for the one kind of run that is ALLOWED to carry no StackFactor token: a
220
+ * delivery to a webhook declared `requireToken: false`. There the platform
221
+ * authenticates nobody and passes the provider's request to the integration as
222
+ * it arrived, so there is no token to validate and no session to build — the
223
+ * handler verifies its provider itself, and brings its own API key (from its
224
+ * configuration) if it has to act on the platform.
225
+ *
226
+ * This does not open the agent up. The Execute RPC is reachable only by callers
227
+ * holding Cloud Run's invoker role — the backend and the batch worker — so a
228
+ * request with no token means the platform CHOSE to send none, and it only ever
229
+ * does that for such a webhook. A token that IS present is always validated,
230
+ * whatever the event, and every other event still requires one.
231
+ */
232
+ export const isAnonymousWebhookRun = (event, request) => Number(event) === WEBHOOK_RECEIVED &&
233
+ !(request?.authToken ?? request?.authorization);
234
+ /** `ExecuteRequest.event` of a scheduled run (backend-core EVENT_TYPE.SCHEDULED_RUN). */
235
+ const SCHEDULED_RUN = 510;
236
+ /**
237
+ * True for the runs that are ALLOWED to carry no StackFactor token, because
238
+ * the platform starts them with no identity on purpose:
239
+ * - a delivery to a `requireToken: false` webhook (isAnonymousWebhookRun);
240
+ * - a scheduled run: the clock is nobody.
241
+ * Both run with `session: null`. Since backend-core's decision record §11 the
242
+ * platform starts both with a token of their own (the integration's managed
243
+ * account in the tenant), so against a current platform neither is ever
244
+ * tokenless and this answers false; it stays for a platform that predates
245
+ * that, and can go once none does. The reasoning of isAnonymousWebhookRun holds
246
+ * for both: only the platform can reach Execute or start an execution, so no
247
+ * token means the platform chose to send none. A token that IS present is
248
+ * always validated, and every other event still requires one.
249
+ */
250
+ export const isAnonymousRun = (event, request) => isAnonymousWebhookRun(event, request) ||
251
+ (Number(event) === SCHEDULED_RUN &&
252
+ !(request?.authToken ?? request?.authorization));
198
253
  /** Marks an auth failure so the handler can map it to gRPC UNAUTHENTICATED. */
199
254
  class UnauthenticatedError extends Error {
200
255
  }
@@ -297,6 +352,149 @@ const toErrorFrame = (error) => {
297
352
  }
298
353
  return { code: 500, message: String(error?.message ?? error) };
299
354
  };
355
+ /** How the platform records a settled run (backend-core BATCH_JOB.STATUS). */
356
+ const RUN_STATUS = { COMPLETED: 2, FAILED: 3 };
357
+ /**
358
+ * Settles the run this execution claimed, if it claimed one. Never throws and
359
+ * never changes the execution's own outcome: the work either happened or it
360
+ * did not, and a platform that cannot be told so right now will time the run
361
+ * out on its own. `settled: false` means it already did.
362
+ */
363
+ const settleRun = async (claimed, lifecycle, status, outcome, batchJobId) => {
364
+ if (!claimed)
365
+ return;
366
+ try {
367
+ const answer = await lifecycle.settle(status, outcome ?? null, claimed.token);
368
+ if (!answer?.settled) {
369
+ logger.log(null, logger.levels.warn, `run-once execution for event record ${batchJobId}: the run was no longer running, so its outcome was not recorded`);
370
+ }
371
+ }
372
+ catch (error) {
373
+ logger.log(null, logger.levels.warn, `run-once execution for event record ${batchJobId}: settling the run failed: ${error?.message ?? error}`);
374
+ }
375
+ };
376
+ /**
377
+ * Run-once mode: the same agent, started as one Cloud Run JOB execution for
378
+ * one platform event (`runAs: job`). Fetches the execution's envelope from the
379
+ * backend with the execution's token — exactly what a service call would have
380
+ * received in its Execute request — binds the same {@link RequestContext} the
381
+ * gRPC path binds, runs the handler once, and exits: 0 on success, 1 on any
382
+ * failure so Cloud Run retries the task. SIGTERM (Cloud Run cancelling the
383
+ * execution or its task timeout) aborts the in-flight work.
384
+ *
385
+ * The execution reports its own run: it claims it before doing the work (an
386
+ * execution that loses the claim exits 0 without repeating it), says it is
387
+ * alive while it runs, and settles it with the outcome. The handler is
388
+ * written once and does not know which runtime it is on, nor that any of this
389
+ * is happening.
390
+ */
391
+ export const runOnce = async (main, options = {}) => {
392
+ installGlobals();
393
+ const exit = options.exit ?? ((code) => process.exit(code));
394
+ const batchJobId = process.env[EXECUTION_ENV.BATCH_JOB_ID] ?? "";
395
+ const token = process.env[EXECUTION_ENV.TOKEN] ?? "";
396
+ const fetchEnvelope = options.fetchEnvelope ??
397
+ ((id, tok) => clientIntegration.getExecutionEnvelope(id, tok));
398
+ const authenticate = options.authenticate ?? defaultAuthenticate;
399
+ const abort = new AbortController();
400
+ const onSignal = () => abort.abort();
401
+ process.once("SIGTERM", onSignal);
402
+ process.once("SIGINT", onSignal);
403
+ const lifecycle = options.lifecycle ?? {
404
+ claim: clientIntegration.claimExecution,
405
+ heartbeat: clientIntegration.heartbeatExecution,
406
+ settle: clientIntegration.settleExecution,
407
+ };
408
+ // Set once the run is ours: only an execution that claimed a run settles it.
409
+ let claimed = null;
410
+ let heartbeat = null;
411
+ // What the run used is reported before it is settled, however it ended:
412
+ // settling revokes the token the report is made with.
413
+ let reportWhatWasUsed = async () => { };
414
+ let stopReporting = () => { };
415
+ try {
416
+ const req = await fetchEnvelope(batchJobId, token);
417
+ const request = safeParse(req?.request_json, {});
418
+ // A scheduled run carries no identity by design: see isAnonymousRun.
419
+ const session = isAnonymousRun(req?.event, request)
420
+ ? null
421
+ : await authenticate(request);
422
+ const context = {
423
+ config: safeParse(req?.config_json, {}),
424
+ params: {
425
+ contentType: req?.content_type,
426
+ data: safeParse(req?.data_json, {}),
427
+ },
428
+ request: { ...request, session },
429
+ event: req?.event ?? 0,
430
+ onProgress: (update) => {
431
+ logger.log(null, logger.levels.info, `progress ${Math.round(update?.progress ?? 0)}%: ${update?.message ?? ""}`);
432
+ },
433
+ signal: abort.signal,
434
+ session,
435
+ usage: new UsageLedger(),
436
+ };
437
+ reportWhatWasUsed = () => reportUsage(context, { report: options.reportUsage, last: true });
438
+ let entry = main;
439
+ const entryPath = String(req?.code ?? "").trim();
440
+ if (entryPath) {
441
+ const loaded = await loadAgentModule(entryPath, "handler");
442
+ if (loaded.reason) {
443
+ throw new Error(`entry module "${entryPath}" could not be loaded (${loaded.reason}): ${loaded.detail}`);
444
+ }
445
+ entry = loaded.fn;
446
+ }
447
+ // Claim before doing anything. Of however many executions the platform may
448
+ // start for one run — a retried task, a redelivered message — exactly one
449
+ // wins; the others exit 0 without repeating the work, which is not a
450
+ // failure and must not be retried. The execution's token names its run
451
+ // (every run, scheduled ones included, is started with one), so nothing
452
+ // else is sent.
453
+ const answer = await lifecycle.claim(token);
454
+ if (!answer?.claimed) {
455
+ logger.log(null, logger.levels.info, `run-once execution for event record ${batchJobId}: another execution already claimed this run, nothing to do`);
456
+ exit(0);
457
+ return;
458
+ }
459
+ claimed = { token };
460
+ heartbeat = setInterval(() => {
461
+ void lifecycle
462
+ .heartbeat(token)
463
+ .catch((error) => logger.log(null, logger.levels.warn, `run-once execution for event record ${batchJobId}: heartbeat failed: ${error?.message ?? error}`));
464
+ }, HEARTBEAT_MS);
465
+ heartbeat.unref?.();
466
+ stopReporting = reportUsageWhileRunning(context, {
467
+ report: options.reportUsage,
468
+ intervalMs: options.usageReportIntervalMs,
469
+ });
470
+ logger.log(null, logger.levels.info, `run-once execution for event record ${batchJobId} (${req?.content_type ?? ""}) starting`);
471
+ const result = await runWithContext(context, () => entry(abort.signal));
472
+ logger.log(null, logger.levels.info, `run-once execution for event record ${batchJobId} completed: ${JSON.stringify(result ?? null).slice(0, 500)}`);
473
+ stopReporting();
474
+ await reportWhatWasUsed();
475
+ await settleRun(claimed, lifecycle, RUN_STATUS.COMPLETED, result, batchJobId);
476
+ exit(0);
477
+ }
478
+ catch (error) {
479
+ const frame = toErrorFrame(error);
480
+ logger.log(null, logger.levels.error, `run-once execution for event record ${batchJobId} failed${abort.signal.aborted ? " (aborted)" : ""}: ${frame.code} ${frame.message}`);
481
+ // Work that failed still used what it used.
482
+ stopReporting();
483
+ await reportWhatWasUsed();
484
+ // The run is ours to close whether it went well or not: a run left
485
+ // running is one the platform has to time out, and its credential lives
486
+ // until it does.
487
+ await settleRun(claimed, lifecycle, RUN_STATUS.FAILED, frame, batchJobId);
488
+ exit(1);
489
+ }
490
+ finally {
491
+ stopReporting();
492
+ if (heartbeat)
493
+ clearInterval(heartbeat);
494
+ process.off("SIGTERM", onSignal);
495
+ process.off("SIGINT", onSignal);
496
+ }
497
+ };
300
498
  /**
301
499
  * Start a gRPC server that exposes `main` as the fleet-standard `Agent.Execute`
302
500
  * RPC. Per request it binds a {@link RequestContext} (so the agent's globals are
@@ -309,6 +507,12 @@ const toErrorFrame = (error) => {
309
507
  * serve(require("./src/main"));
310
508
  */
311
509
  export const serve = (main, options = {}) => {
510
+ // Started as a job execution: run the handler once and exit instead of
511
+ // serving. Same image, same entrypoint; the environment decides.
512
+ if (isRunOnceExecution()) {
513
+ void runOnce(main, options);
514
+ return new grpc.Server();
515
+ }
312
516
  installGlobals();
313
517
  const port = options.port ?? (Number(process.env.PORT) || 8080);
314
518
  const host = options.host ?? "0.0.0.0";
@@ -331,9 +535,13 @@ export const serve = (main, options = {}) => {
331
535
  };
332
536
  const handle = async () => {
333
537
  // Authenticate first — refuse the call when the token is missing/invalid.
334
- let session;
538
+ // The exceptions carry no token by design and run with no session: see
539
+ // isAnonymousRun.
540
+ let session = null;
335
541
  try {
336
- session = await authenticate(request);
542
+ if (!isAnonymousRun(req.event, request)) {
543
+ session = await authenticate(request);
544
+ }
337
545
  }
338
546
  catch (error) {
339
547
  const message = error?.message ?? "Unauthenticated";
@@ -358,6 +566,7 @@ export const serve = (main, options = {}) => {
358
566
  onProgress,
359
567
  signal: abort.signal,
360
568
  session,
569
+ usage: new UsageLedger(),
361
570
  };
362
571
  // Which function actually runs. Default is the agent's own `main` — the
363
572
  // path every caller took before `code` existed, and still the path taken
@@ -388,8 +597,21 @@ export const serve = (main, options = {}) => {
388
597
  }
389
598
  entry = loaded.fn;
390
599
  }
600
+ // What the call used is reported before it is answered, however it
601
+ // ended — a call that failed, or that its caller gave up on, still used
602
+ // what it used — because the platform revokes the call's token as soon
603
+ // as it has the answer.
604
+ const stopReporting = reportUsageWhileRunning(context, {
605
+ report: options.reportUsage,
606
+ intervalMs: options.usageReportIntervalMs,
607
+ });
608
+ const reportWhatWasUsed = async () => {
609
+ stopReporting();
610
+ await reportUsage(context, { report: options.reportUsage, last: true });
611
+ };
391
612
  try {
392
613
  const result = await runWithContext(context, () => entry(abort.signal));
614
+ await reportWhatWasUsed();
393
615
  if (!abort.signal.aborted) {
394
616
  call.write({
395
617
  result: { result_json: JSON.stringify(result ?? null) },
@@ -397,6 +619,7 @@ export const serve = (main, options = {}) => {
397
619
  }
398
620
  }
399
621
  catch (error) {
622
+ await reportWhatWasUsed();
400
623
  if (!abort.signal.aborted) {
401
624
  logger.log(null, logger.levels.error, `agent Execute failed: ${error?.message ?? error}`);
402
625
  call.write({ error: toErrorFrame(error) });
@@ -86,6 +86,10 @@ declare const _default: {
86
86
  assertTavilyUsable: (config: any, options: TavilyConfig) => void;
87
87
  buildTavilyTools: (config: any, options: TavilyConfig, usageTracker?: UsageTracker | null) => any[];
88
88
  getTavilyOptions: (config: any) => TavilyConfig | null;
89
+ recordTavilyUsage: (tracker: UsageTracker | null | undefined, _config: any, credits: number, sources: {
90
+ url: string;
91
+ title?: string;
92
+ }[]) => void;
89
93
  };
90
94
  export default _default;
91
95
  //# sourceMappingURL=tavily.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"tavily.d.ts","sourceRoot":"","sources":["../../src/tavily.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AA6BnD,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,SAAS,GAAG,OAAO,GAAG,KAAK,CAAC;AAEpE;;;;;;;;;GASG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,cAAc,EAAE,CAAC;IACzB,iEAAiE;IACjE,WAAW,CAAC,EAAE,OAAO,GAAG,UAAU,GAAG,MAAM,GAAG,YAAY,CAAC;IAC3D,kFAAkF;IAClF,YAAY,CAAC,EAAE,OAAO,GAAG,UAAU,CAAC;IACpC,2CAA2C;IAC3C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,oEAAoE;IACpE,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,qEAAqE;IACrE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,4CAA4C;IAC5C,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,6CAA6C;IAC7C,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,uEAAuE;IACvE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,MAAM,CAAC,EAAE,UAAU,GAAG,MAAM,CAAC;CAC9B,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB,GAAI,QAAQ,GAAG,KAAG,YAAY,GAAG,IAI7D,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,GAAI,QAAQ,GAAG,EAAE,SAAS,YAAY,KAAG,IAsBvE,CAAC;AAqLF;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,gBAAgB,GAC3B,QAAQ,GAAG,EACX,SAAS,YAAY,EACrB,eAAc,YAAY,GAAG,IAAW,KACvC,GAAG,EA2PL,CAAC;;iCA3dyC,GAAG,WAAW,YAAY,KAAG,IAAI;+BA6NlE,GAAG,WACF,YAAY,iBACP,YAAY,GAAG,IAAI,KAChC,GAAG,EAAE;+BA/OiC,GAAG,KAAG,YAAY,GAAG,IAAI;;AA4elE,wBAIE"}
1
+ {"version":3,"file":"tavily.d.ts","sourceRoot":"","sources":["../../src/tavily.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AA0BnD,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,SAAS,GAAG,OAAO,GAAG,KAAK,CAAC;AAEpE;;;;;;;;;GASG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,cAAc,EAAE,CAAC;IACzB,iEAAiE;IACjE,WAAW,CAAC,EAAE,OAAO,GAAG,UAAU,GAAG,MAAM,GAAG,YAAY,CAAC;IAC3D,kFAAkF;IAClF,YAAY,CAAC,EAAE,OAAO,GAAG,UAAU,CAAC;IACpC,2CAA2C;IAC3C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,oEAAoE;IACpE,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,qEAAqE;IACrE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,4CAA4C;IAC5C,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,6CAA6C;IAC7C,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,uEAAuE;IACvE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,MAAM,CAAC,EAAE,UAAU,GAAG,MAAM,CAAC;CAC9B,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB,GAAI,QAAQ,GAAG,KAAG,YAAY,GAAG,IAI7D,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,GAAI,QAAQ,GAAG,EAAE,SAAS,YAAY,KAAG,IAsBvE,CAAC;AAsLF;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,gBAAgB,GAC3B,QAAQ,GAAG,EACX,SAAS,YAAY,EACrB,eAAc,YAAY,GAAG,IAAW,KACvC,GAAG,EA2PL,CAAC;;iCA5dyC,GAAG,WAAW,YAAY,KAAG,IAAI;+BA8NlE,GAAG,WACF,YAAY,iBACP,YAAY,GAAG,IAAI,KAChC,GAAG,EAAE;+BAhPiC,GAAG,KAAG,YAAY,GAAG,IAAI;iCAsIvD,YAAY,GAAG,IAAI,GAAG,SAAS,WAC/B,GAAG,WACH,MAAM,WACN;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,KACzC,IAAI;;AAmWP,wBAKE"}
@@ -4,9 +4,8 @@ import constants from "./const.js";
4
4
  import errorHandlingHelper from "./errorHandling.js";
5
5
  import logger from "./logger.js";
6
6
  import { getAbortSignal } from "./runtimeContext.js";
7
+ import { recordUsage } from "./usage.js";
7
8
  const TAVILY_API_BASE = "https://api.tavily.com";
8
- /** Tavily list price per credit, used when the integration defines no rate. */
9
- const DEFAULT_CREDIT_COST_USD = 0.008;
10
9
  /**
11
10
  * Context budgets, in characters, applied to everything a tool hands back to
12
11
  * the model. Tavily returns whatever the page holds, and unlike a provider's
@@ -121,19 +120,25 @@ const callTavily = async (endpoint, body, apiKey, timeoutSeconds) => {
121
120
  throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, `Tavily ${endpoint} returned ${response.status}: ${detail.slice(0, 500)}`);
122
121
  }
123
122
  };
123
+ /** The name Tavily's credits are counted and priced under. */
124
+ const TAVILY_MODEL = "tavily";
124
125
  /**
125
- * Adds one Tavily call's credits to the tracker and records the URLs it
126
- * returned. Credits come from the response's own `usage` block, priced with the
127
- * `tavily-credit-costs` constant (USD per credit) and accumulated under
128
- * `tavilyCredits`. Sources land on `tracker.webSearchSources`, deduplicated by
129
- * URL across the whole run, so callers have the citation list Tavily's terms
130
- * require when this output is shown to end users.
131
- * @param tracker - The caller's usage accumulator; no-ops when absent
132
- * @param config - Config object carrying the `tavily-credit-costs` constant
126
+ * Counts one Tavily call's credits and records the URLs it returned. Credits
127
+ * come from the response's own `usage` block; they are counted into the
128
+ * request's usage ledger, which is reported to the platform, and into the
129
+ * caller's tracker under `tavilyCredits`. Sources land on
130
+ * `tracker.webSearchSources`, deduplicated by URL across the whole run, so
131
+ * callers have the citation list Tavily's terms require when this output is
132
+ * shown to end users.
133
+ * @param tracker - The caller's usage accumulator, when it passed one
134
+ * @param _config - No longer read: it carried the rate, which is the platform's to say
133
135
  * @param credits - Credits reported by the Tavily response
134
136
  * @param sources - URLs (and titles where known) the call returned
135
137
  */
136
- const recordTavilyUsage = (tracker, config, credits, sources) => {
138
+ const recordTavilyUsage = (tracker, _config, credits, sources) => {
139
+ if (Number.isFinite(credits) && credits > 0) {
140
+ recordUsage(TAVILY_MODEL, "credits", credits);
141
+ }
137
142
  if (!tracker)
138
143
  return;
139
144
  if (typeof tracker.cost !== "number")
@@ -141,13 +146,6 @@ const recordTavilyUsage = (tracker, config, credits, sources) => {
141
146
  if (!tracker.tokens || typeof tracker.tokens !== "object")
142
147
  tracker.tokens = {};
143
148
  if (Number.isFinite(credits) && credits > 0) {
144
- const configured = Number(config?.["tavily-credit-costs"]);
145
- const rate = Number.isFinite(configured)
146
- ? configured
147
- : DEFAULT_CREDIT_COST_USD;
148
- const addedCost = credits * rate;
149
- if (Number.isFinite(addedCost) && addedCost > 0)
150
- tracker.cost += addedCost;
151
149
  tracker.tokens.tavilyCredits =
152
150
  (tracker.tokens.tavilyCredits || 0) + credits;
153
151
  }
@@ -392,4 +390,5 @@ export default {
392
390
  assertTavilyUsable,
393
391
  buildTavilyTools,
394
392
  getTavilyOptions,
393
+ recordTavilyUsage,
395
394
  };
@@ -0,0 +1,74 @@
1
+ import { type RequestContext } from "./runtimeContext.js";
2
+ /**
3
+ * What an agent used, and how the platform gets to know.
4
+ *
5
+ * An agent says WHAT it used — a model, a unit, an amount — and the platform
6
+ * says what that costs, who is charged and what it was for. Everything that
7
+ * calls a model through this package counts what the call used into the
8
+ * request's {@link UsageLedger}; {@link serve} and {@link runOnce} report the
9
+ * ledger to the platform while the work runs and when it ends, whether it
10
+ * ended well or not. An agent does nothing for this to happen.
11
+ *
12
+ * An agent that calls a provider itself — its own SDK, an HTTP request —
13
+ * counts what that used with {@link recordUsage}.
14
+ */
15
+ /** What usage is counted in; the units the platform has rates for. */
16
+ export declare const USAGE_UNITS: readonly ["inputTokens", "outputTokens", "imageInputTokens", "imageOutputTokens", "characters", "seconds", "credits"];
17
+ export type UsageUnit = (typeof USAGE_UNITS)[number];
18
+ export interface UsageEntry {
19
+ model: string;
20
+ unit: UsageUnit;
21
+ amount: number;
22
+ }
23
+ /** Sends one report to the platform. Rejects when the platform did not take it. */
24
+ export type ReportUsage = (usage: UsageEntry[], reportId: string, token: string) => Promise<unknown>;
25
+ /** How often work that is still running reports what it used so far. */
26
+ export declare const USAGE_REPORT_INTERVAL_MS = 60000;
27
+ /**
28
+ * What one request used and has not reported yet, summed by model and unit.
29
+ */
30
+ export declare class UsageLedger {
31
+ private pending;
32
+ /** Adds to what is pending. Anything that is not a positive amount of a known unit is ignored. */
33
+ add(model: string, unit: UsageUnit, amount: number): void;
34
+ /** What is pending, which then no longer is. */
35
+ take(): UsageEntry[];
36
+ /** True when there is nothing to report. */
37
+ get isEmpty(): boolean;
38
+ }
39
+ /**
40
+ * Counts what the running request used. For an agent that calls a provider
41
+ * itself; what is called through `langChain` is counted already. Outside a
42
+ * request there is nothing to count into, and nothing happens.
43
+ * @param model - The model's id, as its provider names it
44
+ * @param unit - What the amount is counted in
45
+ * @param amount - How much
46
+ */
47
+ export declare const recordUsage: (model: string, unit: UsageUnit, amount: number) => void;
48
+ /**
49
+ * Reports what the request used and has not reported yet. Never throws, and
50
+ * never changes what the work answers: the work happened, and a platform that
51
+ * cannot be told what it used right now is told by the next report, or not at
52
+ * all — which is logged as an error, with what was used.
53
+ *
54
+ * @param context - The request whose ledger is reported
55
+ * @param options - `report` sends a report (default: the platform's route);
56
+ * `last` says no other report follows, so what cannot be sent is given up on
57
+ * rather than kept for the next one
58
+ */
59
+ export declare const reportUsage: (context: Pick<RequestContext, "usage" | "request"> | undefined, options?: {
60
+ report?: ReportUsage;
61
+ last?: boolean;
62
+ retryBaseMs?: number;
63
+ }) => Promise<void>;
64
+ /**
65
+ * Reports what the request used so far every {@link USAGE_REPORT_INTERVAL_MS}
66
+ * while the work runs, so work that dies has reported most of what it used.
67
+ * @returns Stops it
68
+ */
69
+ export declare const reportUsageWhileRunning: (context: Pick<RequestContext, "usage" | "request">, options?: {
70
+ report?: ReportUsage;
71
+ intervalMs?: number;
72
+ retryBaseMs?: number;
73
+ }) => (() => void);
74
+ //# sourceMappingURL=usage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"usage.d.ts","sourceRoot":"","sources":["../../src/usage.ts"],"names":[],"mappings":"AAGA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAEtE;;;;;;;;;;;;GAYG;AAEH,sEAAsE;AACtE,eAAO,MAAM,WAAW,uHAQd,CAAC;AAEX,MAAM,MAAM,SAAS,GAAG,CAAC,OAAO,WAAW,CAAC,CAAC,MAAM,CAAC,CAAC;AAErD,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,mFAAmF;AACnF,MAAM,MAAM,WAAW,GAAG,CACxB,KAAK,EAAE,UAAU,EAAE,EACnB,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,KACV,OAAO,CAAC,OAAO,CAAC,CAAC;AAOtB,wEAAwE;AACxE,eAAO,MAAM,wBAAwB,QAAS,CAAC;AAI/C;;GAEG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,OAAO,CAAiC;IAEhD,kGAAkG;IAClG,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI;IAkBzD,gDAAgD;IAChD,IAAI,IAAI,UAAU,EAAE;IAMpB,4CAA4C;IAC5C,IAAI,OAAO,IAAI,OAAO,CAErB;CACF;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,WAAW,GAAI,OAAO,MAAM,EAAE,MAAM,SAAS,EAAE,QAAQ,MAAM,KAAG,IAE5E,CAAC;AA8BF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,WAAW,GACtB,SAAS,IAAI,CAAC,cAAc,EAAE,OAAO,GAAG,SAAS,CAAC,GAAG,SAAS,EAC9D,UAAS;IAAE,MAAM,CAAC,EAAE,WAAW,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAAO,KAC3E,OAAO,CAAC,IAAI,CAuCd,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,GAClC,SAAS,IAAI,CAAC,cAAc,EAAE,OAAO,GAAG,SAAS,CAAC,EAClD,UAAS;IAAE,MAAM,CAAC,EAAE,WAAW,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAAO,KAChF,CAAC,MAAM,IAAI,CAMb,CAAC"}