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.
@@ -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}