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.
@@ -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.
@@ -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
+
@@ -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