fiscallm 0.1.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.
fiscallm/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """Track the AI cost of each user against their subscription revenue.
2
+
3
+ from fiscallm import Meter
4
+
5
+ meter = Meter(api_key=os.environ["FISCALLM_KEY"])
6
+ meter.track_openai(response, user_id=revenuecat_app_user_id, feature="chat")
7
+ """
8
+
9
+ from .meter import DEFAULT_ENDPOINT, MAX_BATCH, Meter
10
+ from .usage import StreamUsage, Usage, from_anthropic, from_gemini, from_openai
11
+
12
+ __all__ = ["Meter", "Usage", "StreamUsage", "from_openai", "from_anthropic", "from_gemini", "DEFAULT_ENDPOINT", "MAX_BATCH"]
13
+ __version__ = "0.1.0"
fiscallm/meter.py ADDED
@@ -0,0 +1,277 @@
1
+ """The FiscaLLM meter. Standard library only.
2
+
3
+ Tracking calls only queue an event; a background thread sends batches. The
4
+ meter never raises and never blocks the caller, except ``flush()``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import atexit
10
+ import json
11
+ import logging
12
+ import threading
13
+ import time
14
+ import urllib.error
15
+ import urllib.request
16
+ import uuid
17
+ from datetime import datetime, timezone
18
+ from typing import Any, AsyncIterable, AsyncIterator, Callable, Dict, Iterable, Iterator, List, Optional, TypeVar, Union
19
+
20
+ from .usage import StreamUsage, Usage, from_anthropic, from_gemini, from_openai
21
+
22
+ DEFAULT_ENDPOINT = "https://fiscallm.com/api/v1/events"
23
+ MAX_BATCH = 100
24
+ TIMEOUT_S = 2.0
25
+ RETRY_DELAY_S = 0.25
26
+ INTERVAL_S = 2.0
27
+
28
+ log = logging.getLogger("fiscallm")
29
+ T = TypeVar("T")
30
+
31
+ PostFn = Callable[[str, Dict[str, str], bytes], int]
32
+
33
+
34
+ def _http_post(url: str, headers: Dict[str, str], body: bytes) -> int:
35
+ """POSTs and returns the status code; raises on network errors."""
36
+ request = urllib.request.Request(url, data=body, headers=headers, method="POST")
37
+ try:
38
+ with urllib.request.urlopen(request, timeout=TIMEOUT_S) as response:
39
+ return response.status
40
+ except urllib.error.HTTPError as e:
41
+ return e.code
42
+
43
+
44
+ def _count(value: Any) -> int:
45
+ try:
46
+ number = round(float(value))
47
+ except (TypeError, ValueError):
48
+ return 0
49
+ return int(number) if number > 0 else 0
50
+
51
+
52
+ class Meter:
53
+ """Reports AI usage per user to FiscaLLM.
54
+
55
+ Create one per process. In serverless functions (AWS Lambda, Google Cloud
56
+ Functions) call ``flush()`` before returning.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ api_key: Optional[str],
62
+ *,
63
+ endpoint: str = DEFAULT_ENDPOINT,
64
+ flush_interval: float = INTERVAL_S,
65
+ _post: Optional[PostFn] = None,
66
+ ) -> None:
67
+ self._api_key = api_key
68
+ self._endpoint = endpoint
69
+ self._interval = flush_interval
70
+ self._post = _post or _http_post
71
+ self._queue: List[Dict[str, Any]] = []
72
+ self._lock = threading.Lock()
73
+ self._send_lock = threading.Lock()
74
+ self._wake = threading.Event()
75
+ self._warned: set = set()
76
+ self._closed = False
77
+ self._thread: Optional[threading.Thread] = None
78
+ if not api_key:
79
+ self._warn_once("no-key", "no api_key given; events are not sent")
80
+ atexit.register(self.flush)
81
+
82
+ # ---- public API ----
83
+
84
+ def track(
85
+ self,
86
+ *,
87
+ user_id: str,
88
+ provider: str,
89
+ model: str,
90
+ input_tokens: Union[int, float, None] = 0,
91
+ output_tokens: Union[int, float, None] = 0,
92
+ cached_tokens: Union[int, float, None] = 0,
93
+ cache_write_tokens: Union[int, float, None] = 0,
94
+ cost_usd: Optional[float] = None,
95
+ feature: Optional[str] = None,
96
+ ts: Optional[datetime] = None,
97
+ ) -> None:
98
+ """Queues one event. ``input_tokens`` excludes cache reads and writes.
99
+ Pass ``cost_usd`` for calls not priced per token (images, voice)."""
100
+ try:
101
+ if not self._api_key:
102
+ return
103
+ if not user_id or not provider or not model:
104
+ self._warn_once("invalid", "track() needs user_id, provider and model; event skipped")
105
+ return
106
+ when = ts or datetime.now(timezone.utc)
107
+ if when.tzinfo is None:
108
+ when = when.replace(tzinfo=timezone.utc)
109
+ event: Dict[str, Any] = {
110
+ "id": str(uuid.uuid4()),
111
+ "userId": str(user_id),
112
+ "ts": when.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
113
+ "provider": provider,
114
+ "model": model,
115
+ "inputTokens": _count(input_tokens),
116
+ "outputTokens": _count(output_tokens),
117
+ "cachedTokens": _count(cached_tokens),
118
+ "cacheWriteTokens": _count(cache_write_tokens),
119
+ }
120
+ if isinstance(cost_usd, (int, float)) and cost_usd >= 0:
121
+ event["costUsd"] = float(cost_usd)
122
+ if feature:
123
+ event["feature"] = feature
124
+ with self._lock:
125
+ self._queue.append(event)
126
+ full = len(self._queue) >= MAX_BATCH
127
+ self._ensure_thread()
128
+ if full:
129
+ self._wake.set()
130
+ except Exception: # Metering must never break the caller.
131
+ pass
132
+
133
+ def track_openai(self, response: Any, *, user_id: str, feature: Optional[str] = None, provider: Optional[str] = None, model: Optional[str] = None) -> None:
134
+ """A Chat Completions or Responses API result, from the SDK or ``r.json()``.
135
+ Set ``provider`` for OpenAI-compatible APIs such as Groq or OpenRouter."""
136
+ self._track_usage(self._safe(from_openai, response), "track_openai", user_id, feature, provider, model)
137
+
138
+ def track_anthropic(self, response: Any, *, user_id: str, feature: Optional[str] = None, model: Optional[str] = None) -> None:
139
+ """A Messages API result."""
140
+ self._track_usage(self._safe(from_anthropic, response), "track_anthropic", user_id, feature, None, model)
141
+
142
+ def track_gemini(self, response: Any, *, user_id: str, feature: Optional[str] = None, model: Optional[str] = None) -> None:
143
+ """A ``generate_content`` result from google-genai, or the REST JSON."""
144
+ self._track_usage(self._safe(from_gemini, response), "track_gemini", user_id, feature, None, model)
145
+
146
+ def track_stream(self, stream: Any, *, user_id: str, feature: Optional[str] = None, provider: Optional[str] = None, model: Optional[str] = None) -> Any:
147
+ """Wraps a streamed result; chunks pass through unchanged and usage is
148
+ recorded when the stream ends. Works for sync and async iterables.
149
+ OpenAI Chat Completions needs ``stream_options={"include_usage": True}``."""
150
+ if hasattr(stream, "__aiter__"):
151
+ return self._wrap_async(stream, user_id, feature, provider, model)
152
+ return self._wrap_sync(stream, user_id, feature, provider, model)
153
+
154
+ def flush(self) -> None:
155
+ """Sends everything queued, waiting at most a few seconds. Never raises."""
156
+ try:
157
+ self._send_all()
158
+ except Exception:
159
+ pass
160
+
161
+ async def aflush(self) -> None:
162
+ """``flush()`` for async code, run off the event loop."""
163
+ import asyncio
164
+
165
+ await asyncio.get_running_loop().run_in_executor(None, self.flush)
166
+
167
+ # ---- internals ----
168
+
169
+ def _wrap_sync(self, stream: Iterable[T], user_id: str, feature: Optional[str], provider: Optional[str], model: Optional[str]) -> Iterator[T]:
170
+ acc = StreamUsage()
171
+ try:
172
+ for chunk in stream:
173
+ try:
174
+ acc.add(chunk)
175
+ except Exception:
176
+ pass
177
+ yield chunk
178
+ finally:
179
+ # Also runs when the caller stops early; partial usage is still
180
+ # the best estimate of what was billed.
181
+ self._finish_stream(acc, user_id, feature, provider, model)
182
+
183
+ async def _wrap_async(self, stream: AsyncIterable[T], user_id: str, feature: Optional[str], provider: Optional[str], model: Optional[str]) -> AsyncIterator[T]:
184
+ acc = StreamUsage()
185
+ try:
186
+ async for chunk in stream:
187
+ try:
188
+ acc.add(chunk)
189
+ except Exception:
190
+ pass
191
+ yield chunk
192
+ finally:
193
+ self._finish_stream(acc, user_id, feature, provider, model)
194
+
195
+ def _finish_stream(self, acc: StreamUsage, user_id: str, feature: Optional[str], provider: Optional[str], model: Optional[str]) -> None:
196
+ try:
197
+ if acc.missing_openai_usage:
198
+ self._warn_once("include-usage", 'OpenAI stream had no usage; pass stream_options={"include_usage": True}')
199
+ return
200
+ self._track_usage(acc.usage, "track_stream", user_id, feature, provider, model)
201
+ except Exception:
202
+ pass
203
+
204
+ def _safe(self, fn: Callable[[Any], Optional[Usage]], response: Any) -> Optional[Usage]:
205
+ try:
206
+ return fn(response)
207
+ except Exception:
208
+ return None
209
+
210
+ def _track_usage(self, usage: Optional[Usage], source: str, user_id: str, feature: Optional[str], provider: Optional[str], model: Optional[str]) -> None:
211
+ if usage is None:
212
+ self._warn_once(f"no-usage-{source}", f"{source}: no token usage found in the response; event skipped")
213
+ return
214
+ self.track(
215
+ user_id=user_id,
216
+ provider=provider or usage.provider,
217
+ model=model or usage.model,
218
+ input_tokens=usage.input_tokens,
219
+ output_tokens=usage.output_tokens,
220
+ cached_tokens=usage.cached_tokens,
221
+ cache_write_tokens=usage.cache_write_tokens,
222
+ feature=feature,
223
+ )
224
+
225
+ def _warn_once(self, key: str, message: str) -> None:
226
+ if key in self._warned:
227
+ return
228
+ self._warned.add(key)
229
+ try:
230
+ log.warning("[fiscallm] %s", message)
231
+ except Exception:
232
+ pass
233
+
234
+ def _ensure_thread(self) -> None:
235
+ if self._thread is not None and self._thread.is_alive():
236
+ return
237
+ with self._lock:
238
+ if self._thread is not None and self._thread.is_alive():
239
+ return
240
+ # Daemon: never keeps the process alive; atexit flushes the rest.
241
+ self._thread = threading.Thread(target=self._run, name="fiscallm", daemon=True)
242
+ self._thread.start()
243
+
244
+ def _run(self) -> None:
245
+ while not self._closed:
246
+ self._wake.wait(self._interval)
247
+ self._wake.clear()
248
+ try:
249
+ self._send_all()
250
+ except Exception:
251
+ pass
252
+
253
+ def _send_all(self) -> None:
254
+ with self._send_lock:
255
+ while True:
256
+ with self._lock:
257
+ batch, self._queue = self._queue[:MAX_BATCH], self._queue[MAX_BATCH:]
258
+ if not batch:
259
+ return
260
+ self._send(batch)
261
+
262
+ def _send(self, batch: List[Dict[str, Any]]) -> None:
263
+ body = json.dumps({"events": batch}).encode()
264
+ headers = {"content-type": "application/json", "authorization": f"Bearer {self._api_key}", "user-agent": "fiscallm-python/0.1.0"}
265
+ for attempt in range(2):
266
+ try:
267
+ status = self._post(self._endpoint, headers, body)
268
+ except Exception:
269
+ status = 0
270
+ if 200 <= status < 300:
271
+ return
272
+ if status and status != 429 and status < 500:
273
+ self._warn_once(f"http-{status}", f"events rejected ({status}); check the api_key")
274
+ return
275
+ if attempt == 0:
276
+ time.sleep(RETRY_DELAY_S)
277
+ self._warn_once("unreachable", f"could not reach {self._endpoint}; {len(batch)} events dropped")
fiscallm/py.typed ADDED
File without changes
fiscallm/usage.py ADDED
@@ -0,0 +1,179 @@
1
+ """Reads token usage from provider responses by shape, without importing any
2
+ provider SDK. Works with SDK objects (OpenAI, Anthropic, google-genai) and with
3
+ plain dicts from ``response.json()``.
4
+
5
+ Our convention: input_tokens excludes cache reads and cache writes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Any, Optional
12
+
13
+
14
+ @dataclass
15
+ class Usage:
16
+ provider: str
17
+ model: str
18
+ input_tokens: int
19
+ output_tokens: int
20
+ cached_tokens: int
21
+ cache_write_tokens: int
22
+
23
+
24
+ def _get(obj: Any, *names: str) -> Any:
25
+ """First present attribute or key among ``names``."""
26
+ if obj is None:
27
+ return None
28
+ for name in names:
29
+ if isinstance(obj, dict):
30
+ if name in obj and obj[name] is not None:
31
+ return obj[name]
32
+ else:
33
+ value = getattr(obj, name, None)
34
+ if value is not None:
35
+ return value
36
+ return None
37
+
38
+
39
+ def _n(value: Any) -> int:
40
+ try:
41
+ number = int(value)
42
+ except (TypeError, ValueError):
43
+ return 0
44
+ return number if number > 0 else 0
45
+
46
+
47
+ def _openai_usage(model: str, u: Any) -> Usage:
48
+ total = _n(_get(u, "prompt_tokens", "input_tokens"))
49
+ details = _get(u, "prompt_tokens_details", "input_tokens_details")
50
+ cached = _n(_get(details, "cached_tokens"))
51
+ written = _n(_get(details, "cache_write_tokens"))
52
+ return Usage(
53
+ provider="openai",
54
+ model=model,
55
+ input_tokens=max(0, total - cached - written),
56
+ # Includes reasoning tokens, which are billed as output.
57
+ output_tokens=_n(_get(u, "completion_tokens", "output_tokens")),
58
+ cached_tokens=cached,
59
+ cache_write_tokens=written,
60
+ )
61
+
62
+
63
+ def _anthropic_usage(model: str, u: Any) -> Usage:
64
+ return Usage(
65
+ provider="anthropic",
66
+ model=model,
67
+ input_tokens=_n(_get(u, "input_tokens")),
68
+ output_tokens=_n(_get(u, "output_tokens")),
69
+ cached_tokens=_n(_get(u, "cache_read_input_tokens")),
70
+ cache_write_tokens=_n(_get(u, "cache_creation_input_tokens")),
71
+ )
72
+
73
+
74
+ def _gemini_usage(model: str, u: Any) -> Usage:
75
+ cached = _n(_get(u, "cached_content_token_count", "cachedContentTokenCount"))
76
+ prompt = _n(_get(u, "prompt_token_count", "promptTokenCount"))
77
+ tool = _n(_get(u, "tool_use_prompt_token_count", "toolUsePromptTokenCount"))
78
+ return Usage(
79
+ provider="google",
80
+ model=model,
81
+ # The prompt count includes the cached part.
82
+ input_tokens=max(0, prompt - cached) + tool,
83
+ # Thinking tokens are billed at the output price.
84
+ output_tokens=_n(_get(u, "candidates_token_count", "candidatesTokenCount"))
85
+ + _n(_get(u, "thoughts_token_count", "thoughtsTokenCount")),
86
+ cached_tokens=cached,
87
+ cache_write_tokens=0,
88
+ )
89
+
90
+
91
+ def from_openai(res: Any) -> Optional[Usage]:
92
+ usage = _get(res, "usage")
93
+ return None if usage is None else _openai_usage(str(_get(res, "model") or ""), usage)
94
+
95
+
96
+ def from_anthropic(res: Any) -> Optional[Usage]:
97
+ usage = _get(res, "usage")
98
+ return None if usage is None else _anthropic_usage(str(_get(res, "model") or ""), usage)
99
+
100
+
101
+ def from_gemini(res: Any) -> Optional[Usage]:
102
+ usage = _get(res, "usage_metadata", "usageMetadata")
103
+ return None if usage is None else _gemini_usage(str(_get(res, "model_version", "modelVersion") or ""), usage)
104
+
105
+
106
+ class StreamUsage:
107
+ """Collects usage from streamed chunks of any of the three providers."""
108
+
109
+ def __init__(self) -> None:
110
+ self.kind: Optional[str] = None
111
+ self.model = ""
112
+ self.raw: Any = None
113
+ self._anthropic: dict = {}
114
+
115
+ @property
116
+ def missing_openai_usage(self) -> bool:
117
+ return self.kind == "openai" and self.raw is None
118
+
119
+ def add(self, chunk: Any) -> None:
120
+ kind = _get(chunk, "type")
121
+ kind = kind if isinstance(kind, str) else ""
122
+
123
+ # Anthropic: message_start has the input; message_delta has
124
+ # cumulative totals that can also update the input counts.
125
+ if kind == "message_start":
126
+ message = _get(chunk, "message")
127
+ self.kind = "anthropic"
128
+ self.model = str(_get(message, "model") or self.model)
129
+ self._merge(_get(message, "usage"))
130
+ return
131
+ if kind == "message_delta":
132
+ self.kind = "anthropic"
133
+ self._merge(_get(chunk, "usage"))
134
+ return
135
+
136
+ # OpenAI Responses API: response.completed and friends.
137
+ if kind.startswith("response.") and _get(chunk, "response") is not None:
138
+ response = _get(chunk, "response")
139
+ self.kind = "openai"
140
+ self.model = str(_get(response, "model") or self.model)
141
+ if _get(response, "usage") is not None:
142
+ self.raw = _get(response, "usage")
143
+ return
144
+
145
+ # OpenAI Chat Completions: usage on the last chunk with include_usage.
146
+ if _get(chunk, "object") == "chat.completion.chunk" or _get(chunk, "choices") is not None:
147
+ self.kind = "openai"
148
+ self.model = str(_get(chunk, "model") or self.model)
149
+ if _get(chunk, "usage") is not None:
150
+ self.raw = _get(chunk, "usage")
151
+ return
152
+
153
+ # Gemini: each chunk may carry usage metadata; the last is final.
154
+ metadata = _get(chunk, "usage_metadata", "usageMetadata")
155
+ if metadata is not None or _get(chunk, "candidates") is not None:
156
+ self.kind = "google"
157
+ self.model = str(_get(chunk, "model_version", "modelVersion") or self.model)
158
+ if metadata is not None:
159
+ self.raw = metadata
160
+
161
+ def _merge(self, usage: Any) -> None:
162
+ if usage is None:
163
+ return
164
+ fields = ("input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens")
165
+ for field in fields:
166
+ value = _get(usage, field)
167
+ if value is not None:
168
+ self._anthropic[field] = value
169
+ self.raw = self._anthropic
170
+
171
+ @property
172
+ def usage(self) -> Optional[Usage]:
173
+ if self.kind is None or self.raw is None:
174
+ return None
175
+ if self.kind == "anthropic":
176
+ return _anthropic_usage(self.model, self.raw)
177
+ if self.kind == "google":
178
+ return _gemini_usage(self.model, self.raw)
179
+ return _openai_usage(self.model, self.raw)
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.5
2
+ Name: fiscallm
3
+ Version: 0.1.0
4
+ Summary: Track AI cost per user against subscription revenue. Zero-dependency SDK for your backend.
5
+ Project-URL: Homepage, https://fiscallm.com
6
+ Project-URL: Documentation, https://fiscallm.com/docs
7
+ Project-URL: Source, https://github.com/magna56/ai_spend_book
8
+ License-Expression: MIT
9
+ Keywords: ai,anthropic,cost,gemini,llm,openai,revenuecat
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+
16
+ # fiscallm
17
+
18
+ Track the AI cost of each user of your app against their subscription revenue. Zero dependencies, Python 3.9+.
19
+
20
+ The SDK never sits in the path of your AI calls: tracking only queues an event, a background thread sends batches, and it never raises. Prompts and responses are never sent, only token counts.
21
+
22
+ ```sh
23
+ pip install fiscallm
24
+ ```
25
+
26
+ ```python
27
+ import os
28
+ from fiscallm import Meter
29
+
30
+ meter = Meter(api_key=os.environ["FISCALLM_KEY"]) # one per process
31
+
32
+ # user_id must be the user's RevenueCat app user ID.
33
+ res = openai_client.chat.completions.create(model=model, messages=messages)
34
+ meter.track_openai(res, user_id=user_id, feature="chat")
35
+
36
+ meter.track_anthropic(message, user_id=user_id)
37
+ meter.track_gemini(result, user_id=user_id)
38
+
39
+ # Streams (sync or async): iterate the returned stream.
40
+ stream = meter.track_stream(
41
+ openai_client.chat.completions.create(model=model, messages=messages, stream=True,
42
+ stream_options={"include_usage": True}),
43
+ user_id=user_id,
44
+ )
45
+
46
+ # Anything not priced per token:
47
+ meter.track(user_id=user_id, provider="fal", model="flux-pro", cost_usd=0.05)
48
+
49
+ # Serverless (AWS Lambda, Cloud Functions): before returning
50
+ meter.flush() # or: await meter.aflush()
51
+ ```
52
+
53
+ OpenAI-compatible APIs work with `meter.track_openai(res, user_id=user_id, provider="groq")`.
54
+
55
+ Docs: https://fiscallm.com/docs
@@ -0,0 +1,7 @@
1
+ fiscallm/__init__.py,sha256=eziWD4lwDY-PJLXIwBZwn0P1la4qwMw7ol11WP-_Nkg,525
2
+ fiscallm/meter.py,sha256=6-s2bVkIt9t7qQz38UbgTEgNXp4bCHeH7JYlTBIHExs,10992
3
+ fiscallm/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ fiscallm/usage.py,sha256=gS0ChVcfuvng5QKyid_myVVqRvtQ7S_5lrczXTsfEVk,6365
5
+ fiscallm-0.1.0.dist-info/METADATA,sha256=RRoJPoZvety9TmJVfnLWwSoQ9U6Mf8NlxhEFYhSGZDk,1962
6
+ fiscallm-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
7
+ fiscallm-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any