botzone-cost 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.
- botzone_cost-0.1.0/LICENSE +21 -0
- botzone_cost-0.1.0/PKG-INFO +85 -0
- botzone_cost-0.1.0/README.md +55 -0
- botzone_cost-0.1.0/botzone_cost/__init__.py +6 -0
- botzone_cost-0.1.0/botzone_cost/_wrap.py +201 -0
- botzone_cost-0.1.0/botzone_cost/queue.py +75 -0
- botzone_cost-0.1.0/botzone_cost.egg-info/PKG-INFO +85 -0
- botzone_cost-0.1.0/botzone_cost.egg-info/SOURCES.txt +12 -0
- botzone_cost-0.1.0/botzone_cost.egg-info/dependency_links.txt +1 -0
- botzone_cost-0.1.0/botzone_cost.egg-info/requires.txt +14 -0
- botzone_cost-0.1.0/botzone_cost.egg-info/top_level.txt +1 -0
- botzone_cost-0.1.0/pyproject.toml +36 -0
- botzone_cost-0.1.0/setup.cfg +4 -0
- botzone_cost-0.1.0/tests/test_wrap.py +117 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Botzone
|
|
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.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: botzone-cost
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cost-tracking SDK for Anthropic, OpenAI, and Gemini Python clients
|
|
5
|
+
Author-email: Botzone <hello@botzone.ai>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/botzone-ai/cost-sdk-py
|
|
8
|
+
Project-URL: Repository, https://github.com/botzone-ai/cost-sdk-py
|
|
9
|
+
Project-URL: Issues, https://github.com/botzone-ai/cost-sdk-py/issues
|
|
10
|
+
Keywords: llm,cost-tracking,anthropic,openai,gemini
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: httpx>=0.25
|
|
20
|
+
Provides-Extra: anthropic
|
|
21
|
+
Requires-Dist: anthropic>=0.30; extra == "anthropic"
|
|
22
|
+
Provides-Extra: openai
|
|
23
|
+
Requires-Dist: openai>=1.0; extra == "openai"
|
|
24
|
+
Provides-Extra: gemini
|
|
25
|
+
Requires-Dist: google-generativeai>=0.5; extra == "gemini"
|
|
26
|
+
Provides-Extra: test
|
|
27
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "test"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# botzone-cost
|
|
32
|
+
|
|
33
|
+
Cost-tracking SDK for Anthropic, OpenAI, and Gemini Python clients. Wrap your
|
|
34
|
+
existing client; per-call usage flows to your Cost dashboard. Adds zero
|
|
35
|
+
measurable latency to the host call.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
pip install botzone-cost
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from anthropic import Anthropic
|
|
47
|
+
from botzone_cost import wrap
|
|
48
|
+
|
|
49
|
+
client = wrap(Anthropic(), api_key="cost_sk_...", route="follow-up-draft")
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Same surface for OpenAI and Gemini:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from openai import OpenAI
|
|
56
|
+
import google.generativeai as genai
|
|
57
|
+
from botzone_cost import wrap
|
|
58
|
+
|
|
59
|
+
openai_client = wrap(OpenAI(), route="summariser")
|
|
60
|
+
gemini = wrap(genai.GenerativeModel("gemini-2.5-flash"), route="classifier")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Options
|
|
64
|
+
|
|
65
|
+
| arg | default |
|
|
66
|
+
| ---------------- | ------------------------------------------------ |
|
|
67
|
+
| `api_key` | env `COST_API_KEY` |
|
|
68
|
+
| `endpoint` | env `COST_ENDPOINT` or `https://cost.botzone.ai` |
|
|
69
|
+
| `route` | (none: strongly recommended) |
|
|
70
|
+
| `user_id` | (sha256-hashed in the SDK before send) |
|
|
71
|
+
| `feature_tag` | (none) |
|
|
72
|
+
| `enabled` | `True` |
|
|
73
|
+
| `capture_bodies` | `False` (reserved, no effect today, see below) |
|
|
74
|
+
|
|
75
|
+
## What gets captured
|
|
76
|
+
|
|
77
|
+
Token counts (including Anthropic prompt-cache reads / writes and OpenAI cached
|
|
78
|
+
prompt tokens), latency, model, route, user id (hashed), feature tag. Computed
|
|
79
|
+
USD cost is added server-side from the live pricing table. The Python SDK is
|
|
80
|
+
**metadata-only today**: it does not send raw request or response bodies, and
|
|
81
|
+
the `capture_bodies` parameter is reserved for future parity with the
|
|
82
|
+
TypeScript SDK.
|
|
83
|
+
|
|
84
|
+
End-user identifiers passed via `user_id` are SHA-256 hashed in the SDK before
|
|
85
|
+
send; the plaintext never leaves your process.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# botzone-cost
|
|
2
|
+
|
|
3
|
+
Cost-tracking SDK for Anthropic, OpenAI, and Gemini Python clients. Wrap your
|
|
4
|
+
existing client; per-call usage flows to your Cost dashboard. Adds zero
|
|
5
|
+
measurable latency to the host call.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
pip install botzone-cost
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from anthropic import Anthropic
|
|
17
|
+
from botzone_cost import wrap
|
|
18
|
+
|
|
19
|
+
client = wrap(Anthropic(), api_key="cost_sk_...", route="follow-up-draft")
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Same surface for OpenAI and Gemini:
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from openai import OpenAI
|
|
26
|
+
import google.generativeai as genai
|
|
27
|
+
from botzone_cost import wrap
|
|
28
|
+
|
|
29
|
+
openai_client = wrap(OpenAI(), route="summariser")
|
|
30
|
+
gemini = wrap(genai.GenerativeModel("gemini-2.5-flash"), route="classifier")
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Options
|
|
34
|
+
|
|
35
|
+
| arg | default |
|
|
36
|
+
| ---------------- | ------------------------------------------------ |
|
|
37
|
+
| `api_key` | env `COST_API_KEY` |
|
|
38
|
+
| `endpoint` | env `COST_ENDPOINT` or `https://cost.botzone.ai` |
|
|
39
|
+
| `route` | (none: strongly recommended) |
|
|
40
|
+
| `user_id` | (sha256-hashed in the SDK before send) |
|
|
41
|
+
| `feature_tag` | (none) |
|
|
42
|
+
| `enabled` | `True` |
|
|
43
|
+
| `capture_bodies` | `False` (reserved, no effect today, see below) |
|
|
44
|
+
|
|
45
|
+
## What gets captured
|
|
46
|
+
|
|
47
|
+
Token counts (including Anthropic prompt-cache reads / writes and OpenAI cached
|
|
48
|
+
prompt tokens), latency, model, route, user id (hashed), feature tag. Computed
|
|
49
|
+
USD cost is added server-side from the live pricing table. The Python SDK is
|
|
50
|
+
**metadata-only today**: it does not send raw request or response bodies, and
|
|
51
|
+
the `capture_bodies` parameter is reserved for future parity with the
|
|
52
|
+
TypeScript SDK.
|
|
53
|
+
|
|
54
|
+
End-user identifiers passed via `user_id` are SHA-256 hashed in the SDK before
|
|
55
|
+
send; the plaintext never leaves your process.
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""wrap() entry point. Auto-detects Anthropic / OpenAI / Gemini clients."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
from .queue import IngestionQueue
|
|
11
|
+
|
|
12
|
+
_queues: dict[str, IngestionQueue] = {}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _get_queue(api_key: Optional[str], endpoint: Optional[str], enabled: bool) -> Optional[IngestionQueue]:
|
|
16
|
+
if not enabled:
|
|
17
|
+
return None
|
|
18
|
+
api_key = api_key or os.environ.get("COST_API_KEY")
|
|
19
|
+
if not api_key:
|
|
20
|
+
return None
|
|
21
|
+
endpoint = endpoint or os.environ.get("COST_ENDPOINT", "https://cost.botzone.ai")
|
|
22
|
+
key = f"{api_key}|{endpoint}"
|
|
23
|
+
q = _queues.get(key)
|
|
24
|
+
if not q:
|
|
25
|
+
q = IngestionQueue(api_key=api_key, endpoint=endpoint)
|
|
26
|
+
_queues[key] = q
|
|
27
|
+
return q
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _sha256(s: str) -> str:
|
|
31
|
+
return hashlib.sha256(s.encode("utf-8")).hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _now() -> str:
|
|
35
|
+
return datetime.now(timezone.utc).isoformat()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def wrap(
|
|
39
|
+
client: Any,
|
|
40
|
+
*,
|
|
41
|
+
api_key: Optional[str] = None,
|
|
42
|
+
endpoint: Optional[str] = None,
|
|
43
|
+
route: Optional[str] = None,
|
|
44
|
+
user_id: Optional[str] = None,
|
|
45
|
+
feature_tag: Optional[str] = None,
|
|
46
|
+
enabled: bool = True,
|
|
47
|
+
capture_bodies: bool = False,
|
|
48
|
+
provider: Optional[str] = None,
|
|
49
|
+
) -> Any:
|
|
50
|
+
"""Wrap an LLM client to capture cost-tracking events.
|
|
51
|
+
|
|
52
|
+
Returns the same client (mutated) for chaining. Raises ValueError if
|
|
53
|
+
the provider can't be detected.
|
|
54
|
+
|
|
55
|
+
The Python SDK is metadata-only today: it ships token counts, model,
|
|
56
|
+
route, latency, hashed user id, and feature tag. It does not send raw
|
|
57
|
+
request or response bodies. The ``capture_bodies`` keyword is reserved
|
|
58
|
+
for future parity with the TypeScript SDK and has no effect today.
|
|
59
|
+
"""
|
|
60
|
+
queue = _get_queue(api_key, endpoint, enabled)
|
|
61
|
+
user_hash = _sha256(user_id) if user_id else None
|
|
62
|
+
|
|
63
|
+
detected = provider or _detect(client)
|
|
64
|
+
if detected == "anthropic":
|
|
65
|
+
return _wrap_anthropic(client, queue, route, user_hash, feature_tag, capture_bodies)
|
|
66
|
+
if detected == "openai":
|
|
67
|
+
return _wrap_openai(client, queue, route, user_hash, feature_tag, capture_bodies)
|
|
68
|
+
if detected == "gemini":
|
|
69
|
+
return _wrap_gemini(client, queue, route, user_hash, feature_tag, capture_bodies)
|
|
70
|
+
raise ValueError(
|
|
71
|
+
"[botzone-cost] could not detect provider: pass provider='anthropic'|'openai'|'gemini'"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _detect(client: Any) -> Optional[str]:
|
|
76
|
+
# Prefer module-path detection so auto-mocks don't all look like Anthropic.
|
|
77
|
+
module = getattr(type(client), "__module__", "") or ""
|
|
78
|
+
if module.startswith("anthropic"):
|
|
79
|
+
return "anthropic"
|
|
80
|
+
if module.startswith("openai"):
|
|
81
|
+
return "openai"
|
|
82
|
+
if module.startswith("google.generativeai") or module.startswith("google.genai"):
|
|
83
|
+
return "gemini"
|
|
84
|
+
# Fallback duck-typing for thin wrappers that don't carry the module name.
|
|
85
|
+
if hasattr(client, "messages") and hasattr(client.messages, "create"):
|
|
86
|
+
return "anthropic"
|
|
87
|
+
if hasattr(client, "chat") and hasattr(client.chat, "completions"):
|
|
88
|
+
return "openai"
|
|
89
|
+
if hasattr(client, "generate_content"):
|
|
90
|
+
return "gemini"
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _emit(queue: Optional[IngestionQueue], event: dict) -> None:
|
|
95
|
+
if queue is not None:
|
|
96
|
+
queue.enqueue(event)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _wrap_anthropic(client: Any, queue, route, user_hash, feature_tag, capture_bodies):
|
|
100
|
+
original = client.messages.create
|
|
101
|
+
|
|
102
|
+
def wrapped(*args, **kwargs):
|
|
103
|
+
start = time.time()
|
|
104
|
+
result = original(*args, **kwargs)
|
|
105
|
+
latency_ms = int((time.time() - start) * 1000)
|
|
106
|
+
if queue is not None:
|
|
107
|
+
usage = getattr(result, "usage", None)
|
|
108
|
+
input_tokens = getattr(usage, "input_tokens", 0) or 0
|
|
109
|
+
output_tokens = getattr(usage, "output_tokens", 0) or 0
|
|
110
|
+
cached = getattr(usage, "cache_read_input_tokens", 0) or 0
|
|
111
|
+
cache_creation = getattr(usage, "cache_creation_input_tokens", 0) or 0
|
|
112
|
+
_emit(queue, {
|
|
113
|
+
"provider": "anthropic",
|
|
114
|
+
"model": kwargs.get("model") or getattr(result, "model", "unknown"),
|
|
115
|
+
"promptTokens": input_tokens,
|
|
116
|
+
"completionTokens": output_tokens,
|
|
117
|
+
"cachedTokens": cached,
|
|
118
|
+
"cacheCreationTokens": cache_creation,
|
|
119
|
+
"latencyMs": latency_ms,
|
|
120
|
+
"route": route,
|
|
121
|
+
"userIdHash": user_hash,
|
|
122
|
+
"featureTag": feature_tag,
|
|
123
|
+
"occurredAt": _now(),
|
|
124
|
+
})
|
|
125
|
+
return result
|
|
126
|
+
|
|
127
|
+
client.messages.create = wrapped
|
|
128
|
+
return client
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _wrap_openai(client: Any, queue, route, user_hash, feature_tag, capture_bodies):
|
|
132
|
+
original = client.chat.completions.create
|
|
133
|
+
|
|
134
|
+
def wrapped(*args, **kwargs):
|
|
135
|
+
start = time.time()
|
|
136
|
+
result = original(*args, **kwargs)
|
|
137
|
+
latency_ms = int((time.time() - start) * 1000)
|
|
138
|
+
if queue is not None:
|
|
139
|
+
usage = getattr(result, "usage", None)
|
|
140
|
+
prompt_tokens = getattr(usage, "prompt_tokens", 0) or 0
|
|
141
|
+
completion_tokens = getattr(usage, "completion_tokens", 0) or 0
|
|
142
|
+
details = getattr(usage, "prompt_tokens_details", None)
|
|
143
|
+
cached = getattr(details, "cached_tokens", 0) if details else 0
|
|
144
|
+
_emit(queue, {
|
|
145
|
+
"provider": "openai",
|
|
146
|
+
"model": kwargs.get("model") or getattr(result, "model", "unknown"),
|
|
147
|
+
"promptTokens": prompt_tokens,
|
|
148
|
+
"completionTokens": completion_tokens,
|
|
149
|
+
"cachedTokens": cached or 0,
|
|
150
|
+
"cacheCreationTokens": 0,
|
|
151
|
+
"latencyMs": latency_ms,
|
|
152
|
+
"route": route,
|
|
153
|
+
"userIdHash": user_hash,
|
|
154
|
+
"featureTag": feature_tag,
|
|
155
|
+
"occurredAt": _now(),
|
|
156
|
+
})
|
|
157
|
+
return result
|
|
158
|
+
|
|
159
|
+
client.chat.completions.create = wrapped
|
|
160
|
+
return client
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _wrap_gemini(model: Any, queue, route, user_hash, feature_tag, capture_bodies):
|
|
164
|
+
"""Wraps a `google.generativeai.GenerativeModel` instance directly."""
|
|
165
|
+
original = model.generate_content
|
|
166
|
+
model_name = getattr(model, "model_name", None) or getattr(model, "_model_name", "unknown")
|
|
167
|
+
if isinstance(model_name, str) and model_name.startswith("models/"):
|
|
168
|
+
model_name = model_name[len("models/"):]
|
|
169
|
+
|
|
170
|
+
def wrapped(*args, **kwargs):
|
|
171
|
+
start = time.time()
|
|
172
|
+
result = original(*args, **kwargs)
|
|
173
|
+
latency_ms = int((time.time() - start) * 1000)
|
|
174
|
+
if queue is not None:
|
|
175
|
+
usage = getattr(result, "usage_metadata", None)
|
|
176
|
+
prompt = getattr(usage, "prompt_token_count", 0) if usage else 0
|
|
177
|
+
completion = getattr(usage, "candidates_token_count", 0) if usage else 0
|
|
178
|
+
cached = getattr(usage, "cached_content_token_count", 0) if usage else 0
|
|
179
|
+
_emit(queue, {
|
|
180
|
+
"provider": "gemini",
|
|
181
|
+
"model": model_name,
|
|
182
|
+
"promptTokens": prompt or 0,
|
|
183
|
+
"completionTokens": completion or 0,
|
|
184
|
+
"cachedTokens": cached or 0,
|
|
185
|
+
"cacheCreationTokens": 0,
|
|
186
|
+
"latencyMs": latency_ms,
|
|
187
|
+
"route": route,
|
|
188
|
+
"userIdHash": user_hash,
|
|
189
|
+
"featureTag": feature_tag,
|
|
190
|
+
"occurredAt": _now(),
|
|
191
|
+
})
|
|
192
|
+
return result
|
|
193
|
+
|
|
194
|
+
model.generate_content = wrapped
|
|
195
|
+
return model
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def flush() -> None:
|
|
199
|
+
"""Block until all pending events are sent. Useful in scripts before exit."""
|
|
200
|
+
for q in _queues.values():
|
|
201
|
+
q.flush()
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Background ingestion queue.
|
|
2
|
+
|
|
3
|
+
Fire-and-forget: enqueue() returns immediately. A daemon thread flushes the
|
|
4
|
+
buffer every 2 seconds (and on process exit). Drops events past a 1000-item
|
|
5
|
+
cap rather than leak memory.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import atexit
|
|
10
|
+
import json
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class IngestionQueue:
|
|
19
|
+
def __init__(self, api_key: str, endpoint: str, *, http: Optional[httpx.Client] = None):
|
|
20
|
+
self._api_key = api_key
|
|
21
|
+
self._endpoint = endpoint.rstrip("/")
|
|
22
|
+
self._buf: list[dict] = []
|
|
23
|
+
self._lock = threading.Lock()
|
|
24
|
+
self._stop = threading.Event()
|
|
25
|
+
self._dropped = 0
|
|
26
|
+
self._http = http or httpx.Client(timeout=5.0)
|
|
27
|
+
self._thread = threading.Thread(target=self._loop, daemon=True)
|
|
28
|
+
self._thread.start()
|
|
29
|
+
atexit.register(self._on_exit)
|
|
30
|
+
|
|
31
|
+
def enqueue(self, event: dict) -> None:
|
|
32
|
+
with self._lock:
|
|
33
|
+
if len(self._buf) >= 1000:
|
|
34
|
+
self._dropped += 1
|
|
35
|
+
return
|
|
36
|
+
self._buf.append(event)
|
|
37
|
+
|
|
38
|
+
def flush(self) -> None:
|
|
39
|
+
with self._lock:
|
|
40
|
+
batch, self._buf = self._buf, []
|
|
41
|
+
if not batch:
|
|
42
|
+
return
|
|
43
|
+
for chunk_start in range(0, len(batch), 50):
|
|
44
|
+
chunk = batch[chunk_start : chunk_start + 50]
|
|
45
|
+
self._send(chunk)
|
|
46
|
+
|
|
47
|
+
def dropped_count(self) -> int:
|
|
48
|
+
return self._dropped
|
|
49
|
+
|
|
50
|
+
def _send(self, batch: list[dict], attempt: int = 0) -> None:
|
|
51
|
+
try:
|
|
52
|
+
self._http.post(
|
|
53
|
+
f"{self._endpoint}/api/v1/events",
|
|
54
|
+
headers={"x-api-key": self._api_key, "content-type": "application/json"},
|
|
55
|
+
content=json.dumps({"events": batch}),
|
|
56
|
+
)
|
|
57
|
+
except Exception:
|
|
58
|
+
if attempt < 3:
|
|
59
|
+
time.sleep(0.2 * (2 ** attempt))
|
|
60
|
+
self._send(batch, attempt + 1)
|
|
61
|
+
|
|
62
|
+
def _loop(self) -> None:
|
|
63
|
+
while not self._stop.is_set():
|
|
64
|
+
self._stop.wait(2.0)
|
|
65
|
+
try:
|
|
66
|
+
self.flush()
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
def _on_exit(self) -> None:
|
|
71
|
+
self._stop.set()
|
|
72
|
+
try:
|
|
73
|
+
self.flush()
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: botzone-cost
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cost-tracking SDK for Anthropic, OpenAI, and Gemini Python clients
|
|
5
|
+
Author-email: Botzone <hello@botzone.ai>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/botzone-ai/cost-sdk-py
|
|
8
|
+
Project-URL: Repository, https://github.com/botzone-ai/cost-sdk-py
|
|
9
|
+
Project-URL: Issues, https://github.com/botzone-ai/cost-sdk-py/issues
|
|
10
|
+
Keywords: llm,cost-tracking,anthropic,openai,gemini
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: httpx>=0.25
|
|
20
|
+
Provides-Extra: anthropic
|
|
21
|
+
Requires-Dist: anthropic>=0.30; extra == "anthropic"
|
|
22
|
+
Provides-Extra: openai
|
|
23
|
+
Requires-Dist: openai>=1.0; extra == "openai"
|
|
24
|
+
Provides-Extra: gemini
|
|
25
|
+
Requires-Dist: google-generativeai>=0.5; extra == "gemini"
|
|
26
|
+
Provides-Extra: test
|
|
27
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "test"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# botzone-cost
|
|
32
|
+
|
|
33
|
+
Cost-tracking SDK for Anthropic, OpenAI, and Gemini Python clients. Wrap your
|
|
34
|
+
existing client; per-call usage flows to your Cost dashboard. Adds zero
|
|
35
|
+
measurable latency to the host call.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
pip install botzone-cost
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from anthropic import Anthropic
|
|
47
|
+
from botzone_cost import wrap
|
|
48
|
+
|
|
49
|
+
client = wrap(Anthropic(), api_key="cost_sk_...", route="follow-up-draft")
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Same surface for OpenAI and Gemini:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from openai import OpenAI
|
|
56
|
+
import google.generativeai as genai
|
|
57
|
+
from botzone_cost import wrap
|
|
58
|
+
|
|
59
|
+
openai_client = wrap(OpenAI(), route="summariser")
|
|
60
|
+
gemini = wrap(genai.GenerativeModel("gemini-2.5-flash"), route="classifier")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Options
|
|
64
|
+
|
|
65
|
+
| arg | default |
|
|
66
|
+
| ---------------- | ------------------------------------------------ |
|
|
67
|
+
| `api_key` | env `COST_API_KEY` |
|
|
68
|
+
| `endpoint` | env `COST_ENDPOINT` or `https://cost.botzone.ai` |
|
|
69
|
+
| `route` | (none: strongly recommended) |
|
|
70
|
+
| `user_id` | (sha256-hashed in the SDK before send) |
|
|
71
|
+
| `feature_tag` | (none) |
|
|
72
|
+
| `enabled` | `True` |
|
|
73
|
+
| `capture_bodies` | `False` (reserved, no effect today, see below) |
|
|
74
|
+
|
|
75
|
+
## What gets captured
|
|
76
|
+
|
|
77
|
+
Token counts (including Anthropic prompt-cache reads / writes and OpenAI cached
|
|
78
|
+
prompt tokens), latency, model, route, user id (hashed), feature tag. Computed
|
|
79
|
+
USD cost is added server-side from the live pricing table. The Python SDK is
|
|
80
|
+
**metadata-only today**: it does not send raw request or response bodies, and
|
|
81
|
+
the `capture_bodies` parameter is reserved for future parity with the
|
|
82
|
+
TypeScript SDK.
|
|
83
|
+
|
|
84
|
+
End-user identifiers passed via `user_id` are SHA-256 hashed in the SDK before
|
|
85
|
+
send; the plaintext never leaves your process.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
botzone_cost/__init__.py
|
|
5
|
+
botzone_cost/_wrap.py
|
|
6
|
+
botzone_cost/queue.py
|
|
7
|
+
botzone_cost.egg-info/PKG-INFO
|
|
8
|
+
botzone_cost.egg-info/SOURCES.txt
|
|
9
|
+
botzone_cost.egg-info/dependency_links.txt
|
|
10
|
+
botzone_cost.egg-info/requires.txt
|
|
11
|
+
botzone_cost.egg-info/top_level.txt
|
|
12
|
+
tests/test_wrap.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
botzone_cost
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "botzone-cost"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Cost-tracking SDK for Anthropic, OpenAI, and Gemini Python clients"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Botzone", email = "hello@botzone.ai" }]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
dependencies = ["httpx>=0.25"]
|
|
14
|
+
keywords = ["llm", "cost-tracking", "anthropic", "openai", "gemini"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
19
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/botzone-ai/cost-sdk-py"
|
|
25
|
+
Repository = "https://github.com/botzone-ai/cost-sdk-py"
|
|
26
|
+
Issues = "https://github.com/botzone-ai/cost-sdk-py/issues"
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
anthropic = ["anthropic>=0.30"]
|
|
30
|
+
openai = ["openai>=1.0"]
|
|
31
|
+
gemini = ["google-generativeai>=0.5"]
|
|
32
|
+
test = ["pytest>=7", "pytest-asyncio>=0.23"]
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.packages.find]
|
|
35
|
+
where = ["."]
|
|
36
|
+
include = ["botzone_cost*"]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Unit tests for botzone_cost.wrap (no real LLM clients required)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
from unittest.mock import MagicMock, patch
|
|
7
|
+
|
|
8
|
+
from botzone_cost import wrap, flush
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _fake_anthropic(input_tokens=100, output_tokens=50, cached=80):
|
|
12
|
+
client = MagicMock()
|
|
13
|
+
client.messages.create.return_value = SimpleNamespace(
|
|
14
|
+
model="claude-sonnet-4-6",
|
|
15
|
+
usage=SimpleNamespace(
|
|
16
|
+
input_tokens=input_tokens,
|
|
17
|
+
output_tokens=output_tokens,
|
|
18
|
+
cache_read_input_tokens=cached,
|
|
19
|
+
cache_creation_input_tokens=0,
|
|
20
|
+
),
|
|
21
|
+
)
|
|
22
|
+
return client
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _fake_openai(prompt_tokens=200, completion_tokens=75, cached=50):
|
|
26
|
+
client = MagicMock()
|
|
27
|
+
client.chat.completions.create.return_value = SimpleNamespace(
|
|
28
|
+
model="gpt-4o-mini",
|
|
29
|
+
usage=SimpleNamespace(
|
|
30
|
+
prompt_tokens=prompt_tokens,
|
|
31
|
+
completion_tokens=completion_tokens,
|
|
32
|
+
prompt_tokens_details=SimpleNamespace(cached_tokens=cached),
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
return client
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _fake_gemini(prompt=300, completion=100, cached=0):
|
|
39
|
+
model = MagicMock()
|
|
40
|
+
model.model_name = "gemini-2.5-flash"
|
|
41
|
+
model.generate_content.return_value = SimpleNamespace(
|
|
42
|
+
usage_metadata=SimpleNamespace(
|
|
43
|
+
prompt_token_count=prompt,
|
|
44
|
+
candidates_token_count=completion,
|
|
45
|
+
cached_content_token_count=cached,
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
return model
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _captured_events(monkeypatch, body_calls):
|
|
52
|
+
"""Returns a stub IngestionQueue.enqueue capturing events into body_calls."""
|
|
53
|
+
def fake_enqueue(self, event):
|
|
54
|
+
body_calls.append(event)
|
|
55
|
+
return fake_enqueue
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _setup(monkeypatch, body_calls):
|
|
59
|
+
monkeypatch.setenv("COST_API_KEY", "cost_sk_test")
|
|
60
|
+
monkeypatch.setenv("COST_ENDPOINT", "http://localhost:3001")
|
|
61
|
+
from botzone_cost import _wrap as wrap_mod
|
|
62
|
+
wrap_mod._queues.clear()
|
|
63
|
+
from botzone_cost.queue import IngestionQueue
|
|
64
|
+
monkeypatch.setattr(IngestionQueue, "enqueue", _captured_events(monkeypatch, body_calls))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_anthropic(monkeypatch):
|
|
68
|
+
body_calls: list = []
|
|
69
|
+
_setup(monkeypatch, body_calls)
|
|
70
|
+
client = wrap(_fake_anthropic(), route="test", provider="anthropic")
|
|
71
|
+
client.messages.create(model="claude-sonnet-4-6", messages=[])
|
|
72
|
+
assert len(body_calls) == 1
|
|
73
|
+
ev = body_calls[0]
|
|
74
|
+
assert ev["provider"] == "anthropic"
|
|
75
|
+
assert ev["promptTokens"] == 100
|
|
76
|
+
assert ev["cachedTokens"] == 80
|
|
77
|
+
assert ev["route"] == "test"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_openai(monkeypatch):
|
|
81
|
+
body_calls: list = []
|
|
82
|
+
_setup(monkeypatch, body_calls)
|
|
83
|
+
client = wrap(_fake_openai(), route="summarise", provider="openai")
|
|
84
|
+
client.chat.completions.create(model="gpt-4o-mini", messages=[])
|
|
85
|
+
assert len(body_calls) == 1
|
|
86
|
+
ev = body_calls[0]
|
|
87
|
+
assert ev["provider"] == "openai"
|
|
88
|
+
assert ev["promptTokens"] == 200
|
|
89
|
+
assert ev["cachedTokens"] == 50
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_gemini(monkeypatch):
|
|
93
|
+
body_calls: list = []
|
|
94
|
+
_setup(monkeypatch, body_calls)
|
|
95
|
+
model = wrap(_fake_gemini(), route="classify", provider="gemini")
|
|
96
|
+
model.generate_content("hello")
|
|
97
|
+
assert len(body_calls) == 1
|
|
98
|
+
ev = body_calls[0]
|
|
99
|
+
assert ev["provider"] == "gemini"
|
|
100
|
+
assert ev["model"] == "gemini-2.5-flash"
|
|
101
|
+
assert ev["promptTokens"] == 300
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_disabled(monkeypatch):
|
|
105
|
+
body_calls: list = []
|
|
106
|
+
_setup(monkeypatch, body_calls)
|
|
107
|
+
client = wrap(_fake_anthropic(), enabled=False, provider="anthropic")
|
|
108
|
+
client.messages.create(model="claude-sonnet-4-6", messages=[])
|
|
109
|
+
assert body_calls == []
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_passthrough_returns_result(monkeypatch):
|
|
113
|
+
body_calls: list = []
|
|
114
|
+
_setup(monkeypatch, body_calls)
|
|
115
|
+
client = wrap(_fake_anthropic(), provider="anthropic")
|
|
116
|
+
result = client.messages.create(model="claude-sonnet-4-6", messages=[])
|
|
117
|
+
assert result.model == "claude-sonnet-4-6"
|