arize-phoenix 12.3.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 (73) hide show
  1. {arize_phoenix-12.3.0.dist-info → arize_phoenix-12.5.0.dist-info}/METADATA +2 -1
  2. {arize_phoenix-12.3.0.dist-info → arize_phoenix-12.5.0.dist-info}/RECORD +73 -72
  3. phoenix/auth.py +27 -2
  4. phoenix/config.py +302 -53
  5. phoenix/db/README.md +546 -28
  6. phoenix/db/models.py +3 -3
  7. phoenix/server/api/auth.py +9 -0
  8. phoenix/server/api/context.py +2 -0
  9. phoenix/server/api/dataloaders/__init__.py +2 -0
  10. phoenix/server/api/dataloaders/dataset_dataset_splits.py +52 -0
  11. phoenix/server/api/input_types/ProjectSessionSort.py +158 -1
  12. phoenix/server/api/input_types/SpanSort.py +2 -1
  13. phoenix/server/api/input_types/UserRoleInput.py +1 -0
  14. phoenix/server/api/mutations/annotation_config_mutations.py +6 -6
  15. phoenix/server/api/mutations/api_key_mutations.py +13 -5
  16. phoenix/server/api/mutations/chat_mutations.py +3 -3
  17. phoenix/server/api/mutations/dataset_label_mutations.py +6 -6
  18. phoenix/server/api/mutations/dataset_mutations.py +8 -8
  19. phoenix/server/api/mutations/dataset_split_mutations.py +7 -7
  20. phoenix/server/api/mutations/experiment_mutations.py +2 -2
  21. phoenix/server/api/mutations/export_events_mutations.py +3 -3
  22. phoenix/server/api/mutations/model_mutations.py +4 -4
  23. phoenix/server/api/mutations/project_mutations.py +4 -4
  24. phoenix/server/api/mutations/project_session_annotations_mutations.py +4 -4
  25. phoenix/server/api/mutations/project_trace_retention_policy_mutations.py +8 -4
  26. phoenix/server/api/mutations/prompt_label_mutations.py +7 -7
  27. phoenix/server/api/mutations/prompt_mutations.py +7 -7
  28. phoenix/server/api/mutations/prompt_version_tag_mutations.py +3 -3
  29. phoenix/server/api/mutations/span_annotations_mutations.py +5 -5
  30. phoenix/server/api/mutations/trace_annotations_mutations.py +4 -4
  31. phoenix/server/api/mutations/trace_mutations.py +3 -3
  32. phoenix/server/api/mutations/user_mutations.py +8 -5
  33. phoenix/server/api/routers/auth.py +23 -32
  34. phoenix/server/api/routers/oauth2.py +213 -24
  35. phoenix/server/api/routers/v1/__init__.py +18 -4
  36. phoenix/server/api/routers/v1/annotation_configs.py +19 -30
  37. phoenix/server/api/routers/v1/annotations.py +21 -22
  38. phoenix/server/api/routers/v1/datasets.py +86 -64
  39. phoenix/server/api/routers/v1/documents.py +2 -3
  40. phoenix/server/api/routers/v1/evaluations.py +12 -24
  41. phoenix/server/api/routers/v1/experiment_evaluations.py +2 -3
  42. phoenix/server/api/routers/v1/experiment_runs.py +16 -11
  43. phoenix/server/api/routers/v1/experiments.py +57 -22
  44. phoenix/server/api/routers/v1/projects.py +16 -50
  45. phoenix/server/api/routers/v1/prompts.py +30 -31
  46. phoenix/server/api/routers/v1/sessions.py +2 -5
  47. phoenix/server/api/routers/v1/spans.py +35 -26
  48. phoenix/server/api/routers/v1/traces.py +11 -19
  49. phoenix/server/api/routers/v1/users.py +13 -29
  50. phoenix/server/api/routers/v1/utils.py +3 -7
  51. phoenix/server/api/subscriptions.py +3 -3
  52. phoenix/server/api/types/Dataset.py +95 -6
  53. phoenix/server/api/types/Project.py +24 -68
  54. phoenix/server/app.py +3 -2
  55. phoenix/server/authorization.py +5 -4
  56. phoenix/server/bearer_auth.py +13 -5
  57. phoenix/server/jwt_store.py +8 -6
  58. phoenix/server/oauth2.py +172 -5
  59. phoenix/server/static/.vite/manifest.json +39 -39
  60. phoenix/server/static/assets/{components-Bs8eJEpU.js → components-cwdYEs7B.js} +501 -404
  61. phoenix/server/static/assets/{index-C6WEu5UP.js → index-Dc0vD1Rn.js} +1 -1
  62. phoenix/server/static/assets/{pages-D-n2pkoG.js → pages-BDkB3a_a.js} +577 -533
  63. phoenix/server/static/assets/{vendor-D2eEI-6h.js → vendor-Ce6GTAin.js} +1 -1
  64. phoenix/server/static/assets/{vendor-arizeai-kfOei7nf.js → vendor-arizeai-CSF-1Kc5.js} +1 -1
  65. phoenix/server/static/assets/{vendor-codemirror-1bq_t1Ec.js → vendor-codemirror-Bv8J_7an.js} +3 -3
  66. phoenix/server/static/assets/{vendor-recharts-DQ4xfrf4.js → vendor-recharts-DcLgzI7g.js} +1 -1
  67. phoenix/server/static/assets/{vendor-shiki-GGmcIQxA.js → vendor-shiki-BF8rh_7m.js} +1 -1
  68. phoenix/trace/attributes.py +80 -13
  69. phoenix/version.py +1 -1
  70. {arize_phoenix-12.3.0.dist-info → arize_phoenix-12.5.0.dist-info}/WHEEL +0 -0
  71. {arize_phoenix-12.3.0.dist-info → arize_phoenix-12.5.0.dist-info}/entry_points.txt +0 -0
  72. {arize_phoenix-12.3.0.dist-info → arize_phoenix-12.5.0.dist-info}/licenses/IP_NOTICE +0 -0
  73. {arize_phoenix-12.3.0.dist-info → arize_phoenix-12.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -12,7 +12,7 @@ from strawberry.relay import GlobalID
12
12
  from strawberry.types import Info
13
13
 
14
14
  from phoenix.db import models
15
- from phoenix.server.api.auth import IsNotReadOnly
15
+ from phoenix.server.api.auth import IsNotReadOnly, IsNotViewer
16
16
  from phoenix.server.api.context import Context
17
17
  from phoenix.server.api.exceptions import BadRequest, Conflict, NotFound
18
18
  from phoenix.server.api.queries import Query
@@ -81,7 +81,7 @@ class DeleteModelMutationPayload:
81
81
 
82
82
  @strawberry.type
83
83
  class ModelMutationMixin:
84
- @strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
84
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer]) # type: ignore
85
85
  async def create_model(
86
86
  self,
87
87
  info: Info[Context, None],
@@ -114,7 +114,7 @@ class ModelMutationMixin:
114
114
  query=Query(),
115
115
  )
116
116
 
117
- @strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
117
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer]) # type: ignore
118
118
  async def update_model(
119
119
  self,
120
120
  info: Info[Context, None],
@@ -167,7 +167,7 @@ class ModelMutationMixin:
167
167
  query=Query(),
168
168
  )
169
169
 
170
- @strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
170
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer]) # type: ignore
171
171
  async def delete_model(
172
172
  self,
173
173
  info: Info[Context, None],
@@ -8,7 +8,7 @@ from strawberry.types import Info
8
8
 
9
9
  from phoenix.config import DEFAULT_PROJECT_NAME
10
10
  from phoenix.db import models
11
- from phoenix.server.api.auth import IsNotReadOnly
11
+ from phoenix.server.api.auth import IsNotReadOnly, IsNotViewer
12
12
  from phoenix.server.api.context import Context
13
13
  from phoenix.server.api.exceptions import BadRequest, Conflict
14
14
  from phoenix.server.api.input_types.ClearProjectInput import ClearProjectInput
@@ -27,7 +27,7 @@ class ProjectMutationPayload:
27
27
 
28
28
  @strawberry.type
29
29
  class ProjectMutationMixin:
30
- @strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
30
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer]) # type: ignore
31
31
  async def create_project(
32
32
  self,
33
33
  info: Info[Context, None],
@@ -52,7 +52,7 @@ class ProjectMutationMixin:
52
52
  info.context.event_queue.put(ProjectInsertEvent((project.id,)))
53
53
  return ProjectMutationPayload(project=to_gql_project(project), query=Query())
54
54
 
55
- @strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
55
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer]) # type: ignore
56
56
  async def delete_project(self, info: Info[Context, None], id: GlobalID) -> Query:
