@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,769 @@
1
+ /**
2
+ * HTTP fetch adapter — automatic cost tracking for globalThis.fetch.
3
+ *
4
+ * Patches `globalThis.fetch` to intercept HTTP calls and auto-record
5
+ * `external_cost` events using the service catalog for cost extraction,
6
+ * with user-registered domain rates taking precedence.
7
+ *
8
+ * V2: integrates service catalog, session auto-grouping, and response
9
+ * body/header cost extraction.
10
+ *
11
+ * Implements US-035 (TypeScript counterpart to the Python HTTP adapter).
12
+ */
13
+ import { randomUUID } from "node:crypto";
14
+ import { createRequire } from "node:module";
15
+ import { getCurrentTask, isNetworkEventSuppressed } from "../core/context.js";
16
+ import { classifyDestination, measureBytesFromHeaders, } from "./_netbytes.js";
17
+ import { getAccountant, } from "./network-accountant.js";
18
+ import { createCostEvent, } from "../core/models.js";
19
+ import { createAutoTask } from "../core/auto-task.js";
20
+ import { ServiceCatalog } from "../pricing/service-catalog.js";
21
+ import { SessionManager } from "../core/session.js";
22
+ import { scrubUrl } from "../security/redaction.js";
23
+ // ---------------------------------------------------------------------------
24
+ // Module-level state
25
+ // ---------------------------------------------------------------------------
26
+ /** Map of domain → { costUsd, per } registered rates (user overrides). */
27
+ const _domainRates = new Map();
28
+ /** Events recorded by the adapter.
29
+ *
30
+ * Sprint 4 §5.2 (A3) — hard FIFO cap matching Python (commit c1d87a7).
31
+ * Pre-fix this array grew unbounded across the process lifetime,
32
+ * leaking memory on long-running services with many HTTP-tracked
33
+ * tasks. Capped at 10 000 entries; oldest 10% dropped in one batch
34
+ * when over to avoid O(n) `shift()` per recording.
35
+ */
36
+ const _RECORDED_EVENTS_CAP = 10_000;
37
+ const _recordedEvents = [];
38
+ function _pushRecordedEvent(event) {
39
+ _recordedEvents.push(event);
40
+ if (_recordedEvents.length > _RECORDED_EVENTS_CAP) {
41
+ _recordedEvents.splice(0, _RECORDED_EVENTS_CAP / 10);
42
+ }
43
+ }
44
+ /**
45
+ * Combined request + response bytes above which an un-cataloged call
46
+ * emits a `network` event. Mirrors python config
47
+ * `network_event_threshold_bytes = 102_400` (100 KiB).
48
+ */
49
+ const NETWORK_EVENT_THRESHOLD_BYTES = 102_400;
50
+ /** Original fetch reference before patching. */
51
+ let _originalFetch = null;
52
+ /** Whether fetch is currently patched. */
53
+ let _patched = false;
54
+ /* eslint-disable @typescript-eslint/no-explicit-any */
55
+ /** Original `http.request` / `https.request` references before patching. */
56
+ let _originalHttpRequest = null;
57
+ let _originalHttpsRequest = null;
58
+ let _originalHttpGet = null;
59
+ let _originalHttpsGet = null;
60
+ /* eslint-enable @typescript-eslint/no-explicit-any */
61
+ /** Lazily-loaded service catalog. */
62
+ let _catalog = null;
63
+ /** Session manager for auto-grouping. */
64
+ let _sessionManager = null;
65
+ /** Event buffer reference (set via trackHttp). */
66
+ let _buffer = null;
67
+ /** Max response body size to parse (1 MB). */
68
+ const MAX_BODY_SIZE = 1_048_576;
69
+ // ---------------------------------------------------------------------------
70
+ // Domain rate registration
71
+ // ---------------------------------------------------------------------------
72
+ /**
73
+ * Register a cost rate for HTTP calls to the given domain.
74
+ *
75
+ * User-registered rates take precedence over catalog entries.
76
+ *
77
+ * @param domain Hostname to match, e.g. `"api.example.com"` (no port).
78
+ * @param costUsd Cost per call in USD.
79
+ * @param per Unit label (default `"request"`).
80
+ */
81
+ export function registerDomainRate(domain, costUsd, per = "request") {
82
+ _domainRates.set(domain, { costUsd, per });
83
+ }
84
+ /**
85
+ * Return a snapshot of all registered domain rates.
86
+ */
87
+ export function getDomainRates() {
88
+ const result = {};
89
+ for (const [domain, rate] of _domainRates.entries()) {
90
+ result[domain] = { ...rate };
91
+ }
92
+ return result;
93
+ }
94
+ /**
95
+ * Remove all registered domain rates.
96
+ */
97
+ export function clearDomainRates() {
98
+ _domainRates.clear();
99
+ }
100
+ // ---------------------------------------------------------------------------
101
+ // Fetch patching
102
+ // ---------------------------------------------------------------------------
103
+ /**
104
+ * Patch `globalThis.fetch` to intercept HTTP calls and auto-record
105
+ * `external_cost` events.
106
+ *
107
+ * Loads the service catalog on first call. If an EventBuffer is provided,
108
+ * events are also persisted and session auto-grouping is enabled.
109
+ *
110
+ * Idempotent — calling multiple times without `untrackHttp()` in between is
111
+ * safe; the second call is a no-op.
112
+ */
113
+ /**
114
+ * Symbol used to tag dexcost's wrapped fetch. Sprint 3 Theme E / §4.2.1.
115
+ * `Symbol.for(...)` returns the same symbol across realms / module
116
+ * instances, so two copies of the dexcost SDK (e.g. in a Yarn PnP
117
+ * setup where multiple versions are deduped poorly) will recognise
118
+ * each other's patches and refuse to double-wrap.
119
+ *
120
+ * Detect another patcher (Sentry, OpenTelemetry, Datadog) by reading
121
+ * their own marker properties; if found, store both pointers and
122
+ * chain through them so neither tool's interception is lost.
123
+ */
124
+ const DEXCOST_PATCHED = Symbol.for("dexcost.patched");
125
+ export function trackHttp(buffer) {
126
+ if (_patched)
127
+ return;
128
+ // §4.2.1: refuse to wrap an already-dexcost-wrapped fetch.
129
+ const current = globalThis.fetch;
130
+ if (current && current[DEXCOST_PATCHED] === true) {
131
+ // Already patched — likely a duplicate SDK install. No-op + warn.
132
+ console.warn("dexcost: globalThis.fetch is already wrapped by dexcost. " +
133
+ "trackHttp() called twice (or two SDK copies). Skipping.");
134
+ _patched = true;
135
+ return;
136
+ }
137
+ _originalFetch = globalThis.fetch;
138
+ _patched = true;
139
+ // Lazily initialise the service catalog
140
+ if (_catalog === null) {
141
+ try {
142
+ _catalog = new ServiceCatalog();
143
+ }
144
+ catch {
145
+ // If catalog fails to load, continue without it
146
+ _catalog = null;
147
+ }
148
+ }
149
+ if (buffer) {
150
+ _buffer = buffer;
151
+ _sessionManager = new SessionManager();
152
+ }
153
+ // Replace globalThis.fetch with wrapper
154
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
155
+ globalThis.fetch = async function wrappedFetch(input, init) {
156
+ // ── v1 byte measurement — request side (known before fetch) ─────────
157
+ // Scrub once at extraction so every downstream use (events, hostname
158
+ // parse, placeholder equality-lookup at the streamed-body re-type
159
+ // site) sees the same safe value. Critical for B11: re-type at
160
+ // _finaliseHttpCall must match on the same string that was stored.
161
+ const urlStr = scrubUrl(_resolveUrlStr(input));
162
+ const method = _resolveMethod(input, init);
163
+ const requestHeaders = _resolveRequestHeaders(input, init);
164
+ const requestBodyLen = _resolveRequestBodyLen(input, init);
165
+ const requestBytes = measureBytesFromHeaders(method, urlStr, requestHeaders, requestBodyLen);
166
+ const response = await _originalFetch(input, init);
167
+ // ── v1 destination classification + byte details ─────────────────────
168
+ let hostname = "";
169
+ let protocol = "https";
170
+ try {
171
+ const parsed = new URL(urlStr);
172
+ hostname = parsed.hostname;
173
+ protocol = (parsed.protocol || "https:").replace(":", "") || "https";
174
+ }
175
+ catch {
176
+ // Unparseable URL — fall through with empty host; classifyDestination
177
+ // returns null for empty input.
178
+ }
179
+ const isInternal = classifyDestination(hostname);
180
+ const suppressed = isNetworkEventSuppressed();
181
+ // Resolve accountant from registry via the active task. The task's id
182
+ // is looked up via the existing context — see _resolveHttpTask below.
183
+ // Direct reference here would create a cycle; lookup is done inside
184
+ // the byte-recording callback when the task_id is known.
185
+ const callContext = {
186
+ urlStr,
187
+ method,
188
+ hostname,
189
+ protocol,
190
+ requestBytes,
191
+ isInternal,
192
+ suppressed,
193
+ responseHeaderBytes: _measureResponseHeaderBytes(response),
194
+ };
195
+ // Wrap the response body in a TransformStream that counts bytes as
196
+ // they flow through to the caller, then records into the accountant
197
+ // + (for un-cataloged calls) emits a `network` event at stream end.
198
+ // For zero-body responses (HEAD, 204) we fall back to immediate
199
+ // finalisation.
200
+ const wrappedResponse = _wrapResponseForByteCounting(response, callContext);
201
+ // The cost-extraction path (catalog / domain-rate / un-cataloged
202
+ // external_cost-zero) still runs immediately — it works off the
203
+ // RESPONSE HEADERS for catalog lookup and may consume a JSON body
204
+ // independently. v1 §4.3 byte_details are stamped on every event
205
+ // (request side is known; response side is added later via the
206
+ // recording stream — for v1, only request-side byte_details land on
207
+ // catalog/domain-rate events. Response-side flows into the task
208
+ // aggregate via the accountant. v2 finalize back-fills network event
209
+ // costs after the snapshot.)
210
+ await _maybeRecordCost(urlStr, wrappedResponse, callContext);
211
+ return wrappedResponse;
212
+ };
213
+ // §4.2.1: tag our wrapper so a second `trackHttp()` (or a duplicate
214
+ // SDK install across realms) doesn't double-wrap.
215
+ Object.defineProperty(globalThis.fetch, DEXCOST_PATCHED, {
216
+ value: true,
217
+ enumerable: false,
218
+ configurable: false,
219
+ writable: false,
220
+ });
221
+ // Also patch Node's low-level http/https transports — many SDKs
222
+ // (AWS SDK v2, older clients, agents) use these directly rather than
223
+ // the global fetch. Mirrors the Python adapter patching multiple
224
+ // transports (requests/httpx/aiohttp/botocore/urllib3).
225
+ _patchNodeHttp();
226
+ }
227
+ /* eslint-disable @typescript-eslint/no-explicit-any */
228
+ /** Build a URL string from the args passed to http(s).request / .get. */
229
+ function _urlFromRequestArgs(isHttps, args) {
230
+ const first = args[0];
231
+ if (typeof first === "string") {
232
+ return first;
233
+ }
234
+ if (first instanceof URL) {
235
+ return first.toString();
236
+ }
237
+ if (first && typeof first === "object") {
238
+ // RequestOptions object
239
+ const protocol = first.protocol ?? (isHttps ? "https:" : "http:");
240
+ const host = first.hostname ?? first.host ?? "localhost";
241
+ const port = first.port ? `:${first.port}` : "";
242
+ const path = first.path ?? "/";
243
+ return `${protocol}//${host}${port}${path}`;
244
+ }
245
+ return null;
246
+ }
247
+ /** CommonJS require, used to obtain the mutable http/https module objects. */
248
+ const _require = createRequire(import.meta.url);
249
+ /** Patch http.request/get and https.request/get to record external costs. */
250
+ function _patchNodeHttp() {
251
+ if (_originalHttpRequest !== null)
252
+ return; // already patched
253
+ let nodeHttp;
254
+ let nodeHttps;
255
+ try {
256
+ nodeHttp = _require("node:http");
257
+ nodeHttps = _require("node:https");
258
+ }
259
+ catch {
260
+ return; // http/https unavailable — skip
261
+ }
262
+ _originalHttpRequest = nodeHttp.request;
263
+ _originalHttpsRequest = nodeHttps.request;
264
+ _originalHttpGet = nodeHttp.get;
265
+ _originalHttpsGet = nodeHttps.get;
266
+ const makeWrapper = (original, isHttps) => function wrappedRequest(...args) {
267
+ const req = original.apply(this, args);
268
+ try {
269
+ const raw = _urlFromRequestArgs(isHttps, args);
270
+ const urlStr = raw ? scrubUrl(raw) : raw;
271
+ if (urlStr) {
272
+ // Record on response — body is not parsed for Node-level
273
+ // requests (matches the Python urllib3 wrapper's behaviour).
274
+ if (req && typeof req.on === "function") {
275
+ req.on("response", () => {
276
+ void _maybeRecordCost(urlStr);
277
+ });
278
+ }
279
+ else {
280
+ void _maybeRecordCost(urlStr);
281
+ }
282
+ }
283
+ }
284
+ catch {
285
+ // never crash user code
286
+ }
287
+ return req;
288
+ };
289
+ // Sprint 3 Theme E / §4.2.2 — atomic patch. If `Object.freeze` has
290
+ // been applied to one of the modules (some serverless runtimes do
291
+ // this), a partial patch leaves the SDK in a half-installed state
292
+ // where request is wrapped but get is unrestored — and the originals
293
+ // are forgotten so untrackHttp() can't roll back. Try all 4
294
+ // assignments and on ANY failure restore everything we already
295
+ // wrote.
296
+ const wrappers = {
297
+ httpRequest: makeWrapper(_originalHttpRequest, false),
298
+ httpsRequest: makeWrapper(_originalHttpsRequest, true),
299
+ httpGet: makeWrapper(_originalHttpGet, false),
300
+ httpsGet: makeWrapper(_originalHttpsGet, true),
301
+ };
302
+ const installed = [];
303
+ try {
304
+ nodeHttp.request = wrappers.httpRequest;
305
+ installed.push(() => { nodeHttp.request = _originalHttpRequest; });
306
+ nodeHttps.request = wrappers.httpsRequest;
307
+ installed.push(() => { nodeHttps.request = _originalHttpsRequest; });
308
+ nodeHttp.get = wrappers.httpGet;
309
+ installed.push(() => { nodeHttp.get = _originalHttpGet; });
310
+ nodeHttps.get = wrappers.httpsGet;
311
+ installed.push(() => { nodeHttps.get = _originalHttpsGet; });
312
+ }
313
+ catch {
314
+ // Roll back every wrapper we successfully installed BEFORE the
315
+ // failure, so the customer's http stack is exactly where it
316
+ // started. Best-effort: each restore may itself throw if the
317
+ // module is fully frozen — we swallow that.
318
+ for (const restore of installed) {
319
+ try {
320
+ restore();
321
+ }
322
+ catch { /* frozen, leave as-is */ }
323
+ }
324
+ _originalHttpRequest = null;
325
+ _originalHttpsRequest = null;
326
+ _originalHttpGet = null;
327
+ _originalHttpsGet = null;
328
+ }
329
+ }
330
+ /** Restore the original http/https transports. */
331
+ function _unpatchNodeHttp() {
332
+ if (_originalHttpRequest === null)
333
+ return;
334
+ try {
335
+ const nodeHttp = _require("node:http");
336
+ const nodeHttps = _require("node:https");
337
+ nodeHttp.request = _originalHttpRequest;
338
+ nodeHttps.request = _originalHttpsRequest;
339
+ nodeHttp.get = _originalHttpGet;
340
+ nodeHttps.get = _originalHttpsGet;
341
+ }
342
+ catch {
343
+ // best-effort
344
+ }
345
+ _originalHttpRequest = null;
346
+ _originalHttpsRequest = null;
347
+ _originalHttpGet = null;
348
+ _originalHttpsGet = null;
349
+ }
350
+ /* eslint-enable @typescript-eslint/no-explicit-any */
351
+ /**
352
+ * Restore `globalThis.fetch` to its original value.
353
+ */
354
+ export function untrackHttp() {
355
+ if (!_patched || _originalFetch === null)
356
+ return;
357
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
358
+ globalThis.fetch = _originalFetch;
359
+ _originalFetch = null;
360
+ _patched = false;
361
+ _sessionManager = null;
362
+ _buffer = null;
363
+ _unpatchNodeHttp();
364
+ }
365
+ // ---------------------------------------------------------------------------
366
+ // Catalog / session accessors (for testing)
367
+ // ---------------------------------------------------------------------------
368
+ /** Get the loaded service catalog instance (may be null). */
369
+ export function getServiceCatalog() {
370
+ return _catalog;
371
+ }
372
+ /** Reset the service catalog (for testing). */
373
+ export function resetServiceCatalog() {
374
+ _catalog = null;
375
+ }
376
+ /** Get the session manager (for testing). */
377
+ export function getSessionManager() {
378
+ return _sessionManager;
379
+ }
380
+ // ---------------------------------------------------------------------------
381
+ // Recorded events accessors
382
+ // ---------------------------------------------------------------------------
383
+ /**
384
+ * Return all cost events recorded by the adapter since the last
385
+ * `clearRecordedEvents()` call.
386
+ */
387
+ export function getRecordedEvents() {
388
+ return [..._recordedEvents];
389
+ }
390
+ /**
391
+ * Clear the adapter's recorded events list.
392
+ */
393
+ export function clearRecordedEvents() {
394
+ _recordedEvents.length = 0;
395
+ }
396
+ // ---------------------------------------------------------------------------
397
+ // Internal helpers
398
+ // ---------------------------------------------------------------------------
399
+ /**
400
+ * Resolve the task an HTTP cost should be attributed to.
401
+ *
402
+ * Order: active task → session task → freshly-created auto-task. An
403
+ * auto-task is always returned, so HTTP costs are never silently lost.
404
+ */
405
+ function _resolveHttpTask() {
406
+ const current = getCurrentTask();
407
+ if (current !== undefined) {
408
+ return current;
409
+ }
410
+ if (_sessionManager && _buffer) {
411
+ const sessionTask = _sessionManager.runInSession("http", _buffer, () => getCurrentTask());
412
+ if (sessionTask !== undefined) {
413
+ return sessionTask;
414
+ }
415
+ }
416
+ // No task and no session — create an auto-task (mirrors Python).
417
+ const autoTask = createAutoTask("http_call");
418
+ if (_buffer) {
419
+ _buffer.upsertTask(autoTask);
420
+ }
421
+ return autoTask;
422
+ }
423
+ /**
424
+ * Extract cost from a fetch `Response` for a matched catalog entry.
425
+ *
426
+ * Clones the response, parses a JSON body when small enough, and falls
427
+ * back to header-only extraction on any failure. Returns `null` if
428
+ * extraction is impossible.
429
+ */
430
+ async function _extractFromResponse(catalog, entry, response) {
431
+ if (!entry)
432
+ return null;
433
+ try {
434
+ const cloned = response.clone();
435
+ const headers = cloned.headers;
436
+ let body = null;
437
+ const contentType = headers.get("content-type") ?? "";
438
+ const contentLength = headers.get("content-length");
439
+ const cl = Number.parseInt(contentLength ?? "", 10);
440
+ const bodyTooLarge = contentLength !== null && !Number.isNaN(cl) && cl > MAX_BODY_SIZE;
441
+ if (contentType.includes("application/json") && !bodyTooLarge) {
442
+ try {
443
+ body = await cloned.json();
444
+ }
445
+ catch {
446
+ // Body not parseable — continue with null
447
+ }
448
+ }
449
+ return catalog.extractCost(entry, headers, body);
450
+ }
451
+ catch {
452
+ // Failed to read response — try header-only extraction
453
+ try {
454
+ return catalog.extractCost(entry, response.headers, null);
455
+ }
456
+ catch {
457
+ return null;
458
+ }
459
+ }
460
+ }
461
+ function _resolveUrlStr(input) {
462
+ if (typeof input === "string")
463
+ return input;
464
+ if (input instanceof URL)
465
+ return input.toString();
466
+ return input.url;
467
+ }
468
+ function _resolveMethod(input, init) {
469
+ if (init?.method)
470
+ return init.method.toUpperCase();
471
+ if (input instanceof Request)
472
+ return input.method.toUpperCase();
473
+ return "GET";
474
+ }
475
+ function _resolveRequestHeaders(input, init) {
476
+ const headers = {};
477
+ const src = init?.headers ?? (input instanceof Request ? input.headers : undefined);
478
+ if (!src)
479
+ return headers;
480
+ if (src instanceof Headers) {
481
+ src.forEach((value, key) => {
482
+ headers[key] = value;
483
+ });
484
+ }
485
+ else if (Array.isArray(src)) {
486
+ for (const [k, v] of src)
487
+ headers[k] = v;
488
+ }
489
+ else {
490
+ for (const [k, v] of Object.entries(src))
491
+ headers[k] = String(v);
492
+ }
493
+ return headers;
494
+ }
495
+ function _resolveRequestBodyLen(input, init) {
496
+ const body = init?.body ?? (input instanceof Request ? null : undefined);
497
+ if (!body)
498
+ return 0;
499
+ if (typeof body === "string")
500
+ return Buffer.byteLength(body, "utf-8");
501
+ if (body instanceof URLSearchParams)
502
+ return Buffer.byteLength(body.toString(), "utf-8");
503
+ if (body instanceof ArrayBuffer)
504
+ return body.byteLength;
505
+ if (ArrayBuffer.isView(body))
506
+ return body.byteLength;
507
+ if (body instanceof Blob)
508
+ return body.size;
509
+ // FormData / ReadableStream — size unknown without consuming.
510
+ return 0;
511
+ }
512
+ function _measureResponseHeaderBytes(response) {
513
+ const headers = {};
514
+ response.headers.forEach((value, key) => {
515
+ headers[key] = value;
516
+ });
517
+ // Pass empty method/url so the request-line formula contributes only the
518
+ // constant 12-byte " HTTP/1.1\r\n" overhead (mirrors Python).
519
+ return measureBytesFromHeaders("", "", headers, 0);
520
+ }
521
+ function _isInternalToValue(p) {
522
+ return p;
523
+ }
524
+ /**
525
+ * Wrap a Response in a new Response whose body is piped through a
526
+ * counting TransformStream. The counter is held in the closure; when the
527
+ * stream's flush fires (source ended) — or when the caller drops the
528
+ * response (Drop/cancel) — the byte total is recorded into the active
529
+ * task's accountant and, for un-cataloged calls that aren't suppressed,
530
+ * a `network` event is emitted with `cost_pending=true`.
531
+ *
532
+ * Zero-body responses (status 204, HEAD requests, no `body` stream)
533
+ * finalise immediately so the accountant still sees the call.
534
+ */
535
+ function _wrapResponseForByteCounting(response, ctx) {
536
+ let bytesRead = 0;
537
+ let finalised = false;
538
+ const finalise = () => {
539
+ if (finalised)
540
+ return;
541
+ finalised = true;
542
+ _finaliseHttpCall(ctx, bytesRead);
543
+ };
544
+ if (!response.body) {
545
+ finalise();
546
+ return response;
547
+ }
548
+ // TransformStream's Transformer interface has transform + flush; cancel
549
+ // isn't a member. To catch early-abort (caller cancels the stream), we
550
+ // wrap the readable side in a separate ReadableStream that listens for
551
+ // .cancel() and calls finalise. The TransformStream's flush() handles
552
+ // the natural end-of-stream case.
553
+ const counting = new TransformStream({
554
+ transform(chunk, controller) {
555
+ bytesRead += chunk.byteLength;
556
+ controller.enqueue(chunk);
557
+ },
558
+ flush() {
559
+ finalise();
560
+ },
561
+ });
562
+ const piped = response.body.pipeThrough(counting);
563
+ // Add an early-abort hook by wrapping `piped` in a ReadableStream that
564
+ // forwards reads from `piped`'s reader and triggers finalise on cancel.
565
+ const reader = piped.getReader();
566
+ const earlyAbortWrapper = new ReadableStream({
567
+ async pull(controller) {
568
+ try {
569
+ const { value, done } = await reader.read();
570
+ if (done) {
571
+ controller.close();
572
+ return;
573
+ }
574
+ controller.enqueue(value);
575
+ }
576
+ catch (err) {
577
+ controller.error(err);
578
+ finalise();
579
+ }
580
+ },
581
+ cancel(reason) {
582
+ finalise(); // v1 §5.5 early-abort: bytes-actually-received.
583
+ return reader.cancel(reason);
584
+ },
585
+ });
586
+ return new Response(earlyAbortWrapper, {
587
+ status: response.status,
588
+ statusText: response.statusText,
589
+ headers: response.headers,
590
+ });
591
+ }
592
+ /**
593
+ * Called once the response body has been fully drained, cancelled, or
594
+ * was empty. Records the byte totals into the task's accountant and, if
595
+ * the call was un-cataloged AND not suppressed AND notable
596
+ * (combined_bytes > threshold OR status >= 400), emits a `network`
597
+ * event with `cost_pending=true`.
598
+ */
599
+ function _finaliseHttpCall(ctx, responseBodyBytes) {
600
+ const responseBytes = ctx.responseHeaderBytes + responseBodyBytes;
601
+ const task = _resolveHttpTask();
602
+ const accountant = getAccountant(task.taskId);
603
+ if (accountant) {
604
+ accountant.record(ctx.hostname, responseBytes, ctx.requestBytes, ctx.isInternal);
605
+ }
606
+ // Network-event emission is the un-cataloged path's responsibility.
607
+ // _maybeRecordCost decides which path was taken — for catalog /
608
+ // domain-rate calls it sets a "matched" flag we check here. Stored on
609
+ // the ctx so a single-call closure threads it.
610
+ if (ctx._matchedCatalog || ctx.suppressed)
611
+ return;
612
+ const combined = ctx.requestBytes + responseBytes;
613
+ // Find the most recent un-cataloged external_cost-zero event for this
614
+ // URL — that's the one we want to REPLACE with a network event (the
615
+ // current behaviour records an external_cost-zero; v1 §4.4 wants a
616
+ // network event instead). Match by url to avoid race with concurrent
617
+ // calls.
618
+ for (let i = _recordedEvents.length - 1; i >= 0; i--) {
619
+ const ev = _recordedEvents[i];
620
+ if (ev.eventType === "external_cost" &&
621
+ ev.costUsd === 0 &&
622
+ ev.costConfidence === "unknown" &&
623
+ ev.details?.url === ctx.urlStr &&
624
+ !ev.details?._reTyped) {
625
+ // Mark and re-type or drop based on threshold.
626
+ if (combined > NETWORK_EVENT_THRESHOLD_BYTES) {
627
+ ev.eventType = "network";
628
+ ev.serviceName = ctx.hostname;
629
+ ev.details = {
630
+ ...ev.details,
631
+ method: ctx.method,
632
+ cost_pending: true,
633
+ protocol: ctx.protocol,
634
+ request_bytes: ctx.requestBytes,
635
+ response_bytes: responseBytes,
636
+ is_internal_traffic: _isInternalToValue(ctx.isInternal),
637
+ _reTyped: true,
638
+ };
639
+ }
640
+ else {
641
+ // Below threshold and no error → counters-only. Drop the
642
+ // placeholder external_cost-zero event from the in-memory list.
643
+ // The durable EventBuffer doesn't expose removeEvent today, so a
644
+ // placeholder may still be persisted there; this matches Python's
645
+ // behaviour where the placeholder pattern was removed by re-typing
646
+ // un-cataloged calls instead of by deletion. For tests reading
647
+ // _recordedEvents directly this is the visible behaviour.
648
+ _recordedEvents.splice(i, 1);
649
+ }
650
+ break;
651
+ }
652
+ }
653
+ }
654
+ async function _maybeRecordCost(urlStr, response, ctx) {
655
+ let hostname;
656
+ let parsedUrl;
657
+ try {
658
+ parsedUrl = new URL(urlStr);
659
+ hostname = parsedUrl.hostname;
660
+ }
661
+ catch {
662
+ return;
663
+ }
664
+ const domain = hostname.includes(":") ? hostname.split(":")[0] : hostname;
665
+ // Resolve the task to attribute this cost to. An auto-task is created
666
+ // when none is active so HTTP costs are never silently lost (mirrors
667
+ // the Python adapter's session auto-creation).
668
+ const task = _resolveHttpTask();
669
+ // v1 §4.3 byte_details — stamped into every event below. Response_bytes
670
+ // are deferred to the TransformStream finalisation; for now only the
671
+ // request side is known on catalog/domain-rate events.
672
+ const byteDetailsRequestOnly = ctx
673
+ ? {
674
+ protocol: ctx.protocol,
675
+ request_bytes: ctx.requestBytes,
676
+ is_internal_traffic: _isInternalToValue(ctx.isInternal),
677
+ }
678
+ : {};
679
+ // 1. Check user-registered domain rate first (highest precedence)
680
+ const rate = _domainRates.get(domain);
681
+ if (rate !== undefined) {
682
+ const event = createCostEvent({
683
+ eventId: randomUUID(),
684
+ taskId: task.taskId,
685
+ eventType: "external_cost",
686
+ costUsd: rate.costUsd,
687
+ costConfidence: "exact",
688
+ pricingSource: "rate_registry",
689
+ serviceName: domain,
690
+ details: { url: urlStr, per: rate.per, ...byteDetailsRequestOnly },
691
+ });
692
+ _pushRecordedEvent(event);
693
+ if (_buffer) {
694
+ _buffer.addEvent(event);
695
+ }
696
+ if (ctx)
697
+ ctx._matchedCatalog = true;
698
+ return;
699
+ }
700
+ // 2. Check service catalog
701
+ if (_catalog) {
702
+ const entry = _catalog.lookup(urlStr);
703
+ if (entry) {
704
+ // Extract cost from response
705
+ let extractionResult = null;
706
+ if (!response) {
707
+ // No response body available (Node-level request) — extract
708
+ // from the catalog entry alone.
709
+ try {
710
+ extractionResult = _catalog.extractCost(entry, new Headers(), null);
711
+ }
712
+ catch {
713
+ // Give up on extraction
714
+ }
715
+ }
716
+ else {
717
+ extractionResult = await _extractFromResponse(_catalog, entry, response);
718
+ }
719
+ if (extractionResult) {
720
+ const event = createCostEvent({
721
+ eventId: randomUUID(),
722
+ taskId: task.taskId,
723
+ eventType: "external_cost",
724
+ costUsd: extractionResult.costUsd,
725
+ costConfidence: extractionResult.confidence,
726
+ pricingSource: "rate_registry",
727
+ serviceName: extractionResult.serviceName,
728
+ details: {
729
+ url: urlStr,
730
+ pricingSource: extractionResult.pricingSource,
731
+ catalogService: entry.display_name,
732
+ ...byteDetailsRequestOnly,
733
+ },
734
+ });
735
+ _pushRecordedEvent(event);
736
+ if (_buffer) {
737
+ _buffer.addEvent(event);
738
+ }
739
+ if (ctx)
740
+ ctx._matchedCatalog = true;
741
+ return;
742
+ }
743
+ }
744
+ }
745
+ // 3. Un-cataloged — emit a placeholder external_cost-zero event that
746
+ // _finaliseHttpCall will RE-TYPE to a `network` event with
747
+ // cost_pending=true once the response body has been fully drained
748
+ // (and byte counts are known). If combined bytes stay below
749
+ // threshold and there's no error, the placeholder is dropped instead
750
+ // — counters-only path (v1 §4.4). If suppressed (LLM-host call),
751
+ // no event is emitted at all.
752
+ if (ctx?.suppressed) {
753
+ return; // bytes still flow into the accountant via finalise
754
+ }
755
+ const event = createCostEvent({
756
+ eventId: randomUUID(),
757
+ taskId: task.taskId,
758
+ eventType: "external_cost",
759
+ costUsd: 0,
760
+ costConfidence: "unknown",
761
+ serviceName: domain,
762
+ details: { url: urlStr, ...byteDetailsRequestOnly },
763
+ });
764
+ _pushRecordedEvent(event);
765
+ if (_buffer) {
766
+ _buffer.addEvent(event);
767
+ }
768
+ }
769
+ //# sourceMappingURL=http.js.map