arize-phoenix 4.36.0__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 (80) hide show
  1. {arize_phoenix-4.36.0.dist-info → arize_phoenix-5.0.0.dist-info}/METADATA +10 -12
  2. {arize_phoenix-4.36.0.dist-info → arize_phoenix-5.0.0.dist-info}/RECORD +68 -59
  3. phoenix/__init__.py +86 -0
  4. phoenix/auth.py +275 -14
  5. phoenix/config.py +277 -25
  6. phoenix/db/enums.py +20 -0
  7. phoenix/db/facilitator.py +112 -0
  8. phoenix/db/migrations/versions/cd164e83824f_users_and_tokens.py +157 -0
  9. phoenix/db/models.py +145 -60
  10. phoenix/experiments/evaluators/code_evaluators.py +9 -3
  11. phoenix/experiments/functions.py +1 -4
  12. phoenix/server/api/README.md +28 -0
  13. phoenix/server/api/auth.py +32 -0
  14. phoenix/server/api/context.py +50 -2
  15. phoenix/server/api/dataloaders/__init__.py +4 -0
  16. phoenix/server/api/dataloaders/user_roles.py +30 -0
  17. phoenix/server/api/dataloaders/users.py +33 -0
  18. phoenix/server/api/exceptions.py +7 -0
  19. phoenix/server/api/mutations/__init__.py +0 -2
  20. phoenix/server/api/mutations/api_key_mutations.py +104 -86
  21. phoenix/server/api/mutations/dataset_mutations.py +8 -8
  22. phoenix/server/api/mutations/experiment_mutations.py +2 -2
  23. phoenix/server/api/mutations/export_events_mutations.py +3 -3
  24. phoenix/server/api/mutations/project_mutations.py +3 -3
  25. phoenix/server/api/mutations/span_annotations_mutations.py +4 -4
  26. phoenix/server/api/mutations/trace_annotations_mutations.py +4 -4
  27. phoenix/server/api/mutations/user_mutations.py +282 -42
  28. phoenix/server/api/openapi/schema.py +2 -2
  29. phoenix/server/api/queries.py +48 -39
  30. phoenix/server/api/routers/__init__.py +11 -0
  31. phoenix/server/api/routers/auth.py +284 -0
  32. phoenix/server/api/routers/embeddings.py +26 -0
  33. phoenix/server/api/routers/oauth2.py +456 -0
  34. phoenix/server/api/routers/v1/__init__.py +38 -16
  35. phoenix/server/api/types/ApiKey.py +11 -0
  36. phoenix/server/api/types/AuthMethod.py +9 -0
  37. phoenix/server/api/types/User.py +48 -4
  38. phoenix/server/api/types/UserApiKey.py +35 -1
  39. phoenix/server/api/types/UserRole.py +7 -0
  40. phoenix/server/app.py +103 -31
  41. phoenix/server/bearer_auth.py +161 -0
  42. phoenix/server/email/__init__.py +0 -0
  43. phoenix/server/email/sender.py +26 -0
  44. phoenix/server/email/templates/__init__.py +0 -0
  45. phoenix/server/email/templates/password_reset.html +19 -0
  46. phoenix/server/email/types.py +11 -0
  47. phoenix/server/grpc_server.py +6 -0
  48. phoenix/server/jwt_store.py +504 -0
  49. phoenix/server/main.py +40 -9
  50. phoenix/server/oauth2.py +51 -0
  51. phoenix/server/prometheus.py +20 -0
  52. phoenix/server/rate_limiters.py +191 -0
  53. phoenix/server/static/.vite/manifest.json +31 -31
  54. phoenix/server/static/assets/{components-Dte7_KRd.js → components-REunxTt6.js} +348 -286
  55. phoenix/server/static/assets/index-DAPJxlCw.js +101 -0
  56. phoenix/server/static/assets/{pages-CnTvEGEN.js → pages-1VrMk2pW.js} +559 -291
  57. phoenix/server/static/assets/{vendor-BC3OPQuM.js → vendor-B5IC0ivG.js} +5 -5
  58. phoenix/server/static/assets/{vendor-arizeai-NjB3cZzD.js → vendor-arizeai-aFbT4kl1.js} +2 -2
  59. phoenix/server/static/assets/{vendor-codemirror-gE_JCOgX.js → vendor-codemirror-BEGorXSV.js} +1 -1
  60. phoenix/server/static/assets/{vendor-recharts-BXLYwcXF.js → vendor-recharts-6nUU7gU_.js} +1 -1
  61. phoenix/server/templates/index.html +1 -0
  62. phoenix/server/types.py +157 -1
  63. phoenix/session/client.py +7 -2
  64. phoenix/utilities/client.py +16 -0
  65. phoenix/version.py +1 -1
  66. phoenix/db/migrations/future_versions/README.md +0 -4
  67. phoenix/db/migrations/future_versions/cd164e83824f_users_and_tokens.py +0 -293
  68. phoenix/db/migrations/versions/.gitignore +0 -1
  69. phoenix/server/api/mutations/auth.py +0 -18
  70. phoenix/server/api/mutations/auth_mutations.py +0 -65
  71. phoenix/server/static/assets/index-fq1-hCK4.js +0 -100
  72. phoenix/trace/langchain/__init__.py +0 -3
  73. phoenix/trace/langchain/instrumentor.py +0 -34
  74. phoenix/trace/llama_index/__init__.py +0 -3
  75. phoenix/trace/llama_index/callback.py +0 -102
  76. phoenix/trace/openai/__init__.py +0 -3
  77. phoenix/trace/openai/instrumentor.py +0 -30
  78. {arize_phoenix-4.36.0.dist-info → arize_phoenix-5.0.0.dist-info}/WHEEL +0 -0
  79. {arize_phoenix-4.36.0.dist-info → arize_phoenix-5.0.0.dist-info}/licenses/IP_NOTICE +0 -0
  80. {arize_phoenix-4.36.0.dist-info → arize_phoenix-5.0.0.dist-info}/licenses/LICENSE +0 -0
