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 ADDED
@@ -0,0 +1,5 @@
1
+ """Mintlyr: track AI cost per feature and per user. Zero dependencies."""
2
+ from ._client import atrack_ai, flush, init, track_ai
3
+
4
+ __all__ = ["init", "track_ai", "atrack_ai", "flush"]
5
+ __version__ = "0.1.0"
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ mintlyr