llmapi-tracker 1.0.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.
- api_tracker/__init__.py +5 -0
- api_tracker/client.py +49 -0
- api_tracker/models.py +30 -0
- api_tracker/pricing.py +79 -0
- api_tracker/providers/__init__.py +8 -0
- api_tracker/providers/gemini.py +36 -0
- api_tracker/providers/openai.py +36 -0
- api_tracker/py.typed +1 -0
- api_tracker/tracker.py +47 -0
- api_tracker/transport.py +25 -0
- backend/__init__.py +1 -0
- backend/app/__init__.py +1 -0
- backend/app/database.py +24 -0
- backend/app/frontend.py +31 -0
- backend/app/launcher.py +17 -0
- backend/app/main.py +57 -0
- backend/app/models.py +116 -0
- backend/app/paths.py +60 -0
- backend/app/routes/__init__.py +1 -0
- backend/app/routes/analytics.py +91 -0
- backend/app/routes/models.py +29 -0
- backend/app/routes/pricing.py +44 -0
- backend/app/routes/projects.py +55 -0
- backend/app/routes/providers.py +25 -0
- backend/app/routes/usage.py +27 -0
- backend/app/schemas.py +130 -0
- backend/app/services/__init__.py +1 -0
- backend/app/services/cost_service.py +33 -0
- backend/app/services/usage_service.py +119 -0
- backend/app/static/assets/index-3YVy8dav.css +1 -0
- backend/app/static/assets/index-jlcpybVb.js +75 -0
- backend/app/static/index.html +13 -0
- llmapi_tracker-1.0.0.dist-info/METADATA +225 -0
- llmapi_tracker-1.0.0.dist-info/RECORD +38 -0
- llmapi_tracker-1.0.0.dist-info/WHEEL +5 -0
- llmapi_tracker-1.0.0.dist-info/entry_points.txt +2 -0
- llmapi_tracker-1.0.0.dist-info/licenses/LICENSE +21 -0
- llmapi_tracker-1.0.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from fastapi import APIRouter, Depends
|
|
2
|
+
from sqlalchemy import func
|
|
3
|
+
from sqlalchemy.orm import Session
|
|
4
|
+
|
|
5
|
+
from ..database import get_db
|
|
6
|
+
from ..models import ApiUsage, Model, Project, Provider
|
|
7
|
+
|
|
8
|
+
router = APIRouter(prefix="/analytics", tags=["Analytics"])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@router.get("/overview")
|
|
12
|
+
def overview(db: Session = Depends(get_db)):
|
|
13
|
+
rows = db.query(ApiUsage).all()
|
|
14
|
+
count = len(rows)
|
|
15
|
+
total_tokens = sum(r.total_tokens for r in rows)
|
|
16
|
+
total_cost = sum(r.total_cost for r in rows)
|
|
17
|
+
avg_latency = (sum(r.latency_ms for r in rows) / count) if count else 0
|
|
18
|
+
error_rate = ((sum(1 for r in rows if r.status != "success") / count) * 100) if count else 0
|
|
19
|
+
return {
|
|
20
|
+
"requests": count,
|
|
21
|
+
"tokens": total_tokens,
|
|
22
|
+
"cost": total_cost,
|
|
23
|
+
"average_latency_ms": avg_latency,
|
|
24
|
+
"error_rate": error_rate,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@router.get("/cost")
|
|
29
|
+
def cost(db: Session = Depends(get_db)):
|
|
30
|
+
rows = db.query(func.date(ApiUsage.timestamp).label("day"), func.sum(ApiUsage.total_cost)).group_by("day").all()
|
|
31
|
+
return [{"date": str(day), "cost": cost or 0} for day, cost in rows]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@router.get("/tokens")
|
|
35
|
+
def tokens(db: Session = Depends(get_db)):
|
|
36
|
+
rows = db.query(func.date(ApiUsage.timestamp).label("day"), func.sum(ApiUsage.input_tokens), func.sum(ApiUsage.output_tokens), func.sum(ApiUsage.thinking_tokens), func.sum(ApiUsage.cached_tokens)).group_by("day").all()
|
|
37
|
+
return [{"date": str(day), "input": i or 0, "output": o or 0, "thinking": t or 0, "cached": c or 0} for day, i, o, t, c in rows]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@router.get("/latency")
|
|
41
|
+
def latency(db: Session = Depends(get_db)):
|
|
42
|
+
rows = [row.latency_ms for row in db.query(ApiUsage).all()]
|
|
43
|
+
if not rows:
|
|
44
|
+
return {"average": 0, "p50": 0, "p95": 0, "p99": 0}
|
|
45
|
+
rows.sort()
|
|
46
|
+
count = len(rows)
|
|
47
|
+
return {
|
|
48
|
+
"average": sum(rows) / count,
|
|
49
|
+
"p50": rows[int(count * 0.5) - 1 if count > 1 else 0],
|
|
50
|
+
"p95": rows[int(count * 0.95) - 1 if count > 1 else 0],
|
|
51
|
+
"p99": rows[int(count * 0.99) - 1 if count > 1 else 0],
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@router.get("/errors")
|
|
56
|
+
def errors(db: Session = Depends(get_db)):
|
|
57
|
+
rows = db.query(ApiUsage.error_type, func.count(ApiUsage.id)).filter(ApiUsage.status != "success").group_by(ApiUsage.error_type).all()
|
|
58
|
+
return [{"error_type": error_type or "Unknown", "count": count} for error_type, count in rows]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@router.get("/by-project")
|
|
62
|
+
def by_project(db: Session = Depends(get_db)):
|
|
63
|
+
rows = (
|
|
64
|
+
db.query(Project.name, func.count(ApiUsage.id), func.sum(ApiUsage.total_tokens), func.sum(ApiUsage.total_cost), func.avg(ApiUsage.latency_ms))
|
|
65
|
+
.join(ApiUsage, ApiUsage.project_id == Project.id)
|
|
66
|
+
.group_by(Project.name)
|
|
67
|
+
.all()
|
|
68
|
+
)
|
|
69
|
+
return [{"project": name, "requests": req or 0, "tokens": tokens or 0, "cost": cost or 0, "latency_ms": latency or 0} for name, req, tokens, cost, latency in rows]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@router.get("/by-provider")
|
|
73
|
+
def by_provider(db: Session = Depends(get_db)):
|
|
74
|
+
rows = (
|
|
75
|
+
db.query(Provider.name, func.count(ApiUsage.id), func.sum(ApiUsage.total_tokens), func.sum(ApiUsage.total_cost), func.avg(ApiUsage.latency_ms))
|
|
76
|
+
.join(ApiUsage, ApiUsage.provider_id == Provider.id)
|
|
77
|
+
.group_by(Provider.name)
|
|
78
|
+
.all()
|
|
79
|
+
)
|
|
80
|
+
return [{"provider": name, "requests": req or 0, "tokens": tokens or 0, "cost": cost or 0, "latency_ms": latency or 0} for name, req, tokens, cost, latency in rows]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@router.get("/by-model")
|
|
84
|
+
def by_model(db: Session = Depends(get_db)):
|
|
85
|
+
rows = (
|
|
86
|
+
db.query(Model.model_name, func.count(ApiUsage.id), func.sum(ApiUsage.total_tokens), func.sum(ApiUsage.total_cost), func.avg(ApiUsage.latency_ms))
|
|
87
|
+
.join(ApiUsage, ApiUsage.model_id == Model.id)
|
|
88
|
+
.group_by(Model.model_name)
|
|
89
|
+
.all()
|
|
90
|
+
)
|
|
91
|
+
return [{"model": name, "requests": req or 0, "tokens": tokens or 0, "cost": cost or 0, "latency_ms": latency or 0} for name, req, tokens, cost, latency in rows]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from fastapi import APIRouter, Depends
|
|
2
|
+
from sqlalchemy.orm import Session
|
|
3
|
+
|
|
4
|
+
from ..database import get_db
|
|
5
|
+
from ..models import Model
|
|
6
|
+
from ..schemas import ModelCreate, ModelResponse
|
|
7
|
+
|
|
8
|
+
router = APIRouter(prefix="/models", tags=["Models"])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@router.get("", response_model=list[ModelResponse])
|
|
12
|
+
def list_models(db: Session = Depends(get_db)):
|
|
13
|
+
return db.query(Model).order_by(Model.created_at.desc()).all()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.post("", response_model=ModelResponse)
|
|
17
|
+
def create_model(payload: ModelCreate, db: Session = Depends(get_db)):
|
|
18
|
+
row = (
|
|
19
|
+
db.query(Model)
|
|
20
|
+
.filter(Model.provider_id == payload.provider_id, Model.model_name == payload.model_name)
|
|
21
|
+
.first()
|
|
22
|
+
)
|
|
23
|
+
if row:
|
|
24
|
+
return row
|
|
25
|
+
row = Model(**payload.model_dump())
|
|
26
|
+
db.add(row)
|
|
27
|
+
db.commit()
|
|
28
|
+
db.refresh(row)
|
|
29
|
+
return row
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
2
|
+
from sqlalchemy.orm import Session
|
|
3
|
+
|
|
4
|
+
from ..database import get_db
|
|
5
|
+
from ..models import ModelPricing
|
|
6
|
+
from ..schemas import PricingCreate, PricingResponse, PricingUpdate
|
|
7
|
+
|
|
8
|
+
router = APIRouter(prefix="/pricing", tags=["Pricing"])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@router.post("", response_model=PricingResponse)
|
|
12
|
+
def create_pricing(pricing: PricingCreate, db: Session = Depends(get_db)):
|
|
13
|
+
row = ModelPricing(**pricing.model_dump())
|
|
14
|
+
db.add(row)
|
|
15
|
+
db.commit()
|
|
16
|
+
db.refresh(row)
|
|
17
|
+
return row
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@router.get("", response_model=list[PricingResponse])
|
|
21
|
+
def list_pricing(db: Session = Depends(get_db)):
|
|
22
|
+
return db.query(ModelPricing).order_by(ModelPricing.effective_from.desc()).all()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@router.put("/{pricing_id}", response_model=PricingResponse)
|
|
26
|
+
def update_pricing(pricing_id: int, pricing: PricingUpdate, db: Session = Depends(get_db)):
|
|
27
|
+
row = db.query(ModelPricing).filter(ModelPricing.id == pricing_id).first()
|
|
28
|
+
if not row:
|
|
29
|
+
raise HTTPException(status_code=404, detail="Pricing not found")
|
|
30
|
+
for key, value in pricing.model_dump(exclude_unset=True).items():
|
|
31
|
+
setattr(row, key, value)
|
|
32
|
+
db.commit()
|
|
33
|
+
db.refresh(row)
|
|
34
|
+
return row
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@router.delete("/{pricing_id}")
|
|
38
|
+
def delete_pricing(pricing_id: int, db: Session = Depends(get_db)):
|
|
39
|
+
row = db.query(ModelPricing).filter(ModelPricing.id == pricing_id).first()
|
|
40
|
+
if not row:
|
|
41
|
+
raise HTTPException(status_code=404, detail="Pricing not found")
|
|
42
|
+
db.delete(row)
|
|
43
|
+
db.commit()
|
|
44
|
+
return {"success": True}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
2
|
+
from sqlalchemy.orm import Session
|
|
3
|
+
|
|
4
|
+
from ..database import get_db
|
|
5
|
+
from ..models import Project
|
|
6
|
+
from ..schemas import ProjectCreate, ProjectResponse, ProjectUpdate
|
|
7
|
+
|
|
8
|
+
router = APIRouter(prefix="/projects", tags=["Projects"])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@router.post("", response_model=ProjectResponse)
|
|
12
|
+
def create_project(project: ProjectCreate, db: Session = Depends(get_db)):
|
|
13
|
+
existing = db.query(Project).filter(Project.name == project.name).first()
|
|
14
|
+
if existing:
|
|
15
|
+
return existing
|
|
16
|
+
row = Project(**project.model_dump())
|
|
17
|
+
db.add(row)
|
|
18
|
+
db.commit()
|
|
19
|
+
db.refresh(row)
|
|
20
|
+
return row
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@router.get("", response_model=list[ProjectResponse])
|
|
24
|
+
def list_projects(db: Session = Depends(get_db)):
|
|
25
|
+
return db.query(Project).order_by(Project.created_at.desc()).all()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@router.get("/{project_id}", response_model=ProjectResponse)
|
|
29
|
+
def get_project(project_id: int, db: Session = Depends(get_db)):
|
|
30
|
+
row = db.query(Project).filter(Project.id == project_id).first()
|
|
31
|
+
if not row:
|
|
32
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
33
|
+
return row
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@router.put("/{project_id}", response_model=ProjectResponse)
|
|
37
|
+
def update_project(project_id: int, project: ProjectUpdate, db: Session = Depends(get_db)):
|
|
38
|
+
row = db.query(Project).filter(Project.id == project_id).first()
|
|
39
|
+
if not row:
|
|
40
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
41
|
+
for key, value in project.model_dump(exclude_unset=True).items():
|
|
42
|
+
setattr(row, key, value)
|
|
43
|
+
db.commit()
|
|
44
|
+
db.refresh(row)
|
|
45
|
+
return row
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@router.delete("/{project_id}")
|
|
49
|
+
def delete_project(project_id: int, db: Session = Depends(get_db)):
|
|
50
|
+
row = db.query(Project).filter(Project.id == project_id).first()
|
|
51
|
+
if not row:
|
|
52
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
53
|
+
db.delete(row)
|
|
54
|
+
db.commit()
|
|
55
|
+
return {"success": True}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from fastapi import APIRouter, Depends
|
|
2
|
+
from sqlalchemy.orm import Session
|
|
3
|
+
|
|
4
|
+
from ..database import get_db
|
|
5
|
+
from ..models import Provider
|
|
6
|
+
from ..schemas import ProviderCreate, ProviderResponse
|
|
7
|
+
|
|
8
|
+
router = APIRouter(prefix="/providers", tags=["Providers"])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@router.get("", response_model=list[ProviderResponse])
|
|
12
|
+
def list_providers(db: Session = Depends(get_db)):
|
|
13
|
+
return db.query(Provider).order_by(Provider.created_at.desc()).all()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.post("", response_model=ProviderResponse)
|
|
17
|
+
def create_provider(payload: ProviderCreate, db: Session = Depends(get_db)):
|
|
18
|
+
row = db.query(Provider).filter(Provider.name == payload.name).first()
|
|
19
|
+
if row:
|
|
20
|
+
return row
|
|
21
|
+
row = Provider(name=payload.name)
|
|
22
|
+
db.add(row)
|
|
23
|
+
db.commit()
|
|
24
|
+
db.refresh(row)
|
|
25
|
+
return row
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
2
|
+
from sqlalchemy.orm import Session
|
|
3
|
+
|
|
4
|
+
from ..database import get_db
|
|
5
|
+
from ..models import ApiUsage, Model, Project, Provider
|
|
6
|
+
from ..schemas import UsageDataIn, UsageResponse
|
|
7
|
+
from ..services.usage_service import create_usage
|
|
8
|
+
|
|
9
|
+
router = APIRouter(prefix="/usage", tags=["Usage"])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@router.post("", response_model=UsageResponse)
|
|
13
|
+
def post_usage(payload: UsageDataIn, db: Session = Depends(get_db)):
|
|
14
|
+
return create_usage(db, payload.model_dump())
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@router.get("", response_model=list[UsageResponse])
|
|
18
|
+
def list_usage(db: Session = Depends(get_db)):
|
|
19
|
+
return db.query(ApiUsage).order_by(ApiUsage.timestamp.desc()).limit(1000).all()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.get("/{usage_id}", response_model=UsageResponse)
|
|
23
|
+
def get_usage(usage_id: int, db: Session = Depends(get_db)):
|
|
24
|
+
row = db.query(ApiUsage).filter(ApiUsage.id == usage_id).first()
|
|
25
|
+
if not row:
|
|
26
|
+
raise HTTPException(status_code=404, detail="Usage not found")
|
|
27
|
+
return row
|
backend/app/schemas.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ProjectBase(BaseModel):
|
|
10
|
+
name: str
|
|
11
|
+
description: Optional[str] = None
|
|
12
|
+
environment: str = "development"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ProjectCreate(ProjectBase):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ProjectUpdate(BaseModel):
|
|
20
|
+
name: Optional[str] = None
|
|
21
|
+
description: Optional[str] = None
|
|
22
|
+
environment: Optional[str] = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ProjectResponse(ProjectBase):
|
|
26
|
+
id: int
|
|
27
|
+
created_at: datetime
|
|
28
|
+
model_config = ConfigDict(from_attributes=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ProviderCreate(BaseModel):
|
|
32
|
+
name: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ProviderResponse(ProviderCreate):
|
|
36
|
+
id: int
|
|
37
|
+
created_at: datetime
|
|
38
|
+
model_config = ConfigDict(from_attributes=True)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ModelCreate(BaseModel):
|
|
42
|
+
provider_id: int
|
|
43
|
+
model_name: str
|
|
44
|
+
model_type: str = "text"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ModelResponse(ModelCreate):
|
|
48
|
+
id: int
|
|
49
|
+
created_at: datetime
|
|
50
|
+
model_config = ConfigDict(from_attributes=True)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PricingCreate(BaseModel):
|
|
54
|
+
model_id: int
|
|
55
|
+
input_price_per_1m: float = 0.0
|
|
56
|
+
output_price_per_1m: float = 0.0
|
|
57
|
+
thinking_price_per_1m: float = 0.0
|
|
58
|
+
cached_input_price_per_1m: float = 0.0
|
|
59
|
+
currency: str = "USD"
|
|
60
|
+
effective_from: datetime
|
|
61
|
+
effective_to: Optional[datetime] = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class PricingUpdate(BaseModel):
|
|
65
|
+
input_price_per_1m: Optional[float] = None
|
|
66
|
+
output_price_per_1m: Optional[float] = None
|
|
67
|
+
thinking_price_per_1m: Optional[float] = None
|
|
68
|
+
cached_input_price_per_1m: Optional[float] = None
|
|
69
|
+
currency: Optional[str] = None
|
|
70
|
+
effective_from: Optional[datetime] = None
|
|
71
|
+
effective_to: Optional[datetime] = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class PricingResponse(PricingCreate):
|
|
75
|
+
id: int
|
|
76
|
+
created_at: datetime
|
|
77
|
+
updated_at: datetime
|
|
78
|
+
model_config = ConfigDict(from_attributes=True)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class UsageDataIn(BaseModel):
|
|
82
|
+
project: str
|
|
83
|
+
provider: str
|
|
84
|
+
model: str
|
|
85
|
+
internal_request_id: str
|
|
86
|
+
provider_request_id: Optional[str] = None
|
|
87
|
+
input_tokens: int = 0
|
|
88
|
+
output_tokens: int = 0
|
|
89
|
+
thinking_tokens: int = 0
|
|
90
|
+
cached_tokens: int = 0
|
|
91
|
+
total_tokens: int = 0
|
|
92
|
+
audio_seconds: float = 0.0
|
|
93
|
+
characters: int = 0
|
|
94
|
+
request_count: int = 1
|
|
95
|
+
latency_ms: float = 0.0
|
|
96
|
+
status: str = "success"
|
|
97
|
+
http_status_code: Optional[int] = None
|
|
98
|
+
error_type: Optional[str] = None
|
|
99
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
100
|
+
capture_content: bool = False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class UsageResponse(BaseModel):
|
|
104
|
+
id: int
|
|
105
|
+
project_id: int
|
|
106
|
+
provider_id: int
|
|
107
|
+
model_id: int
|
|
108
|
+
internal_request_id: str
|
|
109
|
+
provider_request_id: Optional[str]
|
|
110
|
+
timestamp: datetime
|
|
111
|
+
input_tokens: int
|
|
112
|
+
output_tokens: int
|
|
113
|
+
thinking_tokens: int
|
|
114
|
+
cached_tokens: int
|
|
115
|
+
total_tokens: int
|
|
116
|
+
input_cost: float
|
|
117
|
+
output_cost: float
|
|
118
|
+
thinking_cost: float
|
|
119
|
+
cached_cost: float
|
|
120
|
+
total_cost: float
|
|
121
|
+
audio_seconds: float
|
|
122
|
+
characters: int
|
|
123
|
+
request_count: int
|
|
124
|
+
latency_ms: float
|
|
125
|
+
status: str
|
|
126
|
+
http_status_code: Optional[int]
|
|
127
|
+
error_type: Optional[str]
|
|
128
|
+
metadata_json: dict[str, Any]
|
|
129
|
+
capture_content: bool
|
|
130
|
+
model_config = ConfigDict(from_attributes=True)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class PricingSnapshot:
|
|
8
|
+
input_price_per_1m: float = 0.0
|
|
9
|
+
output_price_per_1m: float = 0.0
|
|
10
|
+
thinking_price_per_1m: float = 0.0
|
|
11
|
+
cached_input_price_per_1m: float = 0.0
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def calculate_costs(
|
|
15
|
+
*,
|
|
16
|
+
pricing: PricingSnapshot,
|
|
17
|
+
input_tokens: int = 0,
|
|
18
|
+
output_tokens: int = 0,
|
|
19
|
+
thinking_tokens: int = 0,
|
|
20
|
+
cached_tokens: int = 0,
|
|
21
|
+
) -> dict[str, float]:
|
|
22
|
+
input_cost = (input_tokens / 1_000_000) * pricing.input_price_per_1m
|
|
23
|
+
output_cost = (output_tokens / 1_000_000) * pricing.output_price_per_1m
|
|
24
|
+
thinking_cost = (thinking_tokens / 1_000_000) * pricing.thinking_price_per_1m
|
|
25
|
+
cached_cost = (cached_tokens / 1_000_000) * pricing.cached_input_price_per_1m
|
|
26
|
+
total_cost = input_cost + output_cost + thinking_cost + cached_cost
|
|
27
|
+
return {
|
|
28
|
+
"input_cost": input_cost,
|
|
29
|
+
"output_cost": output_cost,
|
|
30
|
+
"thinking_cost": thinking_cost,
|
|
31
|
+
"cached_cost": cached_cost,
|
|
32
|
+
"total_cost": total_cost,
|
|
33
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from sqlalchemy.orm import Session
|
|
7
|
+
|
|
8
|
+
from ..models import ApiUsage, Model, ModelPricing, Project, Provider
|
|
9
|
+
from .cost_service import PricingSnapshot, calculate_costs
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _get_or_create_project(db: Session, name: str) -> Project:
|
|
13
|
+
project = db.query(Project).filter(Project.name == name).first()
|
|
14
|
+
if project:
|
|
15
|
+
return project
|
|
16
|
+
project = Project(name=name, environment="development")
|
|
17
|
+
db.add(project)
|
|
18
|
+
db.commit()
|
|
19
|
+
db.refresh(project)
|
|
20
|
+
return project
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _get_or_create_provider(db: Session, name: str) -> Provider:
|
|
24
|
+
provider = db.query(Provider).filter(Provider.name == name).first()
|
|
25
|
+
if provider:
|
|
26
|
+
return provider
|
|
27
|
+
provider = Provider(name=name)
|
|
28
|
+
db.add(provider)
|
|
29
|
+
db.commit()
|
|
30
|
+
db.refresh(provider)
|
|
31
|
+
return provider
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _get_or_create_model(db: Session, provider_id: int, model_name: str) -> Model:
|
|
35
|
+
model = (
|
|
36
|
+
db.query(Model)
|
|
37
|
+
.filter(Model.provider_id == provider_id, Model.model_name == model_name)
|
|
38
|
+
.first()
|
|
39
|
+
)
|
|
40
|
+
if model:
|
|
41
|
+
return model
|
|
42
|
+
model = Model(provider_id=provider_id, model_name=model_name, model_type="text")
|
|
43
|
+
db.add(model)
|
|
44
|
+
db.commit()
|
|
45
|
+
db.refresh(model)
|
|
46
|
+
return model
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_applicable_pricing(db: Session, model_id: int, timestamp: datetime) -> ModelPricing | None:
|
|
50
|
+
pricing = (
|
|
51
|
+
db.query(ModelPricing)
|
|
52
|
+
.filter(
|
|
53
|
+
ModelPricing.model_id == model_id,
|
|
54
|
+
ModelPricing.effective_from <= timestamp,
|
|
55
|
+
((ModelPricing.effective_to.is_(None)) | (ModelPricing.effective_to >= timestamp)),
|
|
56
|
+
)
|
|
57
|
+
.order_by(ModelPricing.effective_from.desc())
|
|
58
|
+
.first()
|
|
59
|
+
)
|
|
60
|
+
if pricing:
|
|
61
|
+
return pricing
|
|
62
|
+
return (
|
|
63
|
+
db.query(ModelPricing)
|
|
64
|
+
.filter(ModelPricing.model_id == model_id)
|
|
65
|
+
.order_by(ModelPricing.effective_from.desc())
|
|
66
|
+
.first()
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def create_usage(db: Session, payload: dict[str, Any]) -> ApiUsage:
|
|
71
|
+
project = _get_or_create_project(db, payload["project"])
|
|
72
|
+
provider = _get_or_create_provider(db, payload["provider"])
|
|
73
|
+
model = _get_or_create_model(db, provider.id, payload["model"])
|
|
74
|
+
timestamp = payload.get("timestamp") or datetime.utcnow()
|
|
75
|
+
pricing = get_applicable_pricing(db, model.id, timestamp)
|
|
76
|
+
snapshot = PricingSnapshot(
|
|
77
|
+
input_price_per_1m=getattr(pricing, "input_price_per_1m", 0.0),
|
|
78
|
+
output_price_per_1m=getattr(pricing, "output_price_per_1m", 0.0),
|
|
79
|
+
thinking_price_per_1m=getattr(pricing, "thinking_price_per_1m", 0.0),
|
|
80
|
+
cached_input_price_per_1m=getattr(pricing, "cached_input_price_per_1m", 0.0),
|
|
81
|
+
)
|
|
82
|
+
costs = calculate_costs(
|
|
83
|
+
pricing=snapshot,
|
|
84
|
+
input_tokens=payload.get("input_tokens", 0),
|
|
85
|
+
output_tokens=payload.get("output_tokens", 0),
|
|
86
|
+
thinking_tokens=payload.get("thinking_tokens", 0),
|
|
87
|
+
cached_tokens=payload.get("cached_tokens", 0),
|
|
88
|
+
)
|
|
89
|
+
usage = ApiUsage(
|
|
90
|
+
project_id=project.id,
|
|
91
|
+
provider_id=provider.id,
|
|
92
|
+
model_id=model.id,
|
|
93
|
+
internal_request_id=payload["internal_request_id"],
|
|
94
|
+
provider_request_id=payload.get("provider_request_id"),
|
|
95
|
+
timestamp=timestamp,
|
|
96
|
+
input_tokens=payload.get("input_tokens", 0),
|
|
97
|
+
output_tokens=payload.get("output_tokens", 0),
|
|
98
|
+
thinking_tokens=payload.get("thinking_tokens", 0),
|
|
99
|
+
cached_tokens=payload.get("cached_tokens", 0),
|
|
100
|
+
total_tokens=payload.get("total_tokens", 0),
|
|
101
|
+
input_cost=costs["input_cost"],
|
|
102
|
+
output_cost=costs["output_cost"],
|
|
103
|
+
thinking_cost=costs["thinking_cost"],
|
|
104
|
+
cached_cost=costs["cached_cost"],
|
|
105
|
+
total_cost=costs["total_cost"],
|
|
106
|
+
audio_seconds=payload.get("audio_seconds", 0.0),
|
|
107
|
+
characters=payload.get("characters", 0),
|
|
108
|
+
request_count=payload.get("request_count", 1),
|
|
109
|
+
latency_ms=payload.get("latency_ms", 0.0),
|
|
110
|
+
status=payload.get("status", "success"),
|
|
111
|
+
http_status_code=payload.get("http_status_code"),
|
|
112
|
+
error_type=payload.get("error_type"),
|
|
113
|
+
metadata_json=payload.get("metadata", {}),
|
|
114
|
+
capture_content=payload.get("capture_content", False),
|
|
115
|
+
)
|
|
116
|
+
db.add(usage)
|
|
117
|
+
db.commit()
|
|
118
|
+
db.refresh(usage)
|
|
119
|
+
return usage
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.collapse{visibility:collapse}.fixed{position:fixed}.flex{display:flex}.table{display:table}.grid{display:grid}.hidden{display:none}.border{border-width:1px}.blur{--tw-blur: blur(8px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}:root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,sans-serif;background:#0b1020;color:#e5eefb}html,body,#root{height:100%;margin:0}body{background:radial-gradient(circle at top left,rgba(96,165,250,.18),transparent 28%),linear-gradient(180deg,#0b1020,#0f172a)}.app{display:grid;grid-template-columns:260px 1fr;min-height:100vh}.sidebar{padding:24px;background:#0f172aeb;border-right:1px solid rgba(148,163,184,.16)}.brand{font-size:20px;font-weight:700;margin-bottom:24px}.sidebar a{display:block;padding:10px 12px;border-radius:10px;color:#bfd0f1;text-decoration:none;margin-bottom:6px}.sidebar a.active{background:#60a5fa29;color:#fff}.content{padding:28px}.page{background:#111827b8;border:1px solid rgba(148,163,184,.16);border-radius:20px;padding:28px}
|