@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.
- package/LICENSE +21 -0
- package/README.md +210 -0
- package/dist/adapters/_netbytes.d.ts +31 -0
- package/dist/adapters/_netbytes.d.ts.map +1 -0
- package/dist/adapters/_netbytes.js +154 -0
- package/dist/adapters/_netbytes.js.map +1 -0
- package/dist/adapters/aws-lambda.d.ts +41 -0
- package/dist/adapters/aws-lambda.d.ts.map +1 -0
- package/dist/adapters/aws-lambda.js +65 -0
- package/dist/adapters/aws-lambda.js.map +1 -0
- package/dist/adapters/browser.d.ts +52 -0
- package/dist/adapters/browser.d.ts.map +1 -0
- package/dist/adapters/browser.js +127 -0
- package/dist/adapters/browser.js.map +1 -0
- package/dist/adapters/compute-wrap.d.ts +33 -0
- package/dist/adapters/compute-wrap.d.ts.map +1 -0
- package/dist/adapters/compute-wrap.js +188 -0
- package/dist/adapters/compute-wrap.js.map +1 -0
- package/dist/adapters/data/aws_lambda_pricing.json +61 -0
- package/dist/adapters/gpu-wrap.d.ts +31 -0
- package/dist/adapters/gpu-wrap.d.ts.map +1 -0
- package/dist/adapters/gpu-wrap.js +147 -0
- package/dist/adapters/gpu-wrap.js.map +1 -0
- package/dist/adapters/http.d.ts +58 -0
- package/dist/adapters/http.d.ts.map +1 -0
- package/dist/adapters/http.js +769 -0
- package/dist/adapters/http.js.map +1 -0
- package/dist/adapters/index.d.ts +11 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +12 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/network-accountant.d.ts +63 -0
- package/dist/adapters/network-accountant.d.ts.map +1 -0
- package/dist/adapters/network-accountant.js +153 -0
- package/dist/adapters/network-accountant.js.map +1 -0
- package/dist/cli/index.d.ts +13 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +225 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/scanner.d.ts +39 -0
- package/dist/cli/scanner.d.ts.map +1 -0
- package/dist/cli/scanner.js +480 -0
- package/dist/cli/scanner.js.map +1 -0
- package/dist/clients.d.ts +54 -0
- package/dist/clients.d.ts.map +1 -0
- package/dist/clients.js +240 -0
- package/dist/clients.js.map +1 -0
- package/dist/cloud-detect.d.ts +96 -0
- package/dist/cloud-detect.d.ts.map +1 -0
- package/dist/cloud-detect.js +545 -0
- package/dist/cloud-detect.js.map +1 -0
- package/dist/core/auto-task.d.ts +20 -0
- package/dist/core/auto-task.d.ts.map +1 -0
- package/dist/core/auto-task.js +34 -0
- package/dist/core/auto-task.js.map +1 -0
- package/dist/core/cgroup-reader.d.ts +45 -0
- package/dist/core/cgroup-reader.d.ts.map +1 -0
- package/dist/core/cgroup-reader.js +124 -0
- package/dist/core/cgroup-reader.js.map +1 -0
- package/dist/core/cgroup-walker.d.ts +60 -0
- package/dist/core/cgroup-walker.d.ts.map +1 -0
- package/dist/core/cgroup-walker.js +166 -0
- package/dist/core/cgroup-walker.js.map +1 -0
- package/dist/core/compute-accountant.d.ts +51 -0
- package/dist/core/compute-accountant.d.ts.map +1 -0
- package/dist/core/compute-accountant.js +179 -0
- package/dist/core/compute-accountant.js.map +1 -0
- package/dist/core/compute-runtime.d.ts +42 -0
- package/dist/core/compute-runtime.d.ts.map +1 -0
- package/dist/core/compute-runtime.js +80 -0
- package/dist/core/compute-runtime.js.map +1 -0
- package/dist/core/config.d.ts +44 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +66 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/context.d.ts +76 -0
- package/dist/core/context.d.ts.map +1 -0
- package/dist/core/context.js +91 -0
- package/dist/core/context.js.map +1 -0
- package/dist/core/fargate-metadata.d.ts +27 -0
- package/dist/core/fargate-metadata.d.ts.map +1 -0
- package/dist/core/fargate-metadata.js +102 -0
- package/dist/core/fargate-metadata.js.map +1 -0
- package/dist/core/gpu-accountant.d.ts +104 -0
- package/dist/core/gpu-accountant.d.ts.map +1 -0
- package/dist/core/gpu-accountant.js +383 -0
- package/dist/core/gpu-accountant.js.map +1 -0
- package/dist/core/gpu-runtime.d.ts +58 -0
- package/dist/core/gpu-runtime.d.ts.map +1 -0
- package/dist/core/gpu-runtime.js +131 -0
- package/dist/core/gpu-runtime.js.map +1 -0
- package/dist/core/heuristics.d.ts +74 -0
- package/dist/core/heuristics.d.ts.map +1 -0
- package/dist/core/heuristics.js +182 -0
- package/dist/core/heuristics.js.map +1 -0
- package/dist/core/models.d.ts +149 -0
- package/dist/core/models.d.ts.map +1 -0
- package/dist/core/models.js +226 -0
- package/dist/core/models.js.map +1 -0
- package/dist/core/nvml-reader.d.ts +114 -0
- package/dist/core/nvml-reader.d.ts.map +1 -0
- package/dist/core/nvml-reader.js +323 -0
- package/dist/core/nvml-reader.js.map +1 -0
- package/dist/core/session.d.ts +48 -0
- package/dist/core/session.d.ts.map +1 -0
- package/dist/core/session.js +123 -0
- package/dist/core/session.js.map +1 -0
- package/dist/core/tracker.d.ts +364 -0
- package/dist/core/tracker.d.ts.map +1 -0
- package/dist/core/tracker.js +1073 -0
- package/dist/core/tracker.js.map +1 -0
- package/dist/data/compute_prices.json +180 -0
- package/dist/data/egress_prices.json +418 -0
- package/dist/data/gpu_prices.json +412 -0
- package/dist/data/service_prices.json +2595 -0
- package/dist/dev-console.d.ts +12 -0
- package/dist/dev-console.d.ts.map +1 -0
- package/dist/dev-console.js +60 -0
- package/dist/dev-console.js.map +1 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +61 -0
- package/dist/index.js.map +1 -0
- package/dist/instruments/anthropic.d.ts +26 -0
- package/dist/instruments/anthropic.d.ts.map +1 -0
- package/dist/instruments/anthropic.js +242 -0
- package/dist/instruments/anthropic.js.map +1 -0
- package/dist/instruments/bedrock.d.ts +29 -0
- package/dist/instruments/bedrock.d.ts.map +1 -0
- package/dist/instruments/bedrock.js +215 -0
- package/dist/instruments/bedrock.js.map +1 -0
- package/dist/instruments/cohere.d.ts +29 -0
- package/dist/instruments/cohere.d.ts.map +1 -0
- package/dist/instruments/cohere.js +237 -0
- package/dist/instruments/cohere.js.map +1 -0
- package/dist/instruments/gemini.d.ts +30 -0
- package/dist/instruments/gemini.d.ts.map +1 -0
- package/dist/instruments/gemini.js +247 -0
- package/dist/instruments/gemini.js.map +1 -0
- package/dist/instruments/index.d.ts +35 -0
- package/dist/instruments/index.d.ts.map +1 -0
- package/dist/instruments/index.js +54 -0
- package/dist/instruments/index.js.map +1 -0
- package/dist/instruments/mcp.d.ts +24 -0
- package/dist/instruments/mcp.d.ts.map +1 -0
- package/dist/instruments/mcp.js +459 -0
- package/dist/instruments/mcp.js.map +1 -0
- package/dist/instruments/openai.d.ts +26 -0
- package/dist/instruments/openai.d.ts.map +1 -0
- package/dist/instruments/openai.js +221 -0
- package/dist/instruments/openai.js.map +1 -0
- package/dist/instruments/vercel-ai.d.ts +28 -0
- package/dist/instruments/vercel-ai.d.ts.map +1 -0
- package/dist/instruments/vercel-ai.js +192 -0
- package/dist/instruments/vercel-ai.js.map +1 -0
- package/dist/integrations/langchain.d.ts +65 -0
- package/dist/integrations/langchain.d.ts.map +1 -0
- package/dist/integrations/langchain.js +165 -0
- package/dist/integrations/langchain.js.map +1 -0
- package/dist/middleware/express.d.ts +55 -0
- package/dist/middleware/express.d.ts.map +1 -0
- package/dist/middleware/express.js +101 -0
- package/dist/middleware/express.js.map +1 -0
- package/dist/middleware/index.d.ts +6 -0
- package/dist/middleware/index.d.ts.map +1 -0
- package/dist/middleware/index.js +5 -0
- package/dist/middleware/index.js.map +1 -0
- package/dist/pricing/compute-pricing.d.ts +57 -0
- package/dist/pricing/compute-pricing.d.ts.map +1 -0
- package/dist/pricing/compute-pricing.js +627 -0
- package/dist/pricing/compute-pricing.js.map +1 -0
- package/dist/pricing/cost_map.json +37665 -0
- package/dist/pricing/egress-pricing.d.ts +55 -0
- package/dist/pricing/egress-pricing.d.ts.map +1 -0
- package/dist/pricing/egress-pricing.js +226 -0
- package/dist/pricing/egress-pricing.js.map +1 -0
- package/dist/pricing/engine.d.ts +24 -0
- package/dist/pricing/engine.d.ts.map +1 -0
- package/dist/pricing/engine.js +148 -0
- package/dist/pricing/engine.js.map +1 -0
- package/dist/pricing/gpu-pricing.d.ts +63 -0
- package/dist/pricing/gpu-pricing.d.ts.map +1 -0
- package/dist/pricing/gpu-pricing.js +484 -0
- package/dist/pricing/gpu-pricing.js.map +1 -0
- package/dist/pricing/rates.d.ts +17 -0
- package/dist/pricing/rates.d.ts.map +1 -0
- package/dist/pricing/rates.js +102 -0
- package/dist/pricing/rates.js.map +1 -0
- package/dist/pricing/service-catalog.d.ts +87 -0
- package/dist/pricing/service-catalog.d.ts.map +1 -0
- package/dist/pricing/service-catalog.js +406 -0
- package/dist/pricing/service-catalog.js.map +1 -0
- package/dist/schema/dexcost-event.v1.json +111 -0
- package/dist/schema/dexcost-task.v1.json +160 -0
- package/dist/schema/validate.d.ts +15 -0
- package/dist/schema/validate.d.ts.map +1 -0
- package/dist/schema/validate.js +87 -0
- package/dist/schema/validate.js.map +1 -0
- package/dist/security/redaction.d.ts +55 -0
- package/dist/security/redaction.d.ts.map +1 -0
- package/dist/security/redaction.js +144 -0
- package/dist/security/redaction.js.map +1 -0
- package/dist/transport/buffer.d.ts +117 -0
- package/dist/transport/buffer.d.ts.map +1 -0
- package/dist/transport/buffer.js +759 -0
- package/dist/transport/buffer.js.map +1 -0
- package/dist/transport/pusher.d.ts +89 -0
- package/dist/transport/pusher.d.ts.map +1 -0
- package/dist/transport/pusher.js +323 -0
- package/dist/transport/pusher.js.map +1 -0
- 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
|