arize-phoenix 10.0.4__py3-none-any.whl → 12.28.1__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.
Files changed (276) hide show
  1. {arize_phoenix-10.0.4.dist-info → arize_phoenix-12.28.1.dist-info}/METADATA +124 -72
  2. arize_phoenix-12.28.1.dist-info/RECORD +499 -0
  3. {arize_phoenix-10.0.4.dist-info → arize_phoenix-12.28.1.dist-info}/WHEEL +1 -1
  4. {arize_phoenix-10.0.4.dist-info → arize_phoenix-12.28.1.dist-info}/licenses/IP_NOTICE +1 -1
  5. phoenix/__generated__/__init__.py +0 -0
  6. phoenix/__generated__/classification_evaluator_configs/__init__.py +20 -0
  7. phoenix/__generated__/classification_evaluator_configs/_document_relevance_classification_evaluator_config.py +17 -0
  8. phoenix/__generated__/classification_evaluator_configs/_hallucination_classification_evaluator_config.py +17 -0
  9. phoenix/__generated__/classification_evaluator_configs/_models.py +18 -0
  10. phoenix/__generated__/classification_evaluator_configs/_tool_selection_classification_evaluator_config.py +17 -0
  11. phoenix/__init__.py +5 -4
  12. phoenix/auth.py +39 -2
  13. phoenix/config.py +1763 -91
  14. phoenix/datetime_utils.py +120 -2
  15. phoenix/db/README.md +595 -25
  16. phoenix/db/bulk_inserter.py +145 -103
  17. phoenix/db/engines.py +140 -33
  18. phoenix/db/enums.py +3 -12
  19. phoenix/db/facilitator.py +302 -35
  20. phoenix/db/helpers.py +1000 -65
  21. phoenix/db/iam_auth.py +64 -0
  22. phoenix/db/insertion/dataset.py +135 -2
  23. phoenix/db/insertion/document_annotation.py +9 -6
  24. phoenix/db/insertion/evaluation.py +2 -3
  25. phoenix/db/insertion/helpers.py +17 -2
  26. phoenix/db/insertion/session_annotation.py +176 -0
  27. phoenix/db/insertion/span.py +15 -11
  28. phoenix/db/insertion/span_annotation.py +3 -4
  29. phoenix/db/insertion/trace_annotation.py +3 -4
  30. phoenix/db/insertion/types.py +50 -20
  31. phoenix/db/migrations/versions/01a8342c9cdf_add_user_id_on_datasets.py +40 -0
  32. phoenix/db/migrations/versions/0df286449799_add_session_annotations_table.py +105 -0
  33. phoenix/db/migrations/versions/272b66ff50f8_drop_single_indices.py +119 -0
  34. phoenix/db/migrations/versions/58228d933c91_dataset_labels.py +67 -0
  35. phoenix/db/migrations/versions/699f655af132_experiment_tags.py +57 -0
  36. phoenix/db/migrations/versions/735d3d93c33e_add_composite_indices.py +41 -0
  37. phoenix/db/migrations/versions/a20694b15f82_cost.py +196 -0
  38. phoenix/db/migrations/versions/ab513d89518b_add_user_id_on_dataset_versions.py +40 -0
  39. phoenix/db/migrations/versions/d0690a79ea51_users_on_experiments.py +40 -0
  40. phoenix/db/migrations/versions/deb2c81c0bb2_dataset_splits.py +139 -0
  41. phoenix/db/migrations/versions/e76cbd66ffc3_add_experiments_dataset_examples.py +87 -0
  42. phoenix/db/models.py +669 -56
  43. phoenix/db/pg_config.py +10 -0
  44. phoenix/db/types/model_provider.py +4 -0
  45. phoenix/db/types/token_price_customization.py +29 -0
  46. phoenix/db/types/trace_retention.py +23 -15
  47. phoenix/experiments/evaluators/utils.py +3 -3
  48. phoenix/experiments/functions.py +160 -52
  49. phoenix/experiments/tracing.py +2 -2
  50. phoenix/experiments/types.py +1 -1
  51. phoenix/inferences/inferences.py +1 -2
  52. phoenix/server/api/auth.py +38 -7
  53. phoenix/server/api/auth_messages.py +46 -0
  54. phoenix/server/api/context.py +100 -4
  55. phoenix/server/api/dataloaders/__init__.py +79 -5
  56. phoenix/server/api/dataloaders/annotation_configs_by_project.py +31 -0
  57. phoenix/server/api/dataloaders/annotation_summaries.py +60 -8
  58. phoenix/server/api/dataloaders/average_experiment_repeated_run_group_latency.py +50 -0
  59. phoenix/server/api/dataloaders/average_experiment_run_latency.py +17 -24
  60. phoenix/server/api/dataloaders/cache/two_tier_cache.py +1 -2
  61. phoenix/server/api/dataloaders/dataset_dataset_splits.py +52 -0
  62. phoenix/server/api/dataloaders/dataset_example_revisions.py +0 -1
  63. phoenix/server/api/dataloaders/dataset_example_splits.py +40 -0
  64. phoenix/server/api/dataloaders/dataset_examples_and_versions_by_experiment_run.py +47 -0
  65. phoenix/server/api/dataloaders/dataset_labels.py +36 -0
  66. phoenix/server/api/dataloaders/document_evaluation_summaries.py +2 -2
  67. phoenix/server/api/dataloaders/document_evaluations.py +6 -9
  68. phoenix/server/api/dataloaders/experiment_annotation_summaries.py +88 -34
  69. phoenix/server/api/dataloaders/experiment_dataset_splits.py +43 -0
  70. phoenix/server/api/dataloaders/experiment_error_rates.py +21 -28
  71. phoenix/server/api/dataloaders/experiment_repeated_run_group_annotation_summaries.py +77 -0
  72. phoenix/server/api/dataloaders/experiment_repeated_run_groups.py +57 -0
  73. phoenix/server/api/dataloaders/experiment_runs_by_experiment_and_example.py +44 -0
  74. phoenix/server/api/dataloaders/last_used_times_by_generative_model_id.py +35 -0
  75. phoenix/server/api/dataloaders/latency_ms_quantile.py +40 -8
  76. phoenix/server/api/dataloaders/record_counts.py +37 -10
  77. phoenix/server/api/dataloaders/session_annotations_by_session.py +29 -0
  78. phoenix/server/api/dataloaders/span_cost_by_span.py +24 -0
  79. phoenix/server/api/dataloaders/span_cost_detail_summary_entries_by_generative_model.py +56 -0
  80. phoenix/server/api/dataloaders/span_cost_detail_summary_entries_by_project_session.py +57 -0
  81. phoenix/server/api/dataloaders/span_cost_detail_summary_entries_by_span.py +43 -0
  82. phoenix/server/api/dataloaders/span_cost_detail_summary_entries_by_trace.py +56 -0
  83. phoenix/server/api/dataloaders/span_cost_details_by_span_cost.py +27 -0
  84. phoenix/server/api/dataloaders/span_cost_summary_by_experiment.py +57 -0
  85. phoenix/server/api/dataloaders/span_cost_summary_by_experiment_repeated_run_group.py +64 -0
  86. phoenix/server/api/dataloaders/span_cost_summary_by_experiment_run.py +58 -0
  87. phoenix/server/api/dataloaders/span_cost_summary_by_generative_model.py +55 -0
  88. phoenix/server/api/dataloaders/span_cost_summary_by_project.py +152 -0
  89. phoenix/server/api/dataloaders/span_cost_summary_by_project_session.py +56 -0
  90. phoenix/server/api/dataloaders/span_cost_summary_by_trace.py +55 -0
  91. phoenix/server/api/dataloaders/span_costs.py +29 -0
  92. phoenix/server/api/dataloaders/table_fields.py +2 -2
  93. phoenix/server/api/dataloaders/token_prices_by_model.py +30 -0
  94. phoenix/server/api/dataloaders/trace_annotations_by_trace.py +27 -0
  95. phoenix/server/api/dataloaders/types.py +29 -0
  96. phoenix/server/api/exceptions.py +11 -1
  97. phoenix/server/api/helpers/dataset_helpers.py +5 -1
  98. phoenix/server/api/helpers/playground_clients.py +1243 -292
  99. phoenix/server/api/helpers/playground_registry.py +2 -2
  100. phoenix/server/api/helpers/playground_spans.py +8 -4
  101. phoenix/server/api/helpers/playground_users.py +26 -0
  102. phoenix/server/api/helpers/prompts/conversions/aws.py +83 -0
  103. phoenix/server/api/helpers/prompts/conversions/google.py +103 -0
  104. phoenix/server/api/helpers/prompts/models.py +205 -22
  105. phoenix/server/api/input_types/{SpanAnnotationFilter.py → AnnotationFilter.py} +22 -14
  106. phoenix/server/api/input_types/ChatCompletionInput.py +6 -2
  107. phoenix/server/api/input_types/CreateProjectInput.py +27 -0
  108. phoenix/server/api/input_types/CreateProjectSessionAnnotationInput.py +37 -0
  109. phoenix/server/api/input_types/DatasetFilter.py +17 -0
  110. phoenix/server/api/input_types/ExperimentRunSort.py +237 -0
  111. phoenix/server/api/input_types/GenerativeCredentialInput.py +9 -0
  112. phoenix/server/api/input_types/GenerativeModelInput.py +5 -0
  113. phoenix/server/api/input_types/ProjectSessionSort.py +161 -1
  114. phoenix/server/api/input_types/PromptFilter.py +14 -0
  115. phoenix/server/api/input_types/PromptVersionInput.py +52 -1
  116. phoenix/server/api/input_types/SpanSort.py +44 -7
  117. phoenix/server/api/input_types/TimeBinConfig.py +23 -0
  118. phoenix/server/api/input_types/UpdateAnnotationInput.py +34 -0
  119. phoenix/server/api/input_types/UserRoleInput.py +1 -0
  120. phoenix/server/api/mutations/__init__.py +10 -0
  121. phoenix/server/api/mutations/annotation_config_mutations.py +8 -8
  122. phoenix/server/api/mutations/api_key_mutations.py +19 -23
  123. phoenix/server/api/mutations/chat_mutations.py +154 -47
  124. phoenix/server/api/mutations/dataset_label_mutations.py +243 -0
  125. phoenix/server/api/mutations/dataset_mutations.py +21 -16
  126. phoenix/server/api/mutations/dataset_split_mutations.py +351 -0
  127. phoenix/server/api/mutations/experiment_mutations.py +2 -2
  128. phoenix/server/api/mutations/export_events_mutations.py +3 -3
  129. phoenix/server/api/mutations/model_mutations.py +210 -0
  130. phoenix/server/api/mutations/project_mutations.py +49 -10
  131. phoenix/server/api/mutations/project_session_annotations_mutations.py +158 -0
  132. phoenix/server/api/mutations/project_trace_retention_policy_mutations.py +8 -4
  133. phoenix/server/api/mutations/prompt_label_mutations.py +74 -65
  134. phoenix/server/api/mutations/prompt_mutations.py +65 -129
  135. phoenix/server/api/mutations/prompt_version_tag_mutations.py +11 -8
  136. phoenix/server/api/mutations/span_annotations_mutations.py +15 -10
  137. phoenix/server/api/mutations/trace_annotations_mutations.py +14 -10
  138. phoenix/server/api/mutations/trace_mutations.py +47 -3
  139. phoenix/server/api/mutations/user_mutations.py +66 -41
  140. phoenix/server/api/queries.py +768 -293
  141. phoenix/server/api/routers/__init__.py +2 -2
  142. phoenix/server/api/routers/auth.py +154 -88
  143. phoenix/server/api/routers/ldap.py +229 -0
  144. phoenix/server/api/routers/oauth2.py +369 -106
  145. phoenix/server/api/routers/v1/__init__.py +24 -4
  146. phoenix/server/api/routers/v1/annotation_configs.py +23 -31
  147. phoenix/server/api/routers/v1/annotations.py +481 -17
  148. phoenix/server/api/routers/v1/datasets.py +395 -81
  149. phoenix/server/api/routers/v1/documents.py +142 -0
  150. phoenix/server/api/routers/v1/evaluations.py +24 -31
  151. phoenix/server/api/routers/v1/experiment_evaluations.py +19 -8
  152. phoenix/server/api/routers/v1/experiment_runs.py +337 -59
  153. phoenix/server/api/routers/v1/experiments.py +479 -48
  154. phoenix/server/api/routers/v1/models.py +7 -0
  155. phoenix/server/api/routers/v1/projects.py +18 -49
  156. phoenix/server/api/routers/v1/prompts.py +54 -40
  157. phoenix/server/api/routers/v1/sessions.py +108 -0
  158. phoenix/server/api/routers/v1/spans.py +1091 -81
  159. phoenix/server/api/routers/v1/traces.py +132 -78
  160. phoenix/server/api/routers/v1/users.py +389 -0
  161. phoenix/server/api/routers/v1/utils.py +3 -7
  162. phoenix/server/api/subscriptions.py +305 -88
  163. phoenix/server/api/types/Annotation.py +90 -23
  164. phoenix/server/api/types/ApiKey.py +13 -17
  165. phoenix/server/api/types/AuthMethod.py +1 -0
  166. phoenix/server/api/types/ChatCompletionSubscriptionPayload.py +1 -0
  167. phoenix/server/api/types/CostBreakdown.py +12 -0
  168. phoenix/server/api/types/Dataset.py +226 -72
  169. phoenix/server/api/types/DatasetExample.py +88 -18
  170. phoenix/server/api/types/DatasetExperimentAnnotationSummary.py +10 -0
  171. phoenix/server/api/types/DatasetLabel.py +57 -0
  172. phoenix/server/api/types/DatasetSplit.py +98 -0
  173. phoenix/server/api/types/DatasetVersion.py +49 -4
  174. phoenix/server/api/types/DocumentAnnotation.py +212 -0
  175. phoenix/server/api/types/Experiment.py +264 -59
  176. phoenix/server/api/types/ExperimentComparison.py +5 -10
  177. phoenix/server/api/types/ExperimentRepeatedRunGroup.py +155 -0
  178. phoenix/server/api/types/ExperimentRepeatedRunGroupAnnotationSummary.py +9 -0
  179. phoenix/server/api/types/ExperimentRun.py +169 -65
  180. phoenix/server/api/types/ExperimentRunAnnotation.py +158 -39
  181. phoenix/server/api/types/GenerativeModel.py +245 -3
  182. phoenix/server/api/types/GenerativeProvider.py +70 -11
  183. phoenix/server/api/types/{Model.py → InferenceModel.py} +1 -1
  184. phoenix/server/api/types/ModelInterface.py +16 -0
  185. phoenix/server/api/types/PlaygroundModel.py +20 -0
  186. phoenix/server/api/types/Project.py +1278 -216
  187. phoenix/server/api/types/ProjectSession.py +188 -28
  188. phoenix/server/api/types/ProjectSessionAnnotation.py +187 -0
  189. phoenix/server/api/types/ProjectTraceRetentionPolicy.py +1 -1
  190. phoenix/server/api/types/Prompt.py +119 -39
  191. phoenix/server/api/types/PromptLabel.py +42 -25
  192. phoenix/server/api/types/PromptVersion.py +11 -8
  193. phoenix/server/api/types/PromptVersionTag.py +65 -25
  194. phoenix/server/api/types/ServerStatus.py +6 -0
  195. phoenix/server/api/types/Span.py +167 -123
  196. phoenix/server/api/types/SpanAnnotation.py +189 -42
  197. phoenix/server/api/types/SpanCostDetailSummaryEntry.py +10 -0
  198. phoenix/server/api/types/SpanCostSummary.py +10 -0
  199. phoenix/server/api/types/SystemApiKey.py +65 -1
  200. phoenix/server/api/types/TokenPrice.py +16 -0
  201. phoenix/server/api/types/TokenUsage.py +3 -3
  202. phoenix/server/api/types/Trace.py +223 -51
  203. phoenix/server/api/types/TraceAnnotation.py +149 -50
  204. phoenix/server/api/types/User.py +137 -32
  205. phoenix/server/api/types/UserApiKey.py +73 -26
  206. phoenix/server/api/types/node.py +10 -0
  207. phoenix/server/api/types/pagination.py +11 -2
  208. phoenix/server/app.py +290 -45
  209. phoenix/server/authorization.py +38 -3
  210. phoenix/server/bearer_auth.py +34 -24
  211. phoenix/server/cost_tracking/cost_details_calculator.py +196 -0
  212. phoenix/server/cost_tracking/cost_model_lookup.py +179 -0
  213. phoenix/server/cost_tracking/helpers.py +68 -0
  214. phoenix/server/cost_tracking/model_cost_manifest.json +3657 -830
  215. phoenix/server/cost_tracking/regex_specificity.py +397 -0
  216. phoenix/server/cost_tracking/token_cost_calculator.py +57 -0
  217. phoenix/server/daemons/__init__.py +0 -0
  218. phoenix/server/daemons/db_disk_usage_monitor.py +214 -0
  219. phoenix/server/daemons/generative_model_store.py +103 -0
  220. phoenix/server/daemons/span_cost_calculator.py +99 -0
  221. phoenix/server/dml_event.py +17 -0
  222. phoenix/server/dml_event_handler.py +5 -0
  223. phoenix/server/email/sender.py +56 -3
  224. phoenix/server/email/templates/db_disk_usage_notification.html +19 -0
  225. phoenix/server/email/types.py +11 -0
  226. phoenix/server/experiments/__init__.py +0 -0
  227. phoenix/server/experiments/utils.py +14 -0
  228. phoenix/server/grpc_server.py +11 -11
  229. phoenix/server/jwt_store.py +17 -15
  230. phoenix/server/ldap.py +1449 -0
  231. phoenix/server/main.py +26 -10
  232. phoenix/server/oauth2.py +330 -12
  233. phoenix/server/prometheus.py +66 -6
  234. phoenix/server/rate_limiters.py +4 -9
  235. phoenix/server/retention.py +33 -20
  236. phoenix/server/session_filters.py +49 -0
  237. phoenix/server/static/.vite/manifest.json +55 -51
  238. phoenix/server/static/assets/components-BreFUQQa.js +6702 -0
  239. phoenix/server/static/assets/{index-E0M82BdE.js → index-CTQoemZv.js} +140 -56
  240. phoenix/server/static/assets/pages-DBE5iYM3.js +9524 -0
  241. phoenix/server/static/assets/vendor-BGzfc4EU.css +1 -0
  242. phoenix/server/static/assets/vendor-DCE4v-Ot.js +920 -0
  243. phoenix/server/static/assets/vendor-codemirror-D5f205eT.js +25 -0
  244. phoenix/server/static/assets/vendor-recharts-V9cwpXsm.js +37 -0
  245. phoenix/server/static/assets/vendor-shiki-Do--csgv.js +5 -0
  246. phoenix/server/static/assets/vendor-three-CmB8bl_y.js +3840 -0
  247. phoenix/server/templates/index.html +40 -6
  248. phoenix/server/thread_server.py +1 -2
  249. phoenix/server/types.py +14 -4
  250. phoenix/server/utils.py +74 -0
  251. phoenix/session/client.py +56 -3
  252. phoenix/session/data_extractor.py +5 -0
  253. phoenix/session/evaluation.py +14 -5
  254. phoenix/session/session.py +45 -9
  255. phoenix/settings.py +5 -0
  256. phoenix/trace/attributes.py +80 -13
  257. phoenix/trace/dsl/helpers.py +90 -1
  258. phoenix/trace/dsl/query.py +8 -6
  259. phoenix/trace/projects.py +5 -0
  260. phoenix/utilities/template_formatters.py +1 -1
  261. phoenix/version.py +1 -1
  262. arize_phoenix-10.0.4.dist-info/RECORD +0 -405
  263. phoenix/server/api/types/Evaluation.py +0 -39
  264. phoenix/server/cost_tracking/cost_lookup.py +0 -255
  265. phoenix/server/static/assets/components-DULKeDfL.js +0 -4365
  266. phoenix/server/static/assets/pages-Cl0A-0U2.js +0 -7430
  267. phoenix/server/static/assets/vendor-WIZid84E.css +0 -1
  268. phoenix/server/static/assets/vendor-arizeai-Dy-0mSNw.js +0 -649
  269. phoenix/server/static/assets/vendor-codemirror-DBtifKNr.js +0 -33
  270. phoenix/server/static/assets/vendor-oB4u9zuV.js +0 -905
  271. phoenix/server/static/assets/vendor-recharts-D-T4KPz2.js +0 -59
  272. phoenix/server/static/assets/vendor-shiki-BMn4O_9F.js +0 -5
  273. phoenix/server/static/assets/vendor-three-C5WAXd5r.js +0 -2998
  274. phoenix/utilities/deprecation.py +0 -31
  275. {arize_phoenix-10.0.4.dist-info → arize_phoenix-12.28.1.dist-info}/entry_points.txt +0 -0
  276. {arize_phoenix-10.0.4.dist-info → arize_phoenix-12.28.1.dist-info}/licenses/LICENSE +0 -0
