fischer-agentkit/tests/integration/admin/test_quota_enforcement.py

426 lines
16 KiB
Python

"""Quota enforcement integration tests (U10).
Verifies quota enforcement end-to-end through the LLMGateway:
- Token limit exceeded → QuotaExceededError raised.
- Cost limit exceeded → QuotaExceededError raised.
- Model not in whitelist → QuotaExceededError raised.
- No quota set → request allowed.
- Multi-department: strictest-wins (one exceeds, other doesn't → rejected).
- Integration test with real LLMGateway + mock provider + InMemoryUsageStore.
These tests use a real :class:`LLMGateway` with a :class:`FakeProvider`
(mock LLM provider) and a real :class:`QuotaService` backed by a temp
SQLite auth DB. No external services (Redis, real LLM API) are required.
"""
from __future__ import annotations
import uuid
from pathlib import Path
import pytest
from agentkit.llm.gateway import LLMGateway, QuotaExceededError
from agentkit.llm.protocol import (
LLMProvider,
LLMRequest,
LLMResponse,
TokenUsage,
)
from agentkit.llm.providers.usage_store import InMemoryUsageStore
from agentkit.server.admin.quota_service import (
get_quota_service,
set_quota_service,
)
from agentkit.server.auth.models import init_auth_db
# ---------------------------------------------------------------------------
# Test doubles
# ---------------------------------------------------------------------------
class FakeProvider(LLMProvider):
"""A minimal LLMProvider that returns a fixed response.
The response usage (prompt_tokens, completion_tokens) can be
customized per-instance to simulate different token consumption.
"""
def __init__(
self,
name: str = "fake",
prompt_tokens: int = 100,
completion_tokens: int = 50,
) -> None:
self._name = name
self._prompt_tokens = prompt_tokens
self._completion_tokens = completion_tokens
self.last_request: LLMRequest | None = None
self.call_count = 0
async def chat(self, request: LLMRequest) -> LLMResponse:
self.last_request = request
self.call_count += 1
return LLMResponse(
content=f"response from {self._name}",
model=request.model,
usage=TokenUsage(
prompt_tokens=self._prompt_tokens,
completion_tokens=self._completion_tokens,
),
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def store() -> InMemoryUsageStore:
return InMemoryUsageStore()
@pytest.fixture
def gateway(store: InMemoryUsageStore) -> LLMGateway:
"""A real LLMGateway with a FakeProvider registered as "openai"."""
gw = LLMGateway(usage_store=store)
gw.register_provider("openai", FakeProvider("openai"))
return gw
@pytest.fixture
async def fresh_db(tmp_path: Path) -> Path:
"""A brand-new auth DB on a fresh path (no data)."""
db_path = tmp_path / "quota_enforcement.db"
await init_auth_db(db_path)
return db_path
@pytest.fixture(autouse=True)
def _reset_quota_singleton():
"""Reset the QuotaService singleton before and after each test."""
set_quota_service(None)
yield
set_quota_service(None)
def _random_dept_id() -> str:
return str(uuid.uuid4())
# ---------------------------------------------------------------------------
# Quota enforcement tests
# ---------------------------------------------------------------------------
class TestQuotaEnforcement:
"""Tests for quota enforcement in LLM calls."""
async def test_token_limit_blocks_request(
self, gateway: LLMGateway, store: InMemoryUsageStore, fresh_db: Path
):
"""When department exceeds token limit, LLM call raises QuotaExceededError."""
dept_id = _random_dept_id()
svc = get_quota_service()
# Set a 100-token daily limit.
await svc.set_quota(fresh_db, dept_id, "token_limit", 100, period="daily")
# Pre-populate usage with 90 tokens (just under the limit).
gateway._usage_tracker.record(
agent_name="prev",
model="openai/gpt-4o",
usage=TokenUsage(prompt_tokens=60, completion_tokens=30),
cost=0.0,
latency_ms=10,
user_id="u1",
department_id=dept_id,
)
# The FakeProvider would use 100+50=150 tokens, but the quota
# check happens BEFORE the provider call. Since current usage
# (90) + nothing is checked — the gateway checks current_usage
# >= limit, which is 90 < 100, so this would actually pass.
#
# To force a block, we pre-populate usage AT the limit (100
# tokens). The check is `current_usage >= limit`, so 100 >= 100
# → blocked.
store._records.clear()
gateway._usage_tracker.record(
agent_name="prev",
model="openai/gpt-4o",
usage=TokenUsage(prompt_tokens=70, completion_tokens=30),
cost=0.0,
latency_ms=10,
user_id="u1",
department_id=dept_id,
)
with pytest.raises(QuotaExceededError) as exc_info:
await gateway.chat(
messages=[{"role": "user", "content": "hi"}],
model="openai/gpt-4o",
user_id="u1",
department_ids=[dept_id],
db_path=fresh_db,
)
err = exc_info.value
assert err.department_id == dept_id
assert err.quota_type == "token_limit"
assert err.period == "daily"
assert err.limit == 100
assert err.current == 100 # 70 prompt + 30 completion
async def test_cost_limit_blocks_request(
self, gateway: LLMGateway, store: InMemoryUsageStore, fresh_db: Path
):
"""When department exceeds cost limit, LLM call raises QuotaExceededError."""
dept_id = _random_dept_id()
svc = get_quota_service()
# cost_limit is in cents. Set 100 cents ($1.00) daily limit.
await svc.set_quota(fresh_db, dept_id, "cost_limit", 100, period="daily")
# Pre-populate usage with $1.50 cost = 150 cents, exceeding the
# 100-cent limit.
gateway._usage_tracker.record(
agent_name="prev",
model="openai/gpt-4o",
usage=TokenUsage(prompt_tokens=100, completion_tokens=50),
cost=1.50, # $1.50 = 150 cents
latency_ms=10,
user_id="u1",
department_id=dept_id,
)
with pytest.raises(QuotaExceededError) as exc_info:
await gateway.chat(
messages=[{"role": "user", "content": "hi"}],
model="openai/gpt-4o",
user_id="u1",
department_ids=[dept_id],
db_path=fresh_db,
)
err = exc_info.value
assert err.quota_type == "cost_limit"
assert err.period == "daily"
assert err.limit == 100
# current is in cents (150 cents = $1.50).
assert err.current == 150.0
async def test_model_whitelist_blocks_unlisted_model(self, gateway: LLMGateway, fresh_db: Path):
"""When model not in whitelist, LLM call is rejected."""
dept_id = _random_dept_id()
svc = get_quota_service()
# Whitelist only allows "claude" — gateway is calling "gpt-4o".
await svc.set_quota(fresh_db, dept_id, "model_whitelist", ["claude"], period="daily")
with pytest.raises(QuotaExceededError) as exc_info:
await gateway.chat(
messages=[{"role": "user", "content": "hi"}],
model="openai/gpt-4o",
user_id="u1",
department_ids=[dept_id],
db_path=fresh_db,
)
err = exc_info.value
assert err.quota_type == "model_whitelist"
assert err.department_id == dept_id
# For model_whitelist, current is the rejected model name.
assert err.current == "openai/gpt-4o"
async def test_no_quota_allows_all(self, gateway: LLMGateway, fresh_db: Path):
"""Without any quota set, all requests are allowed."""
dept_id = _random_dept_id()
# No quota set — request should succeed.
response = await gateway.chat(
messages=[{"role": "user", "content": "hi"}],
model="openai/gpt-4o",
user_id="u1",
department_ids=[dept_id],
db_path=fresh_db,
)
assert response.content == "response from openai"
assert response.usage.total_tokens == 150 # 100 prompt + 50 completion
async def test_multi_department_strictest_wins(
self, gateway: LLMGateway, store: InMemoryUsageStore, fresh_db: Path
):
"""User in depts A+B: A has quota, B doesn't → A's quota applies.
Strictest-wins: if ANY department fails ANY check, the request
is rejected.
"""
dept_a = _random_dept_id()
dept_b = _random_dept_id()
svc = get_quota_service()
# Set a 1-token limit on dept A only; dept B has no quota.
await svc.set_quota(fresh_db, dept_a, "token_limit", 1, period="daily")
# Pre-populate usage for dept A so it exceeds the 1-token limit.
gateway._usage_tracker.record(
agent_name="prev",
model="openai/gpt-4o",
usage=TokenUsage(prompt_tokens=10, completion_tokens=5),
cost=0.0,
latency_ms=10,
user_id="u1",
department_id=dept_a,
)
with pytest.raises(QuotaExceededError) as exc_info:
await gateway.chat(
messages=[{"role": "user", "content": "hi"}],
model="openai/gpt-4o",
user_id="u1",
department_ids=[dept_a, dept_b],
db_path=fresh_db,
)
# The error should reference dept_a (the one that exceeded).
assert exc_info.value.department_id == dept_a
assert exc_info.value.quota_type == "token_limit"
async def test_quota_check_with_real_gateway(
self, gateway: LLMGateway, store: InMemoryUsageStore, fresh_db: Path
):
"""Integration test with real LLMGateway + mock provider.
Verifies the full flow:
1. Quota check happens before the provider call.
2. On success, usage is recorded with the correct department_id.
3. The usage record carries user_id + department_id.
"""
dept_id = _random_dept_id()
svc = get_quota_service()
# Set a generous token limit (1M tokens) — should not block.
await svc.set_quota(fresh_db, dept_id, "token_limit", 1_000_000, period="daily")
# Make the LLM call.
response = await gateway.chat(
messages=[{"role": "user", "content": "hi"}],
model="openai/gpt-4o",
user_id="u1",
department_ids=[dept_id],
db_path=fresh_db,
)
assert response.content == "response from openai"
# Verify usage was recorded with the correct attributes.
summary = store.get_usage()
assert len(summary.records) == 1
rec = summary.records[0]
assert rec.user_id == "u1"
assert rec.department_id == dept_id
assert rec.model == "gpt-4o"
assert rec.total_tokens == 150 # 100 prompt + 50 completion
# Verify the quota check counted this usage (next call should
# still pass since the limit is 1M tokens).
response2 = await gateway.chat(
messages=[{"role": "user", "content": "hi again"}],
model="openai/gpt-4o",
user_id="u1",
department_ids=[dept_id],
db_path=fresh_db,
)
assert response2.content == "response from openai"
# Now there should be 2 usage records.
summary = store.get_usage()
assert len(summary.records) == 2
# All records should carry the department_id.
assert all(r.department_id == dept_id for r in summary.records)
async def test_quota_check_skipped_without_db_path(self, gateway: LLMGateway, fresh_db: Path):
"""When db_path is None, no quota check is performed."""
dept_id = _random_dept_id()
svc = get_quota_service()
# Set a tiny quota that would normally block.
await svc.set_quota(fresh_db, dept_id, "token_limit", 1, period="daily")
# Call without db_path — should succeed (no quota check).
response = await gateway.chat(
messages=[{"role": "user", "content": "hi"}],
model="openai/gpt-4o",
user_id="u1",
department_ids=[dept_id],
db_path=None,
)
assert response.content == "response from openai"
async def test_quota_check_skipped_without_department_ids(
self, gateway: LLMGateway, fresh_db: Path
):
"""When department_ids is None, no quota check is performed."""
response = await gateway.chat(
messages=[{"role": "user", "content": "hi"}],
model="openai/gpt-4o",
user_id="u1",
department_ids=None,
db_path=fresh_db,
)
assert response.content == "response from openai"
async def test_model_whitelist_allows_listed_model(self, gateway: LLMGateway, fresh_db: Path):
"""Model in whitelist → request allowed."""
dept_id = _random_dept_id()
svc = get_quota_service()
# Whitelist uses the full resolved model identifier (provider/model).
await svc.set_quota(
fresh_db,
dept_id,
"model_whitelist",
["openai/gpt-4o"],
period="daily",
)
response = await gateway.chat(
messages=[{"role": "user", "content": "hi"}],
model="openai/gpt-4o",
user_id="u1",
department_ids=[dept_id],
db_path=fresh_db,
)
assert response.content == "response from openai"
async def test_quota_check_uses_correct_period_window(
self, gateway: LLMGateway, store: InMemoryUsageStore, fresh_db: Path
):
"""Quota check uses the daily window (since 00:00 UTC today).
The quota check happens BEFORE the LLM call, using the current
accumulated usage. So:
- 1st call: usage=0, check 0 >= 150 → False → allowed. After
the call, usage=150.
- 2nd call: usage=150, check 150 >= 150 → True → blocked.
"""
dept_id = _random_dept_id()
svc = get_quota_service()
# Set a 150-token daily limit (the FakeProvider uses 150 tokens
# per call: 100 prompt + 50 completion).
await svc.set_quota(fresh_db, dept_id, "token_limit", 150, period="daily")
# First call: current usage is 0, under the 150 limit → allowed.
response = await gateway.chat(
messages=[{"role": "user", "content": "hi"}],
model="openai/gpt-4o",
user_id="u1",
department_ids=[dept_id],
db_path=fresh_db,
)
assert response.content == "response from openai"
# Second call: current usage is now 150 (from the first call),
# which is >= the 150-token limit → blocked.
with pytest.raises(QuotaExceededError) as exc_info:
await gateway.chat(
messages=[{"role": "user", "content": "hi again"}],
model="openai/gpt-4o",
user_id="u1",
department_ids=[dept_id],
db_path=fresh_db,
)
assert exc_info.value.quota_type == "token_limit"
assert exc_info.value.current == 150 # accumulated from first call
assert exc_info.value.limit == 150