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 +13 -0
- fiscallm/meter.py +277 -0
- fiscallm/py.typed +0 -0
- fiscallm/usage.py +179 -0
- fiscallm-0.1.0.dist-info/METADATA +55 -0
- fiscallm-0.1.0.dist-info/RECORD +7 -0
- fiscallm-0.1.0.dist-info/WHEEL +4 -0
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,,
|