57
57
  project_id = from_global_id_with_expected_type(global_id=id, expected_type_name="Project")
58
58
  async with info.context.db() as session:
@@ -69,7 +69,7 @@ class ProjectMutationMixin:
69
69
  info.context.event_queue.put(ProjectDeleteEvent((project_id,)))
70
70
  return Query()
71
71
 
72
- @strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
72
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer]) # type: ignore
73
73
  async def clear_project(self, info: Info[Context, None], input: ClearProjectInput) -> Query:
74
74
  project_id = from_global_id_with_expected_type(
75
75
  global_id=input.id, expected_type_name="Project"
@@ -8,7 +8,7 @@ from strawberry import Info
8
8
  from strawberry.relay import GlobalID
9
9
 
10
10
  from phoenix.db import models
11
- from phoenix.server.api.auth import IsLocked, IsNotReadOnly
11
+ from phoenix.server.api.auth import IsLocked, IsNotReadOnly, IsNotViewer
12
12
  from phoenix.server.api.context import Context
13
13
  from phoenix.server.api.exceptions import BadRequest, Conflict, NotFound, Unauthorized
14
14
  from phoenix.server.api.helpers.annotations import get_user_identifier
@@ -38,7 +38,7 @@ class ProjectSessionAnnotationMutationPayload:
38
38
 
39
39
  @strawberry.type
40
40
  class ProjectSessionAnnotationMutationMixin:
41
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsLocked]) # type: ignore
41
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsLocked]) # type: ignore
42
42
  async def create_project_session_annotations(
43
43
  self, info: Info[Context, None], input: CreateProjectSessionAnnotationInput
44
44
  ) -> ProjectSessionAnnotationMutationPayload:
@@ -85,7 +85,7 @@ class ProjectSessionAnnotationMutationMixin:
85
85
  query=Query(),
86
86
  )
87
87
 
88
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsLocked]) # type: ignore
88
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsLocked]) # type: ignore
89
89
  async def update_project_session_annotations(
90
90
  self, info: Info[Context, None], input: UpdateAnnotationInput
91
91
  ) -> ProjectSessionAnnotationMutationPayload:
@@ -126,7 +126,7 @@ class ProjectSessionAnnotationMutationMixin:
126
126
  query=Query(),
127
127
  )
128
128
 
129
- @strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
129
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer]) # type: ignore
130
130
  async def delete_project_session_annotation(
131
131
  self, info: Info[Context, None], id: GlobalID
132
132
  ) -> ProjectSessionAnnotationMutationPayload:
@@ -16,7 +16,7 @@ from phoenix.db.types.trace_retention import (
16
16
  TraceRetentionCronExpression,
17
17
  TraceRetentionRule,
18
18
  )
19
- from phoenix.server.api.auth import IsAdminIfAuthEnabled, IsLocked, IsNotReadOnly
19
+ from phoenix.server.api.auth import IsAdminIfAuthEnabled, IsLocked, IsNotReadOnly, IsNotViewer
20
20
  from phoenix.server.api.context import Context
21
21
  from phoenix.server.api.exceptions import BadRequest, NotFound
22
22
  from phoenix.server.api.queries import Query
@@ -113,7 +113,9 @@ class ProjectTraceRetentionPolicyMutationPayload:
113
113
 
114
114
  @strawberry.type
115
115
  class ProjectTraceRetentionPolicyMutationMixin:
116
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsAdminIfAuthEnabled, IsLocked]) # type: ignore
116
+ @strawberry.mutation(
117
+ permission_classes=[IsNotReadOnly, IsNotViewer, IsAdminIfAuthEnabled, IsLocked]
118
+ ) # type: ignore
117
119
  async def create_project_trace_retention_policy(
118
120
  self,
119
121
  info: Info[Context, None],
@@ -146,7 +148,9 @@ class ProjectTraceRetentionPolicyMutationMixin:
146
148
  node=ProjectTraceRetentionPolicy(id=policy.id, db_policy=policy),
147
149
  )
148
150
 
149
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsAdminIfAuthEnabled, IsLocked]) # type: ignore
151
+ @strawberry.mutation(
152
+ permission_classes=[IsNotReadOnly, IsNotViewer, IsAdminIfAuthEnabled, IsLocked]
153
+ ) # type: ignore
150
154
  async def patch_project_trace_retention_policy(
151
155
  self,
152
156
  info: Info[Context, None],
@@ -204,7 +208,7 @@ class ProjectTraceRetentionPolicyMutationMixin:
204
208
  node=ProjectTraceRetentionPolicy(id=policy.id, db_policy=policy),
205
209
  )
206
210
 
207
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsAdminIfAuthEnabled]) # type: ignore
211
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsAdminIfAuthEnabled]) # type: ignore
208
212
  async def delete_project_trace_retention_policy(
209
213
  self,
210
214
  info: Info[Context, None],
@@ -10,7 +10,7 @@ from strawberry.relay import GlobalID
10
10
  from strawberry.types import Info
11
11
 
12
12
  from phoenix.db import models
13
- from phoenix.server.api.auth import IsLocked, IsNotReadOnly
13
+ from phoenix.server.api.auth import IsLocked, IsNotReadOnly, IsNotViewer
14
14
  from phoenix.server.api.context import Context
15
15
  from phoenix.server.api.exceptions import Conflict, NotFound
16
16
  from phoenix.server.api.queries import Query
@@ -69,7 +69,7 @@ class PromptLabelAssociationMutationPayload:
69
69
 
70
70
  @strawberry.type
71
71
  class PromptLabelMutationMixin:
72
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsLocked]) # type: ignore
72
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsLocked]) # type: ignore
73
73
  async def create_prompt_label(
74
74
  self, info: Info[Context, None], input: CreatePromptLabelInput
75
75
  ) -> PromptLabelMutationPayload:
@@ -89,7 +89,7 @@ class PromptLabelMutationMixin:
89
89
  query=Query(),
90
90
  )
91
91
 
92
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsLocked]) # type: ignore
92
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsLocked]) # type: ignore
93
93
  async def patch_prompt_label(
94
94
  self, info: Info[Context, None], input: PatchPromptLabelInput
95
95
  ) -> PromptLabelMutationPayload:
@@ -117,7 +117,7 @@ class PromptLabelMutationMixin:
117
117
  query=Query(),
118
118
  )
119
119
 
120
- @strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
120
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer]) # type: ignore
121
121
  async def delete_prompt_labels(
122
122
  self, info: Info[Context, None], input: DeletePromptLabelsInput
123
123
  ) -> PromptLabelDeleteMutationPayload:
@@ -139,7 +139,7 @@ class PromptLabelMutationMixin:
139
139
  query=Query(),
140
140
  )
141
141
 
142
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsLocked]) # type: ignore
142
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsLocked]) # type: ignore
143
143
  async def set_prompt_labels(
144
144
  self, info: Info[Context, None], input: SetPromptLabelsInput
145
145
  ) -> PromptLabelAssociationMutationPayload:
@@ -168,7 +168,7 @@ class PromptLabelMutationMixin:
168
168
  query=Query(),
169
169
  )
170
170
 
171
- @strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
171
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer]) # type: ignore
172
172
  async def unset_prompt_labels(
173
173
  self, info: Info[Context, None], input: UnsetPromptLabelsInput
174
174
  ) -> PromptLabelAssociationMutationPayload:
