Skip to content

Commit e22b70f

Browse files
committed
feat: add LangChain budget-guard integration
1 parent 84d48a0 commit e22b70f

5 files changed

Lines changed: 280 additions & 3 deletions

File tree

README.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,29 @@ response = guarded_completion(guard, model="gpt-4o", messages=[...])
108108
Prefer the LiteLLM-native callback? Register `budget_guard_callback(guard)` on
109109
`litellm.callbacks`.
110110

111+
### LangChain
112+
113+
```bash
114+
pip install floe-guard[langchain]
115+
```
116+
117+
```python
118+
from langchain_openai import ChatOpenAI
119+
from floe_guard import BudgetGuard
120+
from floe_guard.integrations.langchain import budget_guard_callback_handler
121+
122+
guard = BudgetGuard(limit_usd=1.00)
123+
llm = ChatOpenAI(model="gpt-4o", callbacks=[budget_guard_callback_handler(guard)])
124+
llm.invoke("hello") # checks budget before the call, records spend after
125+
```
126+
127+
The handler checks the budget on LLM start (raising `BudgetExceeded` aborts the
128+
call before it runs) and records token usage on LLM end.
129+
111130
### Coming next
112131

113-
LangChain (callback) and the Vercel AI SDK (TypeScript middleware) are next. Open
114-
an issue if you want one sooner.
132+
The Vercel AI SDK (TypeScript middleware) is next. Open an issue if you want one
133+
sooner.
115134

116135
## Honest about what this is
117136

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]

