django-llm-ledger 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.
Files changed (38) hide show
  1. django_llm_ledger-0.1.0/.gitignore +10 -0
  2. django_llm_ledger-0.1.0/CHANGELOG.md +12 -0
  3. django_llm_ledger-0.1.0/LICENSE +21 -0
  4. django_llm_ledger-0.1.0/PKG-INFO +232 -0
  5. django_llm_ledger-0.1.0/README.md +201 -0
  6. django_llm_ledger-0.1.0/pyproject.toml +59 -0
  7. django_llm_ledger-0.1.0/src/llm_ledger/__init__.py +18 -0
  8. django_llm_ledger-0.1.0/src/llm_ledger/admin.py +54 -0
  9. django_llm_ledger-0.1.0/src/llm_ledger/apps.py +7 -0
  10. django_llm_ledger-0.1.0/src/llm_ledger/client.py +353 -0
  11. django_llm_ledger-0.1.0/src/llm_ledger/conf.py +35 -0
  12. django_llm_ledger-0.1.0/src/llm_ledger/evals.py +177 -0
  13. django_llm_ledger-0.1.0/src/llm_ledger/management/__init__.py +0 -0
  14. django_llm_ledger-0.1.0/src/llm_ledger/management/commands/__init__.py +0 -0
  15. django_llm_ledger-0.1.0/src/llm_ledger/management/commands/llm_evaluate.py +53 -0
  16. django_llm_ledger-0.1.0/src/llm_ledger/migrations/0001_initial.py +62 -0
  17. django_llm_ledger-0.1.0/src/llm_ledger/migrations/0002_eval_cost.py +28 -0
  18. django_llm_ledger-0.1.0/src/llm_ledger/migrations/__init__.py +0 -0
  19. django_llm_ledger-0.1.0/src/llm_ledger/models.py +62 -0
  20. django_llm_ledger-0.1.0/src/llm_ledger/pricing.py +29 -0
  21. django_llm_ledger-0.1.0/src/llm_ledger/providers/__init__.py +35 -0
  22. django_llm_ledger-0.1.0/src/llm_ledger/providers/base.py +61 -0
  23. django_llm_ledger-0.1.0/src/llm_ledger/providers/fake.py +44 -0
  24. django_llm_ledger-0.1.0/src/llm_ledger/providers/http.py +211 -0
  25. django_llm_ledger-0.1.0/src/llm_ledger/signals.py +5 -0
  26. django_llm_ledger-0.1.0/src/llm_ledger/stats.py +87 -0
  27. django_llm_ledger-0.1.0/src/llm_ledger/templates/admin/llm_ledger/_group_table.html +10 -0
  28. django_llm_ledger-0.1.0/src/llm_ledger/templates/admin/llm_ledger/dashboard.html +94 -0
  29. django_llm_ledger-0.1.0/src/llm_ledger/templates/admin/llm_ledger/llmcall/change_list.html +5 -0
  30. django_llm_ledger-0.1.0/tests/__init__.py +0 -0
  31. django_llm_ledger-0.1.0/tests/conftest.py +19 -0
  32. django_llm_ledger-0.1.0/tests/settings.py +37 -0
  33. django_llm_ledger-0.1.0/tests/test_admin.py +52 -0
  34. django_llm_ledger-0.1.0/tests/test_client.py +163 -0
  35. django_llm_ledger-0.1.0/tests/test_evals.py +130 -0
  36. django_llm_ledger-0.1.0/tests/test_providers.py +116 -0
  37. django_llm_ledger-0.1.0/tests/test_stream.py +203 -0
  38. django_llm_ledger-0.1.0/tests/urls.py +4 -0
