@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.
package/dist/cjs/serve.js CHANGED
@@ -36,7 +36,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.serve = void 0;
39
+ exports.serve = exports.runOnce = exports.isAnonymousRun = exports.isAnonymousWebhookRun = exports.isRunOnceExecution = exports.EXECUTION_ENV = void 0;
40
40
  const grpc = __importStar(require("@grpc/grpc-js"));
41
41
  const node_fs_1 = require("node:fs");
42
42
  const node_path_1 = require("node:path");
@@ -45,6 +45,7 @@ const client_api_1 = require("@stackfactor/client-api");
45
45
  const logger_js_1 = __importDefault(require("./logger.js"));
46
46
  const agentProto_js_1 = require("./agentProto.js");
47
47
  const runtimeContext_js_1 = require("./runtimeContext.js");
48
+ const usage_js_1 = require("./usage.js");
48
49
  // Genuine dynamic `import()` that survives TypeScript's CJS downlevelling (which
49
50
  // would otherwise turn `import()` into `require()` and break file:// URLs and
50
51
  // ESM check modules). Built once; see the call site in runSelfCheck.
@@ -234,6 +235,63 @@ const runSelfCheck = async (checkCode, config, session) => {
234
235
  clearTimeout(timer);
235
236
  }
236
237
  };
238
+ /** How often a running execution says it is still alive. */
239
+ const HEARTBEAT_MS = 30_000;
240
+ /**
241
+ * Environment a Cloud Run JOB execution is started with by the platform
242
+ * (stackfactor-batch-jobs, agentJobExecutions.ts): references only — the
243
+ * event record, the integration, the tenant, and a token minted for this
244
+ * execution alone. Their presence is what switches {@link serve} into run-once
245
+ * mode; the payload and the tenant's configuration are fetched, never passed.
246
+ */
247
+ exports.EXECUTION_ENV = {
248
+ BATCH_JOB_ID: "SF_EXECUTION_BATCH_JOB_ID",
249
+ INTEGRATION_ID: "SF_EXECUTION_INTEGRATION_ID",
250
+ TENANT_ID: "SF_EXECUTION_TENANT_ID",
251
+ TOKEN: "SF_EXECUTION_TOKEN",
252
+ };
253
+ /** True when this process was started as a job execution rather than a service. */
254
+ const isRunOnceExecution = (env = process.env) => Boolean(env[exports.EXECUTION_ENV.BATCH_JOB_ID] && env[exports.EXECUTION_ENV.TOKEN]);
255
+ exports.isRunOnceExecution = isRunOnceExecution;
256
+ /** `ExecuteRequest.event` of an inbound webhook delivery (backend-core EVENT_TYPE.WEBHOOK_RECEIVED). */
257
+ const WEBHOOK_RECEIVED = 350;
258
+ /**
259
+ * True for the one kind of run that is ALLOWED to carry no StackFactor token: a
260
+ * delivery to a webhook declared `requireToken: false`. There the platform
261
+ * authenticates nobody and passes the provider's request to the integration as
262
+ * it arrived, so there is no token to validate and no session to build — the
263
+ * handler verifies its provider itself, and brings its own API key (from its
264
+ * configuration) if it has to act on the platform.
265
+ *
266
+ * This does not open the agent up. The Execute RPC is reachable only by callers
267
+ * holding Cloud Run's invoker role — the backend and the batch worker — so a
268
+ * request with no token means the platform CHOSE to send none, and it only ever
269
+ * does that for such a webhook. A token that IS present is always validated,
270
+ * whatever the event, and every other event still requires one.
271
+ */
272
+ const isAnonymousWebhookRun = (event, request) => Number(event) === WEBHOOK_RECEIVED &&
273
+ !(request?.authToken ?? request?.authorization);
274
+ exports.isAnonymousWebhookRun = isAnonymousWebhookRun;
275
+ /** `ExecuteRequest.event` of a scheduled run (backend-core EVENT_TYPE.SCHEDULED_RUN). */
276
+ const SCHEDULED_RUN = 510;
277
+ /**
278
+ * True for the runs that are ALLOWED to carry no StackFactor token, because
279
+ * the platform starts them with no identity on purpose:
280
+ * - a delivery to a `requireToken: false` webhook (isAnonymousWebhookRun);
281
+ * - a scheduled run: the clock is nobody.
282
+ * Both run with `session: null`. Since backend-core's decision record §11 the
283
+ * platform starts both with a token of their own (the integration's managed
284
+ * account in the tenant), so against a current platform neither is ever
285
+ * tokenless and this answers false; it stays for a platform that predates
286
+ * that, and can go once none does. The reasoning of isAnonymousWebhookRun holds
287
+ * for both: only the platform can reach Execute or start an execution, so no
288
+ * token means the platform chose to send none. A token that IS present is
289
+ * always validated, and every other event still requires one.
290
+ */
291
+ const isAnonymousRun = (event, request) => (0, exports.isAnonymousWebhookRun)(event, request) ||
292
+ (Number(event) === SCHEDULED_RUN &&
293
+ !(request?.authToken ?? request?.authorization));
294
+ exports.isAnonymousRun = isAnonymousRun;
237
295
  /** Marks an auth failure so the handler can map it to gRPC UNAUTHENTICATED. */
