R1-MATH / server.py
gaaaaaaaaaaa's picture
Pre-DPO Quality
fc9409e verified
Raw History Blame Contribute Delete
32.5 kB
import asyncio
import json
import logging
import os
import re
import time
from pathlib import Path
from typing import Any, AsyncIterator, Iterator, Literal
import path_explorer
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from src.inference import (
BaseInferenceBackend,
InferenceConfig,
TransformersBackend,
build_inference_backend,
)
from src.pipeline.runtime_instructions import (
instruction_block,
thinking_limit_reached_instruction,
)
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"))
logger = logging.getLogger(__name__)
BASE_DIR = Path(__file__).resolve().parent
OUTPUTS_DIR = BASE_DIR / "outputs"
MODEL_JSON_DIR = BASE_DIR / "model_json"
DISABLE_VLLM_MODE = True
app = FastAPI(title="MATH Reasoning Lab")
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
class ChatMessage(BaseModel):
role: Literal["system", "user", "assistant"]
content: str
class ChatRequest(BaseModel):
messages: list[ChatMessage] = Field(default_factory=list)
model: str = Field(default="peft:1K")
temperature: float = Field(default=0.6, ge=0.0, le=2.0)
max_tokens: int = Field(default=2048, ge=1, le=32768)
fast_mode: bool = Field(default=False)
thinking_effort: Literal[
"none",
"flash",
"speed",
"low",
"medium",
"high",
"extreme",
"max",
] = Field(default="medium")
class ModelRequest(BaseModel):
model: str = Field(default="peft:1K")
class JsonSaveRequest(BaseModel):
filename: str = Field(default="dpo_turns.jsonl")
record: dict[str, Any] = Field(default_factory=dict)
def discover_model_options() -> list[dict[str, Any]]:
options: list[dict[str, Any]] = [
{
"id": "base:qwen2.5-3b-math",
"label": "Qwen2.5 3B base",
"kind": "base",
"path": None,
"available": True,
}
]
if OUTPUTS_DIR.exists():
for adapter_config in sorted(OUTPUTS_DIR.rglob("adapter_config.json")):
adapter_dir = adapter_config.parent
relative_path = adapter_dir.relative_to(OUTPUTS_DIR).as_posix()
checkpoint = checkpoint_name(adapter_dir)
family = model_family_name(adapter_dir)
label = adapter_label(adapter_dir)
options.append(
{
"id": f"peft:{relative_path}",
"label": label,
"kind": "peft",
"path": str(adapter_dir),
"relative_path": relative_path,
"family": family,
"checkpoint": checkpoint,
"available": True,
}
)
return options
def checkpoint_name(adapter_dir: Path) -> str | None:
for part in reversed(adapter_dir.parts):
if part.startswith("checkpoint-"):
return part
return None
def checkpoint_step(option: dict[str, Any]) -> int:
checkpoint = option.get("checkpoint") or ""
match = re.search(r"checkpoint-(\d+)", checkpoint)
return int(match.group(1)) if match else -1
def model_family_name(adapter_dir: Path) -> str:
relative_parts = adapter_dir.relative_to(OUTPUTS_DIR).parts
checkpoint = checkpoint_name(adapter_dir)
if checkpoint and len(relative_parts) > 1:
return relative_parts[-2]
return relative_parts[-1]
def adapter_label(adapter_dir: Path) -> str:
relative_path = adapter_dir.relative_to(OUTPUTS_DIR).as_posix()
checkpoint = checkpoint_name(adapter_dir)
family = model_family_name(adapter_dir)
if checkpoint:
checkpoint_label = checkpoint.replace("-", " ")
return f"{checkpoint_label} - {family}"
return f"{relative_path} PEFT"
def model_options_by_id() -> dict[str, dict[str, Any]]:
return {option["id"]: option for option in discover_model_options()}
def default_model_id() -> str:
options = discover_model_options()
peft_options = [option for option in options if option["kind"] == "peft"]
checkpoint_options = [option for option in peft_options if option.get("checkpoint")]
if checkpoint_options:
return max(
checkpoint_options,
key=lambda option: (checkpoint_step(option), option.get("relative_path", "")),
)["id"]
peft_by_id = {option["id"]: option for option in peft_options}
if "peft:1K" in peft_by_id:
return "peft:1K"
if peft_options:
return peft_options[0]["id"]
return "base:qwen2.5-3b-math"
MODEL_STATE: dict[str, Any] = {
"connected": False,
"loading": False,
"mode": "real",
"model": default_model_id(),
"label": model_options_by_id().get(default_model_id(), {}).get("label", "Qwen2.5 3B base"),
"error": None,
}
LOADED_MODELS: dict[str, Any] = {}
MODEL_LOCK = asyncio.Lock()
INFERENCE_BACKENDS: dict[tuple[str, str, str], BaseInferenceBackend] = {}
INFERENCE_LOCK = asyncio.Lock()
LAST_INFERENCE_HEALTH: dict[str, Any] = {
"ok": None,
"backend": None,
"model": None,
"error": None,
}
THINKING_TOKEN_BUDGETS = {
"none": 64,
"flash": 256,
"speed": 512,
"low": 1024,
"medium": 2048,
"high": 4096,
"extreme": 5120,
"max": 8192,
}
BEGIN_THOUGHT = "<|begin_of_thought|>"
END_THOUGHT = "<|end_of_thought|>"
BEGIN_SOLUTION = "<|begin_of_solution|>"
END_SOLUTION = "<|end_of_solution|>"
PROTOCOL_TAGS = (BEGIN_THOUGHT, END_THOUGHT, BEGIN_SOLUTION, END_SOLUTION)
THOUGHT_STOP_SEQUENCES = (END_THOUGHT, BEGIN_SOLUTION, END_SOLUTION)
STREAM_TEXT_BATCH_CHARS = 96
STREAM_TEXT_BATCH_SECONDS = 0.04
THOUGHT_SENTENCE_COMPLETION_TOKENS = 96
@app.get("/")
async def index() -> FileResponse:
return FileResponse(BASE_DIR / "index.html")
@app.get("/api/status")
async def status() -> dict[str, Any]:
return {
**MODEL_STATE,
**runtime_info(),
"inference": inference_status(),
"models": discover_model_options(),
"default_model": default_model_id(),
}
@app.get("/api/models")
async def models() -> dict[str, Any]:
return {"models": discover_model_options(), "default_model": default_model_id()}
@app.post("/api/preload")
async def preload(request: ModelRequest) -> dict[str, Any]:
option = resolve_model_option(request.model)
try:
config = inference_config()
if config.backend == "vllm":
await check_inference_health()
else:
await get_local_model(option["id"])
except Exception:
pass
return {**MODEL_STATE, **runtime_info(), "inference": inference_status()}
@app.get("/api/inference/health")
async def inference_health() -> dict[str, Any]:
return await check_inference_health()
@app.get("/api/model-json/files")
async def model_json_files() -> dict[str, list[str]]:
MODEL_JSON_DIR.mkdir(parents=True, exist_ok=True)
files = sorted(path.name for path in MODEL_JSON_DIR.glob("*.jsonl") if path.is_file())
return {"files": files}
@app.post("/api/model-json/save")
async def save_model_json(request: JsonSaveRequest) -> dict[str, Any]:
MODEL_JSON_DIR.mkdir(parents=True, exist_ok=True)
path = model_json_path(request.filename)
with path.open("a", encoding="utf-8") as file:
file.write(json.dumps(request.record, ensure_ascii=False, separators=(",", ":")) + "\n")
return {"ok": True, "file": path.name}
def last_user_message(messages: list[ChatMessage]) -> str:
for message in reversed(messages):
if message.role == "user":
return message.content
return "Solve a sample olympiad-style algebra problem."
def set_model_state(**updates: Any) -> None:
MODEL_STATE.update(updates)
def model_json_path(filename: str) -> Path:
name = filename.strip()
if not name:
name = "dpo_turns.jsonl"
if not name.endswith(".jsonl"):
name = f"{name}.jsonl"
if "/" in name or "\\" in name or name in {".jsonl", ".", ".."}:
raise HTTPException(status_code=400, detail="Invalid JSONL filename.")
path = (MODEL_JSON_DIR / name).resolve()
if path.parent != MODEL_JSON_DIR.resolve():
raise HTTPException(status_code=400, detail="Invalid JSONL filename.")
return path
def runtime_info() -> dict[str, str]:
try:
from src.device import runtime_metadata
return runtime_metadata()
except Exception as exc:
return {
"device": "unknown",
"device_label": "Runtime unavailable",
"torch_dtype": "unknown",
"device_error": str(exc),
}
def distillation_settings() -> dict[str, Any]:
try:
settings = path_explorer.Get("src/pipeline/distillation/deepseek/settings", raw=True)
return settings if isinstance(settings, dict) else {}
except Exception as exc:
logger.warning("Could not load distillation settings for inference config: %r", exc)
return {}
def local_model_settings() -> dict[str, Any]:
settings = distillation_settings().get("LOCAL_MODEL", {})
return settings if isinstance(settings, dict) else {}
def qwen_system_prompt() -> str:
try:
value = path_explorer.Get("src/model/Qwen3BInstruct/settings/PROMPT")
return str(value or "")
except Exception as exc:
logger.warning("Could not load Qwen system prompt for vLLM backend: %r", exc)
return ""
def inference_config() -> InferenceConfig:
config = InferenceConfig.from_mapping(local_model_settings(), system_prompt=qwen_system_prompt())
if DISABLE_VLLM_MODE and config.backend == "vllm":
config.backend = "transformers"
return config
def inference_status() -> dict[str, Any]:
config = inference_config()
return {
"backend": config.backend,
"vllm_model": config.vllm_model_name,
"vllm_base_url": config.vllm_base_url,
"fallback_to_transformers": config.vllm_fallback_to_transformers,
"health": LAST_INFERENCE_HEALTH,
}
def resolve_model_option(model_id: str) -> dict[str, Any]:
options = model_options_by_id()
if model_id == "qwen2.5-3b-math":
model_id = "base:qwen2.5-3b-math"
elif model_id in {"1K", "outputs/1K"}:
model_id = "peft:1K"
elif model_id.startswith("outputs/"):
model_id = f"peft:{model_id.removeprefix('outputs/')}"
if model_id in options:
return options[model_id]
path_matches = [
option
for option in options.values()
if option.get("relative_path") == model_id
or option.get("path") == model_id
or Path(str(option.get("path"))).name == model_id
]
if len(path_matches) == 1:
return path_matches[0]
return options[default_model_id()]
async def get_local_model(model_id: str) -> Any:
if model_id in LOADED_MODELS:
option = resolve_model_option(model_id)
set_model_state(
loading=False,
connected=True,
mode=option["kind"],
model=option["id"],
label=option["label"],
error=None,
)
return LOADED_MODELS[model_id]
async with MODEL_LOCK:
if model_id in LOADED_MODELS:
option = resolve_model_option(model_id)
set_model_state(
loading=False,
connected=True,
mode=option["kind"],
model=option["id"],
label=option["label"],
error=None,
)
return LOADED_MODELS[model_id]
option = resolve_model_option(model_id)
set_model_state(
loading=True,
connected=False,
mode=option["kind"],
model=option["id"],
label=option["label"],
error=None,
)
def load() -> Any:
from src.model.Qwen3BInstruct import BaseModel as QwenModel
model = QwenModel(peft_adapter_path=option["path"])
model._ensure_init()
return model
try:
loaded_model = await asyncio.to_thread(load)
LOADED_MODELS[model_id] = loaded_model
set_model_state(loading=False, connected=True, mode=option["kind"], error=None)
return loaded_model
except Exception as exc:
set_model_state(loading=False, connected=False, mode=option["kind"], error=str(exc))
raise
def request_messages(request: ChatRequest) -> list[dict[str, str]]:
return [{"role": message.role, "content": message.content} for message in request.messages]
async def transformers_backend(model_id: str) -> TransformersBackend:
option = resolve_model_option(model_id)
model = await get_local_model(option["id"])
return TransformersBackend(model=model, model_name=option["label"])
async def get_inference_backend(model_id: str) -> BaseInferenceBackend:
config = inference_config()
option = resolve_model_option(model_id)
cache_key = (
config.backend,
config.vllm_base_url if config.backend == "vllm" else option["id"],
config.vllm_model_name if config.backend == "vllm" else option["label"],
)
if cache_key in INFERENCE_BACKENDS:
return INFERENCE_BACKENDS[cache_key]
async with INFERENCE_LOCK:
if cache_key in INFERENCE_BACKENDS:
return INFERENCE_BACKENDS[cache_key]
if config.backend == "vllm":
backend = build_inference_backend(config)
set_model_state(
loading=False,
connected=LAST_INFERENCE_HEALTH.get("ok") is True,
mode="vllm",
model=config.vllm_model_name,
label=f"vLLM {config.vllm_model_name}",
error=None,
)
else:
backend = await transformers_backend(option["id"])
INFERENCE_BACKENDS[cache_key] = backend
return backend
async def fallback_transformers_backend(model_id: str, reason: Exception) -> TransformersBackend:
logger.warning("vllm_fallback_to_transformers reason=%r", reason)
option = resolve_model_option(model_id)
return await transformers_backend(option["id"])
async def check_inference_health() -> dict[str, Any]:
config = inference_config()
if config.backend == "transformers":
result = {
"ok": True,
"backend": "transformers",
"model": resolve_model_option(MODEL_STATE.get("model", default_model_id()))["label"],
}
LAST_INFERENCE_HEALTH.update(result, error=None)
return dict(LAST_INFERENCE_HEALTH)
backend = build_inference_backend(config)
try:
result = await asyncio.to_thread(backend.health_check)
LAST_INFERENCE_HEALTH.update(result, error=None)
set_model_state(
loading=False,
connected=True,
mode="vllm",
model=config.vllm_model_name,
label=f"vLLM {config.vllm_model_name}",
error=None,
)
return dict(LAST_INFERENCE_HEALTH)
except Exception as exc:
result = {
"ok": False,
"backend": "vllm",
"model": config.vllm_model_name,
"base_url": config.vllm_base_url,
"error": str(exc),
}
LAST_INFERENCE_HEALTH.update(result)
set_model_state(
loading=False,
connected=False,
mode="vllm",
model=config.vllm_model_name,
label=f"vLLM {config.vllm_model_name}",
error=str(exc),
)
if not config.vllm_fallback_to_transformers:
raise HTTPException(status_code=503, detail=result)
return dict(LAST_INFERENCE_HEALTH)
def mock_response(request: ChatRequest) -> str:
prompt = last_user_message(request.messages)
return f"""<|begin_of_thought|>
The local model is not connected, so this fallback response is being streamed to keep the UI testable.
For the prompt "{prompt}", a simple sample computation is:
\\[
1 + 1 = 2.
\\]
<|end_of_thought|>
<|begin_of_solution|>
Mock fallback answer: \\(\\boxed{{2}}\\).
<|end_of_solution|>"""
async def mock_model_stream(request: ChatRequest) -> AsyncIterator[str]:
text = mock_response(request)
chunk_size = 18
for index in range(0, len(text), chunk_size):
await asyncio.sleep(0.055)
yield text[index : index + chunk_size]
async def next_stream_chunk(streamer: Iterator[str]) -> str | None:
sentinel = object()
chunk = await asyncio.to_thread(next, streamer, sentinel)
return None if chunk is sentinel else chunk
def matching_protocol_tag(buffer: str) -> str | None:
for tag in PROTOCOL_TAGS:
if buffer.startswith(tag):
return tag
return None
def could_be_protocol_tag(buffer: str) -> bool:
return buffer.startswith("<") and any(tag.startswith(buffer) for tag in PROTOCOL_TAGS)
async def stream_model_once(
request: ChatRequest,
max_tokens: int,
assistant_prefix: str | None = None,
state: dict[str, Any] | None = None,
stop_sequences: tuple[str, ...] = (),
ignore_eos: bool = False,
temperature_override: float | None = None,
) -> AsyncIterator[str]:
if state is not None:
state["generated_tokens"] = 0
state["stop_reason"] = None
state["backend"] = None
state["fallback_reason"] = None
config = inference_config()
backend = await get_inference_backend(request.model)
messages = request_messages(request)
async def open_stream(active_backend: BaseInferenceBackend) -> Iterator[str]:
effective_max_tokens = max_tokens
effective_temperature = request.temperature if temperature_override is None else temperature_override
effective_top_p = 0.95
if active_backend.name == "vllm":
effective_max_tokens = min(max_tokens, config.vllm_max_tokens) if config.vllm_max_tokens > 0 else max_tokens
effective_temperature = config.vllm_temperature
effective_top_p = config.vllm_top_p
if state is not None:
state["active_max_tokens"] = effective_max_tokens
return await asyncio.to_thread(
active_backend.stream_generate,
messages,
effective_max_tokens,
effective_temperature,
effective_top_p,
list(stop_sequences),
assistant_prefix,
ignore_eos,
)
try:
streamer = await open_stream(backend)
except Exception as exc:
if backend.name != "vllm" or not config.vllm_fallback_to_transformers:
raise
if state is not None:
state["fallback_reason"] = str(exc)
backend = await fallback_transformers_backend(request.model, exc)
streamer = await open_stream(backend)
if state is not None:
state["backend"] = backend.name
try:
while True:
try:
chunk = await next_stream_chunk(streamer)
except Exception as exc:
if backend.name != "vllm" or not config.vllm_fallback_to_transformers:
raise
if getattr(streamer, "output_text", "") or int(
getattr(streamer, "generated_token_count", 0) or 0,
) > 0:
raise
if state is not None:
state["fallback_reason"] = str(exc)
backend = await fallback_transformers_backend(request.model, exc)
streamer = await open_stream(backend)
if state is not None:
state["backend"] = backend.name
state["generated_tokens"] = 0
state["stop_reason"] = None
continue
if state is not None:
state["generated_tokens"] = int(
getattr(streamer, "generated_token_count", state.get("generated_tokens", 0)),
)
state["stop_reason"] = getattr(streamer, "stop_reason", state.get("stop_reason"))
if chunk is None:
break
if chunk:
yield chunk
finally:
if state is not None:
tokens = int(getattr(streamer, "generated_token_count", state.get("generated_tokens", 0)))
state["generated_tokens"] = tokens
stop_reason = getattr(streamer, "stop_reason", state.get("stop_reason"))
if stop_reason == "stop":
stop_reason = "stop_sequence"
active_max_tokens = int(state.get("active_max_tokens") or max_tokens)
state["stop_reason"] = stop_reason or ("length" if tokens >= active_max_tokens else "eos")
def token_event(text: str) -> dict[str, Any]:
return {"type": "token", "text": text}
def usage_event(
*,
phase: str,
generated_tokens: int,
token_budget: int,
stop_reason: str | None = None,
done: bool = False,
) -> dict[str, Any]:
return {
"type": "usage",
"phase": phase,
"thinking_tokens": generated_tokens,
"thinking_token_budget": token_budget,
"thinking_stop_reason": stop_reason,
"thinking_done": done,
}
def reported_thinking_tokens(generated_tokens: int, token_budget: int) -> int:
return min(max(0, generated_tokens), token_budget)
def thought_tail(thought_parts: list[str], max_chars: int = 256) -> str:
if not thought_parts:
return ""
tail: list[str] = []
remaining = max_chars
for part in reversed(thought_parts):
if remaining <= 0:
break
segment = part[-remaining:]
tail.append(segment)
remaining -= len(segment)
return "".join(reversed(tail))
def thought_ends_sentence(text: str) -> bool:
stripped = text.rstrip()
if not stripped:
return False
if re.search(r"""[.!?。!?]["')\]}»”’]*$""", stripped):
return True
if stripped.endswith(("\\]", "$$")):
return True
if re.search(r"""\\end\{[a-zA-Z*]+\}$""", stripped):
return True
return False
async def stream_thought_phase(
source: AsyncIterator[str],
thought_parts: list[str],
generation_state: dict[str, Any],
token_budget: int,
*,
allow_sentence_completion_after_budget: bool = False,
) -> AsyncIterator[dict[str, Any]]:
buffer = ""
forwarding_thought = True
yield token_event(BEGIN_THOUGHT)
async for chunk in source:
buffer += chunk
output: list[str] = []
stop_after_output = False
while buffer:
tag = matching_protocol_tag(buffer)
if tag is not None:
buffer = buffer[len(tag):]
if tag in {END_THOUGHT, BEGIN_SOLUTION, END_SOLUTION}:
forwarding_thought = False
generation_state["stop_reason"] = "stop_sequence"
stop_after_output = True
buffer = ""
break
continue
if could_be_protocol_tag(buffer):
break
generated_tokens = int(generation_state.get("generated_tokens") or 0)
budget_reached = generated_tokens >= token_budget
if (
allow_sentence_completion_after_budget
and budget_reached
and thought_ends_sentence(thought_tail(thought_parts))
):
generation_state["stop_reason"] = "length"
stop_after_output = True
buffer = ""
break
char = buffer[0]
buffer = buffer[1:]
if forwarding_thought:
thought_parts.append(char)
output.append(char)
if (
allow_sentence_completion_after_budget
and budget_reached
and thought_ends_sentence(thought_tail(thought_parts))
):
generation_state["stop_reason"] = "length"
stop_after_output = True
buffer = ""
break
if output:
yield token_event("".join(output))
yield usage_event(
phase="thought",
generated_tokens=reported_thinking_tokens(
int(generation_state.get("generated_tokens") or 0),
token_budget,
),
token_budget=token_budget,
)
if stop_after_output:
return
async def stream_solution_phase(source: AsyncIterator[str]) -> AsyncIterator[str]:
buffer = ""
saw_end_solution = False
async for chunk in source:
buffer += chunk
output: list[str] = []
while buffer:
tag = matching_protocol_tag(buffer)
if tag is not None:
buffer = buffer[len(tag):]
if tag == END_SOLUTION:
output.append(tag)
saw_end_solution = True
buffer = ""
break
continue
if could_be_protocol_tag(buffer):
break
output.append(buffer[0])
buffer = buffer[1:]
if output:
yield "".join(output)
if saw_end_solution:
return
if buffer and not could_be_protocol_tag(buffer):
yield buffer
if not saw_end_solution:
yield f"\n{END_SOLUTION}"
async def real_model_events(request: ChatRequest) -> AsyncIterator[dict[str, Any]]:
if request.fast_mode:
async for event in fast_model_events(request):
yield event
return
thinking_limit = THINKING_TOKEN_BUDGETS[request.thinking_effort]
thought_parts: list[str] = []
thought_state: dict[str, Any] = {}
thought_generation_limit = thinking_limit + THOUGHT_SENTENCE_COMPLETION_TOKENS
first_pass = stream_model_once(
request,
max_tokens=thought_generation_limit,
state=thought_state,
stop_sequences=THOUGHT_STOP_SEQUENCES,
ignore_eos=True,
)
async for event in stream_thought_phase(
first_pass,
thought_parts,
thought_state,
thinking_limit,
allow_sentence_completion_after_budget=True,
):
yield event
thought_stop_reason = thought_state.get("stop_reason")
thought_tokens = reported_thinking_tokens(
int(thought_state.get("generated_tokens") or 0),
thinking_limit,
)
if thought_stop_reason == "length":
runtime_instruction = instruction_block(thinking_limit_reached_instruction())
thought_parts.append(runtime_instruction)
yield token_event(runtime_instruction)
thought = "".join(thought_parts).strip()
assistant_prefix = f"{BEGIN_THOUGHT}\n{thought}\n{END_THOUGHT}\n\n{BEGIN_SOLUTION}\n"
yield usage_event(
phase="thought",
generated_tokens=thought_tokens,
token_budget=thinking_limit,
stop_reason=thought_stop_reason,
done=True,
)
yield token_event(f"\n{END_THOUGHT}\n\n{BEGIN_SOLUTION}\n")
second_pass = stream_model_once(
request,
max_tokens=request.max_tokens,
assistant_prefix=assistant_prefix,
stop_sequences=(END_SOLUTION,),
)
async for chunk in stream_solution_phase(second_pass):
yield token_event(chunk)
async def fast_model_events(request: ChatRequest) -> AsyncIterator[dict[str, Any]]:
thinking_limit = THINKING_TOKEN_BUDGETS[request.thinking_effort]
generation_state: dict[str, Any] = {}
max_tokens = thinking_limit + request.max_tokens
source = stream_model_once(
request,
max_tokens=max_tokens,
state=generation_state,
stop_sequences=(END_SOLUTION,),
temperature_override=0.0,
)
async for chunk in source:
yield token_event(chunk)
yield usage_event(
phase="thought",
generated_tokens=reported_thinking_tokens(
int(generation_state.get("generated_tokens") or 0),
thinking_limit,
),
token_budget=thinking_limit,
stop_reason=generation_state.get("stop_reason"),
done=True,
)
async def mock_model_events(request: ChatRequest) -> AsyncIterator[dict[str, Any]]:
async for chunk in mock_model_stream(request):
yield token_event(chunk)
async def model_events(request: ChatRequest) -> AsyncIterator[dict[str, Any]]:
option = resolve_model_option(request.model)
request.model = option["id"]
config = inference_config()
if config.backend == "vllm":
set_model_state(
model=config.vllm_model_name,
label=f"vLLM {config.vllm_model_name}",
mode="vllm",
)
else:
set_model_state(model=option["id"], label=option["label"], mode=option["kind"])
try:
async for event in real_model_events(request):
yield event
except Exception as exc:
set_model_state(loading=False, connected=False, error=str(exc))
logger.exception("local_model_events_failed backend=%s model=%s", config.backend, request.model)
if config.backend == "vllm" and not config.vllm_fallback_to_transformers:
raise
async for event in mock_model_events(request):
yield event
async def model_stream(request: ChatRequest) -> AsyncIterator[str]:
async for event in model_events(request):
if event.get("type") == "token":
yield event.get("text", "")
async def model_complete(request: ChatRequest) -> str:
chunks: list[str] = []
async for chunk in model_stream(request):
chunks.append(chunk)
return "".join(chunks)
@app.post("/api/chat")
async def chat(request: ChatRequest) -> dict[str, str]:
return {"text": await model_complete(request)}
@app.post("/api/chat/stream")
async def chat_stream(request: ChatRequest) -> StreamingResponse:
async def events() -> AsyncIterator[str]:
text_batch: list[str] = []
text_batch_len = 0
last_flush = time.monotonic()
def flush_text_batch() -> str | None:
nonlocal text_batch, text_batch_len, last_flush
if not text_batch:
return None
payload = json.dumps({"text": "".join(text_batch)})
text_batch = []
text_batch_len = 0
last_flush = time.monotonic()
return f"event: token\ndata: {payload}\n\n"
try:
async for event in model_events(request):
event_type = event.get("type")
if event_type == "token":
text = event.get("text", "")
if not text:
continue
text_batch.append(text)
text_batch_len += len(text)
if (
text_batch_len >= STREAM_TEXT_BATCH_CHARS
or time.monotonic() - last_flush >= STREAM_TEXT_BATCH_SECONDS
):
flushed = flush_text_batch()
if flushed:
yield flushed
continue
flushed = flush_text_batch()
if flushed:
yield flushed
if event_type == "usage":
payload = json.dumps({key: value for key, value in event.items() if key != "type"})
yield f"event: usage\ndata: {payload}\n\n"
flushed = flush_text_batch()
if flushed:
yield flushed
yield "event: done\ndata: {}\n\n"
except asyncio.CancelledError:
raise
except Exception as exc:
payload = json.dumps({"message": str(exc)})
yield f"event: error\ndata: {payload}\n\n"
return StreamingResponse(
events(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"server:app",
host="127.0.0.1",
port=8000,
reload=False,
)