-
Notifications
You must be signed in to change notification settings - Fork 223
feat(budget): enforce ApiKey.budget_limit_cents on the chat path #73
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
base: main
Are you sure you want to change the base?
Changes from all commits
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 |
|---|---|---|
|
|
@@ -31,6 +31,7 @@ | |
| from app.protocols.sse import AdapterError | ||
| 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 | ||
|
|
@@ -306,6 +307,34 @@ async def execute_chat( | |
| 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 total spend (sum of `cost_microcents` for all non-deleted | ||
| # request-log rows, including streaming 499/503 rows that already | ||
| # incurred provider billing). Checked before any routing, resolution, | ||
| # or cache work so an exhausted key costs the operator nothing — no | ||
| # upstream attempt, no cache fill. | ||
| # | ||
| # NOTE: This is a best-effort soft limit, not a hard atomic cap. | ||
| # Spend is read before the request and the current request's cost is | ||
| # only written after the response/stream completes. N concurrent | ||
| # requests from the same budgeted key all observe the same | ||
| # pre-request total and may all pass the check, exceeding the cap by | ||
| # up to N× per-request cost in a burst. A hard cap would require a | ||
| # reservation/claim or row-level lock before dispatch; the current | ||
| # design trades strictness for simplicity and avoids holding a DB | ||
| # transaction across the upstream call. See spend.py for aggregation | ||
| # semantics. | ||
| 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 Serialize budget check with spend writeback (missing claim/CAS): concurrent requests bypass the cap The check is a read of committed spend at request start; the current request's cost is written only after the response/stream completes, in a different transaction, with no reservation, row lock, or compare-and-swap. N concurrent requests from the same budgeted key all read the same pre-request total and all pass the check, and because streaming rows land only after [DONE] is drained, the window is the whole stream duration. An attacker holding a leaked key — the exact threat model this feature exists for — can keep many requests in flight at once and exceed budget_limit_cents by an unbounded factor while the recorded sum stays near-zero until each stream finishes; "an exhausted key costs the operator nothing" (comment at line 313) is false under concurrency. The NOTE at line 319 documents this as a deliberate soft limit, but the consequence is the accounted quantity the feature exists to bound being exceeded, and no test exercises the concurrent path. Concrete fix: hold a per-key lock for the whole check→dispatch→writeback sequence (e.g. |
||
|
|
||
| client = await router_cache.get_router(db) | ||
| 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,48 @@ | ||
| """Per-key spend lookup used to enforce `ApiKey.budget_limit_cents`. | ||
|
|
||
| Semantics: `budget_limit_cents` is a lifetime cap on the key's total | ||
| spend — sum of `cost_microcents` for all non-deleted request-log rows. | ||
| 1 cent = 10,000 microcents (1 USD = 1,000,000 microcents, matching | ||
| chat.py's cost math). | ||
|
|
||
| Rows are counted regardless of HTTP status because the streaming path | ||
| records billable token usage even when the final status is 499 (client | ||
| disconnect, chat.py:596) or 503 (mid-stream upstream failure, | ||
| chat.py:646); filtering on `status_code < 400` would exclude those and | ||
| make the cap bypassable by closing the stream early after reading the | ||
| usage chunk. | ||
|
|
||
| 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 all non-deleted spend ever recorded for this key. | ||
|
|
||
| Counts every row with `cost_microcents` regardless of `status_code` | ||
| so that streaming disconnect (499) and mid-stream upstream failure | ||
| (503) costs — which already incurred provider billing — are not | ||
| excluded from the budget. Failed requests with zero cost contribute | ||
| nothing to the sum regardless. | ||
| """ | ||
| stmt = select(func.coalesce(func.sum(RequestLog.cost_microcents), 0)).where( | ||
| RequestLog.api_key_id == api_key_id, | ||
| RequestLog.is_deleted == 0, | ||
| ) | ||
|
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 error-path spend toward the budget cap get_lifetime_spend_microcents sums only rows with status_code < 400, but chat.py records real, non-zero cost_microcents on rows with status_code >= 400: the blocking path writes status_code = exc.http_status (e.g. 429/503) with whatever usage the upstream returned before failing (app/routes/chat.py finally-block after the UpstreamProviderError/generic except), and the streaming path writes 499 (client disconnect after upstream already billed tokens) and 503 (mid-stream error with partial agg_usage). A leaked/budgeted key — the exact threat the commit says the budget protects against ("a leaked key meant unbounded spend"; "an exhausted key costs the operator nothing") — can repeatedly drive requests that fail upstream or disconnect mid-stream; the provider bills each one, but none ever counts toward budget_limit_cents, so the stated lifetime cap is not actually a cap on money spent. The filter matches analytics.py's status<400 spend definition, but analytics is a reporting view; here it gates the money limit, and the exclusion is a partial-failure path that tests (all mocking successful 200 responses) never exercise. |
||
| return int((await session.execute(stmt)).scalar_one()) | ||
|
|
||
|
|
||
| def budget_exceeded(spend_microcents: int, budget_limit_cents: int) -> bool: | ||
| return spend_microcents >= budget_limit_cents * MICROCENTS_PER_CENT | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,208 @@ | ||
| """Budget enforcement on /v1/chat/completions. | ||
|
|
||
| `budget_limit_cents` was loaded into KeyContext but never enforced anywhere — | ||
| a leaked key meant unbounded spend. These tests pin the new behavior: an | ||
| exhausted key gets 429 before any routing / cache / upstream work and | ||
| unbudgeted keys are unaffected. Provisioning of budgeted/allowlisted keys | ||
| is covered in the keys-authz PR. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import time | ||
| from unittest.mock import AsyncMock | ||
|
|
||
| import pytest | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| async def budget_env(tmp_sqlite_url, monkeypatch): | ||
| """Full app + seeded root key, with the router client mocked out. | ||
|
|
||
| Yields (make_client, fake_client, session_factory, root_key). | ||
| """ | ||
| monkeypatch.setenv("DATABASE_URL", tmp_sqlite_url) | ||
| monkeypatch.setenv("OPENAI_API_KEY", "sk-test-openai") | ||
|
|
||
| from app import config as cfg | ||
| cfg.get_settings.cache_clear() | ||
|
|
||
| from packages.db.engine import build_engine | ||
| from packages.db.models.base import Base | ||
|
|
||
| engine = build_engine(tmp_sqlite_url) | ||
| async with engine.begin() as conn: | ||
| await conn.run_sync(Base.metadata.create_all) | ||
|
|
||
| from sqlalchemy.ext.asyncio import async_sessionmaker | ||
|
|
||
| from packages.db import session as session_mod | ||
| factory = async_sessionmaker(engine, expire_on_commit=False) | ||
| session_mod._session_factory = factory | ||
|
|
||
| from app.seed import seed_initial_state | ||
| async with factory() as s: | ||
| seed = await seed_initial_state(s) | ||
|
|
||
| fake_client = AsyncMock() | ||
| fake_client.acompletion = AsyncMock( | ||
| return_value={ | ||
| "id": "chatcmpl-budget-test", | ||
| "model": "gpt-4o-mini", | ||
| "object": "chat.completion", | ||
| "created": int(time.time()), | ||
| "choices": [{ | ||
| "index": 0, | ||
| "message": {"role": "assistant", "content": "Hello!"}, | ||
| "finish_reason": "stop", | ||
| }], | ||
| "usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7}, | ||
| "_orca_meta": { | ||
| "provider": "openai", | ||
| "litellm_model": "openai/gpt-4o-mini", | ||
| "latency_ms": 42, | ||
| }, | ||
| } | ||
| ) | ||
|
|
||
| from app import router_cache | ||
| router_cache.invalidate_router() | ||
|
|
||
| async def _fake_get_router(_session): | ||
| return fake_client | ||
|
|
||
| monkeypatch.setattr(router_cache, "get_router", _fake_get_router) | ||
|
|
||
| from httpx import ASGITransport, AsyncClient | ||
|
|
||
| from app.main import create_app | ||
| app = create_app() | ||
|
|
||
| async def make_client(api_key: str): | ||
| return AsyncClient( | ||
| transport=ASGITransport(app=app), | ||
| base_url="http://t", | ||
| headers={"Authorization": f"Bearer {api_key}"}, | ||
| ) | ||
|
|
||
| yield make_client, fake_client, factory, seed.api_key | ||
|
|
||
| await engine.dispose() | ||
| session_mod._session_factory = None | ||
|
|
||
|
|
||
| async def _make_budgeted_key( | ||
| factory, *, budget_limit_cents: int | None | ||
| ) -> tuple[str, str]: | ||
| """Insert a budgeted child key; return (plaintext_key, key_id).""" | ||
| from packages.auth.hashing import generate_api_key | ||
| from packages.db.models.api_key import ApiKey | ||
|
|
||
| full_key, key_hash, key_prefix = generate_api_key() | ||
| async with factory() as s: | ||
| row = ApiKey( | ||
| workspace_id="default", | ||
| name="budgeted", | ||
| key_hash=key_hash, | ||
| key_prefix=key_prefix, | ||
| budget_limit_cents=budget_limit_cents, | ||
| ) | ||
| s.add(row) | ||
| await s.commit() | ||
| await s.refresh(row) | ||
| return full_key, row.id | ||
|
|
||
|
|
||
| async def _add_billable_spend(factory, key_id: str, microcents: int) -> None: | ||
| from packages.db.models.request_log import RequestLog | ||
|
|
||
| async with factory() as s: | ||
| s.add(RequestLog( | ||
| workspace_id="default", | ||
| api_key_id=key_id, | ||
| trace_id="budget-test-trace", | ||
| model_requested="gpt-4o-mini", | ||
| model_resolved="gpt-4o-mini", | ||
| provider="openai", | ||
| routing_strategy="balanced", | ||
| input_tokens=5, | ||
| output_tokens=2, | ||
| cost_microcents=microcents, | ||
| latency_ms=10, | ||
| status_code=200, | ||
| )) | ||
| await s.commit() | ||
|
|
||
|
|
||
| async def test_exhausted_budget_returns_429_without_upstream_call(budget_env): | ||
| make_client, fake, factory, _root = budget_env | ||
| key, key_id = await _make_budgeted_key(factory, budget_limit_cents=1) | ||
| # Pre-load spend past the 1-cent cap (10_000 microcents). | ||
| await _add_billable_spend(factory, key_id, microcents=20_000) | ||
|
|
||
| async with await make_client(key) as c: | ||
| r = await c.post( | ||
| "/v1/chat/completions", | ||
| json={"model": "gpt-4o-mini", | ||
| "messages": [{"role": "user", "content": "hi"}]}, | ||
| ) | ||
|
|
||
| assert r.status_code == 429, r.text | ||
| assert r.json()["error"]["type"] == "rate_limit_error" | ||
| fake.acompletion.assert_not_awaited() | ||
|
|
||
|
|
||
| async def test_blocked_request_writes_no_log_row(budget_env): | ||
| make_client, _fake, factory, _root = budget_env | ||
| key, key_id = await _make_budgeted_key(factory, budget_limit_cents=1) | ||
| await _add_billable_spend(factory, key_id, microcents=99_999) | ||
|
|
||
| async with await make_client(key) as c: | ||
| await c.post( | ||
| "/v1/chat/completions", | ||
| json={"model": "gpt-4o-mini", | ||
| "messages": [{"role": "user", "content": "hi"}]}, | ||
| ) | ||
|
|
||
| from sqlalchemy import func, select | ||
|
|
||
| from packages.db.models.request_log import RequestLog | ||
|
|
||
| async with factory() as s: | ||
| count = ( | ||
| await s.execute( | ||
| select(func.count()).select_from(RequestLog).where( | ||
| RequestLog.api_key_id == key_id | ||
| ) | ||
| ) | ||
| ).scalar_one() | ||
| assert count == 1 # only the pre-loaded history row | ||
|
|
||
|
|
||
| async def test_under_budget_key_serves_normally(budget_env): | ||
| make_client, fake, factory, _root = budget_env | ||
| key, _key_id = await _make_budgeted_key(factory, budget_limit_cents=100) | ||
|
|
||
| async with await make_client(key) as c: | ||
| r = await c.post( | ||
| "/v1/chat/completions", | ||
| json={"model": "gpt-4o-mini", | ||
| "messages": [{"role": "user", "content": "hi"}]}, | ||
| ) | ||
|
|
||
| assert r.status_code == 200, r.text | ||
| fake.acompletion.assert_awaited_once() | ||
|
|
||
|
|
||
| async def test_unbudgeted_root_key_unaffected(budget_env): | ||
| make_client, fake, _factory, root = budget_env | ||
|
|
||
| async with await make_client(root) as c: | ||
| r = await c.post( | ||
| "/v1/chat/completions", | ||
| json={"model": "gpt-4o-mini", | ||
| "messages": [{"role": "user", "content": "hi"}]}, | ||
| ) | ||
|
|
||
| assert r.status_code == 200, r.text | ||
| fake.acompletion.assert_awaited_once() |
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.
🟠 P1 Serialize the budget check against the spend record, or concurrent requests blow through the lifetime cap
The new enforcement is a check-then-act with no serialization.
get_lifetime_spend_microcents(line 276) reads the sum of committed RequestLog rows for the key, and the row that records the current request's cost is only committed at the very end of the handler: blocking path line 735await db.commit()(after the upstreamacompletioncall, which takes seconds), streaming path lines 556-562 in_finalize(after the whole stream, and in a different session from the one the check ran in). There is no row lock, no per-key asyncio lock (the only lock in the codebase is router_cache's_cache_lock), and no atomic reservation. So N concurrent requests for the same key that arrive while spend is below the cap all read the same under-cap sum, all pass, and all commit their cost — the documented "lifetime cap" is exceeded by the sum of every concurrently in-flight request's cost (each upstream call is a multi-second window; a long stream keeps its spend invisible for its whole duration, widening the window further). The unit/integration tests only ever exercise sequential requests, so this never shows up. Consequence: the operator's money limit on the key is not actually a limit — they are billed beyond the cap they configured. Fix: make the check and the spend record atomic and serialized per key — e.g. takeSELECT ... FOR UPDATEon the api_keys row (or a per-key spend ledger) at check time and hold it until the request-log row is committed (for the streaming path that means writing the log row in the same transaction/session that holds the lock), so a second concurrent request sees the first request's spend before deciding to proceed.