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
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import secrets
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
from functools import partial
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from urllib.parse import urlencode, urlparse, urlunparse
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
|
9
|
+
from sqlalchemy import select
|
|
10
|
+
from sqlalchemy.orm import joinedload
|
|
11
|
+
from starlette.status import (
|
|
12
|
+
HTTP_204_NO_CONTENT,
|
|
13
|
+
HTTP_401_UNAUTHORIZED,
|
|
14
|
+
HTTP_404_NOT_FOUND,
|
|
15
|
+
HTTP_422_UNPROCESSABLE_ENTITY,
|
|
16
|
+
HTTP_503_SERVICE_UNAVAILABLE,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from phoenix.auth import (
|
|
20
|
+
DEFAULT_SECRET_LENGTH,
|
|
21
|
+
PHOENIX_ACCESS_TOKEN_COOKIE_NAME,
|
|
22
|
+
PHOENIX_REFRESH_TOKEN_COOKIE_NAME,
|
|
23
|
+
Token,
|
|
24
|
+
compute_password_hash,
|
|
25
|
+
delete_access_token_cookie,
|
|
26
|
+
delete_oauth2_nonce_cookie,
|
|
27
|
+
delete_oauth2_state_cookie,
|
|
28
|
+
delete_refresh_token_cookie,
|
|
29
|
+
is_valid_password,
|
|
30
|
+
set_access_token_cookie,
|
|
31
|
+
set_refresh_token_cookie,
|
|
32
|
+
validate_password_format,
|
|
33
|
+
)
|
|
34
|
+
from phoenix.config import get_base_url, get_env_disable_rate_limit, get_env_host_root_path
|
|
35
|
+
from phoenix.db import enums, models
|
|
36
|
+
from phoenix.server.bearer_auth import PhoenixUser, create_access_and_refresh_tokens
|
|
37
|
+
from phoenix.server.email.types import EmailSender
|
|
38
|
+
from phoenix.server.rate_limiters import ServerRateLimiter, fastapi_ip_rate_limiter
|
|
39
|
+
from phoenix.server.types import (
|
|
40
|
+
AccessTokenClaims,
|
|
41
|
+
PasswordResetTokenClaims,
|
|
42
|
+
PasswordResetTokenId,
|
|
43
|
+
RefreshTokenClaims,
|
|
44
|
+
TokenStore,
|
|
45
|
+
UserId,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
rate_limiter = ServerRateLimiter(
|
|
49
|
+
per_second_rate_limit=0.2,
|
|
50
|
+
enforcement_window_seconds=60,
|
|
51
|
+
partition_seconds=60,
|
|
52
|
+
active_partitions=2,
|
|
53
|
+
)
|
|
54
|
+
login_rate_limiter = fastapi_ip_rate_limiter(
|
|
55
|
+
rate_limiter,
|
|
56
|
+
paths=[
|
|
57
|
+
"/auth/login",
|
|
58
|
+
"/auth/logout",
|
|
59
|
+
"/auth/refresh",
|
|
60
|
+
"/auth/password-reset-email",
|
|
61
|
+
"/auth/password-reset",
|
|
62
|
+
],
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
auth_dependencies = [Depends(login_rate_limiter)] if not get_env_disable_rate_limit() else []
|
|
66
|
+
router = APIRouter(prefix="/auth", include_in_schema=False, dependencies=auth_dependencies)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@router.post("/login")
|
|
70
|
+
async def login(request: Request) -> Response:
|
|
71
|
+
assert isinstance(access_token_expiry := request.app.state.access_token_expiry, timedelta)
|
|
72
|
+
assert isinstance(refresh_token_expiry := request.app.state.refresh_token_expiry, timedelta)
|
|
73
|
+
token_store: TokenStore = request.app.state.get_token_store()
|
|
74
|
+
data = await request.json()
|
|
75
|
+
email = data.get("email")
|
|
76
|
+
password = data.get("password")
|
|
77
|
+
|
|
78
|
+
if not email or not password:
|
|
79
|
+
raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Email and password required")
|
|
80
|
+
|
|
81
|
+
async with request.app.state.db() as session:
|
|
82
|
+
user = await session.scalar(
|
|
83
|
+
select(models.User).filter_by(email=email).options(joinedload(models.User.role))
|
|
84
|
+
)
|
|
85
|
+
if (
|
|
86
|
+
user is None
|
|
87
|
+
or (password_hash := user.password_hash) is None
|
|
88
|
+
or (salt := user.password_salt) is None
|
|
89
|
+
):
|
|
90
|
+
raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail=LOGIN_FAILED_MESSAGE)
|
|
91
|
+
|
|
92
|
+
loop = asyncio.get_running_loop()
|
|
93
|
+
password_is_valid = partial(
|
|
94
|
+
is_valid_password, password=password, salt=salt, password_hash=password_hash
|
|
95
|
+
)
|
|
96
|
+
if not await loop.run_in_executor(None, password_is_valid):
|
|
97
|
+
raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail=LOGIN_FAILED_MESSAGE)
|
|
98
|
+
|
|
99
|
+
access_token, refresh_token = await create_access_and_refresh_tokens(
|
|
100
|
+
token_store=token_store,
|
|
101
|
+
user=user,
|
|
102
|
+
access_token_expiry=access_token_expiry,
|
|
103
|
+
refresh_token_expiry=refresh_token_expiry,
|
|
104
|
+
)
|
|
105
|
+
response = Response(status_code=HTTP_204_NO_CONTENT)
|
|
106
|
+
response = set_access_token_cookie(
|
|
107
|
+
response=response, access_token=access_token, max_age=access_token_expiry
|
|
108
|
+
)
|
|
109
|
+
response = set_refresh_token_cookie(
|
|
110
|
+
response=response, refresh_token=refresh_token, max_age=refresh_token_expiry
|
|
111
|
+
)
|
|
112
|
+
return response
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@router.post("/logout")
|
|
116
|
+
async def logout(
|
|
117
|
+
request: Request,
|
|
118
|
+
) -> Response:
|
|
119
|
+
token_store: TokenStore = request.app.state.get_token_store()
|
|
120
|
+
user_id = None
|
|
121
|
+
if isinstance(user := request.user, PhoenixUser):
|
|
122
|
+
user_id = user.identity
|
|
123
|
+
elif (refresh_token := request.cookies.get(PHOENIX_REFRESH_TOKEN_COOKIE_NAME)) and (
|
|
124
|
+
isinstance(
|
|
125
|
+
refresh_token_claims := await token_store.read(Token(refresh_token)),
|
|
126
|
+
RefreshTokenClaims,
|
|
127
|
+
)
|
|
128
|
+
and isinstance(subject := refresh_token_claims.subject, UserId)
|
|
129
|
+
):
|
|
130
|
+
user_id = subject
|
|
131
|
+
if user_id:
|
|
132
|
+
await token_store.log_out(user_id)
|
|
133
|
+
response = Response(status_code=HTTP_204_NO_CONTENT)
|
|
134
|
+
response = delete_access_token_cookie(response)
|
|
135
|
+
response = delete_refresh_token_cookie(response)
|
|
136
|
+
response = delete_oauth2_state_cookie(response)
|
|
137
|
+
response = delete_oauth2_nonce_cookie(response)
|
|
138
|
+
return response
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@router.post("/refresh")
|
|
142
|
+
async def refresh_tokens(request: Request) -> Response:
|
|
143
|
+
assert isinstance(access_token_expiry := request.app.state.access_token_expiry, timedelta)
|
|
144
|
+
assert isinstance(refresh_token_expiry := request.app.state.refresh_token_expiry, timedelta)
|
|
145
|
+
if (refresh_token := request.cookies.get(PHOENIX_REFRESH_TOKEN_COOKIE_NAME)) is None:
|
|
146
|
+
raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Missing refresh token")
|
|
147
|
+
token_store: TokenStore = request.app.state.get_token_store()
|
|
148
|
+
refresh_token_claims = await token_store.read(Token(refresh_token))
|
|
149
|
+
if (
|
|
150
|
+
not isinstance(refresh_token_claims, RefreshTokenClaims)
|
|
151
|
+
or (refresh_token_id := refresh_token_claims.token_id) is None
|
|
152
|
+
or refresh_token_claims.subject is None
|
|
153
|
+
or (user_id := int(refresh_token_claims.subject)) is None
|
|
154
|
+
or (expiration_time := refresh_token_claims.expiration_time) is None
|
|
155
|
+
):
|
|
156
|
+
raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
|
|
157
|
+
if expiration_time.timestamp() < datetime.now().timestamp():
|
|
158
|
+
raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Expired refresh token")
|
|
159
|
+
await token_store.revoke(refresh_token_id)
|
|
160
|
+
|
|
161
|
+
if (
|
|
162
|
+
(access_token := request.cookies.get(PHOENIX_ACCESS_TOKEN_COOKIE_NAME)) is not None
|
|
163
|
+
and isinstance(
|
|
164
|
+
access_token_claims := await token_store.read(Token(access_token)), AccessTokenClaims
|
|
165
|
+
)
|
|
166
|
+
and (access_token_id := access_token_claims.token_id)
|
|
167
|
+
):
|
|
168
|
+
await token_store.revoke(access_token_id)
|
|
169
|
+
|
|
170
|
+
async with request.app.state.db() as session:
|
|
171
|
+
if (
|
|
172
|
+
user := await session.scalar(
|
|
173
|
+
select(models.User).filter_by(id=user_id).options(joinedload(models.User.role))
|
|
174
|
+
)
|
|
175
|
+
) is None:
|
|
176
|
+
raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail="User not found")
|
|
177
|
+
access_token, refresh_token = await create_access_and_refresh_tokens(
|
|
178
|
+
token_store=token_store,
|
|
179
|
+
user=user,
|
|
180
|
+
access_token_expiry=access_token_expiry,
|
|
181
|
+
refresh_token_expiry=refresh_token_expiry,
|
|
182
|
+
)
|
|
183
|
+
response = Response(status_code=HTTP_204_NO_CONTENT)
|
|
184
|
+
response = set_access_token_cookie(
|
|
185
|
+
response=response, access_token=access_token, max_age=access_token_expiry
|
|
186
|
+
)
|
|
187
|
+
response = set_refresh_token_cookie(
|
|
188
|
+
response=response, refresh_token=refresh_token, max_age=refresh_token_expiry
|
|
189
|
+
)
|
|
190
|
+
return response
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@router.post("/password-reset-email")
|
|
194
|
+
async def initiate_password_reset(request: Request) -> Response:
|
|
195
|
+
data = await request.json()
|
|
196
|
+
if not (email := data.get("email")):
|
|
197
|
+
raise MISSING_EMAIL
|
|
198
|
+
sender: EmailSender = request.app.state.email_sender
|
|
199
|
+
if sender is None:
|
|
200
|
+
raise SMTP_UNAVAILABLE
|
|
201
|
+
assert isinstance(token_expiry := request.app.state.password_reset_token_expiry, timedelta)
|
|
202
|
+
async with request.app.state.db() as session:
|
|
203
|
+
user = await session.scalar(
|
|
204
|
+
select(models.User)
|
|
205
|
+
.filter_by(email=email)
|
|
206
|
+
.options(
|
|
207
|
+
joinedload(models.User.password_reset_token).load_only(models.PasswordResetToken.id)
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
if user is None or user.auth_method != enums.AuthMethod.LOCAL.value:
|
|
211
|
+
# Withold privileged information
|
|
212
|
+
return Response(status_code=HTTP_204_NO_CONTENT)
|
|
213
|
+
token_store: TokenStore = request.app.state.get_token_store()
|
|
214
|
+
if user.password_reset_token:
|
|
215
|
+
await token_store.revoke(PasswordResetTokenId(user.password_reset_token.id))
|
|
216
|
+
password_reset_token_claims = PasswordResetTokenClaims(
|
|
217
|
+
subject=UserId(user.id),
|
|
218
|
+
issued_at=datetime.now(timezone.utc),
|
|
219
|
+
expiration_time=datetime.now(timezone.utc) + token_expiry,
|
|
220
|
+
)
|
|
221
|
+
token, _ = await token_store.create_password_reset_token(password_reset_token_claims)
|
|
222
|
+
url = urlparse(request.headers.get("referer") or get_base_url())
|
|
223
|
+
path = Path(get_env_host_root_path()) / "reset-password-with-token"
|
|
224
|
+
query_string = urlencode(dict(token=token))
|
|
225
|
+
components = (url.scheme, url.netloc, path.as_posix(), "", query_string, "")
|
|
226
|
+
reset_url = urlunparse(components)
|
|
227
|
+
await sender.send_password_reset_email(email, reset_url)
|
|
228
|
+
return Response(status_code=HTTP_204_NO_CONTENT)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@router.post("/password-reset")
|
|
232
|
+
async def reset_password(request: Request) -> Response:
|
|
233
|
+
data = await request.json()
|
|
234
|
+
if not (password := data.get("password")):
|
|
235
|
+
raise MISSING_PASSWORD
|
|
236
|
+
token_store: TokenStore = request.app.state.get_token_store()
|
|
237
|
+
if (
|
|
238
|
+
not (token := data.get("token"))
|
|
239
|
+
or not isinstance((claims := await token_store.read(token)), PasswordResetTokenClaims)
|
|
240
|
+
or not claims.expiration_time
|
|
241
|
+
or claims.expiration_time < datetime.now(timezone.utc)
|
|
242
|
+
):
|
|
243
|
+
raise INVALID_TOKEN
|
|
244
|
+
assert (user_id := claims.subject)
|
|
245
|
+
async with request.app.state.db() as session:
|
|
246
|
+
user = await session.scalar(select(models.User).filter_by(id=int(user_id)))
|
|
247
|
+
if user is None or user.auth_method != enums.AuthMethod.LOCAL.value:
|
|
248
|
+
# Withold privileged information
|
|
249
|
+
return Response(status_code=HTTP_204_NO_CONTENT)
|
|
250
|
+
validate_password_format(password)
|
|
251
|
+
user.password_salt = secrets.token_bytes(DEFAULT_SECRET_LENGTH)
|
|
252
|
+
loop = asyncio.get_running_loop()
|
|
253
|
+
user.password_hash = await loop.run_in_executor(
|
|
254
|
+
None, partial(compute_password_hash, password=password, salt=user.password_salt)
|
|
255
|
+
)
|
|
256
|
+
user.reset_password = False
|
|
257
|
+
async with request.app.state.db() as session:
|
|
258
|
+
session.add(user)
|
|
259
|
+
await session.flush()
|
|
260
|
+
response = Response(status_code=HTTP_204_NO_CONTENT)
|
|
261
|
+
assert (token_id := claims.token_id)
|
|
262
|
+
await token_store.revoke(token_id)
|
|
263
|
+
await token_store.log_out(UserId(user.id))
|
|
264
|
+
return response
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
LOGIN_FAILED_MESSAGE = "Invalid email and/or password"
|
|
268
|
+
|
|
269
|
+
MISSING_EMAIL = HTTPException(
|
|
270
|
+
status_code=HTTP_422_UNPROCESSABLE_ENTITY,
|
|
271
|
+
detail="Email required",
|
|
272
|
+
)
|
|
273
|
+
MISSING_PASSWORD = HTTPException(
|
|
274
|
+
status_code=HTTP_422_UNPROCESSABLE_ENTITY,
|
|
275
|
+
detail="Password required",
|
|
276
|
+
)
|
|
277
|
+
SMTP_UNAVAILABLE = HTTPException(
|
|
278
|
+
status_code=HTTP_503_SERVICE_UNAVAILABLE,
|
|
279
|
+
detail="SMTP server not configured",
|
|
280
|
+
)
|
|
281
|
+
INVALID_TOKEN = HTTPException(
|
|
282
|
+
status_code=HTTP_401_UNAUTHORIZED,
|
|
283
|
+
detail="Invalid token",
|
|
284
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from fastapi import APIRouter, Depends
|
|
2
|
+
from fastapi.responses import FileResponse
|
|
3
|
+
from starlette.exceptions import HTTPException
|
|
4
|
+
from starlette.requests import Request
|
|
5
|
+
|
|
6
|
+
from phoenix.server.bearer_auth import is_authenticated
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def create_embeddings_router(authentication_enabled: bool) -> APIRouter:
|
|
10
|
+
"""
|
|
11
|
+
Instantiates a router for the embeddings API.
|
|
12
|
+
"""
|
|
13
|
+
router = APIRouter(dependencies=[Depends(is_authenticated)] if authentication_enabled else [])
|
|
14
|
+
|
|
15
|
+
@router.get("/exports")
|
|
16
|
+
async def download_exported_file(request: Request, filename: str) -> FileResponse:
|
|
17
|
+
file = request.app.state.export_path / (filename + ".parquet")
|
|
18
|
+
if not file.is_file():
|
|
19
|
+
raise HTTPException(status_code=404)
|
|
20
|
+
return FileResponse(
|
|
21
|
+
path=file,
|
|
22
|
+
filename=file.name,
|
|
23
|
+
media_type="application/x-octet-stream",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
return router
|