Skip to content

Commit 85a3711

Browse files
instrument pre-completion chat availability
1 parent fe48268 commit 85a3711

6 files changed

Lines changed: 507 additions & 32 deletions

File tree

src/mlpa/core/auth/authorize.py

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
ServiceType,
1414
)
1515
from mlpa.core.config import env
16+
from mlpa.core.metrics import record_chat_availability_for
17+
from mlpa.core.prometheus_metrics import AvailabilityReason
1618
from mlpa.core.routers.appattest import app_attest_auth
1719
from mlpa.core.utils import extract_user_from_play_integrity_jwt, parse_app_attest_jwt
1820

@@ -123,27 +125,57 @@ async def authorize_chat_request(
123125
)
124126

125127
if not is_service_type_valid:
128+
record_chat_availability_for(
129+
AvailabilityReason.INVALID_SERVICE_TYPE_FOR_MODEL,
130+
model=chat_request.model,
131+
service_type=service_type.value,
132+
purpose="",
133+
)
126134
raise HTTPException(
127135
status_code=400,
128136
detail=f"Invalid service-type value for model {chat_request.model}. Should be one of {env.forced_model_service_type_pairs.get(chat_request.model)}",
129137
)
130138

131-
return await _authorize_common_request(
132-
request=request,
133-
build_authorized_request=lambda user, purpose_value: AuthorizedChatRequest(
134-
user=user,
139+
try:
140+
return await _authorize_common_request(
141+
request=request,
142+
build_authorized_request=lambda user, purpose_value: AuthorizedChatRequest(
143+
user=user,
144+
service_type=service_type.value,
145+
purpose=purpose_value,
146+
**chat_request.model_dump(exclude_unset=True),
147+
),
148+
authorization=authorization,
149+
service_type=service_type,
150+
purpose=purpose,
151+
x_dev_authorization=x_dev_authorization,
152+
use_app_attest=use_app_attest,
153+
use_qa_certificates=use_qa_certificates,
154+
use_play_integrity=use_play_integrity,
155+
)
156+
except HTTPException as exc:
157+
# Record only the terminal auth dispositions the dependency intentionally
158+
# emits. 401 is an expected or normalized auth rejection. 400 is a
159+
# client-error request to the auth layer (invalid purpose, or malformed App
160+
# Attest base64 decoded in app_attest_auth). Any other status, including App
161+
# Attest's explicit 500, is re-raised unrecorded; auth-system-failure capture
162+
# is left to a follow-on auth backend change. Non-HTTPException errors are not
163+
# caught here and propagate unrecorded. Purpose is unresolved at these
164+
# dispositions, so a stable "" placeholder is used, never the unvalidated
165+
# header value.
166+
if exc.status_code == 401:
167+
reason = AvailabilityReason.AUTH_REJECTED
168+
elif exc.status_code == 400:
169+
reason = AvailabilityReason.INVALID_AUTH_REQUEST
170+
else:
171+
raise
172+
record_chat_availability_for(
173+
reason,
174+
model=chat_request.model,
135175
service_type=service_type.value,
136-
purpose=purpose_value,
137-
**chat_request.model_dump(exclude_unset=True),
138-
),
139-
authorization=authorization,
140-
service_type=service_type,
141-
purpose=purpose,
142-
x_dev_authorization=x_dev_authorization,
143-
use_app_attest=use_app_attest,
144-
use_qa_certificates=use_qa_certificates,
145-
use_play_integrity=use_play_integrity,
146-
)
176+
purpose="",
177+
)
178+
raise
147179

148180

