toolbound 0.2.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,137 @@
1
+ """Expiring tokens for cursors, approval requests and approval grants.
2
+
3
+ * :meth:`Signer.sign` makes integrity-protected tokens (HMAC-SHA256). Their
4
+ payload is **readable** by anyone holding the token. Used for grants, which
5
+ hold only ids and digests.
6
+ * :meth:`Signer.seal` makes authenticated **encrypted** tokens (AES-256-GCM).
7
+ Used for cursors (which reach the model) and approval requests (which hold
8
+ arguments and trusted bound values).
9
+
10
+ Both kinds expire and are bound to a purpose prefix, so a token minted for one
11
+ purpose is never accepted for another.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import hashlib
18
+ import hmac
19
+ import json
20
+ import secrets
21
+ import time
22
+ from typing import Any
23
+
24
+ from cryptography.exceptions import InvalidTag
25
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
26
+
27
+ from .errors import ConfigurationError
28
+
29
+ _MIN_SECRET_BYTES = 32
30
+ _DEFAULT_MAX_TOKEN = 8192
31
+ _NONCE_BYTES = 12
32
+
33
+
34
+ def _b64(data: bytes) -> str:
35
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
36
+
37
+
38
+ def _unb64(text: str) -> bytes:
39
+ return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
40
+
41
+
42
+ def canonical_digest(value: Any) -> str:
43
+ """SHA-256 over canonical JSON (sorted keys, no whitespace)."""
44
+ raw = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
45
+ return hashlib.sha256(raw.encode()).hexdigest()
46
+
47
+
48
+ class Signer:
49
+ """Signs small JSON payloads. Tokens look like ``<prefix>.<payload>.<mac>``.
50
+
51
+ Tokens are integrity-protected, not encrypted: never put secrets in them.
52
+ """
53
+
54
+ def __init__(self, secret: bytes | str | None) -> None:
55
+ if secret is None:
56
+ key = secrets.token_bytes(32)
57
+ self.ephemeral = True
58
+ else:
59
+ key = secret.encode() if isinstance(secret, str) else bytes(secret)
60
+ self.ephemeral = False
61
+ if len(key) < _MIN_SECRET_BYTES:
62
+ raise ConfigurationError("Gateway secret must be at least 32 bytes")
63
+ self._key = key
64
+ # Separate keys per primitive, derived from the configured secret.
65
+ self._mac_key = hmac.new(key, b"toolbound/v1/mac", hashlib.sha256).digest()
66
+ self._aead = AESGCM(hmac.new(key, b"toolbound/v1/seal", hashlib.sha256).digest())
67
+
68
+ def seal(self, prefix: str, payload: dict[str, Any], ttl_seconds: float) -> str:
69
+ """Encrypt and authenticate ``payload``; the prefix is bound as associated data."""
70
+ body = dict(payload)
71
+ body["exp"] = int(time.time() + ttl_seconds)
72
+ plaintext = json.dumps(body, sort_keys=True, separators=(",", ":")).encode()
73
+ nonce = secrets.token_bytes(_NONCE_BYTES)
74
+ ciphertext = self._aead.encrypt(nonce, plaintext, prefix.encode())
75
+ return f"{prefix}.{_b64(nonce + ciphertext)}"
76
+
77
+ def unseal(
78
+ self, prefix: str, token: Any, *, max_length: int = _DEFAULT_MAX_TOKEN
79
+ ) -> dict[str, Any] | None:
80
+ """Return the payload, or None if malformed, forged, for another purpose, or expired."""
81
+ if not isinstance(token, str) or len(token) > max_length:
82
+ return None
83
+ head, sep, data = token.partition(".")
84
+ if not sep or head != prefix or "." in data:
85
+ return None
86
+ try:
87
+ raw = _unb64(data)
88
+ except ValueError:
89
+ return None
90
+ if len(raw) <= _NONCE_BYTES:
91
+ return None
92
+ try:
93
+ plaintext = self._aead.decrypt(raw[:_NONCE_BYTES], raw[_NONCE_BYTES:], prefix.encode())
94
+ payload = json.loads(plaintext)
95
+ except (InvalidTag, ValueError, UnicodeDecodeError):
96
+ return None
97
+ return _unexpired(payload)
98
+
99
+ def sign(self, prefix: str, payload: dict[str, Any], ttl_seconds: float) -> str:
100
+ body = dict(payload)
101
+ body["exp"] = int(time.time() + ttl_seconds)
102
+ encoded = _b64(json.dumps(body, sort_keys=True, separators=(",", ":")).encode())
103
+ mac = self._mac(prefix, encoded)
104
+ return f"{prefix}.{encoded}.{mac}"
105
+
106
+ def verify(
107
+ self, prefix: str, token: Any, *, max_length: int = _DEFAULT_MAX_TOKEN
108
+ ) -> dict[str, Any] | None:
109
+ """Return the payload, or None if the token is malformed, forged or expired."""
110
+ if not isinstance(token, str) or len(token) > max_length:
111
+ return None
112
+ parts = token.split(".")
113
+ if len(parts) != 3 or parts[0] != prefix:
114
+ return None
115
+ expected = self._mac(prefix, parts[1])
116
+ if not hmac.compare_digest(expected, parts[2]):
117
+ return None
118
+ try:
119
+ payload = json.loads(_unb64(parts[1]))
120
+ except (ValueError, UnicodeDecodeError):
121
+ return None
122
+ return _unexpired(payload)
123
+
124
+ def _mac(self, prefix: str, encoded: str) -> str:
125
+ digest = hmac.new(self._mac_key, f"{prefix}.{encoded}".encode(), hashlib.sha256).digest()
126
+ return _b64(digest[:24])
127
+
128
+ def __repr__(self) -> str:
129
+ return f"Signer(ephemeral={self.ephemeral})"
130
+
131
+
132
+ def _unexpired(payload: Any) -> dict[str, Any] | None:
133
+ if not isinstance(payload, dict) or not isinstance(payload.get("exp"), int):
134
+ return None
135
+ if payload["exp"] < time.time():
136
+ return None
137
+ return payload
@@ -0,0 +1,102 @@
1
+ """Default in-memory :class:`~toolbound.core.ports.StateStore`, and claim helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import heapq
6
+ import logging
7
+ import threading
8
+ import time
9
+
10
+ from .errors import ErrorCode, StateStoreError, ToolFailure
11
+ from .ports import StateStore
12
+
13
+ logger = logging.getLogger("toolbound")
14
+
15
+
16
+ class MemoryStateStore:
17
+ """Per-process TTL set. Suitable for a single worker or for tests.
18
+
19
+ It never forgets a key before its TTL expires: when ``max_entries`` live
20
+ keys exist, new claims raise :class:`StateStoreError` and Toolbound refuses
21
+ the call (fail closed) instead of evicting a live key, which would let an
22
+ approval or call id be used twice.
23
+
24
+ Keys are partitioned by their first two ``:``-separated segments. Toolbound's
25
+ keys are ``call:<actor+tenant>:…`` and ``approval:<actor+tenant>:…``, so one
26
+ actor filling its partition (``max_per_partition``) cannot block anyone else.
27
+ ``max_entries`` bounds total memory.
28
+
29
+ It is thread-safe and not bound to an event loop. With several processes,
30
+ replay protection needs a shared store; see ``docs/security.md`` for a Redis
31
+ recipe.
32
+ """
33
+
34
+ def __init__(self, *, max_entries: int = 200_000, max_per_partition: int = 10_000) -> None:
35
+ if max_entries < 1 or max_per_partition < 1:
36
+ raise ValueError("max_entries and max_per_partition must be at least 1")
37
+ self._entries: dict[str, float] = {}
38
+ self._expiry: list[tuple[float, str]] = []
39
+ self._partitions: dict[str, int] = {}
40
+ self._max = max_entries
41
+ self._max_partition = max_per_partition
42
+ self._lock = threading.Lock()
43
+
44
+ async def claim(self, key: str, ttl_seconds: float) -> bool:
45
+ with self._lock:
46
+ now = time.monotonic()
47
+ self._evict(now)
48
+ expiry = self._entries.get(key)
49
+ if expiry is not None and expiry > now:
50
+ return False
51
+ partition = _partition(key)
52
+ if expiry is None and self._partitions.get(partition, 0) >= self._max_partition:
53
+ raise StateStoreError(
54
+ f"MemoryStateStore partition is full ({self._max_partition} live keys); "
55
+ "refusing to evict unexpired replay-protection entries"
56
+ )
57
+ if expiry is None and len(self._entries) >= self._max:
58
+ raise StateStoreError(
59
+ f"MemoryStateStore is full ({self._max} live keys); refusing to evict "
60
+ "unexpired replay-protection entries"
61
+ )
62
+ deadline = now + ttl_seconds
63
+ if expiry is None:
64
+ self._partitions[partition] = self._partitions.get(partition, 0) + 1
65
+ self._entries[key] = deadline
66
+ heapq.heappush(self._expiry, (deadline, key))
67
+ return True
68
+
69
+ def __len__(self) -> int:
70
+ return len(self._entries)
71
+
72
+ def _evict(self, now: float) -> None:
73
+ while self._expiry and self._expiry[0][0] <= now:
74
+ deadline, key = heapq.heappop(self._expiry)
75
+ if self._entries.get(key) == deadline:
76
+ del self._entries[key]
77
+ partition = _partition(key)
78
+ left = self._partitions.get(partition, 1) - 1
79
+ if left:
80
+ self._partitions[partition] = left
81
+ else:
82
+ self._partitions.pop(partition, None)
83
+
84
+
85
+ def _partition(key: str) -> str:
86
+ return ":".join(key.split(":", 2)[:2])
87
+
88
+
89
+ async def claim_or_fail(store: StateStore, key: str, ttl_seconds: float) -> bool:
90
+ """``store.claim`` that fails closed: any store error stops the call."""
91
+ try:
92
+ return bool(await store.claim(key, ttl_seconds))
93
+ except Exception as exc:
94
+ logger.error("state store claim failed: %s", type(exc).__name__)
95
+ full = isinstance(exc, StateStoreError)
96
+ raise ToolFailure(
97
+ ErrorCode.INTERNAL_ERROR,
98
+ "replay protection is unavailable, so the call was not executed"
99
+ + (" (too many recent calls for this user)" if full else ""),
100
+ retryable=not full,
101
+ request_sent=False,
102
+ ) from None
@@ -0,0 +1,24 @@
1
+ """Optional tokenizer-backed estimators (install ``toolbound[tiktoken]``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class TiktokenEstimator:
9
+ """Counts tokens with a tiktoken encoding (default ``o200k_base``).
10
+
11
+ tiktoken implements OpenAI tokenizers. For other model families it is still
12
+ a closer approximation than byte counting, but it is not exact.
13
+ """
14
+
15
+ def __init__(self, encoding: str = "o200k_base") -> None:
16
+ try:
17
+ import tiktoken
18
+ except ImportError as exc: # pragma: no cover - environment dependent
19
+ raise ImportError("pip install 'toolbound[tiktoken]'") from exc
20
+ self._encoding: Any = tiktoken.get_encoding(encoding)
21
+ self.name = encoding
22
+
23
+ def count(self, text: str) -> int:
24
+ return len(self._encoding.encode(text, disallowed_special=()))
@@ -0,0 +1,202 @@
1
+ """Load declarative contracts (YAML or JSON) into :class:`Operation` objects.
2
+
3
+ Files are validated against :data:`CONTRACT_SCHEMA` and then against the same
4
+ checks as Python-defined contracts. Files with ``status: draft`` (for example
5
+ generated by an importer) are refused until a reviewer changes the status.
6
+ Custom Python transforms cannot be referenced from files except by name via
7
+ ``Gateway(transforms={...})``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from pathlib import Path
14
+ from typing import Any, cast
15
+
16
+ from jsonschema import Draft202012Validator
17
+
18
+ from toolbound.core.contracts import (
19
+ ApiKeyAuth,
20
+ Auth,
21
+ BasicAuth,
22
+ BearerAuth,
23
+ Effect,
24
+ Field,
25
+ FieldSpec,
26
+ Limits,
27
+ NetworkPolicy,
28
+ NoAuth,
29
+ Operation,
30
+ Output,
31
+ Pagination,
32
+ RateLimit,
33
+ RetryPolicy,
34
+ Service,
35
+ )
36
+ from toolbound.core.errors import ConfigurationError
37
+ from toolbound.core.paths import parse_path
38
+
39
+ from .meta_schema import CONTRACT_SCHEMA
40
+
41
+ _VALIDATOR = Draft202012Validator(CONTRACT_SCHEMA)
42
+ _SUFFIXES = (".yaml", ".yml", ".json")
43
+
44
+ __all__ = ["CONTRACT_SCHEMA", "load_contract_file", "load_contracts", "parse_contract"]
45
+
46
+
47
+ def load_contracts(path: str | Path) -> list[Operation]:
48
+ """Load one file, or every ``*.yaml|*.yml|*.json`` file in a directory (sorted)."""
49
+ root = Path(path)
50
+ if root.is_dir():
51
+ files = sorted(p for p in root.iterdir() if p.suffix in _SUFFIXES and p.is_file())
52
+ if not files:
53
+ raise ConfigurationError(f"no contract files found in {root}")
54
+ operations: list[Operation] = []
55
+ for file in files:
56
+ operations.extend(load_contract_file(file))
57
+ return operations
58
+ return load_contract_file(root)
59
+
60
+
61
+ def load_contract_file(path: str | Path) -> list[Operation]:
62
+ file = Path(path)
63
+ text = file.read_text(encoding="utf-8")
64
+ if file.suffix == ".json":
65
+ document = json.loads(text)
66
+ elif file.suffix in (".yaml", ".yml"):
67
+ try:
68
+ import yaml
69
+ except ImportError as exc: # pragma: no cover - depends on environment
70
+ raise ConfigurationError(
71
+ "YAML contracts need PyYAML: pip install 'toolbound[yaml]'"
72
+ ) from exc
73
+ document = yaml.safe_load(text)
74
+ else:
75
+ raise ConfigurationError(f"unsupported contract file type: {file.name}")
76
+ try:
77
+ return parse_contract(document)
78
+ except ConfigurationError as exc:
79
+ raise ConfigurationError(f"{file}: {exc}") from exc
80
+
81
+
82
+ def parse_contract(document: Any) -> list[Operation]:
83
+ """Validate a parsed contract document and build its operations."""
84
+ errors = sorted(_VALIDATOR.iter_errors(document), key=lambda e: list(e.absolute_path))
85
+ if errors:
86
+ first = errors[0]
87
+ where = "/".join(str(p) for p in first.absolute_path) or "(root)"
88
+ raise ConfigurationError(f"invalid contract at {where}: {first.message}")
89
+ if document.get("status", "active") == "draft":
90
+ raise ConfigurationError(
91
+ "contract is marked 'status: draft'; review it and set 'status: active' to load it"
92
+ )
93
+ service = _service(document["service"])
94
+ return [_operation(op, service) for op in document["operations"]]
95
+
96
+
97
+ def _service(raw: dict[str, Any]) -> Service:
98
+ return Service(
99
+ name=raw["name"],
100
+ base_url=raw["base_url"],
101
+ auth=_auth(raw.get("auth")),
102
+ headers=raw.get("headers", {}),
103
+ network=_network(raw.get("network", {})),
104
+ limits=Limits(**raw.get("limits", {})),
105
+ retry=_retry(raw.get("retry", {})),
106
+ )
107
+
108
+
109
+ def _auth(raw: dict[str, Any] | None) -> Auth:
110
+ if raw is None or raw["type"] == "none":
111
+ return NoAuth()
112
+ credential = raw.get("credential")
113
+ if not credential:
114
+ raise ConfigurationError(f"auth type {raw['type']!r} needs 'credential'")
115
+ if raw["type"] == "bearer":
116
+ return BearerAuth(credential)
117
+ if raw["type"] == "basic":
118
+ return BasicAuth(credential)
119
+ if "query" in raw:
120
+ return ApiKeyAuth(credential, header=None, query=raw["query"])
121
+ return ApiKeyAuth(credential, header=raw.get("header", "X-API-Key"))
122
+
123
+
124
+ def _network(raw: dict[str, Any]) -> NetworkPolicy:
125
+ return NetworkPolicy(
126
+ allow_hosts=tuple(raw.get("allow_hosts", ())),
127
+ allow_ports=tuple(raw.get("allow_ports", ())),
128
+ allow_http=raw.get("allow_http", False),
129
+ allow_networks=tuple(raw.get("allow_networks", ())),
130
+ follow_redirects=raw.get("follow_redirects", False),
131
+ max_redirects=raw.get("max_redirects", 3),
132
+ )
133
+
134
+
135
+ def _retry(raw: dict[str, Any]) -> RetryPolicy:
136
+ data = dict(raw)
137
+ if "retry_statuses" in data:
138
+ data["retry_statuses"] = frozenset(data["retry_statuses"])
139
+ return RetryPolicy(**data)
140
+
141
+
142
+ def _shape(raw: dict[str, Any] | None) -> dict[str, FieldSpec] | None:
143
+ if raw is None:
144
+ return None
145
+ shape: dict[str, FieldSpec] = {}
146
+ for key, value in raw.items():
147
+ parse_path(value if isinstance(value, str) else value["from"]) # fail early
148
+ if isinstance(value, str):
149
+ shape[key] = value
150
+ else:
151
+ kwargs: dict[str, Any] = {
152
+ "source": value["from"],
153
+ "max_chars": value.get("max_chars"),
154
+ "max_items": value.get("max_items"),
155
+ "transform": value.get("transform"),
156
+ "shape": _shape(value.get("shape")),
157
+ }
158
+ if "default" in value:
159
+ kwargs["default"] = value["default"]
160
+ shape[key] = Field(**kwargs)
161
+ return shape
162
+
163
+
164
+ def _output(raw: dict[str, Any] | None) -> Output:
165
+ if not raw:
166
+ return Output()
167
+ data = dict(raw)
168
+ parse_path(raw.get("root", "$"))
169
+ data["shape"] = _shape(raw.get("shape"))
170
+ data["error_shape"] = _shape(raw.get("error_shape"))
171
+ if "required" in data:
172
+ data["required"] = tuple(data["required"])
173
+ if "redact_keys" in data:
174
+ data["redact_keys"] = tuple(data["redact_keys"])
175
+ return Output(**data)
176
+
177
+
178
+ def _operation(raw: dict[str, Any], service: Service) -> Operation:
179
+ rate = raw.get("rate_limit")
180
+ pagination = raw.get("pagination")
181
+ return Operation(
182
+ name=raw["name"],
183
+ description=raw["description"],
184
+ service=service,
185
+ method=raw.get("method", "GET"),
186
+ path=raw["path"],
187
+ effect=cast(Effect, raw.get("effect")), # None: inferred from the method
188
+ input=raw.get("input", {}),
189
+ bind=raw.get("bind", {}),
190
+ required_scopes=tuple(raw.get("required_scopes", ())),
191
+ tags=tuple(raw.get("tags", ())),
192
+ group=raw.get("group"),
193
+ requires_approval=raw.get("requires_approval"),
194
+ rate_limit=RateLimit(**rate) if rate else None,
195
+ query=tuple(raw["query"]) if "query" in raw else None,
196
+ body=tuple(raw["body"]) if "body" in raw else None,
197
+ body_format=raw.get("body_format", "json"),
198
+ headers=raw.get("headers", {}),
199
+ idempotency_header=raw.get("idempotency_header"),
200
+ pagination=Pagination(**pagination) if pagination else None,
201
+ output=_output(raw.get("output")),
202
+ )
@@ -0,0 +1,162 @@
1
+ """JSON Schema for declarative contract files (format version 1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ _FIELD: dict[str, Any] = {
8
+ "oneOf": [
9
+ {"type": "string"},
10
+ {
11
+ "type": "object",
12
+ "additionalProperties": False,
13
+ "required": ["from"],
14
+ "properties": {
15
+ "from": {"type": "string"},
16
+ "max_chars": {"type": "integer", "minimum": 1},
17
+ "max_items": {"type": "integer", "minimum": 1},
18
+ "transform": {"type": "string"},
19
+ "default": {},
20
+ "shape": {"$ref": "#/$defs/shape"},
21
+ },
22
+ },
23
+ ]
24
+ }
25
+
26
+ CONTRACT_SCHEMA: dict[str, Any] = {
27
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
28
+ "title": "Toolbound contract file",
29
+ "type": "object",
30
+ "additionalProperties": False,
31
+ "required": ["version", "service", "operations"],
32
+ "properties": {
33
+ "version": {"const": 1},
34
+ "status": {"enum": ["active", "draft"]},
35
+ "service": {
36
+ "type": "object",
37
+ "additionalProperties": False,
38
+ "required": ["name", "base_url"],
39
+ "properties": {
40
+ "name": {"type": "string"},
41
+ "base_url": {"type": "string"},
42
+ "auth": {
43
+ "type": "object",
44
+ "additionalProperties": False,
45
+ "required": ["type"],
46
+ "properties": {
47
+ "type": {"enum": ["none", "bearer", "api_key", "basic"]},
48
+ "credential": {"type": "string"},
49
+ "header": {"type": "string"},
50
+ "query": {"type": "string"},
51
+ },
52
+ },
53
+ "headers": {"type": "object", "additionalProperties": {"type": "string"}},
54
+ "network": {
55
+ "type": "object",
56
+ "additionalProperties": False,
57
+ "properties": {
58
+ "allow_hosts": {"type": "array", "items": {"type": "string"}},
59
+ "allow_ports": {"type": "array", "items": {"type": "integer"}},
60
+ "allow_http": {"type": "boolean"},
61
+ "allow_networks": {"type": "array", "items": {"type": "string"}},
62
+ "follow_redirects": {"type": "boolean"},
63
+ "max_redirects": {"type": "integer", "minimum": 0},
64
+ },
65
+ },
66
+ "limits": {
67
+ "type": "object",
68
+ "additionalProperties": False,
69
+ "properties": {
70
+ "timeout": {"type": "number", "exclusiveMinimum": 0},
71
+ "connect_timeout": {"type": "number", "exclusiveMinimum": 0},
72
+ "max_request_bytes": {"type": "integer", "minimum": 1},
73
+ "max_response_bytes": {"type": "integer", "minimum": 1},
74
+ "max_concurrency": {"type": "integer", "minimum": 1},
75
+ "total_timeout": {"type": "number", "exclusiveMinimum": 0},
76
+ },
77
+ },
78
+ "retry": {
79
+ "type": "object",
80
+ "additionalProperties": False,
81
+ "properties": {
82
+ "max_attempts": {"type": "integer", "minimum": 1},
83
+ "backoff_base": {"type": "number", "minimum": 0},
84
+ "backoff_max": {"type": "number", "minimum": 0},
85
+ "retry_statuses": {"type": "array", "items": {"type": "integer"}},
86
+ "max_retry_after": {"type": "number", "minimum": 0},
87
+ },
88
+ },
89
+ },
90
+ },
91
+ "operations": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/operation"}},
92
+ },
93
+ "$defs": {
94
+ "shape": {"type": "object", "additionalProperties": _FIELD},
95
+ "operation": {
96
+ "type": "object",
97
+ "additionalProperties": False,
98
+ "required": ["name", "description", "path"],
99
+ "properties": {
100
+ "name": {"type": "string"},
101
+ "description": {"type": "string"},
102
+ "method": {"type": "string"},
103
+ "path": {"type": "string"},
104
+ "effect": {"enum": ["read", "write", "destructive"]},
105
+ "input": {"type": "object"},
106
+ "bind": {"type": "object"},
107
+ "required_scopes": {"type": "array", "items": {"type": "string"}},
108
+ "tags": {"type": "array", "items": {"type": "string"}},
109
+ "group": {"type": "string"},
110
+ "requires_approval": {"type": "boolean"},
111
+ "rate_limit": {
112
+ "type": "object",
113
+ "additionalProperties": False,
114
+ "required": ["calls", "per_seconds"],
115
+ "properties": {
116
+ "calls": {"type": "integer", "minimum": 1},
117
+ "per_seconds": {"type": "number", "exclusiveMinimum": 0},
118
+ "scope": {"enum": ["actor", "tenant", "global"]},
119
+ },
120
+ },
121
+ "query": {"type": "array", "items": {"type": "string"}},
122
+ "body": {"type": "array", "items": {"type": "string"}},
123
+ "body_format": {"enum": ["json", "form"]},
124
+ "headers": {"type": "object", "additionalProperties": {"type": "string"}},
125
+ "idempotency_header": {"type": "string"},
126
+ "pagination": {
127
+ "type": "object",
128
+ "additionalProperties": False,
129
+ "required": ["type"],
130
+ "properties": {
131
+ "type": {"enum": ["cursor", "link", "page"]},
132
+ "param": {"type": "string"},
133
+ "next_path": {"type": "string"},
134
+ "start": {"type": "integer"},
135
+ "page_size": {"type": "integer", "minimum": 1},
136
+ },
137
+ },
138
+ "output": {
139
+ "type": "object",
140
+ "additionalProperties": False,
141
+ "properties": {
142
+ "root": {"type": "string"},
143
+ "shape": {"$ref": "#/$defs/shape"},
144
+ "required": {"type": "array", "items": {"type": "string"}},
145
+ "max_items": {"type": "integer", "minimum": 1},
146
+ "max_chars": {"type": "integer", "minimum": 1},
147
+ "max_tokens": {"type": ["integer", "null"], "minimum": 16},
148
+ "overflow": {"enum": ["truncate", "reject"]},
149
+ "selectable": {"type": "boolean"},
150
+ "redact_keys": {"type": "array", "items": {"type": "string"}},
151
+ "redact_secrets": {"type": "boolean"},
152
+ "schema": {"type": "object"},
153
+ "schema_mode": {"enum": ["strict", "warn"]},
154
+ "error_shape": {"$ref": "#/$defs/shape"},
155
+ "accept_text": {"type": "boolean"},
156
+ "continuation": {"type": "boolean"},
157
+ },
158
+ },
159
+ },
160
+ },
161
+ },
162
+ }
toolbound/py.typed ADDED
File without changes