Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
BEGIN;
SET LOCAL lock_timeout = '10s';
SET LOCAL statement_timeout = '30s';

DO $install$
DECLARE
target_schema CONSTANT TEXT := current_schema();
daily_table CONSTANT REGCLASS := to_regclass(format('%I.%I', target_schema, 'LiteLLM_DailyUserSpend'));
observation_table CONSTANT REGCLASS := to_regclass(format('%I.%I', target_schema, 'LiteLLM_AutoRouterBaselineObservation'));
-- This marker versions the full column/function/trigger contract, preserved by older pods.
implementation_version CONSTANT INTEGER := 1;
function_body CONSTANT TEXT := $function$
DECLARE
previous_publication JSONB := OLD.daily_costs_publication::jsonb;
next_publication JSONB := NEW.publication::jsonb;
attribution JSONB := NEW.data::jsonb -> 'daily';
previous_estimated BOOLEAN := COALESCE(previous_publication ->> 'status' = 'estimated', FALSE)
AND previous_publication ->> 'actual_spend' IS NOT NULL
AND previous_publication ->> 'baseline_spend' IS NOT NULL;
next_estimated BOOLEAN := COALESCE(next_publication ->> 'status' = 'estimated', FALSE)
AND next_publication ->> 'actual_spend' IS NOT NULL
AND next_publication ->> 'baseline_spend' IS NOT NULL;
request_delta BIGINT := (CASE WHEN next_estimated THEN 1 ELSE 0 END)
- (CASE WHEN previous_estimated THEN 1 ELSE 0 END);
actual_delta DOUBLE PRECISION :=
(CASE WHEN next_estimated THEN (next_publication ->> 'actual_spend')::double precision ELSE 0 END)
- (CASE WHEN previous_estimated THEN (previous_publication ->> 'actual_spend')::double precision ELSE 0 END);
target_user_id TEXT;
BEGIN
NEW.daily_costs_publication := NEW.publication;
IF request_delta = 0 AND actual_delta = 0 THEN
RETURN NEW;
END IF;

FOR target_user_id IN
SELECT DISTINCT COALESCE(target ->> 'entity_id', '')
FROM jsonb_array_elements(COALESCE(attribution -> 'targets', '[]'::jsonb)) AS target
WHERE target ->> 'entity' = 'user'
ORDER BY 1
LOOP
INSERT INTO "LiteLLM_DailyUserSpend" (
id, user_id, date, api_key, model, custom_llm_provider,
mcp_namespaced_tool_name, endpoint, model_group,
autorouter_estimated_requests, autorouter_estimated_actual_spend, updated_at
) VALUES (
'autorouter-coverage:' || length(NEW.request_id)::text || ':' || NEW.request_id || ':' || target_user_id,
target_user_id, COALESCE(attribution ->> 'date', ''), COALESCE(attribution ->> 'api_key', ''),
COALESCE(attribution ->> 'model', ''), COALESCE(attribution ->> 'custom_llm_provider', ''),
COALESCE(attribution ->> 'mcp_namespaced_tool_name', ''), COALESCE(attribution ->> 'endpoint', ''),
attribution ->> 'model_group', request_delta, actual_delta, (NOW() AT TIME ZONE 'UTC')
)
ON CONFLICT (user_id, date, api_key, model, custom_llm_provider, mcp_namespaced_tool_name, endpoint)
DO UPDATE SET
autorouter_estimated_requests = "LiteLLM_DailyUserSpend".autorouter_estimated_requests
+ EXCLUDED.autorouter_estimated_requests,
autorouter_estimated_actual_spend = "LiteLLM_DailyUserSpend".autorouter_estimated_actual_spend
+ EXCLUDED.autorouter_estimated_actual_spend,
updated_at = (NOW() AT TIME ZONE 'UTC');
Comment thread
tin-berri marked this conversation as resolved.
END LOOP;
RETURN NEW;
END;
$function$;
missing_columns BOOLEAN;
invalid_columns BOOLEAN;
function_id OID;
function_version INTEGER;
function_ready BOOLEAN;
trigger_ready BOOLEAN;
BEGIN
IF daily_table IS NULL OR observation_table IS NULL THEN
RAISE EXCEPTION 'Daily auto-router coverage requires its tables in schema %', target_schema;
END IF;