@@ -188,7 +188,7 @@ class PromptLabelMutationMixin:
188
188
  )
189
189
  result = await session.execute(stmt)
190
190
 
191
- if result.rowcount != len(label_ids):
191
+ if result.rowcount != len(label_ids): # type: ignore[attr-defined]
192
192
  label_ids_str = ", ".join(str(i) for i in label_ids)
193
193
  raise NotFound(
194
194
  f"No association between prompt={prompt_id} and labels={label_ids_str}."
@@ -13,7 +13,7 @@ from strawberry.types import Info
13
13
  from phoenix.db import models
14
14
  from phoenix.db.types.identifier import Identifier as IdentifierModel
15
15
  from phoenix.db.types.model_provider import ModelProvider
16
- from phoenix.server.api.auth import IsLocked, IsNotReadOnly
16
+ from phoenix.server.api.auth import IsLocked, IsNotReadOnly, IsNotViewer
17
17
  from phoenix.server.api.context import Context
18
18
  from phoenix.server.api.exceptions import BadRequest, Conflict, NotFound
19
19
  from phoenix.server.api.helpers.prompts.models import (
@@ -75,7 +75,7 @@ class DeletePromptMutationPayload:
75
75
 
76
76
  @strawberry.type
77
77
  class PromptMutationMixin:
78
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsLocked]) # type: ignore
78
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsLocked]) # type: ignore
79
79
  async def create_chat_prompt(
80
80
  self, info: Info[Context, None], input: CreateChatPromptInput
81
81
  ) -> Prompt:
@@ -142,7 +142,7 @@ class PromptMutationMixin:
142
142
  raise Conflict(f"A prompt named '{input.name}' already exists")
143
143
  return to_gql_prompt_from_orm(prompt)
144
144
 
145
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsLocked]) # type: ignore
145
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsLocked]) # type: ignore
146
146
  async def create_chat_prompt_version(
147
147
  self,
148
148
  info: Info[Context, None],
@@ -220,7 +220,7 @@ class PromptMutationMixin:
220
220
 
221
221
  return to_gql_prompt_from_orm(prompt)
222
222
 
223
- @strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
223
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer]) # type: ignore
224
224
  async def delete_prompt(
225
225
  self, info: Info[Context, None], input: DeletePromptInput
226
226
  ) -> DeletePromptMutationPayload:
@@ -231,13 +231,13 @@ class PromptMutationMixin:
231
231
  stmt = delete(models.Prompt).where(models.Prompt.id == prompt_id)
232
232
  result = await session.execute(stmt)
233
233
 
234
- if result.rowcount == 0:
234
+ if result.rowcount == 0: # type: ignore[attr-defined]
235
235
  raise NotFound(f"Prompt with ID '{input.prompt_id}' not found")
236
236
 
237
237
  await session.commit()
238
238
  return DeletePromptMutationPayload(query=Query())
239
239
 
240
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsLocked]) # type: ignore
240
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsLocked]) # type: ignore
241
241
  async def clone_prompt(self, info: Info[Context, None], input: ClonePromptInput) -> Prompt:
242
242
  prompt_id = from_global_id_with_expected_type(
243
243
  global_id=input.prompt_id, expected_type_name=Prompt.__name__
@@ -290,7 +290,7 @@ class PromptMutationMixin:
290
290
  raise Conflict(f"A prompt named '{input.name}' already exists")
291
291
  return to_gql_prompt_from_orm(new_prompt)
292
292
 
293
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsLocked]) # type: ignore
293
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsLocked]) # type: ignore
294
294
  async def patch_prompt(self, info: Info[Context, None], input: PatchPromptInput) -> Prompt:
