arize-phoenix 3.16.1__py3-none-any.whl → 7.7.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.
Potentially problematic release.
This version of arize-phoenix might be problematic. Click here for more details.
- arize_phoenix-7.7.1.dist-info/METADATA +261 -0
- arize_phoenix-7.7.1.dist-info/RECORD +345 -0
- {arize_phoenix-3.16.1.dist-info → arize_phoenix-7.7.1.dist-info}/WHEEL +1 -1
- arize_phoenix-7.7.1.dist-info/entry_points.txt +3 -0
- phoenix/__init__.py +86 -14
- phoenix/auth.py +309 -0
- phoenix/config.py +675 -45
- phoenix/core/model.py +32 -30
- phoenix/core/model_schema.py +102 -109
- phoenix/core/model_schema_adapter.py +48 -45
- phoenix/datetime_utils.py +24 -3
- phoenix/db/README.md +54 -0
- phoenix/db/__init__.py +4 -0
- phoenix/db/alembic.ini +85 -0
- phoenix/db/bulk_inserter.py +294 -0
- phoenix/db/engines.py +208 -0
- phoenix/db/enums.py +20 -0
- phoenix/db/facilitator.py +113 -0
- phoenix/db/helpers.py +159 -0
- phoenix/db/insertion/constants.py +2 -0
- phoenix/db/insertion/dataset.py +227 -0
- phoenix/db/insertion/document_annotation.py +171 -0
- phoenix/db/insertion/evaluation.py +191 -0
- phoenix/db/insertion/helpers.py +98 -0
- phoenix/db/insertion/span.py +193 -0
- phoenix/db/insertion/span_annotation.py +158 -0
- phoenix/db/insertion/trace_annotation.py +158 -0
- phoenix/db/insertion/types.py +256 -0
- phoenix/db/migrate.py +86 -0
- phoenix/db/migrations/data_migration_scripts/populate_project_sessions.py +199 -0
- phoenix/db/migrations/env.py +114 -0
- phoenix/db/migrations/script.py.mako +26 -0
- phoenix/db/migrations/versions/10460e46d750_datasets.py +317 -0
- phoenix/db/migrations/versions/3be8647b87d8_add_token_columns_to_spans_table.py +126 -0
- phoenix/db/migrations/versions/4ded9e43755f_create_project_sessions_table.py +66 -0
- phoenix/db/migrations/versions/cd164e83824f_users_and_tokens.py +157 -0
- phoenix/db/migrations/versions/cf03bd6bae1d_init.py +280 -0
- phoenix/db/models.py +807 -0
- phoenix/exceptions.py +5 -1
- phoenix/experiments/__init__.py +6 -0
- phoenix/experiments/evaluators/__init__.py +29 -0
- phoenix/experiments/evaluators/base.py +158 -0
- phoenix/experiments/evaluators/code_evaluators.py +184 -0
- phoenix/experiments/evaluators/llm_evaluators.py +473 -0
- phoenix/experiments/evaluators/utils.py +236 -0
- phoenix/experiments/functions.py +772 -0
- phoenix/experiments/tracing.py +86 -0
- phoenix/experiments/types.py +726 -0
- phoenix/experiments/utils.py +25 -0
- phoenix/inferences/__init__.py +0 -0
- phoenix/{datasets → inferences}/errors.py +6 -5
- phoenix/{datasets → inferences}/fixtures.py +49 -42
- phoenix/{datasets/dataset.py → inferences/inferences.py} +121 -105
- phoenix/{datasets → inferences}/schema.py +11 -11
- phoenix/{datasets → inferences}/validation.py +13 -14
- phoenix/logging/__init__.py +3 -0
- phoenix/logging/_config.py +90 -0
- phoenix/logging/_filter.py +6 -0
- phoenix/logging/_formatter.py +69 -0
- phoenix/metrics/__init__.py +5 -4
- phoenix/metrics/binning.py +4 -3
- phoenix/metrics/metrics.py +2 -1
- phoenix/metrics/mixins.py +7 -6
- phoenix/metrics/retrieval_metrics.py +2 -1
- phoenix/metrics/timeseries.py +5 -4
- phoenix/metrics/wrappers.py +9 -3
- phoenix/pointcloud/clustering.py +5 -5
- phoenix/pointcloud/pointcloud.py +7 -5
- phoenix/pointcloud/projectors.py +5 -6
- phoenix/pointcloud/umap_parameters.py +53 -52
- phoenix/server/api/README.md +28 -0
- phoenix/server/api/auth.py +44 -0
- phoenix/server/api/context.py +152 -9
- phoenix/server/api/dataloaders/__init__.py +91 -0
- phoenix/server/api/dataloaders/annotation_summaries.py +139 -0
- phoenix/server/api/dataloaders/average_experiment_run_latency.py +54 -0
- phoenix/server/api/dataloaders/cache/__init__.py +3 -0
- phoenix/server/api/dataloaders/cache/two_tier_cache.py +68 -0
- phoenix/server/api/dataloaders/dataset_example_revisions.py +131 -0
- phoenix/server/api/dataloaders/dataset_example_spans.py +38 -0
- phoenix/server/api/dataloaders/document_evaluation_summaries.py +144 -0
- phoenix/server/api/dataloaders/document_evaluations.py +31 -0
- phoenix/server/api/dataloaders/document_retrieval_metrics.py +89 -0
- phoenix/server/api/dataloaders/experiment_annotation_summaries.py +79 -0
- phoenix/server/api/dataloaders/experiment_error_rates.py +58 -0
- phoenix/server/api/dataloaders/experiment_run_annotations.py +36 -0
- phoenix/server/api/dataloaders/experiment_run_counts.py +49 -0
- phoenix/server/api/dataloaders/experiment_sequence_number.py +44 -0
- phoenix/server/api/dataloaders/latency_ms_quantile.py +188 -0
- phoenix/server/api/dataloaders/min_start_or_max_end_times.py +85 -0
- phoenix/server/api/dataloaders/project_by_name.py +31 -0
- phoenix/server/api/dataloaders/record_counts.py +116 -0
- phoenix/server/api/dataloaders/session_io.py +79 -0
- phoenix/server/api/dataloaders/session_num_traces.py +30 -0
- phoenix/server/api/dataloaders/session_num_traces_with_error.py +32 -0
- phoenix/server/api/dataloaders/session_token_usages.py +41 -0
- phoenix/server/api/dataloaders/session_trace_latency_ms_quantile.py +55 -0
- phoenix/server/api/dataloaders/span_annotations.py +26 -0
- phoenix/server/api/dataloaders/span_dataset_examples.py +31 -0
- phoenix/server/api/dataloaders/span_descendants.py +57 -0
- phoenix/server/api/dataloaders/span_projects.py +33 -0
- phoenix/server/api/dataloaders/token_counts.py +124 -0
- phoenix/server/api/dataloaders/trace_by_trace_ids.py +25 -0
- phoenix/server/api/dataloaders/trace_root_spans.py +32 -0
- phoenix/server/api/dataloaders/user_roles.py +30 -0
- phoenix/server/api/dataloaders/users.py +33 -0
- phoenix/server/api/exceptions.py +48 -0
- phoenix/server/api/helpers/__init__.py +12 -0
- phoenix/server/api/helpers/dataset_helpers.py +217 -0
- phoenix/server/api/helpers/experiment_run_filters.py +763 -0
- phoenix/server/api/helpers/playground_clients.py +948 -0
- phoenix/server/api/helpers/playground_registry.py +70 -0
- phoenix/server/api/helpers/playground_spans.py +455 -0
- phoenix/server/api/input_types/AddExamplesToDatasetInput.py +16 -0
- phoenix/server/api/input_types/AddSpansToDatasetInput.py +14 -0
- phoenix/server/api/input_types/ChatCompletionInput.py +38 -0
- phoenix/server/api/input_types/ChatCompletionMessageInput.py +24 -0
- phoenix/server/api/input_types/ClearProjectInput.py +15 -0
- phoenix/server/api/input_types/ClusterInput.py +2 -2
- phoenix/server/api/input_types/CreateDatasetInput.py +12 -0
- phoenix/server/api/input_types/CreateSpanAnnotationInput.py +18 -0
- phoenix/server/api/input_types/CreateTraceAnnotationInput.py +18 -0
- phoenix/server/api/input_types/DataQualityMetricInput.py +5 -2
- phoenix/server/api/input_types/DatasetExampleInput.py +14 -0
- phoenix/server/api/input_types/DatasetSort.py +17 -0
- phoenix/server/api/input_types/DatasetVersionSort.py +16 -0
- phoenix/server/api/input_types/DeleteAnnotationsInput.py +7 -0
- phoenix/server/api/input_types/DeleteDatasetExamplesInput.py +13 -0
- phoenix/server/api/input_types/DeleteDatasetInput.py +7 -0
- phoenix/server/api/input_types/DeleteExperimentsInput.py +7 -0
- phoenix/server/api/input_types/DimensionFilter.py +4 -4
- phoenix/server/api/input_types/GenerativeModelInput.py +17 -0
- phoenix/server/api/input_types/Granularity.py +1 -1
- phoenix/server/api/input_types/InvocationParameters.py +162 -0
- phoenix/server/api/input_types/PatchAnnotationInput.py +19 -0
- phoenix/server/api/input_types/PatchDatasetExamplesInput.py +35 -0
- phoenix/server/api/input_types/PatchDatasetInput.py +14 -0
- phoenix/server/api/input_types/PerformanceMetricInput.py +5 -2
- phoenix/server/api/input_types/ProjectSessionSort.py +29 -0
- phoenix/server/api/input_types/SpanAnnotationSort.py +17 -0
- phoenix/server/api/input_types/SpanSort.py +134 -69
- phoenix/server/api/input_types/TemplateOptions.py +10 -0
- phoenix/server/api/input_types/TraceAnnotationSort.py +17 -0
- phoenix/server/api/input_types/UserRoleInput.py +9 -0
- phoenix/server/api/mutations/__init__.py +28 -0
- phoenix/server/api/mutations/api_key_mutations.py +167 -0
- phoenix/server/api/mutations/chat_mutations.py +593 -0
- phoenix/server/api/mutations/dataset_mutations.py +591 -0
- phoenix/server/api/mutations/experiment_mutations.py +75 -0
- phoenix/server/api/{types/ExportEventsMutation.py → mutations/export_events_mutations.py} +21 -18
- phoenix/server/api/mutations/project_mutations.py +57 -0
- phoenix/server/api/mutations/span_annotations_mutations.py +128 -0
- phoenix/server/api/mutations/trace_annotations_mutations.py +127 -0
- phoenix/server/api/mutations/user_mutations.py +329 -0
- phoenix/server/api/openapi/__init__.py +0 -0
- phoenix/server/api/openapi/main.py +17 -0
- phoenix/server/api/openapi/schema.py +16 -0
- phoenix/server/api/queries.py +738 -0
- phoenix/server/api/routers/__init__.py +11 -0
- phoenix/server/api/routers/auth.py +284 -0
- phoenix/server/api/routers/embeddings.py +26 -0
- phoenix/server/api/routers/oauth2.py +488 -0
- phoenix/server/api/routers/v1/__init__.py +64 -0
- phoenix/server/api/routers/v1/datasets.py +1017 -0
- phoenix/server/api/routers/v1/evaluations.py +362 -0
- phoenix/server/api/routers/v1/experiment_evaluations.py +115 -0
- phoenix/server/api/routers/v1/experiment_runs.py +167 -0
- phoenix/server/api/routers/v1/experiments.py +308 -0
- phoenix/server/api/routers/v1/pydantic_compat.py +78 -0
- phoenix/server/api/routers/v1/spans.py +267 -0
- phoenix/server/api/routers/v1/traces.py +208 -0
- phoenix/server/api/routers/v1/utils.py +95 -0
- phoenix/server/api/schema.py +44 -241
- phoenix/server/api/subscriptions.py +597 -0
- phoenix/server/api/types/Annotation.py +21 -0
- phoenix/server/api/types/AnnotationSummary.py +55 -0
- phoenix/server/api/types/AnnotatorKind.py +16 -0
- phoenix/server/api/types/ApiKey.py +27 -0
- phoenix/server/api/types/AuthMethod.py +9 -0
- phoenix/server/api/types/ChatCompletionMessageRole.py +11 -0
- phoenix/server/api/types/ChatCompletionSubscriptionPayload.py +46 -0
- phoenix/server/api/types/Cluster.py +25 -24
- phoenix/server/api/types/CreateDatasetPayload.py +8 -0
- phoenix/server/api/types/DataQualityMetric.py +31 -13
- phoenix/server/api/types/Dataset.py +288 -63
- phoenix/server/api/types/DatasetExample.py +85 -0
- phoenix/server/api/types/DatasetExampleRevision.py +34 -0
- phoenix/server/api/types/DatasetVersion.py +14 -0
- phoenix/server/api/types/Dimension.py +32 -31
- phoenix/server/api/types/DocumentEvaluationSummary.py +9 -8
- phoenix/server/api/types/EmbeddingDimension.py +56 -49
- phoenix/server/api/types/Evaluation.py +25 -31
- phoenix/server/api/types/EvaluationSummary.py +30 -50
- phoenix/server/api/types/Event.py +20 -20
- phoenix/server/api/types/ExampleRevisionInterface.py +14 -0
- phoenix/server/api/types/Experiment.py +152 -0
- phoenix/server/api/types/ExperimentAnnotationSummary.py +13 -0
- phoenix/server/api/types/ExperimentComparison.py +17 -0
- phoenix/server/api/types/ExperimentRun.py +119 -0
- phoenix/server/api/types/ExperimentRunAnnotation.py +56 -0
- phoenix/server/api/types/GenerativeModel.py +9 -0
- phoenix/server/api/types/GenerativeProvider.py +85 -0
- phoenix/server/api/types/Inferences.py +80 -0
- phoenix/server/api/types/InferencesRole.py +23 -0
- phoenix/server/api/types/LabelFraction.py +7 -0
- phoenix/server/api/types/MimeType.py +2 -2
- phoenix/server/api/types/Model.py +54 -54
- phoenix/server/api/types/PerformanceMetric.py +8 -5
- phoenix/server/api/types/Project.py +407 -142
- phoenix/server/api/types/ProjectSession.py +139 -0
- phoenix/server/api/types/Segments.py +4 -4
- phoenix/server/api/types/Span.py +221 -176
- phoenix/server/api/types/SpanAnnotation.py +43 -0
- phoenix/server/api/types/SpanIOValue.py +15 -0
- phoenix/server/api/types/SystemApiKey.py +9 -0
- phoenix/server/api/types/TemplateLanguage.py +10 -0
- phoenix/server/api/types/TimeSeries.py +19 -15
- phoenix/server/api/types/TokenUsage.py +11 -0
- phoenix/server/api/types/Trace.py +154 -0
- phoenix/server/api/types/TraceAnnotation.py +45 -0
- phoenix/server/api/types/UMAPPoints.py +7 -7
- phoenix/server/api/types/User.py +60 -0
- phoenix/server/api/types/UserApiKey.py +45 -0
- phoenix/server/api/types/UserRole.py +15 -0
- phoenix/server/api/types/node.py +4 -112
- phoenix/server/api/types/pagination.py +156 -57
- phoenix/server/api/utils.py +34 -0
- phoenix/server/app.py +864 -115
- phoenix/server/bearer_auth.py +163 -0
- phoenix/server/dml_event.py +136 -0
- phoenix/server/dml_event_handler.py +256 -0
- phoenix/server/email/__init__.py +0 -0
- phoenix/server/email/sender.py +97 -0
- phoenix/server/email/templates/__init__.py +0 -0
- phoenix/server/email/templates/password_reset.html +19 -0
- phoenix/server/email/types.py +11 -0
- phoenix/server/grpc_server.py +102 -0
- phoenix/server/jwt_store.py +505 -0
- phoenix/server/main.py +305 -116
- phoenix/server/oauth2.py +52 -0
- phoenix/server/openapi/__init__.py +0 -0
- phoenix/server/prometheus.py +111 -0
- phoenix/server/rate_limiters.py +188 -0
- phoenix/server/static/.vite/manifest.json +87 -0
- phoenix/server/static/assets/components-Cy9nwIvF.js +2125 -0
- phoenix/server/static/assets/index-BKvHIxkk.js +113 -0
- phoenix/server/static/assets/pages-CUi2xCVQ.js +4449 -0
- phoenix/server/static/assets/vendor-DvC8cT4X.js +894 -0
- phoenix/server/static/assets/vendor-DxkFTwjz.css +1 -0
- phoenix/server/static/assets/vendor-arizeai-Do1793cv.js +662 -0
- phoenix/server/static/assets/vendor-codemirror-BzwZPyJM.js +24 -0
- phoenix/server/static/assets/vendor-recharts-_Jb7JjhG.js +59 -0
- phoenix/server/static/assets/vendor-shiki-Cl9QBraO.js +5 -0
- phoenix/server/static/assets/vendor-three-DwGkEfCM.js +2998 -0
- phoenix/server/telemetry.py +68 -0
- phoenix/server/templates/index.html +82 -23
- phoenix/server/thread_server.py +3 -3
- phoenix/server/types.py +275 -0
- phoenix/services.py +27 -18
- phoenix/session/client.py +743 -68
- phoenix/session/data_extractor.py +31 -7
- phoenix/session/evaluation.py +3 -9
- phoenix/session/session.py +263 -219
- phoenix/settings.py +22 -0
- phoenix/trace/__init__.py +2 -22
- phoenix/trace/attributes.py +338 -0
- phoenix/trace/dsl/README.md +116 -0
- phoenix/trace/dsl/filter.py +663 -213
- phoenix/trace/dsl/helpers.py +73 -21
- phoenix/trace/dsl/query.py +574 -201
- phoenix/trace/exporter.py +24 -19
- phoenix/trace/fixtures.py +368 -32
- phoenix/trace/otel.py +71 -219
- phoenix/trace/projects.py +3 -2
- phoenix/trace/schemas.py +33 -11
- phoenix/trace/span_evaluations.py +21 -16
- phoenix/trace/span_json_decoder.py +6 -4
- phoenix/trace/span_json_encoder.py +2 -2
- phoenix/trace/trace_dataset.py +47 -32
- phoenix/trace/utils.py +21 -4
- phoenix/utilities/__init__.py +0 -26
- phoenix/utilities/client.py +132 -0
- phoenix/utilities/deprecation.py +31 -0
- phoenix/utilities/error_handling.py +3 -2
- phoenix/utilities/json.py +109 -0
- phoenix/utilities/logging.py +8 -0
- phoenix/utilities/project.py +2 -2
- phoenix/utilities/re.py +49 -0
- phoenix/utilities/span_store.py +0 -23
- phoenix/utilities/template_formatters.py +99 -0
- phoenix/version.py +1 -1
- arize_phoenix-3.16.1.dist-info/METADATA +0 -495
- arize_phoenix-3.16.1.dist-info/RECORD +0 -178
- phoenix/core/project.py +0 -619
- phoenix/core/traces.py +0 -96
- phoenix/experimental/evals/__init__.py +0 -73
- phoenix/experimental/evals/evaluators.py +0 -413
- phoenix/experimental/evals/functions/__init__.py +0 -4
- phoenix/experimental/evals/functions/classify.py +0 -453
- phoenix/experimental/evals/functions/executor.py +0 -353
- phoenix/experimental/evals/functions/generate.py +0 -138
- phoenix/experimental/evals/functions/processing.py +0 -76
- phoenix/experimental/evals/models/__init__.py +0 -14
- phoenix/experimental/evals/models/anthropic.py +0 -175
- phoenix/experimental/evals/models/base.py +0 -170
- phoenix/experimental/evals/models/bedrock.py +0 -221
- phoenix/experimental/evals/models/litellm.py +0 -134
- phoenix/experimental/evals/models/openai.py +0 -448
- phoenix/experimental/evals/models/rate_limiters.py +0 -246
- phoenix/experimental/evals/models/vertex.py +0 -173
- phoenix/experimental/evals/models/vertexai.py +0 -186
- phoenix/experimental/evals/retrievals.py +0 -96
- phoenix/experimental/evals/templates/__init__.py +0 -50
- phoenix/experimental/evals/templates/default_templates.py +0 -472
- phoenix/experimental/evals/templates/template.py +0 -195
- phoenix/experimental/evals/utils/__init__.py +0 -172
- phoenix/experimental/evals/utils/threads.py +0 -27
- phoenix/server/api/helpers.py +0 -11
- phoenix/server/api/routers/evaluation_handler.py +0 -109
- phoenix/server/api/routers/span_handler.py +0 -70
- phoenix/server/api/routers/trace_handler.py +0 -60
- phoenix/server/api/types/DatasetRole.py +0 -23
- phoenix/server/static/index.css +0 -6
- phoenix/server/static/index.js +0 -7447
- phoenix/storage/span_store/__init__.py +0 -23
- phoenix/storage/span_store/text_file.py +0 -85
- phoenix/trace/dsl/missing.py +0 -60
- phoenix/trace/langchain/__init__.py +0 -3
- phoenix/trace/langchain/instrumentor.py +0 -35
- phoenix/trace/llama_index/__init__.py +0 -3
- phoenix/trace/llama_index/callback.py +0 -102
- phoenix/trace/openai/__init__.py +0 -3
- phoenix/trace/openai/instrumentor.py +0 -30
- {arize_phoenix-3.16.1.dist-info → arize_phoenix-7.7.1.dist-info}/licenses/IP_NOTICE +0 -0
- {arize_phoenix-3.16.1.dist-info → arize_phoenix-7.7.1.dist-info}/licenses/LICENSE +0 -0
- /phoenix/{datasets → db/insertion}/__init__.py +0 -0
- /phoenix/{experimental → db/migrations}/__init__.py +0 -0
- /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
|