Skip to content

Commit 28cb8c2

Browse files
authored
feat(llm-gateway): add billing-denial error codes and legacy-client shim (#71343)
## Problem PostHog Code's usage-based billing cutover (#70951) gates unbilled orgs to the free-tier model list, but the gate's 403 goes out as a bare FastAPI `detail` string. Pre-cutover desktop builds classify errors by substring: they treat that 403 as a fatal session error and tear a healthy session down ("Connection lost" + recovery loops), and no client can tell which limit tripped a 429 without parsing prose. **Why:** installed desktop builds can't be updated in time for the cutover — the gateway's error bodies are the only lever that reaches them, and this can deploy ahead of the model-gate flip. ## Changes - 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 — a compat shim that routes pre-cutover PostHog Code clients to their usage-limit modal instead of session teardown (documented inline; remove once those builds roll forward). - Throttle 429s repeat the reason in `error.message` (SDK error strings often surface only the message) and carry the throttle scope as `error.code` (`billable_credits`, `user_cost_burst`, `user_cost_sustained`, …), so clients can classify the limit cause. Companion client work: [PostHog/code#3485](PostHog/code#3485) classifies these bodies into cause-aware billing UX. ## How did you test this code? Automated only: the enforcement-path gate tests now pin the new envelope (`code`, the `(rate_limit)` suffix), the rate-limiting endpoint test pins the message/reason/code shape, and a TestClient-level test pins the gate 403's wire body end-to-end (top-level error envelope with `code: "model_gate"` and the `(rate_limit)` suffix — never `{"detail": ...}` nesting) through the raise site and the exception-handler unwrap together. All would fail if the shim or codes were dropped. Full llm-gateway suite green locally (1239 passed, 50 skipped, 30 xfailed, 22 xpassed). ## Automatic notifications - [ ] Publish to changelog? - [ ] Alert Sales and Marketing teams? ## Docs update None — internal error-body shape. ## 🤖 Agent context **Autonomy:** Human-driven (agent-assisted) PostHog Code session. Part of the usage-based billing cutover client plan: this PR makes gateway billing denials classifiable by both pre- and post-cutover desktop builds. --- *Created with [PostHog Code](https://posthog.com/code?ref=pr)*
1 parent 5780984 commit 28cb8c2

3 files changed

Lines changed: 81 additions & 6 deletions

File tree

services/llm-gateway/src/llm_gateway/dependencies.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,16 @@ async def enforce_throttles(
233233
team_id=user.team_id,
234234
product=product,
235235
)
236-
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=model_error)
236+
raise HTTPException(
237+
status_code=status.HTTP_403_FORBIDDEN,
238+
detail={
239+
"error": {
240+
"message": f"{model_error} (rate_limit)",
241+
"type": "permission_error",
242+
"code": "model_gate",
243+
}
244+
},
245+
)
237246

238247
context = ThrottleContext(
239248
user=user,
@@ -262,11 +271,16 @@ async def enforce_throttles(
262271
status_code=result.status_code,
263272
)
264273
headers = {"Retry-After": str(result.retry_after)} if result.retry_after is not None else None
274+
reason = result.detail
275+
message = (
276+
f"Rate limit exceeded: {reason}" if reason and reason != "Rate limit exceeded" else "Rate limit exceeded"
277+
)
265278
detail = {
266279
"error": {
267-
"message": "Rate limit exceeded",
280+
"message": message,
268281
"type": "rate_limit_error",
269-
"reason": result.detail,
282+
"reason": reason,
283+
**({"code": result.scope} if result.scope else {}),
270284
}
271285
}
272286
raise HTTPException(status_code=result.status_code, detail=detail, headers=headers)

services/llm-gateway/tests/test_dependencies.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,7 @@ async def test_multipart_transcription_model_is_gated(self, monkeypatch: pytest.
347347
await enforce_throttles(request=request, user=user, runner=runner)
348348

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

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

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

services/llm-gateway/tests/test_rate_limiting.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,10 +244,65 @@ async def allow_request(self, context: ThrottleContext) -> ThrottleResult:
244244

245245
assert response.status_code == 429
246246
assert response.headers["retry-after"] == "3600"
247+
# The reason is repeated in the message (SDK error strings often
248+
# surface only error.message) and the throttle scope rides along as
249+
# a machine-readable code.
247250
assert response.json() == {
248251
"error": {
249-
"message": "Rate limit exceeded",
252+
"message": "Rate limit exceeded: Product rate limit exceeded",
250253
"type": "rate_limit_error",
251254
"reason": "Product rate limit exceeded",
255+
"code": "test_throttle",
252256
}
253257
}
258+
259+
260+
class TestFreeTierModelGateErrorBody:
261+
def test_gate_403_wire_body_carries_code_and_legacy_shim(
262+
self, mock_db_pool: MagicMock, monkeypatch: pytest.MonkeyPatch
263+
) -> None:
264+
"""Pins the gate 403's wire shape end-to-end (raise site + exception-handler
265+
unwrap): a top-level error envelope, never FastAPI's {"detail": ...} nesting.
266+
Pre-cutover PostHog Code clients route this 403 by the "(rate_limit)"
267+
substring in the message and newer clients read the code — a regression in
268+
either strands installed builds in fatal-session teardown."""
269+
from llm_gateway.auth.models import AuthenticatedUser
270+
from llm_gateway.config import get_settings
271+
from llm_gateway.dependencies import get_authenticated_user
272+
from llm_gateway.products.config import POSTHOG_CODE_US_APP_ID
273+
274+
monkeypatch.setenv("LLM_GATEWAY_POSTHOG_CODE_MODEL_GATE_ENABLED", "true")
275+
get_settings.cache_clear()
276+
try:
277+
app = create_test_app(mock_db_pool)
278+
# OAuth caller on the Code app whose org isn't billed for Code usage
279+
# (the conftest quota resolver reports code_usage_billing_active=False).
280+
app.dependency_overrides[get_authenticated_user] = lambda: AuthenticatedUser(
281+
user_id=7,
282+
team_id=1,
283+
auth_method="oauth_access_token",
284+
distinct_id="unbilled-user",
285+
scopes=["*"],
286+
application_id=POSTHOG_CODE_US_APP_ID,
287+
)
288+
289+
with TestClient(app) as client:
290+
response = client.post(
291+
"/posthog_code/v1/messages",
292+
json={
293+
"model": "claude-fable-5",
294+
"max_tokens": 16,
295+
"messages": [{"role": "user", "content": "Hi"}],
296+
},
297+
)
298+
299+
assert response.status_code == 403
300+
body = response.json()
301+
assert "detail" not in body
302+
error = body["error"]
303+
assert error["type"] == "permission_error"
304+
assert error["code"] == "model_gate"
305+
assert "claude-fable-5" in error["message"]
306+
assert error["message"].endswith("(rate_limit)")
307+
finally:
308+
get_settings.cache_clear()

0 commit comments

Comments
 (0)