phoenix/auth.py CHANGED
@@ -1,29 +1,61 @@
1
+ from __future__ import annotations
2
+
1
3
  import re
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timedelta
6
+ from enum import Enum, auto
2
7
  from hashlib import pbkdf2_hmac
8
+ from typing import Any, Literal, Optional, Protocol
9
+
10
+ from starlette.responses import Response
11
+ from typing_extensions import TypeVar
12
+
13
+ from phoenix.config import get_env_phoenix_use_secure_cookies
3
14
 
15
+ ResponseType = TypeVar("ResponseType", bound=Response)
4
16
 
5
- def compute_password_hash(*, password: str, salt: str) -> str:
17
+
18
+ def compute_password_hash(*, password: str, salt: bytes) -> bytes:
6
19
  """
7
20
  Salts and hashes a password using PBKDF2, HMAC, and SHA256.
21
+
22
+ Args:
23
+ password (str): the password to hash
24
+ salt (bytes): the salt to use, must not be zero-length
25
+ Returns:
26
+ bytes: the hashed password
8
27
  """
28
+ assert salt
9
29
  password_bytes = password.encode("utf-8")
10
- salt_bytes = salt.encode("utf-8")
11
- password_hash_bytes = pbkdf2_hmac("sha256", password_bytes, salt_bytes, NUM_ITERATIONS)
12
- password_hash = password_hash_bytes.hex()
13
- return password_hash
30
+ return pbkdf2_hmac("sha256", password_bytes, salt, NUM_ITERATIONS)
14
31
 
15
32
 
16
- def is_valid_password(*, password: str, salt: str, password_hash: str) -> bool:
33
+ def is_valid_password(*, password: str, salt: bytes, password_hash: bytes) -> bool:
17
34
  """
18
35
  Determines whether the password is valid by salting and hashing the password
19
36
  and comparing against the existing hash value.
37
+
38
+ Args:
39
+ password (str): the password to validate
40
+ salt (bytes): the salt to use, must not be zero-length
41
+ password_hash (bytes): the hash to compare against
42
+ Returns:
43
+ bool: True if the password is valid, False otherwise
20
44
  """
45
+ assert salt
21
46
  return password_hash == compute_password_hash(password=password, salt=salt)
22
47
 
23
48
 
24
49
  def validate_email_format(email: str) -> None:
25
50
  """
26
51
  Checks that the email has a valid format.
52
+
53
+ Args:
54
+ email (str): the email address to validate
55
+ Returns:
56
+ None
57
+ Raises:
58
+ ValueError: if the email address is invalid
27
59
  """
28
60
  if EMAIL_PATTERN.match(email) is None:
29
61
  raise ValueError("Invalid email address")
