llm-tally 0.2.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.
- llm_tally-0.2.0/.gitignore +30 -0
- llm_tally-0.2.0/CHANGELOG.md +36 -0
- llm_tally-0.2.0/LICENSE +22 -0
- llm_tally-0.2.0/PKG-INFO +295 -0
- llm_tally-0.2.0/README.md +263 -0
- llm_tally-0.2.0/pyproject.toml +76 -0
- llm_tally-0.2.0/src/llm_tally/__init__.py +72 -0
- llm_tally-0.2.0/src/llm_tally/__main__.py +5 -0
- llm_tally-0.2.0/src/llm_tally/cache.py +611 -0
- llm_tally-0.2.0/src/llm_tally/cli.py +84 -0
- llm_tally-0.2.0/src/llm_tally/core.py +2135 -0
- llm_tally-0.2.0/src/llm_tally/dashboard.py +98 -0
- llm_tally-0.2.0/src/llm_tally/pricing.py +369 -0
- llm_tally-0.2.0/src/llm_tally/py.typed +0 -0
- llm_tally-0.2.0/src/llm_tally/retry.py +510 -0
- llm_tally-0.2.0/src/llm_tally/templates/dashboard.css +322 -0
- llm_tally-0.2.0/src/llm_tally/templates/dashboard.html +177 -0
- llm_tally-0.2.0/src/llm_tally/templates/dashboard.js +376 -0
- llm_tally-0.2.0/src/llm_tally/tracker.py +874 -0
- llm_tally-0.2.0/tests/test_bug_regressions.py +902 -0
- llm_tally-0.2.0/tests/test_cache.py +154 -0
- llm_tally-0.2.0/tests/test_core_sync_async.py +118 -0
- llm_tally-0.2.0/tests/test_cost_and_dashboard.py +165 -0
- llm_tally-0.2.0/tests/test_retry.py +185 -0
- llm_tally-0.2.0/tests/test_review_regressions.py +92 -0
- llm_tally-0.2.0/tests/test_usability_features.py +747 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# Distribution / packaging
|
|
7
|
+
dist/
|
|
8
|
+
build/
|
|
9
|
+
*.egg-info/
|
|
10
|
+
.eggs/
|
|
11
|
+
|
|
12
|
+
# Testing & coverage
|
|
13
|
+
.pytest_cache/
|
|
14
|
+
.coverage
|
|
15
|
+
htmlcov/
|
|
16
|
+
|
|
17
|
+
# Virtual environments
|
|
18
|
+
.venv/
|
|
19
|
+
venv/
|
|
20
|
+
env/
|
|
21
|
+
|
|
22
|
+
# Local LLM Tally runtime logs & generated dashboards
|
|
23
|
+
.llm_tally/
|
|
24
|
+
.ailoghelper/
|
|
25
|
+
|
|
26
|
+
# IDE & OS files
|
|
27
|
+
.vscode/
|
|
28
|
+
.idea/
|
|
29
|
+
.DS_Store
|
|
30
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Breaking changes
|
|
6
|
+
- The package was renamed from `django-llm-tally` / `django_llm_tally` to `llm-tally` / `llm_tally`, and the Django helpers (`get_django_llm_tally_settings`, `make_django_dashboard_view`) were removed.
|
|
7
|
+
- **Caching is now opt-in.** `default_cache_enabled` defaults to `False`. Pass `cache=True` per call, or `default_cache_enabled=True` to restore the old behaviour. `@tally.cache`, `cached_call`, and `cached_llm_call` still always cache.
|
|
8
|
+
- **Dashboard auto-updates are throttled** to at most once per `dashboard_update_interval` seconds (default 2.0). Pending updates are written in the background, at exit, or by `tally.flush()`. Set `dashboard_update_interval=0` for the old write-on-every-call behaviour.
|
|
9
|
+
- **HTTP 429 / rate-limit errors are now retried** (honoring `Retry-After`). Set `retry_on_rate_limit=False` to disable this.
|
|
10
|
+
- **The JSONL log is append-only.** A finished stream appends its final record instead of rewriting the file in place, so a stream call takes two lines and readers keep the last line per `id`. `JSONLLogger.compact()` / `llm-tally compact` rewrites the file with one line per call. If other tools read the file directly, de-duplicate by `id`.
|
|
11
|
+
- `log_previews=False` now also hides error message text (exception types and status codes are kept), and `redact` now also applies to error messages, retry-attempt messages, and `config` strings.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- Functions created by a factory (closures with the same qualified name) no longer share cache entries. Each live closure gets its own cache identity.
|
|
15
|
+
- A *returned* response object with HTTP 429 is now retried like a raised rate-limit error (unless `retry_on_rate_limit=False`), and `Retry-After` is read from dict-style responses too.
|
|
16
|
+
- Passing `None` to `LLMTally(...)` arguments now means "use the default". Before, it silently disabled caching, retries, or the dashboard, logged the model as `"None"`, or raised `TypeError`.
|
|
17
|
+
- A `model=` keyword passed to the wrapped function is now used to price the call when the response doesn't report the model. Before, it was priced as `default_model`.
|
|
18
|
+
- A stream abandoned before it finished was left logged as `success` with 0 output tokens. It is now logged as `incomplete`, with the tokens seen so far.
|
|
19
|
+
- Stream context managers whose `__enter__` / `__aenter__` return a different object (e.g. Anthropic `MessageStreamManager`) are now tracked and iterated correctly. Usage is read from the SDK's message snapshot when you iterate `stream.text_stream`.
|
|
20
|
+
- A stream manager that fails on `with` entry is logged as `failed` with the real error. It used to be logged as `incomplete` / "abandoned".
|
|
21
|
+
- A `redact` function that raises no longer loses the record. The text is stored as `"[redaction failed]"`.
|
|
22
|
+
- On large logs, a stream call took about 110 ms because the whole file was rewritten; it now takes about 1 ms (100k-line log). Reads are incremental, and the dashboard throttle stretches to 10× the rebuild time.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- `CallRecord.estimated_tokens` flags costs that were estimated from text length. The report's `overview` has `estimated_calls` / `estimated_cost_usd`, and the dashboard marks these costs with `~`.
|
|
26
|
+
- Per-call `metadata=` on `track` / `run` / `cache` / `cached_call`, instance-wide `metadata=`, and `tally.context(**metadata)` (safe across threads and asyncio). The report's new `metadata_costs` and a new dashboard section show which user, tenant, or prompt version costs the most.
|
|
27
|
+
- `log_previews=False` and `redact=callable` to keep prompt and response text out of logs and the dashboard.
|
|
28
|
+
- `on_record=` sinks / `add_sink()`, and `persist_logs=False`, for custom storage. `async_sinks=True` (with `sink_queue_size`) delivers records on a background thread.
|
|
29
|
+
- `max_log_mb=` / `log_backups=` log rotation, and `llm-tally compact`.
|
|
30
|
+
- `tally.wrap_context(fn)` carries `tally.context(...)` metadata into thread-pool workers.
|
|
31
|
+
- `cache_backend=` for a shared or custom cache.
|
|
32
|
+
- `retry_jitter`, `max_retry_after`, `RetryConfig.compute_wait()`, and `extract_retry_after()`.
|
|
33
|
+
- `pricing_file=` / `tally.load_pricing()` / `PricingRegistry.load_file()` for JSON price lists.
|
|
34
|
+
- `days=` on `get_records` / `get_report` / `render_dashboard` / `generate_dashboard`, and `dashboard_days=`.
|
|
35
|
+
- `llm-tally` command-line tool (`report`, `dashboard`), also runnable as `python -m llm_tally`.
|
|
36
|
+
- `py.typed` marker, and a CI job that builds the wheel and checks it includes the templates.
|
llm_tally-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fauzi Azzam
|
|
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.
|
|
22
|
+
|
llm_tally-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: llm-tally
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Zero-dependency Python LLM toolkit with self-evicting in-memory caching, smart 5xx/429/network retries, per-feature cost tracking, and an interactive HTML dashboard.
|
|
5
|
+
Project-URL: Homepage, https://github.com/fozieng/llm_tally
|
|
6
|
+
Project-URL: Repository, https://github.com/fozieng/llm_tally
|
|
7
|
+
Project-URL: Issues, https://github.com/fozieng/llm_tally/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/fozieng/llm_tally/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Fauzi Azzam
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ai,anthropic,cache,cost-tracking,dashboard,gemini,llm,observability,openai,retry
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.9
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: build>=1.0.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=7.4.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: twine>=5.0.0; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# llm-tally
|
|
34
|
+
|
|
35
|
+
Zero-dependency Python toolkit for **self-evicting in-memory LLM caching**, **resilient 5xx / 429 / network retries**, and **per-feature cost & reliability analytics** with a self-contained **HTML dashboard**.
|
|
36
|
+
|
|
37
|
+
Works in any Python 3.9+ application with zero external runtime dependencies. Fully typed (`py.typed`).
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Core Features
|
|
42
|
+
|
|
43
|
+
### 1. Self-Evicting Local In-Memory Cache (opt-in)
|
|
44
|
+
- **Input + Settings Snapshot**: Hashes the function's arguments (`args`, `kwargs`) together with its settings (`model`, `feature`, `config`) and stores the generated `output`.
|
|
45
|
+
- **Instant Cache Hits**: Calling the function again with the same input and settings returns a copy of the cached output without invoking the LLM.
|
|
46
|
+
- **Lifetime (`ttl`) Auto-Vanish**: Entries expire after `ttl` seconds (default `300s`).
|
|
47
|
+
- **Kill After `X` Requests (`max_requests`)**: An entry removes itself after being fetched `X` times.
|
|
48
|
+
- **Off by default in `@tally.track`.** LLM output with `temperature > 0` is supposed to vary, so caching only happens where you ask for it (`cache=True`, `@tally.cache`, or `default_cache_enabled=True`).
|
|
49
|
+
- **Pluggable**: pass `cache_backend=` to share a cache across workers (e.g. a Redis-backed object with the same `fetch`/`store`/`clear` methods as `InMemoryCache`).
|
|
50
|
+
- Factory-created closures have separate cache identities. These identities last for the function's lifetime in its process, so closure entries are not shared across workers or restarts.
|
|
51
|
+
|
|
52
|
+
### 2. Smart Retries on HTTP 5xx, 429 Rate Limits & Network Issues
|
|
53
|
+
- Retries **HTTP 500/5xx** errors (raised exceptions or returned response objects), **HTTP 429 / `RateLimitError`**, and **network/timeout** errors (`ConnectionError`, `TimeoutError`, `URLError`, SDK connection/timeout exceptions).
|
|
54
|
+
- Retries **3 times** by default with delays of **`0.5s`, `1.0s`, `1.5s`** (configurable).
|
|
55
|
+
- Honors the server's **`Retry-After` / `retry-after-ms`** header (capped by `max_retry_after`, default 60s). Optional `retry_jitter` spreads out retries from many workers.
|
|
56
|
+
- If all attempts fail, raises `RetryExhaustedError` with every attempt's error (`err.attempts`, `err.errors`).
|
|
57
|
+
|
|
58
|
+
### 3. Per-Feature Cost Analyzer & Interactive HTML Dashboard
|
|
59
|
+
- **Built-in pricing** for OpenAI, Anthropic, and Google Gemini models, plus `register_model(...)` or a JSON `pricing_file` for anything else.
|
|
60
|
+
- **Accurate model attribution**: uses the decorator's `model=`, then `config["model"]`, then the model reported in the response, then the `model=` keyword your function was called with.
|
|
61
|
+
- **Estimated vs. real tokens**: when a response carries no token usage, tokens are estimated from text length and the record is flagged `estimated_tokens=True` (shown as `~$` in the dashboard).
|
|
62
|
+
- **Per-call metadata**: tag calls with `user_id`, `tenant`, `prompt_version`, ... and see which ones cost the most.
|
|
63
|
+
- **Self-Contained Static HTML Dashboard (`.llm_tally/dashboard.html`)** answers:
|
|
64
|
+
1. **How much money did I spend this week?**
|
|
65
|
+
2. **Which feature costs the most?** (per feature and per config)
|
|
66
|
+
3. **Which user / tenant / prompt version costs the most?** (per metadata key)
|
|
67
|
+
4. **Which calls fail the most?**
|
|
68
|
+
5. **Which calls are slow?** (Average, P95, Max)
|
|
69
|
+
6. **Interactive search** across features, models, configs, metadata, inputs/outputs, statuses, and errors.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Installation
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install llm-tally
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Quick Start
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from llm_tally import LLMTally
|
|
85
|
+
|
|
86
|
+
tally = LLMTally() # logs to .llm_tally/logs.jsonl, dashboard at .llm_tally/dashboard.html
|
|
87
|
+
|
|
88
|
+
@tally.track(feature="support_chatbot")
|
|
89
|
+
def ask_llm(prompt: str, model: str = "gpt-4o") -> dict:
|
|
90
|
+
# Replace with an OpenAI / Anthropic / Gemini SDK call and return its response
|
|
91
|
+
return {
|
|
92
|
+
"content": f"Answer to: {prompt}",
|
|
93
|
+
"usage": {"input_tokens": 120, "output_tokens": 45},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
response = ask_llm("How do I reset my password?", model="gpt-4o-mini") # priced as gpt-4o-mini
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Every constructor argument is optional, and passing `None` always means "use the default", so you can forward optional settings directly:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
tally = LLMTally(
|
|
103
|
+
default_model=os.getenv("LLM_MODEL"), # None -> "gpt-4o-mini"
|
|
104
|
+
log_path=os.getenv("LLM_TALLY_LOG"), # None -> ".llm_tally/logs.jsonl"
|
|
105
|
+
default_cache_enabled=True, # opt in to caching for every tracked call
|
|
106
|
+
default_ttl=300.0,
|
|
107
|
+
default_retries=3,
|
|
108
|
+
retry_delays=(0.5, 1.0, 1.5),
|
|
109
|
+
)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Which function should I use?
|
|
115
|
+
|
|
116
|
+
| You want to... | Use |
|
|
117
|
+
|--------------------------------------------------------|-----|
|
|
118
|
+
| Track cost + retry (+ optional cache) on a function | `@tally.track(...)` |
|
|
119
|
+
| Same thing, but for one call without a decorator | `tally.run(func, *args, **options)` |
|
|
120
|
+
| Cache a function (with cost tracking) | `@tally.cache(...)` / `tally.cached_call(func, ...)` |
|
|
121
|
+
| Cache only, no logging, no tally instance | `cached_llm_call(func, ...)` |
|
|
122
|
+
| Retry only, no logging | `tally.retry_call(func, ...)` |
|
|
123
|
+
|
|
124
|
+
`run`, `cached_call`, and `retry_call` take their own options (`model`, `config`, `metadata`, `ttl`, ...) as keyword arguments, so those names are not forwarded to `func`. Use the decorator if your function itself needs a `model=` argument.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Caching (Feature 1)
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
@tally.track(feature="faq", cache=True, ttl=120.0, max_requests=5, config={"temperature": 0})
|
|
132
|
+
def answer_faq(question: str) -> str:
|
|
133
|
+
...
|
|
134
|
+
|
|
135
|
+
from llm_tally import cached_llm_call
|
|
136
|
+
|
|
137
|
+
out = cached_llm_call(expensive_prompt, "Quantum Computing", ttl=60.0, max_requests=2,
|
|
138
|
+
settings={"temperature": 0})
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
After `ttl` seconds the entry vanishes on its next fetch; after `max_requests` hits it kills itself, and the next call generates a fresh response.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Retries (Feature 2)
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
from llm_tally import LLMTally, RetryExhaustedError
|
|
149
|
+
|
|
150
|
+
tally = LLMTally(
|
|
151
|
+
retry_on_rate_limit=True, # retry HTTP 429 / RateLimitError (default)
|
|
152
|
+
retry_jitter=0.2, # add up to +20% random wait per retry
|
|
153
|
+
max_retry_after=30.0, # never wait longer than 30s for a Retry-After header
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
@tally.track(feature="payment_summary", model="claude-3-7-sonnet")
|
|
157
|
+
def flaky_call():
|
|
158
|
+
raise ConnectionError("Upstream LLM gateway reset connection")
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
flaky_call()
|
|
162
|
+
except RetryExhaustedError as err:
|
|
163
|
+
for attempt in err.attempts:
|
|
164
|
+
print(attempt.attempt, attempt.delay_before, attempt.status_code, attempt.message)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Client errors (400, 401, 403, 404, 422) are never retried. Use `extra_exceptions=(MyError,)` to retry your own exception types.
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## Per-Call Metadata (users, tenants, prompt versions)
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
tally = LLMTally(metadata={"env": "prod"}) # added to every record
|
|
175
|
+
|
|
176
|
+
@tally.track(feature="chat", metadata={"prompt_version": "v3"})
|
|
177
|
+
def chat(prompt: str): ...
|
|
178
|
+
|
|
179
|
+
with tally.context(user_id=user.id, tenant=org.slug): # thread- and asyncio-safe
|
|
180
|
+
chat("hello")
|
|
181
|
+
|
|
182
|
+
tally.run(other_func, "hi", metadata={"request_id": rid})
|
|
183
|
+
|
|
184
|
+
report = tally.get_report()
|
|
185
|
+
print(report["metadata_costs"]["user_id"][0]) # most expensive user
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Precedence: instance `metadata` < `tally.context(...)` < decorator / `run(metadata=...)`.
|
|
189
|
+
|
|
190
|
+
Context metadata follows asyncio tasks automatically, but Python does not pass it to `ThreadPoolExecutor` / `threading.Thread` workers. Wrap the function you submit:
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
with tally.context(user_id="alice"), ThreadPoolExecutor() as pool:
|
|
194
|
+
pool.map(tally.wrap_context(chat), prompts)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## Privacy: Prompt Previews
|
|
200
|
+
|
|
201
|
+
By default the first 140 characters of each input and output are stored for search. To turn this off or mask sensitive data:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
import re
|
|
205
|
+
|
|
206
|
+
# Store no prompt/response text, and hide error message text (exception types and status codes are kept)
|
|
207
|
+
tally = LLMTally(log_previews=False)
|
|
208
|
+
|
|
209
|
+
# Mask text in previews, error messages, retry-attempt messages, and string values in `config`
|
|
210
|
+
tally = LLMTally(redact=lambda text: re.sub(r"[\w.+-]+@[\w-]+\.[\w.]+", "[email]", text))
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
If your `redact` function raises, the text is stored as `"[redaction failed]"` and a warning is shown. The call is still logged. `metadata` values are stored exactly as you pass them.
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## Custom Storage & Sinks
|
|
218
|
+
|
|
219
|
+
Every finished call is passed as a `CallRecord` to your `on_record` callables. Use them to send records to a database, OpenTelemetry, a queue, etc. A failing sink only warns; it never breaks the LLM call.
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
def ship(record):
|
|
223
|
+
db.insert("llm_calls", record.to_dict())
|
|
224
|
+
|
|
225
|
+
tally = LLMTally(on_record=ship) # JSONL file + your sink
|
|
226
|
+
tally = LLMTally(on_record=ship, persist_logs=False) # your sink only (no local file)
|
|
227
|
+
tally = LLMTally(on_record=ship, async_sinks=True) # run sinks on a background thread
|
|
228
|
+
tally.add_sink(another_sink)
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
By default sinks run inside the call, so a slow sink adds its latency to every LLM call. With `async_sinks=True` records go through a bounded background queue (`sink_queue_size`, default 10,000). If the queue fills up, records are dropped with a warning. `tally.flush()` (also run at exit) waits for queued records to be delivered.
|
|
232
|
+
|
|
233
|
+
Streams produce exactly one record each, once they finish. A stream that is abandoned before it ends is logged with `status="incomplete"`. An SDK stream manager that fails when the `with` block opens (e.g. Anthropic `messages.stream()` failing to connect) is logged as `failed` with the real error. Retries don't apply to it, because the SDK sends that request on entry.
|
|
234
|
+
|
|
235
|
+
### The JSONL log file
|
|
236
|
+
- **Append-only and safe across processes**: every write is one appended line under a POSIX file lock, so multiple threads and processes on one machine never lose records. A finished stream appends its final version, and readers keep the **last line for each `id`**. Run `tally.logger.compact()` or `llm-tally compact` to rewrite the file with one line per call.
|
|
237
|
+
- **Incremental reads**: reports and the dashboard only parse what was appended since the last read.
|
|
238
|
+
- **Rotation**: `LLMTally(max_log_mb=100, log_backups=3)` moves the log to `logs.jsonl.1` ... `.3` when it would exceed 100 MB. Reports and the dashboard only read the current file.
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## Custom Models & Pricing
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
tally.register_model("my-company-fine-tune", input_cost_per_1m=1.20, output_cost_per_1m=4.80)
|
|
246
|
+
|
|
247
|
+
# Or keep prices in a file you can update without a release:
|
|
248
|
+
tally = LLMTally(pricing_file="prices.json")
|
|
249
|
+
tally.load_pricing("more_prices.json")
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
```json
|
|
253
|
+
{
|
|
254
|
+
"my-company-fine-tune": {"input_cost_per_1m": 1.2, "output_cost_per_1m": 4.8, "provider": "custom"},
|
|
255
|
+
"gpt-4o": {"input_cost_per_1m": 2.5, "output_cost_per_1m": 10.0, "cached_input_cost_per_1m": 1.25}
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Unknown models fall back to $0.50 / $1.50 per 1M tokens and emit a `UserWarning`.
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## Dashboard & Reports
|
|
264
|
+
|
|
265
|
+
The dashboard is regenerated automatically, but **at most once every `dashboard_update_interval` seconds** (default `2.0`), so the overhead per call stays flat as logs grow. If a rebuild is slow (a very large log), the interval stretches to 10× the rebuild time, so rebuilding never uses more than about 10% of a core. A pending update is written in the background when the interval ends, and at interpreter exit.
|
|
266
|
+
|
|
267
|
+
```python
|
|
268
|
+
tally = LLMTally(dashboard_update_interval=10.0, dashboard_days=30) # last 30 days only
|
|
269
|
+
|
|
270
|
+
report = tally.get_report(days=7)
|
|
271
|
+
print("Spent this week ($):", report["spend_summary"]["this_week_usd"])
|
|
272
|
+
print("Most expensive feature:", report["overview"]["top_cost_feature"])
|
|
273
|
+
print("Estimated-token calls:", report["overview"]["estimated_calls"])
|
|
274
|
+
|
|
275
|
+
tally.flush() # write any pending dashboard update now
|
|
276
|
+
path = tally.generate_dashboard() # or force a full rebuild
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Set `dashboard_update_interval=0` to rebuild on every call, or `auto_update_dashboard=False` to only build it yourself.
|
|
280
|
+
|
|
281
|
+
### Command line
|
|
282
|
+
|
|
283
|
+
```bash
|
|
284
|
+
llm-tally report # summary of .llm_tally/logs.jsonl
|
|
285
|
+
llm-tally report --days 7 --json # full analytics as JSON
|
|
286
|
+
llm-tally dashboard --log logs.jsonl --out dash.html --days 30
|
|
287
|
+
llm-tally compact --log logs.jsonl # one line per call
|
|
288
|
+
python -m llm_tally report # same, without the console script
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
---
|
|
292
|
+
|
|
293
|
+
## License
|
|
294
|
+
|
|
295
|
+
MIT
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# llm-tally
|
|
2
|
+
|
|
3
|
+
Zero-dependency Python toolkit for **self-evicting in-memory LLM caching**, **resilient 5xx / 429 / network retries**, and **per-feature cost & reliability analytics** with a self-contained **HTML dashboard**.
|
|
4
|
+
|
|
5
|
+
Works in any Python 3.9+ application with zero external runtime dependencies. Fully typed (`py.typed`).
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Core Features
|
|
10
|
+
|
|
11
|
+
### 1. Self-Evicting Local In-Memory Cache (opt-in)
|
|
12
|
+
- **Input + Settings Snapshot**: Hashes the function's arguments (`args`, `kwargs`) together with its settings (`model`, `feature`, `config`) and stores the generated `output`.
|
|
13
|
+
- **Instant Cache Hits**: Calling the function again with the same input and settings returns a copy of the cached output without invoking the LLM.
|
|
14
|
+
- **Lifetime (`ttl`) Auto-Vanish**: Entries expire after `ttl` seconds (default `300s`).
|
|
15
|
+
- **Kill After `X` Requests (`max_requests`)**: An entry removes itself after being fetched `X` times.
|
|
16
|
+
- **Off by default in `@tally.track`.** LLM output with `temperature > 0` is supposed to vary, so caching only happens where you ask for it (`cache=True`, `@tally.cache`, or `default_cache_enabled=True`).
|
|
17
|
+
- **Pluggable**: pass `cache_backend=` to share a cache across workers (e.g. a Redis-backed object with the same `fetch`/`store`/`clear` methods as `InMemoryCache`).
|
|
18
|
+
- Factory-created closures have separate cache identities. These identities last for the function's lifetime in its process, so closure entries are not shared across workers or restarts.
|
|
19
|
+
|
|
20
|
+
### 2. Smart Retries on HTTP 5xx, 429 Rate Limits & Network Issues
|
|
21
|
+
- Retries **HTTP 500/5xx** errors (raised exceptions or returned response objects), **HTTP 429 / `RateLimitError`**, and **network/timeout** errors (`ConnectionError`, `TimeoutError`, `URLError`, SDK connection/timeout exceptions).
|
|
22
|
+
- Retries **3 times** by default with delays of **`0.5s`, `1.0s`, `1.5s`** (configurable).
|
|
23
|
+
- Honors the server's **`Retry-After` / `retry-after-ms`** header (capped by `max_retry_after`, default 60s). Optional `retry_jitter` spreads out retries from many workers.
|
|
24
|
+
- If all attempts fail, raises `RetryExhaustedError` with every attempt's error (`err.attempts`, `err.errors`).
|
|
25
|
+
|
|
26
|
+
### 3. Per-Feature Cost Analyzer & Interactive HTML Dashboard
|
|
27
|
+
- **Built-in pricing** for OpenAI, Anthropic, and Google Gemini models, plus `register_model(...)` or a JSON `pricing_file` for anything else.
|
|
28
|
+
- **Accurate model attribution**: uses the decorator's `model=`, then `config["model"]`, then the model reported in the response, then the `model=` keyword your function was called with.
|
|
29
|
+
- **Estimated vs. real tokens**: when a response carries no token usage, tokens are estimated from text length and the record is flagged `estimated_tokens=True` (shown as `~$` in the dashboard).
|
|
30
|
+
- **Per-call metadata**: tag calls with `user_id`, `tenant`, `prompt_version`, ... and see which ones cost the most.
|
|
31
|
+
- **Self-Contained Static HTML Dashboard (`.llm_tally/dashboard.html`)** answers:
|
|
32
|
+
1. **How much money did I spend this week?**
|
|
33
|
+
2. **Which feature costs the most?** (per feature and per config)
|
|
34
|
+
3. **Which user / tenant / prompt version costs the most?** (per metadata key)
|
|
35
|
+
4. **Which calls fail the most?**
|
|
36
|
+
5. **Which calls are slow?** (Average, P95, Max)
|
|
37
|
+
6. **Interactive search** across features, models, configs, metadata, inputs/outputs, statuses, and errors.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install llm-tally
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Quick Start
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from llm_tally import LLMTally
|
|
53
|
+
|
|
54
|
+
tally = LLMTally() # logs to .llm_tally/logs.jsonl, dashboard at .llm_tally/dashboard.html
|
|
55
|
+
|
|
56
|
+
@tally.track(feature="support_chatbot")
|
|
57
|
+
def ask_llm(prompt: str, model: str = "gpt-4o") -> dict:
|
|
58
|
+
# Replace with an OpenAI / Anthropic / Gemini SDK call and return its response
|
|
59
|
+
return {
|
|
60
|
+
"content": f"Answer to: {prompt}",
|
|
61
|
+
"usage": {"input_tokens": 120, "output_tokens": 45},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
response = ask_llm("How do I reset my password?", model="gpt-4o-mini") # priced as gpt-4o-mini
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Every constructor argument is optional, and passing `None` always means "use the default", so you can forward optional settings directly:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
tally = LLMTally(
|
|
71
|
+
default_model=os.getenv("LLM_MODEL"), # None -> "gpt-4o-mini"
|
|
72
|
+
log_path=os.getenv("LLM_TALLY_LOG"), # None -> ".llm_tally/logs.jsonl"
|
|
73
|
+
default_cache_enabled=True, # opt in to caching for every tracked call
|
|
74
|
+
default_ttl=300.0,
|
|
75
|
+
default_retries=3,
|
|
76
|
+
retry_delays=(0.5, 1.0, 1.5),
|
|
77
|
+
)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Which function should I use?
|
|
83
|
+
|
|
84
|
+
| You want to... | Use |
|
|
85
|
+
|--------------------------------------------------------|-----|
|
|
86
|
+
| Track cost + retry (+ optional cache) on a function | `@tally.track(...)` |
|
|
87
|
+
| Same thing, but for one call without a decorator | `tally.run(func, *args, **options)` |
|
|
88
|
+
| Cache a function (with cost tracking) | `@tally.cache(...)` / `tally.cached_call(func, ...)` |
|
|
89
|
+
| Cache only, no logging, no tally instance | `cached_llm_call(func, ...)` |
|
|
90
|
+
| Retry only, no logging | `tally.retry_call(func, ...)` |
|
|
91
|
+
|
|
92
|
+
`run`, `cached_call`, and `retry_call` take their own options (`model`, `config`, `metadata`, `ttl`, ...) as keyword arguments, so those names are not forwarded to `func`. Use the decorator if your function itself needs a `model=` argument.
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Caching (Feature 1)
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
@tally.track(feature="faq", cache=True, ttl=120.0, max_requests=5, config={"temperature": 0})
|
|
100
|
+
def answer_faq(question: str) -> str:
|
|
101
|
+
...
|
|
102
|
+
|
|
103
|
+
from llm_tally import cached_llm_call
|
|
104
|
+
|
|
105
|
+
out = cached_llm_call(expensive_prompt, "Quantum Computing", ttl=60.0, max_requests=2,
|
|
106
|
+
settings={"temperature": 0})
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
After `ttl` seconds the entry vanishes on its next fetch; after `max_requests` hits it kills itself, and the next call generates a fresh response.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Retries (Feature 2)
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from llm_tally import LLMTally, RetryExhaustedError
|
|
117
|
+
|
|
118
|
+
tally = LLMTally(
|
|
119
|
+
retry_on_rate_limit=True, # retry HTTP 429 / RateLimitError (default)
|
|
120
|
+
retry_jitter=0.2, # add up to +20% random wait per retry
|
|
121
|
+
max_retry_after=30.0, # never wait longer than 30s for a Retry-After header
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
@tally.track(feature="payment_summary", model="claude-3-7-sonnet")
|
|
125
|
+
def flaky_call():
|
|
126
|
+
raise ConnectionError("Upstream LLM gateway reset connection")
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
flaky_call()
|
|
130
|
+
except RetryExhaustedError as err:
|
|
131
|
+
for attempt in err.attempts:
|
|
132
|
+
print(attempt.attempt, attempt.delay_before, attempt.status_code, attempt.message)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Client errors (400, 401, 403, 404, 422) are never retried. Use `extra_exceptions=(MyError,)` to retry your own exception types.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Per-Call Metadata (users, tenants, prompt versions)
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
tally = LLMTally(metadata={"env": "prod"}) # added to every record
|
|
143
|
+
|
|
144
|
+
@tally.track(feature="chat", metadata={"prompt_version": "v3"})
|
|
145
|
+
def chat(prompt: str): ...
|
|
146
|
+
|
|
147
|
+
with tally.context(user_id=user.id, tenant=org.slug): # thread- and asyncio-safe
|
|
148
|
+
chat("hello")
|
|
149
|
+
|
|
150
|
+
tally.run(other_func, "hi", metadata={"request_id": rid})
|
|
151
|
+
|
|
152
|
+
report = tally.get_report()
|
|
153
|
+
print(report["metadata_costs"]["user_id"][0]) # most expensive user
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Precedence: instance `metadata` < `tally.context(...)` < decorator / `run(metadata=...)`.
|
|
157
|
+
|
|
158
|
+
Context metadata follows asyncio tasks automatically, but Python does not pass it to `ThreadPoolExecutor` / `threading.Thread` workers. Wrap the function you submit:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
with tally.context(user_id="alice"), ThreadPoolExecutor() as pool:
|
|
162
|
+
pool.map(tally.wrap_context(chat), prompts)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Privacy: Prompt Previews
|
|
168
|
+
|
|
169
|
+
By default the first 140 characters of each input and output are stored for search. To turn this off or mask sensitive data:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
import re
|
|
173
|
+
|
|
174
|
+
# Store no prompt/response text, and hide error message text (exception types and status codes are kept)
|
|
175
|
+
tally = LLMTally(log_previews=False)
|
|
176
|
+
|
|
177
|
+
# Mask text in previews, error messages, retry-attempt messages, and string values in `config`
|
|
178
|
+
tally = LLMTally(redact=lambda text: re.sub(r"[\w.+-]+@[\w-]+\.[\w.]+", "[email]", text))
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
If your `redact` function raises, the text is stored as `"[redaction failed]"` and a warning is shown. The call is still logged. `metadata` values are stored exactly as you pass them.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Custom Storage & Sinks
|
|
186
|
+
|
|
187
|
+
Every finished call is passed as a `CallRecord` to your `on_record` callables. Use them to send records to a database, OpenTelemetry, a queue, etc. A failing sink only warns; it never breaks the LLM call.
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
def ship(record):
|
|
191
|
+
db.insert("llm_calls", record.to_dict())
|
|
192
|
+
|
|
193
|
+
tally = LLMTally(on_record=ship) # JSONL file + your sink
|
|
194
|
+
tally = LLMTally(on_record=ship, persist_logs=False) # your sink only (no local file)
|
|
195
|
+
tally = LLMTally(on_record=ship, async_sinks=True) # run sinks on a background thread
|
|
196
|
+
tally.add_sink(another_sink)
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
By default sinks run inside the call, so a slow sink adds its latency to every LLM call. With `async_sinks=True` records go through a bounded background queue (`sink_queue_size`, default 10,000). If the queue fills up, records are dropped with a warning. `tally.flush()` (also run at exit) waits for queued records to be delivered.
|
|
200
|
+
|
|
201
|
+
Streams produce exactly one record each, once they finish. A stream that is abandoned before it ends is logged with `status="incomplete"`. An SDK stream manager that fails when the `with` block opens (e.g. Anthropic `messages.stream()` failing to connect) is logged as `failed` with the real error. Retries don't apply to it, because the SDK sends that request on entry.
|
|
202
|
+
|
|
203
|
+
### The JSONL log file
|
|
204
|
+
- **Append-only and safe across processes**: every write is one appended line under a POSIX file lock, so multiple threads and processes on one machine never lose records. A finished stream appends its final version, and readers keep the **last line for each `id`**. Run `tally.logger.compact()` or `llm-tally compact` to rewrite the file with one line per call.
|
|
205
|
+
- **Incremental reads**: reports and the dashboard only parse what was appended since the last read.
|
|
206
|
+
- **Rotation**: `LLMTally(max_log_mb=100, log_backups=3)` moves the log to `logs.jsonl.1` ... `.3` when it would exceed 100 MB. Reports and the dashboard only read the current file.
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## Custom Models & Pricing
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
tally.register_model("my-company-fine-tune", input_cost_per_1m=1.20, output_cost_per_1m=4.80)
|
|
214
|
+
|
|
215
|
+
# Or keep prices in a file you can update without a release:
|
|
216
|
+
tally = LLMTally(pricing_file="prices.json")
|
|
217
|
+
tally.load_pricing("more_prices.json")
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
```json
|
|
221
|
+
{
|
|
222
|
+
"my-company-fine-tune": {"input_cost_per_1m": 1.2, "output_cost_per_1m": 4.8, "provider": "custom"},
|
|
223
|
+
"gpt-4o": {"input_cost_per_1m": 2.5, "output_cost_per_1m": 10.0, "cached_input_cost_per_1m": 1.25}
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Unknown models fall back to $0.50 / $1.50 per 1M tokens and emit a `UserWarning`.
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## Dashboard & Reports
|
|
232
|
+
|
|
233
|
+
The dashboard is regenerated automatically, but **at most once every `dashboard_update_interval` seconds** (default `2.0`), so the overhead per call stays flat as logs grow. If a rebuild is slow (a very large log), the interval stretches to 10× the rebuild time, so rebuilding never uses more than about 10% of a core. A pending update is written in the background when the interval ends, and at interpreter exit.
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
tally = LLMTally(dashboard_update_interval=10.0, dashboard_days=30) # last 30 days only
|
|
237
|
+
|
|
238
|
+
report = tally.get_report(days=7)
|
|
239
|
+
print("Spent this week ($):", report["spend_summary"]["this_week_usd"])
|
|
240
|
+
print("Most expensive feature:", report["overview"]["top_cost_feature"])
|
|
241
|
+
print("Estimated-token calls:", report["overview"]["estimated_calls"])
|
|
242
|
+
|
|
243
|
+
tally.flush() # write any pending dashboard update now
|
|
244
|
+
path = tally.generate_dashboard() # or force a full rebuild
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Set `dashboard_update_interval=0` to rebuild on every call, or `auto_update_dashboard=False` to only build it yourself.
|
|
248
|
+
|
|
249
|
+
### Command line
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
llm-tally report # summary of .llm_tally/logs.jsonl
|
|
253
|
+
llm-tally report --days 7 --json # full analytics as JSON
|
|
254
|
+
llm-tally dashboard --log logs.jsonl --out dash.html --days 30
|
|
255
|
+
llm-tally compact --log logs.jsonl # one line per call
|
|
256
|
+
python -m llm_tally report # same, without the console script
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## License
|
|
262
|
+
|
|
263
|
+
MIT
|