@@ -7,7 +7,7 @@ from starlette.requests import Request
7
7
  from strawberry import UNSET, Info
8
8
 
9
9
  from phoenix.db import models
10
- from phoenix.server.api.auth import IsLocked, IsNotReadOnly
10
+ from phoenix.server.api.auth import IsLocked, IsNotReadOnly, IsNotViewer
11
11
  from phoenix.server.api.context import Context
12
12
  from phoenix.server.api.exceptions import BadRequest, NotFound, Unauthorized
13
13
  from phoenix.server.api.helpers.annotations import get_user_identifier
@@ -21,7 +21,7 @@ from phoenix.server.api.queries import Query
21
21
  from phoenix.server.api.types.AnnotationSource import AnnotationSource
22
22
  from phoenix.server.api.types.AnnotatorKind import AnnotatorKind
23
23
  from phoenix.server.api.types.node import from_global_id_with_expected_type
24
- from phoenix.server.api.types.SpanAnnotation import SpanAnnotation, to_gql_span_annotation
24
+ from phoenix.server.api.types.SpanAnnotation import SpanAnnotation
25
25
  from phoenix.server.bearer_auth import PhoenixUser
26
26
  from phoenix.server.dml_event import SpanAnnotationDeleteEvent, SpanAnnotationInsertEvent