@@ -33,16 +65,245 @@ def validate_password_format(password: str) -> None:
33
65
  """
34
66
  Checks that the password has a valid format.
35
67
  """
36
- if not password:
37
- raise ValueError("Password must be non-empty")
38
- if any(char.isspace() for char in password):
39
- raise ValueError("Password cannot contain whitespace characters")
40
- if not password.isascii():
41
- raise ValueError("Password can contain only ASCII characters")
42
- if not len(password) >= MIN_PASSWORD_LENGTH:
43
- raise ValueError(f"Password must be at least {MIN_PASSWORD_LENGTH} characters long")
68
+ PASSWORD_REQUIREMENTS.validate(password)
69
+
70
+
71
+ def set_access_token_cookie(
72
+ *, response: ResponseType, access_token: str, max_age: timedelta
73
+ ) -> ResponseType:
74
+ return _set_cookie(
75
+ response=response,
76
+ cookie_name=PHOENIX_ACCESS_TOKEN_COOKIE_NAME,
77
+ cookie_max_age=max_age,
78
+ samesite="strict",
79
+ value=access_token,
80
+ )
81
+
82
+
83
+ def set_refresh_token_cookie(
84
+ *, response: ResponseType, refresh_token: str, max_age: timedelta
85
+ ) -> ResponseType:
86
+ return _set_cookie(
87
+ response=response,
88
+ cookie_name=PHOENIX_REFRESH_TOKEN_COOKIE_NAME,
89
+ cookie_max_age=max_age,
90
+ samesite="strict",
91
+ value=refresh_token,
92
+ )
93
+
94
+
95
+ def set_oauth2_state_cookie(
96
+ *, response: ResponseType, state: str, max_age: timedelta
97
+ ) -> ResponseType:
98
+ return _set_cookie(
99
+ response=response,
100
+ cookie_name=PHOENIX_OAUTH2_STATE_COOKIE_NAME,
101
+ cookie_max_age=max_age,
102
+ samesite="lax",
103
+ value=state,
104
+ )
105
+
106
+
107
+ def set_oauth2_nonce_cookie(
108
+ *, response: ResponseType, nonce: str, max_age: timedelta
109
+ ) -> ResponseType:
110
+ return _set_cookie(
111
+ response=response,
112
+ cookie_name=PHOENIX_OAUTH2_NONCE_COOKIE_NAME,
113
+ cookie_max_age=max_age,
114
+ samesite="lax",
115
+ value=nonce,
116
+ )
117
+
118
+
119
+ def _set_cookie(
120
+ *,
121
+ response: ResponseType,
122
+ cookie_name: str,
123
+ cookie_max_age: timedelta,
124
+ samesite: Literal["strict", "lax"],
125
+ value: str,
126
+ ) -> ResponseType:
127
+ response.set_cookie(
128
+ key=cookie_name,
129
+ value=value,
130
+ secure=get_env_phoenix_use_secure_cookies(),
131
+ httponly=True,
132
+ samesite=samesite,
133
+ max_age=int(cookie_max_age.total_seconds()),
134
+ )
135
+ return response
136
+
137
+
138
+ def delete_access_token_cookie(response: ResponseType) -> ResponseType:
139
+ response.delete_cookie(key=PHOENIX_ACCESS_TOKEN_COOKIE_NAME)
140
+ return response
141
+
142
+
143
+ def delete_refresh_token_cookie(response: ResponseType) -> ResponseType:
144
+ response.delete_cookie(key=PHOENIX_REFRESH_TOKEN_COOKIE_NAME)
145
+ return response
146
+
147
+
148
+ def delete_oauth2_state_cookie(response: ResponseType) -> ResponseType:
149
+ response.delete_cookie(key=PHOENIX_OAUTH2_STATE_COOKIE_NAME)
150
+ return response
151
+
152
+
153
+ def delete_oauth2_nonce_cookie(response: ResponseType) -> ResponseType:
154
+ response.delete_cookie(key=PHOENIX_OAUTH2_NONCE_COOKIE_NAME)
155
+ return response
156
+
157
+
158
+ @dataclass(frozen=True)
159
+ class _PasswordRequirements:
160
+ """
161
+ Password must be at least `length` characters long. Password must not contain whitespace
162
+ characters. Password can contain only ASCII characters. The arguments `special_chars`,
163
+ `digits`, `upper_case`, and `lower_case` control what category of characters will appear
164
+ in the password. If set to True, at least one character from the corresponding category
165
+ is guaranteed to appear. Special characters are characters from `!@#$%^&*()_+`, digits
166
+ are characters from `0123456789`, and uppercase and lowercase characters are characters
167
+ from the ASCII set of letters.
168
+
169
+ Attributes:
170
+ length (int): the minimum length of the password
171
+ digits (bool): whether the password must contain at least one digit
172
+ lower_case (bool): whether the password must contain at least one lowercase letter
173
+ upper_case (bool): whether the password must contain at least one uppercase letter
174
+ special_chars (bool): whether the password must contain at least one special character
175
+ """
176
+
177
+ length: int
178
+ digits: bool = False
179
+ lower_case: bool = False
180
+ upper_case: bool = False
181
+ special_chars: bool = False
182
+
183
+ def validate(
184
+ self,
185
+ string: str,
186
+ /,
187
+ err_msg_subject: Literal["Password", "Phoenix secret"] = "Password",
188
+ ) -> None:
189
+ """
190
+ Validates the password against the requirements.
191
+
192
+ Args:
193
+ string (str): the password to validate
194
+ err_msg_subject (str, optional): the subject of the error message,
195
+ defaults to "Password"
196
+ Returns:
197
+ None
198
+ Raises:
199
+ ValueError: if the password does not meet the requirements
200
+ """
201
+ if not string:
202
+ raise ValueError(f"{err_msg_subject} must be non-empty")
203
+ if any(char.isspace() for char in string):
204
+ raise ValueError(f"{err_msg_subject} must not contain whitespace characters")
205
+ if not string.isascii():
206
+ raise ValueError(f"{err_msg_subject} must contain only ASCII characters")
207
+ err_msg = []
208
+ if len(string) < self.length:
209
+ err_msg.append(f"must be at least {self.length} characters long")
210
+ if self.digits and not any(char.isdigit() for char in string):
211
+ err_msg.append("at least one digit")
212
+ if self.lower_case and not any(char.islower() for char in string):
213
+ err_msg.append("at least one lowercase letter")
214
+ if self.upper_case and not any(char.isupper() for char in string):
215
+ err_msg.append("at least one uppercase letter")
216
+ if self.special_chars and not any(char in "!@#$%^&*()_+" for char in string):
217
+ err_msg.append("at least one special character")
218
+ if not err_msg:
219
+ return
220
+ if len(err_msg) > 1:
221
+ err_text = f"{err_msg_subject} " + ", ".join(err_msg[:-1]) + ", and " + err_msg[-1]
222
+ else:
223
+ err_text = f"{err_msg_subject} {err_msg[0]}"
224
+ raise ValueError(err_text)
44
225
 
