Skip to content

Commit ef700ec

Browse files
authored
Merge pull request #5 from Floe-Labs/feat/hosted-enforcement
feat: wire hosted.py to the live credit-remaining endpoint
2 parents ff4e414 + 5a363b2 commit ef700ec

5 files changed

Lines changed: 346 additions & 19 deletions

File tree

README.md

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,32 @@ Floe moves enforcement server-side against a real credit line:
153153
- **Cross-vendor** — one budget over LLM tokens *and* paid (x402) tool calls.
154154
- **Team budgets + analytics** — shared ceilings, per-agent isolation, spend history.
155155

156-
Set `FLOE_API_KEY` and floe-guard exposes a hook to delegate enforcement to
157-
hosted Floe (see [`src/floe_guard/hosted.py`](src/floe_guard/hosted.py) — wiring
158-
the live endpoint is in progress; the local guard is fully functional today).
156+
Set `FLOE_API_KEY` (your agent key, `floe_<hex>`) and floe-guard can read your
157+
agent's **server-side remaining budget** from the live Floe endpoint:
158+
159+
```python
160+
from floe_guard import hosted_enforcement_available, hosted_remaining_usd
161+
162+
if hosted_enforcement_available(): # True when FLOE_API_KEY is set
163+
remaining = hosted_remaining_usd() # USD left, read from Floe's server
164+
```
165+
166+
`hosted_remaining_usd()` GETs `/v1/agents/credit-remaining` and returns the USD
167+
remaining — the minimum of your auto-borrow headroom and your session spend
168+
remaining. It raises `HostedEnforcementError` on a bad/missing key (401), a
169+
closed or suspended agent (403), an unprovisioned agent (404), or a network
170+
failure.
171+
172+
Env vars:
173+
174+
- `FLOE_API_KEY` — your agent key. Required for the read.
175+
- `FLOE_API_BASE_URL` — override the API host (defaults to
176+
`https://credit-api.floelabs.xyz`).
177+
178+
Honest scope: this call only **reads** the remaining budget. The un-bypassable,
179+
cross-vendor *enforcement* is the hosted Floe product running server-side — not
180+
this client. Use the number to inform a local ceiling; the server stays the
181+
source of truth.
159182

160183
**[dev-dashboard.floelabs.xyz](https://dev-dashboard.floelabs.xyz)** ·
161184
**[floelabs.xyz](https://floelabs.xyz)**

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
]

src/floe_guard/errors.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,16 @@ def __init__(self, model: str) -> None:
4343
)
4444

4545

46+
class HostedEnforcementError(FloeGuardError):
47+
"""Raised when a read against the hosted Floe budget endpoint fails.
48+
49+
Covers a missing API key, a non-200 response (401 bad/missing key, 403 agent
50+
closed/suspended, 404 agent not provisioned), a network/timeout failure, or a
51+
malformed response body. The message states plainly what went wrong — this
52+
client only *reads* server-side remaining budget; it does not enforce.
53+
"""
54+
55+
4656
class UnpriceableModelWarning(UserWarning):
4757
"""Warned (loudly) whenever an unpriceable model is seen.
4858

src/floe_guard/hosted.py

Lines changed: 140 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,166 @@
1-
"""Hosted Floe upgrade hook (stub — not wired to a live endpoint).
1+
"""Hosted Floe upgrade hook — reads server-side remaining budget.
22
33
The local :class:`~floe_guard.BudgetGuard` is *estimate-based*: it prices tokens
44
from a vendored cost map in your own process. A determined agent (or a bug) can
55
run around it, and it only sees the one vendor you instrumented.
66
7-
Hosted Floe is the upgrade: enforcement moves server-side against a real credit
7+
Hosted Floe is the upgrade: enforcement lives server-side against a real credit
88
line, so the ceiling is **un-bypassable** and spans **every vendor** (LLM tokens
99
*and* paid x402 tool calls) under one budget, with team budgets and analytics.
1010
11-
This module is intentionally a no-op stub. When ``FLOE_API_KEY`` is set,
12-
:func:`hosted_enforcement_available` reports it so callers can branch toward the
13-
hosted path. Wiring the actual delegation is tracked below — we do NOT ship a
14-
fabricated endpoint.
11+
This module is the read side of that upgrade. When ``FLOE_API_KEY`` is set,
12+
:func:`hosted_enforcement_available` reports it and :func:`hosted_remaining_usd`
13+
queries the live Floe endpoint for the agent's remaining server-side budget.
14+
15+
Honest framing: this client only **reads** the remaining budget. The actual
16+
un-bypassable, cross-vendor *enforcement* is performed server-side by hosted Floe
17+
— not by this code. Use the returned number to inform a local ceiling; the
18+
server is the source of truth.
1519
1620
Upgrade: https://dev-dashboard.floelabs.xyz · https://floelabs.xyz
1721
"""
1822

1923
from __future__ import annotations
2024

25+
import json
2126
import os
27+
import urllib.error
28+
import urllib.parse
29+
import urllib.request
30+
31+
from .errors import HostedEnforcementError
2232

2333
FLOE_API_KEY_ENV = "FLOE_API_KEY"
34+
FLOE_API_BASE_URL_ENV = "FLOE_API_BASE_URL"
35+
DEFAULT_BASE_URL = "https://credit-api.floelabs.xyz"
36+
CREDIT_REMAINING_PATH = "/v1/agents/credit-remaining"
37+
38+
# USDC has 6 implied decimals; raw integer strings divide by this to get USD.
39+
_USDC_DECIMALS = 1_000_000
2440

2541

2642
def hosted_enforcement_available() -> bool:
2743
"""True if a Floe API key is present in the environment.
2844
29-
Presence of a key is the signal that a caller *could* delegate enforcement to
30-
hosted Floe. It does not itself perform any network call.
45+
Presence of a key is the signal that a caller *could* read the hosted budget.
46+
It does not itself perform any network call.
3147
"""
3248
return bool(os.environ.get(FLOE_API_KEY_ENV, "").strip())
3349

3450

35-
# TODO(hosted-upgrade): when FLOE_API_KEY is set, delegate enforcement to hosted
36-
# Floe instead of (or alongside) the local estimate. The hosted path debits a
37-
# real, server-side credit line — un-bypassable and cross-vendor — so the budget
38-
# holds even if the local process is bypassed. This requires the public hosted
39-
# budget API to be finalized; until then this stays a documented no-op rather
40-
# than a fabricated endpoint. See README "Upgrade to hosted Floe".
51+
def hosted_remaining_usd(
52+
api_key: str | None = None,
53+
*,
54+
base_url: str | None = None,
55+
timeout: float = 10.0,
56+
) -> float:
57+
"""Read the agent's remaining server-side budget from hosted Floe, in USD.
58+
59+
GETs ``/v1/agents/credit-remaining`` with ``Authorization: Bearer <key>`` and
60+
returns the remaining budget as a USD float. "Remaining" is the **minimum** of
61+
``headroomToAutoBorrow`` and (when present) ``sessionSpendRemaining`` — both
62+
are raw USDC strings (6 decimals) divided by 1e6.
63+
64+
This is a *read* only. Enforcement still happens server-side in Floe.
65+
66+
Args:
67+
api_key: agent key (``floe_<hex>``). Defaults to ``FLOE_API_KEY`` env var.
68+
base_url: API base. Defaults to ``FLOE_API_BASE_URL`` env var, else the
69+
production host.
70+
timeout: socket timeout in seconds.
71+
72+
Raises:
73+
HostedEnforcementError: missing key, non-200 (401/403/404 surfaced with
74+
the server's ``error`` field), network/timeout, or malformed JSON.
75+
"""
76+
key = (api_key or os.environ.get(FLOE_API_KEY_ENV, "")).strip()
77+
if not key:
78+
raise HostedEnforcementError(
79+
f"No Floe API key: pass api_key= or set {FLOE_API_KEY_ENV}."
80+
)
81+
82+
env_base = os.environ.get(FLOE_API_BASE_URL_ENV, "").strip()
83+
base = ((base_url or "").strip() or env_base or DEFAULT_BASE_URL).rstrip("/")
84+
parsed = urllib.parse.urlparse(base)
85+
if parsed.scheme != "https" or not parsed.netloc:
86+
# The request carries the Floe agent key as a bearer token — never send it
87+
# over a non-https or malformed URL where it could leak to an arbitrary host.
88+
raise HostedEnforcementError(
89+
f"Refusing to send the Floe API key to {base!r}: "
90+
"the base URL must be an https:// URL with a host."
91+
)
92+
url = f"{base}{CREDIT_REMAINING_PATH}"
93+
94+
request = urllib.request.Request(
95+
url,
96+
method="GET",
97+
headers={"Authorization": f"Bearer {key}", "Accept": "application/json"},
98+
)
99+
100+
try:
101+
with urllib.request.urlopen(request, timeout=timeout) as response:
102+
body = response.read()
103+
except urllib.error.HTTPError as exc:
104+
raise HostedEnforcementError(_describe_http_error(exc)) from exc
105+
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as exc:
106+
raise HostedEnforcementError(
107+
f"Could not reach hosted Floe at {url}: {exc}"
108+
) from exc
109+
110+
try:
111+
payload = json.loads(body)
112+
except (ValueError, TypeError) as exc:
113+
raise HostedEnforcementError(
114+
f"Malformed JSON from hosted Floe at {url}: {exc}"
115+
) from exc
116+
117+
return _remaining_usd_from_payload(payload, url)
118+
119+
120+
def _remaining_usd_from_payload(payload: object, url: str) -> float:
121+
if not isinstance(payload, dict):
122+
raise HostedEnforcementError(
123+
f"Unexpected response shape from hosted Floe at {url}: expected an object."
124+
)
125+
126+
headroom = _usd_from_raw(payload.get("headroomToAutoBorrow"), "headroomToAutoBorrow", url)
127+
128+
session_raw = payload.get("sessionSpendRemaining")
129+
if session_raw is None:
130+
return headroom
131+
132+
session = _usd_from_raw(session_raw, "sessionSpendRemaining", url)
133+
return min(headroom, session)
134+
135+
136+
def _usd_from_raw(value: object, field: str, url: str) -> float:
137+
try:
138+
return int(str(value)) / _USDC_DECIMALS
139+
except (ValueError, TypeError) as exc:
140+
raise HostedEnforcementError(
141+
f"Invalid {field!r} ({value!r}) from hosted Floe at {url}: {exc}"
142+
) from exc
143+
144+
145+
def _describe_http_error(exc: urllib.error.HTTPError) -> str:
146+
server_error = _server_error_field(exc)
147+
detail = f" ({server_error})" if server_error else ""
148+
if exc.code == 401:
149+
return f"Hosted Floe rejected the API key (401 unauthorized){detail}."
150+
if exc.code == 403:
151+
return f"Hosted Floe agent is closed or suspended (403 forbidden){detail}."
152+
if exc.code == 404:
153+
return f"Hosted Floe agent has no credit limit / not provisioned (404){detail}."
154+
return f"Hosted Floe returned HTTP {exc.code}{detail}."
155+
156+
157+
def _server_error_field(exc: urllib.error.HTTPError) -> str | None:
158+
try:
159+
data = json.loads(exc.read())
160+
except Exception:
161+
return None
162+
if isinstance(data, dict):
163+
value = data.get("error")
164+
if isinstance(value, str):
165+
return value
166+
return None

0 commit comments

Comments
 (0)