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.
Files changed (46) hide show
  1. {django_explain_errors-0.11.0/django_explain_errors.egg-info → django_explain_errors-0.12.0}/PKG-INFO +13 -10
  2. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/README.md +12 -9
  3. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0/django_explain_errors.egg-info}/PKG-INFO +13 -10
  4. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/django_explain_errors.egg-info/SOURCES.txt +1 -0
  5. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/explain_errors/middleware.py +54 -7
  6. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/setup.py +1 -1
  7. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_debug_page.py +1 -1
  8. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_eval_judge.py +158 -0
  9. django_explain_errors-0.12.0/tests/test_eval_run.py +831 -0
  10. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_middleware.py +2 -2
  11. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_print_stdout.py +3 -3
  12. django_explain_errors-0.12.0/tests/test_token_param_fallback.py +249 -0
  13. django_explain_errors-0.11.0/tests/test_eval_run.py +0 -389
  14. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/LICENSE +0 -0
  15. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/MANIFEST.in +0 -0
  16. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/django_explain_errors.egg-info/dependency_links.txt +0 -0
  17. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/django_explain_errors.egg-info/requires.txt +0 -0
  18. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/django_explain_errors.egg-info/top_level.txt +0 -0
  19. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/explain_errors/__init__.py +0 -0
  20. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/explain_errors/client.py +0 -0
  21. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/explain_errors/debug_page.py +0 -0
  22. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/explain_errors/management/__init__.py +0 -0
  23. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/explain_errors/management/commands/__init__.py +0 -0
  24. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/explain_errors/management/commands/build_error_index.py +0 -0
  25. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/explain_errors/rag/__init__.py +0 -0
  26. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/explain_errors/rag/indexer.py +0 -0
  27. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/explain_errors/rag/retriever.py +0 -0
  28. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/explain_errors/rag/store.py +0 -0
  29. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/explain_errors/sanitize.py +0 -0
  30. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/explain_errors/throttle.py +0 -0
  31. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/explain_errors/tracebacks.py +0 -0
  32. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/setup.cfg +0 -0
  33. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_async_marking.py +0 -0
  34. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_build_error_index.py +0 -0
  35. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_client.py +0 -0
  36. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_dedup.py +0 -0
  37. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_eval_fixtures.py +0 -0
  38. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_language.py +0 -0
  39. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_local_embeddings.py +0 -0
  40. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_process_exception_split.py +0 -0
  41. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_rag.py +0 -0
  42. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_sanitize.py +0 -0
  43. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_signals.py +0 -0
  44. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_throttle.py +0 -0
  45. {django_explain_errors-0.11.0 → django_explain_errors-0.12.0}/tests/test_tracebacks.py +0 -0
  46. {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.11.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 `gpt-4o-mini` output from
72
- the eval harness.*
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-4o-mini):
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-4o-mini`) doesn't exist on Anthropic's API
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 the default model (`gpt-4o-mini`) uses the `o200k_base` tokenizer,
571
- which handles non-Latin scripts considerably better than the `cl100k_base`-era ratios
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 `gpt-4o-mini` output from
20
- the eval harness.*
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-4o-mini):
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-4o-mini`) doesn't exist on Anthropic's API
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 the default model (`gpt-4o-mini`) uses the `o200k_base` tokenizer,
519
- which handles non-Latin scripts considerably better than the `cl100k_base`-era ratios
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.11.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 `gpt-4o-mini` output from
72
- the eval harness.*
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-4o-mini):
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-4o-mini`) doesn't exist on Anthropic's API
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 the default model (`gpt-4o-mini`) uses the `o200k_base` tokenizer,
571
- which handles non-Latin scripts considerably better than the `cl100k_base`-era ratios
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
 
@@ -38,5 +38,6 @@ tests/test_rag.py
38
38
  tests/test_sanitize.py
39
39
  tests/test_signals.py
40
40
  tests/test_throttle.py
41
+ tests/test_token_param_fallback.py
41
42
  tests/test_tracebacks.py
42
43
  tests/test_truncation.py
@@ -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-4o-mini")
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.openai_client.chat.completions.create(
264
- model=self.model,
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.11.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-4o-mini):\n", "Mocked explanation.")
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)