arize-phoenix 12.4.0__py3-none-any.whl → 12.6.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 (65) hide show
  1. {arize_phoenix-12.4.0.dist-info → arize_phoenix-12.6.0.dist-info}/METADATA +1 -1
  2. {arize_phoenix-12.4.0.dist-info → arize_phoenix-12.6.0.dist-info}/RECORD +62 -60
  3. phoenix/auth.py +8 -2
  4. phoenix/db/models.py +3 -3
  5. phoenix/server/api/auth.py +9 -0
  6. phoenix/server/api/context.py +2 -0
  7. phoenix/server/api/dataloaders/__init__.py +2 -0
  8. phoenix/server/api/dataloaders/dataset_dataset_splits.py +52 -0
  9. phoenix/server/api/input_types/ExperimentRunSort.py +237 -0
  10. phoenix/server/api/input_types/ProjectSessionSort.py +158 -1
  11. phoenix/server/api/input_types/SpanSort.py +2 -1
  12. phoenix/server/api/input_types/UserRoleInput.py +1 -0
  13. phoenix/server/api/mutations/annotation_config_mutations.py +6 -6
  14. phoenix/server/api/mutations/api_key_mutations.py +13 -5
  15. phoenix/server/api/mutations/chat_mutations.py +3 -3
  16. phoenix/server/api/mutations/dataset_label_mutations.py +6 -6
  17. phoenix/server/api/mutations/dataset_mutations.py +8 -8
  18. phoenix/server/api/mutations/dataset_split_mutations.py +7 -7
  19. phoenix/server/api/mutations/experiment_mutations.py +2 -2
  20. phoenix/server/api/mutations/export_events_mutations.py +3 -3
  21. phoenix/server/api/mutations/model_mutations.py +4 -4
  22. phoenix/server/api/mutations/project_mutations.py +4 -4
  23. phoenix/server/api/mutations/project_session_annotations_mutations.py +4 -4
  24. phoenix/server/api/mutations/project_trace_retention_policy_mutations.py +8 -4
  25. phoenix/server/api/mutations/prompt_label_mutations.py +7 -7
  26. phoenix/server/api/mutations/prompt_mutations.py +7 -7
  27. phoenix/server/api/mutations/prompt_version_tag_mutations.py +3 -3
  28. phoenix/server/api/mutations/span_annotations_mutations.py +5 -5
  29. phoenix/server/api/mutations/trace_annotations_mutations.py +4 -4
  30. phoenix/server/api/mutations/trace_mutations.py +3 -3
  31. phoenix/server/api/mutations/user_mutations.py +8 -5
  32. phoenix/server/api/routers/auth.py +2 -2
  33. phoenix/server/api/routers/v1/__init__.py +16 -1
  34. phoenix/server/api/routers/v1/annotation_configs.py +7 -1
  35. phoenix/server/api/routers/v1/datasets.py +48 -8
  36. phoenix/server/api/routers/v1/experiment_runs.py +7 -1
  37. phoenix/server/api/routers/v1/experiments.py +41 -5
  38. phoenix/server/api/routers/v1/projects.py +3 -31
  39. phoenix/server/api/routers/v1/users.py +0 -7
  40. phoenix/server/api/subscriptions.py +3 -3
  41. phoenix/server/api/types/Dataset.py +95 -6
  42. phoenix/server/api/types/Experiment.py +60 -25
  43. phoenix/server/api/types/Project.py +24 -68
  44. phoenix/server/app.py +2 -0
  45. phoenix/server/authorization.py +3 -1
  46. phoenix/server/bearer_auth.py +9 -0
  47. phoenix/server/jwt_store.py +8 -6
  48. phoenix/server/static/.vite/manifest.json +44 -44
  49. phoenix/server/static/assets/{components-BvsExS75.js → components-CboqzKQ9.js} +520 -397
  50. phoenix/server/static/assets/{index-iq8WDxat.js → index-CYYGI5-x.js} +2 -2
  51. phoenix/server/static/assets/{pages-Ckg4SLQ9.js → pages-DdlUeKi2.js} +616 -604
  52. phoenix/server/static/assets/vendor-CQ4tN9P7.js +918 -0
  53. phoenix/server/static/assets/vendor-arizeai-Cb1ncvYH.js +106 -0
  54. phoenix/server/static/assets/{vendor-codemirror-1bq_t1Ec.js → vendor-codemirror-CckmKopH.js} +3 -3
  55. phoenix/server/static/assets/{vendor-recharts-DQ4xfrf4.js → vendor-recharts-BC1ysIKu.js} +1 -1
  56. phoenix/server/static/assets/{vendor-shiki-GGmcIQxA.js → vendor-shiki-B45T-YxN.js} +1 -1
  57. phoenix/server/static/assets/vendor-three-BtCyLs1w.js +3840 -0
  58. phoenix/version.py +1 -1
  59. phoenix/server/static/assets/vendor-D2eEI-6h.js +0 -914
  60. phoenix/server/static/assets/vendor-arizeai-kfOei7nf.js +0 -156
  61. phoenix/server/static/assets/vendor-three-BLWp5bic.js +0 -2998
  62. {arize_phoenix-12.4.0.dist-info → arize_phoenix-12.6.0.dist-info}/WHEEL +0 -0
  63. {arize_phoenix-12.4.0.dist-info → arize_phoenix-12.6.0.dist-info}/entry_points.txt +0 -0
  64. {arize_phoenix-12.4.0.dist-info → arize_phoenix-12.6.0.dist-info}/licenses/IP_NOTICE +0 -0
  65. {arize_phoenix-12.4.0.dist-info → arize_phoenix-12.6.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,237 @@
1
+ import operator
2
+ from enum import Enum, auto
3
+ from typing import Any, Optional
4
+
5
+ import strawberry
6
+ from sqlalchemy import ColumnElement, Select, and_, func, literal, or_, select, tuple_
7
+ from sqlalchemy.sql.selectable import NamedFromClause
8
+ from strawberry import Maybe
9
+ from typing_extensions import assert_never
10
+
11
+ from phoenix.db import models
12
+ from phoenix.server.api.types.pagination import (
13
+ Cursor,
14
+ CursorSortColumn,
15
+ CursorSortColumnDataType,
16
+ CursorSortColumnValue,
17
+ )
18
+ from phoenix.server.api.types.SortDir import SortDir
19
+
20
+
21
+ @strawberry.enum
22
+ class ExperimentRunMetric(Enum):
23
+ latencyMs = auto()
24
+
25
+
26
+ @strawberry.input(one_of=True)
27
+ class ExperimentRunColumn:
28
+ metric: Maybe[ExperimentRunMetric]
29
+ annotation_name: Maybe[str]
30
+
31
+
32
+ @strawberry.input(description="The sort key and direction for experiment run connections")
33
+ class ExperimentRunSort:
34
+ col: ExperimentRunColumn
35
+ dir: SortDir
36
+
37
+
38
+ def get_experiment_run_cursor(
39
+ run: models.ExperimentRun, annotation_score: Optional[float], sort: Optional[ExperimentRunSort]
40
+ ) -> Cursor:
41
+ sort_column: Optional[CursorSortColumn] = None
42
+ if sort:
43
+ if sort.col.metric:
44
+ metric = sort.col.metric.value
45
+ assert metric is not None
46
+ if metric is ExperimentRunMetric.latencyMs:
47
+ sort_column = CursorSortColumn(
48
+ type=CursorSortColumnDataType.FLOAT,
49
+ value=run.latency_ms,
50
+ )
51
+ else:
52
+ assert_never(metric)
53
+ elif sort.col.annotation_name:
54
+ data_type = (
55
+ CursorSortColumnDataType.FLOAT
56
+ if annotation_score is not None
57
+ else CursorSortColumnDataType.NULL
58
+ )
59
+ sort_column = CursorSortColumn(
60
+ type=data_type,
61
+ value=annotation_score,
62
+ )
63
+ return Cursor(rowid=run.id, sort_column=sort_column)
64
+
65
+
66
+ def add_order_by_and_page_start_to_query(
67
+ query: Select[Any],
68
+ sort: Optional[ExperimentRunSort],
69
+ experiment_rowid: int,
70
+ after_experiment_run_rowid: Optional[int],
71
+ after_sort_column_value: Optional[CursorSortColumnValue] = None,
72
+ ) -> Select[Any]:
73
+ mean_annotation_scores: Optional[NamedFromClause] = None
74
+ if sort and sort.col.annotation_name:
75
+ annotation_name = sort.col.annotation_name.value
76
+ assert annotation_name is not None
77
+ mean_annotation_scores = _get_mean_annotation_scores_subquery(annotation_name)
78
+ order_by_columns = _get_order_by_columns(
79
+ sort=sort, experiment_rowid=experiment_rowid, mean_annotation_scores=mean_annotation_scores
80
+ )
81
+ query = query.order_by(*order_by_columns)
82
+ if after_experiment_run_rowid is not None:
83
+ query = _add_after_expression(
84
+ query=query,
85
+ sort=sort,
86
+ experiment_run_rowid=after_experiment_run_rowid,
87
+ after_sort_column_value=after_sort_column_value,
88
+ mean_annotation_scores=mean_annotation_scores,
89
+ )
90
+ query = _add_joins_and_selects_to_query(
91
+ query=query,
92
+ sort=sort,
93
+ mean_annotation_scores=mean_annotation_scores,
94
+ )
95
+ return query
96
+
97
+
98
+ def _get_order_by_columns(
99
+ sort: Optional[ExperimentRunSort],
100
+ experiment_rowid: int,
101
+ mean_annotation_scores: Optional[NamedFromClause],
102
+ ) -> tuple[ColumnElement[Any], ...]:
103
+ if not sort:
104
+ # Ideally, this would sort the runs by (example_id, repetition_number),
105
+ # but this would require making the cursor more complex or adding an additional query
106
+ # to handle the after cursor.
107
+ return (models.ExperimentRun.id.asc(),)
108
+ sort_direction = sort.dir
109
+ if sort.col.metric:
110
+ metric = sort.col.metric.value
111
+ assert metric is not None
112
+ if metric is ExperimentRunMetric.latencyMs:
113
+ if sort_direction is SortDir.asc:
114
+ return (models.ExperimentRun.latency_ms.asc(), models.ExperimentRun.id.asc())
115
+ else:
116
+ return (models.ExperimentRun.latency_ms.desc(), models.ExperimentRun.id.desc())
117
+ else:
118
+ assert_never(metric)
119
+ elif sort.col.annotation_name:
120
+ annotation_name = sort.col.annotation_name.value
121
+ assert annotation_name is not None
122
+ assert mean_annotation_scores is not None
123
+ if sort_direction is SortDir.asc:
124
+ return (
125
+ mean_annotation_scores.c.score.asc().nulls_last(),
126
+ models.ExperimentRun.id.asc(),
127
+ )
128
+ else:
129
+ return (
130
+ mean_annotation_scores.c.score.desc().nulls_last(),
131
+ models.ExperimentRun.id.desc(),
132
+ )
133
+ raise NotImplementedError
134
+
135
+
136
+ def _add_after_expression(
137
+ query: Select[Any],
138
+ sort: Optional[ExperimentRunSort],
139
+ experiment_run_rowid: int,
140
+ after_sort_column_value: Optional[CursorSortColumnValue],
141
+ mean_annotation_scores: Optional[NamedFromClause],
142
+ ) -> Select[Any]:
143
+ if not sort:
144
+ # Ideally, this would return the runs sorted by (example_id, repetition_number),
145
+ # but this would require making the cursor more complex or adding an additional query.
146
+ return query.where(models.ExperimentRun.id > literal(experiment_run_rowid))
147
+ sort_direction = sort.dir
148
+ compare_fn = operator.gt if sort_direction is SortDir.asc else operator.lt
149
+ if sort.col.metric:
150
+ metric = sort.col.metric.value
151
+ assert metric is not None
152
+ if metric is ExperimentRunMetric.latencyMs:
153
+ assert after_sort_column_value is not None
154
+ return query.where(
155
+ compare_fn(
156
+ tuple_(models.ExperimentRun.latency_ms, models.ExperimentRun.id),
157
+ tuple_(
158
+ literal(after_sort_column_value),
159
+ literal(experiment_run_rowid),
160
+ ),
161
+ )
162
+ )
163
+ else:
164
+ assert_never(metric)
165
+ elif sort.col.annotation_name:
166
+ annotation_name = sort.col.annotation_name.value
167
+ assert annotation_name is not None
168
+ assert mean_annotation_scores is not None
169
+ if after_sort_column_value is None:
170
+ return query.where(
171
+ and_(
172
+ compare_fn(models.ExperimentRun.id, literal(experiment_run_rowid)),
173
+ mean_annotation_scores.c.score.is_(None),
174
+ )
175
+ )
176
+ else:
177
+ return query.where(
178
+ or_(
179
+ compare_fn(
180
+ tuple_(mean_annotation_scores.c.score, models.ExperimentRun.id),
181
+ tuple_(
182
+ literal(after_sort_column_value),
183
+ literal(experiment_run_rowid),
184
+ ),
185
+ ),
186
+ mean_annotation_scores.c.score.is_(None),
187
+ )
188
+ )
189
+ raise NotImplementedError
190
+
191
+
192
+ def _get_mean_annotation_scores_subquery(annotation_name: str) -> NamedFromClause:
193
+ return (
194
+ select(
195
+ func.avg(models.ExperimentRunAnnotation.score).label("score"),
196
+ models.ExperimentRunAnnotation.experiment_run_id.label("experiment_run_id"),
197
+ )
198
+ .select_from(models.ExperimentRunAnnotation)
199
+ .join(
200
+ models.ExperimentRun,
201
+ models.ExperimentRunAnnotation.experiment_run_id == models.ExperimentRun.id,
202
+ )
203
+ .where(models.ExperimentRunAnnotation.name == annotation_name)
204
+ .group_by(models.ExperimentRunAnnotation.experiment_run_id)
205
+ .subquery()
206
+ .alias("mean_annotation_scores")
207
+ )
208
+
209
+
210
+ def _add_joins_and_selects_to_query(
211
+ query: Select[tuple[models.ExperimentRun]],
212
+ sort: Optional[ExperimentRunSort],
213
+ mean_annotation_scores: Optional[NamedFromClause],
214
+ ) -> Select[tuple[models.ExperimentRun]]:
215
+ if not sort:
216
+ return query
217
+ if sort.col.metric:
218
+ metric = sort.col.metric.value
219
+ assert metric is not None
220
+ if metric is ExperimentRunMetric.latencyMs:
221
+ return query
222
+ else:
223
+ assert_never(metric)
224
+ elif sort.col.annotation_name:
225
+ annotation_name = sort.col.annotation_name.value
226
+ assert annotation_name is not None
227
+ assert mean_annotation_scores is not None
228
+ query = query.join(
229
+ mean_annotation_scores,
230
+ mean_annotation_scores.c.experiment_run_id == models.ExperimentRun.id,
231
+ isouter=True,
232
+ )
233
+ query = query.add_columns(
234
+ mean_annotation_scores.c.score.label("score")
235
+ ) # the score must be in the select so that the value can be included in the cursor
236
+ return query
237
+ raise NotImplementedError
@@ -1,8 +1,16 @@
1
+ from dataclasses import dataclass
1
2
  from enum import Enum, auto
3
+ from typing import Any, Optional
2
4
 
3
5
  import strawberry
6
+ from sqlalchemy import and_, desc, func, nulls_last, select
7
+ from sqlalchemy.orm import InstrumentedAttribute
8
+ from sqlalchemy.sql.expression import Select
9
+ from strawberry import UNSET
4
10
  from typing_extensions import assert_never
5
11
 
12
+ from phoenix.db import models
13
+ from phoenix.db.helpers import truncate_name
6
14
  from phoenix.server.api.types.pagination import CursorSortColumnDataType
7
15
  from phoenix.server.api.types.SortDir import SortDir
8
16
 
@@ -15,6 +23,29 @@ class ProjectSessionColumn(Enum):
15
23
  numTraces = auto()
16
24
  costTotal = auto()
17
25
 
26
+ @property
27
+ def column_name(self) -> str:
28
+ return truncate_name(f"{self.name}_project_session_sort_column")
29
+
30
+ def as_orm_expression(self, joined_table: Optional[Any] = None) -> Any:
31
+ expr: Any
32
+ if self is ProjectSessionColumn.startTime:
33
+ expr = models.ProjectSession.start_time
34
+ elif self is ProjectSessionColumn.endTime:
35
+ expr = models.ProjectSession.end_time
36
+ elif self is ProjectSessionColumn.tokenCountTotal:
37
+ assert joined_table is not None
38
+ expr = joined_table.c.key
39
+ elif self is ProjectSessionColumn.numTraces:
40
+ assert joined_table is not None
41
+ expr = joined_table.c.key
42
+ elif self is ProjectSessionColumn.costTotal:
43
+ assert joined_table is not None
44
+ expr = joined_table.c.key
45
+ else:
46
+ assert_never(self)
47
+ return expr.label(self.column_name)
48
+
18
49
  @property
19
50
  def data_type(self) -> CursorSortColumnDataType:
20
51
  if self is ProjectSessionColumn.tokenCountTotal or self is ProjectSessionColumn.numTraces:
@@ -25,8 +56,134 @@ class ProjectSessionColumn(Enum):
25
56
  return CursorSortColumnDataType.FLOAT
26
57
  assert_never(self)
27
58
 
59
+ def join_tables(self, stmt: Select[Any]) -> tuple[Select[Any], Any]:
60
+ """
61
+ If needed, joins tables required for the sort column.
62
+ """
63
+ if self is ProjectSessionColumn.tokenCountTotal:
64
+ sort_subq = (
65
+ select(
66
+ models.Trace.project_session_rowid.label("id"),
67
+ func.sum(models.Span.cumulative_llm_token_count_total).label("key"),
68
+ )
69
+ .join_from(models.Trace, models.Span)
70
+ .where(models.Span.parent_id.is_(None))
71
+ .group_by(models.Trace.project_session_rowid)
72
+ ).subquery()
73
+ stmt = stmt.join(sort_subq, models.ProjectSession.id == sort_subq.c.id)
74
+ return stmt, sort_subq
75
+ if self is ProjectSessionColumn.numTraces:
76
+ sort_subq = (
77
+ select(
78
+ models.Trace.project_session_rowid.label("id"),
79
+ func.count(models.Trace.id).label("key"),
80
+ ).group_by(models.Trace.project_session_rowid)
81
+ ).subquery()
82
+ stmt = stmt.join(sort_subq, models.ProjectSession.id == sort_subq.c.id)
83
+ return stmt, sort_subq
84
+ if self is ProjectSessionColumn.costTotal:
85
+ sort_subq = (
86
+ select(
87
+ models.Trace.project_session_rowid.label("id"),
88
+ func.sum(models.SpanCost.total_cost).label("key"),
89
+ )
90
+ .join_from(
91
+ models.Trace,
92
+ models.SpanCost,
93
+ models.Trace.id == models.SpanCost.trace_rowid,
94
+ )
95
+ .group_by(models.Trace.project_session_rowid)
96
+ ).subquery()
97
+ stmt = stmt.join(sort_subq, models.ProjectSession.id == sort_subq.c.id)
98
+ return stmt, sort_subq
99
+ return stmt, None
100
+
101
+
102
+ @strawberry.enum
103
+ class ProjectSessionAnnoAttr(Enum):
104
+ score = "score"
105
+ label = "label"
106
+
107
+ @property
108
+ def column_name(self) -> str:
109
+ return f"{self.value}_anno_sort_column"
110
+
111
+ @property
112
+ def orm_expression(self) -> Any:
113
+ expr: InstrumentedAttribute[Any]
114
+ if self is ProjectSessionAnnoAttr.score:
115
+ expr = models.ProjectSessionAnnotation.score
116
+ elif self is ProjectSessionAnnoAttr.label:
117
+ expr = models.ProjectSessionAnnotation.label
118
+ else:
119
+ assert_never(self)
120
+ return expr.label(self.column_name)
121
+
122
+ @property
123
+ def data_type(self) -> CursorSortColumnDataType:
124
+ if self is ProjectSessionAnnoAttr.label:
125
+ return CursorSortColumnDataType.STRING
126
+ if self is ProjectSessionAnnoAttr.score:
127
+ return CursorSortColumnDataType.FLOAT
128
+ assert_never(self)
129
+
130
+
131
+ @strawberry.input
132
+ class ProjectSessionAnnoResultKey:
133
+ name: str
134
+ attr: ProjectSessionAnnoAttr
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class ProjectSessionSortConfig:
139
+ stmt: Select[Any]
140
+ orm_expression: Any
141
+ dir: SortDir
142
+ column_name: str
143
+ column_data_type: CursorSortColumnDataType
144
+
28
145
 
29
146
  @strawberry.input(description="The sort key and direction for ProjectSession connections.")
30
147
  class ProjectSessionSort:
31
- col: ProjectSessionColumn
148
+ col: Optional[ProjectSessionColumn] = UNSET
149
+ anno_result_key: Optional[ProjectSessionAnnoResultKey] = UNSET
32
150
  dir: SortDir
151
+
152
+ def update_orm_expr(self, stmt: Select[Any]) -> ProjectSessionSortConfig:
153
+ if (col := self.col) and not self.anno_result_key:
154
+ stmt, joined_table = col.join_tables(stmt)
155
+ expr = col.as_orm_expression(joined_table)
156
+ stmt = stmt.add_columns(expr)
157
+ if self.dir == SortDir.desc:
158
+ expr = desc(expr)
159
+ return ProjectSessionSortConfig(
160
+ stmt=stmt.order_by(nulls_last(expr)),
161
+ orm_expression=col.as_orm_expression(joined_table),
162
+ dir=self.dir,
163
+ column_name=col.column_name,
164
+ column_data_type=col.data_type,
165
+ )
166
+ if (anno_result_key := self.anno_result_key) and not col:
167
+ anno_name = anno_result_key.name
168
+ anno_attr = anno_result_key.attr
169
+ expr = anno_result_key.attr.orm_expression
170
+ stmt = stmt.add_columns(expr)
171
+ if self.dir == SortDir.desc:
172
+ expr = desc(expr)
173
+ stmt = stmt.join(
174
+ models.ProjectSessionAnnotation,
175
+ onclause=and_(
176
+ models.ProjectSessionAnnotation.project_session_id == models.ProjectSession.id,
177
+ models.ProjectSessionAnnotation.name == anno_name,
178
+ ),
179
+ ).order_by(nulls_last(expr))
180
+ return ProjectSessionSortConfig(
181
+ stmt=stmt,
182
+ orm_expression=anno_result_key.attr.orm_expression,
183
+ dir=self.dir,
184
+ column_name=anno_attr.column_name,
185
+ column_data_type=anno_attr.data_type,
186
+ )
187
+ raise ValueError(
188
+ "Exactly one of `col` or `annoResultKey` must be specified on `ProjectSessionSort`."
189
+ )
@@ -11,6 +11,7 @@ from typing_extensions import assert_never
11
11
 
12
12
  import phoenix.trace.v1 as pb
13
13
  from phoenix.db import models
14
+ from phoenix.db.helpers import truncate_name
14
15
  from phoenix.server.api.types.pagination import CursorSortColumnDataType
15
16
  from phoenix.server.api.types.SortDir import SortDir
16
17
  from phoenix.trace.schemas import SpanID
@@ -32,7 +33,7 @@ class SpanColumn(Enum):
32
33
 
33
34
  @property
34
35
  def column_name(self) -> str:
35
- return f"{self.name}_span_sort_column"
36
+ return truncate_name(f"{self.name}_span_sort_column")
36
37
 
37
38
  def as_orm_expression(self, joined_table: Optional[Any] = None) -> Any:
38
39
  expr: Any
@@ -7,3 +7,4 @@ import strawberry
7
7
  class UserRoleInput(Enum):
8
8
  ADMIN = "ADMIN"
9
9
  MEMBER = "MEMBER"
10
+ VIEWER = "VIEWER"
@@ -23,7 +23,7 @@ from phoenix.db.types.annotation_configs import (
23
23
  from phoenix.db.types.annotation_configs import (
24
24
  FreeformAnnotationConfig as FreeformAnnotationConfigModel,
25
25
  )
26
- from phoenix.server.api.auth import IsNotReadOnly
26
+ from phoenix.server.api.auth import IsNotReadOnly, IsNotViewer
27
27
  from phoenix.server.api.context import Context
28
28
  from phoenix.server.api.exceptions import BadRequest, Conflict, NotFound
29
29
  from phoenix.server.api.queries import Query
@@ -197,7 +197,7 @@ def _to_pydantic_freeform_annotation_config(
197
197
 
198
198
  @strawberry.type
199
199
  class AnnotationConfigMutationMixin:
200
- @strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore[misc]
200
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer]) # type: ignore[misc]
201
201
  async def create_annotation_config(
202
202
  self,
203
203
  info: Info[Context, None],
@@ -236,7 +236,7 @@ class AnnotationConfigMutationMixin:
236
236
  annotation_config=to_gql_annotation_config(annotation_config),
237
237
  )
238
238
 
239
- @strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore[misc]
239
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer]) # type: ignore[misc]
240
240
  async def update_annotation_config(
241
241
  self,
242
242
  info: Info[Context, None],
@@ -285,7 +285,7 @@ class AnnotationConfigMutationMixin:
285
285
  annotation_config=to_gql_annotation_config(annotation_config),
286
286
  )
