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.
- dexcost/__init__.py +537 -0
- dexcost/adapters/__init__.py +18 -0
- dexcost/adapters/_netbytes.py +53 -0
- dexcost/adapters/aws_lambda.py +122 -0
- dexcost/adapters/browser.py +171 -0
- dexcost/adapters/data/aws_lambda_pricing.json +61 -0
- dexcost/adapters/http.py +810 -0
- dexcost/auto_task.py +74 -0
- dexcost/cgroup_reader.py +121 -0
- dexcost/cgroup_walker.py +184 -0
- dexcost/cli.py +222 -0
- dexcost/clients.py +270 -0
- dexcost/cloud_detect.py +573 -0
- dexcost/compute_accountant.py +220 -0
- dexcost/compute_pricing.py +624 -0
- dexcost/compute_runtime.py +79 -0
- dexcost/compute_wrap.py +209 -0
- dexcost/config.py +111 -0
- dexcost/context.py +202 -0
- dexcost/data/compute_prices.json +180 -0
- dexcost/data/egress_prices.json +418 -0
- dexcost/data/gpu_prices.json +412 -0
- dexcost/data/model_cost_map.json +37665 -0
- dexcost/data/service_prices.json +2595 -0
- dexcost/dev_console.py +99 -0
- dexcost/egress_pricing.py +185 -0
- dexcost/fargate_metadata.py +110 -0
- dexcost/gpu_accountant.py +470 -0
- dexcost/gpu_pricing.py +506 -0
- dexcost/gpu_runtime.py +152 -0
- dexcost/gpu_wrap.py +144 -0
- dexcost/heuristics.py +168 -0
- dexcost/instruments/__init__.py +30 -0
- dexcost/instruments/anthropic.py +566 -0
- dexcost/instruments/bedrock.py +579 -0
- dexcost/instruments/cohere.py +544 -0
- dexcost/instruments/gemini.py +430 -0
- dexcost/instruments/litellm.py +594 -0
- dexcost/instruments/mcp.py +608 -0
- dexcost/instruments/openai.py +505 -0
- dexcost/integrations/__init__.py +15 -0
- dexcost/integrations/langchain.py +252 -0
- dexcost/integrations/traces.py +56 -0
- dexcost/models/__init__.py +23 -0
- dexcost/models/_serde.py +43 -0
- dexcost/models/enums.py +50 -0
- dexcost/models/event.py +117 -0
- dexcost/models/task.py +173 -0
- dexcost/network_accountant.py +159 -0
- dexcost/nvml_reader.py +315 -0
- dexcost/pricing.py +389 -0
- dexcost/py.typed +0 -0
- dexcost/rates.py +146 -0
- dexcost/redaction.py +135 -0
- dexcost/scanner.py +607 -0
- dexcost/schema.py +55 -0
- dexcost/service_catalog.py +421 -0
- dexcost/session.py +149 -0
- dexcost/storage/__init__.py +14 -0
- dexcost/storage/migrations.py +280 -0
- dexcost/storage/protocol.py +109 -0
- dexcost/storage/sqlite.py +694 -0
- dexcost/sync.py +385 -0
- dexcost/tracker.py +1406 -0
- dexcost-0.1.0.dist-info/METADATA +250 -0
- dexcost-0.1.0.dist-info/RECORD +69 -0
- dexcost-0.1.0.dist-info/WHEEL +4 -0
- dexcost-0.1.0.dist-info/entry_points.txt +2 -0
- 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))
|