arize-phoenix 12.4.0__py3-none-any.whl → 12.5.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 (59) hide show
  1. {arize_phoenix-12.4.0.dist-info → arize_phoenix-12.5.0.dist-info}/METADATA +1 -1
  2. {arize_phoenix-12.4.0.dist-info → arize_phoenix-12.5.0.dist-info}/RECORD +59 -58
  3. phoenix/auth.py +8 -2
  4. phoenix/db/models.py +3 -3
  5. phoenix/server/api/auth.py +9 -0
  6. phoenix/server/api/context.py +2 -0
  7. phoenix/server/api/dataloaders/__init__.py +2 -0
  8. phoenix/server/api/dataloaders/dataset_dataset_splits.py +52 -0
  9. phoenix/server/api/input_types/ProjectSessionSort.py +158 -1
  10. phoenix/server/api/input_types/SpanSort.py +2 -1
  11. phoenix/server/api/input_types/UserRoleInput.py +1 -0
  12. phoenix/server/api/mutations/annotation_config_mutations.py +6 -6
  13. phoenix/server/api/mutations/api_key_mutations.py +13 -5
  14. phoenix/server/api/mutations/chat_mutations.py +3 -3
  15. phoenix/server/api/mutations/dataset_label_mutations.py +6 -6
  16. phoenix/server/api/mutations/dataset_mutations.py +8 -8
  17. phoenix/server/api/mutations/dataset_split_mutations.py +7 -7
  18. phoenix/server/api/mutations/experiment_mutations.py +2 -2
  19. phoenix/server/api/mutations/export_events_mutations.py +3 -3
  20. phoenix/server/api/mutations/model_mutations.py +4 -4
  21. phoenix/server/api/mutations/project_mutations.py +4 -4
  22. phoenix/server/api/mutations/project_session_annotations_mutations.py +4 -4
  23. phoenix/server/api/mutations/project_trace_retention_policy_mutations.py +8 -4
  24. phoenix/server/api/mutations/prompt_label_mutations.py +7 -7
  25. phoenix/server/api/mutations/prompt_mutations.py +7 -7
  26. phoenix/server/api/mutations/prompt_version_tag_mutations.py +3 -3
  27. phoenix/server/api/mutations/span_annotations_mutations.py +5 -5
  28. phoenix/server/api/mutations/trace_annotations_mutations.py +4 -4
  29. phoenix/server/api/mutations/trace_mutations.py +3 -3
  30. phoenix/server/api/mutations/user_mutations.py +8 -5
  31. phoenix/server/api/routers/auth.py +2 -2
  32. phoenix/server/api/routers/v1/__init__.py +16 -1
  33. phoenix/server/api/routers/v1/annotation_configs.py +7 -1
  34. phoenix/server/api/routers/v1/datasets.py +48 -8
  35. phoenix/server/api/routers/v1/experiment_runs.py +7 -1
  36. phoenix/server/api/routers/v1/experiments.py +41 -5
  37. phoenix/server/api/routers/v1/projects.py +3 -31
  38. phoenix/server/api/routers/v1/users.py +0 -7
  39. phoenix/server/api/subscriptions.py +3 -3
  40. phoenix/server/api/types/Dataset.py +95 -6
  41. phoenix/server/api/types/Project.py +24 -68
  42. phoenix/server/app.py +2 -0
  43. phoenix/server/authorization.py +3 -1
  44. phoenix/server/bearer_auth.py +9 -0
  45. phoenix/server/jwt_store.py +8 -6
  46. phoenix/server/static/.vite/manifest.json +39 -39
  47. phoenix/server/static/assets/{components-BvsExS75.js → components-cwdYEs7B.js} +501 -394
  48. phoenix/server/static/assets/{index-iq8WDxat.js → index-Dc0vD1Rn.js} +1 -1
  49. phoenix/server/static/assets/{pages-Ckg4SLQ9.js → pages-BDkB3a_a.js} +577 -533
  50. phoenix/server/static/assets/{vendor-D2eEI-6h.js → vendor-Ce6GTAin.js} +1 -1
  51. phoenix/server/static/assets/{vendor-arizeai-kfOei7nf.js → vendor-arizeai-CSF-1Kc5.js} +1 -1
  52. phoenix/server/static/assets/{vendor-codemirror-1bq_t1Ec.js → vendor-codemirror-Bv8J_7an.js} +3 -3
  53. phoenix/server/static/assets/{vendor-recharts-DQ4xfrf4.js → vendor-recharts-DcLgzI7g.js} +1 -1
  54. phoenix/server/static/assets/{vendor-shiki-GGmcIQxA.js → vendor-shiki-BF8rh_7m.js} +1 -1
  55. phoenix/version.py +1 -1
  56. {arize_phoenix-12.4.0.dist-info → arize_phoenix-12.5.0.dist-info}/WHEEL +0 -0
  57. {arize_phoenix-12.4.0.dist-info → arize_phoenix-12.5.0.dist-info}/entry_points.txt +0 -0
  58. {arize_phoenix-12.4.0.dist-info → arize_phoenix-12.5.0.dist-info}/licenses/IP_NOTICE +0 -0
  59. {arize_phoenix-12.4.0.dist-info → arize_phoenix-12.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -7,7 +7,7 @@ from starlette.requests import Request
7
7
  from strawberry import UNSET, Info
8
8
 
9
9
  from phoenix.db import models
10
- from phoenix.server.api.auth import IsLocked, IsNotReadOnly
10
+ from phoenix.server.api.auth import IsLocked, IsNotReadOnly, IsNotViewer
11
11
  from phoenix.server.api.context import Context
12
12
  from phoenix.server.api.exceptions import BadRequest, NotFound, Unauthorized
13
13
  from phoenix.server.api.helpers.annotations import get_user_identifier
@@ -34,7 +34,7 @@ class SpanAnnotationMutationPayload:
34
34
 
35
35
  @strawberry.type
36
36
  class SpanAnnotationMutationMixin:
37
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsLocked]) # type: ignore
37
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsLocked]) # type: ignore
38
38
  async def create_span_annotations(
39
39
  self, info: Info[Context, None], input: list[CreateSpanAnnotationInput]
40
40
  ) -> SpanAnnotationMutationPayload:
@@ -148,7 +148,7 @@ class SpanAnnotationMutationMixin:
148
148
  query=Query(),
149
149
  )
150
150
 
151
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsLocked]) # type: ignore
151
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsLocked]) # type: ignore
152
152
  async def create_span_note(
153
153
  self, info: Info[Context, None], annotation_input: CreateSpanNoteInput
154
154
  ) -> SpanAnnotationMutationPayload:
@@ -191,7 +191,7 @@ class SpanAnnotationMutationMixin:
191
191
  query=Query(),
192
192
  )
193
193
 
194
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsLocked]) # type: ignore
194
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsLocked]) # type: ignore
195
195
  async def patch_span_annotations(
196
196
  self, info: Info[Context, None], input: list[PatchAnnotationInput]
197
197
  ) -> SpanAnnotationMutationPayload:
@@ -268,7 +268,7 @@ class SpanAnnotationMutationMixin:
268
268
  query=Query(),
269
269
  )
270
270
 
271
- @strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
271
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer]) # type: ignore
272
272
  async def delete_span_annotations(
273
273
  self, info: Info[Context, None], input: DeleteAnnotationsInput
274
274
  ) -> SpanAnnotationMutationPayload:
@@ -6,7 +6,7 @@ from starlette.requests import Request
6
6
  from strawberry import UNSET, Info
7
7
 
8
8
  from phoenix.db import models
9
- from phoenix.server.api.auth import IsLocked, IsNotReadOnly
9
+ from phoenix.server.api.auth import IsLocked, IsNotReadOnly, IsNotViewer
10
10
  from phoenix.server.api.context import Context
11
11
  from phoenix.server.api.exceptions import BadRequest, NotFound, Unauthorized
12
12
  from phoenix.server.api.helpers.annotations import get_user_identifier
@@ -29,7 +29,7 @@ class TraceAnnotationMutationPayload:
29
29
 
30
30
  @strawberry.type
