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.
- agentgov/__init__.py +213 -0
- agentgov/adapters/__init__.py +23 -0
- agentgov/adapters/crewai.py +417 -0
- agentgov/adapters/langchain.py +548 -0
- agentgov/cli.py +510 -0
- agentgov/cognitive.py +1385 -0
- agentgov/core.py +3860 -0
- agentgov/dummy.py +109 -0
- agentgov/exceptions.py +545 -0
- agentgov/interceptor.py +1092 -0
- agentgov/postgres.py +1185 -0
- agentgov/proxy.py +244 -0
- agentgov/py.typed +0 -0
- agentgov/receipts/__init__.py +115 -0
- agentgov/receipts/_ed25519.py +109 -0
- agentgov/receipts/_lines.py +54 -0
- agentgov/receipts/canonical.py +167 -0
- agentgov/receipts/log.py +422 -0
- agentgov/receipts/merkle.py +228 -0
- agentgov/receipts/rows.py +172 -0
- agentgov/receipts/schema.py +1272 -0
- agentgov/receipts/signing.py +281 -0
- agentgov/receipts/verify.py +320 -0
- agentgov/receipts/witness.py +285 -0
- agentgov/reconciliation.py +959 -0
- agentgov/storage.py +1033 -0
- agentgov/streaming.py +646 -0
- interlock_agentgov-0.3.0.dist-info/METADATA +1032 -0
- interlock_agentgov-0.3.0.dist-info/RECORD +32 -0
- interlock_agentgov-0.3.0.dist-info/WHEEL +4 -0
- interlock_agentgov-0.3.0.dist-info/entry_points.txt +2 -0
- interlock_agentgov-0.3.0.dist-info/licenses/LICENSE +191 -0
|
@@ -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)
|