interlock-agentgov 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,548 @@
1
+ """LangChain and LangGraph adapter.
2
+
3
+ Two entry points, for the two ways people actually build on LangChain.
4
+
5
+ :class:`GovernedChatModel` wraps a chat model directly. It is the two-line
6
+ drop-in: construct it around the model you already have, and every ``invoke``
7
+ authorizes, meters, and settles.
8
+
9
+ :class:`GovernedCallbackHandler` plugs into LangChain's callback system
10
+ instead. This is the one to reach for with **LangGraph**, agent executors, or
11
+ any chain deep enough that you never touch the model object — callbacks
12
+ propagate down the whole run tree, so one handler governs every model call in
13
+ a graph regardless of how many nodes it passes through.
14
+
15
+ **Nothing here imports LangChain.** Every object is duck-typed, so this module
16
+ loads on a machine that has never heard of it and is tested against fakes.
17
+
18
+ One implementation detail worth knowing, because it is the difference between
19
+ a governor that works and one that silently does not: LangChain **swallows
20
+ exceptions raised inside callbacks** unless the handler sets
21
+ ``raise_error = True``. :class:`GovernedCallbackHandler` sets it, so a
22
+ :class:`~agentgov.exceptions.DenialOfWalletError` or
23
+ :class:`~agentgov.exceptions.AgentThrashingError` actually halts the graph
24
+ rather than being logged and ignored.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ import threading
31
+ import uuid
32
+ from collections.abc import Mapping, Sequence
33
+ from typing import Any
34
+
35
+ from agentgov.core import Authorization, BudgetManager
36
+ from agentgov.exceptions import AgentGovError
37
+ from agentgov.interceptor import Interceptor, TokenUsage
38
+ from agentgov.reconciliation import MeteringJournal
39
+
40
+ __all__ = [
41
+ "GovernedCallbackHandler",
42
+ "GovernedChatModel",
43
+ "extract_langchain_usage",
44
+ "govern_chat_model",
45
+ ]
46
+
47
+ logger = logging.getLogger("agentgov.adapters.langchain")
48
+
49
+ # Usage has lived in three different places across LangChain versions and
50
+ # provider integrations. Try all of them rather than pinning to one.
51
+ _USAGE_CONTAINERS = ("usage_metadata", "token_usage", "usage")
52
+ _INPUT_ALIASES = ("input_tokens", "prompt_tokens", "promptTokens")
53
+ _OUTPUT_ALIASES = ("output_tokens", "completion_tokens", "completionTokens")
54
+ _CACHE_READ_ALIASES = ("cache_read_input_tokens", "cache_read")
55
+ _CACHE_WRITE_ALIASES = ("cache_creation_input_tokens", "cache_creation")
56
+
57
+
58
+ def _read_int(source: object, aliases: Sequence[str]) -> int:
59
+ """Pull the first present integer among ``aliases`` off a dict or object."""
60
+ for alias in aliases:
61
+ value: object = None
62
+ if isinstance(source, Mapping):
63
+ value = source.get(alias)
64
+ else:
65
+ value = getattr(source, alias, None)
66
+ if isinstance(value, int) and not isinstance(value, bool):
67
+ return value
68
+ if isinstance(value, float):
69
+ return int(value)
70
+ return 0
71
+
72
+
73
+ def _usage_from(source: object) -> TokenUsage | None:
74
+ """Build usage from a dict or object that carries token counts."""
75
+ if source is None:
76
+ return None
77
+ usage = TokenUsage(
78
+ input_tokens=_read_int(source, _INPUT_ALIASES),
79
+ output_tokens=_read_int(source, _OUTPUT_ALIASES),
80
+ cache_read_input_tokens=_read_int(source, _CACHE_READ_ALIASES),
81
+ cache_creation_input_tokens=_read_int(source, _CACHE_WRITE_ALIASES),
82
+ )
83
+ return usage if usage.total_tokens else None
84
+
85
+
86
+ def extract_langchain_usage(result: object) -> TokenUsage:
87
+ """Read token counts out of a LangChain ``LLMResult`` or message.
88
+
89
+ Looks in every place LangChain has put usage across its versions and
90
+ provider integrations: ``llm_output["token_usage"]`` (the OpenAI shape),
91
+ ``llm_output["usage"]`` (the Anthropic shape), and
92
+ ``generations[0][0].message.usage_metadata`` (the modern, normalised one).
93
+
94
+ :param result: An ``LLMResult``, an ``AIMessage``, or anything carrying
95
+ one of the recognised usage containers.
96
+ :returns: The token counts found.
97
+ :raises TypeError: If no usage is present anywhere. Deliberately loud:
98
+ silently metering zero would let an unrecognised response shape spend
99
+ without limit.
100
+ """
101
+ # 1. The modern normalised location, on the generated message itself.
102
+ generations = getattr(result, "generations", None)
103
+ if isinstance(generations, Sequence):
104
+ for batch in generations:
105
+ if not isinstance(batch, Sequence):
106
+ continue
107
+ for generation in batch:
108
+ message = getattr(generation, "message", generation)
109
+ for container in _USAGE_CONTAINERS:
110
+ found = _usage_from(getattr(message, container, None))
111
+ if found is not None:
112
+ return found
113
+
114
+ # 2. The provider-specific `llm_output` bag.
115
+ llm_output = getattr(result, "llm_output", None)
116
+ if isinstance(llm_output, Mapping):
117
+ for container in _USAGE_CONTAINERS:
118
+ found = _usage_from(llm_output.get(container))
119
+ if found is not None:
120
+ return found
121
+ found = _usage_from(llm_output)
122
+ if found is not None:
123
+ return found
124
+
125
+ # 3. The object itself, for a bare message or a plain dict.
126
+ for container in _USAGE_CONTAINERS:
127
+ found = _usage_from(getattr(result, container, None))
128
+ if found is not None:
129
+ return found
130
+ if isinstance(result, Mapping):
131
+ for container in _USAGE_CONTAINERS:
132
+ found = _usage_from(result.get(container))
133
+ if found is not None:
134
+ return found
135
+ direct = _usage_from(result)
136
+ if direct is not None:
137
+ return direct
138
+
139
+ raise TypeError(
140
+ f"no token usage found on {type(result).__name__}; pass an explicit "
141
+ f"extract_usage to the Interceptor, or record usage manually"
142
+ )
143
+
144
+
145
+ class GovernedCallbackHandler:
146
+ """A LangChain callback handler that governs every model call in a run.
147
+
148
+ Attach it once and every model call beneath it — through chains, agents,
149
+ tools, or a LangGraph state machine — is authorized before it starts and
150
+ settled when it ends::
151
+
152
+ handler = GovernedCallbackHandler(manager, "researcher")
153
+ graph.invoke(state, config={"callbacks": [handler]})
154
+
155
+ The hold is keyed by LangChain's ``run_id``, so concurrent branches of a
156
+ graph each carry their own encumbrance and settle independently.
157
+
158
+ A run that errors has its hold voided. A run that never reports an end —
159
+ a process killed mid-graph — leaves a hold that
160
+ :meth:`~agentgov.core.BudgetManager.void_stale` reclaims.
161
+
162
+ :param manager: The governor to enforce against.
163
+ :param scope_id: The scope these calls are charged to.
164
+ :param model: Model id used to look up pricing.
165
+ :param interceptor: An existing interceptor to use instead of building one.
166
+ :param journal: Optional metering journal, for later reconciliation.
167
+ :param interceptor_options: Forwarded to :class:`~agentgov.interceptor.Interceptor`
168
+ when one is constructed — ``cognitive``, ``safety_buffer``, and the rest.
169
+ """
170
+
171
+ # LangChain swallows callback exceptions unless this is set. Without it a
172
+ # denial-of-wallet halt would be logged and the graph would keep spending.
173
+ raise_error = True
174
+ run_inline = True
175
+
176
+ def __init__(
177
+ self,
178
+ manager: BudgetManager | None = None,
179
+ scope_id: str = "",
180
+ *,
181
+ model: str = "claude-opus-5",
182
+ interceptor: Interceptor | None = None,
183
+ journal: MeteringJournal | None = None,
184
+ **interceptor_options: Any,
185
+ ) -> None:
186
+ if interceptor is None:
187
+ if manager is None or not scope_id:
188
+ raise ValueError(
189
+ "provide either an interceptor, or a manager and scope_id to build one"
190
+ )
191
+ interceptor = Interceptor(manager, scope_id, model=model, **interceptor_options)
192
+ self._interceptor = interceptor
193
+ self._journal = journal
194
+ self._lock = threading.Lock()
195
+ self._holds: dict[uuid.UUID, Authorization] = {}
196
+
197
+ # -- LangChain's handler protocol -------------------------------------
198
+
199
+ @property
200
+ def ignore_llm(self) -> bool:
201
+ """LangChain asks this before dispatching model events."""
202
+ return False
203
+
204
+ @property
205
+ def ignore_chain(self) -> bool:
206
+ return True
207
+
208
+ @property
209
+ def ignore_agent(self) -> bool:
210
+ return True
211
+
212
+ @property
213
+ def ignore_retriever(self) -> bool:
214
+ return True
215
+
216
+ @property
217
+ def ignore_chat_model(self) -> bool:
218
+ return False
219
+
220
+ @property
221
+ def interceptor(self) -> Interceptor:
222
+ """The underlying primitive, unchanged and reachable."""
223
+ return self._interceptor
224
+
225
+ @property
226
+ def outstanding(self) -> int:
227
+ """How many runs currently hold an unsettled authorization."""
228
+ with self._lock:
229
+ return len(self._holds)
230
+
231
+ # -- lifecycle --------------------------------------------------------
232
+
233
+ def on_chat_model_start(
234
+ self,
235
+ serialized: Mapping[str, Any],
236
+ messages: Sequence[Sequence[Any]],
237
+ *,
238
+ run_id: uuid.UUID,
239
+ **kwargs: Any,
240
+ ) -> None:
241
+ """Authorize before a chat model call begins.
242
+
243
+ :raises ~agentgov.exceptions.DenialOfWalletError: If the scope cannot
244
+ afford the call.
245
+ :raises ~agentgov.exceptions.AgentThrashingError: If the cognitive
246
+ breaker finds the graph looping.
247
+ """
248
+ self._begin(run_id, _flatten_messages(messages), kwargs)
249
+
250
+ def on_llm_start(
251
+ self,
252
+ serialized: Mapping[str, Any],
253
+ prompts: Sequence[str],
254
+ *,
255
+ run_id: uuid.UUID,
256
+ **kwargs: Any,
257
+ ) -> None:
258
+ """Authorize before a completion-style model call begins."""
259
+ self._begin(run_id, list(prompts), kwargs)
260
+
261
+ def on_llm_end(self, response: object, *, run_id: uuid.UUID, **kwargs: Any) -> None:
262
+ """Settle the call at its true cost."""
263
+ authorization = self._claim(run_id)
264
+ if authorization is None:
265
+ return
266
+ try:
267
+ usage = extract_langchain_usage(response)
268
+ except TypeError:
269
+ # An unrecognised response shape. Charging the full hold is the
270
+ # conservative choice: under-charging is the one direction a spend
271
+ # governor must never fail in.
272
+ logger.warning(
273
+ "no usage on %s for run %s; capturing the full authorization of %s",
274
+ type(response).__name__,
275
+ run_id,
276
+ authorization.amount,
277
+ )
278
+ self._interceptor.manager.capture(
279
+ authorization, authorization.amount, memo="langchain call, usage unavailable"
280
+ )
281
+ return
282
+
283
+ cost = self._interceptor.pricing.cost_of(usage)
284
+ entry = self._interceptor.manager.capture(authorization, cost, memo="langchain model call")
285
+ if self._journal is not None:
286
+ self._journal.record_usage(
287
+ entry.transaction_id,
288
+ self._interceptor.pricing.model_id,
289
+ usage,
290
+ self._interceptor.scope_id,
291
+ entry.timestamp,
292
+ )
293
+
294
+ def on_llm_error(self, error: BaseException, *, run_id: uuid.UUID, **kwargs: Any) -> None:
295
+ """Release the hold: a failed call generated nothing to bill."""
296
+ authorization = self._claim(run_id)
297
+ if authorization is not None:
298
+ self._interceptor.manager.void(authorization, memo="langchain call failed")
299
+
300
+ # -- internals --------------------------------------------------------
301
+
302
+ def _begin(self, run_id: uuid.UUID, texts: Sequence[str], kwargs: Mapping[str, Any]) -> None:
303
+ """Size, observe, and authorize one model call."""
304
+ payload: dict[str, Any] = {"messages": list(texts)}
305
+ invocation = kwargs.get("invocation_params")
306
+ if isinstance(invocation, Mapping) and "max_tokens" in invocation:
307
+ payload["max_tokens"] = invocation["max_tokens"]
308
+
309
+ breaker = self._interceptor.cognitive
310
+ if breaker is not None:
311
+ # Ahead of the hold, so a thrashing graph costs nothing to stop.
312
+ breaker.observe_call(
313
+ self._interceptor.scope_id,
314
+ "langchain.model",
315
+ (),
316
+ payload,
317
+ trajectory=self._interceptor.trajectory,
318
+ )
319
+
320
+ hold = self._interceptor.size_hold((), payload)
321
+ authorization = self._interceptor.manager.authorize(
322
+ self._interceptor.scope_id, hold, memo="langchain call authorization"
323
+ )
324
+ with self._lock:
325
+ self._holds[run_id] = authorization
326
+
327
+ def _claim(self, run_id: uuid.UUID) -> Authorization | None:
328
+ """Take ownership of a run's hold, exactly once."""
329
+ with self._lock:
330
+ return self._holds.pop(run_id, None)
331
+
332
+ def release_all(self, *, memo: str = "langchain run abandoned") -> int:
333
+ """Void every outstanding hold. For teardown after a failed run.
334
+
335
+ :param memo: Audit context recorded on the releases.
336
+ :returns: How many holds were released.
337
+ """
338
+ with self._lock:
339
+ outstanding = list(self._holds.values())
340
+ self._holds.clear()
341
+ for authorization in outstanding:
342
+ try:
343
+ self._interceptor.manager.void(authorization, memo=memo)
344
+ except AgentGovError: # pragma: no cover - already settled elsewhere
345
+ logger.debug("hold %s was already resolved", authorization.authorization_id)
346
+ return len(outstanding)
347
+
348
+
349
+ def _flatten_messages(messages: Sequence[Sequence[Any]]) -> list[str]:
350
+ """Reduce LangChain's nested message batches to plain text for sizing."""
351
+ texts: list[str] = []
352
+ for batch in messages:
353
+ if isinstance(batch, str):
354
+ texts.append(batch)
355
+ continue
356
+ for message in batch:
357
+ content = getattr(message, "content", message)
358
+ if isinstance(content, str):
359
+ texts.append(content)
360
+ elif isinstance(content, Sequence):
361
+ for block in content:
362
+ if isinstance(block, Mapping) and isinstance(block.get("text"), str):
363
+ texts.append(block["text"])
364
+ elif isinstance(block, str):
365
+ texts.append(block)
366
+ return texts
367
+
368
+
369
+ class GovernedChatModel:
370
+ """A LangChain chat model whose calls are metered and enforced.
371
+
372
+ The two-line drop-in::
373
+
374
+ model = GovernedChatModel(ChatAnthropic(model="claude-opus-5"), gov, "researcher")
375
+ response = model.invoke(messages) # unchanged call site, now governed
376
+
377
+ Attribute access falls through to the wrapped model, so anything this
378
+ adapter does not govern keeps working. ``invoke``, ``ainvoke``, and
379
+ ``stream`` are routed through the interceptor; ``bind``, ``bind_tools``,
380
+ and ``with_config`` return a governed wrapper around the model they
381
+ produce, so a bound model does not silently escape governance.
382
+
383
+ :param model: The LangChain chat model to wrap. Never mutated.
384
+ :param manager: The governor to enforce against.
385
+ :param scope_id: The scope these calls are charged to.
386
+ :param pricing_model: Model id used to look up pricing, when it cannot be
387
+ read off the wrapped model. When omitted, the wrapped model's own
388
+ ``model``/``model_name`` attribute is used.
389
+ :param journal: Optional metering journal, for later reconciliation.
390
+ :param interceptor: An existing interceptor to use instead of building one.
391
+ :param interceptor_options: Forwarded to :class:`~agentgov.interceptor.Interceptor`.
392
+ """
393
+
394
+ __slots__ = ("_interceptor", "_journal", "_model")
395
+
396
+ def __init__(
397
+ self,
398
+ model: object,
399
+ manager: BudgetManager | None = None,
400
+ scope_id: str = "",
401
+ *,
402
+ pricing_model: str | None = None,
403
+ journal: MeteringJournal | None = None,
404
+ interceptor: Interceptor | None = None,
405
+ **interceptor_options: Any,
406
+ ) -> None:
407
+ if interceptor is None:
408
+ if manager is None or not scope_id:
409
+ raise ValueError(
410
+ "provide either an interceptor, or a manager and scope_id to build one"
411
+ )
412
+ resolved = pricing_model or _infer_model_name(model) or "claude-opus-5"
413
+ # Default to the LangChain-aware extractor: the generic one does
414
+ # not know where LangChain hides usage, and silently metering zero
415
+ # is the one failure a spend governor must not have.
416
+ interceptor_options.setdefault("extract_usage", extract_langchain_usage)
417
+ interceptor = Interceptor(manager, scope_id, model=resolved, **interceptor_options)
418
+ self._model = model
419
+ self._interceptor = interceptor
420
+ self._journal = journal
421
+
422
+ @property
423
+ def interceptor(self) -> Interceptor:
424
+ """The underlying primitive, unchanged and reachable."""
425
+ return self._interceptor
426
+
427
+ @property
428
+ def raw(self) -> object:
429
+ """The unwrapped LangChain model."""
430
+ return self._model
431
+
432
+ def __repr__(self) -> str:
433
+ return f"<governed {self._model!r} scope={self._interceptor.scope_id!r}>"
434
+
435
+ def __getattr__(self, name: str) -> Any:
436
+ if name.startswith("_"):
437
+ # Without this, an attribute miss during construction or copying
438
+ # recurses forever through this same __getattr__.
439
+ raise AttributeError(name)
440
+ attribute = getattr(self._model, name)
441
+ if name in ("bind", "bind_tools", "with_config", "with_retry", "with_structured_output"):
442
+ return self._rewrap(attribute)
443
+ return attribute
444
+
445
+ def _rewrap(self, factory: Any) -> Any:
446
+ """Keep governance attached across LangChain's builder methods."""
447
+
448
+ def bound(*args: object, **kwargs: object) -> Any:
449
+ produced = factory(*args, **kwargs)
450
+ return GovernedChatModel(produced, interceptor=self._interceptor, journal=self._journal)
451
+
452
+ return bound
453
+
454
+ def invoke(self, input: object, *args: object, **kwargs: object) -> Any:
455
+ """Call the model under budget enforcement, returning its own result."""
456
+ return self._run(self._model.invoke, input, args, kwargs) # type: ignore[attr-defined]
457
+
458
+ async def ainvoke(self, input: object, *args: object, **kwargs: object) -> Any:
459
+ """Await the model under budget enforcement."""
460
+ payload = _sizing_payload(input, kwargs)
461
+ result = await self._interceptor.ainvoke(
462
+ _sizing_shim_async(self._model.ainvoke, input, args), # type: ignore[attr-defined]
463
+ **payload,
464
+ )
465
+ return self._settle(result)
466
+
467
+ def stream(self, input: object, *args: object, **kwargs: object) -> Any:
468
+ """Stream from the model under budget enforcement.
469
+
470
+ Returns an unentered :class:`~agentgov.streaming.MeteredStream`, so the
471
+ hold is resolved however the caller leaves the loop.
472
+ """
473
+ payload = _sizing_payload(input, kwargs)
474
+ return self._interceptor.stream(
475
+ _sizing_shim(self._model.stream, input, args), # type: ignore[attr-defined]
476
+ **payload,
477
+ )
478
+
479
+ def _run(
480
+ self, call: Any, input: object, args: Sequence[object], kwargs: Mapping[str, object]
481
+ ) -> Any:
482
+ payload = _sizing_payload(input, kwargs)
483
+ result = self._interceptor.invoke(_sizing_shim(call, input, args), **payload)
484
+ return self._settle(result)
485
+
486
+ def _settle(self, result: Any) -> Any:
487
+ if self._journal is not None:
488
+ self._journal.record(result)
489
+ return result.response
490
+
491
+
492
+ def _sizing_payload(input: object, kwargs: Mapping[str, object]) -> dict[str, Any]:
493
+ """Build the kwargs the interceptor sizes a hold from.
494
+
495
+ The wrapped call's real arguments are captured in a closure; what is
496
+ passed through the interceptor is a payload shaped for
497
+ :func:`~agentgov.interceptor.extract_prompt_text` to read.
498
+ """
499
+ payload: dict[str, Any] = {"messages": _flatten_messages([input])} # type: ignore[list-item]
500
+ if "max_tokens" in kwargs:
501
+ payload["max_tokens"] = kwargs["max_tokens"]
502
+ return payload
503
+
504
+
505
+ def _sizing_shim(call: Any, input: object, args: Sequence[object]) -> Any:
506
+ """Adapt a model call so the interceptor can size it and still invoke it."""
507
+
508
+ def invoke(**_sizing: object) -> Any:
509
+ return call(input, *args)
510
+
511
+ invoke.__name__ = getattr(call, "__name__", "invoke")
512
+ return invoke
513
+
514
+
515
+ def _sizing_shim_async(call: Any, input: object, args: Sequence[object]) -> Any:
516
+ """Async counterpart of :func:`_sizing_shim`."""
517
+
518
+ async def ainvoke(**_sizing: object) -> Any:
519
+ return await call(input, *args)
520
+
521
+ ainvoke.__name__ = getattr(call, "__name__", "ainvoke")
522
+ return ainvoke
523
+
524
+
525
+ def _infer_model_name(model: object) -> str | None:
526
+ """Best-effort read of a LangChain model's identifier."""
527
+ for attribute in ("model", "model_name", "model_id", "deployment_name"):
528
+ value = getattr(model, attribute, None)
529
+ if isinstance(value, str) and value:
530
+ return value
531
+ return None
532
+
533
+
534
+ def govern_chat_model(
535
+ model: object,
536
+ manager: BudgetManager,
537
+ scope_id: str,
538
+ **options: Any,
539
+ ) -> GovernedChatModel:
540
+ """Wrap a LangChain chat model in one call.
541
+
542
+ :param model: The chat model to govern.
543
+ :param manager: The governor to enforce against.
544
+ :param scope_id: The scope these calls are charged to.
545
+ :param options: Forwarded to :class:`GovernedChatModel`.
546
+ :returns: The governed model.
547
+ """
548
+ return GovernedChatModel(model, manager, scope_id, **options)