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

6788 lines
254 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""
OpenAI-compatible API server for oMLX.
This module provides a FastAPI server that exposes an OpenAI-compatible
API for LLM inference using MLX on Apple Silicon.
Features:
- Multi-model serving with LRU-based memory management
- Continuous batching for high throughput
- Paged KV cache with prefix sharing
- OpenAI-compatible chat/completions API
- Anthropic Messages API compatibility
- Streaming responses
- MCP (Model Context Protocol) tool integration
- Tool calling (Qwen/Llama formats)
- Structured output (JSON schema validation)
Usage:
# Multi-model serving
omlx serve --model-dir /path/to/models --max-model-memory 32GB
# With pinned models
omlx serve --model-dir /path/to/models --max-model-memory 48GB --pin llama-3b,qwen-7b
# With MCP tools
omlx serve --model-dir /path/to/models --max-model-memory 32GB --mcp-config mcp.json
The server provides:
- POST /v1/completions - Text completions
- POST /v1/chat/completions - Chat completions
- POST /v1/messages - Anthropic Messages API
- POST /v1/responses - OpenAI Responses API (Codex compatibility)
- GET /v1/models - List available models (with load status)
- GET /health - Health check
- GET /v1/mcp/tools - List MCP tools
- GET /v1/mcp/servers - MCP server status
- POST /v1/mcp/execute - Execute MCP tool
"""
import argparse
import asyncio
import inspect
import json
import logging
import os
import time
import uuid
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Optional, Union
from fastapi import Depends, FastAPI, HTTPException, Response
from fastapi import Request as FastAPIRequest
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from omlx._version import __version__
from .api.anthropic_models import (
MessagesRequest as AnthropicMessagesRequest,
)
from .api.anthropic_models import (
TokenCountRequest,
TokenCountResponse,
)
from .api.anthropic_utils import (
convert_anthropic_to_internal,
convert_anthropic_to_internal_harmony,
convert_anthropic_tools_to_internal,
convert_internal_to_anthropic_response,
create_content_block_start_event,
create_content_block_stop_event,
create_error_event,
create_input_json_delta_event,
create_message_delta_event,
create_message_start_event,
create_message_stop_event,
create_text_delta_event,
create_thinking_delta_event,
map_finish_reason_to_stop_reason,
request_has_cache_control,
)
from .api.embedding_models import (
EmbeddingData,
EmbeddingRequest,
EmbeddingResponse,
EmbeddingUsage,
)
from .api.embedding_utils import (
encode_embedding_base64,
normalize_embedding_items,
normalize_input,
truncate_embedding,
)
from .api.markitdown import (
MARKITDOWN_MODEL_ID,
MarkItDownRequestError,
convert_messages_to_markdown_async,
is_markitdown_model,
markitdown_model_visible,
preprocess_markitdown_file_parts_async,
request_has_file_parts,
stream_messages_to_markdown_async,
)
# Import from new modular API
from .api.openai_models import (
AssistantMessage,
ChatCompletionChoice,
ChatCompletionChunk,
ChatCompletionChunkChoice,
ChatCompletionChunkDelta,
ChatCompletionRequest,
ChatCompletionResponse,
CompletionChoice,
CompletionRequest,
CompletionResponse,
ModelInfo,
ModelsResponse,
PromptTokensDetails,
Usage,
)
from .api.parser_tool_calls import (
convert_parser_tool_calls as _convert_parser_tool_calls,
)
from .api.rerank_models import (
RerankRequest,
RerankResponse,
RerankResult,
RerankUsage,
)
from .api.responses_models import (
OutputItem,
ResponseObject,
ResponsesRequest,
)
from .api.responses_utils import (
ResponseStateCorruptError,
ResponseStateNotFoundError,
ResponseStore,
build_function_call_output_item,
build_message_output_item,
build_reasoning_output_item,
build_response_store_record,
build_response_usage,
convert_responses_input_to_messages,
convert_responses_tools,
format_sse_event,
normalize_response_output_to_messages,
)
from .api.thinking import ThinkingParser, extract_thinking, prompt_opens_thinking
from .api.tool_calling import (
ToolCallStreamFilter,
build_json_system_prompt,
convert_tools_for_template,
enrich_tool_params_for_gemma4,
extract_tool_calls_with_thinking,
parse_json_output,
restore_gemma4_param_names,
sanitize_tool_call_markup,
)
from .api.utils import (
clean_special_tokens,
detect_and_strip_partial,
extract_multimodal_content,
extract_text_content,
has_nonleading_system_message,
prepare_system_messages_for_template,
uses_native_reasoning_content,
)
from .engine import BaseEngine, VLMBatchedEngine
from .engine.embedding import EmbeddingEngine
from .engine.reranker import RerankerEngine
from .engine_pool import EnginePool
from .exceptions import (
EnginePoolError,
InsufficientMemoryError,
InvalidRequestError,
ModelBusyError,
ModelLoadingError,
ModelNotFoundError,
ModelTooLargeError,
ModelUnavailableError,
PrefillMemoryExceededError,
SchedulerQueueFullError,
)
from .server_metrics import get_server_metrics, reset_server_metrics
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Security bearer for API key authentication
security = HTTPBearer(auto_error=False)
# =============================================================================
# Server State
# =============================================================================
class EngineType(Enum):
"""Type of engine to retrieve."""
LLM = "llm"
EMBEDDING = "embedding"
RERANKER = "reranker"
@dataclass
class SamplingDefaults:
"""Default sampling parameters."""
# Fallback context length used by ``get_max_context_window`` only
# when neither a per-model override nor a model-config-discovered
# native context length is available. Setting this does NOT cap
# models that declare their own context — use
# ``max_context_window_policy`` for the operator-policy cap.
max_context_window: int = 32768
# Optional operator policy cap. When set, models whose native
# context length is discovered get ``min(native, policy)``. Per-model
# overrides and the fallback default above are not affected — those
# represent explicit choices that the policy cannot override
# without surprising migration semantics for existing
# ``settings.json`` files.
max_context_window_policy: int | None = None
max_tokens: int = 32768
temperature: float = 1.0
top_p: float = 0.95
top_k: int = 0
repetition_penalty: float = 1.0
force_sampling: bool = False
@dataclass
class ServerState:
"""
Encapsulated server state.
This class holds all global state for the server, making it easier
to manage and test.
"""
engine_pool: Optional[EnginePool] = None
default_model: Optional[str] = None
mcp_manager: Optional[object] = None
mcp_executor: Optional[object] = None
sampling: SamplingDefaults = field(default_factory=SamplingDefaults)
api_key: Optional[str] = None
settings_manager: Optional[object] = None # ModelSettingsManager
global_settings: Optional[object] = None # GlobalSettings
hf_downloader: Optional[object] = None # HFDownloader
ms_downloader: Optional[object] = None # MSDownloader
process_memory_enforcer: Optional[object] = None # ProcessMemoryEnforcer
responses_store: ResponseStore = field(default_factory=ResponseStore)
oq_manager: Optional[object] = None # OQManager
hf_uploader: Optional[object] = None # HFUploader
# False while the startup pinned-model preload is still running.
# /health returns 503 with status "loading" until it flips to True so
# port watchdogs see liveness instead of a closed port (#2184).
pinned_preload_complete: bool = True
# Global server state instance
_server_state: ServerState = ServerState()
def get_server_state() -> ServerState:
"""Get the global server state."""
return _server_state
def get_engine_pool() -> EnginePool:
"""Get the engine pool, raising error if not initialized."""
if _server_state.engine_pool is None:
raise HTTPException(status_code=503, detail="Server not initialized")
return _server_state.engine_pool
def get_mcp_manager():
"""Get the MCP manager instance (may be None)."""
return _server_state.mcp_manager
async def verify_api_key(
request: FastAPIRequest,
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> bool:
"""Verify API key if configured.
Checks the provided Bearer token against the main API key and all sub keys.
Also accepts the x-api-key header as a fallback (Anthropic SDK compatibility).
"""
from .admin.auth import fingerprint_key, verify_any_api_key
# No auth required if no API key is configured
if _server_state.api_key is None:
return True
# Skip verification if enabled
if (
_server_state.global_settings is not None
and _server_state.global_settings.auth.skip_api_key_verification
):
return True
# Extract API key from Bearer token or x-api-key header
if credentials is not None:
api_key_value = credentials.credentials
else:
# Fallback: check x-api-key header (Anthropic SDK compatibility)
api_key_value = request.headers.get("x-api-key")
if api_key_value is None:
raise HTTPException(status_code=401, detail="API key required")
# Check main key and sub keys
sub_keys = (
_server_state.global_settings.auth.sub_keys
if _server_state.global_settings is not None
else []
)
if not verify_any_api_key(api_key_value, _server_state.api_key, sub_keys):
logger.warning("Rejected API key (fp=%s)", fingerprint_key(api_key_value))
raise HTTPException(status_code=401, detail="Invalid API key")
return True
def _reset_boundary_snapshots_for_server() -> None:
"""Reset ephemeral boundary snapshots at server lifecycle boundaries."""
engine_pool = _server_state.engine_pool
if engine_pool is None:
return
scheduler_config = getattr(engine_pool, "_scheduler_config", None)
cache_dir = getattr(scheduler_config, "paged_ssd_cache_dir", None)
if not cache_dir:
return
try:
from .cache.boundary_snapshot_store import reset_boundary_snapshot_root
reset_boundary_snapshot_root(Path(cache_dir))
except Exception as exc: # pragma: no cover - best-effort cleanup
logger.warning("Failed to reset boundary snapshot directory: %s", exc)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""FastAPI lifespan for startup/shutdown events."""
# Startup: Auto-populate server aliases for the admin dashboard
# so users get sensible hostname/IP options for API URL hints
# without manual configuration. Only runs when the persisted list
# is empty so user-curated aliases are never overwritten.
if (
_server_state.global_settings is not None
and not _server_state.global_settings.server.server_aliases
):
try:
from .utils.network import detect_server_aliases
detected = detect_server_aliases(
host=_server_state.global_settings.server.host
)
if detected:
_server_state.global_settings.server.server_aliases = detected
try:
_server_state.global_settings.save()
except Exception as save_exc: # pragma: no cover - filesystem race
logger.warning(
"Auto-detected server aliases but could not persist: %s",
save_exc,
)
logger.info("Auto-detected server aliases: %s", detected)
except Exception as exc: # pragma: no cover - never block startup
logger.warning("Server alias auto-detection failed: %s", exc)
_reset_boundary_snapshots_for_server()
# Start process memory enforcer if configured
if (
_server_state.global_settings is not None
and _server_state.engine_pool is not None
):
from .process_memory_enforcer import ProcessMemoryEnforcer
memory_settings = _server_state.global_settings.memory
enforcer = ProcessMemoryEnforcer(
engine_pool=_server_state.engine_pool,
memory_guard_tier=memory_settings.memory_guard_tier,
memory_guard_custom_ceiling_gb=memory_settings.memory_guard_custom_ceiling_gb,
settings_manager=_server_state.settings_manager,
prefill_memory_guard=memory_settings.prefill_memory_guard,
global_settings=_server_state.global_settings,
soft_threshold=memory_settings.soft_threshold,
hard_threshold=memory_settings.hard_threshold,
prefill_safe_zone_ratio=memory_settings.prefill_safe_zone_ratio,
prefill_min_chunk_tokens=memory_settings.prefill_min_chunk_tokens,
)
_server_state.process_memory_enforcer = enforcer
_server_state.engine_pool._process_memory_enforcer = enforcer
# Engine pool consults the enforcer for the pre-load ceiling.
_server_state.engine_pool._get_final_ceiling = enforcer.get_final_ceiling
enforcer.start()
# Startup: Preload pinned models in the background so uvicorn binds the
# HTTP port immediately after this lifespan yields. A large pinned
# preload (hundreds of GB) otherwise keeps the port closed for minutes,
# and anything watchdogging the port hard-kills the process mid-load —
# the worst moment for the kernel wired-memory stranding in #2184.
# /health answers 503 with status "loading" until the preload finishes.
# Runs after the enforcer wiring above so the preload sees the final
# memory ceiling.
preload_task = None
if _server_state.engine_pool is not None:
_server_state.pinned_preload_complete = False
async def _preload_pinned() -> None:
try:
await _server_state.engine_pool.preload_pinned_models()
finally:
_server_state.pinned_preload_complete = True
preload_task = asyncio.create_task(_preload_pinned())
# Start TTL-only checker if process memory enforcer is not running
# (enforcer already includes TTL checks in its polling loop)
ttl_task = None
if (
_server_state.process_memory_enforcer is None
and _server_state.engine_pool is not None
):
async def _ttl_check_loop():
while True:
try:
if _server_state.settings_manager is not None:
await _server_state.engine_pool.check_ttl_expirations(
_server_state.settings_manager,
global_idle_timeout_seconds=(
_server_state.global_settings.idle_timeout.idle_timeout_seconds
if _server_state.global_settings
else None
),
)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"TTL check error: {e}")
await asyncio.sleep(1.0)
ttl_task = asyncio.create_task(_ttl_check_loop())
# Initialize MCP if config provided
# Priority: env var > settings.json
mcp_config = os.environ.get("OMLX_MCP_CONFIG")
if not mcp_config and _server_state.global_settings:
mcp_config = _server_state.global_settings.mcp.config_path
if mcp_config:
await init_mcp(mcp_config)
yield
# Shutdown: Save all-time stats, stop TTL task, process memory enforcer, etc.
if preload_task is not None and not preload_task.done():
# SIGTERM arrived while pinned models were still loading. Cancel the
# await; engine_pool.shutdown() below unloads whatever finished.
preload_task.cancel()
with suppress(asyncio.CancelledError):
await preload_task
get_server_metrics().save_alltime()
if ttl_task is not None:
ttl_task.cancel()
try:
await ttl_task
except asyncio.CancelledError:
pass
if _server_state.process_memory_enforcer is not None:
await _server_state.process_memory_enforcer.stop()
if _server_state.engine_pool is not None:
_server_state.engine_pool._process_memory_enforcer = None
logger.info("Process memory enforcer stopped")
if _server_state.hf_downloader is not None:
await _server_state.hf_downloader.shutdown()
logger.info("HF Downloader stopped")
if _server_state.ms_downloader is not None:
await _server_state.ms_downloader.shutdown()
logger.info("MS Downloader stopped")
if _server_state.mcp_manager is not None:
await _server_state.mcp_manager.stop()
logger.info("MCP manager stopped")
if _server_state.engine_pool is not None:
await _server_state.engine_pool.shutdown()
_reset_boundary_snapshots_for_server()
logger.info("Engine pool shutdown")
app = FastAPI(
title="oMLX API",
description="LLM inference, optimized for your Mac",
version=__version__,
lifespan=lifespan,
)
# Include MCP routes
from .api.mcp_routes import router as mcp_router
from .api.mcp_routes import set_mcp_manager_getter
set_mcp_manager_getter(get_mcp_manager)
app.include_router(mcp_router, dependencies=[Depends(verify_api_key)])
# Include audio routes only when mlx-audio is installed.
# audio_routes.py itself only imports fastapi/stdlib at module level, so it
# would always import successfully — we need an explicit mlx-audio check.
try:
import mlx_audio as _ # noqa: F401
from .api.audio_routes import router as audio_router
app.include_router(audio_router, dependencies=[Depends(verify_api_key)])
del _
except ImportError:
pass
# Include admin routes
from .admin.auth import _RedirectToLogin
from .admin.routes import router as admin_router
from .admin.routes import set_admin_getters
set_admin_getters(
get_server_state,
get_engine_pool,
lambda: _server_state.settings_manager,
lambda: _server_state.global_settings,
)
app.include_router(admin_router)
@app.exception_handler(_RedirectToLogin)
async def redirect_to_login_handler(request, exc):
"""Redirect unauthenticated browser requests to the admin login page."""
return RedirectResponse(url="/admin", status_code=302)
def _status_to_error_type(status_code: int) -> str:
"""Map HTTP status code to OpenAI error type string."""
if status_code == 401:
return "authentication_error"
if status_code == 404:
return "not_found_error"
if status_code == 413:
# Body-size rejections are still request-shape errors.
return "invalid_request_error"
if status_code == 429:
return "rate_limit_error"
if status_code >= 500:
return "server_error"
return "invalid_request_error"
def _is_api_route(request: FastAPIRequest) -> bool:
"""Check if request targets an OpenAI-compatible API route.
Path-prefix only. This assumes the FastAPI app is mounted at root
(the oMLX deployment shape) and that route paths are case-sensitive
— both true today. If a future deployment mounts this app under a
prefix (``app.mount("/api", ...)``), ``request.url.path`` returns
the full mounted path and every ``/v1/...`` route would be
classified as non-API. Switch to ``request.scope.get("route")``
matching at that point.
"""
return request.url.path.startswith("/v1/")
def _openai_error_body(message, status_code: int, param=None, code=None) -> dict:
"""Build an OpenAI-compatible error response body."""
return {
"error": {
"message": message,
"type": _status_to_error_type(status_code),
"param": param,
"code": code,
}
}
@app.exception_handler(HTTPException)
async def http_exception_handler(request: FastAPIRequest, exc: HTTPException):
"""Log all HTTP errors (4xx/5xx) before returning the response."""
# Admin session expiry from dashboard polling — not worth logging.
# But keep /admin/api/login 401s visible (possible brute force attempts).
_is_admin_session_expiry = (
request.url.path.startswith("/admin/")
and request.url.path != "/admin/api/login"
and exc.status_code == 401
)
if not _is_admin_session_expiry:
logger.warning(
"%s %s%d: %s",
request.method,
request.url.path,
exc.status_code,
exc.detail,
)
if _is_api_route(request):
content = _openai_error_body(exc.detail, exc.status_code)
else:
content = {"detail": exc.detail}
return JSONResponse(status_code=exc.status_code, content=content)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: FastAPIRequest, exc: RequestValidationError
):
"""Log request validation errors (422) before returning the response."""
logger.warning(
"%s %s → 422: %s",
request.method,
request.url.path,
exc.errors(),
)
if _is_api_route(request):
errors = exc.errors()
parts = []
for err in errors:
loc = " -> ".join(str(x) for x in err.get("loc", []))
msg = err.get("msg", "")
parts.append(f"{loc}: {msg}" if loc else msg)
detail_str = "; ".join(parts)
param = errors[0].get("loc", [None])[-1] if errors else None
content = _openai_error_body(detail_str, 422, param=param)
else:
content = {"detail": exc.errors()}
return JSONResponse(status_code=422, content=content)
@app.exception_handler(InvalidRequestError)
async def invalid_request_error_handler(
request: FastAPIRequest, exc: InvalidRequestError
):
"""Map internal request validation failures to OpenAI-compatible 400s."""
logger.warning(
"%s %s -> 400: %s",
request.method,
request.url.path,
exc,
)
if _is_api_route(request):
content = _openai_error_body(str(exc), 400, param=exc.field)
else:
content = {"detail": str(exc)}
return JSONResponse(status_code=400, content=content)
@app.exception_handler(SchedulerQueueFullError)
async def scheduler_queue_full_handler(
request: FastAPIRequest, exc: SchedulerQueueFullError
):
"""Map scheduler queue cap exhaustion to HTTP 503 + Retry-After."""
logger.warning(
"%s %s → 503: %s",
request.method,
request.url.path,
exc,
)
detail = (
f"Scheduler waiting queue full ({exc.current_depth}/{exc.max_depth}). "
f"Try again shortly."
)
if _is_api_route(request):
content = _openai_error_body(detail, 503)
else:
content = {"detail": detail}
return JSONResponse(
status_code=503,
content=content,
headers={"Retry-After": "1"},
)
def _prefill_memory_error_detail(exc: PrefillMemoryExceededError) -> str:
return (
"oMLX prefill memory guard rejected this prompt: "
f"{str(exc)} "
"To continue, set Memory Guard to aggressive, raise the custom "
"memory guard ceiling, free system memory, or compact/reduce context."
)
def _prefill_memory_openai_error_body(
exc: PrefillMemoryExceededError,
*,
status_code: int = 400,
) -> dict:
content = _openai_error_body(
_prefill_memory_error_detail(exc),
status_code,
code="prefill_memory_exceeded",
)
content["type"] = "error"
content["error"]["omlx_code"] = "prefill_memory_exceeded"
if exc.estimated_bytes is not None:
content["error"]["estimated_bytes"] = exc.estimated_bytes
if exc.limit_bytes is not None:
content["error"]["limit_bytes"] = exc.limit_bytes
return content
@app.exception_handler(PrefillMemoryExceededError)
async def prefill_memory_exceeded_handler(
request: FastAPIRequest, exc: PrefillMemoryExceededError
):
"""Map prefill peak overshoot to HTTP 400 with a clear JSON body.
The synchronous prefill memory guard in ``Scheduler.add_request`` raises
this when the estimated KV+SDPA peak for a request would push memory
past the user-configured memory guard ceiling. The caller's prompt
fits in the model's context window but is too large for the host's
headroom.
This is an actionable request rejection, not an HTTP body-size
rejection. HTTP 400 also prevents Anthropic clients from collapsing
this oMLX memory-guard failure into Anthropic's generic
"Request too large (max 32MB)" body-size error.
"""
detail = _prefill_memory_error_detail(exc)
status_code = 400
logger.warning(
"%s %s%d: %s",
request.method,
request.url.path,
status_code,
detail,
)
if _is_api_route(request):
# code="prefill_memory_exceeded" lets OpenAI-SDK clients branch
# on the failure mode. Without it, "context window too small"
# and "host has no memory headroom" both surface as
# invalid_request_error with code=None and clients can only
# tell the user "shorten your prompt" — which is wrong when
# the actual fix is to loosen the memory guard.
# Surface the structured fields so clients can branch on
# numeric values instead of regex-matching the human message.
# OpenAI clients ignore unknown error fields so this is a
# forward-compatible extension.
content = _prefill_memory_openai_error_body(exc, status_code=status_code)
else:
content = {
"detail": detail,
"omlx_code": "prefill_memory_exceeded",
}
if exc.estimated_bytes is not None:
content["estimated_bytes"] = exc.estimated_bytes
if exc.limit_bytes is not None:
content["limit_bytes"] = exc.limit_bytes
return JSONResponse(status_code=status_code, content=content)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: FastAPIRequest, exc: Exception):
"""Log unhandled exceptions as 500 errors."""
logger.error(
"%s %s → 500 (unhandled): %s",
request.method,
request.url.path,
exc,
)
if _is_api_route(request):
content = _openai_error_body("Internal server error", 500)
else:
content = {"detail": "Internal server error"}
return JSONResponse(status_code=500, content=content)
class DebugRequestLoggingMiddleware:
"""Pure ASGI middleware for trace-level request body logging.
Uses raw ASGI protocol instead of BaseHTTPMiddleware to avoid
wrapping StreamingResponse in an intermediate pipe layer, which
causes connection corruption on HTTP keep-alive connections.
"""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if (
scope["type"] != "http"
or not logger.isEnabledFor(5)
or scope.get("method") != "POST"
):
await self.app(scope, receive, send)
return
# Read and cache the request body for logging
body_parts = []
while True:
message = await receive()
body_parts.append(message)
if not message.get("more_body", False):
break
body = b"".join(part.get("body", b"") for part in body_parts)
logger.log(
5,
"Incoming %s %s — body: %s",
scope["method"],
scope["path"],
body.decode("utf-8", errors="replace"),
)
# Replay cached body for inner app, then forward real receive
body_sent = False
async def cached_receive():
nonlocal body_sent
if not body_sent:
body_sent = True
return {"type": "http.request", "body": body, "more_body": False}
return await receive()
await self.app(scope, cached_receive, send)
app.add_middleware(DebugRequestLoggingMiddleware)
# =============================================================================
# Engine Getters
# =============================================================================
def _wake_process_memory_enforcer(*, active: bool = False) -> None:
enforcer = _server_state.process_memory_enforcer
wake = getattr(enforcer, "wake", None) if enforcer is not None else None
if callable(wake):
wake(active=active)
async def get_engine(
model_id: str | None = None,
engine_type: EngineType = EngineType.LLM,
_lease: bool = False,
_leased_out: list | None = None,
) -> Union[BaseEngine, EmbeddingEngine, RerankerEngine]:
"""
Get engine for the specified model and type.
This is the unified engine getter that handles LLM, embedding, and reranker models.
Args:
model_id: Model ID to get engine for, or None for default (LLM only)
engine_type: Type of engine to retrieve (LLM, EMBEDDING, or RERANKER)
_lease: When True, take an atomic in-use lease on the engine that the
pool actually loaded (eviction-proof until released). The caller
MUST release exactly one lease per successful leased call.
_leased_out: When _lease is True, the EXACT pool model_id that was
leased is appended to this list. Release using that id (not the
request model) so the lease/release ids always match even when the
pool falls back to the default model.
Returns:
The loaded engine of the appropriate type
Raises:
HTTPException: If model not found, wrong type, or memory error
"""
pool = get_engine_pool()
# Default model only applies to LLM
if model_id is None:
if engine_type != EngineType.LLM:
raise HTTPException(
status_code=400,
detail=f"Model ID is required for {engine_type.value} engines",
)
model_id = _server_state.default_model
if model_id is None:
raise HTTPException(
status_code=400, detail="No model specified and no default model set"
)
# Resolve alias/profile request to the physical model. Exposed profiles
# may carry engine-construction settings (MTP/DFlash/etc.); pass those
# transient settings to the pool so the loaded variant can switch without
# mutating the base model's persisted settings.
requested_model_id = model_id
runtime_settings = None
sm = _server_state.settings_manager
if (
engine_type == EngineType.LLM
and sm is not None
and hasattr(sm, "get_exposed_profile_runtime_settings_for_request")
):
runtime = sm.get_exposed_profile_runtime_settings_for_request(
requested_model_id
)
if runtime is not None:
model_id, runtime_settings = runtime
else:
model_id = pool.resolve_model_id(model_id, sm)
else:
model_id = pool.resolve_model_id(model_id, sm)
_wake_process_memory_enforcer(active=True)
# Only thread optional kwargs through when they are needed, so the common
# path keeps the original pool.get_engine(model_id) call shape.
_lease_kwargs = {"_lease": True} if _lease else {}
if runtime_settings is not None:
_lease_kwargs["runtime_settings"] = runtime_settings
try:
engine = await pool.get_engine(model_id, **_lease_kwargs)
if _lease and _leased_out is not None:
_leased_out.append(model_id)
except ModelNotFoundError as e:
# Fallback to default model if enabled (LLM only)
if (
engine_type == EngineType.LLM
and _server_state.global_settings
and _server_state.global_settings.model.model_fallback
and _server_state.default_model
):
logger.info(
f"Model '{model_id}' not found, falling back to "
f"default model '{_server_state.default_model}'"
)
try:
_wake_process_memory_enforcer(active=True)
_fallback_kwargs = {"_lease": True} if _lease else {}
fb_engine = await pool.get_engine(
_server_state.default_model, **_fallback_kwargs
)
if _lease and _leased_out is not None:
_leased_out.append(_server_state.default_model)
return fb_engine
except Exception:
pass # Fall through to original 404
# Show aliases instead of directory names for user-friendly display
available = e.available_models
sm = _server_state.settings_manager
if sm:
display = []
for mid in available:
ms = sm.get_settings(mid)
display.append(ms.model_alias if ms.model_alias else mid)
available = display
detail = (
f"Model '{model_id}' not found. "
f"Available models: {', '.join(available) if available else '(none)'}"
)
raise HTTPException(status_code=404, detail=detail)
except ModelTooLargeError as e:
raise HTTPException(status_code=507, detail=str(e))
except InsufficientMemoryError as e:
raise HTTPException(status_code=507, detail=str(e))
except ModelUnavailableError as e:
raise HTTPException(status_code=409, detail=str(e)) from e
except ModelLoadingError as e:
raise HTTPException(status_code=409, detail=str(e))
except ModelBusyError as e:
raise HTTPException(status_code=409, detail=str(e))
except EnginePoolError as e:
raise HTTPException(status_code=500, detail=str(e))
# Validate engine type. If a lease was taken above but validation fails,
# release it before raising so a rejected request never leaks an in_use
# count (which would pin the engine non-evictable forever).
try:
if engine_type == EngineType.EMBEDDING:
if not isinstance(engine, EmbeddingEngine):
raise HTTPException(
status_code=400,
detail=f"Model '{model_id}' is not an embedding model. "
f"Use /v1/chat/completions for LLM models.",
)
elif engine_type == EngineType.RERANKER:
if not isinstance(engine, RerankerEngine):
raise HTTPException(
status_code=400,
detail=f"Model '{model_id}' is not a reranker model. "
f"Use a SequenceClassification model for reranking.",
)
elif engine_type == EngineType.LLM:
# #507: non-LLM engines (STT/TTS/STS/Embedding/Reranker) previously
# fell through and crashed on `engine.model_type` with an unhandled
# 500. Reject with a clear 400 pointing the caller at the right
# endpoint.
if not isinstance(engine, BaseEngine):
_endpoint_hint = _suggest_endpoint_for_engine(engine)
raise HTTPException(
status_code=400,
detail=(
f"Model '{model_id}' is not an LLM / chat model. "
f"{_endpoint_hint}"
),
)
except BaseException:
if _lease and _leased_out:
await pool.release_engine(_leased_out.pop())
raise
return engine
def _suggest_endpoint_for_engine(engine: object) -> str:
"""Return a one-line hint pointing at the correct endpoint for a non-LLM engine."""
# Import audio engine classes lazily so that oMLX without the [audio]
# extra still imports this module.
try:
from omlx.engine.stt import STTEngine as stt_engine_cls
except Exception: # pragma: no cover - defensive
stt_engine_cls = None
try:
from omlx.engine.tts import TTSEngine as tts_engine_cls
except Exception: # pragma: no cover - defensive
tts_engine_cls = None
try:
from omlx.engine.sts import STSEngine as sts_engine_cls
except Exception: # pragma: no cover - defensive
sts_engine_cls = None
if stt_engine_cls is not None and isinstance(engine, stt_engine_cls):
return "Use /v1/audio/transcriptions for speech-to-text models."
if tts_engine_cls is not None and isinstance(engine, tts_engine_cls):
return "Use /v1/audio/speech for text-to-speech models."
if sts_engine_cls is not None and isinstance(engine, sts_engine_cls):
return "Use /v1/audio/process for speech-to-speech / audio processing models."
if isinstance(engine, EmbeddingEngine):
return "Use /v1/embeddings for embedding models."
if isinstance(engine, RerankerEngine):
return "Use /v1/rerank for reranker models."
return "Use the model's dedicated endpoint (see /v1/models)."
@dataclass
class _LLMEngineLease:
"""Release handle for an LLM engine lease taken from EnginePool."""
model_id: str | None = None
released: bool = False
async def release(self) -> None:
if self.released:
return
self.released = True
if self.model_id is not None:
await get_engine_pool().release_engine(self.model_id)
def abort_requested(self) -> bool:
if self.model_id is None or self.released:
return False
pool = _server_state.engine_pool
if pool is None:
return False
is_abort_requested = getattr(pool, "is_abort_requested", None)
if not callable(is_abort_requested):
return False
return bool(is_abort_requested(self.model_id))
async def _raise_if_llm_lease_abort_requested(lease: _LLMEngineLease) -> None:
if lease.abort_requested():
raise HTTPException(
status_code=507,
detail=(
"Request aborted before scheduling because process memory "
"pressure requested this model to unload. Retry with a shorter "
"context or after memory pressure drops."
),
)
async def _release_after_stream(
generator: AsyncIterator[str],
lease: _LLMEngineLease,
) -> AsyncIterator[str]:
try:
await _raise_if_llm_lease_abort_requested(lease)
async for chunk in generator:
yield chunk
finally:
await lease.release()
async def get_engine_for_model(
model: str | None = None,
*,
lease: _LLMEngineLease | None = None,
) -> BaseEngine:
"""
Get LLM engine for the specified model (or default).
This is a convenience wrapper around get_engine() for LLM models.
Args:
model: Model ID to get engine for, or None for default
Returns:
The loaded engine
Raises:
HTTPException: If model not found or memory error
"""
if lease is None:
return await get_engine(model, EngineType.LLM)
leased: list[str] = []
engine = await get_engine(
model,
EngineType.LLM,
_lease=True,
_leased_out=leased,
)
if leased:
lease.model_id = leased[0]
return engine
async def get_embedding_engine(model: str) -> EmbeddingEngine:
"""
Get embedding engine for the specified model.
This is a convenience wrapper around get_engine() for embedding models.
Args:
model: Model ID to get engine for
Returns:
The loaded embedding engine
Raises:
HTTPException: If model not found, is not an embedding model, or memory error
"""
return await get_engine(model, EngineType.EMBEDDING)
async def get_reranker_engine(model: str) -> RerankerEngine:
"""
Get reranker engine for the specified model.
This is a convenience wrapper around get_engine() for reranker models.
Args:
model: Model ID to get engine for
Returns:
The loaded reranker engine
Raises:
HTTPException: If model not found, is not a reranker model, or memory error
"""
return await get_engine(model, EngineType.RERANKER)
@asynccontextmanager
async def acquire_embedding_engine(model: str):
"""Acquire an embedding engine with an atomic, eviction-proof in-use lease.
Resolves + loads + validates exactly like get_embedding_engine, but holds
the engine non-evictable for the duration of the request and releases the
lease on the EXACT pool model_id the pool loaded (handles default-model
fallback) in finally.
"""
leased: list = []
engine = await get_engine(
model, EngineType.EMBEDDING, _lease=True, _leased_out=leased
)
try:
yield engine
finally:
if leased:
await get_engine_pool().release_engine(leased[0])
@asynccontextmanager
async def acquire_reranker_engine(model: str):
"""Acquire a reranker engine with an atomic, eviction-proof in-use lease.
See acquire_embedding_engine for the lease/release contract.
"""
leased: list = []
engine = await get_engine(
model, EngineType.RERANKER, _lease=True, _leased_out=leased
)
try:
yield engine
finally:
if leased:
await get_engine_pool().release_engine(leased[0])
def get_sampling_params(
req_temperature: float | None,
req_top_p: float | None,
model_id: str | None = None,
req_top_k: int | None = None,
req_repetition_penalty: float | None = None,
req_min_p: float | None = None,
req_presence_penalty: float | None = None,
req_frequency_penalty: float | None = None,
req_max_tokens: int | None = None,
ocr_defaults: dict | None = None,
req_xtc_probability: float | None = None,
req_xtc_threshold: float | None = None,
) -> tuple[float, float, int, float, float, float, float, int, float, float]:
"""
Get effective sampling parameters with per-model settings support.
Priority:
- If force_sampling is True (global or model level): force sampling knobs
that affect token selection.
- max_tokens is an output length cap, so it always uses
request > model settings > ocr_defaults > global defaults.
- Otherwise: request > model settings > ocr_defaults > global defaults.
Returns:
tuple of (temperature, top_p, top_k, repetition_penalty, min_p, presence_penalty, frequency_penalty, max_tokens, xtc_probability, xtc_threshold)
"""
global_sampling = _server_state.sampling
# Get per-model (or exposed-profile) settings if available
model_settings = get_model_settings_for_request(model_id)
# Resolve alias so physical-model defaults can still be found by real model ID
model_id = resolve_model_id(model_id)
# Resolve OCR defaults if not provided by caller
if ocr_defaults is None and model_id:
ocr_defaults = _get_ocr_defaults(model_id)
# Check force at any level
force = global_sampling.force_sampling or (
model_settings and model_settings.force_sampling
)
if force:
# Forced mode: use model settings if available, else global
if model_settings and model_settings.temperature is not None:
temperature = model_settings.temperature
elif ocr_defaults and "temperature" in ocr_defaults:
temperature = ocr_defaults["temperature"]
else:
temperature = global_sampling.temperature
if model_settings and model_settings.top_p is not None:
top_p = model_settings.top_p
else:
top_p = global_sampling.top_p
if model_settings and model_settings.top_k is not None:
top_k = model_settings.top_k
else:
top_k = global_sampling.top_k
else:
# Normal mode: priority request > model > ocr_defaults > global
if req_temperature is not None:
temperature = req_temperature
elif model_settings and model_settings.temperature is not None:
temperature = model_settings.temperature
elif ocr_defaults and "temperature" in ocr_defaults:
temperature = ocr_defaults["temperature"]
else:
temperature = global_sampling.temperature
if req_top_p is not None:
top_p = req_top_p
elif model_settings and model_settings.top_p is not None:
top_p = model_settings.top_p
else:
top_p = global_sampling.top_p
if req_top_k is not None:
top_k = req_top_k
elif model_settings and model_settings.top_k is not None:
top_k = model_settings.top_k
elif ocr_defaults and "top_k" in ocr_defaults:
top_k = ocr_defaults["top_k"]
else:
top_k = global_sampling.top_k
# Repetition penalty: request > model settings > ocr_defaults > global (1.0)
if req_repetition_penalty is not None:
repetition_penalty = req_repetition_penalty
elif model_settings and model_settings.repetition_penalty is not None:
repetition_penalty = model_settings.repetition_penalty
elif ocr_defaults and "repetition_penalty" in ocr_defaults:
repetition_penalty = ocr_defaults["repetition_penalty"]
else:
repetition_penalty = getattr(global_sampling, "repetition_penalty", 1.0)
# Min P: request > model settings > default (0.0)
if req_min_p is not None:
min_p = req_min_p
elif model_settings and getattr(model_settings, "min_p", None) is not None:
min_p = model_settings.min_p
else:
min_p = 0.0
# Presence penalty: request > model settings > default (0.0)
if req_presence_penalty is not None:
presence_penalty = req_presence_penalty
elif (
model_settings and getattr(model_settings, "presence_penalty", None) is not None
):
presence_penalty = model_settings.presence_penalty
else:
presence_penalty = 0.0
# Frequency penalty: request > model settings > default (0.0)
if req_frequency_penalty is not None:
frequency_penalty = req_frequency_penalty
elif (
model_settings
and getattr(model_settings, "frequency_penalty", None) is not None
):
frequency_penalty = model_settings.frequency_penalty
else:
frequency_penalty = 0.0
# Max tokens is an output length cap, not a sampling knob. Honor request
# bounds even when force_sampling pins token-selection parameters.
if req_max_tokens is not None:
max_tokens = req_max_tokens
elif model_settings and model_settings.max_tokens is not None:
max_tokens = model_settings.max_tokens
elif ocr_defaults and "max_tokens" in ocr_defaults:
max_tokens = ocr_defaults["max_tokens"]
else:
max_tokens = global_sampling.max_tokens
# XTC probability: request > default (0.0 = disabled)
xtc_probability = req_xtc_probability if req_xtc_probability is not None else 0.0
# XTC threshold: request > default (0.1 = safe default when probability is set)
xtc_threshold = req_xtc_threshold if req_xtc_threshold is not None else 0.1
logger.debug(
f"Sampling params: temperature={temperature}, top_p={top_p}, top_k={top_k}, "
f"repetition_penalty={repetition_penalty}, min_p={min_p}, presence_penalty={presence_penalty}, "
f"frequency_penalty={frequency_penalty}, max_tokens={max_tokens}, "
f"xtc_probability={xtc_probability}, xtc_threshold={xtc_threshold}"
f"{' (forced)' if force else ''}"
f"{f' (model: {model_id})' if model_id else ''}"
)
return (
temperature,
top_p,
top_k,
repetition_penalty,
min_p,
presence_penalty,
frequency_penalty,
max_tokens,
xtc_probability,
xtc_threshold,
)
def _strip_synthetic_think_prefix(chunk_text: str, think_tag: str) -> str:
"""Drop the scheduler's synthetic think opener from a raw completions chunk.
Raw completions are a pure continuation of the prompt. When the prompt
itself ends with an open think tag, the scheduler still prepends a
synthetic ``"<think>\\n"`` to the first streamed chunk (chat streams rely
on it to rebuild the reasoning block), but the opener belongs to the
prompt and the non-streaming completions path never returns it. Stripping
it keeps both completion paths returning the same continuation.
"""
prefix = f"{think_tag}\n"
return chunk_text[len(prefix) :] if chunk_text.startswith(prefix) else chunk_text
def _resolve_thinking_budget(request, model_id: str | None) -> int | None:
"""Resolve thinking budget: request param > model settings > None."""
# Check request-level override (OpenAI format)
req_budget = getattr(request, "thinking_budget", None)
# For Anthropic: check thinking.budget_tokens
if req_budget is None and hasattr(request, "thinking") and request.thinking:
req_budget = getattr(request.thinking, "budget_tokens", None)
if req_budget is not None:
return req_budget
ms = get_model_settings_for_request(model_id)
if ms and ms.thinking_budget_enabled and ms.thinking_budget_tokens:
return ms.thinking_budget_tokens
return None
def get_model_settings_for_request(model_id: str | None):
"""Return settings for the requested API model name via ModelSettingsManager."""
sm = _server_state.settings_manager
if not model_id or sm is None:
return None
resolved_model_id = resolve_model_id(model_id)
if not hasattr(sm, "get_settings_for_request"):
return sm.get_settings(resolved_model_id or model_id)
return sm.get_settings_for_request(
model_id,
resolved_model_id=resolved_model_id,
)
def resolve_model_id(model_id: str | None) -> str | None:
"""Resolve a model alias to its real model ID.
Returns the resolved ID, or the original value if no alias match.
"""
if model_id is None:
return None
pool = _server_state.engine_pool
if pool is None:
return model_id
return pool.resolve_model_id(model_id, _server_state.settings_manager)
async def _ensure_tokenizer_for_system_probe(
engine: BaseEngine, messages: list
) -> None:
"""Load lazy engines before probing mid-conversation system placement."""
if not has_nonleading_system_message(messages):
return
if getattr(engine, "tokenizer", None) is not None:
return
await engine.start()
def _unsupported_mid_system_policy() -> str:
settings = _server_state.global_settings
preserve_cache = True
if settings is not None:
preserve_cache = bool(
getattr(settings.server, "preserve_mid_system_cache", True)
)
return "user_note_safe" if preserve_cache else "strict"
def _format_generation_speed_for_log(
output,
tokens_per_sec: float,
*,
is_diffusion: bool,
) -> str:
if not is_diffusion:
return f"{tokens_per_sec:.1f} tok/s"
parts = [f"{tokens_per_sec:.1f} tok/s e2e"]
output_tps = float(getattr(output, "generation_tps", 0.0) or 0.0)
if output_tps > 0:
parts.append(f"output={output_tps:.1f} tok/s")
canvas_tps = float(getattr(output, "diffusion_canvas_tps", 0.0) or 0.0)
if canvas_tps > 0:
parts.append(f"canvas={canvas_tps:.1f} tok/s")
prompt_tps = float(getattr(output, "prompt_tps", 0.0) or 0.0)
if prompt_tps > 0:
parts.append(f"prompt={prompt_tps:.1f} tok/s")
work_tps = float(getattr(output, "diffusion_work_tps", 0.0) or 0.0)
if work_tps > 0:
parts.append(f"work={work_tps:.1f} tok/s")
steps = int(getattr(output, "diffusion_denoising_steps", 0) or 0)
if steps > 0:
parts.append(f"steps={steps}")
return ", ".join(parts)
def _resolve_metric_durations(
output,
*,
is_diffusion: bool,
prefill_duration: float = 0.0,
generation_duration: float = 0.0,
) -> tuple[float, float]:
if not is_diffusion:
return prefill_duration, generation_duration
prompt_tps = float(getattr(output, "prompt_tps", 0.0) or 0.0)
if prompt_tps > 0:
prefill_duration = output.prompt_tokens / prompt_tps
generation_tps = float(getattr(output, "generation_tps", 0.0) or 0.0)
if generation_tps > 0:
generation_duration = output.completion_tokens / generation_tps
return prefill_duration, generation_duration
def _get_ocr_defaults(model_id: str | None) -> dict | None:
"""Get OCR generation defaults for a model, or None if not an OCR model."""
if model_id is None:
return None
pool = _server_state.engine_pool
if pool is None:
return None
entry = pool.get_entry(model_id)
if entry is None:
return None
from .engine.vlm import OCR_MODEL_GENERATION_DEFAULTS, OCR_MODEL_TYPES
cmt = getattr(entry, "config_model_type", "")
if cmt in OCR_MODEL_TYPES:
return OCR_MODEL_GENERATION_DEFAULTS.get(cmt)
return None
def get_max_context_window(model_id: str | None = None) -> int | None:
"""
Get effective max context window limit.
Resolution:
1. **Per-model override** (admin UI / settings.json) — always
wins. An operator who has set a per-model number knows what
they want; ``max_context_window_policy`` does not clamp it.
2. **Model-config-discovered native context length** (#1308),
optionally clamped by the operator policy: if
``sampling.max_context_window_policy`` is set, return
``min(native, policy)``; otherwise return ``native`` as-is.
3. **Fallback default** from ``SamplingSettings.max_context_window``
— only used when neither tier 1 nor tier 2 yields a value.
Treated as a default, NOT capped by the policy; existing
``settings.json`` files carrying the historical ``32768``
default keep working unchanged after upgrade.
The policy field is intentionally nullable and unset by default so
no existing install behavior shifts. Setting it engages
``min(native, policy)`` across every model whose native context is
discoverable; per-model overrides remain the operator's escape
hatch for individual models that should exceed the policy.
Returns:
Max context window token count, or ``None`` if no tier resolves
(only possible when neither the model nor the global default
provides a value, which shouldn't happen in practice).
"""
# Resolve alias for physical model metadata, but keep requested alias settings.
requested_model_id = model_id
model_settings = get_model_settings_for_request(requested_model_id)
model_id = resolve_model_id(model_id)
# Priority 1: explicit per-model override (not capped by policy)
if model_settings and model_settings.max_context_window is not None:
return model_settings.max_context_window
# Priority 2: model-native context, optionally clamped by policy
pool = _server_state.engine_pool
if model_id and pool is not None:
entry = pool.get_entry(model_id)
if entry is not None and entry.model_context_length is not None:
native = entry.model_context_length
policy = getattr(_server_state.sampling, "max_context_window_policy", None)
if policy is not None and policy > 0:
return min(native, policy)
return native
# Priority 3: fallback default (not capped — preserves legacy
# settings.json behavior).
return _server_state.sampling.max_context_window
def get_embedding_max_length(
model_id: str | None = None,
request_max_length: int | None = None,
) -> int | None:
"""Get max token length for embedding requests.
Returns ``None`` when neither the request nor the server's
``max_context_window`` pins a limit, so the embedding model resolves its
own configured context length (``max_position_embeddings`` / tokenizer
``model_max_length`` in ``MLXEmbeddingModel._resolve_max_length``) instead
of re-truncating long-context models at the legacy 512-token cap (#1687).
"""
if request_max_length is not None:
return request_max_length
return get_max_context_window(model_id)
def scale_anthropic_tokens(token_count: int, model_id: str | None = None) -> int:
"""
Scale token count for Anthropic API response if context scaling is enabled.
Adjusts reported token counts so that Claude Code's auto-compact
triggers at the correct timing when using models with smaller context
windows than the target (default 200k).
Formula: scaled = token_count * (target_context_size / actual_context_size)
Args:
token_count: Original token count to scale.
model_id: Model ID to get context window for.
Returns:
Scaled token count, or original if scaling not applicable.
"""
global_settings = _server_state.global_settings
if global_settings is None:
return token_count
cc = global_settings.claude_code
if not cc.context_scaling_enabled:
return token_count
actual = get_max_context_window(model_id)
if not actual or actual >= cc.target_context_size:
return token_count
return int(token_count * cc.target_context_size / actual)
def validate_context_window(
num_prompt_tokens: int, model_id: str | None = None
) -> None:
"""
Validate that prompt token count does not exceed max context window.
Raises HTTPException 400 if the prompt is too long.
"""
max_ctx = get_max_context_window(model_id)
if max_ctx and num_prompt_tokens > max_ctx:
raise HTTPException(
status_code=400,
detail=(
f"Prompt too long: {num_prompt_tokens} tokens exceeds "
f"max context window of {max_ctx} tokens"
),
)
def init_server(
model_dirs: str | list[str],
scheduler_config=None,
api_key: str | None = None,
global_settings: object | None = None,
):
"""
Initialize server with model directories for multi-model serving.
Args:
model_dirs: Path or list of paths to directories containing model subdirectories
scheduler_config: Scheduler config for BatchedEngine
api_key: API key for authentication (optional)
global_settings: GlobalSettings instance (optional)
Note:
- Pinned models and default model are managed via admin page (model_settings.json)
- Sampling parameters (max_tokens, temperature, etc.) are per-model settings
Raises:
ValueError: If model directory doesn't exist or no models found
"""
from pathlib import Path
from .model_settings import ModelSettingsManager
# Store API key
_server_state.api_key = api_key
_server_state.global_settings = global_settings
response_state_dir = None
if global_settings:
response_state_dir = (
global_settings.cache.get_ssd_cache_dir(global_settings.base_path)
/ "response-state"
)
_server_state.responses_store = ResponseStore(state_dir=response_state_dir)
# Refresh i18n with loaded language setting
from .admin.routes import _refresh_i18n_globals
_refresh_i18n_globals()
# Initialize auth with persistent secret key
if global_settings:
if not global_settings.auth.secret_key:
import secrets as _secrets
global_settings.auth.secret_key = _secrets.token_hex(32)
global_settings.save()
logger.info("Generated and saved new auth secret key")
from .admin.auth import init_auth
init_auth(
global_settings.auth.secret_key, lambda: _server_state.global_settings
)
# Configure CORS middleware from settings
cors_origins = global_settings.server.cors_origins if global_settings else ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins,
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
logger.info(f"CORS origins: {cors_origins}")
# Initialize model settings manager
base_path = (
Path(global_settings.base_path) if global_settings else Path.home() / ".omlx"
)
_server_state.settings_manager = ModelSettingsManager(base_path)
# Get pinned models from settings file only (managed via admin page)
pinned_models = _server_state.settings_manager.get_pinned_model_ids()
# Get default model from settings file only (managed via admin page)
settings_default = _server_state.settings_manager.get_default_model_id()
# Load default sampling values from global settings
# Per-model settings will override these via get_sampling_params()
if global_settings and global_settings.sampling:
_server_state.sampling = SamplingDefaults(
max_context_window=global_settings.sampling.max_context_window,
max_context_window_policy=getattr(
global_settings.sampling, "max_context_window_policy", None
),
max_tokens=global_settings.sampling.max_tokens,
temperature=global_settings.sampling.temperature,
top_p=global_settings.sampling.top_p,
top_k=global_settings.sampling.top_k,
repetition_penalty=getattr(
global_settings.sampling, "repetition_penalty", 1.0
),
)
else:
_server_state.sampling = SamplingDefaults()
# Normalize model_dirs to list
if isinstance(model_dirs, str):
dir_list = [model_dirs]
else:
dir_list = list(model_dirs)
if global_settings and hasattr(global_settings, "get_effective_model_dirs"):
dir_list = [str(d) for d in global_settings.get_effective_model_dirs()]
# Create directories if needed
for md in dir_list:
model_path = Path(md)
if not model_path.exists():
model_path.mkdir(parents=True, exist_ok=True)
logger.warning(f"Model directory created (empty): {md}")
# Create engine pool. The pool consults enforcer.get_final_ceiling()
# for pre-load admission — wired up later in startup once the enforcer
# is constructed.
_server_state.engine_pool = EnginePool(
scheduler_config=scheduler_config,
)
# Discover models (use pinned models from settings file)
_server_state.engine_pool._settings_manager = _server_state.settings_manager
_server_state.engine_pool.discover_models(dir_list, pinned_models)
_server_state.engine_pool.apply_settings_overrides(_server_state.settings_manager)
if _server_state.engine_pool.model_count == 0:
logger.warning(
f"No models found in {', '.join(dir_list)}. Add models to serve them."
)
# Set default model (from settings file, fallback to first model)
available_models = _server_state.engine_pool.get_model_ids()
if available_models:
if settings_default:
if settings_default in available_models:
_server_state.default_model = settings_default
else:
logger.warning(
f"Default model '{settings_default}' not found, using first model"
)
_server_state.default_model = available_models[0]
else:
_server_state.default_model = available_models[0]
else:
_server_state.default_model = None
# Reset server metrics for fresh start (with all-time persistence)
stats_path = base_path / "stats.json"
reset_server_metrics(stats_path=stats_path)
logger.info(
f"Server initialized with {_server_state.engine_pool.model_count} models"
)
if _server_state.default_model:
logger.info(f"Default model: {_server_state.default_model}")
else:
logger.info("No default model (no models available)")
if global_settings and getattr(global_settings, "memory", None):
logger.info(
f"Memory guard tier: {global_settings.memory.memory_guard_tier} "
f"(guard {'on' if global_settings.memory.prefill_memory_guard else 'off'})"
)
logger.info(f"Default max tokens: {_server_state.sampling.max_tokens}")
if api_key:
logger.info("API key authentication: enabled")
# Initialize HuggingFace downloader
from .admin.hf_downloader import HFDownloader
from .admin.routes import set_hf_downloader
async def _refresh_models_after_download():
"""Re-discover models when a HuggingFace download completes."""
if _server_state.engine_pool and _server_state.settings_manager:
pinned = _server_state.settings_manager.get_pinned_model_ids()
_server_state.engine_pool.discover_models(dir_list, pinned)
_server_state.engine_pool.apply_settings_overrides(
_server_state.settings_manager
)
logger.info("Model pool refreshed after download completion")
_server_state.hf_downloader = HFDownloader(
model_dir=dir_list[0], # Downloads go to primary directory
on_complete=_refresh_models_after_download,
)
set_hf_downloader(_server_state.hf_downloader)
logger.info("HF Downloader initialized")
# Initialize ModelScope downloader (optional - requires modelscope SDK)
try:
from .admin.ms_downloader import MS_SDK_AVAILABLE, MSDownloader
if MS_SDK_AVAILABLE:
from .admin.routes import set_ms_downloader
_server_state.ms_downloader = MSDownloader(
model_dir=dir_list[0],
on_complete=_refresh_models_after_download,
)
set_ms_downloader(_server_state.ms_downloader)
logger.info("ModelScope Downloader initialized")
else:
logger.info("ModelScope SDK not installed, MS downloader disabled")
except ImportError:
logger.info("ModelScope support not available")
# Initialize oQ Quantizer
from .admin.oq_manager import OQManager
from .admin.routes import set_oq_manager
_server_state.oq_manager = OQManager(
model_dirs=[str(d) for d in dir_list],
on_complete=_refresh_models_after_download,
)
set_oq_manager(_server_state.oq_manager)
logger.info("oQ Quantizer initialized")
# Initialize HuggingFace uploader
from .admin.hf_uploader import HFUploader
from .admin.routes import set_hf_uploader
_server_state.hf_uploader = HFUploader(
model_dirs=[str(d) for d in dir_list],
)
set_hf_uploader(_server_state.hf_uploader)
logger.info("HF Uploader initialized")
_KEEPALIVE_SENTINEL = object()
_KEEPALIVE_COMMENT = ": keep-alive\n\n"
# The delta carries "role":"assistant" because this frame is the FIRST event
# of every stream and some accumulators type the whole stream from the first
# chunk's role: LangChain.js builds a generic ChatMessageChunk when it is
# absent, and merging the real chunks into it silently discards all
# tool_call_chunks (#2074, hits n8n AI Agent workflows). Cloud APIs open every
# stream with a role chunk, so clients rely on it; the OpenAI SDKs, openai-go,
# and LangChain all tolerate the duplicate role in later chunks.
_KEEPALIVE_CHAT_CHUNK = (
'data: {"id":"chatcmpl-keepalive","object":"chat.completion.chunk",'
'"created":0,"model":"keepalive",'
'"choices":[{"index":0,"delta":{"role":"assistant","content":""},'
'"finish_reason":null}]}\n\n'
)
_KEEPALIVE_COMPLETION_CHUNK = (
'data: {"id":"cmpl-keepalive","object":"text_completion","created":0,'
'"model":"keepalive",'
'"choices":[{"index":0,"text":"","logprobs":null,"finish_reason":null}]}\n\n'
)
_KEEPALIVE_ANTHROPIC_PING = 'event: ping\ndata: {"type":"ping"}\n\n'
def _resolve_keepalive(protocol: str) -> Optional[str]:
"""Pick a wire-level keepalive frame for the given API protocol.
Returns None when the configured mode disables keepalive for this protocol.
Modes: "chunk" (default, protocol-aware), "comment" (legacy SSE comment),
"off" (no keepalive). Some clients (e.g. OpenClaw / WorkBuddy) cannot parse
SSE comment lines, so the chunk mode emits valid no-op events instead.
"""
global_settings = _server_state.global_settings
mode = "chunk"
if global_settings is not None:
mode = getattr(global_settings.server, "sse_keepalive_mode", "chunk")
if mode == "off":
return None
if mode == "comment":
return _KEEPALIVE_COMMENT
if protocol == "openai_chat":
return _KEEPALIVE_CHAT_CHUNK
if protocol == "openai_completion":
return _KEEPALIVE_COMPLETION_CHUNK
if protocol == "anthropic":
return _KEEPALIVE_ANTHROPIC_PING
if protocol == "openai_responses":
return None
return None
def _chat_keepalive_chunk(response_id: str) -> str:
"""Keepalive frame that shares the stream's completion id.
The static ``_KEEPALIVE_CHAT_CHUNK`` carries a sentinel id
(``chatcmpl-keepalive``) that differs from the real completion chunks.
Strict OpenAI stream accumulators (e.g. the official ``openai-go`` SDK)
assume every chunk in one streamed completion shares a single ``id``: they
latch the first chunk's id and silently drop later chunks whose id differs,
discarding the real ``tool_calls``/``finish_reason``/``usage``. Emitting the
keepalive with the stream's own ``response_id`` makes it a true no-op for
those clients while remaining a parseable data event for clients that can't
handle SSE comment lines.
The delta must also carry ``"role":"assistant"`` — see the comment on
``_KEEPALIVE_CHAT_CHUNK`` (accumulators that type the stream from the
first chunk's role drop tool_call_chunks without it, #2074).
"""
return (
'data: {"id":"' + response_id + '","object":"chat.completion.chunk",'
'"created":0,"model":"keepalive",'
'"choices":[{"index":0,"delta":{"role":"assistant","content":""},'
'"finish_reason":null}]}\n\n'
)
async def _safe_anext(ait):
"""Wrapper for __anext__ that converts StopAsyncIteration to a sentinel.
StopAsyncIteration cannot propagate through asyncio.Task (raises RuntimeError),
so we catch it here and return a sentinel value instead.
"""
try:
return await ait.__anext__()
except StopAsyncIteration:
return _KEEPALIVE_SENTINEL
async def _with_sse_keepalive(
generator: AsyncIterator[str],
http_request: Optional["FastAPIRequest"] = None,
interval: float = 10.0,
disconnect_poll: float = 2.0,
keepalive_chunk: Optional[str] = _KEEPALIVE_COMMENT,
) -> AsyncIterator[str]:
"""Wrap an SSE generator to send periodic keepalive frames.
During long prefill (e.g. 90k tokens), no SSE events are emitted,
causing clients with read timeouts (like Claude Code) to disconnect.
This wrapper periodically yields a keepalive frame to hold the
connection open. The frame format depends on caller-supplied
keepalive_chunk: a legacy SSE comment, a protocol-aware no-op event,
or None to disable emission entirely.
When http_request is provided, also polls for client disconnect
between prefill steps. This detects cancellation during long prefills
where uvicorn's ASGI disconnect message is not delivered until after
the generator yields.
"""
ait = generator.__aiter__()
task = None
keepalive_elapsed = 0.0
# Send initial keepalive immediately so clients with short read
# timeouts (e.g. openclaw ~15s) don't disconnect during prefill.
if keepalive_chunk is not None:
yield keepalive_chunk
try:
while True:
task = asyncio.ensure_future(_safe_anext(ait))
keepalive_elapsed = 0.0
while not task.done():
# Use shorter poll interval for disconnect detection,
# accumulate time for keepalive emission
wait_time = disconnect_poll if http_request else interval
done, _ = await asyncio.wait({task}, timeout=wait_time)
if done:
break
# Check for client disconnect
if http_request is not None:
try:
disconnected = await http_request.is_disconnected()
if disconnected:
logger.info(
"Client disconnected during streaming (is_disconnected), cancelling"
)
task.cancel()
try:
await task
except (asyncio.CancelledError, StopAsyncIteration):
pass
return
except Exception as e:
logger.debug(f"is_disconnected() check failed: {e}")
pass # is_disconnected() can fail if scope is already closed
# Send keepalive at the configured interval
keepalive_elapsed += wait_time
if keepalive_elapsed >= interval:
keepalive_elapsed = 0.0
if keepalive_chunk is not None:
yield keepalive_chunk
if task.done():
try:
result = task.result()
except Exception as e:
if isinstance(e, PrefillMemoryExceededError):
logger.warning(f"SSE generator prefill rejected: {e}")
error_data = _prefill_memory_openai_error_body(e)
else:
logger.error(f"SSE generator error: {e}")
error_data = {
"error": {"message": str(e), "type": "server_error"}
}
yield f"data: {json.dumps(error_data)}\n\n"
yield "data: [DONE]\n\n"
return
if result is _KEEPALIVE_SENTINEL:
return
yield result
finally:
if task is not None and not task.done():
task.cancel()
try:
await task
except (asyncio.CancelledError, StopAsyncIteration):
pass
if hasattr(ait, "aclose"):
await ait.aclose()
async def _run_with_disconnect_guard(
http_request: FastAPIRequest,
coro,
poll_interval: float = 1.0,
):
"""Run a coroutine with client disconnect detection.
For non-streaming requests, FastAPI/uvicorn does NOT automatically cancel
the handler coroutine when a client disconnects. This helper polls
is_disconnected() periodically and cancels the task on disconnect,
which triggers CancelledError -> abort_request() in EngineCore.generate()
to free scheduler/GPU resources.
"""
task = asyncio.create_task(coro)
while not task.done():
done, _ = await asyncio.wait({task}, timeout=poll_interval)
if done:
break
if await http_request.is_disconnected():
logger.info("Client disconnected, cancelling generation task")
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
return None
return task.result()
async def _with_json_keepalive(
http_request: FastAPIRequest,
coro,
interval: float = 10.0,
disconnect_poll: float = 2.0,
) -> AsyncIterator[str]:
"""Wrap a coroutine to send keepalive spaces while waiting for completion.
For non-streaming requests, the HTTP response body is buffered until
generation finishes, causing client read timeouts on long prefills.
This wrapper uses StreamingResponse to send space characters as
keepalive. JSON parsers ignore leading whitespace, so the final
response parses normally.
"""
task = asyncio.ensure_future(coro)
keepalive_elapsed = 0.0
yield " "
try:
while not task.done():
done, _ = await asyncio.wait({task}, timeout=disconnect_poll)
if done:
break
if http_request is not None:
try:
disconnected = await http_request.is_disconnected()
if disconnected:
logger.info(
"Client disconnected during non-streaming response, cancelling"
)
task.cancel()
try:
await task
except (asyncio.CancelledError, StopAsyncIteration):
pass
return
except Exception:
pass
keepalive_elapsed += disconnect_poll
if keepalive_elapsed >= interval:
keepalive_elapsed = 0.0
yield " "
try:
result = task.result()
except PrefillMemoryExceededError as e:
logger.warning(f"JSON keepalive prefill rejected: {e}")
yield json.dumps(_prefill_memory_openai_error_body(e))
return
if result is not None:
yield result
finally:
if not task.done():
task.cancel()
try:
await task
except (asyncio.CancelledError, StopAsyncIteration):
pass
@app.get("/health")
async def health(response: Response):
"""Health check endpoint.
Answers 503 with status "loading" while the startup pinned-model
preload is still running: the port is already bound (liveness for
watchdogs, #2184) but the server is not ready to serve those models.
"""
mcp_info = None
if _server_state.mcp_manager is not None:
connected = sum(
1
for s in _server_state.mcp_manager.get_server_status()
if s.state.value == "connected"
)
total = len(_server_state.mcp_manager.get_server_status())
mcp_info = {
"enabled": True,
"servers_connected": connected,
"servers_total": total,
"tools_available": len(_server_state.mcp_manager.get_all_tools()),
}
pool_status = None
if _server_state.engine_pool is not None:
enforcer = _server_state.process_memory_enforcer
ceiling = 0
if enforcer is not None:
try:
ceiling = enforcer.get_final_ceiling()
except Exception as exc: # noqa: BLE001
logger.warning("Health memory ceiling unavailable: %s", exc)
pool_status = {
"model_count": _server_state.engine_pool.model_count,
"loaded_count": _server_state.engine_pool.loaded_model_count,
"final_ceiling": ceiling,
"current_model_memory": _server_state.engine_pool.current_model_memory,
}
loading = not _server_state.pinned_preload_complete
if loading:
response.status_code = 503
return {
"status": "loading" if loading else "healthy",
"default_model": _server_state.default_model,
"engine_pool": pool_status,
"mcp": mcp_info,
}
@app.get("/api/status")
async def server_status(_: bool = Depends(verify_api_key)):
"""Lightweight status endpoint for external tool polling (statuslines, scripts)."""
from .custom_kernels import native_kernel_status
from .model_discovery import format_size
from .server_metrics import get_server_metrics
metrics = get_server_metrics()
snapshot = metrics.get_snapshot()
pool = _server_state.engine_pool
models_discovered = 0
models_loaded = 0
models_loading = 0
loaded_models = []
model_memory_used = 0
model_memory_max = None
if pool is not None:
models_discovered = pool.model_count
models_loaded = pool.loaded_model_count
loaded_models = pool.get_loaded_model_ids()
model_memory_used = pool.current_model_memory
enforcer = _server_state.process_memory_enforcer
if enforcer is not None:
try:
model_memory_max = enforcer.get_final_ceiling()
except Exception as exc: # noqa: BLE001
logger.warning("Status memory ceiling unavailable: %s", exc)
for entry in pool._entries.values():
if entry.is_loading:
models_loading += 1
# Aggregate active/waiting requests across all loaded engines
active_requests = 0
waiting_requests = 0
if pool is not None:
for entry in pool._entries.values():
engine = entry.engine
if engine is None:
continue
async_core = getattr(engine, "_engine", None)
if async_core is None:
continue
core = getattr(async_core, "engine", None)
if core is None:
continue
active_requests += len(getattr(core, "_output_collectors", {}))
sched = getattr(core, "scheduler", None)
if sched is not None:
waiting_requests += len(getattr(sched, "waiting", []))
return {
"status": "ok",
"version": __version__,
"uptime_seconds": snapshot["uptime_seconds"],
"models_discovered": models_discovered,
"models_loaded": models_loaded,
"models_loading": models_loading,
"default_model": _server_state.default_model,
"loaded_models": loaded_models,
"total_requests": snapshot["total_requests"],
"active_requests": active_requests,
"waiting_requests": waiting_requests,
"total_prompt_tokens": snapshot["total_prompt_tokens"],
"total_completion_tokens": snapshot["total_completion_tokens"],
"total_cached_tokens": snapshot["total_cached_tokens"],
"cache_efficiency": snapshot["cache_efficiency"],
"avg_prefill_tps": snapshot["avg_prefill_tps"],
"avg_generation_tps": snapshot["avg_generation_tps"],
"model_memory_used": model_memory_used,
"model_memory_max": model_memory_max,
"model_memory_used_formatted": (
format_size(model_memory_used) if model_memory_used else "0B"
),
"model_memory_max_formatted": (
format_size(model_memory_max) if model_memory_max else "unlimited"
),
"custom_kernels": native_kernel_status(),
}
def _markitdown_virtual_model_status() -> dict:
return {
"id": MARKITDOWN_MODEL_ID,
"model_path": "builtin://markitdown",
"loaded": True,
"is_loading": False,
"loading_started_at": None,
"estimated_size": 0,
"actual_size": 0,
"pinned": False,
"engine_type": "markitdown",
"model_type": "markitdown",
"config_model_type": "markitdown",
"thinking_default": None,
"preserve_thinking_default": None,
"source_type": "builtin",
"source_repo_id": None,
"last_access": None,
}
def _markitdown_is_visible() -> bool:
return markitdown_model_visible(_server_state.global_settings)
def _with_markitdown_status(status: dict) -> dict:
if not _markitdown_is_visible():
return status
augmented = dict(status)
models = list(augmented.get("models", []))
if not any(m.get("id") == MARKITDOWN_MODEL_ID for m in models):
models.append(_markitdown_virtual_model_status())
augmented["models"] = models
augmented["model_count"] = len(models)
augmented["loaded_count"] = sum(1 for m in models if m.get("loaded"))
return augmented
def _with_exposed_profile_status(status: dict) -> dict:
settings_manager = _server_state.settings_manager
if settings_manager is None:
return status
list_profiles = getattr(settings_manager, "list_exposed_profile_models", None)
if not callable(list_profiles):
return status
augmented = dict(status)
models = [dict(m) for m in augmented.get("models", [])]
by_id = {m.get("id"): m for m in models}
existing_ids = set(by_id)
for profile in list_profiles():
source_model_id = profile.get("source_model_id")
profile_model_id = profile.get("model_id")
if (
not source_model_id
or not profile_model_id
or source_model_id not in by_id
or profile_model_id in existing_ids
):
continue
profile_status = dict(by_id[source_model_id])
profile_status.update(
{
"id": profile_model_id,
"source_model_id": source_model_id,
"profile_name": profile.get("name"),
"profile_api_name": profile.get("api_name"),
"profile_display_name": profile.get("display_name"),
}
)
models.append(profile_status)
existing_ids.add(profile_model_id)
augmented["models"] = models
augmented["model_count"] = len(models)
augmented["loaded_count"] = sum(1 for m in models if m.get("loaded"))
return augmented
async def _preprocess_markitdown_files_for_llm(
request: ChatCompletionRequest,
) -> ChatCompletionRequest:
if not request_has_file_parts(request.messages):
return request
try:
messages = await preprocess_markitdown_file_parts_async(
request.messages,
global_settings=_server_state.global_settings,
engine_pool=_server_state.engine_pool,
settings_manager=_server_state.settings_manager,
get_sampling_params=get_sampling_params,
fail_when_disabled=True,
allow_missing_historical_files=True,
)
except MarkItDownRequestError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return request.model_copy(update={"messages": messages})
def _build_markitdown_chat_response(
request: ChatCompletionRequest,
markdown: str,
) -> ChatCompletionResponse:
return ChatCompletionResponse(
model=request.model,
choices=[
ChatCompletionChoice(
message=AssistantMessage(content=markdown),
finish_reason="stop",
)
],
usage=Usage(prompt_tokens=0, completion_tokens=0, total_tokens=0),
)
async def _stream_markitdown_chat_response(
request: ChatCompletionRequest,
markdown_chunks: AsyncIterator[str],
response_id: str | None = None,
) -> AsyncIterator[str]:
response_id = response_id or f"chatcmpl-{uuid.uuid4().hex[:8]}"
role_chunk = ChatCompletionChunk(
id=response_id,
model=request.model,
choices=[
ChatCompletionChunkChoice(
delta=ChatCompletionChunkDelta(role="assistant"),
)
],
)
yield f"data: {role_chunk.model_dump_json(exclude_none=True)}\n\n"
emitted = False
async for markdown in markdown_chunks:
if not markdown:
continue
emitted = True
content_chunk = ChatCompletionChunk(
id=response_id,
model=request.model,
choices=[
ChatCompletionChunkChoice(
delta=ChatCompletionChunkDelta(content=markdown),
)
],
)
yield f"data: {content_chunk.model_dump_json(exclude_none=True)}\n\n"
if not emitted:
raise MarkItDownRequestError(
"No text or supported file content found for MarkItDown.",
status_code=400,
)
final_chunk = ChatCompletionChunk(
id=response_id,
model=request.model,
choices=[
ChatCompletionChunkChoice(
delta=ChatCompletionChunkDelta(),
finish_reason="stop",
)
],
)
yield f"data: {final_chunk.model_dump_json(exclude_none=True)}\n\n"
if request.stream_options and request.stream_options.include_usage:
usage_chunk = ChatCompletionChunk(
id=response_id,
model=request.model,
choices=[],
usage=Usage(prompt_tokens=0, completion_tokens=0, total_tokens=0),
)
yield f"data: {usage_chunk.model_dump_json(exclude_none=True)}\n\n"
yield "data: [DONE]\n\n"
async def _create_markitdown_chat_completion(
request: ChatCompletionRequest,
http_request: FastAPIRequest,
):
if not _markitdown_is_visible():
raise HTTPException(
status_code=404,
detail=f"Model not found: {MARKITDOWN_MODEL_ID}",
)
if request.stream:
response_id = f"chatcmpl-{uuid.uuid4().hex[:8]}"
keepalive = _resolve_keepalive("openai_chat")
if keepalive == _KEEPALIVE_CHAT_CHUNK:
keepalive = _chat_keepalive_chunk(response_id)
markdown_chunks = stream_messages_to_markdown_async(
request.messages,
global_settings=_server_state.global_settings,
engine_pool=_server_state.engine_pool,
settings_manager=_server_state.settings_manager,
get_sampling_params=get_sampling_params,
latest_user_only=True,
)
return StreamingResponse(
_with_sse_keepalive(
_stream_markitdown_chat_response(
request,
markdown_chunks,
response_id=response_id,
),
http_request=http_request,
keepalive_chunk=keepalive,
),
media_type="text/event-stream",
headers={"X-Accel-Buffering": "no", "Cache-Control": "no-cache"},
)
async def _build_markitdown_completion():
try:
markdown = await convert_messages_to_markdown_async(
request.messages,
global_settings=_server_state.global_settings,
engine_pool=_server_state.engine_pool,
settings_manager=_server_state.settings_manager,
get_sampling_params=get_sampling_params,
latest_user_only=True,
)
except MarkItDownRequestError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
if not markdown:
raise HTTPException(
status_code=400,
detail="No text or supported file content found for MarkItDown.",
)
logger.info("MarkItDown completion converted request to markdown")
return _build_markitdown_chat_response(
request,
markdown,
).model_dump_json(exclude_none=True)
return StreamingResponse(
_with_json_keepalive(http_request, _build_markitdown_completion()),
media_type="application/json",
)
@app.get("/v1/models")
async def list_models(_: bool = Depends(verify_api_key)) -> ModelsResponse:
"""List all available models with load status."""
models = []
favorite_ids: set[str] = set()
if _server_state.engine_pool is not None:
status = _server_state.engine_pool.get_status()
settings_manager = _server_state.settings_manager
hide_helpers = bool(
_server_state.global_settings is not None
and _server_state.global_settings.model.hide_helper_models
)
# Set of draft-model references (paths / repo ids) pointed at by other
# models' speculative settings — used to flag "helper" drafters that
# only differ from a chat model by being referenced elsewhere.
referenced_drafts: set[str] = set()
if hide_helpers and settings_manager:
for _ms in settings_manager.get_all_settings().values():
for ref in (
_ms.specprefill_draft_model,
_ms.dflash_draft_model,
_ms.vlm_mtp_draft_model,
):
if ref:
referenced_drafts.add(ref)
excluded_model_ids: set[str] = set()
for m in status["models"]:
model_id = m["id"]
display_id = model_id
ms = None
if settings_manager:
ms = settings_manager.get_settings(model_id)
if ms.model_alias:
display_id = ms.model_alias
# Per-model hide: user-selected, always applied.
is_hidden = ms is not None and ms.is_hidden
# Global helper hide: skip drafters when the toggle is on. A model
# is a drafter if intrinsically flagged at discovery (config marker)
# or referenced as another model's draft.
is_hidden_helper = hide_helpers and (
m.get("is_helper")
or model_id in referenced_drafts
or m.get("model_path") in referenced_drafts
or (m.get("source_repo_id") in referenced_drafts)
)
if is_hidden or is_hidden_helper:
excluded_model_ids.add(model_id)
continue
if ms is not None and ms.is_favorite:
favorite_ids.add(display_id)
models.append(
ModelInfo(
id=display_id,
owned_by="omlx",
max_model_len=get_max_context_window(model_id),
)
)
if settings_manager:
physical_ids = {m["id"] for m in status["models"]}
existing_ids = {m.id for m in models}
for profile in settings_manager.list_exposed_profile_models():
source_model_id = profile["source_model_id"]
profile_model_id = profile["model_id"]
if (
source_model_id not in physical_ids
or source_model_id in excluded_model_ids
or profile_model_id in existing_ids
):
continue
models.append(
ModelInfo(
id=profile_model_id,
owned_by="omlx",
max_model_len=get_max_context_window(profile_model_id),
)
)
existing_ids.add(profile_model_id)
if _markitdown_is_visible() and not any(
m.id == MARKITDOWN_MODEL_ID for m in models
):
models.append(ModelInfo(id=MARKITDOWN_MODEL_ID, owned_by="omlx"))
# Favorites first; stable sort keeps alphabetical order within groups.
if favorite_ids:
models.sort(key=lambda m: m.id not in favorite_ids)
return ModelsResponse(data=models)
@app.get("/v1/models/status")
async def list_models_status(_: bool = Depends(verify_api_key)):
"""
List all available models with detailed status.
Extended endpoint that provides more information than /v1/models.
"""
if _server_state.engine_pool is None:
raise HTTPException(status_code=503, detail="Server not initialized")
status = _with_exposed_profile_status(
_with_markitdown_status(_server_state.engine_pool.get_status())
)
for m in status["models"]:
model_id = m["id"]
if is_markitdown_model(model_id):
m["max_context_window"] = None
m["max_tokens"] = None
continue
m["max_context_window"] = get_max_context_window(model_id)
source_model_id = m.get("source_model_id") or model_id
# Resolve effective max_tokens: model setting > global default
max_tokens = _server_state.sampling.max_tokens
if _server_state.settings_manager:
sm = _server_state.settings_manager
if hasattr(sm, "get_settings_for_request"):
ms = sm.get_settings_for_request(
model_id,
resolved_model_id=source_model_id,
)
else:
ms = sm.get_settings(source_model_id)
base_ms = sm.get_settings(source_model_id)
if base_ms and base_ms.model_alias and source_model_id == model_id:
m["model_alias"] = base_ms.model_alias
if ms and ms.max_tokens is not None:
max_tokens = ms.max_tokens
m["max_tokens"] = max_tokens
return status
@app.post("/v1/models/{model_id}/unload")
async def unload_model(model_id: str, _: bool = Depends(verify_api_key)):
"""Manually unload a model from memory."""
if _server_state.engine_pool is None:
raise HTTPException(status_code=503, detail="Server not initialized")
entry = _server_state.engine_pool.get_entry(model_id)
if entry is None:
raise HTTPException(status_code=404, detail=f"Model not found: {model_id}")
if entry.engine is None:
raise HTTPException(status_code=400, detail=f"Model not loaded: {model_id}")
await _server_state.engine_pool._unload_engine(model_id)
return {"status": "ok", "model_id": model_id}
@app.post("/v1/models/{model_id}/load")
async def load_model_public(model_id: str, _: bool = Depends(verify_api_key)):
"""Load a discovered model into memory. Blocks until loading completes."""
if _server_state.engine_pool is None:
raise HTTPException(status_code=503, detail="Server not initialized")
entry = _server_state.engine_pool.get_entry(model_id)
if entry is None:
raise HTTPException(status_code=404, detail=f"Model not found: {model_id}")
if entry.engine is not None:
return {
"status": "ok",
"model_id": model_id,
"message": f"Already loaded: {model_id}",
}
try:
await _server_state.engine_pool.get_engine(model_id)
except ModelNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except ModelTooLargeError as e:
raise HTTPException(status_code=507, detail=str(e)) from e
except InsufficientMemoryError as e:
raise HTTPException(status_code=507, detail=str(e)) from e
except ModelUnavailableError as e:
raise HTTPException(status_code=409, detail=str(e)) from e
except ModelLoadingError as e:
raise HTTPException(status_code=409, detail=str(e)) from e
except ModelBusyError as e:
raise HTTPException(status_code=409, detail=str(e)) from e
except EnginePoolError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e
return {"status": "ok", "model_id": model_id, "message": f"Loaded {model_id}"}
# =============================================================================
# Embeddings Endpoint
# =============================================================================
@app.post("/v1/embeddings")
async def create_embeddings(
request: EmbeddingRequest,
http_request: FastAPIRequest,
_: bool = Depends(verify_api_key),
):
"""
Create embeddings for input text(s).
OpenAI-compatible endpoint for generating text embeddings.
Example request:
```json
{
"model": "all-MiniLM-L6-v2",
"input": ["Hello, world!", "How are you?"],
"encoding_format": "float"
}
```
Supports:
- Single text or list of texts
- float or base64 encoding format
- Optional dimension reduction (with renormalization)
"""
oq_manager = getattr(_server_state, "oq_manager", None)
if oq_manager and oq_manager.is_quantizing:
raise HTTPException(
status_code=503,
detail="Server is busy with oQ quantization. Please try again after quantization completes.",
)
# Validate the model up front (resolves + loads + type-checks) so a bad
# model still 400/404s before we start the streaming response. The actual
# eviction-proof lease is taken inside _build_embeddings, which is where
# the engine is used (the StreamingResponse runs that coroutine later).
await get_embedding_engine(request.model)
if request.items is not None:
embedding_inputs = normalize_embedding_items(request.items)
elif request.input is not None:
embedding_inputs = normalize_input(request.input)
else:
embedding_inputs = []
if not embedding_inputs:
raise HTTPException(status_code=400, detail="Input cannot be empty")
max_length = get_embedding_max_length(request.model, request.max_length)
async def _build_embeddings():
start_time = time.perf_counter()
try:
async with acquire_embedding_engine(request.model) as engine:
output = await engine.embed(
embedding_inputs,
max_length=max_length,
truncation=request.truncation,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except TypeError as e:
raise HTTPException(status_code=400, detail=str(e))
elapsed = time.perf_counter() - start_time
logger.info(
f"Embedding: {len(embedding_inputs)} inputs, {output.dimensions} dims, "
f"{output.total_tokens} tokens, max_length={max_length}, "
f"truncation={request.truncation} in {elapsed:.3f}s"
)
get_server_metrics().record_request_complete(
prompt_tokens=output.total_tokens,
completion_tokens=0,
cached_tokens=0,
prefill_duration=elapsed,
model_id=resolve_model_id(request.model) or request.model,
)
data = []
for i, embedding in enumerate(output.embeddings):
if request.dimensions and request.dimensions < len(embedding):
embedding = truncate_embedding(embedding, request.dimensions)
if request.encoding_format == "base64":
formatted_embedding = encode_embedding_base64(embedding)
else:
formatted_embedding = embedding
data.append(
EmbeddingData(
index=i,
embedding=formatted_embedding,
)
)
return EmbeddingResponse(
data=data,
model=request.model,
usage=EmbeddingUsage(
prompt_tokens=output.total_tokens,
total_tokens=output.total_tokens,
),
).model_dump_json()
return StreamingResponse(
_with_json_keepalive(http_request, _build_embeddings()),
media_type="application/json",
)
# =============================================================================
# Rerank Endpoint
# =============================================================================
def normalize_documents(documents: list[str] | list[dict]) -> list[str]:
"""Normalize document input to list of strings."""
result = []
for doc in documents:
if isinstance(doc, str):
result.append(doc)
elif isinstance(doc, dict):
result.append(doc.get("text", ""))
else:
result.append(str(doc))
return result
@app.post("/v1/rerank")
async def create_rerank(
request: RerankRequest,
_: bool = Depends(verify_api_key),
) -> RerankResponse:
"""
Rerank documents by relevance to a query.
Cohere/Jina-compatible endpoint for document reranking.
Example request:
```json
{
"model": "bge-reranker-v2-m3",
"query": "What is machine learning?",
"documents": [
"Machine learning is a subset of AI...",
"The weather today is sunny...",
"Deep learning uses neural networks..."
],
"top_n": 2
}
```
Supports:
- String documents or dict documents with 'text' field
- Optional top_n to limit results
- Optional return_documents to include document text in response
"""
if _server_state.oq_manager and _server_state.oq_manager.is_quantizing:
raise HTTPException(
status_code=503,
detail="Server is busy with oQ quantization. Please try again after quantization completes.",
)
# Validate the model up front (resolves + loads + type-checks). The
# eviction-proof lease is held only around the actual rerank() call below.
await get_reranker_engine(request.model)
# Preserve original structure for the engine (multimodal rerankers need
# dicts with 'image'), but keep a normalized text view for logging and
# emptiness checks.
documents_raw = request.documents
documents_text = normalize_documents(documents_raw)
if not documents_text:
raise HTTPException(status_code=400, detail="Documents cannot be empty")
if not request.query:
raise HTTPException(status_code=400, detail="Query cannot be empty")
# Perform reranking
start_time = time.perf_counter()
async with acquire_reranker_engine(request.model) as engine:
output = await engine.rerank(
query=request.query,
documents=documents_raw,
top_n=request.top_n,
)
elapsed = time.perf_counter() - start_time
logger.info(
f"Rerank: {len(documents_raw)} docs, "
f"{output.total_tokens} tokens in {elapsed:.3f}s"
)
get_server_metrics().record_request_complete(
prompt_tokens=output.total_tokens,
completion_tokens=0,
cached_tokens=0,
prefill_duration=elapsed,
model_id=resolve_model_id(request.model) or request.model,
)
# Format response - results sorted by score (descending). Strings wrap
# into {"text": "..."}; dict inputs pass through as-is so multimodal
# callers get their original 'image' back.
results = []
for idx in output.indices:
if request.return_documents:
orig = documents_raw[idx]
display_doc = orig if isinstance(orig, dict) else {"text": orig}
else:
display_doc = None
result = RerankResult(
index=idx,
relevance_score=output.scores[idx],
document=display_doc,
)
results.append(result)
return RerankResponse(
results=results,
model=request.model,
usage=RerankUsage(total_tokens=output.total_tokens),
)
# =============================================================================
# Completion Endpoints
# =============================================================================
@app.post("/v1/completions")
async def create_completion(
request: CompletionRequest,
http_request: FastAPIRequest,
_: bool = Depends(verify_api_key),
):
"""Create a text completion."""
if _server_state.oq_manager and _server_state.oq_manager.is_quantizing:
raise HTTPException(
status_code=503,
detail="Server is busy with oQ quantization. Please try again after quantization completes.",
)
lease = _LLMEngineLease()
try:
load_start = time.perf_counter()
engine = await get_engine_for_model(request.model, lease=lease)
model_load_duration = time.perf_counter() - load_start
# Handle single prompt or list of prompts
prompts = (
request.prompt if isinstance(request.prompt, list) else [request.prompt]
)
# Validate context window for each prompt
prompt_token_ids_by_prompt = []
for prompt in prompts:
prompt_token_ids = list(engine.tokenizer.encode(prompt))
prompt_token_ids_by_prompt.append(prompt_token_ids)
validate_context_window(len(prompt_token_ids), request.model)
# Pre-flight prefill memory guard — see create_chat_completion for
# the reason this must precede any StreamingResponse return.
# Thread the client-provided X-Request-ID when present so the 400
# log line and the FastAPI handler trace correlate with whatever
# the client is using on its side.
upstream_request_id = http_request.headers.get("x-request-id")
await _raise_if_llm_lease_abort_requested(lease)
for prompt in prompts:
await engine.preflight_completion(prompt, request_id=upstream_request_id)
await _raise_if_llm_lease_abort_requested(lease)
if request.stream:
return StreamingResponse(
_release_after_stream(
_with_sse_keepalive(
stream_completion(
engine,
prompts[0],
request,
model_load_duration=model_load_duration,
prompt_token_ids=prompt_token_ids_by_prompt[0],
),
http_request=http_request,
keepalive_chunk=_resolve_keepalive("openai_completion"),
),
lease,
),
media_type="text/event-stream",
headers={"X-Accel-Buffering": "no", "Cache-Control": "no-cache"},
)
# Non-streaming response with keepalive during prefill
async def _build_completion():
await _raise_if_llm_lease_abort_requested(lease)
start_time = time.perf_counter()
choices = []
total_completion_tokens = 0
total_prompt_tokens = 0
total_cached_tokens = 0
(
temperature,
top_p,
top_k,
repetition_penalty,
min_p,
presence_penalty,
frequency_penalty,
max_tokens,
xtc_probability,
xtc_threshold,
) = get_sampling_params(
request.temperature,
request.top_p,
request.model,
req_top_k=getattr(request, "top_k", None),
req_repetition_penalty=getattr(request, "repetition_penalty", None),
req_min_p=getattr(request, "min_p", None),
req_presence_penalty=getattr(request, "presence_penalty", None),
req_frequency_penalty=getattr(request, "frequency_penalty", None),
req_max_tokens=request.max_tokens,
req_xtc_probability=getattr(request, "xtc_probability", None),
req_xtc_threshold=getattr(request, "xtc_threshold", None),
)
gen_kwargs = {}
thinking_budget = _resolve_thinking_budget(request, request.model)
if thinking_budget is not None:
gen_kwargs["thinking_budget"] = thinking_budget
for i, prompt in enumerate(prompts):
output = await engine.generate(
prompt=prompt,
max_tokens=max_tokens,
temperature=temperature,
top_p=top_p,
top_k=top_k,
min_p=min_p,
repetition_penalty=repetition_penalty,
presence_penalty=presence_penalty,
frequency_penalty=frequency_penalty,
xtc_probability=xtc_probability,
xtc_threshold=xtc_threshold,
stop=request.stop,
seed=request.seed,
**gen_kwargs,
)
choices.append(
CompletionChoice(
index=i,
text=output.text,
finish_reason=output.finish_reason,
)
)
total_completion_tokens += output.completion_tokens
total_prompt_tokens += output.prompt_tokens
total_cached_tokens += output.cached_tokens
elapsed = time.perf_counter() - start_time
tokens_per_sec = total_completion_tokens / elapsed if elapsed > 0 else 0
logger.info(
f"Completion: {total_completion_tokens} tokens in {elapsed:.2f}s ({tokens_per_sec:.1f} tok/s), prompt: {total_prompt_tokens}"
)
get_server_metrics().record_request_complete(
prompt_tokens=total_prompt_tokens,
completion_tokens=total_completion_tokens,
cached_tokens=total_cached_tokens,
generation_duration=elapsed,
model_id=resolve_model_id(request.model) or request.model,
)
return CompletionResponse(
model=request.model,
choices=choices,
usage=Usage(
prompt_tokens=total_prompt_tokens,
completion_tokens=total_completion_tokens,
total_tokens=total_prompt_tokens + total_completion_tokens,
prompt_tokens_details=PromptTokensDetails(
cached_tokens=total_cached_tokens,
),
model_load_duration=(
round(model_load_duration, 2)
if model_load_duration > 1.0
else None
),
total_time=round(elapsed, 2),
),
).model_dump_json(exclude_none=True)
return StreamingResponse(
_release_after_stream(
_with_json_keepalive(http_request, _build_completion()),
lease,
),
media_type="application/json",
)
except BaseException:
await lease.release()
raise
@app.post("/v1/chat/completions")
async def create_chat_completion(
request: ChatCompletionRequest,
http_request: FastAPIRequest,
_: bool = Depends(verify_api_key),
):
"""
Create a chat completion.
Structured output (JSON mode):
```json
response_format={"type": "json_object"}
```
Structured output (JSON Schema):
```json
response_format={
"type": "json_schema",
"json_schema": {
"name": "my_schema",
"schema": {"type": "object", "properties": {...}}
}
}
```
"""
# Log incoming request summary at debug, message content at trace
logger.debug(
f"Chat completion request received: model={request.model}, "
f"messages={len(request.messages)}, stream={request.stream}, "
f"max_tokens={request.max_tokens}, temp={request.temperature}"
)
if logger.isEnabledFor(5):
for i, msg in enumerate(request.messages):
content_preview = str(msg.content)[:200] if msg.content else "(empty)"
logger.log(
5, " Message[%d]: role=%s, content=%s...", i, msg.role, content_preview
)
if is_markitdown_model(request.model):
return await _create_markitdown_chat_completion(request, http_request)
request = await _preprocess_markitdown_files_for_llm(request)
# Block inference during quantization to prevent GPU Metal errors
if _server_state.oq_manager and _server_state.oq_manager.is_quantizing:
raise HTTPException(
status_code=503,
detail="Server is busy with oQ quantization. Please try again after quantization completes.",
)
lease = _LLMEngineLease()
try:
load_start = time.perf_counter()
engine = await get_engine_for_model(request.model, lease=lease)
model_load_duration = time.perf_counter() - load_start
# Resolve alias to real model ID for settings lookups
resolved_model = resolve_model_id(request.model) or request.model
# Get per-model settings
max_tool_result_tokens = None
merged_ct_kwargs = {}
forced_keys: set[str] = set()
reasoning_parser = None
settings_guided_grammar = None
ms = get_model_settings_for_request(request.model)
if ms:
max_tool_result_tokens = ms.max_tool_result_tokens
reasoning_parser = ms.reasoning_parser
settings_guided_grammar = _settings_guided_grammar(ms)
if ms.chat_template_kwargs:
merged_ct_kwargs.update(ms.chat_template_kwargs)
forced_keys = set(ms.forced_ct_kwargs or [])
# Dedicated enable_thinking toggle takes precedence over chat_template_kwargs
if ms.enable_thinking is not None:
merged_ct_kwargs["enable_thinking"] = ms.enable_thinking
# preserve_thinking: keep <think> blocks in historical turns (Qwen 3.6+)
if ms.preserve_thinking is not None:
merged_ct_kwargs["preserve_thinking"] = ms.preserve_thinking
# Per-request kwargs override model settings (except forced keys)
if request.chat_template_kwargs:
for k, v in request.chat_template_kwargs.items():
if k not in forced_keys:
merged_ct_kwargs[k] = v
# Extract messages - different engines need different content handling.
# Templates that expose message.reasoning_content natively (Qwen 3.6+)
# get reasoning as a separate field; others fall back to <think> inlined
# in content.
_entry = get_engine_pool().get_entry(resolved_model)
native_reasoning = uses_native_reasoning_content(
resolved_model,
config_model_type=(
getattr(_entry, "config_model_type", None)
if _entry is not None
else None
),
engine_model_type=getattr(engine, "model_type", None),
preserve_thinking_default=(
getattr(_entry, "preserve_thinking_default", None)
if _entry is not None
else None
),
)
is_vlm = isinstance(engine, VLMBatchedEngine)
is_dflash_vlm = not is_vlm and getattr(
engine, "supports_multimodal_fallback", False
)
extractor = getattr(engine, "message_extractor", None)
merge_system_fallback_roles = not (is_vlm or is_dflash_vlm)
if extractor is not None:
extractor_kwargs = {}
try:
if (
"consolidate_system_messages"
in inspect.signature(extractor).parameters
):
extractor_kwargs["consolidate_system_messages"] = False
except (TypeError, ValueError):
pass
messages = extractor(
request.messages,
max_tool_result_tokens,
engine.tokenizer,
**extractor_kwargs,
)
merge_system_fallback_roles = True
elif is_vlm or is_dflash_vlm:
# VLM or DFlash with VLM fallback: preserve image_url content parts
messages = extract_multimodal_content(
request.messages,
max_tool_result_tokens,
engine.tokenizer,
native_reasoning_content=native_reasoning,
consolidate_system_messages=False,
)
else:
messages = extract_text_content(
request.messages,
max_tool_result_tokens,
engine.tokenizer,
native_reasoning_content=native_reasoning,
consolidate_system_messages=False,
)
# Detect and strip partial mode at the API boundary — exactly once,
# before any chat template application. The boolean result is forwarded
# as an explicit parameter so the engine never has to re-derive it.
is_partial = detect_and_strip_partial(messages)
# Compile grammar for structured output (logit-level enforcement).
# Grammar compilation needs the tokenizer, so ensure the engine is loaded.
response_format = request.response_format
guided_grammar = _effective_guided_grammar(
structured_outputs=request.structured_outputs,
response_format=response_format,
request_guided_grammar=request.guided_grammar,
settings_guided_grammar=settings_guided_grammar,
)
structured_outputs = _normalize_structured_outputs(
request.structured_outputs,
guided_grammar,
)
_reject_diffusion_structured_outputs(
engine,
response_format=response_format,
structured_outputs=structured_outputs,
guided_grammar=guided_grammar,
)
if structured_outputs is not None or response_format:
await engine.start()
compiled_grammar = _compile_grammar_for_request(
engine,
structured_outputs=structured_outputs,
response_format=response_format,
chat_template_kwargs=merged_ct_kwargs or None,
reasoning_parser=reasoning_parser,
)
# Fall back to prompt injection when grammar is not compiled. The degrade
# is also surfaced to the caller as a Warning response header (#1241).
# Only response formats that actually request grammar-constrained JSON
# (json_object / json_schema) can be "unenforced"; a plain text format
# never asked for enforcement, so it must not warn (#1241 review).
response_format_warning = None
if compiled_grammar is None and _response_format_requests_grammar(
response_format
):
response_format_warning = _response_format_warning_header(response_format)
json_instruction = build_json_system_prompt(response_format)
if json_instruction:
messages = _inject_json_instruction(messages, json_instruction)
# Merge MCP tools with user-provided tools unless the request explicitly
# disables tool use.
tools_disabled = request.tool_choice == "none"
if getattr(engine, "is_diffusion_model", False) and not getattr(
engine, "supports_tool_calling", False
):
if request.tools and not tools_disabled:
raise InvalidRequestError(
"Tool calling is not supported for this diffusion model "
"(no tool parser matched its chat template).",
field="tools",
)
tools_disabled = True
effective_tools = None if tools_disabled else request.tools
if _server_state.mcp_manager and not tools_disabled:
# Convert Pydantic ToolDefinition models to dicts for merge_tools
user_tools_dicts = (
[t.model_dump() for t in request.tools] if request.tools else None
)
effective_tools = _server_state.mcp_manager.get_merged_tools(
user_tools_dicts
)
# Validate context window before sending to model
tools_for_template = (
convert_tools_for_template(effective_tools) if effective_tools else None
)
# Gemma 4 drops required params that lack descriptions — enrich them
if tools_for_template and "gemma" in (resolved_model or "").lower():
tools_for_template = enrich_tool_params_for_gemma4(tools_for_template)
await _ensure_tokenizer_for_system_probe(engine, messages)
messages = prepare_system_messages_for_template(
messages,
engine.tokenizer,
tools=tools_for_template,
chat_template_kwargs=merged_ct_kwargs or None,
is_partial=is_partial,
merge_consecutive_roles=merge_system_fallback_roles,
unsupported_mid_system_policy=_unsupported_mid_system_policy(),
)
try:
num_prompt_tokens = engine.count_chat_tokens(
messages,
tools_for_template,
chat_template_kwargs=merged_ct_kwargs or None,
is_partial=is_partial,
)
except Exception as e:
# Catch chat template rendering failures: Jinja2 TemplateError,
# AssertionError from strict role validation, ValueError, etc.
err_name = type(e).__name__.lower()
err_msg = str(e).lower()
if (
"template" in err_name
or "template" in err_msg
or isinstance(e, (AssertionError, ValueError))
):
raise HTTPException(status_code=400, detail=f"Chat template error: {e}")
raise
validate_context_window(num_prompt_tokens, request.model)
# Prepare kwargs
(
temperature,
top_p,
top_k,
repetition_penalty,
min_p,
presence_penalty,
frequency_penalty,
max_tokens,
xtc_probability,
xtc_threshold,
) = get_sampling_params(
request.temperature,
request.top_p,
request.model,
req_top_k=getattr(request, "top_k", None),
req_repetition_penalty=getattr(request, "repetition_penalty", None),
req_min_p=getattr(request, "min_p", None),
req_presence_penalty=getattr(request, "presence_penalty", None),
req_frequency_penalty=getattr(request, "frequency_penalty", None),
req_max_tokens=request.max_tokens,
req_xtc_probability=getattr(request, "xtc_probability", None),
req_xtc_threshold=getattr(request, "xtc_threshold", None),
)
chat_kwargs = {
"max_tokens": max_tokens,
"temperature": temperature,
"top_p": top_p,
"top_k": top_k,
"min_p": min_p,
"repetition_penalty": repetition_penalty,
"presence_penalty": presence_penalty,
"frequency_penalty": frequency_penalty,
"xtc_probability": xtc_probability,
"xtc_threshold": xtc_threshold,
}
# Add seed for reproducible generation (best-effort)
if request.seed is not None:
chat_kwargs["seed"] = request.seed
# Add thinking budget if applicable
thinking_budget = _resolve_thinking_budget(request, request.model)
if thinking_budget is not None:
chat_kwargs["thinking_budget"] = thinking_budget
# Auto-set enable_thinking in chat template kwargs when a thinking
# budget is active (from request or model settings). Some chat
# templates (e.g. Gemma 4) explicitly suppress thinking unless this
# kwarg is True.
if thinking_budget is not None and "enable_thinking" not in merged_ct_kwargs:
merged_ct_kwargs["enable_thinking"] = True
# Auto-set preserve_thinking only when the template advertises support
# for it (Qwen 3.6+). Other templates silently ignore unknown kwargs
# today but strict templates could raise, so gate on the detected flag.
_entry = get_engine_pool().get_entry(resolved_model)
if (
_entry is not None
and _entry.preserve_thinking_default is True
and merged_ct_kwargs.get("enable_thinking") is not False
and "preserve_thinking" not in merged_ct_kwargs
):
merged_ct_kwargs["preserve_thinking"] = True
# Add compiled grammar for logit-level structured output.
# When a reasoning_parser is configured, the structural tag includes
# a thinking phase — auto-set a thinking_budget so the model exits
# the reasoning phase and the grammar can activate.
if compiled_grammar is not None:
chat_kwargs["compiled_grammar"] = compiled_grammar
if reasoning_parser and "thinking_budget" not in chat_kwargs:
default_budget = min(max_tokens // 2, 4096)
chat_kwargs["thinking_budget"] = default_budget
logger.debug(
"Auto-set thinking_budget=%d for grammar-constrained request",
default_budget,
)
# Add tools if provided (includes MCP tools)
if tools_for_template:
chat_kwargs["tools"] = tools_for_template
# Add chat template kwargs
if merged_ct_kwargs:
chat_kwargs["chat_template_kwargs"] = merged_ct_kwargs
# Forward partial-mode decision to the engine explicitly
chat_kwargs["is_partial"] = is_partial
# SpecPrefill: per-request overrides (fall back to model_settings)
if request.specprefill is not None:
chat_kwargs["specprefill"] = request.specprefill
if request.specprefill_keep_pct is not None:
chat_kwargs["specprefill_keep_pct"] = request.specprefill_keep_pct
elif _server_state.settings_manager and ms.specprefill_keep_pct is not None:
chat_kwargs["specprefill_keep_pct"] = ms.specprefill_keep_pct
if getattr(request, "specprefill_threshold", None) is not None:
chat_kwargs["specprefill_threshold"] = request.specprefill_threshold
elif _server_state.settings_manager and ms.specprefill_threshold is not None:
chat_kwargs["specprefill_threshold"] = ms.specprefill_threshold
if request.stop:
chat_kwargs["stop"] = request.stop
# Pre-flight prefill memory guard. Must run BEFORE either branch wraps
# the response in a StreamingResponse — starlette emits
# http.response.start (status 200) before iterating the body generator,
# so a typed exception thrown later by add_request lands as "Caught
# handled exception, but response already started" and the client sees
# an incomplete chunked read. Running the check here lets
# prefill_memory_exceeded_handler return a clean HTTP 400.
await _raise_if_llm_lease_abort_requested(lease)
await engine.preflight_chat(
messages,
request_id=http_request.headers.get("x-request-id"),
**chat_kwargs,
)
await _raise_if_llm_lease_abort_requested(lease)
if request.stream:
# Pre-mint the completion id so the keepalive frame (emitted before the
# generator starts) can share it. See _chat_keepalive_chunk.
response_id = f"chatcmpl-{uuid.uuid4().hex[:8]}"
keepalive = _resolve_keepalive("openai_chat")
if keepalive == _KEEPALIVE_CHAT_CHUNK:
keepalive = _chat_keepalive_chunk(response_id)
sse_headers = {"X-Accel-Buffering": "no", "Cache-Control": "no-cache"}
if response_format_warning:
sse_headers["Warning"] = response_format_warning
return StreamingResponse(
_release_after_stream(
_with_sse_keepalive(
stream_chat_completion(
engine,
messages,
request,
model_load_duration=model_load_duration,
resolved_model=resolved_model,
response_id=response_id,
**chat_kwargs,
),
http_request=http_request,
keepalive_chunk=keepalive,
),
lease,
),
media_type="text/event-stream",
headers=sse_headers,
)
# Non-streaming response with keepalive during prefill
async def _build_chat_completion():
await _raise_if_llm_lease_abort_requested(lease)
start_time = time.perf_counter()
output = await engine.chat(messages=messages, **chat_kwargs)
elapsed = time.perf_counter() - start_time
tokens_per_sec = output.completion_tokens / elapsed if elapsed > 0 else 0
is_diffusion = getattr(engine, "is_diffusion_model", False)
speed_text = _format_generation_speed_for_log(
output,
tokens_per_sec,
is_diffusion=is_diffusion,
)
logger.info(
f"Chat completion: {output.completion_tokens} tokens in {elapsed:.2f}s "
f"({speed_text}), prompt: {output.prompt_tokens}, "
f"finish_reason={output.finish_reason}, max_tokens={max_tokens}, "
f"request_max_tokens={request.max_tokens}"
)
metric_prefill_duration, metric_gen_duration = _resolve_metric_durations(
output,
is_diffusion=is_diffusion,
generation_duration=elapsed,
)
get_server_metrics().record_request_complete(
prompt_tokens=output.prompt_tokens,
completion_tokens=output.completion_tokens,
cached_tokens=output.cached_tokens,
prefill_duration=metric_prefill_duration,
generation_duration=metric_gen_duration,
model_id=resolved_model,
)
# Separate thinking from content
raw_text = clean_special_tokens(output.text) if output.text else ""
thinking_content, regular_content = extract_thinking(raw_text)
cleaned_thinking = sanitize_tool_call_markup(
thinking_content, engine.tokenizer
)
# Protocol parsers can return structured tool_calls directly.
if output.tool_calls:
tool_calls = _convert_parser_tool_calls(output.tool_calls)
cleaned_text = regular_content
else:
extraction = extract_tool_calls_with_thinking(
thinking_content,
regular_content,
tokenizer=engine.tokenizer,
tools=tools_for_template,
)
cleaned_text = extraction.cleaned_text
tool_calls = extraction.tool_calls
cleaned_thinking = extraction.cleaned_thinking
# Process response_format if specified
if response_format and not tool_calls:
cleaned_text, parsed_json, is_valid, error = parse_json_output(
cleaned_text or regular_content, response_format
)
if parsed_json is not None:
cleaned_text = json.dumps(parsed_json)
if not is_valid:
logger.warning(f"JSON validation failed: {error}")
# Reverse Gemma 4 parameter renaming (param_description -> description)
if tool_calls and "gemma" in (resolved_model or "").lower():
for tc in tool_calls:
if tc.function and tc.function.arguments:
try:
args = json.loads(tc.function.arguments)
args = restore_gemma4_param_names(args)
tc.function.arguments = json.dumps(args, ensure_ascii=False)
except (json.JSONDecodeError, AttributeError):
pass
finish_reason = "tool_calls" if tool_calls else output.finish_reason
return ChatCompletionResponse(
model=request.model,
choices=[
ChatCompletionChoice(
message=AssistantMessage(
content=cleaned_text.strip() if cleaned_text else None,
reasoning_content=(
cleaned_thinking if cleaned_thinking else None
),
tool_calls=tool_calls,
),
finish_reason=finish_reason,
)
],
usage=Usage(
prompt_tokens=output.prompt_tokens,
completion_tokens=output.completion_tokens,
total_tokens=output.prompt_tokens + output.completion_tokens,
prompt_tokens_details=PromptTokensDetails(
cached_tokens=output.cached_tokens,
),
model_load_duration=(
round(model_load_duration, 2)
if model_load_duration > 1.0
else None
),
total_time=round(elapsed, 2),
),
).model_dump_json(exclude_none=True)
json_headers = (
{"Warning": response_format_warning} if response_format_warning else None
)
return StreamingResponse(
_release_after_stream(
_with_json_keepalive(http_request, _build_chat_completion()),
lease,
),
media_type="application/json",
headers=json_headers,
)
except BaseException:
await lease.release()
raise
def _inject_json_instruction(messages: list, instruction: str) -> list:
"""
Inject JSON instruction into messages.
If a system message exists, append to it. Otherwise, prepend a new system message.
"""
messages = list(messages) # Make a copy
# Only attach to a leading system message. A mid-conversation system
# message may be intentionally placed there to preserve prefix cache hits.
system_idx = None
if messages:
first = messages[0]
role = (
first.get("role")
if isinstance(first, dict)
else getattr(first, "role", None)
)
if role == "system":
system_idx = 0
if system_idx is not None:
# Append to existing system message
msg = messages[system_idx]
if isinstance(msg, dict):
existing = msg.get("content", "")
msg["content"] = f"{existing}\n\n{instruction}"
else:
existing = getattr(msg, "content", "") or ""
msg.content = f"{existing}\n\n{instruction}"
else:
# Prepend new system message
messages.insert(0, {"role": "system", "content": instruction})
return messages
def _normalize_structured_outputs(
structured_outputs=None, guided_grammar: str | None = None
):
"""Fold guided_grammar into the existing structured_outputs grammar shape."""
if structured_outputs is not None:
return structured_outputs
if guided_grammar:
return {"grammar": guided_grammar}
return None
def _reject_diffusion_structured_outputs(
engine: BaseEngine,
*,
response_format=None,
structured_outputs=None,
guided_grammar: str | None = None,
) -> None:
if not getattr(engine, "is_diffusion_model", False):
return
# ``response_format`` (json_object / json_schema) is NOT rejected here:
# it degrades to prompt-injected JSON with a Warning header, the same
# fallback used when xgrammar is unavailable (#1241). Only explicit
# grammar requests — ``structured_outputs`` and ``guided_grammar`` —
# are rejected, because logit-mask enforcement has no equivalent in
# the parallel denoising loop.
if structured_outputs is None and not guided_grammar:
return
raise InvalidRequestError(
"structured_outputs and guided grammar are not supported "
"with diffusion models (response_format degrades to "
"prompt-injected JSON).",
field="response_format",
)
def _settings_guided_grammar(settings) -> str | None:
"""Return a non-empty enabled model-level guided grammar."""
if not settings:
return None
if not getattr(settings, "guided_grammar_enabled", False):
return None
grammar = getattr(settings, "guided_grammar", None)
if not grammar:
return None
grammar = grammar.strip()
return grammar or None
def _effective_guided_grammar(
structured_outputs=None,
response_format=None,
request_guided_grammar: str | None = None,
settings_guided_grammar: str | None = None,
) -> str | None:
"""Choose the request grammar alias or eligible model default."""
if request_guided_grammar:
return request_guided_grammar
if structured_outputs is None and response_format is None:
return settings_guided_grammar
return None
def _build_format_element(structured_outputs=None, response_format=None):
"""Build an xgrammar structural-tag format element from the request.
Returns a format dict (e.g. ``{"type": "json_schema", ...}``) suitable
for embedding in a structural tag, or ``None`` if no grammar is needed.
Also returns ``"bare"`` compilation hint when the grammar should be
compiled directly (EBNF / regex / choice) rather than via structural tag.
"""
import json as _json
from .api.openai_models import StructuredOutputOptions
if structured_outputs is not None:
if isinstance(structured_outputs, dict):
structured_outputs = StructuredOutputOptions(**structured_outputs)
if structured_outputs.json_schema is not None:
schema = structured_outputs.json_schema
if isinstance(schema, str):
schema = _json.loads(schema)
return {"type": "json_schema", "json_schema": schema}
if structured_outputs.grammar is not None:
return {"type": "grammar", "grammar": structured_outputs.grammar}
if structured_outputs.regex is not None:
return {"type": "regex", "pattern": structured_outputs.regex}
if structured_outputs.choice is not None:
ebnf = "root ::= " + " | ".join(
_json.dumps(c) for c in structured_outputs.choice
)
return {"type": "grammar", "grammar": ebnf}
if response_format is not None:
rf = response_format
rf_type = rf.get("type") if isinstance(rf, dict) else getattr(rf, "type", None)
if rf_type == "json_schema":
js = (
rf.get("json_schema")
if isinstance(rf, dict)
else getattr(rf, "json_schema", None)
)
if js is not None:
schema = (
js.get("schema")
if isinstance(js, dict)
else getattr(js, "schema_", None)
)
if schema is not None:
return {"type": "json_schema", "json_schema": schema}
elif rf_type == "json_object":
return {"type": "json_schema", "json_schema": {}}
return None
def _patch_output_format(tag_dict: dict, user_grammar: dict) -> bool:
"""Replace the output ``any_text`` slot in a builtin structural tag.
Walks the structural tag dict produced by
``xgrammar.get_builtin_structural_tag`` and swaps the ``any_text``
element that represents the model's output with ``user_grammar``.
Returns ``True`` if a replacement was made.
"""
fmt = tag_dict.get("format", tag_dict)
if fmt.get("type") == "any_text":
tag_dict["format"] = user_grammar
return True
if fmt.get("type") == "sequence":
for i in range(len(fmt["elements"]) - 1, -1, -1):
if fmt["elements"][i].get("type") == "any_text":
fmt["elements"][i] = user_grammar
return True
if fmt.get("type") == "tags_with_separator":
for tag in reversed(fmt["tags"]):
if tag.get("type") == "tag" and "final" in tag.get("begin", ""):
tag["content"] = user_grammar
return True
if fmt["tags"]:
fmt["tags"][-1]["content"] = user_grammar
return True
return False
def _compile_with_structural_tag(
compiler, fmt: dict, reasoning_parser: str, chat_template_kwargs: dict | None
):
"""Compile a grammar wrapped in an xgrammar builtin structural tag.
Uses ``xgrammar.get_builtin_structural_tag`` to obtain the model's
protocol structure (thinking tags, channel markers, etc.) and patches
the user's grammar into the output slot.
"""
from omlx._torch_stub import install as _install_torch_stub
_install_torch_stub()
import xgrammar as xgr
reasoning = not (
chat_template_kwargs and chat_template_kwargs.get("enable_thinking") is False
)
tag = xgr.get_builtin_structural_tag(reasoning_parser, reasoning=reasoning)
tag_dict = tag.model_dump()
if not _patch_output_format(tag_dict, fmt):
logger.warning(
"Could not patch output format for reasoning_parser=%s, "
"compiling structural tag as-is",
reasoning_parser,
)
return compiler.compile_structural_tag(tag_dict)
def _compile_bare_grammar(compiler, fmt: dict):
"""Compile a grammar without any structural tag wrapping."""
if fmt["type"] == "json_schema":
import json as _json
schema = fmt["json_schema"]
if not schema:
return compiler.compile_builtin_json_grammar()
schema_str = _json.dumps(schema) if isinstance(schema, dict) else schema
return compiler.compile_json_schema(schema_str)
elif fmt["type"] == "grammar":
return compiler.compile_grammar(fmt["grammar"])
elif fmt["type"] == "regex":
return compiler.compile_regex(fmt["pattern"])
return None
def _response_format_requests_strict(response_format) -> bool:
"""True when an OpenAI ``response_format`` demands strict json_schema output.
A ``json_schema`` response_format with ``strict: true`` signals that the
caller expects schema-conformant output, not best-effort. When
grammar-constrained decoding is unavailable the request still falls back to
prompt injection, but the downgrade is logged at a level that names the
unhonored ``strict`` intent so it is not silent (issue #1241).
"""
if response_format is None:
return False
rf = response_format
rf_type = rf.get("type") if isinstance(rf, dict) else getattr(rf, "type", None)
if rf_type != "json_schema":
return False
js = (
rf.get("json_schema")
if isinstance(rf, dict)
else getattr(rf, "json_schema", None)
)
if js is None:
return False
strict = js.get("strict") if isinstance(js, dict) else getattr(js, "strict", None)
return bool(strict)
def _response_format_requests_grammar(response_format) -> bool:
"""True when an OpenAI ``response_format`` maps to grammar-constrained JSON.
Delegates to :func:`_build_format_element` so the unenforced-degrade signal
stays in sync with what actually gets compiled: a format earns the
Warning header / prompt-injection fallback only when a grammar element would
have been built for it. That is non-``None`` exactly for ``json_object``
and a ``json_schema`` carrying a schema; a plain ``{"type": "text"}`` (or a
json_schema with no schema) maps to nothing and must not warn. Sharing the
one source of truth keeps the header consistent with the server-side warn
log and avoids claiming "grammar-constrained decoding unavailable" for a
request that never described an enforceable grammar (#1241 review).
"""
if response_format is None:
return False
return _build_format_element(response_format=response_format) is not None
def _compile_grammar_for_request(
engine: BaseEngine,
structured_outputs=None,
response_format=None,
chat_template_kwargs=None,
reasoning_parser=None,
):
"""Compile a grammar from structured_outputs or response_format.
When ``reasoning_parser`` is set (e.g. ``"qwen"``, ``"harmony"``),
the user's grammar is wrapped in an xgrammar builtin structural tag
so that protocol tokens (thinking tags, channel markers) are handled
automatically. When not set, the grammar is compiled bare.
Returns a compiled grammar object or ``None``. ``structured_outputs``
raises :class:`HTTPException` when grammar is unavailable or fails to
compile. A ``response_format`` degrades to ``None`` so the caller can fall
back to prompt injection; the downgrade is logged (and named as an
unhonored strict request when ``strict: true`` was set) rather than being
silent (#1241).
"""
compiler = getattr(engine, "grammar_compiler", None)
fmt = _build_format_element(structured_outputs, response_format)
if fmt is None:
return None
if compiler is None:
if structured_outputs is not None:
from omlx.utils.install import get_install_method
method = get_install_method()
if method == "homebrew":
detail = (
"Structured output requires xgrammar. "
"Reinstall with: brew reinstall omlx --with-grammar"
)
elif method == "dmg":
# DMG bundles xgrammar with a torch stub; reaching this
# branch means the bundled load failed (e.g. native binding
# incompatibility). Surface it instead of pointing users to
# a different install method.
detail = (
"Structured output is unavailable: xgrammar failed to "
"load in this build. Please report this issue."
)
else:
detail = (
"Structured output requires xgrammar. "
"Install with: pip install 'omlx[grammar]'"
)
raise HTTPException(status_code=400, detail=detail)
if response_format is not None:
_warn_response_format_not_enforced(response_format)
return None
try:
if reasoning_parser:
return _compile_with_structural_tag(
compiler,
fmt,
reasoning_parser,
chat_template_kwargs,
)
return _compile_bare_grammar(compiler, fmt)
except Exception as e:
if structured_outputs is not None:
raise HTTPException(
status_code=400,
detail=f"Grammar compilation error: {e}",
)
_warn_response_format_not_enforced(response_format, error=e)
return None
def _warn_response_format_not_enforced(response_format, error=None):
"""Log that a ``response_format`` request fell back to prompt injection.
Previously a ``response_format`` that could not be grammar-constrained
(no compiler available, or a compilation error) degraded to best-effort
prompt injection silently, giving the client no signal that the schema was
not enforced (#1241). A ``strict: true`` request gets a message that names
the unhonored strict intent.
"""
reason = f" ({error})" if error is not None else ""
if _response_format_requests_strict(response_format):
logger.warning(
"response_format requested strict json_schema output but "
"grammar-constrained decoding is unavailable; strict enforcement "
"cannot be honored, falling back to best-effort prompt injection "
"(output is NOT schema-enforced)%s.",
reason,
)
else:
logger.warning(
"response_format requested but grammar-constrained decoding is "
"unavailable; output will not be schema-enforced (falling back to "
"prompt injection)%s.",
reason,
)
def _response_format_warning_header(response_format) -> str:
"""Build an RFC 7234 ``Warning`` header for an unenforced response_format.
The server already logs the downgrade (see
:func:`_warn_response_format_not_enforced`), but that signal is only
visible to the operator. This header surfaces the same fact to the API
caller so a client can tell that ``response_format`` fell back to
best-effort prompt injection rather than schema-enforced output (#1241).
Header values must be single-line ASCII, so the text is terse.
"""
if _response_format_requests_strict(response_format):
text = (
"response_format strict json_schema not enforced; "
"grammar-constrained decoding unavailable, output is "
"best-effort and NOT schema-enforced"
)
else:
text = (
"response_format not enforced; grammar-constrained decoding "
"unavailable, output is best-effort"
)
return f'199 omlx "{text}"'
# =============================================================================
# Streaming Helpers
# =============================================================================
async def stream_completion(
engine: BaseEngine,
prompt: str,
request: CompletionRequest,
model_load_duration: float = 0.0,
prompt_token_ids: list[int] | None = None,
) -> AsyncIterator[str]:
"""Stream completion response."""
start_time = time.perf_counter()
first_token_time = None
last_output = None
# Parity with the non-streaming path: when the prompt opens a thinking
# block, the first chunk carries the scheduler's synthetic think opener;
# strip it once so the stream is a pure continuation of the prompt.
pending_think_prefix_strip, think_tag = prompt_opens_thinking(
getattr(engine, "tokenizer", None), prompt, prompt_token_ids=prompt_token_ids
)
(
temperature,
top_p,
top_k,
repetition_penalty,
min_p,
presence_penalty,
frequency_penalty,
max_tokens,
xtc_probability,
xtc_threshold,
) = get_sampling_params(
request.temperature,
request.top_p,
request.model,
req_top_k=getattr(request, "top_k", None),
req_repetition_penalty=getattr(request, "repetition_penalty", None),
req_min_p=getattr(request, "min_p", None),
req_presence_penalty=getattr(request, "presence_penalty", None),
req_frequency_penalty=getattr(request, "frequency_penalty", None),
req_max_tokens=request.max_tokens,
req_xtc_probability=getattr(request, "xtc_probability", None),
req_xtc_threshold=getattr(request, "xtc_threshold", None),
)
gen_kwargs = {}
thinking_budget = _resolve_thinking_budget(request, request.model)
if thinking_budget is not None:
gen_kwargs["thinking_budget"] = thinking_budget
try:
async for output in engine.stream_generate(
prompt=prompt,
max_tokens=max_tokens,
temperature=temperature,
top_p=top_p,
top_k=top_k,
min_p=min_p,
repetition_penalty=repetition_penalty,
presence_penalty=presence_penalty,
frequency_penalty=frequency_penalty,
xtc_probability=xtc_probability,
xtc_threshold=xtc_threshold,
stop=request.stop,
seed=request.seed,
**gen_kwargs,
):
if first_token_time is None and output.new_text:
first_token_time = time.perf_counter()
last_output = output
chunk_text = output.new_text
if pending_think_prefix_strip and chunk_text:
chunk_text = _strip_synthetic_think_prefix(chunk_text, think_tag)
pending_think_prefix_strip = False
data = {
"id": f"cmpl-{uuid.uuid4().hex[:8]}",
"object": "text_completion",
"created": int(time.time()),
"model": request.model,
"choices": [
{
"index": 0,
"text": chunk_text,
"finish_reason": (
output.finish_reason if output.finished else None
),
}
],
}
yield f"data: {json.dumps(data)}\n\n"
except Exception as e:
logger.error(f"Error during completion streaming: {e}")
error_data = {"error": {"message": str(e), "type": "server_error"}}
yield f"data: {json.dumps(error_data)}\n\n"
yield "data: [DONE]\n\n"
return
# Record metrics
if last_output and last_output.finished:
end_time = time.perf_counter()
total_duration = end_time - start_time
ttft = (first_token_time - start_time) if first_token_time else total_duration
is_diffusion = getattr(engine, "is_diffusion_model", False)
if is_diffusion:
gen_duration = total_duration
else:
gen_duration = end_time - (first_token_time or start_time)
metric_prefill_duration, metric_gen_duration = _resolve_metric_durations(
last_output,
is_diffusion=is_diffusion,
prefill_duration=ttft,
generation_duration=gen_duration,
)
get_server_metrics().record_request_complete(
prompt_tokens=last_output.prompt_tokens,
completion_tokens=last_output.completion_tokens,
cached_tokens=last_output.cached_tokens,
prefill_duration=metric_prefill_duration,
generation_duration=metric_gen_duration,
model_id=resolve_model_id(request.model) or request.model,
)
speed_duration = total_duration if is_diffusion else gen_duration
tokens_per_sec = (
last_output.completion_tokens / speed_duration if speed_duration > 0 else 0
)
speed_text = _format_generation_speed_for_log(
last_output,
tokens_per_sec,
is_diffusion=is_diffusion,
)
logger.info(
f"Completion: {last_output.completion_tokens} tokens in "
f"{total_duration:.2f}s ({speed_text}), "
f"prompt: {last_output.prompt_tokens}"
)
# Emit usage chunk if requested
if request.stream_options and request.stream_options.include_usage:
total_time = end_time - start_time
pt = last_output.prompt_tokens
ct = last_output.completion_tokens
usage_data = {
"id": f"cmpl-{uuid.uuid4().hex[:8]}",
"object": "text_completion",
"created": int(time.time()),
"model": request.model,
"choices": [],
"usage": Usage(
prompt_tokens=pt,
completion_tokens=ct,
total_tokens=pt + ct,
prompt_tokens_details=PromptTokensDetails(
cached_tokens=last_output.cached_tokens,
),
model_load_duration=(
round(model_load_duration, 2)
if model_load_duration > 1.0
else None
),
time_to_first_token=round(ttft, 2),
total_time=round(total_time, 2),
prompt_eval_duration=round(metric_prefill_duration, 2),
generation_duration=round(metric_gen_duration, 2),
prompt_tokens_per_second=(
round(pt / metric_prefill_duration, 2)
if metric_prefill_duration > 0
else None
),
generation_tokens_per_second=(
round(ct / metric_gen_duration, 2)
if metric_gen_duration > 0
else None
),
).model_dump(exclude_none=True),
}
yield f"data: {json.dumps(usage_data)}\n\n"
yield "data: [DONE]\n\n"
def _copy_chat_template_messages(messages: list) -> list:
return [
dict(message) if isinstance(message, dict) else message for message in messages
]
def _render_chat_prompt_for_thinking_detection(
engine: BaseEngine,
messages: list,
kwargs: dict,
) -> tuple[str, list[int] | None]:
tokenizer = getattr(engine, "tokenizer", None)
if tokenizer is None:
return "", None
template_messages = _copy_chat_template_messages(messages)
tools = kwargs.get("tools")
chat_template_kwargs = kwargs.get("chat_template_kwargs")
is_partial = kwargs.get("is_partial")
engine_renderer = getattr(engine, "_apply_chat_template", None)
if is_partial is not None:
for message in template_messages:
if isinstance(message, dict):
message.pop("partial", None)
if callable(engine_renderer):
prompt = engine_renderer(
template_messages,
tools,
chat_template_kwargs=chat_template_kwargs,
is_partial=is_partial,
)
else:
template_kwargs = {
"tokenize": False,
"add_generation_prompt": not bool(is_partial),
}
if is_partial:
template_kwargs["continue_final_message"] = True
if tools:
template_kwargs["tools"] = tools
if chat_template_kwargs:
template_kwargs.update(chat_template_kwargs)
try:
prompt = tokenizer.apply_chat_template(template_messages, **template_kwargs)
except TypeError:
if chat_template_kwargs:
for key in chat_template_kwargs:
template_kwargs.pop(key, None)
template_kwargs.pop("tools", None)
template_kwargs.pop("enable_thinking", None)
prompt = tokenizer.apply_chat_template(template_messages, **template_kwargs)
if isinstance(prompt, str):
return prompt, None
if isinstance(prompt, list):
try:
return "", [int(token_id) for token_id in prompt]
except (TypeError, ValueError):
return str(prompt), None
return str(prompt), None
async def stream_chat_completion(
engine: BaseEngine,
messages: list,
request: ChatCompletionRequest,
model_load_duration: float = 0.0,
resolved_model: Optional[str] = None,
response_id: Optional[str] = None,
**kwargs,
) -> AsyncIterator[str]:
"""Stream chat completion response.
Streams content tokens with reasoning/thinking separation, then at
completion parses tool calls from accumulated text and emits them
as structured tool_calls chunks (OpenAI streaming format).
"""
start_time = time.perf_counter()
first_token_time = None
last_output = None
accumulated_text = ""
has_tools = bool(kwargs.get("tools"))
start_in_thinking = False
try:
tokenizer = getattr(engine, "tokenizer", None)
if tokenizer is not None:
prompt, prompt_token_ids = _render_chat_prompt_for_thinking_detection(
engine, messages, kwargs
)
start_in_thinking, _ = prompt_opens_thinking(
tokenizer, prompt, prompt_token_ids=prompt_token_ids
)
except Exception as exc:
logger.debug("Could not detect chat stream thinking state: %s", exc)
thinking_parser = ThinkingParser(start_in_thinking=start_in_thinking)
# Reuse the id pre-minted by the caller (so the keepalive frame can share
# it); otherwise mint one for direct/non-streaming callers.
response_id = response_id or f"chatcmpl-{uuid.uuid4().hex[:8]}"
# First chunk with role
first_chunk = ChatCompletionChunk(
id=response_id,
model=request.model,
choices=[
ChatCompletionChunkChoice(
delta=ChatCompletionChunkDelta(role="assistant"),
)
],
)
yield f"data: {first_chunk.model_dump_json(exclude_none=True)}\n\n"
# Stream content token-by-token. When tools are present, a
# ToolCallStreamFilter suppresses known tool-call control markup so
# clients do not see raw envelopes/tags in assistant content deltas.
tool_filter = None
thinking_filter = None
stream_content = True
if has_tools:
_content_filter = ToolCallStreamFilter(engine.tokenizer)
_thinking_filter = ToolCallStreamFilter(engine.tokenizer)
if _content_filter.active:
tool_filter = _content_filter
thinking_filter = _thinking_filter
else:
stream_content = False
try:
async for output in engine.stream_chat(messages=messages, **kwargs):
if first_token_time is None and output.new_text:
first_token_time = time.perf_counter()
last_output = output
if output.new_text:
accumulated_text += output.new_text
if stream_content and output.new_text:
thinking_delta, content_delta = thinking_parser.feed(output.new_text)
# Emit reasoning_content delta
if thinking_delta:
if thinking_filter:
thinking_delta = thinking_filter.feed(thinking_delta)
chunk = ChatCompletionChunk(
id=response_id,
model=request.model,
choices=[
ChatCompletionChunkChoice(
delta=ChatCompletionChunkDelta(
reasoning_content=thinking_delta
),
finish_reason=None,
)
],
)
if thinking_delta:
yield f"data: {chunk.model_dump_json(exclude_none=True)}\n\n"
# Emit content delta — filter out tool-call markup when
# tools are present so clients see clean streamed text.
if content_delta:
if tool_filter:
content_delta = tool_filter.feed(content_delta)
if content_delta:
chunk = ChatCompletionChunk(
id=response_id,
model=request.model,
choices=[
ChatCompletionChunkChoice(
delta=ChatCompletionChunkDelta(
content=content_delta
),
finish_reason=None,
)
],
)
yield f"data: {chunk.model_dump_json(exclude_none=True)}\n\n"
except Exception as e:
logger.error(f"Error during chat streaming: {e}")
error_data = {"error": {"message": str(e), "type": "server_error"}}
yield f"data: {json.dumps(error_data)}\n\n"
yield "data: [DONE]\n\n"
return
# Flush remaining buffered content from thinking/tool-call parsers
if stream_content:
thinking_delta, content_delta = thinking_parser.finish()
if thinking_delta:
if thinking_filter:
thinking_delta = thinking_filter.feed(thinking_delta)
if thinking_delta:
chunk = ChatCompletionChunk(
id=response_id,
model=request.model,
choices=[
ChatCompletionChunkChoice(
delta=ChatCompletionChunkDelta(
reasoning_content=thinking_delta
),
finish_reason=None,
)
],
)
yield f"data: {chunk.model_dump_json(exclude_none=True)}\n\n"
if thinking_filter:
remaining_thinking = thinking_filter.finish()
if remaining_thinking:
chunk = ChatCompletionChunk(
id=response_id,
model=request.model,
choices=[
ChatCompletionChunkChoice(
delta=ChatCompletionChunkDelta(
reasoning_content=remaining_thinking
),
finish_reason=None,
)
],
)
yield f"data: {chunk.model_dump_json(exclude_none=True)}\n\n"
if content_delta:
if tool_filter:
content_delta = tool_filter.feed(content_delta)
if content_delta:
chunk = ChatCompletionChunk(
id=response_id,
model=request.model,
choices=[
ChatCompletionChunkChoice(
delta=ChatCompletionChunkDelta(content=content_delta),
finish_reason=None,
)
],
)
yield f"data: {chunk.model_dump_json(exclude_none=True)}\n\n"
if tool_filter:
remaining = tool_filter.finish()
if remaining:
chunk = ChatCompletionChunk(
id=response_id,
model=request.model,
choices=[
ChatCompletionChunkChoice(
delta=ChatCompletionChunkDelta(content=remaining),
finish_reason=None,
)
],
)
yield f"data: {chunk.model_dump_json(exclude_none=True)}\n\n"
# Parse tool calls from accumulated text
tool_calls = None
cleaned_text = accumulated_text
if last_output and last_output.tool_calls:
# Protocol parser already extracted structured tool calls.
tool_calls = _convert_parser_tool_calls(last_output.tool_calls)
cleaned_text = ""
elif has_tools and accumulated_text:
# Separate thinking from content, then parse tool calls from content
# (falls back to thinking content for small models)
thinking_content, regular_content = extract_thinking(accumulated_text)
extraction = extract_tool_calls_with_thinking(
thinking_content,
regular_content,
tokenizer=engine.tokenizer,
tools=kwargs.get("tools"),
)
cleaned_text = extraction.cleaned_text
tool_calls = extraction.tool_calls
cleaned_thinking = extraction.cleaned_thinking
# Process response_format if specified
if request.response_format and not tool_calls:
cleaned_text, parsed_json, is_valid, error = parse_json_output(
cleaned_text, request.response_format
)
if parsed_json is not None:
cleaned_text = json.dumps(parsed_json)
if not is_valid:
logger.warning(f"JSON validation failed: {error}")
# Buffered mode: emit thinking and cleaned content now
if not stream_content:
if cleaned_thinking:
chunk = ChatCompletionChunk(
id=response_id,
model=request.model,
choices=[
ChatCompletionChunkChoice(
delta=ChatCompletionChunkDelta(
reasoning_content=cleaned_thinking
),
finish_reason=None,
)
],
)
yield f"data: {chunk.model_dump_json(exclude_none=True)}\n\n"
if cleaned_text:
chunk = ChatCompletionChunk(
id=response_id,
model=request.model,
choices=[
ChatCompletionChunkChoice(
delta=ChatCompletionChunkDelta(content=cleaned_text),
finish_reason=None,
)
],
)
yield f"data: {chunk.model_dump_json(exclude_none=True)}\n\n"
# Reverse Gemma 4 parameter renaming for streaming path
if tool_calls and "gemma" in (resolved_model or request.model or "").lower():
for tc in tool_calls:
if tc.function and tc.function.arguments:
try:
args = json.loads(tc.function.arguments)
args = restore_gemma4_param_names(args)
tc.function.arguments = json.dumps(args, ensure_ascii=False)
except (json.JSONDecodeError, AttributeError):
pass
# Emit tool call chunks if found
if tool_calls:
for i, tc in enumerate(tool_calls):
tc_chunk = ChatCompletionChunk(
id=response_id,
model=request.model,
choices=[
ChatCompletionChunkChoice(
delta=ChatCompletionChunkDelta(
tool_calls=[
{
"index": i,
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
],
),
)
],
)
yield f"data: {tc_chunk.model_dump_json(exclude_none=True)}\n\n"
# Final chunk with finish_reason
finish_reason = (
"tool_calls"
if tool_calls
else (last_output.finish_reason if last_output else "stop")
)
final_chunk = ChatCompletionChunk(
id=response_id,
model=request.model,
choices=[
ChatCompletionChunkChoice(
delta=ChatCompletionChunkDelta(),
finish_reason=finish_reason,
)
],
)
yield f"data: {final_chunk.model_dump_json(exclude_none=True)}\n\n"
# Record metrics and emit usage chunk
if last_output and last_output.finished:
end_time = time.perf_counter()
total_duration = end_time - start_time
ttft = (first_token_time - start_time) if first_token_time else total_duration
is_diffusion = getattr(engine, "is_diffusion_model", False)
if is_diffusion:
gen_duration = total_duration
else:
gen_duration = end_time - (first_token_time or start_time)
metric_prefill_duration, metric_gen_duration = _resolve_metric_durations(
last_output,
is_diffusion=is_diffusion,
prefill_duration=ttft,
generation_duration=gen_duration,
)
get_server_metrics().record_request_complete(
prompt_tokens=last_output.prompt_tokens,
completion_tokens=last_output.completion_tokens,
cached_tokens=last_output.cached_tokens,
prefill_duration=metric_prefill_duration,
generation_duration=metric_gen_duration,
model_id=resolved_model or request.model,
)
speed_duration = total_duration if is_diffusion else gen_duration
tokens_per_sec = (
last_output.completion_tokens / speed_duration if speed_duration > 0 else 0
)
speed_text = _format_generation_speed_for_log(
last_output,
tokens_per_sec,
is_diffusion=is_diffusion,
)
logger.info(
f"Chat completion: {last_output.completion_tokens} tokens in "
f"{total_duration:.2f}s ({speed_text}), "
f"prompt: {last_output.prompt_tokens}, finish_reason={finish_reason}, "
f"max_tokens={kwargs.get('max_tokens')}, "
f"request_max_tokens={request.max_tokens}"
)
# Emit usage chunk if requested
if request.stream_options and request.stream_options.include_usage:
total_time = end_time - start_time
pt = last_output.prompt_tokens
ct = last_output.completion_tokens
usage_chunk = ChatCompletionChunk(
id=response_id,
model=request.model,
choices=[],
usage=Usage(
prompt_tokens=pt,
completion_tokens=ct,
total_tokens=pt + ct,
prompt_tokens_details=PromptTokensDetails(
cached_tokens=last_output.cached_tokens,
),
model_load_duration=(
round(model_load_duration, 2)
if model_load_duration > 1.0
else None
),
time_to_first_token=round(ttft, 2),
total_time=round(total_time, 2),
prompt_eval_duration=round(metric_prefill_duration, 2),
generation_duration=round(metric_gen_duration, 2),
prompt_tokens_per_second=(
round(pt / metric_prefill_duration, 2)
if metric_prefill_duration > 0
else None
),
generation_tokens_per_second=(
round(ct / metric_gen_duration, 2)
if metric_gen_duration > 0
else None
),
),
)
yield f"data: {usage_chunk.model_dump_json(exclude_none=True)}\n\n"
yield "data: [DONE]\n\n"
# =============================================================================
# Anthropic Messages API
# =============================================================================
async def stream_anthropic_messages(
engine: BaseEngine,
messages: list,
request: AnthropicMessagesRequest,
resolved_model: Optional[str] = None,
**kwargs,
) -> AsyncIterator[str]:
"""
Stream Anthropic Messages API response.
For Harmony models (gpt-oss), separates analysis and final channels:
- index=0: analysis channel (<think>...</think>) - displayed as thinking
- index=1: final channel (response text) - displayed as message
For other models:
- index=0: all text
Emits events in Anthropic SSE format:
1. message_start - Initial message
2. content_block_start - Start block(s)
3. content_block_delta - Text chunks
4. content_block_stop - End block(s)
5. (tool blocks if present)
6. message_delta - Final stop_reason and usage
7. message_stop - End marker
"""
start_time = time.perf_counter()
first_token_time = None
message_id = f"msg_{uuid.uuid4().hex[:24]}"
accumulated_text = ""
# Track content blocks with thinking separation. Some templates open the
# thinking block in the prompt itself, so the generated text starts with
# reasoning body and only later emits </think>.
start_in_thinking = False
try:
tokenizer = getattr(engine, "tokenizer", None)
if tokenizer is not None:
prompt, prompt_token_ids = _render_chat_prompt_for_thinking_detection(
engine, messages, kwargs
)
start_in_thinking, _ = prompt_opens_thinking(
tokenizer, prompt, prompt_token_ids=prompt_token_ids
)
except Exception as exc:
logger.debug("Could not detect Anthropic stream thinking state: %s", exc)
thinking_parser = ThinkingParser(start_in_thinking=start_in_thinking)
thinking_block_started = False
text_block_started = False
block_index = 0
last_output = None # Track last output for tool_calls and token counts
# Filter tool-call markup from streamed content when tools are present.
has_tools = bool(kwargs.get("tools"))
tool_filter = None
thinking_filter = None
if has_tools:
_content_filter = ToolCallStreamFilter(engine.tokenizer)
_thinking_filter = ToolCallStreamFilter(engine.tokenizer)
if _content_filter.active:
tool_filter = _content_filter
thinking_filter = _thinking_filter
# Does the client opt into Anthropic's cache_control accounting?
# When yes, message_start.input_tokens reports the post-partition value
# (0 here, since we approximate the whole prompt as belonging to the
# cache_control region — the final message_delta refines with the real
# cache hit count). When no, input_tokens carries the full prompt count.
uses_cache_control = request_has_cache_control(request)
# Calculate input tokens before streaming starts
# This is needed for message_start event
estimated_input_tokens = 0
try:
if hasattr(engine, "tokenizer") and engine.tokenizer is not None:
# Build the prompt using chat template
template_kwargs = {"tokenize": False, "add_generation_prompt": True}
if kwargs.get("tools"):
template_kwargs["tools"] = kwargs["tools"]
if kwargs.get("chat_template_kwargs"):
template_kwargs.update(kwargs["chat_template_kwargs"])
prompt = engine.tokenizer.apply_chat_template(messages, **template_kwargs)
# Tokenize to count
tokens = engine.tokenizer.encode(prompt)
estimated_input_tokens = len(tokens)
except Exception as e:
logger.debug(f"Could not estimate input tokens: {e}")
# 1. Send message_start with estimated input tokens
yield create_message_start_event(
message_id=message_id,
model=request.model,
input_tokens=(
0
if uses_cache_control
else scale_anthropic_tokens(estimated_input_tokens, request.model)
),
)
# 3. Stream content with thinking/content separation
try:
async for output in engine.stream_chat(messages=messages, **kwargs):
last_output = output # Keep reference for tool_calls and token counts
if first_token_time is None and output.new_text:
first_token_time = time.perf_counter()
if output.new_text:
accumulated_text += output.new_text
thinking_delta, content_delta = thinking_parser.feed(output.new_text)
# Emit thinking content as thinking block
if thinking_delta:
if thinking_filter:
thinking_delta = thinking_filter.feed(thinking_delta)
if thinking_delta:
# Close any open text block before starting a new
# thinking block at a fresh index. Anthropic SDKs
# reject mixed-type content_block events at the same
# index — this transition handles a model that emits
# a second thinking section after some text.
if text_block_started:
yield create_content_block_stop_event(index=block_index)
block_index += 1
text_block_started = False
if not thinking_block_started:
yield create_content_block_start_event(
index=block_index, block_type="thinking"
)
thinking_block_started = True
yield create_thinking_delta_event(
index=block_index, thinking=thinking_delta
)
# Emit regular content as text block — filter tool-call
# markup when a known start marker is available.
if content_delta:
if tool_filter:
content_delta = tool_filter.feed(content_delta)
if content_delta:
# When tools are requested AND we haven't yet opened
# a text block, drop pure-whitespace deltas. Models
# often emit a leading newline around <tool_call>
# envelopes that tool_filter passes through
# (whitespace isn't part of the envelope markers).
# Without this guard, the `\n` opens a text block
# that then holds only whitespace — surfacing as
# a phantom empty-ish text block before the
# tool_use blocks.
if (
not text_block_started
and kwargs.get("tools")
and not content_delta.strip()
):
pass # drop leading whitespace adjacent to tool envelopes
else:
# Close thinking block if transitioning to text
if thinking_block_started and not text_block_started:
yield create_content_block_stop_event(index=block_index)
block_index += 1
thinking_block_started = False
if not text_block_started:
yield create_content_block_start_event(
index=block_index, block_type="text"
)
text_block_started = True
yield create_text_delta_event(
index=block_index, text=content_delta
)
if output.finished:
break
except Exception as e:
logger.error(f"Error during Anthropic streaming: {e}")
yield create_error_event("api_error", str(e))
yield create_message_stop_event()
return
# Flush remaining buffered content from thinking parser
thinking_delta, content_delta = thinking_parser.finish()
if thinking_delta:
if thinking_filter:
thinking_delta = thinking_filter.feed(thinking_delta)
if thinking_delta:
if text_block_started:
yield create_content_block_stop_event(index=block_index)
block_index += 1
text_block_started = False
if not thinking_block_started:
yield create_content_block_start_event(
index=block_index, block_type="thinking"
)
thinking_block_started = True
yield create_thinking_delta_event(
index=block_index, thinking=thinking_delta
)
if thinking_filter:
remaining_thinking = thinking_filter.finish()
if remaining_thinking:
if text_block_started:
yield create_content_block_stop_event(index=block_index)
block_index += 1
text_block_started = False
if not thinking_block_started:
yield create_content_block_start_event(
index=block_index, block_type="thinking"
)
thinking_block_started = True
yield create_thinking_delta_event(
index=block_index, thinking=remaining_thinking
)
if content_delta:
if tool_filter:
content_delta = tool_filter.feed(content_delta)
if content_delta:
if thinking_block_started and not text_block_started:
yield create_content_block_stop_event(index=block_index)
block_index += 1
thinking_block_started = False
if not text_block_started:
yield create_content_block_start_event(
index=block_index, block_type="text"
)
text_block_started = True
yield create_text_delta_event(index=block_index, text=content_delta)
# Flush any remaining buffered content from the tool-call filter
if tool_filter:
remaining = tool_filter.finish()
if remaining:
if not text_block_started:
if thinking_block_started:
yield create_content_block_stop_event(index=block_index)
block_index += 1
thinking_block_started = False
yield create_content_block_start_event(
index=block_index, block_type="text"
)
text_block_started = True
yield create_text_delta_event(index=block_index, text=remaining)
# 5. Handle tool calls (moved before block-closing so empty-text-block
# emission can skip when tool_use blocks will follow).
# For Harmony models, use tool_calls from output (parsed by HarmonyStreamingParser)
# For other models, parse from accumulated text
tool_calls = None
if last_output and last_output.tool_calls:
# Protocol parser already extracted structured tool calls.
tool_calls = _convert_parser_tool_calls(last_output.tool_calls)
elif kwargs.get("tools"):
# Non-Harmony: separate thinking, then parse tool calls from content
# (falls back to thinking content for small models)
thinking_content, regular_content = extract_thinking(accumulated_text)
extraction = extract_tool_calls_with_thinking(
thinking_content,
regular_content,
tokenizer=engine.tokenizer,
tools=kwargs.get("tools"),
)
tool_calls = extraction.tool_calls
# 4. Close open blocks
if thinking_block_started and not text_block_started:
# Only thinking was emitted. Keep block_index on the just-closed
# block so following tool_use blocks start at the next contiguous index.
yield create_content_block_stop_event(index=block_index)
if text_block_started:
yield create_content_block_stop_event(index=block_index)
elif not thinking_block_started and not tool_calls:
# No content AND no tool_calls — emit an empty text block so the
# message is well-formed. When tool_calls will follow, skip this —
# the tool_use blocks carry the semantic content, and an empty
# preceding text block confuses SDK clients that treat content[0]
# as authoritative.
yield create_content_block_start_event(index=block_index, block_type="text")
yield create_content_block_stop_event(index=block_index)
# Reverse Gemma 4 parameter renaming
if tool_calls and "gemma" in (resolved_model or request.model or "").lower():
for tc in tool_calls:
if tc.function and tc.function.arguments:
try:
args = json.loads(tc.function.arguments)
args = restore_gemma4_param_names(args)
tc.function.arguments = json.dumps(args, ensure_ascii=False)
except (json.JSONDecodeError, AttributeError):
pass
# Emit tool_use blocks if present
# When neither text nor thinking was streamed AND the empty-text-block
# emission was skipped (because tool_calls are about to follow), the
# tool_use block takes index 0. Otherwise it follows the last emitted
# text/thinking block at block_index+1.
if not text_block_started and not thinking_block_started:
tool_block_start = 0
else:
tool_block_start = block_index + 1
if tool_calls:
for i, tc in enumerate(tool_calls, start=tool_block_start):
# Start tool_use block
yield create_content_block_start_event(
index=i,
block_type="tool_use",
id=tc.id,
name=tc.function.name,
)
# Send input as delta
yield create_input_json_delta_event(
index=i, partial_json=tc.function.arguments
)
# Close tool block
yield create_content_block_stop_event(index=i)
# 6. Send message_delta with stop_reason and actual token counts
stop_reason = map_finish_reason_to_stop_reason(
output.finish_reason if output else "stop", bool(tool_calls)
)
# Use actual token counts from the last output
actual_input_tokens = scale_anthropic_tokens(
last_output.prompt_tokens if last_output else 0, request.model
)
actual_output_tokens = scale_anthropic_tokens(
last_output.completion_tokens if last_output else 0, request.model
)
actual_cached_tokens = scale_anthropic_tokens(
last_output.cached_tokens if last_output else 0, request.model
)
yield create_message_delta_event(
stop_reason=stop_reason,
output_tokens=actual_output_tokens,
input_tokens=actual_input_tokens,
cached_tokens=actual_cached_tokens,
request_uses_cache_control=uses_cache_control,
)
# Record metrics
if last_output:
end_time = time.perf_counter()
total_duration = end_time - start_time
ttft = (first_token_time - start_time) if first_token_time else total_duration
if getattr(engine, "is_diffusion_model", False):
gen_duration = total_duration
else:
gen_duration = end_time - (first_token_time or start_time)
get_server_metrics().record_request_complete(
prompt_tokens=last_output.prompt_tokens,
completion_tokens=last_output.completion_tokens,
cached_tokens=last_output.cached_tokens,
prefill_duration=ttft,
generation_duration=gen_duration,
model_id=resolved_model or request.model,
)
# 7. Send message_stop
yield create_message_stop_event()
@app.post("/v1/messages")
async def create_anthropic_message(
request: AnthropicMessagesRequest,
http_request: FastAPIRequest,
_: bool = Depends(verify_api_key),
):
"""
Create a message using Anthropic Messages API format.
This endpoint provides compatibility with Anthropic's Messages API,
allowing clients that use Anthropic SDK to work with oMLX.
Example request:
```json
{
"model": "claude-3-sonnet",
"max_tokens": 1024,
"messages": [
{"role": "user", "content": "Hello, how are you?"}
]
}
```
Streaming is supported with `stream: true`.
"""
logger.debug(
f"Anthropic Messages request: model={request.model}, "
f"messages={len(request.messages)}, stream={request.stream}, "
f"max_tokens={request.max_tokens}"
)
if _server_state.oq_manager and _server_state.oq_manager.is_quantizing:
raise HTTPException(
status_code=503,
detail="Server is busy with oQ quantization. Please try again after quantization completes.",
)
lease = _LLMEngineLease()
try:
engine = await get_engine_for_model(request.model, lease=lease)
# Resolve alias to real model ID for settings lookups
resolved_model = resolve_model_id(request.model) or request.model
# Get per-model settings
max_tool_result_tokens = None
merged_ct_kwargs = {}
forced_keys: set[str] = set()
ms = get_model_settings_for_request(request.model)
if ms:
max_tool_result_tokens = ms.max_tool_result_tokens
if ms.chat_template_kwargs:
merged_ct_kwargs.update(ms.chat_template_kwargs)
forced_keys = set(ms.forced_ct_kwargs or [])
# Dedicated enable_thinking toggle takes precedence over chat_template_kwargs
if ms.enable_thinking is not None:
merged_ct_kwargs["enable_thinking"] = ms.enable_thinking
# preserve_thinking: keep <think> blocks in historical turns (Qwen 3.6+)
if ms.preserve_thinking is not None:
merged_ct_kwargs["preserve_thinking"] = ms.preserve_thinking
# Per-request kwargs override model settings (except forced keys)
if request.chat_template_kwargs:
for k, v in request.chat_template_kwargs.items():
if k not in forced_keys:
merged_ct_kwargs[k] = v
# Pass Anthropic thinking config to chat template (except forced keys)
if hasattr(request, "thinking") and request.thinking:
if "enable_thinking" not in forced_keys:
thinking_type = getattr(request.thinking, "type", None)
if thinking_type in ("enabled", "adaptive"):
merged_ct_kwargs["enable_thinking"] = True
elif thinking_type == "disabled":
merged_ct_kwargs["enable_thinking"] = False
logger.debug(
f"Tool result truncation config: max_tokens={max_tool_result_tokens}, "
f"has_tokenizer={engine.tokenizer is not None}"
)
# Convert Anthropic format to internal format
# Harmony models need special handling to preserve tool format
is_vlm = isinstance(engine, VLMBatchedEngine)
is_dflash_vlm = not is_vlm and getattr(
engine, "supports_multimodal_fallback", False
)
_entry = get_engine_pool().get_entry(resolved_model)
native_reasoning = uses_native_reasoning_content(
resolved_model,
config_model_type=(
getattr(_entry, "config_model_type", None)
if _entry is not None
else None
),
engine_model_type=getattr(engine, "model_type", None),
preserve_thinking_default=(
getattr(_entry, "preserve_thinking_default", None)
if _entry is not None
else None
),
)
if engine.model_type == "gpt_oss":
messages = convert_anthropic_to_internal_harmony(
request,
max_tool_result_tokens,
engine.tokenizer,
consolidate_system_messages=False,
)
else:
messages = convert_anthropic_to_internal(
request,
max_tool_result_tokens,
engine.tokenizer,
preserve_images=is_vlm or is_dflash_vlm,
native_reasoning_content=native_reasoning,
consolidate_system_messages=False,
)
# Apply model-specific message extraction (e.g. Gemma 4 converts
# role=tool messages into tool_responses on assistant turns).
extractor = getattr(engine, "message_extractor", None)
merge_system_fallback_roles = not (is_vlm or is_dflash_vlm)
if extractor is not None:
extractor_kwargs = {}
try:
if (
"consolidate_system_messages"
in inspect.signature(extractor).parameters
):
extractor_kwargs["consolidate_system_messages"] = False
except (TypeError, ValueError):
pass
messages = extractor(
messages,
max_tool_result_tokens,
engine.tokenizer,
**extractor_kwargs,
)
merge_system_fallback_roles = True
# Detect and strip partial mode at the API boundary — exactly once.
is_partial = detect_and_strip_partial(messages)
# Prepare kwargs
(
temperature,
top_p,
top_k,
repetition_penalty,
min_p,
presence_penalty,
frequency_penalty,
max_tokens,
xtc_probability,
xtc_threshold,
) = get_sampling_params(
request.temperature,
request.top_p,
request.model,
req_top_k=getattr(request, "top_k", None),
req_repetition_penalty=getattr(request, "repetition_penalty", None),
req_max_tokens=request.max_tokens,
)
chat_kwargs = {
"max_tokens": max_tokens,
"temperature": temperature,
"top_p": top_p,
"top_k": top_k,
"min_p": min_p,
"repetition_penalty": repetition_penalty,
"presence_penalty": presence_penalty,
"frequency_penalty": frequency_penalty,
"xtc_probability": xtc_probability,
"xtc_threshold": xtc_threshold,
}
# Add thinking budget if applicable
thinking_budget = _resolve_thinking_budget(request, request.model)
if thinking_budget is not None:
chat_kwargs["thinking_budget"] = thinking_budget
# Auto-set enable_thinking in chat template kwargs when a thinking
# budget is active but enable_thinking was not already set (e.g. via
# the Anthropic thinking.type field above or model settings).
if thinking_budget is not None and "enable_thinking" not in merged_ct_kwargs:
merged_ct_kwargs["enable_thinking"] = True
# Auto-set preserve_thinking only when the template advertises support
# for it (Qwen 3.6+). Gated on detection so other templates don't
# receive an unknown kwarg.
_entry = get_engine_pool().get_entry(resolved_model)
if (
_entry is not None
and _entry.preserve_thinking_default is True
and merged_ct_kwargs.get("enable_thinking") is not False
and "preserve_thinking" not in merged_ct_kwargs
):
merged_ct_kwargs["preserve_thinking"] = True
# Merge MCP tools with user-provided Anthropic tools
user_internal = convert_anthropic_tools_to_internal(request.tools)
if getattr(engine, "is_diffusion_model", False) and not getattr(
engine, "supports_tool_calling", False
):
if user_internal:
raise InvalidRequestError(
"Tool calling is not supported for this diffusion model "
"(no tool parser matched its chat template).",
field="tools",
)
internal_tools = None
elif _server_state.mcp_manager:
mcp_openai_tools = _server_state.mcp_manager.get_all_tools_openai()
combined = (mcp_openai_tools or []) + (user_internal or [])
# Deduplicate by function name (user tools take precedence)
if combined:
seen = {}
for tool in combined:
name = tool.get("function", {}).get("name", "")
seen[name] = tool
internal_tools = list(seen.values())
else:
internal_tools = None
else:
internal_tools = user_internal
# Gemma 4 drops required params that lack descriptions — enrich them
if internal_tools and "gemma" in (resolved_model or "").lower():
internal_tools = enrich_tool_params_for_gemma4(internal_tools)
if internal_tools:
chat_kwargs["tools"] = internal_tools
# Add chat template kwargs
if merged_ct_kwargs:
chat_kwargs["chat_template_kwargs"] = merged_ct_kwargs
# Forward partial-mode decision to the engine explicitly
chat_kwargs["is_partial"] = is_partial
await _ensure_tokenizer_for_system_probe(engine, messages)
messages = prepare_system_messages_for_template(
messages,
engine.tokenizer,
tools=internal_tools,
chat_template_kwargs=merged_ct_kwargs or None,
is_partial=is_partial,
merge_consecutive_roles=merge_system_fallback_roles,
unsupported_mid_system_policy=_unsupported_mid_system_policy(),
)
# Validate context window before sending to model
try:
num_prompt_tokens = engine.count_chat_tokens(
messages,
internal_tools,
chat_template_kwargs=merged_ct_kwargs or None,
is_partial=is_partial,
)
except Exception as e:
err_name = type(e).__name__.lower()
err_msg = str(e).lower()
if (
"template" in err_name
or "template" in err_msg
or isinstance(e, (AssertionError, ValueError))
):
raise HTTPException(status_code=400, detail=f"Chat template error: {e}")
raise
validate_context_window(num_prompt_tokens, request.model)
# Add stop sequences
if request.stop_sequences:
chat_kwargs["stop"] = request.stop_sequences
# Pre-flight prefill memory guard — must precede any StreamingResponse
# return so PrefillMemoryExceededError can be mapped to HTTP 400.
await _raise_if_llm_lease_abort_requested(lease)
await engine.preflight_chat(
messages,
request_id=http_request.headers.get("x-request-id"),
**chat_kwargs,
)
await _raise_if_llm_lease_abort_requested(lease)
if request.stream:
return StreamingResponse(
_release_after_stream(
_with_sse_keepalive(
stream_anthropic_messages(
engine,
messages,
request,
resolved_model=resolved_model,
**chat_kwargs,
),
http_request=http_request,
keepalive_chunk=_resolve_keepalive("anthropic"),
),
lease,
),
media_type="text/event-stream",
headers={"X-Accel-Buffering": "no", "Cache-Control": "no-cache"},
)
# Non-streaming response with keepalive during prefill
async def _build_anthropic_message():
await _raise_if_llm_lease_abort_requested(lease)
start_time = time.perf_counter()
output = await engine.chat(messages=messages, **chat_kwargs)
elapsed = time.perf_counter() - start_time
tokens_per_sec = output.completion_tokens / elapsed if elapsed > 0 else 0
logger.info(
f"Anthropic message: {output.completion_tokens} tokens in {elapsed:.2f}s "
f"({tokens_per_sec:.1f} tok/s)"
)
get_server_metrics().record_request_complete(
prompt_tokens=output.prompt_tokens,
completion_tokens=output.completion_tokens,
cached_tokens=output.cached_tokens,
generation_duration=elapsed,
model_id=resolved_model,
)
# Separate thinking from content
raw_text = clean_special_tokens(output.text) if output.text else ""
thinking_content, regular_content = extract_thinking(raw_text)
cleaned_thinking = sanitize_tool_call_markup(
thinking_content, engine.tokenizer
)
# Protocol parsers can return structured tool_calls directly.
if output.tool_calls:
tool_calls = _convert_parser_tool_calls(output.tool_calls)
cleaned_text = regular_content
else:
extraction = extract_tool_calls_with_thinking(
thinking_content,
regular_content,
tokenizer=engine.tokenizer,
tools=internal_tools,
)
cleaned_text = extraction.cleaned_text
tool_calls = extraction.tool_calls
cleaned_thinking = extraction.cleaned_thinking
# Reverse Gemma 4 parameter renaming
if tool_calls and "gemma" in (resolved_model or "").lower():
for tc in tool_calls:
if tc.function and tc.function.arguments:
try:
args = json.loads(tc.function.arguments)
args = restore_gemma4_param_names(args)
tc.function.arguments = json.dumps(args, ensure_ascii=False)
except (json.JSONDecodeError, AttributeError):
pass
response = convert_internal_to_anthropic_response(
text=cleaned_text.strip() if cleaned_text else "",
model=request.model,
prompt_tokens=scale_anthropic_tokens(
output.prompt_tokens, request.model
),
completion_tokens=scale_anthropic_tokens(
output.completion_tokens, request.model
),
finish_reason=output.finish_reason,
tool_calls=tool_calls,
thinking=cleaned_thinking if cleaned_thinking else None,
cached_tokens=scale_anthropic_tokens(
output.cached_tokens, request.model
),
request_uses_cache_control=request_has_cache_control(request),
)
return response.model_dump_json()
return StreamingResponse(
_release_after_stream(
_with_json_keepalive(http_request, _build_anthropic_message()),
lease,
),
media_type="application/json",
)
except BaseException:
await lease.release()
raise
@app.post("/v1/messages/count_tokens")
async def count_anthropic_tokens(
request: TokenCountRequest,
_: bool = Depends(verify_api_key),
):
"""
Count tokens in a message request.
Uses the loaded model's tokenizer to accurately count tokens
including system prompt, messages, and tools.
This is compatible with Anthropic's token counting API.
"""
if _server_state.oq_manager and _server_state.oq_manager.is_quantizing:
raise HTTPException(
status_code=503,
detail="Server is busy with oQ quantization. Please try again after quantization completes.",
)
lease = _LLMEngineLease()
try:
engine = await get_engine_for_model(request.model, lease=lease)
await _raise_if_llm_lease_abort_requested(lease)
# Convert Anthropic format to internal format
# Create a temporary MessagesRequest to reuse existing conversion logic
temp_request = AnthropicMessagesRequest(
model=request.model,
max_tokens=1, # Dummy value, not used for token counting
messages=request.messages,
system=request.system,
tools=request.tools,
tool_choice=request.tool_choice,
thinking=request.thinking,
)
messages = convert_anthropic_to_internal(temp_request)
# Convert tools if present
internal_tools = convert_anthropic_tools_to_internal(request.tools)
# Apply chat template to get prompt
tokenizer = engine.tokenizer
template_kwargs = {
"tokenize": False,
"add_generation_prompt": True,
}
if internal_tools:
template_kwargs["tools"] = internal_tools
try:
prompt = tokenizer.apply_chat_template(messages, **template_kwargs)
except Exception as e:
logger.warning(
f"Failed to apply chat template: {e}, using simple concatenation"
)
# Fallback: simple concatenation
prompt = "\n".join(
f"{msg.get('role', 'user')}: {msg.get('content', '')}"
for msg in messages
)
# Tokenize to count tokens
if isinstance(prompt, str):
token_ids = tokenizer.encode(prompt)
else:
token_ids = prompt # Already tokenized
input_tokens = scale_anthropic_tokens(len(token_ids), request.model)
logger.debug(f"Token count: {input_tokens} tokens for {len(messages)} messages")
return TokenCountResponse(input_tokens=input_tokens)
finally:
await lease.release()
# =============================================================================
# Responses API (/v1/responses) — OpenAI Codex compatibility
# =============================================================================
def _should_store_response(store_flag: Optional[bool]) -> bool:
"""OpenAI Responses defaults to storing responses unless explicitly disabled."""
return store_flag is not False
def _resolve_previous_response_messages(previous_response_id: str) -> list[dict]:
"""Resolve a previous_response_id chain into chat messages."""
try:
return _server_state.responses_store.resolve_chain_messages(
previous_response_id
)
except ResponseStateNotFoundError as exc:
raise HTTPException(
status_code=404,
detail=(
"Response state not found for previous_response_id. "
"It may have been deleted, evicted, or lost after restart."
),
) from exc
except ResponseStateCorruptError as exc:
raise HTTPException(
status_code=409,
detail=(
"Stored response state is incomplete or corrupted for "
"previous_response_id."
),
) from exc
def _store_response_state(
public_response: dict,
input_messages: list[dict],
) -> None:
"""Persist the response object and the normalized conversation state."""
output_messages = normalize_response_output_to_messages(
public_response.get("output", [])
)
record = build_response_store_record(
public_response,
input_messages=input_messages,
output_messages=output_messages,
)
_server_state.responses_store.put(public_response["id"], record)
@app.post("/v1/responses")
async def create_response(
request: ResponsesRequest,
http_request: FastAPIRequest,
_: bool = Depends(verify_api_key),
):
"""Create a response (OpenAI Responses API)."""
if _server_state.oq_manager and _server_state.oq_manager.is_quantizing:
raise HTTPException(
status_code=503,
detail="Server is busy with oQ quantization. Please try again after quantization completes.",
)
logger.debug(
f"Responses API request: model={request.model}, stream={request.stream}"
)
load_start = time.perf_counter()
lease = _LLMEngineLease()
try:
engine = await get_engine_for_model(request.model, lease=lease)
model_load_duration = time.perf_counter() - load_start
resolved_model = resolve_model_id(request.model) or request.model
current_input_messages = convert_responses_input_to_messages(
request.input,
consolidate_system_messages=False,
)
# Build previous context from previous_response_id
previous_messages = None
if request.previous_response_id:
previous_messages = _resolve_previous_response_messages(
request.previous_response_id
)
# Convert Responses API input → internal messages
messages = convert_responses_input_to_messages(
request.input,
request.instructions,
previous_messages,
consolidate_system_messages=False,
)
# Convert tools: flat → nested
openai_tools = convert_responses_tools(request.tools)
if (
getattr(engine, "is_diffusion_model", False)
and not getattr(engine, "supports_tool_calling", False)
and openai_tools
):
raise InvalidRequestError(
"Tool calling is not supported for this diffusion model "
"(no tool parser matched its chat template).",
field="tools",
)
# Get per-model settings
merged_ct_kwargs = {}
reasoning_parser = None
ms = get_model_settings_for_request(request.model)
if ms:
reasoning_parser = ms.reasoning_parser
if ms.chat_template_kwargs:
merged_ct_kwargs.update(ms.chat_template_kwargs)
# Dedicated enable_thinking toggle takes precedence over chat_template_kwargs
if ms.enable_thinking is not None:
merged_ct_kwargs["enable_thinking"] = ms.enable_thinking
# preserve_thinking: keep <think> blocks in historical turns (Qwen 3.6+)
if ms.preserve_thinking is not None:
merged_ct_kwargs["preserve_thinking"] = ms.preserve_thinking
# Note: extract_text_content/extract_harmony_messages/extract_multimodal_content
# are NOT called here because convert_responses_input_to_messages() already
# returns plain dicts in {"role": str, "content": str} format.
# Those extract functions expect Pydantic Message objects from OpenAI/Anthropic requests.
# Handle text.format (structured output)
response_format = None
compiled_grammar = None
if request.text and request.text.format:
fmt = request.text.format
if fmt.type == "json_object":
response_format = {"type": "json_object"}
elif fmt.type == "json_schema":
response_format = {
"type": "json_schema",
"json_schema": {
"name": fmt.name or "response",
"schema": fmt.schema_ or {},
"strict": fmt.strict or False,
},
}
if response_format:
from .api.openai_models import ResponseFormat
_reject_diffusion_structured_outputs(
engine,
response_format=response_format,
)
await engine.start()
rf = ResponseFormat(**response_format)
compiled_grammar = _compile_grammar_for_request(
engine,
response_format=rf,
chat_template_kwargs=merged_ct_kwargs or None,
reasoning_parser=reasoning_parser,
)
if compiled_grammar is None:
json_instruction = build_json_system_prompt(rf)
if json_instruction:
messages = _inject_json_instruction(messages, json_instruction)
else:
compiled_grammar = None
# Merge MCP tools
effective_tools = (
None
if (
getattr(engine, "is_diffusion_model", False)
and not getattr(engine, "supports_tool_calling", False)
)
else openai_tools
)
if _server_state.mcp_manager and effective_tools:
effective_tools = _server_state.mcp_manager.get_merged_tools(openai_tools)
# Convert tools for chat template
tools_for_template = (
convert_tools_for_template(effective_tools) if effective_tools else None
)
# Gemma 4 drops required params that lack descriptions — enrich them
if tools_for_template and "gemma" in (resolved_model or "").lower():
tools_for_template = enrich_tool_params_for_gemma4(tools_for_template)
await _ensure_tokenizer_for_system_probe(engine, messages)
messages = prepare_system_messages_for_template(
messages,
engine.tokenizer,
tools=tools_for_template,
chat_template_kwargs=merged_ct_kwargs or None,
is_partial=False,
merge_consecutive_roles=True,
unsupported_mid_system_policy=_unsupported_mid_system_policy(),
)
# Validate context window
try:
num_prompt_tokens = engine.count_chat_tokens(
messages,
tools_for_template,
chat_template_kwargs=merged_ct_kwargs or None,
)
except Exception as e:
err_name = type(e).__name__.lower()
err_msg = str(e).lower()
if (
"template" in err_name
or "template" in err_msg
or isinstance(e, (AssertionError, ValueError))
):
raise HTTPException(status_code=400, detail=f"Chat template error: {e}")
raise
validate_context_window(num_prompt_tokens, request.model)
# Build sampling kwargs
(
temperature,
top_p,
top_k,
repetition_penalty,
min_p,
presence_penalty,
frequency_penalty,
max_tokens,
xtc_probability,
xtc_threshold,
) = get_sampling_params(
request.temperature,
request.top_p,
request.model,
req_top_k=getattr(request, "top_k", None),
req_repetition_penalty=getattr(request, "repetition_penalty", None),
req_max_tokens=request.max_output_tokens,
)
chat_kwargs = {
"max_tokens": max_tokens,
"temperature": temperature,
"top_p": top_p,
"top_k": top_k,
"min_p": min_p,
"repetition_penalty": repetition_penalty,
"presence_penalty": presence_penalty,
"frequency_penalty": frequency_penalty,
"xtc_probability": xtc_probability,
"xtc_threshold": xtc_threshold,
}
# Add seed for reproducible generation (best-effort)
if request.seed is not None:
chat_kwargs["seed"] = request.seed
# Add thinking budget if applicable
thinking_budget = _resolve_thinking_budget(request, request.model)
if thinking_budget is not None:
chat_kwargs["thinking_budget"] = thinking_budget
# Auto-set enable_thinking when thinking budget is active.
if thinking_budget is not None and "enable_thinking" not in merged_ct_kwargs:
merged_ct_kwargs["enable_thinking"] = True
# Auto-set preserve_thinking only when the template advertises support
# for it (Qwen 3.6+). Gated on detection so other templates don't
# receive an unknown kwarg.
_entry = get_engine_pool().get_entry(resolved_model)
native_reasoning = bool(_entry and _entry.preserve_thinking_default is True)
if (
native_reasoning
and merged_ct_kwargs.get("enable_thinking") is not False
and "preserve_thinking" not in merged_ct_kwargs
):
merged_ct_kwargs["preserve_thinking"] = True
# Add compiled grammar for logit-level structured output.
if compiled_grammar is not None:
chat_kwargs["compiled_grammar"] = compiled_grammar
if reasoning_parser and "thinking_budget" not in chat_kwargs:
default_budget = min(max_tokens // 2, 4096)
chat_kwargs["thinking_budget"] = default_budget
logger.debug(
"Auto-set thinking_budget=%d for grammar-constrained request",
default_budget,
)
if tools_for_template:
chat_kwargs["tools"] = tools_for_template
if merged_ct_kwargs:
chat_kwargs["chat_template_kwargs"] = merged_ct_kwargs
# Pre-flight prefill memory guard — must precede any StreamingResponse
# return so PrefillMemoryExceededError can be mapped to HTTP 400.
await _raise_if_llm_lease_abort_requested(lease)
await engine.preflight_chat(
messages,
request_id=http_request.headers.get("x-request-id"),
**chat_kwargs,
)
await _raise_if_llm_lease_abort_requested(lease)
if request.stream:
return StreamingResponse(
_release_after_stream(
_with_sse_keepalive(
stream_responses_api(
engine,
messages,
request,
input_messages=current_input_messages,
store_response=_should_store_response(request.store),
model_load_duration=model_load_duration,
resolved_model=resolved_model,
response_format=response_format,
native_reasoning=native_reasoning,
**chat_kwargs,
),
http_request=http_request,
keepalive_chunk=_resolve_keepalive("openai_responses"),
),
lease,
),
media_type="text/event-stream",
headers={"X-Accel-Buffering": "no", "Cache-Control": "no-cache"},
)
# Non-streaming with keepalive during prefill
async def _build_responses_api():
await _raise_if_llm_lease_abort_requested(lease)
start_time = time.perf_counter()
output = await engine.chat(messages=messages, **chat_kwargs)
elapsed = time.perf_counter() - start_time
tokens_per_sec = output.completion_tokens / elapsed if elapsed > 0 else 0
logger.info(
f"Responses API: {output.completion_tokens} tokens in {elapsed:.2f}s "
f"({tokens_per_sec:.1f} tok/s)"
)
get_server_metrics().record_request_complete(
prompt_tokens=output.prompt_tokens,
completion_tokens=output.completion_tokens,
cached_tokens=output.cached_tokens,
generation_duration=elapsed,
model_id=resolved_model,
)
# Process output text
raw_text = clean_special_tokens(output.text) if output.text else ""
thinking_content, regular_content = extract_thinking(raw_text)
# Parse tool calls
if output.tool_calls:
tool_calls = _convert_parser_tool_calls(output.tool_calls)
cleaned_text = regular_content
cleaned_thinking = sanitize_tool_call_markup(
thinking_content, engine.tokenizer
)
else:
extraction = extract_tool_calls_with_thinking(
thinking_content,
regular_content,
tokenizer=engine.tokenizer,
tools=tools_for_template,
)
cleaned_text = extraction.cleaned_text
tool_calls = extraction.tool_calls
cleaned_thinking = extraction.cleaned_thinking
# Reverse Gemma 4 parameter renaming
if tool_calls and "gemma" in (resolved_model or "").lower():
for tc in tool_calls:
fn = getattr(tc, "function", None)
if fn and fn.arguments:
try:
args = json.loads(fn.arguments)
args = restore_gemma4_param_names(args)
fn.arguments = json.dumps(args, ensure_ascii=False)
except (json.JSONDecodeError, AttributeError):
pass
# Process response_format if specified
if response_format and not tool_calls:
cleaned_text, parsed_json, is_valid, error = parse_json_output(
cleaned_text or regular_content, response_format
)
if parsed_json is not None:
cleaned_text = json.dumps(parsed_json)
if not is_valid:
logger.warning(f"JSON validation failed: {error}")
# Build output items
output_items: list[OutputItem] = []
reasoning_text = (cleaned_thinking or "").strip()
if reasoning_text:
output_items.append(build_reasoning_output_item(reasoning_text))
output_items.append(
build_message_output_item(cleaned_text.strip() if cleaned_text else "")
)
if tool_calls:
for tc in tool_calls:
if hasattr(tc, "function"):
call_id = tc.id
name = tc.function.name
arguments = tc.function.arguments
elif isinstance(tc, dict):
call_id = tc.get(
"call_id", tc.get("id", f"call_{uuid.uuid4().hex[:8]}")
)
name = tc.get("name", "")
arguments = tc.get("arguments", "{}")
else:
continue
output_items.append(
build_function_call_output_item(
name=name,
arguments=arguments,
call_id=call_id,
)
)
reasoning_token_count = (
len(engine.tokenizer.encode(reasoning_text)) if reasoning_text else 0
)
usage = build_response_usage(
input_tokens=output.prompt_tokens,
output_tokens=output.completion_tokens,
reasoning_tokens=reasoning_token_count,
cached_tokens=output.cached_tokens,
)
response_obj = ResponseObject(
model=request.model,
status="completed",
output=output_items,
usage=usage,
tools=request.tools or [],
tool_choice=request.tool_choice or "auto",
temperature=temperature,
top_p=top_p,
max_output_tokens=request.max_output_tokens,
previous_response_id=request.previous_response_id,
)
# Store response
if _should_store_response(request.store):
_store_response_state(
response_obj.model_dump(exclude_none=True),
input_messages=current_input_messages,
)
return response_obj.model_dump_json()
return StreamingResponse(
_release_after_stream(
_with_json_keepalive(http_request, _build_responses_api()),
lease,
),
media_type="application/json",
)
except BaseException:
await lease.release()
raise
async def stream_responses_api(
engine: BaseEngine,
messages: list,
request: ResponsesRequest,
input_messages: Optional[list[dict]] = None,
store_response: bool = True,
model_load_duration: float = 0.0,
resolved_model: Optional[str] = None,
response_format=None,
native_reasoning: bool = False,
**kwargs,
) -> AsyncIterator[str]:
"""Stream Responses API events (SSE with named event types)."""
from .api.shared_models import IDPrefix, generate_id
start_time = time.perf_counter()
first_token_time = None
last_output = None
accumulated_text = ""
accumulated_reasoning = ""
has_tools = bool(kwargs.get("tools"))
thinking_parser = ThinkingParser(start_in_thinking=native_reasoning)
seq = 0
response_id = generate_id(IDPrefix.RESPONSE)
msg_id = generate_id(IDPrefix.MESSAGE)
reasoning_id = generate_id(IDPrefix.REASONING)
# Lazy item emission state — items are opened on first token
reasoning_opened = False
reasoning_closed = False
message_opened = False
next_output_index = 0
reasoning_output_index: Optional[int] = None # captured when reasoning opens
msg_output_index: Optional[int] = None # captured when message opens
# Build initial response object (in_progress, empty output)
initial_response = ResponseObject(
id=response_id,
model=request.model,
status="in_progress",
output=[],
tools=request.tools or [],
tool_choice=request.tool_choice or "auto",
temperature=request.temperature,
top_p=request.top_p,
max_output_tokens=request.max_output_tokens,
previous_response_id=request.previous_response_id,
)
initial_data = initial_response.model_dump(exclude_none=True)
# 1. response.created
seq += 1
yield format_sse_event(
"response.created",
{
"type": "response.created",
"response": initial_data,
"sequence_number": seq,
},
)
# 2. response.in_progress
seq += 1
yield format_sse_event(
"response.in_progress",
{
"type": "response.in_progress",
"response": initial_data,
"sequence_number": seq,
},
)
# --- helper closures for lazy item emission ----------------------
def _open_reasoning():
nonlocal seq, reasoning_opened, reasoning_output_index, next_output_index
if reasoning_opened:
return []
reasoning_opened = True
reasoning_output_index = next_output_index
next_output_index += 1
events = []
seq += 1
events.append(
format_sse_event(
"response.output_item.added",
{
"type": "response.output_item.added",
"output_index": reasoning_output_index,
"item": {
"type": "reasoning",
"id": reasoning_id,
"status": "in_progress",
"summary": [],
},
"sequence_number": seq,
},
)
)
seq += 1
events.append(
format_sse_event(
"response.reasoning_summary_part.added",
{
"type": "response.reasoning_summary_part.added",
"item_id": reasoning_id,
"output_index": reasoning_output_index,
"summary_index": 0,
"part": {"type": "summary_text", "text": ""},
"sequence_number": seq,
},
)
)
return events
def _close_reasoning():
nonlocal seq, reasoning_closed
if reasoning_closed or not reasoning_opened:
return []
reasoning_closed = True
reasoning_text = accumulated_reasoning
events = []
seq += 1
events.append(
format_sse_event(
"response.reasoning_summary_text.done",
{
"type": "response.reasoning_summary_text.done",
"item_id": reasoning_id,
"output_index": reasoning_output_index,
"summary_index": 0,
"text": reasoning_text,
"sequence_number": seq,
},
)
)
seq += 1
events.append(
format_sse_event(
"response.reasoning_summary_part.done",
{
"type": "response.reasoning_summary_part.done",
"item_id": reasoning_id,
"output_index": reasoning_output_index,
"summary_index": 0,
"part": {"type": "summary_text", "text": reasoning_text},
"sequence_number": seq,
},
)
)
seq += 1
events.append(
format_sse_event(
"response.output_item.done",
{
"type": "response.output_item.done",
"output_index": reasoning_output_index,
"item": {
"type": "reasoning",
"id": reasoning_id,
"status": "completed",
"summary": [{"type": "summary_text", "text": reasoning_text}],
},
"sequence_number": seq,
},
)
)
return events
def _open_message():
nonlocal seq, message_opened, next_output_index, msg_output_index
if message_opened:
return []
message_opened = True
msg_output_index = next_output_index
next_output_index += 1
events = []
seq += 1
events.append(
format_sse_event(
"response.output_item.added",
{
"type": "response.output_item.added",
"output_index": msg_output_index,
"item": {
"type": "message",
"id": msg_id,
"status": "in_progress",
"role": "assistant",
"content": [],
},
"sequence_number": seq,
},
)
)
seq += 1
events.append(
format_sse_event(
"response.content_part.added",
{
"type": "response.content_part.added",
"item_id": msg_id,
"output_index": msg_output_index,
"content_index": 0,
"part": {"type": "output_text", "text": "", "annotations": []},
"sequence_number": seq,
},
)
)
return events
def _emit_reasoning_delta(delta: str):
nonlocal seq, accumulated_reasoning
if not delta:
return []
accumulated_reasoning += delta
events = []
events.extend(_open_reasoning())
seq += 1
events.append(
format_sse_event(
"response.reasoning_summary_text.delta",
{
"type": "response.reasoning_summary_text.delta",
"item_id": reasoning_id,
"output_index": reasoning_output_index,
"summary_index": 0,
"delta": delta,
"sequence_number": seq,
},
)
)
return events
# -----------------------------------------------------------------
# Open message/reasoning items lazily so non-native <think> blocks can still
# become a leading Responses reasoning item.
# Stream tokens
tool_filter = None
thinking_filter = None
stream_content = True
if has_tools:
_content_filter = ToolCallStreamFilter(engine.tokenizer)
_thinking_filter = ToolCallStreamFilter(engine.tokenizer)
if _content_filter.active:
tool_filter = _content_filter
thinking_filter = _thinking_filter
else:
stream_content = False
try:
async for output in engine.stream_chat(messages=messages, **kwargs):
if first_token_time is None and output.new_text:
first_token_time = time.perf_counter()
last_output = output
if output.new_text:
accumulated_text += output.new_text
if stream_content and output.new_text:
thinking_delta, content_delta = thinking_parser.feed(output.new_text)
if thinking_delta:
if thinking_filter:
thinking_delta = thinking_filter.feed(thinking_delta)
for ev in _emit_reasoning_delta(thinking_delta):
yield ev
if content_delta:
if reasoning_opened and not reasoning_closed:
for ev in _close_reasoning():
yield ev
for ev in _open_message():
yield ev
if tool_filter:
content_delta = tool_filter.feed(content_delta)
if content_delta:
seq += 1
yield format_sse_event(
"response.output_text.delta",
{
"type": "response.output_text.delta",
"item_id": msg_id,
"output_index": msg_output_index,
"content_index": 0,
"delta": content_delta,
"sequence_number": seq,
},
)
except Exception as e:
logger.error(f"Error during Responses API streaming: {e}")
seq += 1
yield format_sse_event(
"response.failed",
{
"type": "response.failed",
"response": {**initial_data, "status": "failed"},
"sequence_number": seq,
},
)
return
# Flush remaining content from parsers
if stream_content:
thinking_delta, content_delta = thinking_parser.finish()
if thinking_delta:
if thinking_filter:
thinking_delta = thinking_filter.feed(thinking_delta)
for ev in _emit_reasoning_delta(thinking_delta):
yield ev
if thinking_filter:
remaining_thinking = thinking_filter.finish()
for ev in _emit_reasoning_delta(remaining_thinking):
yield ev
if content_delta:
if reasoning_opened and not reasoning_closed:
for ev in _close_reasoning():
yield ev
for ev in _open_message():
yield ev
if tool_filter:
content_delta = tool_filter.feed(content_delta)
if content_delta:
seq += 1
yield format_sse_event(
"response.output_text.delta",
{
"type": "response.output_text.delta",
"item_id": msg_id,
"output_index": msg_output_index,
"content_index": 0,
"delta": content_delta,
"sequence_number": seq,
},
)
if tool_filter:
remaining = tool_filter.finish()
if remaining:
if reasoning_opened and not reasoning_closed:
for ev in _close_reasoning():
yield ev
for ev in _open_message():
yield ev
seq += 1
yield format_sse_event(
"response.output_text.delta",
{
"type": "response.output_text.delta",
"item_id": msg_id,
"output_index": msg_output_index,
"content_index": 0,
"delta": remaining,
"sequence_number": seq,
},
)
# Parse tool calls from accumulated text
tool_calls = None
cleaned_text = accumulated_text
if last_output and last_output.tool_calls:
tool_calls = _convert_parser_tool_calls(last_output.tool_calls)
cleaned_text = ""
elif has_tools and accumulated_text:
thinking_content, regular_content = extract_thinking(accumulated_text)
extraction = extract_tool_calls_with_thinking(
thinking_content,
regular_content,
tokenizer=engine.tokenizer,
tools=kwargs.get("tools"),
)
cleaned_text = extraction.cleaned_text
tool_calls = extraction.tool_calls
if not stream_content:
cleaned_thinking = (extraction.cleaned_thinking or "").strip()
for ev in _emit_reasoning_delta(cleaned_thinking):
yield ev
if reasoning_opened and not reasoning_closed:
for ev in _close_reasoning():
yield ev
if not stream_content and cleaned_text:
for ev in _open_message():
yield ev
seq += 1
yield format_sse_event(
"response.output_text.delta",
{
"type": "response.output_text.delta",
"item_id": msg_id,
"output_index": msg_output_index,
"content_index": 0,
"delta": cleaned_text,
"sequence_number": seq,
},
)
else:
# No tools — use raw accumulated text minus thinking.
thinking_content, regular_content = extract_thinking(accumulated_text)
cleaned_text = clean_special_tokens(regular_content) if regular_content else ""
# Reverse Gemma 4 parameter renaming
if tool_calls and "gemma" in (resolved_model or request.model or "").lower():
for tc in tool_calls:
fn = getattr(tc, "function", None)
if fn and fn.arguments:
try:
args = json.loads(fn.arguments)
args = restore_gemma4_param_names(args)
fn.arguments = json.dumps(args, ensure_ascii=False)
except (json.JSONDecodeError, AttributeError):
pass
final_text = cleaned_text.strip() if cleaned_text else ""
# Process response_format if specified
if response_format and not tool_calls:
_, parsed_json, is_valid, error = parse_json_output(final_text, response_format)
if parsed_json is not None:
final_text = json.dumps(parsed_json)
if not is_valid:
logger.warning(f"JSON validation failed: {error}")
if reasoning_opened and not reasoning_closed:
for ev in _close_reasoning():
yield ev
# Ensure message item is opened (even if no content was streamed).
for ev in _open_message():
yield ev
# response.output_text.done
seq += 1
yield format_sse_event(
"response.output_text.done",
{
"type": "response.output_text.done",
"item_id": msg_id,
"output_index": msg_output_index,
"content_index": 0,
"text": final_text,
"sequence_number": seq,
},
)
# response.content_part.done
seq += 1
yield format_sse_event(
"response.content_part.done",
{
"type": "response.content_part.done",
"item_id": msg_id,
"output_index": msg_output_index,
"content_index": 0,
"part": {"type": "output_text", "text": final_text, "annotations": []},
"sequence_number": seq,
},
)
# response.output_item.done (message)
seq += 1
yield format_sse_event(
"response.output_item.done",
{
"type": "response.output_item.done",
"output_index": msg_output_index,
"item": {
"type": "message",
"id": msg_id,
"status": "completed",
"role": "assistant",
"content": [
{"type": "output_text", "text": final_text, "annotations": []}
],
},
"sequence_number": seq,
},
)
# Build output items for final response
output_items = []
reasoning_text = accumulated_reasoning
if reasoning_text:
output_items.append(
{
"type": "reasoning",
"id": reasoning_id,
"status": "completed",
"summary": [{"type": "summary_text", "text": reasoning_text}],
}
)
output_items.append(
{
"type": "message",
"id": msg_id,
"status": "completed",
"role": "assistant",
"content": [{"type": "output_text", "text": final_text, "annotations": []}],
}
)
# Emit function call items if present
if tool_calls:
output_index = next_output_index
for tc in tool_calls:
if hasattr(tc, "function"):
call_id = tc.id
name = tc.function.name
arguments = tc.function.arguments
elif isinstance(tc, dict):
call_id = tc.get(
"call_id", tc.get("id", f"call_{uuid.uuid4().hex[:8]}")
)
name = tc.get("name", "")
arguments = tc.get("arguments", "{}")
else:
continue
fc_id = generate_id(IDPrefix.FUNCTION_CALL)
fc_item = {
"type": "function_call",
"id": fc_id,
"call_id": call_id,
"name": name,
"arguments": "",
"status": "in_progress",
}
# output_item.added
seq += 1
yield format_sse_event(
"response.output_item.added",
{
"type": "response.output_item.added",
"output_index": output_index,
"item": fc_item,
"sequence_number": seq,
},
)
# function_call_arguments.delta
seq += 1
yield format_sse_event(
"response.function_call_arguments.delta",
{
"type": "response.function_call_arguments.delta",
"item_id": fc_id,
"output_index": output_index,
"delta": arguments,
"sequence_number": seq,
},
)
# function_call_arguments.done
seq += 1
yield format_sse_event(
"response.function_call_arguments.done",
{
"type": "response.function_call_arguments.done",
"item_id": fc_id,
"output_index": output_index,
"arguments": arguments,
"sequence_number": seq,
},
)
# output_item.done
completed_fc = {
"type": "function_call",
"id": fc_id,
"call_id": call_id,
"name": name,
"arguments": arguments,
"status": "completed",
}
seq += 1
yield format_sse_event(
"response.output_item.done",
{
"type": "response.output_item.done",
"output_index": output_index,
"item": completed_fc,
"sequence_number": seq,
},
)
output_items.append(completed_fc)
output_index += 1
next_output_index = output_index
# Record metrics
usage_data = None
if last_output and last_output.finished:
end_time = time.perf_counter()
total_duration = end_time - start_time
ttft = (first_token_time - start_time) if first_token_time else total_duration
if getattr(engine, "is_diffusion_model", False):
gen_duration = total_duration
else:
gen_duration = end_time - (first_token_time or start_time)
get_server_metrics().record_request_complete(
prompt_tokens=last_output.prompt_tokens,
completion_tokens=last_output.completion_tokens,
cached_tokens=last_output.cached_tokens,
prefill_duration=ttft,
generation_duration=gen_duration,
model_id=resolved_model or request.model,
)
reasoning_token_count = (
len(engine.tokenizer.encode(reasoning_text)) if reasoning_text else 0
)
usage_data = {
"input_tokens": last_output.prompt_tokens,
"output_tokens": last_output.completion_tokens,
"total_tokens": last_output.prompt_tokens + last_output.completion_tokens,
"input_tokens_details": {"cached_tokens": last_output.cached_tokens},
"output_tokens_details": {"reasoning_tokens": reasoning_token_count},
}
# 13. response.completed — MUST always be sent
final_response = {
"id": response_id,
"object": "response",
"created_at": initial_response.created_at,
"model": request.model,
"status": "completed",
"output": output_items,
"usage": usage_data,
"tool_choice": request.tool_choice or "auto",
"tools": (
[t.model_dump(exclude_none=True) for t in request.tools]
if request.tools
else []
),
"temperature": request.temperature,
"top_p": request.top_p,
"max_output_tokens": request.max_output_tokens,
}
if request.previous_response_id:
final_response["previous_response_id"] = request.previous_response_id
seq += 1
yield format_sse_event(
"response.completed",
{
"type": "response.completed",
"response": final_response,
"sequence_number": seq,
},
)
# Store for future previous_response_id usage
if store_response:
_store_response_state(final_response, input_messages=input_messages or [])
@app.get("/v1/responses/{response_id}")
async def get_response(
response_id: str,
_: bool = Depends(verify_api_key),
):
"""Retrieve a stored response."""
data = _server_state.responses_store.get(response_id)
if data is None:
raise HTTPException(status_code=404, detail="Response not found")
return data
@app.delete("/v1/responses/{response_id}")
async def delete_response(
response_id: str,
_: bool = Depends(verify_api_key),
):
"""Delete a stored response."""
if not _server_state.responses_store.delete(response_id):
raise HTTPException(status_code=404, detail="Response not found")
return {"id": response_id, "object": "response.deleted", "deleted": True}
# =============================================================================
# MCP Initialization
# =============================================================================
async def init_mcp(config_path: str):
"""Initialize MCP manager from config file."""
try:
from omlx.mcp import MCPClientManager, ToolExecutor, load_mcp_config
config = load_mcp_config(config_path)
_server_state.mcp_manager = MCPClientManager(config)
await _server_state.mcp_manager.start()
_server_state.mcp_executor = ToolExecutor(_server_state.mcp_manager)
logger.info(
f"MCP initialized with {len(_server_state.mcp_manager.get_all_tools())} tools"
)
except ImportError:
logger.warning(
"MCP SDK not installed. MCP features disabled. "
"Install with: pip install mcp"
)
return
except Exception as e:
logger.error(
f"Failed to initialize MCP: {e}. "
"MCP features disabled. Fix your MCP config and restart."
)
return
# =============================================================================
# Main Entry Point
# =============================================================================
def main():
"""Run the server (use omlx CLI instead)."""
parser = argparse.ArgumentParser(
description="oMLX multi-model serving for Apple Silicon",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Multi-model serving
python -m omlx.server --model-dir /path/to/models
# With pinned models
python -m omlx.server --model-dir /path/to/models --pin llama-3b,qwen-7b
# With MCP tools
python -m omlx.server --model-dir /path/to/models --mcp-config mcp.json
Note: Use the omlx CLI for full feature support.
""",
)
parser.add_argument(
"--model-dir",
type=str,
required=True,
help="Directory containing model subdirectories",
)
parser.add_argument(
"--pin",
type=str,
default=None,
help="Comma-separated model names to keep always loaded",
)
parser.add_argument(
"--default-model",
type=str,
default=None,
help="Default model when not specified in request",
)
parser.add_argument(
"--host",
type=str,
default="0.0.0.0",
help="Host to bind to",
)
parser.add_argument(
"--port",
type=int,
default=8000,
help="Port to bind to",
)
parser.add_argument(
"--mcp-config",
type=str,
default=None,
help="Path to MCP configuration file (JSON/YAML)",
)
parser.add_argument(
"--max-tokens",
type=int,
default=32768,
help="Default max tokens for generation",
)
args = parser.parse_args()
# Set MCP config for lifespan
if args.mcp_config:
os.environ["OMLX_MCP_CONFIG"] = args.mcp_config
# Parse pinned models
pinned_models = args.pin.split(",") if args.pin else []
# Initialize server
init_server(
model_dir=args.model_dir,
pinned_models=pinned_models,
default_model=args.default_model,
max_tokens=args.max_tokens,
)
# Start server
import uvicorn
uvicorn.run(app, host=args.host, port=args.port)
if __name__ == "__main__":
main()