arize-phoenix 12.2.0__py3-none-any.whl → 12.4.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 (46) hide show
  1. {arize_phoenix-12.2.0.dist-info → arize_phoenix-12.4.0.dist-info}/METADATA +2 -1
  2. {arize_phoenix-12.2.0.dist-info → arize_phoenix-12.4.0.dist-info}/RECORD +45 -44
  3. phoenix/auth.py +19 -0
  4. phoenix/config.py +302 -53
  5. phoenix/db/README.md +546 -28
  6. phoenix/server/api/auth_messages.py +46 -0
  7. phoenix/server/api/routers/auth.py +21 -30
  8. phoenix/server/api/routers/oauth2.py +255 -46
  9. phoenix/server/api/routers/v1/__init__.py +2 -3
  10. phoenix/server/api/routers/v1/annotation_configs.py +12 -29
  11. phoenix/server/api/routers/v1/annotations.py +21 -22
  12. phoenix/server/api/routers/v1/datasets.py +38 -56
  13. phoenix/server/api/routers/v1/documents.py +2 -3
  14. phoenix/server/api/routers/v1/evaluations.py +12 -24
  15. phoenix/server/api/routers/v1/experiment_evaluations.py +2 -3
  16. phoenix/server/api/routers/v1/experiment_runs.py +9 -10
  17. phoenix/server/api/routers/v1/experiments.py +16 -17
  18. phoenix/server/api/routers/v1/projects.py +15 -21
  19. phoenix/server/api/routers/v1/prompts.py +30 -31
  20. phoenix/server/api/routers/v1/sessions.py +2 -5
  21. phoenix/server/api/routers/v1/spans.py +35 -26
  22. phoenix/server/api/routers/v1/traces.py +11 -19
  23. phoenix/server/api/routers/v1/users.py +14 -23
  24. phoenix/server/api/routers/v1/utils.py +3 -7
  25. phoenix/server/app.py +6 -2
  26. phoenix/server/authorization.py +2 -3
  27. phoenix/server/bearer_auth.py +4 -5
  28. phoenix/server/cost_tracking/model_cost_manifest.json +54 -54
  29. phoenix/server/oauth2.py +174 -9
  30. phoenix/server/static/.vite/manifest.json +39 -39
  31. phoenix/server/static/assets/{components-BG6v0EM8.js → components-BvsExS75.js} +422 -387
  32. phoenix/server/static/assets/{index-CSVcULw1.js → index-iq8WDxat.js} +12 -12
  33. phoenix/server/static/assets/{pages-DgaM7kpM.js → pages-Ckg4SLQ9.js} +542 -488
  34. phoenix/server/static/assets/vendor-D2eEI-6h.js +914 -0
  35. phoenix/server/static/assets/{vendor-arizeai-DlOj0PQQ.js → vendor-arizeai-kfOei7nf.js} +2 -2
  36. phoenix/server/static/assets/{vendor-codemirror-B2PHH5yZ.js → vendor-codemirror-1bq_t1Ec.js} +3 -3
  37. phoenix/server/static/assets/{vendor-recharts-CKsi4IjN.js → vendor-recharts-DQ4xfrf4.js} +1 -1
  38. phoenix/server/static/assets/{vendor-shiki-DN26BkKE.js → vendor-shiki-GGmcIQxA.js} +1 -1
  39. phoenix/server/templates/index.html +1 -0
  40. phoenix/trace/attributes.py +80 -13
  41. phoenix/version.py +1 -1
  42. phoenix/server/static/assets/vendor-BqTEkGQU.js +0 -903
  43. {arize_phoenix-12.2.0.dist-info → arize_phoenix-12.4.0.dist-info}/WHEEL +0 -0
  44. {arize_phoenix-12.2.0.dist-info → arize_phoenix-12.4.0.dist-info}/entry_points.txt +0 -0
  45. {arize_phoenix-12.2.0.dist-info → arize_phoenix-12.4.0.dist-info}/licenses/IP_NOTICE +0 -0
  46. {arize_phoenix-12.2.0.dist-info → arize_phoenix-12.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -14,12 +14,7 @@ from pydantic import BaseModel, Field
14
14
  from sqlalchemy import exists, select, update
15
15
  from starlette.requests import Request
16
16
  from starlette.responses import Response, StreamingResponse
17
- from starlette.status import (
18
- HTTP_202_ACCEPTED,
19
- HTTP_400_BAD_REQUEST,
20
- HTTP_404_NOT_FOUND,
21
- HTTP_422_UNPROCESSABLE_ENTITY,
22
- )
17
+ from starlette.status import HTTP_404_NOT_FOUND
23
18
  from strawberry.relay import GlobalID
24
19
 
25
20
  from phoenix.config import DEFAULT_PROJECT_NAME
@@ -33,7 +28,7 @@ from phoenix.server.api.types.node import from_global_id_with_expected_type
33
28
  from phoenix.server.authorization import is_not_locked
34
29
  from phoenix.server.bearer_auth import PhoenixUser
35
30
  from phoenix.server.dml_event import SpanAnnotationInsertEvent, SpanDeleteEvent
36
- from phoenix.trace.attributes import flatten
31
+ from phoenix.trace.attributes import flatten, unflatten
37
32
  from phoenix.trace.dsl import SpanQuery as SpanQuery_
38
33
  from phoenix.trace.schemas import (
39
34
  Span as SpanForInsertion,
@@ -440,7 +435,7 @@ class SpansResponseBody(PaginatedResponseBody[Span]):
440
435
  "/spans",
441
436
  operation_id="querySpans",
442
437
  summary="Query spans with query DSL",
443
- responses=add_errors_to_responses([HTTP_404_NOT_FOUND, HTTP_422_UNPROCESSABLE_ENTITY]),
438
+ responses=add_errors_to_responses([404, 422]),
444
439
  include_in_schema=False,
445
440
  )
446
441
  async def query_spans_handler(
@@ -467,7 +462,7 @@ async def query_spans_handler(
467
462
  except Exception as e:
468
463
  raise HTTPException(
469
464
  detail=f"Invalid query: {e}",
470
- status_code=HTTP_422_UNPROCESSABLE_ENTITY,
465
+ status_code=422,
471
466
  )
472
467
 
473
468
  async with request.app.state.db() as session:
@@ -490,7 +485,7 @@ async def query_spans_handler(
490
485
  )
491
486
  results.append(df)
492
487
  if not results:
493
- raise HTTPException(status_code=HTTP_404_NOT_FOUND)
488
+ raise HTTPException(status_code=404)
494
489
 
495
490
  if accept == "application/json":
496
491
  boundary_token = token_urlsafe(64)
@@ -574,7 +569,7 @@ def _to_any_value(value: Any) -> OtlpAnyValue:
574
569
  summary="Search spans with simple filters (no DSL)",
575
570
  description="Return spans within a project filtered by time range. "
576
571
  "Supports cursor-based pagination.",
577
- responses=add_errors_to_responses([HTTP_404_NOT_FOUND, HTTP_422_UNPROCESSABLE_ENTITY]),
572
+ responses=add_errors_to_responses([404, 422]),
578
573
  )
579
574
  async def span_search_otlpv1(
580
575
  request: Request,
@@ -617,7 +612,7 @@ async def span_search_otlpv1(
617
612
  cursor_rowid = int(GlobalID.from_id(cursor).node_id)
618
613
  stmt = stmt.where(models.Span.id <= cursor_rowid)
619
614
  except Exception:
620
- raise HTTPException(status_code=HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid cursor")
615
+ raise HTTPException(status_code=422, detail="Invalid cursor")
621
616
 
622
617
  stmt = stmt.limit(limit + 1)
623
618
 
@@ -711,7 +706,7 @@ async def span_search_otlpv1(
711
706
  summary="List spans with simple filters (no DSL)",
712
707
  description="Return spans within a project filtered by time range. "
713
708
  "Supports cursor-based pagination.",
714
- responses=add_errors_to_responses([HTTP_404_NOT_FOUND, HTTP_422_UNPROCESSABLE_ENTITY]),
709
+ responses=add_errors_to_responses([404, 422]),
715
710
  )
716
711
  async def span_search(
717
712
  request: Request,
@@ -751,7 +746,7 @@ async def span_search(
751
746
  try:
752
747
  cursor_rowid = int(GlobalID.from_id(cursor).node_id)
753
748
  except Exception:
754
- raise HTTPException(status_code=HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid cursor")
749
+ raise HTTPException(status_code=422, detail="Invalid cursor")
755
750
  stmt = stmt.where(models.Span.id <= cursor_rowid)
756
751
 
757
752
  stmt = stmt.limit(limit + 1)
@@ -867,9 +862,7 @@ class AnnotateSpansResponseBody(ResponseBody[list[InsertedSpanAnnotation]]):
867
862
  dependencies=[Depends(is_not_locked)],
868
863
  operation_id="annotateSpans",
869
864
  summary="Create span annotations",
870
- responses=add_errors_to_responses(
871
- [{"status_code": HTTP_404_NOT_FOUND, "description": "Span not found"}]
872
- ),
865
+ responses=add_errors_to_responses([{"status_code": 404, "description": "Span not found"}]),
873
866
  response_description="Span annotations inserted successfully",
874
867
  include_in_schema=True,
875
868
  )
@@ -915,7 +908,7 @@ async def annotate_spans(
915
908
  if missing_span_ids:
916
909
  raise HTTPException(
917
910
  detail=f"Spans with IDs {', '.join(missing_span_ids)} do not exist.",
918
- status_code=HTTP_404_NOT_FOUND,
911
+ status_code=404,
919
912
  )
920
913
  inserted_ids = []
921
914
  dialect = SupportedSQLDialect(session.bind.dialect.name)
@@ -957,8 +950,8 @@ class CreateSpansResponseBody(V1RoutesBaseModel):
957
950
  "Submit spans to be inserted into a project. If any spans are invalid or "
958
951
  "duplicates, no spans will be inserted."
959
952
  ),
960
- responses=add_errors_to_responses([HTTP_404_NOT_FOUND, HTTP_400_BAD_REQUEST]),
961
- status_code=HTTP_202_ACCEPTED,
953
+ responses=add_errors_to_responses([404, 400]),
954
+ status_code=202,
962
955
  )
963
956
  async def create_spans(
964
957
  request: Request,
@@ -997,6 +990,7 @@ async def create_spans(
997
990
  # Add back the openinference.span.kind attribute since it's stored separately in the API
998
991
  attributes = dict(api_span.attributes)
999
992
  attributes["openinference.span.kind"] = api_span.span_kind
993
+ attributes = unflatten(attributes.items())
1000
994
 
1001
995
  # Create span for insertion - note we ignore the 'id' field as it's server-generated
1002
996
  return SpanForInsertion(
@@ -1015,8 +1009,23 @@ async def create_spans(
1015
1009
  conversation=None, # Unused
1016
1010
  )
1017
1011
 
1018
- async with request.app.state.db() as session:
1019
- project = await _get_project_by_identifier(session, project_identifier)
1012
+ try:
1013
+ id_ = from_global_id_with_expected_type(
1014
+ GlobalID.from_id(project_identifier),
1015
+ "Project",
1016
+ )
1017
+ except Exception:
1018
+ project_name = project_identifier
1019
+ else:
1020
+ stmt = select(models.Project).filter_by(id=id_)
1021
+ async with request.app.state.db() as session:
1022
+ project = await session.scalar(stmt)
1023
+ if project is None:
1024
+ raise HTTPException(
1025
+ status_code=HTTP_404_NOT_FOUND,
1026
+ detail=f"Project with ID {project_identifier} not found",
1027
+ )
1028
+ project_name = project.name
1020
1029
 
1021
1030
  total_received = len(request_body.data)
1022
1031
  duplicate_spans: list[dict[str, str]] = []
@@ -1044,7 +1053,7 @@ async def create_spans(
1044
1053
 
1045
1054
  try:
1046
1055
  span_for_insertion = convert_api_span_for_insertion(api_span)
1047
- spans_to_queue.append((span_for_insertion, project.name))
1056
+ spans_to_queue.append((span_for_insertion, project_name))
1048
1057
  except Exception as e:
1049
1058
  invalid_spans.append(
1050
1059
  {
@@ -1066,7 +1075,7 @@ async def create_spans(
1066
1075
  "invalid_spans": invalid_spans,
1067
1076
  }
1068
1077
  raise HTTPException(
1069
- status_code=HTTP_400_BAD_REQUEST,
1078
+ status_code=400,
1070
1079
  detail=json.dumps(error_detail),
1071
1080
  )
1072
1081
 
@@ -1102,7 +1111,7 @@ async def create_spans(
1102
1111
  **Note**: This operation is irreversible and may create orphaned spans.
1103
1112
  """
1104
1113
  ),
1105
- responses=add_errors_to_responses([HTTP_404_NOT_FOUND]),
1114
+ responses=add_errors_to_responses([404]),
1106
1115
  status_code=204, # No Content for successful deletion
1107
1116
  )
1108
1117
  async def delete_span(
@@ -1154,7 +1163,7 @@ async def delete_span(
1154
1163
 
1155
1164
  if target_span is None:
1156
1165
  raise HTTPException(
1157
- status_code=HTTP_404_NOT_FOUND,
1166
+ status_code=404,
1158
1167
  detail=error_detail,
1159
1168
  )
1160
1169
 
@@ -14,12 +14,6 @@ from starlette.concurrency import run_in_threadpool
14
14
  from starlette.datastructures import State
15
15
  from starlette.requests import Request
16
16
  from starlette.responses import Response
17
- from starlette.status import (
18
- HTTP_404_NOT_FOUND,
19
- HTTP_415_UNSUPPORTED_MEDIA_TYPE,
20
- HTTP_422_UNPROCESSABLE_ENTITY,
21
- HTTP_503_SERVICE_UNAVAILABLE,
22
- )
23
17
  from strawberry.relay import GlobalID
24
18
 
25
19
  from phoenix.db import models
@@ -49,7 +43,7 @@ def is_not_at_capacity(request: Request) -> None:
49
43
  SPAN_QUEUE_REJECTIONS.inc()
50
44
  raise HTTPException(
51
45
  detail="Server is at capacity and cannot process more requests",
52
- status_code=HTTP_503_SERVICE_UNAVAILABLE,
46
+ status_code=503,
53
47
  )
54
48
 
55
49
 
@@ -61,14 +55,14 @@ def is_not_at_capacity(request: Request) -> None:
61
55
  responses=add_errors_to_responses(
62
56
  [
63
57
  {
64
- "status_code": HTTP_415_UNSUPPORTED_MEDIA_TYPE,
58
+ "status_code": 415,
65
59
  "description": (
66
60
  "Unsupported content type (only `application/x-protobuf` is supported)"
67
61
  ),
68
62
  },
69
- {"status_code": HTTP_422_UNPROCESSABLE_ENTITY, "description": "Invalid request body"},
63
+ {"status_code": 422, "description": "Invalid request body"},
70
64
  {
71
- "status_code": HTTP_503_SERVICE_UNAVAILABLE,
65
+ "status_code": 503,
72
66
  "description": "Server is at capacity and cannot process more requests",
73
67
  },
74
68
  ]
@@ -92,12 +86,12 @@ async def post_traces(
92
86
  if content_type != "application/x-protobuf":
93
87
  raise HTTPException(
94
88
  detail=f"Unsupported content type: {content_type}",
95
- status_code=HTTP_415_UNSUPPORTED_MEDIA_TYPE,
89
+ status_code=415,
96
90
  )
97
91
  if content_encoding and content_encoding not in ("gzip", "deflate"):
98
92
  raise HTTPException(
99
93
  detail=f"Unsupported content encoding: {content_encoding}",
100
- status_code=HTTP_415_UNSUPPORTED_MEDIA_TYPE,
94
+ status_code=415,
101
95
  )
102
96
  body = await request.body()
103
97
  if content_encoding == "gzip":
@@ -110,7 +104,7 @@ async def post_traces(
110
104
  except DecodeError:
111
105
  raise HTTPException(
112
106
  detail="Request body is invalid ExportTraceServiceRequest",
113
- status_code=HTTP_422_UNPROCESSABLE_ENTITY,
107
+ status_code=422,
114
108
  )
115
109
  background_tasks.add_task(_add_spans, req, request.state)
116
110
 
@@ -141,9 +135,7 @@ class AnnotateTracesResponseBody(ResponseBody[list[InsertedTraceAnnotation]]):
141
135
  dependencies=[Depends(is_not_locked)],
142
136
  operation_id="annotateTraces",
143
137
  summary="Create trace annotations",
144
- responses=add_errors_to_responses(
145
- [{"status_code": HTTP_404_NOT_FOUND, "description": "Trace not found"}]
146
- ),
138
+ responses=add_errors_to_responses([{"status_code": 404, "description": "Trace not found"}]),
147
139
  )
148
140
  async def annotate_traces(
149
141
  request: Request,
@@ -177,7 +169,7 @@ async def annotate_traces(
177
169
  if missing_trace_ids:
178
170
  raise HTTPException(
179
171
  detail=f"Traces with IDs {', '.join(missing_trace_ids)} do not exist.",
180
- status_code=HTTP_404_NOT_FOUND,
172
+ status_code=404,
181
173
  )
182
174
  inserted_ids = []
183
175
  dialect = SupportedSQLDialect(session.bind.dialect.name)
@@ -220,7 +212,7 @@ async def _add_spans(req: ExportTraceServiceRequest, state: State) -> None:
220
212
  "2. An OpenTelemetry trace_id (hex string)\n\n"
221
213
  "This will permanently remove all spans in the trace and their associated data."
222
214
  ),
223
- responses=add_errors_to_responses([HTTP_404_NOT_FOUND]),
215
+ responses=add_errors_to_responses([404]),
224
216
  status_code=204, # No Content for successful deletion
225
217
  )
226
218
  async def delete_trace(
@@ -268,7 +260,7 @@ async def delete_trace(
268
260
 
269
261
  if project_id is None:
270
262
  raise HTTPException(
271
- status_code=HTTP_404_NOT_FOUND,
263
+ status_code=404,
272
264
  detail=error_detail,
273
265
  )
274
266
 
@@ -12,15 +12,6 @@ from sqlalchemy.exc import IntegrityError as PostgreSQLIntegrityError
12
12
  from sqlalchemy.orm import joinedload
13
13
  from sqlean.dbapi2 import IntegrityError as SQLiteIntegrityError # type: ignore[import-untyped]
14
14
  from starlette.datastructures import Secret
15
- from starlette.status import (
16
- HTTP_201_CREATED,
17
- HTTP_204_NO_CONTENT,
18
- HTTP_400_BAD_REQUEST,
19
- HTTP_403_FORBIDDEN,
20
- HTTP_404_NOT_FOUND,
21
- HTTP_409_CONFLICT,
22
- HTTP_422_UNPROCESSABLE_ENTITY,
23
- )
24
15
  from strawberry.relay import GlobalID
25
16
  from typing_extensions import TypeAlias, assert_never
26
17
 
@@ -113,7 +104,7 @@ DEFAULT_PAGINATION_PAGE_LIMIT = 100
113
104
  response_description="A list of users.",
114
105
  responses=add_errors_to_responses(
115
106
  [
116
- HTTP_422_UNPROCESSABLE_ENTITY,
107
+ 422,
117
108
  ],
118
109
  ),
119
110
  dependencies=[Depends(require_admin)],
@@ -187,12 +178,12 @@ async def list_users(
187
178
  summary="Create a new user",
188
179
  description="Create a new user with the specified configuration.",
189
180
  response_description="The newly created user.",
190
- status_code=HTTP_201_CREATED,
181
+ status_code=201,
191
182
  responses=add_errors_to_responses(
192
183
  [
193
- {"status_code": HTTP_400_BAD_REQUEST, "description": "Role not found."},
194
- {"status_code": HTTP_409_CONFLICT, "description": "Username or email already exists."},
195
- HTTP_422_UNPROCESSABLE_ENTITY,
184
+ {"status_code": 400, "description": "Role not found."},
185
+ {"status_code": 409, "description": "Username or email already exists."},
186
+ 422,
196
187
  ]
197
188
  ),
198
189
  dependencies=[Depends(require_admin), Depends(is_not_locked)],
@@ -213,14 +204,14 @@ async def create_user(
213
204
  # Prevent creation of SYSTEM users
214
205
  if role == "SYSTEM":
215
206
  raise HTTPException(
216
- status_code=HTTP_400_BAD_REQUEST,
207
+ status_code=400,
217
208
  detail="Cannot create users with SYSTEM role",
218
209
  )
219
210
 
220
211
  # TODO: Implement VIEWER role
221
212
  if role == "VIEWER":
222
213
  raise HTTPException(
223
- status_code=HTTP_400_BAD_REQUEST,
214
+ status_code=400,
224
215
  detail="VIEWER role not yet implemented",
225
216
  )
226
217
 
@@ -259,12 +250,12 @@ async def create_user(
259
250
  session.add(user)
260
251
  except (PostgreSQLIntegrityError, SQLiteIntegrityError) as e:
261
252
  if "users.username" in str(e):
262
- raise HTTPException(status_code=HTTP_409_CONFLICT, detail="Username already exists")
253
+ raise HTTPException(status_code=409, detail="Username already exists")
263
254
  elif "users.email" in str(e):
264
- raise HTTPException(status_code=HTTP_409_CONFLICT, detail="Email already exists")
255
+ raise HTTPException(status_code=409, detail="Email already exists")
265
256
  else:
266
257
  raise HTTPException(
267
- status_code=HTTP_409_CONFLICT,
258
+ status_code=409,
268
259
  detail="Failed to create user due to a conflict with existing data",
269
260
  )
270
261
  id_ = str(GlobalID("User", str(user.id)))
@@ -314,13 +305,13 @@ async def create_user(
314
305
  summary="Delete a user by ID",
315
306
  description="Delete an existing user by their unique GlobalID.",
316
307
  response_description="No content returned on successful deletion.",
317
- status_code=HTTP_204_NO_CONTENT,
308
+ status_code=204,
318
309
  responses=add_errors_to_responses(
319
310
  [
320
- {"status_code": HTTP_404_NOT_FOUND, "description": "User not found."},
321
- HTTP_422_UNPROCESSABLE_ENTITY,
311
+ {"status_code": 404, "description": "User not found."},
312
+ 422,
322
313
  {
323
- "status_code": HTTP_403_FORBIDDEN,
314
+ "status_code": 403,
324
315
  "description": "Cannot delete the default admin or system user",
325
316
  },
326
317
  ]
@@ -3,10 +3,6 @@ from typing import Any, Generic, Optional, TypedDict, TypeVar, Union
3
3
  from fastapi import HTTPException
4
4
  from sqlalchemy import select
5
5
  from sqlalchemy.ext.asyncio import AsyncSession
6
- from starlette.status import (
7
- HTTP_404_NOT_FOUND,
8
- HTTP_422_UNPROCESSABLE_ENTITY,
9
- )
10
6
  from strawberry.relay import GlobalID
11
7
  from typing_extensions import TypeAlias, assert_never
12
8
 
@@ -135,21 +131,21 @@ async def _get_project_by_identifier(
135
131
  name = project_identifier
136
132
  except HTTPException:
137
133
  raise HTTPException(
138
- status_code=HTTP_422_UNPROCESSABLE_ENTITY,
134
+ status_code=422,
139
135
  detail=f"Invalid project identifier format: {project_identifier}",
140
136
  )
141
137
  stmt = select(models.Project).filter_by(name=name)
142
138
  project = await session.scalar(stmt)
143
139
  if project is None:
144
140
  raise HTTPException(
145
- status_code=HTTP_404_NOT_FOUND,
141
+ status_code=404,
146
142
  detail=f"Project with name {name} not found",
147
143
  )
148
144
  else:
149
145
  project = await session.get(models.Project, id_)
150
146
  if project is None:
151
147
  raise HTTPException(
152
- status_code=HTTP_404_NOT_FOUND,
148
+ status_code=404,
153
149
  detail=f"Project with ID {project_identifier} not found",
154
150
  )
155
151
  return project
phoenix/server/app.py CHANGED
@@ -45,7 +45,6 @@ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoin
45
45
  from starlette.requests import Request
46
46
  from starlette.responses import JSONResponse, PlainTextResponse, RedirectResponse, Response
47
47
  from starlette.staticfiles import StaticFiles
48
- from starlette.status import HTTP_401_UNAUTHORIZED
49
48
  from starlette.templating import Jinja2Templates
50
49
  from starlette.types import Scope, StatefulLifespan
51
50
  from strawberry.extensions import SchemaExtension
@@ -81,6 +80,7 @@ from phoenix.db.facilitator import Facilitator
81
80
  from phoenix.db.helpers import SupportedSQLDialect
82
81
  from phoenix.exceptions import PhoenixMigrationError
83
82
  from phoenix.pointcloud.umap_parameters import UMAPParameters
83
+ from phoenix.server.api.auth_messages import AUTH_ERROR_MESSAGES, AuthErrorCode
84
84
  from phoenix.server.api.context import Context, DataLoaders
85
85
  from phoenix.server.api.dataloaders import (
86
86
  AnnotationConfigsByProjectDataLoader,
@@ -250,6 +250,8 @@ class AppConfig(NamedTuple):
250
250
  web_manifest_path: Path
251
251
  authentication_enabled: bool
252
252
  """ Whether authentication is enabled """
253
+ auth_error_messages: dict[AuthErrorCode, str]
254
+ """ Mapping of auth error codes to user-friendly messages """
253
255
  oauth2_idps: Sequence[OAuth2Idp]
254
256
  basic_auth_disabled: bool = False
255
257
  auto_login_idp_name: Optional[str] = None
@@ -326,6 +328,7 @@ class Static(StaticFiles):
326
328
  "support_email": self._app_config.support_email,
327
329
  "has_db_threshold": self._app_config.has_db_threshold,
328
330
  "allow_external_resources": self._app_config.allow_external_resources,
331
+ "auth_error_messages": self._app_config.auth_error_messages,
329
332
  },
330
333
  )
331
334
  except Exception as e:
@@ -348,7 +351,7 @@ class RequestOriginHostnameValidator(BaseHTTPMiddleware):
348
351
  if not (url := headers.get(key)):
349
352
  continue
350
353
  if urlparse(url).hostname not in self._trusted_hostnames:
351
- return Response(f"untrusted {key}", status_code=HTTP_401_UNAUTHORIZED)
354
+ return Response(f"untrusted {key}", status_code=401)
352
355
  return await call_next(request)
353
356
 
354
357
 
@@ -1146,6 +1149,7 @@ def create_app(
1146
1149
  and get_env_database_usage_insertion_blocking_threshold_percentage()
1147
1150
  ),
1148
1151
  allow_external_resources=get_env_allow_external_resources(),
1152
+ auth_error_messages=dict(AUTH_ERROR_MESSAGES) if authentication_enabled else {},
1149
1153
  ),
1150
1154
  ),
1151
1155
  name="static",
@@ -23,7 +23,6 @@ Usage:
23
23
  """
24
24
 
25
25
  from fastapi import HTTPException, Request
26
- from fastapi import status as fastapi_status
27
26
 
28
27
  from phoenix.config import get_env_support_email
29
28
  from phoenix.server.bearer_auth import PhoenixUser
@@ -49,7 +48,7 @@ def require_admin(request: Request) -> None:
49
48
  # System users have all privileges
50
49
  if not (isinstance(user, PhoenixUser) and user.is_admin):
51
50
  raise HTTPException(
52
- status_code=fastapi_status.HTTP_403_FORBIDDEN,
51
+ status_code=403,
53
52
  detail="Only admin or system users can perform this action.",
54
53
  )
55
54
 
@@ -82,6 +81,6 @@ def is_not_locked(request: Request) -> None:
82
81
  if support_email := get_env_support_email():
83
82
  detail += f" Need help? Contact us at {support_email}"
84
83
  raise HTTPException(
85
- status_code=fastapi_status.HTTP_507_INSUFFICIENT_STORAGE,
84
+ status_code=507,
86
85
  detail=detail,
87
86
  )
@@ -9,7 +9,6 @@ from fastapi import HTTPException, Request, WebSocket, WebSocketException
9
9
  from grpc_interceptor import AsyncServerInterceptor
10
10
  from starlette.authentication import AuthCredentials, AuthenticationBackend, BaseUser
11
11
  from starlette.requests import HTTPConnection
12
- from starlette.status import HTTP_401_UNAUTHORIZED
13
12
  from typing_extensions import override
14
13
 
15
14
  from phoenix import config
@@ -144,16 +143,16 @@ async def is_authenticated(
144
143
  """
145
144
  assert request or websocket
146
145
  if request and not isinstance((user := request.user), PhoenixUser):
147
- raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Invalid token")
146
+ raise HTTPException(status_code=401, detail="Invalid token")
148
147
  if websocket and not isinstance((user := websocket.user), PhoenixUser):
149
- raise WebSocketException(code=HTTP_401_UNAUTHORIZED, reason="Invalid token")
148
+ raise WebSocketException(code=401, reason="Invalid token")
150
149
  if isinstance(user, PhoenixSystemUser):
151
150
  return
152
151
  claims = user.claims
153
152
  if claims.status is ClaimSetStatus.EXPIRED:
154
- raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Expired token")
153
+ raise HTTPException(status_code=401, detail="Expired token")
155
154
  if claims.status is not ClaimSetStatus.VALID:
156
- raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Invalid token")
155
+ raise HTTPException(status_code=401, detail="Invalid token")
157
156
 
158
157
 
159
158
  async def create_access_and_refresh_tokens(
@@ -449,6 +449,60 @@
449
449
  }
450
450
  ]
451
451
  },
452
+ {
453
+ "name": "claude-sonnet-4-5",
454
+ "name_pattern": "claude-sonnet-4-5",
455
+ "source": "litellm",
456
+ "token_prices": [
457
+ {
458
+ "base_rate": 3e-6,
459
+ "is_prompt": true,
460
+ "token_type": "input"
461
+ },
462
+ {
463
+ "base_rate": 0.000015,
464
+ "is_prompt": false,
465
+ "token_type": "output"
466
+ },
467
+ {
468
+ "base_rate": 3e-7,
469
+ "is_prompt": true,
470
+ "token_type": "cache_read"
471
+ },
472
+ {
473
+ "base_rate": 3.75e-6,
474
+ "is_prompt": true,
475
+ "token_type": "cache_write"
476
+ }
477
+ ]
478
+ },
479
+ {
480
+ "name": "claude-sonnet-4-5-20250929",
481
+ "name_pattern": "claude-sonnet-4-5-20250929",
482
+ "source": "litellm",
483
+ "token_prices": [
484
+ {
485
+ "base_rate": 3e-6,
486
+ "is_prompt": true,
487
+ "token_type": "input"
488
+ },
489
+ {
490
+ "base_rate": 0.000015,
491
+ "is_prompt": false,
492
+ "token_type": "output"
493
+ },
494
+ {
495
+ "base_rate": 3e-7,
496
+ "is_prompt": true,
497
+ "token_type": "cache_read"
498
+ },
499
+ {
500
+ "base_rate": 3.75e-6,
501
+ "is_prompt": true,
502
+ "token_type": "cache_write"
503
+ }
504
+ ]
505
+ },
452
506
  {
453
507
  "name": "gemini-2.0-flash",
454
508
  "name_pattern": "gemini-2.0-flash(@[a-zA-Z0-9]+)?",
@@ -1028,60 +1082,6 @@
1028
1082
  }
1029
1083
  ]
1030
1084
  },
1031
- {
1032
- "name": "gemini-flash-latest",
1033
- "name_pattern": "gemini-flash-latest",
1034
- "source": "litellm",
1035
- "token_prices": [
1036
- {
1037
- "base_rate": 3e-7,
1038
- "is_prompt": true,
1039
- "token_type": "input"
1040
- },
1041
- {
1042
- "base_rate": 2.5e-6,
1043
- "is_prompt": false,
1044
- "token_type": "output"
1045
- },
1046
- {
1047
- "base_rate": 7.5e-8,
1048
- "is_prompt": true,
1049
- "token_type": "cache_read"
1050
- },
1051
- {
1052
- "base_rate": 1e-6,
1053
- "is_prompt": true,
1054
- "token_type": "audio"
1055
- }
1056
- ]
1057
- },
1058
- {
1059
- "name": "gemini-flash-lite-latest",
1060
- "name_pattern": "gemini-flash-lite-latest",
1061
- "source": "litellm",
1062
- "token_prices": [
1063
- {
1064
- "base_rate": 1e-7,
1065
- "is_prompt": true,
1066
- "token_type": "input"
1067
- },
1068
- {
1069
- "base_rate": 4e-7,
1070
- "is_prompt": false,
1071
- "token_type": "output"
1072
- },
1073
- {
1074
- "base_rate": 2.5e-8,
1075
- "is_prompt": true,
1076
- "token_type": "cache_read"
1077
- },
1078
- {
1079
- "base_rate": 3e-7,
1080
- "is_prompt": true,
1081
- "token_type": "audio"
1082
- }
1083
- ]
1084
- },
1085
1085
  {
1086
1086
  "name": "gpt-3.5-turbo",
1087
1087
  "name_pattern": "gpt-(35|3.5)-turbo",