llmapi-tracker 1.0.0__tar.gz → 1.0.2__tar.gz
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.
- {llmapi_tracker-1.0.0/llmapi_tracker.egg-info → llmapi_tracker-1.0.2}/PKG-INFO +21 -12
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/README.md +17 -11
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/backend/app/database.py +0 -2
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/backend/app/frontend.py +5 -3
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/backend/app/launcher.py +5 -1
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/backend/app/main.py +0 -2
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/backend/app/models.py +18 -7
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/backend/app/paths.py +0 -1
- llmapi_tracker-1.0.2/backend/app/routes/__init__.py +0 -0
- llmapi_tracker-1.0.2/backend/app/routes/analytics.py +171 -0
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/backend/app/routes/models.py +4 -1
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/backend/app/routes/pricing.py +3 -1
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/backend/app/routes/projects.py +3 -1
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/backend/app/routes/usage.py +1 -1
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/backend/app/schemas.py +19 -19
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/backend/app/services/usage_service.py +10 -4
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2/llmapi_tracker.egg-info}/PKG-INFO +21 -12
- llmapi_tracker-1.0.2/pyproject.toml +83 -0
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/tests/test_analytics_routes.py +0 -2
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/tests/test_paths.py +4 -3
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/tests/test_routes.py +16 -6
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/tests/test_tracker.py +3 -1
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/tests/test_usage_service.py +17 -7
- llmapi_tracker-1.0.0/backend/app/routes/__init__.py +0 -1
- llmapi_tracker-1.0.0/backend/app/routes/analytics.py +0 -91
- llmapi_tracker-1.0.0/pyproject.toml +0 -45
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/LICENSE +0 -0
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/backend/__init__.py +0 -0
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/backend/app/__init__.py +0 -0
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/backend/app/routes/providers.py +0 -0
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/backend/app/services/__init__.py +0 -0
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/backend/app/services/cost_service.py +0 -0
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/backend/app/static/assets/index-3YVy8dav.css +0 -0
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/backend/app/static/assets/index-jlcpybVb.js +0 -0
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/backend/app/static/index.html +0 -0
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/llmapi_tracker.egg-info/SOURCES.txt +0 -0
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/llmapi_tracker.egg-info/dependency_links.txt +0 -0
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/llmapi_tracker.egg-info/entry_points.txt +0 -0
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/llmapi_tracker.egg-info/requires.txt +0 -0
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/llmapi_tracker.egg-info/top_level.txt +0 -0
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/sdk/api_tracker/__init__.py +0 -0
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/sdk/api_tracker/client.py +0 -0
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/sdk/api_tracker/models.py +0 -0
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/sdk/api_tracker/pricing.py +0 -0
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/sdk/api_tracker/providers/__init__.py +0 -0
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/sdk/api_tracker/providers/gemini.py +0 -0
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/sdk/api_tracker/providers/openai.py +0 -0
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/sdk/api_tracker/py.typed +0 -0
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/sdk/api_tracker/tracker.py +0 -0
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/sdk/api_tracker/transport.py +0 -0
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/setup.cfg +0 -0
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/tests/test_cost_service.py +0 -0
- {llmapi_tracker-1.0.0 → llmapi_tracker-1.0.2}/tests/test_provider_model_routes.py +0 -0
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: llmapi-tracker
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.2
|
|
4
4
|
Summary: Local-first API usage and cost tracking platform
|
|
5
5
|
Author: API Tracker
|
|
6
6
|
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Lns9777/API-Usage-Tracker
|
|
8
|
+
Project-URL: Repository, https://github.com/Lns9777/API-Usage-Tracker
|
|
9
|
+
Project-URL: Issues, https://github.com/Lns9777/API-Usage-Tracker/issues
|
|
7
10
|
Requires-Python: >=3.10
|
|
8
11
|
Description-Content-Type: text/markdown
|
|
9
12
|
License-File: LICENSE
|
|
@@ -27,24 +30,24 @@ Requires-Dist: build>=1.2; extra == "dev"
|
|
|
27
30
|
Requires-Dist: twine>=5.1; extra == "dev"
|
|
28
31
|
Dynamic: license-file
|
|
29
32
|
|
|
30
|
-
#
|
|
33
|
+
# llmapi-tracker
|
|
31
34
|
|
|
32
35
|
## What API Tracker Is
|
|
33
36
|
|
|
34
|
-
|
|
37
|
+
`llmapi-tracker` is a local-first API usage and cost observability platform for LLM applications. It records request counts, token usage, latency, errors, and versioned pricing for provider models such as OpenAI and Gemini.
|
|
35
38
|
|
|
36
|
-
The project is
|
|
39
|
+
The project is designed to keep data local by default. It stores usage and pricing in a local SQLite database and does not persist prompts, responses, or raw API keys in the application database.
|
|
37
40
|
|
|
38
41
|
## Features
|
|
39
42
|
|
|
40
43
|
- Local FastAPI backend with SQLite storage
|
|
41
44
|
- Versioned pricing records for historical cost tracking
|
|
42
45
|
- Usage capture for input, output, thinking, cached, and total tokens
|
|
43
|
-
-
|
|
46
|
+
- Request-level and analytics-level cost calculation
|
|
44
47
|
- React + Vite dashboard for usage, analytics, providers, models, and pricing
|
|
45
48
|
- SDK wrappers for OpenAI and Gemini
|
|
46
49
|
- Packaged launcher command that starts the local app and opens the browser
|
|
47
|
-
- Open-source repository
|
|
50
|
+
- Open-source repository with tests, CI, and release automation
|
|
48
51
|
|
|
49
52
|
## Installation
|
|
50
53
|
|
|
@@ -65,10 +68,16 @@ npm install
|
|
|
65
68
|
|
|
66
69
|
### Package install
|
|
67
70
|
|
|
68
|
-
|
|
71
|
+
```bash
|
|
72
|
+
pip install llmapi-tracker
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
If you want optional provider SDK support:
|
|
69
76
|
|
|
70
77
|
```bash
|
|
71
|
-
|
|
78
|
+
pip install "llmapi-tracker[openai]"
|
|
79
|
+
pip install "llmapi-tracker[gemini]"
|
|
80
|
+
pip install "llmapi-tracker[all]"
|
|
72
81
|
```
|
|
73
82
|
|
|
74
83
|
## Starting the Application
|
|
@@ -88,7 +97,7 @@ npm run dev
|
|
|
88
97
|
|
|
89
98
|
### Packaged app
|
|
90
99
|
|
|
91
|
-
After
|
|
100
|
+
After the frontend assets are built and packaged into the backend static directory:
|
|
92
101
|
|
|
93
102
|
```bash
|
|
94
103
|
api-tracker
|
|
@@ -102,7 +111,7 @@ The launcher starts the backend server and opens the browser to the local dashbo
|
|
|
102
111
|
|
|
103
112
|
```python
|
|
104
113
|
import os
|
|
105
|
-
from
|
|
114
|
+
from api_tracker import APITracker
|
|
106
115
|
|
|
107
116
|
tracker = APITracker(
|
|
108
117
|
project="Pharmacy-AI",
|
|
@@ -201,7 +210,7 @@ python scripts/verify_frontend_assets.py
|
|
|
201
210
|
|
|
202
211
|
## Contributing
|
|
203
212
|
|
|
204
|
-
|
|
213
|
+
`llmapi-tracker` is intended to be open source and contributor friendly.
|
|
205
214
|
|
|
206
215
|
Contribution files:
|
|
207
216
|
|
|
@@ -221,5 +230,5 @@ Contribution expectations:
|
|
|
221
230
|
|
|
222
231
|
## License
|
|
223
232
|
|
|
224
|
-
|
|
233
|
+
`llmapi-tracker` is released under the MIT License. See [`LICENSE`](L:/Personal/APITracker/LICENSE).
|
|
225
234
|
|
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
#
|
|
1
|
+
# llmapi-tracker
|
|
2
2
|
|
|
3
3
|
## What API Tracker Is
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
`llmapi-tracker` is a local-first API usage and cost observability platform for LLM applications. It records request counts, token usage, latency, errors, and versioned pricing for provider models such as OpenAI and Gemini.
|
|
6
6
|
|
|
7
|
-
The project is
|
|
7
|
+
The project is designed to keep data local by default. It stores usage and pricing in a local SQLite database and does not persist prompts, responses, or raw API keys in the application database.
|
|
8
8
|
|
|
9
9
|
## Features
|
|
10
10
|
|
|
11
11
|
- Local FastAPI backend with SQLite storage
|
|
12
12
|
- Versioned pricing records for historical cost tracking
|
|
13
13
|
- Usage capture for input, output, thinking, cached, and total tokens
|
|
14
|
-
-
|
|
14
|
+
- Request-level and analytics-level cost calculation
|
|
15
15
|
- React + Vite dashboard for usage, analytics, providers, models, and pricing
|
|
16
16
|
- SDK wrappers for OpenAI and Gemini
|
|
17
17
|
- Packaged launcher command that starts the local app and opens the browser
|
|
18
|
-
- Open-source repository
|
|
18
|
+
- Open-source repository with tests, CI, and release automation
|
|
19
19
|
|
|
20
20
|
## Installation
|
|
21
21
|
|
|
@@ -36,10 +36,16 @@ npm install
|
|
|
36
36
|
|
|
37
37
|
### Package install
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
```bash
|
|
40
|
+
pip install llmapi-tracker
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
If you want optional provider SDK support:
|
|
40
44
|
|
|
41
45
|
```bash
|
|
42
|
-
|
|
46
|
+
pip install "llmapi-tracker[openai]"
|
|
47
|
+
pip install "llmapi-tracker[gemini]"
|
|
48
|
+
pip install "llmapi-tracker[all]"
|
|
43
49
|
```
|
|
44
50
|
|
|
45
51
|
## Starting the Application
|
|
@@ -59,7 +65,7 @@ npm run dev
|
|
|
59
65
|
|
|
60
66
|
### Packaged app
|
|
61
67
|
|
|
62
|
-
After
|
|
68
|
+
After the frontend assets are built and packaged into the backend static directory:
|
|
63
69
|
|
|
64
70
|
```bash
|
|
65
71
|
api-tracker
|
|
@@ -73,7 +79,7 @@ The launcher starts the backend server and opens the browser to the local dashbo
|
|
|
73
79
|
|
|
74
80
|
```python
|
|
75
81
|
import os
|
|
76
|
-
from
|
|
82
|
+
from api_tracker import APITracker
|
|
77
83
|
|
|
78
84
|
tracker = APITracker(
|
|
79
85
|
project="Pharmacy-AI",
|
|
@@ -172,7 +178,7 @@ python scripts/verify_frontend_assets.py
|
|
|
172
178
|
|
|
173
179
|
## Contributing
|
|
174
180
|
|
|
175
|
-
|
|
181
|
+
`llmapi-tracker` is intended to be open source and contributor friendly.
|
|
176
182
|
|
|
177
183
|
Contribution files:
|
|
178
184
|
|
|
@@ -192,5 +198,5 @@ Contribution expectations:
|
|
|
192
198
|
|
|
193
199
|
## License
|
|
194
200
|
|
|
195
|
-
|
|
201
|
+
`llmapi-tracker` is released under the MIT License. See [`LICENSE`](L:/Personal/APITracker/LICENSE).
|
|
196
202
|
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
3
|
from fastapi import APIRouter
|
|
6
4
|
from fastapi.responses import FileResponse
|
|
7
5
|
from fastapi.staticfiles import StaticFiles
|
|
@@ -12,7 +10,11 @@ from .paths import get_frontend_dist_dir
|
|
|
12
10
|
def mount_frontend(app, prefix: str = "") -> None:
|
|
13
11
|
static_dir = get_frontend_dist_dir()
|
|
14
12
|
if static_dir.exists():
|
|
15
|
-
app.mount(
|
|
13
|
+
app.mount(
|
|
14
|
+
prefix or "/assets",
|
|
15
|
+
StaticFiles(directory=static_dir / "assets"),
|
|
16
|
+
name="assets",
|
|
17
|
+
)
|
|
16
18
|
|
|
17
19
|
|
|
18
20
|
def register_frontend_routes(router: APIRouter) -> None:
|
|
@@ -10,7 +10,11 @@ import uvicorn
|
|
|
10
10
|
def launch_app() -> None:
|
|
11
11
|
host = os.getenv("API_TRACKER_HOST", "127.0.0.1")
|
|
12
12
|
port = int(os.getenv("API_TRACKER_PORT", "8000"))
|
|
13
|
-
open_browser = os.getenv("API_TRACKER_OPEN_BROWSER", "true").lower() not in {
|
|
13
|
+
open_browser = os.getenv("API_TRACKER_OPEN_BROWSER", "true").lower() not in {
|
|
14
|
+
"0",
|
|
15
|
+
"false",
|
|
16
|
+
"no",
|
|
17
|
+
}
|
|
14
18
|
if open_browser:
|
|
15
19
|
url = f"http://{host}:{port}"
|
|
16
20
|
threading.Timer(1.0, lambda: webbrowser.open(url)).start()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from datetime import datetime
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
4
|
|
|
5
5
|
from sqlalchemy import (
|
|
6
6
|
JSON,
|
|
@@ -20,7 +20,8 @@ from .database import Base
|
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def utcnow() -> datetime:
|
|
23
|
-
return datetime.utcnow()
|
|
23
|
+
# return datetime.utcnow()
|
|
24
|
+
return datetime.now(timezone.utc)
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
class Project(Base):
|
|
@@ -42,21 +43,29 @@ class Provider(Base):
|
|
|
42
43
|
name = Column(String(100), unique=True, nullable=False, index=True)
|
|
43
44
|
created_at = Column(DateTime, default=utcnow, nullable=False)
|
|
44
45
|
|
|
45
|
-
models = relationship(
|
|
46
|
+
models = relationship(
|
|
47
|
+
"Model", back_populates="provider", cascade="all, delete-orphan"
|
|
48
|
+
)
|
|
46
49
|
|
|
47
50
|
|
|
48
51
|
class Model(Base):
|
|
49
52
|
__tablename__ = "models"
|
|
50
|
-
__table_args__ = (
|
|
53
|
+
__table_args__ = (
|
|
54
|
+
UniqueConstraint("provider_id", "model_name", name="uq_provider_model_name"),
|
|
55
|
+
)
|
|
51
56
|
|
|
52
57
|
id = Column(Integer, primary_key=True, index=True)
|
|
53
|
-
provider_id = Column(
|
|
58
|
+
provider_id = Column(
|
|
59
|
+
Integer, ForeignKey("providers.id"), nullable=False, index=True
|
|
60
|
+
)
|
|
54
61
|
model_name = Column(String(150), nullable=False, index=True)
|
|
55
62
|
model_type = Column(String(50), default="text", nullable=False)
|
|
56
63
|
created_at = Column(DateTime, default=utcnow, nullable=False)
|
|
57
64
|
|
|
58
65
|
provider = relationship("Provider", back_populates="models")
|
|
59
|
-
pricing = relationship(
|
|
66
|
+
pricing = relationship(
|
|
67
|
+
"ModelPricing", back_populates="model", cascade="all, delete-orphan"
|
|
68
|
+
)
|
|
60
69
|
usage = relationship("ApiUsage", back_populates="model_ref")
|
|
61
70
|
|
|
62
71
|
|
|
@@ -86,7 +95,9 @@ class ApiUsage(Base):
|
|
|
86
95
|
|
|
87
96
|
id = Column(Integer, primary_key=True, index=True)
|
|
88
97
|
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False, index=True)
|
|
89
|
-
provider_id = Column(
|
|
98
|
+
provider_id = Column(
|
|
99
|
+
Integer, ForeignKey("providers.id"), nullable=False, index=True
|
|
100
|
+
)
|
|
90
101
|
model_id = Column(Integer, ForeignKey("models.id"), nullable=False, index=True)
|
|
91
102
|
internal_request_id = Column(String(255), nullable=False, unique=True, index=True)
|
|
92
103
|
provider_request_id = Column(String(255), nullable=True, index=True)
|
|
File without changes
|
|
@@ -0,0 +1,171 @@
|
|
|
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 = (
|
|
19
|
+
((sum(1 for r in rows if r.status != "success") / count) * 100) if count else 0
|
|
20
|
+
)
|
|
21
|
+
return {
|
|
22
|
+
"requests": count,
|
|
23
|
+
"tokens": total_tokens,
|
|
24
|
+
"cost": total_cost,
|
|
25
|
+
"average_latency_ms": avg_latency,
|
|
26
|
+
"error_rate": error_rate,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@router.get("/cost")
|
|
31
|
+
def cost(db: Session = Depends(get_db)):
|
|
32
|
+
rows = (
|
|
33
|
+
db.query(
|
|
34
|
+
func.date(ApiUsage.timestamp).label("day"), func.sum(ApiUsage.total_cost)
|
|
35
|
+
)
|
|
36
|
+
.group_by("day")
|
|
37
|
+
.all()
|
|
38
|
+
)
|
|
39
|
+
return [{"date": str(day), "cost": cost or 0} for day, cost in rows]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@router.get("/tokens")
|
|
43
|
+
def tokens(db: Session = Depends(get_db)):
|
|
44
|
+
rows = (
|
|
45
|
+
db.query(
|
|
46
|
+
func.date(ApiUsage.timestamp).label("day"),
|
|
47
|
+
func.sum(ApiUsage.input_tokens),
|
|
48
|
+
func.sum(ApiUsage.output_tokens),
|
|
49
|
+
func.sum(ApiUsage.thinking_tokens),
|
|
50
|
+
func.sum(ApiUsage.cached_tokens),
|
|
51
|
+
)
|
|
52
|
+
.group_by("day")
|
|
53
|
+
.all()
|
|
54
|
+
)
|
|
55
|
+
return [
|
|
56
|
+
{
|
|
57
|
+
"date": str(day),
|
|
58
|
+
"input": i or 0,
|
|
59
|
+
"output": o or 0,
|
|
60
|
+
"thinking": t or 0,
|
|
61
|
+
"cached": c or 0,
|
|
62
|
+
}
|
|
63
|
+
for day, i, o, t, c in rows
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@router.get("/latency")
|
|
68
|
+
def latency(db: Session = Depends(get_db)):
|
|
69
|
+
rows = [row.latency_ms for row in db.query(ApiUsage).all()]
|
|
70
|
+
if not rows:
|
|
71
|
+
return {"average": 0, "p50": 0, "p95": 0, "p99": 0}
|
|
72
|
+
rows.sort()
|
|
73
|
+
count = len(rows)
|
|
74
|
+
return {
|
|
75
|
+
"average": sum(rows) / count,
|
|
76
|
+
"p50": rows[int(count * 0.5) - 1 if count > 1 else 0],
|
|
77
|
+
"p95": rows[int(count * 0.95) - 1 if count > 1 else 0],
|
|
78
|
+
"p99": rows[int(count * 0.99) - 1 if count > 1 else 0],
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@router.get("/errors")
|
|
83
|
+
def errors(db: Session = Depends(get_db)):
|
|
84
|
+
rows = (
|
|
85
|
+
db.query(ApiUsage.error_type, func.count(ApiUsage.id))
|
|
86
|
+
.filter(ApiUsage.status != "success")
|
|
87
|
+
.group_by(ApiUsage.error_type)
|
|
88
|
+
.all()
|
|
89
|
+
)
|
|
90
|
+
return [
|
|
91
|
+
{"error_type": error_type or "Unknown", "count": count}
|
|
92
|
+
for error_type, count in rows
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@router.get("/by-project")
|
|
97
|
+
def by_project(db: Session = Depends(get_db)):
|
|
98
|
+
rows = (
|
|
99
|
+
db.query(
|
|
100
|
+
Project.name,
|
|
101
|
+
func.count(ApiUsage.id),
|
|
102
|
+
func.sum(ApiUsage.total_tokens),
|
|
103
|
+
func.sum(ApiUsage.total_cost),
|
|
104
|
+
func.avg(ApiUsage.latency_ms),
|
|
105
|
+
)
|
|
106
|
+
.join(ApiUsage, ApiUsage.project_id == Project.id)
|
|
107
|
+
.group_by(Project.name)
|
|
108
|
+
.all()
|
|
109
|
+
)
|
|
110
|
+
return [
|
|
111
|
+
{
|
|
112
|
+
"project": name,
|
|
113
|
+
"requests": req or 0,
|
|
114
|
+
"tokens": tokens or 0,
|
|
115
|
+
"cost": cost or 0,
|
|
116
|
+
"latency_ms": latency or 0,
|
|
117
|
+
}
|
|
118
|
+
for name, req, tokens, cost, latency in rows
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@router.get("/by-provider")
|
|
123
|
+
def by_provider(db: Session = Depends(get_db)):
|
|
124
|
+
rows = (
|
|
125
|
+
db.query(
|
|
126
|
+
Provider.name,
|
|
127
|
+
func.count(ApiUsage.id),
|
|
128
|
+
func.sum(ApiUsage.total_tokens),
|
|
129
|
+
func.sum(ApiUsage.total_cost),
|
|
130
|
+
func.avg(ApiUsage.latency_ms),
|
|
131
|
+
)
|
|
132
|
+
.join(ApiUsage, ApiUsage.provider_id == Provider.id)
|
|
133
|
+
.group_by(Provider.name)
|
|
134
|
+
.all()
|
|
135
|
+
)
|
|
136
|
+
return [
|
|
137
|
+
{
|
|
138
|
+
"provider": name,
|
|
139
|
+
"requests": req or 0,
|
|
140
|
+
"tokens": tokens or 0,
|
|
141
|
+
"cost": cost or 0,
|
|
142
|
+
"latency_ms": latency or 0,
|
|
143
|
+
}
|
|
144
|
+
for name, req, tokens, cost, latency in rows
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@router.get("/by-model")
|
|
149
|
+
def by_model(db: Session = Depends(get_db)):
|
|
150
|
+
rows = (
|
|
151
|
+
db.query(
|
|
152
|
+
Model.model_name,
|
|
153
|
+
func.count(ApiUsage.id),
|
|
154
|
+
func.sum(ApiUsage.total_tokens),
|
|
155
|
+
func.sum(ApiUsage.total_cost),
|
|
156
|
+
func.avg(ApiUsage.latency_ms),
|
|
157
|
+
)
|
|
158
|
+
.join(ApiUsage, ApiUsage.model_id == Model.id)
|
|
159
|
+
.group_by(Model.model_name)
|
|
160
|
+
.all()
|
|
161
|
+
)
|
|
162
|
+
return [
|
|
163
|
+
{
|
|
164
|
+
"model": name,
|
|
165
|
+
"requests": req or 0,
|
|
166
|
+
"tokens": tokens or 0,
|
|
167
|
+
"cost": cost or 0,
|
|
168
|
+
"latency_ms": latency or 0,
|
|
169
|
+
}
|
|
170
|
+
for name, req, tokens, cost, latency in rows
|
|
171
|
+
]
|
|
@@ -17,7 +17,10 @@ def list_models(db: Session = Depends(get_db)):
|
|
|
17
17
|
def create_model(payload: ModelCreate, db: Session = Depends(get_db)):
|
|
18
18
|
row = (
|
|
19
19
|
db.query(Model)
|
|
20
|
-
.filter(
|
|
20
|
+
.filter(
|
|
21
|
+
Model.provider_id == payload.provider_id,
|
|
22
|
+
Model.model_name == payload.model_name,
|
|
23
|
+
)
|
|
21
24
|
.first()
|
|
22
25
|
)
|
|
23
26
|
if row:
|
|
@@ -23,7 +23,9 @@ def list_pricing(db: Session = Depends(get_db)):
|
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
@router.put("/{pricing_id}", response_model=PricingResponse)
|
|
26
|
-
def update_pricing(
|
|
26
|
+
def update_pricing(
|
|
27
|
+
pricing_id: int, pricing: PricingUpdate, db: Session = Depends(get_db)
|
|
28
|
+
):
|
|
27
29
|
row = db.query(ModelPricing).filter(ModelPricing.id == pricing_id).first()
|
|
28
30
|
if not row:
|
|
29
31
|
raise HTTPException(status_code=404, detail="Pricing not found")
|
|
@@ -34,7 +34,9 @@ def get_project(project_id: int, db: Session = Depends(get_db)):
|
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
@router.put("/{project_id}", response_model=ProjectResponse)
|
|
37
|
-
def update_project(
|
|
37
|
+
def update_project(
|
|
38
|
+
project_id: int, project: ProjectUpdate, db: Session = Depends(get_db)
|
|
39
|
+
):
|
|
38
40
|
row = db.query(Project).filter(Project.id == project_id).first()
|
|
39
41
|
if not row:
|
|
40
42
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|
|
2
2
|
from sqlalchemy.orm import Session
|
|
3
3
|
|
|
4
4
|
from ..database import get_db
|
|
5
|
-
from ..models import ApiUsage
|
|
5
|
+
from ..models import ApiUsage
|
|
6
6
|
from ..schemas import UsageDataIn, UsageResponse
|
|
7
7
|
from ..services.usage_service import create_usage
|
|
8
8
|
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from datetime import datetime
|
|
4
|
-
from typing import Any
|
|
4
|
+
from typing import Any
|
|
5
5
|
|
|
6
6
|
from pydantic import BaseModel, ConfigDict, Field
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
class ProjectBase(BaseModel):
|
|
10
10
|
name: str
|
|
11
|
-
description:
|
|
11
|
+
description: str | None = None
|
|
12
12
|
environment: str = "development"
|
|
13
13
|
|
|
14
14
|
|
|
@@ -17,9 +17,9 @@ class ProjectCreate(ProjectBase):
|
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
class ProjectUpdate(BaseModel):
|
|
20
|
-
name:
|
|
21
|
-
description:
|
|
22
|
-
environment:
|
|
20
|
+
name: str | None = None
|
|
21
|
+
description: str | None = None
|
|
22
|
+
environment: str | None = None
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
class ProjectResponse(ProjectBase):
|
|
@@ -58,17 +58,17 @@ class PricingCreate(BaseModel):
|
|
|
58
58
|
cached_input_price_per_1m: float = 0.0
|
|
59
59
|
currency: str = "USD"
|
|
60
60
|
effective_from: datetime
|
|
61
|
-
effective_to:
|
|
61
|
+
effective_to: datetime | None = None
|
|
62
62
|
|
|
63
63
|
|
|
64
64
|
class PricingUpdate(BaseModel):
|
|
65
|
-
input_price_per_1m:
|
|
66
|
-
output_price_per_1m:
|
|
67
|
-
thinking_price_per_1m:
|
|
68
|
-
cached_input_price_per_1m:
|
|
69
|
-
currency:
|
|
70
|
-
effective_from:
|
|
71
|
-
effective_to:
|
|
65
|
+
input_price_per_1m: float | None = None
|
|
66
|
+
output_price_per_1m: float | None = None
|
|
67
|
+
thinking_price_per_1m: float | None = None
|
|
68
|
+
cached_input_price_per_1m: float | None = None
|
|
69
|
+
currency: str | None = None
|
|
70
|
+
effective_from: datetime | None = None
|
|
71
|
+
effective_to: datetime | None = None
|
|
72
72
|
|
|
73
73
|
|
|
74
74
|
class PricingResponse(PricingCreate):
|
|
@@ -83,7 +83,7 @@ class UsageDataIn(BaseModel):
|
|
|
83
83
|
provider: str
|
|
84
84
|
model: str
|
|
85
85
|
internal_request_id: str
|
|
86
|
-
provider_request_id:
|
|
86
|
+
provider_request_id: str | None = None
|
|
87
87
|
input_tokens: int = 0
|
|
88
88
|
output_tokens: int = 0
|
|
89
89
|
thinking_tokens: int = 0
|
|
@@ -94,8 +94,8 @@ class UsageDataIn(BaseModel):
|
|
|
94
94
|
request_count: int = 1
|
|
95
95
|
latency_ms: float = 0.0
|
|
96
96
|
status: str = "success"
|
|
97
|
-
http_status_code:
|
|
98
|
-
error_type:
|
|
97
|
+
http_status_code: int | None = None
|
|
98
|
+
error_type: str | None = None
|
|
99
99
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
100
100
|
capture_content: bool = False
|
|
101
101
|
|
|
@@ -106,7 +106,7 @@ class UsageResponse(BaseModel):
|
|
|
106
106
|
provider_id: int
|
|
107
107
|
model_id: int
|
|
108
108
|
internal_request_id: str
|
|
109
|
-
provider_request_id:
|
|
109
|
+
provider_request_id: str | None
|
|
110
110
|
timestamp: datetime
|
|
111
111
|
input_tokens: int
|
|
112
112
|
output_tokens: int
|
|
@@ -123,8 +123,8 @@ class UsageResponse(BaseModel):
|
|
|
123
123
|
request_count: int
|
|
124
124
|
latency_ms: float
|
|
125
125
|
status: str
|
|
126
|
-
http_status_code:
|
|
127
|
-
error_type:
|
|
126
|
+
http_status_code: int | None
|
|
127
|
+
error_type: str | None
|
|
128
128
|
metadata_json: dict[str, Any]
|
|
129
129
|
capture_content: bool
|
|
130
130
|
model_config = ConfigDict(from_attributes=True)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from datetime import datetime
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
4
|
from typing import Any
|
|
5
5
|
|
|
6
6
|
from sqlalchemy.orm import Session
|
|
@@ -46,13 +46,18 @@ def _get_or_create_model(db: Session, provider_id: int, model_name: str) -> Mode
|
|
|
46
46
|
return model
|
|
47
47
|
|
|
48
48
|
|
|
49
|
-
def get_applicable_pricing(
|
|
49
|
+
def get_applicable_pricing(
|
|
50
|
+
db: Session, model_id: int, timestamp: datetime
|
|
51
|
+
) -> ModelPricing | None:
|
|
50
52
|
pricing = (
|
|
51
53
|
db.query(ModelPricing)
|
|
52
54
|
.filter(
|
|
53
55
|
ModelPricing.model_id == model_id,
|
|
54
56
|
ModelPricing.effective_from <= timestamp,
|
|
55
|
-
(
|
|
57
|
+
(
|
|
58
|
+
(ModelPricing.effective_to.is_(None))
|
|
59
|
+
| (ModelPricing.effective_to >= timestamp)
|
|
60
|
+
),
|
|
56
61
|
)
|
|
57
62
|
.order_by(ModelPricing.effective_from.desc())
|
|
58
63
|
.first()
|
|
@@ -71,7 +76,8 @@ def create_usage(db: Session, payload: dict[str, Any]) -> ApiUsage:
|
|
|
71
76
|
project = _get_or_create_project(db, payload["project"])
|
|
72
77
|
provider = _get_or_create_provider(db, payload["provider"])
|
|
73
78
|
model = _get_or_create_model(db, provider.id, payload["model"])
|
|
74
|
-
timestamp = payload.get("timestamp") or datetime.utcnow()
|
|
79
|
+
# timestamp = payload.get("timestamp") or datetime.utcnow()
|
|
80
|
+
timestamp = payload.get("timestamp") or datetime.now(timezone.utc)
|
|
75
81
|
pricing = get_applicable_pricing(db, model.id, timestamp)
|
|
76
82
|
snapshot = PricingSnapshot(
|
|
77
83
|
input_price_per_1m=getattr(pricing, "input_price_per_1m", 0.0),
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: llmapi-tracker
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.2
|
|
4
4
|
Summary: Local-first API usage and cost tracking platform
|
|
5
5
|
Author: API Tracker
|
|
6
6
|
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Lns9777/API-Usage-Tracker
|
|
8
|
+
Project-URL: Repository, https://github.com/Lns9777/API-Usage-Tracker
|
|
9
|
+
Project-URL: Issues, https://github.com/Lns9777/API-Usage-Tracker/issues
|
|
7
10
|
Requires-Python: >=3.10
|
|
8
11
|
Description-Content-Type: text/markdown
|
|
9
12
|
License-File: LICENSE
|
|
@@ -27,24 +30,24 @@ Requires-Dist: build>=1.2; extra == "dev"
|
|
|
27
30
|
Requires-Dist: twine>=5.1; extra == "dev"
|
|
28
31
|
Dynamic: license-file
|
|
29
32
|
|
|
30
|
-
#
|
|
33
|
+
# llmapi-tracker
|
|
31
34
|
|
|
32
35
|
## What API Tracker Is
|
|
33
36
|
|
|
34
|
-
|
|
37
|
+
`llmapi-tracker` is a local-first API usage and cost observability platform for LLM applications. It records request counts, token usage, latency, errors, and versioned pricing for provider models such as OpenAI and Gemini.
|
|
35
38
|
|
|
36
|
-
The project is
|
|
39
|
+
The project is designed to keep data local by default. It stores usage and pricing in a local SQLite database and does not persist prompts, responses, or raw API keys in the application database.
|
|
37
40
|
|
|
38
41
|
## Features
|
|
39
42
|
|
|
40
43
|
- Local FastAPI backend with SQLite storage
|
|
41
44
|
- Versioned pricing records for historical cost tracking
|
|
42
45
|
- Usage capture for input, output, thinking, cached, and total tokens
|
|
43
|
-
-
|
|
46
|
+
- Request-level and analytics-level cost calculation
|
|
44
47
|
- React + Vite dashboard for usage, analytics, providers, models, and pricing
|
|
45
48
|
- SDK wrappers for OpenAI and Gemini
|
|
46
49
|
- Packaged launcher command that starts the local app and opens the browser
|
|
47
|
-
- Open-source repository
|
|
50
|
+
- Open-source repository with tests, CI, and release automation
|
|
48
51
|
|
|
49
52
|
## Installation
|
|
50
53
|
|
|
@@ -65,10 +68,16 @@ npm install
|
|
|
65
68
|
|
|
66
69
|
### Package install
|
|
67
70
|
|
|
68
|
-
|
|
71
|
+
```bash
|
|
72
|
+
pip install llmapi-tracker
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
If you want optional provider SDK support:
|
|
69
76
|
|
|
70
77
|
```bash
|
|
71
|
-
|
|
78
|
+
pip install "llmapi-tracker[openai]"
|
|
79
|
+
pip install "llmapi-tracker[gemini]"
|
|
80
|
+
pip install "llmapi-tracker[all]"
|
|
72
81
|
```
|
|
73
82
|
|
|
74
83
|
## Starting the Application
|
|
@@ -88,7 +97,7 @@ npm run dev
|
|
|
88
97
|
|
|
89
98
|
### Packaged app
|
|
90
99
|
|
|
91
|
-
After
|
|
100
|
+
After the frontend assets are built and packaged into the backend static directory:
|
|
92
101
|
|
|
93
102
|
```bash
|
|
94
103
|
api-tracker
|
|
@@ -102,7 +111,7 @@ The launcher starts the backend server and opens the browser to the local dashbo
|
|
|
102
111
|
|
|
103
112
|
```python
|
|
104
113
|
import os
|
|
105
|
-
from
|
|
114
|
+
from api_tracker import APITracker
|
|
106
115
|
|
|
107
116
|
tracker = APITracker(
|
|
108
117
|
project="Pharmacy-AI",
|
|
@@ -201,7 +210,7 @@ python scripts/verify_frontend_assets.py
|
|
|
201
210
|
|
|
202
211
|
## Contributing
|
|
203
212
|
|
|
204
|
-
|
|
213
|
+
`llmapi-tracker` is intended to be open source and contributor friendly.
|
|
205
214
|
|
|
206
215
|
Contribution files:
|
|
207
216
|
|
|
@@ -221,5 +230,5 @@ Contribution expectations:
|
|
|
221
230
|
|
|
222
231
|
## License
|
|
223
232
|
|
|
224
|
-
|
|
233
|
+
`llmapi-tracker` is released under the MIT License. See [`LICENSE`](L:/Personal/APITracker/LICENSE).
|
|
225
234
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
[project]
|
|
7
|
+
name = "llmapi-tracker"
|
|
8
|
+
version = "1.0.2"
|
|
9
|
+
description = "Local-first API usage and cost tracking platform"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
license = { text = "MIT" }
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "API Tracker" }
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
dependencies = [
|
|
18
|
+
"requests>=2.32",
|
|
19
|
+
"fastapi>=0.115",
|
|
20
|
+
"uvicorn[standard]>=0.30",
|
|
21
|
+
"sqlalchemy>=2.0",
|
|
22
|
+
"pydantic>=2.8",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/Lns9777/API-Usage-Tracker"
|
|
28
|
+
Repository = "https://github.com/Lns9777/API-Usage-Tracker"
|
|
29
|
+
Issues = "https://github.com/Lns9777/API-Usage-Tracker/issues"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
openai = ["openai>=1.40"]
|
|
34
|
+
gemini = ["google-genai>=0.7"]
|
|
35
|
+
all = ["openai>=1.40", "google-genai>=0.7"]
|
|
36
|
+
dev = [
|
|
37
|
+
"pytest>=8.0",
|
|
38
|
+
"httpx>=0.27",
|
|
39
|
+
"ruff>=0.6",
|
|
40
|
+
"build>=1.2",
|
|
41
|
+
"twine>=5.1",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
[project.scripts]
|
|
46
|
+
api-tracker = "backend.app.launcher:launch_app"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
[tool.setuptools]
|
|
50
|
+
package-dir = { "backend" = "backend", "api_tracker" = "sdk/api_tracker" }
|
|
51
|
+
packages = [
|
|
52
|
+
"backend",
|
|
53
|
+
"backend.app",
|
|
54
|
+
"backend.app.routes",
|
|
55
|
+
"backend.app.services",
|
|
56
|
+
"api_tracker",
|
|
57
|
+
"api_tracker.providers",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
[tool.setuptools.package-data]
|
|
62
|
+
api_tracker = ["py.typed"]
|
|
63
|
+
backend = ["app/static/**/*"]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
[tool.ruff]
|
|
67
|
+
line-length = 88
|
|
68
|
+
target-version = "py310"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
[tool.ruff.lint]
|
|
72
|
+
select = [
|
|
73
|
+
"E",
|
|
74
|
+
"F",
|
|
75
|
+
"I",
|
|
76
|
+
"B",
|
|
77
|
+
"UP",
|
|
78
|
+
"RUF",
|
|
79
|
+
"DTZ",
|
|
80
|
+
]
|
|
81
|
+
ignore = [
|
|
82
|
+
"B008",
|
|
83
|
+
]
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
from pathlib import Path
|
|
2
|
-
|
|
3
1
|
from backend.app import paths
|
|
4
2
|
|
|
5
3
|
|
|
@@ -13,7 +11,10 @@ def test_database_path_uses_explicit_overrides(monkeypatch, tmp_path):
|
|
|
13
11
|
|
|
14
12
|
assert paths.get_data_dir() == data_dir
|
|
15
13
|
assert paths.get_database_path() == data_dir / "api_tracker.db"
|
|
16
|
-
assert
|
|
14
|
+
assert (
|
|
15
|
+
paths.get_database_url()
|
|
16
|
+
== f"sqlite:///{(data_dir / 'api_tracker.db').as_posix()}"
|
|
17
|
+
)
|
|
17
18
|
|
|
18
19
|
monkeypatch.setenv("API_TRACKER_DB_PATH", str(db_path))
|
|
19
20
|
assert paths.get_database_path() == db_path
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from datetime import datetime
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
2
|
|
|
3
3
|
from fastapi.testclient import TestClient
|
|
4
4
|
from sqlalchemy import create_engine
|
|
@@ -8,7 +8,6 @@ from sqlalchemy.pool import StaticPool
|
|
|
8
8
|
from backend.app.database import Base, get_db
|
|
9
9
|
from backend.app.main import app
|
|
10
10
|
|
|
11
|
-
|
|
12
11
|
engine = create_engine(
|
|
13
12
|
"sqlite://",
|
|
14
13
|
connect_args={"check_same_thread": False},
|
|
@@ -36,7 +35,9 @@ def teardown_module():
|
|
|
36
35
|
|
|
37
36
|
def test_project_crud():
|
|
38
37
|
client = TestClient(app)
|
|
39
|
-
response = client.post(
|
|
38
|
+
response = client.post(
|
|
39
|
+
"/projects", json={"name": "alpha", "description": "A", "environment": "dev"}
|
|
40
|
+
)
|
|
40
41
|
assert response.status_code == 200
|
|
41
42
|
project_id = response.json()["id"]
|
|
42
43
|
|
|
@@ -52,7 +53,14 @@ def test_project_crud():
|
|
|
52
53
|
def test_pricing_and_usage_endpoints():
|
|
53
54
|
client = TestClient(app)
|
|
54
55
|
provider = client.post("/providers", json={"name": "openai"}).json()
|
|
55
|
-
model = client.post(
|
|
56
|
+
model = client.post(
|
|
57
|
+
"/models",
|
|
58
|
+
json={
|
|
59
|
+
"provider_id": provider["id"],
|
|
60
|
+
"model_name": "gpt-5",
|
|
61
|
+
"model_type": "text",
|
|
62
|
+
},
|
|
63
|
+
).json()
|
|
56
64
|
client.post(
|
|
57
65
|
"/pricing",
|
|
58
66
|
json={
|
|
@@ -62,7 +70,8 @@ def test_pricing_and_usage_endpoints():
|
|
|
62
70
|
"thinking_price_per_1m": 3.0,
|
|
63
71
|
"cached_input_price_per_1m": 4.0,
|
|
64
72
|
"currency": "USD",
|
|
65
|
-
"effective_from": datetime.utcnow().isoformat(),
|
|
73
|
+
# "effective_from": datetime.utcnow().isoformat(),
|
|
74
|
+
"effective_from": datetime.now(timezone.utc).isoformat(),
|
|
66
75
|
"effective_to": None,
|
|
67
76
|
},
|
|
68
77
|
)
|
|
@@ -77,7 +86,8 @@ def test_pricing_and_usage_endpoints():
|
|
|
77
86
|
"output_tokens": 1000000,
|
|
78
87
|
"thinking_tokens": 1000000,
|
|
79
88
|
"cached_tokens": 1000000,
|
|
80
|
-
"timestamp": datetime.
|
|
89
|
+
# "timestamp": datetime.now(timezone.utc).isoformat(),
|
|
90
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
81
91
|
},
|
|
82
92
|
)
|
|
83
93
|
assert usage.status_code == 200
|
|
@@ -15,7 +15,9 @@ def test_tracker_records_usage(monkeypatch):
|
|
|
15
15
|
dummy = DummyTransport()
|
|
16
16
|
tracker.transport = dummy
|
|
17
17
|
|
|
18
|
-
usage = tracker.record(
|
|
18
|
+
usage = tracker.record(
|
|
19
|
+
model="gpt-5", input_tokens=10, output_tokens=5, total_tokens=15
|
|
20
|
+
)
|
|
19
21
|
|
|
20
22
|
assert usage.total_tokens == 15
|
|
21
23
|
assert dummy.sent[0]["model"] == "gpt-5"
|
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
from datetime import datetime
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
2
|
|
|
3
3
|
from sqlalchemy import create_engine
|
|
4
4
|
from sqlalchemy.orm import sessionmaker
|
|
5
5
|
|
|
6
6
|
from backend.app.database import Base
|
|
7
|
-
from backend.app.models import ModelPricing, Project, Provider
|
|
7
|
+
from backend.app.models import Model, ModelPricing, Project, Provider
|
|
8
8
|
from backend.app.services.usage_service import create_usage, get_applicable_pricing
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def make_session():
|
|
12
|
-
engine = create_engine(
|
|
12
|
+
engine = create_engine(
|
|
13
|
+
"sqlite:///:memory:", connect_args={"check_same_thread": False}
|
|
14
|
+
)
|
|
13
15
|
Base.metadata.create_all(bind=engine)
|
|
14
16
|
return sessionmaker(bind=engine)()
|
|
15
17
|
|
|
@@ -29,8 +31,10 @@ def seed_pricing(db):
|
|
|
29
31
|
thinking_price_per_1m=3.0,
|
|
30
32
|
cached_input_price_per_1m=4.0,
|
|
31
33
|
currency="USD",
|
|
32
|
-
effective_from=datetime(2026, 1, 1),
|
|
33
|
-
|
|
34
|
+
# effective_from=datetime(2026, 1, 1),
|
|
35
|
+
effective_from=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
|
36
|
+
# effective_to=datetime(2026, 6, 30),
|
|
37
|
+
effective_to=datetime(2026, 6, 30, tzinfo=timezone.utc),
|
|
34
38
|
)
|
|
35
39
|
)
|
|
36
40
|
db.commit()
|
|
@@ -40,7 +44,12 @@ def seed_pricing(db):
|
|
|
40
44
|
def test_get_applicable_pricing_returns_version_for_timestamp():
|
|
41
45
|
db = make_session()
|
|
42
46
|
model = seed_pricing(db)
|
|
43
|
-
pricing = get_applicable_pricing(db, model.id, datetime(2026, 3, 1))
|
|
47
|
+
# pricing = get_applicable_pricing(db, model.id, datetime(2026, 3, 1))
|
|
48
|
+
pricing = get_applicable_pricing(
|
|
49
|
+
db,
|
|
50
|
+
model.id,
|
|
51
|
+
datetime(2026, 3, 1, tzinfo=timezone.utc),
|
|
52
|
+
)
|
|
44
53
|
assert pricing is not None
|
|
45
54
|
assert pricing.input_price_per_1m == 1.0
|
|
46
55
|
|
|
@@ -59,7 +68,8 @@ def test_create_usage_applies_pricing_snapshot():
|
|
|
59
68
|
"output_tokens": 1_000_000,
|
|
60
69
|
"thinking_tokens": 1_000_000,
|
|
61
70
|
"cached_tokens": 1_000_000,
|
|
62
|
-
"timestamp": datetime(2026, 3, 1),
|
|
71
|
+
# "timestamp": datetime(2026, 3, 1),
|
|
72
|
+
"timestamp": datetime(2026, 3, 1, tzinfo=timezone.utc),
|
|
63
73
|
},
|
|
64
74
|
)
|
|
65
75
|
assert usage.total_cost == 10.0
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
from . import analytics, models, pricing, projects, providers, usage
|
|
@@ -1,91 +0,0 @@
|
|
|
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]
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
[build-system]
|
|
2
|
-
requires = ["setuptools>=68", "wheel"]
|
|
3
|
-
build-backend = "setuptools.build_meta"
|
|
4
|
-
|
|
5
|
-
[project]
|
|
6
|
-
name = "llmapi-tracker"
|
|
7
|
-
version = "1.0.0"
|
|
8
|
-
description = "Local-first API usage and cost tracking platform"
|
|
9
|
-
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.10"
|
|
11
|
-
license = { text = "MIT" }
|
|
12
|
-
authors = [
|
|
13
|
-
{ name = "API Tracker" }
|
|
14
|
-
]
|
|
15
|
-
dependencies = [
|
|
16
|
-
"requests>=2.32",
|
|
17
|
-
"fastapi>=0.115",
|
|
18
|
-
"uvicorn[standard]>=0.30",
|
|
19
|
-
"sqlalchemy>=2.0",
|
|
20
|
-
"pydantic>=2.8",
|
|
21
|
-
]
|
|
22
|
-
|
|
23
|
-
[project.optional-dependencies]
|
|
24
|
-
openai = ["openai>=1.40"]
|
|
25
|
-
gemini = ["google-genai>=0.7"]
|
|
26
|
-
all = ["openai>=1.40", "google-genai>=0.7"]
|
|
27
|
-
dev = ["pytest>=8.0", "httpx>=0.27", "ruff>=0.6", "build>=1.2", "twine>=5.1"]
|
|
28
|
-
|
|
29
|
-
[project.scripts]
|
|
30
|
-
api-tracker = "backend.app.launcher:launch_app"
|
|
31
|
-
|
|
32
|
-
[tool.setuptools]
|
|
33
|
-
package-dir = {"backend" = "backend", "api_tracker" = "sdk/api_tracker"}
|
|
34
|
-
packages = [
|
|
35
|
-
"backend",
|
|
36
|
-
"backend.app",
|
|
37
|
-
"backend.app.routes",
|
|
38
|
-
"backend.app.services",
|
|
39
|
-
"api_tracker",
|
|
40
|
-
"api_tracker.providers",
|
|
41
|
-
]
|
|
42
|
-
|
|
43
|
-
[tool.setuptools.package-data]
|
|
44
|
-
api_tracker = ["py.typed"]
|
|
45
|
-
backend = ["app/static/**/*"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|