refactor(proxy)!: drop the unused accounting event tables (#49923)

LiteLLM_AccountingEvent and LiteLLM_AccountingDelivery have had no reader since
the SpendLogs projection replaced them, so the migration drops them with their
enums. AccountingDeliveryState stays because LiteLLM_SpendLogDelivery uses it

The schema now spells the delivery_id default the way Postgres stores it, which
ends the drift Prisma reported against the ledger migration. The schema check
compares base types only, so an attribute-only change is no longer reported as
a type change

Co-authored-by: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
joshua-berri 2026-11-04 20:23:30 -08:00 • committed by GitHub
parent 5a392b9e5f
commit 49f56e8b23
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 100 additions and 204 deletions

View file

@ -0,0 +1,5 @@
SET LOCAL lock_timeout = '10s';
DROP TABLE IF EXISTS "LiteLLM_AccountingDelivery";
DROP TABLE IF EXISTS "LiteLLM_AccountingEvent";
DROP TYPE IF EXISTS "AccountingDeliveryTarget";
DROP TYPE IF EXISTS "AccountingEventKind";

View file

@ -2071,78 +2071,14 @@ model LiteLLM_TelemetryReport {
@@index([created_at])
}
enum AccountingEventKind {
inbound_usage
outbound_usage
cost
}
enum AccountingDeliveryState {
pending
applied
parked
}
enum AccountingDeliveryTarget {
LiteLLM_VerificationToken
LiteLLM_UserTable
LiteLLM_TeamTable
LiteLLM_TeamMembership
LiteLLM_OrganizationTable
LiteLLM_OrganizationMembership
LiteLLM_EndUserTable
LiteLLM_AgentsTable
LiteLLM_TagTable
LiteLLM_ProjectTable
LiteLLM_ModelAccessGroupBudgetTable
LiteLLM_BudgetTable
LiteLLM_BudgetWindowSpend
LiteLLM_DailyUserSpend
LiteLLM_DailyTeamSpend
LiteLLM_DailyOrganizationSpend
LiteLLM_DailyEndUserSpend
LiteLLM_DailyAgentSpend
LiteLLM_DailyTagSpend
LiteLLM_DailyModelUsage
LiteLLM_DailyToolSpend
LiteLLM_AutoRouterDailySpend
}
// Durable accounting ledger. One row per usage or cost event; payload holds the
// private event envelope versioned by schema_version. Usage rows retain request-scoped
// pricing context; cost rows include resolved rates and the finalized pricing snapshot.
model LiteLLM_AccountingEvent {
event_id String @id
kind AccountingEventKind
source_event_id String?
schema_version Int
payload_hash String
occurred_at DateTime
recorded_at DateTime @default(now())
payload Json
@@index([recorded_at])
@@index([kind, source_event_id])
}
// One row per (event, target table, version): pending work for the projector and,
// once applied in the same transaction as the target write, the receipt that stops a retry.
model LiteLLM_AccountingDelivery {
event_id String
target AccountingDeliveryTarget
version Int
state AccountingDeliveryState @default(pending)
attempts Int @default(0)
next_attempt_at DateTime @default(now())
applied_at DateTime?
last_error String?
@@id([event_id, target, version])
@@index([target, state, next_attempt_at])
}
model LiteLLM_SpendLogDelivery {
delivery_id String @id @default(dbgenerated("gen_random_uuid()::text"))
delivery_id String @id @default(dbgenerated("(gen_random_uuid())::text"))
request_id String
state AccountingDeliveryState @default(pending)
accounting_payload Json

View file

@ -338,8 +338,6 @@ _PRISMA_MODELS: Final[frozenset[str]] = frozenset(
"LiteLLM_ToolTable",
"LiteLLM_TelemetryReport",
"LiteLLM_AccessGroupTable",
"LiteLLM_AccountingDelivery",
"LiteLLM_AccountingEvent",
"LiteLLM_SpendLogDelivery",
"LiteLLM_ClaudeCodePluginTable",
"LiteLLM_ClaudeCodePluginUpload",

View file

@ -2071,78 +2071,14 @@ model LiteLLM_TelemetryReport {
@@index([created_at])
}
enum AccountingEventKind {
inbound_usage
outbound_usage
cost
}
enum AccountingDeliveryState {
pending
applied
parked
}
enum AccountingDeliveryTarget {
LiteLLM_VerificationToken
LiteLLM_UserTable
LiteLLM_TeamTable
LiteLLM_TeamMembership
LiteLLM_OrganizationTable
LiteLLM_OrganizationMembership
LiteLLM_EndUserTable
LiteLLM_AgentsTable
LiteLLM_TagTable
LiteLLM_ProjectTable
LiteLLM_ModelAccessGroupBudgetTable
LiteLLM_BudgetTable
LiteLLM_BudgetWindowSpend
LiteLLM_DailyUserSpend
LiteLLM_DailyTeamSpend
LiteLLM_DailyOrganizationSpend
LiteLLM_DailyEndUserSpend
LiteLLM_DailyAgentSpend
LiteLLM_DailyTagSpend
LiteLLM_DailyModelUsage
LiteLLM_DailyToolSpend
LiteLLM_AutoRouterDailySpend
}
// Durable accounting ledger. One row per usage or cost event; payload holds the
// private event envelope versioned by schema_version. Usage rows retain request-scoped
// pricing context; cost rows include resolved rates and the finalized pricing snapshot.
model LiteLLM_AccountingEvent {
event_id String @id
kind AccountingEventKind
source_event_id String?
schema_version Int
payload_hash String
occurred_at DateTime
recorded_at DateTime @default(now())
payload Json
@@index([recorded_at])
@@index([kind, source_event_id])
}
// One row per (event, target table, version): pending work for the projector and,
// once applied in the same transaction as the target write, the receipt that stops a retry.
model LiteLLM_AccountingDelivery {
event_id String
target AccountingDeliveryTarget
version Int
state AccountingDeliveryState @default(pending)
attempts Int @default(0)
next_attempt_at DateTime @default(now())
applied_at DateTime?
last_error String?
@@id([event_id, target, version])
@@index([target, state, next_attempt_at])
}
model LiteLLM_SpendLogDelivery {
delivery_id String @id @default(dbgenerated("gen_random_uuid()::text"))
delivery_id String @id @default(dbgenerated("(gen_random_uuid())::text"))
request_id String
state AccountingDeliveryState @default(pending)
accounting_payload Json

View file

@ -2071,78 +2071,14 @@ model LiteLLM_TelemetryReport {
@@index([created_at])
}
enum AccountingEventKind {
inbound_usage
outbound_usage
cost
}
enum AccountingDeliveryState {
pending
applied
parked
}
enum AccountingDeliveryTarget {
LiteLLM_VerificationToken
LiteLLM_UserTable
LiteLLM_TeamTable
LiteLLM_TeamMembership
LiteLLM_OrganizationTable
LiteLLM_OrganizationMembership
LiteLLM_EndUserTable
LiteLLM_AgentsTable
LiteLLM_TagTable
LiteLLM_ProjectTable
LiteLLM_ModelAccessGroupBudgetTable
LiteLLM_BudgetTable
LiteLLM_BudgetWindowSpend
LiteLLM_DailyUserSpend
LiteLLM_DailyTeamSpend
LiteLLM_DailyOrganizationSpend
LiteLLM_DailyEndUserSpend
LiteLLM_DailyAgentSpend
LiteLLM_DailyTagSpend
LiteLLM_DailyModelUsage
LiteLLM_DailyToolSpend
LiteLLM_AutoRouterDailySpend
}
// Durable accounting ledger. One row per usage or cost event; payload holds the
// private event envelope versioned by schema_version. Usage rows retain request-scoped
// pricing context; cost rows include resolved rates and the finalized pricing snapshot.
model LiteLLM_AccountingEvent {
event_id String @id
kind AccountingEventKind
source_event_id String?
schema_version Int
payload_hash String
occurred_at DateTime
recorded_at DateTime @default(now())
payload Json
@@index([recorded_at])
@@index([kind, source_event_id])
}
// One row per (event, target table, version): pending work for the projector and,
// once applied in the same transaction as the target write, the receipt that stops a retry.
model LiteLLM_AccountingDelivery {
event_id String
target AccountingDeliveryTarget
version Int
state AccountingDeliveryState @default(pending)
attempts Int @default(0)
next_attempt_at DateTime @default(now())
applied_at DateTime?
last_error String?
@@id([event_id, target, version])
@@index([target, state, next_attempt_at])
}
model LiteLLM_SpendLogDelivery {
delivery_id String @id @default(dbgenerated("gen_random_uuid()::text"))
delivery_id String @id @default(dbgenerated("(gen_random_uuid())::text"))
request_id String
state AccountingDeliveryState @default(pending)
accounting_payload Json

View file

@ -13,6 +13,7 @@ from pydantic import TypeAdapter
Schema = Mapping[str, Mapping[str, str]]
MODELS: Final = re.compile(r"^[ \t]*model\s+(\w+)\s*\{(.*?)^[ \t]*\}", re.MULTILINE | re.DOTALL)
DROPPED_UNUSED_MODELS: Final = frozenset({"LiteLLM_AccountingDelivery", "LiteLLM_AccountingEvent"})
def parse_model_fields(schema: str) -> Schema:
@ -27,18 +28,18 @@ def _field_changes(model: str, field: str, old_type: str, new_fields: Mapping[st
if field not in new_fields:
yield f"Breaking: Field {model}.{field} was removed"
return
new_type: Final = new_fields[field]
if old_type == new_type:
return
if "?" in old_type and "?" not in new_type:
old_base: Final = old_type.split()[0]
new_base: Final = new_fields[field].split()[0]
if old_base.endswith("?") and not new_base.endswith("?"):
yield f"Breaking: Field {model}.{field} changed from optional to required"
if not old_type.startswith(new_type.split("?")[0]):
yield f"Breaking: Field {model}.{field} changed type from {old_type} to {new_type}"
if old_base.rstrip("?") != new_base.rstrip("?"):
yield f"Breaking: Field {model}.{field} changed type from {old_base} to {new_base}"
def _model_changes(model: str, old_fields: Mapping[str, str], new_schema: Schema) -> Iterator[str]:
if model not in new_schema:
yield f"Breaking: Model {model} was removed"
if model not in DROPPED_UNUSED_MODELS:
yield f"Breaking: Model {model} was removed"
return
for field, old_type in old_fields.items():
yield from _field_changes(model, field, old_type, new_schema[model])

View file

@ -0,0 +1,64 @@
import os
from pathlib import Path
from typing import Final
import psycopg
import pytest
_MIGRATIONS: Final = Path(__file__).parents[4] / "litellm-proxy-extras/litellm_proxy_extras/migrations"
_OLD_LEDGER: Final = _MIGRATIONS / "20261027000000_add_accounting_ledger_tables/migration.sql"
_SPEND_LOG_DELIVERY: Final = _MIGRATIONS / "20261103010000_spendlogs_accounting_delivery/migration.sql"
_DROP: Final = _MIGRATIONS / "20261104010000_drop_accounting_event_tables/migration.sql"
_DROPPED: Final = (
"AccountingDeliveryTarget",
"AccountingEventKind",
"LiteLLM_AccountingDelivery",
"LiteLLM_AccountingEvent",
)
def _apply(connection: psycopg.Connection[tuple[object, ...]], migration: Path) -> None:
with connection.transaction():
connection.execute(migration.read_bytes())
def _present(connection: psycopg.Connection[tuple[object, ...]], names: tuple[str, ...]) -> tuple[object, ...]:
return tuple(
row[0]
for row in connection.execute(
"SELECT relname FROM pg_class WHERE relname = ANY(%s) "
"UNION SELECT typname FROM pg_type WHERE typname = ANY(%s) ORDER BY 1",
(list(names), list(names)),
).fetchall()
)
@pytest.mark.parametrize("old_states", [(), ("applied", "pending", "parked")], ids=["empty", "with_rows"])
def test_drop_removes_the_old_ledger_and_keeps_spend_log_deliveries(old_states: tuple[str, ...]) -> None:
with psycopg.connect(os.environ["DATABASE_URL"], autocommit=True) as connection:
_apply(connection, _OLD_LEDGER)
connection.execute('CREATE TABLE "LiteLLM_SpendLogs" (request_id text PRIMARY KEY)')
_apply(connection, _SPEND_LOG_DELIVERY)
for index, state in enumerate(old_states):
connection.execute(
'INSERT INTO "LiteLLM_AccountingEvent" (event_id, kind, schema_version, payload_hash, occurred_at, '
"payload) VALUES (%s, 'cost', 1, 'hash', now(), '{}')",
(f"event-{index}",),
)
connection.execute(
'INSERT INTO "LiteLLM_AccountingDelivery" (event_id, target, version, state) '
"VALUES (%s, 'LiteLLM_UserTable', 1, %s)",
(f"event-{index}", state),
)
connection.execute(
'INSERT INTO "LiteLLM_SpendLogDelivery" (request_id, state, accounting_payload, is_batch) '
"VALUES ('request', 'pending', '{}', false)"
)
_apply(connection, _DROP)
_apply(connection, _DROP)
assert _present(connection, _DROPPED) == ()
assert connection.execute('SELECT request_id, state::text FROM "LiteLLM_SpendLogDelivery"').fetchall() == [
("request", "pending")
]

View file

@ -12,6 +12,12 @@ def test_aaaaaschema_compatibility() -> None:
assert check_breaking_changes(parse_model_fields(ORIGINAL), parse_model_fields(added)) == ()
def test_changing_only_a_field_attribute_is_not_breaking() -> None:
previous: Final = parse_model_fields('model Item {\n id String @id @default(dbgenerated("a()::text"))\n}\n')
current: Final = parse_model_fields('model Item {\n id String @id @default(dbgenerated("(a())::text"))\n}\n')
assert check_breaking_changes(previous, current) == ()
def test_parse_model_fields_retains_types_and_attributes() -> None:
assert parse_model_fields(ORIGINAL) == {"Item": {"id": "String @id", "label": "String?"}}
@ -40,3 +46,17 @@ def test_schema_compatibility_rejects_removed_models_fields_and_narrowed_types(
schema: str, expected: tuple[str, ...]
) -> None:
assert check_breaking_changes(parse_model_fields(ORIGINAL), parse_model_fields(schema)) == expected
def test_only_the_dropped_accounting_models_may_be_removed() -> None:
accounting: Final = (
"model LiteLLM_AccountingEvent {\n event_id String @id\n}\n"
"model LiteLLM_AccountingDelivery {\n event_id String\n note String?\n}\n"
)
previous: Final = parse_model_fields(ORIGINAL + accounting)
assert check_breaking_changes(previous, parse_model_fields(ORIGINAL)) == ()
assert check_breaking_changes(previous, parse_model_fields("")) == ("Breaking: Model Item was removed",)
assert check_breaking_changes(
previous, parse_model_fields(ORIGINAL + "model LiteLLM_AccountingDelivery {\n event_id String\n}\n")
) == ("Breaking: Field LiteLLM_AccountingDelivery.note was removed",)