31
31
  class TraceAnnotationMutationMixin:
32
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsLocked]) # type: ignore
32
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsLocked]) # type: ignore
33
33
  async def create_trace_annotations(
34
34
  self, info: Info[Context, None], input: list[CreateTraceAnnotationInput]
35
35
  ) -> TraceAnnotationMutationPayload:
@@ -120,7 +120,7 @@ class TraceAnnotationMutationMixin:
120
120
  query=Query(),
121
121
  )
122
122
 
123
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsLocked]) # type: ignore
123
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsLocked]) # type: ignore
124
124
  async def patch_trace_annotations(
125
125
  self, info: Info[Context, None], input: list[PatchAnnotationInput]
126
126
  ) -> TraceAnnotationMutationPayload:
@@ -195,7 +195,7 @@ class TraceAnnotationMutationMixin:
195
195
  query=Query(),
196
196
  )
197
197
 
198
- @strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
198
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer]) # type: ignore
199
199
  async def delete_trace_annotations(
200
200
  self, info: Info[Context, None], input: DeleteAnnotationsInput
201
201
  ) -> TraceAnnotationMutationPayload:
@@ -6,7 +6,7 @@ from strawberry.relay import GlobalID
6
6
  from strawberry.types import Info
7
7
 
8
8
  from phoenix.db import models
9
- from phoenix.server.api.auth import IsNotReadOnly
9
+ from phoenix.server.api.auth import IsNotReadOnly, IsNotViewer
10
10
  from phoenix.server.api.context import Context
11
11
  from phoenix.server.api.exceptions import BadRequest
12
12
  from phoenix.server.api.queries import Query
@@ -16,7 +16,7 @@ from phoenix.server.dml_event import SpanDeleteEvent
16
16
 
17
17
  @strawberry.type
18
18
  class TraceMutationMixin:
19
- @strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
19
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer]) # type: ignore
20
20
  async def delete_traces(
21
21
  self,
22
22
  info: Info[Context, None],
@@ -73,7 +73,7 @@ class TraceMutationMixin:
73
73
  info.context.event_queue.put(SpanDeleteEvent(project_ids))
74
74
  return Query()
75
75
 
76
- @strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
76
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer]) # type: ignore
77
77
  async def transfer_traces_to_project(
78
78
  self,
79
79
  info: Info[Context, None],
@@ -27,7 +27,7 @@ from phoenix.auth import (
27
27
  )
28
28
  from phoenix.config import get_env_disable_basic_auth
29
29
  from phoenix.db import models
30
- from phoenix.server.api.auth import IsAdmin, IsLocked, IsNotReadOnly
30
+ from phoenix.server.api.auth import IsAdmin, IsLocked, IsNotReadOnly, IsNotViewer
31
31
  from phoenix.server.api.context import Context
32
32
  from phoenix.server.api.exceptions import BadRequest, Conflict, NotFound, Unauthorized
33
33
  from phoenix.server.api.input_types.UserRoleInput import UserRoleInput
@@ -110,7 +110,7 @@ class UserMutationPayload:
110
110
 
111
111
  @strawberry.type
112
112
  class UserMutationMixin:
113
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsAdmin, IsLocked]) # type: ignore
113
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsAdmin, IsLocked]) # type: ignore
114
114
  async def create_user(
115
115
  self,
116
116
  info: Info[Context, None],
@@ -157,7 +157,7 @@ class UserMutationMixin:
157
157
  logger.error(f"Failed to send welcome email: {error}")
158
158
  return UserMutationPayload(user=to_gql_user(user))
159
159
 
160
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsAdmin]) # type: ignore
160
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsAdmin]) # type: ignore
161
161
  async def patch_user(
162
162
  self,
163
163
  info: Info[Context, None],
@@ -174,6 +174,7 @@ class UserMutationMixin:
174
174
  if not (user := await session.scalar(_select_user_by_id(user_id))):
175
175
  raise NotFound("User not found")
176
176
  stack.enter_context(session.no_autoflush)
177
+ should_log_out = False
177
178
  if input.new_role:
178
179
  if user.email == DEFAULT_ADMIN_EMAIL:
179
180
  raise Unauthorized("Cannot modify role for the default admin user")
@@ -183,6 +184,7 @@ class UserMutationMixin:
183
184
  if user_role_id is None:
184
185
  raise NotFound(f"Role {input.new_role.value} not found")
185
186
  user.user_role_id = user_role_id
187
+ should_log_out = True
186
188
  if password := input.new_password:
187
189
  if user.auth_method != "LOCAL":
188
190
  raise Conflict("Cannot modify password for non-local user")
@@ -191,6 +193,7 @@ class UserMutationMixin:
191
193
  user.password_salt = salt
192
194
  user.password_hash = await info.context.hash_password(Secret(password), salt)
193
195
  user.reset_password = True
196
+ should_log_out = True
194
197
  if username := input.new_username:
195
198
  user.username = username
196
199
  assert user in session.dirty
@@ -199,7 +202,7 @@ class UserMutationMixin:
199
202
  except (PostgreSQLIntegrityError, SQLiteIntegrityError) as error:
200
203
  raise Conflict(_user_operation_error_message(error, "modify"))
201
204
  assert user
202
- if input.new_password:
205
+ if should_log_out:
203
206
  await info.context.log_out(user.id)
204
207
  return UserMutationPayload(user=to_gql_user(user))
205
208
 
@@ -245,7 +248,7 @@ class UserMutationMixin:
245
248
  response.delete_cookie(PHOENIX_ACCESS_TOKEN_COOKIE_NAME)
246
249
  return UserMutationPayload(user=to_gql_user(user))
247
250
 
248
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsAdmin, IsLocked]) # type: ignore
251
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsAdmin, IsLocked]) # type: ignore
249
252
  async def delete_users(
250
253
  self,
251
254
  info: Info[Context, None],
@@ -161,7 +161,7 @@ async def refresh_tokens(request: Request) -> Response:
161
161
  or (expiration_time := refresh_token_claims.expiration_time) is None
162
162
  ):
