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
1 change: 0 additions & 1 deletion src/tollgate/adapters/integrations/sdk/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ class InternalError(TollgateApiError):
"reservation_not_held": ReservationNotHeld,
"unknown_model": UnknownModel,
"amount_out_of_range": InvalidRequest,
"non_positive_estimate": InvalidRequest,
"enforcement_unavailable": EnforcementUnavailable,
"internal_error": InternalError,
"conflicting_budget_scope": InternalError,
Expand Down
18 changes: 12 additions & 6 deletions src/tollgate/adapters/postgres/price_book_repo.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"""PostgresPriceBookRepository: resolve the current price for a (provider, model) (§3, ADR 0028).

The current price-book version is the one with the latest ``published_at`` (ADR 0028). A single
join from ``price`` to ``price_book``, filtered to the pair and ordered by ``published_at``
descending, takes that version and its rates — or returns ``None`` when the pair is unpriced, which
the reserve turns into an ``UnknownModel`` denial. Explicit async SQLAlchemy Core, no ORM; like the
other repositories it never imports ``application`` and satisfies the port structurally.
The current price-book version is the one with the latest ``published_at`` (ADR 0028), with
``version`` as a deterministic tiebreak when several versions share a ``published_at`` (#98). A
single join from ``price`` to ``price_book``, filtered to the pair and ordered by that total
order descending, takes the current version and its rates — or returns ``None`` when the pair is
unpriced, which the reserve turns into an ``UnknownModel`` denial. Explicit async SQLAlchemy Core,
no ORM; like the other repositories it never imports ``application`` and satisfies the port
structurally.
"""

