llm-tracker 0.2.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,24 @@
1
+ from dotenv import load_dotenv
2
+
3
+ # Load .env before any submodule reads os.environ at import time, regardless
4
+ # of whether/when the consuming application calls load_dotenv() itself.
5
+ load_dotenv()
6
+
7
+ from .client import (
8
+ TrackedAsyncAzureOpenAI,
9
+ TrackedAsyncOpenAI,
10
+ TrackedAzureOpenAI,
11
+ TrackedOpenAI,
12
+ request_ctx,
13
+ )
14
+
15
+ __version__ = "0.2.0"
16
+
17
+ __all__ = [
18
+ "TrackedAzureOpenAI",
19
+ "TrackedOpenAI",
20
+ "TrackedAsyncAzureOpenAI",
21
+ "TrackedAsyncOpenAI",
22
+ "request_ctx",
23
+ "__version__",
24
+ ]
llm_tracker/client.py ADDED
@@ -0,0 +1,173 @@
1
+ import logging
2
+ import os
3
+ import time
4
+ import contextvars
5
+ from openai import AsyncAzureOpenAI, AsyncOpenAI, AzureOpenAI, OpenAI
6
+ from .pricing import get_cost
7
+ from .logger import log_usage
8
+
9
+ logger = logging.getLogger("llm_tracker")
10
+
11
+ # Holds per-request metadata (api_name, environment, user_id, request_id).
12
+ # Set automatically by middleware.py for FastAPI routes, or left as default
13
+ # for manual/test scripts. `default=None` (rather than a mutable {}) avoids
14
+ # every unset context sharing — and potentially mutating — the same dict.
15
+ request_ctx = contextvars.ContextVar("request_ctx", default=None)
16
+
17
+
18
+ def _get_ctx():
19
+ return request_ctx.get() or {}
20
+
21
+
22
+ # Default environment label when nothing is set via middleware or ctx.
23
+ # Individual devs running local test scripts should set:
24
+ # export LLM_TRACKER_DEFAULT_ENV=test
25
+ DEFAULT_ENVIRONMENT = os.getenv("LLM_TRACKER_DEFAULT_ENV", "test")
26
+
27
+
28
+ def _build_record(deployment, usage, latency_ms):
29
+ ctx = _get_ctx()
30
+ prompt_tokens = getattr(usage, "prompt_tokens", None)
31
+ completion_tokens = getattr(usage, "completion_tokens", None)
32
+ total_tokens = getattr(usage, "total_tokens", None)
33
+ cost = get_cost(deployment, prompt_tokens, completion_tokens)
34
+
35
+ return {
36
+ "api_name": ctx.get("api_name", "manual_script"),
37
+ "endpoint": ctx.get("endpoint"),
38
+ "environment": ctx.get("environment", DEFAULT_ENVIRONMENT),
39
+ "user_id": ctx.get("user_id"),
40
+ "request_id": ctx.get("request_id"),
41
+ "deployment": deployment,
42
+ "prompt_tokens": prompt_tokens,
43
+ "completion_tokens": completion_tokens,
44
+ "total_tokens": total_tokens,
45
+ "cost_usd": cost,
46
+ "latency_ms": latency_ms,
47
+ }
48
+
49
+
50
+ def _track_response(deployment, response, start):
51
+ latency_ms = int((time.time() - start) * 1000)
52
+ usage = getattr(response, "usage", None)
53
+ if usage is None:
54
+ logger.debug("no usage info on response for model=%s, skipping cost tracking", deployment)
55
+ return
56
+ log_usage(_build_record(deployment, usage, latency_ms))
57
+
58
+
59
+ class TrackedAzureOpenAI:
60
+ """
61
+ Drop-in replacement for openai.AzureOpenAI.
62
+ Usage is identical to the original SDK — only the import changes.
63
+
64
+ from llm_tracker import TrackedAzureOpenAI
65
+ client = TrackedAzureOpenAI(
66
+ api_key=os.getenv("AZURE_OPENAI_API_KEY"),
67
+ azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"),
68
+ api_version=os.getenv("AZURE_OPENAI_API_VERSION"),
69
+ )
70
+ response = client.chat.completions.create(model="gpt-4o-mini", messages=[...])
71
+ """
72
+
73
+ def __init__(self, **kwargs):
74
+ self._client = AzureOpenAI(**kwargs)
75
+ self.chat = _TrackedChat(self._client)
76
+
77
+ def __getattr__(self, name):
78
+ return getattr(self._client, name)
79
+
80
+
81
+ class TrackedOpenAI:
82
+ """
83
+ Drop-in replacement for openai.OpenAI.
84
+ Usage is identical to the original SDK — only the import changes.
85
+
86
+ from llm_tracker import TrackedOpenAI
87
+ client = TrackedOpenAI(
88
+ api_key=os.getenv("OPENAI_API_KEY"),
89
+ base_url=os.getenv("AZURE_OPENAI_ENDPOINT"), # optional, for Azure routing
90
+ )
91
+ response = client.chat.completions.create(model="gpt-4o", messages=[...])
92
+ """
93
+
94
+ def __init__(self, **kwargs):
95
+ self._client = OpenAI(**kwargs)
96
+ self.chat = _TrackedChat(self._client)
97
+
98
+ def __getattr__(self, name):
99
+ return getattr(self._client, name)
100
+
101
+
102
+ class _TrackedChat:
103
+ def __init__(self, client):
104
+ self.completions = _TrackedCompletions(client)
105
+
106
+
107
+ class _TrackedCompletions:
108
+ def __init__(self, client):
109
+ self._client = client
110
+
111
+ def create(self, **kwargs):
112
+ deployment = kwargs.get("model", "unknown")
113
+ start = time.time()
114
+ response = self._client.chat.completions.create(**kwargs)
115
+ _track_response(deployment, response, start)
116
+ return response
117
+
118
+
119
+ class TrackedAsyncAzureOpenAI:
120
+ """
121
+ Drop-in replacement for openai.AsyncAzureOpenAI.
122
+ Usage is identical to the original SDK — only the import changes.
123
+
124
+ from llm_tracker import TrackedAsyncAzureOpenAI
125
+ client = TrackedAsyncAzureOpenAI(
126
+ api_key=os.getenv("AZURE_OPENAI_API_KEY"),
127
+ azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"),
128
+ api_version=os.getenv("AZURE_OPENAI_API_VERSION"),
129
+ )
130
+ response = await client.chat.completions.create(model="gpt-4o-mini", messages=[...])
131
+ """
132
+
133
+ def __init__(self, **kwargs):
134
+ self._client = AsyncAzureOpenAI(**kwargs)
135
+ self.chat = _TrackedAsyncChat(self._client)
136
+
137
+ def __getattr__(self, name):
138
+ return getattr(self._client, name)
139
+
140
+
141
+ class TrackedAsyncOpenAI:
142
+ """
143
+ Drop-in replacement for openai.AsyncOpenAI.
144
+ Usage is identical to the original SDK — only the import changes.
145
+
146
+ from llm_tracker import TrackedAsyncOpenAI
147
+ client = TrackedAsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY"))
148
+ response = await client.chat.completions.create(model="gpt-4o", messages=[...])
149
+ """
150
+
151
+ def __init__(self, **kwargs):
152
+ self._client = AsyncOpenAI(**kwargs)
153
+ self.chat = _TrackedAsyncChat(self._client)
154
+
155
+ def __getattr__(self, name):
156
+ return getattr(self._client, name)
157
+
158
+
159
+ class _TrackedAsyncChat:
160
+ def __init__(self, client):
161
+ self.completions = _TrackedAsyncCompletions(client)
162
+
163
+
164
+ class _TrackedAsyncCompletions:
165
+ def __init__(self, client):
166
+ self._client = client
167
+
168
+ async def create(self, **kwargs):
169
+ deployment = kwargs.get("model", "unknown")
170
+ start = time.time()
171
+ response = await self._client.chat.completions.create(**kwargs)
172
+ _track_response(deployment, response, start)
173
+ return response
llm_tracker/db.py ADDED
@@ -0,0 +1,93 @@
1
+ import os
2
+ import uuid
3
+ import threading
4
+ from urllib.parse import quote
5
+ from sqlalchemy import create_engine, Column, String, Integer, DECIMAL, DateTime
6
+ from sqlalchemy.orm import sessionmaker, declarative_base
7
+ from datetime import datetime, timezone, timedelta
8
+
9
+ Base = declarative_base()
10
+
11
+ _engine = None
12
+ _SessionLocal = None
13
+ _init_lock = threading.Lock()
14
+
15
+
16
+ def _build_database_url():
17
+ db_user = os.getenv("LLM_TRACKER_DB_USER", "root")
18
+ db_password = os.getenv("LLM_TRACKER_DB_PASSWORD", "")
19
+ db_host = os.getenv("LLM_TRACKER_DB_HOST", "localhost")
20
+ db_port = os.getenv("LLM_TRACKER_DB_PORT", "3306")
21
+ db_name = os.getenv("LLM_TRACKER_DB_NAME", "oneclarity_db")
22
+
23
+ # URL-encode password to handle special characters like @ in passwords
24
+ db_password_encoded = quote(db_password, safe="")
25
+ return f"mysql+pymysql://{db_user}:{db_password_encoded}@{db_host}:{db_port}/{db_name}"
26
+
27
+
28
+ def _build_connect_args():
29
+ use_ssl = os.getenv("LLM_TRACKER_USE_SSL", "1").lower() in ("1", "true", "yes", "on")
30
+ ssl_ca = os.getenv("LLM_TRACKER_SSL_CA", "").strip() or None
31
+
32
+ # SSL connection args for managed MySQL providers (Azure, AWS RDS, etc.)
33
+ connect_args = {}
34
+ if use_ssl:
35
+ connect_args["ssl"] = {"ssl": True}
36
+ if ssl_ca:
37
+ connect_args["ssl"]["ca"] = ssl_ca
38
+ return connect_args
39
+
40
+
41
+ def get_engine():
42
+ """Lazily build the SQLAlchemy engine on first use, reading env vars at
43
+ that point rather than at import time — so `load_dotenv()` (called by
44
+ the caller, or by `llm_tracker/__init__.py`) always runs first."""
45
+ global _engine
46
+ if _engine is None:
47
+ with _init_lock:
48
+ if _engine is None:
49
+ _engine = create_engine(
50
+ _build_database_url(),
51
+ pool_size=5,
52
+ pool_recycle=3600,
53
+ connect_args=_build_connect_args(),
54
+ )
55
+ return _engine
56
+
57
+
58
+ def get_sessionmaker():
59
+ global _SessionLocal
60
+ if _SessionLocal is None:
61
+ with _init_lock:
62
+ if _SessionLocal is None:
63
+ _SessionLocal = sessionmaker(bind=get_engine())
64
+ return _SessionLocal
65
+
66
+
67
+ def get_ist_time():
68
+ """Get current time in IST (UTC+5:30)."""
69
+ ist = timezone(timedelta(hours=5, minutes=30))
70
+ return datetime.now(ist).replace(tzinfo=None)
71
+
72
+
73
+ class UsageLog(Base):
74
+ __tablename__ = "ai_llm_usage_logs"
75
+
76
+ id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
77
+ created_at = Column(DateTime, default=get_ist_time)
78
+ api_name = Column(String(255), index=True)
79
+ endpoint = Column(String(255), index=True)
80
+ environment = Column(String(20), index=True) # prod / test
81
+ user_id = Column(String(100))
82
+ request_id = Column(String(64))
83
+ deployment = Column(String(100))
84
+ prompt_tokens = Column(Integer)
85
+ completion_tokens = Column(Integer)
86
+ total_tokens = Column(Integer)
87
+ cost_usd = Column(DECIMAL(10, 6))
88
+ latency_ms = Column(Integer)
89
+
90
+
91
+ def init_db():
92
+ """Run once to create the table (or run schema.sql manually)."""
93
+ Base.metadata.create_all(get_engine())
llm_tracker/logger.py ADDED
@@ -0,0 +1,51 @@
1
+ import asyncio
2
+ import logging
3
+ import queue
4
+ import threading
5
+ from .db import UsageLog, get_sessionmaker
6
+
7
+ logger = logging.getLogger("llm_tracker")
8
+
9
+ _log_queue = queue.Queue()
10
+ _worker_started = False
11
+ _worker_lock = threading.Lock()
12
+
13
+
14
+ def _worker():
15
+ while True:
16
+ record = _log_queue.get()
17
+ try:
18
+ with get_sessionmaker()() as session:
19
+ session.add(UsageLog(**record))
20
+ session.commit()
21
+ except Exception:
22
+ logger.exception("failed to save usage log")
23
+ finally:
24
+ _log_queue.task_done()
25
+
26
+
27
+ def _ensure_worker_started():
28
+ global _worker_started
29
+ if not _worker_started:
30
+ with _worker_lock:
31
+ if not _worker_started:
32
+ threading.Thread(target=_worker, daemon=True, name="llm_tracker_writer").start()
33
+ _worker_started = True
34
+
35
+
36
+ def log_usage(record: dict):
37
+ """Enqueue a usage record. Never blocks the caller."""
38
+ _ensure_worker_started()
39
+ _log_queue.put(record)
40
+
41
+
42
+ def flush():
43
+ """Call at the end of a short-lived script to make sure all queued writes
44
+ finish before the process exits."""
45
+ _log_queue.join()
46
+
47
+
48
+ async def aflush():
49
+ """Async-friendly equivalent of flush() — awaits pending writes without
50
+ blocking the event loop."""
51
+ await asyncio.to_thread(_log_queue.join)
@@ -0,0 +1,39 @@
1
+ import os
2
+ import uuid
3
+ from starlette.middleware.base import BaseHTTPMiddleware
4
+ from .client import request_ctx
5
+
6
+ # Default environment for live traffic through the middleware.
7
+ # Devs testing an endpoint can override per-request by sending header: X-Env: test
8
+ DEFAULT_LIVE_ENV = os.getenv("LLM_TRACKER_DEFAULT_ENV", "prod")
9
+
10
+ # API name identifies the service/repo (e.g., "snapshot", "reporting")
11
+ # Set this in .env as LLM_TRACKER_API_NAME
12
+ API_NAME = os.getenv("LLM_TRACKER_API_NAME", "unknown")
13
+
14
+ # User ID identifies who built/owns this API service for cost attribution
15
+ # Set this in .env as LLM_TRACKER_USER_ID
16
+ USER_ID = os.getenv("LLM_TRACKER_USER_ID", None)
17
+
18
+
19
+ class LLMContextMiddleware(BaseHTTPMiddleware):
20
+ """
21
+ Register once in main.py:
22
+
23
+ from llm_tracker.middleware import LLMContextMiddleware
24
+ app.add_middleware(LLMContextMiddleware)
25
+
26
+ Automatically tags every LLM call made during a request with the
27
+ route path, environment, user id, and a unique request id — devs
28
+ don't need to pass any of this manually.
29
+ """
30
+
31
+ async def dispatch(self, request, call_next):
32
+ request_ctx.set({
33
+ "api_name": API_NAME,
34
+ "endpoint": request.url.path,
35
+ "environment": request.headers.get("X-Env", DEFAULT_LIVE_ENV),
36
+ "user_id": USER_ID,
37
+ "request_id": str(uuid.uuid4()),
38
+ })
39
+ return await call_next(request)
llm_tracker/pricing.py ADDED
@@ -0,0 +1,41 @@
1
+ import json
2
+ import logging
3
+ import os
4
+
5
+ logger = logging.getLogger("llm_tracker")
6
+
7
+ # ── Default pricing (USD per 1K tokens) ──
8
+ # You can override the whole table via env var LLM_TRACKER_PRICING_JSON
9
+ # so pricing updates don't require a code change/deploy.
10
+ # Example env value:
11
+ # LLM_TRACKER_PRICING_JSON='{"gpt-4o-mini":{"input":0.00015,"output":0.0006}}'
12
+
13
+ DEFAULT_PRICING = {
14
+ "gpt-4o-mini": {"input": 0.00015, "output": 0.0006},
15
+ "gpt-4o": {"input": 0.0025, "output": 0.01},
16
+ "gpt-4.1": {"input": 0.002, "output": 0.008},
17
+ }
18
+
19
+
20
+ def _load_pricing():
21
+ pricing_env = os.getenv("LLM_TRACKER_PRICING_JSON")
22
+ if not pricing_env:
23
+ return DEFAULT_PRICING
24
+ try:
25
+ return json.loads(pricing_env)
26
+ except json.JSONDecodeError:
27
+ logger.warning("LLM_TRACKER_PRICING_JSON is invalid JSON, using defaults")
28
+ return DEFAULT_PRICING
29
+
30
+
31
+ PRICING_PER_1K = _load_pricing()
32
+
33
+
34
+ def get_cost(deployment: str, prompt_tokens, completion_tokens) -> float:
35
+ if prompt_tokens is None or completion_tokens is None:
36
+ return 0.0
37
+ rates = PRICING_PER_1K.get(deployment, {"input": 0, "output": 0})
38
+ return round(
39
+ (prompt_tokens / 1000) * rates["input"] + (completion_tokens / 1000) * rates["output"],
40
+ 6,
41
+ )
@@ -0,0 +1,254 @@
1
+ Metadata-Version: 2.4
2
+ Name: llm-tracker
3
+ Version: 0.2.0
4
+ Summary: Track LLM API costs, tokens, and latency to MySQL
5
+ Author-email: OneClarity <dev@oneclarity.ai>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/mentor-oneclarity/AI_LLM_Tracking
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: openai>=1.30.0
18
+ Requires-Dist: sqlalchemy>=2.0.0
19
+ Requires-Dist: pymysql>=1.1.0
20
+ Requires-Dist: python-dotenv>=1.0.0
21
+ Requires-Dist: starlette>=0.37.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=7.0; extra == "dev"
24
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
25
+ Requires-Dist: black>=22.0; extra == "dev"
26
+ Requires-Dist: flake8>=4.0; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # llm_tracker
30
+
31
+ Automatically log OpenAI API usage (tokens, cost, latency) to MySQL. Drop-in wrapper that requires only a 1-line import change.
32
+
33
+ ## What It Does
34
+
35
+ Every call to `client.chat.completions.create()` logs:
36
+ - **Tokens** (prompt, completion, total)
37
+ - **Cost** (calculated from pricing table)
38
+ - **Latency** (milliseconds)
39
+ - **Metadata** (service name, endpoint, environment, user ID, request ID)
40
+
41
+ All logged to MySQL table `ai_llm_usage_logs` for cost analysis dashboards.
42
+
43
+ ---
44
+
45
+ ## Quick Start (5 minutes)
46
+
47
+ ### 1. Install
48
+
49
+ ```bash
50
+ pip install llm-tracker
51
+ ```
52
+
53
+ ### 2. Configure
54
+
55
+ Copy `.env.example` to `.env` and fill in:
56
+
57
+ ```bash
58
+ # Who you are
59
+ LLM_TRACKER_API_NAME=snapshot # Your service name
60
+ LLM_TRACKER_USER_ID=your-user-id # Your personal/team ID
61
+
62
+ # MySQL connection
63
+ LLM_TRACKER_DB_HOST=mysql.example.com
64
+ LLM_TRACKER_DB_PORT=3306
65
+ LLM_TRACKER_DB_USER=mysqladmin
66
+ LLM_TRACKER_DB_PASSWORD=password
67
+ LLM_TRACKER_DB_NAME=dev_db
68
+
69
+ # Optional
70
+ LLM_TRACKER_USE_SSL=1 # SSL enabled (default: 1)
71
+ LLM_TRACKER_DEFAULT_ENV=beta # test/beta/prod (default: test)
72
+ ```
73
+
74
+ See `.env.example` for all variables with explanations.
75
+
76
+ ### 3. Initialize Database
77
+
78
+ **First time only:**
79
+ ```bash
80
+ python -c "from llm_tracker.db import init_db; init_db()"
81
+ ```
82
+
83
+ This creates the `ai_llm_usage_logs` table.
84
+
85
+ ### 4. Use Tracked Client
86
+
87
+ **In your code**, change only the import:
88
+
89
+ ```python
90
+ # Before
91
+ from openai import OpenAI
92
+ client = OpenAI(api_key="...", base_url="...")
93
+
94
+ # After
95
+ from llm_tracker import TrackedOpenAI
96
+ client = TrackedOpenAI(api_key="...", base_url="...")
97
+
98
+ # Everything else stays the same
99
+ response = client.chat.completions.create(
100
+ model="gpt-4o",
101
+ messages=[{"role": "user", "content": "hello"}]
102
+ )
103
+ ```
104
+
105
+ ### 5. (FastAPI only) Add Middleware
106
+
107
+ In your FastAPI app startup:
108
+
109
+ ```python
110
+ from fastapi import FastAPI
111
+ from llm_tracker.middleware import LLMContextMiddleware
112
+
113
+ app = FastAPI()
114
+ app.add_middleware(LLMContextMiddleware)
115
+ ```
116
+
117
+ This automatically populates:
118
+ - `api_name` — your service name (from env)
119
+ - `endpoint` — the route path (e.g., `/jobs/medium-brain`)
120
+ - `user_id` — your ID (from env)
121
+ - `request_id` — unique per request (auto-generated)
122
+ - `environment` — from env var or `X-Env` header
123
+
124
+ ---
125
+
126
+ ## Environment Variables
127
+
128
+ ### Required
129
+
130
+ | Variable | Example | Description |
131
+ |----------|---------|-------------|
132
+ | `LLM_TRACKER_API_NAME` | `snapshot` | Your service/repo name |
133
+ | `LLM_TRACKER_USER_ID` | `abc123def` | Your personal/team ID (cost attribution) |
134
+ | `LLM_TRACKER_DB_HOST` | `mysql.example.com` | MySQL hostname |
135
+ | `LLM_TRACKER_DB_USER` | `mysqladmin` | MySQL username |
136
+ | `LLM_TRACKER_DB_PASSWORD` | `password123` | MySQL password |
137
+ | `LLM_TRACKER_DB_NAME` | `dev_db` | MySQL database name |
138
+
139
+ ### Optional
140
+
141
+ | Variable | Default | Description |
142
+ |----------|---------|-------------|
143
+ | `LLM_TRACKER_DB_PORT` | `3306` | MySQL port |
144
+ | `LLM_TRACKER_USE_SSL` | `1` | Enable SSL (0=off, 1=on) |
145
+ | `LLM_TRACKER_SSL_CA` | (system) | Path to CA certificate |
146
+ | `LLM_TRACKER_DEFAULT_ENV` | `test` | Environment label: `test`, `beta`, or `prod` |
147
+ | `LLM_TRACKER_PRICING_JSON` | (built-in) | Override pricing table as JSON |
148
+
149
+ ### Special: Per-Request Environment
150
+
151
+ Send `X-Env` header to override environment for a single request:
152
+
153
+ ```bash
154
+ curl -H "X-Env: test" http://localhost:8088/jobs/medium-brain
155
+ ```
156
+
157
+ ---
158
+
159
+ ## What Gets Logged
160
+
161
+ Table: `ai_llm_usage_logs`
162
+
163
+ | Column | Example | Notes |
164
+ |--------|---------|-------|
165
+ | `id` | `a1b2c3d4-...` | UUID (auto-generated) |
166
+ | `created_at` | `2026-07-07 12:30:45` | IST timestamp (auto) |
167
+ | `api_name` | `snapshot` | From `LLM_TRACKER_API_NAME` |
168
+ | `endpoint` | `/jobs/medium-brain` | HTTP route (FastAPI only) |
169
+ | `deployment` | `gpt-4o` | Model name |
170
+ | `environment` | `beta` | From `LLM_TRACKER_DEFAULT_ENV` |
171
+ | `user_id` | `abc123def` | From `LLM_TRACKER_USER_ID` |
172
+ | `request_id` | `xyz789abc` | Per-request UUID (FastAPI) |
173
+ | `prompt_tokens` | `150` | Input tokens |
174
+ | `completion_tokens` | `50` | Output tokens |
175
+ | `total_tokens` | `200` | Sum |
176
+ | `cost_usd` | `0.0045` | Calculated cost |
177
+ | `latency_ms` | `1234` | Round-trip time |
178
+
179
+ ### Query Example
180
+
181
+ ```sql
182
+ -- Total cost by endpoint (last 7 days)
183
+ SELECT endpoint, deployment, COUNT(*) as calls, SUM(cost_usd) as total_cost
184
+ FROM ai_llm_usage_logs
185
+ WHERE api_name = 'snapshot' AND created_at > NOW() - INTERVAL 7 DAY
186
+ GROUP BY endpoint, deployment
187
+ ORDER BY total_cost DESC;
188
+ ```
189
+
190
+ ---
191
+
192
+ ## For Manual Scripts (No FastAPI)
193
+
194
+ Load env vars and call `flush()` before exit:
195
+
196
+ ```python
197
+ from dotenv import load_dotenv
198
+ from llm_tracker import TrackedOpenAI
199
+ from llm_tracker.logger import flush
200
+
201
+ load_dotenv()
202
+
203
+ client = TrackedOpenAI(api_key="...")
204
+ response = client.chat.completions.create(
205
+ model="gpt-4o",
206
+ messages=[...]
207
+ )
208
+
209
+ flush() # ensure background writes finish before script exits
210
+ ```
211
+
212
+ ---
213
+
214
+ ## Supported Models
215
+
216
+ Built-in pricing for:
217
+ - `gpt-4o` — $0.0025 input / $0.01 output per 1K tokens
218
+ - `gpt-4o-mini` — $0.00015 input / $0.0006 output per 1K tokens
219
+ - `gpt-4.1` — $0.002 input / $0.008 output per 1K tokens
220
+
221
+ Unknown models log `$0.00` cost. Override pricing with `LLM_TRACKER_PRICING_JSON`.
222
+
223
+ ---
224
+
225
+ ## Async Support
226
+
227
+ For async apps, use `TrackedAsyncOpenAI` / `TrackedAsyncAzureOpenAI` — same API, `await` the call:
228
+
229
+ ```python
230
+ from llm_tracker import TrackedAsyncOpenAI
231
+ from llm_tracker.logger import aflush
232
+
233
+ client = TrackedAsyncOpenAI(api_key="...")
234
+ response = await client.chat.completions.create(
235
+ model="gpt-4o",
236
+ messages=[{"role": "user", "content": "hello"}],
237
+ )
238
+
239
+ await aflush() # async-friendly equivalent of flush()
240
+ ```
241
+
242
+ ## Known Limitations
243
+
244
+ - ❌ Streaming (`stream=True`) not supported
245
+ - ❌ Embeddings not tracked (by design — cheap)
246
+ - ✓ Sync and async OpenAI/AzureOpenAI clients supported
247
+
248
+ ---
249
+
250
+ ## Support
251
+
252
+ - **Issues**: GitHub issues
253
+ - **Docs**: See `.env.example` and `schema.sql`
254
+ - **Examples**: `example_usage.py`
@@ -0,0 +1,11 @@
1
+ llm_tracker/__init__.py,sha256=XMW12PhrpJHb526R7pHKR68AJRoFv09Yl8C75g6FHXY,513
2
+ llm_tracker/client.py,sha256=ooj9oAnQLAfxSbmCpAb7OjoU4ktgFICNmqQZEvxp1WU,5737
3
+ llm_tracker/db.py,sha256=u3P5cxng16xadoon-4Pj_TTqf1A0OXY6GN1cFLyYKAo,3104
4
+ llm_tracker/logger.py,sha256=yJPvVwmBdbn7astWdn3JKRw-RK9SihQbsPjS8ymeUmU,1340
5
+ llm_tracker/middleware.py,sha256=tQHF3uO1Aj73bENS64bmPNQrygRDXOBLStDr-y6Cv18,1411
6
+ llm_tracker/pricing.py,sha256=4_u-_iFThjbAwe-Psp9EnrShmM71BWUHYg1HFfc03wY,1260
7
+ llm_tracker-0.2.0.dist-info/licenses/LICENSE,sha256=ectiFxIC9TyCUgN-KAjsF6Ie9oxomimDzQQxzh-5Seg,1067
8
+ llm_tracker-0.2.0.dist-info/METADATA,sha256=1VoJ-Dt2RB-5BTBzpG30EAKTTngnJQ-9xPzg-eip57g,6933
9
+ llm_tracker-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
+ llm_tracker-0.2.0.dist-info/top_level.txt,sha256=X5UoJG5Oo5TOz2KOtiqof1iunmOoubUPSKoMQTypjGE,12
11
+ llm_tracker-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OneClarity
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ llm_tracker