farol-sdk 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.
- farol_sdk-0.1.0/.gitignore +6 -0
- farol_sdk-0.1.0/PKG-INFO +70 -0
- farol_sdk-0.1.0/README.md +43 -0
- farol_sdk-0.1.0/farol/__init__.py +7 -0
- farol_sdk-0.1.0/farol/sdk.py +265 -0
- farol_sdk-0.1.0/pyproject.toml +39 -0
farol_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: farol-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Agent observability for builders
|
|
5
|
+
Project-URL: Homepage, https://usefarol.dev
|
|
6
|
+
Author: Farol
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: agents,llm,observability,supabase,tracing
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Provides-Extra: all
|
|
20
|
+
Requires-Dist: resend>=2.0.0; extra == 'all'
|
|
21
|
+
Requires-Dist: supabase>=2.0.0; extra == 'all'
|
|
22
|
+
Provides-Extra: resend
|
|
23
|
+
Requires-Dist: resend>=2.0.0; extra == 'resend'
|
|
24
|
+
Provides-Extra: supabase
|
|
25
|
+
Requires-Dist: supabase>=2.0.0; extra == 'supabase'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# farol-sdk
|
|
29
|
+
|
|
30
|
+
Agent observability for builders. Wrap a function with `@trace`, pass token counts on the `run` dict, and Farol syncs runs to your dashboard—cost anomalies, alerts, the lot.
|
|
31
|
+
|
|
32
|
+
**[usefarol.dev](https://usefarol.dev)**
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install farol-sdk
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Sync to Supabase and email alerts need extras (zero required deps otherwise):
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install 'farol-sdk[supabase,resend]'
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Set environment variables: `SUPABASE_URL`, `SUPABASE_KEY`, optionally `SUPABASE_SERVICE_KEY` for writes + API key resolution; `RESEND_API_KEY` and `ALERT_EMAIL` for anomaly emails.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from farol import trace
|
|
56
|
+
|
|
57
|
+
@trace("research-agent", farol_key="frl_your_key_here", model="claude-3-5-haiku-latest")
|
|
58
|
+
def my_agent(task: str, *, run):
|
|
59
|
+
run["input_tokens"] = 100
|
|
60
|
+
run["output_tokens"] = 50
|
|
61
|
+
return "done"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The wrapped function receives `run` with `steps`, token fields, and timing; Farol computes cost from `cost_per_1k_tokens` (default suits many providers—override as needed).
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
MIT
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# farol-sdk
|
|
2
|
+
|
|
3
|
+
Agent observability for builders. Wrap a function with `@trace`, pass token counts on the `run` dict, and Farol syncs runs to your dashboard—cost anomalies, alerts, the lot.
|
|
4
|
+
|
|
5
|
+
**[usefarol.dev](https://usefarol.dev)**
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install farol-sdk
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Sync to Supabase and email alerts need extras (zero required deps otherwise):
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install 'farol-sdk[supabase,resend]'
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Set environment variables: `SUPABASE_URL`, `SUPABASE_KEY`, optionally `SUPABASE_SERVICE_KEY` for writes + API key resolution; `RESEND_API_KEY` and `ALERT_EMAIL` for anomaly emails.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from farol import trace
|
|
29
|
+
|
|
30
|
+
@trace("research-agent", farol_key="frl_your_key_here", model="claude-3-5-haiku-latest")
|
|
31
|
+
def my_agent(task: str, *, run):
|
|
32
|
+
run["input_tokens"] = 100
|
|
33
|
+
run["output_tokens"] = 50
|
|
34
|
+
return "done"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The wrapped function receives `run` with `steps`, token fields, and timing; Farol computes cost from `cost_per_1k_tokens` (default suits many providers—override as needed).
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## License
|
|
42
|
+
|
|
43
|
+
MIT
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Farol SDK — trace decorator and run sync.
|
|
3
|
+
|
|
4
|
+
Optional integrations (install extras: pip install 'farol-sdk[supabase]' etc.):
|
|
5
|
+
- supabase: sync runs, baseline lookup for anomaly detection
|
|
6
|
+
- resend: email alerts on cost anomalies
|
|
7
|
+
|
|
8
|
+
Environment variables (when using integrations):
|
|
9
|
+
SUPABASE_URL, SUPABASE_KEY — read baseline / optional anon client
|
|
10
|
+
SUPABASE_SERVICE_KEY — if set, used for api_keys lookup and runs insert (recommended)
|
|
11
|
+
RESEND_API_KEY, ALERT_EMAIL — anomaly emails
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import statistics
|
|
18
|
+
import time
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from typing import Any, Callable, Optional, TypeVar
|
|
21
|
+
|
|
22
|
+
COST_ALERT_THRESHOLD = 0.0001
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
from supabase import create_client as _create_client
|
|
26
|
+
except ImportError: # pragma: no cover
|
|
27
|
+
_create_client = None # type: ignore[misc, assignment]
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
import resend as _resend
|
|
31
|
+
except ImportError: # pragma: no cover
|
|
32
|
+
_resend = None
|
|
33
|
+
|
|
34
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
35
|
+
|
|
36
|
+
_supabase_read = None
|
|
37
|
+
_supabase_admin = None
|
|
38
|
+
_clients_initialized = False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _init_supabase_clients() -> None:
|
|
42
|
+
global _supabase_read, _supabase_admin, _clients_initialized
|
|
43
|
+
if _clients_initialized:
|
|
44
|
+
return
|
|
45
|
+
_clients_initialized = True
|
|
46
|
+
if _create_client is None:
|
|
47
|
+
return
|
|
48
|
+
url = os.environ.get("SUPABASE_URL")
|
|
49
|
+
key = os.environ.get("SUPABASE_KEY")
|
|
50
|
+
service = os.environ.get("SUPABASE_SERVICE_KEY")
|
|
51
|
+
if not url or not key:
|
|
52
|
+
return
|
|
53
|
+
try:
|
|
54
|
+
_supabase_read = _create_client(url, key)
|
|
55
|
+
_supabase_admin = _create_client(url, service) if service else _supabase_read
|
|
56
|
+
except Exception: # pragma: no cover
|
|
57
|
+
_supabase_read = None
|
|
58
|
+
_supabase_admin = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _supabase_ready() -> bool:
|
|
62
|
+
_init_supabase_clients()
|
|
63
|
+
return _supabase_read is not None and _supabase_admin is not None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _finalize_run_logging(run: dict) -> None:
|
|
67
|
+
print(
|
|
68
|
+
f"[Farol] Run recorded — {run['duration_ms']}ms | ${run['cost_usd']} | "
|
|
69
|
+
f"status: {run['status']}"
|
|
70
|
+
)
|
|
71
|
+
if run["cost_usd"] > COST_ALERT_THRESHOLD:
|
|
72
|
+
print(
|
|
73
|
+
f"[Farol] COST ALERT — {run['agent']} exceeded ${COST_ALERT_THRESHOLD} "
|
|
74
|
+
f"threshold (actual: ${run['cost_usd']})"
|
|
75
|
+
)
|
|
76
|
+
if run["anomaly"]:
|
|
77
|
+
print(f"[Farol] COST ANOMALY DETECTED — {run['anomaly_reason']}")
|
|
78
|
+
_send_anomaly_email(run)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _send_anomaly_email(run: dict) -> None:
|
|
82
|
+
if _resend is None:
|
|
83
|
+
return
|
|
84
|
+
api_key = os.environ.get("RESEND_API_KEY")
|
|
85
|
+
alert_to = os.environ.get("ALERT_EMAIL")
|
|
86
|
+
if not api_key or not alert_to:
|
|
87
|
+
return
|
|
88
|
+
_resend.api_key = api_key
|
|
89
|
+
email_payload = {
|
|
90
|
+
"to": [alert_to],
|
|
91
|
+
"subject": f"[Farol] Cost anomaly detected — {run['agent']}",
|
|
92
|
+
"text": (
|
|
93
|
+
"A cost anomaly was detected by Farol.\n\n"
|
|
94
|
+
f"Agent: {run['agent']}\n"
|
|
95
|
+
f"Reason: {run.get('anomaly_reason')}\n"
|
|
96
|
+
f"Run ID: {run['id']}\n"
|
|
97
|
+
f"Timestamp: {run['timestamp']}\n"
|
|
98
|
+
),
|
|
99
|
+
}
|
|
100
|
+
try:
|
|
101
|
+
_resend.Emails.send({**email_payload, "from": "Farol <alerts@usefarol.dev>"})
|
|
102
|
+
except Exception:
|
|
103
|
+
try:
|
|
104
|
+
_resend.Emails.send({**email_payload, "from": "Farol <onboarding@resend.dev>"})
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def save_run(run: dict) -> None:
|
|
110
|
+
anomaly = False
|
|
111
|
+
anomaly_reason: Optional[str] = None
|
|
112
|
+
|
|
113
|
+
if _supabase_ready() and _supabase_read is not None:
|
|
114
|
+
try:
|
|
115
|
+
baseline_response = (
|
|
116
|
+
_supabase_read.table("runs")
|
|
117
|
+
.select("cost_usd")
|
|
118
|
+
.eq("agent", run["agent"])
|
|
119
|
+
.eq("status", "success")
|
|
120
|
+
.order("timestamp", desc=True)
|
|
121
|
+
.limit(20)
|
|
122
|
+
.execute()
|
|
123
|
+
)
|
|
124
|
+
baseline_rows = baseline_response.data or []
|
|
125
|
+
except Exception as e:
|
|
126
|
+
print(f"[Farol] Baseline lookup failed: {e}")
|
|
127
|
+
baseline_rows = []
|
|
128
|
+
else:
|
|
129
|
+
baseline_rows = []
|
|
130
|
+
|
|
131
|
+
baseline_costs = []
|
|
132
|
+
for row in baseline_rows:
|
|
133
|
+
try:
|
|
134
|
+
baseline_costs.append(float(row.get("cost_usd")))
|
|
135
|
+
except (TypeError, ValueError, AttributeError):
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
if len(baseline_costs) < 5:
|
|
139
|
+
anomaly = False
|
|
140
|
+
anomaly_reason = "Building baseline — need 5+ runs"
|
|
141
|
+
else:
|
|
142
|
+
median_cost = statistics.median(baseline_costs)
|
|
143
|
+
mean_cost = statistics.mean(baseline_costs)
|
|
144
|
+
std_dev_cost = statistics.stdev(baseline_costs)
|
|
145
|
+
current_cost = float(run["cost_usd"])
|
|
146
|
+
|
|
147
|
+
is_over_3x_median = current_cost > (3 * median_cost)
|
|
148
|
+
is_over_2x_and_2std = current_cost > (2 * median_cost) and current_cost > (
|
|
149
|
+
mean_cost + (2 * std_dev_cost)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
if is_over_3x_median or is_over_2x_and_2std:
|
|
153
|
+
anomaly = True
|
|
154
|
+
ratio = (current_cost / median_cost) if median_cost > 0 else float("inf")
|
|
155
|
+
ratio_text = "∞" if ratio == float("inf") else f"{ratio:.1f}"
|
|
156
|
+
anomaly_reason = (
|
|
157
|
+
f"Cost {ratio_text}× above median baseline "
|
|
158
|
+
f"(median: ${median_cost:.6f}, actual: ${current_cost:.6f})"
|
|
159
|
+
)
|
|
160
|
+
else:
|
|
161
|
+
anomaly = False
|
|
162
|
+
anomaly_reason = None
|
|
163
|
+
|
|
164
|
+
run["anomaly"] = anomaly
|
|
165
|
+
run["anomaly_reason"] = anomaly_reason
|
|
166
|
+
|
|
167
|
+
if not _supabase_ready() or _supabase_admin is None:
|
|
168
|
+
_finalize_run_logging(run)
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
user_id = None
|
|
173
|
+
if run.get("farol_key"):
|
|
174
|
+
try:
|
|
175
|
+
key_res = (
|
|
176
|
+
_supabase_admin.table("api_keys")
|
|
177
|
+
.select("user_id")
|
|
178
|
+
.eq("api_key", run["farol_key"])
|
|
179
|
+
.limit(1)
|
|
180
|
+
.execute()
|
|
181
|
+
)
|
|
182
|
+
key_rows = key_res.data or []
|
|
183
|
+
if key_rows and key_rows[0].get("user_id"):
|
|
184
|
+
user_id = key_rows[0].get("user_id")
|
|
185
|
+
else:
|
|
186
|
+
print("[Farol] Invalid API key — run not synced")
|
|
187
|
+
return
|
|
188
|
+
except Exception:
|
|
189
|
+
print("[Farol] Invalid API key — run not synced")
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
payload = {
|
|
193
|
+
"id": run["id"],
|
|
194
|
+
"agent": run["agent"],
|
|
195
|
+
"model": run["model"],
|
|
196
|
+
"topic": run.get("topic"),
|
|
197
|
+
"status": run["status"],
|
|
198
|
+
"duration_ms": run["duration_ms"],
|
|
199
|
+
"input_tokens": run["input_tokens"],
|
|
200
|
+
"output_tokens": run["output_tokens"],
|
|
201
|
+
"cost_usd": float(run["cost_usd"]),
|
|
202
|
+
"anomaly": run.get("anomaly", False),
|
|
203
|
+
"anomaly_reason": run.get("anomaly_reason", None),
|
|
204
|
+
"steps": run["steps"],
|
|
205
|
+
"error": run.get("error"),
|
|
206
|
+
"timestamp": run["timestamp"],
|
|
207
|
+
}
|
|
208
|
+
if user_id is not None:
|
|
209
|
+
payload["user_id"] = user_id
|
|
210
|
+
|
|
211
|
+
_supabase_admin.table("runs").insert(payload).execute()
|
|
212
|
+
print("[Farol] Synced to Supabase")
|
|
213
|
+
except Exception as e:
|
|
214
|
+
print(f"[Farol] Supabase sync failed: {e}")
|
|
215
|
+
|
|
216
|
+
_finalize_run_logging(run)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def trace(
|
|
220
|
+
agent_name: str,
|
|
221
|
+
farol_key: Optional[str] = None,
|
|
222
|
+
model: str = "claude-haiku-4-5-20251001",
|
|
223
|
+
cost_per_1k_tokens: float = 0.00025,
|
|
224
|
+
) -> Callable[[F], F]:
|
|
225
|
+
def decorator(func: F) -> F:
|
|
226
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
227
|
+
run: dict[str, Any] = {
|
|
228
|
+
"id": f"run_{int(time.time())}",
|
|
229
|
+
"agent": agent_name,
|
|
230
|
+
"model": model,
|
|
231
|
+
"timestamp": datetime.utcnow().isoformat(),
|
|
232
|
+
"status": "running",
|
|
233
|
+
"steps": [],
|
|
234
|
+
"input_tokens": 0,
|
|
235
|
+
"output_tokens": 0,
|
|
236
|
+
"cost_usd": 0.0,
|
|
237
|
+
"duration_ms": 0,
|
|
238
|
+
"error": None,
|
|
239
|
+
"anomaly": False,
|
|
240
|
+
"anomaly_reason": None,
|
|
241
|
+
}
|
|
242
|
+
if farol_key:
|
|
243
|
+
run["farol_key"] = farol_key
|
|
244
|
+
|
|
245
|
+
start = time.time()
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
result = func(*args, run=run, **kwargs)
|
|
249
|
+
run["status"] = "success"
|
|
250
|
+
return result
|
|
251
|
+
except Exception as e:
|
|
252
|
+
run["status"] = "error"
|
|
253
|
+
run["error"] = str(e)
|
|
254
|
+
raise
|
|
255
|
+
finally:
|
|
256
|
+
run["duration_ms"] = round((time.time() - start) * 1000)
|
|
257
|
+
run["cost_usd"] = round(
|
|
258
|
+
(run["input_tokens"] + run["output_tokens"]) / 1000 * cost_per_1k_tokens,
|
|
259
|
+
6,
|
|
260
|
+
)
|
|
261
|
+
save_run(run)
|
|
262
|
+
|
|
263
|
+
return wrapper # type: ignore[return-value]
|
|
264
|
+
|
|
265
|
+
return decorator
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.18.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "farol-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Agent observability for builders"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Farol" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["observability", "agents", "llm", "tracing", "supabase"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
dependencies = []
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
supabase = ["supabase>=2.0.0"]
|
|
32
|
+
resend = ["resend>=2.0.0"]
|
|
33
|
+
all = ["supabase>=2.0.0", "resend>=2.0.0"]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://usefarol.dev"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["farol"]
|