426 lines
16 KiB
Python
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
|