Skip to content

Commit bf1ecd7

Browse files
authored
Merge remote-tracking branch 'origin/main' into feat/vercel-ai-sdk
# Conflicts: # README.md
2 parents 7444a77 + 51ef6be commit bf1ecd7

14 files changed

Lines changed: 874 additions & 34 deletions

File tree

.github/workflows/ci.yml

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
name: CI
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches: [main]
7+
8+
concurrency:
9+
group: ci-${{ github.workflow }}-${{ github.ref }}
10+
cancel-in-progress: true
11+
12+
permissions:
13+
contents: read
14+
15+
jobs:
16+
lint:
17+
runs-on: ubuntu-latest
18+
timeout-minutes: 10
19+
steps:
20+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
21+
with:
22+
persist-credentials: false
23+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
24+
with:
25+
python-version: '3.13'
26+
- run: python -m pip install --upgrade pip
27+
- run: pip install -e ".[dev]"
28+
- name: Ruff
29+
run: ruff check .
30+
31+
test:
32+
runs-on: ubuntu-latest
33+
timeout-minutes: 10
34+
strategy:
35+
fail-fast: false
36+
matrix:
37+
python-version: ['3.10', '3.11', '3.12', '3.13']
38+
steps:
39+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
40+
with:
41+
persist-credentials: false
42+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
43+
with:
44+
python-version: ${{ matrix.python-version }}
45+
- run: python -m pip install --upgrade pip
46+
# Install the framework extras so the adapter handler tests actually run
47+
# (instead of skipping). langchain-core covers the LangChain handler tests.
48+
- run: pip install -e ".[dev,langchain]"
49+
- name: Pytest
50+
run: pytest -q
51+
52+
test-bare:
53+
# Locks the dependency-free contract: the package and its test suite must
54+
# work with NO framework extra installed (handler tests skip cleanly, no
55+
# import errors). This is what publish.yml runs before releasing.
56+
runs-on: ubuntu-latest
57+
timeout-minutes: 10
58+
steps:
59+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
60+
with:
61+
persist-credentials: false
62+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
63+
with:
64+
python-version: '3.13'
65+
- run: python -m pip install --upgrade pip
66+
- run: pip install -e ".[dev]"
67+
- name: Pytest (no extras)
68+
run: pytest -q

README.md

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# floe-guard
22

