项目文件夹

文件
2026-07-13 13:22:34 +08:00

218 行
7.9 KiB
Python

"""Budget tracking and enforcement for the MLflow Gateway.
This module provides budget-related functions for recording costs, refreshing policies,
firing exceeded-budget webhooks, and creating on_complete callbacks for budget recording.
"""
import logging
from fastapi import HTTPException
import mlflow
from mlflow.entities import SpanType
from mlflow.entities.gateway_budget_policy import BudgetAction, BudgetTargetScope
from mlflow.entities.trace_location import MlflowExperimentLocation
from mlflow.entities.webhook import WebhookAction, WebhookEntity, WebhookEvent
from mlflow.gateway.budget_tracker import BudgetWindow, get_budget_tracker
from mlflow.gateway.tracing_utils import (
_gateway_span_attributes,
_gateway_span_name,
_get_model_span_info,
)
from mlflow.store.tracking.gateway.entities import GatewayEndpointConfig
from mlflow.store.tracking.sqlalchemy_store import SqlAlchemyStore
from mlflow.tracing.constant import CostKey, SpanAttributeKey
from mlflow.tracing.utils import calculate_cost_by_model_and_token_usage
from mlflow.utils.workspace_utils import DEFAULT_WORKSPACE_NAME
from mlflow.webhooks.delivery import deliver_webhook
from mlflow.webhooks.types import BudgetPolicyExceededPayload
_logger = logging.getLogger(__name__)
def calculate_existing_cost_for_windows(
store: SqlAlchemyStore, windows: list[BudgetWindow]
) -> dict[str, float]:
"""Calculate spend for budget windows from trace history.
Queries historical trace cost data for each window so that budget tracking
accounts for spend across all gateway workers and survives server restarts.
Returns:
Dict mapping budget_policy_id to historical spend amount.
"""
result: dict[str, float] = {}
if not windows:
return result
for window in windows:
try:
start_ms = int(window.window_start.timestamp() * 1000)
end_ms = int(window.window_end.timestamp() * 1000)
workspace = (
window.policy.workspace
if window.policy.target_scope == BudgetTargetScope.WORKSPACE
else None
)
spend = store.sum_gateway_trace_cost(
start_time_ms=start_ms,
end_time_ms=end_ms,
workspace=workspace,
)
if spend > 0:
result[window.policy.budget_policy_id] = spend
except Exception:
_logger.debug(
"Failed to calculate existing cost for policy %s",
window.policy.budget_policy_id,
exc_info=True,
)
return result
def maybe_refresh_budget_policies(store: SqlAlchemyStore) -> None:
"""Refresh budget policies from the database if stale."""
tracker = get_budget_tracker()
if tracker.needs_refresh():
try:
policies = store.list_budget_policies()
windows = tracker.refresh_policies(policies)
existing_spend = calculate_existing_cost_for_windows(store, windows)
tracker.backfill_spend(existing_spend)
except Exception:
_logger.debug("Failed to refresh budget policies", exc_info=True)
def _compute_cost_from_child_spans(trace_id: str) -> float:
"""Sum total cost across child spans.
Prefers ``LLM_COST`` if already set on the span (computed at span.end()),
otherwise falls back to calculating from MODEL + CHAT_USAGE via LiteLLM.
"""
total = 0.0
for info in _get_model_span_info(trace_id):
if llm_cost := info.attributes.get(SpanAttributeKey.LLM_COST):
total += llm_cost.get(CostKey.TOTAL_COST, 0.0)
else:
model_name = info.attributes.get(SpanAttributeKey.MODEL)
usage = info.attributes.get(SpanAttributeKey.CHAT_USAGE)
if not usage:
continue
model_provider = info.attributes.get(SpanAttributeKey.MODEL_PROVIDER)
if cost := calculate_cost_by_model_and_token_usage(model_name, usage, model_provider):
total += cost.get(CostKey.TOTAL_COST, 0.0)
return total
def fire_budget_exceeded_webhooks(
newly_exceeded: list[BudgetWindow],
workspace: str | None,
registry_store,
) -> None:
"""Fire budget_policy.exceeded webhooks for newly-exceeded budget windows."""
event = WebhookEvent(WebhookEntity.BUDGET_POLICY, WebhookAction.EXCEEDED)
for window in newly_exceeded:
policy = window.policy
if policy.budget_action != BudgetAction.ALERT:
continue
payload = BudgetPolicyExceededPayload(
budget_policy_id=policy.budget_policy_id,
budget_unit=policy.budget_unit.value,
budget_amount=policy.budget_amount,
current_spend=window.cumulative_spend,
duration_unit=policy.duration.unit.value,
duration_value=policy.duration.value,
target_scope=policy.target_scope.value,
workspace=workspace or (policy.workspace or DEFAULT_WORKSPACE_NAME),
window_start=int(window.window_start.timestamp() * 1000),
)
deliver_webhook(event=event, payload=payload, store=registry_store)
def _create_budget_error_trace(
endpoint_config: GatewayEndpointConfig,
exception: HTTPException,
) -> None:
"""Create an error trace for a budget limit rejection.
Only creates a trace when usage tracking is enabled, matching the guard
in ``maybe_traced_gateway_call``.
"""
if not endpoint_config.usage_tracking:
return
try:
with mlflow.start_span(
name=_gateway_span_name(endpoint_config),
span_type=SpanType.LLM,
trace_destination=MlflowExperimentLocation(endpoint_config.experiment_id),
attributes=_gateway_span_attributes(endpoint_config),
) as span:
span.record_exception(exception)
except Exception:
_logger.debug("Failed to create budget error trace", exc_info=True)
def check_budget_limit(
store: SqlAlchemyStore,
endpoint_config: GatewayEndpointConfig,
workspace: str | None = None,
) -> None:
"""Check if any REJECT-capable budget policy is exceeded.
Raises HTTPException(429) with an error trace if the budget limit is exceeded.
"""
maybe_refresh_budget_policies(store)
tracker = get_budget_tracker()
exceeded, window = tracker.should_reject_request(workspace=workspace)
if exceeded:
policy = window.policy
unit = policy.duration.unit.value.lower()
if policy.duration.value == 1:
unit = unit.rstrip("s")
reset_time = window.window_end.strftime("%Y-%m-%dT%H:%M:%SZ")
detail = (
f"Budget limit exceeded. "
f"Limit: ${policy.budget_amount:.2f} USD per "
f"{policy.duration.value} {unit}. "
f"Budget resets at {reset_time}. "
"Request rejected."
)
exc = HTTPException(status_code=429, detail=detail)
_create_budget_error_trace(endpoint_config, exc)
raise exc
def make_budget_on_complete(
store: SqlAlchemyStore,
workspace: str | None,
):
"""Create an on_complete callback that records budget cost from child span attributes."""
from mlflow.server.handlers import _get_model_registry_store
try:
registry_store = _get_model_registry_store()
except Exception:
registry_store = None
def on_complete():
try:
span = mlflow.get_current_active_span()
if not span:
return
total_cost = _compute_cost_from_child_spans(span.trace_id)
if total_cost <= 0:
return
maybe_refresh_budget_policies(store)
tracker = get_budget_tracker()
if newly_exceeded := tracker.record_cost(total_cost, workspace=workspace):
if registry_store:
fire_budget_exceeded_webhooks(newly_exceeded, workspace, registry_store)
except Exception:
_logger.debug("Failed to record budget cost", exc_info=True)
return on_complete