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
phoenix/server/main.py CHANGED
@@ -7,7 +7,7 @@ from pathlib import Path
7
7
  from ssl import CERT_REQUIRED
8
8
  from threading import Thread
9
9
  from time import sleep, time
10
- from typing import Optional
10
+ from typing import Awaitable, Callable, Optional
11
11
  from urllib.parse import urljoin
12
12
 
13
13
  from jinja2 import BaseLoader, Environment
@@ -25,16 +25,19 @@ from phoenix.config import (
25
25
  get_env_db_logging_level,
26
26
  get_env_disable_migrations,
27
27
  get_env_enable_prometheus,
28
+ get_env_fullstory_org,
28
29
  get_env_grpc_port,
29
30
  get_env_host,
30
31
  get_env_host_root_path,
31
32
  get_env_log_migrations,
32
33
  get_env_logging_level,
33
34
  get_env_logging_mode,
35
+ get_env_management_url,
34
36
  get_env_oauth2_settings,
35
37
  get_env_password_reset_token_expiry,
36
38
  get_env_port,
37
39
  get_env_refresh_token_expiry,
40
+ get_env_scarf_sh_pixel_id,
38
41
  get_env_smtp_hostname,
39
42
  get_env_smtp_mail_from,
40
43
  get_env_smtp_password,
@@ -90,15 +93,15 @@ _WELCOME_MESSAGE = Environment(loader=BaseLoader()).from_string("""
90
93
  ██║ ██║ ██║╚██████╔╝███████╗██║ ╚████║██║██╔╝ ██╗
91
94
  ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝╚═╝╚═╝ ╚═╝ v{{ version }}
92
95
 
96
+ | ⭐️⭐️⭐️ Support Open Source ⭐️⭐️⭐️
97
+ | ⭐️⭐️⭐️ Star on GitHub! ⭐️⭐️⭐️
98
+ | https://github.com/Arize-ai/phoenix
93
99
  |
94
100
  | 🌎 Join our Community 🌎
95
101
  | https://arize-ai.slack.com/join/shared_invite/zt-2w57bhem8-hq24MB6u7yE_ZF_ilOYSBw#/shared-invite/email
96
102
  |
97
- | ⭐️ Leave us a Star ⭐️
98
- | https://github.com/Arize-ai/phoenix
99
- |
100
103
  | 📚 Documentation 📚
101
- | https://docs.arize.com/phoenix
104
+ | https://arize.com/docs/phoenix
102
105
  |
103
106
  | 🚀 Phoenix Server 🚀
104
107
  | Phoenix UI: {{ ui_path }}
@@ -369,13 +372,17 @@ def main() -> None:
369
372
  start_prometheus()
370
373
 
371
374
  engine = create_engine_and_run_migrations(db_connection_str)
372
- instrumentation_cleanups = instrument_engine_if_enabled(engine)
375
+ shutdown_callbacks: list[Callable[[], None | Awaitable[None]]] = []
376
+ shutdown_callbacks.extend(instrument_engine_if_enabled(engine))
377
+ # Ensure engine is disposed on shutdown to properly close database connections
378
+ shutdown_callbacks.append(engine.dispose)
373
379
  factory = DbSessionFactory(db=_db(engine), dialect=engine.dialect.name)
374
380
  corpus_model = (
375
381
  None if corpus_inferences is None else create_model_from_inferences(corpus_inferences)
376
382
  )
377
383
 
378
384
  allowed_origins = get_env_allowed_origins()
385
+ management_url = get_env_management_url()
379
386
 
380
387
  # Get TLS configuration
381
388
  tls_enabled_for_http = get_env_tls_enabled_for_http()
@@ -386,12 +393,15 @@ def main() -> None:
386
393
  # Print information about the server
387
394
  http_scheme = "https" if tls_enabled_for_http else "http"
388
395
  grpc_scheme = "https" if tls_enabled_for_grpc else "http"
396
+ # Use localhost for display when host is the loopback address to make URLs clickable
397
+ display_host = "localhost" if host in ("0.0.0.0", "::") else host
389
398
  root_path = urljoin(f"{http_scheme}://{host}:{port}", host_root_path)
399
+ display_root_path = urljoin(f"{http_scheme}://{display_host}:{port}", host_root_path)
390
400
  msg = _WELCOME_MESSAGE.render(
391
401
  version=phoenix_version,
392
- ui_path=root_path,
393
- grpc_path=f"{grpc_scheme}://{host}:{get_env_grpc_port()}",
394
- http_path=urljoin(root_path, "v1/traces"),
402
+ ui_path=display_root_path,
403
+ grpc_path=f"{grpc_scheme}://{display_host}:{get_env_grpc_port()}",
404
+ http_path=urljoin(display_root_path, "v1/traces"),
395
405
  storage=get_printable_db_url(db_connection_str),
396
406
  schema=get_env_database_schema(),
397
407
  auth_enabled=auth_settings.enable_auth,
@@ -442,7 +452,7 @@ def main() -> None:
442
452
  initial_spans=fixture_spans,
443
453
  initial_evaluations=fixture_evals,
444
454
  startup_callbacks=[lambda: print(msg)],
445
- shutdown_callbacks=instrumentation_cleanups,
455
+ shutdown_callbacks=shutdown_callbacks,
446
456
  secret=auth_settings.phoenix_secret,
447
457
  password_reset_token_expiry=get_env_password_reset_token_expiry(),
448
458
  access_token_expiry=get_env_access_token_expiry(),
@@ -450,7 +460,9 @@ def main() -> None:
450
460
  scaffolder_config=scaffolder_config,
451
461
  email_sender=email_sender,
452
462
  oauth2_client_configs=get_env_oauth2_settings(),
463
+ ldap_config=auth_settings.ldap_config,
453
464
  allowed_origins=allowed_origins,
465
+ management_url=management_url,
454
466
  )
455
467
 
456
468
  # Configure server with TLS if enabled
@@ -459,6 +471,7 @@ def main() -> None:
459
471
  host=host, # type: ignore[arg-type]
460
472
  port=port,
461
473
  root_path=host_root_path,
474
+ log_level=Settings.logging_level,
462
475
  )
463
476
 
464
477
  if tls_enabled_for_http:
@@ -483,11 +496,14 @@ def main() -> None:
483
496
 
484
497
 
485
498
  def initialize_settings() -> None:
499
+ """Initialize the settings from environment variables."""
486
500
  Settings.logging_mode = get_env_logging_mode()
487
501
  Settings.logging_level = get_env_logging_level()
488
502
  Settings.db_logging_level = get_env_db_logging_level()
489
503
  Settings.log_migrations = get_env_log_migrations()
490
504
  Settings.disable_migrations = get_env_disable_migrations()
505
+ Settings.fullstory_org = get_env_fullstory_org()
506
+ Settings.scarf_sh_pixel_id = get_env_scarf_sh_pixel_id()
491
507
 
492
508
 
493
509
  if __name__ == "__main__":
phoenix/server/oauth2.py CHANGED
@@ -1,12 +1,16 @@
1
- from collections.abc import Iterable
2
- from typing import Any, Iterator, Optional
1
+ import logging
2
+ from collections.abc import Iterable, Mapping
3
+ from typing import Any, Iterator, Optional, get_args
3
4
 
5
+ import jmespath
4
6
  from authlib.integrations.base_client import BaseApp
5
7
  from authlib.integrations.base_client.async_app import AsyncOAuth2Mixin
6
8
  from authlib.integrations.base_client.async_openid import AsyncOpenIDMixin
7
9
  from authlib.integrations.httpx_client import AsyncOAuth2Client as AsyncHttpxOAuth2Client
8
10
 
9
- from phoenix.config import OAuth2ClientConfig
11
+ from phoenix.config import AssignableUserRoleName, OAuth2ClientConfig
12
+
13
+ logger = logging.getLogger(__name__)
10
14
 
11
15
 
12
16
  class OAuth2Client(AsyncOAuth2Mixin, AsyncOpenIDMixin, BaseApp): # type:ignore[misc]
@@ -25,13 +29,78 @@ class OAuth2Client(AsyncOAuth2Mixin, AsyncOpenIDMixin, BaseApp): # type:ignore[
25
29
  display_name: str,
26
30
  allow_sign_up: bool,
27
31
  auto_login: bool,
32
+ use_pkce: bool = False,
33
+ groups_attribute_path: Optional[str] = None,
34
+ allowed_groups: Optional[list[str]] = None,
35
+ role_attribute_path: Optional[str] = None,
36
+ role_mapping: Optional[Mapping[str, AssignableUserRoleName]] = None,
37
+ role_attribute_strict: bool = False,
28
38
  **kwargs: Any,
29
39
  ) -> None:
30
40
  self._display_name = display_name
31
41
  self._allow_sign_up = allow_sign_up
32
42
  self._auto_login = auto_login
43
+ self._use_pkce = use_pkce
44
+
45
+ self._groups_attribute_path = (
46
+ groups_attribute_path.strip()
47
+ if groups_attribute_path and groups_attribute_path.strip()
48
+ else None
49
+ )
50
+
51
+ if allowed_groups:
52
+ self._allowed_groups = {g for g in allowed_groups if g.strip()}
53
+ else:
54
+ self._allowed_groups = set()
55
+
56
+ if self._allowed_groups and not self._groups_attribute_path:
57
+ raise ValueError(
58
+ "groups_attribute_path must be specified when allowed_groups is configured. "
59
+ "Group-based access control requires both parameters to be set."
60
+ )
61
+
62
+ if self._groups_attribute_path and not self._allowed_groups:
63
+ raise ValueError(
64
+ "allowed_groups must be specified when groups_attribute_path is configured. "
65
+ "Group-based access control requires both parameters to be set. "
66
+ "If you don't need group-based access control, remove groups_attribute_path."
67
+ )
68
+
69
+ self._compiled_groups_path = self._compile_jmespath_expression(
70
+ self._groups_attribute_path, "GROUPS_ATTRIBUTE_PATH"
71
+ )
72
+
73
+ # Role mapping configuration
74
+ self._role_attribute_path = (
75
+ role_attribute_path.strip()
76
+ if role_attribute_path and role_attribute_path.strip()
77
+ else None
78
+ )
79
+ self._role_mapping = role_mapping
80
+ self._role_attribute_strict = role_attribute_strict
81
+ self._compiled_role_path = self._compile_jmespath_expression(
82
+ self._role_attribute_path, "ROLE_ATTRIBUTE_PATH"
83
+ )
84
+
33
85
  super().__init__(framework=None, *args, **kwargs)
34
- self._allow_sign_up = allow_sign_up
86
+
87
+ @staticmethod
88
+ def _compile_jmespath_expression(
89
+ path: Optional[str], attribute_name: str
90
+ ) -> Optional[jmespath.parser.ParsedResult]:
91
+ """Validate and compile JMESPath expression at startup for fail-fast behavior."""
92
+ if not path:
93
+ return None
94
+
95
+ try:
96
+ return jmespath.compile(path)
97
+ except (jmespath.exceptions.JMESPathError, jmespath.exceptions.ParseError) as e:
98
+ raise ValueError(
99
+ f"Invalid JMESPath expression in {attribute_name}: '{path}'. Error: {e}. "
100
+ "Hint: Claim keys with special characters (colons, dots, slashes, hyphens) "
101
+ "must be enclosed in double quotes. "
102
+ "Examples: '\"cognito:groups\"', '\"https://myapp.com/groups\"'"
103
+ ) from e
35
104
 
36
105
  @property
37
106
  def allow_sign_up(self) -> bool:
@@ -45,6 +114,240 @@ class OAuth2Client(AsyncOAuth2Mixin, AsyncOpenIDMixin, BaseApp): # type:ignore[
45
114
  def display_name(self) -> str:
46
115
  return self._display_name
47
116
 
117
+ @property
118
+ def use_pkce(self) -> bool:
119
+ return self._use_pkce
120
+
121
+ def has_sufficient_claims(self, claims: dict[str, Any]) -> bool:
122
+ """
123
+ Check if the ID token contains all application-required claims.
124
+
125
+ OIDC Core §2 mandates that ID tokens contain authentication claims (iss, sub, aud,
126
+ exp, iat), but user profile claims (email, name, groups, roles) are optional and may
127
+ only be available via UserInfo endpoint (§5.4, §5.5). This method determines if we
128
+ need to call UserInfo.
129
+
130
+ Application-required claims:
131
+ - email: Required for user identification and account creation
132
+ - groups: Required if group-based access control is configured
133
+ - roles: Required if role mapping is configured
134
+
135
+ If any required claim is missing, returns False to trigger UserInfo endpoint call.
136
+
137
+ Args:
138
+ claims: Claims from ID token (OIDC Core §3.1.3.3)
139
+
140
+ Returns:
141
+ True if all application-required claims are present (UserInfo not needed)
142
+ False if additional claims must be fetched from UserInfo endpoint
143
+ """
144
+ # Check for email claim (required by application)
145
+ email = claims.get("email")
146
+ if not email or not isinstance(email, str) or not email.strip():
147
+ # Email missing or invalid, need UserInfo
148
+ return False
149
+
150
+ # Check for group claims if group-based access control is configured
151
+ if self._compiled_groups_path:
152
+ groups = self._extract_groups_from_claims(claims)
153
+ if len(groups) == 0:
154
+ # Groups required but not present, need UserInfo
155
+ return False
156
+
157
+ # Check for role claims if role mapping is configured
158
+ if self._compiled_role_path:
159
+ # Check if role claim EXISTS (not whether it maps successfully)
160
+ # Optimization: If the claim exists but doesn't map, UserInfo won't help
161
+ result = self._compiled_role_path.search(claims)
162
+ role_value = self._normalize_to_single_string(result)
163
+ if not role_value:
164
+ # Role claim missing - UserInfo might have a mappable role
165
+ # (could upgrade from default VIEWER to ADMIN/MEMBER)
166
+ return False
167
+ # Role exists - UserInfo won't help even if role doesn't map
168
+ # (UserInfo will have the same unmappable role)
169
+
170
+ # All required claims present
171
+ return True
172
+
173
+ def validate_access(self, user_claims: dict[str, Any]) -> None:
174
+ """
175
+ Validate that the user has access based on configured claim-based access control.
176
+
177
+ Currently supports group-based access control. In the future, this may be extended
178
+ to support organization-based or other claim-based authorization mechanisms.
179
+
180
+ Args:
181
+ user_claims: Claims from the OIDC ID token (OIDC Core §3.1.3.3) or userinfo
182
+ endpoint (OIDC Core §5.3). Custom claims for groups/roles are extracted
183
+ per OIDC Core §5.1.2 (Additional Claims).
184
+
185
+ Raises:
186
+ PermissionError: If user doesn't meet the access requirements
187
+ """
188
+ if not self._allowed_groups or not self._groups_attribute_path:
189
+ return
190
+
191
+ user_groups = self._extract_groups_from_claims(user_claims)
192
+
193
+ if not any(group in self._allowed_groups for group in user_groups):
194
+ raise PermissionError(
195
+ "Access denied. Your account does not belong to any authorized groups."
196
+ )
197
+
198
+ def _extract_groups_from_claims(self, claims: dict[str, Any]) -> list[str]:
199
+ """Extract group values from claims using the configured JMESPath expression."""
200
+ if not self._compiled_groups_path:
201
+ return []
202
+
203
+ result = self._compiled_groups_path.search(claims)
204
+ return self._normalize_to_string_list(result)
205
+
206
+ @staticmethod
207
+ def _normalize_to_string_list(value: Any) -> list[str]:
208
+ """
209
+ Normalize a JMESPath result to a list of strings.
210
+
211
+ Handles common OIDC claim formats: single values, lists, and scalar types.
212
+ Non-scalar items (dicts, nested lists) are silently skipped.
213
+
214
+ Args:
215
+ value: Result from JMESPath query
216
+
217
+ Returns:
218
+ List of string values, or empty list if value cannot be normalized
219
+ """
220
+ if value is None:
221
+ return []
222
+
223
+ if isinstance(value, str):
224
+ return [value]
225
+
226
+ if isinstance(value, (int, float, bool)):
227
+ return [str(value)]
228
+
229
+ if isinstance(value, list):
230
+ return [
231
+ str(item) if isinstance(item, (int, float, bool)) else item
232
+ for item in value
233
+ if isinstance(item, (str, int, float, bool))
234
+ ]
235
+
236
+ return []
237
+
238
+ def extract_and_map_role(self, user_claims: dict[str, Any]) -> Optional[AssignableUserRoleName]:
239
+ """
240
+ Extract and map user role from OIDC claims.
241
+
242
+ This method extracts the role claim using the configured JMESPath expression,
243
+ optionally applies role mapping to translate IDP role values to Phoenix roles,
244
+ and handles missing/invalid roles based on the strict mode setting.
245
+
246
+ Role Mapping Flow:
247
+ 1. Extract role claim using ROLE_ATTRIBUTE_PATH (JMESPath)
248
+ - Supports simple paths: "role", "user.org.role"
249
+ - Supports conditional logic: "contains(groups[*], 'admin') && 'ADMIN' || 'VIEWER'"
250
+ 2. Apply ROLE_MAPPING (if configured) to translate IDP role → Phoenix role
251
+ - If ROLE_MAPPING not set, use extracted value directly if valid (ADMIN/MEMBER/VIEWER)
252
+ - This allows JMESPath expressions to return Phoenix roles directly
253
+ 3. Validate Phoenix role (ADMIN, MEMBER, VIEWER - SYSTEM excluded for OAuth)
254
+ 4. Handle missing/invalid roles:
255
+ - strict=True: Raise PermissionError (deny access)
256
+ - strict=False: Return "VIEWER" (default, least privilege)
257
+
258
+ IMPORTANT: Backward Compatibility
259
+ - If ROLE_ATTRIBUTE_PATH is NOT configured, returns None
260
+ - This preserves existing users' roles (no unwanted downgrades)
261
+ - Caller should only apply "VIEWER" default for NEW users
262
+
263
+ Args:
264
+ user_claims: Claims from the OIDC ID token or userinfo endpoint
265
+
266
+ Returns:
267
+ Phoenix role name (ADMIN, MEMBER, or VIEWER), or None if role attribute
268
+ path is not configured (to preserve existing user roles)
269
+
270
+ Raises:
271
+ PermissionError: If strict mode is enabled and role cannot be determined
272
+ """
273
+ # If no role mapping configured, return None to preserve existing user roles
274
+ if not self._compiled_role_path:
275
+ return None
276
+
277
+ # Extract role from claims
278
+ result = self._compiled_role_path.search(user_claims)
279
+ role_value = self._normalize_to_single_string(result)
280
+
281
+ # If role claim is missing or empty
282
+ if not role_value:
283
+ if self._role_attribute_strict:
284
+ raise PermissionError(
285
+ f"Access denied: Role claim not found in user claims. "
286
+ f"Role attribute path '{self._role_attribute_path}' is configured with "
287
+ f"strict mode enabled."
288
+ )
289
+ return "VIEWER" # Non-strict: default to least privilege
290
+
291
+ # Apply role mapping if configured
292
+ if self._role_mapping:
293
+ mapped_role = self._role_mapping.get(role_value)
294
+ if not mapped_role:
295
+ # Role value doesn't match any mapping
296
+ if self._role_attribute_strict:
297
+ raise PermissionError(
298
+ f"Access denied: Role '{role_value}' is not mapped to a Phoenix role. "
299
+ f"Role mapping is configured with strict mode enabled."
300
+ )
301
+ return "VIEWER" # Non-strict: default to least privilege
302
+ return mapped_role
303
+
304
+ # No role mapping configured, but role path exists
305
+ # Try to use the raw role value directly if it's a valid Phoenix role
306
+ # Note: SYSTEM is excluded from valid roles for OIDC (validated at config parsing)
307
+ role_upper = role_value.upper()
308
+ if role_upper in get_args(AssignableUserRoleName):
309
+ return role_upper # type: ignore[return-value]
310
+
311
+ # Role value is not a valid Phoenix role
312
+ if self._role_attribute_strict:
313
+ raise PermissionError(
314
+ f"Access denied: Role '{role_value}' is not a valid Phoenix role "
315
+ f"(expected ADMIN, MEMBER, or VIEWER). Strict mode is enabled."
316
+ )
317
+ return "VIEWER" # Non-strict: default to least privilege
318
+
319
+ @staticmethod
320
+ def _normalize_to_single_string(value: Any) -> Optional[str]:
321
+ """
322
+ Normalize a JMESPath result to a single string value.
323
+
324
+ Handles common OIDC claim formats for single-value fields like role.
325
+ If the result is a list, takes the first element.
326
+
327
+ Args:
328
+ value: Result from JMESPath query
329
+
330
+ Returns:
331
+ String value or None if value cannot be normalized
332
+ """
333
+ if value is None:
334
+ return None
335
+
336
+ if isinstance(value, str):
337
+ return value.strip() or None
338
+
339
+ if isinstance(value, (int, float, bool)):
340
+ return str(value)
341
+
342
+ if isinstance(value, list) and len(value) > 0:
343
+ first = value[0]
344
+ if isinstance(first, str):
345
+ return first.strip() or None
346
+ if isinstance(first, (int, float, bool)):
347
+ return str(first)
348
+
349
+ return None
350
+
48
351
 
49
352
  class OAuth2Clients:
50
353
  def __init__(self) -> None:
@@ -67,26 +370,41 @@ class OAuth2Clients:
67
370
  def add_client(self, config: OAuth2ClientConfig) -> None:
68
371
  if (idp_name := config.idp_name) in self._clients:
69
372
  raise ValueError(f"oauth client already registered: {idp_name}")
373
+ # RFC 6749 §3.3: scope parameter (space-delimited list of scopes)
374
+ client_kwargs = {"scope": config.scopes}
375
+
376
+ if config.token_endpoint_auth_method:
377
+ # OIDC Core §9: Client authentication method at token endpoint
378
+ client_kwargs["token_endpoint_auth_method"] = config.token_endpoint_auth_method
379
+ if config.use_pkce:
380
+ # Always use S256 for PKCE (RFC 7636 §4.2: SHA-256 code challenge method)
381
+ client_kwargs["code_challenge_method"] = "S256"
382
+
70
383
  client = OAuth2Client(
71
384
  name=config.idp_name,
72
- client_id=config.client_id,
73
- client_secret=config.client_secret,
74
- server_metadata_url=config.oidc_config_url,
75
- client_kwargs={"scope": "openid email profile"},
385
+ client_id=config.client_id, # RFC 6749 §2.2
386
+ client_secret=config.client_secret, # RFC 6749 §2.3.1
387
+ server_metadata_url=config.oidc_config_url, # OIDC Discovery §4
388
+ client_kwargs=client_kwargs,
76
389
  display_name=config.idp_display_name,
77
390
  allow_sign_up=config.allow_sign_up,
78
391
  auto_login=config.auto_login,
392
+ use_pkce=config.use_pkce,
393
+ groups_attribute_path=config.groups_attribute_path,
394
+ allowed_groups=config.allowed_groups,
395
+ role_attribute_path=config.role_attribute_path,
396
+ role_mapping=config.role_mapping,
397
+ role_attribute_strict=config.role_attribute_strict,
79
398
  )
399
+
80
400
  if config.auto_login:
81
401
  if self._auto_login_client:
82
402
  raise ValueError("only one auto-login client is allowed")
83
403
  self._auto_login_client = client
84
404
  self._clients[config.idp_name] = client
85
405
 
86
- def get_client(self, idp_name: str) -> OAuth2Client:
87
- if (client := self._clients.get(idp_name)) is None:
88
- raise ValueError(f"unknown or unregistered OAuth2 client: {idp_name}")
89
- return client
406
+ def get_client(self, idp_name: str) -> Optional[OAuth2Client]:
407
+ return self._clients.get(idp_name)
90
408
 
91
409
  @classmethod
92
410
  def from_configs(cls, configs: Iterable[OAuth2ClientConfig]) -> "OAuth2Clients":
@@ -9,6 +9,7 @@ import psutil
9
9
  from prometheus_client import (
10
10
  Counter,
11
11
  Gauge,
12
+ Histogram,
12
13
  Summary,
13
14
  start_http_server,
14
15
  )
@@ -36,14 +37,19 @@ CPU_METRIC = Gauge(
36
37
  name="cpu_usage_percent",
37
38
  documentation="CPU usage percent",
38
39
  )
39
- BULK_LOADER_INSERTION_TIME = Summary(
40
- name="bulk_loader_insertion_time_seconds_summary",
41
- documentation="Summary of database insertion time (seconds)",
40
+ BULK_LOADER_SPAN_INSERTION_TIME = Histogram(
41
+ namespace="phoenix",
42
+ name="bulk_loader_span_insertion_time_seconds",
43
+ documentation="Histogram of span database insertion time (seconds)",
44
+ buckets=[0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0, 180.0], # 500ms to 3min
42
45
  )
43
- BULK_LOADER_SPAN_INSERTIONS = Counter(
44
- name="bulk_loader_span_insertions_total",
45
- documentation="Total count of bulk loader span insertions",
46
+
47
+ BULK_LOADER_SPAN_EXCEPTIONS = Counter(
48
+ namespace="phoenix",
49
+ name="bulk_loader_span_exceptions_total",
50
+ documentation="Total count of span insertion exceptions",
46
51
  )
52
+
47
53
  BULK_LOADER_EVALUATION_INSERTIONS = Counter(
48
54
  name="bulk_loader_evaluation_insertions_total",
49
55
  documentation="Total count of bulk loader evaluation insertions",
@@ -73,6 +79,59 @@ JWT_STORE_API_KEYS_ACTIVE = Gauge(
73
79
  documentation="Current number of API keys in the JWT store",
74
80
  )
75
81
 
82
+ DB_DISK_USAGE_BYTES = Gauge(
83
+ name="database_disk_usage_bytes",
84
+ documentation="Current database disk usage in bytes",
85
+ )
86
+ DB_DISK_USAGE_RATIO = Gauge(
87
+ name="database_disk_usage_ratio",
88
+ documentation="Current database disk usage as ratio of allocated capacity (0-1)",
89
+ )
90
+ DB_INSERTIONS_BLOCKED = Gauge(
91
+ name="database_insertions_blocked",
92
+ documentation="Whether database insertions are currently blocked due to disk usage "
93
+ "(1 = blocked, 0 = not blocked)",
94
+ )
95
+ DB_DISK_USAGE_WARNING_EMAILS_SENT = Counter(
96
+ name="database_disk_usage_warning_emails_sent_total",
97
+ documentation="Total count of database disk usage warning emails sent",
98
+ )
99
+ DB_DISK_USAGE_WARNING_EMAIL_ERRORS = Counter(
100
+ name="database_disk_usage_warning_email_errors_total",
101
+ documentation="Total count of database disk usage warning email send errors",
102
+ )
103
+
104
+ SPAN_QUEUE_REJECTIONS = Counter(
105
+ namespace="phoenix",
106
+ name="span_queue_rejections_total",
107
+ documentation="Total count of requests rejected due to span queue being full",
108
+ )
109
+
110
+ SPAN_QUEUE_SIZE = Gauge(
111
+ namespace="phoenix",
112
+ name="span_queue_size",
113
+ documentation="Current number of spans in the processing queue",
114
+ )
115
+
116
+ BULK_LOADER_LAST_ACTIVITY = Gauge(
117
+ namespace="phoenix",
118
+ name="bulk_loader_last_activity_timestamp_seconds",
119
+ documentation="Unix timestamp when bulk loader last processed items",
120
+ )
121
+
122
+ RETENTION_SWEEPER_LAST_RUN = Gauge(
123
+ namespace="phoenix",
124
+ name="retention_sweeper_last_run_seconds",
125
+ documentation="Unix timestamp (seconds since epoch) of the last retention sweeper run",
126
+ )
127
+
128
+ RETENTION_POLICY_EXECUTIONS = Counter(
129
+ namespace="phoenix",
130
+ name="retention_policy_executions_total",
131
+ documentation="Total number of retention policy executions",
132
+ labelnames=["status"],
133
+ )
134
+
76
135
 
77
136
  class PrometheusMiddleware(BaseHTTPMiddleware):
78
137
  async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
@@ -210,6 +269,7 @@ def estimate_cpu_usage_percent() -> Optional[float]:
210
269
  except Exception:
211
270
  pass
212
271
  return psutil.cpu_percent(interval=None)
272
+ return None
213
273
 
214
274
 
215
275
  @lru_cache(maxsize=1)
@@ -3,12 +3,7 @@ import time
3
3
  from collections import defaultdict
4
4
  from collections.abc import Callable, Coroutine
5
5
  from functools import partial
6
- from typing import (
7
- Any,
8
- Optional,
9
- Pattern, # import from re module when we drop support for 3.8
10
- Union,
11
- )
6
+ from typing import Any, Iterable
12
7
 
13
8
  from fastapi import HTTPException, Request
14
9
 
@@ -129,7 +124,7 @@ class ServerRateLimiter:
129
124
  def _fetch_token_bucket(self, key: str, request_time: float) -> TokenBucket:
130
125
  current_partition_index = self._current_partition_index(request_time)
131
126
  active_indices = self._active_partition_indices(current_partition_index)
132
- bucket: Optional[TokenBucket] = None
127
+ bucket: TokenBucket | None = None
133
128
  for ii in active_indices:
134
129
  partition = self.cache_partitions[ii]
135
130
  if key in partition:
@@ -153,7 +148,7 @@ class ServerRateLimiter:
153
148
 
154
149
 
155
150
  def fastapi_ip_rate_limiter(
156
- rate_limiter: ServerRateLimiter, paths: Optional[list[Union[str, Pattern[str]]]] = None
151
+ rate_limiter: ServerRateLimiter, paths: Iterable[str | re.Pattern[str]] | None = None
157
152
  ) -> Callable[[Request], Coroutine[Any, Any, Request]]:
158
153
  async def dependency(request: Request) -> Request:
159
154
  if paths is None or any(path_match(request.url.path, path) for path in paths):
@@ -182,7 +177,7 @@ def fastapi_route_rate_limiter(
182
177
  return dependency
183
178
 
184
179
 
185
- def path_match(path: str, match_pattern: Union[str, Pattern[str]]) -> bool:
180
+ def path_match(path: str, match_pattern: str | re.Pattern[str]) -> bool:
186
181
  if isinstance(match_pattern, re.Pattern):
187
182
  return bool(match_pattern.match(path))
188
183
  return path == match_pattern