agentprov-capture 0.1.1__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.
- agentprov_capture/__init__.py +5 -0
- agentprov_capture/endpoints.py +252 -0
- agentprov_capture/endpoints.toml +156 -0
- agentprov_capture/envstate.py +182 -0
- agentprov_capture/errors.py +37 -0
- agentprov_capture/events.py +196 -0
- agentprov_capture/log.py +536 -0
- agentprov_capture/py.typed +0 -0
- agentprov_capture/redact.py +695 -0
- agentprov_capture/redact_allowlist.toml +52 -0
- agentprov_capture/rules/cap.rules.toml +121 -0
- agentprov_capture/verify.py +377 -0
- agentprov_capture-0.1.1.dist-info/METADATA +24 -0
- agentprov_capture-0.1.1.dist-info/RECORD +15 -0
- agentprov_capture-0.1.1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"""agentprov_capture: the capture/0.1 raw-evidence event log shared by the AgentPROV core (reader and
|
|
2
|
+
verifier) and the isolated live backends (writer). Stdlib + rfc8785 only, Python >= 3.12."""
|
|
3
|
+
|
|
4
|
+
FORMAT = "capture/0.1"
|
|
5
|
+
__version__ = "0.1.1" # equals capture/pyproject.toml (tests/unit/test_versions_source.py)
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""The endpoint registry: ``endpoints.toml`` as validated data (MN M.1.10 item 2; N.2.1, N.2.1a; G.9.2).
|
|
2
|
+
|
|
3
|
+
Stdlib only (``tomllib``; Python >= 3.12). The registry names environment variables, never values. Rules enforced here:
|
|
4
|
+
|
|
5
|
+
* a base URL is https, except loopback (``127.0.0.1``, ``[::1]``, ``localhost``);
|
|
6
|
+
* a base URL has no userinfo, query or fragment (a key could hide in them; G.6.4);
|
|
7
|
+
* a ``local`` entry is loopback and names its ``server`` kind (D-690). Its ``api_key_env`` is optional: a server set up
|
|
8
|
+
with an API key (oMLX's, D-827) gets it as a bearer header when the key is set, and none otherwise. A non-local entry
|
|
9
|
+
names ``api_key_env`` and has no ``server``;
|
|
10
|
+
* a custom base URL's host must equal the registered host unless the caller passes ``allow_custom=True``
|
|
11
|
+
(the CLI's ``--allow-custom-endpoint``, recorded in the manifest);
|
|
12
|
+
* ``api`` names the wire format the endpoint speaks: ``openai_chat`` (the default; OpenAI-compatible chat completions)
|
|
13
|
+
or ``anthropic_messages`` (the Anthropic Messages API; D-656).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import ipaddress
|
|
19
|
+
import re
|
|
20
|
+
import tomllib
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from importlib import resources
|
|
23
|
+
from typing import Any, Final
|
|
24
|
+
from urllib.parse import urlsplit
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"API_KINDS",
|
|
28
|
+
"LOCAL_SERVERS",
|
|
29
|
+
"LOOPBACK_HOSTS",
|
|
30
|
+
"Endpoint",
|
|
31
|
+
"EndpointError",
|
|
32
|
+
"check_base_url",
|
|
33
|
+
"get_endpoint",
|
|
34
|
+
"is_loopback_host",
|
|
35
|
+
"is_private_lan_host",
|
|
36
|
+
"load_endpoints",
|
|
37
|
+
"parse_registry",
|
|
38
|
+
"validate_base_url",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
#: The loopback hosts of G.9.2, as ``urllib.parse`` reports them (brackets removed from the IPv6 literal).
|
|
42
|
+
LOOPBACK_HOSTS: Final = frozenset({"127.0.0.1", "::1", "localhost"})
|
|
43
|
+
|
|
44
|
+
#: The wire formats an endpoint may speak (``api``); the first is the default.
|
|
45
|
+
API_KINDS: Final = ("openai_chat", "anthropic_messages")
|
|
46
|
+
#: The local server kinds of D-690. ``server`` selects the discovery and fingerprint probes of ``agentprov models``.
|
|
47
|
+
LOCAL_SERVERS: Final = frozenset({"ollama", "omlx", "lmstudio", "mlx_lm", "llamacpp", "vllm"})
|
|
48
|
+
|
|
49
|
+
_ENV_NAME: Final = re.compile(r"[A-Z][A-Z0-9_]{0,63}")
|
|
50
|
+
_ENDPOINT_NAME: Final = re.compile(r"[a-z][a-z0-9_-]{0,31}")
|
|
51
|
+
_HEADER_NAME: Final = re.compile(r"[a-z0-9][a-z0-9-]{0,63}")
|
|
52
|
+
_ENTRY_KEYS: Final = frozenset(
|
|
53
|
+
{
|
|
54
|
+
"base_url",
|
|
55
|
+
"base_url_env",
|
|
56
|
+
"api_key_env",
|
|
57
|
+
"model_env",
|
|
58
|
+
"local",
|
|
59
|
+
"server_max_concurrency",
|
|
60
|
+
"request_headers",
|
|
61
|
+
"response_headers",
|
|
62
|
+
"api",
|
|
63
|
+
"server",
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class EndpointError(ValueError):
|
|
69
|
+
"""A registry or base-URL violation. ``code`` is stable; messages never carry values from the environment."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, code: str, message: str) -> None:
|
|
72
|
+
super().__init__(f"{code}: {message}")
|
|
73
|
+
self.code = code
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True, slots=True)
|
|
77
|
+
class Endpoint:
|
|
78
|
+
"""One registry entry. ``*_env`` fields are environment-variable NAMES (or None), never values."""
|
|
79
|
+
|
|
80
|
+
name: str
|
|
81
|
+
base_url: str
|
|
82
|
+
host: str
|
|
83
|
+
base_url_env: str | None
|
|
84
|
+
api_key_env: str | None
|
|
85
|
+
model_env: str | None
|
|
86
|
+
local: bool
|
|
87
|
+
server_max_concurrency: int | None
|
|
88
|
+
#: header NAMES (lower case) whose values wire-capture redaction may record besides the global allowlist
|
|
89
|
+
#: (``redact_allowlist.toml``): for example an API-version header (request) or a request-id header (response).
|
|
90
|
+
#: The hard denylist of G.6.4 wins over them.
|
|
91
|
+
request_headers: tuple[str, ...] = ()
|
|
92
|
+
response_headers: tuple[str, ...] = ()
|
|
93
|
+
#: the wire format (one of :data:`API_KINDS`): which LLM element and recorder the harness uses (D-656)
|
|
94
|
+
api: str = API_KINDS[0]
|
|
95
|
+
#: the local server kind (one of :data:`LOCAL_SERVERS`) for a ``local`` entry; ``None`` for a remote one (D-690)
|
|
96
|
+
server: str | None = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def is_loopback_host(host: str) -> bool:
|
|
100
|
+
return host.lower() in LOOPBACK_HOSTS
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
#: The private ranges a LAN endpoint may use (D-698): RFC 1918, the shared address space of RFC 6598 (CGNAT, used by
|
|
104
|
+
#: Tailscale-style overlays), and IPv6 unique local addresses. Link-local, loopback and every public range are not.
|
|
105
|
+
LAN_NETWORKS: Final = tuple(ipaddress.ip_network(n) for n in (
|
|
106
|
+
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "fc00::/7"))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def is_private_lan_host(host: str) -> bool:
|
|
110
|
+
"""A literal IP address in :data:`LAN_NETWORKS` (never a name: a name can be made to resolve anywhere), without an
|
|
111
|
+
IPv6 zone id (``%en0`` names an interface of this host, not a LAN peer)."""
|
|
112
|
+
if "%" in host:
|
|
113
|
+
return False
|
|
114
|
+
try:
|
|
115
|
+
address = ipaddress.ip_address(host)
|
|
116
|
+
except ValueError:
|
|
117
|
+
return False
|
|
118
|
+
return any(address in net for net in LAN_NETWORKS if net.version == address.version)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def validate_base_url(base_url: str, *, allow_lan: bool = False) -> str:
|
|
122
|
+
"""Check the G.9.2 / G.6.4 rules on ``base_url`` and return its host (lower case).
|
|
123
|
+
|
|
124
|
+
http is allowed on a loopback host, and, with ``allow_lan`` (the CLI's ``--allow-lan-endpoint``, D-698), on a
|
|
125
|
+
literal private address. Raises :class:`EndpointError` (``endpoint.url.*``). Nothing is fetched or resolved.
|
|
126
|
+
"""
|
|
127
|
+
if not isinstance(base_url, str) or not base_url or any(ord(c) < 0x21 or ord(c) == 0x7F for c in base_url):
|
|
128
|
+
raise EndpointError("endpoint.url.malformed", "base URL is empty or contains whitespace or control characters")
|
|
129
|
+
try:
|
|
130
|
+
parts = urlsplit(base_url)
|
|
131
|
+
host = parts.hostname
|
|
132
|
+
parts.port # noqa: B018 - raises ValueError on a bad port
|
|
133
|
+
except ValueError:
|
|
134
|
+
raise EndpointError("endpoint.url.malformed", "base URL cannot be parsed") from None
|
|
135
|
+
if parts.scheme not in ("http", "https") or not host:
|
|
136
|
+
raise EndpointError("endpoint.url.malformed", "base URL needs an http(s) scheme and a host")
|
|
137
|
+
if parts.username is not None or parts.password is not None or "@" in parts.netloc:
|
|
138
|
+
raise EndpointError("endpoint.url.userinfo", "base URL must not carry userinfo")
|
|
139
|
+
if parts.query or "?" in base_url:
|
|
140
|
+
raise EndpointError("endpoint.url.query", "base URL must not carry a query string")
|
|
141
|
+
if parts.fragment or "#" in base_url:
|
|
142
|
+
raise EndpointError("endpoint.url.fragment", "base URL must not carry a fragment")
|
|
143
|
+
host = host.lower()
|
|
144
|
+
if parts.scheme != "https" and not is_loopback_host(host) and not (allow_lan and is_private_lan_host(host)):
|
|
145
|
+
raise EndpointError("endpoint.url.not_https", "base URL must be https unless the host is loopback")
|
|
146
|
+
return host
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def check_base_url(endpoint: Endpoint, base_url: str, *, allow_custom: bool = False) -> bool:
|
|
150
|
+
"""Validate a base URL given for ``endpoint`` (e.g. from its ``base_url_env``). Returns True when it is a custom
|
|
151
|
+
endpoint (its host differs from the registered one), which the caller must record in the manifest.
|
|
152
|
+
|
|
153
|
+
The https/loopback/userinfo/query rules always apply. A host mismatch raises ``endpoint.url.host_mismatch``
|
|
154
|
+
unless ``allow_custom`` (the CLI's ``--allow-custom-endpoint``, R-39).
|
|
155
|
+
"""
|
|
156
|
+
host = validate_base_url(base_url)
|
|
157
|
+
if host == endpoint.host:
|
|
158
|
+
return False
|
|
159
|
+
if not allow_custom:
|
|
160
|
+
raise EndpointError("endpoint.url.host_mismatch", f"host does not match the registered host of {endpoint.name!r}")
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _opt_env(entry: dict[str, Any], key: str, name: str) -> str | None:
|
|
165
|
+
value = entry.get(key)
|
|
166
|
+
if value is None:
|
|
167
|
+
return None
|
|
168
|
+
if not isinstance(value, str) or not _ENV_NAME.fullmatch(value):
|
|
169
|
+
raise EndpointError("endpoint.registry.env_name", f"endpoint {name!r}: {key} must be an upper-case variable name")
|
|
170
|
+
return value
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _opt_headers(entry: dict[str, Any], key: str, name: str) -> tuple[str, ...]:
|
|
174
|
+
value = entry.get(key, [])
|
|
175
|
+
if not isinstance(value, list) or not all(isinstance(h, str) and _HEADER_NAME.fullmatch(h) for h in value):
|
|
176
|
+
raise EndpointError("endpoint.registry.header", f"endpoint {name!r}: {key} must be a list of lower-case header names")
|
|
177
|
+
return tuple(sorted(set(value)))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def parse_registry(data: dict[str, Any]) -> dict[str, Endpoint]:
|
|
181
|
+
"""Validate a parsed ``endpoints.toml`` document and return the entries by name, sorted."""
|
|
182
|
+
if data.get("version") != 1:
|
|
183
|
+
raise EndpointError("endpoint.registry.version", "unsupported registry version")
|
|
184
|
+
entries = data.get("endpoints")
|
|
185
|
+
if not isinstance(entries, dict) or not entries:
|
|
186
|
+
raise EndpointError("endpoint.registry.empty", "registry has no [endpoints.*] tables")
|
|
187
|
+
out: dict[str, Endpoint] = {}
|
|
188
|
+
for name in sorted(entries):
|
|
189
|
+
entry = entries[name]
|
|
190
|
+
if not _ENDPOINT_NAME.fullmatch(name) or not isinstance(entry, dict):
|
|
191
|
+
raise EndpointError("endpoint.registry.name", "bad endpoint name or entry")
|
|
192
|
+
unknown = set(entry) - _ENTRY_KEYS
|
|
193
|
+
if unknown:
|
|
194
|
+
raise EndpointError("endpoint.registry.key", f"endpoint {name!r}: unknown key {sorted(unknown)[0]!r}")
|
|
195
|
+
base_url = entry.get("base_url")
|
|
196
|
+
if not isinstance(base_url, str):
|
|
197
|
+
raise EndpointError("endpoint.registry.base_url", f"endpoint {name!r}: base_url is required")
|
|
198
|
+
host = validate_base_url(base_url)
|
|
199
|
+
local = entry.get("local", False)
|
|
200
|
+
if not isinstance(local, bool):
|
|
201
|
+
raise EndpointError("endpoint.registry.local", f"endpoint {name!r}: local must be a boolean")
|
|
202
|
+
if local and not is_loopback_host(host):
|
|
203
|
+
raise EndpointError("endpoint.registry.local", f"endpoint {name!r}: a local entry must be loopback")
|
|
204
|
+
api_key_env = _opt_env(entry, "api_key_env", name)
|
|
205
|
+
if not local and api_key_env is None:
|
|
206
|
+
raise EndpointError("endpoint.registry.api_key_env", f"endpoint {name!r}: api_key_env is required")
|
|
207
|
+
server = entry.get("server")
|
|
208
|
+
if local and (not isinstance(server, str) or server not in LOCAL_SERVERS):
|
|
209
|
+
raise EndpointError("endpoint.registry.server", f"endpoint {name!r}: a local entry needs server, one of "
|
|
210
|
+
f"{sorted(LOCAL_SERVERS)}")
|
|
211
|
+
if not local and server is not None:
|
|
212
|
+
raise EndpointError("endpoint.registry.server", f"endpoint {name!r}: server belongs to local entries")
|
|
213
|
+
conc = entry.get("server_max_concurrency")
|
|
214
|
+
if conc is not None and (isinstance(conc, bool) or not isinstance(conc, int) or conc < 1):
|
|
215
|
+
raise EndpointError(
|
|
216
|
+
"endpoint.registry.concurrency", f"endpoint {name!r}: server_max_concurrency must be an integer >= 1"
|
|
217
|
+
)
|
|
218
|
+
api = entry.get("api", API_KINDS[0])
|
|
219
|
+
if api not in API_KINDS:
|
|
220
|
+
raise EndpointError("endpoint.registry.api", f"endpoint {name!r}: api must be one of {list(API_KINDS)}")
|
|
221
|
+
if local and api != API_KINDS[0]: # every local server kind of D-690 speaks the OpenAI chat wire
|
|
222
|
+
raise EndpointError("endpoint.registry.local_api", f"endpoint {name!r}: a local entry speaks {API_KINDS[0]}")
|
|
223
|
+
out[name] = Endpoint(
|
|
224
|
+
name=name,
|
|
225
|
+
base_url=base_url,
|
|
226
|
+
host=host,
|
|
227
|
+
base_url_env=_opt_env(entry, "base_url_env", name),
|
|
228
|
+
api_key_env=api_key_env,
|
|
229
|
+
model_env=_opt_env(entry, "model_env", name),
|
|
230
|
+
local=local,
|
|
231
|
+
server_max_concurrency=conc,
|
|
232
|
+
request_headers=_opt_headers(entry, "request_headers", name),
|
|
233
|
+
response_headers=_opt_headers(entry, "response_headers", name),
|
|
234
|
+
api=api,
|
|
235
|
+
server=server,
|
|
236
|
+
)
|
|
237
|
+
return out
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def load_endpoints() -> dict[str, Endpoint]:
|
|
241
|
+
"""Load and validate the packaged ``endpoints.toml`` (read through ``importlib.resources``)."""
|
|
242
|
+
raw = resources.files("agentprov_capture").joinpath("endpoints.toml").read_bytes()
|
|
243
|
+
return parse_registry(tomllib.loads(raw.decode("utf-8")))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def get_endpoint(name: str) -> Endpoint:
|
|
247
|
+
"""The registry entry ``name`` (``EndpointError("endpoint.unknown")`` if absent)."""
|
|
248
|
+
registry = load_endpoints()
|
|
249
|
+
try:
|
|
250
|
+
return registry[name]
|
|
251
|
+
except KeyError:
|
|
252
|
+
raise EndpointError("endpoint.unknown", f"unknown endpoint shorthand; known: {sorted(registry)}") from None
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# AgentPROV endpoint registry (MN M.1.10 item 2; N.2.1 `--model`; N.2.1a; G.9.2).
|
|
2
|
+
# Data, not code: read through importlib.resources by agentprov_capture.endpoints (no __init__.py needed here).
|
|
3
|
+
#
|
|
4
|
+
# Names only: `base_url_env`, `api_key_env` and `model_env` name environment variables (the .env keys); no value is
|
|
5
|
+
# ever stored here. There are no default model ids (Q5, closed): a model id comes from `--model-id` or `model_env`.
|
|
6
|
+
# `base_url` is the public default. Its host is the registered host that any other base URL must match unless
|
|
7
|
+
# `--allow-custom-endpoint` is given (R-39). A base URL MUST be https, except loopback (127.0.0.1, [::1], localhost),
|
|
8
|
+
# and MUST carry no userinfo, query or fragment (G.6.4).
|
|
9
|
+
#
|
|
10
|
+
# `request_headers` / `response_headers` (optional, lower-case header NAMES) extend the global header allowlist of
|
|
11
|
+
# redact_allowlist.toml for that endpoint (G.6.4: "per-endpoint names declared in endpoints.toml"); the hard denylist
|
|
12
|
+
# wins over them. Only request-id style headers are declared; add a name here, with a test, when a provider needs it.
|
|
13
|
+
#
|
|
14
|
+
# `api` (optional) is the wire format: `openai_chat` (default) or `anthropic_messages` (D-656). It selects the LLM
|
|
15
|
+
# element and the wire recorder's decoding; the CLI sets the agent's provider from it.
|
|
16
|
+
#
|
|
17
|
+
# Baseline set from AgentDojo-PROV `runtime.py` (build_* functions and _LLM_BUILDERS); `omlx` is new (D-10); `lmstudio`,
|
|
18
|
+
# `mlx_lm`, `llamacpp` and `vllm` are new (D-690).
|
|
19
|
+
|
|
20
|
+
version = 1
|
|
21
|
+
|
|
22
|
+
[endpoints.deepseek]
|
|
23
|
+
base_url = "https://api.deepseek.com/v1"
|
|
24
|
+
base_url_env = "DEEPSEEK_BASE_URL"
|
|
25
|
+
api_key_env = "DEEPSEEK_API_KEY"
|
|
26
|
+
model_env = "DEEPSEEK_MODEL"
|
|
27
|
+
local = false
|
|
28
|
+
|
|
29
|
+
[endpoints.openai]
|
|
30
|
+
base_url = "https://api.openai.com/v1"
|
|
31
|
+
base_url_env = "OPENAI_BASE_URL"
|
|
32
|
+
api_key_env = "OPENAI_API_KEY"
|
|
33
|
+
model_env = "OPENAI_MODEL"
|
|
34
|
+
local = false
|
|
35
|
+
response_headers = ["x-request-id", "openai-version"]
|
|
36
|
+
|
|
37
|
+
# One OpenRouter key and base URL serve openrouter, gemini, llama, qwen and glm (baseline `_build_openrouter_llm`).
|
|
38
|
+
[endpoints.openrouter]
|
|
39
|
+
base_url = "https://openrouter.ai/api/v1"
|
|
40
|
+
base_url_env = "OPENROUTER_BASE_URL"
|
|
41
|
+
api_key_env = "OPENROUTER_API_KEY"
|
|
42
|
+
model_env = "OPENROUTER_MODEL"
|
|
43
|
+
local = false
|
|
44
|
+
response_headers = ["x-request-id"]
|
|
45
|
+
|
|
46
|
+
[endpoints.gemini]
|
|
47
|
+
base_url = "https://openrouter.ai/api/v1"
|
|
48
|
+
base_url_env = "OPENROUTER_BASE_URL"
|
|
49
|
+
api_key_env = "OPENROUTER_API_KEY"
|
|
50
|
+
model_env = "GEMINI_MODEL"
|
|
51
|
+
local = false
|
|
52
|
+
response_headers = ["x-request-id"]
|
|
53
|
+
|
|
54
|
+
[endpoints.llama]
|
|
55
|
+
base_url = "https://openrouter.ai/api/v1"
|
|
56
|
+
base_url_env = "OPENROUTER_BASE_URL"
|
|
57
|
+
api_key_env = "OPENROUTER_API_KEY"
|
|
58
|
+
model_env = "LLAMA_MODEL"
|
|
59
|
+
local = false
|
|
60
|
+
response_headers = ["x-request-id"]
|
|
61
|
+
|
|
62
|
+
[endpoints.qwen]
|
|
63
|
+
base_url = "https://openrouter.ai/api/v1"
|
|
64
|
+
base_url_env = "OPENROUTER_BASE_URL"
|
|
65
|
+
api_key_env = "OPENROUTER_API_KEY"
|
|
66
|
+
model_env = "QWEN_MODEL"
|
|
67
|
+
local = false
|
|
68
|
+
response_headers = ["x-request-id"]
|
|
69
|
+
|
|
70
|
+
[endpoints.glm]
|
|
71
|
+
base_url = "https://openrouter.ai/api/v1"
|
|
72
|
+
base_url_env = "OPENROUTER_BASE_URL"
|
|
73
|
+
api_key_env = "OPENROUTER_API_KEY"
|
|
74
|
+
model_env = "GLM_MODEL"
|
|
75
|
+
local = false
|
|
76
|
+
response_headers = ["x-request-id"]
|
|
77
|
+
|
|
78
|
+
# The Anthropic Messages API, direct (D-656). The SDK appends /v1/messages to the base URL. Model ids are the bare API
|
|
79
|
+
# ids (claude-opus-5-5, claude-sonnet-5-5, claude-haiku-5-5, ...), never date-suffixed. Recorded header values: the
|
|
80
|
+
# API version and beta flags sent, and the request id and the two retry headers the SDK's retry decision reads. The
|
|
81
|
+
# organisation id and the rate-limit headers keep their names only; x-api-key is on the hard denylist.
|
|
82
|
+
[endpoints.anthropic]
|
|
83
|
+
base_url = "https://api.anthropic.com"
|
|
84
|
+
base_url_env = "ANTHROPIC_BASE_URL"
|
|
85
|
+
api_key_env = "ANTHROPIC_API_KEY"
|
|
86
|
+
model_env = "ANTHROPIC_MODEL"
|
|
87
|
+
local = false
|
|
88
|
+
api = "anthropic_messages"
|
|
89
|
+
request_headers = ["anthropic-beta", "anthropic-version"]
|
|
90
|
+
response_headers = ["request-id", "retry-after", "x-should-retry"]
|
|
91
|
+
|
|
92
|
+
# Local OpenAI-compatible servers (N.2.1a; D-690): http on loopback is allowed and no key is needed (an optional
|
|
93
|
+
# `api_key_env` sends one when it is set, D-827). `server` names the server kind; `agentprov models` uses it to
|
|
94
|
+
# fingerprint the server that answers (two kinds share a default port: 8000 omlx/vllm, 8080 mlx_lm/llamacpp) and to
|
|
95
|
+
# read the model identity it exposes. Base URLs are pinned to 127.0.0.1, not `localhost`, which may resolve to ::1.
|
|
96
|
+
# Tool calls: whether a model's replies carry structured `tool_calls` depends on the server, its flags and the model's
|
|
97
|
+
# template; the preflight probe decides (D.10.9 A4), and text-encoded calls need
|
|
98
|
+
# `--allow-text-tool-calls`. `server_max_concurrency` is set only where the server's default is fixed; LM Studio and
|
|
99
|
+
# llama.cpp report their own value, and `--server-max-concurrency` overrides.
|
|
100
|
+
|
|
101
|
+
# Ollama. Structured tool calls for models whose capabilities include "tools"; others are refused by the server (400).
|
|
102
|
+
# OLLAMA_NUM_PARALLEL depends on the version and memory, and is not reported, so no concurrency default is set.
|
|
103
|
+
[endpoints.ollama]
|
|
104
|
+
base_url = "http://127.0.0.1:11434/v1"
|
|
105
|
+
base_url_env = "LOCAL_LLM_BASE_URL"
|
|
106
|
+
model_env = "LOCAL_MODEL_ID"
|
|
107
|
+
local = true
|
|
108
|
+
server = "ollama"
|
|
109
|
+
|
|
110
|
+
# oMLX (MLX inference server, Apple Silicon). The model id is the oMLX model directory name or alias (`--model-id`).
|
|
111
|
+
# `server_max_concurrency` is oMLX's `--max-concurrent-requests` default. Structured tool calls through oMLX's parser.
|
|
112
|
+
# `api_key_env` is optional on a local entry (D-827): oMLX can be set up with an API key. When `OMLX_API_KEY` is set
|
|
113
|
+
# (keychain first, then .env), it is sent as a bearer header to the loopback server, and never to a LAN address; when
|
|
114
|
+
# it is not set, no key is sent, as for every other local server. `model_env` (D-827 addendum): `OMLX_MODEL` in .env
|
|
115
|
+
# names the served model id (oMLX's own id, e.g. its folder name, not a Hugging Face repo id) when `--model-id` is not
|
|
116
|
+
# given; without either, a terminal offers a pick.
|
|
117
|
+
[endpoints.omlx]
|
|
118
|
+
base_url = "http://127.0.0.1:8000/v1"
|
|
119
|
+
base_url_env = "OMLX_BASE_URL"
|
|
120
|
+
api_key_env = "OMLX_API_KEY"
|
|
121
|
+
model_env = "OMLX_MODEL"
|
|
122
|
+
local = true
|
|
123
|
+
server = "omlx"
|
|
124
|
+
server_max_concurrency = 8
|
|
125
|
+
|
|
126
|
+
# LM Studio's server (`lms server start`). Native tool calls for models with `trained_for_tool_use`; for other models LM
|
|
127
|
+
# Studio adds its own tool-use system prompt on the server (invisible to the wire capture), which `run` refuses unless
|
|
128
|
+
# `--allow-text-tool-calls`. The parallel-request limit is read from the loaded instance (`/api/v1/models`).
|
|
129
|
+
[endpoints.lmstudio]
|
|
130
|
+
base_url = "http://127.0.0.1:1234/v1"
|
|
131
|
+
base_url_env = "LMSTUDIO_BASE_URL"
|
|
132
|
+
local = true
|
|
133
|
+
server = "lmstudio"
|
|
134
|
+
|
|
135
|
+
# mlx_lm.server (MLX LM). The model id is a Hugging Face repo id or a local path. Requests are served one at a time by
|
|
136
|
+
# default. Tool calls depend on the mlx-lm version and the model's chat template; text-encoded calls are common.
|
|
137
|
+
[endpoints.mlx_lm]
|
|
138
|
+
base_url = "http://127.0.0.1:8080/v1"
|
|
139
|
+
base_url_env = "MLX_LM_BASE_URL"
|
|
140
|
+
local = true
|
|
141
|
+
server = "mlx_lm"
|
|
142
|
+
server_max_concurrency = 1
|
|
143
|
+
|
|
144
|
+
# llama.cpp `llama-server`. Structured tool calls only when started with `--jinja`; the slot count is read from /props.
|
|
145
|
+
[endpoints.llamacpp]
|
|
146
|
+
base_url = "http://127.0.0.1:8080/v1"
|
|
147
|
+
base_url_env = "LLAMACPP_BASE_URL"
|
|
148
|
+
local = true
|
|
149
|
+
server = "llamacpp"
|
|
150
|
+
|
|
151
|
+
# vLLM's OpenAI-compatible server. Structured tool calls only with `--enable-auto-tool-choice --tool-call-parser <p>`.
|
|
152
|
+
[endpoints.vllm]
|
|
153
|
+
base_url = "http://127.0.0.1:8000/v1"
|
|
154
|
+
base_url_env = "VLLM_BASE_URL"
|
|
155
|
+
local = true
|
|
156
|
+
server = "vllm"
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Environment-state digests, snapshots and diffs (C.13.3 ``pass.start``/``pass.end``/``state``; B CAP-18, F-12).
|
|
2
|
+
|
|
3
|
+
Extracted from the baseline ``agentdojo_prov/envstate.py`` (rules E-1...E-5 of the audit) and changed in exactly the
|
|
4
|
+
ways the specification decided:
|
|
5
|
+
|
|
6
|
+
* **E-1** ``dump_state``: a duck-typed ``model_dump(mode="json")`` (pydantic), else a JSON round trip with ``default=str``.
|
|
7
|
+
No benchmark import.
|
|
8
|
+
* **E-2 (changed)** the canonical form is **JCS** (RFC 8785) of the dump, and the digest is a mapping
|
|
9
|
+
``{"sha256": <hex, no prefix>, "canon": "jcs-rfc8785"}`` that is the ``env`` member of ``pass.start`` as is
|
|
10
|
+
(C.13.8). The baseline's ``sort_keys``/``ensure_ascii=False`` form is *not* reproduced; baseline digests are
|
|
11
|
+
imported with ``canon: "adprov.envstate@1"`` (C.3) and never recomputed. Values JCS cannot carry are encoded as in
|
|
12
|
+
C.11.2-C.11.3 before hashing: integers beyond +-(2^53-1) as ``{"$int": "<decimal>"}``, ``NaN``/``Infinity``/
|
|
13
|
+
``-0.0`` as ``{"$float": ...}``, lone surrogates as ``{"$utf16": <base64>}``, objects whose first-level member
|
|
14
|
+
starts with ``$`` as ``{"$lit": <object>}``.
|
|
15
|
+
* **E-3** keys are walked in sorted order; list elements are matched by canonical content (multiset), and a
|
|
16
|
+
same-multiset different-order list gives one ``reordered`` entry.
|
|
17
|
+
* **E-4 (F-12 fixed)** values are cut at ``VALUE_CAP`` characters and entries at ``ENTRY_CAP``; ``truncated`` is
|
|
18
|
+
true if **any** entry or value was cut (the baseline ignored value cuts).
|
|
19
|
+
* **E-5** the end record carries ``changed`` and a ``diff`` only when the state changed.
|
|
20
|
+
|
|
21
|
+
Paths in diff entries are RFC 6901 JSON Pointers (the baseline used dotted paths, ambiguous for keys containing a
|
|
22
|
+
dot); an element added to or removed from a list is reported at the pointer of the *list* with ``"element": true``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import base64
|
|
28
|
+
import hashlib
|
|
29
|
+
import json
|
|
30
|
+
import math
|
|
31
|
+
from typing import Any, Final
|
|
32
|
+
|
|
33
|
+
import rfc8785
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"CANON",
|
|
37
|
+
"DIFF_FORMAT",
|
|
38
|
+
"ENTRY_CAP",
|
|
39
|
+
"VALUE_CAP",
|
|
40
|
+
"diff",
|
|
41
|
+
"digest",
|
|
42
|
+
"dump_state",
|
|
43
|
+
"end_env",
|
|
44
|
+
"normalise",
|
|
45
|
+
"snapshot",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
CANON: Final = "jcs-rfc8785"
|
|
49
|
+
DIFF_FORMAT: Final = "agentprov.envdiff@1"
|
|
50
|
+
VALUE_CAP: Final = 400
|
|
51
|
+
ENTRY_CAP: Final = 200
|
|
52
|
+
_MAX_SAFE_INT: Final = 2**53 - 1
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def dump_state(env: Any) -> Any:
|
|
56
|
+
"""The environment as plain JSON-able data (E-1): a pydantic-style ``model_dump(mode="json")`` or a JSON round trip
|
|
57
|
+
with ``default=str``."""
|
|
58
|
+
if hasattr(env, "model_dump"):
|
|
59
|
+
return env.model_dump(mode="json")
|
|
60
|
+
return json.loads(json.dumps(env, default=str))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def normalise(value: Any) -> Any:
|
|
64
|
+
"""Encode what JCS cannot carry (C.11.2-C.11.3) so ``rfc8785`` accepts any plain JSON-able value."""
|
|
65
|
+
if isinstance(value, bool) or value is None:
|
|
66
|
+
return value
|
|
67
|
+
if isinstance(value, int):
|
|
68
|
+
return value if abs(value) <= _MAX_SAFE_INT else {"$int": str(value)}
|
|
69
|
+
if isinstance(value, float):
|
|
70
|
+
if math.isnan(value):
|
|
71
|
+
return {"$float": "NaN"}
|
|
72
|
+
if math.isinf(value):
|
|
73
|
+
return {"$float": "Infinity" if value > 0 else "-Infinity"}
|
|
74
|
+
if value == 0.0 and math.copysign(1.0, value) < 0:
|
|
75
|
+
return {"$float": "-0"}
|
|
76
|
+
return value
|
|
77
|
+
if isinstance(value, str):
|
|
78
|
+
try:
|
|
79
|
+
value.encode("utf-8")
|
|
80
|
+
except UnicodeEncodeError:
|
|
81
|
+
units = value.encode("utf-16-be", "surrogatepass")
|
|
82
|
+
return {"$utf16": base64.b64encode(units).decode("ascii")}
|
|
83
|
+
return value
|
|
84
|
+
if isinstance(value, dict):
|
|
85
|
+
out = {normalise_key(k): normalise(v) for k, v in value.items()}
|
|
86
|
+
if any(k.startswith("$") for k in out):
|
|
87
|
+
return {"$lit": out}
|
|
88
|
+
return out
|
|
89
|
+
if isinstance(value, (list, tuple)):
|
|
90
|
+
return [normalise(v) for v in value]
|
|
91
|
+
raise TypeError(f"cannot canonicalise a {type(value).__name__}; dump_state() the environment first")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def normalise_key(key: Any) -> str:
|
|
95
|
+
if not isinstance(key, str):
|
|
96
|
+
raise TypeError("object keys must be strings; dump_state() the environment first")
|
|
97
|
+
return key
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def snapshot(dump: Any) -> bytes:
|
|
101
|
+
"""The canonical bytes of an environment dump: JCS of :func:`normalise` (store as a blob for a full snapshot)."""
|
|
102
|
+
return rfc8785.dumps(normalise(dump))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def digest(dump: Any) -> dict[str, str]:
|
|
106
|
+
"""``{"sha256": <hex>, "canon": "jcs-rfc8785"}`` of the dump (E-2): the ``env`` of ``pass.start``."""
|
|
107
|
+
return {"sha256": hashlib.sha256(snapshot(dump)).hexdigest(), "canon": CANON}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _canon_text(value: Any) -> str:
|
|
111
|
+
"""JCS text of an already normalised value."""
|
|
112
|
+
return rfc8785.dumps(value).decode("utf-8")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _ptr(parts: list[str]) -> str:
|
|
116
|
+
return "".join("/" + p.replace("~", "~0").replace("/", "~1") for p in parts)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _cut(value: Any, cuts: list[bool]) -> str:
|
|
120
|
+
text = _canon_text(value)
|
|
121
|
+
if len(text) > VALUE_CAP:
|
|
122
|
+
cuts[0] = True
|
|
123
|
+
return text[:VALUE_CAP] + "…"
|
|
124
|
+
return text
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _walk(before: Any, after: Any, path: list[str], out: list[dict[str, Any]], cuts: list[bool]) -> None:
|
|
128
|
+
if isinstance(before, dict) and isinstance(after, dict):
|
|
129
|
+
for key in sorted(set(before) | set(after), key=str):
|
|
130
|
+
sub = [*path, str(key)]
|
|
131
|
+
if key not in after:
|
|
132
|
+
out.append({"op": "removed", "path": _ptr(sub), "before": _cut(before[key], cuts)})
|
|
133
|
+
elif key not in before:
|
|
134
|
+
out.append({"op": "added", "path": _ptr(sub), "after": _cut(after[key], cuts)})
|
|
135
|
+
else:
|
|
136
|
+
_walk(before[key], after[key], sub, out, cuts)
|
|
137
|
+
elif isinstance(before, list) and isinstance(after, list):
|
|
138
|
+
# elements are matched by content, not position: deleting the first element must not report every later one
|
|
139
|
+
canon_before = [_canon_text(x) for x in before]
|
|
140
|
+
canon_after = [_canon_text(x) for x in after]
|
|
141
|
+
remaining = list(canon_after)
|
|
142
|
+
gone = []
|
|
143
|
+
for item, c in zip(before, canon_before, strict=True):
|
|
144
|
+
if c in remaining:
|
|
145
|
+
remaining.remove(c)
|
|
146
|
+
else:
|
|
147
|
+
gone.append(item)
|
|
148
|
+
if not gone and not remaining and canon_before != canon_after:
|
|
149
|
+
out.append({"op": "reordered", "path": _ptr(path)})
|
|
150
|
+
for item in gone:
|
|
151
|
+
out.append({"op": "removed", "path": _ptr(path), "element": True, "before": _cut(item, cuts)})
|
|
152
|
+
for c in remaining:
|
|
153
|
+
out.append({"op": "added", "path": _ptr(path), "element": True, "after": _cut(json.loads(c), cuts)})
|
|
154
|
+
elif _canon_text(before) != _canon_text(after):
|
|
155
|
+
out.append({"op": "changed", "path": _ptr(path), "before": _cut(before, cuts), "after": _cut(after, cuts)})
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def diff(before: Any, after: Any) -> dict[str, Any]:
|
|
159
|
+
"""What a pass changed, as the ``env.diff`` mapping ``{"format", "value": {"n_changes", "entries"}, "truncated"}``.
|
|
160
|
+
|
|
161
|
+
Lossy by design (value and entry caps); the digests, not the diff, decide whether two states are equal.
|
|
162
|
+
"""
|
|
163
|
+
entries: list[dict[str, Any]] = []
|
|
164
|
+
cuts = [False]
|
|
165
|
+
_walk(normalise(before), normalise(after), [], entries, cuts)
|
|
166
|
+
return {
|
|
167
|
+
"format": DIFF_FORMAT,
|
|
168
|
+
"value": {"n_changes": len(entries), "entries": entries[:ENTRY_CAP]},
|
|
169
|
+
"truncated": cuts[0] or len(entries) > ENTRY_CAP,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def end_env(before: Any, after: Any, *, snapshot_ref: dict[str, Any] | None = None, with_diff: bool = True) -> dict[str, Any]:
|
|
174
|
+
"""The ``env`` member of ``pass.end`` (E-5): the digest of ``after``, ``changed`` (digest differs from ``before``),
|
|
175
|
+
a ``diff`` only when changed (and ``with_diff``), and an optional ``snapshot`` blob reference."""
|
|
176
|
+
after_digest = digest(after)
|
|
177
|
+
out: dict[str, Any] = {**after_digest, "changed": digest(before) != after_digest}
|
|
178
|
+
if out["changed"] and with_diff:
|
|
179
|
+
out["diff"] = diff(before, after)
|
|
180
|
+
if snapshot_ref is not None:
|
|
181
|
+
out["snapshot"] = snapshot_ref
|
|
182
|
+
return out
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Exceptions of ``agentprov_capture``. Each carries a stable dotted ``code``; messages never carry secret values."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = ["CaptureError", "CaptureLogError", "CaptureWriteError", "RedactionError"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CaptureError(Exception):
|
|
9
|
+
"""Base class. ``code`` is stable and dotted (``cap.<area>.<what>``)."""
|
|
10
|
+
|
|
11
|
+
code = "cap.error"
|
|
12
|
+
|
|
13
|
+
def __init__(self, message: str, *, code: str | None = None) -> None:
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
if code is not None:
|
|
16
|
+
self.code = code
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CaptureLogError(CaptureError):
|
|
20
|
+
"""The log file or its state does not allow the operation (exists, closed, poisoned, too large, ...)."""
|
|
21
|
+
|
|
22
|
+
code = "cap.log.error"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CaptureWriteError(CaptureError):
|
|
26
|
+
"""An event, blob or call violates the capture/0.1 model; nothing was written."""
|
|
27
|
+
|
|
28
|
+
code = "cap.write.invalid"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RedactionError(CaptureError):
|
|
32
|
+
"""Redaction failed or its configuration is unusable (G.6.4, fail closed): nothing was written.
|
|
33
|
+
|
|
34
|
+
The message never includes the content being scanned; the original exception is deliberately not chained.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
code = "cap.redact.failed"
|