django-explain-errors 0.11.0__tar.gz → 0.12.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.
- {django_explain_errors-0.11.0/django_explain_errors.egg-info → django_explain_errors-0.12.0}/PKG-INFO +13 -10
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/README.md +12 -9
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0/django_explain_errors.egg-info}/PKG-INFO +13 -10
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/django_explain_errors.egg-info/SOURCES.txt +1 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/explain_errors/middleware.py +54 -7
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/setup.py +1 -1
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_debug_page.py +1 -1
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_eval_judge.py +158 -0
- django_explain_errors-0.12.0/tests/test_eval_run.py +831 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_middleware.py +2 -2
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_print_stdout.py +3 -3
- django_explain_errors-0.12.0/tests/test_token_param_fallback.py +249 -0
- django_explain_errors-0.11.0/tests/test_eval_run.py +0 -389
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/LICENSE +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/MANIFEST.in +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/django_explain_errors.egg-info/dependency_links.txt +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/django_explain_errors.egg-info/requires.txt +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/django_explain_errors.egg-info/top_level.txt +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/explain_errors/__init__.py +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/explain_errors/client.py +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/explain_errors/debug_page.py +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/explain_errors/management/__init__.py +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/explain_errors/management/commands/__init__.py +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/explain_errors/management/commands/build_error_index.py +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/explain_errors/rag/__init__.py +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/explain_errors/rag/indexer.py +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/explain_errors/rag/retriever.py +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/explain_errors/rag/store.py +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/explain_errors/sanitize.py +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/explain_errors/throttle.py +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/explain_errors/tracebacks.py +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/setup.cfg +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_async_marking.py +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_build_error_index.py +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_client.py +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_dedup.py +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_eval_fixtures.py +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_language.py +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_local_embeddings.py +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_process_exception_split.py +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_rag.py +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_sanitize.py +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_signals.py +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_throttle.py +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_tracebacks.py +0 -0
- {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_truncation.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-explain-errors
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.0
|
|
4
4
|
Summary: Django middleware that explains unhandled exceptions in DEBUG using an LLM, optionally grounded in your own project source via a local vector index. Works with OpenAI, Claude, or any OpenAI-compatible endpoint including local models.
|
|
5
5
|
Home-page: https://github.com/topunix/django-explain-errors
|
|
6
6
|
Author: topunix
|
|
@@ -68,8 +68,8 @@ Explains Django exceptions on the debug page, pointing at the real fix in your o
|
|
|
68
68
|
</p>
|
|
69
69
|
|
|
70
70
|
*The traceback held only Django and SQLite internals. With RAG enabled, the explanation names the
|
|
71
|
-
failing function (`clone_latest_post`) and gives the one-line fix. Real
|
|
72
|
-
the
|
|
71
|
+
failing function (`clone_latest_post`) and gives the one-line fix. Real output from the eval
|
|
72
|
+
harness, generated with `gpt-4o-mini` (the default before `gpt-6-luna`).*
|
|
73
73
|
|
|
74
74
|
- **Strongly recommended:** enable RAG over your project, so explanations name the actual file and function instead of guessing ([measured in the eval harness](#does-rag-actually-help)). It is off by default only because [setup](#codebase-aware-explanations-rag) takes three steps.
|
|
75
75
|
- Works with OpenAI, Claude, or any OpenAI-compatible endpoint, including local models via Ollama
|
|
@@ -196,7 +196,7 @@ python manage.py check --deploy --fail-level WARNING
|
|
|
196
196
|
In the terminal, the explanation is printed with the model that produced it:
|
|
197
197
|
|
|
198
198
|
```
|
|
199
|
-
Error explanation (gpt-
|
|
199
|
+
Error explanation (gpt-6-luna):
|
|
200
200
|
The view raised a ValueError because ...
|
|
201
201
|
```
|
|
202
202
|
|
|
@@ -272,13 +272,17 @@ catches the exception internally, so it never becomes an unhandled exception, bu
|
|
|
272
272
|
than relying on `got_request_exception`. That event is unrelated to whatever error the
|
|
273
273
|
developer is actually investigating.
|
|
274
274
|
|
|
275
|
+
OpenAI reasoning models are supported. They reject `max_tokens`, so when the API rejects it
|
|
276
|
+
the middleware retries once with `max_completion_tokens` (same value, `OPENAI_MAX_TOKENS`)
|
|
277
|
+
and keeps using that for later calls. Other providers keep receiving `max_tokens`.
|
|
278
|
+
|
|
275
279
|
## Configuration
|
|
276
280
|
|
|
277
281
|
| Setting / variable | Required | Description |
|
|
278
282
|
| ------------------ | -------- | ----------- |
|
|
279
283
|
| `OPENAI_API_KEY` (env or settings) | Yes, unless `OPENAI_BASE_URL` points at an endpoint that does not authenticate | API key used to authenticate with the configured endpoint. Read first from the environment, then from `settings`. |
|
|
280
284
|
| `DEBUG` | Yes | The middleware is only active when `DEBUG=True`. When `False`, requests pass through untouched. |
|
|
281
|
-
| `OPENAI_MODEL` | No | Model used for explanations. Defaults to `gpt-4o-mini
|
|
285
|
+
| `OPENAI_MODEL` | No | Model used for explanations. Defaults to `gpt-6-luna` (was `gpt-4o-mini`). If you set `OPENAI_MODEL` yourself, the default change does not affect you, but OpenAI retires `gpt-4o`, `gpt-4o-mini`, `gpt-4.1`, and `gpt-4.1-mini` on 2027-01-14, after which calls to them fail. |
|
|
282
286
|
| `OPENAI_MAX_TOKENS` | No | Ceiling on tokens generated for the explanation, not a target — the system prompt itself asks for a concise answer. Defaults to `1000`; scales up automatically when `EXPLAIN_ERRORS_LANGUAGE` is set (see below), unless you set this explicitly, which always overrides the scaling. |
|
|
283
287
|
| `OPENAI_TIMEOUT` | No | Request timeout in seconds for model API calls. Defaults to `10`. |
|
|
284
288
|
| `OPENAI_MAX_TRACEBACK_CHARS` | No | Total character budget for the traceback sent to the model. Application frames (your own code, as opposed to Django, the standard library, or installed packages) are always kept; library frames fill whatever budget remains, nearest the raise point first, with an `... N library frames omitted ...` line where frames are dropped. If the application frames alone exceed the budget, falls back to keeping the last N characters of the raw traceback. Defaults to `3000`. |
|
|
@@ -323,7 +327,7 @@ OPENAI_API_KEY = "your_anthropic_api_key_here"
|
|
|
323
327
|
OPENAI_MODEL = "..." # see Anthropic's current model list below
|
|
324
328
|
```
|
|
325
329
|
|
|
326
|
-
`OPENAI_MODEL` is mandatory here: the default (`gpt-
|
|
330
|
+
`OPENAI_MODEL` is mandatory here: the default (`gpt-6-luna`) doesn't exist on Anthropic's API
|
|
327
331
|
and will 404. Use one of the model names from
|
|
328
332
|
[Anthropic's model overview](https://platform.claude.com/docs/en/about-claude/models/overview);
|
|
329
333
|
model names are versioned and retired over time, so check that page rather than relying on a
|
|
@@ -567,9 +571,8 @@ generated, so the extra headroom costs nothing if unused. Setting `OPENAI_MAX_TO
|
|
|
567
571
|
explicitly always overrides this scaling, at any value, including one lower than the
|
|
568
572
|
unscaled 1,000 default. The multiplier itself is deliberately generous rather than
|
|
569
573
|
precise: the underlying tokens-per-word figures are estimates, not direct
|
|
570
|
-
measurements, and
|
|
571
|
-
|
|
572
|
-
these estimates lean on.
|
|
574
|
+
measurements, and tokenizers differ across models and providers, so the
|
|
575
|
+
multiplier is not calibrated to any one of them.
|
|
573
576
|
|
|
574
577
|
## Codebase-aware explanations (RAG)
|
|
575
578
|
|
|
@@ -792,7 +795,7 @@ Most of the gap comes from two checks, measured on the 10 fixtures whose cause i
|
|
|
792
795
|
- **Points to the right file and function:** RAG-on 26, RAG-off 13
|
|
793
796
|
- **Avoids invented details:** RAG-on 26, RAG-off 17
|
|
794
797
|
|
|
795
|
-
Without your source, gpt-4o-mini tends to invent a plausible function name or parameter rather than admit it doesn't know. With the real code in front of it, it mostly doesn't.
|
|
798
|
+
Without your source, `gpt-4o-mini` (the generator in these runs, the default before `gpt-6-luna`) tends to invent a plausible function name or parameter rather than admit it doesn't know. With the real code in front of it, it mostly doesn't.
|
|
796
799
|
|
|
797
800
|
**Limitations:**
|
|
798
801
|
|
|
@@ -16,8 +16,8 @@ Explains Django exceptions on the debug page, pointing at the real fix in your o
|
|
|
16
16
|
</p>
|
|
17
17
|
|
|
18
18
|
*The traceback held only Django and SQLite internals. With RAG enabled, the explanation names the
|
|
19
|
-
failing function (`clone_latest_post`) and gives the one-line fix. Real
|
|
20
|
-
the
|
|
19
|
+
failing function (`clone_latest_post`) and gives the one-line fix. Real output from the eval
|
|
20
|
+
harness, generated with `gpt-4o-mini` (the default before `gpt-6-luna`).*
|
|
21
21
|
|
|
22
22
|
- **Strongly recommended:** enable RAG over your project, so explanations name the actual file and function instead of guessing ([measured in the eval harness](#does-rag-actually-help)). It is off by default only because [setup](#codebase-aware-explanations-rag) takes three steps.
|
|
23
23
|
- Works with OpenAI, Claude, or any OpenAI-compatible endpoint, including local models via Ollama
|
|
@@ -144,7 +144,7 @@ python manage.py check --deploy --fail-level WARNING
|
|
|
144
144
|
In the terminal, the explanation is printed with the model that produced it:
|
|
145
145
|
|
|
146
146
|
```
|
|
147
|
-
Error explanation (gpt-
|
|
147
|
+
Error explanation (gpt-6-luna):
|
|
148
148
|
The view raised a ValueError because ...
|
|
149
149
|
```
|
|
150
150
|
|
|
@@ -220,13 +220,17 @@ catches the exception internally, so it never becomes an unhandled exception, bu
|
|
|
220
220
|
than relying on `got_request_exception`. That event is unrelated to whatever error the
|
|
221
221
|
developer is actually investigating.
|
|
222
222
|
|
|
223
|
+
OpenAI reasoning models are supported. They reject `max_tokens`, so when the API rejects it
|
|
224
|
+
the middleware retries once with `max_completion_tokens` (same value, `OPENAI_MAX_TOKENS`)
|
|
225
|
+
and keeps using that for later calls. Other providers keep receiving `max_tokens`.
|
|
226
|
+
|
|
223
227
|
## Configuration
|
|
224
228
|
|
|
225
229
|
| Setting / variable | Required | Description |
|
|
226
230
|
| ------------------ | -------- | ----------- |
|
|
227
231
|
| `OPENAI_API_KEY` (env or settings) | Yes, unless `OPENAI_BASE_URL` points at an endpoint that does not authenticate | API key used to authenticate with the configured endpoint. Read first from the environment, then from `settings`. |
|
|
228
232
|
| `DEBUG` | Yes | The middleware is only active when `DEBUG=True`. When `False`, requests pass through untouched. |
|
|
229
|
-
| `OPENAI_MODEL` | No | Model used for explanations. Defaults to `gpt-4o-mini
|
|
233
|
+
| `OPENAI_MODEL` | No | Model used for explanations. Defaults to `gpt-6-luna` (was `gpt-4o-mini`). If you set `OPENAI_MODEL` yourself, the default change does not affect you, but OpenAI retires `gpt-4o`, `gpt-4o-mini`, `gpt-4.1`, and `gpt-4.1-mini` on 2027-01-14, after which calls to them fail. |
|
|
230
234
|
| `OPENAI_MAX_TOKENS` | No | Ceiling on tokens generated for the explanation, not a target — the system prompt itself asks for a concise answer. Defaults to `1000`; scales up automatically when `EXPLAIN_ERRORS_LANGUAGE` is set (see below), unless you set this explicitly, which always overrides the scaling. |
|
|
231
235
|
| `OPENAI_TIMEOUT` | No | Request timeout in seconds for model API calls. Defaults to `10`. |
|
|
232
236
|
| `OPENAI_MAX_TRACEBACK_CHARS` | No | Total character budget for the traceback sent to the model. Application frames (your own code, as opposed to Django, the standard library, or installed packages) are always kept; library frames fill whatever budget remains, nearest the raise point first, with an `... N library frames omitted ...` line where frames are dropped. If the application frames alone exceed the budget, falls back to keeping the last N characters of the raw traceback. Defaults to `3000`. |
|
|
@@ -271,7 +275,7 @@ OPENAI_API_KEY = "your_anthropic_api_key_here"
|
|
|
271
275
|
OPENAI_MODEL = "..." # see Anthropic's current model list below
|
|
272
276
|
```
|
|
273
277
|
|
|
274
|
-
`OPENAI_MODEL` is mandatory here: the default (`gpt-
|
|
278
|
+
`OPENAI_MODEL` is mandatory here: the default (`gpt-6-luna`) doesn't exist on Anthropic's API
|
|
275
279
|
and will 404. Use one of the model names from
|
|
276
280
|
[Anthropic's model overview](https://platform.claude.com/docs/en/about-claude/models/overview);
|
|
277
281
|
model names are versioned and retired over time, so check that page rather than relying on a
|
|
@@ -515,9 +519,8 @@ generated, so the extra headroom costs nothing if unused. Setting `OPENAI_MAX_TO
|
|
|
515
519
|
explicitly always overrides this scaling, at any value, including one lower than the
|
|
516
520
|
unscaled 1,000 default. The multiplier itself is deliberately generous rather than
|
|
517
521
|
precise: the underlying tokens-per-word figures are estimates, not direct
|
|
518
|
-
measurements, and
|
|
519
|
-
|
|
520
|
-
these estimates lean on.
|
|
522
|
+
measurements, and tokenizers differ across models and providers, so the
|
|
523
|
+
multiplier is not calibrated to any one of them.
|
|
521
524
|
|
|
522
525
|
## Codebase-aware explanations (RAG)
|
|
523
526
|
|
|
@@ -740,7 +743,7 @@ Most of the gap comes from two checks, measured on the 10 fixtures whose cause i
|
|
|
740
743
|
- **Points to the right file and function:** RAG-on 26, RAG-off 13
|
|
741
744
|
- **Avoids invented details:** RAG-on 26, RAG-off 17
|
|
742
745
|
|
|
743
|
-
Without your source, gpt-4o-mini tends to invent a plausible function name or parameter rather than admit it doesn't know. With the real code in front of it, it mostly doesn't.
|
|
746
|
+
Without your source, `gpt-4o-mini` (the generator in these runs, the default before `gpt-6-luna`) tends to invent a plausible function name or parameter rather than admit it doesn't know. With the real code in front of it, it mostly doesn't.
|
|
744
747
|
|
|
745
748
|
**Limitations:**
|
|
746
749
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-explain-errors
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.0
|
|
4
4
|
Summary: Django middleware that explains unhandled exceptions in DEBUG using an LLM, optionally grounded in your own project source via a local vector index. Works with OpenAI, Claude, or any OpenAI-compatible endpoint including local models.
|
|
5
5
|
Home-page: https://github.com/topunix/django-explain-errors
|
|
6
6
|
Author: topunix
|
|
@@ -68,8 +68,8 @@ Explains Django exceptions on the debug page, pointing at the real fix in your o
|
|
|
68
68
|
</p>
|
|
69
69
|
|
|
70
70
|
*The traceback held only Django and SQLite internals. With RAG enabled, the explanation names the
|
|
71
|
-
failing function (`clone_latest_post`) and gives the one-line fix. Real
|
|
72
|
-
the
|
|
71
|
+
failing function (`clone_latest_post`) and gives the one-line fix. Real output from the eval
|
|
72
|
+
harness, generated with `gpt-4o-mini` (the default before `gpt-6-luna`).*
|
|
73
73
|
|
|
74
74
|
- **Strongly recommended:** enable RAG over your project, so explanations name the actual file and function instead of guessing ([measured in the eval harness](#does-rag-actually-help)). It is off by default only because [setup](#codebase-aware-explanations-rag) takes three steps.
|
|
75
75
|
- Works with OpenAI, Claude, or any OpenAI-compatible endpoint, including local models via Ollama
|
|
@@ -196,7 +196,7 @@ python manage.py check --deploy --fail-level WARNING
|
|
|
196
196
|
In the terminal, the explanation is printed with the model that produced it:
|
|
197
197
|
|
|
198
198
|
```
|
|
199
|
-
Error explanation (gpt-
|
|
199
|
+
Error explanation (gpt-6-luna):
|
|
200
200
|
The view raised a ValueError because ...
|
|
201
201
|
```
|
|
202
202
|
|
|
@@ -272,13 +272,17 @@ catches the exception internally, so it never becomes an unhandled exception, bu
|
|
|
272
272
|
than relying on `got_request_exception`. That event is unrelated to whatever error the
|
|
273
273
|
developer is actually investigating.
|
|
274
274
|
|
|
275
|
+
OpenAI reasoning models are supported. They reject `max_tokens`, so when the API rejects it
|
|
276
|
+
the middleware retries once with `max_completion_tokens` (same value, `OPENAI_MAX_TOKENS`)
|
|
277
|
+
and keeps using that for later calls. Other providers keep receiving `max_tokens`.
|
|
278
|
+
|
|
275
279
|
## Configuration
|
|
276
280
|
|
|
277
281
|
| Setting / variable | Required | Description |
|
|
278
282
|
| ------------------ | -------- | ----------- |
|
|
279
283
|
| `OPENAI_API_KEY` (env or settings) | Yes, unless `OPENAI_BASE_URL` points at an endpoint that does not authenticate | API key used to authenticate with the configured endpoint. Read first from the environment, then from `settings`. |
|
|
280
284
|
| `DEBUG` | Yes | The middleware is only active when `DEBUG=True`. When `False`, requests pass through untouched. |
|
|
281
|
-
| `OPENAI_MODEL` | No | Model used for explanations. Defaults to `gpt-4o-mini
|
|
285
|
+
| `OPENAI_MODEL` | No | Model used for explanations. Defaults to `gpt-6-luna` (was `gpt-4o-mini`). If you set `OPENAI_MODEL` yourself, the default change does not affect you, but OpenAI retires `gpt-4o`, `gpt-4o-mini`, `gpt-4.1`, and `gpt-4.1-mini` on 2027-01-14, after which calls to them fail. |
|
|
282
286
|
| `OPENAI_MAX_TOKENS` | No | Ceiling on tokens generated for the explanation, not a target — the system prompt itself asks for a concise answer. Defaults to `1000`; scales up automatically when `EXPLAIN_ERRORS_LANGUAGE` is set (see below), unless you set this explicitly, which always overrides the scaling. |
|
|
283
287
|
| `OPENAI_TIMEOUT` | No | Request timeout in seconds for model API calls. Defaults to `10`. |
|
|
284
288
|
| `OPENAI_MAX_TRACEBACK_CHARS` | No | Total character budget for the traceback sent to the model. Application frames (your own code, as opposed to Django, the standard library, or installed packages) are always kept; library frames fill whatever budget remains, nearest the raise point first, with an `... N library frames omitted ...` line where frames are dropped. If the application frames alone exceed the budget, falls back to keeping the last N characters of the raw traceback. Defaults to `3000`. |
|
|
@@ -323,7 +327,7 @@ OPENAI_API_KEY = "your_anthropic_api_key_here"
|
|
|
323
327
|
OPENAI_MODEL = "..." # see Anthropic's current model list below
|
|
324
328
|
```
|
|
325
329
|
|
|
326
|
-
`OPENAI_MODEL` is mandatory here: the default (`gpt-
|
|
330
|
+
`OPENAI_MODEL` is mandatory here: the default (`gpt-6-luna`) doesn't exist on Anthropic's API
|
|
327
331
|
and will 404. Use one of the model names from
|
|
328
332
|
[Anthropic's model overview](https://platform.claude.com/docs/en/about-claude/models/overview);
|
|
329
333
|
model names are versioned and retired over time, so check that page rather than relying on a
|
|
@@ -567,9 +571,8 @@ generated, so the extra headroom costs nothing if unused. Setting `OPENAI_MAX_TO
|
|
|
567
571
|
explicitly always overrides this scaling, at any value, including one lower than the
|
|
568
572
|
unscaled 1,000 default. The multiplier itself is deliberately generous rather than
|
|
569
573
|
precise: the underlying tokens-per-word figures are estimates, not direct
|
|
570
|
-
measurements, and
|
|
571
|
-
|
|
572
|
-
these estimates lean on.
|
|
574
|
+
measurements, and tokenizers differ across models and providers, so the
|
|
575
|
+
multiplier is not calibrated to any one of them.
|
|
573
576
|
|
|
574
577
|
## Codebase-aware explanations (RAG)
|
|
575
578
|
|
|
@@ -792,7 +795,7 @@ Most of the gap comes from two checks, measured on the 10 fixtures whose cause i
|
|
|
792
795
|
- **Points to the right file and function:** RAG-on 26, RAG-off 13
|
|
793
796
|
- **Avoids invented details:** RAG-on 26, RAG-off 17
|
|
794
797
|
|
|
795
|
-
Without your source, gpt-4o-mini tends to invent a plausible function name or parameter rather than admit it doesn't know. With the real code in front of it, it mostly doesn't.
|
|
798
|
+
Without your source, `gpt-4o-mini` (the generator in these runs, the default before `gpt-6-luna`) tends to invent a plausible function name or parameter rather than admit it doesn't know. With the real code in front of it, it mostly doesn't.
|
|
796
799
|
|
|
797
800
|
**Limitations:**
|
|
798
801
|
|
|
@@ -3,6 +3,7 @@ import logging
|
|
|
3
3
|
import threading
|
|
4
4
|
from collections import OrderedDict
|
|
5
5
|
|
|
6
|
+
import openai
|
|
6
7
|
from django.conf import settings
|
|
7
8
|
from django.http import JsonResponse
|
|
8
9
|
from asgiref.sync import markcoroutinefunction, sync_to_async
|
|
@@ -81,6 +82,9 @@ class ExplainErrorsMiddleware:
|
|
|
81
82
|
# above this one runs in sync mode and receives a coroutine.
|
|
82
83
|
markcoroutinefunction(self)
|
|
83
84
|
self.openai_client = None
|
|
85
|
+
# Output-cap parameter sent to the model. Reasoning models reject
|
|
86
|
+
# max_tokens; _create_completion switches this once, per instance.
|
|
87
|
+
self._token_param = "max_tokens"
|
|
84
88
|
self.throttle = None
|
|
85
89
|
self.dedup_enabled = False
|
|
86
90
|
self._explanation_cache = OrderedDict()
|
|
@@ -93,7 +97,7 @@ class ExplainErrorsMiddleware:
|
|
|
93
97
|
self.throttle = SlidingWindowThrottle(max_calls, window_seconds)
|
|
94
98
|
|
|
95
99
|
# Configurable via settings, with sensible defaults.
|
|
96
|
-
self.model = getattr(settings, "OPENAI_MODEL", "gpt-
|
|
100
|
+
self.model = getattr(settings, "OPENAI_MODEL", "gpt-6-luna")
|
|
97
101
|
self.language = getattr(settings, "EXPLAIN_ERRORS_LANGUAGE", None)
|
|
98
102
|
|
|
99
103
|
configured_max_tokens = getattr(settings, "OPENAI_MAX_TOKENS", None)
|
|
@@ -251,6 +255,39 @@ class ExplainErrorsMiddleware:
|
|
|
251
255
|
|
|
252
256
|
return prompt
|
|
253
257
|
|
|
258
|
+
def _create_completion(self, messages):
|
|
259
|
+
"""Send the chat completion, adapting to the model's output-cap parameter.
|
|
260
|
+
|
|
261
|
+
Sends max_tokens by default. If the API rejects exactly that parameter
|
|
262
|
+
(OpenAI reasoning models require max_completion_tokens), retries once
|
|
263
|
+
with max_completion_tokens and remembers the choice on this instance.
|
|
264
|
+
Any other error, or a failed retry, propagates to the caller.
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
def create(token_param):
|
|
268
|
+
return self.openai_client.chat.completions.create(
|
|
269
|
+
model=self.model,
|
|
270
|
+
messages=messages,
|
|
271
|
+
**{token_param: self.max_tokens},
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
return create(self._token_param)
|
|
276
|
+
except openai.BadRequestError as exc:
|
|
277
|
+
if self._token_param != "max_tokens" or not (
|
|
278
|
+
getattr(exc, "param", None) == "max_tokens"
|
|
279
|
+
and getattr(exc, "code", None) == "unsupported_parameter"
|
|
280
|
+
):
|
|
281
|
+
raise
|
|
282
|
+
response = create("max_completion_tokens")
|
|
283
|
+
self._token_param = "max_completion_tokens"
|
|
284
|
+
logger.info(
|
|
285
|
+
"explain_errors: model %s rejected max_tokens; using "
|
|
286
|
+
"max_completion_tokens instead",
|
|
287
|
+
self.model,
|
|
288
|
+
)
|
|
289
|
+
return response
|
|
290
|
+
|
|
254
291
|
def _generate_explanation(self, prompt):
|
|
255
292
|
"""Call the model and return the explanation text, or None on failure.
|
|
256
293
|
|
|
@@ -260,13 +297,11 @@ class ExplainErrorsMiddleware:
|
|
|
260
297
|
explanation = None
|
|
261
298
|
try:
|
|
262
299
|
# Call model API
|
|
263
|
-
response = self.
|
|
264
|
-
|
|
265
|
-
messages=[
|
|
300
|
+
response = self._create_completion(
|
|
301
|
+
[
|
|
266
302
|
{"role": "system", "content": self.system_prompt},
|
|
267
303
|
{"role": "user", "content": prompt},
|
|
268
|
-
]
|
|
269
|
-
max_tokens=self.max_tokens,
|
|
304
|
+
]
|
|
270
305
|
)
|
|
271
306
|
explanation = response.choices[0].message.content
|
|
272
307
|
usage = getattr(response, "usage", None)
|
|
@@ -276,11 +311,23 @@ class ExplainErrorsMiddleware:
|
|
|
276
311
|
getattr(usage, "completion_tokens", None),
|
|
277
312
|
)
|
|
278
313
|
if response.choices[0].finish_reason == "length":
|
|
314
|
+
hint = ""
|
|
315
|
+
if self._token_param == "max_completion_tokens":
|
|
316
|
+
hint = (
|
|
317
|
+
" (reasoning tokens count against this limit; raise it "
|
|
318
|
+
"if explanations come back short or empty)"
|
|
319
|
+
)
|
|
279
320
|
logger.warning(
|
|
280
|
-
"explain_errors: explanation truncated by OPENAI_MAX_TOKENS=%s",
|
|
321
|
+
"explain_errors: explanation truncated by OPENAI_MAX_TOKENS=%s%s",
|
|
281
322
|
self.max_tokens,
|
|
323
|
+
hint,
|
|
282
324
|
)
|
|
283
325
|
|
|
326
|
+
if not explanation or not explanation.strip():
|
|
327
|
+
# Reasoning can consume the whole budget and leave no text.
|
|
328
|
+
explanation = None
|
|
329
|
+
raise ValueError("the model returned an empty response")
|
|
330
|
+
|
|
284
331
|
if getattr(settings, "EXPLAIN_ERRORS_PRINT_STDOUT", True):
|
|
285
332
|
print(f"Error explanation ({self.model}):\n", explanation)
|
|
286
333
|
except Exception as e:
|
|
@@ -10,7 +10,7 @@ with open('README.md', encoding='utf-8') as f:
|
|
|
10
10
|
|
|
11
11
|
setup(
|
|
12
12
|
name='django-explain-errors',
|
|
13
|
-
version='0.
|
|
13
|
+
version='0.12.0',
|
|
14
14
|
packages=find_packages(exclude=['tests', 'tests.*', 'evals', 'evals.*']),
|
|
15
15
|
description='Django middleware that explains unhandled exceptions in DEBUG using an LLM, optionally grounded in your own project source via a local vector index. Works with OpenAI, Claude, or any OpenAI-compatible endpoint including local models.',
|
|
16
16
|
long_description_content_type='text/markdown',
|
|
@@ -165,7 +165,7 @@ class DebugPageRequestCycleTest(SimpleTestCase):
|
|
|
165
165
|
content = response.content.decode()
|
|
166
166
|
self.assertIn('id="explain-errors"', content)
|
|
167
167
|
self.assertIn("Mocked explanation.", content)
|
|
168
|
-
mock_print.assert_any_call("Error explanation (gpt-
|
|
168
|
+
mock_print.assert_any_call("Error explanation (gpt-6-luna):\n", "Mocked explanation.")
|
|
169
169
|
|
|
170
170
|
@override_settings(EXPLAIN_ERRORS_INJECT_DEBUG_PAGE=False)
|
|
171
171
|
def test_inject_debug_page_off_no_banner(self):
|
|
@@ -391,3 +391,161 @@ class CompareClaimsGuardTest(unittest.TestCase):
|
|
|
391
391
|
)
|
|
392
392
|
|
|
393
393
|
self.assertFalse(result["judge_failure"])
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _openai_error(cls, status=None):
|
|
397
|
+
"""An instance of the openai exception class without constructing its
|
|
398
|
+
httpx request/response (which httpx package openai imports varies by
|
|
399
|
+
openai version).
|
|
400
|
+
"""
|
|
401
|
+
exc = Exception.__new__(cls)
|
|
402
|
+
exc.args = ("boom",)
|
|
403
|
+
if status is not None:
|
|
404
|
+
exc.status_code = status
|
|
405
|
+
return exc
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _flaky_client(errors, then=VALID_RESPONSE):
|
|
409
|
+
"""Raises each of `errors` in turn, then returns `then`."""
|
|
410
|
+
client = MagicMock()
|
|
411
|
+
ok = MagicMock(
|
|
412
|
+
choices=[MagicMock(message=MagicMock(content=then))],
|
|
413
|
+
usage=MagicMock(prompt_tokens=7, completion_tokens=3),
|
|
414
|
+
)
|
|
415
|
+
client.chat.completions.create.side_effect = list(errors) + [ok]
|
|
416
|
+
return client
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
class CompareRetryTest(unittest.TestCase):
|
|
420
|
+
|
|
421
|
+
def test_pre_drawn_position_is_used_and_rng_untouched(self):
|
|
422
|
+
fixture = _make_fixture()
|
|
423
|
+
client = _client_returning(VALID_RESPONSE)
|
|
424
|
+
|
|
425
|
+
class ExplodingRng:
|
|
426
|
+
def random(self):
|
|
427
|
+
raise AssertionError("rng must not be drawn when rag_on_is_a is given")
|
|
428
|
+
|
|
429
|
+
result = compare(
|
|
430
|
+
client, "m", "tb", fixture, "src", "off", "on",
|
|
431
|
+
rng=ExplodingRng(), rag_on_is_a=False,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
self.assertFalse(result["rag_on_is_a"])
|
|
435
|
+
|
|
436
|
+
def test_rate_limit_is_retried_and_counted(self):
|
|
437
|
+
import openai
|
|
438
|
+
|
|
439
|
+
fixture = _make_fixture()
|
|
440
|
+
errors = [_openai_error(openai.RateLimitError, 429)] * 2
|
|
441
|
+
client = _flaky_client(errors)
|
|
442
|
+
sleeps = []
|
|
443
|
+
|
|
444
|
+
result = compare(
|
|
445
|
+
client, "m", "tb", fixture, "src", "off", "on",
|
|
446
|
+
rng=_FixedRng(0.1), sleep=sleeps.append,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
self.assertFalse(result["judge_failure"])
|
|
450
|
+
self.assertEqual(result["retries"], 2)
|
|
451
|
+
self.assertEqual(len(sleeps), 2)
|
|
452
|
+
self.assertEqual(client.chat.completions.create.call_count, 3)
|
|
453
|
+
self.assertEqual(result["prompt_tokens"], 7)
|
|
454
|
+
|
|
455
|
+
def test_backoff_grows_and_is_capped(self):
|
|
456
|
+
import openai
|
|
457
|
+
|
|
458
|
+
from evals import judge
|
|
459
|
+
|
|
460
|
+
fixture = _make_fixture()
|
|
461
|
+
client = _flaky_client([_openai_error(openai.InternalServerError, 503)] * 4)
|
|
462
|
+
ceilings = []
|
|
463
|
+
|
|
464
|
+
class RecordingJitter:
|
|
465
|
+
def uniform(self, low, high):
|
|
466
|
+
ceilings.append(high)
|
|
467
|
+
return high
|
|
468
|
+
|
|
469
|
+
original = judge._jitter_rng
|
|
470
|
+
judge._jitter_rng = RecordingJitter()
|
|
471
|
+
self.addCleanup(setattr, judge, "_jitter_rng", original)
|
|
472
|
+
|
|
473
|
+
compare(
|
|
474
|
+
client, "m", "tb", fixture, "src", "off", "on",
|
|
475
|
+
rng=_FixedRng(0.1), max_attempts=5, sleep=lambda s: None,
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
self.assertEqual(ceilings, [1.0, 2.0, 4.0, 8.0])
|
|
479
|
+
|
|
480
|
+
def test_timeout_and_connection_errors_are_retried(self):
|
|
481
|
+
import openai
|
|
482
|
+
|
|
483
|
+
fixture = _make_fixture()
|
|
484
|
+
client = _flaky_client(
|
|
485
|
+
[_openai_error(openai.APITimeoutError), _openai_error(openai.APIConnectionError)]
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
result = compare(
|
|
489
|
+
client, "m", "tb", fixture, "src", "off", "on",
|
|
490
|
+
rng=_FixedRng(0.1), sleep=lambda s: None,
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
self.assertFalse(result["judge_failure"])
|
|
494
|
+
self.assertEqual(result["retries"], 2)
|
|
495
|
+
|
|
496
|
+
def test_exhausted_retries_is_a_judge_failure_with_retries_recorded(self):
|
|
497
|
+
import openai
|
|
498
|
+
|
|
499
|
+
fixture = _make_fixture()
|
|
500
|
+
client = MagicMock()
|
|
501
|
+
client.chat.completions.create.side_effect = _openai_error(openai.RateLimitError, 429)
|
|
502
|
+
|
|
503
|
+
result = compare(
|
|
504
|
+
client, "m", "tb", fixture, "src", "off", "on",
|
|
505
|
+
rng=_FixedRng(0.1), max_attempts=3, sleep=lambda s: None,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
self.assertTrue(result["judge_failure"])
|
|
509
|
+
self.assertEqual(result["retries"], 2)
|
|
510
|
+
self.assertEqual(client.chat.completions.create.call_count, 3)
|
|
511
|
+
self.assertIn("boom", result["error"])
|
|
512
|
+
self.assertIn("rag_on_is_a", result)
|
|
513
|
+
|
|
514
|
+
def test_non_transient_error_is_not_retried(self):
|
|
515
|
+
import openai
|
|
516
|
+
|
|
517
|
+
fixture = _make_fixture()
|
|
518
|
+
client = MagicMock()
|
|
519
|
+
client.chat.completions.create.side_effect = _openai_error(
|
|
520
|
+
openai.AuthenticationError, 401
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
result = compare(
|
|
524
|
+
client, "m", "tb", fixture, "src", "off", "on",
|
|
525
|
+
rng=_FixedRng(0.1), sleep=lambda s: self.fail("must not sleep"),
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
self.assertTrue(result["judge_failure"])
|
|
529
|
+
self.assertEqual(result["retries"], 0)
|
|
530
|
+
self.assertEqual(client.chat.completions.create.call_count, 1)
|
|
531
|
+
|
|
532
|
+
def test_malformed_response_is_not_retried(self):
|
|
533
|
+
fixture = _make_fixture()
|
|
534
|
+
client = _client_returning("not json")
|
|
535
|
+
|
|
536
|
+
result = compare(
|
|
537
|
+
client, "m", "tb", fixture, "src", "off", "on",
|
|
538
|
+
rng=_FixedRng(0.1), sleep=lambda s: self.fail("must not sleep"),
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
self.assertTrue(result["judge_failure"])
|
|
542
|
+
self.assertEqual(result["retries"], 0)
|
|
543
|
+
self.assertEqual(client.chat.completions.create.call_count, 1)
|
|
544
|
+
|
|
545
|
+
def test_clean_call_records_zero_retries(self):
|
|
546
|
+
fixture = _make_fixture()
|
|
547
|
+
client = _client_returning(VALID_RESPONSE)
|
|
548
|
+
|
|
549
|
+
result = compare(client, "m", "tb", fixture, "src", "off", "on", rng=_FixedRng(0.1))
|
|
550
|
+
|
|
551
|
+
self.assertEqual(result["retries"], 0)
|