from __future__ import annotations
Expand Down Expand Up @@ -39,7 +41,11 @@ async def resolve_price(self, provider: str, model: str) -> PricedModel | None:
price.join(price_book, price.c.price_book_version == price_book.c.version)
)
.where(price.c.provider == provider, price.c.model == model)
.order_by(price_book.c.published_at.desc())
# version is a deterministic tiebreak: published_at defaults to the transaction
# timestamp, so a bulk seed publishes several versions at the same instant and
# published_at alone leaves an arbitrary, run-varying pick — but the resolved
# version is stamped on the reservation and drives commit reconciliation (#98).
.order_by(price_book.c.published_at.desc(), price_book.c.version.desc())
.limit(1)
)
).first()
Expand Down
2 changes: 0 additions & 2 deletions src/tollgate/api/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
EnforcementUnavailable,
IdempotencyKeyReuse,
InsufficientBudget,
NonPositiveEstimate,
ReservationNotHeld,
ScopeNotAuthorized,
TollgateError,
Expand Down Expand Up @@ -65,7 +64,6 @@
ReservationNotHeld: (409, "reservation_not_held", "reservation is not held"),
UnknownModel: (422, "unknown_model", "unknown (provider, model) pair"),
AmountOutOfRange: (422, "amount_out_of_range", "amount out of representable range"),
NonPositiveEstimate: (422, "non_positive_estimate", "reserve estimate must be positive"),
BalanceGuardViolation: (500, "balance_guard_violation", "balance guard matched no row"),
ConflictingBudgetScope: (
500,
Expand Down
9 changes: 4 additions & 5 deletions src/tollgate/application/handlers/reserve.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
from tollgate.domain.errors import (
IdempotencyKeyReuse,
InsufficientBudget,
NonPositiveEstimate,
TollgateError,
UnknownModel,
)
Expand Down Expand Up @@ -137,15 +136,15 @@ async def reserve(self, auth: AuthContext, command: ReserveCommand) -> ReserveRe

nodes = await resolve_applicable_nodes(tx.budgets, auth, command.project_id)

# A zero estimate (a genuinely free, zero-priced model, or a zero-token request) is
# admitted with a zero-micro hold rather than rejected, so free and paid models transit
# the same guard; the reserve walks every node at amount 0 and commit reconciles to 0
# (#104). ``estimate_micro`` never returns negative, so estimate >= 0 always holds.
estimate = estimate_micro(
priced.price,
input_bound_tokens=command.input_bound_tokens,
max_output_tokens=command.max_output_tokens,
)
if estimate == 0:
raise NonPositiveEstimate(
"worst-case estimate is zero; a reserve must gate a positive amount"
)
now = self._clock.now()
period_start = calendar_month_start(now)
ttl_deadline = now + timedelta(seconds=self._ttl_seconds)
Expand Down
10 changes: 0 additions & 10 deletions src/tollgate/domain/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,6 @@ class BudgetNotFound(TollgateError):
"""No budget governs the requested scope."""


class NonPositiveEstimate(TollgateError):
"""A reserve's worst-case estimate is zero, so it would gate nothing.

A budget is only real if a reserve can stop the request that would breach it; an
estimate of zero consumes no headroom and admits a call whose entire cost then books
as unguarded overage. Such a reserve is denied so callers cannot under-declare their
bounds past the gate (§4, ADR 0003 caller-declared-bounds trust model).
"""


class BalanceGuardViolation(TollgateError):
"""A guarded balance mutation matched a number of rows other than one.

Expand Down
30 changes: 30 additions & 0 deletions tests/integration/test_price_book_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,36 @@ async def test_resolve_price_takes_the_latest_published_version(db_conn: AsyncCo
assert priced.price.cached_input_micro_per_token == Decimal("1.5")


async def test_resolve_price_breaks_a_published_at_tie_deterministically(
db_conn: AsyncConnection,
) -> None:
# published_at defaults to the transaction timestamp, so a bulk seed publishes several versions
# with the SAME published_at. Ordering by published_at alone then leaves an arbitrary pick that
# can differ run to run; version is the deterministic tiebreak so "latest published" is a total
# order (#98). The stamped version drives commit reconciliation, so it must not be ambiguous.
same_instant = datetime(2026, 6, 1, tzinfo=UTC)
await _publish(
db_conn,
version="v-a",
published_at=same_instant,
input_rate="1",
output_rate="2",
cached_rate="0.5",
)
await _publish(
db_conn,
version="v-b",
published_at=same_instant,
input_rate="3",
output_rate="4",
cached_rate="1.5",
)
priced = await PostgresPriceBookRepository(db_conn).resolve_price("anthropic", "claude")
assert priced is not None
assert priced.version == "v-b" # highest version wins the tie, deterministically
assert priced.price.input_micro_per_token == Decimal("3")


async def test_resolve_price_returns_none_for_an_unpriced_pair(db_conn: AsyncConnection) -> None:
await _publish(
db_conn,
Expand Down
2 changes: 0 additions & 2 deletions tests/unit/test_api_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
EnforcementUnavailable,
IdempotencyKeyReuse,
InsufficientBudget,
NonPositiveEstimate,
ReservationNotHeld,
ScopeNotAuthorized,
TollgateError,
Expand Down Expand Up @@ -46,7 +45,6 @@ async def boom() -> None:
(ReservationNotHeld(), 409, "reservation_not_held"),
(UnknownModel("anthropic", "unpriced"), 422, "unknown_model"),
(AmountOutOfRange("amount out of range"), 422, "amount_out_of_range"),
(NonPositiveEstimate(), 422, "non_positive_estimate"),
(BalanceGuardViolation(), 500, "balance_guard_violation"),
(ConflictingBudgetScope("user", "u1"), 500, "conflicting_budget_scope"),
(EnforcementUnavailable(), 503, "enforcement_unavailable"),
Expand Down
2 changes: 0 additions & 2 deletions tests/unit/test_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
EnforcementUnavailable,
IdempotencyKeyReuse,
InsufficientBudget,
NonPositiveEstimate,
ReservationNotHeld,
ScopeNotAuthorized,
TollgateError,
Expand All @@ -31,7 +30,6 @@ def test_every_error_descends_from_base() -> None:
AuthenticationFailed,
ScopeNotAuthorized,
AmountOutOfRange,
NonPositiveEstimate,
BalanceGuardViolation,
):
assert issubclass(exc, TollgateError)
Expand Down
24 changes: 14 additions & 10 deletions tests/unit/test_reserve.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
BudgetNotFound,
IdempotencyKeyReuse,
InsufficientBudget,
NonPositiveEstimate,
ScopeNotAuthorized,
UnknownModel,
)
Expand Down Expand Up @@ -393,16 +392,21 @@ async def test_reserve_denies_insufficient_budget_naming_the_binding_node() -> N
assert uow.rolled_back is True


async def test_reserve_denies_a_zero_worst_case_estimate() -> None:
# A reserve whose worst-case estimate is zero gates nothing; deny it before touching
# any balance, and roll back so no idempotency key is cached (#65).
async def test_reserve_admits_a_zero_estimate_with_a_zero_hold() -> None:
# A genuinely free (zero-priced) model or a zero-token request estimates to 0. Rather than 4xx,
# admit it with a zero-micro hold so free and paid models route through the same guard; the
# reserve walks every node at amount 0 and commit later reconciles to 0 (#104).
handler, uow = _build()
with pytest.raises(NonPositiveEstimate):
await handler.reserve(_auth(), _command(input_bound_tokens=0, max_output_tokens=0))
assert uow._ctx.reserve_tx.calls == [] # never reached the balance guard
assert uow._ctx.reservations.inserted is None
assert uow._ctx.idempotency.stored == []
assert uow.rolled_back is True
result = await handler.reserve(_auth(), _command(input_bound_tokens=0, max_output_tokens=0))
assert result.estimated_micro == 0
ctx = uow._ctx
assert ctx.reserve_tx.calls == [([_ORG_NODE, _USER_NODE], _PERIOD, 0)] # zero-hold guard walk
assert ctx.reservations.inserted is not None
record, lines = ctx.reservations.inserted
assert record.estimated_micro == 0
assert all(line.amount_micro == 0 for line in lines)
assert ctx.idempotency.stored != [] # a success caches its response
assert uow.rolled_back is False


async def test_reserve_includes_an_authorized_project_budget() -> None:
Expand Down
Loading