238
296
  class UnauthenticatedError extends Error {
239
297
  }
@@ -336,6 +394,150 @@ const toErrorFrame = (error) => {
336
394
  }
337
395
  return { code: 500, message: String(error?.message ?? error) };
338
396
  };
397
+ /** How the platform records a settled run (backend-core BATCH_JOB.STATUS). */
398
+ const RUN_STATUS = { COMPLETED: 2, FAILED: 3 };
399
+ /**
400
+ * Settles the run this execution claimed, if it claimed one. Never throws and
401
+ * never changes the execution's own outcome: the work either happened or it
402
+ * did not, and a platform that cannot be told so right now will time the run
403
+ * out on its own. `settled: false` means it already did.
404
+ */
405
+ const settleRun = async (claimed, lifecycle, status, outcome, batchJobId) => {
406
+ if (!claimed)
407
+ return;
408
+ try {
409
+ const answer = await lifecycle.settle(status, outcome ?? null, claimed.token);
410
+ if (!answer?.settled) {
411
+ logger_js_1.default.log(null, logger_js_1.default.levels.warn, `run-once execution for event record ${batchJobId}: the run was no longer running, so its outcome was not recorded`);
412
+ }
413
+ }
414
+ catch (error) {
415
+ logger_js_1.default.log(null, logger_js_1.default.levels.warn, `run-once execution for event record ${batchJobId}: settling the run failed: ${error?.message ?? error}`);
416
+ }
417
+ };
418
+ /**
419
+ * Run-once mode: the same agent, started as one Cloud Run JOB execution for
420
+ * one platform event (`runAs: job`). Fetches the execution's envelope from the
421
+ * backend with the execution's token — exactly what a service call would have
422
+ * received in its Execute request — binds the same {@link RequestContext} the
423
+ * gRPC path binds, runs the handler once, and exits: 0 on success, 1 on any
424
+ * failure so Cloud Run retries the task. SIGTERM (Cloud Run cancelling the
425
+ * execution or its task timeout) aborts the in-flight work.
426
+ *
427
+ * The execution reports its own run: it claims it before doing the work (an
428
+ * execution that loses the claim exits 0 without repeating it), says it is
429
+ * alive while it runs, and settles it with the outcome. The handler is
430
+ * written once and does not know which runtime it is on, nor that any of this
431
+ * is happening.
432
+ */
433
+ const runOnce = async (main, options = {}) => {
434
+ (0, runtimeContext_js_1.installGlobals)();
435
+ const exit = options.exit ?? ((code) => process.exit(code));
436
+ const batchJobId = process.env[exports.EXECUTION_ENV.BATCH_JOB_ID] ?? "";
437
+ const token = process.env[exports.EXECUTION_ENV.TOKEN] ?? "";
438
+ const fetchEnvelope = options.fetchEnvelope ??
439
+ ((id, tok) => client_api_1.integration.getExecutionEnvelope(id, tok));
440
+ const authenticate = options.authenticate ?? defaultAuthenticate;
441
+ const abort = new AbortController();
442
+ const onSignal = () => abort.abort();
443
+ process.once("SIGTERM", onSignal);
444
+ process.once("SIGINT", onSignal);
445
+ const lifecycle = options.lifecycle ?? {
446
+ claim: client_api_1.integration.claimExecution,
447
+ heartbeat: client_api_1.integration.heartbeatExecution,
448
+ settle: client_api_1.integration.settleExecution,
449
+ };
450
+ // Set once the run is ours: only an execution that claimed a run settles it.
451
+ let claimed = null;
452
+ let heartbeat = null;
453
+ // What the run used is reported before it is settled, however it ended:
454
+ // settling revokes the token the report is made with.
455
+ let reportWhatWasUsed = async () => { };
456
+ let stopReporting = () => { };
457
+ try {
458
+ const req = await fetchEnvelope(batchJobId, token);
459
+ const request = safeParse(req?.request_json, {});
460
+ // A scheduled run carries no identity by design: see isAnonymousRun.
461
+ const session = (0, exports.isAnonymousRun)(req?.event, request)
462
+ ? null
463
+ : await authenticate(request);
464
+ const context = {
465
+ config: safeParse(req?.config_json, {}),
466
+ params: {
467
+ contentType: req?.content_type,
468
+ data: safeParse(req?.data_json, {}),
469
+ },
470
+ request: { ...request, session },
471
+ event: req?.event ?? 0,
472
+ onProgress: (update) => {
473
+ logger_js_1.default.log(null, logger_js_1.default.levels.info, `progress ${Math.round(update?.progress ?? 0)}%: ${update?.message ?? ""}`);
474
+ },
475
+ signal: abort.signal,
476
+ session,
477
+ usage: new usage_js_1.UsageLedger(),
478
+ };
479
+ reportWhatWasUsed = () => (0, usage_js_1.reportUsage)(context, { report: options.reportUsage, last: true });
480
+ let entry = main;
481
+ const entryPath = String(req?.code ?? "").trim();
482
+ if (entryPath) {
483
+ const loaded = await loadAgentModule(entryPath, "handler");
484
+ if (loaded.reason) {
485
+ throw new Error(`entry module "${entryPath}" could not be loaded (${loaded.reason}): ${loaded.detail}`);
486
+ }
487
+ entry = loaded.fn;
488
+ }
489
+ // Claim before doing anything. Of however many executions the platform may
490
+ // start for one run — a retried task, a redelivered message — exactly one
491
+ // wins; the others exit 0 without repeating the work, which is not a
492
+ // failure and must not be retried. The execution's token names its run
493
+ // (every run, scheduled ones included, is started with one), so nothing
494
+ // else is sent.
495
+ const answer = await lifecycle.claim(token);
496
+ if (!answer?.claimed) {
497
+ logger_js_1.default.log(null, logger_js_1.default.levels.info, `run-once execution for event record ${batchJobId}: another execution already claimed this run, nothing to do`);
498
+ exit(0);
499
+ return;
500
+ }
501
+ claimed = { token };
502
+ heartbeat = setInterval(() => {
503
+ void lifecycle
504
+ .heartbeat(token)
505
+ .catch((error) => logger_js_1.default.log(null, logger_js_1.default.levels.warn, `run-once execution for event record ${batchJobId}: heartbeat failed: ${error?.message ?? error}`));
506
+ }, HEARTBEAT_MS);
507
+ heartbeat.unref?.();
508
+ stopReporting = (0, usage_js_1.reportUsageWhileRunning)(context, {
509
+ report: options.reportUsage,
510
+ intervalMs: options.usageReportIntervalMs,
511
+ });
512
+ logger_js_1.default.log(null, logger_js_1.default.levels.info, `run-once execution for event record ${batchJobId} (${req?.content_type ?? ""}) starting`);
513
+ const result = await (0, runtimeContext_js_1.runWithContext)(context, () => entry(abort.signal));
514
+ logger_js_1.default.log(null, logger_js_1.default.levels.info, `run-once execution for event record ${batchJobId} completed: ${JSON.stringify(result ?? null).slice(0, 500)}`);
515
+ stopReporting();
516
+ await reportWhatWasUsed();
517
+ await settleRun(claimed, lifecycle, RUN_STATUS.COMPLETED, result, batchJobId);
518
+ exit(0);
519
+ }
520
+ catch (error) {
521
+ const frame = toErrorFrame(error);
522
+ logger_js_1.default.log(null, logger_js_1.default.levels.error, `run-once execution for event record ${batchJobId} failed${abort.signal.aborted ? " (aborted)" : ""}: ${frame.code} ${frame.message}`);
523
+ // Work that failed still used what it used.
524
+ stopReporting();
525
+ await reportWhatWasUsed();
526
+ // The run is ours to close whether it went well or not: a run left
527
+ // running is one the platform has to time out, and its credential lives
528
+ // until it does.
529
+ await settleRun(claimed, lifecycle, RUN_STATUS.FAILED, frame, batchJobId);
530
+ exit(1);
531
+ }
532
+ finally {
533
+ stopReporting();
534
+ if (heartbeat)
535
+ clearInterval(heartbeat);
536
+ process.off("SIGTERM", onSignal);
537
+ process.off("SIGINT", onSignal);
538
+ }
539
+ };
540
+ exports.runOnce = runOnce;
339
541
  /**
340
542
  * Start a gRPC server that exposes `main` as the fleet-standard `Agent.Execute`
341
543
  * RPC. Per request it binds a {@link RequestContext} (so the agent's globals are
@@ -348,6 +550,12 @@ const toErrorFrame = (error) => {
348
550
  * serve(require("./src/main"));
349
551
  */
350
552
  const serve = (main, options = {}) => {
553
+ // Started as a job execution: run the handler once and exit instead of
554
+ // serving. Same image, same entrypoint; the environment decides.
555
+ if ((0, exports.isRunOnceExecution)()) {
556
+ void (0, exports.runOnce)(main, options);
557
+ return new grpc.Server();
558
+ }
351
559
  (0, runtimeContext_js_1.installGlobals)();
352
560
  const port = options.port ?? (Number(process.env.PORT) || 8080);
353
561
  const host = options.host ?? "0.0.0.0";
@@ -370,9 +578,13 @@ const serve = (main, options = {}) => {
370
578
  };
371
579
  const handle = async () => {
372
580
  // Authenticate first — refuse the call when the token is missing/invalid.
373
- let session;
581
+ // The exceptions carry no token by design and run with no session: see
582
+ // isAnonymousRun.
583
+ let session = null;
374
584
  try {
375
- session = await authenticate(request);
585
+ if (!(0, exports.isAnonymousRun)(req.event, request)) {
586
+ session = await authenticate(request);
587
+ }
376
588
  }
377
589
  catch (error) {
378
590
  const message = error?.message ?? "Unauthenticated";
@@ -397,6 +609,7 @@ const serve = (main, options = {}) => {
397
609
  onProgress,
398
610
  signal: abort.signal,
399
611
  session,
612
+ usage: new usage_js_1.UsageLedger(),
400
613
  };
401
614
  // Which function actually runs. Default is the agent's own `main` — the
402
615
  // path every caller took before `code` existed, and still the path taken
@@ -427,8 +640,21 @@ const serve = (main, options = {}) => {
427
640
  }
428
641
  entry = loaded.fn;
429
642
  }
643
+ // What the call used is reported before it is answered, however it
644
+ // ended — a call that failed, or that its caller gave up on, still used
645
+ // what it used — because the platform revokes the call's token as soon
646
+ // as it has the answer.
647
+ const stopReporting = (0, usage_js_1.reportUsageWhileRunning)(context, {
648
+ report: options.reportUsage,
649
+ intervalMs: options.usageReportIntervalMs,
650
+ });
651
+ const reportWhatWasUsed = async () => {
652
+ stopReporting();
653
+ await (0, usage_js_1.reportUsage)(context, { report: options.reportUsage, last: true });
654
+ };
430
655
  try {
431
656
  const result = await (0, runtimeContext_js_1.runWithContext)(context, () => entry(abort.signal));
657
+ await reportWhatWasUsed();
432
658
  if (!abort.signal.aborted) {
433
659
  call.write({
434
660
  result: { result_json: JSON.stringify(result ?? null) },
@@ -436,6 +662,7 @@ const serve = (main, options = {}) => {
436
662
  }
437
663
  }
438
664
  catch (error) {
665
+ await reportWhatWasUsed();
439
666
  if (!abort.signal.aborted) {
440
667
  logger_js_1.default.log(null, logger_js_1.default.levels.error, `agent Execute failed: ${error?.message ?? error}`);
441
668
  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"}
@@ -10,9 +10,8 @@ const const_js_1 = __importDefault(require("./const.js"));
10
10
  const errorHandling_js_1 = __importDefault(require("./errorHandling.js"));
11
11
  const logger_js_1 = __importDefault(require("./logger.js"));
12
12
  const runtimeContext_js_1 = require("./runtimeContext.js");
13
+ const usage_js_1 = require("./usage.js");
13
14
  const TAVILY_API_BASE = "https://api.tavily.com";
14
- /** Tavily list price per credit, used when the integration defines no rate. */
15
- const DEFAULT_CREDIT_COST_USD = 0.008;
16
15
  /**
17
16
  * Context budgets, in characters, applied to everything a tool hands back to
18
17
  * the model. Tavily returns whatever the page holds, and unlike a provider's
@@ -129,19 +128,25 @@ const callTavily = async (endpoint, body, apiKey, timeoutSeconds) => {
129
128
  throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, `Tavily ${endpoint} returned ${response.status}: ${detail.slice(0, 500)}`);
130
129
  }
131
130
  };
131
+ /** The name Tavily's credits are counted and priced under. */
132
+ const TAVILY_MODEL = "tavily";
132
133
  /**
133
- * Adds one Tavily call's credits to the tracker and records the URLs it
134
- * returned. Credits come from the response's own `usage` block, priced with the
135
- * `tavily-credit-costs` constant (USD per credit) and accumulated under
136
- * `tavilyCredits`. Sources land on `tracker.webSearchSources`, deduplicated by
137
- * URL across the whole run, so callers have the citation list Tavily's terms
138
- * require when this output is shown to end users.
139
- * @param tracker - The caller's usage accumulator; no-ops when absent
140
- * @param config - Config object carrying the `tavily-credit-costs` constant
134
+ * Counts one Tavily call's credits and records the URLs it returned. Credits
135
+ * come from the response's own `usage` block; they are counted into the
136
+ * request's usage ledger, which is reported to the platform, and into the
137
+ * caller's tracker under `tavilyCredits`. Sources land on
138
+ * `tracker.webSearchSources`, deduplicated by URL across the whole run, so
139
+ * callers have the citation list Tavily's terms require when this output is
140
+ * shown to end users.
141
+ * @param tracker - The caller's usage accumulator, when it passed one
142
+ * @param _config - No longer read: it carried the rate, which is the platform's to say
141
143
  * @param credits - Credits reported by the Tavily response
142
144
  * @param sources - URLs (and titles where known) the call returned
143
145
  */
144
- const recordTavilyUsage = (tracker, config, credits, sources) => {
146
+ const recordTavilyUsage = (tracker, _config, credits, sources) => {
147
+ if (Number.isFinite(credits) && credits > 0) {
148
+ (0, usage_js_1.recordUsage)(TAVILY_MODEL, "credits", credits);
149
+ }
145
150
  if (!tracker)
146
151
  return;
147
152
  if (typeof tracker.cost !== "number")
@@ -149,13 +154,6 @@ const recordTavilyUsage = (tracker, config, credits, sources) => {
149
154
  if (!tracker.tokens || typeof tracker.tokens !== "object")
150
155
  tracker.tokens = {};
151
156
  if (Number.isFinite(credits) && credits > 0) {
152
- const configured = Number(config?.["tavily-credit-costs"]);
153
- const rate = Number.isFinite(configured)
154
- ? configured
155
- : DEFAULT_CREDIT_COST_USD;
156
- const addedCost = credits * rate;
157
- if (Number.isFinite(addedCost) && addedCost > 0)
158
- tracker.cost += addedCost;
159
157
  tracker.tokens.tavilyCredits =
160
158
  (tracker.tokens.tavilyCredits || 0) + credits;
161
159
  }
@@ -401,4 +399,5 @@ exports.default = {
401
399
  assertTavilyUsable: exports.assertTavilyUsable,
402
400
  buildTavilyTools: exports.buildTavilyTools,
403
401
  getTavilyOptions: exports.getTavilyOptions,
402
+ recordTavilyUsage,
404
403
  };
@@ -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"}
@@ -0,0 +1,160 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.reportUsageWhileRunning = exports.reportUsage = exports.recordUsage = exports.UsageLedger = exports.USAGE_REPORT_INTERVAL_MS = exports.USAGE_UNITS = void 0;
7
+ const node_crypto_1 = require("node:crypto");
8
+ const client_api_1 = require("@stackfactor/client-api");
9
+ const logger_js_1 = __importDefault(require("./logger.js"));
10
+ const runtimeContext_js_1 = require("./runtimeContext.js");
11
+ /**
12
+ * What an agent used, and how the platform gets to know.
13
+ *
14
+ * An agent says WHAT it used — a model, a unit, an amount — and the platform
15
+ * says what that costs, who is charged and what it was for. Everything that
16
+ * calls a model through this package counts what the call used into the
17
+ * request's {@link UsageLedger}; {@link serve} and {@link runOnce} report the
18
+ * ledger to the platform while the work runs and when it ends, whether it
19
+ * ended well or not. An agent does nothing for this to happen.
20
+ *
21
+ * An agent that calls a provider itself — its own SDK, an HTTP request —
22
+ * counts what that used with {@link recordUsage}.
23
+ */
24
+ /** What usage is counted in; the units the platform has rates for. */
25
+ exports.USAGE_UNITS = [
26
+ "inputTokens",
27
+ "outputTokens",
28
+ "imageInputTokens",
29
+ "imageOutputTokens",
30
+ "characters",
31
+ "seconds",
32
+ "credits",
33
+ ];
34
+ /** Entries one report may carry (the platform's limit). */
35
+ const REPORT_ENTRIES_MAX = 100;
36
+ /** How often a report is tried before it is given up on, and the first wait between tries. */
37
+ const REPORT_ATTEMPTS = 3;
38
+ const REPORT_RETRY_BASE_MS = 500;
39
+ /** How often work that is still running reports what it used so far. */
40
+ exports.USAGE_REPORT_INTERVAL_MS = 60_000;
41
+ const keyOf = (model, unit) => `${model}\n${unit}`;
42
+ /**
43
+ * What one request used and has not reported yet, summed by model and unit.
44
+ */
45
+ class UsageLedger {
46
+ pending = new Map();
47
+ /** Adds to what is pending. Anything that is not a positive amount of a known unit is ignored. */
48
+ add(model, unit, amount) {
49
+ if (typeof model !== "string" ||
50
+ model.trim().length === 0 ||
51
+ !exports.USAGE_UNITS.includes(unit) ||
52
+ typeof amount !== "number" ||
53
+ !Number.isFinite(amount) ||
54
+ amount <= 0) {
55
+ return;
56
+ }
57
+ const name = model.trim();
58
+ const key = keyOf(name, unit);
59
+ const entry = this.pending.get(key);
60
+ if (entry)
61
+ entry.amount += amount;
62
+ else
63
+ this.pending.set(key, { model: name, unit, amount });
64
+ }
65
+ /** What is pending, which then no longer is. */
66
+ take() {
67
+ const entries = [...this.pending.values()];
68
+ this.pending = new Map();
69
+ return entries;
70
+ }
71
+ /** True when there is nothing to report. */
72
+ get isEmpty() {
73
+ return this.pending.size === 0;
74
+ }
75
+ }
76
+ exports.UsageLedger = UsageLedger;
77
+ /**
78
+ * Counts what the running request used. For an agent that calls a provider
79
+ * itself; what is called through `langChain` is counted already. Outside a
80
+ * request there is nothing to count into, and nothing happens.
81
+ * @param model - The model's id, as its provider names it
82
+ * @param unit - What the amount is counted in
83
+ * @param amount - How much
84
+ */
85
+ const recordUsage = (model, unit, amount) => {
86
+ (0, runtimeContext_js_1.getContext)()?.usage?.add(model, unit, amount);
87
+ };
88
+ exports.recordUsage = recordUsage;
89
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
90
+ const defaultReport = (usage, reportId, token) => client_api_1.integration.reportRunUsage(usage, reportId, token);
91
+ /**
92
+ * Sends one report, again when it fails, under the same name every time: the
93
+ * platform charges a report it has seen only once.
94
+ */
95
+ const send = async (entries, token, report, retryBaseMs) => {
96
+ const reportId = (0, node_crypto_1.randomUUID)();
97
+ for (let attempt = 1;; attempt++) {
98
+ try {
99
+ await report(entries, reportId, token);
100
+ return;
101
+ }
102
+ catch (error) {
103
+ if (attempt >= REPORT_ATTEMPTS)
104
+ throw error;
105
+ await delay(retryBaseMs * 2 ** (attempt - 1));
106
+ }
107
+ }
108
+ };
109
+ /**
110
+ * Reports what the request used and has not reported yet. Never throws, and
111
+ * never changes what the work answers: the work happened, and a platform that
112
+ * cannot be told what it used right now is told by the next report, or not at
113
+ * all — which is logged as an error, with what was used.
114
+ *
115
+ * @param context - The request whose ledger is reported
116
+ * @param options - `report` sends a report (default: the platform's route);
117
+ * `last` says no other report follows, so what cannot be sent is given up on
118
+ * rather than kept for the next one
119
+ */
120
+ const reportUsage = async (context, options = {}) => {
121
+ const ledger = context?.usage;
122
+ if (!ledger || ledger.isEmpty)
123
+ return;
124
+ const token = context.request?.authToken ?? context.request?.authorization;
125
+ const entries = ledger.take();
126
+ if (!token) {
127
+ logger_js_1.default.log(null, logger_js_1.default.levels.error, `usage was not reported, the request carries no token: ${JSON.stringify(entries)}`);
128
+ return;
129
+ }
130
+ for (let start = 0; start < entries.length; start += REPORT_ENTRIES_MAX) {
131
+ const chunk = entries.slice(start, start + REPORT_ENTRIES_MAX);
132
+ try {
133
+ await send(chunk, token, options.report ?? defaultReport, options.retryBaseMs ?? REPORT_RETRY_BASE_MS);
134
+ }
135
+ catch (error) {
136
+ if (options.last) {
137
+ logger_js_1.default.log(null, logger_js_1.default.levels.error, `usage could not be reported (${error?.message ?? error}): ${JSON.stringify(chunk)}`);
138
+ }
139
+ else {
140
+ for (const entry of chunk)
141
+ ledger.add(entry.model, entry.unit, entry.amount);
142
+ logger_js_1.default.log(null, logger_js_1.default.levels.warn, `usage could not be reported, it is kept for the next report: ${error?.message ?? error}`);
143
+ }
144
+ }
145
+ }
146
+ };
147
+ exports.reportUsage = reportUsage;
148
+ /**
149
+ * Reports what the request used so far every {@link USAGE_REPORT_INTERVAL_MS}
150
+ * while the work runs, so work that dies has reported most of what it used.
151
+ * @returns Stops it
152
+ */
153
+ const reportUsageWhileRunning = (context, options = {}) => {
154
+ const timer = setInterval(() => {
155
+ void (0, exports.reportUsage)(context, { report: options.report, retryBaseMs: options.retryBaseMs });
156
+ }, options.intervalMs ?? exports.USAGE_REPORT_INTERVAL_MS);
157
+ timer.unref?.();
158
+ return () => clearInterval(timer);
159
+ };
160
+ exports.reportUsageWhileRunning = reportUsageWhileRunning;
@@ -2,9 +2,10 @@ import constants from "./const.js";
2
2
  import errorHandling, { AppError } from "./errorHandling.js";
3
3
  import langChain from "./langChain.js";
4
4
  import logger from "./logger.js";
5
- import { serve } from "./serve.js";
5
+ import { serve, runOnce, isRunOnceExecution, EXECUTION_ENV } from "./serve.js";
6
6
  import { callAgent, checkAgent } from "./client.js";
7
7
  import * as runtimeContext from "./runtimeContext.js";
8
+ import { recordUsage, USAGE_UNITS } from "./usage.js";
8
9
  export { constants };
9
10
  export { errorHandling, AppError };
10
11
  export type { ParsedError } from "./errorHandling.js";
@@ -12,9 +13,11 @@ export { langChain };
12
13
  export type { UsageTracker } from "./langChain.js";
13
14
  export type { TavilyConfig, TavilyToolName } from "./tavily.js";
14
15
  export { logger };
15
- export { serve };
16
+ export { serve, runOnce, isRunOnceExecution, EXECUTION_ENV };
16
17
  export { callAgent };
17
18
  export { checkAgent };
18
19
  export type { AgentExecuteRequest, CallAgentOptions, CheckAgentOptions, AgentHealth, } from "./client.js";
19
20
  export { runtimeContext };
21
+ export { recordUsage, USAGE_UNITS };
22
+ export type { UsageEntry, UsageUnit } from "./usage.js";
20
23
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,MAAM,YAAY,CAAC;AACnC,OAAO,aAAa,EAAE,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAC7D,OAAO,SAAS,MAAM,gBAAgB,CAAC;AACvC,OAAO,MAAM,MAAM,aAAa,CAAC;AACjC,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACnC,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,KAAK,cAAc,MAAM,qBAAqB,CAAC;AAEtD,OAAO,EAAE,SAAS,EAAE,CAAC;AAErB,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,CAAC;AAEnC,YAAY,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEtD,OAAO,EAAE,SAAS,EAAE,CAAC;AAErB,YAAY,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAEnD,YAAY,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAEhE,OAAO,EAAE,MAAM,EAAE,CAAC;AAElB,OAAO,EAAE,KAAK,EAAE,CAAC;AAEjB,OAAO,EAAE,SAAS,EAAE,CAAC;AAErB,OAAO,EAAE,UAAU,EAAE,CAAC;AAEtB,YAAY,EACV,mBAAmB,EACnB,gBAAgB,EAChB,iBAAiB,EACjB,WAAW,GACZ,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,cAAc,EAAE,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,MAAM,YAAY,CAAC;AACnC,OAAO,aAAa,EAAE,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAC7D,OAAO,SAAS,MAAM,gBAAgB,CAAC;AACvC,OAAO,MAAM,MAAM,aAAa,CAAC;AACjC,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAC/E,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,KAAK,cAAc,MAAM,qBAAqB,CAAC;AACtD,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAEtD,OAAO,EAAE,SAAS,EAAE,CAAC;AAErB,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,CAAC;AAEnC,YAAY,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEtD,OAAO,EAAE,SAAS,EAAE,CAAC;AAErB,YAAY,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAEnD,YAAY,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAEhE,OAAO,EAAE,MAAM,EAAE,CAAC;AAElB,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,kBAAkB,EAAE,aAAa,EAAE,CAAC;AAE7D,OAAO,EAAE,SAAS,EAAE,CAAC;AAErB,OAAO,EAAE,UAAU,EAAE,CAAC;AAEtB,YAAY,EACV,mBAAmB,EACnB,gBAAgB,EAChB,iBAAiB,EACjB,WAAW,GACZ,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,cAAc,EAAE,CAAC;AAE1B,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,CAAC;AAEpC,YAAY,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC"}