1"""Usage and rankings read service over persisted relay request logs.
2
3Aggregates the ``ai_relay_request_logs`` table into per-user daily usage
4and per-model rankings using only generic SQL, so the service works on
5any backend. Cost is summed as a decimal cast at the database and
6normalised to a trimmed decimal string on the way out.
7"""
8
9from __future__ import annotations
10
11from datetime import datetime, timedelta
12from decimal import Decimal
13from typing import TYPE_CHECKING
14
15from lexigram.contracts.ai.relay import (
16 RelayDailyUsage,
17 RelayModelRank,
18 RelayRequestLogEntry,
19 RelayUsageServiceProtocol,
20)
21from lexigram.primitives import clock
22
23if TYPE_CHECKING:
24 from lexigram.contracts.data import DatabaseProviderProtocol
25
26__all__ = ["RelayUsageService"]
27
28_DAILY_USAGE = """
29SELECT date(created_at) AS day,
30 sum(prompt_tokens) AS prompt_tokens,
31 sum(completion_tokens) AS completion_tokens,
32 sum(cast(cost AS decimal)) AS cost
33FROM ai_relay_request_logs
34WHERE user_id = ? AND date(created_at) >= ?
35GROUP BY date(created_at)
36ORDER BY day
37"""
38
39_MODEL_RANK = """
40SELECT model,
41 sum(completion_tokens) AS completion_tokens,
42 count(*) AS request_count,
43 sum(cast(cost AS decimal)) AS cost
44FROM ai_relay_request_logs
45WHERE date(created_at) >= ?
46GROUP BY model
47ORDER BY completion_tokens DESC
48LIMIT ?
49"""
50
51_LIST_REQUESTS = """
52SELECT request_id, user_id, token_id, endpoint_kind, model, channel_name,
53 status, created_at, prompt_tokens, completion_tokens, cost,
54 latency_ms, error_code
55FROM ai_relay_request_logs
56WHERE date(created_at) >= ?
57 AND (? IS NULL OR user_id = ?)
58 AND (? IS NULL OR token_id = ?)
59ORDER BY created_at DESC, request_id DESC
60LIMIT ? OFFSET ?
61"""
62
63
64def _cost_text(value: object) -> str:
65 """Trim a summed numeric cost to a short decimal string."""
66 magnitude = round(float(str(value or "0")), 6)
67 return str(Decimal(str(magnitude)))
68
69
70class RelayUsageService(RelayUsageServiceProtocol):
71 """Aggregate reads over ``ai_relay_request_logs``.
72
73 Args:
74 db: A connected
75 :class:`~lexigram.contracts.data.DatabaseProviderProtocol`
76 resolved from the DI container.
77 """
78
79 def __init__(self, db: DatabaseProviderProtocol) -> None:
80 self._db = db
81
82 async def daily_usage(self, user_id: str, days: int) -> list[RelayDailyUsage]:
83 """Aggregate tokens and cost per day for *user_id*.
84
85 Args:
86 user_id: The user to aggregate.
87 days: Include entries from this many days back (inclusive).
88
89 Returns:
90 Daily aggregates ordered by day ascending.
91 """
92 cutoff = (clock.now().date() - timedelta(days=days - 1)).isoformat()
93 result = await self._db.execute_query(_DAILY_USAGE, [user_id, cutoff])
94 return [
95 RelayDailyUsage(
96 day=row["day"],
97 prompt_tokens=int(row["prompt_tokens"] or 0),
98 completion_tokens=int(row["completion_tokens"] or 0),
99 cost=_cost_text(row["cost"]),
100 )
101 for row in result.rows
102 ]
103
104 async def model_rank(self, days: int, limit: int) -> list[RelayModelRank]:
105 """Rank models by completion tokens over the window.
106
107 Args:
108 days: Include entries from this many days back (inclusive).
109 limit: Maximum number of ranked models to return.
110
111 Returns:
112 Models ordered by completion tokens descending.
113 """
114 cutoff = (clock.now().date() - timedelta(days=days - 1)).isoformat()
115 result = await self._db.execute_query(_MODEL_RANK, [cutoff, limit])
116 return [
117 RelayModelRank(
118 model=row["model"],
119 completion_tokens=int(row["completion_tokens"] or 0),
120 request_count=int(row["request_count"] or 0),
121 cost=_cost_text(row["cost"]),
122 )
123 for row in result.rows
124 ]
125
126 async def list_requests(
127 self,
128 days: int,
129 page: int,
130 page_size: int,
131 *,
132 user_id: str | None = None,
133 token_id: str | None = None,
134 ) -> list[RelayRequestLogEntry]:
135 """List recent request-log entries, newest first.
136
137 Args:
138 days: Include entries from this many days back (inclusive).
139 page: One-based result page number.
140 page_size: Maximum entries per page.
141 user_id: Optional user filter; ``None`` matches all users.
142 token_id: Optional token filter; ``None`` matches all tokens.
143
144 Returns:
145 The matching entries ordered by time descending.
146 """
147 cutoff = (clock.now().date() - timedelta(days=days - 1)).isoformat()
148 result = await self._db.execute_query(
149 _LIST_REQUESTS,
150 [
151 cutoff,
152 user_id,
153 user_id,
154 token_id,
155 token_id,
156 page_size,
157 (page - 1) * page_size,
158 ],
159 )
160 return [
161 RelayRequestLogEntry(
162 request_id=row["request_id"],
163 user_id=row["user_id"],
164 token_id=row["token_id"],
165 endpoint_kind=row["endpoint_kind"],
166 model=row["model"],
167 channel_name=row["channel_name"],
168 status=row["status"],
169 created_at=datetime.fromisoformat(row["created_at"]),
170 prompt_tokens=int(row["prompt_tokens"] or 0),
171 completion_tokens=int(row["completion_tokens"] or 0),
172 cost=row["cost"],
173 latency_ms=int(row["latency_ms"] or 0),
174 error_code=row["error_code"],
175 )
176 for row in result.rows
177 ]