dexcost 0.1.0__py3-none-any.whl

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 (69) hide show
  1. dexcost/__init__.py +537 -0
  2. dexcost/adapters/__init__.py +18 -0
  3. dexcost/adapters/_netbytes.py +53 -0
  4. dexcost/adapters/aws_lambda.py +122 -0
  5. dexcost/adapters/browser.py +171 -0
  6. dexcost/adapters/data/aws_lambda_pricing.json +61 -0
  7. dexcost/adapters/http.py +810 -0
  8. dexcost/auto_task.py +74 -0
  9. dexcost/cgroup_reader.py +121 -0
  10. dexcost/cgroup_walker.py +184 -0
  11. dexcost/cli.py +222 -0
  12. dexcost/clients.py +270 -0
  13. dexcost/cloud_detect.py +573 -0
  14. dexcost/compute_accountant.py +220 -0
  15. dexcost/compute_pricing.py +624 -0
  16. dexcost/compute_runtime.py +79 -0
  17. dexcost/compute_wrap.py +209 -0
  18. dexcost/config.py +111 -0
  19. dexcost/context.py +202 -0
  20. dexcost/data/compute_prices.json +180 -0
  21. dexcost/data/egress_prices.json +418 -0
  22. dexcost/data/gpu_prices.json +412 -0
  23. dexcost/data/model_cost_map.json +37665 -0
  24. dexcost/data/service_prices.json +2595 -0
  25. dexcost/dev_console.py +99 -0
  26. dexcost/egress_pricing.py +185 -0
  27. dexcost/fargate_metadata.py +110 -0
  28. dexcost/gpu_accountant.py +470 -0
  29. dexcost/gpu_pricing.py +506 -0
  30. dexcost/gpu_runtime.py +152 -0
  31. dexcost/gpu_wrap.py +144 -0
  32. dexcost/heuristics.py +168 -0
  33. dexcost/instruments/__init__.py +30 -0
  34. dexcost/instruments/anthropic.py +566 -0
  35. dexcost/instruments/bedrock.py +579 -0
  36. dexcost/instruments/cohere.py +544 -0
  37. dexcost/instruments/gemini.py +430 -0
  38. dexcost/instruments/litellm.py +594 -0
  39. dexcost/instruments/mcp.py +608 -0
  40. dexcost/instruments/openai.py +505 -0
  41. dexcost/integrations/__init__.py +15 -0
  42. dexcost/integrations/langchain.py +252 -0
  43. dexcost/integrations/traces.py +56 -0
  44. dexcost/models/__init__.py +23 -0
  45. dexcost/models/_serde.py +43 -0
  46. dexcost/models/enums.py +50 -0
  47. dexcost/models/event.py +117 -0
  48. dexcost/models/task.py +173 -0
  49. dexcost/network_accountant.py +159 -0
  50. dexcost/nvml_reader.py +315 -0
  51. dexcost/pricing.py +389 -0
  52. dexcost/py.typed +0 -0
  53. dexcost/rates.py +146 -0
  54. dexcost/redaction.py +135 -0
  55. dexcost/scanner.py +607 -0
  56. dexcost/schema.py +55 -0
  57. dexcost/service_catalog.py +421 -0
  58. dexcost/session.py +149 -0
  59. dexcost/storage/__init__.py +14 -0
  60. dexcost/storage/migrations.py +280 -0
  61. dexcost/storage/protocol.py +109 -0
  62. dexcost/storage/sqlite.py +694 -0
  63. dexcost/sync.py +385 -0
  64. dexcost/tracker.py +1406 -0
  65. dexcost-0.1.0.dist-info/METADATA +250 -0
  66. dexcost-0.1.0.dist-info/RECORD +69 -0
  67. dexcost-0.1.0.dist-info/WHEEL +4 -0
  68. dexcost-0.1.0.dist-info/entry_points.txt +2 -0
  69. dexcost-0.1.0.dist-info/licenses/LICENSE +21 -0
