arize-phoenix 11.7.0__py3-none-any.whl → 11.9.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.
- {arize_phoenix-11.7.0.dist-info → arize_phoenix-11.9.0.dist-info}/METADATA +14 -2
- {arize_phoenix-11.7.0.dist-info → arize_phoenix-11.9.0.dist-info}/RECORD +39 -37
- phoenix/config.py +33 -0
- phoenix/datetime_utils.py +112 -1
- phoenix/db/helpers.py +156 -1
- phoenix/db/insertion/span.py +12 -10
- phoenix/db/insertion/types.py +9 -2
- phoenix/server/api/auth.py +28 -6
- phoenix/server/api/dataloaders/span_cost_summary_by_experiment.py +6 -7
- phoenix/server/api/exceptions.py +6 -0
- phoenix/server/api/input_types/CreateProjectInput.py +27 -0
- phoenix/server/api/input_types/TimeBinConfig.py +23 -0
- phoenix/server/api/mutations/project_mutations.py +37 -1
- phoenix/server/api/mutations/trace_mutations.py +45 -1
- phoenix/server/api/routers/oauth2.py +19 -2
- phoenix/server/api/types/CostBreakdown.py +4 -7
- phoenix/server/api/types/Project.py +891 -69
- phoenix/server/app.py +7 -3
- phoenix/server/authorization.py +27 -2
- phoenix/server/cost_tracking/cost_details_calculator.py +22 -16
- phoenix/server/cost_tracking/model_cost_manifest.json +85 -0
- phoenix/server/daemons/span_cost_calculator.py +2 -8
- phoenix/server/dml_event.py +4 -0
- phoenix/server/email/sender.py +2 -1
- phoenix/server/email/templates/db_disk_usage_notification.html +3 -0
- phoenix/server/static/.vite/manifest.json +36 -36
- phoenix/server/static/assets/{components-J3qjrjBf.js → components-IBd-PDxA.js} +452 -293
- phoenix/server/static/assets/{index-CEObsQf_.js → index-B8EBC_Z5.js} +17 -11
- phoenix/server/static/assets/{pages-CW1UdBht.js → pages-6D1duYIe.js} +569 -439
- phoenix/server/static/assets/vendor-BzZ0oklU.js +939 -0
- phoenix/server/static/assets/vendor-arizeai-CvjUqTrl.js +168 -0
- phoenix/server/static/assets/{vendor-codemirror-k3zCIjlN.js → vendor-codemirror-CKK25Gd7.js} +1 -1
- phoenix/server/static/assets/vendor-recharts-CWtaRhQC.js +37 -0
- phoenix/server/static/assets/{vendor-shiki-DPtuv2M4.js → vendor-shiki-D30GF-p9.js} +1 -1
- phoenix/version.py +1 -1
- phoenix/server/static/assets/vendor-BnPh9i9e.js +0 -911
- phoenix/server/static/assets/vendor-arizeai-Cr9o_Iu_.js +0 -642
- phoenix/server/static/assets/vendor-recharts-BdblEuGB.js +0 -59
- {arize_phoenix-11.7.0.dist-info → arize_phoenix-11.9.0.dist-info}/WHEEL +0 -0
- {arize_phoenix-11.7.0.dist-info → arize_phoenix-11.9.0.dist-info}/entry_points.txt +0 -0
- {arize_phoenix-11.7.0.dist-info → arize_phoenix-11.9.0.dist-info}/licenses/IP_NOTICE +0 -0
- {arize_phoenix-11.7.0.dist-info → arize_phoenix-11.9.0.dist-info}/licenses/LICENSE +0 -0
phoenix/db/insertion/span.py
CHANGED
|
@@ -28,15 +28,6 @@ async def insert_span(
|
|
|
28
28
|
project_name: str,
|
|
29
29
|
) -> Optional[SpanInsertionEvent]:
|
|
30
30
|
dialect = SupportedSQLDialect(session.bind.dialect.name)
|
|
31
|
-
if (
|
|
32
|
-
project_rowid := await session.scalar(
|
|
33
|
-
select(models.Project.id).filter_by(name=project_name)
|
|
34
|
-
)
|
|
35
|
-
) is None:
|
|
36
|
-
project_rowid = await session.scalar(
|
|
37
|
-
insert(models.Project).values(name=project_name).returning(models.Project.id)
|
|
38
|
-
)
|
|
39
|
-
assert project_rowid is not None
|
|
40
31
|
|
|
41
32
|
trace_id = span.context.trace_id
|
|
42
33
|
trace: models.Trace = await session.scalar(
|
|
@@ -44,16 +35,27 @@ async def insert_span(
|
|
|
44
35
|
) or models.Trace(trace_id=trace_id)
|
|
45
36
|
|
|
46
37
|
if trace.id is not None:
|
|
38
|
+
# We use the existing project_rowid on the trace because we allow users to transfer traces
|
|
39
|
+
# between projects, so the project_name parameter is ignored for existing traces.
|
|
40
|
+
project_rowid = trace.project_rowid
|
|
47
41
|
# Trace record may need to be updated.
|
|
48
42
|
if trace.end_time < span.end_time:
|
|
49
43
|
trace.end_time = span.end_time
|
|
50
|
-
trace.project_rowid = project_rowid
|
|
51
44
|
if span.start_time < trace.start_time:
|
|
52
45
|
trace.start_time = span.start_time
|
|
53
46
|
else:
|
|
54
47
|
# Trace record needs to be persisted for the first time.
|
|
55
48
|
trace.start_time = span.start_time
|
|
56
49
|
trace.end_time = span.end_time
|
|
50
|
+
if (
|
|
51
|
+
project_rowid := await session.scalar(
|
|
52
|
+
select(models.Project.id).filter_by(name=project_name)
|
|
53
|
+
)
|
|
54
|
+
) is None:
|
|
55
|
+
project_rowid = await session.scalar(
|
|
56
|
+
insert(models.Project).values(name=project_name).returning(models.Project.id)
|
|
57
|
+
)
|
|
58
|
+
assert project_rowid is not None
|
|
57
59
|
trace.project_rowid = project_rowid
|
|
58
60
|
session.add(trace)
|
|
59
61
|
|
phoenix/db/insertion/types.py
CHANGED
|
@@ -94,7 +94,10 @@ class QueueInserter(ABC, Generic[_PrecursorT, _InsertableT, _RowT, _DmlEventT]):
|
|
|
94
94
|
async def insert(self) -> Optional[list[_DmlEventT]]:
|
|
95
95
|
if not self._queue:
|
|
96
96
|
return None
|
|
97
|
-
|
|
97
|
+
parcels = self._queue.copy()
|
|
98
|
+
# IMPORTANT: Use .clear() instead of reassignment, i.e. self._queue = [], to
|
|
99
|
+
# avoid potential race conditions when appending postponed items to the queue.
|
|
100
|
+
self._queue.clear()
|
|
98
101
|
events: list[_DmlEventT] = []
|
|
99
102
|
async with self._db() as session:
|
|
100
103
|
to_insert, to_postpone, _ = await self._partition(session, *parcels)
|
|
@@ -104,9 +107,13 @@ class QueueInserter(ABC, Generic[_PrecursorT, _InsertableT, _RowT, _DmlEventT]):
|
|
|
104
107
|
to_postpone.extend(to_retry)
|
|
105
108
|
if to_postpone:
|
|
106
109
|
loop = asyncio.get_running_loop()
|
|
107
|
-
loop.call_later(self._retry_delay_sec, self.
|
|
110
|
+
loop.call_later(self._retry_delay_sec, self._add_postponed_to_queue, to_postpone)
|
|
108
111
|
return events
|
|
109
112
|
|
|
113
|
+
def _add_postponed_to_queue(self, items: list[Postponed[_PrecursorT]]) -> None:
|
|
114
|
+
"""Add postponed items back to the queue for retry."""
|
|
115
|
+
self._queue.extend(items)
|
|
116
|
+
|
|
110
117
|
def _insert_on_conflict(self, *records: Mapping[str, Any]) -> Insert:
|
|
111
118
|
return insert_on_conflict(
|
|
112
119
|
*records,
|
phoenix/server/api/auth.py
CHANGED
|
@@ -3,8 +3,10 @@ from typing import Any
|
|
|
3
3
|
|
|
4
4
|
from strawberry import Info
|
|
5
5
|
from strawberry.permission import BasePermission
|
|
6
|
+
from typing_extensions import override
|
|
6
7
|
|
|
7
|
-
from phoenix.
|
|
8
|
+
from phoenix.config import get_env_support_email
|
|
9
|
+
from phoenix.server.api.exceptions import InsufficientStorage, Unauthorized
|
|
8
10
|
from phoenix.server.bearer_auth import PhoenixUser
|
|
9
11
|
|
|
10
12
|
|
|
@@ -20,15 +22,35 @@ class IsNotReadOnly(Authorization):
|
|
|
20
22
|
return not info.context.read_only
|
|
21
23
|
|
|
22
24
|
|
|
23
|
-
class IsLocked(
|
|
24
|
-
"""
|
|
25
|
-
Disables mutations and subscriptions that create or update data but allows
|
|
26
|
-
queries and delete mutations.
|
|
25
|
+
class IsLocked(BasePermission):
|
|
27
26
|
"""
|
|
27
|
+
Permission class that restricts data-modifying operations when insufficient storage.
|
|
28
|
+
|
|
29
|
+
When database storage capacity is exceeded, this permission blocks mutations and
|
|
30
|
+
subscriptions that create or update data, while allowing queries and delete mutations
|
|
31
|
+
to continue. This prevents database overflow while maintaining read access and the
|
|
32
|
+
ability to free up space through deletions.
|
|
28
33
|
|
|
29
|
-
|
|
34
|
+
Raises:
|
|
35
|
+
InsufficientStorage: When storage capacity is exceeded and data operations
|
|
36
|
+
are temporarily disabled. The error includes guidance for resolution
|
|
37
|
+
and support contact information if configured.
|
|
38
|
+
"""
|
|
30
39
|
|
|
40
|
+
@override
|
|
41
|
+
def on_unauthorized(self) -> None:
|
|
42
|
+
"""Create user-friendly error message when storage operations are blocked."""
|
|
43
|
+
message = (
|
|
44
|
+
"Database operations are disabled due to insufficient storage. "
|
|
45
|
+
"Please delete old data or increase storage."
|
|
46
|
+
)
|
|
47
|
+
if support_email := get_env_support_email():
|
|
48
|
+
message += f" Need help? Contact us at {support_email}"
|
|
49
|
+
raise InsufficientStorage(message)
|
|
50
|
+
|
|
51
|
+
@override
|
|
31
52
|
def has_permission(self, source: Any, info: Info, **kwargs: Any) -> bool:
|
|
53
|
+
"""Check if database operations are allowed based on storage capacity and lock status."""
|
|
32
54
|
return not (info.context.db.should_not_insert_or_update or info.context.locked)
|
|
33
55
|
|
|
34
56
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from collections import defaultdict
|
|
2
2
|
|
|
3
3
|
from sqlalchemy import func, select
|
|
4
|
-
from sqlalchemy.sql.functions import coalesce
|
|
5
4
|
from strawberry.dataloader import DataLoader
|
|
6
5
|
from typing_extensions import TypeAlias
|
|
7
6
|
|
|
@@ -23,12 +22,12 @@ class SpanCostSummaryByExperimentDataLoader(DataLoader[Key, Result]):
|
|
|
23
22
|
stmt = (
|
|
24
23
|
select(
|
|
25
24
|
models.ExperimentRun.experiment_id,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
25
|
+
func.sum(models.SpanCost.prompt_cost).label("prompt_cost"),
|
|
26
|
+
func.sum(models.SpanCost.completion_cost).label("completion_cost"),
|
|
27
|
+
func.sum(models.SpanCost.total_cost).label("total_cost"),
|
|
28
|
+
func.sum(models.SpanCost.prompt_tokens).label("prompt_tokens"),
|
|
29
|
+
func.sum(models.SpanCost.completion_tokens).label("completion_tokens"),
|
|
30
|
+
func.sum(models.SpanCost.total_tokens).label("total_tokens"),
|
|
32
31
|
)
|
|
33
32
|
.select_from(models.ExperimentRun)
|
|
34
33
|
.join(models.Trace, models.ExperimentRun.trace_id == models.Trace.trace_id)
|
phoenix/server/api/exceptions.py
CHANGED
|
@@ -27,6 +27,12 @@ class Unauthorized(CustomGraphQLError):
|
|
|
27
27
|
"""
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
class InsufficientStorage(CustomGraphQLError):
|
|
31
|
+
"""
|
|
32
|
+
An error raised when the database has insufficient storage to complete a request.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
30
36
|
class Conflict(CustomGraphQLError):
|
|
31
37
|
"""
|
|
32
38
|
An error raised when a mutation cannot be completed due to a conflict with
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
import strawberry
|
|
5
|
+
from strawberry import UNSET
|
|
6
|
+
|
|
7
|
+
from phoenix.server.api.exceptions import BadRequest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@strawberry.input
|
|
11
|
+
class CreateProjectInput:
|
|
12
|
+
name: str
|
|
13
|
+
description: Optional[str] = UNSET
|
|
14
|
+
gradient_start_color: Optional[str] = UNSET
|
|
15
|
+
gradient_end_color: Optional[str] = UNSET
|
|
16
|
+
|
|
17
|
+
def __post_init__(self) -> None:
|
|
18
|
+
if not self.name.strip():
|
|
19
|
+
raise BadRequest("Name cannot be empty")
|
|
20
|
+
if self.gradient_start_color and not re.match(
|
|
21
|
+
r"^#([0-9a-fA-F]{6})$", self.gradient_start_color
|
|
22
|
+
):
|
|
23
|
+
raise BadRequest("Gradient start color must be a valid hex color")
|
|
24
|
+
if self.gradient_end_color and not re.match(
|
|
25
|
+
r"^#([0-9a-fA-F]{6})$", self.gradient_end_color
|
|
26
|
+
):
|
|
27
|
+
raise BadRequest("Gradient end color must be a valid hex color")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
import strawberry
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@strawberry.enum
|
|
7
|
+
class TimeBinScale(Enum):
|
|
8
|
+
MINUTE = "minute"
|
|
9
|
+
HOUR = "hour"
|
|
10
|
+
DAY = "day"
|
|
11
|
+
WEEK = "week"
|
|
12
|
+
MONTH = "month"
|
|
13
|
+
YEAR = "year"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@strawberry.input
|
|
17
|
+
class TimeBinConfig:
|
|
18
|
+
scale: TimeBinScale = strawberry.field(
|
|
19
|
+
default=TimeBinScale.HOUR, description="The scale of time bins for aggregation."
|
|
20
|
+
)
|
|
21
|
+
utc_offset_minutes: int = strawberry.field(
|
|
22
|
+
default=0, description="Offset in minutes from UTC for local time binning."
|
|
23
|
+
)
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import strawberry
|
|
2
2
|
from sqlalchemy import delete, select
|
|
3
|
+
from sqlalchemy.exc import IntegrityError as PostgreSQLIntegrityError
|
|
3
4
|
from sqlalchemy.orm import load_only
|
|
5
|
+
from sqlean.dbapi2 import IntegrityError as SQLiteIntegrityError # type: ignore[import-untyped]
|
|
4
6
|
from strawberry.relay import GlobalID
|
|
5
7
|
from strawberry.types import Info
|
|
6
8
|
|
|
@@ -8,14 +10,48 @@ from phoenix.config import DEFAULT_PROJECT_NAME
|
|
|
8
10
|
from phoenix.db import models
|
|
9
11
|
from phoenix.server.api.auth import IsNotReadOnly
|
|
10
12
|
from phoenix.server.api.context import Context
|
|
13
|
+
from phoenix.server.api.exceptions import BadRequest, Conflict
|
|
11
14
|
from phoenix.server.api.input_types.ClearProjectInput import ClearProjectInput
|
|
15
|
+
from phoenix.server.api.input_types.CreateProjectInput import CreateProjectInput
|
|
12
16
|
from phoenix.server.api.queries import Query
|
|
13
17
|
from phoenix.server.api.types.node import from_global_id_with_expected_type
|
|
14
|
-
from phoenix.server.
|
|
18
|
+
from phoenix.server.api.types.Project import Project, to_gql_project
|
|
19
|
+
from phoenix.server.dml_event import ProjectDeleteEvent, ProjectInsertEvent, SpanDeleteEvent
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@strawberry.type
|
|
23
|
+
class ProjectMutationPayload:
|
|
24
|
+
project: Project
|
|
25
|
+
query: Query
|
|
15
26
|
|
|
16
27
|
|
|
17
28
|
@strawberry.type
|
|
18
29
|
class ProjectMutationMixin:
|
|
30
|
+
@strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
|
|
31
|
+
async def create_project(
|
|
32
|
+
self,
|
|
33
|
+
info: Info[Context, None],
|
|
34
|
+
input: CreateProjectInput,
|
|
35
|
+
) -> ProjectMutationPayload:
|
|
36
|
+
if not (name := input.name.strip()):
|
|
37
|
+
raise BadRequest("Name cannot be empty")
|
|
38
|
+
description = (input.description or "").strip() or None
|
|
39
|
+
gradient_start_color = (input.gradient_start_color or "").strip() or None
|
|
40
|
+
gradient_end_color = (input.gradient_end_color or "").strip() or None
|
|
41
|
+
project = models.Project(
|
|
42
|
+
name=name,
|
|
43
|
+
description=description,
|
|
44
|
+
gradient_start_color=gradient_start_color,
|
|
45
|
+
gradient_end_color=gradient_end_color,
|
|
46
|
+
)
|
|
47
|
+
try:
|
|
48
|
+
async with info.context.db() as session:
|
|
49
|
+
session.add(project)
|
|
50
|
+
except (PostgreSQLIntegrityError, SQLiteIntegrityError):
|
|
51
|
+
raise Conflict(f"Project with name '{name}' already exists")
|
|
52
|
+
info.context.event_queue.put(ProjectInsertEvent((project.id,)))
|
|
53
|
+
return ProjectMutationPayload(project=to_gql_project(project), query=Query())
|
|
54
|
+
|
|
19
55
|
@strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
|
|
20
56
|
async def delete_project(self, info: Info[Context, None], id: GlobalID) -> Query:
|
|
21
57
|
project_id = from_global_id_with_expected_type(global_id=id, expected_type_name="Project")
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import strawberry
|
|
2
|
-
from sqlalchemy import and_, delete, not_, select
|
|
2
|
+
from sqlalchemy import and_, delete, not_, select, update
|
|
3
3
|
from sqlalchemy.orm import load_only
|
|
4
4
|
from sqlalchemy.sql import literal
|
|
5
5
|
from strawberry.relay import GlobalID
|
|
@@ -72,3 +72,47 @@ class TraceMutationMixin:
|
|
|
72
72
|
)
|
|
73
73
|
info.context.event_queue.put(SpanDeleteEvent(project_ids))
|
|
74
74
|
return Query()
|
|
75
|
+
|
|
76
|
+
@strawberry.mutation(permission_classes=[IsNotReadOnly]) # type: ignore
|
|
77
|
+
async def transfer_traces_to_project(
|
|
78
|
+
self,
|
|
79
|
+
info: Info[Context, None],
|
|
80
|
+
trace_ids: list[GlobalID],
|
|
81
|
+
project_id: GlobalID,
|
|
82
|
+
) -> Query:
|
|
83
|
+
if not trace_ids:
|
|
84
|
+
raise BadRequest("Must provide at least one trace ID to transfer")
|
|
85
|
+
trace_ids = list(set(trace_ids))
|
|
86
|
+
try:
|
|
87
|
+
trace_rowids = [
|
|
88
|
+
from_global_id_with_expected_type(global_id=id, expected_type_name="Trace")
|
|
89
|
+
for id in trace_ids
|
|
90
|
+
]
|
|
91
|
+
dest_project_rowid = from_global_id_with_expected_type(
|
|
92
|
+
global_id=project_id, expected_type_name="Project"
|
|
93
|
+
)
|
|
94
|
+
except ValueError as error:
|
|
95
|
+
raise BadRequest(str(error))
|
|
96
|
+
|
|
97
|
+
async with info.context.db() as session:
|
|
98
|
+
dest_project = await session.get(models.Project, dest_project_rowid)
|
|
99
|
+
if dest_project is None:
|
|
100
|
+
raise BadRequest("Destination project does not exist")
|
|
101
|
+
|
|
102
|
+
traces = (
|
|
103
|
+
await session.scalars(select(models.Trace).where(models.Trace.id.in_(trace_rowids)))
|
|
104
|
+
).all()
|
|
105
|
+
if len(traces) < len(trace_rowids):
|
|
106
|
+
raise BadRequest("Invalid trace IDs provided")
|
|
107
|
+
|
|
108
|
+
source_project_ids = set(trace.project_rowid for trace in traces)
|
|
109
|
+
if len(source_project_ids) > 1:
|
|
110
|
+
raise BadRequest("Cannot transfer traces from multiple projects")
|
|
111
|
+
|
|
112
|
+
await session.execute(
|
|
113
|
+
update(models.Trace)
|
|
114
|
+
.where(models.Trace.id.in_(trace_rowids))
|
|
115
|
+
.values(project_rowid=dest_project_rowid)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return Query()
|
|
@@ -169,7 +169,11 @@ async def create_tokens(
|
|
|
169
169
|
error=f"OAuth2 IDP {idp_name} does not appear to support OpenID Connect.",
|
|
170
170
|
)
|
|
171
171
|
user_info = await oauth2_client.parse_id_token(token_data, nonce=stored_nonce)
|
|
172
|
-
|
|
172
|
+
try:
|
|
173
|
+
user_info = _parse_user_info(user_info)
|
|
174
|
+
except MissingEmailScope as error:
|
|
175
|
+
return _redirect_to_login(request=request, error=str(error))
|
|
176
|
+
|
|
173
177
|
try:
|
|
174
178
|
async with request.app.state.db() as session:
|
|
175
179
|
user = await _process_oauth2_user(
|
|
@@ -237,7 +241,12 @@ def _parse_user_info(user_info: dict[str, Any]) -> UserInfo:
|
|
|
237
241
|
"""
|
|
238
242
|
assert isinstance(subject := user_info.get("sub"), (str, int))
|
|
239
243
|
idp_user_id = str(subject)
|
|
240
|
-
|
|
244
|
+
email = user_info.get("email")
|
|
245
|
+
if not isinstance(email, str):
|
|
246
|
+
raise MissingEmailScope(
|
|
247
|
+
"Please ensure your OIDC provider is configured to use the 'email' scope."
|
|
248
|
+
)
|
|
249
|
+
|
|
241
250
|
assert isinstance(username := user_info.get("name"), str) or username is None
|
|
242
251
|
assert (
|
|
243
252
|
isinstance(profile_picture_url := user_info.get("picture"), str)
|
|
@@ -541,6 +550,14 @@ class NotInvited(Exception):
|
|
|
541
550
|
pass
|
|
542
551
|
|
|
543
552
|
|
|
553
|
+
class MissingEmailScope(Exception):
|
|
554
|
+
"""
|
|
555
|
+
Raised when the OIDC provider does not return the email scope.
|
|
556
|
+
"""
|
|
557
|
+
|
|
558
|
+
pass
|
|
559
|
+
|
|
560
|
+
|
|
544
561
|
def _redirect_to_login(*, request: Request, error: str) -> RedirectResponse:
|
|
545
562
|
"""
|
|
546
563
|
Creates a RedirectResponse to the login page to display an error message.
|
|
@@ -5,11 +5,8 @@ import strawberry
|
|
|
5
5
|
|
|
6
6
|
@strawberry.type
|
|
7
7
|
class CostBreakdown:
|
|
8
|
-
tokens: Optional[float] =
|
|
8
|
+
tokens: Optional[float] = strawberry.field(
|
|
9
|
+
default=None,
|
|
10
|
+
description="Total number of tokens, including tokens for which no cost was computed.",
|
|
11
|
+
)
|
|
9
12
|
cost: Optional[float] = None
|
|
10
|
-
|
|
11
|
-
@strawberry.field
|
|
12
|
-
def cost_per_token(self) -> Optional[float]:
|
|
13
|
-
if self.tokens and self.cost:
|
|
14
|
-
return self.cost / self.tokens
|
|
15
|
-
return None
|