27
27
 
@@ -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:
@@ -138,7 +138,7 @@ class SpanAnnotationMutationMixin:
138
138
 
139
139
  # Convert the fully loaded annotations to GQL types
140
140
  returned_annotations = [
141
- to_gql_span_annotation(anno) for anno in ordered_final_annotations
141
+ SpanAnnotation(id=anno.id, db_record=anno) for anno in ordered_final_annotations
142
142
  ]
143
143
 
144
144
  await session.commit()
@@ -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:
@@ -184,14 +184,16 @@ class SpanAnnotationMutationMixin:
184
184
  processed_annotation = result.one()
185
185
 
186
186
  info.context.event_queue.put(SpanAnnotationInsertEvent((processed_annotation.id,)))
187
- returned_annotation = to_gql_span_annotation(processed_annotation)
187
+ returned_annotation = SpanAnnotation(
188
+ id=processed_annotation.id, db_record=processed_annotation
189
+ )
188
190
  await session.commit()
189
191
  return SpanAnnotationMutationPayload(
190
192
  span_annotations=[returned_annotation],
191
193
  query=Query(),
192
194
  )
193
195
 
194
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsLocked]) # type: ignore
196
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsLocked]) # type: ignore
195
197
  async def patch_span_annotations(
196
198
  self, info: Info[Context, None], input: list[PatchAnnotationInput]
197
199
  ) -> SpanAnnotationMutationPayload:
