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.
@@ -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.0"
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
- Both return immediately. Neither raises exceptions -- a tracking failure (or an
9
- unknown provider) is logged at WARNING with the call's identity, because that
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 .buffer import buffer_record
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
- if not _post_to_server("/track", payload):
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
- if not _post_to_server("/track", payload):
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
- if not _post_to_server("/log_call", payload):
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