163
163
  raise HTTPException(status_code=401, detail="Invalid refresh token")
164
- if expiration_time.timestamp() < datetime.now().timestamp():
164
+ if expiration_time.timestamp() <= datetime.now(timezone.utc).timestamp():
165
165
  raise HTTPException(status_code=401, detail="Expired refresh token")
166
166
  await token_store.revoke(refresh_token_id)
167
167
 
@@ -253,7 +253,7 @@ async def reset_password(request: Request) -> Response:
253
253
  not (token := data.get("token"))
254
254
  or not isinstance((claims := await token_store.read(token)), PasswordResetTokenClaims)
255
255
  or not claims.expiration_time
256
- or claims.expiration_time < datetime.now(timezone.utc)
256
+ or claims.expiration_time <= datetime.now(timezone.utc)
257
257
  ):
258
258
  raise INVALID_TOKEN
259
259
  assert (user_id := claims.subject)
@@ -1,7 +1,7 @@
1
1
  from fastapi import APIRouter, Depends, HTTPException, Request
2
2
  from fastapi.security import APIKeyHeader
3
3
 
4
- from phoenix.server.bearer_auth import is_authenticated
4
+ from phoenix.server.bearer_auth import PhoenixUser, is_authenticated
5
5
 
6
6
  from .annotation_configs import router as annotation_configs_router
7
7
  from .annotations import router as annotations_router
@@ -33,6 +33,20 @@ async def prevent_access_in_read_only_mode(request: Request) -> None:
33
33
  )
34
34
 
35
35
 
36
+ async def restrict_access_by_viewers(request: Request) -> None:
37
+ """
38
+ Prevents access to the REST API for viewers, except for GET requests
39
+ and specific allowed POST routes.
40
+ """
41
+ if request.method == "GET":
42
+ return
43
+ if isinstance(request.user, PhoenixUser) and request.user.is_viewer:
44
+ raise HTTPException(
45
+ status_code=403,
46
+ detail="Viewers cannot perform this action.",
47
+ )
48
+
49
+
36
50
  def create_v1_router(authentication_enabled: bool) -> APIRouter:
37
51
  """
38
52
  Instantiates the v1 REST API router.
@@ -50,6 +64,7 @@ def create_v1_router(authentication_enabled: bool) -> APIRouter:
50
64
  )
51
65
  )
52
66
  dependencies.append(Depends(is_authenticated))
67
+ dependencies.append(Depends(restrict_access_by_viewers))
53
68
 
54
69
  router = APIRouter(
55
70
  prefix="/v1",
@@ -349,7 +349,13 @@ async def delete_annotation_config(
349
349
  request: Request,
350
350
  config_id: str = Path(..., description="ID of the annotation configuration"),
351
351
  ) -> DeleteAnnotationConfigResponseBody:
352
- config_gid = GlobalID.from_id(config_id)
352
+ try:
353
+ config_gid = GlobalID.from_id(config_id)
354
+ except Exception:
355
+ raise HTTPException(
356
+ status_code=422,
357
+ detail=f"Invalid annotation configuration ID format: {config_id}",
358
+ )
353
359
  if config_gid.type_name not in (
354
360
  CategoricalAnnotationConfigType.__name__,
355
361
  ContinuousAnnotationConfigType.__name__,
@@ -209,7 +209,13 @@ class GetDatasetResponseBody(ResponseBody[DatasetWithExampleCount]):
209
209
  async def get_dataset(
210
210
  request: Request, id: str = Path(description="The ID of the dataset")
211
211
  ) -> GetDatasetResponseBody:
212
- dataset_id = GlobalID.from_id(id)
212
+ try:
213
+ dataset_id = GlobalID.from_id(id)
214
+ except Exception as e:
215
+ raise HTTPException(
216
+ detail=f"Invalid dataset ID format: {id}",
217
+ status_code=422,
218
+ ) from e
213
219
 
214
220
  if (type_name := dataset_id.type_name) != DATASET_NODE_NAME:
215
221
  raise HTTPException(detail=f"ID {dataset_id} refers to a f{type_name}", status_code=404)
@@ -400,7 +406,12 @@ async def upload_dataset(
400
406
  description="If true, fulfill request synchronously and return JSON containing dataset_id.",
401
407
  ),
402
408
  ) -> Optional[UploadDatasetResponseBody]:
403
- request_content_type = request.headers["content-type"]
409
+ request_content_type = request.headers.get("content-type")
410
+ if not request_content_type:
411
+ raise HTTPException(
412
+ detail="Missing content-type header",
413
+ status_code=400,
414
+ )
404
415
  examples: Union[Examples, Awaitable[Examples]]
405
416
  if request_content_type.startswith("application/json"):
406
417
  try:
@@ -709,8 +720,24 @@ async def get_dataset_examples(
709
720
  ),
710
721
  ),
711
722
  ) -> ListDatasetExamplesResponseBody:
712
- dataset_gid = GlobalID.from_id(id)
713
- version_gid = GlobalID.from_id(version_id) if version_id else None
723
+ try:
724
+ dataset_gid = GlobalID.from_id(id)
725
+ except Exception as e:
726
+ raise HTTPException(
727
+ detail=f"Invalid dataset ID format: {id}",
728
+ status_code=422,
729
+ ) from e
730
+
731
+ if version_id:
732
+ try:
733
+ version_gid = GlobalID.from_id(version_id)
734
+ except Exception as e:
735
+ raise HTTPException(
736
+ detail=f"Invalid dataset version ID format: {version_id}",
737
+ status_code=422,
738
+ ) from e
739
+ else:
740
+ version_gid = None
714
741
 
715
742
  if (dataset_type := dataset_gid.type_name) != "Dataset":
716
743
  raise HTTPException(detail=f"ID {dataset_gid} refers to a {dataset_type}", status_code=404)
@@ -992,12 +1019,25 @@ def _get_content_jsonl_openai_evals(examples: list[models.DatasetExampleRevision
992
1019
  async def _get_db_examples(
993
1020
  *, session: Any, id: str, version_id: Optional[str]
994
1021
  ) -> tuple[str, list[models.DatasetExampleRevision]]:
995
- dataset_id = from_global_id_with_expected_type(GlobalID.from_id(id), DATASET_NODE_NAME)
1022
+ try:
1023
+ dataset_id = from_global_id_with_expected_type(GlobalID.from_id(id), DATASET_NODE_NAME)
1024
+ except Exception as e:
1025
+ raise HTTPException(
1026
+ detail=f"Invalid dataset ID format: {id}",
1027
+ status_code=422,
1028
+ ) from e
1029
+
996
1030
  dataset_version_id: Optional[int] = None
997
1031
  if version_id:
998
- dataset_version_id = from_global_id_with_expected_type(
999
- GlobalID.from_id(version_id), DATASET_VERSION_NODE_NAME
1000
- )
1032
+ try:
1033
+ dataset_version_id = from_global_id_with_expected_type(
1034
+ GlobalID.from_id(version_id), DATASET_VERSION_NODE_NAME
1035
+ )
1036
+ except Exception as e:
1037
+ raise HTTPException(
1038
+ detail=f"Invalid dataset version ID format: {version_id}",
1039
+ status_code=422,
1040
+ ) from e
1001
1041
  latest_version = (
1002
1042
  select(
1003
1043
  models.DatasetExampleRevision.dataset_example_id,
@@ -159,7 +159,13 @@ async def list_experiment_runs(
159
159
  gt=0,
160
160
  ),
161
161
  ) -> ListExperimentRunsResponseBody:
162
- experiment_gid = GlobalID.from_id(experiment_id)
162
+ try:
163
+ experiment_gid = GlobalID.from_id(experiment_id)
164
+ except Exception as e:
165
+ raise HTTPException(
166
+ detail=f"Invalid experiment ID format: {experiment_id}",
167
+ status_code=422,
168
+ ) from e
163
169
  try:
164
170
  experiment_rowid = from_global_id_with_expected_type(experiment_gid, "Experiment")
165
171
  except ValueError:
@@ -104,7 +104,13 @@ async def create_experiment(
104
104
  request_body: CreateExperimentRequestBody,
105
105
  dataset_id: str = Path(..., title="Dataset ID"),
106
106
  ) -> CreateExperimentResponseBody:
107
- dataset_globalid = GlobalID.from_id(dataset_id)
107
+ try:
108
+ dataset_globalid = GlobalID.from_id(dataset_id)
109
+ except Exception as e:
110
+ raise HTTPException(
111
+ detail=f"Invalid dataset ID format: {dataset_id}",
112
+ status_code=422,
113
+ ) from e
108
114
  try:
109
115
  dataset_rowid = from_global_id_with_expected_type(dataset_globalid, "Dataset")
110
116
  except ValueError:
@@ -117,6 +123,12 @@ async def create_experiment(
117
123
  if dataset_version_globalid_str is not None:
118
124
  try:
119
125
  dataset_version_globalid = GlobalID.from_id(dataset_version_globalid_str)
126
+ except Exception as e:
127
+ raise HTTPException(
128
+ detail=f"Invalid dataset version ID format: {dataset_version_globalid_str}",
129
+ status_code=422,
130
+ ) from e
131
+ try:
120
132
  dataset_version_id = from_global_id_with_expected_type(
121
133
  dataset_version_globalid, "DatasetVersion"
122
134
  )
@@ -232,7 +244,13 @@ class GetExperimentResponseBody(ResponseBody[Experiment]):
232
244
  response_description="Experiment retrieved successfully",
233
245
  )
234
246
  async def get_experiment(request: Request, experiment_id: str) -> GetExperimentResponseBody:
235
- experiment_globalid = GlobalID.from_id(experiment_id)
247
+ try:
248
+ experiment_globalid = GlobalID.from_id(experiment_id)
249
+ except Exception as e:
250
+ raise HTTPException(
251
+ detail=f"Invalid experiment ID format: {experiment_id}",
252
+ status_code=422,
253
+ ) from e
236
254
  try:
237
255
  experiment_rowid = from_global_id_with_expected_type(experiment_globalid, "Experiment")
238
256
  except ValueError:
@@ -282,7 +300,13 @@ async def list_experiments(
282
300
  request: Request,
283
301
  dataset_id: str = Path(..., title="Dataset ID"),
284
302
  ) -> ListExperimentsResponseBody:
285
- dataset_gid = GlobalID.from_id(dataset_id)
303
+ try:
304
+ dataset_gid = GlobalID.from_id(dataset_id)
305
+ except Exception as e:
306
+ raise HTTPException(
307
+ detail=f"Invalid dataset ID format: {dataset_id}",
308
+ status_code=422,
309
+ ) from e
286
310
  try:
287
311
  dataset_rowid = from_global_id_with_expected_type(dataset_gid, "Dataset")
288
312
  except ValueError:
@@ -397,7 +421,13 @@ async def get_experiment_json(
397
421
  request: Request,
398
422
  experiment_id: str = Path(..., title="Experiment ID"),
399
423
  ) -> Response:
400
- experiment_globalid = GlobalID.from_id(experiment_id)
424
+ try:
425
+ experiment_globalid = GlobalID.from_id(experiment_id)
426
+ except Exception as e:
427
+ raise HTTPException(
428
+ detail=f"Invalid experiment ID format: {experiment_id}",
429
+ status_code=422,
430
+ ) from e
401
431
  try:
402
432
  experiment_rowid = from_global_id_with_expected_type(experiment_globalid, "Experiment")
403
433
  except ValueError:
@@ -464,7 +494,13 @@ async def get_experiment_csv(
464
494
  request: Request,
465
495
  experiment_id: str = Path(..., title="Experiment ID"),
466
496
  ) -> Response:
467
- experiment_globalid = GlobalID.from_id(experiment_id)
497
+ try:
498
+ experiment_globalid = GlobalID.from_id(experiment_id)
499
+ except Exception as e:
500
+ raise HTTPException(
501
+ detail=f"Invalid experiment ID format: {experiment_id}",
502
+ status_code=422,
503
+ ) from e
468
504
  try:
469
505
  experiment_rowid = from_global_id_with_expected_type(experiment_globalid, "Experiment")
470
506
  except ValueError:
@@ -9,7 +9,6 @@ from strawberry.relay import GlobalID
9
9
  from phoenix.config import DEFAULT_PROJECT_NAME
10
10
  from phoenix.db import models
11
11
  from phoenix.db.helpers import exclude_experiment_projects
12
- from phoenix.db.models import UserRoleName
13
12
  from phoenix.server.api.routers.v1.models import V1RoutesBaseModel
14
13
  from phoenix.server.api.routers.v1.utils import (
15
14
  PaginatedResponseBody,
@@ -18,7 +17,7 @@ from phoenix.server.api.routers.v1.utils import (
18
17
  add_errors_to_responses,
19
18
  )
20
19
  from phoenix.server.api.types.Project import Project as ProjectNodeType
21
- from phoenix.server.authorization import is_not_locked
20
+ from phoenix.server.authorization import is_not_locked, require_admin
22
21
 
23
22
  router = APIRouter(tags=["projects"])
24
23
 
@@ -210,7 +209,7 @@ async def create_project(
210
209
 
211
210
  @router.put(
212
211
  "/projects/{project_identifier}",
213
- dependencies=[Depends(is_not_locked)],
212
+ dependencies=[Depends(require_admin), Depends(is_not_locked)],
214
213
  operation_id="updateProject",
215
214
  summary="Update a project by ID or name", # noqa: E501
216
215
  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
@@ -245,20 +244,6 @@ async def update_project(
245
244
  Raises:
246
245
  HTTPException: If the project identifier format is invalid or the project is not found.
247
246
  """ # noqa: E501