dexcost/__init__.py ADDED
@@ -0,0 +1,537 @@
1
+ """dexcost — Agent Unit Economics SDK.
2
+
3
+ Track end-to-end business-task costs for AI agents, including LLM calls,
4
+ non-LLM service fees, and retry waste, attributed to customers, projects,
5
+ and workflows.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import atexit
11
+ import logging
12
+ import os
13
+ from typing import Any
14
+
15
+ _log = logging.getLogger(__name__)
16
+
17
+ from contextlib import contextmanager
18
+ from collections.abc import Generator
19
+ from decimal import Decimal
20
+
21
+ __version__ = "0.1.0"
22
+
23
+ from dexcost.clients import TrackedAnthropic, TrackedOpenAI
24
+ from dexcost.compute_wrap import (
25
+ wrap_azure_functions_handler,
26
+ wrap_cloud_functions_handler,
27
+ wrap_cloud_run_handler,
28
+ wrap_lambda_handler,
29
+ wrap_vercel_handler,
30
+ )
31
+ from dexcost.config import DexcostConfig, InvalidAPIKeyError, validate_api_key
32
+ from dexcost.gpu_wrap import (
33
+ wrap_modal_handler,
34
+ wrap_replicate_handler,
35
+ wrap_runpod_handler,
36
+ )
37
+ from dexcost.context import (
38
+ DexcostContext,
39
+ async_task_context,
40
+ clear_context,
41
+ get_context,
42
+ get_current_task,
43
+ set_context as _set_context_impl,
44
+ set_current_task,
45
+ task_context,
46
+ )
47
+ from dexcost.instruments import (
48
+ instrument_anthropic,
49
+ instrument_bedrock,
50
+ instrument_cohere,
51
+ instrument_gemini,
52
+ instrument_litellm,
53
+ instrument_mcp,
54
+ instrument_openai,
55
+ uninstrument_anthropic,
56
+ uninstrument_bedrock,
57
+ uninstrument_cohere,
58
+ uninstrument_gemini,
59
+ uninstrument_litellm,
60
+ uninstrument_mcp,
61
+ uninstrument_openai,
62
+ )
63
+ from dexcost.models import (
64
+ CostConfidence,
65
+ Event,
66
+ EventType,
67
+ PricingSource,
68
+ Task,
69
+ TaskStatus,
70
+ )
71
+ from dexcost.pricing import CostResult, PricingEngine
72
+ from dexcost.rates import RateEntry, RateRegistry
73
+ from dexcost.redaction import enforce_metadata_limit, hash_value, redact_dict
74
+ from dexcost.schema import validate
75
+ from dexcost.sync import SyncWorker
76
+ from dexcost.service_catalog import ServiceCatalog
77
+ from dexcost.session import SessionManager, get_session_manager
78
+ from dexcost.tracker import ALL_SUPPORTED_INSTRUMENTS, CostTracker, TrackedTask
79
+
80
+ _global_config: DexcostConfig | None = None
81
+ _sync_worker: SyncWorker | None = None
82
+ _pricing_engine: PricingEngine | None = None
83
+ _global_tracker: CostTracker | None = None
84
+ # Sprint 1 Theme B / §2.2.4: register_at_fork must be installed exactly
85
+ # once per process (not once per init() call) — guards against the hook
86
+ # being registered multiple times if init() is called after close().
87
+ _fork_hook_registered: bool = False
88
+
89
+
90
+ def _reinit_after_fork() -> None:
91
+ """Re-establish per-process state in a child after os.fork().
92
+
93
+ The child inherits the parent's SQLite connection fd (corrupting if
94
+ both processes write) and the parent's SyncWorker Thread object
95
+ (which is a dangling reference — the underlying OS thread is not
96
+ copied). This handler is registered via os.register_at_fork in
97
+ init() exactly once per process. Sprint 1 Theme B / §2.2.4.
98
+ """
99
+ global _sync_worker, _global_tracker, _global_config
100
+
101
+ # Drop the inherited SyncWorker reference WITHOUT calling .stop() —
102
+ # the underlying thread no longer exists in the child, so the
103
+ # threading.Event / join() in stop() would deadlock.
104
+ _sync_worker = None
105
+
106
+ # Close the inherited SQLite connection on the tracker and recreate
107
+ # storage so the child gets a fresh fd. Without this, parent + child
108
+ # writes interleave through the same fd and corrupt the file.
109
+ if _global_tracker is not None and _global_config is not None:
110
+ try:
111
+ _global_tracker._storage.close()
112
+ except Exception:
113
+ pass # closing an inherited fd may fail; ignore
114
+ from dexcost.storage.sqlite import SQLiteStorage
115
+ _global_tracker._storage = SQLiteStorage(db_path=_global_config.buffer_path)
116
+
117
+ # Re-wire any adapter modules that hold their own reference.
118
+ from dexcost.adapters.browser import set_storage as _set_browser_storage
119
+ _set_browser_storage(_global_tracker._storage)
120
+
121
+ # Restart the sync worker on a fresh thread + fresh connection if
122
+ # we're in cloud mode. The child gets its own background pusher.
123
+ if _global_config.storage_mode == "cloud" and not _global_config.is_dev:
124
+ sync_storage = SQLiteStorage(db_path=_global_config.buffer_path)
125
+ _sync_worker = SyncWorker(
126
+ config=_global_config,
127
+ storage=sync_storage,
128
+ db_path=_global_config.buffer_path,
129
+ )
130
+ _sync_worker.start()
131
+
132
+
133
+ def _atexit_handler() -> None:
134
+ """Flush pending events and close connections on process exit."""
135
+ global _sync_worker, _global_tracker
136
+ if _sync_worker is not None:
137
+ try:
138
+ _sync_worker.flush()
139
+ except Exception:
140
+ pass
141
+ try:
142
+ _sync_worker.stop()
143
+ except Exception:
144
+ pass
145
+ _sync_worker = None
146
+ _global_tracker = None
147
+
148
+
149
+ def set_context(
150
+ customer_id: str | None = None,
151
+ project_id: str | None = None,
152
+ metadata: dict[str, Any] | None = None,
153
+ agent: str | None = None,
154
+ ) -> None:
155
+ """Set the attribution context for subsequent LLM calls and tasks.
156
+
157
+ Args:
158
+ customer_id: Identifier for the customer.
159
+ project_id: Identifier for the project.
160
+ metadata: Optional dict of extra metadata.
161
+ agent: Optional agent name — used as task_type for auto-created
162
+ session tasks instead of the default ``"agent_session"``.
163
+ """
164
+ _set_context_impl(
165
+ customer_id=customer_id,
166
+ project_id=project_id,
167
+ metadata=metadata,
168
+ agent=agent,
169
+ )
170
+
171
+
172
+ def init(
173
+ api_key: str | None = None,
174
+ storage: str | None = None,
175
+ buffer_path: str | None = None,
176
+ batch_size: int = 100,
177
+ flush_interval: float = 5.0,
178
+ auto_instrument: list[str] | None = None,
179
+ redact_fields: list[str] | None = None,
180
+ hash_customer_id: bool = False,
181
+ track_http: bool = True,
182
+ service_catalog_url: str | None = None,
183
+ environment: str | None = None,
184
+ enable_retry_heuristics: bool = False,
185
+ retry_heuristic_window: float | None = None,
186
+ retry_heuristic_threshold: float | None = None,
187
+ track_network: bool = True,
188
+ network_event_threshold_bytes: int = 102_400,
189
+ network_event_on_error: bool = True,
190
+ network_event_latency_ms: int = 0,
191
+ compute_billing_overrides: dict[str, str] | None = None,
192
+ k8s_node_aware: bool = False,
193
+ ) -> DexcostConfig:
194
+ """Initialize dexcost SDK configuration (US-017).
195
+
196
+ When a valid API key is provided (or set via ``DEXCOST_API_KEY``),
197
+ a background :class:`SyncWorker` is started to push buffered events
198
+ to the Control Layer (US-016).
199
+
200
+ Args:
201
+ enable_retry_heuristics: Opt in to the advanced
202
+ :class:`~dexcost.heuristics.RetryHeuristicEngine` (US-036).
203
+ Off by default.
204
+ retry_heuristic_window: Optional sliding-window size in seconds for
205
+ heuristic retry detection. Defaults to the tracker's retry window.
206
+ retry_heuristic_threshold: Optional confidence threshold (0.0–1.0)
207
+ for flagging an event as a heuristic retry.
208
+ track_network: Enable or disable network/egress byte capture. Default ``True``.
209
+ network_event_threshold_bytes: Combined request+response bytes above which
210
+ an un-cataloged HTTP call emits a ``network`` event. Default 100 KiB
211
+ (102 400 bytes).
212
+ network_event_on_error: Emit a ``network`` event for un-cataloged HTTP calls
213
+ whose response status is >= 400. Default ``True``.
214
+ network_event_latency_ms: Emit a ``network`` event when call latency exceeds
215
+ this many milliseconds. ``0`` disables latency-based emission (default).
216
+ """
217
+ global _global_config, _sync_worker, _global_tracker, _fork_hook_registered
218
+
219
+ # Sprint 1 Theme B / §2.2.4(a): idempotency guard. A second init()
220
+ # call without an intervening close() would otherwise orphan the
221
+ # existing SyncWorker thread (the previous reference is dropped
222
+ # without .stop()) — duplicate workers then race on the same SQLite
223
+ # file. Log + return the existing tracker.
224
+ if _global_tracker is not None:
225
+ _log.warning(
226
+ "dexcost.init() called more than once without an intervening "
227
+ "close(); ignoring this call and keeping the existing tracker. "
228
+ "If you intend to reconfigure, call dexcost.close() first."
229
+ )
230
+ return _global_config # type: ignore[return-value]
231
+
232
+ _global_config = DexcostConfig(
233
+ api_key=api_key,
234
+ storage=storage,
235
+ buffer_path=buffer_path,
236
+ batch_size=batch_size,
237
+ flush_interval_seconds=flush_interval,
238
+ redact_fields=redact_fields or [],
239
+ hash_customer_id=hash_customer_id,
240
+ environment=environment,
241
+ track_network=track_network,
242
+ network_event_threshold_bytes=network_event_threshold_bytes,
243
+ network_event_on_error=network_event_on_error,
244
+ network_event_latency_ms=network_event_latency_ms,
245
+ )
246
+
247
+ # v2 network-cost — kick off non-blocking cloud detection. No-op when
248
+ # track_network is off. Phase 1a/1b run synchronously here (sub-ms);
249
+ # Phase 2 runs on a daemon thread that never blocks init().
250
+ from dexcost.cloud_detect import start_background_detection as _start_detect
251
+ _start_detect(track_network=_global_config.track_network)
252
+
253
+ # Dev mode — console output, no cloud push
254
+ if _global_config.is_dev:
255
+ from dexcost.dev_console import enable_dev_mode
256
+ enable_dev_mode()
257
+
258
+ # Patch ThreadPoolExecutor to propagate contextvars to child threads.
259
+ # Libraries like LangExtract, OpenAI, etc. use ThreadPoolExecutor for
260
+ # parallel work — without this, child threads can't find the active task.
261
+ from dexcost.context import patch_thread_context
262
+ patch_thread_context()
263
+
264
+ # Create the global tracker with auto-instrumentation.
265
+ # Thread retry-heuristic settings through so the advanced
266
+ # RetryHeuristicEngine (US-036) is reachable via init().
267
+ _global_tracker = CostTracker(
268
+ auto_instrument=auto_instrument,
269
+ enable_retry_heuristics=enable_retry_heuristics,
270
+ retry_heuristic_window=retry_heuristic_window,
271
+ retry_heuristic_threshold=retry_heuristic_threshold,
272
+ compute_billing_overrides=compute_billing_overrides,
273
+ k8s_node_aware=k8s_node_aware,
274
+ )
275
+
276
+ # Wire the browser adapter to the tracker's storage so track_browser()
277
+ # cost events are persisted durably and shipped by the SyncWorker. The
278
+ # browser adapter has no init flag — it is opt-in via its context manager —
279
+ # so storage is wired unconditionally and used only if track_browser runs.
280
+ from dexcost.adapters.browser import set_storage as _set_browser_storage
281
+
282
+ _set_browser_storage(_global_tracker._storage)
283
+
284
+ # Start background sync worker in cloud mode (US-016)
285
+ if _global_config.storage_mode == "cloud" and not _global_config.is_dev:
286
+ from dexcost.storage.sqlite import SQLiteStorage
287
+
288
+ sync_storage = SQLiteStorage(db_path=_global_config.buffer_path)
289
+ _sync_worker = SyncWorker(
290
+ config=_global_config,
291
+ storage=sync_storage,
292
+ db_path=_global_config.buffer_path,
293
+ )
294
+ _sync_worker.start()
295
+ atexit.register(_atexit_handler)
296
+
297
+ # Sprint 1 Theme B / §2.2.4(b): fork safety. After os.fork() the
298
+ # child inherits the parent's SQLite connection fd and the
299
+ # SyncWorker Thread object (the thread itself does not survive
300
+ # fork — only the Python wrapper is copied). Concurrent writes
301
+ # from two processes to the same fd corrupt SQLite; the dangling
302
+ # thread object would make stop() / flush() hang. Reset both in
303
+ # the child by closing inherited resources and re-running the
304
+ # sync-worker bootstrap. Registered exactly once per process.
305
+ if not _fork_hook_registered and hasattr(os, "register_at_fork"):
306
+ os.register_at_fork(after_in_child=_reinit_after_fork)
307
+ _fork_hook_registered = True
308
+
309
+ # Non-blocking pricing data refresh from Control Layer (US-044)
310
+ if _global_config.storage_mode == "cloud" and not _global_config.is_dev:
311
+ try:
312
+ _pricing_engine = PricingEngine(api_key=_global_config.api_key)
313
+ _pricing_engine.start_background_refresh(_global_config.endpoint)
314
+ except Exception:
315
+ pass # Fail-silent — bundled pricing is always available
316
+
317
+ # Auto-track HTTP calls via service catalog
318
+ if track_http:
319
+ from dexcost.adapters.http import (
320
+ get_catalog,
321
+ set_network_config as _set_network_config,
322
+ set_storage as _set_http_storage,
323
+ track_http as _track_http_fn,
324
+ )
325
+
326
+ _track_http_fn()
327
+ # Wire the adapter to the tracker's storage so HTTP cost events are
328
+ # persisted durably and shipped by the SyncWorker — without this they
329
+ # would only land in the adapter's in-memory list and never sync.
330
+ _set_http_storage(_global_tracker._storage)
331
+ # Wire the SDK config so the adapter uses the caller's network-capture
332
+ # settings (thresholds, on/off toggles) rather than hard-coded defaults.
333
+ _set_network_config(_global_config)
334
+ if service_catalog_url:
335
+ catalog = get_catalog()
336
+ catalog.refresh_from_url(service_catalog_url)
337
+
338
+ return _global_config
339
+
340
+
341
+ @contextmanager
342
+ def task(
343
+ task_type: str = "",
344
+ metadata: dict[str, Any] | None = None,
345
+ ) -> Generator[TrackedTask, None, None]:
346
+ """Group multiple costs into one business task.
347
+
348
+ Reads ``customer_id`` and ``project_id`` from :func:`set_context` if set.
349
+
350
+ Args:
351
+ task_type: Identifier for the kind of task (e.g. ``"resolve_ticket"``).
352
+ metadata: Optional dict of extra metadata.
353
+
354
+ Yields:
355
+ A :class:`TrackedTask` handle.
356
+
357
+ Raises:
358
+ RuntimeError: If ``dexcost.init()`` has not been called.
359
+ """
360
+ if _global_tracker is None:
361
+ raise RuntimeError("dexcost not initialized — call dexcost.init() first")
362
+ ctx = get_context()
363
+ with _global_tracker.task(
364
+ task_type=task_type,
365
+ customer_id=ctx.customer_id if ctx else None,
366
+ project_id=ctx.project_id if ctx else None,
367
+ metadata=metadata,
368
+ ) as t:
369
+ yield t
370
+
371
+
372
+ def record_cost(
373
+ service: str,
374
+ cost_usd: Decimal | str,
375
+ *,
376
+ event_type: str = "external_cost",
377
+ cost_confidence: str = "exact",
378
+ pricing_source: str = "manual",
379
+ pricing_version: str | None = None,
380
+ details: dict[str, Any] | None = None,
381
+ ) -> Event:
382
+ """Record a non-LLM cost event against the current active task.
383
+
384
+ Args:
385
+ service: Name of the external service (e.g. ``"google_maps_api"``).
386
+ cost_usd: Cost in USD (Decimal or string).
387
+ event_type: ``"external_cost"`` (default) or ``"compute_cost"``.
388
+ cost_confidence: One of ``exact``, ``computed``, ``estimated``, ``unknown``.
389
+ pricing_source: Source of pricing data (default ``"manual"``).
390
+ pricing_version: Optional hash referencing the rate snapshot used.
391
+ details: Optional dict of extra metadata.
392
+
393
+ Returns:
394
+ The persisted :class:`Event`.
395
+
396
+ Raises:
397
+ RuntimeError: If ``dexcost.init()`` has not been called or no active task exists.
398
+ """
399
+ if _global_tracker is None:
400
+ raise RuntimeError("dexcost not initialized — call dexcost.init() first")
401
+ current = get_current_task()
402
+ if current is None:
403
+ raise RuntimeError("No active task — use dexcost.task() context manager first")
404
+ tracked = TrackedTask(current, _global_tracker._storage, _global_tracker)
405
+ return tracked.record_cost(
406
+ service=service,
407
+ cost_usd=cost_usd,
408
+ event_type=event_type,
409
+ cost_confidence=cost_confidence,
410
+ pricing_source=pricing_source,
411
+ pricing_version=pricing_version,
412
+ details=details,
413
+ )
414
+
415
+
416
+ def set_api_key(new_key: str) -> bool:
417
+ """Update the SDK's API key and resume sync after auth failure.
418
+
419
+ Sprint 2 Theme D / §3.2.3 (B14). When the Control Layer returns
420
+ 401/403 the SyncWorker permanently stops (sync.py:366-369). Without
421
+ this function the only recovery is restarting the customer's
422
+ process. ``set_api_key`` updates the global config + clears the
423
+ worker's stop signal + restarts the worker thread if it has
424
+ already terminated.
425
+
426
+ Returns True on success, False if ``init()`` has not been called
427
+ (logs a warning).
428
+ """
429
+ global _global_config, _sync_worker, _global_tracker
430
+ if _global_config is None or _global_tracker is None:
431
+ _log.warning(
432
+ "dexcost.set_api_key() called before init(); ignoring. "
433
+ "Call dexcost.init(api_key=...) first."
434
+ )
435
+ return False
436
+ _global_config.api_key = new_key
437
+ if _sync_worker is None:
438
+ return True # Local-only mode; nothing else to do.
439
+ # Clear the auth-failed signal so subsequent pushes proceed.
440
+ _sync_worker._stop_event.clear()
441
+ # Reset backoff so the next attempt isn't artificially delayed by
442
+ # the failure history that triggered the original auth issue.
443
+ _sync_worker._backoff = 1.0
444
+ # If the worker thread already terminated (auth-failure path
445
+ # `return`s from _run), spawn a fresh one. threading.Thread cannot
446
+ # be restarted, so we rebuild the SyncWorker with the same config
447
+ # and storage. The buffered events on disk persist across this
448
+ # transition.
449
+ if _sync_worker._thread is None or not _sync_worker._thread.is_alive():
450
+ from dexcost.storage.sqlite import SQLiteStorage
451
+ sync_storage = SQLiteStorage(db_path=_global_config.buffer_path)
452
+ _sync_worker = SyncWorker(
453
+ config=_global_config,
454
+ storage=sync_storage,
455
+ db_path=_global_config.buffer_path,
456
+ )
457
+ _sync_worker.start()
458
+ return True
459
+
460
+
461
+ def close() -> None:
462
+ """Shut down the global tracker and flush any pending events.
463
+
464
+ Safe to call even if ``init()`` has not been called (no-op).
465
+ """
466
+ global _global_tracker, _sync_worker
467
+ if _sync_worker is not None:
468
+ _sync_worker.flush()
469
+ _sync_worker.stop()
470
+ _sync_worker = None
471
+ _global_tracker = None
472
+
473
+
474
+ def flush() -> None:
475
+ """Force immediate sync of buffered events to the Control Layer.
476
+
477
+ No-op if the SDK is in local-only mode or ``init()`` has not been called.
478
+ """
479
+ if _sync_worker is not None:
480
+ _sync_worker.flush()
481
+
482
+
483
+ __all__ = [
484
+ "ALL_SUPPORTED_INSTRUMENTS",
485
+ "CostConfidence",
486
+ "CostResult",
487
+ "CostTracker",
488
+ "DexcostConfig",
489
+ "DexcostContext",
490
+ "Event",
491
+ "EventType",
492
+ "InvalidAPIKeyError",
493
+ "PricingEngine",
494
+ "PricingSource",
495
+ "RateEntry",
496
+ "RateRegistry",
497
+ "ServiceCatalog",
498
+ "SessionManager",
499
+ "SyncWorker",
500
+ "Task",
501
+ "TaskStatus",
502
+ "TrackedAnthropic",
503
+ "TrackedOpenAI",
504
+ "TrackedTask",
505
+ "__version__",
506
+ "async_task_context",
507
+ "clear_context",
508
+ "close",
509
+ "enforce_metadata_limit",
510
+ "flush",
511
+ "get_context",
512
+ "get_current_task",
513
+ "hash_value",
514
+ "init",
515
+ "instrument_anthropic",
516
+ "instrument_bedrock",
517
+ "instrument_cohere",
518
+ "instrument_gemini",
519
+ "instrument_litellm",
520
+ "instrument_mcp",
521
+ "instrument_openai",
522
+ "record_cost",
523
+ "redact_dict",
524
+ "set_context",
525
+ "set_current_task",
526
+ "task",
527
+ "task_context",
528
+ "uninstrument_anthropic",
529
+ "uninstrument_bedrock",
530
+ "uninstrument_cohere",
531
+ "uninstrument_gemini",
532
+ "uninstrument_litellm",
533
+ "uninstrument_mcp",
534
+ "uninstrument_openai",
535
+ "validate",
536
+ "validate_api_key",
537
+ ]
@@ -0,0 +1,18 @@
1
+ """Adapters for automatic cost tracking of HTTP, browser, and compute services.
2
+
3
+ This package provides adapters that intercept or wrap outgoing
4
+ requests and automatically record cost events.
5
+ """
6
+
7
+ from dexcost.adapters.aws_lambda import get_supported_regions, lambda_cost
8
+ from dexcost.adapters.browser import track_browser
9
+ from dexcost.adapters.http import register_domain_rate, track_http, untrack_http
10
+
11
+ __all__ = [
12
+ "get_supported_regions",
13
+ "lambda_cost",
14
+ "register_domain_rate",
15
+ "track_browser",
16
+ "track_http",
17
+ "untrack_http",
18
+ ]
@@ -0,0 +1,53 @@
1
+ """Helpers for the HTTP network adapter: destination classification and
2
+ byte measurement. Pure functions — no SDK state, no I/O beyond parsing.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import ipaddress
8
+ from typing import Any
9
+
10
+
11
+ def classify_destination(host: str) -> bool | None:
12
+ """Return whether *host* is internal traffic.
13
+
14
+ ``True`` — host is an RFC1918 / loopback / link-local IP literal.
15
+ ``False`` — host is a public IP literal.
16
+ ``None`` — host is a name (not an IP literal); the SDK does not perform
17
+ an extra DNS lookup to resolve it.
18
+
19
+ Note: CGNAT shared address space (100.64.0.0/10, RFC 6598) is classified
20
+ ``False`` because ``ipaddress.is_private`` does not include it.
21
+ """
22
+ if not host:
23
+ return None
24
+ try:
25
+ ip = ipaddress.ip_address(host)
26
+ except ValueError:
27
+ return None
28
+ # is_loopback and is_link_local are spelled out deliberately: it keeps the
29
+ # intent readable and is robust across Python versions. On CPython 3.11+
30
+ # is_private already subsumes both, but the explicit terms cost nothing.
31
+ return bool(ip.is_private or ip.is_loopback or ip.is_link_local)
32
+
33
+
34
+ def _headers_byte_len(headers: dict[str, Any]) -> int:
35
+ """Approximate on-the-wire size of a header block: ``Key: Value\\r\\n`` each."""
36
+ total = 0
37
+ for key, value in headers.items():
38
+ total += len(str(key)) + len(str(value)) + 4 # ": " + CRLF
39
+ return total + 2 # trailing CRLF that ends the header block
40
+
41
+
42
+ def measure_bytes_from_headers(
43
+ method: str, url: str, headers: dict[str, Any], body_len: int
44
+ ) -> int:
45
+ """Approximate the on-the-wire byte size of one HTTP message.
46
+
47
+ ``request line + header block + body``. Used for both directions: pass
48
+ the request method/url/headers for bytes-out, or ``"" / "" / response
49
+ headers`` for bytes-in. *body_len* is the known body length in bytes.
50
+ *body_len* tolerates a numeric string — it is coerced via ``int(...)``.
51
+ """
52
+ request_line = len(str(method)) + len(str(url)) + 12 # method + url + " HTTP/1.1\r\n"
53
+ return request_line + _headers_byte_len(headers) + max(0, int(body_len))