tokenome 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.
tokenome/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ from .client import TokenLens
2
+ from .context import (
3
+ clear_context,
4
+ clear_default_context,
5
+ get_context,
6
+ set_context,
7
+ set_default_context,
8
+ )
9
+
10
+ __all__ = [
11
+ "TokenLens",
12
+ "set_context",
13
+ "set_default_context",
14
+ "get_context",
15
+ "clear_context",
16
+ "clear_default_context",
17
+ ]
tokenome/client.py ADDED
@@ -0,0 +1,350 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ from dataclasses import dataclass
7
+ from importlib import import_module
8
+ from importlib.metadata import PackageNotFoundError, version
9
+ from typing import Any
10
+
11
+ from .config import (
12
+ DEFAULT_DROP_POLICY,
13
+ DEFAULT_DURABILITY,
14
+ DEFAULT_ENDPOINT,
15
+ DEFAULT_MAX_SPOOL_AGE_DAYS,
16
+ DEFAULT_MAX_SPOOL_BYTES,
17
+ DEFAULT_QUEUE_MAXSIZE,
18
+ DEFAULT_RETRY_MAX_DELAY_SECONDS,
19
+ DEFAULT_TIMEOUT_SECONDS,
20
+ TokenomeConfig,
21
+ )
22
+ from .context import clear_context, clear_default_context
23
+ from .delivery.sender import HttpSender
24
+ from .delivery.worker import DeliveryWorker
25
+ from .errors import MissingOptionalDependencyError, UnsupportedProviderVersionError
26
+ from .limits import DEFAULT_MAX_PAYLOAD_BYTES
27
+ from .models import TelemetryEvent, WrapperDefaults
28
+ from .providers.openai import OPENAI_SUPPORTED_VERSION_RANGE
29
+ from .serialization import sanitize_tags
30
+ from .spool import SQLiteEventSpool
31
+ from .spool.memory import MemorySpool
32
+ from .wrapper import wrap_provider_client
33
+
34
+ logger = logging.getLogger("tokenome")
35
+
36
+ _ENV_API_KEY = "TOKENLENS_API_KEY"
37
+ _ENV_ENDPOINT = "TOKENLENS_ENDPOINT"
38
+ _ENV_DEBUG = "TOKENLENS_DEBUG"
39
+ _ENV_ENABLED = "TOKENLENS_ENABLED"
40
+ _ENV_TAGS = "TOKENLENS_TAGS"
41
+ _ENV_TIMEOUT = "TOKENLENS_TIMEOUT_SECONDS"
42
+ _ENV_QUEUE_MAXSIZE = "TOKENLENS_QUEUE_MAXSIZE"
43
+ _ENV_PROJECT_ID = "TOKENLENS_PROJECT_ID"
44
+ _ENV_ENVIRONMENT = "TOKENLENS_ENVIRONMENT"
45
+ _ENV_DURABILITY = "TOKENLENS_DURABILITY"
46
+ _ENV_SPOOL_PATH = "TOKENLENS_SPOOL_PATH"
47
+ _ENV_MAX_SPOOL_BYTES = "TOKENLENS_MAX_SPOOL_BYTES"
48
+ _ENV_MAX_SPOOL_AGE_DAYS = "TOKENLENS_MAX_SPOOL_AGE_DAYS"
49
+ _ENV_RETRY_MAX_DELAY_SECONDS = "TOKENLENS_RETRY_MAX_DELAY_SECONDS"
50
+ _ENV_DROP_POLICY = "TOKENLENS_DROP_POLICY"
51
+
52
+ _TRUTHY = {"1", "true", "t", "yes", "y", "on"}
53
+ _FALSY = {"0", "false", "f", "no", "n", "off"}
54
+ _ALLOWED_DURABILITY = {"durable_local", "best_effort", "agent"}
55
+ _ALLOWED_DROP_POLICY = {"drop_oldest", "drop_newest", "block"}
56
+
57
+
58
+ @dataclass(slots=True)
59
+ class _TokenLensState:
60
+ config: TokenomeConfig
61
+ spool: SQLiteEventSpool | MemorySpool
62
+ sender: HttpSender
63
+ worker: DeliveryWorker
64
+
65
+ def enqueue(self, event: TelemetryEvent) -> None:
66
+ if not self.config.enabled:
67
+ return
68
+ try:
69
+ stored = self.spool.append(event)
70
+ except Exception as exc:
71
+ # Spool corruption or unexpected error — log at debug and drop.
72
+ logger.debug("spool append failed for event %s: %s", event.event_id, exc)
73
+ return
74
+ if stored:
75
+ self.worker.notify_event_available()
76
+ else:
77
+ # Capacity drop — spool is full or event oversized.
78
+ logger.debug("spool dropped event %s (capacity or size limit)", event.event_id)
79
+
80
+ def flush(self) -> bool:
81
+ return self.worker.flush(timeout=10.0)
82
+
83
+ def close(self) -> None:
84
+ self.worker.close(timeout=2.0)
85
+ self.sender.close()
86
+ self.spool.close()
87
+
88
+
89
+ _default_client: TokenLens | None = None
90
+
91
+
92
+ class TokenLens:
93
+ def __init__(
94
+ self,
95
+ *,
96
+ api_key: str,
97
+ project_id: str | None = None,
98
+ environment: str | None = None,
99
+ endpoint: str = DEFAULT_ENDPOINT,
100
+ enabled: bool = True,
101
+ debug: bool = False,
102
+ timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
103
+ queue_maxsize: int = DEFAULT_QUEUE_MAXSIZE,
104
+ durability: str = DEFAULT_DURABILITY,
105
+ spool_path: str | None = None,
106
+ max_spool_bytes: int = DEFAULT_MAX_SPOOL_BYTES,
107
+ max_spool_age_days: int = DEFAULT_MAX_SPOOL_AGE_DAYS,
108
+ retry_max_delay_seconds: float = DEFAULT_RETRY_MAX_DELAY_SECONDS,
109
+ drop_policy: str = DEFAULT_DROP_POLICY,
110
+ default_tags: dict[str, object] | None = None,
111
+ ) -> None:
112
+ self.project_id = project_id
113
+ self.environment = environment
114
+ normalized_durability = _parse_choice(
115
+ durability,
116
+ env_name="durability",
117
+ allowed=_ALLOWED_DURABILITY,
118
+ )
119
+ normalized_drop_policy = _parse_choice(
120
+ drop_policy,
121
+ env_name="drop_policy",
122
+ allowed=_ALLOWED_DROP_POLICY,
123
+ )
124
+ if normalized_durability == "agent":
125
+ raise ValueError("durability='agent' is documented future mode and not implemented yet")
126
+
127
+ sanitized_default_tags = sanitize_tags(default_tags) or {}
128
+ config = TokenomeConfig(
129
+ api_key=api_key,
130
+ endpoint=endpoint,
131
+ enabled=enabled,
132
+ debug=debug,
133
+ timeout_seconds=timeout_seconds,
134
+ queue_maxsize=queue_maxsize,
135
+ durability=normalized_durability, # type: ignore[arg-type]
136
+ spool_path=spool_path,
137
+ max_spool_bytes=max_spool_bytes,
138
+ max_spool_age_days=max_spool_age_days,
139
+ retry_max_delay_seconds=retry_max_delay_seconds,
140
+ drop_policy=normalized_drop_policy, # type: ignore[arg-type]
141
+ default_tags=sanitized_default_tags,
142
+ )
143
+
144
+ if normalized_durability == "durable_local":
145
+ spool: SQLiteEventSpool | MemorySpool = SQLiteEventSpool(
146
+ path=spool_path,
147
+ max_bytes=max_spool_bytes,
148
+ max_age_days=max_spool_age_days,
149
+ drop_policy=normalized_drop_policy, # type: ignore[arg-type]
150
+ )
151
+ else:
152
+ spool = MemorySpool(max_size=queue_maxsize)
153
+
154
+ sender = HttpSender(
155
+ endpoint=endpoint,
156
+ api_key=api_key,
157
+ timeout_seconds=timeout_seconds,
158
+ debug=debug,
159
+ )
160
+ worker = DeliveryWorker(
161
+ spool=spool,
162
+ sender=sender,
163
+ max_payload_bytes=DEFAULT_MAX_PAYLOAD_BYTES,
164
+ shutdown_timeout_seconds=2.0,
165
+ )
166
+ worker.start()
167
+
168
+ self._state = _TokenLensState(
169
+ config=config,
170
+ spool=spool,
171
+ sender=sender,
172
+ worker=worker,
173
+ )
174
+
175
+ @property
176
+ def config(self) -> TokenomeConfig:
177
+ return self._state.config
178
+
179
+ @classmethod
180
+ def init(cls, **kwargs: Any) -> TokenLens:
181
+ global _default_client
182
+ if _default_client is not None:
183
+ _default_client.close()
184
+ _default_client = cls(**kwargs)
185
+ return _default_client
186
+
187
+ @classmethod
188
+ def init_from_env(cls) -> TokenLens:
189
+ api_key = os.getenv(_ENV_API_KEY)
190
+ if not api_key:
191
+ raise ValueError(f"Missing required environment variable: {_ENV_API_KEY}")
192
+ return cls.init(
193
+ api_key=api_key,
194
+ project_id=os.getenv(_ENV_PROJECT_ID),
195
+ environment=os.getenv(_ENV_ENVIRONMENT),
196
+ endpoint=os.getenv(_ENV_ENDPOINT, DEFAULT_ENDPOINT),
197
+ enabled=_parse_bool(os.getenv(_ENV_ENABLED), default=True),
198
+ debug=_parse_bool(os.getenv(_ENV_DEBUG), default=False),
199
+ timeout_seconds=float(os.getenv(_ENV_TIMEOUT, str(DEFAULT_TIMEOUT_SECONDS))),
200
+ queue_maxsize=int(os.getenv(_ENV_QUEUE_MAXSIZE, "10000")),
201
+ durability=os.getenv(_ENV_DURABILITY, DEFAULT_DURABILITY),
202
+ spool_path=os.getenv(_ENV_SPOOL_PATH),
203
+ max_spool_bytes=int(os.getenv(_ENV_MAX_SPOOL_BYTES, str(DEFAULT_MAX_SPOOL_BYTES))),
204
+ max_spool_age_days=int(
205
+ os.getenv(_ENV_MAX_SPOOL_AGE_DAYS, str(DEFAULT_MAX_SPOOL_AGE_DAYS))
206
+ ),
207
+ retry_max_delay_seconds=float(
208
+ os.getenv(_ENV_RETRY_MAX_DELAY_SECONDS, str(DEFAULT_RETRY_MAX_DELAY_SECONDS))
209
+ ),
210
+ drop_policy=os.getenv(_ENV_DROP_POLICY, DEFAULT_DROP_POLICY),
211
+ default_tags=_parse_tags(os.getenv(_ENV_TAGS)),
212
+ )
213
+
214
+ @classmethod
215
+ def is_initialized(cls) -> bool:
216
+ return _default_client is not None
217
+
218
+ def wrap(
219
+ self,
220
+ client: Any,
221
+ *,
222
+ provider: str | None = None,
223
+ route: str | None = None,
224
+ feature: str | None = None,
225
+ user_id: str | None = None,
226
+ user_id_hash: str | None = None,
227
+ session_id: str | None = None,
228
+ tags: dict[str, object] | None = None,
229
+ ) -> Any:
230
+ defaults = WrapperDefaults(
231
+ provider=provider or _guess_provider(client),
232
+ project_id=self.project_id,
233
+ environment=self.environment,
234
+ route=route,
235
+ feature=feature,
236
+ user_id=user_id,
237
+ user_id_hash=user_id_hash,
238
+ session_id=session_id,
239
+ tags={**self.config.default_tags, **(sanitize_tags(tags) or {})},
240
+ )
241
+ return wrap_provider_client(client, self._state.enqueue, defaults)
242
+
243
+ def wrap_openai(self, client: Any, **context_overrides: Any) -> Any:
244
+ return self.wrap(client, provider="openai", **context_overrides)
245
+
246
+ def OpenAI(self, *args: Any, **kwargs: Any) -> Any:
247
+ openai = _import_openai_module()
248
+ _ensure_supported_openai_version()
249
+ client = openai.OpenAI(*args, **kwargs)
250
+ return self.wrap_openai(client)
251
+
252
+ def AsyncOpenAI(self, *args: Any, **kwargs: Any) -> Any:
253
+ openai = _import_openai_module()
254
+ _ensure_supported_openai_version()
255
+ client = openai.AsyncOpenAI(*args, **kwargs)
256
+ return self.wrap_openai(client)
257
+
258
+ def flush(self) -> bool:
259
+ return self._state.flush()
260
+
261
+ def close(self) -> None:
262
+ global _default_client
263
+ self._state.close()
264
+ if _default_client is self:
265
+ _default_client = None
266
+ clear_context()
267
+ clear_default_context()
268
+
269
+
270
+ def get_state() -> _TokenLensState | None:
271
+ if _default_client is None:
272
+ return None
273
+ return _default_client._state
274
+
275
+
276
+ def _parse_bool(raw: str | None, *, default: bool) -> bool:
277
+ if raw is None:
278
+ return default
279
+ normalized = raw.strip().lower()
280
+ if normalized in _TRUTHY:
281
+ return True
282
+ if normalized in _FALSY:
283
+ return False
284
+ raise ValueError(f"Invalid boolean value: {raw}")
285
+
286
+
287
+ def _parse_tags(raw: str | None) -> dict[str, object] | None:
288
+ if not raw:
289
+ return None
290
+ data = json.loads(raw)
291
+ if not isinstance(data, dict):
292
+ raise ValueError(f"{_ENV_TAGS} must be a JSON object")
293
+ return dict(data)
294
+
295
+
296
+ def _parse_choice(raw: str, *, env_name: str, allowed: set[str]) -> str:
297
+ normalized = raw.strip().lower()
298
+ if normalized not in allowed:
299
+ options = ", ".join(sorted(allowed))
300
+ raise ValueError(f"Invalid {env_name} value: {raw}. Expected one of: {options}")
301
+ return normalized
302
+
303
+
304
+ def _guess_provider(client: Any) -> str:
305
+ module = type(client).__module__.lower()
306
+ if "anthropic" in module:
307
+ return "anthropic"
308
+ if "openai" in module:
309
+ return "openai"
310
+ if hasattr(client, "responses") or (
311
+ hasattr(client, "chat") and hasattr(client.chat, "completions")
312
+ ):
313
+ return "openai-compatible"
314
+ return type(client).__name__.lower()
315
+
316
+
317
+ def _import_openai_module() -> Any:
318
+ try:
319
+ return import_module("openai")
320
+ except ModuleNotFoundError as exc:
321
+ raise MissingOptionalDependencyError(
322
+ 'OpenAI integration requires `pip install "tokenome[openai]"`.'
323
+ ) from exc
324
+
325
+
326
+ def _ensure_supported_openai_version() -> str:
327
+ try:
328
+ installed = version("openai")
329
+ except PackageNotFoundError as exc:
330
+ raise MissingOptionalDependencyError(
331
+ 'OpenAI integration requires `pip install "tokenome[openai]"`.'
332
+ ) from exc
333
+
334
+ major = _major_version(installed)
335
+ if major < 2:
336
+ raise UnsupportedProviderVersionError(
337
+ "Unsupported openai version "
338
+ f"`{installed}`. Supported range: {OPENAI_SUPPORTED_VERSION_RANGE}."
339
+ )
340
+ if major >= 3:
341
+ raise UnsupportedProviderVersionError(
342
+ "Untested openai version "
343
+ f"`{installed}`. Supported range: {OPENAI_SUPPORTED_VERSION_RANGE}."
344
+ )
345
+ return installed
346
+
347
+
348
+ def _major_version(raw: str) -> int:
349
+ head = raw.split(".", maxsplit=1)[0]
350
+ return int(head)
tokenome/config.py ADDED
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Literal
5
+
6
+ DEFAULT_ENDPOINT = "https://api.tokenome.ai/v1/events/batch"
7
+ DEFAULT_QUEUE_MAXSIZE = 10_000
8
+ DEFAULT_TIMEOUT_SECONDS = 1.5
9
+ DEFAULT_DURABILITY: Literal["durable_local", "best_effort", "agent"] = "durable_local"
10
+ DEFAULT_MAX_SPOOL_BYTES = 100 * 1024 * 1024
11
+ DEFAULT_MAX_SPOOL_AGE_DAYS = 7
12
+ DEFAULT_RETRY_MAX_DELAY_SECONDS = 900.0
13
+ DEFAULT_DROP_POLICY: Literal["drop_oldest", "drop_newest", "block"] = "drop_oldest"
14
+
15
+
16
+ @dataclass(slots=True, frozen=True)
17
+ class TokenomeConfig:
18
+ """Immutable configuration for Tokenome SDK.
19
+
20
+ Batch sizing and flush interval are not exposed as constructor parameters
21
+ because they are part of the SDK/server contract. Changing them risks
22
+ 413 Payload Too Large, rate limits, or server-side rejection.
23
+ """
24
+
25
+ api_key: str
26
+ endpoint: str = DEFAULT_ENDPOINT
27
+ enabled: bool = True
28
+ debug: bool = False
29
+ timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS
30
+ queue_maxsize: int = DEFAULT_QUEUE_MAXSIZE
31
+ durability: Literal["durable_local", "best_effort", "agent"] = DEFAULT_DURABILITY
32
+ spool_path: str | None = None
33
+ max_spool_bytes: int = DEFAULT_MAX_SPOOL_BYTES
34
+ max_spool_age_days: int = DEFAULT_MAX_SPOOL_AGE_DAYS
35
+ retry_max_delay_seconds: float = DEFAULT_RETRY_MAX_DELAY_SECONDS
36
+ drop_policy: Literal["drop_oldest", "drop_newest", "block"] = DEFAULT_DROP_POLICY
37
+ default_tags: dict[str, object] = field(default_factory=dict)
tokenome/context.py ADDED
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ from contextvars import ContextVar
4
+ from dataclasses import dataclass, field
5
+
6
+
7
+ @dataclass(slots=True)
8
+ class TrackingContext:
9
+ route: str | None = None
10
+ feature: str | None = None
11
+ user_id: str | None = None
12
+ user_id_hash: str | None = None
13
+ session_id: str | None = None
14
+ tags: dict[str, object] = field(default_factory=dict)
15
+
16
+
17
+ _ctx: ContextVar[TrackingContext | None] = ContextVar("tokenome_context", default=None)
18
+ _default_context = TrackingContext()
19
+
20
+
21
+ def _merge(base: TrackingContext, override: TrackingContext | None) -> TrackingContext:
22
+ if override is None:
23
+ override = TrackingContext()
24
+ return TrackingContext(
25
+ route=override.route if override.route is not None else base.route,
26
+ feature=override.feature if override.feature is not None else base.feature,
27
+ user_id=override.user_id if override.user_id is not None else base.user_id,
28
+ user_id_hash=(
29
+ override.user_id_hash if override.user_id_hash is not None else base.user_id_hash
30
+ ),
31
+ session_id=override.session_id if override.session_id is not None else base.session_id,
32
+ tags={**base.tags, **override.tags},
33
+ )
34
+
35
+
36
+ def set_context(
37
+ *,
38
+ route: str | None = None,
39
+ feature: str | None = None,
40
+ user_id: str | None = None,
41
+ user_id_hash: str | None = None,
42
+ session_id: str | None = None,
43
+ tags: dict[str, object] | None = None,
44
+ ) -> None:
45
+ _ctx.set(
46
+ TrackingContext(
47
+ route=route,
48
+ feature=feature,
49
+ user_id=user_id,
50
+ user_id_hash=user_id_hash,
51
+ session_id=session_id,
52
+ tags=dict(tags or {}),
53
+ )
54
+ )
55
+
56
+
57
+ def set_default_context(
58
+ *,
59
+ route: str | None = None,
60
+ feature: str | None = None,
61
+ user_id: str | None = None,
62
+ user_id_hash: str | None = None,
63
+ session_id: str | None = None,
64
+ tags: dict[str, object] | None = None,
65
+ ) -> None:
66
+ global _default_context
67
+ _default_context = TrackingContext(
68
+ route=route,
69
+ feature=feature,
70
+ user_id=user_id,
71
+ user_id_hash=user_id_hash,
72
+ session_id=session_id,
73
+ tags=dict(tags or {}),
74
+ )
75
+
76
+
77
+ def get_context() -> TrackingContext:
78
+ return _merge(_default_context, _ctx.get())
79
+
80
+
81
+ def clear_context() -> None:
82
+ _ctx.set(None)
83
+
84
+
85
+ def clear_default_context() -> None:
86
+ global _default_context
87
+ _default_context = TrackingContext()
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from .result import SendResult
4
+ from .sender import HttpSender, SenderProtocol
5
+ from .worker import DeliveryWorker
6
+
7
+ __all__ = ["DeliveryWorker", "HttpSender", "SendResult", "SenderProtocol"]
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(slots=True)
8
+ class SendResult:
9
+ """Result of a batch send attempt."""
10
+
11
+ ok: bool = False
12
+ retryable: bool = False
13
+ status_code: int | None = None
14
+ error: str | None = None
15
+ retry_after_seconds: int | None = None
16
+ extra: dict[str, Any] | None = None
17
+ acked: list[str] | None = None
18
+ dropped: list[str] | None = None
19
+ retryable_ids: list[str] | None = None
20
+
21
+ @classmethod
22
+ def success(
23
+ cls, *, acked: list[str] | None = None, status_code: int | None = None
24
+ ) -> SendResult:
25
+ return cls(ok=True, retryable=False, status_code=status_code, error=None, acked=acked or [])
26
+
27
+ @classmethod
28
+ def retryable_error(
29
+ cls,
30
+ *,
31
+ status_code: int | None = None,
32
+ error: str | None = None,
33
+ retry_after_seconds: int | None = None,
34
+ retryable_ids: list[str] | None = None,
35
+ ) -> SendResult:
36
+ return cls(
37
+ ok=False,
38
+ retryable=True,
39
+ status_code=status_code,
40
+ error=error,
41
+ retry_after_seconds=retry_after_seconds,
42
+ retryable_ids=retryable_ids or [],
43
+ )
44
+
45
+ @classmethod
46
+ def fatal_error(
47
+ cls,
48
+ *,
49
+ status_code: int | None = None,
50
+ error: str | None = None,
51
+ dropped: list[str] | None = None,
52
+ ) -> SendResult:
53
+ return cls(
54
+ ok=False, retryable=False, status_code=status_code, error=error, dropped=dropped or []
55
+ )