arize-phoenix 3.16.0__py3-none-any.whl → 7.7.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 (338) hide show
  1. arize_phoenix-7.7.0.dist-info/METADATA +261 -0
  2. arize_phoenix-7.7.0.dist-info/RECORD +345 -0
  3. {arize_phoenix-3.16.0.dist-info → arize_phoenix-7.7.0.dist-info}/WHEEL +1 -1
  4. arize_phoenix-7.7.0.dist-info/entry_points.txt +3 -0
  5. phoenix/__init__.py +86 -14
  6. phoenix/auth.py +309 -0
  7. phoenix/config.py +675 -45
  8. phoenix/core/model.py +32 -30
  9. phoenix/core/model_schema.py +102 -109
  10. phoenix/core/model_schema_adapter.py +48 -45
  11. phoenix/datetime_utils.py +24 -3
  12. phoenix/db/README.md +54 -0
  13. phoenix/db/__init__.py +4 -0
  14. phoenix/db/alembic.ini +85 -0
  15. phoenix/db/bulk_inserter.py +294 -0
  16. phoenix/db/engines.py +208 -0
  17. phoenix/db/enums.py +20 -0
  18. phoenix/db/facilitator.py +113 -0
  19. phoenix/db/helpers.py +159 -0
  20. phoenix/db/insertion/constants.py +2 -0
  21. phoenix/db/insertion/dataset.py +227 -0
  22. phoenix/db/insertion/document_annotation.py +171 -0
  23. phoenix/db/insertion/evaluation.py +191 -0
  24. phoenix/db/insertion/helpers.py +98 -0
  25. phoenix/db/insertion/span.py +193 -0
  26. phoenix/db/insertion/span_annotation.py +158 -0
  27. phoenix/db/insertion/trace_annotation.py +158 -0
  28. phoenix/db/insertion/types.py +256 -0
  29. phoenix/db/migrate.py +86 -0
  30. phoenix/db/migrations/data_migration_scripts/populate_project_sessions.py +199 -0
  31. phoenix/db/migrations/env.py +114 -0
  32. phoenix/db/migrations/script.py.mako +26 -0
  33. phoenix/db/migrations/versions/10460e46d750_datasets.py +317 -0
  34. phoenix/db/migrations/versions/3be8647b87d8_add_token_columns_to_spans_table.py +126 -0
  35. phoenix/db/migrations/versions/4ded9e43755f_create_project_sessions_table.py +66 -0
  36. phoenix/db/migrations/versions/cd164e83824f_users_and_tokens.py +157 -0
  37. phoenix/db/migrations/versions/cf03bd6bae1d_init.py +280 -0
  38. phoenix/db/models.py +807 -0
  39. phoenix/exceptions.py +5 -1
  40. phoenix/experiments/__init__.py +6 -0
  41. phoenix/experiments/evaluators/__init__.py +29 -0
  42. phoenix/experiments/evaluators/base.py +158 -0
  43. phoenix/experiments/evaluators/code_evaluators.py +184 -0
  44. phoenix/experiments/evaluators/llm_evaluators.py +473 -0
  45. phoenix/experiments/evaluators/utils.py +236 -0
  46. phoenix/experiments/functions.py +772 -0
  47. phoenix/experiments/tracing.py +86 -0
  48. phoenix/experiments/types.py +726 -0
  49. phoenix/experiments/utils.py +25 -0
  50. phoenix/inferences/__init__.py +0 -0
  51. phoenix/{datasets → inferences}/errors.py +6 -5
  52. phoenix/{datasets → inferences}/fixtures.py +49 -42
  53. phoenix/{datasets/dataset.py → inferences/inferences.py} +121 -105
  54. phoenix/{datasets → inferences}/schema.py +11 -11
  55. phoenix/{datasets → inferences}/validation.py +13 -14
  56. phoenix/logging/__init__.py +3 -0
  57. phoenix/logging/_config.py +90 -0
  58. phoenix/logging/_filter.py +6 -0
  59. phoenix/logging/_formatter.py +69 -0
  60. phoenix/metrics/__init__.py +5 -4
  61. phoenix/metrics/binning.py +4 -3
  62. phoenix/metrics/metrics.py +2 -1
  63. phoenix/metrics/mixins.py +7 -6
  64. phoenix/metrics/retrieval_metrics.py +2 -1
  65. phoenix/metrics/timeseries.py +5 -4
  66. phoenix/metrics/wrappers.py +9 -3
  67. phoenix/pointcloud/clustering.py +5 -5
  68. phoenix/pointcloud/pointcloud.py +7 -5
  69. phoenix/pointcloud/projectors.py +5 -6
  70. phoenix/pointcloud/umap_parameters.py +53 -52
  71. phoenix/server/api/README.md +28 -0
  72. phoenix/server/api/auth.py +44 -0
  73. phoenix/server/api/context.py +152 -9
  74. phoenix/server/api/dataloaders/__init__.py +91 -0
  75. phoenix/server/api/dataloaders/annotation_summaries.py +139 -0
  76. phoenix/server/api/dataloaders/average_experiment_run_latency.py +54 -0
  77. phoenix/server/api/dataloaders/cache/__init__.py +3 -0
  78. phoenix/server/api/dataloaders/cache/two_tier_cache.py +68 -0
  79. phoenix/server/api/dataloaders/dataset_example_revisions.py +131 -0
  80. phoenix/server/api/dataloaders/dataset_example_spans.py +38 -0
  81. phoenix/server/api/dataloaders/document_evaluation_summaries.py +144 -0
  82. phoenix/server/api/dataloaders/document_evaluations.py +31 -0
  83. phoenix/server/api/dataloaders/document_retrieval_metrics.py +89 -0
  84. phoenix/server/api/dataloaders/experiment_annotation_summaries.py +79 -0
  85. phoenix/server/api/dataloaders/experiment_error_rates.py +58 -0
  86. phoenix/server/api/dataloaders/experiment_run_annotations.py +36 -0
  87. phoenix/server/api/dataloaders/experiment_run_counts.py +49 -0
  88. phoenix/server/api/dataloaders/experiment_sequence_number.py +44 -0
  89. phoenix/server/api/dataloaders/latency_ms_quantile.py +188 -0
  90. phoenix/server/api/dataloaders/min_start_or_max_end_times.py +85 -0
  91. phoenix/server/api/dataloaders/project_by_name.py +31 -0
  92. phoenix/server/api/dataloaders/record_counts.py +116 -0
  93. phoenix/server/api/dataloaders/session_io.py +79 -0
  94. phoenix/server/api/dataloaders/session_num_traces.py +30 -0
  95. phoenix/server/api/dataloaders/session_num_traces_with_error.py +32 -0
  96. phoenix/server/api/dataloaders/session_token_usages.py +41 -0
  97. phoenix/server/api/dataloaders/session_trace_latency_ms_quantile.py +55 -0
  98. phoenix/server/api/dataloaders/span_annotations.py +26 -0
  99. phoenix/server/api/dataloaders/span_dataset_examples.py +31 -0
  100. phoenix/server/api/dataloaders/span_descendants.py +57 -0
  101. phoenix/server/api/dataloaders/span_projects.py +33 -0
  102. phoenix/server/api/dataloaders/token_counts.py +124 -0
  103. phoenix/server/api/dataloaders/trace_by_trace_ids.py +25 -0
  104. phoenix/server/api/dataloaders/trace_root_spans.py +32 -0
  105. phoenix/server/api/dataloaders/user_roles.py +30 -0
  106. phoenix/server/api/dataloaders/users.py +33 -0
  107. phoenix/server/api/exceptions.py +48 -0
  108. phoenix/server/api/helpers/__init__.py +12 -0
  109. phoenix/server/api/helpers/dataset_helpers.py +217 -0
  110. phoenix/server/api/helpers/experiment_run_filters.py +763 -0
  111. phoenix/server/api/helpers/playground_clients.py +948 -0
  112. phoenix/server/api/helpers/playground_registry.py +70 -0
  113. phoenix/server/api/helpers/playground_spans.py +455 -0
  114. phoenix/server/api/input_types/AddExamplesToDatasetInput.py +16 -0
  115. phoenix/server/api/input_types/AddSpansToDatasetInput.py +14 -0
  116. phoenix/server/api/input_types/ChatCompletionInput.py +38 -0
  117. phoenix/server/api/input_types/ChatCompletionMessageInput.py +24 -0
  118. phoenix/server/api/input_types/ClearProjectInput.py +15 -0
  119. phoenix/server/api/input_types/ClusterInput.py +2 -2
  120. phoenix/server/api/input_types/CreateDatasetInput.py +12 -0
  121. phoenix/server/api/input_types/CreateSpanAnnotationInput.py +18 -0
  122. phoenix/server/api/input_types/CreateTraceAnnotationInput.py +18 -0
  123. phoenix/server/api/input_types/DataQualityMetricInput.py +5 -2
  124. phoenix/server/api/input_types/DatasetExampleInput.py +14 -0
  125. phoenix/server/api/input_types/DatasetSort.py +17 -0
  126. phoenix/server/api/input_types/DatasetVersionSort.py +16 -0
  127. phoenix/server/api/input_types/DeleteAnnotationsInput.py +7 -0
  128. phoenix/server/api/input_types/DeleteDatasetExamplesInput.py +13 -0
  129. phoenix/server/api/input_types/DeleteDatasetInput.py +7 -0
  130. phoenix/server/api/input_types/DeleteExperimentsInput.py +7 -0
  131. phoenix/server/api/input_types/DimensionFilter.py +4 -4
  132. phoenix/server/api/input_types/GenerativeModelInput.py +17 -0
  133. phoenix/server/api/input_types/Granularity.py +1 -1
  134. phoenix/server/api/input_types/InvocationParameters.py +162 -0
  135. phoenix/server/api/input_types/PatchAnnotationInput.py +19 -0
  136. phoenix/server/api/input_types/PatchDatasetExamplesInput.py +35 -0
  137. phoenix/server/api/input_types/PatchDatasetInput.py +14 -0
  138. phoenix/server/api/input_types/PerformanceMetricInput.py +5 -2
  139. phoenix/server/api/input_types/ProjectSessionSort.py +29 -0
  140. phoenix/server/api/input_types/SpanAnnotationSort.py +17 -0
  141. phoenix/server/api/input_types/SpanSort.py +134 -69
  142. phoenix/server/api/input_types/TemplateOptions.py +10 -0
  143. phoenix/server/api/input_types/TraceAnnotationSort.py +17 -0
  144. phoenix/server/api/input_types/UserRoleInput.py +9 -0
  145. phoenix/server/api/mutations/__init__.py +28 -0
  146. phoenix/server/api/mutations/api_key_mutations.py +167 -0
  147. phoenix/server/api/mutations/chat_mutations.py +593 -0
  148. phoenix/server/api/mutations/dataset_mutations.py +591 -0
  149. phoenix/server/api/mutations/experiment_mutations.py +75 -0
  150. phoenix/server/api/{types/ExportEventsMutation.py → mutations/export_events_mutations.py} +21 -18
  151. phoenix/server/api/mutations/project_mutations.py +57 -0
  152. phoenix/server/api/mutations/span_annotations_mutations.py +128 -0
  153. phoenix/server/api/mutations/trace_annotations_mutations.py +127 -0
  154. phoenix/server/api/mutations/user_mutations.py +329 -0
  155. phoenix/server/api/openapi/__init__.py +0 -0
  156. phoenix/server/api/openapi/main.py +17 -0
  157. phoenix/server/api/openapi/schema.py +16 -0
  158. phoenix/server/api/queries.py +738 -0
  159. phoenix/server/api/routers/__init__.py +11 -0
  160. phoenix/server/api/routers/auth.py +284 -0
  161. phoenix/server/api/routers/embeddings.py +26 -0
  162. phoenix/server/api/routers/oauth2.py +488 -0
  163. phoenix/server/api/routers/v1/__init__.py +64 -0
  164. phoenix/server/api/routers/v1/datasets.py +1017 -0
  165. phoenix/server/api/routers/v1/evaluations.py +362 -0
  166. phoenix/server/api/routers/v1/experiment_evaluations.py +115 -0
  167. phoenix/server/api/routers/v1/experiment_runs.py +167 -0
  168. phoenix/server/api/routers/v1/experiments.py +308 -0
  169. phoenix/server/api/routers/v1/pydantic_compat.py +78 -0
  170. phoenix/server/api/routers/v1/spans.py +267 -0
  171. phoenix/server/api/routers/v1/traces.py +208 -0
  172. phoenix/server/api/routers/v1/utils.py +95 -0
  173. phoenix/server/api/schema.py +44 -247
  174. phoenix/server/api/subscriptions.py +597 -0
  175. phoenix/server/api/types/Annotation.py +21 -0
  176. phoenix/server/api/types/AnnotationSummary.py +55 -0
  177. phoenix/server/api/types/AnnotatorKind.py +16 -0
  178. phoenix/server/api/types/ApiKey.py +27 -0
  179. phoenix/server/api/types/AuthMethod.py +9 -0
  180. phoenix/server/api/types/ChatCompletionMessageRole.py +11 -0
  181. phoenix/server/api/types/ChatCompletionSubscriptionPayload.py +46 -0
  182. phoenix/server/api/types/Cluster.py +25 -24
  183. phoenix/server/api/types/CreateDatasetPayload.py +8 -0
  184. phoenix/server/api/types/DataQualityMetric.py +31 -13
  185. phoenix/server/api/types/Dataset.py +288 -63
  186. phoenix/server/api/types/DatasetExample.py +85 -0
  187. phoenix/server/api/types/DatasetExampleRevision.py +34 -0
  188. phoenix/server/api/types/DatasetVersion.py +14 -0
  189. phoenix/server/api/types/Dimension.py +32 -31
  190. phoenix/server/api/types/DocumentEvaluationSummary.py +9 -8
  191. phoenix/server/api/types/EmbeddingDimension.py +56 -49
  192. phoenix/server/api/types/Evaluation.py +25 -31
  193. phoenix/server/api/types/EvaluationSummary.py +30 -50
  194. phoenix/server/api/types/Event.py +20 -20
  195. phoenix/server/api/types/ExampleRevisionInterface.py +14 -0
  196. phoenix/server/api/types/Experiment.py +152 -0
  197. phoenix/server/api/types/ExperimentAnnotationSummary.py +13 -0
  198. phoenix/server/api/types/ExperimentComparison.py +17 -0
  199. phoenix/server/api/types/ExperimentRun.py +119 -0
  200. phoenix/server/api/types/ExperimentRunAnnotation.py +56 -0
  201. phoenix/server/api/types/GenerativeModel.py +9 -0
  202. phoenix/server/api/types/GenerativeProvider.py +85 -0
  203. phoenix/server/api/types/Inferences.py +80 -0
  204. phoenix/server/api/types/InferencesRole.py +23 -0
  205. phoenix/server/api/types/LabelFraction.py +7 -0
  206. phoenix/server/api/types/MimeType.py +2 -2
  207. phoenix/server/api/types/Model.py +54 -54
  208. phoenix/server/api/types/PerformanceMetric.py +8 -5
  209. phoenix/server/api/types/Project.py +407 -142
  210. phoenix/server/api/types/ProjectSession.py +139 -0
  211. phoenix/server/api/types/Segments.py +4 -4
  212. phoenix/server/api/types/Span.py +221 -176
  213. phoenix/server/api/types/SpanAnnotation.py +43 -0
  214. phoenix/server/api/types/SpanIOValue.py +15 -0
  215. phoenix/server/api/types/SystemApiKey.py +9 -0
  216. phoenix/server/api/types/TemplateLanguage.py +10 -0
  217. phoenix/server/api/types/TimeSeries.py +19 -15
  218. phoenix/server/api/types/TokenUsage.py +11 -0
  219. phoenix/server/api/types/Trace.py +154 -0
  220. phoenix/server/api/types/TraceAnnotation.py +45 -0
  221. phoenix/server/api/types/UMAPPoints.py +7 -7
  222. phoenix/server/api/types/User.py +60 -0
  223. phoenix/server/api/types/UserApiKey.py +45 -0
  224. phoenix/server/api/types/UserRole.py +15 -0
  225. phoenix/server/api/types/node.py +13 -107
  226. phoenix/server/api/types/pagination.py +156 -57
  227. phoenix/server/api/utils.py +34 -0
  228. phoenix/server/app.py +864 -115
  229. phoenix/server/bearer_auth.py +163 -0
  230. phoenix/server/dml_event.py +136 -0
  231. phoenix/server/dml_event_handler.py +256 -0
  232. phoenix/server/email/__init__.py +0 -0
  233. phoenix/server/email/sender.py +97 -0
  234. phoenix/server/email/templates/__init__.py +0 -0
  235. phoenix/server/email/templates/password_reset.html +19 -0
  236. phoenix/server/email/types.py +11 -0
  237. phoenix/server/grpc_server.py +102 -0
  238. phoenix/server/jwt_store.py +505 -0
  239. phoenix/server/main.py +305 -116
  240. phoenix/server/oauth2.py +52 -0
  241. phoenix/server/openapi/__init__.py +0 -0
  242. phoenix/server/prometheus.py +111 -0
  243. phoenix/server/rate_limiters.py +188 -0
  244. phoenix/server/static/.vite/manifest.json +87 -0
  245. phoenix/server/static/assets/components-Cy9nwIvF.js +2125 -0
  246. phoenix/server/static/assets/index-BKvHIxkk.js +113 -0
  247. phoenix/server/static/assets/pages-CUi2xCVQ.js +4449 -0
  248. phoenix/server/static/assets/vendor-DvC8cT4X.js +894 -0
  249. phoenix/server/static/assets/vendor-DxkFTwjz.css +1 -0
  250. phoenix/server/static/assets/vendor-arizeai-Do1793cv.js +662 -0
  251. phoenix/server/static/assets/vendor-codemirror-BzwZPyJM.js +24 -0
  252. phoenix/server/static/assets/vendor-recharts-_Jb7JjhG.js +59 -0
  253. phoenix/server/static/assets/vendor-shiki-Cl9QBraO.js +5 -0
  254. phoenix/server/static/assets/vendor-three-DwGkEfCM.js +2998 -0
  255. phoenix/server/telemetry.py +68 -0
  256. phoenix/server/templates/index.html +82 -23
  257. phoenix/server/thread_server.py +3 -3
  258. phoenix/server/types.py +275 -0
  259. phoenix/services.py +27 -18
  260. phoenix/session/client.py +743 -68
  261. phoenix/session/data_extractor.py +31 -7
  262. phoenix/session/evaluation.py +3 -9
  263. phoenix/session/session.py +263 -219
  264. phoenix/settings.py +22 -0
  265. phoenix/trace/__init__.py +2 -22
  266. phoenix/trace/attributes.py +338 -0
  267. phoenix/trace/dsl/README.md +116 -0
  268. phoenix/trace/dsl/filter.py +663 -213
  269. phoenix/trace/dsl/helpers.py +73 -21
  270. phoenix/trace/dsl/query.py +574 -201
  271. phoenix/trace/exporter.py +24 -19
  272. phoenix/trace/fixtures.py +368 -32
  273. phoenix/trace/otel.py +71 -219
  274. phoenix/trace/projects.py +3 -2
  275. phoenix/trace/schemas.py +33 -11
  276. phoenix/trace/span_evaluations.py +21 -16
  277. phoenix/trace/span_json_decoder.py +6 -4
  278. phoenix/trace/span_json_encoder.py +2 -2
  279. phoenix/trace/trace_dataset.py +47 -32
  280. phoenix/trace/utils.py +21 -4
  281. phoenix/utilities/__init__.py +0 -26
  282. phoenix/utilities/client.py +132 -0
  283. phoenix/utilities/deprecation.py +31 -0
  284. phoenix/utilities/error_handling.py +3 -2
  285. phoenix/utilities/json.py +109 -0
  286. phoenix/utilities/logging.py +8 -0
  287. phoenix/utilities/project.py +2 -2
  288. phoenix/utilities/re.py +49 -0
  289. phoenix/utilities/span_store.py +0 -23
  290. phoenix/utilities/template_formatters.py +99 -0
  291. phoenix/version.py +1 -1
  292. arize_phoenix-3.16.0.dist-info/METADATA +0 -495
  293. arize_phoenix-3.16.0.dist-info/RECORD +0 -178
  294. phoenix/core/project.py +0 -617
  295. phoenix/core/traces.py +0 -100
  296. phoenix/experimental/evals/__init__.py +0 -73
  297. phoenix/experimental/evals/evaluators.py +0 -413
  298. phoenix/experimental/evals/functions/__init__.py +0 -4
  299. phoenix/experimental/evals/functions/classify.py +0 -453
  300. phoenix/experimental/evals/functions/executor.py +0 -353
  301. phoenix/experimental/evals/functions/generate.py +0 -138
  302. phoenix/experimental/evals/functions/processing.py +0 -76
  303. phoenix/experimental/evals/models/__init__.py +0 -14
  304. phoenix/experimental/evals/models/anthropic.py +0 -175
  305. phoenix/experimental/evals/models/base.py +0 -170
  306. phoenix/experimental/evals/models/bedrock.py +0 -221
  307. phoenix/experimental/evals/models/litellm.py +0 -134
  308. phoenix/experimental/evals/models/openai.py +0 -448
  309. phoenix/experimental/evals/models/rate_limiters.py +0 -246
  310. phoenix/experimental/evals/models/vertex.py +0 -173
  311. phoenix/experimental/evals/models/vertexai.py +0 -186
  312. phoenix/experimental/evals/retrievals.py +0 -96
  313. phoenix/experimental/evals/templates/__init__.py +0 -50
  314. phoenix/experimental/evals/templates/default_templates.py +0 -472
  315. phoenix/experimental/evals/templates/template.py +0 -195
  316. phoenix/experimental/evals/utils/__init__.py +0 -172
  317. phoenix/experimental/evals/utils/threads.py +0 -27
  318. phoenix/server/api/helpers.py +0 -11
  319. phoenix/server/api/routers/evaluation_handler.py +0 -109
  320. phoenix/server/api/routers/span_handler.py +0 -70
  321. phoenix/server/api/routers/trace_handler.py +0 -60
  322. phoenix/server/api/types/DatasetRole.py +0 -23
  323. phoenix/server/static/index.css +0 -6
  324. phoenix/server/static/index.js +0 -7447
  325. phoenix/storage/span_store/__init__.py +0 -23
  326. phoenix/storage/span_store/text_file.py +0 -85
  327. phoenix/trace/dsl/missing.py +0 -60
  328. phoenix/trace/langchain/__init__.py +0 -3
  329. phoenix/trace/langchain/instrumentor.py +0 -35
  330. phoenix/trace/llama_index/__init__.py +0 -3
  331. phoenix/trace/llama_index/callback.py +0 -102
  332. phoenix/trace/openai/__init__.py +0 -3
  333. phoenix/trace/openai/instrumentor.py +0 -30
  334. {arize_phoenix-3.16.0.dist-info → arize_phoenix-7.7.0.dist-info}/licenses/IP_NOTICE +0 -0
  335. {arize_phoenix-3.16.0.dist-info → arize_phoenix-7.7.0.dist-info}/licenses/LICENSE +0 -0
  336. /phoenix/{datasets → db/insertion}/__init__.py +0 -0
  337. /phoenix/{experimental → db/migrations}/__init__.py +0 -0
  338. /phoenix/{storage → db/migrations/data_migration_scripts}/__init__.py +0 -0
@@ -0,0 +1,488 @@
1
+ import re
2
+ from dataclasses import dataclass
3
+ from datetime import timedelta
4
+ from random import randrange
5
+ from typing import Any, Optional, TypedDict
6
+ from urllib.parse import unquote, urlparse
7
+
8
+ from authlib.common.security import generate_token
9
+ from authlib.integrations.starlette_client import OAuthError
10
+ from authlib.jose import jwt
11
+ from authlib.jose.errors import JoseError
12
+ from fastapi import APIRouter, Cookie, Depends, Path, Query, Request
13
+ from sqlalchemy import Boolean, and_, case, cast, func, insert, or_, select, update
14
+ from sqlalchemy.ext.asyncio import AsyncSession
15
+ from sqlalchemy.orm import joinedload
16
+ from sqlean.dbapi2 import IntegrityError # type: ignore[import-untyped]
17
+ from starlette.datastructures import URL, URLPath
18
+ from starlette.responses import RedirectResponse
19
+ from starlette.routing import Router
20
+ from starlette.status import HTTP_302_FOUND
21
+ from typing_extensions import Annotated, NotRequired, TypeGuard
22
+
23
+ from phoenix.auth import (
24
+ DEFAULT_OAUTH2_LOGIN_EXPIRY_MINUTES,
25
+ PHOENIX_OAUTH2_NONCE_COOKIE_NAME,
26
+ PHOENIX_OAUTH2_STATE_COOKIE_NAME,
27
+ delete_oauth2_nonce_cookie,
28
+ delete_oauth2_state_cookie,
29
+ set_access_token_cookie,
30
+ set_oauth2_nonce_cookie,
31
+ set_oauth2_state_cookie,
32
+ set_refresh_token_cookie,
33
+ )
34
+ from phoenix.config import get_env_disable_rate_limit
35
+ from phoenix.db import models
36
+ from phoenix.db.enums import UserRole
37
+ from phoenix.server.bearer_auth import create_access_and_refresh_tokens
38
+ from phoenix.server.oauth2 import OAuth2Client
39
+ from phoenix.server.rate_limiters import (
40
+ ServerRateLimiter,
41
+ fastapi_ip_rate_limiter,
42
+ fastapi_route_rate_limiter,
43
+ )
44
+ from phoenix.server.types import TokenStore
45
+
46
+ _LOWERCASE_ALPHANUMS_AND_UNDERSCORES = r"[a-z0-9_]+"
47
+
48
+ login_rate_limiter = fastapi_ip_rate_limiter(
49
+ ServerRateLimiter(
50
+ per_second_rate_limit=0.2,
51
+ enforcement_window_seconds=30,
52
+ partition_seconds=60,
53
+ active_partitions=2,
54
+ ),
55
+ )
56
+
57
+ create_tokens_rate_limiter = fastapi_route_rate_limiter(
58
+ ServerRateLimiter(
59
+ per_second_rate_limit=0.5,
60
+ enforcement_window_seconds=30,
61
+ partition_seconds=60,
62
+ active_partitions=2,
63
+ )
64
+ )
65
+
66
+ router = APIRouter(
67
+ prefix="/oauth2",
68
+ include_in_schema=False,
69
+ )
70
+
71
+ if not get_env_disable_rate_limit():
72
+ login_dependencies = [Depends(login_rate_limiter)]
73
+ create_tokens_dependencies = [Depends(create_tokens_rate_limiter)]
74
+ else:
75
+ login_dependencies = []
76
+ create_tokens_dependencies = []
77
+
78
+
79
+ @router.post("/{idp_name}/login", dependencies=login_dependencies)
80
+ async def login(
81
+ request: Request,
82
+ idp_name: Annotated[str, Path(min_length=1, pattern=_LOWERCASE_ALPHANUMS_AND_UNDERSCORES)],
83
+ return_url: Optional[str] = Query(default=None, alias="returnUrl"),
84
+ ) -> RedirectResponse:
85
+ secret = request.app.state.get_secret()
86
+ if not isinstance(
87
+ oauth2_client := request.app.state.oauth2_clients.get_client(idp_name), OAuth2Client
88
+ ):
89
+ return _redirect_to_login(request=request, error=f"Unknown IDP: {idp_name}.")
90
+ if (referer := request.headers.get("referer")) is not None:
91
+ # if the referer header is present, use it as the origin URL
92
+ parsed_url = urlparse(referer)
93
+ origin_url = _append_root_path_if_exists(
94
+ request=request, base_url=f"{parsed_url.scheme}://{parsed_url.netloc}"
95
+ )
96
+ else:
97
+ # fall back to the base url as the origin URL
98
+ origin_url = str(request.base_url)
99
+ authorization_url_data = await oauth2_client.create_authorization_url(
100
+ redirect_uri=_get_create_tokens_endpoint(
101
+ request=request, origin_url=origin_url, idp_name=idp_name
102
+ ),
103
+ state=_generate_state_for_oauth2_authorization_code_flow(
104
+ secret=secret, origin_url=origin_url, return_url=return_url
105
+ ),
106
+ )
107
+ assert isinstance(authorization_url := authorization_url_data.get("url"), str)
108
+ assert isinstance(state := authorization_url_data.get("state"), str)
109
+ assert isinstance(nonce := authorization_url_data.get("nonce"), str)
110
+ response = RedirectResponse(url=authorization_url, status_code=HTTP_302_FOUND)
111
+ response = set_oauth2_state_cookie(
112
+ response=response,
113
+ state=state,
114
+ max_age=timedelta(minutes=DEFAULT_OAUTH2_LOGIN_EXPIRY_MINUTES),
115
+ )
116
+ response = set_oauth2_nonce_cookie(
117
+ response=response,
118
+ nonce=nonce,
119
+ max_age=timedelta(minutes=DEFAULT_OAUTH2_LOGIN_EXPIRY_MINUTES),
120
+ )
121
+ return response
122
+
123
+
124
+ @router.get("/{idp_name}/tokens", dependencies=create_tokens_dependencies)
125
+ async def create_tokens(
126
+ request: Request,
127
+ idp_name: Annotated[str, Path(min_length=1, pattern=_LOWERCASE_ALPHANUMS_AND_UNDERSCORES)],
128
+ state: str = Query(),
129
+ authorization_code: str = Query(alias="code"),
130
+ stored_state: str = Cookie(alias=PHOENIX_OAUTH2_STATE_COOKIE_NAME),
131
+ stored_nonce: str = Cookie(alias=PHOENIX_OAUTH2_NONCE_COOKIE_NAME),
132
+ ) -> RedirectResponse:
133
+ secret = request.app.state.get_secret()
134
+ if state != stored_state:
135
+ return _redirect_to_login(request=request, error=_INVALID_OAUTH2_STATE_MESSAGE)
136
+ try:
137
+ payload = _parse_state_payload(secret=secret, state=state)
138
+ except JoseError:
139
+ return _redirect_to_login(request=request, error=_INVALID_OAUTH2_STATE_MESSAGE)
140
+ if (return_url := payload.get("return_url")) is not None and not _is_relative_url(
141
+ unquote(return_url)
142
+ ):
143
+ return _redirect_to_login(request=request, error="Attempting login with unsafe return URL.")
144
+ assert isinstance(access_token_expiry := request.app.state.access_token_expiry, timedelta)
145
+ assert isinstance(refresh_token_expiry := request.app.state.refresh_token_expiry, timedelta)
146
+ token_store: TokenStore = request.app.state.get_token_store()
147
+ if not isinstance(
148
+ oauth2_client := request.app.state.oauth2_clients.get_client(idp_name), OAuth2Client
149
+ ):
150
+ return _redirect_to_login(request=request, error=f"Unknown IDP: {idp_name}.")
151
+ try:
152
+ token_data = await oauth2_client.fetch_access_token(
153
+ state=state,
154
+ code=authorization_code,
155
+ redirect_uri=_get_create_tokens_endpoint(
156
+ request=request, origin_url=payload["origin_url"], idp_name=idp_name
157
+ ),
158
+ )
159
+ except OAuthError as error:
160
+ return _redirect_to_login(request=request, error=str(error))
161
+ _validate_token_data(token_data)
162
+ if "id_token" not in token_data:
163
+ return _redirect_to_login(
164
+ request=request,
165
+ error=f"OAuth2 IDP {idp_name} does not appear to support OpenID Connect.",
166
+ )
167
+ user_info = await oauth2_client.parse_id_token(token_data, nonce=stored_nonce)
168
+ user_info = _parse_user_info(user_info)
169
+ try:
170
+ async with request.app.state.db() as session:
171
+ user = await _ensure_user_exists_and_is_up_to_date(
172
+ session,
173
+ oauth2_client_id=str(oauth2_client.client_id),
174
+ user_info=user_info,
175
+ )
176
+ except EmailAlreadyInUse as error:
177
+ return _redirect_to_login(request=request, error=str(error))
178
+ access_token, refresh_token = await create_access_and_refresh_tokens(
179
+ user=user,
180
+ token_store=token_store,
181
+ access_token_expiry=access_token_expiry,
182
+ refresh_token_expiry=refresh_token_expiry,
183
+ )
184
+ redirect_path = _prepend_root_path_if_exists(request=request, path=return_url or "/")
185
+ response = RedirectResponse(
186
+ url=redirect_path,
187
+ status_code=HTTP_302_FOUND,
188
+ )
189
+ response = set_access_token_cookie(
190
+ response=response, access_token=access_token, max_age=access_token_expiry
191
+ )
192
+ response = set_refresh_token_cookie(
193
+ response=response, refresh_token=refresh_token, max_age=refresh_token_expiry
194
+ )
195
+ response = delete_oauth2_state_cookie(response)
196
+ response = delete_oauth2_nonce_cookie(response)
197
+ return response
198
+
199
+
200
+ @dataclass
201
+ class UserInfo:
202
+ idp_user_id: str
203
+ email: str
204
+ username: Optional[str]
205
+ profile_picture_url: Optional[str]
206
+
207
+
208
+ def _validate_token_data(token_data: dict[str, Any]) -> None:
209
+ """
210
+ Performs basic validations on the token data returned by the IDP.
211
+ """
212
+ assert isinstance(token_data.get("access_token"), str)
213
+ assert isinstance(token_type := token_data.get("token_type"), str)
214
+ assert token_type.lower() == "bearer"
215
+
216
+
217
+ def _parse_user_info(user_info: dict[str, Any]) -> UserInfo:
218
+ """
219
+ Parses user info from the IDP's ID token.
220
+ """
221
+ assert isinstance(subject := user_info.get("sub"), (str, int))
222
+ idp_user_id = str(subject)
223
+ assert isinstance(email := user_info.get("email"), str)
224
+ assert isinstance(username := user_info.get("name"), str) or username is None
225
+ assert (
226
+ isinstance(profile_picture_url := user_info.get("picture"), str)
227
+ or profile_picture_url is None
228
+ )
229
+ return UserInfo(
230
+ idp_user_id=idp_user_id,
231
+ email=email,
232
+ username=username,
233
+ profile_picture_url=profile_picture_url,
234
+ )
235
+
236
+
237
+ async def _ensure_user_exists_and_is_up_to_date(
238
+ session: AsyncSession, /, *, oauth2_client_id: str, user_info: UserInfo
239
+ ) -> models.User:
240
+ user = await _get_user(
241
+ session,
242
+ oauth2_client_id=oauth2_client_id,
243
+ idp_user_id=user_info.idp_user_id,
244
+ )
245
+ if user is None:
246
+ user = await _create_user(session, oauth2_client_id=oauth2_client_id, user_info=user_info)
247
+ elif user.email != user_info.email:
248
+ user = await _update_user_email(session, user_id=user.id, email=user_info.email)
249
+ return user
250
+
251
+
252
+ async def _get_user(
253
+ session: AsyncSession, /, *, oauth2_client_id: str, idp_user_id: str
254
+ ) -> Optional[models.User]:
255
+ """
256
+ Retrieves the user uniquely identified by the given OAuth2 client ID and IDP
257
+ user ID.
258
+ """
259
+ user = await session.scalar(
260
+ select(models.User)
261
+ .where(
262
+ and_(
263
+ models.User.oauth2_client_id == oauth2_client_id,
264
+ models.User.oauth2_user_id == idp_user_id,
265
+ )
266
+ )
267
+ .options(joinedload(models.User.role))
268
+ )
269
+ return user
270
+
271
+
272
+ async def _create_user(
273
+ session: AsyncSession,
274
+ /,
275
+ *,
276
+ oauth2_client_id: str,
277
+ user_info: UserInfo,
278
+ ) -> models.User:
279
+ """
280
+ Creates a new user with the user info from the IDP.
281
+ """
282
+ email_exists, username_exists = await _email_and_username_exist(
283
+ session,
284
+ email=(email := user_info.email),
285
+ username=(username := user_info.username),
286
+ )
287
+ if email_exists:
288
+ raise EmailAlreadyInUse(f"An account for {email} is already in use.")
289
+ member_role_id = (
290
+ select(models.UserRole.id)
291
+ .where(models.UserRole.name == UserRole.MEMBER.value)
292
+ .scalar_subquery()
293
+ )
294
+ user_id = await session.scalar(
295
+ insert(models.User)
296
+ .returning(models.User.id)
297
+ .values(
298
+ user_role_id=member_role_id,
299
+ oauth2_client_id=oauth2_client_id,
300
+ oauth2_user_id=user_info.idp_user_id,
301
+ username=_with_random_suffix(username) if username and username_exists else username,
302
+ email=email,
303
+ profile_picture_url=user_info.profile_picture_url,
304
+ reset_password=False,
305
+ )
306
+ )
307
+ assert isinstance(user_id, int)
308
+ user = await session.scalar(
309
+ select(models.User).where(models.User.id == user_id).options(joinedload(models.User.role))
310
+ ) # query user again for joined load
311
+ assert isinstance(user, models.User)
312
+ return user
313
+
314
+
315
+ async def _update_user_email(session: AsyncSession, /, *, user_id: int, email: str) -> models.User:
316
+ """
317
+ Updates an existing user's email.
318
+ """
319
+ try:
320
+ await session.execute(
321
+ update(models.User)
322
+ .where(models.User.id == user_id)
323
+ .values(email=email)
324
+ .options(joinedload(models.User.role))
325
+ )
326
+ except IntegrityError:
327
+ raise EmailAlreadyInUse(f"An account for {email} is already in use.")
328
+ user = await session.scalar(
329
+ select(models.User).where(models.User.id == user_id).options(joinedload(models.User.role))
330
+ ) # query user again for joined load
331
+ assert isinstance(user, models.User)
332
+ return user
333
+
334
+
335
+ async def _email_and_username_exist(
336
+ session: AsyncSession, /, *, email: str, username: Optional[str]
337
+ ) -> tuple[bool, bool]:
338
+ """
339
+ Checks whether the email and username are already in use.
340
+ """
341
+ [(email_exists, username_exists)] = (
342
+ await session.execute(
343
+ select(
344
+ cast(
345
+ func.coalesce(
346
+ func.max(case((models.User.email == email, 1), else_=0)),
347
+ 0,
348
+ ),
349
+ Boolean,
350
+ ).label("email_exists"),
351
+ cast(
352
+ func.coalesce(
353
+ func.max(case((models.User.username == username, 1), else_=0)),
354
+ 0,
355
+ ),
356
+ Boolean,
357
+ ).label("username_exists"),
358
+ ).where(or_(models.User.email == email, models.User.username == username))
359
+ )
360
+ ).all()
361
+ return email_exists, username_exists
362
+
363
+
364
+ class EmailAlreadyInUse(Exception):
365
+ pass
366
+
367
+
368
+ def _redirect_to_login(*, request: Request, error: str) -> RedirectResponse:
369
+ """
370
+ Creates a RedirectResponse to the login page to display an error message.
371
+ """
372
+ login_path = _prepend_root_path_if_exists(request=request, path="/login")
373
+ url = URL(login_path).include_query_params(error=error)
374
+ response = RedirectResponse(url=url)
375
+ response = delete_oauth2_state_cookie(response)
376
+ response = delete_oauth2_nonce_cookie(response)
377
+ return response
378
+
379
+
380
+ def _prepend_root_path_if_exists(*, request: Request, path: str) -> str:
381
+ """
382
+ If a root path is configured, prepends it to the input path.
383
+ """
384
+ if not path.startswith("/"):
385
+ raise ValueError("path must start with a forward slash")
386
+ root_path = _get_root_path(request=request)
387
+ if root_path.endswith("/"):
388
+ root_path = root_path.rstrip("/")
389
+ return root_path + path
390
+
391
+
392
+ def _append_root_path_if_exists(*, request: Request, base_url: str) -> str:
393
+ """
394
+ If a root path is configured, appends it to the input base url.
395
+ """
396
+ if not (root_path := _get_root_path(request=request)):
397
+ return base_url
398
+ return str(URLPath(root_path).make_absolute_url(base_url=base_url))
399
+
400
+
401
+ def _get_root_path(*, request: Request) -> str:
402
+ """
403
+ Gets the root path from the request.
404
+ """
405
+ return str(request.scope.get("root_path", ""))
406
+
407
+
408
+ def _get_create_tokens_endpoint(*, request: Request, origin_url: str, idp_name: str) -> str:
409
+ """
410
+ Gets the endpoint for create tokens route.
411
+ """
412
+ router: Router = request.scope["router"]
413
+ url_path = router.url_path_for(create_tokens.__name__, idp_name=idp_name)
414
+ return str(url_path.make_absolute_url(base_url=origin_url))
415
+
416
+
417
+ def _generate_state_for_oauth2_authorization_code_flow(
418
+ *, secret: str, origin_url: str, return_url: Optional[str]
419
+ ) -> str:
420
+ """
421
+ Generates a JWT whose payload contains both an OAuth2 state (generated using
422
+ the `authlib` default algorithm) and a return URL. This allows us to pass
423
+ the return URL to the OAuth2 authorization server via the `state` query
424
+ parameter and have it returned to us in the callback without needing to
425
+ maintain state.
426
+ """
427
+ header = {"alg": _JWT_ALGORITHM}
428
+ payload = _OAuth2StatePayload(
429
+ random=generate_token(),
430
+ origin_url=origin_url,
431
+ )
432
+ if return_url is not None:
433
+ payload["return_url"] = return_url
434
+ jwt_bytes: bytes = jwt.encode(header=header, payload=payload, key=secret)
435
+ return jwt_bytes.decode()
436
+
437
+
438
+ class _OAuth2StatePayload(TypedDict):
439
+ """
440
+ Represents the OAuth2 state payload.
441
+ """
442
+
443
+ random: str
444
+ origin_url: str
445
+ return_url: NotRequired[str]
446
+
447
+
448
+ def _parse_state_payload(*, secret: str, state: str) -> _OAuth2StatePayload:
449
+ """
450
+ Validates the JWT signature and parses the return URL from the OAuth2 state.
451
+ """
452
+ payload = jwt.decode(s=state, key=secret)
453
+ if _is_oauth2_state_payload(payload):
454
+ return payload
455
+ raise ValueError("Invalid OAuth2 state payload.")
456
+
457
+
458
+ def _is_relative_url(url: str) -> bool:
459
+ """
460
+ Determines whether the URL is relative.
461
+ """
462
+ return bool(_RELATIVE_URL_PATTERN.match(url))
463
+
464
+
465
+ def _with_random_suffix(string: str) -> str:
466
+ """
467
+ Appends a random suffix.
468
+ """
469
+ return f"{string}-{randrange(10_000, 100_000)}"
470
+
471
+
472
+ def _is_oauth2_state_payload(maybe_state_payload: Any) -> TypeGuard[_OAuth2StatePayload]:
473
+ """
474
+ Determines whether the given object is an OAuth2 state payload.
475
+ """
476
+
477
+ return (
478
+ isinstance(maybe_state_payload, dict)
479
+ and {"random", "origin_url"}.issubset((keys := set(maybe_state_payload.keys())))
480
+ and keys.issubset({"random", "origin_url", "return_url"})
481
+ )
482
+
483
+
484
+ _JWT_ALGORITHM = "HS256"
485
+ _INVALID_OAUTH2_STATE_MESSAGE = (
486
+ "Received invalid state parameter during OAuth2 authorization code flow for IDP {idp_name}."
487
+ )
488
+ _RELATIVE_URL_PATTERN = re.compile(r"^/($|\w)")
@@ -0,0 +1,64 @@
1
+ from fastapi import APIRouter, Depends, HTTPException, Request
2
+ from fastapi.security import APIKeyHeader
3
+ from starlette.status import HTTP_403_FORBIDDEN
4
+
5
+ from phoenix.server.bearer_auth import is_authenticated
6
+
7
+ from .datasets import router as datasets_router
8
+ from .evaluations import router as evaluations_router
9
+ from .experiment_evaluations import router as experiment_evaluations_router
10
+ from .experiment_runs import router as experiment_runs_router
11
+ from .experiments import router as experiments_router
12
+ from .spans import router as spans_router
13
+ from .traces import router as traces_router
14
+ from .utils import add_errors_to_responses
15
+
16
+ REST_API_VERSION = "1.0"
17
+
18
+
19
+ async def prevent_access_in_read_only_mode(request: Request) -> None:
20
+ """
21
+ Prevents access to the REST API in read-only mode.
22
+ """
23
+ if request.app.state.read_only:
24
+ raise HTTPException(
25
+ detail="The Phoenix REST API is disabled in read-only mode.",
26
+ status_code=HTTP_403_FORBIDDEN,
27
+ )
28
+
29
+
30
+ def create_v1_router(authentication_enabled: bool) -> APIRouter:
31
+ """
32
+ Instantiates the v1 REST API router.
33
+ """
34
+ dependencies = [Depends(prevent_access_in_read_only_mode)]
35
+ if authentication_enabled:
36
+ dependencies.append(
37
+ Depends(
38
+ APIKeyHeader(
39
+ name="Authorization",
40
+ scheme_name="Bearer",
41
+ auto_error=False,
42
+ description="Enter `Bearer` followed by a space and then the token.",
43
+ )
44
+ )
45
+ )
46
+ dependencies.append(Depends(is_authenticated))
47
+
48
+ router = APIRouter(
49
+ prefix="/v1",
50
+ dependencies=dependencies,
51
+ responses=add_errors_to_responses(
52
+ [
53
+ HTTP_403_FORBIDDEN # adds a 403 response to routes in the generated OpenAPI schema
54
+ ]
55
+ ),
56
+ )
57
+ router.include_router(datasets_router)
58
+ router.include_router(experiments_router)
59
+ router.include_router(experiment_runs_router)
60
+ router.include_router(experiment_evaluations_router)
61
+ router.include_router(traces_router)
62
+ router.include_router(spans_router)
63
+ router.include_router(evaluations_router)
64
+ return router