refactor(api): tighten fixed-session chat input contract

Reject mismatched models and require a single user message so the OpenAI-compatible endpoint reflects the fixed-session nanobot runtime without extra compatibility noise.
This commit is contained in:
Xubin Ren 2026-03-30 14:20:14 +00:00
parent 5635907e33
commit 55501057ac
2 changed files with 77 additions and 18 deletions

View File

@ -69,21 +69,17 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
return _error_json(400, "Invalid JSON body")
messages = body.get("messages")
if not messages or not isinstance(messages, list):
return _error_json(400, "messages field is required and must be a non-empty array")
if not isinstance(messages, list) or len(messages) != 1:
return _error_json(400, "Only a single user message is supported")
# Stream not yet supported
if body.get("stream", False):
return _error_json(400, "stream=true is not supported yet. Set stream=false or omit it.")
# Extract last user message — nanobot manages its own multi-turn history
user_content = None
for msg in reversed(messages):
if msg.get("role") == "user":
user_content = msg.get("content", "")
break
if user_content is None:
return _error_json(400, "messages must contain at least one user message")
message = messages[0]
if not isinstance(message, dict) or message.get("role") != "user":
return _error_json(400, "Only a single user message is supported")
user_content = message.get("content", "")
if isinstance(user_content, list):
# Multi-modal content array — extract text parts
user_content = " ".join(
@ -92,7 +88,9 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
agent_loop = request.app["agent_loop"]
timeout_s: float = request.app.get("request_timeout", 120.0)
model_name: str = body.get("model") or request.app.get("model_name", "nanobot")
model_name: str = request.app.get("model_name", "nanobot")
if (requested_model := body.get("model")) and requested_model != model_name:
return _error_json(400, f"Only configured model '{model_name}' is available")
session_lock: asyncio.Lock = request.app["session_lock"]
logger.info("API request session_key={} content={}", API_SESSION_KEY, user_content[:80])
@ -190,10 +188,3 @@ def create_app(agent_loop, model_name: str = "nanobot", request_timeout: float =
app.router.add_get("/v1/models", handle_models)
app.router.add_get("/health", handle_health)
return app
def run_server(agent_loop, host: str = "127.0.0.1", port: int = 8900,
model_name: str = "nanobot", request_timeout: float = 120.0) -> None:
"""Create and run the server (blocking)."""
app = create_app(agent_loop, model_name=model_name, request_timeout=request_timeout)
web.run_app(app, host=host, port=port, print=lambda msg: logger.info(msg))

View File

@ -14,6 +14,7 @@ from nanobot.api.server import (
_chat_completion_response,
_error_json,
create_app,
handle_chat_completions,
)
try:
@ -93,6 +94,73 @@ async def test_stream_true_returns_400(aiohttp_client, app) -> None:
assert "stream" in body["error"]["message"].lower()
@pytest.mark.asyncio
async def test_model_mismatch_returns_400() -> None:
request = MagicMock()
request.json = AsyncMock(
return_value={
"model": "other-model",
"messages": [{"role": "user", "content": "hello"}],
}
)
request.app = {
"agent_loop": _make_mock_agent(),
"model_name": "test-model",
"request_timeout": 10.0,
"session_lock": asyncio.Lock(),
}
resp = await handle_chat_completions(request)
assert resp.status == 400
body = json.loads(resp.body)
assert "test-model" in body["error"]["message"]
@pytest.mark.asyncio
async def test_single_user_message_required() -> None:
request = MagicMock()
request.json = AsyncMock(
return_value={
"messages": [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "previous reply"},
],
}
)
request.app = {
"agent_loop": _make_mock_agent(),
"model_name": "test-model",
"request_timeout": 10.0,
"session_lock": asyncio.Lock(),
}
resp = await handle_chat_completions(request)
assert resp.status == 400
body = json.loads(resp.body)
assert "single user message" in body["error"]["message"].lower()
@pytest.mark.asyncio
async def test_single_user_message_must_have_user_role() -> None:
request = MagicMock()
request.json = AsyncMock(
return_value={
"messages": [{"role": "system", "content": "you are a bot"}],
}
)
request.app = {
"agent_loop": _make_mock_agent(),
"model_name": "test-model",
"request_timeout": 10.0,
"session_lock": asyncio.Lock(),
}
resp = await handle_chat_completions(request)
assert resp.status == 400
body = json.loads(resp.body)
assert "single user message" in body["error"]["message"].lower()
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_successful_request_uses_fixed_api_session(aiohttp_client, mock_agent) -> None: