728 lines
26 KiB
Python
728 lines
26 KiB
Python
import asyncio
|
|
import json
|
|
import random
|
|
from random import randint
|
|
from typing import Any, AsyncGenerator, Dict, Optional, Union
|
|
|
|
from fastapi import FastAPI, HTTPException, Request
|
|
from starlette.responses import JSONResponse, StreamingResponse
|
|
|
|
from ray.llm._internal.common.utils.cloud_utils import LoraMirrorConfig
|
|
from ray.llm._internal.serve.core.configs.llm_config import (
|
|
DiskMultiplexConfig,
|
|
LLMConfig,
|
|
)
|
|
from ray.llm._internal.serve.core.configs.openai_api_models import (
|
|
ChatCompletionRequest,
|
|
ChatCompletionResponse,
|
|
CompletionRequest,
|
|
CompletionResponse,
|
|
DetokenizeRequest,
|
|
DetokenizeResponse,
|
|
EmbeddingRequest,
|
|
EmbeddingResponse,
|
|
ErrorResponse,
|
|
ScoreRequest,
|
|
ScoreResponse,
|
|
TokenizeRequest,
|
|
TokenizeResponse,
|
|
TranscriptionRequest,
|
|
TranscriptionResponse,
|
|
)
|
|
from ray.llm._internal.serve.core.engine.protocol import LLMEngine
|
|
from ray.llm._internal.serve.core.protocol import RawRequestInfo
|
|
from ray.llm._internal.serve.engines.vllm.kv_transfer.base import (
|
|
DefaultConnectorBackend,
|
|
)
|
|
from ray.llm._internal.serve.utils.lora_serve_utils import LoraModelLoader
|
|
from ray.serve.context import (
|
|
_get_internal_replica_context,
|
|
_get_serve_request_context,
|
|
)
|
|
|
|
|
|
class MockVLLMEngine(LLMEngine):
|
|
"""Mock vLLM Engine that generates fake text responses.
|
|
|
|
- In case of LoRA it generates a prefix with the model name in the text part of the response.
|
|
"""
|
|
|
|
def __init__(self, llm_config: LLMConfig):
|
|
"""Create a mock vLLM Engine.
|
|
|
|
Args:
|
|
llm_config: The llm configuration for this engine
|
|
"""
|
|
self.llm_config = llm_config
|
|
# The mock skips engine init, where setup_engine_backend attaches this.
|
|
if llm_config.engine_kwargs.get("kv_transfer_config"):
|
|
llm_config._kv_connector_backend = DefaultConnectorBackend(llm_config)
|
|
self.started = False
|
|
self._current_lora_model: Dict[str, DiskMultiplexConfig] = {}
|
|
self._is_sleeping = False
|
|
self._is_paused = False
|
|
|
|
async def start(self):
|
|
"""Start the mock engine."""
|
|
self.started = True
|
|
|
|
def routing_stats(self) -> Dict[str, Any]:
|
|
"""Mock engine advertises no routing stats (no KV-cache events)."""
|
|
return {}
|
|
|
|
async def resolve_lora(self, lora_model: DiskMultiplexConfig):
|
|
"""Resolve/load a LoRA model."""
|
|
self._current_lora_model[lora_model.model_id] = lora_model
|
|
|
|
async def check_health(self) -> None:
|
|
"""Check the health of the mock engine."""
|
|
if not self.started:
|
|
raise RuntimeError("Engine not started")
|
|
|
|
async def reset_prefix_cache(self) -> None:
|
|
"""Reset the prefix cache of the mock engine."""
|
|
if not self.started:
|
|
raise RuntimeError("Engine not started")
|
|
|
|
async def start_profile(self) -> None:
|
|
"""Start profiling of the mock engine."""
|
|
if not self.started:
|
|
raise RuntimeError("Engine not started")
|
|
|
|
async def stop_profile(self) -> None:
|
|
"""Stop profiling of the mock engine."""
|
|
if not self.started:
|
|
raise RuntimeError("Engine not started")
|
|
|
|
async def sleep(self, **kwargs: Any) -> None:
|
|
"""Put the mock engine to sleep.
|
|
|
|
This mimics vLLM's behavior: resets prefix cache and sets sleeping state.
|
|
|
|
Args:
|
|
**kwargs: Engine-specific options.
|
|
"""
|
|
if not self.started:
|
|
raise RuntimeError("Engine not started")
|
|
# vLLM resets prefix cache on sleep
|
|
await self.reset_prefix_cache()
|
|
self._is_sleeping = True
|
|
|
|
async def wakeup(self, **kwargs: Any) -> None:
|
|
"""Wake up the mock engine from sleep.
|
|
|
|
Args:
|
|
**kwargs: Engine-specific options.
|
|
"""
|
|
if not self.started:
|
|
raise RuntimeError("Engine not started")
|
|
self._is_sleeping = False
|
|
|
|
async def is_sleeping(self) -> bool:
|
|
"""Check if the mock engine is sleeping.
|
|
|
|
Returns:
|
|
True if the engine is sleeping, False otherwise.
|
|
"""
|
|
return self._is_sleeping
|
|
|
|
async def pause(self, **kwargs: Any) -> None:
|
|
"""Pause generation on the mock engine.
|
|
|
|
This mimics vLLM's behavior: halts generation while keeping weights in GPU.
|
|
|
|
Args:
|
|
**kwargs: Engine-specific options (mode, clear_cache).
|
|
"""
|
|
if not self.started:
|
|
raise RuntimeError("Engine not started")
|
|
# vLLM optionally clears cache on pause
|
|
if kwargs.get("clear_cache", True):
|
|
await self.reset_prefix_cache()
|
|
self._is_paused = True
|
|
|
|
async def resume(self, **kwargs: Any) -> None:
|
|
"""Resume generation on the mock engine after pause.
|
|
|
|
Args:
|
|
**kwargs: Engine-specific options.
|
|
"""
|
|
if not self.started:
|
|
raise RuntimeError("Engine not started")
|
|
self._is_paused = False
|
|
|
|
async def is_paused(self) -> bool:
|
|
"""Check if the mock engine is paused.
|
|
|
|
Returns:
|
|
True if the engine is paused, False otherwise.
|
|
"""
|
|
return self._is_paused
|
|
|
|
async def build_asgi_app(self):
|
|
"""Build a minimal ASGI app for direct-streaming tests."""
|
|
app = FastAPI()
|
|
|
|
@app.middleware("http")
|
|
async def _tag_serving_replica(request: Request, call_next):
|
|
# Tag each response with the serving replica and the session id it
|
|
# saw, so direct-streaming tests can assert affinity over HAProxy.
|
|
response = await call_next(request)
|
|
ctx = _get_internal_replica_context()
|
|
if ctx is not None:
|
|
response.headers["x-replica-id"] = ctx.replica_id.unique_id
|
|
response.headers[
|
|
"x-serve-session-id"
|
|
] = _get_serve_request_context().session_id
|
|
return response
|
|
|
|
def check_model(model: Optional[str]) -> None:
|
|
if model is not None and model != self.llm_config.model_id:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"Could not find model {model}",
|
|
)
|
|
|
|
async def to_response(gen):
|
|
try:
|
|
first = await gen.__anext__()
|
|
except StopAsyncIteration:
|
|
return JSONResponse(content={})
|
|
|
|
if isinstance(first, ErrorResponse):
|
|
raise HTTPException(
|
|
status_code=first.error.code,
|
|
detail=first.error.message,
|
|
)
|
|
|
|
if isinstance(first, str):
|
|
|
|
async def stream():
|
|
yield first
|
|
async for item in gen:
|
|
if isinstance(item, str):
|
|
yield item
|
|
else:
|
|
yield f"data: {item.model_dump_json()}\n\n"
|
|
|
|
return StreamingResponse(stream(), media_type="text/event-stream")
|
|
|
|
return JSONResponse(content=first.model_dump())
|
|
|
|
@app.get("/v1/models")
|
|
async def models():
|
|
return {
|
|
"object": "list",
|
|
"data": [
|
|
{
|
|
"id": self.llm_config.model_id,
|
|
"object": "model",
|
|
"created": 0,
|
|
"owned_by": "mock",
|
|
"metadata": {"input_modality": "text"},
|
|
}
|
|
],
|
|
}
|
|
|
|
@app.post("/v1/chat/completions")
|
|
async def chat_completions(request: Request):
|
|
body = ChatCompletionRequest.model_validate(await request.json())
|
|
check_model(body.model)
|
|
return await to_response(self.chat(body))
|
|
|
|
@app.post("/v1/completions")
|
|
async def completions(request: Request):
|
|
body = CompletionRequest.model_validate(await request.json())
|
|
check_model(body.model)
|
|
return await to_response(self.completions(body))
|
|
|
|
return app
|
|
|
|
async def chat(
|
|
self,
|
|
request: ChatCompletionRequest,
|
|
raw_request_info: Optional[RawRequestInfo] = None,
|
|
) -> AsyncGenerator[Union[str, ChatCompletionResponse, ErrorResponse], None]:
|
|
"""Mock chat completion."""
|
|
if not self.started:
|
|
raise RuntimeError("Engine not started")
|
|
|
|
# Extract prompt text from messages
|
|
prompt_text = ""
|
|
if request.messages:
|
|
for message in request.messages:
|
|
if hasattr(message, "content") and message.content:
|
|
prompt_text += str(message.content) + " "
|
|
|
|
max_tokens = getattr(request, "max_tokens", None) or randint(1, 10)
|
|
|
|
# Generate streaming response
|
|
async for response in self._generate_chat_response(
|
|
request=request, prompt_text=prompt_text.strip(), max_tokens=max_tokens
|
|
):
|
|
yield response
|
|
|
|
async def completions(
|
|
self,
|
|
request: CompletionRequest,
|
|
raw_request_info: Optional[RawRequestInfo] = None,
|
|
) -> AsyncGenerator[Union[str, CompletionResponse, ErrorResponse], None]:
|
|
"""Mock text completion."""
|
|
if not self.started:
|
|
raise RuntimeError("Engine not started")
|
|
|
|
prompt_text = str(request.prompt) if request.prompt else ""
|
|
max_tokens = getattr(request, "max_tokens", None) or randint(5, 20)
|
|
|
|
# Generate streaming response
|
|
async for response in self._generate_completion_response(
|
|
request=request, prompt_text=prompt_text, max_tokens=max_tokens
|
|
):
|
|
yield response
|
|
|
|
async def embeddings(
|
|
self,
|
|
request: EmbeddingRequest,
|
|
raw_request_info: Optional[RawRequestInfo] = None,
|
|
) -> AsyncGenerator[Union[str, EmbeddingResponse, ErrorResponse], None]:
|
|
"""Mock embeddings generation."""
|
|
if not self.started:
|
|
raise RuntimeError("Engine not started")
|
|
|
|
# Generate a mock embedding response
|
|
embedding_data = []
|
|
inputs = request.input if isinstance(request.input, list) else [request.input]
|
|
|
|
for i, text in enumerate(inputs):
|
|
# Generate random embedding vector
|
|
dimensions = getattr(request, "dimensions", None) or 1536
|
|
embedding = [random.uniform(-1, 1) for _ in range(dimensions)]
|
|
|
|
embedding_data.append(
|
|
{"object": "embedding", "embedding": embedding, "index": i}
|
|
)
|
|
|
|
response = EmbeddingResponse(
|
|
object="list",
|
|
data=embedding_data,
|
|
model=request.model or self.llm_config.model_id,
|
|
usage={
|
|
"prompt_tokens": len(str(request.input).split()),
|
|
"total_tokens": len(str(request.input).split()),
|
|
},
|
|
)
|
|
yield response
|
|
|
|
async def transcriptions(
|
|
self,
|
|
request: TranscriptionRequest,
|
|
raw_request_info: Optional[RawRequestInfo] = None,
|
|
) -> AsyncGenerator[Union[str, TranscriptionResponse, ErrorResponse], None]:
|
|
"""Mock transcription generation."""
|
|
if not self.started:
|
|
raise RuntimeError("Engine not started")
|
|
|
|
# Extract audio file info
|
|
language = getattr(request, "language", "en")
|
|
temperature = getattr(request, "temperature", 0.0)
|
|
|
|
# Generate transcription response
|
|
async for response in self._generate_transcription_response(
|
|
request=request, language=language, temperature=temperature
|
|
):
|
|
yield response
|
|
|
|
async def score(
|
|
self,
|
|
request: ScoreRequest,
|
|
raw_request_info: Optional[RawRequestInfo] = None,
|
|
) -> AsyncGenerator[Union[str, ScoreResponse, ErrorResponse], None]:
|
|
"""Mock score generation for text pairs."""
|
|
if not self.started:
|
|
raise RuntimeError("Engine not started")
|
|
|
|
# Extract text_1 and text_2 from the request
|
|
text_1 = getattr(request, "text_1", "")
|
|
text_2 = getattr(request, "text_2", "")
|
|
|
|
# Convert to lists if they aren't already
|
|
text_1_list = text_1 if isinstance(text_1, list) else [text_1]
|
|
text_2_list = text_2 if isinstance(text_2, list) else [text_2]
|
|
|
|
# Generate mock scores for each pair
|
|
score_data = []
|
|
for i, (t1, t2) in enumerate(zip(text_1_list, text_2_list)):
|
|
# Generate a random score (can be any float value)
|
|
score = random.uniform(-10.0, 10.0)
|
|
|
|
score_data.append({"object": "score", "score": score, "index": i})
|
|
|
|
# Create the response
|
|
response = ScoreResponse(
|
|
object="list",
|
|
data=score_data,
|
|
model=request.model or self.llm_config.model_id,
|
|
usage={
|
|
"prompt_tokens": len(str(text_1).split()) + len(str(text_2).split()),
|
|
"total_tokens": len(str(text_1).split()) + len(str(text_2).split()),
|
|
},
|
|
)
|
|
yield response
|
|
|
|
async def tokenize(
|
|
self,
|
|
request: TokenizeRequest,
|
|
raw_request_info: Optional[RawRequestInfo] = None,
|
|
) -> AsyncGenerator[Union[TokenizeResponse, ErrorResponse], None]:
|
|
"""Mock tokenize generation."""
|
|
if not self.started:
|
|
raise RuntimeError("Engine not started")
|
|
|
|
# Get prompt text from the request
|
|
prompt = getattr(request, "prompt", None)
|
|
if prompt is None:
|
|
# For TokenizeChatRequest, messages would be used
|
|
messages = getattr(request, "messages", [])
|
|
prompt = " ".join(str(getattr(m, "content", "")) for m in messages if m)
|
|
|
|
# Generate mock token IDs (simple: use character codes)
|
|
prompt_str = str(prompt) if prompt else ""
|
|
tokens = [ord(c) for c in prompt_str]
|
|
|
|
# Optionally generate token strings
|
|
return_token_strs = getattr(request, "return_token_strs", False)
|
|
token_strs = list(prompt_str) if return_token_strs else None
|
|
|
|
response = TokenizeResponse(
|
|
count=len(tokens),
|
|
max_model_len=4096, # Mock max model length
|
|
tokens=tokens,
|
|
token_strs=token_strs,
|
|
)
|
|
yield response
|
|
|
|
async def detokenize(
|
|
self,
|
|
request: DetokenizeRequest,
|
|
raw_request_info: Optional[RawRequestInfo] = None,
|
|
) -> AsyncGenerator[Union[DetokenizeResponse, ErrorResponse], None]:
|
|
"""Mock detokenize generation."""
|
|
if not self.started:
|
|
raise RuntimeError("Engine not started")
|
|
|
|
# Get tokens from the request
|
|
tokens = getattr(request, "tokens", [])
|
|
|
|
# Convert token IDs back to characters (inverse of our mock tokenize)
|
|
prompt = "".join(chr(t) if 0 <= t < 0x110000 else "?" for t in tokens)
|
|
|
|
response = DetokenizeResponse(prompt=prompt)
|
|
yield response
|
|
|
|
def _maybe_attach_kv_transfer_params(self, request, response) -> None:
|
|
"""Stamp the serving replica id into ``kv_transfer_params`` for P/D tests.
|
|
|
|
The orchestrator sends the prefill request with ``remote_engine_id``
|
|
unset; fill it with this replica's id so the response reports the prefill
|
|
replica. On the decode request the id is already set and passes through.
|
|
Lets tests observe that the session id pinned the prefill replica, not
|
|
just the decode ingress.
|
|
"""
|
|
params = getattr(request, "kv_transfer_params", None)
|
|
if not params:
|
|
return
|
|
params = dict(params)
|
|
if params.get("remote_engine_id") is None:
|
|
ctx = _get_internal_replica_context()
|
|
if ctx is not None:
|
|
params["remote_engine_id"] = ctx.replica_id.unique_id
|
|
response.kv_transfer_params = params
|
|
|
|
async def _generate_chat_response(
|
|
self, request: ChatCompletionRequest, prompt_text: str, max_tokens: int
|
|
) -> AsyncGenerator[Union[str, ChatCompletionResponse], None]:
|
|
"""Generate mock chat completion response."""
|
|
|
|
request_id = request.request_id or f"chatcmpl-{random.randint(1000, 9999)}"
|
|
# # Use request.model if provided, otherwise fall back to llm_config.model_id
|
|
model_name = request.model or self.llm_config.model_id
|
|
lora_prefix = (
|
|
""
|
|
if request.model not in self._current_lora_model
|
|
else f"[lora_model] {request.model}: "
|
|
)
|
|
if request.stream:
|
|
# Streaming response - return SSE formatted strings
|
|
created_time = int(asyncio.get_event_loop().time())
|
|
|
|
for i in range(max_tokens):
|
|
if i == 0:
|
|
token = f"{lora_prefix}test_{i} "
|
|
else:
|
|
token = f"test_{i} "
|
|
if i == max_tokens - 1:
|
|
# no space for the last token
|
|
token = f"test_{i}"
|
|
|
|
# Create streaming chunk
|
|
choice = {
|
|
"index": 0,
|
|
"delta": {
|
|
"content": token,
|
|
"role": "assistant" if i == 0 else None,
|
|
},
|
|
"finish_reason": "stop" if i == max_tokens - 1 else None,
|
|
}
|
|
|
|
chunk_data = {
|
|
"id": request_id,
|
|
"object": "chat.completion.chunk",
|
|
"created": created_time,
|
|
"model": model_name,
|
|
"choices": [choice],
|
|
}
|
|
|
|
# Format as SSE
|
|
yield f"data: {json.dumps(chunk_data)}\n\n"
|
|
await asyncio.sleep(0.01) # Simulate processing time
|
|
|
|
# Send final [DONE] message
|
|
yield "data: [DONE]\n\n"
|
|
else:
|
|
# Non-streaming response - return response object
|
|
generated_text = " ".join([f"test_{i}" for i in range(max_tokens)])
|
|
generated_text = f"{lora_prefix}{generated_text}"
|
|
|
|
choice = {
|
|
"index": 0,
|
|
"message": {"role": "assistant", "content": generated_text},
|
|
"finish_reason": "stop",
|
|
}
|
|
|
|
response = ChatCompletionResponse(
|
|
id=request_id,
|
|
object="chat.completion",
|
|
created=int(asyncio.get_event_loop().time()),
|
|
model=model_name,
|
|
choices=[choice],
|
|
usage={
|
|
"prompt_tokens": len(prompt_text.split()),
|
|
"completion_tokens": max_tokens,
|
|
"total_tokens": len(prompt_text.split()) + max_tokens,
|
|
},
|
|
)
|
|
|
|
self._maybe_attach_kv_transfer_params(request, response)
|
|
yield response
|
|
|
|
async def _generate_completion_response(
|
|
self, request: CompletionRequest, prompt_text: str, max_tokens: int
|
|
) -> AsyncGenerator[Union[str, CompletionResponse], None]:
|
|
"""Generate mock completion response."""
|
|
|
|
request_id = request.request_id or f"cmpl-{random.randint(1000, 9999)}"
|
|
model_name = request.model or self.llm_config.model_id
|
|
lora_prefix = (
|
|
""
|
|
if request.model not in self._current_lora_model
|
|
else f"[lora_model] {request.model}: "
|
|
)
|
|
if request.stream:
|
|
# Streaming response - return SSE formatted strings
|
|
created_time = int(asyncio.get_event_loop().time())
|
|
|
|
for i in range(max_tokens):
|
|
if i == 0:
|
|
token = f"{lora_prefix}test_{i} "
|
|
else:
|
|
token = f"test_{i} "
|
|
if i == max_tokens - 1:
|
|
# no space for the last token
|
|
token = f"test_{i}"
|
|
|
|
choice = {
|
|
"index": 0,
|
|
"text": token,
|
|
"finish_reason": "stop" if i == max_tokens - 1 else None,
|
|
}
|
|
|
|
chunk_data = {
|
|
"id": request_id,
|
|
"object": "text_completion",
|
|
"created": created_time,
|
|
"model": model_name,
|
|
"choices": [choice],
|
|
}
|
|
|
|
# Format as SSE
|
|
yield f"data: {json.dumps(chunk_data)}\n\n"
|
|
await asyncio.sleep(0.01)
|
|
|
|
# Send final [DONE] message
|
|
yield "data: [DONE]\n\n"
|
|
else:
|
|
# Non-streaming response - return response object
|
|
generated_text = " ".join([f"test_{i}" for i in range(max_tokens)])
|
|
generated_text = f"{lora_prefix}{generated_text}"
|
|
|
|
choice = {"index": 0, "text": generated_text, "finish_reason": "stop"}
|
|
|
|
response = CompletionResponse(
|
|
id=request_id,
|
|
object="text_completion",
|
|
created=int(asyncio.get_event_loop().time()),
|
|
model=model_name,
|
|
choices=[choice],
|
|
usage={
|
|
"prompt_tokens": len(prompt_text.split()),
|
|
"completion_tokens": max_tokens,
|
|
"total_tokens": len(prompt_text.split()) + max_tokens,
|
|
},
|
|
)
|
|
|
|
self._maybe_attach_kv_transfer_params(request, response)
|
|
yield response
|
|
|
|
async def _generate_transcription_response(
|
|
self,
|
|
request: TranscriptionRequest,
|
|
language: str,
|
|
temperature: float,
|
|
) -> AsyncGenerator[Union[str, TranscriptionResponse], None]:
|
|
"""Generate mock transcription response."""
|
|
|
|
request_id = request.request_id or f"transcribe-{random.randint(1000, 9999)}"
|
|
lora_prefix = (
|
|
""
|
|
if request.model not in self._current_lora_model
|
|
else f"[lora_model] {request.model}: "
|
|
)
|
|
|
|
# Generate mock transcription text with LoRA prefix
|
|
mock_transcription_text = (
|
|
f"Mock transcription in {language} language with temperature {temperature}"
|
|
)
|
|
if lora_prefix:
|
|
mock_transcription_text = f"{lora_prefix}{mock_transcription_text}"
|
|
|
|
model_name = request.model or self.llm_config.model_id
|
|
|
|
if request.stream:
|
|
# Streaming response - return SSE formatted strings
|
|
created_time = int(asyncio.get_event_loop().time())
|
|
|
|
# Split transcription into words for streaming
|
|
words = mock_transcription_text.split()
|
|
|
|
for i, word in enumerate(words):
|
|
# Create streaming chunk
|
|
choice = {
|
|
"delta": {
|
|
"content": word + (" " if i < len(words) - 1 else ""),
|
|
},
|
|
}
|
|
|
|
chunk_data = {
|
|
"delta": None,
|
|
"type": None,
|
|
"logprobs": None,
|
|
"id": request_id,
|
|
"object": "transcription.chunk",
|
|
"created": created_time,
|
|
"model": model_name,
|
|
"choices": [choice],
|
|
}
|
|
|
|
# Format as SSE
|
|
yield f"data: {json.dumps(chunk_data)}\n\n"
|
|
await asyncio.sleep(0.01) # Simulate processing time
|
|
|
|
# Send final chunk with finish_reason
|
|
final_choice = {
|
|
"delta": {
|
|
"content": "",
|
|
"finish_reason": "stop",
|
|
"stop_reason": None,
|
|
},
|
|
}
|
|
|
|
final_chunk_data = {
|
|
"delta": None,
|
|
"type": None,
|
|
"logprobs": None,
|
|
"id": request_id,
|
|
"object": "transcription.chunk",
|
|
"created": created_time,
|
|
"model": model_name,
|
|
"choices": [final_choice],
|
|
}
|
|
|
|
yield f"data: {json.dumps(final_chunk_data)}\n\n"
|
|
|
|
# Send final [DONE] message
|
|
yield "data: [DONE]\n\n"
|
|
else:
|
|
# Non-streaming response - return response object
|
|
response = TranscriptionResponse(
|
|
text=mock_transcription_text,
|
|
logprobs=None,
|
|
usage={
|
|
"seconds": 5.0,
|
|
"type": "duration",
|
|
},
|
|
)
|
|
yield response
|
|
|
|
|
|
class MockAsyncLLM:
|
|
"""Mock vLLM's AsyncLLM: ``generate`` replays a fixed list of
|
|
``RequestOutput``s, with ``error_after`` raising mid-stream."""
|
|
|
|
def __init__(self, script, error_after=None):
|
|
self.script = script
|
|
self.error_after = error_after
|
|
|
|
async def generate(self, prompt, sampling_params, request_id, **kwargs):
|
|
for i, output in enumerate(self.script):
|
|
if self.error_after is not None and i == self.error_after:
|
|
raise RuntimeError("engine failure")
|
|
yield output
|
|
|
|
|
|
class FakeLoraModelLoader(LoraModelLoader):
|
|
"""Fake LoRA model loader for testing that bypasses S3 entirely."""
|
|
|
|
async def load_model_from_config(
|
|
self, lora_model_id: str, llm_config
|
|
) -> DiskMultiplexConfig:
|
|
"""Load a fake LoRA model without any S3 access."""
|
|
return DiskMultiplexConfig(
|
|
model_id=lora_model_id,
|
|
max_total_tokens=llm_config.max_request_context_length,
|
|
local_path="/fake/local/path",
|
|
lora_assigned_int_id=random.randint(1, 100),
|
|
)
|
|
|
|
async def load_model(
|
|
self, lora_model_id: str, lora_mirror_config: LoraMirrorConfig
|
|
) -> DiskMultiplexConfig:
|
|
"""Load a fake LoRA model."""
|
|
return DiskMultiplexConfig(
|
|
model_id=lora_model_id,
|
|
max_total_tokens=lora_mirror_config.max_total_tokens,
|
|
local_path="/fake/local/path",
|
|
lora_assigned_int_id=random.randint(1, 100),
|
|
)
|
|
|
|
|
|
class PGCreationMockEngine(MockVLLMEngine):
|
|
"""
|
|
A wrapper around the mock engine that forces it to create the placement
|
|
group on startup, simulating the real vLLM initialization sequence.
|
|
"""
|
|
|
|
def __init__(self, llm_config, *args, **kwargs):
|
|
super().__init__(llm_config, *args, **kwargs)
|
|
self.engine_config = llm_config.get_engine_config()
|
|
self.engine_config.get_or_create_pg()
|