botzone-cost 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.
@@ -0,0 +1,6 @@
1
+ """Cost-tracking SDK for Anthropic, OpenAI, and Gemini clients."""
2
+
3
+ from ._wrap import wrap, flush
4
+
5
+ __all__ = ["wrap", "flush"]
6
+ __version__ = "0.1.0"
botzone_cost/_wrap.py ADDED
@@ -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()
botzone_cost/queue.py ADDED
@@ -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,8 @@
1
+ botzone_cost/__init__.py,sha256=KJ43Yxr-_P5fwJH8NNrjlkrCsRd8l23h4nizdQMTMOk,150
2
+ botzone_cost/_wrap.py,sha256=MmgKGXo6SRM-cCXqK2C3pDkCIu-l6kpYj1UiwbPr950,7524
3
+ botzone_cost/queue.py,sha256=vDE-uOnjrvm_W7DMh0_YUFla0VbO-Vp1snXMwteYKkU,2236
4
+ botzone_cost-0.1.0.dist-info/licenses/LICENSE,sha256=niCot9KiAWe3mspxoLi9L9j72paHY492FgXLkmkcXJA,1064
5
+ botzone_cost-0.1.0.dist-info/METADATA,sha256=Iiv8YbQsT6OcHRr6_G5F_P06T5H5lXj3wBuGQDAnQ6w,3093
6
+ botzone_cost-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
7
+ botzone_cost-0.1.0.dist-info/top_level.txt,sha256=lXZoFY4ON_nsj2cmfAYylZ8bY77FsubrOhOggbhPITY,13
8
+ botzone_cost-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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 @@
1
+ botzone_cost