nullprotocol 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,276 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import math
6
+ import os
7
+ import re
8
+ import time
9
+ from typing import Any, NoReturn
10
+ from urllib.parse import urlsplit
11
+
12
+ import httpx
13
+
14
+ from ._errors import APIError, ProtocolError, TransportError
15
+
16
+ _UNSET = object()
17
+
18
+
19
+ def json_body(value: Any) -> bytes:
20
+ try:
21
+ return json.dumps(
22
+ value, ensure_ascii=False, allow_nan=False, separators=(",", ":")
23
+ ).encode()
24
+ except (TypeError, ValueError, UnicodeError) as exc:
25
+ raise ValueError("Request data must be valid UTF-8 JSON (no NaN or Infinity).") from exc
26
+
27
+
28
+ def utf16_length(value: str) -> int:
29
+ try:
30
+ return len(value.encode("utf-16-le")) // 2
31
+ except UnicodeError as exc:
32
+ raise ValueError("Text must not contain unpaired surrogates.") from exc
33
+
34
+
35
+ def identifier(value: str, kind: str) -> str:
36
+ pattern = r"[a-z0-9][a-z0-9._-]{0,63}" if kind == "agent ID" else r"[a-z][a-z0-9._-]{0,63}"
37
+ if not isinstance(value, str) or not re.fullmatch(pattern, value):
38
+ raise ValueError(f"Invalid {kind}.")
39
+ return value
40
+
41
+
42
+ def scope_id(value: str | None) -> str | None:
43
+ if value is not None and (
44
+ not isinstance(value, str)
45
+ or not 1 <= utf16_length(value) <= 128
46
+ or re.search(r"[\x00-\x1f\x7f-\x9f]", value)
47
+ ):
48
+ raise ValueError("scope must be 1–128 UTF-16 characters without control characters.")
49
+ return value
50
+
51
+
52
+ def request_id(value: str) -> str:
53
+ if not isinstance(value, str) or not re.fullmatch(r"[\x21-\x7e]{1,128}", value):
54
+ raise ValueError("idempotency_key must be 1–128 visible ASCII characters without spaces.")
55
+ return value
56
+
57
+
58
+ class _Config:
59
+ def __init__(
60
+ self, key: str | None, endpoint: str | None, timeout: float, max_retries: int
61
+ ) -> None:
62
+ actual = key if key is not None else os.environ.get("NULLPROTOCOL_KEY")
63
+ if (
64
+ not isinstance(actual, str)
65
+ or not actual.strip()
66
+ or re.search(r"[\s\x00-\x1f\x7f]", actual)
67
+ ):
68
+ raise ValueError("Set key or NULLPROTOCOL_KEY to an active Space SDK key.")
69
+ address = (
70
+ endpoint or os.environ.get("NULLPROTOCOL_API_URL") or "https://api.nullprotocol.ai"
71
+ )
72
+ parsed = urlsplit(address)
73
+ if (
74
+ parsed.scheme not in ("http", "https")
75
+ or not parsed.netloc
76
+ or parsed.username is not None
77
+ or parsed.password is not None
78
+ or parsed.query
79
+ or parsed.fragment
80
+ or parsed.path.rstrip("/") not in ("", "/v1")
81
+ ):
82
+ raise ValueError("endpoint must be an HTTP(S) origin, optionally ending in /v1.")
83
+ if (
84
+ not isinstance(timeout, (int, float))
85
+ or isinstance(timeout, bool)
86
+ or not math.isfinite(timeout)
87
+ or timeout <= 0
88
+ ):
89
+ raise ValueError("timeout must be a positive number of seconds.")
90
+ if type(max_retries) is not int or not 0 <= max_retries <= 5:
91
+ raise ValueError("max_retries must be an integer from 0 to 5.")
92
+ self.base = address.rstrip("/").removesuffix("/v1") + "/v1"
93
+ self.key = actual
94
+ self.timeout = timeout
95
+ self.max_retries = max_retries
96
+
97
+
98
+ def _invalid_constant(_value: str) -> NoReturn:
99
+ raise ValueError("Non-finite numbers are not valid JSON.")
100
+
101
+
102
+ def _decode(response: httpx.Response, task_id: str | None) -> dict[str, Any]:
103
+ try:
104
+ body = response.json(parse_constant=_invalid_constant)
105
+ except ValueError as exc:
106
+ if not response.is_success:
107
+ raise APIError(
108
+ response.status_code,
109
+ "non_json_response",
110
+ request_id=task_id,
111
+ retry_after=response.headers.get("retry-after"),
112
+ ) from exc
113
+ raise ProtocolError("Expected a JSON response.", request_id=task_id) from exc
114
+ if not response.is_success:
115
+ detail = body if isinstance(body, dict) else {}
116
+ error = detail.get("error", "http_error")
117
+ raise APIError(
118
+ response.status_code,
119
+ error if isinstance(error, str) else "http_error",
120
+ request_id=task_id,
121
+ details=detail,
122
+ retry_after=response.headers.get("retry-after"),
123
+ )
124
+ if not isinstance(body, dict):
125
+ raise ProtocolError("Expected a JSON object response.", request_id=task_id)
126
+ return body
127
+
128
+
129
+ def _delay(response: httpx.Response | None, attempt: int) -> float | None:
130
+ if response is not None:
131
+ retry_after = response.headers.get("retry-after")
132
+ if retry_after is not None:
133
+ try:
134
+ value = float(retry_after)
135
+ except ValueError:
136
+ return None # Do not guess when a server supplies an unsupported date format.
137
+ if not math.isfinite(value) or value < 0 or value > 30:
138
+ return None # Return the response instead of ignoring a long delay.
139
+ return value
140
+ return float(min(0.25 * (2**attempt), 2.0))
141
+
142
+
143
+ def _retryable(response: httpx.Response) -> bool:
144
+ if response.status_code == 429:
145
+ return True
146
+ if response.status_code == 409:
147
+ try:
148
+ return bool(response.json().get("error") == "in_progress")
149
+ except (ValueError, AttributeError):
150
+ return False
151
+ if response.status_code == 503:
152
+ try:
153
+ return bool(response.json().get("error") == "busy")
154
+ except (ValueError, AttributeError):
155
+ return False
156
+ return False
157
+
158
+
159
+ class Transport:
160
+ def __init__(self, config: _Config, client: httpx.Client | None = None) -> None:
161
+ self.config = config
162
+ self.client = client if client is not None else httpx.Client(follow_redirects=False)
163
+ self.owned = client is None
164
+ self.closed = False
165
+
166
+ def close(self) -> None:
167
+ if not self.closed:
168
+ self.closed = True
169
+ if self.owned:
170
+ self.client.close()
171
+
172
+ def request(
173
+ self,
174
+ method: str,
175
+ path: str,
176
+ *,
177
+ body: Any = _UNSET,
178
+ params: dict[str, str] | None = None,
179
+ task_id: str | None = None,
180
+ ) -> dict[str, Any]:
181
+ if self.closed:
182
+ raise TransportError("This NullProtocol client is closed.", request_id=task_id)
183
+ content = None if body is _UNSET else json_body(body)
184
+ headers = {"Authorization": f"Bearer {self.config.key}", "Accept": "application/json"}
185
+ if content is not None:
186
+ headers["Content-Type"] = "application/json"
187
+ if task_id is not None:
188
+ headers["Idempotency-Key"] = task_id
189
+ retries = self.config.max_retries if method == "GET" or task_id is not None else 0
190
+ for attempt in range(retries + 1):
191
+ response = None
192
+ try:
193
+ response = self.client.request(
194
+ method,
195
+ self.config.base + path,
196
+ headers=headers,
197
+ content=content,
198
+ params=params,
199
+ timeout=self.config.timeout,
200
+ follow_redirects=False,
201
+ )
202
+ except httpx.TransportError as exc:
203
+ if attempt == retries:
204
+ raise TransportError(
205
+ "NullProtocol could not be reached. "
206
+ "Preserve request_id before retrying a run.",
207
+ request_id=task_id,
208
+ ) from exc
209
+ if response is not None and (attempt == retries or not _retryable(response)):
210
+ return _decode(response, task_id)
211
+ delay = _delay(response, attempt)
212
+ if delay is None:
213
+ assert response is not None
214
+ return _decode(response, task_id)
215
+ time.sleep(delay)
216
+ raise AssertionError("Unreachable transport state")
217
+
218
+
219
+ class AsyncTransport:
220
+ def __init__(self, config: _Config, client: httpx.AsyncClient | None = None) -> None:
221
+ self.config = config
222
+ self.client = client if client is not None else httpx.AsyncClient(follow_redirects=False)
223
+ self.owned = client is None
224
+ self.closed = False
225
+
226
+ async def close(self) -> None:
227
+ if not self.closed:
228
+ self.closed = True
229
+ if self.owned:
230
+ await self.client.aclose()
231
+
232
+ async def request(
233
+ self,
234
+ method: str,
235
+ path: str,
236
+ *,
237
+ body: Any = _UNSET,
238
+ params: dict[str, str] | None = None,
239
+ task_id: str | None = None,
240
+ ) -> dict[str, Any]:
241
+ if self.closed:
242
+ raise TransportError("This NullProtocol client is closed.", request_id=task_id)
243
+ content = None if body is _UNSET else json_body(body)
244
+ headers = {"Authorization": f"Bearer {self.config.key}", "Accept": "application/json"}
245
+ if content is not None:
246
+ headers["Content-Type"] = "application/json"
247
+ if task_id is not None:
248
+ headers["Idempotency-Key"] = task_id
249
+ retries = self.config.max_retries if method == "GET" or task_id is not None else 0
250
+ for attempt in range(retries + 1):
251
+ response = None
252
+ try:
253
+ response = await self.client.request(
254
+ method,
255
+ self.config.base + path,
256
+ headers=headers,
257
+ content=content,
258
+ params=params,
259
+ timeout=self.config.timeout,
260
+ follow_redirects=False,
261
+ )
262
+ except httpx.TransportError as exc:
263
+ if attempt == retries:
264
+ raise TransportError(
265
+ "NullProtocol could not be reached. "
266
+ "Preserve request_id before retrying a run.",
267
+ request_id=task_id,
268
+ ) from exc
269
+ if response is not None and (attempt == retries or not _retryable(response)):
270
+ return _decode(response, task_id)
271
+ delay = _delay(response, attempt)
272
+ if delay is None:
273
+ assert response is not None
274
+ return _decode(response, task_id)
275
+ await asyncio.sleep(delay)
276
+ raise AssertionError("Unreachable transport state")
nullprotocol/_types.py ADDED
@@ -0,0 +1,175 @@
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from dataclasses import dataclass, field
5
+ from typing import Any, Generic, TypeVar
6
+ from uuid import UUID
7
+
8
+ from ._errors import ProtocolError
9
+
10
+ T = TypeVar("T")
11
+
12
+
13
+ def _count(value: Any, name: str) -> int:
14
+ if type(value) is not int or value < 0:
15
+ raise ProtocolError(f"Invalid {name} in the server response.")
16
+ return value
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Usage:
21
+ input_tokens: int
22
+ output_tokens: int
23
+ uncounted_requests: int | None = None
24
+
25
+ @classmethod
26
+ def from_dict(cls, value: Any) -> Usage | None:
27
+ if value is None:
28
+ return None
29
+ if not isinstance(value, dict):
30
+ raise ProtocolError("Invalid generation usage in the server response.")
31
+ uncounted = value.get("uncountedRequests")
32
+ if uncounted is not None:
33
+ uncounted = _count(uncounted, "uncountedRequests")
34
+ if uncounted == 0:
35
+ raise ProtocolError("uncountedRequests must be positive when reported.")
36
+ return cls(
37
+ _count(value.get("inputTokens"), "inputTokens"),
38
+ _count(value.get("outputTokens"), "outputTokens"),
39
+ uncounted,
40
+ )
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class KnowledgeUsage:
45
+ input_tokens: int | None = None
46
+ uncounted_requests: int | None = None
47
+
48
+ @classmethod
49
+ def from_dict(cls, value: Any) -> KnowledgeUsage | None:
50
+ if value is None:
51
+ return None
52
+ if not isinstance(value, dict):
53
+ raise ProtocolError("Invalid Knowledge usage in the server response.")
54
+ counted, uncounted = value.get("inputTokens"), value.get("uncountedRequests")
55
+ if counted is not None:
56
+ counted = _count(counted, "Knowledge inputTokens")
57
+ if uncounted is not None:
58
+ uncounted = _count(uncounted, "Knowledge uncountedRequests")
59
+ if uncounted == 0:
60
+ raise ProtocolError("Knowledge uncountedRequests must be positive.")
61
+ return cls(counted, uncounted)
62
+
63
+
64
+ @dataclass(frozen=True, eq=False)
65
+ class Result(Generic[T]):
66
+ """A model-operation result. raw preserves all server fields without renaming JSON data."""
67
+
68
+ success: bool
69
+ operation_id: str | None
70
+ request_id: str
71
+ data: T | None = None
72
+ summary: str | None = None
73
+ key_points: list[str] | None = None
74
+ recommendation: str | None = None
75
+ score: float | None = None
76
+ reasoning: str | None = None
77
+ action: str | None = None
78
+ parameters: dict[str, Any] | None = None
79
+ error_code: str | None = None
80
+ error: str | None = None
81
+ attempts: int | None = None
82
+ repaired: bool | None = None
83
+ usage: Usage | None = None
84
+ knowledge_usage: KnowledgeUsage | None = None
85
+ knowledge: dict[str, Any] | None = None
86
+ history_error: str | None = None
87
+ _raw: dict[str, Any] = field(default_factory=dict, repr=False)
88
+
89
+ @property
90
+ def raw(self) -> dict[str, Any]:
91
+ return deepcopy(self._raw)
92
+
93
+ @classmethod
94
+ def from_response(cls, response: dict[str, Any], request_id: str) -> Result[Any]:
95
+ value = response.get("result")
96
+ if not isinstance(value, dict) or type(value.get("success")) is not bool:
97
+ raise ProtocolError(
98
+ "Missing operation result in the server response.", request_id=request_id
99
+ )
100
+ if "operationId" not in response:
101
+ raise ProtocolError(
102
+ "Missing operationId in the server response.", request_id=request_id
103
+ )
104
+ operation_id = response.get("operationId")
105
+ if operation_id is not None and not isinstance(operation_id, str):
106
+ raise ProtocolError(
107
+ "Invalid operationId in the server response.", request_id=request_id
108
+ )
109
+ if operation_id is not None:
110
+ try:
111
+ UUID(operation_id)
112
+ except ValueError as exc:
113
+ raise ProtocolError("Invalid operationId UUID.", request_id=request_id) from exc
114
+ for name in ("attempts",):
115
+ if name in value:
116
+ try:
117
+ _count(value[name], name)
118
+ except ProtocolError as exc:
119
+ exc.request_id = request_id
120
+ raise
121
+ if "repaired" in value and type(value["repaired"]) is not bool:
122
+ raise ProtocolError("Invalid repaired flag.", request_id=request_id)
123
+ if "historyError" in value and not isinstance(value["historyError"], str):
124
+ raise ProtocolError("Invalid historyError.", request_id=request_id)
125
+ if "knowledge" in value and not isinstance(value["knowledge"], dict):
126
+ raise ProtocolError("Invalid Knowledge evidence.", request_id=request_id)
127
+ if not value["success"] and not all(
128
+ isinstance(value.get(key), str) for key in ("errorCode", "error")
129
+ ):
130
+ raise ProtocolError("Missing operation error details.", request_id=request_id)
131
+ try:
132
+ usage = Usage.from_dict(value.get("usage"))
133
+ knowledge_usage = KnowledgeUsage.from_dict(value.get("knowledgeUsage"))
134
+ except ProtocolError as exc:
135
+ exc.request_id = request_id
136
+ raise
137
+ return cls(
138
+ success=value["success"],
139
+ operation_id=operation_id,
140
+ request_id=request_id,
141
+ data=deepcopy(value.get("data")),
142
+ summary=value.get("summary"),
143
+ key_points=deepcopy(value.get("keyPoints")),
144
+ recommendation=value.get("recommendation"),
145
+ score=value.get("score"),
146
+ reasoning=value.get("reasoning"),
147
+ action=value.get("action"),
148
+ parameters=deepcopy(value.get("parameters")),
149
+ error_code=value.get("errorCode"),
150
+ error=value.get("error"),
151
+ attempts=value.get("attempts"),
152
+ repaired=value.get("repaired"),
153
+ usage=usage,
154
+ knowledge_usage=knowledge_usage,
155
+ knowledge=deepcopy(value.get("knowledge")),
156
+ history_error=value.get("historyError"),
157
+ _raw=deepcopy(value),
158
+ )
159
+
160
+
161
+ @dataclass(frozen=True)
162
+ class Execution:
163
+ success: bool
164
+ outcome: str
165
+ action: str | None = None
166
+ result: Any = None
167
+ error_code: str | None = None
168
+ error: str | None = None
169
+ history_error: str | None = None
170
+
171
+
172
+ @dataclass(frozen=True)
173
+ class ActionContext:
174
+ decision: Result[Any]
175
+ input: Any
nullprotocol/py.typed ADDED
File without changes