arize-phoenix 4.36.0__py3-none-any.whl → 5.0.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.0.0.dist-info}/METADATA +10 -12
- {arize_phoenix-4.36.0.dist-info → arize_phoenix-5.0.0.dist-info}/RECORD +68 -59
- 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/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.0.0.dist-info}/WHEEL +0 -0
- {arize_phoenix-4.36.0.dist-info → arize_phoenix-5.0.0.dist-info}/licenses/IP_NOTICE +0 -0
- {arize_phoenix-4.36.0.dist-info → arize_phoenix-5.0.0.dist-info}/licenses/LICENSE +0 -0
phoenix/server/api/context.py
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
|
+
from asyncio import get_running_loop
|
|
1
2
|
from dataclasses import dataclass
|
|
3
|
+
from functools import cached_property, partial
|
|
2
4
|
from pathlib import Path
|
|
3
|
-
from typing import Any, Optional
|
|
5
|
+
from typing import Any, Optional, cast
|
|
4
6
|
|
|
7
|
+
from starlette.requests import Request as StarletteRequest
|
|
5
8
|
from starlette.responses import Response as StarletteResponse
|
|
6
9
|
from strawberry.fastapi import BaseContext
|
|
7
10
|
|
|
11
|
+
from phoenix.auth import (
|
|
12
|
+
compute_password_hash,
|
|
13
|
+
)
|
|
8
14
|
from phoenix.core.model_schema import Model
|
|
15
|
+
from phoenix.db import models
|
|
9
16
|
from phoenix.server.api.dataloaders import (
|
|
10
17
|
AnnotationSummaryDataLoader,
|
|
11
18
|
AverageExperimentRunLatencyDataLoader,
|
|
@@ -30,9 +37,18 @@ from phoenix.server.api.dataloaders import (
|
|
|
30
37
|
SpanProjectsDataLoader,
|
|
31
38
|
TokenCountDataLoader,
|
|
32
39
|
TraceRowIdsDataLoader,
|
|
40
|
+
UserRolesDataLoader,
|
|
41
|
+
UsersDataLoader,
|
|
33
42
|
)
|
|
43
|
+
from phoenix.server.bearer_auth import PhoenixUser
|
|
34
44
|
from phoenix.server.dml_event import DmlEvent
|
|
35
|
-
from phoenix.server.types import
|
|
45
|
+
from phoenix.server.types import (
|
|
46
|
+
CanGetLastUpdatedAt,
|
|
47
|
+
CanPutItem,
|
|
48
|
+
DbSessionFactory,
|
|
49
|
+
TokenStore,
|
|
50
|
+
UserId,
|
|
51
|
+
)
|
|
36
52
|
|
|
37
53
|
|
|
38
54
|
@dataclass
|
|
@@ -59,6 +75,8 @@ class DataLoaders:
|
|
|
59
75
|
token_counts: TokenCountDataLoader
|
|
60
76
|
trace_row_ids: TraceRowIdsDataLoader
|
|
61
77
|
project_by_name: ProjectByNameDataLoader
|
|
78
|
+
users: UsersDataLoader
|
|
79
|
+
user_roles: UserRolesDataLoader
|
|
62
80
|
|
|
63
81
|
|
|
64
82
|
class _NoOp:
|
|
@@ -77,7 +95,9 @@ class Context(BaseContext):
|
|
|
77
95
|
event_queue: CanPutItem[DmlEvent] = _NoOp()
|
|
78
96
|
corpus: Optional[Model] = None
|
|
79
97
|
read_only: bool = False
|
|
98
|
+
auth_enabled: bool = False
|
|
80
99
|
secret: Optional[str] = None
|
|
100
|
+
token_store: Optional[TokenStore] = None
|
|
81
101
|
|
|
82
102
|
def get_secret(self) -> str:
|
|
83
103
|
"""A type-safe way to get the application secret. Throws an error if the secret is not set.
|
|
@@ -92,6 +112,14 @@ class Context(BaseContext):
|
|
|
92
112
|
)
|
|
93
113
|
return self.secret
|
|
94
114
|
|
|
115
|
+
def get_request(self) -> StarletteRequest:
|
|
116
|
+
"""
|
|
117
|
+
A type-safe way to get the request object. Throws an error if the request is not set.
|
|
118
|
+
"""
|
|
119
|
+
if not isinstance(request := self.request, StarletteRequest):
|
|
120
|
+
raise ValueError("no request is set")
|
|
121
|
+
return request
|
|
122
|
+
|
|
95
123
|
def get_response(self) -> StarletteResponse:
|
|
96
124
|
"""
|
|
97
125
|
A type-safe way to get the response object. Throws an error if the response is not set.
|
|
@@ -99,3 +127,23 @@ class Context(BaseContext):
|
|
|
99
127
|
if (response := self.response) is None:
|
|
100
128
|
raise ValueError("no response is set")
|
|
101
129
|
return response
|
|
130
|
+
|
|
131
|
+
async def is_valid_password(self, password: str, user: models.User) -> bool:
|
|
132
|
+
return (
|
|
133
|
+
(hash_ := user.password_hash) is not None
|
|
134
|
+
and (salt := user.password_salt) is not None
|
|
135
|
+
and hash_ == await self.hash_password(password, salt)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
async def hash_password(password: str, salt: bytes) -> bytes:
|
|
140
|
+
compute = partial(compute_password_hash, password=password, salt=salt)
|
|
141
|
+
return await get_running_loop().run_in_executor(None, compute)
|
|
142
|
+
|
|
143
|
+
async def log_out(self, user_id: int) -> None:
|
|
144
|
+
assert self.token_store is not None
|
|
145
|
+
await self.token_store.log_out(UserId(user_id))
|
|
146
|
+
|
|
147
|
+
@cached_property
|
|
148
|
+
def user(self) -> PhoenixUser:
|
|
149
|
+
return cast(PhoenixUser, self.get_request().user)
|
|
@@ -25,6 +25,8 @@ from .span_descendants import SpanDescendantsDataLoader
|
|
|
25
25
|
from .span_projects import SpanProjectsDataLoader
|
|
26
26
|
from .token_counts import TokenCountCache, TokenCountDataLoader
|
|
27
27
|
from .trace_row_ids import TraceRowIdsDataLoader
|
|
28
|
+
from .user_roles import UserRolesDataLoader
|
|
29
|
+
from .users import UsersDataLoader
|
|
28
30
|
|
|
29
31
|
__all__ = [
|
|
30
32
|
"CacheForDataLoaders",
|
|
@@ -50,6 +52,8 @@ __all__ = [
|
|
|
50
52
|
"TraceRowIdsDataLoader",
|
|
51
53
|
"ProjectByNameDataLoader",
|
|
52
54
|
"SpanAnnotationsDataLoader",
|
|
55
|
+
"UsersDataLoader",
|
|
56
|
+
"UserRolesDataLoader",
|
|
53
57
|
]
|
|
54
58
|
|
|
55
59
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from typing import DefaultDict, List, Optional
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import select
|
|
5
|
+
from strawberry.dataloader import DataLoader
|
|
6
|
+
from typing_extensions import TypeAlias
|
|
7
|
+
|
|
8
|
+
from phoenix.db import models
|
|
9
|
+
from phoenix.server.types import DbSessionFactory
|
|
10
|
+
|
|
11
|
+
UserRoleId: TypeAlias = int
|
|
12
|
+
Key: TypeAlias = UserRoleId
|
|
13
|
+
Result: TypeAlias = Optional[models.UserRole]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UserRolesDataLoader(DataLoader[Key, Result]):
|
|
17
|
+
"""DataLoader that batches together user roles by their ids."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, db: DbSessionFactory) -> None:
|
|
20
|
+
super().__init__(load_fn=self._load_fn)
|
|
21
|
+
self._db = db
|
|
22
|
+
|
|
23
|
+
async def _load_fn(self, keys: List[Key]) -> List[Result]:
|
|
24
|
+
user_roles_by_id: DefaultDict[Key, Result] = defaultdict(None)
|
|
25
|
+
async with self._db() as session:
|
|
26
|
+
data = await session.stream_scalars(select(models.UserRole))
|
|
27
|
+
async for user_role in data:
|
|
28
|
+
user_roles_by_id[user_role.id] = user_role
|
|
29
|
+
|
|
30
|
+
return [user_roles_by_id.get(role_id) for role_id in keys]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from typing import DefaultDict, List, Optional
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import select
|
|
5
|
+
from strawberry.dataloader import DataLoader
|
|
6
|
+
from typing_extensions import TypeAlias
|
|
7
|
+
|
|
8
|
+
from phoenix.db import models
|
|
9
|
+
from phoenix.server.types import DbSessionFactory
|
|
10
|
+
|
|
11
|
+
UserId: TypeAlias = int
|
|
12
|
+
Key: TypeAlias = UserId
|
|
13
|
+
Result: TypeAlias = Optional[models.User]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UsersDataLoader(DataLoader[Key, Result]):
|
|
17
|
+
"""DataLoader that batches together users by their ids."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, db: DbSessionFactory) -> None:
|
|
20
|
+
super().__init__(load_fn=self._load_fn)
|
|
21
|
+
self._db = db
|
|
22
|
+
|
|
23
|
+
async def _load_fn(self, keys: List[Key]) -> List[Result]:
|
|
24
|
+
user_ids = list(set(keys))
|
|
25
|
+
users_by_id: DefaultDict[Key, Result] = defaultdict(None)
|
|
26
|
+
async with self._db() as session:
|
|
27
|
+
data = await session.stream_scalars(
|
|
28
|
+
select(models.User).where(models.User.id.in_(user_ids))
|
|
29
|
+
)
|
|
30
|
+
async for user in data:
|
|
31
|
+
users_by_id[user.id] = user
|
|
32
|
+
|
|
33
|
+
return [users_by_id.get(user_id) for user_id in keys]
|
phoenix/server/api/exceptions.py
CHANGED
|
@@ -27,6 +27,13 @@ class Unauthorized(CustomGraphQLError):
|
|
|
27
27
|
"""
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
class Conflict(CustomGraphQLError):
|
|
31
|
+
"""
|
|
32
|
+
An error raised when a mutation cannot be completed due to a conflict with
|
|
33
|
+
the current state of one or more resources.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
30
37
|
def get_mask_errors_extension() -> MaskErrors:
|
|
31
38
|
return MaskErrors(
|
|
32
39
|
should_mask_error=_should_mask_error,
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import strawberry
|
|
2
2
|
|
|
3
3
|
from phoenix.server.api.mutations.api_key_mutations import ApiKeyMutationMixin
|
|
4
|
-
from phoenix.server.api.mutations.auth_mutations import AuthMutationMixin
|
|
5
4
|
from phoenix.server.api.mutations.dataset_mutations import DatasetMutationMixin
|
|
6
5
|
from phoenix.server.api.mutations.experiment_mutations import ExperimentMutationMixin
|
|
7
6
|
from phoenix.server.api.mutations.export_events_mutations import ExportEventsMutationMixin
|
|
@@ -14,7 +13,6 @@ from phoenix.server.api.mutations.user_mutations import UserMutationMixin
|
|
|
14
13
|
@strawberry.type
|
|
15
14
|
class Mutation(
|
|
16
15
|
ApiKeyMutationMixin,
|
|
17
|
-
AuthMutationMixin,
|
|
18
16
|
DatasetMutationMixin,
|
|
19
17
|
ExperimentMutationMixin,
|
|
20
18
|
ExportEventsMutationMixin,
|
|
@@ -1,20 +1,22 @@
|
|
|
1
|
-
from datetime import datetime
|
|
2
|
-
from typing import
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from typing import Optional
|
|
3
3
|
|
|
4
|
-
import jwt
|
|
5
4
|
import strawberry
|
|
6
|
-
from sqlalchemy import
|
|
5
|
+
from sqlalchemy import select
|
|
7
6
|
from strawberry import UNSET
|
|
8
7
|
from strawberry.relay import GlobalID
|
|
9
8
|
from strawberry.types import Info
|
|
10
9
|
|
|
11
|
-
from phoenix.db import models
|
|
10
|
+
from phoenix.db import enums, models
|
|
11
|
+
from phoenix.server.api.auth import IsAdmin, IsNotReadOnly
|
|
12
12
|
from phoenix.server.api.context import Context
|
|
13
|
-
from phoenix.server.api.exceptions import
|
|
14
|
-
from phoenix.server.api.mutations.auth import HasSecret, IsAuthenticated
|
|
13
|
+
from phoenix.server.api.exceptions import Unauthorized
|
|
15
14
|
from phoenix.server.api.queries import Query
|
|
16
15
|
from phoenix.server.api.types.node import from_global_id_with_expected_type
|
|
17
16
|
from phoenix.server.api.types.SystemApiKey import SystemApiKey
|
|
17
|
+
from phoenix.server.api.types.UserApiKey import UserApiKey
|
|
18
|
+
from phoenix.server.bearer_auth import PhoenixUser
|
|
19
|
+
from phoenix.server.types import ApiKeyAttributes, ApiKeyClaims, ApiKeyId, UserId
|
|
18
20
|
|
|
19
21
|
|
|
20
22
|
@strawberry.type
|
|
@@ -31,119 +33,135 @@ class CreateApiKeyInput:
|
|
|
31
33
|
expires_at: Optional[datetime] = UNSET
|
|
32
34
|
|
|
33
35
|
|
|
36
|
+
@strawberry.type
|
|
37
|
+
class CreateUserApiKeyMutationPayload:
|
|
38
|
+
jwt: str
|
|
39
|
+
api_key: UserApiKey
|
|
40
|
+
query: Query
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@strawberry.input
|
|
44
|
+
class CreateUserApiKeyInput:
|
|
45
|
+
name: str
|
|
46
|
+
description: Optional[str] = UNSET
|
|
47
|
+
expires_at: Optional[datetime] = UNSET
|
|
48
|
+
|
|
49
|
+
|
|
34
50
|
@strawberry.input
|
|
35
51
|
class DeleteApiKeyInput:
|
|
36
52
|
id: GlobalID
|
|
37
53
|
|
|
38
54
|
|
|
39
55
|
@strawberry.type
|
|
40
|
-
class
|
|
41
|
-
|
|
56
|
+
class DeleteApiKeyMutationPayload:
|
|
57
|
+
apiKeyId: GlobalID
|
|
42
58
|
query: Query
|
|
43
59
|
|
|
44
60
|
|
|
45
61
|
@strawberry.type
|
|
46
62
|
class ApiKeyMutationMixin:
|
|
47
|
-
@strawberry.mutation(permission_classes=[
|
|
63
|
+
@strawberry.mutation(permission_classes=[IsNotReadOnly, IsAdmin]) # type: ignore
|
|
48
64
|
async def create_system_api_key(
|
|
49
65
|
self, info: Info[Context, None], input: CreateApiKeyInput
|
|
50
66
|
) -> CreateSystemApiKeyMutationPayload:
|
|
51
|
-
|
|
67
|
+
assert (token_store := info.context.token_store) is not None
|
|
68
|
+
user_role = enums.UserRole.SYSTEM
|
|
52
69
|
async with info.context.db() as session:
|
|
53
70
|
# Get the system user - note this could be pushed into a dataloader
|
|
54
71
|
system_user = await session.scalar(
|
|
55
72
|
select(models.User)
|
|
56
73
|
.join(models.UserRole) # Join User with UserRole
|
|
57
|
-
.where(models.UserRole.name ==
|
|
74
|
+
.where(models.UserRole.name == user_role.value) # Filter where role is SYSTEM
|
|
75
|
+
.order_by(models.User.id)
|
|
58
76
|
.limit(1)
|
|
59
77
|
)
|
|
60
78
|
if system_user is None:
|
|
61
79
|
raise ValueError("System user not found")
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
)
|
|
73
|
-
api_key = await session.scalar(insert_stmt)
|
|
74
|
-
assert api_key is not None
|
|
75
|
-
|
|
76
|
-
encoded_jwt = create_jwt(
|
|
77
|
-
secret=info.context.get_secret(),
|
|
78
|
-
name=api_key.name,
|
|
79
|
-
id=api_key.id,
|
|
80
|
-
description=api_key.description,
|
|
81
|
-
iat=api_key.created_at,
|
|
82
|
-
exp=api_key.expires_at,
|
|
80
|
+
issued_at = datetime.now(timezone.utc)
|
|
81
|
+
claims = ApiKeyClaims(
|
|
82
|
+
subject=UserId(system_user.id),
|
|
83
|
+
issued_at=issued_at,
|
|
84
|
+
expiration_time=input.expires_at or None,
|
|
85
|
+
attributes=ApiKeyAttributes(
|
|
86
|
+
user_role=user_role,
|
|
87
|
+
name=input.name,
|
|
88
|
+
description=input.description,
|
|
89
|
+
),
|
|
83
90
|
)
|
|
91
|
+
token, token_id = await token_store.create_api_key(claims)
|
|
84
92
|
return CreateSystemApiKeyMutationPayload(
|
|
85
|
-
jwt=
|
|
93
|
+
jwt=token,
|
|
86
94
|
api_key=SystemApiKey(
|
|
87
|
-
id_attr=
|
|
88
|
-
name=
|
|
89
|
-
description=
|
|
90
|
-
created_at=
|
|
91
|
-
expires_at=
|
|
95
|
+
id_attr=int(token_id),
|
|
96
|
+
name=input.name,
|
|
97
|
+
description=input.description or None,
|
|
98
|
+
created_at=issued_at,
|
|
99
|
+
expires_at=input.expires_at or None,
|
|
92
100
|
),
|
|
93
101
|
query=Query(),
|
|
94
102
|
)
|
|
95
103
|
|
|
96
|
-
@strawberry.mutation(permission_classes=[
|
|
104
|
+
@strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
|
|
105
|
+
async def create_user_api_key(
|
|
106
|
+
self, info: Info[Context, None], input: CreateUserApiKeyInput
|
|
107
|
+
) -> CreateUserApiKeyMutationPayload:
|
|
108
|
+
assert (token_store := info.context.token_store) is not None
|
|
109
|
+
try:
|
|
110
|
+
user = info.context.request.user # type: ignore
|
|
111
|
+
assert isinstance(user, PhoenixUser)
|
|
112
|
+
except AttributeError:
|
|
113
|
+
raise ValueError("User not found")
|
|
114
|
+
issued_at = datetime.now(timezone.utc)
|
|
115
|
+
claims = ApiKeyClaims(
|
|
116
|
+
subject=user.identity,
|
|
117
|
+
issued_at=issued_at,
|
|
118
|
+
expiration_time=input.expires_at or None,
|
|
119
|
+
attributes=ApiKeyAttributes(
|
|
120
|
+
user_role=enums.UserRole.MEMBER,
|
|
121
|
+
name=input.name,
|
|
122
|
+
description=input.description,
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
token, token_id = await token_store.create_api_key(claims)
|
|
126
|
+
return CreateUserApiKeyMutationPayload(
|
|
127
|
+
jwt=token,
|
|
128
|
+
api_key=UserApiKey(
|
|
129
|
+
id_attr=int(token_id),
|
|
130
|
+
name=input.name,
|
|
131
|
+
description=input.description or None,
|
|
132
|
+
created_at=issued_at,
|
|
133
|
+
expires_at=input.expires_at or None,
|
|
134
|
+
user_id=int(user.identity),
|
|
135
|
+
),
|
|
136
|
+
query=Query(),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
@strawberry.mutation(permission_classes=[IsNotReadOnly, IsAdmin]) # type: ignore
|
|
97
140
|
async def delete_system_api_key(
|
|
98
141
|
self, info: Info[Context, None], input: DeleteApiKeyInput
|
|
99
|
-
) ->
|
|
142
|
+
) -> DeleteApiKeyMutationPayload:
|
|
143
|
+
assert (token_store := info.context.token_store) is not None
|
|
100
144
|
api_key_id = from_global_id_with_expected_type(
|
|
101
145
|
input.id, expected_type_name=SystemApiKey.__name__
|
|
102
146
|
)
|
|
147
|
+
await token_store.revoke(ApiKeyId(api_key_id))
|
|
148
|
+
return DeleteApiKeyMutationPayload(apiKeyId=input.id, query=Query())
|
|
149
|
+
|
|
150
|
+
@strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
|
|
151
|
+
async def delete_user_api_key(
|
|
152
|
+
self, info: Info[Context, None], input: DeleteApiKeyInput
|
|
153
|
+
) -> DeleteApiKeyMutationPayload:
|
|
154
|
+
assert (token_store := info.context.token_store) is not None
|
|
155
|
+
api_key_id = from_global_id_with_expected_type(
|
|
156
|
+
input.id, expected_type_name=UserApiKey.__name__
|
|
157
|
+
)
|
|
103
158
|
async with info.context.db() as session:
|
|
104
|
-
api_key = await session.
|
|
159
|
+
api_key = await session.scalar(
|
|
160
|
+
select(models.ApiKey).where(models.ApiKey.id == api_key_id)
|
|
161
|
+
)
|
|
105
162
|
if api_key is None:
|
|
106
|
-
raise
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def create_jwt(
|
|
114
|
-
*,
|
|
115
|
-
secret: str,
|
|
116
|
-
algorithm: str = "HS256",
|
|
117
|
-
name: str,
|
|
118
|
-
description: Optional[str],
|
|
119
|
-
iat: datetime,
|
|
120
|
-
exp: Optional[datetime],
|
|
121
|
-
id: int,
|
|
122
|
-
) -> str:
|
|
123
|
-
"""Create a signed JSON Web Token for authentication
|
|
124
|
-
|
|
125
|
-
Args:
|
|
126
|
-
secret (str): the secret to sign with
|
|
127
|
-
name (str): name of the key / token
|
|
128
|
-
description (Optional[str]): description of the token
|
|
129
|
-
iat (datetime): the issued at time
|
|
130
|
-
exp (Optional[datetime]): the expiry, if set
|
|
131
|
-
id (int): the id of the key
|
|
132
|
-
algorithm (str, optional): the algorithm to use. Defaults to "HS256".
|
|
133
|
-
|
|
134
|
-
Returns:
|
|
135
|
-
str: The encoded JWT
|
|
136
|
-
"""
|
|
137
|
-
payload: Dict[str, Any] = {
|
|
138
|
-
"name": name,
|
|
139
|
-
"description": description,
|
|
140
|
-
"iat": iat.utcnow(),
|
|
141
|
-
"id": id,
|
|
142
|
-
}
|
|
143
|
-
if exp is not None:
|
|
144
|
-
payload["exp"] = exp.utcnow()
|
|
145
|
-
|
|
146
|
-
# Encode the payload to create the JWT
|
|
147
|
-
token = jwt.encode(payload, secret, algorithm=algorithm)
|
|
148
|
-
|
|
149
|
-
return token
|
|
163
|
+
raise ValueError(f"API key with id {input.id} not found")
|
|
164
|
+
if int((user := info.context.user).identity) != api_key.user_id and not user.is_admin:
|
|
165
|
+
raise Unauthorized("User not authorized to delete")
|
|
166
|
+
await token_store.revoke(ApiKeyId(api_key_id))
|
|
167
|
+
return DeleteApiKeyMutationPayload(apiKeyId=input.id, query=Query())
|
|
@@ -12,6 +12,7 @@ from strawberry.types import Info
|
|
|
12
12
|
|
|
13
13
|
from phoenix.db import models
|
|
14
14
|
from phoenix.db.helpers import get_eval_trace_ids_for_datasets, get_project_names_for_datasets
|
|
15
|
+
from phoenix.server.api.auth import IsNotReadOnly
|
|
15
16
|
from phoenix.server.api.context import Context
|
|
16
17
|
from phoenix.server.api.exceptions import BadRequest, NotFound
|
|
17
18
|
from phoenix.server.api.helpers.dataset_helpers import (
|
|
@@ -28,7 +29,6 @@ from phoenix.server.api.input_types.PatchDatasetExamplesInput import (
|
|
|
28
29
|
PatchDatasetExamplesInput,
|
|
29
30
|
)
|
|
30
31
|
from phoenix.server.api.input_types.PatchDatasetInput import PatchDatasetInput
|
|
31
|
-
from phoenix.server.api.mutations.auth import IsAuthenticated
|
|
32
32
|
from phoenix.server.api.types.Dataset import Dataset, to_gql_dataset
|
|
33
33
|
from phoenix.server.api.types.DatasetExample import DatasetExample
|
|
34
34
|
from phoenix.server.api.types.node import from_global_id_with_expected_type
|
|
@@ -44,7 +44,7 @@ class DatasetMutationPayload:
|
|
|
44
44
|
|
|
45
45
|
@strawberry.type
|
|
46
46
|
class DatasetMutationMixin:
|
|
47
|
-
@strawberry.mutation(permission_classes=[
|
|
47
|
+
@strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
|
|
48
48
|
async def create_dataset(
|
|
49
49
|
self,
|
|
50
50
|
info: Info[Context, None],
|
|
@@ -67,7 +67,7 @@ class DatasetMutationMixin:
|
|
|
67
67
|
info.context.event_queue.put(DatasetInsertEvent((dataset.id,)))
|
|
68
68
|
return DatasetMutationPayload(dataset=to_gql_dataset(dataset))
|
|
69
69
|
|
|
70
|
-
@strawberry.mutation(permission_classes=[
|
|
70
|
+
@strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
|
|
71
71
|
async def patch_dataset(
|
|
72
72
|
self,
|
|
73
73
|
info: Info[Context, None],
|
|
@@ -96,7 +96,7 @@ class DatasetMutationMixin:
|
|
|
96
96
|
info.context.event_queue.put(DatasetInsertEvent((dataset.id,)))
|
|
97
97
|
return DatasetMutationPayload(dataset=to_gql_dataset(dataset))
|
|
98
98
|
|
|
99
|
-
@strawberry.mutation(permission_classes=[
|
|
99
|
+
@strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
|
|
100
100
|
async def add_spans_to_dataset(
|
|
101
101
|
self,
|
|
102
102
|
info: Info[Context, None],
|
|
@@ -225,7 +225,7 @@ class DatasetMutationMixin:
|
|
|
225
225
|
info.context.event_queue.put(DatasetInsertEvent((dataset.id,)))
|
|
226
226
|
return DatasetMutationPayload(dataset=to_gql_dataset(dataset))
|
|
227
227
|
|
|
228
|
-
@strawberry.mutation(permission_classes=[
|
|
228
|
+
@strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
|
|
229
229
|
async def add_examples_to_dataset(
|
|
230
230
|
self, info: Info[Context, None], input: AddExamplesToDatasetInput
|
|
231
231
|
) -> DatasetMutationPayload:
|
|
@@ -351,7 +351,7 @@ class DatasetMutationMixin:
|
|
|
351
351
|
info.context.event_queue.put(DatasetInsertEvent((dataset.id,)))
|
|
352
352
|
return DatasetMutationPayload(dataset=to_gql_dataset(dataset))
|
|
353
353
|
|
|
354
|
-
@strawberry.mutation(permission_classes=[
|
|
354
|
+
@strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
|
|
355
355
|
async def delete_dataset(
|
|
356
356
|
self,
|
|
357
357
|
info: Info[Context, None],
|
|
@@ -382,7 +382,7 @@ class DatasetMutationMixin:
|
|
|
382
382
|
info.context.event_queue.put(DatasetDeleteEvent((dataset.id,)))
|
|
383
383
|
return DatasetMutationPayload(dataset=to_gql_dataset(dataset))
|
|
384
384
|
|
|
385
|
-
@strawberry.mutation(permission_classes=[
|
|
385
|
+
@strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
|
|
386
386
|
async def patch_dataset_examples(
|
|
387
387
|
self,
|
|
388
388
|
info: Info[Context, None],
|
|
@@ -474,7 +474,7 @@ class DatasetMutationMixin:
|
|
|
474
474
|
info.context.event_queue.put(DatasetInsertEvent((dataset.id,)))
|
|
475
475
|
return DatasetMutationPayload(dataset=to_gql_dataset(dataset))
|
|
476
476
|
|
|
477
|
-
@strawberry.mutation(permission_classes=[
|
|
477
|
+
@strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
|
|
478
478
|
async def delete_dataset_examples(
|
|
479
479
|
self, info: Info[Context, None], input: DeleteDatasetExamplesInput
|
|
480
480
|
) -> DatasetMutationPayload:
|
|
@@ -8,10 +8,10 @@ from strawberry.types import Info
|
|
|
8
8
|
|
|
9
9
|
from phoenix.db import models
|
|
10
10
|
from phoenix.db.helpers import get_eval_trace_ids_for_experiments, get_project_names_for_experiments
|
|
11
|
+
from phoenix.server.api.auth import IsNotReadOnly
|
|
11
12
|
from phoenix.server.api.context import Context
|
|
12
13
|
from phoenix.server.api.exceptions import CustomGraphQLError
|
|
13
14
|
from phoenix.server.api.input_types.DeleteExperimentsInput import DeleteExperimentsInput
|
|
14
|
-
from phoenix.server.api.mutations.auth import IsAuthenticated
|
|
15
15
|
from phoenix.server.api.types.Experiment import Experiment, to_gql_experiment
|
|
16
16
|
from phoenix.server.api.types.node import from_global_id_with_expected_type
|
|
17
17
|
from phoenix.server.api.utils import delete_projects, delete_traces
|
|
@@ -25,7 +25,7 @@ class ExperimentMutationPayload:
|
|
|
25
25
|
|
|
26
26
|
@strawberry.type
|
|
27
27
|
class ExperimentMutationMixin:
|
|
28
|
-
@strawberry.mutation(permission_classes=[
|
|
28
|
+
@strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
|
|
29
29
|
async def delete_experiments(
|
|
30
30
|
self,
|
|
31
31
|
info: Info[Context, None],
|
|
@@ -8,9 +8,9 @@ from strawberry import ID, UNSET
|
|
|
8
8
|
from strawberry.types import Info
|
|
9
9
|
|
|
10
10
|
import phoenix.core.model_schema as ms
|
|
11
|
+
from phoenix.server.api.auth import IsNotReadOnly
|
|
11
12
|
from phoenix.server.api.context import Context
|
|
12
13
|
from phoenix.server.api.input_types.ClusterInput import ClusterInput
|
|
13
|
-
from phoenix.server.api.mutations.auth import IsAuthenticated
|
|
14
14
|
from phoenix.server.api.types.Event import parse_event_ids_by_inferences_role, unpack_event_id
|
|
15
15
|
from phoenix.server.api.types.ExportedFile import ExportedFile
|
|
16
16
|
from phoenix.server.api.types.InferencesRole import AncillaryInferencesRole, InferencesRole
|
|
@@ -19,7 +19,7 @@ from phoenix.server.api.types.InferencesRole import AncillaryInferencesRole, Inf
|
|
|
19
19
|
@strawberry.type
|
|
20
20
|
class ExportEventsMutationMixin:
|
|
21
21
|
@strawberry.mutation(
|
|
22
|
-
permission_classes=[
|
|
22
|
+
permission_classes=[IsNotReadOnly],
|
|
23
23
|
description=(
|
|
24
24
|
"Given a list of event ids, export the corresponding data subset in Parquet format."
|
|
25
25
|
" File name is optional, but if specified, should be without file extension. By default"
|
|
@@ -51,7 +51,7 @@ class ExportEventsMutationMixin:
|
|
|
51
51
|
return ExportedFile(file_name=file_name)
|
|
52
52
|
|
|
53
53
|
@strawberry.mutation(
|
|
54
|
-
permission_classes=[
|
|
54
|
+
permission_classes=[IsNotReadOnly],
|
|
55
55
|
description=(
|
|
56
56
|
"Given a list of clusters, export the corresponding data subset in Parquet format."
|
|
57
57
|
" File name is optional, but if specified, should be without file extension. By default"
|
|
@@ -6,9 +6,9 @@ from strawberry.types import Info
|
|
|
6
6
|
|
|
7
7
|
from phoenix.config import DEFAULT_PROJECT_NAME
|
|
8
8
|
from phoenix.db import models
|
|
9
|
+
from phoenix.server.api.auth import IsNotReadOnly
|
|
9
10
|
from phoenix.server.api.context import Context
|
|
10
11
|
from phoenix.server.api.input_types.ClearProjectInput import ClearProjectInput
|
|
11
|
-
from phoenix.server.api.mutations.auth import IsAuthenticated
|
|
12
12
|
from phoenix.server.api.queries import Query
|
|
13
13
|
from phoenix.server.api.types.node import from_global_id_with_expected_type
|
|
14
14
|
from phoenix.server.dml_event import ProjectDeleteEvent, SpanDeleteEvent
|
|
@@ -16,7 +16,7 @@ from phoenix.server.dml_event import ProjectDeleteEvent, SpanDeleteEvent
|
|
|
16
16
|
|
|
17
17
|
@strawberry.type
|
|
18
18
|
class ProjectMutationMixin:
|
|
19
|
-
@strawberry.mutation(permission_classes=[
|
|
19
|
+
@strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
|
|
20
20
|
async def delete_project(self, info: Info[Context, None], id: GlobalID) -> Query:
|
|
21
21
|
project_id = from_global_id_with_expected_type(global_id=id, expected_type_name="Project")
|
|
22
22
|
async with info.context.db() as session:
|
|
@@ -33,7 +33,7 @@ class ProjectMutationMixin:
|
|
|
33
33
|
info.context.event_queue.put(ProjectDeleteEvent((project_id,)))
|
|
34
34
|
return Query()
|
|
35
35
|
|
|
36
|
-
@strawberry.mutation(permission_classes=[
|
|
36
|
+
@strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
|
|
37
37
|
async def clear_project(self, info: Info[Context, None], input: ClearProjectInput) -> Query:
|
|
38
38
|
project_id = from_global_id_with_expected_type(
|
|
39
39
|
global_id=input.id, expected_type_name="Project"
|
|
@@ -6,11 +6,11 @@ from strawberry import UNSET
|
|
|
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
10
|
from phoenix.server.api.context import Context
|
|
10
11
|
from phoenix.server.api.input_types.CreateSpanAnnotationInput import CreateSpanAnnotationInput
|
|
11
12
|
from phoenix.server.api.input_types.DeleteAnnotationsInput import DeleteAnnotationsInput
|
|
12
13
|
from phoenix.server.api.input_types.PatchAnnotationInput import PatchAnnotationInput
|
|
13
|
-
from phoenix.server.api.mutations.auth import IsAuthenticated
|
|
14
14
|
from phoenix.server.api.queries import Query
|
|
15
15
|
from phoenix.server.api.types.node import from_global_id_with_expected_type
|
|
16
16
|
from phoenix.server.api.types.SpanAnnotation import SpanAnnotation, to_gql_span_annotation
|
|
@@ -25,7 +25,7 @@ class SpanAnnotationMutationPayload:
|
|
|
25
25
|
|
|
26
26
|
@strawberry.type
|
|
27
27
|
class SpanAnnotationMutationMixin:
|
|
28
|
-
@strawberry.mutation(permission_classes=[
|
|
28
|
+
@strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
|
|
29
29
|
async def create_span_annotations(
|
|
30
30
|
self, info: Info[Context, None], input: List[CreateSpanAnnotationInput]
|
|
31
31
|
) -> SpanAnnotationMutationPayload:
|
|
@@ -59,7 +59,7 @@ class SpanAnnotationMutationMixin:
|
|
|
59
59
|
query=Query(),
|
|
60
60
|
)
|
|
61
61
|
|
|
62
|
-
@strawberry.mutation(permission_classes=[
|
|
62
|
+
@strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
|
|
63
63
|
async def patch_span_annotations(
|
|
64
64
|
self, info: Info[Context, None], input: List[PatchAnnotationInput]
|
|
65
65
|
) -> SpanAnnotationMutationPayload:
|
|
@@ -99,7 +99,7 @@ class SpanAnnotationMutationMixin:
|
|
|
99
99
|
info.context.event_queue.put(SpanAnnotationInsertEvent((span_annotation.id,)))
|
|
100
100
|
return SpanAnnotationMutationPayload(span_annotations=patched_annotations, query=Query())
|
|
101
101
|
|
|
102
|
-
@strawberry.mutation(permission_classes=[
|
|
102
|
+
@strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
|
|
103
103
|
async def delete_span_annotations(
|
|
104
104
|
self, info: Info[Context, None], input: DeleteAnnotationsInput
|
|
105
105
|
) -> SpanAnnotationMutationPayload:
|