src/floe_guard/integrations/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@
44
55
pip install floe-guard[litellm]
66
pip install floe-guard[crewai]
7+
pip install floe-guard[langchain]
78
"""
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""LangChain adapter (optional extra: ``pip install floe-guard[langchain]``).
2+
3+
:func:`budget_guard_callback_handler` builds a LangChain callback handler you pass
4+
to any LLM or chat model. It checks the budget *before* the call
5+
(``on_llm_start`` / ``on_chat_model_start``) — raising
6+
:class:`~floe_guard.BudgetExceeded` aborts the call so it never runs — and records
7+
spend *after* the response (``on_llm_end``).
8+
9+
from langchain_openai import ChatOpenAI
10+
from floe_guard import BudgetGuard
11+
from floe_guard.integrations.langchain import budget_guard_callback_handler
12+
13+
guard = BudgetGuard(limit_usd=1.00)
14+
llm = ChatOpenAI(model="gpt-4o", callbacks=[budget_guard_callback_handler(guard)])
15+
llm.invoke("hello")
16+
17+
Every priced response routes through the same :class:`~floe_guard.BudgetGuard` as
18+
the other adapters, so token usage is priced via the bundled cost map.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
from typing import Any
24+
25+
from ..guard import BudgetGuard
26+
27+
28+
def _require_langchain() -> Any:
29+
try:
30+
import langchain_core # noqa: F401
31+
except ImportError as e: # pragma: no cover - exercised only without the extra
32+
raise ImportError(
33+
"The LangChain adapter requires langchain-core. "
34+
"Install with: pip install floe-guard[langchain]"
35+
) from e
36+
return langchain_core
37+
38+
39+
def _model_from_result(response: Any) -> str:
40+
"""Pull the model name from a LangChain ``LLMResult``."""
41+
llm_output = getattr(response, "llm_output", None)
42+
if isinstance(llm_output, dict):
43+
model = llm_output.get("model_name") or llm_output.get("model")
44+
if model:
45+
return str(model)
46+
return ""
47+
48+
49+
def _usage_from_result(response: Any) -> tuple[int, int]:
50+
"""Pull (prompt_tokens, completion_tokens) from a LangChain ``LLMResult``.
51+
52+
Handles both shapes LangChain emits: the provider ``token_usage`` block in
53+
``llm_output`` (e.g. OpenAI: ``prompt_tokens``/``completion_tokens``) and the
54+
standardized ``usage_metadata`` on a message (``input_tokens``/``output_tokens``).
55+
"""
56+
llm_output = getattr(response, "llm_output", None)
57+
if isinstance(llm_output, dict):
58+
usage = llm_output.get("token_usage") or llm_output.get("usage")
59+
if isinstance(usage, dict):
60+
prompt = int(usage.get("prompt_tokens", 0) or 0)
61+
completion = int(usage.get("completion_tokens", 0) or 0)
62+
if prompt > 0 or completion > 0:
63+
return prompt, completion
64+
65+
# Fall back to per-message usage_metadata (input_tokens/output_tokens), the
66+
# provider-agnostic shape newer chat models attach to each generation.
67+
prompt = completion = 0
68+
for batch in getattr(response, "generations", None) or []:
69+
for gen in batch:
70+
meta = getattr(getattr(gen, "message", None), "usage_metadata", None)
71+
if isinstance(meta, dict):
72+
prompt += int(meta.get("input_tokens", 0) or 0)
73+
completion += int(meta.get("output_tokens", 0) or 0)
74+
return prompt, completion
75+
76+
77+
def _record_result(guard: BudgetGuard, response: Any) -> None:
78+
model = _model_from_result(response)
79+
prompt_tokens, completion_tokens = _usage_from_result(response)
80+
if prompt_tokens <= 0 and completion_tokens <= 0:
81+
# No tokens were spent — nothing to meter.
82+
return
83+
# There IS spend to account for. Route it through record() even when the model
84+
# id is missing, so the guard's configured policy applies (fail-closed → warn +
85+
# raise; fail-open → warn + skip). Silently skipping here would let a real,
86+
# completed call go unmetered and skew the next check().
87+
guard.record(model, prompt_tokens, completion_tokens)
88+
89+
90+
def budget_guard_callback_handler(guard: BudgetGuard) -> Any:
91+
"""Build a LangChain callback handler that enforces ``guard`` on every call.
92+
93+
Pass it to any LLM or chat model::
94+
95+
llm = ChatOpenAI(model="gpt-4o", callbacks=[budget_guard_callback_handler(guard)])
96+
97+
``on_llm_start``/``on_chat_model_start`` run ``guard.check()`` (raising
98+
:class:`~floe_guard.BudgetExceeded` to abort), and ``on_llm_end`` records the
99+
response's token cost.
100+
"""
101+
_require_langchain()
102+
from langchain_core.callbacks import BaseCallbackHandler
103+
104+
class BudgetGuardCallbackHandler(BaseCallbackHandler): # type: ignore[misc]
105+
# LangChain swallows exceptions raised inside callbacks by default, which
106+
# would let a blocked call run anyway. raise_error=True propagates
107+
# BudgetExceeded so the call is actually aborted — the whole point.
108+
raise_error = True
109+
110+
def __init__(self) -> None:
111+
super().__init__()
112+
self.guard = guard
113+
114+
def on_llm_start(self, serialized: Any, prompts: Any, **kwargs: Any) -> None:
115+
self.guard.check()
116+
117+
def on_chat_model_start(self, serialized: Any, messages: Any, **kwargs: Any) -> None:
118+
self.guard.check()
119+
120+
def on_llm_end(self, response: Any, **kwargs: Any) -> None:
121+
_record_result(self.guard, response)
122+
123+
return BudgetGuardCallbackHandler()
124+
125+
126+
__all__ = ["budget_guard_callback_handler"]

tests/test_langchain_adapter.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""LangChain adapter tests.
2+
3+
The parsing helpers (``_model_from_result``/``_usage_from_result``/
4+
``_record_result``) are duck-typed over an ``LLMResult`` and need no langchain
5+
install, so they run in CI without the optional extra. The two handler tests
6+
exercise the real callback through the factory and prove the hard-stop: a call
7+
under budget is allowed and accrued, and a call that would cross the ceiling
8+
raises ``BudgetExceeded`` in ``on_llm_start`` — before the call runs.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from dataclasses import dataclass, field
14+
15+
import pytest
16+
17+
from floe_guard import BudgetExceeded, BudgetGuard, UnpriceableModelError, UnpriceableModelWarning
18+
from floe_guard.integrations.langchain import (
19+
_model_from_result,
20+
_record_result,
21+
_usage_from_result,
22+
budget_guard_callback_handler,
23+
)
24+
25+
26+
@dataclass
27+
class _Msg:
28+
usage_metadata: dict
29+
30+
31+
@dataclass
32+
class _Gen:
33+
message: _Msg
34+
35+
36+
@dataclass
37+
class _Result:
38+
"""Stand-in for a LangChain ``LLMResult``."""
39+
40+
llm_output: dict | None = None
41+
generations: list = field(default_factory=list)
42+
43+
44+
def _openai_result(prompt: int, completion: int, model: str = "gpt-4o") -> _Result:
45+
return _Result(
46+
llm_output={
47+
"model_name": model,
48+
"token_usage": {"prompt_tokens": prompt, "completion_tokens": completion},
49+
}
50+
)
51+
52+
53+
def test_model_from_result_reads_llm_output() -> None:
54+
assert _model_from_result(_openai_result(1, 1)) == "gpt-4o"
55+
56+
57+
def test_model_from_result_missing_is_empty() -> None:
58+
assert _model_from_result(_Result(llm_output=None)) == ""
59+
60+
61+
def test_usage_from_token_usage_block() -> None:
62+
assert _usage_from_result(_openai_result(5, 7)) == (5, 7)
63+
64+
65+
def test_usage_from_usage_metadata_fallback() -> None:
66+
# No token_usage in llm_output — fall back to per-message usage_metadata.
67+
result = _Result(
68+
llm_output={"model_name": "gpt-4o"},
69+
generations=[[_Gen(_Msg({"input_tokens": 5, "output_tokens": 7}))]],
70+
)
71+
assert _usage_from_result(result) == (5, 7)
72+
73+
74+
def test_record_result_accrues() -> None:
75+
guard = BudgetGuard(limit_usd=1.0)
76+
_record_result(guard, _openai_result(1_000, 1_000))
77+
assert guard.spent_usd == pytest.approx(0.0125)
78+
79+
80+
def test_usage_present_but_model_missing_fails_closed() -> None:
81+
# Tokens were spent but the model id is missing. This MUST go through record()
82+
# (fail-closed → raise), not be silently skipped unmetered.
83+
guard = BudgetGuard(limit_usd=1.0) # fail_closed defaults to True
84+
result = _Result(
85+
llm_output={"token_usage": {"prompt_tokens": 1_000, "completion_tokens": 1_000}}
86+
)
87+
with pytest.warns(UnpriceableModelWarning):
88+
with pytest.raises(UnpriceableModelError):
89+
_record_result(guard, result)
90+
assert guard.spent_usd == 0.0
91+
92+
93+
def test_usage_present_but_model_missing_fail_open_warns_and_skips() -> None:
94+
guard = BudgetGuard(limit_usd=1.0, fail_closed=False)
95+
result = _Result(
96+
llm_output={"token_usage": {"prompt_tokens": 1_000, "completion_tokens": 1_000}}
97+
)
98+
with pytest.warns(UnpriceableModelWarning):
99+
_record_result(guard, result)
100+
assert guard.spent_usd == 0.0
101+
102+
103+
def test_no_usage_response_is_a_noop() -> None:
104+
guard = BudgetGuard(limit_usd=1.0)
105+
_record_result(guard, _Result(llm_output={"model_name": "gpt-4o"}))
106+
_record_result(guard, _openai_result(0, 0))
107+
assert guard.spent_usd == 0.0
108+
109+
110+
def test_handler_allows_under_budget_and_records() -> None:
111+
guard = BudgetGuard(limit_usd=1.0)
112+
handler = budget_guard_callback_handler(guard)
113+
114+
handler.on_llm_start({}, ["hello"]) # under budget — no raise
115+
handler.on_llm_end(_openai_result(1_000, 1_000))
116+
assert guard.spent_usd == pytest.approx(0.0125)
117+
118+
119+
def test_handler_blocks_before_crossing() -> None:
120+
# First call costs 0.0125 and primes _last_cost; the next call's projection
121+
# (0.025) crosses the 0.02 ceiling, so on_llm_start raises BEFORE it runs.
122+
guard = BudgetGuard(limit_usd=0.02)
123+
handler = budget_guard_callback_handler(guard)
124+
125+
handler.on_llm_start({}, ["hello"])
126+
handler.on_llm_end(_openai_result(1_000, 1_000))
127+
assert guard.spent_usd == pytest.approx(0.0125)
128+
129+
with pytest.raises(BudgetExceeded):
130+
handler.on_llm_start({}, ["hello"])

0 commit comments

Comments
 (0)