@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,759 @@
1
+ /**
2
+ * SQLite-backed event buffer for dexcost.
3
+ *
4
+ * Persists cost events and tasks to a local SQLite database using the exact
5
+ * same schema as the Python SDK. Data survives process restarts.
6
+ *
7
+ * Uses better-sqlite3 for synchronous, high-performance SQLite access.
8
+ */
9
+ import { createRequire } from "node:module";
10
+ import { mkdirSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { dirname, join } from "node:path";
13
+ // ---------------------------------------------------------------------------
14
+ // Helpers
15
+ // ---------------------------------------------------------------------------
16
+ function rowToEvent(row) {
17
+ return {
18
+ eventId: row.event_id,
19
+ taskId: row.task_id,
20
+ eventType: row.event_type,
21
+ provider: row.provider ?? undefined,
22
+ model: row.model ?? undefined,
23
+ inputTokens: row.input_tokens ?? undefined,
24
+ outputTokens: row.output_tokens ?? undefined,
25
+ cachedTokens: row.cached_tokens ?? undefined,
26
+ serviceName: row.service_name ?? undefined,
27
+ costUsd: Number(row.cost_usd),
28
+ latencyMs: row.latency_ms ?? undefined,
29
+ costConfidence: row.cost_confidence,
30
+ pricingSource: row.pricing_source ?? undefined,
31
+ pricingVersion: row.pricing_version ?? undefined,
32
+ isRetry: Boolean(row.is_retry),
33
+ retryReason: row.retry_reason ?? undefined,
34
+ retryOf: row.retry_of ?? undefined,
35
+ details: (() => {
36
+ let d = {};
37
+ try {
38
+ d = row.details != null ? JSON.parse(row.details) : {};
39
+ }
40
+ catch {
41
+ d = {};
42
+ }
43
+ return d;
44
+ })(),
45
+ occurredAt: new Date(row.timestamp),
46
+ schemaVersion: "1",
47
+ };
48
+ }
49
+ function rowToTask(row) {
50
+ return {
51
+ taskId: row.task_id,
52
+ taskType: row.task_type,
53
+ status: row.status,
54
+ startedAt: new Date(row.started_at),
55
+ endedAt: row.ended_at != null ? new Date(row.ended_at) : undefined,
56
+ metadata: (() => {
57
+ let m = {};
58
+ try {
59
+ m = row.metadata != null ? JSON.parse(row.metadata) : {};
60
+ }
61
+ catch {
62
+ m = {};
63
+ }
64
+ return m;
65
+ })(),
66
+ llmCostUsd: Number(row.llm_cost_usd ?? "0"),
67
+ externalCostUsd: Number(row.external_cost_usd ?? "0"),
68
+ computeCostUsd: Number(row.compute_cost_usd ?? "0"),
69
+ totalCostUsd: Number(row.total_cost_usd ?? "0"),
70
+ totalInputTokens: row.total_input_tokens ?? 0,
71
+ totalOutputTokens: row.total_output_tokens ?? 0,
72
+ totalCachedTokens: row.total_cached_tokens ?? 0,
73
+ retryCount: row.retry_count ?? 0,
74
+ retryCostUsd: Number(row.retry_cost_usd ?? "0"),
75
+ failureCount: row.failure_count ?? 0,
76
+ customerId: row.customer_id ?? undefined,
77
+ projectId: row.project_id ?? undefined,
78
+ parentTaskId: row.parent_task_id ?? undefined,
79
+ experimentId: row.experiment_id ?? undefined,
80
+ variant: row.variant ?? undefined,
81
+ // Network capture fields default to zero / empty for rows that
82
+ // pre-date the v1 migration. Phase D wires the SQLite columns +
83
+ // serialisation/deserialisation; for now legacy rows read back as
84
+ // fresh, matching Python's from_dict defaults.
85
+ networkBytesIn: 0,
86
+ networkBytesOut: 0,
87
+ networkCallCount: 0,
88
+ networkByHost: { hosts: [] },
89
+ networkCostUsd: 0,
90
+ // Same legacy-row default as the other network/GPU fields: rows
91
+ // pre-dating the GPU columns read back as fresh zero. Matches
92
+ // Python's from_dict default and aligns with the post-Sprint-2
93
+ // 5-subsystem `Task` type.
94
+ gpuCostUsd: 0,
95
+ schemaVersion: "1",
96
+ };
97
+ }
98
+ // ---------------------------------------------------------------------------
99
+ // DDL
100
+ // ---------------------------------------------------------------------------
101
+ const CREATE_TASKS = `
102
+ CREATE TABLE IF NOT EXISTS tasks (
103
+ task_id TEXT PRIMARY KEY,
104
+ task_type TEXT NOT NULL,
105
+ status TEXT NOT NULL,
106
+ started_at TEXT NOT NULL,
107
+ ended_at TEXT,
108
+ metadata TEXT,
109
+ llm_cost_usd TEXT,
110
+ external_cost_usd TEXT,
111
+ compute_cost_usd TEXT,
112
+ total_cost_usd TEXT,
113
+ total_input_tokens INTEGER,
114
+ total_output_tokens INTEGER,
115
+ total_cached_tokens INTEGER,
116
+ retry_count INTEGER DEFAULT 0,
117
+ retry_cost_usd TEXT DEFAULT '0',
118
+ failure_count INTEGER DEFAULT 0,
119
+ customer_id TEXT,
120
+ project_id TEXT,
121
+ parent_task_id TEXT,
122
+ experiment_id TEXT,
123
+ variant TEXT,
124
+ sync_status TEXT NOT NULL DEFAULT 'pending'
125
+ )`;
126
+ const CREATE_EVENTS = `
127
+ CREATE TABLE IF NOT EXISTS events (
128
+ event_id TEXT PRIMARY KEY,
129
+ task_id TEXT NOT NULL,
130
+ event_type TEXT NOT NULL,
131
+ provider TEXT,
132
+ model TEXT,
133
+ input_tokens INTEGER,
134
+ output_tokens INTEGER,
135
+ cached_tokens INTEGER,
136
+ service_name TEXT,
137
+ cost_usd TEXT NOT NULL,
138
+ latency_ms INTEGER,
139
+ cost_confidence TEXT NOT NULL DEFAULT 'exact',
140
+ pricing_source TEXT,
141
+ pricing_version TEXT,
142
+ is_retry INTEGER DEFAULT 0,
143
+ retry_reason TEXT,
144
+ retry_of TEXT,
145
+ details TEXT,
146
+ timestamp TEXT NOT NULL,
147
+ sync_status TEXT NOT NULL DEFAULT 'pending'
148
+ )`;
149
+ const CREATE_SCHEMA_VERSION = `
150
+ CREATE TABLE IF NOT EXISTS schema_version (
151
+ version_id INTEGER PRIMARY KEY AUTOINCREMENT,
152
+ version_number INTEGER NOT NULL,
153
+ applied_at TEXT NOT NULL DEFAULT (datetime('now')),
154
+ migration_name TEXT
155
+ )`;
156
+ const INDEXES = [
157
+ `CREATE INDEX IF NOT EXISTS idx_tasks_customer ON tasks(customer_id, started_at)`,
158
+ `CREATE INDEX IF NOT EXISTS idx_tasks_type ON tasks(task_type, started_at)`,
159
+ `CREATE INDEX IF NOT EXISTS idx_tasks_period ON tasks(started_at)`,
160
+ `CREATE INDEX IF NOT EXISTS idx_events_task ON events(task_id)`,
161
+ `CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type, timestamp)`,
162
+ `CREATE INDEX IF NOT EXISTS idx_events_sync ON events(sync_status, timestamp)`,
163
+ `CREATE INDEX IF NOT EXISTS idx_tasks_sync ON tasks(sync_status, started_at)`,
164
+ ];
165
+ // ---------------------------------------------------------------------------
166
+ // MemoryBufferStore — in-memory fallback when better-sqlite3 is unavailable
167
+ // (Vercel Edge, Cloudflare Workers, Bun without bindings).
168
+ //
169
+ // Sprint 1 Theme B / §2.2.3 (B8 follow-on). The audit-minimum no-op
170
+ // fallback (commit a6eb6db) kept customer apps alive but silently
171
+ // dropped events. This store provides durable in-memory buffering with
172
+ // a hard 10k-entry cap per kind (events, tasks) and FIFO eviction
173
+ // (Map iteration order = insertion order). Events still don't survive
174
+ // process restarts — that's the SQLite path's job — but they're now
175
+ // available to the sync pusher within the process lifetime.
176
+ // ---------------------------------------------------------------------------
177
+ const MEM_BUFFER_MAX_EVENTS = 10_000;
178
+ const MEM_BUFFER_MAX_TASKS = 10_000;
179
+ class MemoryBufferStore {
180
+ _events = new Map();
181
+ _tasks = new Map();
182
+ addEvent(event) {
183
+ this._evict(this._events, MEM_BUFFER_MAX_EVENTS);
184
+ // Clone to detach from caller mutations.
185
+ this._events.set(event.eventId, {
186
+ event: { ...event },
187
+ syncStatus: "pending",
188
+ capturedAt: new Date(),
189
+ syncedAt: null,
190
+ });
191
+ }
192
+ updateEvent(event) {
193
+ // Only update if entry exists — matches SQLite's UPDATE semantics
194
+ // (no-op when no row matches).
195
+ const existing = this._events.get(event.eventId);
196
+ if (existing == null)
197
+ return;
198
+ existing.event = { ...event };
199
+ }
200
+ upsertTask(task) {
201
+ const existing = this._tasks.get(task.taskId);
202
+ if (existing != null) {
203
+ existing.task = { ...task };
204
+ return;
205
+ }
206
+ this._evict(this._tasks, MEM_BUFFER_MAX_TASKS);
207
+ this._tasks.set(task.taskId, {
208
+ task: { ...task },
209
+ syncStatus: "pending",
210
+ capturedAt: new Date(),
211
+ syncedAt: null,
212
+ });
213
+ }
214
+ getPendingEvents(limit) {
215
+ const out = [];
216
+ for (const entry of this._events.values()) {
217
+ if (entry.syncStatus !== "pending")
218
+ continue;
219
+ out.push(entry.event);
220
+ if (out.length >= limit)
221
+ break;
222
+ }
223
+ return out;
224
+ }
225
+ markSynced(eventIds) {
226
+ const now = new Date();
227
+ for (const id of eventIds) {
228
+ const entry = this._events.get(id);
229
+ if (entry != null) {
230
+ entry.syncStatus = "synced";
231
+ entry.syncedAt = now;
232
+ }
233
+ }
234
+ }
235
+ getTask(taskId) {
236
+ return this._tasks.get(taskId)?.task;
237
+ }
238
+ getAllTasks() {
239
+ return Array.from(this._tasks.values(), (e) => e.task);
240
+ }
241
+ getPendingTasks() {
242
+ const out = [];
243
+ for (const entry of this._tasks.values()) {
244
+ if (entry.syncStatus === "pending")
245
+ out.push(entry.task);
246
+ }
247
+ return out;
248
+ }
249
+ markTasksSynced(taskIds) {
250
+ const now = new Date();
251
+ for (const id of taskIds) {
252
+ const entry = this._tasks.get(id);
253
+ if (entry != null) {
254
+ entry.syncStatus = "synced";
255
+ entry.syncedAt = now;
256
+ }
257
+ }
258
+ }
259
+ get pendingTaskCount() {
260
+ let n = 0;
261
+ for (const e of this._tasks.values())
262
+ if (e.syncStatus === "pending")
263
+ n += 1;
264
+ return n;
265
+ }
266
+ get pendingCount() {
267
+ let n = 0;
268
+ for (const e of this._events.values())
269
+ if (e.syncStatus === "pending")
270
+ n += 1;
271
+ return n;
272
+ }
273
+ getAllEvents() {
274
+ return Array.from(this._events.values(), (e) => e.event);
275
+ }
276
+ queryEvents(taskId) {
277
+ const out = [];
278
+ for (const entry of this._events.values()) {
279
+ if (entry.event.taskId === taskId)
280
+ out.push(entry.event);
281
+ }
282
+ return out;
283
+ }
284
+ purgeSynced(retentionHours) {
285
+ const cutoff = Date.now() - retentionHours * 3600 * 1000;
286
+ let removed = 0;
287
+ for (const [id, entry] of this._events) {
288
+ if (entry.syncStatus === "synced" && entry.syncedAt != null &&
289
+ entry.syncedAt.getTime() < cutoff) {
290
+ this._events.delete(id);
291
+ removed += 1;
292
+ }
293
+ }
294
+ return removed;
295
+ }
296
+ purgeOldPending(maxAgeDays) {
297
+ const cutoff = Date.now() - maxAgeDays * 24 * 3600 * 1000;
298
+ let removed = 0;
299
+ for (const [id, entry] of this._events) {
300
+ if (entry.syncStatus === "pending" && entry.capturedAt.getTime() < cutoff) {
301
+ this._events.delete(id);
302
+ removed += 1;
303
+ }
304
+ }
305
+ return removed;
306
+ }
307
+ close() {
308
+ this._events.clear();
309
+ this._tasks.clear();
310
+ }
311
+ // Test-only: total entry counts (used by buffer regression tests to
312
+ // exercise the FIFO eviction cap without going through every getter).
313
+ _eventCount() { return this._events.size; }
314
+ _taskCount() { return this._tasks.size; }
315
+ _evict(map, max) {
316
+ while (map.size >= max) {
317
+ // Map iteration order = insertion order, so first key is oldest.
318
+ const oldestKey = map.keys().next().value;
319
+ if (oldestKey === undefined)
320
+ break;
321
+ map.delete(oldestKey);
322
+ }
323
+ }
324
+ }
325
+ // ---------------------------------------------------------------------------
326
+ // EventBuffer
327
+ // ---------------------------------------------------------------------------
328
+ /**
329
+ * SQLite-backed buffer that persists events and tasks across process restarts.
330
+ *
331
+ * Schema is identical to the Python SDK so both SDKs can share a database file.
332
+ * Costs are stored as TEXT strings to avoid floating-point precision loss.
333
+ */
334
+ export class EventBuffer {
335
+ // null when better-sqlite3 is unavailable; in that case `_mem` holds
336
+ // the in-memory fallback store and every method delegates to it.
337
+ _db;
338
+ _mem = null;
339
+ /**
340
+ * Test-only seam. When `true`, the constructor takes the no-binding
341
+ * fallback path without attempting the require — used by
342
+ * tests/runtime-fallback.test.ts to simulate Vercel Edge / Cloudflare
343
+ * Workers behaviour without touching the real native module. Do NOT
344
+ * set this in production code. Sprint 1 Theme B / §2.2.3 (B8).
345
+ */
346
+ static _forceFallbackForTest = false;
347
+ constructor(dbPath) {
348
+ // Sprint 1 Theme B / §2.2.3 (B8): try to load better-sqlite3
349
+ // dynamically. If the native binding is absent or fails to load
350
+ // (Vercel Edge, Cloudflare Workers, Bun without bindings), fall
351
+ // back to a Map-based in-memory buffer with a 10k-entry cap so
352
+ // init() doesn't crash the customer app and events are still
353
+ // available to the sync pusher within the process lifetime.
354
+ let DatabaseCtor = null;
355
+ if (EventBuffer._forceFallbackForTest) {
356
+ this._db = null;
357
+ this._mem = new MemoryBufferStore();
358
+ console.warn("dexcost: EventBuffer._forceFallbackForTest is set — using in-memory buffer (events do not survive process restart)");
359
+ return;
360
+ }
361
+ try {
362
+ const require = createRequire(import.meta.url);
363
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
364
+ DatabaseCtor = require("better-sqlite3");
365
+ }
366
+ catch (err) {
367
+ console.warn("dexcost: better-sqlite3 not available in this runtime; falling " +
368
+ "back to in-memory buffer (events do not survive process " +
369
+ "restart, hard cap 10k entries). Install better-sqlite3 as a " +
370
+ "peer dependency for durable buffering. Cause: " +
371
+ (err instanceof Error ? err.message : String(err)));
372
+ this._db = null;
373
+ this._mem = new MemoryBufferStore();
374
+ return;
375
+ }
376
+ const resolvedPath = dbPath ?? join(homedir(), ".dexcost", "buffer.db");
377
+ try {
378
+ mkdirSync(dirname(resolvedPath), { recursive: true });
379
+ }
380
+ catch (err) {
381
+ throw new Error(`Cannot create dexcost storage directory: ${err instanceof Error ? err.message : err}`);
382
+ }
383
+ this._db = new DatabaseCtor(resolvedPath);
384
+ // PRAGMAs and DDL
385
+ try {
386
+ this._db.pragma("journal_mode=WAL");
387
+ this._db.pragma("synchronous=NORMAL");
388
+ this._db.pragma("foreign_keys=ON");
389
+ this._db.exec(CREATE_TASKS);
390
+ this._db.exec(CREATE_EVENTS);
391
+ this._db.exec(CREATE_SCHEMA_VERSION);
392
+ // Migrate older databases that pre-date the tasks.sync_status column.
393
+ // CREATE TABLE IF NOT EXISTS won't add columns to an existing table,
394
+ // so add it explicitly; ignore the "duplicate column" error when the
395
+ // column already exists.
396
+ this._migrateAddColumn("tasks", "sync_status", "TEXT NOT NULL DEFAULT 'pending'");
397
+ for (const idx of INDEXES) {
398
+ this._db.exec(idx);
399
+ }
400
+ }
401
+ catch (err) {
402
+ throw new Error(`Cannot initialize dexcost database: ${err instanceof Error ? err.message : err}`);
403
+ }
404
+ // Seed schema_version if empty
405
+ const versionCount = this._db.prepare("SELECT COUNT(*) AS count FROM schema_version").get().count;
406
+ if (versionCount === 0) {
407
+ try {
408
+ this._db
409
+ .prepare(`INSERT INTO schema_version (version_number, migration_name)
410
+ VALUES (1, 'initial')`)
411
+ .run();
412
+ }
413
+ catch {
414
+ // SQLite error — skip seeding, don't crash
415
+ }
416
+ }
417
+ }
418
+ /**
419
+ * Add `column` to `table` if it does not already exist.
420
+ *
421
+ * SQLite has no `ADD COLUMN IF NOT EXISTS`, so a duplicate-column error
422
+ * is the expected signal that the migration has already been applied and
423
+ * is swallowed. Any other failure is also tolerated so init never crashes.
424
+ */
425
+ _migrateAddColumn(table, column, definition) {
426
+ if (!this._db)
427
+ return;
428
+ try {
429
+ this._db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
430
+ }
431
+ catch {
432
+ // Column already exists (duplicate column name) or other benign error.
433
+ }
434
+ }
435
+ /**
436
+ * Add a cost event to the buffer with sync_status = 'pending'.
437
+ */
438
+ addEvent(event) {
439
+ if (this._mem) {
440
+ this._mem.addEvent(event);
441
+ return;
442
+ }
443
+ if (!this._db)
444
+ return;
445
+ try {
446
+ this._db
447
+ .prepare(`INSERT INTO events (
448
+ event_id, task_id, event_type, provider, model,
449
+ input_tokens, output_tokens, cached_tokens, service_name,
450
+ cost_usd, latency_ms, cost_confidence, pricing_source, pricing_version,
451
+ is_retry, retry_reason, retry_of, details, timestamp, sync_status
452
+ ) VALUES (
453
+ ?, ?, ?, ?, ?,
454
+ ?, ?, ?, ?,
455
+ ?, ?, ?, ?, ?,
456
+ ?, ?, ?, ?, ?, 'pending'
457
+ )`)
458
+ .run(event.eventId, event.taskId, event.eventType, event.provider ?? null, event.model ?? null, event.inputTokens ?? null, event.outputTokens ?? null, event.cachedTokens ?? null, event.serviceName ?? null, event.costUsd.toString(), event.latencyMs ?? null, event.costConfidence, event.pricingSource ?? null, event.pricingVersion ?? null, event.isRetry ? 1 : 0, event.retryReason ?? null, event.retryOf ?? null, JSON.stringify(event.details), event.occurredAt.toISOString());
459
+ }
460
+ catch {
461
+ // SQLite error (disk full, locked) — skip this event, don't crash
462
+ }
463
+ }
464
+ /**
465
+ * Insert or replace a task in the buffer.
466
+ *
467
+ * The task is (re)marked `sync_status = 'pending'`: an upsert means the
468
+ * task's data changed (new cost rolled up, status flipped, etc.), so it
469
+ * must be re-sent on the next push. `markTasksSynced` flips it to
470
+ * `'synced'` after a successful POST so unchanged tasks are not re-sent.
471
+ */
472
+ upsertTask(task) {
473
+ if (this._mem) {
474
+ this._mem.upsertTask(task);
475
+ return;
476
+ }
477
+ if (!this._db)
478
+ return;
479
+ try {
480
+ this._db
481
+ .prepare(`INSERT OR REPLACE INTO tasks (
482
+ task_id, task_type, status, started_at, ended_at, metadata,
483
+ llm_cost_usd, external_cost_usd, compute_cost_usd, total_cost_usd,
484
+ total_input_tokens, total_output_tokens, total_cached_tokens,
485
+ retry_count, retry_cost_usd, failure_count,
486
+ customer_id, project_id, parent_task_id, experiment_id, variant,
487
+ sync_status
488
+ ) VALUES (
489
+ ?, ?, ?, ?, ?, ?,
490
+ ?, ?, ?, ?,
491
+ ?, ?, ?,
492
+ ?, ?, ?,
493
+ ?, ?, ?, ?, ?,
494
+ 'pending'
495
+ )`)
496
+ .run(task.taskId, task.taskType, task.status, task.startedAt.toISOString(), task.endedAt ? task.endedAt.toISOString() : null, JSON.stringify(task.metadata), task.llmCostUsd.toString(), task.externalCostUsd.toString(), task.computeCostUsd.toString(), task.totalCostUsd.toString(), task.totalInputTokens, task.totalOutputTokens, task.totalCachedTokens, task.retryCount, task.retryCostUsd.toString(), task.failureCount, task.customerId ?? null, task.projectId ?? null, task.parentTaskId ?? null, task.experimentId ?? null, task.variant ?? null);
497
+ }
498
+ catch {
499
+ // SQLite error (disk full, locked) — skip this upsert, don't crash
500
+ }
501
+ }
502
+ /**
503
+ * Return up to `limit` pending events, ordered by timestamp ASC.
504
+ */
505
+ getPendingEvents(limit = 100) {
506
+ if (this._mem)
507
+ return this._mem.getPendingEvents(limit);
508
+ if (!this._db)
509
+ return [];
510
+ const rows = this._db
511
+ .prepare(`SELECT * FROM events WHERE sync_status = 'pending' ORDER BY timestamp ASC LIMIT ?`)
512
+ .all(limit);
513
+ return rows.map(rowToEvent);
514
+ }
515
+ /**
516
+ * Mark the given event IDs as synced.
517
+ */
518
+ markSynced(eventIds) {
519
+ if (this._mem) {
520
+ this._mem.markSynced(eventIds);
521
+ return;
522
+ }
523
+ if (!this._db)
524
+ return;
525
+ if (eventIds.length === 0)
526
+ return;
527
+ try {
528
+ const placeholders = eventIds.map(() => "?").join(", ");
529
+ this._db
530
+ .prepare(`UPDATE events SET sync_status = 'synced' WHERE event_id IN (${placeholders})`)
531
+ .run(...eventIds);
532
+ }
533
+ catch {
534
+ // SQLite error (disk full, locked) — skip marking, don't crash
535
+ }
536
+ }
537
+ /**
538
+ * Retrieve a task by ID, or undefined if not found.
539
+ */
540
+ getTask(taskId) {
541
+ if (this._mem)
542
+ return this._mem.getTask(taskId);
543
+ if (!this._db)
544
+ return undefined;
545
+ const row = this._db
546
+ .prepare("SELECT * FROM tasks WHERE task_id = ?")
547
+ .get(taskId);
548
+ return row != null ? rowToTask(row) : undefined;
549
+ }
550
+ /**
551
+ * Return all tasks in the buffer.
552
+ */
553
+ getAllTasks() {
554
+ if (this._mem)
555
+ return this._mem.getAllTasks();
556
+ if (!this._db)
557
+ return [];
558
+ const rows = this._db.prepare("SELECT * FROM tasks").all();
559
+ return rows.map(rowToTask);
560
+ }
561
+ /**
562
+ * Return all tasks awaiting sync (`sync_status = 'pending'`).
563
+ *
564
+ * The pusher sends only these so unchanged tasks are not re-POSTed on
565
+ * every push cycle.
566
+ */
567
+ getPendingTasks() {
568
+ if (this._mem)
569
+ return this._mem.getPendingTasks();
570
+ if (!this._db)
571
+ return [];
572
+ const rows = this._db
573
+ .prepare("SELECT * FROM tasks WHERE sync_status = 'pending'")
574
+ .all();
575
+ return rows.map(rowToTask);
576
+ }
577
+ /**
578
+ * Mark the given task IDs as synced.
579
+ *
580
+ * Called by the pusher after a successful POST so the tasks are excluded
581
+ * from subsequent pushes until they are upserted again.
582
+ */
583
+ markTasksSynced(taskIds) {
584
+ if (this._mem) {
585
+ this._mem.markTasksSynced(taskIds);
586
+ return;
587
+ }
588
+ if (!this._db)
589
+ return;
590
+ if (taskIds.length === 0)
591
+ return;
592
+ try {
593
+ const placeholders = taskIds.map(() => "?").join(", ");
594
+ this._db
595
+ .prepare(`UPDATE tasks SET sync_status = 'synced' WHERE task_id IN (${placeholders})`)
596
+ .run(...taskIds);
597
+ }
598
+ catch {
599
+ // SQLite error (disk full, locked) — skip marking, don't crash
600
+ }
601
+ }
602
+ /** The number of tasks awaiting sync (`sync_status = 'pending'`). */
603
+ get pendingTaskCount() {
604
+ if (this._mem)
605
+ return this._mem.pendingTaskCount;
606
+ if (!this._db)
607
+ return 0;
608
+ const row = this._db
609
+ .prepare("SELECT COUNT(*) AS count FROM tasks WHERE sync_status = 'pending'")
610
+ .get();
611
+ return row.count;
612
+ }
613
+ /**
614
+ * Return all events in the buffer (including synced).
615
+ */
616
+ getAllEvents() {
617
+ if (this._mem)
618
+ return this._mem.getAllEvents();
619
+ if (!this._db)
620
+ return [];
621
+ const rows = this._db.prepare("SELECT * FROM events").all();
622
+ return rows.map(rowToEvent);
623
+ }
624
+ /**
625
+ * Return events for a specific task, ordered by timestamp DESC.
626
+ */
627
+ queryEvents(taskId) {
628
+ if (this._mem)
629
+ return this._mem.queryEvents(taskId);
630
+ if (!this._db)
631
+ return [];
632
+ const rows = this._db
633
+ .prepare("SELECT * FROM events WHERE task_id = ? ORDER BY timestamp DESC")
634
+ .all(taskId);
635
+ return rows.map(rowToEvent);
636
+ }
637
+ /**
638
+ * Update all columns of an existing event in-place.
639
+ */
640
+ updateEvent(event) {
641
+ if (this._mem) {
642
+ this._mem.updateEvent(event);
643
+ return;
644
+ }
645
+ if (!this._db)
646
+ return;
647
+ try {
648
+ this._db
649
+ .prepare(`UPDATE events SET
650
+ task_id = ?,
651
+ event_type = ?,
652
+ provider = ?,
653
+ model = ?,
654
+ input_tokens = ?,
655
+ output_tokens = ?,
656
+ cached_tokens = ?,
657
+ service_name = ?,
658
+ cost_usd = ?,
659
+ latency_ms = ?,
660
+ cost_confidence = ?,
661
+ pricing_source = ?,
662
+ pricing_version = ?,
663
+ is_retry = ?,
664
+ retry_reason = ?,
665
+ retry_of = ?,
666
+ details = ?,
667
+ timestamp = ?
668
+ WHERE event_id = ?`)
669
+ .run(event.taskId, event.eventType, event.provider ?? null, event.model ?? null, event.inputTokens ?? null, event.outputTokens ?? null, event.cachedTokens ?? null, event.serviceName ?? null, event.costUsd.toString(), event.latencyMs ?? null, event.costConfidence, event.pricingSource ?? null, event.pricingVersion ?? null, event.isRetry ? 1 : 0, event.retryReason ?? null, event.retryOf ?? null, JSON.stringify(event.details), event.occurredAt.toISOString(), event.eventId);
670
+ }
671
+ catch {
672
+ // SQLite error (disk full, locked) — skip this update, don't crash
673
+ }
674
+ }
675
+ /**
676
+ * Return the number of pending (unsynced) events.
677
+ */
678
+ get pendingCount() {
679
+ if (this._mem)
680
+ return this._mem.pendingCount;
681
+ if (!this._db)
682
+ return 0;
683
+ const row = this._db
684
+ .prepare("SELECT COUNT(*) AS count FROM events WHERE sync_status = 'pending'")
685
+ .get();
686
+ return row.count;
687
+ }
688
+ /**
689
+ * Delete synced events older than `retentionHours` and VACUUM.
690
+ *
691
+ * Returns the number of deleted rows.
692
+ */
693
+ purgeSynced(retentionHours = 48) {
694
+ if (this._mem)
695
+ return this._mem.purgeSynced(retentionHours);
696
+ if (!this._db)
697
+ return 0;
698
+ try {
699
+ const cutoff = new Date(Date.now() - retentionHours * 3_600_000).toISOString();
700
+ const result = this._db
701
+ .prepare(`DELETE FROM events WHERE sync_status = 'synced' AND timestamp < ?`)
702
+ .run(cutoff);
703
+ const deleted = result.changes;
704
+ if (deleted > 0) {
705
+ this._db.pragma("wal_checkpoint(TRUNCATE)");
706
+ this._db.exec("VACUUM");
707
+ }
708
+ return deleted;
709
+ }
710
+ catch {
711
+ // SQLite error — skip purge, don't crash
712
+ return 0;
713
+ }
714
+ }
715
+ /**
716
+ * Delete pending events older than `maxAgeDays` and VACUUM.
717
+ *
718
+ * Safety net for events that can never be synced (rejected API key,
719
+ * permanently-down endpoint, etc.) so the local buffer cannot grow
720
+ * unbounded. Mirrors the Python SDK's `purge_old_pending` (default 7
721
+ * days). Returns the number of deleted rows.
722
+ */
723
+ purgeOldPending(maxAgeDays = 7) {
724
+ if (this._mem)
725
+ return this._mem.purgeOldPending(maxAgeDays);
726
+ if (!this._db)
727
+ return 0;
728
+ try {
729
+ const cutoff = new Date(Date.now() - maxAgeDays * 86_400_000).toISOString();
730
+ const result = this._db
731
+ .prepare(`DELETE FROM events WHERE sync_status = 'pending' AND timestamp < ?`)
732
+ .run(cutoff);
733
+ const deleted = result.changes;
734
+ if (deleted > 0) {
735
+ this._db.pragma("wal_checkpoint(TRUNCATE)");
736
+ this._db.exec("VACUUM");
737
+ }
738
+ return deleted;
739
+ }
740
+ catch {
741
+ // SQLite error — skip purge, don't crash
742
+ return 0;
743
+ }
744
+ }
745
+ /**
746
+ * Close the underlying database connection.
747
+ */
748
+ close() {
749
+ if (this._mem) {
750
+ this._mem.close();
751
+ this._mem = null;
752
+ return;
753
+ }
754
+ if (!this._db)
755
+ return;
756
+ this._db.close();
757
+ }
758
+ }
759
+ //# sourceMappingURL=buffer.js.map