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,5 @@
1
+ from .client import APITracker
2
+ from .providers.gemini import GeminiTracker
3
+ from .providers.openai import OpenAITracker
4
+
5
+ __all__ = ["APITracker", "OpenAITracker", "GeminiTracker"]
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,8 @@
1
+ from .openai import OpenAITracker
2
+ from .gemini import GeminiTracker
3
+
4
+
5
+ __all__ = [
6
+ "OpenAITracker",
7
+ "GeminiTracker",
8
+ ]
@@ -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
@@ -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
+
@@ -0,0 +1 @@
1
+
@@ -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()
@@ -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)
@@ -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