spendline 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spendline-0.1.0/LICENSE +21 -0
- spendline-0.1.0/PKG-INFO +105 -0
- spendline-0.1.0/README.md +74 -0
- spendline-0.1.0/pyproject.toml +46 -0
- spendline-0.1.0/spendline/__init__.py +7 -0
- spendline-0.1.0/spendline/autopatch.py +107 -0
- spendline-0.1.0/spendline/batch.py +53 -0
- spendline-0.1.0/spendline/client.py +50 -0
- spendline-0.1.0/spendline/costs.py +95 -0
- spendline-0.1.0/spendline/langchain.py +148 -0
- spendline-0.1.0/spendline/track.py +132 -0
spendline-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Spendline
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
spendline-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spendline
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Track LLM tokens, cost, latency, and model usage in production.
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: llm,ai,openai,anthropic,bedrock,observability,cost-tracking
|
|
8
|
+
Author: Spendline
|
|
9
|
+
Author-email: hello@spendline.dev
|
|
10
|
+
Requires-Python: >=3.8
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Dist: httpx (>=0.24)
|
|
24
|
+
Requires-Dist: requests (>=2.28)
|
|
25
|
+
Project-URL: Documentation, https://github.com/emartai/spendline/tree/main/doc
|
|
26
|
+
Project-URL: Homepage, https://github.com/emartai/spendline
|
|
27
|
+
Project-URL: Issues, https://github.com/emartai/spendline/issues
|
|
28
|
+
Project-URL: Repository, https://github.com/emartai/spendline
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# Spendline Python SDK
|
|
32
|
+
|
|
33
|
+
Spendline tracks LLM usage in production with one line of code. The SDK captures tokens, model, latency, cost, timestamp, workflow ID, and request metadata without collecting prompt or completion text.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install spendline
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quick Start
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from openai import OpenAI
|
|
45
|
+
from spendline import track
|
|
46
|
+
|
|
47
|
+
client = OpenAI()
|
|
48
|
+
|
|
49
|
+
response = track(
|
|
50
|
+
client.chat.completions.create(
|
|
51
|
+
model="gpt-5-mini",
|
|
52
|
+
messages=[{"role": "user", "content": "Say hello"}],
|
|
53
|
+
),
|
|
54
|
+
workflow_id="support-bot",
|
|
55
|
+
session_id="session-123",
|
|
56
|
+
metadata={"feature": "chat", "environment": "production"},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
print(response.choices[0].message.content)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Auto-Patch Supported Clients
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from spendline import patch
|
|
66
|
+
|
|
67
|
+
patch()
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## LangChain
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from spendline.langchain import SpendlineCallbackHandler
|
|
74
|
+
|
|
75
|
+
handler = SpendlineCallbackHandler(workflow_id="chatbot")
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Supported Providers
|
|
79
|
+
|
|
80
|
+
| Provider | Support |
|
|
81
|
+
| --- | --- |
|
|
82
|
+
| OpenAI | `track()` and `patch()` |
|
|
83
|
+
| Anthropic | `track()` and `patch()` |
|
|
84
|
+
| Google Gemini | `track()` |
|
|
85
|
+
| DeepSeek | `track()` |
|
|
86
|
+
| Bedrock | `track()` |
|
|
87
|
+
|
|
88
|
+
## Environment Variables
|
|
89
|
+
|
|
90
|
+
| Variable | Purpose |
|
|
91
|
+
| --- | --- |
|
|
92
|
+
| `SPENDLINE_API_KEY` | Default ingest key |
|
|
93
|
+
| `SPENDLINE_API_URL` | Override API base URL |
|
|
94
|
+
| `SPENDLINE_DISABLE` | Disable tracking when `true` |
|
|
95
|
+
| `SPENDLINE_LOG` | Print tracked events locally |
|
|
96
|
+
|
|
97
|
+
## Privacy
|
|
98
|
+
|
|
99
|
+
Spendline does not collect:
|
|
100
|
+
|
|
101
|
+
- Prompt text
|
|
102
|
+
- Completion text
|
|
103
|
+
- Raw request bodies
|
|
104
|
+
- Secrets from your application context
|
|
105
|
+
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Spendline Python SDK
|
|
2
|
+
|
|
3
|
+
Spendline tracks LLM usage in production with one line of code. The SDK captures tokens, model, latency, cost, timestamp, workflow ID, and request metadata without collecting prompt or completion text.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install spendline
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from openai import OpenAI
|
|
15
|
+
from spendline import track
|
|
16
|
+
|
|
17
|
+
client = OpenAI()
|
|
18
|
+
|
|
19
|
+
response = track(
|
|
20
|
+
client.chat.completions.create(
|
|
21
|
+
model="gpt-5-mini",
|
|
22
|
+
messages=[{"role": "user", "content": "Say hello"}],
|
|
23
|
+
),
|
|
24
|
+
workflow_id="support-bot",
|
|
25
|
+
session_id="session-123",
|
|
26
|
+
metadata={"feature": "chat", "environment": "production"},
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
print(response.choices[0].message.content)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Auto-Patch Supported Clients
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from spendline import patch
|
|
36
|
+
|
|
37
|
+
patch()
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## LangChain
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from spendline.langchain import SpendlineCallbackHandler
|
|
44
|
+
|
|
45
|
+
handler = SpendlineCallbackHandler(workflow_id="chatbot")
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Supported Providers
|
|
49
|
+
|
|
50
|
+
| Provider | Support |
|
|
51
|
+
| --- | --- |
|
|
52
|
+
| OpenAI | `track()` and `patch()` |
|
|
53
|
+
| Anthropic | `track()` and `patch()` |
|
|
54
|
+
| Google Gemini | `track()` |
|
|
55
|
+
| DeepSeek | `track()` |
|
|
56
|
+
| Bedrock | `track()` |
|
|
57
|
+
|
|
58
|
+
## Environment Variables
|
|
59
|
+
|
|
60
|
+
| Variable | Purpose |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| `SPENDLINE_API_KEY` | Default ingest key |
|
|
63
|
+
| `SPENDLINE_API_URL` | Override API base URL |
|
|
64
|
+
| `SPENDLINE_DISABLE` | Disable tracking when `true` |
|
|
65
|
+
| `SPENDLINE_LOG` | Print tracked events locally |
|
|
66
|
+
|
|
67
|
+
## Privacy
|
|
68
|
+
|
|
69
|
+
Spendline does not collect:
|
|
70
|
+
|
|
71
|
+
- Prompt text
|
|
72
|
+
- Completion text
|
|
73
|
+
- Raw request bodies
|
|
74
|
+
- Secrets from your application context
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["poetry-core>=1.8.0"]
|
|
3
|
+
build-backend = "poetry.core.masonry.api"
|
|
4
|
+
|
|
5
|
+
[tool.poetry]
|
|
6
|
+
name = "spendline"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Track LLM tokens, cost, latency, and model usage in production."
|
|
9
|
+
authors = ["Spendline <hello@spendline.dev>"]
|
|
10
|
+
license = "MIT"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
packages = [{ include = "spendline" }]
|
|
13
|
+
keywords = ["llm", "ai", "openai", "anthropic", "bedrock", "observability", "cost-tracking"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.8",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
25
|
+
]
|
|
26
|
+
include = ["LICENSE"]
|
|
27
|
+
|
|
28
|
+
[tool.poetry.dependencies]
|
|
29
|
+
python = ">=3.8"
|
|
30
|
+
requests = ">=2.28"
|
|
31
|
+
httpx = ">=0.24"
|
|
32
|
+
|
|
33
|
+
[tool.poetry.group.dev.dependencies]
|
|
34
|
+
pytest = ">=8.0"
|
|
35
|
+
pytest-mock = ">=3.0"
|
|
36
|
+
responses = ">=0.25"
|
|
37
|
+
pytest-asyncio = ">=0.23"
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
testpaths = ["tests"]
|
|
41
|
+
|
|
42
|
+
[tool.poetry.urls]
|
|
43
|
+
Homepage = "https://github.com/emartai/spendline"
|
|
44
|
+
Repository = "https://github.com/emartai/spendline"
|
|
45
|
+
Documentation = "https://github.com/emartai/spendline/tree/main/doc"
|
|
46
|
+
Issues = "https://github.com/emartai/spendline/issues"
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Automatic provider patching for Spendline."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
import warnings
|
|
6
|
+
from functools import wraps
|
|
7
|
+
|
|
8
|
+
from .track import _record_response
|
|
9
|
+
|
|
10
|
+
_MARKER_ATTR = "__spendline_patched__"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _tracking_disabled() -> bool:
|
|
14
|
+
return os.getenv("SPENDLINE_DISABLE", "").lower() == "true"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _warn_missing_api_key():
|
|
18
|
+
if not os.getenv("SPENDLINE_API_KEY"):
|
|
19
|
+
warnings.warn(
|
|
20
|
+
"SPENDLINE_API_KEY is not set; spendline.patch() will not send tracking data.",
|
|
21
|
+
RuntimeWarning,
|
|
22
|
+
stacklevel=2,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _patch_sync_method(target, attr_name):
|
|
27
|
+
original = getattr(target, attr_name, None)
|
|
28
|
+
if original is None or getattr(original, _MARKER_ATTR, False):
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
@wraps(original)
|
|
32
|
+
def wrapped(*args, **kwargs):
|
|
33
|
+
started_at = time.perf_counter()
|
|
34
|
+
response = original(*args, **kwargs)
|
|
35
|
+
try:
|
|
36
|
+
_record_response(response, started_at=started_at)
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
return response
|
|
40
|
+
|
|
41
|
+
setattr(wrapped, _MARKER_ATTR, True)
|
|
42
|
+
setattr(target, attr_name, wrapped)
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _patch_async_method(target, attr_name):
|
|
47
|
+
original = getattr(target, attr_name, None)
|
|
48
|
+
if original is None or getattr(original, _MARKER_ATTR, False):
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
@wraps(original)
|
|
52
|
+
async def wrapped(*args, **kwargs):
|
|
53
|
+
started_at = time.perf_counter()
|
|
54
|
+
response = await original(*args, **kwargs)
|
|
55
|
+
try:
|
|
56
|
+
_record_response(response, started_at=started_at)
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
return response
|
|
60
|
+
|
|
61
|
+
setattr(wrapped, _MARKER_ATTR, True)
|
|
62
|
+
setattr(target, attr_name, wrapped)
|
|
63
|
+
return True
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _patch_openai():
|
|
67
|
+
try:
|
|
68
|
+
import openai
|
|
69
|
+
except ImportError:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
patched = False
|
|
73
|
+
|
|
74
|
+
chat = getattr(openai, "chat", None)
|
|
75
|
+
completions = getattr(chat, "completions", None) if chat is not None else None
|
|
76
|
+
if completions is not None:
|
|
77
|
+
patched = _patch_sync_method(completions, "create") or patched
|
|
78
|
+
patched = _patch_async_method(completions, "acreate") or patched
|
|
79
|
+
|
|
80
|
+
return patched
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _patch_anthropic():
|
|
84
|
+
try:
|
|
85
|
+
import anthropic
|
|
86
|
+
except ImportError:
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
patched = False
|
|
90
|
+
messages = getattr(anthropic, "messages", None)
|
|
91
|
+
if messages is not None:
|
|
92
|
+
patched = _patch_sync_method(messages, "create") or patched
|
|
93
|
+
patched = _patch_sync_method(messages, "stream") or patched
|
|
94
|
+
|
|
95
|
+
return patched
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def patch():
|
|
99
|
+
"""Patches supported provider SDK entrypoints in place."""
|
|
100
|
+
|
|
101
|
+
if _tracking_disabled():
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
_warn_missing_api_key()
|
|
105
|
+
_patch_openai()
|
|
106
|
+
_patch_anthropic()
|
|
107
|
+
return None
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Batch sending for Spendline ingest events."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BatchBuffer:
|
|
7
|
+
"""Buffers ingest events and flushes them asynchronously."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, api_key: str, api_url: str, max_size=100, flush_interval=2.0):
|
|
10
|
+
self.api_key = api_key
|
|
11
|
+
self.api_url = api_url.rstrip("/")
|
|
12
|
+
self.max_size = max_size
|
|
13
|
+
self.flush_interval = flush_interval
|
|
14
|
+
self._buffer = []
|
|
15
|
+
self._lock = threading.Lock()
|
|
16
|
+
self._timer = None
|
|
17
|
+
self._start_timer()
|
|
18
|
+
|
|
19
|
+
def add(self, event: dict):
|
|
20
|
+
with self._lock:
|
|
21
|
+
self._buffer.append(event)
|
|
22
|
+
if len(self._buffer) >= self.max_size:
|
|
23
|
+
self._flush_locked()
|
|
24
|
+
|
|
25
|
+
def _start_timer(self):
|
|
26
|
+
self._timer = threading.Timer(self.flush_interval, self._flush_and_restart)
|
|
27
|
+
self._timer.daemon = True
|
|
28
|
+
self._timer.start()
|
|
29
|
+
|
|
30
|
+
def _flush_and_restart(self):
|
|
31
|
+
with self._lock:
|
|
32
|
+
self._flush_locked()
|
|
33
|
+
self._start_timer()
|
|
34
|
+
|
|
35
|
+
def _flush_locked(self):
|
|
36
|
+
if not self._buffer:
|
|
37
|
+
return
|
|
38
|
+
events = self._buffer[:]
|
|
39
|
+
self._buffer = []
|
|
40
|
+
threading.Thread(target=self._send, args=(events,), daemon=True).start()
|
|
41
|
+
|
|
42
|
+
def _send(self, events: list):
|
|
43
|
+
try:
|
|
44
|
+
import requests as http
|
|
45
|
+
|
|
46
|
+
http.post(
|
|
47
|
+
f"{self.api_url}/v1/ingest",
|
|
48
|
+
json=events,
|
|
49
|
+
headers={"Authorization": f"Bearer {self.api_key}"},
|
|
50
|
+
timeout=5,
|
|
51
|
+
)
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Client helpers for the Spendline SDK."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import threading
|
|
5
|
+
|
|
6
|
+
from .batch import BatchBuffer
|
|
7
|
+
|
|
8
|
+
DEFAULT_API_URL = os.getenv("SPENDLINE_API_URL", "https://api.spendline.dev")
|
|
9
|
+
|
|
10
|
+
_clients = {}
|
|
11
|
+
_clients_lock = threading.Lock()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Spendline:
|
|
15
|
+
"""Small SDK client that owns a shared batch buffer."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, api_key=None, api_url=None):
|
|
18
|
+
self.api_key = api_key or os.getenv("SPENDLINE_API_KEY")
|
|
19
|
+
self.api_url = (api_url or os.getenv("SPENDLINE_API_URL") or DEFAULT_API_URL).rstrip("/")
|
|
20
|
+
self.batch = BatchBuffer(self.api_key, self.api_url) if self.api_key else None
|
|
21
|
+
|
|
22
|
+
def track(self, response_or_fn, workflow_id=None, session_id=None, metadata=None):
|
|
23
|
+
from .track import track
|
|
24
|
+
|
|
25
|
+
return track(
|
|
26
|
+
response_or_fn,
|
|
27
|
+
api_key=self.api_key,
|
|
28
|
+
workflow_id=workflow_id,
|
|
29
|
+
session_id=session_id,
|
|
30
|
+
metadata=metadata,
|
|
31
|
+
api_url=self.api_url,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_client(api_key=None, api_url=None):
|
|
36
|
+
"""Returns a shared Spendline client per api key and URL."""
|
|
37
|
+
|
|
38
|
+
resolved_api_key = api_key or os.getenv("SPENDLINE_API_KEY")
|
|
39
|
+
resolved_api_url = (api_url or os.getenv("SPENDLINE_API_URL") or DEFAULT_API_URL).rstrip("/")
|
|
40
|
+
|
|
41
|
+
if not resolved_api_key:
|
|
42
|
+
return Spendline(api_key=None, api_url=resolved_api_url)
|
|
43
|
+
|
|
44
|
+
cache_key = (resolved_api_key, resolved_api_url)
|
|
45
|
+
with _clients_lock:
|
|
46
|
+
client = _clients.get(cache_key)
|
|
47
|
+
if client is None:
|
|
48
|
+
client = Spendline(api_key=resolved_api_key, api_url=resolved_api_url)
|
|
49
|
+
_clients[cache_key] = client
|
|
50
|
+
return client
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Pricing lookup and response usage extraction."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import requests as http
|
|
8
|
+
|
|
9
|
+
CACHE_TTL = 86400
|
|
10
|
+
API_URL = os.getenv("SPENDLINE_API_URL", "https://api.spendline.dev")
|
|
11
|
+
|
|
12
|
+
_lock = threading.Lock()
|
|
13
|
+
_model_cache = {}
|
|
14
|
+
_cache_fetched_at = 0.0
|
|
15
|
+
|
|
16
|
+
FALLBACK_BASELINE = {
|
|
17
|
+
"claude-sonnet-4-6": {"input": 3.00, "output": 15.00},
|
|
18
|
+
"claude-haiku-4-5": {"input": 1.00, "output": 5.00},
|
|
19
|
+
"gpt-5.2": {"input": 1.75, "output": 14.00},
|
|
20
|
+
"gpt-5-mini": {"input": 0.25, "output": 2.00},
|
|
21
|
+
"gemini-2-5-flash": {"input": 0.30, "output": 2.50},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def getCostMap(api_url=None) -> dict:
|
|
26
|
+
"""Returns the pricing map, refreshing at most once per TTL."""
|
|
27
|
+
|
|
28
|
+
global _cache_fetched_at, _model_cache
|
|
29
|
+
|
|
30
|
+
now = time.time()
|
|
31
|
+
with _lock:
|
|
32
|
+
if _cache_fetched_at and (now - _cache_fetched_at) < CACHE_TTL:
|
|
33
|
+
return _model_cache
|
|
34
|
+
|
|
35
|
+
base_url = (api_url or os.getenv("SPENDLINE_API_URL") or API_URL).rstrip("/")
|
|
36
|
+
try:
|
|
37
|
+
response = http.get(f"{base_url}/v1/models", timeout=3)
|
|
38
|
+
data = response.json()
|
|
39
|
+
_model_cache = {
|
|
40
|
+
model["model_id"]: {
|
|
41
|
+
"input": model["input_cost_per_1m"],
|
|
42
|
+
"output": model["output_cost_per_1m"],
|
|
43
|
+
}
|
|
44
|
+
for model in data["models"]
|
|
45
|
+
}
|
|
46
|
+
_cache_fetched_at = now
|
|
47
|
+
except Exception:
|
|
48
|
+
if not _model_cache:
|
|
49
|
+
_model_cache = FALLBACK_BASELINE.copy()
|
|
50
|
+
|
|
51
|
+
return _model_cache
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def calculate_cost(model: str, tokens_in: int, tokens_out: int, api_url=None) -> tuple:
|
|
55
|
+
"""Returns (cost_usd, unknown_model)."""
|
|
56
|
+
|
|
57
|
+
cost_map = getCostMap(api_url=api_url)
|
|
58
|
+
if model not in cost_map:
|
|
59
|
+
return 0.0, True
|
|
60
|
+
|
|
61
|
+
prices = cost_map[model]
|
|
62
|
+
cost = (tokens_in / 1_000_000 * prices["input"]) + (
|
|
63
|
+
tokens_out / 1_000_000 * prices["output"]
|
|
64
|
+
)
|
|
65
|
+
return round(cost, 8), False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def extract_usage(response) -> dict:
|
|
69
|
+
"""Handles OpenAI, Anthropic, and Bedrock response shapes."""
|
|
70
|
+
|
|
71
|
+
usage = getattr(response, "usage", None)
|
|
72
|
+
|
|
73
|
+
if usage is not None and hasattr(usage, "prompt_tokens"):
|
|
74
|
+
return {
|
|
75
|
+
"tokens_in": getattr(usage, "prompt_tokens", 0) or 0,
|
|
76
|
+
"tokens_out": getattr(usage, "completion_tokens", 0) or 0,
|
|
77
|
+
"model": getattr(response, "model", "unknown"),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if usage is not None and hasattr(usage, "input_tokens"):
|
|
81
|
+
return {
|
|
82
|
+
"tokens_in": getattr(usage, "input_tokens", 0) or 0,
|
|
83
|
+
"tokens_out": getattr(usage, "output_tokens", 0) or 0,
|
|
84
|
+
"model": getattr(response, "model", "unknown"),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if isinstance(response, dict) and "usage" in response:
|
|
88
|
+
usage_dict = response.get("usage") or {}
|
|
89
|
+
return {
|
|
90
|
+
"tokens_in": usage_dict.get("inputTokens", 0) or 0,
|
|
91
|
+
"tokens_out": usage_dict.get("outputTokens", 0) or 0,
|
|
92
|
+
"model": response.get("modelId", "unknown"),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {"tokens_in": 0, "tokens_out": 0, "model": "unknown"}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""LangChain callback handler for Spendline."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
from .client import get_client
|
|
9
|
+
from .costs import calculate_cost, getCostMap
|
|
10
|
+
from .track import _tracking_disabled, _truncate_metadata, detect_provider
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from langchain.callbacks.base import BaseCallbackHandler as _BaseCallbackHandler
|
|
14
|
+
|
|
15
|
+
_LANGCHAIN_IMPORT_ERROR = None
|
|
16
|
+
except ImportError as exc:
|
|
17
|
+
_BaseCallbackHandler = object
|
|
18
|
+
_LANGCHAIN_IMPORT_ERROR = exc
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SpendlineCallbackHandler(_BaseCallbackHandler):
|
|
22
|
+
"""LangChain callback that tracks LLM usage to Spendline."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, workflow_id=None, session_id=None, api_key=None):
|
|
25
|
+
if _LANGCHAIN_IMPORT_ERROR is not None:
|
|
26
|
+
raise ImportError(
|
|
27
|
+
"langchain is required to use SpendlineCallbackHandler. "
|
|
28
|
+
"Install langchain to enable this integration."
|
|
29
|
+
) from _LANGCHAIN_IMPORT_ERROR
|
|
30
|
+
|
|
31
|
+
self.workflow_id = workflow_id
|
|
32
|
+
self.session_id = session_id
|
|
33
|
+
self.api_key = api_key or os.getenv("SPENDLINE_API_KEY")
|
|
34
|
+
self._start_times = {}
|
|
35
|
+
self._models = {}
|
|
36
|
+
|
|
37
|
+
def on_llm_start(self, serialized, prompts, **kwargs):
|
|
38
|
+
try:
|
|
39
|
+
if _tracking_disabled():
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
run_id = kwargs.get("run_id")
|
|
43
|
+
if run_id is None:
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
self._start_times[str(run_id)] = time.perf_counter()
|
|
47
|
+
|
|
48
|
+
model = (
|
|
49
|
+
(serialized or {}).get("kwargs", {}).get("model_name")
|
|
50
|
+
or (serialized or {}).get("kwargs", {}).get("model")
|
|
51
|
+
or (serialized or {}).get("name")
|
|
52
|
+
or "unknown"
|
|
53
|
+
)
|
|
54
|
+
self._models[str(run_id)] = model
|
|
55
|
+
|
|
56
|
+
if os.getenv("SPENDLINE_LOG", "").lower() == "true" and model != "unknown":
|
|
57
|
+
print({"spendline_langchain_model": model})
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
def on_llm_end(self, response, **kwargs):
|
|
62
|
+
try:
|
|
63
|
+
if _tracking_disabled():
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
run_id = str(kwargs.get("run_id"))
|
|
67
|
+
started_at = self._start_times.pop(run_id, time.perf_counter())
|
|
68
|
+
model = self._models.pop(run_id, "unknown")
|
|
69
|
+
|
|
70
|
+
llm_output = getattr(response, "llm_output", None) or {}
|
|
71
|
+
usage = llm_output.get("token_usage") or llm_output.get("usage") or {}
|
|
72
|
+
|
|
73
|
+
tokens_in = int(
|
|
74
|
+
usage.get("prompt_tokens")
|
|
75
|
+
or usage.get("input_tokens")
|
|
76
|
+
or usage.get("inputTokens")
|
|
77
|
+
or 0
|
|
78
|
+
)
|
|
79
|
+
tokens_out = int(
|
|
80
|
+
usage.get("completion_tokens")
|
|
81
|
+
or usage.get("output_tokens")
|
|
82
|
+
or usage.get("outputTokens")
|
|
83
|
+
or 0
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
model = llm_output.get("model_name") or llm_output.get("model") or model
|
|
87
|
+
provider = detect_provider(model)
|
|
88
|
+
getCostMap()
|
|
89
|
+
cost_usd, unknown_model = calculate_cost(model, tokens_in, tokens_out)
|
|
90
|
+
latency_ms = int((time.perf_counter() - started_at) * 1000)
|
|
91
|
+
|
|
92
|
+
event = {
|
|
93
|
+
"model": model,
|
|
94
|
+
"provider": provider,
|
|
95
|
+
"tokens_in": tokens_in,
|
|
96
|
+
"tokens_out": tokens_out,
|
|
97
|
+
"latency_ms": latency_ms,
|
|
98
|
+
"cost_usd": cost_usd,
|
|
99
|
+
"unknown_model": unknown_model,
|
|
100
|
+
"workflow_id": self.workflow_id,
|
|
101
|
+
"session_id": self.session_id,
|
|
102
|
+
"request_id": str(uuid.uuid4()),
|
|
103
|
+
"metadata": None,
|
|
104
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if os.getenv("SPENDLINE_LOG", "").lower() == "true":
|
|
108
|
+
print(event)
|
|
109
|
+
|
|
110
|
+
client = get_client(self.api_key)
|
|
111
|
+
if client.batch is not None:
|
|
112
|
+
client.batch.add(event)
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
def on_llm_error(self, error, **kwargs):
|
|
117
|
+
try:
|
|
118
|
+
if _tracking_disabled():
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
run_id = str(kwargs.get("run_id"))
|
|
122
|
+
started_at = self._start_times.pop(run_id, time.perf_counter())
|
|
123
|
+
model = self._models.pop(run_id, "unknown")
|
|
124
|
+
latency_ms = int((time.perf_counter() - started_at) * 1000)
|
|
125
|
+
|
|
126
|
+
event = {
|
|
127
|
+
"model": model,
|
|
128
|
+
"provider": detect_provider(model),
|
|
129
|
+
"tokens_in": 0,
|
|
130
|
+
"tokens_out": 0,
|
|
131
|
+
"latency_ms": latency_ms,
|
|
132
|
+
"cost_usd": 0.0,
|
|
133
|
+
"unknown_model": model == "unknown",
|
|
134
|
+
"workflow_id": self.workflow_id,
|
|
135
|
+
"session_id": self.session_id,
|
|
136
|
+
"request_id": str(uuid.uuid4()),
|
|
137
|
+
"metadata": _truncate_metadata({"error": True}),
|
|
138
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if os.getenv("SPENDLINE_LOG", "").lower() == "true":
|
|
142
|
+
print(event)
|
|
143
|
+
|
|
144
|
+
client = get_client(self.api_key)
|
|
145
|
+
if client.batch is not None:
|
|
146
|
+
client.batch.add(event)
|
|
147
|
+
except Exception:
|
|
148
|
+
pass
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Core Spendline tracking helpers."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
from .client import get_client
|
|
9
|
+
from .costs import calculate_cost, extract_usage
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def detect_provider(model: str) -> str:
|
|
13
|
+
"""Best-effort provider detection from model naming."""
|
|
14
|
+
|
|
15
|
+
if model.startswith("claude-"):
|
|
16
|
+
return "anthropic"
|
|
17
|
+
if model.startswith("gpt-"):
|
|
18
|
+
return "openai"
|
|
19
|
+
if model.startswith("o1") or model.startswith("o3") or model.startswith("o4"):
|
|
20
|
+
return "openai"
|
|
21
|
+
if model.startswith("gemini-"):
|
|
22
|
+
return "google"
|
|
23
|
+
if model.startswith("deepseek-"):
|
|
24
|
+
return "deepseek"
|
|
25
|
+
if model.startswith("anthropic."):
|
|
26
|
+
return "bedrock"
|
|
27
|
+
if model.startswith("amazon."):
|
|
28
|
+
return "bedrock"
|
|
29
|
+
return "unknown"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _tracking_disabled() -> bool:
|
|
33
|
+
return os.getenv("SPENDLINE_DISABLE", "").lower() == "true"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _truncate_metadata(metadata):
|
|
37
|
+
if not isinstance(metadata, dict):
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
clean = {}
|
|
41
|
+
for key, value in metadata.items():
|
|
42
|
+
if isinstance(value, str):
|
|
43
|
+
clean[key] = value[:500]
|
|
44
|
+
elif isinstance(value, (int, float, bool)) or value is None:
|
|
45
|
+
clean[key] = value
|
|
46
|
+
else:
|
|
47
|
+
clean[key] = str(value)[:500]
|
|
48
|
+
return clean
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _record_response(
|
|
52
|
+
response,
|
|
53
|
+
*,
|
|
54
|
+
started_at,
|
|
55
|
+
api_key=None,
|
|
56
|
+
workflow_id=None,
|
|
57
|
+
session_id=None,
|
|
58
|
+
metadata=None,
|
|
59
|
+
api_url=None,
|
|
60
|
+
):
|
|
61
|
+
"""Builds and buffers a single event for a completed response."""
|
|
62
|
+
|
|
63
|
+
resolved_api_key = api_key or os.getenv("SPENDLINE_API_KEY")
|
|
64
|
+
if not resolved_api_key:
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
usage = extract_usage(response)
|
|
68
|
+
model = usage["model"]
|
|
69
|
+
tokens_in = int(usage["tokens_in"])
|
|
70
|
+
tokens_out = int(usage["tokens_out"])
|
|
71
|
+
provider = detect_provider(model)
|
|
72
|
+
|
|
73
|
+
if api_url:
|
|
74
|
+
cost_usd, unknown_model = calculate_cost(
|
|
75
|
+
model, tokens_in, tokens_out, api_url=api_url
|
|
76
|
+
)
|
|
77
|
+
else:
|
|
78
|
+
cost_usd, unknown_model = calculate_cost(model, tokens_in, tokens_out)
|
|
79
|
+
latency_ms = int((time.perf_counter() - started_at) * 1000)
|
|
80
|
+
|
|
81
|
+
event = {
|
|
82
|
+
"model": model,
|
|
83
|
+
"provider": provider,
|
|
84
|
+
"tokens_in": tokens_in,
|
|
85
|
+
"tokens_out": tokens_out,
|
|
86
|
+
"latency_ms": latency_ms,
|
|
87
|
+
"cost_usd": cost_usd,
|
|
88
|
+
"unknown_model": unknown_model,
|
|
89
|
+
"workflow_id": workflow_id,
|
|
90
|
+
"session_id": session_id,
|
|
91
|
+
"request_id": str(uuid.uuid4()),
|
|
92
|
+
"metadata": _truncate_metadata(metadata),
|
|
93
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if os.getenv("SPENDLINE_LOG", "").lower() == "true":
|
|
97
|
+
print(event)
|
|
98
|
+
|
|
99
|
+
client = get_client(resolved_api_key, api_url=api_url)
|
|
100
|
+
if client.batch is not None:
|
|
101
|
+
client.batch.add(event)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def track(response_or_fn, api_key=None, workflow_id=None, session_id=None, metadata=None, api_url=None):
|
|
105
|
+
"""Tracks a response or wrapped function and always returns the original response."""
|
|
106
|
+
|
|
107
|
+
if _tracking_disabled():
|
|
108
|
+
return response_or_fn() if callable(response_or_fn) else response_or_fn
|
|
109
|
+
|
|
110
|
+
response = None
|
|
111
|
+
has_response = False
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
started_at = time.perf_counter()
|
|
115
|
+
response = response_or_fn() if callable(response_or_fn) else response_or_fn
|
|
116
|
+
has_response = True
|
|
117
|
+
|
|
118
|
+
_record_response(
|
|
119
|
+
response,
|
|
120
|
+
started_at=started_at,
|
|
121
|
+
api_key=api_key,
|
|
122
|
+
workflow_id=workflow_id,
|
|
123
|
+
session_id=session_id,
|
|
124
|
+
metadata=metadata,
|
|
125
|
+
api_url=api_url,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return response
|
|
129
|
+
except Exception:
|
|
130
|
+
if has_response:
|
|
131
|
+
return response
|
|
132
|
+
raise
|