unsloth/studio/tests/test_openai_completions.py
Daniel Han f08aef1804 Studio (#4237)
* Rebuild Studio branch on top of main

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix security and code quality issues for Studio PR #4237

- Validate models_dir query param against allowed directory roots
  to prevent path traversal in /api/models/local endpoint
- Replace string startswith() with Path.is_relative_to() for
  frontend path traversal check in serve_frontend
- Sanitize SSE error messages to not leak exception details to
  clients (4 locations in inference.py)
- Bind port-discovery socket to 127.0.0.1 instead of all interfaces
  in llama_cpp backend
- Import datasets_root and resolve_output_dir in embedding training
  function to fix NameError and use managed output directory
- Remove stale .gitignore entries for package-lock.json and test
  directories so tests can be tracked in version control
- Add venv-reexecution logic to ui CLI command matching the studio
  command behavior

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Move models_dir path validation before try/except block

The HTTPException(403) was inside the try/except Exception handler,
so it would be caught and re-raised as a 500. Moving the validation
before the try block ensures the 403 is returned directly and also
makes the control flow clearer for static analysis (path is validated
before any filesystem operations).

* Use os.path.realpath + startswith for models_dir validation

CodeQL py/path-injection does not recognize Path.is_relative_to() as
a sanitizer. Switched to os.path.realpath + str.startswith which is
a recognized sanitizer pattern in CodeQL's taint analysis. The
startswith check uses root_str + os.sep to prevent prefix collisions
(e.g. /app/models_evil matching /app/models).

* Never pass user input to Path constructor in models_dir validation

CodeQL traces taint through Path(resolved) even after a startswith
barrier guard. Fix: the user-supplied models_dir is only used as a
string for comparison against allowed roots. The Path object passed
to _scan_models_dir comes from the trusted allowed_roots list, not
from user input. This fully breaks the taint chain.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-03-12 03:36:19 -07:00

301 lines
11 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""
Tests for the OpenAI-compatible /chat/completions endpoint.
Validates:
- Streaming: SSE chunk format matches OpenAI spec
- Non-streaming: single JSON ChatCompletion response
- System prompt extraction from messages array
- Request validation (no messages, missing model, etc.)
- Response headers for proxy compatibility
All tests mock the inference backend and bypass auth.
"""
import sys
import json
from pathlib import Path
from unittest.mock import MagicMock, patch, PropertyMock
import pytest
# ── Path setup ────────────────────────────────────────────────────
_backend_root = Path(__file__).resolve().parent.parent / "backend"
if str(_backend_root) not in sys.path:
sys.path.insert(0, str(_backend_root))
from fastapi.testclient import TestClient
from main import app
# ── Fixtures ──────────────────────────────────────────────────────
def _make_mock_backend(
*, tokens: list[str] | None = None, active_model: str = "test-model"
):
"""Build a mock InferenceBackend that yields preset tokens."""
backend = MagicMock()
backend.active_model_name = active_model
backend.models = {active_model: {"is_vision": False}}
def fake_generate(**kwargs):
for t in tokens or ["Hello", "Hello world", "Hello world!"]:
yield t
backend.generate_chat_response = MagicMock(side_effect = fake_generate)
backend.reset_generation_state = MagicMock()
return backend
def _parse_sse_data(raw: str) -> list[dict | str]:
"""Extract `data:` payloads from raw SSE text. Returns dicts or raw strings."""
results = []
for line in raw.split("\n"):
if line.startswith("data: "):
payload = line[len("data: ") :]
if payload == "[DONE]":
results.append("[DONE]")
else:
try:
results.append(json.loads(payload))
except json.JSONDecodeError:
results.append(payload)
return results
@pytest.fixture()
def client():
yield TestClient(app)
# =====================================================================
# Streaming tests
# =====================================================================
class TestStreamingChunkFormat:
"""Each SSE chunk must match the OpenAI chat.completion.chunk schema."""
def test_chunks_have_required_fields(self, client: TestClient):
mock_backend = _make_mock_backend(tokens = ["Hi"])
with patch("routes.inference.get_inference_backend", return_value = mock_backend):
resp = client.post(
"/api/inference/chat/completions",
json = {
"messages": [{"role": "user", "content": "Hello"}],
"stream": True,
},
)
assert resp.status_code == 200
chunks = _parse_sse_data(resp.text)
# Filter to actual chunk dicts (not [DONE])
json_chunks = [c for c in chunks if isinstance(c, dict) and "choices" in c]
assert len(json_chunks) >= 2 # role chunk + content chunk(s) + final
for chunk in json_chunks:
assert "id" in chunk
assert chunk["object"] == "chat.completion.chunk"
assert "created" in chunk
assert "model" in chunk
assert len(chunk["choices"]) == 1
assert "delta" in chunk["choices"][0]
def test_first_chunk_has_role(self, client: TestClient):
mock_backend = _make_mock_backend(tokens = ["Hi"])
with patch("routes.inference.get_inference_backend", return_value = mock_backend):
resp = client.post(
"/api/inference/chat/completions",
json = {"messages": [{"role": "user", "content": "Hello"}]},
)
chunks = [
c
for c in _parse_sse_data(resp.text)
if isinstance(c, dict) and "choices" in c
]
first = chunks[0]
assert first["choices"][0]["delta"].get("role") == "assistant"
def test_last_chunk_has_stop_finish_reason(self, client: TestClient):
mock_backend = _make_mock_backend(tokens = ["Done"])
with patch("routes.inference.get_inference_backend", return_value = mock_backend):
resp = client.post(
"/api/inference/chat/completions",
json = {"messages": [{"role": "user", "content": "Hello"}]},
)
chunks = [
c
for c in _parse_sse_data(resp.text)
if isinstance(c, dict) and "choices" in c
]
last = chunks[-1]
assert last["choices"][0]["finish_reason"] == "stop"
# Delta should be empty on the final chunk
assert last["choices"][0]["delta"].get("content") is None
def test_stream_ends_with_done(self, client: TestClient):
mock_backend = _make_mock_backend(tokens = ["x"])
with patch("routes.inference.get_inference_backend", return_value = mock_backend):
resp = client.post(
"/api/inference/chat/completions",
json = {"messages": [{"role": "user", "content": "Hello"}]},
)
all_data = _parse_sse_data(resp.text)
assert all_data[-1] == "[DONE]"
def test_consistent_id_across_chunks(self, client: TestClient):
mock_backend = _make_mock_backend(tokens = ["a", "b", "c"])
with patch("routes.inference.get_inference_backend", return_value = mock_backend):
resp = client.post(
"/api/inference/chat/completions",
json = {"messages": [{"role": "user", "content": "Hello"}]},
)
chunks = [
c
for c in _parse_sse_data(resp.text)
if isinstance(c, dict) and "choices" in c
]
ids = set(c["id"] for c in chunks)
assert len(ids) == 1, "All chunks should share the same completion ID"
class TestStreamingHeaders:
"""Verify response headers for SSE proxy compatibility."""
def test_headers(self, client: TestClient):
mock_backend = _make_mock_backend(tokens = ["x"])
with patch("routes.inference.get_inference_backend", return_value = mock_backend):
resp = client.post(
"/api/inference/chat/completions",
json = {"messages": [{"role": "user", "content": "Hello"}]},
)
assert resp.headers["content-type"].startswith("text/event-stream")
assert resp.headers.get("cache-control") == "no-cache"
assert resp.headers.get("x-accel-buffering") == "no"
# =====================================================================
# Non-streaming tests
# =====================================================================
class TestNonStreaming:
"""When stream=false, return a single ChatCompletion JSON object."""
def test_returns_json_object(self, client: TestClient):
mock_backend = _make_mock_backend(tokens = ["Full response text"])
with patch("routes.inference.get_inference_backend", return_value = mock_backend):
resp = client.post(
"/api/inference/chat/completions",
json = {
"messages": [{"role": "user", "content": "Hello"}],
"stream": False,
},
)
assert resp.status_code == 200
body = resp.json()
assert body["object"] == "chat.completion"
assert body["choices"][0]["message"]["role"] == "assistant"
assert body["choices"][0]["message"]["content"] == "Full response text"
assert body["choices"][0]["finish_reason"] == "stop"
def test_non_streaming_has_model(self, client: TestClient):
mock_backend = _make_mock_backend(tokens = ["x"], active_model = "my-model")
with patch("routes.inference.get_inference_backend", return_value = mock_backend):
resp = client.post(
"/api/inference/chat/completions",
json = {
"messages": [{"role": "user", "content": "Hi"}],
"stream": False,
},
)
body = resp.json()
assert body["model"] == "my-model"
# =====================================================================
# System prompt extraction
# =====================================================================
class TestSystemPromptExtraction:
"""System messages should be extracted and passed as system_prompt."""
def test_system_message_extracted(self, client: TestClient):
mock_backend = _make_mock_backend(tokens = ["ok"])
with patch("routes.inference.get_inference_backend", return_value = mock_backend):
client.post(
"/api/inference/chat/completions",
json = {
"messages": [
{"role": "system", "content": "You are a pirate."},
{"role": "user", "content": "Hello"},
],
"stream": False,
},
)
# Check that generate_chat_response was called with the correct system_prompt
call_kwargs = mock_backend.generate_chat_response.call_args[1]
assert call_kwargs["system_prompt"] == "You are a pirate."
# System message should NOT be in the chat_messages list
assert all(m["role"] != "system" for m in call_kwargs["messages"])
def test_default_system_prompt_when_none(self, client: TestClient):
mock_backend = _make_mock_backend(tokens = ["ok"])
with patch("routes.inference.get_inference_backend", return_value = mock_backend):
client.post(
"/api/inference/chat/completions",
json = {
"messages": [{"role": "user", "content": "Hello"}],
"stream": False,
},
)
call_kwargs = mock_backend.generate_chat_response.call_args[1]
assert call_kwargs["system_prompt"] == "You are a helpful AI assistant."
# =====================================================================
# Error handling
# =====================================================================
class TestErrorHandling:
"""Validate error responses for bad requests."""
def test_no_model_loaded(self, client: TestClient):
mock_backend = _make_mock_backend()
mock_backend.active_model_name = None
with patch("routes.inference.get_inference_backend", return_value = mock_backend):
resp = client.post(
"/api/inference/chat/completions",
json = {"messages": [{"role": "user", "content": "Hi"}]},
)
assert resp.status_code == 400
assert "No model loaded" in resp.json()["detail"]
def test_only_system_messages_rejected(self, client: TestClient):
mock_backend = _make_mock_backend()
with patch("routes.inference.get_inference_backend", return_value = mock_backend):
resp = client.post(
"/api/inference/chat/completions",
json = {
"messages": [{"role": "system", "content": "You are a bot."}],
},
)
assert resp.status_code == 400
assert "non-system message" in resp.json()["detail"]