nornweave 0.1.2__py3-none-any.whl → 0.1.4__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.
nornweave/muninn/tools.py CHANGED
@@ -95,55 +95,133 @@ async def send_email(
95
95
  async def search_email(
96
96
  client: NornWeaveClient,
97
97
  query: str,
98
- inbox_id: str,
98
+ inbox_id: str | None = None,
99
+ thread_id: str | None = None,
99
100
  limit: int = 10,
101
+ offset: int = 0,
100
102
  ) -> dict[str, Any]:
101
- """Search for emails.
103
+ """Search for emails with flexible filters.
102
104
 
103
- Find relevant messages in an inbox by query text.
105
+ Find relevant messages by query text. At least one of inbox_id or thread_id must be provided.
104
106
 
105
107
  Args:
106
108
  client: NornWeave API client.
107
- query: Search query.
108
- inbox_id: Inbox to search in.
109
+ query: Search query (searches subject, body, sender, attachment filenames).
110
+ inbox_id: Filter by inbox ID (optional).
111
+ thread_id: Filter by thread ID (optional).
109
112
  limit: Maximum number of results (default: 10).
113
+ offset: Pagination offset (default: 0).
110
114
 
111
115
  Returns:
112
- Search results with matching messages.
116
+ Search results with matching messages including expanded fields.
113
117
 
114
118
  Raises:
115
- Exception: If search fails.
119
+ Exception: If search fails or no filter provided.
116
120
  """
121
+ if inbox_id is None and thread_id is None:
122
+ raise Exception("At least one filter (inbox_id or thread_id) is required")
123
+
117
124
  try:
118
- result = await client.search_messages(
119
- query=query,
125
+ result = await client.list_messages(
120
126
  inbox_id=inbox_id,
127
+ thread_id=thread_id,
128
+ q=query,
121
129
  limit=limit,
130
+ offset=offset,
122
131
  )
123
132
 
124
- # Format results for MCP
133
+ # Format results for MCP with expanded fields
125
134
  messages = []
126
135
  for item in result.get("items", []):
127
- messages.append(
128
- {
129
- "id": item["id"],
130
- "thread_id": item["thread_id"],
131
- "content": item.get("content_clean", ""),
132
- "created_at": item.get("created_at"),
133
- }
134
- )
136
+ messages.append(_format_message_for_mcp(item))
135
137
 
136
138
  return {
137
139
  "query": query,
138
140
  "count": len(messages),
141
+ "total": result.get("total", len(messages)),
139
142
  "messages": messages,
140
143
  }
141
144
  except httpx.HTTPStatusError as e:
142
145
  if e.response.status_code == 404:
143
- raise Exception(f"Inbox '{inbox_id}' not found") from e
146
+ raise Exception("Inbox or thread not found") from e
147
+ if e.response.status_code == 422:
148
+ raise Exception("At least one filter (inbox_id or thread_id) is required") from e
144
149
  raise Exception(f"Search failed: {e.response.status_code}") from e
145
150
 
146
151
 
152
+ async def list_messages(
153
+ client: NornWeaveClient,
154
+ inbox_id: str | None = None,
155
+ thread_id: str | None = None,
156
+ limit: int = 50,
157
+ offset: int = 0,
158
+ ) -> dict[str, Any]:
159
+ """List messages with flexible filters.
160
+
161
+ List messages from an inbox or thread. At least one of inbox_id or thread_id must be provided.
162
+
163
+ Args:
164
+ client: NornWeave API client.
165
+ inbox_id: Filter by inbox ID (optional).
166
+ thread_id: Filter by thread ID (optional).
167
+ limit: Maximum number of results (default: 50).
168
+ offset: Pagination offset (default: 0).
169
+
170
+ Returns:
171
+ List of messages with expanded fields.
172
+
173
+ Raises:
174
+ Exception: If listing fails or no filter provided.
175
+ """
176
+ if inbox_id is None and thread_id is None:
177
+ raise Exception("At least one filter (inbox_id or thread_id) is required")
178
+
179
+ try:
180
+ result = await client.list_messages(
181
+ inbox_id=inbox_id,
182
+ thread_id=thread_id,
183
+ limit=limit,
184
+ offset=offset,
185
+ )
186
+
187
+ # Format results for MCP with expanded fields
188
+ messages = []
189
+ for item in result.get("items", []):
190
+ messages.append(_format_message_for_mcp(item))
191
+
192
+ return {
193
+ "count": len(messages),
194
+ "total": result.get("total", len(messages)),
195
+ "messages": messages,
196
+ }
197
+ except httpx.HTTPStatusError as e:
198
+ if e.response.status_code == 404:
199
+ raise Exception("Inbox or thread not found") from e
200
+ if e.response.status_code == 422:
201
+ raise Exception("At least one filter (inbox_id or thread_id) is required") from e
202
+ raise Exception(f"List messages failed: {e.response.status_code}") from e
203
+
204
+
205
+ def _format_message_for_mcp(item: dict[str, Any]) -> dict[str, Any]:
206
+ """Format a message response for MCP tools with all expanded fields."""
207
+ return {
208
+ "id": item["id"],
209
+ "thread_id": item["thread_id"],
210
+ "inbox_id": item["inbox_id"],
211
+ "direction": item.get("direction"),
212
+ "subject": item.get("subject"),
213
+ "from_address": item.get("from_address"),
214
+ "to_addresses": item.get("to_addresses", []),
215
+ "cc_addresses": item.get("cc_addresses"),
216
+ "text": item.get("text"),
217
+ "content_clean": item.get("content_clean", ""),
218
+ "timestamp": item.get("timestamp"),
219
+ "preview": item.get("preview"),
220
+ "labels": item.get("labels", []),
221
+ "created_at": item.get("created_at"),
222
+ }
223
+
224
+
147
225
  async def wait_for_reply(
148
226
  client: NornWeaveClient,
149
227
  thread_id: str,
@@ -205,3 +283,173 @@ async def wait_for_reply(
205
283
  "timeout": True,
206
284
  "waited_seconds": timeout_seconds,
207
285
  }
286
+
287
+
288
+ async def list_attachments(
289
+ client: NornWeaveClient,
290
+ message_id: str | None = None,
291
+ thread_id: str | None = None,
292
+ inbox_id: str | None = None,
293
+ limit: int = 100,
294
+ ) -> dict[str, Any]:
295
+ """List attachments for a message, thread, or inbox.
296
+
297
+ Retrieve metadata for attachments. Exactly one filter must be provided.
298
+
299
+ Args:
300
+ client: NornWeave API client.
301
+ message_id: Filter by message ID.
302
+ thread_id: Filter by thread ID (all messages in thread).
303
+ inbox_id: Filter by inbox ID (all messages in inbox).
304
+ limit: Maximum number of results (default: 100).
305
+
306
+ Returns:
307
+ List of attachment metadata objects.
308
+
309
+ Raises:
310
+ Exception: If filter is missing or invalid.
311
+ """
312
+ # Validate exactly one filter
313
+ filters = [f for f in [message_id, thread_id, inbox_id] if f is not None]
314
+ if len(filters) == 0:
315
+ raise Exception("Exactly one filter required: message_id, thread_id, or inbox_id")
316
+ if len(filters) > 1:
317
+ raise Exception("Only one filter allowed: message_id, thread_id, or inbox_id")
318
+
319
+ try:
320
+ result = await client.list_attachments(
321
+ message_id=message_id,
322
+ thread_id=thread_id,
323
+ inbox_id=inbox_id,
324
+ limit=limit,
325
+ )
326
+
327
+ # Format results for MCP
328
+ attachments = []
329
+ for item in result.get("items", []):
330
+ attachments.append(
331
+ {
332
+ "id": item["id"],
333
+ "message_id": item["message_id"],
334
+ "filename": item["filename"],
335
+ "content_type": item["content_type"],
336
+ "size": item["size"],
337
+ }
338
+ )
339
+
340
+ return {
341
+ "count": len(attachments),
342
+ "attachments": attachments,
343
+ }
344
+ except httpx.HTTPStatusError as e:
345
+ if e.response.status_code == 400:
346
+ raise Exception(
347
+ "Invalid filter: exactly one of message_id, thread_id, or inbox_id required"
348
+ ) from e
349
+ if e.response.status_code == 404:
350
+ raise Exception("Resource not found") from e
351
+ raise Exception(f"Failed to list attachments: {e.response.status_code}") from e
352
+
353
+
354
+ async def get_attachment_content(
355
+ client: NornWeaveClient,
356
+ attachment_id: str,
357
+ ) -> dict[str, Any]:
358
+ """Retrieve attachment content as base64.
359
+
360
+ Download the binary content of an attachment, encoded as base64.
361
+
362
+ Args:
363
+ client: NornWeave API client.
364
+ attachment_id: The attachment ID to retrieve.
365
+
366
+ Returns:
367
+ Attachment content with base64-encoded data, content_type, and filename.
368
+
369
+ Raises:
370
+ Exception: If attachment not found.
371
+ """
372
+ try:
373
+ result = await client.get_attachment_content(
374
+ attachment_id=attachment_id,
375
+ response_format="base64",
376
+ )
377
+ return {
378
+ "content": result["content"],
379
+ "content_type": result["content_type"],
380
+ "filename": result["filename"],
381
+ }
382
+ except httpx.HTTPStatusError as e:
383
+ if e.response.status_code == 404:
384
+ raise Exception(f"Attachment '{attachment_id}' not found") from e
385
+ if e.response.status_code == 401:
386
+ raise Exception("Attachment download URL expired or invalid") from e
387
+ raise Exception(f"Failed to get attachment content: {e.response.status_code}") from e
388
+
389
+
390
+ async def send_email_with_attachments(
391
+ client: NornWeaveClient,
392
+ inbox_id: str,
393
+ recipient: str,
394
+ subject: str,
395
+ body: str,
396
+ attachments: list[dict[str, str]],
397
+ thread_id: str | None = None,
398
+ ) -> dict[str, Any]:
399
+ """Send an email with attachments.
400
+
401
+ Send an email with one or more attachments. Each attachment should include
402
+ filename, content_type, and base64-encoded content.
403
+
404
+ Args:
405
+ client: NornWeave API client.
406
+ inbox_id: The inbox to send from.
407
+ recipient: Email address to send to.
408
+ subject: Email subject.
409
+ body: Markdown content for the email body.
410
+ attachments: List of attachment dicts with keys:
411
+ - filename: Name of the file
412
+ - content_type: MIME type (e.g., "application/pdf")
413
+ - content: Base64-encoded file content
414
+ thread_id: Optional thread ID for replies.
415
+
416
+ Returns:
417
+ Send response with message_id, thread_id, status.
418
+
419
+ Raises:
420
+ Exception: If sending fails or attachments are invalid.
421
+ """
422
+ # Validate attachments
423
+ if not attachments:
424
+ raise Exception(
425
+ "At least one attachment is required. Use send_email for messages without attachments."
426
+ )
427
+
428
+ for i, att in enumerate(attachments):
429
+ if not att.get("filename"):
430
+ raise Exception(f"Attachment {i} missing filename")
431
+ if not att.get("content_type"):
432
+ raise Exception(f"Attachment {i} missing content_type")
433
+ if not att.get("content"):
434
+ raise Exception(f"Attachment {i} missing content (base64)")
435
+
436
+ try:
437
+ result = await client.send_message_with_attachments(
438
+ inbox_id=inbox_id,
439
+ to=[recipient],
440
+ subject=subject,
441
+ body=body,
442
+ attachments=attachments,
443
+ reply_to_thread_id=thread_id,
444
+ )
445
+ return {
446
+ "message_id": result["id"],
447
+ "thread_id": result["thread_id"],
448
+ "status": result.get("status", "sent"),
449
+ }
450
+ except httpx.HTTPStatusError as e:
451
+ if e.response.status_code == 404:
452
+ raise Exception(f"Inbox '{inbox_id}' not found") from e
453
+ if e.response.status_code == 400:
454
+ raise Exception("Invalid attachment data (check base64 encoding)") from e
455
+ raise Exception(f"Failed to send email: {e.response.status_code}") from e
@@ -54,7 +54,7 @@ class DatabaseBlobStorage(AttachmentStorageBackend):
54
54
  self,
55
55
  attachment_id: str,
56
56
  content: bytes,
57
- _metadata: AttachmentMetadata,
57
+ metadata: AttachmentMetadata, # noqa: ARG002 - required by interface
58
58
  ) -> StorageResult:
59
59
  """
60
60
  Store attachment in database.
@@ -1,13 +1,20 @@
1
1
  """Base storage adapter with shared SQLAlchemy functionality."""
2
2
 
3
3
  import uuid
4
- from datetime import UTC, datetime, timedelta
4
+ from datetime import UTC, date, datetime, timedelta
5
5
  from typing import TYPE_CHECKING, Any
6
6
 
7
- from sqlalchemy import or_, select
7
+ from sqlalchemy import func, or_, select
8
8
 
9
9
  from nornweave.core.interfaces import StorageInterface
10
- from nornweave.urdr.orm import AttachmentORM, EventORM, InboxORM, MessageORM, ThreadORM
10
+ from nornweave.urdr.orm import (
11
+ AttachmentORM,
12
+ EventORM,
13
+ InboxORM,
14
+ LlmTokenUsageORM,
15
+ MessageORM,
16
+ ThreadORM,
17
+ )
11
18
 
12
19
  if TYPE_CHECKING:
13
20
  from sqlalchemy.ext.asyncio import AsyncSession
@@ -115,6 +122,13 @@ class BaseSQLAlchemyAdapter(StorageInterface):
115
122
  orm_thread.subject = thread.subject
116
123
  orm_thread.last_message_at = thread.last_message_at
117
124
  orm_thread.participant_hash = thread.participant_hash
125
+ if thread.timestamp is not None:
126
+ orm_thread.timestamp = thread.timestamp
127
+ orm_thread.message_count = thread.message_count
128
+ orm_thread.preview = thread.preview
129
+ orm_thread.senders = thread.senders
130
+ orm_thread.recipients = thread.recipients
131
+ orm_thread.summary = thread.summary
118
132
  await self._session.flush()
119
133
  await self._session.refresh(orm_thread)
120
134
  return orm_thread.to_pydantic()
@@ -221,6 +235,63 @@ class BaseSQLAlchemyAdapter(StorageInterface):
221
235
  result = await self._session.execute(stmt)
222
236
  return [row.to_pydantic() for row in result.scalars().all()]
223
237
 
238
+ async def search_messages_advanced(
239
+ self,
240
+ *,
241
+ inbox_id: str | None = None,
242
+ thread_id: str | None = None,
243
+ query: str | None = None,
244
+ limit: int = 50,
245
+ offset: int = 0,
246
+ ) -> tuple[list[Message], int]:
247
+ """
248
+ Search messages with flexible filters.
249
+
250
+ Returns tuple of (messages, total_count).
251
+ Override in subclass for dialect-specific search (ILIKE for Postgres).
252
+ """
253
+ # Build base query with filters
254
+ base_conditions = []
255
+ if inbox_id:
256
+ base_conditions.append(MessageORM.inbox_id == inbox_id)
257
+ if thread_id:
258
+ base_conditions.append(MessageORM.thread_id == thread_id)
259
+
260
+ # Text search condition
261
+ if query:
262
+ pattern = f"%{query}%"
263
+ # Subquery for attachment filename search
264
+ attachment_subquery = (
265
+ select(AttachmentORM.message_id)
266
+ .where(AttachmentORM.filename.like(pattern))
267
+ .distinct()
268
+ .scalar_subquery()
269
+ )
270
+ text_condition = or_(
271
+ MessageORM.subject.like(pattern),
272
+ MessageORM.content_raw.like(pattern),
273
+ MessageORM.from_address.like(pattern),
274
+ MessageORM.id.in_(attachment_subquery),
275
+ )
276
+ base_conditions.append(text_condition)
277
+
278
+ # Count query
279
+ count_stmt = select(func.count(MessageORM.id))
280
+ if base_conditions:
281
+ count_stmt = count_stmt.where(*base_conditions)
282
+ count_result = await self._session.execute(count_stmt)
283
+ total = count_result.scalar() or 0
284
+
285
+ # Data query with pagination
286
+ data_stmt = select(MessageORM)
287
+ if base_conditions:
288
+ data_stmt = data_stmt.where(*base_conditions)
289
+ data_stmt = data_stmt.order_by(MessageORM.created_at.desc()).limit(limit).offset(offset)
290
+ data_result = await self._session.execute(data_stmt)
291
+ messages = [row.to_pydantic() for row in data_result.scalars().all()]
292
+
293
+ return messages, total
294
+
224
295
  # -------------------------------------------------------------------------
225
296
  # Event methods
226
297
  # -------------------------------------------------------------------------
@@ -334,6 +405,76 @@ class BaseSQLAlchemyAdapter(StorageInterface):
334
405
  for row in result.scalars().all()
335
406
  ]