3+
[![PyPI version](https://img.shields.io/pypi/v/floe-guard.svg)](https://pypi.org/project/floe-guard/)
4+
[![Downloads](https://static.pepy.tech/badge/floe-guard/month)](https://pepy.tech/project/floe-guard)
5+
[![Python versions](https://img.shields.io/pypi/pyversions/floe-guard.svg)](https://pypi.org/project/floe-guard/)
6+
[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
7+
38
**A local budget guardrail for AI agents.** It hard-stops your agent *before its
49
next LLM call* when it would cross a spend ceiling — so a runaway loop dies at
510
$0.10 instead of $4,000. No account, no signup, no network. Runs in your process.
@@ -26,8 +31,9 @@ BUDGET EXCEEDED — call blocked
2631
The next call would cross your budget; floe-guard stopped your agent before it ran.
2732
```
2833

29-
_Animated demo coming — run `python examples/runaway_loop.py` to watch it stop a loop live._
30-
<!-- TODO: record docs/stop-the-loop.gif and restore the embed: ![stop the loop](docs/stop-the-loop.gif) -->
34+
![floe-guard hard-stopping a runaway loop before it crosses a $0.10 ceiling](docs/stop-the-loop.gif)
35+
36+
_Run it yourself: `python examples/runaway_loop.py` — no API key, no account, no network._
3137

3238
## See it stop a loop (no API key needed)
3339

@@ -108,6 +114,25 @@ response = guarded_completion(guard, model="gpt-4o", messages=[...])
108114
Prefer the LiteLLM-native callback? Register `budget_guard_callback(guard)` on
109115
`litellm.callbacks`.
110116

117+
### LangChain
118+
119+
```bash
120+
pip install floe-guard[langchain] langchain-openai # langchain-openai only for the ChatOpenAI example below
121+
```
122+
123+
```python
124+
from langchain_openai import ChatOpenAI
125+
from floe_guard import BudgetGuard
126+
from floe_guard.integrations.langchain import budget_guard_callback_handler
127+
128+
guard = BudgetGuard(limit_usd=1.00)
129+
llm = ChatOpenAI(model="gpt-4o", callbacks=[budget_guard_callback_handler(guard)])
130+
llm.invoke("hello") # checks budget before the call, records spend after
131+
```
132+
133+
The handler checks the budget on LLM start (raising `BudgetExceeded` aborts the
134+
call before it runs) and records token usage on LLM end.
135+
111136
### Vercel AI SDK
112137

113138
The Vercel AI SDK is TypeScript-only, so it ships as a separate npm package that
@@ -133,10 +158,6 @@ The middleware `check()`s before each call (throwing `BudgetExceeded` to halt th
133158
run) and `record()`s priced usage after — same semantics as the Python guard. See
134159
[`js/README.md`](js/README.md).
135160

136-
### Coming next
137-
138-
LangChain (callback) is next. Open an issue if you want one sooner.
139-
140161
## Honest about what this is
141162

142163
floe-guard is a **local, estimate-based** guardrail. It prices tokens from a
@@ -158,12 +179,45 @@ Floe moves enforcement server-side against a real credit line:
158179
- **Cross-vendor** — one budget over LLM tokens *and* paid (x402) tool calls.
159180
- **Team budgets + analytics** — shared ceilings, per-agent isolation, spend history.
160181

161-
Set `FLOE_API_KEY` and floe-guard exposes a hook to delegate enforcement to
162-
hosted Floe (see [`src/floe_guard/hosted.py`](src/floe_guard/hosted.py) — wiring
163-
the live endpoint is in progress; the local guard is fully functional today).
182+
Set `FLOE_API_KEY` (your agent key, `floe_<hex>`) and floe-guard can read your
183+
agent's **server-side remaining budget** from the live Floe endpoint:
184+
185+
```python
186+
from floe_guard import hosted_enforcement_available, hosted_remaining_usd
187+
188+
if hosted_enforcement_available(): # True when FLOE_API_KEY is set
189+
remaining = hosted_remaining_usd() # USD left, read from Floe's server
190+
```
191+
192+
`hosted_remaining_usd()` GETs `/v1/agents/credit-remaining` and returns the USD
193+
remaining — the minimum of your auto-borrow headroom and your session spend
194+
remaining. It raises `HostedEnforcementError` on a bad/missing key (401), a
195+
closed or suspended agent (403), an unprovisioned agent (404), or a network
196+
failure.
197+
198+
Env vars:
164199

165-
**[dev-dashboard.floelabs.xyz](https://dev-dashboard.floelabs.xyz)** ·
166-
**[floelabs.xyz](https://floelabs.xyz)**
200+
- `FLOE_API_KEY` — your agent key. Required for the read.
201+
- `FLOE_API_BASE_URL` — override the API host (defaults to
202+
`https://credit-api.floelabs.xyz`).
203+
204+
Honest scope: this call only **reads** the remaining budget. The un-bypassable,
205+
cross-vendor *enforcement* is the hosted Floe product running server-side — not
206+
this client. Use the number to inform a local ceiling; the server stays the
207+
source of truth.
208+
209+
**[dev-dashboard.floelabs.xyz](https://dev-dashboard.floelabs.xyz/?utm_source=floe-guard&utm_medium=readme&utm_campaign=oss)** ·
210+
**[floelabs.xyz](https://floelabs.xyz/?utm_source=floe-guard&utm_medium=readme&utm_campaign=oss)**
211+
212+
## Built with floe-guard
213+
214+
Using floe-guard in your project? Add the badge so others find it:
215+
216+
[![guarded by floe-guard](https://img.shields.io/badge/guarded%20by-floe--guard-2f81f7.svg)](https://github.com/Floe-Labs/floe-guard)
217+
218+
```markdown
219+
[![guarded by floe-guard](https://img.shields.io/badge/guarded%20by-floe--guard-2f81f7.svg)](https://github.com/Floe-Labs/floe-guard)
220+
```
167221

168222
## Development
169223

docs/INSTRUMENTATION.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Instrumentation
2+
3+
How we measure floe-guard's adoption — and the one thing we deliberately *don't* do.
4+
5+
## No runtime telemetry (deliberate)
6+
7+
floe-guard does **not** phone home. It sends no usage events, no install pings, no
8+
identifiers — nothing leaves your process at runtime except hosted-budget reads
9+
you explicitly opt into by setting `FLOE_API_KEY` (the *Upgrade to hosted Floe*
10+
path) — never otherwise.
11+
12+
This is a choice, not an oversight. A guardrail's whole value is trust: a library
13+
that silently exfiltrates usage from people's agents is the opposite of a tool you
14+
hand a budget to. Runtime telemetry in OSS dev tools reliably triggers backlash
15+
and forks. So we measure adoption only from **public, outside-in signals** — the
16+
ones below — and accept that they're coarser than telemetry. That trade is correct
17+
for this project.
18+
19+
## The signals we track
20+
21+
| Runbook KPI | How it's measured | Source |
22+
|---|---|---|
23+
| Star velocity | GitHub stargazers over time | `scripts/metrics.py` → GitHub API |
24+
| Fork count | GitHub forks | `scripts/metrics.py` |
25+
| Install velocity (`pip install`) | PyPI download counts (day/week/month) | `scripts/metrics.py` → pypistats.org |
26+
| README reach | repo views & unique clones | `scripts/metrics.py` (needs `GITHUB_TOKEN`) |
27+
| README → site click-through | UTM params on outbound links | Floe web analytics |
28+
| Install → hosted signup | UTM `utm_source=floe-guard` lands in the signup funnel | Floe web analytics |
29+
| "Built with floe-guard" usage | badge backlinks | GitHub code/badge search |
30+
31+
### Snapshot script
32+
33+
```bash
34+
python scripts/metrics.py # stars, forks, PyPI downloads
35+
GITHUB_TOKEN=ghp_xxx python scripts/metrics.py # + repo views/clones
36+
```
37+
38+
It prints real numbers only — if a source is unreachable it says so rather than
39+
guessing. Run it on a schedule and diff snapshots to get *velocity* (the runbook's
40+
actual target), not just totals.
41+
42+
### UTM scheme
43+
44+
Outbound links in the README carry:
45+
46+
```
47+
?utm_source=floe-guard&utm_medium=readme&utm_campaign=oss
48+
```
49+
50+
So clicks from this repo into `dev-dashboard.floelabs.xyz` / `floelabs.xyz` are
51+
attributable in Floe's web analytics, and any session that converts to a hosted
52+
signup keeps `utm_source=floe-guard` — that's the **install → signup** handoff the
53+
runbook calls the critical metric. The conversion side lives in the Floe web app's
54+
funnel, not in this repo.
55+
56+
### "Built with floe-guard" badge
57+
58+
Downstream projects can advertise usage with the badge in the README's *Built with
59+
floe-guard* section. Each embed is a backlink — searchable on GitHub — and is the
60+
honest version of a "powered by" usage signal (opt-in, public, no tracking).
61+
62+
## What's still manual
63+
64+
- Posting the launch content and seeding (the runbook's Week-1/2 GTM motion).
65+
- Wiring the UTM-tagged sessions into a dashboard on the Floe web side.

docs/stop-the-loop.gif

110 KB
Loading

docs/stop-the-loop.gif.PLACEHOLDER.txt

Lines changed: 0 additions & 6 deletions
This file was deleted.

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ readme = "README.md"
1010
requires-python = ">=3.10"
1111
license = { file = "LICENSE" }
1212
authors = [{ name = "Floe Labs" }]
13-
keywords = ["llm", "agents", "budget", "guardrail", "crewai", "litellm", "cost", "openai", "anthropic"]
13+
keywords = ["llm", "agents", "budget", "guardrail", "crewai", "litellm", "langchain", "cost", "openai", "anthropic"]
1414
classifiers = [
1515
"Development Status :: 4 - Beta",
1616
"Intended Audience :: Developers",
@@ -26,6 +26,7 @@ dependencies = []
2626
[project.optional-dependencies]
2727
litellm = ["litellm>=1.0"]
2828
crewai = ["crewai>=0.30", "litellm>=1.0"]
29+
langchain = ["langchain-core>=0.3"]
2930
dev = ["pytest>=7.0", "ruff>=0.4"]
3031

3132
[project.urls]

scripts/metrics.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Pull floe-guard's public adoption metrics into a single snapshot.
2+
3+
No dependencies — stdlib only. Reads public APIs:
4+
- GitHub repo stats (stars, forks, watchers) — no auth needed
5+
- GitHub traffic (views, clones) — needs a token with push access
6+
- PyPI downloads (last day / week / month) — no auth needed (pypistats.org)
7+
8+
Usage:
9+
python scripts/metrics.py
10+
GITHUB_TOKEN=ghp_xxx python scripts/metrics.py # also fetch traffic (views/clones)
11+
12+
These are the runbook KPIs (star velocity, forks, install velocity). Run it on a
13+
schedule and diff snapshots to get velocity. It only reports real numbers — if a
14+
source is unreachable it says so rather than guessing.
15+
"""
16+
from __future__ import annotations
17+
18+
import json
19+
import os
20+
import urllib.error
21+
import urllib.request
22+
23+
REPO = "Floe-Labs/floe-guard"
24+
PACKAGE = "floe-guard"
25+
26+
27+
def _get(
28+
url: str, headers: dict[str, str] | None = None, timeout: float = 15.0
29+
) -> dict[str, object]:
30+
req = urllib.request.Request(url, headers=headers or {})
31+
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 (trusted hosts)
32+
return json.loads(resp.read().decode())
33+
34+
35+
def github_repo() -> dict[str, object]:
36+
try:
37+
d = _get(
38+
f"https://api.github.com/repos/{REPO}",
39+
headers={"Accept": "application/vnd.github+json", "User-Agent": "floe-guard-metrics"},
40+
)
41+
return {
42+
"stars": d.get("stargazers_count"),
43+
"forks": d.get("forks_count"),
44+
"watchers": d.get("subscribers_count"),
45+
"open_issues": d.get("open_issues_count"),
46+
}
47+
except (urllib.error.URLError, ValueError) as e:
48+
return {"error": f"github repo stats unavailable: {e}"}
49+
50+
51+
def github_traffic() -> dict[str, object]:
52+
token = os.environ.get("GITHUB_TOKEN", "").strip()
53+
if not token:
54+
return {"note": "set GITHUB_TOKEN (push access) to fetch views/clones"}
55+
hdr = {
56+
"Authorization": f"Bearer {token}",
57+
"Accept": "application/vnd.github+json",
58+
"User-Agent": "floe-guard-metrics",
59+
}
60+
out: dict[str, object] = {}
61+
for kind in ("views", "clones"):
62+
try:
63+
d = _get(f"https://api.github.com/repos/{REPO}/traffic/{kind}", headers=hdr)
64+
out[kind] = {"count": d.get("count"), "uniques": d.get("uniques")}
65+
except (urllib.error.URLError, ValueError) as e:
66+
out[kind] = {"error": str(e)}
67+
return out
68+
69+
70+
def pypi_downloads() -> dict[str, object]:
71+
try:
72+
d = _get(
73+
f"https://pypistats.org/api/packages/{PACKAGE}/recent",
74+
headers={"User-Agent": "floe-guard-metrics"},
75+
)
76+
return d.get("data", {"error": "no data field"})
77+
except (urllib.error.URLError, ValueError) as e:
78+
return {"error": f"pypistats unavailable (new packages take ~1 day to appear): {e}"}
79+
80+
81+
def main() -> None:
82+
snapshot = {
83+
"repo": REPO,
84+
"package": PACKAGE,
85+
"github": github_repo(),
86+
"traffic": github_traffic(),
87+
"pypi_downloads_recent": pypi_downloads(),
88+
}
89+
print(json.dumps(snapshot, indent=2))
90+
91+
92+
if __name__ == "__main__":
93+
main()

src/floe_guard/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@
1616
from .errors import (
1717
BudgetExceeded,
1818
FloeGuardError,
19+
HostedEnforcementError,
1920
UnpriceableModelError,
2021
UnpriceableModelWarning,
2122
)
2223
from .guard import BudgetGuard
24+
from .hosted import hosted_enforcement_available, hosted_remaining_usd
2325
from .pricing import ManualPrice, PricedModel, price_tokens, resolve_price
2426

2527
__version__ = "0.1.0"
@@ -28,10 +30,13 @@
2830
"BudgetGuard",
2931
"BudgetExceeded",
3032
"FloeGuardError",
33+
"HostedEnforcementError",
3134
"UnpriceableModelError",
3235
"UnpriceableModelWarning",
3336
"ManualPrice",
3437
"PricedModel",
3538
"price_tokens",
3639
"resolve_price",
40+
"hosted_enforcement_available",
41+
"hosted_remaining_usd",
3742
]

0 commit comments

Comments
 (0)