1516 lines
59 KiB
Python
1516 lines
59 KiB
Python
"""
|
|
Database-backed Gateway API endpoints for MLflow Server.
|
|
|
|
This module provides dynamic gateway endpoints that are configured from the database
|
|
rather than from a static YAML configuration file. It integrates the AI Gateway
|
|
functionality directly into the MLflow tracking server.
|
|
"""
|
|
|
|
import functools
|
|
import logging
|
|
import sys
|
|
import time
|
|
from collections.abc import AsyncIterable, Callable
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
from fastapi.responses import StreamingResponse
|
|
|
|
from mlflow.entities.gateway_endpoint import GatewayModelLinkageType
|
|
from mlflow.exceptions import MlflowException
|
|
from mlflow.gateway.budget import check_budget_limit, make_budget_on_complete
|
|
from mlflow.gateway.config import (
|
|
AmazonBedrockConfig,
|
|
AnthropicConfig,
|
|
EndpointConfig,
|
|
EndpointType,
|
|
GatewayRequestType,
|
|
GeminiConfig,
|
|
LiteLLMConfig,
|
|
MistralConfig,
|
|
OpenAIAPIType,
|
|
OpenAIConfig,
|
|
Provider,
|
|
VertexAIConfig,
|
|
_AuthConfigKey,
|
|
_OpenAICompatibleConfig,
|
|
)
|
|
from mlflow.gateway.constants import MLFLOW_GATEWAY_CALLER_HEADER, GatewayCaller
|
|
from mlflow.gateway.guardrail_utils import (
|
|
extract_auth_headers,
|
|
load_guardrails,
|
|
run_post_llm_guardrails,
|
|
run_post_llm_guardrails_passthrough,
|
|
run_pre_llm_guardrails,
|
|
)
|
|
from mlflow.gateway.guardrails import (
|
|
_SANITIZE_BYPASS_HEADER,
|
|
GuardrailViolation,
|
|
JudgeGuardrail,
|
|
)
|
|
from mlflow.gateway.providers import get_provider
|
|
from mlflow.gateway.providers.base import (
|
|
PASSTHROUGH_ROUTES,
|
|
BaseProvider,
|
|
FallbackProvider,
|
|
PassthroughAction,
|
|
TrafficRouteProvider,
|
|
)
|
|
from mlflow.gateway.providers.utils import provider_call_duration_ms
|
|
from mlflow.gateway.schemas import chat, embeddings
|
|
from mlflow.gateway.tracing_utils import (
|
|
aggregate_anthropic_messages_stream_chunks,
|
|
aggregate_chat_stream_chunks,
|
|
aggregate_gemini_stream_generate_content_chunks,
|
|
aggregate_openai_responses_stream_chunks,
|
|
maybe_traced_gateway_call,
|
|
)
|
|
from mlflow.gateway.utils import safe_stream, to_sse_chunk, translate_http_exception
|
|
from mlflow.protos.databricks_pb2 import RESOURCE_DOES_NOT_EXIST
|
|
from mlflow.store.tracking.abstract_store import AbstractStore
|
|
from mlflow.store.tracking.gateway.config_resolver import get_endpoint_config
|
|
from mlflow.store.tracking.gateway.entities import (
|
|
GatewayEndpointConfig,
|
|
GatewayModelConfig,
|
|
RoutingStrategy,
|
|
)
|
|
from mlflow.store.tracking.sqlalchemy_store import SqlAlchemyStore
|
|
from mlflow.telemetry.events import GatewayInvocationEvent, GatewayInvocationType
|
|
from mlflow.telemetry.track import _record_event
|
|
from mlflow.tracing.constant import TraceMetadataKey
|
|
from mlflow.tracking._tracking_service.utils import _get_store
|
|
from mlflow.types.chat import ChatCompletionRequest
|
|
from mlflow.utils.provider_filter import is_provider_allowed, normalize_provider_name
|
|
from mlflow.utils.workspace_context import get_request_workspace
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
gateway_router = APIRouter(prefix="/gateway", tags=["gateway"])
|
|
|
|
|
|
async def _get_request_body(request: Request) -> dict[str, Any]:
|
|
"""
|
|
Get request body, using cached version if available.
|
|
|
|
The auth middleware may have already parsed the request body for permission
|
|
validation. Since Starlette request body can only be read once, we cache
|
|
the parsed body in request.state.cached_body for reuse by route handlers.
|
|
|
|
Args:
|
|
request: The FastAPI Request object.
|
|
|
|
Returns:
|
|
Parsed JSON body as a dictionary.
|
|
|
|
Raises:
|
|
HTTPException: If the request body is not valid JSON.
|
|
"""
|
|
# Check if body was already parsed by auth middleware
|
|
cached_body = getattr(request.state, "cached_body", None)
|
|
if isinstance(cached_body, dict):
|
|
return cached_body
|
|
|
|
# Otherwise parse it now
|
|
try:
|
|
return await request.json()
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=f"Invalid JSON payload: {e!s}")
|
|
|
|
|
|
def _get_user_metadata(request: Request) -> dict[str, Any]:
|
|
"""
|
|
Extract user metadata from request state for tracing.
|
|
|
|
The auth middleware stores the authenticated user's info in request.state.
|
|
|
|
Args:
|
|
request: The FastAPI Request object.
|
|
|
|
Returns:
|
|
Dictionary with user metadata (username and user_id if available).
|
|
"""
|
|
metadata = {}
|
|
if username := getattr(request.state, "username", None):
|
|
metadata[TraceMetadataKey.AUTH_USERNAME] = username
|
|
if user_id := getattr(request.state, "user_id", None):
|
|
metadata[TraceMetadataKey.AUTH_USER_ID] = str(user_id)
|
|
return metadata
|
|
|
|
|
|
def _record_gateway_invocation(invocation_type: GatewayInvocationType) -> Callable[..., Any]:
|
|
"""
|
|
Decorator for gateway invocation endpoints that records telemetry:
|
|
success/failure status, duration, streaming mode, and caller.
|
|
|
|
As a side effect, relays provider call duration to the gateway timing middleware by
|
|
writing `request.state.gateway_provider_duration_ms`. This is required because
|
|
Starlette's call_next() copies the ContextVar context for the handler task, so
|
|
mutations to provider_call_duration_ms don't propagate back to the middleware.
|
|
|
|
Timing headers (X-MLflow-Gateway-Duration-Ms, X-MLflow-Gateway-Overhead-Duration-Ms)
|
|
are injected by gateway_timing_middleware in fastapi_app.py.
|
|
|
|
Args:
|
|
invocation_type: The type of invocation endpoint.
|
|
"""
|
|
|
|
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
@functools.wraps(func)
|
|
async def wrapper(*args, **kwargs):
|
|
start_time = time.perf_counter()
|
|
success = True
|
|
result = None
|
|
|
|
# Extract caller header from the Request object if present,
|
|
# only accepting known caller values to avoid logging arbitrary input.
|
|
caller = None
|
|
request = next((a for a in (*args, *kwargs.values()) if isinstance(a, Request)), None)
|
|
if request is not None:
|
|
raw_caller = request.headers.get(MLFLOW_GATEWAY_CALLER_HEADER)
|
|
if raw_caller in {e.value for e in GatewayCaller}:
|
|
caller = raw_caller
|
|
|
|
try:
|
|
result = await func(*args, **kwargs)
|
|
except Exception:
|
|
success = False
|
|
raise
|
|
finally:
|
|
duration_ms = int((time.perf_counter() - start_time) * 1000)
|
|
provider_duration = int(provider_call_duration_ms.get())
|
|
is_streaming = isinstance(result, StreamingResponse)
|
|
params = {
|
|
"is_streaming": is_streaming,
|
|
"invocation_type": invocation_type,
|
|
}
|
|
# provider_call_duration_ms is only updated by send_request()
|
|
# (non-streaming); send_stream_request() never sets it, so
|
|
# timing fields would always be 0 for streaming responses.
|
|
if not is_streaming:
|
|
params["provider_duration_ms"] = provider_duration
|
|
params["gateway_overhead_ms"] = max(0, duration_ms - provider_duration)
|
|
if caller:
|
|
params["caller"] = caller
|
|
if request is not None:
|
|
params["has_traceparent"] = request.headers.get("traceparent") is not None
|
|
auth_mod = sys.modules.get("mlflow.server.auth")
|
|
params["auth_enabled"] = auth_mod.is_auth_enabled() if auth_mod else False
|
|
if endpoint_id := getattr(request.state, "endpoint_id", None):
|
|
params["endpoint_id"] = endpoint_id
|
|
# Prefer the actual provider from the response (set by
|
|
# BaseProvider after the call) over the endpoint config
|
|
# estimate, which may not reflect traffic-split/fallback.
|
|
actual_provider = getattr(result, "provider", None)
|
|
if provider := (actual_provider or getattr(request.state, "provider", None)):
|
|
params["provider"] = provider
|
|
_record_event(
|
|
GatewayInvocationEvent,
|
|
params=params,
|
|
success=success,
|
|
duration_ms=duration_ms,
|
|
)
|
|
# Relay provider timing to the middleware via request.state.
|
|
# ContextVar values set in the handler task don't propagate back
|
|
# to the middleware task (Starlette copies the context for call_next).
|
|
if request is not None:
|
|
request.state.gateway_provider_duration_ms = int(
|
|
provider_call_duration_ms.get()
|
|
)
|
|
|
|
return result
|
|
|
|
return wrapper
|
|
|
|
return decorator
|
|
|
|
|
|
def _set_gateway_telemetry_state(request: Request, endpoint_config) -> None:
|
|
"""Set endpoint_id and provider on request.state for telemetry attribution."""
|
|
request.state.endpoint_id = endpoint_config.endpoint_id
|
|
if endpoint_config.models:
|
|
primary_model = next(
|
|
(
|
|
m
|
|
for m in endpoint_config.models
|
|
if m.linkage_type == GatewayModelLinkageType.PRIMARY
|
|
),
|
|
endpoint_config.models[0],
|
|
)
|
|
request.state.provider = str(primary_model.provider)
|
|
|
|
|
|
def _build_openai_compatible_config(model_config: "GatewayModelConfig"):
|
|
"""Build an _OpenAICompatibleConfig for providers that use the OpenAI API format."""
|
|
auth_config = model_config.auth_config or {}
|
|
return _OpenAICompatibleConfig(
|
|
api_key=model_config.secret_value.get(_AuthConfigKey.API_KEY),
|
|
api_base=auth_config.get(_AuthConfigKey.API_BASE),
|
|
)
|
|
|
|
|
|
def _build_endpoint_config(
|
|
endpoint_name: str,
|
|
model_config: GatewayModelConfig,
|
|
endpoint_type: EndpointType,
|
|
) -> EndpointConfig:
|
|
"""
|
|
Build an EndpointConfig from model configuration.
|
|
|
|
This function combines provider config building and endpoint config building
|
|
into a single operation.
|
|
|
|
Args:
|
|
endpoint_name: The endpoint name.
|
|
model_config: The model configuration object with decrypted secrets.
|
|
endpoint_type: Endpoint type (chat or embeddings).
|
|
|
|
Returns:
|
|
EndpointConfig instance ready for provider instantiation.
|
|
|
|
Raises:
|
|
MlflowException: If provider configuration is invalid.
|
|
"""
|
|
provider_name = model_config.provider
|
|
if not is_provider_allowed(provider_name):
|
|
_logger.debug(
|
|
"Provider '%s' blocked by MLFLOW_GATEWAY_ALLOWED_PROVIDERS",
|
|
provider_name,
|
|
)
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"Provider '{provider_name}' is not allowed by the current gateway provider policy."
|
|
)
|
|
|
|
provider_config = None
|
|
|
|
if model_config.provider == Provider.OPENAI:
|
|
auth_config = model_config.auth_config or {}
|
|
openai_config = {
|
|
"openai_api_key": model_config.secret_value.get(_AuthConfigKey.API_KEY),
|
|
}
|
|
|
|
# Check if this is Azure OpenAI (requires api_type, deployment_name, api_base, api_version)
|
|
if "api_type" in auth_config and auth_config["api_type"] in ("azure", "azuread"):
|
|
openai_config["openai_api_type"] = auth_config["api_type"]
|
|
openai_config["openai_api_base"] = auth_config.get(_AuthConfigKey.API_BASE)
|
|
openai_config["openai_deployment_name"] = auth_config.get("deployment_name")
|
|
openai_config["openai_api_version"] = auth_config.get("api_version")
|
|
else:
|
|
# Standard OpenAI
|
|
if _AuthConfigKey.API_BASE in auth_config:
|
|
openai_config["openai_api_base"] = auth_config[_AuthConfigKey.API_BASE]
|
|
if "organization" in auth_config:
|
|
openai_config["openai_organization"] = auth_config["organization"]
|
|
|
|
provider_config = OpenAIConfig(**openai_config)
|
|
elif model_config.provider == Provider.AZURE:
|
|
auth_config = model_config.auth_config or {}
|
|
model_config.provider = Provider.OPENAI
|
|
provider_config = OpenAIConfig(
|
|
openai_api_type=OpenAIAPIType.AZURE,
|
|
openai_api_key=model_config.secret_value.get(_AuthConfigKey.API_KEY),
|
|
openai_api_base=auth_config.get(_AuthConfigKey.API_BASE),
|
|
openai_deployment_name=model_config.model_name,
|
|
openai_api_version=auth_config.get("api_version"),
|
|
)
|
|
elif model_config.provider == Provider.ANTHROPIC:
|
|
anthropic_config = {
|
|
"anthropic_api_key": model_config.secret_value.get(_AuthConfigKey.API_KEY),
|
|
}
|
|
if model_config.auth_config:
|
|
if "version" in model_config.auth_config:
|
|
anthropic_config["anthropic_version"] = model_config.auth_config["version"]
|
|
if _AuthConfigKey.API_BASE in model_config.auth_config:
|
|
anthropic_config["anthropic_api_base"] = model_config.auth_config[
|
|
_AuthConfigKey.API_BASE
|
|
]
|
|
provider_config = AnthropicConfig(**anthropic_config)
|
|
elif model_config.provider == Provider.MISTRAL:
|
|
provider_config = MistralConfig(
|
|
mistral_api_key=model_config.secret_value.get(_AuthConfigKey.API_KEY),
|
|
)
|
|
elif model_config.provider == Provider.GEMINI:
|
|
provider_config = GeminiConfig(
|
|
gemini_api_key=model_config.secret_value.get(_AuthConfigKey.API_KEY),
|
|
)
|
|
elif model_config.provider in {
|
|
Provider.GROQ,
|
|
Provider.DEEPSEEK,
|
|
Provider.XAI,
|
|
Provider.OPENROUTER,
|
|
Provider.OLLAMA,
|
|
Provider.PORTKEY,
|
|
}:
|
|
provider_config = _build_openai_compatible_config(model_config)
|
|
elif normalize_provider_name(model_config.provider) == Provider.DATABRICKS:
|
|
from mlflow.gateway.providers.databricks import DatabricksConfig
|
|
|
|
auth_config = model_config.auth_config or {}
|
|
auth_mode = auth_config.get(_AuthConfigKey.AUTH_MODE, "pat_token")
|
|
config_kwargs = {}
|
|
if api_base := auth_config.get(_AuthConfigKey.API_BASE):
|
|
config_kwargs["host"] = api_base
|
|
if auth_mode == "oauth_m2m":
|
|
config_kwargs["client_id"] = auth_config.get("client_id")
|
|
config_kwargs["client_secret"] = model_config.secret_value.get("client_secret")
|
|
else:
|
|
config_kwargs["token"] = model_config.secret_value.get(_AuthConfigKey.API_KEY)
|
|
provider_config = DatabricksConfig(**config_kwargs)
|
|
model_config.provider = Provider.DATABRICKS
|
|
elif normalize_provider_name(model_config.provider) == Provider.BEDROCK:
|
|
auth_config = model_config.auth_config or {}
|
|
auth_mode = auth_config.get(_AuthConfigKey.AUTH_MODE, "api_key")
|
|
if auth_mode == "api_key":
|
|
# Bearer token auth — bypasses boto3 SigV4
|
|
provider_config = AmazonBedrockConfig(
|
|
aws_config={
|
|
"aws_bearer_token": model_config.secret_value.get(_AuthConfigKey.API_KEY),
|
|
"aws_region": auth_config.get("aws_region_name"),
|
|
}
|
|
)
|
|
elif auth_mode == "access_keys":
|
|
provider_config = AmazonBedrockConfig(
|
|
aws_config={
|
|
"aws_access_key_id": model_config.secret_value.get("aws_access_key_id"),
|
|
"aws_secret_access_key": model_config.secret_value.get("aws_secret_access_key"),
|
|
"aws_region": auth_config.get("aws_region_name"),
|
|
}
|
|
)
|
|
elif auth_mode == "iam_role":
|
|
provider_config = AmazonBedrockConfig(
|
|
aws_config={
|
|
"aws_role_arn": auth_config.get("aws_role_name"),
|
|
"aws_region": auth_config.get("aws_region_name"),
|
|
}
|
|
)
|
|
else:
|
|
# default_chain — boto3 resolves credentials from the
|
|
# environment (env vars, ~/.aws/credentials, instance profile, etc.)
|
|
aws_config = {"aws_region": auth_config.get("aws_region_name")}
|
|
if role_arn := auth_config.get("aws_role_name"):
|
|
aws_config["aws_role_arn"] = role_arn
|
|
provider_config = AmazonBedrockConfig(aws_config=aws_config)
|
|
model_config.provider = Provider.BEDROCK
|
|
elif model_config.provider == Provider.VERTEX_AI:
|
|
auth_config = model_config.auth_config or {}
|
|
provider_config = VertexAIConfig(
|
|
vertex_project=auth_config.get("vertex_project"),
|
|
vertex_location=auth_config.get("vertex_location"),
|
|
vertex_credentials=model_config.secret_value.get("vertex_credentials"),
|
|
)
|
|
else:
|
|
# Use LiteLLM as fallback for unsupported providers
|
|
# Store the original provider name for LiteLLM's provider/model format
|
|
original_provider = model_config.provider
|
|
auth_config = model_config.auth_config or {}
|
|
# Merge auth_config with secret_value (secret_value contains api_key and other secrets)
|
|
litellm_config = {
|
|
"litellm_provider": original_provider,
|
|
"litellm_auth_config": auth_config | model_config.secret_value,
|
|
}
|
|
provider_config = LiteLLMConfig(**litellm_config)
|
|
model_config.provider = Provider.LITELLM
|
|
|
|
# Build and return EndpointConfig
|
|
return EndpointConfig(
|
|
name=endpoint_name,
|
|
endpoint_type=endpoint_type,
|
|
model={
|
|
"name": model_config.model_name,
|
|
"provider": model_config.provider,
|
|
"config": provider_config.model_dump(),
|
|
},
|
|
)
|
|
|
|
|
|
def _create_provider(
|
|
endpoint_config: GatewayEndpointConfig,
|
|
endpoint_type: EndpointType,
|
|
enable_tracing: bool = False,
|
|
) -> BaseProvider:
|
|
"""
|
|
Create a provider instance based on endpoint routing strategy.
|
|
|
|
Fallback is independent of routing strategy - if fallback_config is present,
|
|
the provider is wrapped with FallbackProvider.
|
|
|
|
Args:
|
|
endpoint_config: The endpoint configuration with model details and routing config.
|
|
endpoint_type: Endpoint type (chat or embeddings).
|
|
|
|
Returns:
|
|
Provider instance (standard provider, TrafficRouteProvider, or FallbackProvider).
|
|
|
|
Raises:
|
|
MlflowException: If endpoint configuration is invalid or has no models.
|
|
"""
|
|
# Get PRIMARY models
|
|
primary_models = [
|
|
model
|
|
for model in endpoint_config.models
|
|
if model.linkage_type == GatewayModelLinkageType.PRIMARY
|
|
]
|
|
|
|
if not primary_models:
|
|
raise MlflowException(
|
|
f"Endpoint '{endpoint_config.endpoint_name}' has no PRIMARY models configured",
|
|
error_code=RESOURCE_DOES_NOT_EXIST,
|
|
)
|
|
|
|
# Create base provider based on routing strategy
|
|
if endpoint_config.routing_strategy == RoutingStrategy.REQUEST_BASED_TRAFFIC_SPLIT:
|
|
# Traffic split: distribute requests based on weights
|
|
configs = []
|
|
weights = []
|
|
for model_config in primary_models:
|
|
gateway_endpoint_config = _build_endpoint_config(
|
|
endpoint_name=endpoint_config.endpoint_name,
|
|
model_config=model_config,
|
|
endpoint_type=endpoint_type,
|
|
)
|
|
configs.append(gateway_endpoint_config)
|
|
weights.append(int(model_config.weight * 100)) # Convert to percentage
|
|
|
|
primary_provider = TrafficRouteProvider(
|
|
configs=configs,
|
|
traffic_splits=weights,
|
|
routing_strategy="TRAFFIC_SPLIT",
|
|
enable_tracing=enable_tracing,
|
|
)
|
|
else:
|
|
# Default: use the first PRIMARY model
|
|
model_config = primary_models[0]
|
|
gateway_endpoint_config = _build_endpoint_config(
|
|
endpoint_config.endpoint_name, model_config, endpoint_type
|
|
)
|
|
provider_class = get_provider(model_config.provider)
|
|
primary_provider = provider_class(gateway_endpoint_config, enable_tracing=enable_tracing)
|
|
|
|
# Wrap with FallbackProvider if fallback configuration exists
|
|
if endpoint_config.fallback_config:
|
|
fallback_models = [
|
|
model
|
|
for model in endpoint_config.models
|
|
if model.linkage_type == GatewayModelLinkageType.FALLBACK
|
|
]
|
|
|
|
if not fallback_models:
|
|
_logger.debug(
|
|
f"Endpoint '{endpoint_config.endpoint_name}' has fallback_config "
|
|
"but no FALLBACK models configured"
|
|
)
|
|
return primary_provider
|
|
|
|
# Sort fallback models by fallback_order
|
|
fallback_models.sort(
|
|
key=lambda m: m.fallback_order if m.fallback_order is not None else float("inf")
|
|
)
|
|
|
|
fallback_providers = [
|
|
get_provider(model_config.provider)(
|
|
_build_endpoint_config(
|
|
endpoint_name=endpoint_config.endpoint_name,
|
|
model_config=model_config,
|
|
endpoint_type=endpoint_type,
|
|
),
|
|
enable_tracing=enable_tracing,
|
|
)
|
|
for model_config in fallback_models
|
|
]
|
|
|
|
max_attempts = endpoint_config.fallback_config.max_attempts or len(fallback_models)
|
|
|
|
# FallbackProvider expects all providers (primary + fallback)
|
|
all_providers = [primary_provider] + fallback_providers
|
|
|
|
return FallbackProvider(
|
|
providers=all_providers,
|
|
max_attempts=max_attempts + 1, # +1 to include primary
|
|
strategy=endpoint_config.fallback_config.strategy,
|
|
enable_tracing=enable_tracing,
|
|
)
|
|
|
|
return primary_provider
|
|
|
|
|
|
def _create_provider_from_endpoint_name(
|
|
store: SqlAlchemyStore,
|
|
endpoint_name: str,
|
|
endpoint_type: EndpointType,
|
|
enable_tracing: bool = True,
|
|
) -> tuple[BaseProvider, GatewayEndpointConfig]:
|
|
"""
|
|
Create a provider from an endpoint name.
|
|
|
|
Args:
|
|
store: The SQLAlchemy store instance.
|
|
endpoint_name: The endpoint name.
|
|
endpoint_type: Endpoint type (chat or embeddings).
|
|
enable_tracing: If True, enables MLflow tracing for provider calls.
|
|
|
|
Returns:
|
|
Tuple of (provider instance, endpoint config)
|
|
"""
|
|
endpoint_config = get_endpoint_config(endpoint_name=endpoint_name, store=store)
|
|
return _create_provider(
|
|
endpoint_config, endpoint_type, enable_tracing=enable_tracing
|
|
), endpoint_config
|
|
|
|
|
|
def _validate_store(store: AbstractStore) -> None:
|
|
if not isinstance(store, SqlAlchemyStore):
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Gateway endpoints are only available with SqlAlchemyStore, "
|
|
f"got {type(store).__name__}.",
|
|
)
|
|
|
|
|
|
def _extract_endpoint_name_from_model(body: dict[str, Any]) -> str:
|
|
"""
|
|
Extract and validate the endpoint name from the 'model' parameter in the request body.
|
|
|
|
Args:
|
|
body: The request body dictionary
|
|
|
|
Returns:
|
|
The endpoint name extracted from the 'model' parameter
|
|
|
|
Raises:
|
|
HTTPException: If the 'model' parameter is missing
|
|
"""
|
|
endpoint_name = body.get("model")
|
|
if not endpoint_name:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Missing required 'model' parameter in request body",
|
|
)
|
|
return endpoint_name
|
|
|
|
|
|
def _get_guardrails_and_auth(
|
|
store, endpoint_config, request: Request
|
|
) -> tuple[list[JudgeGuardrail], dict[str, str]]:
|
|
"""Load guardrails and extract auth headers, skipping guardrails for internal bypass calls."""
|
|
headers = dict(request.headers)
|
|
bypass = headers.get(_SANITIZE_BYPASS_HEADER) == "1"
|
|
guardrails = [] if bypass else load_guardrails(store, endpoint_config, request)
|
|
return guardrails, extract_auth_headers(headers)
|
|
|
|
|
|
@gateway_router.post("/{endpoint_name}/mlflow/invocations", response_model=None)
|
|
@translate_http_exception
|
|
@_record_gateway_invocation(GatewayInvocationType.MLFLOW_INVOCATIONS)
|
|
async def invocations(endpoint_name: str, request: Request):
|
|
"""
|
|
Unified invocations endpoint handler that supports both chat and embeddings.
|
|
|
|
The handler automatically detects the request type based on the payload structure:
|
|
- If payload has "messages" field -> chat endpoint
|
|
- If payload has "input" field -> embeddings endpoint
|
|
"""
|
|
body = await _get_request_body(request)
|
|
user_metadata = _get_user_metadata(request)
|
|
headers = dict(request.headers)
|
|
|
|
store = _get_store()
|
|
workspace = get_request_workspace()
|
|
|
|
_validate_store(store)
|
|
endpoint_config = get_endpoint_config(endpoint_name=endpoint_name, store=store)
|
|
_set_gateway_telemetry_state(request, endpoint_config)
|
|
check_budget_limit(store, endpoint_config, workspace=workspace)
|
|
guardrails, auth_headers = _get_guardrails_and_auth(store, endpoint_config, request)
|
|
|
|
# Detect request type based on payload structure
|
|
if "messages" in body:
|
|
# Chat request
|
|
endpoint_type = EndpointType.LLM_V1_CHAT
|
|
try:
|
|
payload = chat.RequestPayload(**body)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=f"Invalid chat payload: {e!s}")
|
|
|
|
provider, endpoint_config = _create_provider_from_endpoint_name(
|
|
store, endpoint_name, endpoint_type
|
|
)
|
|
|
|
if payload.stream:
|
|
# Post-LLM guardrails are not applied to streaming responses.
|
|
# Pre-LLM guardrails run inside the trace as child spans; violations
|
|
# are surfaced as SSE error chunks via safe_stream.
|
|
async def _guarded_stream(
|
|
payload: chat.RequestPayload,
|
|
):
|
|
request_dict = await run_pre_llm_guardrails(
|
|
guardrails,
|
|
payload.model_dump(),
|
|
auth_headers=auth_headers,
|
|
usage_tracking=endpoint_config.usage_tracking,
|
|
payload_schema=ChatCompletionRequest.model_json_schema(),
|
|
)
|
|
async for chunk in provider.chat_stream(chat.RequestPayload(**request_dict)):
|
|
yield chunk
|
|
|
|
stream = maybe_traced_gateway_call(
|
|
_guarded_stream,
|
|
endpoint_config,
|
|
user_metadata,
|
|
output_reducer=aggregate_chat_stream_chunks,
|
|
request_headers=headers,
|
|
request_type=GatewayRequestType.UNIFIED_CHAT,
|
|
on_complete=make_budget_on_complete(store, workspace),
|
|
)(payload)
|
|
return StreamingResponse(
|
|
safe_stream(to_sse_chunk(chunk.model_dump_json()) async for chunk in stream),
|
|
media_type="text/event-stream",
|
|
)
|
|
else:
|
|
|
|
async def _guarded_chat(
|
|
payload: chat.RequestPayload,
|
|
) -> chat.ResponsePayload:
|
|
request_dict = await run_pre_llm_guardrails(
|
|
guardrails,
|
|
payload.model_dump(),
|
|
auth_headers=auth_headers,
|
|
usage_tracking=endpoint_config.usage_tracking,
|
|
payload_schema=ChatCompletionRequest.model_json_schema(),
|
|
)
|
|
modified_payload = chat.RequestPayload(**request_dict)
|
|
response = await provider.chat(modified_payload)
|
|
return await run_post_llm_guardrails(
|
|
guardrails,
|
|
request_dict,
|
|
response,
|
|
auth_headers=auth_headers,
|
|
usage_tracking=endpoint_config.usage_tracking,
|
|
)
|
|
|
|
try:
|
|
return await maybe_traced_gateway_call(
|
|
_guarded_chat,
|
|
endpoint_config,
|
|
user_metadata,
|
|
request_headers=headers,
|
|
request_type=GatewayRequestType.UNIFIED_CHAT,
|
|
on_complete=make_budget_on_complete(store, workspace),
|
|
)(payload)
|
|
except GuardrailViolation as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
elif "input" in body:
|
|
# Embeddings request
|
|
endpoint_type = EndpointType.LLM_V1_EMBEDDINGS
|
|
try:
|
|
payload = embeddings.RequestPayload(**body)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=f"Invalid embeddings payload: {e!s}")
|
|
|
|
provider, endpoint_config = _create_provider_from_endpoint_name(
|
|
store, endpoint_name, endpoint_type
|
|
)
|
|
|
|
return await maybe_traced_gateway_call(
|
|
provider.embeddings,
|
|
endpoint_config,
|
|
user_metadata,
|
|
request_headers=headers,
|
|
request_type=GatewayRequestType.UNIFIED_EMBEDDINGS,
|
|
on_complete=make_budget_on_complete(store, workspace),
|
|
)(payload)
|
|
|
|
else:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Invalid request: payload format must be either chat or embeddings",
|
|
)
|
|
|
|
|
|
@gateway_router.post("/mlflow/v1/chat/completions", response_model=None)
|
|
@translate_http_exception
|
|
@_record_gateway_invocation(GatewayInvocationType.MLFLOW_CHAT_COMPLETIONS)
|
|
async def chat_completions(request: Request):
|
|
"""
|
|
OpenAI-compatible chat completions endpoint.
|
|
|
|
This endpoint follows the OpenAI API format where the endpoint name is specified
|
|
via the "model" parameter in the request body, allowing clients to use the
|
|
standard OpenAI SDK.
|
|
|
|
Example:
|
|
POST /gateway/mlflow/v1/chat/completions
|
|
{
|
|
"model": "my-endpoint-name",
|
|
"messages": [{"role": "user", "content": "Hello"}]
|
|
}
|
|
"""
|
|
body = await _get_request_body(request)
|
|
user_metadata = _get_user_metadata(request)
|
|
headers = dict(request.headers)
|
|
|
|
# Extract endpoint name from "model" parameter
|
|
endpoint_name = _extract_endpoint_name_from_model(body)
|
|
body.pop("model")
|
|
|
|
store = _get_store()
|
|
workspace = get_request_workspace()
|
|
|
|
_validate_store(store)
|
|
provider, endpoint_config = _create_provider_from_endpoint_name(
|
|
store, endpoint_name, EndpointType.LLM_V1_CHAT
|
|
)
|
|
_set_gateway_telemetry_state(request, endpoint_config)
|
|
check_budget_limit(store, endpoint_config, workspace=workspace)
|
|
guardrails, auth_headers = _get_guardrails_and_auth(store, endpoint_config, request)
|
|
|
|
try:
|
|
payload = chat.RequestPayload(**body)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=f"Invalid chat payload: {e!s}")
|
|
|
|
if payload.stream:
|
|
# Post-LLM guardrails are not applied to streaming responses.
|
|
# Pre-LLM guardrails run inside the trace as child spans; violations
|
|
# are surfaced as SSE error chunks via safe_stream.
|
|
async def _guarded_stream(
|
|
payload: chat.RequestPayload,
|
|
):
|
|
request_dict = await run_pre_llm_guardrails(
|
|
guardrails,
|
|
payload.model_dump(),
|
|
auth_headers=auth_headers,
|
|
usage_tracking=endpoint_config.usage_tracking,
|
|
payload_schema=ChatCompletionRequest.model_json_schema(),
|
|
)
|
|
async for chunk in provider.chat_stream(chat.RequestPayload(**request_dict)):
|
|
yield chunk
|
|
|
|
stream = maybe_traced_gateway_call(
|
|
_guarded_stream,
|
|
endpoint_config,
|
|
user_metadata,
|
|
output_reducer=aggregate_chat_stream_chunks,
|
|
request_headers=headers,
|
|
request_type=GatewayRequestType.UNIFIED_CHAT,
|
|
on_complete=make_budget_on_complete(store, workspace),
|
|
)(payload)
|
|
return StreamingResponse(
|
|
safe_stream(to_sse_chunk(chunk.model_dump_json()) async for chunk in stream),
|
|
media_type="text/event-stream",
|
|
)
|
|
else:
|
|
|
|
async def _guarded_chat(
|
|
payload: chat.RequestPayload,
|
|
) -> chat.ResponsePayload:
|
|
request_dict = await run_pre_llm_guardrails(
|
|
guardrails,
|
|
payload.model_dump(),
|
|
auth_headers=auth_headers,
|
|
usage_tracking=endpoint_config.usage_tracking,
|
|
payload_schema=ChatCompletionRequest.model_json_schema(),
|
|
)
|
|
modified_payload = chat.RequestPayload(**request_dict)
|
|
response = await provider.chat(modified_payload)
|
|
return await run_post_llm_guardrails(
|
|
guardrails,
|
|
request_dict,
|
|
response,
|
|
auth_headers=auth_headers,
|
|
usage_tracking=endpoint_config.usage_tracking,
|
|
)
|
|
|
|
try:
|
|
return await maybe_traced_gateway_call(
|
|
_guarded_chat,
|
|
endpoint_config,
|
|
user_metadata,
|
|
request_headers=headers,
|
|
request_type=GatewayRequestType.UNIFIED_CHAT,
|
|
on_complete=make_budget_on_complete(store, workspace),
|
|
)(payload)
|
|
except GuardrailViolation as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
@gateway_router.post(PASSTHROUGH_ROUTES[PassthroughAction.OPENAI_CHAT], response_model=None)
|
|
@translate_http_exception
|
|
@_record_gateway_invocation(GatewayInvocationType.OPENAI_PASSTHROUGH_CHAT)
|
|
async def openai_passthrough_chat(request: Request):
|
|
"""
|
|
OpenAI passthrough endpoint for chat completions.
|
|
|
|
This endpoint accepts raw OpenAI API format and passes it through to the
|
|
OpenAI provider with the configured API key and model. The 'model' parameter
|
|
in the request specifies which MLflow endpoint to use.
|
|
|
|
Supports streaming responses when the 'stream' parameter is set to true.
|
|
|
|
Example:
|
|
POST /gateway/openai/v1/chat/completions
|
|
{
|
|
"model": "my-openai-endpoint",
|
|
"messages": [{"role": "user", "content": "Hello"}],
|
|
"temperature": 0.7,
|
|
"stream": true
|
|
}
|
|
"""
|
|
body = await _get_request_body(request)
|
|
user_metadata = _get_user_metadata(request)
|
|
|
|
endpoint_name = _extract_endpoint_name_from_model(body)
|
|
body.pop("model")
|
|
store = _get_store()
|
|
workspace = get_request_workspace()
|
|
_validate_store(store)
|
|
headers = dict(request.headers)
|
|
provider, endpoint_config = _create_provider_from_endpoint_name(
|
|
store, endpoint_name, EndpointType.LLM_V1_CHAT
|
|
)
|
|
_set_gateway_telemetry_state(request, endpoint_config)
|
|
check_budget_limit(store, endpoint_config, workspace=workspace)
|
|
guardrails, auth_headers = _get_guardrails_and_auth(store, endpoint_config, request)
|
|
|
|
if body.get("stream", False):
|
|
# Post-LLM guardrails are not applied to streaming responses.
|
|
async def _guarded_stream(body: dict[str, Any]):
|
|
request_dict = await run_pre_llm_guardrails(
|
|
guardrails,
|
|
body,
|
|
auth_headers=auth_headers,
|
|
usage_tracking=endpoint_config.usage_tracking,
|
|
)
|
|
stream = await provider.passthrough(
|
|
action=PassthroughAction.OPENAI_CHAT, payload=request_dict, headers=headers
|
|
)
|
|
async for chunk in stream:
|
|
yield chunk
|
|
|
|
traced_stream = maybe_traced_gateway_call(
|
|
_guarded_stream,
|
|
endpoint_config,
|
|
user_metadata,
|
|
request_headers=headers,
|
|
request_type=GatewayRequestType.PASSTHROUGH_MODEL_OPENAI_CHAT,
|
|
on_complete=make_budget_on_complete(store, workspace),
|
|
)
|
|
return StreamingResponse(
|
|
safe_stream(traced_stream(body), as_bytes=True), media_type="text/event-stream"
|
|
)
|
|
|
|
async def _guarded_passthrough(body: dict[str, Any]) -> dict[str, Any]:
|
|
body = await run_pre_llm_guardrails(
|
|
guardrails,
|
|
body,
|
|
auth_headers=auth_headers,
|
|
usage_tracking=endpoint_config.usage_tracking,
|
|
)
|
|
response = await provider.passthrough(
|
|
action=PassthroughAction.OPENAI_CHAT, payload=body, headers=headers
|
|
)
|
|
return await run_post_llm_guardrails_passthrough(
|
|
guardrails,
|
|
body,
|
|
response,
|
|
auth_headers=auth_headers,
|
|
usage_tracking=endpoint_config.usage_tracking,
|
|
)
|
|
|
|
try:
|
|
return await maybe_traced_gateway_call(
|
|
_guarded_passthrough,
|
|
endpoint_config,
|
|
user_metadata,
|
|
request_headers=headers,
|
|
request_type=GatewayRequestType.PASSTHROUGH_MODEL_OPENAI_CHAT,
|
|
on_complete=make_budget_on_complete(store, workspace),
|
|
)(body)
|
|
except GuardrailViolation as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
@gateway_router.post(PASSTHROUGH_ROUTES[PassthroughAction.OPENAI_EMBEDDINGS], response_model=None)
|
|
@translate_http_exception
|
|
@_record_gateway_invocation(GatewayInvocationType.OPENAI_PASSTHROUGH_EMBEDDINGS)
|
|
async def openai_passthrough_embeddings(request: Request):
|
|
"""
|
|
OpenAI passthrough endpoint for embeddings.
|
|
|
|
This endpoint accepts raw OpenAI API format and passes it through to the
|
|
OpenAI provider with the configured API key and model. The 'model' parameter
|
|
in the request specifies which MLflow endpoint to use.
|
|
|
|
Example:
|
|
POST /gateway/openai/v1/embeddings
|
|
{
|
|
"model": "my-openai-endpoint",
|
|
"input": "The food was delicious and the waiter..."
|
|
}
|
|
"""
|
|
body = await _get_request_body(request)
|
|
user_metadata = _get_user_metadata(request)
|
|
|
|
endpoint_name = _extract_endpoint_name_from_model(body)
|
|
body.pop("model")
|
|
store = _get_store()
|
|
workspace = get_request_workspace()
|
|
_validate_store(store)
|
|
headers = dict(request.headers)
|
|
provider, endpoint_config = _create_provider_from_endpoint_name(
|
|
store, endpoint_name, EndpointType.LLM_V1_EMBEDDINGS
|
|
)
|
|
_set_gateway_telemetry_state(request, endpoint_config)
|
|
check_budget_limit(store, endpoint_config, workspace=workspace)
|
|
guardrails, auth_headers = _get_guardrails_and_auth(store, endpoint_config, request)
|
|
|
|
try:
|
|
body = await run_pre_llm_guardrails(
|
|
guardrails,
|
|
body,
|
|
auth_headers=auth_headers,
|
|
usage_tracking=endpoint_config.usage_tracking,
|
|
)
|
|
except GuardrailViolation as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
traced_passthrough = maybe_traced_gateway_call(
|
|
provider.passthrough,
|
|
endpoint_config,
|
|
user_metadata,
|
|
request_headers=headers,
|
|
request_type=GatewayRequestType.PASSTHROUGH_MODEL_OPENAI_EMBEDDINGS,
|
|
on_complete=make_budget_on_complete(store, workspace),
|
|
)
|
|
# Post-LLM guardrails are skipped for embeddings: responses are float vectors
|
|
# that content judges cannot meaningfully evaluate.
|
|
return await traced_passthrough(
|
|
action=PassthroughAction.OPENAI_EMBEDDINGS, payload=body, headers=headers
|
|
)
|
|
|
|
|
|
async def _openai_responses_passthrough_unary(
|
|
request: Request,
|
|
body: dict[str, Any],
|
|
action: PassthroughAction,
|
|
request_type: GatewayRequestType,
|
|
) -> dict[str, Any]:
|
|
"""Shared body for the unary (non-streaming) OpenAI Responses-shaped
|
|
passthrough routes — the non-streaming branch of ``/responses`` and the
|
|
unary-only ``/responses/compact``.
|
|
|
|
Resolves the configured endpoint from ``body["model"]``, runs the usual
|
|
telemetry / budget / guardrail setup, wraps the upstream call in pre/post
|
|
guardrails, and returns the raw upstream response.
|
|
``GuardrailViolation`` is translated to HTTP 400. Each call site is
|
|
responsible for reading the request body (so callers can also reject
|
|
``stream=true`` when their endpoint doesn't support streaming, as
|
|
``/responses/compact`` does).
|
|
"""
|
|
user_metadata = _get_user_metadata(request)
|
|
endpoint_name = _extract_endpoint_name_from_model(body)
|
|
body.pop("model")
|
|
store = _get_store()
|
|
workspace = get_request_workspace()
|
|
_validate_store(store)
|
|
headers = dict(request.headers)
|
|
provider, endpoint_config = _create_provider_from_endpoint_name(
|
|
store, endpoint_name, EndpointType.LLM_V1_CHAT
|
|
)
|
|
_set_gateway_telemetry_state(request, endpoint_config)
|
|
check_budget_limit(store, endpoint_config, workspace=workspace)
|
|
guardrails, auth_headers = _get_guardrails_and_auth(store, endpoint_config, request)
|
|
|
|
async def _guarded_passthrough(body: dict[str, Any]) -> dict[str, Any]:
|
|
body = await run_pre_llm_guardrails(
|
|
guardrails,
|
|
body,
|
|
auth_headers=auth_headers,
|
|
usage_tracking=endpoint_config.usage_tracking,
|
|
)
|
|
response = await provider.passthrough(action=action, payload=body, headers=headers)
|
|
return await run_post_llm_guardrails_passthrough(
|
|
guardrails,
|
|
body,
|
|
response,
|
|
auth_headers=auth_headers,
|
|
usage_tracking=endpoint_config.usage_tracking,
|
|
)
|
|
|
|
try:
|
|
return await maybe_traced_gateway_call(
|
|
_guarded_passthrough,
|
|
endpoint_config,
|
|
user_metadata,
|
|
request_headers=headers,
|
|
request_type=request_type,
|
|
on_complete=make_budget_on_complete(store, workspace),
|
|
)(body)
|
|
except GuardrailViolation as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
@gateway_router.post(PASSTHROUGH_ROUTES[PassthroughAction.OPENAI_RESPONSES], response_model=None)
|
|
@translate_http_exception
|
|
@_record_gateway_invocation(GatewayInvocationType.OPENAI_PASSTHROUGH_RESPONSES)
|
|
async def openai_passthrough_responses(request: Request):
|
|
"""
|
|
OpenAI passthrough endpoint for the Responses API.
|
|
|
|
This endpoint accepts raw OpenAI Responses API format and passes it through to the
|
|
OpenAI provider with the configured API key and model. The 'model' parameter
|
|
in the request specifies which MLflow endpoint to use.
|
|
|
|
Supports streaming responses when the 'stream' parameter is set to true.
|
|
|
|
Example:
|
|
POST /gateway/openai/v1/responses
|
|
{
|
|
"model": "my-openai-endpoint",
|
|
"input": [{"type": "text", "text": "Hello"}],
|
|
"instructions": "You are a helpful assistant",
|
|
"stream": true
|
|
}
|
|
"""
|
|
body = await _get_request_body(request)
|
|
|
|
if body.get("stream", False):
|
|
# Post-LLM guardrails are not applied to streaming responses. Streaming
|
|
# keeps its own inline setup because the closure below captures these
|
|
# variables directly; the unary path delegates to the shared helper.
|
|
user_metadata = _get_user_metadata(request)
|
|
endpoint_name = _extract_endpoint_name_from_model(body)
|
|
body.pop("model")
|
|
store = _get_store()
|
|
workspace = get_request_workspace()
|
|
_validate_store(store)
|
|
headers = dict(request.headers)
|
|
provider, endpoint_config = _create_provider_from_endpoint_name(
|
|
store, endpoint_name, EndpointType.LLM_V1_CHAT
|
|
)
|
|
_set_gateway_telemetry_state(request, endpoint_config)
|
|
check_budget_limit(store, endpoint_config, workspace=workspace)
|
|
guardrails, auth_headers = _get_guardrails_and_auth(store, endpoint_config, request)
|
|
|
|
async def _guarded_stream(body: dict[str, Any]):
|
|
request_dict = await run_pre_llm_guardrails(
|
|
guardrails,
|
|
body,
|
|
auth_headers=auth_headers,
|
|
usage_tracking=endpoint_config.usage_tracking,
|
|
)
|
|
stream = await provider.passthrough(
|
|
action=PassthroughAction.OPENAI_RESPONSES, payload=request_dict, headers=headers
|
|
)
|
|
async for chunk in stream:
|
|
yield chunk
|
|
|
|
traced_stream = maybe_traced_gateway_call(
|
|
_guarded_stream,
|
|
endpoint_config,
|
|
user_metadata,
|
|
output_reducer=aggregate_openai_responses_stream_chunks,
|
|
request_headers=headers,
|
|
request_type=GatewayRequestType.PASSTHROUGH_MODEL_OPENAI_RESPONSES,
|
|
on_complete=make_budget_on_complete(store, workspace),
|
|
)
|
|
return StreamingResponse(
|
|
safe_stream(traced_stream(body), as_bytes=True), media_type="text/event-stream"
|
|
)
|
|
|
|
return await _openai_responses_passthrough_unary(
|
|
request,
|
|
body,
|
|
action=PassthroughAction.OPENAI_RESPONSES,
|
|
request_type=GatewayRequestType.PASSTHROUGH_MODEL_OPENAI_RESPONSES,
|
|
)
|
|
|
|
|
|
@gateway_router.post(
|
|
PASSTHROUGH_ROUTES[PassthroughAction.OPENAI_RESPONSES_COMPACT], response_model=None
|
|
)
|
|
@translate_http_exception
|
|
@_record_gateway_invocation(GatewayInvocationType.OPENAI_PASSTHROUGH_RESPONSES)
|
|
async def openai_passthrough_responses_compact(request: Request):
|
|
"""
|
|
OpenAI passthrough endpoint for the Responses API ``/compact`` route.
|
|
|
|
Mirrors :func:`openai_passthrough_responses` for session compaction calls
|
|
(e.g. those issued by the OpenAI Agents SDK's ``OpenAIResponsesCompactionSession``).
|
|
Compaction is a unary request — there is no streaming variant. A request
|
|
that sets ``stream=true`` is rejected with HTTP 400 to avoid the underlying
|
|
provider passthrough (which treats all non-embeddings actions as
|
|
stream-capable) attempting an SSE stream against an upstream endpoint that
|
|
does not support it.
|
|
|
|
Example:
|
|
POST /gateway/openai/v1/responses/compact
|
|
{
|
|
"model": "my-openai-endpoint",
|
|
"previous_response_id": "resp_abc123"
|
|
}
|
|
"""
|
|
body = await _get_request_body(request)
|
|
|
|
# /responses/compact is unary upstream; explicitly reject `stream=true`
|
|
# before the provider's passthrough machinery tries to open an SSE stream.
|
|
if body.get("stream", False):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=(
|
|
"stream=true is not supported on /responses/compact; compaction is a unary request."
|
|
),
|
|
)
|
|
|
|
return await _openai_responses_passthrough_unary(
|
|
request,
|
|
body,
|
|
action=PassthroughAction.OPENAI_RESPONSES_COMPACT,
|
|
request_type=GatewayRequestType.PASSTHROUGH_MODEL_OPENAI_RESPONSES,
|
|
)
|
|
|
|
|
|
@gateway_router.post(PASSTHROUGH_ROUTES[PassthroughAction.ANTHROPIC_MESSAGES], response_model=None)
|
|
@translate_http_exception
|
|
@_record_gateway_invocation(GatewayInvocationType.ANTHROPIC_PASSTHROUGH_MESSAGES)
|
|
async def anthropic_passthrough_messages(request: Request):
|
|
"""
|
|
Anthropic passthrough endpoint for the Messages API.
|
|
|
|
This endpoint accepts raw Anthropic API format and passes it through to the
|
|
Anthropic provider with the configured API key and model. The 'model' parameter
|
|
in the request specifies which MLflow endpoint to use.
|
|
|
|
Supports streaming responses when the 'stream' parameter is set to true.
|
|
|
|
Example:
|
|
POST /gateway/anthropic/v1/messages
|
|
{
|
|
"model": "my-anthropic-endpoint",
|
|
"messages": [{"role": "user", "content": "Hello"}],
|
|
"max_tokens": 1024,
|
|
"stream": true
|
|
}
|
|
"""
|
|
body = await _get_request_body(request)
|
|
user_metadata = _get_user_metadata(request)
|
|
|
|
endpoint_name = _extract_endpoint_name_from_model(body)
|
|
body.pop("model")
|
|
store = _get_store()
|
|
workspace = get_request_workspace()
|
|
_validate_store(store)
|
|
headers = dict(request.headers)
|
|
provider, endpoint_config = _create_provider_from_endpoint_name(
|
|
store, endpoint_name, EndpointType.LLM_V1_CHAT
|
|
)
|
|
_set_gateway_telemetry_state(request, endpoint_config)
|
|
check_budget_limit(store, endpoint_config, workspace=workspace)
|
|
guardrails, auth_headers = _get_guardrails_and_auth(store, endpoint_config, request)
|
|
|
|
if body.get("stream", False):
|
|
# Post-LLM guardrails are not applied to streaming responses.
|
|
async def _guarded_stream(body: dict[str, Any]):
|
|
request_dict = await run_pre_llm_guardrails(
|
|
guardrails,
|
|
body,
|
|
auth_headers=auth_headers,
|
|
usage_tracking=endpoint_config.usage_tracking,
|
|
)
|
|
stream = await provider.passthrough(
|
|
action=PassthroughAction.ANTHROPIC_MESSAGES, payload=request_dict, headers=headers
|
|
)
|
|
async for chunk in stream:
|
|
yield chunk
|
|
|
|
traced_stream = maybe_traced_gateway_call(
|
|
_guarded_stream,
|
|
endpoint_config,
|
|
user_metadata,
|
|
output_reducer=aggregate_anthropic_messages_stream_chunks,
|
|
request_headers=headers,
|
|
request_type=GatewayRequestType.PASSTHROUGH_MODEL_ANTHROPIC_MESSAGES,
|
|
on_complete=make_budget_on_complete(store, workspace),
|
|
message_format="anthropic",
|
|
)
|
|
return StreamingResponse(
|
|
safe_stream(traced_stream(body), as_bytes=True), media_type="text/event-stream"
|
|
)
|
|
|
|
async def _guarded_passthrough(body: dict[str, Any]) -> dict[str, Any]:
|
|
body = await run_pre_llm_guardrails(
|
|
guardrails,
|
|
body,
|
|
auth_headers=auth_headers,
|
|
usage_tracking=endpoint_config.usage_tracking,
|
|
)
|
|
response = await provider.passthrough(
|
|
action=PassthroughAction.ANTHROPIC_MESSAGES, payload=body, headers=headers
|
|
)
|
|
return await run_post_llm_guardrails_passthrough(
|
|
guardrails,
|
|
body,
|
|
response,
|
|
auth_headers=auth_headers,
|
|
usage_tracking=endpoint_config.usage_tracking,
|
|
)
|
|
|
|
try:
|
|
return await maybe_traced_gateway_call(
|
|
_guarded_passthrough,
|
|
endpoint_config,
|
|
user_metadata,
|
|
request_headers=headers,
|
|
request_type=GatewayRequestType.PASSTHROUGH_MODEL_ANTHROPIC_MESSAGES,
|
|
on_complete=make_budget_on_complete(store, workspace),
|
|
message_format="anthropic",
|
|
)(body)
|
|
except GuardrailViolation as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
@gateway_router.post(
|
|
PASSTHROUGH_ROUTES[PassthroughAction.GEMINI_GENERATE_CONTENT], response_model=None
|
|
)
|
|
@translate_http_exception
|
|
@_record_gateway_invocation(GatewayInvocationType.GEMINI_PASSTHROUGH_GENERATE_CONTENT)
|
|
async def gemini_passthrough_generate_content(endpoint_name: str, request: Request):
|
|
"""
|
|
Gemini passthrough endpoint for generateContent API (non-streaming).
|
|
|
|
This endpoint accepts raw Gemini API format and passes it through to the
|
|
Gemini provider with the configured API key. The endpoint_name in the URL path
|
|
specifies which MLflow endpoint to use.
|
|
|
|
Example:
|
|
POST /gateway/gemini/v1beta/models/my-gemini-endpoint:generateContent
|
|
{
|
|
"contents": [
|
|
{
|
|
"role": "user",
|
|
"parts": [{"text": "Hello"}]
|
|
}
|
|
]
|
|
}
|
|
"""
|
|
body = await _get_request_body(request)
|
|
user_metadata = _get_user_metadata(request)
|
|
|
|
store = _get_store()
|
|
workspace = get_request_workspace()
|
|
_validate_store(store)
|
|
headers = dict(request.headers)
|
|
provider, endpoint_config = _create_provider_from_endpoint_name(
|
|
store, endpoint_name, EndpointType.LLM_V1_CHAT
|
|
)
|
|
_set_gateway_telemetry_state(request, endpoint_config)
|
|
check_budget_limit(store, endpoint_config, workspace=workspace)
|
|
guardrails, auth_headers = _get_guardrails_and_auth(store, endpoint_config, request)
|
|
|
|
async def _guarded_passthrough(body: dict[str, Any]) -> dict[str, Any]:
|
|
body = await run_pre_llm_guardrails(
|
|
guardrails,
|
|
body,
|
|
auth_headers=auth_headers,
|
|
usage_tracking=endpoint_config.usage_tracking,
|
|
)
|
|
response = await provider.passthrough(
|
|
action=PassthroughAction.GEMINI_GENERATE_CONTENT, payload=body, headers=headers
|
|
)
|
|
return await run_post_llm_guardrails_passthrough(
|
|
guardrails,
|
|
body,
|
|
response,
|
|
auth_headers=auth_headers,
|
|
usage_tracking=endpoint_config.usage_tracking,
|
|
)
|
|
|
|
try:
|
|
return await maybe_traced_gateway_call(
|
|
_guarded_passthrough,
|
|
endpoint_config,
|
|
user_metadata,
|
|
request_headers=headers,
|
|
request_type=GatewayRequestType.PASSTHROUGH_MODEL_GEMINI_GENERATE_CONTENT,
|
|
on_complete=make_budget_on_complete(store, workspace),
|
|
message_format="gemini",
|
|
)(body)
|
|
except GuardrailViolation as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
@gateway_router.post(
|
|
PASSTHROUGH_ROUTES[PassthroughAction.GEMINI_STREAM_GENERATE_CONTENT], response_model=None
|
|
)
|
|
@translate_http_exception
|
|
@_record_gateway_invocation(GatewayInvocationType.GEMINI_PASSTHROUGH_STREAM_GENERATE_CONTENT)
|
|
async def gemini_passthrough_stream_generate_content(endpoint_name: str, request: Request):
|
|
"""
|
|
Gemini passthrough endpoint for streamGenerateContent API (streaming).
|
|
|
|
This endpoint accepts raw Gemini API format and passes it through to the
|
|
Gemini provider with the configured API key. The endpoint_name in the URL path
|
|
specifies which MLflow endpoint to use.
|
|
|
|
Example:
|
|
POST /gateway/gemini/v1beta/models/my-gemini-endpoint:streamGenerateContent
|
|
{
|
|
"contents": [
|
|
{
|
|
"role": "user",
|
|
"parts": [{"text": "Hello"}]
|
|
}
|
|
]
|
|
}
|
|
"""
|
|
body = await _get_request_body(request)
|
|
user_metadata = _get_user_metadata(request)
|
|
|
|
store = _get_store()
|
|
workspace = get_request_workspace()
|
|
_validate_store(store)
|
|
headers = dict(request.headers)
|
|
provider, endpoint_config = _create_provider_from_endpoint_name(
|
|
store, endpoint_name, EndpointType.LLM_V1_CHAT
|
|
)
|
|
_set_gateway_telemetry_state(request, endpoint_config)
|
|
check_budget_limit(store, endpoint_config, workspace=workspace)
|
|
guardrails, auth_headers = _get_guardrails_and_auth(store, endpoint_config, request)
|
|
|
|
# Post-LLM guardrails are not applied to streaming responses.
|
|
async def _guarded_stream(body: dict[str, Any]):
|
|
request_dict = await run_pre_llm_guardrails(
|
|
guardrails,
|
|
body,
|
|
auth_headers=auth_headers,
|
|
usage_tracking=endpoint_config.usage_tracking,
|
|
)
|
|
stream = await provider.passthrough(
|
|
action=PassthroughAction.GEMINI_STREAM_GENERATE_CONTENT,
|
|
payload=request_dict,
|
|
headers=headers,
|
|
)
|
|
async for chunk in stream:
|
|
yield chunk
|
|
|
|
traced_stream = maybe_traced_gateway_call(
|
|
_guarded_stream,
|
|
endpoint_config,
|
|
user_metadata,
|
|
output_reducer=aggregate_gemini_stream_generate_content_chunks,
|
|
request_headers=headers,
|
|
request_type=GatewayRequestType.PASSTHROUGH_MODEL_GEMINI_GENERATE_CONTENT,
|
|
on_complete=make_budget_on_complete(store, workspace),
|
|
message_format="gemini",
|
|
)
|
|
return StreamingResponse(
|
|
safe_stream(traced_stream(body), as_bytes=True), media_type="text/event-stream"
|
|
)
|
|
|
|
|
|
@gateway_router.post("/proxy/{endpoint_name}/{path:path}", response_model=None)
|
|
@translate_http_exception
|
|
@_record_gateway_invocation(GatewayInvocationType.RAW_PROXY)
|
|
async def raw_proxy(endpoint_name: str, path: str, request: Request):
|
|
"""
|
|
Raw proxy endpoint.
|
|
|
|
Routes the request payload as-is to the upstream provider at
|
|
<provider_base_url>/<path>, using the credentials configured for the named
|
|
endpoint. Unlike the typed passthrough routes, the ``model`` field in the
|
|
payload is NOT replaced with the value from the endpoint config.
|
|
|
|
Streaming is detected automatically from the response Content-Type
|
|
(``text/event-stream`` or ``application/x-ndjson``), so this endpoint
|
|
supports providers like Gemini that signal streaming via Content-Type rather
|
|
than a ``"stream": true`` request flag.
|
|
|
|
Example:
|
|
POST /gateway/proxy/my-openai-endpoint/chat/completions
|
|
{
|
|
"model": "gpt-4o",
|
|
"messages": [{"role": "user", "content": "Hello"}]
|
|
}
|
|
"""
|
|
body = await _get_request_body(request)
|
|
user_metadata = _get_user_metadata(request)
|
|
store = _get_store()
|
|
workspace = get_request_workspace()
|
|
_validate_store(store)
|
|
headers = dict(request.headers)
|
|
# Preserve upstream query parameters (e.g. Gemini's ?alt=sse for SSE streaming).
|
|
if qs := request.url.query:
|
|
path = f"{path}?{qs}"
|
|
# The proxy route is type-agnostic; EndpointType is only used to satisfy
|
|
# EndpointConfig construction and has no effect on proxy behavior (the proxy
|
|
# bypasses typed routing entirely). LLM_V1_CHAT is used as the default since
|
|
# the DB does not store a per-endpoint type for DB-backed gateway endpoints.
|
|
provider, endpoint_config = _create_provider_from_endpoint_name(
|
|
store, endpoint_name, EndpointType.LLM_V1_CHAT
|
|
)
|
|
_set_gateway_telemetry_state(request, endpoint_config)
|
|
check_budget_limit(store, endpoint_config, workspace=workspace)
|
|
guardrails, auth_headers = _get_guardrails_and_auth(store, endpoint_config, request)
|
|
|
|
# _do_proxy is always an async generator so maybe_traced_gateway_call can wrap it
|
|
# consistently regardless of whether the upstream responds with a streaming or
|
|
# non-streaming body. For streaming responses it yields raw bytes; for
|
|
# non-streaming responses it yields a single dict. The route handler peeks at
|
|
# the first item to decide how to respond to the client.
|
|
async def _do_proxy(body: dict[str, Any]):
|
|
try:
|
|
body = await run_pre_llm_guardrails(
|
|
guardrails,
|
|
body,
|
|
auth_headers=auth_headers,
|
|
usage_tracking=endpoint_config.usage_tracking,
|
|
)
|
|
except GuardrailViolation as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
result = await provider.proxy(path, body, headers)
|
|
|
|
if isinstance(result, AsyncIterable):
|
|
# Post-LLM guardrails are not applied to streaming responses.
|
|
async for chunk in result:
|
|
yield chunk
|
|
else:
|
|
try:
|
|
result = await run_post_llm_guardrails_passthrough(
|
|
guardrails,
|
|
body,
|
|
result,
|
|
auth_headers=auth_headers,
|
|
usage_tracking=endpoint_config.usage_tracking,
|
|
)
|
|
except GuardrailViolation as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
yield result
|
|
|
|
traced_proxy = maybe_traced_gateway_call(
|
|
_do_proxy,
|
|
endpoint_config,
|
|
user_metadata,
|
|
request_headers=headers,
|
|
request_type=GatewayRequestType.RAW_PROXY,
|
|
on_complete=make_budget_on_complete(store, workspace),
|
|
)
|
|
|
|
gen = traced_proxy(body)
|
|
try:
|
|
first = await gen.__anext__()
|
|
except StopAsyncIteration:
|
|
# _do_proxy raised before yielding (e.g. guardrail violation, provider 501).
|
|
# The original HTTPException already propagated; re-raise a generic 500 as fallback.
|
|
raise HTTPException(status_code=500, detail="Proxy handler exited without a response.")
|
|
if isinstance(first, bytes):
|
|
|
|
async def _prepend(first_chunk, rest):
|
|
yield first_chunk
|
|
async for chunk in rest:
|
|
yield chunk
|
|
|
|
return StreamingResponse(
|
|
safe_stream(_prepend(first, gen), as_bytes=True), media_type="text/event-stream"
|
|
)
|
|
return first
|