prinvox 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.
- prinvox-0.1.0/.gitignore +8 -0
- prinvox-0.1.0/LICENSE +21 -0
- prinvox-0.1.0/PKG-INFO +233 -0
- prinvox-0.1.0/README.md +212 -0
- prinvox-0.1.0/pyproject.toml +55 -0
- prinvox-0.1.0/src/prinvox/__init__.py +34 -0
- prinvox-0.1.0/src/prinvox/client.py +346 -0
- prinvox-0.1.0/src/prinvox/errors.py +81 -0
- prinvox-0.1.0/src/prinvox/py.typed +0 -0
- prinvox-0.1.0/src/prinvox/types.py +22 -0
prinvox-0.1.0/.gitignore
ADDED
prinvox-0.1.0/LICENSE
ADDED
|
@@ -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.
|
prinvox-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
prinvox-0.1.0/README.md
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# prinvox (Python SDK)
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
> **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.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install prinvox
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
13
|
+
## What it needs — and what it never needs
|
|
14
|
+
|
|
15
|
+
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).
|
|
16
|
+
|
|
17
|
+
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.
|
|
18
|
+
|
|
19
|
+
**Server-side only.** Never put `PRINVOX_INGESTION_KEY` in browser code, a frontend bundle or a mobile app.
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import os
|
|
25
|
+
from prinvox import PrinVox
|
|
26
|
+
|
|
27
|
+
client = PrinVox(api_key=os.environ["PRINVOX_INGESTION_KEY"])
|
|
28
|
+
|
|
29
|
+
result = client.track(
|
|
30
|
+
provider="openai",
|
|
31
|
+
model="gpt-4.1",
|
|
32
|
+
event_id=response.id, # idempotency key, see below
|
|
33
|
+
feature_key="assistant",
|
|
34
|
+
customer_ref="cus_123",
|
|
35
|
+
input_tokens=1000,
|
|
36
|
+
output_tokens=500,
|
|
37
|
+
)
|
|
38
|
+
print(result.status) # "processed" or "duplicate"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`track` takes keyword arguments only, returns a `TrackResult` or raises a [`PrinVoxError`](#errors).
|
|
42
|
+
|
|
43
|
+
### OpenAI
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from openai import OpenAI
|
|
47
|
+
from prinvox import PrinVox
|
|
48
|
+
|
|
49
|
+
openai_client = OpenAI() # your own key, PrinVox never sees it
|
|
50
|
+
prinvox = PrinVox(api_key=os.environ["PRINVOX_INGESTION_KEY_OPENAI"])
|
|
51
|
+
MODEL = "gpt-4.1-mini" # send the alias you requested, not a dated snapshot name
|
|
52
|
+
|
|
53
|
+
response = openai_client.responses.create(model=MODEL, input="Summarize: ...")
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
prinvox.track(
|
|
57
|
+
provider="openai",
|
|
58
|
+
model=MODEL,
|
|
59
|
+
event_id=response.id,
|
|
60
|
+
feature_key="summarize",
|
|
61
|
+
customer_ref=app_customer_id,
|
|
62
|
+
input_tokens=response.usage.input_tokens if response.usage else None,
|
|
63
|
+
output_tokens=response.usage.output_tokens if response.usage else None,
|
|
64
|
+
)
|
|
65
|
+
except Exception:
|
|
66
|
+
pass # telemetry must never break your product (log it if you like)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Anthropic
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
import anthropic
|
|
73
|
+
|
|
74
|
+
claude = anthropic.Anthropic() # your own key
|
|
75
|
+
message = claude.messages.create(model="claude-haiku-4-5", max_tokens=512, messages=[{"role": "user", "content": question}])
|
|
76
|
+
|
|
77
|
+
prinvox.track( # a PrinVox client created with the ingestion key for Anthropic
|
|
78
|
+
provider="anthropic",
|
|
79
|
+
model="claude-haiku-4-5",
|
|
80
|
+
event_id=message.id,
|
|
81
|
+
feature_key="support-answer",
|
|
82
|
+
customer_ref=app_customer_id,
|
|
83
|
+
input_tokens=message.usage.input_tokens,
|
|
84
|
+
output_tokens=message.usage.output_tokens,
|
|
85
|
+
)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Google Gemini
|
|
89
|
+
|
|
90
|
+
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.
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
import uuid
|
|
94
|
+
from google import genai
|
|
95
|
+
|
|
96
|
+
gemini = genai.Client() # your own key
|
|
97
|
+
response = gemini.models.generate_content(model="gemini-2.5-flash", contents=prompt)
|
|
98
|
+
u = response.usage_metadata
|
|
99
|
+
audio = sum((d.token_count or 0) for d in (u.prompt_tokens_details or []) if str(d.modality).endswith("AUDIO"))
|
|
100
|
+
|
|
101
|
+
prinvox.track( # a PrinVox client created with the ingestion key for Google Gemini
|
|
102
|
+
provider="google_gemini",
|
|
103
|
+
model="gemini-2.5-flash",
|
|
104
|
+
event_id=response.response_id or str(uuid.uuid4()),
|
|
105
|
+
feature_key="ai-chat",
|
|
106
|
+
customer_ref=app_customer_id,
|
|
107
|
+
input_tokens=u.prompt_token_count,
|
|
108
|
+
input_audio_tokens=audio,
|
|
109
|
+
output_tokens=(u.candidates_token_count or 0) + (u.thoughts_token_count or 0),
|
|
110
|
+
)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### FastAPI (server-side)
|
|
114
|
+
|
|
115
|
+
`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.
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
import os
|
|
119
|
+
from fastapi import BackgroundTasks, FastAPI
|
|
120
|
+
from prinvox import PrinVox
|
|
121
|
+
|
|
122
|
+
app = FastAPI()
|
|
123
|
+
prinvox = PrinVox(api_key=os.environ["PRINVOX_INGESTION_KEY_OPENAI"]) # read from the server's environment only
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def report(response_id: str, customer_id: str, usage) -> None:
|
|
127
|
+
try:
|
|
128
|
+
prinvox.track(
|
|
129
|
+
provider="openai", model="gpt-4.1-mini", event_id=response_id, feature_key="chat", customer_ref=customer_id,
|
|
130
|
+
input_tokens=usage.input_tokens, output_tokens=usage.output_tokens,
|
|
131
|
+
)
|
|
132
|
+
except Exception:
|
|
133
|
+
pass # never fail the user's request over telemetry
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@app.post("/chat")
|
|
137
|
+
def chat(prompt: str, customer_id: str, background: BackgroundTasks):
|
|
138
|
+
response = openai_client.responses.create(model="gpt-4.1-mini", input=prompt)
|
|
139
|
+
background.add_task(report, response.id, customer_id, response.usage)
|
|
140
|
+
return {"text": response.output_text}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Event fields
|
|
144
|
+
|
|
145
|
+
| Argument | Required | Notes |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| `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. |
|
|
148
|
+
| `model` | yes | The exact alias you requested. An unpriced model is **stored with an unknown cost**, never an invented price. |
|
|
149
|
+
| `event_id` | yes | Unique per provider request. **This is the idempotency key** (sent as `provider_request_id`). |
|
|
150
|
+
| `feature_key` | yes | Your name for the AI feature. |
|
|
151
|
+
| `input_tokens`, `output_tokens` | no | Omit them if the provider reported no usage — the cost is then unknown, not zero. |
|
|
152
|
+
| `input_audio_tokens` | Gemini 2.5 Flash family | A subset of `input_tokens`; `0` if none. |
|
|
153
|
+
| `customer_ref` | no | Your own id for the end customer (`external_customer_id`). Without it the cost is *Unattributed*. |
|
|
154
|
+
| `stripe_customer_id` | no | The `cus_…` id in your connected Stripe account — the only link between usage and Stripe revenue. |
|
|
155
|
+
| `timestamp` | no | A `datetime` (naive means UTC) or an ISO 8601 string. Defaults to now. Reports show the trailing 30 days by default. |
|
|
156
|
+
|
|
157
|
+
There is no `metadata` field: the ingestion API does not store one.
|
|
158
|
+
|
|
159
|
+
## Idempotency and retries
|
|
160
|
+
|
|
161
|
+
`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.
|
|
162
|
+
|
|
163
|
+
There is no automatic `event_id`: a random id would defeat idempotency across your own retries. Use the provider's response id.
|
|
164
|
+
|
|
165
|
+
## Errors
|
|
166
|
+
|
|
167
|
+
`track` raises `PrinVoxError` or a subclass — never anything carrying your key, the Authorization header or the event body:
|
|
168
|
+
|
|
169
|
+
| Class | `code` | When |
|
|
170
|
+
|---|---|---|
|
|
171
|
+
| `PrinVoxValidationError` | `invalid_event`, `invalid_options` | Rejected locally; nothing was sent. |
|
|
172
|
+
| `PrinVoxValidationError` | `invalid_request` | The server refused it (400 `invalid_body` / `provider_mismatch`, 413, …). See `api_error`. |
|
|
173
|
+
| `PrinVoxAuthenticationError` | `authentication`, `forbidden` | 401 (wrong, missing or replaced key) / 403. |
|
|
174
|
+
| `PrinVoxError` | `payment_required` | 402 — the free evaluation ended. |
|
|
175
|
+
| `PrinVoxError` | `quota_exceeded` | 429 `trial_event_limit` — the evaluation's event quota is used up. Final: subscribe instead of resending. |
|
|
176
|
+
| `PrinVoxRateLimitError` | `rate_limited` | 429 throttle — see `retry_after`; resend later with the same `event_id`. |
|
|
177
|
+
| `PrinVoxError` | `server_error` | 5xx after retries. |
|
|
178
|
+
| `PrinVoxConnectionError` | `network`, `timeout` | No answer. |
|
|
179
|
+
| `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. |
|
|
180
|
+
|
|
181
|
+
Attributes: `code`, `message`, `status_code`, `api_error`, `retry_after`, `request_id`; `to_dict()` is safe to log.
|
|
182
|
+
|
|
183
|
+
## Options
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
PrinVox(
|
|
187
|
+
api_key, # required
|
|
188
|
+
base_url=None, # default https://www.prinvox.com — point it at a Preview environment to test
|
|
189
|
+
timeout=None, # seconds for connecting and for each read, default 10
|
|
190
|
+
max_retries=2,
|
|
191
|
+
retry_delay=0.25, # backoff base in seconds
|
|
192
|
+
http_client=None, # your own httpx.Client (proxies, tests); you keep ownership of it
|
|
193
|
+
)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
`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.
|
|
197
|
+
|
|
198
|
+
## Development
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
python -m venv .venv && . .venv/bin/activate
|
|
202
|
+
pip install -e . pytest ruff mypy build
|
|
203
|
+
pytest && ruff check . && mypy && python -m build
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Versioning
|
|
207
|
+
|
|
208
|
+
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.
|
|
209
|
+
|
|
210
|
+
## License
|
|
211
|
+
|
|
212
|
+
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,55 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"] # 1.27+ ships license-files as dist-info/licenses/ (PEP 639)
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "prinvox"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official PrinVox Margin Guard SDK: report AI usage events (OpenAI, Anthropic, Google Gemini) from your Python server."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = ["httpx>=0.27,<1"]
|
|
12
|
+
keywords = ["prinvox", "margin-guard", "ai", "llm", "usage", "cost", "gross-margin", "openai", "anthropic", "gemini"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
# MIT applies to THIS package only (see LICENSE). It does not license the PrinVox SaaS platform or any other proprietary PrinVox source.
|
|
24
|
+
license = { text = "MIT" }
|
|
25
|
+
license-files = ["LICENSE"]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://www.prinvox.com"
|
|
29
|
+
Documentation = "https://www.prinvox.com/en/docs"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/prinvox"]
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.sdist]
|
|
35
|
+
include = ["src/prinvox", "README.md", "LICENSE", "pyproject.toml"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
testpaths = ["tests"]
|
|
39
|
+
addopts = "-q"
|
|
40
|
+
|
|
41
|
+
[tool.ruff]
|
|
42
|
+
line-length = 170
|
|
43
|
+
target-version = "py310"
|
|
44
|
+
src = ["src", "tests"]
|
|
45
|
+
|
|
46
|
+
[tool.ruff.lint]
|
|
47
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
48
|
+
|
|
49
|
+
[tool.ruff.lint.per-file-ignores]
|
|
50
|
+
"scripts/*" = ["E501"] # a throwaway Preview smoke script: long call lines are clearer than wrapped ones
|
|
51
|
+
|
|
52
|
+
[tool.mypy]
|
|
53
|
+
python_version = "3.10"
|
|
54
|
+
strict = true
|
|
55
|
+
files = ["src/prinvox"]
|
|
@@ -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
|
+
]
|
|
@@ -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)
|
|
@@ -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"``)."""
|
|
File without changes
|
|
@@ -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"
|