prinvox 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.
prinvox/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """Official PrinVox Margin Guard SDK for Python.
2
+
3
+ Report AI usage (OpenAI, Anthropic, Google Gemini) from your server::
4
+
5
+ from prinvox import PrinVox
6
+
7
+ client = PrinVox(api_key=os.environ["PRINVOX_INGESTION_KEY"])
8
+ client.track(provider="openai", model="gpt-4.1", event_id=response.id, feature_key="assistant", ...)
9
+ """
10
+
11
+ from .client import PrinVox
12
+ from .errors import (
13
+ PrinVoxAuthenticationError,
14
+ PrinVoxConnectionError,
15
+ PrinVoxError,
16
+ PrinVoxRateLimitError,
17
+ PrinVoxValidationError,
18
+ )
19
+ from .types import KnownProvider, Provider, TrackResult
20
+
21
+ __version__ = "0.1.0"
22
+
23
+ __all__ = [
24
+ "PrinVox",
25
+ "PrinVoxError",
26
+ "PrinVoxAuthenticationError",
27
+ "PrinVoxConnectionError",
28
+ "PrinVoxRateLimitError",
29
+ "PrinVoxValidationError",
30
+ "TrackResult",
31
+ "KnownProvider",
32
+ "Provider",
33
+ "__version__",
34
+ ]
prinvox/client.py ADDED
@@ -0,0 +1,346 @@
1
+ """PrinVox Margin Guard client: a thin, synchronous wrapper over ``POST /api/margin-guard/usage``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import operator
8
+ import random
9
+ import re
10
+ import time
11
+ from datetime import datetime, timezone
12
+ from typing import Any
13
+ from urllib.parse import urlsplit
14
+
15
+ import httpx
16
+
17
+ from . import errors
18
+ from .errors import (
19
+ PrinVoxAuthenticationError,
20
+ PrinVoxConnectionError,
21
+ PrinVoxError,
22
+ PrinVoxRateLimitError,
23
+ PrinVoxValidationError,
24
+ )
25
+ from .types import Provider, TrackResult
26
+
27
+ DEFAULT_BASE_URL = "https://www.prinvox.com"
28
+ USAGE_PATH = "/api/margin-guard/usage"
29
+ DEFAULT_TIMEOUT = 10.0
30
+ DEFAULT_MAX_RETRIES = 2
31
+ MAX_RETRIES_CEILING = 5
32
+ DEFAULT_RETRY_DELAY = 0.25
33
+ _LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
34
+ # A permanent 5xx (501, 505, ...) will not heal by waiting.
35
+ _RETRYABLE_STATUSES = {500, 502, 503, 504}
36
+
37
+ # Server-side limits (MARGIN_GUARD_FIELD_LIMITS); the server stays the authority, these only fail fast.
38
+ _LIMITS = {"feature_key": 100, "model": 100, "event_id": 200, "customer_ref": 200}
39
+ _BAD_KEY_CHARS = re.compile(r"[\s\x00-\x1f\x7f]")
40
+
41
+
42
+ def _fail_options(message: str) -> PrinVoxError:
43
+ return PrinVoxValidationError(errors.INVALID_OPTIONS, message)
44
+
45
+
46
+ def _fail_event(message: str) -> PrinVoxError:
47
+ return PrinVoxValidationError(errors.INVALID_EVENT, message)
48
+
49
+
50
+ def _non_blank(value: object, limit: int) -> bool:
51
+ return isinstance(value, str) and value.strip() != "" and len(value) <= limit
52
+
53
+
54
+ def _token_count(name: str, value: object) -> int | None:
55
+ if value is None:
56
+ return None
57
+ # bool is an int subclass (True would silently become 1); float and Decimal are not integers. Anything that is a true
58
+ # integer type (numpy.int64, ...) is accepted through operator.index and normalised to a plain int.
59
+ if isinstance(value, bool):
60
+ raise _fail_event(f"{name} must be a non-negative integer.")
61
+ try:
62
+ number = operator.index(value) # type: ignore[arg-type]
63
+ except TypeError:
64
+ raise _fail_event(f"{name} must be a non-negative integer.") from None
65
+ if number < 0 or number > 2**53 - 1:
66
+ raise _fail_event(f"{name} must be a non-negative integer.")
67
+ return int(number)
68
+
69
+
70
+ _ISO = re.compile(r"^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:[.,](\d+))?(Z|[+-]\d{2}(?::?\d{2})?)?$")
71
+
72
+
73
+ def _normalise_iso(text: str) -> str:
74
+ """ISO 8601 extended form with any number of fractional digits and `Z` or `+hh[:mm]`, accepted identically on Python 3.10+."""
75
+ match = _ISO.match(text.strip())
76
+ if not match:
77
+ raise ValueError("not an ISO 8601 date-time")
78
+ day, clock, fraction, zone = match.groups()
79
+ fraction_part = f".{(fraction + '000000')[:6]}" if fraction else ""
80
+ if zone is None or zone == "Z":
81
+ zone_part = "+00:00" if zone == "Z" else ""
82
+ else:
83
+ digits = zone[1:].replace(":", "")
84
+ zone_part = f"{zone[0]}{digits[:2]}:{(digits + '00')[2:4]}"
85
+ return f"{day}T{clock}{fraction_part}{zone_part}"
86
+
87
+
88
+ def _iso_timestamp(value: object) -> str:
89
+ if value is None:
90
+ moment = datetime.now(timezone.utc)
91
+ elif isinstance(value, datetime):
92
+ # A naive datetime is taken as UTC (documented); an aware one is converted to UTC.
93
+ moment = value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc)
94
+ elif isinstance(value, str):
95
+ try:
96
+ parsed = datetime.fromisoformat(_normalise_iso(value))
97
+ except ValueError as exc:
98
+ raise _fail_event("timestamp is not a valid ISO 8601 date.") from exc
99
+ moment = parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed.astimezone(timezone.utc)
100
+ else:
101
+ raise _fail_event("timestamp must be a datetime or an ISO 8601 string.")
102
+ return moment.isoformat(timespec="milliseconds").replace("+00:00", "Z")
103
+
104
+
105
+ class PrinVox:
106
+ """PrinVox Margin Guard client. Server-side only: it holds your ingestion key.
107
+
108
+ ``api_key``: your PrinVox ingestion key (``mgik_...``), created under Margin Guard -> Connections, one per provider.
109
+ ``base_url``: defaults to ``https://www.prinvox.com``; set it to test against a Preview environment or a local server.
110
+ ``timeout``: seconds, applied to connecting and to each read. Default 10.
111
+ ``max_retries``: extra attempts after a network error, a timeout or a 500/502/503/504 answer. Default 2, maximum 5,
112
+ ``0`` disables. Retrying is safe because every event carries its own ``event_id`` (the server's idempotency key): a
113
+ repeat is answered ``duplicate`` and never counted twice. 429 and 4xx are never retried automatically.
114
+ ``retry_delay``: base of the exponential backoff in seconds (default 0.25: 0.25, 0.5, 1, ...).
115
+ ``http_client``: an ``httpx.Client`` of your own (proxies, a mock transport in tests). You keep ownership of it.
116
+ """
117
+
118
+ def __init__(
119
+ self,
120
+ api_key: str,
121
+ *,
122
+ base_url: str | None = None,
123
+ timeout: float | None = None,
124
+ max_retries: int = DEFAULT_MAX_RETRIES,
125
+ retry_delay: float = DEFAULT_RETRY_DELAY,
126
+ http_client: httpx.Client | None = None,
127
+ ) -> None:
128
+ if not isinstance(api_key, str) or api_key == "" or _BAD_KEY_CHARS.search(api_key):
129
+ raise _fail_options("api_key must be your PrinVox ingestion key (a non-empty string without whitespace).")
130
+
131
+ try:
132
+ parts = urlsplit(base_url if base_url is not None else DEFAULT_BASE_URL)
133
+ hostname = parts.hostname
134
+ port_number = parts.port # raises ValueError for an out-of-range or non-numeric port
135
+ except ValueError as exc:
136
+ raise _fail_options("base_url must be a valid URL.") from exc
137
+ if parts.query or parts.fragment:
138
+ raise _fail_options("base_url must not contain a query string or fragment.")
139
+ if parts.scheme not in ("http", "https") or not hostname:
140
+ raise _fail_options("base_url must be a valid http(s) URL.")
141
+ if parts.scheme != "https" and hostname not in _LOOPBACK_HOSTS:
142
+ raise _fail_options("base_url must use https (http is allowed only for localhost).")
143
+ if parts.username or parts.password:
144
+ raise _fail_options("base_url must not contain credentials.")
145
+ host = f"[{hostname}]" if ":" in hostname else hostname
146
+ port = f":{port_number}" if port_number is not None else ""
147
+ self._endpoint = f"{parts.scheme}://{host}{port}{parts.path.rstrip('/')}{USAGE_PATH}"
148
+
149
+ effective_timeout = DEFAULT_TIMEOUT if timeout is None else timeout
150
+ if isinstance(effective_timeout, bool) or not isinstance(effective_timeout, (int, float)) or not math.isfinite(effective_timeout) or effective_timeout <= 0:
151
+ raise _fail_options("timeout must be a positive number of seconds.")
152
+ if isinstance(max_retries, bool) or not isinstance(max_retries, int) or not 0 <= max_retries <= MAX_RETRIES_CEILING:
153
+ raise _fail_options(f"max_retries must be an integer from 0 to {MAX_RETRIES_CEILING}.")
154
+ if isinstance(retry_delay, bool) or not isinstance(retry_delay, (int, float)) or not math.isfinite(retry_delay) or retry_delay < 0:
155
+ raise _fail_options("retry_delay must be a non-negative number of seconds.")
156
+
157
+ self._api_key = api_key
158
+ self._base_url = f"{parts.scheme}://{host}{port}{parts.path.rstrip('/')}"
159
+ self._timeout = float(effective_timeout)
160
+ self._max_retries = max_retries
161
+ self._retry_delay = float(retry_delay)
162
+ self._owns_client = http_client is None
163
+ self._http = http_client if http_client is not None else httpx.Client()
164
+
165
+ # -- secret hygiene ---------------------------------------------------------------------------------------------
166
+ def __repr__(self) -> str:
167
+ return f"PrinVox(base_url={self._base_url!r}, timeout={self._timeout!r}, max_retries={self._max_retries!r}, api_key=<redacted>)"
168
+
169
+ def __getstate__(self) -> object:
170
+ raise TypeError("PrinVox clients hold a secret and cannot be pickled or copied.")
171
+
172
+ def __copy__(self) -> object:
173
+ raise TypeError("PrinVox clients hold a secret and cannot be pickled or copied.")
174
+
175
+ def __deepcopy__(self, memo: object) -> object:
176
+ raise TypeError("PrinVox clients hold a secret and cannot be pickled or copied.")
177
+
178
+ # -- lifecycle ------------------------------------------------------------------------------------------------------
179
+ def close(self) -> None:
180
+ """Close the underlying HTTP client (only if this SDK created it)."""
181
+ if self._owns_client:
182
+ self._http.close()
183
+
184
+ def __enter__(self) -> PrinVox:
185
+ return self
186
+
187
+ def __exit__(self, *exc_info: object) -> None:
188
+ self.close()
189
+
190
+ # -- public API -----------------------------------------------------------------------------------------------------
191
+ def track(
192
+ self,
193
+ *,
194
+ provider: Provider,
195
+ model: str,
196
+ event_id: str,
197
+ feature_key: str,
198
+ input_tokens: int | None = None,
199
+ output_tokens: int | None = None,
200
+ input_audio_tokens: int | None = None,
201
+ customer_ref: str | None = None,
202
+ stripe_customer_id: str | None = None,
203
+ timestamp: object | None = None,
204
+ ) -> TrackResult:
205
+ """Report one AI request's usage. Returns ``TrackResult`` (``processed`` or ``duplicate``) or raises ``PrinVoxError``.
206
+
207
+ ``event_id`` is the idempotency key (sent as ``provider_request_id``): use the provider's own response id
208
+ (OpenAI ``response.id``, Anthropic ``message.id``, Gemini ``response_id``) or any id you keep stable for this
209
+ request across your own retries. Omit token counts the provider did not report: the cost is then UNKNOWN, never zero.
210
+ ``input_audio_tokens`` is REQUIRED (0 if none) for gemini-2.5-flash, gemini-2.5-flash-lite and gemini-3.1-flash-lite;
211
+ without it their cost is UNKNOWN. For Gemini, ``output_tokens`` includes thinking tokens.
212
+ ``timestamp``: a ``datetime`` (naive means UTC) or an ISO 8601 string (``2026-10-20T10:00:00.5Z``, ``+02:00`` ...); defaults to now.
213
+ This call is BLOCKING (network I/O, retries): do not call it directly on an asyncio event loop; run it in a thread or a background task.
214
+ """
215
+ body = json.dumps(
216
+ self._wire(provider, model, event_id, feature_key, input_tokens, output_tokens, input_audio_tokens, customer_ref, stripe_customer_id, timestamp),
217
+ separators=(",", ":"),
218
+ )
219
+ last: PrinVoxError | None = None
220
+ for attempt in range(self._max_retries + 1):
221
+ if attempt > 0:
222
+ # Exponential backoff with jitter (50-100 %), so many clients do not retry in lock-step.
223
+ time.sleep(self._retry_delay * 2 ** (attempt - 1) * (0.5 + random.random() / 2))
224
+ try:
225
+ return self._send(body)
226
+ except PrinVoxError as error:
227
+ last = error
228
+ # Only a transient failure is repeated. The same body (same event_id) goes out each time, so a repeat of
229
+ # an event the server already stored is answered `duplicate`, never counted twice.
230
+ transient = error.code in (errors.NETWORK, errors.TIMEOUT) or (
231
+ error.code == errors.SERVER_ERROR and error.status_code in _RETRYABLE_STATUSES
232
+ )
233
+ if not transient:
234
+ raise
235
+ assert last is not None
236
+ raise last
237
+
238
+ # -- internals ------------------------------------------------------------------------------------------------------
239
+ @staticmethod
240
+ def _wire(
241
+ provider: object,
242
+ model: object,
243
+ event_id: object,
244
+ feature_key: object,
245
+ input_tokens: object,
246
+ output_tokens: object,
247
+ input_audio_tokens: object,
248
+ customer_ref: object,
249
+ stripe_customer_id: object,
250
+ timestamp: object,
251
+ ) -> dict[str, Any]:
252
+ if not isinstance(provider, str) or provider.strip() == "":
253
+ raise _fail_event("provider is required.")
254
+ if not _non_blank(model, _LIMITS["model"]):
255
+ raise _fail_event("model is required (1-100 characters).")
256
+ if not _non_blank(event_id, _LIMITS["event_id"]):
257
+ raise _fail_event("event_id is required (1-200 characters): it is the idempotency key.")
258
+ if not _non_blank(feature_key, _LIMITS["feature_key"]):
259
+ raise _fail_event("feature_key is required (1-100 characters).")
260
+ if customer_ref is not None and not _non_blank(customer_ref, _LIMITS["customer_ref"]):
261
+ raise _fail_event("customer_ref must be 1-200 characters.")
262
+ if stripe_customer_id is not None and not _non_blank(stripe_customer_id, 100):
263
+ raise _fail_event("stripe_customer_id must be a Stripe customer id (cus_...).")
264
+ wire: dict[str, Any] = {
265
+ "feature_key": feature_key,
266
+ "provider": provider,
267
+ "model": model,
268
+ "provider_request_id": event_id,
269
+ "external_customer_id": customer_ref,
270
+ "stripe_customer_id": stripe_customer_id,
271
+ "input_tokens": _token_count("input_tokens", input_tokens),
272
+ "output_tokens": _token_count("output_tokens", output_tokens),
273
+ "input_audio_tokens": _token_count("input_audio_tokens", input_audio_tokens),
274
+ "occurred_at": _iso_timestamp(timestamp),
275
+ }
276
+ # Fields not given are omitted, never sent as null/0: "unknown" must stay unknown.
277
+ return {k: v for k, v in wire.items() if v is not None}
278
+
279
+ def _send(self, body: str) -> TrackResult:
280
+ try:
281
+ response = self._http.post(
282
+ self._endpoint,
283
+ content=body,
284
+ headers={"authorization": f"Bearer {self._api_key}", "content-type": "application/json", "accept": "application/json"},
285
+ timeout=self._timeout,
286
+ )
287
+ except (httpx.LocalProtocolError, httpx.UnsupportedProtocol):
288
+ # Something wrong on OUR side of the request (for example a header value h11 refuses): retrying cannot help.
289
+ raise _fail_options("The request could not be built (check api_key and base_url).") from None
290
+ except httpx.TimeoutException:
291
+ # Fixed messages only: the underlying exception is deliberately not attached (it could echo request details).
292
+ raise PrinVoxConnectionError(errors.TIMEOUT, f"PrinVox did not answer within {self._timeout:g} s.") from None
293
+ except httpx.HTTPError:
294
+ raise PrinVoxConnectionError(errors.NETWORK, "Could not reach PrinVox (network error).") from None
295
+ return self._interpret(response)
296
+
297
+ @staticmethod
298
+ def _interpret(response: httpx.Response) -> TrackResult:
299
+ status = response.status_code
300
+ request_id = response.headers.get("x-request-id")
301
+ payload: dict[str, Any] | None = None
302
+ try:
303
+ parsed = json.loads(response.text)
304
+ payload = parsed if isinstance(parsed, dict) else None
305
+ except ValueError:
306
+ payload = None
307
+ raw_error = payload.get("error") if payload else None
308
+ api_error = raw_error[:100] if isinstance(raw_error, str) else None
309
+ common: dict[str, Any] = {"status_code": status, "api_error": api_error, "request_id": request_id}
310
+
311
+ if status in (200, 201):
312
+ if payload and payload.get("status") in ("processed", "duplicate"):
313
+ return TrackResult(status=payload["status"])
314
+ raise PrinVoxError(errors.INVALID_RESPONSE, "PrinVox answered with an unexpected body.", status_code=status, request_id=request_id)
315
+ # Any other 2xx is not part of the contract: say so, instead of calling a possibly stored event "rejected".
316
+ if 200 <= status < 300:
317
+ raise PrinVoxError(errors.INVALID_RESPONSE, f"PrinVox answered with an unexpected success status ({status}).", status_code=status, request_id=request_id)
318
+ if 300 <= status < 400:
319
+ # httpx does not follow redirects here: the event was never evaluated. Almost always a base_url problem.
320
+ raise PrinVoxError(
321
+ errors.INVALID_RESPONSE,
322
+ f"PrinVox redirected the request ({status}); check base_url (the canonical host is https://www.prinvox.com).",
323
+ status_code=status,
324
+ request_id=request_id,
325
+ )
326
+ if status == 401:
327
+ raise PrinVoxAuthenticationError(errors.AUTHENTICATION, "PrinVox rejected the ingestion key (missing, wrong or replaced).", **common)
328
+ if status == 403:
329
+ raise PrinVoxAuthenticationError(errors.FORBIDDEN, "PrinVox refused this request (403).", **common)
330
+ if status == 402:
331
+ raise PrinVoxError(errors.PAYMENT_REQUIRED, "The free evaluation has ended; subscribe to keep ingesting events.", **common)
332
+ if status == 429:
333
+ # `trial_event_limit` is the evaluation's total event quota: final, not a throttle -- resending will not help.
334
+ if api_error == "trial_event_limit":
335
+ raise PrinVoxError(errors.QUOTA_EXCEEDED, "The free evaluation has used all of its events; subscribe to keep ingesting.", **common)
336
+ retry_after: float | None = None
337
+ try:
338
+ header = float(response.headers.get("retry-after", ""))
339
+ retry_after = header if math.isfinite(header) and header > 0 else None
340
+ except ValueError:
341
+ retry_after = None
342
+ raise PrinVoxRateLimitError(errors.RATE_LIMITED, "PrinVox rate limit reached; resend later with the same event_id.", retry_after=retry_after, **common)
343
+ if status >= 500:
344
+ raise PrinVoxError(errors.SERVER_ERROR, f"PrinVox is temporarily unavailable ({status}).", **common)
345
+ suffix = f": {api_error}" if api_error else ""
346
+ raise PrinVoxValidationError(errors.INVALID_REQUEST, f"PrinVox rejected the event ({status}{suffix}).", **common)
prinvox/errors.py ADDED
@@ -0,0 +1,81 @@
1
+ """Exceptions raised by the PrinVox SDK.
2
+
3
+ Every exception carries only safe, fixed information: a stable ``code``, an HTTP ``status_code`` where there was one,
4
+ the server's own error string (``api_error``), ``retry_after`` and ``request_id``. None of them can contain the ingestion
5
+ key, the Authorization header or the event body: messages are fixed strings built inside this package.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ # Stable error codes, identical to the Node.js SDK's.
11
+ INVALID_OPTIONS = "invalid_options" # the client was constructed with something unusable
12
+ INVALID_EVENT = "invalid_event" # rejected locally, nothing was sent
13
+ AUTHENTICATION = "authentication" # 401: missing, wrong or replaced ingestion key
14
+ FORBIDDEN = "forbidden" # 403
15
+ INVALID_REQUEST = "invalid_request" # other 4xx the server rejected (400 invalid_body / provider_mismatch, 413, ...)
16
+ PAYMENT_REQUIRED = "payment_required" # 402: the free evaluation ended (trial_expired)
17
+ QUOTA_EXCEEDED = "quota_exceeded" # 429 trial_event_limit: the evaluation's event quota is used up (final)
18
+ RATE_LIMITED = "rate_limited" # 429 throttle: resend later with the same event_id
19
+ SERVER_ERROR = "server_error" # 5xx
20
+ NETWORK = "network" # no answer: DNS, connection, TLS
21
+ TIMEOUT = "timeout" # no answer within ``timeout``
22
+ INVALID_RESPONSE = "invalid_response" # an answer the SDK cannot interpret
23
+
24
+
25
+ class PrinVoxError(Exception):
26
+ """Base class of every error this SDK raises."""
27
+
28
+ def __init__(
29
+ self,
30
+ code: str,
31
+ message: str,
32
+ *,
33
+ status_code: int | None = None,
34
+ api_error: str | None = None,
35
+ retry_after: float | None = None,
36
+ request_id: str | None = None,
37
+ ) -> None:
38
+ super().__init__(message)
39
+ self.code = code
40
+ self.message = message
41
+ self.status_code = status_code
42
+ self.api_error = api_error
43
+ self.retry_after = retry_after
44
+ self.request_id = request_id
45
+
46
+ def __str__(self) -> str:
47
+ return self.message
48
+
49
+ def __repr__(self) -> str:
50
+ return (
51
+ f"{type(self).__name__}(code={self.code!r}, status_code={self.status_code!r}, "
52
+ f"api_error={self.api_error!r}, message={self.message!r})"
53
+ )
54
+
55
+ def to_dict(self) -> dict[str, object]:
56
+ """Safe to log or serialise: no secret can be in any of these fields."""
57
+ return {
58
+ "name": type(self).__name__,
59
+ "code": self.code,
60
+ "message": self.message,
61
+ "status_code": self.status_code,
62
+ "api_error": self.api_error,
63
+ "retry_after": self.retry_after,
64
+ "request_id": self.request_id,
65
+ }
66
+
67
+
68
+ class PrinVoxValidationError(PrinVoxError):
69
+ """The event was refused: locally (``invalid_event``) or by the server (``invalid_request``, 400/413/...)."""
70
+
71
+
72
+ class PrinVoxAuthenticationError(PrinVoxError):
73
+ """401 (wrong, missing or replaced ingestion key) or 403."""
74
+
75
+
76
+ class PrinVoxRateLimitError(PrinVoxError):
77
+ """429 throttle. ``retry_after`` (seconds) is the server's hint; resend later with the SAME ``event_id``."""
78
+
79
+
80
+ class PrinVoxConnectionError(PrinVoxError):
81
+ """No answer: a network failure (``code == "network"``) or a timeout (``code == "timeout"``)."""
prinvox/py.typed ADDED
File without changes
prinvox/types.py ADDED
@@ -0,0 +1,22 @@
1
+ """Public types. Plain dataclasses: no third-party model library."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+ #: Providers PrinVox prices today. ``Provider`` stays open (``str``) so a provider added later needs no SDK upgrade.
9
+ KnownProvider = Literal["openai", "anthropic", "google_gemini"]
10
+ Provider = KnownProvider | str
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class TrackResult:
15
+ """The server's answer to a stored or repeated event."""
16
+
17
+ #: ``processed``: stored now. ``duplicate``: this ``event_id`` was already stored; nothing changed.
18
+ status: Literal["processed", "duplicate"]
19
+
20
+ @property
21
+ def duplicate(self) -> bool:
22
+ return self.status == "duplicate"
@@ -0,0 +1,233 @@
1
+ Metadata-Version: 2.5
2
+ Name: prinvox
3
+ Version: 0.1.0
4
+ Summary: Official PrinVox Margin Guard SDK: report AI usage events (OpenAI, Anthropic, Google Gemini) from your Python server.
5
+ Project-URL: Homepage, https://www.prinvox.com
6
+ Project-URL: Documentation, https://www.prinvox.com/en/docs
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Keywords: ai,anthropic,cost,gemini,gross-margin,llm,margin-guard,openai,prinvox,usage
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: httpx<1,>=0.27
20
+ Description-Content-Type: text/markdown
21
+
22
+ # prinvox (Python SDK)
23
+
24
+ Official Python SDK for **PrinVox Margin Guard**. It reports your AI usage (OpenAI, Anthropic, Google Gemini) to PrinVox so you see revenue, AI cost and gross margin per customer, plan and feature.
25
+
26
+ > **Version 0.1.0.** The SDK is a thin wrapper over the PrinVox ingestion HTTP API, documented at [prinvox.com/en/docs](https://www.prinvox.com/en/docs), which you can also call directly.
27
+
28
+ ```bash
29
+ pip install prinvox
30
+ ```
31
+
32
+ Python **3.10+**. One dependency: [`httpx`](https://www.python-httpx.org/). Synchronous API: `track` blocks (network I/O, retries), so never call it directly on an asyncio event loop — use a worker thread or a background task (see FastAPI below). An async client is a planned follow-up.
33
+
34
+ ## What it needs — and what it never needs
35
+
36
+ The SDK uses **one secret: your PrinVox ingestion key** (`mgik_…`, created under *Margin Guard → Connections*, one per provider). It sends usage **metadata** (model, token counts, your customer and feature labels).
37
+
38
+ PrinVox does **not** need, and the SDK never touches, your OpenAI, Anthropic or Google (Gemini) API key. You keep calling those providers with their own SDKs and then report what they told you. Prices live on the server: the SDK contains none.
39
+
40
+ **Server-side only.** Never put `PRINVOX_INGESTION_KEY` in browser code, a frontend bundle or a mobile app.
41
+
42
+ ## Quick start
43
+
44
+ ```python
45
+ import os
46
+ from prinvox import PrinVox
47
+
48
+ client = PrinVox(api_key=os.environ["PRINVOX_INGESTION_KEY"])
49
+
50
+ result = client.track(
51
+ provider="openai",
52
+ model="gpt-4.1",
53
+ event_id=response.id, # idempotency key, see below
54
+ feature_key="assistant",
55
+ customer_ref="cus_123",
56
+ input_tokens=1000,
57
+ output_tokens=500,
58
+ )
59
+ print(result.status) # "processed" or "duplicate"
60
+ ```
61
+
62
+ `track` takes keyword arguments only, returns a `TrackResult` or raises a [`PrinVoxError`](#errors).
63
+
64
+ ### OpenAI
65
+
66
+ ```python
67
+ from openai import OpenAI
68
+ from prinvox import PrinVox
69
+
70
+ openai_client = OpenAI() # your own key, PrinVox never sees it
71
+ prinvox = PrinVox(api_key=os.environ["PRINVOX_INGESTION_KEY_OPENAI"])
72
+ MODEL = "gpt-4.1-mini" # send the alias you requested, not a dated snapshot name
73
+
74
+ response = openai_client.responses.create(model=MODEL, input="Summarize: ...")
75
+
76
+ try:
77
+ prinvox.track(
78
+ provider="openai",
79
+ model=MODEL,
80
+ event_id=response.id,
81
+ feature_key="summarize",
82
+ customer_ref=app_customer_id,
83
+ input_tokens=response.usage.input_tokens if response.usage else None,
84
+ output_tokens=response.usage.output_tokens if response.usage else None,
85
+ )
86
+ except Exception:
87
+ pass # telemetry must never break your product (log it if you like)
88
+ ```
89
+
90
+ ### Anthropic
91
+
92
+ ```python
93
+ import anthropic
94
+
95
+ claude = anthropic.Anthropic() # your own key
96
+ message = claude.messages.create(model="claude-haiku-4-5", max_tokens=512, messages=[{"role": "user", "content": question}])
97
+
98
+ prinvox.track( # a PrinVox client created with the ingestion key for Anthropic
99
+ provider="anthropic",
100
+ model="claude-haiku-4-5",
101
+ event_id=message.id,
102
+ feature_key="support-answer",
103
+ customer_ref=app_customer_id,
104
+ input_tokens=message.usage.input_tokens,
105
+ output_tokens=message.usage.output_tokens,
106
+ )
107
+ ```
108
+
109
+ ### Google Gemini
110
+
111
+ The canonical provider value is `google_gemini` (a bare `google` is refused). Thinking tokens are billed as output, so add them. The 2.5 Flash family bills audio input higher, so say how much of the prompt was audio — `0` for text-only calls. **Without `input_audio_tokens` the cost of those models is stored as UNKNOWN** (never silently priced as text), and the report shows that figure as incomplete.
112
+
113
+ ```python
114
+ import uuid
115
+ from google import genai
116
+
117
+ gemini = genai.Client() # your own key
118
+ response = gemini.models.generate_content(model="gemini-2.5-flash", contents=prompt)
119
+ u = response.usage_metadata
120
+ audio = sum((d.token_count or 0) for d in (u.prompt_tokens_details or []) if str(d.modality).endswith("AUDIO"))
121
+
122
+ prinvox.track( # a PrinVox client created with the ingestion key for Google Gemini
123
+ provider="google_gemini",
124
+ model="gemini-2.5-flash",
125
+ event_id=response.response_id or str(uuid.uuid4()),
126
+ feature_key="ai-chat",
127
+ customer_ref=app_customer_id,
128
+ input_tokens=u.prompt_token_count,
129
+ input_audio_tokens=audio,
130
+ output_tokens=(u.candidates_token_count or 0) + (u.thoughts_token_count or 0),
131
+ )
132
+ ```
133
+
134
+ ### FastAPI (server-side)
135
+
136
+ `track` is synchronous. Run it as a background task so it runs after the response and in a worker thread, never on the event loop.
137
+
138
+ ```python
139
+ import os
140
+ from fastapi import BackgroundTasks, FastAPI
141
+ from prinvox import PrinVox
142
+
143
+ app = FastAPI()
144
+ prinvox = PrinVox(api_key=os.environ["PRINVOX_INGESTION_KEY_OPENAI"]) # read from the server's environment only
145
+
146
+
147
+ def report(response_id: str, customer_id: str, usage) -> None:
148
+ try:
149
+ prinvox.track(
150
+ provider="openai", model="gpt-4.1-mini", event_id=response_id, feature_key="chat", customer_ref=customer_id,
151
+ input_tokens=usage.input_tokens, output_tokens=usage.output_tokens,
152
+ )
153
+ except Exception:
154
+ pass # never fail the user's request over telemetry
155
+
156
+
157
+ @app.post("/chat")
158
+ def chat(prompt: str, customer_id: str, background: BackgroundTasks):
159
+ response = openai_client.responses.create(model="gpt-4.1-mini", input=prompt)
160
+ background.add_task(report, response.id, customer_id, response.usage)
161
+ return {"text": response.output_text}
162
+ ```
163
+
164
+ ## Event fields
165
+
166
+ | Argument | Required | Notes |
167
+ |---|---|---|
168
+ | `provider` | yes | `"openai"`, `"anthropic"`, `"google_gemini"` (other strings are passed through). The server accepts the Gemini aliases `gemini`, `google-ai`, `google_ai`; a bare `google` is refused. |
169
+ | `model` | yes | The exact alias you requested. An unpriced model is **stored with an unknown cost**, never an invented price. |
170
+ | `event_id` | yes | Unique per provider request. **This is the idempotency key** (sent as `provider_request_id`). |
171
+ | `feature_key` | yes | Your name for the AI feature. |
172
+ | `input_tokens`, `output_tokens` | no | Omit them if the provider reported no usage — the cost is then unknown, not zero. |
173
+ | `input_audio_tokens` | Gemini 2.5 Flash family | A subset of `input_tokens`; `0` if none. |
174
+ | `customer_ref` | no | Your own id for the end customer (`external_customer_id`). Without it the cost is *Unattributed*. |
175
+ | `stripe_customer_id` | no | The `cus_…` id in your connected Stripe account — the only link between usage and Stripe revenue. |
176
+ | `timestamp` | no | A `datetime` (naive means UTC) or an ISO 8601 string. Defaults to now. Reports show the trailing 30 days by default. |
177
+
178
+ There is no `metadata` field: the ingestion API does not store one.
179
+
180
+ ## Idempotency and retries
181
+
182
+ `event_id` makes every call safe to repeat: the same `event_id` for the same provider is stored once and answered `duplicate`. The SDK therefore retries **only** transient failures — network errors, timeouts and 500/502/503/504 — up to `max_retries` (default 2, max 5) with bounded, jittered exponential backoff, always re-sending the identical body. It never retries 4xx, and never retries 429 (`Retry-After` is 60 s, far longer than a telemetry call should wait): you get a `PrinVoxRateLimitError` with `retry_after` and can resend later **with the same `event_id`**. `max_retries=0` disables retries.
183
+
184
+ There is no automatic `event_id`: a random id would defeat idempotency across your own retries. Use the provider's response id.
185
+
186
+ ## Errors
187
+
188
+ `track` raises `PrinVoxError` or a subclass — never anything carrying your key, the Authorization header or the event body:
189
+
190
+ | Class | `code` | When |
191
+ |---|---|---|
192
+ | `PrinVoxValidationError` | `invalid_event`, `invalid_options` | Rejected locally; nothing was sent. |
193
+ | `PrinVoxValidationError` | `invalid_request` | The server refused it (400 `invalid_body` / `provider_mismatch`, 413, …). See `api_error`. |
194
+ | `PrinVoxAuthenticationError` | `authentication`, `forbidden` | 401 (wrong, missing or replaced key) / 403. |
195
+ | `PrinVoxError` | `payment_required` | 402 — the free evaluation ended. |
196
+ | `PrinVoxError` | `quota_exceeded` | 429 `trial_event_limit` — the evaluation's event quota is used up. Final: subscribe instead of resending. |
197
+ | `PrinVoxRateLimitError` | `rate_limited` | 429 throttle — see `retry_after`; resend later with the same `event_id`. |
198
+ | `PrinVoxError` | `server_error` | 5xx after retries. |
199
+ | `PrinVoxConnectionError` | `network`, `timeout` | No answer. |
200
+ | `PrinVoxError` | `invalid_response` | An answer the SDK cannot interpret (including an unexpected 2xx such as 202/204 from a proxy). The event may be stored; resending the same `event_id` is safe. |
201
+
202
+ Attributes: `code`, `message`, `status_code`, `api_error`, `retry_after`, `request_id`; `to_dict()` is safe to log.
203
+
204
+ ## Options
205
+
206
+ ```python
207
+ PrinVox(
208
+ api_key, # required
209
+ base_url=None, # default https://www.prinvox.com — point it at a Preview environment to test
210
+ timeout=None, # seconds for connecting and for each read, default 10
211
+ max_retries=2,
212
+ retry_delay=0.25, # backoff base in seconds
213
+ http_client=None, # your own httpx.Client (proxies, tests); you keep ownership of it
214
+ )
215
+ ```
216
+
217
+ `base_url` must be `https` (plain `http` only for `localhost`). To try the SDK without touching production data, use a Preview deployment's URL together with that environment's own ingestion key. The client holds a connection pool: reuse one instance, or use it as a context manager (`with PrinVox(...) as client:`). Its `repr()` redacts the key and it cannot be pickled or copied.
218
+
219
+ ## Development
220
+
221
+ ```bash
222
+ python -m venv .venv && . .venv/bin/activate
223
+ pip install -e . pytest ruff mypy build
224
+ pytest && ruff check . && mypy && python -m build
225
+ ```
226
+
227
+ ## Versioning
228
+
229
+ The ingestion API has no version prefix today; the SDK wraps it as it is. New optional fields will be added without breaking existing calls.
230
+
231
+ ## License
232
+
233
+ The PrinVox SDK is licensed under the MIT License. This license applies to this SDK package only and does not license the PrinVox SaaS platform or other proprietary PrinVox source code.
@@ -0,0 +1,9 @@
1
+ prinvox/__init__.py,sha256=CAK9-YWXEMHrLz_w0CBtQMrl6uNzod6AJ9YbgsQJKBY,842
2
+ prinvox/client.py,sha256=ckltOCPwWbuzFwZcTHH_V-WjEXab32B1Oeb6q8GMSYM,18042
3
+ prinvox/errors.py,sha256=jyOfnn39--IHONX_2lqbZnwqaZ0Y7MIJWG0A8_6mrpE,3253
4
+ prinvox/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ prinvox/types.py,sha256=NlWS9K7QUaTfHwn87macHco43_UDwc-_flTful1t-MU,727
6
+ prinvox-0.1.0.dist-info/METADATA,sha256=uI-tsiQDrYydetQmKkHbVAqu2QcCxleSyXn2RWedZdM,11035
7
+ prinvox-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
8
+ prinvox-0.1.0.dist-info/licenses/LICENSE,sha256=P0GTx4L6_nnX5LKgpaa56MaO3MeAIk4d7Gp302yH_Gw,1068
9
+ prinvox-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Amin Sayegh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.