6788 lines
254 KiB
Python
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()
|