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.
@@ -0,0 +1,6 @@
1
+ .env
2
+ config.js
3
+ venv/
4
+ __pycache__/
5
+ *.pyc
6
+ runs.json
@@ -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,7 @@
1
+ """Farol — agent observability for builders."""
2
+
3
+ from farol.sdk import trace
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = ["trace", "__version__"]
@@ -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"]