arize-phoenix 8.22.1__py3-none-any.whl → 8.24.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-8.22.1.dist-info → arize_phoenix-8.24.0.dist-info}/METADATA +22 -2
- {arize_phoenix-8.22.1.dist-info → arize_phoenix-8.24.0.dist-info}/RECORD +31 -29
- phoenix/config.py +65 -3
- phoenix/db/facilitator.py +118 -85
- phoenix/db/helpers.py +16 -0
- phoenix/server/api/context.py +2 -0
- phoenix/server/api/mutations/user_mutations.py +10 -0
- phoenix/server/api/queries.py +3 -14
- phoenix/server/api/routers/v1/__init__.py +2 -0
- phoenix/server/api/routers/v1/projects.py +393 -0
- phoenix/server/api/subscriptions.py +1 -1
- phoenix/server/app.py +17 -0
- phoenix/server/email/sender.py +74 -47
- phoenix/server/email/templates/welcome.html +12 -0
- phoenix/server/email/types.py +16 -1
- phoenix/server/main.py +8 -1
- phoenix/server/static/.vite/manifest.json +36 -36
- phoenix/server/static/assets/{components-BAc4OPED.js → components-B6cljCxu.js} +82 -82
- phoenix/server/static/assets/{index-Du53xkjY.js → index-DfHKoAV9.js} +2 -2
- phoenix/server/static/assets/{pages-Dz-gbBPF.js → pages-Dhitcl5V.js} +342 -339
- phoenix/server/static/assets/{vendor-CEisxXSv.js → vendor-C3H3sezv.js} +1 -1
- phoenix/server/static/assets/{vendor-arizeai-BCTsSnvS.js → vendor-arizeai-DT8pwHfH.js} +1 -1
- phoenix/server/static/assets/{vendor-codemirror-DIWnRs_7.js → vendor-codemirror-DvimrGxD.js} +1 -1
- phoenix/server/static/assets/{vendor-recharts-Bame54mG.js → vendor-recharts-DuSQBcYW.js} +1 -1
- phoenix/server/static/assets/{vendor-shiki-Cc73E4D-.js → vendor-shiki-i05Hmswh.js} +1 -1
- phoenix/session/session.py +10 -0
- phoenix/version.py +1 -1
- {arize_phoenix-8.22.1.dist-info → arize_phoenix-8.24.0.dist-info}/WHEEL +0 -0
- {arize_phoenix-8.22.1.dist-info → arize_phoenix-8.24.0.dist-info}/entry_points.txt +0 -0
- {arize_phoenix-8.22.1.dist-info → arize_phoenix-8.24.0.dist-info}/licenses/IP_NOTICE +0 -0
- {arize_phoenix-8.22.1.dist-info → arize_phoenix-8.24.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import logging
|
|
1
2
|
import secrets
|
|
2
3
|
from contextlib import AsyncExitStack
|
|
3
4
|
from datetime import datetime, timezone
|
|
@@ -32,6 +33,8 @@ from phoenix.server.api.types.User import User, to_gql_user
|
|
|
32
33
|
from phoenix.server.bearer_auth import PhoenixUser
|
|
33
34
|
from phoenix.server.types import AccessTokenId, ApiKeyId, PasswordResetTokenId, RefreshTokenId
|
|
34
35
|
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
35
38
|
|
|
36
39
|
@strawberry.input
|
|
37
40
|
class CreateUserInput:
|
|
@@ -39,6 +42,7 @@ class CreateUserInput:
|
|
|
39
42
|
username: str
|
|
40
43
|
password: str
|
|
41
44
|
role: UserRoleInput
|
|
45
|
+
send_welcome_email: Optional[bool] = False
|
|
42
46
|
|
|
43
47
|
|
|
44
48
|
@strawberry.input
|
|
@@ -111,6 +115,12 @@ class UserMutationMixin:
|
|
|
111
115
|
await session.flush()
|
|
112
116
|
except (PostgreSQLIntegrityError, SQLiteIntegrityError) as error:
|
|
113
117
|
raise Conflict(_user_operation_error_message(error))
|
|
118
|
+
if input.send_welcome_email and info.context.email_sender is not None:
|
|
119
|
+
try:
|
|
120
|
+
await info.context.email_sender.send_welcome_email(user.email, user.username)
|
|
121
|
+
except Exception as error:
|
|
122
|
+
# Log the error but do not raise it
|
|
123
|
+
logger.error(f"Failed to send welcome email: {error}")
|
|
114
124
|
return UserMutationPayload(user=to_gql_user(user))
|
|
115
125
|
|
|
116
126
|
@strawberry.mutation(permission_classes=[IsNotReadOnly, IsAdmin, IsLocked]) # type: ignore
|
phoenix/server/api/queries.py
CHANGED
|
@@ -19,7 +19,7 @@ from phoenix.config import (
|
|
|
19
19
|
getenv,
|
|
20
20
|
)
|
|
21
21
|
from phoenix.db import enums, models
|
|
22
|
-
from phoenix.db.helpers import SupportedSQLDialect
|
|
22
|
+
from phoenix.db.helpers import SupportedSQLDialect, exclude_experiment_projects
|
|
23
23
|
from phoenix.db.models import DatasetExample as OrmExample
|
|
24
24
|
from phoenix.db.models import DatasetExampleRevision as OrmRevision
|
|
25
25
|
from phoenix.db.models import DatasetVersion as OrmVersion
|
|
@@ -42,7 +42,6 @@ from phoenix.server.api.input_types.ClusterInput import ClusterInput
|
|
|
42
42
|
from phoenix.server.api.input_types.Coordinates import InputCoordinate2D, InputCoordinate3D
|
|
43
43
|
from phoenix.server.api.input_types.DatasetSort import DatasetSort
|
|
44
44
|
from phoenix.server.api.input_types.InvocationParameters import InvocationParameter
|
|
45
|
-
from phoenix.server.api.subscriptions import PLAYGROUND_PROJECT_NAME
|
|
46
45
|
from phoenix.server.api.types.Cluster import Cluster, to_gql_clusters
|
|
47
46
|
from phoenix.server.api.types.Dataset import Dataset, to_gql_dataset
|
|
48
47
|
from phoenix.server.api.types.DatasetExample import DatasetExample
|
|
@@ -233,18 +232,8 @@ class Query:
|
|
|
233
232
|
last=last,
|
|
234
233
|
before=before if isinstance(before, CursorString) else None,
|
|
235
234
|
)
|
|
236
|
-
stmt = (
|
|
237
|
-
|
|
238
|
-
.outerjoin(
|
|
239
|
-
models.Experiment,
|
|
240
|
-
and_(
|
|
241
|
-
models.Project.name == models.Experiment.project_name,
|
|
242
|
-
models.Experiment.project_name != PLAYGROUND_PROJECT_NAME,
|
|
243
|
-
),
|
|
244
|
-
)
|
|
245
|
-
.where(models.Experiment.project_name.is_(None))
|
|
246
|
-
.order_by(models.Project.id)
|
|
247
|
-
)
|
|
235
|
+
stmt = select(models.Project).order_by(models.Project.id)
|
|
236
|
+
stmt = exclude_experiment_projects(stmt)
|
|
248
237
|
async with info.context.db() as session:
|
|
249
238
|
projects = await session.stream_scalars(stmt)
|
|
250
239
|
data = [
|
|
@@ -9,6 +9,7 @@ from .evaluations import router as evaluations_router
|
|
|
9
9
|
from .experiment_evaluations import router as experiment_evaluations_router
|
|
10
10
|
from .experiment_runs import router as experiment_runs_router
|
|
11
11
|
from .experiments import router as experiments_router
|
|
12
|
+
from .projects import router as projects_router
|
|
12
13
|
from .prompts import router as prompts_router
|
|
13
14
|
from .spans import router as spans_router
|
|
14
15
|
from .traces import router as traces_router
|
|
@@ -63,4 +64,5 @@ def create_v1_router(authentication_enabled: bool) -> APIRouter:
|
|
|
63
64
|
router.include_router(spans_router)
|
|
64
65
|
router.include_router(evaluations_router)
|
|
65
66
|
router.include_router(prompts_router)
|
|
67
|
+
router.include_router(projects_router)
|
|
66
68
|
return router
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, HTTPException, Path, Query
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
from sqlalchemy import select
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
from starlette.requests import Request
|
|
8
|
+
from starlette.status import (
|
|
9
|
+
HTTP_204_NO_CONTENT,
|
|
10
|
+
HTTP_403_FORBIDDEN,
|
|
11
|
+
HTTP_404_NOT_FOUND,
|
|
12
|
+
HTTP_422_UNPROCESSABLE_ENTITY,
|
|
13
|
+
)
|
|
14
|
+
from strawberry.relay import GlobalID
|
|
15
|
+
|
|
16
|
+
from phoenix.config import DEFAULT_PROJECT_NAME
|
|
17
|
+
from phoenix.db import models
|
|
18
|
+
from phoenix.db.enums import UserRole
|
|
19
|
+
from phoenix.db.helpers import exclude_experiment_projects
|
|
20
|
+
from phoenix.server.api.routers.v1.models import V1RoutesBaseModel
|
|
21
|
+
from phoenix.server.api.routers.v1.utils import (
|
|
22
|
+
PaginatedResponseBody,
|
|
23
|
+
ResponseBody,
|
|
24
|
+
add_errors_to_responses,
|
|
25
|
+
)
|
|
26
|
+
from phoenix.server.api.types.node import from_global_id_with_expected_type
|
|
27
|
+
from phoenix.server.api.types.Project import Project as ProjectNodeType
|
|
28
|
+
|
|
29
|
+
router = APIRouter(tags=["projects"])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ProjectData(V1RoutesBaseModel):
|
|
33
|
+
name: str = Field(..., min_length=1)
|
|
34
|
+
description: Optional[str] = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Project(ProjectData):
|
|
38
|
+
id: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class GetProjectsResponseBody(PaginatedResponseBody[Project]):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class GetProjectResponseBody(ResponseBody[Project]):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CreateProjectRequestBody(ProjectData):
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CreateProjectResponseBody(ResponseBody[Project]):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class UpdateProjectRequestBody(V1RoutesBaseModel):
|
|
58
|
+
description: Optional[str] = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class UpdateProjectResponseBody(ResponseBody[Project]):
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@router.get(
|
|
66
|
+
"/projects",
|
|
67
|
+
operation_id="getProjects",
|
|
68
|
+
summary="List all projects", # noqa: E501
|
|
69
|
+
description="Retrieve a paginated list of all projects in the system.", # noqa: E501
|
|
70
|
+
response_description="A list of projects with pagination information", # noqa: E501
|
|
71
|
+
responses=add_errors_to_responses(
|
|
72
|
+
[
|
|
73
|
+
HTTP_422_UNPROCESSABLE_ENTITY,
|
|
74
|
+
]
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
async def get_projects(
|
|
78
|
+
request: Request,
|
|
79
|
+
cursor: Optional[str] = Query(
|
|
80
|
+
default=None,
|
|
81
|
+
description="Cursor for pagination (project ID)",
|
|
82
|
+
),
|
|
83
|
+
limit: int = Query(
|
|
84
|
+
default=100, description="The max number of projects to return at a time.", gt=0
|
|
85
|
+
),
|
|
86
|
+
include_experiment_projects: bool = Query(
|
|
87
|
+
default=False,
|
|
88
|
+
description="Include experiment projects in the response. Experiment projects are created from running experiments.", # noqa: E501
|
|
89
|
+
),
|
|
90
|
+
) -> GetProjectsResponseBody:
|
|
91
|
+
"""
|
|
92
|
+
Retrieve a paginated list of all projects in the system.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
request (Request): The FastAPI request object.
|
|
96
|
+
cursor (Optional[str]): Pagination cursor (project ID).
|
|
97
|
+
limit (int): Maximum number of projects to return per request.
|
|
98
|
+
include_experiment_projects (bool): Flag to include experiment projects in the response.
|
|
99
|
+
Experiment projects are created from running experiments.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
GetProjectsResponseBody: Response containing a list of projects and pagination information.
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
HTTPException: If the cursor format is invalid.
|
|
106
|
+
""" # noqa: E501
|
|
107
|
+
stmt = select(models.Project).order_by(models.Project.id.desc())
|
|
108
|
+
if not include_experiment_projects:
|
|
109
|
+
stmt = exclude_experiment_projects(stmt)
|
|
110
|
+
async with request.app.state.db() as session:
|
|
111
|
+
if cursor:
|
|
112
|
+
try:
|
|
113
|
+
cursor_id = GlobalID.from_id(cursor).node_id
|
|
114
|
+
stmt = stmt.filter(models.Project.id <= int(cursor_id))
|
|
115
|
+
except ValueError:
|
|
116
|
+
raise HTTPException(
|
|
117
|
+
detail=f"Invalid cursor format: {cursor}",
|
|
118
|
+
status_code=HTTP_422_UNPROCESSABLE_ENTITY,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
stmt = stmt.limit(limit + 1)
|
|
122
|
+
projects = (await session.scalars(stmt)).all()
|
|
123
|
+
|
|
124
|
+
if not projects:
|
|
125
|
+
return GetProjectsResponseBody(next_cursor=None, data=[])
|
|
126
|
+
|
|
127
|
+
next_cursor = None
|
|
128
|
+
if len(projects) == limit + 1:
|
|
129
|
+
last_project = projects[-1]
|
|
130
|
+
next_cursor = str(GlobalID(ProjectNodeType.__name__, str(last_project.id)))
|
|
131
|
+
projects = projects[:-1]
|
|
132
|
+
|
|
133
|
+
project_responses = [_to_project_response(project) for project in projects]
|
|
134
|
+
return GetProjectsResponseBody(next_cursor=next_cursor, data=project_responses)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@router.get(
|
|
138
|
+
"/projects/{project_identifier}",
|
|
139
|
+
operation_id="getProject",
|
|
140
|
+
summary="Get project by ID or name", # noqa: E501
|
|
141
|
+
description="Retrieve a specific project using its unique identifier: either project ID or project name. Note: When using a project name as the identifier, it cannot contain slash (/), question mark (?), or pound sign (#) characters.", # noqa: E501
|
|
142
|
+
response_description="The requested project", # noqa: E501
|
|
143
|
+
responses=add_errors_to_responses(
|
|
144
|
+
[
|
|
145
|
+
HTTP_404_NOT_FOUND,
|
|
146
|
+
HTTP_422_UNPROCESSABLE_ENTITY,
|
|
147
|
+
]
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
async def get_project(
|
|
151
|
+
request: Request,
|
|
152
|
+
project_identifier: str = Path(
|
|
153
|
+
description="The project identifier: either project ID or project name. If using a project name, it cannot contain slash (/), question mark (?), or pound sign (#) characters.", # noqa: E501
|
|
154
|
+
),
|
|
155
|
+
) -> GetProjectResponseBody:
|
|
156
|
+
"""
|
|
157
|
+
Retrieve a specific project by its ID or name.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
request (Request): The FastAPI request object.
|
|
161
|
+
project_identifier (str): The project identifier: either project ID or project name.
|
|
162
|
+
If using a project name, it cannot contain slash (/), question mark (?), or pound sign (#) characters.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
GetProjectResponseBody: Response containing the requested project.
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
HTTPException: If the project identifier format is invalid or the project is not found.
|
|
169
|
+
""" # noqa: E501
|
|
170
|
+
async with request.app.state.db() as session:
|
|
171
|
+
project = await _get_project_by_identifier(session, project_identifier)
|
|
172
|
+
data = _to_project_response(project)
|
|
173
|
+
return GetProjectResponseBody(data=data)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@router.post(
|
|
177
|
+
"/projects",
|
|
178
|
+
operation_id="createProject",
|
|
179
|
+
summary="Create a new project", # noqa: E501
|
|
180
|
+
description="Create a new project with the specified configuration.", # noqa: E501
|
|
181
|
+
response_description="The newly created project", # noqa: E501
|
|
182
|
+
responses=add_errors_to_responses(
|
|
183
|
+
[
|
|
184
|
+
HTTP_422_UNPROCESSABLE_ENTITY,
|
|
185
|
+
]
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
async def create_project(
|
|
189
|
+
request: Request,
|
|
190
|
+
request_body: CreateProjectRequestBody,
|
|
191
|
+
) -> CreateProjectResponseBody:
|
|
192
|
+
"""
|
|
193
|
+
Create a new project.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
request (Request): The FastAPI request object.
|
|
197
|
+
request_body (CreateProjectRequestBody): The request body containing project data.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
CreateProjectResponseBody: Response containing the created project.
|
|
201
|
+
|
|
202
|
+
Raises:
|
|
203
|
+
HTTPException: If any validation error occurs.
|
|
204
|
+
"""
|
|
205
|
+
async with request.app.state.db() as session:
|
|
206
|
+
project = models.Project(
|
|
207
|
+
name=request_body.name,
|
|
208
|
+
description=request_body.description,
|
|
209
|
+
)
|
|
210
|
+
session.add(project)
|
|
211
|
+
await session.flush()
|
|
212
|
+
data = _to_project_response(project)
|
|
213
|
+
return CreateProjectResponseBody(data=data)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@router.put(
|
|
217
|
+
"/projects/{project_identifier}",
|
|
218
|
+
operation_id="updateProject",
|
|
219
|
+
summary="Update a project by ID or name", # noqa: E501
|
|
220
|
+
description="Update an existing project with new configuration. Project names cannot be changed. The project identifier is either project ID or project name. Note: When using a project name as the identifier, it cannot contain slash (/), question mark (?), or pound sign (#) characters.", # noqa: E501
|
|
221
|
+
response_description="The updated project", # noqa: E501
|
|
222
|
+
responses=add_errors_to_responses(
|
|
223
|
+
[
|
|
224
|
+
HTTP_403_FORBIDDEN,
|
|
225
|
+
HTTP_404_NOT_FOUND,
|
|
226
|
+
HTTP_422_UNPROCESSABLE_ENTITY,
|
|
227
|
+
]
|
|
228
|
+
),
|
|
229
|
+
)
|
|
230
|
+
async def update_project(
|
|
231
|
+
request: Request,
|
|
232
|
+
request_body: UpdateProjectRequestBody,
|
|
233
|
+
project_identifier: str = Path(
|
|
234
|
+
description="The project identifier: either project ID or project name. If using a project name, it cannot contain slash (/), question mark (?), or pound sign (#) characters.", # noqa: E501
|
|
235
|
+
),
|
|
236
|
+
) -> UpdateProjectResponseBody:
|
|
237
|
+
"""
|
|
238
|
+
Update an existing project.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
request (Request): The FastAPI request object.
|
|
242
|
+
request_body (UpdateProjectRequestBody): The request body containing the new description.
|
|
243
|
+
project_identifier (str): The project identifier: either project ID or project name.
|
|
244
|
+
If using a project name, it cannot contain slash (/), question mark (?), or pound sign (#) characters.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
UpdateProjectResponseBody: Response containing the updated project.
|
|
248
|
+
|
|
249
|
+
Raises:
|
|
250
|
+
HTTPException: If the project identifier format is invalid or the project is not found.
|
|
251
|
+
""" # noqa: E501
|
|
252
|
+
if request.app.state.authentication_enabled:
|
|
253
|
+
async with request.app.state.db() as session:
|
|
254
|
+
# Check if the user is an admin
|
|
255
|
+
stmt = (
|
|
256
|
+
select(models.UserRole.name)
|
|
257
|
+
.join(models.User)
|
|
258
|
+
.where(models.User.id == int(request.user.identity))
|
|
259
|
+
)
|
|
260
|
+
role_name = await session.scalar(stmt)
|
|
261
|
+
if role_name != UserRole.ADMIN.value:
|
|
262
|
+
raise HTTPException(
|
|
263
|
+
status_code=HTTP_403_FORBIDDEN,
|
|
264
|
+
detail="Only admins can update projects",
|
|
265
|
+
)
|
|
266
|
+
async with request.app.state.db() as session:
|
|
267
|
+
project = await _get_project_by_identifier(session, project_identifier)
|
|
268
|
+
|
|
269
|
+
# Update the description if provided
|
|
270
|
+
if request_body.description is not None:
|
|
271
|
+
project.description = request_body.description
|
|
272
|
+
|
|
273
|
+
data = _to_project_response(project)
|
|
274
|
+
return UpdateProjectResponseBody(data=data)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@router.delete(
|
|
278
|
+
"/projects/{project_identifier}",
|
|
279
|
+
operation_id="deleteProject",
|
|
280
|
+
summary="Delete a project by ID or name", # noqa: E501
|
|
281
|
+
description="Delete an existing project and all its associated data. The project identifier is either project ID or project name. The default project cannot be deleted. Note: When using a project name as the identifier, it cannot contain slash (/), question mark (?), or pound sign (#) characters.", # noqa: E501
|
|
282
|
+
response_description="No content returned on successful deletion", # noqa: E501
|
|
283
|
+
status_code=HTTP_204_NO_CONTENT,
|
|
284
|
+
responses=add_errors_to_responses(
|
|
285
|
+
[
|
|
286
|
+
HTTP_403_FORBIDDEN,
|
|
287
|
+
HTTP_404_NOT_FOUND,
|
|
288
|
+
HTTP_422_UNPROCESSABLE_ENTITY,
|
|
289
|
+
]
|
|
290
|
+
),
|
|
291
|
+
)
|
|
292
|
+
async def delete_project(
|
|
293
|
+
request: Request,
|
|
294
|
+
project_identifier: str = Path(
|
|
295
|
+
description="The project identifier: either project ID or project name. If using a project name, it cannot contain slash (/), question mark (?), or pound sign (#) characters.", # noqa: E501
|
|
296
|
+
),
|
|
297
|
+
) -> None:
|
|
298
|
+
"""
|
|
299
|
+
Delete an existing project.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
request (Request): The FastAPI request object.
|
|
303
|
+
project_identifier (str): The project identifier: either project ID or project name.
|
|
304
|
+
If using a project name, it cannot contain slash (/), question mark (?), or pound sign (#) characters.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
None: Returns a 204 No Content response on success.
|
|
308
|
+
|
|
309
|
+
Raises:
|
|
310
|
+
HTTPException: If the project identifier format is invalid, the project is not found, or it's the default project.
|
|
311
|
+
""" # noqa: E501
|
|
312
|
+
if request.app.state.authentication_enabled:
|
|
313
|
+
async with request.app.state.db() as session:
|
|
314
|
+
# Check if the user is an admin
|
|
315
|
+
stmt = (
|
|
316
|
+
select(models.UserRole.name)
|
|
317
|
+
.join(models.User)
|
|
318
|
+
.where(models.User.id == int(request.user.identity))
|
|
319
|
+
)
|
|
320
|
+
role_name = await session.scalar(stmt)
|
|
321
|
+
if role_name != UserRole.ADMIN.value:
|
|
322
|
+
raise HTTPException(
|
|
323
|
+
status_code=HTTP_403_FORBIDDEN,
|
|
324
|
+
detail="Only admins can delete projects",
|
|
325
|
+
)
|
|
326
|
+
async with request.app.state.db() as session:
|
|
327
|
+
project = await _get_project_by_identifier(session, project_identifier)
|
|
328
|
+
|
|
329
|
+
# The default project must not be deleted - it's forbidden
|
|
330
|
+
if project.name == DEFAULT_PROJECT_NAME:
|
|
331
|
+
raise HTTPException(
|
|
332
|
+
status_code=HTTP_403_FORBIDDEN,
|
|
333
|
+
detail="The default project cannot be deleted",
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
await session.delete(project)
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _to_project_response(project: models.Project) -> Project:
|
|
341
|
+
return Project(
|
|
342
|
+
id=str(GlobalID(ProjectNodeType.__name__, str(project.id))),
|
|
343
|
+
name=project.name,
|
|
344
|
+
description=project.description,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
async def _get_project_by_identifier(
|
|
349
|
+
session: AsyncSession,
|
|
350
|
+
project_identifier: str,
|
|
351
|
+
) -> models.Project:
|
|
352
|
+
"""
|
|
353
|
+
Get a project by its ID or name.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
session: The database session.
|
|
357
|
+
project_identifier: The project ID or name.
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
The project object.
|
|
361
|
+
|
|
362
|
+
Raises:
|
|
363
|
+
HTTPException: If the identifier format is invalid or the project is not found.
|
|
364
|
+
"""
|
|
365
|
+
# Try to parse as a GlobalID first
|
|
366
|
+
try:
|
|
367
|
+
id_ = from_global_id_with_expected_type(
|
|
368
|
+
GlobalID.from_id(project_identifier),
|
|
369
|
+
ProjectNodeType.__name__,
|
|
370
|
+
)
|
|
371
|
+
except Exception:
|
|
372
|
+
try:
|
|
373
|
+
name = project_identifier
|
|
374
|
+
except HTTPException:
|
|
375
|
+
raise HTTPException(
|
|
376
|
+
status_code=HTTP_422_UNPROCESSABLE_ENTITY,
|
|
377
|
+
detail=f"Invalid project identifier format: {project_identifier}",
|
|
378
|
+
)
|
|
379
|
+
stmt = select(models.Project).filter_by(name=name)
|
|
380
|
+
project = await session.scalar(stmt)
|
|
381
|
+
if project is None:
|
|
382
|
+
raise HTTPException(
|
|
383
|
+
status_code=HTTP_404_NOT_FOUND,
|
|
384
|
+
detail=f"Project with name {name} not found",
|
|
385
|
+
)
|
|
386
|
+
else:
|
|
387
|
+
project = await session.get(models.Project, id_)
|
|
388
|
+
if project is None:
|
|
389
|
+
raise HTTPException(
|
|
390
|
+
status_code=HTTP_404_NOT_FOUND,
|
|
391
|
+
detail=f"Project with ID {project_identifier} not found",
|
|
392
|
+
)
|
|
393
|
+
return project
|
|
@@ -23,6 +23,7 @@ from strawberry.relay.types import GlobalID
|
|
|
23
23
|
from strawberry.types import Info
|
|
24
24
|
from typing_extensions import TypeAlias, assert_never
|
|
25
25
|
|
|
26
|
+
from phoenix.config import PLAYGROUND_PROJECT_NAME
|
|
26
27
|
from phoenix.datetime_utils import local_now, normalize_datetime
|
|
27
28
|
from phoenix.db import models
|
|
28
29
|
from phoenix.server.api.auth import IsLocked, IsNotReadOnly
|
|
@@ -84,7 +85,6 @@ ChatCompletionResult: TypeAlias = tuple[
|
|
|
84
85
|
DatasetExampleID, Optional[models.Span], models.ExperimentRun
|
|
85
86
|
]
|
|
86
87
|
ChatStream: TypeAlias = AsyncGenerator[ChatCompletionSubscriptionPayload, None]
|
|
87
|
-
PLAYGROUND_PROJECT_NAME = "playground"
|
|
88
88
|
|
|
89
89
|
|
|
90
90
|
@strawberry.type
|
phoenix/server/app.py
CHANGED
|
@@ -24,6 +24,7 @@ from urllib.parse import urlparse
|
|
|
24
24
|
|
|
25
25
|
import strawberry
|
|
26
26
|
from fastapi import APIRouter, Depends, FastAPI
|
|
27
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
27
28
|
from fastapi.middleware.gzip import GZipMiddleware
|
|
28
29
|
from fastapi.utils import is_body_allowed_for_status_code
|
|
29
30
|
from grpc.aio import ServerInterceptor
|
|
@@ -59,6 +60,7 @@ from phoenix.config import (
|
|
|
59
60
|
get_env_host,
|
|
60
61
|
get_env_port,
|
|
61
62
|
server_instrumentation_is_enabled,
|
|
63
|
+
verify_server_environment_variables,
|
|
62
64
|
)
|
|
63
65
|
from phoenix.core.model_schema import Model
|
|
64
66
|
from phoenix.db import models
|
|
@@ -550,6 +552,7 @@ def create_graphql_router(
|
|
|
550
552
|
read_only: bool = False,
|
|
551
553
|
secret: Optional[str] = None,
|
|
552
554
|
token_store: Optional[TokenStore] = None,
|
|
555
|
+
email_sender: Optional[EmailSender] = None,
|
|
553
556
|
) -> GraphQLRouter[Context, None]:
|
|
554
557
|
"""Creates the GraphQL router.
|
|
555
558
|
|
|
@@ -565,6 +568,8 @@ def create_graphql_router(
|
|
|
565
568
|
cache_for_dataloaders (Optional[CacheForDataLoaders], optional): GraphQL data loaders.
|
|
566
569
|
read_only (bool, optional): Marks the app as read-only. Defaults to False.
|
|
567
570
|
secret (Optional[str], optional): The application secret for auth. Defaults to None.
|
|
571
|
+
token_store (Optional[TokenStore], optional): The token store for auth. Defaults to None.
|
|
572
|
+
email_sender (Optional[EmailSender], optional): The email sender. Defaults to None.
|
|
568
573
|
|
|
569
574
|
Returns:
|
|
570
575
|
GraphQLRouter: The router mounted at /graphql
|
|
@@ -653,6 +658,7 @@ def create_graphql_router(
|
|
|
653
658
|
auth_enabled=authentication_enabled,
|
|
654
659
|
secret=secret,
|
|
655
660
|
token_store=token_store,
|
|
661
|
+
email_sender=email_sender,
|
|
656
662
|
)
|
|
657
663
|
|
|
658
664
|
return GraphQLRouter(
|
|
@@ -765,7 +771,9 @@ def create_app(
|
|
|
765
771
|
email_sender: Optional[EmailSender] = None,
|
|
766
772
|
oauth2_client_configs: Optional[list[OAuth2ClientConfig]] = None,
|
|
767
773
|
bulk_inserter_factory: Optional[Callable[..., BulkInserter]] = None,
|
|
774
|
+
allowed_origins: Optional[list[str]] = None,
|
|
768
775
|
) -> FastAPI:
|
|
776
|
+
verify_server_environment_variables()
|
|
769
777
|
if model.embedding_dimensions:
|
|
770
778
|
try:
|
|
771
779
|
import fast_hdbscan # noqa: F401
|
|
@@ -868,6 +876,7 @@ def create_app(
|
|
|
868
876
|
read_only=read_only,
|
|
869
877
|
secret=secret,
|
|
870
878
|
token_store=token_store,
|
|
879
|
+
email_sender=email_sender,
|
|
871
880
|
)
|
|
872
881
|
if enable_prometheus:
|
|
873
882
|
from phoenix.server.prometheus import PrometheusMiddleware
|
|
@@ -948,6 +957,14 @@ def create_app(
|
|
|
948
957
|
FastAPIInstrumentor().instrument(tracer_provider=tracer_provider)
|
|
949
958
|
FastAPIInstrumentor.instrument_app(app, tracer_provider=tracer_provider)
|
|
950
959
|
shutdown_callbacks_list.append(FastAPIInstrumentor().uninstrument)
|
|
960
|
+
if allowed_origins:
|
|
961
|
+
app.add_middleware(
|
|
962
|
+
CORSMiddleware,
|
|
963
|
+
allow_origins=allowed_origins,
|
|
964
|
+
allow_credentials=True,
|
|
965
|
+
allow_methods=["*"],
|
|
966
|
+
allow_headers=["*"],
|
|
967
|
+
)
|
|
951
968
|
return app
|
|
952
969
|
|
|
953
970
|
|