@@ -256,7 +258,7 @@ class SpanAnnotationMutationMixin:
256
258
  session.add(span_annotation)
257
259
 
258
260
  patched_annotations = [
259
- to_gql_span_annotation(span_annotation)
261
+ SpanAnnotation(id=span_annotation.id, db_record=span_annotation)
260
262
  for span_annotation in span_annotations_by_id.values()
261
263
  ]
262
264
 
@@ -268,7 +270,7 @@ class SpanAnnotationMutationMixin:
268
270
  query=Query(),
269
271
  )
270
272
 
271
- @strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
273
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer]) # type: ignore
272
274
  async def delete_span_annotations(
273
275
  self, info: Info[Context, None], input: DeleteAnnotationsInput
274
276
  ) -> SpanAnnotationMutationPayload:
@@ -320,7 +322,10 @@ class SpanAnnotationMutationMixin:
320
322
  )
321
323
 
322
324
  deleted_annotations_gql = [
323
- to_gql_span_annotation(deleted_annotations_by_id[id]) for id in span_annotation_ids
325
+ SpanAnnotation(
326
+ id=deleted_annotations_by_id[id].id, db_record=deleted_annotations_by_id[id]
327
+ )
328
+ for id in span_annotation_ids
324
329
  ]
325
330
  info.context.event_queue.put(
326
331
  SpanAnnotationDeleteEvent(tuple(deleted_annotations_by_id.keys()))
@@ -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
@@ -16,7 +16,7 @@ from phoenix.server.api.input_types.PatchAnnotationInput import PatchAnnotationI
16
16
  from phoenix.server.api.queries import Query
17
17
  from phoenix.server.api.types.AnnotationSource import AnnotationSource
18
18
  from phoenix.server.api.types.node import from_global_id_with_expected_type
19
- from phoenix.server.api.types.TraceAnnotation import TraceAnnotation, to_gql_trace_annotation
19
+ from phoenix.server.api.types.TraceAnnotation import TraceAnnotation
20
20
  from phoenix.server.bearer_auth import PhoenixUser
21
21
  from phoenix.server.dml_event import TraceAnnotationDeleteEvent, TraceAnnotationInsertEvent
22
22
 
@@ -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:
@@ -49,8 +49,7 @@ class TraceAnnotationMutationMixin:
49
49
  trace_rowid = from_global_id_with_expected_type(annotation_input.trace_id, "Trace")
50
50
  except ValueError:
51
51
  raise BadRequest(
52
- f"Invalid trace ID for annotation at index {idx}: "
53
- f"{annotation_input.trace_id}"
52
+ f"Invalid trace ID for annotation at index {idx}: {annotation_input.trace_id}"
54
53
  )
55
54
  trace_rowids.append(trace_rowid)
56
55
 
@@ -112,7 +111,9 @@ class TraceAnnotationMutationMixin:
112
111
  info.context.event_queue.put(TraceAnnotationInsertEvent(inserted_annotation_ids))
113
112
 
114
113
  returned_annotations = [
115
- to_gql_trace_annotation(processed_annotations_map[i])
114
+ TraceAnnotation(
115
+ id=processed_annotations_map[i].id, db_record=processed_annotations_map[i]
116
+ )
116
117
  for i in sorted(processed_annotations_map.keys())
117
118
  ]
118
119
 
@@ -121,7 +122,7 @@ class TraceAnnotationMutationMixin:
121
122
  query=Query(),
122
123
  )