295
295
  prompt_id = from_global_id_with_expected_type(
296
296
  global_id=input.prompt_id, expected_type_name=Prompt.__name__
@@ -10,7 +10,7 @@ from strawberry.types import Info
10
10
 
11
11
  from phoenix.db import models
12
12
  from phoenix.db.types.identifier import Identifier as IdentifierModel
13
- from phoenix.server.api.auth import IsLocked, IsNotReadOnly
13
+ from phoenix.server.api.auth import IsLocked, IsNotReadOnly, IsNotViewer
14
14
  from phoenix.server.api.context import Context
15
15
  from phoenix.server.api.exceptions import BadRequest, Conflict, NotFound
16
16
  from phoenix.server.api.queries import Query
@@ -42,7 +42,7 @@ class PromptVersionTagMutationPayload:
42
42
 
43
43
  @strawberry.type
44
44
  class PromptVersionTagMutationMixin:
45
- @strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
45
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer]) # type: ignore
46
46
  async def delete_prompt_version_tag(
47
47
  self, info: Info[Context, None], input: DeletePromptVersionTagInput
48
48
  ) -> PromptVersionTagMutationPayload:
@@ -78,7 +78,7 @@ class PromptVersionTagMutationMixin:
78
78
  prompt_version_tag=None, query=Query(), prompt=to_gql_prompt_from_orm(prompt)
79
79
  )
80
80
 
81
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsLocked]) # type: ignore
81
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsLocked]) # type: ignore
82
82
  async def set_prompt_version_tag(
83
83
  self, info: Info[Context, None], input: SetPromptVersionTagInput
84
84
  ) -> PromptVersionTagMutationPayload:
@@ -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],
@@ -7,15 +7,6 @@ from urllib.parse import urlencode, urlparse, urlunparse
7
7
  from fastapi import APIRouter, Depends, HTTPException, Request, Response
8
8
  from sqlalchemy import func, select
9
9
  from sqlalchemy.orm import joinedload
10
- from starlette.status import (
11
- HTTP_204_NO_CONTENT,
12
- HTTP_302_FOUND,
13
- HTTP_401_UNAUTHORIZED,
14
- HTTP_403_FORBIDDEN,
15
- HTTP_404_NOT_FOUND,
16
- HTTP_422_UNPROCESSABLE_ENTITY,
17
- HTTP_503_SERVICE_UNAVAILABLE,
18
- )
19
10
 
20
11
  from phoenix.auth import (
21
12
  DEFAULT_SECRET_LENGTH,
@@ -76,7 +67,7 @@ router = APIRouter(prefix="/auth", include_in_schema=False, dependencies=auth_de
76
67
  @router.post("/login")
77
68
  async def login(request: Request) -> Response:
78
69
  if get_env_disable_basic_auth():
79
- raise HTTPException(status_code=HTTP_403_FORBIDDEN)
70
+ raise HTTPException(status_code=403)
80
71
  assert isinstance(access_token_expiry := request.app.state.access_token_expiry, timedelta)
81
72
  assert isinstance(refresh_token_expiry := request.app.state.refresh_token_expiry, timedelta)
82
73
  token_store: TokenStore = request.app.state.get_token_store()
@@ -85,7 +76,7 @@ async def login(request: Request) -> Response:
85
76
  password = data.get("password")
86
77
 
87
78
  if not email or not password:
88
- raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Email and password required")
79
+ raise HTTPException(status_code=401, detail="Email and password required")
89
80
 
90
81
  # Sanitize email by trimming and lowercasing
91
82
  email = sanitize_email(email)
@@ -101,14 +92,14 @@ async def login(request: Request) -> Response:
101
92
  or (password_hash := user.password_hash) is None
102
93
  or (salt := user.password_salt) is None
103
94
  ):
104
- raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail=LOGIN_FAILED_MESSAGE)
95
+ raise HTTPException(status_code=401, detail=LOGIN_FAILED_MESSAGE)
105
96
 
106
97
  loop = asyncio.get_running_loop()
107
98
  password_is_valid = partial(
108
99
  is_valid_password, password=password, salt=salt, password_hash=password_hash
109
100
  )
110
101
  if not await loop.run_in_executor(None, password_is_valid):
111
- raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail=LOGIN_FAILED_MESSAGE)
102
+ raise HTTPException(status_code=401, detail=LOGIN_FAILED_MESSAGE)
112
103
 