287
287
 
288
- @strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore[misc]
288
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer]) # type: ignore[misc]
289
289
  async def delete_annotation_configs(
290
290
  self,
291
291
  info: Info[Context, None],
@@ -317,7 +317,7 @@ class AnnotationConfigMutationMixin:
317
317
  ],
318
318
  )
319
319
 
320
- @strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore[misc]
320
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer]) # type: ignore[misc]
321
321
  async def add_annotation_config_to_project(
322
322
  self,
323
323
  info: Info[Context, None],
@@ -377,7 +377,7 @@ class AnnotationConfigMutationMixin:
377
377
  project=Project(project_rowid=project_id),
378
378
  )
379
379
 
380
- @strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore[misc]
380
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer]) # type: ignore[misc]
381
381
  async def remove_annotation_config_from_project(
382
382
  self,
383
383
  info: Info[Context, None],
@@ -1,5 +1,5 @@
1
1
  from datetime import datetime, timezone
2
- from typing import Optional
2
+ from typing import Literal, Optional
3
3
 
4
4
  import strawberry
5
5
  from sqlalchemy import select
@@ -9,7 +9,7 @@ from strawberry.types import Info
9
9
 
10
10
  from phoenix.db import models
11
11
  from phoenix.db.models import UserRoleName
12
- from phoenix.server.api.auth import IsAdmin, IsLocked, IsNotReadOnly
12
+ from phoenix.server.api.auth import IsAdmin, IsLocked, IsNotReadOnly, IsNotViewer
13
13
  from phoenix.server.api.context import Context
14
14
  from phoenix.server.api.exceptions import Unauthorized
15
15
  from phoenix.server.api.queries import Query
@@ -61,7 +61,7 @@ class DeleteApiKeyMutationPayload:
61
61
 
62
62
  @strawberry.type
63
63
  class ApiKeyMutationMixin:
64
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsAdmin, IsLocked]) # type: ignore
64
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsAdmin, IsLocked]) # type: ignore
65
65
  async def create_system_api_key(
66
66
  self, info: Info[Context, None], input: CreateApiKeyInput
67
67
  ) -> CreateSystemApiKeyMutationPayload:
@@ -113,12 +113,20 @@ class ApiKeyMutationMixin:
113
113
  except AttributeError:
114
114
  raise ValueError("User not found")
115
115
  issued_at = datetime.now(timezone.utc)
116
+ # Determine user role for API key
117
+ user_role: Literal["ADMIN", "MEMBER", "VIEWER"]
118
+ if user.is_admin:
119
+ user_role = "ADMIN"
120
+ elif user.is_viewer:
121
+ user_role = "VIEWER"
122
+ else:
123
+ user_role = "MEMBER"
116
124
  claims = ApiKeyClaims(
117
125
  subject=user.identity,
118
126
  issued_at=issued_at,
119
127
  expiration_time=input.expires_at or None,
120
128
  attributes=ApiKeyAttributes(
121
- user_role="ADMIN" if user.is_admin else "MEMBER",
129
+ user_role=user_role,
122
130
  name=input.name,
123
131
  description=input.description,
124
132
  ),
@@ -137,7 +145,7 @@ class ApiKeyMutationMixin:
137
145
  query=Query(),
138
146
  )
139
147
 
140
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsAdmin]) # type: ignore
148
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsAdmin]) # type: ignore
141
149
  async def delete_system_api_key(
142
150
  self, info: Info[Context, None], input: DeleteApiKeyInput
143
151
  ) -> DeleteApiKeyMutationPayload:
@@ -30,7 +30,7 @@ from phoenix.db.helpers import (
30
30
  get_dataset_example_revisions,
31
31
  insert_experiment_with_examples_snapshot,
32
32
  )
33
- from phoenix.server.api.auth import IsLocked, IsNotReadOnly
33
+ from phoenix.server.api.auth import IsLocked, IsNotReadOnly, IsNotViewer
34
34
  from phoenix.server.api.context import Context
35
35
  from phoenix.server.api.exceptions import BadRequest, CustomGraphQLError, NotFound
36
36
  from phoenix.server.api.helpers.dataset_helpers import get_dataset_example_output
@@ -131,7 +131,7 @@ class ChatCompletionOverDatasetMutationPayload:
131
131
 
132
132
  @strawberry.type
133
133
  class ChatCompletionMutationMixin:
134
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsLocked]) # type: ignore
134
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsLocked]) # type: ignore
135
135
  @classmethod
136
136
  async def chat_completion_over_dataset(
137
137
  cls,
@@ -302,7 +302,7 @@ class ChatCompletionMutationMixin:
302
302
  payload.examples.append(example_payload)
303
303
  return payload
304
304
 
305
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsLocked]) # type: ignore
305
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsLocked]) # type: ignore
306
306
  @classmethod
307
307
  async def chat_completion(
308
308
  cls, info: Info[Context, None], input: ChatCompletionInput
@@ -10,7 +10,7 @@ from strawberry.relay.types 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 BadRequest, Conflict, NotFound
16
16
  from phoenix.server.api.queries import Query
@@ -78,7 +78,7 @@ class UnsetDatasetLabelsMutationPayload:
78
78
 
79
79
  @strawberry.type
80
80
  class DatasetLabelMutationMixin:
81
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsLocked]) # type: ignore
81
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsLocked]) # type: ignore
82
82
  async def create_dataset_label(
83
83
  self,
84
84
  info: Info[Context, None],
@@ -100,7 +100,7 @@ class DatasetLabelMutationMixin:
100
100
  dataset_label=to_gql_dataset_label(dataset_label_orm)
101
101
  )
102
102
 
103
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsLocked]) # type: ignore
103
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsLocked]) # type: ignore
104
104
  async def update_dataset_label(
105
105
  self, info: Info[Context, None], input: UpdateDatasetLabelInput
106
106
  ) -> UpdateDatasetLabelMutationPayload:
@@ -133,7 +133,7 @@ class DatasetLabelMutationMixin:
133
133
  dataset_label=to_gql_dataset_label(dataset_label_orm)
134
134
  )
135
135
 
136
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsLocked]) # type: ignore
136
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsLocked]) # type: ignore
137
137
  async def delete_dataset_labels(
138
138
  self, info: Info[Context, None], input: DeleteDatasetLabelsInput
139
139
  ) -> DeleteDatasetLabelsMutationPayload:
@@ -166,7 +166,7 @@ class DatasetLabelMutationMixin:
166
166
  ]
167
167
  )
168
168
 
169
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsLocked]) # type: ignore
169
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsLocked]) # type: ignore
170
170
  async def set_dataset_labels(
171
171
  self, info: Info[Context, None], input: SetDatasetLabelsInput
172
172
  ) -> SetDatasetLabelsMutationPayload:
@@ -248,7 +248,7 @@ class DatasetLabelMutationMixin:
248
248
  query=Query(),
249
249
  )
250
250
 
251
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsLocked]) # type: ignore
251
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsLocked]) # type: ignore
252
252
  async def unset_dataset_labels(
253
253
  self, info: Info[Context, None], input: UnsetDatasetLabelsInput
254
254
  ) -> UnsetDatasetLabelsMutationPayload: