@dexcost/sdk 0.2.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.
Files changed (211) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +210 -0
  3. package/dist/adapters/_netbytes.d.ts +31 -0
  4. package/dist/adapters/_netbytes.d.ts.map +1 -0
  5. package/dist/adapters/_netbytes.js +154 -0
  6. package/dist/adapters/_netbytes.js.map +1 -0
  7. package/dist/adapters/aws-lambda.d.ts +41 -0
  8. package/dist/adapters/aws-lambda.d.ts.map +1 -0
  9. package/dist/adapters/aws-lambda.js +65 -0
  10. package/dist/adapters/aws-lambda.js.map +1 -0
  11. package/dist/adapters/browser.d.ts +52 -0
  12. package/dist/adapters/browser.d.ts.map +1 -0
  13. package/dist/adapters/browser.js +127 -0
  14. package/dist/adapters/browser.js.map +1 -0
  15. package/dist/adapters/compute-wrap.d.ts +33 -0
  16. package/dist/adapters/compute-wrap.d.ts.map +1 -0
  17. package/dist/adapters/compute-wrap.js +188 -0
  18. package/dist/adapters/compute-wrap.js.map +1 -0
  19. package/dist/adapters/data/aws_lambda_pricing.json +61 -0
  20. package/dist/adapters/gpu-wrap.d.ts +31 -0
  21. package/dist/adapters/gpu-wrap.d.ts.map +1 -0
  22. package/dist/adapters/gpu-wrap.js +147 -0
  23. package/dist/adapters/gpu-wrap.js.map +1 -0
  24. package/dist/adapters/http.d.ts +58 -0
  25. package/dist/adapters/http.d.ts.map +1 -0
  26. package/dist/adapters/http.js +769 -0
  27. package/dist/adapters/http.js.map +1 -0
  28. package/dist/adapters/index.d.ts +11 -0
  29. package/dist/adapters/index.d.ts.map +1 -0
  30. package/dist/adapters/index.js +12 -0
  31. package/dist/adapters/index.js.map +1 -0
  32. package/dist/adapters/network-accountant.d.ts +63 -0
  33. package/dist/adapters/network-accountant.d.ts.map +1 -0
  34. package/dist/adapters/network-accountant.js +153 -0
  35. package/dist/adapters/network-accountant.js.map +1 -0
  36. package/dist/cli/index.d.ts +13 -0
  37. package/dist/cli/index.d.ts.map +1 -0
  38. package/dist/cli/index.js +225 -0
  39. package/dist/cli/index.js.map +1 -0
  40. package/dist/cli/scanner.d.ts +39 -0
  41. package/dist/cli/scanner.d.ts.map +1 -0
  42. package/dist/cli/scanner.js +480 -0
  43. package/dist/cli/scanner.js.map +1 -0
  44. package/dist/clients.d.ts +54 -0
  45. package/dist/clients.d.ts.map +1 -0
  46. package/dist/clients.js +240 -0
  47. package/dist/clients.js.map +1 -0
  48. package/dist/cloud-detect.d.ts +96 -0
  49. package/dist/cloud-detect.d.ts.map +1 -0
  50. package/dist/cloud-detect.js +545 -0
  51. package/dist/cloud-detect.js.map +1 -0
  52. package/dist/core/auto-task.d.ts +20 -0
  53. package/dist/core/auto-task.d.ts.map +1 -0
  54. package/dist/core/auto-task.js +34 -0
  55. package/dist/core/auto-task.js.map +1 -0
  56. package/dist/core/cgroup-reader.d.ts +45 -0
  57. package/dist/core/cgroup-reader.d.ts.map +1 -0
  58. package/dist/core/cgroup-reader.js +124 -0
  59. package/dist/core/cgroup-reader.js.map +1 -0
  60. package/dist/core/cgroup-walker.d.ts +60 -0
  61. package/dist/core/cgroup-walker.d.ts.map +1 -0
  62. package/dist/core/cgroup-walker.js +166 -0
  63. package/dist/core/cgroup-walker.js.map +1 -0
  64. package/dist/core/compute-accountant.d.ts +51 -0
  65. package/dist/core/compute-accountant.d.ts.map +1 -0
  66. package/dist/core/compute-accountant.js +179 -0
  67. package/dist/core/compute-accountant.js.map +1 -0
  68. package/dist/core/compute-runtime.d.ts +42 -0
  69. package/dist/core/compute-runtime.d.ts.map +1 -0
  70. package/dist/core/compute-runtime.js +80 -0
  71. package/dist/core/compute-runtime.js.map +1 -0
  72. package/dist/core/config.d.ts +44 -0
  73. package/dist/core/config.d.ts.map +1 -0
  74. package/dist/core/config.js +66 -0
  75. package/dist/core/config.js.map +1 -0
  76. package/dist/core/context.d.ts +76 -0
  77. package/dist/core/context.d.ts.map +1 -0
  78. package/dist/core/context.js +91 -0
  79. package/dist/core/context.js.map +1 -0
  80. package/dist/core/fargate-metadata.d.ts +27 -0
  81. package/dist/core/fargate-metadata.d.ts.map +1 -0
  82. package/dist/core/fargate-metadata.js +102 -0
  83. package/dist/core/fargate-metadata.js.map +1 -0
  84. package/dist/core/gpu-accountant.d.ts +104 -0
  85. package/dist/core/gpu-accountant.d.ts.map +1 -0
  86. package/dist/core/gpu-accountant.js +383 -0
  87. package/dist/core/gpu-accountant.js.map +1 -0
  88. package/dist/core/gpu-runtime.d.ts +58 -0
  89. package/dist/core/gpu-runtime.d.ts.map +1 -0
  90. package/dist/core/gpu-runtime.js +131 -0
  91. package/dist/core/gpu-runtime.js.map +1 -0
  92. package/dist/core/heuristics.d.ts +74 -0
  93. package/dist/core/heuristics.d.ts.map +1 -0
  94. package/dist/core/heuristics.js +182 -0
  95. package/dist/core/heuristics.js.map +1 -0
  96. package/dist/core/models.d.ts +149 -0
  97. package/dist/core/models.d.ts.map +1 -0
  98. package/dist/core/models.js +226 -0
  99. package/dist/core/models.js.map +1 -0
  100. package/dist/core/nvml-reader.d.ts +114 -0
  101. package/dist/core/nvml-reader.d.ts.map +1 -0
  102. package/dist/core/nvml-reader.js +323 -0
  103. package/dist/core/nvml-reader.js.map +1 -0
  104. package/dist/core/session.d.ts +48 -0
  105. package/dist/core/session.d.ts.map +1 -0
  106. package/dist/core/session.js +123 -0
  107. package/dist/core/session.js.map +1 -0
  108. package/dist/core/tracker.d.ts +364 -0
  109. package/dist/core/tracker.d.ts.map +1 -0
  110. package/dist/core/tracker.js +1073 -0
  111. package/dist/core/tracker.js.map +1 -0
  112. package/dist/data/compute_prices.json +180 -0
  113. package/dist/data/egress_prices.json +418 -0
  114. package/dist/data/gpu_prices.json +412 -0
  115. package/dist/data/service_prices.json +2595 -0
  116. package/dist/dev-console.d.ts +12 -0
  117. package/dist/dev-console.d.ts.map +1 -0
  118. package/dist/dev-console.js +60 -0
  119. package/dist/dev-console.js.map +1 -0
  120. package/dist/index.d.ts +52 -0
  121. package/dist/index.d.ts.map +1 -0
  122. package/dist/index.js +61 -0
  123. package/dist/index.js.map +1 -0
  124. package/dist/instruments/anthropic.d.ts +26 -0
  125. package/dist/instruments/anthropic.d.ts.map +1 -0
  126. package/dist/instruments/anthropic.js +242 -0
  127. package/dist/instruments/anthropic.js.map +1 -0
  128. package/dist/instruments/bedrock.d.ts +29 -0
  129. package/dist/instruments/bedrock.d.ts.map +1 -0
  130. package/dist/instruments/bedrock.js +215 -0
  131. package/dist/instruments/bedrock.js.map +1 -0
  132. package/dist/instruments/cohere.d.ts +29 -0
  133. package/dist/instruments/cohere.d.ts.map +1 -0
  134. package/dist/instruments/cohere.js +237 -0
  135. package/dist/instruments/cohere.js.map +1 -0
  136. package/dist/instruments/gemini.d.ts +30 -0
  137. package/dist/instruments/gemini.d.ts.map +1 -0
  138. package/dist/instruments/gemini.js +247 -0
  139. package/dist/instruments/gemini.js.map +1 -0
  140. package/dist/instruments/index.d.ts +35 -0
  141. package/dist/instruments/index.d.ts.map +1 -0
  142. package/dist/instruments/index.js +54 -0
  143. package/dist/instruments/index.js.map +1 -0
  144. package/dist/instruments/mcp.d.ts +24 -0
  145. package/dist/instruments/mcp.d.ts.map +1 -0
  146. package/dist/instruments/mcp.js +459 -0
  147. package/dist/instruments/mcp.js.map +1 -0
  148. package/dist/instruments/openai.d.ts +26 -0
  149. package/dist/instruments/openai.d.ts.map +1 -0
  150. package/dist/instruments/openai.js +221 -0
  151. package/dist/instruments/openai.js.map +1 -0
  152. package/dist/instruments/vercel-ai.d.ts +28 -0
  153. package/dist/instruments/vercel-ai.d.ts.map +1 -0
  154. package/dist/instruments/vercel-ai.js +192 -0
  155. package/dist/instruments/vercel-ai.js.map +1 -0
  156. package/dist/integrations/langchain.d.ts +65 -0
  157. package/dist/integrations/langchain.d.ts.map +1 -0
  158. package/dist/integrations/langchain.js +165 -0
  159. package/dist/integrations/langchain.js.map +1 -0
  160. package/dist/middleware/express.d.ts +55 -0
  161. package/dist/middleware/express.d.ts.map +1 -0
  162. package/dist/middleware/express.js +101 -0
  163. package/dist/middleware/express.js.map +1 -0
  164. package/dist/middleware/index.d.ts +6 -0
  165. package/dist/middleware/index.d.ts.map +1 -0
  166. package/dist/middleware/index.js +5 -0
  167. package/dist/middleware/index.js.map +1 -0
  168. package/dist/pricing/compute-pricing.d.ts +57 -0
  169. package/dist/pricing/compute-pricing.d.ts.map +1 -0
  170. package/dist/pricing/compute-pricing.js +627 -0
  171. package/dist/pricing/compute-pricing.js.map +1 -0
  172. package/dist/pricing/cost_map.json +37665 -0
  173. package/dist/pricing/egress-pricing.d.ts +55 -0
  174. package/dist/pricing/egress-pricing.d.ts.map +1 -0
  175. package/dist/pricing/egress-pricing.js +226 -0
  176. package/dist/pricing/egress-pricing.js.map +1 -0
  177. package/dist/pricing/engine.d.ts +24 -0
  178. package/dist/pricing/engine.d.ts.map +1 -0
  179. package/dist/pricing/engine.js +148 -0
  180. package/dist/pricing/engine.js.map +1 -0
  181. package/dist/pricing/gpu-pricing.d.ts +63 -0
  182. package/dist/pricing/gpu-pricing.d.ts.map +1 -0
  183. package/dist/pricing/gpu-pricing.js +484 -0
  184. package/dist/pricing/gpu-pricing.js.map +1 -0
  185. package/dist/pricing/rates.d.ts +17 -0
  186. package/dist/pricing/rates.d.ts.map +1 -0
  187. package/dist/pricing/rates.js +102 -0
  188. package/dist/pricing/rates.js.map +1 -0
  189. package/dist/pricing/service-catalog.d.ts +87 -0
  190. package/dist/pricing/service-catalog.d.ts.map +1 -0
  191. package/dist/pricing/service-catalog.js +406 -0
  192. package/dist/pricing/service-catalog.js.map +1 -0
  193. package/dist/schema/dexcost-event.v1.json +111 -0
  194. package/dist/schema/dexcost-task.v1.json +160 -0
  195. package/dist/schema/validate.d.ts +15 -0
  196. package/dist/schema/validate.d.ts.map +1 -0
  197. package/dist/schema/validate.js +87 -0
  198. package/dist/schema/validate.js.map +1 -0
  199. package/dist/security/redaction.d.ts +55 -0
  200. package/dist/security/redaction.d.ts.map +1 -0
  201. package/dist/security/redaction.js +144 -0
  202. package/dist/security/redaction.js.map +1 -0
  203. package/dist/transport/buffer.d.ts +117 -0
  204. package/dist/transport/buffer.d.ts.map +1 -0
  205. package/dist/transport/buffer.js +759 -0
  206. package/dist/transport/buffer.js.map +1 -0
  207. package/dist/transport/pusher.d.ts +89 -0
  208. package/dist/transport/pusher.d.ts.map +1 -0
  209. package/dist/transport/pusher.js +323 -0
  210. package/dist/transport/pusher.js.map +1 -0
  211. package/package.json +93 -0
