|
22 | 22 | """ |
23 | 23 |
|
24 | 24 | import asyncio |
| 25 | +import json |
25 | 26 | from datetime import datetime, timedelta |
26 | 27 |
|
27 | 28 | import pytest |
|
68 | 69 | TOKEN_TYPE = "gen_ai.token.type" |
69 | 70 | MODEL_KEY = "gen_ai.request.model" |
70 | 71 |
|
71 | | -# Each is a member of VALID_METRIC_ATTRIBUTE_NAMES and is stamped on the metric |
72 | | -# by default (proven by the no-filter test below). |
73 | | -HIGH_CARDINALITY_KEYS = ( |
| 72 | +# Keys inside the ceiling that an operator's filter must still be able to remove. |
| 73 | +# Every one is bounded, so it survives the ceiling and only the operator's own |
| 74 | +# exclude_list takes it off; that is what makes the filter tests non-vacuous. |
| 75 | +FILTERABLE_KEYS = ( |
74 | 76 | "hidden_params", |
75 | 77 | "metadata.user_api_key_hash", |
76 | | - "metadata.requester_ip_address", |
77 | | - "metadata.requester_metadata", |
78 | | - "metadata.applied_guardrails", |
| 78 | + "metadata.user_api_key_team_id", |
79 | 79 | ) |
80 | 80 |
|
81 | 81 | PROMPT_TOKENS = 137 |
@@ -103,6 +103,7 @@ def _build_call(stream: bool = True): |
103 | 103 | "standard_logging_object": { |
104 | 104 | "metadata": { |
105 | 105 | "user_api_key_hash": "hash-abc123", |
| 106 | + "user_api_key_team_id": "team-1", |
106 | 107 | "requester_ip_address": "10.0.0.7", |
107 | 108 | "requester_metadata": {"team": "alpha", "tier": "gold"}, |
108 | 109 | "applied_guardrails": ["pii", "toxicity"], |
@@ -215,14 +216,14 @@ def test_metrics_off_by_default_records_nothing(): |
215 | 216 |
|
216 | 217 |
|
217 | 218 | def test_exclude_list_strips_high_cardinality_across_metrics(): |
218 | | - """exclude_list set AFTER construction (the proxy path) removes every |
219 | | - high-cardinality key from more than one metric while the low-cardinality |
220 | | - model attribute survives.""" |
| 219 | + """exclude_list set AFTER construction (the proxy path) removes every listed |
| 220 | + key from more than one metric while the low-cardinality model attribute |
| 221 | + survives.""" |
221 | 222 | metrics = _drive_success( |
222 | 223 | InMemoryMetricReader(), |
223 | | - callback_settings_attributes={"exclude_list": list(HIGH_CARDINALITY_KEYS)}, |
| 224 | + callback_settings_attributes={"exclude_list": list(FILTERABLE_KEYS)}, |
224 | 225 | ) |
225 | | - excluded = set(HIGH_CARDINALITY_KEYS) |
| 226 | + excluded = set(FILTERABLE_KEYS) |
226 | 227 |
|
227 | 228 | for name in (OPERATION_DURATION, TOKEN_USAGE): |
228 | 229 | points = metrics[name] |
@@ -251,18 +252,103 @@ def test_include_list_allows_only_listed_attributes(): |
251 | 252 | assert set(dp.attributes.keys()) - {TOKEN_TYPE} == allowed |
252 | 253 |
|
253 | 254 |
|
254 | | -def test_no_filter_keeps_high_cardinality_keys(): |
255 | | - """Backward compatibility: without an attributes config every high-cardinality |
256 | | - key the call carries is still stamped, so the filter tests above prove a real |
257 | | - removal rather than a key that was never present.""" |
| 255 | +def test_no_filter_still_keeps_the_filterable_keys(): |
| 256 | + """Without an attributes config every key the filter tests remove is present, |
| 257 | + so those tests prove a real removal rather than a key that was never there.""" |
258 | 258 | metrics = _drive_success(InMemoryMetricReader()) |
259 | | - expected = set(HIGH_CARDINALITY_KEYS) |
| 259 | + expected = set(FILTERABLE_KEYS) |
260 | 260 |
|
261 | 261 | for name in (OPERATION_DURATION, TOKEN_USAGE): |
262 | 262 | for dp in metrics[name]: |
263 | 263 | assert expected.issubset(set(dp.attributes.keys())) |
264 | 264 |
|
265 | 265 |
|
| 266 | +def test_two_calls_differing_only_per_request_share_one_series(): |
| 267 | + """The whole point of the ceiling: metric cardinality must not grow with traffic. |
| 268 | +
|
| 269 | + Every field here moves on every real request -- the response cost, the call id, |
| 270 | + the cache key, the provider's remaining-rate-limit headers -- and each one used |
| 271 | + to reach the datapoint inside a single ``hidden_params`` label. A unique label |
| 272 | + value is a new time series, so each of the six instruments minted one series per |
| 273 | + request, which is both a Grafana Cloud bill proportional to traffic and a |
| 274 | + histogram that cannot be aggregated. Identical attribute sets is what "one |
| 275 | + series" means to the SDK. |
| 276 | + """ |
| 277 | + reader = InMemoryMetricReader() |
| 278 | + logger = _logger(reader, enable_metrics=True) |
| 279 | + |
| 280 | + for index, cost in enumerate((RESPONSE_COST, RESPONSE_COST * 3)): |
| 281 | + kwargs, response_obj, start, end = _build_call() |
| 282 | + kwargs["response_cost"] = cost |
| 283 | + kwargs["standard_logging_object"]["hidden_params"] = { |
| 284 | + "model_id": "m-1", |
| 285 | + "api_base": "https://api.openai.com/v1", |
| 286 | + "litellm_call_id": f"call-{index}", |
| 287 | + "cache_key": f"cache-{index}", |
| 288 | + "response_cost": cost, |
| 289 | + "litellm_overhead_time_ms": 1.5 + index, |
| 290 | + "usage_object": {"prompt_tokens": index, "completion_tokens": index}, |
| 291 | + "additional_headers": {"x_ratelimit_remaining_requests": 100 - index}, |
| 292 | + } |
| 293 | + asyncio.run(logger.async_log_success_event(kwargs, response_obj, start, end)) |
| 294 | + |
| 295 | + for name in ALL_METRICS: |
| 296 | + attribute_sets = { |
| 297 | + tuple(sorted((k, v) for k, v in dp.attributes.items() if k != TOKEN_TYPE)) |
| 298 | + for dp in _metrics_by_name(reader)[name] |
| 299 | + } |
| 300 | + assert len(attribute_sets) == 1, f"{name} split into {len(attribute_sets)} series across 2 requests" |
| 301 | + |
| 302 | + |
| 303 | +def test_hidden_params_label_carries_only_bounded_deployment_fields(): |
| 304 | + """``hidden_params`` survives the ceiling, but only as the deployment identity. |
| 305 | +
|
| 306 | + ``model_id`` and ``api_base`` are bounded by the router's deployment list and are |
| 307 | + what a per-deployment dashboard reads. Everything else in the object is |
| 308 | + per-request and belongs on the span, which already carries it. |
| 309 | + """ |
| 310 | + kwargs, response_obj, start, end = _build_call() |
| 311 | + kwargs["standard_logging_object"]["hidden_params"] = { |
| 312 | + "model_id": "m-1", |
| 313 | + "api_base": "https://api.openai.com/v1", |
| 314 | + "litellm_call_id": "abc", |
| 315 | + "cache_key": "ck-1", |
| 316 | + "response_cost": RESPONSE_COST, |
| 317 | + } |
| 318 | + reader = InMemoryMetricReader() |
| 319 | + logger = _logger(reader, enable_metrics=True) |
| 320 | + asyncio.run(logger.async_log_success_event(kwargs, response_obj, start, end)) |
| 321 | + |
| 322 | + label = _metrics_by_name(reader)[OPERATION_DURATION][0].attributes["hidden_params"] |
| 323 | + assert json.loads(label) == {"model_id": "m-1", "api_base": "https://api.openai.com/v1"} |
| 324 | + |
| 325 | + |
| 326 | +def test_success_attributes_are_capped_at_the_ceiling(): |
| 327 | + """The success path carries exactly the ceiling, no client-supplied attributes. |
| 328 | +
|
| 329 | + The fixture deliberately sets every excluded key, so this asserts a real removal |
| 330 | + rather than keys that were never present. |
| 331 | + """ |
| 332 | + kwargs, response_obj, start, end = _build_call() |
| 333 | + metadata = kwargs["standard_logging_object"]["metadata"] |
| 334 | + metadata.update( |
| 335 | + { |
| 336 | + "spend_logs_metadata": {"cost_center": "abc"}, |
| 337 | + "user_api_key_end_user_id": "end-user-1", |
| 338 | + "user_api_key_user_email": "someone@example.com", |
| 339 | + } |
| 340 | + ) |
| 341 | + reader = InMemoryMetricReader() |
| 342 | + logger = _logger(reader, enable_metrics=True) |
| 343 | + asyncio.run(logger.async_log_success_event(kwargs, response_obj, start, end)) |
| 344 | + metrics = _metrics_by_name(reader) |
| 345 | + |
| 346 | + for name in ALL_METRICS: |
| 347 | + for dp in metrics[name]: |
| 348 | + leaked = set(dp.attributes) - set(BOUNDED_KEYS) - {TOKEN_TYPE} |
| 349 | + assert not leaked, f"{name} leaked {leaked}" |
| 350 | + |
| 351 | + |
266 | 352 | def test_metrics_reach_operator_configured_global_provider(monkeypatch): |
267 | 353 | """Regression: with no meter provider injected, the six gen_ai.client.* |
268 | 354 | histograms must record through the operator's globally configured |
@@ -353,18 +439,18 @@ def test_token_type_rejected_from_either_list(attributes, monkeypatch): |
353 | 439 | # caller (so a caller could mint a fresh series per request, and a failure costs |
354 | 440 | # them no provider spend) or varies per request, or is PII duplicating an id that |
355 | 441 | # is already on the series. |
356 | | -UNBOUNDED_FAILURE_KEYS = ( |
357 | | - "hidden_params", |
| 442 | +UNBOUNDED_KEYS = ( |
358 | 443 | "metadata.requester_metadata", |
359 | 444 | "metadata.requester_ip_address", |
360 | 445 | "metadata.spend_logs_metadata", |
361 | 446 | "metadata.user_api_key_end_user_id", |
362 | 447 | "metadata.user_api_key_user_email", |
363 | 448 | ) |
364 | 449 |
|
365 | | -# The exact set a failure datapoint may carry: the operation, plus |
366 | | -# operator-provisioned identity. |
367 | | -BOUNDED_FAILURE_KEYS = ( |
| 450 | +# The exact set a datapoint may carry on either path: the operation, the |
| 451 | +# operator-provisioned identity, and the deployment that served it. |
| 452 | +BOUNDED_KEYS = ( |
| 453 | + "hidden_params", |
368 | 454 | "gen_ai.operation.name", |
369 | 455 | "gen_ai.system", |
370 | 456 | "gen_ai.request.model", |
@@ -413,7 +499,11 @@ def _build_failure( |
413 | 499 | "requester_metadata": {"trace": "caller-supplied-unique-value"}, |
414 | 500 | "spend_logs_metadata": {"ticket": "caller-supplied-unique-value"}, |
415 | 501 | }, |
416 | | - "hidden_params": {"litellm_call_id": "abc"}, |
| 502 | + "hidden_params": { |
| 503 | + "litellm_call_id": "abc", |
| 504 | + "model_id": "m-1", |
| 505 | + "api_base": "https://api.openai.com/v1", |
| 506 | + }, |
417 | 507 | } |
418 | 508 | if error_information is not None: |
419 | 509 | standard_logging_object["error_information"] = error_information |
@@ -519,11 +609,16 @@ def test_failure_attributes_are_a_bounded_allowlist(): |
519 | 609 | succeeded = next(dp for dp in points if ERROR_TYPE not in dp.attributes) |
520 | 610 | failed = next(dp for dp in points if ERROR_TYPE in dp.attributes) |
521 | 611 |
|
522 | | - missing = set(UNBOUNDED_FAILURE_KEYS) - set(succeeded.attributes) |
| 612 | + supplied = set(kwargs["standard_logging_object"]["metadata"]) |
| 613 | + missing = {key for key in UNBOUNDED_KEYS if key.removeprefix("metadata.") not in supplied} |
523 | 614 | assert not missing, f"fixture never carried {missing}, so the exclusion below proves nothing" |
524 | | - leaked = set(UNBOUNDED_FAILURE_KEYS) & set(failed.attributes) |
| 615 | + leaked = set(UNBOUNDED_KEYS) & set(failed.attributes) |
525 | 616 | assert not leaked, f"failure datapoint leaked unbounded attributes: {leaked}" |
526 | | - assert set(failed.attributes) == set(BOUNDED_FAILURE_KEYS) | {ERROR_TYPE} |
| 617 | + assert set(failed.attributes) == set(BOUNDED_KEYS) | {ERROR_TYPE} |
| 618 | + assert json.loads(failed.attributes["hidden_params"]) == { |
| 619 | + "model_id": "m-1", |
| 620 | + "api_base": "https://api.openai.com/v1", |
| 621 | + } |
527 | 622 |
|
528 | 623 |
|
529 | 624 | def test_operator_filter_can_still_narrow_the_failure_allowlist(): |
|
0 commit comments