Files
wehub-resource-sync e9a2f726c9
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:29:51 +08:00

1955 lines
64 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""
Integration tests for oMLX server endpoints.
Tests the FastAPI endpoints using TestClient with mocked EnginePool and Engine
to verify request/response formats without loading actual models.
"""
import json
from dataclasses import dataclass, field
from types import SimpleNamespace
from typing import Any, Dict, List, Optional
from unittest.mock import AsyncMock
import pytest
from fastapi.testclient import TestClient
from omlx.api.responses_utils import ResponseStore
from omlx.engine.base import BaseEngine
from omlx.engine.embedding import EmbeddingEngine
from omlx.engine.reranker import RerankerEngine
from omlx.mcp.types import MCPToolResult
@dataclass
class MockEmbeddingOutput:
"""Mock embedding output for testing."""
embeddings: List[List[float]] = field(
default_factory=lambda: [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]]
)
total_tokens: int = 10
dimensions: int = 3
@dataclass
class MockRerankOutput:
"""Mock rerank output for testing."""
scores: List[float] = field(default_factory=lambda: [0.9, 0.5, 0.3])
indices: List[int] = field(default_factory=lambda: [0, 1, 2])
total_tokens: int = 50
@dataclass
class MockGenerationOutput:
"""Mock generation output for testing."""
text: str = "Hello, I am a helpful assistant."
tokens: List[int] = field(default_factory=lambda: [1, 2, 3, 4, 5])
prompt_tokens: int = 10
completion_tokens: int = 5
finish_reason: str = "stop"
new_text: str = ""
finished: bool = True
tool_calls: Optional[List[Dict[str, Any]]] = None
cached_tokens: int = 0
class MockEmbeddingEngineImpl(EmbeddingEngine):
"""Mock embedding engine for testing that inherits from EmbeddingEngine."""
def __init__(self, model_name: str = "test-embedding-model"):
# Don't call super().__init__ to avoid loading real model
self._model_name = model_name
self._model = None # Set as None but present
self.calls: List[Dict[str, Any]] = []
@property
def model_name(self) -> str:
return self._model_name
async def start(self) -> None:
pass
async def stop(self) -> None:
pass
async def embed(self, texts, **kwargs) -> MockEmbeddingOutput:
self.calls.append({"texts": list(texts), "kwargs": dict(kwargs)})
return MockEmbeddingOutput(
embeddings=[[0.1, 0.2, 0.3] for _ in texts],
total_tokens=len(texts) * 5,
dimensions=3,
)
def get_stats(self) -> Dict[str, Any]:
return {"model_name": self._model_name, "loaded": True}
class MockRerankerEngineImpl(RerankerEngine):
"""Mock reranker engine for testing that inherits from RerankerEngine."""
def __init__(self, model_name: str = "test-reranker-model"):
# Don't call super().__init__ to avoid loading real model
self._model_name = model_name
self._model = None # Set as None but present
@property
def model_name(self) -> str:
return self._model_name
async def start(self) -> None:
pass
async def stop(self) -> None:
pass
async def rerank(
self, query: str, documents: List[str], top_n: Optional[int] = None, **kwargs
) -> MockRerankOutput:
n_docs = len(documents)
scores = [0.9 - i * 0.2 for i in range(n_docs)]
indices = list(range(n_docs))
if top_n:
indices = indices[:top_n]
return MockRerankOutput(
scores=scores,
indices=indices,
total_tokens=n_docs * 20,
)
def get_stats(self) -> Dict[str, Any]:
return {"model_name": self._model_name, "loaded": True}
class MockTokenizer:
"""Mock tokenizer for testing."""
def __init__(self):
self.eos_token_id = 2
def encode(self, text: str) -> List[int]:
# Simple simulation: split by words
return [100 + i for i, _ in enumerate(text.split())]
def decode(self, tokens: List[int], skip_special_tokens: bool = True) -> str:
return f"<decoded:{len(tokens)} tokens>"
def apply_chat_template(
self, messages: List[Dict], tokenize: bool = False, **kwargs
) -> str:
parts = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
parts.append(f"{role}: {content}")
return "\n".join(parts)
class MockBaseEngine(BaseEngine):
"""Mock LLM engine for testing."""
def __init__(self, model_name: str = "test-llm-model"):
self._model_name = model_name
self._tokenizer = MockTokenizer()
self._model_type = "llama"
@property
def model_name(self) -> str:
return self._model_name
@property
def tokenizer(self):
return self._tokenizer
@property
def model_type(self) -> Optional[str]:
return self._model_type
@property
def prefix_cache_enabled(self) -> bool:
return False
async def start(self) -> None:
pass
async def stop(self) -> None:
pass
async def generate(self, prompt: str, **kwargs) -> MockGenerationOutput:
return MockGenerationOutput(text="Generated response.")
async def stream_generate(self, prompt: str, **kwargs):
yield MockGenerationOutput(
text="Hello",
new_text="Hello",
finished=False,
)
yield MockGenerationOutput(
text="Hello world",
new_text=" world",
finished=True,
finish_reason="stop",
)
def count_chat_tokens(
self, messages: List[Dict], tools=None, chat_template_kwargs=None, **kwargs
) -> int:
prompt = self._tokenizer.apply_chat_template(messages, tokenize=False)
return len(self._tokenizer.encode(prompt))
async def chat(self, messages: List[Dict], **kwargs) -> MockGenerationOutput:
return MockGenerationOutput(text="Chat response.")
async def stream_chat(self, messages: List[Dict], **kwargs):
yield MockGenerationOutput(
text="Hello",
new_text="Hello",
finished=False,
)
yield MockGenerationOutput(
text="Hello from chat",
new_text=" from chat",
finished=True,
finish_reason="stop",
)
def get_stats(self) -> Dict[str, Any]:
return {}
def get_cache_stats(self):
return None
class RecordingResponsesEngine(MockBaseEngine):
"""Mock engine that records request messages across /v1/responses calls."""
def __init__(self, outputs: Optional[List[MockGenerationOutput]] = None):
super().__init__()
self._outputs = list(outputs or [])
self.recorded_messages: List[List[Dict[str, Any]]] = []
self._model_type = "gpt_oss"
async def chat(self, messages: List[Dict], **kwargs) -> MockGenerationOutput:
self.recorded_messages.append(messages)
if self._outputs:
return self._outputs.pop(0)
return MockGenerationOutput(text="Chat response.")
class MockEnginePool:
"""Mock engine pool for testing."""
def __init__(
self,
llm_engine: Optional[MockBaseEngine] = None,
embedding_engine: Optional[MockEmbeddingEngineImpl] = None,
reranker_engine: Optional[MockRerankerEngineImpl] = None,
):
self._llm_engine = llm_engine or MockBaseEngine()
self._embedding_engine = embedding_engine
self._reranker_engine = reranker_engine
self._models = [
{"id": "test-model", "loaded": True, "pinned": False, "size": 1000000}
]
self._entries: Dict[str, Any] = {}
self.get_engine_calls: List[Dict[str, Any]] = []
self.release_calls: List[str] = []
self.abort_requested_models: set[str] = set()
@property
def model_count(self) -> int:
return len(self._models)
@property
def loaded_model_count(self) -> int:
return sum(1 for m in self._models if m["loaded"])
@property
def max_model_memory(self) -> int:
return 32 * 1024 * 1024 * 1024 # 32GB
@property
def current_model_memory(self) -> int:
return 1000000
def get_entry(self, model_id: str):
return self._entries.get(model_id)
def resolve_model_id(self, model_id_or_alias, settings_manager=None):
return model_id_or_alias
def get_model_ids(self) -> List[str]:
return [m["id"] for m in self._models]
def get_status(self) -> Dict[str, Any]:
return {
"models": self._models,
"loaded_count": self.loaded_model_count,
"max_model_memory": self.max_model_memory,
}
async def get_engine(
self,
model_id: str,
_lease: bool = False,
runtime_settings=None,
):
# _lease mirrors the real EnginePool's acquire-vs-use lease (#1667);
# the mock has no eviction so it just accepts the flag.
# runtime_settings mirrors exposed-profile variant loads.
self.get_engine_calls.append(
{
"model_id": model_id,
"_lease": _lease,
"runtime_settings": runtime_settings,
}
)
# Return appropriate engine based on model name pattern
if "embed" in model_id.lower():
if self._embedding_engine:
return self._embedding_engine
raise ValueError(f"No embedding engine for {model_id}")
elif "rerank" in model_id.lower():
if self._reranker_engine:
return self._reranker_engine
raise ValueError(f"No reranker engine for {model_id}")
return self._llm_engine
async def release_engine(self, model_id: str) -> None:
# No-op release counterpart of the in-use lease (#1667).
self.release_calls.append(model_id)
return None
def is_abort_requested(self, model_id: str) -> bool:
return model_id in self.abort_requested_models
@pytest.fixture
def mock_llm_engine():
"""Create a mock LLM engine."""
return MockBaseEngine()
@pytest.fixture
def mock_embedding_engine():
"""Create a mock embedding engine."""
return MockEmbeddingEngineImpl()
@pytest.fixture
def mock_reranker_engine():
"""Create a mock reranker engine."""
return MockRerankerEngineImpl()
@pytest.fixture
def mock_engine_pool(mock_llm_engine, mock_embedding_engine, mock_reranker_engine):
"""Create a mock engine pool."""
return MockEnginePool(
llm_engine=mock_llm_engine,
embedding_engine=mock_embedding_engine,
reranker_engine=mock_reranker_engine,
)
@pytest.fixture
def client(mock_engine_pool):
"""Create a test client with mocked server state."""
from omlx.server import app, _server_state
# Store original state
original_pool = _server_state.engine_pool
original_default = _server_state.default_model
# Set mock state
_server_state.engine_pool = mock_engine_pool
_server_state.default_model = "test-model"
yield TestClient(app)
# Restore original state
_server_state.engine_pool = original_pool
_server_state.default_model = original_default
class TestHealthEndpoint:
"""Tests for the /health endpoint."""
def test_health_returns_healthy_status(self, client):
"""Test that health endpoint returns healthy status."""
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
def test_health_contains_required_fields(self, client):
"""Test that health response contains required fields."""
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert "status" in data
assert "default_model" in data
assert "engine_pool" in data
def test_health_engine_pool_info(self, client):
"""Test that health response contains engine pool info."""
response = client.get("/health")
assert response.status_code == 200
data = response.json()
pool_info = data["engine_pool"]
assert "model_count" in pool_info
assert "loaded_count" in pool_info
assert "final_ceiling" in pool_info
assert "current_model_memory" in pool_info
class TestModelsEndpoint:
"""Tests for the /v1/models endpoint."""
def test_models_returns_list(self, client):
"""Test that models endpoint returns a list."""
response = client.get("/v1/models")
assert response.status_code == 200
data = response.json()
assert data["object"] == "list"
assert "data" in data
def test_models_format(self, client):
"""Test that model entries have correct format."""
response = client.get("/v1/models")
assert response.status_code == 200
data = response.json()
if data["data"]:
model = data["data"][0]
assert "id" in model
assert "object" in model
class TestResponsesEndpoint:
def test_responses_uses_llm_lease(self, client, mock_engine_pool):
response = client.post(
"/v1/responses",
json={"model": "test-model", "input": "Hello"},
)
assert response.status_code == 200
assert mock_engine_pool.get_engine_calls[-1]["_lease"] is True
assert mock_engine_pool.release_calls == ["test-model"]
def test_response_endpoint_includes_reasoning_item_for_think_blocks(
self, client, mock_llm_engine
):
mock_llm_engine.chat = AsyncMock(
return_value=MockGenerationOutput(
text="<think>Need to reason.</think>Hello!",
prompt_tokens=3,
completion_tokens=6,
finish_reason="stop",
finished=True,
)
)
response = client.post(
"/v1/responses",
json={"model": "test-model", "input": "Hello"},
)
assert response.status_code == 200
data = response.json()
assert [item["type"] for item in data["output"]] == ["reasoning", "message"]
assert data["output"][0]["summary"][0]["text"] == "Need to reason."
assert data["output"][1]["content"][0]["text"] == "Hello!"
assert data["usage"]["output_tokens_details"]["reasoning_tokens"] == 3
def test_response_stream_includes_reasoning_item_for_think_blocks(
self, client, mock_llm_engine
):
async def stream_chat(messages, **kwargs):
yield MockGenerationOutput(
text="<think>Need to reason.</think>Hello!",
new_text="<think>Need to reason.</think>Hello!",
prompt_tokens=3,
completion_tokens=6,
finish_reason="stop",
finished=True,
)
mock_llm_engine.stream_chat = stream_chat
response = client.post(
"/v1/responses",
json={"model": "test-model", "input": "Hello", "stream": True},
)
assert response.status_code == 200
events = [
json.loads(line.removeprefix("data: "))
for line in response.text.splitlines()
if line.startswith("data: ")
]
reasoning_deltas = [
event["delta"]
for event in events
if event.get("type") == "response.reasoning_summary_text.delta"
]
assert "".join(reasoning_deltas) == "Need to reason."
added_items = [
event
for event in events
if event.get("type") == "response.output_item.added"
]
assert added_items[0]["item"]["type"] == "reasoning"
assert added_items[0]["output_index"] == 0
assert added_items[1]["item"]["type"] == "message"
assert added_items[1]["output_index"] == 1
completed = next(
event for event in events if event.get("type") == "response.completed"
)
output = completed["response"]["output"]
assert [item["type"] for item in output] == ["reasoning", "message"]
assert output[0]["summary"][0]["text"] == "Need to reason."
assert output[1]["content"][0]["text"] == "Hello!"
usage = completed["response"]["usage"]
assert usage["output_tokens_details"]["reasoning_tokens"] == 3
def test_response_endpoint_recovers_tool_call_from_thinking(self, tmp_path):
from omlx.server import app, _server_state
state_dir = tmp_path / "response-state"
engine = RecordingResponsesEngine(
outputs=[
MockGenerationOutput(
text=(
"<think>Need to inspect first."
'<tool_call>{"name":"exec_command","arguments":{"cmd":"ls"}}</tool_call>'
"Then continue.</think>"
),
finish_reason="stop",
),
]
)
pool = MockEnginePool(llm_engine=engine)
original_pool = _server_state.engine_pool
original_default = _server_state.default_model
original_store = _server_state.responses_store
try:
_server_state.engine_pool = pool
_server_state.default_model = "test-model"
_server_state.responses_store = ResponseStore(state_dir=state_dir)
client = TestClient(app)
response = client.post(
"/v1/responses",
json={
"model": "test-model",
"input": "Explore the code",
"tools": [
{
"type": "function",
"name": "exec_command",
"description": "Run a shell command",
"parameters": {
"type": "object",
"properties": {"cmd": {"type": "string"}},
"required": ["cmd"],
},
}
],
},
)
assert response.status_code == 200
output_items = response.json()["output"]
message_items = [item for item in output_items if item["type"] == "message"]
reasoning_items = [
item for item in output_items if item["type"] == "reasoning"
]
function_items = [
item for item in output_items if item["type"] == "function_call"
]
assert len(reasoning_items) == 1
assert reasoning_items[0]["summary"][0]["text"] == (
"Need to inspect first.Then continue."
)
assert "<tool_call>" not in reasoning_items[0]["summary"][0]["text"]
assert len(message_items) == 1
assert message_items[0]["content"][0]["text"] == ""
assert "<tool_call>" not in message_items[0]["content"][0]["text"]
assert len(function_items) == 1
assert function_items[0]["name"] == "exec_command"
assert function_items[0]["arguments"] == '{"cmd": "ls"}'
finally:
_server_state.engine_pool = original_pool
_server_state.default_model = original_default
_server_state.responses_store = original_store
def test_previous_response_id_persists_across_store_restart(self, tmp_path):
from omlx.server import app, _server_state
state_dir = tmp_path / "response-state"
engine = RecordingResponsesEngine(
outputs=[
MockGenerationOutput(
text="",
finish_reason="tool_calls",
tool_calls=[
{
"id": "call_123",
"name": "exec_command",
"arguments": '{"cmd":"ls"}',
}
],
),
MockGenerationOutput(text="Done.", finish_reason="stop"),
]
)
pool = MockEnginePool(llm_engine=engine)
original_pool = _server_state.engine_pool
original_default = _server_state.default_model
original_store = _server_state.responses_store
try:
_server_state.engine_pool = pool
_server_state.default_model = "test-model"
_server_state.responses_store = ResponseStore(state_dir=state_dir)
client = TestClient(app)
first = client.post(
"/v1/responses",
json={"model": "test-model", "input": "Explore the code"},
)
assert first.status_code == 200
first_id = first.json()["id"]
# Simulate a restart by rebuilding the store from disk.
_server_state.responses_store = ResponseStore(state_dir=state_dir)
second = client.post(
"/v1/responses",
json={
"model": "test-model",
"previous_response_id": first_id,
"input": [
{
"type": "function_call_output",
"call_id": "call_123",
"output": "file1.txt\nfile2.txt",
}
],
},
)
assert second.status_code == 200
replayed = engine.recorded_messages[1]
assert replayed[0] == {"role": "user", "content": "Explore the code"}
assert replayed[1]["role"] == "assistant"
assert replayed[1]["tool_calls"][0]["id"] == "call_123"
assert replayed[2] == {
"role": "tool",
"tool_call_id": "call_123",
"content": "file1.txt\nfile2.txt",
}
finally:
_server_state.engine_pool = original_pool
_server_state.default_model = original_default
_server_state.responses_store = original_store
def test_missing_previous_response_id_returns_404(self, tmp_path):
from omlx.server import app, _server_state
engine = RecordingResponsesEngine(outputs=[MockGenerationOutput(text="Done.")])
pool = MockEnginePool(llm_engine=engine)
original_pool = _server_state.engine_pool
original_default = _server_state.default_model
original_store = _server_state.responses_store
try:
_server_state.engine_pool = pool
_server_state.default_model = "test-model"
_server_state.responses_store = ResponseStore(
state_dir=tmp_path / "response-state"
)
client = TestClient(app)
response = client.post(
"/v1/responses",
json={
"model": "test-model",
"previous_response_id": "resp_missing",
"input": "Continue",
},
)
assert response.status_code == 404
finally:
_server_state.engine_pool = original_pool
_server_state.default_model = original_default
_server_state.responses_store = original_store
class TestModelsStatusEndpoint:
"""Tests for the /v1/models/status endpoint."""
def test_models_status_returns_details(self, client):
"""Test that models status returns detailed info."""
response = client.get("/v1/models/status")
assert response.status_code == 200
data = response.json()
assert "models" in data
def test_models_status_includes_model_alias(self, client):
"""Model aliases should be available to clients that join status metadata."""
from omlx.server import _server_state
class Settings:
model_alias = "gpt-4o"
max_context_window = 32768
max_tokens = 8192
class SettingsManager:
def get_settings(self, model_id):
return Settings()
def get_settings_for_request(self, model_id, resolved_model_id=None):
return Settings()
original_settings_manager = _server_state.settings_manager
try:
_server_state.settings_manager = SettingsManager()
response = client.get("/v1/models/status")
finally:
_server_state.settings_manager = original_settings_manager
assert response.status_code == 200
model = response.json()["models"][0]
assert model["id"] == "test-model"
assert model["model_alias"] == "gpt-4o"
assert model["max_context_window"] == 32768
assert model["max_tokens"] == 8192
class TestCompletionEndpoint:
"""Tests for the /v1/completions endpoint."""
def test_completion_uses_llm_lease(self, client, mock_engine_pool):
"""LLM completion keeps a pool lease until the response body finishes."""
response = client.post(
"/v1/completions",
json={
"model": "test-model",
"prompt": "Hello, world!",
},
)
assert response.status_code == 200
assert mock_engine_pool.get_engine_calls[-1]["_lease"] is True
assert mock_engine_pool.release_calls == ["test-model"]
def test_completion_basic_request(self, client):
"""Test basic completion request."""
response = client.post(
"/v1/completions",
json={
"model": "test-model",
"prompt": "Hello, world!",
},
)
assert response.status_code == 200
data = response.json()
assert "choices" in data
assert len(data["choices"]) > 0
assert "text" in data["choices"][0]
def test_completion_response_format(self, client):
"""Test completion response has correct format."""
response = client.post(
"/v1/completions",
json={
"model": "test-model",
"prompt": "Test prompt",
"max_tokens": 100,
},
)
assert response.status_code == 200
data = response.json()
assert data["object"] == "text_completion"
assert "model" in data
assert "choices" in data
assert "usage" in data
def test_completion_with_list_prompt(self, client):
"""Test completion with list of prompts."""
response = client.post(
"/v1/completions",
json={
"model": "test-model",
"prompt": ["First prompt", "Second prompt"],
},
)
assert response.status_code == 200
data = response.json()
assert "choices" in data
def test_completion_includes_cached_tokens_on_cache_hit(
self, client, mock_llm_engine
):
"""Non-streaming completion responses should expose cached token counts."""
mock_llm_engine.generate = AsyncMock(
return_value=MockGenerationOutput(
text="Generated response.",
prompt_tokens=2215,
completion_tokens=5,
cached_tokens=2048,
)
)
response = client.post(
"/v1/completions",
json={
"model": "test-model",
"prompt": "Cache hit prompt",
},
)
assert response.status_code == 200
data = response.json()
assert data["usage"]["prompt_tokens_details"]["cached_tokens"] == 2048
def test_completion_forwards_thinking_budget(self, client, mock_llm_engine):
"""Non-streaming /v1/completions must forward thinking_budget to the
engine. Regression for #1825: the field was absent from
CompletionRequest, so Pydantic dropped it and it never reached
generate()."""
mock_llm_engine.generate = AsyncMock(
return_value=MockGenerationOutput(text="Generated response.")
)
response = client.post(
"/v1/completions",
json={
"model": "test-model",
"prompt": "Explain why the sky is blue.",
"thinking_budget": 300,
},
)
assert response.status_code == 200
assert mock_llm_engine.generate.call_args.kwargs["thinking_budget"] == 300
def test_completion_streaming_forwards_thinking_budget(
self, client, mock_llm_engine
):
"""Streaming /v1/completions must forward thinking_budget to the
engine (companion to the non-streaming path; see #1825)."""
captured = {}
async def recording_stream_generate(prompt, **kwargs):
captured.update(kwargs)
yield MockGenerationOutput(text="Hi", new_text="Hi", finished=False)
yield MockGenerationOutput(
text="Hi there",
new_text=" there",
finished=True,
finish_reason="stop",
)
mock_llm_engine.stream_generate = recording_stream_generate
response = client.post(
"/v1/completions",
json={
"model": "test-model",
"prompt": "Explain why the sky is blue.",
"stream": True,
"thinking_budget": 300,
},
)
assert response.status_code == 200
assert captured.get("thinking_budget") == 300
def test_completion_thinking_budget_from_model_settings(
self, client, mock_llm_engine
):
"""A model-level thinking budget (admin settings) applies to
/v1/completions even when the request omits the parameter, matching
/v1/chat/completions."""
from omlx.model_settings import ModelSettings
from omlx.server import _server_state
class StubSettingsManager:
def get_settings(self, model_id):
return ModelSettings(
thinking_budget_enabled=True,
thinking_budget_tokens=256,
)
mock_llm_engine.generate = AsyncMock(
return_value=MockGenerationOutput(text="Generated response.")
)
original_settings_manager = _server_state.settings_manager
_server_state.settings_manager = StubSettingsManager()
try:
response = client.post(
"/v1/completions",
json={
"model": "test-model",
"prompt": "Explain why the sky is blue.",
},
)
finally:
_server_state.settings_manager = original_settings_manager
assert response.status_code == 200
assert mock_llm_engine.generate.call_args.kwargs["thinking_budget"] == 256
class TestChatCompletionEndpoint:
"""Tests for the /v1/chat/completions endpoint."""
def test_chat_completion_uses_llm_lease(self, client, mock_engine_pool):
"""Chat completion keeps a pool lease until the response body finishes."""
response = client.post(
"/v1/chat/completions",
json={
"model": "test-model",
"messages": [{"role": "user", "content": "Hello"}],
},
)
assert response.status_code == 200
assert mock_engine_pool.get_engine_calls[-1]["_lease"] is True
assert mock_engine_pool.release_calls == ["test-model"]
def test_chat_completion_basic(self, client):
"""Test basic chat completion request."""
response = client.post(
"/v1/chat/completions",
json={
"model": "test-model",
"messages": [{"role": "user", "content": "Hello"}],
},
)
assert response.status_code == 200
data = response.json()
assert "choices" in data
assert len(data["choices"]) > 0
def test_chat_completion_response_format(self, client):
"""Test chat completion response format."""
response = client.post(
"/v1/chat/completions",
json={
"model": "test-model",
"messages": [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hi!"},
],
},
)
assert response.status_code == 200
data = response.json()
assert data["object"] == "chat.completion"
assert "model" in data
assert "choices" in data
assert data["choices"][0]["message"]["role"] == "assistant"
assert "usage" in data
def test_chat_completion_with_parameters(self, client):
"""Test chat completion with sampling parameters."""
response = client.post(
"/v1/chat/completions",
json={
"model": "test-model",
"messages": [{"role": "user", "content": "Test"}],
"temperature": 0.7,
"top_p": 0.9,
"max_tokens": 256,
},
)
assert response.status_code == 200
def test_chat_completion_includes_cached_tokens_on_cache_hit(
self, client, mock_llm_engine
):
"""Non-streaming chat responses should expose cached token counts."""
mock_llm_engine.chat = AsyncMock(
return_value=MockGenerationOutput(
text="Chat response.",
prompt_tokens=2215,
completion_tokens=5,
cached_tokens=2048,
finish_reason="stop",
finished=True,
)
)
response = client.post(
"/v1/chat/completions",
json={
"model": "test-model",
"messages": [{"role": "user", "content": "Cache hit prompt"}],
},
)
assert response.status_code == 200
data = response.json()
assert data["usage"]["prompt_tokens_details"]["cached_tokens"] == 2048
def test_chat_completion_sanitizes_reasoning_tool_call_markup(
self, client, mock_llm_engine
):
"""Thinking-only tool calls should become structured tool_calls without leaked markup."""
mock_llm_engine.chat = AsyncMock(
return_value=MockGenerationOutput(
text=(
"<think>Need to inspect first."
'<tool_call>{"name":"get_weather","arguments":{"city":"SF"}}</tool_call>'
"Then continue.</think>"
),
prompt_tokens=10,
completion_tokens=5,
finish_reason="stop",
finished=True,
)
)
response = client.post(
"/v1/chat/completions",
json={
"model": "test-model",
"messages": [{"role": "user", "content": "Hi"}],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
}
],
},
)
assert response.status_code == 200
data = response.json()
message = data["choices"][0]["message"]
assert message["reasoning_content"] == "Need to inspect first.Then continue."
assert "<tool_call>" not in message["reasoning_content"]
assert len(message["tool_calls"]) == 1
assert message["tool_calls"][0]["function"]["name"] == "get_weather"
assert message["tool_calls"][0]["function"]["arguments"] == '{"city": "SF"}'
assert data["choices"][0]["finish_reason"] == "tool_calls"
@pytest.mark.parametrize(
("tool_choice", "request_tools", "expect_tools"),
[
(None, None, True),
("auto", None, True),
("none", None, False),
(
"none",
[
{
"type": "function",
"function": {
"name": "user_search",
"description": "Search from request tools",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"},
},
},
},
}
],
False,
),
],
)
def test_chat_completion_tool_choice_controls_mcp_tools(
self,
client,
mock_llm_engine,
tool_choice,
request_tools,
expect_tools,
):
"""tool_choice='none' should suppress request and globally configured tools."""
from omlx.server import _server_state
class RecordingMCPManager:
def __init__(self):
self.calls = []
def get_merged_tools(self, user_tools=None):
self.calls.append(user_tools)
return [
{
"type": "function",
"function": {
"name": "mcp_search",
"description": "Search via MCP",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"},
},
},
},
}
]
recorded_count_tools = []
recorded_chat_kwargs = []
def count_chat_tokens(messages, tools=None, **kwargs):
recorded_count_tools.append(tools)
return 1
async def chat(messages, **kwargs):
recorded_chat_kwargs.append(kwargs)
return MockGenerationOutput(
text="Plain response.",
prompt_tokens=1,
completion_tokens=1,
finish_reason="stop",
)
original_mcp_manager = _server_state.mcp_manager
manager = RecordingMCPManager()
mock_llm_engine.count_chat_tokens = count_chat_tokens
mock_llm_engine.chat = chat
payload = {
"model": "test-model",
"messages": [{"role": "user", "content": "Hello"}],
}
if tool_choice is not None:
payload["tool_choice"] = tool_choice
if request_tools is not None:
payload["tools"] = request_tools
try:
_server_state.mcp_manager = manager
response = client.post("/v1/chat/completions", json=payload)
finally:
_server_state.mcp_manager = original_mcp_manager
assert response.status_code == 200
assert recorded_chat_kwargs
if expect_tools:
assert manager.calls == [request_tools]
assert recorded_count_tools[0] is not None
assert "tools" in recorded_chat_kwargs[0]
else:
assert manager.calls == []
assert recorded_count_tools == [None]
assert "tools" not in recorded_chat_kwargs[0]
class TestAnthropicMessagesEndpoint:
"""Tests for the /v1/messages endpoint (Anthropic format)."""
def test_anthropic_messages_uses_llm_lease(self, client, mock_engine_pool):
response = client.post(
"/v1/messages",
json={
"model": "test-model",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}],
},
)
assert response.status_code == 200
assert mock_engine_pool.get_engine_calls[-1]["_lease"] is True
assert mock_engine_pool.release_calls == ["test-model"]
def test_anthropic_messages_basic(self, client):
"""Test basic Anthropic messages request."""
response = client.post(
"/v1/messages",
json={
"model": "test-model",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}],
},
)
assert response.status_code == 200
data = response.json()
assert data["type"] == "message"
assert data["role"] == "assistant"
def test_anthropic_messages_response_format(self, client):
"""Test Anthropic messages response format."""
response = client.post(
"/v1/messages",
json={
"model": "test-model",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hi there!"}],
},
)
assert response.status_code == 200
data = response.json()
assert "id" in data
assert "content" in data
assert "usage" in data
assert "input_tokens" in data["usage"]
assert "output_tokens" in data["usage"]
def test_anthropic_messages_with_system(self, client):
"""Test Anthropic messages with system prompt."""
response = client.post(
"/v1/messages",
json={
"model": "test-model",
"max_tokens": 1024,
"system": "You are a helpful assistant.",
"messages": [{"role": "user", "content": "Hello!"}],
},
)
assert response.status_code == 200
def test_anthropic_messages_sanitize_thinking_tool_call_markup(
self, client, mock_llm_engine
):
"""Anthropic thinking blocks should not expose raw tool-call markup."""
mock_llm_engine.chat = AsyncMock(
return_value=MockGenerationOutput(
text=(
"<think>Need to inspect first."
'<tool_call>{"name":"get_weather","arguments":{"city":"SF"}}</tool_call>'
"Then continue.</think>"
),
prompt_tokens=10,
completion_tokens=5,
finish_reason="stop",
finished=True,
)
)
response = client.post(
"/v1/messages",
json={
"model": "test-model",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hi"}],
"tools": [
{
"name": "get_weather",
"description": "Get weather",
"input_schema": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
}
],
},
)
assert response.status_code == 200
data = response.json()
thinking_blocks = [
block for block in data["content"] if block["type"] == "thinking"
]
tool_use_blocks = [
block for block in data["content"] if block["type"] == "tool_use"
]
assert len(thinking_blocks) == 1
assert thinking_blocks[0]["thinking"] == "Need to inspect first.Then continue."
assert "<tool_call>" not in thinking_blocks[0]["thinking"]
assert len(tool_use_blocks) == 1
assert tool_use_blocks[0]["name"] == "get_weather"
assert tool_use_blocks[0]["input"] == {"city": "SF"}
assert data["stop_reason"] == "tool_use"
def test_anthropic_messages_with_exposed_profile_model(
self, client, mock_llm_engine, mock_engine_pool, tmp_path
):
"""A model:profile id resolves and overlays profile settings on /v1/messages."""
from omlx.model_settings import ModelSettings, ModelSettingsManager
from omlx.server import _server_state
manager = ModelSettingsManager(tmp_path)
manager.set_settings("test-model", ModelSettings(temperature=0.1))
manager.save_profile(
model_id="test-model",
name="thinking",
display_name="Thinking",
description=None,
settings={"temperature": 0.9, "enable_thinking": True},
expose_as_model=True,
)
def resolve_model_id(model_id, settings_manager=None):
if settings_manager is not None:
source = settings_manager.get_exposed_profile_source_model_id(model_id)
if source:
return source
return model_id
recorded_chat_kwargs = []
async def chat(messages, **kwargs):
recorded_chat_kwargs.append(kwargs)
return MockGenerationOutput(
text="Hello from the profile.",
prompt_tokens=1,
completion_tokens=1,
finish_reason="stop",
)
mock_llm_engine.chat = chat
original_resolve = mock_engine_pool.resolve_model_id
original_settings_manager = _server_state.settings_manager
mock_engine_pool.resolve_model_id = resolve_model_id
try:
_server_state.settings_manager = manager
response = client.post(
"/v1/messages",
json={
"model": "test-model:thinking",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}],
},
)
finally:
_server_state.settings_manager = original_settings_manager
mock_engine_pool.resolve_model_id = original_resolve
assert response.status_code == 200
data = response.json()
assert data["type"] == "message"
assert data["role"] == "assistant"
# The profile's settings — not the base model's — reached the engine.
assert recorded_chat_kwargs
assert recorded_chat_kwargs[0]["temperature"] == 0.9
ct_kwargs = recorded_chat_kwargs[0].get("chat_template_kwargs") or {}
assert ct_kwargs.get("enable_thinking") is True
class TestEmbeddingsEndpoint:
"""Tests for the /v1/embeddings endpoint."""
def test_embeddings_single_input(self, client, mock_engine_pool):
"""Test embeddings with single input."""
mock_engine_pool._models.append(
{"id": "test-embed-model", "loaded": True, "pinned": False, "size": 500000}
)
response = client.post(
"/v1/embeddings",
json={
"model": "test-embed-model",
"input": "Hello, world!",
},
)
assert response.status_code == 200
data = response.json()
assert data["object"] == "list"
assert "data" in data
assert len(data["data"]) == 1
assert data["data"][0]["object"] == "embedding"
def test_embeddings_multiple_inputs(self, client, mock_engine_pool):
"""Test embeddings with multiple inputs."""
mock_engine_pool._models.append(
{"id": "test-embed-model", "loaded": True, "pinned": False, "size": 500000}
)
response = client.post(
"/v1/embeddings",
json={
"model": "test-embed-model",
"input": ["First text", "Second text"],
},
)
assert response.status_code == 200
data = response.json()
assert len(data["data"]) == 2
def test_embeddings_use_discovered_context_length(self, client, mock_engine_pool):
"""Embedding requests should not fall back to mlx-embeddings' 512 default."""
mock_engine_pool._models.append(
{"id": "test-embed-model", "loaded": True, "pinned": False, "size": 500000}
)
mock_engine_pool._entries["test-embed-model"] = SimpleNamespace(
model_context_length=40960
)
response = client.post(
"/v1/embeddings",
json={
"model": "test-embed-model",
"input": "hello",
},
)
assert response.status_code == 200
kwargs = mock_engine_pool._embedding_engine.calls[-1]["kwargs"]
assert kwargs["max_length"] == 40960
assert kwargs["truncation"] is True
def test_embeddings_request_max_length_overrides_default(
self, client, mock_engine_pool
):
"""Explicit max_length should be forwarded to the embedding engine."""
mock_engine_pool._models.append(
{"id": "test-embed-model", "loaded": True, "pinned": False, "size": 500000}
)
mock_engine_pool._entries["test-embed-model"] = SimpleNamespace(
model_context_length=40960
)
response = client.post(
"/v1/embeddings",
json={
"model": "test-embed-model",
"input": "hello",
"max_length": 1024,
"truncation": False,
},
)
assert response.status_code == 200
kwargs = mock_engine_pool._embedding_engine.calls[-1]["kwargs"]
assert kwargs["max_length"] == 1024
assert kwargs["truncation"] is False
def test_embeddings_response_format(self, client, mock_engine_pool):
"""Test embeddings response format."""
mock_engine_pool._models.append(
{"id": "test-embed-model", "loaded": True, "pinned": False, "size": 500000}
)
response = client.post(
"/v1/embeddings",
json={
"model": "test-embed-model",
"input": "Test text",
},
)
assert response.status_code == 200
data = response.json()
assert "model" in data
assert "usage" in data
assert "prompt_tokens" in data["usage"]
assert "total_tokens" in data["usage"]
assert "embedding" in data["data"][0]
assert isinstance(data["data"][0]["embedding"], list)
def test_embeddings_structured_items_input(self, client, mock_engine_pool):
"""Test embeddings with structured multimodal items."""
mock_engine_pool._models.append(
{"id": "test-embed-model", "loaded": True, "pinned": False, "size": 500000}
)
image_data_uri = (
"data:image/png;base64,"
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/"
"x8AAwMCAO+/p9sAAAAASUVORK5CYII="
)
response = client.post(
"/v1/embeddings",
json={
"model": "test-embed-model",
"items": [
{"text": "hello"},
{"image": image_data_uri},
{
"text": "hello",
"image": image_data_uri,
},
],
},
)
assert response.status_code == 200
data = response.json()
assert len(data["data"]) == 3
def test_embeddings_rejects_mixed_input_sources(self, client, mock_engine_pool):
"""Test embeddings rejects input and items together."""
mock_engine_pool._models.append(
{"id": "test-embed-model", "loaded": True, "pinned": False, "size": 500000}
)
response = client.post(
"/v1/embeddings",
json={
"model": "test-embed-model",
"input": "hello",
"items": [{"text": "hello"}],
},
)
assert response.status_code == 422
class TestRerankEndpoint:
"""Tests for the /v1/rerank endpoint."""
def test_rerank_basic(self, client, mock_engine_pool):
"""Test basic rerank request."""
mock_engine_pool._models.append(
{
"id": "test-rerank-model",
"loaded": True,
"pinned": False,
"size": 500000,
}
)
response = client.post(
"/v1/rerank",
json={
"model": "test-rerank-model",
"query": "What is machine learning?",
"documents": [
"ML is a subset of AI.",
"The weather is nice today.",
],
},
)
assert response.status_code == 200
data = response.json()
assert "results" in data
assert len(data["results"]) == 2
def test_rerank_with_top_n(self, client, mock_engine_pool):
"""Test rerank with top_n parameter."""
mock_engine_pool._models.append(
{
"id": "test-rerank-model",
"loaded": True,
"pinned": False,
"size": 500000,
}
)
response = client.post(
"/v1/rerank",
json={
"model": "test-rerank-model",
"query": "Test query",
"documents": ["Doc 1", "Doc 2", "Doc 3"],
"top_n": 2,
},
)
assert response.status_code == 200
data = response.json()
assert len(data["results"]) == 2
def test_rerank_response_format(self, client, mock_engine_pool):
"""Test rerank response format."""
mock_engine_pool._models.append(
{
"id": "test-rerank-model",
"loaded": True,
"pinned": False,
"size": 500000,
}
)
response = client.post(
"/v1/rerank",
json={
"model": "test-rerank-model",
"query": "Test",
"documents": ["Document 1"],
"return_documents": True,
},
)
assert response.status_code == 200
data = response.json()
assert "id" in data
assert "model" in data
assert "results" in data
result = data["results"][0]
assert "index" in result
assert "relevance_score" in result
assert "document" in result
class TestTokenCountEndpoint:
"""Tests for the /v1/messages/count_tokens endpoint."""
def test_token_count_uses_llm_lease(self, client, mock_engine_pool):
response = client.post(
"/v1/messages/count_tokens",
json={
"model": "test-model",
"messages": [{"role": "user", "content": "Hello"}],
},
)
assert response.status_code == 200
assert mock_engine_pool.get_engine_calls[-1]["_lease"] is True
assert mock_engine_pool.release_calls == ["test-model"]
def test_token_count_basic(self, client):
"""Test basic token counting."""
response = client.post(
"/v1/messages/count_tokens",
json={
"model": "test-model",
"messages": [{"role": "user", "content": "Hello world"}],
},
)
assert response.status_code == 200
data = response.json()
assert "input_tokens" in data
assert isinstance(data["input_tokens"], int)
def test_token_count_with_system(self, client):
"""Test token counting with system prompt."""
response = client.post(
"/v1/messages/count_tokens",
json={
"model": "test-model",
"system": "You are helpful.",
"messages": [{"role": "user", "content": "Hi!"}],
},
)
assert response.status_code == 200
data = response.json()
assert "input_tokens" in data
class TestMCPEndpoints:
"""Tests for MCP-related endpoints."""
def test_mcp_tools_empty(self, client):
"""Test MCP tools endpoint when no MCP configured."""
response = client.get("/v1/mcp/tools")
assert response.status_code == 200
data = response.json()
assert "tools" in data
assert "count" in data
assert data["count"] == 0
def test_mcp_servers_empty(self, client):
"""Test MCP servers endpoint when no MCP configured."""
response = client.get("/v1/mcp/servers")
assert response.status_code == 200
data = response.json()
assert "servers" in data
def test_mcp_execute_no_config(self, client):
"""Test MCP execute fails when not configured."""
response = client.post(
"/v1/mcp/execute",
json={
"tool_name": "test_tool",
"arguments": {},
},
)
# Should return 503 when MCP not configured
assert response.status_code == 503
def test_mcp_execute_accepts_tool_alias(self, client):
"""Test MCP execute accepts tool as an alias for tool_name."""
from omlx.server import _server_state
original_mcp_manager = _server_state.mcp_manager
manager = AsyncMock()
manager.execute_tool.return_value = MCPToolResult(
tool_name="test_tool",
content={"ok": True},
)
try:
_server_state.mcp_manager = manager
response = client.post(
"/v1/mcp/execute",
json={
"tool": "test_tool",
"arguments": {"query": "hello"},
},
)
finally:
_server_state.mcp_manager = original_mcp_manager
assert response.status_code == 200
assert response.json() == {
"tool_name": "test_tool",
"content": {"ok": True},
"is_error": False,
"error_message": None,
}
manager.execute_tool.assert_awaited_once_with(
"test_tool",
{"query": "hello"},
)
def test_mcp_execute_tool_name_field(self, client):
"""Test MCP execute happy path with tool_name field."""
from omlx.server import _server_state
original_mcp_manager = _server_state.mcp_manager
manager = AsyncMock()
manager.execute_tool.return_value = MCPToolResult(
tool_name="my_tool",
content="ok",
)
try:
_server_state.mcp_manager = manager
response = client.post(
"/v1/mcp/execute",
json={
"tool_name": "my_tool",
"arguments": {"q": "x"},
},
)
finally:
_server_state.mcp_manager = original_mcp_manager
assert response.status_code == 200
manager.execute_tool.assert_awaited_once_with("my_tool", {"q": "x"})
def test_mcp_execute_tool_name_wins_over_tool(self, client):
"""Test tool_name takes precedence when both fields are present."""
from omlx.server import _server_state
original_mcp_manager = _server_state.mcp_manager
manager = AsyncMock()
manager.execute_tool.return_value = MCPToolResult(
tool_name="canonical",
content="ok",
)
try:
_server_state.mcp_manager = manager
response = client.post(
"/v1/mcp/execute",
json={
"tool_name": "canonical",
"tool": "alias_should_lose",
"arguments": {},
},
)
finally:
_server_state.mcp_manager = original_mcp_manager
assert response.status_code == 200
manager.execute_tool.assert_awaited_once_with("canonical", {})
def test_mcp_execute_rejects_missing_tool(self, client):
"""Test MCP execute returns 422 when neither tool nor tool_name is present."""
response = client.post(
"/v1/mcp/execute",
json={"arguments": {"q": "x"}},
)
assert response.status_code == 422
class TestErrorHandling:
"""Tests for error handling in endpoints."""
def test_missing_model(self, client):
"""Test error when model is not specified."""
# For Anthropic endpoint, missing model should raise validation error
response = client.post(
"/v1/messages",
json={
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}],
},
)
assert response.status_code == 422 # Validation error
def test_empty_messages(self, client):
"""Test error when messages is empty."""
response = client.post(
"/v1/chat/completions",
json={
"model": "test-model",
"messages": [],
},
)
# Empty messages may be allowed or raise error depending on implementation
# Just verify we get a response
assert response.status_code in [200, 400, 422]
def test_invalid_request_format(self, client):
"""Test error for invalid request format."""
response = client.post(
"/v1/chat/completions",
json={
"invalid_field": "test",
},
)
assert response.status_code == 422
class TestJsonOutputParsing:
"""Tests for parse_json_output in non-streaming endpoints."""
def test_chat_completion_parses_markdown_json(self, client, mock_llm_engine):
"""Markdown-wrapped JSON should be parsed when response_format=json_object."""
import json
mock_llm_engine.chat = AsyncMock(
return_value=MockGenerationOutput(
text='```json\n{"name": "test", "age": 25}\n```',
prompt_tokens=10,
completion_tokens=8,
finish_reason="stop",
finished=True,
)
)
response = client.post(
"/v1/chat/completions",
json={
"model": "test-model",
"messages": [{"role": "user", "content": "Return JSON"}],
"response_format": {"type": "json_object"},
},
)
assert response.status_code == 200
data = response.json()
content = data["choices"][0]["message"]["content"]
parsed = json.loads(content)
assert parsed == {"name": "test", "age": 25}
def test_chat_completion_clean_json_unchanged(self, client, mock_llm_engine):
"""Already-clean JSON should pass through without corruption."""
import json
mock_llm_engine.chat = AsyncMock(
return_value=MockGenerationOutput(
text='{"key": "value"}',
prompt_tokens=10,
completion_tokens=5,
finish_reason="stop",
finished=True,
)
)
response = client.post(
"/v1/chat/completions",
json={
"model": "test-model",
"messages": [{"role": "user", "content": "Return JSON"}],
"response_format": {"type": "json_object"},
},
)
assert response.status_code == 200
data = response.json()
content = data["choices"][0]["message"]["content"]
parsed = json.loads(content)
assert parsed == {"key": "value"}
def test_responses_parses_markdown_json(self, client, mock_llm_engine):
"""Responses API should parse markdown-wrapped JSON with text.format."""
import json
mock_llm_engine.chat = AsyncMock(
return_value=MockGenerationOutput(
text='```json\n{"city": "Seoul", "temp": 15}\n```',
prompt_tokens=10,
completion_tokens=8,
finish_reason="stop",
finished=True,
)
)
response = client.post(
"/v1/responses",
json={
"model": "test-model",
"input": "Return weather JSON",
"text": {
"format": {"type": "json_object"},
},
},
)
assert response.status_code == 200
data = response.json()
output_text = data["output"][0]["content"][0]["text"]
parsed = json.loads(output_text)
assert parsed == {"city": "Seoul", "temp": 15}
def test_responses_without_format_unchanged(self, client, mock_llm_engine):
"""Responses API without text.format should return raw text."""
mock_llm_engine.chat = AsyncMock(
return_value=MockGenerationOutput(
text="Hello, how can I help?",
prompt_tokens=10,
completion_tokens=5,
finish_reason="stop",
finished=True,
)
)
response = client.post(
"/v1/responses",
json={
"model": "test-model",
"input": "Hi",
},
)
assert response.status_code == 200
data = response.json()
output_text = data["output"][0]["content"][0]["text"]
assert "Hello" in output_text