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.
- {arize_phoenix-4.35.2.dist-info → arize_phoenix-5.0.0.dist-info}/METADATA +10 -12
- {arize_phoenix-4.35.2.dist-info → arize_phoenix-5.0.0.dist-info}/RECORD +92 -79
- phoenix/__init__.py +86 -0
- phoenix/auth.py +275 -14
- phoenix/config.py +369 -27
- phoenix/db/alembic.ini +0 -34
- phoenix/db/engines.py +27 -10
- phoenix/db/enums.py +20 -0
- phoenix/db/facilitator.py +112 -0
- phoenix/db/insertion/dataset.py +0 -1
- phoenix/db/insertion/types.py +1 -1
- phoenix/db/migrate.py +3 -3
- phoenix/db/migrations/env.py +0 -7
- phoenix/db/migrations/versions/cd164e83824f_users_and_tokens.py +157 -0
- phoenix/db/models.py +145 -60
- phoenix/experiments/evaluators/code_evaluators.py +9 -3
- phoenix/experiments/functions.py +1 -4
- phoenix/inferences/fixtures.py +0 -1
- phoenix/inferences/inferences.py +0 -1
- phoenix/logging/__init__.py +3 -0
- phoenix/logging/_config.py +90 -0
- phoenix/logging/_filter.py +6 -0
- phoenix/logging/_formatter.py +69 -0
- phoenix/metrics/__init__.py +0 -1
- phoenix/otel/settings.py +4 -4
- phoenix/server/api/README.md +28 -0
- phoenix/server/api/auth.py +32 -0
- phoenix/server/api/context.py +50 -2
- phoenix/server/api/dataloaders/__init__.py +4 -0
- phoenix/server/api/dataloaders/user_roles.py +30 -0
- phoenix/server/api/dataloaders/users.py +33 -0
- phoenix/server/api/exceptions.py +7 -0
- phoenix/server/api/mutations/__init__.py +0 -2
- phoenix/server/api/mutations/api_key_mutations.py +104 -86
- phoenix/server/api/mutations/dataset_mutations.py +8 -8
- phoenix/server/api/mutations/experiment_mutations.py +2 -2
- phoenix/server/api/mutations/export_events_mutations.py +3 -3
- phoenix/server/api/mutations/project_mutations.py +3 -3
- phoenix/server/api/mutations/span_annotations_mutations.py +4 -4
- phoenix/server/api/mutations/trace_annotations_mutations.py +4 -4
- phoenix/server/api/mutations/user_mutations.py +282 -42
- phoenix/server/api/openapi/schema.py +2 -2
- phoenix/server/api/queries.py +48 -39
- phoenix/server/api/routers/__init__.py +11 -0
- phoenix/server/api/routers/auth.py +284 -0
- phoenix/server/api/routers/embeddings.py +26 -0
- phoenix/server/api/routers/oauth2.py +456 -0
- phoenix/server/api/routers/v1/__init__.py +38 -16
- phoenix/server/api/routers/v1/datasets.py +0 -1
- phoenix/server/api/types/ApiKey.py +11 -0
- phoenix/server/api/types/AuthMethod.py +9 -0
- phoenix/server/api/types/User.py +48 -4
- phoenix/server/api/types/UserApiKey.py +35 -1
- phoenix/server/api/types/UserRole.py +7 -0
- phoenix/server/app.py +105 -34
- phoenix/server/bearer_auth.py +161 -0
- phoenix/server/email/__init__.py +0 -0
- phoenix/server/email/sender.py +26 -0
- phoenix/server/email/templates/__init__.py +0 -0
- phoenix/server/email/templates/password_reset.html +19 -0
- phoenix/server/email/types.py +11 -0
- phoenix/server/grpc_server.py +6 -0
- phoenix/server/jwt_store.py +504 -0
- phoenix/server/main.py +61 -30
- phoenix/server/oauth2.py +51 -0
- phoenix/server/prometheus.py +20 -0
- phoenix/server/rate_limiters.py +191 -0
- phoenix/server/static/.vite/manifest.json +31 -31
- phoenix/server/static/assets/{components-Dte7_KRd.js → components-REunxTt6.js} +348 -286
- phoenix/server/static/assets/index-DAPJxlCw.js +101 -0
- phoenix/server/static/assets/{pages-CnTvEGEN.js → pages-1VrMk2pW.js} +559 -291
- phoenix/server/static/assets/{vendor-BC3OPQuM.js → vendor-B5IC0ivG.js} +5 -5
- phoenix/server/static/assets/{vendor-arizeai-NjB3cZzD.js → vendor-arizeai-aFbT4kl1.js} +2 -2
- phoenix/server/static/assets/{vendor-codemirror-gE_JCOgX.js → vendor-codemirror-BEGorXSV.js} +1 -1
- phoenix/server/static/assets/{vendor-recharts-BXLYwcXF.js → vendor-recharts-6nUU7gU_.js} +1 -1
- phoenix/server/telemetry.py +2 -2
- phoenix/server/templates/index.html +1 -0
- phoenix/server/types.py +157 -1
- phoenix/services.py +0 -1
- phoenix/session/client.py +7 -3
- phoenix/session/evaluation.py +0 -1
- phoenix/session/session.py +0 -1
- phoenix/settings.py +9 -0
- phoenix/trace/exporter.py +0 -1
- phoenix/trace/fixtures.py +0 -2
- phoenix/utilities/client.py +16 -0
- phoenix/utilities/logging.py +9 -1
- phoenix/utilities/re.py +3 -3
- phoenix/version.py +1 -1
- phoenix/db/migrations/future_versions/README.md +0 -4
- phoenix/db/migrations/future_versions/cd164e83824f_users_and_tokens.py +0 -293
- phoenix/db/migrations/versions/.gitignore +0 -1
- phoenix/server/api/mutations/auth.py +0 -18
- phoenix/server/api/mutations/auth_mutations.py +0 -65
- phoenix/server/static/assets/index-fq1-hCK4.js +0 -100
- phoenix/trace/langchain/__init__.py +0 -3
- phoenix/trace/langchain/instrumentor.py +0 -35
- phoenix/trace/llama_index/__init__.py +0 -3
- phoenix/trace/llama_index/callback.py +0 -103
- phoenix/trace/openai/__init__.py +0 -3
- phoenix/trace/openai/instrumentor.py +0 -31
- {arize_phoenix-4.35.2.dist-info → arize_phoenix-5.0.0.dist-info}/WHEEL +0 -0
- {arize_phoenix-4.35.2.dist-info → arize_phoenix-5.0.0.dist-info}/licenses/IP_NOTICE +0 -0
- {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
|
-
|
|
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
|
-
|
|
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 =
|
|
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()
|
phoenix/server/oauth2.py
ADDED
|
@@ -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
|
phoenix/server/prometheus.py
CHANGED
|
@@ -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-
|
|
3
|
-
"file": "assets/components-
|
|
2
|
+
"_components-REunxTt6.js": {
|
|
3
|
+
"file": "assets/components-REunxTt6.js",
|
|
4
4
|
"name": "components",
|
|
5
5
|
"imports": [
|
|
6
|
-
"_vendor-
|
|
7
|
-
"_vendor-arizeai-
|
|
8
|
-
"_pages-
|
|
6
|
+
"_vendor-B5IC0ivG.js",
|
|
7
|
+
"_vendor-arizeai-aFbT4kl1.js",
|
|
8
|
+
"_pages-1VrMk2pW.js",
|
|
9
9
|
"_vendor-three-DwGkEfCM.js",
|
|
10
|
-
"_vendor-codemirror-
|
|
10
|
+
"_vendor-codemirror-BEGorXSV.js"
|
|
11
11
|
]
|
|
12
12
|
},
|
|
13
|
-
"_pages-
|
|
14
|
-
"file": "assets/pages-
|
|
13
|
+
"_pages-1VrMk2pW.js": {
|
|
14
|
+
"file": "assets/pages-1VrMk2pW.js",
|
|
15
15
|
"name": "pages",
|
|
16
16
|
"imports": [
|
|
17
|
-
"_vendor-
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"_vendor-recharts-
|
|
21
|
-
"_vendor-codemirror-
|
|
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-
|
|
29
|
-
"file": "assets/vendor-
|
|
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-
|
|
39
|
-
"file": "assets/vendor-arizeai-
|
|
38
|
+
"_vendor-arizeai-aFbT4kl1.js": {
|
|
39
|
+
"file": "assets/vendor-arizeai-aFbT4kl1.js",
|
|
40
40
|
"name": "vendor-arizeai",
|
|
41
41
|
"imports": [
|
|
42
|
-
"_vendor-
|
|
42
|
+
"_vendor-B5IC0ivG.js"
|
|
43
43
|
]
|
|
44
44
|
},
|
|
45
|
-
"_vendor-codemirror-
|
|
46
|
-
"file": "assets/vendor-codemirror-
|
|
45
|
+
"_vendor-codemirror-BEGorXSV.js": {
|
|
46
|
+
"file": "assets/vendor-codemirror-BEGorXSV.js",
|
|
47
47
|
"name": "vendor-codemirror",
|
|
48
48
|
"imports": [
|
|
49
|
-
"_vendor-
|
|
49
|
+
"_vendor-B5IC0ivG.js"
|
|
50
50
|
]
|
|
51
51
|
},
|
|
52
|
-
"_vendor-recharts-
|
|
53
|
-
"file": "assets/vendor-recharts-
|
|
52
|
+
"_vendor-recharts-6nUU7gU_.js": {
|
|
53
|
+
"file": "assets/vendor-recharts-6nUU7gU_.js",
|
|
54
54
|
"name": "vendor-recharts",
|
|
55
55
|
"imports": [
|
|
56
|
-
"_vendor-
|
|
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-
|
|
64
|
+
"file": "assets/index-DAPJxlCw.js",
|
|
65
65
|
"name": "index",
|
|
66
66
|
"src": "index.tsx",
|
|
67
67
|
"isEntry": true,
|
|
68
68
|
"imports": [
|
|
69
|
-
"_vendor-
|
|
70
|
-
"_vendor-arizeai-
|
|
71
|
-
"_pages-
|
|
72
|
-
"_components-
|
|
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-
|
|
75
|
-
"_vendor-codemirror-
|
|
74
|
+
"_vendor-recharts-6nUU7gU_.js",
|
|
75
|
+
"_vendor-codemirror-BEGorXSV.js"
|
|
76
76
|
]
|
|
77
77
|
}
|
|
78
78
|
}
|