Skip to content

feat(plugins): add livekit-plugins-floe (metered inference + budget guard) - #6890

Open
achris7 wants to merge 11 commits into
livekit:mainfrom
achris7:feat/livekit-plugins-floe
Open

feat(plugins): add livekit-plugins-floe (metered inference + budget guard)#6890
achris7 wants to merge 11 commits into
livekit:mainfrom
achris7:feat/livekit-plugins-floe

Conversation

@achris7

@achris7 achris7 commented Aug 18, 2026

Copy link
Copy Markdown

What

Adds livekit-plugins-floe β€” a plugin that routes an agent's LLM through Floe so spend is metered and can be guarded against a budget, in two modes:

  • Keyless gateway β€” Floe holds the upstream provider keys and bills your Floe balance (FLOE_API_KEY only).
  • BYOK β€” bring your own upstream provider key, forwarded via X-Floe-Provider-Key; Floe meters spend against your budget (FLOE_PROVIDER_KEY).

floe.LLM subclasses the openai plugin's LLM (Floe's endpoint is OpenAI-compatible), so it drops into an AgentSession unchanged:

from livekit.plugins import floe
session = AgentSession(llm=floe.LLM(model="openai/gpt-4o"), stt=..., tts=...)

Usage reconciliation

The plugin also ships FloeUsageReconciler, which subscribes to session_usage_updated and reconciles LiveKit's per-model token accounting against Floe's cost map β€” LiveKit metrics on one side, Floe pricing on the other, per served model. A divergence between the local estimate and Floe's billed amount is the signal worth acting on. The README documents an OpenTelemetry export path for the same numbers (cost observability, not enforcement β€” the budget guard stays in floe-guard).

