mintlyr 0.1.0__tar.gz

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-0.1.0/PKG-INFO ADDED
@@ -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,28 @@
1
+ # mintlyr (Python)
2
+
3
+ Track what each AI feature and each user costs. No dependencies; Python 3.9+.
4
+
5
+ ```python
6
+ import mintlyr
7
+ from openai import OpenAI
8
+
9
+ mintlyr.init(api_key="ml_live_...")
10
+ client = OpenAI()
11
+
12
+ reply = mintlyr.track_ai(
13
+ feature="summarize",
14
+ provider="openai",
15
+ user_id="user_123", # hashed before it leaves your server
16
+ call=lambda: client.chat.completions.create(model="gpt-5", messages=[...]),
17
+ )
18
+ ```
19
+
20
+ Async: `await mintlyr.atrack_ai(feature=..., call=async_fn)`.
21
+
22
+ - Returns exactly what your call returns; errors from your call propagate unchanged.
23
+ - Tracking never raises into or blocks your code. Events are batched and sent in the background.
24
+ - In serverless functions or short scripts call `mintlyr.flush()` before exiting.
25
+ - `init(..., local_mode=True)` tracks nothing, useful in tests.
26
+ - Works with OpenAI, Anthropic and Gemini response objects (or plain dicts).
27
+
28
+ Not yet published to PyPI; install from the repo for now: `pip install ./packages/sdk-python`.
@@ -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"
@@ -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
@@ -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)
@@ -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
@@ -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,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ mintlyr/__init__.py
4
+ mintlyr/_batcher.py
5
+ mintlyr/_client.py
6
+ mintlyr/_transport.py
7
+ mintlyr/_usage.py
8
+ mintlyr.egg-info/PKG-INFO
9
+ mintlyr.egg-info/SOURCES.txt
10
+ mintlyr.egg-info/dependency_links.txt
11
+ mintlyr.egg-info/top_level.txt
12
+ tests/test_client.py
13
+ tests/test_usage.py
@@ -0,0 +1 @@
1
+ mintlyr
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mintlyr"
7
+ version = "0.1.0"
8
+ description = "Track AI cost per feature and per user. Zero dependencies."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ dependencies = []
13
+
14
+ [tool.setuptools]
15
+ packages = ["mintlyr"]
16
+
17
+ [tool.pytest.ini_options]
18
+ testpaths = ["tests"]
19
+ pythonpath = ["."]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,101 @@
1
+ import asyncio
2
+ import pytest
3
+
4
+ import mintlyr
5
+ from mintlyr._batcher import Batcher, MAX_BATCH_EVENTS
6
+
7
+
8
+ def make(sent, **kw):
9
+ return Batcher(send=lambda evs: sent.append(list(evs)), batch_size=kw.get("batch_size", 3), flush_interval_s=kw.get("flush_interval_s", 60))
10
+
11
+
12
+ def test_flushes_when_batch_full():
13
+ sent = []
14
+ b = make(sent)
15
+ for i in range(3):
16
+ b.enqueue({"i": i})
17
+ assert [len(x) for x in sent] == [3]
18
+
19
+
20
+ def test_manual_flush_sends_remainder_once():
21
+ sent = []
22
+ b = make(sent)
23
+ b.enqueue({"i": 1})
24
+ b.flush()
25
+ b.flush()
26
+ assert [len(x) for x in sent] == [1]
27
+
28
+
29
+ def test_batch_size_is_capped_to_api_limit():
30
+ sent = []
31
+ b = make(sent, batch_size=10**6)
32
+ assert b.batch_size == MAX_BATCH_EVENTS
33
+
34
+
35
+ def test_oversized_and_unserializable_events_are_dropped_not_raised():
36
+ sent = []
37
+ b = make(sent)
38
+ b.enqueue({"x": "a" * 600_000})
39
+ b.enqueue({"x": object()})
40
+ b.flush()
41
+ assert sent == []
42
+
43
+
44
+ def test_send_failure_never_raises():
45
+ def boom(evs):
46
+ raise RuntimeError("network down")
47
+ b = Batcher(send=boom, batch_size=1, flush_interval_s=60)
48
+ b.enqueue({"a": 1}) # must not raise
49
+
50
+
51
+ def test_track_ai_returns_result_and_queues_event():
52
+ sent = []
53
+ mintlyr.init(api_key="ml_live_x", _send=lambda evs: sent.append(list(evs)))
54
+ out = mintlyr.track_ai(
55
+ feature="summarize",
56
+ call=lambda: {"model": "gpt-5", "usage": {"prompt_tokens": 9, "completion_tokens": 4}},
57
+ provider="openai",
58
+ user_id="alice",
59
+ )
60
+ assert out["model"] == "gpt-5"
61
+ mintlyr.flush()
62
+ ev = sent[0][0]
63
+ assert ev["feature"] == "summarize" and ev["input_tokens"] == 9 and ev["output_tokens"] == 4
64
+ assert ev["model"] == "gpt-5" and ev["response_time_ms"] >= 0
65
+ assert ev["end_user_id"] and ev["end_user_id"] != "alice" and len(ev["end_user_id"]) == 16
66
+
67
+
68
+ def test_caller_exceptions_propagate_and_nothing_is_tracked():
69
+ sent = []
70
+ mintlyr.init(api_key="k", _send=lambda evs: sent.append(list(evs)))
71
+
72
+ def bad():
73
+ raise ValueError("model error")
74
+ with pytest.raises(ValueError):
75
+ mintlyr.track_ai(feature="f", call=bad)
76
+ mintlyr.flush()
77
+ assert sent == []
78
+
79
+
80
+ def test_local_mode_sends_nothing():
81
+ sent = []
82
+ mintlyr.init(api_key="k", local_mode=True, _send=lambda evs: sent.append(list(evs)))
83
+ mintlyr.track_ai(feature="f", call=lambda: {})
84
+ mintlyr.flush()
85
+ assert sent == []
86
+
87
+
88
+ def test_async_variant():
89
+ asyncio.run(_async_case())
90
+
91
+
92
+ async def _async_case():
93
+ sent = []
94
+ mintlyr.init(api_key="k", _send=lambda evs: sent.append(list(evs)))
95
+
96
+ async def call():
97
+ return {"model": "m", "usage": {"input_tokens": 1, "output_tokens": 2}}
98
+ out = await mintlyr.atrack_ai(feature="f", call=call, provider="anthropic")
99
+ assert out["model"] == "m"
100
+ mintlyr.flush()
101
+ assert sent[0][0]["output_tokens"] == 2
@@ -0,0 +1,33 @@
1
+ from types import SimpleNamespace as NS
2
+
3
+ from mintlyr._usage import extract_usage
4
+
5
+
6
+ def test_openai_dict():
7
+ r = {"model": "gpt-5", "usage": {"prompt_tokens": 10, "completion_tokens": 5}}
8
+ assert extract_usage(r, "openai") == (10, 5, "gpt-5")
9
+
10
+
11
+ def test_openai_object():
12
+ r = NS(model="gpt-5", usage=NS(prompt_tokens=7, completion_tokens=3))
13
+ assert extract_usage(r, "openai") == (7, 3, "gpt-5")
14
+
15
+
16
+ def test_anthropic_object():
17
+ r = NS(model="claude-sonnet-4-5", usage=NS(input_tokens=11, output_tokens=2))
18
+ assert extract_usage(r, "anthropic") == (11, 2, "claude-sonnet-4-5")
19
+
20
+
21
+ def test_gemini_object():
22
+ r = NS(usage_metadata=NS(prompt_token_count=4, candidates_token_count=6), model_version="gemini-2.5-flash")
23
+ assert extract_usage(r, "google") == (4, 6, "gemini-2.5-flash")
24
+
25
+
26
+ def test_auto_detects_each_shape():
27
+ assert extract_usage({"model": "m", "usage": {"input_tokens": 3, "output_tokens": 1}}, "auto")[:2] == (3, 1)
28
+ assert extract_usage({"model": "m", "usage": {"prompt_tokens": 2, "completion_tokens": 1}}, "auto")[:2] == (2, 1)
29
+
30
+
31
+ def test_unknown_shape_is_zero_not_error():
32
+ assert extract_usage(object(), "auto") == (0, 0, "unknown")
33
+ assert extract_usage(None, "openai") == (0, 0, "unknown")