FOR attempt IN 1..2 LOOP
SELECT bool_or(a.attnum IS NULL),
bool_or(a.attnum IS NOT NULL AND (
a.atttypid <> required.column_type::regtype
OR a.attnotnull <> required.not_null
OR CASE WHEN required.not_null
THEN COALESCE(pg_get_expr(d.adbin, d.adrelid), '') !~ '^0([.]0+)?(::(bigint|double precision))?$'
ELSE d.oid IS NOT NULL END
))
INTO missing_columns, invalid_columns
FROM (VALUES
(daily_table, 'autorouter_accounted_requests', 'bigint', TRUE),
(daily_table, 'autorouter_requests', 'bigint', TRUE),
(daily_table, 'autorouter_llm_spend', 'double precision', TRUE),
(daily_table, 'autorouter_classifier_cost', 'double precision', TRUE),
(daily_table, 'autorouter_classifier_cost_recorded_requests', 'bigint', TRUE),
(daily_table, 'autorouter_estimated_requests', 'bigint', TRUE),
(daily_table, 'autorouter_estimated_actual_spend', 'double precision', TRUE),
(observation_table, 'daily_costs_publication', 'text', FALSE)
) AS required(table_id, column_name, column_type, not_null)
LEFT JOIN pg_attribute a ON a.attrelid = required.table_id
AND a.attname = required.column_name AND a.attnum > 0 AND NOT a.attisdropped
LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum;

IF invalid_columns THEN
RAISE EXCEPTION 'Daily auto-router coverage has incompatible column definitions in schema %', target_schema;
END IF;

SELECT p.oid,
substring(obj_description(p.oid, 'pg_proc') FROM '^litellm:autorouter_daily_coverage:([0-9]{1,9})$')::integer,
p.prorettype = 'trigger'::regtype AND l.lanname = 'plpgsql'
AND p.provolatile = 'v' AND NOT p.prosecdef AND p.proconfig IS NULL
AND (p.prosrc = function_body OR COALESCE(
substring(obj_description(p.oid, 'pg_proc') FROM '^litellm:autorouter_daily_coverage:([0-9]{1,9})$')::integer,
0
) > implementation_version)
INTO function_id, function_version, function_ready
FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
JOIN pg_language l ON l.oid = p.prolang
WHERE n.nspname = target_schema AND p.proname = 'litellm_update_daily_autorouter_coverage'
AND p.pronargs = 0;

SELECT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_attribute a ON a.attrelid = observation_table AND a.attname = 'publication'
AND a.attnum > 0 AND NOT a.attisdropped
WHERE t.tgrelid = observation_table AND t.tgname = 'litellm_update_daily_autorouter_coverage'
AND NOT t.tgisinternal AND t.tgenabled IN ('O', 'A') AND t.tgtype = 19
AND t.tgattr::text = a.attnum::text AND t.tgnargs = 0 AND t.tgqual IS NULL
AND t.tgfoid = function_id
) INTO trigger_ready;

IF NOT missing_columns AND COALESCE(function_ready, FALSE) AND trigger_ready THEN
RETURN;
END IF;
IF function_version > implementation_version THEN
RAISE EXCEPTION 'Newer daily auto-router coverage is incompatible with this build in schema %', target_schema;
END IF;
IF attempt = 1 THEN
PERFORM pg_advisory_xact_lock(1279874117, 1145129292);
END IF;
END LOOP;

IF missing_columns THEN
ALTER TABLE "LiteLLM_DailyUserSpend"
ADD COLUMN IF NOT EXISTS "autorouter_accounted_requests" BIGINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS "autorouter_requests" BIGINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS "autorouter_llm_spend" DOUBLE PRECISION NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS "autorouter_classifier_cost" DOUBLE PRECISION NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS "autorouter_classifier_cost_recorded_requests" BIGINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS "autorouter_estimated_requests" BIGINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS "autorouter_estimated_actual_spend" DOUBLE PRECISION NOT NULL DEFAULT 0;

ALTER TABLE "LiteLLM_AutoRouterBaselineObservation"
ADD COLUMN IF NOT EXISTS "daily_costs_publication" TEXT;
END IF;

IF NOT COALESCE(function_ready, FALSE) THEN
EXECUTE format(
'CREATE OR REPLACE FUNCTION %I.litellm_update_daily_autorouter_coverage() '
'RETURNS TRIGGER AS %L LANGUAGE plpgsql VOLATILE SECURITY INVOKER',
target_schema, function_body
);
EXECUTE format('ALTER FUNCTION %I.litellm_update_daily_autorouter_coverage() RESET ALL', target_schema);
EXECUTE format('COMMENT ON FUNCTION %I.litellm_update_daily_autorouter_coverage() IS %L',
target_schema, 'litellm:autorouter_daily_coverage:' || implementation_version::text);
END IF;

IF NOT trigger_ready THEN
DROP TRIGGER IF EXISTS litellm_update_daily_autorouter_coverage ON "LiteLLM_AutoRouterBaselineObservation";
CREATE TRIGGER litellm_update_daily_autorouter_coverage
BEFORE UPDATE OF publication ON "LiteLLM_AutoRouterBaselineObservation"
FOR EACH ROW EXECUTE PROCEDURE litellm_update_daily_autorouter_coverage();
END IF;
Comment thread
tin-berri marked this conversation as resolved.
END;
$install$;

