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.
- {arize_phoenix-12.4.0.dist-info → arize_phoenix-12.5.0.dist-info}/METADATA +1 -1
- {arize_phoenix-12.4.0.dist-info → arize_phoenix-12.5.0.dist-info}/RECORD +59 -58
- phoenix/auth.py +8 -2
- phoenix/db/models.py +3 -3
- phoenix/server/api/auth.py +9 -0
- phoenix/server/api/context.py +2 -0
- phoenix/server/api/dataloaders/__init__.py +2 -0
- phoenix/server/api/dataloaders/dataset_dataset_splits.py +52 -0
- phoenix/server/api/input_types/ProjectSessionSort.py +158 -1
- phoenix/server/api/input_types/SpanSort.py +2 -1
- phoenix/server/api/input_types/UserRoleInput.py +1 -0
- phoenix/server/api/mutations/annotation_config_mutations.py +6 -6
- phoenix/server/api/mutations/api_key_mutations.py +13 -5
- phoenix/server/api/mutations/chat_mutations.py +3 -3
- phoenix/server/api/mutations/dataset_label_mutations.py +6 -6
- phoenix/server/api/mutations/dataset_mutations.py +8 -8
- phoenix/server/api/mutations/dataset_split_mutations.py +7 -7
- phoenix/server/api/mutations/experiment_mutations.py +2 -2
- phoenix/server/api/mutations/export_events_mutations.py +3 -3
- phoenix/server/api/mutations/model_mutations.py +4 -4
- phoenix/server/api/mutations/project_mutations.py +4 -4
- phoenix/server/api/mutations/project_session_annotations_mutations.py +4 -4
- phoenix/server/api/mutations/project_trace_retention_policy_mutations.py +8 -4
- phoenix/server/api/mutations/prompt_label_mutations.py +7 -7
- phoenix/server/api/mutations/prompt_mutations.py +7 -7
- phoenix/server/api/mutations/prompt_version_tag_mutations.py +3 -3
- phoenix/server/api/mutations/span_annotations_mutations.py +5 -5
- phoenix/server/api/mutations/trace_annotations_mutations.py +4 -4
- phoenix/server/api/mutations/trace_mutations.py +3 -3
- phoenix/server/api/mutations/user_mutations.py +8 -5
- phoenix/server/api/routers/auth.py +2 -2
- phoenix/server/api/routers/v1/__init__.py +16 -1
- phoenix/server/api/routers/v1/annotation_configs.py +7 -1
- phoenix/server/api/routers/v1/datasets.py +48 -8
- phoenix/server/api/routers/v1/experiment_runs.py +7 -1
- phoenix/server/api/routers/v1/experiments.py +41 -5
- phoenix/server/api/routers/v1/projects.py +3 -31
- phoenix/server/api/routers/v1/users.py +0 -7
- phoenix/server/api/subscriptions.py +3 -3
- phoenix/server/api/types/Dataset.py +95 -6
- phoenix/server/api/types/Project.py +24 -68
- phoenix/server/app.py +2 -0
- phoenix/server/authorization.py +3 -1
- phoenix/server/bearer_auth.py +9 -0
- phoenix/server/jwt_store.py +8 -6
- phoenix/server/static/.vite/manifest.json +39 -39
- phoenix/server/static/assets/{components-BvsExS75.js → components-cwdYEs7B.js} +501 -394
- phoenix/server/static/assets/{index-iq8WDxat.js → index-Dc0vD1Rn.js} +1 -1
- phoenix/server/static/assets/{pages-Ckg4SLQ9.js → pages-BDkB3a_a.js} +577 -533
- phoenix/server/static/assets/{vendor-D2eEI-6h.js → vendor-Ce6GTAin.js} +1 -1
- phoenix/server/static/assets/{vendor-arizeai-kfOei7nf.js → vendor-arizeai-CSF-1Kc5.js} +1 -1
- phoenix/server/static/assets/{vendor-codemirror-1bq_t1Ec.js → vendor-codemirror-Bv8J_7an.js} +3 -3
- phoenix/server/static/assets/{vendor-recharts-DQ4xfrf4.js → vendor-recharts-DcLgzI7g.js} +1 -1
- phoenix/server/static/assets/{vendor-shiki-GGmcIQxA.js → vendor-shiki-BF8rh_7m.js} +1 -1
- phoenix/version.py +1 -1
- {arize_phoenix-12.4.0.dist-info → arize_phoenix-12.5.0.dist-info}/WHEEL +0 -0
- {arize_phoenix-12.4.0.dist-info → arize_phoenix-12.5.0.dist-info}/entry_points.txt +0 -0
- {arize_phoenix-12.4.0.dist-info → arize_phoenix-12.5.0.dist-info}/licenses/IP_NOTICE +0 -0
- {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
|
|
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()
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
713
|
-
|
|
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
|
-
|
|
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
|
-
|
|
999
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]:
|