113
104
  access_token, refresh_token = await create_access_and_refresh_tokens(
114
105
  token_store=token_store,
@@ -116,7 +107,7 @@ async def login(request: Request) -> Response:
116
107
  access_token_expiry=access_token_expiry,
117
108
  refresh_token_expiry=refresh_token_expiry,
118
109
  )
119
- response = Response(status_code=HTTP_204_NO_CONTENT)
110
+ response = Response(status_code=204)
120
111
  response = set_access_token_cookie(
121
112
  response=response, access_token=access_token, max_age=access_token_expiry
122
113
  )
@@ -146,7 +137,7 @@ async def logout(
146
137
  await token_store.log_out(user_id)
147
138
  redirect_path = "/logout" if get_env_disable_basic_auth() else "/login"
148
139
  redirect_url = prepend_root_path(request.scope, redirect_path)
149
- response = Response(status_code=HTTP_302_FOUND, headers={"Location": redirect_url})
140
+ response = Response(status_code=302, headers={"Location": redirect_url})
150
141
  response = delete_access_token_cookie(response)
151
142
  response = delete_refresh_token_cookie(response)
152
143
  response = delete_oauth2_state_cookie(response)
@@ -159,7 +150,7 @@ async def refresh_tokens(request: Request) -> Response:
159
150
  assert isinstance(access_token_expiry := request.app.state.access_token_expiry, timedelta)
160
151
  assert isinstance(refresh_token_expiry := request.app.state.refresh_token_expiry, timedelta)
161
152
  if (refresh_token := request.cookies.get(PHOENIX_REFRESH_TOKEN_COOKIE_NAME)) is None:
162
- raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Missing refresh token")
153
+ raise HTTPException(status_code=401, detail="Missing refresh token")
163
154
  token_store: TokenStore = request.app.state.get_token_store()
164
155
  refresh_token_claims = await token_store.read(Token(refresh_token))
165
156
  if (
@@ -169,9 +160,9 @@ async def refresh_tokens(request: Request) -> Response:
169
160
  or (user_id := int(refresh_token_claims.subject)) is None
170
161
  or (expiration_time := refresh_token_claims.expiration_time) is None
171
162
  ):
172
- raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
173
- if expiration_time.timestamp() < datetime.now().timestamp():
174
- raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Expired refresh token")
163
+ raise HTTPException(status_code=401, detail="Invalid refresh token")
164
+ if expiration_time.timestamp() <= datetime.now(timezone.utc).timestamp():
165
+ raise HTTPException(status_code=401, detail="Expired refresh token")
175
166
  await token_store.revoke(refresh_token_id)
176
167
 
177
168
  if (
@@ -189,14 +180,14 @@ async def refresh_tokens(request: Request) -> Response:
189
180
  select(models.User).filter_by(id=user_id).options(joinedload(models.User.role))
190
181
  )
191
182
  ) is None:
192
- raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail="User not found")
183
+ raise HTTPException(status_code=404, detail="User not found")
193
184
  access_token, refresh_token = await create_access_and_refresh_tokens(
194
185
  token_store=token_store,
195
186
  user=user,
196
187
  access_token_expiry=access_token_expiry,
197
188
  refresh_token_expiry=refresh_token_expiry,
198
189
  )
199
- response = Response(status_code=HTTP_204_NO_CONTENT)
190
+ response = Response(status_code=204)
200
191
  response = set_access_token_cookie(
201
192
  response=response, access_token=access_token, max_age=access_token_expiry
202
193
  )
@@ -209,7 +200,7 @@ async def refresh_tokens(request: Request) -> Response:
209
200
  @router.post("/password-reset-email")
210
201
  async def initiate_password_reset(request: Request) -> Response:
211
202
  if get_env_disable_basic_auth():
212
- raise HTTPException(status_code=HTTP_403_FORBIDDEN)
203
+ raise HTTPException(status_code=403)
213
204
  data = await request.json()
214
205
  if not (email := data.get("email")):
