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/config.py
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
|
+
import logging
|
|
1
2
|
import os
|
|
2
3
|
import re
|
|
3
4
|
import tempfile
|
|
4
|
-
from
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
from enum import Enum
|
|
5
8
|
from pathlib import Path
|
|
6
|
-
from typing import Dict, List, Optional, Tuple
|
|
9
|
+
from typing import Dict, List, Optional, Tuple, overload
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
from phoenix.utilities.logging import log_a_list
|
|
7
13
|
|
|
8
14
|
from .utilities.re import parse_env_headers
|
|
9
15
|
|
|
10
|
-
logger = getLogger(__name__)
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
11
17
|
|
|
12
18
|
# Phoenix environment variables
|
|
13
19
|
ENV_PHOENIX_PORT = "PHOENIX_PORT"
|
|
@@ -56,6 +62,24 @@ ENV_PHOENIX_ENABLE_PROMETHEUS = "PHOENIX_ENABLE_PROMETHEUS"
|
|
|
56
62
|
"""
|
|
57
63
|
Whether to enable Prometheus. Defaults to false.
|
|
58
64
|
"""
|
|
65
|
+
ENV_LOGGING_MODE = "PHOENIX_LOGGING_MODE"
|
|
66
|
+
"""
|
|
67
|
+
The logging mode (either 'default' or 'structured').
|
|
68
|
+
"""
|
|
69
|
+
ENV_LOGGING_LEVEL = "PHOENIX_LOGGING_LEVEL"
|
|
70
|
+
"""
|
|
71
|
+
The logging level ('debug', 'info', 'warning', 'error', 'critical') for the Phoenix backend. For
|
|
72
|
+
database logging see ENV_DB_LOGGING_LEVEL. Defaults to info.
|
|
73
|
+
"""
|
|
74
|
+
ENV_DB_LOGGING_LEVEL = "PHOENIX_DB_LOGGING_LEVEL"
|
|
75
|
+
"""
|
|
76
|
+
The logging level ('debug', 'info', 'warning', 'error', 'critical') for the Phoenix ORM.
|
|
77
|
+
Defaults to warning.
|
|
78
|
+
"""
|
|
79
|
+
ENV_LOG_MIGRATIONS = "PHOENIX_LOG_MIGRATIONS"
|
|
80
|
+
"""
|
|
81
|
+
Whether or not to log migrations. Defaults to true.
|
|
82
|
+
"""
|
|
59
83
|
|
|
60
84
|
# Phoenix server OpenTelemetry instrumentation environment variables
|
|
61
85
|
ENV_PHOENIX_SERVER_INSTRUMENTATION_OTLP_TRACE_COLLECTOR_HTTP_ENDPOINT = (
|
|
@@ -65,10 +89,50 @@ ENV_PHOENIX_SERVER_INSTRUMENTATION_OTLP_TRACE_COLLECTOR_GRPC_ENDPOINT = (
|
|
|
65
89
|
"PHOENIX_SERVER_INSTRUMENTATION_OTLP_TRACE_COLLECTOR_GRPC_ENDPOINT"
|
|
66
90
|
)
|
|
67
91
|
|
|
68
|
-
#
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
92
|
+
# Authentication settings
|
|
93
|
+
ENV_PHOENIX_ENABLE_AUTH = "PHOENIX_ENABLE_AUTH"
|
|
94
|
+
ENV_PHOENIX_DISABLE_RATE_LIMIT = "PHOENIX_DISABLE_RATE_LIMIT"
|
|
95
|
+
ENV_PHOENIX_SECRET = "PHOENIX_SECRET"
|
|
96
|
+
ENV_PHOENIX_API_KEY = "PHOENIX_API_KEY"
|
|
97
|
+
ENV_PHOENIX_USE_SECURE_COOKIES = "PHOENIX_USE_SECURE_COOKIES"
|
|
98
|
+
ENV_PHOENIX_ACCESS_TOKEN_EXPIRY_MINUTES = "PHOENIX_ACCESS_TOKEN_EXPIRY_MINUTES"
|
|
99
|
+
"""
|
|
100
|
+
The duration, in minutes, before access tokens expire.
|
|
101
|
+
"""
|
|
102
|
+
ENV_PHOENIX_REFRESH_TOKEN_EXPIRY_MINUTES = "PHOENIX_REFRESH_TOKEN_EXPIRY_MINUTES"
|
|
103
|
+
"""
|
|
104
|
+
The duration, in minutes, before refresh tokens expire.
|
|
105
|
+
"""
|
|
106
|
+
ENV_PHOENIX_PASSWORD_RESET_TOKEN_EXPIRY_MINUTES = "PHOENIX_PASSWORD_RESET_TOKEN_EXPIRY_MINUTES"
|
|
107
|
+
"""
|
|
108
|
+
The duration, in minutes, before password reset tokens expire.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
# SMTP settings
|
|
112
|
+
ENV_PHOENIX_SMTP_HOSTNAME = "PHOENIX_SMTP_HOSTNAME"
|
|
113
|
+
"""
|
|
114
|
+
The SMTP server hostname to use for sending emails. SMTP is disabled if this is not set.
|
|
115
|
+
"""
|
|
116
|
+
ENV_PHOENIX_SMTP_PORT = "PHOENIX_SMTP_PORT"
|
|
117
|
+
"""
|
|
118
|
+
The SMTP server port to use for sending emails. Defaults to 587.
|
|
119
|
+
"""
|
|
120
|
+
ENV_PHOENIX_SMTP_USERNAME = "PHOENIX_SMTP_USERNAME"
|
|
121
|
+
"""
|
|
122
|
+
The SMTP server username to use for sending emails. Should be set if SMTP is enabled.
|
|
123
|
+
"""
|
|
124
|
+
ENV_PHOENIX_SMTP_PASSWORD = "PHOENIX_SMTP_PASSWORD"
|
|
125
|
+
"""
|
|
126
|
+
The SMTP server password to use for sending emails. Should be set if SMTP is enabled.
|
|
127
|
+
"""
|
|
128
|
+
ENV_PHOENIX_SMTP_MAIL_FROM = "PHOENIX_SMTP_MAIL_FROM"
|
|
129
|
+
"""
|
|
130
|
+
The email address to use as the sender when sending emails. Should be set if SMTP is enabled.
|
|
131
|
+
"""
|
|
132
|
+
ENV_PHOENIX_SMTP_VALIDATE_CERTS = "PHOENIX_SMTP_VALIDATE_CERTS"
|
|
133
|
+
"""
|
|
134
|
+
Whether to validate SMTP server certificates. Defaults to true.
|
|
135
|
+
"""
|
|
72
136
|
|
|
73
137
|
|
|
74
138
|
def server_instrumentation_is_enabled() -> bool:
|
|
@@ -110,12 +174,16 @@ def get_working_dir() -> Path:
|
|
|
110
174
|
return Path.home().resolve() / ".phoenix"
|
|
111
175
|
|
|
112
176
|
|
|
113
|
-
|
|
177
|
+
@overload
|
|
178
|
+
def _bool_val(env_var: str) -> Optional[bool]: ...
|
|
179
|
+
@overload
|
|
180
|
+
def _bool_val(env_var: str, default: bool) -> bool: ...
|
|
181
|
+
def _bool_val(env_var: str, default: Optional[bool] = None) -> Optional[bool]:
|
|
114
182
|
"""
|
|
115
|
-
Parses a boolean environment variable, returning
|
|
183
|
+
Parses a boolean environment variable, returning `default` if the variable is not set.
|
|
116
184
|
"""
|
|
117
185
|
if (value := os.environ.get(env_var)) is None:
|
|
118
|
-
return
|
|
186
|
+
return default
|
|
119
187
|
assert (lower := value.lower()) in (
|
|
120
188
|
"true",
|
|
121
189
|
"false",
|
|
@@ -123,44 +191,234 @@ def get_boolean_env_var(env_var: str) -> Optional[bool]:
|
|
|
123
191
|
return lower == "true"
|
|
124
192
|
|
|
125
193
|
|
|
194
|
+
@overload
|
|
195
|
+
def _float_val(env_var: str) -> Optional[float]: ...
|
|
196
|
+
@overload
|
|
197
|
+
def _float_val(env_var: str, default: float) -> float: ...
|
|
198
|
+
def _float_val(env_var: str, default: Optional[float] = None) -> Optional[float]:
|
|
199
|
+
"""
|
|
200
|
+
Parses a numeric environment variable, returning `default` if the variable is not set.
|
|
201
|
+
"""
|
|
202
|
+
if (value := os.environ.get(env_var)) is None:
|
|
203
|
+
return default
|
|
204
|
+
try:
|
|
205
|
+
return float(value)
|
|
206
|
+
except ValueError:
|
|
207
|
+
raise ValueError(
|
|
208
|
+
f"Invalid value for environment variable {env_var}: {value}. "
|
|
209
|
+
f"Value must be a number."
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@overload
|
|
214
|
+
def _int_val(env_var: str) -> Optional[int]: ...
|
|
215
|
+
@overload
|
|
216
|
+
def _int_val(env_var: str, default: int) -> int: ...
|
|
217
|
+
def _int_val(env_var: str, default: Optional[int] = None) -> Optional[int]:
|
|
218
|
+
"""
|
|
219
|
+
Parses a numeric environment variable, returning `default` if the variable is not set.
|
|
220
|
+
"""
|
|
221
|
+
if (value := os.environ.get(env_var)) is None:
|
|
222
|
+
return default
|
|
223
|
+
try:
|
|
224
|
+
return int(value)
|
|
225
|
+
except ValueError:
|
|
226
|
+
raise ValueError(
|
|
227
|
+
f"Invalid value for environment variable {env_var}: {value}. "
|
|
228
|
+
f"Value must be an integer."
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
126
232
|
def get_env_enable_auth() -> bool:
|
|
127
233
|
"""
|
|
128
|
-
Gets the value of the
|
|
234
|
+
Gets the value of the PHOENIX_ENABLE_AUTH environment variable.
|
|
235
|
+
"""
|
|
236
|
+
return _bool_val(ENV_PHOENIX_ENABLE_AUTH, False)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def get_env_disable_rate_limit() -> bool:
|
|
240
|
+
"""
|
|
241
|
+
Gets the value of the PHOENIX_DISABLE_RATE_LIMIT environment variable.
|
|
129
242
|
"""
|
|
130
|
-
return
|
|
243
|
+
return _bool_val(ENV_PHOENIX_DISABLE_RATE_LIMIT, False)
|
|
131
244
|
|
|
132
245
|
|
|
133
246
|
def get_env_phoenix_secret() -> Optional[str]:
|
|
134
247
|
"""
|
|
135
|
-
Gets the value of the
|
|
248
|
+
Gets the value of the PHOENIX_SECRET environment variable
|
|
136
249
|
and performs validation.
|
|
137
250
|
"""
|
|
138
|
-
phoenix_secret = os.environ.get(
|
|
251
|
+
phoenix_secret = os.environ.get(ENV_PHOENIX_SECRET)
|
|
139
252
|
if phoenix_secret is None:
|
|
140
253
|
return None
|
|
141
|
-
|
|
254
|
+
from phoenix.auth import REQUIREMENTS_FOR_PHOENIX_SECRET
|
|
255
|
+
|
|
256
|
+
REQUIREMENTS_FOR_PHOENIX_SECRET.validate(phoenix_secret, "Phoenix secret")
|
|
142
257
|
return phoenix_secret
|
|
143
258
|
|
|
144
259
|
|
|
145
|
-
def
|
|
260
|
+
def get_env_phoenix_use_secure_cookies() -> bool:
|
|
261
|
+
return _bool_val(ENV_PHOENIX_USE_SECURE_COOKIES, False)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def get_env_phoenix_api_key() -> Optional[str]:
|
|
265
|
+
return os.environ.get(ENV_PHOENIX_API_KEY)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def get_env_auth_settings() -> Tuple[bool, Optional[str]]:
|
|
146
269
|
"""
|
|
147
270
|
Gets auth settings and performs validation.
|
|
148
271
|
"""
|
|
149
272
|
enable_auth = get_env_enable_auth()
|
|
150
273
|
phoenix_secret = get_env_phoenix_secret()
|
|
151
|
-
if enable_auth:
|
|
152
|
-
|
|
153
|
-
"
|
|
154
|
-
"
|
|
155
|
-
)
|
|
156
|
-
else:
|
|
157
|
-
assert not phoenix_secret, (
|
|
158
|
-
"DANGEROUSLY_SET_PHOENIX_SECRET cannot be set "
|
|
159
|
-
"unless auth is enabled with DANGEROUSLY_SET_PHOENIX_ENABLE_AUTH"
|
|
274
|
+
if enable_auth and not phoenix_secret:
|
|
275
|
+
raise ValueError(
|
|
276
|
+
f"`{ENV_PHOENIX_SECRET}` must be set when "
|
|
277
|
+
f"auth is enabled with `{ENV_PHOENIX_ENABLE_AUTH}`"
|
|
160
278
|
)
|
|
161
279
|
return enable_auth, phoenix_secret
|
|
162
280
|
|
|
163
281
|
|
|
282
|
+
def get_env_password_reset_token_expiry() -> timedelta:
|
|
283
|
+
"""
|
|
284
|
+
Gets the password reset token expiry.
|
|
285
|
+
"""
|
|
286
|
+
from phoenix.auth import DEFAULT_PASSWORD_RESET_TOKEN_EXPIRY_MINUTES
|
|
287
|
+
|
|
288
|
+
minutes = _float_val(
|
|
289
|
+
ENV_PHOENIX_PASSWORD_RESET_TOKEN_EXPIRY_MINUTES,
|
|
290
|
+
DEFAULT_PASSWORD_RESET_TOKEN_EXPIRY_MINUTES,
|
|
291
|
+
)
|
|
292
|
+
assert minutes > 0
|
|
293
|
+
return timedelta(minutes=minutes)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def get_env_access_token_expiry() -> timedelta:
|
|
297
|
+
"""
|
|
298
|
+
Gets the access token expiry.
|
|
299
|
+
"""
|
|
300
|
+
from phoenix.auth import DEFAULT_ACCESS_TOKEN_EXPIRY_MINUTES
|
|
301
|
+
|
|
302
|
+
minutes = _float_val(
|
|
303
|
+
ENV_PHOENIX_ACCESS_TOKEN_EXPIRY_MINUTES,
|
|
304
|
+
DEFAULT_ACCESS_TOKEN_EXPIRY_MINUTES,
|
|
305
|
+
)
|
|
306
|
+
assert minutes > 0
|
|
307
|
+
return timedelta(minutes=minutes)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def get_env_refresh_token_expiry() -> timedelta:
|
|
311
|
+
"""
|
|
312
|
+
Gets the refresh token expiry.
|
|
313
|
+
"""
|
|
314
|
+
from phoenix.auth import DEFAULT_REFRESH_TOKEN_EXPIRY_MINUTES
|
|
315
|
+
|
|
316
|
+
minutes = _float_val(
|
|
317
|
+
ENV_PHOENIX_REFRESH_TOKEN_EXPIRY_MINUTES,
|
|
318
|
+
DEFAULT_REFRESH_TOKEN_EXPIRY_MINUTES,
|
|
319
|
+
)
|
|
320
|
+
assert minutes > 0
|
|
321
|
+
return timedelta(minutes=minutes)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def get_env_smtp_username() -> str:
|
|
325
|
+
return os.getenv(ENV_PHOENIX_SMTP_USERNAME) or ""
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def get_env_smtp_password() -> str:
|
|
329
|
+
return os.getenv(ENV_PHOENIX_SMTP_PASSWORD) or ""
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def get_env_smtp_mail_from() -> str:
|
|
333
|
+
return os.getenv(ENV_PHOENIX_SMTP_MAIL_FROM) or "noreply@arize.com"
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def get_env_smtp_hostname() -> str:
|
|
337
|
+
return os.getenv(ENV_PHOENIX_SMTP_HOSTNAME) or ""
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def get_env_smtp_port() -> int:
|
|
341
|
+
port = _int_val(ENV_PHOENIX_SMTP_PORT, 587)
|
|
342
|
+
assert 0 < port <= 65_535
|
|
343
|
+
return port
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def get_env_smtp_validate_certs() -> bool:
|
|
347
|
+
return _bool_val(ENV_PHOENIX_SMTP_VALIDATE_CERTS, True)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@dataclass(frozen=True)
|
|
351
|
+
class OAuth2ClientConfig:
|
|
352
|
+
idp_name: str
|
|
353
|
+
idp_display_name: str
|
|
354
|
+
client_id: str
|
|
355
|
+
client_secret: str
|
|
356
|
+
oidc_config_url: str
|
|
357
|
+
|
|
358
|
+
@classmethod
|
|
359
|
+
def from_env(cls, idp_name: str) -> "OAuth2ClientConfig":
|
|
360
|
+
idp_name_upper = idp_name.upper()
|
|
361
|
+
if not (
|
|
362
|
+
client_id := os.getenv(
|
|
363
|
+
client_id_env_var := f"PHOENIX_OAUTH2_{idp_name_upper}_CLIENT_ID"
|
|
364
|
+
)
|
|
365
|
+
):
|
|
366
|
+
raise ValueError(
|
|
367
|
+
f"A client id must be set for the {idp_name} OAuth2 IDP "
|
|
368
|
+
f"via the {client_id_env_var} environment variable"
|
|
369
|
+
)
|
|
370
|
+
if not (
|
|
371
|
+
client_secret := os.getenv(
|
|
372
|
+
client_secret_env_var := f"PHOENIX_OAUTH2_{idp_name_upper}_CLIENT_SECRET"
|
|
373
|
+
)
|
|
374
|
+
):
|
|
375
|
+
raise ValueError(
|
|
376
|
+
f"A client secret must be set for the {idp_name} OAuth2 IDP "
|
|
377
|
+
f"via the {client_secret_env_var} environment variable"
|
|
378
|
+
)
|
|
379
|
+
if not (
|
|
380
|
+
oidc_config_url := (
|
|
381
|
+
os.getenv(
|
|
382
|
+
oidc_config_url_env_var := f"PHOENIX_OAUTH2_{idp_name_upper}_OIDC_CONFIG_URL",
|
|
383
|
+
)
|
|
384
|
+
)
|
|
385
|
+
):
|
|
386
|
+
raise ValueError(
|
|
387
|
+
f"An OpenID Connect configuration URL must be set for the {idp_name} OAuth2 IDP "
|
|
388
|
+
f"via the {oidc_config_url_env_var} environment variable"
|
|
389
|
+
)
|
|
390
|
+
if urlparse(oidc_config_url).scheme != "https":
|
|
391
|
+
raise ValueError(
|
|
392
|
+
f"Server metadata URL for {idp_name} OAuth2 IDP "
|
|
393
|
+
"must be a valid URL using the https protocol"
|
|
394
|
+
)
|
|
395
|
+
return cls(
|
|
396
|
+
idp_name=idp_name,
|
|
397
|
+
idp_display_name=os.getenv(
|
|
398
|
+
f"PHOENIX_OAUTH2_{idp_name_upper}_DISPLAY_NAME",
|
|
399
|
+
_get_default_idp_display_name(idp_name),
|
|
400
|
+
),
|
|
401
|
+
client_id=client_id,
|
|
402
|
+
client_secret=client_secret,
|
|
403
|
+
oidc_config_url=oidc_config_url,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def get_env_oauth2_settings() -> List[OAuth2ClientConfig]:
|
|
408
|
+
"""
|
|
409
|
+
Get OAuth2 settings from environment variables.
|
|
410
|
+
"""
|
|
411
|
+
|
|
412
|
+
idp_names = set()
|
|
413
|
+
pattern = re.compile(
|
|
414
|
+
r"^PHOENIX_OAUTH2_(\w+)_(DISPLAY_NAME|CLIENT_ID|CLIENT_SECRET|OIDC_CONFIG_URL)$"
|
|
415
|
+
)
|
|
416
|
+
for env_var in os.environ:
|
|
417
|
+
if (match := pattern.match(env_var)) is not None and (idp_name := match.group(1).lower()):
|
|
418
|
+
idp_names.add(idp_name)
|
|
419
|
+
return [OAuth2ClientConfig.from_env(idp_name) for idp_name in sorted(idp_names)]
|
|
420
|
+
|
|
421
|
+
|
|
164
422
|
PHOENIX_DIR = Path(__file__).resolve().parent
|
|
165
423
|
# Server config
|
|
166
424
|
SERVER_DIR = PHOENIX_DIR / "server"
|
|
@@ -184,8 +442,6 @@ EXPORT_DIR = ROOT_DIR / "exports"
|
|
|
184
442
|
INFERENCES_DIR = ROOT_DIR / "inferences"
|
|
185
443
|
TRACE_DATASETS_DIR = ROOT_DIR / "trace_datasets"
|
|
186
444
|
|
|
187
|
-
ENABLE_AUTH, PHOENIX_SECRET = get_auth_settings()
|
|
188
|
-
|
|
189
445
|
|
|
190
446
|
def ensure_working_dir() -> None:
|
|
191
447
|
"""
|
|
@@ -331,5 +587,91 @@ def get_web_base_url() -> str:
|
|
|
331
587
|
return get_base_url()
|
|
332
588
|
|
|
333
589
|
|
|
590
|
+
class LoggingMode(Enum):
|
|
591
|
+
DEFAULT = "default"
|
|
592
|
+
STRUCTURED = "structured"
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def get_env_logging_mode() -> LoggingMode:
|
|
596
|
+
if (logging_mode := os.getenv(ENV_LOGGING_MODE)) is None:
|
|
597
|
+
return LoggingMode.DEFAULT
|
|
598
|
+
try:
|
|
599
|
+
return LoggingMode(logging_mode.lower().strip())
|
|
600
|
+
except ValueError:
|
|
601
|
+
raise ValueError(
|
|
602
|
+
f"Invalid value `{logging_mode}` for env var `{ENV_LOGGING_MODE}`. "
|
|
603
|
+
f"Valid values are: {log_a_list([mode.value for mode in LoggingMode],'and')} "
|
|
604
|
+
"(case-insensitive)."
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def get_env_logging_level() -> int:
|
|
609
|
+
return _get_logging_level(
|
|
610
|
+
env_var=ENV_LOGGING_LEVEL,
|
|
611
|
+
default_level=logging.INFO,
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def get_env_db_logging_level() -> int:
|
|
616
|
+
return _get_logging_level(
|
|
617
|
+
env_var=ENV_DB_LOGGING_LEVEL,
|
|
618
|
+
default_level=logging.WARNING,
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _get_logging_level(env_var: str, default_level: int) -> int:
|
|
623
|
+
logging_level = os.getenv(env_var)
|
|
624
|
+
if not logging_level:
|
|
625
|
+
return default_level
|
|
626
|
+
|
|
627
|
+
# levelNamesMapping = logging.getLevelNamesMapping() is not supported in python 3.8
|
|
628
|
+
# but is supported in 3.12. Hence, we define the mapping ourselves and will remove
|
|
629
|
+
# this once we drop support for older python versions
|
|
630
|
+
levelNamesMapping = logging._nameToLevel.copy()
|
|
631
|
+
|
|
632
|
+
valid_values = [level for level in levelNamesMapping if level != "NOTSET"]
|
|
633
|
+
|
|
634
|
+
if logging_level.upper() not in valid_values:
|
|
635
|
+
raise ValueError(
|
|
636
|
+
f"Invalid value `{logging_level}` for env var `{env_var}`. "
|
|
637
|
+
f"Valid values are: {log_a_list(valid_values,'and')} (case-insensitive)."
|
|
638
|
+
)
|
|
639
|
+
return levelNamesMapping[logging_level.upper()]
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def get_env_log_migrations() -> bool:
|
|
643
|
+
log_migrations = os.getenv(ENV_LOG_MIGRATIONS)
|
|
644
|
+
# Default to True
|
|
645
|
+
if log_migrations is None:
|
|
646
|
+
return True
|
|
647
|
+
|
|
648
|
+
if log_migrations.lower() == "true":
|
|
649
|
+
return True
|
|
650
|
+
elif log_migrations.lower() == "false":
|
|
651
|
+
return False
|
|
652
|
+
else:
|
|
653
|
+
raise ValueError(
|
|
654
|
+
f"Invalid value for environment variable {ENV_LOG_MIGRATIONS}: "
|
|
655
|
+
f"{log_migrations}. Value values are 'TRUE' and 'FALSE' (case-insensitive)."
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
class OAuth2Idp(Enum):
|
|
660
|
+
AWS_COGNITO = "aws_cognito"
|
|
661
|
+
GOOGLE = "google"
|
|
662
|
+
MICROSOFT_ENTRA_ID = "microsoft_entra_id"
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _get_default_idp_display_name(idp_name: str) -> str:
|
|
666
|
+
"""
|
|
667
|
+
Get the default display name for an OAuth2 IDP.
|
|
668
|
+
"""
|
|
669
|
+
if idp_name == OAuth2Idp.AWS_COGNITO.value:
|
|
670
|
+
return "AWS Cognito"
|
|
671
|
+
if idp_name == OAuth2Idp.MICROSOFT_ENTRA_ID.value:
|
|
672
|
+
return "Microsoft Entra ID"
|
|
673
|
+
return idp_name.replace("_", " ").title()
|
|
674
|
+
|
|
675
|
+
|
|
334
676
|
DEFAULT_PROJECT_NAME = "default"
|
|
335
677
|
_KUBERNETES_PHOENIX_PORT_PATTERN = re.compile(r"^tcp://\d{1,3}[.]\d{1,3}[.]\d{1,3}[.]\d{1,3}:\d+$")
|
phoenix/db/alembic.ini
CHANGED
|
@@ -83,37 +83,3 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne
|
|
|
83
83
|
# ruff.executable = %(here)s/.venv/bin/ruff
|
|
84
84
|
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
|
85
85
|
|
|
86
|
-
# Logging configuration
|
|
87
|
-
[loggers]
|
|
88
|
-
keys = root,sqlalchemy,alembic
|
|
89
|
-
|
|
90
|
-
[handlers]
|
|
91
|
-
keys = console
|
|
92
|
-
|
|
93
|
-
[formatters]
|
|
94
|
-
keys = generic
|
|
95
|
-
|
|
96
|
-
[logger_root]
|
|
97
|
-
level = WARN
|
|
98
|
-
handlers = console
|
|
99
|
-
qualname =
|
|
100
|
-
|
|
101
|
-
[logger_sqlalchemy]
|
|
102
|
-
level = WARN
|
|
103
|
-
handlers =
|
|
104
|
-
qualname = sqlalchemy.engine
|
|
105
|
-
|
|
106
|
-
[logger_alembic]
|
|
107
|
-
level = WARN
|
|
108
|
-
handlers =
|
|
109
|
-
qualname = alembic
|
|
110
|
-
|
|
111
|
-
[handler_console]
|
|
112
|
-
class = StreamHandler
|
|
113
|
-
args = (sys.stderr,)
|
|
114
|
-
level = NOTSET
|
|
115
|
-
formatter = generic
|
|
116
|
-
|
|
117
|
-
[formatter_generic]
|
|
118
|
-
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
119
|
-
datefmt = %H:%M:%S
|
phoenix/db/engines.py
CHANGED
|
@@ -13,7 +13,7 @@ from sqlalchemy import URL, StaticPool, event, make_url
|
|
|
13
13
|
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
|
14
14
|
from typing_extensions import assert_never
|
|
15
15
|
|
|
16
|
-
from phoenix.config import get_env_database_schema
|
|
16
|
+
from phoenix.config import LoggingMode, get_env_database_schema
|
|
17
17
|
from phoenix.db.helpers import SupportedSQLDialect
|
|
18
18
|
from phoenix.db.migrate import migrate_in_thread
|
|
19
19
|
from phoenix.db.models import init_models
|
|
@@ -64,7 +64,7 @@ def get_async_db_url(connection_str: str) -> URL:
|
|
|
64
64
|
def create_engine(
|
|
65
65
|
connection_str: str,
|
|
66
66
|
migrate: bool = True,
|
|
67
|
-
|
|
67
|
+
log_to_stdout: bool = False,
|
|
68
68
|
) -> AsyncEngine:
|
|
69
69
|
"""
|
|
70
70
|
Factory to create a SQLAlchemy engine from a URL string.
|
|
@@ -74,10 +74,25 @@ def create_engine(
|
|
|
74
74
|
raise ValueError("Failed to parse database from connection string")
|
|
75
75
|
backend = SupportedSQLDialect(url.get_backend_name())
|
|
76
76
|
url = get_async_db_url(url.render_as_string(hide_password=False))
|
|
77
|
+
# If Phoenix is run as an application, we want to pass log_migrations_to_stdout=False
|
|
78
|
+
# and let the configured sqlalchemy logger handle the migration logs
|
|
79
|
+
log_migrations_to_stdout = (
|
|
80
|
+
Settings.log_migrations and Settings.logging_mode != LoggingMode.STRUCTURED
|
|
81
|
+
)
|
|
77
82
|
if backend is SupportedSQLDialect.SQLITE:
|
|
78
|
-
return aio_sqlite_engine(
|
|
83
|
+
return aio_sqlite_engine(
|
|
84
|
+
url=url,
|
|
85
|
+
migrate=migrate,
|
|
86
|
+
log_to_stdout=log_to_stdout,
|
|
87
|
+
log_migrations_to_stdout=log_migrations_to_stdout,
|
|
88
|
+
)
|
|
79
89
|
elif backend is SupportedSQLDialect.POSTGRESQL:
|
|
80
|
-
return aio_postgresql_engine(
|
|
90
|
+
return aio_postgresql_engine(
|
|
91
|
+
url=url,
|
|
92
|
+
migrate=migrate,
|
|
93
|
+
log_to_stdout=log_to_stdout,
|
|
94
|
+
log_migrations_to_stdout=log_migrations_to_stdout,
|
|
95
|
+
)
|
|
81
96
|
else:
|
|
82
97
|
assert_never(backend)
|
|
83
98
|
|
|
@@ -85,8 +100,9 @@ def create_engine(
|
|
|
85
100
|
def aio_sqlite_engine(
|
|
86
101
|
url: URL,
|
|
87
102
|
migrate: bool = True,
|
|
88
|
-
echo: bool = False,
|
|
89
103
|
shared_cache: bool = True,
|
|
104
|
+
log_to_stdout: bool = False,
|
|
105
|
+
log_migrations_to_stdout: bool = True,
|
|
90
106
|
) -> AsyncEngine:
|
|
91
107
|
database = url.database or ":memory:"
|
|
92
108
|
if database.startswith("file:"):
|
|
@@ -105,7 +121,7 @@ def aio_sqlite_engine(
|
|
|
105
121
|
|
|
106
122
|
engine = create_async_engine(
|
|
107
123
|
url=url,
|
|
108
|
-
echo=
|
|
124
|
+
echo=log_to_stdout,
|
|
109
125
|
json_serializer=_dumps,
|
|
110
126
|
async_creator=async_creator,
|
|
111
127
|
poolclass=StaticPool,
|
|
@@ -123,7 +139,7 @@ def aio_sqlite_engine(
|
|
|
123
139
|
else:
|
|
124
140
|
sync_engine = sqlalchemy.create_engine(
|
|
125
141
|
url=url.set(drivername="sqlite"),
|
|
126
|
-
echo=
|
|
142
|
+
echo=log_migrations_to_stdout,
|
|
127
143
|
json_serializer=_dumps,
|
|
128
144
|
creator=lambda: sqlean.connect(f"file:{database}", uri=True),
|
|
129
145
|
)
|
|
@@ -143,14 +159,15 @@ def set_postgresql_search_path(schema: str) -> Callable[[Connection, Any], None]
|
|
|
143
159
|
def aio_postgresql_engine(
|
|
144
160
|
url: URL,
|
|
145
161
|
migrate: bool = True,
|
|
146
|
-
|
|
162
|
+
log_to_stdout: bool = False,
|
|
163
|
+
log_migrations_to_stdout: bool = True,
|
|
147
164
|
) -> AsyncEngine:
|
|
148
|
-
engine = create_async_engine(url=url, echo=
|
|
165
|
+
engine = create_async_engine(url=url, echo=log_to_stdout, json_serializer=_dumps)
|
|
149
166
|
if not migrate:
|
|
150
167
|
return engine
|
|
151
168
|
sync_engine = sqlalchemy.create_engine(
|
|
152
169
|
url=url.set(drivername="postgresql+psycopg"),
|
|
153
|
-
echo=
|
|
170
|
+
echo=log_migrations_to_stdout,
|
|
154
171
|
json_serializer=_dumps,
|
|
155
172
|
)
|
|
156
173
|
if schema := get_env_database_schema():
|
phoenix/db/enums.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Mapping, Type
|
|
3
|
+
|
|
4
|
+
from sqlalchemy.orm import InstrumentedAttribute
|
|
5
|
+
|
|
6
|
+
from phoenix.db import models
|
|
7
|
+
from phoenix.db.models import AuthMethod
|
|
8
|
+
|
|
9
|
+
__all__ = ["AuthMethod", "UserRole", "COLUMN_ENUMS"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UserRole(Enum):
|
|
13
|
+
SYSTEM = "SYSTEM"
|
|
14
|
+
ADMIN = "ADMIN"
|
|
15
|
+
MEMBER = "MEMBER"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
COLUMN_ENUMS: Mapping[InstrumentedAttribute[str], Type[Enum]] = {
|
|
19
|
+
models.UserRole.name: UserRole,
|
|
20
|
+
}
|