Skip to content
Merged
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
23 changes: 22 additions & 1 deletion src/tollgate/adapters/postgres/chargeback_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
``resolve_scope_ancestry`` returns a node's server-derived ancestry map for the authorization
check (section 5.0). ``spend_rollup`` sums one node's own budget ledger rows for a period,
grouped by provider, model, or a label key -- joined on ``budget`` for that single node so a
subtree union never double-counts shared spend (section 2). ``PostgresChargebackReader`` hands
subtree union never double-counts shared spend (section 2); a node that carries no budget of its
own has no such ledger rows, so it raises ``BudgetNotFound`` rather than silently reporting zero
spend (#97). ``PostgresChargebackReader`` hands
out a repository bound to a fresh read-only connection per read. Explicit async SQLAlchemy Core,
no ORM; satisfies the ports structurally without importing ``application``.
"""
Expand All @@ -35,6 +37,7 @@
user_principal,
)
from tollgate.domain.chargeback import BudgetState, GroupBy, GroupByKind, SpendGroup
from tollgate.domain.errors import BudgetNotFound
from tollgate.domain.ids import BudgetId
from tollgate.domain.invariants import Balance
from tollgate.domain.scopes import ScopeKind, scope_rank
Expand Down Expand Up @@ -173,13 +176,31 @@ async def resolve_scope_ancestry(
).first()
return None if row is None else {ScopeKind.ORG: row.org_id, ScopeKind.PROJECT: scope_id}

async def _node_has_own_budget(self, scope_kind: ScopeKind, scope_id: str) -> bool:
"""Whether ``(scope_kind, scope_id)`` carries a budget of its own (a cheap keyed lookup)."""
row = (
await self._conn.execute(
select(budget.c.budget_id)
.where(budget.c.scope_kind == scope_kind.value, budget.c.scope_id == scope_id)
.limit(1)
)
).first()
return row is not None

async def spend_rollup(
self,
scope_kind: ScopeKind,
scope_id: str,
period_start: datetime,
group_by: GroupBy,
) -> Sequence[SpendGroup]:
# A single-node rollup sums only this node's own budget ledger, which already aggregates
# everything beneath it (ADR 0033) — but only if the node carries a budget. A budget-less
# node accrues no ledger rows of its own, so the aggregate would come back empty and
# silently report zero spend even while /v1/budgets shows the subtree's real spend. Fail
# explicitly so the two chargeback reads never disagree (#97).
if not await self._node_has_own_budget(scope_kind, scope_id):
raise BudgetNotFound(f"no budget at {scope_kind.value}:{scope_id} to roll up")
led, bud, res = ledger.c, budget.c, reservation.c
spend = func.sum(led.delta_committed_micro + led.delta_overage_micro)
node = and_(
Expand Down
38 changes: 30 additions & 8 deletions src/tollgate/api/routes/chargeback.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@

from tollgate.api.dependencies import RequestAuth
from tollgate.api.schemas import (
MAX_LABEL_KEY_LEN,
MAX_STR_LEN,
BudgetAlertState,
BudgetStateResponse,
BudgetStatesResponse,
Expand All @@ -29,6 +31,7 @@
from tollgate.domain.chargeback import (
BudgetState,
BudgetStatesView,
GroupBy,
SpendRollup,
crossed_thresholds,
parse_group_by,
Expand All @@ -52,18 +55,42 @@


def _parse_scope(scope: str | None) -> ScopeRef | None:
"""Parse ``<kind>:<id>`` into a :class:`ScopeRef`; ``None`` passes through. 422 on malformed."""
"""Parse ``<kind>:<id>`` into a :class:`ScopeRef`; ``None`` passes through. 422 on malformed.

``scope_id`` is bounded to ``MAX_STR_LEN`` to match the command-path identifier limits — the
read path took an otherwise unbounded query value straight into a ``WHERE`` (#107).
"""
if scope is None:
return None
kind, separator, scope_id = scope.partition(":")
if not separator or kind not in _SCOPE_KINDS or not scope_id:
if not separator or kind not in _SCOPE_KINDS or not scope_id or len(scope_id) > MAX_STR_LEN:
raise HTTPException(
status_code=422,
detail="scope must be '<kind>:<id>' where kind is one of org, team, user, project",
)
return ScopeRef(scope_kind=ScopeKind(kind), scope_id=scope_id)


def _parse_group_by_query(raw: str) -> GroupBy:
"""Parse the ``group_by`` query into a :class:`GroupBy`; 422 on malformed or an oversized key.

The ``label:<key>`` form otherwise carried an unbounded key straight into the rollup grouping;
bound it to ``MAX_LABEL_KEY_LEN`` like the command-path label keys (#107).
"""
parsed = parse_group_by(raw)
if parsed is None:
raise HTTPException(
status_code=422,
detail="group_by must be 'provider', 'model', or 'label:<key>'",
)
if parsed.label_key is not None and len(parsed.label_key) > MAX_LABEL_KEY_LEN:
raise HTTPException(
status_code=422,
detail=f"group_by label key must be at most {MAX_LABEL_KEY_LEN} characters",
)
return parsed


def _node_response(state: BudgetState) -> BudgetStateResponse:
crossed = set(crossed_thresholds(state))
return BudgetStateResponse(
Expand Down Expand Up @@ -117,12 +144,7 @@ async def spend(
period_start: datetime | None = None,
) -> SpendRollupResponse:
"""Realized spend for a scope node, grouped by a dimension, for one period (section 2, 5.0)."""
parsed = parse_group_by(group_by)
if parsed is None:
raise HTTPException(
status_code=422,
detail="group_by must be 'provider', 'model', or 'label:<key>'",
)
parsed = _parse_group_by_query(group_by)
handler: ChargebackHandler = request.app.state.chargeback_handler
rollup = await handler.spend_rollup(
auth, group_by=parsed, scope=_parse_scope(scope), period_start=period_start
Expand Down
2 changes: 1 addition & 1 deletion src/tollgate/api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
MAX_LABEL_KEY_LEN: Final = 128
MAX_LABEL_VALUE_LEN: Final = 256

_LabelKey = Annotated[str, StringConstraints(max_length=MAX_LABEL_KEY_LEN)]
_LabelKey = Annotated[str, StringConstraints(min_length=1, max_length=MAX_LABEL_KEY_LEN)]
_LabelValue = Annotated[str, StringConstraints(max_length=MAX_LABEL_VALUE_LEN)]


Expand Down
12 changes: 9 additions & 3 deletions tests/integration/test_chargeback_spend_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from datetime import UTC, datetime

import pytest
from sqlalchemy.ext.asyncio import AsyncConnection

from tollgate.adapters.postgres.chargeback_repo import PostgresChargebackRepository
Expand All @@ -17,6 +18,7 @@
user_principal,
)
from tollgate.domain.chargeback import GroupBy, GroupByKind
from tollgate.domain.errors import BudgetNotFound
from tollgate.domain.scopes import ScopeKind

_PERIOD = datetime(2026, 7, 1, tzinfo=UTC)
Expand Down Expand Up @@ -352,8 +354,12 @@ async def test_other_period_and_other_node_excluded(db_conn: AsyncConnection) ->
assert [(g.group, g.spend_micro) for g in rows] == [("anthropic", 200)]


async def test_node_without_a_budget_is_empty(db_conn: AsyncConnection) -> None:
async def test_node_without_a_budget_raises_budget_not_found(db_conn: AsyncConnection) -> None:
# A single-node rollup is only defined for a node that carries its own budget: its ledger
# already aggregates everything beneath it (ADR 0033). A budget-less node accrues no ledger
# rows of its own, so returning [] would silently report zero spend while /v1/budgets shows the
# subtree's real spend. Fail explicitly instead so the two reads never disagree (#97).
await _tree(db_conn) # team t1 has no budget
repo = PostgresChargebackRepository(db_conn)
rows = await repo.spend_rollup(ScopeKind.TEAM, "t1", _PERIOD, GroupBy(GroupByKind.PROVIDER))
assert rows == []
with pytest.raises(BudgetNotFound):
await repo.spend_rollup(ScopeKind.TEAM, "t1", _PERIOD, GroupBy(GroupByKind.PROVIDER))
13 changes: 13 additions & 0 deletions tests/unit/test_api_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,19 @@ def test_reserve_request_rejects_oversized_label_value() -> None:
)


def test_reserve_request_rejects_empty_label_key() -> None:
# An empty-string label key is a degenerate grouping dimension; reject it on the command path
# to match the other bounds (#107).
with pytest.raises(ValidationError):
ReserveRequest(
provider="anthropic",
model="claude",
input_bound_tokens=1,
max_output_tokens=1,
labels={"": "prod"},
)


def test_reserve_request_rejects_empty_project_id() -> None:
with pytest.raises(ValidationError):
ReserveRequest(
Expand Down
35 changes: 33 additions & 2 deletions tests/unit/test_chargeback_route.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,16 @@
import pytest
from fastapi import HTTPException

from tollgate.api.routes.chargeback import _node_response, _parse_scope, _spend_response
from tollgate.api.routes.chargeback import (
_node_response,
_parse_group_by_query,
_parse_scope,
_spend_response,
)
from tollgate.api.schemas import MAX_LABEL_KEY_LEN, MAX_STR_LEN
from tollgate.app import build_app
from tollgate.config.settings import Settings
from tollgate.domain.chargeback import BudgetState, SpendGroup, SpendRollup
from tollgate.domain.chargeback import BudgetState, GroupBy, GroupByKind, SpendGroup, SpendRollup
from tollgate.domain.ids import BudgetId
from tollgate.domain.invariants import Balance
from tollgate.domain.scopes import ScopeKind, ScopeRef
Expand All @@ -30,6 +36,31 @@ def test_parse_scope_rejects_malformed_values_as_422(bad: str) -> None:
assert excinfo.value.status_code == 422


def test_parse_scope_rejects_an_oversized_scope_id_as_422() -> None:
# scope_id is otherwise unbounded on the read path; cap it like the command-path bounds (#107).
with pytest.raises(HTTPException) as excinfo:
_parse_scope("team:" + "x" * (MAX_STR_LEN + 1))
assert excinfo.value.status_code == 422


def test_parse_group_by_query_reads_the_dimensions() -> None:
assert _parse_group_by_query("provider") == GroupBy(GroupByKind.PROVIDER)
assert _parse_group_by_query("label:team") == GroupBy(GroupByKind.LABEL, "team")


@pytest.mark.parametrize("bad", ["", "unknown", "label:"])
def test_parse_group_by_query_rejects_malformed_values_as_422(bad: str) -> None:
with pytest.raises(HTTPException) as excinfo:
_parse_group_by_query(bad)
assert excinfo.value.status_code == 422


def test_parse_group_by_query_rejects_an_oversized_label_key_as_422() -> None:
with pytest.raises(HTTPException) as excinfo:
_parse_group_by_query("label:" + "k" * (MAX_LABEL_KEY_LEN + 1))
assert excinfo.value.status_code == 422


def test_node_response_maps_amounts_utilization_and_alert_flags() -> None:
state = BudgetState(
budget_id=BudgetId("b-user"),
Expand Down