mintlyr 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.
- mintlyr/__init__.py +5 -0
- mintlyr/_batcher.py +71 -0
- mintlyr/_client.py +86 -0
- mintlyr/_transport.py +27 -0
- mintlyr/_usage.py +53 -0
- mintlyr-0.1.0.dist-info/METADATA +36 -0
- mintlyr-0.1.0.dist-info/RECORD +9 -0
- mintlyr-0.1.0.dist-info/WHEEL +5 -0
- mintlyr-0.1.0.dist-info/top_level.txt +1 -0
mintlyr/__init__.py
ADDED
mintlyr/_batcher.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Batched, fire-and-forget delivery. Telemetry must never raise into, or block, the host app."""
|
|
2
|
+
import json
|
|
3
|
+
import threading
|
|
4
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
DEFAULT_BATCH_SIZE = 20
|
|
7
|
+
DEFAULT_FLUSH_INTERVAL_S = 2.0
|
|
8
|
+
# The API rejects bodies over 1MB and batches over 1000 events; stay well inside both.
|
|
9
|
+
MAX_BATCH_BYTES = 500_000
|
|
10
|
+
MAX_BATCH_EVENTS = 500
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Batcher:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
send: Callable[[List[Dict[str, Any]]], None],
|
|
17
|
+
batch_size: int = DEFAULT_BATCH_SIZE,
|
|
18
|
+
flush_interval_s: float = DEFAULT_FLUSH_INTERVAL_S,
|
|
19
|
+
) -> None:
|
|
20
|
+
self._send = send
|
|
21
|
+
self.batch_size = max(1, min(batch_size, MAX_BATCH_EVENTS))
|
|
22
|
+
self.flush_interval_s = flush_interval_s
|
|
23
|
+
self._lock = threading.Lock()
|
|
24
|
+
self._queue: List[Dict[str, Any]] = []
|
|
25
|
+
self._bytes = 0
|
|
26
|
+
self._timer: Optional[threading.Timer] = None
|
|
27
|
+
|
|
28
|
+
def enqueue(self, event: Dict[str, Any]) -> None:
|
|
29
|
+
try:
|
|
30
|
+
size = len(json.dumps(event))
|
|
31
|
+
except Exception:
|
|
32
|
+
return # unserializable: drop, never raise
|
|
33
|
+
if size > MAX_BATCH_BYTES:
|
|
34
|
+
return # the API would reject it outright
|
|
35
|
+
to_send: Optional[List[Dict[str, Any]]] = None
|
|
36
|
+
with self._lock:
|
|
37
|
+
if self._queue and self._bytes + size > MAX_BATCH_BYTES:
|
|
38
|
+
to_send = self._take()
|
|
39
|
+
self._deliver(to_send)
|
|
40
|
+
to_send = None
|
|
41
|
+
self._queue.append(event)
|
|
42
|
+
self._bytes += size
|
|
43
|
+
if len(self._queue) >= self.batch_size:
|
|
44
|
+
to_send = self._take()
|
|
45
|
+
elif self._timer is None:
|
|
46
|
+
self._timer = threading.Timer(self.flush_interval_s, self.flush)
|
|
47
|
+
self._timer.daemon = True # never keep the process alive
|
|
48
|
+
self._timer.start()
|
|
49
|
+
if to_send:
|
|
50
|
+
self._deliver(to_send)
|
|
51
|
+
|
|
52
|
+
def flush(self) -> None:
|
|
53
|
+
with self._lock:
|
|
54
|
+
events = self._take()
|
|
55
|
+
if events:
|
|
56
|
+
self._deliver(events)
|
|
57
|
+
|
|
58
|
+
def _take(self) -> List[Dict[str, Any]]:
|
|
59
|
+
if self._timer is not None:
|
|
60
|
+
self._timer.cancel()
|
|
61
|
+
self._timer = None
|
|
62
|
+
events, self._queue, self._bytes = self._queue, [], 0
|
|
63
|
+
return events
|
|
64
|
+
|
|
65
|
+
def _deliver(self, events: List[Dict[str, Any]]) -> None:
|
|
66
|
+
if not events:
|
|
67
|
+
return
|
|
68
|
+
try:
|
|
69
|
+
self._send(events)
|
|
70
|
+
except Exception:
|
|
71
|
+
pass # drop the batch; telemetry never crashes the host app
|
mintlyr/_client.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import atexit
|
|
2
|
+
import hashlib
|
|
3
|
+
import time
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from ._batcher import Batcher
|
|
8
|
+
from ._transport import http_send
|
|
9
|
+
from ._usage import extract_usage
|
|
10
|
+
|
|
11
|
+
DEFAULT_API_URL = "https://mintlyr-api.onrender.com"
|
|
12
|
+
|
|
13
|
+
_batcher: Optional[Batcher] = None
|
|
14
|
+
_enabled = False
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def init(
|
|
18
|
+
api_key: str,
|
|
19
|
+
api_url: str = DEFAULT_API_URL,
|
|
20
|
+
local_mode: bool = False,
|
|
21
|
+
batch_size: int = 20,
|
|
22
|
+
flush_interval_s: float = 2.0,
|
|
23
|
+
_send: Optional[Callable[[List[Dict[str, Any]]], None]] = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Configure Mintlyr once at startup. `local_mode=True` tracks nothing and sends nothing."""
|
|
26
|
+
global _batcher, _enabled
|
|
27
|
+
if _batcher is not None:
|
|
28
|
+
_batcher.flush()
|
|
29
|
+
_enabled = bool(api_key) and not local_mode
|
|
30
|
+
send = _send or (lambda evs: http_send(api_url, api_key, evs))
|
|
31
|
+
_batcher = Batcher(send, batch_size, flush_interval_s)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _hash_user(user_id: str) -> str:
|
|
35
|
+
# Never send raw end-user identifiers. SHA-256 truncated to 16 hex chars.
|
|
36
|
+
return hashlib.sha256(user_id.encode()).hexdigest()[:16]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _record(result: Any, started: float, feature: str, provider: str, model: Optional[str],
|
|
40
|
+
user_id: Optional[str], session_id: Optional[str]) -> None:
|
|
41
|
+
if not _enabled or _batcher is None:
|
|
42
|
+
return
|
|
43
|
+
try:
|
|
44
|
+
in_tok, out_tok, detected = extract_usage(result, provider)
|
|
45
|
+
_batcher.enqueue({
|
|
46
|
+
"feature": feature,
|
|
47
|
+
"provider": provider,
|
|
48
|
+
"model": model or detected,
|
|
49
|
+
"input_tokens": in_tok,
|
|
50
|
+
"output_tokens": out_tok,
|
|
51
|
+
"response_time_ms": int((time.monotonic() - started) * 1000),
|
|
52
|
+
"session_id": session_id,
|
|
53
|
+
"end_user_id": _hash_user(user_id) if user_id else None,
|
|
54
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
55
|
+
})
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def track_ai(feature: str, call: Callable[[], Any], provider: str = "auto", model: Optional[str] = None,
|
|
61
|
+
user_id: Optional[str] = None, session_id: Optional[str] = None) -> Any:
|
|
62
|
+
"""Run `call()` and record its token usage. Returns exactly what `call()` returns.
|
|
63
|
+
Errors from `call()` propagate untouched; errors in tracking never reach the caller."""
|
|
64
|
+
started = time.monotonic()
|
|
65
|
+
result = call()
|
|
66
|
+
_record(result, started, feature, provider, model, user_id, session_id)
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def atrack_ai(feature: str, call: Callable[[], Awaitable[Any]], provider: str = "auto",
|
|
71
|
+
model: Optional[str] = None, user_id: Optional[str] = None,
|
|
72
|
+
session_id: Optional[str] = None) -> Any:
|
|
73
|
+
"""Async variant of track_ai."""
|
|
74
|
+
started = time.monotonic()
|
|
75
|
+
result = await call()
|
|
76
|
+
_record(result, started, feature, provider, model, user_id, session_id)
|
|
77
|
+
return result
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def flush() -> None:
|
|
81
|
+
"""Send queued events now. Call before a short-lived process (serverless, CLI) exits."""
|
|
82
|
+
if _batcher is not None:
|
|
83
|
+
_batcher.flush()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
atexit.register(flush)
|
mintlyr/_transport.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import urllib.request
|
|
3
|
+
from typing import Any, Dict, List
|
|
4
|
+
|
|
5
|
+
SDK_VERSION = "0.1.0"
|
|
6
|
+
TIMEOUT_S = 8
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def http_send(api_url: str, api_key: str, events: List[Dict[str, Any]]) -> None:
|
|
10
|
+
"""POST {events: [...]} to /ingest, retrying once. Raises on final failure (the batcher swallows it)."""
|
|
11
|
+
body = json.dumps({"events": events}).encode()
|
|
12
|
+
headers = {
|
|
13
|
+
"Content-Type": "application/json",
|
|
14
|
+
"Authorization": f"Bearer {api_key}",
|
|
15
|
+
"X-Mintlyr-SDK": "python",
|
|
16
|
+
"X-Mintlyr-SDK-Version": SDK_VERSION,
|
|
17
|
+
"X-Mintlyr-Batch-Size": str(len(events)),
|
|
18
|
+
}
|
|
19
|
+
last: Exception = RuntimeError("unreachable")
|
|
20
|
+
for _ in range(2):
|
|
21
|
+
try:
|
|
22
|
+
req = urllib.request.Request(f"{api_url}/ingest", data=body, headers=headers, method="POST")
|
|
23
|
+
urllib.request.urlopen(req, timeout=TIMEOUT_S).close()
|
|
24
|
+
return
|
|
25
|
+
except Exception as e: # noqa: BLE001
|
|
26
|
+
last = e
|
|
27
|
+
raise last
|
mintlyr/_usage.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Pull token usage out of provider responses (dicts or SDK objects). Never raises."""
|
|
2
|
+
from typing import Any, Tuple
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _get(obj: Any, *names: str) -> Any:
|
|
6
|
+
for name in names:
|
|
7
|
+
if obj is None:
|
|
8
|
+
return None
|
|
9
|
+
val = obj.get(name) if isinstance(obj, dict) else getattr(obj, name, None)
|
|
10
|
+
if val is not None:
|
|
11
|
+
return val
|
|
12
|
+
return None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _int(v: Any) -> int:
|
|
16
|
+
return v if isinstance(v, int) and not isinstance(v, bool) and v > 0 else 0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _openai(r: Any) -> Tuple[int, int, str]:
|
|
20
|
+
u = _get(r, "usage")
|
|
21
|
+
return _int(_get(u, "prompt_tokens")), _int(_get(u, "completion_tokens")), _get(r, "model") or "unknown"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _anthropic(r: Any) -> Tuple[int, int, str]:
|
|
25
|
+
u = _get(r, "usage")
|
|
26
|
+
return _int(_get(u, "input_tokens")), _int(_get(u, "output_tokens")), _get(r, "model") or "unknown"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _gemini(r: Any) -> Tuple[int, int, str]:
|
|
30
|
+
u = _get(r, "usage_metadata", "usageMetadata")
|
|
31
|
+
return (
|
|
32
|
+
_int(_get(u, "prompt_token_count", "promptTokenCount")),
|
|
33
|
+
_int(_get(u, "candidates_token_count", "candidatesTokenCount")),
|
|
34
|
+
_get(r, "model_version", "modelVersion", "model") or "unknown",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def extract_usage(response: Any, provider: str = "auto") -> Tuple[int, int, str]:
|
|
39
|
+
"""Return (input_tokens, output_tokens, model). Unknown shapes give (0, 0, 'unknown')."""
|
|
40
|
+
try:
|
|
41
|
+
if provider == "openai":
|
|
42
|
+
return _openai(response)
|
|
43
|
+
if provider == "anthropic":
|
|
44
|
+
return _anthropic(response)
|
|
45
|
+
if provider == "google":
|
|
46
|
+
return _gemini(response)
|
|
47
|
+
for fn in (_openai, _anthropic, _gemini):
|
|
48
|
+
res = fn(response)
|
|
49
|
+
if res[0] > 0:
|
|
50
|
+
return res
|
|
51
|
+
return (0, 0, _get(response, "model") or "unknown")
|
|
52
|
+
except Exception:
|
|
53
|
+
return (0, 0, "unknown")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mintlyr
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Track AI cost per feature and per user. Zero dependencies.
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# mintlyr (Python)
|
|
10
|
+
|
|
11
|
+
Track what each AI feature and each user costs. No dependencies; Python 3.9+.
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import mintlyr
|
|
15
|
+
from openai import OpenAI
|
|
16
|
+
|
|
17
|
+
mintlyr.init(api_key="ml_live_...")
|
|
18
|
+
client = OpenAI()
|
|
19
|
+
|
|
20
|
+
reply = mintlyr.track_ai(
|
|
21
|
+
feature="summarize",
|
|
22
|
+
provider="openai",
|
|
23
|
+
user_id="user_123", # hashed before it leaves your server
|
|
24
|
+
call=lambda: client.chat.completions.create(model="gpt-5", messages=[...]),
|
|
25
|
+
)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Async: `await mintlyr.atrack_ai(feature=..., call=async_fn)`.
|
|
29
|
+
|
|
30
|
+
- Returns exactly what your call returns; errors from your call propagate unchanged.
|
|
31
|
+
- Tracking never raises into or blocks your code. Events are batched and sent in the background.
|
|
32
|
+
- In serverless functions or short scripts call `mintlyr.flush()` before exiting.
|
|
33
|
+
- `init(..., local_mode=True)` tracks nothing, useful in tests.
|
|
34
|
+
- Works with OpenAI, Anthropic and Gemini response objects (or plain dicts).
|
|
35
|
+
|
|
36
|
+
Not yet published to PyPI; install from the repo for now: `pip install ./packages/sdk-python`.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
mintlyr/__init__.py,sha256=Q_L20CMKHjHHto5A0hW_vX13bp9OTEXDEB2Q4Ey-V74,204
|
|
2
|
+
mintlyr/_batcher.py,sha256=VbTZ4P35Edk0qVKCCcPacqhT_oJ2dWyg_DQDWNyjsdo,2509
|
|
3
|
+
mintlyr/_client.py,sha256=COTy5dIzWmV076kCka1yOUV37tbdn_OWsClFvB9Ujfk,3058
|
|
4
|
+
mintlyr/_transport.py,sha256=0WpAgTUbwQ-BazrUMvUticrGDc3XC5OsVZAcXlgHby8,952
|
|
5
|
+
mintlyr/_usage.py,sha256=vbnsN_mo-ALGl--aYE3bgfjnZ3K0zBFQwaKDKhBX0f8,1827
|
|
6
|
+
mintlyr-0.1.0.dist-info/METADATA,sha256=GEzpvwGMYOU3WTSseGDX_TJs2DSPQp70zYhNotF59B8,1190
|
|
7
|
+
mintlyr-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
8
|
+
mintlyr-0.1.0.dist-info/top_level.txt,sha256=aHrmZMwI-2mrJfbR1qDwDZHRqmm79_4UEO6zI4uqptQ,8
|
|
9
|
+
mintlyr-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mintlyr
|