COMMIT;
8 changes: 8 additions & 0 deletions litellm-proxy-extras/litellm_proxy_extras/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,13 @@ model LiteLLM_DailyUserSpend {
prompt_caching_savings_spend Float @default(0.0)
gateway_injected_caching_savings_spend Float @default(0.0)
autorouter_savings_spend Float @default(0.0)
autorouter_accounted_requests BigInt @default(0)
autorouter_requests BigInt @default(0)
autorouter_llm_spend Float @default(0.0)
autorouter_classifier_cost Float @default(0.0)
autorouter_classifier_cost_recorded_requests BigInt @default(0)
autorouter_estimated_requests BigInt @default(0)
autorouter_estimated_actual_spend Float @default(0.0)
spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
Expand Down Expand Up @@ -1580,6 +1587,7 @@ model LiteLLM_AutoRouterBaselineObservation {
revision BigInt
data String
publication String?
daily_costs_publication String?
conflicted Boolean @default(false)

@@index([scope, started_at, request_id], map: "idx_autorouter_baseline_event_order")
Expand Down
33 changes: 33 additions & 0 deletions litellm-proxy-extras/litellm_proxy_extras/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1145,6 +1145,38 @@ def apply_replica_identity_full_if_requested() -> bool:
prisma_env=prisma_env,
)

@staticmethod
def apply_autorouter_daily_coverage() -> None:
from litellm_proxy_extras.migration_lock import migration_environment

package_dir: Final = Path(__file__).resolve().parent
migration: Final = package_dir / "migrations" / "20260923000000_add_daily_autorouter_costs" / "migration.sql"
for attempt in range(MAX_MIGRATE_DEPLOY_ATTEMPTS):
try:
prisma_toolchain.run_prisma(
[
_get_prisma_command(),
"db",
"execute",
"--schema",
str(package_dir / "schema.prisma"),
"--file",
str(migration),
],
timeout=prisma_command_timeout(),
env=migration_environment(_get_prisma_env()),
stdout=None,
stderr=None,
)
return
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
Comment thread
tin-berri marked this conversation as resolved.
if isinstance(error, OSError) or attempt + 1 == MAX_MIGRATE_DEPLOY_ATTEMPTS:
raise RuntimeError(
"Failed to install daily auto-router coverage; database setup cannot continue"
) from error
logger.warning("Daily auto-router coverage installation attempt %s failed; retrying", attempt + 1)
time.sleep(random.uniform(0.5, 1.5))

@staticmethod
def setup_database(
use_migrate: bool = False, use_v2_resolver: bool = False
Expand All @@ -1170,6 +1202,7 @@ def setup_database(
use_migrate=use_migrate, use_v2_resolver=use_v2_resolver
)
if migrated:
ProxyExtrasDBManager.apply_autorouter_daily_coverage()
ProxyExtrasDBManager.repair_invalid_indexes()
ProxyExtrasDBManager.apply_replica_identity_full_if_requested()
return migrated
Expand Down
11 changes: 4 additions & 7 deletions litellm/models/autorouter_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,12 @@ class LiteLLM_AutoRouterSession(LiteLLMPydanticObjectBase):

@property
def baseline_model(self) -> str | None:
"""The baseline most covered turns were priced against, or None when none were estimated.
"""The baseline recorded by most session turns, including historical turns.

A router reconfigured mid-session leaves turns priced against two baselines; the row keeps both
counts, and the label is the one that priced the most money-carrying turns rather than whatever the
counts, and the label is the one recorded by the most turns rather than whatever the
router is configured with now.
"""
if not self.savings_estimated_baseline_models:
if not self.baseline_models:
return None
return max(
self.savings_estimated_baseline_models,
key=lambda model: (self.savings_estimated_baseline_models[model], model),
)
return max(self.baseline_models, key=lambda model: (self.baseline_models[model], model))
7 changes: 7 additions & 0 deletions litellm/proxy/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -5532,6 +5532,13 @@ class BaseDailySpendTransaction(TypedDict):
# missing value to zero, so requiring it here would describe a shape the aggregation
# is explicitly tested against.
autorouter_savings_spend: NotRequired[float]
autorouter_accounted_requests: ReadOnly[NotRequired[int]]
autorouter_requests: ReadOnly[NotRequired[int]]
autorouter_llm_spend: ReadOnly[NotRequired[float]]
autorouter_classifier_cost: ReadOnly[NotRequired[float]]
autorouter_classifier_cost_recorded_requests: ReadOnly[NotRequired[int]]
autorouter_estimated_requests: ReadOnly[NotRequired[int]]
autorouter_estimated_actual_spend: ReadOnly[NotRequired[float]]

# request level metrics
spend: float
Expand Down
43 changes: 30 additions & 13 deletions litellm/proxy/client/cli/commands/statusline_script.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ class Session(NamedTuple):
turns: int | None = None
savings_estimated_turns: int | None = None
savings_estimated_actual_spend: float | None = None
savings_estimated_baseline_spend: float | None = None


class Credentials(NamedTuple):
Expand Down Expand Up @@ -210,7 +211,8 @@ def _session_from_payload(payload: Mapping[str, object]) -> Session | None:
router_name: Final = printable(payload.get("router_name"))
last_model: Final = printable(payload.get("last_model"))
spend: Final = payload.get("spend")
baseline_spend: Final = payload.get("savings_estimated_baseline_spend", payload.get("baseline_spend"))
baseline_spend: Final = payload.get("baseline_spend")
estimated_baseline_spend: Final = payload.get("savings_estimated_baseline_spend")
turns: Final = payload.get("turns")
estimated_turns: Final = payload.get("savings_estimated_turns")
estimated_actual: Final = payload.get("savings_estimated_actual_spend")
Expand All @@ -222,6 +224,12 @@ def _session_from_payload(payload: Mapping[str, object]) -> Session | None:
not isinstance(baseline_spend, (int, float)) or isinstance(baseline_spend, bool) or not isfinite(baseline_spend)
):
return None
if estimated_baseline_spend is not None and (
not isinstance(estimated_baseline_spend, (int, float))
or isinstance(estimated_baseline_spend, bool)
or not isfinite(estimated_baseline_spend)
):
return None
return Session(
router_name=router_name,
last_model=last_model,
Expand All @@ -242,11 +250,14 @@ def _session_from_payload(payload: Mapping[str, object]) -> Session | None:
and estimated_actual >= 0
else None
),
savings_estimated_baseline_spend=(
float(estimated_baseline_spend) if estimated_baseline_spend is not None else None
),
)


def cache_path(cache_dir: Path, credentials: Credentials, session_id: str) -> Path:
identity: Final = "\n".join((credentials.base_url, credentials.api_key, session_id))
identity: Final = "\n".join(("v2", credentials.base_url, credentials.api_key, session_id))
return cache_dir / hashlib.sha256(identity.encode()).hexdigest()


Expand Down Expand Up @@ -342,35 +353,41 @@ def paint(code: str, text: str) -> str:
routed: Final = paint(BOLD, f"Routed to: {model}")
if session is None:
return routed
if session.savings_estimated_turns == 0 or session.baseline_spend is None:
covered_only: Final = session.baseline_spend is None
baseline_spend: Final = session.savings_estimated_baseline_spend if covered_only else session.baseline_spend
if baseline_spend is None or (covered_only and session.savings_estimated_turns == 0):
return f"{routed}{SEPARATOR}Savings unavailable"
if session.baseline_model is None or session.baseline_spend <= 0:
if session.baseline_model is None or baseline_spend <= 0:
return routed
if session.savings_estimated_turns is not None and (
session.savings_estimated_actual_spend is None
or session.turns is None
or session.savings_estimated_turns > session.turns
if (
covered_only
and session.savings_estimated_turns is not None
and (
session.savings_estimated_actual_spend is None
or session.turns is None
or session.savings_estimated_turns > session.turns
)
):
return f"{routed}{SEPARATOR}Savings unavailable"
compared_spend: Final = (
session.savings_estimated_actual_spend
if session.savings_estimated_turns is not None and session.savings_estimated_actual_spend is not None
if covered_only and session.savings_estimated_actual_spend is not None
else session.spend
)
coverage: Final = (
f"{SEPARATOR}{session.savings_estimated_turns} of {session.turns} turns estimated"
if session.savings_estimated_turns is not None
if covered_only and session.savings_estimated_turns is not None
else ""
)
reference: Final = baseline_label(session.baseline_model, config_dir)
pct: Final = round((session.baseline_spend - compared_spend) / session.baseline_spend * 100)
pct: Final = round((baseline_spend - compared_spend) / baseline_spend * 100)
sign: Final = "-" if pct > 0 else "+" if pct < 0 else ""
delta: Final = paint(LITELLM_COLOR, f"{sign}{abs(pct)}% vs {reference}")
peak: Final = max(compared_spend, session.baseline_spend)
peak: Final = max(compared_spend, baseline_spend)
label_width: Final = max(_display_width(session.router_name), _display_width(reference))
rows: Final = (
(session.router_name, compared_spend, LITELLM_COLOR),
(reference, session.baseline_spend, BASELINE_COLOR),
(reference, baseline_spend, BASELINE_COLOR),
)
lines: Final = (
f"{paint(DIM, label + ' ' * (label_width - _display_width(label)))} "
Expand Down
Loading
Loading