* 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>
301 lines
11 KiB
Python
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"]
|