keble-task 2.22.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.
- keble_task/__init__.py +143 -0
- keble_task/actions.py +304 -0
- keble_task/agent/__init__.py +27 -0
- keble_task/agent/chat_provider.py +117 -0
- keble_task/agent/deps.py +38 -0
- keble_task/agent/tools/__init__.py +14 -0
- keble_task/agent/tools/mutation.py +100 -0
- keble_task/agent/tools/query.py +160 -0
- keble_task/crud.py +347 -0
- keble_task/exceptions.py +62 -0
- keble_task/main.py +2708 -0
- keble_task/schemas/__init__.py +1295 -0
- keble_task/schemas/for_agent.py +177 -0
- keble_task/task_tree.py +106 -0
- keble_task/utils.py +58 -0
- keble_task-2.22.0.dist-info/METADATA +1083 -0
- keble_task-2.22.0.dist-info/RECORD +18 -0
- keble_task-2.22.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,1295 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
from datetime import datetime, timedelta, timezone
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Annotated, Any, Optional
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
import keble_exceptions
|
|
9
|
+
from bson import ObjectId as BsonObjectId
|
|
10
|
+
from keble_helpers import (
|
|
11
|
+
Currency,
|
|
12
|
+
ExchangeRateInUsd,
|
|
13
|
+
Language,
|
|
14
|
+
Money,
|
|
15
|
+
MongoObjectBase,
|
|
16
|
+
ObjectId,
|
|
17
|
+
PydanticModelConfig,
|
|
18
|
+
SchemaBase,
|
|
19
|
+
SharingScope,
|
|
20
|
+
Status,
|
|
21
|
+
Timestamp,
|
|
22
|
+
UsageAccountingSource,
|
|
23
|
+
ensure_aware_utc,
|
|
24
|
+
utc_now,
|
|
25
|
+
)
|
|
26
|
+
from pydantic import (
|
|
27
|
+
AliasChoices,
|
|
28
|
+
BaseModel,
|
|
29
|
+
BeforeValidator,
|
|
30
|
+
Field,
|
|
31
|
+
JsonValue,
|
|
32
|
+
TypeAdapter,
|
|
33
|
+
field_validator,
|
|
34
|
+
model_validator,
|
|
35
|
+
)
|
|
36
|
+
from pydantic_ai.usage import RunUsage
|
|
37
|
+
from pydantic_core import to_jsonable_python
|
|
38
|
+
|
|
39
|
+
from ..exceptions import TaskExceptionType
|
|
40
|
+
|
|
41
|
+
def _metadata_json_fallback(value: object) -> str:
|
|
42
|
+
"""Serialize the few non-JSON-native scalars task metadata may carry.
|
|
43
|
+
|
|
44
|
+
Step by step:
|
|
45
|
+
1. accept BSON ObjectIds because callers often reference Mongo ids internally;
|
|
46
|
+
2. let `to_jsonable_python` handle native JSON, UUIDs, datetimes, and models;
|
|
47
|
+
3. reject genuinely unknown objects instead of silently stringifying runtime state.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
if isinstance(value, BsonObjectId):
|
|
51
|
+
return str(value)
|
|
52
|
+
raise ValueError(f"Unsupported task metadata type: {type(value).__name__}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _normalize_task_metadata(value: object) -> object:
|
|
56
|
+
"""Normalize Mongo-native scalars in task metadata to JSON-safe values on READ.
|
|
57
|
+
|
|
58
|
+
Why: task metadata persisted before the JSON-safe write contract stored some
|
|
59
|
+
fields (e.g. `user_id`) as native `UUID`/BSON Binary. The strict
|
|
60
|
+
`dict[str, JsonValue]` shape rejects those on read. Running the same
|
|
61
|
+
JSON-normalization the write path uses keeps historic rows readable and makes
|
|
62
|
+
the model expose a consistent JSON-safe shape regardless of how the row was
|
|
63
|
+
stored. A no-op for already-JSON metadata; non-dicts pass through so
|
|
64
|
+
`Optional`/`None` and real type errors still surface from normal validation.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
if not isinstance(value, dict):
|
|
68
|
+
return value
|
|
69
|
+
return to_jsonable_python(value, fallback=_metadata_json_fallback)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Canonical JSON-native shape for free-form task metadata (title/subtitle/etc. context).
|
|
73
|
+
# The BeforeValidator normalizes Mongo-native scalars on read so task state,
|
|
74
|
+
# relations, and costs share one strongly-typed, JSON-safe metadata contract that
|
|
75
|
+
# stays readable across historic rows instead of a bare `dict`.
|
|
76
|
+
TaskRelationMetadata = Annotated[
|
|
77
|
+
dict[str, JsonValue], BeforeValidator(_normalize_task_metadata)
|
|
78
|
+
]
|
|
79
|
+
_TASK_RELATION_METADATA_ADAPTER = TypeAdapter(TaskRelationMetadata)
|
|
80
|
+
TaskCostMetadata = Annotated[
|
|
81
|
+
dict[str, JsonValue], BeforeValidator(_normalize_task_metadata)
|
|
82
|
+
]
|
|
83
|
+
_TASK_COST_METADATA_ADAPTER = TypeAdapter(TaskCostMetadata)
|
|
84
|
+
TaskMetadata = Annotated[
|
|
85
|
+
dict[str, JsonValue], BeforeValidator(_normalize_task_metadata)
|
|
86
|
+
]
|
|
87
|
+
_MILLION = Decimal("1000000")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def build_task_relation_metadata(value: object | None) -> TaskRelationMetadata:
|
|
91
|
+
"""Convert relation metadata into the public JSON-safe metadata contract.
|
|
92
|
+
|
|
93
|
+
Step by step:
|
|
94
|
+
1. treat missing metadata as an empty object;
|
|
95
|
+
2. convert supported Python values into JSON-compatible primitives;
|
|
96
|
+
3. validate that the final value is a JSON object before persistence/events.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
if value is None:
|
|
100
|
+
return {}
|
|
101
|
+
json_value = to_jsonable_python(
|
|
102
|
+
value,
|
|
103
|
+
fallback=_metadata_json_fallback,
|
|
104
|
+
)
|
|
105
|
+
return _TASK_RELATION_METADATA_ADAPTER.validate_python(json_value)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def build_task_cost_metadata(value: object | None) -> TaskCostMetadata:
|
|
109
|
+
"""Convert task-cost metadata into the public JSON-safe metadata contract.
|
|
110
|
+
|
|
111
|
+
Step by step:
|
|
112
|
+
1. normalize missing metadata to an empty object;
|
|
113
|
+
2. convert supported Python values into JSON-compatible values;
|
|
114
|
+
3. validate that the stored metadata remains a JSON object.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
if value is None:
|
|
118
|
+
return {}
|
|
119
|
+
json_value = to_jsonable_python(
|
|
120
|
+
value,
|
|
121
|
+
fallback=_metadata_json_fallback,
|
|
122
|
+
)
|
|
123
|
+
return _TASK_COST_METADATA_ADAPTER.validate_python(json_value)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _zero_usd_money() -> Money:
|
|
127
|
+
"""Build a fresh zero-USD money value for default cost fields."""
|
|
128
|
+
|
|
129
|
+
return Money(float_money=0, currency=Currency.USD)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _money_true_amount(*, money: Money) -> Decimal:
|
|
133
|
+
"""Return the true decimal amount represented by `Money`.
|
|
134
|
+
|
|
135
|
+
Step by step:
|
|
136
|
+
1. read the existing `StdMoneyAmount` integer minor-unit value;
|
|
137
|
+
2. divide by 100 only once because `Money` stores cents;
|
|
138
|
+
3. return Decimal so token costs below one cent are preserved.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
return Decimal(money.amount.value) / Decimal("100")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _convert_money_true_amount(
|
|
145
|
+
*,
|
|
146
|
+
money: Money,
|
|
147
|
+
exchange_rates: list[ExchangeRateInUsd],
|
|
148
|
+
currency: Currency,
|
|
149
|
+
) -> Decimal:
|
|
150
|
+
"""Convert a `Money` value and expose its true decimal amount.
|
|
151
|
+
|
|
152
|
+
Step by step:
|
|
153
|
+
1. delegate currency conversion to `Money.exchange_to(...)`;
|
|
154
|
+
2. convert the returned `Money` into a Decimal amount;
|
|
155
|
+
3. keep sub-cent math available to aggregate responses.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
converted = money.exchange_to(exchange_rates=exchange_rates, currency=currency)
|
|
159
|
+
return _money_true_amount(money=converted)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# [MOVED + RENAMED] from keble-product-report, ReportStage
|
|
163
|
+
# and renamed to ReportStage -> ReportStage
|
|
164
|
+
class TaskStage(str, Enum):
|
|
165
|
+
PENDING = "PENDING"
|
|
166
|
+
PROCESSING = "PROCESSING"
|
|
167
|
+
SUCCESS = "SUCCESS"
|
|
168
|
+
FAILURE = "FAILURE"
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def allow_to_start(self):
|
|
172
|
+
return self in [
|
|
173
|
+
# start from 0
|
|
174
|
+
TaskStage.PENDING,
|
|
175
|
+
# restart
|
|
176
|
+
TaskStage.PROCESSING,
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def is_http_url(v: Any) -> bool:
|
|
181
|
+
try:
|
|
182
|
+
parsed = urlparse(str(v))
|
|
183
|
+
except (TypeError, ValueError):
|
|
184
|
+
return False
|
|
185
|
+
if parsed.scheme not in {"http", "https"}:
|
|
186
|
+
return False
|
|
187
|
+
if not parsed.netloc:
|
|
188
|
+
return False
|
|
189
|
+
return True
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# [MOVED + RENAMED] from keble-product-report, ReportBase
|
|
193
|
+
# and renamed to ReportBase -> TaskBase
|
|
194
|
+
class TaskBase(SchemaBase):
|
|
195
|
+
error: Optional[str] = None
|
|
196
|
+
exception_type: Optional[TaskExceptionType] = None
|
|
197
|
+
|
|
198
|
+
stage: TaskStage
|
|
199
|
+
|
|
200
|
+
# [RENAMED] report_type -> task_type
|
|
201
|
+
task_type: str # Type of Task, similar to keble-product-report ReportType
|
|
202
|
+
|
|
203
|
+
progress_key: Optional[str]
|
|
204
|
+
|
|
205
|
+
title: Optional[str]
|
|
206
|
+
subtitle: Optional[str]
|
|
207
|
+
image: Optional[str]
|
|
208
|
+
metadata: Optional[TaskMetadata]
|
|
209
|
+
|
|
210
|
+
# [ADDED] Canonical output/report language owned by the task framework. A task
|
|
211
|
+
# is the single source of truth for the language its handler should produce
|
|
212
|
+
# output in (report copy, localized labels, child tasks), so consumers read
|
|
213
|
+
# `task.language` instead of digging it out of domain-specific metadata blobs.
|
|
214
|
+
# Optional so legacy tasks created before this field still validate and load.
|
|
215
|
+
language: Optional[Language] = None
|
|
216
|
+
|
|
217
|
+
# [RENAMED] scope -> sharing_scope
|
|
218
|
+
sharing_scope: SharingScope = SharingScope.PRIVATE
|
|
219
|
+
# Public short id for sharing (only set when task is public)
|
|
220
|
+
public_id: Optional[str] = None
|
|
221
|
+
status: Status = Status.ACTIVE
|
|
222
|
+
|
|
223
|
+
owner: str
|
|
224
|
+
|
|
225
|
+
# processing started at what timestamp
|
|
226
|
+
started_ts: Optional[Timestamp] = None
|
|
227
|
+
success_ts: Optional[Timestamp] = None
|
|
228
|
+
failure_ts: Optional[Timestamp] = None
|
|
229
|
+
|
|
230
|
+
# [RENAMED] expected_feature_token -> expected_token
|
|
231
|
+
expected_token: int # expect to consume
|
|
232
|
+
|
|
233
|
+
# [RENAMED] consumed_feature_token -> consumed_token
|
|
234
|
+
consumed_token: int = 0 # actual consume
|
|
235
|
+
|
|
236
|
+
# [ADDED], retry mechanism
|
|
237
|
+
attempts: int = 0 # by default, attempts 0 times. Each times, it will increment further until reached maximum retried and should be marked as failure
|
|
238
|
+
timeout_mins: int = 120
|
|
239
|
+
|
|
240
|
+
# [ADDED] at 1.0.2, allow task to create subtasks
|
|
241
|
+
root_task: Optional[ObjectId] = (
|
|
242
|
+
None # which ROOT task, in otherword, the very first task in the chain, it should belongs to itself
|
|
243
|
+
)
|
|
244
|
+
parent_task: Optional[ObjectId] = (
|
|
245
|
+
None # which PARENT task, immediate parent task; In other word, which task created this subtask
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def unfinshed_timeout(self) -> bool:
|
|
250
|
+
before = utc_now() - timedelta(minutes=self.timeout_mins)
|
|
251
|
+
if self.created is None:
|
|
252
|
+
return False
|
|
253
|
+
c = ensure_aware_utc(self.created)
|
|
254
|
+
return before > c and self.stage in [TaskStage.PENDING, TaskStage.PROCESSING]
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def allow_to_retry(self) -> bool:
|
|
258
|
+
return self.attempts < 3 and self.stage.allow_to_start
|
|
259
|
+
|
|
260
|
+
@field_validator("image")
|
|
261
|
+
@classmethod
|
|
262
|
+
def validate_image_url(cls, v: Any) -> Optional[str]:
|
|
263
|
+
"""
|
|
264
|
+
Validate image URL. If it's not a valid URL, keep it as a string.
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
if v is None:
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
if not is_http_url(v):
|
|
271
|
+
raise keble_exceptions.ServerSideInvalidParams(
|
|
272
|
+
admin_note={"image": v},
|
|
273
|
+
alert_admin=True,
|
|
274
|
+
but_got="Image URL is not valid",
|
|
275
|
+
expected="Image URL is valid",
|
|
276
|
+
invalid_params="image",
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
return str(v)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class TaskUpdate(BaseModel):
|
|
283
|
+
"""Owner-editable task patch.
|
|
284
|
+
|
|
285
|
+
Only fields the owner may change post-creation belong here. Fields left
|
|
286
|
+
unset are not written (``aowner_update`` dumps with ``exclude_unset``),
|
|
287
|
+
so partial patches never clobber other columns.
|
|
288
|
+
"""
|
|
289
|
+
|
|
290
|
+
sharing_scope: Optional[SharingScope] = None
|
|
291
|
+
title: Optional[str] = None
|
|
292
|
+
# [ADDED] Owner-set thumbnail. An owner may re-image a task post-creation
|
|
293
|
+
# (e.g. attach a product photo). Optional + default None so omitting it leaves
|
|
294
|
+
# the persisted image untouched; passing None explicitly clears it.
|
|
295
|
+
image: Optional[str] = None
|
|
296
|
+
|
|
297
|
+
@field_validator("image")
|
|
298
|
+
@classmethod
|
|
299
|
+
def _validate_image(cls, v: Any) -> Optional[str]:
|
|
300
|
+
"""Reuse the SAME rule as creation (:meth:`TaskBase.validate_image_url`).
|
|
301
|
+
|
|
302
|
+
Keeping one validator is the single source of truth for what a stored task
|
|
303
|
+
thumbnail may be, so an owner edit can never introduce an image that task
|
|
304
|
+
creation would have rejected.
|
|
305
|
+
"""
|
|
306
|
+
|
|
307
|
+
return TaskBase.validate_image_url(v)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class TaskMongoObject(MongoObjectBase, TaskBase):
|
|
311
|
+
@property
|
|
312
|
+
def is_root_task(self) -> bool:
|
|
313
|
+
return self.parent_task is None
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class TaskMongoObjectExtended(TaskMongoObject):
|
|
317
|
+
childs: list["TaskMongoObjectExtended"] = Field(default_factory=list)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
TaskMongoObjectExtended.model_rebuild()
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class TaskPublicRef(BaseModel):
|
|
324
|
+
"""Lean public-task reference: id + last-updated only.
|
|
325
|
+
|
|
326
|
+
Intention: a projection-shaped read for sitemap-scale listings of PUBLIC tasks
|
|
327
|
+
(`apublic_list_indexable`) where loading the full `TaskMongoObject` (and its
|
|
328
|
+
childs / redis-backed extras) is wasteful. Carries ONLY what a sitemap `<url>`
|
|
329
|
+
needs: the task id (route param) and `updated` (lastmod).
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
model_config = PydanticModelConfig.default()
|
|
333
|
+
|
|
334
|
+
id: ObjectId = Field(
|
|
335
|
+
...,
|
|
336
|
+
validation_alias=AliasChoices("_id", "id"),
|
|
337
|
+
pattern=r"^[0-9a-fA-F]{24}$",
|
|
338
|
+
)
|
|
339
|
+
updated: Optional[datetime] = None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
class TaskRoomResolution(SchemaBase):
|
|
343
|
+
"""Resolve one requested task into its canonical root-room identity.
|
|
344
|
+
|
|
345
|
+
Step by step:
|
|
346
|
+
1. `root_task_id` is the durable workspace/session identifier;
|
|
347
|
+
2. `requested_task_id` is the task the caller asked to focus on.
|
|
348
|
+
"""
|
|
349
|
+
|
|
350
|
+
root_task_id: ObjectId
|
|
351
|
+
requested_task_id: ObjectId
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class TaskRelationType(str, Enum):
|
|
355
|
+
"""Supported non-tree lineage edge types inside one root-task workspace."""
|
|
356
|
+
|
|
357
|
+
MAPPED = "MAPPED"
|
|
358
|
+
REDUCED = "REDUCED"
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class TaskRelationBase(SchemaBase):
|
|
362
|
+
"""Extra lineage edge that does not change canonical task parentage.
|
|
363
|
+
|
|
364
|
+
Step by step:
|
|
365
|
+
1. `root_task` scopes the relation to one task workspace;
|
|
366
|
+
2. `from_task_id` records one source task that influenced another task;
|
|
367
|
+
3. `to_task_id` records the derived task;
|
|
368
|
+
4. `relation_type` describes why the edge exists.
|
|
369
|
+
"""
|
|
370
|
+
|
|
371
|
+
root_task: ObjectId
|
|
372
|
+
from_task_id: ObjectId
|
|
373
|
+
to_task_id: ObjectId
|
|
374
|
+
relation_type: TaskRelationType
|
|
375
|
+
metadata: TaskRelationMetadata = Field(default_factory=dict)
|
|
376
|
+
|
|
377
|
+
@field_validator("metadata", mode="before")
|
|
378
|
+
@classmethod
|
|
379
|
+
def _normalize_metadata(cls, value: object) -> TaskRelationMetadata:
|
|
380
|
+
"""Normalize persisted relation metadata before storing or emitting it."""
|
|
381
|
+
|
|
382
|
+
return build_task_relation_metadata(value)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class TaskRelationCreate(TaskRelationBase):
|
|
386
|
+
"""Create payload for one extra task-lineage relation row."""
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class TaskRelationUpdate(BaseModel):
|
|
390
|
+
"""Update payload for mutable relation fields only."""
|
|
391
|
+
|
|
392
|
+
metadata: Optional[TaskRelationMetadata] = None
|
|
393
|
+
|
|
394
|
+
@field_validator("metadata", mode="before")
|
|
395
|
+
@classmethod
|
|
396
|
+
def _normalize_metadata(
|
|
397
|
+
cls, value: object | None
|
|
398
|
+
) -> TaskRelationMetadata | None:
|
|
399
|
+
"""Normalize relation metadata updates while preserving omitted updates."""
|
|
400
|
+
|
|
401
|
+
if value is None:
|
|
402
|
+
return None
|
|
403
|
+
return build_task_relation_metadata(value)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class TaskRelationMongoObject(MongoObjectBase, TaskRelationBase):
|
|
407
|
+
"""Mongo representation of one extra task-lineage relation row."""
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class TaskCostTimeBucket(str, Enum):
|
|
411
|
+
"""Supported time buckets for task-cost aggregation."""
|
|
412
|
+
|
|
413
|
+
TOTAL = "TOTAL"
|
|
414
|
+
HOUR = "HOUR"
|
|
415
|
+
DAY = "DAY"
|
|
416
|
+
WEEK = "WEEK"
|
|
417
|
+
MONTH = "MONTH"
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class TaskCostAggregateGroupBy(str, Enum):
|
|
421
|
+
"""Task-cost aggregate dimensions supported by the stored row contract."""
|
|
422
|
+
|
|
423
|
+
TOTAL = "TOTAL"
|
|
424
|
+
TAG = "TAG"
|
|
425
|
+
SOURCE = "SOURCE"
|
|
426
|
+
TASK_TYPE = "TASK_TYPE"
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
class TaskCostTokenRates(BaseModel):
|
|
430
|
+
"""Per-million token price table used to price one Pydantic AI run.
|
|
431
|
+
|
|
432
|
+
Step by step:
|
|
433
|
+
1. store each `RunUsage` token field as a typed `Money` rate per million;
|
|
434
|
+
2. default missing rates to zero USD so callers can price only charged fields;
|
|
435
|
+
3. compute row costs with Decimal math so sub-cent token usage is not lost.
|
|
436
|
+
"""
|
|
437
|
+
|
|
438
|
+
model_config = PydanticModelConfig.default()
|
|
439
|
+
|
|
440
|
+
input_tokens: Money = Field(default_factory=_zero_usd_money)
|
|
441
|
+
cache_write_tokens: Money = Field(default_factory=_zero_usd_money)
|
|
442
|
+
cache_read_tokens: Money = Field(default_factory=_zero_usd_money)
|
|
443
|
+
output_tokens: Money = Field(default_factory=_zero_usd_money)
|
|
444
|
+
input_audio_tokens: Money = Field(default_factory=_zero_usd_money)
|
|
445
|
+
cache_audio_read_tokens: Money = Field(default_factory=_zero_usd_money)
|
|
446
|
+
output_audio_tokens: Money = Field(default_factory=_zero_usd_money)
|
|
447
|
+
|
|
448
|
+
def calculate_usage_cost(
|
|
449
|
+
self,
|
|
450
|
+
*,
|
|
451
|
+
run_usage: RunUsage,
|
|
452
|
+
exchange_rates: list[ExchangeRateInUsd],
|
|
453
|
+
currency: Currency,
|
|
454
|
+
) -> Decimal:
|
|
455
|
+
"""Calculate the token-only cost for one `RunUsage` payload.
|
|
456
|
+
|
|
457
|
+
Step by step:
|
|
458
|
+
1. convert every configured per-million rate into the requested currency;
|
|
459
|
+
2. multiply each converted rate by its matching token count over one million;
|
|
460
|
+
3. return the Decimal sum without rounding to cents.
|
|
461
|
+
"""
|
|
462
|
+
|
|
463
|
+
return (
|
|
464
|
+
self._calculate_token_cost(
|
|
465
|
+
tokens=run_usage.input_tokens,
|
|
466
|
+
rate=self.input_tokens,
|
|
467
|
+
exchange_rates=exchange_rates,
|
|
468
|
+
currency=currency,
|
|
469
|
+
)
|
|
470
|
+
+ self._calculate_token_cost(
|
|
471
|
+
tokens=run_usage.cache_write_tokens,
|
|
472
|
+
rate=self.cache_write_tokens,
|
|
473
|
+
exchange_rates=exchange_rates,
|
|
474
|
+
currency=currency,
|
|
475
|
+
)
|
|
476
|
+
+ self._calculate_token_cost(
|
|
477
|
+
tokens=run_usage.cache_read_tokens,
|
|
478
|
+
rate=self.cache_read_tokens,
|
|
479
|
+
exchange_rates=exchange_rates,
|
|
480
|
+
currency=currency,
|
|
481
|
+
)
|
|
482
|
+
+ self._calculate_token_cost(
|
|
483
|
+
tokens=run_usage.output_tokens,
|
|
484
|
+
rate=self.output_tokens,
|
|
485
|
+
exchange_rates=exchange_rates,
|
|
486
|
+
currency=currency,
|
|
487
|
+
)
|
|
488
|
+
+ self._calculate_token_cost(
|
|
489
|
+
tokens=run_usage.input_audio_tokens,
|
|
490
|
+
rate=self.input_audio_tokens,
|
|
491
|
+
exchange_rates=exchange_rates,
|
|
492
|
+
currency=currency,
|
|
493
|
+
)
|
|
494
|
+
+ self._calculate_token_cost(
|
|
495
|
+
tokens=run_usage.cache_audio_read_tokens,
|
|
496
|
+
rate=self.cache_audio_read_tokens,
|
|
497
|
+
exchange_rates=exchange_rates,
|
|
498
|
+
currency=currency,
|
|
499
|
+
)
|
|
500
|
+
+ self._calculate_token_cost(
|
|
501
|
+
tokens=run_usage.output_audio_tokens,
|
|
502
|
+
rate=self.output_audio_tokens,
|
|
503
|
+
exchange_rates=exchange_rates,
|
|
504
|
+
currency=currency,
|
|
505
|
+
)
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
@staticmethod
|
|
509
|
+
def _calculate_token_cost(
|
|
510
|
+
*,
|
|
511
|
+
tokens: int,
|
|
512
|
+
rate: Money,
|
|
513
|
+
exchange_rates: list[ExchangeRateInUsd],
|
|
514
|
+
currency: Currency,
|
|
515
|
+
) -> Decimal:
|
|
516
|
+
"""Calculate one token-field cost from a per-million rate.
|
|
517
|
+
|
|
518
|
+
Step by step:
|
|
519
|
+
1. convert the rate into the response currency through `Money.exchange_to`;
|
|
520
|
+
2. divide token count by one million because rates are per-million;
|
|
521
|
+
3. multiply with Decimal values to preserve sub-cent totals.
|
|
522
|
+
"""
|
|
523
|
+
|
|
524
|
+
rate_amount = _convert_money_true_amount(
|
|
525
|
+
money=rate,
|
|
526
|
+
exchange_rates=exchange_rates,
|
|
527
|
+
currency=currency,
|
|
528
|
+
)
|
|
529
|
+
return rate_amount * Decimal(tokens) / _MILLION
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
class TaskCostBase(SchemaBase):
|
|
533
|
+
"""Durable task-cost row stored separately from `TaskMongoObject`.
|
|
534
|
+
|
|
535
|
+
Step by step:
|
|
536
|
+
1. keep cost accounting in its own collection so task state remains compact;
|
|
537
|
+
2. denormalize task scope fields required by admin cost reports;
|
|
538
|
+
3. store raw `RunUsage`, token rates, extra spend, timing, retry, and metadata.
|
|
539
|
+
"""
|
|
540
|
+
|
|
541
|
+
task_id: ObjectId
|
|
542
|
+
root_task: ObjectId
|
|
543
|
+
owner: str
|
|
544
|
+
task_type: str
|
|
545
|
+
source: UsageAccountingSource | None = None
|
|
546
|
+
tags: list[str] = Field(default_factory=list)
|
|
547
|
+
run_usage: RunUsage
|
|
548
|
+
token_rates_per_million: TaskCostTokenRates = Field(
|
|
549
|
+
default_factory=TaskCostTokenRates
|
|
550
|
+
)
|
|
551
|
+
additional_cost: Money = Field(default_factory=_zero_usd_money)
|
|
552
|
+
seconds: float = Field(default=0, ge=0)
|
|
553
|
+
retry: int = Field(default=0, ge=0)
|
|
554
|
+
occurred_at: datetime = Field(default_factory=utc_now)
|
|
555
|
+
metadata: TaskCostMetadata = Field(default_factory=dict)
|
|
556
|
+
|
|
557
|
+
@field_validator("tags")
|
|
558
|
+
@classmethod
|
|
559
|
+
def _normalize_tags(cls, value: list[str]) -> list[str]:
|
|
560
|
+
"""Normalize tag filters before persistence and aggregation.
|
|
561
|
+
|
|
562
|
+
Step by step:
|
|
563
|
+
1. trim whitespace from each tag;
|
|
564
|
+
2. drop empty tags because `untagged` is derived at aggregation time;
|
|
565
|
+
3. preserve first-seen order while removing duplicates.
|
|
566
|
+
"""
|
|
567
|
+
|
|
568
|
+
tags: list[str] = []
|
|
569
|
+
seen: set[str] = set()
|
|
570
|
+
for tag in value:
|
|
571
|
+
normalized = tag.strip()
|
|
572
|
+
if not normalized:
|
|
573
|
+
continue
|
|
574
|
+
if normalized in seen:
|
|
575
|
+
continue
|
|
576
|
+
seen.add(normalized)
|
|
577
|
+
tags.append(normalized)
|
|
578
|
+
return tags
|
|
579
|
+
|
|
580
|
+
@field_validator("occurred_at")
|
|
581
|
+
@classmethod
|
|
582
|
+
def _normalize_occurred_at(cls, value: datetime) -> datetime:
|
|
583
|
+
"""Store occurrence timestamps as timezone-aware UTC datetimes."""
|
|
584
|
+
|
|
585
|
+
return ensure_aware_utc(value)
|
|
586
|
+
|
|
587
|
+
@field_validator("metadata", mode="before")
|
|
588
|
+
@classmethod
|
|
589
|
+
def _normalize_metadata(cls, value: object) -> TaskCostMetadata:
|
|
590
|
+
"""Normalize cost metadata before storing it."""
|
|
591
|
+
|
|
592
|
+
return build_task_cost_metadata(value)
|
|
593
|
+
|
|
594
|
+
@property
|
|
595
|
+
def total_tokens(self) -> int:
|
|
596
|
+
"""Return the complete token count represented by `run_usage`."""
|
|
597
|
+
|
|
598
|
+
return (
|
|
599
|
+
self.run_usage.input_tokens
|
|
600
|
+
+ self.run_usage.cache_write_tokens
|
|
601
|
+
+ self.run_usage.cache_read_tokens
|
|
602
|
+
+ self.run_usage.output_tokens
|
|
603
|
+
+ self.run_usage.input_audio_tokens
|
|
604
|
+
+ self.run_usage.cache_audio_read_tokens
|
|
605
|
+
+ self.run_usage.output_audio_tokens
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
def calculate_total_cost(
|
|
609
|
+
self,
|
|
610
|
+
*,
|
|
611
|
+
exchange_rates: list[ExchangeRateInUsd],
|
|
612
|
+
currency: Currency,
|
|
613
|
+
) -> Decimal:
|
|
614
|
+
"""Calculate this row's total cost in the requested currency.
|
|
615
|
+
|
|
616
|
+
Step by step:
|
|
617
|
+
1. calculate token spend from `RunUsage` and per-million rates;
|
|
618
|
+
2. convert additional non-token spend through the same exchange table;
|
|
619
|
+
3. return the Decimal sum without cent-rounding.
|
|
620
|
+
"""
|
|
621
|
+
|
|
622
|
+
token_cost = self.token_rates_per_million.calculate_usage_cost(
|
|
623
|
+
run_usage=self.run_usage,
|
|
624
|
+
exchange_rates=exchange_rates,
|
|
625
|
+
currency=currency,
|
|
626
|
+
)
|
|
627
|
+
additional_cost = _convert_money_true_amount(
|
|
628
|
+
money=self.additional_cost,
|
|
629
|
+
exchange_rates=exchange_rates,
|
|
630
|
+
currency=currency,
|
|
631
|
+
)
|
|
632
|
+
return token_cost + additional_cost
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
class TaskCostCreate(TaskCostBase):
|
|
636
|
+
"""Create payload for one durable task-cost row.
|
|
637
|
+
|
|
638
|
+
Step by step:
|
|
639
|
+
1. accept the stored task row as the source of owner/task-type truth;
|
|
640
|
+
2. accept the caller-resolved root task id used for workspace reports;
|
|
641
|
+
3. combine usage, rates, extra cost, timing, retry, and metadata into the
|
|
642
|
+
durable cost-row contract.
|
|
643
|
+
"""
|
|
644
|
+
|
|
645
|
+
@classmethod
|
|
646
|
+
def from_task_usage(
|
|
647
|
+
cls,
|
|
648
|
+
*,
|
|
649
|
+
task: TaskMongoObject,
|
|
650
|
+
root_task: ObjectId,
|
|
651
|
+
tags: list[str] | None,
|
|
652
|
+
run_usage: RunUsage,
|
|
653
|
+
source: UsageAccountingSource | None = None,
|
|
654
|
+
token_rates_per_million: TaskCostTokenRates | None = None,
|
|
655
|
+
additional_cost: Money | None = None,
|
|
656
|
+
seconds: float = 0,
|
|
657
|
+
retry: int = 0,
|
|
658
|
+
occurred_at: datetime | None = None,
|
|
659
|
+
metadata: TaskCostMetadata | None = None,
|
|
660
|
+
) -> "TaskCostCreate":
|
|
661
|
+
"""Build one create row from a persisted task and usage payload.
|
|
662
|
+
|
|
663
|
+
Step by step:
|
|
664
|
+
1. denormalize `owner` and `task_type` from the stored task row;
|
|
665
|
+
2. normalize omitted rates and additional spend to zero-USD values;
|
|
666
|
+
3. preserve caller-provided timing, retry, occurrence time, tags, and
|
|
667
|
+
metadata for later list and aggregate reporting.
|
|
668
|
+
"""
|
|
669
|
+
|
|
670
|
+
return cls(
|
|
671
|
+
task_id=task.id,
|
|
672
|
+
root_task=root_task,
|
|
673
|
+
owner=task.owner,
|
|
674
|
+
task_type=task.task_type,
|
|
675
|
+
source=source,
|
|
676
|
+
tags=tags or [],
|
|
677
|
+
run_usage=run_usage,
|
|
678
|
+
token_rates_per_million=token_rates_per_million or TaskCostTokenRates(),
|
|
679
|
+
additional_cost=additional_cost or Money(
|
|
680
|
+
float_money=0,
|
|
681
|
+
currency=Currency.USD,
|
|
682
|
+
),
|
|
683
|
+
seconds=seconds,
|
|
684
|
+
retry=retry,
|
|
685
|
+
occurred_at=occurred_at or utc_now(),
|
|
686
|
+
metadata=metadata or {},
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
class TaskCostMongoObject(MongoObjectBase, TaskCostBase):
|
|
691
|
+
"""Mongo representation of one task-cost row."""
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
class TaskCostFilterBase(BaseModel):
|
|
695
|
+
"""Canonical indexed filter contract for task-cost reads.
|
|
696
|
+
|
|
697
|
+
Step by step:
|
|
698
|
+
1. own every field shared by list and aggregate task-cost reads;
|
|
699
|
+
2. normalize tag and time filters once before CRUD receives them;
|
|
700
|
+
3. convert the shared contract into the Mongo filter used by indexed reads.
|
|
701
|
+
"""
|
|
702
|
+
|
|
703
|
+
model_config = PydanticModelConfig.default()
|
|
704
|
+
|
|
705
|
+
task_id: ObjectId | None = None
|
|
706
|
+
root_task: ObjectId | None = None
|
|
707
|
+
owner: str | None = None
|
|
708
|
+
task_types: list[str] | None = None
|
|
709
|
+
sources: list[UsageAccountingSource] | None = None
|
|
710
|
+
tags: list[str] | None = None
|
|
711
|
+
start_at: datetime | None = None
|
|
712
|
+
end_at: datetime | None = None
|
|
713
|
+
|
|
714
|
+
@field_validator("sources")
|
|
715
|
+
@classmethod
|
|
716
|
+
def _normalize_sources(
|
|
717
|
+
cls,
|
|
718
|
+
value: list[UsageAccountingSource] | None,
|
|
719
|
+
) -> list[UsageAccountingSource] | None:
|
|
720
|
+
"""Normalize source filters while preserving caller order."""
|
|
721
|
+
|
|
722
|
+
if value is None:
|
|
723
|
+
return None
|
|
724
|
+
sources: list[UsageAccountingSource] = []
|
|
725
|
+
seen: set[UsageAccountingSource] = set()
|
|
726
|
+
for source in value:
|
|
727
|
+
if source in seen:
|
|
728
|
+
continue
|
|
729
|
+
sources.append(source)
|
|
730
|
+
seen.add(source)
|
|
731
|
+
return sources
|
|
732
|
+
|
|
733
|
+
@field_validator("tags")
|
|
734
|
+
@classmethod
|
|
735
|
+
def _normalize_tags(cls, value: list[str] | None) -> list[str] | None:
|
|
736
|
+
"""Normalize all-tags filters while preserving omitted filters."""
|
|
737
|
+
|
|
738
|
+
if value is None:
|
|
739
|
+
return None
|
|
740
|
+
return TaskCostBase._normalize_tags(value)
|
|
741
|
+
|
|
742
|
+
@field_validator("start_at", "end_at")
|
|
743
|
+
@classmethod
|
|
744
|
+
def _normalize_time_filter(cls, value: datetime | None) -> datetime | None:
|
|
745
|
+
"""Normalize optional time filters to timezone-aware UTC datetimes."""
|
|
746
|
+
|
|
747
|
+
if value is None:
|
|
748
|
+
return None
|
|
749
|
+
return ensure_aware_utc(value)
|
|
750
|
+
|
|
751
|
+
def to_mongo_filters(self) -> dict[str, Any]:
|
|
752
|
+
"""Convert this canonical filter into an indexed Mongo query.
|
|
753
|
+
|
|
754
|
+
Step by step:
|
|
755
|
+
1. map optional equality filters onto stored task-cost fields;
|
|
756
|
+
2. apply `tags` with `$all` so every requested tag must be present;
|
|
757
|
+
3. apply `start_at <= occurred_at < end_at` for occurrence windows.
|
|
758
|
+
"""
|
|
759
|
+
|
|
760
|
+
filters: dict[str, Any] = {}
|
|
761
|
+
if self.task_id is not None:
|
|
762
|
+
filters["task_id"] = self.task_id
|
|
763
|
+
if self.root_task is not None:
|
|
764
|
+
filters["root_task"] = self.root_task
|
|
765
|
+
if self.owner is not None:
|
|
766
|
+
filters["owner"] = self.owner
|
|
767
|
+
if self.task_types is not None:
|
|
768
|
+
filters["task_type"] = {"$in": self.task_types}
|
|
769
|
+
if self.sources is not None:
|
|
770
|
+
filters["source"] = {"$in": self.sources}
|
|
771
|
+
if self.tags:
|
|
772
|
+
filters["tags"] = {"$all": self.tags}
|
|
773
|
+
occurred_at_filter: dict[str, datetime] = {}
|
|
774
|
+
if self.start_at is not None:
|
|
775
|
+
occurred_at_filter["$gte"] = self.start_at
|
|
776
|
+
if self.end_at is not None:
|
|
777
|
+
occurred_at_filter["$lt"] = self.end_at
|
|
778
|
+
if occurred_at_filter:
|
|
779
|
+
filters["occurred_at"] = occurred_at_filter
|
|
780
|
+
return filters
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
class TaskCostListRequest(TaskCostFilterBase):
|
|
784
|
+
"""Paginated task-cost list request using canonical read filters.
|
|
785
|
+
|
|
786
|
+
Step by step:
|
|
787
|
+
1. inherit every filter field from `TaskCostFilterBase`;
|
|
788
|
+
2. keep list-only pagination fields on this request schema;
|
|
789
|
+
3. leave Mongo filter construction to the shared base contract.
|
|
790
|
+
"""
|
|
791
|
+
|
|
792
|
+
model_config = PydanticModelConfig.default()
|
|
793
|
+
|
|
794
|
+
skip: int = Field(default=0, ge=0)
|
|
795
|
+
limit: int = Field(default=100, ge=1, le=1000)
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
class TaskCostListResponse(BaseModel):
|
|
799
|
+
"""Paginated task-cost list response."""
|
|
800
|
+
|
|
801
|
+
model_config = PydanticModelConfig.default()
|
|
802
|
+
|
|
803
|
+
costs: list[TaskCostMongoObject]
|
|
804
|
+
total: int
|
|
805
|
+
skip: int
|
|
806
|
+
limit: int
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
class TaskCostAggregateRequest(TaskCostFilterBase):
|
|
810
|
+
"""Task-cost aggregation request using canonical read filters.
|
|
811
|
+
|
|
812
|
+
Step by step:
|
|
813
|
+
1. inherit every filter field from `TaskCostFilterBase`;
|
|
814
|
+
2. choose one time bucket or `TOTAL` for the whole filtered period;
|
|
815
|
+
3. optionally fan each row into tag buckets for tag-level reporting.
|
|
816
|
+
"""
|
|
817
|
+
|
|
818
|
+
model_config = PydanticModelConfig.default()
|
|
819
|
+
|
|
820
|
+
bucket: TaskCostTimeBucket = TaskCostTimeBucket.TOTAL
|
|
821
|
+
group_by: TaskCostAggregateGroupBy = TaskCostAggregateGroupBy.TOTAL
|
|
822
|
+
group_by_tag: bool = False
|
|
823
|
+
currency: Currency = Currency.USD
|
|
824
|
+
|
|
825
|
+
@model_validator(mode="after")
|
|
826
|
+
def _normalize_group_by(self) -> "TaskCostAggregateRequest":
|
|
827
|
+
"""Map legacy tag grouping onto the canonical aggregate enum."""
|
|
828
|
+
|
|
829
|
+
if self.group_by_tag and self.group_by is TaskCostAggregateGroupBy.TOTAL:
|
|
830
|
+
self.group_by = TaskCostAggregateGroupBy.TAG
|
|
831
|
+
if self.group_by_tag and self.group_by is not TaskCostAggregateGroupBy.TAG:
|
|
832
|
+
raise ValueError("group_by_tag=True is only compatible with group_by=TAG")
|
|
833
|
+
self.group_by_tag = self.group_by is TaskCostAggregateGroupBy.TAG
|
|
834
|
+
return self
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
class TaskCostAggregateBucket(BaseModel):
|
|
838
|
+
"""One aggregated task-cost bucket.
|
|
839
|
+
|
|
840
|
+
Step by step:
|
|
841
|
+
1. identify the optional time and tag bucket;
|
|
842
|
+
2. expose precise Decimal cost in the requested currency;
|
|
843
|
+
3. include counts, token totals, seconds, and retries for diagnostics.
|
|
844
|
+
"""
|
|
845
|
+
|
|
846
|
+
model_config = PydanticModelConfig.default()
|
|
847
|
+
|
|
848
|
+
start_at: datetime | None = None
|
|
849
|
+
end_at: datetime | None = None
|
|
850
|
+
tag: str | None = None
|
|
851
|
+
source: UsageAccountingSource | None = None
|
|
852
|
+
task_type: str | None = None
|
|
853
|
+
currency: Currency
|
|
854
|
+
total_cost: Decimal
|
|
855
|
+
count: int
|
|
856
|
+
input_tokens: int
|
|
857
|
+
output_tokens: int
|
|
858
|
+
total_tokens: int
|
|
859
|
+
seconds: float
|
|
860
|
+
retry: int
|
|
861
|
+
|
|
862
|
+
@classmethod
|
|
863
|
+
def empty(
|
|
864
|
+
cls,
|
|
865
|
+
*,
|
|
866
|
+
start_at: datetime | None,
|
|
867
|
+
end_at: datetime | None,
|
|
868
|
+
tag: str | None,
|
|
869
|
+
source: UsageAccountingSource | None,
|
|
870
|
+
task_type: str | None,
|
|
871
|
+
currency: Currency,
|
|
872
|
+
) -> "TaskCostAggregateBucket":
|
|
873
|
+
"""Open one zeroed bucket for a (time, tag, source, task_type) key.
|
|
874
|
+
|
|
875
|
+
Step by step:
|
|
876
|
+
1. fix the bucket identity (window + optional group keys + currency);
|
|
877
|
+
2. start every running total at a typed zero so `accumulate` can fold rows;
|
|
878
|
+
3. keep bucket creation on the type that owns the aggregate contract.
|
|
879
|
+
"""
|
|
880
|
+
|
|
881
|
+
return cls(
|
|
882
|
+
start_at=start_at,
|
|
883
|
+
end_at=end_at,
|
|
884
|
+
tag=tag,
|
|
885
|
+
source=source,
|
|
886
|
+
task_type=task_type,
|
|
887
|
+
currency=currency,
|
|
888
|
+
total_cost=Decimal("0"),
|
|
889
|
+
count=0,
|
|
890
|
+
input_tokens=0,
|
|
891
|
+
output_tokens=0,
|
|
892
|
+
total_tokens=0,
|
|
893
|
+
seconds=0.0,
|
|
894
|
+
retry=0,
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
def accumulate(self, *, cost: "TaskCostBase", row_cost: Decimal) -> None:
|
|
898
|
+
"""Fold one already-converted cost row into this bucket's running totals.
|
|
899
|
+
|
|
900
|
+
Step by step:
|
|
901
|
+
1. add the precomputed `row_cost` (already in the bucket currency);
|
|
902
|
+
2. advance counts, token totals, seconds, and retries from the row;
|
|
903
|
+
3. mutate in place so the bucket stays the single source of bucket math.
|
|
904
|
+
"""
|
|
905
|
+
|
|
906
|
+
self.total_cost = self.total_cost + row_cost
|
|
907
|
+
self.count = self.count + 1
|
|
908
|
+
self.input_tokens = self.input_tokens + cost.run_usage.input_tokens
|
|
909
|
+
self.output_tokens = self.output_tokens + cost.run_usage.output_tokens
|
|
910
|
+
self.total_tokens = self.total_tokens + cost.total_tokens
|
|
911
|
+
self.seconds = self.seconds + cost.seconds
|
|
912
|
+
self.retry = self.retry + cost.retry
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
class TaskCostAggregateResponse(BaseModel):
|
|
916
|
+
"""Task-cost aggregate response for admin reporting.
|
|
917
|
+
|
|
918
|
+
Step by step:
|
|
919
|
+
1. receive already-filtered task-cost rows from CRUD;
|
|
920
|
+
2. calculate row spend in the requested currency;
|
|
921
|
+
3. group rows by requested time bucket and optional tag bucket.
|
|
922
|
+
"""
|
|
923
|
+
|
|
924
|
+
model_config = PydanticModelConfig.default()
|
|
925
|
+
|
|
926
|
+
bucket: TaskCostTimeBucket
|
|
927
|
+
group_by: TaskCostAggregateGroupBy
|
|
928
|
+
group_by_tag: bool
|
|
929
|
+
currency: Currency
|
|
930
|
+
buckets: list[TaskCostAggregateBucket]
|
|
931
|
+
|
|
932
|
+
@classmethod
|
|
933
|
+
def build(
|
|
934
|
+
cls,
|
|
935
|
+
*,
|
|
936
|
+
costs: Sequence[TaskCostBase],
|
|
937
|
+
query: TaskCostAggregateRequest,
|
|
938
|
+
exchange_rates: list[ExchangeRateInUsd],
|
|
939
|
+
) -> "TaskCostAggregateResponse":
|
|
940
|
+
"""Aggregate cost rows into the public admin response contract.
|
|
941
|
+
|
|
942
|
+
Step by step:
|
|
943
|
+
1. resolve the time and tag bucket keys for every row;
|
|
944
|
+
2. fold precise Decimal cost, counts, tokens, seconds, and retries into
|
|
945
|
+
the typed `TaskCostAggregateBucket` that owns the bucket math;
|
|
946
|
+
3. sort the typed buckets deterministically for stable API responses.
|
|
947
|
+
|
|
948
|
+
`costs` is a read-only `Sequence` so `list[TaskCostMongoObject]` from CRUD
|
|
949
|
+
is accepted without invariance errors (build never mutates the input list).
|
|
950
|
+
"""
|
|
951
|
+
|
|
952
|
+
bucket_data: dict[
|
|
953
|
+
tuple[
|
|
954
|
+
datetime | None,
|
|
955
|
+
str | None,
|
|
956
|
+
UsageAccountingSource | None,
|
|
957
|
+
str | None,
|
|
958
|
+
],
|
|
959
|
+
TaskCostAggregateBucket,
|
|
960
|
+
] = {}
|
|
961
|
+
for cost in costs:
|
|
962
|
+
start_at = cls._bucket_start(
|
|
963
|
+
occurred_at=cost.occurred_at,
|
|
964
|
+
bucket=query.bucket,
|
|
965
|
+
)
|
|
966
|
+
row_cost = cost.calculate_total_cost(
|
|
967
|
+
exchange_rates=exchange_rates,
|
|
968
|
+
currency=query.currency,
|
|
969
|
+
)
|
|
970
|
+
for tag, source, task_type in cls._aggregate_group_values(
|
|
971
|
+
cost=cost,
|
|
972
|
+
group_by=query.group_by,
|
|
973
|
+
):
|
|
974
|
+
key = (start_at, tag, source, task_type)
|
|
975
|
+
if key not in bucket_data:
|
|
976
|
+
bucket_data[key] = TaskCostAggregateBucket.empty(
|
|
977
|
+
start_at=start_at,
|
|
978
|
+
end_at=cls._bucket_end(
|
|
979
|
+
start_at=start_at,
|
|
980
|
+
bucket=query.bucket,
|
|
981
|
+
),
|
|
982
|
+
tag=tag,
|
|
983
|
+
source=source,
|
|
984
|
+
task_type=task_type,
|
|
985
|
+
currency=query.currency,
|
|
986
|
+
)
|
|
987
|
+
bucket_data[key].accumulate(cost=cost, row_cost=row_cost)
|
|
988
|
+
return cls(
|
|
989
|
+
bucket=query.bucket,
|
|
990
|
+
group_by=query.group_by,
|
|
991
|
+
group_by_tag=query.group_by_tag,
|
|
992
|
+
currency=query.currency,
|
|
993
|
+
buckets=[
|
|
994
|
+
bucket
|
|
995
|
+
for _, bucket in sorted(
|
|
996
|
+
bucket_data.items(),
|
|
997
|
+
key=lambda item: cls._bucket_sort_key(key=item[0]),
|
|
998
|
+
)
|
|
999
|
+
],
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
@staticmethod
|
|
1003
|
+
def _bucket_start(
|
|
1004
|
+
*,
|
|
1005
|
+
occurred_at: datetime,
|
|
1006
|
+
bucket: TaskCostTimeBucket,
|
|
1007
|
+
) -> datetime | None:
|
|
1008
|
+
"""Resolve the inclusive start timestamp for one aggregate bucket."""
|
|
1009
|
+
|
|
1010
|
+
if bucket is TaskCostTimeBucket.TOTAL:
|
|
1011
|
+
return None
|
|
1012
|
+
if bucket is TaskCostTimeBucket.HOUR:
|
|
1013
|
+
return occurred_at.replace(minute=0, second=0, microsecond=0)
|
|
1014
|
+
if bucket is TaskCostTimeBucket.DAY:
|
|
1015
|
+
return occurred_at.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
1016
|
+
if bucket is TaskCostTimeBucket.WEEK:
|
|
1017
|
+
day_start = occurred_at.replace(
|
|
1018
|
+
hour=0,
|
|
1019
|
+
minute=0,
|
|
1020
|
+
second=0,
|
|
1021
|
+
microsecond=0,
|
|
1022
|
+
)
|
|
1023
|
+
return day_start - timedelta(days=day_start.weekday())
|
|
1024
|
+
return occurred_at.replace(
|
|
1025
|
+
day=1,
|
|
1026
|
+
hour=0,
|
|
1027
|
+
minute=0,
|
|
1028
|
+
second=0,
|
|
1029
|
+
microsecond=0,
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
@staticmethod
|
|
1033
|
+
def _bucket_end(
|
|
1034
|
+
*,
|
|
1035
|
+
start_at: datetime | None,
|
|
1036
|
+
bucket: TaskCostTimeBucket,
|
|
1037
|
+
) -> datetime | None:
|
|
1038
|
+
"""Resolve the exclusive end timestamp for one aggregate bucket."""
|
|
1039
|
+
|
|
1040
|
+
if start_at is None:
|
|
1041
|
+
return None
|
|
1042
|
+
if bucket is TaskCostTimeBucket.HOUR:
|
|
1043
|
+
return start_at + timedelta(hours=1)
|
|
1044
|
+
if bucket is TaskCostTimeBucket.DAY:
|
|
1045
|
+
return start_at + timedelta(days=1)
|
|
1046
|
+
if bucket is TaskCostTimeBucket.WEEK:
|
|
1047
|
+
return start_at + timedelta(days=7)
|
|
1048
|
+
if start_at.month == 12:
|
|
1049
|
+
return start_at.replace(year=start_at.year + 1, month=1)
|
|
1050
|
+
return start_at.replace(month=start_at.month + 1)
|
|
1051
|
+
|
|
1052
|
+
@staticmethod
|
|
1053
|
+
def _aggregate_group_values(
|
|
1054
|
+
*,
|
|
1055
|
+
cost: TaskCostBase,
|
|
1056
|
+
group_by: TaskCostAggregateGroupBy,
|
|
1057
|
+
) -> list[tuple[str | None, UsageAccountingSource | None, str | None]]:
|
|
1058
|
+
"""Resolve the aggregate grouping values for one cost row."""
|
|
1059
|
+
|
|
1060
|
+
if group_by is TaskCostAggregateGroupBy.TAG:
|
|
1061
|
+
if len(cost.tags) == 0:
|
|
1062
|
+
return [("untagged", None, None)]
|
|
1063
|
+
return [(tag, None, None) for tag in cost.tags]
|
|
1064
|
+
if group_by is TaskCostAggregateGroupBy.SOURCE:
|
|
1065
|
+
return [(None, cost.source, None)]
|
|
1066
|
+
if group_by is TaskCostAggregateGroupBy.TASK_TYPE:
|
|
1067
|
+
return [(None, None, cost.task_type)]
|
|
1068
|
+
return [(None, None, None)]
|
|
1069
|
+
|
|
1070
|
+
@staticmethod
|
|
1071
|
+
def _bucket_sort_key(
|
|
1072
|
+
*,
|
|
1073
|
+
key: tuple[
|
|
1074
|
+
datetime | None,
|
|
1075
|
+
str | None,
|
|
1076
|
+
UsageAccountingSource | None,
|
|
1077
|
+
str | None,
|
|
1078
|
+
],
|
|
1079
|
+
) -> tuple[datetime, str, str, str]:
|
|
1080
|
+
"""Build a stable sort key for aggregate response buckets."""
|
|
1081
|
+
|
|
1082
|
+
start_at, tag, source, task_type = key
|
|
1083
|
+
normalized_start_at = start_at or datetime.min.replace(tzinfo=timezone.utc)
|
|
1084
|
+
return normalized_start_at, tag or "", source.value if source else "", task_type or ""
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def _flatten_task_room_graph_tasks(
|
|
1088
|
+
*, task: TaskMongoObjectExtended
|
|
1089
|
+
) -> list["TaskRoomGraphTask"]:
|
|
1090
|
+
"""Flatten one task tree into prompt-safe room graph task nodes.
|
|
1091
|
+
|
|
1092
|
+
Step by step:
|
|
1093
|
+
1. convert the current task row into the compact graph shape;
|
|
1094
|
+
2. recurse through persisted child task rows in stored order;
|
|
1095
|
+
3. return a stable pre-order list so prompts read parent before child.
|
|
1096
|
+
"""
|
|
1097
|
+
|
|
1098
|
+
flattened = [
|
|
1099
|
+
TaskRoomGraphTask(
|
|
1100
|
+
task_id=task.id,
|
|
1101
|
+
parent_task_id=task.parent_task,
|
|
1102
|
+
task_type=task.task_type,
|
|
1103
|
+
title=task.title,
|
|
1104
|
+
subtitle=task.subtitle,
|
|
1105
|
+
)
|
|
1106
|
+
]
|
|
1107
|
+
for child in task.childs:
|
|
1108
|
+
flattened.extend(_flatten_task_room_graph_tasks(task=child))
|
|
1109
|
+
return flattened
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
def _summarize_task_relation_metadata(
|
|
1113
|
+
*, metadata: dict[str, Any]
|
|
1114
|
+
) -> dict[str, str]:
|
|
1115
|
+
"""Convert relation metadata into compact scalar prompt strings.
|
|
1116
|
+
|
|
1117
|
+
Step by step:
|
|
1118
|
+
1. keep simple scalar values because they are useful for agent routing;
|
|
1119
|
+
2. summarize nested values by shape instead of dumping raw payloads;
|
|
1120
|
+
3. truncate long scalar text so relation lines stay compact.
|
|
1121
|
+
"""
|
|
1122
|
+
|
|
1123
|
+
summary: dict[str, str] = {}
|
|
1124
|
+
for key, value in metadata.items():
|
|
1125
|
+
if isinstance(value, dict):
|
|
1126
|
+
summary[key] = f"dict({len(value)} keys)"
|
|
1127
|
+
continue
|
|
1128
|
+
if isinstance(value, list):
|
|
1129
|
+
summary[key] = f"list({len(value)} items)"
|
|
1130
|
+
continue
|
|
1131
|
+
text = "null" if value is None else str(value)
|
|
1132
|
+
summary[key] = text[:117] + "..." if len(text) > 120 else text
|
|
1133
|
+
return summary
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def _format_task_room_metadata_summary(*, metadata: dict[str, str]) -> str:
|
|
1137
|
+
"""Render compact relation metadata in stable key order.
|
|
1138
|
+
|
|
1139
|
+
Step by step:
|
|
1140
|
+
1. return a fixed empty marker when no metadata exists;
|
|
1141
|
+
2. sort metadata keys so prompt diffs remain stable;
|
|
1142
|
+
3. join key/value pairs into one relation-line suffix.
|
|
1143
|
+
"""
|
|
1144
|
+
|
|
1145
|
+
if len(metadata) == 0:
|
|
1146
|
+
return "{}"
|
|
1147
|
+
return "{" + ", ".join(f"{key}={metadata[key]}" for key in sorted(metadata)) + "}"
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
class TaskRoomGraphTask(SchemaBase):
|
|
1151
|
+
"""Compact task node used to explain one root-task room to agents."""
|
|
1152
|
+
|
|
1153
|
+
task_id: ObjectId
|
|
1154
|
+
parent_task_id: ObjectId | None = None
|
|
1155
|
+
task_type: str
|
|
1156
|
+
title: str | None = None
|
|
1157
|
+
subtitle: str | None = None
|
|
1158
|
+
|
|
1159
|
+
def to_prompt_line(self) -> str:
|
|
1160
|
+
"""Render one task node as a compact, stable prompt line.
|
|
1161
|
+
|
|
1162
|
+
Step by step:
|
|
1163
|
+
1. expose ids and task type for deterministic agent references;
|
|
1164
|
+
2. render the parent edge inline so tree structure is visible;
|
|
1165
|
+
3. include only title/subtitle display text when present.
|
|
1166
|
+
"""
|
|
1167
|
+
|
|
1168
|
+
parent_text = str(self.parent_task_id) if self.parent_task_id else "ROOT"
|
|
1169
|
+
parts = [
|
|
1170
|
+
f"- task {self.task_id}",
|
|
1171
|
+
f"type={self.task_type}",
|
|
1172
|
+
f"parent={parent_text}",
|
|
1173
|
+
]
|
|
1174
|
+
if self.title:
|
|
1175
|
+
parts.append(f"title={self.title}")
|
|
1176
|
+
if self.subtitle:
|
|
1177
|
+
parts.append(f"subtitle={self.subtitle}")
|
|
1178
|
+
return " | ".join(parts)
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
class TaskRoomGraphRelation(SchemaBase):
|
|
1182
|
+
"""Compact non-tree relation edge inside one root-task room."""
|
|
1183
|
+
|
|
1184
|
+
from_task_id: ObjectId
|
|
1185
|
+
to_task_id: ObjectId
|
|
1186
|
+
relation_type: TaskRelationType
|
|
1187
|
+
metadata_summary: dict[str, str] = Field(default_factory=dict)
|
|
1188
|
+
|
|
1189
|
+
@classmethod
|
|
1190
|
+
def build(cls, *, relation: TaskRelationMongoObject) -> "TaskRoomGraphRelation":
|
|
1191
|
+
"""Build one prompt-safe relation edge from the persisted relation row.
|
|
1192
|
+
|
|
1193
|
+
Step by step:
|
|
1194
|
+
1. copy stable endpoint ids and relation type unchanged;
|
|
1195
|
+
2. reduce arbitrary metadata to compact scalar strings;
|
|
1196
|
+
3. return a feature-agnostic edge with no grid or positioning knowledge.
|
|
1197
|
+
"""
|
|
1198
|
+
|
|
1199
|
+
return cls(
|
|
1200
|
+
from_task_id=relation.from_task_id,
|
|
1201
|
+
to_task_id=relation.to_task_id,
|
|
1202
|
+
relation_type=relation.relation_type,
|
|
1203
|
+
metadata_summary=_summarize_task_relation_metadata(
|
|
1204
|
+
metadata=relation.metadata
|
|
1205
|
+
),
|
|
1206
|
+
)
|
|
1207
|
+
|
|
1208
|
+
def to_prompt_line(self) -> str:
|
|
1209
|
+
"""Render one relation edge as a compact, stable prompt line.
|
|
1210
|
+
|
|
1211
|
+
Step by step:
|
|
1212
|
+
1. expose relation type and task endpoint ids;
|
|
1213
|
+
2. append reduced metadata in sorted order;
|
|
1214
|
+
3. keep the output single-line for token-efficient room context.
|
|
1215
|
+
"""
|
|
1216
|
+
|
|
1217
|
+
metadata_text = _format_task_room_metadata_summary(
|
|
1218
|
+
metadata=self.metadata_summary
|
|
1219
|
+
)
|
|
1220
|
+
return (
|
|
1221
|
+
f"- relation {self.relation_type.value}: "
|
|
1222
|
+
f"{self.from_task_id} -> {self.to_task_id} | metadata={metadata_text}"
|
|
1223
|
+
)
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
class TaskRoomGraphContext(SchemaBase):
|
|
1227
|
+
"""Generic task-room graph context; feature packages may enrich it externally."""
|
|
1228
|
+
|
|
1229
|
+
root_task_id: ObjectId
|
|
1230
|
+
focused_task_id: ObjectId
|
|
1231
|
+
tasks: list[TaskRoomGraphTask]
|
|
1232
|
+
relations: list[TaskRoomGraphRelation]
|
|
1233
|
+
|
|
1234
|
+
@classmethod
|
|
1235
|
+
def build(
|
|
1236
|
+
cls,
|
|
1237
|
+
*,
|
|
1238
|
+
root_task: TaskMongoObjectExtended,
|
|
1239
|
+
focused_task_id: ObjectId,
|
|
1240
|
+
relations: list[TaskRelationMongoObject],
|
|
1241
|
+
) -> "TaskRoomGraphContext":
|
|
1242
|
+
"""Build generic graph context from one loaded task tree and relation rows.
|
|
1243
|
+
|
|
1244
|
+
Step by step:
|
|
1245
|
+
1. flatten the loaded root task tree into prompt-safe task nodes;
|
|
1246
|
+
2. convert relation rows into compact relation edges;
|
|
1247
|
+
3. validate that the focused task is present in the same root room.
|
|
1248
|
+
"""
|
|
1249
|
+
|
|
1250
|
+
tasks = _flatten_task_room_graph_tasks(task=root_task)
|
|
1251
|
+
task_ids = {task.task_id for task in tasks}
|
|
1252
|
+
if focused_task_id not in task_ids:
|
|
1253
|
+
raise keble_exceptions.ServerSideInvalidParams(
|
|
1254
|
+
admin_note={
|
|
1255
|
+
"root_task_id": root_task.id,
|
|
1256
|
+
"focused_task_id": focused_task_id,
|
|
1257
|
+
},
|
|
1258
|
+
alert_admin=True,
|
|
1259
|
+
but_got="focused task outside loaded root room",
|
|
1260
|
+
expected="focused task belongs to loaded root room",
|
|
1261
|
+
invalid_params="focused_task_id",
|
|
1262
|
+
)
|
|
1263
|
+
return cls(
|
|
1264
|
+
root_task_id=root_task.id,
|
|
1265
|
+
focused_task_id=focused_task_id,
|
|
1266
|
+
tasks=tasks,
|
|
1267
|
+
relations=[
|
|
1268
|
+
TaskRoomGraphRelation.build(relation=relation)
|
|
1269
|
+
for relation in relations
|
|
1270
|
+
],
|
|
1271
|
+
)
|
|
1272
|
+
|
|
1273
|
+
def to_prompt_text(self) -> str:
|
|
1274
|
+
"""Render the generic task-room graph as compact Markdown-like text.
|
|
1275
|
+
|
|
1276
|
+
Step by step:
|
|
1277
|
+
1. identify the durable root room and current focus;
|
|
1278
|
+
2. render task nodes before sidecar relation edges;
|
|
1279
|
+
3. avoid feature-specific grid or positioning fields by design.
|
|
1280
|
+
"""
|
|
1281
|
+
|
|
1282
|
+
lines = [
|
|
1283
|
+
"Task Room Graph Context",
|
|
1284
|
+
f"Root task: {self.root_task_id}",
|
|
1285
|
+
f"Focused task: {self.focused_task_id}",
|
|
1286
|
+
"",
|
|
1287
|
+
"Tasks:",
|
|
1288
|
+
]
|
|
1289
|
+
lines.extend(task.to_prompt_line() for task in self.tasks)
|
|
1290
|
+
lines.extend(["", "Relations:"])
|
|
1291
|
+
if len(self.relations) == 0:
|
|
1292
|
+
lines.append("- none")
|
|
1293
|
+
else:
|
|
1294
|
+
lines.extend(relation.to_prompt_line() for relation in self.relations)
|
|
1295
|
+
return "\n".join(lines)
|