45
226
 
227
+ DEFAULT_ADMIN_USERNAME = "admin"
228
+ DEFAULT_ADMIN_EMAIL = "admin@localhost"
229
+ DEFAULT_ADMIN_PASSWORD = "admin"
230
+ DEFAULT_SYSTEM_USERNAME = "system"
231
+ DEFAULT_SYSTEM_EMAIL = "system@localhost"
232
+ DEFAULT_SECRET_LENGTH = 32
233
+ DEFAULT_PASSWORD_RESET_TOKEN_EXPIRY_MINUTES = 15
234
+ DEFAULT_ACCESS_TOKEN_EXPIRY_MINUTES = 10
235
+ DEFAULT_REFRESH_TOKEN_EXPIRY_MINUTES = 60 * 24 * 7
236
+ """The default length of a secret key in bytes."""
46
237
  EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+[.][^@\s]+\Z")
238
+ """The regular expression pattern for a valid email address."""
47
239
  NUM_ITERATIONS = 10_000
240
+ """The number of iterations to use for the PBKDF2 key derivation function."""
48
241
  MIN_PASSWORD_LENGTH = 4
242
+ """The minimum length of a password."""
243
+ PASSWORD_REQUIREMENTS = _PasswordRequirements(length=MIN_PASSWORD_LENGTH)
244
+ """The requirements for a valid password."""
245
+ REQUIREMENTS_FOR_PHOENIX_SECRET = _PasswordRequirements(
246
+ length=DEFAULT_SECRET_LENGTH, digits=True, lower_case=True
247
+ )
248
+ """The requirements for the Phoenix secret key."""
249
+ JWT_ALGORITHM = "HS256"
250
+ """The algorithm to use for the JSON Web Token."""
251
+ PHOENIX_ACCESS_TOKEN_COOKIE_NAME = "phoenix-access-token"
252
+ """The name of the cookie that stores the Phoenix access token."""
253
+ PHOENIX_REFRESH_TOKEN_COOKIE_NAME = "phoenix-refresh-token"
254
+ """The name of the cookie that stores the Phoenix refresh token."""
255
+ PHOENIX_OAUTH2_STATE_COOKIE_NAME = "phoenix-oauth2-state"
256
+ """The name of the cookie that stores the state used for the OAuth2 authorization code flow."""
257
+ PHOENIX_OAUTH2_NONCE_COOKIE_NAME = "phoenix-oauth2-nonce"
258
+ """The name of the cookie that stores the nonce used for the OAuth2 authorization code flow."""
259
+ DEFAULT_OAUTH2_LOGIN_EXPIRY_MINUTES = 15
260
+ """
261
+ The default amount of time in minutes that can elapse between the initial
262
+ redirect to the IDP and the invocation of the callback URL during the OAuth2
263
+ authorization code flow.
264
+ """
265
+
266
+
267
+ class Token(str): ...
268
+
269
+
270
+ class ClaimSetStatus(Enum):
271
+ VALID = auto()
272
+ INVALID = auto()
273
+ EXPIRED = auto()
274
+
275
+
276
+ @dataclass(frozen=True)
277
+ class TokenAttributes: ...
278
+
279
+
280
+ @dataclass(frozen=True)
281
+ class ClaimSet:
282
+ issuer: Optional[Any] = None
283
+ "Analog of `iss` claim in JWT RFC7519: https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.1"
284
+ subject: Optional[Any] = None
285
+ "Analog of `sub` claim in JWT RFC7519: https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.2"
286
+ audience: Optional[Any] = None
287
+ "Analog of `aud` claim in JWT RFC7519: https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3"
288
+ not_before: Optional[datetime] = None
289
+ "Analog of `nbf` claim in JWT RFC7519: https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.5"
290
+ issued_at: Optional[datetime] = None
291
+ "Analog of `iat` claim in JWT RFC7519: https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.6"
292
+ expiration_time: Optional[datetime] = None
293
+ "Analog of `exp` claim in JWT RFC7519: https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.4"
294
+ token_id: Optional[Any] = None
295
+ "Analog of `jti` claim in JWT RFC7519: https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.7"
296
+ attributes: Optional[TokenAttributes] = None
297
+ "Application/domain-specific claims"
298
+
299
+ @property
300
+ def status(self) -> ClaimSetStatus:
301
+ if self.expiration_time and self.expiration_time.timestamp() < datetime.now().timestamp():
302
+ return ClaimSetStatus.EXPIRED
303
+ if self.token_id is not None and self.subject is not None:
304
+ return ClaimSetStatus.VALID
305
+ return ClaimSetStatus.INVALID
306
+
307
+
308
+ class CanReadToken(Protocol):
309
+ async def read(self, token: Token) -> Optional[ClaimSet]: ...
phoenix/config.py CHANGED
@@ -2,9 +2,12 @@ import logging
2
2
  import os
3
3
  import re
4
4
  import tempfile
5
+ from dataclasses import dataclass
6
+ from datetime import timedelta
5
7
  from enum import Enum
6
8
  from pathlib import Path
7
- from typing import Dict, List, Optional, Tuple
9
+ from typing import Dict, List, Optional, Tuple, overload
10
+ from urllib.parse import urlparse
8
11
 
9
12
  from phoenix.utilities.logging import log_a_list
10
13
 
@@ -86,10 +89,50 @@ ENV_PHOENIX_SERVER_INSTRUMENTATION_OTLP_TRACE_COLLECTOR_GRPC_ENDPOINT = (
86
89
  "PHOENIX_SERVER_INSTRUMENTATION_OTLP_TRACE_COLLECTOR_GRPC_ENDPOINT"
87
90
  )
88
91
 
89
- # Auth is under active development. Phoenix users are strongly advised not to
90
- # set these environment variables until the feature is officially released.
91
- ENV_DANGEROUSLY_SET_PHOENIX_ENABLE_AUTH = "DANGEROUSLY_SET_PHOENIX_ENABLE_AUTH"
92
- ENV_DANGEROUSLY_SET_PHOENIX_SECRET = "DANGEROUSLY_SET_PHOENIX_SECRET"
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
+ """
93
136
 
94
137
 
95
138
  def server_instrumentation_is_enabled() -> bool:
@@ -131,12 +174,16 @@ def get_working_dir() -> Path:
131
174
  return Path.home().resolve() / ".phoenix"
132
175
 
133
176
 
134
- def get_boolean_env_var(env_var: str) -> Optional[bool]:
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]:
135
182
  """
136
- Parses a boolean environment variable, returning None if the variable is not set.
183
+ Parses a boolean environment variable, returning `default` if the variable is not set.
137
184
  """
138
185
  if (value := os.environ.get(env_var)) is None:
139
- return None
186
+ return default
140
187
  assert (lower := value.lower()) in (
141
188
  "true",
142
189
  "false",
@@ -144,44 +191,234 @@ def get_boolean_env_var(env_var: str) -> Optional[bool]:
144
191
  return lower == "true"
145
192
 
146
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
+
147
232
  def get_env_enable_auth() -> bool:
148
233
  """
149
- Gets the value of the DANGEROUSLY_SET_PHOENIX_ENABLE_AUTH environment variable.
234
+ Gets the value of the PHOENIX_ENABLE_AUTH environment variable.
150
235
  """
151
- return get_boolean_env_var(ENV_DANGEROUSLY_SET_PHOENIX_ENABLE_AUTH) is True
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.
242
+ """
243
+ return _bool_val(ENV_PHOENIX_DISABLE_RATE_LIMIT, False)
152
244
 
153
245
 
154
246
  def get_env_phoenix_secret() -> Optional[str]:
155
247
  """
156
- Gets the value of the DANGEROUSLY_SET_PHOENIX_SECRET environment variable
248
+ Gets the value of the PHOENIX_SECRET environment variable
157
249
  and performs validation.
158
250
  """
159
- phoenix_secret = os.environ.get(ENV_DANGEROUSLY_SET_PHOENIX_SECRET)
251
+ phoenix_secret = os.environ.get(ENV_PHOENIX_SECRET)
160
252
  if phoenix_secret is None:
161
253
  return None
162
- # todo: add validation for the phoenix secret
254
+ from phoenix.auth import REQUIREMENTS_FOR_PHOENIX_SECRET
255
+
256
+ REQUIREMENTS_FOR_PHOENIX_SECRET.validate(phoenix_secret, "Phoenix secret")
163
257
  return phoenix_secret
164
258
 
165
259
 
166
- def get_auth_settings() -> Tuple[bool, Optional[str]]:
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]]:
167
269
  """
168
270
  Gets auth settings and performs validation.
169
271
  """
170
272
  enable_auth = get_env_enable_auth()
171
273
  phoenix_secret = get_env_phoenix_secret()
172
- if enable_auth:
173
- assert phoenix_secret, (
174
- "DANGEROUSLY_SET_PHOENIX_SECRET must be set "
175
- "when auth is enabled with DANGEROUSLY_SET_PHOENIX_ENABLE_AUTH"
176
- )
177
- else:
178
- assert not phoenix_secret, (
179
- "DANGEROUSLY_SET_PHOENIX_SECRET cannot be set "
180
- "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}`"
181
278
  )
182
279
  return enable_auth, phoenix_secret
183
280
 
184
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
+
185
422
  PHOENIX_DIR = Path(__file__).resolve().parent
186
423
  # Server config
187
424
  SERVER_DIR = PHOENIX_DIR / "server"
@@ -205,8 +442,6 @@ EXPORT_DIR = ROOT_DIR / "exports"
205
442
  INFERENCES_DIR = ROOT_DIR / "inferences"
206
443
  TRACE_DATASETS_DIR = ROOT_DIR / "trace_datasets"
207
444
 
208
- ENABLE_AUTH, PHOENIX_SECRET = get_auth_settings()
209
-
210
445
 
211
446
  def ensure_working_dir() -> None:
212
447
  """
@@ -421,5 +656,22 @@ def get_env_log_migrations() -> bool:
421
656
  )
422
657
 
423
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
+
424
676
  DEFAULT_PROJECT_NAME = "default"
425
677
  _KUBERNETES_PHOENIX_PORT_PATTERN = re.compile(r"^tcp://\d{1,3}[.]\d{1,3}[.]\d{1,3}[.]\d{1,3}:\d+$")