dj-evals 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dj_evals-0.2.0/PKG-INFO +95 -0
- dj_evals-0.2.0/README.md +82 -0
- dj_evals-0.2.0/dj_evals/__init__.py +6 -0
- dj_evals-0.2.0/dj_evals/events.py +55 -0
- dj_evals-0.2.0/dj_evals/static/dj_evals/run_page.js +180 -0
- dj_evals-0.2.0/dj_evals/templates/dj_evals/run_page.html +53 -0
- dj_evals-0.2.0/dj_evals/views.py +142 -0
- dj_evals-0.2.0/dj_evals.egg-info/PKG-INFO +95 -0
- dj_evals-0.2.0/dj_evals.egg-info/SOURCES.txt +14 -0
- dj_evals-0.2.0/dj_evals.egg-info/dependency_links.txt +1 -0
- dj_evals-0.2.0/dj_evals.egg-info/requires.txt +8 -0
- dj_evals-0.2.0/dj_evals.egg-info/top_level.txt +1 -0
- dj_evals-0.2.0/pyproject.toml +29 -0
- dj_evals-0.2.0/setup.cfg +4 -0
- dj_evals-0.2.0/tests/test_events.py +67 -0
- dj_evals-0.2.0/tests/test_views.py +160 -0
dj_evals-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dj-evals
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Internal Django SSE eval runner
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: django>=5.0
|
|
8
|
+
Provides-Extra: test
|
|
9
|
+
Requires-Dist: pytest>=8.0; extra == "test"
|
|
10
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "test"
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: ruff>=0.8; extra == "dev"
|
|
13
|
+
|
|
14
|
+
# dj-evals
|
|
15
|
+
|
|
16
|
+
Internal Django helper for running one eval function with multiple argument sets in parallel, so you can visually compare output, tool calls, usage, cost, and completion behavior as the runs happen.
|
|
17
|
+
|
|
18
|
+
## Django setup
|
|
19
|
+
|
|
20
|
+
Add `dj_evals` to `INSTALLED_APPS` and mount your eval view:
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
INSTALLED_APPS = [
|
|
24
|
+
"dj_evals",
|
|
25
|
+
]
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from django.urls import path
|
|
30
|
+
|
|
31
|
+
urlpatterns = [
|
|
32
|
+
path("evals/run/", eval_run),
|
|
33
|
+
]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Protect the eval view with your app's normal authorization checks before using it in production.
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from dj_evals import handle_eval_request
|
|
42
|
+
|
|
43
|
+
async def eval_run(request):
|
|
44
|
+
return await handle_eval_request(
|
|
45
|
+
request,
|
|
46
|
+
allowed_paths={"myapp.evals.echo_eval"},
|
|
47
|
+
)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Generate a URL for the view with one or more eval argument dictionaries:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from dj_evals import generate_eval_url
|
|
54
|
+
|
|
55
|
+
url = "/evals/run/" + generate_eval_url(
|
|
56
|
+
"myapp.evals.echo_eval",
|
|
57
|
+
{"model": "gpt-4.1", "text": "hello"},
|
|
58
|
+
{"model": "gemini-3-flash-preview", "text": "hello"},
|
|
59
|
+
)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The GET request renders the comparison page. The page starts one POST request per argument set. Each POST response is a direct SSE stream from the eval run, and the browser renders those events into the comparison panels.
|
|
63
|
+
|
|
64
|
+
## Eval function contract
|
|
65
|
+
|
|
66
|
+
The eval function must be importable by dotted path, whitelisted in `allowed_paths`, and must be an async generator.
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
async def echo_eval(text="hello", model=""):
|
|
70
|
+
yield {"type": "response.output_text.delta", "delta": text}
|
|
71
|
+
yield {"type": "response.completed", "response": {"cost": 0}}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Eval argument dictionaries are passed to the eval function as keyword arguments.
|
|
75
|
+
|
|
76
|
+
## Developers
|
|
77
|
+
|
|
78
|
+
Run a local demo server to try the library in a browser:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
uv run --with daphne daphne examples.local_server:application
|
|
82
|
+
# or, if you have just installed:
|
|
83
|
+
just run
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Then open <http://127.0.0.1:8000/>. The demo redirects to an eval URL that starts three argument sets side by side and streams their events over SSE.
|
|
87
|
+
|
|
88
|
+
Run checks from this repo:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
uv run --with ruff ruff check .
|
|
92
|
+
uv run --with pytest --with pytest-asyncio pytest -q
|
|
93
|
+
# or, if you have just installed:
|
|
94
|
+
just test
|
|
95
|
+
```
|
dj_evals-0.2.0/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# dj-evals
|
|
2
|
+
|
|
3
|
+
Internal Django helper for running one eval function with multiple argument sets in parallel, so you can visually compare output, tool calls, usage, cost, and completion behavior as the runs happen.
|
|
4
|
+
|
|
5
|
+
## Django setup
|
|
6
|
+
|
|
7
|
+
Add `dj_evals` to `INSTALLED_APPS` and mount your eval view:
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
INSTALLED_APPS = [
|
|
11
|
+
"dj_evals",
|
|
12
|
+
]
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from django.urls import path
|
|
17
|
+
|
|
18
|
+
urlpatterns = [
|
|
19
|
+
path("evals/run/", eval_run),
|
|
20
|
+
]
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Protect the eval view with your app's normal authorization checks before using it in production.
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from dj_evals import handle_eval_request
|
|
29
|
+
|
|
30
|
+
async def eval_run(request):
|
|
31
|
+
return await handle_eval_request(
|
|
32
|
+
request,
|
|
33
|
+
allowed_paths={"myapp.evals.echo_eval"},
|
|
34
|
+
)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Generate a URL for the view with one or more eval argument dictionaries:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from dj_evals import generate_eval_url
|
|
41
|
+
|
|
42
|
+
url = "/evals/run/" + generate_eval_url(
|
|
43
|
+
"myapp.evals.echo_eval",
|
|
44
|
+
{"model": "gpt-4.1", "text": "hello"},
|
|
45
|
+
{"model": "gemini-3-flash-preview", "text": "hello"},
|
|
46
|
+
)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The GET request renders the comparison page. The page starts one POST request per argument set. Each POST response is a direct SSE stream from the eval run, and the browser renders those events into the comparison panels.
|
|
50
|
+
|
|
51
|
+
## Eval function contract
|
|
52
|
+
|
|
53
|
+
The eval function must be importable by dotted path, whitelisted in `allowed_paths`, and must be an async generator.
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
async def echo_eval(text="hello", model=""):
|
|
57
|
+
yield {"type": "response.output_text.delta", "delta": text}
|
|
58
|
+
yield {"type": "response.completed", "response": {"cost": 0}}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Eval argument dictionaries are passed to the eval function as keyword arguments.
|
|
62
|
+
|
|
63
|
+
## Developers
|
|
64
|
+
|
|
65
|
+
Run a local demo server to try the library in a browser:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
uv run --with daphne daphne examples.local_server:application
|
|
69
|
+
# or, if you have just installed:
|
|
70
|
+
just run
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Then open <http://127.0.0.1:8000/>. The demo redirects to an eval URL that starts three argument sets side by side and streams their events over SSE.
|
|
74
|
+
|
|
75
|
+
Run checks from this repo:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
uv run --with ruff ruff check .
|
|
79
|
+
uv run --with pytest --with pytest-asyncio pytest -q
|
|
80
|
+
# or, if you have just installed:
|
|
81
|
+
just test
|
|
82
|
+
```
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import inspect
|
|
3
|
+
import json
|
|
4
|
+
import traceback
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def iter_eval_events(*, eval_path, kwargs):
|
|
8
|
+
"""Yield eval events as JSON-safe values for SSE publishing.
|
|
9
|
+
|
|
10
|
+
The eval function is imported from ``eval_path``, called with ``kwargs``, and
|
|
11
|
+
consumed as an async iterator. Events are kept in their original shape when
|
|
12
|
+
possible so the browser can render them.
|
|
13
|
+
"""
|
|
14
|
+
try:
|
|
15
|
+
eval_func = _import_from_dotted_path(eval_path)
|
|
16
|
+
async for event in eval_func(**kwargs):
|
|
17
|
+
yield _json_safe_event(event)
|
|
18
|
+
except Exception as exc:
|
|
19
|
+
yield {"type": "error", "message": f"{exc}\n{traceback.format_exc()}"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _event_to_dict(event):
|
|
23
|
+
"""Return SDK model events as plain dictionaries when possible."""
|
|
24
|
+
if isinstance(event, dict):
|
|
25
|
+
return event
|
|
26
|
+
if not hasattr(event, "to_dict"):
|
|
27
|
+
return event
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
return event.to_dict(mode="json", warnings=False)
|
|
31
|
+
except TypeError:
|
|
32
|
+
return event.to_dict()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _json_safe_event(event):
|
|
36
|
+
event = _event_to_dict(event)
|
|
37
|
+
try:
|
|
38
|
+
json.dumps(event)
|
|
39
|
+
except TypeError:
|
|
40
|
+
return str(event)
|
|
41
|
+
return event
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _import_from_dotted_path(eval_path):
|
|
45
|
+
"""Import and validate an async-generator eval function by dotted path."""
|
|
46
|
+
module_path, _, function_name = eval_path.rpartition(".")
|
|
47
|
+
if not module_path or not function_name:
|
|
48
|
+
raise ImportError(f"Invalid eval_path: {eval_path}")
|
|
49
|
+
module = importlib.import_module(module_path)
|
|
50
|
+
eval_func = getattr(module, function_name)
|
|
51
|
+
if not callable(eval_func):
|
|
52
|
+
raise TypeError(f"{eval_path} is not callable")
|
|
53
|
+
if not inspect.isasyncgenfunction(eval_func):
|
|
54
|
+
raise TypeError(f"{eval_path} is not an async generator function")
|
|
55
|
+
return eval_func
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
function stringify(value) {
|
|
3
|
+
if (typeof value === "string") {
|
|
4
|
+
return value;
|
|
5
|
+
}
|
|
6
|
+
try {
|
|
7
|
+
return JSON.stringify(value, null, 2);
|
|
8
|
+
} catch (error) {
|
|
9
|
+
return String(value);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function append(outputEl, text) {
|
|
14
|
+
outputEl.append(document.createTextNode(String(text)));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function appendEvent(outputEl, event) {
|
|
18
|
+
append(outputEl, "\n" + stringify(event) + "\n");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function appendEmptyLine(outputEl) {
|
|
22
|
+
append(outputEl, "\n");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function appendEvalEvent(outputEl, event) {
|
|
26
|
+
if (typeof event === "string") {
|
|
27
|
+
append(outputEl, event);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!event || typeof event !== "object") {
|
|
32
|
+
appendEvent(outputEl, event);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (event.type === "response.output_text.delta") {
|
|
37
|
+
append(outputEl, event.delta || event.text || "");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (["response.completed", "response.output_text.done"].includes(event.type)) {
|
|
42
|
+
appendEmptyLine(outputEl);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (event.type === "error") {
|
|
47
|
+
append(outputEl, "\n" + (event.message || "Unknown error") + "\n");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (event.choices) {
|
|
52
|
+
event.choices.forEach(function (choice) {
|
|
53
|
+
append(outputEl, (choice.delta || {}).content || "");
|
|
54
|
+
});
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (event.content) {
|
|
59
|
+
append(outputEl, event.content);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
appendEvent(outputEl, event);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readArguments(form) {
|
|
67
|
+
var kwargs = {};
|
|
68
|
+
new FormData(form).forEach(function (value, key) {
|
|
69
|
+
kwargs[key] = value;
|
|
70
|
+
});
|
|
71
|
+
return kwargs;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function getCsrfToken() {
|
|
75
|
+
return document.querySelector('meta[name="csrf-token"]').content;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseSseBlock(block) {
|
|
79
|
+
var eventType = "message";
|
|
80
|
+
var data = [];
|
|
81
|
+
block.replace(/\r/g, "").split("\n").forEach(function (line) {
|
|
82
|
+
if (line.indexOf("event:") === 0) {
|
|
83
|
+
eventType = line.slice(6).trim();
|
|
84
|
+
}
|
|
85
|
+
if (line.indexOf("data:") === 0) {
|
|
86
|
+
data.push(line.slice(5).trimStart());
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
return {type: eventType, data: data.join("\n")};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readSse(text, outputEl, state) {
|
|
93
|
+
var blocks = (state.pendingSseText + text).split("\n\n");
|
|
94
|
+
state.pendingSseText = blocks.pop();
|
|
95
|
+
|
|
96
|
+
blocks.forEach(function (block) {
|
|
97
|
+
var message = parseSseBlock(block);
|
|
98
|
+
if (message.type === "done") {
|
|
99
|
+
appendEmptyLine(outputEl);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Keep-alive or malformed SSE blocks may have no data.
|
|
104
|
+
if (!message.data) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (message.type === "eval-event") {
|
|
109
|
+
appendEvalEvent(outputEl, JSON.parse(message.data).event);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
appendEvent(outputEl, message);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function startRun(section) {
|
|
118
|
+
// The user can re-run a panel while the previous stream is active.
|
|
119
|
+
if (section.djEvalsAbortController) {
|
|
120
|
+
section.djEvalsAbortController.abort();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
var outputEl = section.querySelector(".events");
|
|
124
|
+
var form = section.querySelector(".eval-args");
|
|
125
|
+
var startUrl = new URL(section.dataset.startUrl, window.location.href);
|
|
126
|
+
var state = {
|
|
127
|
+
// Stream chunks can split one SSE message, so keep the unfinished tail.
|
|
128
|
+
pendingSseText: ""
|
|
129
|
+
};
|
|
130
|
+
var abortController = new AbortController();
|
|
131
|
+
section.djEvalsAbortController = abortController;
|
|
132
|
+
startUrl.searchParams.set("eval_args", JSON.stringify(readArguments(form)));
|
|
133
|
+
outputEl.textContent = "";
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
var response = await fetch(startUrl.toString(), {
|
|
137
|
+
method: "POST",
|
|
138
|
+
credentials: "same-origin",
|
|
139
|
+
headers: {"X-CSRFToken": getCsrfToken()},
|
|
140
|
+
signal: abortController.signal
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Validation and permission errors return normal HTTP failures.
|
|
144
|
+
if (!response.ok) {
|
|
145
|
+
append(outputEl, "Eval request failed: " + response.status + "\n");
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
var reader = response.body.getReader();
|
|
150
|
+
var decoder = new TextDecoder();
|
|
151
|
+
while (true) {
|
|
152
|
+
var result = await reader.read();
|
|
153
|
+
if (result.done) {
|
|
154
|
+
readSse(decoder.decode(), outputEl, state);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
readSse(decoder.decode(result.value, {stream: true}), outputEl, state);
|
|
158
|
+
}
|
|
159
|
+
} catch (error) {
|
|
160
|
+
// Re-running a panel aborts the previous request intentionally.
|
|
161
|
+
if (error.name === "AbortError") {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
append(outputEl, String(error) + "\n");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function main() {
|
|
169
|
+
document.querySelectorAll("[data-eval-run]").forEach(function (section) {
|
|
170
|
+
var form = section.querySelector(".eval-args");
|
|
171
|
+
form.addEventListener("submit", function (event) {
|
|
172
|
+
event.preventDefault();
|
|
173
|
+
startRun(section);
|
|
174
|
+
});
|
|
175
|
+
startRun(section);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
main();
|
|
180
|
+
}());
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{% load static %}
|
|
2
|
+
<!doctype html>
|
|
3
|
+
<html>
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="utf-8">
|
|
6
|
+
<meta name="csrf-token" content="{{ csrf_token }}">
|
|
7
|
+
<title>Eval run</title>
|
|
8
|
+
<style>
|
|
9
|
+
html, body { max-width: 100%; overflow-x: hidden; }
|
|
10
|
+
body { font-family: system-ui, sans-serif; margin: 24px; }
|
|
11
|
+
.runs {
|
|
12
|
+
display: flex;
|
|
13
|
+
flex-wrap: wrap;
|
|
14
|
+
gap: 16px;
|
|
15
|
+
}
|
|
16
|
+
section {
|
|
17
|
+
flex: 1 1 calc((100% - 32px) / 3);
|
|
18
|
+
min-width: 0;
|
|
19
|
+
border: 1px solid #ddd;
|
|
20
|
+
box-sizing: border-box;
|
|
21
|
+
padding: 8px;
|
|
22
|
+
}
|
|
23
|
+
pre { white-space: pre-wrap; overflow-wrap: anywhere; max-width: 100%; }
|
|
24
|
+
h1 { overflow-wrap: anywhere; }
|
|
25
|
+
label { display: block; margin: 8px 0; }
|
|
26
|
+
label span { display: block; font-weight: 600; margin-bottom: 4px; }
|
|
27
|
+
input { box-sizing: border-box; width: 100%; }
|
|
28
|
+
button { margin: 8px 0 16px; }
|
|
29
|
+
</style>
|
|
30
|
+
</head>
|
|
31
|
+
<body>
|
|
32
|
+
<main>
|
|
33
|
+
<h1>{{ eval_path }}</h1>
|
|
34
|
+
<div class="runs">
|
|
35
|
+
{% for run in runs %}
|
|
36
|
+
<section data-eval-run data-start-url="{{ run.start_url }}">
|
|
37
|
+
<form class="eval-args">
|
|
38
|
+
{% for key, value in run.arguments %}
|
|
39
|
+
<label>
|
|
40
|
+
<span>{{ key }}</span>
|
|
41
|
+
<input name="{{ key }}" value="{{ value }}">
|
|
42
|
+
</label>
|
|
43
|
+
{% endfor %}
|
|
44
|
+
<button type="submit">Run eval</button>
|
|
45
|
+
</form>
|
|
46
|
+
<pre class="events"></pre>
|
|
47
|
+
</section>
|
|
48
|
+
{% endfor %}
|
|
49
|
+
</div>
|
|
50
|
+
</main>
|
|
51
|
+
<script src="{% static 'dj_evals/run_page.js' %}"></script>
|
|
52
|
+
</body>
|
|
53
|
+
</html>
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from collections.abc import Collection
|
|
3
|
+
from urllib.parse import urlencode
|
|
4
|
+
|
|
5
|
+
from django.http import (
|
|
6
|
+
HttpResponseBadRequest,
|
|
7
|
+
HttpResponseForbidden,
|
|
8
|
+
HttpResponseNotAllowed,
|
|
9
|
+
StreamingHttpResponse,
|
|
10
|
+
)
|
|
11
|
+
from django.middleware.csrf import get_token
|
|
12
|
+
from django.shortcuts import render
|
|
13
|
+
|
|
14
|
+
from .events import iter_eval_events
|
|
15
|
+
|
|
16
|
+
EVAL_PARAM = "eval_path"
|
|
17
|
+
ARGS_PARAM = "eval_args"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def generate_eval_url(eval_path: str, *args: dict) -> str:
|
|
21
|
+
"""Generate a query-string URL for one eval path and argument sets."""
|
|
22
|
+
pairs = [(EVAL_PARAM, eval_path)]
|
|
23
|
+
for kwargs in args:
|
|
24
|
+
# Callers may pass malformed arguments; only dict kwargs can be encoded.
|
|
25
|
+
if not isinstance(kwargs, dict):
|
|
26
|
+
raise TypeError("eval arguments must be a dictionary")
|
|
27
|
+
pairs.append((
|
|
28
|
+
ARGS_PARAM,
|
|
29
|
+
json.dumps(kwargs, sort_keys=True, separators=(",", ":")),
|
|
30
|
+
))
|
|
31
|
+
return "?" + urlencode(pairs)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def handle_eval_request(request, *, allowed_paths: Collection[str]):
|
|
35
|
+
"""Render the eval runner page on GET or publish one eval run on POST.
|
|
36
|
+
|
|
37
|
+
Use this from a host Django view::
|
|
38
|
+
|
|
39
|
+
return await handle_eval_request(
|
|
40
|
+
request,
|
|
41
|
+
allowed_paths={"myapp.evals.some_eval"},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
The request should include an ``eval_path`` query parameter. It must match
|
|
45
|
+
one of the dotted paths in ``allowed_paths``. Use ``generate_eval_url`` to
|
|
46
|
+
create URLs.
|
|
47
|
+
|
|
48
|
+
All eval arguments are passed to the eval function as keyword arguments.
|
|
49
|
+
Multiple argument sets create separate SSE-backed panels.
|
|
50
|
+
|
|
51
|
+
GET renders an HTML page. Browser JavaScript starts each eval run with a
|
|
52
|
+
POST request, and that POST response streams SSE events directly.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
request: Django request with the eval query params.
|
|
56
|
+
allowed_paths: Whitelist of eval dotted paths that can be run.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
``HttpResponse`` for the outer page or run trigger.
|
|
60
|
+
"""
|
|
61
|
+
if request.method == "GET":
|
|
62
|
+
return _render_eval_page(request, allowed_paths)
|
|
63
|
+
if request.method == "POST":
|
|
64
|
+
return await _run_eval(request, allowed_paths)
|
|
65
|
+
return HttpResponseNotAllowed(["GET", "POST"])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _render_eval_page(request, allowed_paths: Collection[str]):
|
|
69
|
+
try:
|
|
70
|
+
eval_path, eval_arguments = _parse_eval_request(request, allowed_paths)
|
|
71
|
+
except PermissionError as exc:
|
|
72
|
+
return HttpResponseForbidden(str(exc))
|
|
73
|
+
except (TypeError, ValueError) as exc:
|
|
74
|
+
return HttpResponseBadRequest(str(exc))
|
|
75
|
+
|
|
76
|
+
runs = []
|
|
77
|
+
for kwargs in eval_arguments:
|
|
78
|
+
query = generate_eval_url(eval_path, kwargs)
|
|
79
|
+
runs.append({
|
|
80
|
+
"arguments": kwargs.items(),
|
|
81
|
+
"start_url": request.path + query,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
return render(request, "dj_evals/run_page.html", {
|
|
85
|
+
"csrf_token": get_token(request),
|
|
86
|
+
"eval_path": eval_path,
|
|
87
|
+
"runs": runs,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def _run_eval(request, allowed_paths: Collection[str]):
|
|
92
|
+
try:
|
|
93
|
+
eval_path, eval_arguments = _parse_eval_request(request, allowed_paths)
|
|
94
|
+
except PermissionError as exc:
|
|
95
|
+
return HttpResponseForbidden(str(exc))
|
|
96
|
+
except (TypeError, ValueError) as exc:
|
|
97
|
+
return HttpResponseBadRequest(str(exc))
|
|
98
|
+
|
|
99
|
+
async def stream_events():
|
|
100
|
+
async for event in iter_eval_events(
|
|
101
|
+
eval_path=eval_path,
|
|
102
|
+
kwargs=eval_arguments[0],
|
|
103
|
+
):
|
|
104
|
+
payload = json.dumps({"event": event}, separators=(",", ":"))
|
|
105
|
+
yield f"event: eval-event\ndata: {payload}\n\n"
|
|
106
|
+
|
|
107
|
+
yield "event: done\ndata: {}\n\n"
|
|
108
|
+
|
|
109
|
+
response = StreamingHttpResponse(
|
|
110
|
+
stream_events(),
|
|
111
|
+
content_type="text/event-stream",
|
|
112
|
+
)
|
|
113
|
+
response["Cache-Control"] = "no-cache"
|
|
114
|
+
response["X-Accel-Buffering"] = "no"
|
|
115
|
+
return response
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _parse_eval_request(
|
|
119
|
+
request,
|
|
120
|
+
allowed_paths: Collection[str],
|
|
121
|
+
) -> tuple[str, list[dict]]:
|
|
122
|
+
eval_path = request.GET.get(EVAL_PARAM, "")
|
|
123
|
+
# Reject missing or unapproved eval paths before importing host code.
|
|
124
|
+
if eval_path not in allowed_paths:
|
|
125
|
+
raise PermissionError("eval_path is not allowed")
|
|
126
|
+
|
|
127
|
+
eval_arguments = []
|
|
128
|
+
for value in request.GET.getlist(ARGS_PARAM):
|
|
129
|
+
try:
|
|
130
|
+
kwargs = json.loads(value)
|
|
131
|
+
except json.JSONDecodeError as exc:
|
|
132
|
+
raise ValueError("eval_args must be valid JSON") from exc
|
|
133
|
+
|
|
134
|
+
# Users can edit eval_args by hand, so validate before use.
|
|
135
|
+
if not isinstance(kwargs, dict):
|
|
136
|
+
raise TypeError("eval arguments must be a dictionary")
|
|
137
|
+
eval_arguments.append(kwargs)
|
|
138
|
+
|
|
139
|
+
# No eval_args means run the eval once with no kwargs.
|
|
140
|
+
if not eval_arguments:
|
|
141
|
+
eval_arguments.append({})
|
|
142
|
+
return eval_path, eval_arguments
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dj-evals
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Internal Django SSE eval runner
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: django>=5.0
|
|
8
|
+
Provides-Extra: test
|
|
9
|
+
Requires-Dist: pytest>=8.0; extra == "test"
|
|
10
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "test"
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: ruff>=0.8; extra == "dev"
|
|
13
|
+
|
|
14
|
+
# dj-evals
|
|
15
|
+
|
|
16
|
+
Internal Django helper for running one eval function with multiple argument sets in parallel, so you can visually compare output, tool calls, usage, cost, and completion behavior as the runs happen.
|
|
17
|
+
|
|
18
|
+
## Django setup
|
|
19
|
+
|
|
20
|
+
Add `dj_evals` to `INSTALLED_APPS` and mount your eval view:
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
INSTALLED_APPS = [
|
|
24
|
+
"dj_evals",
|
|
25
|
+
]
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from django.urls import path
|
|
30
|
+
|
|
31
|
+
urlpatterns = [
|
|
32
|
+
path("evals/run/", eval_run),
|
|
33
|
+
]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Protect the eval view with your app's normal authorization checks before using it in production.
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from dj_evals import handle_eval_request
|
|
42
|
+
|
|
43
|
+
async def eval_run(request):
|
|
44
|
+
return await handle_eval_request(
|
|
45
|
+
request,
|
|
46
|
+
allowed_paths={"myapp.evals.echo_eval"},
|
|
47
|
+
)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Generate a URL for the view with one or more eval argument dictionaries:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from dj_evals import generate_eval_url
|
|
54
|
+
|
|
55
|
+
url = "/evals/run/" + generate_eval_url(
|
|
56
|
+
"myapp.evals.echo_eval",
|
|
57
|
+
{"model": "gpt-4.1", "text": "hello"},
|
|
58
|
+
{"model": "gemini-3-flash-preview", "text": "hello"},
|
|
59
|
+
)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The GET request renders the comparison page. The page starts one POST request per argument set. Each POST response is a direct SSE stream from the eval run, and the browser renders those events into the comparison panels.
|
|
63
|
+
|
|
64
|
+
## Eval function contract
|
|
65
|
+
|
|
66
|
+
The eval function must be importable by dotted path, whitelisted in `allowed_paths`, and must be an async generator.
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
async def echo_eval(text="hello", model=""):
|
|
70
|
+
yield {"type": "response.output_text.delta", "delta": text}
|
|
71
|
+
yield {"type": "response.completed", "response": {"cost": 0}}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Eval argument dictionaries are passed to the eval function as keyword arguments.
|
|
75
|
+
|
|
76
|
+
## Developers
|
|
77
|
+
|
|
78
|
+
Run a local demo server to try the library in a browser:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
uv run --with daphne daphne examples.local_server:application
|
|
82
|
+
# or, if you have just installed:
|
|
83
|
+
just run
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Then open <http://127.0.0.1:8000/>. The demo redirects to an eval URL that starts three argument sets side by side and streams their events over SSE.
|
|
87
|
+
|
|
88
|
+
Run checks from this repo:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
uv run --with ruff ruff check .
|
|
92
|
+
uv run --with pytest --with pytest-asyncio pytest -q
|
|
93
|
+
# or, if you have just installed:
|
|
94
|
+
just test
|
|
95
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
dj_evals/__init__.py
|
|
4
|
+
dj_evals/events.py
|
|
5
|
+
dj_evals/views.py
|
|
6
|
+
dj_evals.egg-info/PKG-INFO
|
|
7
|
+
dj_evals.egg-info/SOURCES.txt
|
|
8
|
+
dj_evals.egg-info/dependency_links.txt
|
|
9
|
+
dj_evals.egg-info/requires.txt
|
|
10
|
+
dj_evals.egg-info/top_level.txt
|
|
11
|
+
dj_evals/static/dj_evals/run_page.js
|
|
12
|
+
dj_evals/templates/dj_evals/run_page.html
|
|
13
|
+
tests/test_events.py
|
|
14
|
+
tests/test_views.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dj_evals
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "dj-evals"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Internal Django SSE eval runner"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"django>=5.0",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[project.optional-dependencies]
|
|
12
|
+
test = [
|
|
13
|
+
"pytest>=8.0",
|
|
14
|
+
"pytest-asyncio>=0.23",
|
|
15
|
+
]
|
|
16
|
+
dev = [
|
|
17
|
+
"ruff>=0.8",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
include = ["dj_evals*"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.package-data]
|
|
25
|
+
dj_evals = ["templates/dj_evals/*.html", "static/dj_evals/*.js"]
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["setuptools>=69", "wheel"]
|
|
29
|
+
build-backend = "setuptools.build_meta"
|
dj_evals-0.2.0/setup.cfg
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from dj_evals.events import iter_eval_events
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.mark.asyncio
|
|
7
|
+
async def test_iter_eval_events_yields_raw_eval_events():
|
|
8
|
+
events = [
|
|
9
|
+
event
|
|
10
|
+
async for event in iter_eval_events(
|
|
11
|
+
eval_path="tests.fake_evals.echo_eval",
|
|
12
|
+
kwargs={"text": "from url", "model": "model-a"},
|
|
13
|
+
)
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
assert events == [
|
|
17
|
+
{"type": "response.output_text.delta", "delta": "model-a:from url"},
|
|
18
|
+
{
|
|
19
|
+
"type": "response.completed",
|
|
20
|
+
"response": {
|
|
21
|
+
"usage": {"input_tokens": 3, "output_tokens": 4},
|
|
22
|
+
"cost": 0.01,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.mark.asyncio
|
|
29
|
+
async def test_iter_eval_events_yields_failures_as_error_events():
|
|
30
|
+
events = [
|
|
31
|
+
event
|
|
32
|
+
async for event in iter_eval_events(
|
|
33
|
+
eval_path="tests.fake_evals.missing_eval",
|
|
34
|
+
kwargs={},
|
|
35
|
+
)
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
assert events[0]["type"] == "error"
|
|
39
|
+
assert "missing_eval" in events[0]["message"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.asyncio
|
|
43
|
+
async def test_iter_eval_events_serializes_sdk_events_without_pydantic_warnings():
|
|
44
|
+
events = [
|
|
45
|
+
event
|
|
46
|
+
async for event in iter_eval_events(
|
|
47
|
+
eval_path="tests.fake_evals.sdk_eval",
|
|
48
|
+
kwargs={},
|
|
49
|
+
)
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
assert events == [
|
|
53
|
+
{"type": "response.output_text.delta", "delta": "quiet sdk event"}
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.mark.asyncio
|
|
58
|
+
async def test_iter_eval_events_stringifies_non_json_events():
|
|
59
|
+
events = [
|
|
60
|
+
event
|
|
61
|
+
async for event in iter_eval_events(
|
|
62
|
+
eval_path="tests.fake_evals.object_eval",
|
|
63
|
+
kwargs={},
|
|
64
|
+
)
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
assert events[0].startswith("<object object at")
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from django.test import RequestFactory
|
|
3
|
+
|
|
4
|
+
from dj_evals import generate_eval_url, handle_eval_request
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_handle_eval_request_is_importable():
|
|
8
|
+
assert callable(handle_eval_request)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_handle_eval_request_requires_named_allowed_paths():
|
|
12
|
+
request = RequestFactory().get(
|
|
13
|
+
"/evals/run/",
|
|
14
|
+
{"eval_path": "tests.fake_evals.echo_eval"},
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
with pytest.raises(TypeError):
|
|
18
|
+
handle_eval_request(request, {"tests.fake_evals.echo_eval"})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_generate_eval_url_encodes_eval_arguments():
|
|
22
|
+
url = generate_eval_url(
|
|
23
|
+
"tests.fake_evals.echo_eval",
|
|
24
|
+
{"model": "gpt-4.1", "temperature": 0.2},
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
assert "eval_path=tests.fake_evals.echo_eval" in url
|
|
28
|
+
assert "eval_args=" in url
|
|
29
|
+
assert "temperature" in url
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_generate_eval_url_rejects_non_dict_arguments():
|
|
33
|
+
with pytest.raises(TypeError, match="dictionary"):
|
|
34
|
+
generate_eval_url("tests.fake_evals.echo_eval", ["not", "a", "dict"])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.mark.asyncio
|
|
38
|
+
async def test_handle_eval_request_renders_sse_panels_for_each_argument_set():
|
|
39
|
+
request = RequestFactory().get(
|
|
40
|
+
"/evals/run/"
|
|
41
|
+
+ generate_eval_url(
|
|
42
|
+
"tests.fake_evals.echo_eval",
|
|
43
|
+
{"model": "gpt-4.1", "text": "hello"},
|
|
44
|
+
{"model": "openrouter/openai/gpt-4.1", "text": "hello"},
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
response = await handle_eval_request(
|
|
49
|
+
request,
|
|
50
|
+
allowed_paths={"tests.fake_evals.echo_eval"},
|
|
51
|
+
)
|
|
52
|
+
html = response.content.decode()
|
|
53
|
+
|
|
54
|
+
assert response.status_code == 200
|
|
55
|
+
assert html.count("<section data-eval-run") == 2
|
|
56
|
+
assert '<script src="/static/dj_evals/run_page.js"></script>' in html
|
|
57
|
+
assert '<meta name="csrf-token"' in html
|
|
58
|
+
assert "/events/" not in html
|
|
59
|
+
assert "data-channel" not in html
|
|
60
|
+
assert "<h1>tests.fake_evals.echo_eval</h1>" in html
|
|
61
|
+
assert 'input name="model" value="gpt-4.1"' in html
|
|
62
|
+
assert 'input name="text" value="hello"' in html
|
|
63
|
+
assert 'input name="model" value="openrouter/openai/gpt-4.1"' in html
|
|
64
|
+
assert '<button type="submit">Run eval</button>' in html
|
|
65
|
+
assert "overflow-x: hidden" in html
|
|
66
|
+
assert "flex-wrap: wrap" in html
|
|
67
|
+
assert "calc((100% - 32px) / 3)" in html
|
|
68
|
+
assert "<iframe" not in html
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.mark.asyncio
|
|
72
|
+
async def test_handle_eval_request_defaults_to_one_empty_argument_set():
|
|
73
|
+
request = RequestFactory().get(
|
|
74
|
+
"/evals/run/" + generate_eval_url("tests.fake_evals.echo_eval"),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
response = await handle_eval_request(
|
|
78
|
+
request,
|
|
79
|
+
allowed_paths={"tests.fake_evals.echo_eval"},
|
|
80
|
+
)
|
|
81
|
+
html = response.content.decode()
|
|
82
|
+
|
|
83
|
+
assert response.status_code == 200
|
|
84
|
+
assert html.count("<section data-eval-run") == 1
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pytest.mark.asyncio
|
|
88
|
+
async def test_handle_eval_request_post_streams_eval_events_as_sse():
|
|
89
|
+
request = RequestFactory().post(
|
|
90
|
+
"/evals/run/"
|
|
91
|
+
+ generate_eval_url(
|
|
92
|
+
"tests.fake_evals.echo_eval",
|
|
93
|
+
{"model": "gpt-4.1", "text": "hello"},
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
response = await handle_eval_request(
|
|
98
|
+
request,
|
|
99
|
+
allowed_paths={"tests.fake_evals.echo_eval"},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
chunks = []
|
|
103
|
+
async for chunk in response.streaming_content:
|
|
104
|
+
chunks.append(chunk.decode() if isinstance(chunk, bytes) else chunk)
|
|
105
|
+
body = "".join(chunks)
|
|
106
|
+
|
|
107
|
+
assert response.status_code == 200
|
|
108
|
+
assert response["Content-Type"] == "text/event-stream"
|
|
109
|
+
assert "event: eval-event" in body
|
|
110
|
+
assert "gpt-4.1:hello" in body
|
|
111
|
+
assert "event: done" in body
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@pytest.mark.asyncio
|
|
115
|
+
async def test_handle_eval_request_rejects_disallowed_eval_path():
|
|
116
|
+
request = RequestFactory().get(
|
|
117
|
+
"/evals/run/",
|
|
118
|
+
{"eval_path": "tests.fake_evals.echo_eval"},
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
response = await handle_eval_request(
|
|
122
|
+
request,
|
|
123
|
+
allowed_paths={"tests.fake_evals.other_eval"},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
assert response.status_code == 403
|
|
127
|
+
assert b"eval_path is not allowed" in response.content
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@pytest.mark.asyncio
|
|
131
|
+
async def test_handle_eval_request_rejects_disallowed_post_eval_path():
|
|
132
|
+
request = RequestFactory().post(
|
|
133
|
+
"/evals/run/"
|
|
134
|
+
+ generate_eval_url(
|
|
135
|
+
"tests.fake_evals.echo_eval",
|
|
136
|
+
{},
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
response = await handle_eval_request(
|
|
141
|
+
request,
|
|
142
|
+
allowed_paths={"tests.fake_evals.other_eval"},
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
assert response.status_code == 403
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@pytest.mark.asyncio
|
|
149
|
+
async def test_handle_eval_request_rejects_unsupported_methods():
|
|
150
|
+
request = RequestFactory().put(
|
|
151
|
+
"/evals/run/",
|
|
152
|
+
QUERY_STRING="eval_path=tests.fake_evals.echo_eval",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
response = await handle_eval_request(
|
|
156
|
+
request,
|
|
157
|
+
allowed_paths={"tests.fake_evals.echo_eval"},
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
assert response.status_code == 405
|