dynamo/benchmarks/prefix_data_generator/tests/mock_server.py

302 lines
9.5 KiB
Python
Executable File

#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""
Simple mock server to receive and log requests from aiperf loadgen.
Prints each request with timestamp, useful for debugging request patterns,
verifying session ordering, and inspecting delays between requests.
Usage:
python mock_server.py --port 8000
Then run aiperf pointing to this server:
python agent_benchmark.py --input-dataset trace.jsonl --url http://localhost:8000
"""
import argparse
import asyncio
import json
import time
from datetime import datetime
from aiohttp import web
class RequestLogger:
"""Tracks and logs incoming requests with timing information."""
def __init__(self, output_file: str | None = None, payload_file: str | None = None):
self.request_count = 0
self.start_time = None
self.session_last_seen: dict[str, float] = {}
self.output_file = output_file
self.payload_file = payload_file
self._file_handle = None
self._payload_handle = None
if output_file:
self._file_handle = open(output_file, "w")
if payload_file:
self._payload_handle = open(payload_file, "w")
def close(self):
"""Close the output files if open."""
if self._file_handle:
self._file_handle.close()
if self._payload_handle:
self._payload_handle.close()
def log_request(self, request_data: dict, headers: dict) -> None:
"""Log a request with timestamp and relevant metadata."""
now = time.time()
if self.start_time is None:
self.start_time = now
self.request_count += 1
elapsed = now - self.start_time
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
# Extract useful fields
model = request_data.get("model", "unknown")
messages = request_data.get("messages", [])
# Support both max_completion_tokens (new) and max_tokens (legacy)
max_tokens = request_data.get(
"max_completion_tokens", request_data.get("max_tokens", "N/A")
)
# Check for session tracking via x-correlation-id header
correlation_id = headers.get(
"x-correlation-id", headers.get("X-Correlation-Id")
)
# Calculate delay since last request from same session
session_delay = None
if correlation_id and correlation_id in self.session_last_seen:
session_delay = (now - self.session_last_seen[correlation_id]) * 1000 # ms
if correlation_id:
self.session_last_seen[correlation_id] = now
# Build log line
log_parts = [
f"[{timestamp}]",
f"#{self.request_count:4d}",
f"elapsed={elapsed:7.2f}s",
f"model={model}",
f"msgs={len(messages)}",
f"max_tokens={max_tokens}",
]
if correlation_id:
# Truncate for display
short_id = correlation_id[:8] if len(correlation_id) > 8 else correlation_id
log_parts.append(f"session={short_id}")
if session_delay is not None:
log_parts.append(f"delay={session_delay:.0f}ms")
log_line = " | ".join(log_parts)
print(log_line)
if self._file_handle:
self._file_handle.write(log_line + "\n")
self._file_handle.flush()
# Dump full payload to separate file
if self._payload_handle:
payload_entry = {
"request_number": self.request_count,
"timestamp": timestamp,
"elapsed_s": elapsed,
"correlation_id": correlation_id,
"headers": {
k: v
for k, v in headers.items()
if k.lower() not in ("authorization", "accept")
},
"body": request_data,
}
self._payload_handle.write(json.dumps(payload_entry) + "\n")
self._payload_handle.flush()
logger: RequestLogger = None # Initialized in main()
async def health_check(request: web.Request) -> web.Response:
"""Health check endpoint."""
return web.json_response({"status": "ok"})
async def chat_completions(request: web.Request) -> web.StreamResponse:
"""Handle /v1/chat/completions requests."""
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"error": "Invalid JSON"}, status=400)
# Log the request
headers = dict(request.headers)
logger.log_request(body, headers)
# Check if streaming is requested
stream = body.get("stream", False)
# Support both max_completion_tokens (new) and max_tokens (legacy)
max_tokens = body.get("max_completion_tokens", body.get("max_tokens", 10))
if stream:
# Return a streaming response
response = web.StreamResponse(
status=200,
headers={
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
)
await response.prepare(request)
# Send a minimal streaming response
# Each chunk simulates a token being generated
for i in range(max_tokens):
chunk = {
"id": f"chatcmpl-mock-{logger.request_count}",
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": body.get("model", "mock"),
"choices": [
{
"index": 0,
"delta": (
{"content": "x"}
if i > 0
else {"role": "assistant", "content": ""}
),
"finish_reason": None,
}
],
}
await response.write(f"data: {json.dumps(chunk)}\n\n".encode())
await asyncio.sleep(0.001) # Small delay between tokens
# Send final chunk with finish_reason
final_chunk = {
"id": f"chatcmpl-mock-{logger.request_count}",
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": body.get("model", "mock"),
"choices": [
{
"index": 0,
"delta": {},
"finish_reason": "stop",
}
],
}
await response.write(f"data: {json.dumps(final_chunk)}\n\n".encode())
await response.write(b"data: [DONE]\n\n")
await response.write_eof()
return response
else:
# Non-streaming response
return web.json_response(
{
"id": f"chatcmpl-mock-{logger.request_count}",
"object": "chat.completion",
"created": int(time.time()),
"model": body.get("model", "mock"),
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "x" * max_tokens},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 10,
"completion_tokens": max_tokens,
"total_tokens": 10 + max_tokens,
},
}
)
async def models_list(request: web.Request) -> web.Response:
"""Handle /v1/models endpoint."""
return web.json_response(
{
"object": "list",
"data": [
{
"id": "mock-model",
"object": "model",
"created": int(time.time()),
"owned_by": "mock",
}
],
}
)
def create_app() -> web.Application:
"""Create the aiohttp application."""
app = web.Application()
app.router.add_get("/health", health_check)
app.router.add_get("/v1/models", models_list)
app.router.add_post("/v1/chat/completions", chat_completions)
return app
def main():
global logger
parser = argparse.ArgumentParser(
description="Mock server to receive and log aiperf requests"
)
parser.add_argument(
"--port",
type=int,
default=8000,
help="Port to listen on (default: 8000)",
)
parser.add_argument(
"--host",
type=str,
default="0.0.0.0",
help="Host to bind to (default: 0.0.0.0)",
)
parser.add_argument(
"--output",
type=str,
default="mock_server_logs.txt",
help="Output file for request logs (default: mock_server_logs.txt)",
)
parser.add_argument(
"--payload-output",
type=str,
default="mock_server_payloads.jsonl",
help="Output file for full request payloads in JSONL format (default: mock_server_payloads.jsonl)",
)
args = parser.parse_args()
# Initialize logger with output files
logger = RequestLogger(output_file=args.output, payload_file=args.payload_output)
print(f"Starting mock server on {args.host}:{args.port}")
print(f"Logging requests to: {args.output}")
print(f"Dumping payloads to: {args.payload_output}")
print("Waiting for requests from aiperf...")
print("-" * 80)
try:
app = create_app()
web.run_app(app, host=args.host, port=args.port, print=None)
finally:
logger.close()
if __name__ == "__main__":
main()