@@ -0,0 +1,1073 @@
1
+ /**
2
+ * CostTracker — the main entry point for recording AI agent costs.
3
+ *
4
+ * Wraps business logic in tracked tasks, records cost events, and
5
+ * manages background flushing to a remote endpoint.
6
+ */
7
+ import { randomUUID } from "node:crypto";
8
+ import { Decimal } from "decimal.js";
9
+ /**
10
+ * Decimal-based addition to defeat floating-point drift in cost
11
+ * accumulation. Sprint 2 Theme E / §3.3.1 (B3).
12
+ *
13
+ * Native `a + b` on `number` accumulates ~2e-16 of error per add;
14
+ * over 10 000 events that adds up to a visible drift in the per-
15
+ * task total. decimal.js does exact decimal arithmetic; we convert
16
+ * back to `number` at the boundary so the customer-facing field
17
+ * type stays `number` (full `number → string` wire-format change
18
+ * is deferred to a major version per plan §0.7).
19
+ */
20
+ function decAdd(a, b) {
21
+ return new Decimal(a).plus(b).toNumber();
22
+ }
23
+ import { createTask, createCostEvent } from "./models.js";
24
+ import { getCurrentTask, runWithTask } from "./context.js";
25
+ import { EventBuffer } from "../transport/buffer.js";
26
+ import { NetworkAccountant, registerAccountant, unregisterAccountant, } from "../adapters/network-accountant.js";
27
+ import { EgressPricingEngine } from "../pricing/egress-pricing.js";
28
+ import { ComputePricingEngine } from "../pricing/compute-pricing.js";
29
+ import { RuntimeKind } from "./compute-runtime.js";
30
+ import { GpuPricingEngine } from "../pricing/gpu-pricing.js";
31
+ import { GpuRuntimeKind } from "./gpu-runtime.js";
32
+ import { getCloudEnv } from "../cloud-detect.js";
33
+ import { EventPusher } from "../transport/pusher.js";
34
+ import { PricingEngine } from "../pricing/engine.js";
35
+ import { RateRegistry } from "../pricing/rates.js";
36
+ import { RetryHeuristicEngine } from "./heuristics.js";
37
+ import { resolveConfig } from "./config.js";
38
+ import { ALL_SUPPORTED_INSTRUMENTS, instrumentProvider, uninstrumentProvider, } from "../instruments/index.js";
39
+ export const DEFAULT_ENDPOINT = "https://api.dexcost.io";
40
+ /**
41
+ * Resolves the Control Layer endpoint from the DEXCOST_ENDPOINT env
42
+ * var. Sprint 1 Theme A / §2.1 (A2): only https:// URLs are accepted.
43
+ * An attacker who controls the env (misconfigured CI runner, hostile
44
+ * container) could otherwise silently exfiltrate cost telemetry to an
45
+ * HTTP collector — we refuse and fall back to the production default
46
+ * with a console.warn.
47
+ *
48
+ * Exported for testability; the CostTracker constructor is the only
49
+ * production caller.
50
+ */
51
+ export function resolveEndpoint() {
52
+ const env = process.env.DEXCOST_ENDPOINT;
53
+ if (env === undefined || env === "") {
54
+ return DEFAULT_ENDPOINT;
55
+ }
56
+ if (!env.startsWith("https://")) {
57
+ console.warn(`dexcost: DEXCOST_ENDPOINT=${JSON.stringify(env)} rejected — only ` +
58
+ `https:// URLs are accepted. Falling back to ${DEFAULT_ENDPOINT}.`);
59
+ return DEFAULT_ENDPOINT;
60
+ }
61
+ return env;
62
+ }
63
+ /** Event types accepted by `recordCost` (non-LLM cost events). */
64
+ const NON_LLM_EVENT_TYPES = new Set(["external_cost", "compute_cost"]);
65
+ import { isDevMode, enableDevMode, logEvent, logTaskComplete } from "../dev-console.js";
66
+ // Side-effect imports to register instruments
67
+ import "../instruments/openai.js";
68
+ import "../instruments/anthropic.js";
69
+ import "../instruments/vercel-ai.js";
70
+ import "../instruments/gemini.js";
71
+ import "../instruments/bedrock.js";
72
+ import "../instruments/cohere.js";
73
+ import "../instruments/mcp.js";
74
+ // ---------------------------------------------------------------------------
75
+ // Singleton / init() factory
76
+ // ---------------------------------------------------------------------------
77
+ let _instance = null;
78
+ /**
79
+ * Sprint 2 Theme E / §3.3.2 (B9) — exit-time flush handlers.
80
+ *
81
+ * Pre-fix events recorded just before `process.exit(0)` were lost:
82
+ * the buffered in-memory queue and the not-yet-flushed pusher batch
83
+ * both died with the process. These handlers run on process tear-
84
+ * down (graceful exit, SIGTERM, SIGINT) and synchronously close the
85
+ * tracker. closeAsync() flushes the pending push first.
86
+ *
87
+ * The handlers are stored so `close()` can unregister them — avoids
88
+ * cross-test listener-leak when init/close cycles repeatedly.
89
+ */
90
+ let _exitHandlers = null;
91
+ function _registerExitHandlers() {
92
+ if (_exitHandlers !== null)
93
+ return;
94
+ const beforeExit = (_code) => {
95
+ // Synchronous best-effort flush on graceful exit. Node will wait
96
+ // for any returned promise from `beforeExit` (unlike `exit`), so
97
+ // closeAsync's in-flight push has a chance to land.
98
+ void globalCloseAsync();
99
+ };
100
+ const sigterm = () => {
101
+ // SIGTERM: containerized environments (k8s, docker stop) deliver
102
+ // this 30s before SIGKILL. Run closeAsync to flush, then let the
103
+ // default handler take over (re-emit so other listeners run).
104
+ void globalCloseAsync();
105
+ };
106
+ const sigint = () => {
107
+ // SIGINT (Ctrl+C in dev): same flush guarantee.
108
+ void globalCloseAsync();
109
+ };
110
+ process.on("beforeExit", beforeExit);
111
+ process.on("SIGTERM", sigterm);
112
+ process.on("SIGINT", sigint);
113
+ _exitHandlers = { beforeExit, sigterm, sigint };
114
+ }
115
+ function _unregisterExitHandlers() {
116
+ if (_exitHandlers === null)
117
+ return;
118
+ if (_exitHandlers.beforeExit)
119
+ process.off("beforeExit", _exitHandlers.beforeExit);
120
+ if (_exitHandlers.sigterm)
121
+ process.off("SIGTERM", _exitHandlers.sigterm);
122
+ if (_exitHandlers.sigint)
123
+ process.off("SIGINT", _exitHandlers.sigint);
124
+ _exitHandlers = null;
125
+ }
126
+ export function init(options = {}) {
127
+ if (_instance !== null) {
128
+ throw new Error("dexcost already initialized — call close() first to reset");
129
+ }
130
+ _instance = new CostTracker(options);
131
+ _registerExitHandlers();
132
+ return _instance;
133
+ }
134
+ export function getTracker() {
135
+ if (_instance === null) {
136
+ throw new Error("dexcost not initialized — call init() first");
137
+ }
138
+ return _instance;
139
+ }
140
+ /**
141
+ * Update the SDK's API key and resume sync after auth failure.
142
+ *
143
+ * Sprint 2 Theme D / §3.2.3 (B14). When the Control Layer returns
144
+ * 401/403 the pusher sets `_authFailed=true` and stops; without this
145
+ * function the only recovery is restarting the customer's process.
146
+ *
147
+ * Returns `true` on success, `false` if `init()` has not been called
148
+ * (logs a console warning).
149
+ */
150
+ export function setApiKey(newKey) {
151
+ if (_instance === null) {
152
+ console.warn("dexcost: setApiKey called before init(); ignoring. " +
153
+ "Call dexcost.init({apiKey:...}) first.");
154
+ return false;
155
+ }
156
+ _instance.setApiKey(newKey);
157
+ return true;
158
+ }
159
+ export async function globalTrack(opts, fn) {
160
+ return getTracker().track(opts, fn);
161
+ }
162
+ export async function globalFlush() {
163
+ return getTracker().flush();
164
+ }
165
+ export function globalClose() {
166
+ if (_instance !== null) {
167
+ _instance.close();
168
+ _instance = null;
169
+ }
170
+ _unregisterExitHandlers();
171
+ }
172
+ export async function globalCloseAsync() {
173
+ if (_instance !== null) {
174
+ await _instance.closeAsync();
175
+ _instance = null;
176
+ }
177
+ _unregisterExitHandlers();
178
+ }
179
+ /**
180
+ * A task that is currently being tracked.
181
+ *
182
+ * Provides methods to record cost events (LLM calls, external costs,
183
+ * retries) against the task.
184
+ */
185
+ export class TrackedTask {
186
+ _task;
187
+ _buffer;
188
+ _tracker;
189
+ _events = [];
190
+ _ended = false;
191
+ constructor(task, buffer, tracker) {
192
+ this._task = task;
193
+ this._buffer = buffer;
194
+ this._tracker = tracker;
195
+ // Register a NetworkAccountant for this task so the patched
196
+ // globalThis.fetch (which sees only the task_id via AsyncLocalStorage)
197
+ // can record byte usage via core.getAccountant(taskId).
198
+ // Unregistered in end().
199
+ registerAccountant(task.taskId, new NetworkAccountant());
200
+ }
201
+ /** The underlying Task data. */
202
+ get task() {
203
+ return this._task;
204
+ }
205
+ /** All events recorded against this task. */
206
+ get events() {
207
+ return this._events;
208
+ }
209
+ /**
210
+ * Record an LLM call event.
211
+ *
212
+ * When `cost` is omitted, the cost is auto-computed via the pricing
213
+ * engine (mirrors Python `tracker.record_llm_call`). Accepts an
214
+ * options object for `error_type`, `details`, `pricingSource`, and
215
+ * `costConfidence`. `error_type` is stored in `details.error_type`.
216
+ */
217
+ recordLlmCall(provider, model, inputTokens, outputTokens, cost, cachedTokens, latencyMs, options = {}) {
218
+ let costUsd;
219
+ let costConfidence;
220
+ let pricingSource;
221
+ let pricingVersion = options.pricingVersion;
222
+ if (cost === undefined) {
223
+ // Auto-compute via the pricing engine (mirrors Python US-010).
224
+ const result = this._tracker.pricing.getCost(model, inputTokens, outputTokens, cachedTokens ?? 0);
225
+ costUsd = result.costUsd;
226
+ costConfidence = options.costConfidence ?? result.costConfidence;
227
+ pricingSource = options.pricingSource ?? result.pricingSource;
228
+ pricingVersion = pricingVersion ?? result.pricingVersion;
229
+ }
230
+ else {
231
+ costUsd = cost;
232
+ costConfidence = options.costConfidence ?? "exact";
233
+ pricingSource = options.pricingSource ?? "manual";
234
+ }
235
+ const details = { ...(options.details ?? {}) };
236
+ if (options.errorType !== undefined) {
237
+ details.error_type = options.errorType;
238
+ }
239
+ const event = createCostEvent({
240
+ eventId: randomUUID(),
241
+ taskId: this._task.taskId,
242
+ eventType: "llm_call",
243
+ costUsd,
244
+ costConfidence,
245
+ pricingSource,
246
+ pricingVersion,
247
+ provider,
248
+ model,
249
+ inputTokens,
250
+ outputTokens,
251
+ cachedTokens,
252
+ latencyMs,
253
+ isRetry: false,
254
+ details,
255
+ });
256
+ // Heuristic retry detection — must run BEFORE the event is persisted so
257
+ // the SQLite row reflects the detected retry. Mirrors the Python SDK,
258
+ // which runs the heuristic engine before `insert_event` (sync.py /
259
+ // tracker.py). Running it after `addEvent` would persist is_retry=0 and
260
+ // any later update would be a separate, easy-to-drop write.
261
+ const engine = this._tracker.heuristicEngine;
262
+ if (engine && !event.isRetry) {
263
+ const match = engine.check(event);
264
+ if (match.isRetry) {
265
+ event.isRetry = true;
266
+ event.retryReason = match.reason || "heuristic";
267
+ event.retryOf = match.matchedEventId;
268
+ event.details = { ...event.details, retry_confidence: match.confidence };
269
+ this._task.retryCount += 1;
270
+ this._task.retryCostUsd = decAdd(this._task.retryCostUsd, costUsd);
271
+ }
272
+ }
273
+ // Persist only after the retry fields have been finalised on `event`.
274
+ this._events.push(event);
275
+ this._buffer.addEvent(event);
276
+ logEvent(event, this._task.taskType);
277
+ // Feed the persisted event into the engine's sliding window.
278
+ if (engine) {
279
+ engine.record(event);
280
+ }
281
+ // Aggregate into task
282
+ this._task.llmCostUsd = decAdd(this._task.llmCostUsd, costUsd);
283
+ this._task.totalCostUsd = decAdd(this._task.totalCostUsd, costUsd);
284
+ this._task.totalInputTokens += inputTokens;
285
+ this._task.totalOutputTokens += outputTokens;
286
+ if (cachedTokens !== undefined) {
287
+ this._task.totalCachedTokens += cachedTokens;
288
+ }
289
+ this._buffer.upsertTask(this._task);
290
+ return event;
291
+ }
292
+ /**
293
+ * Record a non-LLM cost event (external API call, compute, etc.).
294
+ *
295
+ * `eventType` must be `"external_cost"` or `"compute_cost"`; any other
296
+ * value throws an Error (mirrors Python `tracker.record_cost`).
297
+ */
298
+ recordCost(service, costUsd, details, eventType = "external_cost", costConfidence = "exact", pricingSource = "manual", pricingVersion) {
299
+ if (!NON_LLM_EVENT_TYPES.has(eventType)) {
300
+ throw new Error(`event_type must be one of ${[...NON_LLM_EVENT_TYPES].sort().join(", ")}, ` +
301
+ `got "${eventType}"`);
302
+ }
303
+ const event = createCostEvent({
304
+ eventId: randomUUID(),
305
+ taskId: this._task.taskId,
306
+ eventType,
307
+ costUsd,
308
+ costConfidence,
309
+ pricingSource,
310
+ pricingVersion,
311
+ serviceName: service,
312
+ isRetry: false,
313
+ details: details ?? {},
314
+ });
315
+ this._events.push(event);
316
+ this._buffer.addEvent(event);
317
+ logEvent(event, this._task.taskType);
318
+ // Aggregate into task
319
+ if (eventType === "external_cost") {
320
+ this._task.externalCostUsd = decAdd(this._task.externalCostUsd, costUsd);
321
+ }
322
+ else if (eventType === "compute_cost") {
323
+ this._task.computeCostUsd = decAdd(this._task.computeCostUsd, costUsd);
324
+ }
325
+ this._task.totalCostUsd = decAdd(this._task.totalCostUsd, costUsd);
326
+ this._buffer.upsertTask(this._task);
327
+ return event;
328
+ }
329
+ /**
330
+ * Record a retry event.
331
+ */
332
+ markRetry(reason, cost, retryOf) {
333
+ const costUsd = cost ?? 0;
334
+ const event = createCostEvent({
335
+ eventId: randomUUID(),
336
+ taskId: this._task.taskId,
337
+ eventType: "retry_marker",
338
+ costUsd,
339
+ costConfidence: costUsd > 0 ? "exact" : "unknown",
340
+ isRetry: true,
341
+ retryReason: reason,
342
+ retryOf,
343
+ });
344
+ this._events.push(event);
345
+ this._buffer.addEvent(event);
346
+ logEvent(event, this._task.taskType);
347
+ // Aggregate into task
348
+ this._task.retryCount += 1;
349
+ this._task.retryCostUsd = decAdd(this._task.retryCostUsd, costUsd);
350
+ this._task.totalCostUsd = decAdd(this._task.totalCostUsd, costUsd);
351
+ this._buffer.upsertTask(this._task);
352
+ return event;
353
+ }
354
+ /**
355
+ * Link an external trace (e.g., Langfuse, LangSmith, Datadog) to this task.
356
+ *
357
+ * Stored under `metadata._trace_links` with `{ provider, trace_id }`
358
+ * entries — the same shape the Python SDK uses, so cross-SDK buffers
359
+ * interoperate.
360
+ */
361
+ linkTrace(provider, traceId) {
362
+ if (!this._task.metadata["_trace_links"]) {
363
+ this._task.metadata["_trace_links"] = [];
364
+ }
365
+ this._task.metadata["_trace_links"].push({
366
+ provider,
367
+ trace_id: traceId,
368
+ });
369
+ this._buffer.upsertTask(this._task);
370
+ }
371
+ /**
372
+ * Return all linked traces for this task.
373
+ *
374
+ * Each entry is a `{ provider, trace_id }` object (mirrors Python
375
+ * `TrackedTask.get_trace_links`).
376
+ */
377
+ getTraceLinks() {
378
+ const links = this._task.metadata["_trace_links"];
379
+ if (Array.isArray(links)) {
380
+ return links;
381
+ }
382
+ return [];
383
+ }
384
+ /**
385
+ * End the task, setting its status and ended_at timestamp.
386
+ */
387
+ end(status = "success") {
388
+ if (this._ended) {
389
+ throw new Error(`Task ${this._task.taskId} has already been ended.`);
390
+ }
391
+ this._ended = true;
392
+ this._task.status = status;
393
+ this._task.endedAt = new Date();
394
+ if (status === "failed") {
395
+ this._task.failureCount += 1;
396
+ }
397
+ // ── Network finalize — v1 byte aggregates + v2 egress pricing ────
398
+ // Mirrors python tracker.py:_aggregate_costs + rust TrackedTask::
399
+ // finalize_network + go finalizeNetwork. Tier-5 fail-silent: any
400
+ // throw in the egress block is logged and swallowed so a pricing
401
+ // bug never breaks task finalization (the task still ships with
402
+ // v1 + LLM/external/compute costs intact).
403
+ try {
404
+ this._finalizeNetwork();
405
+ }
406
+ catch (err) {
407
+ // eslint-disable-next-line no-console
408
+ console.warn(`[dexcost] egress cost computation failed for task ${this._task.taskId}:`, err);
409
+ this._task.networkCostUsd = 0;
410
+ }
411
+ // ── Compute capture (v1 + v2 cost) ───────────────────────────────────
412
+ // Long-running runtimes emit their compute_cost event at task finalize
413
+ // from the cgroup diff; serverless runtimes have already emitted from
414
+ // the handler wrap with cost_pending=true. Either way, the v2 pricing
415
+ // engine back-fills cost_usd here via the deferred-cost pattern.
416
+ // Wrapped in Tier-5 fail-silent so a pricing throw never breaks
417
+ // finalize (mirrors python tracker.py:_aggregate_costs +
418
+ // _finalize_compute).
419
+ try {
420
+ this._finalizeCompute();
421
+ }
422
+ catch (err) {
423
+ // eslint-disable-next-line no-console
424
+ console.warn(`[dexcost] compute cost computation failed for task ${this._task.taskId}:`, err);
425
+ }
426
+ // ── GPU capture (Phase 2 v1 + v2) ─────────────────────────────────────
427
+ // Long-running GPU runtimes (AWS_EC2_GPU / GCP_GCE_BUNDLED / etc.) emit
428
+ // 1 gpu_cost + N gpu_utilization_signal at task finalize from the cgroup
429
+ // walk + NVML snapshot diff. Serverless runtimes (Modal / RunPod /
430
+ // Replicate) have already emitted via the handler wrap. Either way the
431
+ // GpuPricingEngine back-fills gpu_cost.costUsd here via the deferred-
432
+ // cost pattern. gpu_utilization_signal events are NEVER priced
433
+ // (Decision #3 observability carve-out). Tier-5 fail-silent.
434
+ try {
435
+ this._finalizeGpu();
436
+ }
437
+ catch (err) {
438
+ // eslint-disable-next-line no-console
439
+ console.warn(`[dexcost] gpu cost computation failed for task ${this._task.taskId}:`, err);
440
+ }
441
+ this._buffer.upsertTask(this._task);
442
+ logTaskComplete(this._task);
443
+ }
444
+ /**
445
+ * Compute auto-emission + back-fill at task finalize.
446
+ *
447
+ * Mirrors python tracker.py:_finalize_compute.
448
+ *
449
+ * Step 1: long-running runtime → call snapshotEndAndBuild and insert a
450
+ * compute_cost event with details.cost_pending=true.
451
+ * Step 2: walk all compute_cost events with cost_pending=true, resolve
452
+ * their cost via the pricing engine, then updateEvent to strip
453
+ * the marker + stamp pricing source/confidence/version.
454
+ * Step 3: apply DELTA-based total adjustment — never recompute total_
455
+ * cost_usd from scratch, which would blow away retry_marker
456
+ * and other costs accumulated by the main loop.
457
+ */
458
+ _finalizeCompute() {
459
+ const task = this._task;
460
+ const accountant = task._compute;
461
+ const cloudEnv = getCloudEnv();
462
+ const overrides = this._tracker.computeBillingOverrides;
463
+ let durationMs = 0;
464
+ let windowS = new Decimal(0);
465
+ if (task.endedAt && task.startedAt) {
466
+ const ms = task.endedAt.getTime() - task.startedAt.getTime();
467
+ durationMs = Math.trunc(ms);
468
+ windowS = new Decimal(ms).dividedBy(1000);
469
+ }
470
+ // 1. Long-running runtimes: build + persist the cgroup-diff event.
471
+ const longRunning = new Set([
472
+ RuntimeKind.Fargate,
473
+ RuntimeKind.Ec2,
474
+ RuntimeKind.Gce,
475
+ RuntimeKind.AzureVm,
476
+ RuntimeKind.K8sPod,
477
+ ]);
478
+ const newEventIds = new Set();
479
+ if (accountant && longRunning.has(accountant.runtime)) {
480
+ const details = accountant.snapshotEndAndBuild(durationMs);
481
+ if (details !== null) {
482
+ const ev = createCostEvent({
483
+ eventId: randomUUID(),
484
+ taskId: task.taskId,
485
+ eventType: "compute_cost",
486
+ costUsd: 0,
487
+ costConfidence: "unknown",
488
+ isRetry: false,
489
+ details,
490
+ });
491
+ this._buffer.addEvent(ev);
492
+ this._events.push(ev);
493
+ newEventIds.add(ev.eventId);
494
+ }
495
+ }
496
+ // 2. Back-fill cost on every compute_cost event with cost_pending=true.
497
+ // Track per-event delta so we adjust totals without blowing away the
498
+ // running totals already accumulated by the main loop.
499
+ const engine = this._tracker.computePricing;
500
+ const events = this._buffer.queryEvents(task.taskId);
501
+ let costDelta = new Decimal(0);
502
+ for (const ev of events) {
503
+ if (ev.eventType !== "compute_cost")
504
+ continue;
505
+ const details = ev.details || {};
506
+ if (details.cost_pending !== true)
507
+ continue;
508
+ const oldCost = new Decimal(ev.costUsd);
509
+ const priced = engine.resolveComputeCost(details, cloudEnv, overrides, windowS);
510
+ ev.costUsd = priced.costUsd.toNumber();
511
+ ev.pricingSource = priced.pricingSource;
512
+ ev.costConfidence = priced.costConfidence;
513
+ ev.pricingVersion = `compute:${engine.catalogVersion}`;
514
+ const newDetails = {};
515
+ for (const [k, v] of Object.entries(details)) {
516
+ if (k !== "cost_pending")
517
+ newDetails[k] = v;
518
+ }
519
+ ev.details = newDetails;
520
+ this._buffer.updateEvent(ev);
521
+ // Delta = new - old. For newly-inserted long-running events the
522
+ // main loop never saw them at all, so we add the original $0 too
523
+ // (always 0 here, but explicit per python parity).
524
+ const delta = priced.costUsd.minus(oldCost);
525
+ costDelta = costDelta.plus(delta);
526
+ if (newEventIds.has(ev.eventId)) {
527
+ costDelta = costDelta.plus(oldCost);
528
+ }
529
+ }
530
+ const deltaNum = costDelta.toNumber();
531
+ task.computeCostUsd = decAdd(task.computeCostUsd, deltaNum);
532
+ task.totalCostUsd = decAdd(task.totalCostUsd, deltaNum);
533
+ }
534
+ /**
535
+ * GPU auto-emission + back-fill at task finalize.
536
+ *
537
+ * Mirrors python tracker.py:_finalize_gpu. Three steps:
538
+ *
539
+ * 1. Long-running GPU runtimes (AwsEc2Gpu / GcpGceBundled /
540
+ * GcpGceN1Attached / AzureVmGpu / AzureVmVgpu / LambdaLabs /
541
+ * CoreWeave) call accountant.snapshotEndAndBuild(durationMs) and
542
+ * persist a gpu_cost event (cost_pending=true) plus N
543
+ * gpu_utilization_signal events. Serverless GPU runtimes (Modal /
544
+ * RunPod / Replicate) have already emitted via the handler wrap;
545
+ * this step is a no-op for them.
546
+ * 2. Back-fills cost_usd on every gpu_cost event with cost_pending=true:
547
+ * resolves rate via GpuPricingEngine.resolveGpuCost, sets cost_usd,
548
+ * pricing_source, cost_confidence, pricing_version ("gpu:<version>"
549
+ * — distinct from compute / egress prefixes), and strips the
550
+ * internal cost_pending / _cgroup_scope_fallback /
551
+ * _nvml_product_name_lower hints from details before re-persisting.
552
+ * 3. gpu_utilization_signal events are NEVER touched by the back-fill
553
+ * walker — they stay at cost_usd=0 (Decision #3 observability
554
+ * carve-out). Load-bearing convention §1 carve-out — see test
555
+ * gpu-auto-emission.test.ts.
556
+ *
557
+ * Delta-based total adjustment preserves any retry_marker costs already
558
+ * accumulated by the main aggregation loop.
559
+ */
560
+ _finalizeGpu() {
561
+ const task = this._task;
562
+ const accountant = task._gpu;
563
+ const cloudEnv = getCloudEnv();
564
+ let durationMs = 0;
565
+ let windowS = new Decimal(0);
566
+ if (task.endedAt && task.startedAt) {
567
+ const ms = task.endedAt.getTime() - task.startedAt.getTime();
568
+ durationMs = Math.trunc(ms);
569
+ windowS = new Decimal(ms).dividedBy(1000);
570
+ }
571
+ // 1. Long-running GPU runtimes: snapshot + persist dual events.
572
+ const longRunningGpu = new Set([
573
+ GpuRuntimeKind.AwsEc2Gpu,
574
+ GpuRuntimeKind.GcpGceBundled,
575
+ GpuRuntimeKind.GcpGceN1Attached,
576
+ GpuRuntimeKind.AzureVmGpu,
577
+ GpuRuntimeKind.AzureVmVgpu,
578
+ GpuRuntimeKind.LambdaLabs,
579
+ GpuRuntimeKind.CoreWeave,
580
+ ]);
581
+ const newEventIds = new Set();
582
+ if (accountant && longRunningGpu.has(accountant.runtime)) {
583
+ const { costDetails, signalEvents } = accountant.snapshotEndAndBuild(durationMs);
584
+ if (costDetails !== null) {
585
+ const ev = createCostEvent({
586
+ eventId: randomUUID(),
587
+ taskId: task.taskId,
588
+ eventType: "gpu_cost",
589
+ costUsd: 0,
590
+ costConfidence: "unknown",
591
+ isRetry: false,
592
+ details: costDetails,
593
+ });
594
+ this._buffer.addEvent(ev);
595
+ this._events.push(ev);
596
+ newEventIds.add(ev.eventId);
597
+ }
598
+ if (signalEvents) {
599
+ for (const sig of signalEvents) {
600
+ const sev = createCostEvent({
601
+ eventId: randomUUID(),
602
+ taskId: task.taskId,
603
+ eventType: "gpu_utilization_signal",
604
+ costUsd: 0, // Decision #3 — observability only
605
+ costConfidence: "unknown",
606
+ isRetry: false,
607
+ details: sig,
608
+ });
609
+ this._buffer.addEvent(sev);
610
+ this._events.push(sev);
611
+ }
612
+ }
613
+ }
614
+ // 2. Back-fill cost on every gpu_cost event with cost_pending=true.
615
+ // Per Decision #3, gpu_utilization_signal events are NEVER priced.
616
+ const engine = this._tracker.gpuPricing;
617
+ const events = this._buffer.queryEvents(task.taskId);
618
+ let costDelta = new Decimal(0);
619
+ for (const ev of events) {
620
+ if (ev.eventType !== "gpu_cost")
621
+ continue;
622
+ const details = (ev.details || {});
623
+ if (details.cost_pending !== true)
624
+ continue;
625
+ const oldCost = new Decimal(ev.costUsd);
626
+ const priced = engine.resolveGpuCost(details, cloudEnv, windowS);
627
+ ev.costUsd = priced.costUsd.toNumber();
628
+ ev.pricingSource = priced.pricingSource;
629
+ ev.costConfidence = priced.costConfidence;
630
+ ev.pricingVersion = `gpu:${engine.catalogVersion}`;
631
+ const newDetails = {};
632
+ for (const [k, v] of Object.entries(details)) {
633
+ if (k !== "cost_pending" &&
634
+ k !== "_cgroup_scope_fallback" &&
635
+ k !== "_nvml_product_name_lower") {
636
+ newDetails[k] = v;
637
+ }
638
+ }
639
+ ev.details = newDetails;
640
+ this._buffer.updateEvent(ev);
641
+ const delta = priced.costUsd.minus(oldCost);
642
+ costDelta = costDelta.plus(delta);
643
+ if (newEventIds.has(ev.eventId)) {
644
+ costDelta = costDelta.plus(oldCost); // always 0; explicit
645
+ }
646
+ }
647
+ const deltaNum = costDelta.toNumber();
648
+ task.gpuCostUsd = decAdd(task.gpuCostUsd, deltaNum);
649
+ task.totalCostUsd = decAdd(task.totalCostUsd, deltaNum);
650
+ }
651
+ /**
652
+ * Snapshot the NetworkAccountant onto the task's v1 fields and (if
653
+ * a CloudEnv has been resolved) compute v2 egress dollars + back-fill
654
+ * the cost_pending network events for this task.
655
+ *
656
+ * Caller (end) wraps this in a Tier-5 fail-silent shell.
657
+ */
658
+ _finalizeNetwork() {
659
+ // v1 — drain the accountant into task fields. Lookup-then-unregister:
660
+ // late HTTP calls attributed to this task_id won't find an accountant
661
+ // (no orphan rows; matches Python frozen-then-snapshot).
662
+ const accountant = unregisterAccountant(this._task.taskId);
663
+ if (!accountant) {
664
+ // No accountant was registered (ad-hoc task creation outside
665
+ // CostTracker); v1 fields stay at zero.
666
+ return;
667
+ }
668
+ const snapshot = accountant.finalize();
669
+ this._task.networkBytesIn = snapshot.bytesIn;
670
+ this._task.networkBytesOut = snapshot.bytesOut;
671
+ this._task.networkCallCount = snapshot.callCount;
672
+ this._task.networkByHost = snapshot.byHost;
673
+ // v2 — egress pricing.
674
+ const env = getCloudEnv();
675
+ const engine = new EgressPricingEngine();
676
+ const rate = engine.resolveRate(env.provider, env.region);
677
+ const pricingVersion = `egress:${engine.catalogVersion}`;
678
+ // Convert external_bytes_out (number) to GB. Per spec §6.3 — 1 GB
679
+ // = 10^9 bytes, NOT 2^30. We use plain `number` here because the TS
680
+ // SDK uses number throughout for cost fields (a pre-existing design
681
+ // choice); the catalog stores rates as strings (preserved exactness
682
+ // at rest) and we parseFloat only at the boundary.
683
+ const ratePerGb = parseFloat(rate.ratePerGb);
684
+ const networkCostUsd = (snapshot.externalBytesOut / 1_000_000_000) * ratePerGb;
685
+ this._task.networkCostUsd = networkCostUsd;
686
+ // Stamp per-host egress_cost_usd into network_by_host[].hosts. The
687
+ // per-host external_bytes_out survives the LIVE_CAP overflow + top-N
688
+ // cap; sum(per-host egress_cost_usd) == network_cost_usd by
689
+ // construction (v2 §10.3 property invariant 2).
690
+ const byHost = this._task.networkByHost;
691
+ if (Array.isArray(byHost.hosts)) {
692
+ for (const host of byHost.hosts) {
693
+ const hostExternal = host["external_bytes_out"] ?? 0;
694
+ const hostCost = (hostExternal / 1_000_000_000) * ratePerGb;
695
+ host["egress_cost_usd"] = String(hostCost);
696
+ }
697
+ }
698
+ // v2 §6.4 — back-fill each network event for this task. Walk the
699
+ // buffer's stored events, find any with details.cost_pending ===
700
+ // true, compute their cost, strip the marker, and updateEvent to
701
+ // re-sync.
702
+ const stored = this._buffer.queryEvents(this._task.taskId);
703
+ for (const ev of stored) {
704
+ if (ev.eventType !== "network")
705
+ continue;
706
+ if (ev.details?.cost_pending !== true)
707
+ continue;
708
+ const respBytes = ev.details?.response_bytes ?? 0;
709
+ const reqBytes = ev.details?.request_bytes ?? 0;
710
+ const isInternal = ev.details?.is_internal_traffic === true;
711
+ const billable = isInternal ? 0 : respBytes + reqBytes;
712
+ const evCost = (billable / 1_000_000_000) * ratePerGb;
713
+ ev.costUsd = evCost;
714
+ ev.costConfidence = isInternal
715
+ ? "exact"
716
+ : rate.costConfidence;
717
+ ev.pricingVersion = pricingVersion;
718
+ // Strip cost_pending marker so the back-filled event is no longer
719
+ // "deferred-cost".
720
+ delete ev.details.cost_pending;
721
+ // Stamp egress_pricing_source so the wire payload carries the v2
722
+ // source detail (egress_catalog:aws:us-east-1).
723
+ ev.details.egress_pricing_source =
724
+ isInternal ? "egress_catalog:internal" : rate.pricingSource;
725
+ this._buffer.updateEvent(ev);
726
+ // First-pass total_cost_usd summed this event at 0 (cost_pending);
727
+ // add the back-filled cost.
728
+ this._task.totalCostUsd = decAdd(this._task.totalCostUsd, evCost);
729
+ }
730
+ // Add network_cost_usd to total — captures every external byte
731
+ // (cataloged + below-threshold un-cataloged calls included via the
732
+ // accountant scalar even when they emitted no per-event row).
733
+ this._task.totalCostUsd = decAdd(this._task.totalCostUsd, this._task.networkCostUsd);
734
+ }
735
+ /**
736
+ * Record a usage event priced via the rate registry.
737
+ */
738
+ recordUsage(service, units = 1, details) {
739
+ const rate = this._tracker.getRate(service);
740
+ if (rate === undefined) {
741
+ throw new Error(`No rate registered for service "${service}". Use tracker.registerRate("${service}", per, costUsd) first.`);
742
+ }
743
+ const costUsd = rate * units;
744
+ const event = createCostEvent({
745
+ eventId: randomUUID(),
746
+ taskId: this._task.taskId,
747
+ eventType: "external_cost",
748
+ costUsd,
749
+ costConfidence: "computed",
750
+ pricingSource: "rate_registry",
751
+ pricingVersion: this._tracker.rateRegistry.pricingVersion,
752
+ serviceName: service,
753
+ isRetry: false,
754
+ details: details ?? {},
755
+ });
756
+ this._events.push(event);
757
+ this._buffer.addEvent(event);
758
+ logEvent(event, this._task.taskType);
759
+ this._task.externalCostUsd = decAdd(this._task.externalCostUsd, costUsd);
760
+ this._task.totalCostUsd = decAdd(this._task.totalCostUsd, costUsd);
761
+ this._buffer.upsertTask(this._task);
762
+ return event;
763
+ }
764
+ /**
765
+ * Un-flag a retry event as non-retry, reversing the retry accounting.
766
+ * If eventId is provided, targets that specific event; otherwise targets
767
+ * the most recent retry event.
768
+ */
769
+ markNotRetry(eventId) {
770
+ let target;
771
+ if (eventId) {
772
+ target = this._events.find((e) => e.eventId === eventId && e.isRetry);
773
+ }
774
+ else {
775
+ for (let i = this._events.length - 1; i >= 0; i--) {
776
+ if (this._events[i].isRetry) {
777
+ target = this._events[i];
778
+ break;
779
+ }
780
+ }
781
+ }
782
+ if (!target)
783
+ return undefined;
784
+ target.isRetry = false;
785
+ target.retryReason = undefined;
786
+ target.retryOf = undefined;
787
+ this._task.retryCount = Math.max(0, this._task.retryCount - 1);
788
+ this._task.retryCostUsd = Math.max(0, this._task.retryCostUsd - target.costUsd);
789
+ this._buffer.upsertTask(this._task);
790
+ return target;
791
+ }
792
+ }
793
+ /**
794
+ * Main cost tracker for recording AI agent unit economics.
795
+ *
796
+ * Manages task lifecycle, event recording, and background push to
797
+ * a remote endpoint.
798
+ */
799
+ export class CostTracker {
800
+ _buffer;
801
+ _pusher = null;
802
+ _options;
803
+ _pricing;
804
+ _computePricing;
805
+ _gpuPricing;
806
+ _computeBillingOverrides;
807
+ _k8sNodeAware;
808
+ _rateRegistry;
809
+ _heuristicEngine;
810
+ _instrumented = new Set();
811
+ _config;
812
+ _httpTracked = false;
813
+ constructor(options = {}) {
814
+ this._options = {
815
+ batchSize: 100,
816
+ // Sprint 3 Theme F / §4.1.3 P5: default flush 5 s, matching
817
+ // Python's `flush_interval=5.0`. Pre-fix the TS default was
818
+ // 30 s, leaving up to 6× more time for events to be lost on
819
+ // process exit (and inconsistent with Python's UX).
820
+ flushIntervalMs: 5000,
821
+ ...options,
822
+ };
823
+ // Resolve API key (explicit arg → DEXCOST_API_KEY env var) and storage
824
+ // mode. Throws InvalidAPIKeyError for a malformed key.
825
+ this._config = resolveConfig(this._options.apiKey, this._options.storage);
826
+ // Use the resolved key everywhere downstream (env-var fallback included).
827
+ this._options.apiKey = this._config.apiKey;
828
+ this._buffer = new EventBuffer(this._options.dbPath);
829
+ // Dev mode detection
830
+ const env = options?.environment ?? process.env.DEXCOST_ENV;
831
+ if (env === "development") {
832
+ enableDevMode();
833
+ }
834
+ const endpoint = resolveEndpoint();
835
+ const cloudMode = this._config.storageMode === "cloud" && !isDevMode();
836
+ if (cloudMode) {
837
+ this._pusher = new EventPusher(this._buffer, this._options);
838
+ this._pusher.start();
839
+ }
840
+ this._pricing = new PricingEngine();
841
+ this._computePricing = new ComputePricingEngine();
842
+ // GPU pricing engine (Phase 2 — bundled gpu_prices.json). No init knob
843
+ // needed: GPU billing models are unambiguous per provider (Modal is
844
+ // always per_gpu_second_active, etc.). Mirrors python tracker.py.
845
+ this._gpuPricing = new GpuPricingEngine();
846
+ this._computeBillingOverrides = { ...(options.computeBillingOverrides ?? {}) };
847
+ this._k8sNodeAware = options.k8sNodeAware ?? false;
848
+ // Start background pricing refresh in cloud mode
849
+ if (cloudMode && this._config.apiKey) {
850
+ this._pricing.setApiKey(this._config.apiKey);
851
+ this._pricing.startBackgroundRefresh(endpoint);
852
+ }
853
+ this._rateRegistry = new RateRegistry();
854
+ this._heuristicEngine = options.enableRetryHeuristics
855
+ ? new RetryHeuristicEngine(options.retryHeuristicWindow, options.retryHeuristicThreshold)
856
+ : null;
857
+ const instruments = options.autoInstrument ?? [...ALL_SUPPORTED_INSTRUMENTS];
858
+ for (const name of instruments) {
859
+ void this.instrument(name);
860
+ }
861
+ // Auto-track outgoing HTTP calls (default on, matches Python).
862
+ if (this._options.trackHttp !== false) {
863
+ void this._enableHttpTracking(this._options.serviceCatalogUrl);
864
+ }
865
+ // Wire the browser adapter to durable storage so trackBrowser() cost
866
+ // events are persisted and shipped by the pusher. Browser tracking is
867
+ // opt-in via the trackBrowser() wrapper (no init flag), so the buffer is
868
+ // wired unconditionally and used only if trackBrowser actually runs.
869
+ void import("../adapters/browser.js").then(({ setBrowserBuffer }) => setBrowserBuffer(this._buffer));
870
+ }
871
+ /** The resolved API-key / storage configuration. */
872
+ get config() {
873
+ return this._config;
874
+ }
875
+ /**
876
+ * Patch outgoing HTTP transports to auto-record external costs and,
877
+ * when a catalog URL is provided, refresh the service catalog.
878
+ */
879
+ async _enableHttpTracking(serviceCatalogUrl) {
880
+ try {
881
+ const { trackHttp, getServiceCatalog } = await import("../adapters/http.js");
882
+ trackHttp(this._buffer);
883
+ this._httpTracked = true;
884
+ if (serviceCatalogUrl) {
885
+ const catalog = getServiceCatalog();
886
+ if (catalog) {
887
+ await catalog.refreshFromUrl(serviceCatalogUrl);
888
+ }
889
+ }
890
+ }
891
+ catch {
892
+ // HTTP tracking is best-effort — never crash init.
893
+ }
894
+ }
895
+ /** The underlying event buffer. */
896
+ get buffer() {
897
+ return this._buffer;
898
+ }
899
+ /** The pricing engine used for cost calculations. */
900
+ get pricing() {
901
+ return this._pricing;
902
+ }
903
+ /** The compute pricing engine — wires through to TrackedTask.end finalize. */
904
+ get computePricing() {
905
+ return this._computePricing;
906
+ }
907
+ /** The GPU pricing engine — wires through to TrackedTask.end finalize. */
908
+ get gpuPricing() {
909
+ return this._gpuPricing;
910
+ }
911
+ /** Compute billing-model dispatch overrides (e.g. cloud_run=instance). */
912
+ get computeBillingOverrides() {
913
+ return this._computeBillingOverrides;
914
+ }
915
+ /** Whether K8s node-aware pricing is enabled (reserved for follow-up). */
916
+ get k8sNodeAware() {
917
+ return this._k8sNodeAware;
918
+ }
919
+ /** The rate registry for service-based cost calculations. */
920
+ get rateRegistry() {
921
+ return this._rateRegistry;
922
+ }
923
+ /** The heuristic retry engine, or null if heuristics are disabled. */
924
+ get heuristicEngine() {
925
+ return this._heuristicEngine;
926
+ }
927
+ /** Register a per-unit rate for a named service. */
928
+ registerRate(service, per, costUsd) {
929
+ this._rateRegistry.register(service, per, costUsd);
930
+ }
931
+ /** Get the per-unit cost (in USD) for a named service, or undefined if not registered. */
932
+ getRate(service) {
933
+ return this._rateRegistry.get(service)?.costUsd;
934
+ }
935
+ /**
936
+ * Activate the named instrument, monkey-patching the provider library.
937
+ */
938
+ async instrument(name) {
939
+ if (this._instrumented.has(name))
940
+ return;
941
+ const success = await instrumentProvider(name, this._pricing, this._buffer);
942
+ if (success)
943
+ this._instrumented.add(name);
944
+ }
945
+ /**
946
+ * Deactivate the named instrument, restoring the original library methods.
947
+ */
948
+ uninstrument(name) {
949
+ uninstrumentProvider(name);
950
+ this._instrumented.delete(name);
951
+ }
952
+ /**
953
+ * Execute `fn` inside a tracked task context.
954
+ *
955
+ * Creates a new task, runs the function within an AsyncLocalStorage
956
+ * context, and ends the task on completion (or failure).
957
+ */
958
+ /**
959
+ * Manually start a task and return a `TrackedTask` handle.
960
+ *
961
+ * Use this when callbacks/context managers don't fit your architecture
962
+ * (e.g. Celery-style workers, multi-process pipelines). The caller
963
+ * **must** call `TrackedTask.end()` when the task is complete.
964
+ * Mirrors the Python SDK's `CostTracker.start_task`.
965
+ */
966
+ startTask(opts = {}) {
967
+ const parentTask = getCurrentTask();
968
+ const task = createTask({
969
+ taskId: randomUUID(),
970
+ taskType: opts.taskType ?? "",
971
+ customerId: opts.customerId,
972
+ projectId: opts.projectId,
973
+ metadata: opts.metadata ? { ...opts.metadata } : {},
974
+ parentTaskId: parentTask?.taskId,
975
+ experimentId: opts.experimentId,
976
+ variant: opts.variant,
977
+ });
978
+ this._buffer.upsertTask(task);
979
+ return new TrackedTask(task, this._buffer, this);
980
+ }
981
+ async track(opts, fn) {
982
+ const parentTask = getCurrentTask();
983
+ const task = createTask({
984
+ taskId: randomUUID(),
985
+ taskType: opts.taskType,
986
+ customerId: opts.customerId,
987
+ projectId: opts.projectId,
988
+ metadata: opts.metadata ? { ...opts.metadata } : {},
989
+ parentTaskId: parentTask?.taskId,
990
+ experimentId: opts.experimentId,
991
+ variant: opts.variant,
992
+ });
993
+ this._buffer.upsertTask(task);
994
+ const trackedTask = new TrackedTask(task, this._buffer, this);
995
+ try {
996
+ const result = await runWithTask(task, () => fn(trackedTask));
997
+ if (task.status === "pending") {
998
+ trackedTask.end("success");
999
+ }
1000
+ return result;
1001
+ }
1002
+ catch (error) {
1003
+ trackedTask.end("failed");
1004
+ throw error;
1005
+ }
1006
+ }
1007
+ /**
1008
+ * Force an immediate flush of all buffered events to the remote endpoint.
1009
+ */
1010
+ async flush() {
1011
+ if (this._pusher) {
1012
+ await this._pusher.flush();
1013
+ }
1014
+ }
1015
+ /**
1016
+ * Update the API key on both pricing engine and pusher. Sprint 2
1017
+ * Theme D / §3.2.3 (B14) — entry point for `dexcost.setApiKey`.
1018
+ */
1019
+ setApiKey(newKey) {
1020
+ this._config = { ...this._config, apiKey: newKey };
1021
+ this._pricing.setApiKey(newKey);
1022
+ if (this._pusher) {
1023
+ this._pusher.setApiKey(newKey);
1024
+ }
1025
+ }
1026
+ /**
1027
+ * Stop the background pusher and release resources.
1028
+ */
1029
+ close() {
1030
+ for (const name of this._instrumented) {
1031
+ uninstrumentProvider(name);
1032
+ }
1033
+ this._instrumented.clear();
1034
+ this._disableHttpTracking();
1035
+ if (this._pusher) {
1036
+ // Note: flush() is async but close() is sync by contract.
1037
+ // We call stop() which clears the interval; any in-flight push
1038
+ // completes naturally. Use flush() before close() for guaranteed delivery.
1039
+ this._pusher.stop();
1040
+ }
1041
+ this._pricing.stopBackgroundRefresh();
1042
+ this._buffer.close();
1043
+ }
1044
+ /** Restore patched HTTP transports if HTTP tracking was enabled. */
1045
+ _disableHttpTracking() {
1046
+ if (!this._httpTracked)
1047
+ return;
1048
+ this._httpTracked = false;
1049
+ void import("../adapters/http.js")
1050
+ .then(({ untrackHttp }) => untrackHttp())
1051
+ .catch(() => {
1052
+ // best-effort
1053
+ });
1054
+ }
1055
+ /**
1056
+ * Flush pending events and then stop the background pusher and release resources.
1057
+ * Prefer this over close() when you need to guarantee all events are delivered.
1058
+ */
1059
+ async closeAsync() {
1060
+ for (const name of this._instrumented) {
1061
+ uninstrumentProvider(name);
1062
+ }
1063
+ this._instrumented.clear();
1064
+ this._disableHttpTracking();
1065
+ if (this._pusher) {
1066
+ await this._pusher.flush();
1067
+ this._pusher.stop();
1068
+ }
1069
+ this._pricing.stopBackgroundRefresh();
1070
+ this._buffer.close();
1071
+ }
1072
+ }
1073
+ //# sourceMappingURL=tracker.js.map