Repository navigation
fix(auth): fail closed on the public dev encryption key, add versioned ciphertext #77
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f6ca9b1
76f098f
9240492
6096169
e547d71
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -30,6 +30,7 @@ | |
| from app.deps import get_db, get_key_context | ||
| from app.quality_scores import resolve_model_metrics | ||
| from app.schemas import ChatCompletionRequest | ||
| from packages.auth.spend import budget_exceeded, get_lifetime_spend_microcents | ||
| from packages.auth.types import KeyContext | ||
| from packages.db.models.request_log import RequestLog | ||
| from packages.litellm_adapter.catalog import CATALOG, CATALOG_BY_ID | ||
|
|
@@ -267,6 +268,21 @@ async def chat_completions( | |
| detail=f"Model '{body.model}' is not allowed for this API key", | ||
| ) | ||
|
|
||
| # Budget enforcement: `budget_limit_cents` is a lifetime cap on this | ||
| # key's billable (status < 400) spend. Checked before any routing, | ||
| # resolution, or cache work so an exhausted key costs the operator | ||
| # nothing — no upstream attempt, no cache fill. | ||
| if kc.budget_limit_cents is not None: | ||
| spend = await get_lifetime_spend_microcents(db, str(kc.key_id)) | ||
| if budget_exceeded(spend, kc.budget_limit_cents): | ||
| raise HTTPException( | ||
| status_code=429, | ||
| detail=( | ||
| "API key budget exhausted " | ||
| f"({spend} of {kc.budget_limit_cents * 10_000} microcents spent)." | ||
| ), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 P1 Make the budget enforcement atomic so concurrent requests cannot all pass the cap The new budget gate is a check-then-act with no reservation: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 P1 Make the budget check atomic against concurrent requests so the cap cannot be overshot The new budget enforcement is a pure check-then-act on committed spend: |
||
| ) | ||
|
|
||
| client = await router_cache.get_router(db) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 P2 Make budget enforcement fail closed when request-log spend writes fail The new budget enforcement sums spend exclusively from |
||
| raw_strategy = getattr(client, "strategy", None) | ||
| strategy = raw_strategy if isinstance(raw_strategy, str) and raw_strategy else "balanced" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| """Per-key spend lookup used to enforce `ApiKey.budget_limit_cents`. | ||
|
|
||
| Semantics: `budget_limit_cents` is a lifetime cap on the key's billable | ||
| spend — request-log rows with `status_code < 400`. 1 cent = 10,000 | ||
| microcents (1 USD = 1,000,000 microcents, matching chat.py's cost math). | ||
|
|
||
| Kept free of FastAPI imports so it stays unit-testable and reusable from | ||
| non-HTTP contexts (background jobs, CLI minting tools). | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from sqlalchemy import func, select | ||
| from sqlalchemy.ext.asyncio import AsyncSession | ||
|
|
||
| from packages.db.models.request_log import RequestLog | ||
|
|
||
| MICROCENTS_PER_CENT = 10_000 | ||
|
|
||
|
|
||
| async def get_lifetime_spend_microcents( | ||
| session: AsyncSession, api_key_id: str | ||
| ) -> int: | ||
| """Sum of billable (status < 400) spend ever recorded for this key.""" | ||
| stmt = select(func.coalesce(func.sum(RequestLog.cost_microcents), 0)).where( | ||
| RequestLog.api_key_id == api_key_id, | ||
| RequestLog.is_deleted == 0, | ||
| RequestLog.status_code < 400, | ||
| ) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 P1 Count streamed-but-aborted spend toward the budget, or the cap is trivially bypassable The new budget enforcement counts only request-log rows with status_code < 400. The streaming paths that actually consume provider-billed tokens record status 499 (client disconnect, chat.py:596) and 503 (mid-stream upstream error, chat.py:646); both are >= 400, so their cost never accrues toward |
||
| return int((await session.execute(stmt)).scalar_one()) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 P2 Count client-disconnect (499) spend toward the budget; a stream abort currently escapes the cap while the operator still pays upstream
|
||
|
|
||
|
|
||
| def budget_exceeded(spend_microcents: int, budget_limit_cents: int) -> bool: | ||
| return spend_microcents >= budget_limit_cents * MICROCENTS_PER_CENT | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 P2 End the budget-check read transaction before the upstream call so it cannot block log commits on SQLite
For a budgeted key, the new
get_lifetime_spend_microcents(db, ...)is the first statement executed on the request-scoped sessiondb, and nothing commits or rolls it back before the handler returns. In the streaming path the request-log row is written by_finalizethrough a different session (session_mod._session_factory()), while the request session's transaction — and its SQLite SHARED lock, since the engine enables no WAL/busy-timeout settings — stays open across the entire upstream stream (thedbdependency is torn down only after the response body completes). A separate connection's COMMIT needs an EXCLUSIVE lock and will hit SQLITE_BUSY ("database is locked", default 5s busy timeout) for as long as any budgeted streaming request is in flight, so the stream's own log write (and concurrent requests' log writes) fail, are swallowed by theexcept Exception: logger.warning("request_log_commit_failed")handlers, and the spend/analytics row is silently dropped — the budget then undercounts that request's spend. Before this change the streaming path never executed anything ondb, so the request session never opened a transaction. Fix: end the read transaction immediately after the budget check (e.g.await db.rollback()), or run the spend lookup on a short-lived session, so the request session does not hold a read lock across the upstream call.