123
124
 
124
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsLocked]) # type: ignore
125
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsLocked]) # type: ignore
125
126
  async def patch_trace_annotations(
126
127
  self, info: Info[Context, None], input: list[PatchAnnotationInput]
127
128
  ) -> TraceAnnotationMutationPayload:
@@ -187,7 +188,7 @@ class TraceAnnotationMutationMixin:
187
188
  await session.commit()
188
189
 
189
190
  patched_annotations = [
190
- to_gql_trace_annotation(trace_annotation)
191
+ TraceAnnotation(id=trace_annotation.id, db_record=trace_annotation)
191
192
  for trace_annotation in trace_annotations_by_id.values()
192
193
  ]
193
194
  info.context.event_queue.put(TraceAnnotationInsertEvent(tuple(patch_by_id.keys())))
@@ -196,7 +197,7 @@ class TraceAnnotationMutationMixin:
196
197
  query=Query(),
197
198
  )
198
199
 
199
- @strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
200
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer]) # type: ignore
200
201
  async def delete_trace_annotations(
201
202
  self, info: Info[Context, None], input: DeleteAnnotationsInput
202
203
  ) -> TraceAnnotationMutationPayload:
@@ -246,7 +247,10 @@ class TraceAnnotationMutationMixin:
246
247
  )
