paveo 0.1.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.
paveo/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ """Paveo — admission control for the calls an AI agent makes.
2
+
3
+ Every tool in this space reports what an agent *already* did. Paveo decides
4
+ whether the next call is allowed to leave, using a policy and a budget, and
5
+ refuses it if not.
6
+
7
+ This module is the entire public surface (``docs/SPEC_V1.md`` §3). Anything not
8
+ exported here is private and may change without notice.
9
+
10
+ Paveo opens no network sockets. See ``tests/test_no_egress.py``.
11
+ """
12
+
13
+ import sys
14
+
15
+ # The audit log and the guard's memory lock files with POSIX calls that Windows
16
+ # lacks. Said here, before any of them is imported, so a Windows user reads why
17
+ # instead of a missing-module traceback (found while preparing R1).
18
+ if sys.platform == "win32":
19
+ raise ImportError(
20
+ "Paveo runs on macOS and Linux: its audit log needs POSIX file locking, "
21
+ "which Windows does not have. On Windows, run it inside WSL."
22
+ )
23
+
24
+ from .audit import ChainStatus, verify_chain
25
+ from .errors import (
26
+ BudgetExceeded,
27
+ ConfigError,
28
+ PaveoError,
29
+ PolicyDenied,
30
+ PolicyUnavailable,
31
+ PricingUnknown,
32
+ )
33
+ from .session import Paveo, Session
34
+
35
+ __all__ = [
36
+ "BudgetExceeded",
37
+ "ChainStatus",
38
+ "ConfigError",
39
+ "Paveo",
40
+ "PaveoError",
41
+ "PolicyDenied",
42
+ "PolicyUnavailable",
43
+ "PricingUnknown",
44
+ "Session",
45
+ "verify_chain",
46
+ ]
paveo/_anthropic.py ADDED
@@ -0,0 +1,525 @@
1
+ """The Anthropic adapter: a Messages request in, a bound out (§4.5, D38, D39).
2
+
3
+ **Provider-specific on purpose, and the only file that is.** The decision point
4
+ speaks a neutral language (a model, the most input and output a call can use,
5
+ and the per-class counts it did use), so a second provider is a second adapter
6
+ beside this one, not a change to the core. OpenAI and Gemini hide different
7
+ tokens in different places and report usage in different words; each needs its
8
+ own reader, written against its own provider's documentation, and a price list
9
+ of its own in ``prices.py``. None is guessed from this one.
10
+
11
+ What follows is how an Anthropic request is bounded.
12
+
13
+ A reservation is only a ceiling if its input count really is an upper bound, and
14
+ §4.5's bound, the request's own bytes, holds only for tokens that are *in* the
15
+ bytes. No BPE token is under one byte, so text is safe. Three things are not:
16
+
17
+ - **What the provider adds.** A system prompt whenever ``tools`` is present, and
18
+ the definitions of Anthropic-defined tools, which a request names by ``type``
19
+ in a few bytes and is billed for in thousands of tokens.
20
+ - **Images.** An image costs by its pixels, up to a per-image cap, not by its
21
+ bytes: a tiny, highly compressed image can cost far more tokens than it has
22
+ bytes, and an agent reading the web is handed images by strangers.
23
+ - **Anything referred to rather than sent.** An image by URL or a document by
24
+ ``file_id`` costs its content; its bytes are a reference.
25
+
26
+ The first two are added at the largest figure the provider documents. The third,
27
+ and everything else this module cannot put a number on, is **refused**
28
+ (``PricingUnknown``) rather than guessed. Every allowance below cites the page it
29
+ came from, read on the date in ``prices.VERIFIED["anthropic"]``.
30
+
31
+ **Nothing here is stored, logged or echoed.** The request is payload: it is
32
+ walked for its shape and measured for its length, and every error names a
33
+ position in it (``messages[3].content[0]``) and never a value from it.
34
+
35
+ **Allowlists, not denylists, at every level.** A top-level parameter, a content
36
+ block type or a source type this module does not know is refused, because the
37
+ day the provider ships a new one that bills hidden tokens is the day a denylist
38
+ starts under-reserving in silence (§10.10).
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import json
44
+ from collections.abc import Mapping, Sequence
45
+ from dataclasses import dataclass
46
+ from typing import NoReturn
47
+
48
+ from .errors import ConfigError, PricingUnknown
49
+ from .prices import _printable
50
+
51
+ # The tool-use system prompt the API adds whenever `tools` is present: the largest
52
+ # figure in the pricing page's per-model table (Opus 4.7 with `tool_choice` any or
53
+ # tool). One number for every model, so the bound never depends on the table
54
+ # being keyed right.
55
+ _TOOL_SYSTEM_PROMPT = 804
56
+
57
+ # Anthropic-defined tools, by `type` prefix: the input their definitions add, from
58
+ # the pricing page. Where the page says "about", the figure is rounded up past its
59
+ # largest per-model value, because "about" is not a bound.
60
+ _DEFINED_TOOLS: Mapping[str, int] = {
61
+ "bash_": 325,
62
+ "text_editor_": 700,
63
+ # "about 4,500 ... about 4,590 on Claude Sonnet 5", default members.
64
+ "computer_toolset_": 5_000,
65
+ # Earlier versions: 466-499 system prompt tokens plus about 735 per definition.
66
+ "computer_2": 1_500,
67
+ # "about 6,600 ... about 6,670 on Claude Sonnet 5", plus about 880 when every
68
+ # optional member is enabled.
69
+ "browser_toolset_": 8_000,
70
+ }
71
+
72
+ # The most visual tokens one image can cost on any model the table carries: the
73
+ # high-resolution tier, Claude 4.7 and later (vision page). Added for every image
74
+ # on top of its bytes, since a small file can decode to a large image.
75
+ _IMAGE_TOKENS = 4_784
76
+
77
+ # Parameters that change nothing about the input or are bounded by `max_tokens`.
78
+ # `thinking` counts against `max_tokens`. Everything not named here or in the
79
+ # handled set below is refused, with a trigger in D39.
80
+ _PLAIN_PARAMETERS = frozenset(
81
+ {
82
+ "model",
83
+ "max_tokens",
84
+ "messages",
85
+ "system",
86
+ "tools",
87
+ "tool_choice",
88
+ "temperature",
89
+ "top_p",
90
+ "top_k",
91
+ "stop_sequences",
92
+ "metadata",
93
+ "stream",
94
+ "thinking",
95
+ "speed",
96
+ "inference_geo",
97
+ "cache_control",
98
+ "service_tier",
99
+ "output_config",
100
+ # SDK keyword arguments that shape the HTTP request, not the prompt. Only
101
+ # these: `extra_headers` is refused because a header can carry a beta
102
+ # that changes billing, and `betas` is checked value by value below.
103
+ "betas",
104
+ "timeout",
105
+ }
106
+ )
107
+
108
+ # Beta flags whose only effect on price is a parameter this module already reads
109
+ # and prices. Any other beta can switch on a feature priced where we cannot see
110
+ # it (/code-review, D40), so it is refused until its cost is verified.
111
+ _KNOWN_BETAS = frozenset({"fast-mode-2026-02-01"})
112
+
113
+ # Keyword arguments the SDK sends as headers or keeps for itself: not in the body,
114
+ # so not measured, and one of them (`timeout`) is often not JSON at all.
115
+ _NOT_IN_BODY = frozenset({"betas", "timeout"})
116
+
117
+ # Inside `output_config`: effort and a task budget change how many output tokens
118
+ # are spent, which `max_tokens` already caps. `format` is structured output, whose
119
+ # input cost we have not verified, so it is refused until we have.
120
+ _OUTPUT_CONFIG_KEYS = frozenset({"effort", "task_budget"})
121
+
122
+ _BLOCK_TYPES = frozenset(
123
+ {
124
+ "text",
125
+ "image",
126
+ "document",
127
+ "tool_use",
128
+ "tool_result",
129
+ "thinking",
130
+ "redacted_thinking",
131
+ }
132
+ )
133
+
134
+ _PRICE_AFFECTING = ("speed", "inference_geo")
135
+
136
+
137
+ @dataclass(frozen=True, slots=True)
138
+ class RequestBound:
139
+ """What a request can cost, in the terms the price table prices.
140
+
141
+ ``assumed_output`` is True when ``max_output_tokens`` came from the policy's
142
+ ``assumed_max_output_tokens`` rather than the request, which §4.4 says must be
143
+ visible in the audit record, because the ceiling is then approximate.
144
+ """
145
+
146
+ model: str
147
+ modifiers: Mapping[str, object]
148
+ input_upper_bound: int
149
+ max_output_tokens: int
150
+ assumed_output: bool
151
+
152
+
153
+ def model_of(request: Mapping[str, object]) -> str:
154
+ """The model a request names, or ``ConfigError``.
155
+
156
+ Read on its own, first, because the policy is asked about the model before
157
+ anything else looks at the request (§5.1): a model the policy never names is
158
+ refused before its name can reach a pricing error, or, in shadow mode, unless
159
+ the price table carries it (D48).
160
+ """
161
+ model = request.get("model") if isinstance(request, Mapping) else None
162
+ if not isinstance(model, str) or not model:
163
+ raise ConfigError(
164
+ "the request names no model.",
165
+ remedy=(
166
+ "pass the same mapping you pass to messages.create, including `model`."
167
+ ),
168
+ )
169
+ return model
170
+
171
+
172
+ def bound(
173
+ request: Mapping[str, object], *, assumed_max_output_tokens: int | None
174
+ ) -> RequestBound:
175
+ """Measure a request, or refuse it if it cannot be measured."""
176
+ model = model_of(request)
177
+ refuse = _Refusal(model)
178
+
179
+ for key in request:
180
+ if key not in _PLAIN_PARAMETERS:
181
+ refuse(
182
+ f"the request sets {_printable(key)!r}, a parameter paveo cannot "
183
+ f"bound the cost of",
184
+ "leave it out, or tell us you need it: each one is added once its "
185
+ "cost is verified (D39).",
186
+ )
187
+
188
+ output_config = request.get("output_config", {})
189
+ if not isinstance(output_config, Mapping) or not set(output_config) <= (
190
+ _OUTPUT_CONFIG_KEYS
191
+ ):
192
+ refuse(
193
+ "output_config sets something other than effort or task_budget, and "
194
+ "structured output's input cost is not yet verified",
195
+ "leave output_config.format out for now (D39).",
196
+ )
197
+
198
+ betas = request.get("betas", [])
199
+ if (
200
+ not isinstance(betas, Sequence)
201
+ or isinstance(betas, str | bytes)
202
+ or not all(isinstance(b, str) and b in _KNOWN_BETAS for b in betas)
203
+ ):
204
+ refuse(
205
+ "the request enables a beta whose effect on price paveo has not verified",
206
+ "leave that beta out for now; each is added once its cost is known (D40).",
207
+ )
208
+
209
+ max_tokens = request.get("max_tokens")
210
+ assumed = False
211
+ if max_tokens is None:
212
+ if assumed_max_output_tokens is None:
213
+ refuse(
214
+ "the request sets no max_tokens, so its output has no bound",
215
+ "set max_tokens on the request, or set "
216
+ "defaults.assumed_max_output_tokens in the policy (SPEC_V1.md §4.4).",
217
+ )
218
+ max_tokens = assumed_max_output_tokens
219
+ assumed = True
220
+ if (
221
+ isinstance(max_tokens, bool)
222
+ or not isinstance(max_tokens, int)
223
+ or max_tokens < 0
224
+ ):
225
+ refuse(
226
+ "max_tokens is not a non-negative whole number",
227
+ "set max_tokens to the most output tokens this call may produce.",
228
+ )
229
+
230
+ allowance = _tools_allowance(request.get("tools"), refuse)
231
+ allowance += _content_allowance(request, refuse)
232
+
233
+ return RequestBound(
234
+ model=model,
235
+ modifiers={name: request[name] for name in _PRICE_AFFECTING if name in request},
236
+ input_upper_bound=_byte_length(
237
+ {k: v for k, v in request.items() if k not in _NOT_IN_BODY}
238
+ )
239
+ + allowance,
240
+ max_output_tokens=max_tokens,
241
+ assumed_output=assumed,
242
+ )
243
+
244
+
245
+ class _Refusal:
246
+ """Raises ``PricingUnknown`` for one model. A callable so the walkers stay flat."""
247
+
248
+ def __init__(self, model: str) -> None:
249
+ self._model = model
250
+
251
+ def __call__(self, detail: str, remedy: str) -> NoReturn:
252
+ raise PricingUnknown(model=self._model, detail=detail, remedy=remedy)
253
+
254
+
255
+ def _tools_allowance(tools: object, refuse: _Refusal) -> int:
256
+ if tools is None:
257
+ return 0
258
+ if not isinstance(tools, Sequence) or isinstance(tools, str | bytes):
259
+ refuse("tools is not a list", "pass tools as a list of tool definitions.")
260
+ if not tools:
261
+ return 0
262
+ total = _TOOL_SYSTEM_PROMPT
263
+ for index, tool in enumerate(tools):
264
+ if not isinstance(tool, Mapping):
265
+ refuse(f"tools[{index}] is not a mapping", "pass each tool as a dict.")
266
+ kind = tool.get("type")
267
+ if kind is None or kind == "custom":
268
+ continue # a tool you define: its definition is in the bytes
269
+ allowance = next(
270
+ (
271
+ tokens
272
+ for prefix, tokens in _DEFINED_TOOLS.items()
273
+ if isinstance(kind, str) and kind.startswith(prefix)
274
+ ),
275
+ None,
276
+ )
277
+ if allowance is None:
278
+ refuse(
279
+ f"tools[{index}] is a server or Anthropic-defined tool whose cost "
280
+ f"paveo cannot bound: its results, searches or execution time are "
281
+ f"billed beyond the request",
282
+ "leave it out of calls checked by paveo for now; each is added "
283
+ "once its cost can be bounded (D39).",
284
+ )
285
+ total += allowance
286
+ return total
287
+
288
+
289
+ def _content_allowance(request: Mapping[str, object], refuse: _Refusal) -> int:
290
+ """Walk ``system`` and ``messages`` for images and anything referred to."""
291
+ total = 0
292
+ system = request.get("system")
293
+ if system is not None and not isinstance(system, str):
294
+ total += _blocks(system, "system", refuse)
295
+ messages = request.get("messages")
296
+ if not isinstance(messages, Sequence) or isinstance(messages, str | bytes):
297
+ refuse("messages is not a list", "pass messages as a list of turns.")
298
+ for index, message in enumerate(messages):
299
+ where = f"messages[{index}]"
300
+ if not isinstance(message, Mapping):
301
+ refuse(f"{where} is not a mapping", "pass each turn as a dict.")
302
+ content = message.get("content")
303
+ if not isinstance(content, str):
304
+ total += _blocks(content, f"{where}.content", refuse)
305
+ return total
306
+
307
+
308
+ def _blocks(blocks: object, where: str, refuse: _Refusal) -> int:
309
+ if not isinstance(blocks, Sequence) or isinstance(blocks, str | bytes):
310
+ refuse(f"{where} is neither text nor a list of blocks", _SHAPE_REMEDY)
311
+ total = 0
312
+ for index, block in enumerate(blocks):
313
+ here = f"{where}[{index}]"
314
+ kind = block.get("type") if isinstance(block, Mapping) else None
315
+ if not isinstance(kind, str) or kind not in _BLOCK_TYPES:
316
+ refuse(
317
+ f"{here} is a content block of a type paveo cannot bound",
318
+ "send text, images and documents inline; other block types are "
319
+ "added once their cost can be bounded (D39).",
320
+ )
321
+ kind = block["type"]
322
+ if kind == "image":
323
+ _require_inline(block, here, refuse, allowed=("base64",))
324
+ total += _IMAGE_TOKENS
325
+ elif kind == "document":
326
+ source = _require_inline(block, here, refuse, allowed=("text", "content"))
327
+ if source.get("type") == "content":
328
+ inner = source.get("content")
329
+ total += _blocks(inner, f"{here}.source.content", refuse)
330
+ elif kind == "tool_result":
331
+ content = block.get("content")
332
+ if content is not None and not isinstance(content, str):
333
+ total += _blocks(content, f"{here}.content", refuse)
334
+ return total
335
+
336
+
337
+ def _require_inline(
338
+ block: Mapping[str, object],
339
+ where: str,
340
+ refuse: _Refusal,
341
+ *,
342
+ allowed: tuple[str, ...],
343
+ ) -> Mapping[str, object]:
344
+ source = block.get("source")
345
+ if not isinstance(source, Mapping) or source.get("type") not in allowed:
346
+ refuse(
347
+ f"{where} is sent by reference or in a form whose tokens are not "
348
+ f"bounded by its bytes (a URL, a file_id, or a PDF)",
349
+ "send images as base64 and documents as plain text; content by "
350
+ "reference and PDFs are added once their cost can be bounded "
351
+ "(D39).",
352
+ )
353
+ return source
354
+
355
+
356
+ _SHAPE_REMEDY = "pass the request in the shape messages.create takes."
357
+
358
+
359
+ def _byte_length(request: Mapping[str, object]) -> int:
360
+ """The request's length in UTF-8 bytes, which bounds its text tokens (§4.5).
361
+
362
+ Serialised compactly and without ASCII escaping, so the count is the text's
363
+ own length plus its structure: never less than the tokens the text can be
364
+ split into. The serialisation is measured and discarded.
365
+ """
366
+ try:
367
+ text = json.dumps(request, ensure_ascii=False, separators=(",", ":"))
368
+ except (TypeError, ValueError) as e:
369
+ raise ConfigError(
370
+ "the request holds a value that is not plain JSON, so it cannot be "
371
+ "measured.",
372
+ remedy=(
373
+ "pass plain dicts, lists, strings and numbers, as you would send "
374
+ "over HTTP, rather than SDK objects."
375
+ ),
376
+ ) from e
377
+ try:
378
+ return len(text.encode("utf-8"))
379
+ except UnicodeEncodeError as e: # a lone surrogate: not text any provider takes
380
+ raise ConfigError(
381
+ "the request holds a string that is not valid Unicode.",
382
+ remedy="pass text that can be encoded as UTF-8.",
383
+ ) from e
384
+
385
+
386
+ # Fields of a response's `usage` that are not token counts. They describe the call
387
+ # rather than bill it, and passing them to the price table as classes would read
388
+ # a string as a count, or mark the table stale over a field that costs nothing.
389
+ _INFORMATIONAL = frozenset({"service_tier", "inference_geo", "speed"})
390
+
391
+ _USAGE_CLASSES: Mapping[str, str] = {
392
+ "input_tokens": "input",
393
+ "cache_read_input_tokens": "cache_read",
394
+ "output_tokens": "output",
395
+ }
396
+
397
+
398
+ def usage_classes(usage: Mapping[str, object]) -> dict[str, object]:
399
+ """A response's ``usage``, in the price table's class names (§4.2).
400
+
401
+ Pass ``response.usage.model_dump()``, or the dict the HTTP API returned.
402
+ ``None`` means a field the SDK left empty, and is skipped.
403
+
404
+ **Cache writes are split by lifetime when the response says so**
405
+ (``cache_creation``), and charged at the dearer 1-hour rate when it gives only
406
+ the total: we do not know which it was, so we take the one that cannot
407
+ under-charge.
408
+
409
+ **A field this adapter does not know is passed through under its own name**,
410
+ so the table sees a class it does not know, charges it at its highest rate,
411
+ and refuses every later reserve (§4.8.3). That includes any server tool
412
+ reporting a request: every such tool was refused at reserve time, so one here
413
+ means something billed that paveo did not bound. A new field that costs
414
+ nothing stops calls too, which is §10.12's published trade, and the reason
415
+ this list of known fields is re-read with the price page.
416
+ """
417
+ if not isinstance(usage, Mapping):
418
+ raise ConfigError(
419
+ "usage is not a mapping.",
420
+ remedy="pass response.usage.model_dump(), or the usage dict itself.",
421
+ )
422
+ classes: dict[str, object] = {}
423
+ breakdown = usage.get("cache_creation")
424
+ for key, value in usage.items():
425
+ if value is None or key in _INFORMATIONAL:
426
+ continue
427
+ # How many of the billed output tokens were thinking: a breakdown of
428
+ # output_tokens, not a charge beside it (release notes, 27 May 2026).
429
+ if key == "output_tokens_details" and isinstance(value, Mapping):
430
+ continue
431
+ if key == "iterations" and _one_attempt(value, usage):
432
+ continue
433
+ if key in _USAGE_CLASSES:
434
+ classes[_USAGE_CLASSES[key]] = value
435
+ elif key == "cache_creation_input_tokens":
436
+ if not isinstance(breakdown, Mapping):
437
+ classes["cache_write_1h"] = value
438
+ elif key == "cache_creation" and isinstance(value, Mapping):
439
+ classes.update(
440
+ _cache_writes(value, usage.get("cache_creation_input_tokens"))
441
+ )
442
+ elif key == "server_tool_use" and isinstance(value, Mapping):
443
+ used = sum(v for v in value.values() if isinstance(v, int) and v > 0)
444
+ if used:
445
+ classes["server_tool_use"] = used
446
+ else:
447
+ classes[key] = value if isinstance(value, int) else 0
448
+ return classes
449
+
450
+
451
+ _ATTEMPT_COUNTS = (
452
+ "input_tokens",
453
+ "output_tokens",
454
+ "cache_read_input_tokens",
455
+ "cache_creation_input_tokens",
456
+ )
457
+
458
+
459
+ def _one_attempt(iterations: object, usage: Mapping[str, object]) -> bool:
460
+ """Whether ``usage.iterations`` records only the attempt the top level counts.
461
+
462
+ It is "the per-attempt record of what you're billed", and the top-level
463
+ counts "describe only the attempt that produced the returned message", so
464
+ after a fallback the top level under-counts the bill, at another model's rate
465
+ (refusals-and-fallback page, read 2026-09-26). One plain attempt whose counts
466
+ are the top level's adds nothing. Anything else passes through, so the table
467
+ sees a class it does not know and stops (§4.8.3). The door refuses
468
+ ``fallbacks`` already; this is what holds if a second attempt appears anyway.
469
+ """
470
+ if not isinstance(iterations, list) or len(iterations) > 1:
471
+ return False
472
+ if not iterations:
473
+ return True
474
+ attempt = iterations[0]
475
+ return (
476
+ isinstance(attempt, Mapping)
477
+ and attempt.get("type") == "message"
478
+ # A count one side leaves out is zero on it, as the API reports zeros.
479
+ and all(
480
+ (attempt.get(key) or 0) == (usage.get(key) or 0) for key in _ATTEMPT_COUNTS
481
+ )
482
+ )
483
+
484
+
485
+ _LIFETIMES: Mapping[str, str] = {
486
+ "ephemeral_5m_input_tokens": "cache_write_5m",
487
+ "ephemeral_1h_input_tokens": "cache_write_1h",
488
+ }
489
+
490
+
491
+ def _cache_writes(breakdown: Mapping[str, object], total: object) -> dict[str, object]:
492
+ """Cache writes split by lifetime, with nothing the total reports left out.
493
+
494
+ A lifetime this adapter does not know passes through under its own name, so
495
+ the table sees an unknown class and stops (§4.8.3). And if the known
496
+ lifetimes add up to less than ``cache_creation_input_tokens``, the difference
497
+ is charged at the dearer 1-hour rate: a write the breakdown did not account
498
+ for is still a write (/code-review, D40).
499
+ """
500
+ classes: dict[str, object] = {}
501
+ for key, value in breakdown.items():
502
+ if value is None:
503
+ continue
504
+ name = _LIFETIMES.get(key, key)
505
+ classes[name] = value if isinstance(value, int) else 0
506
+ known = sum(
507
+ v for k, v in classes.items() if k in _LIFETIMES.values() and isinstance(v, int)
508
+ )
509
+ if isinstance(total, int) and not isinstance(total, bool) and total > known:
510
+ missing = total - known
511
+ existing = classes.get("cache_write_1h", 0)
512
+ classes["cache_write_1h"] = (
513
+ existing if isinstance(existing, int) else 0
514
+ ) + missing
515
+ return classes
516
+
517
+
518
+ def served(usage: Mapping[str, object]) -> dict[str, str]:
519
+ """Where the response says the call ran, so an unset geo settles at the truth.
520
+
521
+ ``usage.inference_geo`` reports it (data residency page). An unset geo is
522
+ reserved at the dearest, which is safe, and this lets the charge be exact.
523
+ """
524
+ geo = usage.get("inference_geo") if isinstance(usage, Mapping) else None
525
+ return {"inference_geo": geo} if geo in {"us", "global"} else {}