feat(plugins): add livekit-plugins-floe (metered inference + budget guard) - #6890
feat(plugins): add livekit-plugins-floe (metered inference + budget guard)#6890achris7 wants to merge 11 commits into
Conversation
β¦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.
|
@CodeRabbit review |
β¦ 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.
|
Thanks for the reviews β addressed in the latest push:
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)? |
β¦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).
|
Both addressed in the latest push:
|
β¦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.
|
Good catch β you're right, and it's fixed (not just the 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.
|
Both valid β fixed:
Tests added for keyless plaintext rejection and Floe-only reconciliation (9 total). |
|
Deferring this rather than patching, for a few reasons:
Better addressed upstream in the base openai plugin (a |
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.
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.
|
Both fixed β thanks, the blocking one was a real bug:
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. |
|
Fixed β good edge case. |
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.
|
Fixed β real bug, good catch. |
|
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:
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 |
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:FLOE_API_KEYonly).X-Floe-Provider-Key; Floe meters spend against your budget (FLOE_PROVIDER_KEY).floe.LLMsubclasses theopenaiplugin'sLLM(Floe's endpoint is OpenAI-compatible), so it drops into anAgentSessionunchanged:Usage reconciliation
The plugin also ships
FloeUsageReconciler, which subscribes tosession_usage_updatedand 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 infloe-guard).Scope / notes
floe-guard(PyPI) for the cost map + pricing. It ships type hints but nopy.typedmarker yet, so I added a[[tool.mypy.overrides]]forfloe_guard.*mirroring the existingboto3/mcpoverrides. Happy to switch to requiring apy.typed-shippingfloe-guardrelease instead if you'd prefer no root-config change.livekit-plugins-groqlayout (PEP 420 namespace, hatchling,Plugin.register_plugin); auto-included by thelivekit-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 syntheticAgentSessionUsage(gpt-4o 1000in/500out β $0.0075; unpriceable models fail closed).