Scope / notes

  • STT/TTS are intentionally out of scope for now β€” this is LLM-only.
  • Depends on floe-guard (PyPI) for the cost map + pricing. It ships type hints but no py.typed marker yet, so I added a [[tool.mypy.overrides]] for floe_guard.* mirroring the existing boto3/mcp overrides. Happy to switch to requiring a py.typed-shipping floe-guard release instead if you'd prefer no root-config change.
  • Follows the livekit-plugins-groq layout (PEP 420 namespace, hatchling, Plugin.register_plugin); auto-included by the livekit-plugins/* uv workspace glob.

Testing

  • ruff check + ruff format --check: clean.
  • mypy --strict (root config): Success: no issues found.
  • pytest: exports + plugin registration pass; reconciler pricing verified against a synthetic AgentSessionUsage (gpt-4o 1000in/500out β†’ $0.0075; unpriceable models fail closed).

Draft while I complete the CLA. Feedback on the floe-guard typing approach and on whether a metering/guard plugin fits the plugin taxonomy is very welcome.

…uard)

livekit-plugins-floe routes an agent's LLM through Floe so spend is metered
and can be guarded against a budget, in two modes:

- Keyless gateway β€” Floe holds the upstream provider keys and bills your Floe
  balance (FLOE_API_KEY only).
- BYOK β€” bring your own upstream provider key, forwarded via X-Floe-Provider-Key;
  Floe meters spend against your budget (FLOE_PROVIDER_KEY).

LLM subclasses the openai plugin's LLM (OpenAI-compatible endpoint), so it slots
into an AgentSession unchanged. Also ships FloeUsageReconciler, which reconciles
LiveKit's session_usage_updated token accounting against Floe's cost map per
served model β€” LiveKit metrics on one side, Floe pricing on the other; a
divergence is the signal worth acting on. A README section documents an
OpenTelemetry export path for the same numbers (cost observability, not
enforcement).

STT/TTS are intentionally out of scope for now.

Adds a mypy override for floe-guard (which ships type hints but no py.typed
marker yet), mirroring the existing boto3/mcp overrides.
@achris7

achris7 commented Aug 18, 2026

Copy link
Copy Markdown
Author

@CodeRabbit review

@achris7
achris7 marked this pull request as ready for review August 18, 2026 14:47
@achris7
achris7 requested a review from a team as a code owner August 18, 2026 14:47
Copilot AI lite review requested due to automatic review settings August 18, 2026 14:48
devin-ai-integration[bot]

This comment was marked as resolved.

This comment was marked as resolved.

… report labels

- services.py: inject X-Floe-Provider-Key via the parent LLM's extra_headers
  instead of a hand-built openai.AsyncClient. The parent now owns the client
  (no leaked connection on close) and applies timeout/max_retries in BYOK mode.
  Document that a base_url override targets your own Floe (incl. self-hosted on
  a custom domain), where the provider key is sent by design.
- test_floe.py: add the required module-level category marker
  (pytestmark = pytest.mark.unit) so the module is collected, not rejected.
- metering.py: derive report-only provider/model by splitting the served
  "provider/model" id; pricing still uses the full id.
@achris7

achris7 commented Aug 18, 2026

Copy link
Copy Markdown
Author

Thanks for the reviews β€” addressed in the latest push:

  • BYOK client lifecycle + timeout/retries (Devin, Copilot): dropped the hand-built openai.AsyncClient and now inject X-Floe-Provider-Key via the parent LLM's extra_headers. The parent owns the client (so it's closed on aclose, no leaked connection) and applies timeout/max_retries in BYOK mode too. Good catch.

  • Missing test category marker (Copilot, Devin): added pytestmark = pytest.mark.unit so the module is collected under the project's test rules.

  • Confusing provider/model in the reconciliation report (Copilot): the report now splits the served provider/model id for display; pricing still resolves against the full id.

  • Provider key forwarded to a user-supplied base_url (Devin): I left this as documented behavior rather than adding a host allowlist. Overriding base_url is the supported way to point the plugin at your own Floe instance β€” including a self-hosted deployment on a custom domain β€” so the X-Floe-Provider-Key is meant to travel there. A hostname allowlist would break self-hosting, and this matches how every OpenAI-compatible client sends its key to the configured base_url. I added a docstring note making that explicit; happy to revisit if you'd prefer a scheme (https-only) guard.

Also open to your read on the bigger question: does a metering/budget-guard-oriented plugin fit the plugin taxonomy here, or would you rather see this as an OpenTelemetry integration (there's a fallback exporter documented in the README)?

@CLAassistant

CLAassistant commented Aug 18, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

devin-ai-integration[bot]

This comment was marked as resolved.

…K https guard

- Register livekit-plugins-floe in the root [tool.uv.sources] and in
  livekit-agents' [project.optional-dependencies] (floe extra), and set the
  plugin version to 1.6.10 to match livekit-agents and the other plugins.
  Refresh uv.lock (floe-only, uv lock --check consistent).
- services.py: in BYOK mode, require an https base_url (loopback http allowed
  for local dev) before attaching X-Floe-Provider-Key, so the provider secret
  is never sent over a non-TLS connection. Keyless mode is unaffected. No host
  allowlist, so self-hosted Floe on any https domain still works.
- Add tests for the guard (reject plaintext, allow https/loopback, keyless
  unaffected).
@achris7

achris7 commented Aug 18, 2026

Copy link
Copy Markdown
Author

Both addressed in the latest push:

  • Plugin wiring / installability (Devin): registered livekit-plugins-floe in the root [tool.uv.sources] and in livekit-agents's [project.optional-dependencies] (floe extra), and set the plugin version to 1.6.10 to match livekit-agents and the other plugins (lockstep). uv.lock refreshed; uv lock --check is consistent, diff is floe-only. Small note: the type-check task wasn't actually failing beforehand β€” the livekit-plugins/* workspace-members glob already synced the package (CI type-check was green) β€” but the source/extras registration is the right convention regardless, so it's in.

  • Provider secret over cleartext (Devin): added a fail-closed guard β€” in BYOK mode the resolved base_url must be https before X-Floe-Provider-Key is attached (loopback http allowed for local dev), else it raises. So the key is never sent over a non-TLS connection. I kept this scheme-based rather than a host allowlist so self-hosted Floe on any https domain still works; keyless mode is unaffected. Added tests for the reject/allow/keyless-unaffected paths.

devin-ai-integration[bot]

This comment was marked as resolved.

…supplied client

The https guard only checked the resolved base_url, but the parent LLM ignores
base_url when a caller passes their own client β€” while the X-Floe-Provider-Key
header is applied to every request regardless. A custom client on a plaintext
endpoint would therefore leak the provider key over http, contradicting the
docstring. Validate the effective address the key will reach (client.base_url
when a client is supplied, else the resolved base_url). Add tests for the
custom-client reject/allow paths, and correct the client-arg docstring.
@achris7

achris7 commented Aug 18, 2026

Copy link
Copy Markdown
Author

Good catch β€” you're right, and it's fixed (not just the resolved base_url now). The parent ignores base_url when a caller passes their own client, but extra_headers rides every request, so a custom http:// client + FLOE_PROVIDER_KEY could still leak the key. The guard now validates the effective endpoint β€” client.base_url when a client is supplied, else the resolved base_url β€” so BYOK fails closed in both paths. Also corrected the client docstring (it wrongly implied the header isn't applied to a caller's client). Added tests for the custom-client reject (http) and allow (https) cases.

devin-ai-integration[bot]

This comment was marked as resolved.

…-routed usage

- services.py: apply the TLS guard unconditionally (keyless + BYOK). The Floe
  API key is a bearer credential sent on every request, so keyless mode must
  refuse a non-TLS effective endpoint just like BYOK. Renamed the helper to
  _require_secure_url.
- Tag Floe traffic: override floe.LLM.provider to return "floe" (works for
  self-hosted Floe on any domain; request formatting uses _provider_fmt, so it's
  unaffected). The reconciler now counts only provider="floe" usage, so a
  session that mixes Floe with other providers or realtime no longer inflates
  the Floe-estimated cost.
- Tests for keyless plaintext rejection and Floe-only reconciliation.
@achris7

achris7 commented Aug 18, 2026

Copy link
Copy Markdown
Author

Both valid β€” fixed:

  • Floe key over cleartext in keyless mode: the TLS guard now runs unconditionally (keyless and BYOK), validating the effective endpoint (a caller-supplied client's address included). The Floe API key is a bearer credential on every request, so keyless refuses a non-TLS target the same way BYOK does. Helper renamed _require_secure_url.
  • Inflated cost from non-Floe usage: floe.LLM now tags its usage provider="floe" (a stable tag that works for self-hosted Floe on any domain β€” the parent's request formatting uses _provider_fmt, so this only affects the usage label), and the reconciler counts only provider="floe" entries. A session that mixes Floe with other providers or realtime no longer inflates the Floe-estimated total, keeping it comparable to Floe's bill.

Tests added for keyless plaintext rejection and Floe-only reconciliation (9 total).

devin-ai-integration[bot]

This comment was marked as resolved.

@achris7

achris7 commented Aug 18, 2026

Copy link
Copy Markdown
Author

Deferring this rather than patching, for a few reasons:

  • The parent LLM builds its httpx client internally with follow_redirects=True and exposes no follow_redirects/http_client passthrough (only timeout). Disabling redirects would require supplying a fully caller-owned client β€” which reintroduces the connection-lifecycle and dropped timeout/max_retries issues flagged earlier in this same review. So there's no per-plugin fix that doesn't undo those.
  • It's not floe-specific: every OpenAI-compatible plugin here shares this client behavior and sends Authorization, so redirect-downgrade is a base-plugin concern rather than something to solve once per plugin.
  • httpx strips Authorization on cross-origin redirects (a scheme downgrade is cross-origin) by default, so the Floe bearer key isn't replayed over http. The residual is the custom X-Floe-Provider-Key header on a same-host httpsβ†’http downgrade, which requires pointing base_url at a hostile server.

Better addressed upstream in the base openai plugin (a follow_redirects / http_client knob) than per-plugin β€” happy to raise that separately if it'd be useful.

enable_cost_receipts(session) logs a one-line FloeCost per Floe-routed turn:
cost priced locally by floe-guard (free, est), remaining budget from hosted
Floe when a key is present. Per-turn delta off session_usage_updated, filtered
to provider="floe". Bumps the floe-guard dep to >=0.19 (FloeCost/turn_cost).

Follow-up to the base plugin PR; needs floe-guard 0.19 on PyPI.
devin-ai-integration[bot]

This comment was marked as resolved.

The per-turn cost receipt called hosted_remaining_usd() β€” a blocking ~10s urllib
request β€” directly inside the sync session_usage_updated handler, stalling the
agent's audio/speech loop up to 10s per turn. AgentSession.emit does not await
async callbacks, so the handler must stay sync.

Move the budget read off-loop via asyncio.to_thread, throttled to once per 30s
(TTL); each receipt uses the last cached value (one-refresh lag). A failed read
now drops the budget to None (matches the docstring) instead of showing a stale
balance. No running loop -> budget skipped, cost still shown. Cost pricing,
provider=="floe" filter, per-turn delta, and fail-closed behavior unchanged.

Adds two async tests: the read is offloaded (not called on the handler thread)
and cached into a later receipt; and a failed read drops the budget.
@achris7

achris7 commented Aug 18, 2026

Copy link
Copy Markdown
Author

Both fixed β€” thanks, the blocking one was a real bug:

  • Event-loop stall (πŸ”΄): the ~10s hosted_remaining_usd() urllib call no longer runs on the handler thread. Since AgentSession.emit doesn't schedule async callbacks, the sync handler now fires the budget read as a background task (asyncio.to_thread, throttled to once per 30s, task ref stored), so audio/speech never stall. Each receipt uses the last cached value (a one-turn lag), and the very first turn simply shows no budget until the first refresh completes.
  • Stale budget on failure (🟑): a failed read now sets remaining_usd = None (drops the budget, matching the docstring) instead of keeping the previous figure. Failures are throttled by the same TTL so a down endpoint isn't retried every turn.

Tests: an off-load assertion (the read is not called synchronously; runs once off-loop; cached value applies next turn) and a drop-on-failure test. 12 pass, mypy --strict clean.

devin-ai-integration[bot]

This comment was marked as resolved.

@achris7

achris7 commented Aug 18, 2026

Copy link
Copy Markdown
Author

Fixed β€” good edge case. fetched_at now starts at float("-inf") instead of 0.0, so the first budget lookup always fires regardless of the monotonic clock's (boot-relative) origin; no more missing left $… for the first ~30s on a freshly booted worker. Mirrored the same fix in the pipecat twin.

devin-ai-integration[bot]

This comment was marked as resolved.

enable_cost_receipts read the hosted budget via hosted_remaining_usd() with no
key, which uses the FLOE_API_KEY env var β€” not the key a caller passed to
floe.LLM(api_key=...) in code. A user with an in-code key saw no balance (or, if
the env held a different key, another account's).

Add an api_key= parameter and pass it through to hosted_remaining_usd(api_key)
(which still falls back to FLOE_API_KEY when None, so env-key users stay
zero-config). Also widen the refresh gate to (api_key is not None or
hosted_enforcement_available()) β€” the env-only availability check would
otherwise gate out the in-code-key path entirely, leaving the bug unfixed.

Docstring + README document passing the same key you gave floe.LLM. Adds a test
that an in-code key (no env key) is the one used for the budget read.
@achris7

achris7 commented Aug 18, 2026

Copy link
Copy Markdown
Author

Fixed β€” real bug, good catch. enable_cost_receipts now takes an api_key and reads the budget with hosted_remaining_usd(api_key), so the left $… balance is for the account being billed. If you pass your key in code to floe.LLM(api_key=...), pass the same key here; if it's in FLOE_API_KEY, omit it (zero-config). Also widened the enable gate so an in-code key isn't blocked by the env-only hosted_enforcement_available() check. Test + README updated; mypy --strict clean, 13 tests pass.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 5 additional findings in Devin Review.

Open in Devin Review

@achris7

achris7 commented Aug 18, 2026

Copy link
Copy Markdown
Author

Valid β€” prompt-cached input tokens are billed at a discount, and pricing every input token at the full rate overstates the estimate when caching is active. I'm going to fix this properly rather than patch it partially, because the correct fix is provider-aware:

  • The discount differs by provider (e.g. Anthropic cache-read β‰ˆ 0.1Γ—, OpenAI cache β‰ˆ 0.5Γ—), and it applies to both the per-turn receipt (turn_cost) and the reconciler (price_tokens) β€” so the right place is floe-guard, exposing the cached-token counts through turn_cost/price_tokens with per-provider multipliers, then having both surfaces subtract cached tokens from the full-rate input and price them at the cache rate.

Doing it half-way here (single hardcoded multiplier) would be right for Anthropic and wrong for OpenAI, which is worse than the current honest-but-coarse est. Tracking it as a floe-guard accuracy follow-up. In the meantime the figure is explicitly labelled est (a local estimate; Floe's bill is authoritative), so it doesn't claim invoice parity.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants