Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions services/llm-gateway/src/llm_gateway/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,16 @@ async def enforce_throttles(
team_id=user.team_id,
product=product,
)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=model_error)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": {
"message": f"{model_error} (rate_limit)",
"type": "permission_error",
"code": "model_gate",
}
},
)

context = ThrottleContext(
user=user,
Expand Down Expand Up @@ -262,11 +271,16 @@ async def enforce_throttles(
status_code=result.status_code,
)
headers = {"Retry-After": str(result.retry_after)} if result.retry_after is not None else None
reason = result.detail
message = (
f"Rate limit exceeded: {reason}" if reason and reason != "Rate limit exceeded" else "Rate limit exceeded"
)
detail = {
"error": {
"message": "Rate limit exceeded",
"message": message,
"type": "rate_limit_error",
"reason": result.detail,
"reason": reason,
**({"code": result.scope} if result.scope else {}),
}
}
raise HTTPException(status_code=result.status_code, detail=detail, headers=headers)
Expand Down
10 changes: 8 additions & 2 deletions services/llm-gateway/tests/test_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ async def test_multipart_transcription_model_is_gated(self, monkeypatch: pytest.
await enforce_throttles(request=request, user=user, runner=runner)

assert exc_info.value.status_code == 403
assert "gpt-4o-transcribe" in exc_info.value.detail
assert "gpt-4o-transcribe" in exc_info.value.detail["error"]["message"]
finally:
get_settings.cache_clear()

Expand All @@ -370,7 +370,13 @@ async def test_gated_model_is_rejected_on_the_enforcement_path(self, monkeypatch
await enforce_throttles(request=request, user=user, runner=runner)

assert exc_info.value.status_code == 403
assert "claude-fable-5" in exc_info.value.detail
error = exc_info.value.detail["error"]
assert "claude-fable-5" in error["message"]
assert error["code"] == "model_gate"
# Legacy PostHog Code clients route errors by substring; the
# "(rate_limit)" suffix sends this 403 to their usage-limit modal
# instead of their fatal-session teardown path.
assert error["message"].endswith("(rate_limit)")
finally:
get_settings.cache_clear()

Expand Down
57 changes: 56 additions & 1 deletion services/llm-gateway/tests/test_rate_limiting.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,10 +244,65 @@ async def allow_request(self, context: ThrottleContext) -> ThrottleResult:

assert response.status_code == 429
assert response.headers["retry-after"] == "3600"
# The reason is repeated in the message (SDK error strings often
# surface only error.message) and the throttle scope rides along as
# a machine-readable code.
assert response.json() == {
"error": {
"message": "Rate limit exceeded",
"message": "Rate limit exceeded: Product rate limit exceeded",
"type": "rate_limit_error",
"reason": "Product rate limit exceeded",
"code": "test_throttle",
}
}


class TestFreeTierModelGateErrorBody:
def test_gate_403_wire_body_carries_code_and_legacy_shim(
self, mock_db_pool: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Pins the gate 403's wire shape end-to-end (raise site + exception-handler
unwrap): a top-level error envelope, never FastAPI's {"detail": ...} nesting.
Pre-cutover PostHog Code clients route this 403 by the "(rate_limit)"
substring in the message and newer clients read the code — a regression in
either strands installed builds in fatal-session teardown."""
from llm_gateway.auth.models import AuthenticatedUser
from llm_gateway.config import get_settings
from llm_gateway.dependencies import get_authenticated_user
from llm_gateway.products.config import POSTHOG_CODE_US_APP_ID

monkeypatch.setenv("LLM_GATEWAY_POSTHOG_CODE_MODEL_GATE_ENABLED", "true")
get_settings.cache_clear()
try:
app = create_test_app(mock_db_pool)
# OAuth caller on the Code app whose org isn't billed for Code usage
# (the conftest quota resolver reports code_usage_billing_active=False).
app.dependency_overrides[get_authenticated_user] = lambda: AuthenticatedUser(
user_id=7,
team_id=1,
auth_method="oauth_access_token",
distinct_id="unbilled-user",
scopes=["*"],
application_id=POSTHOG_CODE_US_APP_ID,
)

with TestClient(app) as client:
response = client.post(
"/posthog_code/v1/messages",
json={
"model": "claude-fable-5",
"max_tokens": 16,
"messages": [{"role": "user", "content": "Hi"}],
},
)

assert response.status_code == 403
body = response.json()
assert "detail" not in body
error = body["error"]
assert error["type"] == "permission_error"
assert error["code"] == "model_gate"
assert "claude-fable-5" in error["message"]
assert error["message"].endswith("(rate_limit)")
finally:
get_settings.cache_clear()
Loading