arize-phoenix 4.35.2__py3-none-any.whl → 5.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of arize-phoenix might be problematic. Click here for more details.

Files changed (104) hide show
  1. {arize_phoenix-4.35.2.dist-info → arize_phoenix-5.0.0.dist-info}/METADATA +10 -12
  2. {arize_phoenix-4.35.2.dist-info → arize_phoenix-5.0.0.dist-info}/RECORD +92 -79
  3. phoenix/__init__.py +86 -0
  4. phoenix/auth.py +275 -14
  5. phoenix/config.py +369 -27
  6. phoenix/db/alembic.ini +0 -34
  7. phoenix/db/engines.py +27 -10
  8. phoenix/db/enums.py +20 -0
  9. phoenix/db/facilitator.py +112 -0
  10. phoenix/db/insertion/dataset.py +0 -1
  11. phoenix/db/insertion/types.py +1 -1
  12. phoenix/db/migrate.py +3 -3
  13. phoenix/db/migrations/env.py +0 -7
  14. phoenix/db/migrations/versions/cd164e83824f_users_and_tokens.py +157 -0
  15. phoenix/db/models.py +145 -60
  16. phoenix/experiments/evaluators/code_evaluators.py +9 -3
  17. phoenix/experiments/functions.py +1 -4
  18. phoenix/inferences/fixtures.py +0 -1
  19. phoenix/inferences/inferences.py +0 -1
  20. phoenix/logging/__init__.py +3 -0
  21. phoenix/logging/_config.py +90 -0
  22. phoenix/logging/_filter.py +6 -0
  23. phoenix/logging/_formatter.py +69 -0
  24. phoenix/metrics/__init__.py +0 -1
  25. phoenix/otel/settings.py +4 -4
  26. phoenix/server/api/README.md +28 -0
  27. phoenix/server/api/auth.py +32 -0
  28. phoenix/server/api/context.py +50 -2
  29. phoenix/server/api/dataloaders/__init__.py +4 -0
  30. phoenix/server/api/dataloaders/user_roles.py +30 -0
  31. phoenix/server/api/dataloaders/users.py +33 -0
  32. phoenix/server/api/exceptions.py +7 -0
  33. phoenix/server/api/mutations/__init__.py +0 -2
  34. phoenix/server/api/mutations/api_key_mutations.py +104 -86
  35. phoenix/server/api/mutations/dataset_mutations.py +8 -8
  36. phoenix/server/api/mutations/experiment_mutations.py +2 -2
  37. phoenix/server/api/mutations/export_events_mutations.py +3 -3
  38. phoenix/server/api/mutations/project_mutations.py +3 -3
  39. phoenix/server/api/mutations/span_annotations_mutations.py +4 -4
  40. phoenix/server/api/mutations/trace_annotations_mutations.py +4 -4
  41. phoenix/server/api/mutations/user_mutations.py +282 -42
  42. phoenix/server/api/openapi/schema.py +2 -2
  43. phoenix/server/api/queries.py +48 -39
  44. phoenix/server/api/routers/__init__.py +11 -0
  45. phoenix/server/api/routers/auth.py +284 -0
  46. phoenix/server/api/routers/embeddings.py +26 -0
  47. phoenix/server/api/routers/oauth2.py +456 -0
  48. phoenix/server/api/routers/v1/__init__.py +38 -16
  49. phoenix/server/api/routers/v1/datasets.py +0 -1
  50. phoenix/server/api/types/ApiKey.py +11 -0
  51. phoenix/server/api/types/AuthMethod.py +9 -0
  52. phoenix/server/api/types/User.py +48 -4
  53. phoenix/server/api/types/UserApiKey.py +35 -1
  54. phoenix/server/api/types/UserRole.py +7 -0
  55. phoenix/server/app.py +105 -34
  56. phoenix/server/bearer_auth.py +161 -0
  57. phoenix/server/email/__init__.py +0 -0
  58. phoenix/server/email/sender.py +26 -0
  59. phoenix/server/email/templates/__init__.py +0 -0
  60. phoenix/server/email/templates/password_reset.html +19 -0
  61. phoenix/server/email/types.py +11 -0
  62. phoenix/server/grpc_server.py +6 -0
  63. phoenix/server/jwt_store.py +504 -0
  64. phoenix/server/main.py +61 -30
  65. phoenix/server/oauth2.py +51 -0
  66. phoenix/server/prometheus.py +20 -0
  67. phoenix/server/rate_limiters.py +191 -0
  68. phoenix/server/static/.vite/manifest.json +31 -31
  69. phoenix/server/static/assets/{components-Dte7_KRd.js → components-REunxTt6.js} +348 -286
  70. phoenix/server/static/assets/index-DAPJxlCw.js +101 -0
  71. phoenix/server/static/assets/{pages-CnTvEGEN.js → pages-1VrMk2pW.js} +559 -291
  72. phoenix/server/static/assets/{vendor-BC3OPQuM.js → vendor-B5IC0ivG.js} +5 -5
  73. phoenix/server/static/assets/{vendor-arizeai-NjB3cZzD.js → vendor-arizeai-aFbT4kl1.js} +2 -2
  74. phoenix/server/static/assets/{vendor-codemirror-gE_JCOgX.js → vendor-codemirror-BEGorXSV.js} +1 -1
  75. phoenix/server/static/assets/{vendor-recharts-BXLYwcXF.js → vendor-recharts-6nUU7gU_.js} +1 -1
  76. phoenix/server/telemetry.py +2 -2
  77. phoenix/server/templates/index.html +1 -0
  78. phoenix/server/types.py +157 -1
  79. phoenix/services.py +0 -1
  80. phoenix/session/client.py +7 -3
  81. phoenix/session/evaluation.py +0 -1
  82. phoenix/session/session.py +0 -1
  83. phoenix/settings.py +9 -0
  84. phoenix/trace/exporter.py +0 -1
  85. phoenix/trace/fixtures.py +0 -2
  86. phoenix/utilities/client.py +16 -0
  87. phoenix/utilities/logging.py +9 -1
  88. phoenix/utilities/re.py +3 -3
  89. phoenix/version.py +1 -1
  90. phoenix/db/migrations/future_versions/README.md +0 -4
  91. phoenix/db/migrations/future_versions/cd164e83824f_users_and_tokens.py +0 -293
  92. phoenix/db/migrations/versions/.gitignore +0 -1
  93. phoenix/server/api/mutations/auth.py +0 -18
  94. phoenix/server/api/mutations/auth_mutations.py +0 -65
  95. phoenix/server/static/assets/index-fq1-hCK4.js +0 -100
  96. phoenix/trace/langchain/__init__.py +0 -3
  97. phoenix/trace/langchain/instrumentor.py +0 -35
  98. phoenix/trace/llama_index/__init__.py +0 -3
  99. phoenix/trace/llama_index/callback.py +0 -103
  100. phoenix/trace/openai/__init__.py +0 -3
  101. phoenix/trace/openai/instrumentor.py +0 -31
  102. {arize_phoenix-4.35.2.dist-info → arize_phoenix-5.0.0.dist-info}/WHEEL +0 -0
  103. {arize_phoenix-4.35.2.dist-info → arize_phoenix-5.0.0.dist-info}/licenses/IP_NOTICE +0 -0
  104. {arize_phoenix-4.35.2.dist-info → arize_phoenix-5.0.0.dist-info}/licenses/LICENSE +0 -0
phoenix/server/main.py CHANGED
@@ -1,6 +1,5 @@
1
1
  import atexit
2
2
  import codecs
3
- import logging
4
3
  import os
5
4
  import sys
6
5
  from argparse import ArgumentParser
@@ -11,27 +10,42 @@ from time import sleep, time
11
10
  from typing import List, Optional
12
11
  from urllib.parse import urljoin
13
12
 
13
+ from fastapi_mail import ConnectionConfig
14
14
  from jinja2 import BaseLoader, Environment
15
15
  from uvicorn import Config, Server
16
16
 
17
17
  import phoenix.trace.v1 as pb
18
18
  from phoenix.config import (
19
19
  EXPORT_DIR,
20
- get_auth_settings,
20
+ get_env_access_token_expiry,
21
+ get_env_auth_settings,
21
22
  get_env_database_connection_str,
22
23
  get_env_database_schema,
24
+ get_env_db_logging_level,
23
25
  get_env_enable_prometheus,
24
26
  get_env_grpc_port,
25
27
  get_env_host,
26
28
  get_env_host_root_path,
29
+ get_env_log_migrations,
30
+ get_env_logging_level,
31
+ get_env_logging_mode,
32
+ get_env_oauth2_settings,
33
+ get_env_password_reset_token_expiry,
27
34
  get_env_port,
35
+ get_env_refresh_token_expiry,
36
+ get_env_smtp_hostname,
37
+ get_env_smtp_mail_from,
38
+ get_env_smtp_password,
39
+ get_env_smtp_port,
40
+ get_env_smtp_username,
41
+ get_env_smtp_validate_certs,
28
42
  get_pids_path,
29
- get_working_dir,
30
43
  )