247
248
 
248
249
  deleted_gql_annotations = [
249
- to_gql_trace_annotation(deleted_annotations_by_id[id]) for id in trace_annotation_ids
250
+ TraceAnnotation(
251
+ id=deleted_annotations_by_id[id].id, db_record=deleted_annotations_by_id[id]
252
+ )
253
+ for id in trace_annotation_ids
250
254
  ]
251
255
  info.context.event_queue.put(
252
256
  TraceAnnotationDeleteEvent(tuple(deleted_annotations_by_id.keys()))
@@ -1,12 +1,12 @@
1
1
  import strawberry
2
- from sqlalchemy import and_, delete, not_, select
2
+ from sqlalchemy import and_, delete, not_, select, update
3
3
  from sqlalchemy.orm import load_only
4
4
  from sqlalchemy.sql import literal
5
5
  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],
@@ -72,3 +72,47 @@ class TraceMutationMixin:
72
72
  )
73
73
  info.context.event_queue.put(SpanDeleteEvent(project_ids))
74
74
  return Query()
75
+
76
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer]) # type: ignore
77
+ async def transfer_traces_to_project(
78
+ self,
79
+ info: Info[Context, None],
80
+ trace_ids: list[GlobalID],
81
+ project_id: GlobalID,
82
+ ) -> Query:
83
+ if not trace_ids:
84
+ raise BadRequest("Must provide at least one trace ID to transfer")
85
+ trace_ids = list(set(trace_ids))
86
+ try:
87
+ trace_rowids = [
88
+ from_global_id_with_expected_type(global_id=id, expected_type_name="Trace")
89
+ for id in trace_ids
90
+ ]
91
+ dest_project_rowid = from_global_id_with_expected_type(
92
+ global_id=project_id, expected_type_name="Project"
93
+ )
94
+ except ValueError as error:
95
+ raise BadRequest(str(error))
96
+
97
+ async with info.context.db() as session:
98
+ dest_project = await session.get(models.Project, dest_project_rowid)
99
+ if dest_project is None:
100
+ raise BadRequest("Destination project does not exist")
101
+
102
+ traces = (
103
+ await session.scalars(select(models.Trace).where(models.Trace.id.in_(trace_rowids)))
104
+ ).all()
105
+ if len(traces) < len(trace_rowids):
106
+ raise BadRequest("Invalid trace IDs provided")
107
+
108
+ source_project_ids = set(trace.project_rowid for trace in traces)
109
+ if len(source_project_ids) > 1:
110
+ raise BadRequest("Cannot transfer traces from multiple projects")
111
+
112
+ await session.execute(
113
+ update(models.Trace)
114
+ .where(models.Trace.id.in_(trace_rowids))
115
+ .values(project_rowid=dest_project_rowid)
116
+ )
117
+
118
+ return Query()
@@ -21,18 +21,19 @@ from phoenix.auth import (
21
21
  PASSWORD_REQUIREMENTS,
22
22
  PHOENIX_ACCESS_TOKEN_COOKIE_NAME,
23
23
  PHOENIX_REFRESH_TOKEN_COOKIE_NAME,
24
+ sanitize_email,
24
25
  validate_email_format,
25
26
  validate_password_format,
26
27
  )
27
28
  from phoenix.config import get_env_disable_basic_auth
28
- from phoenix.db import enums, models
29
- from phoenix.server.api.auth import IsAdmin, IsLocked, IsNotReadOnly
29
+ from phoenix.db import models
30
+ from phoenix.server.api.auth import IsAdmin, IsLocked, IsNotReadOnly, IsNotViewer
30
31
  from phoenix.server.api.context import Context
31
32
  from phoenix.server.api.exceptions import BadRequest, Conflict, NotFound, Unauthorized
32
33
  from phoenix.server.api.input_types.UserRoleInput import UserRoleInput
33
34
  from phoenix.server.api.types.AuthMethod import AuthMethod
34
35
  from phoenix.server.api.types.node import from_global_id_with_expected_type
35
- from phoenix.server.api.types.User import User, to_gql_user
36
+ from phoenix.server.api.types.User import User
36
37
  from phoenix.server.bearer_auth import PhoenixUser
