314 lines
13 KiB
Python
314 lines
13 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
"""FastAPI router for the Cohere Chat v2 API.
|
|
|
|
Exposes two routes:
|
|
|
|
* ``POST /cohere/v2/chat`` - the chat endpoint itself.
|
|
* ``POST /cohere/v2/chat/render`` - tokenize a request without running
|
|
generation, mirroring ``POST /v1/chat/completions/render``.
|
|
|
|
The Cohere v2 protocol models are sourced from the official ``cohere``
|
|
Python SDK (``pip install cohere``). To keep that an *optional*
|
|
dependency for vLLM, the SDK-dependent imports - and the route handlers
|
|
themselves - are gated on a one-shot probe at module load. If the SDK
|
|
isn't installed, :func:`attach_router` becomes a no-op (with an info
|
|
log) and vLLM continues to boot normally.
|
|
|
|
Even when the SDK is installed, :func:`attach_router` also requires
|
|
``VLLM_ENABLE_COHERE_API=1`` in the environment before it will expose
|
|
the chat route. The render route additionally requires scale-out
|
|
endpoints to be enabled, i.e. ``--enable-scale-out`` or
|
|
``--tokens-only``. These gates keep unrelated deployments from
|
|
accidentally exposing the APIs.
|
|
|
|
Note: the handlers must live at module scope (not inside
|
|
``attach_router``) so that FastAPI's ``typing.get_type_hints`` resolves
|
|
the ``CohereChatV2Request`` body annotation against the module's
|
|
globals. Defining them locally inside ``attach_router`` would hide the
|
|
type from ``get_type_hints``, causing FastAPI to silently degrade the
|
|
body parameter into a query parameter and reject every request with
|
|
422.
|
|
"""
|
|
|
|
import json
|
|
from http import HTTPStatus
|
|
|
|
from fastapi import APIRouter, Depends, FastAPI, Request
|
|
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
|
|
import vllm.envs as envs
|
|
from vllm.entrypoints.serve.engine.protocol import ErrorResponse
|
|
from vllm.entrypoints.serve.exception_handling.utils import sanitize_message
|
|
from vllm.entrypoints.serve.utils.api_utils import (
|
|
load_aware_call,
|
|
validate_json_request,
|
|
with_cancellation,
|
|
)
|
|
from vllm.logger import init_logger
|
|
|
|
_COHERE_PATH_PREFIX = "/cohere/"
|
|
|
|
logger = init_logger(__name__)
|
|
|
|
|
|
try:
|
|
import cohere # noqa: F401 -- dependency probe
|
|
except ImportError:
|
|
_SDK_AVAILABLE = False
|
|
else:
|
|
_SDK_AVAILABLE = True
|
|
|
|
|
|
if _SDK_AVAILABLE:
|
|
from vllm.entrypoints.cohere.protocol import (
|
|
CohereChatV2Request,
|
|
CohereChatV2Response,
|
|
CohereError,
|
|
)
|
|
from vllm.entrypoints.cohere.serving import CohereServingChatV2
|
|
from vllm.entrypoints.scale_out.render.serving import ServingRender
|
|
from vllm.entrypoints.scale_out.token_in_token_out.protocol import GenerateRequest
|
|
|
|
router = APIRouter()
|
|
render_router = APIRouter()
|
|
|
|
def _serving(request: Request) -> CohereServingChatV2 | None:
|
|
return getattr(request.app.state, "cohere_serving_chat_v2", None)
|
|
|
|
def _serving_render(request: Request) -> ServingRender | None:
|
|
return getattr(request.app.state, "serving_render", None)
|
|
|
|
def _request_id(raw_request: Request | None) -> str | None:
|
|
"""Best-effort lookup of the active request id.
|
|
|
|
Prefers the id the underlying chat handler stamped onto
|
|
``raw_request.state.request_metadata`` (if it got that far before
|
|
failing), falling back to the ``X-Request-Id`` HTTP header. May
|
|
return ``None`` if neither is available, in which case the field
|
|
is omitted from the response.
|
|
"""
|
|
if raw_request is None:
|
|
return None
|
|
meta = getattr(raw_request.state, "request_metadata", None)
|
|
if meta is not None and getattr(meta, "request_id", None):
|
|
return meta.request_id
|
|
return raw_request.headers.get("X-Request-Id")
|
|
|
|
def _error_response(
|
|
error: ErrorResponse,
|
|
raw_request: Request | None,
|
|
*,
|
|
fallback_status: int = HTTPStatus.BAD_REQUEST,
|
|
) -> JSONResponse:
|
|
"""Translate vLLM's internal error envelope into Cohere's shape."""
|
|
info = error.error
|
|
status = info.code or fallback_status
|
|
return JSONResponse(
|
|
status_code=status,
|
|
content=CohereError(
|
|
message=sanitize_message(info.message),
|
|
id=_request_id(raw_request),
|
|
).model_dump(exclude_none=True),
|
|
)
|
|
|
|
@router.post(
|
|
"/cohere/v2/chat",
|
|
dependencies=[Depends(validate_json_request)],
|
|
responses={
|
|
HTTPStatus.OK.value: {"content": {"text/event-stream": {}}},
|
|
HTTPStatus.BAD_REQUEST.value: {"model": CohereError},
|
|
HTTPStatus.NOT_FOUND.value: {"model": CohereError},
|
|
HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": CohereError},
|
|
},
|
|
)
|
|
@with_cancellation
|
|
@load_aware_call
|
|
async def chat_v2(request: CohereChatV2Request, raw_request: Request):
|
|
handler = _serving(raw_request)
|
|
if handler is None:
|
|
return JSONResponse(
|
|
status_code=HTTPStatus.NOT_IMPLEMENTED.value,
|
|
content=CohereError(
|
|
message="The model does not support the Cohere v2 chat API.",
|
|
id=_request_id(raw_request),
|
|
).model_dump(exclude_none=True),
|
|
)
|
|
|
|
try:
|
|
result = await handler.create_chat_v2(request, raw_request)
|
|
except Exception as e: # noqa: BLE001 - report as 500 for parity
|
|
logger.exception("Error in /cohere/v2/chat: %s", e)
|
|
return JSONResponse(
|
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value,
|
|
content=CohereError(
|
|
message=sanitize_message(str(e)),
|
|
id=_request_id(raw_request),
|
|
).model_dump(exclude_none=True),
|
|
)
|
|
|
|
match result:
|
|
case ErrorResponse():
|
|
return _error_response(result, raw_request)
|
|
case CohereChatV2Response():
|
|
return JSONResponse(content=result.model_dump(exclude_none=True))
|
|
case _:
|
|
return StreamingResponse(content=result, media_type="text/event-stream")
|
|
|
|
@render_router.post(
|
|
"/cohere/v2/chat/render",
|
|
dependencies=[Depends(validate_json_request)],
|
|
response_model=GenerateRequest,
|
|
responses={
|
|
HTTPStatus.BAD_REQUEST.value: {"model": CohereError},
|
|
HTTPStatus.NOT_FOUND.value: {"model": CohereError},
|
|
HTTPStatus.NOT_IMPLEMENTED.value: {"model": CohereError},
|
|
HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": CohereError},
|
|
},
|
|
)
|
|
@with_cancellation
|
|
async def chat_v2_render(request: CohereChatV2Request, raw_request: Request):
|
|
"""Tokenize a Cohere v2 chat request without running generation.
|
|
|
|
The Cohere counterpart to ``POST /v1/chat/completions/render``.
|
|
The v2 body goes through the same conversion
|
|
``/cohere/v2/chat`` uses and is then handed to the shared
|
|
:class:`ServingRender`, so the returned ``GenerateRequest``
|
|
carries the prompt tokens and sampling params the chat endpoint
|
|
would have sent to the engine.
|
|
"""
|
|
handler = _serving(raw_request)
|
|
render_handler = _serving_render(raw_request)
|
|
if handler is None or render_handler is None:
|
|
return JSONResponse(
|
|
status_code=HTTPStatus.NOT_IMPLEMENTED.value,
|
|
content=CohereError(
|
|
message=(
|
|
"The model does not support the Cohere v2 chat render API."
|
|
),
|
|
id=_request_id(raw_request),
|
|
).model_dump(exclude_none=True),
|
|
)
|
|
|
|
try:
|
|
chat_request = handler.to_chat_completion_request(request)
|
|
result = await render_handler.render_chat_request(chat_request)
|
|
except Exception as e: # noqa: BLE001 - report as 500 for parity
|
|
logger.exception("Error in /cohere/v2/chat/render: %s", e)
|
|
return JSONResponse(
|
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value,
|
|
content=CohereError(
|
|
message=sanitize_message(str(e)),
|
|
id=_request_id(raw_request),
|
|
).model_dump(exclude_none=True),
|
|
)
|
|
|
|
if isinstance(result, ErrorResponse):
|
|
return _error_response(result, raw_request)
|
|
|
|
return JSONResponse(content=result.model_dump())
|
|
|
|
class CohereErrorEnvelopeMiddleware(BaseHTTPMiddleware):
|
|
"""Rewrite vLLM error bodies into the Cohere ``{message, id}`` shape.
|
|
|
|
The endpoint handler above already returns :class:`CohereError` for
|
|
errors it owns, but globally-registered exception handlers (e.g.
|
|
:func:`validation_exception_handler` for pydantic body errors,
|
|
:func:`http_exception_handler`, engine error handlers) fire *before*
|
|
the handler runs and produce vLLM's internal
|
|
``ErrorResponse`` shape (``{"error": {"message": ...}}``). That
|
|
shape doesn't match the ``CohereError`` schema advertised on the
|
|
route's OpenAPI ``responses``, so clients (and schema-conformance
|
|
tests like ``test_openai_schema.py``) would see a mismatch on
|
|
those paths. This middleware normalises any error body on
|
|
``/cohere/*`` responses to :class:`CohereError`.
|
|
"""
|
|
|
|
async def dispatch(self, request: Request, call_next):
|
|
response = await call_next(request)
|
|
if not request.url.path.startswith(_COHERE_PATH_PREFIX):
|
|
return response
|
|
if response.status_code < 400:
|
|
return response
|
|
content_type = response.headers.get("content-type", "")
|
|
if not content_type.startswith("application/json"):
|
|
return response
|
|
|
|
body = b"".join([chunk async for chunk in response.body_iterator])
|
|
translated = _translate_vllm_error_body(body, request)
|
|
if translated is not None:
|
|
return translated
|
|
passthrough_headers = {
|
|
k: v
|
|
for k, v in response.headers.items()
|
|
if k.lower() != "content-length"
|
|
}
|
|
return Response(
|
|
content=body,
|
|
status_code=response.status_code,
|
|
headers=passthrough_headers,
|
|
media_type=content_type,
|
|
)
|
|
|
|
def _translate_vllm_error_body(raw: bytes, request: Request) -> JSONResponse | None:
|
|
"""Translate a vLLM ``ErrorResponse`` body to a ``CohereError`` body.
|
|
|
|
Returns ``None`` if ``raw`` does not match the vLLM error envelope
|
|
(which signals the middleware to pass the body through unchanged).
|
|
"""
|
|
try:
|
|
data = json.loads(raw)
|
|
except (json.JSONDecodeError, TypeError, ValueError):
|
|
return None
|
|
if not (
|
|
isinstance(data, dict)
|
|
and isinstance(data.get("error"), dict)
|
|
and "message" in data["error"]
|
|
):
|
|
return None
|
|
try:
|
|
err = ErrorResponse.model_validate(data)
|
|
except Exception: # noqa: BLE001 - malformed envelope; pass through
|
|
return None
|
|
return _error_response(err, request)
|
|
|
|
|
|
def attach_router(app: FastAPI) -> None:
|
|
"""Register the enabled ``/cohere/v2/chat`` routes on ``app``.
|
|
|
|
No-op when either:
|
|
|
|
* the ``VLLM_ENABLE_COHERE_API`` env var isn't set to ``1``. The
|
|
Cohere v2 endpoints are opt-in because they carry Cohere-specific
|
|
request/response semantics (grounding citations, tool_plan,
|
|
PLAN/THINKING_CONTENT blocks) that are only meaningful when
|
|
serving a Cohere Command-family model.
|
|
* the optional ``cohere`` SDK isn't installed (the v2 protocol
|
|
models live there)
|
|
|
|
The two skip paths log at different levels: an operator who set
|
|
``VLLM_ENABLE_COHERE_API=1`` but forgot to install ``cohere`` sees
|
|
a WARNING (they explicitly asked for the endpoints and they're
|
|
silently absent), whereas the default-off skip logs at debug.
|
|
"""
|
|
enabled = envs.VLLM_ENABLE_COHERE_API
|
|
if not enabled:
|
|
logger.debug(
|
|
"VLLM_ENABLE_COHERE_API is not set; /cohere/v2/chat endpoints "
|
|
"disabled. Set VLLM_ENABLE_COHERE_API=1 to enable them."
|
|
)
|
|
return
|
|
if not _SDK_AVAILABLE:
|
|
logger.warning(
|
|
"VLLM_ENABLE_COHERE_API=1 but the `cohere` SDK is not "
|
|
"installed; /cohere/v2/chat will not be exposed. Install "
|
|
"with `pip install cohere` to enable the endpoints."
|
|
)
|
|
return
|
|
app.include_router(router)
|
|
args = getattr(app.state, "args", None)
|
|
if getattr(args, "enable_scale_out", False) or getattr(args, "tokens_only", False):
|
|
app.include_router(render_router)
|
|
app.add_middleware(CohereErrorEnvelopeMiddleware)
|