Skip to content

Commit 950d8a1

Browse files
authored
FG-185 Add statistic usage api (#55)
1 parent 94a2586 commit 950d8a1

File tree

4 files changed

+269
-0
lines changed

4 files changed

+269
-0
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,6 @@ venv.bak/
5656
# Performance test results
5757
tests/performance/results/
5858
.coverage*
59+
60+
# Local test script
61+
tools/local_test_script.py

app/api/routes/statistic.py

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
from fastapi import APIRouter, Depends, Query
2+
from sqlalchemy.ext.asyncio import AsyncSession
3+
from sqlalchemy import select, desc, func
4+
from sqlalchemy.sql.functions import coalesce
5+
from datetime import datetime, timedelta, UTC
6+
from enum import StrEnum
7+
8+
from app.api.dependencies import get_async_db, get_current_active_user
9+
from app.models.user import User
10+
from app.models.usage_tracker import UsageTracker
11+
from app.models.provider_key import ProviderKey
12+
from app.models.forge_api_key import ForgeApiKey
13+
from app.api.schemas.statistic import (
14+
UsageRealtimeResponse,
15+
UsageSummaryResponse,
16+
ForgeKeyUsageSummaryResponse,
17+
)
18+
19+
router = APIRouter()
20+
21+
22+
# I want a query parameter called "offset: <int>" and "limit: <int>"
23+
@router.get("/usage/realtime", response_model=list[UsageRealtimeResponse])
24+
async def get_usage_realtime(
25+
current_user: User = Depends(get_current_active_user),
26+
db: AsyncSession = Depends(get_async_db),
27+
offset: int = Query(0, ge=0),
28+
limit: int = Query(10, ge=1),
29+
):
30+
"""
31+
Get real-time usage statistics for the current user up to the last 7 days.
32+
"""
33+
# Calculate the date 7 days ago
34+
seven_days_ago = datetime.now(UTC) - timedelta(days=7)
35+
36+
# Build the query
37+
query = (
38+
select(
39+
UsageTracker.created_at.label("timestamp"),
40+
coalesce(ForgeApiKey.name, ForgeApiKey.key).label("forge_key"),
41+
ProviderKey.provider_name.label("provider_name"),
42+
UsageTracker.model.label("model_name"),
43+
(UsageTracker.input_tokens + UsageTracker.output_tokens).label("tokens"),
44+
func.extract(
45+
"epoch", UsageTracker.updated_at - UsageTracker.created_at
46+
).label("duration"),
47+
)
48+
.join(ProviderKey, UsageTracker.provider_key_id == ProviderKey.id)
49+
.join(ForgeApiKey, UsageTracker.forge_key_id == ForgeApiKey.id)
50+
.where(
51+
UsageTracker.user_id == current_user.id,
52+
UsageTracker.created_at >= seven_days_ago,
53+
)
54+
.order_by(desc(UsageTracker.created_at))
55+
.offset(offset)
56+
.limit(limit)
57+
)
58+
59+
# Execute the query
60+
result = await db.execute(query)
61+
rows = result.fetchall()
62+
63+
# Convert to list of dictionaries
64+
usage_stats = []
65+
for row in rows:
66+
usage_stats.append(
67+
{
68+
"timestamp": row.timestamp,
69+
"forge_key": row.forge_key,
70+
"provider_name": row.provider_name,
71+
"model_name": row.model_name,
72+
"tokens": row.tokens,
73+
"duration": round(float(row.duration), 2)
74+
if row.duration is not None
75+
else 0.0,
76+
}
77+
)
78+
print(usage_stats)
79+
80+
return [UsageRealtimeResponse(**usage_stat) for usage_stat in usage_stats]
81+
82+
83+
class UsageSummaryTimeSpan(StrEnum):
84+
day = "day"
85+
week = "week"
86+
month = "month"
87+
88+
89+
@router.get("/usage/summary", response_model=list[UsageSummaryResponse])
90+
async def get_usage_summary(
91+
current_user: User = Depends(get_current_active_user),
92+
db: AsyncSession = Depends(get_async_db),
93+
span: UsageSummaryTimeSpan = Query(UsageSummaryTimeSpan.week),
94+
):
95+
"""
96+
Get usage summary for the current user for the past day/week/month
97+
"""
98+
start_time = None
99+
if span == UsageSummaryTimeSpan.day:
100+
start_time = datetime.now(UTC) - timedelta(days=1)
101+
elif span == UsageSummaryTimeSpan.week:
102+
start_time = datetime.now(UTC) - timedelta(weeks=1)
103+
elif span == UsageSummaryTimeSpan.month:
104+
start_time = datetime.now(UTC) - timedelta(days=30)
105+
106+
# Build the query based on time span
107+
if span == UsageSummaryTimeSpan.day:
108+
# For daily span, group by hour
109+
time_group = func.date_trunc("hour", UsageTracker.created_at)
110+
else:
111+
# For weekly/monthly span, group by day
112+
time_group = func.date_trunc("day", UsageTracker.created_at)
113+
114+
query = (
115+
select(
116+
time_group.label("time_point"),
117+
coalesce(ForgeApiKey.name, ForgeApiKey.key).label("forge_key"),
118+
func.sum(UsageTracker.input_tokens + UsageTracker.output_tokens).label(
119+
"tokens"
120+
),
121+
)
122+
.join(ForgeApiKey, UsageTracker.forge_key_id == ForgeApiKey.id)
123+
.where(
124+
UsageTracker.user_id == current_user.id,
125+
UsageTracker.created_at >= start_time,
126+
)
127+
.group_by(time_group, ForgeApiKey.name, ForgeApiKey.key)
128+
.order_by(time_group, desc("tokens"), "forge_key")
129+
)
130+
131+
# Execute the query
132+
result = await db.execute(query)
133+
rows = result.fetchall()
134+
135+
data_points = dict()
136+
for row in rows:
137+
if row.time_point not in data_points:
138+
data_points[row.time_point] = {"breakdown": [], "total_tokens": 0}
139+
data_points[row.time_point]["breakdown"].append(
140+
{"forge_key": row.forge_key, "tokens": row.tokens}
141+
)
142+
data_points[row.time_point]["total_tokens"] += row.tokens
143+
144+
return [
145+
UsageSummaryResponse(
146+
time_point=time_point,
147+
breakdown=data_point["breakdown"],
148+
total_tokens=data_point["total_tokens"],
149+
)
150+
for time_point, data_point in data_points.items()
151+
]
152+
153+
154+
class ForgeKeyUsageTimeSpan(StrEnum):
155+
day = "day"
156+
week = "week"
157+
month = "month"
158+
year = "year"
159+
all = "all"
160+
161+
162+
@router.get("/forge-key/usage", response_model=list[ForgeKeyUsageSummaryResponse])
163+
async def get_forge_key_usage(
164+
current_user: User = Depends(get_current_active_user),
165+
db: AsyncSession = Depends(get_async_db),
166+
span: ForgeKeyUsageTimeSpan = Query(ForgeKeyUsageTimeSpan.week),
167+
):
168+
"""
169+
Get usage summary for all the forge keys for the past day/week/month/year/all
170+
"""
171+
start_time = None
172+
if span == ForgeKeyUsageTimeSpan.day:
173+
start_time = datetime.now(UTC) - timedelta(days=1)
174+
elif span == ForgeKeyUsageTimeSpan.week:
175+
start_time = datetime.now(UTC) - timedelta(weeks=1)
176+
elif span == ForgeKeyUsageTimeSpan.month:
177+
start_time = datetime.now(UTC) - timedelta(days=30)
178+
elif span == ForgeKeyUsageTimeSpan.year:
179+
start_time = datetime.now(UTC) - timedelta(days=365)
180+
181+
query = (
182+
select(
183+
coalesce(ForgeApiKey.name, ForgeApiKey.key).label("forge_key"),
184+
func.sum(UsageTracker.input_tokens + UsageTracker.output_tokens).label(
185+
"tokens"
186+
),
187+
)
188+
.join(ForgeApiKey, UsageTracker.forge_key_id == ForgeApiKey.id)
189+
.where(
190+
UsageTracker.user_id == current_user.id,
191+
start_time is None or UsageTracker.created_at >= start_time,
192+
)
193+
.group_by(ForgeApiKey.name, ForgeApiKey.key)
194+
.order_by(desc("tokens"), "forge_key")
195+
)
196+
197+
result = await db.execute(query)
198+
rows = result.fetchall()
199+
200+
return [
201+
ForgeKeyUsageSummaryResponse(forge_key=row.forge_key, tokens=row.tokens)
202+
for row in rows
203+
]

app/api/schemas/statistic.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from pydantic import BaseModel, field_validator
2+
from datetime import datetime
3+
import re
4+
5+
from app.api.schemas.forge_api_key import ForgeApiKeyMasked
6+
7+
def mask_forge_name_or_key(v: str) -> str:
8+
# If the forge key is a valid forge key, mask it
9+
if re.match(r"forge-\w{18}", v):
10+
return ForgeApiKeyMasked.mask_api_key(v)
11+
# Otherwise, return the original value (user customized name)
12+
return v
13+
14+
class UsageRealtimeResponse(BaseModel):
15+
timestamp: datetime
16+
forge_key: str
17+
provider_name: str
18+
model_name: str
19+
tokens: int
20+
duration: float
21+
22+
@field_validator('forge_key')
23+
@classmethod
24+
def mask_forge_key(cls, v: str) -> str:
25+
return mask_forge_name_or_key(v)
26+
27+
@field_validator('timestamp')
28+
@classmethod
29+
def convert_timestamp_to_iso(cls, v: datetime) -> str:
30+
return v.isoformat()
31+
32+
33+
class UsageSummaryBreakdown(BaseModel):
34+
forge_key: str
35+
tokens: int
36+
37+
@field_validator('forge_key')
38+
@classmethod
39+
def mask_forge_key(cls, v: str) -> str:
40+
return mask_forge_name_or_key(v)
41+
42+
43+
class UsageSummaryResponse(BaseModel):
44+
time_point: datetime
45+
breakdown: list[UsageSummaryBreakdown]
46+
total_tokens: int
47+
48+
@field_validator('time_point')
49+
@classmethod
50+
def convert_timestamp_to_iso(cls, v: datetime) -> str:
51+
return v.isoformat()
52+
53+
54+
class ForgeKeyUsageSummaryResponse(BaseModel):
55+
forge_key: str
56+
tokens: int
57+
58+
@field_validator('forge_key')
59+
@classmethod
60+
def mask_forge_key(cls, v: str) -> str:
61+
return mask_forge_name_or_key(v)

app/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
claude_code,
1616
provider_keys,
1717
proxy,
18+
statistic,
1819
stats,
1920
users,
2021
webhooks,
@@ -167,6 +168,7 @@ def create_app() -> FastAPI:
167168
v1_router.include_router(proxy.router, tags=["proxy"])
168169
v1_router.include_router(stats.router, prefix="/stats", tags=["stats"])
169170
v1_router.include_router(webhooks.router, prefix="/webhooks", tags=["webhooks"])
171+
v1_router.include_router(statistic.router, prefix='/statistic', tags=["statistic"])
170172
# Claude Code compatible API endpoints
171173
v1_router.include_router(claude_code.router, tags=["Claude Code API"])
172174

0 commit comments

Comments
 (0)