37
38
  from phoenix.server.types import AccessTokenId, ApiKeyId, PasswordResetTokenId, RefreshTokenId
38
39
 
@@ -49,7 +50,10 @@ class CreateUserInput:
49
50
  auth_method: Optional[AuthMethod] = AuthMethod.LOCAL
50
51
 
51
52
  def __post_init__(self) -> None:
52
- if self.auth_method is AuthMethod.OAUTH2:
53
+ if self.auth_method is AuthMethod.LDAP:
54
+ if self.password:
55
+ raise BadRequest("Password is not allowed for LDAP authentication")
56
+ elif self.auth_method is AuthMethod.OAUTH2:
53
57
  if self.password:
54
58
  raise BadRequest("Password is not allowed for OAuth2 authentication")
55
59
  elif get_env_disable_basic_auth():
@@ -102,6 +106,11 @@ class DeleteUsersInput:
102
106
  user_ids: list[GlobalID]
103
107
 
104
108
 
109
+ @strawberry.type
110
+ class DeleteUsersPayload:
111
+ user_ids: list[GlobalID]
112
+
113
+
105
114
  @strawberry.type
106
115
  class UserMutationPayload:
107
116
  user: User
@@ -109,26 +118,34 @@ class UserMutationPayload:
109
118
 
110
119
  @strawberry.type
111
120
  class UserMutationMixin:
112
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsAdmin, IsLocked]) # type: ignore
121
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsAdmin, IsLocked]) # type: ignore
113
122
  async def create_user(
114
123
  self,
115
124
  info: Info[Context, None],
116
125
  input: CreateUserInput,
117
126
  ) -> UserMutationPayload:
127
+ # Sanitize email by trimming and lowercasing
128
+ email = sanitize_email(input.email)
129
+
118
130
  user: models.User
119
- if input.auth_method is AuthMethod.OAUTH2:
131
+ if input.auth_method is AuthMethod.LDAP:
132
+ user = models.LDAPUser(
133
+ email=email,
134
+ username=input.username,
135
+ )
136
+ elif input.auth_method is AuthMethod.OAUTH2:
120
137
  user = models.OAuth2User(
121
- email=input.email,
138
+ email=email,
122
139
  username=input.username,
123
140
  )
124
141
  else:
125
142
  assert input.password
126
- validate_email_format(input.email)
143
+ validate_email_format(email)
127
144
  validate_password_format(input.password)
128
145
  salt = secrets.token_bytes(DEFAULT_SECRET_LENGTH)
129
146
  password_hash = await info.context.hash_password(Secret(input.password), salt)
130
147
  user = models.LocalUser(
131
- email=input.email,
148
+ email=email,
132
149
  username=input.username,
133
150
  password_hash=password_hash,
134
151
  password_salt=salt,
@@ -151,9 +168,9 @@ class UserMutationMixin:
151
168
  except Exception as error:
152
169
  # Log the error but do not raise it
153
170
  logger.error(f"Failed to send welcome email: {error}")
154
- return UserMutationPayload(user=to_gql_user(user))
171
+ return UserMutationPayload(user=User(id=user.id, db_record=user))
155
172
 
156
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsAdmin, IsLocked]) # type: ignore
173
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsAdmin]) # type: ignore
157
174
  async def patch_user(
158
175
  self,
159
176
  info: Info[Context, None],
@@ -170,6 +187,7 @@ class UserMutationMixin:
170
187
  if not (user := await session.scalar(_select_user_by_id(user_id))):
171
188
  raise NotFound("User not found")
172
189
  stack.enter_context(session.no_autoflush)
190
+ should_log_out = False
173
191
  if input.new_role:
174
192
  if user.email == DEFAULT_ADMIN_EMAIL:
175
193
  raise Unauthorized("Cannot modify role for the default admin user")
@@ -179,6 +197,7 @@ class UserMutationMixin:
179
197
  if user_role_id is None:
180
198
  raise NotFound(f"Role {input.new_role.value} not found")
181
199
  user.user_role_id = user_role_id
200
+ should_log_out = True
182
201
  if password := input.new_password:
183
202
  if user.auth_method != "LOCAL":
184
203
  raise Conflict("Cannot modify password for non-local user")
@@ -187,6 +206,7 @@ class UserMutationMixin:
187
206
  user.password_salt = salt
188
207
  user.password_hash = await info.context.hash_password(Secret(password), salt)
189
208
  user.reset_password = True
209
+ should_log_out = True
190
210
  if username := input.new_username:
191
211
  user.username = username
192
212
  assert user in session.dirty
@@ -195,11 +215,11 @@ class UserMutationMixin:
195
215
  except (PostgreSQLIntegrityError, SQLiteIntegrityError) as error:
196
216
  raise Conflict(_user_operation_error_message(error, "modify"))
197
217
  assert user
198
- if input.new_password:
218
+ if should_log_out:
199
219
  await info.context.log_out(user.id)
200
- return UserMutationPayload(user=to_gql_user(user))
220
+ return UserMutationPayload(user=User(id=user.id, db_record=user))
201
221
 
202
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsLocked]) # type: ignore
222
+ @strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
203
223
  async def patch_viewer(
204
224
  self,
205
225
  info: Info[Context, None],
@@ -239,33 +259,28 @@ class UserMutationMixin:
239
259
  response = info.context.get_response()
240
260
  response.delete_cookie(PHOENIX_REFRESH_TOKEN_COOKIE_NAME)
241
261
  response.delete_cookie(PHOENIX_ACCESS_TOKEN_COOKIE_NAME)
242
- return UserMutationPayload(user=to_gql_user(user))
262
+ return UserMutationPayload(user=User(id=user.id, db_record=user))
243
263
 
244
- @strawberry.mutation(permission_classes=[IsNotReadOnly, IsAdmin, IsLocked]) # type: ignore
264
+ @strawberry.mutation(permission_classes=[IsNotReadOnly, IsNotViewer, IsAdmin, IsLocked]) # type: ignore
245
265
  async def delete_users(
246
266
  self,
247
267
  info: Info[Context, None],
248
268
  input: DeleteUsersInput,
249
- ) -> None:
269
+ ) -> DeleteUsersPayload:
250
270
  assert (token_store := info.context.token_store) is not None
