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
api_tracker/__init__.py
ADDED
api_tracker/client.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from .providers.openai import (
|
|
2
|
+
OpenAITracker,
|
|
3
|
+
)
|
|
4
|
+
|
|
5
|
+
from .providers.gemini import (
|
|
6
|
+
GeminiTracker,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class APITracker:
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
project: str,
|
|
15
|
+
backend_url:
|
|
16
|
+
str = "http://localhost:8000",
|
|
17
|
+
openai_api_key: str = None,
|
|
18
|
+
gemini_api_key: str = None,
|
|
19
|
+
):
|
|
20
|
+
|
|
21
|
+
self.project = project
|
|
22
|
+
|
|
23
|
+
self.backend_url = backend_url
|
|
24
|
+
|
|
25
|
+
self.openai = None
|
|
26
|
+
|
|
27
|
+
self.gemini = None
|
|
28
|
+
|
|
29
|
+
if openai_api_key:
|
|
30
|
+
|
|
31
|
+
self.openai = OpenAITracker(
|
|
32
|
+
|
|
33
|
+
api_key=openai_api_key,
|
|
34
|
+
|
|
35
|
+
project=project,
|
|
36
|
+
|
|
37
|
+
backend_url=backend_url,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if gemini_api_key:
|
|
41
|
+
|
|
42
|
+
self.gemini = GeminiTracker(
|
|
43
|
+
|
|
44
|
+
api_key=gemini_api_key,
|
|
45
|
+
|
|
46
|
+
project=project,
|
|
47
|
+
|
|
48
|
+
backend_url=backend_url,
|
|
49
|
+
)
|
api_tracker/models.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class UsageData:
|
|
9
|
+
project: str
|
|
10
|
+
provider: str
|
|
11
|
+
model: str
|
|
12
|
+
internal_request_id: str
|
|
13
|
+
provider_request_id: Optional[str] = None
|
|
14
|
+
input_tokens: int = 0
|
|
15
|
+
output_tokens: int = 0
|
|
16
|
+
thinking_tokens: int = 0
|
|
17
|
+
cached_tokens: int = 0
|
|
18
|
+
total_tokens: int = 0
|
|
19
|
+
audio_seconds: float = 0.0
|
|
20
|
+
characters: int = 0
|
|
21
|
+
request_count: int = 1
|
|
22
|
+
latency_ms: float = 0.0
|
|
23
|
+
status: str = "success"
|
|
24
|
+
http_status_code: Optional[int] = None
|
|
25
|
+
error_type: Optional[str] = None
|
|
26
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
27
|
+
capture_content: bool = False
|
|
28
|
+
|
|
29
|
+
def to_dict(self) -> dict[str, Any]:
|
|
30
|
+
return self.__dict__.copy()
|
api_tracker/pricing.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
PRICING = {
|
|
2
|
+
|
|
3
|
+
"openai": {
|
|
4
|
+
|
|
5
|
+
"gpt-5": {
|
|
6
|
+
"input": 0.0,
|
|
7
|
+
"output": 0.0,
|
|
8
|
+
"thinking": 0.0,
|
|
9
|
+
"cached": 0.0,
|
|
10
|
+
},
|
|
11
|
+
|
|
12
|
+
},
|
|
13
|
+
|
|
14
|
+
"gemini": {
|
|
15
|
+
|
|
16
|
+
"gemini-2.5-flash": {
|
|
17
|
+
"input": 0.0,
|
|
18
|
+
"output": 0.0,
|
|
19
|
+
"thinking": 0.0,
|
|
20
|
+
"cached": 0.0,
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def calculate_cost(
|
|
28
|
+
provider: str,
|
|
29
|
+
model: str,
|
|
30
|
+
input_tokens: int = 0,
|
|
31
|
+
output_tokens: int = 0,
|
|
32
|
+
thinking_tokens: int = 0,
|
|
33
|
+
cached_tokens: int = 0,
|
|
34
|
+
):
|
|
35
|
+
provider_data = PRICING.get(
|
|
36
|
+
provider.lower(),
|
|
37
|
+
{},
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
model_data = provider_data.get(
|
|
41
|
+
model,
|
|
42
|
+
{
|
|
43
|
+
"input": 0.0,
|
|
44
|
+
"output": 0.0,
|
|
45
|
+
"thinking": 0.0,
|
|
46
|
+
"cached": 0.0,
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
input_cost = (
|
|
51
|
+
input_tokens / 1_000_000
|
|
52
|
+
) * model_data["input"]
|
|
53
|
+
|
|
54
|
+
output_cost = (
|
|
55
|
+
output_tokens / 1_000_000
|
|
56
|
+
) * model_data["output"]
|
|
57
|
+
|
|
58
|
+
thinking_cost = (
|
|
59
|
+
thinking_tokens / 1_000_000
|
|
60
|
+
) * model_data["thinking"]
|
|
61
|
+
|
|
62
|
+
cached_cost = (
|
|
63
|
+
cached_tokens / 1_000_000
|
|
64
|
+
) * model_data["cached"]
|
|
65
|
+
|
|
66
|
+
total_cost = (
|
|
67
|
+
input_cost
|
|
68
|
+
+ output_cost
|
|
69
|
+
+ thinking_cost
|
|
70
|
+
+ cached_cost
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
"input_cost": input_cost,
|
|
75
|
+
"output_cost": output_cost,
|
|
76
|
+
"thinking_cost": thinking_cost,
|
|
77
|
+
"cached_cost": cached_cost,
|
|
78
|
+
"total_cost": total_cost,
|
|
79
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from google import genai
|
|
6
|
+
|
|
7
|
+
from ..tracker import Tracker
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GeminiTracker:
|
|
11
|
+
def __init__(self, api_key: str, project: str, backend_url: str = "http://localhost:8000"):
|
|
12
|
+
self.client = genai.Client(api_key=api_key)
|
|
13
|
+
self.tracker = Tracker(project=project, provider="gemini", backend_url=backend_url)
|
|
14
|
+
|
|
15
|
+
def generate(self, model: str, contents, **kwargs):
|
|
16
|
+
start = time.perf_counter()
|
|
17
|
+
try:
|
|
18
|
+
response = self.client.models.generate_content(model=model, contents=contents, **kwargs)
|
|
19
|
+
latency_ms = (time.perf_counter() - start) * 1000
|
|
20
|
+
usage = getattr(response, "usage_metadata", None)
|
|
21
|
+
self.tracker.record(
|
|
22
|
+
model=model,
|
|
23
|
+
input_tokens=getattr(usage, "prompt_token_count", 0) if usage else 0,
|
|
24
|
+
output_tokens=getattr(usage, "candidates_token_count", 0) if usage else 0,
|
|
25
|
+
thinking_tokens=getattr(usage, "thoughts_token_count", 0) if usage else 0,
|
|
26
|
+
cached_tokens=getattr(usage, "cached_content_token_count", 0) if usage else 0,
|
|
27
|
+
total_tokens=getattr(usage, "total_token_count", 0) if usage else 0,
|
|
28
|
+
provider_request_id=getattr(response, "response_id", None),
|
|
29
|
+
latency_ms=latency_ms,
|
|
30
|
+
status="success",
|
|
31
|
+
)
|
|
32
|
+
return response
|
|
33
|
+
except Exception as exc:
|
|
34
|
+
latency_ms = (time.perf_counter() - start) * 1000
|
|
35
|
+
self.tracker.record(model=model, latency_ms=latency_ms, status="error", error_type=type(exc).__name__)
|
|
36
|
+
raise
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from openai import OpenAI
|
|
6
|
+
|
|
7
|
+
from ..tracker import Tracker
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OpenAITracker:
|
|
11
|
+
def __init__(self, api_key: str, project: str, backend_url: str = "http://localhost:8000"):
|
|
12
|
+
self.client = OpenAI(api_key=api_key)
|
|
13
|
+
self.tracker = Tracker(project=project, provider="openai", backend_url=backend_url)
|
|
14
|
+
|
|
15
|
+
def chat(self, model: str, messages: list, **kwargs):
|
|
16
|
+
start = time.perf_counter()
|
|
17
|
+
try:
|
|
18
|
+
response = self.client.chat.completions.create(model=model, messages=messages, **kwargs)
|
|
19
|
+
latency_ms = (time.perf_counter() - start) * 1000
|
|
20
|
+
usage = getattr(response, "usage", None)
|
|
21
|
+
self.tracker.record(
|
|
22
|
+
model=model,
|
|
23
|
+
input_tokens=getattr(usage, "prompt_tokens", 0) if usage else 0,
|
|
24
|
+
output_tokens=getattr(usage, "completion_tokens", 0) if usage else 0,
|
|
25
|
+
thinking_tokens=getattr(usage, "reasoning_tokens", 0) if usage else 0,
|
|
26
|
+
cached_tokens=getattr(usage, "cached_tokens", 0) if usage else 0,
|
|
27
|
+
total_tokens=getattr(usage, "total_tokens", 0) if usage else 0,
|
|
28
|
+
provider_request_id=getattr(response, "_request_id", None),
|
|
29
|
+
latency_ms=latency_ms,
|
|
30
|
+
status="success",
|
|
31
|
+
)
|
|
32
|
+
return response
|
|
33
|
+
except Exception as exc:
|
|
34
|
+
latency_ms = (time.perf_counter() - start) * 1000
|
|
35
|
+
self.tracker.record(model=model, latency_ms=latency_ms, status="error", error_type=type(exc).__name__)
|
|
36
|
+
raise
|
api_tracker/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
api_tracker/tracker.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
from .models import UsageData
|
|
7
|
+
from .transport import TrackerTransport
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Tracker:
|
|
13
|
+
def __init__(self, project: str, provider: str, backend_url: str = "http://localhost:8000"):
|
|
14
|
+
self.project = project
|
|
15
|
+
self.provider = provider
|
|
16
|
+
self.transport = TrackerTransport(backend_url)
|
|
17
|
+
|
|
18
|
+
def record(self, model: str, **kwargs):
|
|
19
|
+
def _int_or_zero(value):
|
|
20
|
+
return 0 if value is None else int(value)
|
|
21
|
+
|
|
22
|
+
usage = UsageData(
|
|
23
|
+
project=self.project,
|
|
24
|
+
provider=self.provider,
|
|
25
|
+
model=model,
|
|
26
|
+
internal_request_id=kwargs.get("internal_request_id") or str(uuid.uuid4()),
|
|
27
|
+
provider_request_id=kwargs.get("provider_request_id"),
|
|
28
|
+
input_tokens=_int_or_zero(kwargs.get("input_tokens", 0)),
|
|
29
|
+
output_tokens=_int_or_zero(kwargs.get("output_tokens", 0)),
|
|
30
|
+
thinking_tokens=_int_or_zero(kwargs.get("thinking_tokens", 0)),
|
|
31
|
+
cached_tokens=_int_or_zero(kwargs.get("cached_tokens", 0)),
|
|
32
|
+
total_tokens=_int_or_zero(kwargs.get("total_tokens", 0)),
|
|
33
|
+
audio_seconds=kwargs.get("audio_seconds", 0.0),
|
|
34
|
+
characters=kwargs.get("characters", 0),
|
|
35
|
+
request_count=kwargs.get("request_count", 1),
|
|
36
|
+
latency_ms=kwargs.get("latency_ms", 0.0),
|
|
37
|
+
status=kwargs.get("status", "success"),
|
|
38
|
+
http_status_code=kwargs.get("http_status_code"),
|
|
39
|
+
error_type=kwargs.get("error_type"),
|
|
40
|
+
metadata=kwargs.get("metadata", {}),
|
|
41
|
+
capture_content=kwargs.get("capture_content", False),
|
|
42
|
+
)
|
|
43
|
+
try:
|
|
44
|
+
self.transport.send_usage(usage.to_dict())
|
|
45
|
+
except Exception as exc:
|
|
46
|
+
logger.exception("Tracking failed: %s", exc)
|
|
47
|
+
return usage
|
api_tracker/transport.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TrackerTransport:
|
|
11
|
+
def __init__(self, endpoint: str, timeout: float = 2.0):
|
|
12
|
+
self.endpoint = endpoint.rstrip("/")
|
|
13
|
+
self.timeout = timeout
|
|
14
|
+
|
|
15
|
+
def send_usage(self, usage: dict) -> bool:
|
|
16
|
+
try:
|
|
17
|
+
response = requests.post(f"{self.endpoint}/usage", json=usage, timeout=self.timeout)
|
|
18
|
+
if not response.ok:
|
|
19
|
+
print("Usage payload:", usage)
|
|
20
|
+
print("Backend response:", response.status_code, response.text)
|
|
21
|
+
response.raise_for_status()
|
|
22
|
+
return True
|
|
23
|
+
except Exception as exc:
|
|
24
|
+
logger.exception("API Tracker failed to record usage: %s", exc)
|
|
25
|
+
return False
|
backend/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
backend/app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
backend/app/database.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import create_engine
|
|
6
|
+
from sqlalchemy.orm import declarative_base, sessionmaker
|
|
7
|
+
|
|
8
|
+
from .paths import get_database_url
|
|
9
|
+
|
|
10
|
+
DATABASE_URL = get_database_url()
|
|
11
|
+
|
|
12
|
+
connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
|
|
13
|
+
|
|
14
|
+
engine = create_engine(DATABASE_URL, connect_args=connect_args, future=True)
|
|
15
|
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, future=True)
|
|
16
|
+
Base = declarative_base()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_db():
|
|
20
|
+
db = SessionLocal()
|
|
21
|
+
try:
|
|
22
|
+
yield db
|
|
23
|
+
finally:
|
|
24
|
+
db.close()
|
backend/app/frontend.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter
|
|
6
|
+
from fastapi.responses import FileResponse
|
|
7
|
+
from fastapi.staticfiles import StaticFiles
|
|
8
|
+
|
|
9
|
+
from .paths import get_frontend_dist_dir
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def mount_frontend(app, prefix: str = "") -> None:
|
|
13
|
+
static_dir = get_frontend_dist_dir()
|
|
14
|
+
if static_dir.exists():
|
|
15
|
+
app.mount(prefix or "/assets", StaticFiles(directory=static_dir / "assets"), name="assets")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def register_frontend_routes(router: APIRouter) -> None:
|
|
19
|
+
static_dir = get_frontend_dist_dir()
|
|
20
|
+
index_path = static_dir / "index.html"
|
|
21
|
+
|
|
22
|
+
if not index_path.exists():
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
@router.get("/{path:path}")
|
|
26
|
+
@router.get("/")
|
|
27
|
+
def serve_frontend(path: str = ""):
|
|
28
|
+
target = static_dir / path
|
|
29
|
+
if path and target.exists() and target.is_file():
|
|
30
|
+
return FileResponse(target)
|
|
31
|
+
return FileResponse(index_path)
|
backend/app/launcher.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import threading
|
|
5
|
+
import webbrowser
|
|
6
|
+
|
|
7
|
+
import uvicorn
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def launch_app() -> None:
|
|
11
|
+
host = os.getenv("API_TRACKER_HOST", "127.0.0.1")
|
|
12
|
+
port = int(os.getenv("API_TRACKER_PORT", "8000"))
|
|
13
|
+
open_browser = os.getenv("API_TRACKER_OPEN_BROWSER", "true").lower() not in {"0", "false", "no"}
|
|
14
|
+
if open_browser:
|
|
15
|
+
url = f"http://{host}:{port}"
|
|
16
|
+
threading.Timer(1.0, lambda: webbrowser.open(url)).start()
|
|
17
|
+
uvicorn.run("backend.app.main:app", host=host, port=port, reload=False)
|
backend/app/main.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI
|
|
4
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
5
|
+
from fastapi.responses import FileResponse
|
|
6
|
+
from fastapi.staticfiles import StaticFiles
|
|
7
|
+
|
|
8
|
+
from .database import Base, engine
|
|
9
|
+
from .paths import get_frontend_dist_dir
|
|
10
|
+
from .routes import analytics, models, pricing, projects, providers, usage
|
|
11
|
+
|
|
12
|
+
Base.metadata.create_all(bind=engine)
|
|
13
|
+
|
|
14
|
+
app = FastAPI(title="API Tracker", version="1.0.0")
|
|
15
|
+
app.add_middleware(
|
|
16
|
+
CORSMiddleware,
|
|
17
|
+
allow_origins=["*"],
|
|
18
|
+
allow_credentials=True,
|
|
19
|
+
allow_methods=["*"],
|
|
20
|
+
allow_headers=["*"],
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
app.include_router(projects.router)
|
|
24
|
+
app.include_router(usage.router)
|
|
25
|
+
app.include_router(pricing.router)
|
|
26
|
+
app.include_router(providers.router)
|
|
27
|
+
app.include_router(models.router)
|
|
28
|
+
app.include_router(analytics.router)
|
|
29
|
+
|
|
30
|
+
frontend_dist = get_frontend_dist_dir()
|
|
31
|
+
index_file = frontend_dist / "index.html"
|
|
32
|
+
assets_dir = frontend_dist / "assets"
|
|
33
|
+
|
|
34
|
+
if assets_dir.exists():
|
|
35
|
+
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.get("/")
|
|
39
|
+
def root():
|
|
40
|
+
if index_file.exists():
|
|
41
|
+
return FileResponse(index_file)
|
|
42
|
+
return {"name": "API Tracker", "status": "running"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.get("/{path:path}")
|
|
46
|
+
def frontend_fallback(path: str):
|
|
47
|
+
target = frontend_dist / path
|
|
48
|
+
if target.exists() and target.is_file():
|
|
49
|
+
return FileResponse(target)
|
|
50
|
+
if index_file.exists():
|
|
51
|
+
return FileResponse(index_file)
|
|
52
|
+
return {"detail": "frontend not built"}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.get("/health")
|
|
56
|
+
def health():
|
|
57
|
+
return {"status": "healthy"}
|
backend/app/models.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import (
|
|
6
|
+
JSON,
|
|
7
|
+
Boolean,
|
|
8
|
+
Column,
|
|
9
|
+
DateTime,
|
|
10
|
+
Float,
|
|
11
|
+
ForeignKey,
|
|
12
|
+
Integer,
|
|
13
|
+
String,
|
|
14
|
+
Text,
|
|
15
|
+
UniqueConstraint,
|
|
16
|
+
)
|
|
17
|
+
from sqlalchemy.orm import relationship
|
|
18
|
+
|
|
19
|
+
from .database import Base
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def utcnow() -> datetime:
|
|
23
|
+
return datetime.utcnow()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Project(Base):
|
|
27
|
+
__tablename__ = "projects"
|
|
28
|
+
|
|
29
|
+
id = Column(Integer, primary_key=True, index=True)
|
|
30
|
+
name = Column(String(150), unique=True, nullable=False, index=True)
|
|
31
|
+
description = Column(Text, nullable=True)
|
|
32
|
+
environment = Column(String(50), default="development", nullable=False)
|
|
33
|
+
created_at = Column(DateTime, default=utcnow, nullable=False)
|
|
34
|
+
|
|
35
|
+
usage = relationship("ApiUsage", back_populates="project")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Provider(Base):
|
|
39
|
+
__tablename__ = "providers"
|
|
40
|
+
|
|
41
|
+
id = Column(Integer, primary_key=True, index=True)
|
|
42
|
+
name = Column(String(100), unique=True, nullable=False, index=True)
|
|
43
|
+
created_at = Column(DateTime, default=utcnow, nullable=False)
|
|
44
|
+
|
|
45
|
+
models = relationship("Model", back_populates="provider", cascade="all, delete-orphan")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Model(Base):
|
|
49
|
+
__tablename__ = "models"
|
|
50
|
+
__table_args__ = (UniqueConstraint("provider_id", "model_name", name="uq_provider_model_name"),)
|
|
51
|
+
|
|
52
|
+
id = Column(Integer, primary_key=True, index=True)
|
|
53
|
+
provider_id = Column(Integer, ForeignKey("providers.id"), nullable=False, index=True)
|
|
54
|
+
model_name = Column(String(150), nullable=False, index=True)
|
|
55
|
+
model_type = Column(String(50), default="text", nullable=False)
|
|
56
|
+
created_at = Column(DateTime, default=utcnow, nullable=False)
|
|
57
|
+
|
|
58
|
+
provider = relationship("Provider", back_populates="models")
|
|
59
|
+
pricing = relationship("ModelPricing", back_populates="model", cascade="all, delete-orphan")
|
|
60
|
+
usage = relationship("ApiUsage", back_populates="model_ref")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ModelPricing(Base):
|
|
64
|
+
__tablename__ = "model_pricing"
|
|
65
|
+
__table_args__ = (
|
|
66
|
+
UniqueConstraint("model_id", "effective_from", name="uq_model_pricing_version"),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
id = Column(Integer, primary_key=True, index=True)
|
|
70
|
+
model_id = Column(Integer, ForeignKey("models.id"), nullable=False, index=True)
|
|
71
|
+
input_price_per_1m = Column(Float, default=0.0, nullable=False)
|
|
72
|
+
output_price_per_1m = Column(Float, default=0.0, nullable=False)
|
|
73
|
+
thinking_price_per_1m = Column(Float, default=0.0, nullable=False)
|
|
74
|
+
cached_input_price_per_1m = Column(Float, default=0.0, nullable=False)
|
|
75
|
+
currency = Column(String(10), default="USD", nullable=False)
|
|
76
|
+
effective_from = Column(DateTime, default=utcnow, nullable=False, index=True)
|
|
77
|
+
effective_to = Column(DateTime, nullable=True, index=True)
|
|
78
|
+
created_at = Column(DateTime, default=utcnow, nullable=False)
|
|
79
|
+
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow, nullable=False)
|
|
80
|
+
|
|
81
|
+
model = relationship("Model", back_populates="pricing")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ApiUsage(Base):
|
|
85
|
+
__tablename__ = "api_usage"
|
|
86
|
+
|
|
87
|
+
id = Column(Integer, primary_key=True, index=True)
|
|
88
|
+
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False, index=True)
|
|
89
|
+
provider_id = Column(Integer, ForeignKey("providers.id"), nullable=False, index=True)
|
|
90
|
+
model_id = Column(Integer, ForeignKey("models.id"), nullable=False, index=True)
|
|
91
|
+
internal_request_id = Column(String(255), nullable=False, unique=True, index=True)
|
|
92
|
+
provider_request_id = Column(String(255), nullable=True, index=True)
|
|
93
|
+
timestamp = Column(DateTime, default=utcnow, nullable=False, index=True)
|
|
94
|
+
input_tokens = Column(Integer, default=0, nullable=False)
|
|
95
|
+
output_tokens = Column(Integer, default=0, nullable=False)
|
|
96
|
+
thinking_tokens = Column(Integer, default=0, nullable=False)
|
|
97
|
+
cached_tokens = Column(Integer, default=0, nullable=False)
|
|
98
|
+
total_tokens = Column(Integer, default=0, nullable=False)
|
|
99
|
+
input_cost = Column(Float, default=0.0, nullable=False)
|
|
100
|
+
output_cost = Column(Float, default=0.0, nullable=False)
|
|
101
|
+
thinking_cost = Column(Float, default=0.0, nullable=False)
|
|
102
|
+
cached_cost = Column(Float, default=0.0, nullable=False)
|
|
103
|
+
total_cost = Column(Float, default=0.0, nullable=False)
|
|
104
|
+
audio_seconds = Column(Float, default=0.0, nullable=False)
|
|
105
|
+
characters = Column(Integer, default=0, nullable=False)
|
|
106
|
+
request_count = Column(Integer, default=1, nullable=False)
|
|
107
|
+
latency_ms = Column(Float, default=0.0, nullable=False)
|
|
108
|
+
status = Column(String(30), default="success", nullable=False)
|
|
109
|
+
http_status_code = Column(Integer, nullable=True)
|
|
110
|
+
error_type = Column(String(100), nullable=True)
|
|
111
|
+
metadata_json = Column(JSON, default=dict, nullable=False)
|
|
112
|
+
capture_content = Column(Boolean, default=False, nullable=False)
|
|
113
|
+
|
|
114
|
+
project = relationship("Project", back_populates="usage")
|
|
115
|
+
provider = relationship("Provider")
|
|
116
|
+
model_ref = relationship("Model", back_populates="usage")
|
backend/app/paths.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
APP_NAME = "API Tracker"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _ensure_dir(path: Path) -> Path:
|
|
11
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
12
|
+
return path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_data_dir() -> Path:
|
|
16
|
+
override = os.getenv("API_TRACKER_DATA_DIR")
|
|
17
|
+
if override:
|
|
18
|
+
return _ensure_dir(Path(override).expanduser())
|
|
19
|
+
|
|
20
|
+
if os.name == "nt":
|
|
21
|
+
base = os.getenv("LOCALAPPDATA") or Path.home() / "AppData" / "Local"
|
|
22
|
+
preferred = Path(base) / "APITracker"
|
|
23
|
+
try:
|
|
24
|
+
return _ensure_dir(preferred)
|
|
25
|
+
except PermissionError:
|
|
26
|
+
return _ensure_dir(Path.cwd() / ".api-tracker-data")
|
|
27
|
+
|
|
28
|
+
if os.name == "posix":
|
|
29
|
+
base = os.getenv("XDG_DATA_HOME")
|
|
30
|
+
if base:
|
|
31
|
+
preferred = Path(base) / "APITracker"
|
|
32
|
+
else:
|
|
33
|
+
preferred = Path.home() / ".local" / "share" / "APITracker"
|
|
34
|
+
try:
|
|
35
|
+
return _ensure_dir(preferred)
|
|
36
|
+
except PermissionError:
|
|
37
|
+
return _ensure_dir(Path.cwd() / ".api-tracker-data")
|
|
38
|
+
|
|
39
|
+
return _ensure_dir(Path.cwd() / ".api-tracker-data")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_database_path() -> Path:
|
|
43
|
+
override = os.getenv("API_TRACKER_DB_PATH")
|
|
44
|
+
if override:
|
|
45
|
+
path = Path(override).expanduser()
|
|
46
|
+
else:
|
|
47
|
+
path = get_data_dir() / "api_tracker.db"
|
|
48
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
return path
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_database_url() -> str:
|
|
53
|
+
override = os.getenv("DATABASE_URL")
|
|
54
|
+
if override:
|
|
55
|
+
return override
|
|
56
|
+
return f"sqlite:///{get_database_path().as_posix()}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_frontend_dist_dir() -> Path:
|
|
60
|
+
return Path(__file__).resolve().parent / "static"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from . import analytics, models, pricing, projects, providers, usage
|