api-trust-tracker 0.1.0__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.
@@ -0,0 +1,36 @@
1
+ # Dependencies
2
+ node_modules/
3
+ yarn.lock
4
+ package-lock.json
5
+
6
+ # Build output
7
+ website/dist/
8
+ website/.vercel/
9
+
10
+ # Scaffolding
11
+ .agent/
12
+ .agentsync/
13
+ .scaffolding-version
14
+
15
+ # IDE
16
+ .vscode/
17
+ .idea/
18
+ *.swp
19
+ *.swo
20
+
21
+ # Python
22
+ __pycache__/
23
+ *.pyc
24
+ *.db
25
+
26
+ # Generated indexes
27
+ 00_Index_*.md
28
+
29
+ # uv
30
+ uv.lock
31
+
32
+ # OS
33
+ .DS_Store
34
+ Thumbs.db
35
+ .vercel
36
+ .env*.local
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: api-trust-tracker
3
+ Version: 0.1.0
4
+ Summary: Lightweight client for the Synth Insight Labs API trust tracker
5
+ Requires-Python: >=3.11
@@ -0,0 +1,16 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "api-trust-tracker"
7
+ version = "0.1.0"
8
+ description = "Lightweight client for the Synth Insight Labs API trust tracker"
9
+ requires-python = ">=3.11"
10
+ dependencies = []
11
+
12
+ [tool.hatch.build.targets.wheel]
13
+ packages = ["src/api_trust_tracker"]
14
+
15
+ [tool.pytest.ini_options]
16
+ testpaths = ["tests"]
@@ -0,0 +1,18 @@
1
+ """
2
+ API Trust Tracker Client — track API spend across all providers and services.
3
+
4
+ Usage:
5
+ from api_trust_tracker import track, log_call
6
+
7
+ # AI API response tracking
8
+ resp = client.messages.create(model="claude-haiku-4-5", ...)
9
+ track(resp, "anthropic", project="open-brain", caller="classifier")
10
+
11
+ # Non-AI API call logging
12
+ log_call("vercel", service="blob", project="muffinpanrecipes")
13
+ """
14
+
15
+ from .tracker import track, log_call
16
+ from .buffer import flush_buffer, pending_count
17
+
18
+ __all__ = ["track", "log_call", "flush_buffer", "pending_count"]
@@ -0,0 +1,171 @@
1
+ """
2
+ Local SQLite buffer for offline usage tracking.
3
+
4
+ When the hosted endpoint is unreachable, usage records are buffered here.
5
+ Call flush_buffer() to retry sending buffered records when connectivity returns.
6
+ """
7
+
8
+ import json
9
+ import sqlite3
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Optional
13
+ from urllib.error import URLError
14
+ from urllib.request import Request, urlopen
15
+
16
+ BUFFER_DB_PATH = Path.home() / ".local" / "share" / "ai_cost_tracker" / "buffer.db"
17
+
18
+
19
+ def _get_buffer_connection(db_path: Optional[Path] = None) -> sqlite3.Connection:
20
+ """Create a connection to the buffer database."""
21
+ path = db_path or BUFFER_DB_PATH
22
+ path.parent.mkdir(parents=True, exist_ok=True)
23
+ conn = sqlite3.connect(str(path), timeout=5)
24
+ conn.row_factory = sqlite3.Row
25
+ conn.execute("PRAGMA journal_mode=WAL")
26
+ conn.execute("PRAGMA busy_timeout=5000")
27
+ conn.executescript("""
28
+ CREATE TABLE IF NOT EXISTS pending_records (
29
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
30
+ endpoint TEXT NOT NULL,
31
+ payload TEXT NOT NULL,
32
+ created_at TEXT NOT NULL,
33
+ attempts INTEGER DEFAULT 0,
34
+ last_attempt TEXT
35
+ );
36
+ """)
37
+ return conn
38
+
39
+
40
+ def buffer_record(
41
+ endpoint: str,
42
+ payload: dict,
43
+ db_path: Optional[Path] = None,
44
+ ) -> int:
45
+ """
46
+ Store a failed POST payload for later retry.
47
+
48
+ Args:
49
+ endpoint: The relative endpoint path (e.g., "/track" or "/log_call").
50
+ payload: The JSON payload that failed to send.
51
+ db_path: Override buffer DB path for testing.
52
+
53
+ Returns:
54
+ The buffered record's ID.
55
+ """
56
+ conn = _get_buffer_connection(db_path)
57
+ try:
58
+ cursor = conn.execute(
59
+ """INSERT INTO pending_records (endpoint, payload, created_at)
60
+ VALUES (?, ?, ?)""",
61
+ (endpoint, json.dumps(payload), datetime.now().isoformat()),
62
+ )
63
+ conn.commit()
64
+ return cursor.lastrowid
65
+ finally:
66
+ conn.close()
67
+
68
+
69
+ def flush_buffer(
70
+ base_url: str,
71
+ api_key: Optional[str] = None,
72
+ db_path: Optional[Path] = None,
73
+ max_batch: int = 100,
74
+ ) -> dict:
75
+ """
76
+ Retry sending buffered records to the hosted endpoint.
77
+
78
+ Args:
79
+ base_url: The base URL (e.g., "https://api.synthinsightlabs.com/costs").
80
+ api_key: API key for authentication.
81
+ db_path: Override buffer DB path for testing.
82
+ max_batch: Maximum records to flush per call.
83
+
84
+ Returns:
85
+ Dict with keys: sent, failed, remaining.
86
+ """
87
+ conn = _get_buffer_connection(db_path)
88
+ sent = 0
89
+ failed = 0
90
+
91
+ try:
92
+ rows = conn.execute(
93
+ """SELECT id, endpoint, payload, attempts
94
+ FROM pending_records
95
+ ORDER BY created_at ASC
96
+ LIMIT ?""",
97
+ (max_batch,),
98
+ ).fetchall()
99
+
100
+ for row in rows:
101
+ record_id = row["id"]
102
+ endpoint = row["endpoint"]
103
+ payload = row["payload"]
104
+
105
+ url = f"{base_url.rstrip('/')}{endpoint}"
106
+ headers = {"Content-Type": "application/json"}
107
+ if api_key:
108
+ headers["X-API-Key"] = api_key
109
+
110
+ try:
111
+ req = Request(
112
+ url,
113
+ data=payload.encode("utf-8"),
114
+ headers=headers,
115
+ method="POST",
116
+ )
117
+ resp = urlopen(req, timeout=10)
118
+ if resp.status < 300:
119
+ conn.execute(
120
+ "DELETE FROM pending_records WHERE id = ?",
121
+ (record_id,),
122
+ )
123
+ sent += 1
124
+ else:
125
+ conn.execute(
126
+ """UPDATE pending_records
127
+ SET attempts = attempts + 1, last_attempt = ?
128
+ WHERE id = ?""",
129
+ (datetime.now().isoformat(), record_id),
130
+ )
131
+ failed += 1
132
+ except (URLError, OSError, TimeoutError):
133
+ conn.execute(
134
+ """UPDATE pending_records
135
+ SET attempts = attempts + 1, last_attempt = ?
136
+ WHERE id = ?""",
137
+ (datetime.now().isoformat(), record_id),
138
+ )
139
+ failed += 1
140
+
141
+ conn.commit()
142
+
143
+ remaining_row = conn.execute(
144
+ "SELECT COUNT(*) AS cnt FROM pending_records"
145
+ ).fetchone()
146
+ remaining = remaining_row["cnt"] if remaining_row else 0
147
+
148
+ return {"sent": sent, "failed": failed, "remaining": remaining}
149
+ finally:
150
+ conn.close()
151
+
152
+
153
+ def pending_count(db_path: Optional[Path] = None) -> int:
154
+ """Return the number of pending buffered records."""
155
+ conn = _get_buffer_connection(db_path)
156
+ try:
157
+ row = conn.execute("SELECT COUNT(*) AS cnt FROM pending_records").fetchone()
158
+ return row["cnt"] if row else 0
159
+ finally:
160
+ conn.close()
161
+
162
+
163
+ def clear_buffer(db_path: Optional[Path] = None) -> int:
164
+ """Clear all buffered records. Returns count deleted."""
165
+ conn = _get_buffer_connection(db_path)
166
+ try:
167
+ cursor = conn.execute("DELETE FROM pending_records")
168
+ conn.commit()
169
+ return cursor.rowcount
170
+ finally:
171
+ conn.close()
@@ -0,0 +1,242 @@
1
+ """
2
+ Client-side API cost tracker.
3
+
4
+ Two public functions:
5
+ track(response, provider, ...) -- for AI API responses. Extracts tokens, POSTs to server.
6
+ log_call(provider, service, ...) -- for non-AI API calls. Logs that the call happened.
7
+
8
+ Both return immediately. Neither raises exceptions -- tracking failures are silent.
9
+ The API call must always succeed even if tracking fails.
10
+ """
11
+
12
+ import json
13
+ import os
14
+ import platform
15
+ from datetime import datetime
16
+ from typing import Optional
17
+ from urllib.error import URLError
18
+ from urllib.request import Request, urlopen
19
+
20
+ from .buffer import buffer_record
21
+
22
+
23
+ # Configurable endpoint
24
+ COST_TRACKER_URL = os.environ.get(
25
+ "COST_TRACKER_URL", "https://api.synthinsightlabs.com"
26
+ )
27
+ COST_TRACKER_API_KEY = os.environ.get("COST_TRACKER_API_KEY")
28
+
29
+ # Timeout for POSTs (seconds) -- keep short so tracking doesn't slow down the caller
30
+ _POST_TIMEOUT = 5
31
+
32
+
33
+ def _post_to_server(endpoint: str, payload: dict) -> bool:
34
+ """
35
+ POST payload to the hosted endpoint.
36
+ Returns True if successful, False if failed (payload gets buffered).
37
+ """
38
+ url = f"{COST_TRACKER_URL.rstrip('/')}{endpoint}"
39
+ headers = {"Content-Type": "application/json"}
40
+ if COST_TRACKER_API_KEY:
41
+ headers["X-API-Key"] = COST_TRACKER_API_KEY
42
+
43
+ try:
44
+ req = Request(
45
+ url,
46
+ data=json.dumps(payload).encode("utf-8"),
47
+ headers=headers,
48
+ method="POST",
49
+ )
50
+ resp = urlopen(req, timeout=_POST_TIMEOUT)
51
+ return resp.status < 300
52
+ except (URLError, OSError, TimeoutError):
53
+ return False
54
+
55
+
56
+ def _extract_anthropic(response) -> dict:
57
+ """Extract token usage from an Anthropic API response."""
58
+ usage = response.usage
59
+ return {
60
+ "model": response.model,
61
+ "prompt_tokens": usage.input_tokens,
62
+ "completion_tokens": usage.output_tokens,
63
+ "cache_read_tokens": getattr(usage, "cache_read_input_tokens", 0) or 0,
64
+ "cache_creation_tokens": getattr(usage, "cache_creation_input_tokens", 0) or 0,
65
+ }
66
+
67
+
68
+ def _extract_openai(response) -> dict:
69
+ """
70
+ Extract token usage from an OpenAI API response.
71
+ Handles both Chat Completions and Responses API formats.
72
+ """
73
+ usage = response.usage
74
+ model = response.model
75
+
76
+ # Chat Completions API
77
+ if hasattr(usage, "prompt_tokens"):
78
+ return {
79
+ "model": model,
80
+ "prompt_tokens": usage.prompt_tokens,
81
+ "completion_tokens": usage.completion_tokens,
82
+ "cache_read_tokens": 0,
83
+ "cache_creation_tokens": 0,
84
+ }
85
+
86
+ # Responses API
87
+ return {
88
+ "model": model,
89
+ "prompt_tokens": getattr(usage, "input_tokens", 0) or 0,
90
+ "completion_tokens": getattr(usage, "output_tokens", 0) or 0,
91
+ "cache_read_tokens": 0,
92
+ "cache_creation_tokens": 0,
93
+ }
94
+
95
+
96
+ def _extract_google(response, model: Optional[str] = None) -> dict:
97
+ """Extract token usage from a Google Gemini API response."""
98
+ meta = response.usage_metadata
99
+
100
+ prompt = getattr(meta, "prompt_token_count", 0) or 0
101
+ completion = getattr(meta, "candidates_token_count", 0) or 0
102
+
103
+ resolved_model = model
104
+ if resolved_model is None:
105
+ resolved_model = getattr(response, "model_version", None)
106
+ if resolved_model is None:
107
+ resolved_model = getattr(response, "model", "unknown")
108
+
109
+ return {
110
+ "model": resolved_model,
111
+ "prompt_tokens": prompt,
112
+ "completion_tokens": completion,
113
+ "cache_read_tokens": 0,
114
+ "cache_creation_tokens": 0,
115
+ }
116
+
117
+
118
+ _EXTRACTORS = {
119
+ "anthropic": _extract_anthropic,
120
+ "openai": _extract_openai,
121
+ "xai": _extract_openai, # xAI uses OpenAI-compatible format
122
+ "google": _extract_google,
123
+ }
124
+
125
+
126
+ def track(
127
+ response,
128
+ provider: str,
129
+ *,
130
+ model: Optional[str] = None,
131
+ project: Optional[str] = None,
132
+ caller: Optional[str] = None,
133
+ service: str = "chat",
134
+ ):
135
+ """
136
+ Track an AI API response. Extracts tokens, sends to server.
137
+
138
+ The response object passes through unchanged -- call this after every API call.
139
+
140
+ Usage:
141
+ resp = client.messages.create(model="claude-haiku-4-5", ...)
142
+ track(resp, "anthropic", project="open-brain", caller="classifier")
143
+
144
+ resp = openai_client.chat.completions.create(model="gpt-4.1-mini", ...)
145
+ track(resp, "openai", project="flowfi")
146
+
147
+ Args:
148
+ response: The raw SDK response object.
149
+ provider: Provider name ("anthropic", "openai", "xai", "google").
150
+ model: Override model name (extracted from response if not provided).
151
+ project: Project name for attribution.
152
+ caller: Identifies the calling code path.
153
+ service: Service type (default "chat").
154
+
155
+ Returns:
156
+ The original response object, unchanged.
157
+ """
158
+ try:
159
+ provider = provider.lower()
160
+ extractor = _EXTRACTORS.get(provider)
161
+ if extractor is None:
162
+ return response # Unknown provider, pass through silently
163
+
164
+ # Google extractor needs model hint
165
+ if provider == "google":
166
+ usage_data = extractor(response, model=model)
167
+ else:
168
+ usage_data = extractor(response)
169
+
170
+ # Allow model override
171
+ if model:
172
+ usage_data["model"] = model
173
+
174
+ resolved_model = usage_data.get("model", "unknown")
175
+ prompt_tokens = usage_data.get("prompt_tokens", 0)
176
+ completion_tokens = usage_data.get("completion_tokens", 0)
177
+ cache_read = usage_data.get("cache_read_tokens", 0)
178
+ cache_creation = usage_data.get("cache_creation_tokens", 0)
179
+ total_tokens = prompt_tokens + completion_tokens + cache_read + cache_creation
180
+
181
+ payload = {
182
+ "timestamp": datetime.now().isoformat(),
183
+ "provider": provider,
184
+ "model": resolved_model,
185
+ "service": service,
186
+ "api_type": "ai",
187
+ "project": project,
188
+ "prompt_tokens": prompt_tokens,
189
+ "completion_tokens": completion_tokens,
190
+ "cache_read_tokens": cache_read,
191
+ "cache_creation_tokens": cache_creation,
192
+ "total_tokens": total_tokens,
193
+ "estimated_cost_usd": None, # Server calculates cost
194
+ "source_machine": platform.node(),
195
+ "caller": caller,
196
+ }
197
+
198
+ if not _post_to_server("/track", payload):
199
+ buffer_record("/track", payload)
200
+
201
+ except Exception:
202
+ # Never let tracking break the caller
203
+ pass
204
+
205
+ return response
206
+
207
+
208
+ def log_call(
209
+ provider: str,
210
+ *,
211
+ service: str = "api",
212
+ project: Optional[str] = None,
213
+ caller: Optional[str] = None,
214
+ ) -> None:
215
+ """
216
+ Log a non-AI API call (Vercel, Doppler, RunPod, etc.).
217
+
218
+ Usage:
219
+ log_call("vercel", service="blob", project="muffinpanrecipes", caller="storage.upload")
220
+
221
+ Args:
222
+ provider: Service provider name.
223
+ service: Specific service/endpoint being called.
224
+ project: Project name for attribution.
225
+ caller: Identifies the calling code path.
226
+ """
227
+ try:
228
+ payload = {
229
+ "timestamp": datetime.now().isoformat(),
230
+ "provider": provider.lower(),
231
+ "service": service,
232
+ "project": project,
233
+ "caller": caller,
234
+ "source_machine": platform.node(),
235
+ }
236
+
237
+ if not _post_to_server("/log_call", payload):
238
+ buffer_record("/log_call", payload)
239
+
240
+ except Exception:
241
+ # Never let tracking break the caller
242
+ pass