251
271
  if not input.user_ids:
252
- return
253
- user_ids = tuple(
254
- map(
255
- lambda gid: from_global_id_with_expected_type(gid, User.__name__),
256
- set(input.user_ids),
257
- )
258
- )
259
- system_user_role_id = (
260
- select(models.UserRole.id)
261
- .where(models.UserRole.name == enums.UserRole.SYSTEM.value)
262
- .scalar_subquery()
263
- )
264
- admin_user_role_id = (
265
- select(models.UserRole.id)
266
- .where(models.UserRole.name == enums.UserRole.ADMIN.value)
267
- .scalar_subquery()
268
- )
272
+ raise BadRequest("At least one user ID is required")
273
+ user_rowid_to_gid: dict[int, GlobalID] = {}
274
+ for user_gid in input.user_ids:
275
+ try:
276
+ user_rowid = from_global_id_with_expected_type(user_gid, User.__name__)
277
+ except ValueError:
278
+ raise BadRequest(f"Invalid user ID: '{user_gid}'")
279
+ user_rowid_to_gid[user_rowid] = user_gid
280
+
281
+ user_rowids = list(user_rowid_to_gid.keys())
282
+ system_user_role_id = select(models.UserRole.id).filter_by(name="SYSTEM").scalar_subquery()
283
+ admin_user_role_id = select(models.UserRole.id).filter_by(name="ADMIN").scalar_subquery()
269
284
  default_admin_user_id = (
270
285
  select(models.User.id)
271
286
  .where(
@@ -302,7 +317,7 @@ class UserMutationMixin:
302
317
  .select_from(models.User)
303
318
  .where(
304
319
  and_(
305
- models.User.id.in_(user_ids),
320
+ models.User.id.in_(user_rowids),
306
321
  models.User.user_role_id != system_user_role_id,
307
322
  )
308
323
  )
@@ -310,41 +325,51 @@ class UserMutationMixin:
310
325
  ).all()
311
326
  if deletes_default_admin:
312
327
  raise Conflict("Cannot delete the default admin user")
313
- if num_resolved_user_ids < len(user_ids):
328
+ if num_resolved_user_ids < len(user_rowids):
314
329
  raise NotFound("Some user IDs could not be found")
315
330
  password_reset_token_ids = [
316
331
  PasswordResetTokenId(id_)
317
332
  async for id_ in await session.stream_scalars(
318
333
  select(models.PasswordResetToken.id).where(
319
- models.PasswordResetToken.user_id.in_(user_ids)
334
+ models.PasswordResetToken.user_id.in_(user_rowids)
320
335
  )
321
336
  )
322
337
  ]
323
338
  access_token_ids = [
324
339
  AccessTokenId(id_)
325
340
  async for id_ in await session.stream_scalars(
326
- select(models.AccessToken.id).where(models.AccessToken.user_id.in_(user_ids))
341
+ select(models.AccessToken.id).where(models.AccessToken.user_id.in_(user_rowids))
327
342
  )
328
343
  ]
329
344
  refresh_token_ids = [
330
345
  RefreshTokenId(id_)
331
346
  async for id_ in await session.stream_scalars(
332
- select(models.RefreshToken.id).where(models.RefreshToken.user_id.in_(user_ids))
347
+ select(models.RefreshToken.id).where(
348
+ models.RefreshToken.user_id.in_(user_rowids)
349
+ )
333
350
  )
334
351
  ]
335
352
  api_key_ids = [
336
353
  ApiKeyId(id_)
337
354
  async for id_ in await session.stream_scalars(
338
- select(models.ApiKey.id).where(models.ApiKey.user_id.in_(user_ids))
355
+ select(models.ApiKey.id).where(models.ApiKey.user_id.in_(user_rowids))
339
356
  )
340
357
  ]
341
- await session.execute(delete(models.User).where(models.User.id.in_(user_ids)))
358
+ deleted_user_ids = await session.scalars(
359
+ delete(models.User).where(models.User.id.in_(user_rowids)).returning(models.User.id)
360
+ )
342
361
  await token_store.revoke(
343
362
  *password_reset_token_ids,
344
363
  *access_token_ids,
345
364
  *refresh_token_ids,
346
365
  *api_key_ids,
347
366
  )
367
+ unique_deleted_user_ids = set(deleted_user_ids)
368
+ deleted_user_gids: list[GlobalID] = []
369
+ for user_rowid, user_gid in user_rowid_to_gid.items():
370
+ if user_rowid in unique_deleted_user_ids:
371
+ deleted_user_gids.append(user_gid)
372
+ return DeleteUsersPayload(user_ids=deleted_user_gids)
348
373
 
349
374
 
350
375
  def _select_role_id_by_name(role_name: str) -> Select[tuple[int]]: