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.
- api_trust_tracker-0.1.0/.gitignore +36 -0
- api_trust_tracker-0.1.0/PKG-INFO +5 -0
- api_trust_tracker-0.1.0/pyproject.toml +16 -0
- api_trust_tracker-0.1.0/src/api_trust_tracker/__init__.py +18 -0
- api_trust_tracker-0.1.0/src/api_trust_tracker/buffer.py +171 -0
- api_trust_tracker-0.1.0/src/api_trust_tracker/tracker.py +242 -0
|
@@ -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,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
|