Skip to content

Commit 700c40f

Browse files
authored
Merge pull request #3 from Floe-Labs/feat/langchain-adapter
feat: add LangChain budget-guard integration
2 parents 84d48a0 + 99379b0 commit 700c40f

5 files changed

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

0 commit comments

Comments
 (0)