api-cost-tracker 0.2.0__tar.gz → 0.2.1__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_cost_tracker-0.2.1/PKG-INFO +34 -0
- api_cost_tracker-0.2.1/README.md +27 -0
- {api_cost_tracker-0.2.0 → api_cost_tracker-0.2.1}/pyproject.toml +2 -1
- api_cost_tracker-0.2.1/src/api_cost_tracker/buffer.py +227 -0
- {api_cost_tracker-0.2.0 → api_cost_tracker-0.2.1}/src/api_cost_tracker/tracker.py +66 -14
- api_cost_tracker-0.2.1/tests/conftest.py +99 -0
- api_cost_tracker-0.2.1/tests/test_auto_send.py +202 -0
- api_cost_tracker-0.2.1/tests/test_buffer.py +123 -0
- api_cost_tracker-0.2.1/tests/test_client_rename.py +69 -0
- api_cost_tracker-0.2.1/tests/test_tracker.py +301 -0
- api_cost_tracker-0.2.1/tests/test_tracker_failure_warnings.py +114 -0
- api_cost_tracker-0.2.0/PKG-INFO +0 -5
- api_cost_tracker-0.2.0/src/api_cost_tracker/buffer.py +0 -174
- {api_cost_tracker-0.2.0 → api_cost_tracker-0.2.1}/.gitignore +0 -0
- {api_cost_tracker-0.2.0 → api_cost_tracker-0.2.1}/src/api_cost_tracker/__init__.py +0 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: api-cost-tracker
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Lightweight client for the Synth Insight Labs API cost tracker
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
|
|
8
|
+
# api-cost-tracker
|
|
9
|
+
|
|
10
|
+
The client for the Synth Insight Labs API cost tracker. It records what every API
|
|
11
|
+
call costs to `https://api.synthinsightlabs.com` without ever breaking the call it
|
|
12
|
+
measures. Standard library only; macOS and Linux.
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from api_cost_tracker import track, track_tokens, log_call
|
|
16
|
+
|
|
17
|
+
response = client.messages.create(...) # any provider SDK
|
|
18
|
+
track(response, "anthropic", project="my-project", caller="summarizer")
|
|
19
|
+
|
|
20
|
+
track_tokens("anthropic", "claude-haiku-4-5", 1200, 300, project="my-project") # raw HTTP calls
|
|
21
|
+
log_call("vercel", service="blob", project="my-project") # non-AI APIs
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- **Providers:** `anthropic`, `openai`, `xai`, `google`, `openrouter` (records
|
|
25
|
+
OpenRouter's billed `usage.cost`).
|
|
26
|
+
- **Never raises.** A record that can't be sent goes to an offline buffer at
|
|
27
|
+
`~/.local/share/api_trust_tracker/buffer.db`, and each later successful call
|
|
28
|
+
sends a few buffered rows too. A record that is lost anyway is logged at WARNING.
|
|
29
|
+
- **Configuration:** `COST_TRACKER_API_KEY` (required by the server) and
|
|
30
|
+
`COST_TRACKER_URL` (default `https://api.synthinsightlabs.com`), from the
|
|
31
|
+
environment.
|
|
32
|
+
|
|
33
|
+
Renamed from `api-trust-tracker` in 0.2.0. That package now only re-exports this
|
|
34
|
+
one, and the buffer keeps its old directory so both versions share it.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# api-cost-tracker
|
|
2
|
+
|
|
3
|
+
The client for the Synth Insight Labs API cost tracker. It records what every API
|
|
4
|
+
call costs to `https://api.synthinsightlabs.com` without ever breaking the call it
|
|
5
|
+
measures. Standard library only; macOS and Linux.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from api_cost_tracker import track, track_tokens, log_call
|
|
9
|
+
|
|
10
|
+
response = client.messages.create(...) # any provider SDK
|
|
11
|
+
track(response, "anthropic", project="my-project", caller="summarizer")
|
|
12
|
+
|
|
13
|
+
track_tokens("anthropic", "claude-haiku-4-5", 1200, 300, project="my-project") # raw HTTP calls
|
|
14
|
+
log_call("vercel", service="blob", project="my-project") # non-AI APIs
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- **Providers:** `anthropic`, `openai`, `xai`, `google`, `openrouter` (records
|
|
18
|
+
OpenRouter's billed `usage.cost`).
|
|
19
|
+
- **Never raises.** A record that can't be sent goes to an offline buffer at
|
|
20
|
+
`~/.local/share/api_trust_tracker/buffer.db`, and each later successful call
|
|
21
|
+
sends a few buffered rows too. A record that is lost anyway is logged at WARNING.
|
|
22
|
+
- **Configuration:** `COST_TRACKER_API_KEY` (required by the server) and
|
|
23
|
+
`COST_TRACKER_URL` (default `https://api.synthinsightlabs.com`), from the
|
|
24
|
+
environment.
|
|
25
|
+
|
|
26
|
+
Renamed from `api-trust-tracker` in 0.2.0. That package now only re-exports this
|
|
27
|
+
one, and the buffer keeps its old directory so both versions share it.
|
|
@@ -4,8 +4,9 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "api-cost-tracker"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.1"
|
|
8
8
|
description = "Lightweight client for the Synth Insight Labs API cost tracker"
|
|
9
|
+
readme = "README.md"
|
|
9
10
|
requires-python = ">=3.11"
|
|
10
11
|
dependencies = []
|
|
11
12
|
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local SQLite buffer for offline usage tracking.
|
|
3
|
+
|
|
4
|
+
When the hosted endpoint is unreachable, usage records are buffered here.
|
|
5
|
+
flush_buffer() sends them once connectivity returns. The tracker calls it with a
|
|
6
|
+
small batch after every successful POST, so a backlog drains on its own; run
|
|
7
|
+
`report.py flush` (or call flush_buffer()) to send a large backlog at once.
|
|
8
|
+
|
|
9
|
+
One buffer file is shared by every process on the machine. flush_buffer() holds
|
|
10
|
+
an exclusive, non-blocking lock on a sibling `.flush.lock` file while it sends,
|
|
11
|
+
so two flushes never send the same row twice; a flush that finds the lock taken
|
|
12
|
+
returns at once with busy=True, without opening the database. Writers (buffer_record) never take that lock.
|
|
13
|
+
The lock uses fcntl, so the client runs on macOS and Linux only.
|
|
14
|
+
Flushes from clients older than 0.2.1 don't take it either, so run a manual
|
|
15
|
+
flush only from 0.2.1 or later.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import fcntl
|
|
19
|
+
import json
|
|
20
|
+
import sqlite3
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Optional
|
|
24
|
+
from urllib.error import URLError
|
|
25
|
+
from urllib.request import Request, urlopen
|
|
26
|
+
|
|
27
|
+
# Deliberately still the pre-#7830 name: 0.1.x clients that are not upgraded yet
|
|
28
|
+
# keep writing this file, and sharing it is the only way neither version strands
|
|
29
|
+
# rows the other wrote. The schema is unchanged.
|
|
30
|
+
BUFFER_DB_PATH = Path.home() / ".local" / "share" / "api_trust_tracker" / "buffer.db"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get_buffer_connection(db_path: Optional[Path] = None, timeout: float = 5) -> sqlite3.Connection:
|
|
34
|
+
"""Create a connection to the buffer database; `timeout` bounds each wait on a lock."""
|
|
35
|
+
path = db_path or BUFFER_DB_PATH
|
|
36
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
conn = sqlite3.connect(str(path), timeout=timeout)
|
|
38
|
+
conn.row_factory = sqlite3.Row
|
|
39
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
40
|
+
conn.execute(f"PRAGMA busy_timeout={int(timeout * 1000)}")
|
|
41
|
+
conn.executescript("""
|
|
42
|
+
CREATE TABLE IF NOT EXISTS pending_records (
|
|
43
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
44
|
+
endpoint TEXT NOT NULL,
|
|
45
|
+
payload TEXT NOT NULL,
|
|
46
|
+
created_at TEXT NOT NULL,
|
|
47
|
+
attempts INTEGER DEFAULT 0,
|
|
48
|
+
last_attempt TEXT
|
|
49
|
+
);
|
|
50
|
+
""")
|
|
51
|
+
return conn
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def buffer_record(
|
|
55
|
+
endpoint: str,
|
|
56
|
+
payload: dict,
|
|
57
|
+
db_path: Optional[Path] = None,
|
|
58
|
+
) -> int:
|
|
59
|
+
"""
|
|
60
|
+
Store a failed POST payload for later retry.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
endpoint: The relative endpoint path (e.g., "/track" or "/log_call").
|
|
64
|
+
payload: The JSON payload that failed to send.
|
|
65
|
+
db_path: Override buffer DB path for testing.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The buffered record's ID.
|
|
69
|
+
"""
|
|
70
|
+
conn = _get_buffer_connection(db_path)
|
|
71
|
+
try:
|
|
72
|
+
cursor = conn.execute(
|
|
73
|
+
"""INSERT INTO pending_records (endpoint, payload, created_at)
|
|
74
|
+
VALUES (?, ?, ?)""",
|
|
75
|
+
(endpoint, json.dumps(payload), datetime.now().isoformat()),
|
|
76
|
+
)
|
|
77
|
+
conn.commit()
|
|
78
|
+
return cursor.lastrowid
|
|
79
|
+
finally:
|
|
80
|
+
conn.close()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def flush_buffer(
|
|
84
|
+
base_url: str,
|
|
85
|
+
api_key: Optional[str] = None,
|
|
86
|
+
db_path: Optional[Path] = None,
|
|
87
|
+
max_batch: int = 100,
|
|
88
|
+
*,
|
|
89
|
+
stop_on_failure: bool = False,
|
|
90
|
+
timeout: float = 10,
|
|
91
|
+
db_timeout: float = 5,
|
|
92
|
+
) -> dict:
|
|
93
|
+
"""
|
|
94
|
+
Retry sending buffered records to the hosted endpoint.
|
|
95
|
+
|
|
96
|
+
Rows go oldest first among those with the fewest attempts, so one row the
|
|
97
|
+
server keeps rejecting can't hold back the rest. Before each POST the row's
|
|
98
|
+
attempt is recorded and committed; if the buffer is locked by another writer
|
|
99
|
+
for longer than db_timeout, that write raises sqlite3.OperationalError and
|
|
100
|
+
nothing more is sent. After a successful POST the row is deleted and
|
|
101
|
+
committed before the next one.
|
|
102
|
+
|
|
103
|
+
Delivery is at least once. A row is sent twice only if its POST succeeds and
|
|
104
|
+
its DELETE then fails: the process crashes in between, or another writer
|
|
105
|
+
holds the buffer longer than db_timeout in that moment. buffer_record()
|
|
106
|
+
holds it for milliseconds; a flush from a client older than 0.2.1 holds it
|
|
107
|
+
for its whole run, which is one reason to flush only from 0.2.1 or later.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
base_url: The base URL (e.g., "https://api.synthinsightlabs.com").
|
|
111
|
+
api_key: API key for authentication.
|
|
112
|
+
db_path: Override buffer DB path for testing.
|
|
113
|
+
max_batch: Maximum records to send in this call.
|
|
114
|
+
stop_on_failure: Stop at the first failed POST instead of trying the
|
|
115
|
+
rest of the batch (the tracker's automatic flush uses this, so an
|
|
116
|
+
unreachable server costs the caller one timeout, not max_batch).
|
|
117
|
+
timeout: Seconds to wait for each POST.
|
|
118
|
+
db_timeout: Seconds to wait on the buffer's SQLite lock for each read
|
|
119
|
+
or write (the tracker's automatic flush uses a short one).
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Dict with keys: sent, failed, remaining, busy. When another process
|
|
123
|
+
holds the flush lock, busy is True, nothing was sent and remaining is
|
|
124
|
+
None (the buffer isn't opened, so this path never waits on SQLite).
|
|
125
|
+
"""
|
|
126
|
+
path = db_path or BUFFER_DB_PATH
|
|
127
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
with open(path.with_name(path.name + ".flush.lock"), "a") as lock:
|
|
129
|
+
try:
|
|
130
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
131
|
+
except BlockingIOError:
|
|
132
|
+
return {"sent": 0, "failed": 0, "remaining": None, "busy": True}
|
|
133
|
+
try:
|
|
134
|
+
return _send_batch(path, base_url, api_key, max_batch, stop_on_failure, timeout, db_timeout)
|
|
135
|
+
finally:
|
|
136
|
+
fcntl.flock(lock, fcntl.LOCK_UN)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _send_batch(
|
|
140
|
+
path: Path,
|
|
141
|
+
base_url: str,
|
|
142
|
+
api_key: Optional[str],
|
|
143
|
+
max_batch: int,
|
|
144
|
+
stop_on_failure: bool,
|
|
145
|
+
timeout: float,
|
|
146
|
+
db_timeout: float,
|
|
147
|
+
) -> dict:
|
|
148
|
+
"""Send up to max_batch buffered rows; the caller holds the flush lock."""
|
|
149
|
+
conn = _get_buffer_connection(path, timeout=db_timeout)
|
|
150
|
+
sent = 0
|
|
151
|
+
failed = 0
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
rows = conn.execute(
|
|
155
|
+
"""SELECT id, endpoint, payload
|
|
156
|
+
FROM pending_records
|
|
157
|
+
ORDER BY attempts ASC, id ASC
|
|
158
|
+
LIMIT ?""",
|
|
159
|
+
(max_batch,),
|
|
160
|
+
).fetchall()
|
|
161
|
+
|
|
162
|
+
headers = {"Content-Type": "application/json"}
|
|
163
|
+
if api_key:
|
|
164
|
+
headers["X-API-Key"] = api_key
|
|
165
|
+
|
|
166
|
+
for row in rows:
|
|
167
|
+
# Record the attempt before sending: if the buffer is locked this
|
|
168
|
+
# raises here, before anything goes out, instead of after a POST.
|
|
169
|
+
conn.execute(
|
|
170
|
+
"""UPDATE pending_records
|
|
171
|
+
SET attempts = attempts + 1, last_attempt = ?
|
|
172
|
+
WHERE id = ?""",
|
|
173
|
+
(datetime.now().isoformat(), row["id"]),
|
|
174
|
+
)
|
|
175
|
+
conn.commit()
|
|
176
|
+
|
|
177
|
+
url = f"{base_url.rstrip('/')}{row['endpoint']}"
|
|
178
|
+
try:
|
|
179
|
+
req = Request(
|
|
180
|
+
url,
|
|
181
|
+
data=row["payload"].encode("utf-8"),
|
|
182
|
+
headers=headers,
|
|
183
|
+
method="POST",
|
|
184
|
+
)
|
|
185
|
+
ok = urlopen(req, timeout=timeout).status < 300
|
|
186
|
+
except (URLError, OSError, TimeoutError): # governance: allow-silent SF002: a failed retry is counted in `failed`, the row stays buffered with its attempt already recorded, and the caller reports or logs the result
|
|
187
|
+
ok = False
|
|
188
|
+
|
|
189
|
+
if ok:
|
|
190
|
+
conn.execute("DELETE FROM pending_records WHERE id = ?", (row["id"],))
|
|
191
|
+
conn.commit()
|
|
192
|
+
sent += 1
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
failed += 1
|
|
196
|
+
if stop_on_failure:
|
|
197
|
+
break
|
|
198
|
+
|
|
199
|
+
remaining_row = conn.execute(
|
|
200
|
+
"SELECT COUNT(*) AS cnt FROM pending_records"
|
|
201
|
+
).fetchone()
|
|
202
|
+
remaining = remaining_row["cnt"] if remaining_row else 0
|
|
203
|
+
|
|
204
|
+
return {"sent": sent, "failed": failed, "remaining": remaining, "busy": False}
|
|
205
|
+
finally:
|
|
206
|
+
conn.close()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def pending_count(db_path: Optional[Path] = None) -> int:
|
|
210
|
+
"""Return the number of pending buffered records."""
|
|
211
|
+
conn = _get_buffer_connection(db_path)
|
|
212
|
+
try:
|
|
213
|
+
row = conn.execute("SELECT COUNT(*) AS cnt FROM pending_records").fetchone()
|
|
214
|
+
return row["cnt"] if row else 0
|
|
215
|
+
finally:
|
|
216
|
+
conn.close()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def clear_buffer(db_path: Optional[Path] = None) -> int:
|
|
220
|
+
"""Clear all buffered records. Returns count deleted."""
|
|
221
|
+
conn = _get_buffer_connection(db_path)
|
|
222
|
+
try:
|
|
223
|
+
cursor = conn.execute("DELETE FROM pending_records")
|
|
224
|
+
conn.commit()
|
|
225
|
+
return cursor.rowcount
|
|
226
|
+
finally:
|
|
227
|
+
conn.close()
|
|
@@ -5,22 +5,31 @@ Two public functions:
|
|
|
5
5
|
track(response, provider, ...) -- for AI API responses. Extracts tokens, POSTs to server.
|
|
6
6
|
log_call(provider, service, ...) -- for non-AI API calls. Logs that the call happened.
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
unknown provider) is logged at WARNING
|
|
10
|
-
call's spend was not recorded.
|
|
8
|
+
Each call POSTs one record, waiting at most a few seconds. Neither raises
|
|
9
|
+
exceptions -- a tracking failure (or an unknown provider) is logged at WARNING
|
|
10
|
+
with the call's identity, because that call's spend was not recorded.
|
|
11
11
|
The API call must always succeed even if tracking fails.
|
|
12
|
+
|
|
13
|
+
A record whose POST fails goes to the offline buffer (buffer.py). After every
|
|
14
|
+
successful POST the server is known to be reachable, so the tracker also sends
|
|
15
|
+
up to _DRAIN_BATCH buffered rows, stopping at the first failure. A backlog
|
|
16
|
+
therefore drains a few rows per call with no scheduled job.
|
|
17
|
+
|
|
18
|
+
Timestamps are UTC with an explicit offset. Clients before 0.2.1 sent naive
|
|
19
|
+
local time, which the server stored as UTC (#8152).
|
|
12
20
|
"""
|
|
13
21
|
|
|
14
22
|
import json
|
|
15
23
|
import logging
|
|
16
24
|
import os
|
|
17
25
|
import platform
|
|
18
|
-
from datetime import datetime
|
|
26
|
+
from datetime import datetime, timezone
|
|
19
27
|
from typing import Optional
|
|
20
28
|
from urllib.error import URLError
|
|
21
29
|
from urllib.request import Request, urlopen
|
|
22
30
|
|
|
23
|
-
from .
|
|
31
|
+
from . import buffer as _buffer
|
|
32
|
+
from .buffer import buffer_record, flush_buffer
|
|
24
33
|
|
|
25
34
|
_log = logging.getLogger(__name__)
|
|
26
35
|
|
|
@@ -34,6 +43,12 @@ COST_TRACKER_API_KEY = os.environ.get("COST_TRACKER_API_KEY")
|
|
|
34
43
|
# Timeout for POSTs (seconds) -- keep short so tracking doesn't slow down the caller
|
|
35
44
|
_POST_TIMEOUT = 5
|
|
36
45
|
|
|
46
|
+
# Buffered rows sent after each successful POST
|
|
47
|
+
_DRAIN_BATCH = 5
|
|
48
|
+
|
|
49
|
+
# Seconds the automatic drain waits on the buffer's SQLite lock before giving up
|
|
50
|
+
_DRAIN_DB_TIMEOUT = 0.5
|
|
51
|
+
|
|
37
52
|
|
|
38
53
|
def _post_to_server(endpoint: str, payload: dict) -> bool:
|
|
39
54
|
"""
|
|
@@ -58,6 +73,37 @@ def _post_to_server(endpoint: str, payload: dict) -> bool:
|
|
|
58
73
|
return False
|
|
59
74
|
|
|
60
75
|
|
|
76
|
+
def _send(endpoint: str, payload: dict) -> None:
|
|
77
|
+
"""POST one record; buffer it on failure, or drain the buffer a little on success."""
|
|
78
|
+
if _post_to_server(endpoint, payload):
|
|
79
|
+
_drain_buffer()
|
|
80
|
+
else:
|
|
81
|
+
buffer_record(endpoint, payload)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _drain_buffer() -> None:
|
|
85
|
+
"""Send up to _DRAIN_BATCH buffered rows. Never raises; this call's record is already sent."""
|
|
86
|
+
try:
|
|
87
|
+
if not _buffer.BUFFER_DB_PATH.exists(): # don't create a buffer just to look in it
|
|
88
|
+
return
|
|
89
|
+
result = flush_buffer(
|
|
90
|
+
COST_TRACKER_URL,
|
|
91
|
+
api_key=COST_TRACKER_API_KEY,
|
|
92
|
+
max_batch=_DRAIN_BATCH,
|
|
93
|
+
stop_on_failure=True,
|
|
94
|
+
timeout=_POST_TIMEOUT,
|
|
95
|
+
db_timeout=_DRAIN_DB_TIMEOUT,
|
|
96
|
+
)
|
|
97
|
+
if result["failed"]:
|
|
98
|
+
_log.warning(
|
|
99
|
+
"Buffered cost rows were not sent this time (a retry POST failed after %d sent); "
|
|
100
|
+
"%d stay buffered",
|
|
101
|
+
result["sent"], result["remaining"],
|
|
102
|
+
)
|
|
103
|
+
except Exception: # governance: allow-silent SF001: the caller's own record was already sent; buffered rows stay in the buffer for the next drain, and the failure is logged at WARNING
|
|
104
|
+
_log.warning("Buffered cost rows were not sent this time; they stay buffered", exc_info=True)
|
|
105
|
+
|
|
106
|
+
|
|
61
107
|
def _extract_anthropic(response) -> dict:
|
|
62
108
|
"""Extract token usage from an Anthropic API response."""
|
|
63
109
|
usage = response.usage
|
|
@@ -131,8 +177,17 @@ def _extract_openrouter(response) -> dict:
|
|
|
131
177
|
cost is kept as ``reported_cost_usd`` so no registry price is needed for
|
|
132
178
|
OpenRouter model ids such as ``x-ai/grok-4.3``. A missing or invalid
|
|
133
179
|
cost yields None.
|
|
180
|
+
|
|
181
|
+
A response from the Anthropic SDK routed through OpenRouter has
|
|
182
|
+
Anthropic-shaped usage (``input_tokens``, ``cache_read_input_tokens``, ...)
|
|
183
|
+
and no ``prompt_tokens``; its cache tokens are read from those fields
|
|
184
|
+
instead of being dropped (#8053). A Responses-API shape has no Anthropic cache
|
|
185
|
+
fields, so it still records them as 0.
|
|
134
186
|
"""
|
|
135
187
|
data = _extract_openai(response)
|
|
188
|
+
if not hasattr(response.usage, "prompt_tokens"):
|
|
189
|
+
data["cache_read_tokens"] = getattr(response.usage, "cache_read_input_tokens", 0) or 0
|
|
190
|
+
data["cache_creation_tokens"] = getattr(response.usage, "cache_creation_input_tokens", 0) or 0
|
|
136
191
|
cost = getattr(response.usage, "cost", None)
|
|
137
192
|
valid = isinstance(cost, (int, float)) and not isinstance(cost, bool) and cost >= 0
|
|
138
193
|
data["reported_cost_usd"] = float(cost) if valid else None
|
|
@@ -219,7 +274,7 @@ def track(
|
|
|
219
274
|
total_tokens = prompt_tokens + completion_tokens + cache_read + cache_creation
|
|
220
275
|
|
|
221
276
|
payload = {
|
|
222
|
-
"timestamp": datetime.now().isoformat(),
|
|
277
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
223
278
|
"provider": provider,
|
|
224
279
|
"model": resolved_model,
|
|
225
280
|
"service": service,
|
|
@@ -237,8 +292,7 @@ def track(
|
|
|
237
292
|
"caller": caller,
|
|
238
293
|
}
|
|
239
294
|
|
|
240
|
-
|
|
241
|
-
buffer_record("/track", payload)
|
|
295
|
+
_send("/track", payload)
|
|
242
296
|
|
|
243
297
|
except Exception: # governance: allow-silent SF001: documented never-raise contract (the caller's API call already succeeded and must not fail or be retried); the uncounted call is logged at WARNING with its identity
|
|
244
298
|
_log.warning(
|
|
@@ -278,7 +332,7 @@ def track_tokens(
|
|
|
278
332
|
total_tokens = prompt_tokens + completion_tokens + cache_read_tokens + cache_creation_tokens
|
|
279
333
|
|
|
280
334
|
payload = {
|
|
281
|
-
"timestamp": datetime.now().isoformat(),
|
|
335
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
282
336
|
"provider": provider.lower(),
|
|
283
337
|
"model": model,
|
|
284
338
|
"service": service,
|
|
@@ -294,8 +348,7 @@ def track_tokens(
|
|
|
294
348
|
"caller": caller,
|
|
295
349
|
}
|
|
296
350
|
|
|
297
|
-
|
|
298
|
-
buffer_record("/track", payload)
|
|
351
|
+
_send("/track", payload)
|
|
299
352
|
|
|
300
353
|
except Exception: # governance: allow-silent SF001: documented never-raise contract (the caller's API call already succeeded and must not fail or be retried); the uncounted call is logged at WARNING with its identity
|
|
301
354
|
_log.warning(
|
|
@@ -326,7 +379,7 @@ def log_call(
|
|
|
326
379
|
"""
|
|
327
380
|
try:
|
|
328
381
|
payload = {
|
|
329
|
-
"timestamp": datetime.now().isoformat(),
|
|
382
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
330
383
|
"provider": provider.lower(),
|
|
331
384
|
"service": service,
|
|
332
385
|
"project": project,
|
|
@@ -334,8 +387,7 @@ def log_call(
|
|
|
334
387
|
"source_machine": platform.node(),
|
|
335
388
|
}
|
|
336
389
|
|
|
337
|
-
|
|
338
|
-
buffer_record("/log_call", payload)
|
|
390
|
+
_send("/log_call", payload)
|
|
339
391
|
|
|
340
392
|
except Exception: # governance: allow-silent SF001: documented never-raise contract (the caller's API call already succeeded and must not fail or be retried); the uncounted call is logged at WARNING with its identity
|
|
341
393
|
_log.warning(
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Shared fixtures for the api_cost_tracker client tests."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
from unittest.mock import patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
# The package under test, from source (client/src)
|
|
11
|
+
_src_dir = str(Path(__file__).resolve().parent.parent / "src")
|
|
12
|
+
if _src_dir not in sys.path:
|
|
13
|
+
sys.path.insert(0, _src_dir)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture(autouse=True)
|
|
17
|
+
def _isolate_buffer(monkeypatch, tmp_path):
|
|
18
|
+
"""Keep every test away from the real offline buffer."""
|
|
19
|
+
import api_cost_tracker.buffer as client_buffer
|
|
20
|
+
|
|
21
|
+
monkeypatch.setattr(client_buffer, "BUFFER_DB_PATH", tmp_path / "buffer.db")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture
|
|
25
|
+
def tmp_buffer_db(tmp_path):
|
|
26
|
+
"""Provide a temporary buffer database path."""
|
|
27
|
+
return tmp_path / "test_buffer.db"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.fixture
|
|
31
|
+
def anthropic_response():
|
|
32
|
+
"""Mock Anthropic API response."""
|
|
33
|
+
return SimpleNamespace(
|
|
34
|
+
model="claude-haiku-4-5",
|
|
35
|
+
usage=SimpleNamespace(
|
|
36
|
+
input_tokens=150,
|
|
37
|
+
output_tokens=50,
|
|
38
|
+
cache_read_input_tokens=20,
|
|
39
|
+
cache_creation_input_tokens=10,
|
|
40
|
+
),
|
|
41
|
+
content=[SimpleNamespace(type="text", text="Hello world")],
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.fixture
|
|
46
|
+
def openai_chat_response():
|
|
47
|
+
"""Mock OpenAI Chat Completions API response."""
|
|
48
|
+
return SimpleNamespace(
|
|
49
|
+
model="gpt-4.1-mini",
|
|
50
|
+
usage=SimpleNamespace(
|
|
51
|
+
prompt_tokens=200,
|
|
52
|
+
completion_tokens=80,
|
|
53
|
+
total_tokens=280,
|
|
54
|
+
),
|
|
55
|
+
choices=[SimpleNamespace(
|
|
56
|
+
message=SimpleNamespace(content="Hello world"),
|
|
57
|
+
)],
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.fixture
|
|
62
|
+
def openai_responses_api():
|
|
63
|
+
"""Mock OpenAI Responses API response."""
|
|
64
|
+
return SimpleNamespace(
|
|
65
|
+
model="gpt-4.1-mini",
|
|
66
|
+
usage=SimpleNamespace(
|
|
67
|
+
input_tokens=200,
|
|
68
|
+
output_tokens=80,
|
|
69
|
+
),
|
|
70
|
+
output=[SimpleNamespace(type="message", content="Hello")],
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@pytest.fixture
|
|
75
|
+
def google_response():
|
|
76
|
+
"""Mock Google Gemini API response."""
|
|
77
|
+
return SimpleNamespace(
|
|
78
|
+
usage_metadata=SimpleNamespace(
|
|
79
|
+
prompt_token_count=300,
|
|
80
|
+
candidates_token_count=100,
|
|
81
|
+
total_token_count=400,
|
|
82
|
+
),
|
|
83
|
+
model_version="gemini-2.5-flash",
|
|
84
|
+
text="Hello world",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@pytest.fixture
|
|
89
|
+
def mock_server_post():
|
|
90
|
+
"""Patch _post_to_server to prevent real HTTP calls in tracker tests."""
|
|
91
|
+
with patch("api_cost_tracker.tracker._post_to_server", return_value=True) as mock:
|
|
92
|
+
yield mock
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@pytest.fixture
|
|
96
|
+
def mock_server_post_fail():
|
|
97
|
+
"""Patch _post_to_server to simulate server failure."""
|
|
98
|
+
with patch("api_cost_tracker.tracker._post_to_server", return_value=False) as mock:
|
|
99
|
+
yield mock
|