arize-phoenix 4.36.0__py3-none-any.whl → 5.1.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.36.0.dist-info → arize_phoenix-5.1.0.dist-info}/METADATA +10 -12
- {arize_phoenix-4.36.0.dist-info → arize_phoenix-5.1.0.dist-info}/RECORD +69 -60
- phoenix/__init__.py +86 -0
- phoenix/auth.py +275 -14
- phoenix/config.py +277 -25
- phoenix/db/enums.py +20 -0
- phoenix/db/facilitator.py +112 -0
- 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/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/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 +103 -31
- 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 +40 -9
- 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/templates/index.html +1 -0
- phoenix/server/types.py +157 -1
- phoenix/session/client.py +7 -2
- phoenix/trace/fixtures.py +24 -0
- phoenix/utilities/client.py +16 -0
- 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 -34
- phoenix/trace/llama_index/__init__.py +0 -3
- phoenix/trace/llama_index/callback.py +0 -102
- phoenix/trace/openai/__init__.py +0 -3
- phoenix/trace/openai/instrumentor.py +0 -30
- {arize_phoenix-4.36.0.dist-info → arize_phoenix-5.1.0.dist-info}/WHEEL +0 -0
- {arize_phoenix-4.36.0.dist-info → arize_phoenix-5.1.0.dist-info}/licenses/IP_NOTICE +0 -0
- {arize_phoenix-4.36.0.dist-info → arize_phoenix-5.1.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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
#
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
234
|
+
Gets the value of the PHOENIX_ENABLE_AUTH environment variable.
|
|
150
235
|
"""
|
|
151
|
-
return
|
|
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
|
|
248
|
+
Gets the value of the PHOENIX_SECRET environment variable
|
|
157
249
|
and performs validation.
|
|
158
250
|
"""
|
|
159
|
-
phoenix_secret = os.environ.get(
|
|
251
|
+
phoenix_secret = os.environ.get(ENV_PHOENIX_SECRET)
|
|
160
252
|
if phoenix_secret is None:
|
|
161
253
|
return None
|
|
162
|
-
|
|
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
|
|
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
|
-
|
|
174
|
-
"
|
|
175
|
-
"
|
|
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+$")
|