Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions python/src/edge0/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,8 +339,17 @@ def do_POST(self): # noqa: N802
if path not in ("/v1/chat/completions", "/v1/completions"):
self._json(404, {"error": {"message": f"no route {path}"}})
return
length = int(self.headers.get("Content-Length", 0))
payload = json.loads(self.rfile.read(length) or b"{}")
try:
length = int(self.headers.get("Content-Length", 0))
payload = json.loads(self.rfile.read(length) or b"{}")
except ValueError as exc:
# JSONDecodeError and UnicodeDecodeError are ValueErrors; answer
# them like any other malformed request instead of dropping the
# connection with no response.
self._json(400, {"error": {
"message": f"request body is not valid JSON: {exc}",
"type": "invalid_request"}})
return
if path == "/v1/chat/completions":
try:
parse_chat_request(payload)
Expand Down
8 changes: 7 additions & 1 deletion python/src/edge0/server/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,14 @@ def _template_tool_calls(calls) -> list[dict] | None:


def parse_chat_request(payload: dict) -> ChatRequest:
if not isinstance(payload, dict):
raise ValueError("request body must be a JSON object")
messages = payload.get("messages", [])
if not isinstance(messages, list) or not all(
isinstance(m, dict) for m in messages):
raise ValueError("messages must be a list of message objects")
msgs = []
for m in payload.get("messages", []):
for m in messages:
role = str(m.get("role", "user"))
content = m.get("content") or "" # tool-call-only assistant turns
# send content: null, not an omitted key
Expand Down
51 changes: 51 additions & 0 deletions python/tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,57 @@ def test_http_rejects_invalid_tool_arguments_before_generation(stream, transport
assert engine.generated == []


def _post_stdlib(server, path, body: bytes):
handler = type("Edge0Handler", (_StdlibHandler,), {"server_q": server})
httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
try:
req = urlrequest.Request(
f"http://127.0.0.1:{httpd.server_address[1]}{path}",
data=body, headers={"Content-Type": "application/json"})
with pytest.raises(HTTPError) as exc:
urlrequest.urlopen(req, timeout=10)
with exc.value as response:
return response.code, json.loads(response.read())
finally:
httpd.shutdown()
httpd.server_close()
thread.join(timeout=5)


@pytest.mark.parametrize("path", ["/v1/chat/completions", "/v1/completions"])
@pytest.mark.parametrize("body", [b"{not json", b"\xff\xfe"])
def test_stdlib_http_answers_a_malformed_body_with_400(path, body):
engine = FakeEngine()
code, error = _post_stdlib(QueueServer(engine), path, body)
assert code == 400
assert error["error"]["type"] == "invalid_request"
assert engine.generated == []


@pytest.mark.parametrize("transport", ["stdlib", "flask"])
@pytest.mark.parametrize("payload", [
[1, 2], "hello", {"messages": "hi"}, {"messages": ["hi"]},
{"messages": [None]},
])
def test_http_rejects_a_body_that_is_not_a_chat_request(payload, transport):
engine = FakeEngine()
server = QueueServer(engine)
if transport == "flask":
if not _HAS_FLASK:
pytest.skip("flask is not installed")
response = create_app(server).test_client().post(
"/v1/chat/completions", json=payload)
code, error = response.status_code, response.get_json()
else:
code, error = _post_stdlib(
server, "/v1/chat/completions", json.dumps(payload).encode())
assert code == 400
assert error["error"]["type"] == "invalid_request"
assert engine.generated == []


def test_stdlib_http_end_to_end():
eng = FakeEngine()
srv = QueueServer(eng, model_name="fake")
Expand Down
Loading