248
- if request.app.state.authentication_enabled:
249
- async with request.app.state.db() as session:
250
- # Check if the user is an admin
251
- stmt = (
252
- select(models.UserRole.name)
253
- .join(models.User)
254
- .where(models.User.id == int(request.user.identity))
255
- )
256
- role_name: UserRoleName = await session.scalar(stmt)
257
- if role_name != "ADMIN" and role_name != "SYSTEM":
258
- raise HTTPException(
259
- status_code=403,
260
- detail="Only admins can update projects",
261
- )
262
247
  async with request.app.state.db() as session:
263
248
  project = await _get_project_by_identifier(session, project_identifier)
264
249
 
@@ -272,6 +257,7 @@ async def update_project(
272
257
 
273
258
  @router.delete(
274
259
  "/projects/{project_identifier}",
260
+ dependencies=[Depends(require_admin)],
275
261
  operation_id="deleteProject",
276
262
  summary="Delete a project by ID or name", # noqa: E501
277
263
  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
@@ -305,20 +291,6 @@ async def delete_project(
305
291
  Raises:
306
292
  HTTPException: If the project identifier format is invalid, the project is not found, or it's the default project.
307
293
  """ # noqa: E501
308
- if request.app.state.authentication_enabled:
309
- async with request.app.state.db() as session:
310
- # Check if the user is an admin
311
- stmt = (
312
- select(models.UserRole.name)
313
- .join(models.User)
314
- .where(models.User.id == int(request.user.identity))
315
- )
316
- role_name: UserRoleName = await session.scalar(stmt)
317
- if role_name != "ADMIN" and role_name != "SYSTEM":
318
- raise HTTPException(
319
- status_code=403,
320
- detail="Only admins can delete projects",
321
- )
322
294
  async with request.app.state.db() as session:
323
295
  project = await _get_project_by_identifier(session, project_identifier)
324
296
 
@@ -208,13 +208,6 @@ async def create_user(
208
208
  detail="Cannot create users with SYSTEM role",
209
209
  )
210
210
 
211
- # TODO: Implement VIEWER role
212
- if role == "VIEWER":
213
- raise HTTPException(
214
- status_code=400,
215
- detail="VIEWER role not yet implemented",
216
- )
217
-
218
211
  user: models.User
219
212
  if isinstance(user_data, LocalUserData):
220
213
  password = (user_data.password or secrets.token_hex()).strip()
@@ -27,7 +27,7 @@ from phoenix.config import PLAYGROUND_PROJECT_NAME
27
27
  from phoenix.datetime_utils import local_now, normalize_datetime
28
28
  from phoenix.db import models
29
29
  from phoenix.db.helpers import insert_experiment_with_examples_snapshot
30
- from phoenix.server.api.auth import IsLocked, IsNotReadOnly
30
+ from phoenix.server.api.auth import IsLocked, IsNotReadOnly, IsNotViewer
31
31
  from phoenix.server.api.context import Context
32
32
  from phoenix.server.api.exceptions import BadRequest, CustomGraphQLError, NotFound
33
33
  from phoenix.server.api.helpers.playground_clients import (
@@ -94,7 +94,7 @@ ChatStream: TypeAlias = AsyncGenerator[ChatCompletionSubscriptionPayload, None]
94
94
 
95
95
  @strawberry.type
96
96
  class Subscription:
97
- @strawberry.subscription(permission_classes=[IsNotReadOnly, IsLocked]) # type: ignore
97
+ @strawberry.subscription(permission_classes=[IsNotReadOnly, IsNotViewer, IsLocked]) # type: ignore
98
98
  async def chat_completion(
99
99
  self, info: Info[Context, None], input: ChatCompletionInput
100
100
  ) -> AsyncIterator[ChatCompletionSubscriptionPayload]:
@@ -193,7 +193,7 @@ class Subscription:
193
193
  info.context.event_queue.put(SpanInsertEvent(ids=(playground_project_id,)))
194
194
  yield ChatCompletionSubscriptionResult(span=Span(span_rowid=db_span.id, db_span=db_span))
195
195
 
196
- @strawberry.subscription(permission_classes=[IsNotReadOnly, IsLocked]) # type: ignore
196
+ @strawberry.subscription(permission_classes=[IsNotReadOnly, IsNotViewer, IsLocked]) # type: ignore
197
197
  async def chat_completion_over_dataset(
198
198
  self, info: Info[Context, None], input: ChatCompletionOverDatasetInput
199
199
  ) -> AsyncIterator[ChatCompletionSubscriptionPayload]: