From 6ac2179627a2fc1bf49772a5d679103bda2b68ea Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Wed, 15 Jul 2026 16:53:48 -0400 Subject: [PATCH 1/2] feat(llm-gateway): add billing-denial error codes and legacy-client shim The free-tier model-gate 403 now uses the standard error envelope with a machine-readable code ("model_gate"), and its message carries a "(rate_limit)" suffix so pre-cutover PostHog Code desktop builds route it to their usage-limit modal instead of tearing the session down. Throttle 429s repeat the reason in error.message (SDK error strings often surface only the message) and carry the throttle scope as error.code so clients can classify the limit cause without parsing prose. Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec --- .../src/llm_gateway/dependencies.py | 20 ++++++++++++++++--- .../llm-gateway/tests/test_dependencies.py | 10 ++++++++-- .../llm-gateway/tests/test_rate_limiting.py | 6 +++++- 3 files changed, 30 insertions(+), 6 deletions(-) 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..6b8b0f4e6bbb 100644 --- a/services/llm-gateway/tests/test_rate_limiting.py +++ b/services/llm-gateway/tests/test_rate_limiting.py @@ -244,10 +244,14 @@ 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", } } From c4ef91181504e5927f32203d7e9038177af579a9 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Wed, 15 Jul 2026 17:08:13 -0400 Subject: [PATCH 2/2] test(llm-gateway): pin the free-tier gate 403 wire body end-to-end TestClient-level: an unbilled OAuth caller requesting a premium model gets a top-level error envelope (never FastAPI's {"detail": ...} nesting) with code "model_gate" and the "(rate_limit)" legacy-client shim in the message, covering the raise site and the exception-handler unwrap together. Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec --- .../llm-gateway/tests/test_rate_limiting.py | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/services/llm-gateway/tests/test_rate_limiting.py b/services/llm-gateway/tests/test_rate_limiting.py index 6b8b0f4e6bbb..5e7353feabed 100644 --- a/services/llm-gateway/tests/test_rate_limiting.py +++ b/services/llm-gateway/tests/test_rate_limiting.py @@ -255,3 +255,54 @@ async def allow_request(self, context: ThrottleContext) -> ThrottleResult: "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()