449 lines
14 KiB
Python
449 lines
14 KiB
Python
import base64
|
|
import functools
|
|
import json
|
|
import logging
|
|
import posixpath
|
|
import re
|
|
from typing import Any, AsyncGenerator, Iterator
|
|
from urllib.parse import urlparse
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from mlflow.environment_variables import MLFLOW_GATEWAY_URI
|
|
from mlflow.exceptions import MlflowException
|
|
from mlflow.gateway.constants import MLFLOW_AI_GATEWAY_MOSAICML_CHAT_SUPPORTED_MODEL_PREFIXES
|
|
from mlflow.gateway.exceptions import AIGatewayException
|
|
from mlflow.utils.uri import append_to_uri_path
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
_gateway_uri: str | None = None
|
|
|
|
DATABRICKS_SERVING_ENDPOINTS_PATH = "/serving-endpoints"
|
|
|
|
|
|
def normalize_databricks_base_url(base_url: str | None) -> str | None:
|
|
"""
|
|
Normalize Databricks base URL to include /serving-endpoints if not present.
|
|
|
|
Args:
|
|
base_url: The base URL to normalize (e.g., "https://my-workspace.databricks.com")
|
|
|
|
Returns:
|
|
The normalized URL with /serving-endpoints appended if needed, or None if input is None.
|
|
"""
|
|
if not base_url:
|
|
return base_url
|
|
|
|
base_url = base_url.rstrip("/")
|
|
if DATABRICKS_SERVING_ENDPOINTS_PATH in base_url:
|
|
return base_url
|
|
|
|
return f"{base_url}{DATABRICKS_SERVING_ENDPOINTS_PATH}"
|
|
|
|
|
|
def is_valid_endpoint_name(name: str) -> bool:
|
|
"""
|
|
Check whether a string contains any URL reserved characters, spaces, or characters other
|
|
than ASCII alphanumeric, underscore, hyphen, and dot.
|
|
|
|
Returns True if the string doesn't contain any of these characters.
|
|
"""
|
|
return bool(re.fullmatch(r"[a-zA-Z0-9_\-\.]+", name))
|
|
|
|
|
|
def check_configuration_route_name_collisions(config):
|
|
endpoints = config.get("endpoints") or []
|
|
routes = config.get("routes") or []
|
|
|
|
endpoint_names = [endpoint["name"] for endpoint in endpoints]
|
|
route_names = [route["name"] for route in routes]
|
|
|
|
merged_names = endpoint_names + route_names
|
|
if len(merged_names) != len(set(merged_names)):
|
|
raise MlflowException.invalid_parameter_value(
|
|
"Duplicate names found in endpoint / route configurations. "
|
|
"Please remove the duplicate endpoint / route name "
|
|
"from the configuration to ensure that endpoints / routes are created properly."
|
|
)
|
|
|
|
endpoint_config_dict = {endpoint["name"]: endpoint for endpoint in endpoints}
|
|
|
|
for route in routes:
|
|
route_name = route["name"]
|
|
route_task_type = route["task_type"]
|
|
|
|
traffic_percentage_sum = 0
|
|
for destination in route.get("destinations"):
|
|
dest_name = destination.get("name")
|
|
dest_traffic_percentage = destination.get("traffic_percentage")
|
|
traffic_percentage_sum += dest_traffic_percentage
|
|
if dest_name not in endpoint_names:
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"The route destination name must be a endpoint name, "
|
|
f"but the route '{route_name}' has an invalid destination name '{dest_name}'."
|
|
)
|
|
|
|
dest_endpoint_type = endpoint_config_dict[dest_name].get("endpoint_type")
|
|
if route_task_type != dest_endpoint_type:
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"The route destination endpoint types in the route '{route_name}' must have "
|
|
f"endpoint type '{route_task_type}' but got endpoint type "
|
|
f"'{dest_endpoint_type}'."
|
|
)
|
|
|
|
if not (0 <= dest_traffic_percentage <= 100):
|
|
raise MlflowException.invalid_parameter_value(
|
|
"The route destination traffic percentage must between 0 and 100."
|
|
)
|
|
|
|
if traffic_percentage_sum != 100:
|
|
raise MlflowException.invalid_parameter_value(
|
|
"For each route configuration, the traffic percentage sum of destinations "
|
|
f"must be 100, but got invalid configuration of route '{route_name}'."
|
|
)
|
|
|
|
|
|
def check_configuration_deprecated_fields(config):
|
|
endpoints = config.get("endpoints", [])
|
|
for endpoint in endpoints:
|
|
if "route_type" in endpoint:
|
|
raise MlflowException.invalid_parameter_value(
|
|
"The 'route_type' configuration key is not supported in the configuration file. "
|
|
"Use 'endpoint_type' instead."
|
|
)
|
|
|
|
|
|
def kill_child_processes(parent_pid):
|
|
"""
|
|
Gracefully terminate or kill child processes from a main process
|
|
"""
|
|
import psutil
|
|
|
|
parent = psutil.Process(parent_pid)
|
|
for child in parent.children(recursive=True):
|
|
try:
|
|
child.terminate()
|
|
except psutil.NoSuchProcess:
|
|
pass
|
|
_, still_alive = psutil.wait_procs(parent.children(), timeout=3)
|
|
for p in still_alive:
|
|
p.kill()
|
|
|
|
|
|
def _is_valid_uri(uri: str):
|
|
"""
|
|
Evaluates the basic structure of a provided gateway uri to determine if the scheme and
|
|
netloc are provided
|
|
"""
|
|
if uri == "databricks":
|
|
return True
|
|
try:
|
|
parsed = urlparse(uri)
|
|
return parsed.scheme == "databricks" or all([parsed.scheme, parsed.netloc])
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def set_gateway_uri(gateway_uri: str):
|
|
"""Sets the uri of a configured and running MLflow AI Gateway server in a global context.
|
|
Providing a valid uri and calling this function is required in order to use the MLflow
|
|
AI Gateway fluent APIs.
|
|
|
|
Args:
|
|
gateway_uri: The full uri of a running MLflow AI Gateway server or, if running on
|
|
Databricks, "databricks".
|
|
"""
|
|
if not _is_valid_uri(gateway_uri):
|
|
raise MlflowException.invalid_parameter_value(
|
|
"The gateway uri provided is missing required elements. Ensure that the schema "
|
|
"and netloc are provided."
|
|
)
|
|
|
|
global _gateway_uri
|
|
_gateway_uri = gateway_uri
|
|
|
|
|
|
def get_gateway_uri() -> str:
|
|
"""
|
|
Returns the currently set MLflow AI Gateway server uri iff set.
|
|
If the Gateway uri has not been set by using ``set_gateway_uri``, an ``MlflowException``
|
|
is raised.
|
|
"""
|
|
if _gateway_uri is not None:
|
|
return _gateway_uri
|
|
elif uri := MLFLOW_GATEWAY_URI.get():
|
|
return uri
|
|
else:
|
|
raise MlflowException(
|
|
"No Gateway server uri has been set. Please either set the MLflow Gateway URI via "
|
|
"`mlflow.gateway.set_gateway_uri()` or set the environment variable "
|
|
f"{MLFLOW_GATEWAY_URI} to the running Gateway API server's uri"
|
|
)
|
|
|
|
|
|
def assemble_uri_path(paths: list[str]) -> str:
|
|
"""Assemble a correct URI path from a list of path parts.
|
|
|
|
Args:
|
|
paths: A list of strings representing parts of a URI path.
|
|
|
|
Returns:
|
|
A string representing the complete assembled URI path.
|
|
|
|
"""
|
|
stripped_paths = [path.strip("/").lstrip("/") for path in paths if path]
|
|
return "/" + posixpath.join(*stripped_paths) if stripped_paths else "/"
|
|
|
|
|
|
def resolve_route_url(base_url: str, route: str) -> str:
|
|
"""
|
|
Performs a validation on whether the returned value is a fully qualified url (as the case
|
|
with Databricks) or requires the assembly of a fully qualified url by appending the
|
|
Route return route_url to the base url of the AI Gateway server.
|
|
|
|
Args:
|
|
base_url: The base URL. Should include the scheme and domain, e.g.,
|
|
``http://127.0.0.1:6000``.
|
|
route: The route to be appended to the base URL, e.g., ``/api/2.0/gateway/routes/`` or,
|
|
in the case of Databricks, the fully qualified url.
|
|
|
|
Returns:
|
|
The complete URL, either directly returned or formed and returned by joining the
|
|
base URL and the route path.
|
|
"""
|
|
return route if _is_valid_uri(route) else append_to_uri_path(base_url, route)
|
|
|
|
|
|
class SearchRoutesToken:
|
|
def __init__(self, index: int):
|
|
self._index = index
|
|
|
|
@property
|
|
def index(self):
|
|
return self._index
|
|
|
|
@classmethod
|
|
def decode(cls, encoded_token: str):
|
|
try:
|
|
decoded_token = base64.b64decode(encoded_token)
|
|
parsed_token = json.loads(decoded_token)
|
|
index = int(parsed_token.get("index"))
|
|
except Exception as e:
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"Invalid SearchRoutes token: {encoded_token}. The index is not defined as a "
|
|
"value that can be represented as a positive integer."
|
|
) from e
|
|
|
|
if index < 0:
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"Invalid SearchRoutes token: {encoded_token}. The index cannot be negative."
|
|
)
|
|
|
|
return cls(index=index)
|
|
|
|
def encode(self) -> str:
|
|
token_json = json.dumps({
|
|
"index": self.index,
|
|
})
|
|
encoded_token_bytes = base64.b64encode(bytes(token_json, "utf-8"))
|
|
return encoded_token_bytes.decode("utf-8")
|
|
|
|
|
|
def is_valid_mosiacml_chat_model(model_name: str) -> bool:
|
|
return any(
|
|
model_name.lower().startswith(supported)
|
|
for supported in MLFLOW_AI_GATEWAY_MOSAICML_CHAT_SUPPORTED_MODEL_PREFIXES
|
|
)
|
|
|
|
|
|
def is_valid_ai21labs_model(model_name: str) -> bool:
|
|
return model_name in {"j2-ultra", "j2-mid", "j2-light"}
|
|
|
|
|
|
def strip_sse_prefix(s: str) -> str:
|
|
# https://html.spec.whatwg.org/multipage/server-sent-events.html
|
|
return re.sub(r"^data:\s+", "", s)
|
|
|
|
|
|
def parse_sse_lines(chunk: bytes | str) -> Iterator[dict[str, Any]]:
|
|
"""
|
|
Parse SSE-formatted data from a chunk of bytes or string.
|
|
Note that this function assumes that the chunk is complete,
|
|
and incomplete chunks need to be handled by handle_incomplete_chunks.
|
|
|
|
Handles the standard SSE format:
|
|
- Lines prefixed with "data:"
|
|
- [DONE] markers (skipped)
|
|
- Multi-line chunks (split by newlines)
|
|
|
|
Args:
|
|
chunk: Bytes or string containing SSE data.
|
|
|
|
Yields:
|
|
Parsed JSON data dictionaries from the SSE data lines.
|
|
Yields nothing if chunk is empty, invalid, or contains only [DONE].
|
|
"""
|
|
if isinstance(chunk, bytes):
|
|
try:
|
|
chunk_str = chunk.decode("utf-8")
|
|
except UnicodeDecodeError:
|
|
return
|
|
else:
|
|
chunk_str = chunk
|
|
|
|
chunk_str = chunk_str.strip()
|
|
if not chunk_str:
|
|
return
|
|
|
|
for line in chunk_str.split("\n"):
|
|
line = line.strip()
|
|
if not line or line.startswith("event:"):
|
|
continue
|
|
|
|
if not line.startswith("data:"):
|
|
continue
|
|
|
|
data_str = line[5:].strip()
|
|
if not data_str or data_str == "[DONE]":
|
|
continue
|
|
|
|
try:
|
|
yield json.loads(data_str)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
|
|
async def stream_sse_data(
|
|
stream: AsyncGenerator[bytes, Any],
|
|
) -> AsyncGenerator[dict[str, Any], None]:
|
|
"""
|
|
Wrap a streaming response and yield parsed SSE data dictionaries.
|
|
|
|
This is a higher-level utility that combines handle_incomplete_chunks()
|
|
with SSE parsing. Use this for processing SSE streams where you want
|
|
direct access to the parsed JSON data.
|
|
|
|
Args:
|
|
stream: Async generator yielding raw bytes from an SSE stream.
|
|
|
|
Yields:
|
|
Parsed JSON dictionaries from SSE data lines. Skips [DONE] markers
|
|
and empty/invalid lines.
|
|
"""
|
|
async for chunk in handle_incomplete_chunks(stream):
|
|
chunk = chunk.strip()
|
|
if not chunk:
|
|
continue
|
|
|
|
data_str = strip_sse_prefix(chunk.decode("utf-8"))
|
|
if data_str == "[DONE]":
|
|
continue
|
|
|
|
try:
|
|
yield json.loads(data_str)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
|
|
def to_sse_chunk(data: str) -> str:
|
|
# https://html.spec.whatwg.org/multipage/server-sent-events.html
|
|
return f"data: {data}\n\n"
|
|
|
|
|
|
def to_sse_error_chunk(error: Exception) -> str:
|
|
"""Create an SSE-formatted error chunk."""
|
|
error_data = {
|
|
"error": {
|
|
"message": str(error),
|
|
"type": type(error).__name__,
|
|
}
|
|
}
|
|
return to_sse_chunk(json.dumps(error_data))
|
|
|
|
|
|
async def safe_stream(
|
|
stream: AsyncGenerator[str | bytes, None],
|
|
as_bytes: bool = False,
|
|
) -> AsyncGenerator[bytes | str, None]:
|
|
"""
|
|
Wrap a streaming generator with exception handling.
|
|
|
|
When streaming responses, if an error occurs mid-stream after HTTP headers
|
|
have been sent, we can't raise an HTTPException. Instead, this wrapper
|
|
catches exceptions and yields an error chunk so the client can receive
|
|
the error information.
|
|
|
|
Args:
|
|
stream: The async generator to wrap.
|
|
as_bytes: If True, encode the error chunk as bytes. Use this when the
|
|
stream yields bytes (e.g., passthrough endpoints).
|
|
|
|
Yields:
|
|
Chunks from the stream, or an error chunk if an exception occurs.
|
|
"""
|
|
try:
|
|
async for chunk in stream:
|
|
yield chunk
|
|
except Exception as e:
|
|
_logger.exception("Error during streaming response")
|
|
error_chunk = to_sse_error_chunk(e)
|
|
yield error_chunk.encode("utf-8") if as_bytes else error_chunk
|
|
|
|
|
|
def _find_boundary(buffer: bytes) -> int:
|
|
try:
|
|
return buffer.index(b"\n")
|
|
except ValueError:
|
|
return -1
|
|
|
|
|
|
async def handle_incomplete_chunks(
|
|
stream: AsyncGenerator[bytes, Any],
|
|
) -> AsyncGenerator[bytes, Any]:
|
|
"""
|
|
Wraps a streaming response and handles incomplete chunks from the server.
|
|
See https://community.openai.com/t/incomplete-stream-chunks-for-completions-api/383520
|
|
for more information.
|
|
"""
|
|
buffer = b""
|
|
async for chunk in stream:
|
|
buffer += chunk
|
|
while (boundary := _find_boundary(buffer)) != -1:
|
|
yield buffer[:boundary]
|
|
buffer = buffer[boundary + 1 :]
|
|
|
|
if buffer != b"":
|
|
yield buffer
|
|
|
|
|
|
async def make_streaming_response(resp):
|
|
from starlette.responses import StreamingResponse
|
|
|
|
if isinstance(resp, AsyncGenerator):
|
|
return StreamingResponse(
|
|
(to_sse_chunk(d.model_dump_json()) async for d in resp),
|
|
media_type="text/event-stream",
|
|
)
|
|
else:
|
|
return await resp
|
|
|
|
|
|
def translate_http_exception(func):
|
|
"""
|
|
Decorator for translating MLflow exceptions to HTTP exceptions
|
|
"""
|
|
|
|
@functools.wraps(func)
|
|
async def wrapper(*args, **kwargs):
|
|
try:
|
|
return await func(*args, **kwargs)
|
|
except AIGatewayException as e:
|
|
raise HTTPException(status_code=e.status_code, detail=e.detail)
|
|
except MlflowException as e:
|
|
raise HTTPException(
|
|
status_code=e.get_http_status_code(),
|
|
detail={"error_code": e.error_code, "message": e.message},
|
|
)
|
|
|
|
return wrapper
|