Skip to content

Commit d5f66ac

Browse files
authored
feat: cloud usage limits (#7192)
1 parent 241fc8f commit d5f66ac

File tree

25 files changed

+1538
-23
lines changed

25 files changed

+1538
-23
lines changed
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""usage_limits
2+
3+
Revision ID: 2b90f3af54b8
4+
Revises: 9a0296d7421e
5+
Create Date: 2026-01-03 16:55:30.449692
6+
7+
"""
8+
9+
from alembic import op
10+
import sqlalchemy as sa
11+
12+
13+
# revision identifiers, used by Alembic.
14+
revision = "2b90f3af54b8"
15+
down_revision = "9a0296d7421e"
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade() -> None:
21+
op.create_table(
22+
"tenant_usage",
23+
sa.Column("id", sa.Integer(), nullable=False),
24+
sa.Column(
25+
"window_start", sa.DateTime(timezone=True), nullable=False, index=True
26+
),
27+
sa.Column("llm_cost_cents", sa.Float(), nullable=False, server_default="0.0"),
28+
sa.Column("chunks_indexed", sa.Integer(), nullable=False, server_default="0"),
29+
sa.Column("api_calls", sa.Integer(), nullable=False, server_default="0"),
30+
sa.Column(
31+
"non_streaming_api_calls", sa.Integer(), nullable=False, server_default="0"
32+
),
33+
sa.Column(
34+
"updated_at",
35+
sa.DateTime(timezone=True),
36+
server_default=sa.func.now(),
37+
nullable=True,
38+
),
39+
sa.PrimaryKeyConstraint("id"),
40+
sa.UniqueConstraint("window_start", name="uq_tenant_usage_window"),
41+
)
42+
43+
44+
def downgrade() -> None:
45+
op.drop_index("ix_tenant_usage_window_start", table_name="tenant_usage")
46+
op.drop_table("tenant_usage")

backend/ee/onyx/configs/app_configs.py

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -111,17 +111,6 @@
111111
STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY")
112112
STRIPE_PRICE_ID = os.environ.get("STRIPE_PRICE")
113113

114-
OPENAI_DEFAULT_API_KEY = os.environ.get("OPENAI_DEFAULT_API_KEY")
115-
ANTHROPIC_DEFAULT_API_KEY = os.environ.get("ANTHROPIC_DEFAULT_API_KEY")
116-
COHERE_DEFAULT_API_KEY = os.environ.get("COHERE_DEFAULT_API_KEY")
117-
118-
# Vertex AI default credentials (JSON content or path to JSON file)
119-
VERTEXAI_DEFAULT_CREDENTIALS = os.environ.get("VERTEXAI_DEFAULT_CREDENTIALS")
120-
VERTEXAI_DEFAULT_LOCATION = os.environ.get("VERTEXAI_DEFAULT_LOCATION", "global")
121-
122-
# OpenRouter default API key
123-
OPENROUTER_DEFAULT_API_KEY = os.environ.get("OPENROUTER_DEFAULT_API_KEY")
124-
125114
# JWT Public Key URL
126115
JWT_PUBLIC_KEY_URL: str | None = os.getenv("JWT_PUBLIC_KEY_URL", None)
127116

backend/ee/onyx/server/tenants/provisioning.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,7 @@
99
from sqlalchemy import select
1010
from sqlalchemy.orm import Session
1111

12-
from ee.onyx.configs.app_configs import ANTHROPIC_DEFAULT_API_KEY
13-
from ee.onyx.configs.app_configs import COHERE_DEFAULT_API_KEY
1412
from ee.onyx.configs.app_configs import HUBSPOT_TRACKING_URL
15-
from ee.onyx.configs.app_configs import OPENAI_DEFAULT_API_KEY
16-
from ee.onyx.configs.app_configs import OPENROUTER_DEFAULT_API_KEY
17-
from ee.onyx.configs.app_configs import VERTEXAI_DEFAULT_CREDENTIALS
18-
from ee.onyx.configs.app_configs import VERTEXAI_DEFAULT_LOCATION
1913
from ee.onyx.server.tenants.access import generate_data_plane_token
2014
from ee.onyx.server.tenants.models import TenantByDomainResponse
2115
from ee.onyx.server.tenants.models import TenantCreationPayload
@@ -27,8 +21,14 @@
2721
from ee.onyx.server.tenants.user_mapping import get_tenant_id_for_email
2822
from ee.onyx.server.tenants.user_mapping import user_owns_a_tenant
2923
from onyx.auth.users import exceptions
24+
from onyx.configs.app_configs import ANTHROPIC_DEFAULT_API_KEY
25+
from onyx.configs.app_configs import COHERE_DEFAULT_API_KEY
3026
from onyx.configs.app_configs import CONTROL_PLANE_API_BASE_URL
3127
from onyx.configs.app_configs import DEV_MODE
28+
from onyx.configs.app_configs import OPENAI_DEFAULT_API_KEY
29+
from onyx.configs.app_configs import OPENROUTER_DEFAULT_API_KEY
30+
from onyx.configs.app_configs import VERTEXAI_DEFAULT_CREDENTIALS
31+
from onyx.configs.app_configs import VERTEXAI_DEFAULT_LOCATION
3232
from onyx.configs.constants import MilestoneRecordType
3333
from onyx.db.engine.sql_engine import get_session_with_shared_schema
3434
from onyx.db.engine.sql_engine import get_session_with_tenant
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""EE Usage limits - trial detection via billing information."""
2+
3+
from datetime import datetime
4+
from datetime import timezone
5+
6+
from ee.onyx.server.tenants.billing import fetch_billing_information
7+
from ee.onyx.server.tenants.models import BillingInformation
8+
from ee.onyx.server.tenants.models import SubscriptionStatusResponse
9+
from onyx.utils.logger import setup_logger
10+
from shared_configs.configs import MULTI_TENANT
11+
12+
logger = setup_logger()
13+
14+
15+
def is_tenant_on_trial(tenant_id: str) -> bool:
16+
"""
17+
Determine if a tenant is currently on a trial subscription.
18+
19+
In multi-tenant mode, we fetch billing information from the control plane
20+
to determine if the tenant has an active trial.
21+
"""
22+
if not MULTI_TENANT:
23+
return False
24+
25+
try:
26+
billing_info = fetch_billing_information(tenant_id)
27+
28+
# If not subscribed at all, check if we have trial information
29+
if isinstance(billing_info, SubscriptionStatusResponse):
30+
# No subscription means they're likely on trial (new tenant)
31+
return True
32+
33+
if isinstance(billing_info, BillingInformation):
34+
# Check if trial is active
35+
if billing_info.trial_end is not None:
36+
now = datetime.now(timezone.utc)
37+
# Trial active if trial_end is in the future
38+
# and subscription status indicates trialing
39+
if billing_info.trial_end > now and billing_info.status == "trialing":
40+
return True
41+
42+
return False
43+
44+
except Exception as e:
45+
logger.warning(f"Failed to fetch billing info for trial check: {e}")
46+
# Default to trial limits on error (more restrictive = safer)
47+
return True

backend/onyx/auth/captcha.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""Captcha verification for user registration."""
2+
3+
import httpx
4+
from pydantic import BaseModel
5+
from pydantic import Field
6+
7+
from onyx.configs.app_configs import CAPTCHA_ENABLED
8+
from onyx.configs.app_configs import RECAPTCHA_SCORE_THRESHOLD
9+
from onyx.configs.app_configs import RECAPTCHA_SECRET_KEY
10+
from onyx.utils.logger import setup_logger
11+
12+
logger = setup_logger()
13+
14+
RECAPTCHA_VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify"
15+
16+
17+
class CaptchaVerificationError(Exception):
18+
"""Raised when captcha verification fails."""
19+
20+
21+
class RecaptchaResponse(BaseModel):
22+
"""Response from Google reCAPTCHA verification API."""
23+
24+
success: bool
25+
score: float | None = None # Only present for reCAPTCHA v3
26+
action: str | None = None
27+
challenge_ts: str | None = None
28+
hostname: str | None = None
29+
error_codes: list[str] | None = Field(default=None, alias="error-codes")
30+
31+
32+
def is_captcha_enabled() -> bool:
33+
"""Check if captcha verification is enabled."""
34+
return CAPTCHA_ENABLED and bool(RECAPTCHA_SECRET_KEY)
35+
36+
37+
async def verify_captcha_token(
38+
token: str,
39+
expected_action: str = "signup",
40+
) -> None:
41+
"""
42+
Verify a reCAPTCHA token with Google's API.
43+
44+
Args:
45+
token: The reCAPTCHA response token from the client
46+
expected_action: Expected action name for v3 verification
47+
48+
Raises:
49+
CaptchaVerificationError: If verification fails
50+
"""
51+
if not is_captcha_enabled():
52+
return
53+
54+
if not token:
55+
raise CaptchaVerificationError("Captcha token is required")
56+
57+
try:
58+
async with httpx.AsyncClient() as client:
59+
response = await client.post(
60+
RECAPTCHA_VERIFY_URL,
61+
data={
62+
"secret": RECAPTCHA_SECRET_KEY,
63+
"response": token,
64+
},
65+
timeout=10.0,
66+
)
67+
response.raise_for_status()
68+
69+
data = response.json()
70+
result = RecaptchaResponse(**data)
71+
72+
if not result.success:
73+
error_codes = result.error_codes or ["unknown-error"]
74+
logger.warning(f"Captcha verification failed: {error_codes}")
75+
raise CaptchaVerificationError(
76+
f"Captcha verification failed: {', '.join(error_codes)}"
77+
)
78+
79+
# For reCAPTCHA v3, also check the score
80+
if result.score is not None:
81+
if result.score < RECAPTCHA_SCORE_THRESHOLD:
82+
logger.warning(
83+
f"Captcha score too low: {result.score} < {RECAPTCHA_SCORE_THRESHOLD}"
84+
)
85+
raise CaptchaVerificationError(
86+
"Captcha verification failed: suspicious activity detected"
87+
)
88+
89+
# Optionally verify the action matches
90+
if result.action and result.action != expected_action:
91+
logger.warning(
92+
f"Captcha action mismatch: {result.action} != {expected_action}"
93+
)
94+
raise CaptchaVerificationError(
95+
"Captcha verification failed: action mismatch"
96+
)
97+
98+
logger.debug(
99+
f"Captcha verification passed: score={result.score}, "
100+
f"action={result.action}"
101+
)
102+
103+
except httpx.HTTPError as e:
104+
logger.error(f"Captcha API request failed: {e}")
105+
# In case of API errors, we might want to allow registration
106+
# to prevent blocking legitimate users. This is a policy decision.
107+
raise CaptchaVerificationError("Captcha verification service unavailable")

backend/onyx/auth/schemas.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ class UserRead(schemas.BaseUser[uuid.UUID]):
4040
class UserCreate(schemas.BaseUserCreate):
4141
role: UserRole = UserRole.BASIC
4242
tenant_id: str | None = None
43+
# Captcha token for cloud signup protection (optional, only used when captcha is enabled)
44+
captcha_token: str | None = None
4345

4446

4547
class UserUpdateWithRole(schemas.BaseUserUpdate):

backend/onyx/auth/users.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,31 @@ async def create(
292292
safe: bool = False,
293293
request: Optional[Request] = None,
294294
) -> User:
295+
# Verify captcha if enabled (for cloud signup protection)
296+
from onyx.auth.captcha import CaptchaVerificationError
297+
from onyx.auth.captcha import is_captcha_enabled
298+
from onyx.auth.captcha import verify_captcha_token
299+
300+
if is_captcha_enabled() and request is not None:
301+
# Get captcha token from request body or headers
302+
captcha_token = None
303+
if hasattr(user_create, "captcha_token"):
304+
captcha_token = getattr(user_create, "captcha_token", None)
305+
306+
# Also check headers as a fallback
307+
if not captcha_token:
308+
captcha_token = request.headers.get("X-Captcha-Token")
309+
310+
try:
311+
await verify_captcha_token(
312+
captcha_token or "", expected_action="signup"
313+
)
314+
except CaptchaVerificationError as e:
315+
raise HTTPException(
316+
status_code=status.HTTP_400_BAD_REQUEST,
317+
detail={"reason": str(e)},
318+
)
319+
295320
# We verify the password here to make sure it's valid before we proceed
296321
await self.validate_password(
297322
user_create.password, cast(schemas.UC, user_create)

backend/onyx/background/celery/tasks/docprocessing/tasks.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
from onyx.db.search_settings import get_current_search_settings
8787
from onyx.db.search_settings import get_secondary_search_settings
8888
from onyx.db.swap_index import check_and_perform_index_swap
89+
from onyx.db.usage import UsageLimitExceededError
8990
from onyx.document_index.factory import get_default_document_index
9091
from onyx.file_store.document_batch_storage import DocumentBatchStorage
9192
from onyx.file_store.document_batch_storage import get_document_batch_storage
@@ -112,6 +113,7 @@
112113
from shared_configs.configs import INDEXING_MODEL_SERVER_HOST
113114
from shared_configs.configs import INDEXING_MODEL_SERVER_PORT
114115
from shared_configs.configs import MULTI_TENANT
116+
from shared_configs.configs import USAGE_LIMITS_ENABLED
115117
from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR
116118
from shared_configs.contextvars import INDEX_ATTEMPT_INFO_CONTEXTVAR
117119

@@ -1279,6 +1281,31 @@ def docprocessing_task(
12791281
INDEX_ATTEMPT_INFO_CONTEXTVAR.reset(token)
12801282

12811283

1284+
def _check_chunk_usage_limit(tenant_id: str) -> None:
1285+
"""Check if chunk indexing usage limit has been exceeded.
1286+
1287+
Raises UsageLimitExceededError if the limit is exceeded.
1288+
"""
1289+
if not USAGE_LIMITS_ENABLED:
1290+
return
1291+
1292+
from onyx.db.usage import check_usage_limit
1293+
from onyx.db.usage import UsageType
1294+
from onyx.server.usage_limits import get_limit_for_usage_type
1295+
from onyx.server.usage_limits import is_tenant_on_trial
1296+
1297+
is_trial = is_tenant_on_trial(tenant_id)
1298+
limit = get_limit_for_usage_type(UsageType.CHUNKS_INDEXED, is_trial)
1299+
1300+
with get_session_with_current_tenant() as db_session:
1301+
check_usage_limit(
1302+
db_session=db_session,
1303+
usage_type=UsageType.CHUNKS_INDEXED,
1304+
limit=limit,
1305+
pending_amount=0, # Just check current usage
1306+
)
1307+
1308+
12821309
def _docprocessing_task(
12831310
index_attempt_id: int,
12841311
cc_pair_id: int,
@@ -1290,6 +1317,25 @@ def _docprocessing_task(
12901317
if tenant_id:
12911318
CURRENT_TENANT_ID_CONTEXTVAR.set(tenant_id)
12921319

1320+
# Check if chunk indexing usage limit has been exceeded before processing
1321+
if USAGE_LIMITS_ENABLED:
1322+
try:
1323+
_check_chunk_usage_limit(tenant_id)
1324+
except UsageLimitExceededError as e:
1325+
# Log the error and fail the indexing attempt
1326+
task_logger.error(
1327+
f"Chunk indexing usage limit exceeded for tenant {tenant_id}: {e}"
1328+
)
1329+
with get_session_with_current_tenant() as db_session:
1330+
from onyx.db.index_attempt import mark_attempt_failed
1331+
1332+
mark_attempt_failed(
1333+
index_attempt_id=index_attempt_id,
1334+
db_session=db_session,
1335+
failure_reason=str(e),
1336+
)
1337+
raise
1338+
12931339
task_logger.info(
12941340
f"Processing document batch: "
12951341
f"attempt={index_attempt_id} "
@@ -1434,6 +1480,23 @@ def _docprocessing_task(
14341480
adapter=adapter,
14351481
)
14361482

1483+
# Track chunk indexing usage for cloud usage limits
1484+
if USAGE_LIMITS_ENABLED and index_pipeline_result.total_chunks > 0:
1485+
try:
1486+
from onyx.db.usage import increment_usage
1487+
from onyx.db.usage import UsageType
1488+
1489+
with get_session_with_current_tenant() as usage_db_session:
1490+
increment_usage(
1491+
db_session=usage_db_session,
1492+
usage_type=UsageType.CHUNKS_INDEXED,
1493+
amount=index_pipeline_result.total_chunks,
1494+
)
1495+
usage_db_session.commit()
1496+
except Exception as e:
1497+
# Log but don't fail indexing if usage tracking fails
1498+
task_logger.warning(f"Failed to track chunk indexing usage: {e}")
1499+
14371500
# Update batch completion and document counts atomically using database coordination
14381501

14391502
with get_session_with_current_tenant() as db_session, cross_batch_db_lock:

0 commit comments

Comments
 (0)