|
| 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"] |
0 commit comments