31
44
  from phoenix.core.model_schema_adapter import create_model_from_inferences
32
45
  from phoenix.db import get_printable_db_url
33
46
  from phoenix.inferences.fixtures import FIXTURES, get_inferences
34
47
  from phoenix.inferences.inferences import EMPTY_INFERENCES, Inferences
48
+ from phoenix.logging import setup_logging
35
49
  from phoenix.pointcloud.umap_parameters import (
36
50
  DEFAULT_MIN_DIST,
37
51
  DEFAULT_N_NEIGHBORS,
@@ -45,6 +59,7 @@ from phoenix.server.app import (
45
59
  create_engine_and_run_migrations,
46
60
  instrument_engine_if_enabled,
47
61
  )
62
+ from phoenix.server.email.sender import EMAIL_TEMPLATE_FOLDER, FastMailSender
48
63
  from phoenix.server.types import DbSessionFactory
49
64
  from phoenix.settings import Settings
50
65
  from phoenix.trace.fixtures import (
@@ -59,9 +74,6 @@ from phoenix.trace.fixtures import (
59
74
  from phoenix.trace.otel import decode_otlp_span, encode_span_to_otlp
60
75
  from phoenix.trace.schemas import Span
61
76
 
62
- logger = logging.getLogger(__name__)
63
- logger.addHandler(logging.NullHandler())
64
-
65
77
  _WELCOME_MESSAGE = Environment(loader=BaseLoader()).from_string("""
66
78
 
67
79
  ██████╗ ██╗ ██╗ ██████╗ ███████╗███╗ ██╗██╗██╗ ██╗
@@ -83,6 +95,7 @@ _WELCOME_MESSAGE = Environment(loader=BaseLoader()).from_string("""
83
95
  |
84
96
  | 🚀 Phoenix Server 🚀
85
97
  | Phoenix UI: {{ ui_path }}
98
+ | Authentication: {{ auth_enabled }}
86
99
  | Log traces:
87
100
  | - gRPC: {{ grpc_path }}
88
101
  | - HTTP: {{ http_path }}
@@ -92,11 +105,6 @@ _WELCOME_MESSAGE = Environment(loader=BaseLoader()).from_string("""
92
105
  {% endif -%}
93
106
  """)
94
107
 
95
- _EXPERIMENTAL_WARNING = """
96
- 🚨 WARNING: Phoenix is running in experimental mode. 🚨
97
- | Authentication enabled: {auth_enabled}
98
- """
99
-
100
108
 
101
109
  def _write_pid_file_when_ready(
102
110
  server: Server,
@@ -121,19 +129,16 @@ def _get_pid_file() -> Path:
121
129
 
122
130
  DEFAULT_UMAP_PARAMS_STR = f"{DEFAULT_MIN_DIST},{DEFAULT_N_NEIGHBORS},{DEFAULT_N_SAMPLES}"
123
131
 
124
- if __name__ == "__main__":
132
+
133
+ def main() -> None:
125
134
  primary_inferences_name: str
126
135
  reference_inferences_name: Optional[str]
127
136
  trace_dataset_name: Optional[str] = None
128
- simulate_streaming: Optional[bool] = None
129
137
 
130
138
  primary_inferences: Inferences = EMPTY_INFERENCES
131
139
  reference_inferences: Optional[Inferences] = None
132
140
  corpus_inferences: Optional[Inferences] = None
133
141
 
134
- # Initialize the settings for the Server
135
- Settings.log_migrations = True
136
-
137
142
  # automatically remove the pid file when the process is being gracefully terminated
138
143
  atexit.register(_remove_pid_file)
139
144
 
@@ -230,7 +235,7 @@ if __name__ == "__main__":
230
235
  db_connection_str = (
231
236
  args.database_url if args.database_url else get_env_database_connection_str()
232
237
  )
233
- export_path = Path(args.export_path) if args.export_path else EXPORT_DIR
238
+ export_path = Path(args.export_path) if args.export_path else Path(EXPORT_DIR)
234
239
 
235
240
  force_fixture_ingestion = False
236
241
  scaffold_datasets = False
@@ -260,7 +265,6 @@ if __name__ == "__main__":
260
265
  reference_inferences = None
261
266
  elif args.command == "trace-fixture":
262
267
  trace_dataset_name = args.fixture
263
- simulate_streaming = args.simulate_streaming
264
268
  elif args.command == "demo":
265
269
  fixture_name = args.fixture
266
270
  primary_inferences, reference_inferences, corpus_inferences = get_inferences(
@@ -268,7 +272,6 @@ if __name__ == "__main__":
268
272
  args.no_internet,
269
273
  )
270
274
  trace_dataset_name = args.trace_fixture
271
- simulate_streaming = args.simulate_streaming
272
275
  elif args.command == "serve":
273
276
  # We use sets to avoid duplicates
274
277
  if args.with_fixture:
@@ -290,12 +293,6 @@ if __name__ == "__main__":
290
293
  force_fixture_ingestion = args.force_fixture_ingestion
291
294
  scaffold_datasets = args.scaffold_datasets
292
295
  host: Optional[str] = args.host or get_env_host()
293
- display_host = host or "localhost"
294
- # If the host is "::", the convention is to bind to all interfaces. However, uvicorn
295
- # does not support this directly unless the host is set to None.
296
- if host and ":" in host:
297
- # format IPv6 hosts in brackets
298
- display_host = f"[{host}]"
299
296
  if host == "::":
300
297
  # TODO(dustin): why is this necessary? it's not type compliant
301
298
  host = None
@@ -309,7 +306,7 @@ if __name__ == "__main__":
309
306
  reference_inferences,
310
307
  )
311
308
 
312
- authentication_enabled, secret = get_auth_settings()
309
+ authentication_enabled, secret = get_env_auth_settings()
313
310
 
314
311
  fixture_spans: List[Span] = []
315
312
  fixture_evals: List[pb.Evaluation] = []
@@ -336,13 +333,11 @@ if __name__ == "__main__":
336
333
  n_samples=int(umap_params_list[2]),
337
334
  )
338
335
 
339
- logger.info(f"Server umap params: {umap_params}")
340
336
  if enable_prometheus := get_env_enable_prometheus():
341
337
  from phoenix.server.prometheus import start_prometheus
342
338
 
343
339
  start_prometheus()
344
340
 
345
- working_dir = get_working_dir().resolve()
346
341
  engine = create_engine_and_run_migrations(db_connection_str)
347
342
  instrumentation_cleanups = instrument_engine_if_enabled(engine)
348
343
  factory = DbSessionFactory(db=_db(engine), dialect=engine.dialect.name)
@@ -358,9 +353,8 @@ if __name__ == "__main__":
358
353
  http_path=urljoin(root_path, "v1/traces"),
359
354
  storage=get_printable_db_url(db_connection_str),
360
355
  schema=get_env_database_schema(),
356
+ auth_enabled=authentication_enabled,
361
357
  )
362
- if authentication_enabled:
363
- msg += _EXPERIMENTAL_WARNING.format(auth_enabled=True)
364
358
  if sys.platform.startswith("win"):
365
359
  msg = codecs.encode(msg, "ascii", errors="ignore").decode("ascii").strip()
366
360
  scaffolder_config = ScaffolderConfig(
@@ -370,6 +364,25 @@ if __name__ == "__main__":
370
364
  scaffold_datasets=scaffold_datasets,
371
365
  phoenix_url=root_path,
372
366
  )
367
+ email_sender = None
368
+ if mail_sever := get_env_smtp_hostname():
369
+ assert (mail_username := get_env_smtp_username()), "SMTP username is required"
370
+ assert (mail_password := get_env_smtp_password()), "SMTP password is required"
371
+ assert (mail_from := get_env_smtp_mail_from()), "SMTP mail_from is required"
372
+ email_sender = FastMailSender(
373
+ ConnectionConfig(
374
+ MAIL_USERNAME=mail_username,
375
+ MAIL_PASSWORD=mail_password,
376
+ MAIL_FROM=mail_from,
377
+ MAIL_SERVER=mail_sever,
378
+ MAIL_PORT=get_env_smtp_port(),
379
+ VALIDATE_CERTS=get_env_smtp_validate_certs(),
380
+ USE_CREDENTIALS=True,
381
+ MAIL_STARTTLS=True,
382
+ MAIL_SSL_TLS=False,
383
+ TEMPLATE_FOLDER=EMAIL_TEMPLATE_FOLDER,
384
+ )
385
+ )
373
386
  app = create_app(
374
387
  db=factory,
375
388
  export_path=export_path,
@@ -387,10 +400,28 @@ if __name__ == "__main__":
387
400
  startup_callbacks=[lambda: print(msg)],
388
401
  shutdown_callbacks=instrumentation_cleanups,
389
402
  secret=secret,
403
+ password_reset_token_expiry=get_env_password_reset_token_expiry(),
404
+ access_token_expiry=get_env_access_token_expiry(),
405
+ refresh_token_expiry=get_env_refresh_token_expiry(),
390
406
  scaffolder_config=scaffolder_config,
407
+ email_sender=email_sender,
408
+ oauth2_client_configs=get_env_oauth2_settings(),
391
409
  )
392
410
  server = Server(config=Config(app, host=host, port=port, root_path=host_root_path)) # type: ignore
393
411
  Thread(target=_write_pid_file_when_ready, args=(server,), daemon=True).start()
394
412
 
395
413
  # Start the server
396
414
  server.run()
415
+
416
+
417
+ def initialize_settings() -> None:
418
+ Settings.logging_mode = get_env_logging_mode()
419
+ Settings.logging_level = get_env_logging_level()
420
+ Settings.db_logging_level = get_env_db_logging_level()
421
+ Settings.log_migrations = get_env_log_migrations()
422
+
423
+
424
+ if __name__ == "__main__":
425
+ initialize_settings()
426
+ setup_logging()
427
+ main()
@@ -0,0 +1,51 @@
1
+ from typing import Any, Dict, Iterable
2
+
3
+ from authlib.integrations.base_client import BaseApp
4
+ from authlib.integrations.base_client.async_app import AsyncOAuth2Mixin
5
+ from authlib.integrations.base_client.async_openid import AsyncOpenIDMixin
6
+ from authlib.integrations.httpx_client import AsyncOAuth2Client as AsyncHttpxOAuth2Client
7
+
8
+ from phoenix.config import OAuth2ClientConfig
9
+
10
+
11
+ class OAuth2Client(AsyncOAuth2Mixin, AsyncOpenIDMixin, BaseApp): # type:ignore[misc]
12
+ """
13
+ An OAuth2 client class that supports OpenID Connect. Adapted from authlib's
14
+ `StarletteOAuth2App` to be useable without integration with Starlette.
15
+
16
+ https://github.com/lepture/authlib/blob/904d66bebd79bf39fb8814353a22bab7d3e092c4/authlib/integrations/starlette_client/apps.py#L58
17
+ """
18
+
19
+ client_cls = AsyncHttpxOAuth2Client
20
+
21
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
22
+ super().__init__(framework=None, *args, **kwargs)
23
+
24
+
25
+ class OAuth2Clients:
26
+ def __init__(self) -> None:
27
+ self._clients: Dict[str, OAuth2Client] = {}
28
+
29
+ def add_client(self, config: OAuth2ClientConfig) -> None:
30
+ if (idp_name := config.idp_name) in self._clients:
31
+ raise ValueError(f"oauth client already registered: {idp_name}")
32
+ client = OAuth2Client(
33
+ client_id=config.client_id,
34
+ client_secret=config.client_secret,
35
+ server_metadata_url=config.oidc_config_url,
36
+ client_kwargs={"scope": "openid email profile"},
37
+ )
38
+ assert isinstance(client, OAuth2Client)
39
+ self._clients[config.idp_name] = client
40
+
41
+ def get_client(self, idp_name: str) -> OAuth2Client:
42
+ if (client := self._clients.get(idp_name)) is None:
43
+ raise ValueError(f"unknown or unregistered OAuth2 client: {idp_name}")
44
+ return client
45
+
46
+ @classmethod
47
+ def from_configs(cls, configs: Iterable[OAuth2ClientConfig]) -> "OAuth2Clients":
48
+ oauth2_clients = cls()
49
+ for config in configs:
50
+ oauth2_clients.add_client(config)
51
+ return oauth2_clients
@@ -50,6 +50,26 @@ BULK_LOADER_EXCEPTIONS = Counter(
50
50
  documentation="Total count of bulk loader exceptions",
51
51
  )
52
52
 
53
+ RATE_LIMITER_CACHE_SIZE = Gauge(
54
+ name="rate_limiter_cache_size",
55
+ documentation="Current size of the rate limiter cache",
56
+ )
57
+
58
+ RATE_LIMITER_THROTTLES = Counter(
59
+ name="rate_limiter_throttles_total",
60
+ documentation="Total count of rate limiter throttles",
61
+ )
62
+
63
+ JWT_STORE_TOKENS_ACTIVE = Gauge(
64
+ name="jwt_store_tokens_active",
65
+ documentation="Current number of refresh tokens in the JWT store",
66
+ )
67
+
68
+ JWT_STORE_API_KEYS_ACTIVE = Gauge(
69
+ name="jwt_store_api_keys_active",
70
+ documentation="Current number of API keys in the JWT store",
71
+ )
72
+
53
73
 
54
74
  class PrometheusMiddleware(BaseHTTPMiddleware):
55
75
  async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
@@ -0,0 +1,191 @@
1
+ import re
2
+ import time
3
+ from collections import defaultdict
4
+ from functools import partial
5
+ from typing import (
6
+ Any,
7
+ Callable,
8
+ Coroutine,
9
+ DefaultDict,
10
+ List,
11
+ Optional,
12
+ Pattern, # import from re module when we drop support for 3.8
13
+ Union,
14
+ )
15
+
16
+ from fastapi import HTTPException, Request
17
+
18
+ from phoenix.config import get_env_enable_prometheus
19
+ from phoenix.exceptions import PhoenixException
20
+
21
+
22
+ class UnavailableTokensError(PhoenixException):
23
+ pass
24
+
25
+
26
+ class TokenBucket:
27
+ """
28
+ An implementation of the token-bucket algorithm for use as a rate limiter.
29
+
30
+ Args:
31
+ per_second_request_rate (float): The allowed request rate.
32
+ enforcement_window_minutes (float): The time window over which the rate limit is enforced.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ per_second_request_rate: float,
38
+ enforcement_window_seconds: float = 1,
39
+ ):
40
+ self.enforcement_window = enforcement_window_seconds
41
+ self.rate = per_second_request_rate
42
+
43
+ now = time.time()
44
+ self.last_checked = now
45
+ self.tokens = self.max_tokens()
46
+
47
+ def max_tokens(self) -> float:
48
+ return self.rate * self.enforcement_window
49
+
50
+ def available_tokens(self) -> float:
51
+ now = time.time()
52
+ time_since_last_checked = now - self.last_checked
53
+ self.tokens = min(self.max_tokens(), self.rate * time_since_last_checked + self.tokens)
54
+ self.last_checked = now
55
+ return self.tokens
56
+
57
+ def make_request_if_ready(self) -> None:
58
+ if self.available_tokens() < 1:
59
+ if get_env_enable_prometheus():
60
+ from phoenix.server.prometheus import RATE_LIMITER_THROTTLES
61
+
62
+ RATE_LIMITER_THROTTLES.inc()
63
+ raise UnavailableTokensError
64
+ self.tokens -= 1
65
+
66
+
67
+ class ServerRateLimiter:
68
+ """
69
+ This rate limiter holds a cache of token buckets that enforce rate limits.
70
+
71
+ The cache is kept in partitions that rotate every `partition_seconds`. Each user's rate limiter
72
+ can be accessed from all active partitions, the number of active partitions is set with
73
+ `active_partitions`. This guarantees that a user's rate limiter will sit in the cache for at
74
+ least:
75
+
76
+ minimum_cache_lifetime = (active_partitions - 1) * partition_seconds
77
+
78
+ Every time the cache is accessed, inactive partitions are purged. If enough time has passed,
79
+ the entire cache is purged.
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ per_second_rate_limit: float = 0.5,
85
+ enforcement_window_seconds: float = 5,
86
+ partition_seconds: float = 60,
87
+ active_partitions: int = 2,
88
+ ):
89
+ self.bucket_factory = partial(
90
+ TokenBucket,
91
+ per_second_request_rate=per_second_rate_limit,
92
+ enforcement_window_seconds=enforcement_window_seconds,
93
+ )
94
+ self.partition_seconds = partition_seconds
95
+ self.active_partitions = active_partitions
96
+ self.num_partitions = active_partitions + 2 # two overflow partitions to avoid edge cases
97
+ self._reset_rate_limiters()
98
+ self._last_cleanup_time = time.time()
99
+
100
+ def _reset_rate_limiters(self) -> None:
101
+ self.cache_partitions: List[DefaultDict[Any, TokenBucket]] = [
102
+ defaultdict(self.bucket_factory) for _ in range(self.num_partitions)
103
+ ]
104
+
105
+ def _current_partition_index(self, timestamp: float) -> int:
106
+ return (
107
+ int(timestamp // self.partition_seconds) % self.num_partitions
108
+ ) # a cyclic bucket index
109
+
110
+ def _active_partition_indices(self, current_index: int) -> List[int]:
111
+ return [(current_index - ii) % self.num_partitions for ii in range(self.active_partitions)]
112
+
113
+ def _inactive_partition_indices(self, current_index: int) -> List[int]:
114
+ active_indices = set(self._active_partition_indices(current_index))
115
+ all_indices = set(range(self.num_partitions))
116
+ return list(all_indices - active_indices)
117
+
118
+ def _cleanup_expired_limiters(self, request_time: float) -> None:
119
+ time_since_last_cleanup = request_time - self._last_cleanup_time
120
+ if time_since_last_cleanup >= ((self.num_partitions - 1) * self.partition_seconds):
121
+ # Reset the cache to avoid "looping" back to the same partitions
122
+ self._reset_rate_limiters()
123
+ self._last_cleanup_time = request_time
124
+ return
125
+
126
+ current_partition_index = self._current_partition_index(request_time)
127
+ inactive_indices = self._inactive_partition_indices(current_partition_index)
128
+ for ii in inactive_indices:
129
+ self.cache_partitions[ii] = defaultdict(self.bucket_factory)
130
+ self._last_cleanup_time = request_time
131
+
132
+ def _fetch_token_bucket(self, key: str, request_time: float) -> TokenBucket:
133
+ current_partition_index = self._current_partition_index(request_time)
134
+ active_indices = self._active_partition_indices(current_partition_index)
135
+ bucket: Optional[TokenBucket] = None
136
+ for ii in active_indices:
137
+ partition = self.cache_partitions[ii]
138
+ if key in partition:
139
+ bucket = partition.pop(key)
140
+ break
141
+
142
+ current_partition = self.cache_partitions[current_partition_index]
143
+ if key not in current_partition and bucket is not None:
144
+ current_partition[key] = bucket
145
+ return current_partition[key]
146
+
147
+ def make_request(self, key: str) -> None:
148
+ request_time = time.time()
149
+ self._cleanup_expired_limiters(request_time)
150
+ rate_limiter = self._fetch_token_bucket(key, request_time)
151
+ rate_limiter.make_request_if_ready()
152
+ if get_env_enable_prometheus():
153
+ from phoenix.server.prometheus import RATE_LIMITER_CACHE_SIZE
154
+
155
+ RATE_LIMITER_CACHE_SIZE.set(sum(len(partition) for partition in self.cache_partitions))
156
+
157
+
158
+ def fastapi_ip_rate_limiter(
159
+ rate_limiter: ServerRateLimiter, paths: Optional[List[Union[str, Pattern[str]]]] = None
160
+ ) -> Callable[[Request], Coroutine[Any, Any, Request]]:
161
+ async def dependency(request: Request) -> Request:
162
+ if paths is None or any(path_match(request.url.path, path) for path in paths):
163
+ client = request.client
164
+ if client: # bypasses rate limiter if no client
165
+ client_ip = client.host
166
+ try:
167
+ rate_limiter.make_request(client_ip)
168
+ except UnavailableTokensError:
169
+ raise HTTPException(status_code=429, detail="Too Many Requests")
170
+ return request
171
+
172
+ return dependency
173
+
174
+
175
+ def fastapi_route_rate_limiter(
176
+ rate_limiter: ServerRateLimiter,
177
+ ) -> Callable[[Request], Coroutine[Any, Any, Request]]:
178
+ async def dependency(request: Request) -> Request:
179
+ try:
180
+ rate_limiter.make_request(request.url.path)
181
+ except UnavailableTokensError:
182
+ raise HTTPException(status_code=429, detail="Too Many Requests")
183
+ return request
184
+
185
+ return dependency
186
+
187
+
188
+ def path_match(path: str, match_pattern: Union[str, Pattern[str]]) -> bool:
189
+ if isinstance(match_pattern, re.Pattern):
190
+ return bool(match_pattern.match(path))
191
+ return path == match_pattern
@@ -1,32 +1,32 @@
1
1
  {
2
- "_components-Dte7_KRd.js": {
3
- "file": "assets/components-Dte7_KRd.js",
2
+ "_components-REunxTt6.js": {
3
+ "file": "assets/components-REunxTt6.js",
4
4
  "name": "components",
5
5
  "imports": [
6
- "_vendor-BC3OPQuM.js",
7
- "_vendor-arizeai-NjB3cZzD.js",
8
- "_pages-CnTvEGEN.js",
6
+ "_vendor-B5IC0ivG.js",
7
+ "_vendor-arizeai-aFbT4kl1.js",
8
+ "_pages-1VrMk2pW.js",
9
9
  "_vendor-three-DwGkEfCM.js",
10
- "_vendor-codemirror-gE_JCOgX.js"
10
+ "_vendor-codemirror-BEGorXSV.js"
11
11
  ]
12
12
  },
13
- "_pages-CnTvEGEN.js": {
14
- "file": "assets/pages-CnTvEGEN.js",
13
+ "_pages-1VrMk2pW.js": {
14
+ "file": "assets/pages-1VrMk2pW.js",
15
15
  "name": "pages",
16
16
  "imports": [
17
- "_vendor-BC3OPQuM.js",
18
- "_components-Dte7_KRd.js",
19
- "_vendor-arizeai-NjB3cZzD.js",
20
- "_vendor-recharts-BXLYwcXF.js",
21
- "_vendor-codemirror-gE_JCOgX.js"
17
+ "_vendor-B5IC0ivG.js",
18
+ "_vendor-arizeai-aFbT4kl1.js",
19
+ "_components-REunxTt6.js",
20
+ "_vendor-recharts-6nUU7gU_.js",
21
+ "_vendor-codemirror-BEGorXSV.js"
22
22
  ]
23
23
  },
24
24
  "_vendor-!~{003}~.js": {
25
25
  "file": "assets/vendor-DxkFTwjz.css",
26
26
  "src": "_vendor-!~{003}~.js"
27
27
  },
28
- "_vendor-BC3OPQuM.js": {
29
- "file": "assets/vendor-BC3OPQuM.js",
28
+ "_vendor-B5IC0ivG.js": {
29
+ "file": "assets/vendor-B5IC0ivG.js",
30
30
  "name": "vendor",
31
31
  "imports": [
32
32
  "_vendor-three-DwGkEfCM.js"
@@ -35,25 +35,25 @@
35
35
  "assets/vendor-DxkFTwjz.css"
36
36
  ]
37
37
  },
38
- "_vendor-arizeai-NjB3cZzD.js": {
39
- "file": "assets/vendor-arizeai-NjB3cZzD.js",
38
+ "_vendor-arizeai-aFbT4kl1.js": {
39
+ "file": "assets/vendor-arizeai-aFbT4kl1.js",
40
40
  "name": "vendor-arizeai",
41
41
  "imports": [
42
- "_vendor-BC3OPQuM.js"
42
+ "_vendor-B5IC0ivG.js"
43
43
  ]
44
44
  },
45
- "_vendor-codemirror-gE_JCOgX.js": {
46
- "file": "assets/vendor-codemirror-gE_JCOgX.js",
45
+ "_vendor-codemirror-BEGorXSV.js": {
46
+ "file": "assets/vendor-codemirror-BEGorXSV.js",
47
47
  "name": "vendor-codemirror",
48
48
  "imports": [
49
- "_vendor-BC3OPQuM.js"
49
+ "_vendor-B5IC0ivG.js"
50
50
  ]
51
51
  },
52
- "_vendor-recharts-BXLYwcXF.js": {
53
- "file": "assets/vendor-recharts-BXLYwcXF.js",
52
+ "_vendor-recharts-6nUU7gU_.js": {
53
+ "file": "assets/vendor-recharts-6nUU7gU_.js",
54
54
  "name": "vendor-recharts",
55
55
  "imports": [
56
- "_vendor-BC3OPQuM.js"
56
+ "_vendor-B5IC0ivG.js"
57
57
  ]
58
58
  },
59
59
  "_vendor-three-DwGkEfCM.js": {
@@ -61,18 +61,18 @@
61
61
  "name": "vendor-three"
62
62
  },
63
63
  "index.tsx": {
64
- "file": "assets/index-fq1-hCK4.js",
64
+ "file": "assets/index-DAPJxlCw.js",
65
65
  "name": "index",
66
66
  "src": "index.tsx",
67
67
  "isEntry": true,
68
68
  "imports": [
69
- "_vendor-BC3OPQuM.js",
70
- "_vendor-arizeai-NjB3cZzD.js",
71
- "_pages-CnTvEGEN.js",
72
- "_components-Dte7_KRd.js",
69
+ "_vendor-B5IC0ivG.js",
70
+ "_vendor-arizeai-aFbT4kl1.js",
71
+ "_pages-1VrMk2pW.js",
72
+ "_components-REunxTt6.js",
73
73
  "_vendor-three-DwGkEfCM.js",
74
- "_vendor-recharts-BXLYwcXF.js",
75
- "_vendor-codemirror-gE_JCOgX.js"
74
+ "_vendor-recharts-6nUU7gU_.js",
75
+ "_vendor-codemirror-BEGorXSV.js"
76
76
  ]
77
77
  }
78
78
  }