diff --git a/services/llm-gateway/src/llm_gateway/dependencies.py b/services/llm-gateway/src/llm_gateway/dependencies.py index 904c42e5e767..3f52b1d61cde 100644 --- a/services/llm-gateway/src/llm_gateway/dependencies.py +++ b/services/llm-gateway/src/llm_gateway/dependencies.py @@ -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, @@ -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) diff --git a/services/llm-gateway/tests/test_dependencies.py b/services/llm-gateway/tests/test_dependencies.py index 5b94aa8bbefd..9285ab804a5b 100644 --- a/services/llm-gateway/tests/test_dependencies.py +++ b/services/llm-gateway/tests/test_dependencies.py @@ -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() @@ -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() diff --git a/services/llm-gateway/tests/test_rate_limiting.py b/services/llm-gateway/tests/test_rate_limiting.py index 35a2dfb33a89..5e7353feabed 100644 --- a/services/llm-gateway/tests/test_rate_limiting.py +++ b/services/llm-gateway/tests/test_rate_limiting.py @@ -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()