@@ -0,0 +1,10 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
8
+ *.sqlite3
9
+ demo.sqlite3
10
+ MELHORIAS.md
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-11-08)
4
+
5
+ First release.
6
+
7
+ - `llm.complete()` and `llm.stream()`: one call that records provider, model, tokens, cost, latency, feature and user for every attempt.
8
+ - Fallback chain across Ollama, OpenAI-compatible servers and Anthropic over plain HTTP, with retries and exponential backoff. Streams only fall back before the first chunk.
9
+ - Cache for identical requests, monthly budget with a `budget_exceeded` signal and optional blocking.
10
+ - Cost dashboard in the Django admin.
11
+ - Evaluation suites (`manage.py llm_evaluate`) with score, cost, tokens and time per case; fails CI below `--min-score`.
12
+ - Demo store in `example/`.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Israel Mendes
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,232 @@
1
+ Metadata-Version: 2.5
2
+ Name: django-llm-ledger
3
+ Version: 0.1.0
4
+ Summary: Cost tracking, provider fallback and evaluation suites for LLM features in Django apps.
5
+ Project-URL: Source, https://github.com/Israel-Mendes-git/django-llm-ledger
6
+ Project-URL: Changelog, https://github.com/Israel-Mendes-git/django-llm-ledger/blob/master/CHANGELOG.md
7
+ Author: Israel Mendes
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: anthropic,cost,django,evals,llm,observability,ollama,openai
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Framework :: Django
13
+ Classifier: Framework :: Django :: 4.2
14
+ Classifier: Framework :: Django :: 5.1
15
+ Classifier: Framework :: Django :: 5.2
16
+ Classifier: Framework :: Django :: 6.1
17
+ Classifier: Intended Audience :: Developers
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
25
+ Requires-Python: >=3.10
26
+ Requires-Dist: django>=4.2
27
+ Requires-Dist: httpx>=0.27
28
+ Provides-Extra: yaml
29
+ Requires-Dist: pyyaml>=6.0; extra == 'yaml'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # django-llm-ledger
33
+
34
+ Know what your LLM features cost, keep them working when a provider goes down, and catch prompt regressions in CI.
35
+
36
+ Most teams that "added AI to the product" find out three things later: nobody knows what it costs per feature, it breaks when the API has a bad day, and nobody can tell whether yesterday's prompt tweak made answers worse. `django-llm-ledger` is a small Django app that handles all three without changing how you write your feature.
37
+
38
+ ```python
39
+ from llm_ledger import llm
40
+
41
+ answer = llm.complete(
42
+ "Summarize this order for the customer: ...",
43
+ feature="order_summary", # cost is tracked per feature
44
+ user=request.user, # ...and per user
45
+ )
46
+ print(answer.text, answer.cost_usd, answer.latency_ms)
47
+ ```
48
+
49
+ ![Demo store: an answer streams from a local model, then the main provider goes down and the fallback answers](https://github.com/Israel-Mendes-git/django-llm-ledger/raw/master/docs/images/demo.gif)
50
+
51
+ ![Demo store: the main provider is down, the call is retried twice and a local model answers](https://github.com/Israel-Mendes-git/django-llm-ledger/raw/master/docs/images/demo-chat-fallback.png)
52
+
53
+ ![Cost dashboard in the Django admin](https://github.com/Israel-Mendes-git/django-llm-ledger/raw/master/docs/images/admin-dashboard.png)
54
+
55
+ ## What you get
56
+
57
+ - **A ledger of every call**: provider, model, tokens, cost, latency, status, feature and user. Prompts are **not** stored unless you opt in, since they often carry personal data.
58
+ - **A cost dashboard in the Django admin**: spend this month vs. budget, cost per day, top features, models and users, error rate, fallback count.
59
+ - **Retries and fallback**: retryable errors (timeouts, 429, 5xx) are retried with exponential backoff; then the next provider in the chain takes over. Put a local model last and your feature keeps answering even when the paid API is down.
60
+ - **Streaming**: `llm.stream()` yields the answer as it is written, with the same ledger, cache, retries and fallback.
61
+ - **Cache**: identical requests are served from Django's cache and cost nothing.
62
+ - **Budget guard**: a `budget_exceeded` signal when the monthly budget is reached, and optional hard blocking.
63
+ - **Evaluation suites**: a file of questions plus what a good answer must (not) contain. `manage.py llm_evaluate` scores them and fails your CI when the score drops.
64
+ - **No vendor SDKs**: Ollama, OpenAI-compatible servers (OpenAI, LM Studio, vLLM, Groq...) and Anthropic over plain HTTP. Only dependency besides Django is `httpx`.
65
+
66
+ ## Install
67
+
68
+ ```bash
69
+ pip install django-llm-ledger # add [yaml] for YAML suites
70
+ ```
71
+
72
+ ```python
73
+ INSTALLED_APPS = [..., "llm_ledger"]
74
+
75
+ LLM_LEDGER = {
76
+ # Tried in order. The first one that answers wins.
77
+ "PROVIDERS": [
78
+ {"name": "anthropic", "model": "claude-sonnet-5-5"}, # key from ANTHROPIC_API_KEY
79
+ {"name": "openai", "model": "gpt-4.1-mini"}, # key from OPENAI_API_KEY
80
+ {"name": "ollama", "model": "llama3.2"}, # local, free, last resort
81
+ ],
82
+ # USD per 1M tokens. Not shipped on purpose: prices change, and stale prices lie quietly.
83
+ "PRICES": {
84
+ "claude-sonnet-5-5": {"input": 3.0, "output": 15.0},
85
+ "gpt-4.1-mini": {"input": 0.4, "output": 1.6},
86
+ },
87
+ "MONTHLY_BUDGET_USD": 50,
88
+ "LOCAL_CURRENCY": {"code": "BRL", "rate": 5.4}, # optional second currency in the dashboard
89
+ }
90
+ ```
91
+
92
+ ```bash
93
+ python manage.py migrate
94
+ ```
95
+
96
+ The dashboard lives at **Admin → LLM calls → Cost dashboard**.
97
+
98
+ > The prices above are placeholders for the example. Check your provider's pricing page.
99
+
100
+ ## Settings
101
+
102
+ | Key | Default | |
103
+ |---|---|---|
104
+ | `PROVIDERS` | `[{"name": "ollama", "model": "llama3.2"}]` | Fallback chain. `name` is `ollama`, `openai`, `anthropic`, `fake` or a dotted path to your own `Provider` subclass. Extra keys (`base_url`, `api_key`, `api_key_env`, `price`, `timeout`) go to the provider. |
105
+ | `PRICES` | `{}` | USD per 1M tokens, per model. Paid calls without a price are recorded with `cost_usd=None` and flagged in the dashboard. |
106
+ | `MAX_RETRIES` | `2` | Retries per provider for retryable errors. |
107
+ | `RETRY_BACKOFF` | `0.5` | Base delay in seconds; doubles on each retry. |
108
+ | `TIMEOUT` | `60` | HTTP timeout in seconds. |
109
+ | `CACHE_TTL` | `3600` | Seconds to cache identical requests. `0` disables. |
110
+ | `CACHE_ALIAS` | `"default"` | Which Django cache to use. |
111
+ | `MONTHLY_BUDGET_USD` | `None` | Sends `budget_exceeded` when reached. |
112
+ | `BLOCK_OVER_BUDGET` | `False` | Raise `BudgetExceeded` instead of just signalling. |
113
+ | `LOCAL_CURRENCY` | `None` | `{"code": "BRL", "rate": 5.4}` to show a second currency. |
114
+ | `STORE_PROMPTS` | `False` | Store prompt and response text in the ledger. |
115
+
116
+ ## Evaluation suites
117
+
118
+ ```yaml
119
+ # evals/support.yaml
120
+ name: support
121
+ system: "You answer for ACME Store. Returns are accepted within 30 days."
122
+ cases:
123
+ - id: return-window
124
+ input: "How long do I have to return an item?"
125
+ must_contain: ["30 days"]
126
+ - id: no-made-up-promises
127
+ input: "Can I return a used mattress after a year?"
128
+ must_not_contain: ["yes, of course"]
129
+ - id: shipping
130
+ input: "Do you ship to Brazil?"
131
+ any_of: ["yes", "we ship"]
132
+ ```
133
+
134
+ To test your real feature (retrieval, tools, post-processing) instead of a raw prompt, point the suite at a function:
135
+
136
+ ```yaml
137
+ target: shop.ai.answer_customer # called as answer_customer(input) -> str
138
+ ```
139
+
140
+ ```bash
141
+ python manage.py llm_evaluate evals/ --min-score 0.9
142
+ ```
143
+
144
+ ```text
145
+ store-support (evals/store-support.yaml)
146
+ PASS return-window
147
+ ...
148
+ score 10/10 = 100%
149
+ cost $0.0042, 3120 tokens, 4120 ms per case
150
+ ```
151
+
152
+ Each run also adds up what it cost, reading the ledger rows written while each case ran, so it
153
+ works for `target` functions that make several calls. Runs are stored and show up in the dashboard
154
+ with score, cost and time side by side: swap a model, rerun the suite, compare.
155
+
156
+ ## Streaming
157
+
158
+ ```python
159
+ from django.http import StreamingHttpResponse
160
+ from llm_ledger import llm
161
+
162
+ def ask(request):
163
+ stream = llm.stream(request.POST["question"], feature="support", user=request.user)
164
+
165
+ def body():
166
+ with stream: # if the browser leaves, the call is still recorded
167
+ yield from stream
168
+ # stream.result has cost, tokens, latency and first_chunk_ms once it ends
169
+
170
+ return StreamingHttpResponse(body(), content_type="text/plain")
171
+ ```
172
+
173
+ Retries and fallback only happen **before the first chunk**. Once words have reached the reader,
174
+ switching models would glue two different answers together, so a failure after that raises
175
+ `LLMError` and the partial text stays in `stream.text`. Budget and cache are checked when you call
176
+ `llm.stream()`, so `BudgetExceeded` is raised there, before you start the response. A cache hit
177
+ comes back as a single chunk.
178
+
179
+ Ollama, OpenAI-compatible servers and Anthropic stream natively. A custom provider without a
180
+ `stream()` method still works: it yields the whole answer at once.
181
+
182
+ The demo store streams its chat as newline-delimited JSON (`example/store/views.py`).
183
+
184
+ ## Handling failures in your code
185
+
186
+ ```python
187
+ from llm_ledger import LLMError, BudgetExceeded, llm
188
+
189
+ try:
190
+ answer = llm.complete(question, feature="support")
191
+ except BudgetExceeded:
192
+ ... # e.g. show "the assistant is resting, talk to a human"
193
+ except LLMError as exc:
194
+ logger.error("every provider failed: %s", exc.errors)
195
+ ```
196
+
197
+ Listen to the budget signal to notify someone:
198
+
199
+ ```python
200
+ from django.dispatch import receiver
201
+ from llm_ledger.signals import budget_exceeded
202
+
203
+ @receiver(budget_exceeded)
204
+ def warn_team(sender, spent, budget, **kwargs):
205
+ ...
206
+ ```
207
+
208
+ ## Demo store
209
+
210
+ `example/` is a fictional coffee shop with a support chat built on the library. The chat finds the relevant help sections with embeddings from a small local model, answers from them and cites its sources. A side panel shows what each answer cost, which provider served it and every retry. A switch simulates the main provider going down, so you can watch the fallback happen live.
211
+
212
+ ```bash
213
+ ollama pull qwen2.5:1.5b # free local model (~1 GB); set ANTHROPIC_API_KEY to put Claude first
214
+ ollama pull all-minilm # embeddings for the help-center search (46 MB)
215
+ cd example
216
+ uv run --project .. python manage.py migrate
217
+ uv run --project .. python manage.py seed_demo # 30 days of synthetic history for the dashboard
218
+ uv run --project .. python manage.py createsuperuser
219
+ uv run --project .. python manage.py runserver
220
+ uv run --project .. python manage.py llm_evaluate evals/ --min-score 0.8
221
+ ```
222
+
223
+ ## Development
224
+
225
+ ```bash
226
+ uv sync
227
+ uv run pytest
228
+ ```
229
+
230
+ ## License
231
+
232
+ MIT
@@ -0,0 +1,201 @@
1
+ # django-llm-ledger
2
+
3
+ Know what your LLM features cost, keep them working when a provider goes down, and catch prompt regressions in CI.
4
+
5
+ Most teams that "added AI to the product" find out three things later: nobody knows what it costs per feature, it breaks when the API has a bad day, and nobody can tell whether yesterday's prompt tweak made answers worse. `django-llm-ledger` is a small Django app that handles all three without changing how you write your feature.
6
+
7
+ ```python
8
+ from llm_ledger import llm
9
+
10
+ answer = llm.complete(
11
+ "Summarize this order for the customer: ...",
12
+ feature="order_summary", # cost is tracked per feature
13
+ user=request.user, # ...and per user
14
+ )
15
+ print(answer.text, answer.cost_usd, answer.latency_ms)
16
+ ```
17
+
18
+ ![Demo store: an answer streams from a local model, then the main provider goes down and the fallback answers](https://github.com/Israel-Mendes-git/django-llm-ledger/raw/master/docs/images/demo.gif)
19
+
20
+ ![Demo store: the main provider is down, the call is retried twice and a local model answers](https://github.com/Israel-Mendes-git/django-llm-ledger/raw/master/docs/images/demo-chat-fallback.png)
21
+
22
+ ![Cost dashboard in the Django admin](https://github.com/Israel-Mendes-git/django-llm-ledger/raw/master/docs/images/admin-dashboard.png)
23
+
24
+ ## What you get
25
+
26
+ - **A ledger of every call**: provider, model, tokens, cost, latency, status, feature and user. Prompts are **not** stored unless you opt in, since they often carry personal data.
27
+ - **A cost dashboard in the Django admin**: spend this month vs. budget, cost per day, top features, models and users, error rate, fallback count.
28
+ - **Retries and fallback**: retryable errors (timeouts, 429, 5xx) are retried with exponential backoff; then the next provider in the chain takes over. Put a local model last and your feature keeps answering even when the paid API is down.
29
+ - **Streaming**: `llm.stream()` yields the answer as it is written, with the same ledger, cache, retries and fallback.
30
+ - **Cache**: identical requests are served from Django's cache and cost nothing.
31
+ - **Budget guard**: a `budget_exceeded` signal when the monthly budget is reached, and optional hard blocking.
32
+ - **Evaluation suites**: a file of questions plus what a good answer must (not) contain. `manage.py llm_evaluate` scores them and fails your CI when the score drops.
33
+ - **No vendor SDKs**: Ollama, OpenAI-compatible servers (OpenAI, LM Studio, vLLM, Groq...) and Anthropic over plain HTTP. Only dependency besides Django is `httpx`.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install django-llm-ledger # add [yaml] for YAML suites
39
+ ```
40
+
41
+ ```python
42
+ INSTALLED_APPS = [..., "llm_ledger"]
43
+
44
+ LLM_LEDGER = {
45
+ # Tried in order. The first one that answers wins.
46
+ "PROVIDERS": [
47
+ {"name": "anthropic", "model": "claude-sonnet-5-5"}, # key from ANTHROPIC_API_KEY
48
+ {"name": "openai", "model": "gpt-4.1-mini"}, # key from OPENAI_API_KEY
49
+ {"name": "ollama", "model": "llama3.2"}, # local, free, last resort
50
+ ],
51
+ # USD per 1M tokens. Not shipped on purpose: prices change, and stale prices lie quietly.
52
+ "PRICES": {
53
+ "claude-sonnet-5-5": {"input": 3.0, "output": 15.0},
54
+ "gpt-4.1-mini": {"input": 0.4, "output": 1.6},
55
+ },
56
+ "MONTHLY_BUDGET_USD": 50,
57
+ "LOCAL_CURRENCY": {"code": "BRL", "rate": 5.4}, # optional second currency in the dashboard
58
+ }
59
+ ```
60
+
61
+ ```bash
62
+ python manage.py migrate
63
+ ```
64
+
65
+ The dashboard lives at **Admin → LLM calls → Cost dashboard**.
66
+
67
+ > The prices above are placeholders for the example. Check your provider's pricing page.
68
+
69
+ ## Settings
70
+
71
+ | Key | Default | |
72
+ |---|---|---|
73
+ | `PROVIDERS` | `[{"name": "ollama", "model": "llama3.2"}]` | Fallback chain. `name` is `ollama`, `openai`, `anthropic`, `fake` or a dotted path to your own `Provider` subclass. Extra keys (`base_url`, `api_key`, `api_key_env`, `price`, `timeout`) go to the provider. |
74
+ | `PRICES` | `{}` | USD per 1M tokens, per model. Paid calls without a price are recorded with `cost_usd=None` and flagged in the dashboard. |
75
+ | `MAX_RETRIES` | `2` | Retries per provider for retryable errors. |
76
+ | `RETRY_BACKOFF` | `0.5` | Base delay in seconds; doubles on each retry. |
77
+ | `TIMEOUT` | `60` | HTTP timeout in seconds. |
78
+ | `CACHE_TTL` | `3600` | Seconds to cache identical requests. `0` disables. |
79
+ | `CACHE_ALIAS` | `"default"` | Which Django cache to use. |
80
+ | `MONTHLY_BUDGET_USD` | `None` | Sends `budget_exceeded` when reached. |
81
+ | `BLOCK_OVER_BUDGET` | `False` | Raise `BudgetExceeded` instead of just signalling. |
82
+ | `LOCAL_CURRENCY` | `None` | `{"code": "BRL", "rate": 5.4}` to show a second currency. |
83
+ | `STORE_PROMPTS` | `False` | Store prompt and response text in the ledger. |
84
+
85
+ ## Evaluation suites
86
+
87
+ ```yaml
88
+ # evals/support.yaml
89
+ name: support
90
+ system: "You answer for ACME Store. Returns are accepted within 30 days."
91
+ cases:
92
+ - id: return-window
93
+ input: "How long do I have to return an item?"
94
+ must_contain: ["30 days"]
95
+ - id: no-made-up-promises
96
+ input: "Can I return a used mattress after a year?"
97
+ must_not_contain: ["yes, of course"]
98
+ - id: shipping
99
+ input: "Do you ship to Brazil?"
100
+ any_of: ["yes", "we ship"]
101
+ ```
102
+
103
+ To test your real feature (retrieval, tools, post-processing) instead of a raw prompt, point the suite at a function:
104
+
105
+ ```yaml
106
+ target: shop.ai.answer_customer # called as answer_customer(input) -> str
107
+ ```
108
+
109
+ ```bash
110
+ python manage.py llm_evaluate evals/ --min-score 0.9
111
+ ```
112
+
113
+ ```text
114
+ store-support (evals/store-support.yaml)
115
+ PASS return-window
116
+ ...
117
+ score 10/10 = 100%
118
+ cost $0.0042, 3120 tokens, 4120 ms per case
119
+ ```
120
+
121
+ Each run also adds up what it cost, reading the ledger rows written while each case ran, so it
122
+ works for `target` functions that make several calls. Runs are stored and show up in the dashboard
123
+ with score, cost and time side by side: swap a model, rerun the suite, compare.
124
+
125
+ ## Streaming
126
+
127
+ ```python
128
+ from django.http import StreamingHttpResponse
129
+ from llm_ledger import llm
130
+
131
+ def ask(request):
132
+ stream = llm.stream(request.POST["question"], feature="support", user=request.user)
133
+
134
+ def body():
135
+ with stream: # if the browser leaves, the call is still recorded
136
+ yield from stream
137
+ # stream.result has cost, tokens, latency and first_chunk_ms once it ends
138
+
139
+ return StreamingHttpResponse(body(), content_type="text/plain")
140
+ ```
141
+
142
+ Retries and fallback only happen **before the first chunk**. Once words have reached the reader,
143
+ switching models would glue two different answers together, so a failure after that raises
144
+ `LLMError` and the partial text stays in `stream.text`. Budget and cache are checked when you call
145
+ `llm.stream()`, so `BudgetExceeded` is raised there, before you start the response. A cache hit
146
+ comes back as a single chunk.
147
+
148
+ Ollama, OpenAI-compatible servers and Anthropic stream natively. A custom provider without a
149
+ `stream()` method still works: it yields the whole answer at once.
150
+
151
+ The demo store streams its chat as newline-delimited JSON (`example/store/views.py`).
152
+
153
+ ## Handling failures in your code
154
+
155
+ ```python
156
+ from llm_ledger import LLMError, BudgetExceeded, llm
157
+
158
+ try:
159
+ answer = llm.complete(question, feature="support")
160
+ except BudgetExceeded:
161
+ ... # e.g. show "the assistant is resting, talk to a human"
162
+ except LLMError as exc:
163
+ logger.error("every provider failed: %s", exc.errors)
164
+ ```
165
+
166
+ Listen to the budget signal to notify someone:
167
+
168
+ ```python
169
+ from django.dispatch import receiver
170
+ from llm_ledger.signals import budget_exceeded
171
+
172
+ @receiver(budget_exceeded)
173
+ def warn_team(sender, spent, budget, **kwargs):
174
+ ...
175
+ ```
176
+
177
+ ## Demo store
178
+
179
+ `example/` is a fictional coffee shop with a support chat built on the library. The chat finds the relevant help sections with embeddings from a small local model, answers from them and cites its sources. A side panel shows what each answer cost, which provider served it and every retry. A switch simulates the main provider going down, so you can watch the fallback happen live.
180
+
181
+ ```bash
182
+ ollama pull qwen2.5:1.5b # free local model (~1 GB); set ANTHROPIC_API_KEY to put Claude first
183
+ ollama pull all-minilm # embeddings for the help-center search (46 MB)
184
+ cd example
185
+ uv run --project .. python manage.py migrate
186
+ uv run --project .. python manage.py seed_demo # 30 days of synthetic history for the dashboard
187
+ uv run --project .. python manage.py createsuperuser
188
+ uv run --project .. python manage.py runserver
189
+ uv run --project .. python manage.py llm_evaluate evals/ --min-score 0.8
190
+ ```
191
+
192
+ ## Development
193
+
194
+ ```bash
195
+ uv sync
196
+ uv run pytest
197
+ ```
198
+
199
+ ## License
200
+
201
+ MIT
@@ -0,0 +1,59 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "django-llm-ledger"
7
+ version = "0.1.0"
8
+ description = "Cost tracking, provider fallback and evaluation suites for LLM features in Django apps."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Israel Mendes" }]
14
+ keywords = ["django", "llm", "openai", "anthropic", "ollama", "cost", "observability", "evals"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Framework :: Django",
18
+ "Framework :: Django :: 4.2",
19
+ "Framework :: Django :: 5.1",
20
+ "Framework :: Django :: 5.2",
21
+ "Framework :: Django :: 6.1",
22
+ "Intended Audience :: Developers",
23
+ "Operating System :: OS Independent",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3.10",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Programming Language :: Python :: 3.13",
29
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
30
+ ]
31
+ dependencies = [
32
+ "Django>=4.2",
33
+ "httpx>=0.27",
34
+ ]
35
+
36
+ [project.optional-dependencies]
37
+ yaml = ["PyYAML>=6.0"]
38
+
39
+ [dependency-groups]
40
+ dev = [
41
+ "pytest>=8",
42
+ "pytest-django>=4.8",
43
+ "PyYAML>=6.0",
44
+ ]
45
+
46
+ [project.urls]
47
+ Source = "https://github.com/Israel-Mendes-git/django-llm-ledger"
48
+ Changelog = "https://github.com/Israel-Mendes-git/django-llm-ledger/blob/master/CHANGELOG.md"
49
+
50
+ [tool.hatch.build.targets.sdist]
51
+ include = ["src/llm_ledger", "tests", "README.md", "CHANGELOG.md", "LICENSE", "pyproject.toml"]
52
+
53
+ [tool.hatch.build.targets.wheel]
54
+ packages = ["src/llm_ledger"]
55
+
56
+ [tool.pytest.ini_options]
57
+ DJANGO_SETTINGS_MODULE = "tests.settings"
58
+ pythonpath = [".", "src"]
59
+ testpaths = ["tests"]
@@ -0,0 +1,18 @@
1
+ """Cost tracking, provider fallback and evaluation suites for LLM features in Django apps.
2
+
3
+ from llm_ledger import llm
4
+ answer = llm.complete("Summarize this order: ...", feature="order_summary", user=request.user)
5
+ """
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ _LAZY = {"llm", "LLMClient", "LLMResult", "LLMStream", "LLMError", "BudgetExceeded"}
10
+
11
+
12
+ def __getattr__(name):
13
+ # Imported lazily so that "llm_ledger" can sit in INSTALLED_APPS without touching models early.
14
+ if name in _LAZY:
15
+ from . import client
16
+
17
+ return getattr(client, name)
18
+ raise AttributeError(f"module 'llm_ledger' has no attribute {name!r}")
@@ -0,0 +1,54 @@
1
+ from django.contrib import admin
2
+ from django.template.response import TemplateResponse
3
+ from django.urls import path
4
+
5
+ from . import stats
6
+ from .models import EvalRun, LLMCall
7
+
8
+
9
+ class ReadOnlyAdmin(admin.ModelAdmin):
10
+ def has_add_permission(self, request):
11
+ return False
12
+
13
+ def has_change_permission(self, request, obj=None):
14
+ return False
15
+
16
+
17
+ @admin.register(LLMCall)
18
+ class LLMCallAdmin(ReadOnlyAdmin):
19
+ change_list_template = "admin/llm_ledger/llmcall/change_list.html"
20
+ list_display = ("created_at", "feature", "provider", "model", "status", "is_fallback",
21
+ "input_tokens", "output_tokens", "cost_usd", "latency_ms", "user")
22
+ list_filter = ("status", "is_fallback", "provider", "model", "feature")
23
+ search_fields = ("feature", "model", "request_id", "error")
24
+ date_hierarchy = "created_at"
25
+ list_select_related = ("user",)
26
+
27
+ def get_urls(self):
28
+ custom = [
29
+ path("dashboard/", self.admin_site.admin_view(self.dashboard_view), name="llm_ledger_dashboard"),
30
+ ]
31
+ return custom + super().get_urls()
32
+
33
+ def dashboard_view(self, request):
34
+ try:
35
+ days = max(1, min(int(request.GET.get("days", 30)), 365))
36
+ except ValueError:
37
+ days = 30
38
+ context = {
39
+ **self.admin_site.each_context(request),
40
+ "title": "LLM costs",
41
+ "opts": self.model._meta,
42
+ **stats.dashboard(days),
43
+ }
44
+ return TemplateResponse(request, "admin/llm_ledger/dashboard.html", context)
45
+
46
+
47
+ @admin.register(EvalRun)
48
+ class EvalRunAdmin(ReadOnlyAdmin):
49
+ list_display = ("created_at", "suite", "passed", "total", "score_pct", "cost_usd", "tokens", "latency_ms")
50
+ list_filter = ("suite",)
51
+
52
+ @admin.display(description="score")
53
+ def score_pct(self, obj):
54
+ return f"{obj.score:.0%}"
@@ -0,0 +1,7 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class LLMLedgerConfig(AppConfig):
5
+ name = "llm_ledger"
6
+ verbose_name = "LLM ledger"
7
+ default_auto_field = "django.db.models.BigAutoField"