149181
async def authorize_search_request(

src/mlpa/core/completions.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -54,20 +54,29 @@ def _build_litellm_body(req: AuthorizedChatRequest, *, stream: bool) -> dict:
5454
async def get_or_create_user_for_completion(
5555
user_id: str, req: AuthorizedChatRequest | AuthorizedSearchRequest
5656
):
57-
"""Wraps get_or_create_user and records a signup-cap rejection metric if applicable."""
57+
"""Wraps get_or_create_user, recording the signup-cap rejection metric and the
58+
pre-completion availability disposition (signup cap, or a 5xx provisioning
59+
failure) for chat requests."""
5860
try:
5961
return await get_or_create_user(user_id)
6062
except HTTPException as exc:
61-
if (
62-
exc.status_code == 403
63-
and isinstance(exc.detail, dict)
64-
and exc.detail.get("error") == ERROR_CODE_MAX_USERS_REACHED
65-
and isinstance(req, AuthorizedChatRequest)
66-
):
67-
record_chat_request_rejection(
68-
req,
69-
PrometheusRejectionReason.SIGNUP_CAP_EXCEEDED,
70-
)
63+
if isinstance(req, AuthorizedChatRequest):
64+
if (
65+
exc.status_code == 403
66+
and isinstance(exc.detail, dict)
67+
and exc.detail.get("error") == ERROR_CODE_MAX_USERS_REACHED
68+
):
69+
record_chat_request_rejection(
70+
req,
71+
PrometheusRejectionReason.SIGNUP_CAP_EXCEEDED,
72+
)
73+
record_chat_availability(req, AvailabilityReason.SIGNUP_CAP_EXCEEDED)
74+
elif exc.status_code >= 500:
75+
# User-resolution / provisioning system failure for an eligible
76+
# request. Non-signup-cap, non-5xx dispositions are left unrecorded;
77+
# a future client-side 4xx should get its own classification rather
78+
# than counting as an availability failure.
79+
record_chat_availability(req, AvailabilityReason.PROVISIONING_FAILURE)
7180
raise
7281

7382

src/mlpa/core/metrics.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,33 @@ def record_chat_request_rejection(
2525
metrics.chat_request_rejections.labels(reason=reason, **_chat_labels(req)).inc()
2626

2727

28-
def record_chat_availability(
29-
req: AuthorizedChatRequest, reason: AvailabilityReason
28+
def record_chat_availability_for(
29+
reason: AvailabilityReason,
30+
*,
31+
model: str,
32+
service_type: str,
33+
purpose: str,
3034
) -> None:
3135
metrics.chat_availability.labels(
3236
outcome=availability_outcome_for(reason),
3337
reason=reason,
34-
**_chat_labels(req),
38+
model=model,
39+
service_type=service_type,
40+
purpose=purpose,
3541
).inc()
3642

3743

44+
def record_chat_availability(
45+
req: AuthorizedChatRequest, reason: AvailabilityReason
46+
) -> None:
47+
record_chat_availability_for(
48+
reason,
49+
model=req.model,
50+
service_type=req.service_type,
51+
purpose=req.purpose,
52+
)
53+
54+
3855
def record_completion_latency(
3956
req: AuthorizedChatRequest,
4057
result: PrometheusResult,

src/mlpa/core/prometheus_metrics.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,19 @@ class AvailabilityReason(StrEnum):
4444
INVALID_REQUEST = "invalid_request"
4545
# abort
4646
CLIENT_DISCONNECT = "client_disconnect"
47-
# TODO: add pre-completion reasons (auth_rejected, auth_system_failure,
48-
# signup_cap_exceeded, blocked, provisioning_failure, invalid_purpose,
49-
# invalid_service_type_for_model) when those paths are instrumented.
47+
# pre-completion (auth dependency and route body, before the completion path)
48+
# excluded
49+
AUTH_REJECTED = "auth_rejected"
50+
INVALID_AUTH_REQUEST = "invalid_auth_request"
51+
INVALID_SERVICE_TYPE_FOR_MODEL = "invalid_service_type_for_model"
52+
SIGNUP_CAP_EXCEEDED = "signup_cap_exceeded"
53+
BLOCKED = "blocked"
54+
# failure
55+
PROVISIONING_FAILURE = "provisioning_failure"
56+
# Defined but not emitted yet: auth backends normalize system failures to 401
57+
# (indistinguishable from expected rejections), so capturing this is left to a
58+
# follow-on auth backend change that surfaces a real disposition.
59+
AUTH_SYSTEM_FAILURE = "auth_system_failure"
5060

5161

5262
_AVAILABILITY_OUTCOME_BY_REASON: dict[AvailabilityReason, AvailabilityOutcome] = {
@@ -60,6 +70,13 @@ class AvailabilityReason(StrEnum):
6070
AvailabilityReason.INVALID_MODEL_NAME: AvailabilityOutcome.EXCLUDED,
6171
AvailabilityReason.INVALID_REQUEST: AvailabilityOutcome.EXCLUDED,
6272
AvailabilityReason.CLIENT_DISCONNECT: AvailabilityOutcome.ABORT,
73+
AvailabilityReason.AUTH_REJECTED: AvailabilityOutcome.EXCLUDED,
74+
AvailabilityReason.INVALID_AUTH_REQUEST: AvailabilityOutcome.EXCLUDED,
75+
AvailabilityReason.INVALID_SERVICE_TYPE_FOR_MODEL: AvailabilityOutcome.EXCLUDED,
76+
AvailabilityReason.SIGNUP_CAP_EXCEEDED: AvailabilityOutcome.EXCLUDED,
77+
AvailabilityReason.BLOCKED: AvailabilityOutcome.EXCLUDED,
78+
AvailabilityReason.PROVISIONING_FAILURE: AvailabilityOutcome.FAILURE,
79+
AvailabilityReason.AUTH_SYSTEM_FAILURE: AvailabilityOutcome.FAILURE,
6380
}
6481

6582

src/mlpa/run.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@
2626
)
2727
from mlpa.core.http_client import close_http_client, get_http_client
2828
from mlpa.core.logger import logger, setup_logger
29+
from mlpa.core.metrics import record_chat_availability
2930
from mlpa.core.middleware import register_middleware
3031
from mlpa.core.openapi import customize_openapi
3132
from mlpa.core.pg_services.services import app_attest_pg, litellm_pg
33+
from mlpa.core.prometheus_metrics import AvailabilityReason
3234
from mlpa.core.routers.appattest import appattest_router
3335
from mlpa.core.routers.health import health_router
3436
from mlpa.core.routers.mock import mock_router
@@ -207,6 +209,7 @@ async def chat_completion(
207209
)
208210
user, _ = await get_or_create_user_for_completion(user_id, authorized_chat_request)
209211
if user.get("blocked"):
212+
record_chat_availability(authorized_chat_request, AvailabilityReason.BLOCKED)
210213
raise HTTPException(status_code=403, detail={"error": "User is blocked."})
211214

212215
if authorized_chat_request.stream:

0 commit comments

Comments
 (0)