336
407
 
408
+ async def list_attachments_for_thread(
409
+ self,
410
+ thread_id: str,
411
+ *,
412
+ limit: int = 100,
413
+ offset: int = 0,
414
+ ) -> list[dict[str, Any]]:
415
+ """List attachments for all messages in a thread."""
416
+ # Join attachments with messages to filter by thread_id
417
+ stmt = (
418
+ select(AttachmentORM)
419
+ .join(MessageORM, AttachmentORM.message_id == MessageORM.id)
420
+ .where(MessageORM.thread_id == thread_id)
421
+ .order_by(AttachmentORM.created_at)
422
+ .limit(limit)
423
+ .offset(offset)
424
+ )
425
+ result = await self._session.execute(stmt)
426
+ return [
427
+ {
428
+ "id": row.id,
429
+ "message_id": row.message_id,
430
+ "filename": row.filename,
431
+ "content_type": row.content_type,
432
+ "size_bytes": row.size_bytes,
433
+ "disposition": row.disposition,
434
+ "content_id": row.content_id,
435
+ "storage_path": row.storage_path,
436
+ "storage_backend": row.storage_backend,
437
+ "content_hash": row.content_hash,
438
+ "created_at": row.created_at,
439
+ }
440
+ for row in result.scalars().all()
441
+ ]
442
+
443
+ async def list_attachments_for_inbox(
444
+ self,
445
+ inbox_id: str,
446
+ *,
447
+ limit: int = 100,
448
+ offset: int = 0,
449
+ ) -> list[dict[str, Any]]:
450
+ """List attachments for all messages in an inbox."""
451
+ # Join attachments with messages to filter by inbox_id
452
+ stmt = (
453
+ select(AttachmentORM)
454
+ .join(MessageORM, AttachmentORM.message_id == MessageORM.id)
455
+ .where(MessageORM.inbox_id == inbox_id)
456
+ .order_by(AttachmentORM.created_at.desc())
457
+ .limit(limit)
458
+ .offset(offset)
459
+ )
460
+ result = await self._session.execute(stmt)
461
+ return [
462
+ {
463
+ "id": row.id,
464
+ "message_id": row.message_id,
465
+ "filename": row.filename,
466
+ "content_type": row.content_type,
467
+ "size_bytes": row.size_bytes,
468
+ "disposition": row.disposition,
469
+ "content_id": row.content_id,
470
+ "storage_path": row.storage_path,
471
+ "storage_backend": row.storage_backend,
472
+ "content_hash": row.content_hash,
473
+ "created_at": row.created_at,
474
+ }
475
+ for row in result.scalars().all()
476
+ ]
477
+
337
478
  async def delete_attachment(self, attachment_id: str) -> bool:
338
479
  """Delete an attachment."""
339
480
  orm_attachment = await self._session.get(AttachmentORM, attachment_id)
@@ -383,3 +524,32 @@ class BaseSQLAlchemyAdapter(StorageInterface):
383
524
  result = await self._session.execute(stmt)
384
525
  orm_thread = result.scalar_one_or_none()
385
526
  return orm_thread.to_pydantic() if orm_thread else None
527
+
528
+ # -------------------------------------------------------------------------
529
+ # LLM Token Usage methods
530
+ # -------------------------------------------------------------------------
531
+ async def get_token_usage(self, usage_date: date) -> int:
532
+ """Get total tokens used for a given date."""
533
+ stmt = select(LlmTokenUsageORM.tokens_used).where(
534
+ LlmTokenUsageORM.date == usage_date,
535
+ )
536
+ result = await self._session.execute(stmt)
537
+ row = result.scalar_one_or_none()
538
+ return row if row is not None else 0
539
+
540
+ async def record_token_usage(self, usage_date: date, tokens: int) -> None:
541
+ """Record token usage for a given date (upsert: create or increment)."""
542
+ stmt = select(LlmTokenUsageORM).where(LlmTokenUsageORM.date == usage_date)
543
+ result = await self._session.execute(stmt)
544
+ usage_row = result.scalar_one_or_none()
545
+
546
+ if usage_row is None:
547
+ usage_row = LlmTokenUsageORM(
548
+ date=usage_date,
549
+ tokens_used=tokens,
550
+ )
551
+ self._session.add(usage_row)
552
+ else:
553
+ usage_row.tokens_used = usage_row.tokens_used + tokens
554
+
555
+ await self._session.flush()
@@ -0,0 +1,54 @@
1
+ """Add LLM thread summary: summary column on threads, llm_token_usage table.
2
+
3
+ Revision ID: 0003
4
+ Revises: 0002
5
+ Create Date: 2026-02-05
6
+
7
+ """
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ import sqlalchemy as sa
12
+ from alembic import op
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Sequence
16
+
17
+ # revision identifiers, used by Alembic.
18
+ revision: str = "0003"
19
+ down_revision: str | None = "0002"
20
+ branch_labels: str | Sequence[str] | None = None
21
+ depends_on: str | Sequence[str] | None = None
22
+
23
+
24
+ def upgrade() -> None:
25
+ """Add summary column to threads and create llm_token_usage table."""
26
+ # ==========================================================================
27
+ # Add summary column to threads table
28
+ # ==========================================================================
29
+ op.add_column(
30
+ "threads",
31
+ sa.Column("summary", sa.Text(), nullable=True),
32
+ )
33
+
34
+ # ==========================================================================
35
+ # Create llm_token_usage table
36
+ # ==========================================================================
37
+ op.create_table(
38
+ "llm_token_usage",
39
+ sa.Column("date", sa.Date(), nullable=False),
40
+ sa.Column("tokens_used", sa.Integer(), nullable=False, server_default="0"),
41
+ sa.Column(
42
+ "updated_at",
43
+ sa.DateTime(timezone=True),
44
+ nullable=False,
45
+ server_default=sa.func.now(),
46
+ ),
47
+ sa.PrimaryKeyConstraint("date"),
48
+ )
49
+
50
+
51
+ def downgrade() -> None:
52
+ """Remove summary column from threads and drop llm_token_usage table."""
53
+ op.drop_table("llm_token_usage")
54
+ op.drop_column("threads", "summary")
nornweave/urdr/orm.py CHANGED
@@ -1,11 +1,12 @@
1
1
  """SQLAlchemy ORM models for Urðr storage layer."""
2
2
 
3
3
  import uuid
4
- from datetime import datetime
4
+ from datetime import date, datetime
5
5
  from typing import Any
6
6
 
7
7
  from sqlalchemy import (
8
8
  JSON,
9
+ Date,
9
10
  DateTime,
10
11
  ForeignKey,
11
12
  Index,
@@ -171,6 +172,7 @@ class ThreadORM(Base):
171
172
  index=True,
172
173
  )
173
174
  preview: Mapped[str | None] = mapped_column(String(255), nullable=True)
175
+ summary: Mapped[str | None] = mapped_column(Text, nullable=True)
174
176
 
175
177
  # Stats
176
178
  last_message_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
@@ -211,6 +213,7 @@ class ThreadORM(Base):
211
213
  recipients=self.recipients or [],
212
214
  subject=self.subject,
213
215
  preview=self.preview,
216
+ summary=self.summary,
214
217
  attachments=None, # Load separately if needed
215
218
  last_message_id=self.last_message_id,
216
219
  message_count=self.message_count,
@@ -236,6 +239,7 @@ class ThreadORM(Base):
236
239
  subject=thread.subject,
237
240
  normalized_subject=thread.normalized_subject,
238
241
  preview=thread.preview,
242
+ summary=thread.summary,
239
243
  last_message_id=thread.last_message_id,
240
244
  message_count=thread.message_count,
241
245
  size=thread.size,
@@ -639,3 +643,25 @@ class EventORM(Base):
639
643
  thread_id=event.thread_id,
640
644
  message_id=event.message_id,
641
645
  )
646
+
647
+
648
+ class LlmTokenUsageORM(Base):
649
+ """Daily LLM token usage tracking."""
650
+
651
+ __tablename__ = "llm_token_usage"
652
+
653
+ date: Mapped[date] = mapped_column(
654
+ Date,
655
+ primary_key=True,
656
+ )
657
+ tokens_used: Mapped[int] = mapped_column(
658
+ Integer,
659
+ nullable=False,
660
+ default=0,
661
+ )
662
+ updated_at: Mapped[datetime] = mapped_column(
663
+ DateTime(timezone=True),
664
+ nullable=False,
665
+ server_default=func.now(),
666
+ onupdate=func.now(),
667
+ )