@stackfactor/agent-utils 1.2.6 → 1.2.8

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/README.md CHANGED
@@ -27,7 +27,7 @@ import {
27
27
 
28
28
  Unified interface for running LLM prompts, managing LangChain agents, and generating images across OpenAI, Anthropic, Google, DeepSeek, Kimi (Moonshot), and GLM (Zhipu) providers.
29
29
 
30
- ### `langChain.runPromptWithModel(modelName, config, prompt, onProgressReport?, minPercent?, maxPercent?, expectsJsonResponse?, schema?, agentName?, tools?)`
30
+ ### `langChain.runPromptWithModel(modelName, config, prompt, onProgressReport?, minPercent?, maxPercent?, expectsJsonResponse?, schema?, agentName?, tools?, usageTracker?)`
31
31
 
32
32
  Sends a prompt to an LLM and returns the response. Supports three execution modes:
33
33
 
@@ -49,15 +49,33 @@ When `expectsJsonResponse` is `true` (the default), JSON escape instructions are
49
49
  | `schema` | `ZodSchema \| null` | `null` | Zod schema for response validation |
50
50
  | `agentName` | `string` | `"StackFactor"` | Display name for the agent (agentic mode) |
51
51
  | `tools` | `any[]` | `[]` | LangChain tools available to the agent (agentic mode) |
52
+ | `usageTracker` | `UsageTracker \| null` | `null` | Optional accumulator updated on every successful LLM call with `cost` (USD) and per-model token counts. |
52
53
 
53
54
  **Returns:** JSON string (when `expectsJsonResponse` is `true`) or raw content string.
54
55
 
55
56
  ```typescript
57
+ const usageTracker = {};
56
58
  const result = await langChain.runPromptWithModel(
57
59
  "gpt-4o",
58
60
  { openAIAPIKey: "sk-..." },
59
61
  "Generate a summary of this document.",
62
+ null,
63
+ 0,
64
+ 100,
65
+ true,
66
+ null,
67
+ "StackFactor",
68
+ [],
69
+ usageTracker,
60
70
  );
71
+ // usageTracker is now:
72
+ // {
73
+ // cost: 0.00123,
74
+ // tokens: {
75
+ // "gpt-4o_inputTokens": 412,
76
+ // "gpt-4o_outputTokens": 87,
77
+ // }
78
+ // }
61
79
  ```
62
80
 
63
81
  ---
@@ -9,6 +9,7 @@ export { constants };
9
9
  export { errorHandling, AppError };
10
10
  export type { ParsedError } from "./errorHandling.js";
11
11
  export { langChain };
12
+ export type { UsageTracker } from "./langChain.js";
12
13
  export { logger };
13
14
  export { serve };
14
15
  export { callAgent };
@@ -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,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,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,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,10 +1,23 @@
1
+ /**
2
+ * Accumulated usage and cost for a single agent run. Callers initialize an
3
+ * empty object (`{}`) and pass it through `runPromptWithModel`; helpers
4
+ * populate `cost` (USD total across all models) and per-model
5
+ * `<model>_inputTokens` / `<model>_outputTokens` counters under `tokens`.
6
+ */
7
+ export type UsageTracker = {
8
+ cost: number;
9
+ tokens: {
10
+ [tokenKey: string]: number;
11
+ };
12
+ };
1
13
  declare const _default: {
2
14
  checkIfAIProviderConfigured: (config: any) => void;
3
15
  createAgent: (name: string, modelName: string, systemPrompt: string, tools: any[], responseFormat: any, config: any) => any;
4
- runAgent: (agent: any, prompt: string, config: any, onProgress?: Function | null) => Promise<any>;
5
- runPromptWithModel: (modelName: string, config: any, prompt: any, onProgressReport: any, minPercent?: number, maxPercent?: number, expectsJsonResponse?: boolean, schema?: any, agentName?: string, tools?: any[]) => Promise<any>;
6
- runPromptWithModelForImageGeneration: (modelName: string, config: any, prompt: string, options?: any) => Promise<any>;
16
+ runAgent: (agent: any, prompt: string, config: any, onProgress?: Function | null, usageTracker?: UsageTracker | null) => Promise<any>;
17
+ runPromptWithModel: (modelName: string, config: any, prompt: any, onProgressReport: any, minPercent?: number, maxPercent?: number, expectsJsonResponse?: boolean, schema?: any, agentName?: string, tools?: any[], usageTracker?: UsageTracker | null) => Promise<any>;
18
+ runPromptWithModelForImageGeneration: (modelName: string, config: any, prompt: string, options?: any, usageTracker?: UsageTracker | null) => Promise<any>;
7
19
  throwErrorIfNotSuccessful: (response: any) => string;
20
+ updateUsageTrackerForCharacters: (tracker: UsageTracker | null | undefined, modelName: string, characterCount: number, config: any) => void;
8
21
  validateModel: (selectedModel: string, supportedModels: string[]) => string;
9
22
  };
10
23
  export default _default;
@@ -1 +1 @@
1
- {"version":3,"file":"langChain.d.ts","sourceRoot":"","sources":["../../src/langChain.ts"],"names":[],"mappings":";0CAiD6C,GAAG,KAAG,IAAI;wBA0hB/C,MAAM,aACD,MAAM,gBACH,MAAM,SACb,GAAG,EAAE,kBACI,GAAG,UACX,GAAG,KACV,GAAG;sBAyBG,GAAG,UACF,MAAM,UACN,GAAG,eACC,QAAQ,GAAG,IAAI,KAC1B,OAAO,CAAC,GAAG,CAAC;oCAqXF,MAAM,UACT,GAAG,UACH,GAAG,oBACO,GAAG,eACT,MAAM,eACN,MAAM,wBACG,OAAO,WACpB,GAAG,cACA,MAAM,UACV,GAAG,EAAE,KACX,OAAO,CAAC,GAAG,CAAC;sDAosBF,MAAM,UACT,GAAG,UACH,MAAM,YACL,GAAG,KACX,OAAO,CAAC,GAAG,CAAC;0CAn+B8B,GAAG,KAAG,MAAM;mCAoBxC,MAAM,mBACJ,MAAM,EAAE,KACxB,MAAM;;AAwgCT,wBAQE"}
1
+ {"version":3,"file":"langChain.d.ts","sourceRoot":"","sources":["../../src/langChain.ts"],"names":[],"mappings":"AA2BA;;;;;GAKG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE;QAAE,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;CACxC,CAAC;;0CAE2C,GAAG,KAAG,IAAI;wBA+jB/C,MAAM,aACD,MAAM,gBACH,MAAM,SACb,GAAG,EAAE,kBACI,GAAG,UACX,GAAG,KACV,GAAG;sBA0BG,GAAG,UACF,MAAM,UACN,GAAG,eACC,QAAQ,GAAG,IAAI,iBACb,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;oCA4XF,MAAM,UACT,GAAG,UACH,GAAG,oBACO,GAAG,eACT,MAAM,eACN,MAAM,wBACG,OAAO,WACpB,GAAG,cACA,MAAM,UACV,GAAG,EAAE,iBACE,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;sDAusBF,MAAM,UACT,GAAG,UACH,MAAM,YACL,GAAG,iBACE,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;0CA3+B8B,GAAG,KAAG,MAAM;+CApjB9C,YAAY,GAAG,IAAI,GAAG,SAAS,aAC7B,MAAM,kBACD,MAAM,UACd,GAAG,KACV,IAAI;mCAokBU,MAAM,mBACJ,MAAM,EAAE,KACxB,MAAM;;AAkhCT,wBASE"}
@@ -23,20 +23,6 @@ CRITICAL - Your response must be valid JSON. Escape ALL special characters in st
23
23
  - Backslashes → \\\\
24
24
  Do NOT include raw newlines, tabs, or unescaped quotes inside JSON string values.
25
25
  `.trim();
26
- /**
27
- * Reads `global.quota` (set by the StackFactor host before invoking `main()`) and
28
- * throws a `PAYMENT_REQUIRED` error when the session has no remaining budget. No-op
29
- * when `global.quota` is absent (e.g. local tests) or `remaining` is not a number,
30
- * which keeps the library usable outside the StackFactor runtime.
31
- */
32
- const assertQuotaAvailable = () => {
33
- const quota = globalThis.quota;
34
- if (!quota || typeof quota.remaining !== "number")
35
- return;
36
- if (quota.remaining <= 0) {
37
- throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.PAYMENT_REQUIRED, const_js_1.default.ERROR.QUOTA_EXHAUSTED);
38
- }
39
- };
40
26
  const checkIfAIProviderConfigured = (config) => {
41
27
  if (!config ||
42
28
  !config.openAIAPIKey ||
@@ -45,104 +31,137 @@ const checkIfAIProviderConfigured = (config) => {
45
31
  throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, "The integration is not properly configured with all AI providers.");
46
32
  }
47
33
  };
48
- /**
49
- * Subtracts the given USD cost from `global.quota.remaining` and adds it to
50
- * `global.quota.usedThisSession`. Silently no-ops when the quota globals are not
51
- * present so callers do not need to branch. Negative, zero, or non-finite costs
52
- * are ignored to keep counters monotonic.
53
- */
54
- const recordCost = (cost) => {
55
- if (!cost || cost <= 0 || !Number.isFinite(cost))
56
- return;
57
- const quota = globalThis.quota;
58
- if (!quota)
59
- return;
60
- if (typeof quota.remaining === "number") {
61
- quota.remaining -= cost;
62
- }
63
- if (typeof quota.usedThisSession === "number") {
64
- quota.usedThisSession += cost;
65
- }
66
- else {
67
- quota.usedThisSession = cost;
68
- }
69
- };
70
34
  /**
71
35
  * Reads a per-model rate from the flat cost constants the integration defines
72
36
  * in its `config.yaml` (exposed on the config object), e.g.
73
- * `claude-opus-4-7-input-token-costs`. Returns `null` when the constant is
74
- * absent or not numeric, in which case cost recording becomes a no-op (the
75
- * call still succeeds — pricing is the integration's configuration concern).
37
+ * `claude-opus-4-7-input-token-costs`. Returns `0` when the constant is
38
+ * absent or not numeric, which leaves cost contribution zero.
76
39
  */
77
40
  const getModelRate = (modelName, config, kind) => {
78
41
  if (!config || !modelName)
79
- return null;
42
+ return 0;
80
43
  const rate = Number(config[`${modelName}-${kind}-costs`]);
81
- return Number.isFinite(rate) ? rate : null;
44
+ return Number.isFinite(rate) ? rate : 0;
82
45
  };
83
46
  /**
84
- * Accumulates the per-model usage breakdown on `global.quota.byModel` so the
85
- * agent can report input/output token counts and image spend per model to the
86
- * UI. No-ops when the quota global is absent.
47
+ * Adds a single call's usage to the caller-supplied tracker. Initializes
48
+ * `cost` and `tokens` lazily so callers can pass `{}`. No-ops when the
49
+ * tracker is null/undefined.
87
50
  */
88
- const recordModelBreakdown = (modelName, inputCost, outputCost, imageCost, inputTokens = 0, outputTokens = 0) => {
89
- const quota = globalThis.quota;
90
- if (!quota || !modelName)
51
+ const updateUsageTracker = (tracker, modelName, usage, config) => {
52
+ if (!tracker || !usage || !modelName)
91
53
  return;
92
- if (!quota.byModel || typeof quota.byModel !== "object")
93
- quota.byModel = {};
94
- const entry = (quota.byModel[modelName] ||= {
95
- inputCost: 0,
96
- outputCost: 0,
97
- imageCost: 0,
98
- inputTokens: 0,
99
- outputTokens: 0,
100
- });
101
- // Entries created by older versions of this module lack the token counters.
102
- if (typeof entry.inputTokens !== "number")
103
- entry.inputTokens = 0;
104
- if (typeof entry.outputTokens !== "number")
105
- entry.outputTokens = 0;
106
- if (Number.isFinite(inputCost) && inputCost > 0)
107
- entry.inputCost += inputCost;
108
- if (Number.isFinite(outputCost) && outputCost > 0)
109
- entry.outputCost += outputCost;
110
- if (Number.isFinite(imageCost) && imageCost > 0)
111
- entry.imageCost += imageCost;
112
- if (Number.isFinite(inputTokens) && inputTokens > 0)
113
- entry.inputTokens += inputTokens;
114
- if (Number.isFinite(outputTokens) && outputTokens > 0)
115
- entry.outputTokens += outputTokens;
54
+ if (typeof tracker.cost !== "number")
55
+ tracker.cost = 0;
56
+ if (!tracker.tokens || typeof tracker.tokens !== "object")
57
+ tracker.tokens = {};
58
+ const inputTokens = usage.input_tokens || 0;
59
+ const outputTokens = usage.output_tokens || 0;
60
+ const inputRate = getModelRate(modelName, config, "input-token");
61
+ const outputRate = getModelRate(modelName, config, "output-token");
62
+ const addedCost = (inputTokens / 1_000_000) * inputRate +
63
+ (outputTokens / 1_000_000) * outputRate;
64
+ if (Number.isFinite(addedCost) && addedCost > 0)
65
+ tracker.cost += addedCost;
66
+ const inputKey = `${modelName}_inputTokens`;
67
+ const outputKey = `${modelName}_outputTokens`;
68
+ tracker.tokens[inputKey] = (tracker.tokens[inputKey] || 0) + inputTokens;
69
+ tracker.tokens[outputKey] = (tracker.tokens[outputKey] || 0) + outputTokens;
116
70
  };
117
71
  /**
118
- * Computes and records the USD cost of a text LLM call from the
119
- * `<model>-input-token-costs` / `<model>-output-token-costs` constants
120
- * (dollars per million tokens). Updates both the session total
121
- * (`quota.usedThisSession` / `quota.remaining`) and the per-model breakdown
122
- * (`quota.byModel`).
72
+ * Adds a single image-generation call's usage to the caller-supplied tracker.
73
+ * Extracts token counts from provider-specific response shapes: OpenAI returns
74
+ * `response.usage` with `input_tokens_details.{text_tokens,image_tokens}` and
75
+ * `output_tokens`; Google returns `response.usageMetadata` with
76
+ * `promptTokensDetails` (modality-keyed) and `candidatesTokenCount` for the
77
+ * generated image. Reads rates from `<model>-input-token-costs`,
78
+ * `<model>-image-input-token-costs`, and `<model>-image-output-token-costs`.
79
+ * No-ops when the tracker or response usage info is absent.
123
80
  */
124
- const recordTextCost = (modelName, usage, config) => {
125
- if (!usage)
81
+ const updateImageUsageTracker = (tracker, modelName, response, config, provider) => {
82
+ if (!tracker || !modelName || !response)
126
83
  return;
127
- const inputRate = getModelRate(modelName, config, "input-token") || 0;
128
- const outputRate = getModelRate(modelName, config, "output-token") || 0;
129
- const inputTokens = usage.input_tokens || 0;
130
- const outputTokens = usage.output_tokens || 0;
131
- const inputCost = (inputTokens / 1_000_000) * inputRate;
132
- const outputCost = (outputTokens / 1_000_000) * outputRate;
133
- recordCost(inputCost + outputCost);
134
- recordModelBreakdown(modelName, inputCost, outputCost, 0, inputTokens, outputTokens);
84
+ if (typeof tracker.cost !== "number")
85
+ tracker.cost = 0;
86
+ if (!tracker.tokens || typeof tracker.tokens !== "object")
87
+ tracker.tokens = {};
88
+ let textInputTokens = 0;
89
+ let imageInputTokens = 0;
90
+ let imageOutputTokens = 0;
91
+ if (provider === "openai") {
92
+ const usage = response.usage;
93
+ if (!usage)
94
+ return;
95
+ const details = usage.input_tokens_details || {};
96
+ textInputTokens = details.text_tokens || 0;
97
+ imageInputTokens = details.image_tokens || 0;
98
+ // Older shapes report a single input_tokens without modality breakdown.
99
+ if (!textInputTokens && !imageInputTokens && usage.input_tokens) {
100
+ textInputTokens = usage.input_tokens;
101
+ }
102
+ imageOutputTokens = usage.output_tokens || 0;
103
+ }
104
+ else {
105
+ const um = response.usageMetadata;
106
+ if (!um)
107
+ return;
108
+ const promptDetails = Array.isArray(um.promptTokensDetails)
109
+ ? um.promptTokensDetails
110
+ : [];
111
+ for (const d of promptDetails) {
112
+ const modality = String(d?.modality || "").toUpperCase();
113
+ const count = Number(d?.tokenCount) || 0;
114
+ if (modality === "IMAGE")
115
+ imageInputTokens += count;
116
+ else
117
+ textInputTokens += count;
118
+ }
119
+ if (!textInputTokens && !imageInputTokens) {
120
+ textInputTokens = Number(um.promptTokenCount) || 0;
121
+ }
122
+ imageOutputTokens = Number(um.candidatesTokenCount) || 0;
123
+ }
124
+ const textInputRate = getModelRate(modelName, config, "input-token");
125
+ const imageInputRate = getModelRate(modelName, config, "image-input-token");
126
+ const imageOutputRate = getModelRate(modelName, config, "image-output-token");
127
+ const addedCost = (textInputTokens / 1_000_000) * textInputRate +
128
+ (imageInputTokens / 1_000_000) * imageInputRate +
129
+ (imageOutputTokens / 1_000_000) * imageOutputRate;
130
+ if (Number.isFinite(addedCost) && addedCost > 0)
131
+ tracker.cost += addedCost;
132
+ if (textInputTokens > 0) {
133
+ const key = `${modelName}_inputTokens`;
134
+ tracker.tokens[key] = (tracker.tokens[key] || 0) + textInputTokens;
135
+ }
136
+ if (imageInputTokens > 0) {
137
+ const key = `${modelName}_imageInputTokens`;
138
+ tracker.tokens[key] = (tracker.tokens[key] || 0) + imageInputTokens;
139
+ }
140
+ if (imageOutputTokens > 0) {
141
+ const key = `${modelName}_imageOutputTokens`;
142
+ tracker.tokens[key] = (tracker.tokens[key] || 0) + imageOutputTokens;
143
+ }
135
144
  };
136
145
  /**
137
- * Computes and records the USD cost of an image generation call from the
138
- * `<model>-image-costs` constant (dollars per generated image). Updates both
139
- * the session total and the per-model breakdown.
146
+ * Adds character-billed usage (e.g. ElevenLabs TTS) to the caller-supplied
147
+ * tracker. There is no provider response to parse — billing is deterministic
148
+ * from the input character count. Reads the rate from
149
+ * `<model>-character-costs` (USD per million characters) and accumulates
150
+ * under `<model>_characters`. No-ops on missing tracker / model / count.
140
151
  */
141
- const recordImageCost = (modelName, numImages, config) => {
142
- const rate = getModelRate(modelName, config, "image") || 0;
143
- const imageCost = (numImages || 0) * rate;
144
- recordCost(imageCost);
145
- recordModelBreakdown(modelName, 0, 0, imageCost);
152
+ const updateUsageTrackerForCharacters = (tracker, modelName, characterCount, config) => {
153
+ if (!tracker || !modelName || !characterCount)
154
+ return;
155
+ if (typeof tracker.cost !== "number")
156
+ tracker.cost = 0;
157
+ if (!tracker.tokens || typeof tracker.tokens !== "object")
158
+ tracker.tokens = {};
159
+ const rate = getModelRate(modelName, config, "character");
160
+ const addedCost = (characterCount / 1_000_000) * rate;
161
+ if (Number.isFinite(addedCost) && addedCost > 0)
162
+ tracker.cost += addedCost;
163
+ const key = `${modelName}_characters`;
164
+ tracker.tokens[key] = (tracker.tokens[key] || 0) + characterCount;
146
165
  };
147
166
  /**
148
167
  * Extracts a normalized token-usage object from a single LangChain `invoke()`
@@ -541,9 +560,10 @@ const createAgent = (name, modelName, systemPrompt, tools = [], responseFormat,
541
560
  * number of agent steps (defaults to `25` when not specified)
542
561
  * @param onProgress - Optional callback invoked with progress event objects throughout
543
562
  * agent execution
563
+ * @param usageTracker - Optional usage accumulator updated on successful completion
544
564
  * @returns The raw response object returned by the agent's `invoke` method
545
565
  */
546
- const runAgent = async (agent, prompt, config, onProgress = null) => {
566
+ const runAgent = async (agent, prompt, config, onProgress = null, usageTracker = null) => {
547
567
  const startTime = Date.now();
548
568
  const callbacks = onProgress
549
569
  ? [
@@ -578,13 +598,12 @@ const runAgent = async (agent, prompt, config, onProgress = null) => {
578
598
  // many internal LLM calls, but a rate limit surfaces as a thrown error from
579
599
  // the wrapping invoke. On retry we restart the agent run from scratch — any
580
600
  // partial progress (tool calls, intermediate messages) is discarded, since
581
- // the agent state isn't externally checkpointed. Cost is only recorded on a
601
+ // the agent state isn't externally checkpointed. Usage is only recorded on a
582
602
  // successful completion. Matches the behavior of the non-agentic paths.
583
603
  let response;
584
604
  let rateLimitAttempt = 0;
585
605
  while (true) {
586
606
  try {
587
- assertQuotaAvailable();
588
607
  response = await agent.invoke({
589
608
  messages: [{ role: "user", content: prompt }],
590
609
  }, {
@@ -611,7 +630,7 @@ const runAgent = async (agent, prompt, config, onProgress = null) => {
611
630
  throw err;
612
631
  }
613
632
  }
614
- recordTextCost(modelName, sumAgentResponseUsage(response), config);
633
+ updateUsageTracker(usageTracker, modelName, sumAgentResponseUsage(response), config);
615
634
  const endTime = Date.now();
616
635
  const duration = endTime - startTime;
617
636
  logger_js_1.default.log(null, logger_js_1.default.levels.info, `Agent "${agent.options?.name}" completed in ${Math.round(duration / 1000)} seconds.`);
@@ -866,10 +885,13 @@ const buildValidationRetryMessages = (priorMessages, rawContent, validationError
866
885
  * `"StackFactor"`
867
886
  * @param tools - Additional LangChain tools available to the agent in agentic mode;
868
887
  * defaults to an empty array
888
+ * @param usageTracker - Optional accumulator updated with `cost` and per-model token
889
+ * counts for every successful LLM call. Callers should initialise as `{}` before
890
+ * the run and read the populated values after.
869
891
  * @returns The LLM response as a JSON string (when `expectsJsonResponse` is `true`) or
870
892
  * as raw content (when `false`)
871
893
  */
872
- const runPromptWithModel = async (modelName, config, prompt, onProgressReport, minPercent = 0, maxPercent = 100, expectsJsonResponse = true, schema = null, agentName = "StackFactor", tools = []) => {
894
+ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, minPercent = 0, maxPercent = 100, expectsJsonResponse = true, schema = null, agentName = "StackFactor", tools = [], usageTracker = null) => {
873
895
  // Agentic mode: use an agent with tools instead of simple LLM invocation
874
896
  if (config.agentic === true) {
875
897
  // Extract system prompt and user prompt from the prompt parameter
@@ -903,7 +925,7 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
903
925
  const agent = createAgent(agentName, modelName, systemPrompt, [...tools], null, // responseFormat
904
926
  config);
905
927
  // Run the agent with progress callback
906
- const response = await runAgent(agent, userPrompt, config, onProgressReport || null);
928
+ const response = await runAgent(agent, userPrompt, config, onProgressReport || null, usageTracker);
907
929
  // Extract content from agent response
908
930
  const messages = response?.messages || [];
909
931
  if (messages.length === 0) {
@@ -1012,8 +1034,8 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1012
1034
  // callback — same "Generating content..." message and the progress curve
1013
1035
  // continues from the original startTime so the bar doesn't visibly reset.
1014
1036
  // Skipped when OpenAI native response_format is in use (a 422 there is a
1015
- // server-side schema bug, not a model output issue). Each attempt is billed
1016
- // independently via recordCost so retry cost is still visible in telemetry.
1037
+ // server-side schema bug, not a model output issue). Each attempt accumulates
1038
+ // independently into usageTracker so retry usage is still visible.
1017
1039
  const canRetryOnValidation = expectsJsonResponse && !!schema && !useNativeSchema;
1018
1040
  const progressReportInterval = 10; // Report every N chunks
1019
1041
  const overallStartTime = Date.now();
@@ -1034,16 +1056,15 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1034
1056
  let chunkCount = 0;
1035
1057
  let streamUsage = { input_tokens: 0, output_tokens: 0 };
1036
1058
  // Inner loop: wait + retry on 429 around stream setup and consumption.
1037
- // Cost is only recorded on a successful stream — partial streams that error
1038
- // out with a rate limit are not billed. A 429 fired mid-stream simply
1039
- // discards the partial output and restarts cleanly.
1059
+ // Usage is only recorded on a successful stream — partial streams that
1060
+ // error out with a rate limit are not counted. A 429 fired mid-stream
1061
+ // simply discards the partial output and restarts cleanly.
1040
1062
  let rateLimitAttempt = 0;
1041
1063
  while (true) {
1042
1064
  rawContent = "";
1043
1065
  chunkCount = 0;
1044
1066
  streamUsage = { input_tokens: 0, output_tokens: 0 };
1045
1067
  try {
1046
- assertQuotaAvailable();
1047
1068
  // Honour caller cancellation: passing the signal tears down the
1048
1069
  // upstream HTTP request so a cancelled call stops billing tokens.
1049
1070
  const abortSignal = (0, runtimeContext_js_1.getAbortSignal)();
@@ -1086,7 +1107,7 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1086
1107
  throw err;
1087
1108
  }
1088
1109
  }
1089
- recordTextCost(modelName, streamUsage, config);
1110
+ updateUsageTracker(usageTracker, modelName, streamUsage, config);
1090
1111
  if (!rawContent) {
1091
1112
  throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, "LLM returned no content");
1092
1113
  }
@@ -1159,7 +1180,7 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1159
1180
  messagesToSend = messages;
1160
1181
  }
1161
1182
  // Retry once on schema validation failure (see streaming branch for rationale).
1162
- // Each attempt is billed independently via recordCost so retry cost is visible.
1183
+ // Each attempt accumulates independently into usageTracker.
1163
1184
  const canRetryOnValidation = expectsJsonResponse && !!schema && !useNativeSchema;
1164
1185
  let activeMessages = messagesToSend;
1165
1186
  let attempt = 0;
@@ -1170,7 +1191,6 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1170
1191
  let rateLimitAttempt = 0;
1171
1192
  while (true) {
1172
1193
  try {
1173
- assertQuotaAvailable();
1174
1194
  response = await llm.invoke(activeMessages);
1175
1195
  break;
1176
1196
  }
@@ -1186,7 +1206,7 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1186
1206
  throw err;
1187
1207
  }
1188
1208
  }
1189
- recordTextCost(modelName, extractUsageFromInvoke(response), config);
1209
+ updateUsageTracker(usageTracker, modelName, extractUsageFromInvoke(response), config);
1190
1210
  const rawContent = response?.content || response;
1191
1211
  // If not expecting JSON, return raw content directly
1192
1212
  if (!expectsJsonResponse) {
@@ -1245,7 +1265,7 @@ const getImageModelProvider = (modelName) => {
1245
1265
  * @returns An object with `url`, `b64_json`, and `revisedPrompt` for a single image,
1246
1266
  * or `{ images: [...] }` for multiple images
1247
1267
  */
1248
- const generateImageWithOpenAI = async (modelName, config, prompt, options) => {
1268
+ const generateImageWithOpenAI = async (modelName, config, prompt, options, usageTracker = null) => {
1249
1269
  const { size = "1024x1024", style = "vivid", responseFormat = "url", n = 1, } = options;
1250
1270
  if (!config.openAIAPIKey) {
1251
1271
  throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.BAD_REQUEST, "OpenAI API key is required for OpenAI image generation");
@@ -1292,9 +1312,8 @@ const generateImageWithOpenAI = async (modelName, config, prompt, options) => {
1292
1312
  requestParams.style = style;
1293
1313
  }
1294
1314
  }
1295
- assertQuotaAvailable();
1296
1315
  const response = await openai.images.generate(requestParams);
1297
- recordImageCost(modelName, response.data?.length || n, config);
1316
+ updateImageUsageTracker(usageTracker, modelName, response, config, "openai");
1298
1317
  // Format response based on number of images
1299
1318
  if (n === 1) {
1300
1319
  const imageData = response.data[0];
@@ -1332,7 +1351,7 @@ const generateImageWithOpenAI = async (modelName, config, prompt, options) => {
1332
1351
  * @returns A single image descriptor `{ b64_json, mimeType }` when one image is
1333
1352
  * requested, or `{ images: [...] }` for multiple images
1334
1353
  */
1335
- const generateImageWithGoogle = async (modelName, config, prompt, options) => {
1354
+ const generateImageWithGoogle = async (modelName, config, prompt, options, usageTracker = null) => {
1336
1355
  const { aspectRatio = "1:1", numberOfImages = 1, negativePrompt = "", } = options;
1337
1356
  if (!config.googleAPIKey) {
1338
1357
  throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.BAD_REQUEST, "Google API key is required for Google image generation");
@@ -1392,8 +1411,8 @@ const generateImageWithGoogle = async (modelName, config, prompt, options) => {
1392
1411
  safetySettings: safetySettings,
1393
1412
  },
1394
1413
  };
1395
- assertQuotaAvailable();
1396
1414
  const response = await ai.models.generateContent(req);
1415
+ updateImageUsageTracker(usageTracker, modelName, response, config, "google");
1397
1416
  // Extract images from response
1398
1417
  const images = [];
1399
1418
  const candidates = response.candidates || [];
@@ -1411,7 +1430,6 @@ const generateImageWithGoogle = async (modelName, config, prompt, options) => {
1411
1430
  if (images.length === 0) {
1412
1431
  throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.INTERNAL_SERVER_ERROR, `No images were generated by ${modelName}`);
1413
1432
  }
1414
- recordImageCost(modelName, images.length, config);
1415
1433
  if (numberOfImages === 1 || images.length === 1) {
1416
1434
  return images[0];
1417
1435
  }
@@ -1437,7 +1455,7 @@ const generateImageWithGoogle = async (modelName, config, prompt, options) => {
1437
1455
  * and `generateImageWithGoogle` for full option sets); defaults to `{}`
1438
1456
  * @returns The generated image data object returned by the provider-specific function
1439
1457
  */
1440
- const runPromptWithModelForImageGeneration = async (modelName, config, prompt, options = {}) => {
1458
+ const runPromptWithModelForImageGeneration = async (modelName, config, prompt, options = {}, usageTracker = null) => {
1441
1459
  const provider = getImageModelProvider(modelName);
1442
1460
  if (!provider) {
1443
1461
  throw errorHandling_js_1.default.create(const_js_1.default.HTTP_CODES.BAD_REQUEST, `Unable to determine provider for model: ${modelName}. Model name should start with 'gpt-image-', 'gemini-', or 'imagen-'.`);
@@ -1446,10 +1464,10 @@ const runPromptWithModelForImageGeneration = async (modelName, config, prompt, o
1446
1464
  try {
1447
1465
  let result;
1448
1466
  if (provider === "openai") {
1449
- result = await generateImageWithOpenAI(modelName, config, prompt, options);
1467
+ result = await generateImageWithOpenAI(modelName, config, prompt, options, usageTracker);
1450
1468
  }
1451
1469
  else if (provider === "google") {
1452
- result = await generateImageWithGoogle(modelName, config, prompt, options);
1470
+ result = await generateImageWithGoogle(modelName, config, prompt, options, usageTracker);
1453
1471
  }
1454
1472
  const endTime = Date.now();
1455
1473
  const duration = endTime - startTime;
@@ -1477,5 +1495,6 @@ exports.default = {
1477
1495
  runPromptWithModel,
1478
1496
  runPromptWithModelForImageGeneration,
1479
1497
  throwErrorIfNotSuccessful,
1498
+ updateUsageTrackerForCharacters,
1480
1499
  validateModel,
1481
1500
  };
@@ -9,6 +9,7 @@ export { constants };
9
9
  export { errorHandling, AppError };
10
10
  export type { ParsedError } from "./errorHandling.js";
11
11
  export { langChain };
12
+ export type { UsageTracker } from "./langChain.js";
12
13
  export { logger };
13
14
  export { serve };
14
15
  export { callAgent };
@@ -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,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,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,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,10 +1,23 @@
1
+ /**
2
+ * Accumulated usage and cost for a single agent run. Callers initialize an
3
+ * empty object (`{}`) and pass it through `runPromptWithModel`; helpers
4
+ * populate `cost` (USD total across all models) and per-model
5
+ * `<model>_inputTokens` / `<model>_outputTokens` counters under `tokens`.
6
+ */
7
+ export type UsageTracker = {
8
+ cost: number;
9
+ tokens: {
10
+ [tokenKey: string]: number;
11
+ };
12
+ };
1
13
  declare const _default: {
2
14
  checkIfAIProviderConfigured: (config: any) => void;
3
15
  createAgent: (name: string, modelName: string, systemPrompt: string, tools: any[], responseFormat: any, config: any) => any;
4
- runAgent: (agent: any, prompt: string, config: any, onProgress?: Function | null) => Promise<any>;
5
- runPromptWithModel: (modelName: string, config: any, prompt: any, onProgressReport: any, minPercent?: number, maxPercent?: number, expectsJsonResponse?: boolean, schema?: any, agentName?: string, tools?: any[]) => Promise<any>;
6
- runPromptWithModelForImageGeneration: (modelName: string, config: any, prompt: string, options?: any) => Promise<any>;
16
+ runAgent: (agent: any, prompt: string, config: any, onProgress?: Function | null, usageTracker?: UsageTracker | null) => Promise<any>;
17
+ runPromptWithModel: (modelName: string, config: any, prompt: any, onProgressReport: any, minPercent?: number, maxPercent?: number, expectsJsonResponse?: boolean, schema?: any, agentName?: string, tools?: any[], usageTracker?: UsageTracker | null) => Promise<any>;
18
+ runPromptWithModelForImageGeneration: (modelName: string, config: any, prompt: string, options?: any, usageTracker?: UsageTracker | null) => Promise<any>;
7
19
  throwErrorIfNotSuccessful: (response: any) => string;
20
+ updateUsageTrackerForCharacters: (tracker: UsageTracker | null | undefined, modelName: string, characterCount: number, config: any) => void;
8
21
  validateModel: (selectedModel: string, supportedModels: string[]) => string;
9
22
  };
10
23
  export default _default;
@@ -1 +1 @@
1
- {"version":3,"file":"langChain.d.ts","sourceRoot":"","sources":["../../src/langChain.ts"],"names":[],"mappings":";0CAiD6C,GAAG,KAAG,IAAI;wBA0hB/C,MAAM,aACD,MAAM,gBACH,MAAM,SACb,GAAG,EAAE,kBACI,GAAG,UACX,GAAG,KACV,GAAG;sBAyBG,GAAG,UACF,MAAM,UACN,GAAG,eACC,QAAQ,GAAG,IAAI,KAC1B,OAAO,CAAC,GAAG,CAAC;oCAqXF,MAAM,UACT,GAAG,UACH,GAAG,oBACO,GAAG,eACT,MAAM,eACN,MAAM,wBACG,OAAO,WACpB,GAAG,cACA,MAAM,UACV,GAAG,EAAE,KACX,OAAO,CAAC,GAAG,CAAC;sDAosBF,MAAM,UACT,GAAG,UACH,MAAM,YACL,GAAG,KACX,OAAO,CAAC,GAAG,CAAC;0CAn+B8B,GAAG,KAAG,MAAM;mCAoBxC,MAAM,mBACJ,MAAM,EAAE,KACxB,MAAM;;AAwgCT,wBAQE"}
1
+ {"version":3,"file":"langChain.d.ts","sourceRoot":"","sources":["../../src/langChain.ts"],"names":[],"mappings":"AA2BA;;;;;GAKG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE;QAAE,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;CACxC,CAAC;;0CAE2C,GAAG,KAAG,IAAI;wBA+jB/C,MAAM,aACD,MAAM,gBACH,MAAM,SACb,GAAG,EAAE,kBACI,GAAG,UACX,GAAG,KACV,GAAG;sBA0BG,GAAG,UACF,MAAM,UACN,GAAG,eACC,QAAQ,GAAG,IAAI,iBACb,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;oCA4XF,MAAM,UACT,GAAG,UACH,GAAG,oBACO,GAAG,eACT,MAAM,eACN,MAAM,wBACG,OAAO,WACpB,GAAG,cACA,MAAM,UACV,GAAG,EAAE,iBACE,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;sDAusBF,MAAM,UACT,GAAG,UACH,MAAM,YACL,GAAG,iBACE,YAAY,GAAG,IAAI,KAChC,OAAO,CAAC,GAAG,CAAC;0CA3+B8B,GAAG,KAAG,MAAM;+CApjB9C,YAAY,GAAG,IAAI,GAAG,SAAS,aAC7B,MAAM,kBACD,MAAM,UACd,GAAG,KACV,IAAI;mCAokBU,MAAM,mBACJ,MAAM,EAAE,KACxB,MAAM;;AAkhCT,wBASE"}
@@ -18,20 +18,6 @@ CRITICAL - Your response must be valid JSON. Escape ALL special characters in st
18
18
  - Backslashes → \\\\
19
19
  Do NOT include raw newlines, tabs, or unescaped quotes inside JSON string values.
20
20
  `.trim();
21
- /**
22
- * Reads `global.quota` (set by the StackFactor host before invoking `main()`) and
23
- * throws a `PAYMENT_REQUIRED` error when the session has no remaining budget. No-op
24
- * when `global.quota` is absent (e.g. local tests) or `remaining` is not a number,
25
- * which keeps the library usable outside the StackFactor runtime.
26
- */
27
- const assertQuotaAvailable = () => {
28
- const quota = globalThis.quota;
29
- if (!quota || typeof quota.remaining !== "number")
30
- return;
31
- if (quota.remaining <= 0) {
32
- throw errorHandlingHelper.create(constants.HTTP_CODES.PAYMENT_REQUIRED, constants.ERROR.QUOTA_EXHAUSTED);
33
- }
34
- };
35
21
  const checkIfAIProviderConfigured = (config) => {
36
22
  if (!config ||
37
23
  !config.openAIAPIKey ||
@@ -40,104 +26,137 @@ const checkIfAIProviderConfigured = (config) => {
40
26
  throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, "The integration is not properly configured with all AI providers.");
41
27
  }
42
28
  };
43
- /**
44
- * Subtracts the given USD cost from `global.quota.remaining` and adds it to
45
- * `global.quota.usedThisSession`. Silently no-ops when the quota globals are not
46
- * present so callers do not need to branch. Negative, zero, or non-finite costs
47
- * are ignored to keep counters monotonic.
48
- */
49
- const recordCost = (cost) => {
50
- if (!cost || cost <= 0 || !Number.isFinite(cost))
51
- return;
52
- const quota = globalThis.quota;
53
- if (!quota)
54
- return;
55
- if (typeof quota.remaining === "number") {
56
- quota.remaining -= cost;
57
- }
58
- if (typeof quota.usedThisSession === "number") {
59
- quota.usedThisSession += cost;
60
- }
61
- else {
62
- quota.usedThisSession = cost;
63
- }
64
- };
65
29
  /**
66
30
  * Reads a per-model rate from the flat cost constants the integration defines
67
31
  * in its `config.yaml` (exposed on the config object), e.g.
68
- * `claude-opus-4-7-input-token-costs`. Returns `null` when the constant is
69
- * absent or not numeric, in which case cost recording becomes a no-op (the
70
- * call still succeeds — pricing is the integration's configuration concern).
32
+ * `claude-opus-4-7-input-token-costs`. Returns `0` when the constant is
33
+ * absent or not numeric, which leaves cost contribution zero.
71
34
  */
72
35
  const getModelRate = (modelName, config, kind) => {
73
36
  if (!config || !modelName)
74
- return null;
37
+ return 0;
75
38
  const rate = Number(config[`${modelName}-${kind}-costs`]);
76
- return Number.isFinite(rate) ? rate : null;
39
+ return Number.isFinite(rate) ? rate : 0;
77
40
  };
78
41
  /**
79
- * Accumulates the per-model usage breakdown on `global.quota.byModel` so the
80
- * agent can report input/output token counts and image spend per model to the
81
- * UI. No-ops when the quota global is absent.
42
+ * Adds a single call's usage to the caller-supplied tracker. Initializes
43
+ * `cost` and `tokens` lazily so callers can pass `{}`. No-ops when the
44
+ * tracker is null/undefined.
82
45
  */
83
- const recordModelBreakdown = (modelName, inputCost, outputCost, imageCost, inputTokens = 0, outputTokens = 0) => {
84
- const quota = globalThis.quota;
85
- if (!quota || !modelName)
46
+ const updateUsageTracker = (tracker, modelName, usage, config) => {
47
+ if (!tracker || !usage || !modelName)
86
48
  return;
87
- if (!quota.byModel || typeof quota.byModel !== "object")
88
- quota.byModel = {};
89
- const entry = (quota.byModel[modelName] ||= {
90
- inputCost: 0,
91
- outputCost: 0,
92
- imageCost: 0,
93
- inputTokens: 0,
94
- outputTokens: 0,
95
- });
96
- // Entries created by older versions of this module lack the token counters.
97
- if (typeof entry.inputTokens !== "number")
98
- entry.inputTokens = 0;
99
- if (typeof entry.outputTokens !== "number")
100
- entry.outputTokens = 0;
101
- if (Number.isFinite(inputCost) && inputCost > 0)
102
- entry.inputCost += inputCost;
103
- if (Number.isFinite(outputCost) && outputCost > 0)
104
- entry.outputCost += outputCost;
105
- if (Number.isFinite(imageCost) && imageCost > 0)
106
- entry.imageCost += imageCost;
107
- if (Number.isFinite(inputTokens) && inputTokens > 0)
108
- entry.inputTokens += inputTokens;
109
- if (Number.isFinite(outputTokens) && outputTokens > 0)
110
- entry.outputTokens += outputTokens;
49
+ if (typeof tracker.cost !== "number")
50
+ tracker.cost = 0;
51
+ if (!tracker.tokens || typeof tracker.tokens !== "object")
52
+ tracker.tokens = {};
53
+ const inputTokens = usage.input_tokens || 0;
54
+ const outputTokens = usage.output_tokens || 0;
55
+ const inputRate = getModelRate(modelName, config, "input-token");
56
+ const outputRate = getModelRate(modelName, config, "output-token");
57
+ const addedCost = (inputTokens / 1_000_000) * inputRate +
58
+ (outputTokens / 1_000_000) * outputRate;
59
+ if (Number.isFinite(addedCost) && addedCost > 0)
60
+ tracker.cost += addedCost;
61
+ const inputKey = `${modelName}_inputTokens`;
62
+ const outputKey = `${modelName}_outputTokens`;
63
+ tracker.tokens[inputKey] = (tracker.tokens[inputKey] || 0) + inputTokens;
64
+ tracker.tokens[outputKey] = (tracker.tokens[outputKey] || 0) + outputTokens;
111
65
  };
112
66
  /**
113
- * Computes and records the USD cost of a text LLM call from the
114
- * `<model>-input-token-costs` / `<model>-output-token-costs` constants
115
- * (dollars per million tokens). Updates both the session total
116
- * (`quota.usedThisSession` / `quota.remaining`) and the per-model breakdown
117
- * (`quota.byModel`).
67
+ * Adds a single image-generation call's usage to the caller-supplied tracker.
68
+ * Extracts token counts from provider-specific response shapes: OpenAI returns
69
+ * `response.usage` with `input_tokens_details.{text_tokens,image_tokens}` and
70
+ * `output_tokens`; Google returns `response.usageMetadata` with
71
+ * `promptTokensDetails` (modality-keyed) and `candidatesTokenCount` for the
72
+ * generated image. Reads rates from `<model>-input-token-costs`,
73
+ * `<model>-image-input-token-costs`, and `<model>-image-output-token-costs`.
74
+ * No-ops when the tracker or response usage info is absent.
118
75
  */
119
- const recordTextCost = (modelName, usage, config) => {
120
- if (!usage)
76
+ const updateImageUsageTracker = (tracker, modelName, response, config, provider) => {
77
+ if (!tracker || !modelName || !response)
121
78
  return;
122
- const inputRate = getModelRate(modelName, config, "input-token") || 0;
123
- const outputRate = getModelRate(modelName, config, "output-token") || 0;
124
- const inputTokens = usage.input_tokens || 0;
125
- const outputTokens = usage.output_tokens || 0;
126
- const inputCost = (inputTokens / 1_000_000) * inputRate;
127
- const outputCost = (outputTokens / 1_000_000) * outputRate;
128
- recordCost(inputCost + outputCost);
129
- recordModelBreakdown(modelName, inputCost, outputCost, 0, inputTokens, outputTokens);
79
+ if (typeof tracker.cost !== "number")
80
+ tracker.cost = 0;
81
+ if (!tracker.tokens || typeof tracker.tokens !== "object")
82
+ tracker.tokens = {};
83
+ let textInputTokens = 0;
84
+ let imageInputTokens = 0;
85
+ let imageOutputTokens = 0;
86
+ if (provider === "openai") {
87
+ const usage = response.usage;
88
+ if (!usage)
89
+ return;
90
+ const details = usage.input_tokens_details || {};
91
+ textInputTokens = details.text_tokens || 0;
92
+ imageInputTokens = details.image_tokens || 0;
93
+ // Older shapes report a single input_tokens without modality breakdown.
94
+ if (!textInputTokens && !imageInputTokens && usage.input_tokens) {
95
+ textInputTokens = usage.input_tokens;
96
+ }
97
+ imageOutputTokens = usage.output_tokens || 0;
98
+ }
99
+ else {
100
+ const um = response.usageMetadata;
101
+ if (!um)
102
+ return;
103
+ const promptDetails = Array.isArray(um.promptTokensDetails)
104
+ ? um.promptTokensDetails
105
+ : [];
106
+ for (const d of promptDetails) {
107
+ const modality = String(d?.modality || "").toUpperCase();
108
+ const count = Number(d?.tokenCount) || 0;
109
+ if (modality === "IMAGE")
110
+ imageInputTokens += count;
111
+ else
112
+ textInputTokens += count;
113
+ }
114
+ if (!textInputTokens && !imageInputTokens) {
115
+ textInputTokens = Number(um.promptTokenCount) || 0;
116
+ }
117
+ imageOutputTokens = Number(um.candidatesTokenCount) || 0;
118
+ }
119
+ const textInputRate = getModelRate(modelName, config, "input-token");
120
+ const imageInputRate = getModelRate(modelName, config, "image-input-token");
121
+ const imageOutputRate = getModelRate(modelName, config, "image-output-token");
122
+ const addedCost = (textInputTokens / 1_000_000) * textInputRate +
123
+ (imageInputTokens / 1_000_000) * imageInputRate +
124
+ (imageOutputTokens / 1_000_000) * imageOutputRate;
125
+ if (Number.isFinite(addedCost) && addedCost > 0)
126
+ tracker.cost += addedCost;
127
+ if (textInputTokens > 0) {
128
+ const key = `${modelName}_inputTokens`;
129
+ tracker.tokens[key] = (tracker.tokens[key] || 0) + textInputTokens;
130
+ }
131
+ if (imageInputTokens > 0) {
132
+ const key = `${modelName}_imageInputTokens`;
133
+ tracker.tokens[key] = (tracker.tokens[key] || 0) + imageInputTokens;
134
+ }
135
+ if (imageOutputTokens > 0) {
136
+ const key = `${modelName}_imageOutputTokens`;
137
+ tracker.tokens[key] = (tracker.tokens[key] || 0) + imageOutputTokens;
138
+ }
130
139
  };
131
140
  /**
132
- * Computes and records the USD cost of an image generation call from the
133
- * `<model>-image-costs` constant (dollars per generated image). Updates both
134
- * the session total and the per-model breakdown.
141
+ * Adds character-billed usage (e.g. ElevenLabs TTS) to the caller-supplied
142
+ * tracker. There is no provider response to parse — billing is deterministic
143
+ * from the input character count. Reads the rate from
144
+ * `<model>-character-costs` (USD per million characters) and accumulates
145
+ * under `<model>_characters`. No-ops on missing tracker / model / count.
135
146
  */
136
- const recordImageCost = (modelName, numImages, config) => {
137
- const rate = getModelRate(modelName, config, "image") || 0;
138
- const imageCost = (numImages || 0) * rate;
139
- recordCost(imageCost);
140
- recordModelBreakdown(modelName, 0, 0, imageCost);
147
+ const updateUsageTrackerForCharacters = (tracker, modelName, characterCount, config) => {
148
+ if (!tracker || !modelName || !characterCount)
149
+ return;
150
+ if (typeof tracker.cost !== "number")
151
+ tracker.cost = 0;
152
+ if (!tracker.tokens || typeof tracker.tokens !== "object")
153
+ tracker.tokens = {};
154
+ const rate = getModelRate(modelName, config, "character");
155
+ const addedCost = (characterCount / 1_000_000) * rate;
156
+ if (Number.isFinite(addedCost) && addedCost > 0)
157
+ tracker.cost += addedCost;
158
+ const key = `${modelName}_characters`;
159
+ tracker.tokens[key] = (tracker.tokens[key] || 0) + characterCount;
141
160
  };
142
161
  /**
143
162
  * Extracts a normalized token-usage object from a single LangChain `invoke()`
@@ -536,9 +555,10 @@ const createAgent = (name, modelName, systemPrompt, tools = [], responseFormat,
536
555
  * number of agent steps (defaults to `25` when not specified)
537
556
  * @param onProgress - Optional callback invoked with progress event objects throughout
538
557
  * agent execution
558
+ * @param usageTracker - Optional usage accumulator updated on successful completion
539
559
  * @returns The raw response object returned by the agent's `invoke` method
540
560
  */
541
- const runAgent = async (agent, prompt, config, onProgress = null) => {
561
+ const runAgent = async (agent, prompt, config, onProgress = null, usageTracker = null) => {
542
562
  const startTime = Date.now();
543
563
  const callbacks = onProgress
544
564
  ? [
@@ -573,13 +593,12 @@ const runAgent = async (agent, prompt, config, onProgress = null) => {
573
593
  // many internal LLM calls, but a rate limit surfaces as a thrown error from
574
594
  // the wrapping invoke. On retry we restart the agent run from scratch — any
575
595
  // partial progress (tool calls, intermediate messages) is discarded, since
576
- // the agent state isn't externally checkpointed. Cost is only recorded on a
596
+ // the agent state isn't externally checkpointed. Usage is only recorded on a
577
597
  // successful completion. Matches the behavior of the non-agentic paths.
578
598
  let response;
579
599
  let rateLimitAttempt = 0;
580
600
  while (true) {
581
601
  try {
582
- assertQuotaAvailable();
583
602
  response = await agent.invoke({
584
603
  messages: [{ role: "user", content: prompt }],
585
604
  }, {
@@ -606,7 +625,7 @@ const runAgent = async (agent, prompt, config, onProgress = null) => {
606
625
  throw err;
607
626
  }
608
627
  }
609
- recordTextCost(modelName, sumAgentResponseUsage(response), config);
628
+ updateUsageTracker(usageTracker, modelName, sumAgentResponseUsage(response), config);
610
629
  const endTime = Date.now();
611
630
  const duration = endTime - startTime;
612
631
  logger.log(null, logger.levels.info, `Agent "${agent.options?.name}" completed in ${Math.round(duration / 1000)} seconds.`);
@@ -861,10 +880,13 @@ const buildValidationRetryMessages = (priorMessages, rawContent, validationError
861
880
  * `"StackFactor"`
862
881
  * @param tools - Additional LangChain tools available to the agent in agentic mode;
863
882
  * defaults to an empty array
883
+ * @param usageTracker - Optional accumulator updated with `cost` and per-model token
884
+ * counts for every successful LLM call. Callers should initialise as `{}` before
885
+ * the run and read the populated values after.
864
886
  * @returns The LLM response as a JSON string (when `expectsJsonResponse` is `true`) or
865
887
  * as raw content (when `false`)
866
888
  */
867
- const runPromptWithModel = async (modelName, config, prompt, onProgressReport, minPercent = 0, maxPercent = 100, expectsJsonResponse = true, schema = null, agentName = "StackFactor", tools = []) => {
889
+ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, minPercent = 0, maxPercent = 100, expectsJsonResponse = true, schema = null, agentName = "StackFactor", tools = [], usageTracker = null) => {
868
890
  // Agentic mode: use an agent with tools instead of simple LLM invocation
869
891
  if (config.agentic === true) {
870
892
  // Extract system prompt and user prompt from the prompt parameter
@@ -898,7 +920,7 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
898
920
  const agent = createAgent(agentName, modelName, systemPrompt, [...tools], null, // responseFormat
899
921
  config);
900
922
  // Run the agent with progress callback
901
- const response = await runAgent(agent, userPrompt, config, onProgressReport || null);
923
+ const response = await runAgent(agent, userPrompt, config, onProgressReport || null, usageTracker);
902
924
  // Extract content from agent response
903
925
  const messages = response?.messages || [];
904
926
  if (messages.length === 0) {
@@ -1007,8 +1029,8 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1007
1029
  // callback — same "Generating content..." message and the progress curve
1008
1030
  // continues from the original startTime so the bar doesn't visibly reset.
1009
1031
  // Skipped when OpenAI native response_format is in use (a 422 there is a
1010
- // server-side schema bug, not a model output issue). Each attempt is billed
1011
- // independently via recordCost so retry cost is still visible in telemetry.
1032
+ // server-side schema bug, not a model output issue). Each attempt accumulates
1033
+ // independently into usageTracker so retry usage is still visible.
1012
1034
  const canRetryOnValidation = expectsJsonResponse && !!schema && !useNativeSchema;
1013
1035
  const progressReportInterval = 10; // Report every N chunks
1014
1036
  const overallStartTime = Date.now();
@@ -1029,16 +1051,15 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1029
1051
  let chunkCount = 0;
1030
1052
  let streamUsage = { input_tokens: 0, output_tokens: 0 };
1031
1053
  // Inner loop: wait + retry on 429 around stream setup and consumption.
1032
- // Cost is only recorded on a successful stream — partial streams that error
1033
- // out with a rate limit are not billed. A 429 fired mid-stream simply
1034
- // discards the partial output and restarts cleanly.
1054
+ // Usage is only recorded on a successful stream — partial streams that
1055
+ // error out with a rate limit are not counted. A 429 fired mid-stream
1056
+ // simply discards the partial output and restarts cleanly.
1035
1057
  let rateLimitAttempt = 0;
1036
1058
  while (true) {
1037
1059
  rawContent = "";
1038
1060
  chunkCount = 0;
1039
1061
  streamUsage = { input_tokens: 0, output_tokens: 0 };
1040
1062
  try {
1041
- assertQuotaAvailable();
1042
1063
  // Honour caller cancellation: passing the signal tears down the
1043
1064
  // upstream HTTP request so a cancelled call stops billing tokens.
1044
1065
  const abortSignal = getAbortSignal();
@@ -1081,7 +1102,7 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1081
1102
  throw err;
1082
1103
  }
1083
1104
  }
1084
- recordTextCost(modelName, streamUsage, config);
1105
+ updateUsageTracker(usageTracker, modelName, streamUsage, config);
1085
1106
  if (!rawContent) {
1086
1107
  throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, "LLM returned no content");
1087
1108
  }
@@ -1154,7 +1175,7 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1154
1175
  messagesToSend = messages;
1155
1176
  }
1156
1177
  // Retry once on schema validation failure (see streaming branch for rationale).
1157
- // Each attempt is billed independently via recordCost so retry cost is visible.
1178
+ // Each attempt accumulates independently into usageTracker.
1158
1179
  const canRetryOnValidation = expectsJsonResponse && !!schema && !useNativeSchema;
1159
1180
  let activeMessages = messagesToSend;
1160
1181
  let attempt = 0;
@@ -1165,7 +1186,6 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1165
1186
  let rateLimitAttempt = 0;
1166
1187
  while (true) {
1167
1188
  try {
1168
- assertQuotaAvailable();
1169
1189
  response = await llm.invoke(activeMessages);
1170
1190
  break;
1171
1191
  }
@@ -1181,7 +1201,7 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
1181
1201
  throw err;
1182
1202
  }
1183
1203
  }
1184
- recordTextCost(modelName, extractUsageFromInvoke(response), config);
1204
+ updateUsageTracker(usageTracker, modelName, extractUsageFromInvoke(response), config);
1185
1205
  const rawContent = response?.content || response;
1186
1206
  // If not expecting JSON, return raw content directly
1187
1207
  if (!expectsJsonResponse) {
@@ -1240,7 +1260,7 @@ const getImageModelProvider = (modelName) => {
1240
1260
  * @returns An object with `url`, `b64_json`, and `revisedPrompt` for a single image,
1241
1261
  * or `{ images: [...] }` for multiple images
1242
1262
  */
1243
- const generateImageWithOpenAI = async (modelName, config, prompt, options) => {
1263
+ const generateImageWithOpenAI = async (modelName, config, prompt, options, usageTracker = null) => {
1244
1264
  const { size = "1024x1024", style = "vivid", responseFormat = "url", n = 1, } = options;
1245
1265
  if (!config.openAIAPIKey) {
1246
1266
  throw errorHandlingHelper.create(constants.HTTP_CODES.BAD_REQUEST, "OpenAI API key is required for OpenAI image generation");
@@ -1287,9 +1307,8 @@ const generateImageWithOpenAI = async (modelName, config, prompt, options) => {
1287
1307
  requestParams.style = style;
1288
1308
  }
1289
1309
  }
1290
- assertQuotaAvailable();
1291
1310
  const response = await openai.images.generate(requestParams);
1292
- recordImageCost(modelName, response.data?.length || n, config);
1311
+ updateImageUsageTracker(usageTracker, modelName, response, config, "openai");
1293
1312
  // Format response based on number of images
1294
1313
  if (n === 1) {
1295
1314
  const imageData = response.data[0];
@@ -1327,7 +1346,7 @@ const generateImageWithOpenAI = async (modelName, config, prompt, options) => {
1327
1346
  * @returns A single image descriptor `{ b64_json, mimeType }` when one image is
1328
1347
  * requested, or `{ images: [...] }` for multiple images
1329
1348
  */
1330
- const generateImageWithGoogle = async (modelName, config, prompt, options) => {
1349
+ const generateImageWithGoogle = async (modelName, config, prompt, options, usageTracker = null) => {
1331
1350
  const { aspectRatio = "1:1", numberOfImages = 1, negativePrompt = "", } = options;
1332
1351
  if (!config.googleAPIKey) {
1333
1352
  throw errorHandlingHelper.create(constants.HTTP_CODES.BAD_REQUEST, "Google API key is required for Google image generation");
@@ -1387,8 +1406,8 @@ const generateImageWithGoogle = async (modelName, config, prompt, options) => {
1387
1406
  safetySettings: safetySettings,
1388
1407
  },
1389
1408
  };
1390
- assertQuotaAvailable();
1391
1409
  const response = await ai.models.generateContent(req);
1410
+ updateImageUsageTracker(usageTracker, modelName, response, config, "google");
1392
1411
  // Extract images from response
1393
1412
  const images = [];
1394
1413
  const candidates = response.candidates || [];
@@ -1406,7 +1425,6 @@ const generateImageWithGoogle = async (modelName, config, prompt, options) => {
1406
1425
  if (images.length === 0) {
1407
1426
  throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, `No images were generated by ${modelName}`);
1408
1427
  }
1409
- recordImageCost(modelName, images.length, config);
1410
1428
  if (numberOfImages === 1 || images.length === 1) {
1411
1429
  return images[0];
1412
1430
  }
@@ -1432,7 +1450,7 @@ const generateImageWithGoogle = async (modelName, config, prompt, options) => {
1432
1450
  * and `generateImageWithGoogle` for full option sets); defaults to `{}`
1433
1451
  * @returns The generated image data object returned by the provider-specific function
1434
1452
  */
1435
- const runPromptWithModelForImageGeneration = async (modelName, config, prompt, options = {}) => {
1453
+ const runPromptWithModelForImageGeneration = async (modelName, config, prompt, options = {}, usageTracker = null) => {
1436
1454
  const provider = getImageModelProvider(modelName);
1437
1455
  if (!provider) {
1438
1456
  throw errorHandlingHelper.create(constants.HTTP_CODES.BAD_REQUEST, `Unable to determine provider for model: ${modelName}. Model name should start with 'gpt-image-', 'gemini-', or 'imagen-'.`);
@@ -1441,10 +1459,10 @@ const runPromptWithModelForImageGeneration = async (modelName, config, prompt, o
1441
1459
  try {
1442
1460
  let result;
1443
1461
  if (provider === "openai") {
1444
- result = await generateImageWithOpenAI(modelName, config, prompt, options);
1462
+ result = await generateImageWithOpenAI(modelName, config, prompt, options, usageTracker);
1445
1463
  }
1446
1464
  else if (provider === "google") {
1447
- result = await generateImageWithGoogle(modelName, config, prompt, options);
1465
+ result = await generateImageWithGoogle(modelName, config, prompt, options, usageTracker);
1448
1466
  }
1449
1467
  const endTime = Date.now();
1450
1468
  const duration = endTime - startTime;
@@ -1472,5 +1490,6 @@ export default {
1472
1490
  runPromptWithModel,
1473
1491
  runPromptWithModelForImageGeneration,
1474
1492
  throwErrorIfNotSuccessful,
1493
+ updateUsageTrackerForCharacters,
1475
1494
  validateModel,
1476
1495
  };
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.2.6",
6
+ "version": "1.2.8",
7
7
  "description": "",
8
8
  "main": "dist/cjs/index.js",
9
9
  "module": "dist/esm/index.js",