215
206
  raise MISSING_EMAIL
@@ -231,7 +222,7 @@ async def initiate_password_reset(request: Request) -> Response:
231
222
  )
232
223
  if user is None or user.auth_method != "LOCAL":
233
224
  # Withold privileged information
234
- return Response(status_code=HTTP_204_NO_CONTENT)
225
+ return Response(status_code=204)
235
226
  token_store: TokenStore = request.app.state.get_token_store()
236
227
  if user.password_reset_token:
237
228
  await token_store.revoke(PasswordResetTokenId(user.password_reset_token.id))
@@ -247,13 +238,13 @@ async def initiate_password_reset(request: Request) -> Response:
247
238
  components = (url.scheme, url.netloc, path, "", query_string, "")
248
239
  reset_url = urlunparse(components)
249
240
  await sender.send_password_reset_email(email, reset_url)
250
- return Response(status_code=HTTP_204_NO_CONTENT)
241
+ return Response(status_code=204)
251
242
 
252
243
 
253
244
  @router.post("/password-reset")
254
245
  async def reset_password(request: Request) -> Response:
255
246
  if get_env_disable_basic_auth():
256
- raise HTTPException(status_code=HTTP_403_FORBIDDEN)
247
+ raise HTTPException(status_code=403)
257
248
  data = await request.json()
258
249
  if not (password := data.get("password")):
259
250
  raise MISSING_PASSWORD
@@ -262,7 +253,7 @@ async def reset_password(request: Request) -> Response:
262
253
  not (token := data.get("token"))
263
254
  or not isinstance((claims := await token_store.read(token)), PasswordResetTokenClaims)
264
255
  or not claims.expiration_time
265
- or claims.expiration_time < datetime.now(timezone.utc)
256
+ or claims.expiration_time <= datetime.now(timezone.utc)
266
257
  ):
267
258
  raise INVALID_TOKEN
268
259
  assert (user_id := claims.subject)
@@ -270,7 +261,7 @@ async def reset_password(request: Request) -> Response:
270
261
  user = await session.scalar(select(models.User).filter_by(id=int(user_id)))
271
262
  if user is None or user.auth_method != "LOCAL":
272
263
  # Withold privileged information
273
- return Response(status_code=HTTP_204_NO_CONTENT)
264
+ return Response(status_code=204)
274
265
  validate_password_format(password)
275
266
  user.password_salt = secrets.token_bytes(DEFAULT_SECRET_LENGTH)
276
267
  loop = asyncio.get_running_loop()
@@ -281,7 +272,7 @@ async def reset_password(request: Request) -> Response:
281
272
  async with request.app.state.db() as session:
282
273
  session.add(user)
283
274
  await session.flush()
284
- response = Response(status_code=HTTP_204_NO_CONTENT)
275
+ response = Response(status_code=204)
285
276
  assert (token_id := claims.token_id)
286
277
  await token_store.revoke(token_id)
287
278
  await token_store.log_out(UserId(user.id))
@@ -291,18 +282,18 @@ async def reset_password(request: Request) -> Response:
291
282
  LOGIN_FAILED_MESSAGE = "Invalid email and/or password"
292
283
 
293
284
  MISSING_EMAIL = HTTPException(
294
- status_code=HTTP_422_UNPROCESSABLE_ENTITY,
285
+ status_code=422,
295
286
  detail="Email required",
296
287
  )
297
288
  MISSING_PASSWORD = HTTPException(
298
- status_code=HTTP_422_UNPROCESSABLE_ENTITY,
289
+ status_code=422,
299
290
  detail="Password required",
300
291
  )
301
292
  SMTP_UNAVAILABLE = HTTPException(
302
- status_code=HTTP_503_SERVICE_UNAVAILABLE,
293
+ status_code=503,
303
294
  detail="SMTP server not configured",
304
295
  )
305
296
  INVALID_TOKEN = HTTPException(
306
- status_code=HTTP_401_UNAUTHORIZED,
297
+ status_code=401,
307
298
  detail="Invalid token",
308
299
  )